pi-doc-injector 0.1.4 → 0.2.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.
Files changed (3) hide show
  1. package/README.md +12 -2
  2. package/index.ts +70 -26
  3. package/package.json +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 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
 
@@ -98,11 +101,18 @@ Injection is also skipped if the current context usage exceeds 80% of the token
98
101
 
99
102
  The extension uses a per-session injection model:
100
103
 
101
- - On `session_start`, the registry is rebuilt from scratch, resetting all `injected` flags.
104
+ - On `session_start`, the registry scans `docs/` and indexes all valid documents.
102
105
  - Within a session, once a document is injected, it won't be re-injected automatically.
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
+ ### Injection Timing
110
+
111
+ - **User messages**: matched via the `input` event, injected before the assistant
112
+ responds — **same turn**, no delay.
113
+ - **Assistant streaming**: if the assistant mentions a NEW keyword mid-response,
114
+ generation is aborted and restarted with the doc injected immediately.
115
+
106
116
  ### System Prompt Lifecycle
107
117
 
108
118
  Pi **reconstructs the system prompt from source files each turn** (verified against pi v0.70.6).
package/index.ts CHANGED
@@ -66,9 +66,11 @@ export default async function docInjectorExtension(pi: ExtensionAPI) {
66
66
  // ---- State ----
67
67
  let config = loadConfig(process.cwd());
68
68
  let registry: DocRegistry | null = null;
69
+ let initRegistryPromise: Promise<void> | null = null;
69
70
  let enabled = true;
70
71
  let textBuffer = "";
71
72
  let pendingMatches = new Map<string, string[]>(); // filePath → matchedKeywords
73
+ let abortingForInjection = false; // guard against cascading aborts
72
74
 
73
75
  // ---- Helpers ----
74
76
  const getRegistry = () => registry;
@@ -98,8 +100,23 @@ export default async function docInjectorExtension(pi: ExtensionAPI) {
98
100
  };
99
101
 
100
102
  // ---- Event: session_start ----
101
- pi.on("session_start", async (_event, ctx) => {
102
- await initRegistry(ctx.cwd);
103
+ // Pi emits session_start for startup, reload, and real session transitions.
104
+ // Skip the reload variant because resources_discover will rebuild docs right
105
+ // after it, and deduplicate any overlapping non-reload inits.
106
+ pi.on("session_start", async (event, ctx) => {
107
+ if (event.reason === "reload") return;
108
+
109
+ if (initRegistryPromise) {
110
+ await initRegistryPromise;
111
+ return;
112
+ }
113
+
114
+ initRegistryPromise = initRegistry(ctx.cwd);
115
+ try {
116
+ await initRegistryPromise;
117
+ } finally {
118
+ initRegistryPromise = null;
119
+ }
103
120
  });
104
121
 
105
122
  const reloadRegistry = async (): Promise<number> => {
@@ -115,54 +132,65 @@ export default async function docInjectorExtension(pi: ExtensionAPI) {
115
132
  await reloadRegistry();
116
133
  });
117
134
 
118
- // ---- Event: message_update (streaming detection) ----
119
- // NOTE: Pi's message_update event sends the full accumulated content of the
120
- // assistant message on each update, not just the delta. We therefore REPLACE
121
- // (not append to) the text buffer on each event, ensuring we always match
122
- // against the complete message text.
123
- pi.on("message_update", async (event, _ctx) => {
135
+ // ---- Event: input (user message matching) ----
136
+ // message_update only fires for assistant streaming messages, not user
137
+ // messages. We use the input event instead to populate pendingMatches
138
+ // BEFORE before_agent_start fires, so docs are injected in time for
139
+ // the assistant's immediate response.
140
+ pi.on("input", async (event, _ctx) => {
141
+ if (!enabled || !registry) return;
142
+ if (!event.text) return;
143
+
144
+ const matcher = buildMatcher();
145
+ if (!matcher) return;
146
+
147
+ const results = matcher.match(event.text);
148
+ for (const result of results) {
149
+ pendingMatches.set(result.entry.filePath, result.matchedKeywords);
150
+ }
151
+ });
152
+
153
+ // ---- Event: message_update (assistant streaming) ----
154
+ // For assistant streaming messages: if we detect NEW keyword matches for
155
+ // non-injected docs, abort the current generation and restart with the
156
+ // injected context — no waiting for the next turn.
157
+ pi.on("message_update", async (event, ctx) => {
124
158
  if (!enabled || !registry) return;
125
159
 
126
- // Only process assistant messages
127
160
  const msg = event.message;
128
161
  if (msg.role !== "assistant") return;
129
162
 
130
- // Replace buffer with full message text (message_update contains full content)
131
163
  const content = (msg as unknown as { content: unknown }).content;
132
164
  textBuffer = extractText(content);
133
165
  if (!textBuffer) return;
134
166
 
135
- // Run matcher
136
167
  const matcher = buildMatcher();
137
168
  if (!matcher) return;
138
169
 
139
170
  const results = matcher.match(textBuffer);
140
171
 
141
- // Store matches (dedup by filePath)
172
+ let hasNew = false;
142
173
  for (const result of results) {
174
+ if (!pendingMatches.has(result.entry.filePath)) {
175
+ hasNew = true;
176
+ }
143
177
  pendingMatches.set(result.entry.filePath, result.matchedKeywords);
144
178
  }
179
+
180
+ if (hasNew && !ctx.isIdle() && !abortingForInjection) {
181
+ abortingForInjection = true;
182
+ ctx.abort();
183
+ }
145
184
  });
146
185
 
147
186
  // ---- Event: message_end (finalize matches) ----
148
- pi.on("message_end", async (event, ctx) => {
187
+ // Notification moved to before_agent_start so it fires for both user-triggered
188
+ // and auto-abort-triggered injections. message_end now just resets state.
189
+ pi.on("message_end", async (event, _ctx) => {
149
190
  if (!enabled || !registry) return;
150
-
151
191
  const msg = event.message;
152
192
  if (msg.role !== "assistant") return;
153
-
154
- // Clear buffer
155
193
  textBuffer = "";
156
-
157
- // Notify user about pending injections
158
- if (pendingMatches.size > 0) {
159
- const matchedEntries: DocEntry[] = [];
160
- for (const [filePath] of pendingMatches) {
161
- const entry = registry.getEntries().find((e) => e.filePath === filePath);
162
- if (entry) matchedEntries.push(entry);
163
- }
164
- notifyInjection(ctx.ui, matchedEntries, pendingMatches);
165
- }
166
194
  });
167
195
 
168
196
  // ---- Event: before_agent_start (inject into system prompt) ----
@@ -194,6 +222,12 @@ export default async function docInjectorExtension(pi: ExtensionAPI) {
194
222
 
195
223
  // Mark as injected only after confirming injection will happen
196
224
  registry.markInjected(matchedEntries.map((e) => e.filePath));
225
+
226
+ // Notify user about injection (moved here from message_end so it fires
227
+ // even when matches come from user messages, which get cleared before
228
+ // the assistant's message_end)
229
+ notifyInjection(ctx.ui, matchedEntries, pendingMatches);
230
+
197
231
  pendingMatches.clear();
198
232
 
199
233
  return {
@@ -201,6 +235,16 @@ export default async function docInjectorExtension(pi: ExtensionAPI) {
201
235
  };
202
236
  });
203
237
 
238
+ // ---- Event: agent_end (restart after auto-abort) ----
239
+ pi.on("agent_end", async () => {
240
+ if (abortingForInjection) {
241
+ abortingForInjection = false;
242
+ // Send a follow-up message to restart the turn.
243
+ // before_agent_start will inject the matched docs into context.
244
+ pi.sendUserMessage("continue", { deliverAs: "followUp" });
245
+ }
246
+ });
247
+
204
248
  // ---- Commands ----
205
249
  registerCommands(pi, {
206
250
  getRegistry,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-doc-injector",
3
- "version": "0.1.4",
3
+ "version": "0.2.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",