@mariozechner/pi-coding-agent 0.27.8 → 0.28.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/CHANGELOG.md +28 -0
- package/README.md +16 -17
- package/dist/cli/list-models.d.ts +2 -1
- package/dist/cli/list-models.d.ts.map +1 -1
- package/dist/cli/list-models.js +2 -7
- package/dist/cli/list-models.js.map +1 -1
- package/dist/config.d.ts +2 -2
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +3 -3
- package/dist/config.js.map +1 -1
- package/dist/core/agent-session.d.ts +6 -3
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +18 -20
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/auth-storage.d.ts +104 -0
- package/dist/core/auth-storage.d.ts.map +1 -0
- package/dist/core/auth-storage.js +232 -0
- package/dist/core/auth-storage.js.map +1 -0
- package/dist/core/model-registry.d.ts +50 -0
- package/dist/core/model-registry.d.ts.map +1 -0
- package/dist/core/model-registry.js +268 -0
- package/dist/core/model-registry.js.map +1 -0
- package/dist/core/model-resolver.d.ts +7 -4
- package/dist/core/model-resolver.d.ts.map +1 -1
- package/dist/core/model-resolver.js +12 -41
- package/dist/core/model-resolver.js.map +1 -1
- package/dist/core/sdk.d.ts +13 -26
- package/dist/core/sdk.d.ts.map +1 -1
- package/dist/core/sdk.js +24 -101
- package/dist/core/sdk.js.map +1 -1
- package/dist/core/settings-manager.d.ts +0 -5
- package/dist/core/settings-manager.d.ts.map +1 -1
- package/dist/core/settings-manager.js +0 -19
- package/dist/core/settings-manager.js.map +1 -1
- package/dist/core/skills.d.ts.map +1 -1
- package/dist/core/skills.js +15 -1
- package/dist/core/skills.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -8
- package/dist/index.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +37 -21
- package/dist/main.js.map +1 -1
- package/dist/modes/interactive/components/footer.d.ts +3 -1
- package/dist/modes/interactive/components/footer.d.ts.map +1 -1
- package/dist/modes/interactive/components/footer.js +4 -3
- package/dist/modes/interactive/components/footer.js.map +1 -1
- package/dist/modes/interactive/components/model-selector.d.ts +3 -1
- package/dist/modes/interactive/components/model-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/model-selector.js +21 -13
- package/dist/modes/interactive/components/model-selector.js.map +1 -1
- package/dist/modes/interactive/components/oauth-selector.d.ts +3 -1
- package/dist/modes/interactive/components/oauth-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/oauth-selector.js +6 -6
- package/dist/modes/interactive/components/oauth-selector.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +53 -48
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/docs/hooks-v2.md +291 -220
- package/docs/sdk.md +86 -61
- package/examples/custom-tools/hello/index.ts +15 -15
- package/examples/custom-tools/question/index.ts +3 -3
- package/examples/custom-tools/subagent/agents.ts +1 -2
- package/examples/custom-tools/subagent/index.ts +332 -125
- package/examples/custom-tools/todo/index.ts +30 -12
- package/examples/hooks/confirm-destructive.ts +5 -7
- package/examples/hooks/custom-compaction.ts +7 -7
- package/examples/hooks/dirty-repo-guard.ts +5 -9
- package/examples/hooks/permission-gate.ts +1 -5
- package/examples/sdk/02-custom-model.ts +20 -7
- package/examples/sdk/04-skills.ts +1 -1
- package/examples/sdk/05-tools.ts +11 -14
- package/examples/sdk/06-hooks.ts +1 -1
- package/examples/sdk/07-context-files.ts +1 -1
- package/examples/sdk/08-slash-commands.ts +3 -3
- package/examples/sdk/09-api-keys-and-oauth.ts +36 -26
- package/examples/sdk/10-settings.ts +2 -2
- package/examples/sdk/12-full-control.ts +19 -20
- package/examples/sdk/README.md +26 -13
- package/package.json +4 -5
- package/dist/core/model-config.d.ts +0 -54
- package/dist/core/model-config.d.ts.map +0 -1
- package/dist/core/model-config.js +0 -376
- package/dist/core/model-config.js.map +0 -1
- package/dist/core/oauth/index.d.ts +0 -41
- package/dist/core/oauth/index.d.ts.map +0 -1
- package/dist/core/oauth/index.js +0 -84
- package/dist/core/oauth/index.js.map +0 -1
package/docs/hooks-v2.md
CHANGED
|
@@ -1,314 +1,385 @@
|
|
|
1
|
-
# Hooks v2:
|
|
1
|
+
# Hooks v2: Context Control + Commands
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Issue: #289
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Motivation
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
2. Hooks can save custom session entries
|
|
9
|
-
3. Hooks can transform context before it goes to LLM
|
|
10
|
-
4. All handlers get unified baseline access to state
|
|
7
|
+
Enable features like session stacking (`/pop`) as hooks, not core code. Core provides primitives, hooks implement features.
|
|
11
8
|
|
|
12
|
-
|
|
9
|
+
## Primitives
|
|
13
10
|
|
|
14
|
-
|
|
11
|
+
| Primitive | Purpose |
|
|
12
|
+
|-----------|---------|
|
|
13
|
+
| `ctx.saveEntry({type, ...})` | Persist custom entry to session |
|
|
14
|
+
| `pi.on("context", handler)` | Transform messages before LLM |
|
|
15
|
+
| `ctx.rebuildContext()` | Trigger context rebuild |
|
|
16
|
+
| `pi.command(name, opts)` | Register slash command |
|
|
15
17
|
|
|
16
|
-
|
|
18
|
+
## Extended HookEventContext
|
|
17
19
|
|
|
18
20
|
```typescript
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
return; // do nothing
|
|
28
|
-
}
|
|
29
|
-
});
|
|
30
|
-
```
|
|
21
|
+
interface HookEventContext {
|
|
22
|
+
// Existing
|
|
23
|
+
exec, ui, hasUI, cwd, sessionFile
|
|
24
|
+
|
|
25
|
+
// State (read-only)
|
|
26
|
+
model: Model<any> | null;
|
|
27
|
+
thinkingLevel: ThinkingLevel;
|
|
28
|
+
entries: readonly SessionEntry[];
|
|
31
29
|
|
|
32
|
-
|
|
30
|
+
// Utilities
|
|
31
|
+
findModel(provider: string, id: string): Model<any> | null;
|
|
32
|
+
availableModels(): Promise<Model<any>[]>;
|
|
33
|
+
resolveApiKey(model: Model<any>): Promise<string | undefined>;
|
|
33
34
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
35
|
+
// Mutation
|
|
36
|
+
saveEntry(entry: { type: string; [k: string]: unknown }): Promise<void>;
|
|
37
|
+
rebuildContext(): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface ContextMessage {
|
|
41
|
+
message: AppMessage;
|
|
42
|
+
entryIndex: number | null; // null = synthetic
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface ContextEvent {
|
|
46
|
+
type: "context";
|
|
47
|
+
entries: readonly SessionEntry[];
|
|
48
|
+
messages: ContextMessage[];
|
|
49
|
+
}
|
|
42
50
|
```
|
|
43
51
|
|
|
44
|
-
|
|
52
|
+
Commands also get: `args`, `argsRaw`, `signal`, `setModel()`, `setThinkingLevel()`.
|
|
53
|
+
|
|
54
|
+
## Stacking: Design
|
|
55
|
+
|
|
56
|
+
### Entry Format
|
|
45
57
|
|
|
46
58
|
```typescript
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
});
|
|
59
|
+
interface StackPopEntry {
|
|
60
|
+
type: "stack_pop";
|
|
61
|
+
backToIndex: number;
|
|
62
|
+
summary: string;
|
|
63
|
+
prePopSummary?: string; // when crossing compaction
|
|
64
|
+
timestamp: number;
|
|
65
|
+
}
|
|
55
66
|
```
|
|
56
67
|
|
|
57
|
-
|
|
68
|
+
### Crossing Compaction
|
|
58
69
|
|
|
59
|
-
|
|
70
|
+
Entries are never deleted. Raw data always available.
|
|
71
|
+
|
|
72
|
+
When `backToIndex < compaction.firstKeptEntryIndex`:
|
|
73
|
+
1. Read raw entries `[0, backToIndex)` → summarize → `prePopSummary`
|
|
74
|
+
2. Read raw entries `[backToIndex, now)` → summarize → `summary`
|
|
75
|
+
|
|
76
|
+
### Context Algorithm: Later Wins
|
|
77
|
+
|
|
78
|
+
Assign sequential IDs to ranges. On overlap, highest ID wins.
|
|
60
79
|
|
|
61
|
-
```typescript
|
|
62
|
-
// Force context rebuild (after saving entries)
|
|
63
|
-
await ctx.rebuildContext();
|
|
64
80
|
```
|
|
81
|
+
Compaction at 40: range [0, 30) id=0
|
|
82
|
+
StackPop at 50, backTo=20, prePopSummary: ranges [0, 20) id=1, [20, 50) id=2
|
|
65
83
|
|
|
66
|
-
|
|
84
|
+
Index 0-19: id=0 and id=1 cover → id=1 wins (prePopSummary)
|
|
85
|
+
Index 20-29: id=0 and id=2 cover → id=2 wins (popSummary)
|
|
86
|
+
Index 30-49: id=2 covers → id=2 (already emitted at 20)
|
|
87
|
+
Index 50+: no coverage → include as messages
|
|
88
|
+
```
|
|
67
89
|
|
|
68
|
-
|
|
90
|
+
## Complex Scenario Trace
|
|
69
91
|
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
entries: readonly SessionEntry[];
|
|
83
|
-
messages: readonly AppMessage[];
|
|
92
|
+
```
|
|
93
|
+
Initial: [msg1, msg2, msg3, msg4, msg5]
|
|
94
|
+
idx: 1, 2, 3, 4, 5
|
|
95
|
+
|
|
96
|
+
Compaction triggers:
|
|
97
|
+
[msg1-5, compaction{firstKept:4, summary:C1}]
|
|
98
|
+
idx: 1-5, 6
|
|
99
|
+
Context: [C1, msg4, msg5]
|
|
100
|
+
|
|
101
|
+
User continues:
|
|
102
|
+
[..., compaction, msg4, msg5, msg6, msg7]
|
|
103
|
+
idx: 6, 4*, 5*, 7, 8 (* kept from before)
|
|
84
104
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
105
|
+
User does /pop to msg2 (index 2):
|
|
106
|
+
- backTo=2 < firstKept=4 → crossing!
|
|
107
|
+
- prePopSummary: summarize raw [0,2) → P1
|
|
108
|
+
- summary: summarize raw [2,8) → S1
|
|
109
|
+
- save: stack_pop{backTo:2, summary:S1, prePopSummary:P1} at index 9
|
|
110
|
+
|
|
111
|
+
Ranges:
|
|
112
|
+
compaction [0,4) id=0
|
|
113
|
+
prePopSummary [0,2) id=1
|
|
114
|
+
popSummary [2,9) id=2
|
|
115
|
+
|
|
116
|
+
Context build:
|
|
117
|
+
idx 0: covered by id=0,1 → id=1 wins, emit P1
|
|
118
|
+
idx 1: covered by id=0,1 → id=1 (already emitted)
|
|
119
|
+
idx 2: covered by id=0,2 → id=2 wins, emit S1
|
|
120
|
+
idx 3-8: covered by id=0 or id=2 → id=2 (already emitted)
|
|
121
|
+
idx 9: stack_pop entry, skip
|
|
122
|
+
idx 10+: not covered, include as messages
|
|
123
|
+
|
|
124
|
+
Result: [P1, S1, msg10+]
|
|
125
|
+
|
|
126
|
+
User continues, another compaction:
|
|
127
|
+
[..., stack_pop, msg10, msg11, msg12, compaction{firstKept:11, summary:C2}]
|
|
128
|
+
idx: 9, 10, 11, 12, 13
|
|
129
|
+
|
|
130
|
+
Ranges:
|
|
131
|
+
compaction@6 [0,4) id=0
|
|
132
|
+
prePopSummary [0,2) id=1
|
|
133
|
+
popSummary [2,9) id=2
|
|
134
|
+
compaction@13 [0,11) id=3 ← this now covers previous ranges!
|
|
135
|
+
|
|
136
|
+
Context build:
|
|
137
|
+
idx 0-10: covered by multiple, id=3 wins → emit C2 at idx 0
|
|
138
|
+
idx 11+: include as messages
|
|
139
|
+
|
|
140
|
+
Result: [C2, msg11, msg12]
|
|
89
141
|
|
|
90
|
-
|
|
91
|
-
saveEntry(entry: { type: string; [k: string]: unknown }): Promise<void>;
|
|
92
|
-
rebuildContext(): Promise<void>;
|
|
93
|
-
}
|
|
142
|
+
C2's summary text includes info from P1 and S1 (they were in context when C2 was generated).
|
|
94
143
|
```
|
|
95
144
|
|
|
96
|
-
|
|
97
|
-
- `args: string[]`, `argsRaw: string`
|
|
98
|
-
- `setModel()`, `setThinkingLevel()` (state mutation)
|
|
145
|
+
The "later wins" rule naturally handles all cases.
|
|
99
146
|
|
|
100
|
-
##
|
|
147
|
+
## Core Changes
|
|
148
|
+
|
|
149
|
+
| File | Change |
|
|
150
|
+
|------|--------|
|
|
151
|
+
| `session-manager.ts` | `saveEntry()`, `buildSessionContext()` returns `ContextMessage[]` |
|
|
152
|
+
| `hooks/types.ts` | `ContextEvent`, `ContextMessage`, extended context, command types |
|
|
153
|
+
| `hooks/loader.ts` | Track commands |
|
|
154
|
+
| `hooks/runner.ts` | `setStateCallbacks()`, `emitContext()`, command methods |
|
|
155
|
+
| `agent-session.ts` | `saveEntry()`, `rebuildContext()`, state callbacks |
|
|
156
|
+
| `interactive-mode.ts` | Command handling, autocomplete |
|
|
157
|
+
|
|
158
|
+
## Stacking Hook: Complete Implementation
|
|
101
159
|
|
|
102
160
|
```typescript
|
|
161
|
+
import { complete } from "@mariozechner/pi-ai";
|
|
162
|
+
import type { HookAPI, AppMessage, SessionEntry, ContextMessage } from "@mariozechner/pi-coding-agent/hooks";
|
|
163
|
+
|
|
103
164
|
export default function(pi: HookAPI) {
|
|
104
|
-
// Command: /pop
|
|
105
165
|
pi.command("pop", {
|
|
106
|
-
description: "Pop to previous turn, summarizing
|
|
166
|
+
description: "Pop to previous turn, summarizing work",
|
|
107
167
|
handler: async (ctx) => {
|
|
108
|
-
|
|
109
|
-
|
|
168
|
+
const entries = ctx.entries as SessionEntry[];
|
|
169
|
+
|
|
170
|
+
// Get user turns
|
|
171
|
+
const turns = entries
|
|
110
172
|
.map((e, i) => ({ e, i }))
|
|
111
|
-
.filter(({ e }) => e.type === "message" && e.message.role === "user")
|
|
112
|
-
.map(({ e, i }) => ({
|
|
173
|
+
.filter(({ e }) => e.type === "message" && (e as any).message.role === "user")
|
|
174
|
+
.map(({ e, i }) => ({ idx: i, text: preview((e as any).message) }));
|
|
175
|
+
|
|
176
|
+
if (turns.length < 2) return { status: "Need at least 2 turns" };
|
|
113
177
|
|
|
114
|
-
|
|
178
|
+
// Select target (skip last turn - that's current)
|
|
179
|
+
const options = turns.slice(0, -1).map(t => `[${t.idx}] ${t.text}`);
|
|
180
|
+
const selected = ctx.args[0]
|
|
181
|
+
? options.find(o => o.startsWith(`[${ctx.args[0]}]`))
|
|
182
|
+
: await ctx.ui.select("Pop to:", options);
|
|
115
183
|
|
|
116
|
-
// 2. User selects
|
|
117
|
-
const selected = await ctx.ui.select("Pop to:", turns.map(t => t.text));
|
|
118
184
|
if (!selected) return;
|
|
119
|
-
const backTo =
|
|
185
|
+
const backTo = parseInt(selected.match(/\[(\d+)\]/)![1]);
|
|
186
|
+
|
|
187
|
+
// Check compaction crossing
|
|
188
|
+
const compactions = entries.filter(e => e.type === "compaction") as any[];
|
|
189
|
+
const latestCompaction = compactions[compactions.length - 1];
|
|
190
|
+
const crossing = latestCompaction && backTo < latestCompaction.firstKeptEntryIndex;
|
|
120
191
|
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
.
|
|
125
|
-
|
|
192
|
+
// Generate summaries
|
|
193
|
+
let prePopSummary: string | undefined;
|
|
194
|
+
if (crossing) {
|
|
195
|
+
ctx.ui.notify("Crossing compaction, generating pre-pop summary...", "info");
|
|
196
|
+
const preMsgs = getMessages(entries.slice(0, backTo));
|
|
197
|
+
prePopSummary = await summarize(preMsgs, ctx, "context before this work");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const popMsgs = getMessages(entries.slice(backTo));
|
|
201
|
+
const summary = await summarize(popMsgs, ctx, "completed work");
|
|
126
202
|
|
|
127
|
-
//
|
|
203
|
+
// Save and rebuild
|
|
128
204
|
await ctx.saveEntry({
|
|
129
205
|
type: "stack_pop",
|
|
130
206
|
backToIndex: backTo,
|
|
131
207
|
summary,
|
|
132
|
-
|
|
208
|
+
prePopSummary,
|
|
133
209
|
});
|
|
134
210
|
|
|
135
|
-
// 5. Rebuild
|
|
136
211
|
await ctx.rebuildContext();
|
|
137
|
-
return { status:
|
|
212
|
+
return { status: `Popped to turn ${backTo}` };
|
|
138
213
|
}
|
|
139
214
|
});
|
|
140
|
-
|
|
141
|
-
// Context transform: apply stack pops
|
|
215
|
+
|
|
142
216
|
pi.on("context", (event, ctx) => {
|
|
143
|
-
const
|
|
144
|
-
if (!
|
|
217
|
+
const hasPops = event.entries.some(e => e.type === "stack_pop");
|
|
218
|
+
if (!hasPops) return;
|
|
145
219
|
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
const
|
|
220
|
+
// Collect ranges with IDs
|
|
221
|
+
let rangeId = 0;
|
|
222
|
+
const ranges: Array<{from: number; to: number; summary: string; id: number}> = [];
|
|
149
223
|
|
|
150
|
-
for (
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
224
|
+
for (let i = 0; i < event.entries.length; i++) {
|
|
225
|
+
const e = event.entries[i] as any;
|
|
226
|
+
if (e.type === "compaction") {
|
|
227
|
+
ranges.push({ from: 0, to: e.firstKeptEntryIndex, summary: e.summary, id: rangeId++ });
|
|
228
|
+
}
|
|
229
|
+
if (e.type === "stack_pop") {
|
|
230
|
+
if (e.prePopSummary) {
|
|
231
|
+
ranges.push({ from: 0, to: e.backToIndex, summary: e.prePopSummary, id: rangeId++ });
|
|
232
|
+
}
|
|
233
|
+
ranges.push({ from: e.backToIndex, to: i, summary: e.summary, id: rangeId++ });
|
|
234
|
+
}
|
|
154
235
|
}
|
|
155
236
|
|
|
156
|
-
// Build
|
|
157
|
-
const messages:
|
|
237
|
+
// Build messages
|
|
238
|
+
const messages: ContextMessage[] = [];
|
|
239
|
+
const emitted = new Set<number>();
|
|
240
|
+
|
|
158
241
|
for (let i = 0; i < event.entries.length; i++) {
|
|
159
|
-
|
|
242
|
+
const covering = ranges.filter(r => r.from <= i && i < r.to);
|
|
160
243
|
|
|
161
|
-
if (
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
244
|
+
if (covering.length) {
|
|
245
|
+
const winner = covering.reduce((a, b) => a.id > b.id ? a : b);
|
|
246
|
+
if (i === winner.from && !emitted.has(winner.id)) {
|
|
247
|
+
messages.push({
|
|
248
|
+
message: { role: "user", content: `[Summary]\n\n${winner.summary}`, timestamp: Date.now() } as AppMessage,
|
|
249
|
+
entryIndex: null
|
|
250
|
+
});
|
|
251
|
+
emitted.add(winner.id);
|
|
252
|
+
}
|
|
253
|
+
continue;
|
|
167
254
|
}
|
|
168
255
|
|
|
169
256
|
const e = event.entries[i];
|
|
170
|
-
if (e.type === "message")
|
|
257
|
+
if (e.type === "message") {
|
|
258
|
+
messages.push({ message: (e as any).message, entryIndex: i });
|
|
259
|
+
}
|
|
171
260
|
}
|
|
172
261
|
|
|
173
262
|
return { messages };
|
|
174
263
|
});
|
|
175
264
|
}
|
|
176
265
|
|
|
177
|
-
|
|
266
|
+
function getMessages(entries: SessionEntry[]): AppMessage[] {
|
|
267
|
+
return entries.filter(e => e.type === "message").map(e => (e as any).message);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function preview(msg: AppMessage): string {
|
|
271
|
+
const text = typeof msg.content === "string" ? msg.content
|
|
272
|
+
: (msg.content as any[]).filter(c => c.type === "text").map(c => c.text).join(" ");
|
|
273
|
+
return text.slice(0, 40) + (text.length > 40 ? "..." : "");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function summarize(msgs: AppMessage[], ctx: any, purpose: string): Promise<string> {
|
|
178
277
|
const apiKey = await ctx.resolveApiKey(ctx.model);
|
|
179
|
-
|
|
278
|
+
const resp = await complete(ctx.model, {
|
|
279
|
+
messages: [...msgs, { role: "user", content: `Summarize as "${purpose}". Be concise.`, timestamp: Date.now() }]
|
|
280
|
+
}, { apiKey, maxTokens: 2000, signal: ctx.signal });
|
|
281
|
+
return resp.content.filter((c: any) => c.type === "text").map((c: any) => c.text).join("\n");
|
|
180
282
|
}
|
|
181
283
|
```
|
|
182
284
|
|
|
183
|
-
##
|
|
285
|
+
## Edge Cases
|
|
184
286
|
|
|
185
|
-
###
|
|
287
|
+
### Session Resumed Without Hook
|
|
186
288
|
|
|
187
|
-
|
|
188
|
-
// Allow saving arbitrary entries
|
|
189
|
-
saveEntry(entry: { type: string; [k: string]: unknown }): void {
|
|
190
|
-
if (!entry.type) throw new Error("Entry must have type");
|
|
191
|
-
this.inMemoryEntries.push(entry);
|
|
192
|
-
this._persist(entry);
|
|
193
|
-
}
|
|
289
|
+
User has stacking hook, does `/pop`, saves `stack_pop` entry. Later removes hook and resumes session.
|
|
194
290
|
|
|
195
|
-
|
|
196
|
-
|
|
291
|
+
**What happens:**
|
|
292
|
+
1. Core loads all entries (including `stack_pop`)
|
|
293
|
+
2. Core's `buildSessionContext()` ignores unknown types, returns compaction + message entries
|
|
294
|
+
3. `context` event fires, but no handler processes `stack_pop`
|
|
295
|
+
4. Core's messages pass through unchanged
|
|
296
|
+
|
|
297
|
+
**Result:** Messages that were "popped" return to context. The pop is effectively undone.
|
|
197
298
|
|
|
198
|
-
|
|
299
|
+
**Why this is OK:**
|
|
300
|
+
- Session file is intact, no data lost
|
|
301
|
+
- If compaction happened after pop, the compaction summary captured the popped state
|
|
302
|
+
- User removed the hook, so hook's behavior (hiding messages) is gone
|
|
303
|
+
- User can re-add hook to restore stacking behavior
|
|
199
304
|
|
|
305
|
+
**Mitigation:** Could warn on session load if unknown entry types found:
|
|
200
306
|
```typescript
|
|
201
|
-
//
|
|
202
|
-
|
|
203
|
-
type
|
|
204
|
-
|
|
205
|
-
|
|
307
|
+
// In session load
|
|
308
|
+
const unknownTypes = entries
|
|
309
|
+
.map(e => e.type)
|
|
310
|
+
.filter(t => !knownTypes.has(t));
|
|
311
|
+
if (unknownTypes.length) {
|
|
312
|
+
console.warn(`Session has entries of unknown types: ${unknownTypes.join(", ")}`);
|
|
206
313
|
}
|
|
314
|
+
```
|
|
207
315
|
|
|
208
|
-
|
|
316
|
+
### Hook Added to Existing Session
|
|
209
317
|
|
|
210
|
-
|
|
211
|
-
interface CommandOptions {
|
|
212
|
-
description?: string;
|
|
213
|
-
handler: (ctx: CommandContext) => Promise<CommandResult | void>;
|
|
214
|
-
}
|
|
318
|
+
User has old session without stacking. Adds stacking hook, does `/pop`.
|
|
215
319
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
```
|
|
320
|
+
**What happens:**
|
|
321
|
+
1. Hook saves `stack_pop` entry
|
|
322
|
+
2. `context` event fires, hook processes it
|
|
323
|
+
3. Works normally
|
|
221
324
|
|
|
222
|
-
|
|
325
|
+
No issue. Hook processes entries it recognizes, ignores others.
|
|
223
326
|
|
|
224
|
-
|
|
225
|
-
// Track registered commands
|
|
226
|
-
interface LoadedHook {
|
|
227
|
-
path: string;
|
|
228
|
-
handlers: Map<string, Handler[]>;
|
|
229
|
-
commands: Map<string, CommandOptions>; // NEW
|
|
230
|
-
}
|
|
327
|
+
### Multiple Hooks with Different Entry Types
|
|
231
328
|
|
|
232
|
-
|
|
233
|
-
```
|
|
329
|
+
Hook A handles `type_a` entries, Hook B handles `type_b` entries.
|
|
234
330
|
|
|
235
|
-
|
|
331
|
+
**What happens:**
|
|
332
|
+
1. `context` event chains through both hooks
|
|
333
|
+
2. Each hook checks for its entry types, passes through if none found
|
|
334
|
+
3. Each hook's transforms are applied in order
|
|
236
335
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
// Command invocation
|
|
243
|
-
getCommands(): Map<string, CommandOptions>;
|
|
244
|
-
invokeCommand(name: string, argsRaw: string): Promise<CommandResult | void>;
|
|
245
|
-
|
|
246
|
-
// Context event with chaining
|
|
247
|
-
async emitContext(entries, messages): Promise<AppMessage[]> {
|
|
248
|
-
let result = messages;
|
|
249
|
-
for (const hook of this.hooks) {
|
|
250
|
-
const handlers = hook.handlers.get("context");
|
|
251
|
-
for (const h of handlers ?? []) {
|
|
252
|
-
const out = await h({ entries, messages: result }, this.createContext());
|
|
253
|
-
if (out?.messages) result = out.messages;
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
return result;
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
```
|
|
336
|
+
**Best practice:** Hooks should:
|
|
337
|
+
- Only process their own entry types
|
|
338
|
+
- Return `undefined` (pass through) if no relevant entries
|
|
339
|
+
- Use prefixed type names: `myhook_pop`, `myhook_prune`
|
|
260
340
|
|
|
261
|
-
###
|
|
341
|
+
### Conflicting Hooks
|
|
262
342
|
|
|
263
|
-
|
|
264
|
-
// Expose saveEntry
|
|
265
|
-
async saveEntry(entry): Promise<void> {
|
|
266
|
-
this.sessionManager.saveEntry(entry);
|
|
267
|
-
}
|
|
343
|
+
Two hooks both try to handle the same entry type (e.g., both handle `compaction`).
|
|
268
344
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
const entries = this.sessionManager.getEntries();
|
|
273
|
-
const messages = await this._hookRunner.emitContext(entries, base.messages);
|
|
274
|
-
this.agent.replaceMessages(messages);
|
|
275
|
-
}
|
|
345
|
+
**What happens:**
|
|
346
|
+
- Later hook (project > global) wins in the chain
|
|
347
|
+
- Earlier hook's transform is overwritten
|
|
276
348
|
|
|
277
|
-
|
|
278
|
-
|
|
349
|
+
**Mitigation:**
|
|
350
|
+
- Core entry types (`compaction`, `message`, etc.) should not be overridden by hooks
|
|
351
|
+
- Hooks should use unique prefixed type names
|
|
352
|
+
- Document which types are "reserved"
|
|
279
353
|
|
|
280
|
-
###
|
|
354
|
+
### Session with Future Entry Types
|
|
281
355
|
|
|
282
|
-
|
|
283
|
-
// In setupEditorSubmitHandler, check hook commands
|
|
284
|
-
const commands = this.session.hookRunner?.getCommands();
|
|
285
|
-
if (commands?.has(commandName)) {
|
|
286
|
-
const result = await this.session.invokeCommand(commandName, argsRaw);
|
|
287
|
-
// Handle result...
|
|
288
|
-
return;
|
|
289
|
-
}
|
|
356
|
+
User downgrades pi version, session has entry types from newer version.
|
|
290
357
|
|
|
291
|
-
|
|
292
|
-
|
|
358
|
+
**What happens:**
|
|
359
|
+
- Same as "hook removed" - unknown types ignored
|
|
360
|
+
- Core handles what it knows, hooks handle what they know
|
|
293
361
|
|
|
294
|
-
|
|
362
|
+
**Session file is forward-compatible:** Unknown entries are preserved in file, just not processed.
|
|
295
363
|
|
|
296
|
-
|
|
297
|
-
- `saveEntry`/`rebuildContext` in all handlers = more power, more footguns
|
|
298
|
-
- Commands only = safer, but limits hook creativity
|
|
299
|
-
- Recommendation: start with commands only
|
|
364
|
+
## Implementation Phases
|
|
300
365
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
366
|
+
| Phase | Scope | LOC |
|
|
367
|
+
|-------|-------|-----|
|
|
368
|
+
| v2.0 | `saveEntry`, `context` event, `rebuildContext`, extended context | ~150 |
|
|
369
|
+
| v2.1 | `pi.command()`, TUI integration, autocomplete | ~200 |
|
|
370
|
+
| v2.2 | Example hooks, documentation | ~300 |
|
|
305
371
|
|
|
306
|
-
|
|
307
|
-
- Core compaction runs first, then `context` event
|
|
308
|
-
- Hooks can post-process compacted output
|
|
309
|
-
- Future: compaction itself could become a replaceable hook
|
|
372
|
+
## Implementation Order
|
|
310
373
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
374
|
+
1. `ContextMessage` type, update `buildSessionContext()` return type
|
|
375
|
+
2. `saveEntry()` in session-manager
|
|
376
|
+
3. `context` event in runner with chaining
|
|
377
|
+
4. State callbacks interface and wiring
|
|
378
|
+
5. `rebuildContext()` in agent-session
|
|
379
|
+
6. Manual test with simple hook
|
|
380
|
+
7. Command registration in loader
|
|
381
|
+
8. Command invocation in runner
|
|
382
|
+
9. TUI command handling + autocomplete
|
|
383
|
+
10. Stacking example hook
|
|
384
|
+
11. Pruning example hook
|
|
385
|
+
12. Update hooks.md
|