@pontia/pi-client-plugin 0.5.1 → 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.1",
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
+ }
@@ -1,7 +1,10 @@
1
+ import { RpcError } from "./rpc-error.js";
2
+ export { RpcError } from "./rpc-error.js";
3
+ import { readHistory, type HistoryContext } from "./history.js";
1
4
  import { createConnection, type Socket } from "node:net";
2
5
  import { isAbsolute, join } from "node:path";
3
6
 
4
- export const CONTROL_VERSION = 7;
7
+ export const CONTROL_VERSION = 8;
5
8
  export const MAX_CONTROL_FRAME_BYTES = 64 * 1024;
6
9
  // Match the former HTTP event body limit, including the RPC envelope.
7
10
  export const MAX_RPC_FRAME_BYTES = 2 * 1024 * 1024 + 1024;
@@ -30,18 +33,14 @@ export interface LifecycleControl {
30
33
  interrupt(): void;
31
34
  shutdown(): void;
32
35
  }
36
+ export interface CompactionControl {
37
+ compact(operationId: string): void;
38
+ }
33
39
  export interface PiConnection {
34
40
  request(method: string, params: object): Promise<unknown>;
35
41
  registered(identity: ControlIdentity): void;
36
42
  close(): Promise<void>;
37
43
  }
38
- export class RpcError extends Error {
39
- readonly code: number;
40
- constructor(code: number, message: string) {
41
- super(message);
42
- this.code = code;
43
- }
44
- }
45
44
 
