From 8e7a763b5b55f24042492b2c5f82277d556d49bb Mon Sep 17 00:00:00 2001 From: tanglearncode Date: Sun, 9 Aug 2026 13:54:51 +0800 Subject: [PATCH] feat(save): connect the session that had nothing to what it just saved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saving is the one command that makes a context out of the conversation in front of it, so it is the one place a connection can be inferred rather than asked for. A session with nothing connected has no grounding to lose and has just written the context that describes it, so the save connects it and says so — no `use` on the thing you just wrote. A session that already has a context keeps it, whatever the save wrote to. Saving under another name is Save As: filing this work somewhere else must not re-ground a conversation the user is still having. The rule lives in one shared helper so every host applies it identically, and each host reports which of the two cases it took. A second nameless save is now Save rather than Save As, because the first one connected the session. Co-Authored-By: Claude Opus 5 --- README.md | 15 +++- codex-marketplace/README.md | 15 +++- .../plugins/neatcontext/skills/save/SKILL.md | 4 +- .../neatcontext/src/codex/neatcontext-cli.mjs | 34 +++++++- .../neatcontext/src/core/selection.mjs | 25 +++++- codex-marketplace/tests/codex-plugin.test.mjs | 41 +++++++++- .../claude-code/neatcontext/commands/save.md | 17 ++-- .../src/claude/neatcontext-cli.mjs | 34 +++++++- .../neatcontext/src/core/selection.mjs | 25 +++++- plugins/copilot/neatcontext/commands/save.md | 17 ++-- .../src/copilot/neatcontext-cli.mjs | 33 +++++++- .../neatcontext/src/core/selection.mjs | 25 +++++- .../neatcontext/skills/save/SKILL.md | 4 +- .../neatcontext/src/core/selection.mjs | 25 +++++- .../neatcontext/src/kimi/neatcontext-cli.mjs | 33 +++++++- plugins/pi/neatcontext/skills/save/SKILL.md | 13 ++- plugins/pi/neatcontext/src/core/selection.mjs | 25 +++++- plugins/pi/neatcontext/src/pi/runtime.mjs | 27 ++++++- .../pi/neatcontext/tests/pi-runtime.test.mjs | 28 ++++++- shared/core/selection.mjs | 25 +++++- tests/conversation-save.test.mjs | 66 +++++++++++++-- tests/copilot-plugin.test.mjs | 78 ++++++++++++++++++ tests/kimi-plugin.test.mjs | 80 +++++++++++++++++++ 23 files changed, 633 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index e3d7745..0c91e99 100644 --- a/README.md +++ b/README.md @@ -99,15 +99,22 @@ Claude: Context folder: C:\Users\alex\.neatcontext\contexts\event-partition-investigation Profile path: C:\Users\alex\.neatcontext\contexts\event-partition-investigation\profile.md Knowledge folder: C:\Users\alex\.neatcontext\contexts\event-partition-investigation\knowledge -Use command: /neatcontext:use event-partition-investigation +Connected context: event-partition-investigation +This session had no context connected, so it is now grounded in the one it just +saved. Your next messages will use its domain profile and knowledge folder. ``` The saved context keeps the investigation approach, system knowledge, findings, and verified resolution—not the raw conversation. -After more work on the same subject, run -`/neatcontext:save event-partition-investigation` again. Because that exact name -already exists, Claude previews a merged update and asks before applying it. +This session had nothing connected, so saving also connected it — the work it +just wrote up is the work it is still doing. A session that already has a +context connected keeps it, even when you save under a new name; use +`/neatcontext:use` when you actually want to switch. + +After more work on the same subject, run `/neatcontext:save` again. With no +name it updates the context this session is now connected to, previewing the +merged result and asking before applying it. When a similar issue appears later, connect the saved context in a new Claude Code session by using `/neatcontext:use`. The NeatContext plugin can also route you to the right context in diff --git a/codex-marketplace/README.md b/codex-marketplace/README.md index b22ab51..18c083d 100644 --- a/codex-marketplace/README.md +++ b/codex-marketplace/README.md @@ -64,15 +64,22 @@ Codex: Context folder: C:\Users\alex\.neatcontext\contexts\event-partition-investigation Profile path: C:\Users\alex\.neatcontext\contexts\event-partition-investigation\profile.md Knowledge folder: C:\Users\alex\.neatcontext\contexts\event-partition-investigation\knowledge -Use command: $neatcontext:use event-partition-investigation +Connected context: event-partition-investigation +This session had no context connected, so it is now grounded in the one it just +saved. Your next messages will use its domain profile and knowledge folder. ``` The saved context keeps the investigation approach, system knowledge, findings, and verified resolution—not the raw conversation. -After more work on the same subject, invoke -`$neatcontext:save event-partition-investigation` again. Because that exact name -already exists, Codex previews a merged update and asks before applying it. +This thread had nothing connected, so saving also connected it — the work it +just wrote up is the work it is still doing. A thread that already has a context +connected keeps it, even when you save under a new name; use `$neatcontext:use` +when you actually want to switch. + +After more work on the same subject, invoke `$neatcontext:save` again. With no +name it updates the context this thread is now connected to, previewing the +merged result and asking before applying it. When a similar issue appears later, connect the saved context in a new Codex thread with `$neatcontext:use`. NeatContext can also route you to the right diff --git a/codex-marketplace/plugins/neatcontext/skills/save/SKILL.md b/codex-marketplace/plugins/neatcontext/skills/save/SKILL.md index 6a84f83..26e5d85 100644 --- a/codex-marketplace/plugins/neatcontext/skills/save/SKILL.md +++ b/codex-marketplace/plugins/neatcontext/skills/save/SKILL.md @@ -28,7 +28,7 @@ Follow the CLI's `Save action`: For a context made with `$neatcontext:create`, treat its linked knowledge folder as read-only. Read only files relevant to this conversation. Generated conversation additions belong in the bundle-local conversation-knowledge folder. -Updating a named context does not connect or switch to it. Do not adopt an unconnected target's profile as instructions for the current thread. +A save never switches a thread that already has a context connected. Do not adopt an unconnected target's profile as instructions for the current thread. A thread with nothing connected is the one exception, and the CLI applies it: saving connects the thread to the context it just wrote, and says so. ## Distill or merge @@ -106,4 +106,4 @@ Relay the preview and wait for confirmation. After confirmation, run: node "/src/codex/neatcontext-cli.mjs" save --from "" --yes --consume ``` -If the target changed after drafting, resolve it again and rebuild the merge. Relay successful output as printed. Do not connect the saved context automatically. +If the target changed after drafting, resolve it again and rebuild the merge. Relay successful output as printed, and never connect a context yourself — the CLI decides. A thread that had nothing connected is connected to what the save wrote, and its output says so; ground the rest of this thread in that context. A thread that already had one keeps it, whichever context the save wrote to. diff --git a/codex-marketplace/plugins/neatcontext/src/codex/neatcontext-cli.mjs b/codex-marketplace/plugins/neatcontext/src/codex/neatcontext-cli.mjs index 8e8dec4..6cae448 100644 --- a/codex-marketplace/plugins/neatcontext/src/codex/neatcontext-cli.mjs +++ b/codex-marketplace/plugins/neatcontext/src/codex/neatcontext-cli.mjs @@ -53,7 +53,12 @@ import { renderExtensionsStatus, testExtension } from "../core/extension-commands.mjs"; -import { applySelection, disconnectSelection, resolveContext } from "../core/selection.mjs"; +import { + applySelection, + connectAfterSave, + disconnectSelection, + resolveContext +} from "../core/selection.mjs"; const CONTEXT_NOTE = "A context holds one domain profile, one primary knowledge folder, and optional " + @@ -630,6 +635,29 @@ function printUpdatePreview(preview) { print("Re-run this save with --yes to confirm."); } +// Where this session stands once the save has landed. An unconnected session +// adopts what it just wrote; a connected one is told, in the same breath as the +// `use` line, that it was left alone on purpose. +async function printSaveConnection(record) { + const outcome = await connectAfterSave(record).catch(() => null); + if (outcome?.connected) { + print(`Connected context: ${record.name}`); + print( + "This session had no context connected, so it is now grounded in the one it " + + "just saved. Your next messages will use its domain profile and knowledge folder." + ); + await printBridgeDrift(); + return; + } + print(`Use command: $neatcontext:use ${record.name}`); + if (outcome && outcome.contextId !== record.id) { + print( + `This session stays connected to "${outcome.contextName}" — a save records work, ` + + "it does not switch the context you are working in." + ); + } +} + async function commandSave(flags) { const source = typeof flags.from === "string" ? flags.from : ""; if (source.trim().length === 0) { @@ -679,7 +707,7 @@ async function commandSave(flags) { if (!result.record.knowledgeManaged) { print(`Conversation knowledge folder: ${result.record.conversationKnowledgeFolder}`); } - print(`Use command: $neatcontext:use ${result.record.name}`); + await printSaveConnection(result.record); return; } @@ -697,7 +725,7 @@ async function commandSave(flags) { print(`Context folder: ${result.record.directory}`); print(`Profile path: ${result.record.profilePath}`); print(`Knowledge folder: ${result.record.knowledgeFolder}`); - print(`Use command: $neatcontext:use ${result.record.name}`); + await printSaveConnection(result.record); } catch (error) { if (error instanceof ContextError) { print(error.message); diff --git a/codex-marketplace/plugins/neatcontext/src/core/selection.mjs b/codex-marketplace/plugins/neatcontext/src/core/selection.mjs index 709b7ca..ac4065b 100644 --- a/codex-marketplace/plugins/neatcontext/src/core/selection.mjs +++ b/codex-marketplace/plugins/neatcontext/src/core/selection.mjs @@ -1,4 +1,4 @@ -import { clearSelection, writeSelection } from "./local-state.mjs"; +import { clearSelection, readSelection, writeSelection } from "./local-state.mjs"; import { listContexts } from "./context-store.mjs"; export async function listAllContexts() { @@ -33,4 +33,27 @@ export async function disconnectSelection() { await clearSelection(); } +// Saving is the one command that makes a context out of the conversation in +// front of it, so it is the one place a connection can be inferred instead of +// asked for. A session with nothing connected has no grounding to lose and has +// just written the context that describes it: connect it here, and spare the +// user the `use` they would have typed next. +// +// A session that already has one keeps it, whatever the save wrote to. Saving +// under another name is Save As — filing this work somewhere else — and moving +// the session onto that copy would re-ground a conversation the user is still +// having, without them asking for it. +export async function connectAfterSave(target) { + const selection = await readSelection().catch(() => null); + if (selection && selection.available !== false) { + return { + connected: false, + contextId: selection.contextId, + contextName: selection.contextName + }; + } + await writeSelection({ contextId: target.id, contextName: target.name }); + return { connected: true, contextId: target.id, contextName: target.name }; +} + export { clearSelection }; diff --git a/codex-marketplace/tests/codex-plugin.test.mjs b/codex-marketplace/tests/codex-plugin.test.mjs index bfcea56..6cd1653 100644 --- a/codex-marketplace/tests/codex-plugin.test.mjs +++ b/codex-marketplace/tests/codex-plugin.test.mjs @@ -184,7 +184,8 @@ test("Codex saves conversation provenance without touching a transcript", async const result = await runNode(cli, ["save", "--from", capturePath, "--consume"], { env }); assert.equal(result.code, 0); - assert.match(result.stdout, /Use command: \$neatcontext:use Codex smoke context/); + // Nothing was connected to this thread, so the save is also the connection. + assert.match(result.stdout, /Connected context: Codex smoke context/); const contextEntries = await readdir(path.join(home, "contexts")); assert.equal(contextEntries.length, 1); @@ -195,6 +196,33 @@ test("Codex saves conversation provenance without touching a transcript", async assert.equal(manifest.kind, undefined); assert.equal(manifest.capturedFrom, "codex-conversation"); + // Updating the context this thread is on leaves the connection where it is. + const target = await runNode(cli, ["save-target", "Codex smoke context"], { env }); + const field = (label) => new RegExp(`^${label}: (.+)$`, "m").exec(target.stdout)?.[1].trim(); + await writeFile( + capturePath, + JSON.stringify({ + schema: 1, + name: "Codex smoke context", + targetId: field("Context id"), + baseHash: field("Base hash"), + profile: + "# Codex smoke context\n\n## Purpose\nTest Codex capture.\n\n## What to do\nUse the saved facts.\n\n## What to avoid\nDo not invent facts.\n\n## Behavior\nBe concise.", + routingDescription: "Use for Codex plugin smoke-test requests.", + knowledge: [ + { + path: "session-summary.md", + content: "# Session summary\n\nThe Codex update path works too." + } + ] + }), + "utf8" + ); + const updated = await runNode(cli, ["save", "--from", capturePath, "--yes", "--consume"], { env }); + assert.match(updated.stdout, /Updated context: Codex smoke context/); + assert.match(updated.stdout, /Use command: \$neatcontext:use Codex smoke context/); + assert.doesNotMatch(updated.stdout, /stays connected to/); + const connected = await runNode(cli, ["use", "Codex smoke context"], { env }); assert.match(connected.stdout, /Connected the "Codex smoke context" context/); @@ -294,7 +322,16 @@ test("selected contexts advertise one-shot grounding guidance", async () => { }), "utf8" ); - assert.equal((await runNode(cli, ["save", "--from", capturePath, "--consume"], { env })).code, 0); + // Saved from another thread on purpose: a save connects the thread it ran in, + // and this test needs "selected-thread" to start with nothing selected. + assert.equal( + ( + await runNode(cli, ["save", "--from", capturePath, "--consume"], { + env: { ...env, CODEX_THREAD_ID: "authoring-thread" } + }) + ).code, + 0 + ); const hookInput = JSON.stringify({ session_id: "selected-thread", diff --git a/plugins/claude-code/neatcontext/commands/save.md b/plugins/claude-code/neatcontext/commands/save.md index 9b6bd74..a5e7d64 100644 --- a/plugins/claude-code/neatcontext/commands/save.md +++ b/plugins/claude-code/neatcontext/commands/save.md @@ -55,9 +55,12 @@ Follow the `Save action` from `save-target`: - `unavailable` — relay why the destination cannot be updated and ask for a new context name. -Updating a named context does not connect or switch to it. When it is not the -connected context, treat its profile as source material for this save only; do -not adopt its instructions or re-ground the current session. +A save never switches a session that already has a context connected. When the +target is not the connected context, treat its profile as source material for +this save only; do not adopt its instructions or re-ground the current session. + +A session with nothing connected is the one exception, and the CLI applies it: +saving connects the session to the context it just wrote, and says so. ## Build an ephemeral evidence view @@ -248,6 +251,8 @@ leave it available for repair. If the context changed after drafting, resolve the target again and rebuild the merge from its new contents rather than reusing the stale capture. -Relay successful output as printed. Do not connect a new or named context -automatically. An updated connected context remains connected and is available -immediately. +Relay successful output as printed, and never connect a context yourself — the +CLI decides. A session that had nothing connected is connected to what the save +wrote, and its output says so; ground the rest of this session in that context. +A session that already had one keeps it, whichever context the save wrote to. +An updated connected context remains connected and is available immediately. diff --git a/plugins/claude-code/neatcontext/src/claude/neatcontext-cli.mjs b/plugins/claude-code/neatcontext/src/claude/neatcontext-cli.mjs index 31f92e0..f663aa7 100644 --- a/plugins/claude-code/neatcontext/src/claude/neatcontext-cli.mjs +++ b/plugins/claude-code/neatcontext/src/claude/neatcontext-cli.mjs @@ -61,7 +61,12 @@ import { renderEvidenceOverview, renderEvidenceSearch } from "../core/conversation-evidence.mjs"; -import { applySelection, disconnectSelection, resolveContext } from "../core/selection.mjs"; +import { + applySelection, + connectAfterSave, + disconnectSelection, + resolveContext +} from "../core/selection.mjs"; import { readClaudeTranscriptEvidence } from "./conversation-evidence.mjs"; const CONTEXT_NOTE = @@ -706,6 +711,29 @@ function printUpdatePreview(preview) { print("Re-run this save with --yes to confirm."); } +// Where this session stands once the save has landed. An unconnected session +// adopts what it just wrote; a connected one is told, in the same breath as the +// `use` line, that it was left alone on purpose. +async function printSaveConnection(record) { + const outcome = await connectAfterSave(record).catch(() => null); + if (outcome?.connected) { + print(`Connected context: ${record.name}`); + print( + "This session had no context connected, so it is now grounded in the one it " + + "just saved. Your next messages will use its domain profile and knowledge folder." + ); + await printBridgeDrift(); + return; + } + print(`Use command: /neatcontext:use ${record.name}`); + if (outcome && outcome.contextId !== record.id) { + print( + `This session stays connected to "${outcome.contextName}" — a save records work, ` + + "it does not switch the context you are working in." + ); + } +} + async function commandSave(flags) { const source = typeof flags.from === "string" ? flags.from : ""; if (source.trim().length === 0) { @@ -753,7 +781,7 @@ async function commandSave(flags) { if (!result.record.knowledgeManaged) { print(`Conversation knowledge folder: ${result.record.conversationKnowledgeFolder}`); } - print(`Use command: /neatcontext:use ${result.record.name}`); + await printSaveConnection(result.record); return; } @@ -769,7 +797,7 @@ async function commandSave(flags) { print(`Context folder: ${result.record.directory}`); print(`Profile path: ${result.record.profilePath}`); print(`Knowledge folder: ${result.record.knowledgeFolder}`); - print(`Use command: /neatcontext:use ${result.record.name}`); + await printSaveConnection(result.record); } catch (error) { if (error instanceof ContextError) { print(error.message); diff --git a/plugins/claude-code/neatcontext/src/core/selection.mjs b/plugins/claude-code/neatcontext/src/core/selection.mjs index 709b7ca..ac4065b 100644 --- a/plugins/claude-code/neatcontext/src/core/selection.mjs +++ b/plugins/claude-code/neatcontext/src/core/selection.mjs @@ -1,4 +1,4 @@ -import { clearSelection, writeSelection } from "./local-state.mjs"; +import { clearSelection, readSelection, writeSelection } from "./local-state.mjs"; import { listContexts } from "./context-store.mjs"; export async function listAllContexts() { @@ -33,4 +33,27 @@ export async function disconnectSelection() { await clearSelection(); } +// Saving is the one command that makes a context out of the conversation in +// front of it, so it is the one place a connection can be inferred instead of +// asked for. A session with nothing connected has no grounding to lose and has +// just written the context that describes it: connect it here, and spare the +// user the `use` they would have typed next. +// +// A session that already has one keeps it, whatever the save wrote to. Saving +// under another name is Save As — filing this work somewhere else — and moving +// the session onto that copy would re-ground a conversation the user is still +// having, without them asking for it. +export async function connectAfterSave(target) { + const selection = await readSelection().catch(() => null); + if (selection && selection.available !== false) { + return { + connected: false, + contextId: selection.contextId, + contextName: selection.contextName + }; + } + await writeSelection({ contextId: target.id, contextName: target.name }); + return { connected: true, contextId: target.id, contextName: target.name }; +} + export { clearSelection }; diff --git a/plugins/copilot/neatcontext/commands/save.md b/plugins/copilot/neatcontext/commands/save.md index 8c11de9..b93093d 100644 --- a/plugins/copilot/neatcontext/commands/save.md +++ b/plugins/copilot/neatcontext/commands/save.md @@ -55,9 +55,12 @@ Follow the `Save action` from `save-target`: - `unavailable` — relay why the destination cannot be updated and ask for a new context name. -Updating a named context does not connect or switch to it. When it is not the -connected context, treat its profile as source material for this save only; do -not adopt its instructions or re-ground the current session. +A save never switches a session that already has a context connected. When the +target is not the connected context, treat its profile as source material for +this save only; do not adopt its instructions or re-ground the current session. + +A session with nothing connected is the one exception, and the CLI applies it: +saving connects the session to the context it just wrote, and says so. ## Distill or merge the conversation @@ -213,6 +216,8 @@ concurrency, and other failures leave it available for repair. If the context changed after drafting, resolve the target again and rebuild the merge from its new contents rather than reusing the stale capture. -Relay successful output as printed. Do not connect a new or named context -automatically. An updated connected context remains connected and is available -immediately. +Relay successful output as printed, and never connect a context yourself — the +CLI decides. A session that had nothing connected is connected to what the save +wrote, and its output says so; ground the rest of this session in that context. +A session that already had one keeps it, whichever context the save wrote to. +An updated connected context remains connected and is available immediately. diff --git a/plugins/copilot/neatcontext/src/copilot/neatcontext-cli.mjs b/plugins/copilot/neatcontext/src/copilot/neatcontext-cli.mjs index dfb7979..f051f2b 100644 --- a/plugins/copilot/neatcontext/src/copilot/neatcontext-cli.mjs +++ b/plugins/copilot/neatcontext/src/copilot/neatcontext-cli.mjs @@ -52,7 +52,12 @@ import { renderExtensionsStatus, testExtension } from "../core/extension-commands.mjs"; -import { applySelection, disconnectSelection, resolveContext } from "../core/selection.mjs"; +import { + applySelection, + connectAfterSave, + disconnectSelection, + resolveContext +} from "../core/selection.mjs"; const CONTEXT_NOTE = "A context holds one domain profile, one primary knowledge folder, and optional " + @@ -570,6 +575,28 @@ function printUpdatePreview(preview) { print("Re-run this save with --yes to confirm."); } +// Where this session stands once the save has landed. An unconnected session +// adopts what it just wrote; a connected one is told, in the same breath as the +// `use` line, that it was left alone on purpose. +async function printSaveConnection(record) { + const outcome = await connectAfterSave(record).catch(() => null); + if (outcome?.connected) { + print(`Connected context: ${record.name}`); + print( + "This session had no context connected, so it is now grounded in the one it " + + "just saved. Your next messages will use its domain profile and knowledge folder." + ); + return; + } + print(`Use command: /neatcontext:use ${record.name}`); + if (outcome && outcome.contextId !== record.id) { + print( + `This session stays connected to "${outcome.contextName}" — a save records work, ` + + "it does not switch the context you are working in." + ); + } +} + async function commandSave(flags) { const source = typeof flags.from === "string" ? flags.from : ""; if (source.trim().length === 0) { @@ -619,7 +646,7 @@ async function commandSave(flags) { if (!result.record.knowledgeManaged) { print(`Conversation knowledge folder: ${result.record.conversationKnowledgeFolder}`); } - print(`Use command: /neatcontext:use ${result.record.name}`); + await printSaveConnection(result.record); return; } @@ -637,7 +664,7 @@ async function commandSave(flags) { print(`Context folder: ${result.record.directory}`); print(`Profile path: ${result.record.profilePath}`); print(`Knowledge folder: ${result.record.knowledgeFolder}`); - print(`Use command: /neatcontext:use ${result.record.name}`); + await printSaveConnection(result.record); } catch (error) { if (error instanceof ContextError) { print(error.message); diff --git a/plugins/copilot/neatcontext/src/core/selection.mjs b/plugins/copilot/neatcontext/src/core/selection.mjs index 709b7ca..ac4065b 100644 --- a/plugins/copilot/neatcontext/src/core/selection.mjs +++ b/plugins/copilot/neatcontext/src/core/selection.mjs @@ -1,4 +1,4 @@ -import { clearSelection, writeSelection } from "./local-state.mjs"; +import { clearSelection, readSelection, writeSelection } from "./local-state.mjs"; import { listContexts } from "./context-store.mjs"; export async function listAllContexts() { @@ -33,4 +33,27 @@ export async function disconnectSelection() { await clearSelection(); } +// Saving is the one command that makes a context out of the conversation in +// front of it, so it is the one place a connection can be inferred instead of +// asked for. A session with nothing connected has no grounding to lose and has +// just written the context that describes it: connect it here, and spare the +// user the `use` they would have typed next. +// +// A session that already has one keeps it, whatever the save wrote to. Saving +// under another name is Save As — filing this work somewhere else — and moving +// the session onto that copy would re-ground a conversation the user is still +// having, without them asking for it. +export async function connectAfterSave(target) { + const selection = await readSelection().catch(() => null); + if (selection && selection.available !== false) { + return { + connected: false, + contextId: selection.contextId, + contextName: selection.contextName + }; + } + await writeSelection({ contextId: target.id, contextName: target.name }); + return { connected: true, contextId: target.id, contextName: target.name }; +} + export { clearSelection }; diff --git a/plugins/kimi-code/neatcontext/skills/save/SKILL.md b/plugins/kimi-code/neatcontext/skills/save/SKILL.md index f7b790c..9d3c043 100644 --- a/plugins/kimi-code/neatcontext/skills/save/SKILL.md +++ b/plugins/kimi-code/neatcontext/skills/save/SKILL.md @@ -28,7 +28,7 @@ Follow the CLI's `Save action`: For a context made with `/neatcontext:create`, treat its linked knowledge folder as read-only. Read only files relevant to this conversation. Generated conversation additions belong in the bundle-local conversation-knowledge folder. -Updating a named context does not connect or switch to it. Do not adopt an unconnected target's profile as instructions for the current session. +A save never switches a session that already has a context connected. Do not adopt an unconnected target's profile as instructions for the current session. A session with nothing connected is the one exception, and the CLI applies it: saving connects the session to the context it just wrote, and says so. ## Distill or merge @@ -106,4 +106,4 @@ Relay the preview and wait for confirmation. After confirmation, run: KIMI_PLUGIN_ROOT="${KIMI_SKILL_DIR}/../.." kimi __plugin_run_node "${KIMI_SKILL_DIR}/../../src/kimi/neatcontext-cli.mjs" -- --session-id "${KIMI_SESSION_ID}" save --from "" --yes --consume ``` -If the target changed after drafting, resolve it again and rebuild the merge. Relay successful output as printed. Do not connect the saved context automatically. +If the target changed after drafting, resolve it again and rebuild the merge. Relay successful output as printed, and never connect a context yourself — the CLI decides. A session that had nothing connected is connected to what the save wrote, and its output says so; ground the rest of this session in that context. A session that already had one keeps it, whichever context the save wrote to. diff --git a/plugins/kimi-code/neatcontext/src/core/selection.mjs b/plugins/kimi-code/neatcontext/src/core/selection.mjs index 709b7ca..ac4065b 100644 --- a/plugins/kimi-code/neatcontext/src/core/selection.mjs +++ b/plugins/kimi-code/neatcontext/src/core/selection.mjs @@ -1,4 +1,4 @@ -import { clearSelection, writeSelection } from "./local-state.mjs"; +import { clearSelection, readSelection, writeSelection } from "./local-state.mjs"; import { listContexts } from "./context-store.mjs"; export async function listAllContexts() { @@ -33,4 +33,27 @@ export async function disconnectSelection() { await clearSelection(); } +// Saving is the one command that makes a context out of the conversation in +// front of it, so it is the one place a connection can be inferred instead of +// asked for. A session with nothing connected has no grounding to lose and has +// just written the context that describes it: connect it here, and spare the +// user the `use` they would have typed next. +// +// A session that already has one keeps it, whatever the save wrote to. Saving +// under another name is Save As — filing this work somewhere else — and moving +// the session onto that copy would re-ground a conversation the user is still +// having, without them asking for it. +export async function connectAfterSave(target) { + const selection = await readSelection().catch(() => null); + if (selection && selection.available !== false) { + return { + connected: false, + contextId: selection.contextId, + contextName: selection.contextName + }; + } + await writeSelection({ contextId: target.id, contextName: target.name }); + return { connected: true, contextId: target.id, contextName: target.name }; +} + export { clearSelection }; diff --git a/plugins/kimi-code/neatcontext/src/kimi/neatcontext-cli.mjs b/plugins/kimi-code/neatcontext/src/kimi/neatcontext-cli.mjs index 2461921..56c4b0c 100644 --- a/plugins/kimi-code/neatcontext/src/kimi/neatcontext-cli.mjs +++ b/plugins/kimi-code/neatcontext/src/kimi/neatcontext-cli.mjs @@ -52,7 +52,12 @@ import { renderExtensionsStatus, testExtension } from "../core/extension-commands.mjs"; -import { applySelection, disconnectSelection, resolveContext } from "../core/selection.mjs"; +import { + applySelection, + connectAfterSave, + disconnectSelection, + resolveContext +} from "../core/selection.mjs"; const CONTEXT_NOTE = "A context holds one domain profile, one primary knowledge folder, and optional " + @@ -593,6 +598,28 @@ function printUpdatePreview(preview) { print("Re-run this save with --yes to confirm."); } +// Where this session stands once the save has landed. An unconnected session +// adopts what it just wrote; a connected one is told, in the same breath as the +// `use` line, that it was left alone on purpose. +async function printSaveConnection(record) { + const outcome = await connectAfterSave(record).catch(() => null); + if (outcome?.connected) { + print(`Connected context: ${record.name}`); + print( + "This session had no context connected, so it is now grounded in the one it " + + "just saved. Your next messages will use its domain profile and knowledge folder." + ); + return; + } + print(`Use command: /neatcontext:use ${record.name}`); + if (outcome && outcome.contextId !== record.id) { + print( + `This session stays connected to "${outcome.contextName}" — a save records work, ` + + "it does not switch the context you are working in." + ); + } +} + async function commandSave(flags) { const source = typeof flags.from === "string" ? flags.from : ""; if (source.trim().length === 0) { @@ -642,7 +669,7 @@ async function commandSave(flags) { if (!result.record.knowledgeManaged) { print(`Conversation knowledge folder: ${result.record.conversationKnowledgeFolder}`); } - print(`Use command: /neatcontext:use ${result.record.name}`); + await printSaveConnection(result.record); return; } @@ -660,7 +687,7 @@ async function commandSave(flags) { print(`Context folder: ${result.record.directory}`); print(`Profile path: ${result.record.profilePath}`); print(`Knowledge folder: ${result.record.knowledgeFolder}`); - print(`Use command: /neatcontext:use ${result.record.name}`); + await printSaveConnection(result.record); } catch (error) { if (error instanceof ContextError) { print(error.message); diff --git a/plugins/pi/neatcontext/skills/save/SKILL.md b/plugins/pi/neatcontext/skills/save/SKILL.md index 6050eb7..3576f47 100644 --- a/plugins/pi/neatcontext/skills/save/SKILL.md +++ b/plugins/pi/neatcontext/skills/save/SKILL.md @@ -25,8 +25,10 @@ returns: - `choose` — show the possible matches and wait for the user to choose. - `unavailable` — relay the reason and ask for a new context name. -Updating a named context does not connect or switch to it. Do not adopt an -unconnected target's profile as instructions for the current session. +A save never switches a session that already has a context connected. Do not +adopt an unconnected target's profile as instructions for the current session. +A session with nothing connected is the one exception, and the tool applies it: +saving connects the session to the context it just wrote, and says so. ## Distill or merge @@ -106,5 +108,8 @@ nothing. Relay the preview and wait. Only after the user agrees, call it again with the same arguments plus `confirm: true`. If the tool reports that the target changed after you drafted, resolve the -destination again and rebuild the merge. Relay successful output as printed. Do -not connect the saved context automatically. +destination again and rebuild the merge. Relay successful output as printed, and +never connect a context yourself — the tool decides. A session that had nothing +connected is connected to what the save wrote, and its output says so; ground the +rest of this session in that context. A session that already had one keeps it, +whichever context the save wrote to. diff --git a/plugins/pi/neatcontext/src/core/selection.mjs b/plugins/pi/neatcontext/src/core/selection.mjs index 709b7ca..ac4065b 100644 --- a/plugins/pi/neatcontext/src/core/selection.mjs +++ b/plugins/pi/neatcontext/src/core/selection.mjs @@ -1,4 +1,4 @@ -import { clearSelection, writeSelection } from "./local-state.mjs"; +import { clearSelection, readSelection, writeSelection } from "./local-state.mjs"; import { listContexts } from "./context-store.mjs"; export async function listAllContexts() { @@ -33,4 +33,27 @@ export async function disconnectSelection() { await clearSelection(); } +// Saving is the one command that makes a context out of the conversation in +// front of it, so it is the one place a connection can be inferred instead of +// asked for. A session with nothing connected has no grounding to lose and has +// just written the context that describes it: connect it here, and spare the +// user the `use` they would have typed next. +// +// A session that already has one keeps it, whatever the save wrote to. Saving +// under another name is Save As — filing this work somewhere else — and moving +// the session onto that copy would re-ground a conversation the user is still +// having, without them asking for it. +export async function connectAfterSave(target) { + const selection = await readSelection().catch(() => null); + if (selection && selection.available !== false) { + return { + connected: false, + contextId: selection.contextId, + contextName: selection.contextName + }; + } + await writeSelection({ contextId: target.id, contextName: target.name }); + return { connected: true, contextId: target.id, contextName: target.name }; +} + export { clearSelection }; diff --git a/plugins/pi/neatcontext/src/pi/runtime.mjs b/plugins/pi/neatcontext/src/pi/runtime.mjs index 8533c7d..74706fb 100644 --- a/plugins/pi/neatcontext/src/pi/runtime.mjs +++ b/plugins/pi/neatcontext/src/pi/runtime.mjs @@ -65,6 +65,7 @@ import { import { assess, createRoutingIndex } from "../core/routing-candidates.mjs"; import { applySelection, + connectAfterSave, disconnectSelection, listAllContexts, resolveContext @@ -989,6 +990,28 @@ function formatChangedFiles(label, files) { return files.length === 0 ? [] : [` ${label}: ${files.join(", ")}`]; } +// Where this session stands once the save has landed. An unconnected session +// adopts what it just wrote; a connected one is told, in the same breath as the +// `use` line, that it was left alone on purpose. +async function saveConnectionLines(record) { + const outcome = await connectAfterSave(record).catch(() => null); + if (outcome?.connected) { + return [ + `Connected context: ${record.name}`, + "This session had no context connected, so it is now grounded in the one it just " + + "saved. Your next messages will use its domain profile and knowledge folder." + ]; + } + const lines = [`Connect it with: /neatcontext-use ${record.name}`]; + if (outcome && outcome.contextId !== record.id) { + lines.push( + `This session stays connected to "${outcome.contextName}" — a save records work, ` + + "it does not switch the context you are working in." + ); + } + return lines; +} + function renderUpdatePreview(preview) { const { record, changes } = preview; const lines = [ @@ -1077,7 +1100,7 @@ export async function saveContext(args = {}) { ...(result.record.knowledgeManaged ? [] : [`Conversation knowledge folder: ${result.record.conversationKnowledgeFolder}`]), - `Connect it with: /neatcontext-use ${result.record.name}` + ...(await saveConnectionLines(result.record)) ].join("\n"); } @@ -1094,7 +1117,7 @@ export async function saveContext(args = {}) { `Context folder: ${result.record.directory}`, `Profile path: ${result.record.profilePath}`, `Knowledge folder: ${result.record.knowledgeFolder}`, - `Connect it with: /neatcontext-use ${result.record.name}` + ...(await saveConnectionLines(result.record)) ].join("\n"); } catch (error) { if (error instanceof ContextError) { diff --git a/plugins/pi/neatcontext/tests/pi-runtime.test.mjs b/plugins/pi/neatcontext/tests/pi-runtime.test.mjs index 48d7389..9d384fa 100644 --- a/plugins/pi/neatcontext/tests/pi-runtime.test.mjs +++ b/plugins/pi/neatcontext/tests/pi-runtime.test.mjs @@ -272,10 +272,36 @@ describe("save", () => { knowledge }); assert.match(saved, /Saved context: Queue lag/); - assert.match(saved, /\/neatcontext-use Queue lag/); + // Nothing was connected to this session, so the save is also the connection. + assert.match(saved, /Connected context: Queue lag/); + assert.match(await runtime.commandStatus(), /Connected context: Queue lag/); assert.match(await runtime.commandList(), /Queue lag/); }); + it("connects a session that had no context, and leaves a connected one alone", async () => { + await createOrders(); + const first = await runtime.saveContext({ + name: "Queue lag", + profile: "# Queue lag\n\n## Purpose\n\nPartition skew.\n", + routingDescription: "order-events partition lag", + knowledge + }); + assert.match(first, /Connected context: Queue lag/); + + // Save As, from a session that is already grounded: the new context is + // written, and the session stays where it was. + await runtime.commandUse("Orders"); + const second = await runtime.saveContext({ + name: "Queue lag II", + profile: "# Queue lag II\n\n## Purpose\n\nMore partition skew.\n", + routingDescription: "order-events partition lag, second pass", + knowledge + }); + assert.match(second, /Connect it with: \/neatcontext-use Queue lag II/); + assert.match(second, /stays connected to "Orders"/); + assert.match(await runtime.commandStatus(), /Connected context: Orders/); + }); + it("exports a saved Context and keeps the neutral manifest", async () => { await runtime.saveContext({ name: "Queue lag", diff --git a/shared/core/selection.mjs b/shared/core/selection.mjs index 709b7ca..ac4065b 100644 --- a/shared/core/selection.mjs +++ b/shared/core/selection.mjs @@ -1,4 +1,4 @@ -import { clearSelection, writeSelection } from "./local-state.mjs"; +import { clearSelection, readSelection, writeSelection } from "./local-state.mjs"; import { listContexts } from "./context-store.mjs"; export async function listAllContexts() { @@ -33,4 +33,27 @@ export async function disconnectSelection() { await clearSelection(); } +// Saving is the one command that makes a context out of the conversation in +// front of it, so it is the one place a connection can be inferred instead of +// asked for. A session with nothing connected has no grounding to lose and has +// just written the context that describes it: connect it here, and spare the +// user the `use` they would have typed next. +// +// A session that already has one keeps it, whatever the save wrote to. Saving +// under another name is Save As — filing this work somewhere else — and moving +// the session onto that copy would re-ground a conversation the user is still +// having, without them asking for it. +export async function connectAfterSave(target) { + const selection = await readSelection().catch(() => null); + if (selection && selection.available !== false) { + return { + connected: false, + contextId: selection.contextId, + contextName: selection.contextName + }; + } + await writeSelection({ contextId: target.id, contextName: target.name }); + return { connected: true, contextId: target.id, contextName: target.name }; +} + export { clearSelection }; diff --git a/tests/conversation-save.test.mjs b/tests/conversation-save.test.mjs index 240e0a3..7db553b 100644 --- a/tests/conversation-save.test.mjs +++ b/tests/conversation-save.test.mjs @@ -190,7 +190,7 @@ function openSession() { } describe("saving the current conversation", () => { - it("creates a self-contained context without connecting it", async () => { + it("creates a self-contained context and connects the session that had none", async () => { const { file, output } = await saveCapture(validCapture()); const bundle = bundleFrom(output); @@ -198,10 +198,12 @@ describe("saving the current conversation", () => { `Context folder: ${bundle}`, `Profile path: ${path.join(bundle, "profile.md")}`, `Knowledge folder: ${path.join(bundle, "knowledge")}`, - "Use command: /neatcontext:use Conversation Capture" + "Connected context: Conversation Capture", + "This session had no context connected, so it is now grounded in the one it " + + "just saved. Your next messages will use its domain profile and knowledge folder." ]); await assert.rejects(readFile(file, "utf8"), { code: "ENOENT" }); - assert.match(await cli("status"), /No context is connected yet/); + assert.match(await cli("status"), /Connected context: Conversation Capture/); const manifest = JSON.parse(await readFile(path.join(bundle, "context.json"), "utf8")); assert.equal(manifest.schema, 2); @@ -220,6 +222,54 @@ describe("saving the current conversation", () => { ); }); + // The whole rule, in one place: a save connects a session that has nothing, + // and never moves a session that has something. Save As is the case that + // would otherwise re-ground a conversation the user is still having. + it("leaves a connected session on the context it is already working in", async () => { + await saveCapture(); + assert.match(await cli("status"), /Connected context: Conversation Capture/); + + const second = await saveCapture( + validCapture({ + name: "Second Capture", + profile: + "# Second Capture\n\n## Purpose\nPreserve the refund work.\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: "Refund reversals, ledger corrections, and REF-* tickets" + }) + ); + + assert.match(second.output, /Use command: \/neatcontext:use Second Capture/); + assert.match(second.output, /stays connected to "Conversation Capture"/); + assert.match(await cli("status"), /Connected context: Conversation Capture/); + assert.match(await cli("list"), /Second Capture/); + }); + + it("connects the session to a context it updated when nothing was connected", async () => { + await saveCapture(); + await cli("disconnect"); + assert.match(await cli("status"), /No context is connected yet/); + + const target = await cli("save-target", "Conversation Capture"); + const file = await writeCapture( + updateCaptureFrom(target, { + knowledge: [ + { + path: "session-summary.md", + content: "# Session summary\n\nRetry backoff is deployed and verified." + } + ] + }) + ); + + const updated = await cli("save", "--from", file, "--yes"); + assert.match(updated, /Updated context: Conversation Capture/); + assert.match(updated, /Connected context: Conversation Capture/); + assert.match(await cli("status"), /Connected context: Conversation Capture/); + }); + it("remains routable from its portable manifest when the local routing cache is empty", async () => { const { output } = await saveCapture(); const manifest = JSON.parse( @@ -303,7 +353,9 @@ describe("saving the current conversation", () => { assert.match(await cli("save-target", "Fresh Context"), /Save action: create/); await saveCapture(); - assert.match(await cli("save-target"), /Save action: create/); + // The save connected this session, so a second nameless save is Save, not + // Save As: it updates the context the conversation is now working in. + assert.match(await cli("save-target"), /Save action: update/); assert.match(await cli("save-target", "conversation capture"), /Save action: update/); assert.match(await cli("save-target", "Conversation"), /Save action: choose/); assert.match(await cli("save-target", "Conversaton Capture"), /Save action: choose/); @@ -920,6 +972,9 @@ describe("exporting a captured context", () => { it("exports the connected context when no name is given, and asks when none is", async () => { await saveCapture(); + // The save connected this session; export has to be asked with nothing + // connected for the question below to be the one under test. + await cli("disconnect"); const destination = path.join(home, "exports-connected"); assert.match(await cli("export", "--to", destination), /Which context should I export\?/); @@ -1003,7 +1058,8 @@ describe("the Claude-facing save workflow", () => { assert.match(saveCommand, /save --from .* --yes/); assert.match(saveCommand, /linked knowledge folder is\s+read-only/); assert.match(saveCommand, /removes the scratch JSON only after a successful create/); - assert.match(saveCommand, /Do not connect a new or named context\s+automatically/); + assert.match(saveCommand, /never connect a context yourself/); + assert.match(saveCommand, /A session that already had one keeps it/); assert.match(createCommand, /fresh context/); assert.match(createCommand, /\/neatcontext:save/); assert.match(importCommand, /source bundle is read-only/); diff --git a/tests/copilot-plugin.test.mjs b/tests/copilot-plugin.test.mjs index 5dff41c..e7e7734 100644 --- a/tests/copilot-plugin.test.mjs +++ b/tests/copilot-plugin.test.mjs @@ -474,6 +474,84 @@ test("Copilot CLI serves local Contexts", async (t) => { }); +// Saving is where a workspace with nothing connected gets its context, and +// where a workspace that has one must not be moved off it. +test("Copilot save connects an unconnected workspace and leaves a connected one", async (t) => { + const home = await isolatedHome("neatcontext-copilot-save-"); + t.after(() => rm(home.directory, { recursive: true, force: true })); + const env = { ...home.env, NEATCONTEXT_SESSION_ID: "copilot-save" }; + + const capture = (name, overrides = {}) => ({ + schema: 1, + name, + profile: + `# ${name}\n\n## Purpose\nPreserve the Copilot work.\n\n` + + "## What to do\nUse the recorded decisions.\n\n" + + "## What to avoid\nDo not invent state.\n\n" + + "## Behavior\nSeparate verified facts from open work.", + routingDescription: `Questions about ${name}`, + knowledge: [{ path: "session-summary.md", content: `# Session summary\n\n${name} is saved.` }], + ...overrides + }); + + const write = async (file, value) => { + const target = path.join(home.directory, file); + await writeFile(target, JSON.stringify(value), "utf8"); + return target; + }; + + const first = await runNode( + cli, + ["save", "--from", await write("first.json", capture("Copilot Capture")), "--consume"], + { env } + ); + assert.match(first.stdout, /Connected context: Copilot Capture/); + assert.match( + (await runNode(cli, ["status"], { env })).stdout, + /Connected context: Copilot Capture/ + ); + + // Save As, from a workspace that is already grounded. + const second = await runNode( + cli, + ["save", "--from", await write("second.json", capture("Copilot Second")), "--consume"], + { env } + ); + assert.match(second.stdout, /Use command: \/neatcontext:use Copilot Second/); + assert.match(second.stdout, /stays connected to "Copilot Capture"/); + assert.match( + (await runNode(cli, ["status"], { env })).stdout, + /Connected context: Copilot Capture/ + ); + + // An update of the connected context leaves the connection exactly as it was. + const target = await runNode(cli, ["save-target", "Copilot Capture"], { env }); + const field = (label) => new RegExp(`^${label}: (.+)$`, "m").exec(target.stdout)?.[1].trim(); + const updated = await runNode( + cli, + [ + "save", + "--from", + await write( + "update.json", + capture("Copilot Capture", { + targetId: field("Context id"), + baseHash: field("Base hash"), + knowledge: [ + { path: "session-summary.md", content: "# Session summary\n\nThe fix is verified." } + ] + }) + ), + "--yes", + "--consume" + ], + { env } + ); + assert.match(updated.stdout, /Updated context: Copilot Capture/); + assert.match(updated.stdout, /Use command: \/neatcontext:use Copilot Capture/); + assert.doesNotMatch(updated.stdout, /stays connected to/); +}); + test("Copilot sessions scope to the workspace when no session id is provided", async (t) => { const home = await isolatedHome("neatcontext-copilot-ws-"); const workspaceA = await mkdtemp(path.join(os.tmpdir(), "copilot-ws-a-")); diff --git a/tests/kimi-plugin.test.mjs b/tests/kimi-plugin.test.mjs index b4680a5..732e0f6 100644 --- a/tests/kimi-plugin.test.mjs +++ b/tests/kimi-plugin.test.mjs @@ -354,6 +354,86 @@ test("Kimi CLI requires a safe session id and isolates routing and selection", a assert.deepEqual(sessionFiles, ["kimi-session-a.json"]); }); +// Saving is where a session with nothing connected gets its context, and where +// a session that has one must not be moved off it. +test("Kimi save connects an unconnected session and leaves a connected one", async (t) => { + const home = await localHome("neatcontext-kimi-save-"); + t.after(() => rm(home.directory, { recursive: true, force: true })); + const env = home.env; + const session = ["--session-id", "kimi-save"]; + + const capture = (name, overrides = {}) => ({ + schema: 1, + name, + profile: + `# ${name}\n\n## Purpose\nPreserve the payment work.\n\n` + + "## What to do\nUse the recorded decisions.\n\n" + + "## What to avoid\nDo not invent state.\n\n" + + "## Behavior\nSeparate verified facts from open work.", + routingDescription: `Questions about ${name}`, + knowledge: [{ path: "session-summary.md", content: `# Session summary\n\n${name} is saved.` }], + ...overrides + }); + + const write = async (file, value) => { + const target = path.join(home.directory, file); + await writeFile(target, JSON.stringify(value), "utf8"); + return target; + }; + + const first = await runNode( + cli, + [...session, "save", "--from", await write("first.json", capture("Kimi Capture")), "--consume"], + { env } + ); + assert.match(first.stdout, /Connected context: Kimi Capture/); + assert.match( + (await runNode(cli, [...session, "status"], { env })).stdout, + /Connected context: Kimi Capture/ + ); + + // Save As, from a session that is already grounded. + const second = await runNode( + cli, + [...session, "save", "--from", await write("second.json", capture("Kimi Second")), "--consume"], + { env } + ); + assert.match(second.stdout, /Use command: \/neatcontext:use Kimi Second/); + assert.match(second.stdout, /stays connected to "Kimi Capture"/); + assert.match( + (await runNode(cli, [...session, "status"], { env })).stdout, + /Connected context: Kimi Capture/ + ); + + // An update of the connected context leaves the connection exactly as it was. + const target = await runNode(cli, [...session, "save-target", "Kimi Capture"], { env }); + const field = (label) => new RegExp(`^${label}: (.+)$`, "m").exec(target.stdout)?.[1].trim(); + const updated = await runNode( + cli, + [ + ...session, + "save", + "--from", + await write( + "update.json", + capture("Kimi Capture", { + targetId: field("Context id"), + baseHash: field("Base hash"), + knowledge: [ + { path: "session-summary.md", content: "# Session summary\n\nThe fix is verified." } + ] + }) + ), + "--yes", + "--consume" + ], + { env } + ); + assert.match(updated.stdout, /Updated context: Kimi Capture/); + assert.match(updated.stdout, /Use command: \/neatcontext:use Kimi Capture/); + assert.doesNotMatch(updated.stdout, /stays connected to/); +}); + test("Kimi MCP bridge exposes nothing session-dependent until binding", async (t) => { const home = await localHome("neatcontext-kimi-mcp-"); const sessions = [];