pi-context-engine 0.1.2 → 0.1.4
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 +10 -1
- package/docs/IMPLEMENTATION-PLAN.md +483 -0
- package/docs/RESEARCH-RETRIEVAL.md +532 -0
- package/package.json +4 -2
- package/src/config.ts +0 -12
- package/src/engine.ts +22 -2
- package/src/indexer/embedder.ts +35 -2
- package/src/indexer/pipeline.ts +98 -17
- package/src/memory/db.ts +46 -0
package/src/indexer/embedder.ts
CHANGED
|
@@ -40,13 +40,37 @@ interface TransformersModule {
|
|
|
40
40
|
env: Record<string, unknown>;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
export type TransformersLoader = () => Promise<TransformersModule>;
|
|
44
|
+
|
|
45
|
+
const defaultLoadTransformers: TransformersLoader = async () =>
|
|
46
|
+
(await import("@huggingface/transformers")) as unknown as TransformersModule;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The package ships `@huggingface/transformers` as an optional dependency, so a
|
|
50
|
+
* plain npm install has it, but installs done with `--omit=optional` do not.
|
|
51
|
+
* Detect that case so we can tell the user exactly how to fix it instead of
|
|
52
|
+
* crashing with a bare "Cannot find module".
|
|
53
|
+
*/
|
|
54
|
+
function isModuleNotFoundError(err: unknown): boolean {
|
|
55
|
+
const code = (err as NodeJS.ErrnoException | undefined)?.code;
|
|
56
|
+
if (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") return true;
|
|
57
|
+
const message = (err as Error | undefined)?.message ?? "";
|
|
58
|
+
return message.includes("Cannot find module") || message.includes("Cannot find package");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const LOCAL_BACKEND_INSTALL_HINT =
|
|
62
|
+
"Local embedding backend unavailable: @huggingface/transformers is not installed. " +
|
|
63
|
+
"It ships as an optional dependency, so a plain install includes it. " +
|
|
64
|
+
"Fix: reinstall pi-context-engine (pi update npm:pi-context-engine), " +
|
|
65
|
+
"or force the Ollama backend with PCE_EMBED_BACKEND=ollama.";
|
|
66
|
+
|
|
43
67
|
export class LocalBackend implements EmbeddingBackend {
|
|
44
68
|
readonly name = "local";
|
|
45
69
|
readonly modelName: string;
|
|
46
70
|
dimension = 0;
|
|
47
71
|
private pipelinePromise: Promise<(inputs: string | string[]) => Promise<number[][]>> | null = null;
|
|
48
72
|
|
|
49
|
-
constructor(modelName: string = DEFAULT_LOCAL_MODEL) {
|
|
73
|
+
constructor(modelName: string = DEFAULT_LOCAL_MODEL, private readonly loadTransformers: TransformersLoader = defaultLoadTransformers) {
|
|
50
74
|
this.modelName = modelName;
|
|
51
75
|
}
|
|
52
76
|
|
|
@@ -55,7 +79,16 @@ export class LocalBackend implements EmbeddingBackend {
|
|
|
55
79
|
this.pipelinePromise = (async () => {
|
|
56
80
|
const cacheDir = resolveModelCacheDir();
|
|
57
81
|
fs.mkdirSync(cacheDir, { recursive: true });
|
|
58
|
-
|
|
82
|
+
let transformers: TransformersModule;
|
|
83
|
+
try {
|
|
84
|
+
transformers = await this.loadTransformers();
|
|
85
|
+
} catch (err) {
|
|
86
|
+
// Drop the memoized failure so a retry (after installing the
|
|
87
|
+
// package) gets a fresh chance instead of replaying the error.
|
|
88
|
+
this.pipelinePromise = null;
|
|
89
|
+
if (isModuleNotFoundError(err)) throw new Error(LOCAL_BACKEND_INSTALL_HINT);
|
|
90
|
+
throw err;
|
|
91
|
+
}
|
|
59
92
|
transformers.env.cacheDir = cacheDir;
|
|
60
93
|
const extractor = await transformers.pipeline("feature-extraction", this.modelName, {
|
|
61
94
|
pooling: "mean",
|
package/src/indexer/pipeline.ts
CHANGED
|
@@ -139,6 +139,40 @@ function resolveWithin(projectDir: string, target: string): string {
|
|
|
139
139
|
return resolved;
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/** A directory target is excluded when it or any ancestor matches a dir rule (upstream #165b). */
|
|
143
|
+
function cceignoreMatchesDir(relPosix: string, patterns: string[]): boolean {
|
|
144
|
+
const parts = relPosix.split("/");
|
|
145
|
+
for (let i = 1; i <= parts.length; i++) {
|
|
146
|
+
if (matchesAny(parts.slice(0, i).join("/"), true, patterns)) return true;
|
|
147
|
+
}
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Target-path policy gate, mirroring what the full walker enforces while
|
|
153
|
+
* descending: every path segment must pass `indexer.ignore`, files must pass
|
|
154
|
+
* the secret-filename check, and `.cceignore` is matched against the
|
|
155
|
+
* project-relative path (dir rules for directories, file + ancestor rules for
|
|
156
|
+
* files). The project root (`rel === ""`) is exempt — rules apply to entries
|
|
157
|
+
* below the root, not to the root itself (a checkout named `build` must still
|
|
158
|
+
* reindex via `path: "."`).
|
|
159
|
+
*/
|
|
160
|
+
function targetExcluded(
|
|
161
|
+
relPosix: string,
|
|
162
|
+
opts: { isDir: boolean; ignoreSet: Set<string>; redactSecrets: boolean; patterns: string[] },
|
|
163
|
+
): boolean {
|
|
164
|
+
if (relPosix === "") return false;
|
|
165
|
+
const segments = relPosix.split("/");
|
|
166
|
+
for (const seg of segments) {
|
|
167
|
+
if (opts.ignoreSet.has(seg)) return true;
|
|
168
|
+
}
|
|
169
|
+
// The walker only classifies secret names on file entries; a secret-named
|
|
170
|
+
// directory's contents are still gated per file.
|
|
171
|
+
if (!opts.isDir && opts.redactSecrets && isSecretFile(segments[segments.length - 1]!)) return true;
|
|
172
|
+
if (opts.patterns.length === 0) return false;
|
|
173
|
+
return opts.isDir ? cceignoreMatchesDir(relPosix, opts.patterns) : cceignoreMatchesFile(relPosix, opts.patterns);
|
|
174
|
+
}
|
|
175
|
+
|
|
142
176
|
function safeRead(filePath: string): string | null {
|
|
143
177
|
try {
|
|
144
178
|
if (fs.statSync(filePath).size > MAX_FILE_BYTES) return null;
|
|
@@ -149,7 +183,9 @@ function safeRead(filePath: string): string | null {
|
|
|
149
183
|
}
|
|
150
184
|
|
|
151
185
|
function* iterProjectFiles(
|
|
152
|
-
|
|
186
|
+
walkRoot: string,
|
|
187
|
+
/** Project root: `.cceignore` patterns are matched against project-relative paths even when walking from a target. */
|
|
188
|
+
relRoot: string,
|
|
153
189
|
ignoreSet: Set<string>,
|
|
154
190
|
opts: { redactSecrets: boolean; cceignorePatterns: string[] },
|
|
155
191
|
): Generator<string> {
|
|
@@ -175,7 +211,7 @@ function* iterProjectFiles(
|
|
|
175
211
|
}
|
|
176
212
|
if (seen.has(resolved)) continue;
|
|
177
213
|
seen.add(resolved);
|
|
178
|
-
const rel = path.relative(
|
|
214
|
+
const rel = path.relative(relRoot, full).replace(/\\/g, "/");
|
|
179
215
|
if (opts.cceignorePatterns.length > 0 && matchesAny(rel, entry.isDirectory(), opts.cceignorePatterns)) {
|
|
180
216
|
continue;
|
|
181
217
|
}
|
|
@@ -188,7 +224,7 @@ function* iterProjectFiles(
|
|
|
188
224
|
}
|
|
189
225
|
}
|
|
190
226
|
};
|
|
191
|
-
yield* walk(
|
|
227
|
+
yield* walk(walkRoot);
|
|
192
228
|
}
|
|
193
229
|
|
|
194
230
|
/** `{embeddings, cacheHits, cacheMisses}` for a batch, using the content cache. */
|
|
@@ -385,32 +421,77 @@ async function runIndexingLocked(
|
|
|
385
421
|
if (cceignorePatterns.length > 0) log?.(` [.cceignore] ${cceignorePatterns.length} pattern(s) loaded`);
|
|
386
422
|
|
|
387
423
|
// ── Determine the file set ─────────────────────────────────────────────
|
|
424
|
+
/**
|
|
425
|
+
* A file the inclusion policy now rejects must not leave stale chunks
|
|
426
|
+
* behind (upstream #127): purge its manifest entry and indexed rows so a
|
|
427
|
+
* targeted reindex is enough to enforce a new secret/ignore rule.
|
|
428
|
+
*/
|
|
429
|
+
const purgeIndexed = (rels: string[]): void => {
|
|
430
|
+
const indexed = rels.filter((rel) => manifest.getHash(rel) !== undefined);
|
|
431
|
+
if (indexed.length === 0) return;
|
|
432
|
+
backend.deleteByFiles(indexed);
|
|
433
|
+
for (const rel of indexed) {
|
|
434
|
+
manifest.remove(rel);
|
|
435
|
+
result.deletedFiles.push(rel);
|
|
436
|
+
log?.(` [delete] ${rel} (excluded by policy)`);
|
|
437
|
+
}
|
|
438
|
+
manifest.save();
|
|
439
|
+
};
|
|
388
440
|
let fileList: string[];
|
|
389
441
|
if (targetPath) {
|
|
390
442
|
const target = resolveWithin(projectDir, targetPath);
|
|
391
443
|
if (fs.existsSync(target) && fs.statSync(target).isFile()) {
|
|
392
444
|
const rel = path.relative(projectDir, target).replace(/\\/g, "/");
|
|
393
445
|
if (SKIP_EXTENSIONS.has(path.extname(target).toLowerCase())) {
|
|
446
|
+
purgeIndexed([rel]);
|
|
394
447
|
fileList = [];
|
|
395
|
-
} else if (
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
448
|
+
} else if (
|
|
449
|
+
targetExcluded(rel, {
|
|
450
|
+
isDir: false,
|
|
451
|
+
ignoreSet,
|
|
452
|
+
redactSecrets: config.indexerRedactSecrets,
|
|
453
|
+
patterns: cceignorePatterns,
|
|
454
|
+
})
|
|
455
|
+
) {
|
|
456
|
+
// Ancestor segments included: the full walker would have pruned
|
|
457
|
+
// the path before reaching this file (upstream #165b parity).
|
|
402
458
|
result.skippedFiles.push(rel);
|
|
459
|
+
purgeIndexed([rel]);
|
|
403
460
|
fileList = [];
|
|
404
461
|
} else {
|
|
405
462
|
fileList = [target];
|
|
406
463
|
}
|
|
407
464
|
} else if (fs.existsSync(target) && fs.statSync(target).isDirectory()) {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
465
|
+
// Upstream #165b: the walk starts inside the target, so the target
|
|
466
|
+
// path itself must first pass the same policy the full walker
|
|
467
|
+
// enforces while descending — every segment vs `indexer.ignore`
|
|
468
|
+
// (e.g. `dist/nested` is rejected because `dist` is), plus
|
|
469
|
+
// `.cceignore` dir rules for the target and its ancestors.
|
|
470
|
+
const rel = path.relative(projectDir, target).replace(/\\/g, "/");
|
|
471
|
+
const excluded = targetExcluded(rel, {
|
|
472
|
+
isDir: true,
|
|
473
|
+
ignoreSet,
|
|
474
|
+
redactSecrets: config.indexerRedactSecrets,
|
|
475
|
+
patterns: cceignorePatterns,
|
|
476
|
+
});
|
|
477
|
+
if (excluded) {
|
|
478
|
+
result.skippedFiles.push(rel);
|
|
479
|
+
fileList = [];
|
|
480
|
+
} else {
|
|
481
|
+
fileList = [
|
|
482
|
+
...iterProjectFiles(target, projectDir, ignoreSet, {
|
|
483
|
+
redactSecrets: config.indexerRedactSecrets,
|
|
484
|
+
cceignorePatterns,
|
|
485
|
+
}),
|
|
486
|
+
];
|
|
487
|
+
}
|
|
488
|
+
// Targeted runs skip the full-run prune below, so purge manifest
|
|
489
|
+
// entries under this subtree the walk no longer yields: newly
|
|
490
|
+
// excluded files, secret files, and deleted files (upstream
|
|
491
|
+
// #127/#165b). Scoped to the target so siblings are untouched.
|
|
492
|
+
const yielded = new Set(fileList.map((f) => path.relative(projectDir, f).replace(/\\/g, "/")));
|
|
493
|
+
const underScope = (p: string): boolean => (rel === "" ? true : p === rel || p.startsWith(`${rel}/`));
|
|
494
|
+
purgeIndexed(Object.keys(manifest.fileEntries).filter((p) => underScope(p) && !yielded.has(p)));
|
|
414
495
|
} else {
|
|
415
496
|
// Watcher deletion path: prune instead of erroring out.
|
|
416
497
|
const rel = path.relative(projectDir, target).replace(/\\/g, "/");
|
|
@@ -426,7 +507,7 @@ async function runIndexingLocked(
|
|
|
426
507
|
}
|
|
427
508
|
} else {
|
|
428
509
|
fileList = [
|
|
429
|
-
...iterProjectFiles(projectDir, ignoreSet, {
|
|
510
|
+
...iterProjectFiles(projectDir, projectDir, ignoreSet, {
|
|
430
511
|
redactSecrets: config.indexerRedactSecrets,
|
|
431
512
|
cceignorePatterns,
|
|
432
513
|
}),
|
package/src/memory/db.ts
CHANGED
|
@@ -309,9 +309,31 @@ CREATE VIRTUAL TABLE IF NOT EXISTS turn_summaries_vec USING vec0(embedding float
|
|
|
309
309
|
this.writeVecRow("turn_summaries_vec", turnId, vec);
|
|
310
310
|
}
|
|
311
311
|
|
|
312
|
+
/**
|
|
313
|
+
* Count rows still missing a vec entry, without touching an embedder.
|
|
314
|
+
* Lets callers skip the backfill (and its model init) in the steady state.
|
|
315
|
+
*/
|
|
316
|
+
vecBackfillPending(): { decisions: number; turnSummaries: number } {
|
|
317
|
+
if (!this.hasVecTables()) return { decisions: 0, turnSummaries: 0 };
|
|
318
|
+
try {
|
|
319
|
+
const d = this.db
|
|
320
|
+
.prepare("SELECT COUNT(*) AS n FROM decisions d WHERE NOT EXISTS (SELECT 1 FROM decisions_vec v WHERE v.rowid = d.id)")
|
|
321
|
+
.get() as { n: number };
|
|
322
|
+
const t = this.db
|
|
323
|
+
.prepare("SELECT COUNT(*) AS n FROM turn_summaries t WHERE NOT EXISTS (SELECT 1 FROM turn_summaries_vec v WHERE v.rowid = t.id)")
|
|
324
|
+
.get() as { n: number };
|
|
325
|
+
return { decisions: Number(d?.n ?? 0), turnSummaries: Number(t?.n ?? 0) };
|
|
326
|
+
} catch {
|
|
327
|
+
return { decisions: 0, turnSummaries: 0 };
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
312
331
|
async backfillVecTables(embedder: Embedder): Promise<{ decisions: number; turnSummaries: number }> {
|
|
313
332
|
const counts = { decisions: 0, turnSummaries: 0 };
|
|
314
333
|
if (!this.hasVecTables()) return counts;
|
|
334
|
+
// Steady state: nothing missing — skip the embedder entirely.
|
|
335
|
+
const pending = this.vecBackfillPending();
|
|
336
|
+
if (pending.decisions + pending.turnSummaries === 0) return counts;
|
|
315
337
|
try {
|
|
316
338
|
this.ensureVecDim((await embedder.embedQuery("dimension probe")).length);
|
|
317
339
|
} catch {
|
|
@@ -348,6 +370,7 @@ CREATE VIRTUAL TABLE IF NOT EXISTS turn_summaries_vec USING vec0(embedding float
|
|
|
348
370
|
} catch {
|
|
349
371
|
return [];
|
|
350
372
|
}
|
|
373
|
+
await this.healVecTables(embedder, vec);
|
|
351
374
|
return this.vecSearch("decisions_vec", vec, k, maxDistance);
|
|
352
375
|
}
|
|
353
376
|
|
|
@@ -359,9 +382,32 @@ CREATE VIRTUAL TABLE IF NOT EXISTS turn_summaries_vec USING vec0(embedding float
|
|
|
359
382
|
} catch {
|
|
360
383
|
return [];
|
|
361
384
|
}
|
|
385
|
+
await this.healVecTables(embedder, vec);
|
|
362
386
|
return this.vecSearch("turn_summaries_vec", vec, k, maxDistance);
|
|
363
387
|
}
|
|
364
388
|
|
|
389
|
+
/**
|
|
390
|
+
* Self-heal at the point of recall (upstream #137), covering both failure
|
|
391
|
+
* shapes a dim-mismatched or partially-empty vec table produces:
|
|
392
|
+
*
|
|
393
|
+
* 1. Tables built by another embedder: a dim-mismatched MATCH errors and
|
|
394
|
+
* silently returns nothing → recreate at the query's dim.
|
|
395
|
+
* 2. Rows missing for any reason (e.g. a mid-session `recordDecisionVec`
|
|
396
|
+
* wiped the tables via `ensureVecDim` after the engine's one-shot
|
|
397
|
+
* backfill already ran) → restore them here, where the embedder is
|
|
398
|
+
* already in hand.
|
|
399
|
+
*
|
|
400
|
+
* Steady state costs two COUNT queries; the embedder is only touched when
|
|
401
|
+
* something is actually wrong.
|
|
402
|
+
*/
|
|
403
|
+
private async healVecTables(embedder: Embedder, vec: readonly number[]): Promise<void> {
|
|
404
|
+
if (!this.hasVecTables()) return;
|
|
405
|
+
const declared = this.declaredVecDim();
|
|
406
|
+
if (declared !== null && declared !== vec.length) this.ensureVecDim(vec.length);
|
|
407
|
+
const pending = this.vecBackfillPending();
|
|
408
|
+
if (pending.decisions + pending.turnSummaries > 0) await this.backfillVecTables(embedder);
|
|
409
|
+
}
|
|
410
|
+
|
|
365
411
|
private vecSearch(table: string, vec: number[], k: number, maxDistance: number): number[] {
|
|
366
412
|
try {
|
|
367
413
|
const rows = this.db
|