pi-doc-injector 0.3.1 → 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 (4) hide show
  1. package/README.md +108 -13
  2. package/index.ts +46 -25
  3. package/injector.ts +14 -8
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -27,7 +27,23 @@ git clone https://github.com/yourname/pi-doc-injector.git .pi/extensions/doc-inj
27
27
  ## Quick Start
28
28
 
29
29
  1. Create a `docs/` folder in your project root.
30
- 2. Add markdown files with YAML frontmatter:
30
+ 2. Add markdown files with frontmatter (`title` + `keywords`). See [Document Format](#document-format) for supported formats.
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.
34
+ 5. If the assistant mentions a NEW keyword mid-response, generation is
35
+ automatically aborted and restarted with the doc injected immediately.
36
+
37
+ ## Document Format
38
+
39
+ Documents are markdown files (`.md` or `.txt`) that the extension scans for injection.
40
+ Each file can declare `title` and `keywords` via **frontmatter** — a metadata block at the top of the file.
41
+
42
+ ### Supported Frontmatter Formats
43
+
44
+ The extension tries formats in this order and uses the first match it finds:
45
+
46
+ **1. YAML (recommended)**
31
47
 
32
48
  ```md
33
49
  ---
@@ -36,28 +52,57 @@ keywords: [test, testing, jest, vitest]
36
52
  ---
37
53
 
38
54
  # Testing Workflow
55
+ ```
56
+
57
+ **2. C-style block comment** — useful for `.ts`/`.js` doc files:
58
+
59
+ ```md
60
+ /*---
61
+ title: "Testing Workflow"
62
+ keywords: [test, testing, jest, vitest]
63
+ ---*/
39
64
 
40
- Your documentation here...
65
+ # Testing Workflow
41
66
  ```
42
67
 
43
- Keywords can also be specified in block format:
68
+ **3. HTML comment** useful for HTML-generated docs:
44
69
 
45
70
  ```md
46
- ---
71
+ <!--
47
72
  title: "Testing Workflow"
48
- keywords:
73
+ keywords: [test, testing, jest, vitest]
74
+ -->
75
+
76
+ # Testing Workflow
77
+ ```
78
+
79
+ **4. Slash-slash comment** — useful for `.js`/`.ts` sidecar docs:
80
+
81
+ ```md
82
+ //---
83
+ title: "Testing Workflow"
84
+ keywords: [test, testing, jest, vitest]
85
+
86
+ # Testing Workflow
87
+ ```
88
+
89
+ ### Keyword Array Syntax
90
+
91
+ Both **flow** and **block** keyword array syntaxes are supported:
92
+
93
+ ```md
94
+ keywords: [test, testing, jest] # flow: comma-separated in brackets
95
+ keywords: # block: one per line
49
96
  - test
50
97
  - testing
51
98
  - jest
52
- - vitest
53
- ---
54
99
  ```
55
100
 
56
- 3. Start Pi. The extension scans `docs/` on session start.
57
- 4. When the user mentions a keyword, the matching doc is injected into the
58
- system prompt **before the assistant responds** — no one-turn delay.
59
- 5. If the assistant mentions a NEW keyword mid-response, generation is
60
- automatically aborted and restarted with the doc injected immediately.
101
+ ### Auto-Keywords Fallback
102
+
103
+ If a file has **no frontmatter** and `autoKeywords` is enabled (default: `true`), the extension generates keywords heuristically from the filename and content — no metadata needed.
104
+
105
+ If `autoKeywords` is `false`, files without valid frontmatter are **skipped** with a warning.
61
106
 
62
107
  ## Configuration
63
108
 
@@ -68,7 +113,10 @@ Create `.pi/doc-injector.json` in your project root to customize behavior:
68
113
  "docsPath": "./docs",
69
114
  "matchThreshold": 1,
70
115
  "contextThreshold": 80,
71
- "recursive": true
116
+ "recursive": true,
117
+ "autoKeywords": true,
118
+ "llmKeywords": false,
119
+ "llmBatchSize": 20
72
120
  }
73
121
  ```
74
122
 
@@ -78,6 +126,9 @@ Create `.pi/doc-injector.json` in your project root to customize behavior:
78
126
  | `matchThreshold` | `1` | Minimum keyword matches required to inject a doc |
79
127
  | `contextThreshold` | `80` | Skip injection when context usage exceeds this % (0–100) |
80
128
  | `recursive` | `true` | Scan docs subdirectories recursively |
129
+ | `autoKeywords` | `true` | Generate keywords heuristically when frontmatter is missing |
130
+ | `llmKeywords` | `false` | Enable LLM-based keyword generation (see below) |
131
+ | `llmBatchSize` | `20` | Max files per LLM keyword batch |
81
132
 
82
133
  ### Keyword Matching
83
134
 
@@ -96,6 +147,50 @@ Injection is also skipped if the current context usage exceeds 80% of the token
96
147
  | `/doc-inject reset` | Reset all injected flags (docs become re-injectable) |
97
148
  | `/doc-inject status` | Show current injection status and config |
98
149
  | `/doc-reload` | Re-scan docs folder and rebuild registry |
150
+ | `/doc-keywords-gen` | Generate LLM keywords for files without frontmatter (requires `llmKeywords: true` in config) |
151
+
152
+ ## Keyword Generation
153
+
154
+ When a document has no frontmatter keywords, the extension handles it in two ways:
155
+
156
+ ### Heuristic (Automatic)
157
+
158
+ If `autoKeywords` is `true` (default), keywords are generated automatically from:
159
+
160
+ - **Filename parts**: `"api-authentication.md"` → `[api, authentication]`
161
+ - **Markdown headings**: `"# Getting Started"` → `[getting, started]`
162
+ - **Code symbols**: `"function foo()"` → `[foo]`
163
+
164
+ All keywords are filtered through a stop-word list, lowercased, and capped at 20.
165
+
166
+ ### LLM Generation (Manual)
167
+
168
+ For better keywords, enable LLM generation in config:
169
+
170
+ ```json
171
+ {
172
+ "autoKeywords": true,
173
+ "llmKeywords": true,
174
+ "llmBatchSize": 20
175
+ }
176
+ ```
177
+
178
+ Then run `/doc-keywords-gen [path]` to generate keywords via LLM. Without a path argument, it processes all keyword-less files.
179
+
180
+ The LLM reads each file's content and produces 3–10 relevant, searchable keywords per file. Results are saved to the cache and reused on subsequent scans.
181
+
182
+ ### Keyword Source Tracking
183
+
184
+ The cache stores which method was used for each file's keywords:
185
+
186
+ | Source | How set |
187
+ | ------------ | ------------------------------------------------ |
188
+ | `frontmatter` | Keywords declared in file frontmatter |
189
+ | `cache` | Reused from previous scan (mtime match) |
190
+ | `heuristic` | Auto-generated from filename/content |
191
+ | `llm` | Generated via `/doc-keywords-gen` |
192
+
193
+ Use `/doc-inject list` to see each file's keyword source (shown as `[source]` tag).
99
194
 
100
195
  ## Injection Lifecycle
101
196
 
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.3.1",
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",