@howaboua/pi-notes-compaction 0.0.0-stage → 0.0.2

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 ADDED
@@ -0,0 +1,440 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ SessionEntry,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import type { NoteSnapshotData } from "./bridge.js";
7
+ import { type NoteRevision, NotesStore, type SessionRef } from "./store.js";
8
+
9
+ export const NOTE_RECEIPT = "notes-compaction:note:v1";
10
+ export const CACHE_RECEIPT = "notes-compaction:cache:v1";
11
+ export interface NoteReceipt {
12
+ protocol: 1;
13
+ sessionId: string;
14
+ windowId: string;
15
+ runId: string;
16
+ origin?: "routed";
17
+ /** Model-invisible recovery snapshot follows native fork and branch projections. */
18
+ revision: NoteRevision;
19
+ }
20
+
21
+ export function sessionRef(ctx: ExtensionContext): SessionRef {
22
+ return {
23
+ id: ctx.sessionManager.getSessionId(),
24
+ file: ctx.sessionManager.getSessionFile(),
25
+ };
26
+ }
27
+
28
+ export function sourceSessionIds(ctx: ExtensionContext): string[] {
29
+ const ids = new Set([ctx.sessionManager.getSessionId()]);
30
+ for (const entry of ctx.sessionManager.getBranch())
31
+ if (
32
+ entry.type === "custom" &&
33
+ entry.customType === CACHE_RECEIPT &&
34
+ entry.data &&
35
+ typeof entry.data === "object" &&
36
+ "protocol" in entry.data &&
37
+ entry.data.protocol === 1 &&
38
+ "sessionId" in entry.data &&
39
+ typeof entry.data.sessionId === "string"
40
+ )
41
+ ids.add(entry.data.sessionId);
42
+ return [...ids];
43
+ }
44
+
45
+ export function noteReceipt(data: unknown): NoteReceipt | undefined {
46
+ if (
47
+ !data ||
48
+ typeof data !== "object" ||
49
+ !("protocol" in data) ||
50
+ data.protocol !== 1 ||
51
+ !("revision" in data)
52
+ )
53
+ return;
54
+ const record = data as Partial<NoteReceipt>;
55
+ const revision = record.revision;
56
+ if (
57
+ typeof record.sessionId !== "string" ||
58
+ typeof record.windowId !== "string" ||
59
+ typeof record.runId !== "string" ||
60
+ (record.origin !== undefined && record.origin !== "routed") ||
61
+ !revision ||
62
+ typeof revision.id !== "string" ||
63
+ typeof revision.path !== "string" ||
64
+ typeof revision.text !== "string" ||
65
+ !["replace", "append"].includes(revision.mode) ||
66
+ !Number.isFinite(revision.createdAt) ||
67
+ !Number.isFinite(revision.updatedAt)
68
+ )
69
+ return;
70
+ return record as NoteReceipt;
71
+ }
72
+
73
+ function relativeLegacyPath(path: string): string {
74
+ // Legacy canonical paths use the first notes component as the agent-root boundary.
75
+ const components = path.split("/");
76
+ const boundary = components.indexOf("notes");
77
+ return components[0] === "" && components[1] === "root" && boundary > 1
78
+ ? components.slice(boundary + 1).join("/")
79
+ : path;
80
+ }
81
+
82
+ function snapshotNotes(data: unknown, id: string): NoteRevision[] | undefined {
83
+ if (
84
+ !data ||
85
+ typeof data !== "object" ||
86
+ !("protocol" in data) ||
87
+ data.protocol !== 1 ||
88
+ !("files" in data) ||
89
+ !Array.isArray(data.files)
90
+ )
91
+ return;
92
+ const result: NoteRevision[] = [];
93
+ for (const file of data.files) {
94
+ if (
95
+ !file ||
96
+ typeof file !== "object" ||
97
+ typeof file.path !== "string" ||
98
+ typeof file.text !== "string" ||
99
+ !Number.isFinite(file.createdAt) ||
100
+ !Number.isFinite(file.updatedAt)
101
+ )
102
+ return;
103
+ result.push({
104
+ id,
105
+ path: relativeLegacyPath(file.path),
106
+ text: file.text,
107
+ mode: file.operation === "local_append_overlay" ? "append" : "replace",
108
+ createdAt: file.createdAt,
109
+ updatedAt: file.updatedAt,
110
+ });
111
+ }
112
+ return result;
113
+ }
114
+
115
+ /** Only selected-branch receipts choose visible revisions. Sibling writes never leak. */
116
+ export function collectNotes(
117
+ store: Pick<NotesStore, "read">,
118
+ entries: readonly SessionEntry[],
119
+ ): Map<string, NoteRevision> {
120
+ const notes = new Map<string, NoteRevision>();
121
+ for (const entry of entries) {
122
+ if (
123
+ entry.type === "branch_summary" &&
124
+ entry.details &&
125
+ typeof entry.details === "object" &&
126
+ "codexContextNoteHandoff" in entry.details
127
+ ) {
128
+ for (const note of snapshotNotes(
129
+ entry.details.codexContextNoteHandoff,
130
+ entry.id,
131
+ ) ?? [])
132
+ notes.set(note.path, note);
133
+ continue;
134
+ }
135
+ if (entry.type !== "custom") continue;
136
+ if (entry.customType === NOTE_RECEIPT) {
137
+ const receipt = noteReceipt(entry.data);
138
+ if (!receipt)
139
+ throw new Error(
140
+ "A saved note receipt is invalid. Open an intact session branch to recover notes.",
141
+ );
142
+ // A fork carries the receipt even when its source session was explicitly pruned.
143
+ const revision = store.read(receipt.revision.id) ?? receipt.revision;
144
+ notes.set(revision.path, revision);
145
+ } else if (entry.customType === "codex-context-note") {
146
+ const data = entry.data as
147
+ | {
148
+ protocol?: unknown;
149
+ path?: unknown;
150
+ text?: unknown;
151
+ action?: unknown;
152
+ timestamp?: unknown;
153
+ }
154
+ | undefined;
155
+ if (
156
+ data?.protocol !== 1 ||
157
+ typeof data.path !== "string" ||
158
+ typeof data.text !== "string" ||
159
+ (data.action !== "write" && data.action !== "append")
160
+ )
161
+ continue;
162
+ const path = relativeLegacyPath(data.path);
163
+ const previous = notes.get(path);
164
+ const time = typeof data.timestamp === "number" ? data.timestamp : 0;
165
+ notes.set(path, {
166
+ id: entry.id,
167
+ path,
168
+ text:
169
+ data.action === "append"
170
+ ? (previous?.text ?? "") + data.text
171
+ : data.text,
172
+ mode: "replace",
173
+ createdAt: previous?.createdAt ?? time,
174
+ updatedAt: time,
175
+ });
176
+ } else if (entry.customType === "codex-context-note-snapshot") {
177
+ const snapshot = snapshotNotes(entry.data, entry.id);
178
+ if (!snapshot) continue;
179
+ notes.clear();
180
+ for (const note of snapshot) notes.set(note.path, note);
181
+ }
182
+ }
183
+ return notes;
184
+ }
185
+
186
+ /** Receipts carry recovery text, so detached reads need neither a database nor another process. */
187
+ export function exportNotesSnapshot(
188
+ entries: readonly SessionEntry[],
189
+ agentName: string,
190
+ ): NoteSnapshotData {
191
+ if (!/^\/root(?:\/[a-zA-Z0-9_-]+)*$/.test(agentName))
192
+ throw new Error("Invalid notes agent identity");
193
+ const notes = collectNotes({ read: () => undefined }, entries);
194
+ const snapshot: NoteSnapshotData = {
195
+ protocol: 1,
196
+ timestamp: Date.now(),
197
+ files: [...notes.values()]
198
+ .sort((a, b) => a.path.localeCompare(b.path))
199
+ .map((note) => ({
200
+ path: `${agentName}/notes/${canonicalPath(note.path, agentName)}`,
201
+ text: note.text,
202
+ createdAt: note.createdAt,
203
+ updatedAt: note.updatedAt,
204
+ operation:
205
+ note.mode === "append" ? "local_append_overlay" : "local_replacement",
206
+ })),
207
+ };
208
+ if (
209
+ snapshot.files.some((file) => Buffer.byteLength(file.text) > 1_000_000) ||
210
+ Buffer.byteLength(JSON.stringify(snapshot)) > 10_000_000
211
+ )
212
+ throw new Error(
213
+ "Notes snapshot exceeds the retained checkpoint size limit",
214
+ );
215
+ return snapshot;
216
+ }
217
+
218
+ function canonicalPath(path: unknown, agent: string, required = true): string {
219
+ if (typeof path !== "string" || !path.trim()) {
220
+ if (required) throw new Error("Supply a note path");
221
+ return "";
222
+ }
223
+ const root = `${agent}/notes`;
224
+ let value = path;
225
+ if (value === root) value = "";
226
+ else if (value.startsWith(`${root}/`)) value = value.slice(root.length + 1);
227
+ else if (value.startsWith("/"))
228
+ throw new Error(
229
+ "That agent's notes are not available here. Use a relative path for this agent.",
230
+ );
231
+ if (
232
+ (required && !value) ||
233
+ value.split("/").some((part) => part === ".." || part === ".") ||
234
+ /[\0\r\n]/.test(value)
235
+ )
236
+ throw new Error(
237
+ "Use a virtual note path without dot segments or control characters",
238
+ );
239
+ return value;
240
+ }
241
+
242
+ export function normalizeNoteParams(
243
+ params: Record<string, unknown>,
244
+ agent: string,
245
+ ): Record<string, unknown> {
246
+ const result = { ...params };
247
+ const needsPath = ["read_file", "write_file", "append_to_file"].includes(
248
+ String(params["action"]),
249
+ );
250
+ for (const field of ["path", "prefix", "path_prefix"])
251
+ if (field in params || field !== "path" || needsPath)
252
+ result[field] = canonicalPath(params[field], agent, field === "path");
253
+ return result;
254
+ }
255
+
256
+ export function noteHints(
257
+ store: NotesStore,
258
+ ctx: ExtensionContext,
259
+ agent: string,
260
+ ): string {
261
+ const notes = [
262
+ ...collectNotes(store, ctx.sessionManager.getBranch()).values(),
263
+ ]
264
+ .sort((a, b) => b.updatedAt - a.updatedAt)
265
+ .map((note) => `${agent}/notes/${note.path}`);
266
+ const paths = [
267
+ ...new Set([...notes, ...store.cachedPaths(sourceSessionIds(ctx))]),
268
+ ].slice(0, 5);
269
+ return paths.length ? `Recent notes: ${paths.join(", ")}`.slice(0, 4000) : "";
270
+ }
271
+
272
+ export function useNotes(
273
+ pi: ExtensionAPI,
274
+ store: NotesStore,
275
+ params: Record<string, unknown>,
276
+ ctx: ExtensionContext,
277
+ agent: string,
278
+ windowId: string,
279
+ runId: string,
280
+ routed = false,
281
+ ): Record<string, unknown> {
282
+ const notes = collectNotes(store, ctx.sessionManager.getBranch());
283
+ const action = params["action"];
284
+ const root = `${agent}/notes/`;
285
+ if (action === "write_file" || action === "append_to_file") {
286
+ const path = params["path"] as string;
287
+ const text = params["text"];
288
+ if (typeof text !== "string") throw new Error("Supply note text");
289
+ const previous = notes.get(path);
290
+ const next =
291
+ action === "append_to_file" ? (previous?.text ?? "") + text : text;
292
+ if (Buffer.byteLength(next) > 1_000_000)
293
+ throw new Error("Note exceeds 1 MB. Split it into separate paths.");
294
+ const now = Date.now();
295
+ // Append never pretends to decode a remote base. The remote fragment remains separate.
296
+ const mode =
297
+ action === "write_file" ? "replace" : (previous?.mode ?? "append");
298
+ let revision: NoteRevision;
299
+ try {
300
+ revision = store.write(sessionRef(ctx), {
301
+ path,
302
+ text: next,
303
+ mode,
304
+ createdAt: previous?.createdAt ?? now,
305
+ updatedAt: now,
306
+ });
307
+ } catch {
308
+ throw new Error(
309
+ "Local note could not be saved. The selected branch remains unchanged. Retry after local storage is available.",
310
+ );
311
+ }
312
+ try {
313
+ pi.appendEntry(NOTE_RECEIPT, {
314
+ protocol: 1,
315
+ sessionId: ctx.sessionManager.getSessionId(),
316
+ windowId,
317
+ runId,
318
+ ...(routed ? { origin: "routed" as const } : {}),
319
+ revision,
320
+ } satisfies NoteReceipt);
321
+ } catch {
322
+ throw new Error(
323
+ `Note ${root}${path} reached local storage but its session receipt failed. Reread the selected branch before retrying.`,
324
+ );
325
+ }
326
+ return {
327
+ path: `${root}${path}`,
328
+ chars: next.length,
329
+ operation:
330
+ mode === "append" ? "local_append_overlay" : "local_replacement",
331
+ };
332
+ }
333
+ if (action === "read_file") {
334
+ const path = params["path"] as string;
335
+ const note = notes.get(path);
336
+ if (!note) return { path: `${root}${path}`, file: null };
337
+ const lines = note.text.split("\n");
338
+ const start = integer(params["start_line"], 1, lines.length + 1);
339
+ const stop = integer(params["stop_line"], lines.length, lines.length);
340
+ let text = "";
341
+ let next = start;
342
+ while (next <= stop) {
343
+ const line = lines[next - 1] ?? "";
344
+ if (text.length + line.length > 32_000) {
345
+ if (!text)
346
+ throw new Error(
347
+ "This note line exceeds 32000 characters. Rewrite it as shorter lines to read it.",
348
+ );
349
+ break;
350
+ }
351
+ text += `${next > start ? "\n" : ""}${line}`;
352
+ next++;
353
+ }
354
+ return {
355
+ path: `${root}${path}`,
356
+ text,
357
+ start_line: start,
358
+ total_lines: lines.length,
359
+ ...(next <= stop ? { next_start_line: next } : {}),
360
+ operation:
361
+ note.mode === "replace" ? "local_replacement" : "local_append_overlay",
362
+ };
363
+ }
364
+ if (action === "list_files_by_prefix") {
365
+ let files = [...notes.values()].filter((note) =>
366
+ note.path.startsWith(String(params["prefix"] ?? "")),
367
+ );
368
+ const by = params["file_order_by"];
369
+ files.sort((a, b) =>
370
+ by === "created_at"
371
+ ? a.createdAt - b.createdAt
372
+ : by === "updated_at"
373
+ ? a.updatedAt - b.updatedAt
374
+ : a.path.localeCompare(b.path),
375
+ );
376
+ if (params["file_order"] === "descending") files.reverse();
377
+ const maximum = integer(params["max_results"], 100, 100);
378
+ return {
379
+ files: files.slice(0, maximum).map((note) => ({
380
+ path: `${root}${note.path}`,
381
+ chars: note.text.length,
382
+ updated_at: note.updatedAt,
383
+ })),
384
+ truncated: files.length > maximum,
385
+ };
386
+ }
387
+ if (
388
+ action !== "search_contents" ||
389
+ typeof params["query"] !== "string" ||
390
+ !params["query"]
391
+ )
392
+ throw new Error("Supply a nonempty search query");
393
+ const files = [...notes.values()].filter((note) =>
394
+ note.path.startsWith(String(params["path_prefix"] ?? "")),
395
+ );
396
+ if (params["recent_file_first"] === true)
397
+ files.sort((a, b) => b.updatedAt - a.updatedAt);
398
+ const found: {
399
+ path: string;
400
+ matches: { line: number; text: string }[];
401
+ truncated: boolean;
402
+ }[] = [];
403
+ let chars = 0;
404
+ let truncated = false;
405
+ for (const note of files) {
406
+ const all = note.text
407
+ .split("\n")
408
+ .flatMap((line, i) =>
409
+ line.includes(params["query"] as string)
410
+ ? [{ line: i + 1, text: line.slice(0, 1000) }]
411
+ : [],
412
+ );
413
+ if (!all.length) continue;
414
+ const matches = all.slice(
415
+ 0,
416
+ integer(params["max_matches_per_file"], 10, 100),
417
+ );
418
+ const size = JSON.stringify(matches).length;
419
+ if (
420
+ found.length >= integer(params["max_files"], 20, 100) ||
421
+ chars + size > 32_000
422
+ ) {
423
+ truncated = true;
424
+ break;
425
+ }
426
+ found.push({
427
+ path: `${root}${note.path}`,
428
+ matches,
429
+ truncated: matches.length < all.length,
430
+ });
431
+ chars += size;
432
+ }
433
+ return { files: found, truncated };
434
+ }
435
+
436
+ function integer(value: unknown, fallback: number, max: number): number {
437
+ return typeof value === "number" && Number.isInteger(value)
438
+ ? Math.max(1, Math.min(value, max))
439
+ : fallback;
440
+ }
@@ -0,0 +1,115 @@
1
+ import {
2
+ type ExtensionContext,
3
+ getSettingsListTheme,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import { SettingsList, truncateToWidth } from "@earendil-works/pi-tui";
6
+ import { IDLE_MINUTES, type NotesSettings } from "./settings.js";
7
+
8
+ export async function openNotesSettings(
9
+ ctx: ExtensionContext,
10
+ options: {
11
+ settings: () => NotesSettings;
12
+ save: (settings: NotesSettings) => void;
13
+ status: () => string[];
14
+ prune: () => Promise<void>;
15
+ },
16
+ ): Promise<void> {
17
+ while (true) {
18
+ const status = options.status();
19
+ const action = await ctx.ui.custom<"close" | "prune">(
20
+ (tui, theme, _kb, done) => {
21
+ const settings = options.settings();
22
+ const idleValue = (minutes: number) =>
23
+ minutes === 0 ? "off" : `${minutes} min`;
24
+ const list = new SettingsList(
25
+ [
26
+ {
27
+ id: "compact",
28
+ label: "Normal compaction",
29
+ description:
30
+ "Include the installed Pi/PCC compaction flow on new_context. Off keeps notes-only rollover.",
31
+ currentValue: settings.normalCompaction ? "on" : "off",
32
+ values: ["off", "on"],
33
+ },
34
+ {
35
+ id: "idle",
36
+ label: "Idle rollover",
37
+ description:
38
+ "Roll over before the next input only with fresh notes from a completed turn. Not a cache-expiry guarantee.",
39
+ currentValue: idleValue(settings.idleMinutes),
40
+ values: IDLE_MINUTES.map(idleValue),
41
+ },
42
+ {
43
+ id: "prune",
44
+ label: "Prune missing sessions",
45
+ description:
46
+ "Remove stored records only for missing session files. Keeps active sessions, unknown paths and inaccessible files. Confirmation required.",
47
+ currentValue: "select…",
48
+ values: ["select…", "prune…"],
49
+ },
50
+ ],
51
+ 5,
52
+ getSettingsListTheme(),
53
+ (id, value) => {
54
+ if (id === "prune") {
55
+ done("prune");
56
+ return;
57
+ }
58
+ const current = options.settings();
59
+ try {
60
+ if (id === "compact")
61
+ options.save({ ...current, normalCompaction: value === "on" });
62
+ else {
63
+ const idleMinutes = IDLE_MINUTES.find(
64
+ (minutes) => idleValue(minutes) === value,
65
+ );
66
+ if (idleMinutes !== undefined)
67
+ options.save({ ...current, idleMinutes });
68
+ }
69
+ } catch (error) {
70
+ list.updateValue(
71
+ id,
72
+ id === "compact"
73
+ ? current.normalCompaction
74
+ ? "on"
75
+ : "off"
76
+ : idleValue(current.idleMinutes),
77
+ );
78
+ ctx.ui.notify(
79
+ `Could not save notes settings: ${String(error)}`,
80
+ "error",
81
+ );
82
+ }
83
+ tui.requestRender();
84
+ },
85
+ () => done("close"),
86
+ { enableSearch: false },
87
+ );
88
+ return {
89
+ render: (width) =>
90
+ [
91
+ theme.fg("accent", "Notes & context"),
92
+ ...status.map((line) => theme.fg("dim", line)),
93
+ "",
94
+ ...list.render(width),
95
+ "",
96
+ theme.fg(
97
+ "dim",
98
+ "↑↓ navigate · Enter/Space change · Esc close · saved immediately",
99
+ ),
100
+ ].map((line) => truncateToWidth(line, width)),
101
+ invalidate: () => list.invalidate(),
102
+ handleInput: (data) => list.handleInput(data),
103
+ };
104
+ },
105
+ );
106
+ if (action === "close") return;
107
+ if (
108
+ await ctx.ui.confirm(
109
+ "Prune missing sessions?",
110
+ "Delete local notes and cached responses for missing session files? The active session, unknown paths and inaccessible files are retained.",
111
+ )
112
+ )
113
+ await options.prune();
114
+ }
115
+ }
@@ -0,0 +1,48 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+
4
+ export const IDLE_MINUTES = [0, 5, 15, 25, 55] as const;
5
+ export type IdleMinutes = (typeof IDLE_MINUTES)[number];
6
+ export interface NotesSettings {
7
+ normalCompaction: boolean;
8
+ idleMinutes: IdleMinutes;
9
+ }
10
+
11
+ export function readSettings(path: string): NotesSettings {
12
+ let source: string;
13
+ try {
14
+ source = readFileSync(path, "utf8");
15
+ } catch (error) {
16
+ if ((error as NodeJS.ErrnoException).code === "ENOENT")
17
+ return { normalCompaction: false, idleMinutes: 0 };
18
+ throw error;
19
+ }
20
+ const settings: unknown = JSON.parse(source);
21
+ if (
22
+ !settings ||
23
+ typeof settings !== "object" ||
24
+ !("normalCompaction" in settings) ||
25
+ typeof settings.normalCompaction !== "boolean"
26
+ )
27
+ throw new Error(
28
+ "Invalid notes settings. Save a setting in /notes, then reload Pi.",
29
+ );
30
+ const idleMinutes = "idleMinutes" in settings ? settings.idleMinutes : 0;
31
+ if (!IDLE_MINUTES.some((value) => value === idleMinutes))
32
+ throw new Error(
33
+ "Invalid idle rollover interval. Choose off, 5, 15, 25 or 55 minutes.",
34
+ );
35
+ return {
36
+ normalCompaction: settings.normalCompaction,
37
+ idleMinutes: idleMinutes as IdleMinutes,
38
+ };
39
+ }
40
+
41
+ export function writeSettings(path: string, settings: NotesSettings): void {
42
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
43
+ const temporary = `${path}.${process.pid}.tmp`;
44
+ writeFileSync(temporary, `${JSON.stringify(settings, null, 2)}\n`, {
45
+ mode: 0o600,
46
+ });
47
+ renameSync(temporary, path);
48
+ }