mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-09 01:20:45 +08:00
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:
parent
62d3f565ce
commit
0f31a6648a
16 changed files with 756 additions and 132 deletions
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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`) |
|
||||
|
|
|
|||
|
|
@ -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/<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."
|
||||
];
|
||||
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)")
|
||||
|
|
|
|||
|
|
@ -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/<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."
|
||||
];
|
||||
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)")
|
||||
|
|
|
|||
|
|
@ -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/<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."
|
||||
];
|
||||
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)")
|
||||
|
|
|
|||
|
|
@ -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/<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."
|
||||
];
|
||||
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)")
|
||||
|
|
|
|||
|
|
@ -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/<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."
|
||||
];
|
||||
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)")
|
||||
|
|
|
|||
102
lib/index.js
102
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/<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."
|
||||
];
|
||||
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)")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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/<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,活多再拆)
|
||||
|
||||
|
|
|
|||
|
|
@ -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/<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_fork`)拆开干。
|
||||
|
|
|
|||
|
|
@ -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/<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 {
|
||||
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)',
|
||||
'',
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<Record<MemoryBucket, string>> = 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/<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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>,
|
||||
execute: (args: Record<string, unknown>) => Promise<unknown> | 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,
|
||||
}),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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\//)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue