@pontia/pi-client-plugin 0.5.0 → 0.5.2

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.0",
3
+ "version": "0.5.2",
4
4
  "description": "Pi extension that connects coding-agent sessions to Pontia.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1,3 +1,6 @@
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
 
@@ -35,13 +38,6 @@ export interface PiConnection {
35
38
  registered(identity: ControlIdentity): void;
36
39
  close(): Promise<void>;
37
40
  }
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
41
 
46
42
  export function piSocketPath(pontiaHome: string): string {
47
43
  const path = join(pontiaHome, "state", "pi", "rpc.sock");
@@ -68,6 +64,7 @@ class RpcSocket {
68
64
  private onSubmit: (input: ControlInput) => void;
69
65
  private models?: ModelControl;
70
66
  private lifecycle?: LifecycleControl;
67
+ private history?: () => HistoryContext;
71
68
  private onReplay?: (inboxMessageId: string) => void;
72
69
 
73
70
  constructor(
@@ -76,12 +73,14 @@ class RpcSocket {
76
73
  models?: ModelControl,
77
74
  onReplay?: (inboxMessageId: string) => void,
78
75
  lifecycle?: LifecycleControl,
76
+ history?: () => HistoryContext,
79
77
  ) {
80
78
  this.socket = socket;
81
79
  this.onSubmit = onSubmit;
82
80
  this.models = models;
83
81
  this.onReplay = onReplay;
84
82
  this.lifecycle = lifecycle;
83
+ this.history = history;
85
84
  socket.on("data", (chunk: Buffer) => this.receive(chunk));
86
85
  socket.on("error", (error) => this.fail(error));
87
86
  socket.on("close", () =>
@@ -181,6 +180,17 @@ class RpcSocket {
181
180
  error(-32602, "Expected named parameters");
182
181
  return;
183
182
  }
183
+ if (message.method === "history.read" && this.history) {
184
+ try {
185
+ respond({ result: readHistory(this.history(), params) });
186
+ } catch (failure) {
187
+ error(
188
+ failure instanceof RpcError ? failure.code : -32009,
189
+ failure instanceof Error ? failure.message : String(failure),
190
+ );
191
+ }
192
+ return;
193
+ }
184
194
  if (message.method === "ping") {
185
195
  respond({ result: { pong: true } });
186
196
  return;
@@ -299,6 +309,7 @@ export async function connectPi(
299
309
  models?: ModelControl,
300
310
  onReplay?: (inboxMessageId: string) => void,
301
311
  lifecycle?: LifecycleControl,
312
+ history?: () => HistoryContext,
302
313
  ): Promise<PiConnection> {
303
314
  const path = piSocketPath(pontiaHome);
304
315
  let identity: ControlIdentity | undefined;
@@ -369,6 +380,7 @@ export async function connectPi(
369
380
  reportingOnly ? undefined : models,
370
381
  reportingOnly ? undefined : onReplay,
371
382
  reportingOnly ? undefined : lifecycle,
383
+ reportingOnly ? undefined : history,
372
384
  );
373
385
  peers.add(peer);
374
386
  socket.once("close", () => {
package/src/events.ts CHANGED
@@ -8,7 +8,6 @@ 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"
13
12
  | "turn.started"
14
13
  | "turn.output"
@@ -32,8 +31,6 @@ export interface ContextUsageObservation {
32
31
  model: string | null;
33
32
  }
34
33
 
35
- export type SessionMessageUpdatedReason = "append" | "update" | "final";
36
-
37
34
  export type PiTopologyEntryKind =
38
35
  | "user_message"
39
36
  | "assistant_message"
@@ -76,17 +73,6 @@ function turnFact(
76
73
  return { session_id: context.sessionId, turn_id: context.turnId, type, data };
77
74
  }
78
75
 
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
76
  export function buildSessionContextUsageUpdatedEvent(
91
77
  context: TurnContext,
92
78
  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,6 @@
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";
3
4
  import { connectPi, RpcError, type PiConnection, type ControlInput } from "./control-socket.js";
4
5
  import { defaultHookLogFile, type EnvLike, type TurnContext } from "./context.js";
5
6
  import { appendDiagnostic, type DiagnosticEntry } from "./diagnostics.js";
@@ -7,7 +8,6 @@ import { pontiaHomeFromEnv } from "./discovery.js";
7
8
  import {
8
9
  buildSessionContextUsageUpdatedEvent,
9
10
  buildSessionExitedEvent,
10
- buildSessionMessageUpdatedEvent,
11
11
  buildSessionReadyEvent,
12
12
  buildTurnCompletedEvent,
13
13
  buildTurnFailedEvent,
@@ -18,7 +18,6 @@ import {
18
18
  type InternalEvent,
19
19
  type PiTopologyContext,
20
20
  type PiTopologyEntryKind,
21
- type SessionMessageUpdatedReason,
22
21
  } from "./events.js";
23
22
  import { asRecord, optionalString } from "./values.js";
24
23
  import {
@@ -37,7 +36,6 @@ import {
37
36
  assistantDeltaFromEvent,
38
37
  assistantTextFromMessage,
39
38
  errorMessageFromAgentEnd,
40
- isTranscriptBoundaryMessageUpdate,
41
39
  lastAssistantTextFromMessages,
42
40
  } from "./pi-message.js";
43
41
  import { loadProfileSystemPrompt } from "./profile.js";
@@ -340,6 +338,13 @@ export function createPontiaPiExtension(
340
338
  ctx.shutdown();
341
339
  },
342
340
  },
341
+ () => {
342
+ lifecycleContext();
343
+ const manager = piContext!.sessionManager;
344
+ if (manager.getSessionId() !== boundSessionContext?.clientSessionKey)
345
+ throw new Error("Pi history native Session identity changed");
346
+ return { generation, sessionManager: manager } satisfies HistoryContext;
347
+ },
343
348
  );
344
349
  if (generation !== controlGeneration) {
345
350
  await socket.close();
@@ -475,21 +480,6 @@ export function createPontiaPiExtension(
475
480
  },
476
481
  });
477
482
 
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
483
  async function reportContextUsageFromHookEvent(event: unknown, ctx?: unknown): Promise<void> {
494
484
  if (!activeTurn || activeTurn.ended) return;
495
485
  const observation = contextUsageFromPiHook(event, ctx);
@@ -836,16 +826,6 @@ export function createPontiaPiExtension(
836
826
  } else if (delta) {
837
827
  activeTurn.output += delta;
838
828
  }
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
829
  });
850
830
 
851
831
  pi.on("message_end", async (event, ctx) => {
@@ -855,7 +835,6 @@ export function createPontiaPiExtension(
855
835
  (event as unknown as Record<string, unknown> | undefined)?.message,
856
836
  );
857
837
  if (fullText) activeTurn.output = fullText;
858
- await scheduleMessageRefresh("append");
859
838
  });
860
839
 
861
840
  pi.on("agent_settled", (_event, ctx) => {
@@ -878,7 +857,6 @@ export function createPontiaPiExtension(
878
857
  state.context,
879
858
  buildTurnInterruptedEvent(state.context, terminalLeafId),
880
859
  );
881
- await reportFinalMessageRefresh(state);
882
860
  return;
883
861
  }
884
862
 
@@ -888,7 +866,6 @@ export function createPontiaPiExtension(
888
866
  state.context,
889
867
  buildTurnFailedEvent(state.context, failureMessage, terminalLeafId),
890
868
  );
891
- await reportFinalMessageRefresh(state);
892
869
  return;
893
870
  }
894
871
 
@@ -912,7 +889,6 @@ export function createPontiaPiExtension(
912
889
  state.context,
913
890
  buildTurnCompletedEvent(state.context, terminalLeafId),
914
891
  );
915
- await reportFinalMessageRefresh(state);
916
892
  } finally {
917
893
  await state.liveOutput.close();
918
894
  }
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
+ }