Skip to content

Commit df3ee28

Browse files
jpheinclaude
andauthored
fix: add wing param to diary_write/diary_read, derive from transcript path (#659)
* fix: add wing param to diary_write/diary_read, derive from transcript path Without a wing override, all diary entries from the stop hook land in wing_session-hook regardless of which project the session is in, making per-project diary search impossible. - tool_diary_write(): add optional `wing` param; sanitize and use it when provided, fall back to wing_{agent_name} when omitted - tool_diary_read(): add optional `wing` param for filtering by target wing - TOOLS dict: expose `wing` in input_schema for both diary tools - hooks_cli: add _wing_from_transcript_path() helper that extracts the project name from Claude Code paths like ~/.claude/projects/-home-jp-Projects-kiyo-xhci-fix/... → kiyo-xhci-fix - hook_stop: derive project wing and append wing= hint to block reason so Claude writes diary entries to the correct per-project wing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: sanitize wing param, cross-platform paths, tighten test assertions Addresses Copilot review feedback on #659. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: wing_ prefix + agent filter on diary_read Addresses bensig's 2-issue review on this PR. 1. _wing_from_transcript_path() was returning bare project names (e.g. "myproject") while all existing wings follow the wing_* convention from AAAK_SPEC. Entries landed in wing="myproject" while diary_read defaulted to wing="wing_<agent_name>" — orphaning every diary entry written by the stop hook. Now returns "wing_<project>" and falls back to "wing_sessions". 2. tool_diary_read() did not include agent_name in the ChromaDB where filter when a custom wing was provided — any caller with a shared wing could read entries written by other agents. Add {"agent": agent_name} to the $and clause. Also flagged by Qudo and left unresolved until now. Tests updated to expect the wing_ prefix (6 tests). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 818b7f4 commit df3ee28

3 files changed

Lines changed: 107 additions & 10 deletions

File tree

mempalace/hooks_cli.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -379,10 +379,15 @@ def _extract_themes(messages: list[str], max_themes: int = 3) -> list[str]:
379379
def _save_diary_direct(
380380
transcript_path: str,
381381
session_id: str,
382+
wing: str = "",
382383
toast: bool = False,
383384
) -> dict:
384385
"""Write a diary checkpoint by calling the tool function directly (no MCP roundtrip).
385386
387+
If `wing` is set, the entry lands in that wing (typically the project wing
388+
derived from the transcript path). Otherwise falls back to `tool_diary_write`'s
389+
default of `wing_session-hook`.
390+
386391
Returns {"count": N, "themes": [...]} on success, {"count": 0} on failure.
387392
"""
388393
messages = _extract_recent_messages(transcript_path)
@@ -407,6 +412,7 @@ def _save_diary_direct(
407412
agent_name="session-hook",
408413
entry=entry,
409414
topic="checkpoint",
415+
wing=wing,
410416
)
411417
if result.get("success"):
412418
_log(f"Diary checkpoint saved: {result.get('entry_id', '?')}")
@@ -481,6 +487,24 @@ def _parse_harness_input(data: dict, harness: str) -> dict:
481487
}
482488

483489

490+
def _wing_from_transcript_path(transcript_path: str) -> str:
491+
"""Derive a project wing name from a Claude Code transcript path.
492+
493+
Claude Code stores transcripts at:
494+
~/.claude/projects/-home-<user>-Projects-<project>/session.jsonl
495+
We extract <project> and return ``wing_<project>`` to match the
496+
AAAK_SPEC convention (``wing_user``, ``wing_agent``, ``wing_code``,
497+
``wing_<project>``…). Falls back to ``wing_sessions``.
498+
"""
499+
# Normalize path separators for cross-platform (Windows backslashes)
500+
normalized = transcript_path.replace("\\", "/")
501+
match = re.search(r"-Projects-([^/]+?)(?:/|$)", normalized)
502+
if match:
503+
project = match.group(1).lower().replace(" ", "_")
504+
return f"wing_{project}"
505+
return "wing_sessions"
506+
507+
484508
def hook_stop(data: dict, harness: str):
485509
"""Stop hook: block every N messages for auto-save."""
486510
parsed = _parse_harness_input(data, harness)
@@ -543,11 +567,15 @@ def hook_stop(data: dict, harness: str):
543567
silent = True
544568
toast = False
545569

