pi-doc-injector 0.2.1 → 0.3.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.
- package/cache.ts +79 -0
- package/commands.ts +68 -1
- package/config.ts +63 -28
- package/docs/async-subagent-bug.md +199 -0
- package/globber.ts +48 -0
- package/index.ts +151 -20
- package/injector.ts +18 -1
- package/keyword-gen.ts +142 -0
- package/keyword-llm.ts +57 -0
- package/matcher.ts +14 -10
- package/package.json +5 -1
- package/picomatch.d.ts +11 -0
- package/registry.ts +361 -72
- package/types.ts +62 -3
package/cache.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyword cache persistence — load/save the `.pi/doc-injector-cache.json` file.
|
|
3
|
+
*
|
|
4
|
+
* Cache format:
|
|
5
|
+
* { version: 1, files: { [relativePath]: { mtimeMs: number, keywords: string[] } } }
|
|
6
|
+
*
|
|
7
|
+
* Invalid files (wrong version, bad JSON, ENOENT) result in an empty cache.
|
|
8
|
+
*/
|
|
9
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import type { KeywordCache } from "./types";
|
|
12
|
+
|
|
13
|
+
const CACHE_FILENAME = ".pi/doc-injector-cache.json";
|
|
14
|
+
const CACHE_VERSION = 1;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Load the keyword cache from disk.
|
|
18
|
+
* Returns an empty cache (version 1, no files) if the file doesn't exist,
|
|
19
|
+
* has wrong version, or is corrupted.
|
|
20
|
+
*/
|
|
21
|
+
export async function loadCache(cwd: string): Promise<KeywordCache> {
|
|
22
|
+
const cachePath = join(cwd, CACHE_FILENAME);
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const raw = await readFile(cachePath, "utf-8");
|
|
26
|
+
const parsed: unknown = JSON.parse(raw);
|
|
27
|
+
|
|
28
|
+
if (!isValidCache(parsed)) {
|
|
29
|
+
console.warn(
|
|
30
|
+
`[doc-injector] Invalid cache format or version at ${cachePath}, resetting.`,
|
|
31
|
+
);
|
|
32
|
+
return emptyCache();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return parsed;
|
|
36
|
+
} catch (err) {
|
|
37
|
+
// ENOENT = no cache file yet, that's fine
|
|
38
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
39
|
+
console.warn(
|
|
40
|
+
`[doc-injector] Failed to read cache at ${cachePath}:`,
|
|
41
|
+
err instanceof Error ? err.message : String(err),
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
return emptyCache();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Save the keyword cache to disk.
|
|
50
|
+
* Creates parent directories if needed.
|
|
51
|
+
*/
|
|
52
|
+
export async function saveCache(
|
|
53
|
+
cwd: string,
|
|
54
|
+
cache: KeywordCache,
|
|
55
|
+
): Promise<void> {
|
|
56
|
+
const cachePath = join(cwd, CACHE_FILENAME);
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
await mkdir(dirname(cachePath), { recursive: true });
|
|
60
|
+
} catch {
|
|
61
|
+
// Ignore — directory may already exist
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
await writeFile(cachePath, JSON.stringify(cache, null, 2), "utf-8");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Check that a parsed value matches the KeywordCache shape. */
|
|
68
|
+
function isValidCache(value: unknown): value is KeywordCache {
|
|
69
|
+
if (!value || typeof value !== "object") return false;
|
|
70
|
+
const c = value as Record<string, unknown>;
|
|
71
|
+
if (c.version !== CACHE_VERSION) return false;
|
|
72
|
+
if (!c.files || typeof c.files !== "object") return false;
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Return a fresh empty cache. */
|
|
77
|
+
function emptyCache(): KeywordCache {
|
|
78
|
+
return { version: CACHE_VERSION, files: {} };
|
|
79
|
+
}
|
package/commands.ts
CHANGED
|
@@ -3,14 +3,26 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
5
5
|
import type { DocRegistry } from "./registry";
|
|
6
|
+
import type { DocInjectorConfig } from "./types";
|
|
6
7
|
|
|
8
|
+
/** Dependencies injected into the command registrar. */
|
|
7
9
|
export interface CommandDeps {
|
|
8
10
|
getRegistry: () => DocRegistry | null;
|
|
9
11
|
getEnabled: () => boolean;
|
|
10
12
|
setEnabled: (v: boolean) => void;
|
|
11
13
|
reloadRegistry: () => Promise<number>;
|
|
14
|
+
getConfig: () => DocInjectorConfig;
|
|
15
|
+
generateKeywordsLLM: (files: Array<{ path: string; snippet: string; existingKeywords: string[] }>) => Promise<void>;
|
|
12
16
|
}
|
|
13
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Register all doc-injector slash commands on the given ExtensionAPI.
|
|
20
|
+
*
|
|
21
|
+
* Commands:
|
|
22
|
+
* - `/doc-inject [on|off|toggle|list|reset|status]` — manage injection state
|
|
23
|
+
* - `/doc-reload` — re-scan docs folder
|
|
24
|
+
* - `/doc-keywords-gen [path]` — generate LLM keywords for keyword-less files
|
|
25
|
+
*/
|
|
14
26
|
export function registerCommands(pi: ExtensionAPI, deps: CommandDeps): void {
|
|
15
27
|
const cmd = (name: string, desc: string, handler: (args: string, ctx: ExtensionContext) => Promise<void>) => {
|
|
16
28
|
pi.registerCommand(name, { description: desc, handler });
|
|
@@ -49,7 +61,8 @@ export function registerCommands(pi: ExtensionAPI, deps: CommandDeps): void {
|
|
|
49
61
|
}
|
|
50
62
|
const lines = entries.map((e) => {
|
|
51
63
|
const status = e.injected ? "✅" : "⬜";
|
|
52
|
-
|
|
64
|
+
const sourceTag = `[${e.keywordSource}]`;
|
|
65
|
+
return `${status} ${sourceTag} ${e.relativePath}: "${e.title}" — keywords: [${e.keywords.join(", ")}]`;
|
|
53
66
|
});
|
|
54
67
|
ctx.ui.notify(`📄 Registered docs:\n${lines.join("\n")}`, "info");
|
|
55
68
|
} else {
|
|
@@ -81,4 +94,58 @@ export function registerCommands(pi: ExtensionAPI, deps: CommandDeps): void {
|
|
|
81
94
|
ctx.ui.notify(`📄 Reload failed: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
82
95
|
}
|
|
83
96
|
});
|
|
97
|
+
|
|
98
|
+
cmd("doc-keywords-gen", "Generate LLM keywords: /doc-keywords-gen [path] — no arg = all keyword-less files", async (args, ctx) => {
|
|
99
|
+
const reg = deps.getRegistry();
|
|
100
|
+
if (!reg) {
|
|
101
|
+
ctx.ui.notify("📄 No registry loaded", "warning");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const config = deps.getConfig();
|
|
106
|
+
if (!config.llmKeywords) {
|
|
107
|
+
ctx.ui.notify("📄 LLM keyword generation is disabled (llmKeywords: false in config)", "warning");
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const targetPath = args.trim();
|
|
112
|
+
|
|
113
|
+
// Filter to keyword-less entries (keywordSource !== "frontmatter", "cache", or "llm")
|
|
114
|
+
let candidates = reg.getEntries().filter((e) => {
|
|
115
|
+
if (e.keywordSource === "frontmatter") return false;
|
|
116
|
+
if (e.keywordSource === "cache") return false;
|
|
117
|
+
if (e.keywordSource === "llm") return false; // already LLM-generated
|
|
118
|
+
return true;
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
if (targetPath) {
|
|
122
|
+
candidates = candidates.filter((e) => e.relativePath.includes(targetPath));
|
|
123
|
+
if (candidates.length === 0) {
|
|
124
|
+
ctx.ui.notify(`📄 No keyword-less files matching "${targetPath}"`, "info");
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (candidates.length === 0) {
|
|
130
|
+
ctx.ui.notify("📄 All files already have keywords", "info");
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const batchSize = config.llmBatchSize;
|
|
135
|
+
const batches: Array<Array<{ path: string; snippet: string; existingKeywords: string[] }>> = [];
|
|
136
|
+
for (let i = 0; i < candidates.length; i += batchSize) {
|
|
137
|
+
const batch = candidates.slice(i, i + batchSize).map((e) => ({
|
|
138
|
+
path: e.relativePath,
|
|
139
|
+
snippet: e.content.slice(0, 500),
|
|
140
|
+
existingKeywords: e.keywords,
|
|
141
|
+
}));
|
|
142
|
+
batches.push(batch);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
ctx.ui.notify(`📄 Sending ${batches.length} keyword-generation batch(es) for ${candidates.length} file(s)...`, "info");
|
|
146
|
+
|
|
147
|
+
for (const batch of batches) {
|
|
148
|
+
await deps.generateKeywordsLLM(batch);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
84
151
|
}
|
package/config.ts
CHANGED
|
@@ -2,50 +2,85 @@
|
|
|
2
2
|
* Configuration loader for the Doc Injector extension.
|
|
3
3
|
* Reads from `.pi/doc-injector.json` with fallback to defaults.
|
|
4
4
|
*/
|
|
5
|
-
import {
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { DEFAULT_CONFIG, type DocInjectorConfig } from "./types";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* Clamp an integer value to [min, max] range.
|
|
11
|
+
* Warns and clamps if out of range. Returns the default if not a number.
|
|
12
12
|
*/
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
function clampInt(
|
|
14
|
+
value: unknown,
|
|
15
|
+
defaultVal: number,
|
|
16
|
+
min: number,
|
|
17
|
+
max: number,
|
|
18
|
+
fieldName: string,
|
|
19
|
+
): number {
|
|
20
|
+
if (typeof value !== "number" || Number.isNaN(value)) {
|
|
21
|
+
return defaultVal;
|
|
22
|
+
}
|
|
23
|
+
const intVal = Math.trunc(value);
|
|
24
|
+
if (intVal < min || intVal > max) {
|
|
25
|
+
const clamped = Math.max(min, Math.min(max, intVal));
|
|
26
|
+
console.warn(`[doc-injector] ${fieldName} must be ${min}-${max}, got ${intVal}. Clamping to ${clamped}.`);
|
|
27
|
+
return clamped;
|
|
28
|
+
}
|
|
29
|
+
return intVal;
|
|
30
|
+
}
|
|
15
31
|
|
|
16
|
-
|
|
17
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Validate a glob pattern array.
|
|
34
|
+
* Rejects non-array or entries that aren't strings. Returns default on error.
|
|
35
|
+
*/
|
|
36
|
+
function validateGlobArray(value: unknown, defaultVal: string[]): string[] {
|
|
37
|
+
if (!Array.isArray(value)) {
|
|
38
|
+
return [...defaultVal];
|
|
18
39
|
}
|
|
40
|
+
const result: string[] = [];
|
|
41
|
+
for (const item of value) {
|
|
42
|
+
if (typeof item === "string") {
|
|
43
|
+
result.push(item);
|
|
44
|
+
} else {
|
|
45
|
+
console.warn(`[doc-injector] Non-string entry in glob array ignored: ${String(item)}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return result.length > 0 ? result : [...defaultVal];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Load config from `.pi/doc-injector.json` relative to the given cwd.
|
|
53
|
+
* Now async — uses readFile from fs/promises.
|
|
54
|
+
* Validates and clamps all numeric fields. Falls back to DEFAULT_CONFIG
|
|
55
|
+
* if file doesn't exist or is invalid.
|
|
56
|
+
*/
|
|
57
|
+
export async function loadConfig(cwd: string): Promise<DocInjectorConfig> {
|
|
58
|
+
const configPath = join(cwd, ".pi", "doc-injector.json");
|
|
19
59
|
|
|
20
60
|
try {
|
|
21
|
-
const raw =
|
|
61
|
+
const raw = await readFile(configPath, "utf-8");
|
|
22
62
|
const parsed = JSON.parse(raw) as Partial<DocInjectorConfig>;
|
|
23
63
|
|
|
24
|
-
// Clamp contextThreshold to 0-100 range
|
|
25
|
-
let contextThreshold = parsed.contextThreshold ?? DEFAULT_CONFIG.contextThreshold;
|
|
26
|
-
if (typeof contextThreshold === "number" && (contextThreshold < 0 || contextThreshold > 100)) {
|
|
27
|
-
console.warn(`[doc-injector] contextThreshold must be 0-100, got ${contextThreshold}. Clamping.`);
|
|
28
|
-
contextThreshold = Math.max(0, Math.min(100, contextThreshold));
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// Clamp matchThreshold to positive integers
|
|
32
|
-
let matchThreshold = parsed.matchThreshold ?? DEFAULT_CONFIG.matchThreshold;
|
|
33
|
-
if (typeof matchThreshold === "number" && matchThreshold < 1) {
|
|
34
|
-
console.warn(`[doc-injector] matchThreshold must be >= 1, got ${matchThreshold}. Using 1.`);
|
|
35
|
-
matchThreshold = 1;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
64
|
return {
|
|
39
65
|
docsPath: parsed.docsPath ?? DEFAULT_CONFIG.docsPath,
|
|
40
|
-
matchThreshold,
|
|
41
|
-
contextThreshold,
|
|
66
|
+
matchThreshold: clampInt(parsed.matchThreshold, DEFAULT_CONFIG.matchThreshold, 1, Infinity, "matchThreshold"),
|
|
67
|
+
contextThreshold: clampInt(parsed.contextThreshold, DEFAULT_CONFIG.contextThreshold, 0, 100, "contextThreshold"),
|
|
42
68
|
recursive: parsed.recursive ?? DEFAULT_CONFIG.recursive,
|
|
69
|
+
include: validateGlobArray(parsed.include, DEFAULT_CONFIG.include),
|
|
70
|
+
exclude: validateGlobArray(parsed.exclude, DEFAULT_CONFIG.exclude),
|
|
71
|
+
maxFileSize: clampInt(parsed.maxFileSize, DEFAULT_CONFIG.maxFileSize, 1024, 10 * 1024 * 1024, "maxFileSize"),
|
|
72
|
+
autoKeywords: parsed.autoKeywords ?? DEFAULT_CONFIG.autoKeywords,
|
|
73
|
+
llmKeywords: parsed.llmKeywords ?? DEFAULT_CONFIG.llmKeywords,
|
|
74
|
+
maxConcurrent: clampInt(parsed.maxConcurrent, DEFAULT_CONFIG.maxConcurrent, 1, 100, "maxConcurrent"),
|
|
75
|
+
llmBatchSize: clampInt(parsed.llmBatchSize, DEFAULT_CONFIG.llmBatchSize, 1, 100, "llmBatchSize"),
|
|
43
76
|
};
|
|
44
77
|
} catch (err) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
78
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
79
|
+
console.warn(
|
|
80
|
+
`[doc-injector] Failed to parse config at ${configPath}:`,
|
|
81
|
+
err instanceof Error ? err.message : String(err),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
49
84
|
return { ...DEFAULT_CONFIG };
|
|
50
85
|
}
|
|
51
86
|
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# Doc-Injector & Async Subagents Bug Analysis
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
The doc-injector extension causes spinner hangs when used alongside async subagent workflows (e.g., `subagent_isolated`, `subagent_with_context` with `async: true`).
|
|
6
|
+
|
|
7
|
+
## Root Cause
|
|
8
|
+
|
|
9
|
+
### The Flow
|
|
10
|
+
|
|
11
|
+
1. Parent spawns async subagents with `async: true, notifyOnComplete: "inject"`
|
|
12
|
+
2. Parent streams output (e.g., "I've launched 5 agents...")
|
|
13
|
+
3. Doc-injector's `message_update` fires, detects keyword match
|
|
14
|
+
4. Doc-injector calls `ctx.abort()` to abort current turn
|
|
15
|
+
5. `agent_end` fires → extension injects `"continue"` via `setTimeout()`
|
|
16
|
+
6. Parent receives `"continue"` → immediately calls `get_subagent_result x5`
|
|
17
|
+
7. Subagents still running → results not available → **spinner stuck**
|
|
18
|
+
|
|
19
|
+
### Why This Happens
|
|
20
|
+
|
|
21
|
+
The current doc-injector design assumes it can abort the current turn and restart immediately with injected docs. This works for simple single-turn workflows but breaks async subagent workflows because:
|
|
22
|
+
|
|
23
|
+
1. **The injected `"continue"` disrupts the expected flow** - parent should wait for `notifyOnComplete: "inject"` messages, not immediately poll for results
|
|
24
|
+
2. **`get_subagent_result` blocks** - when called before subagents finish, it hangs the parent thread
|
|
25
|
+
3. **The abort signal is shared** - in some subagent implementations (e.g., the `subagent` tool in pi-subagentura), async subagents explicitly don't inherit the parent abort signal. But the injected `"continue"` still breaks the flow
|
|
26
|
+
|
|
27
|
+
### Key Code Path (doc-injector/index.ts)
|
|
28
|
+
|
|
29
|
+
```typescript
|
|
30
|
+
// message_update handler - triggers abort on keyword match
|
|
31
|
+
pi.on("message_update", async (event, ctx) => {
|
|
32
|
+
if (hasNew && !ctx.isIdle() && !abortingForInjection) {
|
|
33
|
+
abortingForInjection = true;
|
|
34
|
+
ctx.abort(); // ← PROBLEM: aborts current turn
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// agent_end handler - injects "continue" after abort
|
|
39
|
+
pi.on("agent_end", async (event, ctx) => {
|
|
40
|
+
if (abortingForInjection) {
|
|
41
|
+
abortingForInjection = false;
|
|
42
|
+
setTimeout(() => {
|
|
43
|
+
pi.sendUserMessage("continue"); // ← PROBLEM: disrupts async flow
|
|
44
|
+
}, 0);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Subagentura System Context
|
|
50
|
+
|
|
51
|
+
### How Async Subagents Work
|
|
52
|
+
|
|
53
|
+
The subagentura system (`/Users/applesucks/dev/pi-agents/subagent.ts`) spawns async subagents that:
|
|
54
|
+
|
|
55
|
+
1. Run in the same process (not subprocess)
|
|
56
|
+
2. Have independent session/context
|
|
57
|
+
3. Signal via `notifyOnComplete: "inject"` - results injected as user messages when complete
|
|
58
|
+
4. Use `get_subagent_result` to poll/block for results
|
|
59
|
+
|
|
60
|
+
### Key Line (subagent.ts line 541, 744)
|
|
61
|
+
|
|
62
|
+
```typescript
|
|
63
|
+
signal: undefined, // async: don't inherit parent signal (would abort subagent when tool returns)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Option A: Remove ctx.abort() Entirely (Recommended)
|
|
67
|
+
|
|
68
|
+
**Change:** Never call `ctx.abort()` in `message_update`. Just record matches and inject on next turn.
|
|
69
|
+
|
|
70
|
+
**Pros:**
|
|
71
|
+
- ✅ Simplest fix
|
|
72
|
+
- ✅ Works with ANY async workflow automatically
|
|
73
|
+
- ✅ No special-casing for subagents
|
|
74
|
+
|
|
75
|
+
**Cons:**
|
|
76
|
+
- ❌ Loses "immediate injection during streaming" optimization
|
|
77
|
+
- ❌ Docs injected on next turn instead of immediately
|
|
78
|
+
|
|
79
|
+
**Implementation:**
|
|
80
|
+
```typescript
|
|
81
|
+
pi.on("message_update", async (event, ctx) => {
|
|
82
|
+
if (keywordGenInFlight) return;
|
|
83
|
+
if (!enabled || !registry) return;
|
|
84
|
+
if (msg.role !== "assistant") return;
|
|
85
|
+
|
|
86
|
+
const content = (msg as unknown as { content: unknown }).content;
|
|
87
|
+
textBuffer = extractText(content);
|
|
88
|
+
if (!textBuffer) return;
|
|
89
|
+
|
|
90
|
+
const matcher = buildMatcher();
|
|
91
|
+
if (!matcher) return;
|
|
92
|
+
|
|
93
|
+
const results = matcher.match(textBuffer);
|
|
94
|
+
for (const result of results) {
|
|
95
|
+
if (!pendingMatches.has(result.entry.filePath)) {
|
|
96
|
+
hasNew = true; // Just record - don't abort
|
|
97
|
+
}
|
|
98
|
+
pendingMatches.set(result.entry.filePath, result.matchedKeywords);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// REMOVE: ctx.abort() entirely
|
|
102
|
+
// Just let the turn complete naturally
|
|
103
|
+
});
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Option B: Detect Async Subagents Before Aborting
|
|
107
|
+
|
|
108
|
+
**Change:** Check if assistant just spawned async subagents, skip abort if so.
|
|
109
|
+
|
|
110
|
+
**Pros:**
|
|
111
|
+
- ✅ Preserves immediate injection behavior
|
|
112
|
+
- ✅ Subagents continue running
|
|
113
|
+
|
|
114
|
+
**Cons:**
|
|
115
|
+
- ❌ Fragile - depends on detecting specific tool call patterns
|
|
116
|
+
- ❌ Doesn't scale to other async workflows
|
|
117
|
+
|
|
118
|
+
### Option C: Only Inject on User Input
|
|
119
|
+
|
|
120
|
+
**Change:** Only trigger immediate injection on `input` event (user messages), not on assistant streaming.
|
|
121
|
+
|
|
122
|
+
**Pros:**
|
|
123
|
+
- ✅ Clean separation of concerns
|
|
124
|
+
- ✅ User intent gets immediate response
|
|
125
|
+
|
|
126
|
+
**Cons:**
|
|
127
|
+
- ❌ Loses streaming injection optimization
|
|
128
|
+
- ❌ Still may not handle all async edge cases
|
|
129
|
+
|
|
130
|
+
## Recommendation
|
|
131
|
+
|
|
132
|
+
**Use Option A:** Remove `ctx.abort()` entirely.
|
|
133
|
+
|
|
134
|
+
The streaming injection optimization is not worth the complexity and the risk of breaking async workflows. Most keyword matches from assistant streaming are not time-critical - they can wait for the next turn.
|
|
135
|
+
|
|
136
|
+
## Additional Notes
|
|
137
|
+
|
|
138
|
+
### The notifyOnComplete:"inject" Flow
|
|
139
|
+
|
|
140
|
+
1. Parent spawns `async: true, notifyOnComplete: "inject"`
|
|
141
|
+
2. Parent continues immediately (doesn't wait)
|
|
142
|
+
3. Each subagent runs independently
|
|
143
|
+
4. When subagent completes, result is injected as user message via `pi.sendUserMessage()`
|
|
144
|
+
5. Parent receives the injected result and continues naturally
|
|
145
|
+
|
|
146
|
+
This flow is designed to work without any intervention from the parent. Breaking it with `ctx.abort()` + `"continue"` defeats the purpose.
|
|
147
|
+
|
|
148
|
+
### Session Context
|
|
149
|
+
|
|
150
|
+
Each subagent runs in its **own session** with its own doc-injector instance. Doc injection in subagent sessions is fine - they inject their own docs into their own context. The problem is only in the **parent session**.
|
|
151
|
+
|
|
152
|
+
## References
|
|
153
|
+
|
|
154
|
+
- doc-injector: `/Users/applesucks/dev/pi-docs/index.ts`
|
|
155
|
+
- subagentura: `/Users/applesucks/dev/pi-agents/subagent.ts`
|
|
156
|
+
- subagentura helpers: `/Users/applesucks/dev/pi-agents/helpers.ts`
|
|
157
|
+
|
|
158
|
+
## Web Research Summary
|
|
159
|
+
|
|
160
|
+
### Official Documentation
|
|
161
|
+
|
|
162
|
+
From `pi.dev/docs/latest/extensions`, the key lifecycle events are:
|
|
163
|
+
|
|
164
|
+
- `before_agent_start` - Fires after user submits prompt, **before** agent loop. Can inject a message and/or modify system prompt.
|
|
165
|
+
- `agent_start` / `agent_end` - Fires once per user prompt
|
|
166
|
+
- `message_update` - Fires during streaming output
|
|
167
|
+
- `input` - Fires on user input
|
|
168
|
+
|
|
169
|
+
**Important note from docs:** "For critical events (before_agent_start, context, tool_call), if all handlers fail, the system continues with default behavior."
|
|
170
|
+
|
|
171
|
+
### Key Extension API Points
|
|
172
|
+
|
|
173
|
+
1. **`ctx.abort()`** - "Request a graceful shutdown of pi. Interactive mode: Deferred until the agent becomes idle (after processing all queued steering and follow-up messages)."
|
|
174
|
+
2. **`ctx.signal`** - Available for extensions to forward cancellation into nested model calls, fetch(), and other abort-aware work.
|
|
175
|
+
3. **`before_agent_start`** - The recommended injection point for context, as it runs before the agent loop starts.
|
|
176
|
+
|
|
177
|
+
### Related GitHub Issues
|
|
178
|
+
|
|
179
|
+
- [Issue #624](https://github.com/earendil-works/pi/issues/624): "before_agent_start event does not properly inject message" - indicates there have been historical issues with injection timing
|
|
180
|
+
- [Issue #2660](https://github.com/earendil-works/pi/issues/2660): "Expose abort signal on ExtensionContext" - led to `ctx.signal` being added
|
|
181
|
+
|
|
182
|
+
### Subagent Extensions Found
|
|
183
|
+
|
|
184
|
+
1. **tintinweb/pi-subagents** (`pi.dev/packages/pi-subagents`)
|
|
185
|
+
- Claude Code-style autonomous subagents
|
|
186
|
+
- Spawns agents in isolated sessions
|
|
187
|
+
- Run in foreground or background
|
|
188
|
+
- Similar to subagentura
|
|
189
|
+
|
|
190
|
+
2. **nicobailon/pi-subagents** (GitHub)
|
|
191
|
+
- Async subagent delegation with truncation, artifacts, session sharing
|
|
192
|
+
|
|
193
|
+
### Conclusion from Web Research
|
|
194
|
+
|
|
195
|
+
No documented solutions exist for the specific conflict between streaming injection (via `ctx.abort()`) and async subagent workflows. The recommended approach based on official docs is:
|
|
196
|
+
|
|
197
|
+
1. Use `before_agent_start` for context injection (runs before agent loop)
|
|
198
|
+
2. Avoid `ctx.abort()` during streaming - it disrupts any workflow that expects continuous streaming
|
|
199
|
+
3. The `input` event is the cleanest injection point for user-driven context since it represents new user intent
|
package/globber.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Glob filter for include/exclude pattern matching.
|
|
3
|
+
* Uses picomatch (0 deps, ~18 KB) to compile patterns once for O(1) matching.
|
|
4
|
+
*/
|
|
5
|
+
import picomatch from "picomatch";
|
|
6
|
+
import type { GlobFilter } from "./types";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Create a glob filter from include and exclude patterns.
|
|
10
|
+
*
|
|
11
|
+
* A path matches if it matches at least one `include` pattern AND
|
|
12
|
+
* does not match any `exclude` pattern.
|
|
13
|
+
*
|
|
14
|
+
* When `include` is empty, all files are considered included
|
|
15
|
+
* (subject to exclude filtering).
|
|
16
|
+
*
|
|
17
|
+
* @param include - Glob patterns for files to include
|
|
18
|
+
* @param exclude - Glob patterns for files/dirs to exclude
|
|
19
|
+
* @returns A GlobFilter with a `match` method
|
|
20
|
+
*/
|
|
21
|
+
export function createGlobFilter(
|
|
22
|
+
include: string[],
|
|
23
|
+
exclude: string[],
|
|
24
|
+
): GlobFilter {
|
|
25
|
+
const includeMatcher =
|
|
26
|
+
include.length > 0
|
|
27
|
+
? picomatch(include, { dot: true })
|
|
28
|
+
: null;
|
|
29
|
+
|
|
30
|
+
const excludeMatcher =
|
|
31
|
+
exclude.length > 0
|
|
32
|
+
? picomatch(exclude, { dot: true })
|
|
33
|
+
: null;
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
match(relativePath: string): boolean {
|
|
37
|
+
// If include patterns are specified, path must match at least one
|
|
38
|
+
if (includeMatcher && !includeMatcher(relativePath)) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
// Path must not match any exclude pattern
|
|
42
|
+
if (excludeMatcher && excludeMatcher(relativePath)) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
return true;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|