From 888c77721e731da97aa44deac6d58da6e4bc9ff6 Mon Sep 17 00:00:00 2001 From: tanglearncode Date: Fri, 14 Aug 2026 03:57:48 +0800 Subject: [PATCH 1/4] feat(import): reconcile a newer copy instead of duplicating it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Import always created. That was right exactly once — the first time a bundle arrived. Every import after it was a newer copy of a context already here, and creating produced a second one that competed during routing while the connected session went on reading the stale copy. The name collision was reported as a dead end: pick another name, or delete the context first. Import now resolves before it acts. It records where a copy came from, so a later bundle from the same origin is recognised, and it answers with one of: nothing new, take it whole, reconcile the two first, or ask. A context is never deleted, and a replacement keeps the id — so a session connected to it reads the updated material immediately. Co-Authored-By: Claude Opus 5 --- README.md | 24 +- codex-marketplace/README.md | 24 +- .../neatcontext/skills/import/SKILL.md | 63 ++- .../neatcontext/src/codex/neatcontext-cli.mjs | 39 +- .../neatcontext/src/core/context-store.mjs | 278 ++++++++++- .../neatcontext/src/core/import-commands.mjs | 287 ++++++++++++ package.json | 10 +- .../neatcontext/commands/import.md | 108 ++++- .../src/claude/neatcontext-cli.mjs | 39 +- .../neatcontext/src/core/context-store.mjs | 278 ++++++++++- .../neatcontext/src/core/import-commands.mjs | 287 ++++++++++++ plugins/copilot/neatcontext/README.md | 3 +- .../copilot/neatcontext/commands/import.md | 112 ++++- .../src/copilot/neatcontext-cli.mjs | 39 +- .../neatcontext/src/core/context-store.mjs | 278 ++++++++++- .../neatcontext/src/core/import-commands.mjs | 287 ++++++++++++ plugins/kimi-code/neatcontext/README.md | 3 +- .../neatcontext/skills/import/SKILL.md | 63 ++- .../neatcontext/src/core/context-store.mjs | 278 ++++++++++- .../neatcontext/src/core/import-commands.mjs | 287 ++++++++++++ .../neatcontext/src/kimi/neatcontext-cli.mjs | 39 +- plugins/pi/neatcontext/README.md | 2 +- .../pi/neatcontext/extensions/neatcontext.js | 47 +- plugins/pi/neatcontext/package.json | 2 +- .../pi/neatcontext/src/core/context-store.mjs | 278 ++++++++++- .../neatcontext/src/core/import-commands.mjs | 287 ++++++++++++ plugins/pi/neatcontext/src/pi/runtime.mjs | 36 +- .../neatcontext/tests/pi-extension.test.mjs | 46 ++ .../pi/neatcontext/tests/pi-runtime.test.mjs | 26 ++ shared/core/context-store.mjs | 278 ++++++++++- shared/core/import-commands.mjs | 287 ++++++++++++ tests/import-hosts.test.mjs | 124 +++++ tests/import-reconcile.test.mjs | 435 ++++++++++++++++++ tools/sync-context-core.mjs | 1 + 34 files changed, 4422 insertions(+), 253 deletions(-) create mode 100644 codex-marketplace/plugins/neatcontext/src/core/import-commands.mjs create mode 100644 plugins/claude-code/neatcontext/src/core/import-commands.mjs create mode 100644 plugins/copilot/neatcontext/src/core/import-commands.mjs create mode 100644 plugins/kimi-code/neatcontext/src/core/import-commands.mjs create mode 100644 plugins/pi/neatcontext/src/core/import-commands.mjs create mode 100644 shared/core/import-commands.mjs create mode 100644 tests/import-hosts.test.mjs create mode 100644 tests/import-reconcile.test.mjs diff --git a/README.md b/README.md index 0c91e99..c3887ee 100644 --- a/README.md +++ b/README.md @@ -195,11 +195,31 @@ knowledge inside the context bundle. ### `/neatcontext:import [folder]` -Import a context bundle shared by someone else. Import creates your own -local copy and leaves the shared folder unchanged. +Import a context bundle shared by someone else. The shared folder is only ever +read: importing makes your own local copy and never writes back to it. + +A bundle you already imported can be imported again — that is how you pick up a +teammate's newer work. Import recognises the copy it gave you and says what +taking the update would cost, rather than building a second context beside it: + +- Nothing new in the bundle, and it says so. +- Your copy untouched since it arrived, so the newer one replaces it whole once + you confirm. It stays the same context, so a session connected to it picks the + material up immediately. +- Both copies changed, so the two are reconciled into one and previewed before + anything is written. Your work is never dropped in favour of theirs. +- A name already taken by a context with no shared origin, which import will not + guess about: it asks whether the two are the same context or a collision, and + waits. + +A context is never deleted to make room for an imported one. After importing, connect it with `/neatcontext:use `. +A merge lives only on your machine until you share it back with +`/neatcontext:export`. Left unshared, the same divergence has to be +reconciled again every time you import. + ### `/neatcontext:export [name] [folder]` Copy a context saved from a conversation into a self-contained bundle diff --git a/codex-marketplace/README.md b/codex-marketplace/README.md index 18c083d..09b18ed 100644 --- a/codex-marketplace/README.md +++ b/codex-marketplace/README.md @@ -157,11 +157,31 @@ knowledge inside the context bundle. ### `$neatcontext:import [folder]` -Import a context bundle shared by someone else. Importing creates your own -local copy and leaves the shared folder unchanged. +Import a context bundle shared by someone else. The shared folder is only ever +read: importing makes your own local copy and never writes back to it. + +A bundle you already imported can be imported again — that is how you pick up a +teammate's newer work. Import recognises the copy it gave you and says what +taking the update would cost, rather than building a second context beside it: + +- Nothing new in the bundle, and it says so. +- Your copy untouched since it arrived, so the newer one replaces it whole once + you confirm. It stays the same context, so a session connected to it picks the + material up immediately. +- Both copies changed, so the two are reconciled into one and previewed before + anything is written. Your work is never dropped in favour of theirs. +- A name already taken by a context with no shared origin, which import will not + guess about: it asks whether the two are the same context or a collision, and + waits. + +A context is never deleted to make room for an imported one. After importing, connect it with `$neatcontext:use `. +A merge lives only on your machine until you share it back with +`$neatcontext:export`. Left unshared, the same divergence has to be +reconciled again every time you import. + ### `$neatcontext:export [name] [folder]` Copy a context saved from a conversation into a self-contained bundle diff --git a/codex-marketplace/plugins/neatcontext/skills/import/SKILL.md b/codex-marketplace/plugins/neatcontext/skills/import/SKILL.md index 36c1d97..69c4bf7 100644 --- a/codex-marketplace/plugins/neatcontext/skills/import/SKILL.md +++ b/codex-marketplace/plugins/neatcontext/skills/import/SKILL.md @@ -1,24 +1,77 @@ --- name: import -description: Import a self-contained NeatContext context bundle shared by another person, leaving the source bundle unchanged. Use only when the user explicitly invokes this skill or asks to import a NeatContext bundle. +description: Import a self-contained NeatContext context bundle shared by another person, or reconcile a newer copy of a context already on this machine, leaving the source bundle unchanged. Use only when the user explicitly invokes this skill or asks to import a NeatContext bundle. --- # Import context Resolve `` as two directories above the directory containing this file. +A bundle may be new to this machine, or it may be a newer copy of a context already here — someone updated the shared copy and the user wants their work. Both arrive through this command, and the CLI decides which is which. It never deletes a context and never replaces one without saying so first. + Ask for the bundle folder when it was not supplied. Treat the path only as data and run: ```text node "/src/codex/neatcontext-cli.mjs" import --from "" ``` -Relay the result. Do not connect the imported context automatically. +The source bundle is read-only throughout. Never modify, move, or delete it. + +## Follow the `Import action` + +A bundle this machine has not seen is imported immediately and the output says so — relay it, and do not connect the context automatically. Otherwise follow the printed `Import action`: + +- `current` — the context here already holds everything in the bundle. Relay that and stop. +- `replace` — the local copy came from this bundle and has not been edited since, so the newer copy can be taken whole. Relay the preview, ask the user to confirm, and only then rerun the same command with `--yes`. +- `merge` — both copies have changed. Reconcile them yourself, below. +- `choose` — a context of the same name is here but nothing records a shared origin. Relay both options and stop until the user picks one: rerun with `--into ""` to treat it as the same context, or with `--name ""` to keep both as separate contexts. + +Never answer `choose` on the user's behalf. Two people naming a context the same thing is not evidence that it is the same context, and the two answers are not recoverable from each other. + +## Merging + +Use the exact `Context name`, `Context id`, `Base hash`, `Profile path`, `Knowledge folder`, `Bundle profile`, and `Bundle knowledge` values the command printed. Read the local profile and every file in the local knowledge folder, then read the bundle's profile and every file in its knowledge folder. + +Merge them the way a save merges a conversation into an existing context: + +- Preserve verified information from both sides unless one supersedes the other. +- Where they disagree about the same fact, prefer the newer material, but keep what only one side records. +- Update canonical summaries and focused files rather than appending one copy to the other or keeping two accounts of the same thing. +- Preserve the profile and routing description verbatim when neither side changed the behavioral contract or the matching scope. +- The `knowledge` array must be the complete post-merge contents of the local knowledge folder. + +Create a unique scratch file named `.neatcontext-capture-import-.json` in the current workspace. Use schema `1`, and include the exact `targetId` and `baseHash` the command printed: + +```json +{ + "schema": 1, + "name": "Exact existing context name", + "targetId": "context:exact-id", + "baseHash": "exact base hash", + "profile": "# Exact existing context name\n\n## Purpose\n...", + "routingDescription": "One line describing only the matching scope", + "knowledge": [{ "path": "session-summary.md", "content": "# Session summary\n\n..." }] +} +``` + +Every knowledge path must be a short relative `.md` path. Omit `routingQuestions` and `routingEntities` unless the merge genuinely widened what the context should be found by; omitting them leaves the stored lists alone. Omit `extensions` as well — an import never grants this machine the ability to reach anything new. + +Preview the merge, which changes nothing: + +```text +node "/src/codex/neatcontext-cli.mjs" import --from "" --merged-from "" +``` -If its name already exists, ask for a different local name and rerun with: +Relay the preview and wait for confirmation. After confirmation, run: ```text -node "/src/codex/neatcontext-cli.mjs" import --from "" --name "" +node "/src/codex/neatcontext-cli.mjs" import --from "" --merged-from "" --yes --consume ``` -Never modify, move, or delete the source bundle. +The scratch file is removed only by that confirmed run, so a preview or a failure leaves it available for repair. If the context changed while drafting, resolve the target again and rebuild the merge from its new contents. + +## After it lands + +A replace and a merge both keep the context's identity — same id, same name, so a thread already connected to it reads the updated material immediately. Relay successful output as printed and never connect a context yourself. + +Point out, when a merge lands, that the merged material exists only on this machine until it is shared back with the export skill. Left unshared, the same divergence has to be reconciled again on every future import. diff --git a/codex-marketplace/plugins/neatcontext/src/codex/neatcontext-cli.mjs b/codex-marketplace/plugins/neatcontext/src/codex/neatcontext-cli.mjs index 6cae448..0878a94 100644 --- a/codex-marketplace/plugins/neatcontext/src/codex/neatcontext-cli.mjs +++ b/codex-marketplace/plugins/neatcontext/src/codex/neatcontext-cli.mjs @@ -8,7 +8,7 @@ // create --name --knowledge create a context (--profile-from ) // save-target [name] decide whether save creates or updates // save --from create or update from this conversation -// import --from import a portable conversation context +// import --from import a bundle, or reconcile one already here // export --to copy a saved context's bundle out for sharing // delete [--yes] delete a context // mode [auto|ask|manual] how the session may route itself between contexts @@ -29,7 +29,6 @@ import { deleteContext, exportContext, fingerprintContext, - importCapturedContext, listContexts, ContextError, listKnowledgeFiles, @@ -37,6 +36,7 @@ import { readProfileText, updateCapturedContext } from "../core/context-store.mjs"; +import { runImport } from "../core/import-commands.mjs"; import { addAlias, isCardStale, @@ -736,30 +736,17 @@ async function commandSave(flags) { } async function commandImport(flags) { - const source = typeof flags.from === "string" ? flags.from : ""; - const name = typeof flags.name === "string" ? flags.name : ""; - try { - const result = await importCapturedContext({ bundleFolder: source, name }); - await putCard(result.record.id, { - useWhen: result.routingDescription, - source: result.profileText - }).catch(() => undefined); - print(`Imported the "${result.record.name}" conversation context.`); - print(` Domain profile: ${result.record.profilePath}`); - print( - ` Knowledge folder: ${result.record.knowledgeFolder} ` + - `(${result.knowledgeFileCount} files)` - ); - print(` Local bundle: ${result.record.directory}`); - print(` Connect it with: $neatcontext:use ${result.record.name}`); - print(`The shared source folder (${source}) was left untouched.`); - } catch (error) { - if (error instanceof ContextError) { - print(error.message); - return; - } - throw error; - } + print( + await runImport({ + bundleFolder: typeof flags.from === "string" ? flags.from : "", + name: typeof flags.name === "string" ? flags.name : "", + into: typeof flags.into === "string" ? flags.into : "", + mergedFrom: typeof flags["merged-from"] === "string" ? flags["merged-from"] : "", + confirmed: flags.yes === true || flags.yes === "true", + consume: flags.consume === true || flags.consume === "true", + useCommand: "$neatcontext:use" + }) + ); } // The routing description is read from the card rather than the manifest: diff --git a/codex-marketplace/plugins/neatcontext/src/core/context-store.mjs b/codex-marketplace/plugins/neatcontext/src/core/context-store.mjs index c9e76b4..ec6e738 100644 --- a/codex-marketplace/plugins/neatcontext/src/core/context-store.mjs +++ b/codex-marketplace/plugins/neatcontext/src/core/context-store.mjs @@ -68,6 +68,36 @@ function slugify(name) { return slug || "context"; } +// Where an imported copy came from, and what it looked like the moment it +// landed here. +// +// `id` is the exporter's context id, and it is the lineage key: a later bundle +// from the same origin is recognised by it, so renaming either copy cannot +// break the link and two teams who picked the same name are never confused for +// each other. The rest is the baseline the next import is judged against — +// `revision` and `updatedAt` are theirs at the time, so a newer bundle can say +// how far they have moved, and `fingerprint` is this copy's own, so anything +// saved or hand-edited here since shows up as divergence. +// +// Read leniently, and absent is a meaningful answer: a copy imported before +// this existed has no baseline, which is treated as divergence rather than as +// a clean slate. That costs a merge review and never costs content. +function readImportLineage(parsed) { + const lineage = parsed?.importedFrom; + if (!lineage || typeof lineage !== "object" || typeof lineage.id !== "string") { + return null; + } + return { + id: lineage.id, + revision: + Number.isInteger(lineage.revision) && lineage.revision > 0 ? lineage.revision : null, + updatedAt: typeof lineage.updatedAt === "string" ? lineage.updatedAt : null, + fingerprint: typeof lineage.fingerprint === "string" ? lineage.fingerprint : null, + bundleFingerprint: + typeof lineage.bundleFingerprint === "string" ? lineage.bundleFingerprint : null + }; +} + function recordFor(directory, parsed) { const legacy = parsed?.schema === LEGACY_SCHEMA && parsed?.kind === "lite"; // `kind` is a schema 1 concept and means nothing at CONTEXT_SCHEMA, so a @@ -116,6 +146,7 @@ function recordFor(directory, parsed) { extensions: readExtensionDeclarations(parsed.extensions), capturedFrom: typeof parsed.capturedFrom === "string" ? parsed.capturedFrom : null, capturedFromConversation: isConversationCapture(parsed.capturedFrom), + importedFrom: readImportLineage(parsed), profilePath: path.join(directory, "profile.md"), createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : null, updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : null, @@ -818,10 +849,10 @@ export async function updateCapturedContext(capture) { } } -// A captured context is already an export bundle. Import reads only the -// portable, generated shape and creates a fresh local id, so a teammate can -// keep the shared folder unchanged and can rename the local copy if necessary. -export async function importCapturedContext({ bundleFolder, name }) { +// One reader for both halves of import: working out what a bundle would do to +// this machine, and then doing it. Those two must never disagree about what the +// bundle contains, so neither gets a parser of its own. +export async function readImportBundle(bundleFolder) { const supplied = (bundleFolder ?? "").trim(); if (supplied.length === 0) { throw new ContextError("A captured context bundle folder is required."); @@ -877,22 +908,239 @@ export async function importCapturedContext({ bundleFolder, name }) { }); } - return createCapturedContext({ - name: typeof name === "string" && name.trim().length > 0 ? name : manifest.name, - profile, - routingDescription: manifest.routingDescription, + return { source, manifest, profile, knowledge }; +} + +// The bundle's contents shaped as an update to a context already here. The +// local name is used rather than the bundle's on purpose: a rename made on +// either side is a local decision, and taking someone else's update must not +// quietly undo it. +function captureFromBundle(bundle, record) { + return { + targetId: record.id, + name: record.name, + profile: bundle.profile, + routingDescription: bundle.manifest.routingDescription, // The point of keeping these in the bundle: a teammate's copy is findable // by the same words as the original, without them rediscovering any of it. + routingQuestions: bundle.manifest.routingQuestions, + routingEntities: bundle.manifest.routingEntities, + knowledge: bundle.knowledge, + // What the bundle says it expects to reach, reduced to declarations. Import + // creates no binding for any of them, so what arrives can say what it wants + // and can run nothing until this machine's owner says otherwise. + extensions: readExtensionDeclarations(bundle.manifest.extensions) + }; +} + +// What a bundle holds, independent of the machine it came from. Revision and +// timestamps are left out deliberately: re-exporting the same material must not +// look like a change, because "have they moved?" is a question about content. +function fingerprintImportBundle(bundle) { + const { manifest } = bundle; + const hash = createHash("sha256"); + hash.update( + JSON.stringify({ + id: typeof manifest.id === "string" ? manifest.id : null, + name: manifest.name, + routingDescription: manifest.routingDescription ?? null, + routingQuestions: normalizeRoutingList(manifest.routingQuestions, MAX_ROUTING_QUESTIONS), + routingEntities: normalizeRoutingList(manifest.routingEntities, MAX_ROUTING_ENTITIES), + extensions: serializeExtensionDeclarations(readExtensionDeclarations(manifest.extensions)) + }) + ); + hash.update("\0profile\0"); + hash.update(bundle.profile); + for (const entry of bundle.knowledge) { + hash.update("\0knowledge\0"); + hash.update(entry.path); + hash.update("\0"); + hash.update(entry.content); + } + return hash.digest("hex"); +} + +// Stamps where a copy came from, as a second write once the bundle is in place. +// The fingerprint has to be taken from the finished context, so it cannot be +// part of the one atomic write that creates it. Losing the process between the +// two leaves the lineage absent, which the next import reads as divergence and +// answers with a merge — a review that costs time and never costs content. +// +// Only `importedFrom` is touched, and `fingerprintContext` does not hash it, so +// the fingerprint written here still describes the context afterwards. +// +// Exported so the give-up path is directly testable. It matters more than it +// looks: by the time this runs the import has already landed, so a failure here +// must cost the bookkeeping and never the context that was just written. +export async function recordImportLineage(record, bundle) { + const { manifest } = bundle; + if (typeof manifest?.id !== "string" || manifest.id.length === 0) { + return record; + } + const lineage = { + id: manifest.id, + revision: + Number.isInteger(manifest.revision) && manifest.revision > 0 ? manifest.revision : 1, + updatedAt: typeof manifest.updatedAt === "string" ? manifest.updatedAt : null, + fingerprint: await fingerprintContext(record), + // The other half of the baseline, and the one a merge depends on. A merged + // context deliberately matches neither side, so "did this copy change?" + // cannot decide whether there is anything left to take — only "did theirs?" + // can, and this is what answers it. + bundleFingerprint: fingerprintImportBundle(bundle) + }; + const manifestPath = path.join(record.directory, "context.json"); + const temporaryPath = path.join( + record.directory, + `.context-lineage-${randomBytes(6).toString("hex")}.json` + ); + try { + const stored = JSON.parse(await readFile(manifestPath, "utf8")); + const updated = { ...stored, importedFrom: lineage }; + await writeFile(temporaryPath, `${JSON.stringify(updated, null, 2)}\n`, "utf8"); + await rename(temporaryPath, manifestPath); + return recordFor(record.directory, updated); + } catch { + return record; + } finally { + await rm(temporaryPath, { force: true }).catch(() => undefined); + } +} + +// A captured context is already an export bundle. Import reads only the +// portable, generated shape and creates a fresh local id, so a teammate can +// keep the shared folder unchanged and can rename the local copy if necessary. +export async function importCapturedContext({ bundleFolder, name }) { + const bundle = await readImportBundle(bundleFolder); + const { manifest } = bundle; + const created = await createCapturedContext({ + name: typeof name === "string" && name.trim().length > 0 ? name : manifest.name, + profile: bundle.profile, + routingDescription: manifest.routingDescription, routingQuestions: manifest.routingQuestions, routingEntities: manifest.routingEntities, - knowledge, - // What the bundle says it expects to reach, reduced to declarations. The - // import creates no binding for any of them, so the imported context arrives - // able to say what it wants and unable to run anything until this machine's - // owner says otherwise. + knowledge: bundle.knowledge, extensions: readExtensionDeclarations(manifest.extensions), capturedFrom: manifest.capturedFrom }); + return { ...created, record: await recordImportLineage(created.record, bundle) }; +} + +// What a second bundle from the same origin should do to the copy already here. +// +// Nothing is written. This answers the only question that matters before an +// import can act — is this new, is it the same copy again, has it moved, and +// has this machine moved too — and the answer decides between creating, +// replacing in place, and asking the model to merge. +// +// Identity is never guessed from content. It comes from the lineage id, from +// the bundle being this very context exported and brought back, or from the +// user adopting a context with `into`. A bare name collision is reported as a +// choice rather than resolved, because two people naming a context the same +// thing is not evidence that it is the same context. +export async function resolveImportTarget({ bundleFolder, into }) { + const bundle = await readImportBundle(bundleFolder); + const localName = normalizeName(bundle.manifest.name); + const contexts = await listContexts(); + const bundleId = typeof bundle.manifest.id === "string" ? bundle.manifest.id : null; + const adopt = (into ?? "").trim(); + + // Adoption is the user supplying the identity the bundle could not prove — + // most often for a copy imported before lineage was recorded. It is taken as + // stated, and the write that follows stamps the lineage, so the assertion is + // needed once rather than at every later import. + const adopted = + adopt.length > 0 + ? (contexts.find( + (context) => + context.id === adopt || context.name.toLowerCase() === adopt.toLowerCase() + ) ?? null) + : null; + if (adopt.length > 0 && !adopted) { + throw new ContextError(`No context here is named "${adopt}".`); + } + + const lineage = + adopted ?? + (bundleId + ? contexts.find( + (context) => context.id === bundleId || context.importedFrom?.id === bundleId + ) + : null); + const named = contexts.find( + (context) => context.name.toLowerCase() === localName.toLowerCase() + ); + const record = lineage ?? named ?? null; + if (!record) { + return { action: "create", bundle, localName, record: null, matchedBy: null }; + } + + const baseHash = await fingerprintContext(record); + const preview = await prepareCapturedContextUpdate({ + ...captureFromBundle(bundle, record), + baseHash + }); + const matchedBy = adopted ? "adopted" : lineage ? "lineage" : "name"; + const base = { bundle, localName, record, matchedBy, baseHash, preview }; + + if (!lineage) { + return { ...base, action: "choose" }; + } + + // Asked in this order for a reason. Whether the bundle has anything new comes + // first, because after a merge the local copy matches neither side by design: + // its contents differ from the bundle permanently, and reading that as + // "behind" would offer to overwrite the merge with the same material it was + // built from, every single time. Nothing new upstream means nothing to do, + // whatever the two copies now look like. + const stillCurrent = + (typeof record.importedFrom?.bundleFingerprint === "string" && + record.importedFrom.bundleFingerprint === fingerprintImportBundle(bundle)) || + !preview.changed; + if (stillCurrent) { + return { ...base, action: "current" }; + } + + // They have moved, so this is about what taking it would cost here. No + // baseline means no way to prove this copy is untouched, and replacing an + // edited copy loses the edits — merge is the answer whenever it cannot be + // ruled out. + // + // A baseline left by a different origin does not count, which is what makes + // adoption safe: saying two contexts are the same does not make this copy's + // material disposable, and it came from somewhere else entirely. + const baseline = + record.importedFrom?.id === bundleId ? record.importedFrom.fingerprint : null; + const diverged = typeof baseline !== "string" || baseline !== baseHash; + return { ...base, action: diverged ? "merge" : "replace" }; +} + +// The fast-forward: this copy has not been touched since it arrived, so the +// bundle's contents replace it wholesale. It stays the same context — same id, +// same name, same connected sessions — because only its contents were ever +// stale. +export async function replaceContextFromBundle({ bundleFolder, targetId, baseHash }) { + const bundle = await readImportBundle(bundleFolder); + const record = await readContext(targetId); + if (!record) { + throw new ContextError("The context selected for this import no longer exists."); + } + const result = await updateCapturedContext({ + ...captureFromBundle(bundle, record), + baseHash, + updatedFrom: "import" + }); + return { ...result, record: await recordImportLineage(result.record, bundle) }; +} + +// Both copies moved, so the model reconciled them and this applies its work. +// The lineage is re-stamped from the bundle in the same breath: a merge that +// did not record which version it consumed would be re-offered, against the +// same stale baseline, on every later import. +export async function applyImportMerge({ bundleFolder, capture }) { + const bundle = await readImportBundle(bundleFolder); + const result = await updateCapturedContext({ ...capture, updatedFrom: "import" }); + return { ...result, record: await recordImportLineage(result.record, bundle) }; } // Rewrites only the declarations on a context's manifest, in place. This is the @@ -1007,6 +1255,10 @@ export async function exportContext({ record, destination, force = false, routin manifest.schema = CONTEXT_SCHEMA; manifest.profileFile = "profile.md"; delete manifest.kind; + // Lineage is this machine's bookkeeping about where its own copy came from, + // and its fingerprint describes a context that only exists here. It means + // nothing to whoever receives the bundle, whose import records its own. + delete manifest.importedFrom; // The last point at which this machine's copy becomes someone else's. Run // the declarations back through the whitelist here, so whatever a hand edit // may have added beside them — a command, an environment, a token — is not diff --git a/codex-marketplace/plugins/neatcontext/src/core/import-commands.mjs b/codex-marketplace/plugins/neatcontext/src/core/import-commands.mjs new file mode 100644 index 0000000..8a54c8b --- /dev/null +++ b/codex-marketplace/plugins/neatcontext/src/core/import-commands.mjs @@ -0,0 +1,287 @@ +// The import command, shared by every host plugin. +// +// Import used to have one outcome: create. That was right exactly once — the +// first time a bundle arrived. Every time after that, the bundle was a newer +// copy of something already here, and creating produced a second context that +// competed with the first during routing while the connected session went on +// reading the stale one. +// +// So import now resolves before it acts, and the interesting part is what it +// refuses to guess. Identity comes from recorded lineage, never from content or +// from a name two people happened to choose alike. Divergence is proven against +// a baseline taken when the copy landed, and an absent baseline counts as +// diverged. Whenever the safe answer cannot be established, the command reports +// and stops rather than writing. +// +// Rendering lives here too. The four hosts differ only in how a slash command +// is spelled, and that arrives as `useCommand`. + +import { readFile, rm } from "node:fs/promises"; +import path from "node:path"; +import { + applyImportMerge, + ContextError, + importCapturedContext, + previewCapturedContextUpdate, + replaceContextFromBundle, + resolveImportTarget +} from "./context-store.mjs"; +import { putCard } from "./routing.mjs"; + +function refreshCard(result) { + return putCard(result.record.id, { + useWhen: result.routingDescription, + source: result.profileText + }).catch(() => undefined); +} + +function changedFiles(lines, label, files) { + if (files.length === 0) return; + lines.push(` ${label}: ${files.join(", ")}`); +} + +// What taking the bundle whole would do to the copy here. The same shape the +// save preview prints, for the same reason: the user is about to approve a +// replacement and should see its extent first. +function describeChanges(lines, preview) { + lines.push(` Domain profile: ${preview.profileChanged ? "changed" : "unchanged"}`); + lines.push(` Routing description: ${preview.routingChanged ? "changed" : "unchanged"}`); + lines.push( + ` Knowledge files: ${preview.changes.added.length} added, ` + + `${preview.changes.updated.length} updated, ${preview.changes.removed.length} removed` + ); + changedFiles(lines, "Add", preview.changes.added); + changedFiles(lines, "Update", preview.changes.updated); + changedFiles(lines, "Remove", preview.changes.removed); +} + +// How far the other copy has moved since this one was taken. Only ever stated +// as the pair, because the two numbers are counters on different machines: once +// both sides have been edited they are not versions of each other, and the only +// honest reading is "they are here, you left from there". +// +// Stated only when the baseline actually describes this bundle. A context +// adopted into a lineage it did not come from has a revision recorded against +// somewhere else, and pairing the two numbers would invent a history. +function describeDistance(lines, record, bundle) { + const theirs = bundle.manifest.revision; + const taken = record.importedFrom?.revision; + if (record.importedFrom?.id !== bundle.manifest.id) return; + if (!Number.isInteger(theirs) || !Number.isInteger(taken)) return; + lines.push(` Their revision: ${theirs} (you last took revision ${taken})`); +} + +function describeImported(lines, result, source, useCommand) { + lines.push(`Imported the "${result.record.name}" conversation context.`); + lines.push(` Domain profile: ${result.record.profilePath}`); + lines.push( + ` Knowledge folder: ${result.record.knowledgeFolder} ` + + `(${result.knowledgeFileCount} files)` + ); + lines.push(` Local bundle: ${result.record.directory}`); + lines.push(` Connect it with: ${useCommand} ${result.record.name}`); + lines.push(`The shared source folder (${source}) was left untouched.`); +} + +function describeUpdated(lines, result, source, headline) { + lines.push(headline); + lines.push(` Domain profile: ${result.record.profilePath}`); + lines.push( + ` Knowledge folder: ${result.record.knowledgeFolder} ` + + `(${result.knowledgeFileCount} files)` + ); + describeChanges(lines, result); + lines.push( + "It is the same context it was, so any session connected to it now reads the " + + "updated material." + ); + lines.push(`The shared source folder (${source}) was left untouched.`); +} + +// Applying a merge the model has already written. This is the only path that +// takes content from neither side wholesale, so the preview is shown and +// confirmed exactly the way a save update is. +async function runMergedImport({ bundleFolder, mergedFrom, confirmed, consume }) { + const lines = []; + let capture; + try { + capture = JSON.parse(await readFile(mergedFrom, "utf8")); + } catch { + lines.push(`Could not read a valid merged capture JSON file at ${mergedFrom}.`); + return lines; + } + if (capture?.schema !== 1) { + lines.push("Unsupported merged capture schema. Expected schema 1."); + return lines; + } + if (typeof capture.targetId !== "string" || capture.targetId.length === 0) { + lines.push( + "A merged capture must carry the exact targetId and baseHash this import printed." + ); + return lines; + } + + const preview = await previewCapturedContextUpdate(capture); + if (!preview.changed) { + lines.push(`The merge does not change the "${preview.record.name}" context.`); + return lines; + } + if (!confirmed) { + lines.push(`Merge the bundle into the "${preview.record.name}" context?`); + describeChanges(lines, preview); + lines.push("Re-run this import with --yes to apply the merge."); + return lines; + } + + const result = await applyImportMerge({ bundleFolder, capture }); + await refreshCard(result); + // Only once it has landed, and only when asked. A preview must leave the + // draft where it is, and so must any failure, or a merge the model spent the + // conversation building would have to be rebuilt from nothing. + if (consume) await rm(mergedFrom, { force: true }).catch(() => undefined); + describeUpdated( + lines, + result, + path.resolve(bundleFolder), + `Merged the bundle into the "${result.record.name}" context.` + ); + return lines; +} + +export async function runImport({ + bundleFolder, + name = "", + into = "", + mergedFrom = "", + confirmed = false, + consume = false, + useCommand +}) { + const lines = []; + try { + if (mergedFrom.trim().length > 0) { + return ( + await runMergedImport({ bundleFolder, mergedFrom, confirmed, consume }) + ).join("\n"); + } + + // Resolved on the bundle's own identity, never on the name a fork would be + // given. Asking "what is already here from this bundle?" under a new name + // answers about the new name, which is nothing, and the duplicate the fork + // is about to sit beside would go unmentioned. + const forkName = name.trim(); + const resolved = await resolveImportTarget({ bundleFolder, into }); + const { bundle, record, preview } = resolved; + const source = bundle.source; + + // An explicit name is the one instruction that overrides the resolution: it + // says to keep both copies as separate contexts. Honoured, and named as the + // choice it is, because two contexts about the same subject will compete + // every time a session routes itself. + if (forkName.length > 0) { + const created = await importCapturedContext({ bundleFolder, name: forkName }); + await refreshCard(created); + // Only worth saying when the two really are copies of one bundle. A name + // that merely collided is a different context, and forking is the right + // answer rather than a cost to warn about. + if (record && resolved.matchedBy === "lineage") { + lines.push( + `Note: "${record.name}" is already a copy of this bundle. You now have two ` + + "separate contexts holding the same material, and both will be considered " + + "whenever a session routes itself." + ); + } + describeImported(lines, created, source, useCommand); + return lines.join("\n"); + } + + if (resolved.action === "create") { + const created = await importCapturedContext({ bundleFolder }); + await refreshCard(created); + describeImported(lines, created, source, useCommand); + return lines.join("\n"); + } + + if (resolved.action === "current") { + lines.push("Import action: current"); + lines.push(`"${record.name}" already holds everything in this bundle. Nothing to import.`); + return lines.join("\n"); + } + + // A name in common is not evidence of a common origin, and the two cases + // want opposite handling, so this is the one outcome that asks. `--into` + // adopts the local context as this bundle's copy; since no baseline against + // this bundle exists for it, adopting leads to a merge and never to a + // replacement. + if (resolved.action === "choose") { + lines.push("Import action: choose"); + lines.push( + `A context named "${record.name}" is already here, but nothing records that it ` + + "came from this bundle — it may be the same context imported before lineage " + + "was tracked, or it may be someone else's context that happens to share the name." + ); + lines.push("Taking the bundle whole would look like this:"); + describeChanges(lines, preview); + lines.push("Say which it is:"); + lines.push(` --into "${record.name}"`); + lines.push(" the same context — reconcile the bundle into it"); + lines.push(' --name ""'); + lines.push(" a different context — keep both, side by side"); + return lines.join("\n"); + } + + if (resolved.action === "merge") { + lines.push("Import action: merge"); + lines.push( + resolved.matchedBy === "adopted" + ? `"${record.name}" is being treated as this bundle's copy, and nothing here ` + + "records what the two once had in common. Taking the bundle whole would " + + "discard whatever only this copy holds, so the two have to be reconciled first." + : `"${record.name}" came from this bundle, and both copies have changed since. ` + + "Taking the bundle whole would discard the work saved here, so the two have " + + "to be reconciled first." + ); + describeDistance(lines, record, bundle); + lines.push(`Context name: ${record.name}`); + lines.push(`Context id: ${record.id}`); + lines.push(`Base hash: ${resolved.baseHash}`); + lines.push(`Profile path: ${record.profilePath}`); + lines.push(`Knowledge folder: ${record.knowledgeFolder}`); + lines.push(`Bundle profile: ${path.join(source, "profile.md")}`); + lines.push(`Bundle knowledge: ${path.join(source, "knowledge")}`); + lines.push("Merge both sides, then apply the result with --merged-from."); + return lines.join("\n"); + } + + if (!confirmed) { + lines.push("Import action: replace"); + lines.push( + `"${record.name}" came from this bundle and has not been edited here since, so ` + + "the newer copy can be taken whole." + ); + describeDistance(lines, record, bundle); + describeChanges(lines, preview); + lines.push("Re-run this import with --yes to take it."); + return lines.join("\n"); + } + + const result = await replaceContextFromBundle({ + bundleFolder, + targetId: record.id, + baseHash: resolved.baseHash + }); + await refreshCard(result); + describeUpdated( + lines, + result, + source, + `Updated the "${result.record.name}" context from the bundle.` + ); + return lines.join("\n"); + } catch (error) { + if (error instanceof ContextError) { + return error.message; + } + throw error; + } +} diff --git a/package.json b/package.json index a1a6298..0c892ff 100644 --- a/package.json +++ b/package.json @@ -11,12 +11,12 @@ "scripts": { "sync:evidence": "node tools/sync-conversation-evidence.mjs", "sync:context": "node tools/sync-context-core.mjs", - "check:claude": "node --check plugins/claude-code/neatcontext/src/core/context-store.mjs && node --check plugins/claude-code/neatcontext/src/core/extension-bindings.mjs && node --check plugins/claude-code/neatcontext/src/core/extension-commands.mjs && node --check plugins/claude-code/neatcontext/src/core/extension-runtime.mjs && node --check plugins/claude-code/neatcontext/src/core/extensions.mjs && node --check plugins/claude-code/neatcontext/src/core/mcp-stdio-client.mjs && node --check plugins/claude-code/neatcontext/src/core/local-state.mjs && node --check plugins/claude-code/neatcontext/src/core/host-session.mjs && node --check plugins/claude-code/neatcontext/src/core/conversation-evidence.mjs && node --check plugins/claude-code/neatcontext/src/core/routing.mjs && node --check plugins/claude-code/neatcontext/src/core/routing-search.mjs && node --check plugins/claude-code/neatcontext/src/core/routing-candidates.mjs && node --check plugins/claude-code/neatcontext/src/core/session-state.mjs && node --check plugins/claude-code/neatcontext/src/core/selection.mjs && node --check plugins/claude-code/neatcontext/src/core/session.mjs && node --check plugins/claude-code/neatcontext/src/core/storage-home.mjs && node --check plugins/claude-code/neatcontext/src/claude/conversation-evidence.mjs && node --check plugins/claude-code/neatcontext/src/claude/mcp-bridge.mjs && node --check plugins/claude-code/neatcontext/src/claude/neatcontext-cli.mjs && node --check plugins/claude-code/neatcontext/src/claude/session.mjs && node --check plugins/claude-code/neatcontext/hooks/session-start.mjs && node --check plugins/claude-code/neatcontext/hooks/stop.mjs && node --check plugins/claude-code/neatcontext/hooks/pre-compact.mjs", - "check:kimi": "node --check plugins/kimi-code/neatcontext/src/core/context-store.mjs && node --check plugins/kimi-code/neatcontext/src/core/extension-bindings.mjs && node --check plugins/kimi-code/neatcontext/src/core/extension-commands.mjs && node --check plugins/kimi-code/neatcontext/src/core/extension-runtime.mjs && node --check plugins/kimi-code/neatcontext/src/core/extensions.mjs && node --check plugins/kimi-code/neatcontext/src/core/mcp-stdio-client.mjs && node --check plugins/kimi-code/neatcontext/src/core/local-state.mjs && node --check plugins/kimi-code/neatcontext/src/core/conversation-evidence.mjs && node --check plugins/kimi-code/neatcontext/src/core/routing.mjs && node --check plugins/kimi-code/neatcontext/src/core/routing-search.mjs && node --check plugins/kimi-code/neatcontext/src/core/routing-candidates.mjs && node --check plugins/kimi-code/neatcontext/src/core/selection.mjs && node --check plugins/kimi-code/neatcontext/src/core/session.mjs && node --check plugins/kimi-code/neatcontext/src/core/storage-home.mjs && node --check plugins/kimi-code/neatcontext/src/kimi/mcp-bridge.mjs && node --check plugins/kimi-code/neatcontext/src/kimi/neatcontext-cli.mjs && node --check plugins/kimi-code/neatcontext/src/kimi/session.mjs", - "check:copilot": "node --check plugins/copilot/neatcontext/src/core/context-store.mjs && node --check plugins/copilot/neatcontext/src/core/extension-bindings.mjs && node --check plugins/copilot/neatcontext/src/core/extension-commands.mjs && node --check plugins/copilot/neatcontext/src/core/extension-runtime.mjs && node --check plugins/copilot/neatcontext/src/core/extensions.mjs && node --check plugins/copilot/neatcontext/src/core/mcp-stdio-client.mjs && node --check plugins/copilot/neatcontext/src/core/local-state.mjs && node --check plugins/copilot/neatcontext/src/core/conversation-evidence.mjs && node --check plugins/copilot/neatcontext/src/core/routing.mjs && node --check plugins/copilot/neatcontext/src/core/routing-search.mjs && node --check plugins/copilot/neatcontext/src/core/routing-candidates.mjs && node --check plugins/copilot/neatcontext/src/core/selection.mjs && node --check plugins/copilot/neatcontext/src/core/session.mjs && node --check plugins/copilot/neatcontext/src/core/storage-home.mjs && node --check plugins/copilot/neatcontext/src/copilot/mcp-bridge.mjs && node --check plugins/copilot/neatcontext/src/copilot/neatcontext-cli.mjs && node --check plugins/copilot/neatcontext/src/copilot/session.mjs", - "check:codex": "node --check codex-marketplace/plugins/neatcontext/src/core/context-store.mjs && node --check codex-marketplace/plugins/neatcontext/src/core/extension-bindings.mjs && node --check codex-marketplace/plugins/neatcontext/src/core/extension-commands.mjs && node --check codex-marketplace/plugins/neatcontext/src/core/extension-runtime.mjs && node --check codex-marketplace/plugins/neatcontext/src/core/extensions.mjs && node --check codex-marketplace/plugins/neatcontext/src/core/mcp-stdio-client.mjs && node --check codex-marketplace/plugins/neatcontext/src/core/local-state.mjs && node --check codex-marketplace/plugins/neatcontext/src/core/host-session.mjs && node --check codex-marketplace/plugins/neatcontext/src/core/conversation-evidence.mjs && node --check codex-marketplace/plugins/neatcontext/src/core/routing.mjs && node --check codex-marketplace/plugins/neatcontext/src/core/routing-search.mjs && node --check codex-marketplace/plugins/neatcontext/src/core/routing-candidates.mjs && node --check codex-marketplace/plugins/neatcontext/src/core/selection.mjs && node --check codex-marketplace/plugins/neatcontext/src/core/session.mjs && node --check codex-marketplace/plugins/neatcontext/src/core/storage-home.mjs && node --check codex-marketplace/plugins/neatcontext/src/codex/mcp-bridge.mjs && node --check codex-marketplace/plugins/neatcontext/src/codex/neatcontext-cli.mjs && node --check codex-marketplace/plugins/neatcontext/src/codex/session.mjs", + "check:claude": "node --check plugins/claude-code/neatcontext/src/core/context-store.mjs && node --check plugins/claude-code/neatcontext/src/core/extension-bindings.mjs && node --check plugins/claude-code/neatcontext/src/core/extension-commands.mjs && node --check plugins/claude-code/neatcontext/src/core/extension-runtime.mjs && node --check plugins/claude-code/neatcontext/src/core/extensions.mjs && node --check plugins/claude-code/neatcontext/src/core/import-commands.mjs && node --check plugins/claude-code/neatcontext/src/core/mcp-stdio-client.mjs && node --check plugins/claude-code/neatcontext/src/core/local-state.mjs && node --check plugins/claude-code/neatcontext/src/core/host-session.mjs && node --check plugins/claude-code/neatcontext/src/core/conversation-evidence.mjs && node --check plugins/claude-code/neatcontext/src/core/routing.mjs && node --check plugins/claude-code/neatcontext/src/core/routing-search.mjs && node --check plugins/claude-code/neatcontext/src/core/routing-candidates.mjs && node --check plugins/claude-code/neatcontext/src/core/session-state.mjs && node --check plugins/claude-code/neatcontext/src/core/selection.mjs && node --check plugins/claude-code/neatcontext/src/core/session.mjs && node --check plugins/claude-code/neatcontext/src/core/storage-home.mjs && node --check plugins/claude-code/neatcontext/src/claude/conversation-evidence.mjs && node --check plugins/claude-code/neatcontext/src/claude/mcp-bridge.mjs && node --check plugins/claude-code/neatcontext/src/claude/neatcontext-cli.mjs && node --check plugins/claude-code/neatcontext/src/claude/session.mjs && node --check plugins/claude-code/neatcontext/hooks/session-start.mjs && node --check plugins/claude-code/neatcontext/hooks/stop.mjs && node --check plugins/claude-code/neatcontext/hooks/pre-compact.mjs", + "check:kimi": "node --check plugins/kimi-code/neatcontext/src/core/context-store.mjs && node --check plugins/kimi-code/neatcontext/src/core/extension-bindings.mjs && node --check plugins/kimi-code/neatcontext/src/core/extension-commands.mjs && node --check plugins/kimi-code/neatcontext/src/core/extension-runtime.mjs && node --check plugins/kimi-code/neatcontext/src/core/extensions.mjs && node --check plugins/kimi-code/neatcontext/src/core/import-commands.mjs && node --check plugins/kimi-code/neatcontext/src/core/mcp-stdio-client.mjs && node --check plugins/kimi-code/neatcontext/src/core/local-state.mjs && node --check plugins/kimi-code/neatcontext/src/core/conversation-evidence.mjs && node --check plugins/kimi-code/neatcontext/src/core/routing.mjs && node --check plugins/kimi-code/neatcontext/src/core/routing-search.mjs && node --check plugins/kimi-code/neatcontext/src/core/routing-candidates.mjs && node --check plugins/kimi-code/neatcontext/src/core/selection.mjs && node --check plugins/kimi-code/neatcontext/src/core/session.mjs && node --check plugins/kimi-code/neatcontext/src/core/storage-home.mjs && node --check plugins/kimi-code/neatcontext/src/kimi/mcp-bridge.mjs && node --check plugins/kimi-code/neatcontext/src/kimi/neatcontext-cli.mjs && node --check plugins/kimi-code/neatcontext/src/kimi/session.mjs", + "check:copilot": "node --check plugins/copilot/neatcontext/src/core/context-store.mjs && node --check plugins/copilot/neatcontext/src/core/extension-bindings.mjs && node --check plugins/copilot/neatcontext/src/core/extension-commands.mjs && node --check plugins/copilot/neatcontext/src/core/extension-runtime.mjs && node --check plugins/copilot/neatcontext/src/core/extensions.mjs && node --check plugins/copilot/neatcontext/src/core/import-commands.mjs && node --check plugins/copilot/neatcontext/src/core/mcp-stdio-client.mjs && node --check plugins/copilot/neatcontext/src/core/local-state.mjs && node --check plugins/copilot/neatcontext/src/core/conversation-evidence.mjs && node --check plugins/copilot/neatcontext/src/core/routing.mjs && node --check plugins/copilot/neatcontext/src/core/routing-search.mjs && node --check plugins/copilot/neatcontext/src/core/routing-candidates.mjs && node --check plugins/copilot/neatcontext/src/core/selection.mjs && node --check plugins/copilot/neatcontext/src/core/session.mjs && node --check plugins/copilot/neatcontext/src/core/storage-home.mjs && node --check plugins/copilot/neatcontext/src/copilot/mcp-bridge.mjs && node --check plugins/copilot/neatcontext/src/copilot/neatcontext-cli.mjs && node --check plugins/copilot/neatcontext/src/copilot/session.mjs", + "check:codex": "node --check codex-marketplace/plugins/neatcontext/src/core/context-store.mjs && node --check codex-marketplace/plugins/neatcontext/src/core/extension-bindings.mjs && node --check codex-marketplace/plugins/neatcontext/src/core/extension-commands.mjs && node --check codex-marketplace/plugins/neatcontext/src/core/extension-runtime.mjs && node --check codex-marketplace/plugins/neatcontext/src/core/extensions.mjs && node --check codex-marketplace/plugins/neatcontext/src/core/import-commands.mjs && node --check codex-marketplace/plugins/neatcontext/src/core/mcp-stdio-client.mjs && node --check codex-marketplace/plugins/neatcontext/src/core/local-state.mjs && node --check codex-marketplace/plugins/neatcontext/src/core/host-session.mjs && node --check codex-marketplace/plugins/neatcontext/src/core/conversation-evidence.mjs && node --check codex-marketplace/plugins/neatcontext/src/core/routing.mjs && node --check codex-marketplace/plugins/neatcontext/src/core/routing-search.mjs && node --check codex-marketplace/plugins/neatcontext/src/core/routing-candidates.mjs && node --check codex-marketplace/plugins/neatcontext/src/core/selection.mjs && node --check codex-marketplace/plugins/neatcontext/src/core/session.mjs && node --check codex-marketplace/plugins/neatcontext/src/core/storage-home.mjs && node --check codex-marketplace/plugins/neatcontext/src/codex/mcp-bridge.mjs && node --check codex-marketplace/plugins/neatcontext/src/codex/neatcontext-cli.mjs && node --check codex-marketplace/plugins/neatcontext/src/codex/session.mjs", "check:pi": "npm --prefix plugins/pi/neatcontext run check", - "check": "node tools/sync-conversation-evidence.mjs --check && node tools/sync-context-core.mjs --check && node --check shared/core/conversation-evidence.mjs && node --check shared/core/context-store.mjs && node --check shared/core/extension-bindings.mjs && node --check shared/core/extension-commands.mjs && node --check shared/core/extension-runtime.mjs && node --check shared/core/extensions.mjs && node --check shared/core/mcp-stdio-client.mjs && node --check shared/core/local-state.mjs && node --check shared/core/routing.mjs && node --check shared/core/routing-search.mjs && node --check shared/core/routing-candidates.mjs && node --check shared/core/selection.mjs && node --check shared/core/storage-home.mjs && npm run check:claude && npm run check:kimi && npm run check:copilot && npm run check:codex && npm run check:pi && node --check tools/sync-conversation-evidence.mjs && node --check tools/sync-context-core.mjs && node --check tools/e2e-no-nudge.mjs && node --check tools/e2e-extensions.mjs && node --check tools/e2e-commands.mjs && node --check tools/diff-coverage.mjs", + "check": "node tools/sync-conversation-evidence.mjs --check && node tools/sync-context-core.mjs --check && node --check shared/core/conversation-evidence.mjs && node --check shared/core/context-store.mjs && node --check shared/core/extension-bindings.mjs && node --check shared/core/extension-commands.mjs && node --check shared/core/extension-runtime.mjs && node --check shared/core/extensions.mjs && node --check shared/core/import-commands.mjs && node --check shared/core/mcp-stdio-client.mjs && node --check shared/core/local-state.mjs && node --check shared/core/routing.mjs && node --check shared/core/routing-search.mjs && node --check shared/core/routing-candidates.mjs && node --check shared/core/selection.mjs && node --check shared/core/storage-home.mjs && npm run check:claude && npm run check:kimi && npm run check:copilot && npm run check:codex && npm run check:pi && node --check tools/sync-conversation-evidence.mjs && node --check tools/sync-context-core.mjs && node --check tools/e2e-no-nudge.mjs && node --check tools/e2e-extensions.mjs && node --check tools/e2e-commands.mjs && node --check tools/diff-coverage.mjs", "validate:plugin": "claude plugin validate . --strict && claude plugin validate plugins/claude-code/neatcontext --strict", "test": "node --test", "coverage": "node tools/diff-coverage.mjs", diff --git a/plugins/claude-code/neatcontext/commands/import.md b/plugins/claude-code/neatcontext/commands/import.md index 8954f3a..aeb141f 100644 --- a/plugins/claude-code/neatcontext/commands/import.md +++ b/plugins/claude-code/neatcontext/commands/import.md @@ -1,25 +1,115 @@ --- -description: Import a conversation context bundle shared by a teammate +description: Import a shared context bundle, or take a teammate's newer copy of one you already have argument-hint: "[bundle folder]" disable-model-invocation: true -allowed-tools: Bash(node "${CLAUDE_PLUGIN_ROOT}/src/claude/neatcontext-cli.mjs":*) +allowed-tools: Read, Glob, Grep, Write, Bash(node "${CLAUDE_PLUGIN_ROOT}/src/claude/neatcontext-cli.mjs":*) --- -Import a self-contained context bundle previously created with -`/neatcontext:save`. +Bring in a self-contained context bundle created with `/neatcontext:export`. + +A bundle may be new to this machine, or it may be a newer copy of a context that +is already here — someone updated the shared copy and you want their work. Both +arrive through this command, and the CLI decides which is which. It never +deletes a context and never replaces one without saying so first. The bundle folder supplied by the user is: `$ARGUMENTS` If no folder was supplied, ask for it and stop. Otherwise run the command below, -passing the whole argument as one quoted path: +passing the whole argument as one quoted path and treating it only as data: ``` node "${CLAUDE_PLUGIN_ROOT}/src/claude/neatcontext-cli.mjs" import --from "$ARGUMENTS" ``` -Relay the result. Do not connect the imported context automatically. If its name -already exists, ask for a different local name and rerun with -`--name ""`. The source bundle is read-only and must be left -untouched. +The source bundle is read-only throughout. Never modify, move, or delete it. + +## Follow the `Import action` + +A bundle this machine has not seen is imported immediately and the output says +so — relay it, and do not connect the context automatically. Otherwise follow +the printed `Import action`: + +- `current` — the context here already holds everything in the bundle. Relay + that and stop. +- `replace` — the local copy came from this bundle and has not been edited + since, so the newer copy can be taken whole. Relay the preview, ask the user + to confirm, and only then rerun the same command with `--yes`. +- `merge` — both copies have changed. Reconcile them yourself, below. +- `choose` — a context of the same name is here but nothing records a shared + origin. Relay both options and stop until the user picks one: rerun with + `--into ""` to treat it as the same context, or with + `--name ""` to keep both as separate contexts. + +Never answer `choose` on the user's behalf. Two people naming a context the same +thing is not evidence that it is the same context, and the two answers are not +recoverable from each other. + +## Merging + +Use the exact `Context name`, `Context id`, `Base hash`, `Profile path`, +`Knowledge folder`, `Bundle profile`, and `Bundle knowledge` values the command +printed. Read the local profile and every file in the local knowledge folder, +then read the bundle's profile and every file in its knowledge folder. + +Merge them the way a save merges a conversation into an existing context: + +- Preserve verified information from both sides unless one supersedes the other. +- Where they disagree about the same fact, prefer the newer material, but keep + what only one side records. +- Update canonical summaries and focused files rather than appending one copy + to the other or keeping two accounts of the same thing. +- Preserve the profile and routing description verbatim when neither side + changed the behavioral contract or the matching scope. +- The `knowledge` array must be the complete post-merge contents of the local + knowledge folder. + +Write one valid JSON file, with no surrounding code fence, to: + +`${CLAUDE_PROJECT_DIR}/.neatcontext-capture-import-${CLAUDE_SESSION_ID}.json` + +``` +{ + "schema": 1, + "name": "Exact existing context name", + "targetId": "context:exact-id", + "baseHash": "exact base hash", + "profile": "# Exact existing context name\n\n## Purpose\n...", + "routingDescription": "One line describing only the matching scope", + "knowledge": [ + { + "path": "session-summary.md", + "content": "# Session summary\n\n..." + } + ] +} +``` + +Every knowledge path must be a short relative `.md` path. Omit +`routingQuestions` and `routingEntities` unless the merge genuinely widened what +the context should be found by; omitting them leaves the stored lists alone. +Omit `extensions` as well — an import never grants this machine the ability to +reach anything new. + +Then preview the merge, which changes nothing: + +``` +node "${CLAUDE_PLUGIN_ROOT}/src/claude/neatcontext-cli.mjs" import --from "$ARGUMENTS" --merged-from "${CLAUDE_PROJECT_DIR}/.neatcontext-capture-import-${CLAUDE_SESSION_ID}.json" +``` + +Relay that preview and ask the user to confirm. Only after they confirm, rerun +the same command with `--yes --consume`. The scratch JSON is removed only by +that confirmed run, so a preview or a failure leaves it available for repair. If +the context changed while you were drafting, resolve the target again and +rebuild the merge from its new contents rather than reusing the stale file. + +## After it lands + +A replace and a merge both keep the context's identity — same id, same name, so +a session already connected to it reads the updated material immediately. +Relay the output as printed and never connect a context yourself. + +Point out, when a merge lands, that the merged material exists only on this +machine until it is shared back with `/neatcontext:export`. Left unshared, the +same divergence has to be reconciled again on every future import. diff --git a/plugins/claude-code/neatcontext/src/claude/neatcontext-cli.mjs b/plugins/claude-code/neatcontext/src/claude/neatcontext-cli.mjs index f663aa7..875e667 100644 --- a/plugins/claude-code/neatcontext/src/claude/neatcontext-cli.mjs +++ b/plugins/claude-code/neatcontext/src/claude/neatcontext-cli.mjs @@ -9,7 +9,7 @@ // save-target [name] decide whether save creates or updates // evidence [projection] inspect ephemeral, privacy-filtered save evidence // save --from create or update from this conversation -// import --from import a portable conversation context +// import --from import a bundle, or reconcile one already here // export --to copy a saved context's bundle out for sharing // delete [--yes] delete a context // mode [auto|ask|manual] how the session may route itself between contexts @@ -30,7 +30,6 @@ import { deleteContext, exportContext, fingerprintContext, - importCapturedContext, listContexts, ContextError, listKnowledgeFiles, @@ -38,6 +37,7 @@ import { readProfileText, updateCapturedContext } from "../core/context-store.mjs"; +import { runImport } from "../core/import-commands.mjs"; import { addAlias, isCardStale, @@ -808,30 +808,17 @@ async function commandSave(flags) { } async function commandImport(flags) { - const source = typeof flags.from === "string" ? flags.from : ""; - const name = typeof flags.name === "string" ? flags.name : ""; - try { - const result = await importCapturedContext({ bundleFolder: source, name }); - await putCard(result.record.id, { - useWhen: result.routingDescription, - source: result.profileText - }).catch(() => undefined); - print(`Imported the "${result.record.name}" conversation context.`); - print(` Domain profile: ${result.record.profilePath}`); - print( - ` Knowledge folder: ${result.record.knowledgeFolder} ` + - `(${result.knowledgeFileCount} files)` - ); - print(` Local bundle: ${result.record.directory}`); - print(` Connect it with: /neatcontext:use ${result.record.name}`); - print(`The shared source folder (${source}) was left untouched.`); - } catch (error) { - if (error instanceof ContextError) { - print(error.message); - return; - } - throw error; - } + print( + await runImport({ + bundleFolder: typeof flags.from === "string" ? flags.from : "", + name: typeof flags.name === "string" ? flags.name : "", + into: typeof flags.into === "string" ? flags.into : "", + mergedFrom: typeof flags["merged-from"] === "string" ? flags["merged-from"] : "", + confirmed: flags.yes === true || flags.yes === "true", + consume: flags.consume === true || flags.consume === "true", + useCommand: "/neatcontext:use" + }) + ); } // The routing description is read from the card rather than the manifest: diff --git a/plugins/claude-code/neatcontext/src/core/context-store.mjs b/plugins/claude-code/neatcontext/src/core/context-store.mjs index c9e76b4..ec6e738 100644 --- a/plugins/claude-code/neatcontext/src/core/context-store.mjs +++ b/plugins/claude-code/neatcontext/src/core/context-store.mjs @@ -68,6 +68,36 @@ function slugify(name) { return slug || "context"; } +// Where an imported copy came from, and what it looked like the moment it +// landed here. +// +// `id` is the exporter's context id, and it is the lineage key: a later bundle +// from the same origin is recognised by it, so renaming either copy cannot +// break the link and two teams who picked the same name are never confused for +// each other. The rest is the baseline the next import is judged against — +// `revision` and `updatedAt` are theirs at the time, so a newer bundle can say +// how far they have moved, and `fingerprint` is this copy's own, so anything +// saved or hand-edited here since shows up as divergence. +// +// Read leniently, and absent is a meaningful answer: a copy imported before +// this existed has no baseline, which is treated as divergence rather than as +// a clean slate. That costs a merge review and never costs content. +function readImportLineage(parsed) { + const lineage = parsed?.importedFrom; + if (!lineage || typeof lineage !== "object" || typeof lineage.id !== "string") { + return null; + } + return { + id: lineage.id, + revision: + Number.isInteger(lineage.revision) && lineage.revision > 0 ? lineage.revision : null, + updatedAt: typeof lineage.updatedAt === "string" ? lineage.updatedAt : null, + fingerprint: typeof lineage.fingerprint === "string" ? lineage.fingerprint : null, + bundleFingerprint: + typeof lineage.bundleFingerprint === "string" ? lineage.bundleFingerprint : null + }; +} + function recordFor(directory, parsed) { const legacy = parsed?.schema === LEGACY_SCHEMA && parsed?.kind === "lite"; // `kind` is a schema 1 concept and means nothing at CONTEXT_SCHEMA, so a @@ -116,6 +146,7 @@ function recordFor(directory, parsed) { extensions: readExtensionDeclarations(parsed.extensions), capturedFrom: typeof parsed.capturedFrom === "string" ? parsed.capturedFrom : null, capturedFromConversation: isConversationCapture(parsed.capturedFrom), + importedFrom: readImportLineage(parsed), profilePath: path.join(directory, "profile.md"), createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : null, updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : null, @@ -818,10 +849,10 @@ export async function updateCapturedContext(capture) { } } -// A captured context is already an export bundle. Import reads only the -// portable, generated shape and creates a fresh local id, so a teammate can -// keep the shared folder unchanged and can rename the local copy if necessary. -export async function importCapturedContext({ bundleFolder, name }) { +// One reader for both halves of import: working out what a bundle would do to +// this machine, and then doing it. Those two must never disagree about what the +// bundle contains, so neither gets a parser of its own. +export async function readImportBundle(bundleFolder) { const supplied = (bundleFolder ?? "").trim(); if (supplied.length === 0) { throw new ContextError("A captured context bundle folder is required."); @@ -877,22 +908,239 @@ export async function importCapturedContext({ bundleFolder, name }) { }); } - return createCapturedContext({ - name: typeof name === "string" && name.trim().length > 0 ? name : manifest.name, - profile, - routingDescription: manifest.routingDescription, + return { source, manifest, profile, knowledge }; +} + +// The bundle's contents shaped as an update to a context already here. The +// local name is used rather than the bundle's on purpose: a rename made on +// either side is a local decision, and taking someone else's update must not +// quietly undo it. +function captureFromBundle(bundle, record) { + return { + targetId: record.id, + name: record.name, + profile: bundle.profile, + routingDescription: bundle.manifest.routingDescription, // The point of keeping these in the bundle: a teammate's copy is findable // by the same words as the original, without them rediscovering any of it. + routingQuestions: bundle.manifest.routingQuestions, + routingEntities: bundle.manifest.routingEntities, + knowledge: bundle.knowledge, + // What the bundle says it expects to reach, reduced to declarations. Import + // creates no binding for any of them, so what arrives can say what it wants + // and can run nothing until this machine's owner says otherwise. + extensions: readExtensionDeclarations(bundle.manifest.extensions) + }; +} + +// What a bundle holds, independent of the machine it came from. Revision and +// timestamps are left out deliberately: re-exporting the same material must not +// look like a change, because "have they moved?" is a question about content. +function fingerprintImportBundle(bundle) { + const { manifest } = bundle; + const hash = createHash("sha256"); + hash.update( + JSON.stringify({ + id: typeof manifest.id === "string" ? manifest.id : null, + name: manifest.name, + routingDescription: manifest.routingDescription ?? null, + routingQuestions: normalizeRoutingList(manifest.routingQuestions, MAX_ROUTING_QUESTIONS), + routingEntities: normalizeRoutingList(manifest.routingEntities, MAX_ROUTING_ENTITIES), + extensions: serializeExtensionDeclarations(readExtensionDeclarations(manifest.extensions)) + }) + ); + hash.update("\0profile\0"); + hash.update(bundle.profile); + for (const entry of bundle.knowledge) { + hash.update("\0knowledge\0"); + hash.update(entry.path); + hash.update("\0"); + hash.update(entry.content); + } + return hash.digest("hex"); +} + +// Stamps where a copy came from, as a second write once the bundle is in place. +// The fingerprint has to be taken from the finished context, so it cannot be +// part of the one atomic write that creates it. Losing the process between the +// two leaves the lineage absent, which the next import reads as divergence and +// answers with a merge — a review that costs time and never costs content. +// +// Only `importedFrom` is touched, and `fingerprintContext` does not hash it, so +// the fingerprint written here still describes the context afterwards. +// +// Exported so the give-up path is directly testable. It matters more than it +// looks: by the time this runs the import has already landed, so a failure here +// must cost the bookkeeping and never the context that was just written. +export async function recordImportLineage(record, bundle) { + const { manifest } = bundle; + if (typeof manifest?.id !== "string" || manifest.id.length === 0) { + return record; + } + const lineage = { + id: manifest.id, + revision: + Number.isInteger(manifest.revision) && manifest.revision > 0 ? manifest.revision : 1, + updatedAt: typeof manifest.updatedAt === "string" ? manifest.updatedAt : null, + fingerprint: await fingerprintContext(record), + // The other half of the baseline, and the one a merge depends on. A merged + // context deliberately matches neither side, so "did this copy change?" + // cannot decide whether there is anything left to take — only "did theirs?" + // can, and this is what answers it. + bundleFingerprint: fingerprintImportBundle(bundle) + }; + const manifestPath = path.join(record.directory, "context.json"); + const temporaryPath = path.join( + record.directory, + `.context-lineage-${randomBytes(6).toString("hex")}.json` + ); + try { + const stored = JSON.parse(await readFile(manifestPath, "utf8")); + const updated = { ...stored, importedFrom: lineage }; + await writeFile(temporaryPath, `${JSON.stringify(updated, null, 2)}\n`, "utf8"); + await rename(temporaryPath, manifestPath); + return recordFor(record.directory, updated); + } catch { + return record; + } finally { + await rm(temporaryPath, { force: true }).catch(() => undefined); + } +} + +// A captured context is already an export bundle. Import reads only the +// portable, generated shape and creates a fresh local id, so a teammate can +// keep the shared folder unchanged and can rename the local copy if necessary. +export async function importCapturedContext({ bundleFolder, name }) { + const bundle = await readImportBundle(bundleFolder); + const { manifest } = bundle; + const created = await createCapturedContext({ + name: typeof name === "string" && name.trim().length > 0 ? name : manifest.name, + profile: bundle.profile, + routingDescription: manifest.routingDescription, routingQuestions: manifest.routingQuestions, routingEntities: manifest.routingEntities, - knowledge, - // What the bundle says it expects to reach, reduced to declarations. The - // import creates no binding for any of them, so the imported context arrives - // able to say what it wants and unable to run anything until this machine's - // owner says otherwise. + knowledge: bundle.knowledge, extensions: readExtensionDeclarations(manifest.extensions), capturedFrom: manifest.capturedFrom }); + return { ...created, record: await recordImportLineage(created.record, bundle) }; +} + +// What a second bundle from the same origin should do to the copy already here. +// +// Nothing is written. This answers the only question that matters before an +// import can act — is this new, is it the same copy again, has it moved, and +// has this machine moved too — and the answer decides between creating, +// replacing in place, and asking the model to merge. +// +// Identity is never guessed from content. It comes from the lineage id, from +// the bundle being this very context exported and brought back, or from the +// user adopting a context with `into`. A bare name collision is reported as a +// choice rather than resolved, because two people naming a context the same +// thing is not evidence that it is the same context. +export async function resolveImportTarget({ bundleFolder, into }) { + const bundle = await readImportBundle(bundleFolder); + const localName = normalizeName(bundle.manifest.name); + const contexts = await listContexts(); + const bundleId = typeof bundle.manifest.id === "string" ? bundle.manifest.id : null; + const adopt = (into ?? "").trim(); + + // Adoption is the user supplying the identity the bundle could not prove — + // most often for a copy imported before lineage was recorded. It is taken as + // stated, and the write that follows stamps the lineage, so the assertion is + // needed once rather than at every later import. + const adopted = + adopt.length > 0 + ? (contexts.find( + (context) => + context.id === adopt || context.name.toLowerCase() === adopt.toLowerCase() + ) ?? null) + : null; + if (adopt.length > 0 && !adopted) { + throw new ContextError(`No context here is named "${adopt}".`); + } + + const lineage = + adopted ?? + (bundleId + ? contexts.find( + (context) => context.id === bundleId || context.importedFrom?.id === bundleId + ) + : null); + const named = contexts.find( + (context) => context.name.toLowerCase() === localName.toLowerCase() + ); + const record = lineage ?? named ?? null; + if (!record) { + return { action: "create", bundle, localName, record: null, matchedBy: null }; + } + + const baseHash = await fingerprintContext(record); + const preview = await prepareCapturedContextUpdate({ + ...captureFromBundle(bundle, record), + baseHash + }); + const matchedBy = adopted ? "adopted" : lineage ? "lineage" : "name"; + const base = { bundle, localName, record, matchedBy, baseHash, preview }; + + if (!lineage) { + return { ...base, action: "choose" }; + } + + // Asked in this order for a reason. Whether the bundle has anything new comes + // first, because after a merge the local copy matches neither side by design: + // its contents differ from the bundle permanently, and reading that as + // "behind" would offer to overwrite the merge with the same material it was + // built from, every single time. Nothing new upstream means nothing to do, + // whatever the two copies now look like. + const stillCurrent = + (typeof record.importedFrom?.bundleFingerprint === "string" && + record.importedFrom.bundleFingerprint === fingerprintImportBundle(bundle)) || + !preview.changed; + if (stillCurrent) { + return { ...base, action: "current" }; + } + + // They have moved, so this is about what taking it would cost here. No + // baseline means no way to prove this copy is untouched, and replacing an + // edited copy loses the edits — merge is the answer whenever it cannot be + // ruled out. + // + // A baseline left by a different origin does not count, which is what makes + // adoption safe: saying two contexts are the same does not make this copy's + // material disposable, and it came from somewhere else entirely. + const baseline = + record.importedFrom?.id === bundleId ? record.importedFrom.fingerprint : null; + const diverged = typeof baseline !== "string" || baseline !== baseHash; + return { ...base, action: diverged ? "merge" : "replace" }; +} + +// The fast-forward: this copy has not been touched since it arrived, so the +// bundle's contents replace it wholesale. It stays the same context — same id, +// same name, same connected sessions — because only its contents were ever +// stale. +export async function replaceContextFromBundle({ bundleFolder, targetId, baseHash }) { + const bundle = await readImportBundle(bundleFolder); + const record = await readContext(targetId); + if (!record) { + throw new ContextError("The context selected for this import no longer exists."); + } + const result = await updateCapturedContext({ + ...captureFromBundle(bundle, record), + baseHash, + updatedFrom: "import" + }); + return { ...result, record: await recordImportLineage(result.record, bundle) }; +} + +// Both copies moved, so the model reconciled them and this applies its work. +// The lineage is re-stamped from the bundle in the same breath: a merge that +// did not record which version it consumed would be re-offered, against the +// same stale baseline, on every later import. +export async function applyImportMerge({ bundleFolder, capture }) { + const bundle = await readImportBundle(bundleFolder); + const result = await updateCapturedContext({ ...capture, updatedFrom: "import" }); + return { ...result, record: await recordImportLineage(result.record, bundle) }; } // Rewrites only the declarations on a context's manifest, in place. This is the @@ -1007,6 +1255,10 @@ export async function exportContext({ record, destination, force = false, routin manifest.schema = CONTEXT_SCHEMA; manifest.profileFile = "profile.md"; delete manifest.kind; + // Lineage is this machine's bookkeeping about where its own copy came from, + // and its fingerprint describes a context that only exists here. It means + // nothing to whoever receives the bundle, whose import records its own. + delete manifest.importedFrom; // The last point at which this machine's copy becomes someone else's. Run // the declarations back through the whitelist here, so whatever a hand edit // may have added beside them — a command, an environment, a token — is not diff --git a/plugins/claude-code/neatcontext/src/core/import-commands.mjs b/plugins/claude-code/neatcontext/src/core/import-commands.mjs new file mode 100644 index 0000000..8a54c8b --- /dev/null +++ b/plugins/claude-code/neatcontext/src/core/import-commands.mjs @@ -0,0 +1,287 @@ +// The import command, shared by every host plugin. +// +// Import used to have one outcome: create. That was right exactly once — the +// first time a bundle arrived. Every time after that, the bundle was a newer +// copy of something already here, and creating produced a second context that +// competed with the first during routing while the connected session went on +// reading the stale one. +// +// So import now resolves before it acts, and the interesting part is what it +// refuses to guess. Identity comes from recorded lineage, never from content or +// from a name two people happened to choose alike. Divergence is proven against +// a baseline taken when the copy landed, and an absent baseline counts as +// diverged. Whenever the safe answer cannot be established, the command reports +// and stops rather than writing. +// +// Rendering lives here too. The four hosts differ only in how a slash command +// is spelled, and that arrives as `useCommand`. + +import { readFile, rm } from "node:fs/promises"; +import path from "node:path"; +import { + applyImportMerge, + ContextError, + importCapturedContext, + previewCapturedContextUpdate, + replaceContextFromBundle, + resolveImportTarget +} from "./context-store.mjs"; +import { putCard } from "./routing.mjs"; + +function refreshCard(result) { + return putCard(result.record.id, { + useWhen: result.routingDescription, + source: result.profileText + }).catch(() => undefined); +} + +function changedFiles(lines, label, files) { + if (files.length === 0) return; + lines.push(` ${label}: ${files.join(", ")}`); +} + +// What taking the bundle whole would do to the copy here. The same shape the +// save preview prints, for the same reason: the user is about to approve a +// replacement and should see its extent first. +function describeChanges(lines, preview) { + lines.push(` Domain profile: ${preview.profileChanged ? "changed" : "unchanged"}`); + lines.push(` Routing description: ${preview.routingChanged ? "changed" : "unchanged"}`); + lines.push( + ` Knowledge files: ${preview.changes.added.length} added, ` + + `${preview.changes.updated.length} updated, ${preview.changes.removed.length} removed` + ); + changedFiles(lines, "Add", preview.changes.added); + changedFiles(lines, "Update", preview.changes.updated); + changedFiles(lines, "Remove", preview.changes.removed); +} + +// How far the other copy has moved since this one was taken. Only ever stated +// as the pair, because the two numbers are counters on different machines: once +// both sides have been edited they are not versions of each other, and the only +// honest reading is "they are here, you left from there". +// +// Stated only when the baseline actually describes this bundle. A context +// adopted into a lineage it did not come from has a revision recorded against +// somewhere else, and pairing the two numbers would invent a history. +function describeDistance(lines, record, bundle) { + const theirs = bundle.manifest.revision; + const taken = record.importedFrom?.revision; + if (record.importedFrom?.id !== bundle.manifest.id) return; + if (!Number.isInteger(theirs) || !Number.isInteger(taken)) return; + lines.push(` Their revision: ${theirs} (you last took revision ${taken})`); +} + +function describeImported(lines, result, source, useCommand) { + lines.push(`Imported the "${result.record.name}" conversation context.`); + lines.push(` Domain profile: ${result.record.profilePath}`); + lines.push( + ` Knowledge folder: ${result.record.knowledgeFolder} ` + + `(${result.knowledgeFileCount} files)` + ); + lines.push(` Local bundle: ${result.record.directory}`); + lines.push(` Connect it with: ${useCommand} ${result.record.name}`); + lines.push(`The shared source folder (${source}) was left untouched.`); +} + +function describeUpdated(lines, result, source, headline) { + lines.push(headline); + lines.push(` Domain profile: ${result.record.profilePath}`); + lines.push( + ` Knowledge folder: ${result.record.knowledgeFolder} ` + + `(${result.knowledgeFileCount} files)` + ); + describeChanges(lines, result); + lines.push( + "It is the same context it was, so any session connected to it now reads the " + + "updated material." + ); + lines.push(`The shared source folder (${source}) was left untouched.`); +} + +// Applying a merge the model has already written. This is the only path that +// takes content from neither side wholesale, so the preview is shown and +// confirmed exactly the way a save update is. +async function runMergedImport({ bundleFolder, mergedFrom, confirmed, consume }) { + const lines = []; + let capture; + try { + capture = JSON.parse(await readFile(mergedFrom, "utf8")); + } catch { + lines.push(`Could not read a valid merged capture JSON file at ${mergedFrom}.`); + return lines; + } + if (capture?.schema !== 1) { + lines.push("Unsupported merged capture schema. Expected schema 1."); + return lines; + } + if (typeof capture.targetId !== "string" || capture.targetId.length === 0) { + lines.push( + "A merged capture must carry the exact targetId and baseHash this import printed." + ); + return lines; + } + + const preview = await previewCapturedContextUpdate(capture); + if (!preview.changed) { + lines.push(`The merge does not change the "${preview.record.name}" context.`); + return lines; + } + if (!confirmed) { + lines.push(`Merge the bundle into the "${preview.record.name}" context?`); + describeChanges(lines, preview); + lines.push("Re-run this import with --yes to apply the merge."); + return lines; + } + + const result = await applyImportMerge({ bundleFolder, capture }); + await refreshCard(result); + // Only once it has landed, and only when asked. A preview must leave the + // draft where it is, and so must any failure, or a merge the model spent the + // conversation building would have to be rebuilt from nothing. + if (consume) await rm(mergedFrom, { force: true }).catch(() => undefined); + describeUpdated( + lines, + result, + path.resolve(bundleFolder), + `Merged the bundle into the "${result.record.name}" context.` + ); + return lines; +} + +export async function runImport({ + bundleFolder, + name = "", + into = "", + mergedFrom = "", + confirmed = false, + consume = false, + useCommand +}) { + const lines = []; + try { + if (mergedFrom.trim().length > 0) { + return ( + await runMergedImport({ bundleFolder, mergedFrom, confirmed, consume }) + ).join("\n"); + } + + // Resolved on the bundle's own identity, never on the name a fork would be + // given. Asking "what is already here from this bundle?" under a new name + // answers about the new name, which is nothing, and the duplicate the fork + // is about to sit beside would go unmentioned. + const forkName = name.trim(); + const resolved = await resolveImportTarget({ bundleFolder, into }); + const { bundle, record, preview } = resolved; + const source = bundle.source; + + // An explicit name is the one instruction that overrides the resolution: it + // says to keep both copies as separate contexts. Honoured, and named as the + // choice it is, because two contexts about the same subject will compete + // every time a session routes itself. + if (forkName.length > 0) { + const created = await importCapturedContext({ bundleFolder, name: forkName }); + await refreshCard(created); + // Only worth saying when the two really are copies of one bundle. A name + // that merely collided is a different context, and forking is the right + // answer rather than a cost to warn about. + if (record && resolved.matchedBy === "lineage") { + lines.push( + `Note: "${record.name}" is already a copy of this bundle. You now have two ` + + "separate contexts holding the same material, and both will be considered " + + "whenever a session routes itself." + ); + } + describeImported(lines, created, source, useCommand); + return lines.join("\n"); + } + + if (resolved.action === "create") { + const created = await importCapturedContext({ bundleFolder }); + await refreshCard(created); + describeImported(lines, created, source, useCommand); + return lines.join("\n"); + } + + if (resolved.action === "current") { + lines.push("Import action: current"); + lines.push(`"${record.name}" already holds everything in this bundle. Nothing to import.`); + return lines.join("\n"); + } + + // A name in common is not evidence of a common origin, and the two cases + // want opposite handling, so this is the one outcome that asks. `--into` + // adopts the local context as this bundle's copy; since no baseline against + // this bundle exists for it, adopting leads to a merge and never to a + // replacement. + if (resolved.action === "choose") { + lines.push("Import action: choose"); + lines.push( + `A context named "${record.name}" is already here, but nothing records that it ` + + "came from this bundle — it may be the same context imported before lineage " + + "was tracked, or it may be someone else's context that happens to share the name." + ); + lines.push("Taking the bundle whole would look like this:"); + describeChanges(lines, preview); + lines.push("Say which it is:"); + lines.push(` --into "${record.name}"`); + lines.push(" the same context — reconcile the bundle into it"); + lines.push(' --name ""'); + lines.push(" a different context — keep both, side by side"); + return lines.join("\n"); + } + + if (resolved.action === "merge") { + lines.push("Import action: merge"); + lines.push( + resolved.matchedBy === "adopted" + ? `"${record.name}" is being treated as this bundle's copy, and nothing here ` + + "records what the two once had in common. Taking the bundle whole would " + + "discard whatever only this copy holds, so the two have to be reconciled first." + : `"${record.name}" came from this bundle, and both copies have changed since. ` + + "Taking the bundle whole would discard the work saved here, so the two have " + + "to be reconciled first." + ); + describeDistance(lines, record, bundle); + lines.push(`Context name: ${record.name}`); + lines.push(`Context id: ${record.id}`); + lines.push(`Base hash: ${resolved.baseHash}`); + lines.push(`Profile path: ${record.profilePath}`); + lines.push(`Knowledge folder: ${record.knowledgeFolder}`); + lines.push(`Bundle profile: ${path.join(source, "profile.md")}`); + lines.push(`Bundle knowledge: ${path.join(source, "knowledge")}`); + lines.push("Merge both sides, then apply the result with --merged-from."); + return lines.join("\n"); + } + + if (!confirmed) { + lines.push("Import action: replace"); + lines.push( + `"${record.name}" came from this bundle and has not been edited here since, so ` + + "the newer copy can be taken whole." + ); + describeDistance(lines, record, bundle); + describeChanges(lines, preview); + lines.push("Re-run this import with --yes to take it."); + return lines.join("\n"); + } + + const result = await replaceContextFromBundle({ + bundleFolder, + targetId: record.id, + baseHash: resolved.baseHash + }); + await refreshCard(result); + describeUpdated( + lines, + result, + source, + `Updated the "${result.record.name}" context from the bundle.` + ); + return lines.join("\n"); + } catch (error) { + if (error instanceof ContextError) { + return error.message; + } + throw error; + } +} diff --git a/plugins/copilot/neatcontext/README.md b/plugins/copilot/neatcontext/README.md index dd9c247..58eec0c 100644 --- a/plugins/copilot/neatcontext/README.md +++ b/plugins/copilot/neatcontext/README.md @@ -38,7 +38,8 @@ copilot plugin install neatcontext@neatcontext - `/neatcontext:list` — list the contexts on this machine. - `/neatcontext:status` — show the selection and routing mode. - `/neatcontext:create` — create a context around an existing knowledge folder. -- `/neatcontext:import [folder]` — import a shared context bundle. +- `/neatcontext:import [folder]` — import a shared context bundle, or take a + teammate's newer copy of one you already have. - `/neatcontext:export [name] [folder]` — export a saved context as a shareable bundle. - `/neatcontext:delete [name or number]` — preview and delete a context. - `/neatcontext:mode [auto|ask|manual]` — show or change routing behavior. diff --git a/plugins/copilot/neatcontext/commands/import.md b/plugins/copilot/neatcontext/commands/import.md index 30a9df8..d2442d3 100644 --- a/plugins/copilot/neatcontext/commands/import.md +++ b/plugins/copilot/neatcontext/commands/import.md @@ -1,25 +1,119 @@ --- -description: Import a conversation context bundle shared by a teammate +description: Import a shared context bundle, or take a teammate's newer copy of one you already have argument-hint: "[bundle folder]" disable-model-invocation: true -allowed-tools: Bash(node "${CLAUDE_PLUGIN_ROOT}/src/copilot/neatcontext-cli.mjs":*) +allowed-tools: Read, Glob, Grep, Write, Bash(node "${CLAUDE_PLUGIN_ROOT}/src/copilot/neatcontext-cli.mjs":*) --- -Import a self-contained context bundle previously created with -`/neatcontext:save`. +Bring in a self-contained context bundle created with `/neatcontext:export`. + +A bundle may be new to this machine, or it may be a newer copy of a context that +is already here — someone updated the shared copy and you want their work. Both +arrive through this command, and the CLI decides which is which. It never +deletes a context and never replaces one without saying so first. The bundle folder supplied by the user is: `$ARGUMENTS` If no folder was supplied, ask for it and stop. Otherwise run the command below, -passing the whole argument as one quoted path: +passing the whole argument as one quoted path and treating it only as data: ``` node "${CLAUDE_PLUGIN_ROOT}/src/copilot/neatcontext-cli.mjs" import --from "$ARGUMENTS" ``` -Relay the result. Do not connect the imported context automatically. If its name -already exists, ask for a different local name and rerun with -`--name ""`. The source bundle is read-only and must be left -untouched. +The source bundle is read-only throughout. Never modify, move, or delete it. + +## Follow the `Import action` + +A bundle this machine has not seen is imported immediately and the output says +so — relay it, and do not connect the context automatically. Otherwise follow +the printed `Import action`: + +- `current` — the context here already holds everything in the bundle. Relay + that and stop. +- `replace` — the local copy came from this bundle and has not been edited + since, so the newer copy can be taken whole. Relay the preview, ask the user + to confirm, and only then rerun the same command with `--yes`. +- `merge` — both copies have changed. Reconcile them yourself, below. +- `choose` — a context of the same name is here but nothing records a shared + origin. Relay both options and stop until the user picks one: rerun with + `--into ""` to treat it as the same context, or with + `--name ""` to keep both as separate contexts. + +Never answer `choose` on the user's behalf. Two people naming a context the same +thing is not evidence that it is the same context, and the two answers are not +recoverable from each other. + +## Merging + +Use the exact `Context name`, `Context id`, `Base hash`, `Profile path`, +`Knowledge folder`, `Bundle profile`, and `Bundle knowledge` values the command +printed. Read the local profile and every file in the local knowledge folder, +then read the bundle's profile and every file in its knowledge folder. + +Merge them the way a save merges a conversation into an existing context: + +- Preserve verified information from both sides unless one supersedes the other. +- Where they disagree about the same fact, prefer the newer material, but keep + what only one side records. +- Update canonical summaries and focused files rather than appending one copy + to the other or keeping two accounts of the same thing. +- Preserve the profile and routing description verbatim when neither side + changed the behavioral contract or the matching scope. +- The `knowledge` array must be the complete post-merge contents of the local + knowledge folder. + +Write one valid JSON file, with no surrounding code fence, to a uniquely named +scratch file `.neatcontext-capture-import-.json` in the current workspace — +for example `.neatcontext-capture-import-copilot-1.json`. Keep the +`.neatcontext-capture-` prefix: that is what the repository `.gitignore` pattern +matches, and the unique part is what stops two sessions in the same workspace +from overwriting each other mid-merge. Every command below refers to the path +you actually used as ``. + +``` +{ + "schema": 1, + "name": "Exact existing context name", + "targetId": "context:exact-id", + "baseHash": "exact base hash", + "profile": "# Exact existing context name\n\n## Purpose\n...", + "routingDescription": "One line describing only the matching scope", + "knowledge": [ + { + "path": "session-summary.md", + "content": "# Session summary\n\n..." + } + ] +} +``` + +Every knowledge path must be a short relative `.md` path. Omit +`routingQuestions` and `routingEntities` unless the merge genuinely widened what +the context should be found by; omitting them leaves the stored lists alone. +Omit `extensions` as well — an import never grants this machine the ability to +reach anything new. + +Then preview the merge, which changes nothing: + +``` +node "${CLAUDE_PLUGIN_ROOT}/src/copilot/neatcontext-cli.mjs" import --from "$ARGUMENTS" --merged-from "" +``` + +Relay that preview and ask the user to confirm. Only after they confirm, rerun +the same command with `--yes --consume`. The scratch JSON is removed only by +that confirmed run, so a preview or a failure leaves it available for repair. If the context +changed while you were drafting, resolve the target again and rebuild the merge +from its new contents rather than reusing the stale file. + +## After it lands + +A replace and a merge both keep the context's identity — same id, same name, so +a session already connected to it reads the updated material immediately. +Relay the output as printed and never connect a context yourself. + +Point out, when a merge lands, that the merged material exists only on this +machine until it is shared back with `/neatcontext:export`. Left unshared, the +same divergence has to be reconciled again on every future import. diff --git a/plugins/copilot/neatcontext/src/copilot/neatcontext-cli.mjs b/plugins/copilot/neatcontext/src/copilot/neatcontext-cli.mjs index fadc671..b8763d3 100644 --- a/plugins/copilot/neatcontext/src/copilot/neatcontext-cli.mjs +++ b/plugins/copilot/neatcontext/src/copilot/neatcontext-cli.mjs @@ -8,7 +8,7 @@ // create --name --knowledge create a context (--profile-from ) // save-target [name] decide whether save creates or updates // save --from create or update from this conversation -// import --from import a portable conversation context +// import --from import a bundle, or reconcile one already here // export --to copy a saved context's bundle out for sharing // delete [--yes] delete a context // mode [auto|ask|manual] how the session may route itself between contexts @@ -32,7 +32,6 @@ import { deleteContext, exportContext, fingerprintContext, - importCapturedContext, listContexts, ContextError, listKnowledgeFiles, @@ -40,6 +39,7 @@ import { readProfileText, updateCapturedContext } from "../core/context-store.mjs"; +import { runImport } from "../core/import-commands.mjs"; import { addAlias, isCardStale, @@ -704,30 +704,17 @@ async function commandSave(flags) { } async function commandImport(flags) { - const source = typeof flags.from === "string" ? flags.from : ""; - const name = typeof flags.name === "string" ? flags.name : ""; - try { - const result = await importCapturedContext({ bundleFolder: source, name }); - await putCard(result.record.id, { - useWhen: result.routingDescription, - source: result.profileText - }).catch(() => undefined); - print(`Imported the "${result.record.name}" conversation context.`); - print(` Domain profile: ${result.record.profilePath}`); - print( - ` Knowledge folder: ${result.record.knowledgeFolder} ` + - `(${result.knowledgeFileCount} files)` - ); - print(` Local bundle: ${result.record.directory}`); - print(` Connect it with: /neatcontext:use ${result.record.name}`); - print(`The shared source folder (${source}) was left untouched.`); - } catch (error) { - if (error instanceof ContextError) { - print(error.message); - return; - } - throw error; - } + print( + await runImport({ + bundleFolder: typeof flags.from === "string" ? flags.from : "", + name: typeof flags.name === "string" ? flags.name : "", + into: typeof flags.into === "string" ? flags.into : "", + mergedFrom: typeof flags["merged-from"] === "string" ? flags["merged-from"] : "", + confirmed: flags.yes === true || flags.yes === "true", + consume: flags.consume === true || flags.consume === "true", + useCommand: "/neatcontext:use" + }) + ); } // The routing description is read from the card rather than the manifest: diff --git a/plugins/copilot/neatcontext/src/core/context-store.mjs b/plugins/copilot/neatcontext/src/core/context-store.mjs index c9e76b4..ec6e738 100644 --- a/plugins/copilot/neatcontext/src/core/context-store.mjs +++ b/plugins/copilot/neatcontext/src/core/context-store.mjs @@ -68,6 +68,36 @@ function slugify(name) { return slug || "context"; } +// Where an imported copy came from, and what it looked like the moment it +// landed here. +// +// `id` is the exporter's context id, and it is the lineage key: a later bundle +// from the same origin is recognised by it, so renaming either copy cannot +// break the link and two teams who picked the same name are never confused for +// each other. The rest is the baseline the next import is judged against — +// `revision` and `updatedAt` are theirs at the time, so a newer bundle can say +// how far they have moved, and `fingerprint` is this copy's own, so anything +// saved or hand-edited here since shows up as divergence. +// +// Read leniently, and absent is a meaningful answer: a copy imported before +// this existed has no baseline, which is treated as divergence rather than as +// a clean slate. That costs a merge review and never costs content. +function readImportLineage(parsed) { + const lineage = parsed?.importedFrom; + if (!lineage || typeof lineage !== "object" || typeof lineage.id !== "string") { + return null; + } + return { + id: lineage.id, + revision: + Number.isInteger(lineage.revision) && lineage.revision > 0 ? lineage.revision : null, + updatedAt: typeof lineage.updatedAt === "string" ? lineage.updatedAt : null, + fingerprint: typeof lineage.fingerprint === "string" ? lineage.fingerprint : null, + bundleFingerprint: + typeof lineage.bundleFingerprint === "string" ? lineage.bundleFingerprint : null + }; +} + function recordFor(directory, parsed) { const legacy = parsed?.schema === LEGACY_SCHEMA && parsed?.kind === "lite"; // `kind` is a schema 1 concept and means nothing at CONTEXT_SCHEMA, so a @@ -116,6 +146,7 @@ function recordFor(directory, parsed) { extensions: readExtensionDeclarations(parsed.extensions), capturedFrom: typeof parsed.capturedFrom === "string" ? parsed.capturedFrom : null, capturedFromConversation: isConversationCapture(parsed.capturedFrom), + importedFrom: readImportLineage(parsed), profilePath: path.join(directory, "profile.md"), createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : null, updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : null, @@ -818,10 +849,10 @@ export async function updateCapturedContext(capture) { } } -// A captured context is already an export bundle. Import reads only the -// portable, generated shape and creates a fresh local id, so a teammate can -// keep the shared folder unchanged and can rename the local copy if necessary. -export async function importCapturedContext({ bundleFolder, name }) { +// One reader for both halves of import: working out what a bundle would do to +// this machine, and then doing it. Those two must never disagree about what the +// bundle contains, so neither gets a parser of its own. +export async function readImportBundle(bundleFolder) { const supplied = (bundleFolder ?? "").trim(); if (supplied.length === 0) { throw new ContextError("A captured context bundle folder is required."); @@ -877,22 +908,239 @@ export async function importCapturedContext({ bundleFolder, name }) { }); } - return createCapturedContext({ - name: typeof name === "string" && name.trim().length > 0 ? name : manifest.name, - profile, - routingDescription: manifest.routingDescription, + return { source, manifest, profile, knowledge }; +} + +// The bundle's contents shaped as an update to a context already here. The +// local name is used rather than the bundle's on purpose: a rename made on +// either side is a local decision, and taking someone else's update must not +// quietly undo it. +function captureFromBundle(bundle, record) { + return { + targetId: record.id, + name: record.name, + profile: bundle.profile, + routingDescription: bundle.manifest.routingDescription, // The point of keeping these in the bundle: a teammate's copy is findable // by the same words as the original, without them rediscovering any of it. + routingQuestions: bundle.manifest.routingQuestions, + routingEntities: bundle.manifest.routingEntities, + knowledge: bundle.knowledge, + // What the bundle says it expects to reach, reduced to declarations. Import + // creates no binding for any of them, so what arrives can say what it wants + // and can run nothing until this machine's owner says otherwise. + extensions: readExtensionDeclarations(bundle.manifest.extensions) + }; +} + +// What a bundle holds, independent of the machine it came from. Revision and +// timestamps are left out deliberately: re-exporting the same material must not +// look like a change, because "have they moved?" is a question about content. +function fingerprintImportBundle(bundle) { + const { manifest } = bundle; + const hash = createHash("sha256"); + hash.update( + JSON.stringify({ + id: typeof manifest.id === "string" ? manifest.id : null, + name: manifest.name, + routingDescription: manifest.routingDescription ?? null, + routingQuestions: normalizeRoutingList(manifest.routingQuestions, MAX_ROUTING_QUESTIONS), + routingEntities: normalizeRoutingList(manifest.routingEntities, MAX_ROUTING_ENTITIES), + extensions: serializeExtensionDeclarations(readExtensionDeclarations(manifest.extensions)) + }) + ); + hash.update("\0profile\0"); + hash.update(bundle.profile); + for (const entry of bundle.knowledge) { + hash.update("\0knowledge\0"); + hash.update(entry.path); + hash.update("\0"); + hash.update(entry.content); + } + return hash.digest("hex"); +} + +// Stamps where a copy came from, as a second write once the bundle is in place. +// The fingerprint has to be taken from the finished context, so it cannot be +// part of the one atomic write that creates it. Losing the process between the +// two leaves the lineage absent, which the next import reads as divergence and +// answers with a merge — a review that costs time and never costs content. +// +// Only `importedFrom` is touched, and `fingerprintContext` does not hash it, so +// the fingerprint written here still describes the context afterwards. +// +// Exported so the give-up path is directly testable. It matters more than it +// looks: by the time this runs the import has already landed, so a failure here +// must cost the bookkeeping and never the context that was just written. +export async function recordImportLineage(record, bundle) { + const { manifest } = bundle; + if (typeof manifest?.id !== "string" || manifest.id.length === 0) { + return record; + } + const lineage = { + id: manifest.id, + revision: + Number.isInteger(manifest.revision) && manifest.revision > 0 ? manifest.revision : 1, + updatedAt: typeof manifest.updatedAt === "string" ? manifest.updatedAt : null, + fingerprint: await fingerprintContext(record), + // The other half of the baseline, and the one a merge depends on. A merged + // context deliberately matches neither side, so "did this copy change?" + // cannot decide whether there is anything left to take — only "did theirs?" + // can, and this is what answers it. + bundleFingerprint: fingerprintImportBundle(bundle) + }; + const manifestPath = path.join(record.directory, "context.json"); + const temporaryPath = path.join( + record.directory, + `.context-lineage-${randomBytes(6).toString("hex")}.json` + ); + try { + const stored = JSON.parse(await readFile(manifestPath, "utf8")); + const updated = { ...stored, importedFrom: lineage }; + await writeFile(temporaryPath, `${JSON.stringify(updated, null, 2)}\n`, "utf8"); + await rename(temporaryPath, manifestPath); + return recordFor(record.directory, updated); + } catch { + return record; + } finally { + await rm(temporaryPath, { force: true }).catch(() => undefined); + } +} + +// A captured context is already an export bundle. Import reads only the +// portable, generated shape and creates a fresh local id, so a teammate can +// keep the shared folder unchanged and can rename the local copy if necessary. +export async function importCapturedContext({ bundleFolder, name }) { + const bundle = await readImportBundle(bundleFolder); + const { manifest } = bundle; + const created = await createCapturedContext({ + name: typeof name === "string" && name.trim().length > 0 ? name : manifest.name, + profile: bundle.profile, + routingDescription: manifest.routingDescription, routingQuestions: manifest.routingQuestions, routingEntities: manifest.routingEntities, - knowledge, - // What the bundle says it expects to reach, reduced to declarations. The - // import creates no binding for any of them, so the imported context arrives - // able to say what it wants and unable to run anything until this machine's - // owner says otherwise. + knowledge: bundle.knowledge, extensions: readExtensionDeclarations(manifest.extensions), capturedFrom: manifest.capturedFrom }); + return { ...created, record: await recordImportLineage(created.record, bundle) }; +} + +// What a second bundle from the same origin should do to the copy already here. +// +// Nothing is written. This answers the only question that matters before an +// import can act — is this new, is it the same copy again, has it moved, and +// has this machine moved too — and the answer decides between creating, +// replacing in place, and asking the model to merge. +// +// Identity is never guessed from content. It comes from the lineage id, from +// the bundle being this very context exported and brought back, or from the +// user adopting a context with `into`. A bare name collision is reported as a +// choice rather than resolved, because two people naming a context the same +// thing is not evidence that it is the same context. +export async function resolveImportTarget({ bundleFolder, into }) { + const bundle = await readImportBundle(bundleFolder); + const localName = normalizeName(bundle.manifest.name); + const contexts = await listContexts(); + const bundleId = typeof bundle.manifest.id === "string" ? bundle.manifest.id : null; + const adopt = (into ?? "").trim(); + + // Adoption is the user supplying the identity the bundle could not prove — + // most often for a copy imported before lineage was recorded. It is taken as + // stated, and the write that follows stamps the lineage, so the assertion is + // needed once rather than at every later import. + const adopted = + adopt.length > 0 + ? (contexts.find( + (context) => + context.id === adopt || context.name.toLowerCase() === adopt.toLowerCase() + ) ?? null) + : null; + if (adopt.length > 0 && !adopted) { + throw new ContextError(`No context here is named "${adopt}".`); + } + + const lineage = + adopted ?? + (bundleId + ? contexts.find( + (context) => context.id === bundleId || context.importedFrom?.id === bundleId + ) + : null); + const named = contexts.find( + (context) => context.name.toLowerCase() === localName.toLowerCase() + ); + const record = lineage ?? named ?? null; + if (!record) { + return { action: "create", bundle, localName, record: null, matchedBy: null }; + } + + const baseHash = await fingerprintContext(record); + const preview = await prepareCapturedContextUpdate({ + ...captureFromBundle(bundle, record), + baseHash + }); + const matchedBy = adopted ? "adopted" : lineage ? "lineage" : "name"; + const base = { bundle, localName, record, matchedBy, baseHash, preview }; + + if (!lineage) { + return { ...base, action: "choose" }; + } + + // Asked in this order for a reason. Whether the bundle has anything new comes + // first, because after a merge the local copy matches neither side by design: + // its contents differ from the bundle permanently, and reading that as + // "behind" would offer to overwrite the merge with the same material it was + // built from, every single time. Nothing new upstream means nothing to do, + // whatever the two copies now look like. + const stillCurrent = + (typeof record.importedFrom?.bundleFingerprint === "string" && + record.importedFrom.bundleFingerprint === fingerprintImportBundle(bundle)) || + !preview.changed; + if (stillCurrent) { + return { ...base, action: "current" }; + } + + // They have moved, so this is about what taking it would cost here. No + // baseline means no way to prove this copy is untouched, and replacing an + // edited copy loses the edits — merge is the answer whenever it cannot be + // ruled out. + // + // A baseline left by a different origin does not count, which is what makes + // adoption safe: saying two contexts are the same does not make this copy's + // material disposable, and it came from somewhere else entirely. + const baseline = + record.importedFrom?.id === bundleId ? record.importedFrom.fingerprint : null; + const diverged = typeof baseline !== "string" || baseline !== baseHash; + return { ...base, action: diverged ? "merge" : "replace" }; +} + +// The fast-forward: this copy has not been touched since it arrived, so the +// bundle's contents replace it wholesale. It stays the same context — same id, +// same name, same connected sessions — because only its contents were ever +// stale. +export async function replaceContextFromBundle({ bundleFolder, targetId, baseHash }) { + const bundle = await readImportBundle(bundleFolder); + const record = await readContext(targetId); + if (!record) { + throw new ContextError("The context selected for this import no longer exists."); + } + const result = await updateCapturedContext({ + ...captureFromBundle(bundle, record), + baseHash, + updatedFrom: "import" + }); + return { ...result, record: await recordImportLineage(result.record, bundle) }; +} + +// Both copies moved, so the model reconciled them and this applies its work. +// The lineage is re-stamped from the bundle in the same breath: a merge that +// did not record which version it consumed would be re-offered, against the +// same stale baseline, on every later import. +export async function applyImportMerge({ bundleFolder, capture }) { + const bundle = await readImportBundle(bundleFolder); + const result = await updateCapturedContext({ ...capture, updatedFrom: "import" }); + return { ...result, record: await recordImportLineage(result.record, bundle) }; } // Rewrites only the declarations on a context's manifest, in place. This is the @@ -1007,6 +1255,10 @@ export async function exportContext({ record, destination, force = false, routin manifest.schema = CONTEXT_SCHEMA; manifest.profileFile = "profile.md"; delete manifest.kind; + // Lineage is this machine's bookkeeping about where its own copy came from, + // and its fingerprint describes a context that only exists here. It means + // nothing to whoever receives the bundle, whose import records its own. + delete manifest.importedFrom; // The last point at which this machine's copy becomes someone else's. Run // the declarations back through the whitelist here, so whatever a hand edit // may have added beside them — a command, an environment, a token — is not diff --git a/plugins/copilot/neatcontext/src/core/import-commands.mjs b/plugins/copilot/neatcontext/src/core/import-commands.mjs new file mode 100644 index 0000000..8a54c8b --- /dev/null +++ b/plugins/copilot/neatcontext/src/core/import-commands.mjs @@ -0,0 +1,287 @@ +// The import command, shared by every host plugin. +// +// Import used to have one outcome: create. That was right exactly once — the +// first time a bundle arrived. Every time after that, the bundle was a newer +// copy of something already here, and creating produced a second context that +// competed with the first during routing while the connected session went on +// reading the stale one. +// +// So import now resolves before it acts, and the interesting part is what it +// refuses to guess. Identity comes from recorded lineage, never from content or +// from a name two people happened to choose alike. Divergence is proven against +// a baseline taken when the copy landed, and an absent baseline counts as +// diverged. Whenever the safe answer cannot be established, the command reports +// and stops rather than writing. +// +// Rendering lives here too. The four hosts differ only in how a slash command +// is spelled, and that arrives as `useCommand`. + +import { readFile, rm } from "node:fs/promises"; +import path from "node:path"; +import { + applyImportMerge, + ContextError, + importCapturedContext, + previewCapturedContextUpdate, + replaceContextFromBundle, + resolveImportTarget +} from "./context-store.mjs"; +import { putCard } from "./routing.mjs"; + +function refreshCard(result) { + return putCard(result.record.id, { + useWhen: result.routingDescription, + source: result.profileText + }).catch(() => undefined); +} + +function changedFiles(lines, label, files) { + if (files.length === 0) return; + lines.push(` ${label}: ${files.join(", ")}`); +} + +// What taking the bundle whole would do to the copy here. The same shape the +// save preview prints, for the same reason: the user is about to approve a +// replacement and should see its extent first. +function describeChanges(lines, preview) { + lines.push(` Domain profile: ${preview.profileChanged ? "changed" : "unchanged"}`); + lines.push(` Routing description: ${preview.routingChanged ? "changed" : "unchanged"}`); + lines.push( + ` Knowledge files: ${preview.changes.added.length} added, ` + + `${preview.changes.updated.length} updated, ${preview.changes.removed.length} removed` + ); + changedFiles(lines, "Add", preview.changes.added); + changedFiles(lines, "Update", preview.changes.updated); + changedFiles(lines, "Remove", preview.changes.removed); +} + +// How far the other copy has moved since this one was taken. Only ever stated +// as the pair, because the two numbers are counters on different machines: once +// both sides have been edited they are not versions of each other, and the only +// honest reading is "they are here, you left from there". +// +// Stated only when the baseline actually describes this bundle. A context +// adopted into a lineage it did not come from has a revision recorded against +// somewhere else, and pairing the two numbers would invent a history. +function describeDistance(lines, record, bundle) { + const theirs = bundle.manifest.revision; + const taken = record.importedFrom?.revision; + if (record.importedFrom?.id !== bundle.manifest.id) return; + if (!Number.isInteger(theirs) || !Number.isInteger(taken)) return; + lines.push(` Their revision: ${theirs} (you last took revision ${taken})`); +} + +function describeImported(lines, result, source, useCommand) { + lines.push(`Imported the "${result.record.name}" conversation context.`); + lines.push(` Domain profile: ${result.record.profilePath}`); + lines.push( + ` Knowledge folder: ${result.record.knowledgeFolder} ` + + `(${result.knowledgeFileCount} files)` + ); + lines.push(` Local bundle: ${result.record.directory}`); + lines.push(` Connect it with: ${useCommand} ${result.record.name}`); + lines.push(`The shared source folder (${source}) was left untouched.`); +} + +function describeUpdated(lines, result, source, headline) { + lines.push(headline); + lines.push(` Domain profile: ${result.record.profilePath}`); + lines.push( + ` Knowledge folder: ${result.record.knowledgeFolder} ` + + `(${result.knowledgeFileCount} files)` + ); + describeChanges(lines, result); + lines.push( + "It is the same context it was, so any session connected to it now reads the " + + "updated material." + ); + lines.push(`The shared source folder (${source}) was left untouched.`); +} + +// Applying a merge the model has already written. This is the only path that +// takes content from neither side wholesale, so the preview is shown and +// confirmed exactly the way a save update is. +async function runMergedImport({ bundleFolder, mergedFrom, confirmed, consume }) { + const lines = []; + let capture; + try { + capture = JSON.parse(await readFile(mergedFrom, "utf8")); + } catch { + lines.push(`Could not read a valid merged capture JSON file at ${mergedFrom}.`); + return lines; + } + if (capture?.schema !== 1) { + lines.push("Unsupported merged capture schema. Expected schema 1."); + return lines; + } + if (typeof capture.targetId !== "string" || capture.targetId.length === 0) { + lines.push( + "A merged capture must carry the exact targetId and baseHash this import printed." + ); + return lines; + } + + const preview = await previewCapturedContextUpdate(capture); + if (!preview.changed) { + lines.push(`The merge does not change the "${preview.record.name}" context.`); + return lines; + } + if (!confirmed) { + lines.push(`Merge the bundle into the "${preview.record.name}" context?`); + describeChanges(lines, preview); + lines.push("Re-run this import with --yes to apply the merge."); + return lines; + } + + const result = await applyImportMerge({ bundleFolder, capture }); + await refreshCard(result); + // Only once it has landed, and only when asked. A preview must leave the + // draft where it is, and so must any failure, or a merge the model spent the + // conversation building would have to be rebuilt from nothing. + if (consume) await rm(mergedFrom, { force: true }).catch(() => undefined); + describeUpdated( + lines, + result, + path.resolve(bundleFolder), + `Merged the bundle into the "${result.record.name}" context.` + ); + return lines; +} + +export async function runImport({ + bundleFolder, + name = "", + into = "", + mergedFrom = "", + confirmed = false, + consume = false, + useCommand +}) { + const lines = []; + try { + if (mergedFrom.trim().length > 0) { + return ( + await runMergedImport({ bundleFolder, mergedFrom, confirmed, consume }) + ).join("\n"); + } + + // Resolved on the bundle's own identity, never on the name a fork would be + // given. Asking "what is already here from this bundle?" under a new name + // answers about the new name, which is nothing, and the duplicate the fork + // is about to sit beside would go unmentioned. + const forkName = name.trim(); + const resolved = await resolveImportTarget({ bundleFolder, into }); + const { bundle, record, preview } = resolved; + const source = bundle.source; + + // An explicit name is the one instruction that overrides the resolution: it + // says to keep both copies as separate contexts. Honoured, and named as the + // choice it is, because two contexts about the same subject will compete + // every time a session routes itself. + if (forkName.length > 0) { + const created = await importCapturedContext({ bundleFolder, name: forkName }); + await refreshCard(created); + // Only worth saying when the two really are copies of one bundle. A name + // that merely collided is a different context, and forking is the right + // answer rather than a cost to warn about. + if (record && resolved.matchedBy === "lineage") { + lines.push( + `Note: "${record.name}" is already a copy of this bundle. You now have two ` + + "separate contexts holding the same material, and both will be considered " + + "whenever a session routes itself." + ); + } + describeImported(lines, created, source, useCommand); + return lines.join("\n"); + } + + if (resolved.action === "create") { + const created = await importCapturedContext({ bundleFolder }); + await refreshCard(created); + describeImported(lines, created, source, useCommand); + return lines.join("\n"); + } + + if (resolved.action === "current") { + lines.push("Import action: current"); + lines.push(`"${record.name}" already holds everything in this bundle. Nothing to import.`); + return lines.join("\n"); + } + + // A name in common is not evidence of a common origin, and the two cases + // want opposite handling, so this is the one outcome that asks. `--into` + // adopts the local context as this bundle's copy; since no baseline against + // this bundle exists for it, adopting leads to a merge and never to a + // replacement. + if (resolved.action === "choose") { + lines.push("Import action: choose"); + lines.push( + `A context named "${record.name}" is already here, but nothing records that it ` + + "came from this bundle — it may be the same context imported before lineage " + + "was tracked, or it may be someone else's context that happens to share the name." + ); + lines.push("Taking the bundle whole would look like this:"); + describeChanges(lines, preview); + lines.push("Say which it is:"); + lines.push(` --into "${record.name}"`); + lines.push(" the same context — reconcile the bundle into it"); + lines.push(' --name ""'); + lines.push(" a different context — keep both, side by side"); + return lines.join("\n"); + } + + if (resolved.action === "merge") { + lines.push("Import action: merge"); + lines.push( + resolved.matchedBy === "adopted" + ? `"${record.name}" is being treated as this bundle's copy, and nothing here ` + + "records what the two once had in common. Taking the bundle whole would " + + "discard whatever only this copy holds, so the two have to be reconciled first." + : `"${record.name}" came from this bundle, and both copies have changed since. ` + + "Taking the bundle whole would discard the work saved here, so the two have " + + "to be reconciled first." + ); + describeDistance(lines, record, bundle); + lines.push(`Context name: ${record.name}`); + lines.push(`Context id: ${record.id}`); + lines.push(`Base hash: ${resolved.baseHash}`); + lines.push(`Profile path: ${record.profilePath}`); + lines.push(`Knowledge folder: ${record.knowledgeFolder}`); + lines.push(`Bundle profile: ${path.join(source, "profile.md")}`); + lines.push(`Bundle knowledge: ${path.join(source, "knowledge")}`); + lines.push("Merge both sides, then apply the result with --merged-from."); + return lines.join("\n"); + } + + if (!confirmed) { + lines.push("Import action: replace"); + lines.push( + `"${record.name}" came from this bundle and has not been edited here since, so ` + + "the newer copy can be taken whole." + ); + describeDistance(lines, record, bundle); + describeChanges(lines, preview); + lines.push("Re-run this import with --yes to take it."); + return lines.join("\n"); + } + + const result = await replaceContextFromBundle({ + bundleFolder, + targetId: record.id, + baseHash: resolved.baseHash + }); + await refreshCard(result); + describeUpdated( + lines, + result, + source, + `Updated the "${result.record.name}" context from the bundle.` + ); + return lines.join("\n"); + } catch (error) { + if (error instanceof ContextError) { + return error.message; + } + throw error; + } +} diff --git a/plugins/kimi-code/neatcontext/README.md b/plugins/kimi-code/neatcontext/README.md index 0782d52..46dad15 100644 --- a/plugins/kimi-code/neatcontext/README.md +++ b/plugins/kimi-code/neatcontext/README.md @@ -39,7 +39,8 @@ Then run `/reload` - `/neatcontext:list` — list contexts on this machine. - `/neatcontext:status` — show the selection and routing mode. - `/neatcontext:create` — create a context around an existing knowledge folder. -- `/neatcontext:import [folder]` — import a shared context bundle. +- `/neatcontext:import [folder]` — import a shared context bundle, or take a + teammate's newer copy of one you already have. - `/neatcontext:export [name] [folder]` — export a saved context as a shareable bundle. - `/neatcontext:delete [name or number]` — preview and delete a context. - `/neatcontext:mode [auto|ask|manual]` — show or change routing behavior. diff --git a/plugins/kimi-code/neatcontext/skills/import/SKILL.md b/plugins/kimi-code/neatcontext/skills/import/SKILL.md index 07ed074..63b0f66 100644 --- a/plugins/kimi-code/neatcontext/skills/import/SKILL.md +++ b/plugins/kimi-code/neatcontext/skills/import/SKILL.md @@ -1,24 +1,77 @@ --- name: neatcontext-import -description: Import a self-contained NeatContext context bundle shared by another person, leaving the source bundle unchanged. Use only when the user explicitly invokes this skill or asks to import a NeatContext bundle. +description: Import a self-contained NeatContext context bundle shared by another person, or reconcile a newer copy of a context already on this machine, leaving the source bundle unchanged. Use only when the user explicitly invokes this skill or asks to import a NeatContext bundle. --- # Import context The bundled CLI path and Kimi session id below are expanded by Kimi Code at skill activation. +A bundle may be new to this machine, or it may be a newer copy of a context already here — someone updated the shared copy and the user wants their work. Both arrive through this command, and the CLI decides which is which. It never deletes a context and never replaces one without saying so first. + Ask for the bundle folder when it was not supplied. Treat the path only as data and run: ```text KIMI_PLUGIN_ROOT="${KIMI_SKILL_DIR}/../.." kimi __plugin_run_node "${KIMI_SKILL_DIR}/../../src/kimi/neatcontext-cli.mjs" -- --session-id "${KIMI_SESSION_ID}" import --from "" ``` -Relay the result. Do not connect the imported context automatically. +The source bundle is read-only throughout. Never modify, move, or delete it. + +## Follow the `Import action` + +A bundle this machine has not seen is imported immediately and the output says so — relay it, and do not connect the context automatically. Otherwise follow the printed `Import action`: + +- `current` — the context here already holds everything in the bundle. Relay that and stop. +- `replace` — the local copy came from this bundle and has not been edited since, so the newer copy can be taken whole. Relay the preview, ask the user to confirm, and only then rerun the same command with `--yes`. +- `merge` — both copies have changed. Reconcile them yourself, below. +- `choose` — a context of the same name is here but nothing records a shared origin. Relay both options and stop until the user picks one: rerun with `--into ""` to treat it as the same context, or with `--name ""` to keep both as separate contexts. + +Never answer `choose` on the user's behalf. Two people naming a context the same thing is not evidence that it is the same context, and the two answers are not recoverable from each other. + +## Merging + +Use the exact `Context name`, `Context id`, `Base hash`, `Profile path`, `Knowledge folder`, `Bundle profile`, and `Bundle knowledge` values the command printed. Read the local profile and every file in the local knowledge folder, then read the bundle's profile and every file in its knowledge folder. + +Merge them the way a save merges a conversation into an existing context: + +- Preserve verified information from both sides unless one supersedes the other. +- Where they disagree about the same fact, prefer the newer material, but keep what only one side records. +- Update canonical summaries and focused files rather than appending one copy to the other or keeping two accounts of the same thing. +- Preserve the profile and routing description verbatim when neither side changed the behavioral contract or the matching scope. +- The `knowledge` array must be the complete post-merge contents of the local knowledge folder. + +Create a unique scratch file named `.neatcontext-capture-import-.json` in the current workspace. Use schema `1`, and include the exact `targetId` and `baseHash` the command printed: + +```json +{ + "schema": 1, + "name": "Exact existing context name", + "targetId": "context:exact-id", + "baseHash": "exact base hash", + "profile": "# Exact existing context name\n\n## Purpose\n...", + "routingDescription": "One line describing only the matching scope", + "knowledge": [{ "path": "session-summary.md", "content": "# Session summary\n\n..." }] +} +``` + +Every knowledge path must be a short relative `.md` path. Omit `routingQuestions` and `routingEntities` unless the merge genuinely widened what the context should be found by; omitting them leaves the stored lists alone. Omit `extensions` as well — an import never grants this machine the ability to reach anything new. + +Preview the merge, which changes nothing: + +```text +KIMI_PLUGIN_ROOT="${KIMI_SKILL_DIR}/../.." kimi __plugin_run_node "${KIMI_SKILL_DIR}/../../src/kimi/neatcontext-cli.mjs" -- --session-id "${KIMI_SESSION_ID}" import --from "" --merged-from "" +``` -If its name already exists, ask for a different local name and rerun with: +Relay the preview and wait for confirmation. After confirmation, run: ```text -KIMI_PLUGIN_ROOT="${KIMI_SKILL_DIR}/../.." kimi __plugin_run_node "${KIMI_SKILL_DIR}/../../src/kimi/neatcontext-cli.mjs" -- --session-id "${KIMI_SESSION_ID}" import --from "" --name "" +KIMI_PLUGIN_ROOT="${KIMI_SKILL_DIR}/../.." kimi __plugin_run_node "${KIMI_SKILL_DIR}/../../src/kimi/neatcontext-cli.mjs" -- --session-id "${KIMI_SESSION_ID}" import --from "" --merged-from "" --yes --consume ``` -Never modify, move, or delete the source bundle. +The scratch file is removed only by that confirmed run, so a preview or a failure leaves it available for repair. If the context changed while drafting, resolve the target again and rebuild the merge from its new contents. + +## After it lands + +A replace and a merge both keep the context's identity — same id, same name, so a session already connected to it reads the updated material immediately. Relay successful output as printed and never connect a context yourself. + +Point out, when a merge lands, that the merged material exists only on this machine until it is shared back with `/neatcontext:export`. Left unshared, the same divergence has to be reconciled again on every future import. diff --git a/plugins/kimi-code/neatcontext/src/core/context-store.mjs b/plugins/kimi-code/neatcontext/src/core/context-store.mjs index c9e76b4..ec6e738 100644 --- a/plugins/kimi-code/neatcontext/src/core/context-store.mjs +++ b/plugins/kimi-code/neatcontext/src/core/context-store.mjs @@ -68,6 +68,36 @@ function slugify(name) { return slug || "context"; } +// Where an imported copy came from, and what it looked like the moment it +// landed here. +// +// `id` is the exporter's context id, and it is the lineage key: a later bundle +// from the same origin is recognised by it, so renaming either copy cannot +// break the link and two teams who picked the same name are never confused for +// each other. The rest is the baseline the next import is judged against — +// `revision` and `updatedAt` are theirs at the time, so a newer bundle can say +// how far they have moved, and `fingerprint` is this copy's own, so anything +// saved or hand-edited here since shows up as divergence. +// +// Read leniently, and absent is a meaningful answer: a copy imported before +// this existed has no baseline, which is treated as divergence rather than as +// a clean slate. That costs a merge review and never costs content. +function readImportLineage(parsed) { + const lineage = parsed?.importedFrom; + if (!lineage || typeof lineage !== "object" || typeof lineage.id !== "string") { + return null; + } + return { + id: lineage.id, + revision: + Number.isInteger(lineage.revision) && lineage.revision > 0 ? lineage.revision : null, + updatedAt: typeof lineage.updatedAt === "string" ? lineage.updatedAt : null, + fingerprint: typeof lineage.fingerprint === "string" ? lineage.fingerprint : null, + bundleFingerprint: + typeof lineage.bundleFingerprint === "string" ? lineage.bundleFingerprint : null + }; +} + function recordFor(directory, parsed) { const legacy = parsed?.schema === LEGACY_SCHEMA && parsed?.kind === "lite"; // `kind` is a schema 1 concept and means nothing at CONTEXT_SCHEMA, so a @@ -116,6 +146,7 @@ function recordFor(directory, parsed) { extensions: readExtensionDeclarations(parsed.extensions), capturedFrom: typeof parsed.capturedFrom === "string" ? parsed.capturedFrom : null, capturedFromConversation: isConversationCapture(parsed.capturedFrom), + importedFrom: readImportLineage(parsed), profilePath: path.join(directory, "profile.md"), createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : null, updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : null, @@ -818,10 +849,10 @@ export async function updateCapturedContext(capture) { } } -// A captured context is already an export bundle. Import reads only the -// portable, generated shape and creates a fresh local id, so a teammate can -// keep the shared folder unchanged and can rename the local copy if necessary. -export async function importCapturedContext({ bundleFolder, name }) { +// One reader for both halves of import: working out what a bundle would do to +// this machine, and then doing it. Those two must never disagree about what the +// bundle contains, so neither gets a parser of its own. +export async function readImportBundle(bundleFolder) { const supplied = (bundleFolder ?? "").trim(); if (supplied.length === 0) { throw new ContextError("A captured context bundle folder is required."); @@ -877,22 +908,239 @@ export async function importCapturedContext({ bundleFolder, name }) { }); } - return createCapturedContext({ - name: typeof name === "string" && name.trim().length > 0 ? name : manifest.name, - profile, - routingDescription: manifest.routingDescription, + return { source, manifest, profile, knowledge }; +} + +// The bundle's contents shaped as an update to a context already here. The +// local name is used rather than the bundle's on purpose: a rename made on +// either side is a local decision, and taking someone else's update must not +// quietly undo it. +function captureFromBundle(bundle, record) { + return { + targetId: record.id, + name: record.name, + profile: bundle.profile, + routingDescription: bundle.manifest.routingDescription, // The point of keeping these in the bundle: a teammate's copy is findable // by the same words as the original, without them rediscovering any of it. + routingQuestions: bundle.manifest.routingQuestions, + routingEntities: bundle.manifest.routingEntities, + knowledge: bundle.knowledge, + // What the bundle says it expects to reach, reduced to declarations. Import + // creates no binding for any of them, so what arrives can say what it wants + // and can run nothing until this machine's owner says otherwise. + extensions: readExtensionDeclarations(bundle.manifest.extensions) + }; +} + +// What a bundle holds, independent of the machine it came from. Revision and +// timestamps are left out deliberately: re-exporting the same material must not +// look like a change, because "have they moved?" is a question about content. +function fingerprintImportBundle(bundle) { + const { manifest } = bundle; + const hash = createHash("sha256"); + hash.update( + JSON.stringify({ + id: typeof manifest.id === "string" ? manifest.id : null, + name: manifest.name, + routingDescription: manifest.routingDescription ?? null, + routingQuestions: normalizeRoutingList(manifest.routingQuestions, MAX_ROUTING_QUESTIONS), + routingEntities: normalizeRoutingList(manifest.routingEntities, MAX_ROUTING_ENTITIES), + extensions: serializeExtensionDeclarations(readExtensionDeclarations(manifest.extensions)) + }) + ); + hash.update("\0profile\0"); + hash.update(bundle.profile); + for (const entry of bundle.knowledge) { + hash.update("\0knowledge\0"); + hash.update(entry.path); + hash.update("\0"); + hash.update(entry.content); + } + return hash.digest("hex"); +} + +// Stamps where a copy came from, as a second write once the bundle is in place. +// The fingerprint has to be taken from the finished context, so it cannot be +// part of the one atomic write that creates it. Losing the process between the +// two leaves the lineage absent, which the next import reads as divergence and +// answers with a merge — a review that costs time and never costs content. +// +// Only `importedFrom` is touched, and `fingerprintContext` does not hash it, so +// the fingerprint written here still describes the context afterwards. +// +// Exported so the give-up path is directly testable. It matters more than it +// looks: by the time this runs the import has already landed, so a failure here +// must cost the bookkeeping and never the context that was just written. +export async function recordImportLineage(record, bundle) { + const { manifest } = bundle; + if (typeof manifest?.id !== "string" || manifest.id.length === 0) { + return record; + } + const lineage = { + id: manifest.id, + revision: + Number.isInteger(manifest.revision) && manifest.revision > 0 ? manifest.revision : 1, + updatedAt: typeof manifest.updatedAt === "string" ? manifest.updatedAt : null, + fingerprint: await fingerprintContext(record), + // The other half of the baseline, and the one a merge depends on. A merged + // context deliberately matches neither side, so "did this copy change?" + // cannot decide whether there is anything left to take — only "did theirs?" + // can, and this is what answers it. + bundleFingerprint: fingerprintImportBundle(bundle) + }; + const manifestPath = path.join(record.directory, "context.json"); + const temporaryPath = path.join( + record.directory, + `.context-lineage-${randomBytes(6).toString("hex")}.json` + ); + try { + const stored = JSON.parse(await readFile(manifestPath, "utf8")); + const updated = { ...stored, importedFrom: lineage }; + await writeFile(temporaryPath, `${JSON.stringify(updated, null, 2)}\n`, "utf8"); + await rename(temporaryPath, manifestPath); + return recordFor(record.directory, updated); + } catch { + return record; + } finally { + await rm(temporaryPath, { force: true }).catch(() => undefined); + } +} + +// A captured context is already an export bundle. Import reads only the +// portable, generated shape and creates a fresh local id, so a teammate can +// keep the shared folder unchanged and can rename the local copy if necessary. +export async function importCapturedContext({ bundleFolder, name }) { + const bundle = await readImportBundle(bundleFolder); + const { manifest } = bundle; + const created = await createCapturedContext({ + name: typeof name === "string" && name.trim().length > 0 ? name : manifest.name, + profile: bundle.profile, + routingDescription: manifest.routingDescription, routingQuestions: manifest.routingQuestions, routingEntities: manifest.routingEntities, - knowledge, - // What the bundle says it expects to reach, reduced to declarations. The - // import creates no binding for any of them, so the imported context arrives - // able to say what it wants and unable to run anything until this machine's - // owner says otherwise. + knowledge: bundle.knowledge, extensions: readExtensionDeclarations(manifest.extensions), capturedFrom: manifest.capturedFrom }); + return { ...created, record: await recordImportLineage(created.record, bundle) }; +} + +// What a second bundle from the same origin should do to the copy already here. +// +// Nothing is written. This answers the only question that matters before an +// import can act — is this new, is it the same copy again, has it moved, and +// has this machine moved too — and the answer decides between creating, +// replacing in place, and asking the model to merge. +// +// Identity is never guessed from content. It comes from the lineage id, from +// the bundle being this very context exported and brought back, or from the +// user adopting a context with `into`. A bare name collision is reported as a +// choice rather than resolved, because two people naming a context the same +// thing is not evidence that it is the same context. +export async function resolveImportTarget({ bundleFolder, into }) { + const bundle = await readImportBundle(bundleFolder); + const localName = normalizeName(bundle.manifest.name); + const contexts = await listContexts(); + const bundleId = typeof bundle.manifest.id === "string" ? bundle.manifest.id : null; + const adopt = (into ?? "").trim(); + + // Adoption is the user supplying the identity the bundle could not prove — + // most often for a copy imported before lineage was recorded. It is taken as + // stated, and the write that follows stamps the lineage, so the assertion is + // needed once rather than at every later import. + const adopted = + adopt.length > 0 + ? (contexts.find( + (context) => + context.id === adopt || context.name.toLowerCase() === adopt.toLowerCase() + ) ?? null) + : null; + if (adopt.length > 0 && !adopted) { + throw new ContextError(`No context here is named "${adopt}".`); + } + + const lineage = + adopted ?? + (bundleId + ? contexts.find( + (context) => context.id === bundleId || context.importedFrom?.id === bundleId + ) + : null); + const named = contexts.find( + (context) => context.name.toLowerCase() === localName.toLowerCase() + ); + const record = lineage ?? named ?? null; + if (!record) { + return { action: "create", bundle, localName, record: null, matchedBy: null }; + } + + const baseHash = await fingerprintContext(record); + const preview = await prepareCapturedContextUpdate({ + ...captureFromBundle(bundle, record), + baseHash + }); + const matchedBy = adopted ? "adopted" : lineage ? "lineage" : "name"; + const base = { bundle, localName, record, matchedBy, baseHash, preview }; + + if (!lineage) { + return { ...base, action: "choose" }; + } + + // Asked in this order for a reason. Whether the bundle has anything new comes + // first, because after a merge the local copy matches neither side by design: + // its contents differ from the bundle permanently, and reading that as + // "behind" would offer to overwrite the merge with the same material it was + // built from, every single time. Nothing new upstream means nothing to do, + // whatever the two copies now look like. + const stillCurrent = + (typeof record.importedFrom?.bundleFingerprint === "string" && + record.importedFrom.bundleFingerprint === fingerprintImportBundle(bundle)) || + !preview.changed; + if (stillCurrent) { + return { ...base, action: "current" }; + } + + // They have moved, so this is about what taking it would cost here. No + // baseline means no way to prove this copy is untouched, and replacing an + // edited copy loses the edits — merge is the answer whenever it cannot be + // ruled out. + // + // A baseline left by a different origin does not count, which is what makes + // adoption safe: saying two contexts are the same does not make this copy's + // material disposable, and it came from somewhere else entirely. + const baseline = + record.importedFrom?.id === bundleId ? record.importedFrom.fingerprint : null; + const diverged = typeof baseline !== "string" || baseline !== baseHash; + return { ...base, action: diverged ? "merge" : "replace" }; +} + +// The fast-forward: this copy has not been touched since it arrived, so the +// bundle's contents replace it wholesale. It stays the same context — same id, +// same name, same connected sessions — because only its contents were ever +// stale. +export async function replaceContextFromBundle({ bundleFolder, targetId, baseHash }) { + const bundle = await readImportBundle(bundleFolder); + const record = await readContext(targetId); + if (!record) { + throw new ContextError("The context selected for this import no longer exists."); + } + const result = await updateCapturedContext({ + ...captureFromBundle(bundle, record), + baseHash, + updatedFrom: "import" + }); + return { ...result, record: await recordImportLineage(result.record, bundle) }; +} + +// Both copies moved, so the model reconciled them and this applies its work. +// The lineage is re-stamped from the bundle in the same breath: a merge that +// did not record which version it consumed would be re-offered, against the +// same stale baseline, on every later import. +export async function applyImportMerge({ bundleFolder, capture }) { + const bundle = await readImportBundle(bundleFolder); + const result = await updateCapturedContext({ ...capture, updatedFrom: "import" }); + return { ...result, record: await recordImportLineage(result.record, bundle) }; } // Rewrites only the declarations on a context's manifest, in place. This is the @@ -1007,6 +1255,10 @@ export async function exportContext({ record, destination, force = false, routin manifest.schema = CONTEXT_SCHEMA; manifest.profileFile = "profile.md"; delete manifest.kind; + // Lineage is this machine's bookkeeping about where its own copy came from, + // and its fingerprint describes a context that only exists here. It means + // nothing to whoever receives the bundle, whose import records its own. + delete manifest.importedFrom; // The last point at which this machine's copy becomes someone else's. Run // the declarations back through the whitelist here, so whatever a hand edit // may have added beside them — a command, an environment, a token — is not diff --git a/plugins/kimi-code/neatcontext/src/core/import-commands.mjs b/plugins/kimi-code/neatcontext/src/core/import-commands.mjs new file mode 100644 index 0000000..8a54c8b --- /dev/null +++ b/plugins/kimi-code/neatcontext/src/core/import-commands.mjs @@ -0,0 +1,287 @@ +// The import command, shared by every host plugin. +// +// Import used to have one outcome: create. That was right exactly once — the +// first time a bundle arrived. Every time after that, the bundle was a newer +// copy of something already here, and creating produced a second context that +// competed with the first during routing while the connected session went on +// reading the stale one. +// +// So import now resolves before it acts, and the interesting part is what it +// refuses to guess. Identity comes from recorded lineage, never from content or +// from a name two people happened to choose alike. Divergence is proven against +// a baseline taken when the copy landed, and an absent baseline counts as +// diverged. Whenever the safe answer cannot be established, the command reports +// and stops rather than writing. +// +// Rendering lives here too. The four hosts differ only in how a slash command +// is spelled, and that arrives as `useCommand`. + +import { readFile, rm } from "node:fs/promises"; +import path from "node:path"; +import { + applyImportMerge, + ContextError, + importCapturedContext, + previewCapturedContextUpdate, + replaceContextFromBundle, + resolveImportTarget +} from "./context-store.mjs"; +import { putCard } from "./routing.mjs"; + +function refreshCard(result) { + return putCard(result.record.id, { + useWhen: result.routingDescription, + source: result.profileText + }).catch(() => undefined); +} + +function changedFiles(lines, label, files) { + if (files.length === 0) return; + lines.push(` ${label}: ${files.join(", ")}`); +} + +// What taking the bundle whole would do to the copy here. The same shape the +// save preview prints, for the same reason: the user is about to approve a +// replacement and should see its extent first. +function describeChanges(lines, preview) { + lines.push(` Domain profile: ${preview.profileChanged ? "changed" : "unchanged"}`); + lines.push(` Routing description: ${preview.routingChanged ? "changed" : "unchanged"}`); + lines.push( + ` Knowledge files: ${preview.changes.added.length} added, ` + + `${preview.changes.updated.length} updated, ${preview.changes.removed.length} removed` + ); + changedFiles(lines, "Add", preview.changes.added); + changedFiles(lines, "Update", preview.changes.updated); + changedFiles(lines, "Remove", preview.changes.removed); +} + +// How far the other copy has moved since this one was taken. Only ever stated +// as the pair, because the two numbers are counters on different machines: once +// both sides have been edited they are not versions of each other, and the only +// honest reading is "they are here, you left from there". +// +// Stated only when the baseline actually describes this bundle. A context +// adopted into a lineage it did not come from has a revision recorded against +// somewhere else, and pairing the two numbers would invent a history. +function describeDistance(lines, record, bundle) { + const theirs = bundle.manifest.revision; + const taken = record.importedFrom?.revision; + if (record.importedFrom?.id !== bundle.manifest.id) return; + if (!Number.isInteger(theirs) || !Number.isInteger(taken)) return; + lines.push(` Their revision: ${theirs} (you last took revision ${taken})`); +} + +function describeImported(lines, result, source, useCommand) { + lines.push(`Imported the "${result.record.name}" conversation context.`); + lines.push(` Domain profile: ${result.record.profilePath}`); + lines.push( + ` Knowledge folder: ${result.record.knowledgeFolder} ` + + `(${result.knowledgeFileCount} files)` + ); + lines.push(` Local bundle: ${result.record.directory}`); + lines.push(` Connect it with: ${useCommand} ${result.record.name}`); + lines.push(`The shared source folder (${source}) was left untouched.`); +} + +function describeUpdated(lines, result, source, headline) { + lines.push(headline); + lines.push(` Domain profile: ${result.record.profilePath}`); + lines.push( + ` Knowledge folder: ${result.record.knowledgeFolder} ` + + `(${result.knowledgeFileCount} files)` + ); + describeChanges(lines, result); + lines.push( + "It is the same context it was, so any session connected to it now reads the " + + "updated material." + ); + lines.push(`The shared source folder (${source}) was left untouched.`); +} + +// Applying a merge the model has already written. This is the only path that +// takes content from neither side wholesale, so the preview is shown and +// confirmed exactly the way a save update is. +async function runMergedImport({ bundleFolder, mergedFrom, confirmed, consume }) { + const lines = []; + let capture; + try { + capture = JSON.parse(await readFile(mergedFrom, "utf8")); + } catch { + lines.push(`Could not read a valid merged capture JSON file at ${mergedFrom}.`); + return lines; + } + if (capture?.schema !== 1) { + lines.push("Unsupported merged capture schema. Expected schema 1."); + return lines; + } + if (typeof capture.targetId !== "string" || capture.targetId.length === 0) { + lines.push( + "A merged capture must carry the exact targetId and baseHash this import printed." + ); + return lines; + } + + const preview = await previewCapturedContextUpdate(capture); + if (!preview.changed) { + lines.push(`The merge does not change the "${preview.record.name}" context.`); + return lines; + } + if (!confirmed) { + lines.push(`Merge the bundle into the "${preview.record.name}" context?`); + describeChanges(lines, preview); + lines.push("Re-run this import with --yes to apply the merge."); + return lines; + } + + const result = await applyImportMerge({ bundleFolder, capture }); + await refreshCard(result); + // Only once it has landed, and only when asked. A preview must leave the + // draft where it is, and so must any failure, or a merge the model spent the + // conversation building would have to be rebuilt from nothing. + if (consume) await rm(mergedFrom, { force: true }).catch(() => undefined); + describeUpdated( + lines, + result, + path.resolve(bundleFolder), + `Merged the bundle into the "${result.record.name}" context.` + ); + return lines; +} + +export async function runImport({ + bundleFolder, + name = "", + into = "", + mergedFrom = "", + confirmed = false, + consume = false, + useCommand +}) { + const lines = []; + try { + if (mergedFrom.trim().length > 0) { + return ( + await runMergedImport({ bundleFolder, mergedFrom, confirmed, consume }) + ).join("\n"); + } + + // Resolved on the bundle's own identity, never on the name a fork would be + // given. Asking "what is already here from this bundle?" under a new name + // answers about the new name, which is nothing, and the duplicate the fork + // is about to sit beside would go unmentioned. + const forkName = name.trim(); + const resolved = await resolveImportTarget({ bundleFolder, into }); + const { bundle, record, preview } = resolved; + const source = bundle.source; + + // An explicit name is the one instruction that overrides the resolution: it + // says to keep both copies as separate contexts. Honoured, and named as the + // choice it is, because two contexts about the same subject will compete + // every time a session routes itself. + if (forkName.length > 0) { + const created = await importCapturedContext({ bundleFolder, name: forkName }); + await refreshCard(created); + // Only worth saying when the two really are copies of one bundle. A name + // that merely collided is a different context, and forking is the right + // answer rather than a cost to warn about. + if (record && resolved.matchedBy === "lineage") { + lines.push( + `Note: "${record.name}" is already a copy of this bundle. You now have two ` + + "separate contexts holding the same material, and both will be considered " + + "whenever a session routes itself." + ); + } + describeImported(lines, created, source, useCommand); + return lines.join("\n"); + } + + if (resolved.action === "create") { + const created = await importCapturedContext({ bundleFolder }); + await refreshCard(created); + describeImported(lines, created, source, useCommand); + return lines.join("\n"); + } + + if (resolved.action === "current") { + lines.push("Import action: current"); + lines.push(`"${record.name}" already holds everything in this bundle. Nothing to import.`); + return lines.join("\n"); + } + + // A name in common is not evidence of a common origin, and the two cases + // want opposite handling, so this is the one outcome that asks. `--into` + // adopts the local context as this bundle's copy; since no baseline against + // this bundle exists for it, adopting leads to a merge and never to a + // replacement. + if (resolved.action === "choose") { + lines.push("Import action: choose"); + lines.push( + `A context named "${record.name}" is already here, but nothing records that it ` + + "came from this bundle — it may be the same context imported before lineage " + + "was tracked, or it may be someone else's context that happens to share the name." + ); + lines.push("Taking the bundle whole would look like this:"); + describeChanges(lines, preview); + lines.push("Say which it is:"); + lines.push(` --into "${record.name}"`); + lines.push(" the same context — reconcile the bundle into it"); + lines.push(' --name ""'); + lines.push(" a different context — keep both, side by side"); + return lines.join("\n"); + } + + if (resolved.action === "merge") { + lines.push("Import action: merge"); + lines.push( + resolved.matchedBy === "adopted" + ? `"${record.name}" is being treated as this bundle's copy, and nothing here ` + + "records what the two once had in common. Taking the bundle whole would " + + "discard whatever only this copy holds, so the two have to be reconciled first." + : `"${record.name}" came from this bundle, and both copies have changed since. ` + + "Taking the bundle whole would discard the work saved here, so the two have " + + "to be reconciled first." + ); + describeDistance(lines, record, bundle); + lines.push(`Context name: ${record.name}`); + lines.push(`Context id: ${record.id}`); + lines.push(`Base hash: ${resolved.baseHash}`); + lines.push(`Profile path: ${record.profilePath}`); + lines.push(`Knowledge folder: ${record.knowledgeFolder}`); + lines.push(`Bundle profile: ${path.join(source, "profile.md")}`); + lines.push(`Bundle knowledge: ${path.join(source, "knowledge")}`); + lines.push("Merge both sides, then apply the result with --merged-from."); + return lines.join("\n"); + } + + if (!confirmed) { + lines.push("Import action: replace"); + lines.push( + `"${record.name}" came from this bundle and has not been edited here since, so ` + + "the newer copy can be taken whole." + ); + describeDistance(lines, record, bundle); + describeChanges(lines, preview); + lines.push("Re-run this import with --yes to take it."); + return lines.join("\n"); + } + + const result = await replaceContextFromBundle({ + bundleFolder, + targetId: record.id, + baseHash: resolved.baseHash + }); + await refreshCard(result); + describeUpdated( + lines, + result, + source, + `Updated the "${result.record.name}" context from the bundle.` + ); + return lines.join("\n"); + } catch (error) { + if (error instanceof ContextError) { + return error.message; + } + throw error; + } +} diff --git a/plugins/kimi-code/neatcontext/src/kimi/neatcontext-cli.mjs b/plugins/kimi-code/neatcontext/src/kimi/neatcontext-cli.mjs index 56c4b0c..1384272 100644 --- a/plugins/kimi-code/neatcontext/src/kimi/neatcontext-cli.mjs +++ b/plugins/kimi-code/neatcontext/src/kimi/neatcontext-cli.mjs @@ -8,7 +8,7 @@ // create --name --knowledge create a context (--profile-from ) // save-target [name] decide whether save creates or updates // save --from create or update from this conversation -// import --from import a portable conversation context +// import --from import a bundle, or reconcile one already here // export --to copy a saved context's bundle out for sharing // delete [--yes] delete a context // mode [auto|ask|manual] how the session may route itself between contexts @@ -28,7 +28,6 @@ import { deleteContext, exportContext, fingerprintContext, - importCapturedContext, listContexts, ContextError, listKnowledgeFiles, @@ -36,6 +35,7 @@ import { readProfileText, updateCapturedContext } from "../core/context-store.mjs"; +import { runImport } from "../core/import-commands.mjs"; import { addAlias, isCardStale, @@ -698,30 +698,17 @@ async function commandSave(flags) { } async function commandImport(flags) { - const source = typeof flags.from === "string" ? flags.from : ""; - const name = typeof flags.name === "string" ? flags.name : ""; - try { - const result = await importCapturedContext({ bundleFolder: source, name }); - await putCard(result.record.id, { - useWhen: result.routingDescription, - source: result.profileText - }).catch(() => undefined); - print(`Imported the "${result.record.name}" conversation context.`); - print(` Domain profile: ${result.record.profilePath}`); - print( - ` Knowledge folder: ${result.record.knowledgeFolder} ` + - `(${result.knowledgeFileCount} files)` - ); - print(` Local bundle: ${result.record.directory}`); - print(` Connect it with: /neatcontext:use ${result.record.name}`); - print(`The shared source folder (${source}) was left untouched.`); - } catch (error) { - if (error instanceof ContextError) { - print(error.message); - return; - } - throw error; - } + print( + await runImport({ + bundleFolder: typeof flags.from === "string" ? flags.from : "", + name: typeof flags.name === "string" ? flags.name : "", + into: typeof flags.into === "string" ? flags.into : "", + mergedFrom: typeof flags["merged-from"] === "string" ? flags["merged-from"] : "", + confirmed: flags.yes === true || flags.yes === "true", + consume: flags.consume === true || flags.consume === "true", + useCommand: "/neatcontext:use" + }) + ); } // The routing description is read from the card rather than the manifest: diff --git a/plugins/pi/neatcontext/README.md b/plugins/pi/neatcontext/README.md index dc4889c..791d252 100644 --- a/plugins/pi/neatcontext/README.md +++ b/plugins/pi/neatcontext/README.md @@ -37,7 +37,7 @@ pi install npm:@xtsoftwarelabs/neatcontext-pi | `/neatcontext-mode [auto\|ask\|manual]` | How this session may re-ground itself (`--global` for the default) | | `/neatcontext-create` | Create a context around a knowledge folder you already have | | `/neatcontext-save [name]` | Save this conversation's durable work as a context | -| `/neatcontext-import ` | Import a shared conversation-context bundle | +| `/neatcontext-import ` | Import a shared bundle, or reconcile a newer copy of one you already have | | `/neatcontext-export [name] --to ` | Export a saved context as a shareable bundle | | `/neatcontext-delete ` | Delete a context | diff --git a/plugins/pi/neatcontext/extensions/neatcontext.js b/plugins/pi/neatcontext/extensions/neatcontext.js index af5a759..45a24e9 100644 --- a/plugins/pi/neatcontext/extensions/neatcontext.js +++ b/plugins/pi/neatcontext/extensions/neatcontext.js @@ -124,6 +124,42 @@ function parseExportArguments(input) { return { context: name.join(" "), destination, force }; } +// Import takes the bundle folder bare and everything else behind a flag, so the +// common case stays one path and the reconciling cases stay explicit. +function parseImportArguments(input) { + const words = splitCommandArguments(input); + const folder = []; + const values = { name: "", into: "", "merged-from": "" }; + let yes = false; + let consume = false; + for (let index = 0; index < words.length; index += 1) { + const word = words[index]; + const valued = Object.keys(values).find( + (flag) => word === `--${flag}` || word.startsWith(`--${flag}=`) + ); + if (word === "--yes") { + yes = true; + } else if (word === "--consume") { + consume = true; + } else if (valued && word === `--${valued}`) { + values[valued] = words[index + 1] ?? ""; + index += 1; + } else if (valued) { + values[valued] = word.slice(valued.length + 3); + } else { + folder.push(word); + } + } + return { + from: folder.join(" "), + name: values.name, + into: values.into, + mergedFrom: values["merged-from"], + yes, + consume + }; +} + export default function (pi) { // --- grounding ------------------------------------------------------------ @@ -526,14 +562,15 @@ export default function (pi) { }); pi.registerCommand("neatcontext-import", { - description: "Import a shared conversation context bundle", + description: "Import a shared conversation context bundle, or reconcile a newer copy", handler: async (args, ctx) => { bindFrom(ctx); - let from = args.trim(); - if (from.length === 0 && ctx.hasUI) { - from = (await ctx.ui.input("Bundle folder to import", "path to the shared folder")) ?? ""; + const options = parseImportArguments(args.trim()); + if (options.from.length === 0 && ctx.hasUI) { + options.from = + (await ctx.ui.input("Bundle folder to import", "path to the shared folder")) ?? ""; } - report(pi, await importContext({ from })); + report(pi, await importContext(options)); } }); diff --git a/plugins/pi/neatcontext/package.json b/plugins/pi/neatcontext/package.json index 41ea3a9..78b7d77 100644 --- a/plugins/pi/neatcontext/package.json +++ b/plugins/pi/neatcontext/package.json @@ -46,7 +46,7 @@ "access": "public" }, "scripts": { - "check": "node --check extensions/neatcontext.js && node --check src/pi/runtime.mjs && node --check src/pi/session.mjs && node --check src/core/context-store.mjs && node --check src/core/extension-bindings.mjs && node --check src/core/extension-commands.mjs && node --check src/core/extension-runtime.mjs && node --check src/core/extensions.mjs && node --check src/core/mcp-stdio-client.mjs && node --check src/core/local-state.mjs && node --check src/core/conversation-evidence.mjs && node --check src/core/routing.mjs && node --check src/core/routing-search.mjs && node --check src/core/routing-candidates.mjs && node --check src/core/selection.mjs && node --check src/core/session.mjs && node --check src/core/storage-home.mjs", + "check": "node --check extensions/neatcontext.js && node --check src/pi/runtime.mjs && node --check src/pi/session.mjs && node --check src/core/context-store.mjs && node --check src/core/extension-bindings.mjs && node --check src/core/extension-commands.mjs && node --check src/core/extension-runtime.mjs && node --check src/core/extensions.mjs && node --check src/core/import-commands.mjs && node --check src/core/mcp-stdio-client.mjs && node --check src/core/local-state.mjs && node --check src/core/conversation-evidence.mjs && node --check src/core/routing.mjs && node --check src/core/routing-search.mjs && node --check src/core/routing-candidates.mjs && node --check src/core/selection.mjs && node --check src/core/session.mjs && node --check src/core/storage-home.mjs", "test": "node --test" } } diff --git a/plugins/pi/neatcontext/src/core/context-store.mjs b/plugins/pi/neatcontext/src/core/context-store.mjs index c9e76b4..ec6e738 100644 --- a/plugins/pi/neatcontext/src/core/context-store.mjs +++ b/plugins/pi/neatcontext/src/core/context-store.mjs @@ -68,6 +68,36 @@ function slugify(name) { return slug || "context"; } +// Where an imported copy came from, and what it looked like the moment it +// landed here. +// +// `id` is the exporter's context id, and it is the lineage key: a later bundle +// from the same origin is recognised by it, so renaming either copy cannot +// break the link and two teams who picked the same name are never confused for +// each other. The rest is the baseline the next import is judged against — +// `revision` and `updatedAt` are theirs at the time, so a newer bundle can say +// how far they have moved, and `fingerprint` is this copy's own, so anything +// saved or hand-edited here since shows up as divergence. +// +// Read leniently, and absent is a meaningful answer: a copy imported before +// this existed has no baseline, which is treated as divergence rather than as +// a clean slate. That costs a merge review and never costs content. +function readImportLineage(parsed) { + const lineage = parsed?.importedFrom; + if (!lineage || typeof lineage !== "object" || typeof lineage.id !== "string") { + return null; + } + return { + id: lineage.id, + revision: + Number.isInteger(lineage.revision) && lineage.revision > 0 ? lineage.revision : null, + updatedAt: typeof lineage.updatedAt === "string" ? lineage.updatedAt : null, + fingerprint: typeof lineage.fingerprint === "string" ? lineage.fingerprint : null, + bundleFingerprint: + typeof lineage.bundleFingerprint === "string" ? lineage.bundleFingerprint : null + }; +} + function recordFor(directory, parsed) { const legacy = parsed?.schema === LEGACY_SCHEMA && parsed?.kind === "lite"; // `kind` is a schema 1 concept and means nothing at CONTEXT_SCHEMA, so a @@ -116,6 +146,7 @@ function recordFor(directory, parsed) { extensions: readExtensionDeclarations(parsed.extensions), capturedFrom: typeof parsed.capturedFrom === "string" ? parsed.capturedFrom : null, capturedFromConversation: isConversationCapture(parsed.capturedFrom), + importedFrom: readImportLineage(parsed), profilePath: path.join(directory, "profile.md"), createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : null, updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : null, @@ -818,10 +849,10 @@ export async function updateCapturedContext(capture) { } } -// A captured context is already an export bundle. Import reads only the -// portable, generated shape and creates a fresh local id, so a teammate can -// keep the shared folder unchanged and can rename the local copy if necessary. -export async function importCapturedContext({ bundleFolder, name }) { +// One reader for both halves of import: working out what a bundle would do to +// this machine, and then doing it. Those two must never disagree about what the +// bundle contains, so neither gets a parser of its own. +export async function readImportBundle(bundleFolder) { const supplied = (bundleFolder ?? "").trim(); if (supplied.length === 0) { throw new ContextError("A captured context bundle folder is required."); @@ -877,22 +908,239 @@ export async function importCapturedContext({ bundleFolder, name }) { }); } - return createCapturedContext({ - name: typeof name === "string" && name.trim().length > 0 ? name : manifest.name, - profile, - routingDescription: manifest.routingDescription, + return { source, manifest, profile, knowledge }; +} + +// The bundle's contents shaped as an update to a context already here. The +// local name is used rather than the bundle's on purpose: a rename made on +// either side is a local decision, and taking someone else's update must not +// quietly undo it. +function captureFromBundle(bundle, record) { + return { + targetId: record.id, + name: record.name, + profile: bundle.profile, + routingDescription: bundle.manifest.routingDescription, // The point of keeping these in the bundle: a teammate's copy is findable // by the same words as the original, without them rediscovering any of it. + routingQuestions: bundle.manifest.routingQuestions, + routingEntities: bundle.manifest.routingEntities, + knowledge: bundle.knowledge, + // What the bundle says it expects to reach, reduced to declarations. Import + // creates no binding for any of them, so what arrives can say what it wants + // and can run nothing until this machine's owner says otherwise. + extensions: readExtensionDeclarations(bundle.manifest.extensions) + }; +} + +// What a bundle holds, independent of the machine it came from. Revision and +// timestamps are left out deliberately: re-exporting the same material must not +// look like a change, because "have they moved?" is a question about content. +function fingerprintImportBundle(bundle) { + const { manifest } = bundle; + const hash = createHash("sha256"); + hash.update( + JSON.stringify({ + id: typeof manifest.id === "string" ? manifest.id : null, + name: manifest.name, + routingDescription: manifest.routingDescription ?? null, + routingQuestions: normalizeRoutingList(manifest.routingQuestions, MAX_ROUTING_QUESTIONS), + routingEntities: normalizeRoutingList(manifest.routingEntities, MAX_ROUTING_ENTITIES), + extensions: serializeExtensionDeclarations(readExtensionDeclarations(manifest.extensions)) + }) + ); + hash.update("\0profile\0"); + hash.update(bundle.profile); + for (const entry of bundle.knowledge) { + hash.update("\0knowledge\0"); + hash.update(entry.path); + hash.update("\0"); + hash.update(entry.content); + } + return hash.digest("hex"); +} + +// Stamps where a copy came from, as a second write once the bundle is in place. +// The fingerprint has to be taken from the finished context, so it cannot be +// part of the one atomic write that creates it. Losing the process between the +// two leaves the lineage absent, which the next import reads as divergence and +// answers with a merge — a review that costs time and never costs content. +// +// Only `importedFrom` is touched, and `fingerprintContext` does not hash it, so +// the fingerprint written here still describes the context afterwards. +// +// Exported so the give-up path is directly testable. It matters more than it +// looks: by the time this runs the import has already landed, so a failure here +// must cost the bookkeeping and never the context that was just written. +export async function recordImportLineage(record, bundle) { + const { manifest } = bundle; + if (typeof manifest?.id !== "string" || manifest.id.length === 0) { + return record; + } + const lineage = { + id: manifest.id, + revision: + Number.isInteger(manifest.revision) && manifest.revision > 0 ? manifest.revision : 1, + updatedAt: typeof manifest.updatedAt === "string" ? manifest.updatedAt : null, + fingerprint: await fingerprintContext(record), + // The other half of the baseline, and the one a merge depends on. A merged + // context deliberately matches neither side, so "did this copy change?" + // cannot decide whether there is anything left to take — only "did theirs?" + // can, and this is what answers it. + bundleFingerprint: fingerprintImportBundle(bundle) + }; + const manifestPath = path.join(record.directory, "context.json"); + const temporaryPath = path.join( + record.directory, + `.context-lineage-${randomBytes(6).toString("hex")}.json` + ); + try { + const stored = JSON.parse(await readFile(manifestPath, "utf8")); + const updated = { ...stored, importedFrom: lineage }; + await writeFile(temporaryPath, `${JSON.stringify(updated, null, 2)}\n`, "utf8"); + await rename(temporaryPath, manifestPath); + return recordFor(record.directory, updated); + } catch { + return record; + } finally { + await rm(temporaryPath, { force: true }).catch(() => undefined); + } +} + +// A captured context is already an export bundle. Import reads only the +// portable, generated shape and creates a fresh local id, so a teammate can +// keep the shared folder unchanged and can rename the local copy if necessary. +export async function importCapturedContext({ bundleFolder, name }) { + const bundle = await readImportBundle(bundleFolder); + const { manifest } = bundle; + const created = await createCapturedContext({ + name: typeof name === "string" && name.trim().length > 0 ? name : manifest.name, + profile: bundle.profile, + routingDescription: manifest.routingDescription, routingQuestions: manifest.routingQuestions, routingEntities: manifest.routingEntities, - knowledge, - // What the bundle says it expects to reach, reduced to declarations. The - // import creates no binding for any of them, so the imported context arrives - // able to say what it wants and unable to run anything until this machine's - // owner says otherwise. + knowledge: bundle.knowledge, extensions: readExtensionDeclarations(manifest.extensions), capturedFrom: manifest.capturedFrom }); + return { ...created, record: await recordImportLineage(created.record, bundle) }; +} + +// What a second bundle from the same origin should do to the copy already here. +// +// Nothing is written. This answers the only question that matters before an +// import can act — is this new, is it the same copy again, has it moved, and +// has this machine moved too — and the answer decides between creating, +// replacing in place, and asking the model to merge. +// +// Identity is never guessed from content. It comes from the lineage id, from +// the bundle being this very context exported and brought back, or from the +// user adopting a context with `into`. A bare name collision is reported as a +// choice rather than resolved, because two people naming a context the same +// thing is not evidence that it is the same context. +export async function resolveImportTarget({ bundleFolder, into }) { + const bundle = await readImportBundle(bundleFolder); + const localName = normalizeName(bundle.manifest.name); + const contexts = await listContexts(); + const bundleId = typeof bundle.manifest.id === "string" ? bundle.manifest.id : null; + const adopt = (into ?? "").trim(); + + // Adoption is the user supplying the identity the bundle could not prove — + // most often for a copy imported before lineage was recorded. It is taken as + // stated, and the write that follows stamps the lineage, so the assertion is + // needed once rather than at every later import. + const adopted = + adopt.length > 0 + ? (contexts.find( + (context) => + context.id === adopt || context.name.toLowerCase() === adopt.toLowerCase() + ) ?? null) + : null; + if (adopt.length > 0 && !adopted) { + throw new ContextError(`No context here is named "${adopt}".`); + } + + const lineage = + adopted ?? + (bundleId + ? contexts.find( + (context) => context.id === bundleId || context.importedFrom?.id === bundleId + ) + : null); + const named = contexts.find( + (context) => context.name.toLowerCase() === localName.toLowerCase() + ); + const record = lineage ?? named ?? null; + if (!record) { + return { action: "create", bundle, localName, record: null, matchedBy: null }; + } + + const baseHash = await fingerprintContext(record); + const preview = await prepareCapturedContextUpdate({ + ...captureFromBundle(bundle, record), + baseHash + }); + const matchedBy = adopted ? "adopted" : lineage ? "lineage" : "name"; + const base = { bundle, localName, record, matchedBy, baseHash, preview }; + + if (!lineage) { + return { ...base, action: "choose" }; + } + + // Asked in this order for a reason. Whether the bundle has anything new comes + // first, because after a merge the local copy matches neither side by design: + // its contents differ from the bundle permanently, and reading that as + // "behind" would offer to overwrite the merge with the same material it was + // built from, every single time. Nothing new upstream means nothing to do, + // whatever the two copies now look like. + const stillCurrent = + (typeof record.importedFrom?.bundleFingerprint === "string" && + record.importedFrom.bundleFingerprint === fingerprintImportBundle(bundle)) || + !preview.changed; + if (stillCurrent) { + return { ...base, action: "current" }; + } + + // They have moved, so this is about what taking it would cost here. No + // baseline means no way to prove this copy is untouched, and replacing an + // edited copy loses the edits — merge is the answer whenever it cannot be + // ruled out. + // + // A baseline left by a different origin does not count, which is what makes + // adoption safe: saying two contexts are the same does not make this copy's + // material disposable, and it came from somewhere else entirely. + const baseline = + record.importedFrom?.id === bundleId ? record.importedFrom.fingerprint : null; + const diverged = typeof baseline !== "string" || baseline !== baseHash; + return { ...base, action: diverged ? "merge" : "replace" }; +} + +// The fast-forward: this copy has not been touched since it arrived, so the +// bundle's contents replace it wholesale. It stays the same context — same id, +// same name, same connected sessions — because only its contents were ever +// stale. +export async function replaceContextFromBundle({ bundleFolder, targetId, baseHash }) { + const bundle = await readImportBundle(bundleFolder); + const record = await readContext(targetId); + if (!record) { + throw new ContextError("The context selected for this import no longer exists."); + } + const result = await updateCapturedContext({ + ...captureFromBundle(bundle, record), + baseHash, + updatedFrom: "import" + }); + return { ...result, record: await recordImportLineage(result.record, bundle) }; +} + +// Both copies moved, so the model reconciled them and this applies its work. +// The lineage is re-stamped from the bundle in the same breath: a merge that +// did not record which version it consumed would be re-offered, against the +// same stale baseline, on every later import. +export async function applyImportMerge({ bundleFolder, capture }) { + const bundle = await readImportBundle(bundleFolder); + const result = await updateCapturedContext({ ...capture, updatedFrom: "import" }); + return { ...result, record: await recordImportLineage(result.record, bundle) }; } // Rewrites only the declarations on a context's manifest, in place. This is the @@ -1007,6 +1255,10 @@ export async function exportContext({ record, destination, force = false, routin manifest.schema = CONTEXT_SCHEMA; manifest.profileFile = "profile.md"; delete manifest.kind; + // Lineage is this machine's bookkeeping about where its own copy came from, + // and its fingerprint describes a context that only exists here. It means + // nothing to whoever receives the bundle, whose import records its own. + delete manifest.importedFrom; // The last point at which this machine's copy becomes someone else's. Run // the declarations back through the whitelist here, so whatever a hand edit // may have added beside them — a command, an environment, a token — is not diff --git a/plugins/pi/neatcontext/src/core/import-commands.mjs b/plugins/pi/neatcontext/src/core/import-commands.mjs new file mode 100644 index 0000000..8a54c8b --- /dev/null +++ b/plugins/pi/neatcontext/src/core/import-commands.mjs @@ -0,0 +1,287 @@ +// The import command, shared by every host plugin. +// +// Import used to have one outcome: create. That was right exactly once — the +// first time a bundle arrived. Every time after that, the bundle was a newer +// copy of something already here, and creating produced a second context that +// competed with the first during routing while the connected session went on +// reading the stale one. +// +// So import now resolves before it acts, and the interesting part is what it +// refuses to guess. Identity comes from recorded lineage, never from content or +// from a name two people happened to choose alike. Divergence is proven against +// a baseline taken when the copy landed, and an absent baseline counts as +// diverged. Whenever the safe answer cannot be established, the command reports +// and stops rather than writing. +// +// Rendering lives here too. The four hosts differ only in how a slash command +// is spelled, and that arrives as `useCommand`. + +import { readFile, rm } from "node:fs/promises"; +import path from "node:path"; +import { + applyImportMerge, + ContextError, + importCapturedContext, + previewCapturedContextUpdate, + replaceContextFromBundle, + resolveImportTarget +} from "./context-store.mjs"; +import { putCard } from "./routing.mjs"; + +function refreshCard(result) { + return putCard(result.record.id, { + useWhen: result.routingDescription, + source: result.profileText + }).catch(() => undefined); +} + +function changedFiles(lines, label, files) { + if (files.length === 0) return; + lines.push(` ${label}: ${files.join(", ")}`); +} + +// What taking the bundle whole would do to the copy here. The same shape the +// save preview prints, for the same reason: the user is about to approve a +// replacement and should see its extent first. +function describeChanges(lines, preview) { + lines.push(` Domain profile: ${preview.profileChanged ? "changed" : "unchanged"}`); + lines.push(` Routing description: ${preview.routingChanged ? "changed" : "unchanged"}`); + lines.push( + ` Knowledge files: ${preview.changes.added.length} added, ` + + `${preview.changes.updated.length} updated, ${preview.changes.removed.length} removed` + ); + changedFiles(lines, "Add", preview.changes.added); + changedFiles(lines, "Update", preview.changes.updated); + changedFiles(lines, "Remove", preview.changes.removed); +} + +// How far the other copy has moved since this one was taken. Only ever stated +// as the pair, because the two numbers are counters on different machines: once +// both sides have been edited they are not versions of each other, and the only +// honest reading is "they are here, you left from there". +// +// Stated only when the baseline actually describes this bundle. A context +// adopted into a lineage it did not come from has a revision recorded against +// somewhere else, and pairing the two numbers would invent a history. +function describeDistance(lines, record, bundle) { + const theirs = bundle.manifest.revision; + const taken = record.importedFrom?.revision; + if (record.importedFrom?.id !== bundle.manifest.id) return; + if (!Number.isInteger(theirs) || !Number.isInteger(taken)) return; + lines.push(` Their revision: ${theirs} (you last took revision ${taken})`); +} + +function describeImported(lines, result, source, useCommand) { + lines.push(`Imported the "${result.record.name}" conversation context.`); + lines.push(` Domain profile: ${result.record.profilePath}`); + lines.push( + ` Knowledge folder: ${result.record.knowledgeFolder} ` + + `(${result.knowledgeFileCount} files)` + ); + lines.push(` Local bundle: ${result.record.directory}`); + lines.push(` Connect it with: ${useCommand} ${result.record.name}`); + lines.push(`The shared source folder (${source}) was left untouched.`); +} + +function describeUpdated(lines, result, source, headline) { + lines.push(headline); + lines.push(` Domain profile: ${result.record.profilePath}`); + lines.push( + ` Knowledge folder: ${result.record.knowledgeFolder} ` + + `(${result.knowledgeFileCount} files)` + ); + describeChanges(lines, result); + lines.push( + "It is the same context it was, so any session connected to it now reads the " + + "updated material." + ); + lines.push(`The shared source folder (${source}) was left untouched.`); +} + +// Applying a merge the model has already written. This is the only path that +// takes content from neither side wholesale, so the preview is shown and +// confirmed exactly the way a save update is. +async function runMergedImport({ bundleFolder, mergedFrom, confirmed, consume }) { + const lines = []; + let capture; + try { + capture = JSON.parse(await readFile(mergedFrom, "utf8")); + } catch { + lines.push(`Could not read a valid merged capture JSON file at ${mergedFrom}.`); + return lines; + } + if (capture?.schema !== 1) { + lines.push("Unsupported merged capture schema. Expected schema 1."); + return lines; + } + if (typeof capture.targetId !== "string" || capture.targetId.length === 0) { + lines.push( + "A merged capture must carry the exact targetId and baseHash this import printed." + ); + return lines; + } + + const preview = await previewCapturedContextUpdate(capture); + if (!preview.changed) { + lines.push(`The merge does not change the "${preview.record.name}" context.`); + return lines; + } + if (!confirmed) { + lines.push(`Merge the bundle into the "${preview.record.name}" context?`); + describeChanges(lines, preview); + lines.push("Re-run this import with --yes to apply the merge."); + return lines; + } + + const result = await applyImportMerge({ bundleFolder, capture }); + await refreshCard(result); + // Only once it has landed, and only when asked. A preview must leave the + // draft where it is, and so must any failure, or a merge the model spent the + // conversation building would have to be rebuilt from nothing. + if (consume) await rm(mergedFrom, { force: true }).catch(() => undefined); + describeUpdated( + lines, + result, + path.resolve(bundleFolder), + `Merged the bundle into the "${result.record.name}" context.` + ); + return lines; +} + +export async function runImport({ + bundleFolder, + name = "", + into = "", + mergedFrom = "", + confirmed = false, + consume = false, + useCommand +}) { + const lines = []; + try { + if (mergedFrom.trim().length > 0) { + return ( + await runMergedImport({ bundleFolder, mergedFrom, confirmed, consume }) + ).join("\n"); + } + + // Resolved on the bundle's own identity, never on the name a fork would be + // given. Asking "what is already here from this bundle?" under a new name + // answers about the new name, which is nothing, and the duplicate the fork + // is about to sit beside would go unmentioned. + const forkName = name.trim(); + const resolved = await resolveImportTarget({ bundleFolder, into }); + const { bundle, record, preview } = resolved; + const source = bundle.source; + + // An explicit name is the one instruction that overrides the resolution: it + // says to keep both copies as separate contexts. Honoured, and named as the + // choice it is, because two contexts about the same subject will compete + // every time a session routes itself. + if (forkName.length > 0) { + const created = await importCapturedContext({ bundleFolder, name: forkName }); + await refreshCard(created); + // Only worth saying when the two really are copies of one bundle. A name + // that merely collided is a different context, and forking is the right + // answer rather than a cost to warn about. + if (record && resolved.matchedBy === "lineage") { + lines.push( + `Note: "${record.name}" is already a copy of this bundle. You now have two ` + + "separate contexts holding the same material, and both will be considered " + + "whenever a session routes itself." + ); + } + describeImported(lines, created, source, useCommand); + return lines.join("\n"); + } + + if (resolved.action === "create") { + const created = await importCapturedContext({ bundleFolder }); + await refreshCard(created); + describeImported(lines, created, source, useCommand); + return lines.join("\n"); + } + + if (resolved.action === "current") { + lines.push("Import action: current"); + lines.push(`"${record.name}" already holds everything in this bundle. Nothing to import.`); + return lines.join("\n"); + } + + // A name in common is not evidence of a common origin, and the two cases + // want opposite handling, so this is the one outcome that asks. `--into` + // adopts the local context as this bundle's copy; since no baseline against + // this bundle exists for it, adopting leads to a merge and never to a + // replacement. + if (resolved.action === "choose") { + lines.push("Import action: choose"); + lines.push( + `A context named "${record.name}" is already here, but nothing records that it ` + + "came from this bundle — it may be the same context imported before lineage " + + "was tracked, or it may be someone else's context that happens to share the name." + ); + lines.push("Taking the bundle whole would look like this:"); + describeChanges(lines, preview); + lines.push("Say which it is:"); + lines.push(` --into "${record.name}"`); + lines.push(" the same context — reconcile the bundle into it"); + lines.push(' --name ""'); + lines.push(" a different context — keep both, side by side"); + return lines.join("\n"); + } + + if (resolved.action === "merge") { + lines.push("Import action: merge"); + lines.push( + resolved.matchedBy === "adopted" + ? `"${record.name}" is being treated as this bundle's copy, and nothing here ` + + "records what the two once had in common. Taking the bundle whole would " + + "discard whatever only this copy holds, so the two have to be reconciled first." + : `"${record.name}" came from this bundle, and both copies have changed since. ` + + "Taking the bundle whole would discard the work saved here, so the two have " + + "to be reconciled first." + ); + describeDistance(lines, record, bundle); + lines.push(`Context name: ${record.name}`); + lines.push(`Context id: ${record.id}`); + lines.push(`Base hash: ${resolved.baseHash}`); + lines.push(`Profile path: ${record.profilePath}`); + lines.push(`Knowledge folder: ${record.knowledgeFolder}`); + lines.push(`Bundle profile: ${path.join(source, "profile.md")}`); + lines.push(`Bundle knowledge: ${path.join(source, "knowledge")}`); + lines.push("Merge both sides, then apply the result with --merged-from."); + return lines.join("\n"); + } + + if (!confirmed) { + lines.push("Import action: replace"); + lines.push( + `"${record.name}" came from this bundle and has not been edited here since, so ` + + "the newer copy can be taken whole." + ); + describeDistance(lines, record, bundle); + describeChanges(lines, preview); + lines.push("Re-run this import with --yes to take it."); + return lines.join("\n"); + } + + const result = await replaceContextFromBundle({ + bundleFolder, + targetId: record.id, + baseHash: resolved.baseHash + }); + await refreshCard(result); + describeUpdated( + lines, + result, + source, + `Updated the "${result.record.name}" context from the bundle.` + ); + return lines.join("\n"); + } catch (error) { + if (error instanceof ContextError) { + return error.message; + } + throw error; + } +} diff --git a/plugins/pi/neatcontext/src/pi/runtime.mjs b/plugins/pi/neatcontext/src/pi/runtime.mjs index 049218f..1c51d45 100644 --- a/plugins/pi/neatcontext/src/pi/runtime.mjs +++ b/plugins/pi/neatcontext/src/pi/runtime.mjs @@ -24,7 +24,6 @@ import { deleteContext as deleteStoredContext, exportContext as exportStoredContext, fingerprintContext, - importCapturedContext, CONTEXT_MISSING_MESSAGE, ContextError, listKnowledgeFiles, @@ -34,6 +33,7 @@ import { renderContext, updateCapturedContext } from "../core/context-store.mjs"; +import { runImport } from "../core/import-commands.mjs"; import { addExtensionToContext, removeExtensionFromContext, @@ -748,34 +748,20 @@ export async function describeContext({ context, useWhen, alias } = {}) { : "Pass a routing description as `useWhen`, or words to remember as `alias`."; } -export async function importContext({ from, name } = {}) { +export async function importContext({ from, name, into, mergedFrom, yes, consume } = {}) { const source = typeof from === "string" ? from : ""; if (source.trim().length === 0) { return "Pass the shared bundle folder to import from."; } - try { - const result = await importCapturedContext({ - bundleFolder: source, - name: typeof name === "string" ? name : "" - }); - await putCard(result.record.id, { - useWhen: result.routingDescription, - source: result.profileText - }).catch(() => undefined); - return [ - `Imported the "${result.record.name}" conversation context.`, - ` Domain profile: ${result.record.profilePath}`, - ` Knowledge folder: ${result.record.knowledgeFolder} (${result.knowledgeFileCount} files)`, - ` Local bundle: ${result.record.directory}`, - ` Connect it with: /neatcontext-use ${result.record.name}`, - `The shared source folder (${source}) was left untouched.` - ].join("\n"); - } catch (error) { - if (error instanceof ContextError) { - return error.message; - } - throw error; - } + return runImport({ + bundleFolder: source, + name: typeof name === "string" ? name : "", + into: typeof into === "string" ? into : "", + mergedFrom: typeof mergedFrom === "string" ? mergedFrom : "", + confirmed: yes === true, + consume: consume === true, + useCommand: "/neatcontext-use" + }); } export async function exportContext({ context, destination, force = false } = {}) { diff --git a/plugins/pi/neatcontext/tests/pi-extension.test.mjs b/plugins/pi/neatcontext/tests/pi-extension.test.mjs index 5419534..bacb50a 100644 --- a/plugins/pi/neatcontext/tests/pi-extension.test.mjs +++ b/plugins/pi/neatcontext/tests/pi-extension.test.mjs @@ -196,6 +196,52 @@ describe("commands", () => { assert.match(api.messages[0].content, /quoted export destination/); }); + it("parses a quoted bundle folder and the reconciling flags for import", async () => { + const runtime = await import("../src/pi/runtime.mjs"); + await runtime.saveContext({ + name: "Plugin import", + profile: "# Plugin import\n\n## Purpose\n\nImport parsing.\n", + routingDescription: "pi import command parsing", + knowledge: [{ path: "session-summary.md", content: "# Summary\n\nImport this.\n" }] + }); + const destination = path.join(home, "quoted import source"); + const exported = await runtime.exportContext({ context: "Plugin import", destination }); + const bundle = /Bundle folder:\s+(.+)/.exec(exported)[1]; + await runtime.deleteContext("Plugin import", { confirm: true }); + + api.messages.length = 0; + await api.commands.get("neatcontext-import").handler(`"${bundle}"`, fakeCtx()); + assert.match(api.messages[0].content, /Imported the "Plugin import" conversation context/); + assert.match(api.messages[0].content, /quoted import source/); + + // A flag the bare-folder parse must not swallow, and a second import that + // has to resolve rather than duplicate. + api.messages.length = 0; + await api.commands.get("neatcontext-import").handler(`"${bundle}" --yes`, fakeCtx()); + assert.match(api.messages[0].content, /Import action: current/); + + api.messages.length = 0; + await api.commands + .get("neatcontext-import") + .handler(`"${bundle}" --name "Plugin import copy"`, fakeCtx()); + assert.match(api.messages[0].content, /Imported the "Plugin import copy"/); + assert.match(api.messages[0].content, /already a copy of this bundle/); + + // The `--flag=value` form, and a flag that carries no value of its own. + api.messages.length = 0; + await api.commands + .get("neatcontext-import") + .handler(`"${bundle}" --into=Nowhere --consume`, fakeCtx()); + assert.match(api.messages[0].content, /No context here is named "Nowhere"/); + }); + + it("asks for the bundle folder when the command was given none", async () => { + const ctx = { ...fakeCtx(), hasUI: true, ui: { input: async () => " " } }; + api.messages.length = 0; + await api.commands.get("neatcontext-import").handler("", ctx); + assert.match(api.messages[0].content, /Pass the shared bundle folder/); + }); + it("routes --global to the mode command without treating it as a mode", async () => { api.messages.length = 0; await api.commands.get("neatcontext-mode").handler("auto --global", fakeCtx()); diff --git a/plugins/pi/neatcontext/tests/pi-runtime.test.mjs b/plugins/pi/neatcontext/tests/pi-runtime.test.mjs index 3d8bb16..b5ace0a 100644 --- a/plugins/pi/neatcontext/tests/pi-runtime.test.mjs +++ b/plugins/pi/neatcontext/tests/pi-runtime.test.mjs @@ -341,6 +341,32 @@ describe("save", () => { assert.equal("kind" in manifest, false); }); + it("imports a bundle, then resolves the same one instead of duplicating it", async () => { + await runtime.saveContext({ + name: "Queue lag", + profile: "# Queue lag\n\n## Purpose\n\nPartition skew.\n", + routingDescription: "order-events partition lag", + knowledge + }); + const exported = await runtime.exportContext({ + context: "Queue lag", + destination: path.join(home, "share") + }); + const bundle = /Bundle folder:\s+(.+)/.exec(exported)[1]; + await runtime.deleteContext("Queue lag", { confirm: true }); + + const imported = await runtime.importContext({ from: bundle }); + assert.match(imported, /Imported the "Queue lag" conversation context/); + assert.match(imported, /Connect it with: {2}\/neatcontext-use Queue lag/); + + // The second import is the one this exists for: same bundle, no duplicate. + assert.match(await runtime.importContext({ from: bundle }), /Import action: current/); + }); + + it("asks for the bundle folder when none was given", async () => { + assert.match(await runtime.importContext({}), /Pass the shared bundle folder/); + }); + it("refuses to export a Context whose knowledge is externally owned", async () => { await createOrders(); const exported = await runtime.exportContext({ diff --git a/shared/core/context-store.mjs b/shared/core/context-store.mjs index c9e76b4..ec6e738 100644 --- a/shared/core/context-store.mjs +++ b/shared/core/context-store.mjs @@ -68,6 +68,36 @@ function slugify(name) { return slug || "context"; } +// Where an imported copy came from, and what it looked like the moment it +// landed here. +// +// `id` is the exporter's context id, and it is the lineage key: a later bundle +// from the same origin is recognised by it, so renaming either copy cannot +// break the link and two teams who picked the same name are never confused for +// each other. The rest is the baseline the next import is judged against — +// `revision` and `updatedAt` are theirs at the time, so a newer bundle can say +// how far they have moved, and `fingerprint` is this copy's own, so anything +// saved or hand-edited here since shows up as divergence. +// +// Read leniently, and absent is a meaningful answer: a copy imported before +// this existed has no baseline, which is treated as divergence rather than as +// a clean slate. That costs a merge review and never costs content. +function readImportLineage(parsed) { + const lineage = parsed?.importedFrom; + if (!lineage || typeof lineage !== "object" || typeof lineage.id !== "string") { + return null; + } + return { + id: lineage.id, + revision: + Number.isInteger(lineage.revision) && lineage.revision > 0 ? lineage.revision : null, + updatedAt: typeof lineage.updatedAt === "string" ? lineage.updatedAt : null, + fingerprint: typeof lineage.fingerprint === "string" ? lineage.fingerprint : null, + bundleFingerprint: + typeof lineage.bundleFingerprint === "string" ? lineage.bundleFingerprint : null + }; +} + function recordFor(directory, parsed) { const legacy = parsed?.schema === LEGACY_SCHEMA && parsed?.kind === "lite"; // `kind` is a schema 1 concept and means nothing at CONTEXT_SCHEMA, so a @@ -116,6 +146,7 @@ function recordFor(directory, parsed) { extensions: readExtensionDeclarations(parsed.extensions), capturedFrom: typeof parsed.capturedFrom === "string" ? parsed.capturedFrom : null, capturedFromConversation: isConversationCapture(parsed.capturedFrom), + importedFrom: readImportLineage(parsed), profilePath: path.join(directory, "profile.md"), createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : null, updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : null, @@ -818,10 +849,10 @@ export async function updateCapturedContext(capture) { } } -// A captured context is already an export bundle. Import reads only the -// portable, generated shape and creates a fresh local id, so a teammate can -// keep the shared folder unchanged and can rename the local copy if necessary. -export async function importCapturedContext({ bundleFolder, name }) { +// One reader for both halves of import: working out what a bundle would do to +// this machine, and then doing it. Those two must never disagree about what the +// bundle contains, so neither gets a parser of its own. +export async function readImportBundle(bundleFolder) { const supplied = (bundleFolder ?? "").trim(); if (supplied.length === 0) { throw new ContextError("A captured context bundle folder is required."); @@ -877,22 +908,239 @@ export async function importCapturedContext({ bundleFolder, name }) { }); } - return createCapturedContext({ - name: typeof name === "string" && name.trim().length > 0 ? name : manifest.name, - profile, - routingDescription: manifest.routingDescription, + return { source, manifest, profile, knowledge }; +} + +// The bundle's contents shaped as an update to a context already here. The +// local name is used rather than the bundle's on purpose: a rename made on +// either side is a local decision, and taking someone else's update must not +// quietly undo it. +function captureFromBundle(bundle, record) { + return { + targetId: record.id, + name: record.name, + profile: bundle.profile, + routingDescription: bundle.manifest.routingDescription, // The point of keeping these in the bundle: a teammate's copy is findable // by the same words as the original, without them rediscovering any of it. + routingQuestions: bundle.manifest.routingQuestions, + routingEntities: bundle.manifest.routingEntities, + knowledge: bundle.knowledge, + // What the bundle says it expects to reach, reduced to declarations. Import + // creates no binding for any of them, so what arrives can say what it wants + // and can run nothing until this machine's owner says otherwise. + extensions: readExtensionDeclarations(bundle.manifest.extensions) + }; +} + +// What a bundle holds, independent of the machine it came from. Revision and +// timestamps are left out deliberately: re-exporting the same material must not +// look like a change, because "have they moved?" is a question about content. +function fingerprintImportBundle(bundle) { + const { manifest } = bundle; + const hash = createHash("sha256"); + hash.update( + JSON.stringify({ + id: typeof manifest.id === "string" ? manifest.id : null, + name: manifest.name, + routingDescription: manifest.routingDescription ?? null, + routingQuestions: normalizeRoutingList(manifest.routingQuestions, MAX_ROUTING_QUESTIONS), + routingEntities: normalizeRoutingList(manifest.routingEntities, MAX_ROUTING_ENTITIES), + extensions: serializeExtensionDeclarations(readExtensionDeclarations(manifest.extensions)) + }) + ); + hash.update("\0profile\0"); + hash.update(bundle.profile); + for (const entry of bundle.knowledge) { + hash.update("\0knowledge\0"); + hash.update(entry.path); + hash.update("\0"); + hash.update(entry.content); + } + return hash.digest("hex"); +} + +// Stamps where a copy came from, as a second write once the bundle is in place. +// The fingerprint has to be taken from the finished context, so it cannot be +// part of the one atomic write that creates it. Losing the process between the +// two leaves the lineage absent, which the next import reads as divergence and +// answers with a merge — a review that costs time and never costs content. +// +// Only `importedFrom` is touched, and `fingerprintContext` does not hash it, so +// the fingerprint written here still describes the context afterwards. +// +// Exported so the give-up path is directly testable. It matters more than it +// looks: by the time this runs the import has already landed, so a failure here +// must cost the bookkeeping and never the context that was just written. +export async function recordImportLineage(record, bundle) { + const { manifest } = bundle; + if (typeof manifest?.id !== "string" || manifest.id.length === 0) { + return record; + } + const lineage = { + id: manifest.id, + revision: + Number.isInteger(manifest.revision) && manifest.revision > 0 ? manifest.revision : 1, + updatedAt: typeof manifest.updatedAt === "string" ? manifest.updatedAt : null, + fingerprint: await fingerprintContext(record), + // The other half of the baseline, and the one a merge depends on. A merged + // context deliberately matches neither side, so "did this copy change?" + // cannot decide whether there is anything left to take — only "did theirs?" + // can, and this is what answers it. + bundleFingerprint: fingerprintImportBundle(bundle) + }; + const manifestPath = path.join(record.directory, "context.json"); + const temporaryPath = path.join( + record.directory, + `.context-lineage-${randomBytes(6).toString("hex")}.json` + ); + try { + const stored = JSON.parse(await readFile(manifestPath, "utf8")); + const updated = { ...stored, importedFrom: lineage }; + await writeFile(temporaryPath, `${JSON.stringify(updated, null, 2)}\n`, "utf8"); + await rename(temporaryPath, manifestPath); + return recordFor(record.directory, updated); + } catch { + return record; + } finally { + await rm(temporaryPath, { force: true }).catch(() => undefined); + } +} + +// A captured context is already an export bundle. Import reads only the +// portable, generated shape and creates a fresh local id, so a teammate can +// keep the shared folder unchanged and can rename the local copy if necessary. +export async function importCapturedContext({ bundleFolder, name }) { + const bundle = await readImportBundle(bundleFolder); + const { manifest } = bundle; + const created = await createCapturedContext({ + name: typeof name === "string" && name.trim().length > 0 ? name : manifest.name, + profile: bundle.profile, + routingDescription: manifest.routingDescription, routingQuestions: manifest.routingQuestions, routingEntities: manifest.routingEntities, - knowledge, - // What the bundle says it expects to reach, reduced to declarations. The - // import creates no binding for any of them, so the imported context arrives - // able to say what it wants and unable to run anything until this machine's - // owner says otherwise. + knowledge: bundle.knowledge, extensions: readExtensionDeclarations(manifest.extensions), capturedFrom: manifest.capturedFrom }); + return { ...created, record: await recordImportLineage(created.record, bundle) }; +} + +// What a second bundle from the same origin should do to the copy already here. +// +// Nothing is written. This answers the only question that matters before an +// import can act — is this new, is it the same copy again, has it moved, and +// has this machine moved too — and the answer decides between creating, +// replacing in place, and asking the model to merge. +// +// Identity is never guessed from content. It comes from the lineage id, from +// the bundle being this very context exported and brought back, or from the +// user adopting a context with `into`. A bare name collision is reported as a +// choice rather than resolved, because two people naming a context the same +// thing is not evidence that it is the same context. +export async function resolveImportTarget({ bundleFolder, into }) { + const bundle = await readImportBundle(bundleFolder); + const localName = normalizeName(bundle.manifest.name); + const contexts = await listContexts(); + const bundleId = typeof bundle.manifest.id === "string" ? bundle.manifest.id : null; + const adopt = (into ?? "").trim(); + + // Adoption is the user supplying the identity the bundle could not prove — + // most often for a copy imported before lineage was recorded. It is taken as + // stated, and the write that follows stamps the lineage, so the assertion is + // needed once rather than at every later import. + const adopted = + adopt.length > 0 + ? (contexts.find( + (context) => + context.id === adopt || context.name.toLowerCase() === adopt.toLowerCase() + ) ?? null) + : null; + if (adopt.length > 0 && !adopted) { + throw new ContextError(`No context here is named "${adopt}".`); + } + + const lineage = + adopted ?? + (bundleId + ? contexts.find( + (context) => context.id === bundleId || context.importedFrom?.id === bundleId + ) + : null); + const named = contexts.find( + (context) => context.name.toLowerCase() === localName.toLowerCase() + ); + const record = lineage ?? named ?? null; + if (!record) { + return { action: "create", bundle, localName, record: null, matchedBy: null }; + } + + const baseHash = await fingerprintContext(record); + const preview = await prepareCapturedContextUpdate({ + ...captureFromBundle(bundle, record), + baseHash + }); + const matchedBy = adopted ? "adopted" : lineage ? "lineage" : "name"; + const base = { bundle, localName, record, matchedBy, baseHash, preview }; + + if (!lineage) { + return { ...base, action: "choose" }; + } + + // Asked in this order for a reason. Whether the bundle has anything new comes + // first, because after a merge the local copy matches neither side by design: + // its contents differ from the bundle permanently, and reading that as + // "behind" would offer to overwrite the merge with the same material it was + // built from, every single time. Nothing new upstream means nothing to do, + // whatever the two copies now look like. + const stillCurrent = + (typeof record.importedFrom?.bundleFingerprint === "string" && + record.importedFrom.bundleFingerprint === fingerprintImportBundle(bundle)) || + !preview.changed; + if (stillCurrent) { + return { ...base, action: "current" }; + } + + // They have moved, so this is about what taking it would cost here. No + // baseline means no way to prove this copy is untouched, and replacing an + // edited copy loses the edits — merge is the answer whenever it cannot be + // ruled out. + // + // A baseline left by a different origin does not count, which is what makes + // adoption safe: saying two contexts are the same does not make this copy's + // material disposable, and it came from somewhere else entirely. + const baseline = + record.importedFrom?.id === bundleId ? record.importedFrom.fingerprint : null; + const diverged = typeof baseline !== "string" || baseline !== baseHash; + return { ...base, action: diverged ? "merge" : "replace" }; +} + +// The fast-forward: this copy has not been touched since it arrived, so the +// bundle's contents replace it wholesale. It stays the same context — same id, +// same name, same connected sessions — because only its contents were ever +// stale. +export async function replaceContextFromBundle({ bundleFolder, targetId, baseHash }) { + const bundle = await readImportBundle(bundleFolder); + const record = await readContext(targetId); + if (!record) { + throw new ContextError("The context selected for this import no longer exists."); + } + const result = await updateCapturedContext({ + ...captureFromBundle(bundle, record), + baseHash, + updatedFrom: "import" + }); + return { ...result, record: await recordImportLineage(result.record, bundle) }; +} + +// Both copies moved, so the model reconciled them and this applies its work. +// The lineage is re-stamped from the bundle in the same breath: a merge that +// did not record which version it consumed would be re-offered, against the +// same stale baseline, on every later import. +export async function applyImportMerge({ bundleFolder, capture }) { + const bundle = await readImportBundle(bundleFolder); + const result = await updateCapturedContext({ ...capture, updatedFrom: "import" }); + return { ...result, record: await recordImportLineage(result.record, bundle) }; } // Rewrites only the declarations on a context's manifest, in place. This is the @@ -1007,6 +1255,10 @@ export async function exportContext({ record, destination, force = false, routin manifest.schema = CONTEXT_SCHEMA; manifest.profileFile = "profile.md"; delete manifest.kind; + // Lineage is this machine's bookkeeping about where its own copy came from, + // and its fingerprint describes a context that only exists here. It means + // nothing to whoever receives the bundle, whose import records its own. + delete manifest.importedFrom; // The last point at which this machine's copy becomes someone else's. Run // the declarations back through the whitelist here, so whatever a hand edit // may have added beside them — a command, an environment, a token — is not diff --git a/shared/core/import-commands.mjs b/shared/core/import-commands.mjs new file mode 100644 index 0000000..8a54c8b --- /dev/null +++ b/shared/core/import-commands.mjs @@ -0,0 +1,287 @@ +// The import command, shared by every host plugin. +// +// Import used to have one outcome: create. That was right exactly once — the +// first time a bundle arrived. Every time after that, the bundle was a newer +// copy of something already here, and creating produced a second context that +// competed with the first during routing while the connected session went on +// reading the stale one. +// +// So import now resolves before it acts, and the interesting part is what it +// refuses to guess. Identity comes from recorded lineage, never from content or +// from a name two people happened to choose alike. Divergence is proven against +// a baseline taken when the copy landed, and an absent baseline counts as +// diverged. Whenever the safe answer cannot be established, the command reports +// and stops rather than writing. +// +// Rendering lives here too. The four hosts differ only in how a slash command +// is spelled, and that arrives as `useCommand`. + +import { readFile, rm } from "node:fs/promises"; +import path from "node:path"; +import { + applyImportMerge, + ContextError, + importCapturedContext, + previewCapturedContextUpdate, + replaceContextFromBundle, + resolveImportTarget +} from "./context-store.mjs"; +import { putCard } from "./routing.mjs"; + +function refreshCard(result) { + return putCard(result.record.id, { + useWhen: result.routingDescription, + source: result.profileText + }).catch(() => undefined); +} + +function changedFiles(lines, label, files) { + if (files.length === 0) return; + lines.push(` ${label}: ${files.join(", ")}`); +} + +// What taking the bundle whole would do to the copy here. The same shape the +// save preview prints, for the same reason: the user is about to approve a +// replacement and should see its extent first. +function describeChanges(lines, preview) { + lines.push(` Domain profile: ${preview.profileChanged ? "changed" : "unchanged"}`); + lines.push(` Routing description: ${preview.routingChanged ? "changed" : "unchanged"}`); + lines.push( + ` Knowledge files: ${preview.changes.added.length} added, ` + + `${preview.changes.updated.length} updated, ${preview.changes.removed.length} removed` + ); + changedFiles(lines, "Add", preview.changes.added); + changedFiles(lines, "Update", preview.changes.updated); + changedFiles(lines, "Remove", preview.changes.removed); +} + +// How far the other copy has moved since this one was taken. Only ever stated +// as the pair, because the two numbers are counters on different machines: once +// both sides have been edited they are not versions of each other, and the only +// honest reading is "they are here, you left from there". +// +// Stated only when the baseline actually describes this bundle. A context +// adopted into a lineage it did not come from has a revision recorded against +// somewhere else, and pairing the two numbers would invent a history. +function describeDistance(lines, record, bundle) { + const theirs = bundle.manifest.revision; + const taken = record.importedFrom?.revision; + if (record.importedFrom?.id !== bundle.manifest.id) return; + if (!Number.isInteger(theirs) || !Number.isInteger(taken)) return; + lines.push(` Their revision: ${theirs} (you last took revision ${taken})`); +} + +function describeImported(lines, result, source, useCommand) { + lines.push(`Imported the "${result.record.name}" conversation context.`); + lines.push(` Domain profile: ${result.record.profilePath}`); + lines.push( + ` Knowledge folder: ${result.record.knowledgeFolder} ` + + `(${result.knowledgeFileCount} files)` + ); + lines.push(` Local bundle: ${result.record.directory}`); + lines.push(` Connect it with: ${useCommand} ${result.record.name}`); + lines.push(`The shared source folder (${source}) was left untouched.`); +} + +function describeUpdated(lines, result, source, headline) { + lines.push(headline); + lines.push(` Domain profile: ${result.record.profilePath}`); + lines.push( + ` Knowledge folder: ${result.record.knowledgeFolder} ` + + `(${result.knowledgeFileCount} files)` + ); + describeChanges(lines, result); + lines.push( + "It is the same context it was, so any session connected to it now reads the " + + "updated material." + ); + lines.push(`The shared source folder (${source}) was left untouched.`); +} + +// Applying a merge the model has already written. This is the only path that +// takes content from neither side wholesale, so the preview is shown and +// confirmed exactly the way a save update is. +async function runMergedImport({ bundleFolder, mergedFrom, confirmed, consume }) { + const lines = []; + let capture; + try { + capture = JSON.parse(await readFile(mergedFrom, "utf8")); + } catch { + lines.push(`Could not read a valid merged capture JSON file at ${mergedFrom}.`); + return lines; + } + if (capture?.schema !== 1) { + lines.push("Unsupported merged capture schema. Expected schema 1."); + return lines; + } + if (typeof capture.targetId !== "string" || capture.targetId.length === 0) { + lines.push( + "A merged capture must carry the exact targetId and baseHash this import printed." + ); + return lines; + } + + const preview = await previewCapturedContextUpdate(capture); + if (!preview.changed) { + lines.push(`The merge does not change the "${preview.record.name}" context.`); + return lines; + } + if (!confirmed) { + lines.push(`Merge the bundle into the "${preview.record.name}" context?`); + describeChanges(lines, preview); + lines.push("Re-run this import with --yes to apply the merge."); + return lines; + } + + const result = await applyImportMerge({ bundleFolder, capture }); + await refreshCard(result); + // Only once it has landed, and only when asked. A preview must leave the + // draft where it is, and so must any failure, or a merge the model spent the + // conversation building would have to be rebuilt from nothing. + if (consume) await rm(mergedFrom, { force: true }).catch(() => undefined); + describeUpdated( + lines, + result, + path.resolve(bundleFolder), + `Merged the bundle into the "${result.record.name}" context.` + ); + return lines; +} + +export async function runImport({ + bundleFolder, + name = "", + into = "", + mergedFrom = "", + confirmed = false, + consume = false, + useCommand +}) { + const lines = []; + try { + if (mergedFrom.trim().length > 0) { + return ( + await runMergedImport({ bundleFolder, mergedFrom, confirmed, consume }) + ).join("\n"); + } + + // Resolved on the bundle's own identity, never on the name a fork would be + // given. Asking "what is already here from this bundle?" under a new name + // answers about the new name, which is nothing, and the duplicate the fork + // is about to sit beside would go unmentioned. + const forkName = name.trim(); + const resolved = await resolveImportTarget({ bundleFolder, into }); + const { bundle, record, preview } = resolved; + const source = bundle.source; + + // An explicit name is the one instruction that overrides the resolution: it + // says to keep both copies as separate contexts. Honoured, and named as the + // choice it is, because two contexts about the same subject will compete + // every time a session routes itself. + if (forkName.length > 0) { + const created = await importCapturedContext({ bundleFolder, name: forkName }); + await refreshCard(created); + // Only worth saying when the two really are copies of one bundle. A name + // that merely collided is a different context, and forking is the right + // answer rather than a cost to warn about. + if (record && resolved.matchedBy === "lineage") { + lines.push( + `Note: "${record.name}" is already a copy of this bundle. You now have two ` + + "separate contexts holding the same material, and both will be considered " + + "whenever a session routes itself." + ); + } + describeImported(lines, created, source, useCommand); + return lines.join("\n"); + } + + if (resolved.action === "create") { + const created = await importCapturedContext({ bundleFolder }); + await refreshCard(created); + describeImported(lines, created, source, useCommand); + return lines.join("\n"); + } + + if (resolved.action === "current") { + lines.push("Import action: current"); + lines.push(`"${record.name}" already holds everything in this bundle. Nothing to import.`); + return lines.join("\n"); + } + + // A name in common is not evidence of a common origin, and the two cases + // want opposite handling, so this is the one outcome that asks. `--into` + // adopts the local context as this bundle's copy; since no baseline against + // this bundle exists for it, adopting leads to a merge and never to a + // replacement. + if (resolved.action === "choose") { + lines.push("Import action: choose"); + lines.push( + `A context named "${record.name}" is already here, but nothing records that it ` + + "came from this bundle — it may be the same context imported before lineage " + + "was tracked, or it may be someone else's context that happens to share the name." + ); + lines.push("Taking the bundle whole would look like this:"); + describeChanges(lines, preview); + lines.push("Say which it is:"); + lines.push(` --into "${record.name}"`); + lines.push(" the same context — reconcile the bundle into it"); + lines.push(' --name ""'); + lines.push(" a different context — keep both, side by side"); + return lines.join("\n"); + } + + if (resolved.action === "merge") { + lines.push("Import action: merge"); + lines.push( + resolved.matchedBy === "adopted" + ? `"${record.name}" is being treated as this bundle's copy, and nothing here ` + + "records what the two once had in common. Taking the bundle whole would " + + "discard whatever only this copy holds, so the two have to be reconciled first." + : `"${record.name}" came from this bundle, and both copies have changed since. ` + + "Taking the bundle whole would discard the work saved here, so the two have " + + "to be reconciled first." + ); + describeDistance(lines, record, bundle); + lines.push(`Context name: ${record.name}`); + lines.push(`Context id: ${record.id}`); + lines.push(`Base hash: ${resolved.baseHash}`); + lines.push(`Profile path: ${record.profilePath}`); + lines.push(`Knowledge folder: ${record.knowledgeFolder}`); + lines.push(`Bundle profile: ${path.join(source, "profile.md")}`); + lines.push(`Bundle knowledge: ${path.join(source, "knowledge")}`); + lines.push("Merge both sides, then apply the result with --merged-from."); + return lines.join("\n"); + } + + if (!confirmed) { + lines.push("Import action: replace"); + lines.push( + `"${record.name}" came from this bundle and has not been edited here since, so ` + + "the newer copy can be taken whole." + ); + describeDistance(lines, record, bundle); + describeChanges(lines, preview); + lines.push("Re-run this import with --yes to take it."); + return lines.join("\n"); + } + + const result = await replaceContextFromBundle({ + bundleFolder, + targetId: record.id, + baseHash: resolved.baseHash + }); + await refreshCard(result); + describeUpdated( + lines, + result, + source, + `Updated the "${result.record.name}" context from the bundle.` + ); + return lines.join("\n"); + } catch (error) { + if (error instanceof ContextError) { + return error.message; + } + throw error; + } +} diff --git a/tests/import-hosts.test.mjs b/tests/import-hosts.test.mjs new file mode 100644 index 0000000..53308af --- /dev/null +++ b/tests/import-hosts.test.mjs @@ -0,0 +1,124 @@ +// The import command, host by host. +// +// What a bundle means — new, current, diverged — is host-neutral and tested +// once in import-reconcile. What this checks is that every host's CLI is +// actually wired to that decision rather than to the older create-only path, +// because a host left behind would answer a second import by silently building +// a duplicate context. + +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, it } from "node:test"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +const HOSTS = [ + { + name: "Claude Code", + dir: path.join(root, "plugins", "claude-code", "neatcontext", "src", "claude"), + session: { CLAUDE_CODE_SESSION_ID: "import-host-test" }, + useCommand: "/neatcontext:use" + }, + { + name: "Codex", + dir: path.join(root, "codex-marketplace", "plugins", "neatcontext", "src", "codex"), + session: { CODEX_THREAD_ID: "import-host-test" }, + useCommand: "$neatcontext:use" + }, + { + name: "GitHub Copilot", + dir: path.join(root, "plugins", "copilot", "neatcontext", "src", "copilot"), + session: { NEATCONTEXT_SESSION_ID: "import-host-test" }, + useCommand: "/neatcontext:use" + }, + { + name: "Kimi Code", + dir: path.join(root, "plugins", "kimi-code", "neatcontext", "src", "kimi"), + session: {}, + cliArgs: ["--session-id", "import-host-test"], + useCommand: "/neatcontext:use" + } +]; + +function run(file, args, env) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [file, ...args], { + env, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true + }); + let out = ""; + child.stdout.on("data", (chunk) => (out += chunk)); + child.stderr.on("data", (chunk) => (out += chunk)); + child.once("error", reject); + child.once("close", () => resolve(out.trim())); + }); +} + +// Written by hand rather than through save and export: this is about the +// command wiring, and a fixed bundle keeps every host reading the same bytes. +async function writeBundle(directory, { id, revision = 1, note = "Shared work." }) { + await mkdir(path.join(directory, "knowledge"), { recursive: true }); + const now = new Date().toISOString(); + await writeFile( + path.join(directory, "context.json"), + `${JSON.stringify( + { + schema: 2, + id, + name: "Shared Incident", + profileFile: "profile.md", + createdAt: now, + updatedAt: now, + revision, + knowledgeFolder: "knowledge", + knowledgeManaged: true, + capturedFrom: "conversation", + routingDescription: "Checkout 5xx, pgbouncer pool exhaustion, INC-* tickets" + }, + null, + 2 + )}\n` + ); + await writeFile( + path.join(directory, "profile.md"), + "# Shared Incident\n\n## Purpose\nCarry the incident findings.\n\n" + + "## What to do\nUse the recorded root cause.\n\n## What to avoid\nDo not re-derive it.\n\n" + + "## Behavior\nCite the runbook.\n" + ); + await writeFile(path.join(directory, "knowledge", "session-summary.md"), `# Summary\n\n${note}\n`); + return directory; +} + +describe("every host resolves an import rather than always creating", () => { + for (const host of HOSTS) { + it(`${host.name} imports once, then reports the same bundle as current`, async (t) => { + const home = await mkdtemp(path.join(os.tmpdir(), "neatcontext-import-host-")); + t.after(async () => { + await rm(home, { recursive: true, force: true }); + }); + const cli = path.join(host.dir, "neatcontext-cli.mjs"); + const env = { ...process.env, NEATCONTEXT_HOME: home, ...host.session }; + const call = (...args) => run(cli, [...(host.cliArgs ?? []), ...args], env); + + const bundle = await writeBundle(path.join(home, "bundle"), { + id: "context:shared-incident-aabbccddeeff" + }); + + const first = await call("import", "--from", bundle); + assert.match(first, /Imported the "Shared Incident" conversation context/); + assert.match( + first, + new RegExp(`Connect it with:\\s+${host.useCommand.replace("$", "\\$")} Shared Incident`) + ); + + const second = await call("import", "--from", bundle); + assert.match(second, /Import action: current/, `${host.name} re-imported instead of resolving`); + assert.doesNotMatch(second, /Imported the "Shared Incident"/); + }); + } +}); diff --git a/tests/import-reconcile.test.mjs b/tests/import-reconcile.test.mjs new file mode 100644 index 0000000..8388089 --- /dev/null +++ b/tests/import-reconcile.test.mjs @@ -0,0 +1,435 @@ +// Importing the same context twice. +// +// The first import is a create and always was. Every import after it is the +// interesting one: the bundle is a newer copy of something already here, and +// the plugin has to work out whether taking it would cost anything before it +// takes it. These protect the four answers — nothing to do, take it whole, +// reconcile first, or ask — and the two things that must never happen: losing +// local work to a replacement, and losing the context's identity to a +// re-create. + +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { after, before, beforeEach, describe, it } from "node:test"; + +const claude = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "plugins", + "claude-code", + "neatcontext", + "src", + "claude" +); + +let home; +let serial = 0; + +before(async () => { + home = await mkdtemp(path.join(os.tmpdir(), "neatcontext-import-test-")); + // The few checks that call the store directly read the same home the CLI does. + process.env.NEATCONTEXT_HOME = home; +}); + +after(async () => { + await rm(home, { recursive: true, force: true }); +}); + +beforeEach(async () => { + await rm(path.join(home, "contexts"), { recursive: true, force: true }); + await rm(path.join(home, "plugin-selection.json"), { force: true }); + await rm(path.join(home, "plugin-sessions"), { recursive: true, force: true }); + await rm(path.join(home, "plugin-routing.json"), { recursive: true, force: true }); +}); + +function cli(...args) { + return new Promise((resolve) => { + const child = spawn(process.execPath, [path.join(claude, "neatcontext-cli.mjs"), ...args], { + stdio: ["ignore", "pipe", "inherit"], + env: { + ...process.env, + CLAUDE_CODE_SESSION_ID: "", + NEATCONTEXT_HOST_KEY: "", + CLAUDE_PID: "", + NEATCONTEXT_HOME: home + } + }); + let out = ""; + child.stdout.on("data", (chunk) => (out += chunk)); + child.on("exit", () => resolve(out.trim())); + }); +} + +function capture(overrides = {}) { + return { + schema: 1, + name: "Team Checkout", + profile: + "# Team Checkout\n\n## Purpose\nCheckout recovery.\n\n" + + "## What to do\nUse the recorded decisions.\n\n" + + "## What to avoid\nDo not invent deployment state.\n\n" + + "## Behavior\nSeparate verified facts from open work.", + routingDescription: "Checkout recovery, payment retries, PAY-* tickets", + knowledge: [{ path: "session-summary.md", content: "# Session summary\n\nOriginal work." }], + ...overrides + }; +} + +async function save(spec = capture(), { yes = false } = {}) { + const file = path.join(home, `capture-${serial++}.json`); + await writeFile(file, JSON.stringify(spec)); + return cli("save", "--from", file, ...(yes ? ["--yes"] : [])); +} + +const field = (output, label) => new RegExp(`^${label}: (.+)$`, "m").exec(output)?.[1]; +const localBundle = (output) => /Local bundle:\s+(.+)/.exec(output)?.[1]; + +const manifestAt = async (directory) => + JSON.parse(await readFile(path.join(directory, "context.json"), "utf8")); + +// A context saved here, exported, then deleted — leaving only the bundle a +// teammate would have handed over. +async function sharedBundle(spec = capture()) { + const saved = await save(spec); + const directory = /Context folder:\s+(.+)/.exec(saved)?.[1]; + const destination = path.join(home, `share-${serial++}`); + await cli("export", spec.name, "--to", destination); + await cli("delete", spec.name, "--yes"); + return path.join(destination, path.basename(directory)); +} + +// What a teammate doing more work and re-sharing looks like from this side. +async function upstreamUpdate(bundle, { profileNote, file }) { + const manifest = await manifestAt(bundle); + manifest.revision += 1; + manifest.updatedAt = new Date().toISOString(); + await writeFile(path.join(bundle, "context.json"), `${JSON.stringify(manifest, null, 2)}\n`); + if (profileNote) { + const profilePath = path.join(bundle, "profile.md"); + await writeFile(profilePath, `${await readFile(profilePath, "utf8")}\n${profileNote}\n`); + } + if (file) await writeFile(path.join(bundle, "knowledge", file.path), file.content); +} + +// Saving local work into the imported copy, the way a session would. +async function localWork(name, knowledge) { + const target = await cli("save-target", name); + return save( + capture({ + name, + targetId: field(target, "Context id"), + baseHash: field(target, "Base hash"), + knowledge: [{ path: "session-summary.md", content: knowledge }] + }), + { yes: true } + ); +} + +describe("importing a bundle this machine already has", () => { + it("records where a copy came from and recognises the same bundle again", async () => { + const bundle = await sharedBundle(); + const imported = await cli("import", "--from", bundle); + assert.match(imported, /Imported the "Team Checkout" conversation context/); + + const lineage = (await manifestAt(localBundle(imported))).importedFrom; + assert.equal(lineage.id, (await manifestAt(bundle)).id); + assert.equal(lineage.revision, 1); + assert.equal(typeof lineage.fingerprint, "string"); + assert.equal(typeof lineage.bundleFingerprint, "string"); + + const again = await cli("import", "--from", bundle); + assert.match(again, /Import action: current/); + assert.match(again, /already holds everything in this bundle/); + }); + + it("previews a newer copy and takes it in place, keeping the same context", async () => { + const bundle = await sharedBundle(); + const directory = localBundle(await cli("import", "--from", bundle)); + const before = await manifestAt(directory); + await cli("use", "Team Checkout"); + + await upstreamUpdate(bundle, { + profileNote: "Upstream: retries are capped.", + file: { path: "runbook.md", content: "# Runbook\n\nRestart the worker.\n" } + }); + + const preview = await cli("import", "--from", bundle); + assert.match(preview, /Import action: replace/); + assert.match(preview, /has not been edited here since/); + assert.match(preview, /Their revision: 2 \(you last took revision 1\)/); + assert.match(preview, /Add: runbook\.md/); + assert.match(preview, /Re-run this import with --yes/); + // A preview writes nothing. + assert.deepEqual(await manifestAt(directory), before); + + const applied = await cli("import", "--from", bundle, "--yes"); + assert.match(applied, /Updated the "Team Checkout" context from the bundle/); + + const after = await manifestAt(directory); + assert.equal(after.id, before.id, "a replacement must not mint a new context id"); + assert.equal(after.updatedFrom, "import"); + assert.equal(after.importedFrom.revision, 2); + assert.match( + await readFile(path.join(directory, "knowledge", "runbook.md"), "utf8"), + /Restart the worker/ + ); + // Same context, so the session that was connected still is. + assert.match(await cli("status"), /Team Checkout/); + assert.match(await cli("list"), /Team Checkout\s+\(connected\)/); + }); + + it("offers a merge instead of a replacement once local work exists", async () => { + const bundle = await sharedBundle(); + const directory = localBundle(await cli("import", "--from", bundle)); + await localWork("Team Checkout", "# Session summary\n\nLocal work happened here."); + await upstreamUpdate(bundle, { profileNote: "Upstream: provider raised the limit." }); + + const resolved = await cli("import", "--from", bundle); + assert.match(resolved, /Import action: merge/); + assert.match(resolved, /both copies have changed since/); + assert.match(resolved, /would discard the work saved here/); + assert.equal(field(resolved, "Context id"), (await manifestAt(directory)).id); + assert.equal(field(resolved, "Bundle profile"), path.join(bundle, "profile.md")); + assert.doesNotMatch(resolved, /--yes/, "a merge is never offered as a one-key overwrite"); + // Nothing was taken: the local work is still the only thing there. + assert.match( + await readFile(path.join(directory, "knowledge", "session-summary.md"), "utf8"), + /Local work happened here/ + ); + }); + + it("applies a merged capture, then treats that bundle as consumed", async () => { + const bundle = await sharedBundle(); + const directory = localBundle(await cli("import", "--from", bundle)); + await localWork("Team Checkout", "# Session summary\n\nLocal work happened here."); + await upstreamUpdate(bundle, { + profileNote: "Upstream: provider raised the limit.", + file: { path: "runbook.md", content: "# Runbook\n\nRestart the worker.\n" } + }); + const resolved = await cli("import", "--from", bundle); + + const merged = path.join(home, `merged-${serial++}.json`); + await writeFile( + merged, + JSON.stringify({ + schema: 1, + name: field(resolved, "Context name"), + targetId: field(resolved, "Context id"), + baseHash: field(resolved, "Base hash"), + profile: capture().profile + "\n\nBoth: retries capped and the limit was raised.", + routingDescription: "Checkout recovery, payment retries, PAY-* tickets", + knowledge: [ + { path: "session-summary.md", content: "# Session summary\n\nBoth sides, reconciled." }, + { path: "runbook.md", content: "# Runbook\n\nRestart the worker.\n" } + ] + }) + ); + + const preview = await cli("import", "--from", bundle, "--merged-from", merged); + assert.match(preview, /Merge the bundle into the "Team Checkout" context\?/); + assert.match(preview, /Re-run this import with --yes/); + assert.match( + await readFile(path.join(directory, "knowledge", "session-summary.md"), "utf8"), + /Local work happened here/, + "a merge preview must not write" + ); + assert.ok(await readFile(merged, "utf8"), "a preview must leave the draft for repair"); + + const applied = await cli( + "import", "--from", bundle, "--merged-from", merged, "--yes", "--consume" + ); + assert.match(applied, /Merged the bundle into the "Team Checkout" context\./); + assert.match( + await readFile(path.join(directory, "knowledge", "session-summary.md"), "utf8"), + /Both sides, reconciled/ + ); + await assert.rejects(() => readFile(merged, "utf8"), "a confirmed merge consumes the draft"); + + // The point of re-stamping lineage: the same divergence is not re-offered + // against a stale baseline every time the bundle is seen again. + assert.match(await cli("import", "--from", bundle), /Import action: current/); + }); + + it("keeps matching after the other side renames, and keeps the local name", async () => { + const bundle = await sharedBundle(); + const directory = localBundle(await cli("import", "--from", bundle)); + + const manifest = await manifestAt(bundle); + manifest.name = "Checkout Recovery (renamed upstream)"; + manifest.revision += 1; + await writeFile(path.join(bundle, "context.json"), `${JSON.stringify(manifest, null, 2)}\n`); + await upstreamUpdate(bundle, { profileNote: "Upstream: renamed and revised." }); + + assert.match(await cli("import", "--from", bundle), /Import action: replace/); + await cli("import", "--from", bundle, "--yes"); + assert.equal( + (await manifestAt(directory)).name, + "Team Checkout", + "a rename upstream must not rename the copy here" + ); + }); +}); + +describe("importing a bundle whose identity cannot be proved", () => { + // A bundle from somewhere else that happens to use a name already taken. + async function strangerBundle() { + const bundle = await sharedBundle(); + await cli("import", "--from", bundle); + const stranger = path.join(home, `stranger-${serial++}`); + await cp(bundle, stranger, { recursive: true }); + const manifest = await manifestAt(stranger); + manifest.id = `context:someone-else-${serial++}00000000`; + await writeFile( + path.join(stranger, "context.json"), + `${JSON.stringify(manifest, null, 2)}\n` + ); + await writeFile( + path.join(stranger, "profile.md"), + "# Team Checkout\n\n## Purpose\nA different team's checkout work.\n" + ); + return stranger; + } + + it("asks which context it is rather than guessing", async () => { + const stranger = await strangerBundle(); + const resolved = await cli("import", "--from", stranger); + assert.match(resolved, /Import action: choose/); + assert.match(resolved, /nothing records that it came from this bundle/); + assert.match(resolved, /--into "Team Checkout"/); + assert.match(resolved, /--name ""/); + assert.doesNotMatch(resolved, /Re-run this import with --yes/); + }); + + it("refuses to adopt a context that is not here", async () => { + const stranger = await strangerBundle(); + assert.match( + await cli("import", "--from", stranger, "--into", "Not A Context"), + /No context here is named "Not A Context"/ + ); + }); + + it("imports a bundle carrying no id, and records no lineage for it", async () => { + const bundle = await sharedBundle(); + const manifest = await manifestAt(bundle); + delete manifest.id; + await writeFile(path.join(bundle, "context.json"), `${JSON.stringify(manifest, null, 2)}\n`); + + const imported = await cli("import", "--from", bundle); + assert.match(imported, /Imported the "Team Checkout" conversation context/); + assert.equal( + (await manifestAt(localBundle(imported))).importedFrom, + undefined, + "there is nothing to key a later import on, so nothing is claimed" + ); + }); + + it("reconciles rather than overwrites when a context is adopted into a lineage", async () => { + const stranger = await strangerBundle(); + const adopted = await cli("import", "--from", stranger, "--into", "Team Checkout"); + // The local copy is untouched since its own import, but that baseline was + // left by a different origin and cannot license replacing it. + assert.match(adopted, /Import action: merge/); + assert.match(adopted, /is being treated as this bundle's copy/); + assert.doesNotMatch(adopted, /Their revision/, "no shared history to count from"); + }); + + it("forks on an explicit name, without calling a different context a duplicate", async () => { + const stranger = await strangerBundle(); + const forked = await cli("import", "--from", stranger, "--name", "Their Team Checkout"); + assert.match(forked, /Imported the "Their Team Checkout" conversation context/); + assert.doesNotMatch( + forked, + /already a copy of this bundle/, + "a name collision between unrelated contexts is not a duplicate" + ); + + const list = await cli("list"); + assert.match(list, /Team Checkout/); + assert.match(list, /Their Team Checkout/); + }); +}); + +describe("importing a bundle a second time under a new name", () => { + it("keeps both copies and says what that costs", async () => { + const bundle = await sharedBundle(); + await cli("import", "--from", bundle); + + const forked = await cli("import", "--from", bundle, "--name", "Team Checkout Fork"); + assert.match(forked, /Imported the "Team Checkout Fork" conversation context/); + assert.match(forked, /"Team Checkout" is already a copy of this bundle/); + assert.match(forked, /both will be considered whenever a session routes itself/); + assert.match(await cli("list"), /Team Checkout Fork/); + }); +}); + +// Two paths the command line cannot stage: a target deleted between resolving +// an import and applying it, and a lineage stamp that fails after the import +// has already landed. Both are reached directly, because what they protect is +// only visible at the seam. +describe("what import does when the ground moves under it", () => { + it("refuses to replace a context that has since been deleted", async () => { + const { replaceContextFromBundle, ContextError } = await import( + "../plugins/claude-code/neatcontext/src/core/context-store.mjs" + ); + const bundle = await sharedBundle(); + await assert.rejects( + () => + replaceContextFromBundle({ + bundleFolder: bundle, + targetId: "context:deleted-while-importing", + baseHash: "irrelevant" + }), + (error) => + error instanceof ContextError && + /no longer exists/.test(error.message) + ); + }); + + it("keeps an imported context when its lineage stamp cannot be written", async () => { + const { readImportBundle, recordImportLineage } = await import( + "../plugins/claude-code/neatcontext/src/core/context-store.mjs" + ); + const bundle = await readImportBundle(await sharedBundle()); + // A record whose directory holds no manifest to patch. + const record = { + id: "context:no-manifest-here", + name: "No Manifest", + directory: path.join(home, `vanished-${serial++}`), + knowledgeFolder: path.join(home, "missing-knowledge"), + knowledgeManaged: true, + profilePath: path.join(home, "missing-profile.md"), + routingDescription: "", + extensions: [], + capturedFrom: "conversation", + createdAt: null, + updatedAt: null, + revision: 1 + }; + assert.equal( + await recordImportLineage(record, bundle), + record, + "the import survives; only the bookkeeping is lost" + ); + }); +}); + +describe("what a bundle carries out of this machine", () => { + it("leaves local lineage behind when exporting an imported context", async () => { + const bundle = await sharedBundle(); + const directory = localBundle(await cli("import", "--from", bundle)); + assert.ok((await manifestAt(directory)).importedFrom); + + const destination = path.join(home, `re-export-${serial++}`); + await cli("export", "Team Checkout", "--to", destination); + const reExported = path.join(destination, path.basename(directory)); + assert.equal( + (await manifestAt(reExported)).importedFrom, + undefined, + "lineage describes this machine's copy and means nothing to a recipient" + ); + }); +}); diff --git a/tools/sync-context-core.mjs b/tools/sync-context-core.mjs index 4ac01b7..2f48715 100644 --- a/tools/sync-context-core.mjs +++ b/tools/sync-context-core.mjs @@ -13,6 +13,7 @@ const files = [ "extension-commands.mjs", "extension-runtime.mjs", "extensions.mjs", + "import-commands.mjs", "local-state.mjs", "mcp-stdio-client.mjs", "routing.mjs", From 25fbd8787006894f9b05e3b063d1c203c1b3f514 Mon Sep 17 00:00:00 2001 From: tanglearncode Date: Fri, 14 Aug 2026 04:02:26 +0800 Subject: [PATCH 2/4] test(import): cover the merged-capture validation gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI caught these; the local run could not. The new module was untracked when coverage ran here, so it was absent from the diff and its lines were never inspected — the four refusals a merged capture can meet, and the draft that reproduces what is already stored, all went unexercised. Each is checked to leave the context byte-for-byte untouched. A merge carrying no targetId is the one worth naming: it would apply as a create and produce exactly the duplicate this command exists to prevent. Co-Authored-By: Claude Opus 5 --- tests/import-reconcile.test.mjs | 56 +++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/tests/import-reconcile.test.mjs b/tests/import-reconcile.test.mjs index 8388089..432d0c2 100644 --- a/tests/import-reconcile.test.mjs +++ b/tests/import-reconcile.test.mjs @@ -254,6 +254,62 @@ describe("importing a bundle this machine already has", () => { assert.match(await cli("import", "--from", bundle), /Import action: current/); }); + it("rejects a merged capture it cannot trust, without touching the context", async () => { + const bundle = await sharedBundle(); + const directory = localBundle(await cli("import", "--from", bundle)); + await localWork("Team Checkout", "# Session summary\n\nLocal work happened here."); + await upstreamUpdate(bundle, { profileNote: "Upstream: provider raised the limit." }); + const resolved = await cli("import", "--from", bundle); + const before = await manifestAt(directory); + + const draft = async (contents) => { + const file = path.join(home, `bad-merge-${serial++}.json`); + await writeFile(file, contents); + return cli("import", "--from", bundle, "--merged-from", file, "--yes", "--consume"); + }; + + assert.match( + await cli("import", "--from", bundle, "--merged-from", path.join(home, "not-written.json")), + /Could not read a valid merged capture JSON file/ + ); + assert.match(await draft("{not json"), /Could not read a valid merged capture JSON file/); + assert.match( + await draft(JSON.stringify({ schema: 9, name: "Team Checkout" })), + /Unsupported merged capture schema\. Expected schema 1\./ + ); + // A merge without a target is a create wearing the wrong hat: it would make + // the duplicate the whole command exists to avoid. + assert.match( + await draft(JSON.stringify({ schema: 1, name: "Team Checkout" })), + /must carry the exact targetId and baseHash this import printed/ + ); + + // A capture that reproduces what is already stored changes nothing, and is + // reported rather than written as a no-op revision. + const unchanged = await draft( + JSON.stringify({ + schema: 1, + name: field(resolved, "Context name"), + targetId: field(resolved, "Context id"), + baseHash: field(resolved, "Base hash"), + profile: await readFile(path.join(directory, "profile.md"), "utf8"), + routingDescription: before.routingDescription, + knowledge: [ + { + path: "session-summary.md", + content: await readFile( + path.join(directory, "knowledge", "session-summary.md"), + "utf8" + ) + } + ] + }) + ); + assert.match(unchanged, /The merge does not change the "Team Checkout" context\./); + + assert.deepEqual(await manifestAt(directory), before, "no rejected draft may write"); + }); + it("keeps matching after the other side renames, and keeps the local name", async () => { const bundle = await sharedBundle(); const directory = localBundle(await cli("import", "--from", bundle)); From 6789a0b131a56876dbef6b292f3c21bdc4bb6061 Mon Sep 17 00:00:00 2001 From: tanglearncode Date: Fri, 14 Aug 2026 04:05:09 +0800 Subject: [PATCH 3/4] test(import): escape a host prefix completely, not just its first symbol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex spells its connect command `$neatcontext:use`, and the host sweep escaped that `$` with a single-occurrence replace before building a RegExp from it. It happened to be correct for one leading symbol and would silently stop being correct for anything else — CodeQL flagged the incomplete escaping. Co-Authored-By: Claude Opus 5 --- tests/import-hosts.test.mjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/import-hosts.test.mjs b/tests/import-hosts.test.mjs index 53308af..378e2b8 100644 --- a/tests/import-hosts.test.mjs +++ b/tests/import-hosts.test.mjs @@ -44,6 +44,11 @@ const HOSTS = [ } ]; +// Host connect commands carry regex metacharacters — Codex spells its prefix +// `$neatcontext:`. Escaped in full rather than per-character, so adding a host +// whose prefix uses some other symbol cannot quietly turn this into a pattern. +const literal = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + function run(file, args, env) { return new Promise((resolve, reject) => { const child = spawn(process.execPath, [file, ...args], { @@ -113,7 +118,7 @@ describe("every host resolves an import rather than always creating", () => { assert.match(first, /Imported the "Shared Incident" conversation context/); assert.match( first, - new RegExp(`Connect it with:\\s+${host.useCommand.replace("$", "\\$")} Shared Incident`) + new RegExp(`Connect it with:\\s+${literal(host.useCommand)} Shared Incident`) ); const second = await call("import", "--from", bundle); From a7df5a4fb5112ace82105100bc199e2e83ca03ef Mon Sep 17 00:00:00 2001 From: tanglearncode Date: Fri, 14 Aug 2026 07:08:20 +0800 Subject: [PATCH 4/4] fix(import): close four ways a reconciliation could go to the wrong place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four were review findings, and each is a case where import wrote somewhere plausible rather than somewhere proven. A merged capture proved only that it was built against some local context at a known base hash. It never proved it was built for the copy this bundle belongs to, nor from the bundle in front of it — so an unrelated context could be updated and then have this lineage stamped over its own, and a draft could be applied after upstream moved, marking material as taken that the merge had never seen. The draft now carries a bundle hash beside the base hash, and both the target's lineage and that hash are checked before anything is written. Forking with --name left two contexts holding one lineage id, and the next import picked between them by list order — alphabetically, so a fork could quietly become the thing that got updated. Several copies is now an answer of its own: it lists them and asks for --into. Adopting a context whose contents already matched the bundle recorded nothing, so the same question came back on the next import and no answer to it could ever fast-forward. Adoption is now persisted the moment it is asserted, as identity alone: claiming the contents had been taken would make the next import report `current` over a copy that never received them. And a routing description written with `describe` lives only in the routing card, where neither the fingerprint nor the manifest can see it — so a fast-forward silently put the bundle's line back. It is now read before the write and kept after it, and the import says so. Deliberately not treated as divergence: a routing tweak is not knowledge and does not need a merge to settle. Co-Authored-By: Claude Opus 5 --- .../neatcontext/skills/import/SKILL.md | 7 +- .../neatcontext/src/core/context-store.mjs | 104 +++++++--- .../neatcontext/src/core/import-commands.mjs | 87 ++++++++- .../plugins/neatcontext/src/core/routing.mjs | 2 +- .../neatcontext/commands/import.md | 22 ++- .../neatcontext/src/core/context-store.mjs | 104 +++++++--- .../neatcontext/src/core/import-commands.mjs | 87 ++++++++- .../neatcontext/src/core/routing.mjs | 2 +- .../copilot/neatcontext/commands/import.md | 22 ++- .../neatcontext/src/core/context-store.mjs | 104 +++++++--- .../neatcontext/src/core/import-commands.mjs | 87 ++++++++- .../copilot/neatcontext/src/core/routing.mjs | 2 +- .../neatcontext/skills/import/SKILL.md | 7 +- .../neatcontext/src/core/context-store.mjs | 104 +++++++--- .../neatcontext/src/core/import-commands.mjs | 87 ++++++++- .../neatcontext/src/core/routing.mjs | 2 +- .../pi/neatcontext/src/core/context-store.mjs | 104 +++++++--- .../neatcontext/src/core/import-commands.mjs | 87 ++++++++- plugins/pi/neatcontext/src/core/routing.mjs | 2 +- shared/core/context-store.mjs | 104 +++++++--- shared/core/import-commands.mjs | 87 ++++++++- shared/core/routing.mjs | 2 +- tests/import-reconcile.test.mjs | 179 ++++++++++++++++-- 23 files changed, 1176 insertions(+), 219 deletions(-) diff --git a/codex-marketplace/plugins/neatcontext/skills/import/SKILL.md b/codex-marketplace/plugins/neatcontext/skills/import/SKILL.md index 69c4bf7..a387f64 100644 --- a/codex-marketplace/plugins/neatcontext/skills/import/SKILL.md +++ b/codex-marketplace/plugins/neatcontext/skills/import/SKILL.md @@ -24,13 +24,13 @@ A bundle this machine has not seen is imported immediately and the output says s - `current` — the context here already holds everything in the bundle. Relay that and stop. - `replace` — the local copy came from this bundle and has not been edited since, so the newer copy can be taken whole. Relay the preview, ask the user to confirm, and only then rerun the same command with `--yes`. - `merge` — both copies have changed. Reconcile them yourself, below. -- `choose` — a context of the same name is here but nothing records a shared origin. Relay both options and stop until the user picks one: rerun with `--into ""` to treat it as the same context, or with `--name ""` to keep both as separate contexts. +- `choose` — the target is not decidable. Either a context of the same name is here but nothing records a shared origin, or several contexts are copies of this bundle because one was forked. Relay the options and stop until the user picks: rerun with `--into ""` to name the context they mean, or with `--name ""` to keep a separate copy. Never answer `choose` on the user's behalf. Two people naming a context the same thing is not evidence that it is the same context, and the two answers are not recoverable from each other. ## Merging -Use the exact `Context name`, `Context id`, `Base hash`, `Profile path`, `Knowledge folder`, `Bundle profile`, and `Bundle knowledge` values the command printed. Read the local profile and every file in the local knowledge folder, then read the bundle's profile and every file in its knowledge folder. +Use the exact `Context name`, `Context id`, `Base hash`, `Bundle hash`, `Profile path`, `Knowledge folder`, `Bundle profile`, and `Bundle knowledge` values the command printed. The three hashes are what prove the merge is for this context, was built on its current contents, and consumed this version of the bundle; a merge that gets any of them wrong is refused rather than applied. Read the local profile and every file in the local knowledge folder, then read the bundle's profile and every file in its knowledge folder. Merge them the way a save merges a conversation into an existing context: @@ -40,7 +40,7 @@ Merge them the way a save merges a conversation into an existing context: - Preserve the profile and routing description verbatim when neither side changed the behavioral contract or the matching scope. - The `knowledge` array must be the complete post-merge contents of the local knowledge folder. -Create a unique scratch file named `.neatcontext-capture-import-.json` in the current workspace. Use schema `1`, and include the exact `targetId` and `baseHash` the command printed: +Create a unique scratch file named `.neatcontext-capture-import-.json` in the current workspace. Use schema `1`, and include the exact `targetId`, `baseHash`, and `bundleHash` the command printed: ```json { @@ -48,6 +48,7 @@ Create a unique scratch file named `.neatcontext-capture-import-.json` i "name": "Exact existing context name", "targetId": "context:exact-id", "baseHash": "exact base hash", + "bundleHash": "exact bundle hash", "profile": "# Exact existing context name\n\n## Purpose\n...", "routingDescription": "One line describing only the matching scope", "knowledge": [{ "path": "session-summary.md", "content": "# Session summary\n\n..." }] diff --git a/codex-marketplace/plugins/neatcontext/src/core/context-store.mjs b/codex-marketplace/plugins/neatcontext/src/core/context-store.mjs index ec6e738..eadabee 100644 --- a/codex-marketplace/plugins/neatcontext/src/core/context-store.mjs +++ b/codex-marketplace/plugins/neatcontext/src/core/context-store.mjs @@ -972,23 +972,30 @@ function fingerprintImportBundle(bundle) { // Exported so the give-up path is directly testable. It matters more than it // looks: by the time this runs the import has already landed, so a failure here // must cost the bookkeeping and never the context that was just written. -export async function recordImportLineage(record, bundle) { +export async function recordImportLineage(record, bundle, { identityOnly = false } = {}) { const { manifest } = bundle; if (typeof manifest?.id !== "string" || manifest.id.length === 0) { return record; } - const lineage = { - id: manifest.id, - revision: - Number.isInteger(manifest.revision) && manifest.revision > 0 ? manifest.revision : 1, - updatedAt: typeof manifest.updatedAt === "string" ? manifest.updatedAt : null, - fingerprint: await fingerprintContext(record), - // The other half of the baseline, and the one a merge depends on. A merged - // context deliberately matches neither side, so "did this copy change?" - // cannot decide whether there is anything left to take — only "did theirs?" - // can, and this is what answers it. - bundleFingerprint: fingerprintImportBundle(bundle) - }; + // Adoption records who this copy is and nothing about what it holds. The + // baselines say "this content came from that bundle", which is a claim only a + // completed take can make: writing them here would tell the next import the + // material had already been merged, and it would answer `current` over a copy + // that never received a byte of it. + const lineage = identityOnly + ? { id: manifest.id, revision: null, updatedAt: null, fingerprint: null, bundleFingerprint: null } + : { + id: manifest.id, + revision: + Number.isInteger(manifest.revision) && manifest.revision > 0 ? manifest.revision : 1, + updatedAt: typeof manifest.updatedAt === "string" ? manifest.updatedAt : null, + fingerprint: await fingerprintContext(record), + // The other half of the baseline, and the one a merge depends on. A + // merged context deliberately matches neither side, so "did this copy + // change?" cannot decide whether there is anything left to take — only + // "did theirs?" can, and this is what answers it. + bundleFingerprint: fingerprintImportBundle(bundle) + }; const manifestPath = path.join(record.directory, "context.json"); const temporaryPath = path.join( record.directory, @@ -1060,19 +1067,36 @@ export async function resolveImportTarget({ bundleFolder, into }) { throw new ContextError(`No context here is named "${adopt}".`); } - const lineage = - adopted ?? - (bundleId - ? contexts.find( - (context) => context.id === bundleId || context.importedFrom?.id === bundleId - ) - : null); + const bundleHash = fingerprintImportBundle(bundle); + const lineageMatches = bundleId + ? contexts.filter( + (context) => context.id === bundleId || context.importedFrom?.id === bundleId + ) + : []; + + // Forking with `--name` leaves two local contexts carrying one lineage id, so + // "the copy from this bundle" stops naming a single thing. Choosing one would + // be choosing by list order — alphabetical, since `listContexts` sorts by + // name — and quietly updating the fork instead of the original. Ask instead. + if (!adopted && lineageMatches.length > 1) { + return { + action: "choose", + bundle, + bundleHash, + localName, + record: null, + candidates: lineageMatches, + matchedBy: "ambiguous" + }; + } + + const lineage = adopted ?? lineageMatches[0] ?? null; const named = contexts.find( (context) => context.name.toLowerCase() === localName.toLowerCase() ); const record = lineage ?? named ?? null; if (!record) { - return { action: "create", bundle, localName, record: null, matchedBy: null }; + return { action: "create", bundle, bundleHash, localName, record: null, matchedBy: null }; } const baseHash = await fingerprintContext(record); @@ -1081,7 +1105,7 @@ export async function resolveImportTarget({ bundleFolder, into }) { baseHash }); const matchedBy = adopted ? "adopted" : lineage ? "lineage" : "name"; - const base = { bundle, localName, record, matchedBy, baseHash, preview }; + const base = { bundle, bundleHash, localName, record, matchedBy, baseHash, preview }; if (!lineage) { return { ...base, action: "choose" }; @@ -1095,7 +1119,7 @@ export async function resolveImportTarget({ bundleFolder, into }) { // whatever the two copies now look like. const stillCurrent = (typeof record.importedFrom?.bundleFingerprint === "string" && - record.importedFrom.bundleFingerprint === fingerprintImportBundle(bundle)) || + record.importedFrom.bundleFingerprint === bundleHash) || !preview.changed; if (stillCurrent) { return { ...base, action: "current" }; @@ -1137,8 +1161,42 @@ export async function replaceContextFromBundle({ bundleFolder, targetId, baseHas // The lineage is re-stamped from the bundle in the same breath: a merge that // did not record which version it consumed would be re-offered, against the // same stale baseline, on every later import. +// +// Two things are checked that `updateCapturedContext` cannot check for itself, +// because it knows about a target and knows nothing about a bundle. +// +// The target has to be this bundle's copy. A capture proves only that it was +// built against *some* local context at a known base hash, so without this an +// unrelated context could be updated here and then have this bundle's lineage +// stamped over its own. +// +// And the bundle has to be the one that was merged. Nothing stops upstream +// changing between drafting and applying, and stamping the newer fingerprint +// over an older draft is the worst outcome available: the late material is +// absent, and the next import says `current` and never offers it again. export async function applyImportMerge({ bundleFolder, capture }) { const bundle = await readImportBundle(bundleFolder); + const bundleId = typeof bundle.manifest.id === "string" ? bundle.manifest.id : null; + const record = await readContext( + typeof capture?.targetId === "string" ? capture.targetId : "" + ); + if (!record) { + throw new ContextError("The context this merge was prepared for no longer exists."); + } + if (!bundleId || record.importedFrom?.id !== bundleId) { + throw new ContextError( + `This merge is addressed to "${record.name}", which is not recorded as the copy ` + + "this bundle belongs to. Resolve the import again and rebuild the merge from what " + + "it prints." + ); + } + if (capture.bundleHash !== fingerprintImportBundle(bundle)) { + throw new ContextError( + "The bundle changed while this merge was being prepared, so applying it would drop " + + "whatever arrived late. Resolve the import again and rebuild the merge from the " + + "current bundle." + ); + } const result = await updateCapturedContext({ ...capture, updatedFrom: "import" }); return { ...result, record: await recordImportLineage(result.record, bundle) }; } diff --git a/codex-marketplace/plugins/neatcontext/src/core/import-commands.mjs b/codex-marketplace/plugins/neatcontext/src/core/import-commands.mjs index 8a54c8b..808b51c 100644 --- a/codex-marketplace/plugins/neatcontext/src/core/import-commands.mjs +++ b/codex-marketplace/plugins/neatcontext/src/core/import-commands.mjs @@ -23,14 +23,33 @@ import { ContextError, importCapturedContext, previewCapturedContextUpdate, + recordImportLineage, replaceContextFromBundle, resolveImportTarget } from "./context-store.mjs"; -import { putCard } from "./routing.mjs"; +import { MAX_USE_WHEN, putCard, readRouting } from "./routing.mjs"; -function refreshCard(result) { +const normalizeUseWhen = (text) => + (text ?? "").trim().replace(/\s+/g, " ").slice(0, MAX_USE_WHEN); + +// A routing line the user wrote with `describe` lives only in the routing card, +// never in the manifest — so the import baseline cannot see it, and taking a +// bundle whole would put the bundle's line back without either side noticing it +// had been overruled. It is not treated as divergence, because a routing tweak +// is not knowledge and does not need a merge to resolve; it is simply kept. +// +// Locally authored means the card and the manifest disagree, which is exactly +// what `describe` leaves behind and what an import or save never does. +async function authoredUseWhen(record) { + const routing = await readRouting().catch(() => null); + const stored = routing?.cards?.[record.id]?.useWhen ?? ""; + if (stored.length === 0) return null; + return stored === normalizeUseWhen(record.routingDescription) ? null : stored; +} + +function refreshCard(result, authored = null) { return putCard(result.record.id, { - useWhen: result.routingDescription, + useWhen: authored ?? result.routingDescription, source: result.profileText }).catch(() => undefined); } @@ -120,6 +139,13 @@ async function runMergedImport({ bundleFolder, mergedFrom, confirmed, consume }) ); return lines; } + if (typeof capture.bundleHash !== "string" || capture.bundleHash.length === 0) { + lines.push( + "A merged capture must carry the exact bundleHash this import printed — it is what " + + "records which version of the bundle was actually merged." + ); + return lines; + } const preview = await previewCapturedContextUpdate(capture); if (!preview.changed) { @@ -133,8 +159,9 @@ async function runMergedImport({ bundleFolder, mergedFrom, confirmed, consume }) return lines; } + const authored = await authoredUseWhen(preview.record); const result = await applyImportMerge({ bundleFolder, capture }); - await refreshCard(result); + await refreshCard(result, authored); // Only once it has landed, and only when asked. A preview must leave the // draft where it is, and so must any failure, or a merge the model spent the // conversation building would have to be rebuilt from nothing. @@ -190,6 +217,12 @@ export async function runImport({ "separate contexts holding the same material, and both will be considered " + "whenever a session routes itself." ); + } else if (resolved.matchedBy === "ambiguous") { + lines.push( + `Note: ${resolved.candidates.length} contexts here were already copies of this ` + + `bundle, and this makes ${resolved.candidates.length + 1}. A later import cannot ` + + 'tell which one you mean and will ask, so name it with --into "".' + ); } describeImported(lines, created, source, useCommand); return lines.join("\n"); @@ -205,6 +238,17 @@ export async function runImport({ if (resolved.action === "current") { lines.push("Import action: current"); lines.push(`"${record.name}" already holds everything in this bundle. Nothing to import.`); + // Adoption is an answer about identity, and it has to survive even when + // there is no content to move. Left unrecorded, the next bundle from this + // origin would ask the same question again — and no answer to it could + // ever fast-forward, because no baseline was ever written down. + if (resolved.matchedBy === "adopted") { + await recordImportLineage(record, bundle, { identityOnly: true }); + lines.push( + `Recorded that "${record.name}" is this bundle's copy, so a later one is ` + + "recognised without being told again." + ); + } return lines.join("\n"); } @@ -213,6 +257,22 @@ export async function runImport({ // adopts the local context as this bundle's copy; since no baseline against // this bundle exists for it, adopting leads to a merge and never to a // replacement. + // Several local contexts already carry this bundle's lineage, which is what + // forking leaves behind. Any of them could be the one meant, and picking is + // the user's call rather than the list order's. + if (resolved.action === "choose" && resolved.matchedBy === "ambiguous") { + lines.push("Import action: choose"); + lines.push( + `${resolved.candidates.length} contexts here are copies of this bundle, so there ` + + "is no single one to update:" + ); + for (const candidate of resolved.candidates) { + lines.push(` ${candidate.name}`); + } + lines.push('Name the one you mean with --into "".'); + return lines.join("\n"); + } + if (resolved.action === "choose") { lines.push("Import action: choose"); lines.push( @@ -242,14 +302,25 @@ export async function runImport({ "to be reconciled first." ); describeDistance(lines, record, bundle); + // Adoption is recorded now rather than at apply time, so the merge that + // follows can be checked against a target this bundle is known to belong + // to. Identity only: nothing has been taken from the bundle yet. + if (resolved.matchedBy === "adopted") { + await recordImportLineage(record, bundle, { identityOnly: true }); + } lines.push(`Context name: ${record.name}`); lines.push(`Context id: ${record.id}`); lines.push(`Base hash: ${resolved.baseHash}`); + lines.push(`Bundle hash: ${resolved.bundleHash}`); lines.push(`Profile path: ${record.profilePath}`); lines.push(`Knowledge folder: ${record.knowledgeFolder}`); lines.push(`Bundle profile: ${path.join(source, "profile.md")}`); lines.push(`Bundle knowledge: ${path.join(source, "knowledge")}`); - lines.push("Merge both sides, then apply the result with --merged-from."); + lines.push( + "Merge both sides, then apply the result with --merged-from. Carry the context " + + "id, base hash, and bundle hash into the draft exactly as printed: they are what " + + "prove the merge is for this context and was built from this bundle." + ); return lines.join("\n"); } @@ -265,12 +336,16 @@ export async function runImport({ return lines.join("\n"); } + const authored = await authoredUseWhen(record); const result = await replaceContextFromBundle({ bundleFolder, targetId: record.id, baseHash: resolved.baseHash }); - await refreshCard(result); + await refreshCard(result, authored); + if (authored) { + lines.push(`Kept the routing description you set here: ${authored}`); + } describeUpdated( lines, result, diff --git a/codex-marketplace/plugins/neatcontext/src/core/routing.mjs b/codex-marketplace/plugins/neatcontext/src/core/routing.mjs index a7a2931..fd21faa 100644 --- a/codex-marketplace/plugins/neatcontext/src/core/routing.mjs +++ b/codex-marketplace/plugins/neatcontext/src/core/routing.mjs @@ -44,7 +44,7 @@ export const DEFAULT_MODE = "auto"; // 2 marks the file as one where a stored mode means somebody chose it. See // `chosenMode` for what schema 1 got wrong and why it cannot be read literally. const SCHEMA = 2; -const MAX_USE_WHEN = 240; +export const MAX_USE_WHEN = 240; const MAX_ALIASES = 12; const MAX_DECISIONS = 100; diff --git a/plugins/claude-code/neatcontext/commands/import.md b/plugins/claude-code/neatcontext/commands/import.md index aeb141f..68d3e37 100644 --- a/plugins/claude-code/neatcontext/commands/import.md +++ b/plugins/claude-code/neatcontext/commands/import.md @@ -37,10 +37,11 @@ the printed `Import action`: since, so the newer copy can be taken whole. Relay the preview, ask the user to confirm, and only then rerun the same command with `--yes`. - `merge` — both copies have changed. Reconcile them yourself, below. -- `choose` — a context of the same name is here but nothing records a shared - origin. Relay both options and stop until the user picks one: rerun with - `--into ""` to treat it as the same context, or with - `--name ""` to keep both as separate contexts. +- `choose` — the target is not decidable. Either a context of the same name is + here but nothing records a shared origin, or several contexts are copies of + this bundle because one was forked. Relay the options and stop until the user + picks: rerun with `--into ""` to name the context they mean, or with + `--name ""` to keep a separate copy. Never answer `choose` on the user's behalf. Two people naming a context the same thing is not evidence that it is the same context, and the two answers are not @@ -48,10 +49,14 @@ recoverable from each other. ## Merging -Use the exact `Context name`, `Context id`, `Base hash`, `Profile path`, -`Knowledge folder`, `Bundle profile`, and `Bundle knowledge` values the command -printed. Read the local profile and every file in the local knowledge folder, -then read the bundle's profile and every file in its knowledge folder. +Use the exact `Context name`, `Context id`, `Base hash`, `Bundle hash`, +`Profile path`, `Knowledge folder`, `Bundle profile`, and `Bundle knowledge` +values the command printed. The three hashes are what prove the merge is for +this context, was built on its current contents, and consumed this version of +the bundle; a merge that gets any of them wrong is refused rather than applied. + +Read the local profile and every file in the local knowledge folder, then read +the bundle's profile and every file in its knowledge folder. Merge them the way a save merges a conversation into an existing context: @@ -75,6 +80,7 @@ Write one valid JSON file, with no surrounding code fence, to: "name": "Exact existing context name", "targetId": "context:exact-id", "baseHash": "exact base hash", + "bundleHash": "exact bundle hash", "profile": "# Exact existing context name\n\n## Purpose\n...", "routingDescription": "One line describing only the matching scope", "knowledge": [ diff --git a/plugins/claude-code/neatcontext/src/core/context-store.mjs b/plugins/claude-code/neatcontext/src/core/context-store.mjs index ec6e738..eadabee 100644 --- a/plugins/claude-code/neatcontext/src/core/context-store.mjs +++ b/plugins/claude-code/neatcontext/src/core/context-store.mjs @@ -972,23 +972,30 @@ function fingerprintImportBundle(bundle) { // Exported so the give-up path is directly testable. It matters more than it // looks: by the time this runs the import has already landed, so a failure here // must cost the bookkeeping and never the context that was just written. -export async function recordImportLineage(record, bundle) { +export async function recordImportLineage(record, bundle, { identityOnly = false } = {}) { const { manifest } = bundle; if (typeof manifest?.id !== "string" || manifest.id.length === 0) { return record; } - const lineage = { - id: manifest.id, - revision: - Number.isInteger(manifest.revision) && manifest.revision > 0 ? manifest.revision : 1, - updatedAt: typeof manifest.updatedAt === "string" ? manifest.updatedAt : null, - fingerprint: await fingerprintContext(record), - // The other half of the baseline, and the one a merge depends on. A merged - // context deliberately matches neither side, so "did this copy change?" - // cannot decide whether there is anything left to take — only "did theirs?" - // can, and this is what answers it. - bundleFingerprint: fingerprintImportBundle(bundle) - }; + // Adoption records who this copy is and nothing about what it holds. The + // baselines say "this content came from that bundle", which is a claim only a + // completed take can make: writing them here would tell the next import the + // material had already been merged, and it would answer `current` over a copy + // that never received a byte of it. + const lineage = identityOnly + ? { id: manifest.id, revision: null, updatedAt: null, fingerprint: null, bundleFingerprint: null } + : { + id: manifest.id, + revision: + Number.isInteger(manifest.revision) && manifest.revision > 0 ? manifest.revision : 1, + updatedAt: typeof manifest.updatedAt === "string" ? manifest.updatedAt : null, + fingerprint: await fingerprintContext(record), + // The other half of the baseline, and the one a merge depends on. A + // merged context deliberately matches neither side, so "did this copy + // change?" cannot decide whether there is anything left to take — only + // "did theirs?" can, and this is what answers it. + bundleFingerprint: fingerprintImportBundle(bundle) + }; const manifestPath = path.join(record.directory, "context.json"); const temporaryPath = path.join( record.directory, @@ -1060,19 +1067,36 @@ export async function resolveImportTarget({ bundleFolder, into }) { throw new ContextError(`No context here is named "${adopt}".`); } - const lineage = - adopted ?? - (bundleId - ? contexts.find( - (context) => context.id === bundleId || context.importedFrom?.id === bundleId - ) - : null); + const bundleHash = fingerprintImportBundle(bundle); + const lineageMatches = bundleId + ? contexts.filter( + (context) => context.id === bundleId || context.importedFrom?.id === bundleId + ) + : []; + + // Forking with `--name` leaves two local contexts carrying one lineage id, so + // "the copy from this bundle" stops naming a single thing. Choosing one would + // be choosing by list order — alphabetical, since `listContexts` sorts by + // name — and quietly updating the fork instead of the original. Ask instead. + if (!adopted && lineageMatches.length > 1) { + return { + action: "choose", + bundle, + bundleHash, + localName, + record: null, + candidates: lineageMatches, + matchedBy: "ambiguous" + }; + } + + const lineage = adopted ?? lineageMatches[0] ?? null; const named = contexts.find( (context) => context.name.toLowerCase() === localName.toLowerCase() ); const record = lineage ?? named ?? null; if (!record) { - return { action: "create", bundle, localName, record: null, matchedBy: null }; + return { action: "create", bundle, bundleHash, localName, record: null, matchedBy: null }; } const baseHash = await fingerprintContext(record); @@ -1081,7 +1105,7 @@ export async function resolveImportTarget({ bundleFolder, into }) { baseHash }); const matchedBy = adopted ? "adopted" : lineage ? "lineage" : "name"; - const base = { bundle, localName, record, matchedBy, baseHash, preview }; + const base = { bundle, bundleHash, localName, record, matchedBy, baseHash, preview }; if (!lineage) { return { ...base, action: "choose" }; @@ -1095,7 +1119,7 @@ export async function resolveImportTarget({ bundleFolder, into }) { // whatever the two copies now look like. const stillCurrent = (typeof record.importedFrom?.bundleFingerprint === "string" && - record.importedFrom.bundleFingerprint === fingerprintImportBundle(bundle)) || + record.importedFrom.bundleFingerprint === bundleHash) || !preview.changed; if (stillCurrent) { return { ...base, action: "current" }; @@ -1137,8 +1161,42 @@ export async function replaceContextFromBundle({ bundleFolder, targetId, baseHas // The lineage is re-stamped from the bundle in the same breath: a merge that // did not record which version it consumed would be re-offered, against the // same stale baseline, on every later import. +// +// Two things are checked that `updateCapturedContext` cannot check for itself, +// because it knows about a target and knows nothing about a bundle. +// +// The target has to be this bundle's copy. A capture proves only that it was +// built against *some* local context at a known base hash, so without this an +// unrelated context could be updated here and then have this bundle's lineage +// stamped over its own. +// +// And the bundle has to be the one that was merged. Nothing stops upstream +// changing between drafting and applying, and stamping the newer fingerprint +// over an older draft is the worst outcome available: the late material is +// absent, and the next import says `current` and never offers it again. export async function applyImportMerge({ bundleFolder, capture }) { const bundle = await readImportBundle(bundleFolder); + const bundleId = typeof bundle.manifest.id === "string" ? bundle.manifest.id : null; + const record = await readContext( + typeof capture?.targetId === "string" ? capture.targetId : "" + ); + if (!record) { + throw new ContextError("The context this merge was prepared for no longer exists."); + } + if (!bundleId || record.importedFrom?.id !== bundleId) { + throw new ContextError( + `This merge is addressed to "${record.name}", which is not recorded as the copy ` + + "this bundle belongs to. Resolve the import again and rebuild the merge from what " + + "it prints." + ); + } + if (capture.bundleHash !== fingerprintImportBundle(bundle)) { + throw new ContextError( + "The bundle changed while this merge was being prepared, so applying it would drop " + + "whatever arrived late. Resolve the import again and rebuild the merge from the " + + "current bundle." + ); + } const result = await updateCapturedContext({ ...capture, updatedFrom: "import" }); return { ...result, record: await recordImportLineage(result.record, bundle) }; } diff --git a/plugins/claude-code/neatcontext/src/core/import-commands.mjs b/plugins/claude-code/neatcontext/src/core/import-commands.mjs index 8a54c8b..808b51c 100644 --- a/plugins/claude-code/neatcontext/src/core/import-commands.mjs +++ b/plugins/claude-code/neatcontext/src/core/import-commands.mjs @@ -23,14 +23,33 @@ import { ContextError, importCapturedContext, previewCapturedContextUpdate, + recordImportLineage, replaceContextFromBundle, resolveImportTarget } from "./context-store.mjs"; -import { putCard } from "./routing.mjs"; +import { MAX_USE_WHEN, putCard, readRouting } from "./routing.mjs"; -function refreshCard(result) { +const normalizeUseWhen = (text) => + (text ?? "").trim().replace(/\s+/g, " ").slice(0, MAX_USE_WHEN); + +// A routing line the user wrote with `describe` lives only in the routing card, +// never in the manifest — so the import baseline cannot see it, and taking a +// bundle whole would put the bundle's line back without either side noticing it +// had been overruled. It is not treated as divergence, because a routing tweak +// is not knowledge and does not need a merge to resolve; it is simply kept. +// +// Locally authored means the card and the manifest disagree, which is exactly +// what `describe` leaves behind and what an import or save never does. +async function authoredUseWhen(record) { + const routing = await readRouting().catch(() => null); + const stored = routing?.cards?.[record.id]?.useWhen ?? ""; + if (stored.length === 0) return null; + return stored === normalizeUseWhen(record.routingDescription) ? null : stored; +} + +function refreshCard(result, authored = null) { return putCard(result.record.id, { - useWhen: result.routingDescription, + useWhen: authored ?? result.routingDescription, source: result.profileText }).catch(() => undefined); } @@ -120,6 +139,13 @@ async function runMergedImport({ bundleFolder, mergedFrom, confirmed, consume }) ); return lines; } + if (typeof capture.bundleHash !== "string" || capture.bundleHash.length === 0) { + lines.push( + "A merged capture must carry the exact bundleHash this import printed — it is what " + + "records which version of the bundle was actually merged." + ); + return lines; + } const preview = await previewCapturedContextUpdate(capture); if (!preview.changed) { @@ -133,8 +159,9 @@ async function runMergedImport({ bundleFolder, mergedFrom, confirmed, consume }) return lines; } + const authored = await authoredUseWhen(preview.record); const result = await applyImportMerge({ bundleFolder, capture }); - await refreshCard(result); + await refreshCard(result, authored); // Only once it has landed, and only when asked. A preview must leave the // draft where it is, and so must any failure, or a merge the model spent the // conversation building would have to be rebuilt from nothing. @@ -190,6 +217,12 @@ export async function runImport({ "separate contexts holding the same material, and both will be considered " + "whenever a session routes itself." ); + } else if (resolved.matchedBy === "ambiguous") { + lines.push( + `Note: ${resolved.candidates.length} contexts here were already copies of this ` + + `bundle, and this makes ${resolved.candidates.length + 1}. A later import cannot ` + + 'tell which one you mean and will ask, so name it with --into "".' + ); } describeImported(lines, created, source, useCommand); return lines.join("\n"); @@ -205,6 +238,17 @@ export async function runImport({ if (resolved.action === "current") { lines.push("Import action: current"); lines.push(`"${record.name}" already holds everything in this bundle. Nothing to import.`); + // Adoption is an answer about identity, and it has to survive even when + // there is no content to move. Left unrecorded, the next bundle from this + // origin would ask the same question again — and no answer to it could + // ever fast-forward, because no baseline was ever written down. + if (resolved.matchedBy === "adopted") { + await recordImportLineage(record, bundle, { identityOnly: true }); + lines.push( + `Recorded that "${record.name}" is this bundle's copy, so a later one is ` + + "recognised without being told again." + ); + } return lines.join("\n"); } @@ -213,6 +257,22 @@ export async function runImport({ // adopts the local context as this bundle's copy; since no baseline against // this bundle exists for it, adopting leads to a merge and never to a // replacement. + // Several local contexts already carry this bundle's lineage, which is what + // forking leaves behind. Any of them could be the one meant, and picking is + // the user's call rather than the list order's. + if (resolved.action === "choose" && resolved.matchedBy === "ambiguous") { + lines.push("Import action: choose"); + lines.push( + `${resolved.candidates.length} contexts here are copies of this bundle, so there ` + + "is no single one to update:" + ); + for (const candidate of resolved.candidates) { + lines.push(` ${candidate.name}`); + } + lines.push('Name the one you mean with --into "".'); + return lines.join("\n"); + } + if (resolved.action === "choose") { lines.push("Import action: choose"); lines.push( @@ -242,14 +302,25 @@ export async function runImport({ "to be reconciled first." ); describeDistance(lines, record, bundle); + // Adoption is recorded now rather than at apply time, so the merge that + // follows can be checked against a target this bundle is known to belong + // to. Identity only: nothing has been taken from the bundle yet. + if (resolved.matchedBy === "adopted") { + await recordImportLineage(record, bundle, { identityOnly: true }); + } lines.push(`Context name: ${record.name}`); lines.push(`Context id: ${record.id}`); lines.push(`Base hash: ${resolved.baseHash}`); + lines.push(`Bundle hash: ${resolved.bundleHash}`); lines.push(`Profile path: ${record.profilePath}`); lines.push(`Knowledge folder: ${record.knowledgeFolder}`); lines.push(`Bundle profile: ${path.join(source, "profile.md")}`); lines.push(`Bundle knowledge: ${path.join(source, "knowledge")}`); - lines.push("Merge both sides, then apply the result with --merged-from."); + lines.push( + "Merge both sides, then apply the result with --merged-from. Carry the context " + + "id, base hash, and bundle hash into the draft exactly as printed: they are what " + + "prove the merge is for this context and was built from this bundle." + ); return lines.join("\n"); } @@ -265,12 +336,16 @@ export async function runImport({ return lines.join("\n"); } + const authored = await authoredUseWhen(record); const result = await replaceContextFromBundle({ bundleFolder, targetId: record.id, baseHash: resolved.baseHash }); - await refreshCard(result); + await refreshCard(result, authored); + if (authored) { + lines.push(`Kept the routing description you set here: ${authored}`); + } describeUpdated( lines, result, diff --git a/plugins/claude-code/neatcontext/src/core/routing.mjs b/plugins/claude-code/neatcontext/src/core/routing.mjs index a7a2931..fd21faa 100644 --- a/plugins/claude-code/neatcontext/src/core/routing.mjs +++ b/plugins/claude-code/neatcontext/src/core/routing.mjs @@ -44,7 +44,7 @@ export const DEFAULT_MODE = "auto"; // 2 marks the file as one where a stored mode means somebody chose it. See // `chosenMode` for what schema 1 got wrong and why it cannot be read literally. const SCHEMA = 2; -const MAX_USE_WHEN = 240; +export const MAX_USE_WHEN = 240; const MAX_ALIASES = 12; const MAX_DECISIONS = 100; diff --git a/plugins/copilot/neatcontext/commands/import.md b/plugins/copilot/neatcontext/commands/import.md index d2442d3..5c286ce 100644 --- a/plugins/copilot/neatcontext/commands/import.md +++ b/plugins/copilot/neatcontext/commands/import.md @@ -37,10 +37,11 @@ the printed `Import action`: since, so the newer copy can be taken whole. Relay the preview, ask the user to confirm, and only then rerun the same command with `--yes`. - `merge` — both copies have changed. Reconcile them yourself, below. -- `choose` — a context of the same name is here but nothing records a shared - origin. Relay both options and stop until the user picks one: rerun with - `--into ""` to treat it as the same context, or with - `--name ""` to keep both as separate contexts. +- `choose` — the target is not decidable. Either a context of the same name is + here but nothing records a shared origin, or several contexts are copies of + this bundle because one was forked. Relay the options and stop until the user + picks: rerun with `--into ""` to name the context they mean, or with + `--name ""` to keep a separate copy. Never answer `choose` on the user's behalf. Two people naming a context the same thing is not evidence that it is the same context, and the two answers are not @@ -48,10 +49,14 @@ recoverable from each other. ## Merging -Use the exact `Context name`, `Context id`, `Base hash`, `Profile path`, -`Knowledge folder`, `Bundle profile`, and `Bundle knowledge` values the command -printed. Read the local profile and every file in the local knowledge folder, -then read the bundle's profile and every file in its knowledge folder. +Use the exact `Context name`, `Context id`, `Base hash`, `Bundle hash`, +`Profile path`, `Knowledge folder`, `Bundle profile`, and `Bundle knowledge` +values the command printed. The three hashes are what prove the merge is for +this context, was built on its current contents, and consumed this version of +the bundle; a merge that gets any of them wrong is refused rather than applied. + +Read the local profile and every file in the local knowledge folder, then read +the bundle's profile and every file in its knowledge folder. Merge them the way a save merges a conversation into an existing context: @@ -79,6 +84,7 @@ you actually used as ``. "name": "Exact existing context name", "targetId": "context:exact-id", "baseHash": "exact base hash", + "bundleHash": "exact bundle hash", "profile": "# Exact existing context name\n\n## Purpose\n...", "routingDescription": "One line describing only the matching scope", "knowledge": [ diff --git a/plugins/copilot/neatcontext/src/core/context-store.mjs b/plugins/copilot/neatcontext/src/core/context-store.mjs index ec6e738..eadabee 100644 --- a/plugins/copilot/neatcontext/src/core/context-store.mjs +++ b/plugins/copilot/neatcontext/src/core/context-store.mjs @@ -972,23 +972,30 @@ function fingerprintImportBundle(bundle) { // Exported so the give-up path is directly testable. It matters more than it // looks: by the time this runs the import has already landed, so a failure here // must cost the bookkeeping and never the context that was just written. -export async function recordImportLineage(record, bundle) { +export async function recordImportLineage(record, bundle, { identityOnly = false } = {}) { const { manifest } = bundle; if (typeof manifest?.id !== "string" || manifest.id.length === 0) { return record; } - const lineage = { - id: manifest.id, - revision: - Number.isInteger(manifest.revision) && manifest.revision > 0 ? manifest.revision : 1, - updatedAt: typeof manifest.updatedAt === "string" ? manifest.updatedAt : null, - fingerprint: await fingerprintContext(record), - // The other half of the baseline, and the one a merge depends on. A merged - // context deliberately matches neither side, so "did this copy change?" - // cannot decide whether there is anything left to take — only "did theirs?" - // can, and this is what answers it. - bundleFingerprint: fingerprintImportBundle(bundle) - }; + // Adoption records who this copy is and nothing about what it holds. The + // baselines say "this content came from that bundle", which is a claim only a + // completed take can make: writing them here would tell the next import the + // material had already been merged, and it would answer `current` over a copy + // that never received a byte of it. + const lineage = identityOnly + ? { id: manifest.id, revision: null, updatedAt: null, fingerprint: null, bundleFingerprint: null } + : { + id: manifest.id, + revision: + Number.isInteger(manifest.revision) && manifest.revision > 0 ? manifest.revision : 1, + updatedAt: typeof manifest.updatedAt === "string" ? manifest.updatedAt : null, + fingerprint: await fingerprintContext(record), + // The other half of the baseline, and the one a merge depends on. A + // merged context deliberately matches neither side, so "did this copy + // change?" cannot decide whether there is anything left to take — only + // "did theirs?" can, and this is what answers it. + bundleFingerprint: fingerprintImportBundle(bundle) + }; const manifestPath = path.join(record.directory, "context.json"); const temporaryPath = path.join( record.directory, @@ -1060,19 +1067,36 @@ export async function resolveImportTarget({ bundleFolder, into }) { throw new ContextError(`No context here is named "${adopt}".`); } - const lineage = - adopted ?? - (bundleId - ? contexts.find( - (context) => context.id === bundleId || context.importedFrom?.id === bundleId - ) - : null); + const bundleHash = fingerprintImportBundle(bundle); + const lineageMatches = bundleId + ? contexts.filter( + (context) => context.id === bundleId || context.importedFrom?.id === bundleId + ) + : []; + + // Forking with `--name` leaves two local contexts carrying one lineage id, so + // "the copy from this bundle" stops naming a single thing. Choosing one would + // be choosing by list order — alphabetical, since `listContexts` sorts by + // name — and quietly updating the fork instead of the original. Ask instead. + if (!adopted && lineageMatches.length > 1) { + return { + action: "choose", + bundle, + bundleHash, + localName, + record: null, + candidates: lineageMatches, + matchedBy: "ambiguous" + }; + } + + const lineage = adopted ?? lineageMatches[0] ?? null; const named = contexts.find( (context) => context.name.toLowerCase() === localName.toLowerCase() ); const record = lineage ?? named ?? null; if (!record) { - return { action: "create", bundle, localName, record: null, matchedBy: null }; + return { action: "create", bundle, bundleHash, localName, record: null, matchedBy: null }; } const baseHash = await fingerprintContext(record); @@ -1081,7 +1105,7 @@ export async function resolveImportTarget({ bundleFolder, into }) { baseHash }); const matchedBy = adopted ? "adopted" : lineage ? "lineage" : "name"; - const base = { bundle, localName, record, matchedBy, baseHash, preview }; + const base = { bundle, bundleHash, localName, record, matchedBy, baseHash, preview }; if (!lineage) { return { ...base, action: "choose" }; @@ -1095,7 +1119,7 @@ export async function resolveImportTarget({ bundleFolder, into }) { // whatever the two copies now look like. const stillCurrent = (typeof record.importedFrom?.bundleFingerprint === "string" && - record.importedFrom.bundleFingerprint === fingerprintImportBundle(bundle)) || + record.importedFrom.bundleFingerprint === bundleHash) || !preview.changed; if (stillCurrent) { return { ...base, action: "current" }; @@ -1137,8 +1161,42 @@ export async function replaceContextFromBundle({ bundleFolder, targetId, baseHas // The lineage is re-stamped from the bundle in the same breath: a merge that // did not record which version it consumed would be re-offered, against the // same stale baseline, on every later import. +// +// Two things are checked that `updateCapturedContext` cannot check for itself, +// because it knows about a target and knows nothing about a bundle. +// +// The target has to be this bundle's copy. A capture proves only that it was +// built against *some* local context at a known base hash, so without this an +// unrelated context could be updated here and then have this bundle's lineage +// stamped over its own. +// +// And the bundle has to be the one that was merged. Nothing stops upstream +// changing between drafting and applying, and stamping the newer fingerprint +// over an older draft is the worst outcome available: the late material is +// absent, and the next import says `current` and never offers it again. export async function applyImportMerge({ bundleFolder, capture }) { const bundle = await readImportBundle(bundleFolder); + const bundleId = typeof bundle.manifest.id === "string" ? bundle.manifest.id : null; + const record = await readContext( + typeof capture?.targetId === "string" ? capture.targetId : "" + ); + if (!record) { + throw new ContextError("The context this merge was prepared for no longer exists."); + } + if (!bundleId || record.importedFrom?.id !== bundleId) { + throw new ContextError( + `This merge is addressed to "${record.name}", which is not recorded as the copy ` + + "this bundle belongs to. Resolve the import again and rebuild the merge from what " + + "it prints." + ); + } + if (capture.bundleHash !== fingerprintImportBundle(bundle)) { + throw new ContextError( + "The bundle changed while this merge was being prepared, so applying it would drop " + + "whatever arrived late. Resolve the import again and rebuild the merge from the " + + "current bundle." + ); + } const result = await updateCapturedContext({ ...capture, updatedFrom: "import" }); return { ...result, record: await recordImportLineage(result.record, bundle) }; } diff --git a/plugins/copilot/neatcontext/src/core/import-commands.mjs b/plugins/copilot/neatcontext/src/core/import-commands.mjs index 8a54c8b..808b51c 100644 --- a/plugins/copilot/neatcontext/src/core/import-commands.mjs +++ b/plugins/copilot/neatcontext/src/core/import-commands.mjs @@ -23,14 +23,33 @@ import { ContextError, importCapturedContext, previewCapturedContextUpdate, + recordImportLineage, replaceContextFromBundle, resolveImportTarget } from "./context-store.mjs"; -import { putCard } from "./routing.mjs"; +import { MAX_USE_WHEN, putCard, readRouting } from "./routing.mjs"; -function refreshCard(result) { +const normalizeUseWhen = (text) => + (text ?? "").trim().replace(/\s+/g, " ").slice(0, MAX_USE_WHEN); + +// A routing line the user wrote with `describe` lives only in the routing card, +// never in the manifest — so the import baseline cannot see it, and taking a +// bundle whole would put the bundle's line back without either side noticing it +// had been overruled. It is not treated as divergence, because a routing tweak +// is not knowledge and does not need a merge to resolve; it is simply kept. +// +// Locally authored means the card and the manifest disagree, which is exactly +// what `describe` leaves behind and what an import or save never does. +async function authoredUseWhen(record) { + const routing = await readRouting().catch(() => null); + const stored = routing?.cards?.[record.id]?.useWhen ?? ""; + if (stored.length === 0) return null; + return stored === normalizeUseWhen(record.routingDescription) ? null : stored; +} + +function refreshCard(result, authored = null) { return putCard(result.record.id, { - useWhen: result.routingDescription, + useWhen: authored ?? result.routingDescription, source: result.profileText }).catch(() => undefined); } @@ -120,6 +139,13 @@ async function runMergedImport({ bundleFolder, mergedFrom, confirmed, consume }) ); return lines; } + if (typeof capture.bundleHash !== "string" || capture.bundleHash.length === 0) { + lines.push( + "A merged capture must carry the exact bundleHash this import printed — it is what " + + "records which version of the bundle was actually merged." + ); + return lines; + } const preview = await previewCapturedContextUpdate(capture); if (!preview.changed) { @@ -133,8 +159,9 @@ async function runMergedImport({ bundleFolder, mergedFrom, confirmed, consume }) return lines; } + const authored = await authoredUseWhen(preview.record); const result = await applyImportMerge({ bundleFolder, capture }); - await refreshCard(result); + await refreshCard(result, authored); // Only once it has landed, and only when asked. A preview must leave the // draft where it is, and so must any failure, or a merge the model spent the // conversation building would have to be rebuilt from nothing. @@ -190,6 +217,12 @@ export async function runImport({ "separate contexts holding the same material, and both will be considered " + "whenever a session routes itself." ); + } else if (resolved.matchedBy === "ambiguous") { + lines.push( + `Note: ${resolved.candidates.length} contexts here were already copies of this ` + + `bundle, and this makes ${resolved.candidates.length + 1}. A later import cannot ` + + 'tell which one you mean and will ask, so name it with --into "".' + ); } describeImported(lines, created, source, useCommand); return lines.join("\n"); @@ -205,6 +238,17 @@ export async function runImport({ if (resolved.action === "current") { lines.push("Import action: current"); lines.push(`"${record.name}" already holds everything in this bundle. Nothing to import.`); + // Adoption is an answer about identity, and it has to survive even when + // there is no content to move. Left unrecorded, the next bundle from this + // origin would ask the same question again — and no answer to it could + // ever fast-forward, because no baseline was ever written down. + if (resolved.matchedBy === "adopted") { + await recordImportLineage(record, bundle, { identityOnly: true }); + lines.push( + `Recorded that "${record.name}" is this bundle's copy, so a later one is ` + + "recognised without being told again." + ); + } return lines.join("\n"); } @@ -213,6 +257,22 @@ export async function runImport({ // adopts the local context as this bundle's copy; since no baseline against // this bundle exists for it, adopting leads to a merge and never to a // replacement. + // Several local contexts already carry this bundle's lineage, which is what + // forking leaves behind. Any of them could be the one meant, and picking is + // the user's call rather than the list order's. + if (resolved.action === "choose" && resolved.matchedBy === "ambiguous") { + lines.push("Import action: choose"); + lines.push( + `${resolved.candidates.length} contexts here are copies of this bundle, so there ` + + "is no single one to update:" + ); + for (const candidate of resolved.candidates) { + lines.push(` ${candidate.name}`); + } + lines.push('Name the one you mean with --into "".'); + return lines.join("\n"); + } + if (resolved.action === "choose") { lines.push("Import action: choose"); lines.push( @@ -242,14 +302,25 @@ export async function runImport({ "to be reconciled first." ); describeDistance(lines, record, bundle); + // Adoption is recorded now rather than at apply time, so the merge that + // follows can be checked against a target this bundle is known to belong + // to. Identity only: nothing has been taken from the bundle yet. + if (resolved.matchedBy === "adopted") { + await recordImportLineage(record, bundle, { identityOnly: true }); + } lines.push(`Context name: ${record.name}`); lines.push(`Context id: ${record.id}`); lines.push(`Base hash: ${resolved.baseHash}`); + lines.push(`Bundle hash: ${resolved.bundleHash}`); lines.push(`Profile path: ${record.profilePath}`); lines.push(`Knowledge folder: ${record.knowledgeFolder}`); lines.push(`Bundle profile: ${path.join(source, "profile.md")}`); lines.push(`Bundle knowledge: ${path.join(source, "knowledge")}`); - lines.push("Merge both sides, then apply the result with --merged-from."); + lines.push( + "Merge both sides, then apply the result with --merged-from. Carry the context " + + "id, base hash, and bundle hash into the draft exactly as printed: they are what " + + "prove the merge is for this context and was built from this bundle." + ); return lines.join("\n"); } @@ -265,12 +336,16 @@ export async function runImport({ return lines.join("\n"); } + const authored = await authoredUseWhen(record); const result = await replaceContextFromBundle({ bundleFolder, targetId: record.id, baseHash: resolved.baseHash }); - await refreshCard(result); + await refreshCard(result, authored); + if (authored) { + lines.push(`Kept the routing description you set here: ${authored}`); + } describeUpdated( lines, result, diff --git a/plugins/copilot/neatcontext/src/core/routing.mjs b/plugins/copilot/neatcontext/src/core/routing.mjs index a7a2931..fd21faa 100644 --- a/plugins/copilot/neatcontext/src/core/routing.mjs +++ b/plugins/copilot/neatcontext/src/core/routing.mjs @@ -44,7 +44,7 @@ export const DEFAULT_MODE = "auto"; // 2 marks the file as one where a stored mode means somebody chose it. See // `chosenMode` for what schema 1 got wrong and why it cannot be read literally. const SCHEMA = 2; -const MAX_USE_WHEN = 240; +export const MAX_USE_WHEN = 240; const MAX_ALIASES = 12; const MAX_DECISIONS = 100; diff --git a/plugins/kimi-code/neatcontext/skills/import/SKILL.md b/plugins/kimi-code/neatcontext/skills/import/SKILL.md index 63b0f66..405076c 100644 --- a/plugins/kimi-code/neatcontext/skills/import/SKILL.md +++ b/plugins/kimi-code/neatcontext/skills/import/SKILL.md @@ -24,13 +24,13 @@ A bundle this machine has not seen is imported immediately and the output says s - `current` — the context here already holds everything in the bundle. Relay that and stop. - `replace` — the local copy came from this bundle and has not been edited since, so the newer copy can be taken whole. Relay the preview, ask the user to confirm, and only then rerun the same command with `--yes`. - `merge` — both copies have changed. Reconcile them yourself, below. -- `choose` — a context of the same name is here but nothing records a shared origin. Relay both options and stop until the user picks one: rerun with `--into ""` to treat it as the same context, or with `--name ""` to keep both as separate contexts. +- `choose` — the target is not decidable. Either a context of the same name is here but nothing records a shared origin, or several contexts are copies of this bundle because one was forked. Relay the options and stop until the user picks: rerun with `--into ""` to name the context they mean, or with `--name ""` to keep a separate copy. Never answer `choose` on the user's behalf. Two people naming a context the same thing is not evidence that it is the same context, and the two answers are not recoverable from each other. ## Merging -Use the exact `Context name`, `Context id`, `Base hash`, `Profile path`, `Knowledge folder`, `Bundle profile`, and `Bundle knowledge` values the command printed. Read the local profile and every file in the local knowledge folder, then read the bundle's profile and every file in its knowledge folder. +Use the exact `Context name`, `Context id`, `Base hash`, `Bundle hash`, `Profile path`, `Knowledge folder`, `Bundle profile`, and `Bundle knowledge` values the command printed. The three hashes are what prove the merge is for this context, was built on its current contents, and consumed this version of the bundle; a merge that gets any of them wrong is refused rather than applied. Read the local profile and every file in the local knowledge folder, then read the bundle's profile and every file in its knowledge folder. Merge them the way a save merges a conversation into an existing context: @@ -40,7 +40,7 @@ Merge them the way a save merges a conversation into an existing context: - Preserve the profile and routing description verbatim when neither side changed the behavioral contract or the matching scope. - The `knowledge` array must be the complete post-merge contents of the local knowledge folder. -Create a unique scratch file named `.neatcontext-capture-import-.json` in the current workspace. Use schema `1`, and include the exact `targetId` and `baseHash` the command printed: +Create a unique scratch file named `.neatcontext-capture-import-.json` in the current workspace. Use schema `1`, and include the exact `targetId`, `baseHash`, and `bundleHash` the command printed: ```json { @@ -48,6 +48,7 @@ Create a unique scratch file named `.neatcontext-capture-import-.json` i "name": "Exact existing context name", "targetId": "context:exact-id", "baseHash": "exact base hash", + "bundleHash": "exact bundle hash", "profile": "# Exact existing context name\n\n## Purpose\n...", "routingDescription": "One line describing only the matching scope", "knowledge": [{ "path": "session-summary.md", "content": "# Session summary\n\n..." }] diff --git a/plugins/kimi-code/neatcontext/src/core/context-store.mjs b/plugins/kimi-code/neatcontext/src/core/context-store.mjs index ec6e738..eadabee 100644 --- a/plugins/kimi-code/neatcontext/src/core/context-store.mjs +++ b/plugins/kimi-code/neatcontext/src/core/context-store.mjs @@ -972,23 +972,30 @@ function fingerprintImportBundle(bundle) { // Exported so the give-up path is directly testable. It matters more than it // looks: by the time this runs the import has already landed, so a failure here // must cost the bookkeeping and never the context that was just written. -export async function recordImportLineage(record, bundle) { +export async function recordImportLineage(record, bundle, { identityOnly = false } = {}) { const { manifest } = bundle; if (typeof manifest?.id !== "string" || manifest.id.length === 0) { return record; } - const lineage = { - id: manifest.id, - revision: - Number.isInteger(manifest.revision) && manifest.revision > 0 ? manifest.revision : 1, - updatedAt: typeof manifest.updatedAt === "string" ? manifest.updatedAt : null, - fingerprint: await fingerprintContext(record), - // The other half of the baseline, and the one a merge depends on. A merged - // context deliberately matches neither side, so "did this copy change?" - // cannot decide whether there is anything left to take — only "did theirs?" - // can, and this is what answers it. - bundleFingerprint: fingerprintImportBundle(bundle) - }; + // Adoption records who this copy is and nothing about what it holds. The + // baselines say "this content came from that bundle", which is a claim only a + // completed take can make: writing them here would tell the next import the + // material had already been merged, and it would answer `current` over a copy + // that never received a byte of it. + const lineage = identityOnly + ? { id: manifest.id, revision: null, updatedAt: null, fingerprint: null, bundleFingerprint: null } + : { + id: manifest.id, + revision: + Number.isInteger(manifest.revision) && manifest.revision > 0 ? manifest.revision : 1, + updatedAt: typeof manifest.updatedAt === "string" ? manifest.updatedAt : null, + fingerprint: await fingerprintContext(record), + // The other half of the baseline, and the one a merge depends on. A + // merged context deliberately matches neither side, so "did this copy + // change?" cannot decide whether there is anything left to take — only + // "did theirs?" can, and this is what answers it. + bundleFingerprint: fingerprintImportBundle(bundle) + }; const manifestPath = path.join(record.directory, "context.json"); const temporaryPath = path.join( record.directory, @@ -1060,19 +1067,36 @@ export async function resolveImportTarget({ bundleFolder, into }) { throw new ContextError(`No context here is named "${adopt}".`); } - const lineage = - adopted ?? - (bundleId - ? contexts.find( - (context) => context.id === bundleId || context.importedFrom?.id === bundleId - ) - : null); + const bundleHash = fingerprintImportBundle(bundle); + const lineageMatches = bundleId + ? contexts.filter( + (context) => context.id === bundleId || context.importedFrom?.id === bundleId + ) + : []; + + // Forking with `--name` leaves two local contexts carrying one lineage id, so + // "the copy from this bundle" stops naming a single thing. Choosing one would + // be choosing by list order — alphabetical, since `listContexts` sorts by + // name — and quietly updating the fork instead of the original. Ask instead. + if (!adopted && lineageMatches.length > 1) { + return { + action: "choose", + bundle, + bundleHash, + localName, + record: null, + candidates: lineageMatches, + matchedBy: "ambiguous" + }; + } + + const lineage = adopted ?? lineageMatches[0] ?? null; const named = contexts.find( (context) => context.name.toLowerCase() === localName.toLowerCase() ); const record = lineage ?? named ?? null; if (!record) { - return { action: "create", bundle, localName, record: null, matchedBy: null }; + return { action: "create", bundle, bundleHash, localName, record: null, matchedBy: null }; } const baseHash = await fingerprintContext(record); @@ -1081,7 +1105,7 @@ export async function resolveImportTarget({ bundleFolder, into }) { baseHash }); const matchedBy = adopted ? "adopted" : lineage ? "lineage" : "name"; - const base = { bundle, localName, record, matchedBy, baseHash, preview }; + const base = { bundle, bundleHash, localName, record, matchedBy, baseHash, preview }; if (!lineage) { return { ...base, action: "choose" }; @@ -1095,7 +1119,7 @@ export async function resolveImportTarget({ bundleFolder, into }) { // whatever the two copies now look like. const stillCurrent = (typeof record.importedFrom?.bundleFingerprint === "string" && - record.importedFrom.bundleFingerprint === fingerprintImportBundle(bundle)) || + record.importedFrom.bundleFingerprint === bundleHash) || !preview.changed; if (stillCurrent) { return { ...base, action: "current" }; @@ -1137,8 +1161,42 @@ export async function replaceContextFromBundle({ bundleFolder, targetId, baseHas // The lineage is re-stamped from the bundle in the same breath: a merge that // did not record which version it consumed would be re-offered, against the // same stale baseline, on every later import. +// +// Two things are checked that `updateCapturedContext` cannot check for itself, +// because it knows about a target and knows nothing about a bundle. +// +// The target has to be this bundle's copy. A capture proves only that it was +// built against *some* local context at a known base hash, so without this an +// unrelated context could be updated here and then have this bundle's lineage +// stamped over its own. +// +// And the bundle has to be the one that was merged. Nothing stops upstream +// changing between drafting and applying, and stamping the newer fingerprint +// over an older draft is the worst outcome available: the late material is +// absent, and the next import says `current` and never offers it again. export async function applyImportMerge({ bundleFolder, capture }) { const bundle = await readImportBundle(bundleFolder); + const bundleId = typeof bundle.manifest.id === "string" ? bundle.manifest.id : null; + const record = await readContext( + typeof capture?.targetId === "string" ? capture.targetId : "" + ); + if (!record) { + throw new ContextError("The context this merge was prepared for no longer exists."); + } + if (!bundleId || record.importedFrom?.id !== bundleId) { + throw new ContextError( + `This merge is addressed to "${record.name}", which is not recorded as the copy ` + + "this bundle belongs to. Resolve the import again and rebuild the merge from what " + + "it prints." + ); + } + if (capture.bundleHash !== fingerprintImportBundle(bundle)) { + throw new ContextError( + "The bundle changed while this merge was being prepared, so applying it would drop " + + "whatever arrived late. Resolve the import again and rebuild the merge from the " + + "current bundle." + ); + } const result = await updateCapturedContext({ ...capture, updatedFrom: "import" }); return { ...result, record: await recordImportLineage(result.record, bundle) }; } diff --git a/plugins/kimi-code/neatcontext/src/core/import-commands.mjs b/plugins/kimi-code/neatcontext/src/core/import-commands.mjs index 8a54c8b..808b51c 100644 --- a/plugins/kimi-code/neatcontext/src/core/import-commands.mjs +++ b/plugins/kimi-code/neatcontext/src/core/import-commands.mjs @@ -23,14 +23,33 @@ import { ContextError, importCapturedContext, previewCapturedContextUpdate, + recordImportLineage, replaceContextFromBundle, resolveImportTarget } from "./context-store.mjs"; -import { putCard } from "./routing.mjs"; +import { MAX_USE_WHEN, putCard, readRouting } from "./routing.mjs"; -function refreshCard(result) { +const normalizeUseWhen = (text) => + (text ?? "").trim().replace(/\s+/g, " ").slice(0, MAX_USE_WHEN); + +// A routing line the user wrote with `describe` lives only in the routing card, +// never in the manifest — so the import baseline cannot see it, and taking a +// bundle whole would put the bundle's line back without either side noticing it +// had been overruled. It is not treated as divergence, because a routing tweak +// is not knowledge and does not need a merge to resolve; it is simply kept. +// +// Locally authored means the card and the manifest disagree, which is exactly +// what `describe` leaves behind and what an import or save never does. +async function authoredUseWhen(record) { + const routing = await readRouting().catch(() => null); + const stored = routing?.cards?.[record.id]?.useWhen ?? ""; + if (stored.length === 0) return null; + return stored === normalizeUseWhen(record.routingDescription) ? null : stored; +} + +function refreshCard(result, authored = null) { return putCard(result.record.id, { - useWhen: result.routingDescription, + useWhen: authored ?? result.routingDescription, source: result.profileText }).catch(() => undefined); } @@ -120,6 +139,13 @@ async function runMergedImport({ bundleFolder, mergedFrom, confirmed, consume }) ); return lines; } + if (typeof capture.bundleHash !== "string" || capture.bundleHash.length === 0) { + lines.push( + "A merged capture must carry the exact bundleHash this import printed — it is what " + + "records which version of the bundle was actually merged." + ); + return lines; + } const preview = await previewCapturedContextUpdate(capture); if (!preview.changed) { @@ -133,8 +159,9 @@ async function runMergedImport({ bundleFolder, mergedFrom, confirmed, consume }) return lines; } + const authored = await authoredUseWhen(preview.record); const result = await applyImportMerge({ bundleFolder, capture }); - await refreshCard(result); + await refreshCard(result, authored); // Only once it has landed, and only when asked. A preview must leave the // draft where it is, and so must any failure, or a merge the model spent the // conversation building would have to be rebuilt from nothing. @@ -190,6 +217,12 @@ export async function runImport({ "separate contexts holding the same material, and both will be considered " + "whenever a session routes itself." ); + } else if (resolved.matchedBy === "ambiguous") { + lines.push( + `Note: ${resolved.candidates.length} contexts here were already copies of this ` + + `bundle, and this makes ${resolved.candidates.length + 1}. A later import cannot ` + + 'tell which one you mean and will ask, so name it with --into "".' + ); } describeImported(lines, created, source, useCommand); return lines.join("\n"); @@ -205,6 +238,17 @@ export async function runImport({ if (resolved.action === "current") { lines.push("Import action: current"); lines.push(`"${record.name}" already holds everything in this bundle. Nothing to import.`); + // Adoption is an answer about identity, and it has to survive even when + // there is no content to move. Left unrecorded, the next bundle from this + // origin would ask the same question again — and no answer to it could + // ever fast-forward, because no baseline was ever written down. + if (resolved.matchedBy === "adopted") { + await recordImportLineage(record, bundle, { identityOnly: true }); + lines.push( + `Recorded that "${record.name}" is this bundle's copy, so a later one is ` + + "recognised without being told again." + ); + } return lines.join("\n"); } @@ -213,6 +257,22 @@ export async function runImport({ // adopts the local context as this bundle's copy; since no baseline against // this bundle exists for it, adopting leads to a merge and never to a // replacement. + // Several local contexts already carry this bundle's lineage, which is what + // forking leaves behind. Any of them could be the one meant, and picking is + // the user's call rather than the list order's. + if (resolved.action === "choose" && resolved.matchedBy === "ambiguous") { + lines.push("Import action: choose"); + lines.push( + `${resolved.candidates.length} contexts here are copies of this bundle, so there ` + + "is no single one to update:" + ); + for (const candidate of resolved.candidates) { + lines.push(` ${candidate.name}`); + } + lines.push('Name the one you mean with --into "".'); + return lines.join("\n"); + } + if (resolved.action === "choose") { lines.push("Import action: choose"); lines.push( @@ -242,14 +302,25 @@ export async function runImport({ "to be reconciled first." ); describeDistance(lines, record, bundle); + // Adoption is recorded now rather than at apply time, so the merge that + // follows can be checked against a target this bundle is known to belong + // to. Identity only: nothing has been taken from the bundle yet. + if (resolved.matchedBy === "adopted") { + await recordImportLineage(record, bundle, { identityOnly: true }); + } lines.push(`Context name: ${record.name}`); lines.push(`Context id: ${record.id}`); lines.push(`Base hash: ${resolved.baseHash}`); + lines.push(`Bundle hash: ${resolved.bundleHash}`); lines.push(`Profile path: ${record.profilePath}`); lines.push(`Knowledge folder: ${record.knowledgeFolder}`); lines.push(`Bundle profile: ${path.join(source, "profile.md")}`); lines.push(`Bundle knowledge: ${path.join(source, "knowledge")}`); - lines.push("Merge both sides, then apply the result with --merged-from."); + lines.push( + "Merge both sides, then apply the result with --merged-from. Carry the context " + + "id, base hash, and bundle hash into the draft exactly as printed: they are what " + + "prove the merge is for this context and was built from this bundle." + ); return lines.join("\n"); } @@ -265,12 +336,16 @@ export async function runImport({ return lines.join("\n"); } + const authored = await authoredUseWhen(record); const result = await replaceContextFromBundle({ bundleFolder, targetId: record.id, baseHash: resolved.baseHash }); - await refreshCard(result); + await refreshCard(result, authored); + if (authored) { + lines.push(`Kept the routing description you set here: ${authored}`); + } describeUpdated( lines, result, diff --git a/plugins/kimi-code/neatcontext/src/core/routing.mjs b/plugins/kimi-code/neatcontext/src/core/routing.mjs index a7a2931..fd21faa 100644 --- a/plugins/kimi-code/neatcontext/src/core/routing.mjs +++ b/plugins/kimi-code/neatcontext/src/core/routing.mjs @@ -44,7 +44,7 @@ export const DEFAULT_MODE = "auto"; // 2 marks the file as one where a stored mode means somebody chose it. See // `chosenMode` for what schema 1 got wrong and why it cannot be read literally. const SCHEMA = 2; -const MAX_USE_WHEN = 240; +export const MAX_USE_WHEN = 240; const MAX_ALIASES = 12; const MAX_DECISIONS = 100; diff --git a/plugins/pi/neatcontext/src/core/context-store.mjs b/plugins/pi/neatcontext/src/core/context-store.mjs index ec6e738..eadabee 100644 --- a/plugins/pi/neatcontext/src/core/context-store.mjs +++ b/plugins/pi/neatcontext/src/core/context-store.mjs @@ -972,23 +972,30 @@ function fingerprintImportBundle(bundle) { // Exported so the give-up path is directly testable. It matters more than it // looks: by the time this runs the import has already landed, so a failure here // must cost the bookkeeping and never the context that was just written. -export async function recordImportLineage(record, bundle) { +export async function recordImportLineage(record, bundle, { identityOnly = false } = {}) { const { manifest } = bundle; if (typeof manifest?.id !== "string" || manifest.id.length === 0) { return record; } - const lineage = { - id: manifest.id, - revision: - Number.isInteger(manifest.revision) && manifest.revision > 0 ? manifest.revision : 1, - updatedAt: typeof manifest.updatedAt === "string" ? manifest.updatedAt : null, - fingerprint: await fingerprintContext(record), - // The other half of the baseline, and the one a merge depends on. A merged - // context deliberately matches neither side, so "did this copy change?" - // cannot decide whether there is anything left to take — only "did theirs?" - // can, and this is what answers it. - bundleFingerprint: fingerprintImportBundle(bundle) - }; + // Adoption records who this copy is and nothing about what it holds. The + // baselines say "this content came from that bundle", which is a claim only a + // completed take can make: writing them here would tell the next import the + // material had already been merged, and it would answer `current` over a copy + // that never received a byte of it. + const lineage = identityOnly + ? { id: manifest.id, revision: null, updatedAt: null, fingerprint: null, bundleFingerprint: null } + : { + id: manifest.id, + revision: + Number.isInteger(manifest.revision) && manifest.revision > 0 ? manifest.revision : 1, + updatedAt: typeof manifest.updatedAt === "string" ? manifest.updatedAt : null, + fingerprint: await fingerprintContext(record), + // The other half of the baseline, and the one a merge depends on. A + // merged context deliberately matches neither side, so "did this copy + // change?" cannot decide whether there is anything left to take — only + // "did theirs?" can, and this is what answers it. + bundleFingerprint: fingerprintImportBundle(bundle) + }; const manifestPath = path.join(record.directory, "context.json"); const temporaryPath = path.join( record.directory, @@ -1060,19 +1067,36 @@ export async function resolveImportTarget({ bundleFolder, into }) { throw new ContextError(`No context here is named "${adopt}".`); } - const lineage = - adopted ?? - (bundleId - ? contexts.find( - (context) => context.id === bundleId || context.importedFrom?.id === bundleId - ) - : null); + const bundleHash = fingerprintImportBundle(bundle); + const lineageMatches = bundleId + ? contexts.filter( + (context) => context.id === bundleId || context.importedFrom?.id === bundleId + ) + : []; + + // Forking with `--name` leaves two local contexts carrying one lineage id, so + // "the copy from this bundle" stops naming a single thing. Choosing one would + // be choosing by list order — alphabetical, since `listContexts` sorts by + // name — and quietly updating the fork instead of the original. Ask instead. + if (!adopted && lineageMatches.length > 1) { + return { + action: "choose", + bundle, + bundleHash, + localName, + record: null, + candidates: lineageMatches, + matchedBy: "ambiguous" + }; + } + + const lineage = adopted ?? lineageMatches[0] ?? null; const named = contexts.find( (context) => context.name.toLowerCase() === localName.toLowerCase() ); const record = lineage ?? named ?? null; if (!record) { - return { action: "create", bundle, localName, record: null, matchedBy: null }; + return { action: "create", bundle, bundleHash, localName, record: null, matchedBy: null }; } const baseHash = await fingerprintContext(record); @@ -1081,7 +1105,7 @@ export async function resolveImportTarget({ bundleFolder, into }) { baseHash }); const matchedBy = adopted ? "adopted" : lineage ? "lineage" : "name"; - const base = { bundle, localName, record, matchedBy, baseHash, preview }; + const base = { bundle, bundleHash, localName, record, matchedBy, baseHash, preview }; if (!lineage) { return { ...base, action: "choose" }; @@ -1095,7 +1119,7 @@ export async function resolveImportTarget({ bundleFolder, into }) { // whatever the two copies now look like. const stillCurrent = (typeof record.importedFrom?.bundleFingerprint === "string" && - record.importedFrom.bundleFingerprint === fingerprintImportBundle(bundle)) || + record.importedFrom.bundleFingerprint === bundleHash) || !preview.changed; if (stillCurrent) { return { ...base, action: "current" }; @@ -1137,8 +1161,42 @@ export async function replaceContextFromBundle({ bundleFolder, targetId, baseHas // The lineage is re-stamped from the bundle in the same breath: a merge that // did not record which version it consumed would be re-offered, against the // same stale baseline, on every later import. +// +// Two things are checked that `updateCapturedContext` cannot check for itself, +// because it knows about a target and knows nothing about a bundle. +// +// The target has to be this bundle's copy. A capture proves only that it was +// built against *some* local context at a known base hash, so without this an +// unrelated context could be updated here and then have this bundle's lineage +// stamped over its own. +// +// And the bundle has to be the one that was merged. Nothing stops upstream +// changing between drafting and applying, and stamping the newer fingerprint +// over an older draft is the worst outcome available: the late material is +// absent, and the next import says `current` and never offers it again. export async function applyImportMerge({ bundleFolder, capture }) { const bundle = await readImportBundle(bundleFolder); + const bundleId = typeof bundle.manifest.id === "string" ? bundle.manifest.id : null; + const record = await readContext( + typeof capture?.targetId === "string" ? capture.targetId : "" + ); + if (!record) { + throw new ContextError("The context this merge was prepared for no longer exists."); + } + if (!bundleId || record.importedFrom?.id !== bundleId) { + throw new ContextError( + `This merge is addressed to "${record.name}", which is not recorded as the copy ` + + "this bundle belongs to. Resolve the import again and rebuild the merge from what " + + "it prints." + ); + } + if (capture.bundleHash !== fingerprintImportBundle(bundle)) { + throw new ContextError( + "The bundle changed while this merge was being prepared, so applying it would drop " + + "whatever arrived late. Resolve the import again and rebuild the merge from the " + + "current bundle." + ); + } const result = await updateCapturedContext({ ...capture, updatedFrom: "import" }); return { ...result, record: await recordImportLineage(result.record, bundle) }; } diff --git a/plugins/pi/neatcontext/src/core/import-commands.mjs b/plugins/pi/neatcontext/src/core/import-commands.mjs index 8a54c8b..808b51c 100644 --- a/plugins/pi/neatcontext/src/core/import-commands.mjs +++ b/plugins/pi/neatcontext/src/core/import-commands.mjs @@ -23,14 +23,33 @@ import { ContextError, importCapturedContext, previewCapturedContextUpdate, + recordImportLineage, replaceContextFromBundle, resolveImportTarget } from "./context-store.mjs"; -import { putCard } from "./routing.mjs"; +import { MAX_USE_WHEN, putCard, readRouting } from "./routing.mjs"; -function refreshCard(result) { +const normalizeUseWhen = (text) => + (text ?? "").trim().replace(/\s+/g, " ").slice(0, MAX_USE_WHEN); + +// A routing line the user wrote with `describe` lives only in the routing card, +// never in the manifest — so the import baseline cannot see it, and taking a +// bundle whole would put the bundle's line back without either side noticing it +// had been overruled. It is not treated as divergence, because a routing tweak +// is not knowledge and does not need a merge to resolve; it is simply kept. +// +// Locally authored means the card and the manifest disagree, which is exactly +// what `describe` leaves behind and what an import or save never does. +async function authoredUseWhen(record) { + const routing = await readRouting().catch(() => null); + const stored = routing?.cards?.[record.id]?.useWhen ?? ""; + if (stored.length === 0) return null; + return stored === normalizeUseWhen(record.routingDescription) ? null : stored; +} + +function refreshCard(result, authored = null) { return putCard(result.record.id, { - useWhen: result.routingDescription, + useWhen: authored ?? result.routingDescription, source: result.profileText }).catch(() => undefined); } @@ -120,6 +139,13 @@ async function runMergedImport({ bundleFolder, mergedFrom, confirmed, consume }) ); return lines; } + if (typeof capture.bundleHash !== "string" || capture.bundleHash.length === 0) { + lines.push( + "A merged capture must carry the exact bundleHash this import printed — it is what " + + "records which version of the bundle was actually merged." + ); + return lines; + } const preview = await previewCapturedContextUpdate(capture); if (!preview.changed) { @@ -133,8 +159,9 @@ async function runMergedImport({ bundleFolder, mergedFrom, confirmed, consume }) return lines; } + const authored = await authoredUseWhen(preview.record); const result = await applyImportMerge({ bundleFolder, capture }); - await refreshCard(result); + await refreshCard(result, authored); // Only once it has landed, and only when asked. A preview must leave the // draft where it is, and so must any failure, or a merge the model spent the // conversation building would have to be rebuilt from nothing. @@ -190,6 +217,12 @@ export async function runImport({ "separate contexts holding the same material, and both will be considered " + "whenever a session routes itself." ); + } else if (resolved.matchedBy === "ambiguous") { + lines.push( + `Note: ${resolved.candidates.length} contexts here were already copies of this ` + + `bundle, and this makes ${resolved.candidates.length + 1}. A later import cannot ` + + 'tell which one you mean and will ask, so name it with --into "".' + ); } describeImported(lines, created, source, useCommand); return lines.join("\n"); @@ -205,6 +238,17 @@ export async function runImport({ if (resolved.action === "current") { lines.push("Import action: current"); lines.push(`"${record.name}" already holds everything in this bundle. Nothing to import.`); + // Adoption is an answer about identity, and it has to survive even when + // there is no content to move. Left unrecorded, the next bundle from this + // origin would ask the same question again — and no answer to it could + // ever fast-forward, because no baseline was ever written down. + if (resolved.matchedBy === "adopted") { + await recordImportLineage(record, bundle, { identityOnly: true }); + lines.push( + `Recorded that "${record.name}" is this bundle's copy, so a later one is ` + + "recognised without being told again." + ); + } return lines.join("\n"); } @@ -213,6 +257,22 @@ export async function runImport({ // adopts the local context as this bundle's copy; since no baseline against // this bundle exists for it, adopting leads to a merge and never to a // replacement. + // Several local contexts already carry this bundle's lineage, which is what + // forking leaves behind. Any of them could be the one meant, and picking is + // the user's call rather than the list order's. + if (resolved.action === "choose" && resolved.matchedBy === "ambiguous") { + lines.push("Import action: choose"); + lines.push( + `${resolved.candidates.length} contexts here are copies of this bundle, so there ` + + "is no single one to update:" + ); + for (const candidate of resolved.candidates) { + lines.push(` ${candidate.name}`); + } + lines.push('Name the one you mean with --into "".'); + return lines.join("\n"); + } + if (resolved.action === "choose") { lines.push("Import action: choose"); lines.push( @@ -242,14 +302,25 @@ export async function runImport({ "to be reconciled first." ); describeDistance(lines, record, bundle); + // Adoption is recorded now rather than at apply time, so the merge that + // follows can be checked against a target this bundle is known to belong + // to. Identity only: nothing has been taken from the bundle yet. + if (resolved.matchedBy === "adopted") { + await recordImportLineage(record, bundle, { identityOnly: true }); + } lines.push(`Context name: ${record.name}`); lines.push(`Context id: ${record.id}`); lines.push(`Base hash: ${resolved.baseHash}`); + lines.push(`Bundle hash: ${resolved.bundleHash}`); lines.push(`Profile path: ${record.profilePath}`); lines.push(`Knowledge folder: ${record.knowledgeFolder}`); lines.push(`Bundle profile: ${path.join(source, "profile.md")}`); lines.push(`Bundle knowledge: ${path.join(source, "knowledge")}`); - lines.push("Merge both sides, then apply the result with --merged-from."); + lines.push( + "Merge both sides, then apply the result with --merged-from. Carry the context " + + "id, base hash, and bundle hash into the draft exactly as printed: they are what " + + "prove the merge is for this context and was built from this bundle." + ); return lines.join("\n"); } @@ -265,12 +336,16 @@ export async function runImport({ return lines.join("\n"); } + const authored = await authoredUseWhen(record); const result = await replaceContextFromBundle({ bundleFolder, targetId: record.id, baseHash: resolved.baseHash }); - await refreshCard(result); + await refreshCard(result, authored); + if (authored) { + lines.push(`Kept the routing description you set here: ${authored}`); + } describeUpdated( lines, result, diff --git a/plugins/pi/neatcontext/src/core/routing.mjs b/plugins/pi/neatcontext/src/core/routing.mjs index a7a2931..fd21faa 100644 --- a/plugins/pi/neatcontext/src/core/routing.mjs +++ b/plugins/pi/neatcontext/src/core/routing.mjs @@ -44,7 +44,7 @@ export const DEFAULT_MODE = "auto"; // 2 marks the file as one where a stored mode means somebody chose it. See // `chosenMode` for what schema 1 got wrong and why it cannot be read literally. const SCHEMA = 2; -const MAX_USE_WHEN = 240; +export const MAX_USE_WHEN = 240; const MAX_ALIASES = 12; const MAX_DECISIONS = 100; diff --git a/shared/core/context-store.mjs b/shared/core/context-store.mjs index ec6e738..eadabee 100644 --- a/shared/core/context-store.mjs +++ b/shared/core/context-store.mjs @@ -972,23 +972,30 @@ function fingerprintImportBundle(bundle) { // Exported so the give-up path is directly testable. It matters more than it // looks: by the time this runs the import has already landed, so a failure here // must cost the bookkeeping and never the context that was just written. -export async function recordImportLineage(record, bundle) { +export async function recordImportLineage(record, bundle, { identityOnly = false } = {}) { const { manifest } = bundle; if (typeof manifest?.id !== "string" || manifest.id.length === 0) { return record; } - const lineage = { - id: manifest.id, - revision: - Number.isInteger(manifest.revision) && manifest.revision > 0 ? manifest.revision : 1, - updatedAt: typeof manifest.updatedAt === "string" ? manifest.updatedAt : null, - fingerprint: await fingerprintContext(record), - // The other half of the baseline, and the one a merge depends on. A merged - // context deliberately matches neither side, so "did this copy change?" - // cannot decide whether there is anything left to take — only "did theirs?" - // can, and this is what answers it. - bundleFingerprint: fingerprintImportBundle(bundle) - }; + // Adoption records who this copy is and nothing about what it holds. The + // baselines say "this content came from that bundle", which is a claim only a + // completed take can make: writing them here would tell the next import the + // material had already been merged, and it would answer `current` over a copy + // that never received a byte of it. + const lineage = identityOnly + ? { id: manifest.id, revision: null, updatedAt: null, fingerprint: null, bundleFingerprint: null } + : { + id: manifest.id, + revision: + Number.isInteger(manifest.revision) && manifest.revision > 0 ? manifest.revision : 1, + updatedAt: typeof manifest.updatedAt === "string" ? manifest.updatedAt : null, + fingerprint: await fingerprintContext(record), + // The other half of the baseline, and the one a merge depends on. A + // merged context deliberately matches neither side, so "did this copy + // change?" cannot decide whether there is anything left to take — only + // "did theirs?" can, and this is what answers it. + bundleFingerprint: fingerprintImportBundle(bundle) + }; const manifestPath = path.join(record.directory, "context.json"); const temporaryPath = path.join( record.directory, @@ -1060,19 +1067,36 @@ export async function resolveImportTarget({ bundleFolder, into }) { throw new ContextError(`No context here is named "${adopt}".`); } - const lineage = - adopted ?? - (bundleId - ? contexts.find( - (context) => context.id === bundleId || context.importedFrom?.id === bundleId - ) - : null); + const bundleHash = fingerprintImportBundle(bundle); + const lineageMatches = bundleId + ? contexts.filter( + (context) => context.id === bundleId || context.importedFrom?.id === bundleId + ) + : []; + + // Forking with `--name` leaves two local contexts carrying one lineage id, so + // "the copy from this bundle" stops naming a single thing. Choosing one would + // be choosing by list order — alphabetical, since `listContexts` sorts by + // name — and quietly updating the fork instead of the original. Ask instead. + if (!adopted && lineageMatches.length > 1) { + return { + action: "choose", + bundle, + bundleHash, + localName, + record: null, + candidates: lineageMatches, + matchedBy: "ambiguous" + }; + } + + const lineage = adopted ?? lineageMatches[0] ?? null; const named = contexts.find( (context) => context.name.toLowerCase() === localName.toLowerCase() ); const record = lineage ?? named ?? null; if (!record) { - return { action: "create", bundle, localName, record: null, matchedBy: null }; + return { action: "create", bundle, bundleHash, localName, record: null, matchedBy: null }; } const baseHash = await fingerprintContext(record); @@ -1081,7 +1105,7 @@ export async function resolveImportTarget({ bundleFolder, into }) { baseHash }); const matchedBy = adopted ? "adopted" : lineage ? "lineage" : "name"; - const base = { bundle, localName, record, matchedBy, baseHash, preview }; + const base = { bundle, bundleHash, localName, record, matchedBy, baseHash, preview }; if (!lineage) { return { ...base, action: "choose" }; @@ -1095,7 +1119,7 @@ export async function resolveImportTarget({ bundleFolder, into }) { // whatever the two copies now look like. const stillCurrent = (typeof record.importedFrom?.bundleFingerprint === "string" && - record.importedFrom.bundleFingerprint === fingerprintImportBundle(bundle)) || + record.importedFrom.bundleFingerprint === bundleHash) || !preview.changed; if (stillCurrent) { return { ...base, action: "current" }; @@ -1137,8 +1161,42 @@ export async function replaceContextFromBundle({ bundleFolder, targetId, baseHas // The lineage is re-stamped from the bundle in the same breath: a merge that // did not record which version it consumed would be re-offered, against the // same stale baseline, on every later import. +// +// Two things are checked that `updateCapturedContext` cannot check for itself, +// because it knows about a target and knows nothing about a bundle. +// +// The target has to be this bundle's copy. A capture proves only that it was +// built against *some* local context at a known base hash, so without this an +// unrelated context could be updated here and then have this bundle's lineage +// stamped over its own. +// +// And the bundle has to be the one that was merged. Nothing stops upstream +// changing between drafting and applying, and stamping the newer fingerprint +// over an older draft is the worst outcome available: the late material is +// absent, and the next import says `current` and never offers it again. export async function applyImportMerge({ bundleFolder, capture }) { const bundle = await readImportBundle(bundleFolder); + const bundleId = typeof bundle.manifest.id === "string" ? bundle.manifest.id : null; + const record = await readContext( + typeof capture?.targetId === "string" ? capture.targetId : "" + ); + if (!record) { + throw new ContextError("The context this merge was prepared for no longer exists."); + } + if (!bundleId || record.importedFrom?.id !== bundleId) { + throw new ContextError( + `This merge is addressed to "${record.name}", which is not recorded as the copy ` + + "this bundle belongs to. Resolve the import again and rebuild the merge from what " + + "it prints." + ); + } + if (capture.bundleHash !== fingerprintImportBundle(bundle)) { + throw new ContextError( + "The bundle changed while this merge was being prepared, so applying it would drop " + + "whatever arrived late. Resolve the import again and rebuild the merge from the " + + "current bundle." + ); + } const result = await updateCapturedContext({ ...capture, updatedFrom: "import" }); return { ...result, record: await recordImportLineage(result.record, bundle) }; } diff --git a/shared/core/import-commands.mjs b/shared/core/import-commands.mjs index 8a54c8b..808b51c 100644 --- a/shared/core/import-commands.mjs +++ b/shared/core/import-commands.mjs @@ -23,14 +23,33 @@ import { ContextError, importCapturedContext, previewCapturedContextUpdate, + recordImportLineage, replaceContextFromBundle, resolveImportTarget } from "./context-store.mjs"; -import { putCard } from "./routing.mjs"; +import { MAX_USE_WHEN, putCard, readRouting } from "./routing.mjs"; -function refreshCard(result) { +const normalizeUseWhen = (text) => + (text ?? "").trim().replace(/\s+/g, " ").slice(0, MAX_USE_WHEN); + +// A routing line the user wrote with `describe` lives only in the routing card, +// never in the manifest — so the import baseline cannot see it, and taking a +// bundle whole would put the bundle's line back without either side noticing it +// had been overruled. It is not treated as divergence, because a routing tweak +// is not knowledge and does not need a merge to resolve; it is simply kept. +// +// Locally authored means the card and the manifest disagree, which is exactly +// what `describe` leaves behind and what an import or save never does. +async function authoredUseWhen(record) { + const routing = await readRouting().catch(() => null); + const stored = routing?.cards?.[record.id]?.useWhen ?? ""; + if (stored.length === 0) return null; + return stored === normalizeUseWhen(record.routingDescription) ? null : stored; +} + +function refreshCard(result, authored = null) { return putCard(result.record.id, { - useWhen: result.routingDescription, + useWhen: authored ?? result.routingDescription, source: result.profileText }).catch(() => undefined); } @@ -120,6 +139,13 @@ async function runMergedImport({ bundleFolder, mergedFrom, confirmed, consume }) ); return lines; } + if (typeof capture.bundleHash !== "string" || capture.bundleHash.length === 0) { + lines.push( + "A merged capture must carry the exact bundleHash this import printed — it is what " + + "records which version of the bundle was actually merged." + ); + return lines; + } const preview = await previewCapturedContextUpdate(capture); if (!preview.changed) { @@ -133,8 +159,9 @@ async function runMergedImport({ bundleFolder, mergedFrom, confirmed, consume }) return lines; } + const authored = await authoredUseWhen(preview.record); const result = await applyImportMerge({ bundleFolder, capture }); - await refreshCard(result); + await refreshCard(result, authored); // Only once it has landed, and only when asked. A preview must leave the // draft where it is, and so must any failure, or a merge the model spent the // conversation building would have to be rebuilt from nothing. @@ -190,6 +217,12 @@ export async function runImport({ "separate contexts holding the same material, and both will be considered " + "whenever a session routes itself." ); + } else if (resolved.matchedBy === "ambiguous") { + lines.push( + `Note: ${resolved.candidates.length} contexts here were already copies of this ` + + `bundle, and this makes ${resolved.candidates.length + 1}. A later import cannot ` + + 'tell which one you mean and will ask, so name it with --into "".' + ); } describeImported(lines, created, source, useCommand); return lines.join("\n"); @@ -205,6 +238,17 @@ export async function runImport({ if (resolved.action === "current") { lines.push("Import action: current"); lines.push(`"${record.name}" already holds everything in this bundle. Nothing to import.`); + // Adoption is an answer about identity, and it has to survive even when + // there is no content to move. Left unrecorded, the next bundle from this + // origin would ask the same question again — and no answer to it could + // ever fast-forward, because no baseline was ever written down. + if (resolved.matchedBy === "adopted") { + await recordImportLineage(record, bundle, { identityOnly: true }); + lines.push( + `Recorded that "${record.name}" is this bundle's copy, so a later one is ` + + "recognised without being told again." + ); + } return lines.join("\n"); } @@ -213,6 +257,22 @@ export async function runImport({ // adopts the local context as this bundle's copy; since no baseline against // this bundle exists for it, adopting leads to a merge and never to a // replacement. + // Several local contexts already carry this bundle's lineage, which is what + // forking leaves behind. Any of them could be the one meant, and picking is + // the user's call rather than the list order's. + if (resolved.action === "choose" && resolved.matchedBy === "ambiguous") { + lines.push("Import action: choose"); + lines.push( + `${resolved.candidates.length} contexts here are copies of this bundle, so there ` + + "is no single one to update:" + ); + for (const candidate of resolved.candidates) { + lines.push(` ${candidate.name}`); + } + lines.push('Name the one you mean with --into "".'); + return lines.join("\n"); + } + if (resolved.action === "choose") { lines.push("Import action: choose"); lines.push( @@ -242,14 +302,25 @@ export async function runImport({ "to be reconciled first." ); describeDistance(lines, record, bundle); + // Adoption is recorded now rather than at apply time, so the merge that + // follows can be checked against a target this bundle is known to belong + // to. Identity only: nothing has been taken from the bundle yet. + if (resolved.matchedBy === "adopted") { + await recordImportLineage(record, bundle, { identityOnly: true }); + } lines.push(`Context name: ${record.name}`); lines.push(`Context id: ${record.id}`); lines.push(`Base hash: ${resolved.baseHash}`); + lines.push(`Bundle hash: ${resolved.bundleHash}`); lines.push(`Profile path: ${record.profilePath}`); lines.push(`Knowledge folder: ${record.knowledgeFolder}`); lines.push(`Bundle profile: ${path.join(source, "profile.md")}`); lines.push(`Bundle knowledge: ${path.join(source, "knowledge")}`); - lines.push("Merge both sides, then apply the result with --merged-from."); + lines.push( + "Merge both sides, then apply the result with --merged-from. Carry the context " + + "id, base hash, and bundle hash into the draft exactly as printed: they are what " + + "prove the merge is for this context and was built from this bundle." + ); return lines.join("\n"); } @@ -265,12 +336,16 @@ export async function runImport({ return lines.join("\n"); } + const authored = await authoredUseWhen(record); const result = await replaceContextFromBundle({ bundleFolder, targetId: record.id, baseHash: resolved.baseHash }); - await refreshCard(result); + await refreshCard(result, authored); + if (authored) { + lines.push(`Kept the routing description you set here: ${authored}`); + } describeUpdated( lines, result, diff --git a/shared/core/routing.mjs b/shared/core/routing.mjs index a7a2931..fd21faa 100644 --- a/shared/core/routing.mjs +++ b/shared/core/routing.mjs @@ -44,7 +44,7 @@ export const DEFAULT_MODE = "auto"; // 2 marks the file as one where a stored mode means somebody chose it. See // `chosenMode` for what schema 1 got wrong and why it cannot be read literally. const SCHEMA = 2; -const MAX_USE_WHEN = 240; +export const MAX_USE_WHEN = 240; const MAX_ALIASES = 12; const MAX_DECISIONS = 100; diff --git a/tests/import-reconcile.test.mjs b/tests/import-reconcile.test.mjs index 432d0c2..95b6b10 100644 --- a/tests/import-reconcile.test.mjs +++ b/tests/import-reconcile.test.mjs @@ -220,6 +220,7 @@ describe("importing a bundle this machine already has", () => { name: field(resolved, "Context name"), targetId: field(resolved, "Context id"), baseHash: field(resolved, "Base hash"), + bundleHash: field(resolved, "Bundle hash"), profile: capture().profile + "\n\nBoth: retries capped and the limit was raised.", routingDescription: "Checkout recovery, payment retries, PAY-* tickets", knowledge: [ @@ -284,26 +285,73 @@ describe("importing a bundle this machine already has", () => { /must carry the exact targetId and baseHash this import printed/ ); + const merged = (overrides = {}) => ({ + schema: 1, + name: field(resolved, "Context name"), + targetId: field(resolved, "Context id"), + baseHash: field(resolved, "Base hash"), + bundleHash: field(resolved, "Bundle hash"), + profile: capture().profile + "\n\nReconciled.", + routingDescription: before.routingDescription, + knowledge: [{ path: "session-summary.md", content: "# Session summary\n\nBoth sides.\n" }], + ...overrides + }); + + // Which bundle version was merged is not recoverable from the draft itself, + // so a draft that does not say cannot be trusted to have merged this one. + assert.match( + await draft(JSON.stringify(merged({ bundleHash: undefined }))), + /must carry the exact bundleHash this import printed/ + ); + + // Upstream moving between drafting and applying is the case that silently + // loses their late work: the draft never saw it, and stamping the newer + // fingerprint would mark it as taken. + assert.match( + await draft(JSON.stringify(merged({ bundleHash: "0".repeat(64) }))), + /bundle changed while this merge was being prepared/ + ); + + // A capture is only proof that it was built against some local context. + // Being addressed to one this bundle has no claim on is the case that would + // overwrite an unrelated context and stamp this lineage over its own. + await save( + capture({ + name: "Unrelated Context", + profile: "# Unrelated Context\n\n## Purpose\nSomething else entirely." + }) + ); + const other = await cli("save-target", "Unrelated Context"); + assert.match( + await draft( + JSON.stringify( + merged({ + name: "Unrelated Context", + targetId: field(other, "Context id"), + baseHash: field(other, "Base hash") + }) + ) + ), + /not recorded as the copy this bundle belongs to/ + ); + // A capture that reproduces what is already stored changes nothing, and is // reported rather than written as a no-op revision. const unchanged = await draft( - JSON.stringify({ - schema: 1, - name: field(resolved, "Context name"), - targetId: field(resolved, "Context id"), - baseHash: field(resolved, "Base hash"), - profile: await readFile(path.join(directory, "profile.md"), "utf8"), - routingDescription: before.routingDescription, - knowledge: [ - { - path: "session-summary.md", - content: await readFile( - path.join(directory, "knowledge", "session-summary.md"), - "utf8" - ) - } - ] - }) + JSON.stringify( + merged({ + profile: await readFile(path.join(directory, "profile.md"), "utf8"), + knowledge: [ + { + path: "session-summary.md", + content: await readFile( + path.join(directory, "knowledge", "session-summary.md"), + "utf8" + ) + } + ] + }) + ) ); assert.match(unchanged, /The merge does not change the "Team Checkout" context\./); @@ -422,6 +470,88 @@ describe("importing a bundle a second time under a new name", () => { }); }); +// Three ways a copy's identity or its local intent can be lost quietly. None of +// them announce themselves: each looks like an ordinary import until something +// the user set is gone, or the wrong context is the one that moved. +describe("what import must not overwrite or forget", () => { + it("will not choose between two copies of one bundle", async () => { + const bundle = await sharedBundle(); + await cli("import", "--from", bundle); + // Forking leaves two contexts carrying one lineage id. The fork sorts first, + // so picking by list order would silently target it instead of the original. + const forked = await cli("import", "--from", bundle, "--name", "AAA Fork"); + assert.match(forked, /"Team Checkout" is already a copy of this bundle/); + // Forking again, now that it is already ambiguous, says so instead. + assert.match( + await cli("import", "--from", bundle, "--name", "ZZZ Fork"), + /2 contexts here were already copies of this bundle, and this makes 3/ + ); + await cli("delete", "ZZZ Fork", "--yes"); + await upstreamUpdate(bundle, { profileNote: "Upstream: something new." }); + + const ambiguous = await cli("import", "--from", bundle); + assert.match(ambiguous, /Import action: choose/); + assert.match(ambiguous, /2 contexts here are copies of this bundle/); + assert.match(ambiguous, /AAA Fork/); + assert.match(ambiguous, /Team Checkout/); + assert.doesNotMatch(ambiguous, /Import action: replace/); + + // Named, it resolves to exactly the one asked for. + assert.match( + await cli("import", "--from", bundle, "--into", "Team Checkout"), + /Import action: replace/ + ); + }); + + it("records an adoption even when there is nothing to import", async () => { + const bundle = await sharedBundle(); + const directory = localBundle(await cli("import", "--from", bundle)); + // Strip the lineage, leaving a copy that matches the bundle byte for byte + // but cannot prove where it came from — a context imported before lineage. + const manifest = await manifestAt(directory); + delete manifest.importedFrom; + await writeFile( + path.join(directory, "context.json"), + `${JSON.stringify(manifest, null, 2)}\n` + ); + assert.match(await cli("import", "--from", bundle), /Import action: choose/); + + const adopted = await cli("import", "--from", bundle, "--into", "Team Checkout"); + assert.match(adopted, /Import action: current/); + assert.match(adopted, /Recorded that "Team Checkout" is this bundle's copy/); + assert.equal((await manifestAt(directory)).importedFrom.id, (await manifestAt(bundle)).id); + + // The assertion was needed once. A later bundle is recognised on its own, + // and — since adoption claimed identity but never claimed to have taken the + // contents — it reconciles rather than overwriting. + await upstreamUpdate(bundle, { profileNote: "Upstream: later work." }); + const next = await cli("import", "--from", bundle); + assert.match(next, /Import action: merge/); + assert.doesNotMatch(next, /Import action: choose/); + }); + + it("keeps a routing description written here when taking a newer copy", async () => { + const bundle = await sharedBundle(); + const directory = localBundle(await cli("import", "--from", bundle)); + const mine = "Only the checkout retry work, not the payment provider migration"; + await cli("describe", "Team Checkout", "--use-when", mine); + await upstreamUpdate(bundle, { profileNote: "Upstream: retries are capped." }); + + // `describe` writes to the routing card and never to the manifest, so this + // is deliberately still a fast-forward rather than a merge. + const applied = await cli("import", "--from", bundle, "--yes"); + assert.match(applied, /Updated the "Team Checkout" context from the bundle/); + assert.match(applied, new RegExp(`Kept the routing description you set here: ${mine}`)); + + const routing = JSON.parse(await readFile(path.join(home, "plugin-routing.json"), "utf8")); + assert.equal( + routing.cards[(await manifestAt(directory)).id].useWhen, + mine, + "the line the user wrote must outlive the bundle's" + ); + }); +}); + // Two paths the command line cannot stage: a target deleted between resolving // an import and applying it, and a lineage stamp that fails after the import // has already landed. Both are reached directly, because what they protect is @@ -445,6 +575,21 @@ describe("what import does when the ground moves under it", () => { ); }); + it("refuses to apply a merge whose target has since been deleted", async () => { + const { applyImportMerge, ContextError } = await import( + "../plugins/claude-code/neatcontext/src/core/context-store.mjs" + ); + const bundle = await sharedBundle(); + await assert.rejects( + () => + applyImportMerge({ + bundleFolder: bundle, + capture: { schema: 1, targetId: "context:deleted-while-merging", baseHash: "x" } + }), + (error) => error instanceof ContextError && /no longer exists/.test(error.message) + ); + }); + it("keeps an imported context when its lineage stamp cannot be written", async () => { const { readImportBundle, recordImportLineage } = await import( "../plugins/claude-code/neatcontext/src/core/context-store.mjs"