570+
project_wing = _wing_from_transcript_path(transcript_path)
571+
546572
if silent:
547573
# Save directly via Python API — systemMessage renders in terminal
548574
result = {"count": 0}
549575
if transcript_path:
550-
result = _save_diary_direct(transcript_path, session_id, toast=toast)
576+
result = _save_diary_direct(
577+
transcript_path, session_id, wing=project_wing, toast=toast
578+
)
551579
_ingest_transcript(transcript_path)
552580
_maybe_auto_ingest(transcript_path)
553581
# Only advance save marker after successful save
@@ -580,7 +608,8 @@ def hook_stop(data: dict, harness: str):
580608
if transcript_path:
581609
_ingest_transcript(transcript_path)
582610
_maybe_auto_ingest(transcript_path)
583-
_output({"decision": "block", "reason": STOP_BLOCK_REASON})
611+
reason = STOP_BLOCK_REASON + f" Write diary entry to wing={project_wing}."
612+
_output({"decision": "block", "reason": reason})
584613
else:
585614
_output({})
586615

mempalace/mcp_server.py

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -918,10 +918,10 @@ def tool_kg_stats():
918918
# ==================== AGENT DIARY ====================
919919

920920

921-
def tool_diary_write(agent_name: str, entry: str, topic: str = "general"):
921+
def tool_diary_write(agent_name: str, entry: str, topic: str = "general", wing: str = ""):
922922
"""
923-
Write a diary entry for this agent. Each agent gets its own wing
924-
with a diary room. Entries are timestamped and accumulate over time.
923+
Write a diary entry for this agent. Entries are timestamped and
924+
accumulate over time in a diary room.
925925
926926
This is the agent's personal journal — observations, thoughts,
927927
what it worked on, what it noticed, what it thinks matters.
@@ -932,7 +932,10 @@ def tool_diary_write(agent_name: str, entry: str, topic: str = "general"):
932932
except ValueError as e:
933933
return {"success": False, "error": str(e)}
934934

935-
wing = f"wing_{agent_name.lower().replace(' ', '_')}"
935+
if wing:
936+
wing = sanitize_name(wing)
937+
else:
938+
wing = f"wing_{agent_name.lower().replace(' ', '_')}"
936939
room = "diary"
937940
col = _get_collection(create=True)
938941
if not col:
@@ -987,24 +990,38 @@ def tool_diary_write(agent_name: str, entry: str, topic: str = "general"):
987990
return {"success": False, "error": str(e)}
988991

989992

990-
def tool_diary_read(agent_name: str, last_n: int = 10):
993+
def tool_diary_read(agent_name: str, last_n: int = 10, wing: str = ""):
991994
"""
992995
Read an agent's recent diary entries. Returns the last N entries
993996
in chronological order — the agent's personal journal.
997+
998+
When ``wing`` is provided, reads from that wing instead of the
999+
agent's default ``wing_<agent_name>`` wing. This lets hooks
1000+
direct diary reads to a project-specific wing derived from
1001+
the transcript path.
9941002
"""
9951003
try:
9961004
agent_name = sanitize_name(agent_name, "agent_name")
1005+
if wing:
1006+
wing = sanitize_name(wing)
9971007
except ValueError as e:
9981008
return {"error": str(e)}
9991009
last_n = max(1, min(last_n, 100))
1000-
wing = f"wing_{agent_name.lower().replace(' ', '_')}"
1010+
if not wing:
1011+
wing = f"wing_{agent_name.lower().replace(' ', '_')}"
10011012
col = _get_collection()
10021013
if not col:
10031014
return _no_palace()
10041015

10051016
try:
10061017
results = col.get(
1007-
where={"$and": [{"wing": wing}, {"room": "diary"}]},
1018+
where={
1019+
"$and": [
1020+
{"wing": wing},
1021+
{"room": "diary"},
1022+
{"agent": agent_name},
1023+
]
1024+
},
10081025
include=["documents", "metadatas"],
10091026
limit=10000,
10101027
)
@@ -1497,6 +1514,10 @@ def tool_reconnect():
14971514
"type": "string",
14981515
"description": "Topic tag (optional, default: general)",
14991516
},
1517+
"wing": {
1518+
"type": "string",
1519+
"description": "Target wing for this diary entry (optional). If omitted, uses wing_{agent_name}. Use this to write diary entries to a project wing instead of an agent-specific wing.",
1520+
},
15001521
},
15011522
"required": ["agent_name", "entry"],
15021523
},
@@ -1515,6 +1536,10 @@ def tool_reconnect():
15151536
"type": "integer",
15161537
"description": "Number of recent entries to read (default: 10)",
15171538
},
1539+
"wing": {
1540+
"type": "string",
1541+
"description": "Wing to read diary entries from (optional). If omitted, reads from wing_{agent_name}.",
1542+
},
15181543
},
15191544
"required": ["agent_name"],
15201545
},

tests/test_hooks_cli.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
_parse_harness_input,
2121
_sanitize_session_id,
2222
_validate_transcript_path,
23+
_wing_from_transcript_path,
2324
hook_stop,
2425
hook_session_start,
2526
hook_precompact,
@@ -233,7 +234,27 @@ def test_stop_hook_saves_silently_at_interval(tmp_path):
233234
# Saves silently — systemMessage notification with themes, no block
234235
assert result["systemMessage"].startswith("\u2726 15 memories woven into the palace")
235236
assert "hooks" in result["systemMessage"]
236-
mock_save.assert_called_once_with(str(transcript), "test", toast=False)
237+
# tmp_path has no "-Projects-" segment, so _wing_from_transcript_path falls back to "wing_sessions"
238+
mock_save.assert_called_once_with(str(transcript), "test", wing="wing_sessions", toast=False)
239+
240+
241+
def test_stop_hook_derives_wing_from_transcript_path(tmp_path):
242+
"""When transcript path looks like a Claude Code path, wing is derived from it."""
243+
project_dir = tmp_path / ".claude" / "projects" / "-home-jp-Projects-myproject"
244+
project_dir.mkdir(parents=True)
245+
transcript = project_dir / "session.jsonl"
246+
_write_transcript(
247+
transcript,
248+
[{"message": {"role": "user", "content": f"msg {i}"}} for i in range(SAVE_INTERVAL)],
249+
)
250+
save_result = {"count": 15, "themes": []}
251+
with patch("mempalace.hooks_cli._save_diary_direct", return_value=save_result) as mock_save:
252+
_capture_hook_output(
253+
hook_stop,
254+
{"session_id": "test", "stop_hook_active": False, "transcript_path": str(transcript)},
255+
state_dir=tmp_path,
256+
)
257+
mock_save.assert_called_once_with(str(transcript), "test", wing="wing_myproject", toast=False)
237258

238259

239260
def test_stop_hook_tracks_save_point(tmp_path):
@@ -281,6 +302,28 @@ def test_precompact_allows(tmp_path):
281302
assert result == {}
282303

283304

305+
# --- _wing_from_transcript_path ---
306+
307+
308+
def test_wing_from_transcript_path_extracts_project():
309+
path = "/home/jp/.claude/projects/-home-jp-Projects-memorypalace/session.jsonl"
310+
assert _wing_from_transcript_path(path) == "wing_memorypalace"
311+
312+
313+
def test_wing_from_transcript_path_fallback():
314+
assert _wing_from_transcript_path("/some/random/path.jsonl") == "wing_sessions"
315+
316+
317+
def test_wing_from_transcript_path_windows_backslashes():
318+
path = "C:\\Users\\jp\\.claude\\projects\\-home-jp-Projects-myapp\\session.jsonl"
319+
assert _wing_from_transcript_path(path) == "wing_myapp"
320+
321+
322+
def test_wing_from_transcript_path_lowercases():
323+
path = "/home/jp/.claude/projects/-home-jp-Projects-MyProject/session.jsonl"
324+
assert _wing_from_transcript_path(path) == "wing_myproject"
325+
326+
284327
# --- _log ---
285328

286329

0 commit comments

Comments
 (0)