karajan-code 2.32.0 → 2.34.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.
Files changed (44) hide show
  1. package/README.md +25 -0
  2. package/package.json +4 -2
  3. package/scripts/esbuild-sea.config.mjs +10 -1
  4. package/scripts/git-hooks/post-merge +7 -0
  5. package/src/audit/audit-history-display.js +61 -0
  6. package/src/audit/audit-history.js +102 -0
  7. package/src/audit/basal-cost.js +17 -12
  8. package/src/audit/harness-scorecard.js +51 -0
  9. package/src/audit/harness-section.js +77 -0
  10. package/src/checks/harness-scorecard.js +41 -0
  11. package/src/cli/register-meta.js +28 -1
  12. package/src/cli/register-pipeline.js +1 -0
  13. package/src/commands/audit.js +73 -16
  14. package/src/commands/doctor.js +2 -0
  15. package/src/commands/rag.js +82 -8
  16. package/src/commands/run.js +8 -0
  17. package/src/config/defaults.js +7 -0
  18. package/src/lang/chunk-go.js +94 -0
  19. package/src/lang/chunk-java.js +90 -0
  20. package/src/lang/chunk-python.js +88 -0
  21. package/src/lang/chunk-rust.js +102 -0
  22. package/src/lang/go.js +14 -0
  23. package/src/lang/java.js +16 -0
  24. package/src/lang/js.js +8 -0
  25. package/src/lang/python.js +14 -0
  26. package/src/lang/registry.js +79 -0
  27. package/src/lang/rust.js +14 -0
  28. package/src/lang/tree-sitter-loader.js +49 -0
  29. package/src/onboarder/collectors/framework-multilang.js +64 -0
  30. package/src/onboarder/collectors/index.js +18 -1
  31. package/src/rag/auto-update.js +50 -0
  32. package/src/rag/chunker.js +7 -3
  33. package/src/rag/eval.js +61 -0
  34. package/src/rag/indexer.js +81 -10
  35. package/src/rag/retriever.js +57 -5
  36. package/src/rag/vec-store.js +69 -3
  37. package/src/rag/watcher.js +6 -4
  38. package/src/utils/manifest-sniff.js +82 -0
  39. package/templates/roles/onboarder.md +10 -1
  40. package/vendor/tree-sitter-grammars/README.md +21 -0
  41. package/vendor/tree-sitter-grammars/go.wasm +0 -0
  42. package/vendor/tree-sitter-grammars/java.wasm +0 -0
  43. package/vendor/tree-sitter-grammars/python.wasm +0 -0
  44. package/vendor/tree-sitter-grammars/rust.wasm +0 -0
@@ -6,11 +6,19 @@
6
6
  // failure mode simple (re-run to refresh).
7
7
  import { existsSync, readFileSync } from "node:fs";
8
8
  import { readdir } from "node:fs/promises";
9
- import { extname, join, relative } from "node:path";
9
+ import { extname, isAbsolute, join, relative } from "node:path";
10
+ import { createHash } from "node:crypto";
11
+ import { execa } from "execa";
10
12
 
11
13
  import { chunkMarkdown, chunkPlan, chunkSource } from "./chunker.js";
12
- import { insertChunk, deleteChunksBySource } from "./vec-store.js";
14
+ import { insertChunk, deleteChunksBySource, findChunkByHash } from "./vec-store.js";
15
+ import { detectAdaptersForProject, buildMatchers, getAllCodeExtensions } from "../lang/registry.js";
13
16
 
17
+ // KJC-PCS-0052 PR-A — antes vivían dos consts hard-coded para JS aquí
18
+ // (CODE_EXT_RE + SKIP_SEGMENTS). Ahora vienen del registry de adapters,
19
+ // que se consulta una vez por proceso para el conjunto global y por
20
+ // projectDir cuando hace falta filtrar el walk.
21
+ const ALL_CODE_EXTENSIONS = new Set(getAllCodeExtensions());
14
22
  const ONBOARDING_DIR = "onboarding", PLANS_DIR = "plans";
15
23
 
16
24
  function detectKind(path) {
@@ -18,7 +26,7 @@ function detectKind(path) {
18
26
  const base = path.split("/").pop() || "";
19
27
  if (ext === ".json" && (/^plan-.+\.json$/i.test(base) || path.includes(`/${PLANS_DIR}/`))) return "plan";
20
28
  if (ext === ".md") return path.includes(`/${ONBOARDING_DIR}/`) ? "onboarding" : "plan";
21
- if ([".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"].includes(ext)) return "code";
29
+ if (ALL_CODE_EXTENSIONS.has(ext)) return "code";
22
30
  return null;
23
31
  }
24
32
 
@@ -46,28 +54,50 @@ export async function indexFile(path, { db, embedder, logger = console, project
46
54
  const kind = detectKind(path);
47
55
  if (!kind) { logger.warn?.(`[rag-indexer] unknown kind: ${path}`); return { indexed: 0, failed: 0 }; }
48
56
  const chunks = chunksFor(path, kind);
49
- if (!chunks.length) return { indexed: 0, failed: 0 };
57
+ if (!chunks.length) return { indexed: 0, failed: 0, skipped: 0 };
50
58
  deleteChunksBySource(db, path);
51
59
  let indexed = 0;
52
60
  let failed = 0;
61
+ let skipped = 0;
53
62
  for (const ch of chunks) {
63
+ // KJC-TSK-0484 — Skip re-embedding identical bodies for the same project.
64
+ // The chunks row is still rewritten under the new source so deletions of
65
+ // an alias don't orphan a row whose owner was the dedup target.
66
+ const contentHash = createHash("sha256").update(ch.text).digest("hex");
54
67
  try {
68
+ const dup = findChunkByHash(db, contentHash, project);
69
+ if (dup) { skipped += 1; continue; }
55
70
  const embedding = await embedder.embed(ch.text);
56
- insertChunk(db, { source: path, kind: ch.metadata.kind || kind, text: ch.text, metadata: ch.metadata, embedding, project });
71
+ insertChunk(db, { source: path, kind: ch.metadata.kind || kind, text: ch.text, metadata: ch.metadata, embedding, project, contentHash });
57
72
  indexed += 1;
58
73
  } catch (err) {
59
74
  failed += 1;
60
75
  logger.warn?.(`[rag-indexer] embed failed for ${path} (${chunkLabel(ch)}): ${err.message}`);
61
76
  }
62
77
  }
63
- logger.info?.(`[rag-indexer] ${relative(process.cwd(), path)} → ${indexed} chunk(s) (${failed} failed)`);
64
- return { indexed, failed };
78
+ const note = skipped ? `, ${skipped} dedup` : "";
79
+ logger.info?.(`[rag-indexer] ${relative(process.cwd(), path)} → ${indexed} chunk(s) (${failed} failed${note})`);
80
+ return { indexed, failed, skipped };
65
81
  }
66
82
 
67
83
  function chunkLabel(ch) {
68
84
  return ch.metadata?.hu_id || ch.metadata?.symbol || ch.metadata?.headingPath?.join(" > ") || "block";
69
85
  }
70
86
 
87
+ // KJC-PCS-0052 PR-B.2.4 — Activa el camino AST en chunkers cuyo adapter
88
+ // expone `prepare()` (Python, Rust). Promise.allSettled aísla cada grammar:
89
+ // si uno falla (red, wasm corrupto), el resto se carga y los .py/.rs sin
90
+ // AST listo caen al fallback regex en chunkSource. JS no tiene `prepare`
91
+ // porque chunker-ast.js usa @babel/parser síncrono (ya cargado).
92
+ export async function prepareAdapters(adapters, { logger = console } = {}) {
93
+ const tasks = adapters.filter((a) => typeof a.prepare === "function");
94
+ if (tasks.length === 0) return;
95
+ const results = await Promise.allSettled(tasks.map((a) => a.prepare()));
96
+ results.forEach((r, i) => {
97
+ if (r.status === "rejected") logger.warn?.(`[rag-indexer] adapter ${tasks[i].id} prepare failed: ${r.reason?.message || r.reason}`);
98
+ });
99
+ }
100
+
71
101
  async function listFiles(dir, predicate) {
72
102
  const out = [];
73
103
  async function walk(d) {
@@ -91,6 +121,7 @@ async function listFiles(dir, predicate) {
91
121
  export async function indexProject(projectDir, { db, embedder, karajanHome, logger = console, withSources = false } = {}) {
92
122
  const totals = { indexed: 0, failed: 0, files: 0 };
93
123
  const slug = projectDir.split("/").pop()?.replace(/[^a-zA-Z0-9._-]/g, "-").toLowerCase() || "project";
124
+ await prepareAdapters(detectAdaptersForProject(projectDir), { logger });
94
125
  const planRoot = join(karajanHome, PLANS_DIR, slug);
95
126
  if (existsSync(planRoot)) {
96
127
  const plans = await listFiles(planRoot, (p) => /plan-.*\.json$/.test(p));
@@ -109,9 +140,9 @@ export async function indexProject(projectDir, { db, embedder, karajanHome, logg
109
140
  // per HU during `kj run`; they hold complete copies of `src/` that
110
141
  // duplicate every chunk and contaminate retrieval scores. `_diet` is the
111
142
  // tests/_diet/ sandbox used by the test-diet audit harness — never user
112
- // code. Skip both.
113
- const SKIP = new Set(["node_modules", ".git", "dist", "build", "coverage", ".karajan", ".next", ".kj", "_diet"]);
114
- const sources = await listFiles(projectDir, (p) => /\.(js|mjs|cjs|ts|tsx|jsx)$/.test(p) && !p.split("/").some((seg) => SKIP.has(seg)));
143
+ // code. Both viven en COMMON_SKIP_SEGMENTS dentro del registry.
144
+ const matchers = buildMatchers(detectAdaptersForProject(projectDir));
145
+ const sources = await listFiles(projectDir, (p) => matchers.isCodeFile(p) && !matchers.shouldSkip(p));
115
146
  for (const s of sources) {
116
147
  const r = await indexFile(s, { db, embedder, logger, project: slug });
117
148
  totals.indexed += r.indexed; totals.failed += r.failed; totals.files += 1;
@@ -119,3 +150,43 @@ export async function indexProject(projectDir, { db, embedder, karajanHome, logg
119
150
  }
120
151
  return totals;
121
152
  }
153
+
154
+ /**
155
+ * KJC-TSK-0455 — Re-index only the files changed between `since` and HEAD.
156
+ * `git diff --name-status -z` drives the work: A/M re-embed, D drop chunks,
157
+ * R drops old + indexes new. Returns `{ indexed, failed, files, deleted, head }`;
158
+ * `head` lets callers persist via setLastIndexedCommit. Throws on diff failure
159
+ * (shallow clone, unknown ref) so the CLI can fall back to a full reindex.
160
+ */
161
+ export async function indexProjectDelta(projectDir, { db, embedder, since, logger = console } = {}) {
162
+ const totals = { indexed: 0, failed: 0, files: 0, deleted: 0, head: null };
163
+ const slug = projectDir.split("/").pop()?.replace(/[^a-zA-Z0-9._-]/g, "-").toLowerCase() || "project";
164
+ const { stdout: head } = await execa("git", ["-C", projectDir, "rev-parse", "HEAD"]);
165
+ totals.head = head.trim();
166
+ if (totals.head === since) return totals;
167
+ const { stdout } = await execa("git", ["-C", projectDir, "diff", "--name-status", "-z", since, "HEAD"]);
168
+ const parts = stdout.split("\0").filter(Boolean);
169
+ const ops = []; // { kind: 'del'|'idx', p }
170
+ for (let i = 0; i < parts.length; i += 1) {
171
+ const s = parts[i];
172
+ if (s.startsWith("R")) { ops.push({ kind: "del", p: parts[++i] }, { kind: "idx", p: parts[++i] }); }
173
+ else if (s === "D") { ops.push({ kind: "del", p: parts[++i] }); }
174
+ else { ops.push({ kind: "idx", p: parts[++i] }); }
175
+ }
176
+ const adapters = detectAdaptersForProject(projectDir);
177
+ await prepareAdapters(adapters, { logger });
178
+ const matchers = buildMatchers(adapters);
179
+ for (const { kind, p } of ops) {
180
+ if (matchers.shouldSkip(p)) continue;
181
+ if (!matchers.isCodeFile(p)) continue;
182
+ const abs = isAbsolute(p) ? p : join(projectDir, p);
183
+ if (kind === "del") {
184
+ totals.deleted += deleteChunksBySource(db, abs);
185
+ logger.info?.(`[rag-indexer] delta delete ${relative(process.cwd(), abs)}`);
186
+ } else if (existsSync(abs)) {
187
+ const r = await indexFile(abs, { db, embedder, logger, project: slug });
188
+ totals.indexed += r.indexed; totals.failed += r.failed; totals.files += 1;
189
+ }
190
+ }
191
+ return totals;
192
+ }
@@ -1,5 +1,5 @@
1
1
  // KJC-PCS-0049 Step 5 — Retriever for the RAG pipeline.
2
- import { searchSimilar, searchBM25 } from "./vec-store.js";
2
+ import { searchSimilar, searchBM25, getEmbeddingsByIds } from "./vec-store.js";
3
3
  import { parseWhere, buildWhereSql } from "./where-parser.js";
4
4
  import { rerank } from "./rerank.js";
5
5
 
@@ -41,7 +41,46 @@ function fuseHits(semantic, keyword, alpha, mode) {
41
41
  return list;
42
42
  }
43
43
 
44
- export async function query(db, embedder, text, { topK = 5, scope = "all", kindBoost = DEFAULT_KIND_BOOST, project = null, mode = "hybrid", alpha = 0.6, where = null, rerankOpts = null } = {}) {
44
+ // KJC-TSK-0484 PR-B — Maximal Marginal Relevance. Given an ordered list of
45
+ // candidates (best-first by `score`), pick `topK` that balance relevance
46
+ // against intra-result diversity. `lambda=1` collapses to plain relevance;
47
+ // `lambda=0` maximises diversity. Cosine sim between candidate embeddings
48
+ // drives the redundancy penalty. Candidates without an embedding are kept
49
+ // at the end (no penalty applies).
50
+ function cosineSim(a, b) {
51
+ if (!a || !b || a.length !== b.length) return 0;
52
+ let dot = 0; let na = 0; let nb = 0;
53
+ for (let i = 0; i < a.length; i += 1) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }
54
+ const denom = Math.sqrt(na) * Math.sqrt(nb);
55
+ return denom === 0 ? 0 : dot / denom;
56
+ }
57
+
58
+ export function mmrRerank(candidates, embeddings, { topK, lambda = 0.7 }) {
59
+ const remaining = candidates.slice();
60
+ const picked = [];
61
+ const relevance = (c) => 1 / (1 + Math.max(0, c.score ?? c.distance ?? 0));
62
+ while (picked.length < topK && remaining.length > 0) {
63
+ let bestIdx = 0; let bestVal = -Infinity;
64
+ for (let i = 0; i < remaining.length; i += 1) {
65
+ const c = remaining[i];
66
+ const rel = relevance(c);
67
+ let maxSim = 0;
68
+ const ce = embeddings.get(c.id);
69
+ if (ce && picked.length > 0) {
70
+ for (const p of picked) {
71
+ const pe = embeddings.get(p.id);
72
+ if (pe) maxSim = Math.max(maxSim, cosineSim(ce, pe));
73
+ }
74
+ }
75
+ const score = lambda * rel - (1 - lambda) * maxSim;
76
+ if (score > bestVal) { bestVal = score; bestIdx = i; }
77
+ }
78
+ picked.push(remaining.splice(bestIdx, 1)[0]);
79
+ }
80
+ return picked;
81
+ }
82
+
83
+ export async function query(db, embedder, text, { topK = 5, scope = "all", kindBoost = DEFAULT_KIND_BOOST, project = null, mode = "hybrid", alpha = 0.6, where = null, rerankOpts = null, diversify = false, mmrLambda = 0.7 } = {}) {
45
84
  if (!text || typeof text !== "string") throw new Error("query: text must be a non-empty string");
46
85
  const fetchK = Math.min(50, topK * 2);
47
86
  const scopeKind = scope === "plans" ? "plan" : scope === "code" ? "code" : scope === "onboarding" ? "onboarding" : null;
@@ -59,11 +98,24 @@ export async function query(db, embedder, text, { topK = 5, scope = "all", kindB
59
98
  const raw = fuseHits(semanticHits, keywordHits, alpha, mode);
60
99
  if (raw.length === 0) return [];
61
100
  const boostSources = shouldBoostSources(text);
62
- const reranked = raw.map((r) => {
101
+ const scored = raw.map((r) => {
63
102
  const kindB = kindBoost[r.kind] || 0;
64
103
  const sourceB = (boostSources && r.kind === "code" && !isTestPath(r.source)) ? SOURCE_BOOST_NON_TEST : 0;
65
104
  return { ...r, score: r.distance - kindB - sourceB };
66
- }).sort((a, b) => a.score - b.score).slice(0, topK);
105
+ }).sort((a, b) => a.score - b.score);
106
+ // KJC-TSK-0484 PR-B — MMR diversification over topK*2 candidates. Fetches
107
+ // embeddings only when requested; cosine between candidate vectors drives
108
+ // the redundancy penalty so near-duplicates that survived dedup (e.g. a
109
+ // boilerplate chunk repeated under slightly different wording) get pushed
110
+ // down. Skipped entirely when `diversify=false` (default).
111
+ let reranked;
112
+ if (diversify) {
113
+ const pool = scored.slice(0, Math.min(scored.length, topK * 2));
114
+ const embeddings = getEmbeddingsByIds(db, pool.map((c) => c.id));
115
+ reranked = mmrRerank(pool, embeddings, { topK, lambda: mmrLambda });
116
+ } else {
117
+ reranked = scored.slice(0, topK);
118
+ }
67
119
  // KJC-TSK-0449 — optional cross-encoder rerank. When `rerankOpts` is set,
68
120
  // we re-score the topK survivors with a (query, passage) cross-encoder;
69
121
  // the kind+source boost has already been applied, so the rerank acts as
@@ -72,4 +124,4 @@ export async function query(db, embedder, text, { topK = 5, scope = "all", kindB
72
124
  return reranked;
73
125
  }
74
126
 
75
- export { shouldBoostSources, isTestPath, fuseHits };
127
+ export { shouldBoostSources, isTestPath, fuseHits, cosineSim };
@@ -53,6 +53,26 @@ export function openVecStore({ dim = DEFAULT_DIM, path = dbPath() } = {}) {
53
53
  db.exec("ALTER TABLE chunks ADD COLUMN project_slug TEXT;");
54
54
  db.exec("CREATE INDEX IF NOT EXISTS chunks_by_project ON chunks(project_slug);");
55
55
  }
56
+ // KJC-TSK-0484 — Content-hash dedup. SHA-256 of the chunk text lets the
57
+ // indexer skip embedding bodies it has already seen for the same project.
58
+ // Older rows keep NULL; the lookup ignores NULLs so they neither dedup
59
+ // nor get deduped until they are re-indexed and stamped.
60
+ if (!cols.includes("content_hash")) {
61
+ db.exec("ALTER TABLE chunks ADD COLUMN content_hash TEXT;");
62
+ db.exec("CREATE INDEX IF NOT EXISTS chunks_by_hash ON chunks(content_hash, project_slug);");
63
+ }
64
+ // KJC-TSK-0455 — Auto-update by commit diff. Per-project metadata tracks
65
+ // the last commit whose changes were reflected in the index, so subsequent
66
+ // `kj rag index --since auto` invocations (and the post-merge hook / pre-run
67
+ // drift check landed in PR2) only re-embed the actual delta instead of the
68
+ // whole project.
69
+ db.exec(`
70
+ CREATE TABLE IF NOT EXISTS vec_store_meta (
71
+ project_slug TEXT PRIMARY KEY,
72
+ last_indexed_commit TEXT,
73
+ last_indexed_at TEXT NOT NULL DEFAULT (datetime('now'))
74
+ );
75
+ `);
56
76
  db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS vec_chunks USING vec0(embedding float[${dim}]);`);
57
77
  // KJC-TSK-0443 — FTS5 keyword index for BM25 hybrid scoring. content='chunks'
58
78
  // makes it a contentless mirror linked by id (no double storage); triggers
@@ -85,13 +105,13 @@ export function openVecStore({ dim = DEFAULT_DIM, path = dbPath() } = {}) {
85
105
  * because the virtual table rowid is set explicitly to the freshly
86
106
  * minted chunks.id. Returns that id.
87
107
  */
88
- export function insertChunk(db, { source, kind, text, metadata = null, embedding, project = null }) {
108
+ export function insertChunk(db, { source, kind, text, metadata = null, embedding, project = null, contentHash = null }) {
89
109
  if (!Array.isArray(embedding) && !ArrayBuffer.isView(embedding)) {
90
110
  throw new Error("insertChunk: embedding must be an array or typed array of floats");
91
111
  }
92
112
  const meta = metadata == null ? null : (typeof metadata === "string" ? metadata : JSON.stringify(metadata));
93
- const ins = db.prepare("INSERT INTO chunks (source, kind, text, metadata, project_slug) VALUES (?, ?, ?, ?, ?)");
94
- const info = ins.run(source, kind, text, meta, project);
113
+ const ins = db.prepare("INSERT INTO chunks (source, kind, text, metadata, project_slug, content_hash) VALUES (?, ?, ?, ?, ?, ?)");
114
+ const info = ins.run(source, kind, text, meta, project, contentHash);
95
115
  // sqlite-vec's vec0 virtual table requires the rowid to arrive as a BigInt
96
116
  // even for values that comfortably fit in a Number. Better-sqlite3 returns
97
117
  // lastInsertRowid as BigInt only when safeIntegers mode is on; force it.
@@ -153,6 +173,52 @@ export function deleteChunksBySource(db, source) {
153
173
  return ids.length;
154
174
  }
155
175
 
176
+ // KJC-TSK-0455 — Per-project commit watermark for `kj rag index --since auto`
177
+ // and the post-merge / pre-run drift hooks landing in PR2.
178
+ export function getLastIndexedCommit(db, projectSlug) {
179
+ if (!projectSlug) return null;
180
+ const row = db.prepare("SELECT last_indexed_commit FROM vec_store_meta WHERE project_slug = ?").get(projectSlug);
181
+ return row?.last_indexed_commit ?? null;
182
+ }
183
+
184
+ export function setLastIndexedCommit(db, projectSlug, commit) {
185
+ if (!projectSlug || !commit) return;
186
+ db.prepare(`
187
+ INSERT INTO vec_store_meta (project_slug, last_indexed_commit, last_indexed_at)
188
+ VALUES (?, ?, datetime('now'))
189
+ ON CONFLICT(project_slug) DO UPDATE SET
190
+ last_indexed_commit = excluded.last_indexed_commit,
191
+ last_indexed_at = excluded.last_indexed_at
192
+ `).run(projectSlug, commit);
193
+ }
194
+
195
+ // KJC-TSK-0484 PR-B — Bulk fetch embeddings for an explicit set of chunk ids.
196
+ // Returns Map<id, Float32Array>. Used by MMR diversification in retriever.js.
197
+ export function getEmbeddingsByIds(db, ids) {
198
+ const out = new Map();
199
+ if (!ids?.length) return out;
200
+ const placeholders = ids.map(() => "?").join(",");
201
+ const rows = db.prepare(`SELECT rowid AS id, embedding FROM vec_chunks WHERE rowid IN (${placeholders})`).all(...ids);
202
+ for (const row of rows) {
203
+ if (!row.embedding) continue;
204
+ const buf = Buffer.isBuffer(row.embedding) ? row.embedding : Buffer.from(row.embedding);
205
+ out.set(Number(row.id), new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4));
206
+ }
207
+ return out;
208
+ }
209
+
210
+ // KJC-TSK-0484 — Lookup an already-indexed chunk by content_hash. Scoped to a
211
+ // project so two repos with the same boilerplate file don't cross-pollute.
212
+ // Returns `{ id, source }` or null. Pass `project=null` to match NULL slugs.
213
+ export function findChunkByHash(db, hash, project = null) {
214
+ if (!hash) return null;
215
+ const sql = project
216
+ ? "SELECT id, source FROM chunks WHERE content_hash = ? AND project_slug = ? LIMIT 1"
217
+ : "SELECT id, source FROM chunks WHERE content_hash = ? AND project_slug IS NULL LIMIT 1";
218
+ const row = project ? db.prepare(sql).get(hash, project) : db.prepare(sql).get(hash);
219
+ return row || null;
220
+ }
221
+
156
222
  export function countChunks(db, { kind = null } = {}) {
157
223
  if (kind) return db.prepare("SELECT COUNT(*) AS n FROM chunks WHERE kind = ?").get(kind).n;
158
224
  return db.prepare("SELECT COUNT(*) AS n FROM chunks").get().n;
@@ -1,23 +1,25 @@
1
1
  // KJC-TSK-0441 (RAG v2.28.0 PR1) — chokidar watcher. Live re-index of plans
2
2
  // + onboarding + (opt) sources, debounced. PID file arbitrates a single daemon.
3
+ // KJC-TSK-0482 — source matcher derivado del registry (multi-lang: JS/Py/Rust/Go/Java).
3
4
  import { writeFileSync, readFileSync, existsSync, unlinkSync } from "node:fs";
4
- import { join } from "node:path";
5
+ import { extname, join } from "node:path";
5
6
  import chokidar from "chokidar";
6
7
  import { openVecStore, deleteChunksBySource, projectSlug } from "./vec-store.js";
7
8
  import { makeEmbedder } from "./embedders/factory.js";
8
9
  import { indexFile } from "./indexer.js";
9
10
  import { getKarajanHome } from "../utils/paths.js";
11
+ import { getAllCodeExtensions } from "../lang/registry.js";
10
12
 
11
13
  const PIDFILE = () => join(getKarajanHome(), "watcher.pid");
12
14
  const DEFAULT_DEBOUNCE_MS = 1000;
13
15
  const SKIP_SEGMENTS = new Set(["node_modules", ".git", "dist", "build", "coverage", ".karajan", ".next", ".kj", "_diet"]);
14
- const SOURCE_RE = /\.(js|mjs|cjs|ts|tsx|jsx)$/;
16
+ const SOURCE_EXTS = new Set(getAllCodeExtensions().map((e) => e.toLowerCase()));
15
17
 
16
- function isWatchable(path) {
18
+ export function isWatchable(path) {
17
19
  if (path.includes("/.karajan/onboarding/")) return /\.md$/.test(path);
18
20
  if (path.includes("/.karajan/plans/")) return /\.json$/.test(path);
19
21
  if (path.split("/").some((seg) => SKIP_SEGMENTS.has(seg))) return false;
20
- return SOURCE_RE.test(path);
22
+ return SOURCE_EXTS.has(extname(path).toLowerCase());
21
23
  }
22
24
 
23
25
  export function startWatcher({ projectDir, config, logger = console, debounceMs = DEFAULT_DEBOUNCE_MS, withSources = false } = {}) {
@@ -0,0 +1,82 @@
1
+ // KJC-TSK-0476 — Multi-stack manifest sniffer. Sin libs externas: JSON.parse
2
+ // para package.json, regex para TOML y go.mod. Lo consumen audit (basal-cost)
3
+ // y, vía PR-D, el onboarder.
4
+ import fs from "node:fs/promises";
5
+ import path from "node:path";
6
+
7
+ async function readMaybe(file) {
8
+ try { return await fs.readFile(file, "utf8"); } catch { return null; }
9
+ }
10
+
11
+ async function sniffNode(dir) {
12
+ const raw = await readMaybe(path.join(dir, "package.json"));
13
+ if (!raw) return null;
14
+ try {
15
+ const pkg = JSON.parse(raw);
16
+ return { stack: "node", manifest: "package.json", name: pkg.name || null,
17
+ deps: Object.keys(pkg.dependencies || {}).length,
18
+ devDeps: Object.keys(pkg.devDependencies || {}).length };
19
+ } catch {
20
+ return { stack: "node", manifest: "package.json", name: null, deps: 0, devDeps: 0, malformed: true };
21
+ }
22
+ }
23
+
24
+ // Cuerpo TOML de `[section]` hasta el siguiente `\n[` o EOF. Lookahead con `/m`
25
+ // cambia el significado de `$` y producía capturas vacías.
26
+ function tomlSection(toml, section) {
27
+ const i = toml.indexOf(`[${section}]`);
28
+ if (i === -1) return "";
29
+ const rest = toml.slice(i + section.length + 2);
30
+ const n = rest.search(/\n\[/);
31
+ return n === -1 ? rest : rest.slice(0, n);
32
+ }
33
+
34
+ const DEP_LINE_RE = /^\s*[A-Za-z0-9_-]+\s*=/;
35
+ const countDepLines = (block, excludePython) => block.split("\n")
36
+ .filter((l) => DEP_LINE_RE.test(l) && !(excludePython && /^\s*python\s*=/.test(l))).length;
37
+
38
+ async function sniffPython(dir) {
39
+ const py = await readMaybe(path.join(dir, "pyproject.toml"));
40
+ if (py) {
41
+ const name = /^name\s*=\s*["']([^"']+)["']/m.exec(py)?.[1] || null;
42
+ const projectDeps = /\[project\][\s\S]*?dependencies\s*=\s*\[([^\]]*)\]/m.exec(py)?.[1] || "";
43
+ const projectCount = projectDeps.split(",").map((s) => s.trim()).filter(Boolean).length;
44
+ const poetryCount = countDepLines(tomlSection(py, "tool.poetry.dependencies"), true);
45
+ return { stack: "python", manifest: "pyproject.toml", name, deps: projectCount + poetryCount, devDeps: 0 };
46
+ }
47
+ const req = await readMaybe(path.join(dir, "requirements.txt"));
48
+ if (!req) return null;
49
+ const lines = req.split("\n").map((l) => l.replace(/#.*$/, "").trim()).filter(Boolean);
50
+ return { stack: "python", manifest: "requirements.txt", name: null, deps: lines.length, devDeps: 0 };
51
+ }
52
+
53
+ async function sniffRust(dir) {
54
+ const toml = await readMaybe(path.join(dir, "Cargo.toml"));
55
+ if (!toml) return null;
56
+ return { stack: "rust", manifest: "Cargo.toml",
57
+ name: /^name\s*=\s*["']([^"']+)["']/m.exec(toml)?.[1] || null,
58
+ deps: countDepLines(tomlSection(toml, "dependencies")),
59
+ devDeps: countDepLines(tomlSection(toml, "dev-dependencies")) };
60
+ }
61
+
62
+ async function sniffGo(dir) {
63
+ const mod = await readMaybe(path.join(dir, "go.mod"));
64
+ if (!mod) return null;
65
+ const block = /require\s*\(([\s\S]*?)\)/m.exec(mod)?.[1] || "";
66
+ const blockCount = block.split("\n").filter((l) => /\S/.test(l) && !l.trim().startsWith("//")).length;
67
+ const singleCount = mod.split("\n").filter((l) => /^require\s+\S+\s+\S+/.test(l)).length;
68
+ return { stack: "go", manifest: "go.mod",
69
+ name: /^module\s+(\S+)/m.exec(mod)?.[1] || null,
70
+ deps: blockCount + singleCount, devDeps: 0 };
71
+ }
72
+
73
+ export async function sniffManifests(dir) {
74
+ const r = await Promise.all([sniffNode(dir), sniffPython(dir), sniffRust(dir), sniffGo(dir)]);
75
+ return r.filter(Boolean);
76
+ }
77
+
78
+ export function summariseStack(manifests) {
79
+ if (!manifests.length) return { primary: null, multi: false, manifests: [] };
80
+ const ranked = [...manifests].sort((a, b) => (b.deps + b.devDeps) - (a.deps + a.devDeps));
81
+ return { primary: ranked[0].stack, multi: manifests.length > 1, manifests };
82
+ }
@@ -14,7 +14,10 @@ If the project is greenfield (no git, no configs, empty tree) emit
14
14
 
15
15
  ## Input
16
16
 
17
- JSON bundle with `projectDir`, `stack`, `tree`, `git` (or null), `configs`,
17
+ JSON bundle with `projectDir`, `stack`, `manifests` (per-stack: node /
18
+ python / rust / go with deps count), `frameworksMultiLang` (Django / Flask /
19
+ FastAPI / Axum / Actix / Rocket / Gin / Echo / Fiber + Rust crate type + Go
20
+ module path + canonical layout dirs), `tree`, `git` (or null), `configs`,
18
21
  `adrs`. Slots may be null/empty.
19
22
 
20
23
  ## Output
@@ -24,6 +27,8 @@ Markdown only — skip any section without data:
24
27
  ```markdown
25
28
  # Architecture Brief — <projectDir>
26
29
  ## Stack
30
+ ## Manifests # only when manifests has entries beyond node
31
+ ## Frameworks # only when frameworksMultiLang has entries
27
32
  ## Layout
28
33
  ## Configs
29
34
  ## Git history
@@ -32,5 +37,9 @@ Markdown only — skip any section without data:
32
37
  ## Recommendations for new work
33
38
  ```
34
39
 
40
+ For non-Node stacks (`frameworksMultiLang[].stack` in {python, rust, go}),
41
+ mention the detected framework(s), crate type or module path, and canonical
42
+ layout dirs (apps/, cmd/, internal/, src/, tests/) when present.
43
+
35
44
  Be conservative: only assert what the bundle shows. "Likely" / "appears to"
36
45
  are fine; invention is not. Aim for ~40–80 lines total.
@@ -0,0 +1,21 @@
1
+ # Vendored tree-sitter grammars
2
+
3
+ WASM binaries used by `src/lang/tree-sitter-loader.js`. Vendored (rather
4
+ than pulled as runtime deps) so the npm package and the SEA binary ship
5
+ exactly the grammars we support — adding all of `@vscode/tree-sitter-wasm`
6
+ would balloon the install by ~22 MB for languages we never chunk.
7
+
8
+ Source: extracted from `@vscode/tree-sitter-wasm@0.3.1`. That package
9
+ re-builds upstream `tree-sitter-*` grammars against the tree-sitter
10
+ 0.25.x ABI, which matches the runtime `web-tree-sitter@0.26.x` we depend
11
+ on. Older prebuilds (e.g. `tree-sitter-wasms@0.1.13`) target an older ABI
12
+ and fail to load.
13
+
14
+ Re-extract with:
15
+
16
+ npm pack @vscode/tree-sitter-wasm
17
+ tar -xzf vscode-tree-sitter-wasm-*.tgz
18
+ cp package/wasm/tree-sitter-python.wasm vendor/tree-sitter-grammars/python.wasm
19
+ cp package/wasm/tree-sitter-rust.wasm vendor/tree-sitter-grammars/rust.wasm
20
+ cp package/wasm/tree-sitter-go.wasm vendor/tree-sitter-grammars/go.wasm
21
+ cp package/wasm/tree-sitter-java.wasm vendor/tree-sitter-grammars/java.wasm