@pi-unipi/unipi 2.10.2 → 2.12.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +1 -1
  3. package/package.json +23 -23
  4. package/packages/ask-user/package.json +2 -2
  5. package/packages/autocomplete/package.json +1 -1
  6. package/packages/background-tasks/package.json +2 -2
  7. package/packages/btw/package.json +2 -2
  8. package/packages/compactor/package.json +3 -3
  9. package/packages/core/package.json +1 -1
  10. package/packages/footer/README.md +20 -3
  11. package/packages/footer/package.json +2 -2
  12. package/packages/footer/src/commands.ts +9 -15
  13. package/packages/footer/src/config.ts +2 -0
  14. package/packages/footer/src/glance-editor.ts +285 -0
  15. package/packages/footer/src/help.ts +1 -1
  16. package/packages/footer/src/index.ts +360 -27
  17. package/packages/footer/src/presets.ts +16 -1
  18. package/packages/footer/src/rendering/icons.ts +8 -0
  19. package/packages/footer/src/rendering/theme.ts +2 -0
  20. package/packages/footer/src/segments/core.ts +114 -7
  21. package/packages/footer/src/tps-tracker.ts +529 -240
  22. package/packages/footer/src/tui/settings-tui.ts +18 -2
  23. package/packages/footer/src/types.ts +4 -0
  24. package/packages/image/package.json +2 -2
  25. package/packages/info-screen/package.json +2 -2
  26. package/packages/input-shortcuts/package.json +2 -2
  27. package/packages/kanboard/package.json +2 -2
  28. package/packages/mcp/package.json +2 -2
  29. package/packages/memory/package.json +3 -3
  30. package/packages/milestone/package.json +2 -2
  31. package/packages/notify/package.json +2 -2
  32. package/packages/ralph/package.json +3 -3
  33. package/packages/subagents/package.json +2 -2
  34. package/packages/trajectory/README.md +23 -7
  35. package/packages/trajectory/index.ts +23 -3
  36. package/packages/trajectory/package.json +1 -1
  37. package/packages/trajectory/src/capture.ts +150 -30
  38. package/packages/trajectory/src/prefix-integrity.ts +213 -0
  39. package/packages/trajectory/src/project.ts +57 -5
  40. package/packages/trajectory/src/server.ts +10 -3
  41. package/packages/trajectory/src/telemetry.ts +79 -18
  42. package/packages/trajectory/src/tracer.ts +401 -0
  43. package/packages/trajectory/src/ui.ts +14 -12
  44. package/packages/unipi/bundled.js +10080 -3406
  45. package/packages/unipi/index.ts +25 -22
  46. package/packages/updater/package.json +2 -2
  47. package/packages/utility/package.json +2 -2
  48. package/packages/web-api/package.json +2 -2
  49. package/packages/workflow/package.json +2 -2
