diff --git a/docs/KNOWLEDGE_BASE.md b/docs/KNOWLEDGE_BASE.md index 4cdd832..e4cda44 100644 --- a/docs/KNOWLEDGE_BASE.md +++ b/docs/KNOWLEDGE_BASE.md @@ -7,7 +7,7 @@ Operator-subset knowledge packages are wired through **netxops settings**, not i | Surface | Responsibility | |---------|----------------| | `netx-ops` | Live netx evidence (alarms / inventory / CLI) — **pure netx**, no KB playbooks | -| `kb-context` | Identity annotation from MANIFEST (`KB_*`) | +| `kb-context` | MANIFEST annotation (`KB_*`) + dual-plane write rules | | `_skills/kb-*` | Shared operator KB triage / ingest / retrieve / export / package | | `paths.localSkills` | Operator-local skills (alongside `_skills/`; same toggles) | @@ -38,9 +38,9 @@ Authoritative packaging prose lives in the workspace contract notes (`插件` / | Channel | Fields | |---------|--------| | `process.env` | `KB_ROOT`, `KB_LOCAL` (when `paths.local` set), `KB_OPERATOR`, `KB_COUNTRY`, `KB_VERSION`, `KB_CONTENT` (JSON of `has*`), `KB_STATUS` | -| Skill `kb-context` | Same identity + `kbLocal` + local create/update/delete tool names | +| Skill `kb-context` | Same MANIFEST fields + `kbLocal` + dual-plane write rules (refs / memories / drafts / suggestions) | | Skills from pack | Registered when `configured` **and** `hasSkills` **and** the inPreset/public toggle for that plane | -| Local FS tools | When `configured` + `paths.local` + same KB toggles: `netx__kbWriteMemory` / `WriteDraft` / `WriteSuggestion` / `UpdateLocal` / `DeleteLocal` / `ListLocal` — jail under `memories\|drafts\|suggestions` only (`identity/` host persona, agent read-only) | +| Local FS tools | When `configured` + `paths.local` + same KB toggles: `netx__kbWriteRef` / `WriteMemory` / `WriteDraft` / `WriteSuggestion` / `UpdateLocal` / `DeleteLocal` / `ListLocal` — jail under `refs\|memories\|drafts\|suggestions`. HQ pack is read-only; evolve site product knowledge with `kbWriteRef` (per-`host_name` PROFILE) and capture experience with `kbWriteMemory` as it appears. | RPC (channel `/netxops`): `kb.status` (saved snapshot), `kb.resolve` with `{ path }` (preview unsaved paths). diff --git a/docs/TOOL_MAP.md b/docs/TOOL_MAP.md index e84243b..d43c18e 100644 --- a/docs/TOOL_MAP.md +++ b/docs/TOOL_MAP.md @@ -35,13 +35,14 @@ Execution: HTTP `apiUrl` + Bearer `NETX_API_TOKEN`. ## knowledge base → `groupKb*` (not ops/topology) -Registered when KB is configured, MANIFEST has `paths.local`, and the KB in-preset / public toggle is on. Host-side FS writes (bypass workspace sandbox). Jail: `{kbLocal}/memories|drafts|suggestions` only — **not** `identity/`. +Registered when KB is configured, MANIFEST has `paths.local`, and the KB in-preset / public toggle is on. Host-side FS writes (bypass workspace sandbox). Jail: `{kbLocal}/refs|memories|drafts|suggestions`. | Tool | Role | |------|------| -| `kbWriteMemory` | New diary entry under `memories/{日常笔记\|排障复盘\|AI思维链}/` (`overwrite` optional) | +| `kbWriteRef` | Site product KB under `refs/{inventory\|devices\|topology\|business\|commands\|handbooks}/`. `devices` requires `device=host_name`; primary profile slug=`PROFILE`. | +| `kbWriteMemory` | Diary under `memories/{日常笔记\|排障复盘\|AI思维链}/` — write as soon as reusable experience appears. | | `kbWriteDraft` | New `DRAFT-…` under `drafts/` (+ `status: draft`) | | `kbWriteSuggestion` | New file under `suggestions/{theory\|improvement}/` | | `kbUpdateLocal` | Replace body of an existing jailed file | | `kbDeleteLocal` | Delete one jailed file | -| `kbListLocal` | List recent `.md` under writable trees | +| `kbListLocal` | List recent `.md` under writable trees (`root` may be `refs`) | diff --git a/lib/agent-tools-common.js b/lib/agent-tools-common.js index 4b52ae1..ff7357f 100644 --- a/lib/agent-tools-common.js +++ b/lib/agent-tools-common.js @@ -221,7 +221,16 @@ import { join as join2, relative, resolve, sep } from "node:path"; var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([ "memories", "drafts", - "suggestions" + "suggestions", + "refs" +]); +var REF_AREAS = Object.freeze([ + "inventory", + "devices", + "topology", + "business", + "commands", + "handbooks" ]); var MEMORY_BUCKET_DIR = Object.freeze({ note: "日常笔记", @@ -281,6 +290,10 @@ function suggestionFileName(opts) { const date = normalizeIsoDate(opts.date, opts.now); return `${date}-${sanitizeSlug(opts.slug)}.md`; } +function refFileName(slug) { + const base = sanitizeSlug(slug, "note"); + return base.toLowerCase().endsWith(".md") ? base : `${base}.md`; +} function resolveWritableLocalPath(localRoot, ...segments) { if (segments.some((s) => s.includes("\x00"))) { throw new Error("path segment contains NUL"); @@ -354,6 +367,19 @@ function draftDir(domain) { return "drafts"; return join2("drafts", sanitizeSlug(d, "misc")); } +function refDir(area, device) { + if (!REF_AREAS.includes(area)) { + throw new Error(`invalid refs area: ${area}`); + } + if (area === "devices") { + const host = sanitizeSlug(device?.trim() || "", ""); + if (!host) { + throw new Error("refs/devices requires device (host_name)"); + } + return join2("refs", "devices", host); + } + return join2("refs", area); +} // src/netx/kb-context-skill.ts var SKILL_NAME = "kb-context"; @@ -364,18 +390,29 @@ function skillBody(snapshot) { const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |"; const localGuide = localRoot ? [ "", - "**Site-writable (`kbLocal`)**: use host tools `netx__kbWriteMemory`, `netx__kbWriteDraft`,", - "`netx__kbWriteSuggestion`, `netx__kbUpdateLocal`, `netx__kbDeleteLocal`, `netx__kbListLocal`.", - "They jail writes to `memories/` / `drafts/` / `suggestions/` only.", - "Do **not** use workspace Write/bash for KB paths (sandbox).", - "`identity/` is persona/policy for the host — **read-only for the agent**; never rewrite it;", - "file boundary issues as `suggestions/` instead." + "### Dual plane (mandatory)", + "", + "- **HQ pack = read-only** (formal RCA, theory, packet, `_common`, `_skills`, shared cmdLib).", + `- **Writable evolution core**: \`${localRoot}\` → \`refs/\` | \`memories/\` | \`drafts/\` | \`suggestions/\`.`, + "- **All KB writes via host tools** (workspace Write/bash cannot reach KB):", + " `netx__kbWriteRef` / `netx__kbWriteMemory` / `netx__kbWriteDraft` / `netx__kbWriteSuggestion` /", + " `netx__kbUpdateLocal` / `netx__kbDeleteLocal` / `netx__kbListLocal`.", + "", + "### refs — site product knowledge (evolve every task)", + "- On any NE: **read** `refs/devices//` first; if missing, prove with netx then `kbWriteRef` to create/update.", + "- `area=devices` requires `device=` (never UUID); primary file slug=`PROFILE`.", + "- Also: `inventory` (ledger), `topology`, `business`, `commands` (site-only), `handbooks`.", + "- Device facts → refs; episodic experience → memories (do not dump ledgers into diaries).", + "", + "### memories — write immediately when valuable", + "- `bucket=note` | `rca_review` | `ai_trace` — short beats lost.", + "- Open cases → `kbWriteDraft`. HQ pack gaps → `kbWriteSuggestion`." ] : [ "", "No local write tools until `paths.local` is present in MANIFEST." ]; return { - description: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).", + description: "Operator knowledge-base context for this Host (HQ pack + local evolution under paths.local).", content: [ "## Knowledge base (configured)", "", @@ -543,6 +580,25 @@ function createSuggestion(snapshot, args) { relativePath: target.relativePath }; } +function createRef(snapshot, args) { + const localRoot = requireLocalRoot(snapshot); + const name = refFileName(args.slug); + const target = resolveWritableLocalPath(localRoot, refDir(args.area, args.device), name); + if (existsSync3(target.absolutePath) && !args.overwrite) { + throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); + } + mkdirSync(dirname2(target.absolutePath), { recursive: true }); + const existed = existsSync3(target.absolutePath); + writeFileSync(target.absolutePath, args.body.endsWith(` +`) ? args.body : `${args.body} +`, "utf8"); + return { + ok: true, + action: existed ? "updated" : "created", + absolutePath: target.absolutePath, + relativePath: target.relativePath + }; +} function updateLocalFile(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const target = resolveExistingWritableFile(localRoot, args.path); @@ -730,7 +786,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { date: args.date != null ? String(args.date) : undefined, overwrite: args.overwrite === true })), - tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for identity/ or formal RCA.", { + tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for formal RCA.", { slug: reqStr("Case stem (DRAFT- prefix added if missing)"), body: reqStr("Markdown body (RCA-ish draft)"), domain: str("Optional fault-domain subfolder under drafts/"), @@ -743,7 +799,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { date: args.date != null ? String(args.date) : undefined, overwrite: args.overwrite === true })), - tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for theory corrections or tool/process improvements — never rewrite identity/.", { + tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for HQ pack/theory corrections or tool/process improvements.", { kind: { type: "string", required: true, @@ -761,22 +817,40 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { date: args.date != null ? String(args.date) : undefined, overwrite: args.overwrite === true })), - tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions ` + `(absolute path or path relative to ${localRoot}). ` + "Cannot touch identity/ or outside paths.local. Drafts keep status: draft.", { + tool("netx__kbWriteRef", `Create (or overwrite) site product-knowledge under ${localRoot}/refs/. ` + "area=inventory|devices|topology|business|commands|handbooks. " + "For devices: require device=host_name; default slug PROFILE for the ops profile. " + "Use for NE ledger, per-device profiles, topology/business notes, site command books — not diaries.", { + area: { + type: "string", + required: true, + enum: ["inventory", "devices", "topology", "business", "commands", "handbooks"], + description: "refs/ subtree" + }, + slug: reqStr("Filename stem (e.g. PROFILE, neighbors, ledger)"), + body: reqStr("Full markdown body"), + device: str("Required when area=devices — host_name (never UUID)"), + overwrite: bool("Replace if the target file already exists") + }, (args) => createRef(liveSnapshot(), { + area: args.area, + slug: String(args.slug ?? ""), + body: String(args.body ?? ""), + device: args.device != null ? String(args.device) : undefined, + overwrite: args.overwrite === true + })), + tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions|refs ` + `(absolute path or path relative to ${localRoot}). ` + "Drafts keep status: draft.", { path: reqStr("Absolute path or path relative to KB local root"), body: reqStr("New full markdown body") }, (args) => updateLocalFile(liveSnapshot(), { path: String(args.path ?? ""), body: String(args.body ?? "") })), - tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions ` + `(absolute or relative to ${localRoot}). Refuses identity/ and escapes.`, { + tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions|refs ` + `(absolute or relative to ${localRoot}).`, { path: reqStr("Absolute path or path relative to KB local root") }, (args) => deleteLocalFile(liveSnapshot(), { path: String(args.path ?? "") })), - tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions (newest first). ` + "Does not include identity/. Read contents via absolute paths from the listing.", { + tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions|refs (newest first). ` + "Read contents via absolute paths from the listing.", { root: { type: "string", - enum: ["memories", "drafts", "suggestions", "all"], + enum: ["memories", "drafts", "suggestions", "refs", "all"], description: "Subtree to list (default all writable)" }, limit: num("Max entries 1–200 (default 50)") diff --git a/lib/agent-tools-nms.js b/lib/agent-tools-nms.js index 39829c8..3fc88b0 100644 --- a/lib/agent-tools-nms.js +++ b/lib/agent-tools-nms.js @@ -221,7 +221,16 @@ import { join as join2, relative, resolve, sep } from "node:path"; var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([ "memories", "drafts", - "suggestions" + "suggestions", + "refs" +]); +var REF_AREAS = Object.freeze([ + "inventory", + "devices", + "topology", + "business", + "commands", + "handbooks" ]); var MEMORY_BUCKET_DIR = Object.freeze({ note: "日常笔记", @@ -281,6 +290,10 @@ function suggestionFileName(opts) { const date = normalizeIsoDate(opts.date, opts.now); return `${date}-${sanitizeSlug(opts.slug)}.md`; } +function refFileName(slug) { + const base = sanitizeSlug(slug, "note"); + return base.toLowerCase().endsWith(".md") ? base : `${base}.md`; +} function resolveWritableLocalPath(localRoot, ...segments) { if (segments.some((s) => s.includes("\x00"))) { throw new Error("path segment contains NUL"); @@ -354,6 +367,19 @@ function draftDir(domain) { return "drafts"; return join2("drafts", sanitizeSlug(d, "misc")); } +function refDir(area, device) { + if (!REF_AREAS.includes(area)) { + throw new Error(`invalid refs area: ${area}`); + } + if (area === "devices") { + const host = sanitizeSlug(device?.trim() || "", ""); + if (!host) { + throw new Error("refs/devices requires device (host_name)"); + } + return join2("refs", "devices", host); + } + return join2("refs", area); +} // src/netx/kb-context-skill.ts var SKILL_NAME = "kb-context"; @@ -364,18 +390,29 @@ function skillBody(snapshot) { const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |"; const localGuide = localRoot ? [ "", - "**Site-writable (`kbLocal`)**: use host tools `netx__kbWriteMemory`, `netx__kbWriteDraft`,", - "`netx__kbWriteSuggestion`, `netx__kbUpdateLocal`, `netx__kbDeleteLocal`, `netx__kbListLocal`.", - "They jail writes to `memories/` / `drafts/` / `suggestions/` only.", - "Do **not** use workspace Write/bash for KB paths (sandbox).", - "`identity/` is persona/policy for the host — **read-only for the agent**; never rewrite it;", - "file boundary issues as `suggestions/` instead." + "### Dual plane (mandatory)", + "", + "- **HQ pack = read-only** (formal RCA, theory, packet, `_common`, `_skills`, shared cmdLib).", + `- **Writable evolution core**: \`${localRoot}\` → \`refs/\` | \`memories/\` | \`drafts/\` | \`suggestions/\`.`, + "- **All KB writes via host tools** (workspace Write/bash cannot reach KB):", + " `netx__kbWriteRef` / `netx__kbWriteMemory` / `netx__kbWriteDraft` / `netx__kbWriteSuggestion` /", + " `netx__kbUpdateLocal` / `netx__kbDeleteLocal` / `netx__kbListLocal`.", + "", + "### refs — site product knowledge (evolve every task)", + "- On any NE: **read** `refs/devices//` first; if missing, prove with netx then `kbWriteRef` to create/update.", + "- `area=devices` requires `device=` (never UUID); primary file slug=`PROFILE`.", + "- Also: `inventory` (ledger), `topology`, `business`, `commands` (site-only), `handbooks`.", + "- Device facts → refs; episodic experience → memories (do not dump ledgers into diaries).", + "", + "### memories — write immediately when valuable", + "- `bucket=note` | `rca_review` | `ai_trace` — short beats lost.", + "- Open cases → `kbWriteDraft`. HQ pack gaps → `kbWriteSuggestion`." ] : [ "", "No local write tools until `paths.local` is present in MANIFEST." ]; return { - description: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).", + description: "Operator knowledge-base context for this Host (HQ pack + local evolution under paths.local).", content: [ "## Knowledge base (configured)", "", @@ -543,6 +580,25 @@ function createSuggestion(snapshot, args) { relativePath: target.relativePath }; } +function createRef(snapshot, args) { + const localRoot = requireLocalRoot(snapshot); + const name = refFileName(args.slug); + const target = resolveWritableLocalPath(localRoot, refDir(args.area, args.device), name); + if (existsSync3(target.absolutePath) && !args.overwrite) { + throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); + } + mkdirSync(dirname2(target.absolutePath), { recursive: true }); + const existed = existsSync3(target.absolutePath); + writeFileSync(target.absolutePath, args.body.endsWith(` +`) ? args.body : `${args.body} +`, "utf8"); + return { + ok: true, + action: existed ? "updated" : "created", + absolutePath: target.absolutePath, + relativePath: target.relativePath + }; +} function updateLocalFile(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const target = resolveExistingWritableFile(localRoot, args.path); @@ -730,7 +786,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { date: args.date != null ? String(args.date) : undefined, overwrite: args.overwrite === true })), - tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for identity/ or formal RCA.", { + tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for formal RCA.", { slug: reqStr("Case stem (DRAFT- prefix added if missing)"), body: reqStr("Markdown body (RCA-ish draft)"), domain: str("Optional fault-domain subfolder under drafts/"), @@ -743,7 +799,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { date: args.date != null ? String(args.date) : undefined, overwrite: args.overwrite === true })), - tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for theory corrections or tool/process improvements — never rewrite identity/.", { + tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for HQ pack/theory corrections or tool/process improvements.", { kind: { type: "string", required: true, @@ -761,22 +817,40 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { date: args.date != null ? String(args.date) : undefined, overwrite: args.overwrite === true })), - tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions ` + `(absolute path or path relative to ${localRoot}). ` + "Cannot touch identity/ or outside paths.local. Drafts keep status: draft.", { + tool("netx__kbWriteRef", `Create (or overwrite) site product-knowledge under ${localRoot}/refs/. ` + "area=inventory|devices|topology|business|commands|handbooks. " + "For devices: require device=host_name; default slug PROFILE for the ops profile. " + "Use for NE ledger, per-device profiles, topology/business notes, site command books — not diaries.", { + area: { + type: "string", + required: true, + enum: ["inventory", "devices", "topology", "business", "commands", "handbooks"], + description: "refs/ subtree" + }, + slug: reqStr("Filename stem (e.g. PROFILE, neighbors, ledger)"), + body: reqStr("Full markdown body"), + device: str("Required when area=devices — host_name (never UUID)"), + overwrite: bool("Replace if the target file already exists") + }, (args) => createRef(liveSnapshot(), { + area: args.area, + slug: String(args.slug ?? ""), + body: String(args.body ?? ""), + device: args.device != null ? String(args.device) : undefined, + overwrite: args.overwrite === true + })), + tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions|refs ` + `(absolute path or path relative to ${localRoot}). ` + "Drafts keep status: draft.", { path: reqStr("Absolute path or path relative to KB local root"), body: reqStr("New full markdown body") }, (args) => updateLocalFile(liveSnapshot(), { path: String(args.path ?? ""), body: String(args.body ?? "") })), - tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions ` + `(absolute or relative to ${localRoot}). Refuses identity/ and escapes.`, { + tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions|refs ` + `(absolute or relative to ${localRoot}).`, { path: reqStr("Absolute path or path relative to KB local root") }, (args) => deleteLocalFile(liveSnapshot(), { path: String(args.path ?? "") })), - tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions (newest first). ` + "Does not include identity/. Read contents via absolute paths from the listing.", { + tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions|refs (newest first). ` + "Read contents via absolute paths from the listing.", { root: { type: "string", - enum: ["memories", "drafts", "suggestions", "all"], + enum: ["memories", "drafts", "suggestions", "refs", "all"], description: "Subtree to list (default all writable)" }, limit: num("Max entries 1–200 (default 50)") diff --git a/lib/agent-tools-ops.js b/lib/agent-tools-ops.js index 72b1002..0a9adad 100644 --- a/lib/agent-tools-ops.js +++ b/lib/agent-tools-ops.js @@ -221,7 +221,16 @@ import { join as join2, relative, resolve, sep } from "node:path"; var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([ "memories", "drafts", - "suggestions" + "suggestions", + "refs" +]); +var REF_AREAS = Object.freeze([ + "inventory", + "devices", + "topology", + "business", + "commands", + "handbooks" ]); var MEMORY_BUCKET_DIR = Object.freeze({ note: "日常笔记", @@ -281,6 +290,10 @@ function suggestionFileName(opts) { const date = normalizeIsoDate(opts.date, opts.now); return `${date}-${sanitizeSlug(opts.slug)}.md`; } +function refFileName(slug) { + const base = sanitizeSlug(slug, "note"); + return base.toLowerCase().endsWith(".md") ? base : `${base}.md`; +} function resolveWritableLocalPath(localRoot, ...segments) { if (segments.some((s) => s.includes("\x00"))) { throw new Error("path segment contains NUL"); @@ -354,6 +367,19 @@ function draftDir(domain) { return "drafts"; return join2("drafts", sanitizeSlug(d, "misc")); } +function refDir(area, device) { + if (!REF_AREAS.includes(area)) { + throw new Error(`invalid refs area: ${area}`); + } + if (area === "devices") { + const host = sanitizeSlug(device?.trim() || "", ""); + if (!host) { + throw new Error("refs/devices requires device (host_name)"); + } + return join2("refs", "devices", host); + } + return join2("refs", area); +} // src/netx/kb-context-skill.ts var SKILL_NAME = "kb-context"; @@ -364,18 +390,29 @@ function skillBody(snapshot) { const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |"; const localGuide = localRoot ? [ "", - "**Site-writable (`kbLocal`)**: use host tools `netx__kbWriteMemory`, `netx__kbWriteDraft`,", - "`netx__kbWriteSuggestion`, `netx__kbUpdateLocal`, `netx__kbDeleteLocal`, `netx__kbListLocal`.", - "They jail writes to `memories/` / `drafts/` / `suggestions/` only.", - "Do **not** use workspace Write/bash for KB paths (sandbox).", - "`identity/` is persona/policy for the host — **read-only for the agent**; never rewrite it;", - "file boundary issues as `suggestions/` instead." + "### Dual plane (mandatory)", + "", + "- **HQ pack = read-only** (formal RCA, theory, packet, `_common`, `_skills`, shared cmdLib).", + `- **Writable evolution core**: \`${localRoot}\` → \`refs/\` | \`memories/\` | \`drafts/\` | \`suggestions/\`.`, + "- **All KB writes via host tools** (workspace Write/bash cannot reach KB):", + " `netx__kbWriteRef` / `netx__kbWriteMemory` / `netx__kbWriteDraft` / `netx__kbWriteSuggestion` /", + " `netx__kbUpdateLocal` / `netx__kbDeleteLocal` / `netx__kbListLocal`.", + "", + "### refs — site product knowledge (evolve every task)", + "- On any NE: **read** `refs/devices//` first; if missing, prove with netx then `kbWriteRef` to create/update.", + "- `area=devices` requires `device=` (never UUID); primary file slug=`PROFILE`.", + "- Also: `inventory` (ledger), `topology`, `business`, `commands` (site-only), `handbooks`.", + "- Device facts → refs; episodic experience → memories (do not dump ledgers into diaries).", + "", + "### memories — write immediately when valuable", + "- `bucket=note` | `rca_review` | `ai_trace` — short beats lost.", + "- Open cases → `kbWriteDraft`. HQ pack gaps → `kbWriteSuggestion`." ] : [ "", "No local write tools until `paths.local` is present in MANIFEST." ]; return { - description: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).", + description: "Operator knowledge-base context for this Host (HQ pack + local evolution under paths.local).", content: [ "## Knowledge base (configured)", "", @@ -543,6 +580,25 @@ function createSuggestion(snapshot, args) { relativePath: target.relativePath }; } +function createRef(snapshot, args) { + const localRoot = requireLocalRoot(snapshot); + const name = refFileName(args.slug); + const target = resolveWritableLocalPath(localRoot, refDir(args.area, args.device), name); + if (existsSync3(target.absolutePath) && !args.overwrite) { + throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); + } + mkdirSync(dirname2(target.absolutePath), { recursive: true }); + const existed = existsSync3(target.absolutePath); + writeFileSync(target.absolutePath, args.body.endsWith(` +`) ? args.body : `${args.body} +`, "utf8"); + return { + ok: true, + action: existed ? "updated" : "created", + absolutePath: target.absolutePath, + relativePath: target.relativePath + }; +} function updateLocalFile(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const target = resolveExistingWritableFile(localRoot, args.path); @@ -730,7 +786,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { date: args.date != null ? String(args.date) : undefined, overwrite: args.overwrite === true })), - tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for identity/ or formal RCA.", { + tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for formal RCA.", { slug: reqStr("Case stem (DRAFT- prefix added if missing)"), body: reqStr("Markdown body (RCA-ish draft)"), domain: str("Optional fault-domain subfolder under drafts/"), @@ -743,7 +799,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { date: args.date != null ? String(args.date) : undefined, overwrite: args.overwrite === true })), - tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for theory corrections or tool/process improvements — never rewrite identity/.", { + tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for HQ pack/theory corrections or tool/process improvements.", { kind: { type: "string", required: true, @@ -761,22 +817,40 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { date: args.date != null ? String(args.date) : undefined, overwrite: args.overwrite === true })), - tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions ` + `(absolute path or path relative to ${localRoot}). ` + "Cannot touch identity/ or outside paths.local. Drafts keep status: draft.", { + tool("netx__kbWriteRef", `Create (or overwrite) site product-knowledge under ${localRoot}/refs/. ` + "area=inventory|devices|topology|business|commands|handbooks. " + "For devices: require device=host_name; default slug PROFILE for the ops profile. " + "Use for NE ledger, per-device profiles, topology/business notes, site command books — not diaries.", { + area: { + type: "string", + required: true, + enum: ["inventory", "devices", "topology", "business", "commands", "handbooks"], + description: "refs/ subtree" + }, + slug: reqStr("Filename stem (e.g. PROFILE, neighbors, ledger)"), + body: reqStr("Full markdown body"), + device: str("Required when area=devices — host_name (never UUID)"), + overwrite: bool("Replace if the target file already exists") + }, (args) => createRef(liveSnapshot(), { + area: args.area, + slug: String(args.slug ?? ""), + body: String(args.body ?? ""), + device: args.device != null ? String(args.device) : undefined, + overwrite: args.overwrite === true + })), + tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions|refs ` + `(absolute path or path relative to ${localRoot}). ` + "Drafts keep status: draft.", { path: reqStr("Absolute path or path relative to KB local root"), body: reqStr("New full markdown body") }, (args) => updateLocalFile(liveSnapshot(), { path: String(args.path ?? ""), body: String(args.body ?? "") })), - tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions ` + `(absolute or relative to ${localRoot}). Refuses identity/ and escapes.`, { + tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions|refs ` + `(absolute or relative to ${localRoot}).`, { path: reqStr("Absolute path or path relative to KB local root") }, (args) => deleteLocalFile(liveSnapshot(), { path: String(args.path ?? "") })), - tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions (newest first). ` + "Does not include identity/. Read contents via absolute paths from the listing.", { + tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions|refs (newest first). ` + "Read contents via absolute paths from the listing.", { root: { type: "string", - enum: ["memories", "drafts", "suggestions", "all"], + enum: ["memories", "drafts", "suggestions", "refs", "all"], description: "Subtree to list (default all writable)" }, limit: num("Max entries 1–200 (default 50)") diff --git a/lib/agent-tools-topology.js b/lib/agent-tools-topology.js index c9d1803..dad42ed 100644 --- a/lib/agent-tools-topology.js +++ b/lib/agent-tools-topology.js @@ -221,7 +221,16 @@ import { join as join2, relative, resolve, sep } from "node:path"; var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([ "memories", "drafts", - "suggestions" + "suggestions", + "refs" +]); +var REF_AREAS = Object.freeze([ + "inventory", + "devices", + "topology", + "business", + "commands", + "handbooks" ]); var MEMORY_BUCKET_DIR = Object.freeze({ note: "日常笔记", @@ -281,6 +290,10 @@ function suggestionFileName(opts) { const date = normalizeIsoDate(opts.date, opts.now); return `${date}-${sanitizeSlug(opts.slug)}.md`; } +function refFileName(slug) { + const base = sanitizeSlug(slug, "note"); + return base.toLowerCase().endsWith(".md") ? base : `${base}.md`; +} function resolveWritableLocalPath(localRoot, ...segments) { if (segments.some((s) => s.includes("\x00"))) { throw new Error("path segment contains NUL"); @@ -354,6 +367,19 @@ function draftDir(domain) { return "drafts"; return join2("drafts", sanitizeSlug(d, "misc")); } +function refDir(area, device) { + if (!REF_AREAS.includes(area)) { + throw new Error(`invalid refs area: ${area}`); + } + if (area === "devices") { + const host = sanitizeSlug(device?.trim() || "", ""); + if (!host) { + throw new Error("refs/devices requires device (host_name)"); + } + return join2("refs", "devices", host); + } + return join2("refs", area); +} // src/netx/kb-context-skill.ts var SKILL_NAME = "kb-context"; @@ -364,18 +390,29 @@ function skillBody(snapshot) { const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |"; const localGuide = localRoot ? [ "", - "**Site-writable (`kbLocal`)**: use host tools `netx__kbWriteMemory`, `netx__kbWriteDraft`,", - "`netx__kbWriteSuggestion`, `netx__kbUpdateLocal`, `netx__kbDeleteLocal`, `netx__kbListLocal`.", - "They jail writes to `memories/` / `drafts/` / `suggestions/` only.", - "Do **not** use workspace Write/bash for KB paths (sandbox).", - "`identity/` is persona/policy for the host — **read-only for the agent**; never rewrite it;", - "file boundary issues as `suggestions/` instead." + "### Dual plane (mandatory)", + "", + "- **HQ pack = read-only** (formal RCA, theory, packet, `_common`, `_skills`, shared cmdLib).", + `- **Writable evolution core**: \`${localRoot}\` → \`refs/\` | \`memories/\` | \`drafts/\` | \`suggestions/\`.`, + "- **All KB writes via host tools** (workspace Write/bash cannot reach KB):", + " `netx__kbWriteRef` / `netx__kbWriteMemory` / `netx__kbWriteDraft` / `netx__kbWriteSuggestion` /", + " `netx__kbUpdateLocal` / `netx__kbDeleteLocal` / `netx__kbListLocal`.", + "", + "### refs — site product knowledge (evolve every task)", + "- On any NE: **read** `refs/devices//` first; if missing, prove with netx then `kbWriteRef` to create/update.", + "- `area=devices` requires `device=` (never UUID); primary file slug=`PROFILE`.", + "- Also: `inventory` (ledger), `topology`, `business`, `commands` (site-only), `handbooks`.", + "- Device facts → refs; episodic experience → memories (do not dump ledgers into diaries).", + "", + "### memories — write immediately when valuable", + "- `bucket=note` | `rca_review` | `ai_trace` — short beats lost.", + "- Open cases → `kbWriteDraft`. HQ pack gaps → `kbWriteSuggestion`." ] : [ "", "No local write tools until `paths.local` is present in MANIFEST." ]; return { - description: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).", + description: "Operator knowledge-base context for this Host (HQ pack + local evolution under paths.local).", content: [ "## Knowledge base (configured)", "", @@ -543,6 +580,25 @@ function createSuggestion(snapshot, args) { relativePath: target.relativePath }; } +function createRef(snapshot, args) { + const localRoot = requireLocalRoot(snapshot); + const name = refFileName(args.slug); + const target = resolveWritableLocalPath(localRoot, refDir(args.area, args.device), name); + if (existsSync3(target.absolutePath) && !args.overwrite) { + throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); + } + mkdirSync(dirname2(target.absolutePath), { recursive: true }); + const existed = existsSync3(target.absolutePath); + writeFileSync(target.absolutePath, args.body.endsWith(` +`) ? args.body : `${args.body} +`, "utf8"); + return { + ok: true, + action: existed ? "updated" : "created", + absolutePath: target.absolutePath, + relativePath: target.relativePath + }; +} function updateLocalFile(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const target = resolveExistingWritableFile(localRoot, args.path); @@ -730,7 +786,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { date: args.date != null ? String(args.date) : undefined, overwrite: args.overwrite === true })), - tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for identity/ or formal RCA.", { + tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for formal RCA.", { slug: reqStr("Case stem (DRAFT- prefix added if missing)"), body: reqStr("Markdown body (RCA-ish draft)"), domain: str("Optional fault-domain subfolder under drafts/"), @@ -743,7 +799,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { date: args.date != null ? String(args.date) : undefined, overwrite: args.overwrite === true })), - tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for theory corrections or tool/process improvements — never rewrite identity/.", { + tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for HQ pack/theory corrections or tool/process improvements.", { kind: { type: "string", required: true, @@ -761,22 +817,40 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { date: args.date != null ? String(args.date) : undefined, overwrite: args.overwrite === true })), - tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions ` + `(absolute path or path relative to ${localRoot}). ` + "Cannot touch identity/ or outside paths.local. Drafts keep status: draft.", { + tool("netx__kbWriteRef", `Create (or overwrite) site product-knowledge under ${localRoot}/refs/. ` + "area=inventory|devices|topology|business|commands|handbooks. " + "For devices: require device=host_name; default slug PROFILE for the ops profile. " + "Use for NE ledger, per-device profiles, topology/business notes, site command books — not diaries.", { + area: { + type: "string", + required: true, + enum: ["inventory", "devices", "topology", "business", "commands", "handbooks"], + description: "refs/ subtree" + }, + slug: reqStr("Filename stem (e.g. PROFILE, neighbors, ledger)"), + body: reqStr("Full markdown body"), + device: str("Required when area=devices — host_name (never UUID)"), + overwrite: bool("Replace if the target file already exists") + }, (args) => createRef(liveSnapshot(), { + area: args.area, + slug: String(args.slug ?? ""), + body: String(args.body ?? ""), + device: args.device != null ? String(args.device) : undefined, + overwrite: args.overwrite === true + })), + tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions|refs ` + `(absolute path or path relative to ${localRoot}). ` + "Drafts keep status: draft.", { path: reqStr("Absolute path or path relative to KB local root"), body: reqStr("New full markdown body") }, (args) => updateLocalFile(liveSnapshot(), { path: String(args.path ?? ""), body: String(args.body ?? "") })), - tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions ` + `(absolute or relative to ${localRoot}). Refuses identity/ and escapes.`, { + tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions|refs ` + `(absolute or relative to ${localRoot}).`, { path: reqStr("Absolute path or path relative to KB local root") }, (args) => deleteLocalFile(liveSnapshot(), { path: String(args.path ?? "") })), - tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions (newest first). ` + "Does not include identity/. Read contents via absolute paths from the listing.", { + tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions|refs (newest first). ` + "Read contents via absolute paths from the listing.", { root: { type: "string", - enum: ["memories", "drafts", "suggestions", "all"], + enum: ["memories", "drafts", "suggestions", "refs", "all"], description: "Subtree to list (default all writable)" }, limit: num("Max entries 1–200 (default 50)") diff --git a/lib/agent-tools.js b/lib/agent-tools.js index 6faf0f6..d79da17 100644 --- a/lib/agent-tools.js +++ b/lib/agent-tools.js @@ -221,7 +221,16 @@ import { join as join2, relative, resolve, sep } from "node:path"; var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([ "memories", "drafts", - "suggestions" + "suggestions", + "refs" +]); +var REF_AREAS = Object.freeze([ + "inventory", + "devices", + "topology", + "business", + "commands", + "handbooks" ]); var MEMORY_BUCKET_DIR = Object.freeze({ note: "日常笔记", @@ -281,6 +290,10 @@ function suggestionFileName(opts) { const date = normalizeIsoDate(opts.date, opts.now); return `${date}-${sanitizeSlug(opts.slug)}.md`; } +function refFileName(slug) { + const base = sanitizeSlug(slug, "note"); + return base.toLowerCase().endsWith(".md") ? base : `${base}.md`; +} function resolveWritableLocalPath(localRoot, ...segments) { if (segments.some((s) => s.includes("\x00"))) { throw new Error("path segment contains NUL"); @@ -354,6 +367,19 @@ function draftDir(domain) { return "drafts"; return join2("drafts", sanitizeSlug(d, "misc")); } +function refDir(area, device) { + if (!REF_AREAS.includes(area)) { + throw new Error(`invalid refs area: ${area}`); + } + if (area === "devices") { + const host = sanitizeSlug(device?.trim() || "", ""); + if (!host) { + throw new Error("refs/devices requires device (host_name)"); + } + return join2("refs", "devices", host); + } + return join2("refs", area); +} // src/netx/kb-context-skill.ts var SKILL_NAME = "kb-context"; @@ -364,18 +390,29 @@ function skillBody(snapshot) { const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |"; const localGuide = localRoot ? [ "", - "**Site-writable (`kbLocal`)**: use host tools `netx__kbWriteMemory`, `netx__kbWriteDraft`,", - "`netx__kbWriteSuggestion`, `netx__kbUpdateLocal`, `netx__kbDeleteLocal`, `netx__kbListLocal`.", - "They jail writes to `memories/` / `drafts/` / `suggestions/` only.", - "Do **not** use workspace Write/bash for KB paths (sandbox).", - "`identity/` is persona/policy for the host — **read-only for the agent**; never rewrite it;", - "file boundary issues as `suggestions/` instead." + "### Dual plane (mandatory)", + "", + "- **HQ pack = read-only** (formal RCA, theory, packet, `_common`, `_skills`, shared cmdLib).", + `- **Writable evolution core**: \`${localRoot}\` → \`refs/\` | \`memories/\` | \`drafts/\` | \`suggestions/\`.`, + "- **All KB writes via host tools** (workspace Write/bash cannot reach KB):", + " `netx__kbWriteRef` / `netx__kbWriteMemory` / `netx__kbWriteDraft` / `netx__kbWriteSuggestion` /", + " `netx__kbUpdateLocal` / `netx__kbDeleteLocal` / `netx__kbListLocal`.", + "", + "### refs — site product knowledge (evolve every task)", + "- On any NE: **read** `refs/devices//` first; if missing, prove with netx then `kbWriteRef` to create/update.", + "- `area=devices` requires `device=` (never UUID); primary file slug=`PROFILE`.", + "- Also: `inventory` (ledger), `topology`, `business`, `commands` (site-only), `handbooks`.", + "- Device facts → refs; episodic experience → memories (do not dump ledgers into diaries).", + "", + "### memories — write immediately when valuable", + "- `bucket=note` | `rca_review` | `ai_trace` — short beats lost.", + "- Open cases → `kbWriteDraft`. HQ pack gaps → `kbWriteSuggestion`." ] : [ "", "No local write tools until `paths.local` is present in MANIFEST." ]; return { - description: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).", + description: "Operator knowledge-base context for this Host (HQ pack + local evolution under paths.local).", content: [ "## Knowledge base (configured)", "", @@ -543,6 +580,25 @@ function createSuggestion(snapshot, args) { relativePath: target.relativePath }; } +function createRef(snapshot, args) { + const localRoot = requireLocalRoot(snapshot); + const name = refFileName(args.slug); + const target = resolveWritableLocalPath(localRoot, refDir(args.area, args.device), name); + if (existsSync3(target.absolutePath) && !args.overwrite) { + throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); + } + mkdirSync(dirname2(target.absolutePath), { recursive: true }); + const existed = existsSync3(target.absolutePath); + writeFileSync(target.absolutePath, args.body.endsWith(` +`) ? args.body : `${args.body} +`, "utf8"); + return { + ok: true, + action: existed ? "updated" : "created", + absolutePath: target.absolutePath, + relativePath: target.relativePath + }; +} function updateLocalFile(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const target = resolveExistingWritableFile(localRoot, args.path); @@ -730,7 +786,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { date: args.date != null ? String(args.date) : undefined, overwrite: args.overwrite === true })), - tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for identity/ or formal RCA.", { + tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for formal RCA.", { slug: reqStr("Case stem (DRAFT- prefix added if missing)"), body: reqStr("Markdown body (RCA-ish draft)"), domain: str("Optional fault-domain subfolder under drafts/"), @@ -743,7 +799,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { date: args.date != null ? String(args.date) : undefined, overwrite: args.overwrite === true })), - tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for theory corrections or tool/process improvements — never rewrite identity/.", { + tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for HQ pack/theory corrections or tool/process improvements.", { kind: { type: "string", required: true, @@ -761,22 +817,40 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { date: args.date != null ? String(args.date) : undefined, overwrite: args.overwrite === true })), - tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions ` + `(absolute path or path relative to ${localRoot}). ` + "Cannot touch identity/ or outside paths.local. Drafts keep status: draft.", { + tool("netx__kbWriteRef", `Create (or overwrite) site product-knowledge under ${localRoot}/refs/. ` + "area=inventory|devices|topology|business|commands|handbooks. " + "For devices: require device=host_name; default slug PROFILE for the ops profile. " + "Use for NE ledger, per-device profiles, topology/business notes, site command books — not diaries.", { + area: { + type: "string", + required: true, + enum: ["inventory", "devices", "topology", "business", "commands", "handbooks"], + description: "refs/ subtree" + }, + slug: reqStr("Filename stem (e.g. PROFILE, neighbors, ledger)"), + body: reqStr("Full markdown body"), + device: str("Required when area=devices — host_name (never UUID)"), + overwrite: bool("Replace if the target file already exists") + }, (args) => createRef(liveSnapshot(), { + area: args.area, + slug: String(args.slug ?? ""), + body: String(args.body ?? ""), + device: args.device != null ? String(args.device) : undefined, + overwrite: args.overwrite === true + })), + tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions|refs ` + `(absolute path or path relative to ${localRoot}). ` + "Drafts keep status: draft.", { path: reqStr("Absolute path or path relative to KB local root"), body: reqStr("New full markdown body") }, (args) => updateLocalFile(liveSnapshot(), { path: String(args.path ?? ""), body: String(args.body ?? "") })), - tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions ` + `(absolute or relative to ${localRoot}). Refuses identity/ and escapes.`, { + tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions|refs ` + `(absolute or relative to ${localRoot}).`, { path: reqStr("Absolute path or path relative to KB local root") }, (args) => deleteLocalFile(liveSnapshot(), { path: String(args.path ?? "") })), - tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions (newest first). ` + "Does not include identity/. Read contents via absolute paths from the listing.", { + tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions|refs (newest first). ` + "Read contents via absolute paths from the listing.", { root: { type: "string", - enum: ["memories", "drafts", "suggestions", "all"], + enum: ["memories", "drafts", "suggestions", "refs", "all"], description: "Subtree to list (default all writable)" }, limit: num("Max entries 1–200 (default 50)") diff --git a/lib/index.js b/lib/index.js index bbe596d..89f35d9 100644 --- a/lib/index.js +++ b/lib/index.js @@ -814,7 +814,16 @@ import { join as join3, relative, resolve, sep } from "node:path"; var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([ "memories", "drafts", - "suggestions" + "suggestions", + "refs" +]); +var REF_AREAS = Object.freeze([ + "inventory", + "devices", + "topology", + "business", + "commands", + "handbooks" ]); var MEMORY_BUCKET_DIR = Object.freeze({ note: "日常笔记", @@ -874,6 +883,10 @@ function suggestionFileName(opts) { const date = normalizeIsoDate(opts.date, opts.now); return `${date}-${sanitizeSlug(opts.slug)}.md`; } +function refFileName(slug) { + const base = sanitizeSlug(slug, "note"); + return base.toLowerCase().endsWith(".md") ? base : `${base}.md`; +} function resolveWritableLocalPath(localRoot, ...segments) { if (segments.some((s) => s.includes("\x00"))) { throw new Error("path segment contains NUL"); @@ -947,6 +960,19 @@ function draftDir(domain) { return "drafts"; return join3("drafts", sanitizeSlug(d, "misc")); } +function refDir(area, device) { + if (!REF_AREAS.includes(area)) { + throw new Error(`invalid refs area: ${area}`); + } + if (area === "devices") { + const host = sanitizeSlug(device?.trim() || "", ""); + if (!host) { + throw new Error("refs/devices requires device (host_name)"); + } + return join3("refs", "devices", host); + } + return join3("refs", area); +} // src/netx/kb-context-skill.ts var SKILL_NAME = "kb-context"; @@ -957,18 +983,29 @@ function skillBody(snapshot) { const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |"; const localGuide = localRoot ? [ "", - "**Site-writable (`kbLocal`)**: use host tools `netx__kbWriteMemory`, `netx__kbWriteDraft`,", - "`netx__kbWriteSuggestion`, `netx__kbUpdateLocal`, `netx__kbDeleteLocal`, `netx__kbListLocal`.", - "They jail writes to `memories/` / `drafts/` / `suggestions/` only.", - "Do **not** use workspace Write/bash for KB paths (sandbox).", - "`identity/` is persona/policy for the host — **read-only for the agent**; never rewrite it;", - "file boundary issues as `suggestions/` instead." + "### Dual plane (mandatory)", + "", + "- **HQ pack = read-only** (formal RCA, theory, packet, `_common`, `_skills`, shared cmdLib).", + `- **Writable evolution core**: \`${localRoot}\` → \`refs/\` | \`memories/\` | \`drafts/\` | \`suggestions/\`.`, + "- **All KB writes via host tools** (workspace Write/bash cannot reach KB):", + " `netx__kbWriteRef` / `netx__kbWriteMemory` / `netx__kbWriteDraft` / `netx__kbWriteSuggestion` /", + " `netx__kbUpdateLocal` / `netx__kbDeleteLocal` / `netx__kbListLocal`.", + "", + "### refs — site product knowledge (evolve every task)", + "- On any NE: **read** `refs/devices//` first; if missing, prove with netx then `kbWriteRef` to create/update.", + "- `area=devices` requires `device=` (never UUID); primary file slug=`PROFILE`.", + "- Also: `inventory` (ledger), `topology`, `business`, `commands` (site-only), `handbooks`.", + "- Device facts → refs; episodic experience → memories (do not dump ledgers into diaries).", + "", + "### memories — write immediately when valuable", + "- `bucket=note` | `rca_review` | `ai_trace` — short beats lost.", + "- Open cases → `kbWriteDraft`. HQ pack gaps → `kbWriteSuggestion`." ] : [ "", "No local write tools until `paths.local` is present in MANIFEST." ]; return { - description: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).", + description: "Operator knowledge-base context for this Host (HQ pack + local evolution under paths.local).", content: [ "## Knowledge base (configured)", "", @@ -1379,6 +1416,25 @@ function createSuggestion(snapshot, args) { relativePath: target.relativePath }; } +function createRef(snapshot, args) { + const localRoot = requireLocalRoot(snapshot); + const name = refFileName(args.slug); + const target = resolveWritableLocalPath(localRoot, refDir(args.area, args.device), name); + if (existsSync4(target.absolutePath) && !args.overwrite) { + throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); + } + mkdirSync(dirname3(target.absolutePath), { recursive: true }); + const existed = existsSync4(target.absolutePath); + writeFileSync(target.absolutePath, args.body.endsWith(` +`) ? args.body : `${args.body} +`, "utf8"); + return { + ok: true, + action: existed ? "updated" : "created", + absolutePath: target.absolutePath, + relativePath: target.relativePath + }; +} function updateLocalFile(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const target = resolveExistingWritableFile(localRoot, args.path); @@ -1584,7 +1640,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { date: args.date != null ? String(args.date) : undefined, overwrite: args.overwrite === true })), - tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for identity/ or formal RCA.", { + tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for formal RCA.", { slug: reqStr("Case stem (DRAFT- prefix added if missing)"), body: reqStr("Markdown body (RCA-ish draft)"), domain: str("Optional fault-domain subfolder under drafts/"), @@ -1597,7 +1653,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { date: args.date != null ? String(args.date) : undefined, overwrite: args.overwrite === true })), - tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for theory corrections or tool/process improvements — never rewrite identity/.", { + tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for HQ pack/theory corrections or tool/process improvements.", { kind: { type: "string", required: true, @@ -1615,22 +1671,40 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) { date: args.date != null ? String(args.date) : undefined, overwrite: args.overwrite === true })), - tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions ` + `(absolute path or path relative to ${localRoot}). ` + "Cannot touch identity/ or outside paths.local. Drafts keep status: draft.", { + tool("netx__kbWriteRef", `Create (or overwrite) site product-knowledge under ${localRoot}/refs/. ` + "area=inventory|devices|topology|business|commands|handbooks. " + "For devices: require device=host_name; default slug PROFILE for the ops profile. " + "Use for NE ledger, per-device profiles, topology/business notes, site command books — not diaries.", { + area: { + type: "string", + required: true, + enum: ["inventory", "devices", "topology", "business", "commands", "handbooks"], + description: "refs/ subtree" + }, + slug: reqStr("Filename stem (e.g. PROFILE, neighbors, ledger)"), + body: reqStr("Full markdown body"), + device: str("Required when area=devices — host_name (never UUID)"), + overwrite: bool("Replace if the target file already exists") + }, (args) => createRef(liveSnapshot(), { + area: args.area, + slug: String(args.slug ?? ""), + body: String(args.body ?? ""), + device: args.device != null ? String(args.device) : undefined, + overwrite: args.overwrite === true + })), + tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions|refs ` + `(absolute path or path relative to ${localRoot}). ` + "Drafts keep status: draft.", { path: reqStr("Absolute path or path relative to KB local root"), body: reqStr("New full markdown body") }, (args) => updateLocalFile(liveSnapshot(), { path: String(args.path ?? ""), body: String(args.body ?? "") })), - tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions ` + `(absolute or relative to ${localRoot}). Refuses identity/ and escapes.`, { + tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions|refs ` + `(absolute or relative to ${localRoot}).`, { path: reqStr("Absolute path or path relative to KB local root") }, (args) => deleteLocalFile(liveSnapshot(), { path: String(args.path ?? "") })), - tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions (newest first). ` + "Does not include identity/. Read contents via absolute paths from the listing.", { + tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions|refs (newest first). ` + "Read contents via absolute paths from the listing.", { root: { type: "string", - enum: ["memories", "drafts", "suggestions", "all"], + enum: ["memories", "drafts", "suggestions", "refs", "all"], description: "Subtree to list (default all writable)" }, limit: num("Max entries 1–200 (default 50)") diff --git a/package.json b/package.json index 3b75b50..093a859 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-netxops", - "version": "0.1.35", + "version": "0.1.37", "description": "DeepSeek Harness Netx Ops: ops/topology + IM delivery + session export + knowledge-base MANIFEST/_skills + localSkills", "license": "MIT", "type": "module", diff --git a/presets/netxops/PERSONA.md b/presets/netxops/PERSONA.md index f60ba14..5e99b20 100644 --- a/presets/netxops/PERSONA.md +++ b/presets/netxops/PERSONA.md @@ -33,7 +33,25 @@ ## 工具 - netx 调用名为 `netx__*`(驼峰)。决策树见技能正文。 - 多台 CLI:**一次** `execManagedNe`(`ne_ids` / `nms_ne_ids` / `targets`)。 -- 工作区:与标准模式相同(shell、搜索、计划、todo、web、委派等);KB 现场可写区用 `netx__kbWrite*` / `UpdateLocal` / `DeleteLocal`(勿对 KB 用沙箱 Write)。 +- 工作区:与标准模式相同(shell、搜索、计划、todo、web、委派等)。 + +## 知识库双平面(KB 已配置时 — 铁律) +- **总部包只读**:正式 RCA、theory、packet、`_common`、`_skills`、总部命令库 — 检索用,不改。 +- **唯一可写区** `{kbLocal}`:`refs/`(本网产品知识)/ `memories/`(过程记忆)/ `drafts/`(案例草稿)/ `suggestions/`(反哺总部)。 +- **写文件只走宿主工具**:`netx__kbWriteRef` / `kbWriteMemory` / `kbWriteDraft` / `kbWriteSuggestion` / `kbUpdateLocal` / `kbDeleteLocal`。**禁止**对 KB 用 workspace `Write` / bash。 + +### refs — 本网产品知识(可进化核心) +- 排障涉及某网元时:**先读** `refs/devices//`;没有则 netx 取证后立刻 `kbWriteRef` 建档/补全。 +- `area=devices` 必须带 `device=host_name`(禁 UUID);画像主文件 slug=`PROFILE`。 +- 台账/汇总 → `inventory`;跨机拓扑 → `topology`;业务域 → `business`;本网命令集 → `commands`;速查 → `handbooks`。 +- **设备事实写 refs,过程经验写 memories**,不要把台账塞进日记。 + +### memories — 随手记 +任务中一旦出现可复用价值(经验、踩坑、口诀、误判纠正),立刻 `kbWriteMemory`;宁短勿丢。 +- `note` / `rca_review` / `ai_trace` + +### drafts / suggestions +未闭环案例 → `kbWriteDraft`;总部通用层有误或工具/流程改进 → `kbWriteSuggestion`。 ## 委派(默认 subagent,活多再拆) diff --git a/presets/netxops/agent.cordis.yml b/presets/netxops/agent.cordis.yml index 36fb4a2..224da38 100644 --- a/presets/netxops/agent.cordis.yml +++ b/presets/netxops/agent.cordis.yml @@ -46,7 +46,25 @@ ## 工具 - netx 调用名为 `netx__*`(驼峰)。决策树见技能正文。 - 多台 CLI:**一次** `execManagedNe`(`ne_ids` / `nms_ne_ids` / `targets`)。 - - 工作区:与标准模式相同(shell、搜索、计划、todo、web、委派等);KB 现场可写区用 `netx__kbWrite*` / `UpdateLocal` / `DeleteLocal`(勿对 KB 用沙箱 Write)。 + - 工作区:与标准模式相同(shell、搜索、计划、todo、web、委派等)。 + + ## 知识库双平面(KB 已配置时 — 铁律) + - **总部包只读**:正式 RCA、theory、packet、`_common`、`_skills`、总部命令库 — 检索用,不改。 + - **唯一可写区** `{kbLocal}`:`refs/`(本网产品知识)/ `memories/`(过程记忆)/ `drafts/`(案例草稿)/ `suggestions/`(反哺总部)。 + - **写文件只走宿主工具**:`netx__kbWriteRef` / `kbWriteMemory` / `kbWriteDraft` / `kbWriteSuggestion` / `kbUpdateLocal` / `kbDeleteLocal`。**禁止**对 KB 用 workspace `Write` / bash。 + + ### refs — 本网产品知识(可进化核心) + - 排障涉及某网元时:**先读** `refs/devices//`;没有则 netx 取证后立刻 `kbWriteRef` 建档/补全。 + - `area=devices` 必须带 `device=host_name`(禁 UUID);画像主文件 slug=`PROFILE`。 + - 台账/汇总 → `inventory`;跨机拓扑 → `topology`;业务域 → `business`;本网命令集 → `commands`;速查 → `handbooks`。 + - **设备事实写 refs,过程经验写 memories**,不要把台账塞进日记。 + + ### memories — 随手记 + 任务中一旦出现可复用价值(经验、踩坑、口诀、误判纠正),立刻 `kbWriteMemory`;宁短勿丢。 + - `note` / `rca_review` / `ai_trace` + + ### drafts / suggestions + 未闭环案例 → `kbWriteDraft`;总部通用层有误或工具/流程改进 → `kbWriteSuggestion`。 ## 委派(默认 subagent,活多再拆) 你本人逻辑上全能。默认自己干;只有活明显偏多、且可并行时,才用 `subagent`(必要时 `subagent_fork`)拆开干。 diff --git a/src/netx/kb-context-skill.ts b/src/netx/kb-context-skill.ts index 0267996..1ae7c2c 100644 --- a/src/netx/kb-context-skill.ts +++ b/src/netx/kb-context-skill.ts @@ -22,12 +22,23 @@ function skillBody(snapshot: KbSnapshot): { description: string; content: string const localGuide = localRoot ? [ '', - '**Site-writable (`kbLocal`)**: use host tools `netx__kbWriteMemory`, `netx__kbWriteDraft`,', - '`netx__kbWriteSuggestion`, `netx__kbUpdateLocal`, `netx__kbDeleteLocal`, `netx__kbListLocal`.', - 'They jail writes to `memories/` / `drafts/` / `suggestions/` only.', - 'Do **not** use workspace Write/bash for KB paths (sandbox).', - '`identity/` is persona/policy for the host — **read-only for the agent**; never rewrite it;', - 'file boundary issues as `suggestions/` instead.', + '### Dual plane (mandatory)', + '', + '- **HQ pack = read-only** (formal RCA, theory, packet, `_common`, `_skills`, shared cmdLib).', + `- **Writable evolution core**: \`${localRoot}\` → \`refs/\` | \`memories/\` | \`drafts/\` | \`suggestions/\`.`, + '- **All KB writes via host tools** (workspace Write/bash cannot reach KB):', + ' `netx__kbWriteRef` / `netx__kbWriteMemory` / `netx__kbWriteDraft` / `netx__kbWriteSuggestion` /', + ' `netx__kbUpdateLocal` / `netx__kbDeleteLocal` / `netx__kbListLocal`.', + '', + '### refs — site product knowledge (evolve every task)', + '- On any NE: **read** `refs/devices//` first; if missing, prove with netx then `kbWriteRef` to create/update.', + '- `area=devices` requires `device=` (never UUID); primary file slug=`PROFILE`.', + '- Also: `inventory` (ledger), `topology`, `business`, `commands` (site-only), `handbooks`.', + '- Device facts → refs; episodic experience → memories (do not dump ledgers into diaries).', + '', + '### memories — write immediately when valuable', + '- `bucket=note` | `rca_review` | `ai_trace` — short beats lost.', + '- Open cases → `kbWriteDraft`. HQ pack gaps → `kbWriteSuggestion`.', ] : [ '', @@ -35,7 +46,7 @@ function skillBody(snapshot: KbSnapshot): { description: string; content: string ] return { description: - 'Operator knowledge-base context for this Host (paths + identity from MANIFEST).', + 'Operator knowledge-base context for this Host (HQ pack + local evolution under paths.local).', content: [ '## Knowledge base (configured)', '', diff --git a/src/netx/kb-local-ops.ts b/src/netx/kb-local-ops.ts index 94f1cfb..9c5af13 100644 --- a/src/netx/kb-local-ops.ts +++ b/src/netx/kb-local-ops.ts @@ -18,12 +18,15 @@ import { draftFileName, memoryDir, memoryFileName, + refDir, + refFileName, resolveExistingWritableFile, resolveKbLocalRoot, resolveWritableLocalPath, suggestionDir, suggestionFileName, type MemoryBucket, + type RefArea, type SuggestionKind, KB_LOCAL_WRITABLE_ROOTS, type KbLocalWritableRoot, @@ -168,7 +171,49 @@ export function createSuggestion( } /** - * Replace body of an existing file under memories|drafts|suggestions. + * Create/overwrite site product-knowledge under `refs/` + * (inventory / devices / topology / business / commands / handbooks). + */ +export function createRef( + snapshot: KbSnapshot, + args: { + area: RefArea + slug: string + body: string + /** Required when area=devices — use host_name. */ + device?: string + overwrite?: boolean + }, +): KbLocalWriteResult { + const localRoot = requireLocalRoot(snapshot) + const name = refFileName(args.slug) + const target = resolveWritableLocalPath( + localRoot, + refDir(args.area, args.device), + name, + ) + if (existsSync(target.absolutePath) && !args.overwrite) { + throw new Error( + `file already exists (pass overwrite=true to replace): ${target.relativePath}`, + ) + } + mkdirSync(dirname(target.absolutePath), { recursive: true }) + const existed = existsSync(target.absolutePath) + writeFileSync( + target.absolutePath, + args.body.endsWith('\n') ? args.body : `${args.body}\n`, + 'utf8', + ) + return { + ok: true, + action: existed ? 'updated' : 'created', + absolutePath: target.absolutePath, + relativePath: target.relativePath, + } +} + +/** + * Replace body of an existing file under memories|drafts|suggestions|refs. * Drafts keep/force `status: draft` in frontmatter. */ export function updateLocalFile( diff --git a/src/netx/kb-local-path.ts b/src/netx/kb-local-path.ts index b86fac3..97fb2bb 100644 --- a/src/netx/kb-local-path.ts +++ b/src/netx/kb-local-path.ts @@ -1,6 +1,6 @@ /** * Path jail for operator-subset `_local` agent writes. - * Writable trees: memories / drafts / suggestions only (not identity / local_skills). + * Writable: memories / drafts / suggestions / refs (not local_skills). */ import { existsSync, realpathSync, statSync } from 'node:fs' @@ -12,6 +12,7 @@ export const KB_LOCAL_WRITABLE_ROOTS = Object.freeze([ 'memories', 'drafts', 'suggestions', + 'refs', ] as const) export type KbLocalWritableRoot = (typeof KB_LOCAL_WRITABLE_ROOTS)[number] @@ -19,6 +20,18 @@ export type KbLocalWritableRoot = (typeof KB_LOCAL_WRITABLE_ROOTS)[number] export type MemoryBucket = 'note' | 'rca_review' | 'ai_trace' export type SuggestionKind = 'theory' | 'improvement' +/** Site product-knowledge areas under `refs/`. */ +export const REF_AREAS = Object.freeze([ + 'inventory', + 'devices', + 'topology', + 'business', + 'commands', + 'handbooks', +] as const) + +export type RefArea = (typeof REF_AREAS)[number] + export const MEMORY_BUCKET_DIR: Readonly> = Object.freeze({ note: '日常笔记', rca_review: '排障复盘', @@ -110,6 +123,11 @@ export function suggestionFileName(opts: { return `${date}-${sanitizeSlug(opts.slug)}.md` } +export function refFileName(slug: string): string { + const base = sanitizeSlug(slug, 'note') + return base.toLowerCase().endsWith('.md') ? base : `${base}.md` +} + /** * Resolve a candidate path and assert it lies under a writable local subtree. * Parent dirs need not exist yet (create path); uses resolve + prefix check. @@ -207,3 +225,20 @@ export function draftDir(domain?: string): string { if (!d) return 'drafts' return join('drafts', sanitizeSlug(d, 'misc')) } + +/** + * `refs//…` — for `devices`, require `device` → `refs/devices//`. + */ +export function refDir(area: RefArea, device?: string): string { + if (!(REF_AREAS as readonly string[]).includes(area)) { + throw new Error(`invalid refs area: ${area}`) + } + if (area === 'devices') { + const host = sanitizeSlug(device?.trim() || '', '') + if (!host) { + throw new Error('refs/devices requires device (host_name)') + } + return join('refs', 'devices', host) + } + return join('refs', area) +} diff --git a/src/netx/kb-local-tools.ts b/src/netx/kb-local-tools.ts index 77ff825..3dc08f1 100644 --- a/src/netx/kb-local-tools.ts +++ b/src/netx/kb-local-tools.ts @@ -1,6 +1,6 @@ /** * Host-side tools for writing under MANIFEST `paths.local` - * (memories / drafts / suggestions). Bypasses workspace sandbox. + * (memories / drafts / suggestions / refs). Bypasses workspace sandbox. */ import type { Context } from '@deepseek-ai/cordis' @@ -9,12 +9,13 @@ import type { KbSnapshot } from './kb-manifest.ts' import { createDraft, createMemory, + createRef, createSuggestion, deleteLocalFile, listLocalFiles, updateLocalFile, } from './kb-local-ops.ts' -import { kbLocalToolsEnabled, resolveKbLocalRoot } from './kb-local-path.ts' +import { kbLocalToolsEnabled, resolveKbLocalRoot, type RefArea } from './kb-local-path.ts' import { getKbContext } from './kb-runtime.ts' const str = (description?: string) => ({ type: 'string' as const, ...(description ? { description } : {}) }) @@ -42,7 +43,7 @@ function liveSnapshot(): KbSnapshot { function tool( name: string, description: string, - /** DSH `defineTool` expects a flat property map — not a full JSON Schema object. */ + /** DSH `defineTool` expects a flat parameter map — not a full JSON Schema object. */ parameters: Record, execute: (args: Record) => Promise | unknown, ) { @@ -108,7 +109,7 @@ export function registerKbLocalTools( 'netx__kbWriteDraft', `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + 'Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. ' - + 'Set overwrite=true to replace. Not for identity/ or formal RCA.', + + 'Set overwrite=true to replace. Not for formal RCA.', { slug: reqStr('Case stem (DRAFT- prefix added if missing)'), body: reqStr('Markdown body (RCA-ish draft)'), @@ -127,7 +128,7 @@ export function registerKbLocalTools( tool( 'netx__kbWriteSuggestion', `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` - + 'Use for theory corrections or tool/process improvements — never rewrite identity/.', + + 'Use for HQ pack/theory corrections or tool/process improvements.', { kind: { type: 'string' as const, @@ -148,11 +149,37 @@ export function registerKbLocalTools( overwrite: args.overwrite === true, }), ), + tool( + 'netx__kbWriteRef', + `Create (or overwrite) site product-knowledge under ${localRoot}/refs/. ` + + 'area=inventory|devices|topology|business|commands|handbooks. ' + + 'For devices: require device=host_name; default slug PROFILE for the ops profile. ' + + 'Use for NE ledger, per-device profiles, topology/business notes, site command books — not diaries.', + { + area: { + type: 'string' as const, + required: true as const, + enum: ['inventory', 'devices', 'topology', 'business', 'commands', 'handbooks'], + description: 'refs/ subtree', + }, + slug: reqStr('Filename stem (e.g. PROFILE, neighbors, ledger)'), + body: reqStr('Full markdown body'), + device: str('Required when area=devices — host_name (never UUID)'), + overwrite: bool('Replace if the target file already exists'), + }, + (args) => createRef(liveSnapshot(), { + area: args.area as RefArea, + slug: String(args.slug ?? ''), + body: String(args.body ?? ''), + device: args.device != null ? String(args.device) : undefined, + overwrite: args.overwrite === true, + }), + ), tool( 'netx__kbUpdateLocal', - `Replace the body of an existing file under memories|drafts|suggestions ` + `Replace the body of an existing file under memories|drafts|suggestions|refs ` + `(absolute path or path relative to ${localRoot}). ` - + 'Cannot touch identity/ or outside paths.local. Drafts keep status: draft.', + + 'Drafts keep status: draft.', { path: reqStr('Absolute path or path relative to KB local root'), body: reqStr('New full markdown body'), @@ -164,8 +191,8 @@ export function registerKbLocalTools( ), tool( 'netx__kbDeleteLocal', - `Delete one existing markdown under memories|drafts|suggestions ` - + `(absolute or relative to ${localRoot}). Refuses identity/ and escapes.`, + `Delete one existing markdown under memories|drafts|suggestions|refs ` + + `(absolute or relative to ${localRoot}).`, { path: reqStr('Absolute path or path relative to KB local root'), }, @@ -175,18 +202,18 @@ export function registerKbLocalTools( ), tool( 'netx__kbListLocal', - `List recent .md files under ${localRoot} memories|drafts|suggestions (newest first). ` - + 'Does not include identity/. Read contents via absolute paths from the listing.', + `List recent .md files under ${localRoot} memories|drafts|suggestions|refs (newest first). ` + + 'Read contents via absolute paths from the listing.', { root: { type: 'string' as const, - enum: ['memories', 'drafts', 'suggestions', 'all'], + enum: ['memories', 'drafts', 'suggestions', 'refs', 'all'], description: 'Subtree to list (default all writable)', }, limit: num('Max entries 1–200 (default 50)'), }, (args) => listLocalFiles(liveSnapshot(), { - root: (args.root as 'memories' | 'drafts' | 'suggestions' | 'all' | undefined) ?? 'all', + root: (args.root as 'memories' | 'drafts' | 'suggestions' | 'refs' | 'all' | undefined) ?? 'all', limit: typeof args.limit === 'number' ? args.limit : undefined, }), ), diff --git a/test/kb-local.test.mjs b/test/kb-local.test.mjs index 4f09e8f..35e1494 100644 --- a/test/kb-local.test.mjs +++ b/test/kb-local.test.mjs @@ -3,7 +3,7 @@ */ import assert from 'node:assert/strict' -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import test from 'node:test' @@ -11,6 +11,7 @@ import test from 'node:test' import { createDraft, createMemory, + createRef, createSuggestion, deleteLocalFile, listLocalFiles, @@ -62,13 +63,13 @@ test('draftFileName forces DRAFT- prefix', () => { assert.match(name, /^DRAFT-20260915-jakarta-down\.md$/) }) -test('resolveWritableLocalPath rejects identity', () => { +test('resolveWritableLocalPath rejects non-writable roots', () => { withTemp((root) => { const local = join(root, '_local') mkdirSync(local, { recursive: true }) assert.throws( - () => resolveWritableLocalPath(local, 'identity', '话术.md'), - /identity|allowed under/, + () => resolveWritableLocalPath(local, 'local_skills', 'x.md'), + /allowed under/, ) }) }) @@ -129,26 +130,50 @@ test('draft injects status: draft frontmatter', () => { }) }) -test('suggestion + refuse identity path update', () => { +test('createRef device PROFILE + list refs', () => { withTemp((root) => { const s = snap(root) - const idDir = join(root, '_local', 'identity') - mkdirSync(idDir, { recursive: true }) - writeFileSync(join(idDir, '话术与边界.md'), 'secret\n', 'utf8') - createSuggestion(s, { + mkdirSync(join(root, '_local'), { recursive: true }) + assert.throws( + () => createRef(s, { area: 'devices', slug: 'PROFILE', body: '# x\n' }), + /requires device/, + ) + const created = createRef(s, { + area: 'devices', + device: 'JKT-PE-01', + slug: 'PROFILE', + body: '# JKT-PE-01\n\nrole: PE\n', + }) + assert.match(created.relativePath, /^refs\/devices\/JKT-PE-01\/PROFILE\.md$/) + assert.equal(readFileSync(created.absolutePath, 'utf8'), '# JKT-PE-01\n\nrole: PE\n') + + createRef(s, { + area: 'inventory', + slug: 'ledger', + body: '# ledger\n', + }) + const listed = listLocalFiles(s, { root: 'refs' }) + assert.equal(listed.entries.length, 2) + + updateLocalFile(s, { + path: created.relativePath, + body: '# JKT-PE-01\n\nrole: PE\nupdated: true\n', + }) + assert.match(readFileSync(created.absolutePath, 'utf8'), /updated: true/) + }) +}) + +test('suggestion create works', () => { + withTemp((root) => { + const s = snap(root) + mkdirSync(join(root, '_local'), { recursive: true }) + const created = createSuggestion(s, { kind: 'improvement', slug: 'tool-x', body: '# sug\n', date: '2026-09-15', }) - assert.throws( - () => updateLocalFile(s, { - path: 'identity/话术与边界.md', - body: 'hacked\n', - }), - /identity|allowed under/, - ) - assert.equal(readFileSync(join(idDir, '话术与边界.md'), 'utf8'), 'secret\n') + assert.match(created.relativePath, /suggestions\/improvement\//) }) })