karajan-code 4.62.1 → 4.63.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/package.json +2 -2
- package/packages/hu-board/src/plan-mutations.js +21 -0
- package/src/cli/register-meta.js +15 -2
- package/src/commands/hu.js +110 -13
- package/src/commands/rag.js +34 -8
- package/src/hu/github-board.js +20 -3
- package/src/hu/team-board-cache.js +84 -0
- package/src/hu/team-board-mirror.js +26 -0
- package/src/lang/chunk-go.js +2 -27
- package/src/lang/chunk-java.js +7 -32
- package/src/lang/chunk-python.js +2 -27
- package/src/lang/chunk-rust.js +2 -27
- package/src/lang/chunk-span.js +49 -0
- package/src/plan/plan-store.js +4 -1
- package/src/rag/auto-update.js +55 -7
- package/src/rag/bootstrap-index.js +86 -0
- package/src/rag/chunker-ast.js +5 -12
- package/src/rag/chunker.js +2 -14
- package/src/rag/embed-header.js +35 -0
- package/src/rag/embed-schema.js +28 -0
- package/src/rag/eval.js +39 -3
- package/src/rag/indexer.js +36 -14
- package/src/rag/worktree-files.js +26 -0
package/src/lang/chunk-python.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// devuelve `[{ text, metadata: { source, kind, symbol, language } }]`.
|
|
15
15
|
|
|
16
16
|
import { getLoaded, loadLanguage } from "./tree-sitter-loader.js";
|
|
17
|
+
import { spanChunks } from "./chunk-span.js";
|
|
17
18
|
|
|
18
19
|
const DEFAULT_LIMIT = 800;
|
|
19
20
|
const DEFAULT_OVERLAP = 100;
|
|
@@ -23,16 +24,6 @@ export async function preparePython() {
|
|
|
23
24
|
return loadLanguage("python");
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
function windowText(text, limit, overlap) {
|
|
27
|
-
if (text.length <= limit) return [text];
|
|
28
|
-
const step = Math.max(1, limit - overlap);
|
|
29
|
-
const out = [];
|
|
30
|
-
for (let i = 0; i < text.length; i += step) {
|
|
31
|
-
out.push(text.slice(i, i + limit));
|
|
32
|
-
if (i + limit >= text.length) break;
|
|
33
|
-
}
|
|
34
|
-
return out;
|
|
35
|
-
}
|
|
36
27
|
|
|
37
28
|
function marksViaAST(text) {
|
|
38
29
|
const entry = getLoaded("python");
|
|
@@ -68,21 +59,5 @@ function marksViaRegex(text) {
|
|
|
68
59
|
}
|
|
69
60
|
|
|
70
61
|
export function chunkPython(text, { path = "<src>", limit = DEFAULT_LIMIT, overlap = DEFAULT_OVERLAP } = {}) {
|
|
71
|
-
|
|
72
|
-
if (marks.length === 0) {
|
|
73
|
-
return windowText(text, limit, overlap).map((piece) => ({
|
|
74
|
-
text: piece,
|
|
75
|
-
metadata: { source: path, kind: "code", symbol: null, language: "python" },
|
|
76
|
-
}));
|
|
77
|
-
}
|
|
78
|
-
const out = [];
|
|
79
|
-
for (let i = 0; i < marks.length; i += 1) {
|
|
80
|
-
const start = marks[i].idx;
|
|
81
|
-
const end = i + 1 < marks.length ? marks[i + 1].idx : text.length;
|
|
82
|
-
const body = text.slice(start, end);
|
|
83
|
-
for (const piece of windowText(body, limit, overlap)) {
|
|
84
|
-
out.push({ text: piece, metadata: { source: path, kind: "code", symbol: marks[i].symbol, language: "python" } });
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
return out;
|
|
62
|
+
return spanChunks(text, marksViaAST(text) ?? marksViaRegex(text), { path, language: "python", limit, overlap });
|
|
88
63
|
}
|
package/src/lang/chunk-rust.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// que metadata.symbol apunte al tipo implementado (útil para grep en RAG).
|
|
8
8
|
|
|
9
9
|
import { getLoaded, loadLanguage } from "./tree-sitter-loader.js";
|
|
10
|
+
import { spanChunks } from "./chunk-span.js";
|
|
10
11
|
|
|
11
12
|
const DEFAULT_LIMIT = 800;
|
|
12
13
|
const DEFAULT_OVERLAP = 100;
|
|
@@ -25,16 +26,6 @@ export async function prepareRust() {
|
|
|
25
26
|
return loadLanguage("rust");
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
function windowText(text, limit, overlap) {
|
|
29
|
-
if (text.length <= limit) return [text];
|
|
30
|
-
const step = Math.max(1, limit - overlap);
|
|
31
|
-
const out = [];
|
|
32
|
-
for (let i = 0; i < text.length; i += step) {
|
|
33
|
-
out.push(text.slice(i, i + limit));
|
|
34
|
-
if (i + limit >= text.length) break;
|
|
35
|
-
}
|
|
36
|
-
return out;
|
|
37
|
-
}
|
|
38
29
|
|
|
39
30
|
function symbolForImpl(node) {
|
|
40
31
|
const typeNode = node.childForFieldName("type");
|
|
@@ -82,21 +73,5 @@ function marksViaRegex(text) {
|
|
|
82
73
|
}
|
|
83
74
|
|
|
84
75
|
export function chunkRust(text, { path = "<src>", limit = DEFAULT_LIMIT, overlap = DEFAULT_OVERLAP } = {}) {
|
|
85
|
-
|
|
86
|
-
if (marks.length === 0) {
|
|
87
|
-
return windowText(text, limit, overlap).map((piece) => ({
|
|
88
|
-
text: piece,
|
|
89
|
-
metadata: { source: path, kind: "code", symbol: null, language: "rust" },
|
|
90
|
-
}));
|
|
91
|
-
}
|
|
92
|
-
const out = [];
|
|
93
|
-
for (let i = 0; i < marks.length; i += 1) {
|
|
94
|
-
const start = marks[i].idx;
|
|
95
|
-
const end = i + 1 < marks.length ? marks[i + 1].idx : text.length;
|
|
96
|
-
const body = text.slice(start, end);
|
|
97
|
-
for (const piece of windowText(body, limit, overlap)) {
|
|
98
|
-
out.push({ text: piece, metadata: { source: path, kind: "code", symbol: marks[i].symbol, language: "rust" } });
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
return out;
|
|
76
|
+
return spanChunks(text, marksViaAST(text) ?? marksViaRegex(text), { path, language: "rust", limit, overlap });
|
|
102
77
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-1171 (RAG-B, ADR 0026): the shared tail of every code chunker. A chunker
|
|
3
|
+
* finds marks ({ idx, symbol, parent? }); this slices the text from each mark to the
|
|
4
|
+
* next, windows what is too long for the embedder, and stamps each piece with its
|
|
5
|
+
* 1-based line range and the symbol it belongs to, so the embedded header and the
|
|
6
|
+
* citations can name file, symbol, parent and lines.
|
|
7
|
+
*/
|
|
8
|
+
const DEFAULT_LIMIT = 800;
|
|
9
|
+
const DEFAULT_OVERLAP = 100;
|
|
10
|
+
|
|
11
|
+
/** 1-based line of a character index. */
|
|
12
|
+
export const lineAt = (text, idx) => {
|
|
13
|
+
let line = 1;
|
|
14
|
+
for (let i = 0; i < idx && i < text.length; i += 1) if (text.charCodeAt(i) === 10) line += 1;
|
|
15
|
+
return line;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// Trailing whitespace (a newline and the next line's indentation) is not a line of the symbol.
|
|
19
|
+
const linesIn = (piece) => {
|
|
20
|
+
const body = piece.trimEnd();
|
|
21
|
+
let n = 0;
|
|
22
|
+
for (let i = 0; i < body.length; i += 1) if (body.charCodeAt(i) === 10) n += 1;
|
|
23
|
+
return n;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** The slice at `startIdx`, windowed, each piece with its line range. */
|
|
27
|
+
export function pieceSpans(text, startIdx, slice, { limit = DEFAULT_LIMIT, overlap = DEFAULT_OVERLAP } = {}) {
|
|
28
|
+
const step = Math.max(1, limit - overlap);
|
|
29
|
+
const out = [];
|
|
30
|
+
for (let i = 0; ; i += step) {
|
|
31
|
+
const piece = slice.slice(i, i + limit);
|
|
32
|
+
const startLine = lineAt(text, startIdx + i);
|
|
33
|
+
out.push({ text: piece, start_line: startLine, end_line: startLine + linesIn(piece) });
|
|
34
|
+
if (i + limit >= slice.length) break;
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Chunks from marks; with no marks, the whole text as one anonymous symbol. */
|
|
40
|
+
export function spanChunks(text, marks, { path = "<src>", kind = "code", language, limit, overlap } = {}) {
|
|
41
|
+
const base = { source: path, kind, ...(language === undefined ? {} : { language }) };
|
|
42
|
+
const ranges = marks.length === 0
|
|
43
|
+
? [{ start: 0, end: text.length, symbol: null, parent: null }]
|
|
44
|
+
: marks.map((m, i) => ({ start: m.idx, end: i + 1 < marks.length ? marks[i + 1].idx : text.length, symbol: m.symbol, parent: m.parent ?? null }));
|
|
45
|
+
return ranges.flatMap(({ start, end, symbol, parent }) =>
|
|
46
|
+
pieceSpans(text, start, text.slice(start, end), { limit, overlap }).map(({ text: piece, start_line, end_line }) => ({
|
|
47
|
+
text: piece, metadata: { ...base, symbol, parent, start_line, end_line },
|
|
48
|
+
})));
|
|
49
|
+
}
|
package/src/plan/plan-store.js
CHANGED
|
@@ -63,8 +63,11 @@ export function projectSlug(projectDir) {
|
|
|
63
63
|
.slice(0, 120);
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
// KJC-TSK-1166: the board honours KJ_PLANS_DIR as its plans root; what kj writes must land
|
|
67
|
+
// in the same root, or the board shows none of it.
|
|
66
68
|
export function plansDir(projectDir) {
|
|
67
|
-
|
|
69
|
+
const root = process.env.KJ_PLANS_DIR || path.join(getKjHome(), "plans");
|
|
70
|
+
return path.join(root, projectSlug(projectDir));
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
/**
|
package/src/rag/auto-update.js
CHANGED
|
@@ -7,10 +7,25 @@ import { fileURLToPath } from "node:url";
|
|
|
7
7
|
import { execa } from "execa";
|
|
8
8
|
|
|
9
9
|
import { projectSlug, getLastIndexedCommit, setLastIndexedCommit } from "./vec-store.js";
|
|
10
|
-
import { indexProjectDelta } from "./indexer.js";
|
|
10
|
+
import { indexProject, indexProjectDelta } from "./indexer.js";
|
|
11
|
+
import { readRefreshedPaths, untrackedFiles, writeRefreshedPaths } from "./worktree-files.js";
|
|
12
|
+
import { startBootstrap } from "./bootstrap-index.js";
|
|
11
13
|
import { ragExclude } from "./indexable.js";
|
|
12
14
|
import { makeGovernedEmbedder } from "./governed-embedder.js";
|
|
13
15
|
import { openProjectStore } from "./project-store.js";
|
|
16
|
+
import { readEmbedSchema } from "./embed-schema.js";
|
|
17
|
+
import { EMBED_SCHEMA } from "./embed-header.js";
|
|
18
|
+
import { getKarajanHome } from "../utils/paths.js";
|
|
19
|
+
|
|
20
|
+
// Said once per process and project: every query of a session would repeat it otherwise.
|
|
21
|
+
const warnedFor = new Set();
|
|
22
|
+
const warnOnce = (logger, key, msg) => { if (!warnedFor.has(key)) { warnedFor.add(key); logger.warn?.(msg); } };
|
|
23
|
+
|
|
24
|
+
/** How many project files the index holds (a prefix compare: paths may hold % or _). */
|
|
25
|
+
const countSources = (db, slug, projectDir) => {
|
|
26
|
+
const prefix = `${projectDir.replace(/[\\/]+$/, "")}/`;
|
|
27
|
+
return db.prepare("SELECT COUNT(DISTINCT source) AS n FROM chunks WHERE project_slug = ? AND substr(source, 1, ?) = ?").get(slug, prefix.length, prefix).n;
|
|
28
|
+
};
|
|
14
29
|
|
|
15
30
|
// The pre-query refresh reindexes at most this many changed files (config.rag.autoUpdate.maxFiles).
|
|
16
31
|
export const DEFAULT_MAX_DRIFT_FILES = 25;
|
|
@@ -27,23 +42,56 @@ export async function maybeAutoUpdate({ projectDir, config, logger = console, fl
|
|
|
27
42
|
const db = openProjectStore({ projectDir, dim: config?.rag?.embedder?.dim || 768 });
|
|
28
43
|
try {
|
|
29
44
|
const since = getLastIndexedCommit(db, slug);
|
|
30
|
-
if (!since
|
|
45
|
+
if (!since) {
|
|
46
|
+
// KJC-TSK-1174 (RAG-A2): no index yet. It is built in the background, once; the session
|
|
47
|
+
// does not wait. A recent attempt that died (no Ollama) is not retried blindly.
|
|
48
|
+
const boot = startBootstrap(projectDir);
|
|
49
|
+
if (boot.recent) {
|
|
50
|
+
warnOnce(logger, `bootstrap:${projectDir}`, `[rag] ${slug} has no index and the background indexing did not finish: run kj rag index --with-sources`);
|
|
51
|
+
return { skipped: true, reason: "bootstrap-failed", head };
|
|
52
|
+
}
|
|
53
|
+
warnOnce(logger, `bootstrap:${projectDir}`, `[rag] ${slug} has no index yet: indexing it in the background (pid ${boot.pid}); queries answer from it once it is done`);
|
|
54
|
+
return { skipped: true, reason: "bootstrapping", pid: boot.pid, head };
|
|
55
|
+
}
|
|
31
56
|
// KJC-BUG-0304: a drift of hundreds of files (an index weeks old) reindexed the
|
|
32
57
|
// repo for half an hour before the query answered. Past the bound the query
|
|
33
58
|
// answers from the index as it is, and says how to bring it up to date.
|
|
34
59
|
const maxFiles = config?.rag?.autoUpdate?.maxFiles ?? DEFAULT_MAX_DRIFT_FILES;
|
|
35
|
-
|
|
36
|
-
|
|
60
|
+
// KJC-TSK-1171 (RAG-B): an index built before the embedded header holds vectors a
|
|
61
|
+
// query by file or symbol name cannot reach. Small, it is rebuilt here; large, the
|
|
62
|
+
// query answers from it and says once how to rebuild.
|
|
63
|
+
const schema = readEmbedSchema(projectDir);
|
|
64
|
+
if (schema < EMBED_SCHEMA) {
|
|
65
|
+
const sources = countSources(db, slug, projectDir);
|
|
66
|
+
if (sources > maxFiles) {
|
|
67
|
+
warnOnce(logger, projectDir, `[rag] the index of ${slug} predates the embedded header (schema ${schema} < ${EMBED_SCHEMA}): answering from it as it is; rebuild it with kj rag index --with-sources`);
|
|
68
|
+
return { skipped: true, reason: "embed-schema-old", sources, head };
|
|
69
|
+
}
|
|
70
|
+
logger.info?.(`[rag] the index of ${slug} predates the embedded header (schema ${schema} < ${EMBED_SCHEMA}); rebuilding ${sources} source(s)`);
|
|
71
|
+
const totals = await indexProject(projectDir, { db, embedder: makeGovernedEmbedder(config), karajanHome: getKarajanHome(), logger, withSources: true, exclude: ragExclude(config) });
|
|
72
|
+
if (totals.head) setLastIndexedCommit(db, slug, totals.head);
|
|
73
|
+
return { ran: true, rebuilt: true, totals };
|
|
74
|
+
}
|
|
75
|
+
// KJC-TSK-1174 (RAG-A1): the drift is measured up to the files on disk, untracked ones
|
|
76
|
+
// included, so a query sees what the session is writing before it is committed.
|
|
77
|
+
// The dirty paths of the last refresh count too: reverted or deleted since, they hold stale chunks.
|
|
78
|
+
const { stdout } = await execa("git", ["-C", projectDir, "diff", "--name-only", "-z", since]);
|
|
79
|
+
const dirty = [...stdout.split("\0").filter(Boolean), ...(await untrackedFiles(projectDir))];
|
|
80
|
+
const reconcile = readRefreshedPaths(projectDir).filter((p) => !dirty.includes(p));
|
|
81
|
+
const changed = dirty.length + reconcile.length;
|
|
82
|
+
if (changed === 0) return { skipped: true, head };
|
|
37
83
|
if (changed > maxFiles) {
|
|
38
84
|
logger.warn?.(`[rag] ${changed} file(s) changed since the last index (more than ${maxFiles}): answering from the index as it is; bring it up to date with kj rag index --since auto`);
|
|
39
85
|
return { skipped: true, reason: "drift-too-large", changed, head };
|
|
40
86
|
}
|
|
41
|
-
logger.info?.(`[rag] drift detected (${since.slice(0, 7)} → ${head.slice(0, 7)}); running delta update`);
|
|
42
|
-
const totals = await indexProjectDelta(projectDir, { db, embedder: makeGovernedEmbedder(config), since, logger, exclude: ragExclude(config) });
|
|
87
|
+
logger.info?.(`[rag] drift detected (${since.slice(0, 7)} → ${head.slice(0, 7)}, ${changed} file(s)); running delta update`);
|
|
88
|
+
const totals = await indexProjectDelta(projectDir, { db, embedder: makeGovernedEmbedder(config), since, logger, exclude: ragExclude(config), worktree: true, reconcile });
|
|
43
89
|
if (totals.head) setLastIndexedCommit(db, slug, totals.head);
|
|
90
|
+
writeRefreshedPaths(projectDir, dirty);
|
|
44
91
|
return { ran: true, totals };
|
|
45
92
|
} catch (err) {
|
|
46
|
-
|
|
93
|
+
// Said once per process: an embedder that is down would repeat it on every query otherwise.
|
|
94
|
+
warnOnce(logger, `failed:${projectDir}`, `[rag] auto-update failed (${err.message}); continuing without refresh`);
|
|
47
95
|
return { failed: true, error: err.message };
|
|
48
96
|
} finally { db.close(); }
|
|
49
97
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// KJC-TSK-1174 (RAG-A2): a project with no index gets one in the background, once, at the first
|
|
2
|
+
// command that needs it. The session goes on; queries answer from the index when it is done.
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { mkdirSync, readFileSync, renameSync, rmdirSync, statSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { dirname, resolve } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { projectDbPath } from "./project-store.js";
|
|
8
|
+
|
|
9
|
+
const KJ_BIN = resolve(fileURLToPath(import.meta.url), "../../../bin/kj.js");
|
|
10
|
+
// An attempt that died (no Ollama, no model) is not repeated on every command: this long apart.
|
|
11
|
+
export const RETRY_MS = 10 * 60 * 1000;
|
|
12
|
+
// A marker that does not parse, or a lock, older than this was left by a crashed command: taken over.
|
|
13
|
+
export const GRACE_MS = 30 * 1000;
|
|
14
|
+
// An indexer is not trusted to run longer than this: past it, its pid may belong to someone else.
|
|
15
|
+
export const MAX_RUN_MS = 60 * 60 * 1000;
|
|
16
|
+
const markerPath = (projectDir) => `${projectDbPath(projectDir)}.bootstrap`;
|
|
17
|
+
|
|
18
|
+
const alive = (pid) => { try { process.kill(pid, 0); return true; } catch { return false; } };
|
|
19
|
+
|
|
20
|
+
/** What the marker says of the last attempt: `{ pid, at, running }`, or null when none was ever made. */
|
|
21
|
+
const unlessGone = (fn) => {
|
|
22
|
+
try { return fn(); }
|
|
23
|
+
catch (err) {
|
|
24
|
+
if (err.code === "ENOENT") { return null; }
|
|
25
|
+
throw err;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function readBootstrap(projectDir, { now = Date.now() } = {}) {
|
|
30
|
+
const file = markerPath(projectDir);
|
|
31
|
+
// Read first, never check-then-read: another command may set the marker aside in between.
|
|
32
|
+
const raw = unlessGone(() => readFileSync(file, "utf8"));
|
|
33
|
+
if (raw === null) return null;
|
|
34
|
+
try {
|
|
35
|
+
const note = JSON.parse(raw);
|
|
36
|
+
return { ...note, running: alive(note.pid) && now - note.at < MAX_RUN_MS };
|
|
37
|
+
} catch {
|
|
38
|
+
// Another command is writing it this very moment: running for a moment; past the grace, a crash.
|
|
39
|
+
const stat = unlessGone(() => statSync(file));
|
|
40
|
+
return stat === null ? null : { pid: null, at: 0, running: now - stat.mtimeMs < GRACE_MS };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Starts `kj rag index --with-sources` detached, or reuses the one running. Returns
|
|
46
|
+
* `{ started, pid }`, or `{ started: false, recent: true }` when the last attempt died less
|
|
47
|
+
* than RETRY_MS ago: the index is then asked for by hand instead of being retried blindly.
|
|
48
|
+
*/
|
|
49
|
+
export function startBootstrap(projectDir, { spawnFn = spawn, bin = KJ_BIN, now = Date.now() } = {}) {
|
|
50
|
+
const file = markerPath(projectDir);
|
|
51
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
52
|
+
// One claimant at a time: the lock directory is created atomically, so of two commands arriving
|
|
53
|
+
// together only one reads the marker and decides.
|
|
54
|
+
const lock = `${file}.lock`;
|
|
55
|
+
const reuse = () => ({ started: false, pid: readBootstrap(projectDir, { now })?.pid ?? null });
|
|
56
|
+
if (!takeLock(lock, now)) return reuse();
|
|
57
|
+
try {
|
|
58
|
+
const last = readBootstrap(projectDir, { now });
|
|
59
|
+
if (last?.running) return { started: false, pid: last.pid };
|
|
60
|
+
if (last && now - last.at < RETRY_MS) return { started: false, recent: true, pid: null };
|
|
61
|
+
// The marker lands whole each time (written apart, then renamed): first with the claimant's
|
|
62
|
+
// pid, so it reads as running meanwhile, then with the indexer's.
|
|
63
|
+
const note = (pid) => { const tmp = `${file}.${process.pid}.tmp`; writeFileSync(tmp, JSON.stringify({ pid, at: now }), "utf8"); renameSync(tmp, file); };
|
|
64
|
+
note(process.pid);
|
|
65
|
+
const child = spawnFn(process.execPath, [bin, "rag", "index", "--with-sources"], { cwd: projectDir, detached: true, stdio: "ignore" });
|
|
66
|
+
child.unref?.();
|
|
67
|
+
note(child.pid);
|
|
68
|
+
return { started: true, pid: child.pid };
|
|
69
|
+
} finally { unlessGone(() => rmdirSync(lock)); }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const takeLock = (lock, now) => {
|
|
73
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
74
|
+
try { mkdirSync(lock); return true; }
|
|
75
|
+
catch (err) {
|
|
76
|
+
if (err.code !== "EEXIST") throw err;
|
|
77
|
+
const held = unlessGone(() => statSync(lock));
|
|
78
|
+
if (held && now - held.mtimeMs < GRACE_MS) return false;
|
|
79
|
+
// The holder crashed. The lock is set aside under a unique name first: only the one caller
|
|
80
|
+
// whose rename succeeds removes it, so nobody removes a lock another caller has just taken.
|
|
81
|
+
const aside = `${lock}.${process.pid}.${now}.stale`;
|
|
82
|
+
if (unlessGone(() => renameSync(lock, aside)) !== null) rmdirSync(aside);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
};
|
package/src/rag/chunker-ast.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// file (errorRecovery: true catches most cases; a hard syntax error
|
|
10
10
|
// returns null so the caller can fall back).
|
|
11
11
|
import { parse } from "@babel/parser";
|
|
12
|
+
import { pieceSpans } from "../lang/chunk-span.js";
|
|
12
13
|
|
|
13
14
|
const PLUGINS = ["typescript", "jsx", "decorators-legacy", "classProperties", "topLevelAwait"];
|
|
14
15
|
|
|
@@ -24,8 +25,10 @@ export function chunkSourceAST(text, { path = "<src>", limit = 800, overlap = 10
|
|
|
24
25
|
const start = leadingCommentsStart(node);
|
|
25
26
|
const slice = text.slice(start, node.end ?? start);
|
|
26
27
|
if (!slice.trim()) continue;
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
// KJC-TSK-1171: each piece carries its line range; top-level declarations have no parent.
|
|
29
|
+
for (const { text: piece, start_line, end_line } of pieceSpans(text, start, slice, { limit, overlap })) {
|
|
30
|
+
out.push({ text: piece, metadata: { source: path, kind: "code", symbol: sym, parent: null, start_line, end_line } });
|
|
31
|
+
}
|
|
29
32
|
}
|
|
30
33
|
return out.length === 0 ? null : out;
|
|
31
34
|
}
|
|
@@ -44,13 +47,3 @@ function leadingCommentsStart(node) {
|
|
|
44
47
|
return node.start ?? 0;
|
|
45
48
|
}
|
|
46
49
|
|
|
47
|
-
function windowText(text, limit, overlap) {
|
|
48
|
-
if (text.length <= limit) return [text];
|
|
49
|
-
const step = Math.max(1, limit - overlap);
|
|
50
|
-
const out = [];
|
|
51
|
-
for (let i = 0; i < text.length; i += step) {
|
|
52
|
-
out.push(text.slice(i, i + limit));
|
|
53
|
-
if (i + limit >= text.length) break;
|
|
54
|
-
}
|
|
55
|
-
return out;
|
|
56
|
-
}
|
package/src/rag/chunker.js
CHANGED
|
@@ -93,6 +93,7 @@ export function chunkPlan(plan, { path = "<plan>", limit = DEFAULT_CHUNK_LIMIT }
|
|
|
93
93
|
|
|
94
94
|
import { chunkSourceAST } from "./chunker-ast.js";
|
|
95
95
|
import { adapterForPath } from "../lang/registry.js";
|
|
96
|
+
import { spanChunks } from "../lang/chunk-span.js";
|
|
96
97
|
|
|
97
98
|
const EXPORT_RE = /^export\s+(?:async\s+)?(?:function|class|const|let)\s+(\w+)/gm;
|
|
98
99
|
|
|
@@ -113,20 +114,7 @@ function chunkSourceRegex(text, { path = "<src>", limit = DEFAULT_CHUNK_LIMIT, o
|
|
|
113
114
|
EXPORT_RE.lastIndex = 0;
|
|
114
115
|
let m;
|
|
115
116
|
while ((m = EXPORT_RE.exec(text)) !== null) marks.push({ idx: m.index, symbol: m[1] });
|
|
116
|
-
|
|
117
|
-
return windowText(text, { limit, overlap })
|
|
118
|
-
.map((piece) => ({ text: piece, metadata: { source: path, kind: "code", symbol: null } }));
|
|
119
|
-
}
|
|
120
|
-
const out = [];
|
|
121
|
-
for (let i = 0; i < marks.length; i += 1) {
|
|
122
|
-
const start = marks[i].idx;
|
|
123
|
-
const end = i + 1 < marks.length ? marks[i + 1].idx : text.length;
|
|
124
|
-
const body = text.slice(start, end);
|
|
125
|
-
for (const piece of windowText(body, { limit, overlap })) {
|
|
126
|
-
out.push({ text: piece, metadata: { source: path, kind: "code", symbol: marks[i].symbol } });
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
return out;
|
|
117
|
+
return spanChunks(text, marks, { path, limit, overlap });
|
|
130
118
|
}
|
|
131
119
|
|
|
132
120
|
export { chunkSourceRegex };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-1171 (RAG-B, ADR 0026): what the embedder reads before a chunk's body.
|
|
3
|
+
* A query by module, class or file name ("the tax module in billing", "session.js")
|
|
4
|
+
* finds nothing in a body that never mentions them; the header puts path, language,
|
|
5
|
+
* parent, symbol and lines into the vector. Only the embedder sees it: the text
|
|
6
|
+
* stored and shown to the agent is the body as it is.
|
|
7
|
+
*/
|
|
8
|
+
import { extname, relative } from "node:path";
|
|
9
|
+
|
|
10
|
+
/** Bumped when what the embedder reads changes: an index with an older one must be rebuilt. */
|
|
11
|
+
export const EMBED_SCHEMA = 2;
|
|
12
|
+
|
|
13
|
+
const LANGUAGES = { js: "javascript", mjs: "javascript", cjs: "javascript", jsx: "javascript", ts: "typescript", tsx: "typescript", py: "python", go: "go", rs: "rust", java: "java", md: "markdown", mdx: "markdown", json: "json", yml: "yaml", yaml: "yaml" };
|
|
14
|
+
|
|
15
|
+
const languageOf = (metadata, source) => metadata.language ?? LANGUAGES[extname(source).slice(1).toLowerCase()] ?? null;
|
|
16
|
+
|
|
17
|
+
/** The header line of a chunk, or "" when nothing beyond the path is known and the path is unknown. */
|
|
18
|
+
export function embedHeader(metadata = {}, { root = process.cwd() } = {}) {
|
|
19
|
+
const source = metadata.source;
|
|
20
|
+
if (!source) return "";
|
|
21
|
+
const parts = [relative(root, source) || source];
|
|
22
|
+
const language = languageOf(metadata, source);
|
|
23
|
+
if (language) parts.push(language);
|
|
24
|
+
if (metadata.headingPath?.length) parts.push(metadata.headingPath.join(" > "));
|
|
25
|
+
if (metadata.hu_id) parts.push(metadata.title ? `${metadata.hu_id} ${metadata.title}` : metadata.hu_id);
|
|
26
|
+
if (metadata.symbol) parts.push(metadata.parent ? `${metadata.parent}.${metadata.symbol}` : metadata.symbol);
|
|
27
|
+
if (metadata.start_line) parts.push(metadata.end_line && metadata.end_line !== metadata.start_line ? `L${metadata.start_line}-${metadata.end_line}` : `L${metadata.start_line}`);
|
|
28
|
+
return parts.join(" · ");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The text the embedder reads: header, blank line, body. The body alone when there is no header. */
|
|
32
|
+
export function embedText(chunk, opts) {
|
|
33
|
+
const header = embedHeader(chunk.metadata, opts);
|
|
34
|
+
return header ? `${header}\n\n${chunk.text}` : chunk.text;
|
|
35
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-1171 (RAG-B): which embed schema a project's index was built with. The
|
|
3
|
+
* marker lives next to the project's store; an index built before the header (or with
|
|
4
|
+
* no marker at all) is schema 1 and is rebuilt once, bounded, before it serves queries.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { dirname } from "node:path";
|
|
8
|
+
import { projectDbPath } from "./project-store.js";
|
|
9
|
+
import { EMBED_SCHEMA } from "./embed-header.js";
|
|
10
|
+
|
|
11
|
+
const PRE_HEADER = 1;
|
|
12
|
+
const markerPath = (projectDir) => `${projectDbPath(projectDir)}.embed-schema`;
|
|
13
|
+
|
|
14
|
+
/** The schema the index holds; 1 when nothing was ever recorded (every index before the header). */
|
|
15
|
+
export function readEmbedSchema(projectDir) {
|
|
16
|
+
const file = markerPath(projectDir);
|
|
17
|
+
if (!existsSync(file)) return PRE_HEADER;
|
|
18
|
+
const n = Number(readFileSync(file, "utf8").trim());
|
|
19
|
+
if (!Number.isInteger(n) || n < 1) throw new Error(`${file} does not hold an embed schema number`);
|
|
20
|
+
return n;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Recorded after a full index, never after a partial one: only a complete rebuild carries the schema. */
|
|
24
|
+
export function writeEmbedSchema(projectDir, schema = EMBED_SCHEMA) {
|
|
25
|
+
const file = markerPath(projectDir);
|
|
26
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
27
|
+
writeFileSync(file, `${schema}\n`, "utf8");
|
|
28
|
+
}
|
package/src/rag/eval.js
CHANGED
|
@@ -39,6 +39,16 @@ export function scoreQuery(hits, expected, ks = DEFAULT_KS) {
|
|
|
39
39
|
return { recall, rr, firstRank };
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
// KJC-TSK-1170 (RAG-E): latency travels with recall and MRR. p95 is the nearest-rank
|
|
43
|
+
// percentile over the measured queries; both are rounded to whole milliseconds.
|
|
44
|
+
function latencyOf(results) {
|
|
45
|
+
const ms = results.map((r) => r.ms).filter((v) => Number.isFinite(v)).toSorted((a, b) => a - b);
|
|
46
|
+
if (ms.length === 0) return null;
|
|
47
|
+
const meanMs = Math.round(ms.reduce((acc, v) => acc + v, 0) / ms.length);
|
|
48
|
+
const p95Ms = Math.round(ms[Math.max(0, Math.ceil(ms.length * 0.95) - 1)]);
|
|
49
|
+
return { meanMs, p95Ms };
|
|
50
|
+
}
|
|
51
|
+
|
|
42
52
|
export function aggregate(results, ks = DEFAULT_KS) {
|
|
43
53
|
const n = results.length || 1;
|
|
44
54
|
const recall = {};
|
|
@@ -46,16 +56,42 @@ export function aggregate(results, ks = DEFAULT_KS) {
|
|
|
46
56
|
recall[`@${k}`] = results.reduce((acc, r) => acc + (r.recall[`@${k}`] || 0), 0) / n;
|
|
47
57
|
}
|
|
48
58
|
const mrr = results.reduce((acc, r) => acc + (r.rr || 0), 0) / n;
|
|
49
|
-
|
|
59
|
+
const latency = latencyOf(results);
|
|
60
|
+
return { n: results.length, recall, mrr, ...(latency ? { latency } : {}) };
|
|
50
61
|
}
|
|
51
62
|
|
|
52
|
-
export async function runEval(queries, runQuery, { topK = 10, ks = DEFAULT_KS } = {}) {
|
|
63
|
+
export async function runEval(queries, runQuery, { topK = 10, ks = DEFAULT_KS, now = () => performance.now() } = {}) {
|
|
53
64
|
const results = [];
|
|
54
65
|
for (const q of queries) {
|
|
66
|
+
const started = now();
|
|
55
67
|
const hits = (await runQuery(q.query, topK)) || [];
|
|
68
|
+
const ms = now() - started;
|
|
56
69
|
const expected = { sources: q.expected_sources || [], symbols: q.expected_symbols || [] };
|
|
57
70
|
const scored = scoreQuery(hits, expected, ks);
|
|
58
|
-
results.push({ query: q.query, hits, ...scored });
|
|
71
|
+
results.push({ query: q.query, hits, ms, ...scored });
|
|
59
72
|
}
|
|
60
73
|
return { results, aggregate: aggregate(results, ks) };
|
|
61
74
|
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* KJC-TSK-1170: the current aggregate against a frozen one. Recall and MRR may not drop
|
|
78
|
+
* more than `tolerance`; p95 latency may not exceed the baseline's by `latencyFactor`,
|
|
79
|
+
* and is skipped when the baseline recorded none. Pure: the caller reads the files.
|
|
80
|
+
* @returns {{ok: boolean, problems: string[]}}
|
|
81
|
+
*/
|
|
82
|
+
export function compareToBaseline(current, baseline, { tolerance = 0, latencyFactor = 2, latencyFloorMs = 50 } = {}) {
|
|
83
|
+
const problems = [];
|
|
84
|
+
for (const k of Object.keys(baseline.recall ?? {})) {
|
|
85
|
+
const now = current.recall?.[k] ?? 0;
|
|
86
|
+
if (now < baseline.recall[k] - tolerance) problems.push(`recall${k} ${now.toFixed(3)} < ${baseline.recall[k].toFixed(3)}`);
|
|
87
|
+
}
|
|
88
|
+
if ((current.mrr ?? 0) < (baseline.mrr ?? 0) - tolerance) problems.push(`MRR ${(current.mrr ?? 0).toFixed(3)} < ${baseline.mrr.toFixed(3)}`);
|
|
89
|
+
const baseP95 = baseline.latency?.p95Ms;
|
|
90
|
+
if (Number.isFinite(baseP95) && Number.isFinite(current.latency?.p95Ms)) {
|
|
91
|
+
// KJC-BUG-0394: the factor alone turns a 2 ms baseline into a 4 ms limit, which machine
|
|
92
|
+
// noise crosses; the floor keeps a tiny baseline from failing on nothing.
|
|
93
|
+
const limit = Math.max(Math.round(baseP95 * latencyFactor), baseP95 + latencyFloorMs);
|
|
94
|
+
if (current.latency.p95Ms > limit) problems.push(`p95 ${current.latency.p95Ms} ms > ${limit} ms (${latencyFactor}x the baseline's ${baseP95} ms, or ${latencyFloorMs} ms over it)`);
|
|
95
|
+
}
|
|
96
|
+
return { ok: problems.length === 0, problems };
|
|
97
|
+
}
|
package/src/rag/indexer.js
CHANGED
|
@@ -14,6 +14,9 @@ import { chunkMarkdown, chunkPlan, chunkSource } from "./chunker.js";
|
|
|
14
14
|
import { insertChunk, deleteChunksBySource, findChunkByHash, getEmbeddingsByIds } from "./vec-store.js";
|
|
15
15
|
import { detectAdaptersForProject, getAllCodeExtensions } from "../lang/registry.js";
|
|
16
16
|
import { indexableReason, listProjectFiles } from "./indexable.js";
|
|
17
|
+
import { EMBED_SCHEMA, embedText } from "./embed-header.js";
|
|
18
|
+
import { writeEmbedSchema } from "./embed-schema.js";
|
|
19
|
+
import { untrackedFiles } from "./worktree-files.js";
|
|
17
20
|
|
|
18
21
|
// KJC-PCS-0052 PR-A — antes vivían dos consts hard-coded para JS aquí
|
|
19
22
|
// (CODE_EXT_RE + SKIP_SEGMENTS). Ahora vienen del registry de adapters,
|
|
@@ -59,7 +62,7 @@ export const sourceKind = () => "code";
|
|
|
59
62
|
* fresh batch is written, so a second call against the same file
|
|
60
63
|
* leaves the store with exactly the latest chunks (no duplicates).
|
|
61
64
|
*/
|
|
62
|
-
export async function indexFile(path, { db, embedder, logger = console, project = null, kind: declaredKind = null } = {}) {
|
|
65
|
+
export async function indexFile(path, { db, embedder, logger = console, project = null, kind: declaredKind = null, root = process.cwd() } = {}) {
|
|
63
66
|
if (!existsSync(path)) { logger.warn?.(`[rag-indexer] file not found: ${path}`); return { indexed: 0, failed: 0 }; }
|
|
64
67
|
const kind = declaredKind ?? detectKind(path);
|
|
65
68
|
if (!kind) { logger.warn?.(`[rag-indexer] unknown kind: ${path}`); return { indexed: 0, failed: 0 }; }
|
|
@@ -75,11 +78,17 @@ export async function indexFile(path, { db, embedder, logger = console, project
|
|
|
75
78
|
// the stored vector: a file whose every chunk duplicates another (CLAUDE.md
|
|
76
79
|
// next to the AGENTS.md kj harden writes) was left with no rows at all, and
|
|
77
80
|
// the coverage check reported it missing after every reindex.
|
|
78
|
-
|
|
81
|
+
// KJC-TSK-1171 (RAG-B): the embedder reads the metadata header before the body, so a
|
|
82
|
+
// query by file, module or symbol name lands; the stored text stays the body alone.
|
|
83
|
+
// The dedup key is what the embedder read, header included, under the current schema: a
|
|
84
|
+
// vector is reused only when the same body sits under the same path, symbol and lines,
|
|
85
|
+
// never one embedded under another header or an older schema.
|
|
86
|
+
const embeddable = embedText(ch, { root });
|
|
87
|
+
const contentHash = createHash("sha256").update(`${EMBED_SCHEMA}\n${embeddable}`).digest("hex");
|
|
79
88
|
try {
|
|
80
89
|
const dup = findChunkByHash(db, contentHash, project);
|
|
81
90
|
const stored = dup ? getEmbeddingsByIds(db, [dup.id]).get(Number(dup.id)) : null;
|
|
82
|
-
const embedding = stored ?? await embedder.embed(
|
|
91
|
+
const embedding = stored ?? await embedder.embed(embeddable);
|
|
83
92
|
insertChunk(db, { source: path, kind: ch.metadata.kind || kind, text: ch.text, metadata: ch.metadata, embedding, project, contentHash });
|
|
84
93
|
if (stored) skipped += 1; else indexed += 1;
|
|
85
94
|
} catch (err) {
|
|
@@ -145,13 +154,13 @@ export async function indexProject(projectDir, { db, embedder, karajanHome, logg
|
|
|
145
154
|
if (existsSync(planRoot)) {
|
|
146
155
|
const plans = await listFiles(planRoot, (p) => /plan-.*\.json$/.test(p));
|
|
147
156
|
for (const p of plans) {
|
|
148
|
-
const r = await indexFile(p, { db, embedder, logger, project: slug });
|
|
157
|
+
const r = await indexFile(p, { db, embedder, logger, project: slug, root: projectDir });
|
|
149
158
|
totals.indexed += r.indexed; totals.failed += r.failed; totals.files += 1;
|
|
150
159
|
}
|
|
151
160
|
}
|
|
152
161
|
const onboarding = join(karajanHome, ONBOARDING_DIR, `${slug}.md`);
|
|
153
162
|
if (existsSync(onboarding)) {
|
|
154
|
-
const r = await indexFile(onboarding, { db, embedder, logger, project: slug });
|
|
163
|
+
const r = await indexFile(onboarding, { db, embedder, logger, project: slug, root: projectDir });
|
|
155
164
|
totals.indexed += r.indexed; totals.failed += r.failed; totals.files += 1;
|
|
156
165
|
}
|
|
157
166
|
if (withSources) {
|
|
@@ -173,9 +182,13 @@ export async function indexProject(projectDir, { db, embedder, karajanHome, logg
|
|
|
173
182
|
if (source.startsWith(prefix) && outOf(source) !== null) deleteChunksBySource(db, source);
|
|
174
183
|
}
|
|
175
184
|
for (const s of sources) {
|
|
176
|
-
const r = await indexFile(s, { db, embedder, logger, project: slug, kind: sourceKind() });
|
|
185
|
+
const r = await indexFile(s, { db, embedder, logger, project: slug, kind: sourceKind(), root: projectDir });
|
|
177
186
|
totals.indexed += r.indexed; totals.failed += r.failed; totals.files += 1;
|
|
178
187
|
}
|
|
188
|
+
// KJC-TSK-1171: only a COMPLETE index of the sources carries the embed schema; a file the
|
|
189
|
+
// embedder failed on leaves the marker as it was, so the rebuild is asked for again.
|
|
190
|
+
if (totals.failed === 0) writeEmbedSchema(projectDir);
|
|
191
|
+
else logger.warn?.(`[rag-indexer] ${totals.failed} chunk(s) failed: the embed schema is not recorded, the index will be rebuilt again`);
|
|
179
192
|
}
|
|
180
193
|
return totals;
|
|
181
194
|
}
|
|
@@ -187,21 +200,30 @@ export async function indexProject(projectDir, { db, embedder, karajanHome, logg
|
|
|
187
200
|
* `head` lets callers persist via setLastIndexedCommit. Throws on diff failure
|
|
188
201
|
* (shallow clone, unknown ref) so the CLI can fall back to a full reindex.
|
|
189
202
|
*/
|
|
190
|
-
export async function indexProjectDelta(projectDir, { db, embedder, since, logger = console, exclude = [] } = {}) {
|
|
203
|
+
export async function indexProjectDelta(projectDir, { db, embedder, since, logger = console, exclude = [], worktree = false, reconcile = [] } = {}) {
|
|
191
204
|
const totals = { indexed: 0, failed: 0, files: 0, deleted: 0, head: null };
|
|
192
205
|
const slug = projectDir.split("/").pop()?.replace(/[^a-zA-Z0-9._-]/g, "-").toLowerCase() || "project";
|
|
193
206
|
const { stdout: head } = await execa("git", ["-C", projectDir, "rev-parse", "HEAD"]);
|
|
194
207
|
totals.head = head.trim();
|
|
195
|
-
if (totals.head === since) return totals;
|
|
196
|
-
|
|
208
|
+
if (totals.head === since && !worktree) return totals;
|
|
209
|
+
// KJC-TSK-1174 (RAG-A1): with `worktree`, the diff ends at the files on disk instead of HEAD,
|
|
210
|
+
// and untracked files join it, so what is being written is retrievable before it is committed.
|
|
211
|
+
const { stdout } = await execa("git", ["-C", projectDir, "diff", "--name-status", "-z", since, ...(worktree ? [] : ["HEAD"])]);
|
|
197
212
|
const parts = stdout.split("\0").filter(Boolean);
|
|
198
213
|
const ops = []; // { kind: 'del'|'idx', p }
|
|
199
|
-
|
|
214
|
+
let i = 0;
|
|
215
|
+
while (i < parts.length) {
|
|
200
216
|
const s = parts[i];
|
|
201
|
-
if (s.startsWith("R")) { ops.push({ kind: "del", p: parts[
|
|
202
|
-
else if (s
|
|
203
|
-
else { ops.push({ kind: "
|
|
217
|
+
if (s.startsWith("R")) { ops.push({ kind: "del", p: parts[i + 1] }, { kind: "idx", p: parts[i + 2] }); i += 3; }
|
|
218
|
+
else if (s.startsWith("C")) { ops.push({ kind: "idx", p: parts[i + 2] }); i += 3; } // a copy keeps its source
|
|
219
|
+
else if (s === "D") { ops.push({ kind: "del", p: parts[i + 1] }); i += 2; }
|
|
220
|
+
else { ops.push({ kind: "idx", p: parts[i + 1] }); i += 2; }
|
|
204
221
|
}
|
|
222
|
+
if (worktree) for (const p of await untrackedFiles(projectDir)) ops.push({ kind: "idx", p });
|
|
223
|
+
// Paths a previous refresh took from a dirty tree: on disk they are reindexed as they are now
|
|
224
|
+
// (reverted or still dirty), gone they are dropped. Each path is handled once.
|
|
225
|
+
const seen = new Set(ops.map((o) => o.p));
|
|
226
|
+
for (const p of reconcile) if (!seen.has(p)) ops.push({ kind: existsSync(join(projectDir, p)) ? "idx" : "del", p });
|
|
205
227
|
const adapters = detectAdaptersForProject(projectDir);
|
|
206
228
|
await prepareAdapters(adapters, { logger });
|
|
207
229
|
for (const { kind, p } of ops) {
|
|
@@ -211,7 +233,7 @@ export async function indexProjectDelta(projectDir, { db, embedder, since, logge
|
|
|
211
233
|
logger.info?.(`[rag-indexer] delta delete ${relative(process.cwd(), abs)}`);
|
|
212
234
|
} else if (indexableReason(relative(projectDir, abs), abs, { exclude, projectDir }) === null) {
|
|
213
235
|
// KJC-TSK-0891: the same criterion as the full walk, not a list of extensions.
|
|
214
|
-
const r = await indexFile(abs, { db, embedder, logger, project: slug, kind: sourceKind() });
|
|
236
|
+
const r = await indexFile(abs, { db, embedder, logger, project: slug, kind: sourceKind(), root: projectDir });
|
|
215
237
|
totals.indexed += r.indexed; totals.failed += r.failed; totals.files += 1;
|
|
216
238
|
}
|
|
217
239
|
}
|