@pi-unipi/trajectory 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.
package/README.md CHANGED
@@ -15,15 +15,23 @@ Typing `/unipi:trajectory` followed by a space auto-completes the available
15
15
  actions (`stop`, `off`, `toggle`), so the options are discoverable without
16
16
  reading the docs. Running it with no argument opens or reuses the server.
17
17
 
18
- The command starts or reuses an in-process server on `127.0.0.1:8176-8186` and opens it in the default browser. The page follows the active session every 500 ms. The server is owned by the current Pi process and is stopped on any Pi session shutdown/reload/resume/new/fork event.
18
+ The command starts or reuses an in-process server on `127.0.0.1:8176-8186` and opens it in the default browser. The page follows the active session every 1.5 seconds, skips unchanged snapshots, and reuses an in-memory telemetry tail instead of rereading the JSONL file. The server is owned by the current Pi process and is stopped on any Pi session shutdown/reload/resume/new/fork event.
19
19
 
20
20
  ## What it shows
21
21
 
22
+ - The exact provider-context path: user/assistant/tool messages, effective system prompt, serialized provider request, and prefix-integrity verdict
23
+ - UniPi package operations only when they mutate context or cross an explicit context epoch boundary; UI-only hooks, registrations, notifications, command bookkeeping, and unrelated EventBus traffic are intentionally excluded from the ledger
24
+ - A per-request prefix-integrity verdict: first request, identical retry, append-only extension, explicit lifecycle boundary, or violation
25
+ - Exact prefix-break evidence: first changed message index/path plus independent system-prompt, tool-schema/order, route, thinking-level, and provider-envelope drift
26
+ - Contributing UniPi package operations between the previous request and a violation
27
+ - One-click **UniPi** and **Violations** filters, plus Trace, Integrity, and Attribution detail tabs
28
+ - The fully assembled effective system prompt for each agent run
22
29
  - Turn-aware User, Assistant, Tool, Compaction, and Branch ledger
23
- - Assistant reasoning and final output
24
- - Tool payloads, results, errors, and measured call duration
30
+ - Effective context messages before each model request and the final provider payload after serialization/extension rewrites
31
+ - Assistant reasoning and final output, including streamed assistant events
32
+ - Tool payloads, final results, errors, and measured call duration
25
33
  - Provider, model, stop reason, token usage, cache reads/writes, and cost
26
- - Provider, model, thinking level, request payload/options, tool schemas, response status/headers
34
+ - Provider, model, thinking level, serialized request payload, and tool schemas
27
35
  - Exact TTFT, decoding/total request timing, and tool execution timing
28
36
  - Input/Model/Tools overview timeline with drag-to-focus
29
37
  - Search, turn folding, call folding, and a local record inspector
@@ -34,7 +42,15 @@ The server binds only to `127.0.0.1`. It reads `SessionManager.getBranch()` and
34
42
 
35
43
  Detailed telemetry is appended to `~/.unipi/trajectory/<session-id>.jsonl` with a private directory/file mode. Authorization, API-key, token, cookie, secret, password, and credential fields and credential-shaped values are recursively redacted before persistence.
36
44
 
37
- Detailed request fields appear when captured by the trajectory telemetry sidecar.
45
+ Context-relevant prompt, request, package mutation, and prefix-integrity fields appear when captured by the trajectory telemetry sidecar. Stream deltas, response headers, inert hooks, and other activity that cannot affect model context are not persisted. Secret-shaped headers and values are redacted before persistence, and pathological events/read volume are bounded so debug capture cannot exhaust the Pi process.
46
+
47
+ ## Prefix integrity
48
+
49
+ Inside one cache epoch, the previous serialized message sequence must be an exact prefix of the next request. An identical retry is also safe. Any earlier edit, removal, reorder, or middle insertion is a violation. System instructions, tool definitions/order, provider route, thinking level, and request envelope are checked independently because changes there also invalidate provider prefix caches.
50
+
51
+ Compaction, tree navigation, model changes, and thinking-level changes create explicit epoch boundaries. The first request after a boundary is labeled as such; append-only enforcement resumes on the following request.
52
+
53
+ Package attribution is intentionally limited to UniPi modules loaded through the all-in-one `@pi-unipi/unipi` umbrella. Standalone trajectory still captures Pi's observable hooks and requests, but cannot attribute calls made by independently loaded packages.
38
54
 
