pi-doc-injector 0.1.4 → 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.
Files changed (3) hide show
  1. package/README.md +4 -1
  2. package/index.ts +60 -24
  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
 
package/index.ts CHANGED
@@ -69,6 +69,7 @@ export default async function docInjectorExtension(pi: ExtensionAPI) {
69
69
  let enabled = true;
70
70
  let textBuffer = "";
71
71
  let pendingMatches = new Map<string, string[]>(); // filePath → matchedKeywords
72
+ let abortingForInjection = false; // guard against cascading aborts
72
73
 
73
74
  // ---- Helpers ----
74
75
  const getRegistry = () => registry;
@@ -97,8 +98,16 @@ export default async function docInjectorExtension(pi: ExtensionAPI) {
97
98
  );
98
99
  };
99
100
 
101
+ let lastInitTime = 0;
102
+
100
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.
101
107
  pi.on("session_start", async (_event, ctx) => {
108
+ const now = Date.now();
109
+ if (now - lastInitTime < 100) return;
110
+ lastInitTime = now;
102
111
  await initRegistry(ctx.cwd);
103
112
  });
104
113
 
@@ -115,54 +124,65 @@ export default async function docInjectorExtension(pi: ExtensionAPI) {
115
124
  await reloadRegistry();
116
125
  });
117
126
 
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) => {
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) => {
124
150
  if (!enabled || !registry) return;
125
151
 
126
- // Only process assistant messages
127
152
  const msg = event.message;
128
153
  if (msg.role !== "assistant") return;
129
154
 
130
- // Replace buffer with full message text (message_update contains full content)
131
155
  const content = (msg as unknown as { content: unknown }).content;
132
156
  textBuffer = extractText(content);
133
157
  if (!textBuffer) return;
134
158
 
135
- // Run matcher
136
159
  const matcher = buildMatcher();
137
160
  if (!matcher) return;
138
161
 
139
162
  const results = matcher.match(textBuffer);
140
163
 
141
- // Store matches (dedup by filePath)
164
+ let hasNew = false;
142
165
  for (const result of results) {
166
+ if (!pendingMatches.has(result.entry.filePath)) {
167
+ hasNew = true;
168
+ }
143
169
  pendingMatches.set(result.entry.filePath, result.matchedKeywords);
144
170
  }
171
+
172
+ if (hasNew && !ctx.isIdle() && !abortingForInjection) {
173
+ abortingForInjection = true;
174
+ ctx.abort();
175
+ }
145
176
  });
146
177
 
147
178
  // ---- Event: message_end (finalize matches) ----
148
- 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) => {
149
182
  if (!enabled || !registry) return;
150
-
151
183
  const msg = event.message;
152
184
  if (msg.role !== "assistant") return;
153
-
154
- // Clear buffer
155
185
  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
186
  });
167
187
 
168
188
  // ---- Event: before_agent_start (inject into system prompt) ----
@@ -194,6 +214,12 @@ export default async function docInjectorExtension(pi: ExtensionAPI) {
194
214
 
195
215
  // Mark as injected only after confirming injection will happen
196
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
+
197
223
  pendingMatches.clear();
198
224
 
199
225
  return {
@@ -201,6 +227,16 @@ export default async function docInjectorExtension(pi: ExtensionAPI) {
201
227
  };
202
228
  });
203
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
+
204
240
  // ---- Commands ----
205
241
  registerCommands(pi, {
206
242
  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.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",