karajan-code 2.21.0 → 2.23.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,47 @@ Opt out: set `telemetry: false` in `~/.karajan/kj.config.yml`
345
345
 
346
346
  ## Recent releases
347
347
 
348
+ > **v2.23.0 released** — Minor. **RAG exposed to agents and humans alike** (KJC-PCS-0049, Steps 7+8+Camino A). After v2.22.0's CLI MVP, the corpus is now reachable from three more places.
349
+ >
350
+ > 1. **MCP tools** `kj_rag_query` + `kj_rag_index` (PR #815). Any MCP-connected agent — Claude Desktop, Cursor, Claude Code, Karajan's own roles — can call them. Tool count 25 → 27. Empty store responds `empty: true` so agents have a deterministic recovery signal.
351
+ > 2. **HU Board RAG panel** (PR #816). New input + scope dropdown (All / Plans / Onboarding / Code) + Search button + results pane between the preflight panel and the kanban. `POST /api/rag/query` backs it.
352
+ > 3. **Role templates teach agents about the tool** (PR #817). `templates/roles/{coder,researcher,architect,planner,spec-reviewer}.md` each gain a tailored 'Prior context (RAG, opt-in)' section. Shared rule: when the store is empty, proceed without retrieval — do NOT block, do NOT ask the human to seed.
353
+ >
354
+ > Workflow:
355
+ >
356
+ > ```bash
357
+ > kj onboard
358
+ > kj rag index
359
+ > kj plan generate task.md --use-onboarding
360
+ > # From here on, every agent invocation can call kj_rag_query via MCP.
361
+ > # Humans get the panel on the Board.
362
+ > ```
363
+ >
364
+ > **Coming in v2.24.0+**: Camino B (`/kj-rag-query` slash command for hosts in Skills mode without MCP), Camino C (automatic pre-loop stage that pre-fetches retrieval and prepends it to the coder/researcher/architect prompt without the agent having to ask), Camino D (Brain decisor heuristic for when retrieval is worth the tokens), chokidar watcher for live re-indexing, AST-aware source chunker, BM25 + cosine hybrid scoring.
365
+ >
366
+ > **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.
367
+ >
368
+ > | Step | PR | Module |
369
+ > |---|---|---|
370
+ > | 1 | #808 | Vector store on `better-sqlite3` + `sqlite-vec` at `~/.karajan/rag.db` |
371
+ > | 2 | #809 | Ollama embedder adapter (`nomic-embed-text`, dim 768, `localhost:11434`) |
372
+ > | 3 | #810 | Three chunkers: markdown heading hierarchy, plan JSON per-HU, JS/TS export-symbol |
373
+ > | 4 | #811 | Indexer (`indexFile` + `indexProject`), idempotent, embedder failures = warn + continue |
374
+ > | 5 | #812 | Retriever + ranking, kind boost breaks ties (plan +0.05, onboarding +0.03, code 0) |
375
+ > | 6 | #813 | `kj rag` CLI: `index [--with-sources]` + `query <text> [--scope] [--top-k]` |
376
+ >
377
+ > ```bash
378
+ > cd ~/your-project
379
+ > kj onboard # Architecture Brief
380
+ > kj plan generate task.md -y # Plans
381
+ > kj rag index # Seed the vec store
382
+ > kj rag query "how did I handle auth in module X?"
383
+ > ```
384
+ >
385
+ > **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.
386
+ >
387
+ > 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`.
388
+ >
348
389
  > **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
390
  >
350
391
  > 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.23.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": {
@@ -590,6 +590,18 @@ async function renderBoard() {
590
590
  Comprobando estado del proyecto…
591
591
  </div>
592
592
  </div>
593
+ <div id="rag-panel" class="rag-panel" style="margin:8px 0;display:flex;gap:8px;align-items:flex-start;flex-wrap:wrap;">
594
+ <input id="rag-query" type="text" placeholder="🔍 RAG search: ask anything about this project's plans / onboarding / code…"
595
+ style="flex:1;min-width:260px;padding:6px 10px;font-size:0.85rem;background:var(--bg-primary);border:1px solid var(--border);color:var(--text);border-radius:var(--radius-sm);" />
596
+ <select id="rag-scope" style="padding:6px 10px;font-size:0.85rem;background:var(--bg-primary);border:1px solid var(--border);color:var(--text);border-radius:var(--radius-sm);">
597
+ <option value="all">All</option><option value="plans">Plans</option><option value="onboarding">Onboarding</option><option value="code">Code</option>
598
+ </select>
599
+ <button id="rag-search-btn" class="control-btn"
600
+ style="padding:6px 12px;font-size:0.85rem;background:var(--color-blue,#3b82f6);color:#fff;border:none;border-radius:var(--radius-sm);cursor:pointer;font-weight:600;">
601
+ Search
602
+ </button>
603
+ <div id="rag-results" style="flex-basis:100%;font-size:0.8rem;color:var(--text-muted);"></div>
604
+ </div>
593
605
  <div id="plan-rollup-banner"></div>
594
606
  <div class="kanban">
595
607
  ${visibleColumns.map((c) => renderKanbanColumn(c.title, c.cls, c.rows)).join('')}
@@ -694,6 +706,39 @@ async function renderBoard() {
694
706
  }
695
707
  });
696
708
  }
709
+
710
+ // RAG search panel (KJC-PCS-0049 Step 8) — input + scope + render hits.
711
+ const ragInput = document.getElementById('rag-query');
712
+ const ragScope = document.getElementById('rag-scope');
713
+ const ragBtn = document.getElementById('rag-search-btn');
714
+ const ragResults = document.getElementById('rag-results');
715
+ async function runRagSearch() {
716
+ const text = ragInput?.value?.trim();
717
+ if (!text) return;
718
+ ragResults.innerHTML = '<em>Searching…</em>';
719
+ try {
720
+ const res = await fetch('/api/rag/query', {
721
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
722
+ body: JSON.stringify({ text, topK: 5, scope: ragScope?.value || 'all' }),
723
+ });
724
+ const body = await res.json();
725
+ if (!res.ok) { ragResults.innerHTML = `<span style="color:var(--color-red);">Error: ${esc(body.error || res.status)}</span>`; return; }
726
+ if (body.empty) { ragResults.innerHTML = '<em>No chunks indexed yet. Run <code>kj rag index</code> in this project\'s terminal first.</em>'; return; }
727
+ if (body.hits.length === 0) { ragResults.innerHTML = '<em>No hits.</em>'; return; }
728
+ ragResults.innerHTML = body.hits.map((h) => {
729
+ const label = h.metadata?.hu_id || h.metadata?.symbol || h.metadata?.headingPath?.join(' > ') || 'block';
730
+ const snippet = h.text.length > 240 ? `${esc(h.text.slice(0, 240))}…` : esc(h.text);
731
+ return `<div style="border-left:3px solid var(--color-blue,#3b82f6);padding:6px 10px;margin:6px 0;background:var(--bg-primary);">
732
+ <div style="font-size:0.75rem;color:var(--text-muted);margin-bottom:4px;">[${esc(h.kind)} · ${esc(label)} · score=${h.score.toFixed(4)}] ${esc(h.source)}</div>
733
+ <div style="font-size:0.85rem;white-space:pre-wrap;">${snippet}</div>
734
+ </div>`;
735
+ }).join('');
736
+ } catch (err) {
737
+ ragResults.innerHTML = `<span style="color:var(--color-red);">Error: ${esc(err.message)}</span>`;
738
+ }
739
+ }
740
+ ragBtn?.addEventListener('click', runRagSearch);
741
+ ragInput?.addEventListener('keydown', (e) => { if (e.key === 'Enter') runRagSearch(); });
697
742
  } catch (err) {
698
743
  app.innerHTML = `<div class="empty-state"><div class="empty-state__title">Error loading board</div><div class="empty-state__text">${esc(err.message)}</div></div>`;
699
744
  }
@@ -1279,4 +1279,31 @@ router.post('/sync', (_req, res) => {
1279
1279
  }
1280
1280
  });
1281
1281
 
1282
+ /**
1283
+ * POST /api/rag/query - Semantic search over the indexed RAG corpus
1284
+ * (KJC-PCS-0049 Step 8). Body: { text, topK?, scope? }. Response:
1285
+ * { hits: [...], empty, topK, scope }. Empty store carries empty=true
1286
+ * so the UI can show an actionable hint instead of just empty results.
1287
+ */
1288
+ router.post('/rag/query', async (req, res) => {
1289
+ const { text, topK = 5, scope = 'all' } = req.body || {};
1290
+ if (typeof text !== 'string' || text.length === 0) {
1291
+ return res.status(400).json({ error: "'text' is required (non-empty string)" });
1292
+ }
1293
+ try {
1294
+ const { openVecStore, countChunks } = await import('../../../../src/rag/vec-store.js');
1295
+ const { OllamaEmbedder } = await import('../../../../src/rag/embedder.js');
1296
+ const { query } = await import('../../../../src/rag/retriever.js');
1297
+ const db = openVecStore({ dim: 768 });
1298
+ try {
1299
+ if (countChunks(db) === 0) return res.json({ hits: [], empty: true, topK, scope });
1300
+ const embedder = new OllamaEmbedder({});
1301
+ const hits = await query(db, embedder, text, { topK: Number(topK), scope });
1302
+ res.json({ hits, empty: false, topK, scope });
1303
+ } finally { db.close(); }
1304
+ } catch (err) {
1305
+ res.status(500).json({ error: err.message });
1306
+ }
1307
+ });
1308
+
1282
1309
  export default router;
@@ -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,57 @@
1
+ // KJC-PCS-0049 Step 7 — MCP handlers calling src/rag/* directly. Imports
2
+ // from src/commands/* are forbidden by the layer-boundaries test
3
+ // (MCP and CLI are peer layers); we hit the pure rag/* modules instead.
4
+ import { countChunks, openVecStore } from "../../rag/vec-store.js";
5
+ import { OllamaEmbedder } from "../../rag/embedder.js";
6
+ import { indexProject } from "../../rag/indexer.js";
7
+ import { query } from "../../rag/retriever.js";
8
+ import { getKarajanHome } from "../../utils/paths.js";
9
+ import { resolveProjectDir, buildConfig, responseText, failPayload } from "../shared-helpers.js";
10
+
11
+ const silentLogger = {
12
+ info: () => {}, warn: () => {}, error: () => {}, debug: () => {}, setContext: () => {},
13
+ };
14
+
15
+ function makeEmbedder(config) {
16
+ const cfg = config?.rag?.embedder || {};
17
+ return new OllamaEmbedder({ url: cfg.url, model: cfg.model, dim: cfg.dim });
18
+ }
19
+
20
+ export async function handleRagQuery(args, server) {
21
+ const text = args?.text;
22
+ if (typeof text !== "string" || text.length === 0) {
23
+ return failPayload("kj_rag_query: 'text' is required and must be a non-empty string");
24
+ }
25
+ try {
26
+ const projectDir = await resolveProjectDir(server, args?.projectDir);
27
+ const config = await buildConfig({ ...args, projectDir }, "rag-query");
28
+ const topK = Number(args?.topK) || 5;
29
+ const scope = args?.scope || "all";
30
+ const db = openVecStore({ dim: config?.rag?.embedder?.dim || 768 });
31
+ try {
32
+ if (countChunks(db) === 0) return responseText({ hits: [], empty: true, topK, scope });
33
+ const hits = await query(db, makeEmbedder(config), text, { topK, scope });
34
+ return responseText({ hits, empty: false, topK, scope });
35
+ } finally { db.close(); }
36
+ } catch (err) {
37
+ return failPayload(`kj_rag_query failed: ${err.message}`);
38
+ }
39
+ }
40
+
41
+ export async function handleRagIndex(args, server) {
42
+ try {
43
+ const projectDir = await resolveProjectDir(server, args?.projectDir);
44
+ const config = await buildConfig({ ...args, projectDir }, "rag-index");
45
+ const db = openVecStore({ dim: config?.rag?.embedder?.dim || 768 });
46
+ try {
47
+ const totals = await indexProject(projectDir, {
48
+ db, embedder: makeEmbedder(config),
49
+ karajanHome: getKarajanHome(), logger: silentLogger,
50
+ withSources: Boolean(args?.withSources),
51
+ });
52
+ return responseText(totals);
53
+ } finally { db.close(); }
54
+ } catch (err) {
55
+ return failPayload(`kj_rag_index failed: ${err.message}`);
56
+ }
57
+ }
@@ -39,6 +39,7 @@ import {
39
39
  handleScan, handleBoard, handleUndo, handleClean
40
40
  } from "./handlers/management-handlers.js";
41
41
  import { handleHu, handleSkills, handleSuggest } from "./handlers/hu-handlers.js";
42
+ import { handleRagQuery, handleRagIndex } from "./handlers/rag-handler.js";
42
43
 
43
44
  export async function handleToolCall(name, args, server, extra) {
44
45
  const a = asObject(args);
@@ -68,6 +69,8 @@ export async function handleToolCall(name, args, server, extra) {
68
69
  kj_skills: (a) => handleSkills(a),
69
70
  kj_undo: (a, server) => handleUndo(a, server),
70
71
  kj_clean: (a) => handleClean(a),
72
+ kj_rag_query: (a, server) => handleRagQuery(a, server),
73
+ kj_rag_index: (a, server) => handleRagIndex(a, server),
71
74
  }[name];
72
75
  if (handler) {
73
76
  return handler(a, server, extra);
package/src/mcp/tools.js CHANGED
@@ -403,5 +403,30 @@ export const tools = [
403
403
  huDays: { type: "number", description: "Keep HU story batches for this many days. Default: 14." }
404
404
  }
405
405
  }
406
+ },
407
+ {
408
+ name: "kj_rag_query",
409
+ description: "Run a semantic search across the indexed Karajan plans, onboarding briefs and (optionally) project source code. Returns the top-K nearest chunks with their metadata so the agent can quote prior decisions, locate where a concept was last implemented, or pull architectural context without re-walking the repo. The store is empty until `kj_rag_index` (or `kj rag index` CLI) has been run at least once on this project; in that case the response carries `empty: true`.",
410
+ inputSchema: {
411
+ type: "object",
412
+ properties: {
413
+ text: { type: "string", description: "The natural-language query." },
414
+ topK: { type: "number", description: "Max number of hits to return. Default: 5." },
415
+ scope: { type: "string", description: "Filter by chunk kind: 'plans' | 'code' | 'onboarding' | 'all' (default)." },
416
+ projectDir: { type: "string", description: "Absolute path to the project directory" }
417
+ },
418
+ required: ["text"]
419
+ }
420
+ },
421
+ {
422
+ name: "kj_rag_index",
423
+ description: "(Re)index this project's Karajan-managed assets — every plan-*.json under ~/.karajan/plans/<slug>/ plus the onboarding brief at ~/.karajan/onboarding/<slug>.md — into the local vector store. Idempotent: a re-run replaces the chunks for each source, never appends duplicates. Pass `withSources: true` to also index the project's JS/TS files (skips node_modules / .git / dist / etc.). Required as a one-time bootstrap before kj_rag_query returns anything; safe to re-run after every `kj plan generate`.",
424
+ inputSchema: {
425
+ type: "object",
426
+ properties: {
427
+ withSources: { type: "boolean", description: "Also index .js/.ts files under projectDir. Default false." },
428
+ projectDir: { type: "string", description: "Absolute path to the project directory" }
429
+ }
430
+ }
406
431
  }
407
432
  ];
@@ -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
+ }
@@ -70,3 +70,7 @@ Karajan projects MAY enforce a CI gate that fails any PR whose net delta exceeds
70
70
  - The gate counts the SUM of every changed file, not per-file. Tests count too. 5 files × 40 LOC = 200 = on the limit.
71
71
  - Excluded from the count: lockfiles, snapshots, `dist/`, `node_modules/`, generated `tests/_diet/`, `public/docs/`. Source + tests count.
72
72
  - Token-economy: oversized PRs get rejected at CI and the work is redone — partitioning upfront saves the round-trip.
73
+
74
+ ## Prior context (RAG, opt-in)
75
+
76
+ If a design decision touches an area the project already shaped (layering, patterns, contract style), call the `kj_rag_query` MCP tool with `{ text, topK: 3, scope: "all" }` and align your `tradeoffs` / `patterns` with what the prior plans already used. When the response carries `empty: true`, the corpus has not been indexed — proceed without it; do NOT block on retrieval.
@@ -110,3 +110,7 @@ Return a JSON object:
110
110
  "summary": "Human-readable summary of changes"
111
111
  }
112
112
  ```
113
+
114
+ ## Prior context (RAG, opt-in)
115
+
116
+ If you need to know how the project handled a similar concern before (auth, error model, retry policy, naming convention…), call the `kj_rag_query` MCP tool with `{ text, topK: 3, scope: "all" }`. It returns the closest chunks from prior plans + onboarding brief + indexed sources. When the response carries `empty: true`, the corpus has not been indexed yet — proceed without it; do NOT block on retrieval, and do NOT ask the human to run `kj rag index`. Use sparingly: one query per concern, not per file.
@@ -55,3 +55,7 @@ Karajan projects MAY enforce a CI gate that fails any PR whose net delta exceeds
55
55
  - The gate counts the SUM of every changed file, not per-file. Tests count too. 5 files × 40 LOC = 200 = on the limit.
56
56
  - Excluded from the count: lockfiles, snapshots, `dist/`, `node_modules/`, generated `tests/_diet/`, `public/docs/`. Source + tests count.
57
57
  - Token-economy: oversized PRs get rejected at CI and the work is redone — partitioning upfront saves the round-trip.
58
+
59
+ ## Prior context (RAG, opt-in)
60
+
61
+ Before emitting blocked_by graphs, dependencies, or reuse markers, query the local RAG corpus via the `kj_rag_query` MCP tool with `{ text, topK: 5, scope: "plans" }` to see how previous plans wired similar HUs. If a prior plan already produced the utility/spike the new plan would need, mark `reuse: ["<that-hu-id>"]` instead of re-implementing. When `empty: true`, the corpus has not been indexed — proceed without retrieval; do NOT block on it.
@@ -35,3 +35,7 @@ You are the **Researcher** in a multi-role AI pipeline. Your job is to investiga
35
35
  "summary": "Research complete: 5 files affected, 2 risks identified"
36
36
  }
37
37
  ```
38
+
39
+ ## Prior context (RAG, opt-in)
40
+
41
+ Before re-walking the repo, query the local RAG corpus via the `kj_rag_query` MCP tool with `{ text, topK: 5, scope: "plans" }` to surface decisions, hot files and ADR mentions captured in past plans + onboarding brief. When the response carries `empty: true`, the corpus has not been indexed yet — proceed with file-level research; do NOT block on retrieval. Quote retrieved evidence as bullet items under your `affects` list when relevant; do NOT paste raw chunks into the summary.
@@ -36,3 +36,7 @@ is the WORST of any finding, or `ok` if findings is empty.
36
36
 
37
37
  Direct, accionable. No moralising. `suggestion` must be concrete (a paste-able
38
38
  sentence), not vague advice.
39
+
40
+ ## Prior context (RAG, opt-in)
41
+
42
+ When evaluating whether the spec duplicates a prior plan's scope or contradicts an ADR, call the `kj_rag_query` MCP tool with `{ text, topK: 3, scope: "all" }` using a phrase from the spec as the query. If retrieved chunks describe overlapping HUs already approved, surface the conflict via a finding with `kind: "scope_overlap"` and reference the prior HU id. When `empty: true`, the corpus has not been indexed — proceed without retrieval; do NOT block on it.