@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.
- package/LICENSE +21 -0
- package/README.md +112 -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 +455 -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 +218 -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 +378 -0
- package/src/ui/injections-view.ts +729 -0
- package/src/ui/json-preview.ts +41 -0
- package/src/ui/layout.ts +148 -0
- package/src/ui/markers.ts +253 -0
- package/src/ui/notes.ts +117 -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 +194 -0
- package/src/ui/usage-view.ts +1255 -0
- package/src/ui/wheel.ts +65 -0
- 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
|
+
}
|