@pi-unipi/unipi 2.10.2 → 2.11.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 (35) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/package.json +23 -23
  3. package/packages/ask-user/package.json +2 -2
  4. package/packages/autocomplete/package.json +1 -1
  5. package/packages/background-tasks/package.json +2 -2
  6. package/packages/btw/package.json +2 -2
  7. package/packages/compactor/package.json +3 -3
  8. package/packages/core/package.json +1 -1
  9. package/packages/footer/package.json +2 -2
  10. package/packages/image/package.json +2 -2
  11. package/packages/info-screen/package.json +2 -2
  12. package/packages/input-shortcuts/package.json +2 -2
  13. package/packages/kanboard/package.json +2 -2
  14. package/packages/mcp/package.json +2 -2
  15. package/packages/memory/package.json +3 -3
  16. package/packages/milestone/package.json +2 -2
  17. package/packages/notify/package.json +2 -2
  18. package/packages/ralph/package.json +3 -3
  19. package/packages/subagents/package.json +2 -2
  20. package/packages/trajectory/README.md +23 -7
  21. package/packages/trajectory/index.ts +23 -3
  22. package/packages/trajectory/package.json +1 -1
  23. package/packages/trajectory/src/capture.ts +150 -30
  24. package/packages/trajectory/src/prefix-integrity.ts +213 -0
  25. package/packages/trajectory/src/project.ts +57 -5
  26. package/packages/trajectory/src/server.ts +10 -3
  27. package/packages/trajectory/src/telemetry.ts +79 -18
  28. package/packages/trajectory/src/tracer.ts +401 -0
  29. package/packages/trajectory/src/ui.ts +14 -12
  30. package/packages/unipi/bundled.js +1554 -769
  31. package/packages/unipi/index.ts +25 -22
  32. package/packages/updater/package.json +2 -2
  33. package/packages/utility/package.json +2 -2
  34. package/packages/web-api/package.json +2 -2
  35. package/packages/workflow/package.json +2 -2
@@ -1,83 +1,203 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { PrefixIntegrityTracker } from "./prefix-integrity.js";
2
3
  import { TelemetrySidecar } from "./telemetry.js";
4
+ import type { UnipiTraceRecorder } from "./tracer.js";
3
5
 
4
6
  interface RequestState {
5
7
  id: number;
6
- startedAt: number;
8
+ turnIndex?: number;
9
+ startedAt?: number;
7
10
  firstTokenAt?: number;
8
11
  }
9
12
 
13
+ interface PendingAgent {
14
+ runId: number;
15
+ }
16
+
10
17
  /** Register read-only observability hooks. Returned reader follows the active session. */