@@ -0,0 +1,213 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ export type PrefixVerdict = "first_request" | "identical_retry" | "prefix_extended" | "boundary" | "violation";
4
+
5
+ export interface PrefixDifference {
6
+ surface: "messages" | "system" | "tools" | "envelope" | "shape";
7
+ kind: "changed" | "inserted" | "removed" | "reordered";
8
+ path: string;
9
+ index?: number;
10
+ beforeFingerprint?: string;
11
+ afterFingerprint?: string;
12
+ }
13
+
14
+ export interface PrefixIntegrityResult {
15
+ epoch: number;
16
+ verdict: PrefixVerdict;
17
+ messageField: string;
18
+ previousItems: number;
19
+ currentItems: number;
20
+ extendedBy: number;
21
+ requestFingerprint: string;
22
+ messagesFingerprint: string;
23
+ systemFingerprint: string;
24
+ toolsFingerprint: string;
25
+ envelopeFingerprint: string;
26
+ boundaryReason?: string;
27
+ differences: PrefixDifference[];
28
+ }
29
+
30
+ interface RequestState {
31
+ requestFingerprint: string;
32
+ messageField: string;
33
+ messages: unknown[];
34
+ messageFingerprints: string[];
35
+ systemFingerprint: string;
36
+ toolsFingerprint: string;
37
+ envelopeFingerprint: string;
38
+ }
39
+
40
+ function canonicalize(value: unknown, seen = new WeakSet<object>()): unknown {
41
+ if (value === undefined) return { $type: "undefined" };
42
+ if (typeof value === "bigint") return { $type: "bigint", value: value.toString() };
43
+ if (typeof value === "number" && !Number.isFinite(value)) return { $type: "number", value: String(value) };
44
+ if (typeof value !== "object" || value === null) return value;
45
+ if (seen.has(value)) return { $type: "cycle" };
46
+ seen.add(value);
47
+ if (Array.isArray(value)) {
48
+ const result = value.map(item => canonicalize(item, seen));
49
+ seen.delete(value);
50
+ return result;
51
+ }
52
+ if (value instanceof Uint8Array) {
53
+ seen.delete(value);
54
+ return { $type: "bytes", length: value.byteLength };
55
+ }
56
+ const result: Record<string, unknown> = {};
57
+ for (const key of Object.keys(value as Record<string, unknown>).sort()) {
58
+ result[key] = canonicalize((value as Record<string, unknown>)[key], seen);
59
+ }
60
+ seen.delete(value);
61
+ return result;
62
+ }
63
+
64
+ function fingerprint(value: unknown): string {
65
+ return createHash("sha256").update(JSON.stringify(canonicalize(value))).digest("hex").slice(0, 16);
66
+ }
67
+
68
+ function payloadObject(payload: unknown): Record<string, unknown> {
69
+ return payload && typeof payload === "object" && !Array.isArray(payload)
70
+ ? payload as Record<string, unknown>
71
+ : { payload };
72
+ }
73
+
74
+ function messageSequence(payload: Record<string, unknown>): { field: string; items: unknown[] } {
75
+ for (const field of ["messages", "input", "contents"] as const) {
76
+ if (Array.isArray(payload[field])) return { field, items: payload[field] as unknown[] };
77
+ }
78
+ return { field: "none", items: [] };
79
+ }
80
+
81
+ function systemSurface(payload: Record<string, unknown>, field: string, messages: unknown[]): unknown {
82
+ const direct = Object.fromEntries(Object.entries(payload).filter(([key]) => /^(system|instructions|developer)/i.test(key)));
83
+ const leading = field === "messages"
84
+ ? messages.filter((message) => {
85
+ if (!message || typeof message !== "object") return false;
86
+ const role = (message as Record<string, unknown>).role;
87
+ return role === "system" || role === "developer";
88
+ })
89
+ : [];
90
+ return { direct, leading };
91
+ }
92
+
93
+ function toolsSurface(payload: Record<string, unknown>): unknown {
94
+ return { tools: payload.tools, toolChoice: payload.tool_choice ?? payload.toolChoice };
95
+ }
96
+
97
+ function envelopeSurface(payload: Record<string, unknown>, sequenceField: string): unknown {
98
+ const omitted = new Set([sequenceField, "tools", "tool_choice", "toolChoice"]);
99
+ return Object.fromEntries(Object.entries(payload).filter(([key]) => !omitted.has(key) && !/^(system|instructions|developer)/i.test(key)));
100
+ }
101
+
102
+ function state(payload: unknown, route?: unknown): RequestState {
103
+ const object = payloadObject(payload);
104
+ const sequence = messageSequence(object);
105
+ const system = systemSurface(object, sequence.field, sequence.items);
106
+ const tools = toolsSurface(object);
107
+ const envelope = { route, payload: envelopeSurface(object, sequence.field) };
108
+ return {
109
+ requestFingerprint: fingerprint({ route, payload: object }),
110
+ messageField: sequence.field,
111
+ messages: sequence.items,
112
+ messageFingerprints: sequence.items.map(fingerprint),
113
+ systemFingerprint: fingerprint(system),
114
+ toolsFingerprint: fingerprint(tools),
115
+ envelopeFingerprint: fingerprint(envelope),
116
+ };
117
+ }
118
+
119
+ function messageDifference(previous: RequestState, current: RequestState): PrefixDifference | undefined {
120
+ if (previous.messageField !== current.messageField) {
121
+ return { surface: "shape", kind: "changed", path: "$.messageField", beforeFingerprint: fingerprint(previous.messageField), afterFingerprint: fingerprint(current.messageField) };
122
+ }
123
+ const limit = Math.min(previous.messageFingerprints.length, current.messageFingerprints.length);
124
+ for (let index = 0; index < limit; index++) {
125
+ if (previous.messageFingerprints[index] === current.messageFingerprints[index]) continue;
126
+ const laterIndex = current.messageFingerprints.indexOf(previous.messageFingerprints[index]!, index + 1);
127
+ const earlierIndex = previous.messageFingerprints.indexOf(current.messageFingerprints[index]!, index + 1);
128
+ return {
129
+ surface: "messages",
130
+ kind: laterIndex >= 0 || earlierIndex >= 0 ? "reordered" : "changed",
131
+ path: `$.${current.messageField}[${index}]`, index,
132
+ beforeFingerprint: previous.messageFingerprints[index], afterFingerprint: current.messageFingerprints[index],
133
+ };
134
+ }
135
+ if (current.messageFingerprints.length < previous.messageFingerprints.length) {
136
+ const index = current.messageFingerprints.length;
137
+ return { surface: "messages", kind: "removed", path: `$.${current.messageField}[${index}]`, index, beforeFingerprint: previous.messageFingerprints[index] };
138
+ }
139
+ return undefined;
140
+ }
141
+
142
+ export class PrefixIntegrityTracker {
143
+ private epoch = 0;
144
+ private previous: RequestState | null = null;
145
+ private boundaryReason: string | undefined;
146
+
147
+ reset(): void {
148
+ this.epoch = 0;
149
+ this.previous = null;
150
+ this.boundaryReason = undefined;
151
+ }
152
+
153
+ markBoundary(reason: string): void {
154
+ if (this.previous) this.epoch++;
155
+ this.previous = null;
156
+ this.boundaryReason = reason;
157
+ }
158
+
159
+ observe(payload: unknown, route?: unknown): PrefixIntegrityResult {
160
+ const current = state(payload, route);
161
+ const boundaryReason = this.boundaryReason;
162
+ if (!this.previous) {
163
+ if (this.epoch === 0) this.epoch = 1;
164
+ this.previous = current;
165
+ this.boundaryReason = undefined;
166
+ return {
167
+ epoch: this.epoch,
168
+ verdict: boundaryReason ? "boundary" : "first_request",
169
+ messageField: current.messageField,
170
+ previousItems: 0,
171
+ currentItems: current.messages.length,
172
+ extendedBy: current.messages.length,
173
+ requestFingerprint: current.requestFingerprint,
174
+ messagesFingerprint: fingerprint(current.messageFingerprints),
175
+ systemFingerprint: current.systemFingerprint,
176
+ toolsFingerprint: current.toolsFingerprint,
177
+ envelopeFingerprint: current.envelopeFingerprint,
178
+ ...(boundaryReason ? { boundaryReason } : {}),
179
+ differences: [],
180
+ };
181
+ }
182
+
183
+ const previous = this.previous;
184
+ const differences: PrefixDifference[] = [];
185
+ const messageDiff = messageDifference(previous, current);
186
+ if (messageDiff) differences.push(messageDiff);
187
+ if (previous.systemFingerprint !== current.systemFingerprint) differences.push({ surface: "system", kind: "changed", path: "$.system", beforeFingerprint: previous.systemFingerprint, afterFingerprint: current.systemFingerprint });
188
+ if (previous.toolsFingerprint !== current.toolsFingerprint) differences.push({ surface: "tools", kind: "changed", path: "$.tools", beforeFingerprint: previous.toolsFingerprint, afterFingerprint: current.toolsFingerprint });
189
+ if (previous.envelopeFingerprint !== current.envelopeFingerprint) differences.push({ surface: "envelope", kind: "changed", path: "$.envelope", beforeFingerprint: previous.envelopeFingerprint, afterFingerprint: current.envelopeFingerprint });
190
+
191
+ const verdict: PrefixVerdict = current.requestFingerprint === previous.requestFingerprint
192
+ ? "identical_retry"
193
+ : differences.length === 0
194
+ ? "prefix_extended"
195
+ : "violation";
196
+ const result: PrefixIntegrityResult = {
197
+ epoch: this.epoch,
198
+ verdict,
199
+ messageField: current.messageField,
200
+ previousItems: previous.messages.length,
201
+ currentItems: current.messages.length,
202
+ extendedBy: Math.max(0, current.messages.length - previous.messages.length),
203
+ requestFingerprint: current.requestFingerprint,
204
+ messagesFingerprint: fingerprint(current.messageFingerprints),
205
+ systemFingerprint: current.systemFingerprint,
206
+ toolsFingerprint: current.toolsFingerprint,
207
+ envelopeFingerprint: current.envelopeFingerprint,
208
+ differences,
209
+ };
210
+ this.previous = current;
211
+ return result;
212
+ }
213
+ }
@@ -1,7 +1,7 @@
1
1
  import type { SessionEntry } from "@earendil-works/pi-coding-agent";
