@xprnvd/pi-context-view 0.0.0-stage → 0.7.2

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 (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -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 +455 -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 +218 -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 +378 -0
  46. package/src/ui/injections-view.ts +729 -0
  47. package/src/ui/json-preview.ts +41 -0
  48. package/src/ui/layout.ts +148 -0
  49. package/src/ui/markers.ts +253 -0
  50. package/src/ui/notes.ts +117 -0
  51. package/src/ui/section-preview.ts +267 -0
  52. package/src/ui/skill-preview.ts +83 -0
  53. package/src/ui/status-bar.ts +94 -0
  54. package/src/ui/usage-map.ts +157 -0
  55. package/src/ui/usage-preview.ts +194 -0
  56. package/src/ui/usage-view.ts +1255 -0
  57. package/src/ui/wheel.ts +65 -0
  58. package/src/usage.ts +461 -0
@@ -0,0 +1,128 @@
1
+ /**
2
+ * SnapshotBuilder: runs the deferred part of a capture off the request's
3
+ * critical path, then publishes the snapshot and its guard update.
4
+ */
5
+ import type { ConversationChange, GuardResult, RequestSnapshot, StructuredChanges } from "../snapshot.ts";
6
+ import { attributeMessage } from "./attribution.ts";
7
+ import { type ConversationEdit, diffConversation, diffSystemState } from "./diff.ts";
8
+ import { redactMessage } from "./redact.ts";
9
+ import { type CapturedRequest, resolveForcedPrompt } from "./request.ts";
10
+
11
+ /** Where built snapshots go; SnapshotStore in production. */
12
+ export interface SnapshotPublisher {
13
+ publish(snapshot: RequestSnapshot): void;
14
+ }
15
+
16
+ /** A capture whose diff has not run yet, with a guard that settled meanwhile. */
17
+ interface ScheduledBuild {
18
+ readonly handle: NodeJS.Immediate;
19
+ guard?: GuardResult;
20
+ /** The guard will not change after `guard`. */
21
+ released: boolean;
22
+ }
23
+
24
+ /**
25
+ * Defers each capture's diff, publishes its snapshot with guard `pending`, and
26
+ * publishes a copy with the same ID for every guard update until the guard is
27
+ * released. A guard that settles before the diff ran is published with the
28
+ * snapshot instead. Request copies and baselines are released when their
29
+ * snapshot is built.
30
+ */
31
+ export class SnapshotBuilder {
32
+ private readonly publisher: SnapshotPublisher;
33
+ private readonly scheduled = new Map<number, ScheduledBuild>();
34
+ /** Published snapshots whose guard may still change. */
35
+ private readonly open = new Map<number, RequestSnapshot>();
36
+
37
+ public constructor(publisher: SnapshotPublisher) {
38
+ this.publisher = publisher;
39
+ }
40
+
41
+ /** Schedule the diff of one capture; the caller's handler returns at once. */
42
+ public build(request: CapturedRequest): void {
43
+ const handle = setImmediate(() => this.publishChanges(request));
44
+ this.scheduled.set(request.id, { handle, released: false });
45
+ }
46
+
47
+ /** Publish a capture's guard, replacing its earlier guard; unknown or released IDs are ignored. */
48
+ public settleGuard(id: number, guard: GuardResult): void {
49
+ const scheduled = this.scheduled.get(id);
50
+ if (scheduled !== undefined) {
51
+ if (!scheduled.released) scheduled.guard = guard;
52
+ return;
53
+ }
54
+ const snapshot = this.open.get(id);
55
+ if (snapshot === undefined) return;
56
+ const updated = { ...snapshot, guard };
57
+ this.open.set(id, updated);
58
+ this.publisher.publish(updated);
59
+ }
60
+
61
+ /** Stop accepting guard updates for a capture: its guard is final. */
62
+ public release(id: number): void {
63
+ const scheduled = this.scheduled.get(id);
64
+ if (scheduled !== undefined) scheduled.released = true;
65
+ this.open.delete(id);
66
+ }
67
+
68
+ /** Cancel scheduled diffs and drop open guards, as at session shutdown. */
69
+ public clear(): void {
70
+ for (const { handle } of this.scheduled.values()) clearImmediate(handle);
71
+ this.scheduled.clear();
72
+ this.open.clear();
73
+ }
74
+
75
+ /** Diff, attribute, and publish one capture. */
76
+ private publishChanges(request: CapturedRequest): void {
77
+ const scheduled = this.scheduled.get(request.id);
78
+ this.scheduled.delete(request.id);
79
+ let snapshot: RequestSnapshot;
80
+ try {
81
+ snapshot = buildRequestSnapshot(request, scheduled?.guard ?? { status: "pending" });
82
+ } catch {
83
+ // Deferred work runs outside Pi's handler error reporting, where a throw
84
+ // would end the host process; an unexpected message shape skips the snapshot
85
+ return;
86
+ }
87
+ if (scheduled?.released !== true) this.open.set(snapshot.id, snapshot);
88
+ this.publisher.publish(snapshot);
89
+ }
90
+ }
91
+
92
+ /** Assemble an immutable snapshot whose retained messages are attributed and redacted. */
93
+ export function buildRequestSnapshot(request: CapturedRequest, guard: GuardResult): RequestSnapshot {
94
+ const baseline = request.baseline.messages.map(({ message }) => message);
95
+ const changes: StructuredChanges = {
96
+ conversation: diffConversation(request.conversation.baseline, request.conversation.request)
97
+ .map(toConversationChange),
98
+ // Replaying an already replayed system state returns it unchanged
99
+ system: diffSystemState(baseline, request.system === undefined ? [] : [request.system]),
100
+ };
101
+ const forcedPrompt = resolveForcedPrompt(request);
102
+ return {
103
+ id: request.id,
104
+ origin: request.origin,
105
+ leafId: request.baseline.leafId,
106
+ changes,
107
+ ...(forcedPrompt === undefined ? {} : { forcedPrompt }),
108
+ guard,
109
+ ...(request.hiddenTools === undefined ? {} : { hiddenTools: request.hiddenTools }),
110
+ };
111
+ }
112
+
113
+ /** Retained form of one edit: entry references, attribution, and redacted request messages only. */
114
+ function toConversationChange(edit: ConversationEdit): ConversationChange {
115
+ switch (edit.type) {
116
+ case "added":
117
+ return { type: "added", message: redactMessage(edit.message), attribution: attributeMessage(edit.message) };
118
+ case "modified":
119
+ return {
120
+ type: "modified",
121
+ entryId: edit.baseline.entryId,
122
+ message: redactMessage(edit.message),
123
+ attribution: attributeMessage(edit.message),
124
+ };
125
+ case "deleted":
126
+ return { type: "deleted", entryId: edit.baseline.entryId, attribution: attributeMessage(edit.baseline.message) };
127
+ }
128
+ }
@@ -0,0 +1,455 @@
1
+ /**
2
+ * Differ: compares a captured request with its baseline. System state and
3
+ * conversation are compared separately (see "Diff" in
4
+ * doc/architecture/capture.md). Pure functions over process-local data.
5
+ */
6
+ import { declarationsEqual, getCurrentSystemMessage, type SystemMessage, type Tool } from "@earendil-works/pi-ai";
7
+
8
+ import type { RequestMessage, SystemChange } from "../snapshot.ts";
9
+ import type { BaselineMessage } from "./request.ts";
10
+
11
+ /** Nesting depth beyond which a comparison leaves the decision to message keys. */
12
+ const MAX_COMPARE_DEPTH = 64;
13
+
14
+ /**
15
+ * A conversation edit before attribution and redaction. Messages are the
16
+ * process-local originals: `message` from the request, `baseline` from the
17
+ * session projection.
18
+ */
19
+ export type ConversationEdit =
20
+ | { readonly type: "added"; readonly message: RequestMessage }
21
+ | { readonly type: "modified"; readonly baseline: BaselineMessage; readonly message: RequestMessage }
22
+ | { readonly type: "deleted"; readonly baseline: BaselineMessage };
23
+
24
+ /** Non-system messages of both sides that remain after their equal ends are removed. */
25
+ export interface UnmatchedMessages {
26
+ /** Number of non-system messages both sides share at the start. */
27
+ readonly prefix: number;
28
+ readonly baseline: readonly BaselineMessage[];
29
+ readonly request: readonly RequestMessage[];
30
+ }
31
+
32
+ /**
33
+ * Compare the replayed system state of both sides. Replay makes Pi's collapsed
34
+ * leading system message equivalent to the sequence it replaced, so only a
35
+ * real difference in content, a section, or a declaration is reported.
36
+ */
37
+ export function diffSystemState(
38
+ baseline: readonly RequestMessage[],
39
+ captured: readonly RequestMessage[],
40
+ ): SystemChange[] {
41
+ const before = getCurrentSystemMessage(baseline);
42
+ const after = getCurrentSystemMessage(captured);
43
+ const changes: SystemChange[] = [];
44
+ const beforeContent = before === undefined ? "" : contentText(before.content);
45
+ const afterContent = after === undefined ? "" : contentText(after.content);
46
+ if (beforeContent !== afterContent) changes.push({ type: "content", text: afterContent });
47
+ changes.push(...diffSections(before?.sections ?? {}, after?.sections ?? {}));
48
+ changes.push(...diffTools(before?.toolsAdded ?? [], after?.toolsAdded ?? []));
49
+ return changes;
50
+ }
51
+
52
+ /**
53
+ * Align the non-system messages of both sides and classify the rest:
54
+ * unmatched request message addition
55
+ * unmatched baseline message deletion
56
+ * both with one role in one gap modification
57
+ * an exact copy of a deleted message deletion plus addition, as for a reorder
58
+ */
59
+ export function diffConversation(
60
+ baseline: readonly BaselineMessage[],
61
+ captured: readonly RequestMessage[],
62
+ ): ConversationEdit[] {
63
+ const before = baseline.filter(({ message }) => message.role !== "system");
64
+ const after = captured.filter((message) => message.role !== "system");
65
+ const keys = new MessageKeys();
66
+ const beforeKeys = before.map(({ message }) => keys.of(message));
67
+ const afterKeys = after.map((message) => keys.of(message));
68
+ const matches = alignSequences(beforeKeys, afterKeys);
69
+ const moved = findMovedKeys(beforeKeys, afterKeys, matches);
70
+
71
+ const edits: ConversationEdit[] = [];
72
+ let beforeIndex = 0;
73
+ let afterIndex = 0;
74
+ // A sentinel match after both ends closes the last gap
75
+ for (const match of [...matches, { before: before.length, after: after.length }]) {
76
+ const gap = { deleted: range(beforeIndex, match.before), added: range(afterIndex, match.after) };
77
+ edits.push(...classifyGap(gap, before, after, {
78
+ deleted: (index) => moved.has(beforeKeys[index]),
79
+ added: (index) => moved.has(afterKeys[index]),
80
+ }));
81
+ beforeIndex = match.before + 1;
82
+ afterIndex = match.after + 1;
83
+ }
84
+ return edits;
85
+ }
86
+
87
+ /**
88
+ * Canonical key of a message for alignment: role, `customType`, and the
89
+ * model-facing payload with sorted object keys. Volatile metadata such as
90
+ * timestamps, usage, `display`, and `details` is not part of it.
91
+ */
92
+ export function messageKey(message: RequestMessage): string {
93
+ return `${message.role}\u0000${customTypeKey(message)}\u0000${canonicalJson(modelFacingPart(message))}`;
94
+ }
95
+
96
+ /**
97
+ * Whether two messages have the same `messageKey()`, decided without building
98
+ * the keys for plain JSON data. Anything JSON would encode differently, such
99
+ * as functions, non-finite numbers, or class instances, falls back to the keys.
100
+ */
101
+ export function sameMessage(a: RequestMessage, b: RequestMessage): boolean {
102
+ if (a.role !== b.role || customTypeKey(a) !== customTypeKey(b)) return false;
103
+ const result = compareJson(modelFacingPart(a), modelFacingPart(b), 0);
104
+ return result === "unknown" ? messageKey(a) === messageKey(b) : result === "equal";
105
+ }
106
+
107
+ /**
108
+ * Remove the non-system messages that both sides share at the start and at
109
+ * the end. Alignment matches these ends first, so `diffConversation()` over
110
+ * the rest returns the same edits as over both whole sides. An unchanged
111
+ * request leaves nothing.
112
+ */
113
+ export function trimMatchedEnds(
114
+ baseline: readonly BaselineMessage[],
115
+ captured: readonly RequestMessage[],
116
+ ): UnmatchedMessages {
117
+ const before = baseline.filter(({ message }) => message.role !== "system");
118
+ const after = captured.filter((message) => message.role !== "system");
119
+ let prefix = 0;
120
+ while (prefix < before.length && prefix < after.length && sameMessage(before[prefix].message, after[prefix])) {
121
+ prefix++;
122
+ }
123
+ let suffix = 0;
124
+ while (suffix < before.length - prefix && suffix < after.length - prefix
125
+ && sameMessage(before[before.length - 1 - suffix].message, after[after.length - 1 - suffix])) suffix++;
126
+ return {
127
+ prefix,
128
+ baseline: before.slice(prefix, before.length - suffix),
129
+ request: after.slice(prefix, after.length - suffix),
130
+ };
131
+ }
132
+
133
+ // ============================================================================
134
+ // System state
135
+ // ============================================================================
136
+
137
+ /** Sections added or changed in `after`, then sections removed from `before`. */
138
+ function diffSections(
139
+ before: Readonly<Record<string, string | null>>,
140
+ after: Readonly<Record<string, string | null>>,
141
+ ): SystemChange[] {
142
+ const changes: SystemChange[] = [];
143
+ for (const [name, text] of Object.entries(after)) {
144
+ if (text !== null && before[name] !== text) changes.push({ type: "section", name, text });
145
+ }
146
+ for (const [name, text] of Object.entries(before)) {
147
+ if (text !== null && (after[name] === undefined || after[name] === null)) {
148
+ changes.push({ type: "section", name, text: null });
149
+ }
150
+ }
151
+ return changes;
152
+ }
153
+
154
+ /** Declarations added or redefined in `after`, then declarations removed from `before`. */
155
+ function diffTools(before: readonly Tool[], after: readonly Tool[]): SystemChange[] {
156
+ const beforeByName = new Map(before.map((tool) => [tool.name, tool]));
157
+ const afterNames = new Set(after.map((tool) => tool.name));
158
+ const changes: SystemChange[] = [];
159
+ for (const tool of after) {
160
+ const previous = beforeByName.get(tool.name);
161
+ if (previous === undefined || !declarationsEqual(previous, tool)) {
162
+ changes.push({ type: "tool", name: tool.name, declaration: tool });
163
+ }
164
+ }
165
+ for (const tool of before) {
166
+ if (!afterNames.has(tool.name)) changes.push({ type: "tool", name: tool.name, declaration: null });
167
+ }
168
+ return changes;
169
+ }
170
+
171
+ /** Plain text of system content; text blocks join with a newline, as Pi renders them. */
172
+ function contentText(content: SystemMessage["content"]): string {
173
+ return typeof content === "string" ? content : content.map((block) => block.text).join("\n");
174
+ }
175
+
176
+ // ============================================================================
177
+ // Conversation
178
+ // ============================================================================
179
+
180
+ /** Unmatched indexes between two consecutive aligned matches. */
181
+ interface Gap {
182
+ readonly deleted: readonly number[];
183
+ readonly added: readonly number[];
184
+ }
185
+
186
+ /** Predicates for gap members whose exact copy is unmatched on the other side. */
187
+ interface MovedMembers {
188
+ readonly deleted: (index: number) => boolean;
189
+ readonly added: (index: number) => boolean;
190
+ }
191
+
192
+ /**
193
+ * Pair each deletion with the next unpaired addition of the same role, keeping
194
+ * order. Moved messages are never paired: a reorder stays a deletion plus an
195
+ * addition. Deletions come first, then the gap's request messages in order.
196
+ */
197
+ function classifyGap(
198
+ gap: Gap,
199
+ before: readonly BaselineMessage[],
200
+ after: readonly RequestMessage[],
201
+ moved: MovedMembers,
202
+ ): ConversationEdit[] {
203
+ const pairs = new Map<number, number>();
204
+ let searchFrom = 0;
205
+ const unpairedDeletions: number[] = [];
206
+ for (const deleted of gap.deleted) {
207
+ const role = before[deleted].message.role;
208
+ const position = moved.deleted(deleted) ? -1 : gap.added.findIndex((added, candidate) =>
209
+ candidate >= searchFrom && !moved.added(added) && after[added].role === role);
210
+ if (position === -1) {
211
+ unpairedDeletions.push(deleted);
212
+ continue;
213
+ }
214
+ pairs.set(gap.added[position], deleted);
215
+ searchFrom = position + 1;
216
+ }
217
+ const edits: ConversationEdit[] = unpairedDeletions.map((index) => ({ type: "deleted", baseline: before[index] }));
218
+ for (const added of gap.added) {
219
+ const paired = pairs.get(added);
220
+ edits.push(paired === undefined
221
+ ? { type: "added", message: after[added] }
222
+ : { type: "modified", baseline: before[paired], message: after[added] });
223
+ }
224
+ return edits;
225
+ }
226
+
227
+ /**
228
+ * Keys that occur both among unmatched baseline and unmatched request messages:
229
+ * the request carries an exact copy of a message from another position.
230
+ */
231
+ function findMovedKeys(
232
+ beforeKeys: readonly number[],
233
+ afterKeys: readonly number[],
234
+ matches: readonly AlignedPair[],
235
+ ): Set<number> {
236
+ const matchedBefore = new Set(matches.map((match) => match.before));
237
+ const matchedAfter = new Set(matches.map((match) => match.after));
238
+ const unmatchedBefore = new Set(beforeKeys.filter((_key, index) => !matchedBefore.has(index)));
239
+ return new Set(afterKeys.filter((key, index) => !matchedAfter.has(index) && unmatchedBefore.has(key)));
240
+ }
241
+
242
+ /** Indexes from `start` up to, but not including, `end`. */
243
+ function range(start: number, end: number): number[] {
244
+ return Array.from({ length: Math.max(0, end - start) }, (_unused, offset) => start + offset);
245
+ }
246
+
247
+ /** Interns message keys as small integers, so alignment compares numbers, not long strings. */
248
+ class MessageKeys {
249
+ private readonly ids = new Map<string, number>();
250
+
251
+ /** The integer ID of a message's canonical key. */
252
+ public of(message: RequestMessage): number {
253
+ const key = messageKey(message);
254
+ let id = this.ids.get(key);
255
+ if (id === undefined) {
256
+ id = this.ids.size;
257
+ this.ids.set(key, id);
258
+ }
259
+ return id;
260
+ }
261
+ }
262
+
263
+ /** The `customType` part of a message key; empty for other roles. */
264
+ function customTypeKey(message: RequestMessage): string {
265
+ return message.role === "custom" ? `${message.customType}` : "";
266
+ }
267
+
268
+ /**
269
+ * The part of a message the model receives, before Pi's conversion. Unknown
270
+ * roles keep every field except the timestamp.
271
+ */
272
+ function modelFacingPart(message: RequestMessage): unknown {
273
+ switch (message.role) {
274
+ case "user":
275
+ case "assistant":
276
+ case "custom":
277
+ return message.content;
278
+ case "toolResult":
279
+ return {
280
+ toolCallId: message.toolCallId,
281
+ toolName: message.toolName,
282
+ content: message.content,
283
+ isError: message.isError,
284
+ };
285
+ case "branchSummary":
286
+ case "compactionSummary":
287
+ return message.summary;
288
+ default: {
289
+ const { timestamp: _timestamp, ...rest } = message;
290
+ return rest;
291
+ }
292
+ }
293
+ }
294
+
295
+ /** JSON with object keys sorted at every level, so key order never affects equality. */
296
+ export function canonicalJson(value: unknown): string {
297
+ return JSON.stringify(value, (_key, nested: unknown) => {
298
+ if (typeof nested !== "object" || nested === null || Array.isArray(nested)) return nested;
299
+ return Object.fromEntries(Object.entries(nested).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
300
+ }) ?? "";
301
+ }
302
+
303
+ /**
304
+ * Result of comparing two values as JSON:
305
+ * equal both encode to the same canonical JSON
306
+ * different their canonical JSON differs
307
+ * unknown a value is outside plain JSON data; compare the encoded keys
308
+ */
309
+ type JsonComparison = "equal" | "different" | "unknown";
310
+
311
+ /**
312
+ * Compare plain JSON data (strings, booleans, null, finite numbers, arrays,
313
+ * and plain objects) as `canonicalJson()` would, without encoding it. Object
314
+ * key order and `undefined` properties do not matter, as in the keys.
315
+ */
316
+ function compareJson(a: unknown, b: unknown, depth: number): JsonComparison {
317
+ if (typeof a === "string" || typeof a === "boolean") {
318
+ if (typeof b !== typeof a) return "unknown";
319
+ return a === b ? "equal" : "different";
320
+ }
321
+ if (typeof a === "number") {
322
+ if (typeof b !== "number" || !Number.isFinite(a) || !Number.isFinite(b)) return "unknown";
323
+ return a === b ? "equal" : "different";
324
+ }
325
+ if (a === null) return b === null ? "equal" : "unknown";
326
+ if (typeof a !== "object" || typeof b !== "object" || b === null || depth >= MAX_COMPARE_DEPTH) return "unknown";
327
+ if (Array.isArray(a) || Array.isArray(b)) {
328
+ return Array.isArray(a) && Array.isArray(b) ? compareJsonArrays(a, b, depth) : "unknown";
329
+ }
330
+ if (!isPlainObject(a) || !isPlainObject(b)) return "unknown";
331
+ return compareJsonObjects(a as Record<string, unknown>, b as Record<string, unknown>, depth);
332
+ }
333
+
334
+ /** Compare two arrays element by element; JSON keeps their order. */
335
+ function compareJsonArrays(a: readonly unknown[], b: readonly unknown[], depth: number): JsonComparison {
336
+ if (a.length !== b.length) return "different";
337
+ for (let index = 0; index < a.length; index++) {
338
+ const result = compareJson(a[index], b[index], depth + 1);
339
+ if (result !== "equal") return result;
340
+ }
341
+ return "equal";
342
+ }
343
+
344
+ /** Compare two plain objects by their defined properties, in any key order. */
345
+ function compareJsonObjects(
346
+ a: Readonly<Record<string, unknown>>,
347
+ b: Readonly<Record<string, unknown>>,
348
+ depth: number,
349
+ ): JsonComparison {
350
+ let unpaired = 0;
351
+ for (const key in a) {
352
+ if (a[key] === undefined) continue;
353
+ unpaired++;
354
+ const result = compareJson(a[key], b[key], depth + 1);
355
+ if (result !== "equal") return result;
356
+ }
357
+ for (const key in b) {
358
+ const value = b[key];
359
+ if (value === undefined) continue;
360
+ // JSON omits these, so an extra one would not make the keys differ
361
+ if (typeof value === "function" || typeof value === "symbol") return "unknown";
362
+ unpaired--;
363
+ }
364
+ return unpaired === 0 ? "equal" : "different";
365
+ }
366
+
367
+ /** Whether a value is an object literal, not a class instance whose JSON may differ. */
368
+ function isPlainObject(value: object): boolean {
369
+ const prototype: unknown = Object.getPrototypeOf(value);
370
+ return prototype === Object.prototype || prototype === null;
371
+ }
372
+
373
+ // ============================================================================
374
+ // Alignment
375
+ // ============================================================================
376
+
377
+ /** Indexes of one matched element in the baseline and the request. */
378
+ export interface AlignedPair {
379
+ readonly before: number;
380
+ readonly after: number;
381
+ }
382
+
383
+ /**
384
+ * Longest common subsequence of two key sequences, as ascending index pairs.
385
+ * The common prefix and suffix are matched directly; Myers' O((N+M)D) search
386
+ * aligns only the middle, which is small when few messages changed.
387
+ */
388
+ export function alignSequences(before: readonly number[], after: readonly number[]): AlignedPair[] {
389
+ let prefix = 0;
390
+ while (prefix < before.length && prefix < after.length && before[prefix] === after[prefix]) prefix++;
391
+ let suffix = 0;
392
+ while (suffix < before.length - prefix && suffix < after.length - prefix
393
+ && before[before.length - 1 - suffix] === after[after.length - 1 - suffix]) suffix++;
394
+
395
+ const pairs: AlignedPair[] = range(0, prefix).map((index) => ({ before: index, after: index }));
396
+ const middle = myersMatches(before.slice(prefix, before.length - suffix), after.slice(prefix, after.length - suffix));
397
+ pairs.push(...middle.map((pair) => ({ before: pair.before + prefix, after: pair.after + prefix })));
398
+ for (let offset = suffix; offset > 0; offset--) {
399
+ pairs.push({ before: before.length - offset, after: after.length - offset });
400
+ }
401
+ return pairs;
402
+ }
403
+
404
+ /** Myers' shortest edit script, backtracked into the matched pairs in ascending order. */
405
+ function myersMatches(before: readonly number[], after: readonly number[]): AlignedPair[] {
406
+ const n = before.length;
407
+ const m = after.length;
408
+ if (n === 0 || m === 0) return [];
409
+ const offset = n + m;
410
+ // furthest[offset + k]: the furthest x reached on diagonal k = x - y
411
+ let furthest = new Int32Array(2 * offset + 2);
412
+ const trace: Int32Array[] = [];
413
+ for (let depth = 0; depth <= offset; depth++) {
414
+ trace.push(furthest);
415
+ furthest = furthest.slice();
416
+ for (let k = -depth; k <= depth; k += 2) {
417
+ let x = followsInsertion(furthest, offset, k, depth) ? furthest[offset + k + 1] : furthest[offset + k - 1] + 1;
418
+ let y = x - k;
419
+ while (x < n && y < m && before[x] === after[y]) {
420
+ x++;
421
+ y++;
422
+ }
423
+ furthest[offset + k] = x;
424
+ if (x >= n && y >= m) return backtrack(trace, offset, n, m);
425
+ }
426
+ }
427
+ return [];
428
+ }
429
+
430
+ /** Whether diagonal `k` at `depth` continues from diagonal `k + 1` (an insertion) rather than `k - 1`. */
431
+ function followsInsertion(furthest: Int32Array, offset: number, k: number, depth: number): boolean {
432
+ return k === -depth || (k !== depth && furthest[offset + k - 1] < furthest[offset + k + 1]);
433
+ }
434
+
435
+ /** Walk the recorded search back from the end and collect the diagonal (matching) moves. */
436
+ function backtrack(trace: readonly Int32Array[], offset: number, n: number, m: number): AlignedPair[] {
437
+ const pairs: AlignedPair[] = [];
438
+ let x = n;
439
+ let y = m;
440
+ for (let depth = trace.length - 1; depth >= 0; depth--) {
441
+ const furthest = trace[depth];
442
+ const k = x - y;
443
+ const previousK = followsInsertion(furthest, offset, k, depth) ? k + 1 : k - 1;
444
+ const previousX = depth === 0 ? 0 : furthest[offset + previousK];
445
+ const previousY = depth === 0 ? 0 : previousX - previousK;
446
+ while (x > previousX && y > previousY) {
447
+ x--;
448
+ y--;
449
+ pairs.push({ before: x, after: y });
450
+ }
451
+ x = previousX;
452
+ y = previousY;
453
+ }
454
+ return pairs.reverse();
455
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * DispatchConfirmer: records the dispatch identity of each paired request once.
3
+ * Assistant `message_start` carries it; assistant `message_end` is the
4
+ * fallback. A cache-warm refresh has no assistant events, so it cannot confirm
5
+ * a paired request. Handlers only read the identity and return.
6
+ */
7
+
8
+ /** The paired request still waiting for its dispatch identity, if any. */
9
+ export class DispatchConfirmer<Request> {
10
+ private awaiting: Request | undefined;
11
+
12
+ /**
13
+ * Wait for the identity of a newly paired request. Returns the previous
14
+ * request when it never received one: each request has at most one payload.
15
+ */
16
+ public expect(request: Request): Request | undefined {
17
+ const previous = this.awaiting;
18
+ this.awaiting = request;
19
+ return previous;
20
+ }
21
+
22
+ /**
23
+ * Take the request still waiting, as when an assistant event confirms it or
24
+ * its run settles without an identity; each request is taken once.
25
+ */
26
+ public take(): Request | undefined {
27
+ const request = this.awaiting;
28
+ this.awaiting = undefined;
29
+ return request;
30
+ }
31
+
32
+ /** Forget the waiting request, as at session shutdown. */
33
+ public clear(): void {
34
+ this.awaiting = undefined;
35
+ }
36
+ }