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
24 changes: 19 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ The plugin is dependency-free. Before opening a PR, sanity-check the scripts:
npm run check # node --check on each host helper script
npm run validate:plugin # Claude Code marketplace validation, warnings included
npm test # local storage and host integration tests
npm run coverage # every changed Claude-plugin source line must run in a test
npm run coverage # every changed host source line must run in a test
```

CI (`.github/workflows/ci.yml`) runs `npm run check` and `npm test` on every
Expand All @@ -37,10 +37,24 @@ single required check is `ci`, which passes only when every CI job did.
## Diff coverage

`npm run coverage` runs the suite and fails if any line the branch adds or
changes under the Claude plugin's `src/` directory was never executed.
Whole-file coverage is not the bar — much of this code predates the tests — but
new code has to arrive with a test that runs it. Other isolated host packages
have their own integration tests in the repository suite.
changes in a host's shipped source was never executed. Whole-file coverage is
not the bar — much of this code predates the tests — but new code has to arrive
with a test that runs it.

Every host is gated, not just Claude Code: `src/claude`, `src/copilot`,
`src/kimi`, `src/codex`, and pi's `src/pi` and `extensions/`. A change applied
to five bridges at once has to be checked on five bridges.

The one exclusion is the Context core copied into each plugin's `src/core/`.
Those copies are generated from `shared/core` and proven byte-identical twice
over — `npm run sync:context -- --check` fails when one drifts, and the host
tests assert equality against Claude Code's. Claude's copy is gated and is the
one the unit tests import, so requiring the same line to run five times would
prove nothing that equality has not already proven.

A test that spawns a host process must let it exit rather than kill it, or the
child never flushes its coverage profile and everything it ran reads as
untested. Use `closeSession` from `tests/process-helpers.mjs`.

Almost everything here is exercised the way the coding hosts exercise it: the
MCP bridges and CLIs are spawned as child processes, which `node --test
Expand Down
81 changes: 70 additions & 11 deletions codex-marketplace/plugins/neatcontext/src/codex/mcp-bridge.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,12 @@ import {
noteDeclined,
readRouting,
renderMenu,
renderShortlist,
resolveMode,
sessionId,
switchPolicy
} from "../core/routing.mjs";
import { assess, createRoutingIndex } from "../core/routing-candidates.mjs";
import { applySelection, resolveContext } from "../core/selection.mjs";

