Add KB local write tools and align Netx Ops with standard preset.

Host-side netx__kb* tools jail create/update/delete under paths.local memories/drafts/suggestions; Netx Ops agent.cordis.yml now includes full standard capability rows plus ops tools.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-15 21:43:41 +08:00
parent 8360c2e4fc
commit 3a731389f0
20 changed files with 6764 additions and 2635 deletions

View file

@ -46,6 +46,7 @@ import { registerGroupSkills } from './netx/group-skills.ts'
import { resolveImTargets } from './netx/im-targets.ts'
import { registerKbContextSkill } from './netx/kb-context-skill.ts'
import { resolveKbRoot } from './netx/kb-manifest.ts'
import { registerKbLocalTools } from './netx/kb-local-tools.ts'
import { registerKbPackSkills } from './netx/kb-pack-skills.ts'
import {
applyKbEnv,
@ -481,6 +482,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
// Optional host-layer publish: groups with `public=true` become visible to other presets.
ctx.inject(['tools'], (toolsCtx) => {
let unregisterTools: (() => void) | undefined
let unregisterKbLocal: (() => void) | undefined
const remountPublicTools = (): void => {
unregisterTools?.()
unregisterTools = undefined
@ -493,11 +495,25 @@ export function apply(ctx: Context, config: Config = Config({})): void {
enabled.join(',') || '(none)',
)
}
const remountKbLocal = (): void => {
unregisterKbLocal?.()
unregisterKbLocal = undefined
const connection = getNetxConnection()
if (!connection || connection.groupKbPublic !== true) return
unregisterKbLocal = registerKbLocalTools(toolsCtx, getKbContext())
}
remountPublicTools()
const stopWatch = watchNetxConnection(() => { remountPublicTools() })
remountKbLocal()
const stopWatch = watchNetxConnection(() => {
remountPublicTools()
remountKbLocal()
})
const stopKbWatch = watchKbContext(() => { remountKbLocal() })
toolsCtx.effect(() => () => {
stopWatch()
stopKbWatch()
unregisterTools?.()
unregisterKbLocal?.()
}, 'netxops: dispose public tools')
})

View file

@ -11,6 +11,7 @@ import {
} from './capability-groups.ts'
import { registerGroupSkills } from './group-skills.ts'
import { registerKbContextSkill } from './kb-context-skill.ts'
import { registerKbLocalTools } from './kb-local-tools.ts'
import { registerKbPackSkills } from './kb-pack-skills.ts'
import { getKbContext, watchKbContext } from './kb-runtime.ts'
import { getNetxConnection, watchNetxConnection } from './runtime.ts'
@ -33,6 +34,7 @@ export interface GroupToolsPluginOptions {
*/
export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOptions): void {
let unregisterTools: (() => void) | undefined
let unregisterKbLocal: (() => void) | undefined
let unregisterSkills: (() => void) | undefined
let skillGeneration = 0
@ -71,8 +73,22 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt
)
}
const remountKbLocal = (): void => {
unregisterKbLocal?.()
unregisterKbLocal = undefined
const connection = getNetxConnection()
if (connection === undefined) return
if (connection.groupKbInPreset === false) return
unregisterKbLocal = registerKbLocalTools(ctx, getKbContext())
}
remountTools()
const stopToolWatch = watchNetxConnection(() => { remountTools() })
remountKbLocal()
const stopToolWatch = watchNetxConnection(() => {
remountTools()
remountKbLocal()
})
const stopKbToolWatch = watchKbContext(() => { remountKbLocal() })
ctx.inject(['skills'], (skillsCtx) => {
let unregisterKbSkill: (() => void) | undefined
@ -146,7 +162,10 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt
ctx.effect(() => () => {
stopToolWatch()
stopKbToolWatch()
unregisterTools?.()
unregisterTools = undefined
unregisterKbLocal?.()
unregisterKbLocal = undefined
}, `${options.name}: dispose tools`)
}

View file

@ -5,6 +5,7 @@
import type { Context } from '@deepseek-ai/cordis'
import type { KbSnapshot } from './kb-manifest.ts'
import { resolveKbLocalRoot } from './kb-local-path.ts'
const SKILL_NAME = 'kb-context'
@ -14,6 +15,24 @@ function skillBody(snapshot: KbSnapshot): { description: string; content: string
.filter(([, on]) => on)
.map(([key]) => key)
.join(', ') || '(none)'
const localRoot = resolveKbLocalRoot(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.',
]
: [
'',
'No local write tools until `paths.local` is present in MANIFEST.',
]
return {
description:
'Operator knowledge-base context for this Host (paths + identity from MANIFEST).',
@ -27,12 +46,14 @@ function skillBody(snapshot: KbSnapshot): { description: string; content: string
`| --- | --- |`,
`| kbStatus | configured |`,
`| kbRoot | \`${snapshot.realRoot}\` |`,
localRow,
`| kbOperator | ${snapshot.operatorName} |`,
`| kbCountry | ${snapshot.country} |`,
`| kbVersion | ${snapshot.version} |`,
`| kbContent (on) | ${flags} |`,
...localGuide,
'',
'Environment mirrors: KB_ROOT, KB_OPERATOR, KB_COUNTRY, KB_VERSION, KB_CONTENT, KB_STATUS.',
'Environment mirrors: KB_ROOT, KB_LOCAL, KB_OPERATOR, KB_COUNTRY, KB_VERSION, KB_CONTENT, KB_STATUS.',
'',
'Business playbooks (kb-troubleshoot, kb-retrieve, …) register from',
`${snapshot.realRoot}/_skills/ (or MANIFEST paths.skills) when hasSkills is true,`,

272
src/netx/kb-local-ops.ts Normal file
View file

@ -0,0 +1,272 @@
/**
* Filesystem ops for KB local writable trees (create / update / delete / list).
*/
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs'
import { dirname, join } from 'node:path'
import type { KbSnapshot } from './kb-manifest.ts'
import {
draftDir,
draftFileName,
memoryDir,
memoryFileName,
resolveExistingWritableFile,
resolveKbLocalRoot,
resolveWritableLocalPath,
suggestionDir,
suggestionFileName,
type MemoryBucket,
type SuggestionKind,
KB_LOCAL_WRITABLE_ROOTS,
type KbLocalWritableRoot,
} from './kb-local-path.ts'
export type KbLocalWriteResult = {
ok: true
action: 'created' | 'updated' | 'deleted'
absolutePath: string
relativePath: string
}
export type KbLocalListEntry = {
absolutePath: string
relativePath: string
mtimeMs: number
size: number
}
function requireLocalRoot(snapshot: KbSnapshot): string {
const root = resolveKbLocalRoot(snapshot)
if (!root) {
throw new Error('KB local root unavailable (configure kbRoot + MANIFEST paths.local)')
}
return root
}
function ensureDraftBody(body: string): string {
const trimmed = body.replace(/^\uFEFF/, '')
if (/^---\r?\n[\s\S]*?\r?\nstatus:\s*draft\b/m.test(trimmed)
|| /^---\r?\n[\s\S]*?\nstatus:\s*["']?draft["']?\s*$/m.test(trimmed)) {
return trimmed.endsWith('\n') ? trimmed : `${trimmed}\n`
}
// Prepend minimal frontmatter when missing.
if (trimmed.startsWith('---')) {
const end = trimmed.indexOf('\n---', 3)
if (end !== -1) {
const fm = trimmed.slice(0, end + 4)
const rest = trimmed.slice(end + 4)
if (/\nstatus:\s*/.test(fm)) return trimmed.endsWith('\n') ? trimmed : `${trimmed}\n`
const injected = fm.replace(/^---\r?\n/, '---\nstatus: draft\n')
const out = `${injected}${rest}`
return out.endsWith('\n') ? out : `${out}\n`
}
}
return `---\nstatus: draft\n---\n\n${trimmed.endsWith('\n') ? trimmed : `${trimmed}\n`}`
}
export function createMemory(
snapshot: KbSnapshot,
args: {
bucket: MemoryBucket
slug: string
body: string
date?: string
overwrite?: boolean
},
): KbLocalWriteResult {
const localRoot = requireLocalRoot(snapshot)
const name = memoryFileName({ date: args.date, slug: args.slug })
const target = resolveWritableLocalPath(localRoot, memoryDir(args.bucket), 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,
}
}
export function createDraft(
snapshot: KbSnapshot,
args: {
slug: string
body: string
domain?: string
date?: string
overwrite?: boolean
},
): KbLocalWriteResult {
const localRoot = requireLocalRoot(snapshot)
const name = draftFileName({ date: args.date, slug: args.slug })
const target = resolveWritableLocalPath(localRoot, draftDir(args.domain), 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, ensureDraftBody(args.body), 'utf8')
return {
ok: true,
action: existed ? 'updated' : 'created',
absolutePath: target.absolutePath,
relativePath: target.relativePath,
}
}
export function createSuggestion(
snapshot: KbSnapshot,
args: {
kind: SuggestionKind
slug: string
body: string
date?: string
overwrite?: boolean
},
): KbLocalWriteResult {
const localRoot = requireLocalRoot(snapshot)
const name = suggestionFileName({ date: args.date, slug: args.slug })
const target = resolveWritableLocalPath(localRoot, suggestionDir(args.kind), 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.
* Drafts keep/force `status: draft` in frontmatter.
*/
export function updateLocalFile(
snapshot: KbSnapshot,
args: { path: string; body: string },
): KbLocalWriteResult {
const localRoot = requireLocalRoot(snapshot)
const target = resolveExistingWritableFile(localRoot, args.path)
if (!existsSync(target.absolutePath)) {
throw new Error(`file not found: ${target.relativePath}`)
}
const body = target.writableRoot === 'drafts'
? ensureDraftBody(args.body)
: (args.body.endsWith('\n') ? args.body : `${args.body}\n`)
writeFileSync(target.absolutePath, body, 'utf8')
return {
ok: true,
action: 'updated',
absolutePath: target.absolutePath,
relativePath: target.relativePath,
}
}
export function deleteLocalFile(
snapshot: KbSnapshot,
args: { path: string },
): KbLocalWriteResult {
const localRoot = requireLocalRoot(snapshot)
const target = resolveExistingWritableFile(localRoot, args.path)
if (!existsSync(target.absolutePath)) {
throw new Error(`file not found: ${target.relativePath}`)
}
rmSync(target.absolutePath, { force: false })
return {
ok: true,
action: 'deleted',
absolutePath: target.absolutePath,
relativePath: target.relativePath,
}
}
function walkFiles(dir: string, baseLocal: string, out: KbLocalListEntry[]): void {
let entries: string[]
try {
entries = readdirSync(dir)
} catch {
return
}
for (const name of entries) {
if (name === '.' || name === '..') continue
const full = join(dir, name)
let st
try {
st = statSync(full)
} catch {
continue
}
if (st.isDirectory()) {
walkFiles(full, baseLocal, out)
} else if (st.isFile() && name.toLowerCase().endsWith('.md')) {
const rel = full.slice(baseLocal.length).replace(/^[/\\]/, '').split(/[/\\]/).join('/')
out.push({
absolutePath: full,
relativePath: rel,
mtimeMs: st.mtimeMs,
size: st.size,
})
}
}
}
export function listLocalFiles(
snapshot: KbSnapshot,
args: {
root?: KbLocalWritableRoot | 'all'
limit?: number
} = {},
): { ok: true; localRoot: string; entries: KbLocalListEntry[] } {
const localRoot = requireLocalRoot(snapshot)
const roots: KbLocalWritableRoot[] = args.root && args.root !== 'all'
? [args.root]
: [...KB_LOCAL_WRITABLE_ROOTS]
const entries: KbLocalListEntry[] = []
for (const r of roots) {
walkFiles(join(localRoot, r), localRoot, entries)
}
entries.sort((a, b) => b.mtimeMs - a.mtimeMs)
const limit = Math.min(Math.max(args.limit ?? 50, 1), 200)
return {
ok: true,
localRoot,
entries: entries.slice(0, limit),
}
}
/** Read a local writable file (optional helper for tests). */
export function readLocalFile(snapshot: KbSnapshot, pathArg: string): string {
const localRoot = requireLocalRoot(snapshot)
const target = resolveExistingWritableFile(localRoot, pathArg)
return readFileSync(target.absolutePath, 'utf8')
}

209
src/netx/kb-local-path.ts Normal file
View file

@ -0,0 +1,209 @@
/**
* Path jail for operator-subset `_local` agent writes.
* Writable trees: memories / drafts / suggestions only (not identity / local_skills).
*/
import { existsSync, realpathSync, statSync } from 'node:fs'
import { join, relative, resolve, sep } from 'node:path'
import type { KbSnapshot } from './kb-manifest.ts'
/** Top-level dirs under paths.local that agents may create/update/delete. */
export const KB_LOCAL_WRITABLE_ROOTS = Object.freeze([
'memories',
'drafts',
'suggestions',
] as const)
export type KbLocalWritableRoot = (typeof KB_LOCAL_WRITABLE_ROOTS)[number]
export type MemoryBucket = 'note' | 'rca_review' | 'ai_trace'
export type SuggestionKind = 'theory' | 'improvement'
export const MEMORY_BUCKET_DIR: Readonly<Record<MemoryBucket, string>> = Object.freeze({
note: '日常笔记',
rca_review: '排障复盘',
ai_trace: 'AI思维链',
})
/**
* Absolute `paths.local` root, or null when KB not configured / field missing.
*/
export function resolveKbLocalRoot(snapshot: KbSnapshot): string | null {
if (snapshot.status !== 'configured') return null
const rel = snapshot.paths.local?.trim()
if (!rel) return null
return resolve(snapshot.realRoot, rel)
}
/** True when local write tools should register. */
export function kbLocalToolsEnabled(snapshot: KbSnapshot): boolean {
return resolveKbLocalRoot(snapshot) !== null
}
/**
* Sanitize a slug for filenames: strip path separators, keep CJK / alnum / ._- .
*/
export function sanitizeSlug(raw: string, fallback = 'entry'): string {
const cleaned = raw
.trim()
.replace(/[/\\]+/g, '-')
.replace(/\.\.+/g, '')
.replace(/[^\w.\u4e00-\u9fff-]+/gu, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
return cleaned || fallback
}
/** Today's date as YYYY-MM-DD (local). */
export function todayIsoDate(now = new Date()): string {
const y = now.getFullYear()
const m = String(now.getMonth() + 1).padStart(2, '0')
const d = String(now.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
/** Compact YYYYMMDD from ISO date or today. */
export function toCompactDate(isoOrEmpty?: string, now = new Date()): string {
const iso = (isoOrEmpty?.trim() || todayIsoDate(now)).replace(/-/g, '')
if (!/^\d{8}$/.test(iso)) {
throw new Error(`invalid date (want YYYY-MM-DD or YYYYMMDD): ${isoOrEmpty}`)
}
return iso
}
function normalizeIsoDate(raw?: string, now = new Date()): string {
const t = raw?.trim()
if (!t) return todayIsoDate(now)
if (/^\d{4}-\d{2}-\d{2}$/.test(t)) return t
if (/^\d{8}$/.test(t)) {
return `${t.slice(0, 4)}-${t.slice(4, 6)}-${t.slice(6, 8)}`
}
throw new Error(`invalid date (want YYYY-MM-DD or YYYYMMDD): ${raw}`)
}
export function memoryFileName(opts: {
date?: string
slug: string
now?: Date
}): string {
const date = normalizeIsoDate(opts.date, opts.now)
return `${date}-${sanitizeSlug(opts.slug)}.md`
}
export function draftFileName(opts: {
date?: string
slug: string
now?: Date
}): string {
const compact = toCompactDate(opts.date, opts.now)
const slug = sanitizeSlug(opts.slug)
const base = slug.toUpperCase().startsWith('DRAFT-') ? slug : `DRAFT-${compact}-${slug}`
return base.toLowerCase().endsWith('.md') ? base : `${base}.md`
}
export function suggestionFileName(opts: {
date?: string
slug: string
now?: Date
}): string {
const date = normalizeIsoDate(opts.date, opts.now)
return `${date}-${sanitizeSlug(opts.slug)}.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.
*/
export function resolveWritableLocalPath(
localRoot: string,
...segments: string[]
): { absolutePath: string; relativePath: string; writableRoot: KbLocalWritableRoot } {
if (segments.some((s) => s.includes('\0'))) {
throw new Error('path segment contains NUL')
}
const absolutePath = resolve(localRoot, ...segments)
const rel = relative(localRoot, absolutePath)
if (!rel || rel.startsWith('..') || rel.split(/[/\\]/).includes('..')) {
throw new Error(`path escapes local root: ${absolutePath}`)
}
const top = rel.split(/[/\\]/)[0] as string
if (!(KB_LOCAL_WRITABLE_ROOTS as readonly string[]).includes(top)) {
throw new Error(
`writes only allowed under ${KB_LOCAL_WRITABLE_ROOTS.join('|')}/ (got ${top}/)`,
)
}
return {
absolutePath,
relativePath: rel.split(sep).join('/'),
writableRoot: top as KbLocalWritableRoot,
}
}
/**
* Resolve an existing file path (absolute or relative to localRoot) inside the jail.
*/
export function resolveExistingWritableFile(
localRoot: string,
pathArg: string,
): { absolutePath: string; relativePath: string; writableRoot: KbLocalWritableRoot } {
const trimmed = pathArg.trim()
if (!trimmed) throw new Error('path is empty')
const candidate = resolve(trimmed)
// Prefer absolute if it already sits under localRoot; else treat as relative.
let absolutePath: string
const localResolved = resolve(localRoot)
const underLocal =
candidate === localResolved
|| candidate.startsWith(localResolved + sep)
if (underLocal) {
absolutePath = candidate
} else {
absolutePath = resolve(localRoot, trimmed)
}
const checked = resolveWritableLocalPath(
localRoot,
...relative(localRoot, absolutePath).split(/[/\\]/).filter(Boolean),
)
// Prefer realpath when the file (or a parent) exists, to defeat symlink escapes.
try {
if (existsSync(checked.absolutePath)) {
const realFile = realpathSync(checked.absolutePath)
const realLocal = realpathSync(localRoot)
const realRel = relative(realLocal, realFile)
if (!realRel || realRel.startsWith('..') || realRel.split(/[/\\]/).includes('..')) {
throw new Error(`path escapes local root after realpath: ${realFile}`)
}
const top = realRel.split(/[/\\]/)[0]!
if (!(KB_LOCAL_WRITABLE_ROOTS as readonly string[]).includes(top)) {
throw new Error(`path not under writable roots: ${realRel}`)
}
if (!statSync(realFile).isFile()) {
throw new Error(`not a file: ${realFile}`)
}
return {
absolutePath: realFile,
relativePath: realRel.split(sep).join('/'),
writableRoot: top as KbLocalWritableRoot,
}
}
} catch (error) {
if (error instanceof Error && /escapes|writable|not a file/.test(error.message)) {
throw error
}
}
return checked
}
export function memoryDir(bucket: MemoryBucket): string {
return join('memories', MEMORY_BUCKET_DIR[bucket])
}
export function suggestionDir(kind: SuggestionKind): string {
return join('suggestions', kind)
}
export function draftDir(domain?: string): string {
const d = domain?.trim()
if (!d) return 'drafts'
return join('drafts', sanitizeSlug(d, 'misc'))
}

225
src/netx/kb-local-tools.ts Normal file
View file

@ -0,0 +1,225 @@
/**
* Host-side tools for writing under MANIFEST `paths.local`
* (memories / drafts / suggestions). Bypasses workspace sandbox.
*/
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { KbSnapshot } from './kb-manifest.ts'
import {
createDraft,
createMemory,
createSuggestion,
deleteLocalFile,
listLocalFiles,
updateLocalFile,
} from './kb-local-ops.ts'
import { kbLocalToolsEnabled, resolveKbLocalRoot } from './kb-local-path.ts'
import { getKbContext } from './kb-runtime.ts'
const str = (description?: string) => ({ type: 'string' as const, ...(description ? { description } : {}) })
const bool = (description?: string) => ({ type: 'boolean' as const, ...(description ? { description } : {}) })
const num = (description?: string) => ({ type: 'number' as const, ...(description ? { description } : {}) })
function renderJson(_args: unknown, value: unknown) {
return [{ type: 'text' as const, text: JSON.stringify(value, null, 0) }]
}
const jsonOut = {
schema: { type: 'json' as const },
render: renderJson,
}
function liveSnapshot(): KbSnapshot {
return getKbContext()
}
function tool(
name: string,
description: string,
parameters: Record<string, unknown>,
execute: (args: Record<string, unknown>) => Promise<unknown> | unknown,
) {
return defineTool({
name,
description,
parameters: parameters as never,
output: jsonOut,
timeoutMs: 30_000,
isConcurrencySafe: () => false,
async execute(args) {
try {
return await execute(args as Record<string, unknown>)
} catch (error) {
throw new Error(error instanceof Error ? error.message : String(error))
}
},
})
}
/**
* Register KB local create/update/delete/list tools when snapshot has paths.local.
* @returns disposer (no-op when disabled).
*/
export function registerKbLocalTools(
ctx: Context,
snapshot: KbSnapshot = getKbContext(),
): () => void {
if (!kbLocalToolsEnabled(snapshot)) return () => {}
const localRoot = resolveKbLocalRoot(snapshot)!
const toolsApi = (ctx as { tools?: { register: (t: unknown) => () => void } }).tools
if (!toolsApi || typeof toolsApi.register !== 'function') return () => {}
const entries = [
tool(
'netx__kbWriteMemory',
`Create (or overwrite) one diary-style memory markdown under ${localRoot}/memories/. `
+ 'bucket: note→日常笔记, rca_review→排障复盘, ai_trace→AI思维链. '
+ 'Default create-only; set overwrite=true to replace same filename. '
+ 'Do not use workspace Write — KB is outside the sandbox.',
{
type: 'object',
additionalProperties: false,
required: ['bucket', 'slug', 'body'],
properties: {
bucket: {
type: 'string',
enum: ['note', 'rca_review', 'ai_trace'],
description: 'Memory subdirectory',
},
slug: str('Short filename stem (no path separators)'),
body: str('Full markdown body'),
date: str('Optional YYYY-MM-DD (default today)'),
overwrite: bool('Replace if the target file already exists'),
},
},
(args) => createMemory(liveSnapshot(), {
bucket: args.bucket as 'note' | 'rca_review' | 'ai_trace',
slug: String(args.slug ?? ''),
body: String(args.body ?? ''),
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.',
{
type: 'object',
additionalProperties: false,
required: ['slug', 'body'],
properties: {
slug: str('Case stem (DRAFT- prefix added if missing)'),
body: str('Markdown body (RCA-ish draft)'),
domain: str('Optional fault-domain subfolder under drafts/'),
date: str('Optional event date YYYY-MM-DD or YYYYMMDD'),
overwrite: bool('Replace if the target file already exists'),
},
},
(args) => createDraft(liveSnapshot(), {
slug: String(args.slug ?? ''),
body: String(args.body ?? ''),
domain: args.domain != null ? String(args.domain) : undefined,
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/.',
{
type: 'object',
additionalProperties: false,
required: ['kind', 'slug', 'body'],
properties: {
kind: {
type: 'string',
enum: ['theory', 'improvement'],
description: 'theory = knowledge fix; improvement = tools/skills/process',
},
slug: str('Short filename stem'),
body: str('Full markdown body'),
date: str('Optional YYYY-MM-DD (default today)'),
overwrite: bool('Replace if the target file already exists'),
},
},
(args) => createSuggestion(liveSnapshot(), {
kind: args.kind as 'theory' | 'improvement',
slug: String(args.slug ?? ''),
body: String(args.body ?? ''),
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.',
{
type: 'object',
additionalProperties: false,
required: ['path', 'body'],
properties: {
path: str('Absolute path or path relative to KB local root'),
body: str('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.`,
{
type: 'object',
additionalProperties: false,
required: ['path'],
properties: {
path: str('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.',
{
type: 'object',
additionalProperties: false,
properties: {
root: {
type: 'string',
enum: ['memories', 'drafts', 'suggestions', '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',
limit: typeof args.limit === 'number' ? args.limit : undefined,
}),
),
]
const disposers = entries.map((entry) => toolsApi.register(entry))
ctx.logger.info(
'netxops: kb local tools registered count=%s localRoot=%s',
disposers.length,
localRoot,
)
return () => {
for (const dispose of disposers) dispose()
}
}

View file

@ -8,6 +8,7 @@
import type { KbSnapshot } from './kb-manifest.ts'
import { unconfiguredKbSnapshot } from './kb-manifest.ts'
import { resolveKbLocalRoot } from './kb-local-path.ts'
type Listener = () => void
@ -20,6 +21,7 @@ const STORE_KEY = Symbol.for('dsh-netxops.kb-store')
const ENV_KEYS = [
'KB_ROOT',
'KB_LOCAL',
'KB_OPERATOR',
'KB_COUNTRY',
'KB_VERSION',
@ -83,6 +85,8 @@ export function applyKbEnv(snapshot: KbSnapshot): void {
process.env.KB_STATUS = snapshot.status
if (snapshot.status !== 'configured') return
process.env.KB_ROOT = snapshot.realRoot
const local = resolveKbLocalRoot(snapshot)
if (local) process.env.KB_LOCAL = local
process.env.KB_OPERATOR = snapshot.operatorName
process.env.KB_COUNTRY = snapshot.country
process.env.KB_VERSION = snapshot.version