pi-doc-injector 0.1.3 → 0.2.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
@@ -54,7 +54,10 @@ keywords:
54
54
  ```
55
55
 
56
56
  3. Start Pi. The extension scans `docs/` on session start.
57
- 4. When the LLM mentions keywords from your docs, the relevant document is injected into the next turn's system prompt.
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.
58
61
 
59
62
  ## Configuration
60
63
 
@@ -63,7 +66,7 @@ Create `.pi/doc-injector.json` in your project root to customize behavior:
63
66
  ```json
64
67
  {
65
68
  "docsPath": "./docs",
66
- "matchThreshold": 2,
69
+ "matchThreshold": 1,
67
70
  "contextThreshold": 80,
68
71
  "recursive": true
69
72
  }
@@ -72,7 +75,7 @@ Create `.pi/doc-injector.json` in your project root to customize behavior:
72
75
  | Option | Default | Description |
73
76
  | ------------------ | ---------- | -------------------------------------------------------- |
74
77
  | `docsPath` | `"./docs"` | Path to docs folder (relative to project root) |
75
- | `matchThreshold` | `2` | Minimum keyword matches required to inject a doc |
78
+ | `matchThreshold` | `1` | Minimum keyword matches required to inject a doc |
76
79
  | `contextThreshold` | `80` | Skip injection when context usage exceeds this % (0–100) |
77
80
  | `recursive` | `true` | Scan docs subdirectories recursively |
78
81
 
@@ -103,14 +106,28 @@ The extension uses a per-session injection model:
103
106
  - Use `/doc-inject reset` to manually reset all flags and allow docs to be injected again.
104
107
  - Use `/doc-inject list` to see which docs have been injected (✅) and which are pending (⬜).
105
108
 
109
+ ### System Prompt Lifecycle
110
+
111
+ Pi **reconstructs the system prompt from source files each turn** (verified against pi v0.70.6).
112
+
113
+ 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.
114
+
115
+ This means:
116
+
117
+ - Injections apply to the **current turn only** and do not persist in subsequent turns.
118
+ - There is no risk of duplicate injection sections stacking up over time.
119
+ - The `injected` flag alone is sufficient to prevent re-injection — no additional deduplication or marker-based stripping is needed.
120
+
121
+ For the full source-level verification, see the JSDoc block in `index.ts`.
122
+
106
123
  ## Development
107
124
 
108
125
  ```bash
109
126
  # Run tests
110
- bun test
127
+ npm test
111
128
 
112
129
  # Run tests in watch mode
113
- bun test --watch
130
+ npm run test:watch
114
131
  ```
115
132
 
116
133
  ## License
package/docs/bun.md CHANGED
@@ -1,32 +1,32 @@
1
1
  ---
2
2
  title: "JavaScript Runtime & Package Manager"
3
- keywords: [bun, package manager, runtime, install, test, build]
3
+ keywords: [npm, node, package manager, runtime, install, test, build]
4
4
  ---
5
5
 
6
6
  # JavaScript Runtime & Package Manager
7
7
 
8
- This project uses **Bun** as its JavaScript runtime and package manager.
8
+ This project uses **Node.js** as its JavaScript runtime and **npm** as its package manager.
9
9
 
10
- ## Why Bun?
10
+ ## Why npm?
11
11
 
12
- - Fast native TypeScript support (no separate tsc step needed)
13
- - All-in-one runtime, bundler, and test runner
14
- - Drop-in replacement for Node.js and npm/yarn/pnpm
15
- - Significantly faster installs and test executions
12
+ - The default package manager that ships with Node.js
13
+ - Widely supported across all CI/CD platforms
14
+ - Deterministic installs via `package-lock.json`
15
+ - No additional runtime or tooling required
16
16
 
17
17
  ## Common Commands
18
18
 
19
19
  ```bash
20
20
  # Install dependencies
21
- bun install
21
+ npm install
22
22
 
23
23
  # Run tests
24
- bun test
24
+ npm test
25
25
 
26
- # Run tests in watch mode (recommended for development)
27
- bun test --watch
26
+ # Run tests in watch mode
27
+ npm run test:watch
28
28
  ```
29
29
 
30
30
  ## Version
31
31
 
32
- This project requires Bun >= 1.3.0. The lockfile (`bun.lock`) ensures reproducible installs.
32
+ This project requires Node.js >= 18.0.0. The lockfile (`package-lock.json`) ensures reproducible installs.
package/docs/publish.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: "Publishing Workflow"
3
- keywords: [publish, release, npm, version, tag, bun, oidc, trusted publisher]
3
+ keywords: [publish, release, npm, version, tag, node, oidc, trusted publisher]
4
4
  ---
5
5
 
6
6
  # Publishing Workflow
@@ -54,9 +54,9 @@ git push origin master --follow-tags
54
54
  ## CI/CD
55
55
 
56
56
  The `Publish` workflow triggers on `v*` tags and:
57
- - Runs `bun install --frozen-lockfile`
57
+ - Runs `npm ci`
58
58
  - Verifies tag matches `package.json` version
