pi-context-engine 0.3.0 → 0.4.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 CHANGED
@@ -95,8 +95,13 @@ unchanged repo took 237 ms.
95
95
  it. You get a notification only when something changed.
96
96
  - **Lazy fallback**: if a search still lands on an empty index, indexing kicks
97
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.
98
+ - **Live reindex while you work**: a recursive file watcher re-indexes on
99
+ edits, saves and branch switches (debounced, so a `git checkout` collapses
100
+ into one run). The agent's own edits are searchable right away instead of
101
+ after the next session.
102
+ - **Switches**: `indexer.auto_index: false` disables the session-start and
103
+ lazy kicks; `indexer.watch: false` disables the file watcher. Use
104
+ `/context-index` for manual builds.
100
105
  - **Why it matters**: incremental runs are cheap (a few hundred ms on an
101
106
  unchanged repo), so this removes the classic cold-start failure of searching
102
107
  an empty index, without ever blocking the UI.
@@ -167,6 +172,7 @@ indexer:
167
172
  chunking: legacy # legacy (default) | ast-density — cAST-style budgeted split-merge (experimental)
168
173
  chunk_budget_chars: 1200 # non-whitespace budget per ast-density chunk
169
174
  auto_index: true # incremental background reindex shortly after session start
175
+ watch: true # reindex on file changes while the session runs (debounced, incremental)
170
176
  memory:
171
177
  redact_pii: true
172
178
  pricing:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-context-engine",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",
@@ -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
@@ -95,6 +95,12 @@ 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
+ /**
99
+ * Watch the project tree while a session runs and reindex on changes
100
+ * (debounced, incremental). Independent of `indexerAutoIndex`, which only
101
+ * gates the session-start kick.
102
+ */
103
+ indexerWatch: boolean;
98
104
  indexerRedactSecrets: boolean;
99
105
  memoryRedactPii: boolean;
100
106
  pricingModel: string;
