pi-context-engine 0.4.0 → 0.5.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 CHANGED
@@ -37,7 +37,7 @@ runs fixed queries. Run it yourself; nothing is cherry-picked.
37
37
 
38
38
  ## What you get
39
39
 
40
- Ten tools the agent can call, plus three slash commands.
40
+ Ten tools the agent can call, plus four slash commands.
41
41
 
42
42
  **Search**
43
43
  - **`context_search`**: hybrid vector + BM25 retrieval with confidence scores.
@@ -56,7 +56,8 @@ Ten tools the agent can call, plus three 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]`, `/context-status`, `/context-savings`,
60
+ `/context-warmup`.
60
61
 
61
62
  **Shipped guidance.** The package also injects a ~900-token guidance section
62
63
  into the system prompt while `context_search` is active: how to formulate
@@ -102,10 +103,29 @@ unchanged repo took 237 ms.
102
103
  - **Switches**: `indexer.auto_index: false` disables the session-start and
103
104
  lazy kicks; `indexer.watch: false` disables the file watcher. Use
104
105
  `/context-index` for manual builds.
106
+ - **Cold-start visibility**: the first build on an empty index announces its
107
+ start (`building the first index…`) and always closes with a result line
108
+ (`updated: N file(s)` or `no files to index`). This is the one background
109
+ run that can take minutes, so a pause reads as "indexing", not as a hung
110
+ session. Warm incremental runs stay quiet on start: they finish in a few
111
+ hundred ms, and a toast per session would be noise.
105
112
  - **Why it matters**: incremental runs are cheap (a few hundred ms on an
106
113
  unchanged repo), so this removes the classic cold-start failure of searching
107
114
  an empty index, without ever blocking the UI.
108
115
 
116
+ ### Model warmup
117
+
118
+ - **`/context-warmup`** downloads the embedding model up front, no project or
119
+ index needed. With `retrieval.rerank: local` it also fetches the reranker
120
+ model; with Ollama as the backend it forces the model pull through the same
121
+ command.
122
+ - **Why it matters**: the embedder initializes lazily, only when a run has
123
+ chunks to embed. An empty repo or an already up-to-date index never touches
124
+ the network, so on a fresh machine or an ephemeral container the download
125
+ lands inside the first search. Warming turns it into an explicit
126
+ provisioning step (a devcontainer `postCreateCommand`, a CI cache setup),
127
+ keeping first searches fast.
128
+
109
129
  ## Requirements
110
130
 
111
131
  - **Node 22+** on macOS, Linux or Windows.
@@ -253,7 +273,7 @@ bootstrapping for non-pi editors.
253
273
 
254
274
  ```bash
255
275
  npm install
256
- npm run check # typecheck + 97 unit tests
276
+ npm run check # typecheck + 251 unit tests
257
277
  npm run smoke:env # tree-sitter + sqlite-vec capability check
258
278
  npm run smoke:loader # load the extension through pi's jiti loader
