From 8abee2112777af24d416ea16fe7cd6438ac3ba94 Mon Sep 17 00:00:00 2001 From: tanglearncode Date: Sun, 9 Aug 2026 06:01:12 +0800 Subject: [PATCH 1/2] feat(routing): narrow the menu on Copilot, Kimi, Codex and pi too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code has been narrowing its routing menu since #75 while the other four hosts still sent every context on the machine, alphabetically, on every call. The shared core already had everything needed; only each host's own bridge was missing the wiring. Copilot, Kimi Code and Codex take the same change as Claude: get_context accepts an optional query, and a request that matches something replaces the full menu with the contexts that matched it and the near-tie rule that comes with it. pi differs in one way worth noting. Its notes go into the system prompt before every turn as well as into get_context, and the system prompt has no request to match against — so that path keeps listing everything, which is the correct answer there. Each host gets a test that drives its own bridge end to end: a request that matches shows only what matched, and a call with no query still shows the whole store. The repository's coverage gate only watches the Claude plugin, so these tests are what holds the port honest. --- .../neatcontext/src/codex/mcp-bridge.mjs | 81 ++++++++++++++++--- codex-marketplace/tests/codex-plugin.test.mjs | 63 +++++++++++++++ .../neatcontext/src/copilot/mcp-bridge.mjs | 81 ++++++++++++++++--- .../neatcontext/src/kimi/mcp-bridge.mjs | 81 ++++++++++++++++--- .../pi/neatcontext/extensions/neatcontext.js | 20 ++++- plugins/pi/neatcontext/src/pi/runtime.mjs | 74 ++++++++++++++--- .../pi/neatcontext/tests/pi-runtime.test.mjs | 51 ++++++++++++ tests/copilot-plugin.test.mjs | 52 +++++++++++- tests/kimi-plugin.test.mjs | 48 ++++++++++- 9 files changed, 498 insertions(+), 53 deletions(-) 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..93ee6dc 100644 --- a/codex-marketplace/tests/codex-plugin.test.mjs +++ b/codex-marketplace/tests/codex-plugin.test.mjs @@ -337,3 +337,66 @@ test("selected contexts advertise one-shot grounding guidance", async () => { 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/); + } finally { + 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-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..ec5df55 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,47 @@ 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/); +}); diff --git a/tests/kimi-plugin.test.mjs b/tests/kimi-plugin.test.mjs index 6653463..9ed6bb6 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,44 @@ 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/); +}); From 457bc31ccabd0a9b944161d5edfa5e2a3bc6f423 Mon Sep 17 00:00:00 2001 From: tanglearncode Date: Sun, 9 Aug 2026 06:19:22 +0800 Subject: [PATCH 2/2] test(coverage): gate every host's source, not only Claude Code's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The diff-coverage gate watched one plugin. That was fine while the other hosts were forks that rarely moved, and stopped being fine the moment a change had to be applied to five bridges at once: the four ports in this branch passed a green coverage job that had not read a line of them. Every host adapter is gated now. The generated Context core copied into each plugin is not: those copies are proven byte-identical by the sync check and by the host tests, and Claude's copy is gated, so requiring the same line to run five times would prove nothing further. Widening it found three real gaps in this branch. Copilot, Kimi and Codex had no test for the fallback that stops an unmatched question from hiding the whole store. pi's extension entry point was untested. And the Codex harness killed its bridge instead of closing it, so the child never flushed its coverage profile and everything it ran read as untested — a latent bug that only an ungated host could hide. --- CONTRIBUTING.md | 24 +++++++-- codex-marketplace/tests/codex-plugin.test.mjs | 24 +++++++-- .../neatcontext/tests/pi-extension.test.mjs | 15 ++++++ tests/copilot-plugin.test.mjs | 8 +++ tests/diff-coverage.test.mjs | 21 ++++++++ tests/kimi-plugin.test.mjs | 8 +++ tools/diff-coverage.mjs | 49 ++++++++++++++++--- 7 files changed, 133 insertions(+), 16 deletions(-) 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/tests/codex-plugin.test.mjs b/codex-marketplace/tests/codex-plugin.test.mjs index 93ee6dc..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,7 +337,7 @@ 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(); } }); @@ -396,7 +399,18 @@ test("Codex narrows the routing menu to the request", async () => { }); 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 { - session.close(); + await session.close(); } }); 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/tests/copilot-plugin.test.mjs b/tests/copilot-plugin.test.mjs index ec5df55..5dff41c 100644 --- a/tests/copilot-plugin.test.mjs +++ b/tests/copilot-plugin.test.mjs @@ -709,4 +709,12 @@ test("Copilot narrows the routing menu to the request", async (t) => { 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 9ed6bb6..b4680a5 100644 --- a/tests/kimi-plugin.test.mjs +++ b/tests/kimi-plugin.test.mjs @@ -500,4 +500,12 @@ test("Kimi narrows the routing menu to the request", async (t) => { 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 ------------------------------------------------------------