karajan-code 2.21.0 → 2.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -345,6 +345,29 @@ Opt out: set `telemetry: false` in `~/.karajan/kj.config.yml`
345
345
 
346
346
  ## Recent releases
347
347
 
348
+ > **v2.22.0 released** — Minor. **Project RAG MVP** (KJC-PCS-0049). Karajan now indexes its plans + onboarding briefs (and optionally project sources) into a local vector store and lets you query them semantically from the CLI. Six PRs.
349
+ >
350
+ > | Step | PR | Module |
351
+ > |---|---|---|
352
+ > | 1 | #808 | Vector store on `better-sqlite3` + `sqlite-vec` at `~/.karajan/rag.db` |
353
+ > | 2 | #809 | Ollama embedder adapter (`nomic-embed-text`, dim 768, `localhost:11434`) |
354
+ > | 3 | #810 | Three chunkers: markdown heading hierarchy, plan JSON per-HU, JS/TS export-symbol |
355
+ > | 4 | #811 | Indexer (`indexFile` + `indexProject`), idempotent, embedder failures = warn + continue |
356
+ > | 5 | #812 | Retriever + ranking, kind boost breaks ties (plan +0.05, onboarding +0.03, code 0) |
357
+ > | 6 | #813 | `kj rag` CLI: `index [--with-sources]` + `query <text> [--scope] [--top-k]` |
358
+ >
359
+ > ```bash
360
+ > cd ~/your-project
361
+ > kj onboard # Architecture Brief
362
+ > kj plan generate task.md -y # Plans
363
+ > kj rag index # Seed the vec store
364
+ > kj rag query "how did I handle auth in module X?"
365
+ > ```
366
+ >
367
+ > **Coming in v2.23.0**: MCP tool `kj_rag_query` (other agents query the RAG), HU Board search panel, chokidar watcher for live re-indexing, AST-aware source chunker, BM25 + cosine hybrid scoring.
368
+ >
369
+ > The SEA binary stubs out `src/rag/*` + `src/commands/rag.js` (same pattern as the HU Board) — `kj rag` requires `npm install -g karajan-code`.
370
+ >
348
371
  > **v2.21.0 released** — Minor. **Brownfield Onboarder role**. Karajan now ships a dedicated path to analyze any existing codebase and produce a Markdown Architecture Brief that the planner can consume as automatic context. Closes KJC-TSK-0384 (3 PRs).
349
372
  >
350
373
  > KJC-TSK-0384 (PRs #804 + #805 + #806): **`kj onboard`** runs five deterministic collectors over a project root — directory walk (ignoring `node_modules` / `.git` / `dist` / `build`), git log (commits, branches, hot files via `--name-only` over the last 200 commits), 18 well-known config patterns + `package.json` scripts, ADR-style filenames under `docs/adr/`, `docs/adrs/`, `docs/architecture/`, plus a one-shot bundle. Then optionally synthesises a Markdown Architecture Brief via the new OnboarderRole. Output lands at `~/.karajan/onboarding/<slug>.md`. Flags: `--no-synth` (skip the LLM call, dump the raw collectors — useful for CI), `--output <path>` (override default target). Greenfield projects produce `# Project is greenfield` instead of erroring.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "2.21.0",
3
+ "version": "2.22.0",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
@@ -80,6 +80,7 @@
80
80
  "js-yaml": "^4.1.0",
81
81
  "knip": "^6.14.1",
82
82
  "madge": "^8.0.0",
83
+ "sqlite-vec": "^0.1.9",
83
84
  "valibot": "^1.3.1"
84
85
  },
85
86
  "devDependencies": {
@@ -139,6 +139,36 @@ const huBoardStubPlugin = {
139
139
  },
140
140
  };
141
141
 
142
+ // Same reasoning as the HU Board stub: src/rag/* + src/commands/rag.js
143
+ // depend on better-sqlite3 + sqlite-vec, native modules that esbuild
144
+ // cannot bundle. The SEA binary stubs them out and tells the user to
145
+ // install via npm to get RAG. KJC-PCS-0049 / Step 6.
146
+ const ragStubPlugin = {
147
+ name: "rag-stub",
148
+ setup(build) {
149
+ build.onResolve({ filter: /[\\/](rag[\\/][^\\/]+|commands[\\/]rag)\.js$/ }, (args) => ({
150
+ path: args.path, namespace: "rag-stub",
151
+ }));
152
+ build.onLoad({ filter: /.*/, namespace: "rag-stub" }, () => ({
153
+ contents: `
154
+ const NOT_AVAILABLE = "The RAG subsystem is not available in this standalone binary.\\n" +
155
+ "Install karajan-code from npm to get \\\`kj rag\\\`:\\n" +
156
+ " npm install -g karajan-code\\n";
157
+ function notAvailable() { throw new Error(NOT_AVAILABLE); }
158
+ module.exports = {
159
+ __esModule: false,
160
+ ragIndexCommand: notAvailable, ragQueryCommand: notAvailable,
161
+ openVecStore: notAvailable, OllamaEmbedder: notAvailable,
162
+ indexFile: notAvailable, indexProject: notAvailable,
163
+ query: notAvailable, chunkMarkdown: notAvailable, chunkPlan: notAvailable, chunkSource: notAvailable,
164
+ default: notAvailable,
165
+ };
166
+ `,
167
+ loader: "js",
168
+ }));
169
+ },
170
+ };
171
+
142
172
  /** @type {import('esbuild').BuildOptions} */
