pi-posthorse 0.10.16 → 0.10.18

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.16",
3
+ "version": "0.10.18",
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
@@ -79,7 +79,7 @@ function automaticGuidance(budget: Budget | undefined, enabled: boolean): string
79
79
  ? ""
80
80
  : `The rollover line is ${budget.rolloverAt.toLocaleString("en-US")} tokens used. `;
81
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.`;
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, or write one if the task has none, then continue with the next step in the same run. new_context is an optional clean reset.`;
83
83
  }
84
84
  export function buildGuidance(ctx: PolicyContext): string {
85
85
  const budget = budgetFor(ctx);
@@ -7,6 +7,7 @@ import { parseSessionLine } from "./session-files.ts";
7
7
  import { sessionNoteTouches } from "./note-evidence.ts";
8
8
  import { mapBatch, readManifest, snapshot, type SnapshotNote } from "./note-snapshots.ts";
9
9
  import {
10
+ addressIn,
10
11
  addressOf,
11
12
  bindingsOf,
12
13
  canonicalPath,
@@ -130,7 +131,7 @@ export async function resolveReference(
130
131
  }
131
132
  visited.add(directory);
132
133
  if (existsSync(notePath(current, path))) {
133
- return { ...current, path };
134
+ return addressIn(current, path);
134
135
  }
135
136
  // A manifest includes later adoptions; the journal includes later writes and deletions.
136
137
  const binding: NoteBinding | undefined =
@@ -204,7 +205,7 @@ function seedCandidates(
204
205
  ) {
205
206
  selected.set(seed.path, {
206
207
  ...seed,
207
- source: { root: binding.root, scope: binding.scope, path: seed.path },
208
+ source: addressIn(binding, seed.path),
208
209
  cleared: false,
209
210
  });
210
211
  }
@@ -83,6 +83,13 @@ export function forkScope(owner: NoteOwner): string {
83
83
  export function scopeDirectory(scope: NoteScope): string {
84
84
  return join(scope.root, scope.scope);
85
85
  }
86
+ /**
87
+ * An address is stored in the journal, and a scope value is often a whole binding. Build an
88
+ * address here, never by spreading a scope, so it holds the two location fields and nothing else.
89
+ */
90
+ export function addressIn(scope: NoteScope, path: string): NoteAddress {
91
+ return { root: scope.root, scope: scope.scope, path };
92
+ }
86
93
  export function sameScope(left: NoteScope, right: NoteScope): boolean {
87
94
  return left.root === right.root && left.scope === right.scope;
88
95
  }
@@ -278,7 +285,7 @@ export function addressOf(path: string, scopes: readonly NoteScope[]): NoteAddre
278
285
  ? undefined
279
286
  : candidates.find((item) => item.directory === match.directory);
280
287
  if (match !== undefined && candidate !== undefined) {
281
- return { ...candidate.scope, path: match.path };
288
+ return addressIn(candidate.scope, match.path);
282
289
  }
283
290
  if (
284
291
  scopes.some(
@@ -18,6 +18,7 @@ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
18
18
  import { isRecord } from "./contracts.ts";
19
19
  import { publishFile } from "./files.ts";
20
20
  import {
21
+ addressIn,
21
22
  notePath,
22
23
  parseBinding,
23
24
  sameOwner,
@@ -127,7 +128,19 @@ async function copyNote(note: SnapshotNote, destination: string): Promise<CopyRe
127
128
  }
128
129
  await utimes(destination, before.atimeMs / 1000, before.mtimeMs / 1000);
129
130
  const [digest, destInfo] = await Promise.all([fileDigest(destination), stat(destination)]);
130
- return { kind: "verified", seed: { ...note, digest, identity: fileIdentity(destInfo) } };
131
+ return {
132
+ kind: "verified",
133
+ // Journals written by 0.10.4 through 0.10.17 hold whole bindings in seed addresses.
134
+ // A new seed keeps each location only, so that data is not copied into this fork.
135
+ seed: {
136
+ path: note.path,
137
+ source: addressIn(note.source, note.source.path),
138
+ lineage: addressIn(note.lineage, note.lineage.path),
139
+ digest,
140
+ identity: fileIdentity(destInfo),
141
+ anchors: note.anchors,
142
+ },
143
+ };
131
144
  } catch (error) {
132
145
  return {
133
146
  kind: "unverified",
package/src/note-store.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  import {
14
14
  NOTES_BINDING,
15
15
  NOTES_TOUCH,
16
+ addressIn,
16
17
  bindingsOf,
17
18
  canonicalPath,
18
19
  inside,
@@ -129,7 +130,7 @@ export class SessionNotes {
129
130
  return manifest;
130
131
  }
131
132
  throw new Error(
132
- `Unknown notes scope ${selector}. Copy a scope selector from notes list/search.`,
133
+ `Unknown notes scope ${JSON.stringify(selector)}. Omit scope for this session's notes, or copy a scope selector from notes list/search.`,
133
134
  );
134
135
  }
135
136
  async #reference(path: string, signal?: AbortSignal): Promise<NoteAddress | undefined> {
@@ -154,7 +155,7 @@ export class SessionNotes {
154
155
  if (options.scope !== undefined) {
155
156
  const scope = await this.selectScope(options.scope);
156
157
  return {
157
- address: { ...scope, path: relative },
158
+ address: addressIn(scope, relative),
158
159
  path: notePath(scope, relative),
159
160
  writable: sameScope(scope, this.binding),
160
161
  };
@@ -162,11 +163,7 @@ export class SessionNotes {
162
163
  const reference = this.owns(relative)
163
164
  ? undefined
164
165
  : await this.#reference(relative, options.signal);
165
- const address = reference ?? {
166
- root: this.binding.root,
167
- scope: this.binding.scope,
168
- path: relative,
169
- };
166
+ const address = reference ?? addressIn(this.binding, relative);
170
167
  return { address, path: notePath(address, relative), writable: reference === undefined };
171
168
  }
172
169
  async adopt(path: string, call: NoteCall): Promise<Adoption> {
package/src/notes.ts CHANGED
@@ -278,6 +278,11 @@ async function searchNotes(
278
278
  header: hits.skipped > 0 ? `[Not searched: ${skipped} over 1 MiB.]\n` : "",
279
279
  });
280
280
  }
281
+ /** Some models send every optional argument; an empty scope means no scope. */
282
+ function withoutEmptyScope(params: NotesParams): NotesParams {
283
+ const { scope, ...unscoped } = params;
284
+ return scope === "" ? unscoped : params;
285
+ }
281
286
  export function registerNotes(
282
287
  pi: ExtensionAPI,
283
288
  policy: (ctx: ExtensionContext) => PolicyContext,
@@ -325,7 +330,8 @@ export function registerNotes(
325
330
  }),
326
331
  ),
327
332
  }),
328
- async execute(...[_id, params, signal, , ctx]) {
333
+ async execute(...[_id, received, signal, , ctx]) {
334
+ const params = withoutEmptyScope(received);
329
335
  // Migrate newly returned legacy package notes without changing this session’s binding.
330
336
  notesDir(ctx.cwd);
331
337
  const session = await store.get(ctx);
@@ -262,8 +262,9 @@ function notesToolTouches(
262
262
  const parsed = resultText === undefined ? undefined : extractNotesResultTarget(resultText);
263
263
  const kind = notesCallKind(args) ?? parsed?.kind;
264
264
  const path = optionalString(args.path);
265
- const scopedPath =
266
- typeof args.scope === "string" && path !== undefined ? `${args.scope}/${path}` : path;
265
+ // An empty scope counts as omitted, as it does when the tool runs.
266
+ const scope = args.scope === "" ? undefined : optionalString(args.scope);
267
+ const scopedPath = scope !== undefined && path !== undefined ? `${scope}/${path}` : path;
267
268
  const callTarget = resolveNoteTarget(scopedPath, context.knownDirs, true);
268
269
  const resTarget = resolveNoteTarget(parsed?.rawPath, context.knownDirs, true);
269
270
  const merged = mergeNoteTargets(callTarget, resTarget);
@@ -77,22 +77,39 @@ function applyTouch(
77
77
  order,
78
78
  };
79
79
  }
80
- function formatNoteLabel(note: TrackedNote): string {
80
+ /** `notes read` resolves a bare name in this session's own notes, so a note elsewhere is named by its path. */
81
+ function pathElsewhere(note: TrackedNote, notesDirectory: string | undefined): string | undefined {
81
82
  if (note.referenceScope !== undefined) {
82
- return `${note.notePath} (another notes scope: ${note.absPath ?? `${note.referenceScope}/${note.notePath}`})`;
83
+ return note.absPath ?? `${note.referenceScope}/${note.notePath}`;
84
+ }
85
+ return note.absPath !== undefined &&
86
+ notesDirectory !== undefined &&
87
+ note.absPath !== join(notesDirectory, note.notePath)
88
+ ? note.absPath
89
+ : undefined;
90
+ }
91
+ function formatNoteLabel(note: TrackedNote, elsewhere: string | undefined): string {
92
+ if (elsewhere !== undefined) {
93
+ return note.referenceScope === undefined ? elsewhere : `${elsewhere} (another notes scope)`;
83
94
  }
84
95
  return note.absPath !== undefined && note.absPath !== note.notePath
85
96
  ? `${note.notePath} (${note.absPath})`
86
97
  : note.notePath;
87
98
  }
88
- function formatWrittenNoteLine(note: WrittenNote, span: RetainedSpan | undefined): string {
89
- const label = formatNoteLabel(note);
99
+ function formatWrittenNoteLine(
100
+ note: WrittenNote,
101
+ span: RetainedSpan | undefined,
102
+ notesDirectory: string | undefined,
103
+ ): string {
104
+ const elsewhere = pathElsewhere(note, notesDirectory);
105
+ const label = formatNoteLabel(note, elsewhere);
106
+ const how = elsewhere === undefined ? "" : " on its path";
90
107
  const { proof } = note;
91
108
  if (proof.kind === "effect-only") {
92
- return `- Active note (written): ${label} [verified effect at entry ${note.entryId}; full content is not retained — read this note now]`;
109
+ return `- Active note (written): ${label} [verified effect at entry ${note.entryId}; full content is not retained — read this note${how} now]`;
93
110
  }
94
111
  if (span === undefined) {
95
- return `- Active note (written): ${label} [compacted at entry ${note.entryId} — read this note now]`;
112
+ return `- Active note (written): ${label} [compacted at entry ${note.entryId} — read this note${how} now]`;
96
113
  }
97
114
  switch (proof.kind) {
98
115
  case "full": {
@@ -101,13 +118,17 @@ function formatWrittenNoteLine(note: WrittenNote, span: RetainedSpan | undefined
101
118
  return `- Active note (written): ${label} [latest full write is in the retained tail above at entry ${proof.entryId}${updated}]`;
102
119
  }
103
120
  case "partial":
104
- return `- Active note (written): ${label} [only partial edits are in the retained tail above at entry ${proof.entryId} — read full note now]`;
121
+ return `- Active note (written): ${label} [only partial edits are in the retained tail above at entry ${proof.entryId} — read full note${how} now]`;
105
122
  case "compacted":
106
- return `- Active note (written): ${label} [compacted before entry ${span.firstId} — read this note now]`;
123
+ return `- Active note (written): ${label} [compacted before entry ${span.firstId} — read this note${how} now]`;
107
124
  }
108
125
  }
109
- function formatReadNoteLine(note: ReadNote, span: RetainedSpan | undefined): string {
110
- const label = formatNoteLabel(note);
126
+ function formatReadNoteLine(
127
+ note: ReadNote,
128
+ span: RetainedSpan | undefined,
129
+ notesDirectory: string | undefined,
130
+ ): string {
131
+ const label = formatNoteLabel(note, pathElsewhere(note, notesDirectory));
111
132
  if (span !== undefined && note.inRetained) {
112
133
  return `- Referenced note (read): ${label} [read in the retained tail above at entry ${note.entryId}]`;
113
134
  }
@@ -123,16 +144,12 @@ function formatNotesAction(
123
144
  if (primaryWritten === undefined) {
124
145
  return "Read the referenced note again if its context is still needed, and keep one current-state note for this task.";
125
146
  }
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;
147
+ const elsewhere = pathElsewhere(primaryWritten, notesDirectory);
148
+ const target = elsewhere ?? primaryWritten.notePath;
132
149
  if (span !== undefined && primaryWritten.proof.kind === "full") {
133
150
  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.`;
134
151
  }
135
- const how = elsewhere ? "read on its path" : "notes read or read on its path";
152
+ const how = elsewhere === undefined ? "notes read or read on its path" : "read on its path";
136
153
  return `Read ${target} (${how}) before continuing to restore the saved goal, progress, decisions, and next steps, then keep that note current as work progresses.`;
137
154
  }
138
155
  function trackedNotes(
@@ -181,16 +198,14 @@ export function renderSessionNotes(
181
198
  }
182
199
  const written = all.filter((note) => note.kind === "written").slice(0, MAX_WRITTEN_NOTES);
183
200
  const readOnly = all.filter((note) => note.kind === "read").slice(0, MAX_READ_NOTES);
201
+ const notesDirectory =
202
+ options.notes === undefined ? defaultDir : scopeDirectory(options.notes.binding);
184
203
  const lines = [
185
- ...written.map((note) => formatWrittenNoteLine(note, span)),
186
- ...readOnly.map((note) => formatReadNoteLine(note, span)),
204
+ ...written.map((note) => formatWrittenNoteLine(note, span, notesDirectory)),
205
+ ...readOnly.map((note) => formatReadNoteLine(note, span, notesDirectory)),
187
206
  ];
188
207
  const header = "Task notes in this session:";
189
- const action = formatNotesAction(
190
- written[0],
191
- span,
192
- options.notes === undefined ? defaultDir : scopeDirectory(options.notes.binding),
193
- );
208
+ const action = formatNotesAction(written[0], span, notesDirectory);
194
209
  const selected: string[] = [];
195
210
  for (const line of lines) {
196
211
  if ([header, ...selected, line, action].join("\n").length <= maxChars) {
package/src/rollover.ts CHANGED
@@ -445,7 +445,7 @@ export function registerRollover(
445
445
  {
446
446
  customType: REMINDER_TYPE,
447
447
  content:
448
- "[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.",
448
+ "[posthorse] Routine task-note update. Update the task's current-state note if it is stale, or write one if the task has none, then continue with the next step in this run.",
449
449
  display: true,
450
450
  details: reminder.fingerprint,
451
451
  },