@narumitw/pi-inspect 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +110 -2
  3. package/dist/app.css +1 -0
  4. package/dist/app.js +128 -0
  5. package/dist/index.html +1 -0
  6. package/docs/REVIEW.md +189 -0
  7. package/docs/VERIFICATION.md +186 -0
  8. package/docs/viewer.md +74 -0
  9. package/package.json +67 -4
  10. package/src/ancestry.ts +32 -0
  11. package/src/collector.ts +143 -0
  12. package/src/context.ts +115 -0
  13. package/src/correlation.ts +27 -0
  14. package/src/entry-index.ts +17 -0
  15. package/src/entry-summary.ts +34 -0
  16. package/src/extension.ts +259 -0
  17. package/src/feed.ts +145 -0
  18. package/src/identity.ts +112 -0
  19. package/src/index.ts +1 -0
  20. package/src/model.ts +117 -0
  21. package/src/native-projection.ts +28 -0
  22. package/src/privacy.ts +136 -0
  23. package/src/projection.ts +374 -0
  24. package/src/server.ts +202 -0
  25. package/src/system-message.ts +117 -0
  26. package/src/web/api.ts +137 -0
  27. package/src/web/app.tsx +570 -0
  28. package/src/web/captured-executions.tsx +177 -0
  29. package/src/web/components.css +261 -0
  30. package/src/web/components.tsx +241 -0
  31. package/src/web/composition.css +554 -0
  32. package/src/web/context-composition.tsx +329 -0
  33. package/src/web/context-inventory.tsx +65 -0
  34. package/src/web/entry-calls.ts +14 -0
  35. package/src/web/format.ts +94 -0
  36. package/src/web/hierarchy.ts +117 -0
  37. package/src/web/history.ts +56 -0
  38. package/src/web/inline-entry.tsx +124 -0
  39. package/src/web/inspector.tsx +386 -0
  40. package/src/web/json-tree.tsx +74 -0
  41. package/src/web/overview.tsx +67 -0
  42. package/src/web/panels.css +267 -0
  43. package/src/web/panes.tsx +123 -0
  44. package/src/web/recorded-content.tsx +49 -0
  45. package/src/web/search.ts +16 -0
  46. package/src/web/session-filters.tsx +100 -0
  47. package/src/web/session.css +114 -0
  48. package/src/web/style.css +332 -0
  49. package/src/web/styles.d.ts +1 -0
  50. package/src/web/timeline.tsx +33 -0
  51. package/src/web/timing.ts +26 -0
  52. package/src/web/trace.css +411 -0
  53. package/src/web/trace.tsx +313 -0
  54. package/src/web/virtual-context.ts +138 -0
@@ -0,0 +1,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
+ }
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
+ }
@@ -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,117 @@
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
+ summary?: string;
12
+ summaryTruncated?: boolean;
13
+ internal?: true;
14
+ timestamp: string;
15
+ name?: string;
16
+ nameTruncated?: boolean;
17
+ tokens?: number;
18
+ status?: "success" | "error" | "cancelled";
19
+ toolCallId?: string;
20
+ toolAnchor?: string;
21
+ }
22
+ export interface Call {
23
+ id: string;
24
+ occurrenceId: string;
25
+ parentOccurrenceId?: string;
26
+ parentUnavailable?: string;
27
+ correlationUnavailable?: boolean;
28
+ parentId?: string;
29
+ name: string;
30
+ status: "running" | "ok" | "error" | "unfinished";
31
+ args: Capture;
32
+ result?: Capture;
33
+ durationMs?: number;
34
+ observedStartedAt?: number;
35
+ observedEndedAt?: number;
36
+ branchAnchor: string | null;
37
+ }
38
+ export interface ToolView {
39
+ name: string;
40
+ description: string;
41
+ exposure: string;
42
+ active: boolean;
43
+ callable: boolean;
44
+ namespace?: string;
45
+ schema: Capture;
46
+ }
47
+ export interface SkillView {
48
+ name: string;
49
+ path: string;
50
+ description: string;
51
+ }
52
+ export interface ContextSegment {
53
+ id: string;
54
+ position: number;
55
+ messageIndex: number;
56
+ blockIndex?: number;
57
+ role: string;
58
+ kind: string;
59
+ category: "system" | "user" | "assistant" | "toolCall" | "toolResult" | "other";
60
+ preview: string;
61
+ timestamp?: string;
62
+ }
63
+ export interface ContextComposition {
64
+ source: "observed-pi-context" | "session-derived";
65
+ leafId: string | null;
66
+ observedAt?: number;
67
+ totalMessages: number;
68
+ messages: Capture[];
69
+ segments: ContextSegment[];
70
+ incomplete: boolean;
71
+ unavailable?: string;
72
+ }
73
+ export interface Snapshot {
74
+ protocol: 1;
75
+ generation: string;
76
+ revision: number;
77
+ sessionId: string;
78
+ name: string;
79
+ nameTruncated?: boolean;
80
+ leafId: string | null;
81
+ totalEntries: number;
82
+ nodes: EntrySummary[];
83
+ incomplete: boolean;
84
+ invalidEntryCount?: number;
85
+ invalidEntries?: { index: number; reason: string; raw: Capture }[];
86
+ context?: ContextComposition;
87
+ providerObservation?: { observedAt: number; data: Capture };
88
+ currentPrompt: Capture;
89
+ tools: ToolView[];
90
+ skills: SkillView[];
91
+ calls: Call[];
92
+ droppedCalls: number;
93
+ invalidCallEvents?: number;
94
+ captureStartedAt: number;
95
+ }
96
+ export interface BranchView {
97
+ ancestryIssue?: string;
98
+ leafId: string;
99
+ entries: EntrySummary[];
100
+ total: number;
101
+ offset: number;
102
+ prompt: Capture;
103
+ previousPrompt: Capture;
104
+ sections: Capture;
105
+ declaredTools: Capture;
106
+ promptUpdates: Capture;
107
+ projection: Capture;
108
+ context?: ContextComposition;
109
+ skillEvidence: Capture;
110
+ }
111
+ export interface DetailView {
112
+ ancestryIssue?: string;
113
+ toolAnchor?: string;
114
+ raw: Capture;
115
+ projected: Capture;
116
+ calls: Call[];
117
+ }
@@ -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
+ }