@pi-unipi/unipi 2.9.0 → 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.
Files changed (35) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +5 -1
  3. package/package.json +23 -22
  4. package/packages/ask-user/package.json +3 -3
  5. package/packages/autocomplete/package.json +2 -2
  6. package/packages/autocomplete/src/constants.ts +7 -0
  7. package/packages/background-tasks/package.json +3 -3
  8. package/packages/btw/package.json +3 -3
  9. package/packages/compactor/package.json +4 -4
  10. package/packages/core/package.json +1 -1
  11. package/packages/footer/package.json +3 -3
  12. package/packages/image/package.json +2 -2
  13. package/packages/info-screen/package.json +3 -3
  14. package/packages/input-shortcuts/package.json +3 -3
  15. package/packages/kanboard/package.json +3 -3
  16. package/packages/mcp/package.json +3 -3
  17. package/packages/memory/package.json +3 -3
  18. package/packages/milestone/package.json +3 -3
  19. package/packages/notify/package.json +3 -3
  20. package/packages/ralph/package.json +3 -3
  21. package/packages/subagents/package.json +3 -3
  22. package/packages/trajectory/README.md +43 -0
  23. package/packages/trajectory/index.ts +58 -0
  24. package/packages/trajectory/package.json +43 -0
  25. package/packages/trajectory/src/capture.ts +83 -0
  26. package/packages/trajectory/src/project.ts +193 -0
  27. package/packages/trajectory/src/server.ts +88 -0
  28. package/packages/trajectory/src/telemetry.ts +75 -0
  29. package/packages/trajectory/src/ui.ts +67 -0
  30. package/packages/unipi/bundled.js +760 -259
  31. package/packages/unipi/index.ts +2 -0
  32. package/packages/updater/package.json +3 -3
  33. package/packages/utility/package.json +3 -3
  34. package/packages/web-api/package.json +2 -2
  35. package/packages/workflow/package.json +2 -2
@@ -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
+ }
@@ -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
+ }
@@ -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
+
@@ -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
+ `;