59
- - Runs `bun test`
59
+ - Runs `npm test`
60
60
  - Publishes to npm with provenance via OIDC
61
61
 
62
62
  Monitor: https://github.com/lmn451/pi-docs/actions
package/index.ts CHANGED
@@ -22,6 +22,29 @@
22
22
  * session, once a doc is injected, it won't be re-injected unless the user
23
23
  * manually runs `/doc-inject reset`.
24
24
  *
25
+ * ## System Prompt Lifecycle (verified against pi v0.70.6)
26
+ *
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):
30
+ *
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)").
42
+ *
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.
47
+ *
25
48
  * ## Race Condition Note
26
49
  *
27
50
  * If `resources_discover` (rebuild) fires while `before_agent_start` is running,
@@ -46,6 +69,7 @@ export default async function docInjectorExtension(pi: ExtensionAPI) {
46
69
  let enabled = true;
47
70
  let textBuffer = "";
48
71
  let pendingMatches = new Map<string, string[]>(); // filePath → matchedKeywords
72
+ let abortingForInjection = false; // guard against cascading aborts
49
73
 
50
74
  // ---- Helpers ----
51
75
  const getRegistry = () => registry;
@@ -74,8 +98,16 @@ export default async function docInjectorExtension(pi: ExtensionAPI) {
74
98
  );
75
99
  };
76
100
 
101
+ let lastInitTime = 0;
102
+
77
103
  // ---- Event: session_start ----
104
+ // Pi fires session_start twice on startup (both with reason "startup").
105
+ // Use a 2-second dedup window to skip the duplicate. Real session changes
106
+ // (/new, /resume, /fork) happen well outside this window.
78
107
  pi.on("session_start", async (_event, ctx) => {
108
+ const now = Date.now();
109
+ if (now - lastInitTime < 100) return;
110
+ lastInitTime = now;
79
111
  await initRegistry(ctx.cwd);
80
112
  });
81
113
 
@@ -92,54 +124,65 @@ export default async function docInjectorExtension(pi: ExtensionAPI) {
92
124
  await reloadRegistry();
93
125
  });
94
126
 
