@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pontia/pi-client-plugin",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "Pi extension that connects coding-agent sessions to Pontia.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -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
+ }
@@ -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;
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();