pi-context-engine 0.1.4 → 0.2.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,11 +14,13 @@ const require = createRequire(import.meta.url);
14
14
  const Parser: any = require("web-tree-sitter");
15
15
 
16
16
  const FUNCTION_TYPES = new Set([
17
- "function_definition", "function_declaration",
18
- "method_definition", "method_declaration",
19
- "arrow_function",
20
- "function_item",
21
- "local_function_statement",
17
+ "function_definition", "function_declaration", "generator_function_declaration",
18
+ "method_definition", "method_declaration", "generator_method",
19
+ "arrow_function", "function_expression",
20
+ "function_item", "local_function_statement",
21
+ // Constructors: a Java/C# class's constructor is a definition users search
22
+ // for by name; missing it left holes exactly like the generator gap.
23
+ "constructor_declaration", "compact_constructor_declaration",
22
24
  ]);
23
25
  const CLASS_TYPES = new Set([
24
26
  "class_definition", "class_declaration",
@@ -34,6 +36,12 @@ const IMPORT_TYPES = new Set([
34
36
  "using_directive",
35
37
  ]);
36
38
 
39
+ /** Child node types that carry a symbol name (any language we parse). */
40
+ const NAME_TYPES = new Set([
41
+ "identifier", "property_identifier", "type_identifier",
42
+ "field_identifier", "name",
43
+ ]);
44
+
37
45
  /** tree-sitter-wasms file stems, keyed by our language id. */
38
46
  const WASM_BY_LANGUAGE: Record<string, string> = {
39
47
  python: "tree-sitter-python",
@@ -60,6 +68,30 @@ interface TsNode {
60
68
  text: string;
61
69
  }
62
70
 
71
+ /** Budget for the R6 module chunk: embed/FTS cap is 5000 chars, leave room for the header. */
72
+ const MODULE_CHUNK_BUDGET_CHARS = 3000;
73
+
74
+ /** R12: default non-whitespace budget per ast-density chunk (cAST-style ~1200). */
75
+ export const DEFAULT_CHUNK_BUDGET_CHARS = 1200;
76
+
77
+ export interface ChunkingOptions {
78
+ /** R12: `legacy` (default) walks function/class nodes; `ast-density` tiles the file into budgeted, self-contained chunks. */
79
+ chunking?: "legacy" | "ast-density";
80
+ /** R12: non-whitespace char budget for `ast-density`. */
81
+ budgetChars?: number;
82
+ }
83
+
84
+ /** Internal build unit for ast-density tiling. */
85
+ interface DensityUnit {
86
+ start: number;
87
+ end: number;
88
+ text: string;
89
+ kind: ChunkType;
90
+ unit: string | null;
91
+ parent: string | null;
92
+ segment: number;
93
+ }
94
+
63
95
  let parserInitPromise: Promise<void> | undefined;
64
96
  const languageCache = new Map<string, Promise<unknown>>();
65
97
 
@@ -125,55 +157,170 @@ export class Chunker {
125
157
  }
126
158
  }
127
159
 
128
- chunkWithImports(source: string, filePath: string, language: string): { chunks: Chunk[]; imports: string[] } {
129
- const chunks = this.chunk(source, filePath, language);
130
- const imports = this.extractImports(source, language);
131
- return { chunks, imports };
160
+ chunkWithImports(
161
+ source: string,
162
+ filePath: string,
163
+ language: string,
164
+ options: ChunkingOptions = {},
165
+ ): { chunks: Chunk[]; imports: string[]; importSpecs: string[] } {
166
+ const chunks =
167
+ options.chunking === "ast-density"
168
+ ? this.chunkAstDensity(source, filePath, language, options.budgetChars ?? DEFAULT_CHUNK_BUDGET_CHARS)
169
+ : this.chunk(source, filePath, language);
170
+ const extracted = this.extractImports(source, language);
171
+ const imports = extracted.imports;
172
+ const importSpecs = extracted.rawSpecs;
173
+ // Always attempt the module chunk, even with zero children: a file of
174
+ // pure top-level code (config tables, scripts) still needs a chunk, and
175
+ // this makes the pipeline's whole-file fallback a rare safety net.
176
+ const moduleChunk = this.buildModuleChunk(source, filePath, language, chunks);
177
+ if (moduleChunk) chunks.push(moduleChunk);
178
+ return { chunks, imports, importSpecs };
132
179
  }
133
180
 
134
- private walk(node: TsNode, source: string, filePath: string, language: string, chunks: Chunk[]): void {
181
+ /**
182
+ * R6: one `module` chunk per AST file covering what the function/class walk
183
+ * leaves out — imports, top-level consts, type declarations, comments.
184
+ *
185
+ * Without it, module-level content (`DEFAULT_IGNORE = [...]`, config keys,
186
+ * prompt strings) was retrievable by NO path: eval queries about defaults
187
+ * scored exactly zero. Chunks carry `chunkType: "module"` which doubles as
188
+ * the parent marker: the retriever exempts module chunks from overlap
189
+ * dedupe (their span covers the whole file) and serves them as the
190
+ * small-to-big parent context of a hit.
191
+ */
192
+ private buildModuleChunk(source: string, filePath: string, language: string, children: Chunk[]): Chunk | null {
193
+ const lines = source.split("\n");
194
+ const covered = new Array<boolean>(lines.length).fill(false);
195
+ for (const c of children) {
196
+ for (let i = Math.max(0, c.startLine - 1); i < Math.min(lines.length, c.endLine); i++) covered[i] = true;
197
+ }
198
+ const uncovered = lines
199
+ .map((line, i) => ({ line, i }))
200
+ .filter(({ line, i }) => !covered[i] && line.trim().length > 0)
201
+ .map(({ line }) => line);
202
+
203
+ const defines = children
204
+ .map((c) => (typeof c.metadata["unit"] === "string" ? (c.metadata["unit"] as string) : null))
205
+ .filter((u): u is string => !!u && !u.includes("."));
206
+ const parts = [...uncovered];
207
+ if (defines.length > 0) parts.push(`defines: ${[...new Set(defines)].slice(0, 30).join(", ")}`);
208
+ if (parts.length === 0) return null;
209
+
210
+ let body = parts.join("\n");
211
+ if (body.length > MODULE_CHUNK_BUDGET_CHARS) {
212
+ // Keep the head: imports and first declarations carry the module shape.
213
+ let cut = 0;
214
+ let used = 0;
215
+ for (const part of parts) {
216
+ if (used + part.length + 1 > MODULE_CHUNK_BUDGET_CHARS) break;
217
+ used += part.length + 1;
218
+ cut++;
219
+ }
220
+ body = `${parts.slice(0, Math.max(1, cut)).join("\n")}\n…[module context truncated]`;
221
+ }
222
+
223
+ const endLine = Math.max(1, lines.length - (lines[lines.length - 1] === "" ? 1 : 0));
224
+ const id = sha256Hex(`${filePath}:module`).slice(0, 16);
225
+ const base = filePath.split("/").pop()!.replace(/\.[^.]+$/, "");
226
+ return makeChunk({
227
+ id,
228
+ content: body,
229
+ chunkType: "module",
230
+ filePath,
231
+ startLine: 1,
232
+ endLine,
233
+ language,
234
+ metadata: { unit: base, tier: "parent" },
235
+ });
236
+ }
237
+
238
+ private walk(node: TsNode, source: string, filePath: string, language: string, chunks: Chunk[], ancestors: string[] = []): void {
239
+ let unit: string | null = null;
135
240
  if (FUNCTION_TYPES.has(node.type)) {
136
- chunks.push(this.nodeToChunk(node, source, filePath, language, "function"));
241
+ unit = nodeUnit(node, ancestors);
242
+ chunks.push(this.nodeToChunk(node, source, filePath, language, "function", unit));
137
243
  } else if (CLASS_TYPES.has(node.type)) {
138
- chunks.push(this.nodeToChunk(node, source, filePath, language, "class"));
244
+ unit = nodeUnit(node, ancestors);
245
+ chunks.push(this.nodeToChunk(node, source, filePath, language, "class", unit));
139
246
  }
247
+ // Descendants inherit the enclosing chain: `PaymentGateway.refund`.
248
+ const name = unit?.split(".").pop();
249
+ const chain = name ? [...ancestors, name] : ancestors;
140
250
  for (const child of node.children) {
141
- this.walk(child, source, filePath, language, chunks);
251
+ this.walk(child, source, filePath, language, chunks, chain);
142
252
  }
143
253
  }
144
254
 
145
- private nodeToChunk(node: TsNode, source: string, filePath: string, language: string, chunkType: ChunkType): Chunk {
255
+ private nodeToChunk(node: TsNode, source: string, filePath: string, language: string, chunkType: ChunkType, unit: string | null = null): Chunk {
146
256
  // node.text is sliced correctly by the binding (UTF-8 aware).
147
257
  void source;
148
258
  const content = node.text;
149
259
  const startLine = node.startPosition.row + 1;
150
260
  const endLine = node.endPosition.row + 1;
151
261
  const id = sha256Hex(`${filePath}:${startLine}:${endLine}:${content.slice(0, 100)}`).slice(0, 16);
152
- return makeChunk({ id, content, chunkType, filePath, startLine, endLine, language });
262
+ return makeChunk({ id, content, chunkType, filePath, startLine, endLine, language, metadata: unit ? { unit } : {} });
153
263
  }
154
264
 
155
- private extractImports(source: string, language: string): string[] {
265
+ /**
266
+ * R12: cAST-style split-merge. Tiles the file into top-level segments,
267
+ * splits oversized ones at syntactic children (line windows only at
268
+ * leaves), then greedily merges small adjacent siblings up to the budget.
269
+ * Concatenating the returned chunks in order (excluding the module chunk)
270
+ * reproduces the source byte-for-byte.
271
+ */
272
+ private chunkAstDensity(source: string, filePath: string, language: string, budget: number): Chunk[] {
156
273
  const lang = syncLanguageCache.get(language);
157
- if (!lang) return [];
274
+ if (!lang) {
275
+ throw new Error(`language ${language} not warmed up — call await warmupLanguages() first`);
276
+ }
277
+ const parser = this.acquireParser();
278
+ try {
279
+ (parser as { setLanguage(l: unknown): void }).setLanguage(lang);
280
+ const tree = (parser as { parse(s: string): { rootNode: TsNode } }).parse(source);
281
+ const units = splitNode(tree.rootNode, source, budget, 0, source.length, null);
282
+ const merged = mergeUnits(units, source, budget);
283
+ const chunks: Chunk[] = merged.map((u, i) => {
284
+ const startLine = offsetToLine(source, u.start);
285
+ const endLine = offsetToLine(source, Math.max(u.start, u.end - 1));
286
+ const id = sha256Hex(`${filePath}:${u.start}:${u.end}:${i}:${u.text.slice(0, 100)}`).slice(0, 16);
287
+ const metadata: Record<string, unknown> = {};
288
+ if (u.unit) metadata["unit"] = u.unit;
289
+ if (u.parent) metadata["parent"] = u.parent;
290
+ if (u.segment > 0) metadata["segment"] = String(u.segment);
291
+ return makeChunk({ id, content: u.text, chunkType: u.kind, filePath, startLine, endLine, language, metadata });
292
+ });
293
+ return dedupeSpans(chunks);
294
+ } finally {
295
+ this.releaseParser(parser);
296
+ }
297
+ }
298
+
299
+ private extractImports(source: string, language: string): { imports: string[]; rawSpecs: string[] } {
300
+ const lang = syncLanguageCache.get(language);
301
+ if (!lang) return { imports: [], rawSpecs: [] };
158
302
  const parser = this.acquireParser();
159
303
  try {
160
304
  (parser as { setLanguage(l: unknown): void }).setLanguage(lang);
161
305
  const tree = (parser as { parse(s: string): { rootNode: TsNode } }).parse(source);
162
306
  const imports: string[] = [];
163
- this.walkImports(tree.rootNode, language, imports);
164
- return [...new Set(imports)];
307
+ const rawSpecs: string[] = [];
308
+ this.walkImports(tree.rootNode, language, imports, rawSpecs);
309
+ return { imports: [...new Set(imports)], rawSpecs: [...new Set(rawSpecs)] };
165
310
  } finally {
166
311
  this.releaseParser(parser);
167
312
  }
168
313
  }
169
314
 
170
- private walkImports(node: TsNode, language: string, imports: string[]): void {
315
+ private walkImports(node: TsNode, language: string, imports: string[], rawSpecs: string[]): void {
171
316
  if (IMPORT_TYPES.has(node.type)) {
172
317
  const module = parseImportModule(node, language);
173
318
  if (module) imports.push(module);
319
+ const raw = parseImportModuleRaw(node);
320
+ if (raw) rawSpecs.push(raw);
174
321
  }
175
322
  for (const child of node.children) {
176
- this.walkImports(child, language, imports);
323
+ this.walkImports(child, language, imports, rawSpecs);
177
324
  }
178
325
  }
179
326
  }
@@ -185,6 +332,42 @@ function firstChildOfType(node: TsNode, types: string[]): TsNode | null {
185
332
  return null;
186
333
  }
187
334
 
335
+ /**
336
+ * `Class.method` / `functionName` for a chunk, from the first name-bearing
337
+ * child plus the enclosing chain. Used by the R1 context header and by the
338
+ * symbol path in retrieval (exact identifier hits rank by name, not vibes).
339
+ *
340
+ * The name child is picked by TYPE priority, not document order: for a Java
341
+ * method with a reference-type return (`Result compute()`), the return type
342
+ * is a `type_identifier` that appears BEFORE the method name — picking the
343
+ * first name-bearing child mislabeled the unit as `Result` and broke exact
344
+ * symbol lookup. Field/property identifiers win, plain identifiers come
345
+ * next, type identifiers only as a last resort.
346
+ */
347
+ const NAME_TYPE_PRIORITY: Record<string, number> = {
348
+ field_identifier: 0,
349
+ property_identifier: 0,
350
+ identifier: 1,
351
+ name: 1,
352
+ type_identifier: 2,
353
+ };
354
+
355
+ function nodeUnit(node: TsNode, ancestors: string[]): string | null {
356
+ let best: { name: string; priority: number } | null = null;
357
+ for (const child of node.children) {
358
+ const priority = NAME_TYPE_PRIORITY[child.type];
359
+ if (priority === undefined) continue;
360
+ const text = child.text?.trim();
361
+ if (!text) continue;
362
+ if (!best || priority < best.priority) best = { name: text, priority };
363
+ }
364
+ const name = best?.name;
365
+ if (name && ancestors.length > 0) return [...ancestors, name].join(".");
366
+ if (name) return name;
367
+ if (ancestors.length > 0) return ancestors.join(".");
368
+ return null;
369
+ }
370
+
188
371
  function firstChild(node: TsNode): TsNode | null {
189
372
  return node.children[0] ?? null;
190
373
  }
@@ -201,6 +384,110 @@ function dedupeSpans(chunks: Chunk[]): Chunk[] {
201
384
  return out;
202
385
  }
203
386
 
387
+ // ── R12: ast-density helpers ─────────────────────────────────────────────────
388
+
389
+ function nonWsLength(text: string): number {
390
+ let n = 0;
391
+ for (const ch of text) if (!/\s/.test(ch)) n++;
392
+ return n;
393
+ }
394
+
395
+ function offsetToLine(source: string, offset: number): number {
396
+ let line = 1;
397
+ for (let i = 0; i < offset && i < source.length; i++) if (source[i] === "\n") line++;
398
+ return line;
399
+ }
400
+
401
+ function classifyNode(node: TsNode): ChunkType {
402
+ if (FUNCTION_TYPES.has(node.type)) return "function";
403
+ if (CLASS_TYPES.has(node.type)) return "class";
404
+ return "doc";
405
+ }
406
+
407
+ /**
408
+ * Tile [rangeStart, rangeEnd) of `source` at node children boundaries: the
409
+ * gap before each child is attached to that child (leading comments travel
410
+ * with the declaration they precede), so the units cover the range with no
411
+ * holes. Oversized children recurse; children without useful children fall
412
+ * back to line windows. The cAST insight: syntax boundaries first, character
413
+ * windows only where syntax runs out.
414
+ */
415
+ function splitNode(node: TsNode, source: string, budget: number, rangeStart: number, rangeEnd: number, parentUnit: string | null): DensityUnit[] {
416
+ const units: DensityUnit[] = [];
417
+ const kids = node.children;
418
+ if (kids.length === 0 || rangeEnd <= rangeStart) {
419
+ return splitByLines(source, rangeStart, rangeEnd, budget, parentUnit);
420
+ }
421
+ for (let i = 0; i < kids.length; i++) {
422
+ const kid = kids[i]!;
423
+ const segStart = i === 0 ? rangeStart : kids[i - 1]!.endIndex;
424
+ const segEnd = i === kids.length - 1 ? rangeEnd : kid.endIndex;
425
+ if (segEnd <= segStart) continue;
426
+ const text = source.slice(segStart, segEnd);
427
+ if (nonWsLength(text) <= budget) {
428
+ const unit = nodeUnit(kid, parentUnit ? [parentUnit] : []);
429
+ units.push({ start: segStart, end: segEnd, text, kind: classifyNode(kid), unit, parent: parentUnit, segment: 0 });
430
+ } else {
431
+ const ownName = nodeUnit(kid, []) ?? parentUnit;
432
+ units.push(...splitNode(kid, source, budget, segStart, segEnd, ownName));
433
+ }
434
+ }
435
+ // Re-number segments within this range so ids stay unique on sibling spans.
436
+ let seg = 0;
437
+ for (const u of units) {
438
+ if (u.parent === parentUnit) u.segment = seg++;
439
+ }
440
+ return units;
441
+ }
442
+
443
+ /** Leaf fallback: budget-sized line windows over [start, end). */
444
+ function splitByLines(source: string, start: number, end: number, budget: number, parentUnit: string | null): DensityUnit[] {
445
+ const units: DensityUnit[] = [];
446
+ let unitStart = start;
447
+ let used = 0;
448
+ let pos = start;
449
+ while (pos < end) {
450
+ let nl = source.indexOf("\n", pos);
451
+ if (nl === -1 || nl >= end) nl = end - 1;
452
+ const lineEnd = nl + 1;
453
+ const line = source.slice(pos, lineEnd);
454
+ const lineNw = nonWsLength(line);
455
+ if (used > 0 && used + lineNw > budget) {
456
+ units.push({ start: unitStart, end: pos, text: source.slice(unitStart, pos), kind: "doc", unit: parentUnit, parent: parentUnit, segment: units.length });
457
+ unitStart = pos;
458
+ used = 0;
459
+ }
460
+ used += lineNw;
461
+ pos = lineEnd;
462
+ }
463
+ if (pos > unitStart) {
464
+ units.push({ start: unitStart, end: pos, text: source.slice(unitStart, pos), kind: "doc", unit: parentUnit, parent: parentUnit, segment: units.length });
465
+ }
466
+ return units;
467
+ }
468
+
469
+ /** Greedy-merge adjacent TRIVIA units while the combination stays within budget. Named units (functions/classes) never merge with siblings: the eval showed that gluing a small function into a budget-sized blob costs the symbol signal (metadata.unit) and dilutes the hash-embedding — R@10 dropped .936 → .772. Trivia (imports, comments, top-level statements) is what needs gluing to become self-contained. */
470
+ function mergeUnits(units: DensityUnit[], source: string, budget: number): DensityUnit[] {
471
+ const isTrivia = (u: DensityUnit) => u.unit === null && u.kind === "doc";
472
+ const out: DensityUnit[] = [];
473
+ for (const u of units) {
474
+ const last = out[out.length - 1];
475
+ if (
476
+ last &&
477
+ last.end === u.start &&
478
+ isTrivia(last) &&
479
+ isTrivia(u) &&
480
+ nonWsLength(last.text) + nonWsLength(u.text) <= budget
481
+ ) {
482
+ last.end = u.end;
483
+ last.text = source.slice(last.start, last.end);
484
+ } else {
485
+ out.push({ ...u });
486
+ }
487
+ }
488
+ return out;
489
+ }
490
+
204
491
  /**
205
492
  * Normalize a JS/TS module specifier to a stable root segment.
206
493
  *
@@ -219,6 +506,7 @@ function normalizeModuleSpecifier(raw: string): string {
219
506
  return segments[0]!;
220
507
  }
221
508
 
509
+ /** Port of upstream `_parse_import_module`. */
222
510
  /** Port of upstream `_parse_import_module`. */
223
511
  function parseImportModule(node: TsNode, _language: string): string | null {
224
512
  const textOf = (n: TsNode) => n.text;
@@ -256,6 +544,18 @@ function parseImportModule(node: TsNode, _language: string): string | null {
256
544
  return null;
257
545
  }
258
546
 
547
+ /**
548
+ * R7: the RAW specifier of a JS/TS string import (`./b.js`, not `b.js`).
549
+ * Import resolution in the pipeline needs it; returns null for syntaxes
550
+ * that carry no path-like specifier (Python dotted names, Rust paths).
551
+ */
552
+ function parseImportModuleRaw(node: TsNode): string | null {
553
+ if (node.type !== "import_statement" && node.type !== "import_declaration") return null;
554
+ const stringChild = firstChildOfType(node, ["string"]);
555
+ if (!stringChild) return null;
556
+ return stringChild.text.replace(/^['"]|['"]$/g, "");
557
+ }
558
+
259
559
  /** Sync cache of loaded Language objects (promises resolved during warmup). */
260
560
  const syncLanguageCache = new Map<string, unknown>();
261
561
 
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Deterministic context headers for chunks — plan item R1
3
+ * (docs/IMPLEMENTATION-PLAN.md).
4
+ *
5
+ * Why: a chunk used to carry only its own text, so a query like "how does the
6
+ * retrieval module merge results" had no signal tying a hit to
7
+ * `src/retrieval/`. Anthropic's contextual retrieval measured failed retrievals
8
+ * dropping 49% from prepending this kind of path/unit context before embedding
9
+ * and before FTS ingest. Ours is free: one line, no LLM, built from data the
10
+ * indexer already has (path, language, enclosing unit, file imports).
11
+ *
12
+ * The header is prepended to `chunk.content` as the single source of truth, so
13
+ * the embedder, the FTS ingest, compression and `expand_chunk` all see it
14
+ * without four separate code paths. It is also indexed text: contextual BM25
15
+ * was the larger half of Anthropic's gain.
16
+ */
17
+ import type { Chunk } from "../types.js";
18
+
19
+ /** ~120 code tokens at 3.3 chars/token: context, never content. */
20
+ export const HEADER_BUDGET_CHARS = 400;
21
+ const MAX_IMPORTS = 8;
22
+ const HEADER_PREFIX = "// file: ";
23
+
24
+ export interface ContextHeaderInput {
25
+ filePath: string;
26
+ language: string;
27
+ unit?: string | null;
28
+ imports?: string[];
29
+ }
30
+
31
+ export function buildContextHeader(input: ContextHeaderInput): string {
32
+ const parts = [`file: ${input.filePath}`, `lang: ${input.language}`];
33
+ if (input.unit) parts.push(`unit: ${input.unit}`);
34
+ const imports = (input.imports ?? []).filter(Boolean);
35
+ if (imports.length > 0) {
36
+ let list = imports.slice(0, MAX_IMPORTS).join(", ");
37
+ if (imports.length > MAX_IMPORTS) list += `, +${imports.length - MAX_IMPORTS} more`;
38
+ parts.push(`imports: ${list}`);
39
+ }
40
+ let header = `// ${parts.join(" | ")}`;
41
+ if (header.length > HEADER_BUDGET_CHARS) {
42
+ // Drop imports first: paths and units carry more signal per character.
43
+ header = `// ${parts.filter((p) => !p.startsWith("imports:")).join(" | ")}`;
44
+ if (header.length > HEADER_BUDGET_CHARS) {
45
+ header = header.slice(0, HEADER_BUDGET_CHARS - 3) + "...";
46
+ }
47
+ }
48
+ return header;
49
+ }
50
+
51
+ /** Prepend a header. Idempotent so re-processing a chunk cannot double it. */
52
+ export function applyContextHeader(content: string, input: ContextHeaderInput): string {
53
+ if (content.startsWith(HEADER_PREFIX)) return content;
54
+ return `${buildContextHeader(input)}\n${content}`;
55
+ }
56
+
57
+ /** Read the unit back out of a header-prefixed chunk (R2 symbol matching). */
58
+ export function parseContextHeaderUnit(content: string): string | null {
59
+ if (!content.startsWith(HEADER_PREFIX)) return null;
60
+ const firstLine = content.slice(0, content.indexOf("\n") < 0 ? undefined : content.indexOf("\n"));
61
+ const match = firstLine.match(/\| unit: ([^|]+?)(?: \|.*|\s*)$/);
62
+ return match ? match[1]!.trim() : null;
63
+ }
64
+
65
+ /** Apply the header to a chunk, reading the unit the chunker recorded. */
66
+ export function applyContextHeaderToChunk(chunk: Chunk, imports: string[]): void {
67
+ const unit = typeof chunk.metadata["unit"] === "string" ? (chunk.metadata["unit"] as string) : null;
68
+ chunk.content = applyContextHeader(chunk.content, {
69
+ filePath: chunk.filePath,
70
+ language: chunk.language,
71
+ unit,
72
+ imports,
73
+ });
74
+ }
75
+
76
+ /**
77
+ * Drop the context header line from stored chunk content (inverse of
78
+ * `applyContextHeader`). Used when re-deriving call-graph identifiers from
79
+ * backend-stored chunks, so header tokens (`file`, `unit`, `imports`, path
80
+ * segments) cannot pollute the identifier set.
81
+ */
82
+ export function stripContextHeader(content: string): string {
83
+ if (!content.startsWith(HEADER_PREFIX)) return content;
84
+ const nl = content.indexOf("\n");
85
+ return nl === -1 ? "" : content.slice(nl + 1);
86
+ }
@@ -10,6 +10,8 @@ import os from "node:os";
10
10
  import { sha256Hex } from "../utils.js";
11
11
 
12
12
  export const DEFAULT_LOCAL_MODEL = "Xenova/all-MiniLM-L6-v2";
13
+ /** R11: code-trained local embedder, measured in README (not a default — see eval table). */
14
+ export const RECOMMENDED_CODE_MODEL = "michaelfeil/jina-embeddings-v2-base-code";
13
15
  export const DEFAULT_OLLAMA_MODEL = "nomic-embed-text";
14
16
  export const DEFAULT_OLLAMA_URL = "http://localhost:11434";
15
17
 
@@ -31,6 +33,11 @@ function resolveModelCacheDir(): string {
31
33
  return path.join(os.homedir(), ".pi-context-engine", "models");
32
34
  }
33
35
 
36
+ /** R10: shared with the reranker so every transformers.js load uses one models dir. */
37
+ export function transformersCacheDir(): string {
38
+ return resolveModelCacheDir();
39
+ }
40
+
34
41
  interface TransformersModule {
35
42
  pipeline: (
36
43
  task: string,
@@ -94,8 +101,16 @@ export class LocalBackend implements EmbeddingBackend {
94
101
  pooling: "mean",
95
102
  normalize: true,
96
103
  } as Record<string, unknown>);
104
+ // R11: transformers.js truncates to the MODEL's max length — up to 8192
105
+ // tokens for long-context embedders, which OOMs a batch on CPU. Cap the
106
+ // text ourselves; ~2000 chars ≈ 512 BPE tokens covers almost every chunk.
107
+ const MAX_EMBED_CHARS = 2000;
108
+ const cap = (s: string) => (s.length > MAX_EMBED_CHARS ? s.slice(0, MAX_EMBED_CHARS) : s);
97
109
  return async (inputs: string | string[]): Promise<number[][]> => {
98
- const out = (await extractor(inputs)) as { data: Float32Array; dims: number[] } | { data: Float32Array; dims: number[] }[];
110
+ const capped = typeof inputs === "string" ? cap(inputs) : inputs.map(cap);
111
+ const out = (await extractor(capped)) as
112
+ | { data: Float32Array; dims: number[] }
113
+ | { data: Float32Array; dims: number[] }[];
99
114
  const single = Array.isArray(out) ? out[0]! : out;
100
115
  const dim = single.dims[single.dims.length - 1]!;
101
116
  const vecs: number[][] = [];
@@ -5,7 +5,7 @@
5
5
  import fs from "node:fs";
6
6
  import { atomicWriteText } from "../utils.js";
7
7
 
8
- export const CURRENT_SCHEMA_VERSION = 2;
8
+ export const CURRENT_SCHEMA_VERSION = 3;
9
9
 
10
10
  interface ManifestData {
11
11
  __schema_version?: number;
@@ -16,7 +16,7 @@ interface ManifestData {
16
16
 
17
17
  export class Manifest {
18
18
  private entries: Record<string, string> = {};
19
- private schemaVersion: number = CURRENT_SCHEMA_VERSION;
19
+ private schemaVersionValue: number = CURRENT_SCHEMA_VERSION;
20
20
  private lastGitShaValue: string | null = null;
21
21
  private embeddingDimValue: number | null = null;
22
22
 
@@ -26,13 +26,13 @@ export class Manifest {
26
26
  const loaded = JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as ManifestData;
27
27
  if (loaded && typeof loaded === "object") {
28
28
  if ("__schema_version" in loaded) {
29
- this.schemaVersion = loaded.__schema_version ?? 1;
29
+ this.schemaVersionValue = loaded.__schema_version ?? 1;
30
30
  this.entries = loaded.files ?? {};
31
31
  this.lastGitShaValue = loaded.last_git_sha ?? null;
32
32
  this.embeddingDimValue = loaded.embedding_dim ?? null;
33
33
  } else {
34
34
  // Old plain-dict format (pre-v0.2) — treat as version 1.
35
- this.schemaVersion = 1;
35
+ this.schemaVersionValue = 1;
36
36
  this.entries = loaded as unknown as Record<string, string>;
37
37
  }
38
38
  }
@@ -48,6 +48,10 @@ export class Manifest {
48
48
  set lastGitSha(value: string | null) {
49
49
  this.lastGitShaValue = value;
50
50
  }
51
+ /** Format version the on-disk manifest was written with (drives migrations). */
52
+ get schemaVersion(): number {
53
+ return this.schemaVersionValue;
54
+ }
51
55
  get embeddingDim(): number | null {
52
56
  return this.embeddingDimValue;
53
57
  }