11
- export function registerTelemetryCapture(pi: ExtensionAPI): () => ReturnType<TelemetrySidecar["read"]> {
18
+ export function registerTelemetryCapture(
19
+ pi: ExtensionAPI,
20
+ sidecarRoot?: string,
21
+ traceRecorder?: UnipiTraceRecorder,
22
+ ): () => ReturnType<TelemetrySidecar["read"]> {
12
23
  let sidecar: TelemetrySidecar | null = null;
13
24
  let requestSeq = 0;
25
+ let runSeq = 0;
14
26
  let active: RequestState | null = null;
27
+ let lastRequestId: number | null = null;
28
+ let currentTurnIndex: number | undefined;
29
+ let pendingAgent: PendingAgent | null = null;
30
+ let previousTraceCursor = 0;
31
+ const prefixIntegrity = new PrefixIntegrityTracker();
15
32
  const toolStarts = new Map<string, number>();
16
33
 
17
34
  const bind = (ctx: ExtensionContext) => {
18
- sidecar = new TelemetrySidecar(ctx.sessionManager.getSessionId());
35
+ const sessionId = ctx.sessionManager.getSessionId();
36
+ sidecar = new TelemetrySidecar(sessionId, sidecarRoot);
37
+ traceRecorder?.bind(sessionId);
38
+ const existing = sidecar.read();
39
+ requestSeq = Math.max(0, ...existing.flatMap(event => typeof event.requestId === "number" ? [event.requestId] : []));
40
+ runSeq = Math.max(0, ...existing.flatMap(event => typeof event.runId === "number" ? [event.runId] : []));
19
41
  active = null;
42
+ lastRequestId = null;
43
+ currentTurnIndex = undefined;
44
+ pendingAgent = null;
45
+ previousTraceCursor = traceRecorder?.cursor() ?? 0;
46
+ prefixIntegrity.reset();
20
47
  toolStarts.clear();
21
48
  };
22
- const append = (type: Parameters<TelemetrySidecar["append"]>[0]["type"], data?: unknown, extra: Record<string, unknown> = {}) => {
23
- sidecar?.append({ type, at: Date.now(), ...extra, ...(data === undefined ? {} : { data }) });
49
+ const append = (
50
+ type: Parameters<TelemetrySidecar["append"]>[0]["type"],
51
+ data?: unknown,
52
+ extra: Record<string, unknown> = {},
53
+ at = Date.now(),
54
+ ) => {
55
+ sidecar?.append({ type, at, ...extra, ...(data === undefined ? {} : { data }) });
24
56
  };
25
57
 
58
+ const ensureRequest = (startedAt?: number) => {
59
+ if (!active) {
60
+ active = { id: ++requestSeq, turnIndex: currentTurnIndex, startedAt };
61
+ } else if (startedAt !== undefined) {
62
+ active.startedAt = startedAt;
63
+ }
64
+ lastRequestId = active.id;
65
+ return active;
66
+ };
67
+ const requestExtra = (request = active) => ({
68
+ ...(request?.id !== undefined ? { requestId: request.id } : lastRequestId === null ? {} : { requestId: lastRequestId }),
69
+ ...(request?.turnIndex !== undefined ? { turnIndex: request.turnIndex } : currentTurnIndex === undefined ? {} : { turnIndex: currentTurnIndex }),
70
+ });
71
+
72
+
26
73
  pi.on("session_start", (_event, ctx) => { bind(ctx); });
74
+ pi.on("resources_discover", () => {});
75
+ pi.on("session_info_changed", () => {});
76
+ pi.on("session_before_switch", () => {});
77
+ pi.on("session_before_fork", () => {});
78
+ pi.on("session_before_compact", () => {});
79
+ pi.on("session_compact", (event) => { prefixIntegrity.markBoundary(`session_compact:${event.reason}`); });
80
+ pi.on("session_before_tree", () => {});
81
+ pi.on("session_tree", () => { prefixIntegrity.markBoundary("session_tree"); });
82
+ pi.on("session_shutdown", () => {});
83
+
84
+ pi.on("before_agent_start", (event) => {
85
+ pendingAgent = { runId: ++runSeq };
86
+ });
87
+ pi.on("agent_start", (event, ctx) => {
88
+ active = null;
89
+ lastRequestId = null;
90
+ currentTurnIndex = undefined;
91
+ const runId = pendingAgent?.runId ?? ++runSeq;
92
+ append("system-prompt", {
93
+ systemPrompt: ctx.getSystemPrompt(),
94
+ provider: ctx.model?.provider,
95
+ model: ctx.model?.id,
96
+ thinkingLevel: ctx.thinkingLevel,
97
+ }, { runId });
98
+ });
99
+ pi.on("agent_end", () => {});
100
+ pi.on("agent_settled", () => { pendingAgent = null; });
101
+ pi.on("turn_start", (event) => {
102
+ currentTurnIndex = event.turnIndex;
103
+ active = {
104
+ id: ++requestSeq,
105
+ turnIndex: event.turnIndex,
106
+ };
107
+ lastRequestId = active.id;
108
+ });
109
+ pi.on("turn_end", () => {});
110
+
111
+ pi.on("context", () => { ensureRequest(); });
112
+ pi.on("before_provider_headers", () => { ensureRequest(); });
27
113
  pi.on("before_provider_request", (event, ctx) => {
28
114
  const startedAt = Date.now();
29
- active = { id: ++requestSeq, startedAt };
115
+ const request = ensureRequest(startedAt);
116
+ const integrity = prefixIntegrity.observe(event.payload, {
117
+ provider: ctx.model?.provider,
118
+ model: ctx.model?.id,
119
+ api: ctx.model?.api,
120
+ thinkingLevel: ctx.thinkingLevel,
121
+ });
122
+ const traceCursor = traceRecorder?.cursor() ?? 0;
123
+ const contributingTrace = traceRecorder?.since(previousTraceCursor) ?? [];
124
+ previousTraceCursor = traceCursor;
125
+ const attributed = integrity.verdict === "violation"
126
+ ? contributingTrace
127
+ .filter(item => item.type === "unipi-trace")
128
+ .map(item => item.data)
129
+ .filter((data): data is Record<string, unknown> => Boolean(data && typeof data === "object"))
130
+ .filter(data => data.phase === "exit" && (
131
+ data.surface === "hook" && data.mutation && (data.mutation as Record<string, unknown>).changed === true ||
132
+ data.surface === "api" && ["sendMessage", "sendUserMessage", "setActiveTools", "setModel", "setThinkingLevel", "registerProvider", "unregisterProvider"].includes(String(data.action)) ||
133
+ data.surface === "context-api" && ["compact", "navigateTree", "switchSession", "newSession", "fork", "reload"].includes(String(data.action))
134
+ ))
135
+ .slice(-20)
136
+ : [];
137
+ append("prefix-integrity", {
138
+ ...integrity,
139
+ attribution: attributed.length > 0 ? attributed : undefined,
140
+ }, requestExtra(request));
30
141
  append("request", {
31
142
  payload: event.payload,
32
143
  provider: ctx.model?.provider,
33
144
  model: ctx.model?.id,
34
145
  thinkingLevel: ctx.thinkingLevel,
35
- tools: Array.isArray((event.payload as { tools?: unknown })?.tools)
36
- ? (event.payload as { tools: unknown }).tools
37
- : undefined,
38
- }, { requestId: active.id });
39
- });
40
- pi.on("after_provider_response", (event) => {
41
- append("response", { status: event.status, headers: event.headers }, active ? { requestId: active.id } : {});
146
+ }, requestExtra(request));
42
147
  });
148
+ pi.on("after_provider_response", () => {});
149
+
150
+ pi.on("message_start", () => {});
43
151
  pi.on("message_update", (event) => {
44
- if (!active || active.firstTokenAt !== undefined) return;
152
+ const request = ensureRequest();
153
+ if (request.firstTokenAt !== undefined) return;
45
154
  if (!["text_delta", "thinking_delta", "toolcall_delta"].includes(event.assistantMessageEvent.type)) return;
46
- active.firstTokenAt = Date.now();
47
- append("first-token", {
48
- ttftMs: active.firstTokenAt - active.startedAt,
49
- eventType: event.assistantMessageEvent.type,
50
- }, { requestId: active.id });
155
+ request.firstTokenAt = Date.now();
156
+
51
157
  });
52
158
  pi.on("message_end", (event) => {
53
159
  if (event.message.role !== "assistant" || !active) return;
54
160
  const completedAt = Date.now();
55
161
  append("message-end", {
56
- totalMs: completedAt - active.startedAt,
57
- ttftMs: active.firstTokenAt === undefined ? undefined : active.firstTokenAt - active.startedAt,
162
+ totalMs: active.startedAt === undefined ? undefined : completedAt - active.startedAt,
163
+ ttftMs: active.startedAt === undefined || active.firstTokenAt === undefined
164
+ ? undefined
165
+ : active.firstTokenAt - active.startedAt,
58
166
  decodingMs: active.firstTokenAt === undefined ? undefined : completedAt - active.firstTokenAt,
59
167
  usage: event.message.usage,
60
168
  stopReason: event.message.stopReason,
61
169
  errorMessage: event.message.errorMessage,
62
- }, { requestId: active.id });
170
+ }, requestExtra());
63
171
  active = null;
64
172
  });
