From dbbb639eeacc0320a097efe9739a1ed9745f46f3 Mon Sep 17 00:00:00 2001 From: tanglearncode Date: Sun, 9 Aug 2026 07:13:33 +0800 Subject: [PATCH] fix(save): keep the matching lists portable domain knowledge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lists added in #79 travel inside the bundle, and the guidance in #80 told the model to fill them with "commands, hosts, people". Hostnames and people mean nothing on anyone else's machine, and asking for people contradicts the save command's own rule against writing unnecessary personal information. A matching list is the easiest place for a context to quietly stop being shareable: an absolute path looks like a usefully rare term while it is written, and is worthless or revealing the moment the bundle is handed over. The hosts had also drifted apart — three asked for hosts and people, two asked for neither — so the same work saved from different hosts produced different bundles. One wording everywhere now, asking for names that belong to the subject: services, components, repositories, ticket ids and prefixes, error strings, product and system names. Each document states that the lists travel and must stay free of absolute paths, home directories, usernames, personal names and email addresses. If the work genuinely is about a particular environment, the profile is where that belongs and is untouched. What changes is that the matching index is not. And a filter, so none of this rests on prose: entries carrying a drive letter path, a UNC path, ~/, /home/, /Users/, /root/, or an email address are dropped from both lists at save time. Ordinary domain terms that merely look technical are kept, which the tests pin. --- .../plugins/neatcontext/skills/save/SKILL.md | 6 ++- .../neatcontext/src/core/context-store.mjs | 18 ++++++- .../claude-code/neatcontext/commands/save.md | 12 ++++- .../neatcontext/src/core/context-store.mjs | 18 ++++++- plugins/copilot/neatcontext/commands/save.md | 12 ++++- .../neatcontext/src/core/context-store.mjs | 18 ++++++- .../neatcontext/skills/save/SKILL.md | 6 ++- .../neatcontext/src/core/context-store.mjs | 18 ++++++- .../pi/neatcontext/extensions/neatcontext.js | 8 +-- plugins/pi/neatcontext/skills/save/SKILL.md | 12 ++++- .../pi/neatcontext/src/core/context-store.mjs | 18 ++++++- shared/core/context-store.mjs | 18 ++++++- tests/routing-expansion-docs.test.mjs | 18 +++++++ tests/routing-expansion.test.mjs | 54 +++++++++++++++++++ 14 files changed, 219 insertions(+), 17 deletions(-) diff --git a/codex-marketplace/plugins/neatcontext/skills/save/SKILL.md b/codex-marketplace/plugins/neatcontext/skills/save/SKILL.md index f74f67d..6a84f83 100644 --- a/codex-marketplace/plugins/neatcontext/skills/save/SKILL.md +++ b/codex-marketplace/plugins/neatcontext/skills/save/SKILL.md @@ -80,7 +80,11 @@ 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. +`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 names that belong to the subject and appear rarely elsewhere: services, components, repositories, ticket ids and prefixes, error strings, product and system names. Both are matched against and never shown, so prefer coverage over polish. + +Both lists travel with the context to anyone it is shared with, so write them as domain knowledge and nothing else: no absolute paths, no home directories, no usernames, no personal names, no email addresses, and nothing whose meaning depends on this machine or this person. If the work genuinely is about a particular environment, say so in the profile and keep these lists to terms a colleague would recognise. + +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. diff --git a/codex-marketplace/plugins/neatcontext/src/core/context-store.mjs b/codex-marketplace/plugins/neatcontext/src/core/context-store.mjs index 9fb69f6..c9e76b4 100644 --- a/codex-marketplace/plugins/neatcontext/src/core/context-store.mjs +++ b/codex-marketplace/plugins/neatcontext/src/core/context-store.mjs @@ -315,6 +315,22 @@ export async function createContext({ name, knowledgeFolder, profile, extensions // // Optional, unlike the description. A bundle written before this existed, or by // a host that does not generate them, is not broken — it just matches on less. +// Terms that cannot mean the same thing on anyone else's machine, or that name +// a person rather than a subject. +// +// A context is domain knowledge, and it is meant to be handed to a teammate +// intact. A matching list is the easiest place for that to quietly stop being +// true: an absolute path or a home directory looks like a useful rare term +// while it is being written and is worthless — or worse, revealing — the moment +// the bundle leaves the machine. +// +// This drops them from the matching lists only. If a local path really is part +// of what the context is about, the profile and the knowledge folder are where +// it belongs and are untouched by this; being unable to *search* for a context +// by someone's home directory costs nothing worth having. +const NOT_PORTABLE = + /(^|\s)(~[\\/]|[a-z]:[\\/]|\\\\)|[\\/](home|users|root)[\\/]|\S+@\S+\.\S/i; + function normalizeRoutingList(value, limit) { if (!Array.isArray(value)) { return []; @@ -325,7 +341,7 @@ function normalizeRoutingList(value, limit) { if (typeof entry !== "string") continue; const clean = entry.trim().replace(/\s+/g, " ").slice(0, MAX_ROUTING_TERM); const key = clean.toLowerCase(); - if (clean.length === 0 || seen.has(key)) continue; + if (clean.length === 0 || seen.has(key) || NOT_PORTABLE.test(clean)) continue; seen.add(key); kept.push(clean); if (kept.length === limit) break; diff --git a/plugins/claude-code/neatcontext/commands/save.md b/plugins/claude-code/neatcontext/commands/save.md index 4619b37..9b6bd74 100644 --- a/plugins/claude-code/neatcontext/commands/save.md +++ b/plugins/claude-code/neatcontext/commands/save.md @@ -149,8 +149,16 @@ 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. +- `routingEntities` — names that belong to the subject and appear rarely + elsewhere: services, components, repositories, ticket ids and prefixes, error + strings, product and system names. + +Both lists travel with the context to anyone it is shared with, so write them as +domain knowledge and nothing else. No absolute paths, no home directories, no +usernames, no personal names, no email addresses, and nothing whose meaning +depends on this machine or this person. If the work genuinely is about a +particular environment, say so in the profile — that is what the profile is for +— and keep these lists to the terms a colleague would recognise. 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 diff --git a/plugins/claude-code/neatcontext/src/core/context-store.mjs b/plugins/claude-code/neatcontext/src/core/context-store.mjs index 9fb69f6..c9e76b4 100644 --- a/plugins/claude-code/neatcontext/src/core/context-store.mjs +++ b/plugins/claude-code/neatcontext/src/core/context-store.mjs @@ -315,6 +315,22 @@ export async function createContext({ name, knowledgeFolder, profile, extensions // // Optional, unlike the description. A bundle written before this existed, or by // a host that does not generate them, is not broken — it just matches on less. +// Terms that cannot mean the same thing on anyone else's machine, or that name +// a person rather than a subject. +// +// A context is domain knowledge, and it is meant to be handed to a teammate +// intact. A matching list is the easiest place for that to quietly stop being +// true: an absolute path or a home directory looks like a useful rare term +// while it is being written and is worthless — or worse, revealing — the moment +// the bundle leaves the machine. +// +// This drops them from the matching lists only. If a local path really is part +// of what the context is about, the profile and the knowledge folder are where +// it belongs and are untouched by this; being unable to *search* for a context +// by someone's home directory costs nothing worth having. +const NOT_PORTABLE = + /(^|\s)(~[\\/]|[a-z]:[\\/]|\\\\)|[\\/](home|users|root)[\\/]|\S+@\S+\.\S/i; + function normalizeRoutingList(value, limit) { if (!Array.isArray(value)) { return []; @@ -325,7 +341,7 @@ function normalizeRoutingList(value, limit) { if (typeof entry !== "string") continue; const clean = entry.trim().replace(/\s+/g, " ").slice(0, MAX_ROUTING_TERM); const key = clean.toLowerCase(); - if (clean.length === 0 || seen.has(key)) continue; + if (clean.length === 0 || seen.has(key) || NOT_PORTABLE.test(clean)) continue; seen.add(key); kept.push(clean); if (kept.length === limit) break; diff --git a/plugins/copilot/neatcontext/commands/save.md b/plugins/copilot/neatcontext/commands/save.md index 8c5d847..8c11de9 100644 --- a/plugins/copilot/neatcontext/commands/save.md +++ b/plugins/copilot/neatcontext/commands/save.md @@ -120,8 +120,16 @@ 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. +- `routingEntities` — names that belong to the subject and appear rarely + elsewhere: services, components, repositories, ticket ids and prefixes, error + strings, product and system names. + +Both lists travel with the context to anyone it is shared with, so write them as +domain knowledge and nothing else. No absolute paths, no home directories, no +usernames, no personal names, no email addresses, and nothing whose meaning +depends on this machine or this person. If the work genuinely is about a +particular environment, say so in the profile — that is what the profile is for +— and keep these lists to the terms a colleague would recognise. 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 diff --git a/plugins/copilot/neatcontext/src/core/context-store.mjs b/plugins/copilot/neatcontext/src/core/context-store.mjs index 9fb69f6..c9e76b4 100644 --- a/plugins/copilot/neatcontext/src/core/context-store.mjs +++ b/plugins/copilot/neatcontext/src/core/context-store.mjs @@ -315,6 +315,22 @@ export async function createContext({ name, knowledgeFolder, profile, extensions // // Optional, unlike the description. A bundle written before this existed, or by // a host that does not generate them, is not broken — it just matches on less. +// Terms that cannot mean the same thing on anyone else's machine, or that name +// a person rather than a subject. +// +// A context is domain knowledge, and it is meant to be handed to a teammate +// intact. A matching list is the easiest place for that to quietly stop being +// true: an absolute path or a home directory looks like a useful rare term +// while it is being written and is worthless — or worse, revealing — the moment +// the bundle leaves the machine. +// +// This drops them from the matching lists only. If a local path really is part +// of what the context is about, the profile and the knowledge folder are where +// it belongs and are untouched by this; being unable to *search* for a context +// by someone's home directory costs nothing worth having. +const NOT_PORTABLE = + /(^|\s)(~[\\/]|[a-z]:[\\/]|\\\\)|[\\/](home|users|root)[\\/]|\S+@\S+\.\S/i; + function normalizeRoutingList(value, limit) { if (!Array.isArray(value)) { return []; @@ -325,7 +341,7 @@ function normalizeRoutingList(value, limit) { if (typeof entry !== "string") continue; const clean = entry.trim().replace(/\s+/g, " ").slice(0, MAX_ROUTING_TERM); const key = clean.toLowerCase(); - if (clean.length === 0 || seen.has(key)) continue; + if (clean.length === 0 || seen.has(key) || NOT_PORTABLE.test(clean)) continue; seen.add(key); kept.push(clean); if (kept.length === limit) break; diff --git a/plugins/kimi-code/neatcontext/skills/save/SKILL.md b/plugins/kimi-code/neatcontext/skills/save/SKILL.md index a4f3a02..f7b790c 100644 --- a/plugins/kimi-code/neatcontext/skills/save/SKILL.md +++ b/plugins/kimi-code/neatcontext/skills/save/SKILL.md @@ -80,7 +80,11 @@ 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. +`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 names that belong to the subject and appear rarely elsewhere: services, components, repositories, ticket ids and prefixes, error strings, product and system names. Both are matched against and never shown, so prefer coverage over polish. + +Both lists travel with the context to anyone it is shared with, so write them as domain knowledge and nothing else: no absolute paths, no home directories, no usernames, no personal names, no email addresses, and nothing whose meaning depends on this machine or this person. If the work genuinely is about a particular environment, say so in the profile and keep these lists to terms a colleague would recognise. + +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. diff --git a/plugins/kimi-code/neatcontext/src/core/context-store.mjs b/plugins/kimi-code/neatcontext/src/core/context-store.mjs index 9fb69f6..c9e76b4 100644 --- a/plugins/kimi-code/neatcontext/src/core/context-store.mjs +++ b/plugins/kimi-code/neatcontext/src/core/context-store.mjs @@ -315,6 +315,22 @@ export async function createContext({ name, knowledgeFolder, profile, extensions // // Optional, unlike the description. A bundle written before this existed, or by // a host that does not generate them, is not broken — it just matches on less. +// Terms that cannot mean the same thing on anyone else's machine, or that name +// a person rather than a subject. +// +// A context is domain knowledge, and it is meant to be handed to a teammate +// intact. A matching list is the easiest place for that to quietly stop being +// true: an absolute path or a home directory looks like a useful rare term +// while it is being written and is worthless — or worse, revealing — the moment +// the bundle leaves the machine. +// +// This drops them from the matching lists only. If a local path really is part +// of what the context is about, the profile and the knowledge folder are where +// it belongs and are untouched by this; being unable to *search* for a context +// by someone's home directory costs nothing worth having. +const NOT_PORTABLE = + /(^|\s)(~[\\/]|[a-z]:[\\/]|\\\\)|[\\/](home|users|root)[\\/]|\S+@\S+\.\S/i; + function normalizeRoutingList(value, limit) { if (!Array.isArray(value)) { return []; @@ -325,7 +341,7 @@ function normalizeRoutingList(value, limit) { if (typeof entry !== "string") continue; const clean = entry.trim().replace(/\s+/g, " ").slice(0, MAX_ROUTING_TERM); const key = clean.toLowerCase(); - if (clean.length === 0 || seen.has(key)) continue; + if (clean.length === 0 || seen.has(key) || NOT_PORTABLE.test(clean)) continue; seen.add(key); kept.push(clean); if (kept.length === limit) break; diff --git a/plugins/pi/neatcontext/extensions/neatcontext.js b/plugins/pi/neatcontext/extensions/neatcontext.js index 6ec52a7..af5a759 100644 --- a/plugins/pi/neatcontext/extensions/neatcontext.js +++ b/plugins/pi/neatcontext/extensions/neatcontext.js @@ -285,9 +285,11 @@ export default function (pi) { 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." + "Names belonging to the subject that appear rarely elsewhere: services, " + + "components, repositories, ticket ids and prefixes, error strings, product and " + + "system names. This travels with the context when it is shared, so no absolute " + + "paths, home directories, usernames, personal names or email addresses. Matched " + + "against, never shown. Omit on an update to leave the stored list alone." }, knowledge: { type: "array", diff --git a/plugins/pi/neatcontext/skills/save/SKILL.md b/plugins/pi/neatcontext/skills/save/SKILL.md index c7259d9..6050eb7 100644 --- a/plugins/pi/neatcontext/skills/save/SKILL.md +++ b/plugins/pi/neatcontext/skills/save/SKILL.md @@ -80,8 +80,16 @@ 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. +- `routingEntities` — names that belong to the subject and appear rarely + elsewhere: services, components, repositories, ticket ids and prefixes, error + strings, product and system names. + +Both lists travel with the context to anyone it is shared with, so write them as +domain knowledge and nothing else. No absolute paths, no home directories, no +usernames, no personal names, no email addresses, and nothing whose meaning +depends on this machine or this person. If the work genuinely is about a +particular environment, say so in the profile — that is what the profile is for +— and keep these lists to the terms a colleague would recognise. 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 diff --git a/plugins/pi/neatcontext/src/core/context-store.mjs b/plugins/pi/neatcontext/src/core/context-store.mjs index 9fb69f6..c9e76b4 100644 --- a/plugins/pi/neatcontext/src/core/context-store.mjs +++ b/plugins/pi/neatcontext/src/core/context-store.mjs @@ -315,6 +315,22 @@ export async function createContext({ name, knowledgeFolder, profile, extensions // // Optional, unlike the description. A bundle written before this existed, or by // a host that does not generate them, is not broken — it just matches on less. +// Terms that cannot mean the same thing on anyone else's machine, or that name +// a person rather than a subject. +// +// A context is domain knowledge, and it is meant to be handed to a teammate +// intact. A matching list is the easiest place for that to quietly stop being +// true: an absolute path or a home directory looks like a useful rare term +// while it is being written and is worthless — or worse, revealing — the moment +// the bundle leaves the machine. +// +// This drops them from the matching lists only. If a local path really is part +// of what the context is about, the profile and the knowledge folder are where +// it belongs and are untouched by this; being unable to *search* for a context +// by someone's home directory costs nothing worth having. +const NOT_PORTABLE = + /(^|\s)(~[\\/]|[a-z]:[\\/]|\\\\)|[\\/](home|users|root)[\\/]|\S+@\S+\.\S/i; + function normalizeRoutingList(value, limit) { if (!Array.isArray(value)) { return []; @@ -325,7 +341,7 @@ function normalizeRoutingList(value, limit) { if (typeof entry !== "string") continue; const clean = entry.trim().replace(/\s+/g, " ").slice(0, MAX_ROUTING_TERM); const key = clean.toLowerCase(); - if (clean.length === 0 || seen.has(key)) continue; + if (clean.length === 0 || seen.has(key) || NOT_PORTABLE.test(clean)) continue; seen.add(key); kept.push(clean); if (kept.length === limit) break; diff --git a/shared/core/context-store.mjs b/shared/core/context-store.mjs index 9fb69f6..c9e76b4 100644 --- a/shared/core/context-store.mjs +++ b/shared/core/context-store.mjs @@ -315,6 +315,22 @@ export async function createContext({ name, knowledgeFolder, profile, extensions // // Optional, unlike the description. A bundle written before this existed, or by // a host that does not generate them, is not broken — it just matches on less. +// Terms that cannot mean the same thing on anyone else's machine, or that name +// a person rather than a subject. +// +// A context is domain knowledge, and it is meant to be handed to a teammate +// intact. A matching list is the easiest place for that to quietly stop being +// true: an absolute path or a home directory looks like a useful rare term +// while it is being written and is worthless — or worse, revealing — the moment +// the bundle leaves the machine. +// +// This drops them from the matching lists only. If a local path really is part +// of what the context is about, the profile and the knowledge folder are where +// it belongs and are untouched by this; being unable to *search* for a context +// by someone's home directory costs nothing worth having. +const NOT_PORTABLE = + /(^|\s)(~[\\/]|[a-z]:[\\/]|\\\\)|[\\/](home|users|root)[\\/]|\S+@\S+\.\S/i; + function normalizeRoutingList(value, limit) { if (!Array.isArray(value)) { return []; @@ -325,7 +341,7 @@ function normalizeRoutingList(value, limit) { if (typeof entry !== "string") continue; const clean = entry.trim().replace(/\s+/g, " ").slice(0, MAX_ROUTING_TERM); const key = clean.toLowerCase(); - if (clean.length === 0 || seen.has(key)) continue; + if (clean.length === 0 || seen.has(key) || NOT_PORTABLE.test(clean)) continue; seen.add(key); kept.push(clean); if (kept.length === limit) break; diff --git a/tests/routing-expansion-docs.test.mjs b/tests/routing-expansion-docs.test.mjs index c1055c0..1033896 100644 --- a/tests/routing-expansion-docs.test.mjs +++ b/tests/routing-expansion-docs.test.mjs @@ -42,6 +42,24 @@ describe("every host asks for the matching material", () => { assert.match(text, /never shown|matched against and never shown|Matched against, never shown/i); }); + it(`${host} says the lists travel and must stay portable`, async () => { + // The lists go into the bundle, and the bundle is meant to be handed to a + // teammate intact. A host that asks for hostnames or people gets a + // context that is worse to share than the work it came from. + const text = await readFile(path.join(root, file), "utf8"); + assert.match(text, /travels? with the context|travel with the context/i); + assert.match(text, /absolute paths/i); + assert.match(text, /usernames|personal names/i); + }); + + it(`${host} never asks for hostnames or people`, async () => { + // The wording this replaced did, which is how machine- and person- + // specific terms would have reached a shared bundle. + const text = await readFile(path.join(root, file), "utf8"); + assert.ok(!/error strings, commands, hosts/i.test(text), `${file} still asks for hosts`); + assert.ok(!/commands, hosts, people/i.test(text), `${file} still asks for people`); + }); + 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"); diff --git a/tests/routing-expansion.test.mjs b/tests/routing-expansion.test.mjs index 97ef6df..d72c540 100644 --- a/tests/routing-expansion.test.mjs +++ b/tests/routing-expansion.test.mjs @@ -208,3 +208,57 @@ describe("what it buys", () => { assert.ok(!results.some((result) => result.name === "Pool limits June")); }); }); + +// A context is domain knowledge, and it is meant to be handed to a teammate +// intact. The matching lists are the easiest place for that to quietly stop +// being true: an absolute path looks like a useful rare term while it is being +// written, and is worthless — or revealing — the moment the bundle is shared. +describe("keeping the bundle portable", () => { + const withEntities = (routingEntities) => + store.createCapturedContext({ ...INCIDENT, routingEntities }); + + it("drops machine paths that could never mean the same thing elsewhere", async () => { + const { record } = await withEntities([ + "checkout-api", + String.raw`C:\Users\someone\projects\checkout`, + "/home/someone/checkout", + "/Users/someone/checkout", + "~/checkout", + String.raw`\\fileserver\share` + ]); + assert.deepEqual(record.routingEntities, ["checkout-api"]); + }); + + it("drops anything that identifies a person rather than a subject", async () => { + const { record } = await withEntities(["pgbouncer", "someone@example.com"]); + assert.deepEqual(record.routingEntities, ["pgbouncer"]); + }); + + it("applies the same rule to the questions", async () => { + const { record } = await store.createCapturedContext({ + ...INCIDENT, + routingQuestions: ["why did checkout fail", String.raw`what is in C:\Users\someone\logs`] + }); + assert.deepEqual(record.routingQuestions, ["why did checkout fail"]); + }); + + it("keeps ordinary domain terms that merely look technical", async () => { + // The filter must not eat the vocabulary the feature exists to store. + const { record } = await withEntities([ + "checkout-api", + "INC-1001", + "default_pool_size", + "src/core/routing.mjs", + "billing-postgres:5432", + "user-service" + ]); + assert.deepEqual(record.routingEntities, [ + "checkout-api", + "INC-1001", + "default_pool_size", + "src/core/routing.mjs", + "billing-postgres:5432", + "user-service" + ]); + }); +});