pi-posthorse 0.10.12 → 0.10.13

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.13",
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": [
@@ -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
@@ -295,7 +295,7 @@ export function registerNotes(
295
295
  label: "Notes",
296
296
  ...toolCards("notes"),
297
297
  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.",
298
+ "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
299
  promptSnippet: "save and recall durable state with private notes for forks",
300
300
  parameters: Type.Object({
301
301
  op: Type.Union(
@@ -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) {