pi-context-engine 0.5.2 → 0.6.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 CHANGED
@@ -56,7 +56,7 @@ Ten tools the agent can call, plus four slash commands.
56
56
  - **`index_status`**: chunk count and measured savings.
57
57
  - **`reindex`**: refresh a path or the whole project.
58
58
 
59
- Commands: `/context-index [--full]`, `/context-status`, `/context-savings`,
59
+ Commands: `/context-index [--full] [--reset]`, `/context-status`, `/context-savings`,
60
60
  `/context-warmup`.
61
61
 
62
62
  **Shipped guidance.** The package also injects a ~900-token guidance section
@@ -109,13 +109,24 @@ unchanged repo took 237 ms.
109
109
  not add a full directory scan. Periodic yields keep the check responsive.
110
110
  This prevents an accidental parent-directory
111
111
  session from starting an unrequested multi-minute workload. `/context-index`
112
- remains available as an explicit manual build.
112
+ remains available as an explicit manual build. While the pause is active,
113
+ every `context_search` result carries a stale-note and `/context-status`
114
+ leads with the pause line, so outdated results are never served silently;
115
+ all messages name the config file that raises the limit.
113
116
  - **Resume after interruption**: the manifest checkpoints completed batches,
114
117
  starting with the first batch and then at most once every five seconds. An
115
118
  interrupted build can skip completed files instead of starting from zero.
116
119
  Migration runs (schema reset, embedding-dimension change) are exempt: their
117
120
  manifest stays untouched on disk until the run completes, so an interrupted
118
121
  migration resumes cleanly instead of stranding stale rows.
122
+ - **One-command reset**: `/context-index --reset` deletes the index through the
123
+ engine while the session is running: sqlite handles are closed first, so it
124
+ succeeds where manual deletion of the storage directory fails with locked
125
+ file errors (EPERM on Windows). The report shows how many files and bytes
126
+ were freed, and memory, sessions, and the savings ledger survive. The
127
+ embedding cache is deleted too, so a rebuild re-embeds from scratch. The
128
+ next search rebuilds via the lazy kick, guarded by the same file budget as
129
+ every automatic run.
119
130
  - **Cold-start visibility**: the first build on an empty index announces its
120
131
  start (`building the first index…`) and always closes with a result line
121
132
  (`updated: N file(s)` or `no files to index`). This is the one background
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-context-engine",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
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/engine.ts CHANGED
@@ -20,7 +20,7 @@ import { SessionCapture } from "./memory/session-capture.js";
20
20
  import { drainOne, enqueueSessionRollup, enqueueTurn, type CompressionDeps } from "./memory/compressor.js";
21
21
  import { compress, expand, compressWithCounts, DEFAULT_LEVEL } from "./memory/grammar.js";
22
22
  import { cosine } from "./memory/extractive.js";
23
- import { AutomaticIndexFileLimitError, runIndexing } from "./indexer/pipeline.js";
23
+ import { AutomaticIndexFileLimitError, runIndexing, withPipelineLock } from "./indexer/pipeline.js";
24
24
  import { getModelPricing } from "./pricing.js";
25
25
  import type { Chunk } from "./types.js";
26
26
 
