@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.
- package/LICENSE +21 -0
- package/README.md +110 -2
- package/dist/app.css +1 -0
- package/dist/app.js +128 -0
- package/dist/index.html +1 -0
- package/docs/REVIEW.md +189 -0
- package/docs/VERIFICATION.md +186 -0
- package/docs/viewer.md +74 -0
- package/package.json +67 -4
- package/src/ancestry.ts +32 -0
- package/src/collector.ts +143 -0
- package/src/context.ts +115 -0
- package/src/correlation.ts +27 -0
- package/src/entry-index.ts +17 -0
- package/src/entry-summary.ts +34 -0
- package/src/extension.ts +259 -0
- package/src/feed.ts +145 -0
- package/src/identity.ts +112 -0
- package/src/index.ts +1 -0
- package/src/model.ts +117 -0
- package/src/native-projection.ts +28 -0
- package/src/privacy.ts +136 -0
- package/src/projection.ts +374 -0
- package/src/server.ts +202 -0
- package/src/system-message.ts +117 -0
- package/src/web/api.ts +137 -0
- package/src/web/app.tsx +570 -0
- package/src/web/captured-executions.tsx +177 -0
- package/src/web/components.css +261 -0
- package/src/web/components.tsx +241 -0
- package/src/web/composition.css +554 -0
- package/src/web/context-composition.tsx +329 -0
- package/src/web/context-inventory.tsx +65 -0
- package/src/web/entry-calls.ts +14 -0
- package/src/web/format.ts +94 -0
- package/src/web/hierarchy.ts +117 -0
- package/src/web/history.ts +56 -0
- package/src/web/inline-entry.tsx +124 -0
- package/src/web/inspector.tsx +386 -0
- package/src/web/json-tree.tsx +74 -0
- package/src/web/overview.tsx +67 -0
- package/src/web/panels.css +267 -0
- package/src/web/panes.tsx +123 -0
- package/src/web/recorded-content.tsx +49 -0
- package/src/web/search.ts +16 -0
- package/src/web/session-filters.tsx +100 -0
- package/src/web/session.css +114 -0
- package/src/web/style.css +332 -0
- package/src/web/styles.d.ts +1 -0
- package/src/web/timeline.tsx +33 -0
- package/src/web/timing.ts +26 -0
- package/src/web/trace.css +411 -0
- package/src/web/trace.tsx +313 -0
- package/src/web/virtual-context.ts +138 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { ChevronDownIcon, ChevronRightIcon, MagnifyingGlassIcon } from "@radix-ui/react-icons";
|
|
2
|
+
import { Heading, TextField } from "@radix-ui/themes";
|
|
3
|
+
import { useEffect, useMemo, useState } from "react";
|
|
4
|
+
import type { ContextComposition as Composition, ContextSegment, Snapshot } from "../model.js";
|
|
5
|
+
import { Data, Glyph, Metadata } from "./components.js";
|
|
6
|
+
import { output, record, time } from "./format.js";
|
|
7
|
+
import { boundedSearch, searchNeedle } from "./search.js";
|
|
8
|
+
import { useVirtualContext } from "./virtual-context.js";
|
|
9
|
+
|
|
10
|
+
const categories = ["system", "user", "assistant", "toolCall", "toolResult", "other"] as const;
|
|
11
|
+
const names: Record<string, string> = {
|
|
12
|
+
system: "System prompt",
|
|
13
|
+
user: "User message",
|
|
14
|
+
assistant: "Assistant",
|
|
15
|
+
toolCall: "Tool call",
|
|
16
|
+
toolResult: "Tool result",
|
|
17
|
+
other: "Other",
|
|
18
|
+
};
|
|
19
|
+
export function ContextComposition({
|
|
20
|
+
context,
|
|
21
|
+
payload,
|
|
22
|
+
preview = false,
|
|
23
|
+
}: {
|
|
24
|
+
context?: Composition;
|
|
25
|
+
payload?: Snapshot["providerObservation"];
|
|
26
|
+
preview?: boolean;
|
|
27
|
+
}) {
|
|
28
|
+
const [query, search] = useState("");
|
|
29
|
+
const [category, filter] = useState("all");
|
|
30
|
+
const [expanded, expand] = useState(new Set<string>());
|
|
31
|
+
const [selected, select] = useState("");
|
|
32
|
+
const segments = context?.segments;
|
|
33
|
+
const needle = useMemo(() => searchNeedle(query), [query]);
|
|
34
|
+
const searchable = useMemo(
|
|
35
|
+
() => context?.messages.map((message) => output(message.value).toLowerCase()) ?? [],
|
|
36
|
+
[context?.messages],
|
|
37
|
+
);
|
|
38
|
+
const rows = useMemo(
|
|
39
|
+
() =>
|
|
40
|
+
(segments ?? []).filter((segment) => {
|
|
41
|
+
const message = searchable[segment.messageIndex] ?? "";
|
|
42
|
+
return (
|
|
43
|
+
(category === "all" || segment.category === category) &&
|
|
44
|
+
(`${segment.role} ${segment.kind} ${segment.preview}`.toLowerCase().includes(needle) ||
|
|
45
|
+
message.includes(needle))
|
|
46
|
+
);
|
|
47
|
+
}),
|
|
48
|
+
[segments, category, needle, searchable],
|
|
49
|
+
);
|
|
50
|
+
const ids = useMemo(() => rows.map((row) => row.id), [rows]);
|
|
51
|
+
const virtual = useVirtualContext(ids);
|
|
52
|
+
const [focused, focus] = useState("");
|
|
53
|
+
const visibleRows = rows.slice(virtual.range.start, virtual.range.end);
|
|
54
|
+
const tabTarget = visibleRows.some((row) => row.id === focused) ? focused : visibleRows[0]?.id;
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
const valid = new Set(segments?.map((row) => row.id));
|
|
57
|
+
expand((old) => new Set([...old].filter((id) => valid.has(id))));
|
|
58
|
+
if (selected && !valid.has(selected)) select("");
|
|
59
|
+
}, [segments, selected]);
|
|
60
|
+
function toggle(id: string): void {
|
|
61
|
+
select(id);
|
|
62
|
+
expand((old) => {
|
|
63
|
+
const next = new Set(old);
|
|
64
|
+
if (next.has(id)) next.delete(id);
|
|
65
|
+
else next.add(id);
|
|
66
|
+
return next;
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
const bucket = Math.max(1, Math.ceil(rows.length / 160));
|
|
70
|
+
return (
|
|
71
|
+
<section className="context-composition" aria-label="Context composition">
|
|
72
|
+
<div className="composition-heading">
|
|
73
|
+
<div>
|
|
74
|
+
<Heading size="5">Context composition</Heading>
|
|
75
|
+
<span className="composition-count">
|
|
76
|
+
{rows.length} captured segments · {context?.totalMessages ?? 0} messages · segment tokens unavailable
|
|
77
|
+
</span>
|
|
78
|
+
</div>
|
|
79
|
+
<fieldset className="context-filters" aria-label="Context category">
|
|
80
|
+
{[
|
|
81
|
+
["all", "All"],
|
|
82
|
+
["system", "System"],
|
|
83
|
+
["user", "User"],
|
|
84
|
+
["assistant", "Assistant"],
|
|
85
|
+
["toolCall", "Tool call"],
|
|
86
|
+
["toolResult", "Tool result"],
|
|
87
|
+
].map(([value, label]) => (
|
|
88
|
+
<button
|
|
89
|
+
type="button"
|
|
90
|
+
key={value}
|
|
91
|
+
aria-pressed={category === value}
|
|
92
|
+
onClick={() => {
|
|
93
|
+
filter(value ?? "all");
|
|
94
|
+
virtual.reset();
|
|
95
|
+
}}
|
|
96
|
+
>
|
|
97
|
+
{label}
|
|
98
|
+
</button>
|
|
99
|
+
))}
|
|
100
|
+
</fieldset>
|
|
101
|
+
<TextField.Root
|
|
102
|
+
aria-label="Search context"
|
|
103
|
+
placeholder="Search captured context…"
|
|
104
|
+
value={query}
|
|
105
|
+
onChange={(event) => {
|
|
106
|
+
search(boundedSearch(event.target.value));
|
|
107
|
+
virtual.reset();
|
|
108
|
+
}}
|
|
109
|
+
>
|
|
110
|
+
<TextField.Slot>
|
|
111
|
+
<MagnifyingGlassIcon />
|
|
112
|
+
</TextField.Slot>
|
|
113
|
+
</TextField.Root>
|
|
114
|
+
</div>
|
|
115
|
+
<div className="context-provenance" role="status">
|
|
116
|
+
{preview
|
|
117
|
+
? "Session-derived preview · selected branch projection; not a captured historical request."
|
|
118
|
+
: context?.source === "observed-pi-context"
|
|
119
|
+
? "Last observed Pi context · context_with_system stage; later hooks and provider serialization may differ."
|
|
120
|
+
: "Session-derived context · native active-leaf projection; request-local hooks and provider serialization are unavailable."}
|
|
121
|
+
{context?.observedAt && (
|
|
122
|
+
<span>
|
|
123
|
+
{" "}
|
|
124
|
+
Observed {new Date(context.observedAt).toLocaleTimeString()} · leaf {context.leafId ?? "unavailable"}
|
|
125
|
+
</span>
|
|
126
|
+
)}
|
|
127
|
+
{!context?.observedAt && <span> Leaf {context?.leafId ?? "unavailable"}.</span>}
|
|
128
|
+
{context?.incomplete && (
|
|
129
|
+
<strong> Bounded capture is incomplete; only available captured content is searchable.</strong>
|
|
130
|
+
)}
|
|
131
|
+
{context?.unavailable && <strong> Unavailable: {context.unavailable}.</strong>}
|
|
132
|
+
</div>
|
|
133
|
+
<div className="composition-body">
|
|
134
|
+
<section
|
|
135
|
+
className="context-scroll"
|
|
136
|
+
ref={virtual.scroller}
|
|
137
|
+
onScroll={virtual.refresh}
|
|
138
|
+
aria-label="Context segments"
|
|
139
|
+
>
|
|
140
|
+
<div className="virtual-segments" style={{ height: virtual.totalHeight }}>
|
|
141
|
+
{visibleRows.map((segment, localIndex) => {
|
|
142
|
+
const index = virtual.range.start + localIndex;
|
|
143
|
+
const position = virtual.positions[index];
|
|
144
|
+
const open = expanded.has(segment.id);
|
|
145
|
+
return (
|
|
146
|
+
<div
|
|
147
|
+
key={segment.id}
|
|
148
|
+
className="context-segment"
|
|
149
|
+
data-segment-id={segment.id}
|
|
150
|
+
data-category={segment.category}
|
|
151
|
+
style={{ top: position?.top }}
|
|
152
|
+
ref={(element) => {
|
|
153
|
+
if (element) virtual.elements.current.set(segment.id, element);
|
|
154
|
+
else virtual.elements.current.delete(segment.id);
|
|
155
|
+
}}
|
|
156
|
+
>
|
|
157
|
+
<button
|
|
158
|
+
type="button"
|
|
159
|
+
className={`segment-row ${selected === segment.id ? "selected" : ""}`}
|
|
160
|
+
tabIndex={tabTarget === segment.id ? 0 : -1}
|
|
161
|
+
aria-expanded={open}
|
|
162
|
+
aria-label={`${segment.position} ${segment.role === "developer" ? "Developer" : names[segment.category]} ${segment.preview}`}
|
|
163
|
+
onFocus={() => focus(segment.id)}
|
|
164
|
+
onClick={() => toggle(segment.id)}
|
|
165
|
+
onKeyDown={(event) => {
|
|
166
|
+
if (event.target !== event.currentTarget || event.ctrlKey || event.altKey || event.metaKey)
|
|
167
|
+
return;
|
|
168
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
169
|
+
event.preventDefault();
|
|
170
|
+
toggle(segment.id);
|
|
171
|
+
} else if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
|
|
172
|
+
event.preventDefault();
|
|
173
|
+
virtual.jump(
|
|
174
|
+
event.key === "Home"
|
|
175
|
+
? 0
|
|
176
|
+
: event.key === "End"
|
|
177
|
+
? rows.length - 1
|
|
178
|
+
: Math.max(0, Math.min(rows.length - 1, index + (event.key === "ArrowDown" ? 1 : -1))),
|
|
179
|
+
true,
|
|
180
|
+
);
|
|
181
|
+
} else if ((event.key === "ArrowRight" && !open) || (event.key === "ArrowLeft" && open)) {
|
|
182
|
+
event.preventDefault();
|
|
183
|
+
toggle(segment.id);
|
|
184
|
+
}
|
|
185
|
+
}}
|
|
186
|
+
>
|
|
187
|
+
<span className="segment-position">{segment.position}</span>
|
|
188
|
+
<Glyph kind={segment.category} />
|
|
189
|
+
<strong>
|
|
190
|
+
{segment.role === "developer" ? "Developer" : names[segment.category]}
|
|
191
|
+
{segment.category === "assistant" && segment.kind !== "assistant" && segment.kind !== "text"
|
|
192
|
+
? ` · ${segment.kind}`
|
|
193
|
+
: ""}
|
|
194
|
+
</strong>
|
|
195
|
+
<span className="segment-preview" title={segment.preview}>
|
|
196
|
+
{segment.preview}
|
|
197
|
+
</span>
|
|
198
|
+
{segment.timestamp && <time title={segment.timestamp}>{time(segment.timestamp)}</time>}
|
|
199
|
+
<span className="segment-chevron" aria-hidden="true">
|
|
200
|
+
{open ? <ChevronDownIcon /> : <ChevronRightIcon />}
|
|
201
|
+
</span>
|
|
202
|
+
</button>
|
|
203
|
+
{open && context && <SegmentDetails key={segment.id} segment={segment} context={context} />}
|
|
204
|
+
</div>
|
|
205
|
+
);
|
|
206
|
+
})}
|
|
207
|
+
</div>
|
|
208
|
+
{!rows.length && (
|
|
209
|
+
<div className="empty-state">
|
|
210
|
+
No captured segments match. This view does not include unrelated session events.
|
|
211
|
+
</div>
|
|
212
|
+
)}
|
|
213
|
+
</section>
|
|
214
|
+
<nav className="context-minimap" aria-label="Context minimap">
|
|
215
|
+
{Array.from({ length: Math.ceil(rows.length / bucket) }, (_, index) => {
|
|
216
|
+
const start = index * bucket;
|
|
217
|
+
const end = Math.min(rows.length, start + bucket);
|
|
218
|
+
const group = rows.slice(start, end);
|
|
219
|
+
return (
|
|
220
|
+
<button
|
|
221
|
+
type="button"
|
|
222
|
+
key={group[0]?.id}
|
|
223
|
+
aria-label={`Jump to context position ${group[0]?.position}`}
|
|
224
|
+
title={`Filtered rows ${start + 1}–${end}`}
|
|
225
|
+
aria-current={start < virtual.viewport.end && end > virtual.viewport.start ? "true" : undefined}
|
|
226
|
+
onClick={() => virtual.jump(start, true)}
|
|
227
|
+
>
|
|
228
|
+
{categories.map((value) => {
|
|
229
|
+
const size = group.filter((row) => row.category === value).length;
|
|
230
|
+
return size ? <span key={value} data-category={value} style={{ flex: size }} /> : null;
|
|
231
|
+
})}
|
|
232
|
+
</button>
|
|
233
|
+
);
|
|
234
|
+
})}
|
|
235
|
+
</nav>
|
|
236
|
+
</div>
|
|
237
|
+
<div className="composition-footer">
|
|
238
|
+
<span>Messages and supported content blocks in source order · filtered minimap</span>
|
|
239
|
+
<span>
|
|
240
|
+
{Math.min(rows.length, virtual.range.start + 1)}–{Math.min(rows.length, virtual.range.end)} / {rows.length}
|
|
241
|
+
</span>
|
|
242
|
+
</div>
|
|
243
|
+
{payload && (
|
|
244
|
+
<details className="provider-observation">
|
|
245
|
+
<summary>
|
|
246
|
+
Advanced · independent provider observation (request association unavailable; may include warming/retries)
|
|
247
|
+
</summary>
|
|
248
|
+
<span>
|
|
249
|
+
Observed {new Date(payload.observedAt).toLocaleTimeString()} · not attributed to the context leaf/turn
|
|
250
|
+
</span>
|
|
251
|
+
<Data
|
|
252
|
+
label="Observed provider payload · redacted bounded copy"
|
|
253
|
+
data={payload.data}
|
|
254
|
+
scope="observed-provider-payload"
|
|
255
|
+
/>
|
|
256
|
+
</details>
|
|
257
|
+
)}
|
|
258
|
+
</section>
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
function SegmentDetails({ segment, context }: { segment: ContextSegment; context: Composition }) {
|
|
262
|
+
const raw = context.messages[segment.messageIndex];
|
|
263
|
+
const message = record(raw?.value);
|
|
264
|
+
const block =
|
|
265
|
+
segment.blockIndex === undefined
|
|
266
|
+
? message?.content === "" && message?.sections
|
|
267
|
+
? message.sections
|
|
268
|
+
: (message?.content ?? message?.sections ?? raw?.value)
|
|
269
|
+
: Array.isArray(message?.content)
|
|
270
|
+
? message.content[segment.blockIndex]
|
|
271
|
+
: undefined;
|
|
272
|
+
const item = record(block);
|
|
273
|
+
const content = item?.text ?? item?.thinking ?? item?.arguments ?? block;
|
|
274
|
+
return (
|
|
275
|
+
<div className="segment-details">
|
|
276
|
+
<section className="message-details">
|
|
277
|
+
<h3>Message details</h3>
|
|
278
|
+
<Metadata
|
|
279
|
+
rows={[
|
|
280
|
+
["Role", segment.role],
|
|
281
|
+
["Type", segment.kind],
|
|
282
|
+
["Position", `${segment.position} / ${context.segments.length}`],
|
|
283
|
+
["Source message", `${segment.messageIndex + 1} / ${context.totalMessages}`],
|
|
284
|
+
...(segment.blockIndex === undefined
|
|
285
|
+
? []
|
|
286
|
+
: [["Content block", String(segment.blockIndex + 1)] as [string, string]]),
|
|
287
|
+
["Timestamp", segment.timestamp],
|
|
288
|
+
["Tokens", "Unavailable for this segment"],
|
|
289
|
+
]}
|
|
290
|
+
/>
|
|
291
|
+
</section>
|
|
292
|
+
<div className="segment-content">
|
|
293
|
+
<Data
|
|
294
|
+
label="Content"
|
|
295
|
+
data={raw ? { value: content ?? null, truncated: raw.truncated } : undefined}
|
|
296
|
+
scope={`${segment.id}-content`}
|
|
297
|
+
/>
|
|
298
|
+
{item?.type === "toolCall" && (
|
|
299
|
+
<Data
|
|
300
|
+
label={`Tool arguments · ${typeof item.name === "string" ? item.name : "unknown"}`}
|
|
301
|
+
data={{ value: item.arguments ?? null, truncated: Boolean(raw?.truncated) }}
|
|
302
|
+
scope={`${segment.id}-arguments`}
|
|
303
|
+
defaultOpen={false}
|
|
304
|
+
/>
|
|
305
|
+
)}
|
|
306
|
+
<Data
|
|
307
|
+
label="Structured content"
|
|
308
|
+
data={raw ? { value: block ?? null, truncated: raw.truncated } : undefined}
|
|
309
|
+
scope={`${segment.id}-structured`}
|
|
310
|
+
defaultOpen={false}
|
|
311
|
+
/>
|
|
312
|
+
<Data
|
|
313
|
+
label="Metadata"
|
|
314
|
+
data={
|
|
315
|
+
raw
|
|
316
|
+
? {
|
|
317
|
+
value: Object.fromEntries(Object.entries(message ?? {}).filter(([key]) => key !== "content")),
|
|
318
|
+
truncated: raw.truncated,
|
|
319
|
+
}
|
|
320
|
+
: undefined
|
|
321
|
+
}
|
|
322
|
+
scope={`${segment.id}-metadata`}
|
|
323
|
+
defaultOpen={false}
|
|
324
|
+
/>
|
|
325
|
+
<Data label="Raw JSON · captured message" data={raw} scope={`${segment.id}-raw`} defaultOpen={false} />
|
|
326
|
+
</div>
|
|
327
|
+
</div>
|
|
328
|
+
);
|
|
329
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Badge, Card, Flex, Text } from "@radix-ui/themes";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import type { Snapshot } from "../model.js";
|
|
4
|
+
import { Data } from "./components.js";
|
|
5
|
+
|
|
6
|
+
export function ContextInventory({ snapshot }: { snapshot?: Snapshot }) {
|
|
7
|
+
const [open, setOpen] = useState(false);
|
|
8
|
+
const [toolsOpen, setToolsOpen] = useState(false);
|
|
9
|
+
const [skillsOpen, setSkillsOpen] = useState(false);
|
|
10
|
+
return (
|
|
11
|
+
<details className="context-inventory" open={open} onToggle={(event) => setOpen(event.currentTarget.open)}>
|
|
12
|
+
<summary>Current runtime information · not historical request evidence</summary>
|
|
13
|
+
{open && (
|
|
14
|
+
<div className="inventory-scroll">
|
|
15
|
+
<Data
|
|
16
|
+
label="Current runtime effective prompt · may not yet be sent"
|
|
17
|
+
data={snapshot?.currentPrompt}
|
|
18
|
+
defaultOpen={false}
|
|
19
|
+
/>
|
|
20
|
+
<Text as="p" size="2" color="gray">
|
|
21
|
+
Current active ≠ provider-visible. MCP connection status is unavailable.
|
|
22
|
+
</Text>
|
|
23
|
+
<details open={toolsOpen} onToggle={(event) => setToolsOpen(event.currentTarget.open)}>
|
|
24
|
+
<summary>Current tools</summary>
|
|
25
|
+
{toolsOpen &&
|
|
26
|
+
snapshot?.tools.map((tool) => (
|
|
27
|
+
<Card key={tool.name} className="inventory-card">
|
|
28
|
+
<Text weight="bold">{tool.name}</Text>
|
|
29
|
+
<Flex gap="1" wrap="wrap">
|
|
30
|
+
<Badge>{tool.namespace ?? "tool"}</Badge>
|
|
31
|
+
<Badge>{tool.exposure}</Badge>
|
|
32
|
+
<Badge>{tool.active ? "active" : "inactive"}</Badge>
|
|
33
|
+
<Badge>{tool.callable ? "callable" : "not callable"}</Badge>
|
|
34
|
+
</Flex>
|
|
35
|
+
<Text as="p" size="2" color="gray">
|
|
36
|
+
{tool.description}
|
|
37
|
+
</Text>
|
|
38
|
+
<Data label={`Schema · ${tool.name}`} data={tool.schema} defaultOpen={false} />
|
|
39
|
+
</Card>
|
|
40
|
+
))}
|
|
41
|
+
</details>
|
|
42
|
+
<details open={skillsOpen} onToggle={(event) => setSkillsOpen(event.currentTarget.open)}>
|
|
43
|
+
<summary>Currently advertised skills</summary>
|
|
44
|
+
<Text as="p" size="2" color="gray">
|
|
45
|
+
Advertised or read does not prove model compliance. Historical catalogs are unavailable.
|
|
46
|
+
</Text>
|
|
47
|
+
{skillsOpen &&
|
|
48
|
+
snapshot?.skills.map((skill) => (
|
|
49
|
+
<Card key={skill.path} className="inventory-card">
|
|
50
|
+
<Text weight="bold">{skill.name}</Text>
|
|
51
|
+
<Badge>advertised</Badge>
|
|
52
|
+
<Text as="p" size="2">
|
|
53
|
+
{skill.description}
|
|
54
|
+
</Text>
|
|
55
|
+
<Text size="1" color="gray">
|
|
56
|
+
{skill.path}
|
|
57
|
+
</Text>
|
|
58
|
+
</Card>
|
|
59
|
+
))}
|
|
60
|
+
</details>
|
|
61
|
+
</div>
|
|
62
|
+
)}
|
|
63
|
+
</details>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { correlatedCalls } from "../correlation.js";
|
|
2
|
+
import type { Call, Capture } from "../model.js";
|
|
3
|
+
import { record } from "./format.js";
|
|
4
|
+
export function entryCalls(raw: Capture | undefined, calls: Call[], anchor?: string): Call[] {
|
|
5
|
+
const message = record(record(raw?.value)?.message);
|
|
6
|
+
const ids = new Set<string>();
|
|
7
|
+
if (message?.role === "toolResult" && typeof message.toolCallId === "string") ids.add(message.toolCallId);
|
|
8
|
+
if (message?.role === "assistant" && Array.isArray(message.content))
|
|
9
|
+
for (const block of message.content) {
|
|
10
|
+
const item = record(block);
|
|
11
|
+
if (item?.type === "toolCall" && typeof item.id === "string") ids.add(item.id);
|
|
12
|
+
}
|
|
13
|
+
return correlatedCalls(ids, anchor, calls);
|
|
14
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { Call, Capture, EntrySummary, Json } from "../model.js";
|
|
2
|
+
|
|
3
|
+
export function output(value: Json): string {
|
|
4
|
+
return typeof value === "string" ? value : JSON.stringify(value, null, 2);
|
|
5
|
+
}
|
|
6
|
+
export function record(value: Json | undefined): { [key: string]: Json } | undefined {
|
|
7
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
|
|
8
|
+
}
|
|
9
|
+
export function duration(ms?: number): string {
|
|
10
|
+
return ms === undefined ? "—" : ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
|
|
11
|
+
}
|
|
12
|
+
export function count(value?: number): string {
|
|
13
|
+
return value === undefined ? "—" : value.toLocaleString("en-US");
|
|
14
|
+
}
|
|
15
|
+
export function time(value: string): string {
|
|
16
|
+
return /^\d{4}-\d\d-\d\dT/.test(value) ? value.slice(11, 19) : value || "—";
|
|
17
|
+
}
|
|
18
|
+
export function group(kind: string): string {
|
|
19
|
+
return ["assistant", "model_change", "thinking_level_change"].includes(kind)
|
|
20
|
+
? "Model"
|
|
21
|
+
: kind === "toolResult"
|
|
22
|
+
? "Tool"
|
|
23
|
+
: ["custom", "custom_message"].includes(kind)
|
|
24
|
+
? "Custom"
|
|
25
|
+
: "Other";
|
|
26
|
+
}
|
|
27
|
+
export function eventName(kind: string): string {
|
|
28
|
+
return kind === "assistant" ? "model" : kind === "toolResult" ? "tool_result" : kind;
|
|
29
|
+
}
|
|
30
|
+
export interface Filters {
|
|
31
|
+
query: string;
|
|
32
|
+
kind: string;
|
|
33
|
+
groups: string[];
|
|
34
|
+
errorsOnly: boolean;
|
|
35
|
+
slowOnly: boolean;
|
|
36
|
+
}
|
|
37
|
+
export function matches(entry: EntrySummary, filters: Filters, calls: Call[]): boolean {
|
|
38
|
+
const call = calls.find(
|
|
39
|
+
(call) =>
|
|
40
|
+
call.id === entry.toolCallId &&
|
|
41
|
+
call.branchAnchor === entry.toolAnchor &&
|
|
42
|
+
!call.parentOccurrenceId &&
|
|
43
|
+
!call.correlationUnavailable,
|
|
44
|
+
);
|
|
45
|
+
return (
|
|
46
|
+
(filters.kind === "all" || entry.kind === filters.kind) &&
|
|
47
|
+
(!filters.groups.length || filters.groups.includes(group(entry.kind))) &&
|
|
48
|
+
(!filters.errorsOnly || entry.status === "error" || call?.status === "error") &&
|
|
49
|
+
(!filters.slowOnly || (call?.durationMs ?? 0) > 10000) &&
|
|
50
|
+
`${entry.kind} ${entry.name ?? ""} ${entry.label} ${entry.summary ?? ""} ${entry.id}`
|
|
51
|
+
.toLowerCase()
|
|
52
|
+
.includes(filters.query)
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
export function matchesCall(call: Call, filters: Filters): boolean {
|
|
56
|
+
return (
|
|
57
|
+
(!filters.groups.length || filters.groups.includes("Tool")) &&
|
|
58
|
+
(!filters.errorsOnly || call.status === "error") &&
|
|
59
|
+
(!filters.slowOnly || (call.durationMs ?? 0) > 10000) &&
|
|
60
|
+
`${call.name} ${call.id}`.toLowerCase().includes(filters.query) &&
|
|
61
|
+
(filters.kind === "all" || filters.kind === "toolResult")
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
export function promptDiff(before?: Capture, after?: Capture): Capture {
|
|
65
|
+
const left = before ? output(before.value).split("\n") : [];
|
|
66
|
+
const right = after ? output(after.value).split("\n") : [];
|
|
67
|
+
let start = 0;
|
|
68
|
+
let end = 0;
|
|
69
|
+
while (start < left.length && start < right.length && left[start] === right[start]) start++;
|
|
70
|
+
while (
|
|
71
|
+
end < left.length - start &&
|
|
72
|
+
end < right.length - start &&
|
|
73
|
+
left[left.length - end - 1] === right[right.length - end - 1]
|
|
74
|
+
)
|
|
75
|
+
end++;
|
|
76
|
+
return {
|
|
77
|
+
value:
|
|
78
|
+
[
|
|
79
|
+
...left.slice(start, left.length - end).map((line) => `- ${line}`),
|
|
80
|
+
...right.slice(start, right.length - end).map((line) => `+ ${line}`),
|
|
81
|
+
].join("\n") || "No prompt change",
|
|
82
|
+
truncated: Boolean(before?.truncated || after?.truncated),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
export function scripts(data?: Capture): Capture | undefined {
|
|
86
|
+
const message = record(record(data?.value)?.message);
|
|
87
|
+
if (!message || !Array.isArray(message.content)) return;
|
|
88
|
+
const code = message.content.flatMap((block) => {
|
|
89
|
+
const item = record(block);
|
|
90
|
+
const args = record(item?.arguments);
|
|
91
|
+
return item?.type === "toolCall" && item.name === "codemode" && typeof args?.code === "string" ? [args.code] : [];
|
|
92
|
+
});
|
|
93
|
+
return code.length ? { value: code.join("\n\n"), truncated: Boolean(data?.truncated) } : undefined;
|
|
94
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
export interface ParentNode {
|
|
2
|
+
id: string;
|
|
3
|
+
parentId: string | null;
|
|
4
|
+
}
|
|
5
|
+
export interface Hierarchy<T extends ParentNode> {
|
|
6
|
+
nodes: Map<string, T>;
|
|
7
|
+
children: Map<string, T[]>;
|
|
8
|
+
roots: T[];
|
|
9
|
+
}
|
|
10
|
+
export interface HierarchyRow<T> {
|
|
11
|
+
node: T;
|
|
12
|
+
depth: number;
|
|
13
|
+
childCount: number;
|
|
14
|
+
}
|
|
15
|
+
export function hierarchy<T extends ParentNode>(items: T[]): Hierarchy<T> {
|
|
16
|
+
const nodes = new Map(items.map((node) => [node.id, node]));
|
|
17
|
+
const children = new Map<string, T[]>();
|
|
18
|
+
const roots: T[] = [];
|
|
19
|
+
for (const node of items) {
|
|
20
|
+
if (!node.parentId || !nodes.has(node.parentId)) roots.push(node);
|
|
21
|
+
else {
|
|
22
|
+
const siblings = children.get(node.parentId) ?? [];
|
|
23
|
+
siblings.push(node);
|
|
24
|
+
children.set(node.parentId, siblings);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
// Malformed rootless cycles remain inspectable; original parent IDs are never rewritten.
|
|
28
|
+
const reached = new Set<string>();
|
|
29
|
+
const stack = roots.slice();
|
|
30
|
+
const visit = () => {
|
|
31
|
+
while (stack.length) {
|
|
32
|
+
const node = stack.pop();
|
|
33
|
+
if (!node || reached.has(node.id)) continue;
|
|
34
|
+
reached.add(node.id);
|
|
35
|
+
stack.push(...(children.get(node.id) ?? []));
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
visit();
|
|
39
|
+
for (const node of items)
|
|
40
|
+
if (!reached.has(node.id)) {
|
|
41
|
+
roots.push(node);
|
|
42
|
+
stack.push(node);
|
|
43
|
+
visit();
|
|
44
|
+
}
|
|
45
|
+
return { nodes, children, roots };
|
|
46
|
+
}
|
|
47
|
+
export function ancestors<T extends ParentNode>(tree: Hierarchy<T>, id: string): string[] {
|
|
48
|
+
const result: string[] = [];
|
|
49
|
+
const seen = new Set([id]);
|
|
50
|
+
let parent = tree.nodes.get(id)?.parentId;
|
|
51
|
+
while (parent && tree.nodes.has(parent) && !seen.has(parent)) {
|
|
52
|
+
seen.add(parent);
|
|
53
|
+
result.push(parent);
|
|
54
|
+
parent = tree.nodes.get(parent)?.parentId;
|
|
55
|
+
}
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
export function withAncestors<T extends ParentNode>(tree: Hierarchy<T>, matches: Set<string>): Set<string> {
|
|
59
|
+
const keep = new Set(matches);
|
|
60
|
+
// Each parent edge is visited once even for very long linear histories.
|
|
61
|
+
for (const id of matches) {
|
|
62
|
+
let current = tree.nodes.get(id)?.parentId;
|
|
63
|
+
const seen = new Set([id]);
|
|
64
|
+
while (current && tree.nodes.has(current) && !seen.has(current)) {
|
|
65
|
+
seen.add(current);
|
|
66
|
+
if (keep.has(current)) break;
|
|
67
|
+
keep.add(current);
|
|
68
|
+
current = tree.nodes.get(current)?.parentId;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return keep;
|
|
72
|
+
}
|
|
73
|
+
export function flatten<T extends ParentNode>(
|
|
74
|
+
tree: Hierarchy<T>,
|
|
75
|
+
expanded: Set<string>,
|
|
76
|
+
keep?: Set<string>,
|
|
77
|
+
): HierarchyRow<T>[] {
|
|
78
|
+
const rows: HierarchyRow<T>[] = [];
|
|
79
|
+
const seen = new Set<string>();
|
|
80
|
+
const stack = tree.roots
|
|
81
|
+
.slice()
|
|
82
|
+
.reverse()
|
|
83
|
+
.map((node) => ({ node, depth: 0 }));
|
|
84
|
+
while (stack.length) {
|
|
85
|
+
const row = stack.pop();
|
|
86
|
+
if (!row || seen.has(row.node.id) || (keep && !keep.has(row.node.id))) continue;
|
|
87
|
+
seen.add(row.node.id);
|
|
88
|
+
const children = (tree.children.get(row.node.id) ?? []).filter((node) => !keep || keep.has(node.id));
|
|
89
|
+
rows.push({ ...row, childCount: children.length });
|
|
90
|
+
if (expanded.has(row.node.id))
|
|
91
|
+
for (const node of children.slice().reverse()) stack.push({ node, depth: row.depth + 1 });
|
|
92
|
+
}
|
|
93
|
+
return rows;
|
|
94
|
+
}
|
|
95
|
+
export function reveal<T extends ParentNode>(tree: Hierarchy<T>, expanded: Set<string>, id: string): Set<string> {
|
|
96
|
+
return new Set([...expanded, ...ancestors(tree, id)]);
|
|
97
|
+
}
|
|
98
|
+
export function label(node: { kind: string; name?: string; label: string }): string {
|
|
99
|
+
if (node.name) return node.name;
|
|
100
|
+
if (node.label && !/^[[{]/.test(node.label.trim())) return node.label;
|
|
101
|
+
switch (node.kind) {
|
|
102
|
+
case "context_edit":
|
|
103
|
+
return "Context contribution updated";
|
|
104
|
+
case "compaction":
|
|
105
|
+
return "Compaction checkpoint";
|
|
106
|
+
case "branch_summary":
|
|
107
|
+
return "Branch summary";
|
|
108
|
+
case "custom":
|
|
109
|
+
return "Custom session entry";
|
|
110
|
+
case "label":
|
|
111
|
+
return "Entry label updated";
|
|
112
|
+
case "session_info":
|
|
113
|
+
return "Session metadata";
|
|
114
|
+
default:
|
|
115
|
+
return "Recorded session entry";
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { EntrySummary } from "../model.js";
|
|
2
|
+
import { label } from "./hierarchy.js";
|
|
3
|
+
|
|
4
|
+
/** Pi bookkeeping entries do not contribute messages; custom_message always does. */
|
|
5
|
+
export function internalEntry(entry: EntrySummary): boolean {
|
|
6
|
+
return !entry.status && entry.internal === true;
|
|
7
|
+
}
|
|
8
|
+
export interface HistoryRow {
|
|
9
|
+
node: EntrySummary;
|
|
10
|
+
turn: string;
|
|
11
|
+
heading?: string;
|
|
12
|
+
}
|
|
13
|
+
export function history(entries: EntrySummary[], leaf: string | null): { rows: HistoryRow[]; issue?: string } {
|
|
14
|
+
const byId = new Map(entries.map((entry) => [entry.id, entry]));
|
|
15
|
+
const path: EntrySummary[] = [];
|
|
16
|
+
const seen = new Set<string>();
|
|
17
|
+
let id = leaf;
|
|
18
|
+
let issue: string | undefined;
|
|
19
|
+
while (id) {
|
|
20
|
+
if (seen.has(id)) {
|
|
21
|
+
issue = "Recorded parent cycle; partial history only";
|
|
22
|
+
break;
|
|
23
|
+
}
|
|
24
|
+
if (path.length >= 10000) {
|
|
25
|
+
issue = "History inspection budget exceeded";
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
const entry = byId.get(id);
|
|
29
|
+
if (!entry) {
|
|
30
|
+
issue = "Parent or branch leaf outside indexed data; partial history only";
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
seen.add(id);
|
|
34
|
+
path.push(entry);
|
|
35
|
+
id = entry.parentId;
|
|
36
|
+
}
|
|
37
|
+
if (!leaf) issue = "Active branch leaf unavailable; use Branch view to inspect indexed entries";
|
|
38
|
+
const rows: HistoryRow[] = [];
|
|
39
|
+
let turn = "before-user";
|
|
40
|
+
let first = true;
|
|
41
|
+
for (const node of path.reverse()) {
|
|
42
|
+
const starts = first || node.kind === "user";
|
|
43
|
+
if (node.kind === "user") turn = node.id;
|
|
44
|
+
rows.push({
|
|
45
|
+
node,
|
|
46
|
+
turn,
|
|
47
|
+
heading: starts ? (node.kind === "user" ? "User turn" : "Before first user turn") : undefined,
|
|
48
|
+
});
|
|
49
|
+
first = false;
|
|
50
|
+
}
|
|
51
|
+
return { rows, issue };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function historySummary(entry: EntrySummary): string {
|
|
55
|
+
return entry.summary || (entry.kind === "system" ? "System prompt update" : label(entry));
|
|
56
|
+
}
|