Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 11 additions & 4 deletions codex-marketplace/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions codex-marketplace/plugins/neatcontext/skills/save/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -106,4 +106,4 @@ Relay the preview and wait for confirmation. After confirmation, run:
node "<plugin-root>/src/codex/neatcontext-cli.mjs" save --from "<capture-path>" --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.
Original file line number Diff line number Diff line change
Expand Up @@ -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 " +
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}

Expand All @@ -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);
Expand Down
25 changes: 24 additions & 1 deletion codex-marketplace/plugins/neatcontext/src/core/selection.mjs
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down Expand Up @@ -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 };
41 changes: 39 additions & 2 deletions codex-marketplace/tests/codex-plugin.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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/);

Expand Down Expand Up @@ -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",
Expand Down
17 changes: 11 additions & 6 deletions plugins/claude-code/neatcontext/commands/save.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
34 changes: 31 additions & 3 deletions plugins/claude-code/neatcontext/src/claude/neatcontext-cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}

Expand All @@ -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);
Expand Down
25 changes: 24 additions & 1 deletion plugins/claude-code/neatcontext/src/core/selection.mjs
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down Expand Up @@ -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 };
17 changes: 11 additions & 6 deletions plugins/copilot/neatcontext/commands/save.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Loading