pi-posthorse 0.10.9 → 0.10.11

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-posthorse",
3
- "version": "0.10.9",
3
+ "version": "0.10.11",
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": [
@@ -71,9 +71,9 @@
71
71
  "@earendil-works/pi-coding-agent": "1.5.0",
72
72
  "@earendil-works/pi-tui": "1.5.0",
73
73
  "@types/node": "24.19.1",
74
- "oxc-parser": "0.156.0",
75
- "oxfmt": "0.75.0",
76
- "oxlint": "1.90.0",
74
+ "oxc-parser": "0.157.0",
75
+ "oxfmt": "0.76.0",
76
+ "oxlint": "1.91.0",
77
77
  "oxlint-tsgolint": "7.0.2005",
78
78
  "typebox": "1.3.27",
79
79
  "typescript": "7.0.2",
@@ -70,9 +70,7 @@ export function registerContextTools(
70
70
  }),
71
71
  ),
72
72
  }),
73
- // Pi's fixed tool contract supplies id, params, signal, updates, and context.
74
- // oxlint-disable-next-line max-params -- QX-002: Pi supplies five fixed positional tool arguments.
75
- async execute(id, { handoff }, _signal, _onUpdate, ctx) {
73
+ async execute(...[id, { handoff }, , , ctx]) {
76
74
  const trimmed = handoff?.trim();
77
75
  const limit = freshPayloadChars(policy(ctx), activeToolTokens());
78
76
  if (trimmed !== undefined && trimmed !== "" && trimmed.length > limit) {
@@ -98,9 +96,7 @@ export function registerContextTools(
98
96
  promptSnippet:
99
97
  "verify remaining context before reporting a token count or changing plans because of context limits",
100
98
  parameters: Type.Object({}),
101
- // Pi's fixed tool contract supplies id, params, signal, updates, and context.
102
- // oxlint-disable-next-line max-params -- QX-002: Pi supplies five fixed positional tool arguments.
103
- async execute(_id, _params, _signal, _onUpdate, ctx) {
99
+ async execute(...[, , , , ctx]) {
104
100
  return contextReport(policy(ctx));
105
101
  },
106
102
  });
package/src/contracts.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { PosthorseDisplay } from "../ui.ts";
3
3
 
4
+ export { isRecord } from "./records.ts";
5
+
4
6
  export type CompactionPolicy = {
5
7
  readonly enabled: boolean;
6
8
  readonly reserveTokens: number;
@@ -108,9 +110,6 @@ export const REMINDER_TYPE = "posthorse-reminder";
108
110
  export const AUTO_HANDOFF_PREFIX = "Automatic context rollover recovery record.";
109
111
  export const EMPTY_HANDOFF = "Fresh context. Restore relevant notes and history before continuing.";
110
112
 
111
- export function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
112
- return typeof value === "object" && value !== null && !Array.isArray(value);
113
- }
114
113
  export function unknownArray(value: unknown): readonly unknown[] | undefined {
115
114
  return Array.isArray(value) ? value : undefined;
116
115
  }
package/src/files.ts CHANGED
@@ -286,13 +286,20 @@ export function notesDir(cwd: string): string {
286
286
  const target = migrationTarget(moved);
287
287
  try {
288
288
  renameSync(local, target);
289
+ } catch (error) {
290
+ if (lstatSync(local, { throwIfNoEntry: false })?.isDirectory() === true) {
291
+ throw error;
292
+ }
293
+ return moved;
294
+ }
295
+ try {
289
296
  // Sessions still running an older Posthorse keep reaching the notes until they reload.
290
297
  symlinkSync(moved, local, "junction");
291
298
  } catch (error) {
292
299
  if (lstatSync(local, { throwIfNoEntry: false })?.isDirectory() === true) {
293
300
  throw error;
294
301
  }
295
- // Another session moved them first, or only the compatibility link failed.
302
+ // Notes are already outside the checkout; the compatibility link is optional.
296
303
  }
297
304
  }
298
305
  return moved;
@@ -10,10 +10,13 @@ import { excerpt, shortKey, sourceTag } from "./message-text.ts";
10
10
  import type { HistoryMatches, HistoryParams } from "./history-search.ts";
11
11
  import type { PageAccess } from "./paging.ts";
12
12
 
13
+ type Continuation<Position> =
14
+ | { readonly kind: "complete" }
15
+ | { readonly kind: "more"; readonly next: Position };
13
16
  type SearchPage = {
14
17
  readonly parts: readonly string[];
15
18
  readonly spans: readonly { readonly headerLength: number; readonly length: number }[];
16
- readonly next?: string | undefined;
19
+ readonly continuation: Continuation<string>;
17
20
  };
18
21
  function cursorFor(matches: HistoryMatches, hit: HistoryHit, offset: number): string {
19
22
  return Buffer.from(JSON.stringify([hit.id, hit.priority, offset, matches.key])).toString(
@@ -56,7 +59,7 @@ function searchParts(matches: HistoryMatches, chars: number): SearchPage {
56
59
  const parts: string[] = [];
57
60
  const spans: Array<{ headerLength: number; length: number }> = [];
58
61
  let available = chars;
59
- let next: string | undefined;
62
+ let continuation: Continuation<string> = { kind: "complete" };
60
63
  for (const hit of matches.hits) {
61
64
  const fragment = hitFragment(hit, matches.cursor, available, parts.length === 0);
62
65
  if (fragment === undefined) {
@@ -65,15 +68,15 @@ function searchParts(matches: HistoryMatches, chars: number): SearchPage {
65
68
  parts.push(fragment.part);
66
69
  spans.push({ headerLength: fragment.headerLength, length: fragment.part.length });
67
70
  available -= fragment.part.length + 1;
68
- next =
71
+ continuation =
69
72
  fragment.end < hit.text.length || parts.length < matches.hits.length
70
- ? cursorFor(matches, hit, fragment.end)
71
- : undefined;
73
+ ? { kind: "more", next: cursorFor(matches, hit, fragment.end) }
74
+ : { kind: "complete" };
72
75
  if (available <= 0 || parts.length >= matches.limit) {
73
76
  break;
74
77
  }
75
78
  }
76
- return { parts, spans, next };
79
+ return { parts, spans, continuation };
77
80
  }
78
81
  export function searchPage(
79
82
  matches: HistoryMatches,
@@ -98,12 +101,14 @@ export function searchPage(
98
101
  page.parts.length > 0
99
102
  ? page.parts.join("\n")
100
103
  : `No history matches "${excerpt(params.query ?? "", 200)}".`;
101
- const tail = skippedNote + (page.next === undefined ? "" : searchFooter(page.next));
104
+ const tail =
105
+ skippedNote +
106
+ (page.continuation.kind === "more" ? searchFooter(page.continuation.next) : "");
102
107
  return pages.result(body + tail, [], {
103
108
  kind: "history-search",
104
109
  entries: [...page.spans],
105
110
  footerLength: tail.length,
106
- more: page.next !== undefined,
111
+ more: page.continuation.kind === "more",
107
112
  skipped: matches.skipped,
108
113
  });
109
114
  }
@@ -155,9 +160,13 @@ export function historyReadPage(
155
160
  item.text.length,
156
161
  offset + chars - (images.length - firstImage) * ESTIMATED_IMAGE_CHARS,
157
162
  );
158
- const more =
163
+ const continuation: Continuation<{ readonly offset: number; readonly imageOffset: number }> =
159
164
  end < item.text.length || imageEnd < item.images.length
160
- ? `\nMore remains; call history read with id "${shownId}" and offset ${end}${item.images.length > 0 ? ` and imageOffset ${imageEnd}` : ""}.`
165
+ ? { kind: "more", next: { offset: end, imageOffset: imageEnd } }
166
+ : { kind: "complete" };
167
+ const more =
168
+ continuation.kind === "more"
169
+ ? `\nMore remains; call history read with id "${shownId}" and offset ${continuation.next.offset}${item.images.length > 0 ? ` and imageOffset ${continuation.next.imageOffset}` : ""}.`
161
170
  : "";
162
171
  const header = `${item.entry.timestamp ?? ""}${sourceTag(source)} [window ${item.windowId}] [${shownId}] [chars ${offset}-${end} of ${item.text.length}] `;
163
172
  return access.pages.result(`${header}${item.text.slice(offset, end)}${more}`, images, {
@@ -115,12 +115,14 @@ type SearchProgress = {
115
115
  readonly seeking: (priority: 0 | 1) => boolean;
116
116
  readonly enough: () => boolean;
117
117
  };
118
+ type CursorProgress =
119
+ | { readonly kind: "seeking"; readonly cursor: HistoryCursor }
120
+ | { readonly kind: "collecting"; readonly cursor?: HistoryCursor };
118
121
  /** Owns ranking, lookahead and one cursor's progress; never holds the whole archive. */
119
122
  class SearchAccumulator {
120
123
  readonly groups: [HistoryHit[], HistoryHit[]] = [[], []];
121
- readonly cursor: HistoryCursor | undefined;
122
124
  readonly limit: number;
123
- private found: boolean;
125
+ private progress: CursorProgress;
124
126
  private readonly fail: (message: string) => never;
125
127
  constructor(
126
128
  limit: number,
@@ -128,24 +130,27 @@ class SearchAccumulator {
128
130
  fail: (message: string) => never,
129
131
  ) {
130
132
  this.limit = limit;
131
- this.cursor = cursor;
132
- this.found = cursor === undefined;
133
+ this.progress = cursor === undefined ? { kind: "collecting" } : { kind: "seeking", cursor };
133
134
  this.fail = fail;
134
135
  }
136
+ get cursor(): HistoryCursor | undefined {
137
+ return this.progress.cursor;
138
+ }
135
139
  add(hit: HistoryHit): void {
136
- const cursor = this.cursor;
140
+ const progress = this.progress;
141
+ const cursor = progress.cursor;
137
142
  if (cursor !== undefined && hit.priority < cursor[1]) {
138
143
  return;
139
144
  }
140
- if (cursor !== undefined && hit.priority === cursor[1] && !this.found) {
141
- if (hit.id !== cursor[0]) {
145
+ if (progress.kind === "seeking" && hit.priority === progress.cursor[1]) {
146
+ if (hit.id !== progress.cursor[0]) {
142
147
  return;
143
148
  }
144
- this.found = true;
145
- if (cursor[2] > hit.text.length) {
149
+ if (progress.cursor[2] > hit.text.length) {
146
150
  this.fail("History cursor is past the entry; restart the search without it.");
147
151
  }
148
- if (cursor[2] === hit.text.length) {
152
+ this.progress = { kind: "collecting", cursor: progress.cursor };
153
+ if (progress.cursor[2] === hit.text.length) {
149
154
  return;
150
155
  }
151
156
  }
@@ -154,13 +159,13 @@ class SearchAccumulator {
154
159
  }
155
160
  }
156
161
  seeking(priority: 0 | 1): boolean {
157
- return this.cursor !== undefined && !this.found && priority === this.cursor[1];
162
+ return this.progress.kind === "seeking" && priority === this.progress.cursor[1];
158
163
  }
159
164
  enough(): boolean {
160
165
  return this.groups[0].length > this.limit;
161
166
  }
162
167
  results(): HistoryHit[] {
163
- if (!this.found) {
168
+ if (this.progress.kind === "seeking") {
164
169
  this.fail("History cursor entry no longer matches; restart the search without it.");
165
170
  }
166
171
  return this.groups.flat();
package/src/history.ts CHANGED
@@ -127,9 +127,7 @@ export function registerHistory(
127
127
  }),
128
128
  ),
129
129
  }),
130
- // Pi's fixed tool contract supplies id, params, signal, updates, and context.
131
- // oxlint-disable-next-line max-params -- QX-002: Pi supplies five fixed positional tool arguments.
132
- async execute(_id, params, signal, _onUpdate, ctx) {
130
+ async execute(...[, params, signal, , ctx]) {
133
131
  const source = historySource(ctx);
134
132
  if (params.op === "search") {
135
133
  const fail = (message: string): never =>
@@ -102,7 +102,7 @@ async function guardOperation(
102
102
  }
103
103
  if (existingEdit(tool, operation) && !session.owns(address.path)) {
104
104
  const copied = await withFileMutationQueue(path, () => session.adopt(address.path, call));
105
- if (copied) {
105
+ if (copied === "adopted") {
106
106
  return `Private copy created at ${path}; no requested edit ran. Read this copy, then retry the edit.`;
107
107
  }
108
108
  }
@@ -108,30 +108,45 @@ export async function mapBatch<Input, Output>(
108
108
  }
109
109
  return results;
110
110
  }
111
- async function copyNote(note: SnapshotNote, destination: string): Promise<NoteSeed> {
111
+ type CopyResult =
112
+ | { readonly kind: "verified"; readonly seed: NoteSeed }
113
+ | { readonly kind: "unverified"; readonly error: Error };
114
+ async function copyNote(note: SnapshotNote, destination: string): Promise<CopyResult> {
112
115
  const source = notePath(note.source, note.source.path);
113
116
  const [before] = await Promise.all([
114
117
  stat(source),
115
118
  mkdir(dirname(destination), { recursive: true }),
116
119
  ]);
117
120
  await copyFile(source, destination, constants.COPYFILE_EXCL);
118
- const after = await stat(source);
119
- if (!sameIdentity(before, after)) {
120
- throw new Error(`Source note changed during snapshot: ${source}. Retry initialization.`);
121
+ try {
122
+ const after = await stat(source);
123
+ if (!sameIdentity(before, after)) {
124
+ throw new Error(
125
+ `Source note changed during snapshot: ${source}. Retry initialization.`,
126
+ );
127
+ }
128
+ await utimes(destination, before.atimeMs / 1000, before.mtimeMs / 1000);
129
+ const [digest, destInfo] = await Promise.all([fileDigest(destination), stat(destination)]);
130
+ return { kind: "verified", seed: { ...note, digest, identity: fileIdentity(destInfo) } };
131
+ } catch (error) {
132
+ return {
133
+ kind: "unverified",
134
+ error: error instanceof Error ? error : new Error(String(error)),
135
+ };
121
136
  }
122
- await utimes(destination, before.atimeMs / 1000, before.mtimeMs / 1000);
123
- const [digest, destInfo] = await Promise.all([fileDigest(destination), stat(destination)]);
124
- return { ...note, digest, identity: fileIdentity(destInfo) };
125
137
  }
126
- /** Publication is the ownership boundary. An existing unrelated directory is never overwritten. */
127
- export async function snapshot(
138
+ type SnapshotPreparation =
139
+ | { readonly kind: "existing"; readonly binding: ForkBinding }
140
+ | { readonly kind: "empty" }
141
+ | { readonly kind: "staging"; readonly stage: string; readonly container: string };
142
+ async function prepareSnapshot(
128
143
  binding: ForkBinding,
129
144
  notes: readonly SnapshotNote[],
130
- ): Promise<ForkBinding> {
145
+ ): Promise<SnapshotPreparation> {
131
146
  const directory = scopeDirectory(binding);
132
147
  const existing = await readManifest(directory);
133
148
  if (existing !== undefined && sameOwner(existing.owner, binding.owner)) {
134
- return existing;
149
+ return { kind: "existing", binding: existing };
135
150
  }
136
151
  const container = dirname(directory);
137
152
  if (
@@ -145,22 +160,41 @@ export async function snapshot(
145
160
  throw new Error(`Incomplete notes snapshot: preserving unexpected data at ${container}.`);
146
161
  }
147
162
  if (notes.length === 0) {
148
- return binding;
163
+ return { kind: "empty" };
149
164
  }
150
165
  await mkdir(dirname(container), { recursive: true });
151
166
  const stage = await mkdtemp(join(dirname(container), ".staging-"));
167
+ return { kind: "staging", stage, container };
168
+ }
169
+ /** Publication is the ownership boundary. An existing unrelated directory is never overwritten. */
170
+ export async function snapshot(
171
+ binding: ForkBinding,
172
+ notes: readonly SnapshotNote[],
173
+ ): Promise<ForkBinding> {
174
+ const preparation = await prepareSnapshot(binding, notes);
175
+ if (preparation.kind !== "staging") {
176
+ return preparation.kind === "existing" ? preparation.binding : binding;
177
+ }
178
+ const { stage, container } = preparation;
152
179
  try {
153
180
  // Keep each file's identity and digest paired with the bytes actually copied.
154
- const seeds = await mapBatch(notes, (note) =>
155
- copyNote(note, notePath({ root: stage, scope: "notes" }, note.path)),
156
- );
181
+ const seeds = await mapBatch(notes, async (note) => {
182
+ const result = await copyNote(
183
+ note,
184
+ notePath({ root: stage, scope: "notes" }, note.path),
185
+ );
186
+ if (result.kind === "unverified") {
187
+ throw result.error;
188
+ }
189
+ return result.seed;
190
+ });
157
191
  const complete: ForkBinding = { ...binding, seeds };
158
192
  await writeFile(join(stage, "manifest.json"), JSON.stringify(complete), { flag: "wx" });
159
193
  await rename(stage, container);
160
194
  return complete;
161
195
  } catch (error) {
162
196
  throw new Error(
163
- `Incomplete notes snapshot for ${directory}: ${error instanceof Error ? error.message : String(error)}`,
197
+ `Incomplete notes snapshot for ${scopeDirectory(binding)}: ${error instanceof Error ? error.message : String(error)}`,
164
198
  { cause: error },
165
199
  );
166
200
  } finally {
@@ -168,7 +202,7 @@ export async function snapshot(
168
202
  }
169
203
  }
170
204
  /** Called inside the destination's native mutation queue, never queues that path recursively. */
171
- export async function adoptNote(binding: NoteBinding, note: SnapshotNote): Promise<NoteSeed> {
205
+ export async function adoptNote(binding: NoteBinding, note: SnapshotNote): Promise<CopyResult> {
172
206
  return copyNote(note, notePath(binding, note.path));
173
207
  }
174
208
  /** Empty initialized scopes publish their manifest when the first note is created. */
package/src/note-store.ts CHANGED
@@ -35,6 +35,7 @@ import type { NotesRecovery } from "./note-evidence.ts";
35
35
 
36
36
  type NotesManager = ExtensionContext["sessionManager"];
37
37
  export type NoteCall = { readonly id: string; readonly rootId: string };
38
+ type Adoption = "owned" | "unavailable" | "adopted";
38
39
  export type NoteRead = {
39
40
  readonly address: NoteAddress;
40
41
  readonly path: string;
@@ -172,16 +173,16 @@ export class SessionNotes {
172
173
  };
173
174
  return { address, path: notePath(address, relative), writable: reference === undefined };
174
175
  }
175
- async adopt(path: string, call: NoteCall): Promise<boolean> {
176
+ async adopt(path: string, call: NoteCall): Promise<Adoption> {
176
177
  const relative = noteRelative(path);
177
178
  if (this.owns(relative)) {
178
- return false;
179
+ return "owned";
179
180
  }
180
181
  const source = await this.#reference(relative);
181
182
  if (source === undefined) {
182
- return false;
183
+ return "unavailable";
183
184
  }
184
- const seed = await adoptNote(this.binding, {
185
+ const result = await adoptNote(this.binding, {
185
186
  path: relative,
186
187
  source,
187
188
  lineage: source,
@@ -194,20 +195,26 @@ export class SessionNotes {
194
195
  scope: this.binding.scope,
195
196
  path: relative,
196
197
  kind: "adopt",
197
- seed,
198
+ ...(result.kind === "verified" ? { seed: result.seed } : {}),
198
199
  },
199
200
  ],
200
201
  call,
201
202
  );
203
+ if (result.kind === "unverified") {
204
+ throw new Error(
205
+ `Private copy created at ${notePath(this.binding, relative)}; source not edited, but copy verification failed. Read the private copy before retrying.`,
206
+ { cause: result.error },
207
+ );
208
+ }
202
209
  try {
203
- await saveAdoption(this.binding, seed);
210
+ await saveAdoption(this.binding, result.seed);
204
211
  } catch (error) {
205
212
  throw new Error(
206
213
  `Private copy created at ${notePath(this.binding, relative)}; source not edited, but its adoption manifest could not be saved. Read the private copy before retrying.`,
207
214
  { cause: error },
208
215
  );
209
216
  }
210
- return true;
217
+ return "adopted";
211
218
  }
212
219
  async prepareWrite(): Promise<void> {
213
220
  await ensureManifest(this.binding);
@@ -215,7 +222,7 @@ export class SessionNotes {
215
222
  async guardReference(path: string, call: NoteCall): Promise<string> {
216
223
  const destination = notePath(this.binding, path);
217
224
  const copied = await withFileMutationQueue(destination, () => this.adopt(path, call));
218
- return copied
225
+ return copied === "adopted"
219
226
  ? `Private copy created at ${destination}; the source was not edited. Read the private copy, then retry the edit on that path.`
220
227
  : `This session writes only ${this.directory}. Read ${destination}, then retry there; the foreign path was not edited.`;
221
228
  }
@@ -232,15 +239,19 @@ export type NotesStore = {
232
239
  readonly roots: () => readonly string[];
233
240
  readonly recovery: (ctx: ExtensionContext) => NotesRecovery | undefined;
234
241
  };
242
+ type SessionState =
243
+ | { readonly kind: "initializing"; readonly value: Promise<SessionNotes> }
244
+ | { readonly kind: "ready"; readonly value: SessionNotes };
235
245
  export function createNotesStore(pi: ExtensionAPI): NotesStore {
236
- const sessions = new Map<string, Promise<SessionNotes>>();
237
- const ready = new Map<string, SessionNotes>();
246
+ const sessions = new Map<string, SessionState>();
238
247
  const roots = new Set<string>();
239
248
  const get = (ctx: ExtensionContext): Promise<SessionNotes> => {
240
249
  const key = sessionKey(ctx);
241
250
  const existing = sessions.get(key);
242
251
  if (existing !== undefined) {
243
- return existing;
252
+ return existing.kind === "initializing"
253
+ ? existing.value
254
+ : Promise.resolve(existing.value);
244
255
  }
245
256
  const bindings = bindingsOf(ctx.sessionManager.getEntries());
246
257
  const header = ctx.sessionManager.getHeader();
@@ -260,19 +271,22 @@ export function createNotesStore(pi: ExtensionAPI): NotesStore {
260
271
  for (const scope of session.scopes) {
261
272
  roots.add(scope.root);
262
273
  }
263
- ready.set(key, session);
274
+ sessions.set(key, { kind: "ready", value: session });
264
275
  return session;
265
276
  })
266
277
  .catch((error: unknown) => {
267
278
  sessions.delete(key);
268
279
  throw error;
269
280
  });
270
- sessions.set(key, initialized);
281
+ sessions.set(key, { kind: "initializing", value: initialized });
271
282
  return initialized;
272
283
  };
273
284
  return {
274
285
  get,
275
286
  roots: () => [...roots],
276
- recovery: (ctx: ExtensionContext) => ready.get(sessionKey(ctx))?.recovery,
287
+ recovery: (ctx: ExtensionContext) => {
288
+ const state = sessions.get(sessionKey(ctx));
289
+ return state?.kind === "ready" ? state.value.recovery : undefined;
290
+ },
277
291
  };
278
292
  }
@@ -23,6 +23,7 @@ function patchOperations(input: unknown): NoteOperation[] {
23
23
  return [];
24
24
  }
25
25
  const operations: NoteOperation[] = [];
26
+ let update: { readonly path: string; readonly end: number } | undefined;
26
27
  for (const match of input.matchAll(
27
28
  /^\*\*\* (Update File|Add File|Delete File|Move to): ([^\r\n]+)$/gmu,
28
29
  )) {
@@ -31,10 +32,15 @@ function patchOperations(input: unknown): NoteOperation[] {
31
32
  continue;
32
33
  }
33
34
  if (match[1] === "Move to") {
34
- const previous = operations.pop();
35
- if (previous !== undefined) {
36
- operations.push({ ...previous, action: "delete" });
35
+ if (
36
+ update !== undefined &&
37
+ /^(?:\r\n|\r|\n)$/u.test(input.slice(update.end, match.index))
38
+ ) {
39
+ operations[operations.length - 1] = { path: update.path, action: "delete" };
40
+ operations.push({ path, action: "create" });
37
41
  }
42
+ update = undefined;
43
+ continue;
38
44
  }
39
45
  let action: NoteAction = "create";
40
46
  if (match[1] === "Update File") {
@@ -43,6 +49,7 @@ function patchOperations(input: unknown): NoteOperation[] {
43
49
  action = "delete";
44
50
  }
45
51
  operations.push({ path, action });
52
+ update = action === "edit" ? { path, end: match.index + match[0].length } : undefined;
46
53
  }
47
54
  return operations;
48
55
  }
package/src/notes.ts CHANGED
@@ -330,9 +330,7 @@ export function registerNotes(
330
330
  }),
331
331
  ),
332
332
  }),
333
- // Pi's fixed tool contract supplies id, params, signal, updates, and context.
334
- // oxlint-disable-next-line max-params -- QX-002: Pi supplies five fixed positional tool arguments.
335
- async execute(_id, params, signal, _onUpdate, ctx) {
333
+ async execute(...[_id, params, signal, , ctx]) {
336
334
  // Migrate newly returned legacy package notes without changing this session’s binding.
337
335
  notesDir(ctx.cwd);
338
336
  const session = await store.get(ctx);
package/src/paging.ts CHANGED
@@ -36,34 +36,31 @@ export type PageAccess = {
36
36
  readonly error: (ctx: PolicyContext, message: string, cursor?: string) => never;
37
37
  readonly notes: (ctx: PolicyContext, page: NotesPage) => TextResult;
38
38
  };
39
+ type PageAccounting = {
40
+ readonly pendingTokens: number;
41
+ readonly usage: number | null | undefined;
42
+ };
43
+ function reconcile(accounting: PageAccounting, usage: PageAccounting["usage"]): PageAccounting {
44
+ // Native usage may already include serial results, but not parallel siblings.
45
+ const counted =
46
+ typeof usage === "number" && typeof accounting.usage === "number"
47
+ ? Math.max(0, usage - accounting.usage)
48
+ : 0;
49
+ return { pendingTokens: Math.max(0, accounting.pendingTokens - counted), usage };
50
+ }
39
51
  /** Shared by note and history tools. Reservations precede delivery and reset at a turn boundary. */
40
52
  export class PageReservations {
41
- private pendingTokens = 0;
42
- private previousUsage: number | null | undefined;
53
+ private accounting: PageAccounting = { pendingTokens: 0, usage: undefined };
43
54
  private readonly toolTokens: () => number;
44
55
  constructor(toolTokens: () => number) {
45
56
  this.toolTokens = toolTokens;
46
57
  }
47
58
  reset(): void {
48
- this.pendingTokens = 0;
49
- this.previousUsage = undefined;
59
+ this.accounting = { pendingTokens: 0, usage: undefined };
50
60
  }
51
61
  size(ctx: PolicyContext, request: PageRequest): number {
52
- const usage = ctx.getContextUsage()?.tokens;
53
- // Native usage may already include serial results, but not parallel siblings.
54
- if (
55
- usage !== undefined &&
56
- usage !== null &&
57
- this.previousUsage !== undefined &&
58
- this.previousUsage !== null
59
- ) {
60
- this.pendingTokens = Math.max(
61
- 0,
62
- this.pendingTokens - Math.max(0, usage - this.previousUsage),
63
- );
64
- }
65
- this.previousUsage = usage;
66
- const remaining = pageCapacity(ctx, this.toolTokens) - this.pendingTokens;
62
+ this.accounting = reconcile(this.accounting, ctx.getContextUsage()?.tokens);
63
+ const remaining = pageCapacity(ctx, this.toolTokens) - this.accounting.pendingTokens;
67
64
  const chars = Math.min(
68
65
  MAX_PAGE_CHARS,
69
66
  Math.max(0, remaining - PAGE_MARGIN_TOKENS) * 4 -
@@ -74,6 +71,15 @@ export class PageReservations {
74
71
  }
75
72
  return chars;
76
73
  }
74
+ private reserve(text: string, imageCount = 0): void {
75
+ this.accounting = {
76
+ ...this.accounting,
77
+ pendingTokens:
78
+ this.accounting.pendingTokens +
79
+ Math.ceil(text.length / 4) +
80
+ imageCount * (ESTIMATED_IMAGE_CHARS / 4),
81
+ };
82
+ }
77
83
  private refuse(remaining: number, request: PageRequest): never {
78
84
  const position =
79
85
  request.cursor !== undefined && request.cursor !== ""
@@ -82,17 +88,16 @@ export class PageReservations {
82
88
  const message = `Too little context remains to read a page safely. Call new_context first, then retry ${position}.`;
83
89
  // Even refusals consume context; omit repeated guidance when it no longer fits.
84
90
  const text = Math.ceil(message.length / 4) <= remaining ? message : "";
85
- this.pendingTokens += Math.ceil(text.length / 4);
91
+ this.reserve(text);
86
92
  throw new Error(text);
87
93
  }
88
94
  result(text: string, images: readonly ImageLike[], display: PosthorseDisplay): TextResult {
89
- this.pendingTokens +=
90
- Math.ceil(text.length / 4) + images.length * (ESTIMATED_IMAGE_CHARS / 4);
95
+ this.reserve(text, images.length);
91
96
  return textResult(text, images, display);
92
97
  }
93
98
  error(ctx: PolicyContext, message: string, cursor?: string): never {
94
99
  this.size(ctx, { offset: 0, cursor });
95
- this.pendingTokens += Math.ceil(message.length / 4);
100
+ this.reserve(message);
96
101
  throw new Error(message);
97
102
  }
98
103
  notes(ctx: PolicyContext, page: NotesPage): TextResult {
package/src/records.ts ADDED
@@ -0,0 +1,3 @@
1
+ export function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
2
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3
+ }