@howaboua/pi-notes-compaction 0.0.0-stage → 0.0.2

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.
@@ -0,0 +1,538 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type {
3
+ AgentSettledEvent,
4
+ ExtensionAPI,
5
+ ExtensionContext,
6
+ InputEvent,
7
+ InputEventResult,
8
+ SessionBeforeCompactEvent,
9
+ SessionCompactFailedEvent,
10
+ } from "@earendil-works/pi-coding-agent";
11
+ import {
12
+ buildSessionProjection,
13
+ prepareBranchEntries,
14
+ } from "@earendil-works/pi-coding-agent";
15
+ import type { NotesBridge } from "./bridge.js";
16
+ import { hasFreshNotes } from "./fresh-notes.js";
17
+ import { noteHints, sessionRef } from "./notes.js";
18
+ import type { IdleMinutes } from "./settings.js";
19
+ import type { NotesStore } from "./store.js";
20
+ import {
21
+ SETTLEMENT_ENTRY,
22
+ WINDOW_MESSAGE,
23
+ Windows,
24
+ windowDetails,
25
+ windowMessage,
26
+ } from "./windows.js";
27
+
28
+ interface Checkpoint {
29
+ sessionId: string;
30
+ windowId: string;
31
+ prompt: string;
32
+ admitted: boolean;
33
+ runId: string | undefined;
34
+ outcome: "completed" | "aborted" | "error" | undefined;
35
+ }
36
+ interface HeldInput {
37
+ sessionId: string;
38
+ phase: "failed" | "rolling" | "releasing" | "draining";
39
+ releasePrompt: string;
40
+ admissions: ((result: InputEventResult) => void)[];
41
+ }
42
+ interface Rollover {
43
+ sessionId: string;
44
+ phase: "scheduled" | "compacting";
45
+ triggerTurn: boolean;
46
+ }
47
+
48
+ export class NotesLifecycle {
49
+ readonly windows = new Windows();
50
+ active = false;
51
+ normalCompaction = false;
52
+ idleMinutes: IdleMinutes = 0;
53
+ bridge: NotesBridge | undefined;
54
+ runId = "";
55
+ private checkpoint: Checkpoint | undefined;
56
+ private held: HeldInput | undefined;
57
+ private rollover: Rollover | undefined;
58
+ private cancelledCompact:
59
+ | {
60
+ sessionId: string;
61
+ signal: AbortSignal;
62
+ instructions: string | undefined;
63
+ }
64
+ | undefined;
65
+
66
+ private readonly pi: ExtensionAPI;
67
+ private readonly store: NotesStore;
68
+ constructor(pi: ExtensionAPI, store: NotesStore) {
69
+ this.pi = pi;
70
+ this.store = store;
71
+ }
72
+ agent(ctx: ExtensionContext): string {
73
+ return this.bridge?.agentName?.(ctx) ?? "/root";
74
+ }
75
+ reset(ctx?: ExtensionContext): void {
76
+ this.checkpoint = undefined;
77
+ this.rollover = undefined;
78
+ this.cancelledCompact = undefined;
79
+ for (const admit of this.held?.admissions ?? [])
80
+ admit({ action: "handled" });
81
+ this.held = undefined;
82
+ this.runId = "";
83
+ this.windows.restore(ctx?.sessionManager.getBranch() ?? []);
84
+ }
85
+ initialize(ctx: ExtensionContext): void {
86
+ const branch = ctx.sessionManager.getBranch();
87
+ this.windows.restore(branch);
88
+ const latest = branch.findLast(
89
+ (entry) =>
90
+ entry.type === "custom_message" &&
91
+ entry.customType === WINDOW_MESSAGE &&
92
+ ["window", "identity"].includes(windowDetails(entry)?.kind ?? ""),
93
+ );
94
+ if (!this.windows.current || !latest) {
95
+ const inherited = Boolean(this.windows.current);
96
+ const ids = this.windows.current ?? this.windows.next();
97
+ this.pi.sendMessage(
98
+ windowMessage(
99
+ ids,
100
+ this.agent(ctx),
101
+ noteHints(this.store, ctx, this.agent(ctx)),
102
+ false,
103
+ inherited ? "identity" : "window",
104
+ ),
105
+ { triggerTurn: false },
106
+ );
107
+ this.windows.current = ids;
108
+ } else if (
109
+ latest.type === "custom_message" &&
110
+ typeof latest.content === "string" &&
111
+ !latest.content.includes(`Agent name: ${this.agent(ctx)}\n`)
112
+ ) {
113
+ this.pi.sendMessage(
114
+ windowMessage(
115
+ this.windows.current,
116
+ this.agent(ctx),
117
+ noteHints(this.store, ctx, this.agent(ctx)),
118
+ false,
119
+ "identity",
120
+ ),
121
+ { triggerTurn: false },
122
+ );
123
+ }
124
+ }
125
+ beforeStart(ctx: ExtensionContext): void {
126
+ this.initialize(ctx);
127
+ this.runId = randomUUID();
128
+ if (this.checkpoint?.admitted) this.checkpoint.runId = this.runId;
129
+ }
130
+ outcome(outcome: "completed" | "aborted" | "error"): void {
131
+ if (this.checkpoint) this.checkpoint.outcome = outcome;
132
+ }
133
+ fresh(ctx: ExtensionContext, runId?: string): boolean {
134
+ return hasFreshNotes(
135
+ ctx.sessionManager.getBranch(),
136
+ this.windows.current?.currentWindowId,
137
+ runId,
138
+ );
139
+ }
140
+ requestRollover(ctx: ExtensionContext): boolean {
141
+ if (this.checkpoint || this.held) return false;
142
+ if (this.rollover) return false;
143
+ this.rollover = {
144
+ sessionId: ctx.sessionManager.getSessionId(),
145
+ phase: "scheduled",
146
+ triggerTurn: true,
147
+ };
148
+ return true;
149
+ }
150
+ private async openWindow(
151
+ ctx: ExtensionContext,
152
+ triggerTurn: boolean,
153
+ trim: boolean,
154
+ ): Promise<void> {
155
+ if (!ctx.isIdle())
156
+ throw new Error("Wait for the current run to settle before rollover");
157
+ const sessionId = ctx.sessionManager.getSessionId();
158
+ await this.bridge?.beforeWindow?.(ctx);
159
+ if (sessionId !== ctx.sessionManager.getSessionId() || !this.active)
160
+ throw new Error("Session changed before rollover");
161
+ const ids = this.windows.next();
162
+ const hint = noteHints(this.store, ctx, this.agent(ctx));
163
+ this.pi.sendMessage(windowMessage(ids, this.agent(ctx), hint, trim), {
164
+ triggerTurn: false,
165
+ });
166
+ this.windows.current = ids;
167
+ if (triggerTurn) this.pi.sendUserMessage("Continue.");
168
+ }
169
+ private async roll(
170
+ ctx: ExtensionContext,
171
+ triggerTurn: boolean,
172
+ ): Promise<void> {
173
+ if (!this.normalCompaction) {
174
+ await this.openWindow(ctx, triggerTurn, true);
175
+ return;
176
+ }
177
+ const pending: Rollover = {
178
+ sessionId: ctx.sessionManager.getSessionId(),
179
+ phase: "compacting",
180
+ triggerTurn,
181
+ };
182
+ this.rollover = pending;
183
+ await new Promise<void>((resolve, reject) => {
184
+ ctx.compact({
185
+ onComplete: () => {
186
+ if (
187
+ this.rollover !== pending ||
188
+ pending.sessionId !== ctx.sessionManager.getSessionId()
189
+ ) {
190
+ reject(new Error("Session changed during compaction"));
191
+ return;
192
+ }
193
+ this.rollover = undefined;
194
+ void this.openWindow(ctx, triggerTurn, false).then(resolve, reject);
195
+ },
196
+ onError: (error) => {
197
+ if (this.rollover === pending) this.rollover = undefined;
198
+ reject(error);
199
+ },
200
+ });
201
+ });
202
+ }
203
+ private startCheckpoint(ctx: ExtensionContext, instructions?: string): void {
204
+ if (!ctx.isIdle() || !this.windows.current || this.checkpoint)
205
+ throw new Error("A checkpoint cannot start until the session settles");
206
+ const prompt = `Save the requested context checkpoint. Checkpoint request: ${randomUUID()}`;
207
+ this.checkpoint = {
208
+ sessionId: ctx.sessionManager.getSessionId(),
209
+ windowId: this.windows.current.currentWindowId,
210
+ prompt,
211
+ admitted: false,
212
+ runId: undefined,
213
+ outcome: undefined,
214
+ };
215
+ try {
216
+ this.pi.sendMessage(
217
+ {
218
+ customType: "notes-compaction:checkpoint:v1",
219
+ content: `<context_window_reminder>\nContext checkpoint requested. Save the current state with notes, then finish your response. The new window opens after this run settles. Do not call new_context. If saving fails, report the failure without rolling over.\n</context_window_reminder>${instructions?.trim() ? `\n\nCheckpoint guidance:\n${instructions}` : ""}`,
220
+ display: true,
221
+ },
222
+ { triggerTurn: false },
223
+ );
224
+ // A settled user kickoff runs ALL input and before_agent_start handlers.
225
+ this.pi.sendUserMessage(prompt);
226
+ } catch (error) {
227
+ this.checkpoint = undefined;
228
+ throw error;
229
+ }
230
+ }
231
+ private idleDue(ctx: ExtensionContext): boolean {
232
+ if (
233
+ this.idleMinutes === 0 ||
234
+ !ctx.isIdle() ||
235
+ !this.windows.current ||
236
+ this.rollover ||
237
+ this.checkpoint
238
+ )
239
+ return false;
240
+ const branch = ctx.sessionManager.getBranch();
241
+ const terminal = branch.findLast(
242
+ (entry) => entry.type === "message" && entry.message.role !== "system",
243
+ );
244
+ if (!terminal) return false;
245
+ const settlement = ctx.sessionManager
246
+ .getEntries()
247
+ .findLast(
248
+ (entry) =>
249
+ entry.type === "custom" &&
250
+ entry.customType === SETTLEMENT_ENTRY &&
251
+ entry.data &&
252
+ typeof entry.data === "object" &&
253
+ "completedEntryId" in entry.data &&
254
+ entry.data.completedEntryId === terminal.id,
255
+ );
256
+ if (
257
+ settlement?.type !== "custom" ||
258
+ !settlement.data ||
259
+ typeof settlement.data !== "object"
260
+ )
261
+ return false;
262
+ const data = settlement.data;
263
+ return (
264
+ !("aborted" in data && data.aborted === true) &&
265
+ "windowId" in data &&
266
+ data.windowId === this.windows.current.currentWindowId &&
267
+ "settledAt" in data &&
268
+ typeof data.settledAt === "number" &&
269
+ Number.isFinite(data.settledAt) &&
270
+ Date.now() - data.settledAt >= this.idleMinutes * 60_000
271
+ );
272
+ }
273
+ async input(
274
+ event: InputEvent,
275
+ ctx: ExtensionContext,
276
+ ): Promise<InputEventResult | undefined> {
277
+ const releasing = this.held;
278
+ if (
279
+ releasing?.phase === "releasing" &&
280
+ event.source === "extension" &&
281
+ event.text === releasing.releasePrompt
282
+ ) {
283
+ const admit = releasing.admissions.shift();
284
+ if (releasing.admissions.length) releasing.phase = "draining";
285
+ else this.held = undefined;
286
+ admit?.({ action: "continue" });
287
+ return { action: "handled" };
288
+ }
289
+ const checkpoint = this.checkpoint;
290
+ if (
291
+ checkpoint &&
292
+ !checkpoint.admitted &&
293
+ event.source === "extension" &&
294
+ event.text === checkpoint.prompt
295
+ ) {
296
+ checkpoint.admitted = true;
297
+ return;
298
+ }
299
+ if (!this.held && (!this.idleDue(ctx) || !this.fresh(ctx))) return;
300
+ const held =
301
+ this.held ??
302
+ ({
303
+ sessionId: ctx.sessionManager.getSessionId(),
304
+ phase: "failed",
305
+ releasePrompt: `Admit saved input: ${randomUUID()}`,
306
+ admissions: [],
307
+ } satisfies HeldInput);
308
+ this.held = held;
309
+ const admission = new Promise<InputEventResult>((resolve) =>
310
+ held.admissions.push(resolve),
311
+ );
312
+ if (held.phase !== "failed") return admission;
313
+ held.phase = "rolling";
314
+ void Promise.resolve().then(async () => {
315
+ try {
316
+ if (
317
+ this.held !== held ||
318
+ held.sessionId !== ctx.sessionManager.getSessionId()
319
+ )
320
+ return;
321
+ await this.finishHeld(ctx, held);
322
+ } catch (error) {
323
+ this.failHeld(ctx, held, error);
324
+ }
325
+ });
326
+ return admission;
327
+ }
328
+ private failHeld(
329
+ ctx: ExtensionContext,
330
+ held: HeldInput,
331
+ error: unknown,
332
+ ): void {
333
+ if (this.held !== held) return;
334
+ held.phase = "failed";
335
+ ctx.ui.notify(
336
+ `Idle rollover failed: ${error instanceof Error ? error.message : String(error)}. Input and attachments remain pending. Submit another prompt to retry, or reload to cancel.`,
337
+ "error",
338
+ );
339
+ }
340
+ private async finishHeld(
341
+ ctx: ExtensionContext,
342
+ held: HeldInput,
343
+ ): Promise<void> {
344
+ held.phase = "rolling";
345
+ await this.roll(ctx, false);
346
+ if (
347
+ this.held !== held ||
348
+ held.sessionId !== ctx.sessionManager.getSessionId()
349
+ )
350
+ return;
351
+ held.phase = "releasing";
352
+ // Pi defers this user kickoff until EVERY agent_settled handler returns.
353
+ // Its input-only admission releases the original SDK calls, preserving images/options.
354
+ this.pi.sendUserMessage(held.releasePrompt);
355
+ }
356
+ beforeCompact(
357
+ event: SessionBeforeCompactEvent,
358
+ ctx: ExtensionContext,
359
+ ): { cancel: true } | undefined {
360
+ if (
361
+ this.normalCompaction ||
362
+ this.rollover?.phase === "compacting" ||
363
+ event.reason === "overflow"
364
+ ) {
365
+ // PCC owns preparation when bridged, including native checkpoint routes.
366
+ if (this.bridge) return;
367
+ try {
368
+ const branch = this.windows.projectBranch(event.branchEntries);
369
+ if (branch.length === event.branchEntries.length) return;
370
+ const physicalCut = event.branchEntries.findIndex(
371
+ (entry) => entry.id === event.preparation.firstKeptEntryId,
372
+ );
373
+ if (physicalCut < 0)
374
+ throw new Error("Compaction kept boundary is missing");
375
+ const keptIds = new Set(
376
+ event.branchEntries.slice(physicalCut).map((entry) => entry.id),
377
+ );
378
+ const projection = buildSessionProjection(branch);
379
+ const projected = projection.entries;
380
+ const summary = projection.messages.find(
381
+ (message) => message.role === "compactionSummary",
382
+ );
383
+ const cut = projected.findIndex((entry) =>
384
+ keptIds.has(entry.sourceEntry.id),
385
+ );
386
+ if (cut < 0)
387
+ throw new Error("Current context kept boundary is missing");
388
+ const summarized = projected.slice(0, cut);
389
+ const messages = summarized
390
+ .flatMap((entry) => entry.messages)
391
+ .filter(
392
+ (message) =>
393
+ message.role !== "system" && message.role !== "compactionSummary",
394
+ );
395
+ if (!messages.length) {
396
+ ctx.ui.notify(
397
+ "Nothing to compact in the current context window. The existing context remains.",
398
+ "warning",
399
+ );
400
+ return { cancel: true };
401
+ }
402
+ // Pi retains this object for its default summarizer after every hook.
403
+ // Keep that route's auth, provider hooks and retries, and physical kept IDs.
404
+ delete event.preparation.previousSummary;
405
+ Object.assign(event.preparation, {
406
+ firstKeptEntryId: projected[cut]!.sourceEntry.id,
407
+ ...(summary ? { previousSummary: summary.summary } : {}),
408
+ messagesToSummarize: messages,
409
+ turnPrefixMessages: [],
410
+ isSplitTurn: false,
411
+ fileOps: prepareBranchEntries(
412
+ summarized.flatMap((entry) =>
413
+ entry.messages.map((message) => ({
414
+ ...entry.sourceEntry,
415
+ type: "message" as const,
416
+ message,
417
+ })),
418
+ ),
419
+ ).fileOps,
420
+ });
421
+ } catch (error) {
422
+ ctx.ui.notify(
423
+ `Compaction failed: ${error instanceof Error ? error.message : String(error)}. The existing context remains.`,
424
+ "error",
425
+ );
426
+ return { cancel: true };
427
+ }
428
+ return;
429
+ }
430
+ if (event.reason === "manual")
431
+ this.cancelledCompact = {
432
+ sessionId: ctx.sessionManager.getSessionId(),
433
+ signal: event.signal,
434
+ instructions: event.customInstructions,
435
+ };
436
+ return { cancel: true };
437
+ }
438
+ async compactFailed(
439
+ event: SessionCompactFailedEvent,
440
+ ctx: ExtensionContext,
441
+ ): Promise<void> {
442
+ const pending = this.cancelledCompact;
443
+ this.cancelledCompact = undefined;
444
+ if (
445
+ !pending ||
446
+ event.reason !== "manual" ||
447
+ !event.aborted ||
448
+ pending.signal.aborted ||
449
+ pending.sessionId !== ctx.sessionManager.getSessionId()
450
+ )
451
+ return;
452
+ if (!pending.instructions?.trim() && this.fresh(ctx))
453
+ await this.roll(ctx, true);
454
+ else this.startCheckpoint(ctx, pending.instructions);
455
+ }
456
+ async settled(
457
+ event: AgentSettledEvent,
458
+ ctx: ExtensionContext,
459
+ ): Promise<void> {
460
+ const checkpoint = this.checkpoint;
461
+ this.checkpoint = undefined;
462
+ try {
463
+ this.store.updateFile(sessionRef(ctx));
464
+ const terminal = ctx.sessionManager
465
+ .getBranch()
466
+ .findLast(
467
+ (entry) =>
468
+ entry.type === "message" && entry.message.role !== "system",
469
+ );
470
+ if (terminal && this.windows.current)
471
+ this.pi.appendEntry(SETTLEMENT_ENTRY, {
472
+ protocol: 1,
473
+ windowId: this.windows.current.currentWindowId,
474
+ completedEntryId: terminal.id,
475
+ settledAt: Date.now(),
476
+ aborted: event.aborted,
477
+ });
478
+ } catch (error) {
479
+ this.rollover = undefined;
480
+ if (
481
+ checkpoint &&
482
+ (event.aborted ||
483
+ checkpoint.outcome === "aborted" ||
484
+ checkpoint.outcome === undefined)
485
+ ) {
486
+ ctx.ui.notify(
487
+ "Checkpoint cancelled. The old context remains.",
488
+ "warning",
489
+ );
490
+ } else if (this.held) this.failHeld(ctx, this.held, error);
491
+ else throw error;
492
+ return;
493
+ }
494
+ if (checkpoint) {
495
+ const valid =
496
+ checkpoint.sessionId === ctx.sessionManager.getSessionId() &&
497
+ checkpoint.windowId === this.windows.current?.currentWindowId;
498
+ const aborted =
499
+ event.aborted ||
500
+ checkpoint.outcome === "aborted" ||
501
+ checkpoint.outcome === undefined;
502
+ if (!valid || aborted) {
503
+ ctx.ui.notify(
504
+ "Checkpoint cancelled. The old context remains.",
505
+ "warning",
506
+ );
507
+ return;
508
+ }
509
+ if (
510
+ checkpoint.outcome !== "completed" ||
511
+ !checkpoint.runId ||
512
+ !this.fresh(ctx, checkpoint.runId)
513
+ ) {
514
+ ctx.ui.notify(
515
+ "Rollover did not start. No fresh note was saved in the completed checkpoint. The old context remains.",
516
+ "warning",
517
+ );
518
+ return;
519
+ }
520
+ await this.roll(ctx, true);
521
+ return;
522
+ }
523
+ if (this.held?.phase === "draining") {
524
+ // Retry admissions are original SDK calls too. Releasing all at once races Agent.prompt.
525
+ this.held.phase = "releasing";
526
+ this.pi.sendUserMessage(this.held.releasePrompt);
527
+ return;
528
+ }
529
+ const rollover = this.rollover;
530
+ if (
531
+ rollover?.phase === "scheduled" &&
532
+ rollover.sessionId === ctx.sessionManager.getSessionId()
533
+ ) {
534
+ this.rollover = undefined;
535
+ await this.roll(ctx, rollover.triggerTurn);
536
+ }
537
+ }
538
+ }