Skip to content
Draft
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

### Bug Fixes

- **`mempalace_memories_filed_away` no longer errors after consuming a valid non-object checkpoint.** The acknowledgement tool deleted `last_checkpoint` and then unconditionally called `.get()` on the decoded JSON, so `null`, arrays, strings, numbers, and booleans produced an internal MCP error with no marker left to retry. It now requires the decoded value to be an object; rejected roots follow the existing generic malformed-checkpoint response without echoing their contents, while valid objects and missing files keep their current behavior. (#2272)
- **`sweep` books a failed `stat` as a failure again.** The non-regular-file gate added in 3.7.1 reads `stat.S_ISREG(f.stat().st_mode)` inside a `try`, and its `except OSError` printed `SKIP` and moved on. A dangling symlink, a symlink loop and a file unlinked between `rglob` and the gate all raise there, and before the gate existed every one of them reached `sweep()` and was booked in `failures` — so `sweep` went from reporting a transcript it could not read to reporting success. A failed probe is now an error, not a benign file type: it is logged, printed as `WARNING`, and appended to `failures`, while a probe that succeeds and says "not regular" still skips silently. (#2221)
- **`mempalace init` no longer tracebacks on a directory it cannot enter.** `_parse_gradle`'s `is_file()` gate sat in front of the `try` that the parser's own `except OSError` provides, so a manifest under a directory with `r` but no `x` raised `PermissionError` out of a call that used to answer "no manifest name". The gate moved inside that `try`, and `_collect_manifest_names` stats through `os.path.isfile`, which reports rather than raises. (#2221)
- **`split` no longer blocks on a FIFO at its own output name, nor writes through a broken link.** The type gate in `main` covers the files the glob listed; `split_file` builds its output names itself, so a pre-existing named pipe at one of them wedged `write_text` in the kernel waiting for a reader. The check asks about the link itself rather than its target, because a dangling symlink at an output name reads as "nothing there" and `write_text` would create the target — landing a chunk outside the output directory. Output names that are anything but a regular file are now skipped with a `SKIP` line. (#2221)
Expand Down
15 changes: 9 additions & 6 deletions mempalace/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -4182,9 +4182,17 @@ def tool_memories_filed_away():
"count": 0,
"timestamp": None,
}
error_result = {
"status": "error",
"message": "\u2726 Journal entry filed in the palace",
"count": 0,
"timestamp": None,
}
try:
data = json.loads(ack_file.read_text(encoding="utf-8"))
ack_file.unlink(missing_ok=True)
if not isinstance(data, dict):
return error_result
msgs = data.get("msgs", 0)
return {
"status": "ok",
Expand All @@ -4194,12 +4202,7 @@ def tool_memories_filed_away():
}
except (json.JSONDecodeError, OSError):
ack_file.unlink(missing_ok=True)
return {
"status": "error",
"message": "\u2726 Journal entry filed in the palace",
"count": 0,
"timestamp": None,
}
return error_result


# ==================== SETTINGS TOOLS ====================
Expand Down
66 changes: 66 additions & 0 deletions tests/test_mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -4058,6 +4058,72 @@ def test_diary_write_chunk_index_metadata(self, monkeypatch, config, palace_path
)


# ── Silent checkpoint acknowledgement ─────────────────────────────────


def _checkpoint_ack_file(tmp_path, monkeypatch):
"""Point the checkpoint tool at an isolated cross-platform home."""
home = tmp_path / "home"
state_dir = home / ".mempalace" / "hook_state"
state_dir.mkdir(parents=True)
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("USERPROFILE", str(home))
drive, tail = os.path.splitdrive(str(home))
monkeypatch.setenv("HOMEDRIVE", drive or "C:")
monkeypatch.setenv("HOMEPATH", tail or str(home))
return state_dir / "last_checkpoint"


@pytest.mark.parametrize(
"payload",
[None, [], "sensitive checkpoint text", 42, True],
ids=["null", "array", "string", "number", "boolean"],
)
def test_memories_filed_away_consumes_non_object_json(tmp_path, monkeypatch, payload):
"""Valid JSON roots that are not objects are malformed checkpoints."""
from mempalace import mcp_server

ack_file = _checkpoint_ack_file(tmp_path, monkeypatch)
ack_file.write_text(json.dumps(payload), encoding="utf-8")

result = mcp_server.tool_memories_filed_away()

assert result == {
"status": "error",
"message": "\u2726 Journal entry filed in the palace",
"count": 0,
"timestamp": None,
}
assert not ack_file.exists()
assert "sensitive checkpoint text" not in json.dumps(result)


def test_memories_filed_away_preserves_existing_outcomes(tmp_path, monkeypatch):
"""Valid, invalid-syntax, and missing checkpoints retain their contract."""
from mempalace import mcp_server

ack_file = _checkpoint_ack_file(tmp_path, monkeypatch)
ack_file.write_text(json.dumps({"msgs": 7, "ts": "2026-01-01T00:00:00"}), encoding="utf-8")
assert mcp_server.tool_memories_filed_away() == {
"status": "ok",
"message": "\u2726 7 messages tucked into drawers",
"count": 7,
"timestamp": "2026-01-01T00:00:00",
}
assert not ack_file.exists()

ack_file.write_text("{not-json", encoding="utf-8")
assert mcp_server.tool_memories_filed_away()["status"] == "error"
assert not ack_file.exists()

assert mcp_server.tool_memories_filed_away() == {
"status": "quiet",
"message": "No recent journal entry",
"count": 0,
"timestamp": None,
}


# ── Cache Invalidation (inode/mtime) ──────────────────────────────────


Expand Down