259
279
  npm run smoke:real-world # index this repo with the local embedder
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-context-engine",
3
- "version": "0.4.0",
3
+ "version": "0.5.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",
package/src/engine.ts CHANGED
@@ -125,8 +125,19 @@ export interface SessionStats {
125
125
  export interface EngineOptions {
126
126
  projectDir: string;
127
127
  config?: Config;
128
- /** Test seam: reuse an existing backend/embedder instead of auto-detecting. */
129
- runtime?: { backend: LocalBackend; embedder: Embedder };
128
+ /** Test seam: reuse an existing backend/embedder/reranker instead of auto-detecting. */
129
+ runtime?: { backend: LocalBackend; embedder: Embedder; reranker?: LocalReranker };
130
+ }
131
+
132
+ /** Issue #11 (upstream warmup): what /context-warmup resolved + cached. */
133
+ export interface WarmupReport {
134
+ backend: string;
135
+ model: string;
136
+ dimension: number;
137
+ /** Present only when retrieval.rerank is "local". */
138
+ reranker?: string;
139
+ rerankerReady?: boolean;
140
+ rerankerError?: string;
130
141
  }
131
142
 
132
143
  /** R13: cheap keyword extraction for decision tags (no LLM): distinctive
@@ -150,10 +161,13 @@ export class ContextEngine {
150
161
  readonly projectName: string;
151
162
  readonly storageBase: string;
152
163
  config: Config;
153
- private readonly injectedRuntime: { backend: LocalBackend; embedder: Embedder } | undefined;
164
+ private readonly injectedRuntime:
165
+ | { backend: LocalBackend; embedder: Embedder; reranker?: LocalReranker }
166
+ | undefined;
154
167
  private backendInstance: LocalBackend | null = null;
155
168
  private embedderInstance: Embedder | null = null;
156
169
  private retrieverInstance: HybridRetriever | null = null;
170
+ private rerankerInstance: LocalReranker | null = null;
157
171
  private compressorInstance: Compressor | null = null;
158
172
  private memoryInstance: MemoryDb | null = null;
159
173
  private memoryFailed = false;
@@ -210,15 +224,48 @@ export class ContextEngine {
210
224
  this.retrieverInstance = new HybridRetriever(this.backend, await this.getEmbedder(), {
211
225
  expandContext: this.config.retrievalExpandContext,
212
226
  autoElaborate: this.config.retrievalAutoElaborate,
213
- reranker:
214
- this.config.retrievalRerank === "local"
215
- ? new LocalReranker(this.config.retrievalRerankModel)
216
- : null,
227
+ reranker: this.getReranker(),
217
228
  });
218
229
  }
219
230
  return this.retrieverInstance;
220
231
  }
221
232
 
233
+ /**
234
+ * The opt-in local reranker, or null when reranking is off. Memoized so
235
+ * /context-warmup loads the SAME instance the retriever later searches
236
+ * with — the model stays warm across the command boundary.
237
+ */
238
+ private getReranker(): LocalReranker | null {
239
+ if (this.config.retrievalRerank !== "local") return null;
240
+ this.rerankerInstance ??= this.injectedRuntime?.reranker ?? new LocalReranker(this.config.retrievalRerankModel);
241
+ return this.rerankerInstance;
242
+ }
243
+
244
+ /**
245
+ * Warm the model caches (issue #11, upstream elara-labs#168): resolve +
246
+ * download + dimension-probe the embedding backend, and — when the opt-in
247
+ * local reranker is configured — its cross-encoder too. Unlike a reindex
248
+ * on an empty or fully up-to-date repo (which never initializes the
249
+ * embedder and therefore never touches the network), this guarantees the
250
+ * models are cached, so the first search or index doesn't pay the fetch
251
+ * inline. Ollama pulls ride the existing ensureModel() path.
252
+ */
253
+ async warmup(): Promise<WarmupReport> {
254
+ const embedder = await this.getEmbedder();
255
+ const report: WarmupReport = {
256
+ backend: embedder.backendName,
257
+ model: embedder.modelName,
258
+ dimension: embedder.dimension,
259
+ };
260
+ const reranker = this.getReranker();
261
+ if (reranker) {
262
+ report.reranker = reranker.model;
263
+ report.rerankerReady = await reranker.warm();
264
+ if (!report.rerankerReady) report.rerankerError = reranker.loadError ?? "unknown error";
265
+ }
266
+ return report;
267
+ }
268
+
222
269
  async getCompressor(): Promise<Compressor> {
223
270
  if (!this.compressorInstance) {
224
271
  this.compressorInstance = new Compressor(
package/src/extension.ts CHANGED
@@ -137,6 +137,14 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
137
137
  const kickBackgroundIndex = (eng: ContextEngine, notify?: (msg: string, level: "info" | "warning") => void) => {
138
138
  lazyIndexStarted = true;
139
139
  indexInFlight = true;
140
+ // Cold-start visibility: the first build on an empty index is the only
141
+ // background run that can take minutes (every file gets chunked and
142
+ // embedded), and it used to stay invisible until the completion line.
143
+ // Announce the start so a long pause reads as "indexing", not as a hung
144
+ // session. Warm incremental runs are NOT announced: they finish in a few
145
+ // hundred ms and a toast on every session start would be noise.
146
+ const announcedStart = eng.chunkCount() === 0 && notify !== undefined;
147
+ if (announcedStart) notify?.(`Context index: building the first index for ${eng.projectName}…`, "info");
140
148
  void eng
141
149
  .autoIndexOnce()
142
150
  .then((result) => {
@@ -145,6 +153,11 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
145
153
  notify(`Context index: background index hit ${result.errors.length} error(s)`, "warning");
146
154
  } else if (result.indexed > 0) {
147
155
  notify(`Context index updated: ${result.indexed} file(s), ${result.chunks} chunk(s)`, "info");
156
+ } else if (announcedStart) {
157
+ // The start toast promised feedback — close the loop even when
158
+ // there is nothing to index (empty project), instead of leaving
159
+ // "building…" hanging without a result.
160
+ notify(`Context index: no files to index for ${eng.projectName}`, "info");
148
161
  }
149
162
  })
150
163
  .catch((err: unknown) => {
@@ -461,6 +474,22 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
461
474
  },
462
475
  });
463
476
 
477
+ pi.registerCommand("context-warmup", {
478
+ description: "Pre-download the embedding (and opt-in reranker) model so the first search doesn't fetch inline",
479
+ handler: async (_args, ctx) => {
480
+ ctx.ui.notify("Warming up embedding model…", "info");
481
+ const report = await getEngine(ctx.cwd).warmup();
482
+ const parts = [`${report.backend} ${report.model} (${report.dimension} dims)`];
483
+ if (report.reranker) {
484
+ parts.push(report.rerankerReady ? `reranker ${report.reranker} ready` : `reranker ${report.reranker} FAILED: ${report.rerankerError}`);
485
+ }
486
+ ctx.ui.notify(
487
+ `✓ Warmup complete: ${parts.join(" · ")}`,
488
+ report.reranker && !report.rerankerReady ? "warning" : "info",
489
+ );
490
+ },
491
+ });
492
+
464
493
  /* ── Lifecycle hooks (replace upstream's curl-based hooks) ───────────── */
465
494
 
466
495
  pi.on("session_start", async (_event, ctx) => {
@@ -56,6 +56,8 @@ export class LocalReranker implements RerankerLike {
56
56
  private loading: Promise<LoadedReranker | null> | null = null;
57
57
  /** Set after a load failure; every later call short-circuits to no-op. */
58
58
  failed = false;
59
+ /** Why the load failed (null before any attempt). Surfaced by /context-warmup. */
60
+ loadError: string | null = null;
59
61
 
60
62
  constructor(
61
63
  readonly model: string,
@@ -88,6 +90,17 @@ export class LocalReranker implements RerankerLike {
88
90
  }
89
91
  }
90
92
 
93
+ /**
94
+ * Force the model load now (issue #11, upstream warmup): downloads and
95
+ * initializes the cross-encoder without needing a query. Returns false
96
+ * when the load failed — the reranker then stays a silent no-op, per its
97
+ * degrade-never-break design; `loadError` carries the reason.
98
+ */
99
+ async warm(): Promise<boolean> {
100
+ if (this.failed) return false;
101
+ return (await this.ensureLoaded()) !== null;
102
+ }
103
+
91
104
  private ensureLoaded(): Promise<LoadedReranker | null> {
92
105
  if (this.loaded) return Promise.resolve(this.loaded);
93
106
  if (!this.loading) {
@@ -102,8 +115,9 @@ export class LocalReranker implements RerankerLike {
102
115
  });
103
116
  this.loaded = { tokenizer, model };
104
117
  return this.loaded;
105
- })().catch(() => {
118
+ })().catch((err: unknown) => {
106
119
  this.failed = true;
120
+ this.loadError = err instanceof Error ? err.message : String(err);
107
121
  return null;
108
122
  });
109
123
  }