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 +22 -5
- package/docs/bun.md +12 -12
- package/docs/publish.md +3 -3
- package/index.ts +83 -24
- package/package.json +7 -5
- package/registry.ts +18 -6
- package/types.ts +1 -1
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
|
|
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":
|
|
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` | `
|
|
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
|
-
|
|
127
|
+
npm test
|
|
111
128
|
|
|
112
129
|
# Run tests in watch mode
|
|
113
|
-
|
|
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: [
|
|
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 **
|
|
8
|
+
This project uses **Node.js** as its JavaScript runtime and **npm** as its package manager.
|
|
9
9
|
|
|
10
|
-
## Why
|
|
10
|
+
## Why npm?
|
|
11
11
|
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
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
|
-
|
|
21
|
+
npm install
|
|
22
22
|
|
|
23
23
|
# Run tests
|
|
24
|
-
|
|
24
|
+
npm test
|
|
25
25
|
|
|
26
|
-
# Run tests in watch mode
|
|
27
|
-
|
|
26
|
+
# Run tests in watch mode
|
|
27
|
+
npm run test:watch
|
|
28
28
|
```
|
|
29
29
|
|
|
30
30
|
## Version
|
|
31
31
|
|
|
32
|
-
This project requires
|
|
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,
|
|
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 `
|
|
57
|
+
- Runs `npm ci`
|
|
58
58
|
- Verifies tag matches `package.json` version
|
|
59
|
-
- Runs `
|
|
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:
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
pi.on("
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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": "
|
|
17
|
-
"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/
|
|
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": "
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
|
168
|
+
fileName,
|
|
157
169
|
});
|
|
158
170
|
}
|
|
159
171
|
|
package/types.ts
CHANGED