pi-context-engine 0.1.2 → 0.1.3

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 CHANGED
@@ -116,7 +116,6 @@ embedding:
116
116
  model: local # or a specific transformers.js model id
117
117
  ollama_model: nomic-embed-text
118
118
  indexer:
119
- watch: true
120
119
  redact_secrets: true
121
120
  memory:
122
121
  redact_pii: true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-context-engine",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Semantic code context for pi: tree-sitter index + hybrid vector/BM25 retrieval + cross-session memory, all local. Port of code-context-engine (MIT, elara-labs) as a native pi extension.",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/config.ts CHANGED
@@ -60,13 +60,9 @@ export interface Config {
60
60
  retrievalConfidenceThreshold: number;
61
61
  retrievalTopK: number;
62
62
  retrievalMarginalRatio: number;
63
- bootstrapMaxTokens: number;
64
- indexerWatch: boolean;
65
- indexerDebounceMs: number;
66
63
  indexerIgnore: string[];
67
64
  indexerRedactSecrets: boolean;
68
65
  memoryRedactPii: boolean;
69
- auditLogEnabled: boolean;
70
66
  pricingModel: string;
71
67
  pricingInput: number | null;
72
68
  pricingOutput: number | null;
@@ -84,13 +80,9 @@ export function defaultConfig(): Config {
84
80
  retrievalConfidenceThreshold: 0.2,
85
81
  retrievalTopK: 20,
86
82
  retrievalMarginalRatio: 0.75,
87
- bootstrapMaxTokens: 10000,
88
- indexerWatch: true,
89
- indexerDebounceMs: 500,
90
83
  indexerIgnore: [...DEFAULT_IGNORE],
91
84
  indexerRedactSecrets: true,
92
85
  memoryRedactPii: true,
93
- auditLogEnabled: false,
94
86
  pricingModel: "opus",
95
87
  pricingInput: null,
96
88
  pricingOutput: null,
@@ -109,13 +101,9 @@ const MAPPING: Record<string, keyof Config> = {
109
101
  "retrieval.confidence_threshold": "retrievalConfidenceThreshold",
110
102
  "retrieval.top_k": "retrievalTopK",
111
103
  "retrieval.marginal_ratio": "retrievalMarginalRatio",
112
- "retrieval.bootstrap_max_tokens": "bootstrapMaxTokens",
113
- "indexer.watch": "indexerWatch",
114
- "indexer.debounce_ms": "indexerDebounceMs",
115
104
  "indexer.ignore": "indexerIgnore",
116
105
  "indexer.redact_secrets": "indexerRedactSecrets",
117
106
  "memory.redact_pii": "memoryRedactPii",
118
- "audit.enabled": "auditLogEnabled",
119
107
  "storage.path": "storagePath",
120
108
  "pricing.model": "pricingModel",
121
109
  "pricing.input": "pricingInput",
package/src/engine.ts CHANGED
@@ -7,7 +7,7 @@ import fs from "node:fs";
7
7
  import path from "node:path";
8
8
  import type { Config } from "./config.js";
9
9
  import { loadConfigForProject, resolveOllamaUrl } from "./config.js";
10
- import { epochSeconds, humaniseRelativeTime, projectStorageDir, sha256Hex } from "./utils.js";
10
+ import { atomicWriteText, epochSeconds, humaniseRelativeTime, projectStorageDir, sha256Hex } from "./utils.js";
11
11
  import { LocalBackend } from "./storage/local-backend.js";
12
12
  import { Embedder, selectBackend } from "./indexer/embedder.js";
13
13
  import { HybridRetriever } from "./retrieval/retriever.js";
@@ -146,6 +146,7 @@ export class ContextEngine {
146
146
  draining = false;
147
147
  private searchesSinceDecision = 0;
148
148
  private hasRecordedDecision = false;
149
+ private vecBackfillStarted = false;
149
150
 
150
151
  constructor(options: EngineOptions) {
151
152
  this.projectDir = fs.realpathSync(path.resolve(options.projectDir));
@@ -247,7 +248,7 @@ export class ContextEngine {
247
248
 
248
249
  private saveStats(): void {
249
250
  try {
250
- fs.writeFileSync(this.statsPath, JSON.stringify(this.stats));
251
+ atomicWriteText(this.statsPath, JSON.stringify(this.stats));
251
252
  } catch {
252
253
  // stats are best-effort
253
254
  }
@@ -426,6 +427,25 @@ export class ContextEngine {
426
427
  void this.drainCompressionQueue();
427
428
  }, 5000);
428
429
  this.drainTimer.unref?.();
430
+ void this.backfillMemoryVec().catch(() => undefined);
431
+ }
432
+
433
+ /**
434
+ * One-shot semantic-memory backfill (upstream #137 lesson). After an
435
+ * embedder change the vec tables are recreated empty; without this, old
436
+ * decisions silently lose semantic recall (FTS still works, so the loss is
437
+ * easy to miss). Guarded by a cheap missing-row count so the steady state
438
+ * never initialises an embedder for this. Recall also self-heals via
439
+ * `MemoryDb.healVecTables`, covering wipes that happen after this runs.
440
+ */
441
+ private async backfillMemoryVec(): Promise<void> {
442
+ if (this.vecBackfillStarted) return;
443
+ this.vecBackfillStarted = true;
444
+ const memory = this.getMemory();
445
+ if (!memory || !memory.vecAvailable || !memory.hasVecTables()) return;
446
+ const pending = memory.vecBackfillPending();
447
+ if (pending.decisions + pending.turnSummaries === 0) return;
448
+ await memory.backfillVecTables(await this.getEmbedder());
429
449
  }
430
450
 
431
451
  async shutdown(): Promise<void> {
@@ -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
- root: string,
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(root, full).replace(/\\/g, "/");
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(root);
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 (ignoreSet.has(path.basename(target))) {
396
- result.skippedFiles.push(rel);
397
- fileList = [];
398
- } else if (config.indexerRedactSecrets && isSecretFile(target)) {
399
- result.skippedFiles.push(rel);
400
- fileList = [];
401
- } else if (cceignorePatterns.length > 0 && cceignoreMatchesFile(rel, cceignorePatterns)) {
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
- fileList = [
409
- ...iterProjectFiles(target, ignoreSet, {
410
- redactSecrets: config.indexerRedactSecrets,
411
- cceignorePatterns,
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