65
- pi.on("tool_execution_start", (event) => {
66
- const at = Date.now();
67
- toolStarts.set(event.toolCallId, at);
68
- append("tool-start", { toolName: event.toolName, args: event.args }, { toolCallId: event.toolCallId });
69
- });
173
+
174
+ pi.on("tool_execution_start", (event) => { toolStarts.set(event.toolCallId, Date.now()); });
175
+ pi.on("tool_call", () => {});
176
+ pi.on("tool_execution_update", () => {});
177
+ pi.on("tool_result", () => {});
70
178
  pi.on("tool_execution_end", (event) => {
71
179
  const at = Date.now();
72
180
  const startedAt = toolStarts.get(event.toolCallId);
73
181
  append("tool-end", {
74
182
  toolName: event.toolName,
75
- result: event.result,
76
183
  isError: event.isError,
77
184
  durationMs: startedAt === undefined ? undefined : at - startedAt,
78
- }, { toolCallId: event.toolCallId });
185
+ }, { ...requestExtra(), toolCallId: event.toolCallId });
79
186
  toolStarts.delete(event.toolCallId);
80
187
  });
81
188
 
82
- return () => sidecar?.read() ?? [];
189
+ pi.on("model_select", (event) => {
190
+ if (event.previousModel && (
191
+ event.previousModel.provider !== event.model.provider ||
192
+ event.previousModel.id !== event.model.id ||
193
+ event.previousModel.api !== event.model.api
194
+ )) prefixIntegrity.markBoundary("model_select");
195
+ });
196
+ pi.on("thinking_level_select", (event) => {
197
+ if (event.previousLevel !== event.level) prefixIntegrity.markBoundary("thinking_level_select");
198
+ });
199
+ pi.on("user_bash", () => {});
200
+ pi.on("input", () => {});
201
+
202
+ return () => sidecar?.read() ?? traceRecorder?.read() ?? [];
83
203
  }
@@ -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" });