karajan-code 2.24.0 → 2.26.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 +16 -0
- package/package.json +1 -1
- package/scripts/esbuild-sea.config.mjs +7 -0
- package/src/checks/ollama.js +41 -0
- package/src/cli/register-pipeline.js +17 -0
- package/src/commands/doctor.js +2 -0
- package/src/commands/init.js +48 -1
- package/src/commands/ollama.js +44 -0
- package/src/commands/onboard.js +10 -1
- package/src/commands/rag.js +9 -3
- package/src/orchestrator/drivers/pre-loop.js +19 -4
- package/src/orchestrator/stages/rag-preload-decisor.js +58 -0
- package/src/rag/ollama-capability.js +59 -0
- package/src/rag/ollama-manager.js +98 -0
- package/src/utils/paths.js +5 -0
- package/templates/skills/kj-rag-query.md +50 -0
package/README.md
CHANGED
|
@@ -345,6 +345,22 @@ Opt out: set `telemetry: false` in `~/.karajan/kj.config.yml`
|
|
|
345
345
|
|
|
346
346
|
## Recent releases
|
|
347
347
|
|
|
348
|
+
> **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.
|
|
349
|
+
>
|
|
350
|
+
> 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
|
+
>
|
|
352
|
+
> 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
|
+
>
|
|
354
|
+
> **Coming in v2.27.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).
|
|
355
|
+
>
|
|
356
|
+
> **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
|
+
>
|
|
358
|
+
> 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.
|
|
359
|
+
>
|
|
360
|
+
> Camino D — Brain decisor heuristic in `src/orchestrator/stages/rag-preload-decisor.js`. New `config.rag.preload.policy`: `always` (v2.24.0 behaviour, kept for back-compat), `never` (benchmarking), `auto` (default). In `auto` mode, retrieval fires when triage decomposes, level ∈ {complex, high, epic}, task body ≥ 200 chars, or `config.rag.preload.brownfield` is set. Otherwise the stage persists `{ skipped: true, reason: 'auto:low-value' }` and the pipeline pays no retrieval cost on trivial tasks.
|
|
361
|
+
>
|
|
362
|
+
> **Coming in v2.26.0+**: chokidar watcher for live re-indexing, AST source chunker (tree-sitter / `@babel/parser`), BM25 + cosine hybrid scoring.
|
|
363
|
+
>
|
|
348
364
|
> **v2.24.0 released** — Minor. **RAG Camino C — pre-loop auto-retrieval** (KJC-PCS-0049). After v2.23.0 taught the agents that `kj_rag_query` exists, Karajan now injects prior context for them automatically: a new pre-loop stage queries the vec store with the task description and prepends the top-K chunks to the task before any LLM call.
|
|
349
365
|
>
|
|
350
366
|
> Opt-in: `config.rag.preload.enabled = false` by default. Five guards before the retrieval fires (`disabled`, `no-task`, empty corpus, no hits, error). The stage never throws — best-effort enrichment, opt-out by default, opt-back-out on failure. When all guards pass, the task receives an extra block:
|
package/package.json
CHANGED
|
@@ -161,6 +161,13 @@ 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,
|
|
164
171
|
default: notAvailable,
|
|
165
172
|
};
|
|
166
173
|
`,
|
|
@@ -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
|
+
}
|
|
@@ -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
|
}
|
package/src/commands/doctor.js
CHANGED
|
@@ -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(),
|
package/src/commands/init.js
CHANGED
|
@@ -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");
|
|
@@ -544,7 +590,7 @@ async function installSkills(logger, interactive) {
|
|
|
544
590
|
|
|
545
591
|
if (installed > 0) {
|
|
546
592
|
logger.info(`Installed ${installed} Karajan skill(s) in .claude/commands/`);
|
|
547
|
-
logger.info("Available as slash commands: /kj-run, /kj-code, /kj-review, /kj-test, /kj-security, /kj-discover, /kj-architect, /kj-sonar");
|
|
593
|
+
logger.info("Available as slash commands: /kj-run, /kj-code, /kj-review, /kj-test, /kj-security, /kj-discover, /kj-architect, /kj-sonar, /kj-rag-query");
|
|
548
594
|
} else {
|
|
549
595
|
logger.info("All skills already installed.");
|
|
550
596
|
}
|
|
@@ -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
|
+
}
|
package/src/commands/onboard.js
CHANGED
|
@@ -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 (
|
|
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");
|
package/src/commands/rag.js
CHANGED
|
@@ -41,13 +41,19 @@ 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-BUG-0061 follow-up: align the CLI `--json` shape with the MCP
|
|
47
|
+
// handler. The MCP tool responds `{ hits: [], empty: true, topK, scope }`
|
|
48
|
+
// so agents (and the `/kj-rag-query` skill from Camino B) have a
|
|
49
|
+
// deterministic recovery signal. The CLI was emitting just `[]`, which
|
|
50
|
+
// is indistinguishable from "query returned zero hits over a populated
|
|
51
|
+
// store" and forced consumers to parse stderr.
|
|
44
52
|
if (countChunks(db) === 0) {
|
|
45
53
|
logger.warn("[rag] No chunks indexed yet. Run 'kj rag index' first.");
|
|
46
|
-
if (flags.json) process.stdout.write(
|
|
54
|
+
if (flags.json) process.stdout.write(`${JSON.stringify({ hits: [], empty: true, topK, scope })}\n`);
|
|
47
55
|
return [];
|
|
48
56
|
}
|
|
49
|
-
const topK = Math.max(1, Number(flags.topK) || 5);
|
|
50
|
-
const scope = flags.scope || "all";
|
|
51
57
|
const hits = await query(db, makeEmbedder(config), text, { topK, scope });
|
|
52
58
|
if (flags.json) {
|
|
53
59
|
process.stdout.write(`${JSON.stringify(hits)}\n`);
|
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
runDiscoverStage, runHuReviewerStage,
|
|
42
42
|
} from "../pre-loop-stages.js";
|
|
43
43
|
import { runRagContextStage } from "../stages/rag-context-stage.js";
|
|
44
|
+
import { shouldPreloadRag } from "../stages/rag-preload-decisor.js";
|
|
44
45
|
// TSK-0336: triage goes through the StageRegistry / StageExecutor contract
|
|
45
46
|
// so canRun / execute / onFailure are actually exercised in production.
|
|
46
47
|
// The registry is a singleton with TriageStage / CoderStage / ReviewerStage
|
|
@@ -131,10 +132,24 @@ export async function runPreLoopStages({ config, logger, emitter, eventBase, ses
|
|
|
131
132
|
// all see prior context without having to call kj_rag_query themselves.
|
|
132
133
|
// Empty store / network / etc. degrade to no-op via the stage itself.
|
|
133
134
|
if (!resumeSkip("ragPreload")) {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
135
|
+
// KJC-TSK-0434 (Camino D): Brain decisor heuristic — skip retrieval
|
|
136
|
+
// when triage + task shape say it would be low-value. Defaults to
|
|
137
|
+
// policy=auto; `always`/`never` are escape hatches.
|
|
138
|
+
const decision = shouldPreloadRag({ triage: triageResult.stageResult, task, config });
|
|
139
|
+
if (!decision.pull) {
|
|
140
|
+
await persistStage("ragPreload", { skipped: true, reason: decision.reason });
|
|
141
|
+
} else {
|
|
142
|
+
const ragRes = await runRagContextStage({ config, logger, emitter, eventBase, task });
|
|
143
|
+
if (!ragRes.skipped && ragRes.contextBlock) {
|
|
144
|
+
task = `${task}\n\n${ragRes.contextBlock}`;
|
|
145
|
+
await persistStage("ragPreload", {
|
|
146
|
+
hits: ragRes.hits.length,
|
|
147
|
+
scope: config?.rag?.preload?.scope || "all",
|
|
148
|
+
decisor: decision.reason,
|
|
149
|
+
});
|
|
150
|
+
} else {
|
|
151
|
+
await persistStage("ragPreload", { skipped: true, reason: ragRes.reason, decisor: decision.reason });
|
|
152
|
+
}
|
|
138
153
|
}
|
|
139
154
|
}
|
|
140
155
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// KJC-TSK-0434 — RAG Camino D: Brain decisor heuristic for retrieval.
|
|
2
|
+
// Decides WHETHER to pay the cost of running the RAG preload stage based
|
|
3
|
+
// on signals from triage + task shape, instead of pre-fetching every run.
|
|
4
|
+
//
|
|
5
|
+
// Returns `{ pull: boolean, reason: string }`. The pre-loop driver
|
|
6
|
+
// skips `runRagContextStage` when `pull === false`.
|
|
7
|
+
//
|
|
8
|
+
// Policies (config.rag.preload.policy):
|
|
9
|
+
// - "always" : pull regardless (back-compat with v2.24.0 behaviour)
|
|
10
|
+
// - "never" : never pull (useful for benchmarking, debugging)
|
|
11
|
+
// - "auto" : heuristic — pull only when triage signals complexity,
|
|
12
|
+
// decomposition, or when the task body is long enough
|
|
13
|
+
// to suggest non-trivial scope. Default.
|
|
14
|
+
|
|
15
|
+
const DEFAULT_POLICY = "auto";
|
|
16
|
+
const COMPLEX_LEVELS = new Set(["complex", "high", "epic"]);
|
|
17
|
+
const LONG_TASK_CHARS = 200;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {object} params
|
|
21
|
+
* @param {object} [params.triage] - triage stage result (may be null)
|
|
22
|
+
* @param {string} [params.task] - the user task text
|
|
23
|
+
* @param {object} [params.config] - full karajan config
|
|
24
|
+
* @returns {{ pull: boolean, reason: string }}
|
|
25
|
+
*/
|
|
26
|
+
export function shouldPreloadRag({ triage, task, config } = {}) {
|
|
27
|
+
const policy = config?.rag?.preload?.policy || DEFAULT_POLICY;
|
|
28
|
+
|
|
29
|
+
if (policy === "always") return { pull: true, reason: "policy:always" };
|
|
30
|
+
if (policy === "never") return { pull: false, reason: "policy:never" };
|
|
31
|
+
|
|
32
|
+
// policy === "auto" — apply heuristic.
|
|
33
|
+
// Brownfield is an explicit user signal — if onboarding produced a
|
|
34
|
+
// brief and config marks the project as brownfield, prior context is
|
|
35
|
+
// almost always worth pulling.
|
|
36
|
+
if (config?.rag?.preload?.brownfield === true) {
|
|
37
|
+
return { pull: true, reason: "auto:brownfield" };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Triage said this is big enough to decompose → multi-HU run benefits
|
|
41
|
+
// from prior context fed to researcher/architect/planner.
|
|
42
|
+
if (triage?.shouldDecompose === true) {
|
|
43
|
+
return { pull: true, reason: "auto:decompose" };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Triage classified the task as complex / high-effort.
|
|
47
|
+
if (triage?.level && COMPLEX_LEVELS.has(String(triage.level).toLowerCase())) {
|
|
48
|
+
return { pull: true, reason: "auto:complex" };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Long task body usually means a brief with context, motivation and
|
|
52
|
+
// non-trivial scope — worth checking the RAG.
|
|
53
|
+
if (typeof task === "string" && task.length >= LONG_TASK_CHARS) {
|
|
54
|
+
return { pull: true, reason: "auto:long-task" };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { pull: false, reason: "auto:low-value" };
|
|
58
|
+
}
|
|
@@ -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
|
+
}
|
package/src/utils/paths.js
CHANGED
|
@@ -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");
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# kj-rag-query — Query Project RAG index
|
|
2
|
+
|
|
3
|
+
Query the Project RAG (vector store + embedder) and return the top-K most relevant chunks of the indexed project: plans, onboarding briefs, and (optionally) source code.
|
|
4
|
+
|
|
5
|
+
Use this when you need prior context about the project before deciding, refactoring, planning, or coding — without scanning the filesystem manually.
|
|
6
|
+
|
|
7
|
+
## Your task
|
|
8
|
+
|
|
9
|
+
$ARGUMENTS
|
|
10
|
+
|
|
11
|
+
## How it works
|
|
12
|
+
|
|
13
|
+
This skill is a thin wrapper over the Karajan CLI:
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
kj rag query "<text>" [--scope <all|plans|onboarding|code>] [--top-k <n>] [--json]
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Parse `$ARGUMENTS` as the natural-language query plus optional flags:
|
|
20
|
+
|
|
21
|
+
- `--scope <s>`: restrict retrieval to one source bucket. Default: `all`.
|
|
22
|
+
- `plans` → indexed plan JSON files (HU titles, scope, blocked_by).
|
|
23
|
+
- `onboarding` → onboarding briefs from `~/.karajan/onboarding/`.
|
|
24
|
+
- `code` → indexed source files (only present if user ran `kj rag index --with-sources`).
|
|
25
|
+
- `--top-k <n>`: number of chunks to return. Default: 3. Max recommended: 10.
|
|
26
|
+
- `--json`: emit raw JSON (use when piping into another tool).
|
|
27
|
+
|
|
28
|
+
If `$ARGUMENTS` is empty, print a one-line usage hint and stop.
|
|
29
|
+
|
|
30
|
+
## Behavior contract
|
|
31
|
+
|
|
32
|
+
1. **Empty store**: if the CLI replies with `empty: true`, do NOT block the conversation. Tell the user once: "RAG index is empty — run `kj rag index` to seed it" and continue without prior context.
|
|
33
|
+
2. **Zero hits**: if the store has chunks but no match scored above threshold, say "no relevant prior context" and continue.
|
|
34
|
+
3. **Hits returned**: render each as a short block — `[score] source — snippet` — and use them as background context for the user's actual question. Do NOT dump raw JSON unless `--json` was passed.
|
|
35
|
+
4. **Never re-validate flags**. Pass `--scope`/`--top-k` straight to the CLI; the CLI is the source of truth.
|
|
36
|
+
|
|
37
|
+
## Examples
|
|
38
|
+
|
|
39
|
+
User: `/kj-rag-query how does the retriever rank chunks`
|
|
40
|
+
→ `kj rag query "how does the retriever rank chunks"`
|
|
41
|
+
|
|
42
|
+
User: `/kj-rag-query auth flow --scope plans --top-k 5`
|
|
43
|
+
→ `kj rag query "auth flow" --scope plans --top-k 5`
|
|
44
|
+
|
|
45
|
+
User: `/kj-rag-query --scope onboarding architecture`
|
|
46
|
+
→ `kj rag query "architecture" --scope onboarding`
|
|
47
|
+
|
|
48
|
+
## Why this exists (Camino B)
|
|
49
|
+
|
|
50
|
+
Hosts that load Karajan as Skills (Claude Code without MCP, Cursor without MCP) cannot reach the `kj_rag_query` MCP tool. This skill bridges that gap with the same retrieval contract.
|