@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
@@ -0,0 +1,313 @@
1
+ import { ChevronDownIcon, ChevronRightIcon, ListBulletIcon, RowsIcon } from "@radix-ui/react-icons";
2
+ import { Button, Flex, Heading, Text } from "@radix-ui/themes";
3
+ import { useEffect, useMemo, useRef, useState } from "react";
4
+ import type { Call, EntrySummary } from "../model.js";
5
+ import { Glyph, Status } from "./components.js";
6
+ import { count, eventName, time } from "./format.js";
7
+ import { flatten, hierarchy, label, reveal, withAncestors } from "./hierarchy.js";
8
+ import { type HistoryRow, historySummary } from "./history.js";
9
+ import { InlineEntry } from "./inline-entry.js";
10
+ import { TimelineMark } from "./timeline.js";
11
+ import { axis } from "./timing.js";
12
+
13
+ export function scrollWithin(container: HTMLElement, element: HTMLElement): void {
14
+ const parent = container.getBoundingClientRect();
15
+ const child = element.getBoundingClientRect();
16
+ if (child.top < parent.top) container.scrollTop -= Math.ceil(parent.top - child.top);
17
+ else if (child.bottom > parent.bottom) container.scrollTop += Math.ceil(child.bottom - parent.bottom);
18
+ }
19
+ export function Trace({
20
+ entries,
21
+ matches,
22
+ selected,
23
+ serial,
24
+ revealSelected,
25
+ select,
26
+ view,
27
+ changeView,
28
+ calls,
29
+ historyRows,
30
+ active = true,
31
+ }: {
32
+ entries: EntrySummary[];
33
+ matches: Set<string>;
34
+ selected: string;
35
+ serial: number;
36
+ revealSelected: boolean;
37
+ select(id: string): void;
38
+ view: string;
39
+ changeView(view: string): void;
40
+ calls: Call[];
41
+ historyRows?: HistoryRow[];
42
+ active?: boolean;
43
+ }) {
44
+ const tree = useMemo(() => hierarchy(entries), [entries]);
45
+ const keep = useMemo(
46
+ () => withAncestors(tree, new Set([...matches, ...(revealSelected && selected ? [selected] : [])])),
47
+ [tree, matches, revealSelected, selected],
48
+ );
49
+ const [expanded, setExpanded] = useState(new Set<string>());
50
+ const [disclosed, setDisclosed] = useState(new Set<string>());
51
+ const [offset, setOffset] = useState(0);
52
+ const [focused, setFocused] = useState("");
53
+ const lastReveal = useRef(-1);
54
+ const focusRequest = useRef(false);
55
+ const scroller = useRef<HTMLDivElement>(null);
56
+ const rowRefs = useRef(new Map<string, HTMLDivElement>());
57
+ const rows = useMemo(
58
+ () =>
59
+ historyRows
60
+ ? historyRows
61
+ .filter(({ node }) => matches.has(node.id) || (revealSelected && node.id === selected))
62
+ .map((row, index, visible) => ({
63
+ ...row,
64
+ depth: 0,
65
+ childCount: 0,
66
+ heading:
67
+ index === 0 || visible[index - 1]?.turn !== row.turn
68
+ ? row.turn === "before-user"
69
+ ? "Before first user turn"
70
+ : "User turn"
71
+ : undefined,
72
+ }))
73
+ : flatten(tree, expanded, keep),
74
+ [tree, expanded, keep, historyRows, matches, revealSelected, selected],
75
+ );
76
+ const pageRows = rows.slice(offset, offset + 50);
77
+ const visibleFocused = pageRows.some((row) => row.node.id === focused) ? focused : (pageRows[0]?.node.id ?? "");
78
+ const baseDepth = pageRows.length ? Math.min(...pageRows.map((row) => row.depth)) : 0;
79
+ const relativeDepth = Math.max(1, ...pageRows.map((row) => row.depth - baseDepth));
80
+ const indent = Math.min(14, 140 / relativeDepth); // Scale the visual rail, never cap hierarchy depth.
81
+ const range = useMemo(() => axis(entries, calls), [entries, calls]);
82
+ useEffect(() => {
83
+ if (!tree.nodes.has(selected) || serial === lastReveal.current) return;
84
+ const next = historyRows ? expanded : reveal(tree, expanded, selected);
85
+ const index = (historyRows ? rows : flatten(tree, next, keep)).findIndex((row) => row.node.id === selected);
86
+ if (index >= 0) {
87
+ // Keep reveal pending until the selection belongs to this projection.
88
+ lastReveal.current = serial;
89
+ if (!historyRows) setExpanded(next);
90
+ setOffset(Math.floor(index / 50) * 50);
91
+ setFocused(selected);
92
+ }
93
+ }, [tree, selected, serial, expanded, keep, historyRows, rows]);
94
+ useEffect(() => {
95
+ const element = rowRefs.current.get(visibleFocused);
96
+ if (!element || !scroller.current) return;
97
+ if (focusRequest.current) {
98
+ element.focus({ preventScroll: true });
99
+ focusRequest.current = false;
100
+ }
101
+ scrollWithin(scroller.current, element);
102
+ }, [visibleFocused]);
103
+ useEffect(() => {
104
+ if (offset >= rows.length && offset) setOffset(Math.max(0, Math.floor((rows.length - 1) / 50) * 50));
105
+ }, [offset, rows.length]);
106
+ function toggle(id: string): void {
107
+ setExpanded((previous) => {
108
+ const next = new Set(previous);
109
+ if (next.has(id)) next.delete(id);
110
+ else next.add(id);
111
+ return next;
112
+ });
113
+ setDisclosed((previous) => new Set([...previous, id]));
114
+ }
115
+ function focus(id: string): void {
116
+ const index = rows.findIndex((row) => row.node.id === id);
117
+ if (index === -1) return;
118
+ focusRequest.current = true;
119
+ setOffset(Math.floor(index / 50) * 50);
120
+ setFocused(id);
121
+ rowRefs.current.get(id)?.focus({ preventScroll: true });
122
+ }
123
+ return (
124
+ <section className={`panel trace-panel trace-${view} ${historyRows ? "trace-history" : "trace-branches"}`}>
125
+ <div className="trace-toolbar">
126
+ <Heading size="3">{historyRows ? "History" : "Branch view"}</Heading>
127
+ <Text size="1" color="gray">
128
+ {historyRows ? "Conversation groups · not execution parentage" : "Actual session ancestry · not a call stack"}
129
+ </Text>
130
+ <div className="trace-actions">
131
+ <Button
132
+ size="1"
133
+ variant="ghost"
134
+ disabled={!keep.size}
135
+ onClick={() => {
136
+ setExpanded(new Set(keep));
137
+ setDisclosed(new Set(keep));
138
+ }}
139
+ >
140
+ Expand all
141
+ </Button>
142
+ <Button size="1" variant="ghost" disabled={!expanded.size} onClick={() => setExpanded(new Set())}>
143
+ Collapse all
144
+ </Button>
145
+ <div className="view-switch">
146
+ {["list", "timeline"].map((mode) => (
147
+ <button type="button" key={mode} aria-pressed={view === mode} onClick={() => changeView(mode)}>
148
+ {mode === "list" ? <ListBulletIcon /> : <RowsIcon />}
149
+ {mode === "list" ? "List" : "Timeline"}
150
+ </button>
151
+ ))}
152
+ </div>
153
+ </div>
154
+ </div>
155
+ <div className="trace-hint">
156
+ Row: select · Chevron: toggle only · ↑↓ navigate · ←→ collapse/expand · Enter select · Space toggle
157
+ </div>
158
+ {view === "timeline" && (
159
+ <div className="time-axis">
160
+ <span>{range ? new Date(range.start).toISOString().slice(11, 23) : "No timing"}</span>
161
+ <span>● Log timestamp · ▰ Observed tool interval (captured executions)</span>
162
+ <span>{range ? new Date(range.end).toISOString().slice(11, 23) : "—"}</span>
163
+ </div>
164
+ )}
165
+ <div className="trace-scroll" ref={scroller}>
166
+ <div role="tree" aria-label="Session trace" className="trace-tree">
167
+ {!rows.length && (
168
+ <div className="empty-state">
169
+ {historyRows
170
+ ? "No visible entries match in this branch. Check filters or Show internal events."
171
+ : "No entries match. Filters keep actual ancestor context."}
172
+ </div>
173
+ )}
174
+ {pageRows.map(({ node: entry, depth, childCount, ...group }) => {
175
+ const open = expanded.has(entry.id);
176
+ const selectedRow = selected === entry.id;
177
+ return (
178
+ <div
179
+ className="trace-item"
180
+ key={entry.id}
181
+ data-trace-entry-id={entry.id}
182
+ data-parent-id={entry.parentId ?? ""}
183
+ data-depth={depth}
184
+ data-state={open ? "open" : "closed"}
185
+ style={{ marginLeft: (depth - baseDepth) * indent }}
186
+ >
187
+ {"heading" in group && typeof group.heading === "string" && (
188
+ <div className="turn-heading">{group.heading}</div>
189
+ )}
190
+ <div
191
+ role="treeitem"
192
+ aria-level={depth + 1}
193
+ aria-expanded={open}
194
+ aria-selected={selectedRow}
195
+ aria-current={selectedRow ? "true" : undefined}
196
+ data-entry-id={entry.id}
197
+ data-match={matches.has(entry.id)}
198
+ tabIndex={visibleFocused === entry.id ? 0 : -1}
199
+ ref={(element) => {
200
+ if (element) rowRefs.current.set(entry.id, element);
201
+ else rowRefs.current.delete(entry.id);
202
+ }}
203
+ className={`trace-row ${selectedRow ? "selected" : ""} ${open ? "expanded" : ""}`}
204
+ data-trace-id={entry.id}
205
+ onFocus={() => setFocused(entry.id)}
206
+ onClick={(event) => {
207
+ if (event.target !== event.currentTarget && (event.target as HTMLElement).closest("button")) return;
208
+ select(entry.id);
209
+ }}
210
+ onKeyDown={(event) => {
211
+ if (event.target !== event.currentTarget || event.altKey || event.ctrlKey || event.metaKey) return;
212
+ const index = rows.findIndex((row) => row.node.id === entry.id);
213
+ if (!["ArrowDown", "ArrowUp", "ArrowRight", "ArrowLeft", "Enter", " "].includes(event.key)) return;
214
+ event.preventDefault();
215
+ if (event.key === "ArrowDown") {
216
+ const next = rows[index + 1];
217
+ if (next) focus(next.node.id);
218
+ } else if (event.key === "ArrowUp") {
219
+ const prev = rows[index - 1];
220
+ if (prev) focus(prev.node.id);
221
+ } else if (event.key === "ArrowRight") {
222
+ if (!open) toggle(entry.id);
223
+ else {
224
+ const child = rows[index + 1];
225
+ if (child && child.depth > depth) focus(child.node.id);
226
+ }
227
+ } else if (event.key === "ArrowLeft") {
228
+ if (open) toggle(entry.id);
229
+ else if (entry.parentId) focus(entry.parentId);
230
+ } else if (event.key === "Enter") select(entry.id);
231
+ else toggle(entry.id);
232
+ }}
233
+ >
234
+ <button
235
+ type="button"
236
+ tabIndex={-1}
237
+ className="expand-button"
238
+ aria-label={`Expand event ${entry.id}`}
239
+ aria-expanded={open}
240
+ onClick={(event) => {
241
+ event.stopPropagation();
242
+ toggle(entry.id);
243
+ }}
244
+ >
245
+ {open ? <ChevronDownIcon /> : <ChevronRightIcon />}
246
+ </button>
247
+ <div className="trace-event">
248
+ <Glyph kind={entry.kind} />
249
+ <span className="row-title">
250
+ <strong>{eventName(entry.kind)}</strong>
251
+ <span title={historySummary(entry)}>
252
+ {historyRows ? historySummary(entry) : label(entry)}
253
+ {entry.summaryTruncated && historyRows ? " · truncated" : ""}
254
+ </span>
255
+ </span>
256
+ <code title={entry.id}>{entry.id.slice(0, 8)}</code>
257
+ {!matches.has(entry.id) && (
258
+ <span className="ancestor-badge">{selectedRow ? "selected outside filters" : "ancestor"}</span>
259
+ )}
260
+ {!historyRows && depth === 0 && entry.parentId && tree.nodes.has(entry.parentId) && (
261
+ <span className="ancestor-badge">recorded parent cycle</span>
262
+ )}
263
+ </div>
264
+ <div className="row-metrics">
265
+ {entry.status && <Status value={entry.status} />}
266
+ {entry.tokens !== undefined && (
267
+ <span title="Recorded assistant totalTokens">{count(entry.tokens)} tok</span>
268
+ )}
269
+ <time title={entry.timestamp}>{time(entry.timestamp)}</time>
270
+ </div>
271
+ {view === "timeline" && <TimelineMark range={range} logTime={entry.timestamp} />}
272
+ </div>
273
+ {open && disclosed.has(entry.id) && (
274
+ <div className="entry-expanded">
275
+ {active && <InlineEntry entry={entry} inspect={select} />}
276
+ {childCount > 0 && (
277
+ <span className="children-label">
278
+ {childCount} visible child {childCount === 1 ? "entry" : "entries"}
279
+ </span>
280
+ )}
281
+ {entry.parentId && !tree.nodes.has(entry.parentId) && (
282
+ <span className="children-label">Parent outside indexed data: {entry.parentId}</span>
283
+ )}
284
+ </div>
285
+ )}
286
+ </div>
287
+ );
288
+ })}
289
+ </div>
290
+ </div>
291
+ <div className="trace-pagination">
292
+ <span>
293
+ {baseDepth
294
+ ? `Absolute hierarchy level ${baseDepth + 1}+ · page-local indentation`
295
+ : historyRows
296
+ ? "Branch-scoped history · browser-only selection"
297
+ : "Original parents retained · browser-only selection"}
298
+ </span>
299
+ <Flex gap="2" align="center">
300
+ <Button size="1" variant="ghost" disabled={!offset} onClick={() => setOffset(Math.max(0, offset - 50))}>
301
+ Previous
302
+ </Button>
303
+ <Text size="1">
304
+ {rows.length ? offset + 1 : 0}–{Math.min(offset + 50, rows.length)} / {rows.length}
305
+ </Text>
306
+ <Button size="1" variant="ghost" disabled={offset + 50 >= rows.length} onClick={() => setOffset(offset + 50)}>
307
+ Next
308
+ </Button>
309
+ </Flex>
310
+ </div>
311
+ </section>
312
+ );
313
+ }
@@ -0,0 +1,138 @@
1
+ import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
2
+
3
+ // Variable-height virtualization. The top visible source ID anchors measurements and tail appends.
4
+ export function useVirtualContext(ids: string[]) {
5
+ const scroller = useRef<HTMLElement>(null);
6
+ const elements = useRef(new Map<string, HTMLDivElement>());
7
+ const anchor = useRef<{ id: string; offset: number } | undefined>(undefined);
8
+ const resetting = useRef(false);
9
+ const [resetPending, setResetPending] = useState(false);
10
+ const pendingFocus = useRef<string | undefined>(undefined);
11
+ const [heights, measure] = useState(new Map<string, number>());
12
+ const [range, setRange] = useState({ start: 0, end: 20 });
13
+ const [viewport, setViewport] = useState({ start: 0, end: 0 });
14
+ const positions = useMemo(() => {
15
+ let top = 0;
16
+ const rows = ids.map((id) => {
17
+ const height = heights.get(id) ?? 52;
18
+ const row = { id, top, height };
19
+ top += height;
20
+ return row;
21
+ });
22
+ return { rows, total: top };
23
+ }, [ids, heights]);
24
+ const current = useRef(positions);
25
+ current.current = positions;
26
+ const refresh = useCallback(() => {
27
+ const node = scroller.current;
28
+ if (!node) return;
29
+ const rows = current.current.rows;
30
+ const at = (top: number) => {
31
+ let lo = 0;
32
+ let hi = rows.length;
33
+ while (lo < hi) {
34
+ const mid = (lo + hi) >>> 1;
35
+ const row = rows[mid];
36
+ if (row && row.top + row.height <= top) lo = mid + 1;
37
+ else hi = mid;
38
+ }
39
+ return Math.min(lo, Math.max(0, rows.length - 1));
40
+ };
41
+ const index = at(node.scrollTop);
42
+ const row = rows[index];
43
+ if (!resetting.current) anchor.current = row ? { id: row.id, offset: node.scrollTop - row.top } : undefined;
44
+ const visible = { start: index, end: Math.min(rows.length, at(node.scrollTop + node.clientHeight) + 1) };
45
+ setViewport((old) => (old.start === visible.start && old.end === visible.end ? old : visible));
46
+ const next = {
47
+ start: Math.max(0, index - 5),
48
+ end: Math.min(rows.length, at(node.scrollTop + node.clientHeight) + 6),
49
+ };
50
+ setRange((old) => (old.start === next.start && old.end === next.end ? old : next));
51
+ }, []);
52
+ useLayoutEffect(() => {
53
+ const node = scroller.current;
54
+ if (!node) return;
55
+ const point = anchor.current;
56
+ const row = point && positions.rows.find((item) => item.id === point.id);
57
+ if (resetPending) {
58
+ node.scrollTop = 0;
59
+ anchor.current = undefined;
60
+ resetting.current = false;
61
+ setResetPending(false);
62
+ } else if (row) node.scrollTop = row.top + point.offset;
63
+ refresh();
64
+ }, [positions, refresh, resetPending]);
65
+ useLayoutEffect(() => {
66
+ const valid = new Set(ids);
67
+ measure((old) => {
68
+ if ([...old.keys()].every((key) => valid.has(key))) return old;
69
+ return new Map([...old].filter(([key]) => valid.has(key)));
70
+ });
71
+ }, [ids]);
72
+ useLayoutEffect(() => {
73
+ let disposed = false;
74
+ const observer = new ResizeObserver((entries) => {
75
+ if (disposed) return;
76
+ const measured = entries.flatMap((entry) => {
77
+ const id = (entry.target as HTMLElement).dataset.segmentId;
78
+ if (!id) {
79
+ refresh();
80
+ return [];
81
+ }
82
+ const height = Math.ceil(entry.target.getBoundingClientRect().height);
83
+ return height > 0 ? [{ id, height }] : [];
84
+ });
85
+ if (measured.length)
86
+ measure((old) => {
87
+ if (measured.every((item) => old.get(item.id) === item.height)) return old;
88
+ const next = new Map(old);
89
+ for (const item of measured) next.set(item.id, item.height);
90
+ return next;
91
+ });
92
+ });
93
+ if (scroller.current) observer.observe(scroller.current);
94
+ for (const id of ids.slice(range.start, range.end)) {
95
+ const element = elements.current.get(id);
96
+ if (element) observer.observe(element);
97
+ }
98
+ const focused =
99
+ pendingFocus.current && elements.current.get(pendingFocus.current)?.querySelector<HTMLElement>(".segment-row");
100
+ if (focused) {
101
+ focused.focus({ preventScroll: true });
102
+ pendingFocus.current = undefined;
103
+ }
104
+ return () => {
105
+ disposed = true;
106
+ observer.disconnect();
107
+ };
108
+ }, [range.start, range.end, ids, refresh]);
109
+ function jump(index: number, focus = false): void {
110
+ const row = positions.rows[index];
111
+ if (!row || !scroller.current) return;
112
+ if (focus) {
113
+ const element = elements.current.get(row.id)?.querySelector<HTMLElement>(".segment-row");
114
+ if (element) element.focus({ preventScroll: true });
115
+ else pendingFocus.current = row.id;
116
+ }
117
+ scroller.current.scrollTop = row.top;
118
+ refresh();
119
+ }
120
+ const reset = useCallback(() => {
121
+ resetting.current = true;
122
+ setResetPending(true);
123
+ if (scroller.current) scroller.current.scrollTop = 0;
124
+ anchor.current = undefined;
125
+ refresh();
126
+ }, [refresh]);
127
+ return {
128
+ scroller,
129
+ elements,
130
+ positions: positions.rows,
131
+ totalHeight: positions.total,
132
+ range,
133
+ viewport,
134
+ refresh,
135
+ jump,
136
+ reset,
137
+ };
138
+ }