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
39 changes: 37 additions & 2 deletions codex-marketplace/plugins/neatcontext/src/codex/mcp-bridge.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,36 @@ const GET_CONTEXT_TOOL = {
// there is nothing to connect.
const NOTHING_CONNECTED_HEAD = "No NeatContext Context is selected for this thread.";

// The manual-mode version, and the fallback whenever no menu follows. Routing
// is off here, so a command the user types is genuinely the only way forward.
const NOTHING_CONNECTED =
`${NOTHING_CONNECTED_HEAD} Continue normal work without NeatContext grounding. Do not retry ` +
"get_context until use_context succeeds or the user explicitly asks to refresh NeatContext " +
"state. Connect one with `$neatcontext:use`.";

// What to say instead when routing is on and there are Contexts to route to.
//
// Leading with `$neatcontext:use` in that situation is what made routing look
// broken: this text is the first and most imperative thing the model reads, and
// it answered "what now?" with a command for the user to type before the menu
// below ever got a turn. The no-polling rule these carry is the same one the
// original text established — a Context that does not exist is not worth asking
// about twice — and selecting one the menu already names is not polling.
const NOTHING_CONNECTED_ROUTABLE =
`${NOTHING_CONNECTED_HEAD} There are Contexts on this machine, listed below with what each ` +
"one is for. When one of them covers what the user asked, select it with use_context and " +
"then call get_context once — do not ask the user to run a command to select a Context you " +
"can already name. When none of them covers it, continue normal work without NeatContext " +
"grounding and do not retry get_context until use_context succeeds or the user explicitly " +
"asks to refresh NeatContext state.";

const NOTHING_CONNECTED_ASK =
`${NOTHING_CONNECTED_HEAD} There are Contexts on this machine, listed below with what each ` +
"one is for. Routing is in ask mode, so name the one that covers what the user asked and ask " +
"whether to select it rather than selecting first. When none of them covers it, continue " +
"normal work without NeatContext grounding and do not retry get_context until use_context " +
"succeeds or the user explicitly asks to refresh NeatContext state.";

const NOTHING_EXISTS =
`${NOTHING_CONNECTED_HEAD} There are none on this machine yet. Continue normal work without ` +
"NeatContext grounding and do not retry get_context. Save the work in this conversation as " +
Expand All @@ -97,7 +122,7 @@ const CONNECTION_RULE = `## Connecting a context, in Codex

Contexts are connected from this session and nowhere else: the \`use_context\` tool, or \`$neatcontext:use <name>\` run by the user. \`$neatcontext:disconnect\` disconnects the current one from this session. New ones are made from here too: \`$neatcontext:save\` turns the work in this conversation into one, and \`$neatcontext:create\` builds one around a folder of documents the user already has.

There is no Desktop connection right now. Contexts are stored by this plugin. When the connected context is the wrong one, or none is connected, name the one you need and offer to switch to it here.`;
There is no Desktop connection right now. Contexts are stored by this plugin. When the connected context is the wrong one, or none is connected, name the one you need and select it here with \`use_context\` — or offer to, when the routing rules above say to ask first.`;

// The two tools that let a session change what it is grounded in. They are the
// plugin's whole routing mechanism: there is no model in any process here, so
Expand Down Expand Up @@ -202,7 +227,17 @@ async function listAllContexts() {
// they have none.
async function nothingConnectedText() {
const { contexts } = await listAllContexts().catch(() => ({ contexts: [] }));
return contexts.length === 0 ? NOTHING_EXISTS : NOTHING_CONNECTED;
if (contexts.length === 0) {
return NOTHING_EXISTS;
}
// The mode decides whether a menu is about to follow this text, and therefore
// whether pointing at a command is the honest answer or the one that breaks
// routing.
const mode = resolveMode(await readRouting().catch(() => ({ sessions: {} })), sessionId());
if (mode === "manual") {
return NOTHING_CONNECTED;
}
return mode === "ask" ? NOTHING_CONNECTED_ASK : NOTHING_CONNECTED_ROUTABLE;
}

// The selected context, or null when nothing is selected. A selection
Expand Down
87 changes: 74 additions & 13 deletions codex-marketplace/plugins/neatcontext/src/core/routing.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ export const MODES = ["auto", "ask", "manual"];
// Asking every time then costs a question per turn and buys nothing.
export const DEFAULT_MODE = "auto";

const SCHEMA = 1;
// 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;
const MAX_ALIASES = 12;
const MAX_DECISIONS = 100;
Expand Down Expand Up @@ -137,25 +139,57 @@ export async function readRouting() {
return {
schema: SCHEMA,
declines,
mode: MODES.includes(parsed?.mode) ? parsed.mode : DEFAULT_MODE,
mode: chosenMode(parsed),
cards,
sessions: typeof parsed?.sessions === "object" && parsed.sessions !== null ? parsed.sessions : {},
decisions: Array.isArray(parsed?.decisions) ? parsed.decisions : []
};
}

// The mode the user actually chose, or null when nobody has.
//
// Null is not a synonym for the default, and the difference is the whole point.
// Until schema 2 the *resolved* mode was written back on every routing write —
// deriving a card, logging a decision, noting a refusal — so a file ended up
// stating whatever the default happened to be in the build that last touched
// it. "ask" was that default until the shortlist learned to ask on its own, so
// every machine that had ever saved a context had "ask" written down, and
// changing the default to "auto" reached none of them. Routing looked switched
// off on exactly the machines that used it most, and reinstalling did not help:
// this file lives in ~/.neatcontext and outlives any one plugin install.
//
// So an "ask" in a pre-schema-2 file is not evidence of a choice, and is
// dropped. A machine where it genuinely was one loses it once, and a single
// `/neatcontext:mode ask --global` puts it back — written under schema 2 this
// time, where a stored mode means somebody asked for it. "manual" is never a
// default, so it was always deliberate and always stands.
function chosenMode(parsed) {
if (!MODES.includes(parsed?.mode)) {
return null;
}
if (parsed.schema !== SCHEMA && parsed.mode === "ask") {
return null;
}
return parsed.mode;
}

async function writeRouting(state) {
// Sessions accumulate forever otherwise — one per host window, ever.
const sessions = Object.entries(state.sessions)
.sort((a, b) => (b[1]?.updatedAt ?? "").localeCompare(a[1]?.updatedAt ?? ""))
.slice(0, MAX_SESSIONS);
const file = routingFilePath();
await mkdir(path.dirname(file), { recursive: true });
const { mode, ...rest } = state;
await writeFile(
file,
`${JSON.stringify(
{
...state,
...rest,
// Written down only when somebody chose it. An unchosen mode stays out
// of the file entirely, so this machine keeps following the default
// rather than pinning whichever one this build happens to ship.
...(MODES.includes(mode) ? { mode } : {}),
sessions: Object.fromEntries(sessions),
declines: pruneDeclines(state.declines, Date.now()),
decisions: state.decisions.slice(-MAX_DECISIONS)
Expand Down Expand Up @@ -232,7 +266,12 @@ export function isCardStale(card, source) {
// auto, another window writing code wants to be left alone.
export function resolveMode(state, id) {
const session = id ? state.sessions[id] : null;
return MODES.includes(session?.mode) ? session.mode : state.mode;
if (MODES.includes(session?.mode)) {
return session.mode;
}
// `state.mode` is null on a machine where nobody has set one, which is what
// lets the default below actually apply.
return MODES.includes(state.mode) ? state.mode : DEFAULT_MODE;
}

export function setMode(mode, { global: isGlobal = false, id = sessionId() } = {}) {
Expand Down Expand Up @@ -422,23 +461,43 @@ export function renderMenu(entries, { connectedId, mode } = {}) {
lines.push(`- **${entry.name}**${marker} — ${describe(entry)}`);
}
lines.push("");
lines.push(...routingInstructions(mode));
lines.push(...routingInstructions(mode, Boolean(connectedId)));
return lines.join("\n");
}

// Shared with the shortlist below, because a shortlist is still a menu: the
// same model still decides, still asks first in ask mode, and still must not
// route on a follow-up. Only the number of things it chooses between differs.
function routingInstructions(mode) {
//
// Split on whether anything is connected, because the two situations are not
// the same move. Switching means leaving somewhere, and every guard here —
// "clearly belongs", "not on a follow-up", "stands on its own" — exists to make
// leaving cost something. A session grounded in nothing has nowhere to leave
// from: the same guards read as reasons to do nothing at all, which is how a
// question that plainly belonged to a saved context ended up answered from
// general knowledge with a slash command offered as consolation.
function routingInstructions(mode, connected) {
return [
mode === "auto"
? "Routing is on (auto). When the user's request clearly belongs to one of the other contexts above, switch to it with the `use_context` tool, then call `get_context` and answer from what it returns. Say in one line that you switched, and which context you are now on. When two contexts are both plausible, do not guess — name them and ask which one."
: "Routing is on (ask). When the user's request clearly belongs to one of the other contexts above, say so and ask before switching — never switch first. If they agree, call `use_context`, then `get_context`, and answer from what it returns.",
"Do not route on follow-ups, short replies, or anything that continues the current topic — a switch needs a request that stands on its own and plainly belongs elsewhere. If the user declines a switch, drop it and do not raise that context again this session.",
connected ? switchInstruction(mode) : connectInstruction(mode),
connected
? "Do not route on follow-ups, short replies, or anything that continues the current topic — a switch needs a request that stands on its own and plainly belongs elsewhere. If the user declines a switch, drop it and do not raise that context again this session."
: "There is no current topic to continue and nothing to leave, so connecting the context a request belongs to is the expected move rather than an interruption. If the user declines one, drop it and do not raise that context again this session.",
"When the user corrects a wrong route, pass what they called it as `alias` to `use_context` so the same words route correctly next time."
];
}

function switchInstruction(mode) {
return mode === "auto"
? "Routing is on (auto). When the user's request clearly belongs to one of the other contexts above, switch to it with the `use_context` tool, then call `get_context` and answer from what it returns. Say in one line that you switched, and which context you are now on. When two contexts are both plausible, do not guess — name them and ask which one."
: "Routing is on (ask). When the user's request clearly belongs to one of the other contexts above, say so and ask before switching — never switch first. If they agree, call `use_context`, then `get_context`, and answer from what it returns.";
}

function connectInstruction(mode) {
return mode === "auto"
? "Routing is on (auto), and this session is grounded in nothing yet. When the user's request belongs to one of the contexts above, connect it with the `use_context` tool, then call `get_context` and answer from what it returns. Do that yourself — do not ask the user to run a command to connect a context you can already name. Say in one line which context you connected. When two contexts are both plausible, do not guess — name them and ask which one."
: "Routing is on (ask), and this session is grounded in nothing yet. When the user's request belongs to one of the contexts above, name it and ask whether to connect it — never connect first. If they agree, call `use_context`, then `get_context`, and answer from what it returns.";
}

// The same menu, cut down to what the request actually reached.
//
// Two things change against the full list. It is short, so each entry can
Expand All @@ -461,13 +520,15 @@ export function renderShortlist(entries, { connectedId, mode, decision } = {}) {
}
lines.push("");
lines.push(
"These are the contexts on this machine whose own description matched the request, best first. Others exist and did not match — that is a reason to stay where you are, not to reach for the closest one here."
connectedId
? "These are the contexts on this machine whose own description matched the request, best first. Others exist and did not match — that is a reason to stay where you are, not to reach for the closest one here."
: "These are the contexts on this machine whose own description matched the request, best first. Others exist and did not match — so if none of these covers the request, say the store does not have it rather than reaching for the closest one here."
);
const tie = tieNote(decision);
if (tie) {
lines.push(tie);
}
lines.push(...routingInstructions(mode));
lines.push(...routingInstructions(mode, Boolean(connectedId)));
return lines.join("\n");
}

Expand All @@ -486,7 +547,7 @@ function tieNote(decision) {
return (
`${names} match the request about equally well, so which one is right is not something to ` +
"decide on the user's behalf. Name them, say in one line what each covers, and ask which — " +
"in auto mode too. Switch only once they have answered."
"in auto mode too. Call `use_context` only once they have answered."
);
}

Expand Down
64 changes: 64 additions & 0 deletions codex-marketplace/tests/codex-plugin.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -451,3 +451,67 @@ test("Codex narrows the routing menu to the request", async () => {
await session.close();
}
});

// A thread with nothing selected is the case routing exists for. Leading with
// `$neatcontext:use` there is what made routing look broken: it is the first
// thing the model reads and it answers "what now?" before the menu below it
// gets a turn. The no-polling rule has to survive the rewording.
test("Codex tells an unselected thread to select a Context itself", async () => {
const home = await mkdtemp(path.join(os.tmpdir(), "neatcontext-codex-unselected-"));
const env = { NEATCONTEXT_HOME: home, CODEX_THREAD_ID: "unselected-thread" };
const capturePath = path.join(home, "refunds.json");
await writeFile(
capturePath,
JSON.stringify({
schema: 1,
name: "Refunds",
profile:
"# Refunds\n\n## Purpose\nRefunds and chargebacks.\n\n## What to do\nAnswer.\n\n" +
"## What to avoid\nGuessing.\n\n## Behavior\nBe concise.",
routingDescription: "refunds and chargebacks",
knowledge: [{ path: "session-summary.md", content: "# Refunds\n\nrefunds" }]
}),
"utf8"
);
assert.equal((await runNode(cli, ["save", "--from", capturePath, "--consume"], { env })).code, 0);
// Saving connects an unconnected session, so step back off it to reach the
// state this test is about.
assert.equal((await runNode(cli, ["disconnect"], { env })).code, 0);

const session = rpcSession(env);
try {
await session.call({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "t", version: "1" } }
});
const routable = (
await session.call({
jsonrpc: "2.0",
id: 2,
method: "tools/call",
params: { name: "get_context", arguments: { query: "refunds" } }
})
).result.content[0].text;
assert.match(routable, /select it with use_context/);
assert.match(routable, /do not ask the user to run a command/);
assert.match(routable, /do not retry get_context/, "the no-polling rule must survive");

// Manual mode publishes no menu, so there is nothing to select from and the
// command really is the only way forward.
assert.equal((await runNode(cli, ["mode", "manual"], { env })).code, 0);
const manual = (
await session.call({
jsonrpc: "2.0",
id: 3,
method: "tools/call",
params: { name: "get_context", arguments: { query: "refunds" } }
})
).result.content[0].text;
assert.match(manual, /Connect one with `\$neatcontext:use`/);
assert.doesNotMatch(manual, /## Contexts/);
} finally {
await session.close();
}
});
Loading