feat: new MCP tools — get/list/update drawer, hook settings, export (resolves #635) (#667)

* feat: MCP reliability — inode detection, WAL rotation, metadata cache, search limits

Infrastructure hardening for the MCP server:
- Detect palace DB replacement via inode tracking (repair command support)
- WAL rotation to prevent unbounded WAL growth
- _fetch_all_metadata() + _get_cached_metadata() with 60s TTL for taxonomy/status
- _MAX_RESULTS cap (100) with limit clamping [1, _MAX_RESULTS]
- max_distance parameter for similarity threshold in search
- Handle all notifications/* methods, null arguments, method=None
- Remove duplicate _client_cache = None declarations
- searcher.py max_distance parameter passthrough

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: new MCP tools (get/list/update drawer, hook settings, memories filed), export, normalize

New MCP tools:
- mempalace_get_drawer: fetch single drawer by ID with full content
- mempalace_list_drawers: paginated listing with wing/room filter
- mempalace_update_drawer: update content/wing/room on existing drawers
- mempalace_hook_settings: get/set hook behavior (silent_save, desktop_toast)
- mempalace_memories_filed_away: check latest checkpoint status

Also includes:
- exporter.py: export palace as browsable markdown files
- normalize.py: tool_use/tool_result capture for richer transcript mining
- layers.py: updated for new tool integration
- config.py: hook settings properties (hook_silent_save, hook_desktop_toast)

Depends on PR 3 (reliability) for _MAX_RESULTS, _metadata_cache, WAL logging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: normalize.py handles string messages and Read offset type mismatch

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: params null guard, L2→cosine docs, empty tool_use_map key guard

- Handle explicit null in MCP params (request.get("params") or {})
- Fix search tool description: L2 → cosine distance (collection uses hnsw:space=cosine)
- Guard against empty string key in tool_use_map from malformed JSONL entries

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: rename ambiguous var 'l' to 'line' (E741 lint)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address code review findings (5 issues)

1. min_similarity backwards-compat: convert similarity to distance scale
   (1.0 - similarity) instead of passing raw value as max_distance
2. Restore structured error reporting (error + partial fields) in
   tool_status, tool_list_wings, tool_list_rooms, tool_get_taxonomy
   — reverts silent except:pass that dropped #647 security hardening
3. inode cache: remove falsy-zero short-circuit so missing DB file
   triggers reconnect instead of reusing stale client
4. _fetch_all_metadata: check for empty batch before extending/advancing
   offset to prevent infinite loop on concurrent deletion
5. KG initialization: only override path when --palace is explicit;
   default runs use KnowledgeGraph's built-in default path

Co-authored-by: jphein <jphein@users.noreply.github.com>

---------

Co-authored-by: jp <jp@jphein.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: jphein <jphein@users.noreply.github.com>
This commit is contained in:
Ben Sigman
2026-04-11 21:25:04 -07:00
committed by GitHub
parent 58eca5075a
commit 20c8f8e57b
9 changed files with 1429 additions and 164 deletions
+136
View File
@@ -0,0 +1,136 @@
import os
import shutil
import tempfile
from pathlib import Path
import yaml
from mempalace.miner import mine
from mempalace.exporter import export_palace
def write_file(path: Path, content: str):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
def _setup_palace(tmpdir):
"""Create a small palace with drawers across two wings for testing."""
project_a = Path(tmpdir) / "project_a"
project_b = Path(tmpdir) / "project_b"
palace_path = str(Path(tmpdir) / "palace")
# Project A: wing=alpha, rooms=backend,frontend
os.makedirs(project_a / "backend")
os.makedirs(project_a / "frontend")
write_file(project_a / "backend" / "server.py", "def serve():\n return 'ok'\n" * 20)
write_file(project_a / "frontend" / "app.js", "function render() { return 'hi'; }\n" * 20)
with open(project_a / "mempalace.yaml", "w") as f:
yaml.dump(
{
"wing": "alpha",
"rooms": [
{"name": "backend", "description": "Backend code"},
{"name": "frontend", "description": "Frontend code"},
],
},
f,
)
# Project B: wing=beta, rooms=docs
os.makedirs(project_b / "docs")
write_file(project_b / "docs" / "guide.md", "# Guide\n\nThis explains things.\n" * 20)
with open(project_b / "mempalace.yaml", "w") as f:
yaml.dump(
{
"wing": "beta",
"rooms": [{"name": "docs", "description": "Documentation"}],
},
f,
)
mine(str(project_a), palace_path)
mine(str(project_b), palace_path)
return palace_path
def test_export_creates_structure():
tmpdir = tempfile.mkdtemp()
try:
palace_path = _setup_palace(tmpdir)
output_dir = os.path.join(tmpdir, "export")
stats = export_palace(palace_path, output_dir)
# Should have two wings
assert stats["wings"] == 2
assert stats["rooms"] >= 2
assert stats["drawers"] >= 3
# Directory structure
assert os.path.isfile(os.path.join(output_dir, "index.md"))
assert os.path.isdir(os.path.join(output_dir, "alpha"))
assert os.path.isdir(os.path.join(output_dir, "beta"))
# Room files exist
assert os.path.isfile(os.path.join(output_dir, "alpha", "backend.md"))
assert os.path.isfile(os.path.join(output_dir, "alpha", "frontend.md"))
assert os.path.isfile(os.path.join(output_dir, "beta", "docs.md"))
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
def test_export_markdown_content():
tmpdir = tempfile.mkdtemp()
try:
palace_path = _setup_palace(tmpdir)
output_dir = os.path.join(tmpdir, "export")
export_palace(palace_path, output_dir)
# Check that room files contain expected markdown elements
backend_md = Path(output_dir) / "alpha" / "backend.md"
content = backend_md.read_text(encoding="utf-8")
assert content.startswith("# alpha / backend\n")
assert "## drawer_" in content
assert "| Field | Value |" in content
assert "| Source |" in content
assert "| Filed |" in content
assert "| Added by |" in content
assert "---" in content
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
def test_export_index_content():
tmpdir = tempfile.mkdtemp()
try:
palace_path = _setup_palace(tmpdir)
output_dir = os.path.join(tmpdir, "export")
export_palace(palace_path, output_dir)
index_md = Path(output_dir) / "index.md"
content = index_md.read_text(encoding="utf-8")
assert "# Palace Export" in content
assert "| Wing | Rooms | Drawers |" in content
assert "[alpha](alpha/)" in content
assert "[beta](beta/)" in content
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
def test_export_empty_palace():
tmpdir = tempfile.mkdtemp()
try:
palace_path = os.path.join(tmpdir, "empty_palace")
output_dir = os.path.join(tmpdir, "export")
stats = export_palace(palace_path, output_dir)
assert stats == {"wings": 0, "rooms": 0, "drawers": 0}
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
+141
View File
@@ -145,6 +145,42 @@ class TestHandleRequest:
resp = handle_request({"method": "unknown/method", "id": 4, "params": {}})
assert resp["error"]["code"] == -32601
def test_any_notification_returns_none(self):
"""All notifications/* methods should return None (no response)."""
from mempalace.mcp_server import handle_request
for method in [
"notifications/initialized",
"notifications/cancelled",
"notifications/progress",
"notifications/roots/list_changed",
]:
resp = handle_request({"method": method, "params": {}})
assert resp is None, f"{method} should return None"
def test_unknown_method_no_id_returns_none(self):
"""Messages without id (notifications) must never get a response."""
from mempalace.mcp_server import handle_request
resp = handle_request({"method": "unknown/thing", "params": {}})
assert resp is None
def test_malformed_method_none(self):
"""method=None or missing should not crash."""
from mempalace.mcp_server import handle_request
# Explicit None
resp = handle_request({"method": None, "params": {}})
assert resp is None # no id → no response
# Missing method entirely
resp = handle_request({"params": {}})
assert resp is None
# method=None with id → should return error, not crash
resp = handle_request({"method": None, "id": 99, "params": {}})
assert resp["error"]["code"] == -32601
def test_tools_call_dispatches(self, monkeypatch, config, palace_path, seeded_kg):
_patch_mcp_server(monkeypatch, config, seeded_kg)
from mempalace.mcp_server import handle_request
@@ -259,6 +295,20 @@ class TestSearchTool:
result = tool_search(query="database", room="backend")
assert all(r["room"] == "backend" for r in result["results"])
def test_search_min_similarity_backwards_compat(self, monkeypatch, config, palace_path, seeded_collection, kg):
"""Old min_similarity param still works via backwards-compat shim."""
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_search
# Old name should work
result = tool_search(query="JWT", min_similarity=1.5)
assert "results" in result
# Old name takes precedence when both provided
result_strict = tool_search(query="JWT", max_distance=999.0, min_similarity=0.01)
result_loose = tool_search(query="JWT", max_distance=0.01, min_similarity=999.0)
assert len(result_strict["results"]) <= len(result_loose["results"])
# ── Write Tools ─────────────────────────────────────────────────────────
@@ -328,6 +378,97 @@ class TestWriteTools:
)
assert result["is_duplicate"] is False
def test_get_drawer(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_get_drawer
result = tool_get_drawer("drawer_proj_backend_aaa")
assert result["drawer_id"] == "drawer_proj_backend_aaa"
assert result["wing"] == "project"
assert result["room"] == "backend"
assert "JWT tokens" in result["content"]
def test_get_drawer_not_found(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_get_drawer
result = tool_get_drawer("nonexistent_drawer")
assert "error" in result
def test_list_drawers(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_list_drawers
result = tool_list_drawers()
assert result["count"] == 4
assert len(result["drawers"]) == 4
def test_list_drawers_with_wing_filter(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_list_drawers
result = tool_list_drawers(wing="project")
assert result["count"] == 3
assert all(d["wing"] == "project" for d in result["drawers"])
def test_list_drawers_with_room_filter(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_list_drawers
result = tool_list_drawers(wing="project", room="backend")
assert result["count"] == 2
assert all(d["room"] == "backend" for d in result["drawers"])
def test_list_drawers_pagination(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_list_drawers
result = tool_list_drawers(limit=2, offset=0)
assert result["count"] == 2
assert result["limit"] == 2
assert result["offset"] == 0
def test_list_drawers_negative_offset_clamped(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_list_drawers
result = tool_list_drawers(offset=-5)
assert result["offset"] == 0
def test_update_drawer_content(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_update_drawer, tool_get_drawer
result = tool_update_drawer("drawer_proj_backend_aaa", content="Updated content about auth.")
assert result["success"] is True
fetched = tool_get_drawer("drawer_proj_backend_aaa")
assert fetched["content"] == "Updated content about auth."
def test_update_drawer_wing_and_room(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_update_drawer
result = tool_update_drawer("drawer_proj_backend_aaa", wing="new_wing", room="new_room")
assert result["success"] is True
assert result["wing"] == "new_wing"
assert result["room"] == "new_room"
def test_update_drawer_not_found(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_update_drawer
result = tool_update_drawer("nonexistent_drawer", content="hello")
assert result["success"] is False
def test_update_drawer_noop(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_update_drawer
result = tool_update_drawer("drawer_proj_backend_aaa")
assert result["success"] is True
assert result.get("noop") is True
# ── KG Tools ────────────────────────────────────────────────────────────
+328 -2
View File
@@ -3,6 +3,8 @@ from unittest.mock import patch
from mempalace.normalize import (
_extract_content,
_format_tool_result,
_format_tool_use,
_messages_to_transcript,
_try_chatgpt_json,
_try_claude_ai_json,
@@ -81,7 +83,7 @@ def test_extract_content_string():
def test_extract_content_list_of_strings():
assert _extract_content(["hello", "world"]) == "hello world"
assert _extract_content(["hello", "world"]) == "hello\nworld"
def test_extract_content_list_of_blocks():
@@ -99,7 +101,198 @@ def test_extract_content_none():
def test_extract_content_mixed_list():
blocks = ["plain", {"type": "text", "text": "block"}]
assert _extract_content(blocks) == "plain block"
assert _extract_content(blocks) == "plain\nblock"
# ── _format_tool_use ──────────────────────────────────────────────────
def test_format_tool_use_bash():
block = {"type": "tool_use", "id": "t1", "name": "Bash",
"input": {"command": "lsusb | grep razer", "description": "Check USB"}}
result = _format_tool_use(block)
assert result == "[Bash] lsusb | grep razer"
def test_format_tool_use_bash_truncates_long_command():
block = {"type": "tool_use", "id": "t1", "name": "Bash",
"input": {"command": "x" * 300}}
result = _format_tool_use(block)
assert len(result) <= len("[Bash] ") + 200 + len("...")
assert result.endswith("...")
def test_format_tool_use_read():
block = {"type": "tool_use", "id": "t1", "name": "Read",
"input": {"file_path": "/home/jp/file.py"}}
result = _format_tool_use(block)
assert result == "[Read /home/jp/file.py]"
def test_format_tool_use_read_with_range():
block = {"type": "tool_use", "id": "t1", "name": "Read",
"input": {"file_path": "/home/jp/file.py", "offset": 10, "limit": 50}}
result = _format_tool_use(block)
assert result == "[Read /home/jp/file.py:10-60]"
def test_format_tool_use_grep():
block = {"type": "tool_use", "id": "t1", "name": "Grep",
"input": {"pattern": "firmware", "path": "/home/jp/proj"}}
result = _format_tool_use(block)
assert result == "[Grep] firmware in /home/jp/proj"
def test_format_tool_use_grep_with_glob():
block = {"type": "tool_use", "id": "t1", "name": "Grep",
"input": {"pattern": "TODO", "glob": "*.py"}}
result = _format_tool_use(block)
assert result == "[Grep] TODO in *.py"
def test_format_tool_use_glob():
block = {"type": "tool_use", "id": "t1", "name": "Glob",
"input": {"pattern": "/home/jp/proj/**/*.py"}}
result = _format_tool_use(block)
assert result == "[Glob] /home/jp/proj/**/*.py"
def test_format_tool_use_edit():
block = {"type": "tool_use", "id": "t1", "name": "Edit",
"input": {"file_path": "/home/jp/file.py", "old_string": "x", "new_string": "y"}}
result = _format_tool_use(block)
assert result == "[Edit /home/jp/file.py]"
def test_format_tool_use_write():
block = {"type": "tool_use", "id": "t1", "name": "Write",
"input": {"file_path": "/home/jp/file.py", "content": "..."}}
result = _format_tool_use(block)
assert result == "[Write /home/jp/file.py]"
def test_format_tool_use_unknown_tool():
block = {"type": "tool_use", "id": "t1", "name": "mcp__mempalace__search",
"input": {"query": "firmware probe", "limit": 5}}
result = _format_tool_use(block)
assert result.startswith("[mcp__mempalace__search]")
assert "firmware probe" in result
def test_format_tool_use_unknown_tool_truncates():
block = {"type": "tool_use", "id": "t1", "name": "SomeTool",
"input": {"data": "x" * 300}}
result = _format_tool_use(block)
assert result.endswith("...")
assert len(result) <= len("[SomeTool] ") + 200 + len("...")
# ── _format_tool_result ──────────────────────────────────────────────
def test_format_tool_result_bash_short():
"""Short Bash output is preserved in full."""
content = "Bus 002 Device 005: ID 1532:0e05 Razer Kiyo Pro"
result = _format_tool_result(content, "Bash")
assert result == "→ Bus 002 Device 005: ID 1532:0e05 Razer Kiyo Pro"
def test_format_tool_result_bash_head_tail():
"""Long Bash output gets head+tail with gap marker."""
lines = [f"line {i}" for i in range(60)]
content = "\n".join(lines)
result = _format_tool_result(content, "Bash")
assert "line 0" in result
assert "line 19" in result
assert "line 40" in result
assert "line 59" in result
assert "20 lines omitted" in result
# Lines 20-39 should be gone
assert "line 20\n" not in result
def test_format_tool_result_bash_exactly_40_lines():
"""Bash output at exactly 40 lines is not truncated."""
lines = [f"line {i}" for i in range(40)]
content = "\n".join(lines)
result = _format_tool_result(content, "Bash")
assert "omitted" not in result
assert "line 0" in result
assert "line 39" in result
def test_format_tool_result_read_omitted():
"""Read results are omitted (content already in palace from project mining)."""
result = _format_tool_result("lots of file content here...", "Read")
assert result == ""
def test_format_tool_result_edit_omitted():
"""Edit results are omitted (diff is in git)."""
result = _format_tool_result("file updated", "Edit")
assert result == ""
def test_format_tool_result_write_omitted():
"""Write results are omitted."""
result = _format_tool_result("file created", "Write")
assert result == ""
def test_format_tool_result_grep_short():
"""Short Grep output is kept."""
content = "src/foo.py\nsrc/bar.py\nsrc/baz.py"
result = _format_tool_result(content, "Grep")
assert "→ src/foo.py" in result
assert "→ src/baz.py" in result
def test_format_tool_result_grep_caps_at_20():
"""Grep output beyond 20 lines is truncated."""
lines = [f"match_{i}.py" for i in range(30)]
content = "\n".join(lines)
result = _format_tool_result(content, "Grep")
assert "match_19.py" in result
assert "match_20.py" not in result
assert "10 more matches" in result
def test_format_tool_result_glob_caps_at_20():
"""Glob output beyond 20 lines is truncated."""
lines = [f"/path/file_{i}.py" for i in range(25)]
content = "\n".join(lines)
result = _format_tool_result(content, "Glob")
assert "file_19.py" in result
assert "file_20.py" not in result
assert "5 more matches" in result
def test_format_tool_result_unknown_short():
"""Unknown tool with short output is kept."""
result = _format_tool_result("some output", "mcp__mempalace__search")
assert result == "→ some output"
def test_format_tool_result_unknown_truncates():
"""Unknown tool output over 2KB is truncated."""
content = "x" * 3000
result = _format_tool_result(content, "SomeTool")
assert result.endswith("... [truncated, 3000 chars]")
assert len(result) < 2200
def test_format_tool_result_list_content():
"""tool_result content can be a list of text blocks."""
content = [{"type": "text", "text": "result line 1"}, {"type": "text", "text": "result line 2"}]
result = _format_tool_result(content, "Bash")
assert "result line 1" in result
assert "result line 2" in result
def test_format_tool_result_empty():
"""Empty result returns empty string."""
result = _format_tool_result("", "Bash")
assert result == ""
# ── _try_claude_code_jsonl ─────────────────────────────────────────────
@@ -501,6 +694,139 @@ def test_messages_to_transcript_assistant_first():
assert "> Q" in result
# ── Tool block integration (Task 3) ───────────────────────────────────
def test_extract_content_with_tool_use():
"""_extract_content includes formatted tool_use blocks."""
content = [
{"type": "text", "text": "Let me check."},
{"type": "tool_use", "id": "t1", "name": "Bash",
"input": {"command": "lsusb"}},
]
result = _extract_content(content)
assert "Let me check." in result
assert "[Bash] lsusb" in result
def test_extract_content_with_tool_result():
"""_extract_content includes formatted tool_result blocks (needs tool_use_map)."""
content = [
{"type": "tool_result", "tool_use_id": "t1", "content": "some output"},
]
result = _extract_content(content, tool_use_map={"t1": "Bash"})
assert "→ some output" in result
def test_extract_content_tool_result_without_map_uses_fallback():
"""tool_result without a map entry uses fallback strategy."""
content = [
{"type": "tool_result", "tool_use_id": "t1", "content": "some output"},
]
result = _extract_content(content)
assert "→ some output" in result
def test_claude_code_jsonl_captures_tool_output():
"""Full integration: tool_use + tool_result appear in normalized transcript."""
lines = [
json.dumps({"type": "human", "message": {"content": "Check the camera"}}),
json.dumps({"type": "assistant", "message": {"content": [
{"type": "text", "text": "Let me check."},
{"type": "tool_use", "id": "t1", "name": "Bash",
"input": {"command": "lsusb | grep razer"}},
]}}),
json.dumps({"type": "human", "message": {"content": [
{"type": "tool_result", "tool_use_id": "t1",
"content": "Bus 002 Device 005: ID 1532:0e05 Razer Kiyo Pro"},
]}}),
json.dumps({"type": "assistant", "message": {"content": "Found it."}}),
]
result = _try_claude_code_jsonl("\n".join(lines))
assert result is not None
assert "> Check the camera" in result
assert "[Bash] lsusb | grep razer" in result
assert "→ Bus 002 Device 005" in result
assert "Found it." in result
def test_claude_code_jsonl_read_result_omitted():
"""Read tool results are omitted but the path breadcrumb is kept."""
lines = [
json.dumps({"type": "human", "message": {"content": "Show me the file"}}),
json.dumps({"type": "assistant", "message": {"content": [
{"type": "text", "text": "Reading it."},
{"type": "tool_use", "id": "t1", "name": "Read",
"input": {"file_path": "/home/jp/file.py"}},
]}}),
json.dumps({"type": "human", "message": {"content": [
{"type": "tool_result", "tool_use_id": "t1",
"content": "entire file contents here that should not appear"},
]}}),
json.dumps({"type": "assistant", "message": {"content": "Here it is."}}),
]
result = _try_claude_code_jsonl("\n".join(lines))
assert result is not None
assert "[Read /home/jp/file.py]" in result
assert "entire file contents here" not in result
def test_claude_code_jsonl_tool_only_user_message_not_counted():
"""A user message containing ONLY tool_results (no text) should not
be added as a separate user turn with '>'."""
lines = [
json.dumps({"type": "human", "message": {"content": "Do it"}}),
json.dumps({"type": "assistant", "message": {"content": [
{"type": "text", "text": "Running."},
{"type": "tool_use", "id": "t1", "name": "Bash",
"input": {"command": "echo hi"}},
]}}),
json.dumps({"type": "human", "message": {"content": [
{"type": "tool_result", "tool_use_id": "t1", "content": "hi"},
]}}),
json.dumps({"type": "assistant", "message": {"content": "Done."}}),
]
result = _try_claude_code_jsonl("\n".join(lines))
assert result is not None
# Only one user turn marker — the original "Do it"
user_turns = [line for line in result.split("\n") if line.strip().startswith(">")]
assert len(user_turns) == 1
assert "> Do it" in result
def test_extract_content_text_only_backward_compat():
"""Text-only content blocks still work (backward compat)."""
content = [
{"type": "text", "text": "Hello"},
{"type": "text", "text": "World"},
]
result = _extract_content(content)
assert "Hello" in result
assert "World" in result
def test_extract_content_string_unchanged():
"""Plain string content still works."""
result = _extract_content("just a string")
assert result == "just a string"
def test_claude_code_jsonl_thinking_blocks_ignored():
"""Thinking blocks are still ignored."""
lines = [
json.dumps({"type": "human", "message": {"content": "Q"}}),
json.dumps({"type": "assistant", "message": {"content": [
{"type": "thinking", "thinking": "", "signature": "abc"},
{"type": "text", "text": "A"},
]}}),
]
result = _try_claude_code_jsonl("\n".join(lines))
assert result is not None
assert "thinking" not in result.lower()
assert "signature" not in result
assert "A" in result
def test_normalize_rejects_large_file():
"""Files over 500 MB should raise IOError before reading."""
with patch("mempalace.normalize.os.path.getsize", return_value=600 * 1024 * 1024):