From debc12f7c0f52dee74a7a8ed1a83848f2493dfc2 Mon Sep 17 00:00:00 2001 From: tanglearncode Date: Sun, 9 Aug 2026 06:44:15 +0800 Subject: [PATCH] feat(save): generate the questions and names a context should be found by MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #79 added the storage; nothing filled it. This is the half that does the work, and it does it at save time on purpose: no plugin process has a model, but the session doing the saving does, and it has just read the material. Doing the language work once, where it is free, beats doing it on every question, where it is impossible. Every host's save document now asks for two lists alongside the routing description: the questions this context should answer, in the words a user would type rather than the words the profile uses, and the names that appear in this work and rarely anywhere else. Both are matched against and never shown, which the documents say outright — a list written for a reader is a worse list than one written to be matched. Omitting them on an update leaves the stored lists alone, so a save from a host or a turn that generated none cannot wipe what an earlier one wrote. pi takes a save as tool arguments rather than a capture file, so its tool schema and runtime carry the fields directly. A test sweeps every host's save document for both fields and both rules. The storage was shared core and arrived everywhere at once; this instruction is per host, and a host that forgot to ask would store contexts that are quietly harder to find than the same work saved elsewhere. --- .../plugins/neatcontext/skills/save/SKILL.md | 4 ++ .../claude-code/neatcontext/commands/save.md | 17 +++++ plugins/copilot/neatcontext/commands/save.md | 17 +++++ .../neatcontext/skills/save/SKILL.md | 4 ++ .../pi/neatcontext/extensions/neatcontext.js | 16 +++++ plugins/pi/neatcontext/skills/save/SKILL.md | 15 ++++- plugins/pi/neatcontext/src/pi/runtime.mjs | 16 ++++- .../pi/neatcontext/tests/pi-runtime.test.mjs | 47 ++++++++++++++ tests/routing-expansion-docs.test.mjs | 64 +++++++++++++++++++ 9 files changed, 198 insertions(+), 2 deletions(-) create mode 100644 tests/routing-expansion-docs.test.mjs diff --git a/codex-marketplace/plugins/neatcontext/skills/save/SKILL.md b/codex-marketplace/plugins/neatcontext/skills/save/SKILL.md index c9f2e45..f74f67d 100644 --- a/codex-marketplace/plugins/neatcontext/skills/save/SKILL.md +++ b/codex-marketplace/plugins/neatcontext/skills/save/SKILL.md @@ -69,6 +69,8 @@ For creation: "name": "Short specific name", "profile": "# Short specific name\n\n## Purpose\n...", "routingDescription": "Scope-only routing description", + "routingQuestions": ["why was checkout throwing 5xx last week", "..."], + "routingEntities": ["INC-1001", "checkout-api", "pgbouncer"], "knowledge": [ { "path": "session-summary.md", @@ -78,6 +80,8 @@ For creation: } ``` +`routingQuestions` holds 10 to 15 questions this context should answer, in the words a user would type rather than the words the profile uses. `routingEntities` holds the names that appear in this work and rarely elsewhere: services, repos, ticket ids, error strings, commands. Both are matched against and never shown, so prefer coverage over polish. On an update, omit both to leave the stored lists alone. + For an update, also include the exact `targetId` and `baseHash` printed by `save-target`. Every knowledge path must be a short relative `.md` path. For creation, run: diff --git a/plugins/claude-code/neatcontext/commands/save.md b/plugins/claude-code/neatcontext/commands/save.md index 8592e0f..4619b37 100644 --- a/plugins/claude-code/neatcontext/commands/save.md +++ b/plugins/claude-code/neatcontext/commands/save.md @@ -143,6 +143,19 @@ says only which future requests belong here, naming systems, repos, components, ticket prefixes, symptoms, and terminology someone would type. Do not put behavioral, tone, or answer-format instructions in this line. +Also derive two lists that are matched against and never shown. They are what +lets someone find this context when they have forgotten it exists. + +- `routingQuestions` — 10 to 15 questions this context should answer, written + the way the user would type them rather than the way the profile describes + them. Include the vague ones ("did we ever fix that timeout thing"). +- `routingEntities` — names that appear in this work and rarely anywhere else: + services, repos, ticket ids, error strings, commands, hosts, people. + +Nothing reads either list aloud, so prefer coverage over polish. On an update, +omit both fields to leave the stored lists untouched; supply them only when the +work has added vocabulary the context should now be found by. + ## Write the capture Write one valid JSON file, with no surrounding code fence, to: @@ -157,6 +170,8 @@ For a new context, use exactly this shape: "name": "Short specific name", "profile": "# Short specific name\n\n## Purpose\n...", "routingDescription": "One line describing only the matching scope", + "routingQuestions": ["why was checkout throwing 5xx last week", "..."], + "routingEntities": ["INC-1001", "checkout-api", "pgbouncer"], "knowledge": [ { "path": "session-summary.md", @@ -176,6 +191,8 @@ For an update, add the exact target values printed by `save-target`: "baseHash": "exact base hash", "profile": "# Exact existing context name\n\n## Purpose\n...", "routingDescription": "One line describing only the matching scope", + "routingQuestions": ["why was checkout throwing 5xx last week", "..."], + "routingEntities": ["INC-1001", "checkout-api", "pgbouncer"], "knowledge": [ { "path": "session-summary.md", diff --git a/plugins/copilot/neatcontext/commands/save.md b/plugins/copilot/neatcontext/commands/save.md index d0c65c6..8c5d847 100644 --- a/plugins/copilot/neatcontext/commands/save.md +++ b/plugins/copilot/neatcontext/commands/save.md @@ -114,6 +114,19 @@ says only which future requests belong here, naming systems, repos, components, ticket prefixes, symptoms, and terminology someone would type. Do not put behavioral, tone, or answer-format instructions in this line. +Also derive two lists that are matched against and never shown. They are what +lets someone find this context when they have forgotten it exists. + +- `routingQuestions` — 10 to 15 questions this context should answer, written + the way the user would type them rather than the way the profile describes + them. Include the vague ones ("did we ever fix that timeout thing"). +- `routingEntities` — names that appear in this work and rarely anywhere else: + services, repos, ticket ids, error strings, commands, hosts, people. + +Nothing reads either list aloud, so prefer coverage over polish. On an update, +omit both fields to leave the stored lists untouched; supply them only when the +work has added vocabulary the context should now be found by. + ## Write the capture Write one valid JSON file, with no surrounding code fence, to a uniquely named @@ -134,6 +147,8 @@ For a new context, use exactly this shape: "name": "Short specific name", "profile": "# Short specific name\n\n## Purpose\n...", "routingDescription": "One line describing only the matching scope", + "routingQuestions": ["why was checkout throwing 5xx last week", "..."], + "routingEntities": ["INC-1001", "checkout-api", "pgbouncer"], "knowledge": [ { "path": "session-summary.md", @@ -153,6 +168,8 @@ For an update, add the exact target values printed by `save-target`: "baseHash": "exact base hash", "profile": "# Exact existing context name\n\n## Purpose\n...", "routingDescription": "One line describing only the matching scope", + "routingQuestions": ["why was checkout throwing 5xx last week", "..."], + "routingEntities": ["INC-1001", "checkout-api", "pgbouncer"], "knowledge": [ { "path": "session-summary.md", diff --git a/plugins/kimi-code/neatcontext/skills/save/SKILL.md b/plugins/kimi-code/neatcontext/skills/save/SKILL.md index 566e4b6..a4f3a02 100644 --- a/plugins/kimi-code/neatcontext/skills/save/SKILL.md +++ b/plugins/kimi-code/neatcontext/skills/save/SKILL.md @@ -69,6 +69,8 @@ For creation: "name": "Short specific name", "profile": "# Short specific name\n\n## Purpose\n...", "routingDescription": "Scope-only routing description", + "routingQuestions": ["why was checkout throwing 5xx last week", "..."], + "routingEntities": ["INC-1001", "checkout-api", "pgbouncer"], "knowledge": [ { "path": "session-summary.md", @@ -78,6 +80,8 @@ For creation: } ``` +`routingQuestions` holds 10 to 15 questions this context should answer, in the words a user would type rather than the words the profile uses. `routingEntities` holds the names that appear in this work and rarely elsewhere: services, repos, ticket ids, error strings, commands. Both are matched against and never shown, so prefer coverage over polish. On an update, omit both to leave the stored lists alone. + For an update, also include the exact `targetId` and `baseHash` printed by `save-target`. Every knowledge path must be a short relative `.md` path. For creation, run: diff --git a/plugins/pi/neatcontext/extensions/neatcontext.js b/plugins/pi/neatcontext/extensions/neatcontext.js index 23faeee..6ec52a7 100644 --- a/plugins/pi/neatcontext/extensions/neatcontext.js +++ b/plugins/pi/neatcontext/extensions/neatcontext.js @@ -273,6 +273,22 @@ export default function (pi) { type: "string", description: "One line of scope, under 200 characters. Scope only, never behavior." }, + routingQuestions: { + type: "array", + items: { type: "string" }, + description: + "10-15 questions this context should catch, in the words a user would type " + + "rather than the words the profile uses. Matched against, never shown. Omit on " + + "an update to leave the stored list alone." + }, + routingEntities: { + type: "array", + items: { type: "string" }, + description: + "Names that appear in this work and rarely elsewhere: services, repos, ticket " + + "ids, error strings, commands, hosts. Matched against, never shown. Omit on an " + + "update to leave the stored list alone." + }, knowledge: { type: "array", description: diff --git a/plugins/pi/neatcontext/skills/save/SKILL.md b/plugins/pi/neatcontext/skills/save/SKILL.md index d4b9eec..c7259d9 100644 --- a/plugins/pi/neatcontext/skills/save/SKILL.md +++ b/plugins/pi/neatcontext/skills/save/SKILL.md @@ -74,10 +74,23 @@ symptoms, ticket prefixes, and terminology someone would actually type. Never include behavioral or formatting instructions: that line is read while *other* contexts are connected. +Also derive two lists that are matched against and never shown. They are what +lets someone find this context when they have forgotten it exists. + +- `routingQuestions` — 10 to 15 questions this context should answer, written + the way the user would type them rather than the way the profile describes + them. Include the vague ones ("did we ever fix that timeout thing"). +- `routingEntities` — names that appear in this work and rarely anywhere else: + services, repos, ticket ids, error strings, commands, hosts, people. + +Nothing reads either list aloud, so prefer coverage over polish. On an update, +omit both fields to leave the stored lists untouched; supply them only when the +work has added vocabulary the context should now be found by. + ## Apply For a new context, call `neatcontext_save` with `name`, `profile`, -`routingDescription`, and `knowledge`. It is created immediately. +`routingDescription`, `routingQuestions`, `routingEntities`, and `knowledge`. It is created immediately. For an update, call it with `targetId`, `baseHash`, `profile`, `routingDescription`, and `knowledge`. That returns a preview and changes diff --git a/plugins/pi/neatcontext/src/pi/runtime.mjs b/plugins/pi/neatcontext/src/pi/runtime.mjs index 657e6ec..0d0bdd3 100644 --- a/plugins/pi/neatcontext/src/pi/runtime.mjs +++ b/plugins/pi/neatcontext/src/pi/runtime.mjs @@ -1019,7 +1019,17 @@ function renderUpdatePreview(preview) { // hosts write JSON to disk and shell out to a CLI because their plugin runs in a // different process, and this one does not. export async function saveContext(args = {}) { - const { name, targetId, baseHash, profile, routingDescription, knowledge, confirm } = args; + const { + name, + targetId, + baseHash, + profile, + routingDescription, + routingQuestions, + routingEntities, + knowledge, + confirm + } = args; if (typeof profile !== "string" || profile.trim().length === 0) { return saveTarget(typeof name === "string" ? name : ""); @@ -1032,6 +1042,10 @@ export async function saveContext(args = {}) { routingDescription: typeof routingDescription === "string" ? routingDescription : "", knowledge: Array.isArray(knowledge) ? knowledge : [] }; + // Left off the capture entirely when the caller said nothing, so an update + // leaves whatever is stored alone rather than clearing it. + if (routingQuestions !== undefined) capture.routingQuestions = routingQuestions; + if (routingEntities !== undefined) capture.routingEntities = routingEntities; try { if (typeof targetId === "string" && targetId.length > 0) { diff --git a/plugins/pi/neatcontext/tests/pi-runtime.test.mjs b/plugins/pi/neatcontext/tests/pi-runtime.test.mjs index a13b773..48d7389 100644 --- a/plugins/pi/neatcontext/tests/pi-runtime.test.mjs +++ b/plugins/pi/neatcontext/tests/pi-runtime.test.mjs @@ -15,6 +15,7 @@ let home; let docs; let runtime; let session; +let store; before(async () => { home = await mkdtemp(path.join(os.tmpdir(), "neatcontext-pi-test-")); @@ -28,6 +29,7 @@ before(async () => { // inside the temporary home. runtime = await import("../src/pi/runtime.mjs"); session = await import("../src/pi/session.mjs"); + store = await import("../src/core/context-store.mjs"); }); after(async () => { @@ -538,3 +540,48 @@ describe("narrowing the menu to the request", () => { ); }); }); + +describe("saving the matching material", () => { + const BASE = { + name: "Pool limits June", + profile: "# Pool limits June\n\n## Purpose\n\nThe June regression.\n", + routingDescription: "billing-postgres default_pool_size regression after the June deploy", + knowledge: [{ path: "session-summary.md", content: "# Summary\n\nPool exhaustion.\n" }] + }; + + it("stores the questions and entities a save supplies", async () => { + await runtime.saveContext({ + ...BASE, + routingQuestions: ["why was checkout throwing 5xx last week"], + routingEntities: ["INC-1001", "checkout-api"] + }); + const stored = (await store.listContexts()).find((entry) => entry.name === "Pool limits June"); + assert.deepEqual(stored.routingQuestions, ["why was checkout throwing 5xx last week"]); + assert.deepEqual(stored.routingEntities, ["INC-1001", "checkout-api"]); + }); + + it("leaves stored lists alone when a save says nothing about them", async () => { + // pi may save from a turn that never generated them; that must not wipe + // what an earlier save wrote. + await runtime.saveContext({ + ...BASE, + routingQuestions: ["why was checkout throwing 5xx last week"], + routingEntities: ["INC-1001"] + }); + const before = (await store.listContexts()).find((entry) => entry.name === "Pool limits June"); + + await runtime.saveContext({ + name: before.name, + targetId: before.id, + baseHash: await store.fingerprintContext(before), + profile: "# Pool limits June\n\n## Purpose\n\nThe June regression, revisited.\n", + routingDescription: BASE.routingDescription, + knowledge: BASE.knowledge, + confirm: true + }); + + const after = (await store.listContexts()).find((entry) => entry.name === "Pool limits June"); + assert.deepEqual(after.routingQuestions, before.routingQuestions); + assert.deepEqual(after.routingEntities, before.routingEntities); + }); +}); diff --git a/tests/routing-expansion-docs.test.mjs b/tests/routing-expansion-docs.test.mjs new file mode 100644 index 0000000..c1055c0 --- /dev/null +++ b/tests/routing-expansion-docs.test.mjs @@ -0,0 +1,64 @@ +// Every host has to ask for the matching material, not just the one somebody +// happened to edit. +// +// The storage for `routingQuestions` and `routingEntities` is shared core, so it +// arrived everywhere at once. The instruction that fills them is not: each host +// carries its own save document, and a context saved from a host whose document +// forgot to ask is stored with nothing to match against and is quietly harder +// to find than the same work saved from another host. +// +// This is the same shape of bug the command sweep in plugin-commands.test.mjs +// exists for: one host's omission hiding in four other hosts' correctness. + +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, it } from "node:test"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +// The document each host's save flow actually reads. +const SAVE_DOCUMENTS = { + "claude-code": "plugins/claude-code/neatcontext/commands/save.md", + copilot: "plugins/copilot/neatcontext/commands/save.md", + "kimi-code": "plugins/kimi-code/neatcontext/skills/save/SKILL.md", + codex: "codex-marketplace/plugins/neatcontext/skills/save/SKILL.md", + pi: "plugins/pi/neatcontext/skills/save/SKILL.md" +}; + +describe("every host asks for the matching material", () => { + for (const [host, file] of Object.entries(SAVE_DOCUMENTS)) { + it(`${host} asks for both lists`, async () => { + const text = await readFile(path.join(root, file), "utf8"); + assert.ok(text.includes("routingQuestions"), `${file} never mentions routingQuestions`); + assert.ok(text.includes("routingEntities"), `${file} never mentions routingEntities`); + }); + + it(`${host} says the lists are never shown`, async () => { + // Without this the model writes them as prose for a reader, which is a + // different and much worse list than one written to be matched against. + const text = await readFile(path.join(root, file), "utf8"); + assert.match(text, /never shown|matched against and never shown|Matched against, never shown/i); + }); + + it(`${host} says omitting them leaves stored lists alone`, async () => { + // The rule that stops a host from wiping what another host wrote. + const text = await readFile(path.join(root, file), "utf8"); + assert.match(text, /omit both|Omit on an update|leave the stored list/i); + }); + } +}); + +describe("the pi save tool", () => { + it("accepts both lists as arrays", async () => { + // pi is the one host that takes a save as tool arguments rather than a + // capture file, so its schema has to carry the fields itself. + const text = await readFile( + path.join(root, "plugins", "pi", "neatcontext", "extensions", "neatcontext.js"), + "utf8" + ); + assert.match(text, /routingQuestions: \{\s*type: "array"/); + assert.match(text, /routingEntities: \{\s*type: "array"/); + }); +});