pi-context-engine 0.5.0 → 0.5.2

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,25 @@ 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.
113
+ - **Resume after interruption**: the manifest checkpoints completed batches,
114
+ starting with the first batch and then at most once every five seconds. An
115
+ interrupted build can skip completed files instead of starting from zero.
116
+ Migration runs (schema reset, embedding-dimension change) are exempt: their
117
+ manifest stays untouched on disk until the run completes, so an interrupted
118
+ migration resumes cleanly instead of stranding stale rows.
119
+ - **Cold-start visibility**: the first build on an empty index announces its
120
+ start (`building the first index…`) and always closes with a result line
121
+ (`updated: N file(s)` or `no files to index`). This is the one background
122
+ run that can take minutes, so a pause reads as "indexing", not as a hung
123
+ session. Warm incremental runs stay quiet on start: they finish in a few
124
+ hundred ms, and a toast per session would be noise.
106
125
  - **Why it matters**: incremental runs are cheap (a few hundred ms on an
107
126
  unchanged repo), so this removes the classic cold-start failure of searching
108
127
  an empty index, without ever blocking the UI.
@@ -186,6 +205,7 @@ indexer:
186
205
  chunking: legacy # legacy (default) | ast-density — cAST-style budgeted split-merge (experimental)
187
206
  chunk_budget_chars: 1200 # non-whitespace budget per ast-density chunk
188
207
  auto_index: true # incremental background reindex shortly after session start
208
+ max_auto_files: 10000 # automatic full-project file budget; 0 disables the guard
189
209
  watch: true # reindex on file changes while the session runs (debounced, incremental)
190
210
  memory:
191
211
  redact_pii: true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-context-engine",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
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"]);
@@ -32,6 +33,7 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
32
33
  let bootstrapSent = false;
33
34
  let autoIndexTimer: NodeJS.Timeout | null = null;
34
35
  let indexInFlight = false;
36
+ let automaticIndexBlock: AutomaticIndexFileLimitError | null = null;
35
37
 
36
38
  /**
37
39
  * File watcher (config `indexer.watch`, default true): a debounced change
@@ -46,22 +48,23 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
46
48
 
47
49
  /**
48
50
  * 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
51
+ * calls `reindex({ automatic: true })` directly — the watcher must re-fire
52
+ * all session, not consume a one-shot. The automatic flag enforces the same
53
+ * workspace file budget as session-start indexing. Shares `indexInFlight` with the other kicks so runs
51
54
  * never stack; a change that arrives mid-run coalesces into exactly one
52
55
  * trailing kick, so edits made *while indexing* still land. Successful
53
56
  * runs stay silent (the user's own edit caused them); failures — both
54
57
  * rejections AND per-file errors resolved in `result.errors` — notify.
55
58
  */
56
59
  const kickWatcherIndex = (eng: ContextEngine, notify?: (msg: string, level: "info" | "warning") => void) => {
57
- if (engine !== eng) return; // session moved on
60
+ if (engine !== eng || automaticIndexBlock) return; // session moved on, or the workspace exceeds the auto budget
58
61
  if (indexInFlight) {
59
62
  watcherKickPending = true;
60
63
  return;
61
64
  }
62
65
  indexInFlight = true;
63
66
  void eng
64
- .reindex({})
67
+ .reindex({ automatic: true })
65
68
  .then((result) => {
66
69
  // Per-file chunking/read failures RESOLVE in result.errors — mirroring
67
70
  // kickBackgroundIndex, surface them or affected files go stale silently.
@@ -71,7 +74,10 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
71
74
  }
72
75
  })
