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 +20 -0
- package/package.json +1 -1
- package/src/config.ts +9 -0
- package/src/engine.ts +12 -5
- package/src/extension.ts +63 -11
- package/src/indexer/pipeline.ts +100 -13
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.
|
|
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
|
-
|
|
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
|
|
50
|
-
* consume a one-shot.
|
|
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
|
|
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
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
});
|
package/src/indexer/pipeline.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|