karajan-code 2.33.0 → 3.0.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 +136 -36
- package/bin/kj-rag-mcp.js +75 -0
- package/docs/README.es.md +4 -4
- package/package.json +22 -16
- package/packages/hu-board/package.json +5 -2
- package/scripts/git-hooks/post-merge +7 -0
- package/src/audit/basal-cost.js +17 -12
- package/src/audit/deterministic-summary.js +20 -0
- package/src/audit/injection-findings.js +105 -0
- package/src/checks/hardware.js +150 -0
- package/src/checks/node.js +9 -10
- package/src/cli/register-meta.js +57 -1
- package/src/cli/register-pipeline.js +1 -0
- package/src/commands/doctor.js +2 -0
- package/src/commands/init.js +41 -1
- package/src/commands/rag-mcp.js +18 -0
- package/src/commands/rag.js +82 -8
- package/src/commands/run.js +8 -0
- package/src/commands/telemetry.js +70 -0
- package/src/config/defaults.js +11 -1
- 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/orchestrator/stages/coder-stage.js +3 -0
- package/src/orchestrator/stages/reviewer-stage.js +11 -0
- 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/roles/audit-role.js +9 -1
- package/src/sonar/scanner.js +51 -16
- package/src/utils/manifest-sniff.js +82 -0
- package/src/utils/quota-simulator.js +56 -0
- package/src/utils/telemetry.js +34 -12
- 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
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// KJC-TSK-0477 — Detectores mínimos de framework por stack (Python/Rust/Go).
|
|
2
|
+
// Consume el sniff de manifests (PR-C) para conocer deps + presencia de
|
|
3
|
+
// archivos canónicos. Cero deps externas, fail-soft (devuelve null o lista
|
|
4
|
+
// vacía). El brief markdown se sintetiza río abajo desde estos campos.
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
|
|
8
|
+
const PY_FW = [
|
|
9
|
+
{ name: "django", deps: ["django"], file: "manage.py" },
|
|
10
|
+
{ name: "flask", deps: ["flask"], file: "app.py" },
|
|
11
|
+
{ name: "fastapi", deps: ["fastapi"], file: "main.py" },
|
|
12
|
+
];
|
|
13
|
+
const RUST_FW = [
|
|
14
|
+
{ name: "axum", deps: ["axum"] },
|
|
15
|
+
{ name: "actix-web", deps: ["actix-web"] },
|
|
16
|
+
{ name: "rocket", deps: ["rocket"] },
|
|
17
|
+
];
|
|
18
|
+
const GO_FW = [
|
|
19
|
+
{ name: "gin", deps: ["github.com/gin-gonic/gin"] },
|
|
20
|
+
{ name: "echo", deps: ["github.com/labstack/echo"] },
|
|
21
|
+
{ name: "fiber", deps: ["github.com/gofiber/fiber"] },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
function matchFw(manifestRaw, projectDir, table) {
|
|
25
|
+
const found = [];
|
|
26
|
+
for (const fw of table) {
|
|
27
|
+
const hasDep = fw.deps.some((d) => manifestRaw?.includes(d));
|
|
28
|
+
const hasFile = fw.file ? existsSync(path.join(projectDir, fw.file)) : false;
|
|
29
|
+
if (hasDep || hasFile) found.push({ name: fw.name, viaDep: hasDep, viaFile: hasFile });
|
|
30
|
+
}
|
|
31
|
+
return found;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function detectLayout(projectDir, expected) {
|
|
35
|
+
return expected.filter((d) => existsSync(path.join(projectDir, d)));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Detect frameworks + canonical layout per stack. Returns one entry per
|
|
39
|
+
// detected manifest stack (skips Node — the existing JS path already covers
|
|
40
|
+
// that with detectProjectStack).
|
|
41
|
+
export function detectFrameworksMultiLang(projectDir, manifests = [], { readManifest } = {}) {
|
|
42
|
+
const out = [];
|
|
43
|
+
for (const m of manifests) {
|
|
44
|
+
if (m.stack === "node") continue;
|
|
45
|
+
const raw = readManifest ? readManifest(m) : "";
|
|
46
|
+
if (m.stack === "python") {
|
|
47
|
+
out.push({ stack: "python", manifest: m.manifest,
|
|
48
|
+
frameworks: matchFw(raw, projectDir, PY_FW),
|
|
49
|
+
layout: detectLayout(projectDir, ["apps", "src", "tests"]) });
|
|
50
|
+
} else if (m.stack === "rust") {
|
|
51
|
+
const isBin = existsSync(path.join(projectDir, "src", "main.rs"));
|
|
52
|
+
const isLib = existsSync(path.join(projectDir, "src", "lib.rs"));
|
|
53
|
+
out.push({ stack: "rust", manifest: m.manifest,
|
|
54
|
+
crateType: isBin && isLib ? "mixed" : isBin ? "binary" : isLib ? "library" : null,
|
|
55
|
+
frameworks: matchFw(raw, projectDir, RUST_FW),
|
|
56
|
+
layout: detectLayout(projectDir, ["src", "tests", "examples", "benches"]) });
|
|
57
|
+
} else if (m.stack === "go") {
|
|
58
|
+
out.push({ stack: "go", manifest: m.manifest, modulePath: m.name || null,
|
|
59
|
+
frameworks: matchFw(raw, projectDir, GO_FW),
|
|
60
|
+
layout: detectLayout(projectDir, ["cmd", "internal", "pkg"]) });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
@@ -7,6 +7,8 @@ import { readdir } from "node:fs/promises";
|
|
|
7
7
|
import { join, relative } from "node:path";
|
|
8
8
|
|
|
9
9
|
import { detectProjectStack } from "../../utils/stack-detect.js";
|
|
10
|
+
import { sniffManifests } from "../../utils/manifest-sniff.js";
|
|
11
|
+
import { detectFrameworksMultiLang } from "./framework-multilang.js";
|
|
10
12
|
|
|
11
13
|
const IGNORED_DIRS = new Set(["node_modules", ".git", "dist", "build", "coverage", ".karajan", ".next", "__pycache__"]);
|
|
12
14
|
|
|
@@ -100,16 +102,31 @@ export async function collectAdrs(projectDir) {
|
|
|
100
102
|
return found;
|
|
101
103
|
}
|
|
102
104
|
|
|
105
|
+
// Read a manifest file as raw text (best-effort) for framework dep matching.
|
|
106
|
+
function readManifestRaw(projectDir) {
|
|
107
|
+
return (m) => {
|
|
108
|
+
try { return readFileSync(join(projectDir, m.manifest), "utf8"); }
|
|
109
|
+
catch { return ""; }
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
103
113
|
// One-shot bundle: every slot is independent; missing slots → null/[].
|
|
114
|
+
// KJC-TSK-0477: manifests (multi-stack) + frameworksMultiLang (Python/Rust/Go)
|
|
115
|
+
// se añaden de forma aditiva para que el brief pueda incluir secciones
|
|
116
|
+
// condicionales sin romper consumidores existentes.
|
|
104
117
|
export async function collectAll(projectDir) {
|
|
105
|
-
const [stack, tree, adrs] = await Promise.all([
|
|
118
|
+
const [stack, tree, adrs, manifests] = await Promise.all([
|
|
106
119
|
detectProjectStack(projectDir).catch(() => null),
|
|
107
120
|
collectTree(projectDir).catch(() => []),
|
|
108
121
|
collectAdrs(projectDir).catch(() => []),
|
|
122
|
+
sniffManifests(projectDir).catch(() => []),
|
|
109
123
|
]);
|
|
124
|
+
const frameworksMultiLang = detectFrameworksMultiLang(projectDir, manifests, { readManifest: readManifestRaw(projectDir) });
|
|
110
125
|
return {
|
|
111
126
|
projectDir,
|
|
112
127
|
stack,
|
|
128
|
+
manifests,
|
|
129
|
+
frameworksMultiLang,
|
|
113
130
|
tree,
|
|
114
131
|
git: collectGitHistory(projectDir),
|
|
115
132
|
configs: collectConfigs(projectDir),
|
|
@@ -19,6 +19,7 @@ import { invokeSolomon } from "../solomon-escalation.js";
|
|
|
19
19
|
import { detectRateLimit } from "../../utils/rate-limit-detector.js";
|
|
20
20
|
import { createStallDetector } from "../../utils/stall-detector.js";
|
|
21
21
|
import { buildStandbyState } from "../../brain/standby-store.js";
|
|
22
|
+
import { applyQuotaSimulation } from "../../utils/quota-simulator.js";
|
|
22
23
|
import { snapshotHomeTopLevel, detectNewHomeEntries, formatLeakMessage, verifyLeaksAgainstTranscript, detectTranscriptCdLeaks } from "../fs-leak-detector.js";
|
|
23
24
|
|
|
24
25
|
export async function runCoderStage({ coderRoleInstance, coderRole, config, logger, emitter, eventBase, session, plannedTask, trackBudget, iteration, brainCtx, acceptanceTests = null, adrs = null, specSection = null, reviewerFindings = null, huId = null }) {
|
|
@@ -76,6 +77,7 @@ export async function runCoderStage({ coderRoleInstance, coderRole, config, logg
|
|
|
76
77
|
coderStall.stop();
|
|
77
78
|
}
|
|
78
79
|
trackBudget({ role: "coder", provider: coderRole.provider, model: coderRole.model, result: coderExecResult.result, duration_ms: Date.now() - coderStart });
|
|
80
|
+
applyQuotaSimulation(coderExecResult, { iteration, agent: coderRole.provider });
|
|
79
81
|
|
|
80
82
|
if (!coderExecResult.ok) {
|
|
81
83
|
const details = coderExecResult.result?.error || coderExecResult.summary || "unknown error";
|
|
@@ -281,6 +283,7 @@ export async function runRefactorerStage({ refactorerRole, config, logger, emitt
|
|
|
281
283
|
refactorerStall.stop();
|
|
282
284
|
}
|
|
283
285
|
trackBudget({ role: "refactorer", provider: refactorerRole.provider, model: refactorerRole.model, result: refResult.result, duration_ms: Date.now() - refactorerStart });
|
|
286
|
+
applyQuotaSimulation(refResult, { iteration, agent: refactorerRole.provider });
|
|
284
287
|
if (!refResult.ok) {
|
|
285
288
|
const details = refResult.result?.error || refResult.summary || "unknown error";
|
|
286
289
|
// Quota cap → hibernate cleanly, same as the coder stage above.
|
|
@@ -13,6 +13,7 @@ import { runReviewerWithFallback } from "../reviewer-fallback.js";
|
|
|
13
13
|
import { invokeSolomon } from "../solomon-escalation.js";
|
|
14
14
|
import { detectRateLimit } from "../../utils/rate-limit-detector.js";
|
|
15
15
|
import { createStallDetector } from "../../utils/stall-detector.js";
|
|
16
|
+
import { applyQuotaSimulation } from "../../utils/quota-simulator.js";
|
|
16
17
|
|
|
17
18
|
function categorizeIssues(issues) {
|
|
18
19
|
const categories = { security: 0, correctness: 0, tests: 0, style: 0, other: 0 };
|
|
@@ -269,6 +270,16 @@ export async function runReviewerStage({ reviewerRole, config, logger, emitter,
|
|
|
269
270
|
reviewerStall.stop();
|
|
270
271
|
}
|
|
271
272
|
|
|
273
|
+
if (applyQuotaSimulation(reviewerExec.execResult, { iteration, agent: reviewerRole.provider })) {
|
|
274
|
+
// Reviewer wraps the agent call: detectRateLimit reads `lastAttempt.result.error`,
|
|
275
|
+
// so propagate the synthetic line into the most recent attempt too.
|
|
276
|
+
const last = reviewerExec.attempts?.at(-1);
|
|
277
|
+
if (last) {
|
|
278
|
+
if (!last.result || typeof last.result !== "object") last.result = {};
|
|
279
|
+
last.result.error = reviewerExec.execResult.result.error;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
272
283
|
if (!reviewerExec.execResult?.ok) {
|
|
273
284
|
const lastAttempt = reviewerExec.attempts.at(-1);
|
|
274
285
|
const details =
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// KJC-TSK-0455 PR2 — Pre-run drift check + post-merge hook installer.
|
|
2
|
+
// Keeps the local RAG index aligned with HEAD so retrieval never serves
|
|
3
|
+
// stale code without the user having to run `kj rag index` by hand.
|
|
4
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { execa } from "execa";
|
|
8
|
+
|
|
9
|
+
import { openVecStore, projectSlug, getLastIndexedCommit, setLastIndexedCommit } from "./vec-store.js";
|
|
10
|
+
import { indexProjectDelta } from "./indexer.js";
|
|
11
|
+
import { makeEmbedder } from "./embedders/factory.js";
|
|
12
|
+
|
|
13
|
+
const HOOK_SRC = resolve(fileURLToPath(import.meta.url), "../../../scripts/git-hooks/post-merge");
|
|
14
|
+
|
|
15
|
+
export async function maybeAutoUpdate({ projectDir, config, logger = console, flags = {} } = {}) {
|
|
16
|
+
// commander turns `--no-rag-update` into flags.ragUpdate === false.
|
|
17
|
+
if (flags?.ragUpdate === false || config?.rag?.autoUpdate?.onRun === false) return { skipped: true };
|
|
18
|
+
if (!projectDir || !existsSync(join(projectDir, ".git"))) return { skipped: true };
|
|
19
|
+
let head;
|
|
20
|
+
try { const r = await execa("git", ["-C", projectDir, "rev-parse", "HEAD"]); head = r.stdout.trim(); }
|
|
21
|
+
catch { return { skipped: true }; }
|
|
22
|
+
const slug = projectSlug(projectDir);
|
|
23
|
+
const db = openVecStore({ dim: config?.rag?.embedder?.dim || 768 });
|
|
24
|
+
try {
|
|
25
|
+
const since = getLastIndexedCommit(db, slug);
|
|
26
|
+
if (!since || since === head) return { skipped: true, head };
|
|
27
|
+
logger.info?.(`[rag] drift detected (${since.slice(0, 7)} → ${head.slice(0, 7)}); running delta update`);
|
|
28
|
+
const totals = await indexProjectDelta(projectDir, { db, embedder: makeEmbedder(config), since, logger });
|
|
29
|
+
if (totals.head) setLastIndexedCommit(db, slug, totals.head);
|
|
30
|
+
return { ran: true, totals };
|
|
31
|
+
} catch (err) {
|
|
32
|
+
logger.warn?.(`[rag] auto-update failed (${err.message}); continuing without refresh`);
|
|
33
|
+
return { failed: true, error: err.message };
|
|
34
|
+
} finally { db.close(); }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function installPostMergeHook({ projectDir, logger = console } = {}) {
|
|
38
|
+
const hooksDir = join(projectDir, ".git", "hooks");
|
|
39
|
+
if (!existsSync(join(projectDir, ".git"))) throw new Error(`Not a git repository: ${projectDir}`);
|
|
40
|
+
if (!existsSync(hooksDir)) mkdirSync(hooksDir, { recursive: true });
|
|
41
|
+
const target = join(hooksDir, "post-merge");
|
|
42
|
+
const src = readFileSync(HOOK_SRC, "utf8");
|
|
43
|
+
if (existsSync(target) && !readFileSync(target, "utf8").includes("KJC-TSK-0455")) {
|
|
44
|
+
logger.warn?.(`[rag] ${target} already exists and was not installed by kj; leaving untouched`);
|
|
45
|
+
return { skipped: true, target };
|
|
46
|
+
}
|
|
47
|
+
writeFileSync(target, src);
|
|
48
|
+
chmodSync(target, 0o755);
|
|
49
|
+
return { installed: true, target };
|
|
50
|
+
}
|
package/src/rag/chunker.js
CHANGED
|
@@ -92,13 +92,17 @@ export function chunkPlan(plan, { path = "<plan>", limit = DEFAULT_CHUNK_LIMIT }
|
|
|
92
92
|
// ---------- source chunker (export-symbol granularity) ------------
|
|
93
93
|
|
|
94
94
|
import { chunkSourceAST } from "./chunker-ast.js";
|
|
95
|
+
import { adapterForPath } from "../lang/registry.js";
|
|
95
96
|
|
|
96
97
|
const EXPORT_RE = /^export\s+(?:async\s+)?(?:function|class|const|let)\s+(\w+)/gm;
|
|
97
98
|
|
|
98
|
-
// KJC-TSK-0444
|
|
99
|
-
//
|
|
100
|
-
//
|
|
99
|
+
// KJC-TSK-0444 + KJC-PCS-0052 PR-B — dispatch por adapter cuando el path
|
|
100
|
+
// coincide con un lenguaje registrado que aporta su propio `chunkSource`
|
|
101
|
+
// (p.ej. Python regex top-level). Si no, mantiene el camino JS histórico:
|
|
102
|
+
// AST de @babel/parser primero, fallback a export-regex.
|
|
101
103
|
export function chunkSource(text, opts = {}) {
|
|
104
|
+
const ad = adapterForPath(opts.path);
|
|
105
|
+
if (ad?.chunkSource) return ad.chunkSource(text, opts);
|
|
102
106
|
const ast = chunkSourceAST(text, opts);
|
|
103
107
|
if (ast) return ast;
|
|
104
108
|
return chunkSourceRegex(text, opts);
|
package/src/rag/eval.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// KJC-TSK-0483 — Retrieval-quality harness. Loads a golden set of queries
|
|
2
|
+
// (each with expected source paths and/or symbol names), runs the retriever
|
|
3
|
+
// against each, and reports recall@k + MRR per query and aggregated. Pure:
|
|
4
|
+
// the runner takes `runQuery` as a function so the CLI plugs in `query()`
|
|
5
|
+
// while tests can use a stub.
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
|
|
8
|
+
const DEFAULT_KS = [5, 10];
|
|
9
|
+
|
|
10
|
+
export function loadGoldenQueries(path) {
|
|
11
|
+
const data = JSON.parse(readFileSync(path, "utf8"));
|
|
12
|
+
if (!Array.isArray(data)) throw new Error(`golden queries: expected an array in ${path}`);
|
|
13
|
+
return data;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function hitMatchesExpected(hit, expected) {
|
|
17
|
+
if (!hit) return false;
|
|
18
|
+
const src = hit.source || "";
|
|
19
|
+
if (expected.sources?.some((s) => src.endsWith(s))) return true;
|
|
20
|
+
const sym = hit.metadata?.symbol;
|
|
21
|
+
if (sym && expected.symbols?.includes(sym)) return true;
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Per-query metrics. `recall@k` is binary — 1 if any expected match lands in
|
|
26
|
+
// the top-k, else 0. `rr` is the reciprocal of the first relevant rank (1 if
|
|
27
|
+
// the very first hit matches, 0 if none do). `firstRank` is null on miss so
|
|
28
|
+
// callers can render "—" without re-checking.
|
|
29
|
+
export function scoreQuery(hits, expected, ks = DEFAULT_KS) {
|
|
30
|
+
const recall = {};
|
|
31
|
+
for (const k of ks) {
|
|
32
|
+
recall[`@${k}`] = hits.slice(0, k).some((h) => hitMatchesExpected(h, expected)) ? 1 : 0;
|
|
33
|
+
}
|
|
34
|
+
let firstRank = null;
|
|
35
|
+
let rr = 0;
|
|
36
|
+
for (let i = 0; i < hits.length; i += 1) {
|
|
37
|
+
if (hitMatchesExpected(hits[i], expected)) { firstRank = i + 1; rr = 1 / firstRank; break; }
|
|
38
|
+
}
|
|
39
|
+
return { recall, rr, firstRank };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function aggregate(results, ks = DEFAULT_KS) {
|
|
43
|
+
const n = results.length || 1;
|
|
44
|
+
const recall = {};
|
|
45
|
+
for (const k of ks) {
|
|
46
|
+
recall[`@${k}`] = results.reduce((acc, r) => acc + (r.recall[`@${k}`] || 0), 0) / n;
|
|
47
|
+
}
|
|
48
|
+
const mrr = results.reduce((acc, r) => acc + (r.rr || 0), 0) / n;
|
|
49
|
+
return { n: results.length, recall, mrr };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function runEval(queries, runQuery, { topK = 10, ks = DEFAULT_KS } = {}) {
|
|
53
|
+
const results = [];
|
|
54
|
+
for (const q of queries) {
|
|
55
|
+
const hits = (await runQuery(q.query, topK)) || [];
|
|
56
|
+
const expected = { sources: q.expected_sources || [], symbols: q.expected_symbols || [] };
|
|
57
|
+
const scored = scoreQuery(hits, expected, ks);
|
|
58
|
+
results.push({ query: q.query, hits, ...scored });
|
|
59
|
+
}
|
|
60
|
+
return { results, aggregate: aggregate(results, ks) };
|
|
61
|
+
}
|
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;
|