pi-context-engine 0.5.1 → 0.5.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
@@ -103,6 +103,22 @@ unchanged repo took 237 ms.
103
103
  - **Switches**: `indexer.auto_index: false` disables the session-start and
104
104
  lazy kicks; `indexer.watch: false` disables the file watcher. Use
105
105
  `/context-index` for manual builds.
106
+ - **Large-workspace guard**: automatic indexing and the watcher pause with a
107
+ warning above `indexer.max_auto_files` (default 10,000). The check uses the
108
+ normal directory walk and stops at the first file above the limit, so it does
109
+ not add a full directory scan. Periodic yields keep the check responsive.
110
+ This prevents an accidental parent-directory
111
+ session from starting an unrequested multi-minute workload. `/context-index`
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.
116
+ - **Resume after interruption**: the manifest checkpoints completed batches,
117
+ starting with the first batch and then at most once every five seconds. An
118
+ interrupted build can skip completed files instead of starting from zero.
119
+ Migration runs (schema reset, embedding-dimension change) are exempt: their
120
+ manifest stays untouched on disk until the run completes, so an interrupted
121
+ migration resumes cleanly instead of stranding stale rows.
106
122
  - **Cold-start visibility**: the first build on an empty index announces its
107
123
  start (`building the first index…`) and always closes with a result line
108
124
  (`updated: N file(s)` or `no files to index`). This is the one background
@@ -192,6 +208,7 @@ indexer:
192
208
  chunking: legacy # legacy (default) | ast-density — cAST-style budgeted split-merge (experimental)
193
209
  chunk_budget_chars: 1200 # non-whitespace budget per ast-density chunk
194
210
  auto_index: true # incremental background reindex shortly after session start
211
+ max_auto_files: 10000 # automatic full-project file budget; 0 disables the guard
195
212
  watch: true # reindex on file changes while the session runs (debounced, incremental)
196
213
  memory:
197
214
  redact_pii: true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-context-engine",
