@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
package/src/feed.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Collector } from "./collector.js";
|
|
3
|
+
import { captureContext } from "./context.js";
|
|
4
|
+
import { EntryIndex } from "./entry-index.js";
|
|
5
|
+
import { recordedLeaf } from "./identity.js";
|
|
6
|
+
import type { ContextComposition, SkillView, Snapshot } from "./model.js";
|
|
7
|
+
import { capture, displayText, readSessionName } from "./privacy.js";
|
|
8
|
+
import { snapshot } from "./projection.js";
|
|
9
|
+
|
|
10
|
+
export class SessionFeed {
|
|
11
|
+
private dirty = true;
|
|
12
|
+
private observed?: ContextComposition;
|
|
13
|
+
private providerObservation?: Snapshot["providerObservation"];
|
|
14
|
+
observeContext(messages: readonly unknown[]): void {
|
|
15
|
+
if (this.closed || this.options.signal.aborted) return;
|
|
16
|
+
this.observed = captureContext(
|
|
17
|
+
messages,
|
|
18
|
+
"observed-pi-context",
|
|
19
|
+
recordedLeaf(this.options.context().sessionManager, this.index().duplicates),
|
|
20
|
+
);
|
|
21
|
+
this.observed.observedAt = Date.now();
|
|
22
|
+
this.changed(false);
|
|
23
|
+
}
|
|
24
|
+
observePayload(payload: unknown): void {
|
|
25
|
+
if (this.closed || this.options.signal.aborted) return;
|
|
26
|
+
// No public request identity associates this callback with a turn; warming/retries use it too.
|
|
27
|
+
this.providerObservation = { observedAt: Date.now(), data: capture(payload, 65536) };
|
|
28
|
+
this.changed(false);
|
|
29
|
+
}
|
|
30
|
+
private indexed?: EntryIndex;
|
|
31
|
+
private closed = false;
|
|
32
|
+
index(): EntryIndex {
|
|
33
|
+
if (this.closed || this.options.signal.aborted) throw new Error("Inspector stopped");
|
|
34
|
+
if (!this.indexed) this.indexed = new EntryIndex(this.options.context().sessionManager.getEntries());
|
|
35
|
+
return this.indexed;
|
|
36
|
+
}
|
|
37
|
+
private cached?: Snapshot;
|
|
38
|
+
private revision = 0;
|
|
39
|
+
private stamp = "";
|
|
40
|
+
private notification?: ReturnType<typeof setTimeout>;
|
|
41
|
+
private poll?: ReturnType<typeof setInterval>;
|
|
42
|
+
constructor(
|
|
43
|
+
private readonly options: {
|
|
44
|
+
pi: ExtensionAPI;
|
|
45
|
+
context(): ExtensionContext;
|
|
46
|
+
collector: Collector;
|
|
47
|
+
skills: SkillView[];
|
|
48
|
+
generation: string;
|
|
49
|
+
signal: AbortSignal;
|
|
50
|
+
invalidate(revision: number): void;
|
|
51
|
+
},
|
|
52
|
+
) {}
|
|
53
|
+
private stateStamp(): string {
|
|
54
|
+
const ctx = this.options.context();
|
|
55
|
+
const manager = ctx.sessionManager;
|
|
56
|
+
const tools = this.options.pi.getAllTools();
|
|
57
|
+
// Public SDK method, omitted from the current ReadonlySessionManager type; readonly adapters may lack it.
|
|
58
|
+
const counted = manager as typeof manager & { getEntryCount?: () => number };
|
|
59
|
+
const count = typeof counted.getEntryCount === "function" ? counted.getEntryCount() : manager.getEntries().length;
|
|
60
|
+
return JSON.stringify({
|
|
61
|
+
count,
|
|
62
|
+
leaf: recordedLeaf(manager),
|
|
63
|
+
name: readSessionName(manager),
|
|
64
|
+
active: this.options.pi.getActiveTools(),
|
|
65
|
+
toolCount: tools.length,
|
|
66
|
+
tools: tools.slice(0, 256).map((tool) => ({
|
|
67
|
+
name: tool.name,
|
|
68
|
+
exposure: tool.exposure,
|
|
69
|
+
namespace: tool.namespace?.name,
|
|
70
|
+
description: displayText(tool.description).slice(0, 512),
|
|
71
|
+
schema: capture(tool.parameters, 2048),
|
|
72
|
+
})),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
start(): void {
|
|
76
|
+
if (this.options.signal.aborted || this.poll) return;
|
|
77
|
+
this.stamp = this.stateStamp();
|
|
78
|
+
this.poll = setInterval(() => {
|
|
79
|
+
if (this.options.signal.aborted) return;
|
|
80
|
+
try {
|
|
81
|
+
const next = this.stateStamp();
|
|
82
|
+
if (next !== this.stamp) {
|
|
83
|
+
this.stamp = next;
|
|
84
|
+
this.changed(true);
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
this.changed(true);
|
|
88
|
+
} // Context failures must not escape a timer callback.
|
|
89
|
+
}, 1000);
|
|
90
|
+
this.options.signal.addEventListener("abort", this.close, { once: true });
|
|
91
|
+
}
|
|
92
|
+
changed(structural: boolean): void {
|
|
93
|
+
if (this.options.signal.aborted) return;
|
|
94
|
+
if (structural) {
|
|
95
|
+
this.dirty = true;
|
|
96
|
+
this.indexed = undefined;
|
|
97
|
+
}
|
|
98
|
+
if (!this.notification)
|
|
99
|
+
this.notification = setTimeout(() => {
|
|
100
|
+
this.notification = undefined;
|
|
101
|
+
if (!this.options.signal.aborted) this.options.invalidate(++this.revision);
|
|
102
|
+
}, 250);
|
|
103
|
+
}
|
|
104
|
+
snapshot(): Snapshot {
|
|
105
|
+
if (this.closed || this.options.signal.aborted) throw new Error("Inspector stopped");
|
|
106
|
+
if (!this.cached || this.dirty) {
|
|
107
|
+
const ctx = this.options.context();
|
|
108
|
+
this.cached = snapshot(
|
|
109
|
+
ctx.sessionManager,
|
|
110
|
+
this.options.collector,
|
|
111
|
+
this.options.generation,
|
|
112
|
+
this.revision,
|
|
113
|
+
ctx.getSystemPrompt(),
|
|
114
|
+
this.options.pi.getAllTools(),
|
|
115
|
+
this.options.pi.getActiveTools(),
|
|
116
|
+
this.options.skills,
|
|
117
|
+
this.index(),
|
|
118
|
+
this.observed,
|
|
119
|
+
);
|
|
120
|
+
this.cached.calls = []; // Do not retain evicted live results inside the static cache.
|
|
121
|
+
this.dirty = false;
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
...this.cached,
|
|
125
|
+
revision: this.revision,
|
|
126
|
+
context: this.observed ?? this.cached.context,
|
|
127
|
+
providerObservation: this.providerObservation,
|
|
128
|
+
calls: this.options.collector.list(),
|
|
129
|
+
droppedCalls: this.options.collector.dropped,
|
|
130
|
+
invalidCallEvents: this.options.collector.invalidEvents,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
close = (): void => {
|
|
134
|
+
this.closed = true;
|
|
135
|
+
clearTimeout(this.notification);
|
|
136
|
+
clearInterval(this.poll);
|
|
137
|
+
this.notification = undefined;
|
|
138
|
+
this.poll = undefined;
|
|
139
|
+
this.options.signal.removeEventListener("abort", this.close);
|
|
140
|
+
this.cached = undefined;
|
|
141
|
+
this.indexed = undefined;
|
|
142
|
+
this.observed = undefined;
|
|
143
|
+
this.providerObservation = undefined;
|
|
144
|
+
};
|
|
145
|
+
}
|
package/src/identity.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { systemMessageIssue } from "./system-message.js";
|
|
3
|
+
|
|
4
|
+
export const MESSAGE_BLOCKS = 2048;
|
|
5
|
+
|
|
6
|
+
export function structuralIssue(entry: SessionEntry | undefined): string | undefined {
|
|
7
|
+
if (typeof entry?.id !== "string" || !entry.id.length) return "missing, empty or non-string entry id";
|
|
8
|
+
if (entry.id.length > 512) return "over-budget entry id";
|
|
9
|
+
if (typeof entry.parentId === "string" && entry.parentId.length > 512) return "over-budget parent id";
|
|
10
|
+
if (entry.type === "label" && entry.label !== undefined && typeof entry.label !== "string")
|
|
11
|
+
return "non-string stored label";
|
|
12
|
+
if (entry.parentId !== null && (typeof entry.parentId !== "string" || !entry.parentId.length))
|
|
13
|
+
return "missing, empty or non-string parent id";
|
|
14
|
+
if (entry.type === "session_info" && entry.name !== undefined && typeof entry.name !== "string")
|
|
15
|
+
return "non-string stored session name";
|
|
16
|
+
if (typeof entry.type !== "string" || !entry.type.length) return "missing, empty or non-string entry type";
|
|
17
|
+
if (entry.type === "message" && (typeof entry.message?.role !== "string" || !entry.message.role.length))
|
|
18
|
+
return "missing, empty or non-string message role";
|
|
19
|
+
if (typeof entry.timestamp !== "string") return "non-string entry timestamp";
|
|
20
|
+
if (entry.timestamp.length > 512) return "over-budget entry timestamp";
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function identityIssue(entry: SessionEntry | undefined): string | undefined {
|
|
25
|
+
const structure = structuralIssue(entry);
|
|
26
|
+
if (structure || !entry) return structure;
|
|
27
|
+
if (
|
|
28
|
+
entry.type === "message" &&
|
|
29
|
+
"content" in entry.message &&
|
|
30
|
+
Array.isArray(entry.message.content) &&
|
|
31
|
+
entry.message.content.length > MESSAGE_BLOCKS
|
|
32
|
+
)
|
|
33
|
+
return "message content exceeds 2,048-block budget";
|
|
34
|
+
if (entry.type === "message" && entry.message.role === "system") {
|
|
35
|
+
// Native sessionEntryToContextMessages restores legacy missing/null content.
|
|
36
|
+
const m = entry.message;
|
|
37
|
+
const issue = systemMessageIssue(m.content == null ? { ...m, content: "" } : m);
|
|
38
|
+
if (issue) return issue;
|
|
39
|
+
}
|
|
40
|
+
if (entry.type === "compaction" && entry.systemMessage) {
|
|
41
|
+
const issue = systemMessageIssue(entry.systemMessage);
|
|
42
|
+
if (issue) return `checkpoint: ${issue}`;
|
|
43
|
+
}
|
|
44
|
+
if (
|
|
45
|
+
entry.type === "message" &&
|
|
46
|
+
entry.message.role === "assistant" &&
|
|
47
|
+
entry.message.content != null &&
|
|
48
|
+
(!Array.isArray(entry.message.content) ||
|
|
49
|
+
Array.from(entry.message.content).some((block) => !block || typeof block !== "object" || Array.isArray(block)))
|
|
50
|
+
)
|
|
51
|
+
return "malformed assistant content block or envelope";
|
|
52
|
+
if (
|
|
53
|
+
entry.type === "message" &&
|
|
54
|
+
entry.message.role === "assistant" &&
|
|
55
|
+
Array.isArray(entry.message.content) &&
|
|
56
|
+
entry.message.content.some(
|
|
57
|
+
(block) =>
|
|
58
|
+
block.type === "toolCall" &&
|
|
59
|
+
(!block.arguments ||
|
|
60
|
+
typeof block.arguments !== "object" ||
|
|
61
|
+
Array.isArray(block.arguments) ||
|
|
62
|
+
typeof block.id !== "string" ||
|
|
63
|
+
!block.id.length ||
|
|
64
|
+
block.id.length > 512 ||
|
|
65
|
+
typeof block.name !== "string" ||
|
|
66
|
+
!block.name.length ||
|
|
67
|
+
block.name.length > 512),
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
return "malformed tool-call envelope";
|
|
71
|
+
if (entry.type === "context_edit") {
|
|
72
|
+
if (typeof entry.targetId !== "string" || !entry.targetId.length || entry.targetId.length > 512)
|
|
73
|
+
return "malformed context-edit target id";
|
|
74
|
+
const value = entry.replacement;
|
|
75
|
+
if (Array.isArray(value?.content) && value.content.length > MESSAGE_BLOCKS)
|
|
76
|
+
return "context-edit content exceeds 2,048-block budget";
|
|
77
|
+
if (
|
|
78
|
+
value !== null &&
|
|
79
|
+
(!value ||
|
|
80
|
+
typeof value !== "object" ||
|
|
81
|
+
Array.isArray(value) ||
|
|
82
|
+
!("content" in value) ||
|
|
83
|
+
(typeof value.content !== "string" && !Array.isArray(value.content)) ||
|
|
84
|
+
(Array.isArray(value.content) &&
|
|
85
|
+
value.content.some((block) => !block || typeof block !== "object" || Array.isArray(block))))
|
|
86
|
+
)
|
|
87
|
+
return "malformed context-edit replacement";
|
|
88
|
+
}
|
|
89
|
+
if (
|
|
90
|
+
entry.type === "message" &&
|
|
91
|
+
entry.message.role === "toolResult" &&
|
|
92
|
+
(typeof entry.message.toolCallId !== "string" ||
|
|
93
|
+
!entry.message.toolCallId.length ||
|
|
94
|
+
entry.message.toolCallId.length > 512)
|
|
95
|
+
)
|
|
96
|
+
return "malformed or over-budget tool-result id";
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function recordedLeaf(
|
|
101
|
+
manager: ExtensionContext["sessionManager"],
|
|
102
|
+
duplicates?: ReadonlySet<string>,
|
|
103
|
+
): string | null {
|
|
104
|
+
const leaf = manager.getLeafId();
|
|
105
|
+
return typeof leaf === "string" &&
|
|
106
|
+
leaf.length > 0 &&
|
|
107
|
+
leaf.length <= 512 &&
|
|
108
|
+
!duplicates?.has(leaf) &&
|
|
109
|
+
!identityIssue(manager.getEntry(leaf))
|
|
110
|
+
? leaf
|
|
111
|
+
: null;
|
|
112
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { registerInspector as default } from "./extension.js";
|
package/src/model.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
export type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
|
|
2
|
+
export interface Capture {
|
|
3
|
+
value: Json;
|
|
4
|
+
truncated: boolean;
|
|
5
|
+
}
|
|
6
|
+
export interface EntrySummary {
|
|
7
|
+
id: string;
|
|
8
|
+
parentId: string | null;
|
|
9
|
+
kind: string;
|
|
10
|
+
label: string;
|
|
11
|
+
timestamp: string;
|
|
12
|
+
name?: string;
|
|
13
|
+
nameTruncated?: boolean;
|
|
14
|
+
tokens?: number;
|
|
15
|
+
status?: "success" | "error" | "cancelled";
|
|
16
|
+
toolCallId?: string;
|
|
17
|
+
toolAnchor?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface Call {
|
|
20
|
+
id: string;
|
|
21
|
+
occurrenceId: string;
|
|
22
|
+
parentOccurrenceId?: string;
|
|
23
|
+
parentUnavailable?: string;
|
|
24
|
+
correlationUnavailable?: boolean;
|
|
25
|
+
parentId?: string;
|
|
26
|
+
name: string;
|
|
27
|
+
status: "running" | "ok" | "error" | "unfinished";
|
|
28
|
+
args: Capture;
|
|
29
|
+
result?: Capture;
|
|
30
|
+
durationMs?: number;
|
|
31
|
+
observedStartedAt?: number;
|
|
32
|
+
observedEndedAt?: number;
|
|
33
|
+
branchAnchor: string | null;
|
|
34
|
+
}
|
|
35
|
+
export interface ToolView {
|
|
36
|
+
name: string;
|
|
37
|
+
description: string;
|
|
38
|
+
exposure: string;
|
|
39
|
+
active: boolean;
|
|
40
|
+
callable: boolean;
|
|
41
|
+
namespace?: string;
|
|
42
|
+
schema: Capture;
|
|
43
|
+
}
|
|
44
|
+
export interface SkillView {
|
|
45
|
+
name: string;
|
|
46
|
+
path: string;
|
|
47
|
+
description: string;
|
|
48
|
+
}
|
|
49
|
+
export interface ContextSegment {
|
|
50
|
+
id: string;
|
|
51
|
+
position: number;
|
|
52
|
+
messageIndex: number;
|
|
53
|
+
blockIndex?: number;
|
|
54
|
+
role: string;
|
|
55
|
+
kind: string;
|
|
56
|
+
category: "system" | "user" | "assistant" | "toolCall" | "toolResult" | "other";
|
|
57
|
+
preview: string;
|
|
58
|
+
timestamp?: string;
|
|
59
|
+
}
|
|
60
|
+
export interface ContextComposition {
|
|
61
|
+
source: "observed-pi-context" | "session-derived";
|
|
62
|
+
leafId: string | null;
|
|
63
|
+
observedAt?: number;
|
|
64
|
+
totalMessages: number;
|
|
65
|
+
messages: Capture[];
|
|
66
|
+
segments: ContextSegment[];
|
|
67
|
+
incomplete: boolean;
|
|
68
|
+
unavailable?: string;
|
|
69
|
+
}
|
|
70
|
+
export interface Snapshot {
|
|
71
|
+
protocol: 1;
|
|
72
|
+
generation: string;
|
|
73
|
+
revision: number;
|
|
74
|
+
sessionId: string;
|
|
75
|
+
name: string;
|
|
76
|
+
nameTruncated?: boolean;
|
|
77
|
+
leafId: string | null;
|
|
78
|
+
totalEntries: number;
|
|
79
|
+
nodes: EntrySummary[];
|
|
80
|
+
incomplete: boolean;
|
|
81
|
+
invalidEntryCount?: number;
|
|
82
|
+
invalidEntries?: { index: number; reason: string; raw: Capture }[];
|
|
83
|
+
context?: ContextComposition;
|
|
84
|
+
providerObservation?: { observedAt: number; data: Capture };
|
|
85
|
+
currentPrompt: Capture;
|
|
86
|
+
tools: ToolView[];
|
|
87
|
+
skills: SkillView[];
|
|
88
|
+
calls: Call[];
|
|
89
|
+
droppedCalls: number;
|
|
90
|
+
invalidCallEvents?: number;
|
|
91
|
+
captureStartedAt: number;
|
|
92
|
+
}
|
|
93
|
+
export interface BranchView {
|
|
94
|
+
ancestryIssue?: string;
|
|
95
|
+
leafId: string;
|
|
96
|
+
entries: EntrySummary[];
|
|
97
|
+
total: number;
|
|
98
|
+
offset: number;
|
|
99
|
+
prompt: Capture;
|
|
100
|
+
previousPrompt: Capture;
|
|
101
|
+
sections: Capture;
|
|
102
|
+
declaredTools: Capture;
|
|
103
|
+
promptUpdates: Capture;
|
|
104
|
+
projection: Capture;
|
|
105
|
+
skillEvidence: Capture;
|
|
106
|
+
}
|
|
107
|
+
export interface DetailView {
|
|
108
|
+
ancestryIssue?: string;
|
|
109
|
+
toolAnchor?: string;
|
|
110
|
+
raw: Capture;
|
|
111
|
+
projected: Capture;
|
|
112
|
+
calls: Call[];
|
|
113
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { buildContextEntries, buildSessionProjection, type SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { identityIssue } from "./identity.js";
|
|
3
|
+
|
|
4
|
+
// Parent/role structure has already been validated. Native selection drops old
|
|
5
|
+
// summaries/checkpoints and applies edits before content-envelope validation.
|
|
6
|
+
export function nativeProjection(path: SessionEntry[], leaf: string | null) {
|
|
7
|
+
const contextEntries = buildContextEntries(path, leaf);
|
|
8
|
+
if (contextEntries[0]?.type === "compaction") {
|
|
9
|
+
const issue = identityIssue(contextEntries[0]);
|
|
10
|
+
if (issue) return { issue };
|
|
11
|
+
}
|
|
12
|
+
const edits = new Map(
|
|
13
|
+
contextEntries.filter((entry) => entry.type === "context_edit").map((entry) => [entry.targetId, entry]),
|
|
14
|
+
);
|
|
15
|
+
for (const entry of edits.values()) {
|
|
16
|
+
if (entry.type === "context_edit") {
|
|
17
|
+
const issue = identityIssue(entry);
|
|
18
|
+
if (issue) return { issue };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const projection = buildSessionProjection(path, leaf);
|
|
22
|
+
for (const projected of projection.entries)
|
|
23
|
+
for (const message of projected.messages) {
|
|
24
|
+
const issue = identityIssue({ ...projected.sourceEntry, type: "message", message } as SessionEntry);
|
|
25
|
+
if (issue) return { issue };
|
|
26
|
+
}
|
|
27
|
+
return { projection };
|
|
28
|
+
}
|
package/src/privacy.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from "node:util";
|
|
2
|
+
import type { Capture, Json } from "./model.js";
|
|
3
|
+
|
|
4
|
+
const secretKey =
|
|
5
|
+
/^(authorization|proxy.authorization|cookie|set.cookie|password|passwd|secret|client[_-]?secret|secret[_-]?key|private[_-]?key|token|api[_-]?key|access[_-]?token|refresh[_-]?token|session[_-]?token|credentials|env|headers)$|(?:^|[_-])(?:api[_-]?key|(?:access|refresh|session)[_-]?token|token|client[_-]?secret|secret[_-]?key|private[_-]?key|password|passwd|secret)$/i;
|
|
6
|
+
|
|
7
|
+
// Display-only: original session entries and event objects are never modified.
|
|
8
|
+
export function displayText(text: string): string {
|
|
9
|
+
return (
|
|
10
|
+
stripVTControlCharacters(text)
|
|
11
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: Display sanitation intentionally removes C0/C1 controls, preserving tab and newline.
|
|
12
|
+
.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, "")
|
|
13
|
+
.replace(/\bBearer\s+[A-Za-z0-9._~+/-]+/gi, "Bearer [redacted]")
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function sessionName(raw: unknown): { name: string; nameTruncated?: true } {
|
|
18
|
+
const clean = typeof raw === "string" ? displayText(raw) : "[unavailable: non-string session name]";
|
|
19
|
+
return { name: clean.slice(0, 512), nameTruncated: clean.length > 512 ? true : undefined };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function readSessionName(manager: { getSessionName(): string | undefined }): {
|
|
23
|
+
name: string;
|
|
24
|
+
nameTruncated?: true;
|
|
25
|
+
} {
|
|
26
|
+
try {
|
|
27
|
+
return sessionName(manager.getSessionName() ?? "Current session");
|
|
28
|
+
} catch {
|
|
29
|
+
return { name: "[unavailable: invalid stored session name]" };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function readSessionId(manager: { getSessionId(): string }): string {
|
|
34
|
+
try {
|
|
35
|
+
const id = manager.getSessionId();
|
|
36
|
+
if (typeof id !== "string" || !id.length || id.length > 512)
|
|
37
|
+
return "[unavailable: invalid or over-budget session ID]";
|
|
38
|
+
return displayText(id) || "[unavailable: blank session ID]";
|
|
39
|
+
} catch {
|
|
40
|
+
return "[unavailable: invalid session ID]";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Bound traversal before serialization: huge strings, arrays, cycles and deep objects are safe.
|
|
45
|
+
export function capture(input: unknown, maxChars = 32768): Capture {
|
|
46
|
+
let budget = maxChars;
|
|
47
|
+
let nodes = 0;
|
|
48
|
+
let truncated = false;
|
|
49
|
+
const seen = new WeakSet<object>();
|
|
50
|
+
function text(value: string): string {
|
|
51
|
+
const clean = displayText(value);
|
|
52
|
+
const length = Math.min(clean.length, Math.max(0, budget));
|
|
53
|
+
budget -= length;
|
|
54
|
+
if (length < clean.length) truncated = true;
|
|
55
|
+
return clean.slice(0, length);
|
|
56
|
+
}
|
|
57
|
+
function visit(value: unknown, depth: number): Json {
|
|
58
|
+
if (++nodes > 2048 || depth > 12 || budget <= 0) {
|
|
59
|
+
truncated = true;
|
|
60
|
+
return "[truncated]";
|
|
61
|
+
}
|
|
62
|
+
budget -= 8;
|
|
63
|
+
if (value === null || value === undefined) return null;
|
|
64
|
+
if (typeof value === "string") return text(value);
|
|
65
|
+
if (typeof value === "boolean") return value;
|
|
66
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : String(value);
|
|
67
|
+
if (typeof value !== "object") {
|
|
68
|
+
truncated = true;
|
|
69
|
+
return `[unsupported ${typeof value}]`;
|
|
70
|
+
}
|
|
71
|
+
if (seen.has(value)) {
|
|
72
|
+
truncated = true;
|
|
73
|
+
return "[circular]";
|
|
74
|
+
}
|
|
75
|
+
seen.add(value);
|
|
76
|
+
if (Array.isArray(value)) {
|
|
77
|
+
const result: Json[] = [];
|
|
78
|
+
for (let index = 0; index < value.length; index++) {
|
|
79
|
+
if (budget <= 0 || nodes >= 2048) {
|
|
80
|
+
truncated = true;
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
|
84
|
+
if (descriptor?.get || descriptor?.set) {
|
|
85
|
+
truncated = true;
|
|
86
|
+
result.push("[accessor omitted]");
|
|
87
|
+
} else result.push(visit(descriptor?.value, depth + 1));
|
|
88
|
+
}
|
|
89
|
+
seen.delete(value);
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
const record = value as Record<string, unknown>;
|
|
93
|
+
const type = Object.getOwnPropertyDescriptor(record, "type")?.value;
|
|
94
|
+
const mimeType = Object.getOwnPropertyDescriptor(record, "mimeType")?.value;
|
|
95
|
+
const data = Object.getOwnPropertyDescriptor(record, "data")?.value;
|
|
96
|
+
const raster =
|
|
97
|
+
type === "image" &&
|
|
98
|
+
typeof mimeType === "string" &&
|
|
99
|
+
/^(image\/(png|jpeg|gif|webp))$/.test(mimeType) &&
|
|
100
|
+
typeof data === "string" &&
|
|
101
|
+
data.length <= Math.min(16384, Math.max(0, budget / 2)) &&
|
|
102
|
+
/^[A-Za-z0-9+/]*={0,2}$/.test(data);
|
|
103
|
+
const result: Record<string, Json> = Object.create(null);
|
|
104
|
+
for (const key in record) {
|
|
105
|
+
if (!Object.hasOwn(record, key)) continue;
|
|
106
|
+
if (budget <= 0 || nodes >= 2048) {
|
|
107
|
+
truncated = true;
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
// Image bytes and opaque provider replay signatures are not useful as raw JSON.
|
|
111
|
+
const cleanKey = displayText(key);
|
|
112
|
+
const name = text(cleanKey.slice(0, 256));
|
|
113
|
+
if (cleanKey.length > 256) truncated = true;
|
|
114
|
+
const descriptor = Object.getOwnPropertyDescriptor(record, key);
|
|
115
|
+
if (descriptor?.get || descriptor?.set) {
|
|
116
|
+
truncated = true;
|
|
117
|
+
result[name] = "[accessor omitted]";
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const classifiedKey = cleanKey.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/([A-Z])([A-Z][a-z])/g, "$1_$2");
|
|
121
|
+
result[name] =
|
|
122
|
+
secretKey.test(classifiedKey) || /(?:^|[_-])secret[_-]?access[_-]?key$/i.test(classifiedKey)
|
|
123
|
+
? "[redacted]"
|
|
124
|
+
: key === "data" && type === "image"
|
|
125
|
+
? raster
|
|
126
|
+
? text(data as string)
|
|
127
|
+
: "[opaque or oversized data omitted]"
|
|
128
|
+
: /^(thinkingSignature|thoughtSignature|textSignature)$/.test(key)
|
|
129
|
+
? "[opaque data omitted]"
|
|
130
|
+
: visit(descriptor?.value, depth + 1);
|
|
131
|
+
}
|
|
132
|
+
seen.delete(value);
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
135
|
+
return { value: visit(input, 0), truncated };
|
|
136
|
+
}
|