@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 +1 -1
- package/src/compaction.ts +225 -0
- package/src/control-socket.ts +48 -8
- package/src/events.ts +3 -14
- package/src/history.ts +143 -0
- package/src/index.ts +50 -32
- package/src/pi-message.ts +0 -17
- package/src/rpc-error.ts +7 -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
|
@@ -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
|
+
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
|
}
|