Fix KB loading bugs and add jailed KB read/search tools (0.1.71).
Some checks failed
Release package / package (macos-latest) (push) Has been cancelled
Release package / package (ubuntu-latest) (push) Has been cancelled
Release package / package (windows-latest) (push) Has been cancelled

- 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:
oliver 2026-10-12 21:19:23 +08:00
parent 9b2166e5b6
commit 2def53e7c7
22 changed files with 4286 additions and 1013 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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). */