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 +17 -0
- package/package.json +1 -1
- package/src/config.ts +9 -0
- package/src/engine.ts +12 -5
- package/src/extension.ts +78 -13
- package/src/indexer/pipeline.ts +100 -13
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.
|
|
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
|
-
|
|
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
|
|
50
|
-
* consume a one-shot.
|
|
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
|
|
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
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|
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;
|