diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4e675ea..6a69418 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 @@ -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 diff --git a/codex-marketplace/plugins/neatcontext/src/codex/mcp-bridge.mjs b/codex-marketplace/plugins/neatcontext/src/codex/mcp-bridge.mjs index 09c9125..ee5768b 100644 --- a/codex-marketplace/plugins/neatcontext/src/codex/mcp-bridge.mjs +++ b/codex-marketplace/plugins/neatcontext/src/codex/mcp-bridge.mjs @@ -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" }; @@ -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, @@ -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) { @@ -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 { @@ -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; } diff --git a/codex-marketplace/tests/codex-plugin.test.mjs b/codex-marketplace/tests/codex-plugin.test.mjs index 12e1cae..bfcea56 100644 --- a/codex-marketplace/tests/codex-plugin.test.mjs +++ b/codex-marketplace/tests/codex-plugin.test.mjs @@ -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, ".."); @@ -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); } }; } @@ -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(); } }); @@ -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(); } }); diff --git a/plugins/copilot/neatcontext/src/copilot/mcp-bridge.mjs b/plugins/copilot/neatcontext/src/copilot/mcp-bridge.mjs index 5f49c81..5dbfcb3 100644 --- a/plugins/copilot/neatcontext/src/copilot/mcp-bridge.mjs +++ b/plugins/copilot/neatcontext/src/copilot/mcp-bridge.mjs @@ -28,10 +28,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" }; @@ -43,7 +45,19 @@ const GET_CONTEXT_TOOL = { "knowledge folders to search. Call this before answering anything that depends on the " + "user's own domain, documents, tools, or team conventions — some hosts do not surface " + "this server's initialize instructions, so the tool description is what carries that rule.", - 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 + } }; // What to say when a session has nothing to ground in. It is deliberately about // what to do *here*: every route is a command in this session, and no other @@ -282,17 +296,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) { @@ -470,13 +527,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 { @@ -508,7 +565,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; } diff --git a/plugins/kimi-code/neatcontext/src/kimi/mcp-bridge.mjs b/plugins/kimi-code/neatcontext/src/kimi/mcp-bridge.mjs index ece8c16..4af5671 100644 --- a/plugins/kimi-code/neatcontext/src/kimi/mcp-bridge.mjs +++ b/plugins/kimi-code/neatcontext/src/kimi/mcp-bridge.mjs @@ -28,10 +28,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" }; @@ -61,7 +63,19 @@ const GET_CONTEXT_TOOL = { "knowledge folders to search. Call this before answering anything that depends on the " + "user's own domain, documents, tools, or team conventions — some hosts do not surface " + "this server's initialize instructions, so the tool description is what carries that rule.", - 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 + } }; // What to say when a session has nothing to ground in. It is deliberately about // what to do *here*: every route is a command in this session, and no other @@ -339,17 +353,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) { @@ -571,13 +628,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 { @@ -609,7 +666,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; } diff --git a/plugins/pi/neatcontext/extensions/neatcontext.js b/plugins/pi/neatcontext/extensions/neatcontext.js index 08a2e04..23faeee 100644 --- a/plugins/pi/neatcontext/extensions/neatcontext.js +++ b/plugins/pi/neatcontext/extensions/neatcontext.js @@ -43,6 +43,20 @@ import { const EMPTY_SCHEMA = { type: "object", properties: {}, additionalProperties: false }; +const GET_CONTEXT_SCHEMA = { + 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 +}; + function text(value) { return { content: [{ type: "text", text: value }], details: undefined }; } @@ -142,10 +156,10 @@ export default function (pi) { promptSnippet: "get_context: the user's own domain knowledge — call before answering anything that " + "depends on their systems, documents, or team conventions.", - parameters: EMPTY_SCHEMA, - async execute(_id, _params, _signal, _onUpdate, ctx) { + parameters: GET_CONTEXT_SCHEMA, + async execute(_id, params, _signal, _onUpdate, ctx) { bindFrom(ctx); - return text(await getContext()); + return text(await getContext(params?.query)); } }); diff --git a/plugins/pi/neatcontext/src/pi/runtime.mjs b/plugins/pi/neatcontext/src/pi/runtime.mjs index 30c0cf6..657e6ec 100644 --- a/plugins/pi/neatcontext/src/pi/runtime.mjs +++ b/plugins/pi/neatcontext/src/pi/runtime.mjs @@ -56,11 +56,13 @@ import { putCard, readRouting, renderMenu, + renderShortlist, resolveMode, sessionId, setMode, switchPolicy } from "../core/routing.mjs"; +import { assess, createRoutingIndex } from "../core/routing-candidates.mjs"; import { applySelection, disconnectSelection, @@ -116,24 +118,72 @@ export async function activeContext() { // --- the notes the plugin adds to every turn --------------------------------- -// 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 -// than cached, so `/neatcontext-mode` and a context created mid-session both -// take effect on the next turn instead of on the next restart. -async function routingMenu() { +// 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 turn instead of on the next restart. +// +// With a request to match against, that 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. pi runs the extension in-process, so this lives +// as long as the session does and is rebuilt when the contexts change rather +// than 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 + })); } // The routing menu when there is one, and how connecting works here. 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. -export async function pluginNotes() { - const menu = await routingMenu().catch(() => null); +// +// Called with no query from the per-turn system prompt, which has no request to +// match against, and with one from get_context. +export async function pluginNotes(query) { + const menu = await routingMenu(query).catch(() => null); return menu ? `${menu}\n\n${CONNECTION_RULE}` : CONNECTION_RULE; } @@ -255,7 +305,7 @@ export async function declareExtension({ id, capability, tools, important } = {} return added.text; } -export async function getContext() { +export async function getContext(query) { const context = await activeContext(); if (context) { const body = context.missing @@ -263,10 +313,10 @@ export async function getContext() { : await renderContext(context.record); const extensions = renderExtensions(await resolveExtensions(context)); const parts = extensions ? [body, extensions] : [body]; - return `${parts.join("\n\n")}\n\n${await pluginNotes()}`; + return `${parts.join("\n\n")}\n\n${await pluginNotes(query)}`; } await resolveExtensions(null); - return `${NOTHING_CONNECTED}\n\n${await pluginNotes()}`; + return `${NOTHING_CONNECTED}\n\n${await pluginNotes(query)}`; } // --- routing tools ------------------------------------------------------------ diff --git a/plugins/pi/neatcontext/tests/pi-extension.test.mjs b/plugins/pi/neatcontext/tests/pi-extension.test.mjs index d7a9506..5419534 100644 --- a/plugins/pi/neatcontext/tests/pi-extension.test.mjs +++ b/plugins/pi/neatcontext/tests/pi-extension.test.mjs @@ -209,3 +209,18 @@ describe("commands", () => { assert.match(api.messages[0].content, /No single context matched/); }); }); + +describe("the get_context tool", () => { + it("takes an optional request and passes it through to the notes", async () => { + // The tool is the only path a query can arrive on in pi, so its schema and + // its hand-off are worth pinning rather than inferring from the runtime. + const tool = api.tools.get("get_context"); + assert.equal(tool.parameters.properties.query.type, "string"); + + const withQuery = await tool.execute("id-1", { query: "partition lag" }, null, null, fakeCtx()); + assert.match(withQuery.content[0].text, /NeatContext/); + + const without = await tool.execute("id-2", {}, null, null, fakeCtx()); + assert.match(without.content[0].text, /NeatContext/); + }); +}); diff --git a/plugins/pi/neatcontext/tests/pi-runtime.test.mjs b/plugins/pi/neatcontext/tests/pi-runtime.test.mjs index 1a3f118..a13b773 100644 --- a/plugins/pi/neatcontext/tests/pi-runtime.test.mjs +++ b/plugins/pi/neatcontext/tests/pi-runtime.test.mjs @@ -487,3 +487,54 @@ describe("extensions", () => { ); }); }); + +describe("narrowing the menu to the request", () => { + 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"] + ]; + + async function seed() { + for (const [name, useWhen] of CORPUS) { + await runtime.createContext({ + name, + knowledgeFolder: docs, + profile: `# ${name}\n\n## Purpose\n\n${useWhen}\n`, + useWhen + }); + } + } + + it("shows only the contexts the request reached", async () => { + await seed(); + const notes = await runtime.getContext("why is checkout throwing 5xx"); + assert.match(notes, /## Contexts that match what the user just asked/); + assert.match(notes, /INC-1001 checkout/); + assert.ok(!notes.includes("Docker container")); + }); + + it("keeps the whole menu when there is no request to match", async () => { + // pi appends the notes to its system prompt every turn, where there is no + // question yet — that path must keep listing everything. + await seed(); + const notes = await runtime.getContext(); + assert.match(notes, /## Contexts available on this machine/); + assert.match(notes, /Docker container/); + assert.match(await runtime.sessionInstructions(), /## Contexts available on this machine/); + }); + + it("keeps the whole menu when nothing matched", async () => { + await seed(); + assert.match( + await runtime.getContext("what is the capital of France"), + /## Contexts available on this machine/ + ); + }); +}); diff --git a/tests/copilot-plugin.test.mjs b/tests/copilot-plugin.test.mjs index f8a0561..5dff41c 100644 --- a/tests/copilot-plugin.test.mjs +++ b/tests/copilot-plugin.test.mjs @@ -203,7 +203,11 @@ async function knowledgeFolder(home, files = { "runbook.md": "# Runbook\n" }) { return folder; } -async function createContext(home, name, { sessionId = "copilot-test" } = {}) { +async function createContext( + home, + name, + { sessionId = "copilot-test", useWhen = `Questions about ${name}` } = {} +) { const folder = await knowledgeFolder(home); const profileFile = path.join(home.directory, `${name.replace(/\W+/g, "-")}-profile.md`); await writeFile( @@ -222,7 +226,7 @@ async function createContext(home, name, { sessionId = "copilot-test" } = {}) { "--profile-from", profileFile, "--use-when", - `Questions about ${name}` + useWhen ], { env: { ...home.env, NEATCONTEXT_SESSION_ID: sessionId } } ); @@ -662,3 +666,55 @@ test("Copilot plugin registers no hooks and nothing that runs on its own", async const cliText = await readFile(cli, "utf8"); assert.doesNotMatch(cliText, /save-nudge|noteSaved/); }); + +test("Copilot narrows the routing menu to the request", async (t) => { + const home = await isolatedHome("neatcontext-copilot-shortlist-"); + const sessions = []; + t.after(async () => { + await Promise.all(sessions.map((session) => session.close())); + }); + const env = { ...home.env, NEATCONTEXT_SESSION_ID: "copilot-shortlist" }; + + 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, useWhen] of corpus) { + await createContext(home, name, { sessionId: "copilot-shortlist", useWhen }); + } + + const session = rpcSession(env); + sessions.push(session); + await session.call(initialize(1)); + + const tools = await session.call({ jsonrpc: "2.0", id: 2, method: "tools/list" }); + const getContext = tools.result.tools.find((tool) => tool.name === "get_context"); + assert.equal(getContext.inputSchema.properties.query.type, "string"); + + const matched = await session.call( + toolCall(3, "get_context", { query: "why is checkout throwing 5xx" }) + ); + assert.match(matched.result.content[0].text, /## Contexts that match what the user just asked/); + assert.match(matched.result.content[0].text, /INC-1001 checkout/); + assert.ok(!matched.result.content[0].text.includes("Docker container")); + + // No request to match against, so nothing is hidden. + const everything = await session.call(toolCall(4, "get_context")); + 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( + toolCall(5, "get_context", { 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/); +}); diff --git a/tests/diff-coverage.test.mjs b/tests/diff-coverage.test.mjs index 3be2951..f4e9091 100644 --- a/tests/diff-coverage.test.mjs +++ b/tests/diff-coverage.test.mjs @@ -24,6 +24,27 @@ describe("which files the gate polices", () => { ); }); + it("takes every host's adapter, not only Claude Code's", () => { + // A change applied to five bridges at once must be checked on five bridges. + assert.equal(isGatedFile("plugins/copilot/neatcontext/src/copilot/mcp-bridge.mjs"), true); + assert.equal(isGatedFile("plugins/kimi-code/neatcontext/src/kimi/mcp-bridge.mjs"), true); + assert.equal(isGatedFile("codex-marketplace/plugins/neatcontext/src/codex/mcp-bridge.mjs"), true); + assert.equal(isGatedFile("plugins/pi/neatcontext/src/pi/runtime.mjs"), true); + assert.equal(isGatedFile("plugins/pi/neatcontext/extensions/neatcontext.js"), true); + }); + + it("leaves out the generated copies of the Context core", () => { + // They are byte-identical to Claude Code's by two other checks, and Claude's + // copy is gated — so requiring the same line to run five times would prove + // nothing that equality has not already proven. + assert.equal(isGatedFile("plugins/copilot/neatcontext/src/core/routing.mjs"), false); + assert.equal(isGatedFile("plugins/kimi-code/neatcontext/src/core/routing.mjs"), false); + assert.equal(isGatedFile("plugins/pi/neatcontext/src/core/routing.mjs"), false); + assert.equal(isGatedFile("codex-marketplace/plugins/neatcontext/src/core/routing.mjs"), false); + // Claude's copy stays gated, and it is the one the unit tests import. + assert.equal(isGatedFile("plugins/claude-code/neatcontext/src/core/routing.mjs"), true); + }); + it("leaves out tests, docs, and the gate's own tooling", () => { // A test file runs by definition; counting it would only dilute the gate. assert.equal(isGatedFile("tests/context.test.mjs"), false); diff --git a/tests/kimi-plugin.test.mjs b/tests/kimi-plugin.test.mjs index 6653463..b4680a5 100644 --- a/tests/kimi-plugin.test.mjs +++ b/tests/kimi-plugin.test.mjs @@ -76,9 +76,9 @@ async function localHome(prefix) { return { directory, knowledge, env: { NEATCONTEXT_HOME: directory } }; } -async function createLocalContext(home, sessionId, name = "payment team") { +async function createLocalContext(home, sessionId, name = "payment team", useWhen) { const profile = path.join(home.directory, "profile.md"); - await writeFile(profile, `# ${name}\n\n## Purpose\nPayment support.\n`); + await writeFile(profile, `# ${name}\n\n## Purpose\n${useWhen ?? "Payment support."}\n`); const result = await runNode( cli, [ @@ -90,7 +90,8 @@ async function createLocalContext(home, sessionId, name = "payment team") { "--knowledge", home.knowledge, "--profile-from", - profile + profile, + ...(useWhen ? ["--use-when", useWhen] : []) ], { env: home.env } ); @@ -459,3 +460,52 @@ test("Kimi MCP bridge exposes nothing session-dependent until binding", async (t assert.doesNotMatch(ungrounded.result.content[0].text, /Connected context: payment team/); }); + +test("Kimi narrows the routing menu to the request", async (t) => { + const home = await localHome("neatcontext-kimi-shortlist-"); + const sessions = []; + t.after(async () => { + await Promise.all(sessions.map((session) => session.close())); + await rm(home.directory, { recursive: true, force: true }); + }); + + 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 manifests", "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, useWhen] of corpus) { + await createLocalContext(home, "kimi-shortlist", name, useWhen); + } + + const session = rpcSession(home.env); + sessions.push(session); + await session.call(initialize(1)); + await session.call(toolCall(2, "bind_session", { session_id: "kimi-shortlist" })); + + const matched = await session.call( + toolCall(3, "get_context", { 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(toolCall(4, "get_context")); + 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( + toolCall(5, "get_context", { 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/); +}); diff --git a/tools/diff-coverage.mjs b/tools/diff-coverage.mjs index c373e1f..1a77330 100644 --- a/tools/diff-coverage.mjs +++ b/tools/diff-coverage.mjs @@ -32,13 +32,50 @@ import { fileURLToPath, pathToFileURL } from "node:url"; const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const CLAUDE_PLUGIN_ROOT = "plugins/claude-code/neatcontext"; -// The plugin's shipped code. Tests are excluded on purpose: a test file is -// covered by definition, and counting it would only dilute the gate. +// Every host's shipped code, not just Claude Code's. +// +// The gate watched one plugin for a while, which was fine when the other hosts +// were forks that rarely moved. It stopped being fine the moment a change had +// to be applied to five bridges at once: the four ports sailed through a green +// coverage job that had not looked at a single line of them. +// +// Each host's own adapter directory is listed rather than matched by a pattern, +// so adding a host is a deliberate edit here and a new plugin cannot arrive +// ungated by accident. +const GATED_DIRECTORIES = [ + `${CLAUDE_PLUGIN_ROOT}/src/`, + "plugins/copilot/neatcontext/src/copilot/", + "plugins/kimi-code/neatcontext/src/kimi/", + "plugins/pi/neatcontext/src/pi/", + "plugins/pi/neatcontext/extensions/", + "codex-marketplace/plugins/neatcontext/src/codex/" +]; + +// The Context core is authored once in shared/core and copied verbatim into +// every plugin, and two separate checks already prove those copies identical: +// `sync-context-core.mjs --check` fails CI when one drifts, and the host tests +// assert byte-equality against Claude Code's. Claude's copy is gated above and +// is the one the unit tests import directly. +// +// So gating the other four copies would demand that the same line be executed +// five times over to prove something already proven by equality. It would add +// no safety and would fail honest changes. Host-specific code, which is not +// generated and not identical, is gated everywhere. +const GENERATED_CORE = /^(plugins|codex-marketplace\/plugins)\/[^/]+\/neatcontext\/src\/core\//; + +// Tests are excluded on purpose: a test file is covered by definition, and +// counting it would only dilute the gate. export function isGatedFile(repoRelativePath) { - return ( - repoRelativePath.startsWith(`${CLAUDE_PLUGIN_ROOT}/src/`) && - repoRelativePath.endsWith(".mjs") - ); + if (!/\.(mjs|js)$/.test(repoRelativePath)) { + return false; + } + if ( + GENERATED_CORE.test(repoRelativePath) && + !repoRelativePath.startsWith(`${CLAUDE_PLUGIN_ROOT}/src/`) + ) { + return false; + } + return GATED_DIRECTORIES.some((directory) => repoRelativePath.startsWith(directory)); } // --- what changed ------------------------------------------------------------