karajan-code 4.35.1 → 4.36.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 +1 -1
- package/packages/hu-board/public/utils/board-view.js +2 -1
- package/packages/hu-board/public/utils/config-editor.js +5 -2
- package/packages/hu-board/public/utils/terminal-panel.js +2 -1
- package/packages/hu-board/src/config-yaml.js +11 -9
- package/packages/hu-board/src/project-dir.js +42 -0
- package/packages/hu-board/src/routes/api.js +11 -3
- package/packages/hu-board/src/terminal-wire.js +5 -1
- package/packages/hu-board/src/terminal.js +8 -3
- package/src/checks/rag-coverage.js +5 -4
- package/src/cli/register-meta.js +15 -4
- package/src/commands/board.js +20 -8
- package/src/commands/bootstrap.js +2 -0
- package/src/commands/env.js +8 -4
- package/src/commands/harden.js +26 -4
- package/src/commands/init.js +2 -1
- package/src/commands/plan/generate.js +4 -6
- package/src/commands/rag.js +69 -10
- package/src/commands/review-gate.js +14 -5
- package/src/commands/watch.js +13 -10
- package/src/config/defaults.js +3 -2
- package/src/environment/playbook.js +10 -3
- package/src/harden/guidelines-engine.js +24 -7
- package/src/harden/sentinel-hooks.js +68 -7
- package/src/lang/registry.js +2 -1
- package/src/mcp/handlers/rag-handler.js +14 -4
- package/src/orchestrator/drivers/post-loop.js +13 -11
- package/src/orchestrator/drivers/pre-loop-phases/auto-hu-batch.js +3 -2
- package/src/orchestrator/stages/rag-context-stage.js +5 -2
- package/src/rag/auto-update.js +5 -3
- package/src/rag/coverage.js +13 -12
- package/src/rag/indexable.js +97 -0
- package/src/rag/indexer.js +31 -13
- package/src/rag/library.js +19 -13
- package/src/rag/migrate.js +73 -0
- package/src/rag/onnx-fallback.js +5 -3
- package/src/rag/project-store.js +48 -0
- package/src/rag/watcher.js +25 -14
- package/src/review/kj-provenance.js +26 -0
- package/src/review/verdict-store.js +6 -1
- package/src/start/sweep.js +4 -3
- package/src/utils/rulesync.js +61 -0
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
// Failures degrade gracefully (warn + continue); the rest of the pipeline
|
|
6
6
|
// never sees an exception from this stage.
|
|
7
7
|
import { emitProgress, makeEvent } from "../../utils/events.js";
|
|
8
|
-
import {
|
|
8
|
+
import { countChunks } from "../../rag/vec-store.js";
|
|
9
9
|
import { makeGovernedEmbedder } from "../../rag/governed-embedder.js";
|
|
10
10
|
import { query } from "../../rag/retriever.js";
|
|
11
|
+
import { openProjectStore } from "../../rag/project-store.js";
|
|
11
12
|
|
|
12
13
|
const DEFAULT_TOP_K = 5;
|
|
13
14
|
const DEFAULT_SCOPE = "all";
|
|
@@ -44,7 +45,9 @@ export async function runRagContextStage({ config, logger, emitter, eventBase, t
|
|
|
44
45
|
if (typeof task !== "string" || task.length === 0) return { skipped: true, reason: "no-task" };
|
|
45
46
|
try {
|
|
46
47
|
const dim = config?.rag?.embedder?.dim || 768;
|
|
47
|
-
|
|
48
|
+
// KJC-TSK-0882: el preload que entra en el prompt del coder solo puede traer
|
|
49
|
+
// codigo de ESTE proyecto: antes abria el indice de la maquina sin filtrar.
|
|
50
|
+
const db = openProjectStore({ projectDir: config?.projectDir || process.cwd(), dim });
|
|
48
51
|
try {
|
|
49
52
|
if (countChunks(db) === 0) {
|
|
50
53
|
logger?.info?.("[rag-preload] corpus is empty, skipping (run `kj rag index` to seed)");
|
package/src/rag/auto-update.js
CHANGED
|
@@ -6,9 +6,11 @@ import { isAbsolute, join, resolve } from "node:path";
|
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { execa } from "execa";
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import { projectSlug, getLastIndexedCommit, setLastIndexedCommit } from "./vec-store.js";
|
|
10
10
|
import { indexProjectDelta } from "./indexer.js";
|
|
11
|
+
import { ragExclude } from "./indexable.js";
|
|
11
12
|
import { makeGovernedEmbedder } from "./governed-embedder.js";
|
|
13
|
+
import { openProjectStore } from "./project-store.js";
|
|
12
14
|
|
|
13
15
|
const HOOK_SRC = resolve(fileURLToPath(import.meta.url), "../../../scripts/git-hooks/post-merge");
|
|
14
16
|
|
|
@@ -20,12 +22,12 @@ export async function maybeAutoUpdate({ projectDir, config, logger = console, fl
|
|
|
20
22
|
try { const r = await execa("git", ["-C", projectDir, "rev-parse", "HEAD"]); head = r.stdout.trim(); }
|
|
21
23
|
catch { return { skipped: true }; }
|
|
22
24
|
const slug = projectSlug(projectDir);
|
|
23
|
-
const db =
|
|
25
|
+
const db = openProjectStore({ projectDir, dim: config?.rag?.embedder?.dim || 768 });
|
|
24
26
|
try {
|
|
25
27
|
const since = getLastIndexedCommit(db, slug);
|
|
26
28
|
if (!since || since === head) return { skipped: true, head };
|
|
27
29
|
logger.info?.(`[rag] drift detected (${since.slice(0, 7)} → ${head.slice(0, 7)}); running delta update`);
|
|
28
|
-
const totals = await indexProjectDelta(projectDir, { db, embedder: makeGovernedEmbedder(config), since, logger });
|
|
30
|
+
const totals = await indexProjectDelta(projectDir, { db, embedder: makeGovernedEmbedder(config), since, logger, exclude: ragExclude(config) });
|
|
29
31
|
if (totals.head) setLastIndexedCommit(db, slug, totals.head);
|
|
30
32
|
return { ran: true, totals };
|
|
31
33
|
} catch (err) {
|
package/src/rag/coverage.js
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* matchers are the single truth, here and in the indexer.
|
|
9
9
|
*/
|
|
10
10
|
import { execa } from "execa";
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
11
|
+
import { isAbsolute, join, normalize, relative } from "node:path";
|
|
12
|
+
import { indexableReason } from "./indexable.js";
|
|
13
13
|
import { GENERATED_CONFIG_FILES } from "../harden/config-templates.js";
|
|
14
14
|
import { getLastIndexedCommit, projectSlug } from "./vec-store.js";
|
|
15
15
|
|
|
@@ -36,11 +36,11 @@ export function isGeneratedByHarden(rel) {
|
|
|
36
36
|
* @param {{db: object}} deps - an open vec store
|
|
37
37
|
* @returns {Promise<{project: string, total: number, indexed: number, missing: string[], stale: string[], lastIndexedCommit: string|null, absent: boolean}>}
|
|
38
38
|
*/
|
|
39
|
-
export async function ragIndexCoverage(projectDir, { db }) {
|
|
39
|
+
export async function ragIndexCoverage(projectDir, { db, exclude = [] }) {
|
|
40
40
|
const project = projectSlug(projectDir);
|
|
41
|
-
const matchers = buildMatchers(detectAdaptersForProject(projectDir));
|
|
42
41
|
const { stdout } = await execa("git", ["-C", projectDir, "ls-files", "-z"]);
|
|
43
|
-
|
|
42
|
+
// KJC-TSK-0891: the indexer's own criterion, not a list of extensions.
|
|
43
|
+
const all = stdout.split("\0").filter((p) => p && indexableReason(p, join(projectDir, p), { exclude, projectDir }) === null);
|
|
44
44
|
// Lo que kj genera se informa aparte: ni se exige indexado ni se esconde.
|
|
45
45
|
const generated = all.filter(isGeneratedByHarden);
|
|
46
46
|
const sources = all.filter((p) => !isGeneratedByHarden(p));
|
|
@@ -82,20 +82,21 @@ export async function ragIndexCoverage(projectDir, { db }) {
|
|
|
82
82
|
*
|
|
83
83
|
* @returns {{state: "indexed"|"stale"|"not-indexable"|"index-empty", rel: string, canAnswer: boolean, reason: string}}
|
|
84
84
|
*/
|
|
85
|
-
export function fileIndexState(projectDir, file, { db }) {
|
|
85
|
+
export function fileIndexState(projectDir, file, { db, exclude = [] }) {
|
|
86
86
|
const project = projectSlug(projectDir);
|
|
87
87
|
// `./src/a.js` y `src/a.js` son el mismo fichero: sin normalizar, el segundo
|
|
88
88
|
// salia "stale" estando indexado (catch de la review).
|
|
89
89
|
const rel = normalize(isAbsolute(file) ? relative(projectDir, file) : file);
|
|
90
|
-
const matchers = buildMatchers(detectAdaptersForProject(projectDir));
|
|
91
90
|
const rows = db.prepare("SELECT DISTINCT source FROM chunks WHERE project_slug = ?").all(project);
|
|
92
91
|
const indexed = new Set(rows.map((r) => normalize(isAbsolute(r.source) ? relative(projectDir, r.source) : r.source)));
|
|
93
92
|
|
|
94
93
|
if (indexed.has(rel)) return { state: "indexed", rel, canAnswer: true, reason: `the index holds chunks for ${rel}` };
|
|
95
94
|
if (indexed.size === 0) return { state: "index-empty", rel, canAnswer: false, reason: `nothing of this project is indexed yet: kj rag index` };
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
100
|
-
|
|
95
|
+
// KJC-TSK-0891: not-indexable only for the file's NATURE (binary, generated,
|
|
96
|
+
// huge, excluded, skipped path), never for its language.
|
|
97
|
+
const out = indexableReason(rel, join(projectDir, rel), { exclude, projectDir });
|
|
98
|
+
if (out) return { state: "not-indexable", rel, canAnswer: false, reason: out };
|
|
99
|
+
// KJC-TSK-0891: `--since auto` only reaches what changed, so it cannot fix an
|
|
100
|
+
// unchanged file the index never held; indexing THIS file does.
|
|
101
|
+
return { state: "stale", rel, canAnswer: false, reason: `${rel} is indexable but absent from the index: kj rag index --file ${rel}` };
|
|
101
102
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-0891 (#1807 point 4) — what the index takes, decided without a list
|
|
3
|
+
* of extensions: there are countless languages, and a list always falls short.
|
|
4
|
+
* Any text file the project versions is indexable. What stays out stays out for
|
|
5
|
+
* its NATURE, never its language: binary, generated, too large, excluded by the
|
|
6
|
+
* project, or under a path the indexer always skips. Where a language adapter
|
|
7
|
+
* exists the chunker splits by symbol; elsewhere it falls back to overlapping
|
|
8
|
+
* text windows (chunkSource already does).
|
|
9
|
+
*/
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
import { closeSync, lstatSync, openSync, readSync } from "node:fs";
|
|
12
|
+
|
|
13
|
+
import { matchesAny } from "@karajan-family/governance";
|
|
14
|
+
|
|
15
|
+
import { buildMatchers, detectAdaptersForProject } from "../lang/registry.js";
|
|
16
|
+
|
|
17
|
+
const MAX_BYTES = 512 * 1024;
|
|
18
|
+
const SNIFF_BYTES = 8192;
|
|
19
|
+
// Generated by tools, not written by the team: noise in any language.
|
|
20
|
+
const GENERATED = [
|
|
21
|
+
/(^|\/)(package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?|Cargo\.lock|poetry\.lock|Pipfile\.lock|uv\.lock|composer\.lock|Gemfile\.lock|go\.sum)$/,
|
|
22
|
+
/\.min\.(js|css)$/,
|
|
23
|
+
/\.map$/,
|
|
24
|
+
/\.snap$/,
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
// May hold secrets: never sent to an embedder, which can be a remote service.
|
|
28
|
+
const SENSITIVE = [
|
|
29
|
+
/(^|\/)\.env(\.[^/]*)?$/,
|
|
30
|
+
/\.(pem|key|p12|pfx|jks|keystore)$/i,
|
|
31
|
+
/(^|\/)id_(rsa|dsa|ecdsa|ed25519)(\.pub)?$/,
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
// The skipped paths are the ones of THIS project's languages: Java's `bin/`
|
|
35
|
+
// is build output, a JS project's `bin/` is source. Cached per project.
|
|
36
|
+
const skipCache = new Map();
|
|
37
|
+
const skipsFor = (projectDir) => {
|
|
38
|
+
const key = projectDir ?? "";
|
|
39
|
+
if (!skipCache.has(key)) skipCache.set(key, buildMatchers(detectAdaptersForProject(projectDir ?? process.cwd())).shouldSkip);
|
|
40
|
+
return skipCache.get(key);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** What the project keeps out of its index: `rag.exclude`, repo-relative globs. */
|
|
44
|
+
export const ragExclude = (config) => (Array.isArray(config?.rag?.exclude) ? config.rag.exclude : []);
|
|
45
|
+
|
|
46
|
+
function looksBinary(abs) {
|
|
47
|
+
const fd = openSync(abs, "r");
|
|
48
|
+
try {
|
|
49
|
+
const buf = Buffer.alloc(SNIFF_BYTES);
|
|
50
|
+
const n = readSync(fd, buf, 0, SNIFF_BYTES, 0);
|
|
51
|
+
return buf.subarray(0, n).includes(0);
|
|
52
|
+
} finally {
|
|
53
|
+
closeSync(fd);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {string} rel path relative to the project root
|
|
59
|
+
* @param {string} abs absolute path
|
|
60
|
+
* @param {{exclude?: string[], skip?: (rel: string) => boolean}} [opts]
|
|
61
|
+
* @returns {string|null} why it stays out of the index, or null if it goes in
|
|
62
|
+
*/
|
|
63
|
+
export function indexableReason(rel, abs, { exclude = [], projectDir = null, skip = skipsFor(projectDir) } = {}) {
|
|
64
|
+
if (skip(rel)) return `${rel} lives under a path the indexer always skips`;
|
|
65
|
+
if (matchesAny(rel, exclude)) return `${rel} is excluded by the project's rag.exclude`;
|
|
66
|
+
if (SENSITIVE.some((re) => re.test(rel))) return `${rel} may hold secrets, and the embedder can be a remote service`;
|
|
67
|
+
if (GENERATED.some((re) => re.test(rel))) return `${rel} is a generated file (lockfile, minified, sourcemap or snapshot)`;
|
|
68
|
+
let st;
|
|
69
|
+
try { st = lstatSync(abs); } catch { return `${rel} does not exist`; }
|
|
70
|
+
// Never followed: a versioned symlink could point the embedder at any
|
|
71
|
+
// readable file outside the project, secrets included.
|
|
72
|
+
if (st.isSymbolicLink()) return `${rel} is a symbolic link`;
|
|
73
|
+
if (!st.isFile()) return `${rel} is not a regular file`;
|
|
74
|
+
if (st.size > MAX_BYTES) return `${rel} is larger than ${MAX_BYTES / 1024} KB`;
|
|
75
|
+
try {
|
|
76
|
+
if (looksBinary(abs)) return `${rel} is binary`;
|
|
77
|
+
} catch {
|
|
78
|
+
return `${rel} could not be read`;
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The project's files as git sees them: versioned, plus new ones git does not
|
|
85
|
+
* ignore. Null outside a git repo, so the caller can fall back to a walk.
|
|
86
|
+
* @returns {string[]|null} paths relative to projectDir
|
|
87
|
+
*/
|
|
88
|
+
export function listProjectFiles(projectDir) {
|
|
89
|
+
try {
|
|
90
|
+
const out = execFileSync("git", ["-C", projectDir, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
|
|
91
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 64 * 1024 * 1024,
|
|
92
|
+
});
|
|
93
|
+
return [...new Set(out.split("\0").filter(Boolean))];
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
package/src/rag/indexer.js
CHANGED
|
@@ -12,7 +12,8 @@ import { execa } from "execa";
|
|
|
12
12
|
|
|
13
13
|
import { chunkMarkdown, chunkPlan, chunkSource } from "./chunker.js";
|
|
14
14
|
import { insertChunk, deleteChunksBySource, findChunkByHash } from "./vec-store.js";
|
|
15
|
-
import { detectAdaptersForProject,
|
|
15
|
+
import { detectAdaptersForProject, getAllCodeExtensions } from "../lang/registry.js";
|
|
16
|
+
import { indexableReason, listProjectFiles } from "./indexable.js";
|
|
16
17
|
|
|
17
18
|
// KJC-PCS-0052 PR-A — antes vivían dos consts hard-coded para JS aquí
|
|
18
19
|
// (CODE_EXT_RE + SKIP_SEGMENTS). Ahora vienen del registry de adapters,
|
|
@@ -39,19 +40,28 @@ function chunksFor(path, kind) {
|
|
|
39
40
|
if (kind === "plan" || kind === "onboarding") {
|
|
40
41
|
return chunkMarkdown(text, { path, kind });
|
|
41
42
|
}
|
|
43
|
+
// KJC-TSK-0891: a project's own prose is still a project source ("code":
|
|
44
|
+
// the store's schema allows no other kind), split by headings.
|
|
45
|
+
if (kind === "code" && /\.mdx?$/i.test(path)) return chunkMarkdown(text, { path, kind });
|
|
42
46
|
if (kind === "code") return chunkSource(text, { path });
|
|
43
47
|
return [];
|
|
44
48
|
}
|
|
45
49
|
|
|
50
|
+
/**
|
|
51
|
+
* KJC-TSK-0891: every project source is indexed as "code". Where an adapter
|
|
52
|
+
* exists it splits by symbol; any other text falls back to overlapping windows.
|
|
53
|
+
*/
|
|
54
|
+
export const sourceKind = () => "code";
|
|
55
|
+
|
|
46
56
|
/**
|
|
47
57
|
* Index a single file. Returns `{ indexed, failed }`. Idempotent —
|
|
48
58
|
* pre-existing chunks for this `source` path are deleted before the
|
|
49
59
|
* fresh batch is written, so a second call against the same file
|
|
50
60
|
* leaves the store with exactly the latest chunks (no duplicates).
|
|
51
61
|
*/
|
|
52
|
-
export async function indexFile(path, { db, embedder, logger = console, project = null } = {}) {
|
|
62
|
+
export async function indexFile(path, { db, embedder, logger = console, project = null, kind: declaredKind = null } = {}) {
|
|
53
63
|
if (!existsSync(path)) { logger.warn?.(`[rag-indexer] file not found: ${path}`); return { indexed: 0, failed: 0 }; }
|
|
54
|
-
const kind = detectKind(path);
|
|
64
|
+
const kind = declaredKind ?? detectKind(path);
|
|
55
65
|
if (!kind) { logger.warn?.(`[rag-indexer] unknown kind: ${path}`); return { indexed: 0, failed: 0 }; }
|
|
56
66
|
const chunks = chunksFor(path, kind);
|
|
57
67
|
if (!chunks.length) return { indexed: 0, failed: 0, skipped: 0 };
|
|
@@ -118,7 +128,7 @@ async function listFiles(dir, predicate) {
|
|
|
118
128
|
* indexing tens of thousands of files is expensive — the CLI exposes
|
|
119
129
|
* it as `--with-sources`.
|
|
120
130
|
*/
|
|
121
|
-
export async function indexProject(projectDir, { db, embedder, karajanHome, logger = console, withSources = false } = {}) {
|
|
131
|
+
export async function indexProject(projectDir, { db, embedder, karajanHome, logger = console, withSources = false, exclude = [] } = {}) {
|
|
122
132
|
// KJC-TSK-0640: a full index must stamp HEAD too — without it,
|
|
123
133
|
// last_indexed_commit stayed null after the FIRST index, so the drift
|
|
124
134
|
// delta-update (maybeAutoUpdate) never engaged until a manual --since.
|
|
@@ -148,10 +158,20 @@ export async function indexProject(projectDir, { db, embedder, karajanHome, logg
|
|
|
148
158
|
// duplicate every chunk and contaminate retrieval scores. `_diet` is the
|
|
149
159
|
// tests/_diet/ sandbox used by the test-diet audit harness — never user
|
|
150
160
|
// code. Both viven en COMMON_SKIP_SEGMENTS dentro del registry.
|
|
151
|
-
|
|
152
|
-
|
|
161
|
+
// KJC-BUG-0231: las exclusiones se juzgan DENTRO del proyecto (ruta relativa).
|
|
162
|
+
// KJC-TSK-0891: sin lista de extensiones: cualquier texto que el proyecto
|
|
163
|
+
// versiona, fuera lo binario, generado, enorme o excluido (indexable.js).
|
|
164
|
+
const outOf = (abs) => indexableReason(relative(projectDir, abs), abs, { exclude, projectDir });
|
|
165
|
+
const listed = listProjectFiles(projectDir);
|
|
166
|
+
const candidates = listed ? listed.map((rel) => join(projectDir, rel)) : await listFiles(projectDir, () => true);
|
|
167
|
+
const sources = candidates.filter((abs) => outOf(abs) === null);
|
|
168
|
+
// Lo que un indice anterior guardo y hoy ya no entra (excluido o borrado), fuera.
|
|
169
|
+
const prefix = `${projectDir}/`;
|
|
170
|
+
for (const { source } of db.prepare("SELECT DISTINCT source FROM chunks WHERE project_slug = ?").all(slug)) {
|
|
171
|
+
if (source.startsWith(prefix) && outOf(source) !== null) deleteChunksBySource(db, source);
|
|
172
|
+
}
|
|
153
173
|
for (const s of sources) {
|
|
154
|
-
const r = await indexFile(s, { db, embedder, logger, project: slug });
|
|
174
|
+
const r = await indexFile(s, { db, embedder, logger, project: slug, kind: sourceKind(s) });
|
|
155
175
|
totals.indexed += r.indexed; totals.failed += r.failed; totals.files += 1;
|
|
156
176
|
}
|
|
157
177
|
}
|
|
@@ -165,7 +185,7 @@ export async function indexProject(projectDir, { db, embedder, karajanHome, logg
|
|
|
165
185
|
* `head` lets callers persist via setLastIndexedCommit. Throws on diff failure
|
|
166
186
|
* (shallow clone, unknown ref) so the CLI can fall back to a full reindex.
|
|
167
187
|
*/
|
|
168
|
-
export async function indexProjectDelta(projectDir, { db, embedder, since, logger = console } = {}) {
|
|
188
|
+
export async function indexProjectDelta(projectDir, { db, embedder, since, logger = console, exclude = [] } = {}) {
|
|
169
189
|
const totals = { indexed: 0, failed: 0, files: 0, deleted: 0, head: null };
|
|
170
190
|
const slug = projectDir.split("/").pop()?.replace(/[^a-zA-Z0-9._-]/g, "-").toLowerCase() || "project";
|
|
171
191
|
const { stdout: head } = await execa("git", ["-C", projectDir, "rev-parse", "HEAD"]);
|
|
@@ -182,16 +202,14 @@ export async function indexProjectDelta(projectDir, { db, embedder, since, logge
|
|
|
182
202
|
}
|
|
183
203
|
const adapters = detectAdaptersForProject(projectDir);
|
|
184
204
|
await prepareAdapters(adapters, { logger });
|
|
185
|
-
const matchers = buildMatchers(adapters);
|
|
186
205
|
for (const { kind, p } of ops) {
|
|
187
|
-
if (matchers.shouldSkip(p)) continue;
|
|
188
|
-
if (!matchers.isCodeFile(p)) continue;
|
|
189
206
|
const abs = isAbsolute(p) ? p : join(projectDir, p);
|
|
190
207
|
if (kind === "del") {
|
|
191
208
|
totals.deleted += deleteChunksBySource(db, abs);
|
|
192
209
|
logger.info?.(`[rag-indexer] delta delete ${relative(process.cwd(), abs)}`);
|
|
193
|
-
} else if (
|
|
194
|
-
|
|
210
|
+
} else if (indexableReason(relative(projectDir, abs), abs, { exclude, projectDir }) === null) {
|
|
211
|
+
// KJC-TSK-0891: the same criterion as the full walk, not a list of extensions.
|
|
212
|
+
const r = await indexFile(abs, { db, embedder, logger, project: slug, kind: sourceKind(abs) });
|
|
195
213
|
totals.indexed += r.indexed; totals.failed += r.failed; totals.files += 1;
|
|
196
214
|
}
|
|
197
215
|
}
|
package/src/rag/library.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* library — the distilled engineering canon as its own RAG collection
|
|
3
|
-
* (KJC-TSK-0697).
|
|
4
|
-
* and
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* (KJC-TSK-0697). KJC-TSK-0883 (ADR 0011): the machine canon (`<pkg>/library/`
|
|
4
|
+
* and `~/.karajan/library/`) indexes into its own ~/.karajan/library.db under
|
|
5
|
+
* project="library", reached by `kj rag query --library`; a project's own cards
|
|
6
|
+
* (`<project>/.karajan/library/`) go to that project's index under its slug. The architect
|
|
7
7
|
* consults it to ground the greenfield alternative (KJC-TSK-0696).
|
|
8
8
|
*/
|
|
9
9
|
|
|
@@ -20,10 +20,16 @@ export const LIBRARY_PROJECT = "library";
|
|
|
20
20
|
|
|
21
21
|
const SHIPPED_LIBRARY_DIR = fileURLToPath(new URL("../../library", import.meta.url));
|
|
22
22
|
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Existing library dirs, shipped canon first. KJC-TSK-0883: `scope` "machine"
|
|
25
|
+
* is the common canon (kj's + the user's), "project" the project's own cards.
|
|
26
|
+
*/
|
|
27
|
+
export function libraryDirs({ pkgLibraryDir = SHIPPED_LIBRARY_DIR, home = os.homedir(), projectDir = process.cwd(), scope = "all" } = {}) {
|
|
28
|
+
const machine = [pkgLibraryDir, join(home, ".karajan", "library")];
|
|
29
|
+
const own = [join(projectDir, ".karajan", "library")];
|
|
30
|
+
const byScope = { machine, project: own, all: [...machine, ...own] };
|
|
31
|
+
if (!byScope[scope]) throw new Error(`libraryDirs: unknown scope "${scope}" (machine | project | all)`);
|
|
32
|
+
return byScope[scope].filter((d) => existsSync(d));
|
|
27
33
|
}
|
|
28
34
|
|
|
29
35
|
/**
|
|
@@ -31,9 +37,9 @@ export function libraryDirs({ pkgLibraryDir = SHIPPED_LIBRARY_DIR, home = os.hom
|
|
|
31
37
|
* re-index per source and identical bodies skip the embedder (content-hash
|
|
32
38
|
* dedup within the library project).
|
|
33
39
|
*/
|
|
34
|
-
export async function indexLibrary({ db, embedder, logger = console, pkgLibraryDir, home, projectDir } = {}) {
|
|
40
|
+
export async function indexLibrary({ db, embedder, logger = console, pkgLibraryDir, home, projectDir, scope = "all", project = LIBRARY_PROJECT } = {}) {
|
|
35
41
|
let indexed = 0, failed = 0, files = 0;
|
|
36
|
-
for (const dir of libraryDirs({ pkgLibraryDir, home, projectDir })) {
|
|
42
|
+
for (const dir of libraryDirs({ pkgLibraryDir, home, projectDir, scope })) {
|
|
37
43
|
for (const name of readdirSync(dir)) {
|
|
38
44
|
if (extname(name).toLowerCase() !== ".md") continue;
|
|
39
45
|
const path = join(dir, name);
|
|
@@ -42,13 +48,13 @@ export async function indexLibrary({ db, embedder, logger = console, pkgLibraryD
|
|
|
42
48
|
const hashed = chunks.map((ch) => ({ ch, contentHash: createHash("sha256").update(ch.text).digest("hex") }));
|
|
43
49
|
// Unchanged card (every chunk body already known) → skip before the
|
|
44
50
|
// delete, so idempotent re-runs never touch the embedder.
|
|
45
|
-
if (hashed.length && hashed.every(({ contentHash }) => findChunkByHash(db, contentHash,
|
|
51
|
+
if (hashed.length && hashed.every(({ contentHash }) => findChunkByHash(db, contentHash, project))) continue;
|
|
46
52
|
deleteChunksBySource(db, path);
|
|
47
53
|
for (const { ch, contentHash } of hashed) {
|
|
48
54
|
try {
|
|
49
|
-
if (findChunkByHash(db, contentHash,
|
|
55
|
+
if (findChunkByHash(db, contentHash, project)) continue;
|
|
50
56
|
const embedding = await embedder.embed(ch.text);
|
|
51
|
-
insertChunk(db, { source: path, kind: "library", text: ch.text, metadata: ch.metadata, embedding, project
|
|
57
|
+
insertChunk(db, { source: path, kind: "library", text: ch.text, metadata: ch.metadata, embedding, project, contentHash });
|
|
52
58
|
indexed += 1;
|
|
53
59
|
} catch (err) {
|
|
54
60
|
failed += 1;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-0888 (RAG-P1a, ADR 0011) — pasar al indice por proyecto sin volver a
|
|
3
|
+
* embeber. Los chunks de UN proyecto se copian de la base global de la maquina
|
|
4
|
+
* a <raiz>/.karajan/rag.db con sus embeddings tal cual, junto con su marca de
|
|
5
|
+
* last_indexed_commit para que el auto-update siga por delta. Cero recomputo
|
|
6
|
+
* (en esta maquina, karajan-code son 23.109 chunks), y la base global no se
|
|
7
|
+
* toca: borrarla es decision del usuario, no efecto de una migracion.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
|
|
11
|
+
import { openVecStore, insertChunk, getLastIndexedCommit, setLastIndexedCommit } from "./vec-store.js";
|
|
12
|
+
|
|
13
|
+
export const countProjectChunks = (db, slug) => db.prepare("SELECT COUNT(*) AS n FROM chunks WHERE project_slug = ?").get(slug).n;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* KJC-TSK-0882: que decir cuando el indice del proyecto no tiene nada suyo.
|
|
17
|
+
* Si la base global tiene sus chunks, migrar (segundos); si no, indexar.
|
|
18
|
+
*/
|
|
19
|
+
export function emptyIndexRemedy({ slug, legacyPath, dim = 768 }) {
|
|
20
|
+
if (slug && existsSync(legacyPath)) {
|
|
21
|
+
const legacy = openVecStore({ dim, path: legacyPath });
|
|
22
|
+
try {
|
|
23
|
+
if (countProjectChunks(legacy, slug) > 0) return "kj rag migrate";
|
|
24
|
+
} finally {
|
|
25
|
+
legacy.close();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return "kj rag index --with-sources";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @returns {{state: "migrated"|"already"|"nothing", migrated: number, reason: string}}
|
|
33
|
+
*/
|
|
34
|
+
export function migrateProjectIndex({ slug, legacyPath, targetPath, dim = 768 }) {
|
|
35
|
+
const nothing = { state: "nothing", migrated: 0, reason: `the global index holds nothing for ${slug}: kj rag index --with-sources` };
|
|
36
|
+
if (!existsSync(legacyPath)) return nothing;
|
|
37
|
+
|
|
38
|
+
const target = openVecStore({ dim, path: targetPath });
|
|
39
|
+
try {
|
|
40
|
+
// Se cuentan los de ESTE proyecto, no el total: un rag.db viejo en
|
|
41
|
+
// .karajan/ puede traer chunks de otro (visto en esta maquina: 42 de otro
|
|
42
|
+
// repo), y contar el total diria "ya migrado" sin haber copiado nada.
|
|
43
|
+
const mine = countProjectChunks(target, slug);
|
|
44
|
+
if (mine > 0) {
|
|
45
|
+
return { state: "already", migrated: 0, reason: `the project index already holds ${mine} chunks of ${slug}: nothing to migrate` };
|
|
46
|
+
}
|
|
47
|
+
const legacy = openVecStore({ dim, path: legacyPath });
|
|
48
|
+
try {
|
|
49
|
+
const rows = legacy.prepare("SELECT id, source, kind, text, metadata, content_hash FROM chunks WHERE project_slug = ?").all(slug);
|
|
50
|
+
if (rows.length === 0) return nothing;
|
|
51
|
+
const vecOf = legacy.prepare("SELECT embedding FROM vec_chunks WHERE rowid = ?");
|
|
52
|
+
const copy = target.transaction(() => {
|
|
53
|
+
for (const r of rows) {
|
|
54
|
+
const raw = vecOf.get(BigInt(r.id))?.embedding;
|
|
55
|
+
if (!raw) continue; // un chunk sin vector no se puede buscar: no se inventa
|
|
56
|
+
const embedding = new Float32Array(raw.buffer.slice(raw.byteOffset, raw.byteOffset + raw.byteLength));
|
|
57
|
+
insertChunk(target, { source: r.source, kind: r.kind, text: r.text, metadata: r.metadata, embedding, project: slug, contentHash: r.content_hash });
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
copy();
|
|
61
|
+
const stamp = getLastIndexedCommit(legacy, slug);
|
|
62
|
+
if (stamp) setLastIndexedCommit(target, slug, stamp);
|
|
63
|
+
const migrated = countProjectChunks(target, slug);
|
|
64
|
+
const parts = [`${migrated} chunks of ${slug} copied with their embeddings`];
|
|
65
|
+
if (stamp) parts.push(`indexed at ${stamp.slice(0, 9)}`);
|
|
66
|
+
return { state: "migrated", migrated, reason: parts.join(", ") };
|
|
67
|
+
} finally {
|
|
68
|
+
legacy.close();
|
|
69
|
+
}
|
|
70
|
+
} finally {
|
|
71
|
+
target.close();
|
|
72
|
+
}
|
|
73
|
+
}
|
package/src/rag/onnx-fallback.js
CHANGED
|
@@ -12,7 +12,7 @@ import path from "node:path";
|
|
|
12
12
|
import Database from "better-sqlite3";
|
|
13
13
|
import { parseDocument } from "yaml";
|
|
14
14
|
import { getProjectConfigPath } from "../config/loader.js";
|
|
15
|
-
import {
|
|
15
|
+
import { projectDbPath } from "./project-store.js";
|
|
16
16
|
|
|
17
17
|
const ONNX_EMBEDDER = { provider: "onnx", dim: 384 };
|
|
18
18
|
|
|
@@ -24,8 +24,10 @@ const ONNX_EMBEDDER = { provider: "onnx", dim: 384 };
|
|
|
24
24
|
* that already HAS chunks is never touched — switching dims there would
|
|
25
25
|
* destroy other projects' indexes.
|
|
26
26
|
*/
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
// KJC-TSK-0882 (ADR 0011): el store es DEL PROYECTO, asi que el fallback a ONNX
|
|
28
|
+
// decide mirando el indice de este proyecto y nunca el de otro.
|
|
29
|
+
export function resetEmptyStore(projectDir = process.cwd()) {
|
|
30
|
+
const storePath = projectDbPath(projectDir);
|
|
29
31
|
if (!existsSync(storePath)) return true;
|
|
30
32
|
// Read-only, schema-agnostic inspection: no DDL, no vec extension, no
|
|
31
33
|
// dimension assumptions — the store is only ever OPENED to be counted.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-0882 (RAG-P1, ADR 0011) — el indice del RAG es DEL PROYECTO.
|
|
3
|
+
*
|
|
4
|
+
* Hasta aqui era una sola base por maquina (~/.karajan/rag.db): 427 MB, 19
|
|
5
|
+
* proyectos y 49.825 chunks en un indice vectorial sin particion. La KNN sacaba
|
|
6
|
+
* los vecinos mas cercanos de TODA la maquina y filtraba por proyecto despues,
|
|
7
|
+
* asi que un proyecto pequeno podia recibir cero resultados con el chunk
|
|
8
|
+
* perfecto indexado, y cinco consultas ni siquiera filtraban (una de ellas, el
|
|
9
|
+
* preload que entra en el prompt del coder).
|
|
10
|
+
*
|
|
11
|
+
* Ahora cada proyecto abre <raiz git>/.karajan/rag.db. La raiz se resuelve por
|
|
12
|
+
* git para que desde un subdirectorio se vea el mismo indice. KJ_RAG_DB sigue
|
|
13
|
+
* mandando cuando esta puesta. El store de karajan-core ya acepta `path`, asi
|
|
14
|
+
* que esto no toca core.
|
|
15
|
+
*/
|
|
16
|
+
import { execFileSync } from "node:child_process";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
|
|
19
|
+
import { openVecStore } from "./vec-store.js";
|
|
20
|
+
import { getKarajanHome } from "../utils/paths.js";
|
|
21
|
+
|
|
22
|
+
function projectRoot(dir) {
|
|
23
|
+
try {
|
|
24
|
+
return execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || dir;
|
|
25
|
+
} catch {
|
|
26
|
+
return dir;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Ruta del indice del proyecto. */
|
|
31
|
+
export function projectDbPath(projectDir = process.cwd()) {
|
|
32
|
+
if (process.env.KJ_RAG_DB) return process.env.KJ_RAG_DB;
|
|
33
|
+
return path.join(projectRoot(projectDir), ".karajan", "rag.db");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Abre el indice del proyecto. El caller es dueno del close(). */
|
|
37
|
+
/** KJC-TSK-0883: the machine canon has its own store, never a project's. */
|
|
38
|
+
export function libraryDbPath() {
|
|
39
|
+
return process.env.KJ_LIBRARY_DB || path.join(getKarajanHome(), "library.db");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function openLibraryStore({ dim = 768 } = {}) {
|
|
43
|
+
return openVecStore({ dim, path: libraryDbPath() });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function openProjectStore({ projectDir = process.cwd(), dim = 768 } = {}) {
|
|
47
|
+
return openVecStore({ dim, path: projectDbPath(projectDir) });
|
|
48
|
+
}
|
package/src/rag/watcher.js
CHANGED
|
@@ -1,18 +1,27 @@
|
|
|
1
1
|
// KJC-TSK-0441 (RAG v2.28.0 PR1) — chokidar watcher. Live re-index of plans
|
|
2
2
|
// + onboarding + (opt) sources, debounced. PID file arbitrates a single daemon.
|
|
3
3
|
// KJC-TSK-0482 — source matcher derivado del registry (multi-lang: JS/Py/Rust/Go/Java).
|
|
4
|
-
import { writeFileSync, readFileSync, existsSync, unlinkSync } from "node:fs";
|
|
5
|
-
import { extname, join } from "node:path";
|
|
4
|
+
import { writeFileSync, readFileSync, existsSync, mkdirSync, unlinkSync } from "node:fs";
|
|
5
|
+
import { dirname, extname, join } from "node:path";
|
|
6
6
|
import chokidar from "chokidar";
|
|
7
|
-
import {
|
|
7
|
+
import { deleteChunksBySource, projectSlug } from "./vec-store.js";
|
|
8
|
+
import { openProjectStore } from "./project-store.js";
|
|
8
9
|
import { makeGovernedEmbedder } from "./governed-embedder.js";
|
|
9
10
|
import { indexFile } from "./indexer.js";
|
|
10
11
|
import { getKarajanHome } from "../utils/paths.js";
|
|
11
12
|
import { getAllCodeExtensions } from "../lang/registry.js";
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
// KJC-TSK-0883 (ADR 0011): un watcher por proyecto, cada uno con su PID.
|
|
15
|
+
export const pidFilePath = (projectDir = process.cwd()) => join(getKarajanHome(), "watchers", `${projectSlug(projectDir)}.pid`);
|
|
16
|
+
|
|
17
|
+
/** Solo lo de ESTE proyecto: sus planes, su onboarding y (opt) sus fuentes. */
|
|
18
|
+
export function watchedPaths(projectDir, { withSources = false } = {}) {
|
|
19
|
+
const slug = projectSlug(projectDir);
|
|
20
|
+
const paths = [join(getKarajanHome(), "onboarding", `${slug}.md`), join(getKarajanHome(), "plans", slug)];
|
|
21
|
+
return withSources ? [...paths, projectDir] : paths;
|
|
22
|
+
}
|
|
14
23
|
const DEFAULT_DEBOUNCE_MS = 1000;
|
|
15
|
-
const SKIP_SEGMENTS = new Set(["node_modules", ".git", "dist", "build", "coverage", ".karajan", ".next", ".kj", "_diet"]);
|
|
24
|
+
const SKIP_SEGMENTS = new Set(["node_modules", ".git", "dist", "build", "coverage", ".karajan", ".next", ".kj", "_diet", ".claude"]);
|
|
16
25
|
const SOURCE_EXTS = new Set(getAllCodeExtensions().map((e) => e.toLowerCase()));
|
|
17
26
|
|
|
18
27
|
export function isWatchable(path) {
|
|
@@ -26,11 +35,9 @@ export function startWatcher({ projectDir, config, logger = console, debounceMs
|
|
|
26
35
|
if (!projectDir) throw new Error("startWatcher: projectDir is required");
|
|
27
36
|
const slug = projectSlug(projectDir);
|
|
28
37
|
const dim = config?.rag?.embedder?.dim || 768;
|
|
29
|
-
const db =
|
|
38
|
+
const db = openProjectStore({ projectDir, dim });
|
|
30
39
|
const embedder = makeGovernedEmbedder(config);
|
|
31
|
-
const
|
|
32
|
-
if (withSources) paths.push(projectDir);
|
|
33
|
-
const watcher = chokidar.watch(paths, { ignoreInitial: true, persistent: true });
|
|
40
|
+
const watcher = chokidar.watch(watchedPaths(projectDir, { withSources }), { ignoreInitial: true, persistent: true });
|
|
34
41
|
const pending = new Map();
|
|
35
42
|
const flush = async (p) => {
|
|
36
43
|
pending.delete(p);
|
|
@@ -49,11 +56,15 @@ export function startWatcher({ projectDir, config, logger = console, debounceMs
|
|
|
49
56
|
};
|
|
50
57
|
}
|
|
51
58
|
|
|
52
|
-
export function writePidFile(pid = process.pid) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
59
|
+
export function writePidFile(projectDir, pid = process.pid) {
|
|
60
|
+
const file = pidFilePath(projectDir);
|
|
61
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
62
|
+
writeFileSync(file, String(pid), "utf8");
|
|
63
|
+
}
|
|
64
|
+
export function clearPidFile(projectDir) { if (existsSync(pidFilePath(projectDir))) unlinkSync(pidFilePath(projectDir)); }
|
|
65
|
+
export function readPidFile(projectDir) {
|
|
66
|
+
if (!existsSync(pidFilePath(projectDir))) return null;
|
|
67
|
+
const pid = Number(readFileSync(pidFilePath(projectDir), "utf8").trim());
|
|
57
68
|
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
58
69
|
}
|
|
59
70
|
export function isPidAlive(pid) {
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* Saltarse un gate ritualmente es peor que no tenerlo, porque parece que se
|
|
12
12
|
* cumple.
|
|
13
13
|
*/
|
|
14
|
+
import { execFileSync } from "node:child_process";
|
|
14
15
|
import { createRequire } from "node:module";
|
|
15
16
|
import { dirname, resolve } from "node:path";
|
|
16
17
|
import { fileURLToPath } from "node:url";
|
|
@@ -40,3 +41,28 @@ export function kjProvenance(moduleDir = here) {
|
|
|
40
41
|
: `${v} (${root})`;
|
|
41
42
|
return { version, root, linked, label };
|
|
42
43
|
}
|
|
44
|
+
|
|
45
|
+
const gitIn = (root) => (args) => execFileSync("git", ["-C", root, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* KJC-TSK-0886: el sello de que kj emite un veredicto. En un arbol enlazado la
|
|
49
|
+
* version no basta (el mismo package.json vale para dos estados del disco), asi
|
|
50
|
+
* que lleva rama y commit del working tree. Nunca lanza: adorna, no decide.
|
|
51
|
+
*/
|
|
52
|
+
export function kjStamp({ provenance = kjProvenance(), git = gitIn(provenance.root) } = {}) {
|
|
53
|
+
const stamp = { version: provenance.version, linked: provenance.linked };
|
|
54
|
+
if (!provenance.linked) return stamp;
|
|
55
|
+
try {
|
|
56
|
+
return { ...stamp, branch: git(["rev-parse", "--abbrev-ref", "HEAD"]).trim(), commit: git(["rev-parse", "HEAD"]).trim() };
|
|
57
|
+
} catch {
|
|
58
|
+
return stamp;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const STAMP_KEYS = ["version", "linked", "branch", "commit"];
|
|
63
|
+
export const stampDiffers = (a, b) => Boolean(a && b) && STAMP_KEYS.some((k) => a[k] !== b[k]);
|
|
64
|
+
|
|
65
|
+
export function stampLabel(s) {
|
|
66
|
+
const where = s.branch ? ` (${s.branch}@${(s.commit || "").slice(0, 7)})` : "";
|
|
67
|
+
return `kj ${s.version ?? "unknown"}${where}`;
|
|
68
|
+
}
|