2
2
  import type { TelemetryEvent } from "./telemetry.js";
3
3
 
4
- export type TrajectoryKind = "system" | "user" | "assistant" | "tool" | "compaction" | "branch";
4
+ export type TrajectoryKind = "system" | "unipi" | "prefix" | "user" | "assistant" | "tool" | "compaction" | "branch";
5
5
 
6
6
  export interface TrajectoryUsage {
7
7
  input?: number;
@@ -38,6 +38,13 @@ export interface TrajectoryRecord {
38
38
  request?: unknown;
39
39
  response?: unknown;
40
40
  tools?: unknown;
41
+ data?: unknown;
42
+ package?: string;
43
+ surface?: string;
44
+ phase?: string;
45
+ verdict?: string;
46
+ epoch?: number;
47
+ attribution?: unknown;
41
48
  }
42
49
 
43
50
  export interface TrajectorySnapshot {
@@ -165,12 +172,56 @@ export function projectTrajectory(
165
172
  }
166
173
  }
167
174
 
175
+ const firstEntryAt = records[0]?.timestamp ?? Date.now();
176
+ let syntheticSeq = -telemetry.length;
177
+ for (const event of telemetry) {
178
+ if (!["system-prompt", "unipi-trace", "prefix-integrity"].includes(event.type)) continue;
179
+ const data = event.data as AnyRecord | undefined;
180
+ // Package traces are attribution evidence, not a second activity log. Only
181
+ // operations that changed a provider-visible surface belong in trajectory.
182
+ if (event.type === "unipi-trace" && data?.affectsContext !== true) continue;
183
+ const traceAction = String(data?.hook ?? data?.action ?? data?.channel ?? data?.surface ?? "operation");
184
+ const kind: TrajectoryKind = event.type === "system-prompt" ? "system" : event.type === "unipi-trace" ? "unipi" : "prefix";
185
+ const title = event.type === "system-prompt"
186
+ ? "SYSTEM PROMPT"
187
+ : event.type === "unipi-trace"
188
+ ? `${String(data?.package ?? "unipi")} · ${traceAction}`
189
+ : `PREFIX · ${String(data?.verdict ?? "unknown")}`;
190
+ const value = event.type === "system-prompt" ? data?.systemPrompt : data;
191
+ records.push({
192
+ id: `telemetry:${event.type}:${event.at}:${syntheticSeq}`,
193
+ seq: syntheticSeq++,
194
+ turn: event.turnIndex === undefined ? null : event.turnIndex + 1,
195
+ step: null,
196
+ kind,
197
+ timestamp: event.at || firstEntryAt,
198
+ durationMs: typeof data?.durationMs === "number" ? data.durationMs : null,
199
+ title,
200
+ preview: event.type === "prefix-integrity"
201
+ ? `${String(data?.verdict ?? "unknown")} · epoch ${String(data?.epoch ?? "?")}${Array.isArray(data?.differences) && data.differences[0] ? ` · ${String(data.differences[0].path ?? data.differences[0].surface ?? "changed")}` : ""}`
202
+ : preview(event.type === "system-prompt" ? value : JSON.stringify(value ?? {})),
203
+ output: event.type === "system-prompt" ? value : undefined,
204
+ data,
205
+ package: typeof data?.package === "string" ? data.package : undefined,
206
+ surface: typeof data?.surface === "string" ? data.surface : undefined,
207
+ phase: typeof data?.phase === "string" ? data.phase : undefined,
208
+ verdict: typeof data?.verdict === "string" ? data.verdict : undefined,
209
+ epoch: typeof data?.epoch === "number" ? data.epoch : undefined,
210
+ attribution: data?.attribution,
211
+ isError: data?.phase === "error" || data?.verdict === "violation",
212
+ request: event.requestId === undefined ? undefined : { requestId: event.requestId },
213
+ });
214
+ }
215
+ records.sort((a, b) => a.timestamp - b.timestamp || a.seq - b.seq);
216
+
168
217
  const assistantRecords = records.filter(record => record.kind === "assistant");
169
- const requestIds = [...new Set(telemetry.flatMap(event => event.requestId === undefined ? [] : [event.requestId]))];
170
- for (const [index, requestId] of requestIds.entries()) {
171
- const record = assistantRecords[index];
172
- if (!record) break;
218
+ const requestIds = [...new Set(telemetry.flatMap(event => event.type === "request" && event.requestId !== undefined ? [event.requestId] : []))];
219
+ for (const requestId of requestIds) {
173
220
  const events = telemetry.filter(event => event.requestId === requestId);
221
+ const requestAt = events.find(event => event.type === "request")?.at ?? 0;
222
+ const record = assistantRecords.find(candidate => candidate.request === undefined && candidate.timestamp >= requestAt)
223
+ ?? assistantRecords.find(candidate => candidate.request === undefined);
224
+ if (!record) break;
174
225
  const request = events.find(event => event.type === "request")?.data as AnyRecord | undefined;
175
226
  const response = events.find(event => event.type === "response")?.data;
176
227
  const end = events.find(event => event.type === "message-end")?.data as AnyRecord | undefined;
@@ -188,6 +239,7 @@ export function projectTrajectory(
188
239
  const end = telemetry.find(event => event.type === "tool-end" && event.toolCallId === record.toolCallId)?.data as AnyRecord | undefined;
189
240
  if (typeof end?.durationMs === "number") record.durationMs = end.durationMs;
190
241
  }
242
+ records.forEach((record, index) => { record.seq = index; });
191
243
 
192
244
  return { ...meta, generatedAt: Date.now(), records };
193
245
  }
@@ -11,10 +11,17 @@ export interface TrajectoryServerOptions {
11
11
 
12
12
  const HOST = "127.0.0.1";
13
13
 
14
- function json(res: ServerResponse, value: unknown): void {
14
+ function json(res: ServerResponse, value: TrajectorySnapshot, requestEtag?: string): void {
15
+ const etag = `W/\"${value.sessionId}:${value.generatedAt}\"`;
16
+ if (requestEtag === etag) {
17
+ res.writeHead(304, { ETag: etag });
18
+ res.end();
19
+ return;
20
+ }
15
21
  res.writeHead(200, {
16
- "Cache-Control": "no-store",
22
+ "Cache-Control": "no-cache",
17
23
  "Content-Type": "application/json; charset=utf-8",
24
+ ETag: etag,
18
25
  "X-Content-Type-Options": "nosniff",
19
26
  });
20
27
  res.end(JSON.stringify(value));
@@ -35,7 +42,7 @@ export class TrajectoryServer {
35
42
  try {
36
43
  const url = new URL(req.url ?? "/", `http://${HOST}`);
37
44
  if (req.method !== "GET") { res.writeHead(405).end("Method Not Allowed"); return; }
38
- if (url.pathname === "/api/snapshot") { json(res, this.options.snapshot()); return; }
45
+ if (url.pathname === "/api/snapshot") { json(res, this.options.snapshot(), req.headers["if-none-match"]); return; }
39
46
  if (url.pathname === "/favicon.ico") { res.writeHead(204).end(); return; }
40
47
  if (url.pathname === "/app.js") {
41
48
  res.writeHead(200, { "Cache-Control": "no-store", "Content-Type": "application/javascript; charset=utf-8", "X-Content-Type-Options": "nosniff" });
@@ -1,8 +1,12 @@
1
- import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
1
+ import { appendFileSync, closeSync, mkdirSync, openSync, readSync, statSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
 
5
5
  export type TelemetryEventType =
6
+ | "hook"
7
+ | "unipi-trace"
8
+ | "prefix-integrity"
9
+ | "system-prompt"
6
10
  | "request"
7
11
  | "response"
8
12
  | "first-token"
@@ -15,6 +19,8 @@ export interface TelemetryEvent {
15
19
  type: TelemetryEventType;
16
20
  at: number;
17
21
  requestId?: number | string;
22
+ runId?: number | string;
23
+ turnIndex?: number;
18
24
  toolCallId?: string;
19
25
  data?: unknown;
20
26
  }
@@ -24,8 +30,58 @@ const SECRET_VALUE = /^(?:bearer\s+|sk-[a-z0-9_-]{12,}|gh[pousr]_[a-z0-9]{12,}|A
24
30
  const MAX_STRING = 200_000;
25
31
  /** Hard ceiling on a serialized event; guards against pathological payloads. */
26
32
  const MAX_EVENT_BYTES = 2_000_000;
27
- /** Sidecar reads are capped so projection cannot allocate unbounded memory. */
28
- const MAX_READ_BYTES = 20_000_000;
33
+ /** Only a small recent tail is needed by the live context inspector. */
34
+ const MAX_READ_BYTES = 5_000_000;
35
+
36
+ interface SharedSidecarState {
37
+ events: TelemetryEvent[];
38
+ sizes: number[];
39
+ retainedBytes: number;
40
+ revision: number;
41
+ }
42
+
43
+ const sharedStates = new Map<string, SharedSidecarState>();
44
+
45
+ function readTail(file: string): { events: TelemetryEvent[]; sizes: number[] } {
46
+ let fd: number | undefined;
47
+ try {
48
+ const size = statSync(file).size;
49
+ const length = Math.min(size, MAX_READ_BYTES);
50
+ const buffer = Buffer.allocUnsafe(length);
51
+ fd = openSync(file, "r");
52
+ readSync(fd, buffer, 0, length, size - length);
53
+ let text = buffer.toString("utf8");
54
+ if (size > length) text = text.slice(text.indexOf("\n") + 1);
55
+ const events: TelemetryEvent[] = [];
56
+ const sizes: number[] = [];
57
+ for (const line of text.split("\n")) {
58
+ if (!line) continue;
59
+ try {
60
+ events.push(JSON.parse(line) as TelemetryEvent);
61
+ sizes.push(Buffer.byteLength(line) + 1);
62
+ } catch { /* Ignore an incomplete or malformed line. */ }
63
+ }
64
+ return { events, sizes };
65
+ } catch {
66
+ return { events: [], sizes: [] };
67
+ } finally {
68
+ if (fd !== undefined) closeSync(fd);
69
+ }
70
+ }
71
+
72
+ function stateFor(file: string): SharedSidecarState {
73
+ const existing = sharedStates.get(file);
74
+ if (existing) return existing;
75
+ const loaded = readTail(file);
76
+ const state = {
77
+ events: loaded.events,
78
+ sizes: loaded.sizes,
79
+ retainedBytes: loaded.sizes.reduce((total, size) => total + size, 0),
80
+ revision: 0,
81
+ };
82
+ sharedStates.set(file, state);
83
+ return state;
84
+ }
29
85
 
30
86
  export function redactTelemetry(value: unknown, seen = new WeakSet<object>()): unknown {
31
87
  if (typeof value === "string") {
@@ -44,32 +100,37 @@ export function redactTelemetry(value: unknown, seen = new WeakSet<object>()): u
44
100
 
45
101
  export class TelemetrySidecar {
46
102
  readonly file: string;
103
+ private readonly state: SharedSidecarState;
47
104
 
48
105
  constructor(sessionId: string, root = join(homedir(), ".unipi", "trajectory")) {
49
106
  mkdirSync(root, { recursive: true, mode: 0o700 });
50
107
  this.file = join(root, `${sessionId.replace(/[^a-zA-Z0-9._-]/g, "_")}.jsonl`);
108
+ this.state = stateFor(this.file);
51
109
  }
52
110
 
53
111
  append(event: Omit<TelemetryEvent, "v">): void {
54
- const serialized = JSON.stringify(redactTelemetry({ v: 1, ...event }));
55
- if (serialized.length > MAX_EVENT_BYTES) return;
112
+ const safe = redactTelemetry({ v: 1, ...event }) as TelemetryEvent;
113
+ const serialized = JSON.stringify(safe);
114
+ const bytes = Buffer.byteLength(serialized) + 1;
115
+ if (bytes > MAX_EVENT_BYTES) return;
56
116
  appendFileSync(this.file, `${serialized}\n`, { encoding: "utf8", mode: 0o600 });
117
+ this.state.events.push(safe);
118
+ this.state.sizes.push(bytes);
119
+ this.state.retainedBytes += bytes;
120
+ this.state.revision++;
121
+ while (this.state.retainedBytes > MAX_READ_BYTES && this.state.events.length > 1) {
122
+ this.state.events.shift();
123
+ this.state.retainedBytes -= this.state.sizes.shift() ?? 0;
124
+ }
57
125
  }
58
126
 
127
+ /** Return the cached recent tail; this never rereads the whole JSONL file. */
59
128
  read(): TelemetryEvent[] {
60
- try {
61
- let lines = readFileSync(this.file, "utf8");
62
- if (lines.length > MAX_READ_BYTES) {
63
- // Keep the most recent events; skip complete earlier lines.
64
- const keep = lines.slice(lines.length - MAX_READ_BYTES);
65
- lines = keep.slice(keep.indexOf("\n") + 1);
66
- }
67
- return lines.split("\n").filter(Boolean).flatMap(line => {
68
- try { return [JSON.parse(line) as TelemetryEvent]; } catch { return []; }
69
- });
70
- } catch {
71
- return [];
72
- }
129
+ return this.state.events.slice();
130
+ }
131
+
132
+ revision(): number {
133
+ return this.state.revision;
73
134
  }
74
135
  }
75
136