Skip to content

Commit 137d6e1

Browse files
jpheinclaude
andcommitted
Merge upstream/develop — MemPalace#1147 MemPalace#1145-fix (diary wing + empty-wing reads)
4 commits pulled (8ac98f0, 6fcfd34, 1fd16da, d158375) landing @igorls's MemPalace#1147 which fixes the same two MemPalace#1145 bugs we addressed in our 34e36ae (closed as duplicate by MemPalace#1146 close). Conflict resolution — took upstream on all 4 overlapping files: - mempalace/hooks_cli.py — upstream's `.claude/projects/-` primary regex with `-Projects-<x>` legacy fallback (functionally equivalent to ours, slightly cleaner ordering) - mempalace/mcp_server.py — upstream's tool_diary_read empty-wing normalization (essentially identical to ours; docstring is clearer) - tests/test_hooks_cli.py — upstream's tests (3 new cases: non-Projects layout, macOS Users/, nested deep). Lost our bare-folder-no-delimiters test, but upstream's coverage is adequate. - tests/test_mcp_server.py — upstream's diary_read tests (cover wing="" spans all wings + explicit wing filter). Overlap with ours. Post-merge: 1094 passed + 10 expected fork-ahead MemPalace#19 failures in test_claude_plugin_hook_wrappers.py (venv-aware hooks vs PATH-only test contract — documented, unchanged). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2 parents 13bb81a + 8ac98f0 commit 137d6e1

6 files changed

