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

@ -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)',
'',

View file

@ -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(

View file

@ -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)
}

View file

@ -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,
}),
),