karajan-code 4.62.0 → 4.63.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.
@@ -14,6 +14,9 @@ import { chunkMarkdown, chunkPlan, chunkSource } from "./chunker.js";
14
14
  import { insertChunk, deleteChunksBySource, findChunkByHash, getEmbeddingsByIds } from "./vec-store.js";
15
15
  import { detectAdaptersForProject, getAllCodeExtensions } from "../lang/registry.js";
16
16
  import { indexableReason, listProjectFiles } from "./indexable.js";
17
+ import { EMBED_SCHEMA, embedText } from "./embed-header.js";
18
+ import { writeEmbedSchema } from "./embed-schema.js";
19
+ import { untrackedFiles } from "./worktree-files.js";
17
20
 
18
21
  // KJC-PCS-0052 PR-A — antes vivían dos consts hard-coded para JS aquí
19
22
  // (CODE_EXT_RE + SKIP_SEGMENTS). Ahora vienen del registry de adapters,
@@ -59,7 +62,7 @@ export const sourceKind = () => "code";
59
62
  * fresh batch is written, so a second call against the same file
60
63
  * leaves the store with exactly the latest chunks (no duplicates).
61
64
  */
62
- export async function indexFile(path, { db, embedder, logger = console, project = null, kind: declaredKind = null } = {}) {
65
+ export async function indexFile(path, { db, embedder, logger = console, project = null, kind: declaredKind = null, root = process.cwd() } = {}) {
63
66
  if (!existsSync(path)) { logger.warn?.(`[rag-indexer] file not found: ${path}`); return { indexed: 0, failed: 0 }; }
64
67
  const kind = declaredKind ?? detectKind(path);
65
68
  if (!kind) { logger.warn?.(`[rag-indexer] unknown kind: ${path}`); return { indexed: 0, failed: 0 }; }
@@ -75,11 +78,17 @@ export async function indexFile(path, { db, embedder, logger = console, project
75
78
  // the stored vector: a file whose every chunk duplicates another (CLAUDE.md
76
79
  // next to the AGENTS.md kj harden writes) was left with no rows at all, and
77
80
  // the coverage check reported it missing after every reindex.
78
- const contentHash = createHash("sha256").update(ch.text).digest("hex");
81
+ // KJC-TSK-1171 (RAG-B): the embedder reads the metadata header before the body, so a
82
+ // query by file, module or symbol name lands; the stored text stays the body alone.
83
+ // The dedup key is what the embedder read, header included, under the current schema: a
84
+ // vector is reused only when the same body sits under the same path, symbol and lines,
85
+ // never one embedded under another header or an older schema.
86
+ const embeddable = embedText(ch, { root });
87
+ const contentHash = createHash("sha256").update(`${EMBED_SCHEMA}\n${embeddable}`).digest("hex");
79
88
  try {
80
89
  const dup = findChunkByHash(db, contentHash, project);
81
90
  const stored = dup ? getEmbeddingsByIds(db, [dup.id]).get(Number(dup.id)) : null;
82
- const embedding = stored ?? await embedder.embed(ch.text);
91
+ const embedding = stored ?? await embedder.embed(embeddable);
83
92
  insertChunk(db, { source: path, kind: ch.metadata.kind || kind, text: ch.text, metadata: ch.metadata, embedding, project, contentHash });
84
93
  if (stored) skipped += 1; else indexed += 1;
85
94
  } catch (err) {
@@ -145,13 +154,13 @@ export async function indexProject(projectDir, { db, embedder, karajanHome, logg
145
154
  if (existsSync(planRoot)) {
146
155
  const plans = await listFiles(planRoot, (p) => /plan-.*\.json$/.test(p));
147
156
  for (const p of plans) {
148
- const r = await indexFile(p, { db, embedder, logger, project: slug });
157
+ const r = await indexFile(p, { db, embedder, logger, project: slug, root: projectDir });
149
158
  totals.indexed += r.indexed; totals.failed += r.failed; totals.files += 1;
150
159
  }
151
160
  }
152
161
  const onboarding = join(karajanHome, ONBOARDING_DIR, `${slug}.md`);
153
162
  if (existsSync(onboarding)) {
154
- const r = await indexFile(onboarding, { db, embedder, logger, project: slug });
163
+ const r = await indexFile(onboarding, { db, embedder, logger, project: slug, root: projectDir });
155
164
  totals.indexed += r.indexed; totals.failed += r.failed; totals.files += 1;
156
165
  }
157
166
  if (withSources) {
@@ -173,9 +182,13 @@ export async function indexProject(projectDir, { db, embedder, karajanHome, logg
173
182
  if (source.startsWith(prefix) && outOf(source) !== null) deleteChunksBySource(db, source);
174
183
  }
175
184
  for (const s of sources) {
176
- const r = await indexFile(s, { db, embedder, logger, project: slug, kind: sourceKind() });
185
+ const r = await indexFile(s, { db, embedder, logger, project: slug, kind: sourceKind(), root: projectDir });
177
186
  totals.indexed += r.indexed; totals.failed += r.failed; totals.files += 1;
178
187
  }
188
+ // KJC-TSK-1171: only a COMPLETE index of the sources carries the embed schema; a file the
189
+ // embedder failed on leaves the marker as it was, so the rebuild is asked for again.
190
+ if (totals.failed === 0) writeEmbedSchema(projectDir);
191
+ else logger.warn?.(`[rag-indexer] ${totals.failed} chunk(s) failed: the embed schema is not recorded, the index will be rebuilt again`);
179
192
  }
180
193
  return totals;
181
194
  }
@@ -187,21 +200,30 @@ export async function indexProject(projectDir, { db, embedder, karajanHome, logg
187
200
  * `head` lets callers persist via setLastIndexedCommit. Throws on diff failure
188
201
  * (shallow clone, unknown ref) so the CLI can fall back to a full reindex.
189
202
  */
190
- export async function indexProjectDelta(projectDir, { db, embedder, since, logger = console, exclude = [] } = {}) {
203
+ export async function indexProjectDelta(projectDir, { db, embedder, since, logger = console, exclude = [], worktree = false, reconcile = [] } = {}) {
191
204
  const totals = { indexed: 0, failed: 0, files: 0, deleted: 0, head: null };
192
205
  const slug = projectDir.split("/").pop()?.replace(/[^a-zA-Z0-9._-]/g, "-").toLowerCase() || "project";
193
206
  const { stdout: head } = await execa("git", ["-C", projectDir, "rev-parse", "HEAD"]);
194
207
  totals.head = head.trim();
195
- if (totals.head === since) return totals;
196
- const { stdout } = await execa("git", ["-C", projectDir, "diff", "--name-status", "-z", since, "HEAD"]);
208
+ if (totals.head === since && !worktree) return totals;
209
+ // KJC-TSK-1174 (RAG-A1): with `worktree`, the diff ends at the files on disk instead of HEAD,
210
+ // and untracked files join it, so what is being written is retrievable before it is committed.
211
+ const { stdout } = await execa("git", ["-C", projectDir, "diff", "--name-status", "-z", since, ...(worktree ? [] : ["HEAD"])]);
197
212
  const parts = stdout.split("\0").filter(Boolean);
198
213
  const ops = []; // { kind: 'del'|'idx', p }
199
- for (let i = 0; i < parts.length; i += 1) {
214
+ let i = 0;
215
+ while (i < parts.length) {
200
216
  const s = parts[i];
201
- if (s.startsWith("R")) { ops.push({ kind: "del", p: parts[++i] }, { kind: "idx", p: parts[++i] }); }
202
- else if (s === "D") { ops.push({ kind: "del", p: parts[++i] }); }
203
- else { ops.push({ kind: "idx", p: parts[++i] }); }
217
+ if (s.startsWith("R")) { ops.push({ kind: "del", p: parts[i + 1] }, { kind: "idx", p: parts[i + 2] }); i += 3; }
218
+ else if (s.startsWith("C")) { ops.push({ kind: "idx", p: parts[i + 2] }); i += 3; } // a copy keeps its source
219
+ else if (s === "D") { ops.push({ kind: "del", p: parts[i + 1] }); i += 2; }
220
+ else { ops.push({ kind: "idx", p: parts[i + 1] }); i += 2; }
204
221
  }
222
+ if (worktree) for (const p of await untrackedFiles(projectDir)) ops.push({ kind: "idx", p });
223
+ // Paths a previous refresh took from a dirty tree: on disk they are reindexed as they are now
224
+ // (reverted or still dirty), gone they are dropped. Each path is handled once.
225
+ const seen = new Set(ops.map((o) => o.p));
226
+ for (const p of reconcile) if (!seen.has(p)) ops.push({ kind: existsSync(join(projectDir, p)) ? "idx" : "del", p });
205
227
  const adapters = detectAdaptersForProject(projectDir);
206
228
  await prepareAdapters(adapters, { logger });
207
229
  for (const { kind, p } of ops) {
@@ -211,7 +233,7 @@ export async function indexProjectDelta(projectDir, { db, embedder, since, logge
211
233
  logger.info?.(`[rag-indexer] delta delete ${relative(process.cwd(), abs)}`);
212
234
  } else if (indexableReason(relative(projectDir, abs), abs, { exclude, projectDir }) === null) {
213
235
  // KJC-TSK-0891: the same criterion as the full walk, not a list of extensions.
214
- const r = await indexFile(abs, { db, embedder, logger, project: slug, kind: sourceKind() });
236
+ const r = await indexFile(abs, { db, embedder, logger, project: slug, kind: sourceKind(), root: projectDir });
215
237
  totals.indexed += r.indexed; totals.failed += r.failed; totals.files += 1;
216
238
  }
217
239
  }
@@ -0,0 +1,26 @@
1
+ // KJC-TSK-1174 (RAG-A1): what the working tree holds beyond HEAD, for the pre-query refresh.
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ import { execa } from "execa";
5
+ import { projectDbPath } from "./project-store.js";
6
+
7
+ /** Files git does not track yet and does not ignore: the ones a session has just created. */
8
+ export async function untrackedFiles(projectDir) {
9
+ const { stdout } = await execa("git", ["-C", projectDir, "ls-files", "--others", "--exclude-standard", "-z"]);
10
+ return stdout.split("\0").filter(Boolean);
11
+ }
12
+
13
+ // The dirty paths the last refresh indexed, next to the store. Reverted or deleted since, git no
14
+ // longer lists them, yet the index holds their discarded content: the next refresh reconciles them.
15
+ const notePath = (projectDir) => `${projectDbPath(projectDir)}.worktree-paths`;
16
+
17
+ export function readRefreshedPaths(projectDir) {
18
+ const file = notePath(projectDir);
19
+ return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : [];
20
+ }
21
+
22
+ export function writeRefreshedPaths(projectDir, paths) {
23
+ const file = notePath(projectDir);
24
+ mkdirSync(dirname(file), { recursive: true });
25
+ writeFileSync(file, JSON.stringify(paths), "utf8");
26
+ }