pi-posthorse 0.10.17 → 0.10.19
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/package.json +1 -1
- package/src/note-provenance.ts +3 -2
- package/src/note-scopes.ts +8 -1
- package/src/note-snapshots.ts +14 -1
- package/src/note-store.ts +3 -6
- package/src/recovery-inputs.ts +255 -0
- package/src/recovery-records.ts +1 -0
- package/src/recovery.ts +28 -180
package/package.json
CHANGED
package/src/note-provenance.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { parseSessionLine } from "./session-files.ts";
|
|
|
7
7
|
import { sessionNoteTouches } from "./note-evidence.ts";
|
|
8
8
|
import { mapBatch, readManifest, snapshot, type SnapshotNote } from "./note-snapshots.ts";
|
|
9
9
|
import {
|
|
10
|
+
addressIn,
|
|
10
11
|
addressOf,
|
|
11
12
|
bindingsOf,
|
|
12
13
|
canonicalPath,
|
|
@@ -130,7 +131,7 @@ export async function resolveReference(
|
|
|
130
131
|
}
|
|
131
132
|
visited.add(directory);
|
|
132
133
|
if (existsSync(notePath(current, path))) {
|
|
133
|
-
return
|
|
134
|
+
return addressIn(current, path);
|
|
134
135
|
}
|
|
135
136
|
// A manifest includes later adoptions; the journal includes later writes and deletions.
|
|
136
137
|
const binding: NoteBinding | undefined =
|
|
@@ -204,7 +205,7 @@ function seedCandidates(
|
|
|
204
205
|
) {
|
|
205
206
|
selected.set(seed.path, {
|
|
206
207
|
...seed,
|
|
207
|
-
source:
|
|
208
|
+
source: addressIn(binding, seed.path),
|
|
208
209
|
cleared: false,
|
|
209
210
|
});
|
|
210
211
|
}
|
package/src/note-scopes.ts
CHANGED
|
@@ -83,6 +83,13 @@ export function forkScope(owner: NoteOwner): string {
|
|
|
83
83
|
export function scopeDirectory(scope: NoteScope): string {
|
|
84
84
|
return join(scope.root, scope.scope);
|
|
85
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* An address is stored in the journal, and a scope value is often a whole binding. Build an
|
|
88
|
+
* address here, never by spreading a scope, so it holds the two location fields and nothing else.
|
|
89
|
+
*/
|
|
90
|
+
export function addressIn(scope: NoteScope, path: string): NoteAddress {
|
|
91
|
+
return { root: scope.root, scope: scope.scope, path };
|
|
92
|
+
}
|
|
86
93
|
export function sameScope(left: NoteScope, right: NoteScope): boolean {
|
|
87
94
|
return left.root === right.root && left.scope === right.scope;
|
|
88
95
|
}
|
|
@@ -278,7 +285,7 @@ export function addressOf(path: string, scopes: readonly NoteScope[]): NoteAddre
|
|
|
278
285
|
? undefined
|
|
279
286
|
: candidates.find((item) => item.directory === match.directory);
|
|
280
287
|
if (match !== undefined && candidate !== undefined) {
|
|
281
|
-
return
|
|
288
|
+
return addressIn(candidate.scope, match.path);
|
|
282
289
|
}
|
|
283
290
|
if (
|
|
284
291
|
scopes.some(
|
package/src/note-snapshots.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
|
18
18
|
import { isRecord } from "./contracts.ts";
|
|
19
19
|
import { publishFile } from "./files.ts";
|
|
20
20
|
import {
|
|
21
|
+
addressIn,
|
|
21
22
|
notePath,
|
|
22
23
|
parseBinding,
|
|
23
24
|
sameOwner,
|
|
@@ -127,7 +128,19 @@ async function copyNote(note: SnapshotNote, destination: string): Promise<CopyRe
|
|
|
127
128
|
}
|
|
128
129
|
await utimes(destination, before.atimeMs / 1000, before.mtimeMs / 1000);
|
|
129
130
|
const [digest, destInfo] = await Promise.all([fileDigest(destination), stat(destination)]);
|
|
130
|
-
return {
|
|
131
|
+
return {
|
|
132
|
+
kind: "verified",
|
|
133
|
+
// Journals written by 0.10.4 through 0.10.17 hold whole bindings in seed addresses.
|
|
134
|
+
// A new seed keeps each location only, so that data is not copied into this fork.
|
|
135
|
+
seed: {
|
|
136
|
+
path: note.path,
|
|
137
|
+
source: addressIn(note.source, note.source.path),
|
|
138
|
+
lineage: addressIn(note.lineage, note.lineage.path),
|
|
139
|
+
digest,
|
|
140
|
+
identity: fileIdentity(destInfo),
|
|
141
|
+
anchors: note.anchors,
|
|
142
|
+
},
|
|
143
|
+
};
|
|
131
144
|
} catch (error) {
|
|
132
145
|
return {
|
|
133
146
|
kind: "unverified",
|
package/src/note-store.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import {
|
|
14
14
|
NOTES_BINDING,
|
|
15
15
|
NOTES_TOUCH,
|
|
16
|
+
addressIn,
|
|
16
17
|
bindingsOf,
|
|
17
18
|
canonicalPath,
|
|
18
19
|
inside,
|
|
@@ -154,7 +155,7 @@ export class SessionNotes {
|
|
|
154
155
|
if (options.scope !== undefined) {
|
|
155
156
|
const scope = await this.selectScope(options.scope);
|
|
156
157
|
return {
|
|
157
|
-
address:
|
|
158
|
+
address: addressIn(scope, relative),
|
|
158
159
|
path: notePath(scope, relative),
|
|
159
160
|
writable: sameScope(scope, this.binding),
|
|
160
161
|
};
|
|
@@ -162,11 +163,7 @@ export class SessionNotes {
|
|
|
162
163
|
const reference = this.owns(relative)
|
|
163
164
|
? undefined
|
|
164
165
|
: await this.#reference(relative, options.signal);
|
|
165
|
-
const address = reference ??
|
|
166
|
-
root: this.binding.root,
|
|
167
|
-
scope: this.binding.scope,
|
|
168
|
-
path: relative,
|
|
169
|
-
};
|
|
166
|
+
const address = reference ?? addressIn(this.binding, relative);
|
|
170
167
|
return { address, path: notePath(address, relative), writable: reference === undefined };
|
|
171
168
|
}
|
|
172
169
|
async adopt(path: string, call: NoteCall): Promise<Adoption> {
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { AUTO_HANDOFF_PREFIX, isRecord, type EntryLike, type ProjectedEntry } from "./contracts.ts";
|
|
2
|
+
import { contextEdits, contextStart, windowHandoff } from "./history-entries.ts";
|
|
3
|
+
import { toolCalls } from "./message-text.ts";
|
|
4
|
+
import {
|
|
5
|
+
formatRecord,
|
|
6
|
+
MAX_RECOVERY_RECORD_CHARS,
|
|
7
|
+
recoveryRecord,
|
|
8
|
+
type RecoveryRecord,
|
|
9
|
+
} from "./recovery-records.ts";
|
|
10
|
+
|
|
11
|
+
/** One input's room as a share of the whole record: 4,000 of the 20,000 characters a record may have. */
|
|
12
|
+
const RECORD_SHARE = 5;
|
|
13
|
+
|
|
14
|
+
function projectedCurrent(
|
|
15
|
+
entries: readonly EntryLike[],
|
|
16
|
+
projected: readonly ProjectedEntry[],
|
|
17
|
+
current: readonly EntryLike[] = entries.slice(contextStart(entries).start),
|
|
18
|
+
retained: ReadonlySet<string> = new Set(),
|
|
19
|
+
): EntryLike[] {
|
|
20
|
+
const edits = contextEdits(entries);
|
|
21
|
+
const omittedIds = new Set(
|
|
22
|
+
projected
|
|
23
|
+
.filter(({ messages }) => messages.length === 0)
|
|
24
|
+
.map(({ sourceEntry }) => sourceEntry.id),
|
|
25
|
+
);
|
|
26
|
+
return current.flatMap((entry) => {
|
|
27
|
+
const id = entry.id ?? "";
|
|
28
|
+
if (retained.has(id) || omittedIds.has(id)) {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
const edit = edits.get(id);
|
|
32
|
+
if (edit?.replacement === null) {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
if (edit?.replacement === undefined) {
|
|
36
|
+
return [entry];
|
|
37
|
+
}
|
|
38
|
+
if (entry.type === "message") {
|
|
39
|
+
return [
|
|
40
|
+
{
|
|
41
|
+
...entry,
|
|
42
|
+
id: edit.id,
|
|
43
|
+
message: { ...entry.message, content: edit.replacement.content },
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
if (entry.type === "custom_message") {
|
|
48
|
+
return [{ ...entry, id: edit.id, content: edit.replacement.content }];
|
|
49
|
+
}
|
|
50
|
+
return [entry];
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
function ownerRecords(
|
|
54
|
+
entries: readonly EntryLike[],
|
|
55
|
+
projected: readonly ProjectedEntry[],
|
|
56
|
+
ownerQuestionRegistered: boolean,
|
|
57
|
+
): RecoveryRecord[] {
|
|
58
|
+
const calls = new Map(
|
|
59
|
+
projected.flatMap(({ messages }) =>
|
|
60
|
+
messages.flatMap((message) =>
|
|
61
|
+
message.role === "assistant"
|
|
62
|
+
? toolCalls(message.content).map((call) => [call.id, call] as const)
|
|
63
|
+
: [],
|
|
64
|
+
),
|
|
65
|
+
),
|
|
66
|
+
);
|
|
67
|
+
return entries.flatMap((entry) => {
|
|
68
|
+
const call = calls.get(entry.message?.toolCallId);
|
|
69
|
+
const record = recoveryRecord(
|
|
70
|
+
entry,
|
|
71
|
+
ownerQuestionRegistered &&
|
|
72
|
+
call?.name === "ask_question" &&
|
|
73
|
+
call.namespace === undefined,
|
|
74
|
+
);
|
|
75
|
+
return record === undefined ? [] : [record];
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
function anchors(records: readonly RecoveryRecord[]): {
|
|
79
|
+
fixed: RecoveryRecord[];
|
|
80
|
+
others: RecoveryRecord[];
|
|
81
|
+
} {
|
|
82
|
+
const firstOwner = records.find((record) => record.label === "owner input") ?? records[0];
|
|
83
|
+
const latestOwner = [...records].reverse().find((record) => record.kind === "owner");
|
|
84
|
+
const latestOverall = records.at(-1);
|
|
85
|
+
const selected = new Set([firstOwner, latestOwner, latestOverall]);
|
|
86
|
+
return {
|
|
87
|
+
fixed: records.filter((record) => selected.has(record)),
|
|
88
|
+
others: records.filter((record) => !selected.has(record)),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function automaticBoundary(entry: EntryLike | undefined): boolean {
|
|
92
|
+
if (
|
|
93
|
+
entry === undefined ||
|
|
94
|
+
windowHandoff(entry)?.trim().startsWith(AUTO_HANDOFF_PREFIX) !== true
|
|
95
|
+
) {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
return (
|
|
99
|
+
entry.type === "context_window" ||
|
|
100
|
+
(isRecord(entry.details) &&
|
|
101
|
+
entry.details.posthorse === 1 &&
|
|
102
|
+
(entry.details.reason === "threshold" || entry.details.reason === "overflow"))
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
export function automaticChainStart(entries: readonly EntryLike[]): number {
|
|
106
|
+
let prefix = entries;
|
|
107
|
+
let window = contextStart(prefix);
|
|
108
|
+
while (automaticBoundary(window.boundary) && window.boundary !== undefined) {
|
|
109
|
+
prefix = prefix.slice(0, prefix.indexOf(window.boundary));
|
|
110
|
+
window = contextStart(prefix);
|
|
111
|
+
}
|
|
112
|
+
return window.start;
|
|
113
|
+
}
|
|
114
|
+
function windowOwners(
|
|
115
|
+
entries: readonly EntryLike[],
|
|
116
|
+
projected: readonly ProjectedEntry[],
|
|
117
|
+
window: readonly EntryLike[],
|
|
118
|
+
ownerQuestionRegistered: boolean,
|
|
119
|
+
): RecoveryRecord[] {
|
|
120
|
+
const restored = projectedCurrent(entries, projected, window);
|
|
121
|
+
const historical = restored.map((sourceEntry) => ({
|
|
122
|
+
sourceEntry,
|
|
123
|
+
messages: sourceEntry.message === undefined ? [] : [sourceEntry.message],
|
|
124
|
+
}));
|
|
125
|
+
return ownerRecords(restored, historical, ownerQuestionRegistered).filter(
|
|
126
|
+
(record) => record.kind === "owner",
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
/** The windows before the current one, newest first, as far back as automatic boundaries reach. */
|
|
130
|
+
function* earlierWindows(entries: readonly EntryLike[]): Generator<{
|
|
131
|
+
readonly closedBy: EntryLike;
|
|
132
|
+
readonly entries: readonly EntryLike[];
|
|
133
|
+
readonly openedBy: EntryLike | undefined;
|
|
134
|
+
}> {
|
|
135
|
+
let prefix = entries;
|
|
136
|
+
let boundary = contextStart(prefix).boundary;
|
|
137
|
+
while (boundary !== undefined && automaticBoundary(boundary)) {
|
|
138
|
+
prefix = prefix.slice(0, prefix.indexOf(boundary));
|
|
139
|
+
const window = contextStart(prefix);
|
|
140
|
+
yield {
|
|
141
|
+
closedBy: boundary,
|
|
142
|
+
entries: prefix.slice(window.start),
|
|
143
|
+
openedBy: window.boundary,
|
|
144
|
+
};
|
|
145
|
+
boundary = window.boundary;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/** A window's owner inputs that still fit the room, taken newest first and returned oldest first. */
|
|
149
|
+
function newestThatFit(
|
|
150
|
+
owners: readonly RecoveryRecord[],
|
|
151
|
+
room: number,
|
|
152
|
+
seen: Set<string>,
|
|
153
|
+
): { readonly records: readonly RecoveryRecord[]; readonly used: number } {
|
|
154
|
+
const records: RecoveryRecord[] = [];
|
|
155
|
+
let used = 0;
|
|
156
|
+
for (const owner of owners.toReversed()) {
|
|
157
|
+
if (used >= room) {
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
if (seen.has(owner.id)) {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const record = {
|
|
164
|
+
...owner,
|
|
165
|
+
label: `${owner.label}; carried from an earlier window, possibly already handled; not current progress`,
|
|
166
|
+
};
|
|
167
|
+
seen.add(owner.id);
|
|
168
|
+
records.unshift(record);
|
|
169
|
+
used += formatRecord(record, Number.MAX_SAFE_INTEGER).length;
|
|
170
|
+
}
|
|
171
|
+
return { records, used };
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Earlier windows give a record the owner's most recent inputs, newest first, until they fill the
|
|
175
|
+
* room of one input. Owner text the current window already holds counts against that room, so a
|
|
176
|
+
* short follow-up keeps the task before it and a long new request stands alone.
|
|
177
|
+
*/
|
|
178
|
+
function recoveryAncestry(
|
|
179
|
+
entries: readonly EntryLike[],
|
|
180
|
+
projected: readonly ProjectedEntry[],
|
|
181
|
+
ownerQuestionRegistered: boolean,
|
|
182
|
+
options: { readonly current: readonly RecoveryRecord[]; readonly maxChars: number },
|
|
183
|
+
): {
|
|
184
|
+
readonly checkpoint?: EntryLike | undefined;
|
|
185
|
+
readonly carried: readonly RecoveryRecord[];
|
|
186
|
+
readonly skipped: readonly string[];
|
|
187
|
+
} {
|
|
188
|
+
const held = options.current
|
|
189
|
+
.filter((record) => record.kind === "owner")
|
|
190
|
+
.reduce((chars, record) => chars + record.text.length, 0);
|
|
191
|
+
let room =
|
|
192
|
+
Math.min(MAX_RECOVERY_RECORD_CHARS, Math.floor(options.maxChars / RECORD_SHARE)) - held;
|
|
193
|
+
const seen = new Set(options.current.map((record) => record.id));
|
|
194
|
+
const carried: RecoveryRecord[] = [];
|
|
195
|
+
const skipped: EntryLike[] = [];
|
|
196
|
+
let ownerWindowFound = false;
|
|
197
|
+
let reached = contextStart(entries).boundary;
|
|
198
|
+
for (const window of earlierWindows(entries)) {
|
|
199
|
+
if (ownerWindowFound && room <= 0) {
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
if (!ownerWindowFound) {
|
|
203
|
+
skipped.push(window.closedBy);
|
|
204
|
+
}
|
|
205
|
+
const owners = windowOwners(entries, projected, window.entries, ownerQuestionRegistered);
|
|
206
|
+
ownerWindowFound ||= owners.length > 0;
|
|
207
|
+
const fit = newestThatFit(owners, room, seen);
|
|
208
|
+
carried.unshift(...fit.records);
|
|
209
|
+
room -= fit.used;
|
|
210
|
+
reached = window.openedBy;
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
checkpoint: automaticBoundary(reached) ? undefined : reached,
|
|
214
|
+
carried,
|
|
215
|
+
skipped: skipped.map((boundary) => boundary.id?.slice(0, 120) ?? "unknown"),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
/** The inputs a record quotes in full (oldest first), the ones it only lists, and where its walk back ended. */
|
|
219
|
+
export function recoveryInputs(
|
|
220
|
+
entries: readonly EntryLike[],
|
|
221
|
+
projected: readonly ProjectedEntry[],
|
|
222
|
+
options: {
|
|
223
|
+
readonly ownerQuestionRegistered: boolean;
|
|
224
|
+
readonly retained: ReadonlySet<string>;
|
|
225
|
+
readonly maxChars: number;
|
|
226
|
+
},
|
|
227
|
+
): {
|
|
228
|
+
readonly fixed: readonly RecoveryRecord[];
|
|
229
|
+
readonly others: readonly RecoveryRecord[];
|
|
230
|
+
readonly checkpoint?: EntryLike | undefined;
|
|
231
|
+
readonly skipped: readonly string[];
|
|
232
|
+
} {
|
|
233
|
+
const { ownerQuestionRegistered, retained, maxChars } = options;
|
|
234
|
+
const { carried, checkpoint, skipped } = recoveryAncestry(
|
|
235
|
+
entries,
|
|
236
|
+
projected,
|
|
237
|
+
ownerQuestionRegistered,
|
|
238
|
+
{
|
|
239
|
+
current: ownerRecords(
|
|
240
|
+
projectedCurrent(entries, projected),
|
|
241
|
+
projected,
|
|
242
|
+
ownerQuestionRegistered,
|
|
243
|
+
),
|
|
244
|
+
maxChars,
|
|
245
|
+
},
|
|
246
|
+
);
|
|
247
|
+
const { fixed, others } = anchors(
|
|
248
|
+
ownerRecords(
|
|
249
|
+
projectedCurrent(entries, projected, undefined, retained),
|
|
250
|
+
projected,
|
|
251
|
+
ownerQuestionRegistered,
|
|
252
|
+
),
|
|
253
|
+
);
|
|
254
|
+
return { fixed: [...carried, ...fixed], others, checkpoint, skipped };
|
|
255
|
+
}
|
package/src/recovery-records.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isReminderType, type EntryLike } from "./contracts.ts";
|
|
2
2
|
import { boundedBlock, imageSummary, imagesOf, textOf } from "./message-text.ts";
|
|
3
3
|
|
|
4
|
+
export const MAX_RECOVERY_RECORD_CHARS = 4_000;
|
|
4
5
|
export type RecoveryRecord = {
|
|
5
6
|
readonly id: string;
|
|
6
7
|
readonly timestamp: string;
|
package/src/recovery.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { AUTO_HANDOFF_PREFIX,
|
|
1
|
+
import { AUTO_HANDOFF_PREFIX, type EntryLike, type ProjectedEntry } from "./contracts.ts";
|
|
2
2
|
import { contextEdits, contextStart, windowHandoff } from "./history-entries.ts";
|
|
3
|
-
import { boundedBlock, excerpt
|
|
3
|
+
import { boundedBlock, excerpt } from "./message-text.ts";
|
|
4
|
+
import { automaticChainStart, recoveryInputs } from "./recovery-inputs.ts";
|
|
4
5
|
import {
|
|
5
6
|
formatRecord,
|
|
6
7
|
indexLine,
|
|
7
|
-
|
|
8
|
+
MAX_RECOVERY_RECORD_CHARS,
|
|
8
9
|
type RecoveryRecord,
|
|
9
10
|
} from "./recovery-records.ts";
|
|
10
11
|
import type { NotesRecovery } from "./note-evidence.ts";
|
|
@@ -16,7 +17,6 @@ import {
|
|
|
16
17
|
type RecoveryEntry,
|
|
17
18
|
} from "./recovery-results.ts";
|
|
18
19
|
|
|
19
|
-
const MAX_RECOVERY_RECORD_CHARS = 4_000;
|
|
20
20
|
const MAX_RECOVERY_RESULT_CHARS = 1_500;
|
|
21
21
|
const HANDOFF_OVERHEAD_RESERVE = 1_000;
|
|
22
22
|
const MAX_NOTES_SECTION_CHARS = 1_200;
|
|
@@ -69,142 +69,6 @@ function priorCheckpoint(entry: EntryLike | undefined, limit: number): string |
|
|
|
69
69
|
const header = `[older checkpoint; possibly stale | context-window entry ${id}]`;
|
|
70
70
|
return boundedBlock(header, handoff, limit);
|
|
71
71
|
}
|
|
72
|
-
function projectedCurrent(
|
|
73
|
-
entries: readonly EntryLike[],
|
|
74
|
-
projected: readonly ProjectedEntry[],
|
|
75
|
-
current: readonly EntryLike[] = entries.slice(contextStart(entries).start),
|
|
76
|
-
retained: ReadonlySet<string> = new Set(),
|
|
77
|
-
): EntryLike[] {
|
|
78
|
-
const edits = contextEdits(entries);
|
|
79
|
-
const omittedIds = new Set(
|
|
80
|
-
projected
|
|
81
|
-
.filter(({ messages }) => messages.length === 0)
|
|
82
|
-
.map(({ sourceEntry }) => sourceEntry.id),
|
|
83
|
-
);
|
|
84
|
-
return current.flatMap((entry) => {
|
|
85
|
-
const id = entry.id ?? "";
|
|
86
|
-
if (retained.has(id) || omittedIds.has(id)) {
|
|
87
|
-
return [];
|
|
88
|
-
}
|
|
89
|
-
const edit = edits.get(id);
|
|
90
|
-
if (edit?.replacement === null) {
|
|
91
|
-
return [];
|
|
92
|
-
}
|
|
93
|
-
if (edit?.replacement === undefined) {
|
|
94
|
-
return [entry];
|
|
95
|
-
}
|
|
96
|
-
if (entry.type === "message") {
|
|
97
|
-
return [
|
|
98
|
-
{
|
|
99
|
-
...entry,
|
|
100
|
-
id: edit.id,
|
|
101
|
-
message: { ...entry.message, content: edit.replacement.content },
|
|
102
|
-
},
|
|
103
|
-
];
|
|
104
|
-
}
|
|
105
|
-
if (entry.type === "custom_message") {
|
|
106
|
-
return [{ ...entry, id: edit.id, content: edit.replacement.content }];
|
|
107
|
-
}
|
|
108
|
-
return [entry];
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
function ownerRecords(
|
|
112
|
-
entries: readonly EntryLike[],
|
|
113
|
-
projected: readonly ProjectedEntry[],
|
|
114
|
-
ownerQuestionRegistered: boolean,
|
|
115
|
-
): RecoveryRecord[] {
|
|
116
|
-
const calls = new Map(
|
|
117
|
-
projected.flatMap(({ messages }) =>
|
|
118
|
-
messages.flatMap((message) =>
|
|
119
|
-
message.role === "assistant"
|
|
120
|
-
? toolCalls(message.content).map((call) => [call.id, call] as const)
|
|
121
|
-
: [],
|
|
122
|
-
),
|
|
123
|
-
),
|
|
124
|
-
);
|
|
125
|
-
return entries.flatMap((entry) => {
|
|
126
|
-
const call = calls.get(entry.message?.toolCallId);
|
|
127
|
-
const record = recoveryRecord(
|
|
128
|
-
entry,
|
|
129
|
-
ownerQuestionRegistered &&
|
|
130
|
-
call?.name === "ask_question" &&
|
|
131
|
-
call.namespace === undefined,
|
|
132
|
-
);
|
|
133
|
-
return record === undefined ? [] : [record];
|
|
134
|
-
});
|
|
135
|
-
}
|
|
136
|
-
function anchors(records: readonly RecoveryRecord[]): {
|
|
137
|
-
fixed: RecoveryRecord[];
|
|
138
|
-
others: RecoveryRecord[];
|
|
139
|
-
} {
|
|
140
|
-
const firstOwner = records.find((record) => record.label === "owner input") ?? records[0];
|
|
141
|
-
const latestOwner = [...records].reverse().find((record) => record.kind === "owner");
|
|
142
|
-
const latestOverall = records.at(-1);
|
|
143
|
-
const selected = new Set([firstOwner, latestOwner, latestOverall]);
|
|
144
|
-
return {
|
|
145
|
-
fixed: records.filter((record) => selected.has(record)),
|
|
146
|
-
others: records.filter((record) => !selected.has(record)),
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
function automaticBoundary(entry: EntryLike | undefined): boolean {
|
|
150
|
-
if (
|
|
151
|
-
entry === undefined ||
|
|
152
|
-
windowHandoff(entry)?.trim().startsWith(AUTO_HANDOFF_PREFIX) !== true
|
|
153
|
-
) {
|
|
154
|
-
return false;
|
|
155
|
-
}
|
|
156
|
-
return (
|
|
157
|
-
entry.type === "context_window" ||
|
|
158
|
-
(isRecord(entry.details) &&
|
|
159
|
-
entry.details.posthorse === 1 &&
|
|
160
|
-
(entry.details.reason === "threshold" || entry.details.reason === "overflow"))
|
|
161
|
-
);
|
|
162
|
-
}
|
|
163
|
-
function automaticChainStart(entries: readonly EntryLike[]): number {
|
|
164
|
-
let prefix = entries;
|
|
165
|
-
let window = contextStart(prefix);
|
|
166
|
-
while (automaticBoundary(window.boundary) && window.boundary !== undefined) {
|
|
167
|
-
prefix = prefix.slice(0, prefix.indexOf(window.boundary));
|
|
168
|
-
window = contextStart(prefix);
|
|
169
|
-
}
|
|
170
|
-
return window.start;
|
|
171
|
-
}
|
|
172
|
-
function recoveryAncestry(
|
|
173
|
-
entries: readonly EntryLike[],
|
|
174
|
-
projected: readonly ProjectedEntry[],
|
|
175
|
-
ownerQuestionRegistered: boolean,
|
|
176
|
-
retained: ReadonlySet<string>,
|
|
177
|
-
): {
|
|
178
|
-
readonly checkpoint?: EntryLike | undefined;
|
|
179
|
-
readonly carried: readonly RecoveryRecord[];
|
|
180
|
-
readonly skipped: readonly string[];
|
|
181
|
-
} {
|
|
182
|
-
let prefix = entries;
|
|
183
|
-
let boundary = contextStart(prefix).boundary;
|
|
184
|
-
const skipped: string[] = [];
|
|
185
|
-
while (automaticBoundary(boundary) && boundary !== undefined) {
|
|
186
|
-
skipped.push(boundary.id?.slice(0, 120) ?? "unknown");
|
|
187
|
-
prefix = prefix.slice(0, prefix.indexOf(boundary));
|
|
188
|
-
const window = contextStart(prefix);
|
|
189
|
-
const restored = projectedCurrent(entries, projected, prefix.slice(window.start));
|
|
190
|
-
const historical = restored.map((sourceEntry) => ({
|
|
191
|
-
sourceEntry,
|
|
192
|
-
messages: sourceEntry.message === undefined ? [] : [sourceEntry.message],
|
|
193
|
-
}));
|
|
194
|
-
const owners = ownerRecords(restored, historical, ownerQuestionRegistered).filter(
|
|
195
|
-
(record) => record.kind === "owner",
|
|
196
|
-
);
|
|
197
|
-
if (owners.length > 0) {
|
|
198
|
-
return {
|
|
199
|
-
checkpoint: automaticBoundary(window.boundary) ? undefined : window.boundary,
|
|
200
|
-
carried: anchors(owners.filter((record) => !retained.has(record.id))).fixed,
|
|
201
|
-
skipped,
|
|
202
|
-
};
|
|
203
|
-
}
|
|
204
|
-
boundary = window.boundary;
|
|
205
|
-
}
|
|
206
|
-
return { checkpoint: boundary, carried: [], skipped };
|
|
207
|
-
}
|
|
208
72
|
function skippedRecords(ids: readonly string[], limit: number): string | undefined {
|
|
209
73
|
const remaining = ids.values();
|
|
210
74
|
const first = remaining.next();
|
|
@@ -346,34 +210,20 @@ function renderIndex(records: readonly RecoveryRecord[], available: number): str
|
|
|
346
210
|
...listed,
|
|
347
211
|
].join("\n");
|
|
348
212
|
}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
const records = ownerRecords(
|
|
362
|
-
projectedCurrent(entries, projected, undefined, retained),
|
|
363
|
-
projected,
|
|
364
|
-
ownerQuestionRegistered,
|
|
365
|
-
);
|
|
366
|
-
const { fixed, others } = anchors(records);
|
|
367
|
-
if (!allRecords.some((record) => record.kind === "owner")) {
|
|
368
|
-
fixed.unshift(
|
|
369
|
-
...carried.map((record) =>
|
|
370
|
-
Object.assign({}, record, {
|
|
371
|
-
label: `${record.label}; carried from an earlier window, possibly already handled; not current progress`,
|
|
372
|
-
}),
|
|
373
|
-
),
|
|
374
|
-
);
|
|
213
|
+
/** The per-block limit at which the blocks fit the budget together; short blocks leave room to long ones. */
|
|
214
|
+
function sharedLimit(lengths: readonly number[], budget: number): number {
|
|
215
|
+
let remaining = budget;
|
|
216
|
+
const ascending = lengths
|
|
217
|
+
.map((length) => Math.min(length, MAX_RECOVERY_RECORD_CHARS))
|
|
218
|
+
.toSorted((a, b) => a - b);
|
|
219
|
+
for (const [index, length] of ascending.entries()) {
|
|
220
|
+
const share = Math.floor(remaining / (ascending.length - index));
|
|
221
|
+
if (length > share) {
|
|
222
|
+
return share;
|
|
223
|
+
}
|
|
224
|
+
remaining -= length;
|
|
375
225
|
}
|
|
376
|
-
return
|
|
226
|
+
return MAX_RECOVERY_RECORD_CHARS;
|
|
377
227
|
}
|
|
378
228
|
function rolloverPreambleWithNotes(
|
|
379
229
|
entries: readonly EntryLike[],
|
|
@@ -417,13 +267,12 @@ export function buildAutoHandoff(
|
|
|
417
267
|
},
|
|
418
268
|
): string {
|
|
419
269
|
const { ownerQuestionRegistered, retainedEntryIds: retained = new Set<string>() } = options;
|
|
420
|
-
const history =
|
|
421
|
-
const priorWindow = history.checkpoint;
|
|
422
|
-
const { fixed, others } = selectedInputs(entries, projected, {
|
|
270
|
+
const { fixed, others, ...history } = recoveryInputs(entries, projected, {
|
|
423
271
|
ownerQuestionRegistered,
|
|
424
272
|
retained,
|
|
425
|
-
|
|
273
|
+
maxChars,
|
|
426
274
|
});
|
|
275
|
+
const priorWindow = history.checkpoint;
|
|
427
276
|
const emptyInputHeader =
|
|
428
277
|
retained.size > 0
|
|
429
278
|
? "No discarded owner or visible coordination inputs were selected for this recovery record."
|
|
@@ -453,19 +302,18 @@ export function buildAutoHandoff(
|
|
|
453
302
|
header,
|
|
454
303
|
Math.max(0, maxChars - joined(minimum).length - HANDOFF_OVERHEAD_RESERVE),
|
|
455
304
|
);
|
|
456
|
-
const handoff = windowHandoff(priorWindow)?.trim();
|
|
457
|
-
const count =
|
|
458
|
-
fixed.length +
|
|
459
|
-
(handoff === undefined || handoff === "" ? 0 : 1) +
|
|
460
|
-
(history.skipped.length > 0 ? 1 : 0);
|
|
461
305
|
const fixedBudget = Math.max(
|
|
462
306
|
0,
|
|
463
307
|
maxChars - joined([preamble, inputHeader, plan.bare]).length - HANDOFF_OVERHEAD_RESERVE,
|
|
464
308
|
);
|
|
465
|
-
const fixedLimit =
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
309
|
+
const fixedLimit = sharedLimit(
|
|
310
|
+
[
|
|
311
|
+
...fixed.map((record) => formatRecord(record, Number.MAX_SAFE_INTEGER)),
|
|
312
|
+
priorCheckpoint(priorWindow, Number.MAX_SAFE_INTEGER),
|
|
313
|
+
skippedRecords(history.skipped, Number.MAX_SAFE_INTEGER),
|
|
314
|
+
].flatMap((block) => (block === undefined ? [] : [block.length])),
|
|
315
|
+
fixedBudget,
|
|
316
|
+
);
|
|
469
317
|
const prior = joined([
|
|
470
318
|
priorCheckpoint(priorWindow, fixedLimit),
|
|
471
319
|
skippedRecords(history.skipped, fixedLimit),
|