Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions codex-marketplace/plugins/neatcontext/skills/save/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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:
Expand Down
17 changes: 17 additions & 0 deletions plugins/claude-code/neatcontext/commands/save.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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",
Expand All @@ -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",
Expand Down
17 changes: 17 additions & 0 deletions plugins/copilot/neatcontext/commands/save.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand All @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions plugins/kimi-code/neatcontext/skills/save/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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:
Expand Down
16 changes: 16 additions & 0 deletions plugins/pi/neatcontext/extensions/neatcontext.js
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
15 changes: 14 additions & 1 deletion plugins/pi/neatcontext/skills/save/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion plugins/pi/neatcontext/src/pi/runtime.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 : "");
Expand All @@ -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) {
Expand Down
47 changes: 47 additions & 0 deletions plugins/pi/neatcontext/tests/pi-runtime.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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-"));
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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);
});
});
64 changes: 64 additions & 0 deletions tests/routing-expansion-docs.test.mjs
Original file line number Diff line number Diff line change
@@ -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"/);
});
});