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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-posthorse",
3
- "version": "0.10.12",
3
+ "version": "0.10.14",
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": [
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
- : ` The rollover line is ${budget.rolloverAt.toLocaleString("en-US")} tokens used under the available Pi settings.`;
88
- return `${CHECKPOINT_GUIDANCE}\nAutomatic rollover follows Pi's compaction setting and retains a safe recent-message tail using compaction.keepRecentTokens, without model summarization.${line} At most one routine task-note reminder may arrive at the end of a working turn, never on a turn that ends the run. new_context is an optional deliberate clean reset, not a checkpoint requirement.`;
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. 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.`;
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; link fuller evidence and history instead of copying them. Edit changed sections rather than resending unchanged content.
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 {
@@ -84,13 +84,13 @@ export function searchPage(
84
84
  host: PolicyContext,
85
85
  pages: PageAccess,
86
86
  ): TextResult {
87
- const chars = pages.size(host, { offset: 0, cursor: params.cursor });
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.", params.cursor);
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
  }
@@ -156,9 +156,11 @@ export function scopeRecoveryTouches(
156
156
  if (address !== undefined && sameScope(address, recovery.binding)) {
157
157
  return touch;
158
158
  }
159
- const currentOwner =
160
- touch.owner !== undefined && sameOwner(touch.owner, recovery.binding.owner);
161
- if (owned.has(touch.notePath) && !currentOwner) {
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),
@@ -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
- async function guardForeign(
72
- address: NoteAddress,
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 `This is another notes scope. Use notes read with ${JSON.stringify({ scope: scopeDirectory(address), path: address.path })} for a labeled read-only comparison, or omit scope to read this session’s copy.`;
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 operation.action !== "read" &&
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
- const copied = await withFileMutationQueue(path, () => session.adopt(address.path, call));
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
- return;
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
- const operations = noteOperations(event.toolName, event.input);
152
- const session = await store.get(ctx).catch((error: unknown) => {
153
- if (
154
- event.toolName === "notes" ||
155
- operations.some((operation) =>
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" || operations.length === 0) {
131
+ if (event.toolName === "notes") {
177
132
  return;
178
133
  }
179
- for (const operation of operations) {
180
- // Preflight never mutates arguments; a copied reference needs a fresh read before retry.
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);
@@ -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 host = access.host();
119
- const chars = pages.size(host, { offset }) - header.length;
120
- if (chars <= 0) {
121
- pages.error(
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. Forks own private copies of selected-branch notes. Ops: list (newest first, size/date; optional folder), read, search (case-insensitive text), write (replace; empty clears), append (one newline-terminated record). list/search discover project-wide notes with scope selectors; use scope for read-only comparisons or a single-scope view. Omit scope for current notes and live references. list/read/search are paged; continue with the returned offset. list shows the current writable directory; current-file reads show an editable path, foreign reads a read-only reference. Edit the returned current path for small changes. A blocked foreign edit may have created a private copy: read that copy before retrying. Append adopts unowned references without losing their text. Keep one concise current-state note and link fuller evidence.",
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 cursor?: string | undefined;
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: PageRequest) => number;
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, cursor?: string) => never;
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
- size(ctx: PolicyContext, request: PageRequest): number {
62
- this.accounting = reconcile(this.accounting, ctx.getContextUsage()?.tokens);
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 chars = Math.min(
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
- if (chars < MIN_PAGE_CHARS) {
70
- this.refuse(remaining, request);
71
- }
72
- return chars;
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, cursor?: string): never {
99
- this.size(ctx, { offset: 0, cursor });
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, { offset }) - header.length;
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 (slash.startsWith("/") || /^[A-Za-z]:\//u.test(slash)) {
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
- return rel === "" ? undefined : { notePath: rel, absPath: trimmed };
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
  }
@@ -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} (read-only reference; notes read ${JSON.stringify({ scope: note.referenceScope, path: 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 ${primaryWritten.notePath} is already visible above at entry ${primaryWritten.proof.entryId}; do not rewrite it unless new findings after entry ${span.lastId} require it.`;
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
- return `Read ${primaryWritten.notePath} (notes read or read on its path) before continuing to restore the saved goal, progress, decisions, and next steps, then keep that note current as work progresses.`;
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(written[0], span);
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, CHECKPOINT_GUIDANCE, freshPayloadChars } from "./budget.ts";
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: `[posthorse] Routine task-note update.\n${CHECKPOINT_GUIDANCE}`,
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
  },