pi-posthorse 0.10.4 → 0.10.6

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.4",
3
+ "version": "0.10.6",
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": [
@@ -51,8 +51,8 @@
51
51
  "lint:fix:oxlint": "oxlint --fix ."
52
52
  },
53
53
  "peerDependencies": {
54
- "@earendil-works/pi-coding-agent": ">=1.4.0",
55
- "@earendil-works/pi-tui": ">=1.4.0",
54
+ "@earendil-works/pi-coding-agent": ">=1.5.0",
55
+ "@earendil-works/pi-tui": ">=1.5.0",
56
56
  "typebox": "*"
57
57
  },
58
58
  "peerDependenciesMeta": {
@@ -67,9 +67,9 @@
67
67
  }
68
68
  },
69
69
  "devDependencies": {
70
- "@earendil-works/pi-ai": "1.4.0",
71
- "@earendil-works/pi-coding-agent": "1.4.0",
72
- "@earendil-works/pi-tui": "1.4.0",
70
+ "@earendil-works/pi-ai": "1.5.0",
71
+ "@earendil-works/pi-coding-agent": "1.5.0",
72
+ "@earendil-works/pi-tui": "1.5.0",
73
73
  "@types/node": "24.19.1",
74
74
  "oxc-parser": "0.156.0",
75
75
  "oxfmt": "0.75.0",
@@ -3,7 +3,13 @@ import { join, relative } from "node:path";
3
3
  import { isRecord } from "./contracts.ts";
4
4
  import { sessionNoteTouches } from "./note-evidence.ts";
5
5
  import { notesUnder, type NoteFile } from "./note-inventory.ts";
6
- import { fileDigest, fileIdentity, readManifest, sameIdentity } from "./note-snapshots.ts";
6
+ import {
7
+ fileDigest,
8
+ fileIdentity,
9
+ mapBatch,
10
+ readManifest,
11
+ sameIdentity,
12
+ } from "./note-snapshots.ts";
7
13
  import {
8
14
  sameScope,
9
15
  scopeDirectory,
@@ -47,12 +53,13 @@ async function projectScopes(
47
53
  }
48
54
  throw error;
49
55
  });