143
173
  export const seaBuildOptions = {
144
174
  entryPoints: ["src/cli.js"],
@@ -169,6 +199,6 @@ export const seaBuildOptions = {
169
199
  // throws → collector returns available:false. npm installs get knip
170
200
  // resolved normally from node_modules.
171
201
  external: ["better-sqlite3", "madge", "knip", "oxc-parser", "oxc-resolver"],
172
- plugins: [seaTransformPlugin, huBoardStubPlugin],
202
+ plugins: [seaTransformPlugin, huBoardStubPlugin, ragStubPlugin],
173
203
  logLevel: "info",
174
204
  };
@@ -3,6 +3,7 @@ import { triageCommand } from "../commands/triage.js";
3
3
  import { researcherCommand } from "../commands/researcher.js";
4
4
  import { architectCommand } from "../commands/architect.js";
5
5
  import { onboardCommand } from "../commands/onboard.js";
6
+ import { ragIndexCommand, ragQueryCommand } from "../commands/rag.js";
6
7
  import { auditCommand } from "../commands/audit.js";
7
8
  import { resumeCommand } from "../commands/resume.js";
8
9
  import { boardCommand } from "../commands/board.js";
@@ -98,6 +99,27 @@ export function registerMeta(program, { pkgVersion }) {
98
99
  });
99
100
  });
100
101
 
102
+ const rag = program.command("rag").description("Retrieval-augmented search over Karajan plans, onboarding briefs and project code");
103
+ rag.command("index")
104
+ .description("Index plans + onboarding (and optionally project sources) into the local vector store")
105
+ .option("--with-sources", "Also index the projectDir's JS/TS files")
106
+ .option("--json", "Output the totals as JSON")
107
+ .action(async (flags) => {
108
+ await withConfig(pkgVersion, "rag-index", flags, async ({ config, logger }) => {
109
+ await ragIndexCommand({ config, logger, flags });
110
+ });
111
+ });
112
+ rag.command("query <text>")
113
+ .description("Run a semantic query against the indexed RAG corpus")
114
+ .option("--scope <scope>", "plans | code | onboarding | all (default: all)", "all")
115
+ .option("--top-k <n>", "Number of hits to return (default: 5)", "5")
116
+ .option("--json", "Output the hits as JSON")
117
+ .action(async (text, flags) => {
118
+ await withConfig(pkgVersion, "rag-query", flags, async ({ config, logger }) => {
119
+ await ragQueryCommand({ text, config, logger, flags });
120
+ });
121
+ });
122
+
101
123
  program
102
124
  .command("audit")
103
125
  .description("Analyze codebase health (read-only)")
