feat: RFC-003 Phase 6 — Knowledge Layer + Review Fixes #242

Merged
xiaomo merged 2 commits from feat/rfc-003-phase-6-knowledge into main 2026-04-29 06:56:53 +00:00
35 changed files with 1164 additions and 5 deletions
+2 -2
View File
@@ -213,9 +213,9 @@ nerve knowledge query "how does the signal bus work"
# Scope
nerve knowledge query "..." # default: cwd repo
nerve knowledge query -r /path/to/other/repo "..."
nerve knowledge query --repo /path/to/other/repo "..."
nerve knowledge query -g "..." # global search (all indexed repos)
# -r and -g are mutually exclusive
# --repo and -g are mutually exclusive
```
### Search Implementation
+1
View File
@@ -23,6 +23,7 @@
"@uncaged/nerve-core": "workspace:*",
"@uncaged/nerve-store": "workspace:*",
"citty": "^0.1.6",
"picomatch": "^4.0.2",
"yaml": "^2.8.3"
},
"devDependencies": {
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { chunkMarkdown } from "../knowledge/chunk-markdown.js";
describe("chunkMarkdown", () => {
it("splits markdown by headings into separate chunks", () => {
const md = `# Title One
Intro para under first heading.
## Title Two
Second section body.
`;
const chunks = chunkMarkdown("docs/guide.md", md);
expect(chunks.length).toBeGreaterThanOrEqual(2);
const joined = chunks.map((c) => c.text).join("\n");
expect(joined).toContain("Title One");
expect(joined).toContain("Title Two");
});
it("includes preamble before first heading as its own chunk when present", () => {
const md = `Preamble line here.
# First Real Heading
Under heading.
`;
const chunks = chunkMarkdown("readme.md", md);
const preamble = chunks.find((c) => c.slug.includes("preamble"));
expect(preamble).toBeDefined();
expect(preamble?.text).toContain("Preamble");
});
});
@@ -0,0 +1,24 @@
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { listKnowledgeFiles } from "../knowledge/glob-files.js";
describe("listKnowledgeFiles", () => {
it("includes matching paths and applies exclude globs", () => {
const root = mkdtempSync(join(tmpdir(), "nerve-glob-"));
mkdirSync(join(root, "src"), { recursive: true });
writeFileSync(join(root, "src", "keep.ts"), "export function x() {}\n");
writeFileSync(join(root, "src", "drop.test.ts"), "// test\n");
const files = listKnowledgeFiles(root, {
include: ["src/**/*.ts"],
exclude: ["**/*.test.ts"],
});
expect(files).toContain("src/keep.ts");
expect(files).not.toContain("src/drop.test.ts");
});
});
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { knowledgeQueryScopeConflictMessage } from "../knowledge/query-scope.js";
describe("knowledgeQueryScopeConflictMessage", () => {
it("returns null when only -r is used", () => {
expect(knowledgeQueryScopeConflictMessage("/tmp/repo", false)).toBeNull();
});
it("returns null when only -g is used", () => {
expect(knowledgeQueryScopeConflictMessage(undefined, true)).toBeNull();
});
it("returns null when neither -r nor -g", () => {
expect(knowledgeQueryScopeConflictMessage(undefined, false)).toBeNull();
});
it("returns error when both -r and -g", () => {
const msg = knowledgeQueryScopeConflictMessage("/some/path", true);
expect(msg).not.toBeNull();
expect(msg).toContain("-r");
expect(msg).toContain("-g");
});
it("treats empty -r as absent", () => {
expect(knowledgeQueryScopeConflictMessage("", true)).toBeNull();
});
});
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import type { KnowledgeChunkRow } from "../knowledge/knowledge-db.js";
import { rankChunksByWordOverlap } from "../knowledge/query.js";
function chunk(path: string, text: string): KnowledgeChunkRow {
return {
path,
slug: `${path}#0`,
chunkIndex: 0,
text,
embedding: Buffer.alloc(8),
contentHash: "ab",
};
}
describe("rankChunksByWordOverlap", () => {
it("returns higher scores for chunks that share words with the query", () => {
const rows = [
chunk("a.md", "the signal bus emits notifications"),
chunk("b.md", "unrelated cooking recipes"),
];
const ranked = rankChunksByWordOverlap("signal bus", rows, 10);
expect(ranked.length).toBe(2);
expect(ranked[0]?.chunk.path).toBe("a.md");
expect(ranked[1]?.chunk.path).toBe("b.md");
expect(ranked[0]?.score).toBeGreaterThan(ranked[1]?.score ?? 0);
});
it("respects limit", () => {
const rows = [chunk("x.md", "one"), chunk("y.md", "two")];
expect(rankChunksByWordOverlap("one", rows, 1)).toHaveLength(1);
});
});
@@ -0,0 +1,26 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
listRegisteredKnowledgeRoots,
readKnowledgeRegistry,
registerKnowledgeRepoRoot,
} from "../knowledge/registry.js";
describe("knowledge repo registry", () => {
it("accumulates registered repo roots under a nerve home", () => {
const nerveHome = mkdtempSync(join(tmpdir(), "nerve-reg-"));
const repoA = mkdtempSync(join(tmpdir(), "repo-a-"));
const repoB = mkdtempSync(join(tmpdir(), "repo-b-"));
registerKnowledgeRepoRoot(repoA, nerveHome);
registerKnowledgeRepoRoot(repoB, nerveHome);
registerKnowledgeRepoRoot(repoA, nerveHome);
expect(readKnowledgeRegistry(nerveHome).roots).toEqual([repoA, repoB].sort());
expect(listRegisteredKnowledgeRoots(nerveHome)).toEqual([repoA, repoB].sort());
});
});
@@ -0,0 +1,43 @@
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { describe, expect, it } from "vitest";
import { runKnowledgeSync } from "../knowledge/sync.js";
describe("runKnowledgeSync", () => {
it("creates knowledge.db with chunk rows", () => {
const nerveHome = mkdtempSync(join(tmpdir(), "nerve-home-"));
const root = mkdtempSync(join(tmpdir(), "nerve-know-sync-"));
mkdirSync(join(root, "docs"), { recursive: true });
writeFileSync(
join(root, "docs", "a.md"),
`# Hello
Some body text about bananas.
`,
);
writeFileSync(
join(root, "knowledge.yaml"),
`include:
- "docs/**/*.md"
exclude: []
`,
);
const result = runKnowledgeSync(root, nerveHome);
expect(result.chunksWritten).toBeGreaterThan(0);
const db = new DatabaseSync(result.dbPath, { readOnly: true });
try {
const row = db.prepare("SELECT COUNT(*) AS c FROM chunks").get() as { c: number };
expect(row.c).toBe(result.chunksWritten);
} finally {
db.close();
}
});
});
+2
View File
@@ -7,6 +7,7 @@ import { createCommand } from "./commands/create.js";
import { daemonCommand } from "./commands/daemon.js";
import { devCommand } from "./commands/dev.js";
import { initCommand } from "./commands/init.js";
import { knowledgeCommand } from "./commands/knowledge.js";
import { remoteCommand } from "./commands/remote.js";
import { senseCommand } from "./commands/sense.js";
import { storeCommand } from "./commands/store.js";
@@ -46,6 +47,7 @@ const main = defineCommand({
daemon: daemonCommand,
dev: devCommand,
validate: validateCommand,
knowledge: knowledgeCommand,
sense: senseCommand,
store: storeCommand,
remote: remoteCommand,
+1
View File
@@ -78,6 +78,7 @@ const GITIGNORE = `data/
logs/
nerve.pid
node_modules/
knowledge.db
`;
const NERVE_SKILLS_MDC = `---
@@ -0,0 +1,79 @@
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { KNOWLEDGE_DB } from "../knowledge/paths.js";
import { queryKnowledgeGlobal, queryKnowledgeRepo } from "../knowledge/query.js";
import { listRegisteredKnowledgeRoots } from "../knowledge/registry.js";
import { findKnowledgeRepoRoot } from "../knowledge/repo-root.js";
const DEFAULT_LIMIT = 10;
export function parseKnowledgeQueryLimit(raw: string | undefined): number {
if (raw === undefined || raw.trim().length === 0) {
return DEFAULT_LIMIT;
}
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) && n > 0 ? n : DEFAULT_LIMIT;
}
export function runKnowledgeQueryGlobal(queryText: string, limit: number): void {
const roots = listRegisteredKnowledgeRoots();
if (roots.length === 0) {
process.stderr.write(
"❌ No registered repos — run `nerve knowledge sync` in each repo first.\n",
);
process.exit(1);
}
const hits = queryKnowledgeGlobal(roots, KNOWLEDGE_DB, queryText, limit);
if (hits.length === 0) {
process.stdout.write("No results.\n");
return;
}
for (let i = 0; i < hits.length; i++) {
const h = hits[i];
if (h === undefined) continue;
const prefix = h.repoRoot !== null ? `[${h.repoRoot}] ` : "";
process.stdout.write(
`${String(i + 1)}. score=${h.score.toFixed(4)} ${prefix}${h.path} (${h.slug})\n${h.text}\n---\n`,
);
}
}
export function runKnowledgeQueryScoped(
repoFlag: string | undefined,
queryText: string,
limit: number,
): void {
let repoRoot: string | null = null;
if (repoFlag !== undefined && String(repoFlag).trim().length > 0) {
repoRoot = resolve(String(repoFlag).trim());
} else {
repoRoot = findKnowledgeRepoRoot(process.cwd());
}
if (repoRoot === null) {
process.stderr.write("❌ No knowledge.yaml found — use -r <path> or run from a repo root.\n");
process.exit(1);
}
const dbPath = `${repoRoot}/${KNOWLEDGE_DB}`;
if (!existsSync(dbPath)) {
process.stderr.write(
`❌ No ${KNOWLEDGE_DB} in ${repoRoot} — run \`nerve knowledge sync\` first.\n`,
);
process.exit(1);
}
const hits = queryKnowledgeRepo(repoRoot, dbPath, queryText, limit);
if (hits.length === 0) {
process.stdout.write("No results.\n");
return;
}
for (let i = 0; i < hits.length; i++) {
const h = hits[i];
if (h === undefined) continue;
process.stdout.write(
`${String(i + 1)}. score=${h.score.toFixed(4)} ${h.path} (${h.slug})\n${h.text}\n---\n`,
);
}
}
+93
View File
@@ -0,0 +1,93 @@
import { defineCommand } from "citty";
import { knowledgeQueryScopeConflictMessage } from "../knowledge/query-scope.js";
import { findKnowledgeRepoRoot } from "../knowledge/repo-root.js";
import { runKnowledgeSync } from "../knowledge/sync.js";
import {
parseKnowledgeQueryLimit,
runKnowledgeQueryGlobal,
runKnowledgeQueryScoped,
} from "./knowledge-query-run.js";
const syncCommand = defineCommand({
meta: {
name: "sync",
description: "Chunk matching files from knowledge.yaml and rebuild knowledge.db",
},
async run() {
const repoRoot = findKnowledgeRepoRoot(process.cwd());
if (repoRoot === null) {
process.stderr.write(
"❌ No knowledge.yaml found — run from a repo that contains knowledge.yaml.\n",
);
process.exit(1);
}
try {
const result = runKnowledgeSync(repoRoot);
process.stdout.write(
`✅ Indexed ${String(result.filesIndexed)} file(s), ${String(result.chunksWritten)} chunk(s) → ${result.dbPath}\n`,
);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`❌ knowledge sync failed: ${msg}\n`);
process.exit(1);
}
},
});
const queryCommand = defineCommand({
meta: {
name: "query",
description: "Search indexed knowledge (word overlap placeholder until embeddings)",
},
args: {
query: {
type: "positional",
required: true,
description: "Search text",
},
repo: {
type: "string",
description: "Use knowledge.db from another repo root (--repo /path)",
required: false,
},
g: {
type: "boolean",
description: "Search across all repos registered via prior sync",
default: false,
},
limit: {
type: "string",
description: "Max hits (default 10)",
required: false,
},
},
async run({ args }) {
const conflict = knowledgeQueryScopeConflictMessage(args.repo, args.g);
if (conflict !== null) {
process.stderr.write(`${conflict}\n`);
process.exit(1);
}
const queryText = args.query;
const limit = parseKnowledgeQueryLimit(args.limit);
if (args.g) {
runKnowledgeQueryGlobal(queryText, limit);
return;
}
runKnowledgeQueryScoped(args.repo as string, queryText, limit);
},
});
export const knowledgeCommand = defineCommand({
meta: {
name: "knowledge",
description: "Project knowledge index (knowledge.yaml + knowledge.db, RFC-003)",
},
subCommands: {
sync: syncCommand,
query: queryCommand,
},
});
@@ -0,0 +1,88 @@
const HEADING_RE = /^(#{1,6})\s+(.+)$/;
export type MarkdownChunk = {
slug: string;
text: string;
};
function slugPart(title: string): string {
const t = title.trim().toLowerCase().replace(/\s+/g, "-");
const safe = t.replace(/[^a-z0-9_-]+/g, "");
return safe.length > 0 ? safe : "section";
}
function splitLargeMarkdownChunk(slugBase: string, text: string): MarkdownChunk[] {
const maxParagraphs = 24;
const paragraphs = text.split(/\n\s*\n/).filter((p) => p.trim().length > 0);
if (paragraphs.length <= maxParagraphs) {
return [{ slug: slugBase, text }];
}
const chunks: MarkdownChunk[] = [];
let part = 0;
for (let i = 0; i < paragraphs.length; i += maxParagraphs) {
const slice = paragraphs.slice(i, i + maxParagraphs).join("\n\n");
chunks.push({ slug: `${slugBase}-part${String(part)}`, text: slice });
part += 1;
}
return chunks;
}
function headingLineIndices(lines: string[]): number[] {
const headingIdx: number[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line !== undefined && HEADING_RE.test(line)) {
headingIdx.push(i);
}
}
return headingIdx;
}
function chunksFromHeadings(
lines: string[],
headingIdx: number[],
baseSlug: string,
): MarkdownChunk[] {
const chunks: MarkdownChunk[] = [];
const firstHead = headingIdx[0] ?? 0;
if (firstHead > 0) {
const preamble = lines.slice(0, firstHead).join("\n").trim();
if (preamble.length > 0) {
chunks.push(...splitLargeMarkdownChunk(`${baseSlug}#preamble`, preamble));
}
}
for (let h = 0; h < headingIdx.length; h++) {
const start = headingIdx[h] ?? 0;
const end = h + 1 < headingIdx.length ? (headingIdx[h + 1] ?? lines.length) : lines.length;
const block = lines.slice(start, end).join("\n").trim();
if (block.length === 0) {
continue;
}
const titleLine = lines[start] ?? "";
const ht = HEADING_RE.exec(titleLine);
const suffix = ht !== null ? slugPart(ht[2] ?? "h") : `h${String(h)}`;
chunks.push(...splitLargeMarkdownChunk(`${baseSlug}#${suffix}-${String(h)}`, block));
}
return chunks;
}
/**
* Split Markdown by headings; long sections are split further by blank-line paragraphs.
*/
export function chunkMarkdown(relativePath: string, source: string): MarkdownChunk[] {
const lines = source.split(/\r?\n/);
const headingIdx = headingLineIndices(lines);
const baseSlug = relativePath.replace(/\//g, "-");
if (headingIdx.length === 0) {
const text = source.trim();
if (text.length === 0) {
return [];
}
return splitLargeMarkdownChunk(`${baseSlug}#doc`, text);
}
const chunks = chunksFromHeadings(lines, headingIdx, baseSlug);
return chunks;
}
@@ -0,0 +1,87 @@
export type TsJsChunk = {
slug: string;
text: string;
};
/**
* Line starts a function-like declaration (heuristic, no full TS parse).
*/
function isFunctionStartLine(line: string): boolean {
const t = line.trimStart();
if (/^(export\s+)?declare\s+/.test(t)) {
return false;
}
if (/^(export\s+)?(async\s+)?function\s+[A-Za-z_$][\w$]*\s*\(/.test(t)) {
return true;
}
if (/^(export\s+)?const\s+[A-Za-z_$][\w$]*\s*=\s*(async\s*)?\(/.test(t)) {
return true;
}
if (/^(export\s+)?const\s+[A-Za-z_$][\w$]*\s*=\s*async\s+function/.test(t)) {
return true;
}
return false;
}
function slugPart(name: string): string {
const safe = name.replace(/[^\w$-]+/g, "-").toLowerCase();
return safe.length > 0 ? safe : "block";
}
function extractRoughName(firstLine: string): string {
const m =
/function\s+([A-Za-z_$][\w$]*)/.exec(firstLine) ?? /const\s+([A-Za-z_$][\w$]*)/.exec(firstLine);
return m !== null && m[1] !== undefined ? m[1] : "fn";
}
/**
* Split `.ts` / `.js` by top-level function-like lines; falls back to paragraph chunks.
*/
export function chunkTypeScriptOrJavaScript(relativePath: string, source: string): TsJsChunk[] {
const baseSlug = relativePath.replace(/\./g, "-").replace(/\//g, "-");
const lines = source.split(/\r?\n/);
const starts: number[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line !== undefined && isFunctionStartLine(line)) {
starts.push(i);
}
}
if (starts.length === 0) {
return paragraphFallbackChunks(baseSlug, source);
}
const chunks: TsJsChunk[] = [];
for (let s = 0; s < starts.length; s++) {
const start = starts[s] ?? 0;
const end = s + 1 < starts.length ? (starts[s + 1] ?? lines.length) : lines.length;
const block = lines.slice(start, end).join("\n").trim();
if (block.length === 0) {
continue;
}
const first = lines[start] ?? "";
const name = extractRoughName(first);
chunks.push({
slug: `${baseSlug}#${slugPart(name)}-${String(s)}`,
text: block,
});
}
return chunks.length > 0 ? chunks : paragraphFallbackChunks(baseSlug, source);
}
function paragraphFallbackChunks(baseSlug: string, source: string): TsJsChunk[] {
const text = source.trim();
if (text.length === 0) {
return [];
}
const parts = text.split(/\n\s*\n/).filter((p) => p.trim().length > 0);
if (parts.length === 0) {
return [{ slug: `${baseSlug}#0`, text }];
}
return parts.map((p, i) => ({
slug: `${baseSlug}#para-${String(i)}`,
text: p.trim(),
}));
}
+23
View File
@@ -0,0 +1,23 @@
import { chunkMarkdown } from "./chunk-markdown.js";
import { chunkTypeScriptOrJavaScript } from "./chunk-typescript.js";
export type KnowledgeChunk = {
slug: string;
text: string;
};
export function chunkKnowledgeFile(relativePath: string, source: string): KnowledgeChunk[] {
const lower = relativePath.toLowerCase();
if (lower.endsWith(".md")) {
return chunkMarkdown(relativePath, source);
}
if (
lower.endsWith(".ts") ||
lower.endsWith(".tsx") ||
lower.endsWith(".js") ||
lower.endsWith(".jsx")
) {
return chunkTypeScriptOrJavaScript(relativePath, source);
}
return [{ slug: `${relativePath.replace(/\//g, "-")}#0`, text: source.trim() }];
}
@@ -0,0 +1,19 @@
import picomatch from "picomatch";
const PICOMATCH_OPTS = { dot: true } as const;
/**
* True if `relativePosixPath` matches any exclude glob (POSIX slashes).
*/
export function matchesKnowledgeExclude(
relativePosixPath: string,
excludePatterns: ReadonlyArray<string>,
): boolean {
for (const pattern of excludePatterns) {
const isMatch = picomatch(pattern, PICOMATCH_OPTS);
if (isMatch(relativePosixPath)) {
return true;
}
}
return false;
}
@@ -0,0 +1,7 @@
import { createHash } from "node:crypto";
/** Deterministic placeholder embedding bytes until a remote embedding service exists (RFC-003). */
export function fakeEmbeddingBytes(text: string): Buffer {
const hash = createHash("sha256").update(text, "utf8").digest();
return Buffer.concat([hash, hash, hash, hash]);
}
+39
View File
@@ -0,0 +1,39 @@
import { globSync, statSync } from "node:fs";
import { join } from "node:path";
import type { KnowledgeConfig } from "@uncaged/nerve-core";
import { matchesKnowledgeExclude } from "./exclude-match.js";
function toPosix(rel: string): string {
return rel.split("\\").join("/");
}
function isFileUnderRoot(repoRoot: string, rel: string): boolean {
try {
return statSync(join(repoRoot, rel)).isFile();
} catch {
return false;
}
}
/**
* Files matched by `include` globs minus `exclude` globs, relative POSIX paths, sorted.
*/
export function listKnowledgeFiles(repoRoot: string, config: KnowledgeConfig): string[] {
const matched = new Set<string>();
for (const pattern of config.include) {
const paths = globSync(pattern, { cwd: repoRoot });
for (const rel of paths) {
const posix = toPosix(rel);
if (!isFileUnderRoot(repoRoot, posix)) {
continue;
}
if (matchesKnowledgeExclude(posix, config.exclude)) {
continue;
}
matched.add(posix);
}
}
return [...matched].sort();
}
@@ -0,0 +1,96 @@
import { createHash } from "node:crypto";
import { DatabaseSync } from "node:sqlite";
import { fakeEmbeddingBytes } from "./fake-embedding.js";
export type KnowledgeChunkRow = {
path: string;
slug: string;
chunkIndex: number;
text: string;
embedding: Buffer;
contentHash: string;
};
export type KnowledgeChunkInsert = {
path: string;
slug: string;
chunkIndex: number;
text: string;
contentHash: string;
};
const SCHEMA = `
CREATE TABLE IF NOT EXISTS chunks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL,
chunk_index INTEGER NOT NULL,
slug TEXT NOT NULL,
text TEXT NOT NULL,
embedding BLOB NOT NULL,
content_hash TEXT NOT NULL,
UNIQUE(path, chunk_index)
);
CREATE INDEX IF NOT EXISTS idx_chunks_path ON chunks(path);
`;
export function openKnowledgeDb(dbPath: string): DatabaseSync {
const db = new DatabaseSync(dbPath);
db.exec(SCHEMA);
return db;
}
export function contentHash(text: string): string {
return createHash("sha256").update(text, "utf8").digest("hex");
}
export function replaceAllChunks(db: DatabaseSync, rows: KnowledgeChunkInsert[]): void {
db.exec("BEGIN IMMEDIATE");
try {
db.prepare("DELETE FROM chunks").run();
const insert = db.prepare(
`INSERT INTO chunks (path, chunk_index, slug, text, embedding, content_hash)
VALUES (@path, @chunk_index, @slug, @text, @embedding, @content_hash)`,
);
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (row === undefined) continue;
const emb = fakeEmbeddingBytes(row.text);
insert.run({
path: row.path,
chunk_index: row.chunkIndex,
slug: row.slug,
text: row.text,
embedding: emb,
content_hash: row.contentHash,
});
}
db.exec("COMMIT");
} catch (e) {
db.exec("ROLLBACK");
throw e;
}
}
export function loadAllChunks(db: DatabaseSync): KnowledgeChunkRow[] {
const stmt = db.prepare(
"SELECT path, chunk_index, slug, text, embedding, content_hash FROM chunks ORDER BY path, chunk_index",
);
const rows = stmt.all() as Array<{
path: string;
chunk_index: number;
slug: string;
text: string;
embedding: Buffer;
content_hash: string;
}>;
return rows.map((r) => ({
path: r.path,
slug: r.slug,
chunkIndex: r.chunk_index,
text: r.text,
embedding: r.embedding,
contentHash: r.content_hash,
}));
}
+2
View File
@@ -0,0 +1,2 @@
export const KNOWLEDGE_YAML = "knowledge.yaml";
export const KNOWLEDGE_DB = "knowledge.db";
+13
View File
@@ -0,0 +1,13 @@
/**
* `-r` and `-g` are mutually exclusive for `nerve knowledge query`.
*/
export function knowledgeQueryScopeConflictMessage(
repoFlag: string | null | undefined,
globalFlag: boolean,
): string | null {
const hasR = repoFlag !== undefined && repoFlag !== null && String(repoFlag).trim().length > 0;
if (hasR && globalFlag) {
return "❌ Use either -r <path> or -g, not both.";
}
return null;
}
+82
View File
@@ -0,0 +1,82 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import type { KnowledgeChunkRow } from "./knowledge-db.js";
import { loadAllChunks, openKnowledgeDb } from "./knowledge-db.js";
import { wordOverlapScore } from "./word-overlap.js";
export type KnowledgeQueryHit = {
repoRoot: string | null;
path: string;
slug: string;
text: string;
score: number;
};
export function rankChunksByWordOverlap(
query: string,
chunks: KnowledgeChunkRow[],
limit: number,
): Array<{ chunk: KnowledgeChunkRow; score: number }> {
const scored = chunks.map((chunk) => ({
chunk,
score: wordOverlapScore(query, `${chunk.text}\n${chunk.path}`),
}));
scored.sort((a, b) => b.score - a.score);
return scored.slice(0, limit);
}
export function queryKnowledgeRepo(
repoRoot: string,
dbPath: string,
queryText: string,
limit: number,
): KnowledgeQueryHit[] {
const db = openKnowledgeDb(dbPath);
try {
const rows = loadAllChunks(db);
const ranked = rankChunksByWordOverlap(queryText, rows, limit);
return ranked.map((r) => ({
repoRoot,
path: r.chunk.path,
slug: r.chunk.slug,
text: r.chunk.text,
score: r.score,
}));
} finally {
db.close();
}
}
export function queryKnowledgeGlobal(
repoRoots: ReadonlyArray<string>,
dbFileName: string,
queryText: string,
limit: number,
): KnowledgeQueryHit[] {
const combined: KnowledgeQueryHit[] = [];
for (const root of repoRoots) {
const dbPath = join(root, dbFileName);
if (!existsSync(dbPath)) {
continue;
}
const db = openKnowledgeDb(dbPath);
try {
const rows = loadAllChunks(db);
const ranked = rankChunksByWordOverlap(queryText, rows, limit);
for (const r of ranked) {
combined.push({
repoRoot: root,
path: r.chunk.path,
slug: r.chunk.slug,
text: r.chunk.text,
score: r.score,
});
}
} finally {
db.close();
}
}
combined.sort((a, b) => b.score - a.score);
return combined.slice(0, limit);
}
+60
View File
@@ -0,0 +1,60 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { getNerveRoot } from "../workspace.js";
export type KnowledgeRepoRegistry = {
roots: ReadonlyArray<string>;
};
const FILE_NAME = "knowledge-repos.json";
/** When `nerveHome` is omitted, uses `~/.uncaged-nerve`. */
export function getKnowledgeRegistryPath(nerveHome: string | null = null): string {
const root = nerveHome ?? getNerveRoot();
return join(root, "data", FILE_NAME);
}
function defaultRegistry(): KnowledgeRepoRegistry {
return { roots: [] };
}
export function readKnowledgeRegistry(nerveHome: string | null = null): KnowledgeRepoRegistry {
const path = getKnowledgeRegistryPath(nerveHome);
try {
const raw = readFileSync(path, "utf8");
const parsed: unknown = JSON.parse(raw);
if (
typeof parsed === "object" &&
parsed !== null &&
"roots" in parsed &&
Array.isArray(parsed.roots)
) {
const roots = parsed.roots.filter((x): x is string => typeof x === "string");
return { roots: [...new Set(roots)].sort() };
}
} catch {
// missing or invalid — treat as empty
}
return defaultRegistry();
}
export function registerKnowledgeRepoRoot(
repoRootAbsolute: string,
nerveHome: string | null = null,
): void {
const resolved = repoRootAbsolute.trim();
if (resolved.length === 0) {
return;
}
const prev = readKnowledgeRegistry(nerveHome);
const nextRoots = [...new Set([...prev.roots, resolved])].sort();
const next: KnowledgeRepoRegistry = { roots: nextRoots };
const path = getKnowledgeRegistryPath(nerveHome);
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`, "utf8");
}
export function listRegisteredKnowledgeRoots(nerveHome: string | null = null): string[] {
return [...readKnowledgeRegistry(nerveHome).roots];
}
+21
View File
@@ -0,0 +1,21 @@
import { existsSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { KNOWLEDGE_YAML } from "./paths.js";
/**
* Walk upward from `startDir` until `knowledge.yaml` exists.
*/
export function findKnowledgeRepoRoot(startDir: string): string | null {
let dir = resolve(startDir);
while (true) {
if (existsSync(join(dir, KNOWLEDGE_YAML))) {
return dir;
}
const parent = dirname(dir);
if (parent === dir) {
return null;
}
dir = parent;
}
}
+79
View File
@@ -0,0 +1,79 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { type KnowledgeConfig, parseKnowledgeYaml } from "@uncaged/nerve-core";
import { chunkKnowledgeFile } from "./chunk.js";
import { listKnowledgeFiles } from "./glob-files.js";
import { contentHash, openKnowledgeDb, replaceAllChunks } from "./knowledge-db.js";
import { KNOWLEDGE_DB, KNOWLEDGE_YAML } from "./paths.js";
import { registerKnowledgeRepoRoot } from "./registry.js";
export type KnowledgeSyncResult = {
repoRoot: string;
dbPath: string;
filesIndexed: number;
chunksWritten: number;
};
function loadConfig(repoRoot: string): KnowledgeConfig {
const raw = readFileSync(join(repoRoot, KNOWLEDGE_YAML), "utf8");
const parsed = parseKnowledgeYaml(raw);
if (!parsed.ok) {
throw parsed.error;
}
return parsed.value;
}
/**
* @param nerveHomeForRegistry — when set, registers this repo under that Nerve home (for tests); default writes `~/.uncaged-nerve/data/knowledge-repos.json`.
*/
export function runKnowledgeSync(
repoRoot: string,
nerveHomeForRegistry: string | null = null,
): KnowledgeSyncResult {
const config = loadConfig(repoRoot);
const relFiles = listKnowledgeFiles(repoRoot, config);
const inserts: Array<{
path: string;
slug: string;
chunkIndex: number;
text: string;
contentHash: string;
}> = [];
for (const rel of relFiles) {
const abs = join(repoRoot, rel);
const source = readFileSync(abs, "utf8");
const chunks = chunkKnowledgeFile(rel, source);
for (let i = 0; i < chunks.length; i++) {
const ch = chunks[i];
if (ch === undefined) continue;
const text = ch.text;
inserts.push({
path: rel,
slug: ch.slug,
chunkIndex: i,
text,
contentHash: contentHash(text),
});
}
}
const dbPath = join(repoRoot, KNOWLEDGE_DB);
const db = openKnowledgeDb(dbPath);
try {
replaceAllChunks(db, inserts);
} finally {
db.close();
}
registerKnowledgeRepoRoot(repoRoot, nerveHomeForRegistry);
return {
repoRoot,
dbPath,
filesIndexed: relFiles.length,
chunksWritten: inserts.length,
};
}
@@ -0,0 +1,26 @@
function tokenize(s: string): Set<string> {
const parts = s
.toLowerCase()
.split(/[^\w]+/)
.filter((x) => x.length > 0);
return new Set(parts);
}
/**
* Jaccard-like score over word sets (placeholder until real embeddings; RFC-003).
*/
export function wordOverlapScore(query: string, document: string): number {
const q = tokenize(query);
const d = tokenize(document);
if (q.size === 0) {
return 0;
}
let inter = 0;
for (const w of q) {
if (d.has(w)) {
inter += 1;
}
}
const union = q.size + d.size - inter;
return union === 0 ? 0 : inter / union;
}
+7
View File
@@ -0,0 +1,7 @@
declare module "picomatch" {
// biome-ignore lint/style/noDefaultExport: ambient declaration mirrors picomatch default export
export default function picomatch(
glob: string,
options?: { dot?: boolean },
): (input: string) => boolean;
}
@@ -8,7 +8,12 @@ import { join } from "node:path";
import type { NerveConfig } from "@uncaged/nerve-core";
import { KNOWN_AGENT_ADAPTER_IDS } from "@uncaged/nerve-core";
/** Matches RoleSpec `agent: "name"` / `agent: 'name'` in workflow TypeScript sources. */
/**
* Matches RoleSpec `agent: "name"` / `agent: 'name'` in workflow TypeScript sources.
* NOTE: This regex can match occurrences inside comments. For current usage (validation
* hint) this is acceptable — false positives just trigger a "missing agent" warning that
* the user can ignore. If precision becomes important, switch to AST-based extraction.
*/
const WORKFLOW_SPEC_AGENT_PATTERN = /agent:\s*["']([^"']+)["']/g;
function collectTsSourceFiles(dir: string, acc: string[]): void {
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { parseKnowledgeYaml } from "../knowledge-config.js";
describe("parseKnowledgeYaml", () => {
it("parses include and exclude glob lists", () => {
const raw = `
include:
- "src/**/*.ts"
- "docs/**/*.md"
exclude:
- "node_modules/**"
- "*.test.ts"
`;
const result = parseKnowledgeYaml(raw);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect([...result.value.include]).toEqual(["src/**/*.ts", "docs/**/*.md"]);
expect([...result.value.exclude]).toEqual(["node_modules/**", "*.test.ts"]);
});
it("defaults missing include/exclude to empty arrays", () => {
const result = parseKnowledgeYaml("{}");
expect(result.ok).toBe(true);
if (!result.ok) return;
expect([...result.value.include]).toEqual([]);
expect([...result.value.exclude]).toEqual([]);
});
it("allows empty document", () => {
const result = parseKnowledgeYaml("");
expect(result.ok).toBe(true);
if (!result.ok) return;
expect([...result.value.include]).toEqual([]);
expect([...result.value.exclude]).toEqual([]);
});
it("rejects non-array include", () => {
const result = parseKnowledgeYaml("include: foo");
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.message).toContain("include");
});
it("rejects empty string entry in exclude", () => {
const result = parseKnowledgeYaml('exclude:\n - ""');
expect(result.ok).toBe(false);
});
});
+4 -2
View File
@@ -1,5 +1,7 @@
/**
* Agent adapter types that have a daemon implementation (RFC-003).
* Keep in sync with `packages/daemon` agent factory dispatch.
* Keep in sync with `packages/daemon/src/agent-registry.ts` adapter dispatch.
* When adding a new adapter (e.g. cursor, hermes, codex), add it here AND
* add the corresponding factory branch in `createAgentFnForConfig`.
*/
export const KNOWN_AGENT_ADAPTER_IDS = ["echo"] as const;
export const KNOWN_AGENT_ADAPTER_IDS = ["echo", "cursor", "hermes", "codex"] as const;
+2
View File
@@ -35,6 +35,8 @@ export { ExtractError } from "./extract-layer.js";
export type { Result } from "./result.js";
export { ok, err } from "./result.js";
export { parseNerveConfig } from "./parse-nerve-config.js";
export type { KnowledgeConfig } from "./knowledge-config.js";
export { parseKnowledgeYaml } from "./knowledge-config.js";
export { isPlainRecord } from "./is-plain-record.js";
export { KNOWN_AGENT_ADAPTER_IDS } from "./agent-adapter-ids.js";
+64
View File
@@ -0,0 +1,64 @@
import { parse } from "yaml";
import { isPlainRecord } from "./is-plain-record.js";
import type { Result } from "./result.js";
import { err, ok } from "./result.js";
export type KnowledgeConfig = {
include: ReadonlyArray<string>;
exclude: ReadonlyArray<string>;
};
function parseStringList(field: unknown, label: string): Result<ReadonlyArray<string>> {
if (field === undefined || field === null) {
return ok([]);
}
if (!Array.isArray(field)) {
return err(new Error(`${label}: must be an array of strings`));
}
const out: string[] = [];
for (let i = 0; i < field.length; i++) {
const item = field[i];
if (typeof item !== "string" || item.length === 0) {
return err(new Error(`${label}[${String(i)}]: must be a non-empty string`));
}
out.push(item);
}
return ok(out);
}
/**
* Parse `knowledge.yaml` at the repo root (RFC-003 Knowledge Layer).
* `include` / `exclude` entries are glob patterns resolved against the repo root.
*/
export function parseKnowledgeYaml(raw: string): Result<KnowledgeConfig> {
let parsed: unknown;
try {
parsed = parse(raw);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return err(new Error(`YAML parse error: ${message}`));
}
if (parsed === undefined || parsed === null) {
return ok({ include: [], exclude: [] });
}
if (!isPlainRecord(parsed)) {
return err(new Error("knowledge.yaml: root must be a mapping"));
}
const includeResult = parseStringList(parsed.include, "include");
if (!includeResult.ok) {
return includeResult;
}
const excludeResult = parseStringList(parsed.exclude, "exclude");
if (!excludeResult.ok) {
return excludeResult;
}
return ok({
include: includeResult.value,
exclude: excludeResult.value,
});
}
+1
View File
@@ -7,6 +7,7 @@ export { cursorAgent } from "./shared/cursor-agent.js";
export { llmExtract, llmExtractWithRetry } from "./shared/llm-extract.js";
export { mergeExtractConfig, type ExtractConfigLayer } from "./shared/merge-extract-config.js";
export {
assertZodMetaSchemas,
createLlmExtractFn,
extractMetaOrThrow,
type ZodMetaSchema,
@@ -36,9 +36,24 @@ export function createLlmExtractFn<T>(deps: {
}): ExtractFn<T> {
return async (raw, schema) => {
const extended = schema as ZodMetaSchema<T>;
// Runtime check deferred — callers should validate at compile time via assertZodMetaSchema
if (!("zod" in extended)) {
throw new Error("extract: schema must be a ZodMetaSchema (include zod parser)");
}
return extractMetaOrThrow(raw, extended.zod, deps);
};
}
/**
* Validate that all schemas in a WorkflowSpec are ZodMetaSchema at compile time,
* before any role is ever invoked. Call this once at daemon startup / hot-reload.
*/
export function assertZodMetaSchemas(schemas: Record<string, Schema<unknown>>): void {
for (const [roleName, schema] of Object.entries(schemas)) {
if (!("zod" in schema)) {
throw new Error(
`Role "${roleName}": schema must be a ZodMetaSchema (include zod parser). Validate schemas at compile time to catch this early.`,
);
}
}
}
+5
View File
@@ -32,6 +32,9 @@ importers:
citty:
specifier: ^0.1.6
version: 0.1.6
picomatch:
specifier: ^4.0.2
version: 4.0.4
yaml:
specifier: ^2.8.3
version: 2.8.3
@@ -121,6 +124,8 @@ importers:
specifier: ^4.14.0
version: 4.85.0(@cloudflare/workers-types@4.20260425.1)
packages/skills: {}
packages/store:
dependencies:
'@uncaged/nerve-core':