Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<parent>/<project>`, `~/code/<project>`). 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_<agent>`, siloing entries that hooks wrote to project-derived wings. (#1145)

### Improvements

Expand Down
23 changes: 19 additions & 4 deletions mempalace/hooks_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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-<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/projects/-home-<user>-dev-<parent>-<project>/session.jsonl
~/.claude/projects/-Users-<user>-<folder>-<project>/session.jsonl
The project directory name is the final dash-separated token of the
encoded folder. Returns ``wing_<project>`` (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-<name>`` segment, useful for
# transcripts not under the standard Claude Code projects dir.
match = re.search(r"-Projects-([^/]+?)(?:/|$)", normalized)
Comment on lines +506 to 516
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.

_wing_from_transcript_path’s new “last dash-separated token” heuristic breaks existing Claude Code paths that include -Projects- when the project directory name itself contains dashes (e.g. .../-home-jp-Projects-kiyo-xhci-fix/... will return wing_fix). Since the legacy -Projects-<name> extraction preserves hyphenated project names, prefer it when present (or at least run that match before the generic .claude/projects/-... match), and add a regression test for a hyphenated -Projects- project name.

Copilot uses AI. Check for mistakes.
if match:
project = match.group(1).lower().replace(" ", "_")
Expand Down
26 changes: 13 additions & 13 deletions mempalace/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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.

Docstring says diary_read returns the last N entries “in chronological order”, but the implementation sorts with reverse=True and returns newest-first. Either update the docstring to say reverse-chronological (most recent first) or change the sort/slicing to return chronological order as documented.

Suggested change
in chronological orderthe agent's personal journal.
in reverse-chronological order (most recent first) the agent's
personal journal.

Copilot uses AI. Check for mistakes.

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.
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_<project>``), so requiring a specific wing on read would
silo those entries from agent-initiated reads.
"""
try:
agent_name = sanitize_name(agent_name, "agent_name")
Expand All @@ -1007,21 +1008,20 @@ def tool_diary_read(agent_name: str, last_n: int = 10, wing: str = ""):
except ValueError as e:
Comment on lines 1004 to 1008
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.

tool_diary_read’s wing normalization only treats the empty string as “no filter”; a whitespace-only wing (e.g. " ") is truthy and will currently raise from sanitize_name, which is inconsistent with the repo’s established optional-filter helper _sanitize_optional_name (it treats None or any not value.strip() as no filter). Consider using _sanitize_optional_name(wing, "wing") here to match the #1097 convention more closely.

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


Expand Down
34 changes: 34 additions & 0 deletions tests/test_mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -740,6 +740,40 @@ def now(cls):
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) ──────────────────────────────────

Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.