3
- "version": "0.5.1",
3
+ "version": "0.5.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
@@ -95,6 +95,8 @@ export interface Config {
95
95
  indexerContextHeaders: boolean;
96
96
  /** Run an incremental reindex in the background shortly after session start. */
97
97
  indexerAutoIndex: boolean;
98
+ /** Maximum files for automatic full-project indexing. Zero disables the guard; manual runs always bypass it. */
99
+ indexerMaxAutoFiles: number;
98
100
  /**
99
101
  * Watch the project tree while a session runs and reindex on changes
100
102
  * (debounced, incremental). Independent of `indexerAutoIndex`, which only
@@ -134,6 +136,7 @@ export function defaultConfig(): Config {
134
136
  indexerIgnore: [...DEFAULT_IGNORE],
135
137
  indexerContextHeaders: true,
136
138
  indexerAutoIndex: true,
139
+ indexerMaxAutoFiles: 10_000,
137
140
  indexerWatch: true,
138
141
  indexerRedactSecrets: true,
139
142
  memoryRedactPii: true,
@@ -169,6 +172,7 @@ const MAPPING: Record<string, keyof Config> = {
169
172
  "indexer.ignore": "indexerIgnore",
170
173
  "indexer.context_headers": "indexerContextHeaders",
171
174
  "indexer.auto_index": "indexerAutoIndex",
175
+ "indexer.max_auto_files": "indexerMaxAutoFiles",
172
176
  "indexer.watch": "indexerWatch",
173
177
  "indexer.redact_secrets": "indexerRedactSecrets",
174
178
  "memory.redact_pii": "memoryRedactPii",
@@ -212,6 +216,11 @@ function applyDict(config: Config, data: any): void {
212
216
  if (typeof item === "string" && !merged.includes(item)) merged.push(item);
213
217
  }
214
218
  config.indexerIgnore = merged;
219
+ } else if (attr === "indexerMaxAutoFiles") {
220
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
221
+ throw new Error(`config ${dotted} must be a non-negative integer`);
222
+ }
223
+ config.indexerMaxAutoFiles = value;
215
224
  } else if (attr === "pricingInput" || attr === "pricingOutput") {
216
225
  if (value !== null && typeof value !== "number") throw new Error(`config ${dotted} must be a number or null`);
217
226
  (config as any)[attr] = value;
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 { runIndexing } from "./indexer/pipeline.js";
23
+ import { AutomaticIndexFileLimitError, runIndexing } from "./indexer/pipeline.js";
24
24
  import { getModelPricing } from "./pricing.js";
25
25
  import type { Chunk } from "./types.js";
26
26
 
@@ -358,7 +358,7 @@ export class ContextEngine {
358
358
  return this.backend.countChunks();
359
359
  }
360
360
 
361
- async reindex(options: { full?: boolean; targetPath?: string | null; onProgress?: (msg: string) => void } = {}): Promise<{
361
+ async reindex(options: { full?: boolean; targetPath?: string | null; automatic?: boolean; onProgress?: (msg: string) => void } = {}): Promise<{
362
362
  indexed: number;
363
363
  chunks: number;
364
364
  deleted: number;
@@ -368,6 +368,10 @@ export class ContextEngine {
368
368
  const result = await runIndexing(this.config, this.projectDir, {
369
369
  full: options.full,
370
370
  targetPath: options.targetPath ?? null,
371
+ maxAutoFiles:
372
+ options.automatic && !options.targetPath && this.config.indexerMaxAutoFiles > 0
373
+ ? this.config.indexerMaxAutoFiles
374
+ : undefined,
371
375
  callbacks: { phase: options.onProgress },
372
376
  runtime: this.injectedRuntime,
373
377
  });
@@ -393,7 +397,8 @@ export class ContextEngine {
393
397
  * next kick retries — the index is still empty, after all. The pipeline
394
398
  * lock in runIndexing serializes this with a manual /context-index or a
395
399
  * lazy search-triggered run, so races are safe — just wasteful, which the
396
- * one-shot guard prevents.
400
+ * one-shot guard prevents. Transient rejections un-consume the one-shot;
401
+ * a workspace-size refusal stays consumed because retrying cannot help.
397
402
  */
398
403
  async autoIndexOnce(): Promise<{
399
404
  indexed: number;
@@ -405,9 +410,11 @@ export class ContextEngine {
405
410
  if (!this.config.indexerAutoIndex || this.autoIndexDone) return null;
406
411
  this.autoIndexDone = true;
407
412
  try {
408
- return await this.reindex({});
413
+ return await this.reindex({ automatic: true });
409
414
  } catch (err) {
410
- this.autoIndexDone = false;
415
+ // A workspace-size refusal is a stable policy decision, not a
416
+ // transient embedding failure. Do not retry it on every lazy search.
417
+ if (!(err instanceof AutomaticIndexFileLimitError)) this.autoIndexDone = false;
411
418
  throw err;
412
419
  }
413
420
  }
package/src/extension.ts CHANGED
@@ -13,6 +13,7 @@ import path from "node:path";
13
13
  import { ContextEngine, isPceConfig } from "./engine.js";
14
14
  import { applyContextSearchGuidance } from "./guidance.js";
15
15
  import { ProjectWatcher } from "./indexer/watcher.js";
16
+ import { AutomaticIndexFileLimitError } from "./indexer/pipeline.js";
16
17
 
17
18
  /** Tools whose payloads are worth capturing for session timelines. */
18
19
  const CAPTURED_TOOLS = new Set(["bash", "read", "edit", "write", "grep", "find", "ls"]);
@@ -25,6 +26,26 @@ const CAPTURED_TOOLS = new Set(["bash", "read", "edit", "write", "grep", "find",
25
26
  const AUTO_INDEX_DELAY_MS = 2000;
26
27
 
27
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
+
28
49
  let engine: ContextEngine | null = null;
29
50
  let currentCwd = process.cwd();
30
51
  let promptNumber = 0;
@@ -32,6 +53,7 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
32
53
  let bootstrapSent = false;
33
54
  let autoIndexTimer: NodeJS.Timeout | null = null;
34
55
  let indexInFlight = false;
56
+ let automaticIndexBlock: AutomaticIndexFileLimitError | null = null;
35
57
 
36
58
  /**
37
59
  * File watcher (config `indexer.watch`, default true): a debounced change
@@ -46,22 +68,23 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
46
68
 
47
69
  /**
48
70
  * Watcher-triggered incremental reindex. Unlike `kickBackgroundIndex` this
49
- * calls `reindex()` directly — the watcher must re-fire all session, not
50
- * consume a one-shot. Shares `indexInFlight` with the other kicks so runs
71
+ * calls `reindex({ automatic: true })` directly — the watcher must re-fire
72
+ * all session, not consume a one-shot. The automatic flag enforces the same
73
+ * workspace file budget as session-start indexing. Shares `indexInFlight` with the other kicks so runs
51
74
  * never stack; a change that arrives mid-run coalesces into exactly one
52
75
  * trailing kick, so edits made *while indexing* still land. Successful
53
76
  * runs stay silent (the user's own edit caused them); failures — both
54
77
  * rejections AND per-file errors resolved in `result.errors` — notify.
55
78
  */
56
79
  const kickWatcherIndex = (eng: ContextEngine, notify?: (msg: string, level: "info" | "warning") => void) => {
57
- if (engine !== eng) return; // session moved on
80
+ if (engine !== eng || automaticIndexBlock) return; // session moved on, or the workspace exceeds the auto budget
58
81
  if (indexInFlight) {
59
82
  watcherKickPending = true;
60
83
  return;
61
84
  }
62
85
  indexInFlight = true;
63
86
  void eng
64
- .reindex({})
87
+ .reindex({ automatic: true })
65
88
  .then((result) => {
66
89
  // Per-file chunking/read failures RESOLVE in result.errors — mirroring
67
90
  // kickBackgroundIndex, surface them or affected files go stale silently.
@@ -71,7 +94,10 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
71
94
  }
72
95
  })
73
96
  .catch((err: unknown) => {
74
- if (engine === eng && notify) {
97
+ if (engine !== eng) return;
98
+ if (err instanceof AutomaticIndexFileLimitError) {
99
+ pauseAutomaticIndexing(eng, err, notify);
100
+ } else if (notify) {
75
101
  const detail = (err instanceof Error ? err.message : String(err)).slice(0, 120);
76
102
  notify(`Context index: watcher reindex failed (${detail})`, "warning");
77
103
  }
@@ -95,6 +121,24 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
95
121
  watcherKickPending = false;
96
122
  };
97
123
 
124
+ /** A stable size refusal pauses every automatic path until the next session. */
125
+ const pauseAutomaticIndexing = (
126
+ eng: ContextEngine,
127
+ err: AutomaticIndexFileLimitError,
128
+ notify?: (msg: string, level: "info" | "warning") => void,
129
+ ) => {
130
+ if (engine !== eng) return;
131
+ automaticIndexBlock = err;
132
+ lazyIndexStarted = true; // a lazy search must not immediately retry the same refusal
133
+ stopWatcher();
134
+ notify?.(
135
+ `Context index: automatic indexing${eng.config.indexerWatch ? " and watcher" : ""} paused. ` +
136
+ `Workspace exceeds ${err.limit.toLocaleString()} indexable files. ` +
137
+ `Run /context-index to index anyway, or ${MAX_AUTO_FILES_HINT}.`,
138
+ "warning",
139
+ );
140
+ };
141
+
98
142
  /**
99
143
  * (Re)point the file watcher at the live engine's project dir. Idempotent
100
144
  * per dir (called from every `getEngine`), so a mid-session project switch
@@ -135,6 +179,7 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
135
179
  * itself one-shot per engine instance.
136
180
  */
137
181
  const kickBackgroundIndex = (eng: ContextEngine, notify?: (msg: string, level: "info" | "warning") => void) => {
182
+ if (engine !== eng || automaticIndexBlock) return;
138
183
  lazyIndexStarted = true;
139
184
  indexInFlight = true;
140
185
  // Cold-start visibility: the first build on an empty index is the only
@@ -167,12 +212,15 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
167
212
  // the lazy path burned on an empty index. Free the lazy path so a
168
213
  // later search retries (autoIndexOnce un-consumes its one-shot),
169
214
  // and surface the failure when we have a notify channel.
170
- if (engine === eng) {
171
- lazyIndexStarted = false;
172
- if (notify) {
173
- const detail = (err instanceof Error ? err.message : String(err)).slice(0, 120);
174
- notify(`Context index: background indexing failed — run /context-index for details (${detail})`, "warning");
175
- }
215
+ if (engine !== eng) return;
216
+ if (err instanceof AutomaticIndexFileLimitError) {
217
+ pauseAutomaticIndexing(eng, err, notify);
218
+ return;
219
+ }
220
+ lazyIndexStarted = false;
221
+ if (notify) {
222
+ const detail = (err instanceof Error ? err.message : String(err)).slice(0, 120);
223
+ notify(`Context index: background indexing failed — run /context-index for details (${detail})`, "warning");
176
224
  }
177
225
  })
178
226
  .finally(() => {
@@ -188,6 +236,8 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
188
236
  // Best-effort: a cross-project switch must not leak sqlite handles.
189
237
  void engine.shutdown().catch(() => undefined);
190
238
  }
239
+ automaticIndexBlock = null;
240
+ lazyIndexStarted = false;
191
241
  // Always track the live engine so `session_shutdown` closes it even when
192
242
  // a tool ran before `session_start` (tests, mid-reload edge cases).
193
243
  engine = new ContextEngine({ projectDir: dir });
@@ -233,6 +283,13 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
233
283
  // `indexer.auto_index: false` disables this lazy kick too — one switch
234
284
  // for all automatic indexing.
235
285
  if (eng.chunkCount() === 0) {
286
+ if (automaticIndexBlock) {
287
+ return text(
288
+ `Automatic indexing is paused for ${eng.projectName}: the workspace exceeds ` +
289
+ `${automaticIndexBlock.limit.toLocaleString()} indexable files. ` +
290
+ `Run /context-index to index manually, or ${MAX_AUTO_FILES_HINT}.`,
291
+ );
292
+ }
236
293
  if (indexInFlight) {
237
294
  return text(
238
295
  `Index for ${eng.projectName} is still building in the background — retry this search in a few seconds.`,
@@ -256,6 +313,8 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
256
313
  let body = result.body;
257
314
  const nudge = eng.buildNudge();
258
315
  if (nudge) body += nudge;
316
+ const stale = staleIndexNote();
317
+ if (stale) body += `\n${stale}`;
259
318
  if (result.droppedLowValue > 0) {
260
319
  body +=
261
320
  "\n[note: lower-confidence results omitted — lower retrieval.marginal_ratio or retrieval.confidence_threshold to include them]";
@@ -394,7 +453,9 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
394
453
  description: "Check index health, chunk count, and token savings for this project.",
395
454
  parameters: Type.Object({}),
396
455
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
397
- return text(await getEngine(ctx.cwd).statusText());
456
+ const status = await getEngine(ctx.cwd).statusText();
457
+ const pause = pauseNotice();
458
+ return text(pause ? `${pause}\n${status}` : status);
398
459
  },
399
460
  });
400
461
 
@@ -451,7 +512,9 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
451
512
  pi.registerCommand("context-status", {
452
513
  description: "Show index health and token savings for this project",
453
514
  handler: async (_args, ctx) => {
454
- ctx.ui.notify(await getEngine(ctx.cwd).statusText(), "info");
515
+ const status = await getEngine(ctx.cwd).statusText();
516
+ const pause = pauseNotice();
517
+ ctx.ui.notify(pause ? `${pause}\n${status}` : status, "info");
455
518
  },
456
519
  });
457
520
 
@@ -494,6 +557,7 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
494
557
 
495
558
  pi.on("session_start", async (_event, ctx) => {
496
559
  currentCwd = ctx.cwd;
560
+ automaticIndexBlock = null;
497
561
  engine = new ContextEngine({ projectDir: ctx.cwd });
498
562
  engine.startBackgroundWork();
499
563
  lazyIndexStarted = false;
@@ -589,6 +653,7 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
589
653
  }
590
654
  stopWatcher();
591
655
  watcherNotify = undefined;
656
+ automaticIndexBlock = null;
592
657
  await engine?.shutdown().catch(() => undefined);
593
658
  engine = null;
594
659
  });
@@ -112,9 +112,24 @@ const LANGUAGE_MAP: Record<string, string> = {
112
112
  /** Skip any single file larger than this — protects from OOM on log dumps. */
113
113
  const MAX_FILE_BYTES = 2 * 1024 * 1024;
114
114
  const BATCH = 50;
115
+ const MANIFEST_CHECKPOINT_MS = 5000;
116
+ const WALK_YIELD_FILES = 250;
117
+ const WALK_YIELD_MS = 50;
115
118
 
116
119
  export class PathOutsideProjectError extends Error {}
117
120
 
121
+ /** Automatic full-project runs refuse workspaces above their configured file budget. */
122
+ export class AutomaticIndexFileLimitError extends Error {
123
+ constructor(
124
+ readonly limit: number,
125
+ /** Exact manifest size, or limit + 1 when the capped walk stopped early. */
126
+ readonly observedFiles: number,
127
+ ) {
128
+ super(`Automatic indexing exceeds the ${limit}-file limit (found at least ${observedFiles})`);
129
+ this.name = "AutomaticIndexFileLimitError";
130
+ }
131
+ }
132
+
118
133
  export interface IndexResult {
119
134
  indexedFiles: string[];
120
135
  skippedFiles: string[];
@@ -235,6 +250,26 @@ function* iterProjectFiles(
235
250
  yield* walk(walkRoot);
236
251
  }
237
252
 
253
+ /**
254
+ * Collect a directory walk without starving pi's input/render loop. The file
255
+ * count covers cheap SSD walks; the time bound also covers slow filesystems.
256
+ */
257
+ async function collectProjectFiles(files: Iterable<string>, maxAutoFiles?: number): Promise<string[]> {
258
+ const collected: string[] = [];
259
+ let lastYield = Date.now();
260
+ for (const filePath of files) {
261
+ if (maxAutoFiles !== undefined && collected.length >= maxAutoFiles) {
262
+ throw new AutomaticIndexFileLimitError(maxAutoFiles, collected.length + 1);
263
+ }
264
+ collected.push(filePath);
265
+ if (collected.length % WALK_YIELD_FILES === 0 || Date.now() - lastYield >= WALK_YIELD_MS) {
266
+ await yieldToEventLoop();
267
+ lastYield = Date.now();
268
+ }
269
+ }
270
+ return collected;
271
+ }
272
+
238
273
  /** `{embeddings, cacheHits, cacheMisses}` for a batch, using the content cache. */
239
274
  async function embedChunks(
240
275
  chunks: Chunk[],
@@ -378,6 +413,8 @@ export async function runIndexing(
378
413
  options: {
379
414
  full?: boolean;
380
415
  targetPath?: string | null;
416
+ /** Automatic full-project runs only. Stop before migrations when the workspace exceeds this budget. */
417
+ maxAutoFiles?: number;
381
418
  callbacks?: IndexCallbacks;
382
419
  /** Reuse an already-constructed backend/embedder (the long-lived server path). */
383
420
  runtime?: { backend: LocalBackend; embedder: Embedder };
@@ -402,7 +439,7 @@ async function runIndexingLocked(
402
439
  config: Config,
403
440
  projectDir: string,
404
441
  storageBase: string,
405
- options: { full?: boolean; targetPath?: string | null; callbacks?: IndexCallbacks },
442
+ options: { full?: boolean; targetPath?: string | null; maxAutoFiles?: number; callbacks?: IndexCallbacks },
406
443
  runtime?: { backend: LocalBackend; embedder: Embedder },
407
444
  ): Promise<IndexResult> {
408
445
  let { full = false, targetPath = null } = options;
@@ -410,7 +447,6 @@ async function runIndexingLocked(
410
447
  const { log, progress, phase } = callbacks;
411
448
 
412
449
  const ownsBackend = runtime === undefined;
413
- const backend = runtime?.backend ?? new LocalBackend(storageBase);
414
450
  const chunker = new Chunker();
415
451
  const manifest = new Manifest(path.join(storageBase, "manifest.json"));
416
452
  const result: IndexResult = {
@@ -423,11 +459,49 @@ async function runIndexingLocked(
423
459
  cacheMisses: 0,
424
460
  };
425
461
 
462
+ const ignoreSet = new Set(config.indexerIgnore);
463
+ const rootIgnoreSet = new Set(ROOT_ONLY_IGNORE);
464
+ const cceignorePatterns = loadIgnorePatterns(projectDir);
465
+ if (cceignorePatterns.length > 0) log?.(` [.cceignore] ${cceignorePatterns.length} pattern(s) loaded`);
466
+
426
467
  // PR #7 review fix: migrations below call manifest.clearEntries(), which
427
468
  // also erases the file list pruneFilesNotIn() needs to detect files that
428
469
  // vanished BEFORE this run. Snapshot the paths first so the prune step
429
470
  // can still delete their indexed chunks instead of orphaning them.
430
471
  const preMigrationPaths = Object.keys(manifest.fileEntries);
472
+ // Checkpoints (below) persist the in-memory manifest mid-run. A run that
473
+ // wiped or invalidated existing entries must NOT do that: the on-disk
474
+ // manifest is what a resumed run derives its migration context from
475
+ // (pre-migration paths, old schema version, git pointer). Checkpointing
476
+ // after clearEntries() would persist the emptied state, so an interrupted
477
+ // migration would resume as a plain incremental run and orphan FTS/graph
478
+ // rows of vanished files or skip the git-history rebuild (Augment review,
479
+ // PR #21). Migration runs save once at the end, exactly as before
480
+ // checkpoints existed — losing resume progress there is fine; correctness
481
+ // is not.
482
+ let migrationRun = false;
483
+
484
+ // The automatic file budget rides on the walk we already need. No second
485
+ // directory scan or per-file stat pass is required. Reuse the completed
486
+ // walk below, and stop at limit + 1 before any store migration can wipe an
487
+ // existing index. A large manifest is an even cheaper early refusal.
488
+ let precheckedFileList: string[] | null = null;
489
+ if (options.maxAutoFiles !== undefined && targetPath === null) {
490
+ if (preMigrationPaths.length > options.maxAutoFiles) {
491
+ throw new AutomaticIndexFileLimitError(options.maxAutoFiles, preMigrationPaths.length);
492
+ }
493
+ precheckedFileList = await collectProjectFiles(
494
+ iterProjectFiles(projectDir, projectDir, ignoreSet, rootIgnoreSet, {
495
+ redactSecrets: config.indexerRedactSecrets,
496
+ cceignorePatterns,
497
+ }),
498
+ options.maxAutoFiles,
499
+ );
500
+ }
501
+
502
+ // Open stores only after the automatic guard has passed, so a refusal
503
+ // cannot leak SQLite handles on the session's long-lived process.
504
+ const backend = runtime?.backend ?? new LocalBackend(storageBase);
431
505
 
432
506
  // Refuse unrecognized index formats outright (no compat reading): a
433
507
  // manifest we can't recognize — pre-versioning, corrupt, or a future
@@ -452,13 +526,9 @@ async function runIndexingLocked(
452
526
  targetPath = null;
453
527
  }
454
528
  full = true;
529
+ migrationRun = true;
455
530
  }
456
531
 
457
- const ignoreSet = new Set(config.indexerIgnore);
458
- const rootIgnoreSet = new Set(ROOT_ONLY_IGNORE);
459
- const cceignorePatterns = loadIgnorePatterns(projectDir);
460
- if (cceignorePatterns.length > 0) log?.(` [.cceignore] ${cceignorePatterns.length} pattern(s) loaded`);
461
-
462
532
  // ── Determine the file set ─────────────────────────────────────────────
463
533
  /**
464
534
  * A file the inclusion policy now rejects must not leave stale chunks
@@ -519,12 +589,12 @@ async function runIndexingLocked(
519
589
  result.skippedFiles.push(rel);
520
590
  fileList = [];
521
591
  } else {
522
- fileList = [
523
- ...iterProjectFiles(target, projectDir, ignoreSet, rootIgnoreSet, {
592
+ fileList = await collectProjectFiles(
593
+ iterProjectFiles(target, projectDir, ignoreSet, rootIgnoreSet, {
524
594
  redactSecrets: config.indexerRedactSecrets,
525
595
  cceignorePatterns,
526
596
  }),
527
- ];
597
+ );
528
598
  }
529
599
  // Targeted runs skip the full-run prune below, so purge manifest
530
600
  // entries under this subtree the walk no longer yields: newly
@@ -548,12 +618,12 @@ async function runIndexingLocked(
548
618
  return result;
549
619
  }
550
620
  } else {
551
- fileList = [
552
- ...iterProjectFiles(projectDir, projectDir, ignoreSet, rootIgnoreSet, {
621
+ fileList = precheckedFileList ?? (await collectProjectFiles(
622
+ iterProjectFiles(projectDir, projectDir, ignoreSet, rootIgnoreSet, {
553
623
  redactSecrets: config.indexerRedactSecrets,
554
624
  cceignorePatterns,
555
625
  }),
556
- ];
626
+ ));
557
627
  }
558
628
 
559
629
  // Warm up every AST grammar we're about to need (async, once).
@@ -616,6 +686,7 @@ async function runIndexingLocked(
616
686
  // The dim swap dropped stored vectors (git history included); reset
617
687
  // the pointer so the next full run re-fetches those commits too.
618
688
  manifest.lastGitSha = null;
689
+ migrationRun = true;
619
690
  }
620
691
  }
621
692
 
@@ -624,6 +695,19 @@ async function runIndexingLocked(
624
695
  let announced = false;
625
696
  let totalCacheHits = 0;
626
697
  let totalCacheMisses = 0;
698
+ // The first successful batch checkpoints immediately; later writes are
699
+ // rate-limited. An interrupted run can therefore skip completed batches
700
+ // without paying a manifest write for every file. Migration runs are
701
+ // exempt (see migrationRun above): their manifest stays untouched on
702
+ // disk until the run completes.
703
+ let lastManifestCheckpoint = 0;
704
+ const checkpointManifest = () => {
705
+ if (migrationRun) return;
706
+ const now = Date.now();
707
+ if (now - lastManifestCheckpoint < MANIFEST_CHECKPOINT_MS) return;
708
+ manifest.save();
709
+ lastManifestCheckpoint = now;
710
+ };
627
711
 
628
712
  for (let batchIndex = 0; batchIndex < fileList.length; batchIndex += BATCH) {
629
713
  // Cooperative yield: the loops below do sync I/O, tree-sitter parsing
@@ -798,6 +882,7 @@ async function runIndexingLocked(
798
882
 
799
883
  if (batchChunks.length > 0) {
800
884
  const { embedder: emb, cache: cch } = await ensureEmbedder();
885
+ manifest.embeddingDim = emb.dimension;
801
886
  if (phase && !announced) {
802
887
  phase(`Embedding ${batchChunks.length.toLocaleString()} chunks${totalBatches > 1 ? ` (batch ${Math.floor(batchIndex / BATCH) + 1}/${totalBatches})` : ""}…`);
803
888
  announced = true;
@@ -817,6 +902,7 @@ async function runIndexingLocked(
817
902
  for (const [rel, hash] of batchManifestUpdates) manifest.update(rel, hash);
818
903
  result.indexedFiles.push(...batchIndexed);
819
904
  }
905
+ if (batchManifestUpdates.length > 0) checkpointManifest();
820
906
  result.skippedFiles.push(...toChunk.filter((t) => !batchIndexed.includes(t.relPath)).map((t) => t.relPath));
821
907
  }
822
908
 
@@ -884,6 +970,7 @@ async function runIndexingLocked(
884
970
  const git = await indexCommits(projectDir, manifest.lastGitSha);
885
971
  if (git.chunks.length > 0) {
886
972
  const { embedder: emb, cache: cch } = await ensureEmbedder();
973
+ manifest.embeddingDim = emb.dimension;
887
974
  phase?.(`Embedding ${git.chunks.length} git commits…`);
888
975
  const stats = await embedChunks(git.chunks, emb, cch);
889
976
  totalCacheHits += stats.hits;