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 +17 -0
- package/package.json +1 -1
- package/scripts/esbuild-sea.config.mjs +9 -0
- package/src/checks/ollama.js +41 -0
- package/src/cli/register-meta.js +1 -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 +17 -5
- package/src/rag/indexer.js +11 -6
- package/src/rag/ollama-capability.js +59 -0
- package/src/rag/ollama-manager.js +98 -0
- package/src/rag/retriever.js +36 -6
- package/src/rag/vec-store.js +28 -6
- package/src/utils/paths.js +5 -0
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
|
@@ -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
|
+
}
|
package/src/cli/register-meta.js
CHANGED
|
@@ -113,6 +113,7 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
113
113
|
.description("Run a semantic query against the indexed RAG corpus")
|
|
114
114
|
.option("--scope <scope>", "plans | code | onboarding | all (default: all)", "all")
|
|
115
115
|
.option("--top-k <n>", "Number of hits to return (default: 5)", "5")
|
|
116
|
+
.option("--project <slug>", "Filter by project slug. Pass 'all' to query across every indexed project. Default: cwd basename")
|
|
116
117
|
.option("--json", "Output the hits as JSON")
|
|
117
118
|
.action(async (text, flags) => {
|
|
118
119
|
await withConfig(pkgVersion, "rag-query", flags, async ({ config, logger }) => {
|
|
@@ -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
|
@@ -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(
|
|
60
|
+
if (flags.json) process.stdout.write(`${JSON.stringify({ hits: [], empty: true, topK, scope })}\n`);
|
|
47
61
|
return [];
|
|
48
62
|
}
|
|
49
|
-
const
|
|
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 {
|
package/src/rag/indexer.js
CHANGED
|
@@ -41,7 +41,7 @@ function chunksFor(path, kind) {
|
|
|
41
41
|
* fresh batch is written, so a second call against the same file
|
|
42
42
|
* leaves the store with exactly the latest chunks (no duplicates).
|
|
43
43
|
*/
|
|
44
|
-
export async function indexFile(path, { db, embedder, logger = console } = {}) {
|
|
44
|
+
export async function indexFile(path, { db, embedder, logger = console, project = null } = {}) {
|
|
45
45
|
if (!existsSync(path)) { logger.warn?.(`[rag-indexer] file not found: ${path}`); return { indexed: 0, failed: 0 }; }
|
|
46
46
|
const kind = detectKind(path);
|
|
47
47
|
if (!kind) { logger.warn?.(`[rag-indexer] unknown kind: ${path}`); return { indexed: 0, failed: 0 }; }
|
|
@@ -53,7 +53,7 @@ export async function indexFile(path, { db, embedder, logger = console } = {}) {
|
|
|
53
53
|
for (const ch of chunks) {
|
|
54
54
|
try {
|
|
55
55
|
const embedding = await embedder.embed(ch.text);
|
|
56
|
-
insertChunk(db, { source: path, kind: ch.metadata.kind || kind, text: ch.text, metadata: ch.metadata, embedding });
|
|
56
|
+
insertChunk(db, { source: path, kind: ch.metadata.kind || kind, text: ch.text, metadata: ch.metadata, embedding, project });
|
|
57
57
|
indexed += 1;
|
|
58
58
|
} catch (err) {
|
|
59
59
|
failed += 1;
|
|
@@ -95,20 +95,25 @@ export async function indexProject(projectDir, { db, embedder, karajanHome, logg
|
|
|
95
95
|
if (existsSync(planRoot)) {
|
|
96
96
|
const plans = await listFiles(planRoot, (p) => /plan-.*\.json$/.test(p));
|
|
97
97
|
for (const p of plans) {
|
|
98
|
-
const r = await indexFile(p, { db, embedder, logger });
|
|
98
|
+
const r = await indexFile(p, { db, embedder, logger, project: slug });
|
|
99
99
|
totals.indexed += r.indexed; totals.failed += r.failed; totals.files += 1;
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
102
|
const onboarding = join(karajanHome, ONBOARDING_DIR, `${slug}.md`);
|
|
103
103
|
if (existsSync(onboarding)) {
|
|
104
|
-
const r = await indexFile(onboarding, { db, embedder, logger });
|
|
104
|
+
const r = await indexFile(onboarding, { db, embedder, logger, project: slug });
|
|
105
105
|
totals.indexed += r.indexed; totals.failed += r.failed; totals.files += 1;
|
|
106
106
|
}
|
|
107
107
|
if (withSources) {
|
|
108
|
-
|
|
108
|
+
// KJC-BUG-0062: `.kj/worktrees/HU-*/` contains git worktrees Karajan spawns
|
|
109
|
+
// per HU during `kj run`; they hold complete copies of `src/` that
|
|
110
|
+
// duplicate every chunk and contaminate retrieval scores. `_diet` is the
|
|
111
|
+
// tests/_diet/ sandbox used by the test-diet audit harness — never user
|
|
112
|
+
// code. Skip both.
|
|
113
|
+
const SKIP = new Set(["node_modules", ".git", "dist", "build", "coverage", ".karajan", ".next", ".kj", "_diet"]);
|
|
109
114
|
const sources = await listFiles(projectDir, (p) => /\.(js|mjs|cjs|ts|tsx|jsx)$/.test(p) && !p.split("/").some((seg) => SKIP.has(seg)));
|
|
110
115
|
for (const s of sources) {
|
|
111
|
-
const r = await indexFile(s, { db, embedder, logger });
|
|
116
|
+
const r = await indexFile(s, { db, embedder, logger, project: slug });
|
|
112
117
|
totals.indexed += r.indexed; totals.failed += r.failed; totals.files += 1;
|
|
113
118
|
}
|
|
114
119
|
}
|
|
@@ -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/rag/retriever.js
CHANGED
|
@@ -10,6 +10,24 @@ import { searchSimilar } from "./vec-store.js";
|
|
|
10
10
|
// between equidistant chunks but won't reorder by big distance gaps.
|
|
11
11
|
const DEFAULT_KIND_BOOST = { plan: 0.05, onboarding: 0.03, code: 0 };
|
|
12
12
|
|
|
13
|
+
// KJC-TSK-0440 — asymmetric source/test boost. v2.26 smoke caught a
|
|
14
|
+
// systematic bias: queries in natural language ("how does X work") return
|
|
15
|
+
// tests/X.test.js ahead of src/X.js because tests carry more descriptive
|
|
16
|
+
// prose. When the query itself does NOT mention test-flavoured terms,
|
|
17
|
+
// give code chunks +0.05 — but only chunks whose source path is NOT a
|
|
18
|
+
// test file. Tests keep the baseline boost.
|
|
19
|
+
const SOURCE_BOOST_NON_TEST = 0.05;
|
|
20
|
+
const TEST_TERMS_RE = /\b(test|tests|spec|specs|expect|describe|it\(|jest|vitest|mocha)\b/i;
|
|
21
|
+
const TEST_PATH_RE = /[\\/](tests?|specs?|__tests__)[\\/]|\.test\.[jt]sx?$|\.spec\.[jt]sx?$/i;
|
|
22
|
+
|
|
23
|
+
function shouldBoostSources(queryText) {
|
|
24
|
+
return !TEST_TERMS_RE.test(queryText);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isTestPath(source) {
|
|
28
|
+
return TEST_PATH_RE.test(source || "");
|
|
29
|
+
}
|
|
30
|
+
|
|
13
31
|
/**
|
|
14
32
|
* Run a natural-language query against the indexed RAG corpus. Returns
|
|
15
33
|
* the top-K chunks ranked by adjusted distance (lower = closer).
|
|
@@ -25,7 +43,7 @@ const DEFAULT_KIND_BOOST = { plan: 0.05, onboarding: 0.03, code: 0 };
|
|
|
25
43
|
* @param {'plans'|'code'|'onboarding'|'all'} [opts.scope='all']
|
|
26
44
|
* @param {Object} [opts.kindBoost]
|
|
27
45
|
*/
|
|
28
|
-
export async function query(db, embedder, text, { topK = 5, scope = "all", kindBoost = DEFAULT_KIND_BOOST } = {}) {
|
|
46
|
+
export async function query(db, embedder, text, { topK = 5, scope = "all", kindBoost = DEFAULT_KIND_BOOST, project = null } = {}) {
|
|
29
47
|
if (!text || typeof text !== "string") throw new Error("query: text must be a non-empty string");
|
|
30
48
|
const queryEmbedding = await embedder.embed(text);
|
|
31
49
|
// Over-fetch (topK * 2) so the rerank step has room to reshuffle
|
|
@@ -33,15 +51,27 @@ export async function query(db, embedder, text, { topK = 5, scope = "all", kindB
|
|
|
33
51
|
// avoid loading the entire store on tiny topK calls.
|
|
34
52
|
const fetchK = Math.min(50, topK * 2);
|
|
35
53
|
const scopeKind = scope === "plans" ? "plan" : scope === "code" ? "code" : scope === "onboarding" ? "onboarding" : null;
|
|
36
|
-
|
|
54
|
+
// KJC-TSK-0438 — `project` filters by chunks.project_slug. null means
|
|
55
|
+
// no filter (pre-v2.27 behaviour). Caller's CLI defaults this to the
|
|
56
|
+
// basename of cwd; pass "all" through commands/rag.js to disable.
|
|
57
|
+
const raw = searchSimilar(db, queryEmbedding, fetchK, { kind: scopeKind, project });
|
|
37
58
|
if (raw.length === 0) return [];
|
|
38
59
|
// Adjust each chunk's distance by its kind boost. The vec store returns
|
|
39
60
|
// cosine distance (smaller = closer); subtracting the boost makes
|
|
40
61
|
// higher-priority kinds appear "closer" without altering the source
|
|
41
62
|
// truth in the DB.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
63
|
+
// KJC-TSK-0440 — apply the source-vs-test asymmetric boost only when
|
|
64
|
+
// the query itself is not test-flavoured. Test-flavoured queries keep
|
|
65
|
+
// the symmetric baseline so e.g. "vitest mock setup" still surfaces
|
|
66
|
+
// test files.
|
|
67
|
+
const boostSources = shouldBoostSources(text);
|
|
68
|
+
const reranked = raw.map((r) => {
|
|
69
|
+
const kindB = kindBoost[r.kind] || 0;
|
|
70
|
+
const sourceB = (boostSources && r.kind === "code" && !isTestPath(r.source)) ? SOURCE_BOOST_NON_TEST : 0;
|
|
71
|
+
return { ...r, score: r.distance - kindB - sourceB };
|
|
72
|
+
}).sort((a, b) => a.score - b.score).slice(0, topK);
|
|
46
73
|
return reranked;
|
|
47
74
|
}
|
|
75
|
+
|
|
76
|
+
// Exported for tests.
|
|
77
|
+
export { shouldBoostSources, isTestPath };
|
package/src/rag/vec-store.js
CHANGED
|
@@ -45,6 +45,14 @@ export function openVecStore({ dim = DEFAULT_DIM, path = dbPath() } = {}) {
|
|
|
45
45
|
CREATE INDEX IF NOT EXISTS chunks_by_source ON chunks(source);
|
|
46
46
|
CREATE INDEX IF NOT EXISTS chunks_by_kind ON chunks(kind);
|
|
47
47
|
`);
|
|
48
|
+
// KJC-TSK-0438 — Project isolation. project_slug = basename of projectDir
|
|
49
|
+
// normalized; chunks indexed before the migration carry NULL and remain
|
|
50
|
+
// queryable when `--project all` (or no slug detected at query time).
|
|
51
|
+
const cols = db.prepare("PRAGMA table_info(chunks)").all().map((c) => c.name);
|
|
52
|
+
if (!cols.includes("project_slug")) {
|
|
53
|
+
db.exec("ALTER TABLE chunks ADD COLUMN project_slug TEXT;");
|
|
54
|
+
db.exec("CREATE INDEX IF NOT EXISTS chunks_by_project ON chunks(project_slug);");
|
|
55
|
+
}
|
|
48
56
|
db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS vec_chunks USING vec0(embedding float[${dim}]);`);
|
|
49
57
|
return db;
|
|
50
58
|
}
|
|
@@ -54,13 +62,13 @@ export function openVecStore({ dim = DEFAULT_DIM, path = dbPath() } = {}) {
|
|
|
54
62
|
* because the virtual table rowid is set explicitly to the freshly
|
|
55
63
|
* minted chunks.id. Returns that id.
|
|
56
64
|
*/
|
|
57
|
-
export function insertChunk(db, { source, kind, text, metadata = null, embedding }) {
|
|
65
|
+
export function insertChunk(db, { source, kind, text, metadata = null, embedding, project = null }) {
|
|
58
66
|
if (!Array.isArray(embedding) && !ArrayBuffer.isView(embedding)) {
|
|
59
67
|
throw new Error("insertChunk: embedding must be an array or typed array of floats");
|
|
60
68
|
}
|
|
61
69
|
const meta = metadata == null ? null : (typeof metadata === "string" ? metadata : JSON.stringify(metadata));
|
|
62
|
-
const ins = db.prepare("INSERT INTO chunks (source, kind, text, metadata) VALUES (?, ?, ?, ?)");
|
|
63
|
-
const info = ins.run(source, kind, text, meta);
|
|
70
|
+
const ins = db.prepare("INSERT INTO chunks (source, kind, text, metadata, project_slug) VALUES (?, ?, ?, ?, ?)");
|
|
71
|
+
const info = ins.run(source, kind, text, meta, project);
|
|
64
72
|
// sqlite-vec's vec0 virtual table requires the rowid to arrive as a BigInt
|
|
65
73
|
// even for values that comfortably fit in a Number. Better-sqlite3 returns
|
|
66
74
|
// lastInsertRowid as BigInt only when safeIntegers mode is on; force it.
|
|
@@ -75,22 +83,36 @@ export function insertChunk(db, { source, kind, text, metadata = null, embedding
|
|
|
75
83
|
* text, metadata, distance }]` for the topK best matches; lower
|
|
76
84
|
* distance = closer. Returns `[]` when the store is empty.
|
|
77
85
|
*/
|
|
78
|
-
export function searchSimilar(db, embedding, topK = 5, { kind = null } = {}) {
|
|
86
|
+
export function searchSimilar(db, embedding, topK = 5, { kind = null, project = null } = {}) {
|
|
79
87
|
const buf = embedding instanceof Float32Array ? embedding : Float32Array.from(embedding);
|
|
80
88
|
const kindClause = kind ? "AND c.kind = ?" : "";
|
|
89
|
+
const projectClause = project ? "AND c.project_slug = ?" : "";
|
|
81
90
|
const sql = `
|
|
82
|
-
SELECT c.id, c.source, c.kind, c.text, c.metadata, v.distance
|
|
91
|
+
SELECT c.id, c.source, c.kind, c.text, c.metadata, c.project_slug, v.distance
|
|
83
92
|
FROM vec_chunks v
|
|
84
93
|
JOIN chunks c ON c.id = v.rowid
|
|
85
|
-
WHERE v.embedding MATCH ? AND k = ? ${kindClause}
|
|
94
|
+
WHERE v.embedding MATCH ? AND k = ? ${kindClause} ${projectClause}
|
|
86
95
|
ORDER BY v.distance
|
|
87
96
|
`;
|
|
88
97
|
const params = [Buffer.from(buf.buffer), topK];
|
|
89
98
|
if (kind) params.push(kind);
|
|
99
|
+
if (project) params.push(project);
|
|
90
100
|
const rows = db.prepare(sql).all(...params);
|
|
91
101
|
return rows.map((r) => ({ ...r, metadata: r.metadata ? safeParse(r.metadata) : null }));
|
|
92
102
|
}
|
|
93
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Normalize a projectDir into a stable slug used for project isolation.
|
|
106
|
+
* Basename → lowercased → non-alphanumeric stripped to dashes.
|
|
107
|
+
* KJC-TSK-0438.
|
|
108
|
+
*/
|
|
109
|
+
export function projectSlug(projectDir) {
|
|
110
|
+
if (!projectDir) return null;
|
|
111
|
+
const base = String(projectDir).replace(/\/+$/, "").split("/").pop();
|
|
112
|
+
if (!base) return null;
|
|
113
|
+
return base.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
114
|
+
}
|
|
115
|
+
|
|
94
116
|
function safeParse(s) { try { return JSON.parse(s); } catch { return s; } }
|
|
95
117
|
|
|
96
118
|
/**
|
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");
|