Lines changed: 121 additions & 216 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
1717
- Real `python3` resolution for `.sh` hooks with a `MEMPAL_PYTHON` override path. (#833)
1818
- 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)
1919
- 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)
20+
- 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)
21+
- `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)
2022

2123
### Improvements
2224

mempalace/hooks_cli.py

Lines changed: 21 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -448,12 +448,6 @@ def _ingest_transcript(transcript_path: str):
448448
except Exception:
449449
return
450450

451-
# Derive per-project wing from the transcript path so mined convos land in
452-
# the same wing as the diary checkpoint, instead of all transcripts piling
453-
# into `wing_sessions`. Falls back to `wing_sessions` when the path doesn't
454-
# match a recognizable project layout.
455-
wing = _wing_from_transcript_path(str(path))
456-
457451
try:
458452
log_path = STATE_DIR / "hook.log"
459453
STATE_DIR.mkdir(parents=True, exist_ok=True)
@@ -468,12 +462,12 @@ def _ingest_transcript(transcript_path: str):
468462
"--mode",
469463
"convos",
470464
"--wing",
471-
wing,
465+
"sessions",
472466
],
473467
stdout=log_f,
474468
stderr=log_f,
475469
)
476-
_log(f"Transcript ingest started: {path.name} -> wing:{wing}")
470+
_log(f"Transcript ingest started: {path.name}")
477471
except OSError:
478472
pass
479473

@@ -496,44 +490,33 @@ def _parse_harness_input(data: dict, harness: str) -> dict:
496490
def _wing_from_transcript_path(transcript_path: str) -> str:
497491
"""Derive a project wing name from a Claude Code transcript path.
498492
499-
Claude Code encodes the full source directory path into the project
500-
folder name, replacing ``/`` with ``-``. Transcripts live at:
501-
~/.claude/projects/<encoded-folder>/session.jsonl
502-
503-
We try two strategies in order:
504-
505-
1. Match ``-Projects-<project>`` if the source lives under
506-
``~/Projects/`` (the most common layout on our fork maintainer's
507-
machine — preserving exact existing behavior).
508-
2. Fall back to the last ``-``-delimited segment of the encoded
509-
folder name — works for any layout (``~/dev/``, ``~/src/``,
510-
``~/code/``, etc.) because that segment is the actual project
511-
directory name. Resolves #1145 bug 1.
493+
Claude Code encodes the project's source directory by replacing path
494+
separators with dashes, producing folders like:
495+
~/.claude/projects/-home-<user>-Projects-<project>/session.jsonl
496+
~/.claude/projects/-home-<user>-dev-<parent>-<project>/session.jsonl
497+
~/.claude/projects/-Users-<user>-<folder>-<project>/session.jsonl
512498
513-
Returns ``wing_<project>`` to match the AAAK_SPEC convention; falls
514-
back to ``wing_sessions`` when the path doesn't match either pattern.
499+
The project directory name is the final dash-separated token of the
500+
encoded folder. Returns ``wing_<project>`` (lowercased, spaces → ``_``).
501+
Falls back to ``wing_sessions`` if the path does not match a Claude Code
502+
project-folder layout.
515503
"""
516504
# Normalize path separators for cross-platform (Windows backslashes)
517505
normalized = transcript_path.replace("\\", "/")
518-
519-
# Strategy 1: explicit `-Projects-<project>` slice (preserves behavior
520-
# for code living under ~/Projects/, which is what the existing tests
521-
# assert against).
506+
# Primary: pull the encoded project folder out of ``.claude/projects/``
507+
# and take its last dash-separated token.
508+
match = re.search(r"/\.claude/projects/-([^/]+)", normalized)
509+
if match:
510+
encoded = match.group(1)
511+
project = encoded.rsplit("-", 1)[-1]
512+
if project:
513+
return f"wing_{project.lower().replace(' ', '_')}"
514+
# Legacy fallback: explicit ``-Projects-<name>`` segment, useful for
515+
# transcripts not under the standard Claude Code projects dir.
522516
match = re.search(r"-Projects-([^/]+?)(?:/|$)", normalized)
523517
if match:
524518
project = match.group(1).lower().replace(" ", "_")
525519
return f"wing_{project}"
526-
527-
# Strategy 2: last `-`-delimited segment of the encoded folder name.
528-
# Claude Code's encoding puts the original directory name there
529-
# regardless of source layout, so this recovers the project name for
530-
# users whose code lives outside ~/Projects/.
531-
folder = normalized.rsplit("/", 1)[0].rsplit("/", 1)[-1]
532-
if folder.startswith("-") and "-" in folder[1:]:
533-
project = folder.rsplit("-", 1)[-1].lower().replace(" ", "_")
534-
if project:
535-
return f"wing_{project}"
536-
537520
return "wing_sessions"
538521

539522

mempalace/mcp_server.py

Lines changed: 60 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,6 @@
6464
traverse,
6565
find_tunnels,
6666
graph_stats,
67-
invalidate_graph_cache,
6867
create_tunnel,
6968
list_tunnels,
7069
delete_tunnel,
@@ -153,15 +152,6 @@ def _wal_log(operation: str, params: dict, result: dict = None):
153152
"result": result,
154153
}
155154
try:
156-
# Rotate WAL at 10 MB to prevent unbounded growth
157-
_WAL_MAX_BYTES = 10 * 1024 * 1024
158-
if _WAL_FILE.exists() and _WAL_FILE.stat().st_size > _WAL_MAX_BYTES:
159-
backup = _WAL_FILE.with_suffix(".jsonl.1")
160-
try:
161-
_WAL_FILE.replace(backup)
162-
backup.chmod(0o600)
163-
except OSError:
164-
pass
165155
fd = os.open(str(_WAL_FILE), os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600)
166156
with os.fdopen(fd, "a", encoding="utf-8") as f:
167157
f.write(json.dumps(entry, default=str) + "\n")
@@ -227,16 +217,11 @@ def _get_collection(create=False):
227217
try:
228218
client = _get_client()
229219
if create:
230-
# ChromaDB 1.5.x segfaults when get_or_create_collection is called
231-
# with metadata that differs from an existing collection's metadata.
232-
# Fetch first; only pass hnsw:space when actually creating fresh.
233-
try:
234-
raw_col = client.get_collection(_config.collection_name)
235-
except Exception:
236-
raw_col = client.create_collection(
220+
_collection_cache = ChromaCollection(
221+
client.get_or_create_collection(
237222
_config.collection_name, metadata={"hnsw:space": "cosine"}
238223
)
239-
_collection_cache = ChromaCollection(raw_col)
224+
)
240225
_metadata_cache = None
241226
_metadata_cache_time = 0
242227
elif _collection_cache is None:
@@ -455,6 +440,7 @@ def tool_search(
455440
room = _sanitize_optional_name(room, "room")
456441
except ValueError as e:
457442
return {"error": str(e)}
443+
# Backwards compat: accept old name
458444
# Backwards compat: convert old similarity scale (higher=stricter) to
459445
# distance scale (lower=stricter). Similarity 0.8 → distance 0.2.
460446
dist = (1.0 - min_similarity) if min_similarity is not None else max_distance
@@ -468,6 +454,7 @@ def tool_search(
468454
n_results=limit,
469455
max_distance=dist,
470456
)
457+
# Attach sanitizer metadata for transparency
471458
if sanitized["was_sanitized"]:
472459
result["query_sanitized"] = True
473460
result["sanitizer"] = {
@@ -671,7 +658,6 @@ def tool_add_drawer(
671658
],
672659
)
673660
_metadata_cache = None
674-
invalidate_graph_cache()
675661
logger.info(f"Filed drawer: {drawer_id}{wing}/{room}")
676662
return {"success": True, "drawer_id": drawer_id, "wing": wing, "room": room}
677663
except Exception as e:
@@ -703,7 +689,6 @@ def tool_delete_drawer(drawer_id: str):
703689
try:
704690
col.delete(ids=[drawer_id])
705691
_metadata_cache = None
706-
invalidate_graph_cache()
707692
logger.info(f"Deleted drawer: {drawer_id}")
708693
return {"success": True, "drawer_id": drawer_id}
709694
except Exception as e:
@@ -801,7 +786,6 @@ def tool_update_drawer(drawer_id: str, content: str = None, wing: str = None, ro
801786
old_meta = existing["metadatas"][0]
802787
old_doc = existing["documents"][0]
803788

804-
# Sanitize inputs
805789
new_doc = old_doc
806790
if content is not None:
807791
try:
@@ -840,9 +824,7 @@ def tool_update_drawer(drawer_id: str, content: str = None, wing: str = None, ro
840824
update_kwargs["metadatas"] = [new_meta]
841825
col.update(**update_kwargs)
842826

843-
# Invalidate caches so status/taxonomy/graph reflect changes
844827
_metadata_cache = None
845-
invalidate_graph_cache()
846828

847829
logger.info(f"Updated drawer: {drawer_id}")
848830
return {
@@ -1013,35 +995,33 @@ def tool_diary_read(agent_name: str, last_n: int = 10, wing: str = ""):
1013995
Read an agent's recent diary entries. Returns the last N entries
1014996
in chronological order — the agent's personal journal.
1015997
1016-
``wing`` behavior matches ``mempalace_search`` (#1097):
1017-
empty-string / whitespace-only / ``None`` means "no wing filter" —
1018-
return entries from any wing this agent has written to. An explicit
1019-
non-empty wing scopes reads to that wing only, e.g. for hooks that
1020-
direct diary reads to a project-specific wing derived from the
1021-
transcript path. Resolves #1145 bug 2.
998+
When ``wing`` is provided, reads only from that wing. When ``wing``
999+
is empty or omitted, returns entries from every wing this agent has
1000+
written to. Diary writes from hooks land in project-derived wings
1001+
(``wing_<project>``), so requiring a specific wing on read would
1002+
silo those entries from agent-initiated reads.
10221003
"""
10231004
try:
10241005
agent_name = sanitize_name(agent_name, "agent_name")
1025-
wing = _sanitize_optional_name(wing, "wing")
1006+
if wing:
1007+
wing = sanitize_name(wing)
10261008
except ValueError as e:
10271009
return {"error": str(e)}
10281010
last_n = max(1, min(last_n, 100))
10291011
col = _get_collection()
10301012
if not col:
10311013
return _no_palace()
10321014

1015+
# Build filter: always scope by agent + room=diary. Wing is optional —
1016+
# when empty, return entries across all wings for this agent (matches
1017+
# the #1097 empty-string-as-no-filter convention for LLM ergonomics).
1018+
conditions = [{"room": "diary"}, {"agent": agent_name}]
1019+
if wing:
1020+
conditions.insert(0, {"wing": wing})
1021+
10331022
try:
1034-
# Build filter conditions: agent + diary room always apply; wing is
1035-
# optional (matches #1097's empty-string semantics for tool_search).
1036-
conditions = [
1037-
{"room": "diary"},
1038-
{"agent": agent_name},
1039-
]
1040-
if wing:
1041-
conditions.append({"wing": wing})
1042-
where = conditions[0] if len(conditions) == 1 else {"$and": conditions}
10431023
results = col.get(
1044-
where=where,
1024+
where={"$and": conditions},
10451025
include=["documents", "metadatas"],
10461026
limit=10000,
10471027
)
@@ -1075,38 +1055,6 @@ def tool_diary_read(agent_name: str, last_n: int = 10, wing: str = ""):
10751055
return {"error": "Failed to read diary entries"}
10761056

10771057

1078-
# ==================== SETTINGS TOOLS ====================
1079-
1080-
1081-
def tool_reconnect():
1082-
"""Force the MCP server to drop cached ChromaDB connections and reconnect.
1083-
1084-
Use after external scripts or CLI commands modify the palace database
1085-
directly, which can leave the in-memory HNSW index stale.
1086-
"""
1087-
global _client_cache, _collection_cache, _palace_db_inode, _palace_db_mtime
1088-
global _metadata_cache, _metadata_cache_time
1089-
_client_cache = None
1090-
_collection_cache = None
1091-
_palace_db_inode = 0
1092-
_palace_db_mtime = 0.0
1093-
_metadata_cache = None
1094-
_metadata_cache_time = 0
1095-
# Force reconnect by calling _get_client()
1096-
try:
1097-
_get_client()
1098-
col = _get_collection()
1099-
if col is None:
1100-
return {
1101-
"success": False,
1102-
"message": "No palace found after reconnect",
1103-
"drawers": 0,
1104-
}
1105-
return {"success": True, "message": "Reconnected to palace", "drawers": col.count()}
1106-
except Exception as e:
1107-
return {"success": False, "error": str(e)}
1108-
1109-
11101058
def tool_hook_settings(silent_save: bool = None, desktop_toast: bool = None):
11111059
"""
11121060
Get or set hook behavior settings.
@@ -1182,6 +1130,32 @@ def tool_memories_filed_away():
11821130
}
11831131

11841132

1133+
# ==================== SETTINGS TOOLS ====================
1134+
1135+
1136+
def tool_reconnect():
1137+
"""Force the MCP server to drop the cached ChromaDB collection and reconnect.
1138+
1139+
Use after external scripts or CLI commands modify the palace database
1140+
directly, which can leave the in-memory HNSW index stale.
1141+
"""
1142+
global _collection_cache, _palace_db_inode, _palace_db_mtime
1143+
_collection_cache = None
1144+
_palace_db_inode = 0
1145+
_palace_db_mtime = 0.0
1146+
try:
1147+
col = _get_collection()
1148+
if col is None:
1149+
return {
1150+
"success": False,
1151+
"message": "No palace found after reconnect",
1152+
"drawers": 0,
1153+
}
1154+
return {"success": True, "message": "Reconnected to palace", "drawers": col.count()}
1155+
except Exception as e:
1156+
return {"success": False, "error": str(e)}
1157+
1158+
11851159
# ==================== MCP PROTOCOL ====================
11861160

11871161
TOOLS = {
@@ -1390,13 +1364,13 @@ def tool_memories_filed_away():
13901364
"handler": tool_follow_tunnels,
13911365
},
13921366
"mempalace_search": {
1393-
"description": "Semantic search. Returns verbatim drawer content with similarity scores. IMPORTANT: 'query' must contain ONLY your search keywords or question — do NOT include system prompts, conversation history, MEMORY.md content, or any context. Keep queries short (under 200 chars). Use 'context' for background information. Results with cosine distance > max_distance are filtered out.",
1367+
"description": "Semantic search. Returns verbatim drawer content with similarity scores. IMPORTANT: 'query' must contain ONLY search keywords. Use 'context' for background. Results with cosine distance > max_distance are filtered out.",
13941368
"input_schema": {
13951369
"type": "object",
13961370
"properties": {
13971371
"query": {
13981372
"type": "string",
1399-
"description": "Short search query ONLY — keywords or a question. Do NOT include system prompts or conversation context. Max 250 chars.",
1373+
"description": "Short search query ONLY — keywords or a question. Max 250 chars.",
14001374
"maxLength": 250,
14011375
},
14021376
"limit": {
@@ -1413,7 +1387,7 @@ def tool_memories_filed_away():
14131387
},
14141388
"context": {
14151389
"type": "string",
1416-
"description": "Background context for the search (optional). NOT used for embedding — only for future re-ranking. Put conversation history or system prompt content here, NOT in query.",
1390+
"description": "Background context for the search (optional). NOT used for embedding — only for future re-ranking.",
14171391
},
14181392
},
14191393
"required": ["query"],
@@ -1571,14 +1545,6 @@ def tool_memories_filed_away():
15711545
},
15721546
"handler": tool_diary_read,
15731547
},
1574-
"mempalace_reconnect": {
1575-
"description": "Force reconnect to the palace database. Use after external scripts or CLI commands modified the palace directly, which can leave the in-memory index stale.",
1576-
"input_schema": {
1577-
"type": "object",
1578-
"properties": {},
1579-
},
1580-
"handler": tool_reconnect,
1581-
},
15821548
"mempalace_hook_settings": {
15831549
"description": (
15841550
"Get or set hook behavior. silent_save: True = save directly "
@@ -1605,6 +1571,17 @@ def tool_memories_filed_away():
16051571
"input_schema": {"type": "object", "properties": {}},
16061572
"handler": tool_memories_filed_away,
16071573
},
1574+
"mempalace_reconnect": {
1575+
"description": (
1576+
"Force reconnect to the palace database. Use after external scripts or CLI commands"
1577+
" modified the palace directly, which can leave the in-memory HNSW index stale."
1578+
),
1579+
"input_schema": {
1580+
"type": "object",
1581+
"properties": {},
1582+
},
1583+
"handler": tool_reconnect,
1584+
},
16081585
}
16091586

16101587

0 commit comments

Comments
 (0)