pi-posthorse 0.10.5 → 0.10.7

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.5",
3
+ "version": "0.10.7",
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": [
@@ -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.