pi-doc-injector 0.4.0 → 0.5.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.
Files changed (3) hide show
  1. package/index.ts +46 -25
  2. package/injector.ts +14 -8
  3. package/package.json +1 -1
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
41
+ *
42
+ * Two independent guards make duplicate injection impossible in a session:
26
43
  *
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):
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.
30
48
  *
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)").
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 call — even if the
52
+ * session is reloaded mid-turn, the next `buildMatcher()` won't see the
53
+ * doc as a candidate.
42
54
  *
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-injection — no
46
- * marker-based stripping or deduplication is needed.
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,7 +68,7 @@ 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";
64
74
  import { DocRegistry } from "./registry";
@@ -308,7 +318,11 @@ export default async function docInjectorExtension(pi: ExtensionAPI) {
308
318
  textBuffer = "";
309
319
  });
310
320
 
311
- // ---- Event: before_agent_start (inject into system prompt) ----
321
+ // ---- Event: before_agent_start (inject as CustomMessage) ----
322
+ // Returns a `message` (CustomMessage with customType: "doc-injector") rather
323
+ // than mutating `systemPrompt`. The system prompt stays byte-identical across
324
+ // turns, preserving the prompt cache. The CustomMessage is appended to the
325
+ // session and sent to the LLM as part of the conversation.
312
326
  pi.on("before_agent_start", async (event, ctx) => {
313
327
  // P5.4b — Guard: skip injection during LLM keyword generation
314
328
  if (keywordGenInFlight) return;
@@ -335,9 +349,12 @@ export default async function docInjectorExtension(pi: ExtensionAPI) {
335
349
  return;
336
350
  }
337
351
 
338
- const append = buildSystemPromptAppend(matchedEntries, pendingMatches);
352
+ const content = buildInjectionContent(matchedEntries, pendingMatches);
339
353
 
340
- // Mark as injected only after confirming injection will happen
354
+ // Mark as injected only after confirming injection will happen.
355
+ // This is the second half of the double-injection guard: even if the
356
+ // matcher ever produced a duplicate match, markInjected prevents a
357
+ // second send.
341
358
  registry.markInjected(matchedEntries.map((e) => e.filePath));
342
359
 
343
360
  // Notify user about injection (moved here from message_end so it fires
@@ -348,7 +365,11 @@ export default async function docInjectorExtension(pi: ExtensionAPI) {
348
365
  pendingMatches.clear();
349
366
 
350
367
  return {
351
- systemPrompt: (event.systemPrompt || "") + "\n\n" + append,
368
+ message: {
369
+ customType: "doc-injector",
370
+ content,
371
+ display: true,
372
+ },
352
373
  };
353
374
  });
354
375
 
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-doc-injector",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
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",