pi-context-engine 0.5.3 → 0.6.0
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 +9 -1
- package/package.json +1 -1
- package/src/engine.ts +95 -6
- package/src/extension.ts +52 -1
- package/src/guidance.ts +1 -0
- package/src/indexer/pipeline.ts +25 -11
package/README.md
CHANGED
|
@@ -56,7 +56,7 @@ Ten tools the agent can call, plus four slash commands.
|
|
|
56
56
|
- **`index_status`**: chunk count and measured savings.
|
|
57
57
|
- **`reindex`**: refresh a path or the whole project.
|
|
58
58
|
|
|
59
|
-
Commands: `/context-index [--full]`, `/context-status`, `/context-savings`,
|
|
59
|
+
Commands: `/context-index [--full] [--reset]`, `/context-status`, `/context-savings`,
|
|
60
60
|
`/context-warmup`.
|
|
61
61
|
|
|
62
62
|
**Shipped guidance.** The package also injects a ~900-token guidance section
|
|
@@ -119,6 +119,14 @@ unchanged repo took 237 ms.
|
|
|
119
119
|
Migration runs (schema reset, embedding-dimension change) are exempt: their
|
|
120
120
|
manifest stays untouched on disk until the run completes, so an interrupted
|
|
121
121
|
migration resumes cleanly instead of stranding stale rows.
|
|
122
|
+
- **One-command reset**: `/context-index --reset` deletes the index through the
|
|
123
|
+
engine while the session is running: sqlite handles are closed first, so it
|
|
124
|
+
succeeds where manual deletion of the storage directory fails with locked
|
|
125
|
+
file errors (EPERM on Windows). The report shows how many files and bytes
|
|
126
|
+
were freed, and memory, sessions, and the savings ledger survive. The
|
|
127
|
+
embedding cache is deleted too, so a rebuild re-embeds from scratch. The
|
|
128
|
+
next search rebuilds via the lazy kick, guarded by the same file budget as
|
|
129
|
+
every automatic run.
|
|
122
130
|
- **Cold-start visibility**: the first build on an empty index announces its
|
|
123
131
|
start (`building the first index…`) and always closes with a result line
|
|
124
132
|
(`updated: N file(s)` or `no files to index`). This is the one background
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-context-engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
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/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 { AutomaticIndexFileLimitError, runIndexing } from "./indexer/pipeline.js";
|
|
23
|
+
import { AutomaticIndexFileLimitError, runIndexing, withPipelineLock } from "./indexer/pipeline.js";
|
|
24
24
|
import { getModelPricing } from "./pricing.js";
|
|
25
25
|
import type { Chunk } from "./types.js";
|
|
26
26
|
|
|
@@ -125,8 +125,10 @@ export interface SessionStats {
|
|
|
125
125
|
export interface EngineOptions {
|
|
126
126
|
projectDir: string;
|
|
127
127
|
config?: Config;
|
|
128
|
-
/** Test seam: reuse an existing backend/embedder/reranker instead of
|
|
129
|
-
|
|
128
|
+
/** Test seam: reuse an existing backend/embedder/reranker instead of
|
|
129
|
+
* auto-detecting. `backend` may be nulled by resetIndex — the pipeline
|
|
130
|
+
* then constructs a fresh one. */
|
|
131
|
+
runtime?: { backend?: LocalBackend | null; embedder: Embedder; reranker?: LocalReranker };
|
|
130
132
|
}
|
|
131
133
|
|
|
132
134
|
/** Issue #11 (upstream warmup): what /context-warmup resolved + cached. */
|
|
@@ -161,8 +163,8 @@ export class ContextEngine {
|
|
|
161
163
|
readonly projectName: string;
|
|
162
164
|
readonly storageBase: string;
|
|
163
165
|
config: Config;
|
|
164
|
-
private
|
|
165
|
-
| { backend
|
|
166
|
+
private injectedRuntime:
|
|
167
|
+
| { backend?: LocalBackend | null; embedder: Embedder; reranker?: LocalReranker }
|
|
166
168
|
| undefined;
|
|
167
169
|
private backendInstance: LocalBackend | null = null;
|
|
168
170
|
private embedderInstance: Embedder | null = null;
|
|
@@ -188,7 +190,7 @@ export class ContextEngine {
|
|
|
188
190
|
this.config = options.config ?? loadConfigForProject(this.projectDir);
|
|
189
191
|
this.injectedRuntime = options.runtime;
|
|
190
192
|
if (options.runtime) {
|
|
191
|
-
this.backendInstance = options.runtime.backend;
|
|
193
|
+
this.backendInstance = options.runtime.backend ?? null;
|
|
192
194
|
this.embedderInstance = options.runtime.embedder;
|
|
193
195
|
}
|
|
194
196
|
this.storageBase = projectStorageDir(this.config.storagePath, this.projectDir);
|
|
@@ -358,6 +360,93 @@ export class ContextEngine {
|
|
|
358
360
|
return this.backend.countChunks();
|
|
359
361
|
}
|
|
360
362
|
|
|
363
|
+
/**
|
|
364
|
+
* Delete this project's index (manifest, vector/FTS/graph stores, embedding
|
|
365
|
+
* cache) while keeping memory, sessions and the savings ledger — those are
|
|
366
|
+
* user history, not index state. Handles are closed BEFORE deletion:
|
|
367
|
+
* Windows refuses to delete files with open sqlite handles, which is why
|
|
368
|
+
* manually deleting the storage dir fails while a session is running.
|
|
369
|
+
* Queues behind in-flight and queued pipeline runs, and holds the same lock
|
|
370
|
+
* during deletion so a later reindex cannot race the reset.
|
|
371
|
+
* Errors are reported per target (e.g. another session holding a handle)
|
|
372
|
+
* instead of failing the whole reset.
|
|
373
|
+
*/
|
|
374
|
+
async resetIndex(): Promise<{ deletedFiles: number; freedBytes: number; errors: string[] }> {
|
|
375
|
+
return withPipelineLock(this.storageBase, () => this.resetIndexLocked());
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private async resetIndexLocked(): Promise<{ deletedFiles: number; freedBytes: number; errors: string[] }> {
|
|
379
|
+
try {
|
|
380
|
+
this.backendInstance?.close();
|
|
381
|
+
} catch {
|
|
382
|
+
// already closed
|
|
383
|
+
}
|
|
384
|
+
this.backendInstance = null;
|
|
385
|
+
this.retrieverInstance = null;
|
|
386
|
+
// Mutate the runtime object rather than replacing it: reindex calls that
|
|
387
|
+
// were queued behind this reset captured the same object and must see the
|
|
388
|
+
// closed backend dropped before they execute. Embedder and reranker
|
|
389
|
+
// survive: they hold no files.
|
|
390
|
+
if (this.injectedRuntime) this.injectedRuntime.backend = null;
|
|
391
|
+
// A reset index may be rebuilt by the next automatic kick.
|
|
392
|
+
this.autoIndexDone = false;
|
|
393
|
+
let deletedFiles = 0;
|
|
394
|
+
let freedBytes = 0;
|
|
395
|
+
const errors: string[] = [];
|
|
396
|
+
const countAndDelete = (target: string): void => {
|
|
397
|
+
let stat: fs.Stats;
|
|
398
|
+
try {
|
|
399
|
+
stat = fs.statSync(target);
|
|
400
|
+
} catch {
|
|
401
|
+
return; // not there — nothing to free
|
|
402
|
+
}
|
|
403
|
+
let files = 0;
|
|
404
|
+
let bytes = 0;
|
|
405
|
+
const measure = (p: string, s: fs.Stats): void => {
|
|
406
|
+
if (s.isDirectory()) {
|
|
407
|
+
for (const entry of fs.readdirSync(p)) {
|
|
408
|
+
const child = path.join(p, entry);
|
|
409
|
+
try {
|
|
410
|
+
measure(child, fs.statSync(child));
|
|
411
|
+
} catch {
|
|
412
|
+
// raced away between readdir and stat
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
files++;
|
|
418
|
+
bytes += s.size;
|
|
419
|
+
};
|
|
420
|
+
try {
|
|
421
|
+
measure(target, stat);
|
|
422
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
423
|
+
// Only report bytes actually released; a locked target must not
|
|
424
|
+
// inflate the success report when rmSync fails.
|
|
425
|
+
deletedFiles += files;
|
|
426
|
+
freedBytes += bytes;
|
|
427
|
+
} catch (err) {
|
|
428
|
+
// Locked file (another session?) — report instead of failing the reset.
|
|
429
|
+
errors.push(`${path.relative(this.storageBase, target) || path.basename(target)}: ${(err as Error).message.slice(0, 80)}`);
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
const graphDb = path.join(this.storageBase, "graph.db");
|
|
433
|
+
const embeddingCache = path.join(this.storageBase, "embedding_cache.db");
|
|
434
|
+
for (const target of [
|
|
435
|
+
path.join(this.storageBase, "manifest.json"),
|
|
436
|
+
path.join(this.storageBase, "vectors"),
|
|
437
|
+
path.join(this.storageBase, "fts"),
|
|
438
|
+
graphDb,
|
|
439
|
+
`${graphDb}-shm`,
|
|
440
|
+
`${graphDb}-wal`,
|
|
441
|
+
embeddingCache,
|
|
442
|
+
`${embeddingCache}-shm`,
|
|
443
|
+
`${embeddingCache}-wal`,
|
|
444
|
+
]) {
|
|
445
|
+
countAndDelete(target);
|
|
446
|
+
}
|
|
447
|
+
return { deletedFiles, freedBytes, errors };
|
|
448
|
+
}
|
|
449
|
+
|
|
361
450
|
async reindex(options: { full?: boolean; targetPath?: string | null; automatic?: boolean; onProgress?: (msg: string) => void } = {}): Promise<{
|
|
362
451
|
indexed: number;
|
|
363
452
|
chunks: number;
|
package/src/extension.ts
CHANGED
|
@@ -245,6 +245,20 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
|
|
|
245
245
|
return engine;
|
|
246
246
|
};
|
|
247
247
|
|
|
248
|
+
/** Human-readable byte count for the reset report (explicit user action —
|
|
249
|
+
* the automatic guard never measures bytes by design). */
|
|
250
|
+
const formatBytes = (n: number): string => {
|
|
251
|
+
if (n < 1024) return `${n} B`;
|
|
252
|
+
const units = ["KiB", "MiB", "GiB"];
|
|
253
|
+
let v = n / 1024;
|
|
254
|
+
let u = 0;
|
|
255
|
+
while (v >= 1024 && u < units.length - 1) {
|
|
256
|
+
v /= 1024;
|
|
257
|
+
u++;
|
|
258
|
+
}
|
|
259
|
+
return `${v.toFixed(v >= 10 ? 0 : 1)} ${units[u]}`;
|
|
260
|
+
};
|
|
261
|
+
|
|
248
262
|
const text = (body: string, details?: unknown) => ({
|
|
249
263
|
content: [{ type: "text" as const, text: body }],
|
|
250
264
|
details: details ?? {},
|
|
@@ -495,9 +509,46 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
|
|
|
495
509
|
/* ── Commands ────────────────────────────────────────────────────────── */
|
|
496
510
|
|
|
497
511
|
pi.registerCommand("context-index", {
|
|
498
|
-
description: "Build or refresh the semantic code index: /context-index [--full]",
|
|
512
|
+
description: "Build or refresh the semantic code index: /context-index [--full] [--reset]",
|
|
499
513
|
handler: async (args, ctx) => {
|
|
500
514
|
const eng = getEngine(ctx.cwd);
|
|
515
|
+
// --reset: delete the index through the engine (handles closed first, so
|
|
516
|
+
// it works while THIS session runs — manual deletion hits locked-file
|
|
517
|
+
// errors). Memory, sessions and savings survive. No auto-rebuild: the
|
|
518
|
+
// next search or /context-index decides, so an oversized workspace is
|
|
519
|
+
// not ambushed with a multi-minute build it just escaped.
|
|
520
|
+
if (args.includes("--reset")) {
|
|
521
|
+
if (indexInFlight) {
|
|
522
|
+
ctx.ui.notify(
|
|
523
|
+
"Context index: indexing is in progress — run /context-index --reset again once it finishes.",
|
|
524
|
+
"warning",
|
|
525
|
+
);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
// Reset also cancels the delayed startup kick. Otherwise its timer
|
|
529
|
+
// would rebuild the index immediately after the explicit delete.
|
|
530
|
+
if (autoIndexTimer) {
|
|
531
|
+
clearTimeout(autoIndexTimer);
|
|
532
|
+
autoIndexTimer = null;
|
|
533
|
+
}
|
|
534
|
+
const report = await eng.resetIndex();
|
|
535
|
+
// Free the lazy kick so the next search can rebuild (guarded by the
|
|
536
|
+
// same file budget as every automatic run).
|
|
537
|
+
lazyIndexStarted = false;
|
|
538
|
+
if (report.errors.length > 0) {
|
|
539
|
+
ctx.ui.notify(
|
|
540
|
+
`⚠ Index reset incomplete: ${report.errors.length} target(s) locked (${report.errors[0]}). Close other sessions on this project and run /context-index --reset again.`,
|
|
541
|
+
"warning",
|
|
542
|
+
);
|
|
543
|
+
} else {
|
|
544
|
+
ctx.ui.notify(
|
|
545
|
+
`✓ Index deleted: ${report.deletedFiles} file(s), ${formatBytes(report.freedBytes)} freed — memory, sessions and savings kept. ` +
|
|
546
|
+
"Run /context-index to build a fresh index.",
|
|
547
|
+
"info",
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
501
552
|
const full = args.includes("--full");
|
|
502
553
|
ctx.ui.notify(full ? "Re-indexing everything…" : "Indexing (incremental)…", "info");
|
|
503
554
|
const result = await eng.reindex({ full });
|
package/src/guidance.ts
CHANGED
|
@@ -100,6 +100,7 @@ If the first search misses:
|
|
|
100
100
|
- If a search says the index is empty, a background index is already running - retry shortly, or run \`/context-index\` for a synchronous build
|
|
101
101
|
- After large refactors, \`reindex\` (or \`/context-index\`) keeps results current; \`index_status\` reports freshness and measured token savings
|
|
102
102
|
- Incremental re-index only re-reads changed files, so it is cheap
|
|
103
|
+
- To wipe the index (stale or corrupted results), \`/context-index --reset\` deletes it safely mid-session - memory and sessions survive; the next search rebuilds
|
|
103
104
|
`;
|
|
104
105
|
|
|
105
106
|
/**
|
package/src/indexer/pipeline.ts
CHANGED
|
@@ -407,6 +407,22 @@ export async function indexCommits(
|
|
|
407
407
|
*/
|
|
408
408
|
const pipelineLocks = new Map<string, Promise<unknown>>();
|
|
409
409
|
|
|
410
|
+
/**
|
|
411
|
+
* Queue work behind every indexing run already registered for this storage
|
|
412
|
+
* dir, and keep later runs queued until the work finishes. Reset uses this
|
|
413
|
+
* instead of a one-shot idle wait: a reindex queued during that wait must not
|
|
414
|
+
* open the stores while deletion is in progress.
|
|
415
|
+
*/
|
|
416
|
+
export async function withPipelineLock<T>(storageBase: string, work: () => T | Promise<T>): Promise<T> {
|
|
417
|
+
const previous = pipelineLocks.get(storageBase) ?? Promise.resolve();
|
|
418
|
+
const current = previous.then(() => work());
|
|
419
|
+
pipelineLocks.set(
|
|
420
|
+
storageBase,
|
|
421
|
+
current.catch(() => undefined),
|
|
422
|
+
);
|
|
423
|
+
return current;
|
|
424
|
+
}
|
|
425
|
+
|
|
410
426
|
export async function runIndexing(
|
|
411
427
|
config: Config,
|
|
412
428
|
projectDirInput: string,
|
|
@@ -416,23 +432,18 @@ export async function runIndexing(
|
|
|
416
432
|
/** Automatic full-project runs only. Stop before migrations when the workspace exceeds this budget. */
|
|
417
433
|
maxAutoFiles?: number;
|
|
418
434
|
callbacks?: IndexCallbacks;
|
|
419
|
-
/** Reuse an already-constructed backend/embedder (the long-lived server path)
|
|
420
|
-
|
|
435
|
+
/** Reuse an already-constructed backend/embedder (the long-lived server path);
|
|
436
|
+
* a null backend falls through to a fresh LocalBackend (used by resetIndex). */
|
|
437
|
+
runtime?: { backend?: LocalBackend | null; embedder: Embedder };
|
|
421
438
|
} = {},
|
|
422
439
|
): Promise<IndexResult> {
|
|
423
440
|
const projectDir = fs.realpathSync(path.resolve(projectDirInput));
|
|
424
441
|
const storageBase = projectStorageDir(config.storagePath, projectDir);
|
|
425
442
|
fs.mkdirSync(storageBase, { recursive: true });
|
|
426
443
|
|
|
427
|
-
|
|
428
|
-
const current = previous.then(() =>
|
|
444
|
+
return withPipelineLock(storageBase, () =>
|
|
429
445
|
runIndexingLocked(config, projectDir, storageBase, options, options.runtime),
|
|
430
446
|
);
|
|
431
|
-
pipelineLocks.set(
|
|
432
|
-
storageBase,
|
|
433
|
-
current.catch(() => undefined),
|
|
434
|
-
);
|
|
435
|
-
return current;
|
|
436
447
|
}
|
|
437
448
|
|
|
438
449
|
async function runIndexingLocked(
|
|
@@ -440,13 +451,16 @@ async function runIndexingLocked(
|
|
|
440
451
|
projectDir: string,
|
|
441
452
|
storageBase: string,
|
|
442
453
|
options: { full?: boolean; targetPath?: string | null; maxAutoFiles?: number; callbacks?: IndexCallbacks },
|
|
443
|
-
runtime?: { backend
|
|
454
|
+
runtime?: { backend?: LocalBackend | null; embedder: Embedder },
|
|
444
455
|
): Promise<IndexResult> {
|
|
445
456
|
let { full = false, targetPath = null } = options;
|
|
446
457
|
const { callbacks = {} } = options;
|
|
447
458
|
const { log, progress, phase } = callbacks;
|
|
448
459
|
|
|
449
|
-
|
|
460
|
+
// The caller only owns the backend it actually passed in; a null backend
|
|
461
|
+
// (resetIndex dropped its closed handle) means the pipeline built its own
|
|
462
|
+
// and must close it — otherwise its sqlite handles leak until exit.
|
|
463
|
+
const ownsBackend = !runtime?.backend;
|
|
450
464
|
const chunker = new Chunker();
|
|
451
465
|
const manifest = new Manifest(path.join(storageBase, "manifest.json"));
|
|
452
466
|
const result: IndexResult = {
|