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

---

## [Unreleased]

### Bug Fixes

- **`mempalace_mine` accepts a single conversation file again, so hook transcript ingest survives a running hub.** `cli.py` has always documented the mine source as "Directory to mine, or one conversation file with `--mode convos`", and `hooks_cli._ingest_transcript` submits exactly one `.jsonl`. The MCP tool validated `os.path.isdir` regardless of mode, and `cmd_mine` forwards to the hub whenever one is registered and healthy, so the documented single-file form was unreachable in the configuration most users run: every Stop and PreCompact transcript ingest failed with `source directory not found`. Nothing surfaced it, because `hook_precompact` returns the same empty object on the success path, leaving a compaction that captured nothing indistinguishable from one that captured everything. `convos` now accepts a file or a directory; the tree-walking modes still require a directory. (#2281)

---

## [3.8.0] — 2026-08-20

Large palaces get fast and stay small: both storage backends lost their palace-wide read paths, a proxied agent session no longer loads a storage stack it never uses, and agents gained a background watcher so coordination stops stalling on nobody listening.
Expand Down
4 changes: 2 additions & 2 deletions integrations/openclaw/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,8 @@ tool-specific workflow below says to.
- `drawer_id` (required)

### Ingest & Cleanup
- `mempalace_mine` — Mine a directory into the palace. Host-level ingest; call only when the user asks to import files.
- `source` (required): directory to mine
- `mempalace_mine` — Mine a directory into the palace, or one conversation file with `mode='convos'`. Host-level ingest; call only when the user asks to import files.
- `source` (required): directory to mine, or one conversation file with `mode='convos'`
- `mode`: `projects` (default), `convos`, or `extract`
- `wing`: target wing (default: source directory name)
- `agent`: recorded on every drawer (default `mempalace`)
Expand Down
15 changes: 12 additions & 3 deletions mempalace/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -3309,8 +3309,16 @@ def tool_mine(
}

src = os.path.expanduser(source) if source else ""
if not src or not os.path.isdir(src):
return {"success": False, "error": f"source directory not found: {source!r}"}
# convos accepts one conversation file as well as a directory — the CLI has
# always documented it that way ("Directory to mine, or one conversation
# file with --mode convos"), and the hooks rely on it: _ingest_transcript
# submits a single .jsonl. Because cmd_mine forwards to the hub whenever one
# is live, a directory-only precondition here made that documented form
# unreachable in the configuration most users run, so every hook transcript
# ingest failed against a running hub (#2281). The other modes still walk a
# tree, so they keep the directory requirement.
if not src or not (os.path.isdir(src) or (mode == "convos" and os.path.isfile(src))):
return {"success": False, "error": f"source not found: {source!r}"}

def _run():
if mode == "convos":
Expand Down Expand Up @@ -5185,6 +5193,7 @@ def tool_patch_submit(
"mempalace_mine": {
"description": (
"Mine a directory into the palace — the MCP equivalent of `mempalace mine`. "
"mode='convos' also accepts a single conversation file. "
"mode='projects' (default) ingests code/docs; mode='convos' ingests chat "
"transcripts; mode='extract' ingests office documents (PDF/DOCX/RTF, requires "
"the mempalace[extract] extra). Runs synchronously and returns the miner's "
Expand All @@ -5197,7 +5206,7 @@ def tool_patch_submit(
"properties": {
"source": {
"type": "string",
"description": "Directory to mine.",
"description": "Directory to mine, or one conversation file with mode='convos'.",
},
"mode": {
"type": "string",
Expand Down
51 changes: 51 additions & 0 deletions tests/test_mcp_mine.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,57 @@ def test_convos_mode_files_drawers(monkeypatch, config, tmp_dir):
del client


def test_convos_mode_accepts_a_single_file(monkeypatch, config, tmp_dir):
"""A lone conversation file is a valid convos source (#2281).

``cli.py`` documents the positional as "Directory to mine, or one
conversation file with --mode convos", and ``hooks_cli._ingest_transcript``
submits exactly one ``.jsonl``. ``cmd_mine`` forwards to the hub whenever one
is live, so a directory-only precondition here makes the documented
single-file form unreachable in the configuration most users run, and every
hook transcript ingest fails.
"""
from mempalace import mcp_server

_patch(monkeypatch, config)
src = os.path.join(tmp_dir, "one-session.txt")
_write(
src,
"> What is memory?\nMemory is persistence.\n\n"
"> Why does it matter?\nIt enables continuity across sessions.\n\n"
"> How do we build it?\nWith structured verbatim storage.\n",
)

result = mcp_server.tool_mine(source=src, mode="convos", wing="test_one_file")
assert result["success"] is True, result.get("error")
assert result["mode"] == "convos"

client = chromadb.PersistentClient(path=config.palace_path)
try:
col = client.get_collection("mempalace_drawers")
assert col.count() >= 2
finally:
del client


def test_projects_mode_still_rejects_a_file(monkeypatch, config, tmp_dir):
"""Only convos gained the single-file form; projects still needs a tree.

Guards the relaxation from widening into "any mode, any path" — without
this, the fix for #2281 would pass just as well if the precondition were
dropped entirely.
"""
from mempalace import mcp_server

_patch(monkeypatch, config)
src = os.path.join(tmp_dir, "notes.md")
_write(src, "# Title\n\n" + ("Some real content. " * 40))

result = mcp_server.tool_mine(source=src, mode="projects")
assert result["success"] is False
assert "source" in result["error"].lower()


def test_stdout_captured_not_leaked_to_fd(monkeypatch, config, tmp_dir, capfd):
"""Miner stdout must land in ``output``, never on the real fd-1 JSON-RPC
channel. ``tool_mine`` redirects fd 1 around the in-process miner."""
Expand Down
4 changes: 2 additions & 2 deletions website/reference/mcp-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,11 +135,11 @@ Delete a drawer by ID. Irreversible.

### `mempalace_mine`

Mine a directory into the palace — the MCP equivalent of `mempalace mine`. Wraps the same in-process miners the CLI uses; runs synchronously and returns the miner's summary as `output`. The palace write lock is automatic — a concurrent mine returns a structured already-running error. Orphan cleanup is separate (see `mempalace_sync`).
Mine a directory into the palace — the MCP equivalent of `mempalace mine`. `mode='convos'` also accepts a single conversation file. Wraps the same in-process miners the CLI uses; runs synchronously and returns the miner's summary as `output`. The palace write lock is automatic — a concurrent mine returns a structured already-running error. Orphan cleanup is separate (see `mempalace_sync`).

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `source` | string | **Yes** | Directory to mine |
| `source` | string | **Yes** | Directory to mine, or one conversation file with `mode='convos'` |
| `mode` | string | No | `projects` (code/docs, default), `convos` (chat transcripts), or `extract` (office docs; needs the `mempalace[extract]` extra) |
| `wing` | string | No | Target wing (default: source directory name) |
| `agent` | string | No | Recorded on every drawer (default: `mempalace`) |
Expand Down