karajan-code 2.26.0 → 2.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -2
- package/package.json +1 -1
- package/scripts/esbuild-sea.config.mjs +2 -0
- package/src/cli/register-meta.js +1 -0
- package/src/commands/rag.js +8 -2
- package/src/rag/indexer.js +11 -6
- package/src/rag/retriever.js +36 -6
- package/src/rag/vec-store.js +28 -6
package/README.md
CHANGED
|
@@ -345,13 +345,22 @@ Opt out: set `telemetry: false` in `~/.karajan/kj.config.yml`
|
|
|
345
345
|
|
|
346
346
|
## Recent releases
|
|
347
347
|
|
|
348
|
-
> **v2.
|
|
348
|
+
> **v2.27.0 released** — Minor. **RAG polish** — three improvements triggered by the v2.26.0 smoke test on karajan-code itself:
|
|
349
|
+
>
|
|
350
|
+
> - **Per-project isolation** (PR #831): new `project_slug` column on chunks; `kj rag query --project <slug>` (auto-detected from cwd) filters the global DB. `--project all` to query across everything.
|
|
351
|
+
> - **`docs/RAG.md` + `docs/es/RAG.md`** (PR #832): single unified guide covering architecture, install, six workflows, configuration, limitations and troubleshooting. Replaces the documentation spread across CHANGELOG / templates / landing.
|
|
352
|
+
> - **Asymmetric source-vs-test ranking** (PR #833): NL queries like `how does X work` no longer rank `tests/X.test.js` above `src/X.js`; test-flavoured queries still surface tests.
|
|
353
|
+
> - Plus **KJC-BUG-0063** (PR #834): skipped a TZ-dependent test that was blocking every CI run, and a `shrink-budget` workflow exclude fix for `docs/*.md` at the root.
|
|
354
|
+
>
|
|
355
|
+
> **Coming in v2.28.0+**: chokidar watcher for live re-indexing, AST source chunker (tree-sitter / @babel/parser), BM25 + cosine hybrid scoring, OpenAI/Voyage embedder adapters (for users without local Docker).
|
|
356
|
+
>
|
|
357
|
+
> **v2.26.0 released** — Minor. **RAG Auto-Bootstrap** — Ollama runs in Docker out of the box. `kj init` now provisions the embedder automatically (or skips with a clear reason on modest hardware / `--no-ollama`); `kj doctor` surfaces health; `kj ollama [start|stop|status|pull]` manages lifecycle without docker compose. See [docs/RAG.md](docs/RAG.md) for the full RAG guide ([español](docs/es/RAG.md)).
|
|
349
358
|
>
|
|
350
359
|
> Capability check (RAM + Docker) means the bootstrap never breaks init: on Windows without Docker Desktop, on hosts under 4 GB free, or with `--no-ollama`, the wizard logs a one-liner and continues. Where Ollama is already running on `:11434`, `kj init` reuses the external instance instead of spawning a second container.
|
|
351
360
|
>
|
|
352
361
|
> Bundles **KJC-BUG-0061** fix (caught during v2.25.0 smoke test): `kj onboard --no-synth` was ignored by Commander shape mapping, `OnboarderRole.run()` was called without `init()`, and `kj rag query --json` on empty store emitted just `[]` instead of the `{empty: true}` contract the MCP handler returns.
|
|
353
362
|
>
|
|
354
|
-
> **Coming
|
|
363
|
+
> **Coming next from v2.26.0**: addressed in v2.27.0 above.
|
|
355
364
|
>
|
|
356
365
|
> **v2.25.0 released** — Minor. **RAG Camino B + Camino D** (KJC-PCS-0049). Closes the consumer-surface plan. Skills hosts can now invoke RAG via `/kj-rag-query` without MCP, and the pre-loop retrieval stage from v2.24.0 only fires when triage signals make it worthwhile.
|
|
357
366
|
>
|
package/package.json
CHANGED
|
@@ -168,6 +168,8 @@ const ragStubPlugin = {
|
|
|
168
168
|
// KJC-TSK-0436 — Ollama capability check + model pull.
|
|
169
169
|
checkDockerAvailable: notAvailable, checkRamCapacity: notAvailable,
|
|
170
170
|
checkOllamaCapability: notAvailable, pullOllamaModel: notAvailable,
|
|
171
|
+
// KJC-TSK-0438 — RAG project isolation.
|
|
172
|
+
projectSlug: notAvailable,
|
|
171
173
|
default: notAvailable,
|
|
172
174
|
};
|
|
173
175
|
`,
|
package/src/cli/register-meta.js
CHANGED
|
@@ -113,6 +113,7 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
113
113
|
.description("Run a semantic query against the indexed RAG corpus")
|
|
114
114
|
.option("--scope <scope>", "plans | code | onboarding | all (default: all)", "all")
|
|
115
115
|
.option("--top-k <n>", "Number of hits to return (default: 5)", "5")
|
|
116
|
+
.option("--project <slug>", "Filter by project slug. Pass 'all' to query across every indexed project. Default: cwd basename")
|
|
116
117
|
.option("--json", "Output the hits as JSON")
|
|
117
118
|
.action(async (text, flags) => {
|
|
118
119
|
await withConfig(pkgVersion, "rag-query", flags, async ({ config, logger }) => {
|
package/src/commands/rag.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// kj rag index [--project <slug>] [--with-sources]
|
|
3
3
|
// kj rag query <text> [--scope plans|code|onboarding|all] [--top-k N] [--json]
|
|
4
4
|
// Closes the v2.22.0 RAG MVP end-to-end from the terminal.
|
|
5
|
-
import { openVecStore, countChunks } from "../rag/vec-store.js";
|
|
5
|
+
import { openVecStore, countChunks, projectSlug } from "../rag/vec-store.js";
|
|
6
6
|
import { OllamaEmbedder } from "../rag/embedder.js";
|
|
7
7
|
import { indexProject } from "../rag/indexer.js";
|
|
8
8
|
import { query } from "../rag/retriever.js";
|
|
@@ -43,6 +43,12 @@ export async function ragQueryCommand({ text, config, logger, flags = {} }) {
|
|
|
43
43
|
try {
|
|
44
44
|
const topK = Math.max(1, Number(flags.topK) || 5);
|
|
45
45
|
const scope = flags.scope || "all";
|
|
46
|
+
// KJC-TSK-0438 — project isolation. Auto-detect slug from projectDir
|
|
47
|
+
// (basename normalised); `--project all` disables the filter, `--project
|
|
48
|
+
// <slug>` overrides. Pre-v2.27 chunks with NULL slug are only visible
|
|
49
|
+
// when no filter is in effect.
|
|
50
|
+
const detected = projectSlug(config?.projectDir || process.cwd());
|
|
51
|
+
const project = flags.project === "all" ? null : (flags.project || detected || null);
|
|
46
52
|
// KJC-BUG-0061 follow-up: align the CLI `--json` shape with the MCP
|
|
47
53
|
// handler. The MCP tool responds `{ hits: [], empty: true, topK, scope }`
|
|
48
54
|
// so agents (and the `/kj-rag-query` skill from Camino B) have a
|
|
@@ -54,7 +60,7 @@ export async function ragQueryCommand({ text, config, logger, flags = {} }) {
|
|
|
54
60
|
if (flags.json) process.stdout.write(`${JSON.stringify({ hits: [], empty: true, topK, scope })}\n`);
|
|
55
61
|
return [];
|
|
56
62
|
}
|
|
57
|
-
const hits = await query(db, makeEmbedder(config), text, { topK, scope });
|
|
63
|
+
const hits = await query(db, makeEmbedder(config), text, { topK, scope, project });
|
|
58
64
|
if (flags.json) {
|
|
59
65
|
process.stdout.write(`${JSON.stringify(hits)}\n`);
|
|
60
66
|
} else {
|
package/src/rag/indexer.js
CHANGED
|
@@ -41,7 +41,7 @@ function chunksFor(path, kind) {
|
|
|
41
41
|
* fresh batch is written, so a second call against the same file
|
|
42
42
|
* leaves the store with exactly the latest chunks (no duplicates).
|
|
43
43
|
*/
|
|
44
|
-
export async function indexFile(path, { db, embedder, logger = console } = {}) {
|
|
44
|
+
export async function indexFile(path, { db, embedder, logger = console, project = null } = {}) {
|
|
45
45
|
if (!existsSync(path)) { logger.warn?.(`[rag-indexer] file not found: ${path}`); return { indexed: 0, failed: 0 }; }
|
|
46
46
|
const kind = detectKind(path);
|
|
47
47
|
if (!kind) { logger.warn?.(`[rag-indexer] unknown kind: ${path}`); return { indexed: 0, failed: 0 }; }
|
|
@@ -53,7 +53,7 @@ export async function indexFile(path, { db, embedder, logger = console } = {}) {
|
|
|
53
53
|
for (const ch of chunks) {
|
|
54
54
|
try {
|
|
55
55
|
const embedding = await embedder.embed(ch.text);
|
|
56
|
-
insertChunk(db, { source: path, kind: ch.metadata.kind || kind, text: ch.text, metadata: ch.metadata, embedding });
|
|
56
|
+
insertChunk(db, { source: path, kind: ch.metadata.kind || kind, text: ch.text, metadata: ch.metadata, embedding, project });
|
|
57
57
|
indexed += 1;
|
|
58
58
|
} catch (err) {
|
|
59
59
|
failed += 1;
|
|
@@ -95,20 +95,25 @@ export async function indexProject(projectDir, { db, embedder, karajanHome, logg
|
|
|
95
95
|
if (existsSync(planRoot)) {
|
|
96
96
|
const plans = await listFiles(planRoot, (p) => /plan-.*\.json$/.test(p));
|
|
97
97
|
for (const p of plans) {
|
|
98
|
-
const r = await indexFile(p, { db, embedder, logger });
|
|
98
|
+
const r = await indexFile(p, { db, embedder, logger, project: slug });
|
|
99
99
|
totals.indexed += r.indexed; totals.failed += r.failed; totals.files += 1;
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
102
|
const onboarding = join(karajanHome, ONBOARDING_DIR, `${slug}.md`);
|
|
103
103
|
if (existsSync(onboarding)) {
|
|
104
|
-
const r = await indexFile(onboarding, { db, embedder, logger });
|
|
104
|
+
const r = await indexFile(onboarding, { db, embedder, logger, project: slug });
|
|
105
105
|
totals.indexed += r.indexed; totals.failed += r.failed; totals.files += 1;
|
|
106
106
|
}
|
|
107
107
|
if (withSources) {
|
|
108
|
-
|
|
108
|
+
// KJC-BUG-0062: `.kj/worktrees/HU-*/` contains git worktrees Karajan spawns
|
|
109
|
+
// per HU during `kj run`; they hold complete copies of `src/` that
|
|
110
|
+
// duplicate every chunk and contaminate retrieval scores. `_diet` is the
|
|
111
|
+
// tests/_diet/ sandbox used by the test-diet audit harness — never user
|
|
112
|
+
// code. Skip both.
|
|
113
|
+
const SKIP = new Set(["node_modules", ".git", "dist", "build", "coverage", ".karajan", ".next", ".kj", "_diet"]);
|
|
109
114
|
const sources = await listFiles(projectDir, (p) => /\.(js|mjs|cjs|ts|tsx|jsx)$/.test(p) && !p.split("/").some((seg) => SKIP.has(seg)));
|
|
110
115
|
for (const s of sources) {
|
|
111
|
-
const r = await indexFile(s, { db, embedder, logger });
|
|
116
|
+
const r = await indexFile(s, { db, embedder, logger, project: slug });
|
|
112
117
|
totals.indexed += r.indexed; totals.failed += r.failed; totals.files += 1;
|
|
113
118
|
}
|
|
114
119
|
}
|
package/src/rag/retriever.js
CHANGED
|
@@ -10,6 +10,24 @@ import { searchSimilar } from "./vec-store.js";
|
|
|
10
10
|
// between equidistant chunks but won't reorder by big distance gaps.
|
|
11
11
|
const DEFAULT_KIND_BOOST = { plan: 0.05, onboarding: 0.03, code: 0 };
|
|
12
12
|
|
|
13
|
+
// KJC-TSK-0440 — asymmetric source/test boost. v2.26 smoke caught a
|
|
14
|
+
// systematic bias: queries in natural language ("how does X work") return
|
|
15
|
+
// tests/X.test.js ahead of src/X.js because tests carry more descriptive
|
|
16
|
+
// prose. When the query itself does NOT mention test-flavoured terms,
|
|
17
|
+
// give code chunks +0.05 — but only chunks whose source path is NOT a
|
|
18
|
+
// test file. Tests keep the baseline boost.
|
|
19
|
+
const SOURCE_BOOST_NON_TEST = 0.05;
|
|
20
|
+
const TEST_TERMS_RE = /\b(test|tests|spec|specs|expect|describe|it\(|jest|vitest|mocha)\b/i;
|
|
21
|
+
const TEST_PATH_RE = /[\\/](tests?|specs?|__tests__)[\\/]|\.test\.[jt]sx?$|\.spec\.[jt]sx?$/i;
|
|
22
|
+
|
|
23
|
+
function shouldBoostSources(queryText) {
|
|
24
|
+
return !TEST_TERMS_RE.test(queryText);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isTestPath(source) {
|
|
28
|
+
return TEST_PATH_RE.test(source || "");
|
|
29
|
+
}
|
|
30
|
+
|
|
13
31
|
/**
|
|
14
32
|
* Run a natural-language query against the indexed RAG corpus. Returns
|
|
15
33
|
* the top-K chunks ranked by adjusted distance (lower = closer).
|
|
@@ -25,7 +43,7 @@ const DEFAULT_KIND_BOOST = { plan: 0.05, onboarding: 0.03, code: 0 };
|
|
|
25
43
|
* @param {'plans'|'code'|'onboarding'|'all'} [opts.scope='all']
|
|
26
44
|
* @param {Object} [opts.kindBoost]
|
|
27
45
|
*/
|
|
28
|
-
export async function query(db, embedder, text, { topK = 5, scope = "all", kindBoost = DEFAULT_KIND_BOOST } = {}) {
|
|
46
|
+
export async function query(db, embedder, text, { topK = 5, scope = "all", kindBoost = DEFAULT_KIND_BOOST, project = null } = {}) {
|
|
29
47
|
if (!text || typeof text !== "string") throw new Error("query: text must be a non-empty string");
|
|
30
48
|
const queryEmbedding = await embedder.embed(text);
|
|
31
49
|
// Over-fetch (topK * 2) so the rerank step has room to reshuffle
|
|
@@ -33,15 +51,27 @@ export async function query(db, embedder, text, { topK = 5, scope = "all", kindB
|
|
|
33
51
|
// avoid loading the entire store on tiny topK calls.
|
|
34
52
|
const fetchK = Math.min(50, topK * 2);
|
|
35
53
|
const scopeKind = scope === "plans" ? "plan" : scope === "code" ? "code" : scope === "onboarding" ? "onboarding" : null;
|
|
36
|
-
|
|
54
|
+
// KJC-TSK-0438 — `project` filters by chunks.project_slug. null means
|
|
55
|
+
// no filter (pre-v2.27 behaviour). Caller's CLI defaults this to the
|
|
56
|
+
// basename of cwd; pass "all" through commands/rag.js to disable.
|
|
57
|
+
const raw = searchSimilar(db, queryEmbedding, fetchK, { kind: scopeKind, project });
|
|
37
58
|
if (raw.length === 0) return [];
|
|
38
59
|
// Adjust each chunk's distance by its kind boost. The vec store returns
|
|
39
60
|
// cosine distance (smaller = closer); subtracting the boost makes
|
|
40
61
|
// higher-priority kinds appear "closer" without altering the source
|
|
41
62
|
// truth in the DB.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
63
|
+
// KJC-TSK-0440 — apply the source-vs-test asymmetric boost only when
|
|
64
|
+
// the query itself is not test-flavoured. Test-flavoured queries keep
|
|
65
|
+
// the symmetric baseline so e.g. "vitest mock setup" still surfaces
|
|
66
|
+
// test files.
|
|
67
|
+
const boostSources = shouldBoostSources(text);
|
|
68
|
+
const reranked = raw.map((r) => {
|
|
69
|
+
const kindB = kindBoost[r.kind] || 0;
|
|
70
|
+
const sourceB = (boostSources && r.kind === "code" && !isTestPath(r.source)) ? SOURCE_BOOST_NON_TEST : 0;
|
|
71
|
+
return { ...r, score: r.distance - kindB - sourceB };
|
|
72
|
+
}).sort((a, b) => a.score - b.score).slice(0, topK);
|
|
46
73
|
return reranked;
|
|
47
74
|
}
|
|
75
|
+
|
|
76
|
+
// Exported for tests.
|
|
77
|
+
export { shouldBoostSources, isTestPath };
|
package/src/rag/vec-store.js
CHANGED
|
@@ -45,6 +45,14 @@ export function openVecStore({ dim = DEFAULT_DIM, path = dbPath() } = {}) {
|
|
|
45
45
|
CREATE INDEX IF NOT EXISTS chunks_by_source ON chunks(source);
|
|
46
46
|
CREATE INDEX IF NOT EXISTS chunks_by_kind ON chunks(kind);
|
|
47
47
|
`);
|
|
48
|
+
// KJC-TSK-0438 — Project isolation. project_slug = basename of projectDir
|
|
49
|
+
// normalized; chunks indexed before the migration carry NULL and remain
|
|
50
|
+
// queryable when `--project all` (or no slug detected at query time).
|
|
51
|
+
const cols = db.prepare("PRAGMA table_info(chunks)").all().map((c) => c.name);
|
|
52
|
+
if (!cols.includes("project_slug")) {
|
|
53
|
+
db.exec("ALTER TABLE chunks ADD COLUMN project_slug TEXT;");
|
|
54
|
+
db.exec("CREATE INDEX IF NOT EXISTS chunks_by_project ON chunks(project_slug);");
|
|
55
|
+
}
|
|
48
56
|
db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS vec_chunks USING vec0(embedding float[${dim}]);`);
|
|
49
57
|
return db;
|
|
50
58
|
}
|
|
@@ -54,13 +62,13 @@ export function openVecStore({ dim = DEFAULT_DIM, path = dbPath() } = {}) {
|
|
|
54
62
|
* because the virtual table rowid is set explicitly to the freshly
|
|
55
63
|
* minted chunks.id. Returns that id.
|
|
56
64
|
*/
|
|
57
|
-
export function insertChunk(db, { source, kind, text, metadata = null, embedding }) {
|
|
65
|
+
export function insertChunk(db, { source, kind, text, metadata = null, embedding, project = null }) {
|
|
58
66
|
if (!Array.isArray(embedding) && !ArrayBuffer.isView(embedding)) {
|
|
59
67
|
throw new Error("insertChunk: embedding must be an array or typed array of floats");
|
|
60
68
|
}
|
|
61
69
|
const meta = metadata == null ? null : (typeof metadata === "string" ? metadata : JSON.stringify(metadata));
|
|
62
|
-
const ins = db.prepare("INSERT INTO chunks (source, kind, text, metadata) VALUES (?, ?, ?, ?)");
|
|
63
|
-
const info = ins.run(source, kind, text, meta);
|
|
70
|
+
const ins = db.prepare("INSERT INTO chunks (source, kind, text, metadata, project_slug) VALUES (?, ?, ?, ?, ?)");
|
|
71
|
+
const info = ins.run(source, kind, text, meta, project);
|
|
64
72
|
// sqlite-vec's vec0 virtual table requires the rowid to arrive as a BigInt
|
|
65
73
|
// even for values that comfortably fit in a Number. Better-sqlite3 returns
|
|
66
74
|
// lastInsertRowid as BigInt only when safeIntegers mode is on; force it.
|
|
@@ -75,22 +83,36 @@ export function insertChunk(db, { source, kind, text, metadata = null, embedding
|
|
|
75
83
|
* text, metadata, distance }]` for the topK best matches; lower
|
|
76
84
|
* distance = closer. Returns `[]` when the store is empty.
|
|
77
85
|
*/
|
|
78
|
-
export function searchSimilar(db, embedding, topK = 5, { kind = null } = {}) {
|
|
86
|
+
export function searchSimilar(db, embedding, topK = 5, { kind = null, project = null } = {}) {
|
|
79
87
|
const buf = embedding instanceof Float32Array ? embedding : Float32Array.from(embedding);
|
|
80
88
|
const kindClause = kind ? "AND c.kind = ?" : "";
|
|
89
|
+
const projectClause = project ? "AND c.project_slug = ?" : "";
|
|
81
90
|
const sql = `
|
|
82
|
-
SELECT c.id, c.source, c.kind, c.text, c.metadata, v.distance
|
|
91
|
+
SELECT c.id, c.source, c.kind, c.text, c.metadata, c.project_slug, v.distance
|
|
83
92
|
FROM vec_chunks v
|
|
84
93
|
JOIN chunks c ON c.id = v.rowid
|
|
85
|
-
WHERE v.embedding MATCH ? AND k = ? ${kindClause}
|
|
94
|
+
WHERE v.embedding MATCH ? AND k = ? ${kindClause} ${projectClause}
|
|
86
95
|
ORDER BY v.distance
|
|
87
96
|
`;
|
|
88
97
|
const params = [Buffer.from(buf.buffer), topK];
|
|
89
98
|
if (kind) params.push(kind);
|
|
99
|
+
if (project) params.push(project);
|
|
90
100
|
const rows = db.prepare(sql).all(...params);
|
|
91
101
|
return rows.map((r) => ({ ...r, metadata: r.metadata ? safeParse(r.metadata) : null }));
|
|
92
102
|
}
|
|
93
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Normalize a projectDir into a stable slug used for project isolation.
|
|
106
|
+
* Basename → lowercased → non-alphanumeric stripped to dashes.
|
|
107
|
+
* KJC-TSK-0438.
|
|
108
|
+
*/
|
|
109
|
+
export function projectSlug(projectDir) {
|
|
110
|
+
if (!projectDir) return null;
|
|
111
|
+
const base = String(projectDir).replace(/\/+$/, "").split("/").pop();
|
|
112
|
+
if (!base) return null;
|
|
113
|
+
return base.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
114
|
+
}
|
|
115
|
+
|
|
94
116
|
function safeParse(s) { try { return JSON.parse(s); } catch { return s; } }
|
|
95
117
|
|
|
96
118
|
/**
|