karajan-code 2.25.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 +8 -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 +47 -0
- package/src/commands/ollama.js +44 -0
- package/src/commands/onboard.js +10 -1
- package/src/commands/rag.js +9 -3
- package/src/rag/ollama-capability.js +59 -0
- package/src/rag/ollama-manager.js +98 -0
- package/src/utils/paths.js +5 -0
package/README.md
CHANGED
|
@@ -345,6 +345,14 @@ 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
|
+
>
|
|
348
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.
|
|
349
357
|
>
|
|
350
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.
|
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");
|
|
@@ -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`);
|
|
@@ -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");
|