39
55
  ## Lifecycle
40
56
 
@@ -42,6 +58,6 @@ Each Pi process owns its own trajectory server. A second Pi process running `/un
42
58
 
43
59
  ## Limitations
44
60
 
45
- - Live transport uses 500 ms polling rather than a push stream.
46
- - Very large branches are rendered in one table; browser virtualization can be added if measured sessions require it.
61
+ - Live transport uses conditional 1.5-second polling rather than a push stream.
62
+ - Very large active branches are rendered in one table; unchanged snapshots are not rebuilt or rerendered.
47
63
  - Tool duration uses exact executor telemetry when captured and session timestamps otherwise.
package/index.ts CHANGED
@@ -2,19 +2,33 @@ import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@earendil-wor
2
2
  import { registerTelemetryCapture } from "./src/capture.js";
3
3
  import { projectTrajectory } from "./src/project.js";
4
4
  import { openBrowser, TrajectoryServer } from "./src/server.js";
5
+ import { createUnipiTracer, type UnipiTraceRecorder } from "./src/tracer.js";
6
+
7
+ export { createUnipiTracer, type UnipiTraceRecorder, type UnipiTracer } from "./src/tracer.js";
5
8
 
6
9
  let server: TrajectoryServer | null = null;
7
10
  let currentContext: ExtensionContext | null = null;
11
+ let cachedLeafId: string | null | undefined;
12
+ let cachedTelemetryRevision = -1;
13
+ let cachedSnapshot: ReturnType<typeof projectTrajectory> | null = null;
8
14
 
9
15
  function stopServer(): boolean {
10
16
  if (!server?.url) return false;
11
17
  server.stop();
12
18
  server = null;
19
+ cachedLeafId = undefined;
20
+ cachedTelemetryRevision = -1;
21
+ cachedSnapshot = null;
13
22
  return true;
14
23
  }
15
24
 
16
- export default function trajectory(pi: ExtensionAPI): void {
17
- const readTelemetry = registerTelemetryCapture(pi);
25
+ export interface TrajectoryOptions {
26
+ traceRecorder?: UnipiTraceRecorder;
27
+ }
28
+
29
+ export default function trajectory(pi: ExtensionAPI, options: TrajectoryOptions = {}): void {
30
+ const recorder = options.traceRecorder ?? createUnipiTracer(pi).recorder;
31
+ const readTelemetry = registerTelemetryCapture(pi, undefined, recorder);
18
32
 
19
33
  pi.registerCommand("unipi:trajectory", {
20
34
  description: "Open or manage UniPi's live trajectory for the current session",
@@ -54,11 +68,17 @@ export default function trajectory(pi: ExtensionAPI): void {
54
68
  server = new TrajectoryServer({
55
69
  snapshot: () => {
56
70
  const active = currentContext ?? ctx;
57
- return projectTrajectory(active.sessionManager.getBranch() as SessionEntry[], {
71
+ const leafId = active.sessionManager.getLeafId();
72
+ const telemetryRevision = recorder.revision();
73
+ if (cachedSnapshot && cachedLeafId === leafId && cachedTelemetryRevision === telemetryRevision) return cachedSnapshot;
74
+ cachedLeafId = leafId;
75
+ cachedTelemetryRevision = telemetryRevision;
76
+ cachedSnapshot = projectTrajectory(active.sessionManager.getBranch() as SessionEntry[], {
58
77
  sessionId: active.sessionManager.getSessionId(),
59
78
  name: active.sessionManager.getSessionName(),
60
79
  cwd: active.cwd,
61
80
  }, readTelemetry());
81
+ return cachedSnapshot;
62
82
  },
63
83
  });
64
84
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/trajectory",
3
- "version": "2.10.2",
3
+ "version": "2.12.0",
4
4
  "description": "Live UniPi trajectory inspector for Pi sessions",
5
5
  "type": "module",
6
6
  "main": "index.ts",
package/src/capture.ts CHANGED
@@ -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
+ }
package/src/project.ts CHANGED
@@ -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
  }