Add refs product KB and dual-plane local evolution (0.1.37).

Site writes device profiles/inventory via kbWriteRef; persona and kb-context require read-then-write refs plus memories for reusable experience.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-16 06:42:22 +08:00
parent 62d3f565ce
commit 0f31a6648a
16 changed files with 756 additions and 132 deletions

View file

@ -7,7 +7,7 @@ Operator-subset knowledge packages are wired through **netxops settings**, not i
| Surface | Responsibility | | Surface | Responsibility |
|---------|----------------| |---------|----------------|
| `netx-ops` | Live netx evidence (alarms / inventory / CLI) — **pure netx**, no KB playbooks | | `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 | | `_skills/kb-*` | Shared operator KB triage / ingest / retrieve / export / package |
| `paths.localSkills` | Operator-local skills (alongside `_skills/`; same toggles) | | `paths.localSkills` | Operator-local skills (alongside `_skills/`; same toggles) |
@ -38,9 +38,9 @@ Authoritative packaging prose lives in the workspace contract notes (`插件` /
| Channel | Fields | | 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` | | `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 | | 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). RPC (channel `/netxops`): `kb.status` (saved snapshot), `kb.resolve` with `{ path }` (preview unsaved paths).

View file

@ -35,13 +35,14 @@ Execution: HTTP `apiUrl` + Bearer `NETX_API_TOKEN`.
## knowledge base → `groupKb*` (not ops/topology) ## 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 | | 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`) | | `kbWriteDraft` | New `DRAFT-…` under `drafts/` (+ `status: draft`) |
| `kbWriteSuggestion` | New file under `suggestions/{theory\|improvement}/` | | `kbWriteSuggestion` | New file under `suggestions/{theory\|improvement}/` |
| `kbUpdateLocal` | Replace body of an existing jailed file | | `kbUpdateLocal` | Replace body of an existing jailed file |
| `kbDeleteLocal` | Delete one 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`) |

View file

@ -221,7 +221,16 @@ import { join as join2, relative, resolve, sep } from "node:path";
var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([ var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([
"memories", "memories",
"drafts", "drafts",
"suggestions" "suggestions",
"refs"
]);
var REF_AREAS = Object.freeze([
"inventory",
"devices",
"topology",
"business",
"commands",
"handbooks"
]); ]);
var MEMORY_BUCKET_DIR = Object.freeze({ var MEMORY_BUCKET_DIR = Object.freeze({
note: "日常笔记", note: "日常笔记",
@ -281,6 +290,10 @@ function suggestionFileName(opts) {
const date = normalizeIsoDate(opts.date, opts.now); const date = normalizeIsoDate(opts.date, opts.now);
return `${date}-${sanitizeSlug(opts.slug)}.md`; 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) { function resolveWritableLocalPath(localRoot, ...segments) {
if (segments.some((s) => s.includes("\x00"))) { if (segments.some((s) => s.includes("\x00"))) {
throw new Error("path segment contains NUL"); throw new Error("path segment contains NUL");
@ -354,6 +367,19 @@ function draftDir(domain) {
return "drafts"; return "drafts";
return join2("drafts", sanitizeSlug(d, "misc")); 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 // src/netx/kb-context-skill.ts
var SKILL_NAME = "kb-context"; var SKILL_NAME = "kb-context";
@ -364,18 +390,29 @@ function skillBody(snapshot) {
const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |"; const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |";
const localGuide = localRoot ? [ const localGuide = localRoot ? [
"", "",
"**Site-writable (`kbLocal`)**: use host tools `netx__kbWriteMemory`, `netx__kbWriteDraft`,", "### Dual plane (mandatory)",
"`netx__kbWriteSuggestion`, `netx__kbUpdateLocal`, `netx__kbDeleteLocal`, `netx__kbListLocal`.", "",
"They jail writes to `memories/` / `drafts/` / `suggestions/` only.", "- **HQ pack = read-only** (formal RCA, theory, packet, `_common`, `_skills`, shared cmdLib).",
"Do **not** use workspace Write/bash for KB paths (sandbox).", `- **Writable evolution core**: \`${localRoot}\` → \`refs/\` | \`memories/\` | \`drafts/\` | \`suggestions/\`.`,
"`identity/` is persona/policy for the host — **read-only for the agent**; never rewrite it;", "- **All KB writes via host tools** (workspace Write/bash cannot reach KB):",
"file boundary issues as `suggestions/` instead." " `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/<host_name>/` first; if missing, prove with netx then `kbWriteRef` to create/update.",
"- `area=devices` requires `device=<host_name>` (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." "No local write tools until `paths.local` is present in MANIFEST."
]; ];
return { 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: [ content: [
"## Knowledge base (configured)", "## Knowledge base (configured)",
"", "",
@ -543,6 +580,25 @@ function createSuggestion(snapshot, args) {
relativePath: target.relativePath 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) { function updateLocalFile(snapshot, args) {
const localRoot = requireLocalRoot(snapshot); const localRoot = requireLocalRoot(snapshot);
const target = resolveExistingWritableFile(localRoot, args.path); const target = resolveExistingWritableFile(localRoot, args.path);
@ -730,7 +786,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
date: args.date != null ? String(args.date) : undefined, date: args.date != null ? String(args.date) : undefined,
overwrite: args.overwrite === true 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)"), slug: reqStr("Case stem (DRAFT- prefix added if missing)"),
body: reqStr("Markdown body (RCA-ish draft)"), body: reqStr("Markdown body (RCA-ish draft)"),
domain: str("Optional fault-domain subfolder under drafts/"), 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, date: args.date != null ? String(args.date) : undefined,
overwrite: args.overwrite === true 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: { kind: {
type: "string", type: "string",
required: true, required: true,
@ -761,22 +817,40 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
date: args.date != null ? String(args.date) : undefined, date: args.date != null ? String(args.date) : undefined,
overwrite: args.overwrite === true 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"), path: reqStr("Absolute path or path relative to KB local root"),
body: reqStr("New full markdown body") body: reqStr("New full markdown body")
}, (args) => updateLocalFile(liveSnapshot(), { }, (args) => updateLocalFile(liveSnapshot(), {
path: String(args.path ?? ""), path: String(args.path ?? ""),
body: String(args.body ?? "") 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") path: reqStr("Absolute path or path relative to KB local root")
}, (args) => deleteLocalFile(liveSnapshot(), { }, (args) => deleteLocalFile(liveSnapshot(), {
path: String(args.path ?? "") 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: { root: {
type: "string", type: "string",
enum: ["memories", "drafts", "suggestions", "all"], enum: ["memories", "drafts", "suggestions", "refs", "all"],
description: "Subtree to list (default all writable)" description: "Subtree to list (default all writable)"
}, },
limit: num("Max entries 1–200 (default 50)") limit: num("Max entries 1–200 (default 50)")

View file

@ -221,7 +221,16 @@ import { join as join2, relative, resolve, sep } from "node:path";
var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([ var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([
"memories", "memories",
"drafts", "drafts",
"suggestions" "suggestions",
"refs"
]);
var REF_AREAS = Object.freeze([
"inventory",
"devices",
"topology",
"business",
"commands",
"handbooks"
]); ]);
var MEMORY_BUCKET_DIR = Object.freeze({ var MEMORY_BUCKET_DIR = Object.freeze({
note: "日常笔记", note: "日常笔记",
@ -281,6 +290,10 @@ function suggestionFileName(opts) {
const date = normalizeIsoDate(opts.date, opts.now); const date = normalizeIsoDate(opts.date, opts.now);
return `${date}-${sanitizeSlug(opts.slug)}.md`; 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) { function resolveWritableLocalPath(localRoot, ...segments) {
if (segments.some((s) => s.includes("\x00"))) { if (segments.some((s) => s.includes("\x00"))) {
throw new Error("path segment contains NUL"); throw new Error("path segment contains NUL");
@ -354,6 +367,19 @@ function draftDir(domain) {
return "drafts"; return "drafts";
return join2("drafts", sanitizeSlug(d, "misc")); 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 // src/netx/kb-context-skill.ts
var SKILL_NAME = "kb-context"; var SKILL_NAME = "kb-context";
@ -364,18 +390,29 @@ function skillBody(snapshot) {
const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |"; const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |";
const localGuide = localRoot ? [ const localGuide = localRoot ? [
"", "",
"**Site-writable (`kbLocal`)**: use host tools `netx__kbWriteMemory`, `netx__kbWriteDraft`,", "### Dual plane (mandatory)",
"`netx__kbWriteSuggestion`, `netx__kbUpdateLocal`, `netx__kbDeleteLocal`, `netx__kbListLocal`.", "",
"They jail writes to `memories/` / `drafts/` / `suggestions/` only.", "- **HQ pack = read-only** (formal RCA, theory, packet, `_common`, `_skills`, shared cmdLib).",
"Do **not** use workspace Write/bash for KB paths (sandbox).", `- **Writable evolution core**: \`${localRoot}\` → \`refs/\` | \`memories/\` | \`drafts/\` | \`suggestions/\`.`,
"`identity/` is persona/policy for the host — **read-only for the agent**; never rewrite it;", "- **All KB writes via host tools** (workspace Write/bash cannot reach KB):",
"file boundary issues as `suggestions/` instead." " `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/<host_name>/` first; if missing, prove with netx then `kbWriteRef` to create/update.",
"- `area=devices` requires `device=<host_name>` (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." "No local write tools until `paths.local` is present in MANIFEST."
]; ];
return { 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: [ content: [
"## Knowledge base (configured)", "## Knowledge base (configured)",
"", "",
@ -543,6 +580,25 @@ function createSuggestion(snapshot, args) {
relativePath: target.relativePath 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) { function updateLocalFile(snapshot, args) {
const localRoot = requireLocalRoot(snapshot); const localRoot = requireLocalRoot(snapshot);
const target = resolveExistingWritableFile(localRoot, args.path); const target = resolveExistingWritableFile(localRoot, args.path);
@ -730,7 +786,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
date: args.date != null ? String(args.date) : undefined, date: args.date != null ? String(args.date) : undefined,
overwrite: args.overwrite === true 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)"), slug: reqStr("Case stem (DRAFT- prefix added if missing)"),
body: reqStr("Markdown body (RCA-ish draft)"), body: reqStr("Markdown body (RCA-ish draft)"),
domain: str("Optional fault-domain subfolder under drafts/"), 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, date: args.date != null ? String(args.date) : undefined,
overwrite: args.overwrite === true 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: { kind: {
type: "string", type: "string",
required: true, required: true,
@ -761,22 +817,40 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
date: args.date != null ? String(args.date) : undefined, date: args.date != null ? String(args.date) : undefined,
overwrite: args.overwrite === true 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"), path: reqStr("Absolute path or path relative to KB local root"),
body: reqStr("New full markdown body") body: reqStr("New full markdown body")
}, (args) => updateLocalFile(liveSnapshot(), { }, (args) => updateLocalFile(liveSnapshot(), {
path: String(args.path ?? ""), path: String(args.path ?? ""),
body: String(args.body ?? "") 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") path: reqStr("Absolute path or path relative to KB local root")
}, (args) => deleteLocalFile(liveSnapshot(), { }, (args) => deleteLocalFile(liveSnapshot(), {
path: String(args.path ?? "") 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: { root: {
type: "string", type: "string",
enum: ["memories", "drafts", "suggestions", "all"], enum: ["memories", "drafts", "suggestions", "refs", "all"],
description: "Subtree to list (default all writable)" description: "Subtree to list (default all writable)"
}, },
limit: num("Max entries 1–200 (default 50)") limit: num("Max entries 1–200 (default 50)")

View file

@ -221,7 +221,16 @@ import { join as join2, relative, resolve, sep } from "node:path";
var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([ var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([
"memories", "memories",
"drafts", "drafts",
"suggestions" "suggestions",
"refs"
]);
var REF_AREAS = Object.freeze([
"inventory",
"devices",
"topology",
"business",
"commands",
"handbooks"
]); ]);
var MEMORY_BUCKET_DIR = Object.freeze({ var MEMORY_BUCKET_DIR = Object.freeze({
note: "日常笔记", note: "日常笔记",
@ -281,6 +290,10 @@ function suggestionFileName(opts) {
const date = normalizeIsoDate(opts.date, opts.now); const date = normalizeIsoDate(opts.date, opts.now);
return `${date}-${sanitizeSlug(opts.slug)}.md`; 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) { function resolveWritableLocalPath(localRoot, ...segments) {
if (segments.some((s) => s.includes("\x00"))) { if (segments.some((s) => s.includes("\x00"))) {
throw new Error("path segment contains NUL"); throw new Error("path segment contains NUL");
@ -354,6 +367,19 @@ function draftDir(domain) {
return "drafts"; return "drafts";
return join2("drafts", sanitizeSlug(d, "misc")); 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 // src/netx/kb-context-skill.ts
var SKILL_NAME = "kb-context"; var SKILL_NAME = "kb-context";
@ -364,18 +390,29 @@ function skillBody(snapshot) {
const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |"; const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |";
const localGuide = localRoot ? [ const localGuide = localRoot ? [
"", "",
"**Site-writable (`kbLocal`)**: use host tools `netx__kbWriteMemory`, `netx__kbWriteDraft`,", "### Dual plane (mandatory)",
"`netx__kbWriteSuggestion`, `netx__kbUpdateLocal`, `netx__kbDeleteLocal`, `netx__kbListLocal`.", "",
"They jail writes to `memories/` / `drafts/` / `suggestions/` only.", "- **HQ pack = read-only** (formal RCA, theory, packet, `_common`, `_skills`, shared cmdLib).",
"Do **not** use workspace Write/bash for KB paths (sandbox).", `- **Writable evolution core**: \`${localRoot}\` → \`refs/\` | \`memories/\` | \`drafts/\` | \`suggestions/\`.`,
"`identity/` is persona/policy for the host — **read-only for the agent**; never rewrite it;", "- **All KB writes via host tools** (workspace Write/bash cannot reach KB):",
"file boundary issues as `suggestions/` instead." " `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/<host_name>/` first; if missing, prove with netx then `kbWriteRef` to create/update.",
"- `area=devices` requires `device=<host_name>` (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." "No local write tools until `paths.local` is present in MANIFEST."
]; ];
return { 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: [ content: [
"## Knowledge base (configured)", "## Knowledge base (configured)",
"", "",
@ -543,6 +580,25 @@ function createSuggestion(snapshot, args) {
relativePath: target.relativePath 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) { function updateLocalFile(snapshot, args) {
const localRoot = requireLocalRoot(snapshot); const localRoot = requireLocalRoot(snapshot);
const target = resolveExistingWritableFile(localRoot, args.path); const target = resolveExistingWritableFile(localRoot, args.path);
@ -730,7 +786,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
date: args.date != null ? String(args.date) : undefined, date: args.date != null ? String(args.date) : undefined,
overwrite: args.overwrite === true 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)"), slug: reqStr("Case stem (DRAFT- prefix added if missing)"),
body: reqStr("Markdown body (RCA-ish draft)"), body: reqStr("Markdown body (RCA-ish draft)"),
domain: str("Optional fault-domain subfolder under drafts/"), 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, date: args.date != null ? String(args.date) : undefined,
overwrite: args.overwrite === true 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: { kind: {
type: "string", type: "string",
required: true, required: true,
@ -761,22 +817,40 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
date: args.date != null ? String(args.date) : undefined, date: args.date != null ? String(args.date) : undefined,
overwrite: args.overwrite === true 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"), path: reqStr("Absolute path or path relative to KB local root"),
body: reqStr("New full markdown body") body: reqStr("New full markdown body")
}, (args) => updateLocalFile(liveSnapshot(), { }, (args) => updateLocalFile(liveSnapshot(), {
path: String(args.path ?? ""), path: String(args.path ?? ""),
body: String(args.body ?? "") 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") path: reqStr("Absolute path or path relative to KB local root")
}, (args) => deleteLocalFile(liveSnapshot(), { }, (args) => deleteLocalFile(liveSnapshot(), {
path: String(args.path ?? "") 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: { root: {
type: "string", type: "string",
enum: ["memories", "drafts", "suggestions", "all"], enum: ["memories", "drafts", "suggestions", "refs", "all"],
description: "Subtree to list (default all writable)" description: "Subtree to list (default all writable)"
}, },
limit: num("Max entries 1–200 (default 50)") limit: num("Max entries 1–200 (default 50)")

View file

@ -221,7 +221,16 @@ import { join as join2, relative, resolve, sep } from "node:path";
var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([ var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([
"memories", "memories",
"drafts", "drafts",
"suggestions" "suggestions",
"refs"
]);
var REF_AREAS = Object.freeze([
"inventory",
"devices",
"topology",
"business",
"commands",
"handbooks"
]); ]);
var MEMORY_BUCKET_DIR = Object.freeze({ var MEMORY_BUCKET_DIR = Object.freeze({
note: "日常笔记", note: "日常笔记",
@ -281,6 +290,10 @@ function suggestionFileName(opts) {
const date = normalizeIsoDate(opts.date, opts.now); const date = normalizeIsoDate(opts.date, opts.now);
return `${date}-${sanitizeSlug(opts.slug)}.md`; 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) { function resolveWritableLocalPath(localRoot, ...segments) {
if (segments.some((s) => s.includes("\x00"))) { if (segments.some((s) => s.includes("\x00"))) {
throw new Error("path segment contains NUL"); throw new Error("path segment contains NUL");
@ -354,6 +367,19 @@ function draftDir(domain) {
return "drafts"; return "drafts";
return join2("drafts", sanitizeSlug(d, "misc")); 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 // src/netx/kb-context-skill.ts
var SKILL_NAME = "kb-context"; var SKILL_NAME = "kb-context";
@ -364,18 +390,29 @@ function skillBody(snapshot) {
const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |"; const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |";
const localGuide = localRoot ? [ const localGuide = localRoot ? [
"", "",
"**Site-writable (`kbLocal`)**: use host tools `netx__kbWriteMemory`, `netx__kbWriteDraft`,", "### Dual plane (mandatory)",
"`netx__kbWriteSuggestion`, `netx__kbUpdateLocal`, `netx__kbDeleteLocal`, `netx__kbListLocal`.", "",
"They jail writes to `memories/` / `drafts/` / `suggestions/` only.", "- **HQ pack = read-only** (formal RCA, theory, packet, `_common`, `_skills`, shared cmdLib).",
"Do **not** use workspace Write/bash for KB paths (sandbox).", `- **Writable evolution core**: \`${localRoot}\` → \`refs/\` | \`memories/\` | \`drafts/\` | \`suggestions/\`.`,
"`identity/` is persona/policy for the host — **read-only for the agent**; never rewrite it;", "- **All KB writes via host tools** (workspace Write/bash cannot reach KB):",
"file boundary issues as `suggestions/` instead." " `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/<host_name>/` first; if missing, prove with netx then `kbWriteRef` to create/update.",
"- `area=devices` requires `device=<host_name>` (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." "No local write tools until `paths.local` is present in MANIFEST."
]; ];
return { 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: [ content: [
"## Knowledge base (configured)", "## Knowledge base (configured)",
"", "",
@ -543,6 +580,25 @@ function createSuggestion(snapshot, args) {
relativePath: target.relativePath 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) { function updateLocalFile(snapshot, args) {
const localRoot = requireLocalRoot(snapshot); const localRoot = requireLocalRoot(snapshot);
const target = resolveExistingWritableFile(localRoot, args.path); const target = resolveExistingWritableFile(localRoot, args.path);
@ -730,7 +786,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
date: args.date != null ? String(args.date) : undefined, date: args.date != null ? String(args.date) : undefined,
overwrite: args.overwrite === true 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)"), slug: reqStr("Case stem (DRAFT- prefix added if missing)"),
body: reqStr("Markdown body (RCA-ish draft)"), body: reqStr("Markdown body (RCA-ish draft)"),
domain: str("Optional fault-domain subfolder under drafts/"), 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, date: args.date != null ? String(args.date) : undefined,
overwrite: args.overwrite === true 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: { kind: {
type: "string", type: "string",
required: true, required: true,
@ -761,22 +817,40 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
date: args.date != null ? String(args.date) : undefined, date: args.date != null ? String(args.date) : undefined,
overwrite: args.overwrite === true 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"), path: reqStr("Absolute path or path relative to KB local root"),
body: reqStr("New full markdown body") body: reqStr("New full markdown body")
}, (args) => updateLocalFile(liveSnapshot(), { }, (args) => updateLocalFile(liveSnapshot(), {
path: String(args.path ?? ""), path: String(args.path ?? ""),
body: String(args.body ?? "") 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") path: reqStr("Absolute path or path relative to KB local root")
}, (args) => deleteLocalFile(liveSnapshot(), { }, (args) => deleteLocalFile(liveSnapshot(), {
path: String(args.path ?? "") 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: { root: {
type: "string", type: "string",
enum: ["memories", "drafts", "suggestions", "all"], enum: ["memories", "drafts", "suggestions", "refs", "all"],
description: "Subtree to list (default all writable)" description: "Subtree to list (default all writable)"
}, },
limit: num("Max entries 1–200 (default 50)") limit: num("Max entries 1–200 (default 50)")

View file

@ -221,7 +221,16 @@ import { join as join2, relative, resolve, sep } from "node:path";
var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([ var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([
"memories", "memories",
"drafts", "drafts",
"suggestions" "suggestions",
"refs"
]);
var REF_AREAS = Object.freeze([
"inventory",
"devices",
"topology",
"business",
"commands",
"handbooks"
]); ]);
var MEMORY_BUCKET_DIR = Object.freeze({ var MEMORY_BUCKET_DIR = Object.freeze({
note: "日常笔记", note: "日常笔记",
@ -281,6 +290,10 @@ function suggestionFileName(opts) {
const date = normalizeIsoDate(opts.date, opts.now); const date = normalizeIsoDate(opts.date, opts.now);
return `${date}-${sanitizeSlug(opts.slug)}.md`; 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) { function resolveWritableLocalPath(localRoot, ...segments) {
if (segments.some((s) => s.includes("\x00"))) { if (segments.some((s) => s.includes("\x00"))) {
throw new Error("path segment contains NUL"); throw new Error("path segment contains NUL");
@ -354,6 +367,19 @@ function draftDir(domain) {
return "drafts"; return "drafts";
return join2("drafts", sanitizeSlug(d, "misc")); 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 // src/netx/kb-context-skill.ts
var SKILL_NAME = "kb-context"; var SKILL_NAME = "kb-context";
@ -364,18 +390,29 @@ function skillBody(snapshot) {
const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |"; const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |";
const localGuide = localRoot ? [ const localGuide = localRoot ? [
"", "",
"**Site-writable (`kbLocal`)**: use host tools `netx__kbWriteMemory`, `netx__kbWriteDraft`,", "### Dual plane (mandatory)",
"`netx__kbWriteSuggestion`, `netx__kbUpdateLocal`, `netx__kbDeleteLocal`, `netx__kbListLocal`.", "",
"They jail writes to `memories/` / `drafts/` / `suggestions/` only.", "- **HQ pack = read-only** (formal RCA, theory, packet, `_common`, `_skills`, shared cmdLib).",
"Do **not** use workspace Write/bash for KB paths (sandbox).", `- **Writable evolution core**: \`${localRoot}\` → \`refs/\` | \`memories/\` | \`drafts/\` | \`suggestions/\`.`,
"`identity/` is persona/policy for the host — **read-only for the agent**; never rewrite it;", "- **All KB writes via host tools** (workspace Write/bash cannot reach KB):",
"file boundary issues as `suggestions/` instead." " `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/<host_name>/` first; if missing, prove with netx then `kbWriteRef` to create/update.",
"- `area=devices` requires `device=<host_name>` (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." "No local write tools until `paths.local` is present in MANIFEST."
]; ];
return { 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: [ content: [
"## Knowledge base (configured)", "## Knowledge base (configured)",
"", "",
@ -543,6 +580,25 @@ function createSuggestion(snapshot, args) {
relativePath: target.relativePath 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) { function updateLocalFile(snapshot, args) {
const localRoot = requireLocalRoot(snapshot); const localRoot = requireLocalRoot(snapshot);
const target = resolveExistingWritableFile(localRoot, args.path); const target = resolveExistingWritableFile(localRoot, args.path);
@ -730,7 +786,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
date: args.date != null ? String(args.date) : undefined, date: args.date != null ? String(args.date) : undefined,
overwrite: args.overwrite === true 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)"), slug: reqStr("Case stem (DRAFT- prefix added if missing)"),
body: reqStr("Markdown body (RCA-ish draft)"), body: reqStr("Markdown body (RCA-ish draft)"),
domain: str("Optional fault-domain subfolder under drafts/"), 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, date: args.date != null ? String(args.date) : undefined,
overwrite: args.overwrite === true 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: { kind: {
type: "string", type: "string",
required: true, required: true,
@ -761,22 +817,40 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
date: args.date != null ? String(args.date) : undefined, date: args.date != null ? String(args.date) : undefined,
overwrite: args.overwrite === true 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"), path: reqStr("Absolute path or path relative to KB local root"),
body: reqStr("New full markdown body") body: reqStr("New full markdown body")
}, (args) => updateLocalFile(liveSnapshot(), { }, (args) => updateLocalFile(liveSnapshot(), {
path: String(args.path ?? ""), path: String(args.path ?? ""),
body: String(args.body ?? "") 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") path: reqStr("Absolute path or path relative to KB local root")
}, (args) => deleteLocalFile(liveSnapshot(), { }, (args) => deleteLocalFile(liveSnapshot(), {
path: String(args.path ?? "") 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: { root: {
type: "string", type: "string",
enum: ["memories", "drafts", "suggestions", "all"], enum: ["memories", "drafts", "suggestions", "refs", "all"],
description: "Subtree to list (default all writable)" description: "Subtree to list (default all writable)"
}, },
limit: num("Max entries 1–200 (default 50)") limit: num("Max entries 1–200 (default 50)")

View file

@ -814,7 +814,16 @@ import { join as join3, relative, resolve, sep } from "node:path";
var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([ var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([
"memories", "memories",
"drafts", "drafts",
"suggestions" "suggestions",
"refs"
]);
var REF_AREAS = Object.freeze([
"inventory",
"devices",
"topology",
"business",
"commands",
"handbooks"
]); ]);
var MEMORY_BUCKET_DIR = Object.freeze({ var MEMORY_BUCKET_DIR = Object.freeze({
note: "日常笔记", note: "日常笔记",
@ -874,6 +883,10 @@ function suggestionFileName(opts) {
const date = normalizeIsoDate(opts.date, opts.now); const date = normalizeIsoDate(opts.date, opts.now);
return `${date}-${sanitizeSlug(opts.slug)}.md`; 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) { function resolveWritableLocalPath(localRoot, ...segments) {
if (segments.some((s) => s.includes("\x00"))) { if (segments.some((s) => s.includes("\x00"))) {
throw new Error("path segment contains NUL"); throw new Error("path segment contains NUL");
@ -947,6 +960,19 @@ function draftDir(domain) {
return "drafts"; return "drafts";
return join3("drafts", sanitizeSlug(d, "misc")); 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 // src/netx/kb-context-skill.ts
var SKILL_NAME = "kb-context"; var SKILL_NAME = "kb-context";
@ -957,18 +983,29 @@ function skillBody(snapshot) {
const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |"; const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |";
const localGuide = localRoot ? [ const localGuide = localRoot ? [
"", "",
"**Site-writable (`kbLocal`)**: use host tools `netx__kbWriteMemory`, `netx__kbWriteDraft`,", "### Dual plane (mandatory)",
"`netx__kbWriteSuggestion`, `netx__kbUpdateLocal`, `netx__kbDeleteLocal`, `netx__kbListLocal`.", "",
"They jail writes to `memories/` / `drafts/` / `suggestions/` only.", "- **HQ pack = read-only** (formal RCA, theory, packet, `_common`, `_skills`, shared cmdLib).",
"Do **not** use workspace Write/bash for KB paths (sandbox).", `- **Writable evolution core**: \`${localRoot}\` → \`refs/\` | \`memories/\` | \`drafts/\` | \`suggestions/\`.`,
"`identity/` is persona/policy for the host — **read-only for the agent**; never rewrite it;", "- **All KB writes via host tools** (workspace Write/bash cannot reach KB):",
"file boundary issues as `suggestions/` instead." " `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/<host_name>/` first; if missing, prove with netx then `kbWriteRef` to create/update.",
"- `area=devices` requires `device=<host_name>` (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." "No local write tools until `paths.local` is present in MANIFEST."
]; ];
return { 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: [ content: [
"## Knowledge base (configured)", "## Knowledge base (configured)",
"", "",
@ -1379,6 +1416,25 @@ function createSuggestion(snapshot, args) {
relativePath: target.relativePath 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) { function updateLocalFile(snapshot, args) {
const localRoot = requireLocalRoot(snapshot); const localRoot = requireLocalRoot(snapshot);
const target = resolveExistingWritableFile(localRoot, args.path); const target = resolveExistingWritableFile(localRoot, args.path);
@ -1584,7 +1640,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
date: args.date != null ? String(args.date) : undefined, date: args.date != null ? String(args.date) : undefined,
overwrite: args.overwrite === true 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)"), slug: reqStr("Case stem (DRAFT- prefix added if missing)"),
body: reqStr("Markdown body (RCA-ish draft)"), body: reqStr("Markdown body (RCA-ish draft)"),
domain: str("Optional fault-domain subfolder under drafts/"), 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, date: args.date != null ? String(args.date) : undefined,
overwrite: args.overwrite === true 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: { kind: {
type: "string", type: "string",
required: true, required: true,
@ -1615,22 +1671,40 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
date: args.date != null ? String(args.date) : undefined, date: args.date != null ? String(args.date) : undefined,
overwrite: args.overwrite === true 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"), path: reqStr("Absolute path or path relative to KB local root"),
body: reqStr("New full markdown body") body: reqStr("New full markdown body")
}, (args) => updateLocalFile(liveSnapshot(), { }, (args) => updateLocalFile(liveSnapshot(), {
path: String(args.path ?? ""), path: String(args.path ?? ""),
body: String(args.body ?? "") 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") path: reqStr("Absolute path or path relative to KB local root")
}, (args) => deleteLocalFile(liveSnapshot(), { }, (args) => deleteLocalFile(liveSnapshot(), {
path: String(args.path ?? "") 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: { root: {
type: "string", type: "string",
enum: ["memories", "drafts", "suggestions", "all"], enum: ["memories", "drafts", "suggestions", "refs", "all"],
description: "Subtree to list (default all writable)" description: "Subtree to list (default all writable)"
}, },
limit: num("Max entries 1–200 (default 50)") limit: num("Max entries 1–200 (default 50)")

View file

@ -1,6 +1,6 @@
{ {
"name": "dsh-netxops", "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", "description": "DeepSeek Harness Netx Ops: ops/topology + IM delivery + session export + knowledge-base MANIFEST/_skills + localSkills",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",

View file

@ -33,7 +33,25 @@
## 工具 ## 工具
- netx 调用名为 `netx__*`(驼峰)。决策树见技能正文。 - netx 调用名为 `netx__*`(驼峰)。决策树见技能正文。
- 多台 CLI:**一次** `execManagedNe`(`ne_ids` / `nms_ne_ids` / `targets`)。 - 多台 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/<host_name>/`;没有则 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,活多再拆)

View file

@ -46,7 +46,25 @@
## 工具 ## 工具
- netx 调用名为 `netx__*`(驼峰)。决策树见技能正文。 - netx 调用名为 `netx__*`(驼峰)。决策树见技能正文。
- 多台 CLI:**一次** `execManagedNe`(`ne_ids` / `nms_ne_ids` / `targets`)。 - 多台 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/<host_name>/`;没有则 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`(必要时 `subagent_fork`)拆开干。 你本人逻辑上全能。默认自己干;只有活明显偏多、且可并行时,才用 `subagent`(必要时 `subagent_fork`)拆开干。

View file

@ -22,12 +22,23 @@ function skillBody(snapshot: KbSnapshot): { description: string; content: string
const localGuide = localRoot const localGuide = localRoot
? [ ? [
'', '',
'**Site-writable (`kbLocal`)**: use host tools `netx__kbWriteMemory`, `netx__kbWriteDraft`,', '### Dual plane (mandatory)',
'`netx__kbWriteSuggestion`, `netx__kbUpdateLocal`, `netx__kbDeleteLocal`, `netx__kbListLocal`.', '',
'They jail writes to `memories/` / `drafts/` / `suggestions/` only.', '- **HQ pack = read-only** (formal RCA, theory, packet, `_common`, `_skills`, shared cmdLib).',
'Do **not** use workspace Write/bash for KB paths (sandbox).', `- **Writable evolution core**: \`${localRoot}\` → \`refs/\` | \`memories/\` | \`drafts/\` | \`suggestions/\`.`,
'`identity/` is persona/policy for the host — **read-only for the agent**; never rewrite it;', '- **All KB writes via host tools** (workspace Write/bash cannot reach KB):',
'file boundary issues as `suggestions/` instead.', ' `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/<host_name>/` first; if missing, prove with netx then `kbWriteRef` to create/update.',
'- `area=devices` requires `device=<host_name>` (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 { return {
description: 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: [ content: [
'## Knowledge base (configured)', '## Knowledge base (configured)',
'', '',

View file

@ -18,12 +18,15 @@ import {
draftFileName, draftFileName,
memoryDir, memoryDir,
memoryFileName, memoryFileName,
refDir,
refFileName,
resolveExistingWritableFile, resolveExistingWritableFile,
resolveKbLocalRoot, resolveKbLocalRoot,
resolveWritableLocalPath, resolveWritableLocalPath,
suggestionDir, suggestionDir,
suggestionFileName, suggestionFileName,
type MemoryBucket, type MemoryBucket,
type RefArea,
type SuggestionKind, type SuggestionKind,
KB_LOCAL_WRITABLE_ROOTS, KB_LOCAL_WRITABLE_ROOTS,
type KbLocalWritableRoot, 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. * Drafts keep/force `status: draft` in frontmatter.
*/ */
export function updateLocalFile( export function updateLocalFile(

View file

@ -1,6 +1,6 @@
/** /**
* Path jail for operator-subset `_local` agent writes. * 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' import { existsSync, realpathSync, statSync } from 'node:fs'
@ -12,6 +12,7 @@ export const KB_LOCAL_WRITABLE_ROOTS = Object.freeze([
'memories', 'memories',
'drafts', 'drafts',
'suggestions', 'suggestions',
'refs',
] as const) ] as const)
export type KbLocalWritableRoot = (typeof KB_LOCAL_WRITABLE_ROOTS)[number] 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 MemoryBucket = 'note' | 'rca_review' | 'ai_trace'
export type SuggestionKind = 'theory' | 'improvement' 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<Record<MemoryBucket, string>> = Object.freeze({ export const MEMORY_BUCKET_DIR: Readonly<Record<MemoryBucket, string>> = Object.freeze({
note: '日常笔记', note: '日常笔记',
rca_review: '排障复盘', rca_review: '排障复盘',
@ -110,6 +123,11 @@ export function suggestionFileName(opts: {
return `${date}-${sanitizeSlug(opts.slug)}.md` 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. * 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. * 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' if (!d) return 'drafts'
return join('drafts', sanitizeSlug(d, 'misc')) return join('drafts', sanitizeSlug(d, 'misc'))
} }
/**
* `refs/<area>/…` — for `devices`, require `device` → `refs/devices/<device>/<file>`.
*/
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)
}

View file

@ -1,6 +1,6 @@
/** /**
* Host-side tools for writing under MANIFEST `paths.local` * 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' import type { Context } from '@deepseek-ai/cordis'
@ -9,12 +9,13 @@ import type { KbSnapshot } from './kb-manifest.ts'
import { import {
createDraft, createDraft,
createMemory, createMemory,
createRef,
createSuggestion, createSuggestion,
deleteLocalFile, deleteLocalFile,
listLocalFiles, listLocalFiles,
updateLocalFile, updateLocalFile,
} from './kb-local-ops.ts' } 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' import { getKbContext } from './kb-runtime.ts'
const str = (description?: string) => ({ type: 'string' as const, ...(description ? { description } : {}) }) const str = (description?: string) => ({ type: 'string' as const, ...(description ? { description } : {}) })
@ -42,7 +43,7 @@ function liveSnapshot(): KbSnapshot {
function tool( function tool(
name: string, name: string,
description: 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<string, unknown>, parameters: Record<string, unknown>,
execute: (args: Record<string, unknown>) => Promise<unknown> | unknown, execute: (args: Record<string, unknown>) => Promise<unknown> | unknown,
) { ) {
@ -108,7 +109,7 @@ export function registerKbLocalTools(
'netx__kbWriteDraft', 'netx__kbWriteDraft',
`Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. `
+ 'Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. ' + '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)'), slug: reqStr('Case stem (DRAFT- prefix added if missing)'),
body: reqStr('Markdown body (RCA-ish draft)'), body: reqStr('Markdown body (RCA-ish draft)'),
@ -127,7 +128,7 @@ export function registerKbLocalTools(
tool( tool(
'netx__kbWriteSuggestion', 'netx__kbWriteSuggestion',
`Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` `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: { kind: {
type: 'string' as const, type: 'string' as const,
@ -148,11 +149,37 @@ export function registerKbLocalTools(
overwrite: args.overwrite === true, 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( tool(
'netx__kbUpdateLocal', '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}). ` + `(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'), path: reqStr('Absolute path or path relative to KB local root'),
body: reqStr('New full markdown body'), body: reqStr('New full markdown body'),
@ -164,8 +191,8 @@ export function registerKbLocalTools(
), ),
tool( tool(
'netx__kbDeleteLocal', 'netx__kbDeleteLocal',
`Delete one existing markdown under memories|drafts|suggestions ` `Delete one existing markdown under memories|drafts|suggestions|refs `
+ `(absolute or relative to ${localRoot}). Refuses identity/ and escapes.`, + `(absolute or relative to ${localRoot}).`,
{ {
path: reqStr('Absolute path or path relative to KB local root'), path: reqStr('Absolute path or path relative to KB local root'),
}, },
@ -175,18 +202,18 @@ export function registerKbLocalTools(
), ),
tool( tool(
'netx__kbListLocal', 'netx__kbListLocal',
`List recent .md files under ${localRoot} memories|drafts|suggestions (newest first). ` `List recent .md files under ${localRoot} memories|drafts|suggestions|refs (newest first). `
+ 'Does not include identity/. Read contents via absolute paths from the listing.', + 'Read contents via absolute paths from the listing.',
{ {
root: { root: {
type: 'string' as const, type: 'string' as const,
enum: ['memories', 'drafts', 'suggestions', 'all'], enum: ['memories', 'drafts', 'suggestions', 'refs', 'all'],
description: 'Subtree to list (default all writable)', description: 'Subtree to list (default all writable)',
}, },
limit: num('Max entries 1–200 (default 50)'), limit: num('Max entries 1–200 (default 50)'),
}, },
(args) => listLocalFiles(liveSnapshot(), { (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, limit: typeof args.limit === 'number' ? args.limit : undefined,
}), }),
), ),

View file

@ -3,7 +3,7 @@
*/ */
import assert from 'node:assert/strict' 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 { tmpdir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
import test from 'node:test' import test from 'node:test'
@ -11,6 +11,7 @@ import test from 'node:test'
import { import {
createDraft, createDraft,
createMemory, createMemory,
createRef,
createSuggestion, createSuggestion,
deleteLocalFile, deleteLocalFile,
listLocalFiles, listLocalFiles,
@ -62,13 +63,13 @@ test('draftFileName forces DRAFT- prefix', () => {
assert.match(name, /^DRAFT-20260915-jakarta-down\.md$/) assert.match(name, /^DRAFT-20260915-jakarta-down\.md$/)
}) })
test('resolveWritableLocalPath rejects identity', () => { test('resolveWritableLocalPath rejects non-writable roots', () => {
withTemp((root) => { withTemp((root) => {
const local = join(root, '_local') const local = join(root, '_local')
mkdirSync(local, { recursive: true }) mkdirSync(local, { recursive: true })
assert.throws( assert.throws(
() => resolveWritableLocalPath(local, 'identity', '话术.md'), () => resolveWritableLocalPath(local, 'local_skills', 'x.md'),
/identity|allowed under/, /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) => { withTemp((root) => {
const s = snap(root) const s = snap(root)
const idDir = join(root, '_local', 'identity') mkdirSync(join(root, '_local'), { recursive: true })
mkdirSync(idDir, { recursive: true }) assert.throws(
writeFileSync(join(idDir, '话术与边界.md'), 'secret\n', 'utf8') () => createRef(s, { area: 'devices', slug: 'PROFILE', body: '# x\n' }),
createSuggestion(s, { /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', kind: 'improvement',
slug: 'tool-x', slug: 'tool-x',
body: '# sug\n', body: '# sug\n',
date: '2026-09-15', date: '2026-09-15',
}) })
assert.throws( assert.match(created.relativePath, /suggestions\/improvement\//)
() => updateLocalFile(s, {
path: 'identity/话术与边界.md',
body: 'hacked\n',
}),
/identity|allowed under/,
)
assert.equal(readFileSync(join(idDir, '话术与边界.md'), 'utf8'), 'secret\n')
}) })
}) })