pi-posthorse 0.10.12 → 0.10.14
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/budget.ts +6 -14
- package/src/history-pages.ts +3 -7
- package/src/history.ts +1 -2
- package/src/note-evidence.ts +5 -3
- package/src/note-guards.ts +15 -65
- package/src/note-provenance.ts +1 -9
- package/src/note-store.ts +1 -22
- package/src/notes.ts +5 -10
- package/src/paging.ts +18 -32
- package/src/recovery-note-touches.ts +12 -2
- package/src/recovery-notes.ts +18 -4
- package/src/rollover.ts +3 -2
package/package.json
CHANGED
package/src/budget.ts
CHANGED
|
@@ -13,13 +13,6 @@ import {
|
|
|
13
13
|
import { imagesOf, textOf } from "./message-text.ts";
|
|
14
14
|
|
|
15
15
|
const MIN_USABLE_TOKENS = Math.ceil(MAX_HANDOFF_CHARS / 4) * 2;
|
|
16
|
-
export const CHECKPOINT_GUIDANCE = `Keep working on the assigned task; Pi handles context rollover automatically.
|
|
17
|
-
|
|
18
|
-
The current context window is not a deadline for finishing your task. Do not reduce scope, skip prescribed steps or verification, or issue a final progress summary because rollover is approaching.
|
|
19
|
-
|
|
20
|
-
Update the task's current-state note if needed: goal, progress, decisions, and next steps. Use available file-editing tools at its absolute path; use notes write only for creation, substantial restructuring, or when editing tools are unavailable. Leave an already-current note unchanged. Then immediately execute the next prescribed task step in this run. No new_context call, waiting for rollover, or user "continue" is needed.
|
|
21
|
-
|
|
22
|
-
Finish when the requested work and required verification are complete, or report a genuine blocker. Approaching context rollover is not a blocker.`;
|
|
23
16
|
export type Budget = {
|
|
24
17
|
readonly contextWindow: number;
|
|
25
18
|
readonly reserveTokens: number;
|
|
@@ -84,8 +77,9 @@ function automaticGuidance(budget: Budget | undefined, enabled: boolean): string
|
|
|
84
77
|
const line =
|
|
85
78
|
budget === undefined
|
|
86
79
|
? ""
|
|
87
|
-
: `
|
|
88
|
-
return
|
|
80
|
+
: `The rollover line is ${budget.rolloverAt.toLocaleString("en-US")} tokens used. `;
|
|
81
|
+
return `Pi rolls context over automatically, so the context window is not a deadline. Do not reduce scope, skip steps or verification, or write a final summary because rollover is near, and do not wait for the user to say "continue". Finish when the requested work and its verification are complete, or report a genuine blocker.
|
|
82
|
+
${line}Rollover keeps a recent-message tail without summarizing. One task-note reminder may arrive at the end of a working turn near the line: update the note only if it is stale, then continue with the next step in the same run. new_context is an optional clean reset.`;
|
|
89
83
|
}
|
|
90
84
|
export function buildGuidance(ctx: PolicyContext): string {
|
|
91
85
|
const budget = budgetFor(ctx);
|
|
@@ -93,14 +87,12 @@ export function buildGuidance(ctx: PolicyContext): string {
|
|
|
93
87
|
const capacity =
|
|
94
88
|
budget === undefined
|
|
95
89
|
? "Configured context capacity is unknown."
|
|
96
|
-
: `Configured context capacity: ${budget.contextWindow.toLocaleString("en-US")} tokens
|
|
90
|
+
: `Configured context capacity: ${budget.contextWindow.toLocaleString("en-US")} tokens (not remaining space).`;
|
|
97
91
|
return `## Automatic context management (Posthorse)
|
|
98
92
|
${automaticGuidance(budget, enabled)}
|
|
99
|
-
${capacity}
|
|
100
|
-
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.
|
|
93
|
+
${capacity} Do not guess capacity or remaining space from response-token or reasoning limits. 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.
|
|
101
94
|
After a rollover, earlier conversation stays in history. Restore notes and todos, then verify live state before stateful or external work; automatic handoffs record inputs, not progress.
|
|
102
|
-
Keep one concise current-state note per task. Preserve decisions and safety constraints
|
|
103
|
-
Write handoffs and notes as normal readable prose; notes and history search match literal text.`;
|
|
95
|
+
Keep one concise current-state note per task: goal, progress, decisions, and next steps. Preserve decisions and safety constraints, link fuller evidence instead of copying it, and edit only changed sections.`;
|
|
104
96
|
}
|
|
105
97
|
/** ponytail: native estimates omit request-local guidance; reserve it until post-transform usage is exposed. */
|
|
106
98
|
export function guidanceTokens(ctx: PolicyContext): number {
|
package/src/history-pages.ts
CHANGED
|
@@ -84,13 +84,13 @@ export function searchPage(
|
|
|
84
84
|
host: PolicyContext,
|
|
85
85
|
pages: PageAccess,
|
|
86
86
|
): TextResult {
|
|
87
|
-
const chars = pages.size(host
|
|
87
|
+
const chars = pages.size(host);
|
|
88
88
|
const reserve = Math.max(
|
|
89
89
|
0,
|
|
90
90
|
...matches.hits.map((hit) => searchFooter(cursorFor(matches, hit, hit.text.length)).length),
|
|
91
91
|
);
|
|
92
92
|
if (reserve >= chars) {
|
|
93
|
-
pages.error(host, "History entry id is too large for pagination."
|
|
93
|
+
pages.error(host, "History entry id is too large for pagination.");
|
|
94
94
|
}
|
|
95
95
|
const skippedNote =
|
|
96
96
|
matches.skipped > 0 && (params.cursor === undefined || params.cursor === "")
|
|
@@ -146,11 +146,7 @@ export function historyReadPage(
|
|
|
146
146
|
const shownId = shownEntryId(item, params.id, source);
|
|
147
147
|
const { offset, imageOffset } = readOffsets(item, params);
|
|
148
148
|
const firstImage = imageOffset < item.images.length ? 1 : 0;
|
|
149
|
-
const chars = access.pages.size(access.host, {
|
|
150
|
-
offset,
|
|
151
|
-
imageCount: firstImage,
|
|
152
|
-
imageOffset: item.images.length > 0 ? imageOffset : undefined,
|
|
153
|
-
});
|
|
149
|
+
const chars = access.pages.size(access.host, { imageCount: firstImage });
|
|
154
150
|
const imageEnd = Math.min(
|
|
155
151
|
item.images.length,
|
|
156
152
|
imageOffset + firstImage + Math.floor((chars - MIN_PAGE_CHARS) / ESTIMATED_IMAGE_CHARS),
|
package/src/history.ts
CHANGED
|
@@ -130,8 +130,7 @@ export function registerHistory(
|
|
|
130
130
|
async execute(...[, params, signal, , ctx]) {
|
|
131
131
|
const source = historySource(ctx);
|
|
132
132
|
if (params.op === "search") {
|
|
133
|
-
const fail = (message: string): never =>
|
|
134
|
-
pages.error(policy(ctx), message, params.cursor);
|
|
133
|
+
const fail = (message: string): never => pages.error(policy(ctx), message);
|
|
135
134
|
const matches = await historyMatches(source, params, signal, fail);
|
|
136
135
|
return searchPage(matches, params, policy(ctx), pages);
|
|
137
136
|
}
|
package/src/note-evidence.ts
CHANGED
|
@@ -156,9 +156,11 @@ export function scopeRecoveryTouches(
|
|
|
156
156
|
if (address !== undefined && sameScope(address, recovery.binding)) {
|
|
157
157
|
return touch;
|
|
158
158
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
159
|
+
// This session's own file-tool effects keep their recorded address and kind in any scope.
|
|
160
|
+
if (touch.owner !== undefined && sameOwner(touch.owner, recovery.binding.owner)) {
|
|
161
|
+
return touch;
|
|
162
|
+
}
|
|
163
|
+
if (owned.has(touch.notePath)) {
|
|
162
164
|
return {
|
|
163
165
|
...touch,
|
|
164
166
|
absPath: notePath(recovery.binding, touch.notePath),
|
package/src/note-guards.ts
CHANGED
|
@@ -5,10 +5,7 @@ import { withFileMutationQueue, type ExtensionAPI } from "@earendil-works/pi-cod
|
|
|
5
5
|
import {
|
|
6
6
|
addressOf,
|
|
7
7
|
canonicalPath,
|
|
8
|
-
inside,
|
|
9
|
-
notePath,
|
|
10
8
|
sameScope,
|
|
11
|
-
scopeDirectory,
|
|
12
9
|
type NoteAddress,
|
|
13
10
|
type NoteTouchRecord,
|
|
14
11
|
} from "./note-scopes.ts";
|
|
@@ -68,48 +65,21 @@ function existingEdit(tool: string, operation: NoteOperation): boolean {
|
|
|
68
65
|
(tool === "write_files" && (operation.action === "write" || operation.action === "clear"))
|
|
69
66
|
);
|
|
70
67
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
operation: NoteOperation,
|
|
74
|
-
captured: CapturedCall,
|
|
75
|
-
): Promise<string> {
|
|
76
|
-
const { session, tool, call } = captured;
|
|
68
|
+
/** An edit of an inherited note in this session's scope applies to its private copy. */
|
|
69
|
+
async function prepareOperation(operation: NoteOperation, captured: CapturedCall): Promise<void> {
|
|
77
70
|
if (operation.action === "read") {
|
|
78
|
-
return
|
|
71
|
+
return;
|
|
79
72
|
}
|
|
80
|
-
return existingEdit(tool, operation) &&
|
|
81
|
-
session.scopes.some((scope) => sameScope(scope, address))
|
|
82
|
-
? session.guardReference(address.path, call)
|
|
83
|
-
: `The foreign note was not changed. This session writes ${notePath(session.binding, address.path)}. Retry explicitly there; creation semantics were not changed.`;
|
|
84
|
-
}
|
|
85
|
-
async function guardOperation(
|
|
86
|
-
operation: NoteOperation,
|
|
87
|
-
captured: CapturedCall,
|
|
88
|
-
): Promise<string | undefined> {
|
|
89
73
|
const { session, call, tool, cwd } = captured;
|
|
90
74
|
const path = toolPath(tool, operation.path, cwd);
|
|
91
75
|
const address = await noteAddress(path, session);
|
|
92
|
-
if (address === undefined) {
|
|
93
|
-
return
|
|
94
|
-
session.scopes.some((scope) =>
|
|
95
|
-
inside(canonicalPath(join(scope.root, ".forks")), canonicalPath(path)),
|
|
96
|
-
)
|
|
97
|
-
? `Managed notes metadata is not editable through file tools: ${path}. Use notes for this session’s checkpoint.`
|
|
98
|
-
: undefined;
|
|
99
|
-
}
|
|
100
|
-
if (!sameScope(address, session.binding)) {
|
|
101
|
-
return guardForeign(address, operation, captured);
|
|
76
|
+
if (address === undefined || !sameScope(address, session.binding)) {
|
|
77
|
+
return;
|
|
102
78
|
}
|
|
103
79
|
if (existingEdit(tool, operation) && !session.owns(address.path)) {
|
|
104
|
-
|
|
105
|
-
if (copied === "adopted") {
|
|
106
|
-
return `Private copy created at ${path}; no requested edit ran. Read this copy, then retry the edit.`;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
if (operation.action !== "read") {
|
|
110
|
-
await session.prepareWrite();
|
|
80
|
+
await withFileMutationQueue(path, () => session.adopt(address.path, call));
|
|
111
81
|
}
|
|
112
|
-
|
|
82
|
+
await session.prepareWrite();
|
|
113
83
|
}
|
|
114
84
|
async function recordedFiles(
|
|
115
85
|
operations: readonly NoteOperation[],
|
|
@@ -148,42 +118,22 @@ async function recordedFiles(
|
|
|
148
118
|
export function registerNoteGuards(pi: ExtensionAPI, store: NotesStore): void {
|
|
149
119
|
const calls = new Map<string, CapturedCall>();
|
|
150
120
|
pi.on("tool_call", async (event, ctx) => {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
store
|
|
157
|
-
.roots()
|
|
158
|
-
.some((root) =>
|
|
159
|
-
inside(
|
|
160
|
-
root,
|
|
161
|
-
canonicalPath(toolPath(event.toolName, operation.path, ctx.cwd)),
|
|
162
|
-
),
|
|
163
|
-
),
|
|
164
|
-
)
|
|
165
|
-
) {
|
|
166
|
-
throw error;
|
|
167
|
-
}
|
|
168
|
-
return;
|
|
169
|
-
});
|
|
170
|
-
if (session === undefined) {
|
|
121
|
+
let session: SessionNotes;
|
|
122
|
+
try {
|
|
123
|
+
session = await store.get(ctx);
|
|
124
|
+
} catch {
|
|
125
|
+
// Failed notes initialization skips bookkeeping; the notes tool reports the error itself.
|
|
171
126
|
return;
|
|
172
127
|
}
|
|
173
128
|
const call = session.startCall(event.toolCallId, event.parentToolCallId);
|
|
174
129
|
const captured = { session, call, cwd: ctx.cwd, tool: event.toolName };
|
|
175
130
|
calls.set(event.toolCallId, captured);
|
|
176
|
-
if (event.toolName === "notes"
|
|
131
|
+
if (event.toolName === "notes") {
|
|
177
132
|
return;
|
|
178
133
|
}
|
|
179
|
-
for (const operation of
|
|
180
|
-
|
|
181
|
-
const reason = await guardOperation(operation, captured);
|
|
182
|
-
if (reason !== undefined) {
|
|
183
|
-
return { block: true, reason };
|
|
184
|
-
}
|
|
134
|
+
for (const operation of noteOperations(event.toolName, event.input)) {
|
|
135
|
+
await prepareOperation(operation, captured);
|
|
185
136
|
}
|
|
186
|
-
return;
|
|
187
137
|
});
|
|
188
138
|
pi.on("tool_result", async (event) => {
|
|
189
139
|
const captured = calls.get(event.toolCallId);
|
package/src/note-provenance.ts
CHANGED
|
@@ -195,11 +195,6 @@ function seedCandidates(
|
|
|
195
195
|
retained: new Set(),
|
|
196
196
|
directories: new Set(scopes.map(scopeDirectory)),
|
|
197
197
|
});
|
|
198
|
-
if (scope === undefined && evidence.touches.length > 0) {
|
|
199
|
-
throw new Error(
|
|
200
|
-
"Incomplete notes snapshot: the source notes root is missing or ambiguous.",
|
|
201
|
-
);
|
|
202
|
-
}
|
|
203
198
|
const selected = touchedCandidates(evidence, scopes, allEntries);
|
|
204
199
|
for (const binding of bindings) {
|
|
205
200
|
for (const seed of seedsOf(binding, allEntries)) {
|
|
@@ -232,6 +227,7 @@ async function forkBinding(
|
|
|
232
227
|
);
|
|
233
228
|
for (const [index, candidate] of candidates.entries()) {
|
|
234
229
|
// Resolve against the immediate parent's files, not a historical ancestor's version.
|
|
230
|
+
// A missing source note is not copied; a known source scope still resolves it later.
|
|
235
231
|
const address = addresses[index];
|
|
236
232
|
if (address !== undefined) {
|
|
237
233
|
files.push({
|
|
@@ -242,10 +238,6 @@ async function forkBinding(
|
|
|
242
238
|
});
|
|
243
239
|
} else if (candidate.cleared) {
|
|
244
240
|
absent.push(candidate.path);
|
|
245
|
-
} else {
|
|
246
|
-
throw new Error(
|
|
247
|
-
`Incomplete notes snapshot: source note ${candidate.path} is unavailable.`,
|
|
248
|
-
);
|
|
249
241
|
}
|
|
250
242
|
}
|
|
251
243
|
return snapshot(
|
package/src/note-store.ts
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import {
|
|
4
|
-
withFileMutationQueue,
|
|
5
|
-
type ExtensionAPI,
|
|
6
|
-
type ExtensionContext,
|
|
7
|
-
} from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
4
|
import type { EntryLike, ProjectedEntry } from "./contracts.ts";
|
|
9
5
|
import { notesDir } from "./files.ts";
|
|
10
6
|
import { adoptNote, ensureManifest, readManifest, saveAdoption } from "./note-snapshots.ts";
|
|
@@ -219,13 +215,6 @@ export class SessionNotes {
|
|
|
219
215
|
async prepareWrite(): Promise<void> {
|
|
220
216
|
await ensureManifest(this.binding);
|
|
221
217
|
}
|
|
222
|
-
async guardReference(path: string, call: NoteCall): Promise<string> {
|
|
223
|
-
const destination = notePath(this.binding, path);
|
|
224
|
-
const copied = await withFileMutationQueue(destination, () => this.adopt(path, call));
|
|
225
|
-
return copied === "adopted"
|
|
226
|
-
? `Private copy created at ${destination}; the source was not edited. Read the private copy, then retry the edit on that path.`
|
|
227
|
-
: `This session writes only ${this.directory}. Read ${destination}, then retry there; the foreign path was not edited.`;
|
|
228
|
-
}
|
|
229
218
|
}
|
|
230
219
|
function sessionKey(ctx: ExtensionContext): string {
|
|
231
220
|
const header = ctx.sessionManager.getHeader();
|
|
@@ -236,7 +225,6 @@ function sessionKey(ctx: ExtensionContext): string {
|
|
|
236
225
|
}
|
|
237
226
|
export type NotesStore = {
|
|
238
227
|
readonly get: (ctx: ExtensionContext) => Promise<SessionNotes>;
|
|
239
|
-
readonly roots: () => readonly string[];
|
|
240
228
|
readonly recovery: (ctx: ExtensionContext) => NotesRecovery | undefined;
|
|
241
229
|
};
|
|
242
230
|
type SessionState =
|
|
@@ -244,7 +232,6 @@ type SessionState =
|
|
|
244
232
|
| { readonly kind: "ready"; readonly value: SessionNotes };
|
|
245
233
|
export function createNotesStore(pi: ExtensionAPI): NotesStore {
|
|
246
234
|
const sessions = new Map<string, SessionState>();
|
|
247
|
-
const roots = new Set<string>();
|
|
248
235
|
const get = (ctx: ExtensionContext): Promise<SessionNotes> => {
|
|
249
236
|
const key = sessionKey(ctx);
|
|
250
237
|
const existing = sessions.get(key);
|
|
@@ -258,19 +245,12 @@ export function createNotesStore(pi: ExtensionAPI): NotesStore {
|
|
|
258
245
|
const root =
|
|
259
246
|
bindings.findLast((binding) => header !== null && sameOwner(binding.owner, header))
|
|
260
247
|
?.root ?? canonicalPath(notesDir(ctx.cwd));
|
|
261
|
-
roots.add(root);
|
|
262
|
-
for (const binding of bindings) {
|
|
263
|
-
roots.add(binding.root);
|
|
264
|
-
}
|
|
265
248
|
const initialized = loadNotesBinding(ctx.sessionManager, root)
|
|
266
249
|
.then(({ binding, source, created }) => {
|
|
267
250
|
if (created) {
|
|
268
251
|
pi.appendEntry(NOTES_BINDING, binding);
|
|
269
252
|
}
|
|
270
253
|
const session = new SessionNotes(pi, binding, ctx.sessionManager, source);
|
|
271
|
-
for (const scope of session.scopes) {
|
|
272
|
-
roots.add(scope.root);
|
|
273
|
-
}
|
|
274
254
|
sessions.set(key, { kind: "ready", value: session });
|
|
275
255
|
return session;
|
|
276
256
|
})
|
|
@@ -283,7 +263,6 @@ export function createNotesStore(pi: ExtensionAPI): NotesStore {
|
|
|
283
263
|
};
|
|
284
264
|
return {
|
|
285
265
|
get,
|
|
286
|
-
roots: () => [...roots],
|
|
287
266
|
recovery: (ctx: ExtensionContext) => {
|
|
288
267
|
const state = sessions.get(sessionKey(ctx));
|
|
289
268
|
return state?.kind === "ready" ? state.value.recovery : undefined;
|
package/src/notes.ts
CHANGED
|
@@ -115,15 +115,10 @@ async function readNote(
|
|
|
115
115
|
const header = target.writable
|
|
116
116
|
? `File: ${path}\n`
|
|
117
117
|
: `Read-only live reference: notes read ${JSON.stringify({ scope: scopeDirectory(target.address), path: target.address.path })}\n`;
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
host,
|
|
123
|
-
`Too little context remains to include the note path. Call new_context first, then retry with offset ${offset}.`,
|
|
124
|
-
);
|
|
125
|
-
}
|
|
126
|
-
const end = Math.min(text.length, offset + chars);
|
|
118
|
+
const end = Math.min(
|
|
119
|
+
text.length,
|
|
120
|
+
offset + pages.size(access.host(), { headerChars: header.length }),
|
|
121
|
+
);
|
|
127
122
|
const more =
|
|
128
123
|
end < text.length
|
|
129
124
|
? `\n[chars ${offset}-${end} of ${text.length}; continue with offset ${end}]`
|
|
@@ -295,7 +290,7 @@ export function registerNotes(
|
|
|
295
290
|
label: "Notes",
|
|
296
291
|
...toolCards("notes"),
|
|
297
292
|
description:
|
|
298
|
-
"Durable notes across Git worktrees and context resets
|
|
293
|
+
"Durable task notes shared across Git worktrees and context resets; forks get private copies. Ops: list (newest first), read, search (case-insensitive), write (replace; empty clears), append (one record). list/search also cover other sessions' note scopes and show their selectors; pass scope to read, list, or search one of them. list/read/search are paged; continue with the returned offset. A read of a current note returns its file path.",
|
|
299
294
|
promptSnippet: "save and recall durable state with private notes for forks",
|
|
300
295
|
parameters: Type.Object({
|
|
301
296
|
op: Type.Union(
|
package/src/paging.ts
CHANGED
|
@@ -11,11 +11,10 @@ import {
|
|
|
11
11
|
type TextResult,
|
|
12
12
|
} from "./contracts.ts";
|
|
13
13
|
|
|
14
|
+
/** What else the page carries: images share the budget, and the header comes out of the body. */
|
|
14
15
|
export type PageRequest = {
|
|
15
|
-
readonly offset: number;
|
|
16
16
|
readonly imageCount?: number;
|
|
17
|
-
readonly
|
|
18
|
-
readonly imageOffset?: number | undefined;
|
|
17
|
+
readonly headerChars?: number;
|
|
19
18
|
};
|
|
20
19
|
export type NotesPage = {
|
|
21
20
|
readonly rows: readonly string[];
|
|
@@ -27,13 +26,13 @@ export type NotesPage = {
|
|
|
27
26
|
readonly more?: boolean;
|
|
28
27
|
};
|
|
29
28
|
export type PageAccess = {
|
|
30
|
-
readonly size: (ctx: PolicyContext, request
|
|
29
|
+
readonly size: (ctx: PolicyContext, request?: PageRequest) => number;
|
|
31
30
|
readonly result: (
|
|
32
31
|
text: string,
|
|
33
32
|
images: readonly ImageLike[],
|
|
34
33
|
display: PosthorseDisplay,
|
|
35
34
|
) => TextResult;
|
|
36
|
-
readonly error: (ctx: PolicyContext, message: string
|
|
35
|
+
readonly error: (ctx: PolicyContext, message: string) => never;
|
|
37
36
|
readonly notes: (ctx: PolicyContext, page: NotesPage) => TextResult;
|
|
38
37
|
};
|
|
39
38
|
type PageAccounting = {
|
|
@@ -58,18 +57,22 @@ export class PageReservations {
|
|
|
58
57
|
reset(): void {
|
|
59
58
|
this.accounting = { pendingTokens: 0, usage: undefined };
|
|
60
59
|
}
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Body characters for the next page. Near the rollover line pages shrink to MIN_PAGE_CHARS
|
|
62
|
+
* instead of refusing, so reading keeps making progress; Pi's rollover handles the overshoot.
|
|
63
|
+
*/
|
|
64
|
+
size(ctx: PolicyContext, request: PageRequest = {}): number {
|
|
65
|
+
this.settle(ctx);
|
|
63
66
|
const remaining = pageCapacity(ctx, this.toolTokens) - this.accounting.pendingTokens;
|
|
64
|
-
const
|
|
67
|
+
const budget = Math.min(
|
|
65
68
|
MAX_PAGE_CHARS,
|
|
66
69
|
Math.max(0, remaining - PAGE_MARGIN_TOKENS) * 4 -
|
|
67
70
|
(request.imageCount ?? 0) * ESTIMATED_IMAGE_CHARS,
|
|
68
71
|
);
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
72
|
+
return Math.max(MIN_PAGE_CHARS, budget - (request.headerChars ?? 0));
|
|
73
|
+
}
|
|
74
|
+
private settle(ctx: PolicyContext): void {
|
|
75
|
+
this.accounting = reconcile(this.accounting, ctx.getContextUsage()?.tokens);
|
|
73
76
|
}
|
|
74
77
|
private reserve(text: string, imageCount = 0): void {
|
|
75
78
|
this.accounting = {
|
|
@@ -80,38 +83,21 @@ export class PageReservations {
|
|
|
80
83
|
imageCount * (ESTIMATED_IMAGE_CHARS / 4),
|
|
81
84
|
};
|
|
82
85
|
}
|
|
83
|
-
private refuse(remaining: number, request: PageRequest): never {
|
|
84
|
-
const position =
|
|
85
|
-
request.cursor !== undefined && request.cursor !== ""
|
|
86
|
-
? "with the same cursor"
|
|
87
|
-
: `with offset ${request.offset}${request.imageOffset === undefined ? "" : ` and imageOffset ${request.imageOffset}`}`;
|
|
88
|
-
const message = `Too little context remains to read a page safely. Call new_context first, then retry ${position}.`;
|
|
89
|
-
// Even refusals consume context; omit repeated guidance when it no longer fits.
|
|
90
|
-
const text = Math.ceil(message.length / 4) <= remaining ? message : "";
|
|
91
|
-
this.reserve(text);
|
|
92
|
-
throw new Error(text);
|
|
93
|
-
}
|
|
94
86
|
result(text: string, images: readonly ImageLike[], display: PosthorseDisplay): TextResult {
|
|
95
87
|
this.reserve(text, images.length);
|
|
96
88
|
return textResult(text, images, display);
|
|
97
89
|
}
|
|
98
|
-
error(ctx: PolicyContext, message: string
|
|
99
|
-
this.
|
|
90
|
+
error(ctx: PolicyContext, message: string): never {
|
|
91
|
+
this.settle(ctx);
|
|
100
92
|
this.reserve(message);
|
|
101
93
|
throw new Error(message);
|
|
102
94
|
}
|
|
103
95
|
notes(ctx: PolicyContext, page: NotesPage): TextResult {
|
|
104
96
|
const { rows, offset, kind, empty, header = "", more } = page;
|
|
105
97
|
const text = rows.length > 0 ? rows.join("\n") : empty;
|
|
106
|
-
const chars = this.size(ctx, {
|
|
98
|
+
const chars = this.size(ctx, { headerChars: header.length });
|
|
107
99
|
const footer = (end: number) =>
|
|
108
100
|
`\n[chars ${offset}-${end}${more === true ? "; more matches follow" : ` of ${text.length}`}; continue with offset ${end}]`;
|
|
109
|
-
if (chars <= footer(text.length).length) {
|
|
110
|
-
this.error(
|
|
111
|
-
ctx,
|
|
112
|
-
`Too little context remains to include the notes path. Call new_context first, then retry with offset ${offset}.`,
|
|
113
|
-
);
|
|
114
|
-
}
|
|
115
101
|
if (offset > 0 && offset >= text.length) {
|
|
116
102
|
this.error(ctx, "Offset is past the end; restart with offset 0.");
|
|
117
103
|
}
|
|
@@ -77,8 +77,11 @@ function matchMarkerPath(slash: string): string | undefined {
|
|
|
77
77
|
return /\/\.pi\/(?:agent\/)?notes\/[^/]+\/[^/]+\/[^/]+\/(.+)$/u.exec(slash)?.[1];
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
function absoluteSlashPath(slash: string): boolean {
|
|
81
|
+
return slash.startsWith("/") || /^[A-Za-z]:\//u.test(slash);
|
|
82
|
+
}
|
|
80
83
|
function unscopedNoteTarget(trimmed: string, slash: string): NoteTarget | undefined {
|
|
81
|
-
if (
|
|
84
|
+
if (absoluteSlashPath(slash)) {
|
|
82
85
|
const base = normalizeRelPath(slash.slice(slash.lastIndexOf("/") + 1));
|
|
83
86
|
return base === "" ? undefined : { notePath: base, absPath: trimmed };
|
|
84
87
|
}
|
|
@@ -98,7 +101,14 @@ function resolveNoteTarget(
|
|
|
98
101
|
const matched = matchNoteDirectory(slash, [...knownDirs])?.path ?? matchMarkerPath(slash);
|
|
99
102
|
if (matched !== undefined) {
|
|
100
103
|
const rel = matched.startsWith(".forks/") ? "" : normalizeRelPath(matched);
|
|
101
|
-
|
|
104
|
+
if (rel === "") {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
if (absoluteSlashPath(slash)) {
|
|
108
|
+
return { notePath: rel, absPath: trimmed };
|
|
109
|
+
}
|
|
110
|
+
// The journal does not record a file tool's working directory, so its relative path has no known base.
|
|
111
|
+
return fromNotesTool ? { notePath: rel } : undefined;
|
|
102
112
|
}
|
|
103
113
|
return fromNotesTool ? unscopedNoteTarget(trimmed, slash) : undefined;
|
|
104
114
|
}
|
package/src/recovery-notes.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
1
2
|
import type { EntryLike, ProjectedEntry } from "./contracts.ts";
|
|
2
3
|
import { sessionNoteTouches, scopeRecoveryTouches, type NotesRecovery } from "./note-evidence.ts";
|
|
4
|
+
import { scopeDirectory } from "./note-scopes.ts";
|
|
3
5
|
import type { NoteTarget, NoteTouch } from "./recovery-note-touches.ts";
|
|
4
6
|
|
|
5
7
|
const MAX_WRITTEN_NOTES = 4;
|
|
@@ -77,7 +79,7 @@ function applyTouch(
|
|
|
77
79
|
}
|
|
78
80
|
function formatNoteLabel(note: TrackedNote): string {
|
|
79
81
|
if (note.referenceScope !== undefined) {
|
|
80
|
-
return `${note.notePath} (
|
|
82
|
+
return `${note.notePath} (another notes scope: ${note.absPath ?? `${note.referenceScope}/${note.notePath}`})`;
|
|
81
83
|
}
|
|
82
84
|
return note.absPath !== undefined && note.absPath !== note.notePath
|
|
83
85
|
? `${note.notePath} (${note.absPath})`
|
|
@@ -116,14 +118,22 @@ function formatReadNoteLine(note: ReadNote, span: RetainedSpan | undefined): str
|
|
|
116
118
|
function formatNotesAction(
|
|
117
119
|
primaryWritten: WrittenNote | undefined,
|
|
118
120
|
span: RetainedSpan | undefined,
|
|
121
|
+
notesDirectory: string | undefined,
|
|
119
122
|
): string {
|
|
120
123
|
if (primaryWritten === undefined) {
|
|
121
124
|
return "Read the referenced note again if its context is still needed, and keep one current-state note for this task.";
|
|
122
125
|
}
|
|
126
|
+
// notes read resolves a bare name in this session's own scope, so a note elsewhere is named by its path.
|
|
127
|
+
const elsewhere =
|
|
128
|
+
primaryWritten.absPath !== undefined &&
|
|
129
|
+
notesDirectory !== undefined &&
|
|
130
|
+
primaryWritten.absPath !== join(notesDirectory, primaryWritten.notePath);
|
|
131
|
+
const target = elsewhere ? primaryWritten.absPath : primaryWritten.notePath;
|
|
123
132
|
if (span !== undefined && primaryWritten.proof.kind === "full") {
|
|
124
|
-
return `Your latest full write to ${
|
|
133
|
+
return `Your latest full write to ${target} is already visible above at entry ${primaryWritten.proof.entryId}; do not rewrite it unless new findings after entry ${span.lastId} require it.`;
|
|
125
134
|
}
|
|
126
|
-
|
|
135
|
+
const how = elsewhere ? "read on its path" : "notes read or read on its path";
|
|
136
|
+
return `Read ${target} (${how}) before continuing to restore the saved goal, progress, decisions, and next steps, then keep that note current as work progresses.`;
|
|
127
137
|
}
|
|
128
138
|
function trackedNotes(
|
|
129
139
|
touches: readonly NoteTouch[],
|
|
@@ -176,7 +186,11 @@ export function renderSessionNotes(
|
|
|
176
186
|
...readOnly.map((note) => formatReadNoteLine(note, span)),
|
|
177
187
|
];
|
|
178
188
|
const header = "Task notes in this session:";
|
|
179
|
-
const action = formatNotesAction(
|
|
189
|
+
const action = formatNotesAction(
|
|
190
|
+
written[0],
|
|
191
|
+
span,
|
|
192
|
+
options.notes === undefined ? defaultDir : scopeDirectory(options.notes.binding),
|
|
193
|
+
);
|
|
180
194
|
const selected: string[] = [];
|
|
181
195
|
for (const line of lines) {
|
|
182
196
|
if ([header, ...selected, line, action].join("\n").length <= maxChars) {
|
package/src/rollover.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type {
|
|
|
6
6
|
SessionBeforeCompactEvent,
|
|
7
7
|
TurnEndEvent,
|
|
8
8
|
} from "@earendil-works/pi-coding-agent";
|
|
9
|
-
import { budgetFor,
|
|
9
|
+
import { budgetFor, freshPayloadChars } from "./budget.ts";
|
|
10
10
|
import {
|
|
11
11
|
isRecord,
|
|
12
12
|
isReminderType,
|
|
@@ -457,7 +457,8 @@ export function registerRollover(
|
|
|
457
457
|
pi.sendMessage(
|
|
458
458
|
{
|
|
459
459
|
customType: REMINDER_TYPE,
|
|
460
|
-
content:
|
|
460
|
+
content:
|
|
461
|
+
"[posthorse] Routine task-note update. Update the task's current-state note if it is stale, then continue with the next step in this run.",
|
|
461
462
|
display: true,
|
|
462
463
|
details: reminder.fingerprint,
|
|
463
464
|
},
|