mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-20 13:30:46 +08:00
Fix KB loading bugs and add jailed KB read/search tools (0.1.71).
- Strip UTF-8 BOM before parsing MANIFEST.json (PowerShell 5.1 output).
- kb.reload force-republishes so edited SKILL.md / new local_skills / identity apply.
- Duplicate or rejected pack skills are skipped instead of unregistering the whole pack.
- kb-context and every pack skill carry resolved absolute paths.* anchors; KB_PATHS env.
- Inline {paths.local}/identity/*.md into kb-context (capped).
- New netx__kbSearch / netx__kbRead host tools, realpath-jailed to kbRoot.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
9b2166e5b6
commit
2def53e7c7
22 changed files with 4286 additions and 1013 deletions
|
|
@ -1001,7 +1001,7 @@ export function apply(ctx: Context, config: ConfigInput<Config> = Config({} as C
|
|||
if (endpoint === 'kb.reload') {
|
||||
const current = source()
|
||||
const kb = resolveKbRoot(current.kbRoot ?? '')
|
||||
publishKbContext(kb)
|
||||
publishKbContext(kb, { force: true })
|
||||
applyKbEnv(kb)
|
||||
if (kb.status === 'configured') {
|
||||
ctx.logger.info(
|
||||
|
|
|
|||
|
|
@ -3,11 +3,51 @@
|
|||
* Tools/scripts still use `process.env.KB_*` from `applyKbEnv`.
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { KbSnapshot } from './kb-manifest.ts'
|
||||
import { renderKbPathTable, type KbSnapshot } from './kb-manifest.ts'
|
||||
import { resolveKbLocalRoot } from './kb-local-path.ts'
|
||||
|
||||
const SKILL_NAME = 'kb-context'
|
||||
/** Per-file / total caps for inlined `_local/identity/*.md` (system-prompt-grade site rules). */
|
||||
const IDENTITY_FILE_MAX = 8_000
|
||||
const IDENTITY_TOTAL_MAX = 24_000
|
||||
|
||||
/**
|
||||
* Site identity files (`{paths.local}/identity/*.md`, README excluded), inlined so the
|
||||
* operator's own boundaries reach the model without an extra read.
|
||||
*/
|
||||
export function readKbIdentity(snapshot: KbSnapshot): Array<{ name: string; text: string }> {
|
||||
const localRoot = resolveKbLocalRoot(snapshot)
|
||||
if (!localRoot) return []
|
||||
const dir = join(localRoot, 'identity')
|
||||
let names: string[]
|
||||
try {
|
||||
names = readdirSync(dir).filter(name => /\.md$/i.test(name) && !/^readme\.md$/i.test(name)).sort()
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
const out: Array<{ name: string; text: string }> = []
|
||||
let budget = IDENTITY_TOTAL_MAX
|
||||
for (const name of names) {
|
||||
if (budget <= 0) break
|
||||
const full = join(dir, name)
|
||||
let text: string
|
||||
try {
|
||||
if (!statSync(full).isFile()) continue
|
||||
text = readFileSync(full, 'utf8').replace(/^\uFEFF/, '').trim()
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (!text) continue
|
||||
const cap = Math.min(IDENTITY_FILE_MAX, budget)
|
||||
if (text.length > cap) text = `${text.slice(0, cap)}\n\n…(truncated — read \`${full}\` for the rest)`
|
||||
budget -= text.length
|
||||
out.push({ name, text })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function skillBody(snapshot: KbSnapshot): { description: string; content: string } {
|
||||
if (snapshot.status === 'configured') {
|
||||
|
|
@ -19,6 +59,14 @@ function skillBody(snapshot: KbSnapshot): { description: string; content: string
|
|||
const localRow = localRoot
|
||||
? `| kbLocal | \`${localRoot}\` |`
|
||||
: '| kbLocal | (MANIFEST paths.local missing) |'
|
||||
const identity = readKbIdentity(snapshot)
|
||||
const identityBlock = identity.length > 0
|
||||
? [
|
||||
'',
|
||||
'### Site identity (`identity/`, authoritative — follow over generic advice)',
|
||||
...identity.flatMap(({ name, text }) => ['', `#### ${name}`, '', text]),
|
||||
]
|
||||
: []
|
||||
const localGuide = localRoot
|
||||
? [
|
||||
'',
|
||||
|
|
@ -46,7 +94,7 @@ function skillBody(snapshot: KbSnapshot): { description: string; content: string
|
|||
]
|
||||
return {
|
||||
description:
|
||||
'Operator knowledge-base context for this Host (HQ pack + local evolution under paths.local).',
|
||||
`Load FIRST before any kb-* skill or KB file access: resolved absolute paths (rca / theory / packet / neIndex / cmdLib / local …) for operator ${snapshot.operatorName} (${snapshot.country}) KB v${snapshot.version}, site identity rules, and KB write rules.`,
|
||||
content: [
|
||||
'## Knowledge base (configured)',
|
||||
'',
|
||||
|
|
@ -62,13 +110,22 @@ function skillBody(snapshot: KbSnapshot): { description: string; content: string
|
|||
`| kbCountry | ${snapshot.country} |`,
|
||||
`| kbVersion | ${snapshot.version} |`,
|
||||
`| kbContent (on) | ${flags} |`,
|
||||
'',
|
||||
'### Path anchors (resolved from MANIFEST `paths.*` — use verbatim, never compose `regions/<country>/<operator>/…`)',
|
||||
'',
|
||||
renderKbPathTable(snapshot),
|
||||
...identityBlock,
|
||||
...localGuide,
|
||||
'',
|
||||
'Environment mirrors: KB_ROOT, KB_LOCAL, KB_OPERATOR, KB_COUNTRY, KB_VERSION, KB_CONTENT, KB_STATUS.',
|
||||
'### Reading the HQ pack',
|
||||
'- `netx__kbSearch` — keyword/regex search across rca / theory / packet / common / local (returns file + line hits).',
|
||||
'- `netx__kbRead` — read one KB file (path relative to kbRoot or absolute inside it; optional line window).',
|
||||
'- Start from the indexes: `neIndex` (NE → cases), `patternMap`, `classification`, then open the cited RCA.',
|
||||
'',
|
||||
'Business playbooks (kb-troubleshoot, kb-retrieve, …) register from',
|
||||
`${snapshot.realRoot}/_skills/ (or MANIFEST paths.skills) when hasSkills is true,`,
|
||||
'plus paths.localSkills when present — use those skills for KB triage;',
|
||||
'Environment mirrors: KB_ROOT, KB_LOCAL, KB_OPERATOR, KB_COUNTRY, KB_VERSION, KB_CONTENT, KB_PATHS (JSON of the anchors above), KB_STATUS.',
|
||||
'',
|
||||
'Business playbooks (kb-troubleshoot, kb-retrieve, …) register from paths.skills',
|
||||
'when hasSkills is true, plus paths.localSkills when present — use those skills for KB triage;',
|
||||
'keep netx-ops for live netx evidence only.',
|
||||
].join('\n'),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* Host-side tools for writing under MANIFEST `paths.local`
|
||||
* (memories / drafts / suggestions / refs). Bypasses workspace sandbox.
|
||||
* Host-side KB tools. Read/search the whole package when configured; write under MANIFEST
|
||||
* `paths.local` (memories / drafts / suggestions / refs). Bypasses workspace sandbox.
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
|
|
@ -15,7 +15,8 @@ import {
|
|||
listLocalFiles,
|
||||
updateLocalFile,
|
||||
} from './kb-local-ops.ts'
|
||||
import { kbLocalToolsEnabled, resolveKbLocalRoot, type RefArea } from './kb-local-path.ts'
|
||||
import { resolveKbLocalRoot, type RefArea } from './kb-local-path.ts'
|
||||
import { readKbFile, searchKb } from './kb-read-ops.ts'
|
||||
import { getKbContext } from './kb-runtime.ts'
|
||||
import { toLosslessJson } from './json-safe.ts'
|
||||
import { getToolMountId, getToolMountStatus, publishToolMountStatus, removeToolMountStatus } from './tool-mount-status.ts'
|
||||
|
|
@ -66,22 +67,8 @@ function tool(
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(),
|
||||
options: { plane?: 'host' | 'preset'; source?: string } = {},
|
||||
): () => 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 = [
|
||||
function writeTools(localRoot: string) {
|
||||
return [
|
||||
tool(
|
||||
'netx__kbWriteMemory',
|
||||
`Create (or overwrite) one diary-style memory markdown under ${localRoot}/memories/. `
|
||||
|
|
@ -206,7 +193,7 @@ export function registerKbLocalTools(
|
|||
tool(
|
||||
'netx__kbListLocal',
|
||||
`List recent .md files under ${localRoot} memories|drafts|suggestions|refs (newest first). `
|
||||
+ 'Read contents via absolute paths from the listing.',
|
||||
+ 'Read contents with netx__kbRead using the listed paths.',
|
||||
{
|
||||
root: {
|
||||
type: 'string' as const,
|
||||
|
|
@ -221,6 +208,65 @@ export function registerKbLocalTools(
|
|||
}),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
function readTools(snapshot: KbSnapshot) {
|
||||
return [
|
||||
tool(
|
||||
'netx__kbSearch',
|
||||
`Search the operator knowledge base at ${snapshot.realRoot} (read-only HQ pack + _local). `
|
||||
+ 'Plain mode: space-separated keywords (NE host_name, alarm, protocol, symptom…), case-insensitive; '
|
||||
+ 'files ranked by how many distinct terms match (filename hits weigh more), up to 4 hit lines each. '
|
||||
+ 'Then open the best hits with netx__kbRead.',
|
||||
{
|
||||
query: reqStr('Keywords (space-separated) or a regex when regex=true'),
|
||||
scope: str('MANIFEST paths key(s) — e.g. rca, theory, theoryVendor, packet, commonPatterns, local, neIndex — or a relative dir; comma-separate several; default all'),
|
||||
regex: bool('Treat query as one case-insensitive JS regex'),
|
||||
limit: num('Max files 1–100 (default 20)'),
|
||||
},
|
||||
(args) => searchKb(liveSnapshot(), {
|
||||
query: String(args.query ?? ''),
|
||||
scope: args.scope != null ? String(args.scope) : undefined,
|
||||
regex: args.regex === true,
|
||||
limit: typeof args.limit === 'number' ? args.limit : undefined,
|
||||
}),
|
||||
),
|
||||
tool(
|
||||
'netx__kbRead',
|
||||
`Read one file (or list one directory) inside the knowledge base at ${snapshot.realRoot}. `
|
||||
+ 'path: relative to kbRoot, absolute inside it, or a MANIFEST paths key (rca, neIndex, cmdLib, patternMap, …). '
|
||||
+ 'Long files: page with offset/limit (1-based lines; default 400).',
|
||||
{
|
||||
path: reqStr('KB-relative path, absolute path inside kbRoot, or a paths.* key'),
|
||||
offset: num('First line (1-based, default 1)'),
|
||||
limit: num('Max lines 1–2000 (default 400)'),
|
||||
},
|
||||
(args) => readKbFile(liveSnapshot(), {
|
||||
path: String(args.path ?? ''),
|
||||
offset: typeof args.offset === 'number' ? args.offset : undefined,
|
||||
limit: typeof args.limit === 'number' ? args.limit : undefined,
|
||||
}),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Register KB tools: read/search whenever configured, plus local create/update/delete/list
|
||||
* when snapshot has paths.local.
|
||||
* @returns disposer (no-op when disabled).
|
||||
*/
|
||||
export function registerKbLocalTools(
|
||||
ctx: Context,
|
||||
snapshot: KbSnapshot = getKbContext(),
|
||||
options: { plane?: 'host' | 'preset'; source?: string } = {},
|
||||
): () => void {
|
||||
if (snapshot.status !== 'configured') return () => {}
|
||||
|
||||
const localRoot = resolveKbLocalRoot(snapshot)
|
||||
const toolsApi = (ctx as { tools?: { register: (t: unknown) => () => void } }).tools
|
||||
if (!toolsApi || typeof toolsApi.register !== 'function') return () => {}
|
||||
|
||||
const entries = [...readTools(snapshot), ...(localRoot ? writeTools(localRoot) : [])]
|
||||
|
||||
const disposers: Array<() => void> = []
|
||||
const source = options.source ?? 'netxops-kb-local'
|
||||
|
|
@ -236,9 +282,10 @@ export function registerKbLocalTools(
|
|||
throw error
|
||||
}
|
||||
ctx.logger.info(
|
||||
'netxops: kb local tools registered count=%s localRoot=%s',
|
||||
'netxops: kb tools registered count=%s kbRoot=%s localRoot=%s',
|
||||
disposers.length,
|
||||
localRoot,
|
||||
snapshot.realRoot,
|
||||
localRoot ?? '(none — read-only)',
|
||||
)
|
||||
return () => {
|
||||
for (const dispose of disposers) dispose()
|
||||
|
|
|
|||
|
|
@ -240,7 +240,8 @@ export function parseManifest(raw: string): {
|
|||
} {
|
||||
let data: unknown
|
||||
try {
|
||||
data = JSON.parse(raw)
|
||||
// PowerShell 5.1 `Out-File/Set-Content -Encoding UTF8` writes a BOM; JSON.parse rejects it.
|
||||
data = JSON.parse(raw.replace(/^\uFEFF/, ''))
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`MANIFEST is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
||||
|
|
@ -335,3 +336,36 @@ export function resolveKbRoot(kbRoot: string, maxDepth = 3): KbSnapshot {
|
|||
return errorSnapshot(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
|
||||
export interface KbResolvedPath {
|
||||
key: string
|
||||
relative: string
|
||||
absolute: string
|
||||
exists: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* MANIFEST `paths.*` resolved against realRoot (stable key order).
|
||||
* Pack skills must read these anchors instead of composing `regions/<c>/<o>/…`.
|
||||
*/
|
||||
export function resolveKbPaths(snapshot: KbSnapshot): KbResolvedPath[] {
|
||||
if (snapshot.status !== 'configured') return []
|
||||
const paths: Record<string, string | undefined> = { skills: '_skills', ...snapshot.paths }
|
||||
return Object.entries(paths)
|
||||
.filter((entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1].trim() !== '')
|
||||
.map(([key, rel]) => {
|
||||
const absolute = resolve(snapshot.realRoot, rel)
|
||||
return { key, relative: rel, absolute, exists: existsSync(absolute) }
|
||||
})
|
||||
}
|
||||
|
||||
/** Markdown anchor table shared by `kb-context` and every registered pack skill. */
|
||||
export function renderKbPathTable(snapshot: KbSnapshot): string {
|
||||
const rows = resolveKbPaths(snapshot)
|
||||
if (rows.length === 0) return '(MANIFEST has no paths — legacy package; ask HQ for a repack)'
|
||||
return [
|
||||
'| paths.* | Absolute path |',
|
||||
'| --- | --- |',
|
||||
...rows.map(row => `| ${row.key} | \`${row.absolute}\`${row.exists ? '' : ' (missing)'} |`),
|
||||
].join('\n')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
import { readdir, stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { KbSnapshot } from './kb-manifest.ts'
|
||||
import { renderKbPathTable, type KbSnapshot } from './kb-manifest.ts'
|
||||
import { loadSkillBundle } from './group-skills.ts'
|
||||
|
||||
export type KbSkillPlane = 'preset' | 'public'
|
||||
|
|
@ -35,6 +35,26 @@ export function resolveKbSkillsRoot(snapshot: KbSnapshot): string {
|
|||
return join(snapshot.realRoot, rel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Appended to every pack skill: the playbooks say "read MANIFEST paths, never compose",
|
||||
* so hand them the resolved anchors instead of costing a MANIFEST read per task.
|
||||
*/
|
||||
export function kbPackSkillFooter(snapshot: KbSnapshot): string {
|
||||
return [
|
||||
'',
|
||||
'',
|
||||
`## Injected KB anchors (netxops · ${snapshot.operatorName} / ${snapshot.country} v${snapshot.version})`,
|
||||
'',
|
||||
`kbStatus=configured · kbRoot=\`${snapshot.realRoot}\``,
|
||||
'',
|
||||
renderKbPathTable(snapshot),
|
||||
'',
|
||||
'These are already resolved from `MANIFEST.json` `paths.*`; use them verbatim.',
|
||||
'Read/search the pack with `netx__kbRead` / `netx__kbSearch` when present; write only via `netx__kbWrite*`.',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute local-skills root from MANIFEST `paths.localSkills`, or null when absent.
|
||||
*/
|
||||
|
|
@ -49,7 +69,14 @@ async function registerSkillsUnderRoot(
|
|||
skillsApi: { register: SkillsRegister },
|
||||
root: string,
|
||||
provider: string,
|
||||
options: { logMissing: boolean; label: string; signal?: AbortSignal },
|
||||
options: {
|
||||
logMissing: boolean
|
||||
label: string
|
||||
signal?: AbortSignal
|
||||
footer: string
|
||||
/** Names already registered by an earlier root (pack before local). */
|
||||
seen: Set<string>
|
||||
},
|
||||
): Promise<Array<() => void>> {
|
||||
let entries: string[]
|
||||
try {
|
||||
|
|
@ -91,15 +118,35 @@ async function registerSkillsUnderRoot(
|
|||
continue
|
||||
}
|
||||
if (options.signal?.aborted) break
|
||||
disposers.push(skillsApi.register({
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
content: skill.content,
|
||||
path: skill.path,
|
||||
resourceBase: { kind: 'directory', path: skill.directory },
|
||||
provider,
|
||||
source: 'custom',
|
||||
}))
|
||||
if (options.seen.has(skill.name)) {
|
||||
ctx.logger.warn(
|
||||
'netxops: kb %s skill %s duplicates an already registered pack skill — skip %s',
|
||||
options.label,
|
||||
skill.name,
|
||||
full,
|
||||
)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
disposers.push(skillsApi.register({
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
content: `${skill.content.trimEnd()}${options.footer}`,
|
||||
path: skill.path,
|
||||
resourceBase: { kind: 'directory', path: skill.directory },
|
||||
provider,
|
||||
source: 'custom',
|
||||
}))
|
||||
options.seen.add(skill.name)
|
||||
} catch (error) {
|
||||
// One rejected bundle must not take the rest of the pack down with it.
|
||||
ctx.logger.warn(
|
||||
'netxops: kb %s skill %s register failed: %s',
|
||||
options.label,
|
||||
skill.name,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
for (const dispose of disposers.splice(0)) dispose()
|
||||
|
|
@ -137,11 +184,15 @@ export async function registerKbPackSkills(
|
|||
|
||||
const skillsRoot = resolveKbSkillsRoot(snapshot)
|
||||
const localRoot = resolveKbLocalSkillsRoot(snapshot)
|
||||
const footer = kbPackSkillFooter(snapshot)
|
||||
const seen = new Set<string>()
|
||||
try {
|
||||
disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, skillsRoot, provider, {
|
||||
logMissing: true,
|
||||
label: 'pack',
|
||||
signal: options.signal,
|
||||
footer,
|
||||
seen,
|
||||
}))
|
||||
|
||||
if (localRoot) {
|
||||
|
|
@ -149,6 +200,8 @@ export async function registerKbPackSkills(
|
|||
logMissing: false,
|
||||
label: 'local',
|
||||
signal: options.signal,
|
||||
footer,
|
||||
seen,
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
|
|||
298
src/netx/kb-read-ops.ts
Normal file
298
src/netx/kb-read-ops.ts
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
/**
|
||||
* Read-only access to the whole operator-subset package (HQ pack + `_local`),
|
||||
* jailed to `realRoot`. Host-side so it works regardless of the workspace sandbox.
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'
|
||||
import { extname, join, relative, resolve, sep } from 'node:path'
|
||||
import { resolveKbPaths, type KbSnapshot } from './kb-manifest.ts'
|
||||
|
||||
const TEXT_EXTENSIONS = new Set(['.md', '.markdown', '.txt', '.json', '.yml', '.yaml', '.csv', '.ps1', '.sh', '.cfg', '.conf', '.log'])
|
||||
const SKIP_DIRS = new Set(['.git', 'node_modules'])
|
||||
const READ_DEFAULT_LINES = 400
|
||||
const READ_MAX_LINES = 2_000
|
||||
const READ_MAX_CHARS = 80_000
|
||||
const SEARCH_MAX_FILE_BYTES = 2 * 1024 * 1024
|
||||
const SEARCH_HITS_PER_FILE = 4
|
||||
const CACHE_MAX_CHARS = 64 * 1024 * 1024
|
||||
|
||||
function requireRoot(snapshot: KbSnapshot): string {
|
||||
if (snapshot.status !== 'configured' || !snapshot.realRoot) {
|
||||
throw new Error('knowledge base not configured (set kbRoot to a folder with MANIFEST.json)')
|
||||
}
|
||||
return snapshot.realRoot
|
||||
}
|
||||
|
||||
function toPosix(rel: string): string {
|
||||
return rel.split(sep).join('/')
|
||||
}
|
||||
|
||||
function isInside(root: string, target: string): boolean {
|
||||
const rel = relative(root, target)
|
||||
return rel === '' || (!rel.startsWith('..') && !rel.split(/[/\\]/).includes('..') && !/^[a-zA-Z]:/.test(rel))
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve `pathArg` (absolute, or relative to kbRoot, or `paths.<key>` shorthand like `rca`)
|
||||
* to a real path inside the package. Symlinks are followed and re-checked.
|
||||
*/
|
||||
export function resolveKbReadPath(snapshot: KbSnapshot, pathArg: string): { absolutePath: string; relativePath: string } {
|
||||
const root = requireRoot(snapshot)
|
||||
const trimmed = pathArg.trim().replace(/^["']|["']$/g, '')
|
||||
if (trimmed.includes('\0')) throw new Error('path contains NUL')
|
||||
const anchor = resolveKbPaths(snapshot).find(row => row.key === trimmed)
|
||||
const candidate = anchor
|
||||
? anchor.absolute
|
||||
: (!trimmed || trimmed === '.' || trimmed === '/') ? root : resolve(root, trimmed)
|
||||
if (!isInside(root, candidate)) throw new Error(`path escapes kbRoot: ${candidate}`)
|
||||
let real: string
|
||||
try {
|
||||
real = realpathSync(candidate)
|
||||
} catch {
|
||||
throw new Error(`not found in KB: ${toPosix(relative(root, candidate)) || '.'}`)
|
||||
}
|
||||
const realRoot = realpathSync(root)
|
||||
if (!isInside(realRoot, real)) throw new Error(`path escapes kbRoot after realpath: ${real}`)
|
||||
return { absolutePath: real, relativePath: toPosix(relative(realRoot, real)) || '.' }
|
||||
}
|
||||
|
||||
export type KbReadResult =
|
||||
| {
|
||||
ok: true
|
||||
kind: 'file'
|
||||
absolutePath: string
|
||||
relativePath: string
|
||||
totalLines: number
|
||||
startLine: number
|
||||
endLine: number
|
||||
truncated: boolean
|
||||
text: string
|
||||
}
|
||||
| {
|
||||
ok: true
|
||||
kind: 'directory'
|
||||
absolutePath: string
|
||||
relativePath: string
|
||||
entries: Array<{ name: string; type: 'dir' | 'file'; size?: number }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a KB file (1-based `offset`, `limit` lines) or list a KB directory.
|
||||
*/
|
||||
export function readKbFile(
|
||||
snapshot: KbSnapshot,
|
||||
args: { path: string; offset?: number; limit?: number },
|
||||
): KbReadResult {
|
||||
const target = resolveKbReadPath(snapshot, args.path)
|
||||
const st = statSync(target.absolutePath)
|
||||
if (st.isDirectory()) {
|
||||
const entries = readdirSync(target.absolutePath, { withFileTypes: true })
|
||||
.filter(entry => !SKIP_DIRS.has(entry.name))
|
||||
.map((entry) => {
|
||||
if (entry.isDirectory()) return { name: entry.name, type: 'dir' as const }
|
||||
let size: number | undefined
|
||||
try { size = statSync(join(target.absolutePath, entry.name)).size } catch { /* ignore */ }
|
||||
return { name: entry.name, type: 'file' as const, size }
|
||||
})
|
||||
.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === 'dir' ? -1 : 1))
|
||||
return { ok: true, kind: 'directory', ...target, entries }
|
||||
}
|
||||
if (!TEXT_EXTENSIONS.has(extname(target.absolutePath).toLowerCase())) {
|
||||
throw new Error(`not a text KB file: ${target.relativePath}`)
|
||||
}
|
||||
const lines = readFileSync(target.absolutePath, 'utf8').replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n').split('\n')
|
||||
const start = Math.max(1, Math.floor(args.offset ?? 1))
|
||||
const limit = Math.min(Math.max(1, Math.floor(args.limit ?? READ_DEFAULT_LINES)), READ_MAX_LINES)
|
||||
let slice = lines.slice(start - 1, start - 1 + limit)
|
||||
let text = slice.join('\n')
|
||||
let truncated = start - 1 + slice.length < lines.length
|
||||
if (text.length > READ_MAX_CHARS) {
|
||||
let used = 0
|
||||
let keep = 0
|
||||
for (const line of slice) {
|
||||
if (used + line.length + 1 > READ_MAX_CHARS) break
|
||||
used += line.length + 1
|
||||
keep += 1
|
||||
}
|
||||
slice = slice.slice(0, Math.max(keep, 1))
|
||||
text = slice.join('\n').slice(0, READ_MAX_CHARS)
|
||||
truncated = true
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
kind: 'file',
|
||||
...target,
|
||||
totalLines: lines.length,
|
||||
startLine: start,
|
||||
endLine: start - 1 + slice.length,
|
||||
truncated,
|
||||
text,
|
||||
}
|
||||
}
|
||||
|
||||
interface CachedFile {
|
||||
mtimeMs: number
|
||||
size: number
|
||||
text: string
|
||||
/** Lower-cased once so plain searches don't re-lowercase every file per term. */
|
||||
lower: string
|
||||
}
|
||||
|
||||
const CACHE_KEY = Symbol.for('dsh-netxops.kb-search-cache')
|
||||
|
||||
function searchCache(): { files: Map<string, CachedFile>; chars: number } {
|
||||
const root = globalThis as typeof globalThis & { [CACHE_KEY]?: { files: Map<string, CachedFile>; chars: number } }
|
||||
root[CACHE_KEY] ??= { files: new Map(), chars: 0 }
|
||||
return root[CACHE_KEY]
|
||||
}
|
||||
|
||||
/** Read through a stat-validated cache so repeated searches don't re-read the pack. */
|
||||
function cachedText(path: string, mtimeMs: number, size: number): CachedFile {
|
||||
const cache = searchCache()
|
||||
const hit = cache.files.get(path)
|
||||
if (hit && hit.mtimeMs === mtimeMs && hit.size === size) return hit
|
||||
const text = readFileSync(path, 'utf8').replace(/^\uFEFF/, '')
|
||||
if (hit) cache.chars -= hit.text.length
|
||||
if (cache.chars + text.length > CACHE_MAX_CHARS) {
|
||||
cache.files.clear()
|
||||
cache.chars = 0
|
||||
}
|
||||
const entry = { mtimeMs, size, text, lower: text.toLowerCase() }
|
||||
cache.files.set(path, entry)
|
||||
cache.chars += text.length
|
||||
return entry
|
||||
}
|
||||
|
||||
function walkTextFiles(dir: string, out: Array<{ path: string; mtimeMs: number; size: number }>): void {
|
||||
let entries
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue
|
||||
const full = join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
walkTextFiles(full, out)
|
||||
continue
|
||||
}
|
||||
if (!entry.isFile() || !TEXT_EXTENSIONS.has(extname(entry.name).toLowerCase())) continue
|
||||
try {
|
||||
const st = statSync(full)
|
||||
if (st.size <= SEARCH_MAX_FILE_BYTES) out.push({ path: full, mtimeMs: st.mtimeMs, size: st.size })
|
||||
} catch {
|
||||
// unreadable — skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface KbSearchHit {
|
||||
relativePath: string
|
||||
absolutePath: string
|
||||
score: number
|
||||
matchedTerms: string[]
|
||||
lines: Array<{ line: number; text: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Search KB text files. Plain mode: whitespace-separated terms, case-insensitive, files
|
||||
* ranked by distinct terms matched (filename hits weigh more). `regex=true` treats the
|
||||
* query as one JS regular expression.
|
||||
*/
|
||||
export function searchKb(
|
||||
snapshot: KbSnapshot,
|
||||
args: { query: string; scope?: string; regex?: boolean; limit?: number },
|
||||
): { ok: true; scope: string; scannedFiles: number; totalMatches: number; hits: KbSearchHit[] } {
|
||||
const root = requireRoot(snapshot)
|
||||
const query = args.query.trim()
|
||||
if (!query) throw new Error('query is empty')
|
||||
const scopeArg = args.scope?.trim() || 'all'
|
||||
const scopes = scopeArg.split(/[,\s]+/).filter(Boolean)
|
||||
const dirs = scopes.includes('all')
|
||||
? [root]
|
||||
: scopes.map(scope => resolveKbReadPath(snapshot, scope).absolutePath)
|
||||
|
||||
/** `test(lower, raw)`: plain terms match the lower-cased text, regex the raw text. */
|
||||
let matchers: Array<{ term: string; test: (lower: string, raw: string) => boolean }>
|
||||
if (args.regex) {
|
||||
let re: RegExp
|
||||
try {
|
||||
re = new RegExp(query, 'i')
|
||||
} catch (error) {
|
||||
throw new Error(`invalid regex: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
matchers = [{ term: query, test: (_lower, raw) => re.test(raw) }]
|
||||
} else {
|
||||
const terms = [...new Set(query.toLowerCase().split(/\s+/).filter(Boolean))]
|
||||
matchers = terms.map(term => ({ term, test: (lower: string) => lower.includes(term) }))
|
||||
}
|
||||
|
||||
const files: Array<{ path: string; mtimeMs: number; size: number }> = []
|
||||
const seen = new Set<string>()
|
||||
for (const dir of dirs) {
|
||||
const st = statSync(dir)
|
||||
const batch: typeof files = []
|
||||
if (st.isFile()) batch.push({ path: dir, mtimeMs: st.mtimeMs, size: st.size })
|
||||
else walkTextFiles(dir, batch)
|
||||
for (const file of batch) {
|
||||
if (seen.has(file.path)) continue
|
||||
seen.add(file.path)
|
||||
files.push(file)
|
||||
}
|
||||
}
|
||||
|
||||
const hits: KbSearchHit[] = []
|
||||
for (const file of files) {
|
||||
let cached: CachedFile
|
||||
try {
|
||||
cached = cachedText(file.path, file.mtimeMs, file.size)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
const rel = toPosix(relative(root, file.path))
|
||||
const matched = new Set<string>()
|
||||
let score = 0
|
||||
for (const m of matchers) {
|
||||
if (m.test(rel.toLowerCase(), rel)) {
|
||||
matched.add(m.term)
|
||||
score += 3
|
||||
}
|
||||
}
|
||||
// Whole-file pre-filter: only split/scan lines of files that contain a term.
|
||||
const inBody = matchers.filter(m => m.test(cached.lower, cached.text))
|
||||
if (inBody.length === 0 && matched.size === 0) continue
|
||||
const lines: Array<{ line: number; text: string }> = []
|
||||
const rows = inBody.length > 0 ? cached.text.split(/\r\n?|\n/) : []
|
||||
let lineHits = 0
|
||||
for (let i = 0; i < rows.length; i += 1) {
|
||||
const row = rows[i]!
|
||||
const lower = row.toLowerCase()
|
||||
let lineHit = false
|
||||
for (const m of inBody) {
|
||||
if (m.test(lower, row)) {
|
||||
matched.add(m.term)
|
||||
lineHit = true
|
||||
}
|
||||
}
|
||||
if (!lineHit) continue
|
||||
lineHits += 1
|
||||
if (lines.length < SEARCH_HITS_PER_FILE) {
|
||||
const trimmed = row.trim()
|
||||
lines.push({ line: i + 1, text: trimmed.length > 240 ? `${trimmed.slice(0, 240)}…` : trimmed })
|
||||
}
|
||||
}
|
||||
// Distinct terms dominate; hit volume only breaks ties so big manuals don't swamp RCAs.
|
||||
score += matched.size * 10 + Math.min(lineHits, 30) * 0.1
|
||||
hits.push({
|
||||
relativePath: rel,
|
||||
absolutePath: file.path,
|
||||
score: Math.round(score * 10) / 10,
|
||||
matchedTerms: [...matched],
|
||||
lines,
|
||||
})
|
||||
}
|
||||
hits.sort((a, b) => b.score - a.score || a.relativePath.localeCompare(b.relativePath))
|
||||
const limit = Math.min(Math.max(Math.floor(args.limit ?? 20), 1), 100)
|
||||
return { ok: true, scope: scopeArg, scannedFiles: files.length, totalMatches: hits.length, hits: hits.slice(0, limit) }
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
*/
|
||||
|
||||
import type { KbSnapshot } from './kb-manifest.ts'
|
||||
import { unconfiguredKbSnapshot } from './kb-manifest.ts'
|
||||
import { resolveKbPaths, unconfiguredKbSnapshot } from './kb-manifest.ts'
|
||||
import { resolveKbLocalRoot } from './kb-local-path.ts'
|
||||
|
||||
type Listener = () => void
|
||||
|
|
@ -26,6 +26,7 @@ const ENV_KEYS = [
|
|||
'KB_COUNTRY',
|
||||
'KB_VERSION',
|
||||
'KB_CONTENT',
|
||||
'KB_PATHS',
|
||||
'KB_STATUS',
|
||||
] as const
|
||||
|
||||
|
|
@ -52,10 +53,12 @@ export function getKbContext(): KbSnapshot {
|
|||
/**
|
||||
* Publish the latest KB snapshot for UI / skill remounts.
|
||||
* @param next - resolved snapshot from `resolveKbRoot`.
|
||||
* @param options.force - notify even when MANIFEST is unchanged (manual reload picks up
|
||||
* edited SKILL.md / new local_skills / identity files on disk).
|
||||
*/
|
||||
export function publishKbContext(next: KbSnapshot): void {
|
||||
export function publishKbContext(next: KbSnapshot, options: { force?: boolean } = {}): void {
|
||||
const state = store()
|
||||
if (JSON.stringify(state.snapshot) === JSON.stringify(next)) return
|
||||
if (!options.force && JSON.stringify(state.snapshot) === JSON.stringify(next)) return
|
||||
state.snapshot = {
|
||||
...next,
|
||||
content: { ...next.content },
|
||||
|
|
@ -94,6 +97,9 @@ export function applyKbEnv(snapshot: KbSnapshot): void {
|
|||
process.env.KB_COUNTRY = snapshot.country
|
||||
process.env.KB_VERSION = snapshot.version
|
||||
process.env.KB_CONTENT = JSON.stringify(snapshot.content)
|
||||
process.env.KB_PATHS = JSON.stringify(
|
||||
Object.fromEntries(resolveKbPaths(snapshot).map(row => [row.key, row.absolute])),
|
||||
)
|
||||
}
|
||||
|
||||
/** Reset store + env to unconfigured (plugin dispose). */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue