mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-09 10:50:46 +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
|
|
@ -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,
|
||||
}),
|
||||
),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue