@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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -2
  3. package/package.json +55 -5
  4. package/src/capture/adjustments.ts +649 -0
  5. package/src/capture/attribution.ts +25 -0
  6. package/src/capture/builder.ts +128 -0
  7. package/src/capture/diff.ts +514 -0
  8. package/src/capture/dispatch.ts +36 -0
  9. package/src/capture/guard.ts +287 -0
  10. package/src/capture/messages.ts +246 -0
  11. package/src/capture/payload.ts +584 -0
  12. package/src/capture/redact.ts +58 -0
  13. package/src/capture/register.ts +129 -0
  14. package/src/capture/request.ts +251 -0
  15. package/src/capture/tools.ts +69 -0
  16. package/src/capture/tracker.ts +71 -0
  17. package/src/command.ts +164 -0
  18. package/src/compaction.ts +57 -0
  19. package/src/config.ts +408 -0
  20. package/src/index.ts +191 -0
  21. package/src/injections.ts +670 -0
  22. package/src/measure.ts +887 -0
  23. package/src/message-preview.ts +72 -0
  24. package/src/model-identity.ts +48 -0
  25. package/src/model.ts +548 -0
  26. package/src/pi-version.ts +29 -0
  27. package/src/probe/failure.ts +34 -0
  28. package/src/probe/filter.ts +126 -0
  29. package/src/probe/silent-probe.ts +349 -0
  30. package/src/probe/token.ts +28 -0
  31. package/src/probe/trigger.ts +178 -0
  32. package/src/probe/view.ts +26 -0
  33. package/src/projection.ts +122 -0
  34. package/src/prompt-additions.ts +226 -0
  35. package/src/prompt-blocks.ts +80 -0
  36. package/src/replay.ts +234 -0
  37. package/src/settings.ts +62 -0
  38. package/src/snapshot.ts +188 -0
  39. package/src/system-prompt-rows.ts +311 -0
  40. package/src/text.ts +46 -0
  41. package/src/token-diff.ts +211 -0
  42. package/src/transcript.ts +19 -0
  43. package/src/ui/color.ts +52 -0
  44. package/src/ui/diff-preview.ts +181 -0
  45. package/src/ui/injections-model.ts +487 -0
  46. package/src/ui/injections-view.ts +1023 -0
  47. package/src/ui/json-preview.ts +41 -0
  48. package/src/ui/layout.ts +161 -0
  49. package/src/ui/markers.ts +253 -0
  50. package/src/ui/notes.ts +117 -0
  51. package/src/ui/search.ts +427 -0
  52. package/src/ui/section-preview.ts +267 -0
  53. package/src/ui/skill-preview.ts +83 -0
  54. package/src/ui/status-bar.ts +94 -0
  55. package/src/ui/usage-map.ts +157 -0
  56. package/src/ui/usage-preview.ts +202 -0
  57. package/src/ui/usage-view.ts +1428 -0
  58. package/src/ui/wheel.ts +65 -0
  59. 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
+ }
@@ -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
+ }
@@ -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
+ }