pi-trace-viewer 0.1.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/LICENSE +21 -0
- package/README.md +151 -0
- package/README.zh.md +151 -0
- package/assets/images/compaction-context-viewport.png +0 -0
- package/assets/images/pi-export-viewport.png +0 -0
- package/assets/images/realtime-session-viewport.png +0 -0
- package/package.json +62 -0
- package/src/collector.ts +218 -0
- package/src/index.ts +145 -0
- package/src/security.ts +20 -0
- package/src/server.ts +244 -0
- package/src/store.ts +200 -0
- package/src/types.ts +126 -0
- package/tsconfig.json +14 -0
- package/web/app.js +541 -0
- package/web/index.html +78 -0
- package/web/render-helpers.js +276 -0
- package/web/styles.css +318 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext, ToolInfo } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { TraceCollector } from "./collector.ts";
|
|
3
|
+
import { closeViewerController, getViewerController, type ViewerController } from "./server.ts";
|
|
4
|
+
import type { SessionSnapshot } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
interface BoundSession {
|
|
7
|
+
id: string;
|
|
8
|
+
collector: TraceCollector;
|
|
9
|
+
getSnapshot: () => SessionSnapshot;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default function piTraceViewer(pi: ExtensionAPI): void {
|
|
13
|
+
pi.registerFlag("pi-trace-port", {
|
|
14
|
+
description: "Starting local port for the pi trace viewer (increments automatically if occupied)",
|
|
15
|
+
type: "string",
|
|
16
|
+
default: "7890",
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
let controller: ViewerController | undefined;
|
|
20
|
+
let bound: BoundSession | undefined;
|
|
21
|
+
let lastSystemPrompt = "";
|
|
22
|
+
let latestTools: ToolInfo[] = [];
|
|
23
|
+
|
|
24
|
+
pi.registerCommand("trace-view", {
|
|
25
|
+
description: "Show the local session and LLM trace viewer URL",
|
|
26
|
+
handler: async (_args, ctx) => {
|
|
27
|
+
if (!controller) {
|
|
28
|
+
ctx.ui.notify("Trace viewer is not running. Check the startup error above.", "error");
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const suffix = bound ? `?session=${encodeURIComponent(bound.id)}` : "";
|
|
32
|
+
ctx.ui.notify(`${controller.url}/${suffix}`, "info");
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
37
|
+
const port = parsePort(pi.getFlag("pi-trace-port"));
|
|
38
|
+
try {
|
|
39
|
+
controller = await getViewerController(port);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
ctx.ui.notify(
|
|
42
|
+
`Trace viewer could not bind 127.0.0.1 (starting at port ${port}): ${error instanceof Error ? error.message : String(error)}. Trace capture is disabled for this session.`,
|
|
43
|
+
"error",
|
|
44
|
+
);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
latestTools = activeTools(pi);
|
|
49
|
+
const getSnapshot = (): SessionSnapshot => snapshotFromContext(ctx, latestTools, lastSystemPrompt, true);
|
|
50
|
+
const initial = getSnapshot();
|
|
51
|
+
const store = controller.register({
|
|
52
|
+
id: initial.id,
|
|
53
|
+
cwd: initial.cwd,
|
|
54
|
+
file: initial.file,
|
|
55
|
+
getSnapshot,
|
|
56
|
+
snapshot: initial,
|
|
57
|
+
});
|
|
58
|
+
const collector = new TraceCollector(store, getSnapshot);
|
|
59
|
+
collector.setTools(latestTools);
|
|
60
|
+
collector.setSystemPrompt(lastSystemPrompt || ctx.getSystemPrompt());
|
|
61
|
+
bound = { id: initial.id, collector, getSnapshot };
|
|
62
|
+
const persistence = store.getPersistence();
|
|
63
|
+
if (persistence.status === "memory_only") {
|
|
64
|
+
ctx.ui.notify(`Trace viewer is running in memory only: ${persistence.error ?? "trace directory is unavailable"}`, "warning");
|
|
65
|
+
}
|
|
66
|
+
ctx.ui.notify(`Trace viewer: ${controller.url}/?session=${encodeURIComponent(initial.id)}`, "info");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
pi.on("before_agent_start", (event) => {
|
|
70
|
+
lastSystemPrompt = event.systemPrompt;
|
|
71
|
+
latestTools = activeTools(pi);
|
|
72
|
+
bound?.collector.setSystemPrompt(event.systemPrompt);
|
|
73
|
+
bound?.collector.setTools(latestTools);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
pi.on("turn_start", (event) => bound?.collector.startTurn(event.turnIndex));
|
|
77
|
+
pi.on("context", (event, ctx) => bound?.collector.onContext(event, ctx));
|
|
78
|
+
pi.on("before_provider_request", (event, ctx) => bound?.collector.onProviderRequest(event, ctx));
|
|
79
|
+
pi.on("after_provider_response", (event, ctx) => bound?.collector.onProviderResponse(event, ctx));
|
|
80
|
+
pi.on("message_update", (event) => bound?.collector.onMessageUpdate(event));
|
|
81
|
+
pi.on("message_end", (event) => bound?.collector.onMessageEnd(event));
|
|
82
|
+
pi.on("session_before_compact", (event, ctx) => bound?.collector.beginCompaction(event, ctx));
|
|
83
|
+
pi.on("session_compact", (event) => bound?.collector.onCompaction(event));
|
|
84
|
+
pi.on("session_compact_failed", (event) =>
|
|
85
|
+
bound?.collector.onCompactionFailed(event.errorMessage ?? (event.aborted ? "Compaction aborted" : "Compaction failed")),
|
|
86
|
+
);
|
|
87
|
+
pi.on("session_before_tree", () => bound?.collector.prepare("branch_summary"));
|
|
88
|
+
pi.on("session_tree", (event) => bound?.collector.onTree(event));
|
|
89
|
+
|
|
90
|
+
const notifySessionUpdated = () => {
|
|
91
|
+
if (bound) controller?.notify(bound.id, "session-updated");
|
|
92
|
+
};
|
|
93
|
+
pi.on("session_info_changed", notifySessionUpdated);
|
|
94
|
+
pi.on("model_select", notifySessionUpdated);
|
|
95
|
+
pi.on("thinking_level_select", notifySessionUpdated);
|
|
96
|
+
pi.on("message_end", notifySessionUpdated);
|
|
97
|
+
pi.on("session_compact", notifySessionUpdated);
|
|
98
|
+
pi.on("session_tree", notifySessionUpdated);
|
|
99
|
+
|
|
100
|
+
pi.on("session_shutdown", async (event) => {
|
|
101
|
+
if (bound && controller) {
|
|
102
|
+
const finalSnapshot = { ...bound.getSnapshot(), active: false, updatedAt: new Date().toISOString() };
|
|
103
|
+
controller.detach(bound.id, finalSnapshot);
|
|
104
|
+
}
|
|
105
|
+
bound = undefined;
|
|
106
|
+
lastSystemPrompt = "";
|
|
107
|
+
latestTools = [];
|
|
108
|
+
if (event.reason === "quit") {
|
|
109
|
+
await closeViewerController();
|
|
110
|
+
controller = undefined;
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function parsePort(value: boolean | string | undefined): number {
|
|
116
|
+
const port = typeof value === "string" ? Number.parseInt(value, 10) : 7890;
|
|
117
|
+
return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : 7890;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function activeTools(pi: ExtensionAPI): ToolInfo[] {
|
|
121
|
+
const active = new Set(pi.getActiveTools());
|
|
122
|
+
return pi.getAllTools().filter((tool) => active.has(tool.name));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function snapshotFromContext(
|
|
126
|
+
ctx: ExtensionContext,
|
|
127
|
+
tools: ToolInfo[],
|
|
128
|
+
systemPrompt: string,
|
|
129
|
+
active: boolean,
|
|
130
|
+
): SessionSnapshot {
|
|
131
|
+
const manager = ctx.sessionManager;
|
|
132
|
+
return {
|
|
133
|
+
id: manager.getSessionId(),
|
|
134
|
+
name: manager.getSessionName(),
|
|
135
|
+
cwd: manager.getCwd(),
|
|
136
|
+
file: manager.getSessionFile(),
|
|
137
|
+
header: structuredClone(manager.getHeader()),
|
|
138
|
+
entries: structuredClone(manager.getEntries()),
|
|
139
|
+
leafId: manager.getLeafId(),
|
|
140
|
+
systemPrompt: systemPrompt || ctx.getSystemPrompt(),
|
|
141
|
+
tools: structuredClone(tools),
|
|
142
|
+
active,
|
|
143
|
+
updatedAt: new Date().toISOString(),
|
|
144
|
+
};
|
|
145
|
+
}
|
package/src/security.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const SENSITIVE_KEY = /^(authorization|proxy-authorization|cookie|set-cookie|x-api-key|api[-_]?key|access[-_]?token|refresh[-_]?token|client[-_]?secret|password)$/i;
|
|
2
|
+
|
|
3
|
+
export function redactSensitive(value: unknown, seen = new WeakSet<object>()): unknown {
|
|
4
|
+
if (Array.isArray(value)) return value.map((item) => redactSensitive(item, seen));
|
|
5
|
+
if (!value || typeof value !== "object") return value;
|
|
6
|
+
if (seen.has(value)) return "[Circular]";
|
|
7
|
+
seen.add(value);
|
|
8
|
+
|
|
9
|
+
const result: Record<string, unknown> = {};
|
|
10
|
+
for (const [key, item] of Object.entries(value)) {
|
|
11
|
+
result[key] = SENSITIVE_KEY.test(key) ? "[REDACTED]" : redactSensitive(item, seen);
|
|
12
|
+
}
|
|
13
|
+
return result;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function redactHeaders(headers: Record<string, string>): Record<string, string> {
|
|
17
|
+
return Object.fromEntries(
|
|
18
|
+
Object.entries(headers).map(([key, value]) => [key, SENSITIVE_KEY.test(key) ? "[REDACTED]" : value]),
|
|
19
|
+
);
|
|
20
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { createServer, type Server, type ServerResponse } from "node:http";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import type { SessionRegistration, SessionSnapshot, TraceRecord } from "./types.ts";
|
|
7
|
+
import { TraceStore } from "./store.ts";
|
|
8
|
+
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
const webRoot = fileURLToPath(new URL("../web", import.meta.url));
|
|
11
|
+
const markedPath = join(dirname(require.resolve("marked")), "marked.umd.js");
|
|
12
|
+
const highlightPath = require.resolve("@highlightjs/cdn-assets/highlight.min.js");
|
|
13
|
+
|
|
14
|
+
interface RegisteredSession {
|
|
15
|
+
registration: SessionRegistration;
|
|
16
|
+
store: TraceStore;
|
|
17
|
+
unlisten: () => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ViewerController {
|
|
21
|
+
readonly port: number;
|
|
22
|
+
readonly url: string;
|
|
23
|
+
register(registration: SessionRegistration): TraceStore;
|
|
24
|
+
detach(sessionId: string, snapshot: SessionSnapshot): void;
|
|
25
|
+
notify(sessionId: string, event: string, payload?: unknown): void;
|
|
26
|
+
close(): Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
class LocalViewerController implements ViewerController {
|
|
30
|
+
readonly port: number;
|
|
31
|
+
readonly url: string;
|
|
32
|
+
private server: Server;
|
|
33
|
+
private sessions = new Map<string, RegisteredSession>();
|
|
34
|
+
private clients = new Set<ServerResponse>();
|
|
35
|
+
|
|
36
|
+
private constructor(server: Server, port: number) {
|
|
37
|
+
this.server = server;
|
|
38
|
+
this.port = port;
|
|
39
|
+
this.url = `http://127.0.0.1:${port}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
static async start(startPort: number): Promise<LocalViewerController> {
|
|
43
|
+
for (let port = startPort; port <= 65535; port++) {
|
|
44
|
+
try {
|
|
45
|
+
return await new Promise<LocalViewerController>((resolve, reject) => {
|
|
46
|
+
const server = createServer();
|
|
47
|
+
const controller = new LocalViewerController(server, port);
|
|
48
|
+
server.on("request", (request, response) => controller.handle(request.method ?? "GET", request.url ?? "/", response));
|
|
49
|
+
const onError = (err: unknown) => {
|
|
50
|
+
server.close();
|
|
51
|
+
reject(err);
|
|
52
|
+
};
|
|
53
|
+
server.once("error", onError);
|
|
54
|
+
server.listen(port, "127.0.0.1", () => {
|
|
55
|
+
server.off("error", onError);
|
|
56
|
+
resolve(controller);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
} catch (error: unknown) {
|
|
60
|
+
const isAddrInUse =
|
|
61
|
+
typeof error === "object" && error !== null && "code" in error && (error as { code?: unknown }).code === "EADDRINUSE";
|
|
62
|
+
if (isAddrInUse) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
throw new Error(`Could not find an available port from ${startPort} to 65535`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
register(registration: SessionRegistration): TraceStore {
|
|
72
|
+
const previous = this.sessions.get(registration.id);
|
|
73
|
+
if (previous) {
|
|
74
|
+
previous.registration = registration;
|
|
75
|
+
this.notify(registration.id, "session-updated");
|
|
76
|
+
return previous.store;
|
|
77
|
+
}
|
|
78
|
+
const store = new TraceStore(registration.snapshot);
|
|
79
|
+
const unlisten = store.onRecord((record) => this.broadcast("trace-record", record));
|
|
80
|
+
this.sessions.set(registration.id, { registration, store, unlisten });
|
|
81
|
+
this.notify(registration.id, "session-added");
|
|
82
|
+
return store;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
detach(sessionId: string, snapshot: SessionSnapshot): void {
|
|
86
|
+
const session = this.sessions.get(sessionId);
|
|
87
|
+
if (!session) return;
|
|
88
|
+
session.registration = { ...session.registration, getSnapshot: undefined, snapshot: { ...snapshot, active: false } };
|
|
89
|
+
this.notify(sessionId, "session-detached");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
notify(sessionId: string, event: string, payload?: unknown): void {
|
|
93
|
+
this.broadcast(event, { sessionId, payload });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async close(): Promise<void> {
|
|
97
|
+
for (const client of this.clients) client.end();
|
|
98
|
+
this.clients.clear();
|
|
99
|
+
for (const session of this.sessions.values()) session.unlisten();
|
|
100
|
+
await new Promise<void>((resolve) => this.server.close(() => resolve()));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private snapshot(session: RegisteredSession): SessionSnapshot {
|
|
104
|
+
if (session.registration.getSnapshot) {
|
|
105
|
+
try {
|
|
106
|
+
const snapshot = session.registration.getSnapshot();
|
|
107
|
+
session.registration.snapshot = snapshot;
|
|
108
|
+
return snapshot;
|
|
109
|
+
} catch {
|
|
110
|
+
// Keep the last stable snapshot while a session is switching.
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return session.registration.snapshot;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private handle(method: string, rawUrl: string, response: ServerResponse): void {
|
|
117
|
+
const url = new URL(rawUrl, this.url);
|
|
118
|
+
if (method === "GET" && url.pathname === "/api/events") {
|
|
119
|
+
response.writeHead(200, {
|
|
120
|
+
"Content-Type": "text/event-stream",
|
|
121
|
+
"Cache-Control": "no-cache",
|
|
122
|
+
Connection: "keep-alive",
|
|
123
|
+
"X-Content-Type-Options": "nosniff",
|
|
124
|
+
});
|
|
125
|
+
response.write("event: ready\ndata: {}\n\n");
|
|
126
|
+
this.clients.add(response);
|
|
127
|
+
response.on("close", () => this.clients.delete(response));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (method === "GET" && url.pathname === "/api/sessions") {
|
|
132
|
+
const sessions = Array.from(this.sessions.values()).map((session) => {
|
|
133
|
+
const snapshot = this.snapshot(session);
|
|
134
|
+
return {
|
|
135
|
+
id: snapshot.id,
|
|
136
|
+
name: snapshot.name,
|
|
137
|
+
cwd: snapshot.cwd,
|
|
138
|
+
active: snapshot.active,
|
|
139
|
+
updatedAt: snapshot.updatedAt,
|
|
140
|
+
callCount: session.store.getCalls().length,
|
|
141
|
+
tracePersistence: session.store.getPersistence(),
|
|
142
|
+
};
|
|
143
|
+
});
|
|
144
|
+
this.json(response, 200, sessions);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const match = url.pathname.match(/^\/api\/sessions\/([^/]+)(?:\/(calls)(?:\/([^/]+))?|\/(download))?$/);
|
|
149
|
+
if (method === "GET" && match) {
|
|
150
|
+
const sessionId = decodeURIComponent(match[1]);
|
|
151
|
+
const session = this.sessions.get(sessionId);
|
|
152
|
+
if (!session) return this.json(response, 404, { error: "Session not found" });
|
|
153
|
+
if (match[4] === "download") {
|
|
154
|
+
const snapshot = this.snapshot(session);
|
|
155
|
+
response.writeHead(200, {
|
|
156
|
+
"Content-Type": "application/x-ndjson; charset=utf-8",
|
|
157
|
+
"Content-Disposition": `attachment; filename="${sessionId}.jsonl"`,
|
|
158
|
+
});
|
|
159
|
+
response.end([JSON.stringify(snapshot.header), ...snapshot.entries.map((entry) => JSON.stringify(entry))].join("\n"));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (match[2] === "calls") {
|
|
163
|
+
const calls = session.store.getCalls();
|
|
164
|
+
if (match[3]) {
|
|
165
|
+
const call = calls.find((candidate) => candidate.callId === decodeURIComponent(match[3]));
|
|
166
|
+
return this.json(response, call ? 200 : 404, call ?? { error: "Call not found" });
|
|
167
|
+
}
|
|
168
|
+
return this.json(response, 200, calls);
|
|
169
|
+
}
|
|
170
|
+
return this.json(response, 200, { ...this.snapshot(session), tracePersistence: session.store.getPersistence() });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (method === "POST" && url.pathname === "/api/refresh") {
|
|
174
|
+
this.broadcast("refresh", {});
|
|
175
|
+
return this.json(response, 200, { ok: true });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (method === "GET" && url.pathname === "/vendor/marked.js") return this.file(response, markedPath, "text/javascript");
|
|
179
|
+
if (method === "GET" && url.pathname === "/vendor/highlight.js") return this.file(response, highlightPath, "text/javascript");
|
|
180
|
+
if (method === "GET" && url.pathname === "/favicon.ico") {
|
|
181
|
+
response.writeHead(204, { "Cache-Control": "public, max-age=86400" });
|
|
182
|
+
response.end();
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (method === "GET" && (url.pathname === "/" || url.pathname === "/index.html")) return this.file(response, join(webRoot, "index.html"), "text/html");
|
|
186
|
+
if (method === "GET" && url.pathname === "/app.js") return this.file(response, join(webRoot, "app.js"), "text/javascript");
|
|
187
|
+
if (method === "GET" && url.pathname === "/render-helpers.js") return this.file(response, join(webRoot, "render-helpers.js"), "text/javascript");
|
|
188
|
+
if (method === "GET" && url.pathname === "/styles.css") return this.file(response, join(webRoot, "styles.css"), "text/css");
|
|
189
|
+
this.json(response, 404, { error: "Not found" });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private file(response: ServerResponse, path: string, contentType: string): void {
|
|
193
|
+
try {
|
|
194
|
+
const content = readFileSync(path);
|
|
195
|
+
response.writeHead(200, {
|
|
196
|
+
"Content-Type": `${contentType}; charset=utf-8`,
|
|
197
|
+
"Cache-Control": "no-cache",
|
|
198
|
+
"Content-Security-Policy": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'",
|
|
199
|
+
"X-Content-Type-Options": "nosniff",
|
|
200
|
+
"Referrer-Policy": "no-referrer",
|
|
201
|
+
});
|
|
202
|
+
response.end(content);
|
|
203
|
+
} catch (error) {
|
|
204
|
+
this.json(response, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private json(response: ServerResponse, status: number, value: unknown): void {
|
|
209
|
+
response.writeHead(status, {
|
|
210
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
211
|
+
"Cache-Control": "no-store",
|
|
212
|
+
"X-Content-Type-Options": "nosniff",
|
|
213
|
+
});
|
|
214
|
+
response.end(JSON.stringify(value));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private broadcast(event: string, data: unknown): void {
|
|
218
|
+
const chunk = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
219
|
+
for (const client of this.clients) client.write(chunk);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const CONTROLLER_KEY = Symbol.for("pi-trace-viewer.controller");
|
|
224
|
+
interface ViewerGlobal {
|
|
225
|
+
[CONTROLLER_KEY]?: Promise<ViewerController>;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function getViewerController(port: number): Promise<ViewerController> {
|
|
229
|
+
const globalState = globalThis as ViewerGlobal;
|
|
230
|
+
globalState[CONTROLLER_KEY] ??= LocalViewerController.start(port).catch((error) => {
|
|
231
|
+
delete globalState[CONTROLLER_KEY];
|
|
232
|
+
throw error;
|
|
233
|
+
});
|
|
234
|
+
return globalState[CONTROLLER_KEY];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export async function closeViewerController(): Promise<void> {
|
|
238
|
+
const globalState = globalThis as ViewerGlobal;
|
|
239
|
+
const controller = globalState[CONTROLLER_KEY];
|
|
240
|
+
if (!controller) return;
|
|
241
|
+
delete globalState[CONTROLLER_KEY];
|
|
242
|
+
const resolved = await controller.catch(() => undefined);
|
|
243
|
+
await resolved?.close();
|
|
244
|
+
}
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import type { CallView, SessionSnapshot, TracePersistence, TraceRecord } from "./types.ts";
|
|
4
|
+
import { TRACE_SCHEMA_VERSION } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
export type TraceListener = (record: TraceRecord) => void;
|
|
7
|
+
type TraceRecordInput = TraceRecord extends infer RecordType
|
|
8
|
+
? RecordType extends TraceRecord
|
|
9
|
+
? Omit<RecordType, "schemaVersion" | "sessionId" | "sequence" | "timestamp"> & { timestamp?: string }
|
|
10
|
+
: never
|
|
11
|
+
: never;
|
|
12
|
+
|
|
13
|
+
export class TraceStore {
|
|
14
|
+
readonly sessionId: string;
|
|
15
|
+
filePath: string | undefined;
|
|
16
|
+
private records: TraceRecord[] = [];
|
|
17
|
+
private sequence = 0;
|
|
18
|
+
private listeners = new Set<TraceListener>();
|
|
19
|
+
private persistence: TracePersistence;
|
|
20
|
+
|
|
21
|
+
constructor(snapshot: SessionSnapshot) {
|
|
22
|
+
this.sessionId = snapshot.id;
|
|
23
|
+
const filePath = join(snapshot.cwd, ".pi-traces", `${snapshot.id}.jsonl`);
|
|
24
|
+
const error = validateTraceDirectory(snapshot.cwd);
|
|
25
|
+
this.filePath = error ? undefined : filePath;
|
|
26
|
+
this.persistence = error ? { status: "memory_only", error } : { status: "persisted", filePath };
|
|
27
|
+
this.load();
|
|
28
|
+
if (!this.records.some((record) => record.type === "trace_header")) {
|
|
29
|
+
this.append({ type: "trace_header", sessionFile: snapshot.file, cwd: snapshot.cwd });
|
|
30
|
+
}
|
|
31
|
+
this.reconcileHistoricalCompactions(snapshot);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
onRecord(listener: TraceListener): () => void {
|
|
35
|
+
this.listeners.add(listener);
|
|
36
|
+
return () => this.listeners.delete(listener);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
append(record: TraceRecordInput): TraceRecord {
|
|
40
|
+
const complete = {
|
|
41
|
+
...record,
|
|
42
|
+
schemaVersion: TRACE_SCHEMA_VERSION,
|
|
43
|
+
sessionId: this.sessionId,
|
|
44
|
+
sequence: ++this.sequence,
|
|
45
|
+
timestamp: record.timestamp ?? new Date().toISOString(),
|
|
46
|
+
} as TraceRecord;
|
|
47
|
+
this.records.push(complete);
|
|
48
|
+
if (this.filePath) {
|
|
49
|
+
try {
|
|
50
|
+
mkdirSync(dirname(this.filePath), { recursive: true, mode: 0o700 });
|
|
51
|
+
appendFileSync(this.filePath, `${JSON.stringify(complete)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
52
|
+
chmodSync(this.filePath, 0o600);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
this.persistence = {
|
|
55
|
+
status: "memory_only",
|
|
56
|
+
error: error instanceof Error ? error.message : String(error),
|
|
57
|
+
};
|
|
58
|
+
this.filePath = undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
for (const listener of this.listeners) listener(complete);
|
|
62
|
+
return complete;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
getPersistence(): TracePersistence {
|
|
66
|
+
return { ...this.persistence };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
getRecords(): readonly TraceRecord[] {
|
|
70
|
+
return this.records;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
getCalls(): CallView[] {
|
|
74
|
+
const calls = new Map<string, CallView>();
|
|
75
|
+
for (const record of this.records) {
|
|
76
|
+
if (!("callId" in record)) continue;
|
|
77
|
+
if (record.type === "call_started") {
|
|
78
|
+
calls.set(record.callId, {
|
|
79
|
+
callId: record.callId,
|
|
80
|
+
kind: record.kind,
|
|
81
|
+
startedAt: record.timestamp,
|
|
82
|
+
turnIndex: record.turnIndex,
|
|
83
|
+
leafId: record.leafId,
|
|
84
|
+
model: record.model,
|
|
85
|
+
captureSource: record.captureSource ?? "live",
|
|
86
|
+
sourceEntryId: record.sourceEntryId,
|
|
87
|
+
status: "running",
|
|
88
|
+
providerRequests: [],
|
|
89
|
+
providerResponses: [],
|
|
90
|
+
outputEvents: [],
|
|
91
|
+
});
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const call = calls.get(record.callId);
|
|
95
|
+
if (!call) continue;
|
|
96
|
+
switch (record.type) {
|
|
97
|
+
case "generic_context":
|
|
98
|
+
call.context = record.context;
|
|
99
|
+
break;
|
|
100
|
+
case "compaction_context":
|
|
101
|
+
call.compactionContext = record.context;
|
|
102
|
+
break;
|
|
103
|
+
case "provider_request":
|
|
104
|
+
call.providerRequests.push({ attempt: record.attempt, timestamp: record.timestamp, payload: record.payload });
|
|
105
|
+
break;
|
|
106
|
+
case "provider_response":
|
|
107
|
+
call.providerResponses.push({
|
|
108
|
+
attempt: record.attempt,
|
|
109
|
+
timestamp: record.timestamp,
|
|
110
|
+
status: record.status,
|
|
111
|
+
headers: record.headers,
|
|
112
|
+
});
|
|
113
|
+
break;
|
|
114
|
+
case "output_event":
|
|
115
|
+
call.outputEvents.push({ timestamp: record.timestamp, event: record.event });
|
|
116
|
+
break;
|
|
117
|
+
case "call_completed":
|
|
118
|
+
call.status = "success";
|
|
119
|
+
call.completedAt = record.timestamp;
|
|
120
|
+
call.finalMessage = record.message;
|
|
121
|
+
call.leafId = record.leafId ?? call.leafId;
|
|
122
|
+
break;
|
|
123
|
+
case "call_failed":
|
|
124
|
+
call.status = "error";
|
|
125
|
+
call.completedAt = record.timestamp;
|
|
126
|
+
call.error = record.error;
|
|
127
|
+
break;
|
|
128
|
+
case "compaction_completed":
|
|
129
|
+
call.status = "success";
|
|
130
|
+
call.completedAt = record.timestamp;
|
|
131
|
+
call.finalMessage = { role: "assistant", content: [{ type: "text", text: record.summary }] };
|
|
132
|
+
break;
|
|
133
|
+
case "branch_summary_completed":
|
|
134
|
+
call.status = "success";
|
|
135
|
+
call.completedAt = record.timestamp;
|
|
136
|
+
call.leafId = record.leafId ?? call.leafId;
|
|
137
|
+
call.finalMessage = { role: "assistant", content: [{ type: "text", text: record.summary }] };
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return Array.from(calls.values()).sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private load(): void {
|
|
145
|
+
if (!this.filePath || !existsSync(this.filePath)) return;
|
|
146
|
+
const loaded: TraceRecord[] = [];
|
|
147
|
+
for (const line of readFileSync(this.filePath, "utf8").split("\n")) {
|
|
148
|
+
if (!line.trim()) continue;
|
|
149
|
+
try {
|
|
150
|
+
const record = JSON.parse(line) as TraceRecord;
|
|
151
|
+
if (record.schemaVersion === TRACE_SCHEMA_VERSION && record.sessionId === this.sessionId) loaded.push(record);
|
|
152
|
+
} catch {
|
|
153
|
+
// A truncated final line is expected after an abrupt process exit.
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
this.records = loaded;
|
|
157
|
+
this.sequence = loaded.reduce((max, record) => Math.max(max, record.sequence), 0);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private reconcileHistoricalCompactions(snapshot: SessionSnapshot): void {
|
|
161
|
+
const completed = this.records.filter((record) => record.type === "compaction_completed");
|
|
162
|
+
for (const entry of snapshot.entries) {
|
|
163
|
+
if (entry.type !== "compaction") continue;
|
|
164
|
+
const alreadyCaptured = completed.some(
|
|
165
|
+
(record) =>
|
|
166
|
+
record.sourceEntryId === entry.id ||
|
|
167
|
+
(record.summary === entry.summary && record.tokensBefore === entry.tokensBefore),
|
|
168
|
+
);
|
|
169
|
+
if (alreadyCaptured) continue;
|
|
170
|
+
const callId = `session-compaction-${entry.id}`;
|
|
171
|
+
this.append({
|
|
172
|
+
type: "call_started",
|
|
173
|
+
callId,
|
|
174
|
+
kind: "compaction",
|
|
175
|
+
leafId: entry.parentId,
|
|
176
|
+
captureSource: "session_entry",
|
|
177
|
+
sourceEntryId: entry.id,
|
|
178
|
+
timestamp: entry.timestamp,
|
|
179
|
+
});
|
|
180
|
+
this.append({
|
|
181
|
+
type: "compaction_completed",
|
|
182
|
+
callId,
|
|
183
|
+
summary: entry.summary,
|
|
184
|
+
tokensBefore: entry.tokensBefore,
|
|
185
|
+
sourceEntryId: entry.id,
|
|
186
|
+
timestamp: entry.timestamp,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function validateTraceDirectory(cwd: string): string | undefined {
|
|
193
|
+
try {
|
|
194
|
+
const stat = statSync(cwd);
|
|
195
|
+
if (!stat.isDirectory()) return `Trace cwd is not a directory: ${cwd}`;
|
|
196
|
+
} catch (error) {
|
|
197
|
+
return error instanceof Error ? error.message : String(error);
|
|
198
|
+
}
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|