73
76
  .catch((err: unknown) => {
74
- if (engine === eng && notify) {
77
+ if (engine !== eng) return;
78
+ if (err instanceof AutomaticIndexFileLimitError) {
79
+ pauseAutomaticIndexing(eng, err, notify);
80
+ } else if (notify) {
75
81
  const detail = (err instanceof Error ? err.message : String(err)).slice(0, 120);
76
82
  notify(`Context index: watcher reindex failed (${detail})`, "warning");
77
83
  }
@@ -95,6 +101,24 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
95
101
  watcherKickPending = false;
96
102
  };
97
103
 
104
+ /** A stable size refusal pauses every automatic path until the next session. */
105
+ const pauseAutomaticIndexing = (
106
+ eng: ContextEngine,
107
+ err: AutomaticIndexFileLimitError,
108
+ notify?: (msg: string, level: "info" | "warning") => void,
109
+ ) => {
110
+ if (engine !== eng) return;
111
+ automaticIndexBlock = err;
112
+ lazyIndexStarted = true; // a lazy search must not immediately retry the same refusal
113
+ stopWatcher();
114
+ notify?.(
115
+ `Context index: automatic indexing${eng.config.indexerWatch ? " and watcher" : ""} paused. ` +
116
+ `Workspace exceeds ${err.limit.toLocaleString()} indexable files. ` +
117
+ "Run /context-index to index anyway, or raise indexer.max_auto_files.",
118
+ "warning",
119
+ );
120
+ };
121
+
98
122
  /**
99
123
  * (Re)point the file watcher at the live engine's project dir. Idempotent
100
124
  * per dir (called from every `getEngine`), so a mid-session project switch
@@ -135,8 +159,17 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
135
159
  * itself one-shot per engine instance.
136
160
  */
137
161
  const kickBackgroundIndex = (eng: ContextEngine, notify?: (msg: string, level: "info" | "warning") => void) => {
162
+ if (engine !== eng || automaticIndexBlock) return;
138
163
  lazyIndexStarted = true;
139
164
  indexInFlight = true;
165
+ // Cold-start visibility: the first build on an empty index is the only
166
+ // background run that can take minutes (every file gets chunked and
167
+ // embedded), and it used to stay invisible until the completion line.
168
+ // Announce the start so a long pause reads as "indexing", not as a hung
169
+ // session. Warm incremental runs are NOT announced: they finish in a few
170
+ // hundred ms and a toast on every session start would be noise.
171
+ const announcedStart = eng.chunkCount() === 0 && notify !== undefined;
172
+ if (announcedStart) notify?.(`Context index: building the first index for ${eng.projectName}…`, "info");
140
173
  void eng
141
174
  .autoIndexOnce()
142
175
  .then((result) => {
@@ -145,6 +178,11 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
145
178
  notify(`Context index: background index hit ${result.errors.length} error(s)`, "warning");
146
179
  } else if (result.indexed > 0) {
147
180
  notify(`Context index updated: ${result.indexed} file(s), ${result.chunks} chunk(s)`, "info");
181
+ } else if (announcedStart) {
182
+ // The start toast promised feedback — close the loop even when
183
+ // there is nothing to index (empty project), instead of leaving
184
+ // "building…" hanging without a result.
185
+ notify(`Context index: no files to index for ${eng.projectName}`, "info");
148
186
  }
149
187
  })
150
188
  .catch((err: unknown) => {
@@ -154,12 +192,15 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
154
192
  // the lazy path burned on an empty index. Free the lazy path so a
155
193
  // later search retries (autoIndexOnce un-consumes its one-shot),
156
194
  // and surface the failure when we have a notify channel.
157
- if (engine === eng) {
158
- lazyIndexStarted = false;
159
- if (notify) {
160
- const detail = (err instanceof Error ? err.message : String(err)).slice(0, 120);
161
- notify(`Context index: background indexing failed — run /context-index for details (${detail})`, "warning");
162
- }
195
+ if (engine !== eng) return;
196
+ if (err instanceof AutomaticIndexFileLimitError) {
197
+ pauseAutomaticIndexing(eng, err, notify);
198
+ return;
199
+ }
200
+ lazyIndexStarted = false;
201
+ if (notify) {
202
+ const detail = (err instanceof Error ? err.message : String(err)).slice(0, 120);
203
+ notify(`Context index: background indexing failed — run /context-index for details (${detail})`, "warning");
163
204
  }
164
205
  })
165
206
  .finally(() => {
@@ -175,6 +216,8 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
175
216
  // Best-effort: a cross-project switch must not leak sqlite handles.
176
217
  void engine.shutdown().catch(() => undefined);
177
218
  }
219
+ automaticIndexBlock = null;
220
+ lazyIndexStarted = false;
178
221
  // Always track the live engine so `session_shutdown` closes it even when
179
222
  // a tool ran before `session_start` (tests, mid-reload edge cases).
180
223
  engine = new ContextEngine({ projectDir: dir });
@@ -220,6 +263,13 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
220
263
  // `indexer.auto_index: false` disables this lazy kick too — one switch
221
264
  // for all automatic indexing.
222
265
  if (eng.chunkCount() === 0) {
266
+ if (automaticIndexBlock) {
267
+ return text(
268
+ `Automatic indexing is paused for ${eng.projectName}: the workspace exceeds ` +
269
+ `${automaticIndexBlock.limit.toLocaleString()} indexable files. ` +
270
+ "Run /context-index to index manually, or raise indexer.max_auto_files.",
271
+ );
272
+ }
223
273
  if (indexInFlight) {
224
274
  return text(
225
275
  `Index for ${eng.projectName} is still building in the background — retry this search in a few seconds.`,
@@ -481,6 +531,7 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
481
531
 
482
532
  pi.on("session_start", async (_event, ctx) => {
483
533
  currentCwd = ctx.cwd;
534
+ automaticIndexBlock = null;
484
535
  engine = new ContextEngine({ projectDir: ctx.cwd });
485
536
  engine.startBackgroundWork();
486
537
  lazyIndexStarted = false;
@@ -576,6 +627,7 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
576
627
  }
577
628
  stopWatcher();
578
629
  watcherNotify = undefined;
630
+ automaticIndexBlock = null;
579
631
  await engine?.shutdown().catch(() => undefined);
580
632
  engine = null;
581
633
  });
@@ -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;