const SERVER_INFO = { name: "neatcontext", version: "0.3.2" };
Expand All @@ -47,7 +49,19 @@ const GET_CONTEXT_TOOL = {
"Load the domain profile and local knowledge pointers for the NeatContext Context " +
"already selected for this thread. Do not call merely to discover whether a Context " +
"is selected.",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description:
"What the user is actually asking about, in their own words. Pass it whenever there " +
"is one: it decides which of the contexts on this machine are worth showing you, " +
"instead of listing all of them. Leave it out and you get the full list."
}
},
additionalProperties: false
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
Expand Down Expand Up @@ -289,17 +303,60 @@ async function contextResponse(message, context) {

// --- Routing: the session picks its own context ------------------------------

// What the model needs to route: every context that exists, one line each on
// what it is for, and the rules for acting on that. Rebuilt on demand rather
// What the model needs to route: the contexts worth considering, one line
// each on what they are for, and the rules for acting on that. Rebuilt on demand rather
// than cached, so `$neatcontext:mode` and a context created mid-session both
// take effect on the next call instead of on the next restart.
async function routingMenu() {
// With a request to match against, the menu is the few contexts that matched
// it; without one it is everything, alphabetically, as it has always been.
const SHORTLIST_LIMIT = 5;
const SHORTLIST_MIN_CONTEXTS = 8;

// One index for this process, which outlives the session it was spawned in.
// That is the point: it is rebuilt when the contexts change, not per question.
const rankContexts = createRoutingIndex({
listFiles: async (record) =>
(await listKnowledgeFiles(record.knowledgeFolder, { limit: 60 })).files
});

async function routingMenu(query) {
const [{ contexts }, state] = await Promise.all([listAllContexts(), readRouting()]);
const selection = await readSelection().catch(() => null);
return renderMenu(menuEntries(contexts, state), {
const options = {
connectedId: selection?.contextId ?? null,
mode: resolveMode(state, sessionId())
});
};
const entries = menuEntries(contexts, state);
const shortlist = await shortlistFor(contexts, state, entries, query);
return shortlist
? renderShortlist(shortlist, { ...options, decision: assess(shortlist) })
: renderMenu(entries, options);
}

// A shortlist needs three things: a request to match against, enough contexts
// that narrowing gains anything, and at least one that actually matched. Any of
// them missing and the full menu goes out instead — a session is never left
// with less to work with than it has today.
async function shortlistFor(contexts, state, entries, query) {
if (
typeof query !== "string" ||
query.trim().length === 0 ||
entries.length < SHORTLIST_MIN_CONTEXTS
) {
return null;
}
const ranked = await rankContexts(contexts, state, query, { limit: SHORTLIST_LIMIT });
if (ranked.length === 0) {
return null;
}
const byId = new Map(entries.map((entry) => [entry.id, entry]));
// The score travels with the entry because how far ahead the leader is
// decides whether the shortlist names a winner or asks a question.
return ranked.map((result) => ({
...byId.get(result.id),
matched: result.matched,
score: result.score
}));
}

function toolText(id, text, isError = false) {
Expand Down Expand Up @@ -492,13 +549,13 @@ async function handleMessage(message) {
//
// The connection rule goes last, so it is the closest thing to the answer the
// session is about to write — and it is the one part that is never omitted.
async function pluginNotes() {
const menu = await routingMenu();
async function pluginNotes(query) {
const menu = await routingMenu(query);
return menu ? `${menu}\n\n${CONNECTION_RULE}` : CONNECTION_RULE;
}

async function withNotes(response, place) {
const notes = await pluginNotes();
async function withNotes(response, place, query) {
const notes = await pluginNotes(query);
if (place === "instructions") {
const existing = response.result.instructions;
return {
Expand Down Expand Up @@ -530,7 +587,9 @@ async function shapeResponse(message, response) {
return await withRoutingTools(response);
}
if (message.method === "tools/call" && message.params?.name === GET_CONTEXT_TOOL.name) {
return withNotes(response, "content");
// The handshake has no request to match against, so only this path can
// narrow the menu — which is also the path that is re-read every turn.
return withNotes(response, "content", message.params?.arguments?.query);
}
return response;
}
Expand Down
85 changes: 81 additions & 4 deletions codex-marketplace/tests/codex-plugin.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";

import { closeSession } from "../../tests/process-helpers.mjs";

const here = path.dirname(fileURLToPath(import.meta.url));
const marketplaceRoot = path.resolve(here, "..");
const repositoryRoot = path.resolve(marketplaceRoot, "..");
Expand Down Expand Up @@ -70,9 +72,10 @@ function rpcSession(env) {
}
return {
call,
// Ends stdin and waits, rather than killing: a killed child never flushes
// its V8 coverage profile, so everything it ran reads as untested.
close() {
child.stdin.end();
child.kill();
return closeSession(child);
}
};
}
Expand Down Expand Up @@ -263,7 +266,7 @@ test("MCP bridge does not advertise get_context for an empty installation", asyn
assert.match(staleCall.result.content[0].text, /Continue normal work/);
assert.match(staleCall.result.content[0].text, /do not retry/i);
} finally {
rpc.close();
await rpc.close();
}
});

Expand Down Expand Up @@ -334,6 +337,80 @@ test("selected contexts advertise one-shot grounding guidance", async () => {
assert.match(getContext.description, /already selected for this thread/);
assert.match(getContext.description, /Do not call merely/);
} finally {
rpc.close();
await rpc.close();
}
});

test("Codex narrows the routing menu to the request", async () => {
const home = await mkdtemp(path.join(os.tmpdir(), "neatcontext-codex-shortlist-"));
const env = { NEATCONTEXT_HOME: home, CODEX_THREAD_ID: "shortlist-thread" };
const corpus = [
["INC-1001 checkout", "checkout-api 5xx from pgbouncer pool exhaustion"],
["Queue lag", "order-events partition lag and consumer rebalancing"],
["Codex design", "Codex CLI plugin design and marketplace packaging"],
["Kimi plugin", "Kimi Code manifests, skills and commands"],
["Evidence", "conversation evidence and transcript adapters"],
["Refunds", "refunds and chargebacks"],
["Docker container", "Ubuntu container with SSH"],
["Marketplace config", "switching the marketplace source"],
["Session drift", "bridge session and thread drift"]
];
for (const [name, routingDescription] of corpus) {
const capturePath = path.join(home, `${name.replace(/\W+/g, "-")}.json`);
await writeFile(
capturePath,
JSON.stringify({
schema: 1,
name,
profile: `# ${name}\n\n## Purpose\n${routingDescription}\n\n## What to do\nAnswer.\n\n## What to avoid\nGuessing.\n\n## Behavior\nBe concise.`,
routingDescription,
knowledge: [{ path: "session-summary.md", content: `# ${name}\n\n${routingDescription}` }]
}),
"utf8"
);
assert.equal((await runNode(cli, ["save", "--from", capturePath, "--consume"], { env })).code, 0);
}
assert.equal((await runNode(cli, ["use", "Refunds"], { 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 matched = await session.call({
jsonrpc: "2.0",
id: 2,
method: "tools/call",
params: { name: "get_context", arguments: { query: "why is checkout throwing 5xx" } }
});
const narrowed = matched.result.content[0].text;
assert.match(narrowed, /## Contexts that match what the user just asked/);
assert.match(narrowed, /INC-1001 checkout/);
assert.ok(!narrowed.includes("Docker container"));

const everything = await session.call({
jsonrpc: "2.0",
id: 3,
method: "tools/call",
params: { name: "get_context", arguments: {} }
});
assert.match(everything.result.content[0].text, /## Contexts available on this machine/);
assert.match(everything.result.content[0].text, /Docker container/);

// A request that reaches nothing must not hide the store behind an empty
// shortlist — the full menu is the safe answer.
const unmatched = await session.call({
jsonrpc: "2.0",
id: 4,
method: "tools/call",
params: { name: "get_context", arguments: { query: "what is the capital of France" } }
});
assert.match(unmatched.result.content[0].text, /## Contexts available on this machine/);
assert.match(unmatched.result.content[0].text, /Docker container/);
} finally {
await session.close();
}
});
Loading