pi-doc-injector 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
@@ -1,6 +1,6 @@
1
1
  # Pi Doc Injector
2
2
 
3
- A [Pi](https://pi.dev) extension that automatically injects relevant project documentation into the LLM system prompt by monitoring streaming output for keyword matches.
3
+ A [Pi](https://pi.dev) extension that automatically injects relevant project documentation into the LLM context by monitoring streaming output for keyword matches. Docs are delivered as a `CustomMessage` so the system prompt stays untouched and the provider's prompt cache stays warm.
4
4
 
5
5
  ## Installation
6
6
 
@@ -29,8 +29,9 @@ git clone https://github.com/yourname/pi-doc-injector.git .pi/extensions/doc-inj
29
29
  1. Create a `docs/` folder in your project root.
30
30
  2. Add markdown files with frontmatter (`title` + `keywords`). See [Document Format](#document-format) for supported formats.
31
31
  3. Start Pi. The extension scans `docs/` on session start.
32
- 4. When the user mentions a keyword, the matching doc is injected into the
33
- system prompt **before the assistant responds** — no one-turn delay.
32
+ 4. When the user mentions a keyword, the matching doc is injected as a
33
+ `CustomMessage` into the conversation **before the assistant responds** —
34
+ no one-turn delay. The system prompt is never modified.
34
35
  5. If the assistant mentions a NEW keyword mid-response, generation is
35
36
  automatically aborted and restarted with the doc injected immediately.
36
37
 
@@ -208,18 +209,41 @@ The extension uses a per-session injection model:
208
209
  - **Assistant streaming**: if the assistant mentions a NEW keyword mid-response,
209
210
  generation is aborted and restarted with the doc injected immediately.
210
211
 
211
- ### System Prompt Lifecycle
212
+ ### Injection Mechanism
212
213
 
213
- Pi **reconstructs the system prompt from source files each turn** (verified against pi v0.70.6).
214
+ On match, the extension returns a `message` field from `before_agent_start`
215
+ with `customType: "doc-injector"`. Pi appends this to the session and sends
216
+ it to the LLM as part of the conversation. The system prompt is **never**
217
+ mutated.
214
218
 
215
- When `before_agent_start` fires, the `systemPrompt` passed to the extension is a freshly rebuilt prompt from `AGENTS.md`, `SYSTEM.md`, skills, and tool snippets. It is **not** accumulated from previous turns.
219
+ #### Why a CustomMessage, not the system prompt?
216
220
 
217
- This means:
221
+ - The system prompt is the highest-value prompt-cache slot. Each unique
222
+ system prompt text breaks the cache (5-min TTL by default). Appending
223
+ per-turn doc content there would invalidate the cache on every first
224
+ injection.
225
+ - A `CustomMessage` only adds to the conversation prefix, leaving the
226
+ system prompt byte-identical across turns and the cache warm.
218
227
 
219
- - Injections apply to the **current turn only** and do not persist in subsequent turns.
220
- - There is no risk of duplicate injection sections stacking up over time.
221
- - The `injected` flag alone is sufficient to prevent re-injection — no additional deduplication or marker-based stripping is needed.
228
+ #### Double-injection prevention
222
229
 
230
+ Two independent guards make duplicate injection impossible in a session:
231
+
232
+ 1. **Matcher guard** — `buildMatcher()` only includes non-injected entries
233
+ (via `getNonInjectedEntries()`), so already-injected docs cannot be
234
+ re-matched.
235
+ 2. **Mark guard** — `markInjected()` runs inside `before_agent_start` before
236
+ the LLM call, so even if the matcher ever produced a duplicate, the
237
+ mark would still prevent a second send.
238
+
239
+ In practice, the matcher guard is the primary defense; the mark guard is
240
+ defense-in-depth for race conditions (e.g. if `resources_discover` rebuilds
241
+ the registry mid-injection).
242
+
243
+ The `injected` flag is per-session: it's reset on `session_start` and can
244
+ be manually cleared with `/doc-inject reset`.
245
+
246
+ For the full source-level verification, see the JSDoc block in `index.ts`.
223
247
  For the full source-level verification, see the JSDoc block in `index.ts`.
224
248
 
225
249
  ## Development
package/cache.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  import { mkdir, readFile, writeFile } from "node:fs/promises";
10
10
  import { dirname, join } from "node:path";
11
11
  import type { KeywordCache } from "./types";
12
+ import type { Notifier } from "./notifier";
12
13
 
13
14
  const CACHE_FILENAME = ".pi/doc-injector-cache.json";
14
15
  const CACHE_VERSION = 1;
@@ -17,8 +18,11 @@ const CACHE_VERSION = 1;
17
18
  * Load the keyword cache from disk.
18
19
  * Returns an empty cache (version 1, no files) if the file doesn't exist,
19
20
  * has wrong version, or is corrupted.
21
+ *
22
+ * Recoverable issues (corrupt JSON, wrong version) emit a warning via the
23
+ * `notifier`. ENOENT (no cache file yet) is silent.
20
24
  */
21
- export async function loadCache(cwd: string): Promise<KeywordCache> {
25
+ export async function loadCache(cwd: string, notifier: Notifier): Promise<KeywordCache> {
22
26
  const cachePath = join(cwd, CACHE_FILENAME);
23
27
 
24
28
  try {
@@ -26,7 +30,7 @@ export async function loadCache(cwd: string): Promise<KeywordCache> {
26
30
  const parsed: unknown = JSON.parse(raw);
27
31
 
28
32
  if (!isValidCache(parsed)) {
29
- console.warn(
33
+ notifier.warn(
30
34
  `[doc-injector] Invalid cache format or version at ${cachePath}, resetting.`,
31
35
  );
32
36
  return emptyCache();
@@ -36,10 +40,8 @@ export async function loadCache(cwd: string): Promise<KeywordCache> {
36
40
  } catch (err) {
37
41
  // ENOENT = no cache file yet, that's fine
38
42
  if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
39
- console.warn(
40
- `[doc-injector] Failed to read cache at ${cachePath}:`,
41
- err instanceof Error ? err.message : String(err),
42
- );
43
+ const detail = err instanceof Error ? err.message : String(err);
44
+ notifier.warn(`[doc-injector] Failed to read cache at ${cachePath}: ${detail}`);
43
45
  }
44
46
  return emptyCache();
45
47
  }
package/config.ts CHANGED
@@ -5,47 +5,56 @@
5
5
  import { readFile } from "node:fs/promises";
6
6
  import { join } from "node:path";
7
7
  import { DEFAULT_CONFIG, type DocInjectorConfig } from "./types";
8
+ import type { Notifier } from "./notifier";
8
9
 
9
10
  /**
10
11
  * Clamp an integer value to [min, max] range.
11
- * Warns and clamps if out of range. Returns the default if not a number.
12
+ * Warns via the `notifier` and clamps if out of range. Returns the default
13
+ * if not a number.
12
14
  */
13
15
  function clampInt(
14
- value: unknown,
15
- defaultVal: number,
16
- min: number,
17
- max: number,
18
- fieldName: string,
16
+ value: unknown,
17
+ defaultVal: number,
18
+ min: number,
19
+ max: number,
20
+ fieldName: string,
21
+ notifier: Notifier,
19
22
  ): number {
20
- if (typeof value !== "number" || Number.isNaN(value)) {
21
- return defaultVal;
22
- }
23
- const intVal = Math.trunc(value);
24
- if (intVal < min || intVal > max) {
25
- const clamped = Math.max(min, Math.min(max, intVal));
26
- console.warn(`[doc-injector] ${fieldName} must be ${min}-${max}, got ${intVal}. Clamping to ${clamped}.`);
27
- return clamped;
28
- }
29
- return intVal;
23
+ if (typeof value !== "number" || Number.isNaN(value)) {
24
+ return defaultVal;
25
+ }
26
+ const intVal = Math.trunc(value);
27
+ if (intVal < min || intVal > max) {
28
+ const clamped = Math.max(min, Math.min(max, intVal));
29
+ notifier.warn(`[doc-injector] ${fieldName} must be ${min}-${max}, got ${intVal}. Clamping to ${clamped}.`);
30
+ return clamped;
31
+ }
32
+ return intVal;
30
33
  }
31
34
 
35
+ /**
32
36
  /**
33
37
  * Validate a glob pattern array.
34
38
  * Rejects non-array or entries that aren't strings. Returns default on error.
39
+ * Warns via the `notifier` for non-string entries.
35
40
  */
36
- function validateGlobArray(value: unknown, defaultVal: string[]): string[] {
37
- if (!Array.isArray(value)) {
38
- return [...defaultVal];
39
- }
40
- const result: string[] = [];
41
- for (const item of value) {
42
- if (typeof item === "string") {
43
- result.push(item);
44
- } else {
45
- console.warn(`[doc-injector] Non-string entry in glob array ignored: ${String(item)}`);
41
+ function validateGlobArray(
42
+ value: unknown,
43
+ defaultVal: string[],
44
+ notifier: Notifier,
45
+ ): string[] {
46
+ if (!Array.isArray(value)) {
47
+ return [...defaultVal];
48
+ }
49
+ const result: string[] = [];
50
+ for (const item of value) {
51
+ if (typeof item === "string") {
52
+ result.push(item);
53
+ } else {
54
+ notifier.warn(`[doc-injector] Non-string entry in glob array ignored: ${String(item)}`);
55
+ }
46
56
  }
47
- }
48
- return result.length > 0 ? result : [...defaultVal];
57
+ return result.length > 0 ? result : [...defaultVal];
49
58
  }
50
59
 
51
60
  /**
@@ -54,33 +63,37 @@ function validateGlobArray(value: unknown, defaultVal: string[]): string[] {
54
63
  * Validates and clamps all numeric fields. Falls back to DEFAULT_CONFIG
55
64
  * if file doesn't exist or is invalid.
56
65
  */
57
- export async function loadConfig(cwd: string): Promise<DocInjectorConfig> {
58
- const configPath = join(cwd, ".pi", "doc-injector.json");
66
+ /**
67
+ * Load config from `.pi/doc-injector.json` relative to the given cwd.
68
+ * Async — uses readFile from fs/promises. Validates and clamps all numeric
69
+ * fields. Falls back to DEFAULT_CONFIG if the file doesn't exist or is
70
+ * invalid. Warnings (clamping, invalid entries) go through the `notifier`.
71
+ */
72
+ export async function loadConfig(cwd: string, notifier: Notifier): Promise<DocInjectorConfig> {
73
+ const configPath = join(cwd, ".pi", "doc-injector.json");
59
74
 
60
- try {
61
- const raw = await readFile(configPath, "utf-8");
62
- const parsed = JSON.parse(raw) as Partial<DocInjectorConfig>;
75
+ try {
76
+ const raw = await readFile(configPath, "utf-8");
77
+ const parsed = JSON.parse(raw) as Partial<DocInjectorConfig>;
63
78
 
64
- return {
65
- docsPath: parsed.docsPath ?? DEFAULT_CONFIG.docsPath,
66
- matchThreshold: clampInt(parsed.matchThreshold, DEFAULT_CONFIG.matchThreshold, 1, Infinity, "matchThreshold"),
67
- contextThreshold: clampInt(parsed.contextThreshold, DEFAULT_CONFIG.contextThreshold, 0, 100, "contextThreshold"),
68
- recursive: parsed.recursive ?? DEFAULT_CONFIG.recursive,
69
- include: validateGlobArray(parsed.include, DEFAULT_CONFIG.include),
70
- exclude: validateGlobArray(parsed.exclude, DEFAULT_CONFIG.exclude),
71
- maxFileSize: clampInt(parsed.maxFileSize, DEFAULT_CONFIG.maxFileSize, 1024, 10 * 1024 * 1024, "maxFileSize"),
72
- autoKeywords: parsed.autoKeywords ?? DEFAULT_CONFIG.autoKeywords,
73
- llmKeywords: parsed.llmKeywords ?? DEFAULT_CONFIG.llmKeywords,
74
- maxConcurrent: clampInt(parsed.maxConcurrent, DEFAULT_CONFIG.maxConcurrent, 1, 100, "maxConcurrent"),
75
- llmBatchSize: clampInt(parsed.llmBatchSize, DEFAULT_CONFIG.llmBatchSize, 1, 100, "llmBatchSize"),
76
- };
77
- } catch (err) {
78
- if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
79
- console.warn(
80
- `[doc-injector] Failed to parse config at ${configPath}:`,
81
- err instanceof Error ? err.message : String(err),
82
- );
79
+ return {
80
+ docsPath: parsed.docsPath ?? DEFAULT_CONFIG.docsPath,
81
+ matchThreshold: clampInt(parsed.matchThreshold, DEFAULT_CONFIG.matchThreshold, 1, Infinity, "matchThreshold", notifier),
82
+ contextThreshold: clampInt(parsed.contextThreshold, DEFAULT_CONFIG.contextThreshold, 0, 100, "contextThreshold", notifier),
83
+ recursive: parsed.recursive ?? DEFAULT_CONFIG.recursive,
84
+ include: validateGlobArray(parsed.include, DEFAULT_CONFIG.include, notifier),
85
+ exclude: validateGlobArray(parsed.exclude, DEFAULT_CONFIG.exclude, notifier),
86
+ maxFileSize: clampInt(parsed.maxFileSize, DEFAULT_CONFIG.maxFileSize, 1024, 10 * 1024 * 1024, "maxFileSize", notifier),
87
+ autoKeywords: parsed.autoKeywords ?? DEFAULT_CONFIG.autoKeywords,
88
+ llmKeywords: parsed.llmKeywords ?? DEFAULT_CONFIG.llmKeywords,
89
+ maxConcurrent: clampInt(parsed.maxConcurrent, DEFAULT_CONFIG.maxConcurrent, 1, 100, "maxConcurrent", notifier),
90
+ llmBatchSize: clampInt(parsed.llmBatchSize, DEFAULT_CONFIG.llmBatchSize, 1, 100, "llmBatchSize", notifier),
91
+ };
92
+ } catch (err) {
93
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
94
+ const detail = err instanceof Error ? err.message : String(err);
95
+ notifier.warn(`[doc-injector] Failed to parse config at ${configPath}: ${detail}`);
96
+ }
97
+ return { ...DEFAULT_CONFIG };
83
98
  }
84
- return { ...DEFAULT_CONFIG };
85
- }
86
99
  }
package/index.ts CHANGED
@@ -4,6 +4,21 @@
4
4
  * Automatically injects relevant project documentation into the LLM context
5
5
  * by monitoring streaming output for keyword matches.
6
6
  *
7
+ * ## Injection Model: CustomMessage (NOT system prompt)
8
+ *
9
+ * On match, the extension returns a `message` field from `before_agent_start`
10
+ * (a `CustomMessage` with `customType: "doc-injector"`). Pi appends this to the
11
+ * session and sends it to the LLM as part of the conversation — the system
12
+ * prompt is NEVER mutated.
13
+ *
14
+ * Why a message and not the system prompt:
15
+ * - The system prompt is the highest-value Anthropic prompt-cache slot. Each
16
+ * unique system prompt text breaks the cache (5-min TTL by default).
17
+ * Appending per-turn doc content there would invalidate the cache on every
18
+ * first injection.
19
+ * - A `message` only adds to the conversation prefix, leaving the system
20
+ * prompt cache warm across turns.
21
+ *
7
22
  * ## Streaming Model
8
23
  *
9
24
  * This extension relies on Pi's streaming event contract:
@@ -13,7 +28,7 @@
13
28
  * - `message_end`: Fires once when the assistant's response is complete.
14
29
  * The extension finalizes matches and notifies the user.
15
30
  * - `before_agent_start`: Fires before the next agent turn. The extension
16
- * injects matched docs into the system prompt, then marks them as injected.
31
+ * returns a `message` carrying the matched docs and marks them as injected.
17
32
  *
18
33
  * ## Injection Lifecycle
19
34
  *
@@ -22,28 +37,23 @@
22
37
  * session, once a doc is injected, it won't be re-injected unless the user
23
38
  * manually runs `/doc-inject reset`.
24
39
  *
25
- * ## System Prompt Lifecycle (verified against pi v0.70.6)
40
+ * ## Double-Injection Prevention
26
41
  *
27
- * Pi **reconstructs the system prompt from source files each turn**. Here is
28
- * the exact flow, verified via source-code review of dist/core/agent-session.js
29
- * and dist/core/extensions/runner.js (v0.70.6):
42
+ * Two independent guards make duplicate injection impossible in a session:
30
43
  *
31
- * 1. Before each agent turn, pi calls `this._rebuildSystemPrompt(toolNames)`.
32
- * This builds the prompt from `AGENTS.md`, `SYSTEM.md`, skills, enabled
33
- * tool snippets never from a previously modified (injected) prompt.
34
- * 2. The rebuilt prompt is stored in `this._baseSystemPrompt`.
35
- * 3. `emitBeforeAgentStart(..., this._baseSystemPrompt, ...)` passes this
36
- * *fresh* base prompt to every extension handler.
37
- * 4. Extension handlers can return a modified `systemPrompt` for the current
38
- * turn. Pi uses the modified prompt **only for this turn**.
39
- * 5. When no extension modifies the prompt, pi explicitly resets to
40
- * `this._baseSystemPrompt` (comment in source: "Ensure we're using the
41
- * base prompt (in case previous turn had modifications)").
44
+ * 1. **Matcher-level guard**: `buildMatcher()` calls `getNonInjectedEntries()`,
45
+ * so already-injected docs are excluded from the candidate set. The
46
+ * `pendingMatches` map is only populated from the matcher's output, so
47
+ * once a doc is injected, the next `input` event cannot re-match it.
42
48
  *
43
- * **Therefore**: Previous injections from `before_agent_start` do NOT persist
44
- * across turns. Duplicate sections cannot accumulate in the system prompt.
45
- * The `injected` flag alone is sufficient to prevent re-injectionno
46
- * marker-based stripping or deduplication is needed.
49
+ * 2. **Mark guard**: `markInjected()` is called inside `before_agent_start`
50
+ * AFTER the build step but BEFORE the return value is processed. This
51
+ * means the flag flips synchronously with the LLM calleven if the
52
+ * session is reloaded mid-turn, the next `buildMatcher()` won't see the
53
+ * doc as a candidate.
54
+ *
55
+ * The two guards are redundant by design: if matcher exclusion ever fails
56
+ * (e.g. a race), the mark step still prevents the doc from being sent twice.
47
57
  *
48
58
  * ## Race Condition Note
49
59
  *
@@ -58,16 +68,22 @@ import { Type } from "@sinclair/typebox";
58
68
  import { resolve } from "node:path";
59
69
  import { loadCache, saveCache } from "./cache";
60
70
  import { loadConfig } from "./config";
61
- import { buildSystemPromptAppend, notifyInjection } from "./injector";
71
+ import { buildInjectionContent, notifyInjection } from "./injector";
62
72
  import { buildKeywordGenPrompt } from "./keyword-llm";
63
73
  import { extractText, KeywordMatcher } from "./matcher";
74
+ import { ExtensionNotifier, type Notifier } from "./notifier";
64
75
  import { DocRegistry } from "./registry";
65
76
  import { DEFAULT_MATCHER_OPTIONS, type DocEntry, type MatchResult, type KeywordCache, type CacheEntry } from "./types";
66
77
  import { registerCommands } from "./commands";
67
78
 
68
79
  export default async function docInjectorExtension(pi: ExtensionAPI) {
69
80
  // ---- State ----
70
- let config = await loadConfig(process.cwd());
81
+ // The notifier buffers warnings emitted during startup (loadConfig,
82
+ // loadCache, initRegistry) and flushes them via ctx.ui.notify() in
83
+ // session_start. The notifier is bound to the extension lifecycle so
84
+ // startup messages aren't lost.
85
+ const notifier: Notifier = new ExtensionNotifier();
86
+ let config = await loadConfig(process.cwd(), notifier);
71
87
  let registry: DocRegistry | null = null;
72
88
  let initRegistryPromise: Promise<void> | null = null;
73
89
  let enabled = true;
@@ -92,7 +108,7 @@ export default async function docInjectorExtension(pi: ExtensionAPI) {
92
108
  const safeSaveCache = async (cwd: string, dirtyEntries: Record<string, CacheEntry>) => {
93
109
  // MAJOR-2 fix: before saveCache, re-read cache from disk to merge
94
110
  // LLM-written entries that may have landed during the scan.
95
- const freshCache = await loadCache(cwd);
111
+ const freshCache = await loadCache(cwd, notifier);
96
112
  const mergedCache: KeywordCache = { version: 1, files: {} };
97
113
 
98
114
  // Start with fresh (disk) entries — includes any LLM writes during scan
@@ -109,10 +125,10 @@ export default async function docInjectorExtension(pi: ExtensionAPI) {
109
125
  };
110
126
 
111
127
  const initRegistry = async (cwd: string) => {
112
- config = await loadConfig(cwd);
128
+ config = await loadConfig(cwd, notifier);
113
129
  const docsPath = resolve(cwd, config.docsPath);
114
- cache = await loadCache(cwd);
115
- registry = await DocRegistry.create(docsPath, config, cache);
130
+ cache = await loadCache(cwd, notifier);
131
+ registry = await DocRegistry.create(docsPath, config, cache, notifier);
116
132
 
117
133
  const dirty = registry.getDirtyCache();
118
134
  if (Object.keys(dirty).length > 0) {
@@ -193,6 +209,12 @@ export default async function docInjectorExtension(pi: ExtensionAPI) {
193
209
  llmBatchesCompleted = 0;
194
210
  llmTotalFiles = 0;
195
211
 
212
+ // Bind the notifier to the live context FIRST so any warnings emitted
213
+ // during initRegistry below go directly to the TUI instead of being
214
+ // buffered. Messages buffered from earlier (e.g. the factory-body
215
+ // loadConfig call) are flushed here in arrival order.
216
+ notifier.setContext(ctx);
217
+
196
218
  if (event.reason === "reload") return;
197
219
 
198
220
  if (initRegistryPromise) {
@@ -213,7 +235,7 @@ export default async function docInjectorExtension(pi: ExtensionAPI) {
213
235
  const effectiveCwd = cwd ?? process.cwd();
214
236
 
215
237
  // Reload cache from disk to pick up LLM-generated entries
216
- const freshCache = await loadCache(effectiveCwd);
238
+ const freshCache = await loadCache(effectiveCwd, notifier);
217
239
  cache = freshCache;
218
240
  registry.updateCache(cache);
219
241
 
@@ -308,7 +330,11 @@ export default async function docInjectorExtension(pi: ExtensionAPI) {
308
330
  textBuffer = "";
309
331
  });
310
332
 
311
- // ---- Event: before_agent_start (inject into system prompt) ----
333
+ // ---- Event: before_agent_start (inject as CustomMessage) ----
334
+ // Returns a `message` (CustomMessage with customType: "doc-injector") rather
335
+ // than mutating `systemPrompt`. The system prompt stays byte-identical across
336
+ // turns, preserving the prompt cache. The CustomMessage is appended to the
337
+ // session and sent to the LLM as part of the conversation.
312
338
  pi.on("before_agent_start", async (event, ctx) => {
313
339
  // P5.4b — Guard: skip injection during LLM keyword generation
314
340
  if (keywordGenInFlight) return;
@@ -335,9 +361,12 @@ export default async function docInjectorExtension(pi: ExtensionAPI) {
335
361
  return;
336
362
  }
337
363
 
338
- const append = buildSystemPromptAppend(matchedEntries, pendingMatches);
364
+ const content = buildInjectionContent(matchedEntries, pendingMatches);
339
365
 
340
- // Mark as injected only after confirming injection will happen
366
+ // Mark as injected only after confirming injection will happen.
367
+ // This is the second half of the double-injection guard: even if the
368
+ // matcher ever produced a duplicate match, markInjected prevents a
369
+ // second send.
341
370
  registry.markInjected(matchedEntries.map((e) => e.filePath));
342
371
 
343
372
  // Notify user about injection (moved here from message_end so it fires
@@ -348,7 +377,11 @@ export default async function docInjectorExtension(pi: ExtensionAPI) {
348
377
  pendingMatches.clear();
349
378
 
350
379
  return {
351
- systemPrompt: (event.systemPrompt || "") + "\n\n" + append,
380
+ message: {
381
+ customType: "doc-injector",
382
+ content,
383
+ display: true,
384
+ },
352
385
  };
353
386
  });
354
387
 
package/injector.ts CHANGED
@@ -1,8 +1,12 @@
1
1
  /**
2
- * Context Injector — formats matched docs into system prompt append
3
- * and sends TUI notifications.
2
+ * Context Injector — formats matched docs into a content string suitable for
3
+ * injection as a `CustomMessage` (returned from `before_agent_start`) and
4
+ * sends TUI notifications.
5
+ *
6
+ * The produced content is delivered to the LLM as a `CustomMessage` rather
7
+ * than appended to the system prompt. This keeps the system prompt
8
+ * byte-identical across turns so the provider's prompt cache stays warm.
4
9
  */
5
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
6
10
  import type { DocEntry } from "./types";
7
11
 
8
12
  /**
@@ -14,7 +18,7 @@ export interface NotifyCapability {
14
18
  }
15
19
 
16
20
  /**
17
- * Sanitize keywords for safe injection into the system prompt.
21
+ * Sanitize keywords for safe display in the injection content.
18
22
  *
19
23
  * - Strips \n and \r (replaces with space) to prevent prompt injection
20
24
  * - Caps each keyword at 100 characters
@@ -29,11 +33,13 @@ function sanitizeKeywords(keywords: string[]): string[] {
29
33
  }
30
34
 
31
35
  /**
32
- * Build a system prompt append string from matched documents.
36
+ * Build the content string for a `CustomMessage` injection from matched
37
+ * documents. This is the payload that gets returned in
38
+ * `before_agent_start`'s `message.content` and sent to the LLM.
33
39
  */
34
- export function buildSystemPromptAppend(
35
- entries: DocEntry[],
36
- matchedKeywords: Map<string, string[]>,
40
+ export function buildInjectionContent(
41
+ entries: DocEntry[],
42
+ matchedKeywords: Map<string, string[]>,
37
43
  ): string {
38
44
  if (entries.length === 0) return "";
39
45
 
package/notifier.ts ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Notifier — thin wrapper around Pi's `ctx.ui.notify()` that buffers
3
+ * messages until a context is available.
4
+ *
5
+ * ## Why a buffer?
6
+ *
7
+ * Several warnings fire at startup (during `loadConfig` and `initRegistry`),
8
+ * before any `ExtensionContext` exists — extensions are constructed first,
9
+ * events fire later. The `Notifier` interface accepts messages at any time:
10
+ *
11
+ * - If a context has been set, messages are forwarded to `ctx.ui.notify()`.
12
+ * - If not, messages are buffered in memory and flushed on the next
13
+ * `setContext()` call (typically from `session_start`).
14
+ *
15
+ * Production code uses `ExtensionNotifier`. Tests inject a plain object
16
+ * satisfying the `Notifier` interface (or a `vi.fn()` spy) — no real
17
+ * extension context is needed.
18
+ */
19
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
20
+
21
+ export type NotifierLevel = "info" | "warning" | "error";
22
+
23
+ export interface Notifier {
24
+ /** Show an informational message. */
25
+ info(message: string): void;
26
+ /** Show a warning. */
27
+ warn(message: string): void;
28
+ /** Show an error. */
29
+ error(message: string): void;
30
+ /**
31
+ * Bind a context. Flushes any buffered messages via `ctx.ui.notify()`
32
+ * in arrival order. Idempotent: re-calling replaces the context and
33
+ * clears the buffer (already-flushed messages are not re-sent).
34
+ */
35
+ setContext(ctx: ExtensionContext): void;
36
+ }
37
+
38
+ /** Production notifier. Buffers until a context is bound. */
39
+ export class ExtensionNotifier implements Notifier {
40
+ private ctx: ExtensionContext | null = null;
41
+ private buffer: Array<{ level: NotifierLevel; message: string }> = [];
42
+
43
+ setContext(ctx: ExtensionContext): void {
44
+ this.ctx = ctx;
45
+ const pending = this.buffer;
46
+ this.buffer = [];
47
+ for (const { level, message } of pending) {
48
+ ctx.ui.notify(message, level);
49
+ }
50
+ }
51
+
52
+ info(message: string): void {
53
+ this.emit("info", message);
54
+ }
55
+
56
+ warn(message: string): void {
57
+ this.emit("warning", message);
58
+ }
59
+
60
+ error(message: string): void {
61
+ this.emit("error", message);
62
+ }
63
+
64
+ private emit(level: NotifierLevel, message: string): void {
65
+ if (this.ctx) {
66
+ this.ctx.ui.notify(message, level);
67
+ } else {
68
+ this.buffer.push({ level, message });
69
+ }
70
+ }
71
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-doc-injector",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Auto-inject relevant project documentation into Pi's LLM context based on keyword matching",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
package/registry.ts CHANGED
@@ -9,6 +9,7 @@ import type { Dirent } from "node:fs";
9
9
  import { readdir, readFile, stat } from "node:fs/promises";
10
10
  import { basename, extname, join, relative, resolve } from "node:path";
11
11
  import type { CacheEntry, DocEntry, DocInjectorConfig, KeywordCache } from "./types";
12
+ import type { Notifier } from "./notifier";
12
13
  import { createGlobFilter } from "./globber";
13
14
  import { generateKeywords } from "./keyword-gen";
14
15
 
@@ -225,28 +226,36 @@ class PromisePool {
225
226
  * Document Registry class. Scans a docs folder and maintains an index of DocEntry.
226
227
  */
227
228
  export class DocRegistry {
228
- private entries: DocEntry[] = [];
229
- private docsPath: string;
230
- private config: DocInjectorConfig;
231
- private cache: KeywordCache | null = null;
232
- private dirtyCache: KeywordCache = { version: 1, files: {} };
233
-
234
- private constructor(docsPath: string, config: DocInjectorConfig, cache?: KeywordCache) {
235
- this.docsPath = docsPath;
236
- this.config = config;
237
- this.cache = cache ?? null;
238
- }
229
+ private entries: DocEntry[] = [];
230
+ private docsPath: string;
231
+ private config: DocInjectorConfig;
232
+ private cache: KeywordCache | null = null;
233
+ private dirtyCache: KeywordCache = { version: 1, files: {} };
234
+ private notifier: Notifier;
235
+
236
+ private constructor(
237
+ docsPath: string,
238
+ config: DocInjectorConfig,
239
+ cache: KeywordCache | undefined,
240
+ notifier: Notifier,
241
+ ) {
242
+ this.docsPath = docsPath;
243
+ this.config = config;
244
+ this.cache = cache ?? null;
245
+ this.notifier = notifier;
246
+ }
239
247
 
240
- /** Create a registry by scanning the docs folder. */
241
- static async create(
242
- docsPath: string,
243
- config: DocInjectorConfig,
244
- cache?: KeywordCache,
245
- ): Promise<DocRegistry> {
246
- const registry = new DocRegistry(docsPath, config, cache);
247
- await registry.rebuild();
248
- return registry;
249
- }
248
+ /** Create a registry by scanning the docs folder. */
249
+ static async create(
250
+ docsPath: string,
251
+ config: DocInjectorConfig,
252
+ cache: KeywordCache | undefined,
253
+ notifier: Notifier,
254
+ ): Promise<DocRegistry> {
255
+ const registry = new DocRegistry(docsPath, config, cache, notifier);
256
+ await registry.rebuild();
257
+ return registry;
258
+ }
250
259
 
251
260
  /** Re-scan the docs folder and rebuild the index. */
252
261
  async rebuild(): Promise<void> {
@@ -274,7 +283,7 @@ export class DocRegistry {
274
283
  const results = await pool.all(tasks);
275
284
  this.entries = results.filter((e): e is DocEntry => e !== null);
276
285
  } catch {
277
- console.warn(`[doc-injector] Docs folder not found: ${resolved}`);
286
+ this.notifier.warn(`[doc-injector] Docs folder not found: ${resolved}`);
278
287
  this.entries = [];
279
288
  }
280
289
  }
@@ -295,7 +304,7 @@ export class DocRegistry {
295
304
 
296
305
  // Step 2: Skip files exceeding maxFileSize
297
306
  if (fileStat.size > this.config.maxFileSize) {
298
- console.warn(
307
+ this.notifier.warn(
299
308
  `[doc-injector] Skipping ${relativePath}: size ${fileStat.size} > max ${this.config.maxFileSize}`,
300
309
  );
301
310
  return null;
@@ -346,7 +355,7 @@ export class DocRegistry {
346
355
  keywordSource = "heuristic";
347
356
  } else {
348
357
  // Step 11: No frontmatter and autoKeywords disabled — skip
349
- console.warn(
358
+ this.notifier.warn(
350
359
  `[doc-injector] Skipping ${relativePath}: no valid frontmatter with keywords`,
351
360
  );
352
361
  return null;
@@ -373,7 +382,7 @@ export class DocRegistry {
373
382
  } catch (err) {
374
383
  // Only warn for unexpected errors, not ENOENT (file deleted/moved after scan)
375
384
  if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
376
- console.warn(`[doc-injector] Error reading ${relativePath}:`, err);
385
+ this.notifier.warn(`[doc-injector] Error reading ${relativePath}: ${err instanceof Error ? err.message : String(err)}`);
377
386
  }
378
387
  return null;
379
388
  }