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/README.md +7 -2
- package/index.ts +13 -3
- package/package.json +24 -21
- package/src/context-tools.ts +2 -2
- package/src/contracts.ts +39 -32
- package/src/files.ts +0 -5
- package/src/guidance.ts +41 -0
- package/src/history-entries.ts +16 -16
- package/src/history-pages.ts +1 -1
- package/src/history-search.ts +6 -6
- package/src/history.ts +5 -2
- package/src/note-discovery.ts +291 -0
- package/src/note-evidence.ts +179 -0
- package/src/note-guards.ts +203 -0
- package/src/note-inventory.ts +111 -0
- package/src/note-provenance.ts +299 -0
- package/src/note-scopes.ts +293 -0
- package/src/note-snapshots.ts +185 -0
- package/src/note-store.ts +278 -0
- package/src/note-tool-effects.ts +190 -0
- package/src/notes.ts +118 -137
- package/src/paging.ts +2 -2
- package/src/recovery-note-touches.ts +389 -0
- package/src/recovery-notes.ts +54 -503
- package/src/recovery-records.ts +90 -0
- package/src/recovery-results.ts +7 -6
- package/src/recovery.ts +24 -108
- package/src/reminders.ts +3 -3
- package/src/retention.ts +23 -17
- package/src/rollover.ts +66 -88
- package/src/session-files.ts +29 -20
- package/src/session-lines.ts +1 -2
- package/src/ui/cards.ts +3 -2
- package/src/ui/display.ts +2 -2
- package/src/ui/text.ts +0 -1
package/src/notes.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
|
147
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
140
|
+
session: SessionNotes,
|
|
217
141
|
params: NotesParams,
|
|
218
|
-
|
|
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 =
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
169
|
+
session: SessionNotes,
|
|
232
170
|
params: NotesParams,
|
|
233
|
-
|
|
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 =
|
|
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
|
-
|
|
216
|
+
session: SessionNotes,
|
|
265
217
|
query: string,
|
|
266
|
-
limit: number,
|
|
267
|
-
|
|
218
|
+
options: { readonly limit: number; readonly scope: string | undefined },
|
|
219
|
+
access: NotesAccess,
|
|
268
220
|
): Promise<NoteHits> {
|
|
269
|
-
const
|
|
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 (
|
|
282
|
-
size > SEARCH_FILE_BYTES
|
|
234
|
+
.map(async (note) =>
|
|
235
|
+
note.size > SEARCH_FILE_BYTES
|
|
283
236
|
? undefined
|
|
284
237
|
: matchingLines(
|
|
285
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
338
|
-
promptSnippet: "save and recall durable state
|
|
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
|
-
|
|
370
|
-
|
|
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(
|
|
352
|
+
return listNotes(session, params, access);
|
|
374
353
|
case "read":
|
|
375
|
-
return
|
|
354
|
+
return params.path === "."
|
|
355
|
+
? listNotes(session, params, access)
|
|
356
|
+
: readNote(session, params, access);
|
|
376
357
|
case "write":
|
|
377
|
-
return writeNote(
|
|
358
|
+
return writeNote(session, params, access);
|
|
378
359
|
case "append":
|
|
379
|
-
return appendNote(
|
|
360
|
+
return appendNote(session, params, access);
|
|
380
361
|
case "search":
|
|
381
|
-
return searchNotes(
|
|
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[];
|