Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 31 additions & 5 deletions mempalace/hooks_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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-<user>-Projects-<project>/session.jsonl
We extract <project> and return ``wing_<project>`` to match the
AAAK_SPEC convention (``wing_user``, ``wing_agent``, ``wing_code``,
``wing_<project>``…). 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/<encoded-folder>/session.jsonl

We try two strategies in order:

1. Match ``-Projects-<project>`` 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_<project>`` 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-<project>` 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"


Expand Down
32 changes: 17 additions & 15 deletions mempalace/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_<agent_name>`` 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,
)
Comment on lines 1015 to 1029
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With wing optional (and potentially unset/empty), tool_diary_read can now fetch across all wings for an agent, but it still does col.get(..., limit=10000) and then sorts/slices client-side. This risks silently truncating results (there’s an existing note elsewhere about a 10K get() limit) and makes the total field inaccurate when there are >10K diary entries. Consider paginating get() (offset loop) or fetching only metadatas first to identify the latest last_n entry IDs, then retrieving documents for just those IDs; this avoids large reads and removes the 10K correctness hazard.

Copilot uses AI. Check for mistakes.
Expand Down
33 changes: 33 additions & 0 deletions tests/test_hooks_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<x>`. 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/<app>."""
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/<app>."""
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 ---


Expand Down
69 changes: 69 additions & 0 deletions tests/test_mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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) ──────────────────────────────────

Expand Down
Loading