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