@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/package.json
CHANGED
|
@@ -1,6 +1,69 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@narumitw/pi-inspect",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Read-only browser inspector for Pi session context and live tool calls.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"private": false,
|
|
8
|
+
"keywords": [
|
|
9
|
+
"pi-package",
|
|
10
|
+
"pi-extension",
|
|
11
|
+
"pi",
|
|
12
|
+
"session",
|
|
13
|
+
"inspector",
|
|
14
|
+
"context",
|
|
15
|
+
"observability"
|
|
16
|
+
],
|
|
17
|
+
"files": [
|
|
18
|
+
"src",
|
|
19
|
+
"dist",
|
|
20
|
+
"docs",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"pi": {
|
|
25
|
+
"extensions": [
|
|
26
|
+
"./src/index.ts"
|
|
27
|
+
]
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "node scripts/build.mjs",
|
|
31
|
+
"check": "npm run build && biome check . && npm run typecheck",
|
|
32
|
+
"format": "biome check --write .",
|
|
33
|
+
"prepack": "npm run build",
|
|
34
|
+
"typecheck": "tsc --noEmit",
|
|
35
|
+
"test": "vitest run --config vitest.config.ts",
|
|
36
|
+
"test:browser": "playwright test --config playwright.config.ts",
|
|
37
|
+
"smoke": "node scripts/smoke.mjs",
|
|
38
|
+
"smoke:git-install": "node scripts/git-install-smoke.mjs"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"@earendil-works/pi-ai": "*",
|
|
42
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@radix-ui/colors": "3.0.0",
|
|
46
|
+
"@radix-ui/react-collapsible": "1.1.20",
|
|
47
|
+
"@radix-ui/react-icons": "1.3.2",
|
|
48
|
+
"@radix-ui/themes": "3.3.0",
|
|
49
|
+
"esbuild": "0.28.2",
|
|
50
|
+
"react": "19.3.0",
|
|
51
|
+
"react-dom": "19.3.0"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@earendil-works/pi-ai": "1.1.0",
|
|
55
|
+
"@earendil-works/pi-coding-agent": "1.1.0",
|
|
56
|
+
"@playwright/test": "1.63.0",
|
|
57
|
+
"@types/node": "26.6.4",
|
|
58
|
+
"@types/react": "19.3.0",
|
|
59
|
+
"@types/react-dom": "19.3.0",
|
|
60
|
+
"typebox": "1.3.36",
|
|
61
|
+
"typescript": "7.0.2",
|
|
62
|
+
"vitest": "5.0.3"
|
|
63
|
+
},
|
|
64
|
+
"repository": {
|
|
65
|
+
"type": "git",
|
|
66
|
+
"url": "https://github.com/narumiruna/pi-extensions",
|
|
67
|
+
"directory": "packages/pi-inspect"
|
|
68
|
+
}
|
|
69
|
+
}
|
package/src/ancestry.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { EntryIndex } from "./entry-index.js";
|
|
4
|
+
import { structuralIssue } from "./identity.js";
|
|
5
|
+
import { nativeProjection } from "./native-projection.js";
|
|
6
|
+
|
|
7
|
+
// Pi's native projection walks parent links without cycle detection; validate before invoking it.
|
|
8
|
+
export function ancestry(
|
|
9
|
+
manager: ExtensionContext["sessionManager"],
|
|
10
|
+
id: string,
|
|
11
|
+
index = new EntryIndex(manager.getEntries()),
|
|
12
|
+
): { path: SessionEntry[]; issue?: string } {
|
|
13
|
+
const path: SessionEntry[] = [];
|
|
14
|
+
const seen = new Set<string>();
|
|
15
|
+
if (index.duplicates.has(id)) return { path: [], issue: "duplicate entry id" };
|
|
16
|
+
let current = index.get(id);
|
|
17
|
+
while (current) {
|
|
18
|
+
const issue = structuralIssue(current);
|
|
19
|
+
if (issue) return { path: path.reverse(), issue };
|
|
20
|
+
if (seen.has(current.id)) return { path: path.reverse(), issue: "recorded parent cycle" };
|
|
21
|
+
if (path.length >= 10000)
|
|
22
|
+
return { path: path.reverse(), issue: "ancestry exceeds the 10,000-entry inspection budget" };
|
|
23
|
+
seen.add(current.id);
|
|
24
|
+
path.push(current);
|
|
25
|
+
const parent = current.parentId;
|
|
26
|
+
if (parent && index.duplicates.has(parent)) return { path: path.reverse(), issue: "duplicate ancestor id" };
|
|
27
|
+
current = parent ? index.get(parent) : undefined;
|
|
28
|
+
if (parent && !current) return { path: path.reverse(), issue: "missing recorded parent" };
|
|
29
|
+
}
|
|
30
|
+
path.reverse();
|
|
31
|
+
return { path, issue: nativeProjection(path, id).issue };
|
|
32
|
+
}
|
package/src/collector.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ToolExecutionEndEvent,
|
|
3
|
+
ToolExecutionStartEvent,
|
|
4
|
+
ToolExecutionUpdateEvent,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import type { Call } from "./model.js";
|
|
7
|
+
import { capture, displayText } from "./privacy.js";
|
|
8
|
+
|
|
9
|
+
function validIdentity(value: unknown): value is string {
|
|
10
|
+
return typeof value === "string" && value.length > 0 && value.length <= 512;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class Collector {
|
|
14
|
+
readonly startedAt = Date.now();
|
|
15
|
+
private calls = new Map<string, Call>();
|
|
16
|
+
private partialAt = new Map<string, number>();
|
|
17
|
+
private sequence = 0;
|
|
18
|
+
dropped = 0;
|
|
19
|
+
invalidEvents = 0;
|
|
20
|
+
private admit(event: { toolCallId: string; toolName: string }): boolean {
|
|
21
|
+
if (validIdentity(event.toolCallId) && validIdentity(event.toolName)) return true;
|
|
22
|
+
this.invalidEvents++;
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
private fenceParent(call: Call, parent: unknown): boolean {
|
|
26
|
+
if (parent !== undefined && !validIdentity(parent)) {
|
|
27
|
+
const changed =
|
|
28
|
+
call.parentUnavailable !== "Invalid or over-budget parent ID; relationship unavailable" ||
|
|
29
|
+
call.parentOccurrenceId !== undefined;
|
|
30
|
+
call.parentId = undefined;
|
|
31
|
+
call.parentOccurrenceId = undefined;
|
|
32
|
+
call.parentUnavailable = "Invalid or over-budget parent ID; relationship unavailable";
|
|
33
|
+
return changed;
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
constructor(private readonly maxCalls = 128) {}
|
|
38
|
+
private create(
|
|
39
|
+
event: { toolCallId: string; parentToolCallId?: string; toolName: string },
|
|
40
|
+
args: unknown,
|
|
41
|
+
anchor: string | null,
|
|
42
|
+
): Call {
|
|
43
|
+
const parentId = validIdentity(event.parentToolCallId) ? event.parentToolCallId : undefined;
|
|
44
|
+
const parent = parentId ? this.active(parentId) : undefined;
|
|
45
|
+
const ambiguousParent =
|
|
46
|
+
event.parentToolCallId !== undefined &&
|
|
47
|
+
this.list().filter((item) => item.id === event.parentToolCallId && item.status === "running").length > 1;
|
|
48
|
+
const call: Call = {
|
|
49
|
+
id: event.toolCallId,
|
|
50
|
+
occurrenceId: `call-${++this.sequence}`,
|
|
51
|
+
parentId,
|
|
52
|
+
parentUnavailable: ambiguousParent ? "Overlapping running parent IDs; relationship unavailable" : undefined,
|
|
53
|
+
parentOccurrenceId: parent?.status === "running" ? parent.occurrenceId : undefined,
|
|
54
|
+
name: displayText(event.toolName),
|
|
55
|
+
status: "running",
|
|
56
|
+
args: capture(args, 8192),
|
|
57
|
+
branchAnchor: parent?.status === "running" ? parent.branchAnchor : validIdentity(anchor) ? anchor : null,
|
|
58
|
+
correlationUnavailable: anchor !== null && !validIdentity(anchor) ? true : undefined,
|
|
59
|
+
};
|
|
60
|
+
this.fenceParent(call, event.parentToolCallId);
|
|
61
|
+
this.calls.set(call.occurrenceId, call);
|
|
62
|
+
while (this.calls.size > this.maxCalls) {
|
|
63
|
+
const first = this.calls.keys().next().value;
|
|
64
|
+
if (first === undefined) break;
|
|
65
|
+
this.calls.delete(first);
|
|
66
|
+
this.partialAt.delete(first);
|
|
67
|
+
this.dropped++;
|
|
68
|
+
}
|
|
69
|
+
return call;
|
|
70
|
+
}
|
|
71
|
+
private active(rawId: string): Call | undefined {
|
|
72
|
+
const running = this.list().filter((call) => call.id === rawId && call.status === "running");
|
|
73
|
+
if (running.length > 1) {
|
|
74
|
+
for (const call of running) call.correlationUnavailable = true;
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
return running[0];
|
|
78
|
+
}
|
|
79
|
+
start(event: ToolExecutionStartEvent, anchor: string | null): void {
|
|
80
|
+
if (!this.admit(event)) return;
|
|
81
|
+
const now = Date.now();
|
|
82
|
+
const call = this.create(event, event.args, anchor);
|
|
83
|
+
call.observedStartedAt = now;
|
|
84
|
+
if (
|
|
85
|
+
this.list().filter((parent) => parent.id === call.id && parent.branchAnchor === call.branchAnchor).length === 1
|
|
86
|
+
) {
|
|
87
|
+
for (const child of this.calls.values())
|
|
88
|
+
if (
|
|
89
|
+
child.parentId === call.id &&
|
|
90
|
+
!child.parentOccurrenceId &&
|
|
91
|
+
!child.parentUnavailable &&
|
|
92
|
+
child.status === "running" &&
|
|
93
|
+
child.branchAnchor === call.branchAnchor
|
|
94
|
+
)
|
|
95
|
+
child.parentOccurrenceId = call.occurrenceId;
|
|
96
|
+
}
|
|
97
|
+
const occurrences = this.list().filter((item) => item.id === call.id && item.branchAnchor === call.branchAnchor);
|
|
98
|
+
if (occurrences.length > 1) {
|
|
99
|
+
for (const occurrence of occurrences) occurrence.correlationUnavailable = true;
|
|
100
|
+
for (let i = 0; i < this.calls.size; i++)
|
|
101
|
+
for (const child of this.calls.values())
|
|
102
|
+
if (child.parentOccurrenceId && this.calls.get(child.parentOccurrenceId)?.correlationUnavailable)
|
|
103
|
+
child.correlationUnavailable = true;
|
|
104
|
+
}
|
|
105
|
+
this.active(event.toolCallId); // Explicitly mark overlapping reused IDs as ambiguous, never misroute their results.
|
|
106
|
+
}
|
|
107
|
+
update(event: ToolExecutionUpdateEvent, anchor: string | null): boolean {
|
|
108
|
+
if (!this.admit(event)) return true; // Publish the new unavailable-event counter.
|
|
109
|
+
const now = Date.now();
|
|
110
|
+
let call = this.active(event.toolCallId);
|
|
111
|
+
if (!call && this.list().some((call) => call.id === event.toolCallId && call.status === "running")) return false;
|
|
112
|
+
call ??= this.create(event, event.args, anchor);
|
|
113
|
+
const parentChanged = this.fenceParent(call, event.parentToolCallId);
|
|
114
|
+
if (now - (this.partialAt.get(call.occurrenceId) ?? -Infinity) < 250) return parentChanged;
|
|
115
|
+
this.partialAt.set(call.occurrenceId, now);
|
|
116
|
+
call.result = capture(event.partialResult);
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
end(event: ToolExecutionEndEvent, anchor: string | null): void {
|
|
120
|
+
if (!this.admit(event)) return;
|
|
121
|
+
const now = Date.now();
|
|
122
|
+
const running = this.list().filter((call) => call.id === event.toolCallId && call.status === "running");
|
|
123
|
+
const call = this.active(event.toolCallId) ?? this.create(event, "[not captured]", anchor);
|
|
124
|
+
if (running.length > 1) {
|
|
125
|
+
call.correlationUnavailable = true;
|
|
126
|
+
call.parentOccurrenceId = undefined;
|
|
127
|
+
}
|
|
128
|
+
this.fenceParent(call, event.parentToolCallId);
|
|
129
|
+
call.status = event.isError ? "error" : "ok";
|
|
130
|
+
call.durationMs =
|
|
131
|
+
typeof event.durationMs === "number" && Number.isFinite(event.durationMs) && event.durationMs >= 0
|
|
132
|
+
? event.durationMs
|
|
133
|
+
: undefined;
|
|
134
|
+
call.observedEndedAt = now;
|
|
135
|
+
call.result = capture(event.result);
|
|
136
|
+
}
|
|
137
|
+
settle(): void {
|
|
138
|
+
for (const call of this.calls.values()) if (call.status === "running") call.status = "unfinished";
|
|
139
|
+
}
|
|
140
|
+
list(): Call[] {
|
|
141
|
+
return [...this.calls.values()];
|
|
142
|
+
}
|
|
143
|
+
}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { buildSessionProjection, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { ancestry } from "./ancestry.js";
|
|
4
|
+
import type { EntryIndex } from "./entry-index.js";
|
|
5
|
+
import { recordedLeaf } from "./identity.js";
|
|
6
|
+
import type { ContextComposition, ContextSegment, Json } from "./model.js";
|
|
7
|
+
import { capture } from "./privacy.js";
|
|
8
|
+
|
|
9
|
+
function object(value: Json | undefined): Record<string, Json> | undefined {
|
|
10
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
|
|
11
|
+
}
|
|
12
|
+
export function captureContext(
|
|
13
|
+
input: readonly unknown[],
|
|
14
|
+
source: ContextComposition["source"],
|
|
15
|
+
leafId: string | null,
|
|
16
|
+
): ContextComposition {
|
|
17
|
+
const result: ContextComposition = {
|
|
18
|
+
source,
|
|
19
|
+
leafId,
|
|
20
|
+
totalMessages: input.length,
|
|
21
|
+
messages: [],
|
|
22
|
+
segments: [],
|
|
23
|
+
incomplete: false,
|
|
24
|
+
};
|
|
25
|
+
const occurrences = new Map<string, number>();
|
|
26
|
+
let budget = 1048576;
|
|
27
|
+
for (const message of input) {
|
|
28
|
+
if (result.segments.length >= 4000 || budget <= 64) {
|
|
29
|
+
result.incomplete = true;
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
const raw = capture(message, Math.min(8192, budget));
|
|
33
|
+
const encoded = JSON.stringify(raw);
|
|
34
|
+
budget -= encoded.length;
|
|
35
|
+
result.incomplete ||= raw.truncated;
|
|
36
|
+
const data = object(raw.value);
|
|
37
|
+
const role = typeof data?.role === "string" ? data.role : "unknown";
|
|
38
|
+
const index = result.messages.length;
|
|
39
|
+
result.messages.push(raw);
|
|
40
|
+
const hash = createHash("sha256").update(encoded).digest("hex").slice(0, 20);
|
|
41
|
+
const occurrence = occurrences.get(hash) ?? 0;
|
|
42
|
+
occurrences.set(hash, occurrence + 1);
|
|
43
|
+
const content = data?.content === "" && data?.sections ? data.sections : data?.content;
|
|
44
|
+
const blocks = Array.isArray(content) && content.length ? content : [content ?? data?.sections ?? raw.value];
|
|
45
|
+
for (const [blockIndex, block] of blocks.entries()) {
|
|
46
|
+
if (result.segments.length >= 4000) {
|
|
47
|
+
result.incomplete = true;
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
const item = object(block);
|
|
51
|
+
const kind = typeof item?.type === "string" ? item.type : role;
|
|
52
|
+
const category: ContextSegment["category"] =
|
|
53
|
+
role === "system" || role === "developer"
|
|
54
|
+
? "system"
|
|
55
|
+
: role === "user"
|
|
56
|
+
? "user"
|
|
57
|
+
: role === "toolResult" || role === "tool"
|
|
58
|
+
? "toolResult"
|
|
59
|
+
: role === "assistant" && kind === "toolCall"
|
|
60
|
+
? "toolCall"
|
|
61
|
+
: role === "assistant"
|
|
62
|
+
? "assistant"
|
|
63
|
+
: "other";
|
|
64
|
+
const preview = item?.name ?? item?.text ?? item?.thinking ?? block;
|
|
65
|
+
const timestamp =
|
|
66
|
+
typeof data?.timestamp === "string"
|
|
67
|
+
? data.timestamp
|
|
68
|
+
: typeof data?.timestamp === "number" &&
|
|
69
|
+
Number.isFinite(data.timestamp) &&
|
|
70
|
+
Math.abs(data.timestamp) < 8640000000000000
|
|
71
|
+
? new Date(data.timestamp).toISOString()
|
|
72
|
+
: undefined;
|
|
73
|
+
result.segments.push({
|
|
74
|
+
id: `${source}-${hash}-${occurrence}-${blockIndex}`,
|
|
75
|
+
position: result.segments.length + 1,
|
|
76
|
+
messageIndex: index,
|
|
77
|
+
...(Array.isArray(content) && content.length ? { blockIndex } : {}),
|
|
78
|
+
role,
|
|
79
|
+
kind,
|
|
80
|
+
category,
|
|
81
|
+
preview: (typeof preview === "string" ? preview : JSON.stringify(preview)).slice(0, 240),
|
|
82
|
+
...(timestamp ? { timestamp } : {}),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
result.incomplete ||= result.messages.length < input.length;
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
export function sessionContext(manager: ExtensionContext["sessionManager"], index: EntryIndex): ContextComposition {
|
|
90
|
+
const leafId = recordedLeaf(manager, index.duplicates);
|
|
91
|
+
const validation = leafId ? ancestry(manager, leafId, index) : { path: [], issue: "No unambiguous active leaf" };
|
|
92
|
+
if (validation.issue)
|
|
93
|
+
return {
|
|
94
|
+
source: "session-derived",
|
|
95
|
+
leafId,
|
|
96
|
+
totalMessages: 0,
|
|
97
|
+
messages: [],
|
|
98
|
+
segments: [],
|
|
99
|
+
incomplete: true,
|
|
100
|
+
unavailable: validation.issue,
|
|
101
|
+
};
|
|
102
|
+
try {
|
|
103
|
+
return captureContext(buildSessionProjection(validation.path, leafId).messages, "session-derived", leafId);
|
|
104
|
+
} catch {
|
|
105
|
+
return {
|
|
106
|
+
source: "session-derived",
|
|
107
|
+
leafId,
|
|
108
|
+
totalMessages: 0,
|
|
109
|
+
messages: [],
|
|
110
|
+
segments: [],
|
|
111
|
+
incomplete: true,
|
|
112
|
+
unavailable: "Native context projection unavailable",
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Call } from "./model.js";
|
|
2
|
+
|
|
3
|
+
// Occurrence IDs form the captured execution graph. Raw IDs are only used with an owning assistant anchor.
|
|
4
|
+
export function correlatedCalls(rawIds: Set<string>, anchor: string | undefined, all: Call[]): Call[] {
|
|
5
|
+
if (!anchor) return [];
|
|
6
|
+
const multiplicity = new Map<string, number>();
|
|
7
|
+
for (const call of all)
|
|
8
|
+
if (call.branchAnchor === anchor) multiplicity.set(call.id, (multiplicity.get(call.id) ?? 0) + 1);
|
|
9
|
+
const ids = new Set(
|
|
10
|
+
all
|
|
11
|
+
.filter(
|
|
12
|
+
(call) =>
|
|
13
|
+
(rawIds.has(call.id) ||
|
|
14
|
+
(call.parentId !== undefined &&
|
|
15
|
+
rawIds.has(call.parentId) &&
|
|
16
|
+
(!call.parentOccurrenceId || !all.some((parent) => parent.occurrenceId === call.parentOccurrenceId)))) &&
|
|
17
|
+
call.branchAnchor === anchor &&
|
|
18
|
+
(!call.parentOccurrenceId || !all.some((parent) => parent.occurrenceId === call.parentOccurrenceId)) &&
|
|
19
|
+
!call.correlationUnavailable &&
|
|
20
|
+
(multiplicity.get(call.id) ?? 0) < 2,
|
|
21
|
+
)
|
|
22
|
+
.map((call) => call.occurrenceId),
|
|
23
|
+
);
|
|
24
|
+
for (let i = 0; i < all.length; i++)
|
|
25
|
+
for (const call of all) if (call.parentOccurrenceId && ids.has(call.parentOccurrenceId)) ids.add(call.occurrenceId);
|
|
26
|
+
return all.filter((call) => ids.has(call.occurrenceId));
|
|
27
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
// Rebuilt only at structural boundaries, never once per inline request.
|
|
4
|
+
export class EntryIndex {
|
|
5
|
+
readonly byId = new Map<string, SessionEntry>();
|
|
6
|
+
readonly duplicates = new Set<string>();
|
|
7
|
+
constructor(readonly entries: SessionEntry[]) {
|
|
8
|
+
for (const entry of entries)
|
|
9
|
+
if (typeof entry?.id === "string") {
|
|
10
|
+
if (this.byId.has(entry.id)) this.duplicates.add(entry.id);
|
|
11
|
+
this.byId.set(entry.id, entry);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
get(id: string): SessionEntry | undefined {
|
|
15
|
+
return this.duplicates.has(id) ? undefined : this.byId.get(id);
|
|
16
|
+
}
|
|
17
|
+
}
|
package/src/extension.ts
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { ancestry } from "./ancestry.js";
|
|
4
|
+
import { Collector } from "./collector.js";
|
|
5
|
+
import { SessionFeed } from "./feed.js";
|
|
6
|
+
import { recordedLeaf } from "./identity.js";
|
|
7
|
+
import type { SkillView } from "./model.js";
|
|
8
|
+
import { branch, detail } from "./projection.js";
|
|
9
|
+
import { startServer, type ViewerServer } from "./server.js";
|
|
10
|
+
|
|
11
|
+
interface Owner {
|
|
12
|
+
controller: AbortController;
|
|
13
|
+
generation: string;
|
|
14
|
+
feed?: SessionFeed;
|
|
15
|
+
ctx: ExtensionContext;
|
|
16
|
+
collector?: Collector;
|
|
17
|
+
skills: SkillView[];
|
|
18
|
+
server?: ViewerServer;
|
|
19
|
+
opening?: Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
export interface Dependencies {
|
|
22
|
+
start: typeof startServer;
|
|
23
|
+
launch(pi: ExtensionAPI, url: string, signal: AbortSignal): Promise<boolean>;
|
|
24
|
+
}
|
|
25
|
+
const defaults: Dependencies = {
|
|
26
|
+
start: startServer,
|
|
27
|
+
async launch(pi, url, signal) {
|
|
28
|
+
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer.exe" : "xdg-open";
|
|
29
|
+
const result = await pi.exec(command, [url], { signal, timeout: 5000 });
|
|
30
|
+
return result.code === 0 && !result.killed;
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function registerInspector(pi: ExtensionAPI, deps: Dependencies = defaults): void {
|
|
35
|
+
const owners = new Map<ExtensionContext["sessionManager"], Owner>();
|
|
36
|
+
const epochs = new WeakMap<ExtensionContext["sessionManager"], object>();
|
|
37
|
+
function epochFor(ctx: ExtensionContext): object {
|
|
38
|
+
let epoch = epochs.get(ctx.sessionManager);
|
|
39
|
+
if (!epoch) {
|
|
40
|
+
epoch = {};
|
|
41
|
+
epochs.set(ctx.sessionManager, epoch);
|
|
42
|
+
}
|
|
43
|
+
return epoch;
|
|
44
|
+
}
|
|
45
|
+
const alive = (owner: Owner) => !owner.controller.signal.aborted && owners.get(owner.ctx.sessionManager) === owner;
|
|
46
|
+
async function stop(ctx: ExtensionContext): Promise<void> {
|
|
47
|
+
const owner = owners.get(ctx.sessionManager);
|
|
48
|
+
if (!owner) return;
|
|
49
|
+
owners.delete(ctx.sessionManager);
|
|
50
|
+
owner.controller.abort();
|
|
51
|
+
owner.feed?.close();
|
|
52
|
+
await owner.server?.close();
|
|
53
|
+
await owner.opening;
|
|
54
|
+
owner.collector = undefined;
|
|
55
|
+
}
|
|
56
|
+
function ownerFor(ctx: ExtensionContext): Owner {
|
|
57
|
+
epochFor(ctx);
|
|
58
|
+
let owner = owners.get(ctx.sessionManager);
|
|
59
|
+
if (!owner) {
|
|
60
|
+
owner = { controller: new AbortController(), generation: randomUUID(), ctx, skills: [] };
|
|
61
|
+
owners.set(ctx.sessionManager, owner);
|
|
62
|
+
}
|
|
63
|
+
owner.ctx = ctx;
|
|
64
|
+
return owner;
|
|
65
|
+
}
|
|
66
|
+
function changed(ctx: ExtensionContext, structural = true): Owner | undefined {
|
|
67
|
+
const owner = owners.get(ctx.sessionManager);
|
|
68
|
+
if (!owner || !alive(owner)) return;
|
|
69
|
+
owner.ctx = ctx;
|
|
70
|
+
owner.feed?.changed(structural);
|
|
71
|
+
return owner;
|
|
72
|
+
}
|
|
73
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
74
|
+
const epoch = {};
|
|
75
|
+
epochs.set(ctx.sessionManager, epoch);
|
|
76
|
+
await stop(ctx);
|
|
77
|
+
if (epochs.get(ctx.sessionManager) === epoch) ownerFor(ctx);
|
|
78
|
+
});
|
|
79
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
80
|
+
epochs.delete(ctx.sessionManager);
|
|
81
|
+
await stop(ctx);
|
|
82
|
+
});
|
|
83
|
+
// Observation only: never return or mutate messages/payload; later hooks may still transform them.
|
|
84
|
+
pi.on("context_with_system", (event, ctx) => {
|
|
85
|
+
const owner = owners.get(ctx.sessionManager);
|
|
86
|
+
if (owner?.collector && alive(owner)) {
|
|
87
|
+
owner.ctx = ctx;
|
|
88
|
+
owner.feed?.observeContext(event.messages);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
pi.on("before_provider_request", (event, ctx) => {
|
|
92
|
+
const owner = owners.get(ctx.sessionManager);
|
|
93
|
+
if (owner?.collector && alive(owner)) owner.feed?.observePayload(event.payload);
|
|
94
|
+
});
|
|
95
|
+
pi.on("message_end", (_event, ctx) => {
|
|
96
|
+
changed(ctx);
|
|
97
|
+
});
|
|
98
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
99
|
+
changed(ctx);
|
|
100
|
+
});
|
|
101
|
+
pi.on("session_compact", (_event, ctx) => {
|
|
102
|
+
changed(ctx);
|
|
103
|
+
});
|
|
104
|
+
pi.on("session_info_changed", (_event, ctx) => {
|
|
105
|
+
changed(ctx);
|
|
106
|
+
});
|
|
107
|
+
pi.on("model_select", (_event, ctx) => {
|
|
108
|
+
changed(ctx);
|
|
109
|
+
});
|
|
110
|
+
pi.on("thinking_level_select", (_event, ctx) => {
|
|
111
|
+
changed(ctx);
|
|
112
|
+
});
|
|
113
|
+
function executionAnchor(ctx: ExtensionContext, rawId: string): string | null {
|
|
114
|
+
const index = owners.get(ctx.sessionManager)?.feed?.index();
|
|
115
|
+
const leaf = recordedLeaf(ctx.sessionManager, index?.duplicates);
|
|
116
|
+
const entry = ctx.sessionManager.getLeafEntry();
|
|
117
|
+
if (leaf && entry?.type === "message" && entry.message.role === "assistant") return leaf;
|
|
118
|
+
return (
|
|
119
|
+
(leaf ? ancestry(ctx.sessionManager, leaf, index).path : [])
|
|
120
|
+
.slice()
|
|
121
|
+
.reverse()
|
|
122
|
+
.find(
|
|
123
|
+
(entry) =>
|
|
124
|
+
entry.type === "message" &&
|
|
125
|
+
entry.message.role === "assistant" &&
|
|
126
|
+
(Array.isArray(entry.message.content) ? entry.message.content : []).some(
|
|
127
|
+
(block) => block.type === "toolCall" && block.id === rawId,
|
|
128
|
+
),
|
|
129
|
+
)?.id ?? leaf
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
pi.on("tool_execution_start", (event, ctx) => {
|
|
133
|
+
const owner = owners.get(ctx.sessionManager);
|
|
134
|
+
owner?.collector?.start(event, executionAnchor(ctx, event.toolCallId));
|
|
135
|
+
changed(ctx, false);
|
|
136
|
+
});
|
|
137
|
+
pi.on("tool_execution_update", (event, ctx) => {
|
|
138
|
+
const owner = owners.get(ctx.sessionManager);
|
|
139
|
+
if (owner?.collector?.update(event, recordedLeaf(ctx.sessionManager, owner?.feed?.index().duplicates)))
|
|
140
|
+
changed(ctx, false);
|
|
141
|
+
});
|
|
142
|
+
pi.on("tool_execution_end", (event, ctx) => {
|
|
143
|
+
const owner = owners.get(ctx.sessionManager);
|
|
144
|
+
owner?.collector?.end(event, executionAnchor(ctx, event.toolCallId));
|
|
145
|
+
changed(ctx, false);
|
|
146
|
+
});
|
|
147
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
148
|
+
owners.get(ctx.sessionManager)?.collector?.settle();
|
|
149
|
+
changed(ctx, false);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
async function open(owner: Owner, ctx: ExtensionCommandContext): Promise<void> {
|
|
153
|
+
try {
|
|
154
|
+
if (!owner.server) {
|
|
155
|
+
const accepted = await ctx.ui.confirm(
|
|
156
|
+
"Open Session Inspector?",
|
|
157
|
+
"Serve this session's prompts, code and tool outputs to a local browser and record bounded context, provider payload and child results in memory? Redaction is incomplete. Nothing is uploaded or saved.",
|
|
158
|
+
{ signal: owner.controller.signal },
|
|
159
|
+
);
|
|
160
|
+
if (!alive(owner) || !accepted) return;
|
|
161
|
+
const options = ctx.getSystemPromptOptions();
|
|
162
|
+
owner.skills = (options.skills ?? [])
|
|
163
|
+
.slice(0, 257) // One sentinel lets the snapshot disclose an incomplete catalog.
|
|
164
|
+
.map((s) => ({ name: s.name, path: s.filePath, description: s.description }));
|
|
165
|
+
owner.collector = new Collector();
|
|
166
|
+
owner.feed = new SessionFeed({
|
|
167
|
+
pi,
|
|
168
|
+
context: () => owner.ctx,
|
|
169
|
+
collector: owner.collector,
|
|
170
|
+
skills: owner.skills,
|
|
171
|
+
generation: owner.generation,
|
|
172
|
+
signal: owner.controller.signal,
|
|
173
|
+
invalidate: (revision) => {
|
|
174
|
+
if (alive(owner)) owner.server?.invalidate(revision);
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
const server = await deps.start({
|
|
178
|
+
generation: owner.generation,
|
|
179
|
+
signal: owner.controller.signal,
|
|
180
|
+
snapshot: () => owner.feed?.snapshot(),
|
|
181
|
+
branch: (id, offset) => branch(ctx.sessionManager, id, offset, owner.skills, owner.feed?.index()),
|
|
182
|
+
detail: (id, leaf) =>
|
|
183
|
+
detail(ctx.sessionManager, id, leaf, owner.collector ?? new Collector(), owner.feed?.index()),
|
|
184
|
+
});
|
|
185
|
+
if (!alive(owner)) {
|
|
186
|
+
await server.close();
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
owner.server = server;
|
|
190
|
+
owner.feed.start();
|
|
191
|
+
}
|
|
192
|
+
if (!alive(owner)) return;
|
|
193
|
+
const url = owner.server.url;
|
|
194
|
+
let launched = false;
|
|
195
|
+
try {
|
|
196
|
+
launched = await deps.launch(pi, url, owner.controller.signal);
|
|
197
|
+
} catch {
|
|
198
|
+
/* URL fallback; no command stderr or token logging. */
|
|
199
|
+
}
|
|
200
|
+
if (!alive(owner)) return;
|
|
201
|
+
ctx.ui.notify(
|
|
202
|
+
`${launched ? "Session Inspector" : "Browser unavailable; open privately"}:\n${url}\n/inspect stop revokes this URL.`,
|
|
203
|
+
launched ? "info" : "warning",
|
|
204
|
+
);
|
|
205
|
+
} catch {
|
|
206
|
+
if (!alive(owner)) return;
|
|
207
|
+
owner.collector = undefined;
|
|
208
|
+
owner.feed?.close();
|
|
209
|
+
owner.feed = undefined;
|
|
210
|
+
await owner.server?.close();
|
|
211
|
+
owner.server = undefined;
|
|
212
|
+
if (alive(owner))
|
|
213
|
+
ctx.ui.notify(
|
|
214
|
+
"Could not open Session Inspector. Build its browser assets first; see the local README.",
|
|
215
|
+
"error",
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const command: Parameters<ExtensionAPI["registerCommand"]>[1] = {
|
|
221
|
+
description: "Open a private, read-only live session web inspector",
|
|
222
|
+
getArgumentCompletions: (prefix) =>
|
|
223
|
+
["stop"].filter((s) => s.startsWith(prefix)).map((value) => ({ value, label: value })),
|
|
224
|
+
handler: async (args, ctx) => {
|
|
225
|
+
const route = args.trim();
|
|
226
|
+
if (route !== "" && route !== "stop") {
|
|
227
|
+
if (ctx.hasUI) {
|
|
228
|
+
ctx.ui.notify("Usage: /inspect [stop]", "warning");
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
throw new Error("Usage: /inspect [stop]");
|
|
232
|
+
}
|
|
233
|
+
if (route === "stop") {
|
|
234
|
+
const epoch = epochFor(ctx);
|
|
235
|
+
await stop(ctx);
|
|
236
|
+
if (ctx.hasUI && epochs.get(ctx.sessionManager) === epoch)
|
|
237
|
+
ctx.ui.notify("Session Inspector stopped; URL revoked.", "info");
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (ctx.mode !== "tui") {
|
|
241
|
+
if (ctx.hasUI) {
|
|
242
|
+
ctx.ui.notify("Opening Session Inspector requires TUI mode. Stop remains available.", "warning");
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
throw new Error("Opening Session Inspector requires TUI mode.");
|
|
246
|
+
}
|
|
247
|
+
const owner = ownerFor(ctx);
|
|
248
|
+
if (owner.opening) return;
|
|
249
|
+
owner.opening = open(owner, ctx);
|
|
250
|
+
try {
|
|
251
|
+
await owner.opening;
|
|
252
|
+
} finally {
|
|
253
|
+
owner.opening = undefined;
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
pi.registerCommand("inspect", command);
|
|
258
|
+
pi.registerCommand("session-inspector", command); // Compatibility route, same ownership and mode guards.
|
|
259
|
+
}
|