50
- for (const entry of entries) {
56
+ const candidates = entries.filter((entry) => !entry.name.startsWith(".staging-"));
57
+ const manifests = await mapBatch(candidates, async (entry) =>
58
+ entry.isDirectory() ? readManifest(join(directory, entry.name, "notes")) : undefined,
59
+ );
60
+ for (const [index, entry] of candidates.entries()) {
51
61
  const path = join(directory, entry.name);
52
- if (entry.name.startsWith(".staging-")) {
53
- continue;
54
- }
55
- const manifest = entry.isDirectory() ? await readManifest(join(path, "notes")) : undefined;
62
+ const manifest = manifests[index];
56
63
  if (manifest === undefined || manifest.root !== session.binding.root) {
57
64
  notice(`Not indexed: unmanaged or incomplete notes data at ${path}.`);
58
65
  } else {
@@ -199,9 +206,19 @@ export class NoteDiscovery {
199
206
  Number(sameScope(b.address, session.binding)) -
200
207
  Number(sameScope(a.address, session.binding)),
201
208
  );
202
- for (const file of group) {
209
+ const results = await mapBatch(group, async (file) => {
210
+ const fileNotices: string[] = [];
211
+ const digest = await this.#digest(file, session, {
212
+ ...options,
213
+ notice: (text) => {
214
+ fileNotices.push(text);
215
+ },
216
+ });
217
+ return { file, digest, fileNotices };
218
+ });
219
+ for (const { file, digest, fileNotices } of results) {
220
+ fileNotices.forEach(options.notice);
203
221
  // Only same-lineage, same-size candidates need content comparison.
204
- const digest = await this.#digest(file, session, options);
205
222
  const previous = digest === undefined ? undefined : matches.get(digest);
206
223
  if (previous === undefined || digest === undefined) {
207
224
  matches.set(digest ?? noteKey(file.address), file);
@@ -229,22 +246,17 @@ export class NoteDiscovery {
229
246
  directories: new Set(session.scopes.map(scopeDirectory)),
230
247
  });
231
248
  const paths = new Set(touches.map((touch) => touch.notePath));
232
- const files: DiscoveredNote[] = [];
233
- for (const path of paths) {
234
- if (
235
- session.owns(path) ||
236
- (options.path !== undefined &&
237
- path !== options.path &&
238
- !path.startsWith(`${options.path}/`))
239
- ) {
240
- continue;
241
- }
242
- const file = await readReference(session, path, options.signal);
243
- if (file !== undefined) {
244
- files.push(file);
245
- }
246
- }
247
- return files;
249
+ const candidates = [...paths].filter(
250
+ (path) =>
251
+ !session.owns(path) &&
252
+ (options.path === undefined ||
253
+ path === options.path ||
254
+ path.startsWith(`${options.path}/`)),
255
+ );
256
+ const resolved = await mapBatch(candidates, (path) =>
257
+ readReference(session, path, options.signal),
258
+ );
259
+ return resolved.filter((file): file is DiscoveredNote => file !== undefined);
248
260
  }
249
261
  async list(
250
262
  session: SessionNotes,
@@ -258,21 +270,32 @@ export class NoteDiscovery {
258
270
  const notice = (text: string) => {
259
271
  notices.push(text);
260
272
  };
261
- const scopes =
262
- options.scope === undefined
263
- ? await projectScopes(session, notice)
264
- : [
273
+ const requestedScope = options.scope;
274
+ const [scopes, references] = await Promise.all([
275
+ requestedScope === undefined
276
+ ? projectScopes(session, notice)
277
+ : (async () => [
265
278
  {
266
- scope: await session.selectScope(options.scope),
267
- manifest: await readManifest(options.scope),
279
+ scope: await session.selectScope(requestedScope),
280
+ manifest: await readManifest(requestedScope),
268
281
  },
269
- ];
282
+ ])(),
283
+ this.#references(session, options),
284
+ ]);
285
+ const scoped = await mapBatch(scopes, async (scope) => {
286
+ const scopeNotices: string[] = [];
287
+ const scopeFiles = await scopedFiles(scope, options, (text) => {
288
+ scopeNotices.push(text);
289
+ });
290
+ return { scopeFiles, scopeNotices };
291
+ });
270
292
  const files: DiscoveredNote[] = [];
271
- for (const scope of scopes) {
293
+ for (const { scopeFiles, scopeNotices } of scoped) {
272
294
  // Preserve native listing order while walking each scope independently.
273
- files.push(...(await scopedFiles(scope, options, notice)));
295
+ notices.push(...scopeNotices);
296
+ files.push(...scopeFiles);
274
297
  }
275
- for (const reference of await this.#references(session, options)) {
298
+ for (const reference of references) {
276
299
  if (!files.some((file) => noteKey(file.address) === noteKey(reference.address))) {
277
300
  files.push(reference);
278
301
  }
@@ -12,6 +12,7 @@ import {
12
12
  type NoteAddress,
13
13
  type NoteTouchRecord,
14
14
  } from "./note-scopes.ts";
15
+ import { mapBatch } from "./note-snapshots.ts";
15
16
  import { noteOperations, verifiedNoteOperations, type NoteOperation } from "./note-tool-effects.ts";
16
17
  import type { NoteCall, NotesStore, SessionNotes } from "./note-store.ts";
17
18
 
@@ -115,12 +116,12 @@ async function recordedFiles(
115
116
  captured: CapturedCall,
116
117
  ): Promise<NoteTouchRecord["files"]> {
117
118
  const files: NoteTouchRecord["files"][number][] = [];
118
- for (const operation of operations) {
119
+ const addresses = await mapBatch(operations, (operation) =>
120
+ noteAddress(toolPath(captured.tool, operation.path, captured.cwd), captured.session),
121
+ );
122
+ for (const [index, operation] of operations.entries()) {
119
123
  // Each result belongs to the scope captured before execution, even after /tree.
120
- const address = await noteAddress(
121
- toolPath(captured.tool, operation.path, captured.cwd),
122
- captured.session,
123
- );
124
+ const address = addresses[index];
124
125
  if (address === undefined) {
125
126
  continue;
126
127
  }
@@ -5,7 +5,7 @@ import { isRecord, type EntryLike } from "./contracts.ts";
5
5
  import { sessionLines } from "./session-lines.ts";
6
6
  import { parseSessionLine } from "./session-files.ts";
7
7
  import { sessionNoteTouches } from "./note-evidence.ts";
8
- import { readManifest, snapshot, type SnapshotNote } from "./note-snapshots.ts";
8
+ import { mapBatch, readManifest, snapshot, type SnapshotNote } from "./note-snapshots.ts";
9
9
  import {
10
10
  addressOf,
11
11
  bindingsOf,
@@ -227,9 +227,12 @@ async function forkBinding(
227
227
  const entries = [...manager.getEntries(), ...source.entries];
228
228
  const files: SnapshotNote[] = [];
229
229
  const absent: string[] = [];
230
- for (const candidate of candidates) {
230
+ const addresses = await mapBatch(candidates, (candidate) =>
231
+ resolveReference(scope ?? candidate.source, candidate.path, entries),
232
+ );
233
+ for (const [index, candidate] of candidates.entries()) {
231
234
  // Resolve against the immediate parent's files, not a historical ancestor's version.
232
- const address = await resolveReference(scope ?? candidate.source, candidate.path, entries);
235
+ const address = addresses[index];
233
236
  if (address !== undefined) {
234
237
  files.push({
235
238
  path: candidate.path,
@@ -89,18 +89,39 @@ export type SnapshotNote = {
89
89
  readonly lineage: NoteAddress;
90
90
  readonly anchors: readonly string[];
91
91
  };
92
+ const NOTE_IO_BATCH = 16;
93
+ export async function mapBatch<Input, Output>(
94
+ items: readonly Input[],
95
+ fn: (item: Input) => Promise<Output>,
96
+ ): Promise<Output[]> {
97
+ const results: Output[] = [];
98
+ for (let start = 0; start < items.length; start += NOTE_IO_BATCH) {
99
+ const settled = await Promise.allSettled(
100
+ items.slice(start, start + NOTE_IO_BATCH).map((item) => fn(item)),
101
+ );
102
+ for (const item of settled) {
103
+ if (item.status === "rejected") {
104
+ throw item.reason instanceof Error ? item.reason : new Error(String(item.reason));
105
+ }
106
+ results.push(item.value);
107
+ }
108
+ }
109
+ return results;
110
+ }
92
111
  async function copyNote(note: SnapshotNote, destination: string): Promise<NoteSeed> {
93
112
  const source = notePath(note.source, note.source.path);
94
- const before = await stat(source);
95
- await mkdir(dirname(destination), { recursive: true });
113
+ const [before] = await Promise.all([
114
+ stat(source),
115
+ mkdir(dirname(destination), { recursive: true }),
116
+ ]);
96
117
  await copyFile(source, destination, constants.COPYFILE_EXCL);
97
118
  const after = await stat(source);
98
119
  if (!sameIdentity(before, after)) {
99
120
  throw new Error(`Source note changed during snapshot: ${source}. Retry initialization.`);
100
121
  }
101
122
  await utimes(destination, before.atimeMs / 1000, before.mtimeMs / 1000);
102
- const digest = await fileDigest(destination);
103
- return { ...note, digest, identity: fileIdentity(await stat(destination)) };
123
+ const [digest, destInfo] = await Promise.all([fileDigest(destination), stat(destination)]);
124
+ return { ...note, digest, identity: fileIdentity(destInfo) };
104
125
  }
105
126
  /** Publication is the ownership boundary. An existing unrelated directory is never overwritten. */
106
127
  export async function snapshot(
@@ -129,11 +150,10 @@ export async function snapshot(
129
150
  await mkdir(dirname(container), { recursive: true });
130
151
  const stage = await mkdtemp(join(dirname(container), ".staging-"));
131
152
  try {
132
- const seeds: NoteSeed[] = [];
133
- for (const note of notes) {
134
- // Keep each file's identity and digest paired with the bytes actually copied.
135
- seeds.push(await copyNote(note, notePath({ root: stage, scope: "notes" }, note.path)));
136
- }
153
+ // 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
+ );
137
157
  const complete: ForkBinding = { ...binding, seeds };
138
158
  await writeFile(join(stage, "manifest.json"), JSON.stringify(complete), { flag: "wx" });
139
159
  await rename(stage, container);
package/src/notes.ts CHANGED
@@ -55,16 +55,17 @@ async function listNotes(
55
55
  ): Promise<TextResult> {
56
56
  const { host, pages, signal } = access;
57
57
  const folder = params.path;
58
- const inventory = await access.discovery.list(session, {
59
- path: folder,
60
- scope: params.scope,
61
- signal,
62
- });
58
+ const [inventory, selectedScope] = await Promise.all([
59
+ access.discovery.list(session, {
60
+ path: folder,
61
+ scope: params.scope,
62
+ signal,
63
+ }),
64
+ (async () =>
65
+ params.scope === undefined ? undefined : session.selectScope(params.scope))(),
66
+ ]);
63
67
  const { files } = inventory;
64
- const dir =
65
- params.scope === undefined
66
- ? session.directory
67
- : scopeDirectory(await session.selectScope(params.scope));
68
+ const dir = selectedScope === undefined ? session.directory : scopeDirectory(selectedScope);
68
69
  const rows: string[] = [];
69
70
  for (let start = 0; start < files.length; start += FORMAT_BATCH) {
70
71
  // Pi gets an event-loop turn before each batch of rows.
package/src/ui/cards.ts CHANGED
@@ -121,6 +121,7 @@ interface ResultFacts {
121
121
  readonly expanded: boolean;
122
122
  readonly imageCount: number;
123
123
  readonly showImages: boolean;
124
+ readonly expandKey: string;
124
125
  }
125
126
 
126
127
  function submittedText(facts: ResultFacts): string {
@@ -310,7 +311,12 @@ function collapsedView(
310
311
  const attachments =
311
312
  attachment.length > 0 ? [truncateToWidth(theme.fg("muted", attachment), width)] : [];
312
313
  const hidden = details || rows.length > shown.length;
313
- return [...summaryRows, ...shown, ...attachments, ...(hidden ? [hint(theme, width)] : [])];
314
+ return [
315
+ ...summaryRows,
316
+ ...shown,
317
+ ...attachments,
318
+ ...(hidden ? [hint(theme, width, facts.expandKey)] : []),
319
+ ];
314
320
  };
315
321
  }
316
322
 
@@ -343,6 +349,8 @@ export function toolCards(name: ToolName): Pick<ToolDefinition, "renderCall" | "
343
349
  expanded,
344
350
  imageCount: result.content.filter((part) => part.type === "image").length,
345
351
  showImages: context.showImages,
352
+ // Pi injects keyText so the hint shows the user's configured keys.
353
+ expandKey: context.keyText("app.tools.expand"),
346
354
  },
347
355
  theme,
348
356
  );
package/src/ui/text.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { keyText, type Theme } from "@earendil-works/pi-coding-agent";
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { Text, stripTerminalSequences, truncateToWidth } from "@earendil-works/pi-tui";
3
3
 
4
4
  export type OutputColor = "toolOutput" | "muted" | "dim" | "error" | "warning";
@@ -81,8 +81,7 @@ export function leadingRows(
81
81
  };
82
82
  }
83
83
 
84
- export function hint(theme: Theme, width: number): string {
85
- const key = keyText("app.tools.expand");
84
+ export function hint(theme: Theme, width: number, key: string): string {
86
85
  return truncateToWidth(
87
86
  theme.fg("dim", key.length > 0 ? `… ${key} to expand` : "… details hidden"),
88
87
  width,
package/ui.ts CHANGED
@@ -35,6 +35,8 @@ export function registerPosthorseMessages(pi: ExtensionAPI): void {
35
35
  : raw;
36
36
  const previewRows = window ? 2 : 3;
37
37
  const previewText = leadingRows(preview, theme, "toolOutput", previewRows);
38
+ // Pi injects keyText so the hint shows the user's configured keys.
39
+ const expandKey = options.keyText("app.tools.expand");
38
40
  let fullText: Text | undefined;
39
41
  const historyText = textBlock("Earlier conversation remains in history.", theme, "muted");
40
42
  const content: Component = {
@@ -57,7 +59,7 @@ export function registerPosthorseMessages(pi: ExtensionAPI): void {
57
59
  : [
58
60
  ...(window ? historyText.render(inner).slice(0, 2) : []),
59
61
  ...previewText(inner).slice(0, previewRows),
60
- hint(theme, inner),
62
+ hint(theme, inner, expandKey),
61
63
  ];
62
64
  const box = new Box(padding, 1, (line) => theme.bg("customMessageBg", line));
63
65
  box.addChild({