karajan-code 2.25.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 CHANGED
@@ -345,6 +345,23 @@ Opt out: set `telemetry: false` in `~/.karajan/kj.config.yml`
345
345
 
346
346
  ## Recent releases
347
347
 
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)).
358
+ >
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.
360
+ >
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.
362
+ >
363
+ > **Coming next from v2.26.0**: addressed in v2.27.0 above.
364
+ >
348
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.
349
366
  >
350
367
  > Camino B — `/kj-rag-query <text> [--scope <s>] [--top-k <n>]` slash command shipped by `kj init` to `.claude/commands/`. Thin wrapper over `kj rag query`; passes flags through, surfaces `empty:true` as a one-line hint, renders hits as background context rather than raw JSON. For Claude Code / Cursor instances loaded without MCP.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "2.25.0",
3
+ "version": "2.27.0",
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",
@@ -161,6 +161,15 @@ const ragStubPlugin = {
161
161
  openVecStore: notAvailable, OllamaEmbedder: notAvailable,
162
162
  indexFile: notAvailable, indexProject: notAvailable,
163
163
  query: notAvailable, chunkMarkdown: notAvailable, chunkPlan: notAvailable, chunkSource: notAvailable,
164
+ // KJC-TSK-0435 — Ollama Docker manager.
165
+ ollamaUp: notAvailable, ollamaDown: notAvailable, ensureComposeFile: notAvailable,
166
+ isOllamaReachable: notAvailable, findAvailableOllamaPort: notAvailable,
167
+ waitForOllamaReady: notAvailable, buildComposeTemplate: notAvailable, normalizeOllamaConfig: notAvailable,
168
+ // KJC-TSK-0436 — Ollama capability check + model pull.
169
+ checkDockerAvailable: notAvailable, checkRamCapacity: notAvailable,
170
+ checkOllamaCapability: notAvailable, pullOllamaModel: notAvailable,
171
+ // KJC-TSK-0438 — RAG project isolation.
172
+ projectSlug: notAvailable,
164
173
  default: notAvailable,
165
174
  };
