pi-posthorse 0.10.2 → 0.10.4
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/README.md +7 -2
- package/index.ts +13 -3
- package/package.json +24 -21
- package/src/context-tools.ts +2 -2
- package/src/contracts.ts +39 -32
- package/src/files.ts +0 -5
- package/src/guidance.ts +41 -0
- package/src/history-entries.ts +16 -16
- package/src/history-pages.ts +1 -1
- package/src/history-search.ts +6 -6
- package/src/history.ts +5 -2
- package/src/note-discovery.ts +291 -0
- package/src/note-evidence.ts +179 -0
- package/src/note-guards.ts +203 -0
- package/src/note-inventory.ts +111 -0
- package/src/note-provenance.ts +299 -0
- package/src/note-scopes.ts +293 -0
- package/src/note-snapshots.ts +185 -0
- package/src/note-store.ts +278 -0
- package/src/note-tool-effects.ts +190 -0
- package/src/notes.ts +118 -137
- package/src/paging.ts +2 -2
- package/src/recovery-note-touches.ts +389 -0
- package/src/recovery-notes.ts +54 -503
- package/src/recovery-records.ts +90 -0
- package/src/recovery-results.ts +7 -6
- package/src/recovery.ts +24 -108
- package/src/reminders.ts +3 -3
- package/src/retention.ts +23 -17
- package/src/rollover.ts +66 -88
- package/src/session-files.ts +29 -20
- package/src/session-lines.ts +1 -2
- package/src/ui/cards.ts +3 -2
- package/src/ui/display.ts +2 -2
- package/src/ui/text.ts +0 -1
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { isReminderType, type EntryLike } from "./contracts.ts";
|
|
2
|
+
import { boundedBlock, imageSummary, imagesOf, textOf } from "./message-text.ts";
|
|
3
|
+
|
|
4
|
+
export type RecoveryRecord = {
|
|
5
|
+
readonly id: string;
|
|
6
|
+
readonly timestamp: string;
|
|
7
|
+
readonly kind: "owner" | "coordination";
|
|
8
|
+
readonly label: string;
|
|
9
|
+
readonly text: string;
|
|
10
|
+
};
|
|
11
|
+
type RecordSource = {
|
|
12
|
+
readonly kind: RecoveryRecord["kind"];
|
|
13
|
+
readonly label: string;
|
|
14
|
+
readonly content: unknown;
|
|
15
|
+
};
|
|
16
|
+
function ownerAnswer(entry: EntryLike, ownerQuestion: boolean): boolean {
|
|
17
|
+
const message = entry.message;
|
|
18
|
+
return (
|
|
19
|
+
ownerQuestion &&
|
|
20
|
+
entry.type === "message" &&
|
|
21
|
+
message?.role === "toolResult" &&
|
|
22
|
+
message.toolName === "ask_question" &&
|
|
23
|
+
message.namespace === undefined &&
|
|
24
|
+
message.isError !== true
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
function recordSource(entry: EntryLike, ownerQuestion: boolean): RecordSource | undefined {
|
|
28
|
+
if (entry.type === "message" && entry.message?.role === "user") {
|
|
29
|
+
return { kind: "owner", label: "owner input", content: entry.message.content };
|
|
30
|
+
}
|
|
31
|
+
if (ownerAnswer(entry, ownerQuestion)) {
|
|
32
|
+
return {
|
|
33
|
+
kind: "owner",
|
|
34
|
+
label: "owner answer via ask_question",
|
|
35
|
+
content: entry.message?.content,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
if (
|
|
39
|
+
entry.type === "custom_message" &&
|
|
40
|
+
entry.display === true &&
|
|
41
|
+
!isReminderType(entry.customType)
|
|
42
|
+
) {
|
|
43
|
+
return {
|
|
44
|
+
kind: "coordination",
|
|
45
|
+
label: `visible ${(entry.customType ?? "custom").slice(0, 80)} coordination input (not direct owner input)`,
|
|
46
|
+
content: entry.content,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
export function recoveryRecord(
|
|
52
|
+
entry: EntryLike,
|
|
53
|
+
ownerQuestion: boolean,
|
|
54
|
+
): RecoveryRecord | undefined {
|
|
55
|
+
const source = recordSource(entry, ownerQuestion);
|
|
56
|
+
if (source === undefined) {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
const id = entry.id?.slice(0, 120) ?? "unknown";
|
|
60
|
+
const text = textOf({ content: source.content }).trim();
|
|
61
|
+
const summary =
|
|
62
|
+
source.label === "owner answer via ask_question"
|
|
63
|
+
? ""
|
|
64
|
+
: imageSummary(imagesOf(source.content));
|
|
65
|
+
const body = [text, summary === "" ? "" : `${summary} — recover with history read id ${id}`]
|
|
66
|
+
.filter((part) => part !== "")
|
|
67
|
+
.join("\n");
|
|
68
|
+
return {
|
|
69
|
+
id,
|
|
70
|
+
timestamp: entry.timestamp?.slice(0, 80) ?? "unknown time",
|
|
71
|
+
kind: source.kind,
|
|
72
|
+
label: source.label,
|
|
73
|
+
text: body === "" ? "(non-text content; recover the entry from history)" : body,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
export function formatRecord(record: RecoveryRecord, limit: number): string {
|
|
77
|
+
return boundedBlock(
|
|
78
|
+
`[${record.label} | ${record.timestamp} | entry ${record.id}]`,
|
|
79
|
+
record.text,
|
|
80
|
+
limit,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
export function indexLine(record: RecoveryRecord): string {
|
|
84
|
+
const header = formatRecord(record, 0);
|
|
85
|
+
if (record.kind !== "owner") {
|
|
86
|
+
return header;
|
|
87
|
+
}
|
|
88
|
+
const preview = record.text.replace(/\s+/g, " ").trim();
|
|
89
|
+
return `${header} ${preview.length > 120 ? `${preview.slice(0, 119)}…` : preview}`;
|
|
90
|
+
}
|
package/src/recovery-results.ts
CHANGED
|
@@ -10,9 +10,13 @@ import {
|
|
|
10
10
|
} from "./message-text.ts";
|
|
11
11
|
|
|
12
12
|
export type RecoveryEntry = EntryLike & { readonly retained: boolean };
|
|
13
|
+
const INCOMPLETE_STOPS = new Set(["error", "aborted", "length"]);
|
|
13
14
|
type LinkedCall = { readonly entry: EntryLike; readonly block: ToolCallLike };
|
|
14
15
|
export type RecoveryBlock = { readonly header: string; readonly text: string };
|
|
15
|
-
export type RecoveryBatch = {
|
|
16
|
+
export type RecoveryBatch = {
|
|
17
|
+
readonly callId?: string | undefined;
|
|
18
|
+
readonly blocks: readonly RecoveryBlock[];
|
|
19
|
+
};
|
|
16
20
|
function callsById(entries: readonly EntryLike[]): Map<string, LinkedCall> {
|
|
17
21
|
const calls = new Map<string, LinkedCall>();
|
|
18
22
|
for (const entry of entries) {
|
|
@@ -30,14 +34,11 @@ function callsById(entries: readonly EntryLike[]): Map<string, LinkedCall> {
|
|
|
30
34
|
function trailingStart(entries: readonly EntryLike[]): number {
|
|
31
35
|
let hasTrailingResult = false;
|
|
32
36
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
33
|
-
const message = entries[i]
|
|
37
|
+
const message = entries[i]?.message;
|
|
34
38
|
if (message?.role === "toolResult") {
|
|
35
39
|
hasTrailingResult = true;
|
|
36
40
|
} else if (message?.role === "assistant") {
|
|
37
|
-
const invalid =
|
|
38
|
-
message.stopReason === "error" ||
|
|
39
|
-
message.stopReason === "aborted" ||
|
|
40
|
-
message.stopReason === "length";
|
|
41
|
+
const invalid = INCOMPLETE_STOPS.has(message.stopReason ?? "");
|
|
41
42
|
if (!invalid || hasTrailingResult) {
|
|
42
43
|
return i;
|
|
43
44
|
}
|
package/src/recovery.ts
CHANGED
|
@@ -1,19 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
AUTO_HANDOFF_PREFIX,
|
|
3
|
-
isRecord,
|
|
4
|
-
isReminderType,
|
|
5
|
-
type EntryLike,
|
|
6
|
-
type ProjectedEntry,
|
|
7
|
-
} from "./contracts.ts";
|
|
1
|
+
import { AUTO_HANDOFF_PREFIX, isRecord, type EntryLike, type ProjectedEntry } from "./contracts.ts";
|
|
8
2
|
import { contextEdits, contextStart, windowHandoff } from "./history-entries.ts";
|
|
3
|
+
import { boundedBlock, excerpt, toolCalls } from "./message-text.ts";
|
|
9
4
|
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
} from "./message-text.ts";
|
|
5
|
+
formatRecord,
|
|
6
|
+
indexLine,
|
|
7
|
+
recoveryRecord,
|
|
8
|
+
type RecoveryRecord,
|
|
9
|
+
} from "./recovery-records.ts";
|
|
10
|
+
import type { NotesRecovery } from "./note-evidence.ts";
|
|
17
11
|
import { renderSessionNotes, type RetainedSpan } from "./recovery-notes.ts";
|
|
18
12
|
import {
|
|
19
13
|
recoverableToolResults,
|
|
@@ -66,90 +60,6 @@ function buildRolloverPreamble(span: RetainedSpan | undefined): string {
|
|
|
66
60
|
: `entries ${span.firstId} through ${span.lastId} above are the carried-over recent tail`;
|
|
67
61
|
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
62
|
}
|
|
69
|
-
type RecoveryRecord = {
|
|
70
|
-
readonly id: string;
|
|
71
|
-
readonly timestamp: string;
|
|
72
|
-
readonly kind: "owner" | "coordination";
|
|
73
|
-
readonly label: string;
|
|
74
|
-
readonly text: string;
|
|
75
|
-
};
|
|
76
|
-
type RecordSource = {
|
|
77
|
-
readonly kind: RecoveryRecord["kind"];
|
|
78
|
-
readonly label: string;
|
|
79
|
-
readonly content: unknown;
|
|
80
|
-
};
|
|
81
|
-
function ownerAnswer(entry: EntryLike, ownerQuestion: boolean): boolean {
|
|
82
|
-
const message = entry.message;
|
|
83
|
-
return (
|
|
84
|
-
ownerQuestion &&
|
|
85
|
-
entry.type === "message" &&
|
|
86
|
-
message?.role === "toolResult" &&
|
|
87
|
-
message.toolName === "ask_question" &&
|
|
88
|
-
message.namespace === undefined &&
|
|
89
|
-
message.isError !== true
|
|
90
|
-
);
|
|
91
|
-
}
|
|
92
|
-
function recordSource(entry: EntryLike, ownerQuestion: boolean): RecordSource | undefined {
|
|
93
|
-
if (entry.type === "message" && entry.message?.role === "user") {
|
|
94
|
-
return { kind: "owner", label: "owner input", content: entry.message.content };
|
|
95
|
-
}
|
|
96
|
-
if (ownerAnswer(entry, ownerQuestion)) {
|
|
97
|
-
return {
|
|
98
|
-
kind: "owner",
|
|
99
|
-
label: "owner answer via ask_question",
|
|
100
|
-
content: entry.message?.content,
|
|
101
|
-
};
|
|
102
|
-
}
|
|
103
|
-
if (
|
|
104
|
-
entry.type === "custom_message" &&
|
|
105
|
-
entry.display === true &&
|
|
106
|
-
!isReminderType(entry.customType)
|
|
107
|
-
) {
|
|
108
|
-
return {
|
|
109
|
-
kind: "coordination",
|
|
110
|
-
label: `visible ${(entry.customType ?? "custom").slice(0, 80)} coordination input (not direct owner input)`,
|
|
111
|
-
content: entry.content,
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
return;
|
|
115
|
-
}
|
|
116
|
-
function recoveryRecord(entry: EntryLike, ownerQuestion: boolean): RecoveryRecord | undefined {
|
|
117
|
-
const source = recordSource(entry, ownerQuestion);
|
|
118
|
-
if (source === undefined) {
|
|
119
|
-
return undefined;
|
|
120
|
-
}
|
|
121
|
-
const id = entry.id?.slice(0, 120) ?? "unknown";
|
|
122
|
-
const text = textOf({ content: source.content }).trim();
|
|
123
|
-
const summary =
|
|
124
|
-
source.label === "owner answer via ask_question"
|
|
125
|
-
? ""
|
|
126
|
-
: imageSummary(imagesOf(source.content));
|
|
127
|
-
const body = [text, summary === "" ? "" : `${summary} — recover with history read id ${id}`]
|
|
128
|
-
.filter((part) => part !== "")
|
|
129
|
-
.join("\n");
|
|
130
|
-
return {
|
|
131
|
-
id,
|
|
132
|
-
timestamp: entry.timestamp?.slice(0, 80) ?? "unknown time",
|
|
133
|
-
kind: source.kind,
|
|
134
|
-
label: source.label,
|
|
135
|
-
text: body === "" ? "(non-text content; recover the entry from history)" : body,
|
|
136
|
-
};
|
|
137
|
-
}
|
|
138
|
-
function formatRecord(record: RecoveryRecord, limit: number): string {
|
|
139
|
-
return boundedBlock(
|
|
140
|
-
`[${record.label} | ${record.timestamp} | entry ${record.id}]`,
|
|
141
|
-
record.text,
|
|
142
|
-
limit,
|
|
143
|
-
);
|
|
144
|
-
}
|
|
145
|
-
function indexLine(record: RecoveryRecord): string {
|
|
146
|
-
const header = formatRecord(record, 0);
|
|
147
|
-
if (record.kind !== "owner") {
|
|
148
|
-
return header;
|
|
149
|
-
}
|
|
150
|
-
const preview = record.text.replace(/\s+/g, " ").trim();
|
|
151
|
-
return `${header} ${preview.length > 120 ? `${preview.slice(0, 119)}…` : preview}`;
|
|
152
|
-
}
|
|
153
63
|
function priorCheckpoint(entry: EntryLike | undefined, limit: number): string | undefined {
|
|
154
64
|
const handoff = windowHandoff(entry)?.trim();
|
|
155
65
|
if (entry === undefined || handoff === undefined || handoff === "") {
|
|
@@ -265,7 +175,7 @@ function recoveryAncestry(
|
|
|
265
175
|
ownerQuestionRegistered: boolean,
|
|
266
176
|
retained: ReadonlySet<string>,
|
|
267
177
|
): {
|
|
268
|
-
readonly checkpoint?: EntryLike;
|
|
178
|
+
readonly checkpoint?: EntryLike | undefined;
|
|
269
179
|
readonly carried: readonly RecoveryRecord[];
|
|
270
180
|
readonly skipped: readonly string[];
|
|
271
181
|
} {
|
|
@@ -296,13 +206,15 @@ function recoveryAncestry(
|
|
|
296
206
|
return { checkpoint: boundary, carried: [], skipped };
|
|
297
207
|
}
|
|
298
208
|
function skippedRecords(ids: readonly string[], limit: number): string | undefined {
|
|
299
|
-
|
|
209
|
+
const remaining = ids.values();
|
|
210
|
+
const first = remaining.next();
|
|
211
|
+
if (first.done === true) {
|
|
300
212
|
return undefined;
|
|
301
213
|
}
|
|
302
|
-
let names =
|
|
214
|
+
let names = first.value;
|
|
303
215
|
let count = 1;
|
|
304
|
-
|
|
305
|
-
const next = `, ${
|
|
216
|
+
for (const id of remaining) {
|
|
217
|
+
const next = `, ${id}`;
|
|
306
218
|
if (names.length + next.length > limit) {
|
|
307
219
|
break;
|
|
308
220
|
}
|
|
@@ -335,7 +247,7 @@ function joined(parts: readonly (string | undefined)[]): string {
|
|
|
335
247
|
}
|
|
336
248
|
type BatchPlan = {
|
|
337
249
|
readonly header?: string;
|
|
338
|
-
readonly omission?: string;
|
|
250
|
+
readonly omission?: string | undefined;
|
|
339
251
|
readonly blocks: readonly RecoveryBlock[];
|
|
340
252
|
readonly bare?: string;
|
|
341
253
|
};
|
|
@@ -359,8 +271,8 @@ function planBatch(
|
|
|
359
271
|
}
|
|
360
272
|
let budget = available;
|
|
361
273
|
let first = batch.blocks.length;
|
|
362
|
-
|
|
363
|
-
const length =
|
|
274
|
+
for (const block of batch.blocks.toReversed()) {
|
|
275
|
+
const length = block.header.length + 2;
|
|
364
276
|
if (length > budget) {
|
|
365
277
|
break;
|
|
366
278
|
}
|
|
@@ -417,8 +329,8 @@ function renderIndex(records: readonly RecoveryRecord[], available: number): str
|
|
|
417
329
|
}
|
|
418
330
|
let budget = available;
|
|
419
331
|
const listed: string[] = [];
|
|
420
|
-
for (
|
|
421
|
-
const line = indexLine(
|
|
332
|
+
for (const record of records.toReversed()) {
|
|
333
|
+
const line = indexLine(record);
|
|
422
334
|
if (line.length + 1 > budget) {
|
|
423
335
|
break;
|
|
424
336
|
}
|
|
@@ -469,6 +381,7 @@ function rolloverPreambleWithNotes(
|
|
|
469
381
|
options: {
|
|
470
382
|
readonly retained: ReadonlySet<string>;
|
|
471
383
|
readonly reserveParts: readonly (string | undefined)[];
|
|
384
|
+
readonly notes?: NotesRecovery | undefined;
|
|
472
385
|
readonly maxChars: number;
|
|
473
386
|
},
|
|
474
387
|
): string {
|
|
@@ -483,6 +396,7 @@ function rolloverPreambleWithNotes(
|
|
|
483
396
|
{
|
|
484
397
|
retained,
|
|
485
398
|
span,
|
|
399
|
+
notes: options.notes,
|
|
486
400
|
maxChars: Math.min(
|
|
487
401
|
MAX_NOTES_SECTION_CHARS,
|
|
488
402
|
Math.max(0, maxChars - reserveLen - HANDOFF_OVERHEAD_RESERVE),
|
|
@@ -499,6 +413,7 @@ export function buildAutoHandoff(
|
|
|
499
413
|
options: {
|
|
500
414
|
readonly ownerQuestionRegistered: boolean;
|
|
501
415
|
readonly retainedEntryIds?: ReadonlySet<string>;
|
|
416
|
+
readonly notes?: NotesRecovery | undefined;
|
|
502
417
|
},
|
|
503
418
|
): string {
|
|
504
419
|
const { ownerQuestionRegistered, retainedEntryIds: retained = new Set<string>() } = options;
|
|
@@ -530,6 +445,7 @@ export function buildAutoHandoff(
|
|
|
530
445
|
retained,
|
|
531
446
|
reserveParts,
|
|
532
447
|
maxChars,
|
|
448
|
+
notes: options.notes,
|
|
533
449
|
});
|
|
534
450
|
const minimum = [preamble, ...reserveParts];
|
|
535
451
|
const plan = planBatch(
|
package/src/reminders.ts
CHANGED
|
@@ -22,7 +22,7 @@ export type ReminderLookup = {
|
|
|
22
22
|
};
|
|
23
23
|
export type StaleReminderPredicate = (message: {
|
|
24
24
|
readonly role: string;
|
|
25
|
-
readonly customType?: string;
|
|
25
|
+
readonly customType?: string | undefined;
|
|
26
26
|
readonly details?: unknown;
|
|
27
27
|
}) => boolean;
|
|
28
28
|
|
|
@@ -53,8 +53,8 @@ function onlyReminders(entries: readonly EntryLike[]): EntryLike[] {
|
|
|
53
53
|
);
|
|
54
54
|
}
|
|
55
55
|
type ScanProgress = {
|
|
56
|
-
readonly boundary?: EntryLike;
|
|
57
|
-
readonly window?: EntryLike;
|
|
56
|
+
readonly boundary?: EntryLike | undefined;
|
|
57
|
+
readonly window?: EntryLike | undefined;
|
|
58
58
|
readonly kept: boolean;
|
|
59
59
|
};
|
|
60
60
|
function advanceBoundary(entry: EntryLike, progress: ScanProgress): ScanProgress {
|
package/src/retention.ts
CHANGED
|
@@ -14,16 +14,18 @@ import {
|
|
|
14
14
|
type MessageLike,
|
|
15
15
|
type PolicyContext,
|
|
16
16
|
} from "./contracts.ts";
|
|
17
|
+
import type { NotesRecovery } from "./note-evidence.ts";
|
|
17
18
|
import { buildAutoHandoff } from "./recovery.ts";
|
|
18
19
|
|
|
19
20
|
type RecoveryCapacity = {
|
|
20
21
|
readonly host: PolicyContext;
|
|
21
22
|
readonly toolTokens: number;
|
|
22
23
|
readonly pendingMessages?: readonly MessageLike[];
|
|
23
|
-
readonly firstKeptEntryId?: string;
|
|
24
|
+
readonly firstKeptEntryId?: string | undefined;
|
|
25
|
+
readonly notes?: NotesRecovery | undefined;
|
|
24
26
|
};
|
|
25
27
|
type RetainedTail = {
|
|
26
|
-
readonly firstKeptEntryId?: string;
|
|
28
|
+
readonly firstKeptEntryId?: string | undefined;
|
|
27
29
|
readonly ids: ReadonlySet<string>;
|
|
28
30
|
};
|
|
29
31
|
type Immutable<T> = T extends string | number | boolean | null | undefined
|
|
@@ -128,26 +130,29 @@ function boundedTail(
|
|
|
128
130
|
start: number,
|
|
129
131
|
maxTokens: number,
|
|
130
132
|
): RetainedTail {
|
|
131
|
-
const costs = entries.map((entry) =>
|
|
132
|
-
entry
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
133
|
+
const costs = entries.slice(start).map((entry) => ({
|
|
134
|
+
entry,
|
|
135
|
+
cost:
|
|
136
|
+
entry.sourceEntry.type === "compaction"
|
|
137
|
+
? 0
|
|
138
|
+
: visibleMessages(entry).reduce(
|
|
139
|
+
(sum, message) => sum + estimateTokens(nativeMessage(message)),
|
|
140
|
+
0,
|
|
141
|
+
),
|
|
142
|
+
}));
|
|
143
|
+
let tokens = costs.reduce((sum, { cost }) => sum + cost, 0);
|
|
144
|
+
for (const [offset, { entry, cost }] of costs.entries()) {
|
|
145
|
+
const index = start + offset;
|
|
146
|
+
if (tokens > 0 && tokens <= maxTokens && safeStart(entry)) {
|
|
142
147
|
const tail = entries.slice(index);
|
|
143
148
|
if (pairedTail(tail)) {
|
|
144
149
|
return {
|
|
145
|
-
firstKeptEntryId:
|
|
146
|
-
ids: new Set(tail.map((
|
|
150
|
+
firstKeptEntryId: entry.sourceEntry.id,
|
|
151
|
+
ids: new Set(tail.map((retained) => retained.sourceEntry.id)),
|
|
147
152
|
};
|
|
148
153
|
}
|
|
149
154
|
}
|
|
150
|
-
tokens -=
|
|
155
|
+
tokens -= cost;
|
|
151
156
|
}
|
|
152
157
|
return { ids: new Set() };
|
|
153
158
|
}
|
|
@@ -181,7 +186,7 @@ export function automaticRecovery(
|
|
|
181
186
|
projected: readonly ProjectionEntry[],
|
|
182
187
|
capacity: RecoveryCapacity,
|
|
183
188
|
ownerQuestionRegistered: boolean,
|
|
184
|
-
): { readonly handoff: string; readonly firstKeptEntryId?: string } | undefined {
|
|
189
|
+
): { readonly handoff: string; readonly firstKeptEntryId?: string | undefined } | undefined {
|
|
185
190
|
const payload = freshPayloadChars(
|
|
186
191
|
capacity.host,
|
|
187
192
|
capacity.toolTokens,
|
|
@@ -196,6 +201,7 @@ export function automaticRecovery(
|
|
|
196
201
|
const handoff = buildAutoHandoff(entries, projected, limit, {
|
|
197
202
|
ownerQuestionRegistered,
|
|
198
203
|
retainedEntryIds: retained.ids,
|
|
204
|
+
notes: capacity.notes,
|
|
199
205
|
});
|
|
200
206
|
return handoff.length > limit
|
|
201
207
|
? undefined
|
package/src/rollover.ts
CHANGED
|
@@ -1,29 +1,30 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
BoundaryResult,
|
|
3
|
+
ContextEvent,
|
|
3
4
|
ExtensionAPI,
|
|
4
5
|
ExtensionContext,
|
|
5
6
|
TurnEndEvent,
|
|
6
7
|
} from "@earendil-works/pi-coding-agent";
|
|
7
|
-
import {
|
|
8
|
-
budgetFor,
|
|
9
|
-
buildGuidance,
|
|
10
|
-
CHECKPOINT_STEPS,
|
|
11
|
-
freshPayloadChars,
|
|
12
|
-
type Budget,
|
|
13
|
-
} from "./budget.ts";
|
|
8
|
+
import { budgetFor, CHECKPOINT_STEPS, freshPayloadChars, type Budget } from "./budget.ts";
|
|
14
9
|
import {
|
|
15
10
|
isRecord,
|
|
16
11
|
isReminderType,
|
|
17
12
|
REMINDER_TYPE,
|
|
18
|
-
type CompactionPolicy,
|
|
19
13
|
type PolicyContext,
|
|
20
14
|
type ProjectedEntry,
|
|
21
15
|
type ReminderFingerprint,
|
|
16
|
+
type SnapshotReader,
|
|
22
17
|
} from "./contracts.ts";
|
|
23
18
|
import { registerContextTools } from "./context-tools.ts";
|
|
19
|
+
import { registerGuidance } from "./guidance.ts";
|
|
24
20
|
import { safeJsonStringify } from "./message-text.ts";
|
|
25
21
|
import { hasRetainedRolloverMarker } from "./recovery.ts";
|
|
26
22
|
import { automaticRecovery } from "./retention.ts";
|
|
23
|
+
import type { NotesRecovery } from "./note-evidence.ts";
|
|
24
|
+
type RolloverResources = {
|
|
25
|
+
readonly resetPages: () => void;
|
|
26
|
+
readonly notes: (ctx: ExtensionContext) => NotesRecovery | undefined;
|
|
27
|
+
};
|
|
27
28
|
import {
|
|
28
29
|
countRetainedMessages,
|
|
29
30
|
createReminderLookup,
|
|
@@ -33,48 +34,7 @@ import {
|
|
|
33
34
|
type StaleReminderPredicate,
|
|
34
35
|
} from "./reminders.ts";
|
|
35
36
|
|
|
36
|
-
export type SnapshotReader = (
|
|
37
|
-
ctx: ExtensionContext,
|
|
38
|
-
livePolicy?: CompactionPolicy,
|
|
39
|
-
) => PolicyContext;
|
|
40
37
|
const REMINDER_BUFFER_TOKENS = 32_000;
|
|
41
|
-
function registerGuidance(pi: ExtensionAPI, policy: SnapshotReader): void {
|
|
42
|
-
let promptOptions: { readonly forceSystemPrompt?: string } | undefined;
|
|
43
|
-
pi.on("before_agent_start", (event) => {
|
|
44
|
-
promptOptions = event.systemPromptOptions;
|
|
45
|
-
});
|
|
46
|
-
pi.on("agent_settled", () => {
|
|
47
|
-
// Prompt options belong to one run, never to later idle context evaluation.
|
|
48
|
-
promptOptions = undefined;
|
|
49
|
-
});
|
|
50
|
-
pi.on("context_with_system", (event, ctx) => {
|
|
51
|
-
const guidance = buildGuidance(policy(ctx));
|
|
52
|
-
const system =
|
|
53
|
-
event.messages.findLast(
|
|
54
|
-
(message) =>
|
|
55
|
-
message.role === "system" && typeof message.sections?.posthorse === "string",
|
|
56
|
-
) ?? event.messages.find((message) => message.role === "system");
|
|
57
|
-
if (system === undefined || system.role !== "system") {
|
|
58
|
-
return;
|
|
59
|
-
}
|
|
60
|
-
if (promptOptions?.forceSystemPrompt !== undefined) {
|
|
61
|
-
// ponytail: Pi applies forced prompts after this hook; keep request-local guidance
|
|
62
|
-
// until the host projects forced text before context_with_system.
|
|
63
|
-
event.messages.splice(1, 0, {
|
|
64
|
-
role: "custom",
|
|
65
|
-
customType: "posthorse-guidance",
|
|
66
|
-
content: guidance,
|
|
67
|
-
display: false,
|
|
68
|
-
timestamp: 0,
|
|
69
|
-
});
|
|
70
|
-
} else {
|
|
71
|
-
system.sections = {
|
|
72
|
-
...system.sections,
|
|
73
|
-
posthorse: `<posthorse>\n${guidance}\n</posthorse>`,
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
});
|
|
77
|
-
}
|
|
78
38
|
function explicitReset(
|
|
79
39
|
event: TurnEndEvent,
|
|
80
40
|
ctx: ExtensionContext,
|
|
@@ -150,7 +110,7 @@ function sendReminder(
|
|
|
150
110
|
readonly tokens: number;
|
|
151
111
|
readonly pendingMessages: readonly {
|
|
152
112
|
readonly role: string;
|
|
153
|
-
readonly customType?: string;
|
|
113
|
+
readonly customType?: string | undefined;
|
|
154
114
|
readonly details?: unknown;
|
|
155
115
|
}[];
|
|
156
116
|
},
|
|
@@ -188,7 +148,7 @@ const NO_STALE_REMINDER: StaleReminderPredicate = () => false;
|
|
|
188
148
|
function staleReminderPredicate(
|
|
189
149
|
messages: readonly {
|
|
190
150
|
readonly role: string;
|
|
191
|
-
readonly customType?: string;
|
|
151
|
+
readonly customType?: string | undefined;
|
|
192
152
|
readonly details?: unknown;
|
|
193
153
|
}[],
|
|
194
154
|
ctx: ExtensionContext,
|
|
@@ -225,6 +185,40 @@ function staleReminderPredicate(
|
|
|
225
185
|
isReminderType(message.customType) &&
|
|
226
186
|
(!enabled || !reminderMatches(message.details, fingerprint));
|
|
227
187
|
}
|
|
188
|
+
function chronologicalRetainedTail(
|
|
189
|
+
messages: ContextEvent["messages"],
|
|
190
|
+
ctx: ExtensionContext,
|
|
191
|
+
stale: StaleReminderPredicate,
|
|
192
|
+
): ContextEvent["messages"] {
|
|
193
|
+
const first = messages[0];
|
|
194
|
+
if (
|
|
195
|
+
messages.length >= 2 &&
|
|
196
|
+
first?.role === "compactionSummary" &&
|
|
197
|
+
hasRetainedRolloverMarker(first.summary)
|
|
198
|
+
) {
|
|
199
|
+
const count = countRetainedMessages(ctx, stale);
|
|
200
|
+
if (count > 0 && 1 + count <= messages.length) {
|
|
201
|
+
const retainedSlice = messages.slice(1, 1 + count);
|
|
202
|
+
const afterSlice = messages.slice(1 + count);
|
|
203
|
+
if (retainedSlice[0]?.role !== "assistant") {
|
|
204
|
+
return [...retainedSlice, first, ...afterSlice];
|
|
205
|
+
}
|
|
206
|
+
return [
|
|
207
|
+
{
|
|
208
|
+
role: "custom",
|
|
209
|
+
customType: "posthorse-retained-tail",
|
|
210
|
+
content: "[Retained pre-rollover tail]",
|
|
211
|
+
display: false,
|
|
212
|
+
timestamp: first.timestamp,
|
|
213
|
+
},
|
|
214
|
+
...retainedSlice,
|
|
215
|
+
first,
|
|
216
|
+
...afterSlice,
|
|
217
|
+
];
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return messages;
|
|
221
|
+
}
|
|
228
222
|
function registerReminderFilter(
|
|
229
223
|
pi: ExtensionAPI,
|
|
230
224
|
policy: SnapshotReader,
|
|
@@ -235,37 +229,9 @@ function registerReminderFilter(
|
|
|
235
229
|
const filtered = event.messages.some(stale)
|
|
236
230
|
? event.messages.filter((message) => !stale(message))
|
|
237
231
|
: event.messages;
|
|
238
|
-
const
|
|
239
|
-
if (
|
|
240
|
-
|
|
241
|
-
first.role === "compactionSummary" &&
|
|
242
|
-
hasRetainedRolloverMarker(first.summary)
|
|
243
|
-
) {
|
|
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
|
-
}
|
|
266
|
-
}
|
|
267
|
-
if (filtered !== event.messages) {
|
|
268
|
-
return { messages: filtered };
|
|
232
|
+
const ordered = chronologicalRetainedTail(filtered, ctx, stale);
|
|
233
|
+
if (ordered !== event.messages) {
|
|
234
|
+
return { messages: ordered };
|
|
269
235
|
}
|
|
270
236
|
return;
|
|
271
237
|
});
|
|
@@ -277,7 +243,7 @@ function automaticDraft(
|
|
|
277
243
|
readonly handoff: string;
|
|
278
244
|
readonly tokensBefore: number;
|
|
279
245
|
readonly reason: string;
|
|
280
|
-
readonly firstKeptEntryId?: string;
|
|
246
|
+
readonly firstKeptEntryId?: string | undefined;
|
|
281
247
|
},
|
|
282
248
|
) {
|
|
283
249
|
let firstKeptEntryId = recovery.firstKeptEntryId;
|
|
@@ -352,7 +318,11 @@ function thresholdFallback(
|
|
|
352
318
|
pi: ExtensionAPI,
|
|
353
319
|
event: TurnEndEvent,
|
|
354
320
|
ctx: ExtensionContext,
|
|
355
|
-
capacity: {
|
|
321
|
+
capacity: {
|
|
322
|
+
readonly host: PolicyContext;
|
|
323
|
+
readonly toolTokens: () => number;
|
|
324
|
+
readonly notes: RolloverResources["notes"];
|
|
325
|
+
},
|
|
356
326
|
): BoundaryResult | undefined {
|
|
357
327
|
if (!fallbackTurnEligible(event, ctx)) {
|
|
358
328
|
return undefined;
|
|
@@ -371,7 +341,12 @@ function thresholdFallback(
|
|
|
371
341
|
const recovery = automaticRecovery(
|
|
372
342
|
ctx.sessionManager.getBranch(),
|
|
373
343
|
projected,
|
|
374
|
-
{
|
|
344
|
+
{
|
|
345
|
+
host,
|
|
346
|
+
toolTokens: toolTokens(),
|
|
347
|
+
pendingMessages: event.context.pendingMessages,
|
|
348
|
+
notes: capacity.notes(ctx),
|
|
349
|
+
},
|
|
375
350
|
ownerQuestionRegistered,
|
|
376
351
|
);
|
|
377
352
|
if (recovery === undefined) {
|
|
@@ -396,6 +371,7 @@ function registerAutomaticRollover(
|
|
|
396
371
|
pi: ExtensionAPI,
|
|
397
372
|
policy: SnapshotReader,
|
|
398
373
|
activeToolTokens: () => number,
|
|
374
|
+
notes: RolloverResources["notes"],
|
|
399
375
|
): void {
|
|
400
376
|
pi.on("session_before_compact", (event, ctx) => {
|
|
401
377
|
if (event.reason === "manual") {
|
|
@@ -420,6 +396,7 @@ function registerAutomaticRollover(
|
|
|
420
396
|
host,
|
|
421
397
|
toolTokens: activeToolTokens(),
|
|
422
398
|
firstKeptEntryId: event.preparation.firstKeptEntryId,
|
|
399
|
+
notes: notes(ctx),
|
|
423
400
|
},
|
|
424
401
|
ownerQuestionRegistered,
|
|
425
402
|
);
|
|
@@ -450,13 +427,13 @@ export function registerRollover(
|
|
|
450
427
|
pi: ExtensionAPI,
|
|
451
428
|
policy: SnapshotReader,
|
|
452
429
|
activeToolTokens: () => number,
|
|
453
|
-
|
|
430
|
+
resources: RolloverResources,
|
|
454
431
|
): void {
|
|
455
432
|
const resetRequests = new Map<string, string>();
|
|
456
433
|
const reminders = createReminderLookup();
|
|
457
434
|
const resetTurn = () => {
|
|
458
435
|
resetRequests.clear();
|
|
459
|
-
resetPages();
|
|
436
|
+
resources.resetPages();
|
|
460
437
|
};
|
|
461
438
|
pi.on("turn_start", resetTurn);
|
|
462
439
|
pi.on("session_start", () => {
|
|
@@ -468,7 +445,7 @@ export function registerRollover(
|
|
|
468
445
|
});
|
|
469
446
|
registerGuidance(pi, policy);
|
|
470
447
|
registerReminderFilter(pi, policy, reminders);
|
|
471
|
-
registerAutomaticRollover(pi, policy, activeToolTokens);
|
|
448
|
+
registerAutomaticRollover(pi, policy, activeToolTokens, resources.notes);
|
|
472
449
|
pi.on("turn_end", (event, ctx) => {
|
|
473
450
|
const requested = event.toolResults.find(
|
|
474
451
|
(result) => result.toolName === "new_context" && resetRequests.has(result.toolCallId),
|
|
@@ -491,6 +468,7 @@ export function registerRollover(
|
|
|
491
468
|
const fallback = thresholdFallback(pi, event, ctx, {
|
|
492
469
|
host,
|
|
493
470
|
toolTokens: activeToolTokens,
|
|
471
|
+
notes: resources.notes,
|
|
494
472
|
});
|
|
495
473
|
if (fallback !== undefined) {
|
|
496
474
|
return fallback;
|