diff --git a/mempalace/hooks_cli.py b/mempalace/hooks_cli.py index 4672e85fcc..23f30ea9aa 100644 --- a/mempalace/hooks_cli.py +++ b/mempalace/hooks_cli.py @@ -490,18 +490,44 @@ 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/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 Code encodes the full source directory path into the project + folder name, replacing ``/`` with ``-``. Transcripts live at: + ~/.claude/projects//session.jsonl + + We try two strategies in order: + + 1. Match ``-Projects-`` if the source lives under + ``~/Projects/`` (the most common layout on our fork maintainer's + machine — preserving exact existing behavior). + 2. Fall back to the last ``-``-delimited segment of the encoded + folder name — works for any layout (``~/dev/``, ``~/src/``, + ``~/code/``, etc.) because that segment is the actual project + directory name. Resolves #1145 bug 1. + + Returns ``wing_`` to match the AAAK_SPEC convention; falls + back to ``wing_sessions`` when the path doesn't match either pattern. """ # Normalize path separators for cross-platform (Windows backslashes) normalized = transcript_path.replace("\\", "/") + + # Strategy 1: explicit `-Projects-` slice (preserves behavior + # for code living under ~/Projects/, which is what the existing tests + # assert against). match = re.search(r"-Projects-([^/]+?)(?:/|$)", normalized) if match: project = match.group(1).lower().replace(" ", "_") return f"wing_{project}" + + # Strategy 2: last `-`-delimited segment of the encoded folder name. + # Claude Code's encoding puts the original directory name there + # regardless of source layout, so this recovers the project name for + # users whose code lives outside ~/Projects/. + folder = normalized.rsplit("/", 1)[0].rsplit("/", 1)[-1] + if folder.startswith("-") and "-" in folder[1:]: + project = folder.rsplit("-", 1)[-1].lower().replace(" ", "_") + if project: + return f"wing_{project}" + return "wing_sessions" diff --git a/mempalace/mcp_server.py b/mempalace/mcp_server.py index f4dc97cf1f..a947aef579 100644 --- a/mempalace/mcp_server.py +++ b/mempalace/mcp_server.py @@ -995,33 +995,35 @@ 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. + ``wing`` behavior matches ``mempalace_search`` (#1097): + empty-string / whitespace-only / ``None`` means "no wing filter" — + return entries from any wing this agent has written to. An explicit + non-empty wing scopes reads to that wing only, e.g. for hooks that + direct diary reads to a project-specific wing derived from the + transcript path. Resolves #1145 bug 2. """ try: agent_name = sanitize_name(agent_name, "agent_name") - if wing: - wing = sanitize_name(wing) + wing = _sanitize_optional_name(wing, "wing") 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() try: + # Build filter conditions: agent + diary room always apply; wing is + # optional (matches #1097's empty-string semantics for tool_search). + conditions = [ + {"room": "diary"}, + {"agent": agent_name}, + ] + if wing: + conditions.append({"wing": wing}) + where = conditions[0] if len(conditions) == 1 else {"$and": conditions} results = col.get( - where={ - "$and": [ - {"wing": wing}, - {"room": "diary"}, - {"agent": agent_name}, - ] - }, + where=where, include=["documents", "metadatas"], limit=10000, ) diff --git a/tests/test_hooks_cli.py b/tests/test_hooks_cli.py index a5af129de1..7b7a29deb3 100644 --- a/tests/test_hooks_cli.py +++ b/tests/test_hooks_cli.py @@ -324,6 +324,39 @@ def test_wing_from_transcript_path_lowercases(): assert _wing_from_transcript_path(path) == "wing_myproject" +def test_wing_from_transcript_path_non_projects_layout(): + """Claude Code encodes any source dir into the project folder name — + not just `~/Projects/`. Users with code under `~/dev/`, `~/src/`, + `~/code/`, etc., should still get a per-project wing, not the generic + ``wing_sessions`` fallback. See #1145 bug 1. + """ + # ~/dev/MyProject/myproject → folder is `-home-user-dev-MyProject-myproject` + path = "/home/user/.claude/projects/-home-user-dev-MyProject-myproject/session.jsonl" + assert _wing_from_transcript_path(path) == "wing_myproject" + + +def test_wing_from_transcript_path_src_layout(): + """Another common non-Projects layout: code in ~/src/.""" + path = "/home/dev/.claude/projects/-home-dev-src-backend-api/session.jsonl" + assert _wing_from_transcript_path(path) == "wing_api" + + +def test_wing_from_transcript_path_code_layout(): + """Another common non-Projects layout: code in ~/code/.""" + path = "/home/alex/.claude/projects/-home-alex-code-webapp/session.jsonl" + assert _wing_from_transcript_path(path) == "wing_webapp" + + +def test_wing_from_transcript_path_bare_folder_no_delimiters_falls_back(): + """When the parent folder doesn't look like Claude Code's encoded form + (no `-` delimiter inside), we can't reliably derive a project — use the + generic fallback. + """ + # A bare folder name with no delimiters inside — can't recover a project. + path = "/tmp/weird/bare/session.jsonl" + assert _wing_from_transcript_path(path) == "wing_sessions" + + # --- _log --- diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 899e6a7c79..a84dd99906 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -740,6 +740,75 @@ def now(cls): assert entry1 in contents assert entry2 in contents + def test_diary_read_empty_wing_returns_across_wings( + self, monkeypatch, config, palace_path, kg + ): + """#1145 bug 2: wing="" should mean "no wing filter" (match + mempalace_search post-#1097), not the agent's default wing. LLM + agents frequently default optional string parameters to "", so the + current default-to-agent-wing behavior silently hides entries that + were written to named wings (e.g. via the per-project wing + derivation added in #659). + """ + _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 + + # Write to a named project wing, not the agent's default wing. + w = tool_diary_write( + agent_name="ProbeAgent", + entry="entry filed into a named wing, should be readable with wing=''", + topic="sync", + wing="wing_someproject", + ) + assert w["success"] is True + + # Read with wing="" — should return the entry (no wing filter). + r = tool_diary_read(agent_name="ProbeAgent", wing="") + assert r["total"] == 1, r + assert "named wing" in r["entries"][0]["content"] + + def test_diary_read_explicit_wing_still_filters( + self, monkeypatch, config, palace_path, kg + ): + """Explicit wing argument should still filter to that wing only — + empty-string fix must not break the normal scoping use case. + """ + _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 + + tool_diary_write( + agent_name="ScopeAgent", + entry="in wing_a", + topic="x", + wing="wing_a", + ) + tool_diary_write( + agent_name="ScopeAgent", + entry="in wing_b", + topic="x", + wing="wing_b", + ) + + r_a = tool_diary_read(agent_name="ScopeAgent", wing="wing_a") + contents_a = [e["content"] for e in r_a["entries"]] + assert r_a["total"] == 1 + assert "in wing_a" in contents_a + assert "in wing_b" not in contents_a + + r_b = tool_diary_read(agent_name="ScopeAgent", wing="wing_b") + contents_b = [e["content"] for e in r_b["entries"]] + assert r_b["total"] == 1 + assert "in wing_b" in contents_b + assert "in wing_a" not in contents_b + + # And wing="" should see both + r_all = tool_diary_read(agent_name="ScopeAgent", wing="") + assert r_all["total"] == 2 + # ── Cache Invalidation (inode/mtime) ──────────────────────────────────