@@ -125,8 +125,10 @@ export interface SessionStats {
125
125
  export interface EngineOptions {
126
126
  projectDir: string;
127
127
  config?: Config;
128
- /** Test seam: reuse an existing backend/embedder/reranker instead of auto-detecting. */
129
- runtime?: { backend: LocalBackend; embedder: Embedder; reranker?: LocalReranker };
128
+ /** Test seam: reuse an existing backend/embedder/reranker instead of
129
+ * auto-detecting. `backend` may be nulled by resetIndex — the pipeline
130
+ * then constructs a fresh one. */
131
+ runtime?: { backend?: LocalBackend | null; embedder: Embedder; reranker?: LocalReranker };
130
132
  }
131
133
 
132
134
  /** Issue #11 (upstream warmup): what /context-warmup resolved + cached. */
@@ -161,8 +163,8 @@ export class ContextEngine {
161
163
  readonly projectName: string;
162
164
  readonly storageBase: string;
163
165
  config: Config;
164
- private readonly injectedRuntime:
165
- | { backend: LocalBackend; embedder: Embedder; reranker?: LocalReranker }
166
+ private injectedRuntime:
167
+ | { backend?: LocalBackend | null; embedder: Embedder; reranker?: LocalReranker }
166
168
  | undefined;
167
169
  private backendInstance: LocalBackend | null = null;
168
170
  private embedderInstance: Embedder | null = null;
@@ -188,7 +190,7 @@ export class ContextEngine {
188
190
  this.config = options.config ?? loadConfigForProject(this.projectDir);
189
191
  this.injectedRuntime = options.runtime;
190
192
  if (options.runtime) {
191
- this.backendInstance = options.runtime.backend;
193
+ this.backendInstance = options.runtime.backend ?? null;
192
194
  this.embedderInstance = options.runtime.embedder;
193
195
  }
194
196
  this.storageBase = projectStorageDir(this.config.storagePath, this.projectDir);
@@ -358,6 +360,93 @@ export class ContextEngine {
358
360
  return this.backend.countChunks();
359
361
  }
360
362
 
363
+ /**
364
+ * Delete this project's index (manifest, vector/FTS/graph stores, embedding
365
+ * cache) while keeping memory, sessions and the savings ledger — those are
366
+ * user history, not index state. Handles are closed BEFORE deletion:
367
+ * Windows refuses to delete files with open sqlite handles, which is why
368
+ * manually deleting the storage dir fails while a session is running.
369
+ * Queues behind in-flight and queued pipeline runs, and holds the same lock
370
+ * during deletion so a later reindex cannot race the reset.
371
+ * Errors are reported per target (e.g. another session holding a handle)
372
+ * instead of failing the whole reset.
373
+ */
374
+ async resetIndex(): Promise<{ deletedFiles: number; freedBytes: number; errors: string[] }> {
375
+ return withPipelineLock(this.storageBase, () => this.resetIndexLocked());
376
+ }
377
+
378
+ private async resetIndexLocked(): Promise<{ deletedFiles: number; freedBytes: number; errors: string[] }> {
379
+ try {
380
+ this.backendInstance?.close();
381
+ } catch {
382
+ // already closed
383
+ }
384
+ this.backendInstance = null;
385
+ this.retrieverInstance = null;
386
+ // Mutate the runtime object rather than replacing it: reindex calls that
387
+ // were queued behind this reset captured the same object and must see the
388
+ // closed backend dropped before they execute. Embedder and reranker
389
+ // survive: they hold no files.
390
+ if (this.injectedRuntime) this.injectedRuntime.backend = null;
391
+ // A reset index may be rebuilt by the next automatic kick.
392
+ this.autoIndexDone = false;
393
+ let deletedFiles = 0;
394
+ let freedBytes = 0;
395
+ const errors: string[] = [];
396
+ const countAndDelete = (target: string): void => {
397
+ let stat: fs.Stats;
398
+ try {
399
+ stat = fs.statSync(target);
400
+ } catch {
401
+ return; // not there — nothing to free
402
+ }
403
+ let files = 0;
404
+ let bytes = 0;
405
+ const measure = (p: string, s: fs.Stats): void => {
406
+ if (s.isDirectory()) {
407
+ for (const entry of fs.readdirSync(p)) {
408
+ const child = path.join(p, entry);
409
+ try {
410
+ measure(child, fs.statSync(child));
411
+ } catch {
412
+ // raced away between readdir and stat
413
+ }
414
+ }
415
+ return;
416
+ }
417
+ files++;
418
+ bytes += s.size;
419
+ };
420
+ try {
421
+ measure(target, stat);
422
+ fs.rmSync(target, { recursive: true, force: true });
423
+ // Only report bytes actually released; a locked target must not
424
+ // inflate the success report when rmSync fails.
425
+ deletedFiles += files;
426
+ freedBytes += bytes;
427
+ } catch (err) {
428
+ // Locked file (another session?) — report instead of failing the reset.
429
+ errors.push(`${path.relative(this.storageBase, target) || path.basename(target)}: ${(err as Error).message.slice(0, 80)}`);
430
+ }
431
+ };
432
+ const graphDb = path.join(this.storageBase, "graph.db");
433
+ const embeddingCache = path.join(this.storageBase, "embedding_cache.db");
434
+ for (const target of [
435
+ path.join(this.storageBase, "manifest.json"),
436
+ path.join(this.storageBase, "vectors"),
437
+ path.join(this.storageBase, "fts"),
438
+ graphDb,
439
+ `${graphDb}-shm`,
440
+ `${graphDb}-wal`,
441
+ embeddingCache,
442
+ `${embeddingCache}-shm`,
443
+ `${embeddingCache}-wal`,
444
+ ]) {
445
+ countAndDelete(target);
446
+ }
447
+ return { deletedFiles, freedBytes, errors };
448
+ }
449
+
361
450
  async reindex(options: { full?: boolean; targetPath?: string | null; automatic?: boolean; onProgress?: (msg: string) => void } = {}): Promise<{
362
451
  indexed: number;
363
452
  chunks: number;
package/src/extension.ts CHANGED
@@ -26,6 +26,26 @@ const CAPTURED_TOOLS = new Set(["bash", "read", "edit", "write", "grep", "find",
26
26
  const AUTO_INDEX_DELAY_MS = 2000;
27
27
 
28
28
  export default function contextEngineExtension(pi: ExtensionAPI) {
29
+ const MAX_AUTO_FILES_HINT =
30
+ "raise indexer.max_auto_files (~/.pi-context-engine/config.yaml or the project's .context-engine.yaml)";
31
+
32
+ /** One-line pause notice for /context-status and the index_status tool: the
33
+ * most likely question there is "why is my index not updating?" */
34
+ const pauseNotice = (): string | null =>
35
+ automaticIndexBlock
36
+ ? `Automatic indexing paused: workspace exceeds ${automaticIndexBlock.limit.toLocaleString()} indexable files — the index may be stale. ` +
37
+ `Run /context-index, or ${MAX_AUTO_FILES_HINT}.`
38
+ : null;
39
+
40
+ /** Bracket note appended to every context_search result while the pause is
41
+ * active and a (possibly outdated) index is served. Without it the agent
42
+ * cannot tell fresh results from stale ones. */
43
+ const staleIndexNote = (): string | null =>
44
+ automaticIndexBlock
45
+ ? `[note: automatic indexing is paused — workspace exceeds ${automaticIndexBlock.limit.toLocaleString()} indexable files; these results may be stale. ` +
46
+ `Run /context-index, or ${MAX_AUTO_FILES_HINT} to refresh]`
47
+ : null;
48
+
29
49
  let engine: ContextEngine | null = null;
30
50
  let currentCwd = process.cwd();
31
51
  let promptNumber = 0;
@@ -114,7 +134,7 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
114
134
  notify?.(
115
135
  `Context index: automatic indexing${eng.config.indexerWatch ? " and watcher" : ""} paused. ` +
116
136
  `Workspace exceeds ${err.limit.toLocaleString()} indexable files. ` +
117
- "Run /context-index to index anyway, or raise indexer.max_auto_files.",
137
+ `Run /context-index to index anyway, or ${MAX_AUTO_FILES_HINT}.`,
118
138
  "warning",
119
139
  );
120
140
  };
@@ -225,6 +245,20 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
225
245
  return engine;
226
246
  };
227
247
 
248
+ /** Human-readable byte count for the reset report (explicit user action —
249
+ * the automatic guard never measures bytes by design). */
250
+ const formatBytes = (n: number): string => {
251
+ if (n < 1024) return `${n} B`;
252
+ const units = ["KiB", "MiB", "GiB"];
253
+ let v = n / 1024;
254
+ let u = 0;
255
+ while (v >= 1024 && u < units.length - 1) {
256
+ v /= 1024;
257
+ u++;
258
+ }
259
+ return `${v.toFixed(v >= 10 ? 0 : 1)} ${units[u]}`;
260
+ };
261
+
228
262
  const text = (body: string, details?: unknown) => ({
229
263
  content: [{ type: "text" as const, text: body }],
230
264
  details: details ?? {},
@@ -267,7 +301,7 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
267
301
  return text(
268
302
  `Automatic indexing is paused for ${eng.projectName}: the workspace exceeds ` +
269
303
  `${automaticIndexBlock.limit.toLocaleString()} indexable files. ` +
270
- "Run /context-index to index manually, or raise indexer.max_auto_files.",
304
+ `Run /context-index to index manually, or ${MAX_AUTO_FILES_HINT}.`,
271
305
  );
272
306
  }
273
307
  if (indexInFlight) {
@@ -293,6 +327,8 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
293
327
  let body = result.body;
294
328
  const nudge = eng.buildNudge();
295
329
  if (nudge) body += nudge;
330
+ const stale = staleIndexNote();
331
+ if (stale) body += `\n${stale}`;
296
332
  if (result.droppedLowValue > 0) {
297
333
  body +=
298
334
  "\n[note: lower-confidence results omitted — lower retrieval.marginal_ratio or retrieval.confidence_threshold to include them]";
@@ -431,7 +467,9 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
431
467
  description: "Check index health, chunk count, and token savings for this project.",
432
468
  parameters: Type.Object({}),
433
469
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
434
- return text(await getEngine(ctx.cwd).statusText());
470
+ const status = await getEngine(ctx.cwd).statusText();
471
+ const pause = pauseNotice();
472
+ return text(pause ? `${pause}\n${status}` : status);
435
473
  },
436
474
  });
437
475
 
@@ -471,9 +509,46 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
471
509
  /* ── Commands ────────────────────────────────────────────────────────── */
472
510
 
473
511
  pi.registerCommand("context-index", {
474
- description: "Build or refresh the semantic code index: /context-index [--full]",
512
+ description: "Build or refresh the semantic code index: /context-index [--full] [--reset]",
475
513
  handler: async (args, ctx) => {
476
514
  const eng = getEngine(ctx.cwd);
515
+ // --reset: delete the index through the engine (handles closed first, so
516
+ // it works while THIS session runs — manual deletion hits locked-file
517
+ // errors). Memory, sessions and savings survive. No auto-rebuild: the
518
+ // next search or /context-index decides, so an oversized workspace is
519
+ // not ambushed with a multi-minute build it just escaped.
520
+ if (args.includes("--reset")) {
521
+ if (indexInFlight) {
522
+ ctx.ui.notify(
523
+ "Context index: indexing is in progress — run /context-index --reset again once it finishes.",
524
+ "warning",
525
+ );
526
+ return;
527
+ }
528
+ // Reset also cancels the delayed startup kick. Otherwise its timer
529
+ // would rebuild the index immediately after the explicit delete.
530
+ if (autoIndexTimer) {
531
+ clearTimeout(autoIndexTimer);
532
+ autoIndexTimer = null;
533
+ }
534
+ const report = await eng.resetIndex();
535
+ // Free the lazy kick so the next search can rebuild (guarded by the
536
+ // same file budget as every automatic run).
537
+ lazyIndexStarted = false;
538
+ if (report.errors.length > 0) {
539
+ ctx.ui.notify(
540
+ `⚠ Index reset incomplete: ${report.errors.length} target(s) locked (${report.errors[0]}). Close other sessions on this project and run /context-index --reset again.`,
541
+ "warning",
542
+ );
543
+ } else {
544
+ ctx.ui.notify(
545
+ `✓ Index deleted: ${report.deletedFiles} file(s), ${formatBytes(report.freedBytes)} freed — memory, sessions and savings kept. ` +
546
+ "Run /context-index to build a fresh index.",
547
+ "info",
548
+ );
549
+ }
550
+ return;
551
+ }
477
552
  const full = args.includes("--full");
478
553
  ctx.ui.notify(full ? "Re-indexing everything…" : "Indexing (incremental)…", "info");
479
554
  const result = await eng.reindex({ full });
@@ -488,7 +563,9 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
488
563
  pi.registerCommand("context-status", {
489
564
  description: "Show index health and token savings for this project",
490
565
  handler: async (_args, ctx) => {
491
- ctx.ui.notify(await getEngine(ctx.cwd).statusText(), "info");
566
+ const status = await getEngine(ctx.cwd).statusText();
567
+ const pause = pauseNotice();
568
+ ctx.ui.notify(pause ? `${pause}\n${status}` : status, "info");
492
569
  },
493
570
  });
494
571
 
package/src/guidance.ts CHANGED
@@ -100,6 +100,7 @@ If the first search misses:
100
100
  - If a search says the index is empty, a background index is already running - retry shortly, or run \`/context-index\` for a synchronous build
101
101
  - After large refactors, \`reindex\` (or \`/context-index\`) keeps results current; \`index_status\` reports freshness and measured token savings
102
102
  - Incremental re-index only re-reads changed files, so it is cheap
103
+ - To wipe the index (stale or corrupted results), \`/context-index --reset\` deletes it safely mid-session - memory and sessions survive; the next search rebuilds
103
104
  `;
104
105
 
105
106
  /**
@@ -407,6 +407,22 @@ export async function indexCommits(
407
407
  */
408
408
  const pipelineLocks = new Map<string, Promise<unknown>>();
409
409
 
410
+ /**
411
+ * Queue work behind every indexing run already registered for this storage
412
+ * dir, and keep later runs queued until the work finishes. Reset uses this
413
+ * instead of a one-shot idle wait: a reindex queued during that wait must not
414
+ * open the stores while deletion is in progress.
415
+ */
416
+ export async function withPipelineLock<T>(storageBase: string, work: () => T | Promise<T>): Promise<T> {
417
+ const previous = pipelineLocks.get(storageBase) ?? Promise.resolve();
418
+ const current = previous.then(() => work());
419
+ pipelineLocks.set(
420
+ storageBase,
421
+ current.catch(() => undefined),
422
+ );
423
+ return current;
424
+ }
425
+
410
426
  export async function runIndexing(
411
427
  config: Config,
412
428
  projectDirInput: string,
@@ -416,23 +432,18 @@ export async function runIndexing(
416
432
  /** Automatic full-project runs only. Stop before migrations when the workspace exceeds this budget. */
417
433
  maxAutoFiles?: number;
418
434
  callbacks?: IndexCallbacks;
419
- /** Reuse an already-constructed backend/embedder (the long-lived server path). */
420
- runtime?: { backend: LocalBackend; embedder: Embedder };
435
+ /** Reuse an already-constructed backend/embedder (the long-lived server path);
436
+ * a null backend falls through to a fresh LocalBackend (used by resetIndex). */
437
+ runtime?: { backend?: LocalBackend | null; embedder: Embedder };
421
438
  } = {},
422
439
  ): Promise<IndexResult> {
423
440
  const projectDir = fs.realpathSync(path.resolve(projectDirInput));
424
441
  const storageBase = projectStorageDir(config.storagePath, projectDir);
425
442
  fs.mkdirSync(storageBase, { recursive: true });
426
443
 
427
- const previous = pipelineLocks.get(storageBase) ?? Promise.resolve();
428
- const current = previous.then(() =>
444
+ return withPipelineLock(storageBase, () =>
429
445
  runIndexingLocked(config, projectDir, storageBase, options, options.runtime),
430
446
  );
431
- pipelineLocks.set(
432
- storageBase,
433
- current.catch(() => undefined),
434
- );
435
- return current;
436
447
  }
437
448
 
438
449
  async function runIndexingLocked(
@@ -440,13 +451,16 @@ async function runIndexingLocked(
440
451
  projectDir: string,
441
452
  storageBase: string,
442
453
  options: { full?: boolean; targetPath?: string | null; maxAutoFiles?: number; callbacks?: IndexCallbacks },
443
- runtime?: { backend: LocalBackend; embedder: Embedder },
454
+ runtime?: { backend?: LocalBackend | null; embedder: Embedder },
444
455
  ): Promise<IndexResult> {
445
456
  let { full = false, targetPath = null } = options;
446
457
  const { callbacks = {} } = options;
447
458
  const { log, progress, phase } = callbacks;
448
459
 
449
- const ownsBackend = runtime === undefined;
460
+ // The caller only owns the backend it actually passed in; a null backend
461
+ // (resetIndex dropped its closed handle) means the pipeline built its own
462
+ // and must close it — otherwise its sqlite handles leak until exit.
463
+ const ownsBackend = !runtime?.backend;
450
464
  const chunker = new Chunker();
451
465
  const manifest = new Manifest(path.join(storageBase, "manifest.json"));
452
466
  const result: IndexResult = {