pi-context-engine 0.2.0 → 0.3.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
@@ -88,6 +88,19 @@ and the index lifecycle.
88
88
  Re-indexing only touches changed files. In the run above, a second pass over an
89
89
  unchanged repo took 237 ms.
90
90
 
91
+ ### Automatic indexing
92
+
93
+ - **Auto-index at session start**: ~2 s after a session opens, an incremental
94
+ reindex runs in the background, so the index is warm before the agent needs
95
+ it. You get a notification only when something changed.
96
+ - **Lazy fallback**: if a search still lands on an empty index, indexing kicks
97
+ off on the spot and the tool asks you to retry in a few seconds.
98
+ - **One switch for both**: `indexer.auto_index: false` disables both behaviors;
99
+ build the index manually with `/context-index` instead.
100
+ - **Why it matters**: incremental runs are cheap (a few hundred ms on an
101
+ unchanged repo), so this removes the classic cold-start failure of searching
102
+ an empty index, without ever blocking the UI.
103
+
91
104
  ## Requirements
92
105
 
93
106
  - **Node 22+** on macOS, Linux or Windows.
@@ -153,6 +166,7 @@ indexer:
153
166
  context_headers: true # prepend `// file: ... | unit: ... | imports: ...` to chunk text
154
167
  chunking: legacy # legacy (default) | ast-density — cAST-style budgeted split-merge (experimental)
155
168
  chunk_budget_chars: 1200 # non-whitespace budget per ast-density chunk
169
+ auto_index: true # incremental background reindex shortly after session start
156
170
  memory:
157
171
  redact_pii: true
158
172
  pricing:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-context-engine",
