@narumitw/pi-inspect 0.0.0-stage → 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 +107 -2
- package/dist/app.css +1 -0
- package/dist/app.js +129 -0
- package/dist/index.html +1 -0
- package/docs/REVIEW.md +189 -0
- package/docs/VERIFICATION.md +186 -0
- package/docs/viewer.md +63 -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/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 +113 -0
- package/src/native-projection.ts +28 -0
- package/src/privacy.ts +136 -0
- package/src/projection.ts +361 -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 +439 -0
- package/src/web/components.css +261 -0
- package/src/web/components.tsx +289 -0
- package/src/web/composition.css +554 -0
- package/src/web/context-composition.tsx +324 -0
- package/src/web/entry-calls.ts +14 -0
- package/src/web/format.ts +92 -0
- package/src/web/hierarchy.ts +117 -0
- package/src/web/inline-entry.tsx +124 -0
- package/src/web/inspector.tsx +337 -0
- package/src/web/json-tree.tsx +74 -0
- package/src/web/live-drawer.tsx +175 -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/search.ts +16 -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 +410 -0
- package/src/web/trace.tsx +273 -0
- package/src/web/tree.tsx +281 -0
- package/src/web/virtual-context.ts +138 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getCurrentSystemMessage,
|
|
3
|
+
getCurrentSystemPrompt,
|
|
4
|
+
getCurrentTools,
|
|
5
|
+
getInitialSystemMessage,
|
|
6
|
+
} from "@earendil-works/pi-ai";
|
|
7
|
+
import {
|
|
8
|
+
buildSessionProjection,
|
|
9
|
+
type ExtensionContext,
|
|
10
|
+
parseSkillBlock,
|
|
11
|
+
type SessionEntry,
|
|
12
|
+
type ToolInfo,
|
|
13
|
+
} from "@earendil-works/pi-coding-agent";
|
|
14
|
+
|
|
15
|
+
type ReadonlySessionManager = ExtensionContext["sessionManager"];
|
|
16
|
+
|
|
17
|
+
import { ancestry } from "./ancestry.js";
|
|
18
|
+
import type { Collector } from "./collector.js";
|
|
19
|
+
import { sessionContext } from "./context.js";
|
|
20
|
+
import { correlatedCalls } from "./correlation.js";
|
|
21
|
+
import { EntryIndex } from "./entry-index.js";
|
|
22
|
+
import { identityIssue, recordedLeaf } from "./identity.js";
|
|
23
|
+
import type { BranchView, ContextComposition, DetailView, EntrySummary, SkillView, Snapshot } from "./model.js";
|
|
24
|
+
import { nativeProjection } from "./native-projection.js";
|
|
25
|
+
import { capture, displayText, readSessionId, readSessionName } from "./privacy.js";
|
|
26
|
+
import { systemReplayIssue } from "./system-message.js";
|
|
27
|
+
|
|
28
|
+
export function summarize(entry: SessionEntry, label?: string): EntrySummary {
|
|
29
|
+
const issue = identityIssue(entry);
|
|
30
|
+
if (issue) throw new Error(issue);
|
|
31
|
+
const message = entry.type === "message" ? entry.message : undefined;
|
|
32
|
+
const kind = displayText(message ? message.role : entry.type).slice(0, 512);
|
|
33
|
+
const value = message && "content" in message ? message.content : entry;
|
|
34
|
+
const preview = capture(value, 180).value;
|
|
35
|
+
const name =
|
|
36
|
+
entry.type === "custom" || entry.type === "custom_message"
|
|
37
|
+
? entry.customType
|
|
38
|
+
: entry.type === "model_change"
|
|
39
|
+
? entry.modelId
|
|
40
|
+
: entry.type === "thinking_level_change"
|
|
41
|
+
? entry.thinkingLevel
|
|
42
|
+
: message?.role === "assistant"
|
|
43
|
+
? message.model
|
|
44
|
+
: message?.role === "toolResult"
|
|
45
|
+
? message.toolName
|
|
46
|
+
: undefined;
|
|
47
|
+
const displayName = typeof name === "string" ? displayText(name) : undefined;
|
|
48
|
+
return {
|
|
49
|
+
id: entry.id,
|
|
50
|
+
parentId: entry.parentId,
|
|
51
|
+
kind,
|
|
52
|
+
timestamp: entry.timestamp,
|
|
53
|
+
name: displayName?.slice(0, 512),
|
|
54
|
+
nameTruncated: displayName !== undefined && displayName.length > 512 ? true : undefined,
|
|
55
|
+
tokens:
|
|
56
|
+
message?.role === "assistant" && Number.isFinite(message.usage?.totalTokens)
|
|
57
|
+
? message.usage?.totalTokens
|
|
58
|
+
: undefined,
|
|
59
|
+
status:
|
|
60
|
+
message?.role === "toolResult" && typeof message.isError === "boolean"
|
|
61
|
+
? message.isError
|
|
62
|
+
? "error"
|
|
63
|
+
: "success"
|
|
64
|
+
: message?.role === "assistant" && message.stopReason === "error"
|
|
65
|
+
? "error"
|
|
66
|
+
: message?.role === "assistant" && message.stopReason === "aborted"
|
|
67
|
+
? "cancelled"
|
|
68
|
+
: undefined,
|
|
69
|
+
toolCallId: message?.role === "toolResult" ? message.toolCallId : undefined,
|
|
70
|
+
label: displayText(
|
|
71
|
+
typeof label === "string" ? label : typeof preview === "string" ? preview : JSON.stringify(preview),
|
|
72
|
+
).slice(0, 180),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function snapshot(
|
|
77
|
+
manager: ReadonlySessionManager,
|
|
78
|
+
collector: Collector,
|
|
79
|
+
generation: string,
|
|
80
|
+
revision: number,
|
|
81
|
+
prompt: string,
|
|
82
|
+
tools: ToolInfo[],
|
|
83
|
+
active: string[],
|
|
84
|
+
skills: SkillView[],
|
|
85
|
+
index = new EntryIndex(manager.getEntries()),
|
|
86
|
+
observedContext?: ContextComposition,
|
|
87
|
+
): Snapshot {
|
|
88
|
+
const entries = index.entries;
|
|
89
|
+
const owning = new Map<string, { id: string; ids: Set<string> }>();
|
|
90
|
+
const invalidEntries: NonNullable<Snapshot["invalidEntries"]> = [];
|
|
91
|
+
let invalidEntryCount = 0;
|
|
92
|
+
const nodes: EntrySummary[] = [];
|
|
93
|
+
const leafId = recordedLeaf(manager, index.duplicates);
|
|
94
|
+
const reserved = new Set(leafId ? ancestry(manager, leafId, index).path.map((entry) => entry.id) : []);
|
|
95
|
+
let remaining = 10000 - reserved.size;
|
|
96
|
+
const included = entries.flatMap((entry, ordinal) =>
|
|
97
|
+
reserved.has(entry.id) || remaining-- > 0 ? [{ entry, ordinal }] : [],
|
|
98
|
+
);
|
|
99
|
+
for (const { ordinal, entry } of included) {
|
|
100
|
+
const reason = index.duplicates.has(entry.id) ? "duplicate entry id" : identityIssue(entry);
|
|
101
|
+
if (reason) {
|
|
102
|
+
invalidEntryCount++;
|
|
103
|
+
if (invalidEntries.length < 20) invalidEntries.push({ index: ordinal, reason, raw: capture(entry, 2048) });
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const source =
|
|
107
|
+
entry.type === "message" && entry.message.role === "assistant"
|
|
108
|
+
? {
|
|
109
|
+
id: entry.id,
|
|
110
|
+
ids: new Set(
|
|
111
|
+
(Array.isArray(entry.message.content) ? entry.message.content : [])
|
|
112
|
+
.filter((block) => block.type === "toolCall")
|
|
113
|
+
.map((block) => block.id),
|
|
114
|
+
),
|
|
115
|
+
}
|
|
116
|
+
: entry.parentId
|
|
117
|
+
? owning.get(entry.parentId)
|
|
118
|
+
: undefined;
|
|
119
|
+
if (source) owning.set(entry.id, source);
|
|
120
|
+
const label = manager.getLabel(entry.id);
|
|
121
|
+
if (label !== undefined && typeof label !== "string") {
|
|
122
|
+
invalidEntryCount++;
|
|
123
|
+
if (invalidEntries.length < 20)
|
|
124
|
+
invalidEntries.push({
|
|
125
|
+
index: ordinal,
|
|
126
|
+
reason: "non-string resolved label",
|
|
127
|
+
raw: capture({ entry, label }, 2048),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
const node = summarize(entry, label);
|
|
131
|
+
if (node.toolCallId && source?.ids.has(node.toolCallId)) node.toolAnchor = source.id;
|
|
132
|
+
nodes.push(node);
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
protocol: 1,
|
|
136
|
+
generation,
|
|
137
|
+
revision,
|
|
138
|
+
sessionId: readSessionId(manager),
|
|
139
|
+
...readSessionName(manager),
|
|
140
|
+
leafId,
|
|
141
|
+
totalEntries: entries.length,
|
|
142
|
+
nodes,
|
|
143
|
+
incomplete: entries.length > 10000 || tools.length > 256 || skills.length > 256 || invalidEntryCount > 0,
|
|
144
|
+
invalidEntryCount,
|
|
145
|
+
invalidEntries,
|
|
146
|
+
context: observedContext ?? sessionContext(manager, index),
|
|
147
|
+
currentPrompt: capture(prompt, 65536),
|
|
148
|
+
tools: tools.slice(0, 256).map((t) => ({
|
|
149
|
+
name: displayText(t.name),
|
|
150
|
+
description: displayText(t.description).slice(0, 512),
|
|
151
|
+
exposure: t.exposure,
|
|
152
|
+
active: active.includes(t.name),
|
|
153
|
+
callable:
|
|
154
|
+
t.exposure === "codemode" || t.exposure === "deferred" || (t.exposure === "direct" && active.includes(t.name)),
|
|
155
|
+
namespace: t.namespace ? displayText(t.namespace.name) : undefined,
|
|
156
|
+
schema: capture(t.parameters, 2048),
|
|
157
|
+
})),
|
|
158
|
+
skills: skills.slice(0, 256).map((s) => ({
|
|
159
|
+
name: displayText(s.name),
|
|
160
|
+
path: displayText(s.path),
|
|
161
|
+
description: displayText(s.description).slice(0, 512),
|
|
162
|
+
})),
|
|
163
|
+
calls: collector.list(),
|
|
164
|
+
droppedCalls: collector.dropped,
|
|
165
|
+
invalidCallEvents: collector.invalidEvents,
|
|
166
|
+
captureStartedAt: collector.startedAt,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function branch(
|
|
171
|
+
manager: ReadonlySessionManager,
|
|
172
|
+
leafId: string,
|
|
173
|
+
offset: number,
|
|
174
|
+
skills: SkillView[],
|
|
175
|
+
index = new EntryIndex(manager.getEntries()),
|
|
176
|
+
): BranchView {
|
|
177
|
+
if (!index.byId.has(leafId)) throw new Error("Unknown entry");
|
|
178
|
+
const walked = ancestry(manager, leafId, index);
|
|
179
|
+
const { path } = walked;
|
|
180
|
+
const projection = walked.issue ? undefined : buildSessionProjection(path, leafId);
|
|
181
|
+
const target = index.get(leafId);
|
|
182
|
+
const previousView = !walked.issue && target?.parentId ? nativeProjection(path, target.parentId) : undefined;
|
|
183
|
+
const previous = previousView?.projection?.messages ?? [];
|
|
184
|
+
const issue = walked.issue ?? (projection && systemReplayIssue(projection.messages)) ?? systemReplayIssue(previous);
|
|
185
|
+
if (issue || !projection) {
|
|
186
|
+
const unavailable = capture(`[unavailable: ${issue}]`);
|
|
187
|
+
return {
|
|
188
|
+
leafId,
|
|
189
|
+
ancestryIssue: issue,
|
|
190
|
+
entries: path
|
|
191
|
+
.slice(offset, offset + 50)
|
|
192
|
+
.filter((entry) => !identityIssue(entry))
|
|
193
|
+
.map((entry) => summarize(entry, manager.getLabel(entry.id))),
|
|
194
|
+
total: path.length,
|
|
195
|
+
offset,
|
|
196
|
+
prompt: unavailable,
|
|
197
|
+
previousPrompt: unavailable,
|
|
198
|
+
sections: unavailable,
|
|
199
|
+
declaredTools: unavailable,
|
|
200
|
+
promptUpdates: unavailable,
|
|
201
|
+
projection: unavailable,
|
|
202
|
+
skillEvidence: unavailable,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
const historicalSystem = getCurrentSystemMessage(projection.messages);
|
|
206
|
+
const evidence: { name: string; state: string; entryId: string }[] = [];
|
|
207
|
+
const callPaths = new Map<string, string>();
|
|
208
|
+
const byPath = new Map(skills.map((skill) => [skill.path, skill]));
|
|
209
|
+
let scanned = 0;
|
|
210
|
+
let evidenceIncomplete = false;
|
|
211
|
+
function spend(): boolean {
|
|
212
|
+
if (++scanned <= 2048 && evidence.length < 256) return true;
|
|
213
|
+
evidenceIncomplete = true;
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
evidenceLoop: for (const entry of path) {
|
|
217
|
+
if (identityIssue(entry)) continue;
|
|
218
|
+
if (entry.type !== "message") continue;
|
|
219
|
+
const m = entry.message;
|
|
220
|
+
if (m.role === "assistant") {
|
|
221
|
+
callPaths.clear();
|
|
222
|
+
const duplicateIds = new Set<string>();
|
|
223
|
+
const encountered = new Set<string>();
|
|
224
|
+
for (const block of Array.isArray(m.content) ? m.content : []) {
|
|
225
|
+
if (!spend()) break evidenceLoop;
|
|
226
|
+
if (block.type === "toolCall") {
|
|
227
|
+
if (encountered.has(block.id)) duplicateIds.add(block.id);
|
|
228
|
+
encountered.add(block.id);
|
|
229
|
+
}
|
|
230
|
+
if (
|
|
231
|
+
block.type === "toolCall" &&
|
|
232
|
+
block.name === "read" &&
|
|
233
|
+
typeof block.arguments?.path === "string" &&
|
|
234
|
+
block.arguments.path.length <= 8192
|
|
235
|
+
) {
|
|
236
|
+
callPaths.set(block.id, block.arguments.path);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
for (const id of duplicateIds) callPaths.delete(id);
|
|
240
|
+
}
|
|
241
|
+
if (m.role === "user" && typeof m.content === "string") {
|
|
242
|
+
if (!spend()) break;
|
|
243
|
+
if (m.content.length > 8192) {
|
|
244
|
+
evidenceIncomplete = true;
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
const invoked = parseSkillBlock(m.content);
|
|
248
|
+
if (invoked) evidence.push({ name: invoked.name, state: "explicitly invoked", entryId: entry.id });
|
|
249
|
+
}
|
|
250
|
+
if (m.role === "toolResult" && m.isError === false) {
|
|
251
|
+
if (!spend()) break;
|
|
252
|
+
const path = callPaths.get(m.toolCallId);
|
|
253
|
+
const skill = path ? byPath.get(path) : undefined;
|
|
254
|
+
if (skill) evidence.push({ name: skill.name, state: "successfully read", entryId: entry.id });
|
|
255
|
+
// Public bounded nested metadata provides names/arguments/status, never child results.
|
|
256
|
+
for (const call of Array.isArray(m.nestedCalls?.calls) ? m.nestedCalls.calls : []) {
|
|
257
|
+
if (!spend()) break evidenceLoop;
|
|
258
|
+
if (!call || typeof call !== "object" || call.name !== "read" || call.status !== "ok") continue;
|
|
259
|
+
const nestedPath = call.arguments?.path;
|
|
260
|
+
const skill = typeof nestedPath === "string" && nestedPath.length <= 8192 ? byPath.get(nestedPath) : undefined;
|
|
261
|
+
if (skill) {
|
|
262
|
+
evidence.push({ name: skill.name, state: "successfully read (nested metadata)", entryId: entry.id });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const capturedEvidence = capture(evidence);
|
|
268
|
+
capturedEvidence.truncated ||= evidenceIncomplete;
|
|
269
|
+
return {
|
|
270
|
+
leafId,
|
|
271
|
+
entries: path
|
|
272
|
+
.slice(offset, offset + 50)
|
|
273
|
+
.filter((entry) => !identityIssue(entry))
|
|
274
|
+
.map((e) => summarize(e, manager.getLabel(e.id))),
|
|
275
|
+
total: path.length,
|
|
276
|
+
offset,
|
|
277
|
+
prompt: capture(
|
|
278
|
+
historicalSystem ? getCurrentSystemPrompt(projection.messages) : "[unavailable: no stored system prompt]",
|
|
279
|
+
65536,
|
|
280
|
+
),
|
|
281
|
+
previousPrompt: capture(
|
|
282
|
+
previousView?.issue
|
|
283
|
+
? `[unavailable: ${previousView.issue}]`
|
|
284
|
+
: getCurrentSystemMessage(previous)
|
|
285
|
+
? getCurrentSystemPrompt(previous)
|
|
286
|
+
: "[unavailable: no stored system prompt]",
|
|
287
|
+
65536,
|
|
288
|
+
),
|
|
289
|
+
sections: capture(historicalSystem?.sections),
|
|
290
|
+
declaredTools: capture(
|
|
291
|
+
getInitialSystemMessage(projection.messages)
|
|
292
|
+
? getCurrentTools(projection.messages).map((t) => ({
|
|
293
|
+
name: t.name,
|
|
294
|
+
description: t.description,
|
|
295
|
+
parameters: t.parameters,
|
|
296
|
+
}))
|
|
297
|
+
: "[unavailable: no initial system/tool checkpoint]",
|
|
298
|
+
),
|
|
299
|
+
promptUpdates: capture(
|
|
300
|
+
path
|
|
301
|
+
.filter((e) => e.type === "message" && e.message.role === "system")
|
|
302
|
+
.map((e) => ({ id: e.id, message: e.type === "message" ? e.message : null })),
|
|
303
|
+
),
|
|
304
|
+
projection: capture(
|
|
305
|
+
projection.entries.map((e) => ({ id: e.sourceEntry.id, messages: e.messages })),
|
|
306
|
+
65536,
|
|
307
|
+
),
|
|
308
|
+
skillEvidence: capturedEvidence,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function detail(
|
|
313
|
+
manager: ReadonlySessionManager,
|
|
314
|
+
id: string,
|
|
315
|
+
leafId: string,
|
|
316
|
+
collector: Collector,
|
|
317
|
+
index = new EntryIndex(manager.getEntries()),
|
|
318
|
+
): DetailView {
|
|
319
|
+
if (index.duplicates.has(id) || index.duplicates.has(leafId)) throw new Error("Ambiguous duplicate entry id");
|
|
320
|
+
const entry = index.get(id);
|
|
321
|
+
if (!entry || !index.get(leafId)) throw new Error("Unknown entry");
|
|
322
|
+
const selected = ancestry(manager, id, index);
|
|
323
|
+
const leaf = id === leafId ? selected : ancestry(manager, leafId, index);
|
|
324
|
+
const issue = selected.issue ?? leaf.issue;
|
|
325
|
+
if (issue)
|
|
326
|
+
return {
|
|
327
|
+
ancestryIssue: issue,
|
|
328
|
+
raw: capture(entry, 65536),
|
|
329
|
+
projected: capture(`[unavailable: ${issue}]`),
|
|
330
|
+
calls: [],
|
|
331
|
+
};
|
|
332
|
+
const projected = buildSessionProjection(leaf.path, leafId).entries.find((e) => e.sourceEntry.id === id);
|
|
333
|
+
const ids = new Set<string>();
|
|
334
|
+
if (entry.type === "message") {
|
|
335
|
+
const m = entry.message;
|
|
336
|
+
if (m.role === "assistant" && !identityIssue(entry))
|
|
337
|
+
for (const b of Array.isArray(m.content) ? m.content : []) if (b.type === "toolCall") ids.add(b.id);
|
|
338
|
+
if (m.role === "toolResult") ids.add(m.toolCallId);
|
|
339
|
+
}
|
|
340
|
+
const toolAnchor =
|
|
341
|
+
entry.type === "message" && entry.message.role === "assistant"
|
|
342
|
+
? entry.id
|
|
343
|
+
: selected.path
|
|
344
|
+
.slice()
|
|
345
|
+
.reverse()
|
|
346
|
+
.find(
|
|
347
|
+
(candidate) =>
|
|
348
|
+
candidate.type === "message" &&
|
|
349
|
+
!identityIssue(candidate) &&
|
|
350
|
+
candidate.message.role === "assistant" &&
|
|
351
|
+
(Array.isArray(candidate.message.content) ? candidate.message.content : []).some(
|
|
352
|
+
(block) => block.type === "toolCall" && ids.has(block.id),
|
|
353
|
+
),
|
|
354
|
+
)?.id;
|
|
355
|
+
return {
|
|
356
|
+
toolAnchor,
|
|
357
|
+
raw: capture(entry, 65536),
|
|
358
|
+
projected: capture(projected?.messages),
|
|
359
|
+
calls: correlatedCalls(ids, toolAnchor, collector.list()),
|
|
360
|
+
};
|
|
361
|
+
}
|
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
|
+
}
|