@@ -128,6 +134,7 @@ export function defaultConfig(): Config {
128
134
  indexerIgnore: [...DEFAULT_IGNORE],
129
135
  indexerContextHeaders: true,
130
136
  indexerAutoIndex: true,
137
+ indexerWatch: true,
131
138
  indexerRedactSecrets: true,
132
139
  memoryRedactPii: true,
133
140
  pricingModel: "opus",
@@ -162,6 +169,7 @@ const MAPPING: Record<string, keyof Config> = {
162
169
  "indexer.ignore": "indexerIgnore",
163
170
  "indexer.context_headers": "indexerContextHeaders",
164
171
  "indexer.auto_index": "indexerAutoIndex",
172
+ "indexer.watch": "indexerWatch",
165
173
  "indexer.redact_secrets": "indexerRedactSecrets",
166
174
  "memory.redact_pii": "memoryRedactPii",
167
175
  "storage.path": "storagePath",
package/src/extension.ts CHANGED
@@ -12,6 +12,7 @@ import fs from "node:fs";
12
12
  import path from "node:path";
13
13
  import { ContextEngine, isPceConfig } from "./engine.js";
14
14
  import { applyContextSearchGuidance } from "./guidance.js";
15
+ import { ProjectWatcher } from "./indexer/watcher.js";
15
16
 
16
17
  /** Tools whose payloads are worth capturing for session timelines. */
17
18
  const CAPTURED_TOOLS = new Set(["bash", "read", "edit", "write", "grep", "find", "ls"]);
@@ -32,6 +33,100 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
32
33
  let autoIndexTimer: NodeJS.Timeout | null = null;
33
34
  let indexInFlight = false;
34
35
 
36
+ /**
37
+ * File watcher (config `indexer.watch`, default true): a debounced change
38
+ * signal over the project tree. Unlike the session-start auto-index this
39
+ * is re-kickable — one kick per settled change burst, for the whole
40
+ * session. Owned by this closure, like `autoIndexTimer`: engine instances
41
+ * are replaced per `session_start` without a shutdown event.
42
+ */
43
+ let watcher: ProjectWatcher | null = null;
44
+ let watcherKickPending = false;
45
+ let watcherNotify: ((msg: string, level: "info" | "warning") => void) | undefined;
46
+
47
+ /**
48
+ * Watcher-triggered incremental reindex. Unlike `kickBackgroundIndex` this
49
+ * calls `reindex()` directly — the watcher must re-fire all session, not
50
+ * consume a one-shot. Shares `indexInFlight` with the other kicks so runs
51
+ * never stack; a change that arrives mid-run coalesces into exactly one
52
+ * trailing kick, so edits made *while indexing* still land. Successful
53
+ * runs stay silent (the user's own edit caused them); failures — both
54
+ * rejections AND per-file errors resolved in `result.errors` — notify.
55
+ */
56
+ const kickWatcherIndex = (eng: ContextEngine, notify?: (msg: string, level: "info" | "warning") => void) => {
57
+ if (engine !== eng) return; // session moved on
58
+ if (indexInFlight) {
59
+ watcherKickPending = true;
60
+ return;
61
+ }
62
+ indexInFlight = true;
63
+ void eng
64
+ .reindex({})
65
+ .then((result) => {
66
+ // Per-file chunking/read failures RESOLVE in result.errors — mirroring
67
+ // kickBackgroundIndex, surface them or affected files go stale silently.
68
+ if (engine !== eng || !notify) return; // session moved on
69
+ if (result.errors.length > 0) {
70
+ notify(`Context index: watcher reindex hit ${result.errors.length} error(s)`, "warning");
71
+ }
72
+ })
73
+ .catch((err: unknown) => {
74
+ if (engine === eng && notify) {
75
+ const detail = (err instanceof Error ? err.message : String(err)).slice(0, 120);
76
+ notify(`Context index: watcher reindex failed (${detail})`, "warning");
77
+ }
78
+ })
79
+ .finally(() => {
80
+ indexInFlight = false;
81
+ drainWatcherKick();
82
+ });
83
+ };
84
+
85
+ /** Deliver a watcher change that arrived while a background run was in flight. */
86
+ const drainWatcherKick = () => {
87
+ if (!watcherKickPending) return;
88
+ watcherKickPending = false;
89
+ if (engine && watcher) kickWatcherIndex(engine, watcherNotify);
90
+ };
91
+
92
+ const stopWatcher = () => {
93
+ watcher?.stop();
94
+ watcher = null;
95
+ watcherKickPending = false;
96
+ };
97
+
98
+ /**
99
+ * (Re)point the file watcher at the live engine's project dir. Idempotent
100
+ * per dir (called from every `getEngine`), so a mid-session project switch
101
+ * re-arms the watcher exactly like the auto-index timer re-points its kick.
102
+ */
103
+ const attachWatcher = (eng: ContextEngine) => {
104
+ if (watcher && watcher.dir === eng.projectDir) return;
105
+ stopWatcher();
106
+ if (!eng.config.indexerWatch) return;
107
+ // An in-project storage dir (storage.path inside the checkout) is written
108
+ // on EVERY reindex run (manifest.save()), so its events must not be
109
+ // accepted — otherwise each kick schedules the next one forever.
110
+ const storageRel = path.relative(eng.projectDir, eng.storageBase);
111
+ const storageInsideProject = storageRel !== "" && !storageRel.startsWith("..") && !path.isAbsolute(storageRel);
112
+ const w = new ProjectWatcher(eng.projectDir, {
113
+ // Same sets the walker enforces: any-depth + root-only semantics,
114
+ // user additions to indexer.ignore included, case-sensitive.
115
+ ignoreSegments: eng.config.indexerIgnore,
116
+ ignorePaths: storageInsideProject ? [eng.storageBase] : [],
117
+ onChange: () => {
118
+ if (engine) kickWatcherIndex(engine, watcherNotify);
119
+ },
120
+ onError: (err) => {
121
+ // E.g. Linux inotify exhaustion: degrade to session-start/lazy
122
+ // indexing and say so once. Never crash the session.
123
+ stopWatcher();
124
+ watcherNotify?.(`Context index: file watcher disabled (${err.message.slice(0, 100)}) — back to session-start indexing only`, "warning");
125
+ },
126
+ });
127
+ if (w.start()) watcher = w;
128
+ };
129
+
35
130
  /**
36
131
  * Kick the one-shot background index (session-start auto-index and the
37
132
  * first-search lazy path share this). Never stacks: `lazyIndexStarted`
@@ -69,6 +164,7 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
69
164
  })
70
165
  .finally(() => {
71
166
  indexInFlight = false;
167
+ drainWatcherKick(); // a watcher change may have landed mid-run
72
168
  });
73
169
  };
74
170
 
@@ -82,6 +178,7 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
82
178
  // Always track the live engine so `session_shutdown` closes it even when
83
179
  // a tool ran before `session_start` (tests, mid-reload edge cases).
84
180
  engine = new ContextEngine({ projectDir: dir });
181
+ attachWatcher(engine);
85
182
  return engine;
86
183
  };
87
184
 
@@ -372,6 +469,12 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
372
469
  engine.startBackgroundWork();
373
470
  lazyIndexStarted = false;
374
471
  bootstrapSent = false;
472
+ watcherNotify = ctx.hasUI ? (msg, level) => ctx.ui.notify(msg, level) : undefined;
473
+ // (Re)arm the file watcher for this session: fresh notify channel, and
474
+ // config may have changed since the last session. No-op when
475
+ // `indexer.watch` is off.
476
+ stopWatcher();
477
+ attachWatcher(engine);
375
478
  if (autoIndexTimer) {
376
479
  clearTimeout(autoIndexTimer);
377
480
  autoIndexTimer = null;
@@ -455,6 +558,8 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
455
558
  clearTimeout(autoIndexTimer);
456
559
  autoIndexTimer = null;
457
560
  }
561
+ stopWatcher();
562
+ watcherNotify = undefined;
458
563
  await engine?.shutdown().catch(() => undefined);
459
564
  engine = null;
460
565
  });
@@ -366,7 +366,10 @@ export async function indexCommits(
366
366
  return { chunks, nodes, edges, headSha };
367
367
  }
368
368
 
369
- /** Per-storage-dir indexing lock so watcher + manual reindex can't interleave. */
369
+ /**
370
+ * Per-storage-dir indexing lock so concurrent reindex runs (auto-index kick,
371
+ * lazy search kick, file watcher, manual /context-index) can't interleave.
372
+ */
370
373
  const pipelineLocks = new Map<string, Promise<unknown>>();
371
374
 
372
375
  export async function runIndexing(
@@ -531,7 +534,8 @@ async function runIndexingLocked(
531
534
  const underScope = (p: string): boolean => (rel === "" ? true : p === rel || p.startsWith(`${rel}/`));
532
535
  purgeIndexed(Object.keys(manifest.fileEntries).filter((p) => underScope(p) && !yielded.has(p)));
533
536
  } else {
534
- // Watcher deletion path: prune instead of erroring out.
537
+ // Target vanished between runs (deleted file, or a branch switch removed it):
538
+ // prune instead of erroring out.
535
539
  const rel = path.relative(projectDir, target).replace(/\\/g, "/");
536
540
  if (manifest.getHash(rel) !== undefined) {
537
541
  backend.deleteByFiles([rel]);
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Real-time reindex trigger — issue #12. A thin debounced wrapper around
3
+ * Node's built-in `fs.watch(dir, { recursive: true })` (one OS handle on
4
+ * Windows/macOS, inotify tree on Linux since Node 19.1).
5
+ *
6
+ * The watcher only produces a *change signal*: the incremental pipeline
7
+ * re-walks and re-hashes anyway, so false positives cost one cheap walk and
8
+ * only missed events matter. That is why the built-in suffices and neither
9
+ * chokidar (per-file stat-confirmed events, Node >= 22.22) nor
10
+ * @parcel/watcher (native addon, snapshot diffs) are needed.
11
+ *
12
+ * Filter semantics deliberately MIRROR the pipeline's inclusion policy
13
+ * (`targetExcluded` / `iterProjectFiles` in pipeline.ts), because a dropped
14
+ * event is the only unrecoverable failure mode: a wrongly accepted event just
15
+ * costs one cheap incremental run.
16
+ */
17
+ import fs from "node:fs";
18
+ import path from "node:path";
19
+ import { DEFAULT_IGNORE, ROOT_ONLY_IGNORE } from "../config.js";
20
+
21
+ /**
22
+ * Trailing-edge debounce for watcher events: long enough that a burst
23
+ * (editor save, `git checkout` rewriting many files) collapses into one
24
+ * kick, short enough that retrieval catches up quickly after an edit.
25
+ */
26
+ export const WATCH_DEBOUNCE_MS = 1500;
27
+
28
+ const ROOT_ONLY_SET = new Set(ROOT_ONLY_IGNORE);
29
+
30
+ export interface ProjectWatcherOptions {
31
+ /** Called once per debounced change burst. Keep it cheap and sync. */
32
+ onChange: () => void;
33
+ /** Fatal watcher problem (e.g. Linux ENOSPC inotify exhaustion). The watcher closes itself; the caller falls back to session-start/lazy indexing. */
34
+ onError?: (err: Error) => void;
35
+ /** Defaults to {@link WATCH_DEBOUNCE_MS}. */
36
+ debounceMs?: number;
37
+ /**
38
+ * Segment names ignored at ANY depth — defaults to `DEFAULT_IGNORE`; the
39
+ * extension passes `config.indexerIgnore` so user additions apply too
40
+ * (same set the walker checks via `ignoreSet.has(seg)`).
41
+ */
42
+ ignoreSegments?: readonly string[];
43
+ /**
44
+ * Absolute directories whose events are dropped entirely. The extension
45
+ * passes the engine's storage dir when it points INSIDE the checkout:
46
+ * every reindex rewrites `manifest.json` there unconditionally, so
47
+ * without this exclusion a watcher kick would re-trigger itself forever
48
+ * after source edits stop (PR #15 review, #12).
49
+ */
50
+ ignorePaths?: readonly string[];
51
+ }
52
+
53
+ export class ProjectWatcher {
54
+ private watcher: fs.FSWatcher | null = null;
55
+ private debounceTimer: NodeJS.Timeout | null = null;
56
+ private stopped = false;
57
+ private watchRoot: string | null = null;
58
+ private readonly ignoreSet: Set<string>;
59
+ private ignorePaths: string[] = [];
60
+
61
+ constructor(
62
+ readonly dir: string,
63
+ private readonly options: ProjectWatcherOptions,
64
+ ) {
65
+ this.ignoreSet = new Set(options.ignoreSegments ?? DEFAULT_IGNORE);
66
+ }
67
+
68
+ /** True while an OS watch handle is held. */
69
+ get watching(): boolean {
70
+ return this.watcher !== null;
71
+ }
72
+
73
+ /** Start watching. Returns false (after reporting via `onError`) when the watch could not be established — never throws. */
74
+ start(): boolean {
75
+ if (this.watcher) return true;
76
+ let watcher: fs.FSWatcher;
77
+ try {
78
+ // Canonicalize 8.3 short names first: on Windows,
79
+ // fs.watch({recursive}) hits an uncatchable libuv assertion
80
+ // (fs-event.c: `filename` vs watched dir prefix mismatch) that ABORTS
81
+ // the whole host process when the watched path has short-name
82
+ // components (common for os.tmpdir() and some tool-set cwds) and an
83
+ // event then reports the long name. realpathSync.native returns the
84
+ // canonical long path and avoids the mismatch entirely.
85
+ const dir = fs.realpathSync.native(this.dir);
86
+ this.watchRoot = dir;
87
+ this.ignorePaths = (this.options.ignorePaths ?? []).map((p) => {
88
+ try {
89
+ return fs.realpathSync.native(p);
90
+ } catch {
91
+ return path.resolve(p);
92
+ }
93
+ });
94
+ watcher = fs.watch(dir, { recursive: true }, (_event, filename) => this.onEvent(filename));
95
+ } catch (err) {
96
+ this.options.onError?.(err as Error);
97
+ return false;
98
+ }
99
+ this.watcher = watcher;
100
+ watcher.on("error", (err) => {
101
+ // E.g. inotify watch exhaustion on huge Linux trees. Close and let
102
+ // the caller degrade to session-start/lazy indexing — a watcher
103
+ // problem must never crash or stall the session.
104
+ this.closeWatcher();
105
+ if (!this.stopped) this.options.onError?.(err);
106
+ });
107
+ // Never keep the host process alive just for indexing.
108
+ watcher.unref?.();
109
+ return true;
110
+ }
111
+
112
+ private onEvent(filename: string | Buffer | null): void {
113
+ if (this.stopped || !this.watcher) return;
114
+ // No filename (happens, e.g. Linux inotify): cannot filter — a missed
115
+ // event is the unrecoverable failure mode, so accept and debounce it.
116
+ if (filename === null) {
117
+ this.scheduleKick();
118
+ return;
119
+ }
120
+ const name = Buffer.isBuffer(filename) ? filename.toString("utf-8") : filename;
121
+ if (name !== "" && this.isNoise(name)) return;
122
+ this.scheduleKick();
123
+ }
124
+
125
+ /**
126
+ * Mirrors the walker's inclusion policy (see header): case-sensitive
127
+ * segment match at any depth, root-only names only for the FIRST segment,
128
+ * and explicit storage-dir path exclusion. Returns "noise" = drop event.
129
+ */
130
+ private isNoise(filename: string): boolean {
131
+ const rel = this.relativeToRoot(filename);
132
+ if (rel === null) return false; // absolute path outside the watch root: be conservative, kick
133
+ const segments = rel.split(/[\\/]/).filter((s) => s !== "");
134
+ if (segments.length === 0) return false;
135
+ // Root-only names (`storage`, `logs`, `tmp`, ...) count only at the
136
+ // project root — nested `src/storage/` is real source the pipeline
137
+ // indexes, so its events must kick (PR #15 review).
138
+ if (ROOT_ONLY_SET.has(segments[0]!)) return true;
139
+ // Case-SENSITIVE like `ignoreSet.has(seg)`: the pipeline indexes
140
+ // `src/Build/`, so its events must not be dropped (PR #15 review).
141
+ if (segments.some((s) => this.ignoreSet.has(s))) return true;
142
+ return this.isUnderIgnoredPath(rel);
143
+ }
144
+
145
+ /** Relative path from the watch root, or null when outside/unknown. */
146
+ private relativeToRoot(filename: string): string | null {
147
+ if (!path.isAbsolute(filename)) return filename;
148
+ const rel = path.relative(this.watchRoot ?? this.dir, filename);
149
+ if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) return null;
150
+ return rel;
151
+ }
152
+
153
+ /** True when the event's resolved path lives under an ignored dir (e.g. an in-project storage dir). */
154
+ private isUnderIgnoredPath(rel: string): boolean {
155
+ if (this.ignorePaths.length === 0 || this.watchRoot === null) return false;
156
+ const abs = path.resolve(this.watchRoot, rel);
157
+ const fold = (p: string) => (process.platform === "win32" ? p.toLowerCase() : p);
158
+ const absFolded = fold(abs);
159
+ return this.ignorePaths.some((p) => {
160
+ const ignored = fold(p);
161
+ return absFolded === ignored || absFolded.startsWith(ignored + path.sep);
162
+ });
163
+ }
164
+
165
+ private scheduleKick(): void {
166
+ if (this.debounceTimer) clearTimeout(this.debounceTimer);
167
+ this.debounceTimer = setTimeout(() => {
168
+ this.debounceTimer = null;
169
+ // `stopped` mid-debounce, or the watcher died (error path) while
170
+ // the timer was pending: no kick.
171
+ if (this.stopped || !this.watcher) return;
172
+ this.options.onChange();
173
+ }, this.options.debounceMs ?? WATCH_DEBOUNCE_MS);
174
+ this.debounceTimer.unref?.();
175
+ }
176
+
177
+ /** Stop watching and cancel any pending debounced kick. Idempotent. */
178
+ stop(): void {
179
+ this.stopped = true;
180
+ if (this.debounceTimer) {
181
+ clearTimeout(this.debounceTimer);
182
+ this.debounceTimer = null;
183
+ }
184
+ this.closeWatcher();
185
+ }
186
+
187
+ private closeWatcher(): void {
188
+ try {
189
+ this.watcher?.close();
190
+ } catch {
191
+ // already closed by the runtime
192
+ }
193
+ this.watcher = null;
194
+ }
195
+ }