@@ -0,0 +1,66 @@
1
+ // KJC-PCS-0049 Step 6 — `kj rag` command group. Two subcommands:
2
+ // kj rag index [--project <slug>] [--with-sources]
3
+ // kj rag query <text> [--scope plans|code|onboarding|all] [--top-k N] [--json]
4
+ // Closes the v2.22.0 RAG MVP end-to-end from the terminal.
5
+ import { openVecStore, countChunks } from "../rag/vec-store.js";
6
+ import { OllamaEmbedder } from "../rag/embedder.js";
7
+ import { indexProject } from "../rag/indexer.js";
8
+ import { query } from "../rag/retriever.js";
9
+ import { getKarajanHome } from "../utils/paths.js";
10
+
11
+ function makeEmbedder(config) {
12
+ const cfg = config?.rag?.embedder || {};
13
+ return new OllamaEmbedder({ url: cfg.url, model: cfg.model, dim: cfg.dim });
14
+ }
15
+
16
+ function openDb(config) {
17
+ return openVecStore({ dim: config?.rag?.embedder?.dim || 768 });
18
+ }
19
+
20
+ export async function ragIndexCommand({ config, logger, flags = {} }) {
21
+ const projectDir = config?.projectDir || process.cwd();
22
+ const db = openDb(config);
23
+ try {
24
+ const totals = await indexProject(projectDir, {
25
+ db, embedder: makeEmbedder(config),
26
+ karajanHome: getKarajanHome(), logger,
27
+ withSources: Boolean(flags.withSources),
28
+ });
29
+ if (flags.json) {
30
+ process.stdout.write(`${JSON.stringify(totals)}\n`);
31
+ } else {
32
+ logger.info(`[rag] indexed ${totals.indexed} chunk(s) across ${totals.files} file(s) (${totals.failed} failed)`);
33
+ }
34
+ return totals;
35
+ } finally {
36
+ db.close();
37
+ }
38
+ }
39
+
40
+ export async function ragQueryCommand({ text, config, logger, flags = {} }) {
41
+ if (!text) throw new Error("kj rag query: text argument required");
42
+ const db = openDb(config);
43
+ try {
44
+ if (countChunks(db) === 0) {
45
+ logger.warn("[rag] No chunks indexed yet. Run 'kj rag index' first.");
46
+ if (flags.json) process.stdout.write("[]\n");
47
+ return [];
48
+ }
49
+ const topK = Math.max(1, Number(flags.topK) || 5);
50
+ const scope = flags.scope || "all";
51
+ const hits = await query(db, makeEmbedder(config), text, { topK, scope });
52
+ if (flags.json) {
53
+ process.stdout.write(`${JSON.stringify(hits)}\n`);
54
+ } else {
55
+ for (const h of hits) {
56
+ const label = h.metadata?.hu_id || h.metadata?.symbol || h.metadata?.headingPath?.join(" > ") || "block";
57
+ logger.info(`[${h.kind} · ${label} · score=${h.score.toFixed(4)}] ${h.source}`);
58
+ logger.info(h.text.length > 240 ? `${h.text.slice(0, 240)}…` : h.text);
59
+ logger.info("");
60
+ }
61
+ }
62
+ return hits;
63
+ } finally {
64
+ db.close();
65
+ }
66
+ }
@@ -0,0 +1,119 @@
1
+ // KJC-PCS-0049 Step 3 — Chunkers for the RAG indexer.
2
+ // Each chunker is pure: takes text + opts, returns
3
+ // `[{ text, metadata: { source, kind, ...extras } }]`. The indexer
4
+ // (Step 4) hands every chunk to the embedder + vec store; the
5
+ // retriever (Step 5) reads metadata back to enrich results.
6
+
7
+ const DEFAULT_CHUNK_LIMIT = 800;
8
+ const DEFAULT_OVERLAP = 100;
9
+
10
+ // ---------- shared splitter ---------------------------------------
11
+
12
+ /**
13
+ * Split a long string into overlapping windows. Used by every chunker
14
+ * that produces a single section too big for the embedder's context.
15
+ */
16
+ function windowText(text, { limit = DEFAULT_CHUNK_LIMIT, overlap = DEFAULT_OVERLAP } = {}) {
17
+ if (text.length <= limit) return [text];
18
+ const step = Math.max(1, limit - overlap);
19
+ const out = [];
20
+ for (let i = 0; i < text.length; i += step) {
21
+ out.push(text.slice(i, i + limit));
22
+ if (i + limit >= text.length) break;
23
+ }
24
+ return out;
25
+ }
26
+
27
+ // ---------- markdown chunker (plans / onboarding briefs) ----------
28
+
29
+ const HEADING_RE = /^(#{1,6})\s+(.+)$/gm;
30
+
31
+ /**
32
+ * Chunk a markdown document by heading hierarchy. The headingPath
33
+ * metadata tracks ancestors so a retrieval result can show "from
34
+ * '# Plan' › '## Auth' › '### Login'".
35
+ */
36
+ export function chunkMarkdown(text, { path = "<inline>", kind = "plan", limit = DEFAULT_CHUNK_LIMIT, overlap = DEFAULT_OVERLAP } = {}) {
37
+ const sections = [];
38
+ const stack = []; // [{ depth, title }]
39
+ let cursor = 0;
40
+ let pending = { headingPath: [], start: 0 };
41
+ HEADING_RE.lastIndex = 0;
42
+ let m;
43
+ while ((m = HEADING_RE.exec(text)) !== null) {
44
+ if (m.index > cursor) {
45
+ pending.body = text.slice(pending.start, m.index).trim();
46
+ if (pending.body) sections.push({ ...pending });
47
+ }
48
+ const depth = m[1].length;
49
+ const title = m[2].trim();
50
+ while (stack.length > 0 && stack[stack.length - 1].depth >= depth) stack.pop();
51
+ stack.push({ depth, title });
52
+ pending = { headingPath: stack.map((s) => s.title), start: HEADING_RE.lastIndex };
53
+ cursor = HEADING_RE.lastIndex;
54
+ }
55
+ const tail = text.slice(pending.start).trim();
56
+ if (tail) sections.push({ ...pending, body: tail });
57
+ const chunks = [];
58
+ for (const sec of sections) {
59
+ const pieces = windowText(sec.body, { limit, overlap });
60
+ for (const piece of pieces) {
61
+ chunks.push({ text: piece, metadata: { source: path, kind, headingPath: sec.headingPath } });
62
+ }
63
+ }
64
+ return chunks;
65
+ }
66
+
67
+ // ---------- plan chunker (one chunk per HU) -----------------------
68
+
69
+ /**
70
+ * Chunk a Karajan plan JSON. One chunk per HU; an extra chunk for the
71
+ * plan-level summary if present. The retriever uses hu_id to deep-link
72
+ * back to the board.
73
+ */
74
+ export function chunkPlan(plan, { path = "<plan>", limit = DEFAULT_CHUNK_LIMIT } = {}) {
75
+ const out = [];
76
+ if (plan?.summary) {
77
+ const pieces = windowText(String(plan.summary), { limit });
78
+ for (const piece of pieces) {
79
+ out.push({ text: piece, metadata: { source: path, kind: "plan", section: "summary" } });
80
+ }
81
+ }
82
+ for (const hu of plan?.hus || []) {
83
+ const parts = [hu.title, hu.description, hu.scope].filter(Boolean).join("\n\n");
84
+ if (!parts.trim()) continue;
85
+ for (const piece of windowText(parts, { limit })) {
86
+ out.push({ text: piece, metadata: { source: path, kind: "plan", hu_id: hu.id || null, title: hu.title || null } });
87
+ }
88
+ }
89
+ return out;
90
+ }
91
+
92
+ // ---------- source chunker (export-symbol granularity) ------------
93
+
94
+ const EXPORT_RE = /^export\s+(?:async\s+)?(?:function|class|const|let)\s+(\w+)/gm;
95
+
96
+ /**
97
+ * Chunk a JS/TS source file by top-level export symbol. Falls back to
98
+ * the windowed splitter on files without explicit exports.
99
+ */
100
+ export function chunkSource(text, { path = "<src>", limit = DEFAULT_CHUNK_LIMIT, overlap = DEFAULT_OVERLAP } = {}) {
101
+ const marks = [];
102
+ EXPORT_RE.lastIndex = 0;
103
+ let m;
104
+ while ((m = EXPORT_RE.exec(text)) !== null) marks.push({ idx: m.index, symbol: m[1] });
105
+ if (marks.length === 0) {
106
+ return windowText(text, { limit, overlap })
107
+ .map((piece) => ({ text: piece, metadata: { source: path, kind: "code", symbol: null } }));
108
+ }
109
+ const out = [];
110
+ for (let i = 0; i < marks.length; i += 1) {
111
+ const start = marks[i].idx;
112
+ const end = i + 1 < marks.length ? marks[i + 1].idx : text.length;
113
+ const body = text.slice(start, end);
114
+ for (const piece of windowText(body, { limit, overlap })) {
115
+ out.push({ text: piece, metadata: { source: path, kind: "code", symbol: marks[i].symbol } });
116
+ }
117
+ }
118
+ return out;
119
+ }
@@ -0,0 +1,81 @@
1
+ // KJC-PCS-0049 Step 2 — Ollama embedder adapter for the RAG pipeline.
2
+ // Talks to a local Ollama server (POST /api/embeddings) and returns
3
+ // Float32Array vectors. Cero deps externas; usa fetch global.
4
+ //
5
+ // Defaults:
6
+ // url = process.env.KJ_OLLAMA_URL or "http://localhost:11434"
7
+ // model = process.env.KJ_OLLAMA_EMBED_MODEL or "nomic-embed-text"
8
+ // dim = 768 (matches nomic-embed-text)
9
+ // timeoutMs = 30000
10
+
11
+ const DEFAULT_URL = "http://localhost:11434";
12
+ const DEFAULT_MODEL = "nomic-embed-text";
13
+ const DEFAULT_DIM = 768;
14
+ const DEFAULT_TIMEOUT_MS = 30000;
15
+
16
+ export class OllamaEmbedderError extends Error {
17
+ constructor(message, { cause, status } = {}) {
18
+ super(message);
19
+ this.name = "OllamaEmbedderError";
20
+ if (cause) this.cause = cause;
21
+ if (status != null) this.status = status;
22
+ }
23
+ }
24
+
25
+ export class OllamaEmbedder {
26
+ constructor({
27
+ url = process.env.KJ_OLLAMA_URL || DEFAULT_URL,
28
+ model = process.env.KJ_OLLAMA_EMBED_MODEL || DEFAULT_MODEL,
29
+ dim = DEFAULT_DIM,
30
+ timeoutMs = DEFAULT_TIMEOUT_MS,
31
+ fetchFn = globalThis.fetch,
32
+ } = {}) {
33
+ this.url = url.replace(/\/$/, "");
34
+ this.model = model;
35
+ this.dim = dim;
36
+ this.timeoutMs = timeoutMs;
37
+ this.fetch = fetchFn;
38
+ }
39
+
40
+ /** Single-text embedding. Returns Float32Array(dim). */
41
+ async embed(text) {
42
+ if (typeof text !== "string" || text.length === 0) {
43
+ throw new OllamaEmbedderError("embed: text must be a non-empty string");
44
+ }
45
+ const ctrl = new AbortController();
46
+ const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
47
+ let res;
48
+ try {
49
+ res = await this.fetch(`${this.url}/api/embeddings`, {
50
+ method: "POST",
51
+ headers: { "Content-Type": "application/json" },
52
+ body: JSON.stringify({ model: this.model, prompt: text }),
53
+ signal: ctrl.signal,
54
+ });
55
+ } catch (err) {
56
+ throw new OllamaEmbedderError(`Ollama embed request failed (${this.url}): ${err.message}`, { cause: err });
57
+ } finally {
58
+ clearTimeout(timer);
59
+ }
60
+ if (!res.ok) {
61
+ throw new OllamaEmbedderError(`Ollama embed HTTP ${res.status} from ${this.url}`, { status: res.status });
62
+ }
63
+ const body = await res.json();
64
+ const arr = body?.embedding;
65
+ if (!Array.isArray(arr) || arr.length === 0) {
66
+ throw new OllamaEmbedderError("Ollama response missing 'embedding' array");
67
+ }
68
+ if (arr.length !== this.dim) {
69
+ throw new OllamaEmbedderError(`Ollama dim mismatch: got ${arr.length}, expected ${this.dim} for model ${this.model}`);
70
+ }
71
+ return Float32Array.from(arr);
72
+ }
73
+
74
+ /** Batch embedding (sequential — Ollama /api/embeddings is single-text). */
75
+ async embedBatch(texts) {
76
+ if (!Array.isArray(texts)) throw new OllamaEmbedderError("embedBatch: texts must be an array");
77
+ const out = [];
78
+ for (const t of texts) out.push(await this.embed(t));
79
+ return out;
80
+ }
81
+ }
@@ -0,0 +1,116 @@
1
+ // KJC-PCS-0049 Step 4 — Indexer for the RAG pipeline. Wires the three
2
+ // previous modules together: chunker → embedder → vec store. The CLI
3
+ // (Step 6) drives this; Step 5's retriever reads what gets persisted
4
+ // here. A chokidar watcher version stays out of scope for v2.22.0 —
5
+ // indexProject() is on-demand from `kj rag index`, which keeps the
6
+ // failure mode simple (re-run to refresh).
7
+ import { existsSync, readFileSync } from "node:fs";
8
+ import { readdir } from "node:fs/promises";
9
+ import { extname, join, relative } from "node:path";
10
+
11
+ import { chunkMarkdown, chunkPlan, chunkSource } from "./chunker.js";
12
+ import { insertChunk, deleteChunksBySource } from "./vec-store.js";
13
+
14
+ const ONBOARDING_DIR = "onboarding", PLANS_DIR = "plans";
15
+
16
+ function detectKind(path) {
17
+ const ext = extname(path).toLowerCase();
18
+ const base = path.split("/").pop() || "";
19
+ if (ext === ".json" && (/^plan-.+\.json$/i.test(base) || path.includes(`/${PLANS_DIR}/`))) return "plan";
20
+ if (ext === ".md") return path.includes(`/${ONBOARDING_DIR}/`) ? "onboarding" : "plan";
21
+ if ([".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"].includes(ext)) return "code";
22
+ return null;
23
+ }
24
+
25
+ function chunksFor(path, kind) {
26
+ const text = readFileSync(path, "utf8");
27
+ if (kind === "plan" && extname(path) === ".json") {
28
+ try { return chunkPlan(JSON.parse(text), { path }); }
29
+ catch { return []; }
30
+ }
31
+ if (kind === "plan" || kind === "onboarding") {
32
+ return chunkMarkdown(text, { path, kind });
33
+ }
34
+ if (kind === "code") return chunkSource(text, { path });
35
+ return [];
36
+ }
37
+
38
+ /**
39
+ * Index a single file. Returns `{ indexed, failed }`. Idempotent —
40
+ * pre-existing chunks for this `source` path are deleted before the
41
+ * fresh batch is written, so a second call against the same file
42
+ * leaves the store with exactly the latest chunks (no duplicates).
43
+ */
44
+ export async function indexFile(path, { db, embedder, logger = console } = {}) {
45
+ if (!existsSync(path)) { logger.warn?.(`[rag-indexer] file not found: ${path}`); return { indexed: 0, failed: 0 }; }
46
+ const kind = detectKind(path);
47
+ if (!kind) { logger.warn?.(`[rag-indexer] unknown kind: ${path}`); return { indexed: 0, failed: 0 }; }
48
+ const chunks = chunksFor(path, kind);
49
+ if (!chunks.length) return { indexed: 0, failed: 0 };
50
+ deleteChunksBySource(db, path);
51
+ let indexed = 0;
52
+ let failed = 0;
53
+ for (const ch of chunks) {
54
+ try {
55
+ const embedding = await embedder.embed(ch.text);
56
+ insertChunk(db, { source: path, kind: ch.metadata.kind || kind, text: ch.text, metadata: ch.metadata, embedding });
57
+ indexed += 1;
58
+ } catch (err) {
59
+ failed += 1;
60
+ logger.warn?.(`[rag-indexer] embed failed for ${path} (${chunkLabel(ch)}): ${err.message}`);
61
+ }
62
+ }
63
+ logger.info?.(`[rag-indexer] ${relative(process.cwd(), path)} → ${indexed} chunk(s) (${failed} failed)`);
64
+ return { indexed, failed };
65
+ }
66
+
67
+ function chunkLabel(ch) {
68
+ return ch.metadata?.hu_id || ch.metadata?.symbol || ch.metadata?.headingPath?.join(" > ") || "block";
69
+ }
70
+
71
+ async function listFiles(dir, predicate) {
72
+ const out = [];
73
+ async function walk(d) {
74
+ let ents; try { ents = await readdir(d, { withFileTypes: true }); } catch { return; }
75
+ for (const e of ents) {
76
+ const p = join(d, e.name);
77
+ if (e.isDirectory()) await walk(p); else if (e.isFile() && predicate(p)) out.push(p);
78
+ }
79
+ }
80
+ await walk(dir); return out;
81
+ }
82
+
83
+ /**
84
+ * Index every Karajan-managed asset for the project: every plan-*.json
85
+ * under `<karajanHome>/plans/<slug>/`, the onboarding brief under
86
+ * `<karajanHome>/onboarding/`, and (optionally) source files under the
87
+ * projectDir. Sources are gated behind `withSources: true` because
88
+ * indexing tens of thousands of files is expensive — the CLI exposes
89
+ * it as `--with-sources`.
90
+ */
91
+ export async function indexProject(projectDir, { db, embedder, karajanHome, logger = console, withSources = false } = {}) {
92
+ const totals = { indexed: 0, failed: 0, files: 0 };
93
+ const slug = projectDir.split("/").pop()?.replace(/[^a-zA-Z0-9._-]/g, "-").toLowerCase() || "project";
94
+ const planRoot = join(karajanHome, PLANS_DIR, slug);
95
+ if (existsSync(planRoot)) {
96
+ const plans = await listFiles(planRoot, (p) => /plan-.*\.json$/.test(p));
97
+ for (const p of plans) {
98
+ const r = await indexFile(p, { db, embedder, logger });
99
+ totals.indexed += r.indexed; totals.failed += r.failed; totals.files += 1;
100
+ }
101
+ }
102
+ const onboarding = join(karajanHome, ONBOARDING_DIR, `${slug}.md`);
103
+ if (existsSync(onboarding)) {
104
+ const r = await indexFile(onboarding, { db, embedder, logger });
105
+ totals.indexed += r.indexed; totals.failed += r.failed; totals.files += 1;
106
+ }
107
+ if (withSources) {
108
+ const SKIP = new Set(["node_modules", ".git", "dist", "build", "coverage", ".karajan", ".next"]);
109
+ const sources = await listFiles(projectDir, (p) => /\.(js|mjs|cjs|ts|tsx|jsx)$/.test(p) && !p.split("/").some((seg) => SKIP.has(seg)));
110
+ for (const s of sources) {
111
+ const r = await indexFile(s, { db, embedder, logger });
112
+ totals.indexed += r.indexed; totals.failed += r.failed; totals.files += 1;
113
+ }
114
+ }
115
+ return totals;
116
+ }
@@ -0,0 +1,47 @@
1
+ // KJC-PCS-0049 Step 5 — Retriever for the RAG pipeline. Composes the
2
+ // embedder (Step 2) + vec store (Step 1) into a one-call query API.
3
+ // The CLI (Step 6) and the future MCP tool are the only consumers.
4
+
5
+ import { searchSimilar } from "./vec-store.js";
6
+
7
+ // Kind boosts for rerank: a plan chunk is more "intentional" than a
8
+ // raw code chunk, an onboarding brief sits between them. Tunable via
9
+ // the third argument; the defaults are conservative — they break ties
10
+ // between equidistant chunks but won't reorder by big distance gaps.
11
+ const DEFAULT_KIND_BOOST = { plan: 0.05, onboarding: 0.03, code: 0 };
12
+
13
+ /**
14
+ * Run a natural-language query against the indexed RAG corpus. Returns
15
+ * the top-K chunks ranked by adjusted distance (lower = closer).
16
+ *
17
+ * query(db, embedder, "how did I handle auth in module X?",
18
+ * { topK: 5, scope: 'plans' })
19
+ *
20
+ * @param {Database} db — opened via openVecStore()
21
+ * @param {OllamaEmbedder} embedder
22
+ * @param {string} text — the query
23
+ * @param {Object} [opts]
24
+ * @param {number} [opts.topK=5]
25
+ * @param {'plans'|'code'|'onboarding'|'all'} [opts.scope='all']
26
+ * @param {Object} [opts.kindBoost]
27
+ */
28
+ export async function query(db, embedder, text, { topK = 5, scope = "all", kindBoost = DEFAULT_KIND_BOOST } = {}) {
29
+ if (!text || typeof text !== "string") throw new Error("query: text must be a non-empty string");
30
+ const queryEmbedding = await embedder.embed(text);
31
+ // Over-fetch (topK * 2) so the rerank step has room to reshuffle
32
+ // without dropping a relevant chunk past the cut. Clamped at 50 to
33
+ // avoid loading the entire store on tiny topK calls.
34
+ const fetchK = Math.min(50, topK * 2);
35
+ const scopeKind = scope === "plans" ? "plan" : scope === "code" ? "code" : scope === "onboarding" ? "onboarding" : null;
36
+ const raw = searchSimilar(db, queryEmbedding, fetchK, { kind: scopeKind });
37
+ if (raw.length === 0) return [];
38
+ // Adjust each chunk's distance by its kind boost. The vec store returns
39
+ // cosine distance (smaller = closer); subtracting the boost makes
40
+ // higher-priority kinds appear "closer" without altering the source
41
+ // truth in the DB.
42
+ const reranked = raw.map((r) => ({
43
+ ...r,
44
+ score: r.distance - (kindBoost[r.kind] || 0),
45
+ })).sort((a, b) => a.score - b.score).slice(0, topK);
46
+ return reranked;
47
+ }
@@ -0,0 +1,113 @@
1
+ // KJC-PCS-0049 Step 1 — Vector store sobre better-sqlite3 + sqlite-vec.
2
+ // El RAG indexer (Step 4) escribe; el retriever (Step 5) lee. Una sola
3
+ // DB en `~/.karajan/rag.db` por instalación.
4
+ import { mkdirSync, existsSync } from "node:fs";
5
+ import { dirname, join } from "node:path";
6
+ import Database from "better-sqlite3";
7
+ import * as sqliteVec from "sqlite-vec";
8
+
9
+ import { getKarajanHome } from "../utils/paths.js";
10
+
11
+ const DEFAULT_DIM = 768;
12
+
13
+ export function dbPath() {
14
+ return process.env.KJ_RAG_DB || join(getKarajanHome(), "rag.db");
15
+ }
16
+
17
+ /**
18
+ * Open the rag.db, load the sqlite-vec extension, and ensure the
19
+ * chunks + vec_chunks tables exist. Idempotent: a second call against
20
+ * the same file is a no-op past the schema creation.
21
+ *
22
+ * Returns the open Database handle. Caller owns close().
23
+ *
24
+ * @param {Object} [opts]
25
+ * @param {number} [opts.dim=768] — embedding dimension. Must match
26
+ * the embedder configured for the project (Step 2). Changing it
27
+ * later requires a fresh DB; the constant is captured in the
28
+ * virtual table DDL.
29
+ */
30
+ export function openVecStore({ dim = DEFAULT_DIM, path = dbPath() } = {}) {
31
+ if (!existsSync(dirname(path))) mkdirSync(dirname(path), { recursive: true });
32
+ const db = new Database(path);
33
+ sqliteVec.load(db);
34
+ db.pragma("journal_mode = WAL");
35
+ db.pragma("foreign_keys = ON");
36
+ db.exec(`
37
+ CREATE TABLE IF NOT EXISTS chunks (
38
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
39
+ source TEXT NOT NULL,
40
+ kind TEXT NOT NULL CHECK (kind IN ('plan', 'code', 'onboarding')),
41
+ text TEXT NOT NULL,
42
+ metadata TEXT,
43
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
44
+ );
45
+ CREATE INDEX IF NOT EXISTS chunks_by_source ON chunks(source);
46
+ CREATE INDEX IF NOT EXISTS chunks_by_kind ON chunks(kind);
47
+ `);
48
+ db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS vec_chunks USING vec0(embedding float[${dim}]);`);
49
+ return db;
50
+ }
51
+
52
+ /**
53
+ * Persist one chunk. The chunks.id and vec_chunks.rowid stay in sync
54
+ * because the virtual table rowid is set explicitly to the freshly
55
+ * minted chunks.id. Returns that id.
56
+ */
57
+ export function insertChunk(db, { source, kind, text, metadata = null, embedding }) {
58
+ if (!Array.isArray(embedding) && !ArrayBuffer.isView(embedding)) {
59
+ throw new Error("insertChunk: embedding must be an array or typed array of floats");
60
+ }
61
+ const meta = metadata == null ? null : (typeof metadata === "string" ? metadata : JSON.stringify(metadata));
62
+ const ins = db.prepare("INSERT INTO chunks (source, kind, text, metadata) VALUES (?, ?, ?, ?)");
63
+ const info = ins.run(source, kind, text, meta);
64
+ // sqlite-vec's vec0 virtual table requires the rowid to arrive as a BigInt
65
+ // even for values that comfortably fit in a Number. Better-sqlite3 returns
66
+ // lastInsertRowid as BigInt only when safeIntegers mode is on; force it.
67
+ const id = typeof info.lastInsertRowid === "bigint" ? info.lastInsertRowid : BigInt(info.lastInsertRowid);
68
+ const buf = embedding instanceof Float32Array ? embedding : Float32Array.from(embedding);
69
+ db.prepare("INSERT INTO vec_chunks(rowid, embedding) VALUES (?, ?)").run(id, Buffer.from(buf.buffer));
70
+ return Number(id);
71
+ }
72
+
73
+ /**
74
+ * Cosine-ordered nearest-neighbour search. Returns `[{ id, source, kind,
75
+ * text, metadata, distance }]` for the topK best matches; lower
76
+ * distance = closer. Returns `[]` when the store is empty.
77
+ */
78
+ export function searchSimilar(db, embedding, topK = 5, { kind = null } = {}) {
79
+ const buf = embedding instanceof Float32Array ? embedding : Float32Array.from(embedding);
80
+ const kindClause = kind ? "AND c.kind = ?" : "";
81
+ const sql = `
82
+ SELECT c.id, c.source, c.kind, c.text, c.metadata, v.distance
83
+ FROM vec_chunks v
84
+ JOIN chunks c ON c.id = v.rowid
85
+ WHERE v.embedding MATCH ? AND k = ? ${kindClause}
86
+ ORDER BY v.distance
87
+ `;
88
+ const params = [Buffer.from(buf.buffer), topK];
89
+ if (kind) params.push(kind);
90
+ const rows = db.prepare(sql).all(...params);
91
+ return rows.map((r) => ({ ...r, metadata: r.metadata ? safeParse(r.metadata) : null }));
92
+ }
93
+
94
+ function safeParse(s) { try { return JSON.parse(s); } catch { return s; } }
95
+
96
+ /**
97
+ * Remove every chunk that came from a given source path. Both tables
98
+ * are touched explicitly because vec0 virtual tables do not honour
99
+ * SQLite foreign-key cascades.
100
+ */
101
+ export function deleteChunksBySource(db, source) {
102
+ const ids = db.prepare("SELECT id FROM chunks WHERE source = ?").all(source).map((r) => r.id);
103
+ if (ids.length === 0) return 0;
104
+ const placeholders = ids.map(() => "?").join(",");
105
+ db.prepare(`DELETE FROM vec_chunks WHERE rowid IN (${placeholders})`).run(...ids);
106
+ db.prepare(`DELETE FROM chunks WHERE id IN (${placeholders})`).run(...ids);
107
+ return ids.length;
108
+ }
109
+
110
+ export function countChunks(db, { kind = null } = {}) {
111
+ if (kind) return db.prepare("SELECT COUNT(*) AS n FROM chunks WHERE kind = ?").get(kind).n;
112
+ return db.prepare("SELECT COUNT(*) AS n FROM chunks").get().n;
113
+ }