166
175
  `,
@@ -0,0 +1,41 @@
1
+ // KJC-TSK-0437 — `kj doctor` check for the RAG embedder.
2
+ // Only fires when rag.preload.enabled === true; otherwise reports
3
+ // "Disabled in config" so doctor stays quiet on greenfield projects
4
+ // that never opted in to RAG.
5
+ import { isOllamaReachable } from "../rag/ollama-manager.js";
6
+ import { STRATEGY } from "./types.js";
7
+
8
+ function ragEnabled(config) {
9
+ return config?.rag?.preload?.enabled === true;
10
+ }
11
+ function ollamaHost(config) {
12
+ const port = config?.rag?.embedder?.port || 11434;
13
+ return config?.rag?.embedder?.host || `http://localhost:${port}`;
14
+ }
15
+
16
+ function createOllamaStatusCheck() {
17
+ return {
18
+ name: "ollama",
19
+ label: "Ollama (RAG embedder)",
20
+ strategy: STRATEGY.NONE,
21
+ async detect({ config }) {
22
+ if (!ragEnabled(config)) {
23
+ return { ok: true, severity: "info", detail: "Disabled in config (rag.preload.enabled=false)" };
24
+ }
25
+ const host = ollamaHost(config);
26
+ let reachable;
27
+ try { reachable = await isOllamaReachable(host); } catch { reachable = false; }
28
+ return {
29
+ ok: reachable,
30
+ severity: reachable ? "info" : "warn",
31
+ detail: reachable ? `Reachable at ${host}` : `Not reachable at ${host}`,
32
+ fix: reachable ? undefined : "Run `kj ollama start` to bootstrap the container, or `kj init --no-ollama` to opt out.",
33
+ extra: { host, reachable },
34
+ };
35
+ },
36
+ };
37
+ }
38
+
39
+ export function getOllamaChecks() {
40
+ return [createOllamaStatusCheck()];
41
+ }
@@ -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 }) => {
@@ -1,4 +1,5 @@
1
1
  import { initCommand } from "../commands/init.js";
2
+ import { ollamaStartCommand, ollamaStopCommand, ollamaStatusCommand, ollamaPullCommand } from "../commands/ollama.js";
2
3
  import { configCommand } from "../commands/config.js";
3
4
  import { codeCommand } from "../commands/code.js";
4
5
  import { reviewCommand } from "../commands/review.js";
@@ -25,6 +26,7 @@ export function registerPipeline(program, { pkgVersion }) {
25
26
  .option("--scaffold-ci", "Scaffold Karajan CI Gateway workflow files")
26
27
  .option("--global", "Save config to ~/.karajan/kj.config.yml (skip the scope wizard)")
27
28
  .option("--local", "Save config to ./.karajan/kj.config.yml (skip the scope wizard)")
29
+ .option("--no-ollama", "Skip the RAG embedder bootstrap (Ollama-in-Docker). Useful on modest hardware or when an external embedder will be wired manually")
28
30
  .action(async (flags) => {
29
31
  await withConfig(pkgVersion, "init", flags, async ({ config: _config, logger }) => {
30
32
  await initCommand({ logger, flags });
@@ -236,4 +238,19 @@ export function registerPipeline(program, { pkgVersion }) {
236
238
  .action(async (flags) => {
237
239
  await reportCommand(flags);
238
240
  });
241
+
242
+ // KJC-TSK-0437 — `kj ollama [start|stop|status|pull]`
243
+ const ollama = program.command("ollama").description("Manage the RAG embedder container (Ollama-in-Docker)");
244
+ ollama.command("start").description("Start Ollama container").action(async () => {
245
+ await withConfig(pkgVersion, "ollama:start", {}, async ({ config, logger }) => ollamaStartCommand({ config, logger }));
246
+ });
247
+ ollama.command("stop").description("Stop Ollama container").action(async () => {
248
+ await withConfig(pkgVersion, "ollama:stop", {}, async ({ config, logger }) => ollamaStopCommand({ config, logger }));
249
+ });
250
+ ollama.command("status").description("Report Ollama reachability + container").action(async () => {
251
+ await withConfig(pkgVersion, "ollama:status", {}, async ({ config, logger }) => ollamaStatusCommand({ config, logger }));
252
+ });
253
+ ollama.command("pull <model>").description("Pull a model into the Ollama container").action(async (model) => {
254
+ await withConfig(pkgVersion, "ollama:pull", {}, async ({ config, logger }) => ollamaPullCommand({ model, config, logger }));
255
+ });
239
256
  }
@@ -18,6 +18,7 @@ import { getSystemChecks } from "../checks/system.js";
18
18
  import { getConfigFileChecks } from "../checks/config-files.js";
19
19
  import { getBinaryChecks } from "../checks/binaries.js";
20
20
  import { getSonarChecks } from "../checks/sonar.js";
21
+ import { getOllamaChecks } from "../checks/ollama.js";
21
22
  import { getCiChecks } from "../checks/ci.js";
22
23
  import { getRtkChecks } from "../checks/rtk.js";
23
24
  import { getNodeChecks } from "../checks/node.js";
@@ -52,6 +53,7 @@ function buildChecks(config, { projectOnly = false } = {}) {
52
53
  ...getConfigFileChecks(),
53
54
  ...getBinaryChecks(),
54
55
  ...getSonarChecks(),
56
+ ...getOllamaChecks(),
55
57
  ...getPortChecks(),
56
58
  ...getTokenChecks(config),
57
59
  ...getMcpHealthChecks(),
@@ -3,6 +3,8 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { getConfigPath, getProjectConfigPath, loadConfig, writeConfig } from "../config.js";
5
5
  import { sonarUp, checkVmMaxMapCount } from "../sonar/manager.js";
6
+ import { ollamaUp, waitForOllamaReady, normalizeOllamaConfig } from "../rag/ollama-manager.js";
7
+ import { checkOllamaCapability, pullOllamaModel } from "../rag/ollama-capability.js";
6
8
  import { exists, ensureDir } from "../utils/fs.js";
7
9
  import { getKarajanHome } from "../utils/paths.js";
8
10
  import { detectAvailableAgents } from "../utils/agent-detect.js";
@@ -500,6 +502,50 @@ async function scaffoldCiGateway(config, flags, logger) {
500
502
  logger.info(" 4. Push the workflow files and enable 'kj run --enable-ci'");
501
503
  }
502
504
 
505
+ // KJC-TSK-0436 — RAG embedder bootstrap. Default-on, opt-out with
506
+ // `--no-ollama`. Skipped automatically when the host can't run it
507
+ // (no Docker, daemon down, < 4 GB free RAM) — a one-line warning
508
+ // tells the user how to wire an external embedder instead.
509
+ async function bootstrapOllama({ flags, config, logger, interactive }) {
510
+ // Commander maps `--no-ollama` to `flags.ollama=false`.
511
+ const skip = flags?.noOllama === true || flags?.ollama === false;
512
+ if (skip) {
513
+ logger.info("Ollama bootstrap skipped (--no-ollama). RAG embedder will be unavailable until you configure one.");
514
+ return;
515
+ }
516
+ const cap = await checkOllamaCapability();
517
+ if (!cap.capable) {
518
+ logger.warn(`Ollama bootstrap skipped: ${cap.reasons.join("; ")}.`);
519
+ logger.warn(" Wire an external embedder via `config.rag.embedder` or rerun `kj init` after fixing the cause.");
520
+ return;
521
+ }
522
+ const result = await ollamaUp(config?.rag?.embedder || null);
523
+ if (result.exitCode !== 0) {
524
+ logger.warn(`Ollama bootstrap failed: ${result.stderr || result.stdout}`);
525
+ return;
526
+ }
527
+ if (result.reusedHost) {
528
+ logger.info(`Reusing existing Ollama at ${result.reusedHost}.`);
529
+ } else {
530
+ logger.info("Ollama container started. Waiting for /api/tags...");
531
+ const ollamaCfg = normalizeOllamaConfig(config?.rag?.embedder || {});
532
+ const ready = await waitForOllamaReady(ollamaCfg.host, ollamaCfg.timeouts.readyMs);
533
+ if (!ready) {
534
+ logger.warn(`Ollama did not become ready within ${ollamaCfg.timeouts.readyMs} ms. Continuing — pull the model manually with \`kj ollama pull nomic-embed-text\`.`);
535
+ return;
536
+ }
537
+ }
538
+ const model = config?.rag?.embedder?.model || "nomic-embed-text";
539
+ logger.info(`Pulling embedder model ${model} (first run downloads ~270 MB)...`);
540
+ const pull = await pullOllamaModel(model, { containerName: config?.rag?.embedder?.container_name });
541
+ if (pull.exitCode === 0) {
542
+ logger.info(` -> ${model} ready. RAG embedder available.`);
543
+ if (interactive) logger.info(" -> Enable pre-loop retrieval with `yq -i '.rag.preload.enabled = true' " + getConfigPath() + "`");
544
+ } else {
545
+ logger.warn(`Model pull failed: ${pull.stderr || pull.stdout}. Retry with \`kj ollama pull ${model}\`.`);
546
+ }
547
+ }
548
+
503
549
  async function installSkills(logger, interactive) {
504
550
  const projectDir = process.cwd();
505
551
  const commandsDir = path.join(projectDir, ".claude", "commands");
@@ -636,6 +682,7 @@ export async function initCommand({ logger, flags = {} }) {
636
682
  await ensureCoderRules(coderRulesPath, logger);
637
683
  await ensureGitignoreEntries(process.cwd(), logger);
638
684
  await installSkills(logger, interactive);
685
+ await bootstrapOllama({ flags, config, logger, interactive });
639
686
 
640
687
  // Auto-detect project stack and preconfigure
641
688
  const stack = await detectProjectStack(process.cwd());
@@ -0,0 +1,44 @@
1
+ // KJC-TSK-0437 — `kj ollama [start|stop|status|pull <model>]` subcommand.
2
+ // Thin wrappers around src/rag/ollama-manager.js so the user can manage
3
+ // the RAG embedder lifecycle without touching docker compose.
4
+ import { ollamaUp, ollamaDown, isOllamaReachable, normalizeOllamaConfig } from "../rag/ollama-manager.js";
5
+ import { pullOllamaModel } from "../rag/ollama-capability.js";
6
+
7
+ function embedderCfg(config) {
8
+ return normalizeOllamaConfig(config?.rag?.embedder || {});
9
+ }
10
+
11
+ export async function ollamaStartCommand({ config, logger }) {
12
+ const result = await ollamaUp(config?.rag?.embedder || null);
13
+ if (result.exitCode !== 0) {
14
+ logger.warn(`ollama start failed: ${result.stderr || result.stdout}`);
15
+ return result;
16
+ }
17
+ logger.info(result.reusedHost ? `Reusing Ollama at ${result.reusedHost}` : "Ollama container started.");
18
+ return result;
19
+ }
20
+
21
+ export async function ollamaStopCommand({ config, logger }) {
22
+ const result = await ollamaDown(config?.rag?.embedder || null);
23
+ if (result.exitCode === 0) logger.info(result.stdout || "Ollama stopped.");
24
+ else logger.warn(`ollama stop failed: ${result.stderr || result.stdout}`);
25
+ return result;
26
+ }
27
+
28
+ export async function ollamaStatusCommand({ config, logger }) {
29
+ const cfg = embedderCfg(config);
30
+ const reachable = await isOllamaReachable(cfg.host, cfg.timeouts.healthcheckSeconds);
31
+ const status = { host: cfg.host, container: cfg.containerName, reachable };
32
+ if (reachable) logger.info(`Ollama reachable at ${cfg.host} (container ${cfg.containerName}).`);
33
+ else logger.warn(`Ollama not reachable at ${cfg.host}. Try \`kj ollama start\`.`);
34
+ return status;
35
+ }
36
+
37
+ export async function ollamaPullCommand({ model, config, logger }) {
38
+ if (!model) throw new Error("kj ollama pull: model argument required");
39
+ logger.info(`Pulling ${model} into ${embedderCfg(config).containerName}...`);
40
+ const result = await pullOllamaModel(model, { containerName: embedderCfg(config).containerName });
41
+ if (result.exitCode === 0) logger.info(` -> ${model} ready.`);
42
+ else logger.warn(`pull ${model} failed: ${result.stderr || result.stdout}`);
43
+ return result;
44
+ }
@@ -15,10 +15,14 @@ export function briefPath(projectDir) {
15
15
  export async function onboardCommand({ config, logger, flags = {} }) {
16
16
  const projectDir = config?.projectDir || process.cwd();
17
17
  const out = flags.output || briefPath(projectDir);
18
+ // KJC-BUG-0061 Bug A: Commander maps `--no-synth` to `flags.synth=false`,
19
+ // not `flags.noSynth=true`. Accept both shapes so callers from Commander
20
+ // and direct API callers behave the same.
21
+ const skipSynth = flags.noSynth === true || flags.synth === false;
18
22
  logger.info(`[onboard] collecting from ${projectDir}`);
19
23
  const bundle = await collectAll(projectDir);
20
24
  await mkdir(path.dirname(out), { recursive: true });
21
- if (flags.noSynth) {
25
+ if (skipSynth) {
22
26
  await writeFile(out, `# Onboarding bundle — ${projectDir}\n\n\`\`\`json\n${JSON.stringify(bundle, null, 2)}\n\`\`\`\n`, "utf8");
23
27
  logger.info(`[onboard] wrote raw bundle to ${out} (--no-synth)`);
24
28
  return { path: out, bundle, brief: null };
@@ -27,6 +31,11 @@ export async function onboardCommand({ config, logger, flags = {} }) {
27
31
  const templatePath = new URL("../../templates/roles/onboarder.md", import.meta.url).pathname;
28
32
  const instructions = existsSync(templatePath) ? await readFile(templatePath, "utf8") : "";
29
33
  const role = new OnboarderRole({ instructions, config, ...roleCfg });
34
+ // KJC-BUG-0061 Bug B: BaseRole.run() refuses to execute unless init()
35
+ // has populated the agent / template context. The plan-generate path
36
+ // already does this; the onboard command was relying on a code path
37
+ // that never ran in isolation.
38
+ await role.init({ projectDir });
30
39
  const result = await role.run({ bundle });
31
40
  const brief = result?.result?.brief || "# Project is greenfield\n\nNo synth output.\n";
32
41
  await writeFile(out, brief, "utf8");
@@ -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";
@@ -41,14 +41,26 @@ export async function ragQueryCommand({ text, config, logger, flags = {} }) {
41
41
  if (!text) throw new Error("kj rag query: text argument required");
42
42
  const db = openDb(config);
43
43
  try {
44
+ const topK = Math.max(1, Number(flags.topK) || 5);
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);
52
+ // KJC-BUG-0061 follow-up: align the CLI `--json` shape with the MCP
53
+ // handler. The MCP tool responds `{ hits: [], empty: true, topK, scope }`
54
+ // so agents (and the `/kj-rag-query` skill from Camino B) have a
55
+ // deterministic recovery signal. The CLI was emitting just `[]`, which
56
+ // is indistinguishable from "query returned zero hits over a populated
57
+ // store" and forced consumers to parse stderr.
44
58
  if (countChunks(db) === 0) {
45
59
  logger.warn("[rag] No chunks indexed yet. Run 'kj rag index' first.");
46
- if (flags.json) process.stdout.write("[]\n");
60
+ if (flags.json) process.stdout.write(`${JSON.stringify({ hits: [], empty: true, topK, scope })}\n`);
47
61
  return [];
48
62
  }
49
- const topK = Math.max(1, Number(flags.topK) || 5);
50
- const scope = flags.scope || "all";
51
- const hits = await query(db, makeEmbedder(config), text, { topK, scope });
63
+ const hits = await query(db, makeEmbedder(config), text, { topK, scope, project });
52
64
  if (flags.json) {
53
65
  process.stdout.write(`${JSON.stringify(hits)}\n`);
54
66
  } else {
@@ -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
- const SKIP = new Set(["node_modules", ".git", "dist", "build", "coverage", ".karajan", ".next"]);
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
  }
@@ -0,0 +1,59 @@
1
+ // KJC-TSK-0436 (RAG Auto-Bootstrap PR2) — Capability check for Ollama
2
+ // bootstrap. Returns whether the host can reasonably run a local Ollama
3
+ // container: Docker installed, daemon reachable, and enough RAM
4
+ // (default >= 4 GB free). The caller (kj init) decides whether to
5
+ // bootstrap, warn, or skip.
6
+ import os from "node:os";
7
+ import { runCommand } from "../utils/process.js";
8
+
9
+ const DEFAULT_MIN_RAM_BYTES = 4 * 1024 * 1024 * 1024; // 4 GB free
10
+ const DEFAULT_TIMEOUT_MS = 5000;
11
+
12
+ export async function checkDockerAvailable(timeoutMs = DEFAULT_TIMEOUT_MS) {
13
+ const v = await runCommand("docker", ["--version"], { timeout: timeoutMs }).catch(() => ({ exitCode: 127 }));
14
+ if (v.exitCode !== 0) return { ok: false, reason: "docker:not-installed" };
15
+ const info = await runCommand("docker", ["info"], { timeout: timeoutMs }).catch(() => ({ exitCode: 1 }));
16
+ if (info.exitCode !== 0) return { ok: false, reason: "docker:daemon-unreachable" };
17
+ return { ok: true };
18
+ }
19
+
20
+ export function checkRamCapacity(minBytes = DEFAULT_MIN_RAM_BYTES) {
21
+ const free = os.freemem();
22
+ const total = os.totalmem();
23
+ if (free < minBytes) {
24
+ return {
25
+ ok: false,
26
+ reason: `ram:insufficient (${formatGB(free)} free < ${formatGB(minBytes)} required)`,
27
+ freeBytes: free,
28
+ totalBytes: total,
29
+ };
30
+ }
31
+ return { ok: true, freeBytes: free, totalBytes: total };
32
+ }
33
+
34
+ /**
35
+ * Combined capability check. Returns `{ capable, reasons[] }`.
36
+ * Caller decides what to do (bootstrap, warn, skip).
37
+ */
38
+ export async function checkOllamaCapability({ minRamBytes = DEFAULT_MIN_RAM_BYTES } = {}) {
39
+ const reasons = [];
40
+ const docker = await checkDockerAvailable();
41
+ if (!docker.ok) reasons.push(docker.reason);
42
+ const ram = checkRamCapacity(minRamBytes);
43
+ if (!ram.ok) reasons.push(ram.reason);
44
+ return { capable: reasons.length === 0, reasons, docker, ram };
45
+ }
46
+
47
+ function formatGB(bytes) {
48
+ return `${(bytes / (1024 ** 3)).toFixed(1)} GB`;
49
+ }
50
+
51
+ /**
52
+ * Pull a model into the Ollama container. Uses `docker exec` so we do
53
+ * not depend on the host having `ollama` CLI installed. Wired by `kj
54
+ * init` after `ollamaUp`, and by `kj ollama pull <model>` (PR3).
55
+ */
56
+ export async function pullOllamaModel(model, { containerName = "kj-ollama", timeoutMs = 10 * 60 * 1000 } = {}) {
57
+ if (!model || typeof model !== "string") throw new Error("pullOllamaModel: model is required");
58
+ return runCommand("docker", ["exec", containerName, "ollama", "pull", model], { timeout: timeoutMs });
59
+ }
@@ -0,0 +1,98 @@
1
+ // KJC-TSK-0435 (RAG Auto-Bootstrap PR1) — Ollama-in-Docker manager.
2
+ // Mirrors src/sonar/manager.js: write docker-compose, start/stop the
3
+ // container, discover a free port, wait for readiness. Next PR wires
4
+ // it to `kj init`.
5
+ import fs from "node:fs/promises";
6
+ import net from "node:net";
7
+ import { ensureDir } from "../utils/fs.js";
8
+ import { runCommand } from "../utils/process.js";
9
+ import { getKarajanHome, getOllamaComposePath } from "../utils/paths.js";
10
+
11
+ const DEFAULTS = { port: 11434, hcS: 5, upMs: 300000, ctrlMs: 120000, readyMs: 90000 };
12
+ const pos = (v, d) => (Number(v) > 0 ? Number(v) : d);
13
+
14
+ export function normalizeOllamaConfig(ollama = {}) {
15
+ const t = ollama.timeouts || {};
16
+ const port = Number(ollama.port) || DEFAULTS.port;
17
+ return {
18
+ host: ollama.host || `http://localhost:${port}`,
19
+ port,
20
+ external: ollama.external === true,
21
+ containerName: ollama.container_name || "kj-ollama",
22
+ volume: ollama.volume || "kj_ollama_data",
23
+ timeouts: {
24
+ healthcheckSeconds: pos(t.healthcheck_seconds, DEFAULTS.hcS),
25
+ composeUpMs: pos(t.compose_up_ms, DEFAULTS.upMs),
26
+ composeControlMs: pos(t.compose_control_ms, DEFAULTS.ctrlMs),
27
+ readyMs: pos(t.ready_ms, DEFAULTS.readyMs),
28
+ },
29
+ };
30
+ }
31
+
32
+ export function buildComposeTemplate(cfg) {
33
+ return `services:
34
+ ollama:
35
+ image: ollama/ollama:latest
36
+ container_name: ${cfg.containerName}
37
+ ports:
38
+ - "${cfg.port}:11434"
39
+ volumes:
40
+ - ${cfg.volume}:/root/.ollama
41
+ restart: unless-stopped
42
+
43
+ volumes:
44
+ ${cfg.volume}:
45
+ `;
46
+ }
47
+
48
+ export async function ensureComposeFile(ollama = null) {
49
+ const cfg = normalizeOllamaConfig(ollama || {});
50
+ await ensureDir(getKarajanHome());
51
+ await fs.writeFile(getOllamaComposePath(), buildComposeTemplate(cfg), "utf8");
52
+ return getOllamaComposePath();
53
+ }
54
+
55
+ export async function isOllamaReachable(host, healthcheckSeconds = DEFAULTS.hcS) {
56
+ const t = String(pos(healthcheckSeconds, DEFAULTS.hcS));
57
+ const r = await runCommand("curl", ["-s", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", t, `${host}/api/tags`]);
58
+ return r.exitCode === 0 && r.stdout.trim().startsWith("2");
59
+ }
60
+
61
+ export async function findAvailableOllamaPort(start = DEFAULTS.port, span = 10) {
62
+ for (let p = start; p < start + span; p++) {
63
+ const free = await new Promise((res) => {
64
+ const s = net.createServer();
65
+ s.once("error", () => res(false));
66
+ s.once("listening", () => s.close(() => res(true)));
67
+ s.listen(p, "127.0.0.1");
68
+ });
69
+ if (free) return p;
70
+ }
71
+ throw new Error(`No free port in [${start}, ${start + span})`);
72
+ }
73
+
74
+ export async function waitForOllamaReady(host, timeoutMs = DEFAULTS.readyMs) {
75
+ const dl = Date.now() + timeoutMs;
76
+ while (Date.now() < dl) {
77
+ if (await isOllamaReachable(host, 2)) return true;
78
+ await new Promise((r) => setTimeout(r, 1500));
79
+ }
80
+ return false;
81
+ }
82
+
83
+ export async function ollamaUp(ollama = null) {
84
+ const cfg = normalizeOllamaConfig(ollama || {});
85
+ if (await isOllamaReachable(cfg.host, cfg.timeouts.healthcheckSeconds)) {
86
+ return { exitCode: 0, stdout: `Ollama already reachable at ${cfg.host}, skipping container start.`, stderr: "", reusedHost: cfg.host };
87
+ }
88
+ if (cfg.external) return { exitCode: 1, stdout: "", stderr: `Configured external Ollama is not reachable at ${cfg.host}.` };
89
+ const compose = await ensureComposeFile(ollama);
90
+ return runCommand("docker", ["compose", "-f", compose, "up", "-d"], { timeout: cfg.timeouts.composeUpMs });
91
+ }
92
+
93
+ export async function ollamaDown(ollama = null) {
94
+ const cfg = normalizeOllamaConfig(ollama || {});
95
+ if (cfg.external) return { exitCode: 0, stdout: "ollama.external=true, skipping Docker stop.", stderr: "" };
96
+ const compose = await ensureComposeFile(ollama);
97
+ return runCommand("docker", ["compose", "-f", compose, "stop"], { timeout: cfg.timeouts.composeControlMs });
98
+ }
@@ -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
- const raw = searchSimilar(db, queryEmbedding, fetchK, { kind: scopeKind });
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
- const reranked = raw.map((r) => ({
43
- ...r,
44
- score: r.distance - (kindBoost[r.kind] || 0),
45
- })).sort((a, b) => a.score - b.score).slice(0, topK);
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 };
@@ -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
  /**
@@ -90,6 +90,11 @@ export function getSonarComposePath() {
90
90
  return path.join(getKarajanHome(), "docker-compose.sonar.yml");
91
91
  }
92
92
 
93
+ /** Ollama RAG embedder compose: `<karajan-home>/docker-compose.ollama.yml` — KJC-TSK-0435. */
94
+ export function getOllamaComposePath() {
95
+ return path.join(getKarajanHome(), "docker-compose.ollama.yml");
96
+ }
97
+
93
98
  /** Webperf cache: `<karajan-home>/webperf/` — KJC-TSK-0420. */
94
99
  export function getWebperfDir() {
95
100
  return path.join(getKarajanHome(), "webperf");