pi-posthorse 0.10.1 → 0.10.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/package.json +1 -1
- package/src/contracts.ts +3 -0
- package/src/recovery-notes.ts +630 -0
- package/src/recovery.ts +85 -6
- package/src/reminders.ts +100 -2
- package/src/rollover.ts +76 -34
package/package.json
CHANGED
package/src/contracts.ts
CHANGED
|
@@ -38,9 +38,12 @@ export type MessageLike = {
|
|
|
38
38
|
readonly content?: unknown;
|
|
39
39
|
readonly summary?: string;
|
|
40
40
|
readonly toolName?: string;
|
|
41
|
+
readonly customType?: string;
|
|
41
42
|
readonly namespace?: string;
|
|
42
43
|
readonly toolCallId?: string;
|
|
43
44
|
readonly isError?: boolean;
|
|
45
|
+
readonly details?: unknown;
|
|
46
|
+
readonly nestedCalls?: unknown;
|
|
44
47
|
readonly command?: string;
|
|
45
48
|
readonly output?: string;
|
|
46
49
|
readonly excludeFromContext?: boolean;
|
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isRecord,
|
|
3
|
+
optionalString,
|
|
4
|
+
type EntryLike,
|
|
5
|
+
type MessageLike,
|
|
6
|
+
type ProjectedEntry,
|
|
7
|
+
type ToolCallLike,
|
|
8
|
+
} from "./contracts.ts";
|
|
9
|
+
import { contextEdits } from "./history-entries.ts";
|
|
10
|
+
import { textOf, toolCalls } from "./message-text.ts";
|
|
11
|
+
|
|
12
|
+
const MAX_WRITTEN_NOTES = 4;
|
|
13
|
+
const MAX_READ_NOTES = 3;
|
|
14
|
+
|
|
15
|
+
type ScannedNoteEntry = {
|
|
16
|
+
readonly entry: EntryLike;
|
|
17
|
+
readonly entryId: string;
|
|
18
|
+
readonly inRetained: boolean;
|
|
19
|
+
};
|
|
20
|
+
export type RetainedSpan = { readonly firstId: string; readonly lastId: string };
|
|
21
|
+
type NoteOpKind = "full-write" | "partial-write" | "read" | "clear";
|
|
22
|
+
type NoteTarget = { readonly notePath: string; readonly absPath?: string };
|
|
23
|
+
type NoteTouch = NoteTarget & {
|
|
24
|
+
readonly kind: NoteOpKind;
|
|
25
|
+
readonly entryId: string;
|
|
26
|
+
readonly inRetained: boolean;
|
|
27
|
+
};
|
|
28
|
+
type TrackedNote = NoteTarget & {
|
|
29
|
+
readonly written: boolean;
|
|
30
|
+
readonly lastEntryId: string;
|
|
31
|
+
readonly lastInRetained: boolean;
|
|
32
|
+
readonly lastWriteEntryId?: string;
|
|
33
|
+
readonly fullWriteRetainedEntryId?: string;
|
|
34
|
+
readonly partialEditRetainedEntryId?: string;
|
|
35
|
+
readonly order: number;
|
|
36
|
+
};
|
|
37
|
+
type ToolResultInfo = {
|
|
38
|
+
readonly entryId: string;
|
|
39
|
+
readonly inRetained: boolean;
|
|
40
|
+
readonly isError: boolean;
|
|
41
|
+
readonly toolName: string;
|
|
42
|
+
readonly text: string;
|
|
43
|
+
readonly details: unknown;
|
|
44
|
+
};
|
|
45
|
+
type TouchContext = {
|
|
46
|
+
readonly entryId: string;
|
|
47
|
+
readonly inRetained: boolean;
|
|
48
|
+
readonly knownDirs: ReadonlySet<string>;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function bareToolName(name: string | undefined): string {
|
|
52
|
+
if (name === undefined || name === "") {
|
|
53
|
+
return "";
|
|
54
|
+
}
|
|
55
|
+
const cut = Math.max(name.lastIndexOf(":"), name.lastIndexOf("/"));
|
|
56
|
+
return cut >= 0 ? name.slice(cut + 1) : name;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function normalizeRelPath(raw: string): string {
|
|
60
|
+
const cleaned = raw
|
|
61
|
+
.trim()
|
|
62
|
+
.replaceAll("\\", "/")
|
|
63
|
+
.replace(/^(?:\.\/)+/u, "")
|
|
64
|
+
.replace(/^\/+/u, "");
|
|
65
|
+
const invalid =
|
|
66
|
+
cleaned === "" ||
|
|
67
|
+
cleaned === "." ||
|
|
68
|
+
cleaned === ".." ||
|
|
69
|
+
cleaned.startsWith("../") ||
|
|
70
|
+
cleaned.includes("/../") ||
|
|
71
|
+
cleaned.endsWith("/");
|
|
72
|
+
return invalid ? "" : cleaned;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function matchMarkerPath(slash: string): string | undefined {
|
|
76
|
+
for (const marker of ["/.pi/notes/", "/posthorse-notes/"]) {
|
|
77
|
+
const idx = slash.lastIndexOf(marker);
|
|
78
|
+
if (idx >= 0) {
|
|
79
|
+
return slash.slice(idx + marker.length);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (slash.startsWith(".pi/notes/") || slash.startsWith("./.pi/notes/")) {
|
|
83
|
+
return slash.replace(/^(?:\.\/)?\.pi\/notes\//u, "");
|
|
84
|
+
}
|
|
85
|
+
const pkgMatch = /\/\.pi\/package-notes\/[0-9a-f]{64}\/notes\/(.+)$/u.exec(slash);
|
|
86
|
+
if (pkgMatch !== null) {
|
|
87
|
+
return pkgMatch[1];
|
|
88
|
+
}
|
|
89
|
+
return /\/\.pi\/(?:agent\/)?notes\/[^/]+\/[^/]+\/[^/]+\/(.+)$/u.exec(slash)?.[1];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function matchKnownDir(slash: string, knownDirs: ReadonlySet<string>): string | undefined {
|
|
93
|
+
for (const dir of knownDirs) {
|
|
94
|
+
if (slash.startsWith(`${dir}/`)) {
|
|
95
|
+
return slash.slice(dir.length + 1);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function resolveNoteTarget(
|
|
102
|
+
rawPath: string | undefined,
|
|
103
|
+
knownDirs: ReadonlySet<string>,
|
|
104
|
+
fromNotesTool: boolean,
|
|
105
|
+
): NoteTarget | undefined {
|
|
106
|
+
if (rawPath === undefined) {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
const trimmed = rawPath.trim();
|
|
110
|
+
const slash = trimmed.replaceAll("\\", "/");
|
|
111
|
+
const matched = matchKnownDir(slash, knownDirs) ?? matchMarkerPath(slash);
|
|
112
|
+
if (matched !== undefined) {
|
|
113
|
+
const rel = normalizeRelPath(matched);
|
|
114
|
+
return rel === "" ? undefined : { notePath: rel, absPath: trimmed };
|
|
115
|
+
}
|
|
116
|
+
if (!fromNotesTool) {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
if (slash.startsWith("/") || /^[A-Za-z]:\//u.test(slash)) {
|
|
120
|
+
const base = normalizeRelPath(slash.slice(slash.lastIndexOf("/") + 1));
|
|
121
|
+
return base === "" ? undefined : { notePath: base, absPath: trimmed };
|
|
122
|
+
}
|
|
123
|
+
const rel = normalizeRelPath(slash);
|
|
124
|
+
return rel === "" ? undefined : { notePath: rel };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function extractNotesResultTarget(
|
|
128
|
+
text: string,
|
|
129
|
+
): { readonly kind: NoteOpKind; readonly rawPath: string } | undefined {
|
|
130
|
+
const patterns: readonly (readonly [RegExp, NoteOpKind])[] = [
|
|
131
|
+
[/^Wrote ([^\r\n]+)$/mu, "full-write"],
|
|
132
|
+
[/^Appended to ([^\r\n]+)$/mu, "partial-write"],
|
|
133
|
+
[/^File: ([^\r\n]+)$/mu, "read"],
|
|
134
|
+
];
|
|
135
|
+
for (const [pattern, kind] of patterns) {
|
|
136
|
+
const match = pattern.exec(text);
|
|
137
|
+
if (match !== null) {
|
|
138
|
+
return { kind, rawPath: match[1] };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function collectResultMap(
|
|
145
|
+
entries: readonly ScannedNoteEntry[],
|
|
146
|
+
): ReadonlyMap<string, ToolResultInfo> {
|
|
147
|
+
const results = new Map<string, ToolResultInfo>();
|
|
148
|
+
for (const item of entries) {
|
|
149
|
+
const msg = item.entry.message;
|
|
150
|
+
const callId = optionalString(msg?.toolCallId);
|
|
151
|
+
if (
|
|
152
|
+
item.entry.type === "message" &&
|
|
153
|
+
msg?.role === "toolResult" &&
|
|
154
|
+
callId !== undefined &&
|
|
155
|
+
callId !== ""
|
|
156
|
+
) {
|
|
157
|
+
results.set(callId, {
|
|
158
|
+
entryId: item.entryId,
|
|
159
|
+
inRetained: item.inRetained,
|
|
160
|
+
isError: msg.isError === true,
|
|
161
|
+
toolName: bareToolName(msg.toolName),
|
|
162
|
+
text: textOf(msg),
|
|
163
|
+
details: msg.details,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return results;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function inferDirFromCall(
|
|
171
|
+
call: ToolCallLike,
|
|
172
|
+
results: ReadonlyMap<string, ToolResultInfo>,
|
|
173
|
+
): string | undefined {
|
|
174
|
+
const callId = optionalString(call.id);
|
|
175
|
+
const res = callId === undefined ? undefined : results.get(callId);
|
|
176
|
+
if (res === undefined || res.isError) {
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
const parsed = extractNotesResultTarget(res.text);
|
|
180
|
+
const rawCallPath = isRecord(call.arguments) ? optionalString(call.arguments.path) : undefined;
|
|
181
|
+
const callPath = rawCallPath === undefined ? "" : normalizeRelPath(rawCallPath);
|
|
182
|
+
if (parsed === undefined || callPath === "") {
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
const slash = parsed.rawPath.trim().replaceAll("\\", "/");
|
|
186
|
+
const suffix = `/${callPath}`;
|
|
187
|
+
return slash.endsWith(suffix) && slash.length > suffix.length
|
|
188
|
+
? slash.slice(0, slash.length - suffix.length)
|
|
189
|
+
: undefined;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function assistantCallsOf(entry: EntryLike): readonly ToolCallLike[] {
|
|
193
|
+
return entry.type === "message" && entry.message?.role === "assistant"
|
|
194
|
+
? toolCalls(entry.message.content)
|
|
195
|
+
: [];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function learnNotesDirs(
|
|
199
|
+
entries: readonly ScannedNoteEntry[],
|
|
200
|
+
results: ReadonlyMap<string, ToolResultInfo>,
|
|
201
|
+
): ReadonlySet<string> {
|
|
202
|
+
const dirs = new Set<string>();
|
|
203
|
+
for (const info of results.values()) {
|
|
204
|
+
const match =
|
|
205
|
+
!info.isError && info.toolName === "notes"
|
|
206
|
+
? /^Notes directory: ([^\r\n]+)$/mu.exec(info.text)
|
|
207
|
+
: null;
|
|
208
|
+
if (match !== null) {
|
|
209
|
+
dirs.add(match[1].trim().replaceAll("\\", "/").replace(/\/+$/u, ""));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
for (const item of entries) {
|
|
213
|
+
for (const call of assistantCallsOf(item.entry)) {
|
|
214
|
+
const inferred = inferDirFromCall(call, results);
|
|
215
|
+
if (inferred !== undefined) {
|
|
216
|
+
dirs.add(inferred);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return dirs;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function pathsFromFileList(value: unknown): string[] {
|
|
224
|
+
if (!Array.isArray(value)) {
|
|
225
|
+
return [];
|
|
226
|
+
}
|
|
227
|
+
return value.flatMap((item: unknown) => {
|
|
228
|
+
if (typeof item === "string") {
|
|
229
|
+
return [item];
|
|
230
|
+
}
|
|
231
|
+
const itemPath = isRecord(item) ? optionalString(item.path) : undefined;
|
|
232
|
+
const skipped =
|
|
233
|
+
isRecord(item) && (item.status === "failed" || item.status === "unattempted");
|
|
234
|
+
return itemPath !== undefined && !skipped ? [itemPath] : [];
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function patchPaths(input: unknown): string[] {
|
|
239
|
+
return typeof input === "string"
|
|
240
|
+
? Array.from(
|
|
241
|
+
input.matchAll(/^\*\*\* (?:Update File|Add File|Move to): ([^\r\n]+)$/gmu),
|
|
242
|
+
(m) => m[1],
|
|
243
|
+
)
|
|
244
|
+
: [];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function detailFilePaths(details: unknown): string[] {
|
|
248
|
+
return isRecord(details)
|
|
249
|
+
? [...pathsFromFileList(details.modifiedFiles), ...pathsFromFileList(details.files)]
|
|
250
|
+
: [];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function extractFileToolSpec(
|
|
254
|
+
tool: string,
|
|
255
|
+
args: Readonly<Record<string, unknown>>,
|
|
256
|
+
details: unknown,
|
|
257
|
+
): { readonly kind: NoteOpKind; readonly rawPaths: readonly string[] } | undefined {
|
|
258
|
+
if (args.preview === true) {
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
261
|
+
const directPath = optionalString(args.path);
|
|
262
|
+
const directKinds: Readonly<Record<string, NoteOpKind | undefined>> = {
|
|
263
|
+
write: args.content === "" ? "clear" : "full-write",
|
|
264
|
+
edit: "partial-write",
|
|
265
|
+
read: "read",
|
|
266
|
+
};
|
|
267
|
+
const directKind = directKinds[tool];
|
|
268
|
+
if (directKind !== undefined && directPath !== undefined) {
|
|
269
|
+
return { kind: directKind, rawPaths: [directPath] };
|
|
270
|
+
}
|
|
271
|
+
if (tool === "write_files" || tool === "replace_text") {
|
|
272
|
+
const kind: NoteOpKind = tool === "write_files" ? "full-write" : "partial-write";
|
|
273
|
+
return { kind, rawPaths: [...pathsFromFileList(args.files), ...detailFilePaths(details)] };
|
|
274
|
+
}
|
|
275
|
+
return tool === "apply_patch"
|
|
276
|
+
? {
|
|
277
|
+
kind: "partial-write",
|
|
278
|
+
rawPaths: [...patchPaths(args.input), ...detailFilePaths(details)],
|
|
279
|
+
}
|
|
280
|
+
: undefined;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function fileToolTouches(
|
|
284
|
+
tool: string,
|
|
285
|
+
args: Readonly<Record<string, unknown>>,
|
|
286
|
+
details: unknown,
|
|
287
|
+
context: TouchContext,
|
|
288
|
+
): NoteTouch[] {
|
|
289
|
+
const spec = extractFileToolSpec(tool, args, details);
|
|
290
|
+
if (spec === undefined) {
|
|
291
|
+
return [];
|
|
292
|
+
}
|
|
293
|
+
return spec.rawPaths.flatMap((raw) => {
|
|
294
|
+
const target = resolveNoteTarget(raw, context.knownDirs, false);
|
|
295
|
+
return target === undefined
|
|
296
|
+
? []
|
|
297
|
+
: [
|
|
298
|
+
{
|
|
299
|
+
...target,
|
|
300
|
+
kind: spec.kind,
|
|
301
|
+
entryId: context.entryId,
|
|
302
|
+
inRetained: context.inRetained,
|
|
303
|
+
},
|
|
304
|
+
];
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function notesCallKind(args: Readonly<Record<string, unknown>>): NoteOpKind | undefined {
|
|
309
|
+
if (args.op === "write") {
|
|
310
|
+
return args.content === "" ? "clear" : "full-write";
|
|
311
|
+
}
|
|
312
|
+
if (args.op === "append") {
|
|
313
|
+
return "partial-write";
|
|
314
|
+
}
|
|
315
|
+
return args.op === "read" ? "read" : undefined;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function mergeNoteTargets(
|
|
319
|
+
callTarget: NoteTarget | undefined,
|
|
320
|
+
resTarget: NoteTarget | undefined,
|
|
321
|
+
): NoteTarget | undefined {
|
|
322
|
+
const notePath = callTarget?.notePath ?? resTarget?.notePath;
|
|
323
|
+
return notePath === undefined
|
|
324
|
+
? undefined
|
|
325
|
+
: { notePath, absPath: resTarget?.absPath ?? callTarget?.absPath };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function notesToolTouches(
|
|
329
|
+
args: Readonly<Record<string, unknown>>,
|
|
330
|
+
resultText: string | undefined,
|
|
331
|
+
context: TouchContext,
|
|
332
|
+
): NoteTouch[] {
|
|
333
|
+
const parsed = resultText === undefined ? undefined : extractNotesResultTarget(resultText);
|
|
334
|
+
const kind = notesCallKind(args) ?? parsed?.kind;
|
|
335
|
+
const callTarget = resolveNoteTarget(optionalString(args.path), context.knownDirs, true);
|
|
336
|
+
const resTarget = resolveNoteTarget(parsed?.rawPath, context.knownDirs, true);
|
|
337
|
+
const merged = mergeNoteTargets(callTarget, resTarget);
|
|
338
|
+
return kind === undefined || merged === undefined
|
|
339
|
+
? []
|
|
340
|
+
: [{ ...merged, kind, entryId: context.entryId, inRetained: context.inRetained }];
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function extractNestedCalls(message: MessageLike): readonly unknown[] {
|
|
344
|
+
const nested = isRecord(message.nestedCalls) ? message.nestedCalls.calls : undefined;
|
|
345
|
+
if (Array.isArray(nested)) {
|
|
346
|
+
return nested;
|
|
347
|
+
}
|
|
348
|
+
const details = isRecord(message.details) ? message.details : undefined;
|
|
349
|
+
if (details !== undefined && Array.isArray(details.calls)) {
|
|
350
|
+
return details.calls;
|
|
351
|
+
}
|
|
352
|
+
const detailNested =
|
|
353
|
+
details !== undefined && isRecord(details.nestedCalls)
|
|
354
|
+
? details.nestedCalls.calls
|
|
355
|
+
: undefined;
|
|
356
|
+
return Array.isArray(detailNested) ? detailNested : [];
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function nestedArgs(raw: Readonly<Record<string, unknown>>): Readonly<Record<string, unknown>> {
|
|
360
|
+
if (isRecord(raw.arguments)) {
|
|
361
|
+
return raw.arguments;
|
|
362
|
+
}
|
|
363
|
+
if (isRecord(raw.args)) {
|
|
364
|
+
return raw.args;
|
|
365
|
+
}
|
|
366
|
+
return isRecord(raw.input) ? raw.input : {};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function touchesFromNested(message: MessageLike, context: TouchContext): NoteTouch[] {
|
|
370
|
+
return extractNestedCalls(message).flatMap((raw) => {
|
|
371
|
+
if (
|
|
372
|
+
!isRecord(raw) ||
|
|
373
|
+
raw.isError === true ||
|
|
374
|
+
raw.status === "error" ||
|
|
375
|
+
(typeof raw.error === "string" && raw.error !== "")
|
|
376
|
+
) {
|
|
377
|
+
return [];
|
|
378
|
+
}
|
|
379
|
+
const name = bareToolName(optionalString(raw.name) ?? optionalString(raw.toolName));
|
|
380
|
+
const args = nestedArgs(raw);
|
|
381
|
+
return name === "notes"
|
|
382
|
+
? notesToolTouches(args, optionalString(raw.result), context)
|
|
383
|
+
: fileToolTouches(name, args, raw.result, context);
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function touchesFromAssistantCall(
|
|
388
|
+
call: ToolCallLike,
|
|
389
|
+
results: ReadonlyMap<string, ToolResultInfo>,
|
|
390
|
+
context: TouchContext,
|
|
391
|
+
): { readonly consumedCallId?: string; readonly touches: readonly NoteTouch[] } {
|
|
392
|
+
const callId = optionalString(call.id);
|
|
393
|
+
const res = callId === undefined ? undefined : results.get(callId);
|
|
394
|
+
const consumedCallId = callId !== undefined && res !== undefined ? callId : undefined;
|
|
395
|
+
if (res?.isError === true) {
|
|
396
|
+
return { consumedCallId, touches: [] };
|
|
397
|
+
}
|
|
398
|
+
const tool = bareToolName(call.name);
|
|
399
|
+
const args = isRecord(call.arguments) ? call.arguments : {};
|
|
400
|
+
const touches =
|
|
401
|
+
tool === "notes"
|
|
402
|
+
? notesToolTouches(args, res?.text, context)
|
|
403
|
+
: fileToolTouches(tool, args, res?.details, context);
|
|
404
|
+
return { consumedCallId, touches };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function touchesFromToolResult(
|
|
408
|
+
msg: MessageLike,
|
|
409
|
+
handledResultIds: ReadonlySet<string>,
|
|
410
|
+
context: TouchContext,
|
|
411
|
+
): NoteTouch[] {
|
|
412
|
+
if (msg.role !== "toolResult" || msg.isError === true) {
|
|
413
|
+
return [];
|
|
414
|
+
}
|
|
415
|
+
const callId = optionalString(msg.toolCallId) ?? "";
|
|
416
|
+
const direct =
|
|
417
|
+
!handledResultIds.has(callId) && bareToolName(msg.toolName) === "notes"
|
|
418
|
+
? notesToolTouches({}, textOf(msg), context)
|
|
419
|
+
: [];
|
|
420
|
+
return [...direct, ...touchesFromNested(msg, context)];
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function collectTouches(entries: readonly ScannedNoteEntry[]): {
|
|
424
|
+
readonly touches: readonly NoteTouch[];
|
|
425
|
+
readonly defaultDir?: string;
|
|
426
|
+
} {
|
|
427
|
+
const results = collectResultMap(entries);
|
|
428
|
+
const knownDirs = learnNotesDirs(entries, results);
|
|
429
|
+
const handledResultIds = new Set<string>();
|
|
430
|
+
const touches: NoteTouch[] = [];
|
|
431
|
+
for (const item of entries) {
|
|
432
|
+
const msg = item.entry.message;
|
|
433
|
+
const context: TouchContext = {
|
|
434
|
+
entryId: item.entryId,
|
|
435
|
+
inRetained: item.inRetained,
|
|
436
|
+
knownDirs,
|
|
437
|
+
};
|
|
438
|
+
for (const call of assistantCallsOf(item.entry)) {
|
|
439
|
+
const out = touchesFromAssistantCall(call, results, context);
|
|
440
|
+
if (out.consumedCallId !== undefined) {
|
|
441
|
+
handledResultIds.add(out.consumedCallId);
|
|
442
|
+
}
|
|
443
|
+
touches.push(...out.touches);
|
|
444
|
+
}
|
|
445
|
+
if (item.entry.type === "message" && msg !== undefined) {
|
|
446
|
+
touches.push(...touchesFromToolResult(msg, handledResultIds, context));
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
const defaultDir = knownDirs.size === 1 ? [...knownDirs][0] : undefined;
|
|
450
|
+
return { touches, defaultDir };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function applyReadTouch(
|
|
454
|
+
previous: TrackedNote | undefined,
|
|
455
|
+
touch: NoteTouch,
|
|
456
|
+
order: number,
|
|
457
|
+
): TrackedNote {
|
|
458
|
+
const wasWritten = previous?.written === true;
|
|
459
|
+
return {
|
|
460
|
+
notePath: touch.notePath,
|
|
461
|
+
absPath: touch.absPath ?? previous?.absPath,
|
|
462
|
+
written: wasWritten,
|
|
463
|
+
lastEntryId: touch.entryId,
|
|
464
|
+
lastInRetained: touch.inRetained,
|
|
465
|
+
lastWriteEntryId: previous?.lastWriteEntryId,
|
|
466
|
+
fullWriteRetainedEntryId: previous?.fullWriteRetainedEntryId,
|
|
467
|
+
partialEditRetainedEntryId: previous?.partialEditRetainedEntryId,
|
|
468
|
+
order: wasWritten ? previous.order : order,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function applyWriteTouch(
|
|
473
|
+
previous: TrackedNote | undefined,
|
|
474
|
+
touch: NoteTouch,
|
|
475
|
+
order: number,
|
|
476
|
+
): TrackedNote {
|
|
477
|
+
return {
|
|
478
|
+
notePath: touch.notePath,
|
|
479
|
+
absPath: touch.absPath ?? previous?.absPath,
|
|
480
|
+
written: true,
|
|
481
|
+
lastEntryId: touch.entryId,
|
|
482
|
+
lastInRetained: touch.inRetained,
|
|
483
|
+
lastWriteEntryId: touch.entryId,
|
|
484
|
+
fullWriteRetainedEntryId:
|
|
485
|
+
touch.kind === "full-write" && touch.inRetained
|
|
486
|
+
? touch.entryId
|
|
487
|
+
: previous?.fullWriteRetainedEntryId,
|
|
488
|
+
partialEditRetainedEntryId:
|
|
489
|
+
touch.kind === "partial-write" && touch.inRetained
|
|
490
|
+
? touch.entryId
|
|
491
|
+
: previous?.partialEditRetainedEntryId,
|
|
492
|
+
order,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function formatNoteLabel(note: TrackedNote): string {
|
|
497
|
+
return note.absPath !== undefined && note.absPath !== note.notePath
|
|
498
|
+
? `${note.notePath} (${note.absPath})`
|
|
499
|
+
: note.notePath;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function formatWrittenNoteLine(note: TrackedNote, span: RetainedSpan | undefined): string {
|
|
503
|
+
const label = formatNoteLabel(note);
|
|
504
|
+
const writeId = note.lastWriteEntryId ?? note.lastEntryId;
|
|
505
|
+
if (span === undefined) {
|
|
506
|
+
return `- Active note (written): ${label} [compacted at entry ${writeId} — read this note now]`;
|
|
507
|
+
}
|
|
508
|
+
if (note.fullWriteRetainedEntryId !== undefined) {
|
|
509
|
+
const updated =
|
|
510
|
+
note.partialEditRetainedEntryId !== undefined &&
|
|
511
|
+
note.lastWriteEntryId === note.partialEditRetainedEntryId
|
|
512
|
+
? `, updated at entry ${note.partialEditRetainedEntryId}`
|
|
513
|
+
: "";
|
|
514
|
+
return `- Active note (written): ${label} [latest full write is in the retained tail above at entry ${note.fullWriteRetainedEntryId}${updated}]`;
|
|
515
|
+
}
|
|
516
|
+
if (note.partialEditRetainedEntryId !== undefined) {
|
|
517
|
+
return `- Active note (written): ${label} [only partial edits are in the retained tail above at entry ${note.partialEditRetainedEntryId} — read full note now]`;
|
|
518
|
+
}
|
|
519
|
+
return `- Active note (written): ${label} [compacted before entry ${span.firstId} — read this note now]`;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function formatReadNoteLine(note: TrackedNote, span: RetainedSpan | undefined): string {
|
|
523
|
+
const label = formatNoteLabel(note);
|
|
524
|
+
if (span !== undefined && note.lastInRetained) {
|
|
525
|
+
return `- Referenced note (read): ${label} [read in the retained tail above at entry ${note.lastEntryId}]`;
|
|
526
|
+
}
|
|
527
|
+
return span !== undefined
|
|
528
|
+
? `- Referenced note (read): ${label} [compacted before entry ${span.firstId}]`
|
|
529
|
+
: `- Referenced note (read): ${label} [compacted at entry ${note.lastEntryId}]`;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function formatNotesAction(
|
|
533
|
+
primaryWritten: TrackedNote | undefined,
|
|
534
|
+
span: RetainedSpan | undefined,
|
|
535
|
+
): string {
|
|
536
|
+
if (primaryWritten === undefined) {
|
|
537
|
+
return "Read the referenced note again if its context is still needed, and keep one current-state note for this task.";
|
|
538
|
+
}
|
|
539
|
+
if (span !== undefined && primaryWritten.fullWriteRetainedEntryId !== undefined) {
|
|
540
|
+
return `Your latest full write to ${primaryWritten.notePath} is already visible above at entry ${primaryWritten.fullWriteRetainedEntryId}; do not rewrite it unless new findings after entry ${span.lastId} require it.`;
|
|
541
|
+
}
|
|
542
|
+
return `Read ${primaryWritten.notePath} (notes read or read on its path) before continuing to restore the saved goal, progress, decisions, and next steps, then keep that note current as work progresses.`;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function projectNoteEntries(
|
|
546
|
+
chainEntries: readonly EntryLike[],
|
|
547
|
+
allEntries: readonly EntryLike[],
|
|
548
|
+
projected: readonly ProjectedEntry[],
|
|
549
|
+
retained: ReadonlySet<string>,
|
|
550
|
+
): ScannedNoteEntry[] {
|
|
551
|
+
const edits = contextEdits(allEntries);
|
|
552
|
+
const omittedIds = new Set(
|
|
553
|
+
projected
|
|
554
|
+
.filter(({ messages }) => messages.length === 0)
|
|
555
|
+
.map(({ sourceEntry }) => sourceEntry.id),
|
|
556
|
+
);
|
|
557
|
+
return chainEntries.flatMap((entry) => {
|
|
558
|
+
const rawId = entry.id ?? "";
|
|
559
|
+
const edit = edits.get(rawId);
|
|
560
|
+
if (omittedIds.has(rawId) || edit?.replacement === null) {
|
|
561
|
+
return [];
|
|
562
|
+
}
|
|
563
|
+
const effective =
|
|
564
|
+
edit?.replacement !== undefined && entry.type === "message"
|
|
565
|
+
? {
|
|
566
|
+
...entry,
|
|
567
|
+
id: edit.id,
|
|
568
|
+
message: { ...entry.message, content: edit.replacement.content },
|
|
569
|
+
}
|
|
570
|
+
: entry;
|
|
571
|
+
const resolvedId = (effective.id ?? rawId).slice(0, 120);
|
|
572
|
+
return [
|
|
573
|
+
{
|
|
574
|
+
entry: effective,
|
|
575
|
+
entryId: resolvedId === "" ? "unknown" : resolvedId,
|
|
576
|
+
inRetained: retained.has(rawId),
|
|
577
|
+
},
|
|
578
|
+
];
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
export function renderSessionNotes(
|
|
583
|
+
chainEntries: readonly EntryLike[],
|
|
584
|
+
allEntries: readonly EntryLike[],
|
|
585
|
+
projected: readonly ProjectedEntry[],
|
|
586
|
+
options: {
|
|
587
|
+
readonly retained: ReadonlySet<string>;
|
|
588
|
+
readonly span: RetainedSpan | undefined;
|
|
589
|
+
readonly maxChars: number;
|
|
590
|
+
},
|
|
591
|
+
): string | undefined {
|
|
592
|
+
const { retained, span, maxChars } = options;
|
|
593
|
+
const entries = projectNoteEntries(chainEntries, allEntries, projected, retained);
|
|
594
|
+
const { touches, defaultDir } = collectTouches(entries);
|
|
595
|
+
const tracked = new Map<string, TrackedNote>();
|
|
596
|
+
let order = 0;
|
|
597
|
+
for (const rawTouch of touches) {
|
|
598
|
+
order += 1;
|
|
599
|
+
const touch =
|
|
600
|
+
rawTouch.absPath === undefined && defaultDir !== undefined
|
|
601
|
+
? { ...rawTouch, absPath: `${defaultDir}/${rawTouch.notePath}` }
|
|
602
|
+
: rawTouch;
|
|
603
|
+
if (touch.kind === "clear") {
|
|
604
|
+
tracked.delete(touch.notePath);
|
|
605
|
+
} else if (touch.kind === "read") {
|
|
606
|
+
tracked.set(touch.notePath, applyReadTouch(tracked.get(touch.notePath), touch, order));
|
|
607
|
+
} else {
|
|
608
|
+
tracked.set(touch.notePath, applyWriteTouch(tracked.get(touch.notePath), touch, order));
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
if (tracked.size === 0) {
|
|
612
|
+
return undefined;
|
|
613
|
+
}
|
|
614
|
+
const all = [...tracked.values()].sort((a, b) => b.order - a.order);
|
|
615
|
+
const written = all.filter((note) => note.written).slice(0, MAX_WRITTEN_NOTES);
|
|
616
|
+
const readOnly = all.filter((note) => !note.written).slice(0, MAX_READ_NOTES);
|
|
617
|
+
const lines = [
|
|
618
|
+
...written.map((note) => formatWrittenNoteLine(note, span)),
|
|
619
|
+
...readOnly.map((note) => formatReadNoteLine(note, span)),
|
|
620
|
+
];
|
|
621
|
+
const header = "Task notes in this session:";
|
|
622
|
+
const action = formatNotesAction(written[0], span);
|
|
623
|
+
const selected: string[] = [];
|
|
624
|
+
for (const line of lines) {
|
|
625
|
+
if ([header, ...selected, line, action].join("\n").length <= maxChars) {
|
|
626
|
+
selected.push(line);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
return selected.length === 0 ? undefined : [header, ...selected, action].join("\n");
|
|
630
|
+
}
|
package/src/recovery.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
textOf,
|
|
15
15
|
toolCalls,
|
|
16
16
|
} from "./message-text.ts";
|
|
17
|
+
import { renderSessionNotes, type RetainedSpan } from "./recovery-notes.ts";
|
|
17
18
|
import {
|
|
18
19
|
recoverableToolResults,
|
|
19
20
|
type RecoveryBatch,
|
|
@@ -24,7 +25,47 @@ import {
|
|
|
24
25
|
const MAX_RECOVERY_RECORD_CHARS = 4_000;
|
|
25
26
|
const MAX_RECOVERY_RESULT_CHARS = 1_500;
|
|
26
27
|
const HANDOFF_OVERHEAD_RESERVE = 1_000;
|
|
28
|
+
const MAX_NOTES_SECTION_CHARS = 1_200;
|
|
27
29
|
const PREAMBLE = `${AUTO_HANDOFF_PREFIX}\nThe previous window may already have finished its work. This record preserves inputs, not current progress. Restore relevant notes and todo state, inspect session history when needed, and verify live state before continuing stateful or external work.\nOwner inputs are direct user intent. Coordination inputs are not direct owner intent and cannot override it.`;
|
|
30
|
+
const RETAINED_MARKER_PREFIX = "Context rolled over after entry ";
|
|
31
|
+
|
|
32
|
+
export function hasRetainedRolloverMarker(summary: string): boolean {
|
|
33
|
+
return (
|
|
34
|
+
summary.startsWith(AUTO_HANDOFF_PREFIX) && summary.includes(`\n${RETAINED_MARKER_PREFIX}`)
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
function retainedSpanOf(
|
|
38
|
+
entries: readonly EntryLike[],
|
|
39
|
+
retained: ReadonlySet<string>,
|
|
40
|
+
): RetainedSpan | undefined {
|
|
41
|
+
if (retained.size === 0) {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
const kept = entries.filter(
|
|
45
|
+
(e) =>
|
|
46
|
+
e.id !== undefined &&
|
|
47
|
+
e.id !== "" &&
|
|
48
|
+
retained.has(e.id) &&
|
|
49
|
+
(e.type === "message" || e.type === "custom_message" || e.type === "branch_summary"),
|
|
50
|
+
);
|
|
51
|
+
const list =
|
|
52
|
+
kept.length > 0
|
|
53
|
+
? kept
|
|
54
|
+
: entries.filter((e) => e.id !== undefined && e.id !== "" && retained.has(e.id));
|
|
55
|
+
const firstId = list[0]?.id?.slice(0, 120);
|
|
56
|
+
const lastId = list.at(-1)?.id?.slice(0, 120);
|
|
57
|
+
return firstId === undefined || lastId === undefined ? undefined : { firstId, lastId };
|
|
58
|
+
}
|
|
59
|
+
function buildRolloverPreamble(span: RetainedSpan | undefined): string {
|
|
60
|
+
if (span === undefined) {
|
|
61
|
+
return PREAMBLE;
|
|
62
|
+
}
|
|
63
|
+
const range =
|
|
64
|
+
span.firstId === span.lastId
|
|
65
|
+
? `entry ${span.firstId} above is the carried-over recent turn`
|
|
66
|
+
: `entries ${span.firstId} through ${span.lastId} above are the carried-over recent tail`;
|
|
67
|
+
return `${PREAMBLE}\n${RETAINED_MARKER_PREFIX}${span.lastId}. Earlier turns before entry ${span.firstId} were compacted into session history; ${range} from before this rollover. Recent messages follow separately; their inputs and tool evidence are not repeated in this record. Start the new context window from here.`;
|
|
68
|
+
}
|
|
28
69
|
type RecoveryRecord = {
|
|
29
70
|
readonly id: string;
|
|
30
71
|
readonly timestamp: string;
|
|
@@ -209,6 +250,15 @@ function automaticBoundary(entry: EntryLike | undefined): boolean {
|
|
|
209
250
|
(entry.details.reason === "threshold" || entry.details.reason === "overflow"))
|
|
210
251
|
);
|
|
211
252
|
}
|
|
253
|
+
function automaticChainStart(entries: readonly EntryLike[]): number {
|
|
254
|
+
let prefix = entries;
|
|
255
|
+
let window = contextStart(prefix);
|
|
256
|
+
while (automaticBoundary(window.boundary) && window.boundary !== undefined) {
|
|
257
|
+
prefix = prefix.slice(0, prefix.indexOf(window.boundary));
|
|
258
|
+
window = contextStart(prefix);
|
|
259
|
+
}
|
|
260
|
+
return window.start;
|
|
261
|
+
}
|
|
212
262
|
function recoveryAncestry(
|
|
213
263
|
entries: readonly EntryLike[],
|
|
214
264
|
projected: readonly ProjectedEntry[],
|
|
@@ -413,6 +463,34 @@ function selectedInputs(
|
|
|
413
463
|
}
|
|
414
464
|
return { fixed, others };
|
|
415
465
|
}
|
|
466
|
+
function rolloverPreambleWithNotes(
|
|
467
|
+
entries: readonly EntryLike[],
|
|
468
|
+
projected: readonly ProjectedEntry[],
|
|
469
|
+
options: {
|
|
470
|
+
readonly retained: ReadonlySet<string>;
|
|
471
|
+
readonly reserveParts: readonly (string | undefined)[];
|
|
472
|
+
readonly maxChars: number;
|
|
473
|
+
},
|
|
474
|
+
): string {
|
|
475
|
+
const { retained, reserveParts, maxChars } = options;
|
|
476
|
+
const span = retainedSpanOf(entries.slice(contextStart(entries).start), retained);
|
|
477
|
+
const basePreamble = buildRolloverPreamble(span);
|
|
478
|
+
const reserveLen = joined([basePreamble, ...reserveParts]).length;
|
|
479
|
+
const notesSection = renderSessionNotes(
|
|
480
|
+
entries.slice(automaticChainStart(entries)),
|
|
481
|
+
entries,
|
|
482
|
+
projected,
|
|
483
|
+
{
|
|
484
|
+
retained,
|
|
485
|
+
span,
|
|
486
|
+
maxChars: Math.min(
|
|
487
|
+
MAX_NOTES_SECTION_CHARS,
|
|
488
|
+
Math.max(0, maxChars - reserveLen - HANDOFF_OVERHEAD_RESERVE),
|
|
489
|
+
),
|
|
490
|
+
},
|
|
491
|
+
);
|
|
492
|
+
return joined([basePreamble, notesSection]);
|
|
493
|
+
}
|
|
416
494
|
/** Raw owner anchors and projected tool evidence have distinct provenance and share one bounded payload. */
|
|
417
495
|
export function buildAutoHandoff(
|
|
418
496
|
entries: readonly EntryLike[],
|
|
@@ -424,10 +502,6 @@ export function buildAutoHandoff(
|
|
|
424
502
|
},
|
|
425
503
|
): string {
|
|
426
504
|
const { ownerQuestionRegistered, retainedEntryIds: retained = new Set<string>() } = options;
|
|
427
|
-
const preamble =
|
|
428
|
-
retained.size === 0
|
|
429
|
-
? PREAMBLE
|
|
430
|
-
: `${PREAMBLE}\nRecent messages follow separately; their inputs and tool evidence are not repeated in this record.`;
|
|
431
505
|
const history = recoveryAncestry(entries, projected, ownerQuestionRegistered, retained);
|
|
432
506
|
const priorWindow = history.checkpoint;
|
|
433
507
|
const { fixed, others } = selectedInputs(entries, projected, {
|
|
@@ -445,14 +519,19 @@ export function buildAutoHandoff(
|
|
|
445
519
|
: emptyInputHeader;
|
|
446
520
|
const batch = recoverableToolResults(projectedToolEntries(entries, projected, retained));
|
|
447
521
|
const header = batchHeader(batch);
|
|
448
|
-
const
|
|
449
|
-
preamble,
|
|
522
|
+
const reserveParts = [
|
|
450
523
|
inputHeader,
|
|
451
524
|
...fixed.map((record) => formatRecord(record, 0)),
|
|
452
525
|
header,
|
|
453
526
|
priorCheckpoint(priorWindow, 0),
|
|
454
527
|
skippedRecords(history.skipped, 0),
|
|
455
528
|
];
|
|
529
|
+
const preamble = rolloverPreambleWithNotes(entries, projected, {
|
|
530
|
+
retained,
|
|
531
|
+
reserveParts,
|
|
532
|
+
maxChars,
|
|
533
|
+
});
|
|
534
|
+
const minimum = [preamble, ...reserveParts];
|
|
456
535
|
const plan = planBatch(
|
|
457
536
|
batch,
|
|
458
537
|
header,
|
package/src/reminders.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import {
|
|
3
|
+
isRecord,
|
|
4
|
+
isReminderType,
|
|
5
|
+
type EntryLike,
|
|
6
|
+
type ProjectedEntry,
|
|
7
|
+
type ReminderFingerprint,
|
|
8
|
+
} from "./contracts.ts";
|
|
9
|
+
import { contextEdits, contextStart, isWindow } from "./history-entries.ts";
|
|
4
10
|
|
|
5
11
|
type ReminderState = {
|
|
6
12
|
readonly manager: ExtensionContext["sessionManager"];
|
|
@@ -14,6 +20,12 @@ export type ReminderLookup = {
|
|
|
14
20
|
readonly reset: () => void;
|
|
15
21
|
readonly read: (ctx: ExtensionContext) => ReminderState;
|
|
16
22
|
};
|
|
23
|
+
export type StaleReminderPredicate = (message: {
|
|
24
|
+
readonly role: string;
|
|
25
|
+
readonly customType?: string;
|
|
26
|
+
readonly details?: unknown;
|
|
27
|
+
}) => boolean;
|
|
28
|
+
|
|
17
29
|
export function reminderMatches(details: unknown, fingerprint: ReminderFingerprint): boolean {
|
|
18
30
|
if (!isRecord(details)) {
|
|
19
31
|
return false;
|
|
@@ -114,3 +126,89 @@ export function createReminderLookup(): ReminderLookup {
|
|
|
114
126
|
},
|
|
115
127
|
};
|
|
116
128
|
}
|
|
129
|
+
|
|
130
|
+
function retainedEntryIdSet(branch: readonly EntryLike[]): ReadonlySet<string> | undefined {
|
|
131
|
+
const compactionIdx = branch.findLastIndex((entry) => entry.type === "compaction");
|
|
132
|
+
const firstKeptId = compactionIdx > 0 ? branch[compactionIdx]?.firstKeptEntryId : undefined;
|
|
133
|
+
if (typeof firstKeptId !== "string" || firstKeptId === "") {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
const firstKeptIdx = branch.findIndex((entry) => entry.id === firstKeptId);
|
|
137
|
+
if (firstKeptIdx < 0 || firstKeptIdx >= compactionIdx) {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
return new Set(
|
|
141
|
+
branch
|
|
142
|
+
.slice(firstKeptIdx, compactionIdx)
|
|
143
|
+
.flatMap((entry) =>
|
|
144
|
+
typeof entry.id === "string" && entry.id !== "" ? [entry.id] : [],
|
|
145
|
+
),
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function countRetainedFromProjected(
|
|
150
|
+
projected: readonly ProjectedEntry[],
|
|
151
|
+
retainedIds: ReadonlySet<string>,
|
|
152
|
+
stale: StaleReminderPredicate,
|
|
153
|
+
): number {
|
|
154
|
+
let count = 0;
|
|
155
|
+
for (const item of projected) {
|
|
156
|
+
if (!retainedIds.has(item.sourceEntry.id ?? "")) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
for (const msg of item.messages) {
|
|
160
|
+
const role = msg.role ?? "";
|
|
161
|
+
if (
|
|
162
|
+
role !== "system" &&
|
|
163
|
+
!stale({ role, customType: msg.customType, details: msg.details })
|
|
164
|
+
) {
|
|
165
|
+
count += 1;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return count;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function entryProducesRetainedMessage(
|
|
173
|
+
entry: EntryLike,
|
|
174
|
+
edits: ReadonlyMap<string, EntryLike>,
|
|
175
|
+
stale: StaleReminderPredicate,
|
|
176
|
+
): boolean {
|
|
177
|
+
if (edits.get(entry.id ?? "")?.replacement === null) {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
if (entry.type === "message") {
|
|
181
|
+
return entry.message?.role !== "system" && entry.message?.excludeFromContext !== true;
|
|
182
|
+
}
|
|
183
|
+
if (entry.type === "custom_message") {
|
|
184
|
+
return !stale({ role: "custom", customType: entry.customType, details: entry.details });
|
|
185
|
+
}
|
|
186
|
+
return entry.type === "branch_summary";
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function countRetainedMessages(
|
|
190
|
+
ctx: ExtensionContext,
|
|
191
|
+
stale: StaleReminderPredicate,
|
|
192
|
+
): number {
|
|
193
|
+
const manager: unknown = ctx.sessionManager;
|
|
194
|
+
const branch: readonly EntryLike[] =
|
|
195
|
+
isRecord(manager) && typeof manager.getBranch === "function"
|
|
196
|
+
? ctx.sessionManager.getBranch()
|
|
197
|
+
: [];
|
|
198
|
+
const retainedIds = retainedEntryIdSet(branch);
|
|
199
|
+
if (retainedIds === undefined || retainedIds.size === 0) {
|
|
200
|
+
return 0;
|
|
201
|
+
}
|
|
202
|
+
if (isRecord(manager) && typeof manager.buildSessionProjection === "function") {
|
|
203
|
+
return countRetainedFromProjected(
|
|
204
|
+
ctx.sessionManager.buildSessionProjection().entries,
|
|
205
|
+
retainedIds,
|
|
206
|
+
stale,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
const edits = contextEdits(branch);
|
|
210
|
+
return branch.filter(
|
|
211
|
+
(entry) =>
|
|
212
|
+
retainedIds.has(entry.id ?? "") && entryProducesRetainedMessage(entry, edits, stale),
|
|
213
|
+
).length;
|
|
214
|
+
}
|
package/src/rollover.ts
CHANGED
|
@@ -22,12 +22,15 @@ import {
|
|
|
22
22
|
} from "./contracts.ts";
|
|
23
23
|
import { registerContextTools } from "./context-tools.ts";
|
|
24
24
|
import { safeJsonStringify } from "./message-text.ts";
|
|
25
|
+
import { hasRetainedRolloverMarker } from "./recovery.ts";
|
|
25
26
|
import { automaticRecovery } from "./retention.ts";
|
|
26
27
|
import {
|
|
28
|
+
countRetainedMessages,
|
|
27
29
|
createReminderLookup,
|
|
28
30
|
hasReminder,
|
|
29
31
|
reminderMatches,
|
|
30
32
|
type ReminderLookup,
|
|
33
|
+
type StaleReminderPredicate,
|
|
31
34
|
} from "./reminders.ts";
|
|
32
35
|
|
|
33
36
|
export type SnapshotReader = (
|
|
@@ -181,49 +184,88 @@ function sendReminder(
|
|
|
181
184
|
{ deliverAs: "steer" },
|
|
182
185
|
);
|
|
183
186
|
}
|
|
187
|
+
const NO_STALE_REMINDER: StaleReminderPredicate = () => false;
|
|
188
|
+
function staleReminderPredicate(
|
|
189
|
+
messages: readonly {
|
|
190
|
+
readonly role: string;
|
|
191
|
+
readonly customType?: string;
|
|
192
|
+
readonly details?: unknown;
|
|
193
|
+
}[],
|
|
194
|
+
ctx: ExtensionContext,
|
|
195
|
+
policy: SnapshotReader,
|
|
196
|
+
reminders: ReminderLookup,
|
|
197
|
+
): StaleReminderPredicate {
|
|
198
|
+
if (
|
|
199
|
+
!messages.some((message) => message.role === "custom" && isReminderType(message.customType))
|
|
200
|
+
) {
|
|
201
|
+
return NO_STALE_REMINDER;
|
|
202
|
+
}
|
|
203
|
+
const marker = messages.find(
|
|
204
|
+
(message) => message.role === "custom" && message.customType === "context-window",
|
|
205
|
+
);
|
|
206
|
+
let windowId: unknown;
|
|
207
|
+
if (marker?.role === "custom") {
|
|
208
|
+
windowId = isRecord(marker.details) ? marker.details.windowId : undefined;
|
|
209
|
+
} else {
|
|
210
|
+
windowId = reminders.read(ctx).windowId;
|
|
211
|
+
}
|
|
212
|
+
if (typeof windowId !== "string") {
|
|
213
|
+
return NO_STALE_REMINDER;
|
|
214
|
+
}
|
|
215
|
+
const host = policy(ctx);
|
|
216
|
+
const budget = budgetFor(host);
|
|
217
|
+
const fingerprint: ReminderFingerprint = {
|
|
218
|
+
windowId,
|
|
219
|
+
contextWindow: budget?.contextWindow,
|
|
220
|
+
reserveTokens: budget?.reserveTokens,
|
|
221
|
+
};
|
|
222
|
+
const enabled = host.getCompactionSettings().enabled;
|
|
223
|
+
return (message) =>
|
|
224
|
+
message.role === "custom" &&
|
|
225
|
+
isReminderType(message.customType) &&
|
|
226
|
+
(!enabled || !reminderMatches(message.details, fingerprint));
|
|
227
|
+
}
|
|
184
228
|
function registerReminderFilter(
|
|
185
229
|
pi: ExtensionAPI,
|
|
186
230
|
policy: SnapshotReader,
|
|
187
231
|
reminders: ReminderLookup,
|
|
188
232
|
): void {
|
|
189
233
|
pi.on("context", (event, ctx) => {
|
|
234
|
+
const stale = staleReminderPredicate(event.messages, ctx, policy, reminders);
|
|
235
|
+
const filtered = event.messages.some(stale)
|
|
236
|
+
? event.messages.filter((message) => !stale(message))
|
|
237
|
+
: event.messages;
|
|
238
|
+
const first = filtered[0];
|
|
190
239
|
if (
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
)
|
|
240
|
+
filtered.length >= 2 &&
|
|
241
|
+
first.role === "compactionSummary" &&
|
|
242
|
+
hasRetainedRolloverMarker(first.summary)
|
|
194
243
|
) {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
244
|
+
const count = countRetainedMessages(ctx, stale);
|
|
245
|
+
if (count > 0 && 1 + count <= filtered.length) {
|
|
246
|
+
const retainedSlice = filtered.slice(1, 1 + count);
|
|
247
|
+
const afterSlice = filtered.slice(1 + count);
|
|
248
|
+
if (retainedSlice[0].role !== "assistant") {
|
|
249
|
+
return { messages: [...retainedSlice, first, ...afterSlice] };
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
messages: [
|
|
253
|
+
{
|
|
254
|
+
role: "custom",
|
|
255
|
+
customType: "posthorse-retained-tail",
|
|
256
|
+
content: "[Retained pre-rollover tail]",
|
|
257
|
+
display: false,
|
|
258
|
+
timestamp: first.timestamp,
|
|
259
|
+
},
|
|
260
|
+
...retainedSlice,
|
|
261
|
+
first,
|
|
262
|
+
...afterSlice,
|
|
263
|
+
],
|
|
264
|
+
};
|
|
265
|
+
}
|
|
208
266
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const fingerprint: ReminderFingerprint = {
|
|
212
|
-
windowId,
|
|
213
|
-
contextWindow: budget?.contextWindow,
|
|
214
|
-
reserveTokens: budget?.reserveTokens,
|
|
215
|
-
};
|
|
216
|
-
const enabled = host.getCompactionSettings().enabled;
|
|
217
|
-
const stale = (message: {
|
|
218
|
-
readonly role: string;
|
|
219
|
-
readonly customType?: string;
|
|
220
|
-
readonly details?: unknown;
|
|
221
|
-
}) =>
|
|
222
|
-
message.role === "custom" &&
|
|
223
|
-
isReminderType(message.customType) &&
|
|
224
|
-
(!enabled || !reminderMatches(message.details, fingerprint));
|
|
225
|
-
if (event.messages.some(stale)) {
|
|
226
|
-
return { messages: event.messages.filter((message) => !stale(message)) };
|
|
267
|
+
if (filtered !== event.messages) {
|
|
268
|
+
return { messages: filtered };
|
|
227
269
|
}
|
|
228
270
|
return;
|
|
229
271
|
});
|