karajan-code 4.3.1 → 4.4.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "4.3.1",
3
+ "version": "4.4.1",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
@@ -109,11 +109,13 @@
109
109
  "helmet": "^8.1.0",
110
110
  "js-yaml": "^4.2.0",
111
111
  "karajan-core": "^1.0.0",
112
+ "karajan-rag": "^1.2.0",
112
113
  "knip": "^6.15.0",
113
114
  "madge": "^8.0.0",
114
115
  "sqlite-vec": "^0.1.9",
115
116
  "valibot": "^1.4.1",
116
- "web-tree-sitter": "^0.26.9"
117
+ "web-tree-sitter": "^0.26.9",
118
+ "yaml": "^2.9.0"
117
119
  },
118
120
  "devDependencies": {
119
121
  "@eslint/js": "^10.0.1",
@@ -6,11 +6,17 @@
6
6
  */
7
7
  import { installPlaybook } from "../environment/playbook.js";
8
8
  import { renderBrief, listBriefs } from "../environment/briefs.js";
9
- import { openVecStore, projectSlug, getLastIndexedCommit } from "../rag/vec-store.js";
9
+ import { existsSync } from "node:fs";
10
+ import { openVecStore, projectSlug, getLastIndexedCommit, dbPath } from "../rag/vec-store.js";
10
11
  import { ragIndexCommand } from "./rag.js";
11
12
  import { renderPendingBlock, PENDING_EXIT_CODE } from "../utils/pending-user-action.js";
13
+ import { onnxConfig, persistOnnxChoice, resetEmptyStore } from "../rag/onnx-fallback.js";
12
14
 
13
15
  function hasRagIndex(config, projectDir) {
16
+ // KJC-BUG-0128: probing must never CREATE the store — openVecStore runs
17
+ // the DDL, and a table born at the default dim (768) breaks the ONNX
18
+ // fallback (384) later. No file → no index, without side effects.
19
+ if (!existsSync(dbPath())) return false;
14
20
  const db = openVecStore({ dim: config?.rag?.embedder?.dim || 768 });
15
21
  try { return Boolean(getLastIndexedCommit(db, projectSlug(projectDir))); }
16
22
  finally { db.close(); }
@@ -59,6 +65,35 @@ export async function envInstallCommand({ config = null, logger = null, flags =
59
65
  : { tool: `${provider} embedder`, action: "needs-user", reason: `the RAG cannot index: ${why} — check rag.embedder in kj config`, manualUrl: "kj config" };
60
66
  console.log(renderPendingBlock([item], { retry: "kj rag index --with-sources (then re-run: kj env install)" }));
61
67
  };
68
+ // KJC-TSK-0683: on limited machines (no Ollama, no Docker) the DEFAULT
69
+ // provider falls back to the built-in ONNX embedder instead of blocking.
70
+ // Only for the first index and only when the user didn't pick a provider
71
+ // explicitly — the election is persisted (dims differ, it must stick).
72
+ const explicitProvider = Boolean(config?.rag?.embedder?.provider);
73
+ const tryOnnxFallback = async (why) => {
74
+ if (explicitProvider) return false;
75
+ console.log(`⚠ default embedder unavailable (${why}) — trying the built-in ONNX embedder (no install needed)…`);
76
+ try {
77
+ // KJC-BUG-0128: an empty store may carry a vec table at the wrong
78
+ // dim (the old probe created it at 768) — reset it so ONNX (384)
79
+ // can index. A store with data is never touched.
80
+ if (!resetEmptyStore()) {
81
+ console.log(" the vector store already has data at another dimension — not switching automatically");
82
+ return false;
83
+ }
84
+ const totals = await ragIndexCommand({ config: onnxConfig(config), logger, flags: { withSources: true } });
85
+ if ((totals?.indexed ?? 0) > 0) {
86
+ const p = await persistOnnxChoice(projectDir);
87
+ console.log(`✓ RAG indexed with the built-in ONNX embedder — persisted in ${p}`);
88
+ console.log(" To move to Ollama later: install it, remove the rag.embedder block from .karajan/kj.config.yml,");
89
+ console.log(" delete the store (~/.karajan/rag.db) and run: kj rag index --with-sources");
90
+ return true;
91
+ }
92
+ } catch (fallbackErr) {
93
+ console.log(` ONNX fallback also failed: ${fallbackErr.message}`);
94
+ }
95
+ return false;
96
+ };
62
97
  try {
63
98
  if (hasRagIndex(config, projectDir)) {
64
99
  console.log("✓ RAG index present");
@@ -66,11 +101,12 @@ export async function envInstallCommand({ config = null, logger = null, flags =
66
101
  console.log("⏳ no RAG index for this project — building it (first time only)…");
67
102
  const totals = await ragIndexCommand({ config, logger, flags: { withSources: true } });
68
103
  if ((totals?.indexed ?? 0) === 0 && (totals?.files ?? 0) > 0) {
69
- blockRag(`0 of ${totals.files} files indexed — is the embedder running?`);
104
+ const why = `0 of ${totals.files} files indexed — is the embedder running?`;
105
+ if (!(await tryOnnxFallback(why))) blockRag(why);
70
106
  }
71
107
  }
72
108
  } catch (err) {
73
- blockRag(err.message);
109
+ if (!(await tryOnnxFallback(err.message))) blockRag(err.message);
74
110
  }
75
111
  }
76
112
  if (result.exitCode !== PENDING_EXIT_CODE) {
@@ -3,7 +3,7 @@
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
5
  import { openVecStore, countChunks, projectSlug, getLastIndexedCommit, setLastIndexedCommit } from "../rag/vec-store.js";
6
- import { makeEmbedder } from "../rag/embedders/factory.js";
6
+ import { makeGovernedEmbedder } from "../rag/governed-embedder.js";
7
7
  import { indexProject, indexProjectDelta } from "../rag/indexer.js";
8
8
  import { query } from "../rag/retriever.js";
9
9
  import { installPostMergeHook, maybeAutoUpdate } from "../rag/auto-update.js";
@@ -19,7 +19,7 @@ export async function ragIndexCommand({ config, logger, flags = {} }) {
19
19
  const db = openDb(config);
20
20
  try {
21
21
  const slug = projectSlug(projectDir);
22
- const embedder = makeEmbedder(config);
22
+ const embedder = makeGovernedEmbedder(config);
23
23
  const sinceFlag = flags.since;
24
24
  // KJC-TSK-0455 — `--since auto` resolves to the last commit we indexed;
25
25
  // an explicit ref is honoured as-is. Without a baseline (first-time
@@ -103,7 +103,7 @@ export async function ragQueryCommand({ text, config, logger, flags = {} }) {
103
103
  const alpha = Math.max(0, Math.min(1, Number(flags.alpha) || 0.6));
104
104
  const where = flags.where || null;
105
105
  const rerankOpts = flags.rerank ? { model: flags.rerankModel } : null;
106
- const hits = await query(db, makeEmbedder(config), text, { topK, scope, project, mode, alpha, where, rerankOpts });
106
+ const hits = await query(db, makeGovernedEmbedder(config), text, { topK, scope, project, mode, alpha, where, rerankOpts });
107
107
  if (flags.json) {
108
108
  process.stdout.write(`${JSON.stringify(hits)}\n`);
109
109
  } else {
@@ -133,7 +133,7 @@ export async function ragEvalCommand({ config, logger, flags = {} }) {
133
133
  const topK = Math.max(1, Number(flags.topK) || 10);
134
134
  const detected = projectSlug(config?.projectDir || process.cwd());
135
135
  const project = flags.project === "all" ? null : (flags.project || detected || null);
136
- const embedder = makeEmbedder(config);
136
+ const embedder = makeGovernedEmbedder(config);
137
137
  const runQuery = async (text, k) => query(db, embedder, text, { topK: k, scope: "all", project });
138
138
  const report = await runEval(queries, runQuery, { topK, ks: [5, 10] });
139
139
  if (flags.json) {
@@ -216,7 +216,11 @@ const DEFAULTS = {
216
216
  // delta re-index before every `kj run`; `onCommit` is consulted by
217
217
  // `kj rag install-hooks` to decide whether to install the post-merge
218
218
  // hook. Both default ON; flip to false to opt out without uninstalling.
219
- autoUpdate: { onCommit: true, onRun: true }
219
+ autoUpdate: { onCommit: true, onRun: true },
220
+ // KJC-TSK-0682 — sensitivity of the indexed code. Cloud embedders
221
+ // (openai/voyage/cohere/mistral) require an explicit "public"; the
222
+ // safe default blocks them (local ollama/onnx always allowed).
223
+ sensitivity: "internal"
220
224
  },
221
225
  // Opt-in. Set by the `kj init` wizard (explicit consent prompt in the OS
222
226
  // locale). When the key is absent or false, no events are sent. See
@@ -2,7 +2,7 @@
2
2
  // from src/commands/* are forbidden by the layer-boundaries test
3
3
  // (MCP and CLI are peer layers); we hit the pure rag/* modules instead.
4
4
  import { countChunks, openVecStore } from "../../rag/vec-store.js";
5
- import { makeEmbedder } from "../../rag/embedders/factory.js";
5
+ import { makeGovernedEmbedder } from "../../rag/governed-embedder.js";
6
6
  import { indexProject } from "../../rag/indexer.js";
7
7
  import { query } from "../../rag/retriever.js";
8
8
  import { getKarajanHome } from "../../utils/paths.js";
@@ -25,7 +25,7 @@ export async function handleRagQuery(args, server) {
25
25
  const db = openVecStore({ dim: config?.rag?.embedder?.dim || 768 });
26
26
  try {
27
27
  if (countChunks(db) === 0) return responseText({ hits: [], empty: true, topK, scope });
28
- const hits = await query(db, makeEmbedder(config), text, { topK, scope });
28
+ const hits = await query(db, makeGovernedEmbedder(config), text, { topK, scope });
29
29
  return responseText({ hits, empty: false, topK, scope });
30
30
  } finally { db.close(); }
31
31
  } catch (err) {
@@ -40,7 +40,7 @@ export async function handleRagIndex(args, server) {
40
40
  const db = openVecStore({ dim: config?.rag?.embedder?.dim || 768 });
41
41
  try {
42
42
  const totals = await indexProject(projectDir, {
43
- db, embedder: makeEmbedder(config),
43
+ db, embedder: makeGovernedEmbedder(config),
44
44
  karajanHome: getKarajanHome(), logger: silentLogger,
45
45
  withSources: Boolean(args?.withSources),
46
46
  });
@@ -6,7 +6,7 @@
6
6
  // never sees an exception from this stage.
7
7
  import { emitProgress, makeEvent } from "../../utils/events.js";
8
8
  import { openVecStore, countChunks } from "../../rag/vec-store.js";
9
- import { makeEmbedder } from "../../rag/embedders/factory.js";
9
+ import { makeGovernedEmbedder } from "../../rag/governed-embedder.js";
10
10
  import { query } from "../../rag/retriever.js";
11
11
 
12
12
  const DEFAULT_TOP_K = 5;
@@ -52,7 +52,7 @@ export async function runRagContextStage({ config, logger, emitter, eventBase, t
52
52
  }
53
53
  const topK = preload.topK || DEFAULT_TOP_K;
54
54
  const scope = preload.scope || DEFAULT_SCOPE;
55
- const embedder = makeEmbedder(config);
55
+ const embedder = makeGovernedEmbedder(config);
56
56
  const searchOpts = resolveSearchOpts(config);
57
57
  const hits = await query(db, embedder, task, { topK, scope, ...searchOpts });
58
58
  if (hits.length === 0) return { skipped: true, reason: "no-hits" };
@@ -8,7 +8,7 @@ import { execa } from "execa";
8
8
 
9
9
  import { openVecStore, projectSlug, getLastIndexedCommit, setLastIndexedCommit } from "./vec-store.js";
10
10
  import { indexProjectDelta } from "./indexer.js";
11
- import { makeEmbedder } from "./embedders/factory.js";
11
+ import { makeGovernedEmbedder } from "./governed-embedder.js";
12
12
 
13
13
  const HOOK_SRC = resolve(fileURLToPath(import.meta.url), "../../../scripts/git-hooks/post-merge");
14
14
 
@@ -25,7 +25,7 @@ export async function maybeAutoUpdate({ projectDir, config, logger = console, fl
25
25
  const since = getLastIndexedCommit(db, slug);
26
26
  if (!since || since === head) return { skipped: true, head };
27
27
  logger.info?.(`[rag] drift detected (${since.slice(0, 7)} → ${head.slice(0, 7)}); running delta update`);
28
- const totals = await indexProjectDelta(projectDir, { db, embedder: makeEmbedder(config), since, logger });
28
+ const totals = await indexProjectDelta(projectDir, { db, embedder: makeGovernedEmbedder(config), since, logger });
29
29
  if (totals.head) setLastIndexedCommit(db, slug, totals.head);
30
30
  return { ran: true, totals };
31
31
  } catch (err) {
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Governed embedder (KJC-TSK-0682 — card filed by the karajan-rag session
3
+ * after its independent security audit closed the same risk there, H2).
4
+ * Chunks of project code must not travel to a cloud embedder without
5
+ * governance: local providers (ollama/onnx) pass through untouched; cloud
6
+ * providers (openai/voyage/cohere/mistral) require an EXPLICIT
7
+ * `rag.sensitivity: public` — the default (`internal`) blocks with an
8
+ * actionable error, never a silent downgrade. Even when allowed, every
9
+ * text is PII-redacted (karajan-rag's audited redactPII) before leaving
10
+ * the machine — defense in depth, not a substitute for the policy.
11
+ */
12
+ import { redactPII } from "karajan-rag";
13
+ import { makeEmbedder } from "./embedders/factory.js";
14
+
15
+ const LOCAL_PROVIDERS = new Set(["ollama", "onnx"]);
16
+ const redact = (text) => redactPII(String(text)).text;
17
+
18
+ class RedactingEmbedder {
19
+ constructor(inner) { this.inner = inner; }
20
+ get dim() { return this.inner.dim; }
21
+ get model() { return this.inner.model; }
22
+ async embed(text) { return this.inner.embed(redact(text)); }
23
+ async embedBatch(texts) { return this.inner.embedBatch(texts.map(redact)); }
24
+ }
25
+
26
+ /** Drop-in replacement for makeEmbedder(config) at every kj call site. */
27
+ export function makeGovernedEmbedder(config) {
28
+ const provider = config?.rag?.embedder?.provider || "ollama";
29
+ if (LOCAL_PROVIDERS.has(provider)) return makeEmbedder(config);
30
+
31
+ const sensitivity = config?.rag?.sensitivity || "internal";
32
+ if (sensitivity !== "public") {
33
+ throw new Error(
34
+ `embedder "${provider}" sends your code to a cloud provider, but rag.sensitivity is "${sensitivity}". `
35
+ + `Use a local embedder (rag.embedder.provider: ollama | onnx) or declare rag.sensitivity: public in kj.config.yml.`
36
+ );
37
+ }
38
+ return new RedactingEmbedder(makeEmbedder(config));
39
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * ONNX embedder fallback (KJC-TSK-0683) — limited machines where installing
3
+ * Ollama/Docker is not viable still get a working RAG: the built-in ONNX
4
+ * embedder (all-MiniLM via @huggingface/transformers, in-process, CPU).
5
+ * The choice is PERSISTED in the project config: onnx (384) and ollama
6
+ * (768) have different vector dims, so the election must be sticky —
7
+ * an index can never be written by one and queried by the other.
8
+ */
9
+ import fs from "node:fs/promises";
10
+ import { existsSync, rmSync } from "node:fs";
11
+ import path from "node:path";
12
+ import Database from "better-sqlite3";
13
+ import { parseDocument } from "yaml";
14
+ import { getProjectConfigPath } from "../config/loader.js";
15
+ import { dbPath } from "./vec-store.js";
16
+
17
+ const ONNX_EMBEDDER = { provider: "onnx", dim: 384 };
18
+
19
+ /**
20
+ * KJC-BUG-0128: the hasRagIndex probe could leave behind an EMPTY store
21
+ * whose vec table was created at the default dim (768) — ONNX inserts (384)
22
+ * then failed on every chunk. Before the fallback indexes, delete an empty
23
+ * store (plus WAL siblings) so it is recreated at the right dim. A store
24
+ * that already HAS chunks is never touched — switching dims there would
25
+ * destroy other projects' indexes.
26
+ */
27
+ export function resetEmptyStore() {
28
+ const storePath = dbPath();
29
+ if (!existsSync(storePath)) return true;
30
+ // Read-only, schema-agnostic inspection: no DDL, no vec extension, no
31
+ // dimension assumptions — the store is only ever OPENED to be counted.
32
+ const db = new Database(storePath, { readonly: true });
33
+ try {
34
+ const hasChunksTable = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chunks'").get();
35
+ if (hasChunksTable && db.prepare("SELECT COUNT(*) AS n FROM chunks").get().n > 0) return false;
36
+ } finally {
37
+ db.close();
38
+ }
39
+ for (const suffix of ["", "-wal", "-shm"]) rmSync(storePath + suffix, { force: true });
40
+ return true;
41
+ }
42
+
43
+ /** The same config, with the embedder swapped to the built-in ONNX. */
44
+ export function onnxConfig(config) {
45
+ return {
46
+ ...config,
47
+ rag: { ...(config?.rag || {}), embedder: { ...(config?.rag?.embedder || {}), ...ONNX_EMBEDDER } },
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Persist the ONNX election in the PROJECT config file. Surgical edit via
53
+ * the yaml Document API: only rag.embedder.{provider,dim} change — the
54
+ * user's comments, ordering and every other key survive untouched.
55
+ */
56
+ export async function persistOnnxChoice(projectDir) {
57
+ const configPath = getProjectConfigPath(projectDir);
58
+ let source = "";
59
+ try {
60
+ source = await fs.readFile(configPath, "utf8");
61
+ } catch { /* no project config yet — create it */ }
62
+ const doc = parseDocument(source || "{}");
63
+ for (const [key, value] of Object.entries(ONNX_EMBEDDER)) {
64
+ doc.setIn(["rag", "embedder", key], value);
65
+ }
66
+ await fs.mkdir(path.dirname(configPath), { recursive: true });
67
+ await fs.writeFile(configPath, doc.toString(), "utf8");
68
+ return configPath;
69
+ }
@@ -5,7 +5,7 @@ import { writeFileSync, readFileSync, existsSync, unlinkSync } from "node:fs";
5
5
  import { extname, join } from "node:path";
6
6
  import chokidar from "chokidar";
7
7
  import { openVecStore, deleteChunksBySource, projectSlug } from "./vec-store.js";
8
- import { makeEmbedder } from "./embedders/factory.js";
8
+ import { makeGovernedEmbedder } from "./governed-embedder.js";
9
9
  import { indexFile } from "./indexer.js";
10
10
  import { getKarajanHome } from "../utils/paths.js";
11
11
  import { getAllCodeExtensions } from "../lang/registry.js";
@@ -27,7 +27,7 @@ export function startWatcher({ projectDir, config, logger = console, debounceMs
27
27
  const slug = projectSlug(projectDir);
28
28
  const dim = config?.rag?.embedder?.dim || 768;
29
29
  const db = openVecStore({ dim });
30
- const embedder = makeEmbedder(config);
30
+ const embedder = makeGovernedEmbedder(config);
31
31
  const paths = [join(getKarajanHome(), "onboarding"), join(getKarajanHome(), "plans")];
32
32
  if (withSources) paths.push(projectDir);
33
33
  const watcher = chokidar.watch(paths, { ignoreInitial: true, persistent: true });