@pi-unipi/trajectory 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.
- package/README.md +23 -7
- package/index.ts +23 -3
- package/package.json +1 -1
- package/src/capture.ts +150 -30
- package/src/prefix-integrity.ts +213 -0
- package/src/project.ts +57 -5
- package/src/server.ts +10 -3
- package/src/telemetry.ts +79 -18
- package/src/tracer.ts +401 -0
- package/src/ui.ts +14 -12
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
|
|
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
|
-
-
|
|
24
|
-
-
|
|
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
|
|
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
|
-
|
|
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
|
|
46
|
-
- Very large branches are rendered in one table;
|
|
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
|
|
17
|
-
|
|
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
|
-
|
|
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
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 = (
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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.
|
|
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
|
-
},
|
|
170
|
+
}, requestExtra());
|
|
63
171
|
active = null;
|
|
64
172
|
});
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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.
|
|
170
|
-
for (const
|
|
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
|
}
|