pi-posthorse 0.10.2 → 0.10.4

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/src/notes.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
2
- import { readdir, realpath, stat } from "node:fs/promises";
3
- import { dirname, isAbsolute, join, normalize, sep } from "node:path";
2
+ import { dirname } from "node:path";
4
3
  import { setImmediate } from "node:timers/promises";
5
4
  import {
6
5
  formatSize,
@@ -12,13 +11,15 @@ import { Type } from "typebox";
12
11
  import { toolCards } from "../ui.ts";
13
12
  import {
14
13
  MAX_PAGE_CHARS,
15
- isRecord,
16
14
  requireValue,
17
15
  textResult,
18
16
  type PolicyContext,
19
17
  type TextResult,
20
18
  } from "./contracts.ts";
21
19
  import { isBinaryFile, notesDir, publishFile, readText } from "./files.ts";
20
+ import { notePath, scopeDirectory } from "./note-scopes.ts";
21
+ import type { NoteCall, NotesStore, SessionNotes } from "./note-store.ts";
22
+ import { NoteDiscovery, noteLabel } from "./note-discovery.ts";
22
23
  import { excerpt, excerptAround } from "./message-text.ts";
23
24
  import type { PageAccess } from "./paging.ts";
24
25
 
@@ -28,135 +29,54 @@ export type NotesParams = {
28
29
  readonly content?: string;
29
30
  readonly query?: string;
30
31
  readonly offset?: number;
32
+ readonly scope?: string;
31
33
  };
32
- type NoteFile = { readonly path: string; readonly size: number; readonly mtimeMs: number };
33
34
  type NoteHits = { readonly rows: string[]; readonly more: boolean; readonly skipped: number };
34
35
  /** Paged operations take a fresh policy snapshot after their filesystem awaits. */
35
36
  type NotesAccess = {
36
37
  readonly host: () => PolicyContext;
37
38
  readonly pages: PageAccess;
38
39
  readonly signal: AbortSignal | undefined;
40
+ readonly call: NoteCall;
41
+ readonly discovery: NoteDiscovery;
39
42
  };
40
43
  /** Larger files are logs or evidence rather than notes; reading them would dominate a search. */
41
44
  const SEARCH_FILE_BYTES = 1024 * 1024;
42
45
  /** Overlapping reads cut a full search of a 212,000-file notes tree from 39 s to 14 s. */
43
46
  const SEARCH_BATCH = 16;
44
- /**
45
- * Maximum explicit `stat` calls overlapped per batch. On a 212,751-note tree, 16, 64 and 256 took
46
- * equal time within host noise. The walk enters one folder at a time, so this bounds our pending
47
- * `stat` calls at any depth. On filesystems that report unknown entry types, Node 24's
48
- * `readdir({ withFileTypes: true })` also issues its own `lstat` for every entry at once.
49
- */
50
- const WALK_BATCH = 64;
51
47
  /** Formatting all 212,751 rows of one listing at once stalled Pi for 156-163 ms. */
52
48
  const FORMAT_BATCH = 10_000;
53
49
  /** Search rescans up to the requested offset, so this also caps the matches one call keeps. */
54
50
  const SEARCH_OFFSET_LIMIT = 1_000_000;
55
- function safeJoin(dir: string, path: string): string {
56
- const relative = normalize(path.replace(/^[/\\]+/, ""));
57
- if (relative === ".." || relative.startsWith(`..${sep}`) || isAbsolute(relative)) {
58
- throw new Error(`Invalid path "${path}": must stay inside the notes directory.`);
59
- }
60
- return join(dir, relative);
61
- }
62
- /** Like `statSync(path, { throwIfNoEntry: false })`: an entry deleted mid-walk is skipped. */
63
- async function statIfPresent(path: string) {
64
- try {
65
- return await stat(path);
66
- } catch (error) {
67
- if (isRecord(error) && (error.code === "ENOENT" || error.code === "ENOTDIR")) {
68
- return;
69
- }
70
- throw error;
71
- }
72
- }
73
- /** A folder's present entries in listing order. */
74
- async function* folderEntries(directory: string, signal: AbortSignal | undefined) {
75
- const entries = await readdir(directory, { withFileTypes: true });
76
- let start = 0;
77
- while (start < entries.length) {
78
- signal?.throwIfAborted();
79
- const batch = entries.slice(start, start + WALK_BATCH);
80
- const firstFolder = batch.findIndex(
81
- (entry) => entry.isDirectory() || entry.isSymbolicLink(),
82
- );
83
- const paths = batch
84
- .slice(0, firstFolder === -1 ? batch.length : firstFolder + 1)
85
- .map((entry) => join(directory, entry.name));
86
- // Stats overlap only up to the next entry that may be a folder.
87
- // oxlint-disable-next-line no-await-in-loop
88
- const infos = await Promise.all(paths.map(statIfPresent));
89
- for (const [index, path] of paths.entries()) {
90
- start += 1;
91
- const info = infos[index];
92
- if (info !== undefined) {
93
- yield { path, info };
94
- }
95
- if (info?.isDirectory() === true) {
96
- // Stat later entries only after this folder's subtree is walked, discarding any
97
- // already taken, so entries removed meanwhile are skipped.
98
- break;
99
- }
100
- }
101
- }
102
- }
103
- /** Files depth-first in directory-listing order, yielding between filesystem calls. */
104
- async function walk(
105
- directory: string,
106
- signal: AbortSignal | undefined,
107
- ancestors: readonly string[] = [],
108
- ): Promise<NoteFile[]> {
109
- signal?.throwIfAborted();
110
- const root = await realpath(directory);
111
- if (ancestors.includes(root)) {
112
- return [];
113
- }
114
- const next = [...ancestors, root];
115
- const files: NoteFile[] = [];
116
- for await (const { path, info } of folderEntries(directory, signal)) {
117
- if (info.isDirectory()) {
118
- for (const file of await walk(path, signal, next)) {
119
- files.push(file);
120
- }
121
- } else {
122
- files.push({ path, size: info.size, mtimeMs: info.mtimeMs });
123
- }
124
- }
125
- return files;
126
- }
127
- async function notesUnder(target: string, signal: AbortSignal | undefined): Promise<NoteFile[]> {
128
- const info = await statIfPresent(target);
129
- signal?.throwIfAborted();
130
- if (info === undefined) {
131
- return [];
132
- }
133
- const files = info.isDirectory()
134
- ? await walk(target, signal)
135
- : [{ path: target, size: info.size, mtimeMs: info.mtimeMs }];
136
- signal?.throwIfAborted();
137
- return files.sort((a, b) => b.mtimeMs - a.mtimeMs);
138
- }
139
51
  async function listNotes(
140
- dir: string,
52
+ session: SessionNotes,
141
53
  params: NotesParams,
142
54
  access: NotesAccess,
143
55
  ): Promise<TextResult> {
144
56
  const { host, pages, signal } = access;
145
57
  const folder = params.path;
146
- const target = folder === undefined || folder === "" ? dir : safeJoin(dir, folder);
147
- const files = await notesUnder(target, signal);
58
+ const inventory = await access.discovery.list(session, {
59
+ path: folder,
60
+ scope: params.scope,
61
+ signal,
62
+ });
63
+ const { files } = inventory;
64
+ const dir =
65
+ params.scope === undefined
66
+ ? session.directory
67
+ : scopeDirectory(await session.selectScope(params.scope));
148
68
  const rows: string[] = [];
149
69
  for (let start = 0; start < files.length; start += FORMAT_BATCH) {
150
70
  // Pi gets an event-loop turn before each batch of rows.
151
- // oxlint-disable-next-line no-await-in-loop
152
71
  await setImmediate();
153
72
  signal?.throwIfAborted();
154
73
  for (const file of files.slice(start, start + FORMAT_BATCH)) {
155
74
  rows.push(
156
- `${file.path.slice(dir.length + 1)} ${formatSize(file.size)} ${new Date(file.mtimeMs).toISOString().slice(0, 16)}Z`,
75
+ `${noteLabel(file, session, inventory.labeled)} ${formatSize(file.size)} ${new Date(file.mtimeMs).toISOString().slice(0, 16)}Z`,
157
76
  );
158
77
  }
159
78
  }
79
+ rows.push(...inventory.notices);
160
80
  return pages.notes(host(), {
161
81
  rows,
162
82
  offset: params.offset ?? 0,
@@ -166,19 +86,20 @@ async function listNotes(
166
86
  });
167
87
  }
168
88
  async function readNote(
169
- dir: string,
89
+ session: SessionNotes,
170
90
  params: NotesParams,
171
91
  access: NotesAccess,
172
92
  ): Promise<TextResult> {
173
93
  const { pages } = access;
174
94
  const relative = requireValue(params.path, "path", params.op);
175
- const path = safeJoin(dir, relative);
95
+ const target = await session.read(relative, { scope: params.scope, signal: access.signal });
96
+ const path = target.path;
176
97
  const info = statSync(path, { throwIfNoEntry: false });
177
98
  if (info === undefined) {
178
99
  throw new Error(`No note at ${relative}. Use op "list" to see available notes.`);
179
100
  }
180
101
  if (info.isDirectory()) {
181
- return listNotes(dir, params, access);
102
+ return listNotes(session, { ...params, scope: scopeDirectory(target.address) }, access);
182
103
  }
183
104
  if (await isBinaryFile(path)) {
184
105
  throw new Error(
@@ -190,7 +111,9 @@ async function readNote(
190
111
  if (offset > 0 && offset >= text.length) {
191
112
  throw new Error(`Offset ${offset} is past the end of ${relative} (${text.length} chars).`);
192
113
  }
193
- const header = `File: ${path}\n`;
114
+ const header = target.writable
115
+ ? `File: ${path}\n`
116
+ : `Read-only live reference: notes read ${JSON.stringify({ scope: scopeDirectory(target.address), path: target.address.path })}\n`;
194
117
  const host = access.host();
195
118
  const chars = pages.size(host, { offset }) - header.length;
196
119
  if (chars <= 0) {
@@ -204,6 +127,7 @@ async function readNote(
204
127
  end < text.length
205
128
  ? `\n[chars ${offset}-${end} of ${text.length}; continue with offset ${end}]`
206
129
  : "";
130
+ session.record([{ ...target.address, kind: "read" }], access.call);
207
131
  return pages.result(`${header}${text.slice(offset, end)}${more}`, [], {
208
132
  kind: "note-read",
209
133
  headerLength: header.length,
@@ -213,36 +137,64 @@ async function readNote(
213
137
  });
214
138
  }
215
139
  async function writeNote(
216
- dir: string,
140
+ session: SessionNotes,
217
141
  params: NotesParams,
218
- signal?: AbortSignal,
142
+ access: NotesAccess,
219
143
  ): Promise<TextResult> {
220
144
  const relative = requireValue(params.path, "path", params.op);
221
145
  const content = params.content;
222
146
  if (content === undefined) {
223
147
  throw new Error('"content" is required for op "write" (use "" to clear a note).');
224
148
  }
225
- const path = safeJoin(dir, relative);
226
- mkdirSync(dirname(path), { recursive: true });
227
- await withFileMutationQueue(path, () => publishFile(path, content, signal));
149
+ const path = notePath(session.binding, relative);
150
+ await withFileMutationQueue(path, async () => {
151
+ await session.prepareWrite();
152
+ mkdirSync(dirname(path), { recursive: true });
153
+ await publishFile(path, content, access.signal);
154
+ session.record(
155
+ [
156
+ {
157
+ root: session.binding.root,
158
+ scope: session.binding.scope,
159
+ path: relative,
160
+ kind: content === "" ? "clear" : "write",
161
+ },
162
+ ],
163
+ access.call,
164
+ );
165
+ });
228
166
  return textResult(`Wrote ${path}`, [], { kind: "note-write" });
229
167
  }
230
168
  async function appendNote(
231
- dir: string,
169
+ session: SessionNotes,
232
170
  params: NotesParams,
233
- signal?: AbortSignal,
171
+ access: NotesAccess,
234
172
  ): Promise<TextResult> {
235
173
  const relative = requireValue(params.path, "path", params.op);
236
174
  const content = requireValue(params.content, "content", params.op);
237
- const path = safeJoin(dir, relative);
238
- mkdirSync(dirname(path), { recursive: true });
175
+ const path = notePath(session.binding, relative);
239
176
  await withFileMutationQueue(path, async () => {
240
- signal?.throwIfAborted();
177
+ access.signal?.throwIfAborted();
178
+ await session.adopt(relative, access.call);
179
+ access.signal?.throwIfAborted();
180
+ await session.prepareWrite();
181
+ mkdirSync(dirname(path), { recursive: true });
241
182
  const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
242
183
  const separator = existing !== "" && !existing.endsWith("\n") ? "\n" : "";
243
184
  // One O_APPEND write keeps records separate across processes. A racing
244
185
  // unterminated write can at worst introduce an extra blank separator.
245
186
  appendFileSync(path, `${separator}${content.replace(/\n?$/, "\n")}`);
187
+ session.record(
188
+ [
189
+ {
190
+ root: session.binding.root,
191
+ scope: session.binding.scope,
192
+ path: relative,
193
+ kind: "write",
194
+ },
195
+ ],
196
+ access.call,
197
+ );
246
198
  });
247
199
  return textResult(`Appended to ${path}`, [], { kind: "note-append" });
248
200
  }
@@ -261,12 +213,14 @@ function matchingLines(name: string, text: string, query: string): string[] {
261
213
  }
262
214
  /** Matches in newest-first note order, stopping once they pass `limit` characters. */
263
215
  async function noteHits(
264
- dir: string,
216
+ session: SessionNotes,
265
217
  query: string,
266
- limit: number,
267
- signal?: AbortSignal,
218
+ options: { readonly limit: number; readonly scope: string | undefined },
219
+ access: NotesAccess,
268
220
  ): Promise<NoteHits> {
269
- const notes = await notesUnder(dir, signal);
221
+ const { signal } = access;
222
+ const inventory = await access.discovery.list(session, { scope: options.scope, signal });
223
+ const notes = inventory.files;
270
224
  const rows: string[] = [];
271
225
  let length = -1;
272
226
  let skipped = 0;
@@ -274,16 +228,15 @@ async function noteHits(
274
228
  // A batch of notes over 1 MiB reads nothing that would notice cancellation.
275
229
  signal?.throwIfAborted();
276
230
  // Batches keep note order, so a search stops within one batch of the page limit.
277
- // oxlint-disable-next-line no-await-in-loop
278
231
  const batch = await Promise.all(
279
232
  notes
280
233
  .slice(start, start + SEARCH_BATCH)
281
- .map(async ({ path, size }) =>
282
- size > SEARCH_FILE_BYTES
234
+ .map(async (note) =>
235
+ note.size > SEARCH_FILE_BYTES
283
236
  ? undefined
284
237
  : matchingLines(
285
- path.slice(dir.length + 1),
286
- (await readText(path, signal)) ?? "",
238
+ noteLabel(note, session, inventory.labeled),
239
+ (await readText(note.path, signal)) ?? "",
287
240
  query,
288
241
  ),
289
242
  ),
@@ -292,15 +245,15 @@ async function noteHits(
292
245
  for (const row of batch.flatMap((found) => found ?? [])) {
293
246
  rows.push(row);
294
247
  length += row.length + 1;
295
- if (length > limit) {
248
+ if (length > options.limit) {
296
249
  return { rows, more: true, skipped };
297
250
  }
298
251
  }
299
252
  }
300
- return { rows, more: false, skipped };
253
+ return { rows: [...rows, ...inventory.notices], more: false, skipped };
301
254
  }
302
255
  async function searchNotes(
303
- dir: string,
256
+ session: SessionNotes,
304
257
  params: NotesParams,
305
258
  access: NotesAccess,
306
259
  ): Promise<TextResult> {
@@ -313,7 +266,12 @@ async function searchNotes(
313
266
  `Notes search pages end before offset ${SEARCH_OFFSET_LIMIT}; search for something more specific.`,
314
267
  );
315
268
  }
316
- const hits = await noteHits(dir, query.toLowerCase(), offset + MAX_PAGE_CHARS, access.signal);
269
+ const hits = await noteHits(
270
+ session,
271
+ query.toLowerCase(),
272
+ { limit: offset + MAX_PAGE_CHARS, scope: params.scope },
273
+ access,
274
+ );
317
275
  const skipped = hits.skipped === 1 ? "1 note" : `${hits.skipped} notes`;
318
276
  return pages.notes(host(), {
319
277
  rows: hits.rows,
@@ -328,14 +286,16 @@ export function registerNotes(
328
286
  pi: ExtensionAPI,
329
287
  policy: (ctx: ExtensionContext) => PolicyContext,
330
288
  pages: PageAccess,
289
+ store: NotesStore,
331
290
  ): void {
291
+ const discovery = new NoteDiscovery();
332
292
  pi.registerTool({
333
293
  name: "notes",
334
294
  label: "Notes",
335
295
  ...toolCards("notes"),
336
296
  description:
337
- "Durable notes shared across Git worktrees and context resets. Ops: list (newest first, size/date; optional folder), read, search (case-insensitive text), write (replace; empty clears), append (one newline-terminated record). list/read/search are paged; continue with the returned offset. list shows the absolute notes directory; read shows the absolute file path. Prefer available file-editing tools for changed sections of one concise current-state note; write for creation or substantial restructuring. Link fuller evidence instead of copying them.",
338
- promptSnippet: "save and recall durable state that survives context resets",
297
+ "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
+ promptSnippet: "save and recall durable state with private notes for forks",
339
299
  parameters: Type.Object({
340
300
  op: Type.Union(
341
301
  [
@@ -350,6 +310,12 @@ export function registerNotes(
350
310
  path: Type.Optional(
351
311
  Type.String({ description: "Note or folder path relative to the notes directory" }),
352
312
  ),
313
+ scope: Type.Optional(
314
+ Type.String({
315
+ description:
316
+ "Absolute scope selector from list/search; only for read/list/search. Omit for current notes and project-wide discovery.",
317
+ }),
318
+ ),
353
319
  content: Type.Optional(
354
320
  Type.String({ description: "Full file content (write) or text to add (append)" }),
355
321
  ),
@@ -364,21 +330,36 @@ export function registerNotes(
364
330
  ),
365
331
  }),
366
332
  // Pi's fixed tool contract supplies id, params, signal, updates, and context.
367
- // oxlint-disable-next-line max-params
333
+ // oxlint-disable-next-line max-params -- QX-002: Pi supplies five fixed positional tool arguments.
368
334
  async execute(_id, params, signal, _onUpdate, ctx) {
369
- const dir = notesDir(ctx.cwd);
370
- const access: NotesAccess = { host: () => policy(ctx), pages, signal };
335
+ // Migrate newly returned legacy package notes without changing this session’s binding.
336
+ notesDir(ctx.cwd);
337
+ const session = await store.get(ctx);
338
+ if (params.scope !== undefined && (params.op === "write" || params.op === "append")) {
339
+ throw new Error(
340
+ "scope is only valid for read/list/search; writes use this session’s notes scope.",
341
+ );
342
+ }
343
+ const access: NotesAccess = {
344
+ host: () => policy(ctx),
345
+ pages,
346
+ signal,
347
+ call: session.call(_id),
348
+ discovery,
349
+ };
371
350
  switch (params.op) {
372
351
  case "list":
373
- return listNotes(dir, params, access);
352
+ return listNotes(session, params, access);
374
353
  case "read":
375
- return readNote(dir, params, access);
354
+ return params.path === "."
355
+ ? listNotes(session, params, access)
356
+ : readNote(session, params, access);
376
357
  case "write":
377
- return writeNote(dir, params, signal);
358
+ return writeNote(session, params, access);
378
359
  case "append":
379
- return appendNote(dir, params, signal);
360
+ return appendNote(session, params, access);
380
361
  case "search":
381
- return searchNotes(dir, params, access);
362
+ return searchNotes(session, params, access);
382
363
  }
383
364
  },
384
365
  });
package/src/paging.ts CHANGED
@@ -14,8 +14,8 @@ import {
14
14
  export type PageRequest = {
15
15
  readonly offset: number;
16
16
  readonly imageCount?: number;
17
- readonly cursor?: string;
18
- readonly imageOffset?: number;
17
+ readonly cursor?: string | undefined;
18
+ readonly imageOffset?: number | undefined;
19
19
  };
20
20
  export type NotesPage = {
21
21
  readonly rows: readonly string[];