Contract for exchanging knowledge between two deployments of the same base — a studio laptop and a work laptop, a desktop and a travel machine.
Reference implementations:
scripts/kb_export.py(pack) andscripts/kb_import.py(merge). Wrappers:shell/export.sh,shell/import.sh, plusexport.command/import.command(macOS) andexport.bat/import.bat(Windows).
The same base deployed twice diverges. One machine accumulates knowledge about
plugins and gear; the other accumulates analysis notes. Both edited
knowledge/domain/tools.md. Neither rsync nor a plain copy can help: the
first would overwrite one side's work, the second would leave two disconnected
piles of Markdown.
- Never lose knowledge. No overwrite happens without a backup, and no ambiguous overwrite happens at all — it becomes a decision for the agent.
- Mechanics in Python, meaning in the agent. The script does what is
provably safe (dedup by content hash, additive copies, provenance stamps).
Everything that needs judgement goes to
review/needs-merge/and is settled by!merge. - Idempotent. Importing the same bundle twice changes nothing the second time.
- Converging, not accumulating. Repeated syncs must not breed duplicates, so identical content is recognised regardless of filename, and pages that were imported and never touched locally fast-forward silently.
machine A machine B
┌──────────────┐ ┌──────────────┐
│ knowledge/ │ │ knowledge/ │
└──────┬───────┘ └──────▲───────┘
│ kb_export.py │ kb_import.py
▼ │
sync/outbox/bundle.zip ──── copy ──► sync/inbox/bundle.zip
│
├─ safe cases → applied automatically
└─ ambiguous → review/needs-merge/
│
!merge (agent)
│
merged knowledge + contradiction audit
│
reindex
python3 scripts/kb_export.py # → sync/outbox/kb-bundle__<label>__<date>.zip
python3 scripts/kb_export.py --label studio-laptop
python3 scripts/kb_export.py --since 2026-06-01 # only knowledge touched since then
python3 scripts/kb_export.py --with-assets # include binary originals
python3 scripts/kb_export.py --dry-run # report contents, write nothing| Section | Path in bundle | Why |
|---|---|---|
knowledge |
knowledge/**.md |
the knowledge itself |
assets-index |
assets-index/**.md |
descriptions of binaries, cheap and useful |
interactions |
interactions/** |
session history — append-only, never conflicts |
meta |
meta/extracted-metadata/, meta/nlp-meta/ |
provenance and NLP enrichment |
config |
config/entities.yml |
role, language, tracked entities |
log |
log.md |
the other base's operations chronology |
assets |
assets/** |
opt-in only (--with-assets) |
raw/, processed/ payloads, review/ queues, sync/ itself, .repomix/
output, and operational state (.watcher.pid, lint reports). Raw material stays
on the machine that produced it — it is the largest, the most private, and the
least useful to the other side.
Assets are opt-in on purpose. For media-heavy roles the binaries dwarf the knowledge by three orders of magnitude. The default bundle carries the descriptions (
assets-index/); when the importing base has no matching file, the entry is annotated with- Note: original file not present in this base, so the reference is honest instead of dangling.
Every bundle starts with manifest.yml: bundle format, source label, role,
language, export date, per-section counts, and for every file its size, SHA-256
and — for knowledge pages — a content fingerprint plus title, lifecycle and
importance. The fingerprint is what makes dedup work.
python3 scripts/kb_import.py # every *.zip in sync/inbox/
python3 scripts/kb_import.py bundle.zip
python3 scripts/kb_import.py --dry-run # classify, write nothing
python3 scripts/kb_import.py --strategy prefer-incomingThe body is the knowledge; frontmatter is bookkeeping. Two pages carry the
same knowledge when their normalized bodies match — line endings unified,
trailing whitespace stripped, trailing blank lines collapsed. Implemented in
kb_common.content_fingerprint().
Frontmatter is deliberately outside the fingerprint. Reading a page on one
machine bumps access_count; tagging it there raises importance. None of that
is a reason to ask a human which version wins — the metadata is merged
non-destructively instead (tags unioned, counters raised to the maximum, dates
advanced to the newest, missing fields filled in, existing values never
replaced).
| Outcome | Condition | What happens |
|---|---|---|
new |
no page at that path, no page with that slug, no page with that fingerprint | copied in, provenance stamped |
identical |
same path, same body, same metadata | skipped |
enriched |
same path and body, incoming metadata adds something | metadata merged — body untouched |
duplicate |
same body at a different path | skipped, reported (the page already exists under another name) |
fast-forward |
local body still matches the merge_source_fingerprint recorded at its last import — i.e. it was never edited here |
body updated, local-only frontmatter fields preserved, backup kept |
conflict |
both bodies changed | local file untouched; incoming staged + diff written to review/needs-merge/ |
A page classified new that overlaps ≥ similarity_threshold (default 0.85)
with an existing page is still added — knowledge is never withheld — but a
merge candidate package is written so the agent can consolidate the two.
Imported pages get:
merged_from: "studio-laptop" # sync.label of the source base
merge_bundle: "kb-bundle__studio-laptop__2026-07-31.zip"
imported_at: "2026-07-31T10:14:36+03:00"
merge_source_fingerprint: "sha256:1a2b3c…" # fingerprint as importedmerge_source_fingerprint is the whole mechanism behind fast-forward: at the
next import, if the local fingerprint still equals it, nothing local was lost by
updating. If it differs, the user edited the page here too — that is a conflict.
These keys live in frontmatter, which is outside the fingerprint, so stamping them never makes the page look changed to the other base.
assets-index/— merged block by block (## <asset>). Missing blocks are appended; existing ones are left alone.interactions/,processed/extracted-metadata/,processed/nlp-meta/— additive. Identical files are skipped; same-name-different-content files are kept side by side as<name>__from-<label>.<ext>.log.md— archived aslog-archive/imported__<label>__<date>.md. The local log is never rewritten; the import itself is appended to it.config/entities.yml— reported, never applied. Adding an entity reshapes routing and folder layout, so it is the agent's call during!merge.
- Every modified file is copied to
sync/backups/<timestamp>/<path>first. - Archive members with absolute paths or
..are refused (zip-slip). --dry-runclassifies everything and writes nothing.- The bundle moves to
sync/applied/only after a successful import.
| Code | Meaning |
|---|---|
0 |
merged cleanly, nothing left to decide |
1 |
merged, conflicts waiting in review/needs-merge/ |
2 |
error (no bundle, unreadable archive, unsupported format) |
Run after every import. This is where the base actually gets better rather than merely bigger.
Cost: ~5–40K tokens depending on the number of conflicts.
-
Read the report. Newest file in
sync/reports/. It lists everything that was added, updated, skipped, and everything still undecided. -
Resolve every package in
review/needs-merge/.- Conflicts: read the local page and
_incoming/<path>in full. Produce one merged page at the local path that keeps every claim true in either version and prefers the more specific formulation. Never drop a fact because the other side did not have it. - Genuine contradictions (the two sides assert incompatible things): keep
the better-sourced claim, and record the other in
knowledge/open-questions/with both sources and dates. Do not silently pick a winner. - Merge candidates (near-duplicates): if both pages describe the same
thing, consolidate into the better-named one and move the other to
knowledge/_archive/withsupersedes:. If they differ, cross-link them with[[wikilinks]]instead. - After resolving, set
merge_source_fingerprinton the merged page to the incoming page's fingerprint, refreshlast_verified, keep the higherimportance, union thetags— then delete the package and its_incoming/file.
- Conflicts: read the local page and
-
Audit for contradictions. Over the merged pages and their neighbours (whatever they
[[link]]to): does the imported knowledge contradict something the base already held? Two bases that diverged for months will disagree — surface it rather than let both statements sit in the index. -
Enrich. This is the payoff of a merge:
- cross-link imported pages into the existing graph with
[[wikilinks]] - refresh
knowledge/routing/(python3 scripts/kb_route.py) - if the import brought a coherent new area, consider whether it deserves an
insights/page synthesizing both sides - report entities the other base tracks that this one does not, and ask the user whether to adopt them
- cross-link imported pages into the existing graph with
-
Finish. Run
python3 scripts/kb_lint.py, then reindex (python3 scripts/kb_reindex.py). Report to the user: what was added, what was merged, what contradicts what, and what you left inopen-questions/.
- Never resolve a conflict by deleting one side wholesale. If a fact exists only in the incoming version, it belongs in the merged page.
- Never apply
entitieschanges from a bundle without asking. - If a conflict cannot be settled without the user (both sides plausible, no source to arbitrate), ask one specific question about that page and move on to the next one — do not stall the whole merge.
- Long-form book rules from
03_PIPELINE.mdstill apply: an imported page that is really the prose of a copyrighted work does not belong invoice/.
# on the studio laptop
./shell/export.sh --label studio-laptop
# copy sync/outbox/kb-bundle__studio-laptop__<date>.zip to the other machine's sync/inbox/
# on the work laptop
./shell/import.sh
# → "2 conflicts waiting in review/needs-merge/"Then in the AI chat:
Read AGENTS.md and use it as the primary instruction for everything that follows.
!merge
Doing it in both directions leaves the two bases converged: each holds the union of the knowledge, with contradictions surfaced instead of buried.
sync:
label: "studio-laptop" # how this base identifies itself. MUST differ per machine
export:
sections: ["knowledge", "assets-index", "interactions", "meta", "config", "log"]
with_assets: false
import:
strategy: "safe" # safe | prefer-incoming | prefer-local
similarity_threshold: 0.85
backup: true
move_applied: trueCLI flags override the config; the config overrides the built-in defaults.
| Strategy | Behaviour on a two-sided change |
|---|---|
safe (default) |
local untouched, conflict queued for !merge |
prefer-incoming |
incoming wins, backup kept in sync/backups/ |
prefer-local |
incoming discarded, reported only |
prefer-incoming and prefer-local exist for bulk one-way syncs (a fresh base
pulling from an established one). For ordinary two-way work, use safe —
anything else silently picks a winner.
A bundle is knowledge, not raw material, but it is still your knowledge:
treat it as sensitive. Move it between machines the way you would move the base
itself. Nothing in the export path contacts the network, and sync/ is excluded
from the index (repomix.config.json) and from git (.gitignore).
If the base is shared with someone else, export with --only knowledge and
review the result — interactions/ contains session history, and
meta/extracted-metadata/ contains original filenames.