pi-posthorse 0.10.3 → 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,122 +29,42 @@ 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
- const infos = await Promise.all(paths.map(statIfPresent));
88
- for (const [index, path] of paths.entries()) {
89
- start += 1;
90
- const info = infos[index];
91
- if (info !== undefined) {
92
- yield { path, info };
93
- }
94
- if (info?.isDirectory() === true) {
95
- // Stat later entries only after this folder's subtree is walked, discarding any
96
- // already taken, so entries removed meanwhile are skipped.
97
- break;
98
- }
99
- }
100
- }
101
- }
102
- /** Files depth-first in directory-listing order, yielding between filesystem calls. */
103
- async function walk(
104
- directory: string,
105
- signal: AbortSignal | undefined,
106
- ancestors: readonly string[] = [],
107
- ): Promise<NoteFile[]> {
108
- signal?.throwIfAborted();
109
- const root = await realpath(directory);
110
- if (ancestors.includes(root)) {
111
- return [];
112
- }
113
- const next = [...ancestors, root];
114
- const files: NoteFile[] = [];
115
- for await (const { path, info } of folderEntries(directory, signal)) {
116
- if (info.isDirectory()) {
117
- for (const file of await walk(path, signal, next)) {
118
- files.push(file);
119
- }
120
- } else {
121
- files.push({ path, size: info.size, mtimeMs: info.mtimeMs });
122
- }
123
- }
124
- return files;
125
- }
126
- async function notesUnder(target: string, signal: AbortSignal | undefined): Promise<NoteFile[]> {
127
- const info = await statIfPresent(target);
128
- signal?.throwIfAborted();
129
- if (info === undefined) {
130
- return [];
131
- }
132
- const files = info.isDirectory()
133
- ? await walk(target, signal)
134
- : [{ path: target, size: info.size, mtimeMs: info.mtimeMs }];
135
- signal?.throwIfAborted();
136
- return files.sort((a, b) => b.mtimeMs - a.mtimeMs);
137
- }
138
51
  async function listNotes(
139
- dir: string,
52
+ session: SessionNotes,
140
53
  params: NotesParams,
141
54
  access: NotesAccess,
142
55
  ): Promise<TextResult> {
143
56
  const { host, pages, signal } = access;
144
57
  const folder = params.path;
145
- const target = folder === undefined || folder === "" ? dir : safeJoin(dir, folder);
146
- 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));
147
68
  const rows: string[] = [];
