@pi-unipi/trajectory 2.10.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 ADDED
@@ -0,0 +1,43 @@
1
+ # @pi-unipi/trajectory
2
+
3
+ Live, localhost-only UniPi trajectory inspector for the current Pi session.
4
+
5
+ ## Usage
6
+
7
+ ```text
8
+ /unipi:trajectory # open or reuse the server
9
+ /unipi:trajectory stop # stop this session's server
10
+ /unipi:trajectory off # alias of stop
11
+ /unipi:trajectory toggle # open when stopped, stop when running
12
+ ```
13
+
14
+ 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.
15
+
16
+ ## What it shows
17
+
18
+ - Turn-aware User, Assistant, Tool, Compaction, and Branch ledger
19
+ - Assistant reasoning and final output
20
+ - Tool payloads, results, errors, and measured call duration
21
+ - Provider, model, stop reason, token usage, cache reads/writes, and cost
22
+ - Provider, model, thinking level, request payload/options, tool schemas, response status/headers
23
+ - Exact TTFT, decoding/total request timing, and tool execution timing
24
+ - Input/Model/Tools overview timeline with drag-to-focus
25
+ - Search, turn folding, call folding, and a local record inspector
26
+
27
+ ## Privacy and model behavior
28
+
29
+ The server binds only to `127.0.0.1`. It reads `SessionManager.getBranch()` and never appends or changes session entries, prompts, provider requests, or model context. It therefore has no prefix-cache effect.
30
+
31
+ 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.
32
+
33
+ Detailed request fields appear when captured by the trajectory telemetry sidecar.
34
+
35
+ ## Lifecycle
36
+
37
+ Each Pi process owns its own trajectory server. A second Pi process running `/unipi:trajectory` picks the next free localhost port in `8176-8186`; it never attaches to or controls another Pi process's server. When the owning Pi session exits, reloads, resumes another session, starts a new session, or forks, its server is closed.
38
+
39
+ ## Limitations
40
+
41
+ - Live transport uses 500 ms polling rather than a push stream.
42
+ - Very large branches are rendered in one table; browser virtualization can be added if measured sessions require it.
43
+ - Tool duration uses exact executor telemetry when captured and session timestamps otherwise.
package/index.ts ADDED
@@ -0,0 +1,58 @@
1
+ import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
2
+ import { registerTelemetryCapture } from "./src/capture.js";
3
+ import { projectTrajectory } from "./src/project.js";
4
+ import { openBrowser, TrajectoryServer } from "./src/server.js";
5
+
6
+ let server: TrajectoryServer | null = null;
7
+ let currentContext: ExtensionContext | null = null;
8
+
9
+ function stopServer(): boolean {
10
+ if (!server?.url) return false;
11
+ server.stop();
12
+ server = null;
13
+ return true;
14
+ }
15
+
16
+ export default function trajectory(pi: ExtensionAPI): void {
17
+ const readTelemetry = registerTelemetryCapture(pi);
18
+
19
+ pi.registerCommand("unipi:trajectory", {
20
+ description: "Open or manage UniPi's live trajectory for the current session",
21
+ handler: async (args, ctx) => {
22
+ const action = String(args ?? "").trim().toLowerCase();
23
+ const wantsStop = ["stop", "off", "--stop", "--off"].includes(action);
24
+ const wantsToggle = ["toggle", "--toggle"].includes(action);
25
+ if (wantsStop || (wantsToggle && server?.url)) {
26
+ ctx.ui.notify(stopServer() ? "Trajectory stopped" : "Trajectory is not running", "info");
27
+ return;
28
+ }
29
+
30
+ currentContext = ctx;
31
+ if (!server) {
32
+ server = new TrajectoryServer({
33
+ snapshot: () => {
34
+ const active = currentContext ?? ctx;
35
+ return projectTrajectory(active.sessionManager.getBranch() as SessionEntry[], {
36
+ sessionId: active.sessionManager.getSessionId(),
37
+ name: active.sessionManager.getSessionName(),
38
+ cwd: active.cwd,
39
+ }, readTelemetry());
40
+ },
41
+ });
42
+ }
43
+ try {
44
+ const url = await server.start();
45
+ openBrowser(url);
46
+ ctx.ui.notify(`Trajectory running at ${url}`, "info");
47
+ } catch (error) {
48
+ ctx.ui.notify(`Trajectory error: ${error instanceof Error ? error.message : String(error)}`, "error");
49
+ }
50
+ },
51
+ });
52
+
53
+ pi.on("session_start", (_event, ctx) => { currentContext = ctx; });
54
+ pi.on("session_shutdown", () => {
55
+ currentContext = null;
56
+ stopServer();
57
+ });
58
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@pi-unipi/trajectory",
3
+ "version": "2.10.0",
4
+ "description": "Live UniPi trajectory inspector for Pi sessions",
5
+ "type": "module",
6
+ "main": "index.ts",
7
+ "license": "MIT",
8
+ "author": "Neuron Mr White",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/Neuron-Mr-White/unipi.git",
12
+ "directory": "packages/trajectory"
13
+ },
14
+ "keywords": [
15
+ "pi-package",
16
+ "pi-extension",
17
+ "trajectory",
18
+ "observability"
19
+ ],
20
+ "files": [
21
+ "index.ts",
22
+ "src/**/*.ts",
23
+ "README.md"
24
+ ],
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "peerDependencies": {
29
+ "@earendil-works/pi-coding-agent": "^0.84.0"
30
+ },
31
+ "devDependencies": {
32
+ "@types/node": "^25.6.0",
33
+ "typescript": "^6.0.0"
34
+ },
35
+ "pi": {
36
+ "extensions": [
37
+ "./index.ts"
38
+ ],
39
+ "skills": [],
40
+ "prompts": [],
41
+ "themes": []
42
+ }
43
+ }
package/src/capture.ts ADDED
@@ -0,0 +1,83 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { TelemetrySidecar } from "./telemetry.js";
3
+
4
+ interface RequestState {
5
+ id: number;
6
+ startedAt: number;
7
+ firstTokenAt?: number;
8
+ }
9
+
10
+ /** Register read-only observability hooks. Returned reader follows the active session. */
11
+ export function registerTelemetryCapture(pi: ExtensionAPI): () => ReturnType<TelemetrySidecar["read"]> {
12
+ let sidecar: TelemetrySidecar | null = null;
13
+ let requestSeq = 0;
14
+ let active: RequestState | null = null;
15
+ const toolStarts = new Map<string, number>();
16
+
17
+ const bind = (ctx: ExtensionContext) => {
18
+ sidecar = new TelemetrySidecar(ctx.sessionManager.getSessionId());
19
+ active = null;
20
+ toolStarts.clear();
21
+ };
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 }) });
24
+ };
25
+
26
+ pi.on("session_start", (_event, ctx) => { bind(ctx); });
27
+ pi.on("before_provider_request", (event, ctx) => {
28
+ const startedAt = Date.now();
29
+ active = { id: ++requestSeq, startedAt };
30
+ append("request", {
31
+ payload: event.payload,
32
+ provider: ctx.model?.provider,
33
+ model: ctx.model?.id,
34
+ 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 } : {});
42
+ });
43
+ pi.on("message_update", (event) => {
44
+ if (!active || active.firstTokenAt !== undefined) return;
45
+ 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 });
51
+ });
52
+ pi.on("message_end", (event) => {
53
+ if (event.message.role !== "assistant" || !active) return;
54
+ const completedAt = Date.now();
55
+ append("message-end", {
56
+ totalMs: completedAt - active.startedAt,
57
+ ttftMs: active.firstTokenAt === undefined ? undefined : active.firstTokenAt - active.startedAt,
58
+ decodingMs: active.firstTokenAt === undefined ? undefined : completedAt - active.firstTokenAt,
59
+ usage: event.message.usage,
60
+ stopReason: event.message.stopReason,
61
+ errorMessage: event.message.errorMessage,
62
+ }, { requestId: active.id });
63
+ active = null;
64
+ });
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
+ });
70
+ pi.on("tool_execution_end", (event) => {
71
+ const at = Date.now();
72
+ const startedAt = toolStarts.get(event.toolCallId);
73
+ append("tool-end", {
74
+ toolName: event.toolName,
75
+ result: event.result,
76
+ isError: event.isError,
77
+ durationMs: startedAt === undefined ? undefined : at - startedAt,
78
+ }, { toolCallId: event.toolCallId });
79
+ toolStarts.delete(event.toolCallId);
80
+ });
81
+
82
+ return () => sidecar?.read() ?? [];
83
+ }
package/src/project.ts ADDED
@@ -0,0 +1,193 @@
1
+ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
2
+ import type { TelemetryEvent } from "./telemetry.js";
3
+
4
+ export type TrajectoryKind = "system" | "user" | "assistant" | "tool" | "compaction" | "branch";
5
+
6
+ export interface TrajectoryUsage {
7
+ input?: number;
8
+ output?: number;
9
+ cacheRead?: number;
10
+ cacheWrite?: number;
11
+ reasoning?: number;
12
+ totalTokens?: number;
13
+ cost?: number;
14
+ }
15
+
16
+ export interface TrajectoryRecord {
17
+ id: string;
18
+ seq: number;
19
+ turn: number | null;
20
+ step: number | null;
21
+ kind: TrajectoryKind;
22
+ timestamp: number;
23
+ durationMs: number | null;
24
+ title: string;
25
+ preview: string;
26
+ input?: unknown;
27
+ output?: unknown;
28
+ thinking?: string;
29
+ provider?: string;
30
+ model?: string;
31
+ stopReason?: string;
32
+ isError?: boolean;
33
+ toolCallId?: string;
34
+ toolName?: string;
35
+ usage?: TrajectoryUsage;
36
+ tokensBefore?: number;
37
+ timing?: { ttftMs?: number; decodingMs?: number; totalMs?: number };
38
+ request?: unknown;
39
+ response?: unknown;
40
+ tools?: unknown;
41
+ }
42
+
43
+ export interface TrajectorySnapshot {
44
+ sessionId: string;
45
+ name?: string;
46
+ cwd?: string;
47
+ generatedAt: number;
48
+ records: TrajectoryRecord[];
49
+ }
50
+
51
+ type AnyRecord = Record<string, any>;
52
+
53
+ function timestamp(value: unknown, fallback = 0): number {
54
+ if (typeof value === "number" && Number.isFinite(value)) return value;
55
+ if (typeof value === "string") {
56
+ const parsed = Date.parse(value);
57
+ if (Number.isFinite(parsed)) return parsed;
58
+ }
59
+ return fallback;
60
+ }
61
+
62
+ function textContent(content: unknown): string {
63
+ if (typeof content === "string") return content;
64
+ if (!Array.isArray(content)) return "";
65
+ return content.flatMap((block: AnyRecord) => {
66
+ if (block?.type === "text") return [String(block.text ?? "")];
67
+ if (block?.type === "thinking") return [String(block.thinking ?? "")];
68
+ return [];
69
+ }).filter(Boolean).join("\n\n");
70
+ }
71
+
72
+ function preview(value: unknown, limit = 180): string {
73
+ const text = typeof value === "string" ? value : textContent(value);
74
+ const compact = text.replace(/\s+/g, " ").trim();
75
+ return compact.length > limit ? `${compact.slice(0, limit - 1)}…` : compact;
76
+ }
77
+
78
+ function usage(value: AnyRecord | undefined): TrajectoryUsage | undefined {
79
+ if (!value || typeof value !== "object") return undefined;
80
+ return {
81
+ ...(typeof value.input === "number" ? { input: value.input } : {}),
82
+ ...(typeof value.output === "number" ? { output: value.output } : {}),
83
+ ...(typeof value.cacheRead === "number" ? { cacheRead: value.cacheRead } : {}),
84
+ ...(typeof value.cacheWrite === "number" ? { cacheWrite: value.cacheWrite } : {}),
85
+ ...(typeof value.reasoning === "number" ? { reasoning: value.reasoning } : {}),
86
+ ...(typeof value.totalTokens === "number" ? { totalTokens: value.totalTokens } : {}),
87
+ ...(typeof value.cost?.total === "number" ? { cost: value.cost.total } : {}),
88
+ };
89
+ }
90
+
91
+ /** Project Pi's active append-only branch into UniPi's stable trajectory ledger. */
92
+ export function projectTrajectory(
93
+ entries: readonly SessionEntry[],
94
+ meta: { sessionId: string; name?: string; cwd?: string },
95
+ telemetry: readonly TelemetryEvent[] = [],
96
+ ): TrajectorySnapshot {
97
+ const records: TrajectoryRecord[] = [];
98
+ const pendingCalls = new Map<string, { record: TrajectoryRecord; startedAt: number }>();
99
+ let turn = 0;
100
+ let step = 0;
101
+
102
+ for (const [seq, typedEntry] of entries.entries()) {
103
+ const entry = typedEntry as unknown as AnyRecord;
104
+ const at = timestamp(entry.timestamp);
105
+ if (entry.type === "message") {
106
+ const message = entry.message as AnyRecord;
107
+ const messageAt = timestamp(message?.timestamp, at);
108
+ if (message?.role === "user") {
109
+ turn++;
110
+ step = 0;
111
+ const body = textContent(message.content);
112
+ records.push({ id: entry.id, seq, turn, step: null, kind: "user", timestamp: messageAt, durationMs: null, title: "USER", preview: preview(body), input: message.content });
113
+ continue;
114
+ }
115
+ if (message?.role === "assistant") {
116
+ step++;
117
+ const blocks = Array.isArray(message.content) ? message.content as AnyRecord[] : [];
118
+ const thinking = blocks.filter(block => block.type === "thinking").map(block => String(block.thinking ?? "")).join("\n\n");
119
+ const output = blocks.filter(block => block.type === "text").map(block => String(block.text ?? "")).join("\n\n");
120
+ const record: TrajectoryRecord = {
121
+ id: entry.id, seq, turn: turn || null, step, kind: "assistant", timestamp: messageAt,
122
+ durationMs: null, title: "ASSISTANT", preview: preview(output || thinking || (blocks.some(block => block.type === "toolCall") ? "Tool call only" : "")),
123
+ output, ...(thinking ? { thinking } : {}), provider: message.provider, model: message.model,
124
+ stopReason: message.stopReason, isError: message.stopReason === "error", usage: usage(message.usage),
125
+ };
126
+ records.push(record);
127
+ for (const [callIndex, block] of blocks.filter(block => block.type === "toolCall").entries()) {
128
+ const callAt = messageAt + callIndex * 0.001;
129
+ const call: TrajectoryRecord = {
130
+ id: `${entry.id}:${block.id}`, seq: seq + (callIndex + 1) / 1000, turn: turn || null, step,
131
+ kind: "tool", timestamp: callAt, durationMs: null, title: String(block.name ?? "TOOL"),
132
+ preview: preview(JSON.stringify(block.arguments ?? {})), input: block.arguments,
133
+ toolCallId: String(block.id ?? ""), toolName: String(block.name ?? ""),
134
+ };
135
+ records.push(call);
136
+ if (call.toolCallId) pendingCalls.set(call.toolCallId, { record: call, startedAt: callAt });
137
+ }
138
+ continue;
139
+ }
140
+ if (message?.role === "toolResult") {
141
+ const callId = String(message.toolCallId ?? "");
142
+ const pending = pendingCalls.get(callId);
143
+ const body = textContent(message.content);
144
+ if (pending) {
145
+ pending.record.output = message.content;
146
+ pending.record.preview = pending.record.preview || preview(body);
147
+ pending.record.durationMs = Math.max(0, messageAt - pending.startedAt);
148
+ pending.record.isError = Boolean(message.isError);
149
+ pending.record.usage = usage(message.usage);
150
+ pendingCalls.delete(callId);
151
+ } else {
152
+ records.push({ id: entry.id, seq, turn: turn || null, step: step || null, kind: "tool", timestamp: messageAt, durationMs: null, title: String(message.toolName ?? "TOOL"), preview: preview(body), output: message.content, toolCallId: callId, toolName: message.toolName, isError: Boolean(message.isError), usage: usage(message.usage) });
153
+ }
154
+ continue;
155
+ }
156
+ if (message?.role === "bashExecution") {
157
+ records.push({ id: entry.id, seq, turn: turn || null, step: step || null, kind: "tool", timestamp: messageAt, durationMs: null, title: "bash", preview: preview(message.command), input: { command: message.command }, output: message.output, isError: Boolean(message.cancelled || (message.exitCode ?? 0) !== 0) });
158
+ }
159
+ continue;
160
+ }
161
+ if (entry.type === "compaction") {
162
+ records.push({ id: entry.id, seq, turn: null, step: null, kind: "compaction", timestamp: at, durationMs: null, title: "COMPACTED", preview: preview(entry.summary), output: entry.summary, usage: usage(entry.usage), tokensBefore: entry.tokensBefore });
163
+ } else if (entry.type === "branch_summary") {
164
+ records.push({ id: entry.id, seq, turn: null, step: null, kind: "branch", timestamp: at, durationMs: null, title: "BRANCH", preview: preview(entry.summary), output: entry.summary, usage: usage(entry.usage) });
165
+ }
166
+ }
167
+
168
+ 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;
173
+ const events = telemetry.filter(event => event.requestId === requestId);
174
+ const request = events.find(event => event.type === "request")?.data as AnyRecord | undefined;
175
+ const response = events.find(event => event.type === "response")?.data;
176
+ const end = events.find(event => event.type === "message-end")?.data as AnyRecord | undefined;
177
+ record.request = request;
178
+ record.response = response;
179
+ record.tools = request?.tools;
180
+ record.timing = end === undefined ? undefined : {
181
+ ...(typeof end.ttftMs === "number" ? { ttftMs: end.ttftMs } : {}),
182
+ ...(typeof end.decodingMs === "number" ? { decodingMs: end.decodingMs } : {}),
183
+ ...(typeof end.totalMs === "number" ? { totalMs: end.totalMs } : {}),
184
+ };
185
+ if (typeof end?.totalMs === "number") record.durationMs = end.totalMs;
186
+ }
187
+ for (const record of records.filter(record => record.kind === "tool" && record.toolCallId)) {
188
+ const end = telemetry.find(event => event.type === "tool-end" && event.toolCallId === record.toolCallId)?.data as AnyRecord | undefined;
189
+ if (typeof end?.durationMs === "number") record.durationMs = end.durationMs;
190
+ }
191
+
192
+ return { ...meta, generatedAt: Date.now(), records };
193
+ }
package/src/server.ts ADDED
@@ -0,0 +1,88 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createServer, type Server, type ServerResponse } from "node:http";
3
+ import type { TrajectorySnapshot } from "./project.js";
4
+ import { TRAJECTORY_APP, TRAJECTORY_CSS, TRAJECTORY_HTML, TRAJECTORY_THEME_CSS } from "./ui.js";
5
+
6
+ export interface TrajectoryServerOptions {
7
+ snapshot: () => TrajectorySnapshot;
8
+ port?: number;
9
+ maxPort?: number;
10
+ }
11
+
12
+ const HOST = "127.0.0.1";
13
+
14
+ function json(res: ServerResponse, value: unknown): void {
15
+ res.writeHead(200, {
16
+ "Cache-Control": "no-store",
17
+ "Content-Type": "application/json; charset=utf-8",
18
+ "X-Content-Type-Options": "nosniff",
19
+ });
20
+ res.end(JSON.stringify(value));
21
+ }
22
+
23
+ export class TrajectoryServer {
24
+ private server: Server | null = null;
25
+ private readonly options: Required<Pick<TrajectoryServerOptions, "port" | "maxPort">> & TrajectoryServerOptions;
26
+ url: string | null = null;
27
+
28
+ constructor(options: TrajectoryServerOptions) {
29
+ this.options = { ...options, port: options.port ?? 8176, maxPort: options.maxPort ?? 8186 };
30
+ }
31
+
32
+ async start(): Promise<string> {
33
+ if (this.url) return this.url;
34
+ this.server = createServer((req, res) => {
35
+ try {
36
+ const url = new URL(req.url ?? "/", `http://${HOST}`);
37
+ if (req.method !== "GET") { res.writeHead(405).end("Method Not Allowed"); return; }
38
+ if (url.pathname === "/api/snapshot") { json(res, this.options.snapshot()); return; }
39
+ if (url.pathname === "/favicon.ico") { res.writeHead(204).end(); return; }
40
+ if (url.pathname === "/app.js") {
41
+ res.writeHead(200, { "Cache-Control": "no-store", "Content-Type": "application/javascript; charset=utf-8", "X-Content-Type-Options": "nosniff" });
42
+ res.end(TRAJECTORY_APP); return;
43
+ }
44
+ if (url.pathname === "/style.css") {
45
+ res.writeHead(200, { "Cache-Control": "no-store", "Content-Type": "text/css; charset=utf-8", "X-Content-Type-Options": "nosniff" });
46
+ res.end(`${TRAJECTORY_CSS}\n${TRAJECTORY_THEME_CSS}`); return;
47
+ }
48
+ if (url.pathname === "/") {
49
+ res.writeHead(200, { "Cache-Control": "no-store", "Content-Security-Policy": "default-src 'self'; style-src 'self'", "Content-Type": "text/html; charset=utf-8", "X-Frame-Options": "DENY" });
50
+ res.end(TRAJECTORY_HTML); return;
51
+ }
52
+ res.writeHead(404).end("Not Found");
53
+ } catch (error) {
54
+ res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
55
+ res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
56
+ }
57
+ });
58
+ for (let port = this.options.port; port <= this.options.maxPort; port++) {
59
+ try {
60
+ await new Promise<void>((resolve, reject) => {
61
+ const onError = (error: Error) => { this.server?.off("listening", onListening); reject(error); };
62
+ const onListening = () => { this.server?.off("error", onError); resolve(); };
63
+ this.server!.once("error", onError).once("listening", onListening).listen(port, HOST);
64
+ });
65
+ this.url = `http://${HOST}:${port}`;
66
+ return this.url;
67
+ } catch (error) {
68
+ if ((error as NodeJS.ErrnoException).code !== "EADDRINUSE") throw error;
69
+ }
70
+ }
71
+ this.server = null;
72
+ throw new Error(`No trajectory port available (${this.options.port}-${this.options.maxPort})`);
73
+ }
74
+
75
+ stop(): void {
76
+ this.server?.close();
77
+ this.server = null;
78
+ this.url = null;
79
+ }
80
+ }
81
+
82
+ export function openBrowser(url: string): void {
83
+ const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
84
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
85
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
86
+ child.on("error", () => {});
87
+ child.unref();
88
+ }
@@ -0,0 +1,75 @@
1
+ import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ export type TelemetryEventType =
6
+ | "request"
7
+ | "response"
8
+ | "first-token"
9
+ | "message-end"
10
+ | "tool-start"
11
+ | "tool-end";
12
+
13
+ export interface TelemetryEvent {
14
+ v: 1;
15
+ type: TelemetryEventType;
16
+ at: number;
17
+ requestId?: number | string;
18
+ toolCallId?: string;
19
+ data?: unknown;
20
+ }
21
+
22
+ const SECRET_KEY = /authorization|api[-_]?key|token|cookie|secret|password|credential/i;
23
+ const SECRET_VALUE = /^(?:bearer\s+|sk-[a-z0-9_-]{12,}|gh[pousr]_[a-z0-9]{12,}|AIza[a-z0-9_-]{20,})/i;
24
+ const MAX_STRING = 200_000;
25
+ /** Hard ceiling on a serialized event; guards against pathological payloads. */
26
+ const MAX_EVENT_BYTES = 2_000_000;
27
+ /** Sidecar reads are capped so projection cannot allocate unbounded memory. */
28
+ const MAX_READ_BYTES = 20_000_000;
29
+
30
+ export function redactTelemetry(value: unknown, seen = new WeakSet<object>()): unknown {
31
+ if (typeof value === "string") {
32
+ if (SECRET_VALUE.test(value.trim())) return "[REDACTED]";
33
+ return value.length > MAX_STRING ? `${value.slice(0, MAX_STRING)}…[truncated]` : value;
34
+ }
35
+ if (value === null || typeof value !== "object") return value;
36
+ if (seen.has(value)) return "[Circular]";
37
+ seen.add(value);
38
+ if (Array.isArray(value)) return value.map(item => redactTelemetry(item, seen));
39
+ return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([key, item]) => [
40
+ key,
41
+ SECRET_KEY.test(key) ? "[REDACTED]" : redactTelemetry(item, seen),
42
+ ]));
43
+ }
44
+
45
+ export class TelemetrySidecar {
46
+ readonly file: string;
47
+
48
+ constructor(sessionId: string, root = join(homedir(), ".unipi", "trajectory")) {
49
+ mkdirSync(root, { recursive: true, mode: 0o700 });
50
+ this.file = join(root, `${sessionId.replace(/[^a-zA-Z0-9._-]/g, "_")}.jsonl`);
51
+ }
52
+
53
+ append(event: Omit<TelemetryEvent, "v">): void {
54
+ const serialized = JSON.stringify(redactTelemetry({ v: 1, ...event }));
55
+ if (serialized.length > MAX_EVENT_BYTES) return;
56
+ appendFileSync(this.file, `${serialized}\n`, { encoding: "utf8", mode: 0o600 });
57
+ }
58
+
59
+ read(): TelemetryEvent[] {
60
+ try {
61
+ let lines = readFileSync(this.file, "utf8");
62
+ if (lines.length > MAX_READ_BYTES) {
63
+ // Keep the most recent events; skip complete earlier lines.
64
+ const keep = lines.slice(lines.length - MAX_READ_BYTES);
65
+ lines = keep.slice(keep.indexOf("\n") + 1);
66
+ }
67
+ return lines.split("\n").filter(Boolean).flatMap(line => {
68
+ try { return [JSON.parse(line) as TelemetryEvent]; } catch { return []; }
69
+ });
70
+ } catch {
71
+ return [];
72
+ }
73
+ }
74
+ }
75
+
package/src/ui.ts ADDED
@@ -0,0 +1,67 @@
1
+ export const TRAJECTORY_HTML = `<!doctype html>
2
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
3
+ <title>UniPi Trajectory</title><link rel="stylesheet" href="/style.css"></head><body>
4
+ <div id="app"><nav class="product-nav" aria-label="UniPi"><strong>UniPi</strong><span>Trajectory</span></nav><header class="toolbar" aria-label="Trajectory toolbar">
5
+ <button id="duration" class="toggle" type="button" aria-pressed="false">◷ Duration</button>
6
+ <button id="turns" class="toggle" type="button" aria-pressed="false">Turns</button>
7
+ <button id="calls" class="toggle" type="button" aria-pressed="false">Calls</button>
8
+ <span class="spacer"></span><span id="connection" class="connection">UniPi · Live</span>
9
+ <button id="theme" class="theme-toggle" type="button" aria-label="Toggle dark theme" title="Toggle theme">☾</button>
10
+ <label class="search"><span>⌕</span><input id="search" type="search" aria-label="Search trajectory" placeholder="Search trajectory"></label>
11
+ </header>
12
+ <section class="timeline" aria-label="Trajectory timeline"><div class="lane-labels"><span>Input</span><span>Model</span><span>Tools</span></div><div id="timeline-track" class="timeline-track" tabindex="0" aria-label="Timeline overview; drag horizontally to focus events"></div></section>
13
+ <div class="workspace"><section id="ledger-pane" class="ledger-pane"><table aria-label="Trajectory event ledger"><colgroup><col class="event-column"><col></colgroup><tbody id="ledger"></tbody></table><div id="empty" class="empty">Trajectory will appear here</div></section>
14
+ <aside id="details" class="details" aria-label="Event details" hidden><header><div><strong id="detail-kind"></strong><small id="detail-location"></small></div><button id="detail-close" aria-label="Close details">×</button></header><nav id="detail-tabs" class="tabs" role="tablist"></nav><section id="detail-body" class="detail-body"></section></aside></div></div>
15
+ <script type="module" src="/app.js"></script></body></html>`;
16
+
17
+ export const TRAJECTORY_CSS = `
18
+ :root{color-scheme:light;--bg:#f7f8fa;--panel:#fff;--line:#e4e6eb;--muted:#74777d;--text:#202124;--blue:#3977ef;--user:#4d8bf5;--model:#9b70df;--tool:#e39b45;--error:#dc4b4b;font:13px Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text)}*{box-sizing:border-box}body{margin:0;overflow:hidden}button,input{font:inherit}.product-nav{height:34px;display:flex;align-items:center;gap:20px;padding:0 20px;border-bottom:1px solid var(--line);background:var(--panel);font-size:12px}.product-nav strong{font-weight:600}.product-nav span{height:34px;display:flex;align-items:center;border-bottom:2px solid var(--blue);color:var(--blue)}.toolbar{height:32px;display:flex;align-items:center;gap:10px;padding:0 20px;background:var(--panel);border-bottom:1px solid var(--line)}button{border:1px solid var(--line);background:#fff;border-radius:4px;color:#50535a;padding:3px 8px;cursor:pointer}.toggle{border-color:transparent;background:transparent;padding:2px}.toggle.active{background:#edf3ff;border-color:#b9cdf7;color:#245ec8}.spacer{flex:1}.connection{font-size:11px;color:var(--muted)}.connection.live{color:#27865b}.search{display:flex;align-items:center;gap:6px;width:min(280px,32vw);border:1px solid var(--line);background:#fafbfc;border-radius:6px;padding:5px 9px}.search input{width:100%;border:0;outline:0;background:transparent}.timeline{height:84px;display:grid;grid-template-columns:56px 1fr;background:var(--panel);border-bottom:1px solid var(--line)}.lane-labels{position:relative;border-right:1px solid var(--line);color:var(--muted);font-size:10px;line-height:1}.lane-labels span{position:absolute;right:5px;height:12px}.lane-labels span:nth-child(1){top:12px}.lane-labels span:nth-child(2){top:35px}.lane-labels span:nth-child(3){top:58px}.timeline-track{position:relative;overflow:hidden;cursor:crosshair}.span{position:absolute;height:12px;min-width:2px;border-radius:1px;opacity:.9;transition:opacity .1s,filter .1s}.span:hover,.span.selected{opacity:1;filter:brightness(.92);z-index:3}.span.user{top:12px;background:var(--user)}.span.assistant{top:35px;background:var(--model)}.span.tool{top:58px;background:var(--tool)}.span.compaction,.span.branch{top:35px;background:#8b919d}.span.error{background:var(--error)}.range{position:absolute;inset:0 auto 0 0;background:#3977ef1c;border-left:1px solid var(--blue);border-right:1px solid var(--blue);pointer-events:none;z-index:2}.workspace{height:calc(100vh - 150px);display:grid;grid-template-columns:minmax(360px,1fr) minmax(340px,38%)}.workspace.details-closed{grid-template-columns:1fr}.ledger-pane{overflow:auto;background:var(--panel)}table{width:100%;border-collapse:collapse;table-layout:fixed}tr{border-bottom:1px solid #eceef1;cursor:pointer}tr.turn-start{border-top:3px solid #cfd3da}tr:hover{background:#f6f8fc}tr.selected{background:#edf3ff}tr.filtered,tr.timeline-filtered{display:none}td{height:30px;vertical-align:middle;padding:0 8px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.event-column{width:172px}.event{position:relative;overflow:visible!important;color:#555a63;text-align:right;padding-left:76px!important}.event::before{content:'•';position:absolute;left:13px;color:#81858d}.request-boundary{position:absolute;top:3px;left:20px;display:inline-grid;align-items:center;height:14px;max-width:68px;padding:0 5px;border:1px solid var(--line);border-radius:2px;background:var(--panel);color:var(--muted);font:9px/12px ui-monospace,SFMono-Regular,Menlo,monospace;white-space:nowrap}.content{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.badge{display:inline-flex;align-items:center;gap:5px;font-size:10px;font-weight:700;letter-spacing:.045em}.badge.user{color:var(--user)}.badge.assistant{color:var(--model)}.badge.tool{color:#b87322}.badge.compaction,.badge.branch{color:#6f7580}.request{display:inline-block;margin-right:6px;padding:2px 5px;border:1px solid #d7d9df;border-radius:4px;color:#666;font-weight:500;letter-spacing:0}.location{display:none}.content{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;line-height:1.4}.arrow{color:#a3a6ad;margin:0 6px}.result{color:#656a73}.error-text{color:var(--error)}.empty{display:none;padding:50px;text-align:center;color:var(--muted)}.empty.visible{display:block}.details{border-left:1px solid var(--line);background:var(--panel);overflow:hidden}.details>header{height:56px;display:flex;align-items:center;justify-content:space-between;padding:10px 14px;border-bottom:1px solid var(--line)}.details>header div{display:flex;flex-direction:column;gap:3px}.details small{color:var(--muted)}#detail-close{font-size:18px;border:0}.tabs{display:flex;gap:2px;padding:8px 12px 0;border-bottom:1px solid var(--line);overflow:auto}.tabs button{border:0;border-bottom:2px solid transparent;border-radius:0;background:none;padding:8px}.tabs button.active{color:var(--blue);border-bottom-color:var(--blue)}.detail-body{height:calc(100% - 101px);overflow:auto;padding:15px}.summary{display:grid;grid-template-columns:120px 1fr;gap:8px 12px;margin:0}.summary dt{color:var(--muted)}.summary dd{margin:0;overflow-wrap:anywhere}.payload{margin:0;padding:13px;background:#f5f6f8;border:1px solid #e5e7eb;border-radius:6px;white-space:pre-wrap;overflow-wrap:anywhere;font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace}.folded{display:none}@media(max-width:760px){.workspace{grid-template-columns:1fr}.details{position:absolute;inset:150px 0 0 10%;z-index:10;box-shadow:-8px 0 24px #0002}.event-column{width:156px}.timeline{grid-template-columns:50px 1fr}.request-boundary{left:18px}.search{width:160px}.product-nav{padding:0 10px}.toolbar{padding:0 10px}}
19
+ `;
20
+
21
+ export const TRAJECTORY_THEME_CSS = `
22
+ :root{
23
+ color-scheme:light;
24
+ --background:oklch(0.9809 0.0025 228.7836);--foreground:oklch(0.3211 0 0);
25
+ --card:oklch(1 0 0);--card-foreground:oklch(0.3211 0 0);
26
+ --primary:oklch(0.8677 0.0735 7.0855);--primary-foreground:oklch(0 0 0);
27
+ --secondary:oklch(0.8148 0.0819 225.7537);--secondary-foreground:oklch(0 0 0);
28
+ --muted:oklch(0.8828 0.0285 98.1033);--muted-foreground:oklch(0.5382 0 0);
29
+ --accent:oklch(0.968 0.211 109.7692);--accent-foreground:oklch(0 0 0);
30
+ --destructive:oklch(0.6368 0.2078 25.3313);--border:oklch(0.8699 0 0);--input:oklch(0.8699 0 0);--ring:oklch(0.8677 0.0735 7.0855);
31
+ --trajectory-user:oklch(0.67 0.085 235);--trajectory-model:oklch(0.67 0.12 307);--trajectory-tool:oklch(0.72 0.16 63);--radius:.5rem;--shadow-sm:0 1px 3px hsl(0 0% 0%/.1),0 1px 2px -1px hsl(0 0% 0%/.1);--shadow-lg:0 1px 3px hsl(0 0% 0%/.1),0 8px 18px -5px hsl(0 0% 0%/.14);
32
+ --font-sans:Poppins,Inter,ui-sans-serif,system-ui,sans-serif;--font-mono:"Roboto Mono",ui-monospace,SFMono-Regular,Menlo,monospace;
33
+ }
34
+ :root.dark{color-scheme:dark;--trajectory-user:#9aa7b5;--trajectory-model:#9876c2;--trajectory-tool:#e9943a;--background:oklch(0.2303 0.0125 264.2926);--foreground:oklch(0.9219 0 0);--card:oklch(0.321 0.0078 223.6661);--card-foreground:oklch(0.9219 0 0);--primary:oklch(0.8027 0.1355 349.2347);--primary-foreground:oklch(0 0 0);--secondary:oklch(0.7395 0.2268 142.8504);--secondary-foreground:oklch(0 0 0);--muted:oklch(0.3867 0 0);--muted-foreground:oklch(0.7155 0 0);--accent:oklch(0.8148 0.0819 225.7537);--accent-foreground:oklch(0 0 0);--border:oklch(0.3867 0 0);--input:oklch(0.3867 0 0);--ring:oklch(0.8027 0.1355 349.2347)}
35
+ html,body{background:var(--background);color:var(--foreground);font-family:var(--font-sans)}
36
+ .product-nav,.toolbar{background:var(--card);border-color:var(--border)}.toolbar{height:32px;margin:0;padding:0 20px;border-width:0 0 1px;border-style:solid;border-radius:0;box-shadow:none}
37
+ button{background:var(--card);color:var(--card-foreground);border-color:var(--border);border-radius:var(--radius);transition:transform .12s,box-shadow .12s,background .12s}button:hover{background:color-mix(in oklch,var(--muted) 42%,var(--card));box-shadow:var(--shadow-sm)}button:active{transform:translateY(1px)}button:focus-visible,input:focus-visible,.timeline-track:focus-visible{outline:2px solid var(--ring);outline-offset:2px}
38
+ .toggle.active{background:var(--primary);border-color:color-mix(in oklch,var(--primary) 72%,var(--border));color:var(--primary-foreground)}.theme-toggle{width:32px;height:32px;padding:0;font-size:16px}.connection.live{color:color-mix(in oklch,var(--secondary) 80%,var(--foreground))}.search{background:color-mix(in oklch,var(--card) 78%,var(--background));border-color:var(--input);border-radius:var(--radius)}.search input{color:var(--foreground)}
39
+ .timeline{height:84px;margin:0;background:var(--card);border-width:0 0 1px;border-style:solid;border-color:var(--border);border-radius:0;box-shadow:none}.timeline-track{border-left-color:var(--border);border-radius:0;background:transparent}.lane-labels,.location,.details small,.summary dt{color:var(--muted-foreground)}.span.user{background:var(--trajectory-user)}.span.assistant{background:var(--trajectory-model)}.span.tool{background:var(--trajectory-tool)}.span.compaction,.span.branch{background:var(--muted)}.range{background:color-mix(in oklch,var(--secondary) 20%,transparent);border-color:var(--secondary)}
40
+ .workspace{height:calc(100vh - 150px);gap:0;padding:0}.ledger-pane,.details{background:var(--card);border:0;border-radius:0;box-shadow:none;overflow:auto}.details{border-left:1px solid var(--border);box-shadow:var(--shadow-lg)}tr{border-bottom-color:color-mix(in oklch,var(--border) 72%,transparent)}tr.turn-start{border-top-color:var(--secondary)}tr:hover{background:color-mix(in oklch,var(--muted) 24%,transparent)}tr.selected{background:color-mix(in oklch,var(--secondary) 18%,transparent)}.content{font-family:var(--font-mono)}.request{background:color-mix(in oklch,var(--muted) 26%,transparent);border-color:var(--border);color:var(--muted-foreground)}.badge.user{color:var(--trajectory-user)}.badge.assistant{padding:2px 5px;border-radius:3px;background:color-mix(in oklch,var(--trajectory-model) 18%,transparent);color:var(--trajectory-model)}.badge.tool{color:var(--trajectory-tool)}.result{color:var(--muted-foreground)}.error-text{color:var(--destructive)}
41
+ .details>header,.tabs{border-color:var(--border)}.tabs button.active{color:var(--secondary);border-bottom-color:var(--secondary)}.payload{font-family:var(--font-mono);background:color-mix(in oklch,var(--background) 70%,var(--card));border-color:var(--border)}
42
+ @media(max-width:760px){.toolbar{margin:0}.timeline{margin:0}.workspace{height:calc(100vh - 150px);padding:0}.details{inset:150px 0 0 6%;border-radius:0}.connection{display:none}}
43
+ `;
44
+
45
+ export const TRAJECTORY_APP = String.raw`
46
+ const q=s=>document.querySelector(s), ledger=q('#ledger'), empty=q('#empty'), details=q('#details'), workspace=q('.workspace'), track=q('#timeline-track');
47
+ const savedTheme=localStorage.getItem('unipi-trajectory-theme');const prefersDark=matchMedia('(prefers-color-scheme: dark)').matches;document.documentElement.classList.toggle('dark',savedTheme?savedTheme==='dark':prefersDark);
48
+ let snapshot={records:[]}, selected=null, activeTab='summary', revision='', collapseTurns=false, collapseCalls=false, timelineRange=null, dragStart=null, actualDuration=false;
49
+ const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
50
+ const stringify=v=>typeof v==='string'?v:JSON.stringify(v??null,null,2);
51
+ const fmtTime=n=>n?new Date(n).toLocaleTimeString(undefined,{hour:'2-digit',minute:'2-digit',second:'2-digit',fractionalSecondDigits:3}):'';
52
+ const fmtDuration=n=>n==null?'':n<1000?Math.round(n)+' ms':(n/1000).toFixed(n<10000?2:1)+' s';
53
+ const location=r=>r.turn==null?'Between turns':'Turn '+r.turn+(r.step==null?'':' · Step '+r.step);
54
+ function has(v){if(v==null)return false;if(typeof v==='string')return v.trim().length>0;if(Array.isArray(v))return v.length>0;if(typeof v==='object')return Object.keys(v).length>0;return true}
55
+ function tabs(r){const values=r.kind==='tool'?[['summary',r],['payload',r.input],['result',r.output],['timing',r.timing]]:r.kind==='assistant'?[['summary',r],['output',r.output],['thinking',r.thinking],['request',r.request],['tools',r.tools],['usage',r.usage],['timing',r.timing]]:r.kind==='user'?[['summary',r],['input',r.input]]:[['summary',r],['output',r.output],['usage',r.usage]];return values.filter(([tab,value])=>tab==='summary'||has(value)).map(([tab])=>tab)}
56
+ function detailValue(r,tab){if(tab==='payload'||tab==='input')return r.input;if(tab==='result'||tab==='output')return r.output;if(tab==='thinking')return r.thinking;if(tab==='request')return r.request;if(tab==='tools')return r.tools;if(tab==='usage')return r.usage;if(tab==='timing')return r.timing;return null}
57
+ function summary(r){const rows=[['Status',r.isError?'Error':r.durationMs==null&&r.kind==='tool'?'Running':'Complete'],['Started',fmtTime(r.timestamp)],['Duration',fmtDuration(r.durationMs)],['TTFT',fmtDuration(r.timing?.ttftMs)],['Decoding',fmtDuration(r.timing?.decodingMs)],['Provider',r.provider],['Model',r.model],['Stop reason',r.stopReason],['Tool',r.toolName],['Call ID',r.toolCallId],['Input tokens',r.usage?.input],['Cached tokens',r.usage?.cacheRead],['Cache created',r.usage?.cacheWrite],['Output tokens',r.usage?.output],['Reasoning tokens',r.usage?.reasoning],['Total tokens',r.usage?.totalTokens],['Cost',r.usage?.cost==null?undefined:'$'+r.usage.cost.toFixed(6)],['Tokens before compaction',r.tokensBefore]].filter(x=>has(x[1]));return '<dl class="summary">'+rows.map(x=>'<dt>'+esc(x[0])+'</dt><dd>'+esc(x[1])+'</dd>').join('')+'</dl>'}
58
+ function showDetails(r){selected=r.id;details.hidden=false;workspace.classList.remove('details-closed');q('#detail-kind').textContent=r.title;q('#detail-location').textContent=location(r);const available=tabs(r);if(!available.includes(activeTab))activeTab='summary';q('#detail-tabs').innerHTML=available.map(t=>'<button role="tab" data-tab="'+t+'" class="'+(t===activeTab?'active':'')+'">'+esc(t[0].toUpperCase()+t.slice(1))+'</button>').join('');q('#detail-tabs').querySelectorAll('button').forEach(b=>b.onclick=()=>{activeTab=b.dataset.tab;showDetails(r)});q('#detail-body').innerHTML=activeTab==='summary'?summary(r):'<pre class="payload">'+esc(stringify(detailValue(r,activeTab)))+'</pre>';renderRows()}
59
+ function renderRows(){const query=q('#search').value.toLowerCase();let lastTurn=null,request=0;ledger.innerHTML='';for(const r of snapshot.records){if(r.kind==='assistant')request++;const hay=JSON.stringify(r).toLowerCase();const filtered=query&&!hay.includes(query);const timelineFiltered=timelineRange&&(r.timestamp+(r.durationMs??0)<timelineRange[0]||r.timestamp>timelineRange[1]);const foldTurn=collapseTurns&&r.turn!=null&&r.turn===lastTurn&&r.kind!=='user';const foldCall=collapseCalls&&r.kind==='tool';const tr=document.createElement('tr');tr.dataset.id=r.id;tr.dataset.kind=r.kind;tr.className=[r.turn!==lastTurn&&r.turn!=null?'turn-start':'',selected===r.id?'selected':'',filtered?'filtered':'',timelineFiltered?'timeline-filtered':'',(foldTurn||foldCall)?'folded':''].filter(Boolean).join(' ');const req=r.kind==='assistant'?'<span class="request-boundary">Request #'+request+'</span>':'';const result=r.kind==='tool'&&r.output!=null?'<span class="arrow">→</span><span class="result '+(r.isError?'error-text':'')+'">'+esc((typeof r.output==='string'?r.output:JSON.stringify(r.output)).replace(/\s+/g,' ').slice(0,180))+'</span>':'';tr.innerHTML='<td class="event">'+req+'<span class="badge '+r.kind+'">'+esc(r.title)+'</span><span class="location">'+esc(location(r))+'</span></td><td class="content">'+esc(r.preview||'')+result+'</td>';tr.onclick=()=>showDetails(r);ledger.append(tr);if(r.turn!=null)lastTurn=r.turn}empty.classList.toggle('visible',snapshot.records.length===0)}
60
+ function timelineModel(){const records=snapshot.records;if(!records.length)return null;if(!actualDuration)return{min:0,max:records.length,spans:records.map((r,index)=>({r,start:index,end:index+1}))};const ordered=records.map((r,index)=>({r,index,start:r.timestamp,end:r.timestamp+Math.max(0,r.durationMs??0)})).sort((a,b)=>a.start-b.start||a.index-b.index);const offsets=new Map;let removed=0,covered=null;for(const span of ordered){if(covered!=null&&span.start>covered)removed+=span.start-covered;offsets.set(span.r.id,removed);covered=covered==null?span.end:Math.max(covered,span.end)}const spans=records.map(r=>({r,start:r.timestamp-(offsets.get(r.id)??0),end:r.timestamp+Math.max(0,r.durationMs??0)-(offsets.get(r.id)??0)}));const min=Math.min(...spans.map(x=>x.start)),rawMax=Math.max(...spans.map(x=>x.end));return{min,max:Math.max(min+1,rawMax),spans}}
61
+ function renderTimeline(){track.innerHTML='';const d=timelineModel();if(!d)return;for(const {r,start,end} of d.spans){const s=document.createElement('span');s.className='span '+r.kind+(r.isError?' error':'')+(selected===r.id?' selected':'');s.style.left=((start-d.min)/(d.max-d.min)*100)+'%';s.style.width=actualDuration?Math.max(.13,((end-start)/(d.max-d.min)*100))+'%':'max(2px,calc('+(100/snapshot.records.length)+'% - 2px))';s.title=r.title+(fmtTime(r.timestamp)?'\n'+fmtTime(r.timestamp):'')+(fmtDuration(r.durationMs)?' · '+fmtDuration(r.durationMs):'');s.onclick=e=>{e.stopPropagation();showDetails(r)};track.append(s)}if(timelineRange){const range=document.createElement('span');range.className='range';range.style.left=((timelineRange[0]-d.min)/(d.max-d.min)*100)+'%';range.style.width=((timelineRange[1]-timelineRange[0])/(d.max-d.min)*100)+'%';track.append(range)}}
62
+ function render(){q('#connection').textContent='UniPi · Live';q('#connection').className='connection live';renderRows();renderTimeline();if(selected){const r=snapshot.records.find(x=>x.id===selected);if(r)showDetails(r)}}
63
+ async function update(){try{const response=await fetch('/api/snapshot',{cache:'no-store'});if(!response.ok)throw Error(response.status);const data=await response.json(), next=JSON.stringify(data);if(next!==revision){revision=next;snapshot=data;render()}}catch(error){q('#connection').textContent='UniPi';q('#connection').className='connection'}}
64
+ function syncThemeButton(){const dark=document.documentElement.classList.contains('dark');q('#theme').textContent=dark?'☀':'☾';q('#theme').setAttribute('aria-label',dark?'Use light theme':'Use dark theme')}syncThemeButton();q('#theme').onclick=()=>{const dark=document.documentElement.classList.toggle('dark');localStorage.setItem('unipi-trajectory-theme',dark?'dark':'light');syncThemeButton()};
65
+ q('#detail-close').onclick=()=>{selected=null;details.hidden=true;workspace.classList.add('details-closed');renderRows();renderTimeline()};q('#search').oninput=renderRows;
66
+ function timelinePoint(e){const d=timelineModel(),box=track.getBoundingClientRect();return d?d.min+Math.max(0,Math.min(1,(e.clientX-box.left)/box.width))*(d.max-d.min):0}track.onpointerdown=e=>{if(e.button===2){timelineRange=null;renderRows();renderTimeline();return}if(e.button!==0)return;dragStart=timelinePoint(e);track.setPointerCapture?.(e.pointerId)};track.onpointerup=e=>{if(dragStart==null)return;const end=timelinePoint(e);timelineRange=[Math.min(dragStart,end),Math.max(dragStart,end)];dragStart=null;renderRows();renderTimeline()};track.oncontextmenu=e=>e.preventDefault();track.onkeydown=e=>{if(e.key==='Escape'){timelineRange=null;renderRows();renderTimeline()}};q('#turns').onclick=e=>{collapseTurns=!collapseTurns;e.currentTarget.classList.toggle('active',collapseTurns);e.currentTarget.setAttribute('aria-pressed',collapseTurns);renderRows()};q('#calls').onclick=e=>{collapseCalls=!collapseCalls;e.currentTarget.classList.toggle('active',collapseCalls);e.currentTarget.setAttribute('aria-pressed',collapseCalls);renderRows()};q('#duration').onclick=e=>{actualDuration=!actualDuration;e.currentTarget.classList.toggle('active',actualDuration);e.currentTarget.setAttribute('aria-pressed',actualDuration);timelineRange=null;renderRows();renderTimeline()};workspace.classList.add('details-closed');update();setInterval(update,500);
67
+ `;