diff --git a/mempalace/hooks_cli.py b/mempalace/hooks_cli.py index 19a71e2..4672e85 100644 --- a/mempalace/hooks_cli.py +++ b/mempalace/hooks_cli.py @@ -379,10 +379,15 @@ def _extract_themes(messages: list[str], max_themes: int = 3) -> list[str]: def _save_diary_direct( transcript_path: str, session_id: str, + wing: str = "", toast: bool = False, ) -> dict: """Write a diary checkpoint by calling the tool function directly (no MCP roundtrip). + If `wing` is set, the entry lands in that wing (typically the project wing + derived from the transcript path). Otherwise falls back to `tool_diary_write`'s + default of `wing_session-hook`. + Returns {"count": N, "themes": [...]} on success, {"count": 0} on failure. """ messages = _extract_recent_messages(transcript_path) @@ -407,6 +412,7 @@ def _save_diary_direct( agent_name="session-hook", entry=entry, topic="checkpoint", + wing=wing, ) if result.get("success"): _log(f"Diary checkpoint saved: {result.get('entry_id', '?')}") @@ -481,6 +487,24 @@ 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``. + """ + # Normalize path separators for cross-platform (Windows backslashes) + normalized = transcript_path.replace("\\", "/") + match = re.search(r"-Projects-([^/]+?)(?:/|$)", normalized) + if match: + project = match.group(1).lower().replace(" ", "_") + return f"wing_{project}" + return "wing_sessions" + + def hook_stop(data: dict, harness: str): """Stop hook: block every N messages for auto-save.""" parsed = _parse_harness_input(data, harness) @@ -543,11 +567,15 @@ def hook_stop(data: dict, harness: str): silent = True toast = False + project_wing = _wing_from_transcript_path(transcript_path) + if silent: # Save directly via Python API — systemMessage renders in terminal result = {"count": 0} if transcript_path: - result = _save_diary_direct(transcript_path, session_id, toast=toast) + result = _save_diary_direct( + transcript_path, session_id, wing=project_wing, toast=toast + ) _ingest_transcript(transcript_path) _maybe_auto_ingest(transcript_path) # Only advance save marker after successful save @@ -580,7 +608,8 @@ def hook_stop(data: dict, harness: str): if transcript_path: _ingest_transcript(transcript_path) _maybe_auto_ingest(transcript_path) - _output({"decision": "block", "reason": STOP_BLOCK_REASON}) + reason = STOP_BLOCK_REASON + f" Write diary entry to wing={project_wing}." + _output({"decision": "block", "reason": reason}) else: _output({}) diff --git a/mempalace/mcp_server.py b/mempalace/mcp_server.py index 6fe8225..606ecfb 100644 --- a/mempalace/mcp_server.py +++ b/mempalace/mcp_server.py @@ -918,10 +918,10 @@ def tool_kg_stats(): # ==================== AGENT DIARY ==================== -def tool_diary_write(agent_name: str, entry: str, topic: str = "general"): +def tool_diary_write(agent_name: str, entry: str, topic: str = "general", wing: str = ""): """ - Write a diary entry for this agent. Each agent gets its own wing - with a diary room. Entries are timestamped and accumulate over time. + Write a diary entry for this agent. Entries are timestamped and + accumulate over time in a diary room. This is the agent's personal journal — observations, thoughts, 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"): except ValueError as e: return {"success": False, "error": str(e)} - wing = f"wing_{agent_name.lower().replace(' ', '_')}" + if wing: + wing = sanitize_name(wing) + else: + wing = f"wing_{agent_name.lower().replace(' ', '_')}" room = "diary" col = _get_collection(create=True) if not col: @@ -987,24 +990,38 @@ def tool_diary_write(agent_name: str, entry: str, topic: str = "general"): return {"success": False, "error": str(e)} -def tool_diary_read(agent_name: str, last_n: int = 10): +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. """ try: agent_name = sanitize_name(agent_name, "agent_name") + if wing: + wing = sanitize_name(wing) except ValueError as e: return {"error": str(e)} last_n = max(1, min(last_n, 100)) - wing = f"wing_{agent_name.lower().replace(' ', '_')}" + if not wing: + wing = f"wing_{agent_name.lower().replace(' ', '_')}" col = _get_collection() if not col: return _no_palace() try: results = col.get( - where={"$and": [{"wing": wing}, {"room": "diary"}]}, + where={ + "$and": [ + {"wing": wing}, + {"room": "diary"}, + {"agent": agent_name}, + ] + }, include=["documents", "metadatas"], limit=10000, ) @@ -1497,6 +1514,10 @@ TOOLS = { "type": "string", "description": "Topic tag (optional, default: general)", }, + "wing": { + "type": "string", + "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.", + }, }, "required": ["agent_name", "entry"], }, @@ -1515,6 +1536,10 @@ TOOLS = { "type": "integer", "description": "Number of recent entries to read (default: 10)", }, + "wing": { + "type": "string", + "description": "Wing to read diary entries from (optional). If omitted, reads from wing_{agent_name}.", + }, }, "required": ["agent_name"], }, diff --git a/tests/test_hooks_cli.py b/tests/test_hooks_cli.py index d981489..a5af129 100644 --- a/tests/test_hooks_cli.py +++ b/tests/test_hooks_cli.py @@ -20,6 +20,7 @@ from mempalace.hooks_cli import ( _parse_harness_input, _sanitize_session_id, _validate_transcript_path, + _wing_from_transcript_path, hook_stop, hook_session_start, hook_precompact, @@ -233,7 +234,27 @@ def test_stop_hook_saves_silently_at_interval(tmp_path): # Saves silently — systemMessage notification with themes, no block assert result["systemMessage"].startswith("\u2726 15 memories woven into the palace") assert "hooks" in result["systemMessage"] - mock_save.assert_called_once_with(str(transcript), "test", toast=False) + # tmp_path has no "-Projects-" segment, so _wing_from_transcript_path falls back to "wing_sessions" + mock_save.assert_called_once_with(str(transcript), "test", wing="wing_sessions", toast=False) + + +def test_stop_hook_derives_wing_from_transcript_path(tmp_path): + """When transcript path looks like a Claude Code path, wing is derived from it.""" + project_dir = tmp_path / ".claude" / "projects" / "-home-jp-Projects-myproject" + project_dir.mkdir(parents=True) + transcript = project_dir / "session.jsonl" + _write_transcript( + transcript, + [{"message": {"role": "user", "content": f"msg {i}"}} for i in range(SAVE_INTERVAL)], + ) + save_result = {"count": 15, "themes": []} + with patch("mempalace.hooks_cli._save_diary_direct", return_value=save_result) as mock_save: + _capture_hook_output( + hook_stop, + {"session_id": "test", "stop_hook_active": False, "transcript_path": str(transcript)}, + state_dir=tmp_path, + ) + mock_save.assert_called_once_with(str(transcript), "test", wing="wing_myproject", toast=False) def test_stop_hook_tracks_save_point(tmp_path): @@ -281,6 +302,28 @@ def test_precompact_allows(tmp_path): assert result == {} +# --- _wing_from_transcript_path --- + + +def test_wing_from_transcript_path_extracts_project(): + path = "/home/jp/.claude/projects/-home-jp-Projects-memorypalace/session.jsonl" + assert _wing_from_transcript_path(path) == "wing_memorypalace" + + +def test_wing_from_transcript_path_fallback(): + assert _wing_from_transcript_path("/some/random/path.jsonl") == "wing_sessions" + + +def test_wing_from_transcript_path_windows_backslashes(): + path = "C:\\Users\\jp\\.claude\\projects\\-home-jp-Projects-myapp\\session.jsonl" + assert _wing_from_transcript_path(path) == "wing_myapp" + + +def test_wing_from_transcript_path_lowercases(): + path = "/home/jp/.claude/projects/-home-jp-Projects-MyProject/session.jsonl" + assert _wing_from_transcript_path(path) == "wing_myproject" + + # --- _log ---