3
- "version": "0.2.0",
3
+ "version": "0.3.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/config.ts CHANGED
@@ -93,6 +93,8 @@ export interface Config {
93
93
  indexerIgnore: string[];
94
94
  /** Prepend the R1 context header (path/lang/unit/imports) to chunk content. */
95
95
  indexerContextHeaders: boolean;
96
+ /** Run an incremental reindex in the background shortly after session start. */
97
+ indexerAutoIndex: boolean;
96
98
  indexerRedactSecrets: boolean;
97
99
  memoryRedactPii: boolean;
98
100
  pricingModel: string;
@@ -125,6 +127,7 @@ export function defaultConfig(): Config {
125
127
  indexerChunkBudgetChars: 1200,
126
128
  indexerIgnore: [...DEFAULT_IGNORE],
127
129
  indexerContextHeaders: true,
130
+ indexerAutoIndex: true,
128
131
  indexerRedactSecrets: true,
129
132
  memoryRedactPii: true,
130
133
  pricingModel: "opus",
@@ -158,6 +161,7 @@ const MAPPING: Record<string, keyof Config> = {
158
161
  "indexer.chunk_budget_chars": "indexerChunkBudgetChars",
159
162
  "indexer.ignore": "indexerIgnore",
160
163
  "indexer.context_headers": "indexerContextHeaders",
164
+ "indexer.auto_index": "indexerAutoIndex",
161
165
  "indexer.redact_secrets": "indexerRedactSecrets",
162
166
  "memory.redact_pii": "memoryRedactPii",
163
167
  "storage.path": "storagePath",
package/src/engine.ts CHANGED
@@ -166,6 +166,7 @@ export class ContextEngine {
166
166
  private searchesSinceDecision = 0;
167
167
  private hasRecordedDecision = false;
168
168
  private vecBackfillStarted = false;
169
+ private autoIndexDone = false;
169
170
 
170
171
  constructor(options: EngineOptions) {
171
172
  this.projectDir = fs.realpathSync(path.resolve(options.projectDir));
@@ -336,6 +337,34 @@ export class ContextEngine {
336
337
  };
337
338
  }
338
339
 
340
+ /**
341
+ * One-shot background incremental index, kicked at session start by the
342
+ * extension (config `indexer.auto_index`, default true). Returns null when
343
+ * disabled or when this engine already ran (or skipped) its auto-index, so
344
+ * callers never stack a second background run. A REJECTED run (backend
345
+ * init, model download, embedding crash) un-consumes the one-shot so the
346
+ * next kick retries — the index is still empty, after all. The pipeline
347
+ * lock in runIndexing serializes this with a manual /context-index or a
348
+ * lazy search-triggered run, so races are safe — just wasteful, which the
349
+ * one-shot guard prevents.
350
+ */
351
+ async autoIndexOnce(): Promise<{
352
+ indexed: number;
353
+ chunks: number;
354
+ deleted: number;
355
+ skipped: number;
356
+ errors: string[];
357
+ } | null> {
358
+ if (!this.config.indexerAutoIndex || this.autoIndexDone) return null;
359
+ this.autoIndexDone = true;
360
+ try {
361
+ return await this.reindex({});
362
+ } catch (err) {
363
+ this.autoIndexDone = false;
364
+ throw err;
365
+ }
366
+ }
367
+
339
368
  // ── Memory writes ─────────────────────────────────────────────────────
340
369
 
341
370
  async recordDecision(
@@ -1252,7 +1281,9 @@ export class ContextEngine {
1252
1281
  lines.push(
1253
1282
  chunks > 0
1254
1283
  ? `${chunks.toLocaleString()} chunks indexed and searchable via context_search.`
1255
- : "Index is empty — run /context-index (or call reindex) to build it.",
1284
+ : this.config.indexerAutoIndex
1285
+ ? "Index is empty — a background index starts automatically at session start (indexer.auto_index); retry context_search shortly."
1286
+ : "Index is empty — run /context-index (or call reindex) to build it.",
1256
1287
  );
1257
1288
  return lines.join("\n");
1258
1289
  }
package/src/extension.ts CHANGED
@@ -16,12 +16,61 @@ import { applyContextSearchGuidance } from "./guidance.js";
16
16
  /** Tools whose payloads are worth capturing for session timelines. */
17
17
  const CAPTURED_TOOLS = new Set(["bash", "read", "edit", "write", "grep", "find", "ls"]);
18
18
 
19
+ /**
20
+ * Delay before the session-start auto-index kicks in: long enough that pi's
21
+ * initial render, skill loading and the user's first keystrokes win the event
22
+ * loop, short enough that the index is warm before the agent needs it.
23
+ */
24
+ const AUTO_INDEX_DELAY_MS = 2000;
25
+
19
26
  export default function contextEngineExtension(pi: ExtensionAPI) {
20
27
  let engine: ContextEngine | null = null;
21
28
  let currentCwd = process.cwd();
22
29
  let promptNumber = 0;
23
30
  let lazyIndexStarted = false;
24
31
  let bootstrapSent = false;
32
+ let autoIndexTimer: NodeJS.Timeout | null = null;
33
+ let indexInFlight = false;
34
+
35
+ /**
36
+ * Kick the one-shot background index (session-start auto-index and the
37
+ * first-search lazy path share this). Never stacks: `lazyIndexStarted`
38
+ * guards re-entry, `indexInFlight` lets context_search say "still
39
+ * building" instead of re-kicking, and `engine.autoIndexOnce()` is
40
+ * itself one-shot per engine instance.
41
+ */
42
+ const kickBackgroundIndex = (eng: ContextEngine, notify?: (msg: string, level: "info" | "warning") => void) => {
43
+ lazyIndexStarted = true;
44
+ indexInFlight = true;
45
+ void eng
46
+ .autoIndexOnce()
47
+ .then((result) => {
48
+ if (!result || !notify || engine !== eng) return; // session moved on
49
+ if (result.errors.length > 0) {
50
+ notify(`Context index: background index hit ${result.errors.length} error(s)`, "warning");
51
+ } else if (result.indexed > 0) {
52
+ notify(`Context index updated: ${result.indexed} file(s), ${result.chunks} chunk(s)`, "info");
53
+ }
54
+ })
55
+ .catch((err: unknown) => {
56
+ // Backend init, model download and embedding failures REJECT the
57
+ // reindex (result.errors only covers per-file errors), so without
58
+ // this branch a failed startup index would die silently and leave
59
+ // the lazy path burned on an empty index. Free the lazy path so a
60
+ // later search retries (autoIndexOnce un-consumes its one-shot),
61
+ // and surface the failure when we have a notify channel.
62
+ if (engine === eng) {
63
+ lazyIndexStarted = false;
64
+ if (notify) {
65
+ const detail = (err instanceof Error ? err.message : String(err)).slice(0, 120);
66
+ notify(`Context index: background indexing failed — run /context-index for details (${detail})`, "warning");
67
+ }
68
+ }
69
+ })
70
+ .finally(() => {
71
+ indexInFlight = false;
72
+ });
73
+ };
25
74
 
26
75
  const getEngine = (cwd?: string): ContextEngine => {
27
76
  const dir = cwd ?? currentCwd;
@@ -68,15 +117,29 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
68
117
  if (!query) return text("Query cannot be empty.");
69
118
  if (query.length > 10_000) return text("Query too long (max 10000 characters).");
70
119
 
71
- // Lazy index on first search: kick off in the background and tell the
120
+ // Empty index: a background build may already be running (session-start
121
+ // auto-index or an earlier search). Never stack a second run; tell the
72
122
  // caller to retry instead of blocking for a multi-minute first index.
73
- if (eng.chunkCount() === 0 && !lazyIndexStarted) {
74
- lazyIndexStarted = true;
75
- void eng.reindex({}).catch(() => undefined);
76
- return text(
77
- `Index for ${eng.projectName} is empty — indexing started in the background. ` +
78
- "Retry this search in a few seconds, or run /context-index for a synchronous index.",
79
- );
123
+ // `indexer.auto_index: false` disables this lazy kick too — one switch
124
+ // for all automatic indexing.
125
+ if (eng.chunkCount() === 0) {
126
+ if (indexInFlight) {
127
+ return text(
128
+ `Index for ${eng.projectName} is still building in the background — retry this search in a few seconds.`,
129
+ );
130
+ }
131
+ if (!eng.config.indexerAutoIndex) {
132
+ return text(
133
+ `Index for ${eng.projectName} is empty and indexer.auto_index is off — run /context-index (or call reindex) to build it.`,
134
+ );
135
+ }
136
+ if (!lazyIndexStarted) {
137
+ kickBackgroundIndex(eng);
138
+ return text(
139
+ `Index for ${eng.projectName} is empty — indexing started in the background. ` +
140
+ "Retry this search in a few seconds, or run /context-index for a synchronous index.",
141
+ );
142
+ }
80
143
  }
81
144
 
82
145
  const result = await eng.search(query, { topK: params.top_k, maxTokens: params.max_tokens });
@@ -309,6 +372,39 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
309
372
  engine.startBackgroundWork();
310
373
  lazyIndexStarted = false;
311
374
  bootstrapSent = false;
375
+ if (autoIndexTimer) {
376
+ clearTimeout(autoIndexTimer);
377
+ autoIndexTimer = null;
378
+ }
379
+ // Auto-index (config `indexer.auto_index`): kick an incremental reindex
380
+ // shortly after the session is ready, so the agent never discovers an
381
+ // empty or stale index mid-task. Incremental runs are cheap when nothing
382
+ // changed (walk + hash only); the pipeline lock serializes this with a
383
+ // manual /context-index or a lazy search-triggered run. Errors surface as
384
+ // a notification, never as a session failure.
385
+ if (engine.config.indexerAutoIndex) {
386
+ const eng = engine;
387
+ autoIndexTimer = setTimeout(() => {
388
+ autoIndexTimer = null;
389
+ if (lazyIndexStarted) return; // a search already kicked a background index
390
+ if (engine !== eng) {
391
+ // getEngine switched projects mid-delay (a tool ran with a
392
+ // different cwd): index the LIVE engine instead of the retired
393
+ // one, so the new project still gets its warmup instead of
394
+ // waiting for an unlucky lazy kick.
395
+ if (engine) {
396
+ kickBackgroundIndex(engine, (msg, level) => {
397
+ if (ctx.hasUI) ctx.ui.notify(msg, level);
398
+ });
399
+ }
400
+ return;
401
+ }
402
+ kickBackgroundIndex(eng, (msg, level) => {
403
+ if (ctx.hasUI) ctx.ui.notify(msg, level);
404
+ });
405
+ }, AUTO_INDEX_DELAY_MS);
406
+ autoIndexTimer.unref?.();
407
+ }
312
408
  });
313
409
 
314
410
  pi.on("before_agent_start", async (event, ctx) => {
@@ -355,6 +451,10 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
355
451
  });
356
452
 
357
453
  pi.on("session_shutdown", async () => {
454
+ if (autoIndexTimer) {
455
+ clearTimeout(autoIndexTimer);
456
+ autoIndexTimer = null;
457
+ }
358
458
  await engine?.shutdown().catch(() => undefined);
359
459
  engine = null;
360
460
  });
@@ -7,7 +7,7 @@
7
7
  import path from "node:path";
8
8
  import fs from "node:fs";
9
9
  import os from "node:os";
10
- import { sha256Hex } from "../utils.js";
10
+ import { sha256Hex, yieldToEventLoop } from "../utils.js";
11
11
 
12
12
  export const DEFAULT_LOCAL_MODEL = "Xenova/all-MiniLM-L6-v2";
13
13
  /** R11: code-trained local embedder, measured in README (not a default — see eval table). */
@@ -38,6 +38,14 @@ export function transformersCacheDir(): string {
38
38
  return resolveModelCacheDir();
39
39
  }
40
40
 
41
+ /**
42
+ * Texts per embed() call in LocalBackend. ONNX inference and tokenization run
43
+ * on the main thread, so this bounds one synchronous block: 64 texts measured
44
+ * at ~0.5-0.9 s per call (a visible TUI freeze, see
45
+ * scripts/bench-event-loop.mts); 16 lands around ~100 ms per block.
46
+ */
47
+ const LOCAL_SUB_BATCH = 16;
48
+
41
49
  interface TransformersModule {
42
50
  pipeline: (
43
51
  task: string,
@@ -135,14 +143,18 @@ export class LocalBackend implements EmbeddingBackend {
135
143
  this.dimension = probe[0]!.length;
136
144
  }
137
145
 
138
- async embedTexts(texts: string[], _batchSize = 64): Promise<number[][]> {
146
+ async embedTexts(texts: string[], _batchSize?: number): Promise<number[][]> {
139
147
  const embed = await this.getPipeline();
140
148
  if (texts.length === 0) return [];
141
149
  if (texts.length === 1) return embed(texts);
142
150
  const out: number[][] = [];
143
- for (let i = 0; i < texts.length; i += 64) {
144
- const batch = await embed(texts.slice(i, i + 64));
151
+ for (let i = 0; i < texts.length; i += LOCAL_SUB_BATCH) {
152
+ const batch = await embed(texts.slice(i, i + LOCAL_SUB_BATCH));
145
153
  out.push(...batch);
154
+ // Cooperative yield: one embed() call blocks the main thread for the
155
+ // whole sub-batch (tokenization + native ONNX run), so without this
156
+ // the UI starves for the whole indexing run.
157
+ if (i + LOCAL_SUB_BATCH < texts.length) await yieldToEventLoop();
146
158
  }
147
159
  return out;
148
160
  }
@@ -17,29 +17,47 @@ interface ManifestData {
17
17
  export class Manifest {
18
18
  private entries: Record<string, string> = {};
19
19
  private schemaVersionValue: number = CURRENT_SCHEMA_VERSION;
20
+ /** Set when an existing manifest was unreadable or not in the recognized
21
+ * format — the pipeline wipes the index stores so no stale rows survive. */
22
+ private storeResetNeeded = false;
20
23
  private lastGitShaValue: string | null = null;
21
24
  private embeddingDimValue: number | null = null;
22
25
 
23
26
  constructor(private readonly manifestPath: string) {
24
- if (fs.existsSync(manifestPath)) {
25
- try {
26
- const loaded = JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as ManifestData;
27
- if (loaded && typeof loaded === "object") {
28
- if ("__schema_version" in loaded) {
29
- this.schemaVersionValue = loaded.__schema_version ?? 1;
30
- this.entries = loaded.files ?? {};
31
- this.lastGitShaValue = loaded.last_git_sha ?? null;
32
- this.embeddingDimValue = loaded.embedding_dim ?? null;
33
- } else {
34
- // Old plain-dict format (pre-v0.2) — treat as version 1.
35
- this.schemaVersionValue = 1;
36
- this.entries = loaded as unknown as Record<string, string>;
37
- }
38
- }
39
- } catch {
40
- this.entries = {};
27
+ if (!fs.existsSync(manifestPath)) return;
28
+ // An existing manifest must prove it is in the recognized versioned
29
+ // format; until it does, it is treated as foreign (version 0) and the
30
+ // pipeline wipes the stores before rebuilding. No compat parsing of
31
+ // pre-versioning shapes.
32
+ let recognized = false;
33
+ try {
34
+ const loaded = JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as ManifestData;
35
+ const version = loaded && typeof loaded === "object" ? loaded.__schema_version : undefined;
36
+ const files = loaded && typeof loaded === "object" ? loaded.files : undefined;
37
+ if (
38
+ typeof version === "number" &&
39
+ version > 0 &&
40
+ files !== null &&
41
+ typeof files === "object" &&
42
+ !Array.isArray(files)
43
+ ) {
44
+ recognized = true;
45
+ this.schemaVersionValue = version;
46
+ this.entries = files ?? {};
47
+ this.lastGitShaValue = loaded.last_git_sha ?? null;
48
+ this.embeddingDimValue = loaded.embedding_dim ?? null;
41
49
  }
50
+ } catch {
51
+ // corrupt JSON — falls through to the reset below
42
52
  }
53
+ if (!recognized) this.schemaVersionValue = 0;
54
+ this.storeResetNeeded = !recognized || this.schemaVersionValue !== CURRENT_SCHEMA_VERSION;
55
+ }
56
+
57
+ /** True when the on-disk manifest was unusable/foreign and the index stores
58
+ * must be wiped before this run repopulates them. */
59
+ get needsStoreReset(): boolean {
60
+ return this.storeResetNeeded;
43
61
  }
44
62
 
45
63
  get lastGitSha(): string | null {