@narumitw/pi-inspect 0.0.0-stage → 0.2.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 (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +110 -2
  3. package/dist/app.css +1 -0
  4. package/dist/app.js +128 -0
  5. package/dist/index.html +1 -0
  6. package/docs/REVIEW.md +189 -0
  7. package/docs/VERIFICATION.md +186 -0
  8. package/docs/viewer.md +74 -0
  9. package/package.json +67 -4
  10. package/src/ancestry.ts +32 -0
  11. package/src/collector.ts +143 -0
  12. package/src/context.ts +115 -0
  13. package/src/correlation.ts +27 -0
  14. package/src/entry-index.ts +17 -0
  15. package/src/entry-summary.ts +34 -0
  16. package/src/extension.ts +259 -0
  17. package/src/feed.ts +145 -0
  18. package/src/identity.ts +112 -0
  19. package/src/index.ts +1 -0
  20. package/src/model.ts +117 -0
  21. package/src/native-projection.ts +28 -0
  22. package/src/privacy.ts +136 -0
  23. package/src/projection.ts +374 -0
  24. package/src/server.ts +202 -0
  25. package/src/system-message.ts +117 -0
  26. package/src/web/api.ts +137 -0
  27. package/src/web/app.tsx +570 -0
  28. package/src/web/captured-executions.tsx +177 -0
  29. package/src/web/components.css +261 -0
  30. package/src/web/components.tsx +241 -0
  31. package/src/web/composition.css +554 -0
  32. package/src/web/context-composition.tsx +329 -0
  33. package/src/web/context-inventory.tsx +65 -0
  34. package/src/web/entry-calls.ts +14 -0
  35. package/src/web/format.ts +94 -0
  36. package/src/web/hierarchy.ts +117 -0
  37. package/src/web/history.ts +56 -0
  38. package/src/web/inline-entry.tsx +124 -0
  39. package/src/web/inspector.tsx +386 -0
  40. package/src/web/json-tree.tsx +74 -0
  41. package/src/web/overview.tsx +67 -0
  42. package/src/web/panels.css +267 -0
  43. package/src/web/panes.tsx +123 -0
  44. package/src/web/recorded-content.tsx +49 -0
  45. package/src/web/search.ts +16 -0
  46. package/src/web/session-filters.tsx +100 -0
  47. package/src/web/session.css +114 -0
  48. package/src/web/style.css +332 -0
  49. package/src/web/styles.d.ts +1 -0
  50. package/src/web/timeline.tsx +33 -0
  51. package/src/web/timing.ts +26 -0
  52. package/src/web/trace.css +411 -0
  53. package/src/web/trace.tsx +313 -0
  54. package/src/web/virtual-context.ts +138 -0
package/src/server.ts ADDED
@@ -0,0 +1,202 @@
1
+ import { randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import { createServer, type ServerResponse } from "node:http";
4
+ import type { Socket } from "node:net";
5
+
6
+ export interface ViewerServer {
7
+ url: string;
8
+ token: string;
9
+ origin: string;
10
+ invalidate(revision: number): void;
11
+ close(): Promise<void>;
12
+ }
13
+ export interface ServerOptions {
14
+ generation: string;
15
+ signal: AbortSignal;
16
+ snapshot(): unknown;
17
+ branch(id: string, offset: number): unknown;
18
+ detail(id: string, leaf: string): unknown;
19
+ assets?: Map<string, { type: string; body: string | Buffer }>;
20
+ }
21
+ export function sendRevision(response: { write(frame: string): boolean; destroy(): void }, frame: string): void {
22
+ // No extension-owned queue: close a backpressured client; reconnect fetches a fresh snapshot.
23
+ if (!response.write(frame)) response.destroy();
24
+ }
25
+ export async function startServer(options: ServerOptions): Promise<ViewerServer> {
26
+ const assets =
27
+ options.assets ??
28
+ new Map([
29
+ [
30
+ "/",
31
+ {
32
+ type: "text/html; charset=utf-8",
33
+ body: await readFile(new URL("../dist/index.html", import.meta.url), { signal: options.signal }),
34
+ },
35
+ ],
36
+ [
37
+ "/app.js",
38
+ {
39
+ type: "text/javascript; charset=utf-8",
40
+ body: await readFile(new URL("../dist/app.js", import.meta.url), { signal: options.signal }),
41
+ },
42
+ ],
43
+ [
44
+ "/app.css",
45
+ {
46
+ type: "text/css; charset=utf-8",
47
+ body: await readFile(new URL("../dist/app.css", import.meta.url), { signal: options.signal }),
48
+ },
49
+ ],
50
+ ]);
51
+ options.signal.throwIfAborted();
52
+ const token = randomBytes(32).toString("base64url");
53
+ const clients = new Set<ServerResponse>();
54
+ const sockets = new Set<Socket>();
55
+ let closed = false;
56
+ let origin = "";
57
+ let revision = 0;
58
+ let closing: Promise<void> | undefined;
59
+ const frame = () =>
60
+ `id: ${revision}\ndata: ${JSON.stringify({ protocol: 1, generation: options.generation, revision })}\n\n`;
61
+ const server = createServer((req, res) => {
62
+ res.setHeader("Cache-Control", "no-store");
63
+ res.setHeader("Referrer-Policy", "no-referrer");
64
+ res.setHeader("X-Content-Type-Options", "nosniff");
65
+ res.setHeader("Cross-Origin-Resource-Policy", "same-origin");
66
+ res.setHeader(
67
+ "Content-Security-Policy",
68
+ "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; font-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'",
69
+ );
70
+ const fail = (status: number, message: string) => {
71
+ res.writeHead(status, { "Content-Type": "text/plain; charset=utf-8" });
72
+ res.end(message);
73
+ };
74
+ if (closed || options.signal.aborted) return fail(410, "Session closed");
75
+ if (
76
+ req.headers.host !== origin.slice("http://".length) ||
77
+ (req.headers.origin !== undefined && req.headers.origin !== origin) ||
78
+ req.headers["sec-fetch-site"] === "cross-site"
79
+ )
80
+ return fail(403, "Forbidden");
81
+ if (req.method !== "GET") return fail(405, "Read-only viewer");
82
+ let url: URL;
83
+ try {
84
+ url = new URL(req.url ?? "/", origin);
85
+ } catch {
86
+ return fail(400, "Invalid URL");
87
+ }
88
+ if (url.origin !== origin) return fail(403, "Forbidden");
89
+ const asset = assets.get(url.pathname);
90
+ if (asset && !url.search) {
91
+ res.writeHead(200, { "Content-Type": asset.type });
92
+ // Generation is a public epoch identifier, not a credential. Encoding keeps the HTML boundary inert.
93
+ const body =
94
+ url.pathname === "/"
95
+ ? asset.body
96
+ .toString()
97
+ .replace(
98
+ "<head>",
99
+ `<head><meta name="inspector-generation" content="${encodeURIComponent(options.generation)}">`,
100
+ )
101
+ : asset.body;
102
+ res.end(body);
103
+ return;
104
+ }
105
+ if (!url.pathname.startsWith("/api/")) return fail(404, "Not found");
106
+ const provided = req.headers["x-inspector-token"];
107
+ if (
108
+ typeof provided !== "string" ||
109
+ Buffer.byteLength(provided) !== Buffer.byteLength(token) ||
110
+ !timingSafeEqual(Buffer.from(provided), Buffer.from(token))
111
+ )
112
+ return fail(401, "Unauthorized");
113
+ if (url.searchParams.get("generation") !== options.generation) return fail(409, "Session generation changed");
114
+ if (url.pathname === "/api/events") {
115
+ if (clients.size >= 8) return fail(429, "Too many viewers");
116
+ res.writeHead(200, { "Content-Type": "text/event-stream", Connection: "keep-alive" });
117
+ clients.add(res);
118
+ res.on("close", () => clients.delete(res));
119
+ sendRevision(res, frame());
120
+ return;
121
+ }
122
+ try {
123
+ let result: unknown;
124
+ if (url.pathname === "/api/snapshot") result = options.snapshot();
125
+ else if (url.pathname === "/api/branch") {
126
+ const offset = url.searchParams.get("offset") ?? "0";
127
+ if (!/^\d{1,8}$/.test(offset)) return fail(400, "Invalid offset");
128
+ result = options.branch(url.searchParams.get("leaf") ?? "", Number(offset));
129
+ } else if (url.pathname === "/api/detail") {
130
+ result = options.detail(url.searchParams.get("id") ?? "", url.searchParams.get("leaf") ?? "");
131
+ } else return fail(404, "Not found");
132
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
133
+ res.end(JSON.stringify(result));
134
+ } catch {
135
+ fail(400, "Unavailable session data");
136
+ }
137
+ });
138
+ server.requestTimeout = 10000;
139
+ server.headersTimeout = 10000;
140
+ server.maxHeadersCount = 32;
141
+ server.on("connection", (socket) => {
142
+ if (sockets.size >= 32) {
143
+ socket.destroy();
144
+ return;
145
+ }
146
+ sockets.add(socket);
147
+ socket.on("close", () => sockets.delete(socket));
148
+ });
149
+ const close = (): Promise<void> => {
150
+ if (closing) return closing;
151
+ closed = true;
152
+ options.signal.removeEventListener("abort", abort);
153
+ for (const client of clients) client.destroy();
154
+ clients.clear();
155
+ for (const socket of sockets) socket.destroy();
156
+ closing = new Promise((resolve) => server.close(() => resolve()));
157
+ return closing;
158
+ };
159
+ const abort = () => {
160
+ void close();
161
+ };
162
+ // Register before listen, and recheck after readiness to cover abort during startup.
163
+ options.signal.addEventListener("abort", abort, { once: true });
164
+ try {
165
+ await new Promise<void>((resolve, reject) => {
166
+ const cancelled = () => reject(new Error("Server startup cancelled"));
167
+ options.signal.addEventListener("abort", cancelled, { once: true });
168
+ const failed = (error: Error) => {
169
+ options.signal.removeEventListener("abort", cancelled);
170
+ reject(error);
171
+ };
172
+ server.once("error", failed);
173
+ server.listen({ port: 0, host: "127.0.0.1", signal: options.signal }, () => {
174
+ options.signal.removeEventListener("abort", cancelled);
175
+ server.removeListener("error", failed);
176
+ resolve();
177
+ });
178
+ });
179
+ options.signal.throwIfAborted();
180
+ } catch (error) {
181
+ await close();
182
+ throw error;
183
+ }
184
+ const address = server.address();
185
+ if (!address || typeof address === "string") {
186
+ await close();
187
+ throw new Error("No loopback address");
188
+ }
189
+ origin = `http://127.0.0.1:${address.port}`;
190
+ return {
191
+ token,
192
+ origin,
193
+ url: `${origin}/#${token}`,
194
+ invalidate(next) {
195
+ if (!closed) {
196
+ revision = next;
197
+ for (const res of clients) sendRevision(res, frame());
198
+ }
199
+ },
200
+ close,
201
+ };
202
+ }
@@ -0,0 +1,117 @@
1
+ // Native historical replay is allowed only within this cumulative input budget.
2
+ export const SYSTEM_REPLAY_CHARACTERS = 1_048_576;
3
+ export const SYSTEM_REPLAY_TOOL_DELTAS = 2048;
4
+
5
+ export function systemReplayIssue(messages: readonly unknown[]): string | undefined {
6
+ let remaining = SYSTEM_REPLAY_CHARACTERS;
7
+ let deltas = SYSTEM_REPLAY_TOOL_DELTAS;
8
+ let nodes = 16_384;
9
+ const seen = new WeakSet<object>();
10
+ // Count raw lengths without serializing, sanitizing, or materializing large metadata.
11
+ function declaration(value: unknown, depth = 0): boolean {
12
+ if (--nodes < 0 || depth > 32) return false;
13
+ if (typeof value === "string") {
14
+ remaining -= value.length;
15
+ return remaining >= 0;
16
+ }
17
+ if (!value || typeof value !== "object" || seen.has(value)) return true;
18
+ seen.add(value);
19
+ if (Array.isArray(value) && value.length > nodes) return false;
20
+ for (const key in value) {
21
+ if (!Object.hasOwn(value, key)) continue;
22
+ remaining -= key.length + 2;
23
+ if (remaining < 0) return false;
24
+ const property = Object.getOwnPropertyDescriptor(value, key);
25
+ if (property?.get || property?.set || !declaration(property?.value, depth + 1)) return false;
26
+ }
27
+ return true;
28
+ }
29
+ for (const value of messages) {
30
+ if (!value || typeof value !== "object" || Array.isArray(value)) continue;
31
+ const m = value as Record<string, unknown>;
32
+ if (m.role !== "system") continue;
33
+ for (const [kind, delta] of [
34
+ ["added", m.toolsAdded],
35
+ ["removed", m.toolsRemoved],
36
+ ] as const) {
37
+ if (!Array.isArray(delta)) continue;
38
+ deltas -= delta.length;
39
+ if (deltas < 0) return "system tool replay exceeds 2,048 cumulative delta budget";
40
+ for (const tool of delta) {
41
+ if (
42
+ !declaration(
43
+ kind === "added"
44
+ ? { name: tool.name, description: tool.description, parameters: tool.parameters }
45
+ : { name: tool.name },
46
+ )
47
+ )
48
+ return "system tool replay exceeds metadata character/node/depth budget";
49
+ }
50
+ }
51
+ remaining -= 2; // Bound join separators even for empty fragments.
52
+ if (typeof m.content === "string") remaining -= m.content.length;
53
+ else if (Array.isArray(m.content)) {
54
+ remaining -= m.content.length; // Also bound native filter/map work and block separators.
55
+ if (remaining < 0) return "system replay exceeds 1,048,576-character input budget";
56
+ for (const block of m.content) {
57
+ if (block?.type === "text" && typeof block.text === "string") remaining -= block.text.length;
58
+ if (remaining < 0) return "system replay exceeds 1,048,576-character input budget";
59
+ }
60
+ }
61
+ if (remaining < 0) return "system replay exceeds 1,048,576-character input budget";
62
+ if (m.sections && typeof m.sections === "object") {
63
+ for (const [key, part] of Object.entries(m.sections)) {
64
+ remaining -= key.length + 2 + (typeof part === "string" ? part.length : 0);
65
+ if (remaining < 0) return "system replay exceeds 1,048,576-character input budget";
66
+ }
67
+ }
68
+ }
69
+ return undefined;
70
+ }
71
+
72
+ // Validate the fields consumed by Pi AI's system-prompt/tool replay. Checkpoints
73
+ // are injected directly; ordinary session messages are normalized by the caller.
74
+ export function systemMessageIssue(value: unknown): string | undefined {
75
+ if (!value || typeof value !== "object" || Array.isArray(value)) return "malformed system-message envelope";
76
+ const m = value as Record<string, unknown>;
77
+ if (m.role !== "system") return "malformed system-message role";
78
+ if (Array.isArray(m.content) && m.content.length > 2048) return "system content exceeds 2,048-block budget";
79
+ if (
80
+ typeof m.content !== "string" &&
81
+ (!Array.isArray(m.content) ||
82
+ m.content.some(
83
+ (block) =>
84
+ !block ||
85
+ typeof block !== "object" ||
86
+ Array.isArray(block) ||
87
+ (block.type === "text" && typeof block.text !== "string"),
88
+ ))
89
+ )
90
+ return "malformed system-message content";
91
+ if (
92
+ m.sections != null &&
93
+ (typeof m.sections !== "object" ||
94
+ Array.isArray(m.sections) ||
95
+ Object.values(m.sections).some((part) => part !== null && typeof part !== "string"))
96
+ )
97
+ return "malformed system-message sections";
98
+ for (const delta of [m.toolsAdded, m.toolsRemoved]) {
99
+ if (Array.isArray(delta) && delta.length > SYSTEM_REPLAY_TOOL_DELTAS)
100
+ return "system tool delta exceeds 2,048-entry budget";
101
+ if (
102
+ delta != null &&
103
+ (!Array.isArray(delta) ||
104
+ delta.some(
105
+ (tool) =>
106
+ !tool ||
107
+ typeof tool !== "object" ||
108
+ Array.isArray(tool) ||
109
+ typeof tool.name !== "string" ||
110
+ !tool.name.length ||
111
+ tool.name.length > 512,
112
+ ))
113
+ )
114
+ return "malformed system-message tool delta";
115
+ }
116
+ return undefined;
117
+ }
package/src/web/api.ts ADDED
@@ -0,0 +1,137 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import type { DetailView } from "../model.js";
3
+
4
+ // Storage is optional reload persistence; a valid fragment must work in restricted profiles.
5
+ function stored(key: string): string | null {
6
+ try {
7
+ return sessionStorage.getItem(key);
8
+ } catch {
9
+ return null;
10
+ }
11
+ }
12
+ function remember(key: string, value: string): void {
13
+ if (!value) return;
14
+ try {
15
+ sessionStorage.setItem(key, value);
16
+ } catch {
17
+ // Keep credentials in memory for this page; fragment-free reloads must reopen from Pi.
18
+ }
19
+ }
20
+ const fragment = location.hash.slice(1);
21
+ const params = new URLSearchParams(fragment);
22
+ // Continue accepting legacy #token=...&generation=... URLs and fragment-free browser reloads.
23
+ const token = /^[\w-]{43}$/.test(fragment) ? fragment : (params.get("token") ?? stored("inspector-token") ?? "");
24
+ const shellGeneration = document.querySelector<HTMLMetaElement>('meta[name="inspector-generation"]')?.content;
25
+ export const generation =
26
+ params.get("generation") ??
27
+ (shellGeneration ? decodeURIComponent(shellGeneration) : (stored("inspector-generation") ?? ""));
28
+ remember("inspector-token", token);
29
+ remember("inspector-generation", generation);
30
+ history.replaceState(null, "", location.pathname);
31
+ export function headers(): HeadersInit {
32
+ return { "X-Inspector-Token": token };
33
+ }
34
+ export class RequestFailure extends Error {
35
+ constructor(
36
+ message: string,
37
+ readonly terminal: boolean,
38
+ ) {
39
+ super(message);
40
+ }
41
+ }
42
+ export async function request<T>(route: string, signal: AbortSignal): Promise<T> {
43
+ const response = await fetch(
44
+ `/api/${route}${route.includes("?") ? "&" : "?"}generation=${encodeURIComponent(generation)}`,
45
+ { headers: headers(), signal, cache: "no-store" },
46
+ );
47
+ if (!response.ok)
48
+ throw new RequestFailure(
49
+ [401, 403, 409, 410].includes(response.status)
50
+ ? "Session expired; open a new viewer from Pi."
51
+ : "Viewer unavailable; reconnecting requires a running Pi session.",
52
+ [401, 403, 409, 410].includes(response.status),
53
+ );
54
+ return response.json() as Promise<T>;
55
+ }
56
+ interface Job {
57
+ signal: AbortSignal;
58
+ run(): Promise<void>;
59
+ cancel(): void;
60
+ }
61
+ const queue: Job[] = [];
62
+ const cache = new Map<string, DetailView>();
63
+ let active = 0;
64
+ function pump(): void {
65
+ while (active < 4 && queue.length) {
66
+ const job = queue.shift();
67
+ if (!job || job.signal.aborted) continue;
68
+ active++;
69
+ void job.run().finally(() => {
70
+ active--;
71
+ pump();
72
+ });
73
+ }
74
+ }
75
+ function loadDetail(id: string, signal: AbortSignal): Promise<DetailView> {
76
+ signal.throwIfAborted();
77
+ const cached = cache.get(id);
78
+ if (cached) return Promise.resolve(cached);
79
+ return new Promise((resolve, reject) => {
80
+ const job: Job = {
81
+ signal,
82
+ cancel: () => {
83
+ const index = queue.indexOf(job);
84
+ if (index !== -1) queue.splice(index, 1);
85
+ reject(new DOMException("Cancelled", "AbortError"));
86
+ },
87
+ run: async () => {
88
+ try {
89
+ const value = await request<DetailView>(
90
+ `detail?id=${encodeURIComponent(id)}&leaf=${encodeURIComponent(id)}`,
91
+ signal,
92
+ );
93
+ if (signal.aborted) return;
94
+ // Raw entries and their own historical projection are immutable; live calls use the latest snapshot.
95
+ const result = cache.get(id) ?? { ...value, calls: [] };
96
+ cache.set(id, result);
97
+ while (cache.size > 64) {
98
+ const first = cache.keys().next().value;
99
+ if (first === undefined) break;
100
+ cache.delete(first);
101
+ }
102
+ resolve(result);
103
+ } catch (error) {
104
+ reject(error);
105
+ } finally {
106
+ signal.removeEventListener("abort", job.cancel);
107
+ }
108
+ },
109
+ };
110
+ signal.addEventListener("abort", job.cancel, { once: true });
111
+ queue.push(job);
112
+ pump();
113
+ });
114
+ }
115
+ export function useDetail(id: string | undefined): { detail?: DetailView; error: string; retry(): void } {
116
+ const [attempt, setAttempt] = useState(0);
117
+ const owner = useRef<{ id: string; attempt: number } | undefined>(undefined);
118
+ const retry = () => setAttempt((value) => value + 1);
119
+ const [state, setState] = useState<{ id?: string; detail?: DetailView; error: string }>({ error: "" });
120
+ useEffect(() => {
121
+ if (!id) return;
122
+ const requestOwner = { id, attempt };
123
+ owner.current = requestOwner;
124
+ const controller = new AbortController();
125
+ setState({ id, error: "" });
126
+ void loadDetail(id, controller.signal)
127
+ .then((detail) => {
128
+ if (!controller.signal.aborted && owner.current === requestOwner) setState({ id, detail, error: "" });
129
+ })
130
+ .catch(() => {
131
+ if (!controller.signal.aborted && owner.current === requestOwner)
132
+ setState({ id, error: "Could not load entry details." });
133
+ });
134
+ return () => controller.abort();
135
+ }, [id, attempt]);
136
+ return { ...(state.id === id ? state : { error: "" }), retry };
137
+ }