95
- // ---- Event: message_update (streaming detection) ----
96
- // NOTE: Pi's message_update event sends the full accumulated content of the
97
- // assistant message on each update, not just the delta. We therefore REPLACE
98
- // (not append to) the text buffer on each event, ensuring we always match
99
- // against the complete message text.
100
- pi.on("message_update", async (event, _ctx) => {
127
+ // ---- Event: input (user message matching) ----
128
+ // message_update only fires for assistant streaming messages, not user
129
+ // messages. We use the input event instead to populate pendingMatches
130
+ // BEFORE before_agent_start fires, so docs are injected in time for
131
+ // the assistant's immediate response.
132
+ pi.on("input", async (event, _ctx) => {
133
+ if (!enabled || !registry) return;
134
+ if (!event.text) return;
135
+
136
+ const matcher = buildMatcher();
137
+ if (!matcher) return;
138
+
139
+ const results = matcher.match(event.text);
140
+ for (const result of results) {
141
+ pendingMatches.set(result.entry.filePath, result.matchedKeywords);
142
+ }
143
+ });
144
+
145
+ // ---- Event: message_update (assistant streaming) ----
146
+ // For assistant streaming messages: if we detect NEW keyword matches for
147
+ // non-injected docs, abort the current generation and restart with the
148
+ // injected context — no waiting for the next turn.
149
+ pi.on("message_update", async (event, ctx) => {
101
150
  if (!enabled || !registry) return;
102
151
 
103
- // Only process assistant messages
104
152
  const msg = event.message;
105
153
  if (msg.role !== "assistant") return;
106
154
 
107
- // Replace buffer with full message text (message_update contains full content)
108
155
  const content = (msg as unknown as { content: unknown }).content;
109
156
  textBuffer = extractText(content);
110
157
  if (!textBuffer) return;
111
158
 
112
- // Run matcher
113
159
  const matcher = buildMatcher();
114
160
  if (!matcher) return;
115
161
 
116
162
  const results = matcher.match(textBuffer);
117
163
 
118
- // Store matches (dedup by filePath)
164
+ let hasNew = false;
119
165
  for (const result of results) {
166
+ if (!pendingMatches.has(result.entry.filePath)) {
167
+ hasNew = true;
168
+ }
120
169
  pendingMatches.set(result.entry.filePath, result.matchedKeywords);
121
170
  }
171
+
172
+ if (hasNew && !ctx.isIdle() && !abortingForInjection) {
173
+ abortingForInjection = true;
174
+ ctx.abort();
175
+ }
122
176
  });
123
177
 
124
178
  // ---- Event: message_end (finalize matches) ----
125
- pi.on("message_end", async (event, ctx) => {
179
+ // Notification moved to before_agent_start so it fires for both user-triggered
180
+ // and auto-abort-triggered injections. message_end now just resets state.
181
+ pi.on("message_end", async (event, _ctx) => {
126
182
  if (!enabled || !registry) return;
127
-
128
183
  const msg = event.message;
129
184
  if (msg.role !== "assistant") return;
130
-
131
- // Clear buffer
132
185
  textBuffer = "";
133
-
134
- // Notify user about pending injections
135
- if (pendingMatches.size > 0) {
136
- const matchedEntries: DocEntry[] = [];
137
- for (const [filePath] of pendingMatches) {
138
- const entry = registry.getEntries().find((e) => e.filePath === filePath);
139
- if (entry) matchedEntries.push(entry);
140
- }
141
- notifyInjection(ctx.ui, matchedEntries, pendingMatches);
142
- }
143
186
  });
144
187
 
145
188
  // ---- Event: before_agent_start (inject into system prompt) ----
@@ -171,6 +214,12 @@ export default async function docInjectorExtension(pi: ExtensionAPI) {
171
214
 
172
215
  // Mark as injected only after confirming injection will happen
173
216
  registry.markInjected(matchedEntries.map((e) => e.filePath));
217
+
218
+ // Notify user about injection (moved here from message_end so it fires
219
+ // even when matches come from user messages, which get cleared before
220
+ // the assistant's message_end)
221
+ notifyInjection(ctx.ui, matchedEntries, pendingMatches);
222
+
174
223
  pendingMatches.clear();
175
224
 
176
225
  return {
@@ -178,6 +227,16 @@ export default async function docInjectorExtension(pi: ExtensionAPI) {
178
227
  };
179
228
  });
180
229
 
230
+ // ---- Event: agent_end (restart after auto-abort) ----
231
+ pi.on("agent_end", async () => {
232
+ if (abortingForInjection) {
233
+ abortingForInjection = false;
234
+ // Send a follow-up message to restart the turn.
235
+ // before_agent_start will inject the matched docs into context.
236
+ pi.sendUserMessage("continue", { deliverAs: "followUp" });
237
+ }
238
+ });
239
+
181
240
  // ---- Commands ----
182
241
  registerCommands(pi, {
183
242
  getRegistry,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-doc-injector",
3
- "version": "0.1.3",
3
+ "version": "0.2.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",
@@ -13,8 +13,8 @@
13
13
  "README.md"
14
14
  ],
15
15
  "scripts": {
16
- "test": "bun test",
17
- "test:watch": "bun test --watch"
16
+ "test": "vitest run",
17
+ "test:watch": "vitest"
18
18
  },
19
19
  "keywords": [
20
20
  "pi-package",
@@ -37,10 +37,12 @@
37
37
  "@mariozechner/pi-coding-agent": "*"
38
38
  },
39
39
  "devDependencies": {
40
- "@types/bun": "^1.3.13"
40
+ "@types/node": "^22.0.0",
41
+ "typescript": "^5.7.0",
42
+ "vitest": "^3.0.0"
41
43
  },
42
44
  "publishConfig": {
43
45
  "access": "public"
44
46
  },
45
- "packageManager": "bun@1.3.11"
47
+ "packageManager": "npm@11.5.2"
46
48
  }
package/registry.ts CHANGED
@@ -144,16 +144,28 @@ export class DocRegistry {
144
144
  for (const dirent of dirents) {
145
145
  if (!dirent.isFile() || !dirent.name.endsWith(".md")) continue;
146
146
 
147
- // Build relative path from the directory tree
148
- const parentPath = (dirent as Dirent & { path?: string }).path ?? "";
149
- const relPath = parentPath
150
- ? relative(dir, join(parentPath, dirent.name))
151
- : dirent.name;
147
+ const fileName = basename(dirent.name);
148
+
149
+ // Cross-runtime: when dirent.name is just the filename, resolve the
150
+ // relative path from the parent directory. Use parentPath (Node 18+)
151
+ // with fallback to .path (Bun) for older runtimes.
152
+ let relPath: string;
153
+ if (dirent.name === fileName) {
154
+ const parentPath = (dirent as Dirent & { parentPath?: string; path?: string }).parentPath
155
+ ?? (dirent as Dirent & { path?: string }).path
156
+ ?? "";
157
+ relPath = parentPath
158
+ ? relative(dir, join(parentPath, dirent.name))
159
+ : dirent.name;
160
+ } else {
161
+ // Node-style: dirent.name already contains the relative path from dir
162
+ relPath = dirent.name;
163
+ }
152
164
 
153
165
  results.push({
154
166
  filePath: join(dir, relPath),
155
167
  relativePath: relPath,
156
- fileName: dirent.name,
168
+ fileName,
157
169
  });
158
170
  }
159
171
 
package/types.ts CHANGED
@@ -38,7 +38,7 @@ export interface DocInjectorConfig {
38
38
  /** Default configuration values. */
39
39
  export const DEFAULT_CONFIG: DocInjectorConfig = {
40
40
  docsPath: "./docs",
41
- matchThreshold: 2,
41
+ matchThreshold: 1,
42
42
  contextThreshold: 80,
43
43
  recursive: true,
44
44
  };