karajan-code 4.62.0 → 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 +112 -13
- package/src/commands/rag.js +34 -8
- package/src/hu/github-board.js +27 -5
- 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-java.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// sin prefijar (coherente con chunk-go), el RAG ya lleva contexto del fichero.
|
|
5
5
|
|
|
6
6
|
import { getLoaded, loadLanguage } from "./tree-sitter-loader.js";
|
|
7
|
+
import { spanChunks } from "./chunk-span.js";
|
|
7
8
|
|
|
8
9
|
const DEFAULT_LIMIT = 800;
|
|
9
10
|
const DEFAULT_OVERLAP = 100;
|
|
@@ -23,17 +24,6 @@ export async function prepareJava() {
|
|
|
23
24
|
return loadLanguage("java");
|
|
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
|
-
|
|
37
27
|
function nameOf(node) {
|
|
38
28
|
const n = node.childForFieldName("name");
|
|
39
29
|
return n ? n.text : null;
|
|
@@ -47,14 +37,15 @@ function marksViaAST(text) {
|
|
|
47
37
|
const marks = [];
|
|
48
38
|
for (const child of tree.rootNode.namedChildren) {
|
|
49
39
|
if (!CONTAINER_KINDS.has(child.type)) continue;
|
|
50
|
-
const
|
|
51
|
-
if (
|
|
40
|
+
const className = nameOf(child);
|
|
41
|
+
if (className) marks.push({ idx: child.startIndex, symbol: className });
|
|
52
42
|
const body = child.childForFieldName("body");
|
|
53
43
|
if (!body) continue;
|
|
54
44
|
for (const m of body.namedChildren) {
|
|
55
45
|
if (!MEMBER_KINDS.has(m.type)) continue;
|
|
56
|
-
const
|
|
57
|
-
|
|
46
|
+
const memberName = nameOf(m);
|
|
47
|
+
// KJC-TSK-1171: a member knows its class (by name); the chunk says both.
|
|
48
|
+
if (memberName) marks.push({ idx: m.startIndex, symbol: memberName, parent: className });
|
|
58
49
|
}
|
|
59
50
|
}
|
|
60
51
|
marks.sort((a, b) => a.idx - b.idx);
|
|
@@ -70,21 +61,5 @@ function marksViaRegex(text) {
|
|
|
70
61
|
}
|
|
71
62
|
|
|
72
63
|
export function chunkJava(text, { path = "<src>", limit = DEFAULT_LIMIT, overlap = DEFAULT_OVERLAP } = {}) {
|
|
73
|
-
|
|
74
|
-
if (marks.length === 0) {
|
|
75
|
-
return windowText(text, limit, overlap).map((piece) => ({
|
|
76
|
-
text: piece,
|
|
77
|
-
metadata: { source: path, kind: "code", symbol: null, language: "java" },
|
|
78
|
-
}));
|
|
79
|
-
}
|
|
80
|
-
const out = [];
|
|
81
|
-
for (let i = 0; i < marks.length; i += 1) {
|
|
82
|
-
const start = marks[i].idx;
|
|
83
|
-
const end = i + 1 < marks.length ? marks[i + 1].idx : text.length;
|
|
84
|
-
const body = text.slice(start, end);
|
|
85
|
-
for (const piece of windowText(body, limit, overlap)) {
|
|
86
|
-
out.push({ text: piece, metadata: { source: path, kind: "code", symbol: marks[i].symbol, language: "java" } });
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
return out;
|
|
64
|
+
return spanChunks(text, marksViaAST(text) ?? marksViaRegex(text), { path, language: "java", limit, overlap });
|
|
90
65
|
}
|
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
|
+
}
|