diff --git a/CHANGELOG.md b/CHANGELOG.md index c5dd04a..c320ae1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Real `python3` resolution for `.sh` hooks with a `MEMPAL_PYTHON` override path. (#833) - Add optional `wing` parameter to `tool_diary_write` / `tool_diary_read` and derive per-project wing from the Claude Code transcript path when writing from the stop hook — diary entries from different projects no longer collapse into a shared default wing. (#659) - Treat empty string as "no filter" in `mempalace_search` `wing`/`room`; LLM agents that default to filling every optional parameter with `""` no longer get bounced with `must be a non-empty string`. (#1097, #1084) +- Broaden `_wing_from_transcript_path` to handle Claude Code project folders without a `-Projects-` segment (e.g. `~/dev//`, `~/code/`). The project name is now derived from the final dash-separated token of the encoded folder, so Linux users with code outside `~/Projects/` get per-project diary scoping instead of falling through to `wing_sessions`. (#1145, follow-up to #659) +- `mempalace_diary_read(wing="")` now returns diary entries from every wing this agent has written to, matching the #1097 "empty-string as no filter" pattern. Previously defaulted to `wing_`, siloing entries that hooks wrote to project-derived wings. (#1145) ### Improvements diff --git a/mempalace/hooks_cli.py b/mempalace/hooks_cli.py index 4672e85..01eca3f 100644 --- a/mempalace/hooks_cli.py +++ b/mempalace/hooks_cli.py @@ -490,14 +490,29 @@ def _parse_harness_input(data: dict, harness: str) -> dict: def _wing_from_transcript_path(transcript_path: str) -> str: """Derive a project wing name from a Claude Code transcript path. - Claude Code stores transcripts at: + Claude Code encodes the project's source directory by replacing path + separators with dashes, producing folders like: ~/.claude/projects/-home--Projects-/session.jsonl - We extract and return ``wing_`` to match the - AAAK_SPEC convention (``wing_user``, ``wing_agent``, ``wing_code``, - ``wing_``…). Falls back to ``wing_sessions``. + ~/.claude/projects/-home--dev--/session.jsonl + ~/.claude/projects/-Users---/session.jsonl + + The project directory name is the final dash-separated token of the + encoded folder. Returns ``wing_`` (lowercased, spaces → ``_``). + Falls back to ``wing_sessions`` if the path does not match a Claude Code + project-folder layout. """ # Normalize path separators for cross-platform (Windows backslashes) normalized = transcript_path.replace("\\", "/") + # Primary: pull the encoded project folder out of ``.claude/projects/`` + # and take its last dash-separated token. + match = re.search(r"/\.claude/projects/-([^/]+)", normalized) + if match: + encoded = match.group(1) + project = encoded.rsplit("-", 1)[-1] + if project: + return f"wing_{project.lower().replace(' ', '_')}" + # Legacy fallback: explicit ``-Projects-`` segment, useful for + # transcripts not under the standard Claude Code projects dir. match = re.search(r"-Projects-([^/]+?)(?:/|$)", normalized) if match: project = match.group(1).lower().replace(" ", "_") diff --git a/mempalace/mcp_server.py b/mempalace/mcp_server.py index f4dc97c..2650e30 100644 --- a/mempalace/mcp_server.py +++ b/mempalace/mcp_server.py @@ -995,10 +995,11 @@ def tool_diary_read(agent_name: str, last_n: int = 10, wing: str = ""): Read an agent's recent diary entries. Returns the last N entries in chronological order — the agent's personal journal. - When ``wing`` is provided, reads from that wing instead of the - agent's default ``wing_`` wing. This lets hooks - direct diary reads to a project-specific wing derived from - the transcript path. + When ``wing`` is provided, reads only from that wing. When ``wing`` + is empty or omitted, returns entries from every wing this agent has + written to. Diary writes from hooks land in project-derived wings + (``wing_``), so requiring a specific wing on read would + silo those entries from agent-initiated reads. """ try: agent_name = sanitize_name(agent_name, "agent_name") @@ -1007,21 +1008,20 @@ def tool_diary_read(agent_name: str, last_n: int = 10, wing: str = ""): except ValueError as e: return {"error": str(e)} last_n = max(1, min(last_n, 100)) - if not wing: - wing = f"wing_{agent_name.lower().replace(' ', '_')}" col = _get_collection() if not col: return _no_palace() + # Build filter: always scope by agent + room=diary. Wing is optional — + # when empty, return entries across all wings for this agent (matches + # the #1097 empty-string-as-no-filter convention for LLM ergonomics). + conditions = [{"room": "diary"}, {"agent": agent_name}] + if wing: + conditions.insert(0, {"wing": wing}) + try: results = col.get( - where={ - "$and": [ - {"wing": wing}, - {"room": "diary"}, - {"agent": agent_name}, - ] - }, + where={"$and": conditions}, include=["documents", "metadatas"], limit=10000, ) diff --git a/tests/test_hooks_cli.py b/tests/test_hooks_cli.py index a5af129..c9a0022 100644 --- a/tests/test_hooks_cli.py +++ b/tests/test_hooks_cli.py @@ -324,6 +324,24 @@ def test_wing_from_transcript_path_lowercases(): assert _wing_from_transcript_path(path) == "wing_myproject" +def test_wing_from_transcript_path_non_projects_layout(): + # Linux users with code under ~/dev/, ~/src/, ~/code/ — no -Projects- segment. + # Project name is the final dash-separated token of the encoded folder. + path = "/home/igor/.claude/projects/-home-igor-dev-MemPalace-mempalace/session.jsonl" + assert _wing_from_transcript_path(path) == "wing_mempalace" + + +def test_wing_from_transcript_path_macos_users_layout(): + # macOS ~/ layout without a Projects/ segment. + path = "/Users/alice/.claude/projects/-Users-alice-code-MyApp/session.jsonl" + assert _wing_from_transcript_path(path) == "wing_myapp" + + +def test_wing_from_transcript_path_nested_deep(): + path = "/home/bob/.claude/projects/-home-bob-work-clients-acme-frontend/session.jsonl" + assert _wing_from_transcript_path(path) == "wing_frontend" + + # --- _log --- diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 899e6a7..480b6bd 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -740,6 +740,40 @@ class TestDiaryTools: assert entry1 in contents assert entry2 in contents + def test_diary_read_empty_wing_spans_all_wings(self, monkeypatch, config, palace_path, kg): + """diary_read(wing='') must return entries from every wing this agent + wrote to. Hooks write to project-derived wings (#659); a reader that + silos by default wing would never see those entries.""" + _patch_mcp_server(monkeypatch, config, kg) + _client, _col = _get_collection(palace_path, create=True) + del _client + from mempalace.mcp_server import tool_diary_read, tool_diary_write + + w1 = tool_diary_write( + agent_name="TestAgent", + entry="default-wing entry", + topic="general", + ) + w2 = tool_diary_write( + agent_name="TestAgent", + entry="project-wing entry", + topic="general", + wing="wing_someproject", + ) + assert w1["success"] and w2["success"] + + # Empty wing → return both entries + r = tool_diary_read(agent_name="TestAgent", wing="") + assert r["total"] == 2 + contents = {e["content"] for e in r["entries"]} + assert "default-wing entry" in contents + assert "project-wing entry" in contents + + # Explicit wing → return only that wing's entries + r_scoped = tool_diary_read(agent_name="TestAgent", wing="wing_someproject") + assert r_scoped["total"] == 1 + assert r_scoped["entries"][0]["content"] == "project-wing entry" + # ── Cache Invalidation (inode/mtime) ────────────────────────────────── diff --git a/uv.lock b/uv.lock index 49c28ff..5af54f1 100644 --- a/uv.lock +++ b/uv.lock @@ -1169,7 +1169,7 @@ wheels = [ [[package]] name = "mempalace" -version = "3.3.2" +version = "3.3.3" source = { editable = "." } dependencies = [ { name = "chromadb" },