46
45
  export function piSocketPath(pontiaHome: string): string {
47
46
  const path = join(pontiaHome, "state", "pi", "rpc.sock");
@@ -68,6 +67,8 @@ class RpcSocket {
68
67
  private onSubmit: (input: ControlInput) => void;
69
68
  private models?: ModelControl;
70
69
  private lifecycle?: LifecycleControl;
70
+ private history?: () => HistoryContext;
71
+ private compaction?: CompactionControl;
71
72
  private onReplay?: (inboxMessageId: string) => void;
72
73
 
73
74
  constructor(
@@ -76,12 +77,16 @@ class RpcSocket {
76
77
  models?: ModelControl,
77
78
  onReplay?: (inboxMessageId: string) => void,
78
79
  lifecycle?: LifecycleControl,
80
+ history?: () => HistoryContext,
81
+ compaction?: CompactionControl,
79
82
  ) {
80
83
  this.socket = socket;
81
84
  this.onSubmit = onSubmit;
82
85
  this.models = models;
83
86
  this.onReplay = onReplay;
84
87
  this.lifecycle = lifecycle;
88
+ this.history = history;
89
+ this.compaction = compaction;
85
90
  socket.on("data", (chunk: Buffer) => this.receive(chunk));
86
91
  socket.on("error", (error) => this.fail(error));
87
92
  socket.on("close", () =>
@@ -181,10 +186,41 @@ class RpcSocket {
181
186
  error(-32602, "Expected named parameters");
182
187
  return;
183
188
  }
189
+ if (message.method === "history.read" && this.history) {
190
+ try {
191
+ respond({ result: readHistory(this.history(), params) });
192
+ } catch (failure) {
193
+ error(
194
+ failure instanceof RpcError ? failure.code : -32009,
195
+ failure instanceof Error ? failure.message : String(failure),
196
+ );
197
+ }
198
+ return;
199
+ }
184
200
  if (message.method === "ping") {
185
201
  respond({ result: { pong: true } });
186
202
  return;
187
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
+ }
188
224
  if (this.lifecycle && (message.method === "interrupt" || message.method === "shutdown")) {
189
225
  try {
190
226
  this.lifecycle[message.method as "interrupt" | "shutdown"]();
@@ -299,6 +335,8 @@ export async function connectPi(
299
335
  models?: ModelControl,
300
336
  onReplay?: (inboxMessageId: string) => void,
301
337
  lifecycle?: LifecycleControl,
338
+ history?: () => HistoryContext,
339
+ compaction?: CompactionControl,
302
340
  ): Promise<PiConnection> {
303
341
  const path = piSocketPath(pontiaHome);
304
342
  let identity: ControlIdentity | undefined;
@@ -369,6 +407,8 @@ export async function connectPi(
369
407
  reportingOnly ? undefined : models,
370
408
  reportingOnly ? undefined : onReplay,
371
409
  reportingOnly ? undefined : lifecycle,
410
+ reportingOnly ? undefined : history,
411
+ reportingOnly ? undefined : compaction,
372
412
  );
373
413
  peers.add(peer);
374
414
  socket.once("close", () => {
package/src/events.ts CHANGED
@@ -8,8 +8,10 @@ export type InternalEventType =
8
8
  | "session.ready"
9
9
  | "session.exited"
10
10
  | "session.model_updated"
11
- | "session.message_updated"
12
11
  | "session.context_usage_updated"
12
+ | "session.compaction_started"
13
+ | "session.compaction_completed"
14
+ | "session.compaction_failed"
13
15
  | "turn.started"
14
16
  | "turn.output"
15
17
  | "turn.completed"
@@ -32,8 +34,6 @@ export interface ContextUsageObservation {
32
34
  model: string | null;
33
35
  }
34
36
 
35
- export type SessionMessageUpdatedReason = "append" | "update" | "final";
36
-
37
37
  export type PiTopologyEntryKind =
38
38
  | "user_message"
39
39
  | "assistant_message"
@@ -76,17 +76,6 @@ function turnFact(
76
76
  return { session_id: context.sessionId, turn_id: context.turnId, type, data };
77
77
  }
78
78
 
79
- export function buildSessionMessageUpdatedEvent(
80
- context: TurnContext,
81
- reason: SessionMessageUpdatedReason,
82
- ): InternalEvent {
83
- return {
84
- session_id: context.sessionId,
85
- type: "session.message_updated",
86
- data: { reason },
87
- };
88
- }
89
-
90
79
  export function buildSessionContextUsageUpdatedEvent(
91
80
  context: TurnContext,
92
81
  usage: ContextUsagePayload,
package/src/history.ts ADDED
@@ -0,0 +1,143 @@
1
+ import { RpcError } from "./rpc-error.js";
2
+
3
+ const PAGE_BYTES = 1024 * 1024;
4
+ export const MAX_HISTORY_ENTRIES = 100_000;
5
+ export interface HistoryContext {
6
+ generation: number;
7
+ sessionManager: {
8
+ getSessionId(): string;
9
+ getEntries(): unknown[];
10
+ getLeafId(): string | null;
11
+ };
12
+ }
13
+ interface Snapshot {
14
+ session_id: string;
15
+ generation: number;
16
+ entry_count: number;
17
+ upper_entry_id: string | null;
18
+ leaf_id: string | null;
19
+ }
20
+ const invalid = (message: string): never => {
21
+ throw new RpcError(-32602, message);
22
+ };
23
+ const token = (value: unknown): string => Buffer.from(JSON.stringify(value)).toString("base64url");
24
+ function decode(value: unknown): Record<string, unknown> {
25
+ if (typeof value !== "string" || value.length > 4096)
26
+ return invalid("Invalid history continuation");
27
+ try {
28
+ const parsed = JSON.parse(Buffer.from(value, "base64url").toString());
29
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || token(parsed) !== value)
30
+ return invalid("Invalid history continuation");
31
+ return parsed;
32
+ } catch {
33
+ return invalid("Invalid history continuation");
34
+ }
35
+ }
36
+ const identity = (value: unknown): value is string =>
37
+ typeof value === "string" && !!value.trim() && value.length <= 512;
38
+
39
+ /** Reads the current canonical manager; tokens contain identities, never file positions. */
40
+ export function readHistory(context: HistoryContext, params: Record<string, unknown>): object {
41
+ if (Object.keys(params).some((key) => !["page_size", "snapshot", "continuation"].includes(key)))
42
+ return invalid("Unknown history parameter");
43
+ const size = params.page_size === undefined ? 64 : params.page_size;
44
+ if (!Number.isInteger(size) || (size as number) < 1 || (size as number) > 128)
45
+ return invalid("Invalid history page size");
46
+ const manager = context.sessionManager;
47
+ const sessionId = manager.getSessionId();
48
+ if (!identity(sessionId)) return invalid("Invalid native Session identity");
49
+ const entries = manager.getEntries() as Record<string, unknown>[];
50
+ if (!Array.isArray(entries) || entries.length > MAX_HISTORY_ENTRIES)
51
+ return invalid("History snapshot exceeds entry limit");
52
+ let snapshot: Snapshot;
53
+ if (params.snapshot === undefined) {
54
+ if (params.continuation !== undefined) return invalid("Continuation requires snapshot");
55
+ snapshot = {
56
+ session_id: sessionId,
57
+ generation: context.generation,
58
+ entry_count: entries.length,
59
+ upper_entry_id: entries.length ? (entries[entries.length - 1].id as string) : null,
60
+ leaf_id: manager.getLeafId(),
61
+ };
62
+ } else {
63
+ const parsed = decode(params.snapshot);
64
+ if (
65
+ Object.keys(parsed).sort().join() !==
66
+ "entry_count,generation,leaf_id,session_id,upper_entry_id" ||
67
+ !Number.isInteger(parsed.entry_count) ||
68
+ (parsed.entry_count as number) < 0 ||
69
+ (parsed.entry_count as number) > MAX_HISTORY_ENTRIES ||
70
+ parsed.session_id !== sessionId ||
71
+ parsed.generation !== context.generation ||
72
+ !(parsed.upper_entry_id === null || identity(parsed.upper_entry_id)) ||
73
+ !(parsed.leaf_id === null || identity(parsed.leaf_id))
74
+ )
75
+ return invalid("History snapshot identity changed");
76
+ snapshot = parsed as unknown as Snapshot;
77
+ }
78
+ const upper =
79
+ snapshot.upper_entry_id === null
80
+ ? -1
81
+ : entries.findIndex((entry) => entry?.id === snapshot.upper_entry_id);
82
+ if (
83
+ snapshot.upper_entry_id !== null &&
84
+ entries.filter((entry) => entry?.id === snapshot.upper_entry_id).length !== 1
85
+ )
86
+ return invalid("Duplicate history upper identity");
87
+ if (snapshot.upper_entry_id !== null && upper < 0) return invalid("Invalid history upper bound");
88
+ if (upper + 1 !== snapshot.entry_count) return invalid("History snapshot entry count changed");
89
+ const ids = new Set<string>();
90
+ for (let index = 0; index <= upper; index++) {
91
+ const entry = entries[index];
92
+ if (
93
+ !entry ||
94
+ !identity(entry.id) ||
95
+ ids.has(entry.id) ||
96
+ !identity(entry.type) ||
97
+ !identity(entry.timestamp) ||
98
+ !(entry.parentId === null || identity(entry.parentId)) ||
99
+ (entry.parentId !== null && !ids.has(entry.parentId as string))
100
+ )
101
+ return invalid("Invalid history entry identity or parent");
102
+ ids.add(entry.id);
103
+ }
104
+ if (snapshot.leaf_id !== null && !ids.has(snapshot.leaf_id))
105
+ return invalid("History leaf is outside snapshot");
106
+ const snapshotToken = token(snapshot);
107
+ let start = 0;
108
+ if (params.continuation !== undefined) {
109
+ const continuation = decode(params.continuation);
110
+ if (
111
+ Object.keys(continuation).sort().join() !== "after,snapshot" ||
112
+ continuation.snapshot !== snapshotToken ||
113
+ !identity(continuation.after)
114
+ )
115
+ return invalid("Invalid history continuation");
116
+ const after = entries.findIndex((entry) => entry?.id === continuation.after);
117
+ if (after < 0 || after >= upper) return invalid("History continuation outside snapshot");
118
+ start = after + 1;
119
+ } else if (params.snapshot !== undefined) return invalid("Snapshot requires continuation");
120
+ const page: unknown[] = [];
121
+ let bytes = 0;
122
+ for (let index = start; index <= upper && page.length < (size as number); index++) {
123
+ const entry = entries[index];
124
+ const encoded = JSON.stringify(entry);
125
+ const length = Buffer.byteLength(encoded);
126
+ if (length > PAGE_BYTES - 8192) return invalid("History entry exceeds response budget");
127
+ if (bytes + length > PAGE_BYTES - 8192) break;
128
+ page.push(entry);
129
+ bytes += length + 1;
130
+ }
131
+ const end = start + page.length;
132
+ const continuation =
133
+ end <= upper ? token({ snapshot: snapshotToken, after: entries[end - 1].id }) : null;
134
+ return {
135
+ session_id: sessionId,
136
+ snapshot: snapshotToken,
137
+ entry_count: snapshot.entry_count,
138
+ upper_entry_id: snapshot.upper_entry_id,
139
+ leaf_id: snapshot.leaf_id,
140
+ entries: page,
141
+ continuation,
142
+ };
143
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import type { HistoryContext } from "./history.js";
4
+ import { PiCompaction } from "./compaction.js";
3
5
  import { connectPi, RpcError, type PiConnection, type ControlInput } from "./control-socket.js";
4
6
  import { defaultHookLogFile, type EnvLike, type TurnContext } from "./context.js";
5
7
  import { appendDiagnostic, type DiagnosticEntry } from "./diagnostics.js";
@@ -7,7 +9,6 @@ import { pontiaHomeFromEnv } from "./discovery.js";
7
9
  import {
8
10
  buildSessionContextUsageUpdatedEvent,
9
11
  buildSessionExitedEvent,
10
- buildSessionMessageUpdatedEvent,
11
12
  buildSessionReadyEvent,
12
13
  buildTurnCompletedEvent,
13
14
  buildTurnFailedEvent,
@@ -18,7 +19,6 @@ import {
18
19
  type InternalEvent,
19
20
  type PiTopologyContext,
20
21
  type PiTopologyEntryKind,
21
- type SessionMessageUpdatedReason,
22
22
  } from "./events.js";
23
23
  import { asRecord, optionalString } from "./values.js";
24
24
  import {
@@ -37,7 +37,6 @@ import {
37
37
  assistantDeltaFromEvent,
38
38
  assistantTextFromMessage,
39
39
  errorMessageFromAgentEnd,
40
- isTranscriptBoundaryMessageUpdate,
41
40
  lastAssistantTextFromMessages,
42
41
  } from "./pi-message.js";
43
42
  import { loadProfileSystemPrompt } from "./profile.js";
@@ -189,6 +188,13 @@ export function createPontiaPiExtension(
189
188
  let controlGeneration = 0;
190
189
  let piContext: ExtensionContext | undefined;
191
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
+ };
192
198
  const directInput = new AsyncLocalStorage<{
193
199
  submission: ControlInput;
194
200
  sessionContext: SessionContext;
@@ -319,6 +325,7 @@ export function createPontiaPiExtension(
319
325
  if (generation !== controlGeneration)
320
326
  throw new Error("Pi session changed during reconnect");
321
327
  await reportModel();
328
+ await compaction()?.reconnect();
322
329
  },
323
330
  },
324
331
  (inboxMessageId) => {
@@ -340,6 +347,33 @@ export function createPontiaPiExtension(
340
347
  ctx.shutdown();
341
348
  },
342
349
  },
350
+ () => {
351
+ lifecycleContext();
352
+ const manager = piContext!.sessionManager;
353
+ if (manager.getSessionId() !== boundSessionContext?.clientSessionKey)
354
+ throw new Error("Pi history native Session identity changed");
355
+ return { generation, sessionManager: manager } satisfies HistoryContext;
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
+ },
343
377
  );
344
378
  if (generation !== controlGeneration) {
345
379
  await socket.close();
@@ -475,21 +509,6 @@ export function createPontiaPiExtension(
475
509
  },
476
510
  });
477
511
 
478
- async function scheduleMessageRefresh(reason: SessionMessageUpdatedReason): Promise<void> {
479
- if (!activeTurn || activeTurn.ended) return;
480
- await activeTurn.reporter.report(
481
- activeTurn.context,
482
- buildSessionMessageUpdatedEvent(activeTurn.context, reason),
483
- );
484
- }
485
-
486
- async function reportFinalMessageRefresh(state: ActiveTurnState): Promise<void> {
487
- await state.reporter.report(
488
- state.context,
489
- buildSessionMessageUpdatedEvent(state.context, "final"),
490
- );
491
- }
492
-
493
512
  async function reportContextUsageFromHookEvent(event: unknown, ctx?: unknown): Promise<void> {
494
513
  if (!activeTurn || activeTurn.ended) return;
495
514
  const observation = contextUsageFromPiHook(event, ctx);
@@ -529,6 +548,18 @@ export function createPontiaPiExtension(
529
548
  await reportModel().catch(controlError);
530
549
  });
531
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
+
532
563
  pi.on("session_start", async (event, ctx) => {
533
564
  if (reportingDisabled) return;
534
565
  const reason = (event as unknown as Record<string, unknown> | undefined)?.reason;
@@ -668,6 +699,7 @@ export function createPontiaPiExtension(
668
699
  await makeReporter(logFile).report(context, buildSessionReadyEvent(context)),
669
700
  );
670
701
  await reportModel().catch(controlError);
702
+ await compaction()?.reconnect().catch(controlError);
671
703
  } catch (error) {
672
704
  await closeControlSocket();
673
705
  const logFile = currentHookLogFile();
@@ -836,16 +868,6 @@ export function createPontiaPiExtension(
836
868
  } else if (delta) {
837
869
  activeTurn.output += delta;
838
870
  }
839
-
840
- if (isTranscriptBoundaryMessageUpdate(event)) await scheduleMessageRefresh("update");
841
- });
842
-
843
- pi.on("tool_execution_start", async (event) => {
844
- if (!event.parentToolCallId) await scheduleMessageRefresh("update");
845
- });
846
-
847
- pi.on("tool_execution_end", async (event) => {
848
- if (!event.parentToolCallId) await scheduleMessageRefresh("update");
849
871
  });
850
872
 
851
873
  pi.on("message_end", async (event, ctx) => {
@@ -855,7 +877,6 @@ export function createPontiaPiExtension(
855
877
  (event as unknown as Record<string, unknown> | undefined)?.message,
856
878
  );
857
879
  if (fullText) activeTurn.output = fullText;
858
- await scheduleMessageRefresh("append");
859
880
  });
860
881
 
861
882
  pi.on("agent_settled", (_event, ctx) => {
@@ -878,7 +899,6 @@ export function createPontiaPiExtension(
878
899
  state.context,
879
900
  buildTurnInterruptedEvent(state.context, terminalLeafId),
880
901
  );
881
- await reportFinalMessageRefresh(state);
882
902
  return;
883
903
  }
884
904
 
@@ -888,7 +908,6 @@ export function createPontiaPiExtension(
888
908
  state.context,
889
909
  buildTurnFailedEvent(state.context, failureMessage, terminalLeafId),
890
910
  );
891
- await reportFinalMessageRefresh(state);
892
911
  return;
893
912
  }
894
913
 
@@ -912,7 +931,6 @@ export function createPontiaPiExtension(
912
931
  state.context,
913
932
  buildTurnCompletedEvent(state.context, terminalLeafId),
914
933
  );
915
- await reportFinalMessageRefresh(state);
916
934
  } finally {
917
935
  await state.liveOutput.close();
918
936
  }
package/src/pi-message.ts CHANGED
@@ -36,23 +36,6 @@ export function assistantDeltaFromEvent(event: unknown): string | undefined {
36
36
  return undefined;
37
37
  }
38
38
 
39
- const transcriptBoundaryStreamEventTypes = new Set([
40
- "thinking_start",
41
- "thinking_end",
42
- "text_start",
43
- "text_end",
44
- "toolcall_start",
45
- "toolcall_end",
46
- ]);
47
-
48
- export function isTranscriptBoundaryMessageUpdate(event: unknown): boolean {
49
- if (!event || typeof event !== "object") return false;
50
- const streamEvent = (event as Record<string, unknown>).assistantMessageEvent;
51
- if (!streamEvent || typeof streamEvent !== "object") return false;
52
- const type = (streamEvent as Record<string, unknown>).type;
53
- return typeof type === "string" && transcriptBoundaryStreamEventTypes.has(type);
54
- }
55
-
56
39
  interface PiAgentEndEventLike {
57
40
  messages: unknown[];
58
41
  }
@@ -0,0 +1,7 @@
1
+ export class RpcError extends Error {
2
+ readonly code: number;
3
+ constructor(code: number, message: string) {
4
+ super(message);
5
+ this.code = code;
6
+ }
7
+ }