pi-posthorse 0.9.2 → 0.10.0
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 +19 -16
- package/index.ts +1 -1
- package/package.json +6 -6
- package/src/budget.ts +3 -3
- package/src/context-tools.ts +3 -3
- package/src/contracts.ts +1 -0
- package/src/files.ts +100 -4
- package/src/notes.ts +4 -4
- package/src/recovery-results.ts +10 -2
- package/src/recovery.ts +58 -21
- package/src/retention.ts +203 -0
- package/src/rollover.ts +29 -20
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# Posthorse
|
|
2
2
|
|
|
3
|
-
Posthorse starts fresh context windows for long [Pi](https://github.com/earendil-works/pi) tasks
|
|
3
|
+
Posthorse starts fresh context windows for long [Pi](https://github.com/earendil-works/pi) tasks without an automatic model-generated summary. Stock Pi already preserves the session journal and retains a recent tail during compaction; Posthorse adds explicit notes and history retrieval, with a handoff or bounded recovery record instead of the automatic summary.
|
|
4
4
|
|
|
5
5
|

|
|
6
6
|
|
|
7
7
|
## Install and start
|
|
8
8
|
|
|
9
|
-
Use Node 24.12.0 or later and Pi 1.
|
|
9
|
+
Use Node 24.12.0 or later and Pi 1.3.0 or later. Posthorse supports official Pi and [fitchmultz/pi](https://github.com/fitchmultz/pi).
|
|
10
10
|
|
|
11
11
|
Update an older Pi installation before you install Posthorse. Run these commands:
|
|
12
12
|
|
|
@@ -23,22 +23,24 @@ Give Pi your task. For example:
|
|
|
23
23
|
|
|
24
24
|
## How rollover works
|
|
25
25
|
|
|
26
|
-
Posthorse can
|
|
26
|
+
Posthorse can nudge the model to update task notes before rollover, then continue working. The nudge does not require `new_context` or wake an agent after its final answer. Large turns can reach rollover before the nudge.
|
|
27
27
|
|
|
28
|
-
Automatic rollover
|
|
28
|
+
Automatic rollover retains a safe recent-message tail using Pi's `compaction.keepRecentTokens`, alongside a bounded recovery record for discarded inputs and tool evidence. It never calls a summarization model. The model must restore notes and check live state before it continues. Optional `new_context` deliberately retains no prior messages; manual `/compact` uses Pi's normal summarization.
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
Safe recent-tail retention and the notes-only reminder are part of the [0.10.0 changes](CHANGELOG.md#0100). Match these docs to your installed version using the changelog; default-branch documentation may describe changes not yet published.
|
|
31
|
+
|
|
32
|
+
Recoverable history is not guaranteed model recall. See the [practical guarantees and limits](docs/context-recovery.md) for the stock-Pi comparison, search scope, checkpoints, privacy, concurrency and cost, or the [rollover reference](docs/reference.md#how-it-works) for implementation details.
|
|
31
33
|
|
|
32
34
|
## Tools
|
|
33
35
|
|
|
34
36
|
These tools are available to the model:
|
|
35
37
|
|
|
36
|
-
| Tool | Use
|
|
37
|
-
| --------------------------- |
|
|
38
|
-
| `new_context({ handoff? })` |
|
|
39
|
-
| `get_context_remaining()` | Estimate tokens left until rollover and the configured context limit. Usage can be unknown before a model response.
|
|
40
|
-
| `notes({ op, ... })` | List, read, search, write, or append task notes.
|
|
41
|
-
| `history({ op, ... })` | Search earlier conversation and read entries, including images. `all: true` includes this project's other sessions and nested subagent runs.
|
|
38
|
+
| Tool | Use |
|
|
39
|
+
| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
40
|
+
| `new_context({ handoff? })` | Optionally request a clean reset with no retained messages after every foreground tool succeeds. Aborted turns and failed tools prevent the reset. |
|
|
41
|
+
| `get_context_remaining()` | Estimate tokens left until rollover and the configured context limit. Usage can be unknown before a model response. |
|
|
42
|
+
| `notes({ op, ... })` | List, read, search, write, or append task notes. |
|
|
43
|
+
| `history({ op, ... })` | Search earlier conversation and read entries, including images. `all: true` includes this project's other sessions and nested subagent runs. |
|
|
42
44
|
|
|
43
45
|
Expand a tool card with Ctrl+O. In fullscreen mode, click a tool card to expand it. A `new_context` card shows a request. Pi's `Compacted from … tokens` card confirms the rollover.
|
|
44
46
|
|
|
@@ -48,10 +50,11 @@ See [tool examples and paging details](docs/reference.md#tools).
|
|
|
48
50
|
|
|
49
51
|
Posthorse uses Pi's effective `compaction` settings. It has no separate settings.
|
|
50
52
|
|
|
51
|
-
| Pi setting
|
|
52
|
-
|
|
|
53
|
-
| `compaction.enabled`
|
|
54
|
-
| `compaction.reserveTokens`
|
|
53
|
+
| Pi setting | Effect |
|
|
54
|
+
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
55
|
+
| `compaction.enabled` | Set to `false` to disable reminders and automatic rollover. `new_context` remains available. |
|
|
56
|
+
| `compaction.reserveTokens` | Automatic Posthorse rollover needs at least 10,000 tokens after this reserve. Smaller budgets use Pi's own compaction. Lower the reserve or choose a larger model. |
|
|
57
|
+
| `compaction.keepRecentTokens` | Approximate recent tail for automatic rollover (Pi's default: 20,000). Zero keeps no messages. Tail size shrinks when safe groups cannot fit the fresh budget. |
|
|
55
58
|
|
|
56
59
|
Two Posthorse copies can register the same tools and compete for rollover. Keep one copy loaded. Check installed sources with `pi list`.
|
|
57
60
|
|
|
@@ -73,7 +76,7 @@ Removal leaves notes and Pi session history in place. See the [installation refe
|
|
|
73
76
|
|
|
74
77
|
## Data and privacy
|
|
75
78
|
|
|
76
|
-
Posthorse makes no network requests. Notes are plaintext files under `.pi/notes/`; linked Git worktrees share them. Add `.pi/notes/` to `.gitignore` if the notes must stay private.
|
|
79
|
+
Posthorse makes no network requests. Notes are plaintext files under `.pi/notes/`; linked Git worktrees share them. Notes for Pi-managed Git packages live outside their checkouts because `pi update` deletes untracked files there: global packages (including their descendants) use Pi's agent-directory `notes/`, and other project-local packages use the containing project's `.pi/package-notes/`, with project-local stores keyed by a full SHA-256 digest of the host and package path. Access existing checkout-local notes with this version before updating the package so they can migrate. Add `.pi/notes/` and `.pi/package-notes/` to the containing project's `.gitignore` if the notes must stay private.
|
|
77
80
|
|
|
78
81
|
History can include user messages, assistant thinking, tool arguments, errors, and images. Content you retrieve enters the context of your selected model and provider.
|
|
79
82
|
|
package/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Posthorse — fresh context, same journey.
|
|
3
3
|
*
|
|
4
|
-
* Pi owns persisted
|
|
4
|
+
* Pi owns persisted context boundaries. Posthorse owns recent-tail retention, reminder and
|
|
5
5
|
* rollover policy, bounded recovery, durable notes, and history retrieval.
|
|
6
6
|
*/
|
|
7
7
|
import {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-posthorse",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Posthorse: fresh context, same journey. No-summary context rollover for Pi, with sparse budget reminders, handoffs, notes, and history.",
|
|
6
6
|
"keywords": [
|
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
"prepublishOnly": "npm run verify"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
|
-
"@earendil-works/pi-coding-agent": "
|
|
52
|
-
"@earendil-works/pi-tui": "
|
|
51
|
+
"@earendil-works/pi-coding-agent": ">=1.3.0",
|
|
52
|
+
"@earendil-works/pi-tui": ">=1.3.0",
|
|
53
53
|
"typebox": "*"
|
|
54
54
|
},
|
|
55
55
|
"peerDependenciesMeta": {
|
|
@@ -64,9 +64,9 @@
|
|
|
64
64
|
}
|
|
65
65
|
},
|
|
66
66
|
"devDependencies": {
|
|
67
|
-
"@earendil-works/pi-ai": "1.
|
|
68
|
-
"@earendil-works/pi-coding-agent": "1.
|
|
69
|
-
"@earendil-works/pi-tui": "1.
|
|
67
|
+
"@earendil-works/pi-ai": "1.3.0",
|
|
68
|
+
"@earendil-works/pi-coding-agent": "1.3.0",
|
|
69
|
+
"@earendil-works/pi-tui": "1.3.0",
|
|
70
70
|
"@types/node": "24.19.1",
|
|
71
71
|
"oxc-parser": "0.153.0",
|
|
72
72
|
"oxfmt": "0.72.0",
|
package/src/budget.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { imagesOf, textOf } from "./message-text.ts";
|
|
|
14
14
|
|
|
15
15
|
const MIN_USABLE_TOKENS = Math.ceil(MAX_HANDOFF_CHARS / 4) * 2;
|
|
16
16
|
export const CHECKPOINT_STEPS =
|
|
17
|
-
"update the task's current-state note (goal, progress, decisions, next steps) with available file-editing tools at its absolute path; use notes write only for creation, substantial restructuring, or when editing tools are unavailable. If the note is already current, leave it unchanged. Then
|
|
17
|
+
"update the task's current-state note (goal, progress, decisions, next steps) with available file-editing tools at its absolute path; use notes write only for creation, substantial restructuring, or when editing tools are unavailable. If the note is already current, leave it unchanged. Then continue working; let automatic rollover happen without calling new_context";
|
|
18
18
|
export type Budget = {
|
|
19
19
|
readonly contextWindow: number;
|
|
20
20
|
readonly reserveTokens: number;
|
|
@@ -84,7 +84,7 @@ function automaticGuidance(budget: Budget | undefined, enabled: boolean): string
|
|
|
84
84
|
budget === undefined
|
|
85
85
|
? ""
|
|
86
86
|
: ` The rollover line is ${budget.rolloverAt.toLocaleString("en-US")} tokens used under the available Pi settings.`;
|
|
87
|
-
return `Automatic rollover follows Pi's compaction setting.${line} At most one
|
|
87
|
+
return `Automatic rollover follows Pi's compaction setting and retains a safe recent-message tail using compaction.keepRecentTokens, without model summarization.${line} At most one notes reminder may arrive at the end of a working turn before the rollover line (${deadline}), never on a turn that ends the run; when it does, ${CHECKPOINT_STEPS}. new_context is an optional deliberate clean reset, not a checkpoint requirement.`;
|
|
88
88
|
}
|
|
89
89
|
export function buildGuidance(ctx: PolicyContext): string {
|
|
90
90
|
const budget = budgetFor(ctx);
|
|
@@ -92,7 +92,7 @@ export function buildGuidance(ctx: PolicyContext): string {
|
|
|
92
92
|
const capacity =
|
|
93
93
|
budget === undefined
|
|
94
94
|
? "Configured context capacity is unknown."
|
|
95
|
-
: `Configured context capacity: ${budget.contextWindow.toLocaleString("en-US")} tokens. This is Pi's best available native limit, not remaining space or a measured provider boundary. Fresh windows use the active configuration; system instructions, tools, the handoff, and new messages consume part of it.`;
|
|
95
|
+
: `Configured context capacity: ${budget.contextWindow.toLocaleString("en-US")} tokens. This is Pi's best available native limit, not remaining space or a measured provider boundary. Fresh windows use the active configuration; system instructions, tools, the handoff, retained recent messages, and new messages consume part of it.`;
|
|
96
96
|
return `## Context self-management (Posthorse)
|
|
97
97
|
${capacity}
|
|
98
98
|
Do not guess context capacity or remaining space from response-token limits or reasoning budgets. Before reporting a remaining-token count or changing your work plan because of context limits, call get_context_remaining. If usage is unknown, report it as unknown. No routine budget checks are needed.
|
package/src/context-tools.ts
CHANGED
|
@@ -60,8 +60,8 @@ export function registerContextTools(
|
|
|
60
60
|
label: "New Context",
|
|
61
61
|
...toolCards("new_context"),
|
|
62
62
|
description:
|
|
63
|
-
"
|
|
64
|
-
promptSnippet: "request a
|
|
63
|
+
"Optionally request a deliberate clean context reset once the current foreground tools succeed; retains no prior messages, unlike automatic rollover. Not required after a notes reminder. Background work continues. Earlier conversation stays recoverable through history. Put continuation state in handoff, or save fuller state in notes first.",
|
|
64
|
+
promptSnippet: "optionally request a clean context reset with an optional handoff",
|
|
65
65
|
parameters: Type.Object({
|
|
66
66
|
handoff: Type.Optional(
|
|
67
67
|
Type.String({
|
|
@@ -94,7 +94,7 @@ export function registerContextTools(
|
|
|
94
94
|
label: "Context Remaining",
|
|
95
95
|
...toolCards("get_context_remaining"),
|
|
96
96
|
description:
|
|
97
|
-
"Estimate tokens left before Pi's automatic rollover and the configured context limit. Check before reporting remaining space or changing your work plan because of context limits. A
|
|
97
|
+
"Estimate tokens left before Pi's automatic rollover and the configured context limit. Check before reporting remaining space or changing your work plan because of context limits. A notes-only reminder may arrive before rollover on a working turn; continue afterward and let rollover happen automatically. No routine checks are needed.",
|
|
98
98
|
promptSnippet:
|
|
99
99
|
"verify remaining context before reporting a token count or changing plans because of context limits",
|
|
100
100
|
parameters: Type.Object({}),
|
package/src/contracts.ts
CHANGED
package/src/files.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
createReadStream,
|
|
4
|
+
existsSync,
|
|
5
|
+
lstatSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
realpathSync,
|
|
9
|
+
renameSync,
|
|
10
|
+
statSync,
|
|
11
|
+
symlinkSync,
|
|
12
|
+
} from "node:fs";
|
|
3
13
|
import {
|
|
4
14
|
access,
|
|
5
15
|
constants,
|
|
@@ -12,7 +22,8 @@ import {
|
|
|
12
22
|
stat,
|
|
13
23
|
unlink,
|
|
14
24
|
} from "node:fs/promises";
|
|
15
|
-
import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
25
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
26
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
16
27
|
import { isRecord } from "./contracts.ts";
|
|
17
28
|
|
|
18
29
|
async function lstatOrMissing(path: string) {
|
|
@@ -171,7 +182,7 @@ function gitRoot(dir: string, marker: string): string {
|
|
|
171
182
|
return basename(common) === ".git" ? dirname(common) : common;
|
|
172
183
|
}
|
|
173
184
|
/** Conventional repos share the primary checkout; separate Git directories own their shared root. */
|
|
174
|
-
|
|
185
|
+
function notesRoot(cwd: string): string {
|
|
175
186
|
for (let dir = cwd; ; dir = dirname(dir)) {
|
|
176
187
|
const marker = join(dir, ".git");
|
|
177
188
|
if (existsSync(marker)) {
|
|
@@ -186,3 +197,88 @@ export function notesRoot(cwd: string): string {
|
|
|
186
197
|
}
|
|
187
198
|
}
|
|
188
199
|
}
|
|
200
|
+
function packageRelativePath(installs: string, root: string): string | undefined {
|
|
201
|
+
const path = relative(installs, root);
|
|
202
|
+
return path !== "" && path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path)
|
|
203
|
+
? path
|
|
204
|
+
: undefined;
|
|
205
|
+
}
|
|
206
|
+
function projectPackageNotes(root: string, cwd: string): string | undefined {
|
|
207
|
+
let moved: string | undefined;
|
|
208
|
+
let checkout = root;
|
|
209
|
+
for (let dir = cwd; dirname(dir) !== dir; dir = dirname(dir)) {
|
|
210
|
+
// Retain the logical checkout path when a package container or source segment is linked.
|
|
211
|
+
if (existsSync(join(dir, ".git")) && realpathSync(dir) === root) {
|
|
212
|
+
checkout = dir;
|
|
213
|
+
}
|
|
214
|
+
if (basename(dir) !== "git" || basename(dirname(dir)) !== ".pi") {
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const path = packageRelativePath(dir, checkout);
|
|
218
|
+
if (path !== undefined) {
|
|
219
|
+
// Flat full keys keep arbitrary Git URL prefix paths out of one another's note stores.
|
|
220
|
+
const key = createHash("sha256").update(path.split(sep).join("/")).digest("hex");
|
|
221
|
+
moved = join(realpathSync(dirname(dir)), "package-notes", key, "notes");
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// The outermost package container also protects packages installed inside another package.
|
|
225
|
+
return moved;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Pi's package manager installs Git sources in <agent>/git or <project>/.pi/git and runs
|
|
229
|
+
* `git clean -fdx` inside their checkouts on update. Keep notes alongside, not inside, that tree.
|
|
230
|
+
* ponytail: notes a package commits under `.pi/notes` return with each update and move into
|
|
231
|
+
* another `from-checkout-*` folder; skip tracked files if a package ever commits its notes.
|
|
232
|
+
*/
|
|
233
|
+
function packageNotes(root: string, cwd: string): string | undefined {
|
|
234
|
+
// Pi cleans only the checkouts; folders that hold several of them keep their own notes.
|
|
235
|
+
if (!existsSync(join(root, ".git"))) {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const agentDir = getAgentDir();
|
|
239
|
+
const installs = join(agentDir, "git");
|
|
240
|
+
const path = packageRelativePath(
|
|
241
|
+
existsSync(installs) ? realpathSync(installs) : installs,
|
|
242
|
+
root,
|
|
243
|
+
);
|
|
244
|
+
return path === undefined
|
|
245
|
+
? (projectPackageNotes(root, cwd) ?? projectPackageNotes(root, root))
|
|
246
|
+
: join(agentDir, "notes", path);
|
|
247
|
+
}
|
|
248
|
+
/** Preserve every existing entry, including empty directories and dangling links. */
|
|
249
|
+
function migrationTarget(moved: string): string {
|
|
250
|
+
if (lstatSync(moved, { throwIfNoEntry: false }) === undefined) {
|
|
251
|
+
return moved;
|
|
252
|
+
}
|
|
253
|
+
const base = join(moved, `from-checkout-${Date.now()}`);
|
|
254
|
+
let target = base;
|
|
255
|
+
for (let suffix = 1; lstatSync(target, { throwIfNoEntry: false }) !== undefined; suffix++) {
|
|
256
|
+
target = `${base}-${suffix}`;
|
|
257
|
+
}
|
|
258
|
+
return target;
|
|
259
|
+
}
|
|
260
|
+
/** The notes directory for `cwd`, moving any notes found inside a Pi package checkout. */
|
|
261
|
+
export function notesDir(cwd: string): string {
|
|
262
|
+
const root = notesRoot(cwd);
|
|
263
|
+
const local = join(root, ".pi", "notes");
|
|
264
|
+
const moved = packageNotes(root, cwd);
|
|
265
|
+
if (moved === undefined) {
|
|
266
|
+
return local;
|
|
267
|
+
}
|
|
268
|
+
if (lstatSync(local, { throwIfNoEntry: false })?.isDirectory() === true) {
|
|
269
|
+
mkdirSync(dirname(moved), { recursive: true });
|
|
270
|
+
// Notes an older Posthorse wrote here after an earlier move keep a folder of their own.
|
|
271
|
+
const target = migrationTarget(moved);
|
|
272
|
+
try {
|
|
273
|
+
renameSync(local, target);
|
|
274
|
+
// Sessions still running an older Posthorse keep reaching the notes until they reload.
|
|
275
|
+
symlinkSync(moved, local, "junction");
|
|
276
|
+
} catch (error) {
|
|
277
|
+
if (lstatSync(local, { throwIfNoEntry: false })?.isDirectory() === true) {
|
|
278
|
+
throw error;
|
|
279
|
+
}
|
|
280
|
+
// Another session moved them first, or only the compatibility link failed.
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return moved;
|
|
284
|
+
}
|
package/src/notes.ts
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
type PolicyContext,
|
|
24
24
|
type TextResult,
|
|
25
25
|
} from "./contracts.ts";
|
|
26
|
-
import { isBinaryFile,
|
|
26
|
+
import { isBinaryFile, notesDir, publishFile, readText } from "./files.ts";
|
|
27
27
|
import { excerpt, excerptAround } from "./message-text.ts";
|
|
28
28
|
import type { PageAccess } from "./paging.ts";
|
|
29
29
|
|
|
@@ -45,7 +45,7 @@ const SEARCH_OFFSET_LIMIT = 1_000_000;
|
|
|
45
45
|
function safeJoin(dir: string, path: string): string {
|
|
46
46
|
const relative = normalize(path.replace(/^[/\\]+/, ""));
|
|
47
47
|
if (relative === ".." || relative.startsWith(`..${sep}`) || isAbsolute(relative)) {
|
|
48
|
-
throw new Error(`Invalid path "${path}": must stay inside
|
|
48
|
+
throw new Error(`Invalid path "${path}": must stay inside the notes directory.`);
|
|
49
49
|
}
|
|
50
50
|
return join(dir, relative);
|
|
51
51
|
}
|
|
@@ -278,7 +278,7 @@ export function registerNotes(
|
|
|
278
278
|
{ description: "Operation to perform" },
|
|
279
279
|
),
|
|
280
280
|
path: Type.Optional(
|
|
281
|
-
Type.String({ description: "Note or folder path relative to
|
|
281
|
+
Type.String({ description: "Note or folder path relative to the notes directory" }),
|
|
282
282
|
),
|
|
283
283
|
content: Type.Optional(
|
|
284
284
|
Type.String({ description: "Full file content (write) or text to add (append)" }),
|
|
@@ -296,7 +296,7 @@ export function registerNotes(
|
|
|
296
296
|
// Pi's fixed tool contract supplies id, params, signal, updates, and context.
|
|
297
297
|
// oxlint-disable-next-line max-params
|
|
298
298
|
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
299
|
-
const dir =
|
|
299
|
+
const dir = notesDir(ctx.cwd);
|
|
300
300
|
switch (params.op) {
|
|
301
301
|
case "list":
|
|
302
302
|
return listNotes(dir, params, policy(ctx), pages);
|
package/src/recovery-results.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
toolIdentity,
|
|
10
10
|
} from "./message-text.ts";
|
|
11
11
|
|
|
12
|
+
export type RecoveryEntry = EntryLike & { readonly sourceEntryId: string | undefined };
|
|
12
13
|
type LinkedCall = { readonly entry: EntryLike; readonly block: ToolCallLike };
|
|
13
14
|
export type RecoveryBlock = { readonly header: string; readonly text: string };
|
|
14
15
|
export type RecoveryBatch = { readonly callId?: string; readonly blocks: readonly RecoveryBlock[] };
|
|
@@ -82,12 +83,19 @@ function resultBlock(
|
|
|
82
83
|
};
|
|
83
84
|
}
|
|
84
85
|
/** Async receipts may arrive after a response; completion does not establish receipt delivery. */
|
|
85
|
-
export function recoverableToolResults(
|
|
86
|
+
export function recoverableToolResults(
|
|
87
|
+
entries: readonly RecoveryEntry[],
|
|
88
|
+
retained: ReadonlySet<string>,
|
|
89
|
+
): RecoveryBatch | undefined {
|
|
86
90
|
const calls = callsById(entries);
|
|
87
91
|
const start = trailingStart(entries);
|
|
88
92
|
const results = entries.filter((entry, index) => {
|
|
89
93
|
const message = entry.message;
|
|
90
|
-
if (
|
|
94
|
+
if (
|
|
95
|
+
message?.role !== "toolResult" ||
|
|
96
|
+
message.toolCallId === undefined ||
|
|
97
|
+
retained.has(entry.sourceEntryId ?? "")
|
|
98
|
+
) {
|
|
91
99
|
return false;
|
|
92
100
|
}
|
|
93
101
|
const call = calls.get(message.toolCallId);
|
package/src/recovery.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
recoverableToolResults,
|
|
19
19
|
type RecoveryBatch,
|
|
20
20
|
type RecoveryBlock,
|
|
21
|
+
type RecoveryEntry,
|
|
21
22
|
} from "./recovery-results.ts";
|
|
22
23
|
|
|
23
24
|
const MAX_RECOVERY_RECORD_CHARS = 4_000;
|
|
@@ -121,6 +122,7 @@ function projectedCurrent(
|
|
|
121
122
|
entries: readonly EntryLike[],
|
|
122
123
|
projected: readonly ProjectedEntry[],
|
|
123
124
|
current: readonly EntryLike[] = entries.slice(contextStart(entries).start),
|
|
125
|
+
retained: ReadonlySet<string> = new Set(),
|
|
124
126
|
): EntryLike[] {
|
|
125
127
|
const edits = contextEdits(entries);
|
|
126
128
|
const omittedIds = new Set(
|
|
@@ -130,7 +132,7 @@ function projectedCurrent(
|
|
|
130
132
|
);
|
|
131
133
|
return current.flatMap((entry) => {
|
|
132
134
|
const id = entry.id ?? "";
|
|
133
|
-
if (id
|
|
135
|
+
if (retained.has(id) || omittedIds.has(id)) {
|
|
134
136
|
return [];
|
|
135
137
|
}
|
|
136
138
|
const edit = edits.get(id);
|
|
@@ -211,6 +213,7 @@ function recoveryAncestry(
|
|
|
211
213
|
entries: readonly EntryLike[],
|
|
212
214
|
projected: readonly ProjectedEntry[],
|
|
213
215
|
ownerQuestionRegistered: boolean,
|
|
216
|
+
retained: ReadonlySet<string>,
|
|
214
217
|
): {
|
|
215
218
|
readonly checkpoint?: EntryLike;
|
|
216
219
|
readonly carried: readonly RecoveryRecord[];
|
|
@@ -234,7 +237,7 @@ function recoveryAncestry(
|
|
|
234
237
|
if (owners.length > 0) {
|
|
235
238
|
return {
|
|
236
239
|
checkpoint: automaticBoundary(window.boundary) ? undefined : window.boundary,
|
|
237
|
-
carried: anchors(owners).fixed,
|
|
240
|
+
carried: anchors(owners.filter((record) => !retained.has(record.id))).fixed,
|
|
238
241
|
skipped,
|
|
239
242
|
};
|
|
240
243
|
}
|
|
@@ -262,13 +265,15 @@ function skippedRecords(ids: readonly string[], limit: number): string | undefin
|
|
|
262
265
|
function projectedToolEntries(
|
|
263
266
|
entries: readonly EntryLike[],
|
|
264
267
|
projected: readonly ProjectedEntry[],
|
|
265
|
-
):
|
|
268
|
+
): RecoveryEntry[] {
|
|
266
269
|
const edits = contextEdits(entries);
|
|
267
270
|
return projected.flatMap(({ sourceEntry, messages }) =>
|
|
268
271
|
messages
|
|
269
272
|
.filter((message) => message.role === "assistant" || message.role === "toolResult")
|
|
270
273
|
.map((message) => ({
|
|
271
274
|
type: "message",
|
|
275
|
+
// Retention follows the source; history reads follow the effective replacement.
|
|
276
|
+
sourceEntryId: sourceEntry.id,
|
|
272
277
|
id: edits.get(sourceEntry.id ?? "")?.id ?? sourceEntry.id,
|
|
273
278
|
message,
|
|
274
279
|
})),
|
|
@@ -378,37 +383,69 @@ function renderIndex(records: readonly RecoveryRecord[], available: number): str
|
|
|
378
383
|
...listed,
|
|
379
384
|
].join("\n");
|
|
380
385
|
}
|
|
381
|
-
|
|
382
|
-
export function buildAutoHandoff(
|
|
386
|
+
function selectedInputs(
|
|
383
387
|
entries: readonly EntryLike[],
|
|
384
388
|
projected: readonly ProjectedEntry[],
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
389
|
+
options: {
|
|
390
|
+
readonly ownerQuestionRegistered: boolean;
|
|
391
|
+
readonly retained: ReadonlySet<string>;
|
|
392
|
+
readonly carried: readonly RecoveryRecord[];
|
|
393
|
+
},
|
|
394
|
+
): { readonly fixed: readonly RecoveryRecord[]; readonly others: readonly RecoveryRecord[] } {
|
|
395
|
+
const { ownerQuestionRegistered, retained, carried } = options;
|
|
396
|
+
const current = projectedCurrent(entries, projected);
|
|
397
|
+
const allRecords = ownerRecords(current, projected, ownerQuestionRegistered);
|
|
390
398
|
const records = ownerRecords(
|
|
391
|
-
projectedCurrent(entries, projected),
|
|
399
|
+
projectedCurrent(entries, projected, undefined, retained),
|
|
392
400
|
projected,
|
|
393
401
|
ownerQuestionRegistered,
|
|
394
402
|
);
|
|
395
403
|
const { fixed, others } = anchors(records);
|
|
396
|
-
if (!
|
|
404
|
+
if (!allRecords.some((record) => record.kind === "owner")) {
|
|
397
405
|
fixed.unshift(
|
|
398
|
-
...
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
406
|
+
...carried.map((record) =>
|
|
407
|
+
Object.assign({}, record, {
|
|
408
|
+
label: `${record.label}; carried from an earlier window, possibly already handled; not current progress`,
|
|
409
|
+
}),
|
|
410
|
+
),
|
|
402
411
|
);
|
|
403
412
|
}
|
|
413
|
+
return { fixed, others };
|
|
414
|
+
}
|
|
415
|
+
/** Raw owner anchors and projected tool evidence have distinct provenance and share one bounded payload. */
|
|
416
|
+
export function buildAutoHandoff(
|
|
417
|
+
entries: readonly EntryLike[],
|
|
418
|
+
projected: readonly ProjectedEntry[],
|
|
419
|
+
maxChars: number,
|
|
420
|
+
options: {
|
|
421
|
+
readonly ownerQuestionRegistered: boolean;
|
|
422
|
+
readonly retainedEntryIds?: ReadonlySet<string>;
|
|
423
|
+
},
|
|
424
|
+
): string {
|
|
425
|
+
const { ownerQuestionRegistered, retainedEntryIds: retained = new Set<string>() } = options;
|
|
426
|
+
const preamble =
|
|
427
|
+
retained.size === 0
|
|
428
|
+
? PREAMBLE
|
|
429
|
+
: `${PREAMBLE}\nRecent messages follow separately; their inputs and tool evidence are not repeated in this record.`;
|
|
430
|
+
const history = recoveryAncestry(entries, projected, ownerQuestionRegistered, retained);
|
|
431
|
+
const priorWindow = history.checkpoint;
|
|
432
|
+
const { fixed, others } = selectedInputs(entries, projected, {
|
|
433
|
+
ownerQuestionRegistered,
|
|
434
|
+
retained,
|
|
435
|
+
carried: history.carried,
|
|
436
|
+
});
|
|
437
|
+
const emptyInputHeader =
|
|
438
|
+
retained.size > 0
|
|
439
|
+
? "No discarded owner or visible coordination inputs were selected for this recovery record."
|
|
440
|
+
: "No selected current-window owner or visible coordination inputs were found.";
|
|
404
441
|
const inputHeader =
|
|
405
442
|
fixed.length > 0
|
|
406
443
|
? "Selected owner and coordination inputs (chronological):"
|
|
407
|
-
:
|
|
408
|
-
const batch = recoverableToolResults(projectedToolEntries(entries, projected));
|
|
444
|
+
: emptyInputHeader;
|
|
445
|
+
const batch = recoverableToolResults(projectedToolEntries(entries, projected), retained);
|
|
409
446
|
const header = batchHeader(batch);
|
|
410
447
|
const minimum = [
|
|
411
|
-
|
|
448
|
+
preamble,
|
|
412
449
|
inputHeader,
|
|
413
450
|
...fixed.map((record) => formatRecord(record, 0)),
|
|
414
451
|
header,
|
|
@@ -427,7 +464,7 @@ export function buildAutoHandoff(
|
|
|
427
464
|
(history.skipped.length > 0 ? 1 : 0);
|
|
428
465
|
const fixedBudget = Math.max(
|
|
429
466
|
0,
|
|
430
|
-
maxChars - joined([
|
|
467
|
+
maxChars - joined([preamble, inputHeader, plan.bare]).length - HANDOFF_OVERHEAD_RESERVE,
|
|
431
468
|
);
|
|
432
469
|
const fixedLimit =
|
|
433
470
|
count > 0
|
|
@@ -438,7 +475,7 @@ export function buildAutoHandoff(
|
|
|
438
475
|
skippedRecords(history.skipped, fixedLimit),
|
|
439
476
|
]);
|
|
440
477
|
const fixedParts = [
|
|
441
|
-
|
|
478
|
+
preamble,
|
|
442
479
|
inputHeader,
|
|
443
480
|
...fixed.map((record) => formatRecord(record, fixedLimit)),
|
|
444
481
|
];
|
package/src/retention.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_COMPACTION_SETTINGS,
|
|
3
|
+
estimateTokens,
|
|
4
|
+
findCutPoint,
|
|
5
|
+
type ProjectedSessionEntry,
|
|
6
|
+
type SessionEntry,
|
|
7
|
+
type SessionMessageEntry,
|
|
8
|
+
} from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { freshPayloadChars } from "./budget.ts";
|
|
10
|
+
import {
|
|
11
|
+
MAX_HANDOFF_CHARS,
|
|
12
|
+
MIN_PAGE_CHARS,
|
|
13
|
+
type EntryLike,
|
|
14
|
+
type MessageLike,
|
|
15
|
+
type PolicyContext,
|
|
16
|
+
} from "./contracts.ts";
|
|
17
|
+
import { buildAutoHandoff } from "./recovery.ts";
|
|
18
|
+
|
|
19
|
+
type RecoveryCapacity = {
|
|
20
|
+
readonly host: PolicyContext;
|
|
21
|
+
readonly toolTokens: number;
|
|
22
|
+
readonly pendingMessages?: readonly MessageLike[];
|
|
23
|
+
readonly firstKeptEntryId?: string;
|
|
24
|
+
};
|
|
25
|
+
type RetainedTail = {
|
|
26
|
+
readonly firstKeptEntryId?: string;
|
|
27
|
+
readonly ids: ReadonlySet<string>;
|
|
28
|
+
};
|
|
29
|
+
type Immutable<T> = T extends string | number | boolean | null | undefined
|
|
30
|
+
? T
|
|
31
|
+
: T extends readonly (infer Item)[]
|
|
32
|
+
? readonly Immutable<Item>[]
|
|
33
|
+
: T extends object
|
|
34
|
+
? { readonly [Key in keyof T]: Immutable<T[Key]> }
|
|
35
|
+
: T;
|
|
36
|
+
type ProjectionEntry = Immutable<ProjectedSessionEntry>;
|
|
37
|
+
const CUT_ROLES = new Set([
|
|
38
|
+
"user",
|
|
39
|
+
"assistant",
|
|
40
|
+
"bashExecution",
|
|
41
|
+
"custom",
|
|
42
|
+
"branchSummary",
|
|
43
|
+
"compactionSummary",
|
|
44
|
+
]);
|
|
45
|
+
function visibleMessages(entry: ProjectionEntry) {
|
|
46
|
+
return entry.messages.filter((message) => message.role !== "system");
|
|
47
|
+
}
|
|
48
|
+
function safeStart(entry: ProjectionEntry): boolean {
|
|
49
|
+
return (
|
|
50
|
+
entry.sourceEntry.type !== "compaction" &&
|
|
51
|
+
visibleMessages(entry).some((message) => CUT_ROLES.has(message.role))
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
function nativeMessage(
|
|
55
|
+
message: ProjectionEntry["messages"][number],
|
|
56
|
+
): SessionMessageEntry["message"] {
|
|
57
|
+
switch (message.role) {
|
|
58
|
+
case "assistant":
|
|
59
|
+
return {
|
|
60
|
+
role: "assistant",
|
|
61
|
+
api: message.api,
|
|
62
|
+
provider: message.provider,
|
|
63
|
+
model: message.model,
|
|
64
|
+
stopReason: message.stopReason,
|
|
65
|
+
usage: message.usage,
|
|
66
|
+
timestamp: message.timestamp,
|
|
67
|
+
content: [...message.content],
|
|
68
|
+
};
|
|
69
|
+
case "toolResult":
|
|
70
|
+
return {
|
|
71
|
+
role: "toolResult",
|
|
72
|
+
toolCallId: message.toolCallId,
|
|
73
|
+
toolName: message.toolName,
|
|
74
|
+
isError: message.isError,
|
|
75
|
+
timestamp: message.timestamp,
|
|
76
|
+
content: [...message.content],
|
|
77
|
+
};
|
|
78
|
+
case "user":
|
|
79
|
+
case "custom":
|
|
80
|
+
return {
|
|
81
|
+
...message,
|
|
82
|
+
content:
|
|
83
|
+
typeof message.content === "string" ? message.content : [...message.content],
|
|
84
|
+
};
|
|
85
|
+
case "system":
|
|
86
|
+
// Compaction checkpoints replace earlier system declarations.
|
|
87
|
+
return { role: "user", content: "", timestamp: message.timestamp };
|
|
88
|
+
case "bashExecution":
|
|
89
|
+
case "branchSummary":
|
|
90
|
+
case "compactionSummary":
|
|
91
|
+
return { ...message };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** The exported native cut finder reads raw entries; materialize only projected contributions. */
|
|
95
|
+
function projectedCut(entries: readonly ProjectionEntry[], keep: number): number {
|
|
96
|
+
const materialized = entries.flatMap<SessionEntry>(({ sourceEntry, messages }) =>
|
|
97
|
+
messages.length === 0 || sourceEntry.type === "compaction"
|
|
98
|
+
? [{ ...sourceEntry, type: "custom", customType: "posthorse-projection", data: {} }]
|
|
99
|
+
: messages.map((message) => ({
|
|
100
|
+
id: sourceEntry.id,
|
|
101
|
+
parentId: sourceEntry.parentId,
|
|
102
|
+
timestamp: sourceEntry.timestamp,
|
|
103
|
+
type: "message",
|
|
104
|
+
message: nativeMessage(message),
|
|
105
|
+
})),
|
|
106
|
+
);
|
|
107
|
+
const cut = findCutPoint(materialized, 0, materialized.length, keep);
|
|
108
|
+
const id = materialized[cut.firstKeptEntryIndex]?.id;
|
|
109
|
+
return entries.findIndex((entry) => entry.sourceEntry.id === id);
|
|
110
|
+
}
|
|
111
|
+
function pairedTail(entries: readonly ProjectionEntry[]): boolean {
|
|
112
|
+
const calls = new Set<string>();
|
|
113
|
+
for (const message of entries.flatMap(visibleMessages)) {
|
|
114
|
+
if (message.role === "assistant") {
|
|
115
|
+
for (const part of message.content.filter((block) => block.type === "toolCall")) {
|
|
116
|
+
calls.add(part.id);
|
|
117
|
+
}
|
|
118
|
+
} else if (message.role === "toolResult") {
|
|
119
|
+
if (!calls.delete(message.toolCallId)) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return calls.size === 0;
|
|
125
|
+
}
|
|
126
|
+
function boundedTail(
|
|
127
|
+
entries: readonly ProjectionEntry[],
|
|
128
|
+
start: number,
|
|
129
|
+
maxTokens: number,
|
|
130
|
+
): RetainedTail {
|
|
131
|
+
const costs = entries.map((entry) =>
|
|
132
|
+
entry.sourceEntry.type === "compaction"
|
|
133
|
+
? 0
|
|
134
|
+
: visibleMessages(entry).reduce(
|
|
135
|
+
(sum, message) => sum + estimateTokens(nativeMessage(message)),
|
|
136
|
+
0,
|
|
137
|
+
),
|
|
138
|
+
);
|
|
139
|
+
let tokens = costs.slice(start).reduce((sum, cost) => sum + cost, 0);
|
|
140
|
+
for (let index = start; index < entries.length; index++) {
|
|
141
|
+
if (tokens > 0 && tokens <= maxTokens && safeStart(entries[index])) {
|
|
142
|
+
const tail = entries.slice(index);
|
|
143
|
+
if (pairedTail(tail)) {
|
|
144
|
+
return {
|
|
145
|
+
firstKeptEntryId: entries[index].sourceEntry.id,
|
|
146
|
+
ids: new Set(tail.map((entry) => entry.sourceEntry.id)),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
tokens -= costs[index];
|
|
151
|
+
}
|
|
152
|
+
return { ids: new Set() };
|
|
153
|
+
}
|
|
154
|
+
function retainedTail(
|
|
155
|
+
projected: readonly ProjectionEntry[],
|
|
156
|
+
capacity: RecoveryCapacity,
|
|
157
|
+
maxTokens: number,
|
|
158
|
+
): RetainedTail {
|
|
159
|
+
const keep =
|
|
160
|
+
capacity.host.getCompactionSettings().keepRecentTokens ??
|
|
161
|
+
DEFAULT_COMPACTION_SETTINGS.keepRecentTokens;
|
|
162
|
+
if (!Number.isFinite(keep) || keep <= 0 || maxTokens <= 0) {
|
|
163
|
+
return { ids: new Set() };
|
|
164
|
+
}
|
|
165
|
+
const boundary = projected.findIndex(
|
|
166
|
+
(entry) => entry.sourceEntry.type === "compaction" && entry.messages.length > 0,
|
|
167
|
+
);
|
|
168
|
+
const entries = projected.slice(boundary + 1);
|
|
169
|
+
if (entries.length === 0) {
|
|
170
|
+
return { ids: new Set() };
|
|
171
|
+
}
|
|
172
|
+
const prepared = entries.findIndex(
|
|
173
|
+
(entry) => entry.sourceEntry.id === capacity.firstKeptEntryId,
|
|
174
|
+
);
|
|
175
|
+
const start = prepared >= 0 ? prepared : projectedCut(entries, keep);
|
|
176
|
+
return start < 0 ? { ids: new Set() } : boundedTail(entries, start, maxTokens);
|
|
177
|
+
}
|
|
178
|
+
/** Recent messages and recovery share half the fresh operational budget, leaving work headroom. */
|
|
179
|
+
export function automaticRecovery(
|
|
180
|
+
entries: readonly EntryLike[],
|
|
181
|
+
projected: readonly ProjectionEntry[],
|
|
182
|
+
capacity: RecoveryCapacity,
|
|
183
|
+
ownerQuestionRegistered: boolean,
|
|
184
|
+
): { readonly handoff: string; readonly firstKeptEntryId?: string } | undefined {
|
|
185
|
+
const payload = freshPayloadChars(
|
|
186
|
+
capacity.host,
|
|
187
|
+
capacity.toolTokens,
|
|
188
|
+
capacity.pendingMessages,
|
|
189
|
+
Number.MAX_SAFE_INTEGER,
|
|
190
|
+
);
|
|
191
|
+
const limit = Math.min(MAX_HANDOFF_CHARS, payload);
|
|
192
|
+
if (limit < MIN_PAGE_CHARS) {
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
const retained = retainedTail(projected, capacity, Math.floor((payload - limit) / 4));
|
|
196
|
+
const handoff = buildAutoHandoff(entries, projected, limit, {
|
|
197
|
+
ownerQuestionRegistered,
|
|
198
|
+
retainedEntryIds: retained.ids,
|
|
199
|
+
});
|
|
200
|
+
return handoff.length > limit
|
|
201
|
+
? undefined
|
|
202
|
+
: { handoff, firstKeptEntryId: retained.firstKeptEntryId };
|
|
203
|
+
}
|
package/src/rollover.ts
CHANGED
|
@@ -14,7 +14,6 @@ import {
|
|
|
14
14
|
import {
|
|
15
15
|
isRecord,
|
|
16
16
|
isReminderType,
|
|
17
|
-
MIN_PAGE_CHARS,
|
|
18
17
|
REMINDER_TYPE,
|
|
19
18
|
type CompactionPolicy,
|
|
20
19
|
type PolicyContext,
|
|
@@ -23,7 +22,7 @@ import {
|
|
|
23
22
|
} from "./contracts.ts";
|
|
24
23
|
import { registerContextTools } from "./context-tools.ts";
|
|
25
24
|
import { safeJsonStringify } from "./message-text.ts";
|
|
26
|
-
import {
|
|
25
|
+
import { automaticRecovery } from "./retention.ts";
|
|
27
26
|
import {
|
|
28
27
|
createReminderLookup,
|
|
29
28
|
hasReminder,
|
|
@@ -42,7 +41,7 @@ function registerGuidance(pi: ExtensionAPI, policy: SnapshotReader): void {
|
|
|
42
41
|
promptOptions = event.systemPromptOptions;
|
|
43
42
|
});
|
|
44
43
|
pi.on("agent_settled", () => {
|
|
45
|
-
//
|
|
44
|
+
// Prompt options belong to one run, never to later idle context evaluation.
|
|
46
45
|
promptOptions = undefined;
|
|
47
46
|
});
|
|
48
47
|
pi.on("context_with_system", (event, ctx) => {
|
|
@@ -175,7 +174,7 @@ function sendReminder(
|
|
|
175
174
|
pi.sendMessage(
|
|
176
175
|
{
|
|
177
176
|
customType: REMINDER_TYPE,
|
|
178
|
-
content: `[posthorse]
|
|
177
|
+
content: `[posthorse] Notes checkpoint: ${(budget.rolloverAt - tokens).toLocaleString("en-US")} tokens remain before Pi's automatic rollover line. Please ${CHECKPOINT_STEPS}.`,
|
|
179
178
|
display: true,
|
|
180
179
|
details: fingerprint,
|
|
181
180
|
},
|
|
@@ -232,12 +231,20 @@ function registerReminderFilter(
|
|
|
232
231
|
function automaticDraft(
|
|
233
232
|
pi: ExtensionAPI,
|
|
234
233
|
ctx: ExtensionContext,
|
|
235
|
-
recovery: {
|
|
234
|
+
recovery: {
|
|
235
|
+
readonly handoff: string;
|
|
236
|
+
readonly tokensBefore: number;
|
|
237
|
+
readonly reason: string;
|
|
238
|
+
readonly firstKeptEntryId?: string;
|
|
239
|
+
},
|
|
236
240
|
) {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
+
let firstKeptEntryId = recovery.firstKeptEntryId;
|
|
242
|
+
if (firstKeptEntryId === undefined) {
|
|
243
|
+
// Public compaction requires an entry id even when no prior conversation safely fits.
|
|
244
|
+
pi.appendEntry("posthorse-boundary", {});
|
|
245
|
+
firstKeptEntryId = ctx.sessionManager.getLeafId() ?? undefined;
|
|
246
|
+
}
|
|
247
|
+
if (firstKeptEntryId === undefined || firstKeptEntryId === "") {
|
|
241
248
|
throw new Error("Pi did not persist the context boundary.");
|
|
242
249
|
}
|
|
243
250
|
return {
|
|
@@ -316,17 +323,16 @@ function thresholdFallback(
|
|
|
316
323
|
return undefined;
|
|
317
324
|
}
|
|
318
325
|
const projected = event.context.contextEntries;
|
|
319
|
-
const limit = freshPayloadChars(host, toolTokens(), event.context.pendingMessages);
|
|
320
326
|
const ownerQuestionRegistered = pi
|
|
321
327
|
.getAllTools()
|
|
322
328
|
.some((tool) => tool.name === "ask_question" && tool.namespace === undefined);
|
|
323
|
-
const
|
|
329
|
+
const recovery = automaticRecovery(
|
|
324
330
|
ctx.sessionManager.getBranch(),
|
|
325
331
|
projected,
|
|
326
|
-
|
|
332
|
+
{ host, toolTokens: toolTokens(), pendingMessages: event.context.pendingMessages },
|
|
327
333
|
ownerQuestionRegistered,
|
|
328
334
|
);
|
|
329
|
-
if (
|
|
335
|
+
if (recovery === undefined) {
|
|
330
336
|
return undefined;
|
|
331
337
|
}
|
|
332
338
|
// ponytail: turn_end drafts are eager. Later projection editors must replace/remove this draft;
|
|
@@ -336,8 +342,8 @@ function thresholdFallback(
|
|
|
336
342
|
...event.entries,
|
|
337
343
|
{
|
|
338
344
|
type: "compaction",
|
|
339
|
-
summary,
|
|
340
|
-
firstKeptEntryId: null,
|
|
345
|
+
summary: recovery.handoff,
|
|
346
|
+
firstKeptEntryId: recovery.firstKeptEntryId ?? null,
|
|
341
347
|
details: { posthorse: 1, reason: "threshold" },
|
|
342
348
|
},
|
|
343
349
|
],
|
|
@@ -362,17 +368,20 @@ function registerAutomaticRollover(
|
|
|
362
368
|
if (budgetFor(host)?.supported !== true) {
|
|
363
369
|
return;
|
|
364
370
|
}
|
|
365
|
-
const limit = freshPayloadChars(host, activeToolTokens());
|
|
366
371
|
const ownerQuestionRegistered = pi
|
|
367
372
|
.getAllTools()
|
|
368
373
|
.some((tool) => tool.name === "ask_question" && tool.namespace === undefined);
|
|
369
|
-
const
|
|
374
|
+
const recovery = automaticRecovery(
|
|
370
375
|
event.branchEntries,
|
|
371
376
|
ctx.sessionManager.buildSessionProjection().entries,
|
|
372
|
-
|
|
377
|
+
{
|
|
378
|
+
host,
|
|
379
|
+
toolTokens: activeToolTokens(),
|
|
380
|
+
firstKeptEntryId: event.preparation.firstKeptEntryId,
|
|
381
|
+
},
|
|
373
382
|
ownerQuestionRegistered,
|
|
374
383
|
);
|
|
375
|
-
if (
|
|
384
|
+
if (recovery === undefined) {
|
|
376
385
|
throw new Error(
|
|
377
386
|
"Too little fresh context capacity for an automatic recovery record.",
|
|
378
387
|
);
|
|
@@ -381,7 +390,7 @@ function registerAutomaticRollover(
|
|
|
381
390
|
return { cancel: true };
|
|
382
391
|
}
|
|
383
392
|
return automaticDraft(pi, ctx, {
|
|
384
|
-
|
|
393
|
+
...recovery,
|
|
385
394
|
tokensBefore: event.preparation.tokensBefore,
|
|
386
395
|
reason: event.reason,
|
|
387
396
|
});
|