mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-09 02:40:45 +08:00
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:
parent
8360c2e4fc
commit
3a731389f0
20 changed files with 6764 additions and 2635 deletions
18
src/index.ts
18
src/index.ts
|
|
@ -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')
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
272
src/netx/kb-local-ops.ts
Normal 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
209
src/netx/kb-local-path.ts
Normal 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
225
src/netx/kb-local-tools.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue