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.
- package/README.md +25 -0
- package/package.json +4 -2
- package/scripts/esbuild-sea.config.mjs +10 -1
- package/scripts/git-hooks/post-merge +7 -0
- package/src/audit/audit-history-display.js +61 -0
- package/src/audit/audit-history.js +102 -0
- package/src/audit/basal-cost.js +17 -12
- package/src/audit/harness-scorecard.js +51 -0
- package/src/audit/harness-section.js +77 -0
- package/src/checks/harness-scorecard.js +41 -0
- package/src/cli/register-meta.js +28 -1
- package/src/cli/register-pipeline.js +1 -0
- package/src/commands/audit.js +73 -16
- package/src/commands/doctor.js +2 -0
- package/src/commands/rag.js +82 -8
- package/src/commands/run.js +8 -0
- package/src/config/defaults.js +7 -0
- package/src/lang/chunk-go.js +94 -0
- package/src/lang/chunk-java.js +90 -0
- package/src/lang/chunk-python.js +88 -0
- package/src/lang/chunk-rust.js +102 -0
- package/src/lang/go.js +14 -0
- package/src/lang/java.js +16 -0
- package/src/lang/js.js +8 -0
- package/src/lang/python.js +14 -0
- package/src/lang/registry.js +79 -0
- package/src/lang/rust.js +14 -0
- package/src/lang/tree-sitter-loader.js +49 -0
- package/src/onboarder/collectors/framework-multilang.js +64 -0
- package/src/onboarder/collectors/index.js +18 -1
- package/src/rag/auto-update.js +50 -0
- package/src/rag/chunker.js +7 -3
- package/src/rag/eval.js +61 -0
- package/src/rag/indexer.js +81 -10
- package/src/rag/retriever.js +57 -5
- package/src/rag/vec-store.js +69 -3
- package/src/rag/watcher.js +6 -4
- package/src/utils/manifest-sniff.js +82 -0
- package/templates/roles/onboarder.md +10 -1
- package/vendor/tree-sitter-grammars/README.md +21 -0
- package/vendor/tree-sitter-grammars/go.wasm +0 -0
- package/vendor/tree-sitter-grammars/java.wasm +0 -0
- package/vendor/tree-sitter-grammars/python.wasm +0 -0
- package/vendor/tree-sitter-grammars/rust.wasm +0 -0
package/src/rag/indexer.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
64
|
-
|
|
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.
|
|
113
|
-
const
|
|
114
|
-
const sources = await listFiles(projectDir, (p) =>
|
|
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
|
+
}
|
package/src/rag/retriever.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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)
|
|
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 };
|
package/src/rag/vec-store.js
CHANGED
|
@@ -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;
|
package/src/rag/watcher.js
CHANGED
|
@@ -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
|
|
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
|
|
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`, `
|
|
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
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|