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.
- package/README.md +49 -0
- package/docs/IMPLEMENTATION-PLAN.md +30 -13
- package/package.json +83 -81
- package/src/compression/compressor.ts +119 -20
- package/src/config.ts +59 -3
- package/src/engine.ts +206 -12
- package/src/extension.ts +12 -1
- package/src/guidance.ts +1 -0
- package/src/indexer/chunker.ts +321 -21
- package/src/indexer/context-header.ts +86 -0
- package/src/indexer/embedder.ts +16 -1
- package/src/indexer/manifest.ts +8 -4
- package/src/indexer/pipeline.ts +223 -18
- package/src/memory/db.ts +128 -1
- package/src/retrieval/confidence.ts +53 -0
- package/src/retrieval/query-parser.ts +33 -0
- package/src/retrieval/reranker.ts +112 -0
- package/src/retrieval/retriever.ts +351 -14
- package/src/storage/fts-store.ts +48 -6
- package/src/storage/graph-store.ts +160 -4
- package/src/storage/local-backend.ts +36 -0
- package/src/storage/vector-store.ts +22 -0
package/src/indexer/chunker.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
164
|
-
|
|
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
|
+
}
|
package/src/indexer/embedder.ts
CHANGED
|
@@ -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
|
|
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[][] = [];
|
package/src/indexer/manifest.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
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.
|
|
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.
|
|
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
|
}
|