Skip to content

feat: add JSONL export/import for cross-device palace sync - #2209

Open
0xdhx wants to merge 8 commits into
MemPalace:developfrom
0xdhx:feat/palace-export-import
Open

feat: add JSONL export/import for cross-device palace sync#2209
0xdhx wants to merge 8 commits into
MemPalace:developfrom
0xdhx:feat/palace-export-import

Conversation

@0xdhx

@0xdhx 0xdhx commented Aug 10, 2026

Copy link
Copy Markdown

feat: add JSONL export/import for cross-device palace sync

What does this PR do?

Implements the recommended solution from #452: a portable, git-friendly export/import pair so a palace can be synced between machines through a private git repo.

  • mempalace export (default --format jsonl) streams every drawer into a JSONL tree organized by wing/room (<out>/<wing>/<room>.jsonl, plus an export-manifest.json with a format version and counts). Output is deterministic — drawers sorted by id, JSON keys sorted, no timestamps — so re-exporting an unchanged palace produces a byte-identical tree and an empty git diff. --format markdown exposes the existing exporter.export_palace (previously library-only) on the CLI.
  • mempalace import <dir> merges an export into a palace: adds drawers whose ids are absent, skips existing ones, and is idempotent on re-import. --dry-run is a parse-only true preview — it never opens (or creates) the palace at all, since opening a local backend is itself a write (the same principle as the repair --dry-run performs the real rebuild in the default (legacy) mode #2144/repair --mode from-sqlite --dry-run ARCHIVES AND REBUILDS the palace (dry-run not honored) #2133 dry-run fixes). Malformed input (bad JSON, pathological nesting, non-UTF-8 files) is counted and reported, never fatal; a concurrent insert of the same id degrades to a skip rather than an abort. Imports into a missing palace create it, so first import on a fresh machine works.
  • Embeddings are deliberately not exported: they are large, binary, and embedder-specific. Import re-embeds locally and says so in its output — the runtime warning requested in the feat: add backup, export, and import commands #453 review.

Scope is deliberately the narrow v1 from the issue discussion (idempotent hash-id dedup). Out of scope, per that thread: metadata-conflict detection for moved drawers, near-duplicate detection for divergent edits (#452 comments), binary backup (#448), and KG export (previously #435/#499). One known v1 caveat, stated rather than hidden: import accepts any *.jsonl tree without requiring export-manifest.json (tolerant by design; the manifest is informational in format v1). That tolerance is also why re-export prunes room files it did not write — otherwise a room whose last drawer was deleted survives as a stale file and the next device re-imports it, resurrecting the drawer. The prune is gated on an export-manifest.json that existed before the run, so exporting into an unrelated directory deletes nothing, and it is skipped on a zero-drawer palace (which warns instead, since an empty count is also what a palace that failed to open looks like). The --format enum currently covers jsonl (round-trippable) and markdown (one-way, browsable); new formats extend the same seam.

How to test

# Round trip: export from one palace, import into a fresh one
mempalace export --output /tmp/palace-export
mempalace import /tmp/palace-export --palace /tmp/fresh-palace
mempalace import /tmp/palace-export --palace /tmp/fresh-palace   # idempotent: 0 imported

# Or the automated coverage
uv run pytest tests/test_export_import_jsonl.py tests/test_exporter.py -v

New tests cover: export structure + byte-level determinism across two runs, full round-trip content fidelity, idempotent re-import, malformed-line tolerance, dry-run creating nothing, and non-directory input rejection. Later review rounds add coverage for the non-regular-file/FIFO guards, symlinked-directory containment, non-scalar metadata reporting, stale-export pruning with its first-run and zero-drawer gates, and the import writer lease. Each of those fixes carries a test that fails when the fix is reverted.

Checklist

  • Tests pass (python -m pytest tests/ -v)
  • No hardcoded paths
  • Linter passes (ruff check .)

Fixes #452

0xdhx added 6 commits August 15, 2026 21:07
Adds `mempalace export` (deterministic, git-friendly JSONL tree by
wing/room; --format markdown exposes the existing browsable exporter)
and `mempalace import` (merge by drawer id: adds new, skips existing,
idempotent, re-embeds locally). Closes the multi-machine sync gap:
export on one machine, sync the tree via a private git repo, import
on another.

Fixes MemPalace#452
- import --dry-run is now a parse-only preview: it never opens (or
  creates) the palace, since opening a local backend is itself a write
- malformed-input tolerance widened: RecursionError on pathological
  JSON and non-UTF-8 files are counted and skipped, never fatal
- concurrent-insert race on add degrades to per-item skip instead of
  aborting the batch
- export groups by sanitized wing/room names, so names that sanitize to
  the same path merge into one file instead of overwriting
- honest memory note in the JSONL export docstring
Round-2 review findings: the per-item fallback now checks existence
AFTER a failed add (closing the second race and never masking a real
backend error); unreadable inputs (IsADirectoryError, permissions,
I/O) are counted like malformed lines instead of aborting; the
exporter module docstring no longer claims bounded memory for the
JSONL leg.
A backend error during a mid-file flush was caught by the file-read
OSError handler, miscounted as malformed input, and left the batch
pending. Files are now parsed under the guard and flushed outside it,
so backend failures surface and filesystem failures stay per-file.
`import_palace` opened every `*.jsonl` glob hit with a plain `open()`. That
is safe for the cases the surrounding comment named — a directory, bad
permissions, bad UTF-8 — because each raises, and the `except OSError` books
it as malformed. A FIFO does neither: opening one for reading parks in the
kernel until a writer appears, so a named pipe carrying a `.jsonl` name
wedges the whole import and no handler can see it.

Adopt the guard the ingest side already uses (`miner._read_text_no_follow`,
MemPalace#2221/MemPalace#2244): `O_NONBLOCK` makes the `S_ISREG` check reachable, `O_NOFOLLOW`
keeps a symlinked entry from reading through to a target outside the import
tree — matching the `_reject_symlink` posture the exporter applies on the
write side — and the EAGAIN arm preserves the blocking read for a regular
file whose write lease we broke. A non-regular entry is booked exactly like
an unreadable one, so a partially hostile export still imports what survives.

The test bounds the regression with SIGALRM, since a regression does not
raise — it hangs. The alarm handler deliberately raises a non-OSError: the
first version used TimeoutError, which IS an OSError subclass, so the
importer's own handler swallowed it and the test passed against unfixed code.
Findings from two adversarial review passes over this round, each verified
before being acted on.

1. The no-follow guard was half a guard. O_NOFOLLOW checks only the final
   path component, and `glob.glob(..., recursive=True)` traverses symlinked
   DIRECTORIES — driven: a probe tree returned `linked_dir/a.jsonl` from
   outside the root. So a symlinked leaf was refused while a regular file
   reached through a symlinked directory was imported as if it belonged to
   the export. `miner._read_text_no_follow` pairs its no-follow open with a
   containment test for exactly this reason; only half of that convention
   was adopted. `_path_within_root` adds the other half. The check is
   resolve-then-open, so a symlink swapped in between the check and the open
   still wins — that race needs per-component `openat` walking and is stated
   as a limitation rather than half-closed.

2. Non-scalar metadata was dropped silently, on a premise that does not hold.
   The filter's comment claimed the backend cannot store non-scalars, which is
   true of Chroma and false of the palace generally: `sqlite_exact` serializes
   metadata through an unrestricted `json.dumps`, so a palace on that backend
   can hold a list or a nested dict. The exporter writes it out raw and the
   importer discarded it without a word. The values are still dropped — this
   import path stores scalars only — but they are counted in a new
   `metadata_dropped` stat and reported. A `metadata` that is not a dict at
   all is discarded WHOLE and counts as one drop; replacing it with `{}`
   before counting reported zero for a total loss, which was the same silent
   loss one level up. An absent `metadata` key is not a drop and reports zero.

Each fix carries a reversion control that fires: disabling containment fails
the outside-tree test, zeroing either drop count fails its metadata test, and
reverting the FIFO guard hangs the import until the test's alarm fires.
@0xdhx
0xdhx force-pushed the feat/palace-export-import branch from 0b05bed to 470f1ca Compare August 16, 2026 02:41
@0xdhx

0xdhx commented Aug 16, 2026

Copy link
Copy Markdown
Author

Rebased onto develop. This was CONFLICTING against a base that had moved 122 commits since the branch point; the only conflict was CHANGELOG.md, with cli.py and exporter.py merging cleanly. Both sides of the changelog are kept, ### Features before ### Bug Fixes to match the ordering used in 3.7.0 / 3.6.0 / 3.5.0.

Reconciling against the new base turned up that the importer predated the non-regular-file work in #2221 / #2207 / #2244 and had exactly the defect those fixed, so there are two hardening commits on top of the rebase.

import_palace could block forever on a non-regular file. It opened every *.jsonl glob hit with a plain open(). That is fine for the cases the surrounding handler named — a directory, bad permissions, bad UTF-8 — because each raises. A FIFO does not: opening one for reading parks in the kernel until a writer appears, so a named pipe carrying a .jsonl name wedged the import, and the enclosing except (OSError, UnicodeDecodeError) could never see it. Now guarded the way miner._read_text_no_follow does it — O_NONBLOCK to make the S_ISREG check reachable, O_NOFOLLOW, and the EAGAIN retry that preserves the blocking read for a regular file whose write lease was broken.

Import reads are contained to the import tree. O_NOFOLLOW guards only the final path component, and glob.glob(..., recursive=True) traverses symlinked directories — so a regular file outside input_dir, reached through a symlinked subdirectory, was imported as if it belonged to the export. _path_within_root closes that, mirroring the containment test _read_text_no_follow pairs with its no-follow open. Stated plainly because it is a real limit rather than a complete fix: the check is resolve-then-open, so it stops a symlink sitting in the tree, not one swapped in between the check and the open. Closing that race properly needs per-component openat walking, which felt disproportionate for v1 — and an import source you do not control is outside the feature's threat model anyway. Happy to do it if you disagree.

Non-scalar metadata is now reported rather than dropped silently. The filter's premise was that the backend cannot store non-scalars. That holds for Chroma and not for the palace generally — sqlite_exact serializes metadata through an unrestricted json.dumps, so a palace on that backend can hold a list or a nested dict, the exporter writes it out raw, and the importer discarded it without a word. The values are still dropped (this import path stores scalars only) but they are counted in a new metadata_dropped stat and reported. A metadata that is not an object at all is discarded whole and counts as one drop; an absent metadata key is not a drop.

Each fix has a test that fails when the fix is reverted — four controls, all four fire. The FIFO test bounds the hang with SIGALRM and deliberately raises a non-OSError, because TimeoutError is an OSError subclass and the importer's own handler swallowed the first version, making the test pass against unfixed code.

Full suite on the rebased branch: 4303 passed, 6 failed, 31 skipped. All 6 failures reproduce on a clean develop checkout at 06cb698 with this PR absent — 5 parametrisations of test_init.py::test_init_filters_sys_path_from_leaked_pythonpath and test_mcp_server.py::TestStaleLibraryGate::test_startup_baseline_survives_module_reload — so they look pre-existing rather than anything this branch introduces. Flagging them rather than touching them here.

Two things I did not change, both design calls rather than defects, and both yours to make:

  1. The body already notes that re-exporting into an existing tree does not delete files for rooms that no longer exist. Worth sharpening: because import walks every *.jsonl without consulting export-manifest.json, that is not only a cosmetic git-diff wrinkle — deleting the last drawer in a room and re-exporting can leave a stale file that another device then re-imports, resurrecting the drawer. Either pruning on export or having import honour the manifest would close it.
  2. import_palace opens the collection for writing without taking mine_palace_lock, which the adapter write path holds. The module docstring claims the same single-writer expectations as mine, so that is a doc/behaviour mismatch either way. Relatedly, export_palace_jsonl calls get_collection() with the default create=True, read_only=False despite being a pure read.

Happy to take any of these in this PR or a follow-up, whichever you prefer.

One process note: after the push the PR is MERGEABLE but sits at UNSTABLE with zero check runs reported. I read that as the workflow runs still awaiting approval for an outside contributor rather than anything failing — so the rebase clears the conflict but will not turn CI green on its own. Let me know if there is something I should do at my end instead.

0xdhx added 2 commits August 15, 2026 22:05
Re-exporting rewrote the rooms that still exist and left the rest in place.
The PR body already noted that as a git-diff wrinkle, but it is worse than
cosmetic: import walks every `*.jsonl` under the tree without consulting
`export-manifest.json`, so a room whose last drawer was deleted survives in a
stale file and the next device re-imports it — the drawer comes back.

Re-export now prunes room files it did not write, and removes wing
directories the prune emptied. Two safety properties, both deliberate:

* Pruning is gated on an `export-manifest.json` that existed BEFORE this run,
  captured before the manifest is rewritten. Exporting into an unrelated
  directory therefore deletes nothing on the first run — the gate is proof the
  tree is one of ours, not merely that it now contains our manifest.
* Only regular files are unlinked; symlinks are skipped rather than followed,
  matching the `_reject_symlink` posture already applied on the write side.

The zero-drawer path deliberately does NOT prune, and says so. An empty count
is also what a palace that failed to open looks like, and silently deleting a
good export on that reading is far worse than leaving a stale one behind — so
it warns and leaves the tree alone.

Also opens the collection with `read_only=True`, since export is a pure read.
`create` is left at its default: flipping it changes what exporting a
not-yet-existing palace does, which is a separate call from this one.
The module docstring claimed import "follows the same single-writer
expectations as `mine`", but nothing enforced it: `import_palace` opened the
collection and added drawers without taking `mine_palace_lock`, which the
adapter write path holds at cli.py for exactly this. The existing-id check and
the add are not one atomic operation, so a concurrent miner could file an id
between them — the docstring was aspirational rather than true.

The lease now spans the whole walk, not just the final flush, because batches
are added AS files are read. The walk moved into `_import_into` so that scope
is explicit and readable rather than an eighty-line indent.

A dry run still takes no lease and opens nothing: acquiring one would create
the palace directory the preview promises not to touch.

Tests cover the pruning, the first-export gate, the zero-drawer warning, and
that the lease is taken for a real import and not for a dry run. Each has a
reversion control that fires.
@0xdhx

0xdhx commented Aug 16, 2026

Copy link
Copy Markdown
Author

Went ahead and did both of the two design calls I flagged at the end of the last comment, one scoped commit each — so either can be dropped or split into a follow-up without touching the other, if you'd rather keep this PR narrow.

Re-export now prunes stale room files (ab9e7f8). This is the one I'd argue is a defect rather than a wrinkle: because import walks every *.jsonl without consulting export-manifest.json, deleting the last drawer in a room and re-exporting left a stale file that the next device re-imported, resurrecting the drawer. Two gates on the deletion, both deliberate:

  • Pruning is gated on an export-manifest.json that existed before this run, captured before the manifest is rewritten. Exporting into an unrelated directory therefore deletes nothing on the first run — the gate is proof the tree is one of ours, not merely that it now contains our manifest.
  • Only regular files are unlinked; symlinks are skipped rather than followed, matching the _reject_symlink posture already applied on the write side.

The zero-drawer path deliberately does not prune, and says so. An empty count is also what a palace that failed to open looks like, and silently deleting a good export on that reading is worse than leaving a stale one behind — so it warns and leaves the tree alone.

import_palace now holds the writer lease for the whole walk (0e710c1). The module docstring already claimed the same single-writer expectations as mine, but nothing enforced it, and the existing-id check and the add are not one atomic operation — a concurrent miner could file an id between them. The lease spans the whole walk rather than just the final flush, because batches are added as files are read; the walk moved into _import_into so that scope is explicit rather than an eighty-line reindent. A dry run still takes no lease and opens nothing, since acquiring one would create the palace directory the preview promises not to touch.

Same commit also opens the collection read_only=True in export_palace_jsonl, since export is a pure read. create is left at its default — flipping it changes what exporting a not-yet-existing palace does, which is a separate call from this one.

Four new tests (pruning, the first-export gate, the zero-drawer warning, and that a real import takes the lease while a dry run does not), each with a reversion control that fires against the unfixed code. Full suite on the branch: 4307 passed, 6 failed, 31 skipped — the same 6 pre-existing failures I flagged last time, unchanged and unrelated.

PR body updated: the caveat about re-export leaving stale files is no longer true, so it has been replaced rather than left standing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: cross-device sync — export/import palace for multi-PC workflows

1 participant