@pontia/pi-client-plugin 0.1.0

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/src/events.ts ADDED
@@ -0,0 +1,325 @@
1
+ import type { TurnContext } from "./context.js";
2
+ import type { SessionContext } from "./session.js";
3
+
4
+ const MAX_TURN_OUTPUT_CHARS = 200;
5
+
6
+ export type InternalEventType = "session.ready" | "session.exited" | "session.message_updated" | "session.context_usage_updated" | "turn.started" | "turn.output" | "turn.completed" | "turn.failed" | "turn.interrupted";
7
+
8
+ export interface ContextUsagePayload {
9
+ used_tokens: number | null;
10
+ max_tokens: number | null;
11
+ remaining_tokens: number | null;
12
+ usage_ratio: number | null;
13
+ input_tokens: number | null;
14
+ output_tokens: number | null;
15
+ cache_tokens: number | null;
16
+ confidence: "exact" | "estimated" | "unknown";
17
+ }
18
+
19
+ export interface ContextUsageObservation {
20
+ context_usage: ContextUsagePayload;
21
+ model: string | null;
22
+ }
23
+
24
+ export type SessionMessageUpdatedReason = "append" | "update" | "final";
25
+
26
+ export type PiTopologyEntryKind =
27
+ | "user_message"
28
+ | "assistant_message"
29
+ | "tool_result_message"
30
+ | "other_message"
31
+ | "thinking_level_change"
32
+ | "model_change"
33
+ | "compaction"
34
+ | "branch_summary"
35
+ | "custom"
36
+ | "custom_message"
37
+ | "label"
38
+ | "session_info"
39
+ | "other";
40
+
41
+ export interface PiTopologyContextEntry {
42
+ id: string;
43
+ kind: PiTopologyEntryKind;
44
+ }
45
+
46
+ export interface PiTopologyContext {
47
+ entries: PiTopologyContextEntry[];
48
+ }
49
+
50
+ export interface InternalEvent {
51
+ session_id: string;
52
+ turn_id?: string;
53
+ type: InternalEventType;
54
+ data: Record<string, unknown>;
55
+ [legacyField: string]: any;
56
+ }
57
+
58
+ type ActiveTurnContext = TurnContext & { turnId: string };
59
+
60
+ function turnFact(context: ActiveTurnContext, type: InternalEventType, data: Record<string, unknown>): InternalEvent {
61
+ return { session_id: context.sessionId, turn_id: context.turnId, type, data };
62
+ }
63
+
64
+ export function buildSessionMessageUpdatedEvent(context: TurnContext, reason: SessionMessageUpdatedReason): InternalEvent {
65
+ return {
66
+ session_id: context.sessionId,
67
+ type: "session.message_updated",
68
+ data: { reason },
69
+ };
70
+ }
71
+
72
+ export function buildSessionContextUsageUpdatedEvent(context: TurnContext, usage: ContextUsagePayload, model?: string | null): InternalEvent {
73
+ const contextUsage: Record<string, unknown> = {};
74
+ for (const [key, value] of Object.entries(usage)) {
75
+ if (value !== null) contextUsage[key] = value;
76
+ }
77
+ const payload: Record<string, unknown> = { context_usage: contextUsage };
78
+ if (model !== undefined && model !== null) payload.model = model;
79
+ return {
80
+ session_id: context.sessionId,
81
+ ...(context.turnId ? { turn_id: context.turnId } : {}),
82
+ type: "session.context_usage_updated",
83
+ data: payload,
84
+ };
85
+ }
86
+
87
+ function optionalNonNegativeInteger(value: unknown): number | null | undefined {
88
+ if (value == null) return null;
89
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : undefined;
90
+ }
91
+
92
+ function optionalRatio(value: unknown): number | null | undefined {
93
+ if (value == null) return null;
94
+ return typeof value === "number" && value >= 0 && value <= 1 ? value : undefined;
95
+ }
96
+
97
+ function optionalNullableString(value: unknown): string | null | undefined {
98
+ if (value == null) return null;
99
+ return typeof value === "string" ? value : undefined;
100
+ }
101
+
102
+ function confidence(value: unknown): ContextUsagePayload["confidence"] | undefined {
103
+ if (value == null) return "unknown";
104
+ return value === "exact" || value === "estimated" || value === "unknown" ? value : undefined;
105
+ }
106
+
107
+ function modelNameFromRecord(record: Record<string, unknown>): string | null | undefined {
108
+ const direct = optionalNullableString(record.model);
109
+ if (direct !== undefined) return direct;
110
+ const model = record.model;
111
+ if (!model || typeof model !== "object" || Array.isArray(model)) return null;
112
+ const modelRecord = model as Record<string, unknown>;
113
+ return optionalNullableString(modelRecord.id) ?? optionalNullableString(modelRecord.modelId) ?? optionalNullableString(modelRecord.name);
114
+ }
115
+
116
+ function hasObservedUsageValue(usage: ContextUsagePayload): boolean {
117
+ return usage.used_tokens !== null || usage.max_tokens !== null || usage.remaining_tokens !== null || usage.usage_ratio !== null || usage.input_tokens !== null || usage.output_tokens !== null || usage.cache_tokens !== null;
118
+ }
119
+
120
+ function contextUsageFromPontiaShape(raw: unknown): ContextUsagePayload | undefined {
121
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
122
+ const record = raw as Record<string, unknown>;
123
+ const used_tokens = optionalNonNegativeInteger(record.used_tokens);
124
+ const max_tokens = optionalNonNegativeInteger(record.max_tokens);
125
+ const remaining_tokens = optionalNonNegativeInteger(record.remaining_tokens);
126
+ const usage_ratio = optionalRatio(record.usage_ratio);
127
+ const input_tokens = optionalNonNegativeInteger(record.input_tokens);
128
+ const output_tokens = optionalNonNegativeInteger(record.output_tokens);
129
+ const cache_tokens = optionalNonNegativeInteger(record.cache_tokens);
130
+ const parsedConfidence = confidence(record.confidence);
131
+
132
+ if (
133
+ used_tokens === undefined ||
134
+ max_tokens === undefined ||
135
+ remaining_tokens === undefined ||
136
+ usage_ratio === undefined ||
137
+ input_tokens === undefined ||
138
+ output_tokens === undefined ||
139
+ cache_tokens === undefined ||
140
+ parsedConfidence === undefined
141
+ ) {
142
+ return undefined;
143
+ }
144
+
145
+ const usage = {
146
+ used_tokens,
147
+ max_tokens,
148
+ remaining_tokens,
149
+ usage_ratio,
150
+ input_tokens,
151
+ output_tokens,
152
+ cache_tokens,
153
+ confidence: parsedConfidence,
154
+ };
155
+ return hasObservedUsageValue(usage) ? usage : undefined;
156
+ }
157
+
158
+ function contextUsageFromPiMessage(message: unknown): ContextUsageObservation | undefined {
159
+ if (!message || typeof message !== "object" || Array.isArray(message)) return undefined;
160
+ const messageRecord = message as Record<string, unknown>;
161
+ if (messageRecord.role !== "assistant") return undefined;
162
+ const usage = messageRecord.usage;
163
+ if (!usage || typeof usage !== "object" || Array.isArray(usage)) return undefined;
164
+ const usageRecord = usage as Record<string, unknown>;
165
+ const input_tokens = optionalNonNegativeInteger(usageRecord.input);
166
+ const output_tokens = optionalNonNegativeInteger(usageRecord.output);
167
+ const cache_read_tokens = optionalNonNegativeInteger(usageRecord.cacheRead);
168
+ const cache_write_tokens = optionalNonNegativeInteger(usageRecord.cacheWrite);
169
+ const used_tokens = optionalNonNegativeInteger(usageRecord.totalTokens);
170
+ const model = modelNameFromRecord(messageRecord);
171
+
172
+ if (
173
+ input_tokens === undefined ||
174
+ output_tokens === undefined ||
175
+ cache_read_tokens === undefined ||
176
+ cache_write_tokens === undefined ||
177
+ used_tokens === undefined ||
178
+ model === undefined
179
+ ) {
180
+ return undefined;
181
+ }
182
+
183
+ const context_usage: ContextUsagePayload = {
184
+ used_tokens,
185
+ max_tokens: null,
186
+ remaining_tokens: null,
187
+ usage_ratio: null,
188
+ input_tokens,
189
+ output_tokens,
190
+ cache_tokens: cache_read_tokens === null && cache_write_tokens === null ? null : (cache_read_tokens ?? 0) + (cache_write_tokens ?? 0),
191
+ confidence: "estimated",
192
+ };
193
+ return hasObservedUsageValue(context_usage) ? { context_usage, model } : undefined;
194
+ }
195
+
196
+ export function contextUsageFromPiContext(ctx: unknown): ContextUsageObservation | undefined {
197
+ if (!ctx || typeof ctx !== "object" || Array.isArray(ctx)) return undefined;
198
+ const record = ctx as Record<string, unknown>;
199
+ const getContextUsage = record.getContextUsage;
200
+ if (typeof getContextUsage !== "function") return undefined;
201
+
202
+ let rawUsage: unknown;
203
+ try {
204
+ rawUsage = getContextUsage.call(ctx);
205
+ } catch {
206
+ return undefined;
207
+ }
208
+ if (!rawUsage || typeof rawUsage !== "object" || Array.isArray(rawUsage)) return undefined;
209
+ const usage = rawUsage as Record<string, unknown>;
210
+ const used_tokens = optionalNonNegativeInteger(usage.tokens);
211
+ const max_tokens = optionalNonNegativeInteger(usage.contextWindow);
212
+ const percent = usage.percent == null ? null : typeof usage.percent === "number" && usage.percent >= 0 && usage.percent <= 100 ? usage.percent : undefined;
213
+ const model = modelNameFromRecord(record);
214
+
215
+ if (used_tokens === undefined || max_tokens === undefined || percent === undefined || model === undefined) {
216
+ return undefined;
217
+ }
218
+
219
+ const context_usage: ContextUsagePayload = {
220
+ used_tokens,
221
+ max_tokens,
222
+ remaining_tokens: used_tokens !== null && max_tokens !== null ? Math.max(0, max_tokens - used_tokens) : null,
223
+ usage_ratio: percent === null ? null : percent / 100,
224
+ input_tokens: null,
225
+ output_tokens: null,
226
+ cache_tokens: null,
227
+ confidence: "estimated",
228
+ };
229
+ return hasObservedUsageValue(context_usage) ? { context_usage, model } : undefined;
230
+ }
231
+
232
+ export function contextUsageFromPiEvent(event: unknown): ContextUsageObservation | undefined {
233
+ if (!event || typeof event !== "object" || Array.isArray(event)) return undefined;
234
+ const record = event as Record<string, unknown>;
235
+ const context_usage = contextUsageFromPontiaShape(record.context_usage);
236
+ if (context_usage) return { context_usage, model: modelNameFromRecord(record) ?? null };
237
+ return contextUsageFromPiMessage(record.message);
238
+ }
239
+
240
+ function mergeContextUsageObservations(primary: ContextUsageObservation | undefined, secondary: ContextUsageObservation | undefined): ContextUsageObservation | undefined {
241
+ if (!primary) return secondary;
242
+ if (!secondary) return primary;
243
+ const context_usage: ContextUsagePayload = {
244
+ used_tokens: secondary.context_usage.used_tokens ?? primary.context_usage.used_tokens,
245
+ max_tokens: secondary.context_usage.max_tokens ?? primary.context_usage.max_tokens,
246
+ remaining_tokens: secondary.context_usage.remaining_tokens ?? primary.context_usage.remaining_tokens,
247
+ usage_ratio: secondary.context_usage.usage_ratio ?? primary.context_usage.usage_ratio,
248
+ input_tokens: primary.context_usage.input_tokens ?? secondary.context_usage.input_tokens,
249
+ output_tokens: primary.context_usage.output_tokens ?? secondary.context_usage.output_tokens,
250
+ cache_tokens: primary.context_usage.cache_tokens ?? secondary.context_usage.cache_tokens,
251
+ confidence: primary.context_usage.confidence === "exact" || secondary.context_usage.confidence === "exact" ? "exact" : primary.context_usage.confidence === "estimated" || secondary.context_usage.confidence === "estimated" ? "estimated" : "unknown",
252
+ };
253
+ return hasObservedUsageValue(context_usage) ? { context_usage, model: primary.model ?? secondary.model } : undefined;
254
+ }
255
+
256
+ export function contextUsageFromPiHook(event: unknown, ctx?: unknown): ContextUsageObservation | undefined {
257
+ return mergeContextUsageObservations(contextUsageFromPiEvent(event), contextUsageFromPiContext(ctx));
258
+ }
259
+
260
+ export function buildTurnStartedEvent(
261
+ context: TurnContext,
262
+ previousLeafId: string | null = null,
263
+ topologyContext?: PiTopologyContext,
264
+ ): InternalEvent {
265
+ const data: Record<string, unknown> = {
266
+ runtime_instance_id: context.runtimeInstanceId,
267
+ input_summary: context.input,
268
+ previous_leaf_id: previousLeafId,
269
+ };
270
+ if (context.inboxMessageId) data.inbox_message_id = context.inboxMessageId;
271
+ if (topologyContext) data.topology_context = topologyContext;
272
+ return { session_id: context.sessionId, type: "turn.started", data };
273
+ }
274
+
275
+ export function buildTurnOutputEvent(context: ActiveTurnContext, output: string): InternalEvent {
276
+ return turnFact(context, "turn.output", {
277
+ output_summary: Array.from(output).slice(0, MAX_TURN_OUTPUT_CHARS).join(""),
278
+ });
279
+ }
280
+
281
+ export function buildTurnCompletedEvent(context: ActiveTurnContext, terminalLeafId: string | null = null): InternalEvent {
282
+ return turnFact(context, "turn.completed", {
283
+ terminal_leaf_id: terminalLeafId,
284
+ });
285
+ }
286
+
287
+ export function buildTurnFailedEvent(context: ActiveTurnContext, message: string, terminalLeafId: string | null = null): InternalEvent {
288
+ return turnFact(context, "turn.failed", {
289
+ failure_message: message,
290
+ terminal_leaf_id: terminalLeafId,
291
+ });
292
+ }
293
+
294
+ export function buildTurnInterruptedEvent(context: ActiveTurnContext, terminalLeafId: string | null = null): InternalEvent {
295
+ return turnFact(context, "turn.interrupted", {
296
+ terminal_leaf_id: terminalLeafId,
297
+ });
298
+ }
299
+
300
+ export function buildSessionReadyEvent(context: SessionContext): InternalEvent {
301
+ const payload: Record<string, unknown> = {
302
+ runtime_instance_id: context.runtimeInstanceId,
303
+ };
304
+ if (context.clientSessionKey) payload.client_session_key = context.clientSessionKey;
305
+ if (context.clientSessionFile) payload.client_session_file = context.clientSessionFile;
306
+ if (context.clientSessionDir) payload.client_session_dir = context.clientSessionDir;
307
+ if (context.clientCwd) payload.client_cwd = context.clientCwd;
308
+
309
+ return {
310
+ session_id: context.sessionId,
311
+ type: "session.ready",
312
+ data: payload,
313
+ };
314
+ }
315
+
316
+ export function buildSessionExitedEvent(context: SessionContext, reason: string): InternalEvent {
317
+ return {
318
+ session_id: context.sessionId,
319
+ type: "session.exited",
320
+ data: {
321
+ reason,
322
+ runtime_instance_id: context.runtimeInstanceId,
323
+ },
324
+ };
325
+ }