@pontia/pi-client-plugin 0.5.2 → 0.5.3
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/package.json +1 -1
- package/src/compaction.ts +225 -0
- package/src/control-socket.ts +29 -1
- package/src/events.ts +3 -0
- package/src/index.ts +42 -0
package/package.json
CHANGED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type {
|
|
3
|
+
SessionBeforeCompactEvent,
|
|
4
|
+
SessionCompactEvent,
|
|
5
|
+
SessionCompactFailedEvent,
|
|
6
|
+
CompactionResult,
|
|
7
|
+
} from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import type { InternalEvent } from "./events.js";
|
|
9
|
+
import type { SessionContext } from "./session.js";
|
|
10
|
+
import { RpcError } from "./rpc-error.js";
|
|
11
|
+
|
|
12
|
+
type Trigger = "manual" | "threshold" | "overflow" | "unknown";
|
|
13
|
+
interface Operation {
|
|
14
|
+
id: string;
|
|
15
|
+
trigger: Trigger;
|
|
16
|
+
started?: InternalEvent;
|
|
17
|
+
terminal?: InternalEvent;
|
|
18
|
+
acknowledged: Set<string>;
|
|
19
|
+
reporting: Promise<void>;
|
|
20
|
+
awaitingNativeResult: boolean;
|
|
21
|
+
failureKey?: string;
|
|
22
|
+
}
|
|
23
|
+
interface State {
|
|
24
|
+
report: (event: InternalEvent) => Promise<boolean>;
|
|
25
|
+
active?: Operation;
|
|
26
|
+
operations: Map<string, Operation>;
|
|
27
|
+
signals: WeakMap<AbortSignal, Operation>;
|
|
28
|
+
failures: WeakSet<object>;
|
|
29
|
+
completed: Map<string, Operation>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Pi reloads extensions in the same process. Keep only control metadata across
|
|
33
|
+
// reload, so a replaced hook or reconnect cannot allocate another operation.
|
|
34
|
+
const stateKey = Symbol.for("pontia.pi.compaction");
|
|
35
|
+
const processState = globalThis as typeof globalThis & { [stateKey]?: Map<string, State> };
|
|
36
|
+
const states = (processState[stateKey] ??= new Map());
|
|
37
|
+
|
|
38
|
+
export class PiCompaction {
|
|
39
|
+
private readonly state: State;
|
|
40
|
+
|
|
41
|
+
constructor(
|
|
42
|
+
home: string,
|
|
43
|
+
private readonly context: SessionContext,
|
|
44
|
+
report: (event: InternalEvent) => Promise<boolean>,
|
|
45
|
+
) {
|
|
46
|
+
const key = JSON.stringify([
|
|
47
|
+
home,
|
|
48
|
+
context.sessionId,
|
|
49
|
+
context.runtimeId,
|
|
50
|
+
context.clientSessionKey,
|
|
51
|
+
]);
|
|
52
|
+
let state = states.get(key);
|
|
53
|
+
if (!state) {
|
|
54
|
+
state = {
|
|
55
|
+
report,
|
|
56
|
+
operations: new Map(),
|
|
57
|
+
signals: new WeakMap(),
|
|
58
|
+
failures: new WeakSet(),
|
|
59
|
+
completed: new Map(),
|
|
60
|
+
};
|
|
61
|
+
states.set(key, state);
|
|
62
|
+
}
|
|
63
|
+
state.report = report;
|
|
64
|
+
this.state = state;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
get running(): boolean {
|
|
68
|
+
return !!this.state.active && !this.state.active.terminal;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async reconnect(): Promise<void> {
|
|
72
|
+
for (const operation of this.state.operations.values()) {
|
|
73
|
+
if (operation.started) await this.publish(operation, !operation.awaitingNativeResult);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
request(operationId: string, compact: () => void): void {
|
|
78
|
+
if (this.state.operations.has(operationId)) return;
|
|
79
|
+
if (this.running) throw new RpcError(-32006, "Pi compaction is already running");
|
|
80
|
+
const operation = this.create("manual", operationId);
|
|
81
|
+
operation.awaitingNativeResult = true;
|
|
82
|
+
try {
|
|
83
|
+
compact();
|
|
84
|
+
} catch (error) {
|
|
85
|
+
this.state.operations.delete(operationId);
|
|
86
|
+
this.state.active = undefined;
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async before(event: SessionBeforeCompactEvent): Promise<void> {
|
|
92
|
+
let operation = this.state.signals.get(event.signal);
|
|
93
|
+
if (!operation) {
|
|
94
|
+
operation = this.running ? this.state.active! : this.create(trigger(event.reason));
|
|
95
|
+
this.state.signals.set(event.signal, operation);
|
|
96
|
+
}
|
|
97
|
+
await this.publish(operation);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async completed(event: SessionCompactEvent): Promise<void> {
|
|
101
|
+
const entry = event.compactionEntry;
|
|
102
|
+
const operation = this.state.completed.get(entry.id) ?? this.current(event.reason);
|
|
103
|
+
this.state.completed.set(entry.id, operation);
|
|
104
|
+
operation.terminal ??= this.fact("session.compaction_completed", operation, {
|
|
105
|
+
native_compaction_id: entry.id ?? null,
|
|
106
|
+
tokens_before: tokens(entry.tokensBefore),
|
|
107
|
+
// Pi's extension event does not expose a post-compaction token count.
|
|
108
|
+
tokens_after: null,
|
|
109
|
+
});
|
|
110
|
+
// The manual context callback supplies Pi's estimatedTokensAfter result.
|
|
111
|
+
// Automatic compaction exposes only the extension event, so it stays null.
|
|
112
|
+
await this.publish(operation, !operation.awaitingNativeResult);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async failed(event: SessionCompactFailedEvent, leaf: string | null): Promise<void> {
|
|
116
|
+
if (this.state.failures.has(event)) return;
|
|
117
|
+
this.state.failures.add(event);
|
|
118
|
+
const failureKey = JSON.stringify([
|
|
119
|
+
event.reason,
|
|
120
|
+
event.aborted,
|
|
121
|
+
event.errorMessage ?? null,
|
|
122
|
+
leaf,
|
|
123
|
+
]);
|
|
124
|
+
const previous = this.state.active;
|
|
125
|
+
const operation =
|
|
126
|
+
this.running || previous?.failureKey === failureKey
|
|
127
|
+
? previous!
|
|
128
|
+
: this.create(trigger(event.reason));
|
|
129
|
+
operation.failureKey = failureKey;
|
|
130
|
+
operation.terminal ??= this.fact("session.compaction_failed", operation, {
|
|
131
|
+
aborted: event.aborted,
|
|
132
|
+
error_message: event.errorMessage ?? null,
|
|
133
|
+
});
|
|
134
|
+
operation.awaitingNativeResult = false;
|
|
135
|
+
await this.publish(operation);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// The callback covers native failures occurring before session_before_compact
|
|
139
|
+
// (for example, a session too small to compact). Hooks remain authoritative
|
|
140
|
+
// for cancellation and supply richer diagnostics when present.
|
|
141
|
+
async callbackError(operationId: string, error: Error): Promise<void> {
|
|
142
|
+
const operation = this.state.operations.get(operationId);
|
|
143
|
+
if (!operation) return;
|
|
144
|
+
operation.awaitingNativeResult = false;
|
|
145
|
+
operation.terminal ??= this.fact("session.compaction_failed", operation, {
|
|
146
|
+
aborted: false,
|
|
147
|
+
error_message: error.message,
|
|
148
|
+
});
|
|
149
|
+
await this.publish(operation);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async callbackComplete(operationId: string, result: CompactionResult): Promise<void> {
|
|
153
|
+
const operation = this.state.operations.get(operationId);
|
|
154
|
+
if (!operation) return;
|
|
155
|
+
operation.awaitingNativeResult = false;
|
|
156
|
+
operation.terminal ??= this.fact("session.compaction_completed", operation, {
|
|
157
|
+
native_compaction_id: null,
|
|
158
|
+
tokens_before: tokens(result.tokensBefore),
|
|
159
|
+
tokens_after: null,
|
|
160
|
+
});
|
|
161
|
+
if (
|
|
162
|
+
!operation.acknowledged.has(operation.terminal.type) &&
|
|
163
|
+
operation.terminal.type === "session.compaction_completed"
|
|
164
|
+
) {
|
|
165
|
+
operation.terminal.data.tokens_after = tokens(result.estimatedTokensAfter);
|
|
166
|
+
}
|
|
167
|
+
await this.publish(operation);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private current(reason: unknown): Operation {
|
|
171
|
+
return this.running ? this.state.active! : this.create(trigger(reason));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private create(reason: Trigger, id = `compact_${randomUUID()}`): Operation {
|
|
175
|
+
const operation: Operation = {
|
|
176
|
+
id,
|
|
177
|
+
trigger: reason,
|
|
178
|
+
acknowledged: new Set(),
|
|
179
|
+
reporting: Promise.resolve(),
|
|
180
|
+
awaitingNativeResult: false,
|
|
181
|
+
};
|
|
182
|
+
this.state.operations.set(id, operation);
|
|
183
|
+
this.state.active = operation;
|
|
184
|
+
return operation;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private fact(
|
|
188
|
+
type: InternalEvent["type"],
|
|
189
|
+
operation: Operation,
|
|
190
|
+
metadata: Record<string, unknown> = {},
|
|
191
|
+
): InternalEvent {
|
|
192
|
+
return {
|
|
193
|
+
session_id: this.context.sessionId,
|
|
194
|
+
type,
|
|
195
|
+
data: { operation_id: operation.id, trigger: operation.trigger, ...metadata },
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private publish(operation: Operation, includeTerminal = true): Promise<void> {
|
|
200
|
+
operation.started ??= this.fact("session.compaction_started", operation);
|
|
201
|
+
operation.reporting = operation.reporting
|
|
202
|
+
.catch(() => {})
|
|
203
|
+
.then(async () => {
|
|
204
|
+
for (const event of [
|
|
205
|
+
operation.started!,
|
|
206
|
+
includeTerminal ? operation.terminal : undefined,
|
|
207
|
+
]) {
|
|
208
|
+
if (!event || operation.acknowledged.has(event.type)) continue;
|
|
209
|
+
if (!(await this.state.report(event))) return;
|
|
210
|
+
operation.acknowledged.add(event.type);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
return operation.reporting;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function trigger(reason: unknown): Trigger {
|
|
218
|
+
return reason === "manual" || reason === "threshold" || reason === "overflow"
|
|
219
|
+
? reason
|
|
220
|
+
: "unknown";
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function tokens(value: unknown): number | null {
|
|
224
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
225
|
+
}
|
package/src/control-socket.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { readHistory, type HistoryContext } from "./history.js";
|
|
|
4
4
|
import { createConnection, type Socket } from "node:net";
|
|
5
5
|
import { isAbsolute, join } from "node:path";
|
|
6
6
|
|
|
7
|
-
export const CONTROL_VERSION =
|
|
7
|
+
export const CONTROL_VERSION = 8;
|
|
8
8
|
export const MAX_CONTROL_FRAME_BYTES = 64 * 1024;
|
|
9
9
|
// Match the former HTTP event body limit, including the RPC envelope.
|
|
10
10
|
export const MAX_RPC_FRAME_BYTES = 2 * 1024 * 1024 + 1024;
|
|
@@ -33,6 +33,9 @@ export interface LifecycleControl {
|
|
|
33
33
|
interrupt(): void;
|
|
34
34
|
shutdown(): void;
|
|
35
35
|
}
|
|
36
|
+
export interface CompactionControl {
|
|
37
|
+
compact(operationId: string): void;
|
|
38
|
+
}
|
|
36
39
|
export interface PiConnection {
|
|
37
40
|
request(method: string, params: object): Promise<unknown>;
|
|
38
41
|
registered(identity: ControlIdentity): void;
|
|
@@ -65,6 +68,7 @@ class RpcSocket {
|
|
|
65
68
|
private models?: ModelControl;
|
|
66
69
|
private lifecycle?: LifecycleControl;
|
|
67
70
|
private history?: () => HistoryContext;
|
|
71
|
+
private compaction?: CompactionControl;
|
|
68
72
|
private onReplay?: (inboxMessageId: string) => void;
|
|
69
73
|
|
|
70
74
|
constructor(
|
|
@@ -74,6 +78,7 @@ class RpcSocket {
|
|
|
74
78
|
onReplay?: (inboxMessageId: string) => void,
|
|
75
79
|
lifecycle?: LifecycleControl,
|
|
76
80
|
history?: () => HistoryContext,
|
|
81
|
+
compaction?: CompactionControl,
|
|
77
82
|
) {
|
|
78
83
|
this.socket = socket;
|
|
79
84
|
this.onSubmit = onSubmit;
|
|
@@ -81,6 +86,7 @@ class RpcSocket {
|
|
|
81
86
|
this.onReplay = onReplay;
|
|
82
87
|
this.lifecycle = lifecycle;
|
|
83
88
|
this.history = history;
|
|
89
|
+
this.compaction = compaction;
|
|
84
90
|
socket.on("data", (chunk: Buffer) => this.receive(chunk));
|
|
85
91
|
socket.on("error", (error) => this.fail(error));
|
|
86
92
|
socket.on("close", () =>
|
|
@@ -195,6 +201,26 @@ class RpcSocket {
|
|
|
195
201
|
respond({ result: { pong: true } });
|
|
196
202
|
return;
|
|
197
203
|
}
|
|
204
|
+
if (message.method === "compact" && this.compaction) {
|
|
205
|
+
if (
|
|
206
|
+
Object.keys(params).some((key) => key !== "operation_id") ||
|
|
207
|
+
typeof params.operation_id !== "string" ||
|
|
208
|
+
!params.operation_id.trim()
|
|
209
|
+
) {
|
|
210
|
+
error(-32602, "compact requires an operation_id");
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
this.compaction.compact(params.operation_id);
|
|
215
|
+
respond({ result: { accepted: true } });
|
|
216
|
+
} catch (failure) {
|
|
217
|
+
error(
|
|
218
|
+
failure instanceof RpcError ? failure.code : -32006,
|
|
219
|
+
failure instanceof Error ? failure.message : String(failure),
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
198
224
|
if (this.lifecycle && (message.method === "interrupt" || message.method === "shutdown")) {
|
|
199
225
|
try {
|
|
200
226
|
this.lifecycle[message.method as "interrupt" | "shutdown"]();
|
|
@@ -310,6 +336,7 @@ export async function connectPi(
|
|
|
310
336
|
onReplay?: (inboxMessageId: string) => void,
|
|
311
337
|
lifecycle?: LifecycleControl,
|
|
312
338
|
history?: () => HistoryContext,
|
|
339
|
+
compaction?: CompactionControl,
|
|
313
340
|
): Promise<PiConnection> {
|
|
314
341
|
const path = piSocketPath(pontiaHome);
|
|
315
342
|
let identity: ControlIdentity | undefined;
|
|
@@ -381,6 +408,7 @@ export async function connectPi(
|
|
|
381
408
|
reportingOnly ? undefined : onReplay,
|
|
382
409
|
reportingOnly ? undefined : lifecycle,
|
|
383
410
|
reportingOnly ? undefined : history,
|
|
411
|
+
reportingOnly ? undefined : compaction,
|
|
384
412
|
);
|
|
385
413
|
peers.add(peer);
|
|
386
414
|
socket.once("close", () => {
|
package/src/events.ts
CHANGED
|
@@ -9,6 +9,9 @@ export type InternalEventType =
|
|
|
9
9
|
| "session.exited"
|
|
10
10
|
| "session.model_updated"
|
|
11
11
|
| "session.context_usage_updated"
|
|
12
|
+
| "session.compaction_started"
|
|
13
|
+
| "session.compaction_completed"
|
|
14
|
+
| "session.compaction_failed"
|
|
12
15
|
| "turn.started"
|
|
13
16
|
| "turn.output"
|
|
14
17
|
| "turn.completed"
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import type { HistoryContext } from "./history.js";
|
|
4
|
+
import { PiCompaction } from "./compaction.js";
|
|
4
5
|
import { connectPi, RpcError, type PiConnection, type ControlInput } from "./control-socket.js";
|
|
5
6
|
import { defaultHookLogFile, type EnvLike, type TurnContext } from "./context.js";
|
|
6
7
|
import { appendDiagnostic, type DiagnosticEntry } from "./diagnostics.js";
|
|
@@ -187,6 +188,13 @@ export function createPontiaPiExtension(
|
|
|
187
188
|
let controlGeneration = 0;
|
|
188
189
|
let piContext: ExtensionContext | undefined;
|
|
189
190
|
let changingModel = false;
|
|
191
|
+
const compaction = () => {
|
|
192
|
+
const context = boundSessionContext;
|
|
193
|
+
if (!context || reportingDisabled || !readyReported) return undefined;
|
|
194
|
+
return new PiCompaction(currentPontiaHome(), context, async (event) =>
|
|
195
|
+
reportAccepted(await makeReporter(currentHookLogFile()).report(context, event)),
|
|
196
|
+
);
|
|
197
|
+
};
|
|
190
198
|
const directInput = new AsyncLocalStorage<{
|
|
191
199
|
submission: ControlInput;
|
|
192
200
|
sessionContext: SessionContext;
|
|
@@ -317,6 +325,7 @@ export function createPontiaPiExtension(
|
|
|
317
325
|
if (generation !== controlGeneration)
|
|
318
326
|
throw new Error("Pi session changed during reconnect");
|
|
319
327
|
await reportModel();
|
|
328
|
+
await compaction()?.reconnect();
|
|
320
329
|
},
|
|
321
330
|
},
|
|
322
331
|
(inboxMessageId) => {
|
|
@@ -345,6 +354,26 @@ export function createPontiaPiExtension(
|
|
|
345
354
|
throw new Error("Pi history native Session identity changed");
|
|
346
355
|
return { generation, sessionManager: manager } satisfies HistoryContext;
|
|
347
356
|
},
|
|
357
|
+
{
|
|
358
|
+
compact(operationId) {
|
|
359
|
+
const ctx = lifecycleContext();
|
|
360
|
+
if (ctx.sessionManager.getSessionId() !== boundSessionContext?.clientSessionKey)
|
|
361
|
+
throw new RpcError(-32006, "Pi compaction native Session identity changed");
|
|
362
|
+
const observation = compaction()!;
|
|
363
|
+
if (!ctx.isIdle() || ctx.hasPendingMessages() || queuedControlInput || changingModel)
|
|
364
|
+
throw new RpcError(-32006, "Pi is busy; compaction was not requested");
|
|
365
|
+
observation.request(operationId, () =>
|
|
366
|
+
ctx.compact({
|
|
367
|
+
onComplete: (result) => {
|
|
368
|
+
void observation.callbackComplete(operationId, result).catch(controlError);
|
|
369
|
+
},
|
|
370
|
+
onError: (error) => {
|
|
371
|
+
void observation.callbackError(operationId, error).catch(controlError);
|
|
372
|
+
},
|
|
373
|
+
}),
|
|
374
|
+
);
|
|
375
|
+
},
|
|
376
|
+
},
|
|
348
377
|
);
|
|
349
378
|
if (generation !== controlGeneration) {
|
|
350
379
|
await socket.close();
|
|
@@ -519,6 +548,18 @@ export function createPontiaPiExtension(
|
|
|
519
548
|
await reportModel().catch(controlError);
|
|
520
549
|
});
|
|
521
550
|
|
|
551
|
+
pi.on("session_before_compact", async (event) => {
|
|
552
|
+
await compaction()?.before(event).catch(controlError);
|
|
553
|
+
});
|
|
554
|
+
pi.on("session_compact", async (event) => {
|
|
555
|
+
await compaction()?.completed(event).catch(controlError);
|
|
556
|
+
});
|
|
557
|
+
pi.on("session_compact_failed", async (event, ctx) => {
|
|
558
|
+
await compaction()
|
|
559
|
+
?.failed(event, leafIdFromHookContext(ctx ?? piContext))
|
|
560
|
+
.catch(controlError);
|
|
561
|
+
});
|
|
562
|
+
|
|
522
563
|
pi.on("session_start", async (event, ctx) => {
|
|
523
564
|
if (reportingDisabled) return;
|
|
524
565
|
const reason = (event as unknown as Record<string, unknown> | undefined)?.reason;
|
|
@@ -658,6 +699,7 @@ export function createPontiaPiExtension(
|
|
|
658
699
|
await makeReporter(logFile).report(context, buildSessionReadyEvent(context)),
|
|
659
700
|
);
|
|
660
701
|
await reportModel().catch(controlError);
|
|
702
|
+
await compaction()?.reconnect().catch(controlError);
|
|
661
703
|
} catch (error) {
|
|
662
704
|
await closeControlSocket();
|
|
663
705
|
const logFile = currentHookLogFile();
|