@xprnvd/pi-context-view 0.0.0-stage → 0.8.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 +114 -2
- package/package.json +55 -5
- package/src/capture/adjustments.ts +649 -0
- package/src/capture/attribution.ts +25 -0
- package/src/capture/builder.ts +128 -0
- package/src/capture/diff.ts +514 -0
- package/src/capture/dispatch.ts +36 -0
- package/src/capture/guard.ts +287 -0
- package/src/capture/messages.ts +246 -0
- package/src/capture/payload.ts +584 -0
- package/src/capture/redact.ts +58 -0
- package/src/capture/register.ts +129 -0
- package/src/capture/request.ts +251 -0
- package/src/capture/tools.ts +69 -0
- package/src/capture/tracker.ts +71 -0
- package/src/command.ts +164 -0
- package/src/compaction.ts +57 -0
- package/src/config.ts +408 -0
- package/src/index.ts +191 -0
- package/src/injections.ts +670 -0
- package/src/measure.ts +887 -0
- package/src/message-preview.ts +72 -0
- package/src/model-identity.ts +48 -0
- package/src/model.ts +548 -0
- package/src/pi-version.ts +29 -0
- package/src/probe/failure.ts +34 -0
- package/src/probe/filter.ts +126 -0
- package/src/probe/silent-probe.ts +349 -0
- package/src/probe/token.ts +28 -0
- package/src/probe/trigger.ts +178 -0
- package/src/probe/view.ts +26 -0
- package/src/projection.ts +122 -0
- package/src/prompt-additions.ts +226 -0
- package/src/prompt-blocks.ts +80 -0
- package/src/replay.ts +234 -0
- package/src/settings.ts +62 -0
- package/src/snapshot.ts +188 -0
- package/src/system-prompt-rows.ts +311 -0
- package/src/text.ts +46 -0
- package/src/token-diff.ts +211 -0
- package/src/transcript.ts +19 -0
- package/src/ui/color.ts +52 -0
- package/src/ui/diff-preview.ts +181 -0
- package/src/ui/injections-model.ts +487 -0
- package/src/ui/injections-view.ts +1023 -0
- package/src/ui/json-preview.ts +41 -0
- package/src/ui/layout.ts +161 -0
- package/src/ui/markers.ts +253 -0
- package/src/ui/notes.ts +117 -0
- package/src/ui/search.ts +427 -0
- package/src/ui/section-preview.ts +267 -0
- package/src/ui/skill-preview.ts +83 -0
- package/src/ui/status-bar.ts +94 -0
- package/src/ui/usage-map.ts +157 -0
- package/src/ui/usage-preview.ts +202 -0
- package/src/ui/usage-view.ts +1428 -0
- package/src/ui/wheel.ts +65 -0
- package/src/usage.ts +461 -0
package/src/replay.ts
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Measurement inputs from recorded system state, plus the live prompt/tool
|
|
3
|
+
* fallback when no state was recorded. Usage and Injections share these helpers.
|
|
4
|
+
*/
|
|
5
|
+
import type { BuildSystemPromptOptions, ToolInfo } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { getCurrentSystemMessage, type Tool } from "@earendil-works/pi-ai";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
analyzeSystemPrompt,
|
|
10
|
+
type DeletedSection,
|
|
11
|
+
type PromptChanges,
|
|
12
|
+
type PromptOptionsSlice,
|
|
13
|
+
SYSTEM_PROMPT_ITEM_ID,
|
|
14
|
+
type ToolSlice,
|
|
15
|
+
UNREGISTERED_TOOL_SOURCE,
|
|
16
|
+
} from "./measure.ts";
|
|
17
|
+
import { buildSnapshot, type InjectionItem, type InjectionSnapshot, type RequestChange } from "./model.ts";
|
|
18
|
+
import type { PromptSourceSlice } from "./prompt-additions.ts";
|
|
19
|
+
import type { RequestMessage, SystemChange } from "./snapshot.ts";
|
|
20
|
+
import { type SystemMessage, systemMessageText } from "./transcript.ts";
|
|
21
|
+
|
|
22
|
+
/** Inputs for an on-demand pi-native prompt/tool fallback without recorded system state. */
|
|
23
|
+
export interface NativeSnapshotInput {
|
|
24
|
+
systemPrompt: string;
|
|
25
|
+
/** Prompt options; `hiddenTools` names the tools Pi leaves out of requests. */
|
|
26
|
+
options: BuildSystemPromptOptions;
|
|
27
|
+
allTools: readonly ToolInfo[];
|
|
28
|
+
activeToolNames: readonly string[];
|
|
29
|
+
/** Loaded extension provenance: labels path extensions and guesses who appended prompt text. */
|
|
30
|
+
promptSources?: readonly PromptSourceSlice[];
|
|
31
|
+
/** Forced prompt of the request, measured instead of the prompt; tool changes still apply. */
|
|
32
|
+
forcedPrompt?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Inputs for Usage's branch-local prompt/tool estimate. */
|
|
36
|
+
export interface UsageSnapshotInput extends NativeSnapshotInput {
|
|
37
|
+
/** Current branch messages; their system messages supply the recorded state. */
|
|
38
|
+
messages: readonly RequestMessage[];
|
|
39
|
+
/** Request-only system changes of the latest request, applied after the replayed state. */
|
|
40
|
+
systemChanges?: readonly SystemChange[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** System state a request carried, with what its changes touched. */
|
|
44
|
+
export interface RequestSystemState {
|
|
45
|
+
readonly state: Pick<SystemMessage, "content" | "sections">;
|
|
46
|
+
readonly declarations: readonly Tool[];
|
|
47
|
+
readonly prompt: PromptChanges;
|
|
48
|
+
readonly tools: ReadonlyMap<string, Exclude<RequestChange, "deleted" | "forced">>;
|
|
49
|
+
readonly deletedTools: readonly Tool[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Build a view-local pi-native fallback without starting a capture; the tools Pi hides are left out and counted. */
|
|
53
|
+
export function buildNativeSnapshot(input: NativeSnapshotInput): InjectionSnapshot {
|
|
54
|
+
const options = copyPromptOptions(input.options);
|
|
55
|
+
const tools = splitHiddenTools(captureActiveTools(input.allTools, input.activeToolNames, input.options),
|
|
56
|
+
input.options.hiddenTools);
|
|
57
|
+
const prompt = input.forcedPrompt ?? input.systemPrompt;
|
|
58
|
+
const items = analyzeSystemPrompt(prompt, options, tools.declared, { sources: input.promptSources });
|
|
59
|
+
return { ...buildSnapshot(markForcedPrompt(items, input.forcedPrompt)), hiddenTools: tools.hiddenNames };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Measure the branch's replayed prompt and tools instead of today's loader
|
|
64
|
+
* prompt/tools, with the latest request's system changes applied once. A forced
|
|
65
|
+
* prompt replaces every section, so only tool changes reach it. The tools Pi
|
|
66
|
+
* currently hides from the model are left out. Only a branch with no recorded
|
|
67
|
+
* system message yet uses the live fallback.
|
|
68
|
+
*/
|
|
69
|
+
export function buildUsageSnapshot(input: UsageSnapshotInput): InjectionSnapshot {
|
|
70
|
+
const forced = input.forcedPrompt;
|
|
71
|
+
const base = getCurrentSystemMessage(input.messages);
|
|
72
|
+
// Undefined means a branch with no recorded system message yet, not an explicitly empty state
|
|
73
|
+
if (base === undefined) return buildNativeSnapshot(input);
|
|
74
|
+
const request = applySystemChanges(base, input.systemChanges ?? []);
|
|
75
|
+
const declarations = splitHiddenTools(request.declarations, input.options.hiddenTools);
|
|
76
|
+
const tools = replayedToolSlices(request.state, declarations.declared, input.allTools);
|
|
77
|
+
const options = copyPromptOptions(input.options);
|
|
78
|
+
const items = analyzeSystemPrompt(forced ?? systemMessageText(request.state), {
|
|
79
|
+
...options,
|
|
80
|
+
// Current loader overrides are not evidence of what this branch recorded.
|
|
81
|
+
customPrompt: undefined, sections: undefined,
|
|
82
|
+
// The replayed layout locates inline or unwrapped request sections; Usage marks no section changes
|
|
83
|
+
}, tools, { sources: input.promptSources }, forced === undefined ? { replayed: request.prompt.replayed } : {});
|
|
84
|
+
return { ...buildSnapshot(markForcedPrompt(items, forced)), hiddenTools: declarations.hiddenNames };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The tools Pi declares to the model, and the names of `tools` it hid from it:
|
|
89
|
+
* they stay active and callable through another tool, such as codemode.
|
|
90
|
+
* `hiddenTools` names the hidden ones, as Pi's prompt options report them.
|
|
91
|
+
*/
|
|
92
|
+
export function splitHiddenTools<T extends { readonly name: string }>(
|
|
93
|
+
tools: readonly T[],
|
|
94
|
+
hiddenTools: readonly string[] | undefined,
|
|
95
|
+
): { readonly declared: T[]; readonly hiddenNames: string[] } {
|
|
96
|
+
const hidden = new Set(hiddenTools);
|
|
97
|
+
const declared: T[] = [];
|
|
98
|
+
const hiddenNames: string[] = [];
|
|
99
|
+
for (const tool of tools) {
|
|
100
|
+
if (hidden.has(tool.name)) hiddenNames.push(tool.name);
|
|
101
|
+
else declared.push(tool);
|
|
102
|
+
}
|
|
103
|
+
return { declared, hiddenNames };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Items with the System Prompt marked `forced` when the request carried a forced prompt. */
|
|
107
|
+
export function markForcedPrompt(items: InjectionItem[], forced: string | undefined): InjectionItem[] {
|
|
108
|
+
if (forced === undefined) return items;
|
|
109
|
+
return items.map((item) => item.id === SYSTEM_PROMPT_ITEM_ID ? { ...item, change: "forced" } : item);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Apply content, section, and tool changes in capture order to a replayed system state. */
|
|
113
|
+
export function applySystemChanges(base: SystemMessage, changes: readonly SystemChange[]): RequestSystemState {
|
|
114
|
+
let content = contentText(base.content);
|
|
115
|
+
const sections: Record<string, string | null> = { ...base.sections };
|
|
116
|
+
const sectionChanges = new Map<string, Exclude<RequestChange, "deleted" | "forced">>();
|
|
117
|
+
const deletedSections: DeletedSection[] = [];
|
|
118
|
+
const declarations = [...(base.toolsAdded ?? [])];
|
|
119
|
+
const toolChanges = new Map<string, Exclude<RequestChange, "deleted" | "forced">>();
|
|
120
|
+
const deletedTools: Tool[] = [];
|
|
121
|
+
for (const change of changes) {
|
|
122
|
+
switch (change.type) {
|
|
123
|
+
case "content":
|
|
124
|
+
content = change.text;
|
|
125
|
+
// Pi renders plain content before the first section, inside the measured Preamble
|
|
126
|
+
sectionChanges.set("preamble", "modified");
|
|
127
|
+
break;
|
|
128
|
+
case "section": {
|
|
129
|
+
const previous = sections[change.name];
|
|
130
|
+
if (change.text === null) {
|
|
131
|
+
if (typeof previous === "string") deletedSections.push({ name: change.name, text: previous });
|
|
132
|
+
delete sections[change.name];
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
sectionChanges.set(change.name, typeof previous === "string" ? "modified" : "added");
|
|
136
|
+
sections[change.name] = change.text;
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
case "tool": {
|
|
140
|
+
const index = declarations.findIndex((tool) => tool.name === change.name);
|
|
141
|
+
if (change.declaration === null) {
|
|
142
|
+
if (index !== -1) deletedTools.push(...declarations.splice(index, 1));
|
|
143
|
+
} else if (index === -1) {
|
|
144
|
+
declarations.push(change.declaration);
|
|
145
|
+
toolChanges.set(change.name, "added");
|
|
146
|
+
} else {
|
|
147
|
+
declarations[index] = change.declaration;
|
|
148
|
+
toolChanges.set(change.name, "modified");
|
|
149
|
+
}
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
state: { content, sections },
|
|
156
|
+
declarations,
|
|
157
|
+
prompt: { sections: sectionChanges, deleted: deletedSections, replayed: { content, sections } },
|
|
158
|
+
tools: toolChanges,
|
|
159
|
+
deletedTools,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Copy the prompt-options slice used by measurement, without shared nested references. */
|
|
164
|
+
export function copyPromptOptions(options: BuildSystemPromptOptions): PromptOptionsSlice {
|
|
165
|
+
return {
|
|
166
|
+
homeDir: process.env.HOME,
|
|
167
|
+
customPrompt: options.customPrompt,
|
|
168
|
+
sections: options.sections === undefined ? undefined : { ...options.sections },
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Snapshot the final active tool set with provenance and payload definitions.
|
|
174
|
+
* Keep pi's active-tool order: it decides which tool owns a guideline bullet
|
|
175
|
+
* that several tools declare.
|
|
176
|
+
*/
|
|
177
|
+
export function captureActiveTools(
|
|
178
|
+
allTools: readonly ToolInfo[],
|
|
179
|
+
activeToolNames: readonly string[],
|
|
180
|
+
options: { readonly toolSnippets?: Readonly<Record<string, string>> },
|
|
181
|
+
): ToolSlice[] {
|
|
182
|
+
const byName = new Map(allTools.map((tool) => [tool.name, tool]));
|
|
183
|
+
return [...new Set(activeToolNames)]
|
|
184
|
+
.map((name) => byName.get(name))
|
|
185
|
+
.filter((tool) => tool !== undefined)
|
|
186
|
+
.map((tool) => ({
|
|
187
|
+
name: tool.name,
|
|
188
|
+
description: tool.description,
|
|
189
|
+
parametersJson: JSON.stringify(tool.parameters ?? {}),
|
|
190
|
+
snippet: options.toolSnippets?.[tool.name],
|
|
191
|
+
guidelines: normalizeGuidelines(tool.promptGuidelines),
|
|
192
|
+
source: tool.sourceInfo.source,
|
|
193
|
+
sourcePath: tool.sourceInfo.path,
|
|
194
|
+
}));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Tool slices for recorded declarations. The declaration supplies name,
|
|
199
|
+
* description, and schema; the prompt snippet comes from the recorded `tools`
|
|
200
|
+
* section. Current registration metadata supplies only guidelines and
|
|
201
|
+
* provenance; an unregistered tool stays unattributed.
|
|
202
|
+
*/
|
|
203
|
+
export function replayedToolSlices(
|
|
204
|
+
state: Pick<SystemMessage, "sections">,
|
|
205
|
+
declarations: readonly Tool[],
|
|
206
|
+
allTools: readonly ToolInfo[],
|
|
207
|
+
): ToolSlice[] {
|
|
208
|
+
const registered = new Map(allTools.map((tool) => [tool.name, tool]));
|
|
209
|
+
const snippetLines = state.sections?.tools?.split("\n") ?? [];
|
|
210
|
+
return declarations.map((tool) => {
|
|
211
|
+
const metadata = registered.get(tool.name);
|
|
212
|
+
const snippetLine = snippetLines.find((line) => line.startsWith(`- ${tool.name}: `));
|
|
213
|
+
return {
|
|
214
|
+
name: tool.name,
|
|
215
|
+
description: tool.description,
|
|
216
|
+
parametersJson: JSON.stringify(tool.parameters),
|
|
217
|
+
snippet: snippetLine?.slice(`- ${tool.name}: `.length),
|
|
218
|
+
guidelines: normalizeGuidelines(metadata?.promptGuidelines),
|
|
219
|
+
source: metadata?.sourceInfo.source ?? UNREGISTERED_TOOL_SOURCE,
|
|
220
|
+
sourcePath: metadata?.sourceInfo.path,
|
|
221
|
+
};
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Plain text of system content; text blocks join with a newline, as Pi renders them. */
|
|
226
|
+
function contentText(content: SystemMessage["content"]): string {
|
|
227
|
+
return typeof content === "string" ? content : content.map((block) => block.text).join("\n");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Normalize the string-or-array promptGuidelines field to an owned array. */
|
|
231
|
+
function normalizeGuidelines(guidelines: string | string[] | undefined): string[] {
|
|
232
|
+
if (guidelines === undefined) return [];
|
|
233
|
+
return Array.isArray(guidelines) ? [...guidelines] : [guidelines];
|
|
234
|
+
}
|
package/src/settings.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read Pi's settings for the probe preconditions and the Usage map's
|
|
3
|
+
* auto-compaction reserve.
|
|
4
|
+
*/
|
|
5
|
+
import { type CacheWarmingMode, type ExtensionAPI, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
|
|
7
|
+
/** Identifies the `compaction.modelOverrides` entry pi would apply to a request. */
|
|
8
|
+
export interface CompactionModel {
|
|
9
|
+
readonly provider: string;
|
|
10
|
+
readonly id: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Wrap Pi's live merged settings, including unsaved runtime changes, so both
|
|
15
|
+
* the probe preconditions and the Usage map read the same source.
|
|
16
|
+
*/
|
|
17
|
+
export function readLiveSettings(pi: ExtensionAPI): SettingsManager {
|
|
18
|
+
return SettingsManager.inMemory(pi.getSettings());
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Read the global-only warming mode; a project override must not hide idle warming.
|
|
23
|
+
* Extensions cannot see an SDK host's custom agent directory, so this reads Pi's
|
|
24
|
+
* default one, which honors `PI_CODING_AGENT_DIR`.
|
|
25
|
+
*/
|
|
26
|
+
export function readGlobalCacheWarmingMode(cwd: string): CacheWarmingMode {
|
|
27
|
+
const settings = SettingsManager.create(cwd, undefined, { projectTrusted: false });
|
|
28
|
+
if (settings.drainErrors().length > 0) throw new Error("Pi settings could not be read.");
|
|
29
|
+
return settings.getCacheWarmingMode();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Read the auto-compaction reserve from Pi's live settings, or undefined when
|
|
34
|
+
* auto-compaction is disabled. Read at view-open time because `enabled` and the
|
|
35
|
+
* model can change at runtime.
|
|
36
|
+
*/
|
|
37
|
+
export function readAutoCompactReserveTokens(pi: ExtensionAPI, model?: CompactionModel): number | undefined {
|
|
38
|
+
try {
|
|
39
|
+
return resolveAutoCompactReserveTokens(readLiveSettings(pi), model);
|
|
40
|
+
} catch {
|
|
41
|
+
// Unreadable settings degrade to a map without the buffer, not a failed view.
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resolve the reserve pi would apply to `model`: its `compaction.modelOverrides`
|
|
48
|
+
* entry, else the ordinary `compaction.reserveTokens`, else pi's default.
|
|
49
|
+
* Returns undefined when auto-compaction is disabled or a setting is invalid.
|
|
50
|
+
*/
|
|
51
|
+
export function resolveAutoCompactReserveTokens(
|
|
52
|
+
settings: SettingsManager,
|
|
53
|
+
model?: CompactionModel,
|
|
54
|
+
): number | undefined {
|
|
55
|
+
try {
|
|
56
|
+
if (!settings.getCompactionEnabled()) return undefined;
|
|
57
|
+
return settings.getCompactionReserveTokens(model);
|
|
58
|
+
} catch {
|
|
59
|
+
// Pi rejects invalid reserve values; show no buffer rather than a wrong one.
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/snapshot.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request snapshots and the SnapshotStore that decouples capture from its
|
|
3
|
+
* consumers (doc/architecture/capture.md). Capture publishes; views read.
|
|
4
|
+
* Imports Pi types only, so the store runs and is tested without a Pi runtime.
|
|
5
|
+
*/
|
|
6
|
+
import type { Tool } from "@earendil-works/pi-ai";
|
|
7
|
+
import type { ContextEvent } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
|
|
9
|
+
/** What produced the captured request: a real agent run or the silent probe. */
|
|
10
|
+
export type CaptureOrigin = "real-turn" | "synthetic-probe";
|
|
11
|
+
|
|
12
|
+
/** One message as Pi passes it to context handlers. */
|
|
13
|
+
export type RequestMessage = ContextEvent["messages"][number];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Best-effort source of a conversation change. Neither field proves
|
|
17
|
+
* ownership: any extension can reuse them. Both absent means unattributed.
|
|
18
|
+
* For a modification or deletion they describe the affected message, which
|
|
19
|
+
* names its owner, not the extension that edited it.
|
|
20
|
+
*/
|
|
21
|
+
export interface ChangeAttribution {
|
|
22
|
+
/** `customType` of a custom message. */
|
|
23
|
+
readonly customType?: string;
|
|
24
|
+
/** Cooperative provenance from a custom message's `details`, which is never sent to the model. */
|
|
25
|
+
readonly provenance?: { readonly source: string; readonly reason?: string };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A request-only conversation edit. Modifications and deletions reference the
|
|
30
|
+
* source entry of their baseline message, so consumers can apply them to a later
|
|
31
|
+
* projection and drop them once the entry is gone. Retained messages are
|
|
32
|
+
* redacted: image data holds only a size marker, `textSignature` is removed, and
|
|
33
|
+
* thinking and tool-call signatures keep only their length.
|
|
34
|
+
*/
|
|
35
|
+
export type ConversationChange =
|
|
36
|
+
| { readonly type: "added"; readonly message: RequestMessage; readonly attribution: ChangeAttribution }
|
|
37
|
+
| {
|
|
38
|
+
readonly type: "modified";
|
|
39
|
+
readonly entryId: string;
|
|
40
|
+
readonly message: RequestMessage;
|
|
41
|
+
readonly attribution: ChangeAttribution;
|
|
42
|
+
}
|
|
43
|
+
| { readonly type: "deleted"; readonly entryId: string; readonly attribution: ChangeAttribution };
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A request-only change of the replayed system state:
|
|
47
|
+
* content the replayed plain content differs; `text` is the request's whole content
|
|
48
|
+
* section a section was added or changed; `null` text removes it
|
|
49
|
+
* tool a declaration was added or redefined; `null` removes it
|
|
50
|
+
*/
|
|
51
|
+
export type SystemChange =
|
|
52
|
+
| { readonly type: "content"; readonly text: string }
|
|
53
|
+
| { readonly type: "section"; readonly name: string; readonly text: string | null }
|
|
54
|
+
| { readonly type: "tool"; readonly name: string; readonly declaration: Tool | null };
|
|
55
|
+
|
|
56
|
+
/** Structured request-only edits between the session projection and the captured request. */
|
|
57
|
+
export interface StructuredChanges {
|
|
58
|
+
readonly conversation: readonly ConversationChange[];
|
|
59
|
+
readonly system: readonly SystemChange[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Provider, API, and model that a paired request was dispatched to. */
|
|
63
|
+
export interface Dispatch {
|
|
64
|
+
readonly provider: string;
|
|
65
|
+
readonly api: string;
|
|
66
|
+
readonly model: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Part of a provider message that the payload guard compares as one text unit:
|
|
71
|
+
* system system prompt or later system message text
|
|
72
|
+
* user user text, including converted custom messages and summaries
|
|
73
|
+
* assistant assistant text
|
|
74
|
+
* tool-call one tool call: its name and arguments
|
|
75
|
+
* tool-result one tool result's text
|
|
76
|
+
*/
|
|
77
|
+
export type MessagePart = "system" | "user" | "assistant" | "tool-call" | "tool-result";
|
|
78
|
+
|
|
79
|
+
/** One changed line of a payload change: only in the payload (`added`) or only in the captured request (`removed`). */
|
|
80
|
+
export interface PayloadChangeLine {
|
|
81
|
+
readonly type: "added" | "removed";
|
|
82
|
+
readonly text: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A payload difference the structured capture cannot explain:
|
|
87
|
+
* payload-change message text added, changed, or removed after the monitor,
|
|
88
|
+
* without structure or attribution
|
|
89
|
+
* payload-tool-change a declaration the payload adds, removes, or describes differently
|
|
90
|
+
* after the monitor; tools Pi hid are not removals
|
|
91
|
+
*/
|
|
92
|
+
export type GuardFinding =
|
|
93
|
+
| {
|
|
94
|
+
readonly type: "payload-change";
|
|
95
|
+
readonly change: "added" | "modified" | "deleted";
|
|
96
|
+
readonly part: MessagePart;
|
|
97
|
+
/** Changed lines; a modification lists only the lines that differ, ignoring whitespace. */
|
|
98
|
+
readonly lines: readonly PayloadChangeLine[];
|
|
99
|
+
}
|
|
100
|
+
| {
|
|
101
|
+
readonly type: "payload-tool-change";
|
|
102
|
+
readonly change: "added" | "modified" | "deleted";
|
|
103
|
+
readonly name: string;
|
|
104
|
+
/**
|
|
105
|
+
* Changed description lines: every line the payload declares for an addition,
|
|
106
|
+
* every captured line for a deletion, and only the lines that differ for a
|
|
107
|
+
* modification, ignoring whitespace.
|
|
108
|
+
*/
|
|
109
|
+
readonly lines: readonly PayloadChangeLine[];
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Payload comparison state. Pending and incomplete mean the comparison is
|
|
114
|
+
* unavailable, never that the request had no payload changes. An incomplete result
|
|
115
|
+
* with `findings` compared only some channels: the findings hold, and `reason`
|
|
116
|
+
* names what was not compared.
|
|
117
|
+
*/
|
|
118
|
+
export type GuardResult =
|
|
119
|
+
| { readonly status: "pending" }
|
|
120
|
+
| { readonly status: "complete"; readonly dispatch: Dispatch; readonly findings: readonly GuardFinding[] }
|
|
121
|
+
| {
|
|
122
|
+
readonly status: "incomplete";
|
|
123
|
+
readonly reason: string;
|
|
124
|
+
readonly dispatch?: Dispatch;
|
|
125
|
+
readonly findings?: readonly GuardFinding[];
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/** One captured request. Raw message content is process-local; never log or persist it. */
|
|
129
|
+
export interface RequestSnapshot {
|
|
130
|
+
/** Local capture number; later captures have larger IDs. A guard update keeps its ID. */
|
|
131
|
+
readonly id: number;
|
|
132
|
+
readonly origin: CaptureOrigin;
|
|
133
|
+
/** Session leaf at capture; `buildSessionProjection(entries, leafId)` rebuilds the baseline. */
|
|
134
|
+
readonly leafId: string | null;
|
|
135
|
+
readonly changes: StructuredChanges;
|
|
136
|
+
/** Effective forced prompt text, present only when it differs from the replayed prompt. */
|
|
137
|
+
readonly forcedPrompt?: string;
|
|
138
|
+
readonly guard: GuardResult;
|
|
139
|
+
/**
|
|
140
|
+
* Active tools Pi left out of the request for a tool's `prepareLoadout()`, as
|
|
141
|
+
* the run's `before_agent_start` reported them; absent when Pi hid none.
|
|
142
|
+
*/
|
|
143
|
+
readonly hiddenTools?: readonly string[];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Read access for consumers. */
|
|
147
|
+
export interface SnapshotReader {
|
|
148
|
+
/** The kept snapshot: the one with the highest ID published so far, of either origin. */
|
|
149
|
+
latest(): RequestSnapshot | undefined;
|
|
150
|
+
/** Report every publication, including guard updates; returns the unsubscribe function. */
|
|
151
|
+
subscribe(listener: (snapshot: RequestSnapshot) => void): () => void;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Keeps one snapshot: the latest by ID. A publication with the same or a
|
|
156
|
+
* higher ID replaces it, so a guard update replaces its own snapshot and the
|
|
157
|
+
* first real request's snapshot releases a probe snapshot. A publication with
|
|
158
|
+
* an older ID only notifies subscribers.
|
|
159
|
+
*/
|
|
160
|
+
export class SnapshotStore implements SnapshotReader {
|
|
161
|
+
private kept: RequestSnapshot | undefined;
|
|
162
|
+
private readonly listeners = new Set<(snapshot: RequestSnapshot) => void>();
|
|
163
|
+
|
|
164
|
+
/** Read the kept snapshot without transferring ownership. */
|
|
165
|
+
public latest(): RequestSnapshot | undefined {
|
|
166
|
+
return this.kept;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Observe every publication, even when a newer snapshot is already kept. */
|
|
170
|
+
public subscribe(listener: (snapshot: RequestSnapshot) => void): () => void {
|
|
171
|
+
this.listeners.add(listener);
|
|
172
|
+
return () => {
|
|
173
|
+
this.listeners.delete(listener);
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Keep a snapshot unless the kept one is newer, then notify subscribers. */
|
|
178
|
+
public publish(snapshot: RequestSnapshot): void {
|
|
179
|
+
if (this.kept === undefined || this.kept.id <= snapshot.id) this.kept = snapshot;
|
|
180
|
+
// Copy: a listener may unsubscribe while being notified
|
|
181
|
+
for (const listener of [...this.listeners]) listener(snapshot);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Drop the kept snapshot at session shutdown; subscriptions stay. */
|
|
185
|
+
public clear(): void {
|
|
186
|
+
this.kept = undefined;
|
|
187
|
+
}
|
|
188
|
+
}
|