148
69
  for (let start = 0; start < files.length; start += FORMAT_BATCH) {
149
70
  // Pi gets an event-loop turn before each batch of rows.
@@ -151,10 +72,11 @@ async function listNotes(
151
72
  signal?.throwIfAborted();
152
73
  for (const file of files.slice(start, start + FORMAT_BATCH)) {
153
74
  rows.push(
154
- `${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`,
155
76
  );
156
77
  }
157
78
  }
79
+ rows.push(...inventory.notices);
158
80
  return pages.notes(host(), {
159
81
  rows,
160
82
  offset: params.offset ?? 0,
@@ -164,19 +86,20 @@ async function listNotes(
164
86
  });
165
87
  }
166
88
  async function readNote(
167
- dir: string,
89
+ session: SessionNotes,
168
90
  params: NotesParams,
169
91
  access: NotesAccess,
170
92
  ): Promise<TextResult> {
171
93
  const { pages } = access;
172
94
  const relative = requireValue(params.path, "path", params.op);
173
- const path = safeJoin(dir, relative);
95
+ const target = await session.read(relative, { scope: params.scope, signal: access.signal });
96
+ const path = target.path;
174
97
  const info = statSync(path, { throwIfNoEntry: false });
175
98
  if (info === undefined) {
176
99
  throw new Error(`No note at ${relative}. Use op "list" to see available notes.`);
177
100
  }
178
101
  if (info.isDirectory()) {
179
- return listNotes(dir, params, access);
102
+ return listNotes(session, { ...params, scope: scopeDirectory(target.address) }, access);
180
103
  }
181
104
  if (await isBinaryFile(path)) {
182
105
  throw new Error(
@@ -188,7 +111,9 @@ async function readNote(
188
111
  if (offset > 0 && offset >= text.length) {
189
112
  throw new Error(`Offset ${offset} is past the end of ${relative} (${text.length} chars).`);
190
113
  }
191
- 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`;
192
117
  const host = access.host();
193
118
  const chars = pages.size(host, { offset }) - header.length;
194
119
  if (chars <= 0) {
@@ -202,6 +127,7 @@ async function readNote(
202
127
  end < text.length
203
128
  ? `\n[chars ${offset}-${end} of ${text.length}; continue with offset ${end}]`
204
129
  : "";
130
+ session.record([{ ...target.address, kind: "read" }], access.call);
205
131
  return pages.result(`${header}${text.slice(offset, end)}${more}`, [], {
206
132
  kind: "note-read",
207
133
  headerLength: header.length,
@@ -211,36 +137,64 @@ async function readNote(
211
137
  });
212
138
  }
213
139
  async function writeNote(
214
- dir: string,
140
+ session: SessionNotes,
215
141
  params: NotesParams,
216
- signal?: AbortSignal,
142
+ access: NotesAccess,
217
143
  ): Promise<TextResult> {
218
144
  const relative = requireValue(params.path, "path", params.op);
219
145
  const content = params.content;
220
146
  if (content === undefined) {
221
147
  throw new Error('"content" is required for op "write" (use "" to clear a note).');
222
148
  }
223
- const path = safeJoin(dir, relative);
224
- mkdirSync(dirname(path), { recursive: true });
225
- 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
+ });
226
166
  return textResult(`Wrote ${path}`, [], { kind: "note-write" });
227
167
  }
228
168
  async function appendNote(
229
- dir: string,
169
+ session: SessionNotes,
230
170
  params: NotesParams,
231
- signal?: AbortSignal,
171
+ access: NotesAccess,
232
172
  ): Promise<TextResult> {
233
173
  const relative = requireValue(params.path, "path", params.op);
234
174
  const content = requireValue(params.content, "content", params.op);
235
- const path = safeJoin(dir, relative);
236
- mkdirSync(dirname(path), { recursive: true });
175
+ const path = notePath(session.binding, relative);
237
176
  await withFileMutationQueue(path, async () => {
238
- 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 });
239
182
  const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
240
183
  const separator = existing !== "" && !existing.endsWith("\n") ? "\n" : "";
241
184
  // One O_APPEND write keeps records separate across processes. A racing
242
185
  // unterminated write can at worst introduce an extra blank separator.
243
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
+ );
244
198
  });
245
199
  return textResult(`Appended to ${path}`, [], { kind: "note-append" });
246
200
  }
@@ -259,12 +213,14 @@ function matchingLines(name: string, text: string, query: string): string[] {
259
213
  }
260
214
  /** Matches in newest-first note order, stopping once they pass `limit` characters. */
261
215
  async function noteHits(
262
- dir: string,
216
+ session: SessionNotes,
263
217
  query: string,
264
- limit: number,
265
- signal?: AbortSignal,
218
+ options: { readonly limit: number; readonly scope: string | undefined },
219
+ access: NotesAccess,
266
220
  ): Promise<NoteHits> {
267
- 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;
268
224
  const rows: string[] = [];
269
225
  let length = -1;
270
226
  let skipped = 0;
@@ -275,12 +231,12 @@ async function noteHits(
275
231
  const batch = await Promise.all(
276
232
  notes
277
233
  .slice(start, start + SEARCH_BATCH)
278
- .map(async ({ path, size }) =>
279
- size > SEARCH_FILE_BYTES
234
+ .map(async (note) =>
235
+ note.size > SEARCH_FILE_BYTES
280
236
  ? undefined
281
237
  : matchingLines(
282
- path.slice(dir.length + 1),
283
- (await readText(path, signal)) ?? "",
238
+ noteLabel(note, session, inventory.labeled),
239
+ (await readText(note.path, signal)) ?? "",
284
240
  query,
285
241
  ),
286
242
  ),
@@ -289,15 +245,15 @@ async function noteHits(
289
245
  for (const row of batch.flatMap((found) => found ?? [])) {
290
246
  rows.push(row);
291
247
  length += row.length + 1;
292
- if (length > limit) {
248
+ if (length > options.limit) {
293
249
  return { rows, more: true, skipped };
294
250
  }
295
251
  }
296
252
  }
297
- return { rows, more: false, skipped };
253
+ return { rows: [...rows, ...inventory.notices], more: false, skipped };
298
254
  }
299
255
  async function searchNotes(
300
- dir: string,
256
+ session: SessionNotes,
301
257
  params: NotesParams,
302
258
  access: NotesAccess,
303
259
  ): Promise<TextResult> {
@@ -310,7 +266,12 @@ async function searchNotes(
310
266
  `Notes search pages end before offset ${SEARCH_OFFSET_LIMIT}; search for something more specific.`,
311
267
  );
312
268
  }
313
- 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
+ );
314
275
  const skipped = hits.skipped === 1 ? "1 note" : `${hits.skipped} notes`;
315
276
  return pages.notes(host(), {
316
277
  rows: hits.rows,
@@ -325,14 +286,16 @@ export function registerNotes(
325
286
  pi: ExtensionAPI,
326
287
  policy: (ctx: ExtensionContext) => PolicyContext,
327
288
  pages: PageAccess,
289
+ store: NotesStore,
328
290
  ): void {
291
+ const discovery = new NoteDiscovery();
329
292
  pi.registerTool({
330
293
  name: "notes",
331
294
  label: "Notes",
332
295
  ...toolCards("notes"),
333
296
  description:
334
- "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.",
335
- 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",
336
299
  parameters: Type.Object({
337
300
  op: Type.Union(
338
301
  [
@@ -347,6 +310,12 @@ export function registerNotes(
347
310
  path: Type.Optional(
348
311
  Type.String({ description: "Note or folder path relative to the notes directory" }),
349
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
+ ),
350
319
  content: Type.Optional(
351
320
  Type.String({ description: "Full file content (write) or text to add (append)" }),
352
321
  ),
@@ -363,19 +332,34 @@ export function registerNotes(
363
332
  // Pi's fixed tool contract supplies id, params, signal, updates, and context.
364
333
  // oxlint-disable-next-line max-params -- QX-002: Pi supplies five fixed positional tool arguments.
365
334
  async execute(_id, params, signal, _onUpdate, ctx) {
366
- const dir = notesDir(ctx.cwd);
367
- 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
+ };
368
350
  switch (params.op) {
369
351
  case "list":
370
- return listNotes(dir, params, access);
352
+ return listNotes(session, params, access);
371
353
  case "read":
372
- return readNote(dir, params, access);
354
+ return params.path === "."
355
+ ? listNotes(session, params, access)
356
+ : readNote(session, params, access);
373
357
  case "write":
374
- return writeNote(dir, params, signal);
358
+ return writeNote(session, params, access);
375
359
  case "append":
376
- return appendNote(dir, params, signal);
360
+ return appendNote(session, params, access);
377
361
  case "search":
378
- return searchNotes(dir, params, access);
362
+ return searchNotes(session, params, access);
379
363
  }
380
364
  },
381
365
  });