@astrosheep/pi-context 0.19.0 → 0.20.0

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.
Files changed (42) hide show
  1. package/dist/src/budget.js +63 -0
  2. package/dist/src/dream/apply.js +87 -0
  3. package/dist/src/dream/cli.js +82 -0
  4. package/dist/src/dream/gates.js +21 -0
  5. package/dist/src/dream/lock.js +58 -0
  6. package/dist/src/dream/manifest.js +16 -0
  7. package/dist/src/dream/runner.js +56 -0
  8. package/dist/src/history-tools.js +105 -0
  9. package/dist/src/history.js +210 -0
  10. package/dist/src/index.js +99 -0
  11. package/dist/src/memory/frontmatter.js +134 -0
  12. package/dist/src/memory/paths.js +54 -0
  13. package/dist/src/memory/store.js +297 -0
  14. package/dist/src/memory/tools.js +175 -0
  15. package/dist/src/notes.js +101 -0
  16. package/dist/src/prompts.js +79 -0
  17. package/dist/src/protocol.js +52 -0
  18. package/dist/src/reset-lifecycle.js +101 -0
  19. package/dist/src/session-reader.js +1 -0
  20. package/dist/src/thresholds.js +72 -0
  21. package/dist/src/tool-output.js +172 -0
  22. package/dist/src/tool-schema.js +26 -0
  23. package/dist/src/warning.js +44 -0
  24. package/dist/test/agent-loop.test.js +212 -0
  25. package/dist/test/coherence.test.js +371 -0
  26. package/dist/test/dream.test.js +43 -0
  27. package/dist/test/history.test.js +21 -0
  28. package/dist/test/integration.test.js +1716 -0
  29. package/dist/test/memory.test.js +370 -0
  30. package/dist/test/pagination.property.test.js +476 -0
  31. package/dist/test/reset-lifecycle.test.js +199 -0
  32. package/package.json +9 -3
  33. package/playbook.md +5 -0
  34. package/src/dream/apply.ts +47 -0
  35. package/src/dream/cli.ts +33 -0
  36. package/src/dream/gates.ts +19 -0
  37. package/src/dream/lock.ts +39 -0
  38. package/src/dream/manifest.ts +21 -0
  39. package/src/dream/runner.ts +53 -0
  40. package/src/memory/store.ts +15 -0
  41. package/src/memory/tools.ts +12 -3
  42. package/src/protocol.ts +2 -2
@@ -0,0 +1,297 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ import { generateDiffString } from "@earendil-works/pi-coding-agent";
5
+ import { assertGlobPattern, assertVirtualPath, globToRegExp } from "../notes.js";
6
+ import { MAX_NOTE_BYTES, MAX_NOTE_PATH_BYTES } from "../protocol.js";
7
+ import { isOrigin, isScope, parseNote, serializeNote, stripLeadingFrontmatter } from "./frontmatter.js";
8
+ import { physicalPath, scopeDir } from "./paths.js";
9
+ /** Typed store refusal. `line_numbers` and `edit_index` are the edit error's addressing fields. */
10
+ export class NoteError extends Error {
11
+ code;
12
+ line_numbers;
13
+ edit_index;
14
+ constructor(code, message, extra = {}) {
15
+ super(message);
16
+ this.name = "NoteError";
17
+ this.code = code;
18
+ this.line_numbers = extra.line_numbers;
19
+ this.edit_index = extra.edit_index;
20
+ }
21
+ }
22
+ const SCOPE_ORDER = ["session", "project", "global"];
23
+ function assertScope(value) {
24
+ if (!isScope(value))
25
+ throw new NoteError("invalid_scope", `scope must be one of session, project, global (got ${JSON.stringify(value)})`);
26
+ return value;
27
+ }
28
+ function assertOrigin(value) {
29
+ if (!isOrigin(value))
30
+ throw new NoteError("invalid_origin", `origin must be one of user, self, external (got ${JSON.stringify(value)})`);
31
+ return value;
32
+ }
33
+ function scopeList(scope) {
34
+ if (scope === undefined || scope === null)
35
+ return [...SCOPE_ORDER];
36
+ return [assertScope(scope)];
37
+ }
38
+ /** First existing file by precedence session → project → global, or only `scope` when given. */
39
+ function resolve(ctx, vpath, scope) {
40
+ for (const candidate of scopeList(scope)) {
41
+ const path = physicalPath(candidate, vpath, ctx);
42
+ if (existsSync(path))
43
+ return { scope: candidate, path, raw: readFileSync(path, "utf8") };
44
+ }
45
+ return undefined;
46
+ }
47
+ /** Recursively list `.md` files under `dir` as forward-slash virtual paths relative to `base`. */
48
+ function walkMarkdown(dir, base = dir) {
49
+ let entries;
50
+ try {
51
+ entries = readdirSync(dir, { withFileTypes: true });
52
+ }
53
+ catch {
54
+ return [];
55
+ }
56
+ const paths = [];
57
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
58
+ const child = `${dir}/${entry.name}`;
59
+ if (entry.isDirectory())
60
+ paths.push(...walkMarkdown(child, base));
61
+ else if (entry.isFile() && entry.name.endsWith(".md"))
62
+ paths.push(child.slice(base.length + 1).split("\\").join("/"));
63
+ }
64
+ return paths;
65
+ }
66
+ function matcherFor(pattern) {
67
+ const normalized = assertGlobPattern(pattern);
68
+ return normalized === undefined ? undefined : globToRegExp(normalized);
69
+ }
70
+ /**
71
+ * Every mutation lands through a tmp file renamed into place in the same directory, so a crash
72
+ * never leaves a torn note. No cross-process locking: out of scope by decision.
73
+ */
74
+ function atomicWrite(path, content) {
75
+ mkdirSync(dirname(path), { recursive: true });
76
+ const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
77
+ try {
78
+ writeFileSync(tmp, content);
79
+ renameSync(tmp, path);
80
+ }
81
+ catch (error) {
82
+ rmSync(tmp, { force: true });
83
+ throw error;
84
+ }
85
+ }
86
+ /** Write-time vpath guard: the byte cap is a tool-boundary rule, never a jail rule. */
87
+ function assertWritablePath(vpath) {
88
+ const bytes = Buffer.byteLength(vpath, "utf8");
89
+ if (bytes > MAX_NOTE_PATH_BYTES)
90
+ throw new NoteError("too_large", `note path exceeds ${MAX_NOTE_PATH_BYTES} UTF-8 bytes (got ${bytes})`);
91
+ }
92
+ /** Serialized-size guard applied after the frontmatter is merged, before any bytes are written. */
93
+ function assertSerializedSize(content) {
94
+ const bytes = Buffer.byteLength(content, "utf8");
95
+ if (bytes > MAX_NOTE_BYTES)
96
+ throw new NoteError("too_large", `note exceeds ${MAX_NOTE_BYTES} UTF-8 bytes (serialized ${bytes})`);
97
+ }
98
+ /** Frontmatter block only (the body separator stripped), for the metadata-only diff. */
99
+ function frontmatterOf(meta) {
100
+ return serializeNote(meta, "").slice(0, -2);
101
+ }
102
+ /** Line numbers (1-based) of every occurrence of `needle` in `body`. */
103
+ function matchLineNumbers(body, needle) {
104
+ const lines = [];
105
+ let cursor = 0;
106
+ for (;;) {
107
+ const index = body.indexOf(needle, cursor);
108
+ if (index === -1)
109
+ break;
110
+ lines.push(body.slice(0, index).split("\n").length);
111
+ cursor = index + Math.max(needle.length, 1);
112
+ }
113
+ return lines;
114
+ }
115
+ /** Create or overwrite a note; overwrite keeps created_at and every unknown key. */
116
+ export function writeNote(ctx, vpath, body, opts) {
117
+ assertVirtualPath(vpath);
118
+ assertWritablePath(vpath);
119
+ const scope = assertScope(opts.scope);
120
+ const origin = assertOrigin(opts.origin);
121
+ const path = physicalPath(scope, vpath, ctx);
122
+ const now = Date.now();
123
+ const cleanBody = stripLeadingFrontmatter(body);
124
+ const existing = existsSync(path) ? parseNote(readFileSync(path, "utf8"), now).meta : undefined;
125
+ const meta = existing ?? {
126
+ scope,
127
+ origin,
128
+ status: "active",
129
+ stale: false,
130
+ created_at: now,
131
+ updated_at: now,
132
+ last_accessed: now,
133
+ access_count: 0,
134
+ };
135
+ meta.scope = scope;
136
+ meta.origin = origin;
137
+ meta.status = "active";
138
+ meta.stale = opts.stale ?? false;
139
+ meta.updated_at = now;
140
+ const serialized = serializeNote(meta, cleanBody);
141
+ assertSerializedSize(serialized);
142
+ atomicWrite(path, serialized);
143
+ return { meta };
144
+ }
145
+ /** Dream harness mutation: metadata changes still use the store's atomic writer. */
146
+ export function updateNoteMeta(ctx, vpath, scope, mutate) {
147
+ assertVirtualPath(vpath);
148
+ const path = physicalPath(scope, vpath, ctx);
149
+ if (!existsSync(path))
150
+ throw new NoteError("not_found", `note not found: ${vpath}`);
151
+ const parsed = parseNote(readFileSync(path, "utf8"));
152
+ const meta = { ...parsed.meta, scope };
153
+ mutate(meta);
154
+ meta.updated_at = Date.now();
155
+ const serialized = serializeNote(meta, parsed.body);
156
+ assertSerializedSize(serialized);
157
+ atomicWrite(path, serialized);
158
+ return { meta, body: parsed.body };
159
+ }
160
+ /** Apply body-only edits against one snapshot, then optionally move via the scope/origin/stale setters. */
161
+ export function editNote(ctx, vpath, edits, opts = {}) {
162
+ assertVirtualPath(vpath);
163
+ assertWritablePath(vpath);
164
+ const operations = edits ?? [];
165
+ if (operations.length === 0 && opts.scope === undefined && opts.origin === undefined && opts.stale === undefined) {
166
+ throw new NoteError("nothing_to_do", "nothing to do: provide edits or at least one of scope, origin, stale");
167
+ }
168
+ const found = resolve(ctx, vpath);
169
+ if (!found)
170
+ throw new NoteError("not_found", "note not found");
171
+ const { meta, body } = parseNote(found.raw);
172
+ // Snapshot the pre-edit frontmatter so the diff can name exactly what the setters changed.
173
+ const beforeMeta = { ...meta };
174
+ // Every edit runs against this one snapshot; nothing is written until all of them succeed,
175
+ // so a failing edit leaves the file byte-identical (frontmatter included).
176
+ let next = body;
177
+ operations.forEach((edit, index) => {
178
+ const oldText = edit?.oldText;
179
+ const newText = edit?.newText;
180
+ if (typeof oldText !== "string" || oldText.length === 0)
181
+ throw new NoteError("no_match", `edit ${index}: oldText must be a non-empty string`, { edit_index: index });
182
+ if (typeof newText !== "string")
183
+ throw new NoteError("no_match", `edit ${index}: newText must be a string`, { edit_index: index });
184
+ const lines = matchLineNumbers(next, oldText);
185
+ if (lines.length === 0)
186
+ throw new NoteError("no_match", `edit ${index}: oldText does not occur in the note body`, { edit_index: index });
187
+ if (lines.length > 1 && !opts.replaceAll) {
188
+ throw new NoteError("ambiguous_edit", `edit ${index}: oldText occurs ${lines.length} times (lines ${lines.join(", ")}); pass replace_all to replace every occurrence`, { line_numbers: lines, edit_index: index });
189
+ }
190
+ next = opts.replaceAll ? next.split(oldText).join(newText) : next.replace(oldText, newText);
191
+ });
192
+ const destScope = opts.scope === undefined ? found.scope : assertScope(opts.scope);
193
+ if (opts.origin !== undefined)
194
+ meta.origin = assertOrigin(opts.origin);
195
+ if (opts.stale !== undefined)
196
+ meta.stale = opts.stale;
197
+ meta.scope = destScope;
198
+ meta.updated_at = Date.now();
199
+ const dest = physicalPath(destScope, vpath, ctx);
200
+ const moving = dest !== found.path;
201
+ if (moving && existsSync(dest)) {
202
+ throw new NoteError("target_exists", `a note already exists at ${vpath} in scope ${destScope}; the move was refused and both files are unchanged`);
203
+ }
204
+ const serialized = serializeNote(meta, next);
205
+ assertSerializedSize(serialized);
206
+ // pi-edit-style diff: body only for a content edit, frontmatter only for a metadata-only
207
+ // update, one combined file diff when both moved.
208
+ const bodyChanged = body !== next;
209
+ const metadataChanged = beforeMeta.scope !== meta.scope || beforeMeta.origin !== meta.origin || beforeMeta.stale !== meta.stale;
210
+ const diff = bodyChanged && metadataChanged
211
+ ? generateDiffString(found.raw, serialized).diff
212
+ : bodyChanged
213
+ ? generateDiffString(body, next).diff
214
+ : metadataChanged
215
+ ? generateDiffString(frontmatterOf(beforeMeta), frontmatterOf(meta)).diff
216
+ : "";
217
+ atomicWrite(dest, serialized);
218
+ if (moving)
219
+ rmSync(found.path);
220
+ return { meta, applied: operations.length, resolved_scope: found.scope, diff };
221
+ }
222
+ /** Read a note and, as a side effect, bump last_accessed/access_count in the file. */
223
+ export function readNote(ctx, vpath, opts = {}) {
224
+ assertVirtualPath(vpath);
225
+ const found = resolve(ctx, vpath, opts.scope);
226
+ if (!found)
227
+ return undefined;
228
+ const now = Date.now();
229
+ const { meta, body } = parseNote(found.raw, now);
230
+ meta.scope = found.scope;
231
+ // Only the two access keys move; updated_at and every other key keep their bytes.
232
+ meta.last_accessed = now;
233
+ meta.access_count = (typeof meta.access_count === "number" ? meta.access_count : 0) + 1;
234
+ atomicWrite(found.path, serializeNote(meta, body));
235
+ return { meta, body, resolvedScope: found.scope };
236
+ }
237
+ /** The scope that holds `vpath` first by precedence, without reading or mutating the file. */
238
+ export function resolveNoteScope(ctx, vpath, scope) {
239
+ const found = resolve(ctx, vpath, scope);
240
+ return found ? { scope: found.scope, path: found.path } : undefined;
241
+ }
242
+ /** Read a note's meta and body without the read side effect (used by the boot index). */
243
+ export function peekNote(ctx, scope, vpath) {
244
+ const path = physicalPath(scope, vpath, ctx);
245
+ const { meta, body } = parseNote(readFileSync(path, "utf8"));
246
+ meta.scope = scope;
247
+ return { meta, body };
248
+ }
249
+ /** Merged rows across scopes, most recently updated first (path then scope break ties). */
250
+ export function listNotes(ctx, opts = {}) {
251
+ const matcher = matcherFor(opts.pattern);
252
+ const rows = [];
253
+ for (const scope of scopeList(opts.scope)) {
254
+ const root = scopeDir(scope, ctx);
255
+ for (const path of walkMarkdown(root)) {
256
+ if (matcher && !matcher.test(path))
257
+ continue;
258
+ const { meta, body } = parseNote(readFileSync(`${root}/${path}`, "utf8"));
259
+ meta.scope = scope;
260
+ rows.push({ path, meta, sizeBytes: Buffer.byteLength(body, "utf8") });
261
+ }
262
+ }
263
+ rows.sort((a, b) => b.meta.updated_at - a.meta.updated_at || a.path.localeCompare(b.path) || a.meta.scope.localeCompare(b.meta.scope));
264
+ return rows;
265
+ }
266
+ /** Case-sensitive literal substring search over note bodies, with a match address per line. */
267
+ export function searchNotes(ctx, queries, opts = {}) {
268
+ const matcher = matcherFor(opts.pattern);
269
+ const rows = [];
270
+ for (const scope of scopeList(opts.scope)) {
271
+ const root = scopeDir(scope, ctx);
272
+ for (const path of walkMarkdown(root)) {
273
+ if (matcher && !matcher.test(path))
274
+ continue;
275
+ const { meta, body } = parseNote(readFileSync(`${root}/${path}`, "utf8"));
276
+ meta.scope = scope;
277
+ let baseChars = 0;
278
+ const matches = [];
279
+ for (const [index, line] of body.split("\n").entries()) {
280
+ if (queries.some((query) => line.includes(query))) {
281
+ let earliest = -1;
282
+ for (const query of queries) {
283
+ const found = line.indexOf(query);
284
+ if (found >= 0 && (earliest < 0 || found < earliest))
285
+ earliest = found;
286
+ }
287
+ matches.push({ line: index + 1, text: line, offsetChars: baseChars + (earliest <= 0 ? 0 : Array.from(line.slice(0, earliest)).length) });
288
+ }
289
+ baseChars += Array.from(line).length + 1;
290
+ }
291
+ if (matches.length > 0)
292
+ rows.push({ path, scope, meta, matches });
293
+ }
294
+ }
295
+ rows.sort((a, b) => a.path.localeCompare(b.path) || a.scope.localeCompare(b.scope));
296
+ return rows;
297
+ }
@@ -0,0 +1,175 @@
1
+ import { Type } from "@earendil-works/pi-ai";
2
+ import { defineTool } from "@earendil-works/pi-coding-agent";
3
+ import { localIso } from "../notes.js";
4
+ import { characterWindowHeader, middleTruncate, output, outputRaw, page, prefixFit, readCharacterWindow, withinTextBudget } from "../tool-output.js";
5
+ import { cursor, nullableString, positiveInteger, searchQueries, searchQuery } from "../tool-schema.js";
6
+ import { serializeNote, stripLeadingFrontmatter } from "./frontmatter.js";
7
+ import { NoteError, editNote, listNotes, readNote, searchNotes, writeNote } from "./store.js";
8
+ const SCOPE = Type.Optional(Type.Union([Type.Literal("session"), Type.Literal("project"), Type.Literal("global")], {
9
+ description: "The note's reach — which root it lives under. session: only this session needs it (checkpoints, scratch state, worker rosters); dies with the session. project: tied to the current working directory — design decisions and repo facts that future sessions here still need. global: follows you everywhere — user laws, preferences, cross-project maps. On write, picks the destination root (default: session). Omit on read/list/search to cover all three; a read resolves session → project → global and returns the first existing file.",
10
+ }));
11
+ const ORIGIN = Type.Optional(Type.Union([Type.Literal("user"), Type.Literal("self"), Type.Literal("external")], {
12
+ description: "Where the note's content came from. user: written or dictated by the human. self: written by you, the agent (default). external: anything else — third-party text, tool output, fetched material.",
13
+ }));
14
+ /** Render epoch-ms metadata as the same local ISO timestamps the frontmatter carries. */
15
+ function wireMeta(meta) {
16
+ return { ...meta, created_at: localIso(meta.created_at), updated_at: localIso(meta.updated_at), last_accessed: localIso(meta.last_accessed) };
17
+ }
18
+ /** Turn a typed store refusal into the pinned error arm; unknown errors stay thrown. */
19
+ function failure(error) {
20
+ if (error instanceof NoteError) {
21
+ const payload = { error: error.message };
22
+ if (error.line_numbers)
23
+ payload.line_numbers = error.line_numbers;
24
+ if (error.edit_index !== undefined)
25
+ payload.edit_index = error.edit_index;
26
+ return output(payload);
27
+ }
28
+ throw error;
29
+ }
30
+ export function registerMemoryTools(pi) {
31
+ pi.registerTool(defineTool({
32
+ name: "notes_write",
33
+ label: "Notes write",
34
+ description: "Create or replace a note as a real markdown file under the session, project, or global note root. Keep notes small and split by topic — by what the note is about, never by who said it (authorship is origin's job); a rewrite replaces the body whole while preserving created_at and every other frontmatter key. stale: true marks the note closed so it leaves the boot index but stays readable and searchable.",
35
+ parameters: Type.Object({ path: Type.String(), content: Type.String(), scope: SCOPE, origin: ORIGIN, stale: Type.Optional(Type.Boolean()) }, { additionalProperties: false }),
36
+ // A batch containing write or edit runs one call at a time, so note read-modify-write cannot race.
37
+ executionMode: "sequential",
38
+ async execute(_id, params, _signal, _update, ctx) {
39
+ const content = params.content;
40
+ try {
41
+ const { meta } = writeNote(ctx, params.path, content, { scope: (params.scope ?? "session"), origin: (params.origin ?? "self"), stale: params.stale });
42
+ return output({ path: params.path, scope: meta.scope, size_bytes: Buffer.byteLength(stripLeadingFrontmatter(content), "utf8"), meta: wireMeta(meta) });
43
+ }
44
+ catch (error) {
45
+ return failure(error);
46
+ }
47
+ },
48
+ }));
49
+ pi.registerTool(defineTool({
50
+ name: "notes_edit",
51
+ label: "Notes edit",
52
+ description: "Edit a note body by exact-text replacement; frontmatter is never editable this way. Each oldText must occur exactly once unless replace_all is set; a multi-match anchor fails with its match line numbers and a zero-match anchor names the failing edit index. edits may be omitted (or empty) for a metadata-only update, which requires at least one of scope/origin/stale. scope/origin/stale are setters: scope moves the file, refusing when the target already exists. The success return carries resolved_scope and a diff of what changed.",
53
+ parameters: Type.Object({ path: Type.String(), edits: Type.Optional(Type.Array(Type.Object({ oldText: Type.String(), newText: Type.String() }, { additionalProperties: false }))), scope: SCOPE, origin: ORIGIN, stale: Type.Optional(Type.Boolean()), replace_all: Type.Optional(Type.Boolean()) }, { additionalProperties: false }),
54
+ executionMode: "sequential",
55
+ async execute(_id, params, _signal, _update, ctx) {
56
+ try {
57
+ const { meta, applied, resolved_scope, diff } = editNote(ctx, params.path, params.edits, { scope: params.scope, origin: params.origin, stale: params.stale, replaceAll: params.replace_all });
58
+ return output({ path: params.path, applied, resolved_scope, diff, meta: wireMeta(meta) });
59
+ }
60
+ catch (error) {
61
+ return failure(error);
62
+ }
63
+ },
64
+ }));
65
+ pi.registerTool(defineTool({
66
+ name: "notes_read",
67
+ label: "Notes read",
68
+ description: "Read a character window of a note file, frontmatter included: offset_chars is the code-point offset to start from (default 0) — a negative value counts back from the end — and limit_chars caps the window (default 12000, max 50000). Each response delivers the longest fitting prefix of that window: concatenate pages in order to reconstruct the note. The response is the raw frontmatter + body behind a one-line [bracketed] header naming the file, the resolved offset, the delivered char range, and the resume cursor.",
69
+ parameters: Type.Object({ path: Type.String(), scope: SCOPE, offset_chars: Type.Optional(Type.Integer({ description: "Code-point offset to start from (default 0). A negative value counts back from the end; the response echoes the resolved absolute offset. Pass the previous next_offset_chars back unchanged to continue." })), limit_chars: Type.Optional(Type.Integer({ minimum: 1, maximum: 50000, description: "Largest requested window in code points (default 12000). A window too large for the wire budget is cut short; next_offset_chars names where the next read resumes." })) }, { additionalProperties: false }),
70
+ async execute(_id, params, _signal, _update, ctx) {
71
+ let note;
72
+ try {
73
+ note = readNote(ctx, params.path, { scope: params.scope });
74
+ }
75
+ catch (error) {
76
+ return failure(error);
77
+ }
78
+ if (!note)
79
+ return output({ error: "note not found", path: params.path });
80
+ const text = serializeNote(note.meta, note.body);
81
+ const totalChars = Array.from(text).length;
82
+ // A positive offset past the end is an addressing error, not an empty page.
83
+ if (typeof params.offset_chars === "number" && params.offset_chars > totalChars) {
84
+ return output({ error: `offset_chars ${params.offset_chars} is past the end: the note has ${totalChars} chars; the largest legal offset is ${totalChars} (an empty end-read)`, path: params.path, offset_chars: params.offset_chars, total_chars: totalChars });
85
+ }
86
+ const created_at = localIso(note.meta.created_at);
87
+ const updated_at = localIso(note.meta.updated_at);
88
+ const limit_chars = Math.min(params.limit_chars ?? 12000, 50000);
89
+ return readCharacterWindow(text, params.offset_chars, params.limit_chars, (window) => {
90
+ const { content, ...rest } = window;
91
+ return outputRaw(characterWindowHeader(params.path, window, ` · ${note.resolvedScope} · created ${created_at} · updated ${updated_at}`), content, { path: params.path, scope: note.resolvedScope, ...rest, limit_chars, created_at, updated_at });
92
+ }, (result) => withinTextBudget(result.content[0].text));
93
+ },
94
+ }));
95
+ pi.registerTool(defineTool({
96
+ name: "notes_list",
97
+ label: "Notes list",
98
+ description: "List note files as rows carrying path, scope, origin, status, stale, size_bytes, created_at, and updated_at, most recently updated first. Without scope, all three scopes are merged; a glob pattern (* within a path segment, ** across segments) filters the virtual paths.",
99
+ parameters: Type.Object({ scope: SCOPE, pattern: nullableString(), cursor: cursor(), max_results: positiveInteger() }, { additionalProperties: false }),
100
+ async execute(_id, params, _signal, _update, ctx) {
101
+ let rows;
102
+ try {
103
+ rows = listNotes(ctx, { scope: params.scope, pattern: params.pattern ?? undefined });
104
+ }
105
+ catch (error) {
106
+ return failure(error);
107
+ }
108
+ const files = rows.map((row) => ({
109
+ path: row.path,
110
+ scope: row.meta.scope,
111
+ origin: row.meta.origin,
112
+ status: row.meta.status,
113
+ stale: row.meta.stale,
114
+ size_bytes: row.sizeBytes,
115
+ created_at: localIso(row.meta.created_at),
116
+ updated_at: localIso(row.meta.updated_at),
117
+ }));
118
+ return output(page(files, params.cursor ?? 0, "files", params.max_results, (file, fits) => {
119
+ if (fits(file))
120
+ return file;
121
+ const path = middleTruncate(file.path, (candidate) => fits({ ...file, path: candidate, path_truncated: true }));
122
+ return { ...file, path, path_truncated: true };
123
+ }));
124
+ },
125
+ }));
126
+ pi.registerTool(defineTool({
127
+ name: "notes_search",
128
+ label: "Notes search",
129
+ description: "Case-sensitive literal substring search over note bodies; query is one string or several (OR), each matched line appears once. Without scope, all three scopes are merged and every entry carries its scope. Each file entry carries matches_total, its full match count before capping. Each match carries line, text, offset_chars (the body-absolute code-point offset of the earliest match).",
130
+ parameters: Type.Object({ query: searchQuery(), scope: SCOPE, pattern: nullableString(), cursor: cursor(), max_matches_per_file: positiveInteger(), max_files: positiveInteger() }, { additionalProperties: false }),
131
+ async execute(_id, params, _signal, _update, ctx) {
132
+ const queries = searchQueries(params.query);
133
+ let rows;
134
+ try {
135
+ rows = searchNotes(ctx, queries, { scope: params.scope, pattern: params.pattern ?? undefined });
136
+ }
137
+ catch (error) {
138
+ return failure(error);
139
+ }
140
+ const maxPerFile = params.max_matches_per_file ?? Number.POSITIVE_INFINITY;
141
+ const result = rows.map((row) => {
142
+ const matches = row.matches.map((match) => ({ line: match.line, text: match.text, truncated: false, total_chars: Array.from(match.text).length, offset_chars: match.offsetChars }));
143
+ return { path: row.path, scope: row.scope, created_at: localIso(row.meta.created_at), updated_at: localIso(row.meta.updated_at), matches_total: matches.length, matches: matches.slice(0, maxPerFile) };
144
+ });
145
+ // Trailing matches are dropped to fit the budget, named by matches_total; a single
146
+ // over-budget line is delivered as a flagged prefix; only a pathological path is
147
+ // middle-truncated, and then only with a visible path_truncated flag.
148
+ const fitFile = (file, fits) => {
149
+ if (fits(file))
150
+ return file;
151
+ const matches = file.matches;
152
+ let low = 0;
153
+ let high = matches.length;
154
+ while (low < high) {
155
+ const mid = Math.ceil((low + high) / 2);
156
+ if (mid >= 1 && fits({ ...file, matches: matches.slice(0, mid) }))
157
+ low = mid;
158
+ else
159
+ high = mid - 1;
160
+ }
161
+ if (low >= 1)
162
+ return { ...file, matches: matches.slice(0, low) };
163
+ const first = matches[0];
164
+ const fitted = (text) => ({ ...file, matches: [{ ...first, text, truncated: true }] });
165
+ const text = prefixFit(first.text, (candidate) => fits(fitted(candidate)));
166
+ const prefix = fitted(text);
167
+ if (fits(prefix))
168
+ return prefix;
169
+ const path = middleTruncate(prefix.path, (candidate) => fits({ ...prefix, path: candidate, path_truncated: true }));
170
+ return { ...prefix, path, path_truncated: true };
171
+ };
172
+ return output(page(result, params.cursor ?? 0, "files", params.max_files, fitFile));
173
+ },
174
+ }));
175
+ }
@@ -0,0 +1,101 @@
1
+ import { MAX_NOTE_BYTES, NOTE_TYPE } from "./protocol.js";
2
+ export function assertVirtualPath(value) {
3
+ if (typeof value !== "string" || value.length === 0)
4
+ throw new Error("path must be a non-empty virtual relative path");
5
+ if (value.includes("\0") || value.includes("\\") || value.startsWith("/"))
6
+ throw new Error("path must be a safe virtual relative path");
7
+ const parts = value.split("/");
8
+ if (parts.some((part) => part.length === 0 || part === "." || part === ".."))
9
+ throw new Error("path contains an unsupported component");
10
+ return value;
11
+ }
12
+ /**
13
+ * Minimal glob over virtual note paths: `*` matches any run within a segment (never
14
+ * `/`), `**` matches any run across segments (a leading double-star followed by a
15
+ * slash also matches zero segments, so it covers the root too), `?` matches exactly
16
+ * one non-`/` character. Everything else is literal and the match is anchored to the
17
+ * whole path.
18
+ */
19
+ export function globToRegExp(pattern) {
20
+ let source = "^";
21
+ for (let index = 0; index < pattern.length; index++) {
22
+ const char = pattern[index];
23
+ if (char === "*") {
24
+ if (pattern[index + 1] === "*") {
25
+ const followedBySlash = pattern[index + 2] === "/";
26
+ source += followedBySlash ? "(?:[^]*\\/)?" : "[^]*";
27
+ index += followedBySlash ? 2 : 1;
28
+ }
29
+ else {
30
+ source += "[^/]*";
31
+ }
32
+ }
33
+ else {
34
+ source += char.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
35
+ }
36
+ }
37
+ return new RegExp(`${source}$`);
38
+ }
39
+ /** Glob patterns are not virtual paths (`*` is legal), so they get their own guard: no NUL, no backslashes. */
40
+ export function assertGlobPattern(value) {
41
+ if (value === undefined || value === null || value === "")
42
+ return undefined;
43
+ if (typeof value !== "string")
44
+ throw new Error("glob pattern must be a string");
45
+ if (value.includes("\0") || value.includes("\\"))
46
+ throw new Error("glob pattern must not contain NUL or backslashes");
47
+ return value;
48
+ }
49
+ /** Replays only pi-context note operations from session custom entries. */
50
+ function isNoteOperation(data) {
51
+ if (typeof data !== "object" || data === null)
52
+ return false;
53
+ const op = data;
54
+ return ((op.op === "write" || op.op === "append") &&
55
+ typeof op.path === "string" &&
56
+ (op.text === undefined || typeof op.text === "string") &&
57
+ (op.stale === undefined || typeof op.stale === "boolean") &&
58
+ (op.text !== undefined || op.stale !== undefined) &&
59
+ typeof op.createdAt === "number" && Number.isFinite(new Date(op.createdAt).getTime()) &&
60
+ typeof op.updatedAt === "number" && Number.isFinite(new Date(op.updatedAt).getTime()));
61
+ }
62
+ export function notesFromSession(ctx) {
63
+ const files = new Map();
64
+ for (const entry of ctx.sessionManager.getBranch()) {
65
+ if (entry.type !== "custom" || entry.customType !== NOTE_TYPE || !isNoteOperation(entry.data))
66
+ continue;
67
+ const op = entry.data;
68
+ try {
69
+ assertVirtualPath(op.path);
70
+ }
71
+ catch {
72
+ continue;
73
+ }
74
+ const previous = files.get(op.path);
75
+ const hasText = op.text !== undefined;
76
+ // A mark-only operation needs an existing note to change; without one it is a no-op.
77
+ if (!hasText && !previous)
78
+ continue;
79
+ const text = hasText ? (op.op === "append" ? `${previous?.text ?? ""}${op.text}` : op.text) : previous.text;
80
+ if (Buffer.byteLength(text, "utf8") > MAX_NOTE_BYTES)
81
+ continue;
82
+ // Carrying text revives unless the call also marks stale; a mark-only op keeps its flag.
83
+ const stale = hasText ? op.stale ?? false : op.stale ?? previous.stale;
84
+ files.set(op.path, { text, stale, createdAt: previous?.createdAt ?? op.createdAt, updatedAt: op.updatedAt });
85
+ }
86
+ return files;
87
+ }
88
+ const pad2 = (value) => String(value).padStart(2, "0");
89
+ /**
90
+ * Format epoch milliseconds as an ISO 8601 string in the host's local time zone with an
91
+ * explicit numeric offset (e.g. 2026-09-15T17:31:45.392+08:00). A UTC host renders
92
+ * "+00:00"; the "Z" designator is never used, and Date.parse round-trips the value.
93
+ */
94
+ export function localIso(epochMs) {
95
+ const date = new Date(epochMs);
96
+ const offsetMinutes = -date.getTimezoneOffset();
97
+ const absOffset = Math.abs(offsetMinutes);
98
+ const offset = `${offsetMinutes < 0 ? "-" : "+"}${pad2(Math.floor(absOffset / 60))}:${pad2(absOffset % 60)}`;
99
+ const wallClock = `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}T${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}.${String(date.getMilliseconds()).padStart(3, "0")}`;
100
+ return `${wallClock}${offset}`;
101
+ }
@@ -0,0 +1,79 @@
1
+ import { historyFromSession } from "./history.js";
2
+ import { localIso } from "./notes.js";
3
+ import { listNotes, peekNote, resolveNoteScope } from "./memory/store.js";
4
+ import { CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, NOTE_PREVIEW_CHARS, NOTE_PREVIEW_HEAD_CHARS, NOTE_PREVIEW_TAIL_CHARS, RESET_SUMMARY, PROTOCOL_BLOCK, GUIDANCE_OPEN_TAG, GUIDANCE_CLOSE_TAG } from "./protocol.js";
5
+ /** Codex-style <context_window> identity block: agent name and first/current/previous window ids only. */
6
+ function identityBlock(agentName, firstWindowId, currentWindowId, previousWindowId) {
7
+ const lines = [
8
+ `Agent name: ${agentName}`,
9
+ `First context window id: ${firstWindowId}`,
10
+ `Current context window id: ${currentWindowId}`,
11
+ ];
12
+ if (previousWindowId)
13
+ lines.push(`Previous context window id: ${previousWindowId}`);
14
+ return `${CONTEXT_WINDOW_OPEN_TAG}\n${lines.join("\n")}\n${CONTEXT_WINDOW_CLOSE_TAG}`;
15
+ }
16
+ /**
17
+ * Recent-notes index: up to three most-recent fresh (non-stale) notes. Each note shows its
18
+ * path, line count, UTF-8 byte count and local ISO update time, followed by an indented inline
19
+ * preview: the whole text when it fits in NOTE_PREVIEW_CHARS, otherwise its first
20
+ * NOTE_PREVIEW_HEAD_CHARS and last NOTE_PREVIEW_TAIL_CHARS Unicode characters joined by an
21
+ * explicit ellipsis. The two slices never overlap, so the preview never duplicates head content
22
+ * as tail content. Stale notes are excluded entirely; empty when no fresh notes remain.
23
+ */
24
+ function notesIndex(ctx) {
25
+ const sections = [];
26
+ // TOC residency ("地图在场"): the map, when present, is injected whole ahead of the list.
27
+ const toc = resolveNoteScope(ctx, "TOC.md");
28
+ if (toc) {
29
+ const body = peekNote(ctx, toc.scope, "TOC.md").body;
30
+ if (body.length > 0)
31
+ sections.push(body);
32
+ }
33
+ // listNotes is already most-recently-updated first; stale notes never reach the index.
34
+ const recentNotes = listNotes(ctx, {})
35
+ .filter((row) => !row.meta.stale)
36
+ .slice(0, 5);
37
+ if (recentNotes.length > 0) {
38
+ const lines = [`You find ${recentNotes.length} crumpled note${recentNotes.length === 1 ? "" : "s"} in your pocket (up to 5, most recent first):`];
39
+ for (const row of recentNotes) {
40
+ const body = peekNote(ctx, row.meta.scope, row.path).body;
41
+ lines.push(`- ${row.path} (${body.split("\n").length} lines, ${row.sizeBytes} UTF-8 bytes, updated ${localIso(row.meta.updated_at)})`);
42
+ const chars = Array.from(body);
43
+ // Short notes stay whole; long notes keep both ends. head + tail <= NOTE_PREVIEW_CHARS < chars.length,
44
+ // so the slices are disjoint and no character is shown twice.
45
+ const preview = chars.length <= NOTE_PREVIEW_CHARS
46
+ ? body
47
+ : `${chars.slice(0, NOTE_PREVIEW_HEAD_CHARS).join("")}…${chars.slice(chars.length - NOTE_PREVIEW_TAIL_CHARS).join("")}`;
48
+ lines.push(preview.split("\n").map((line) => ` ${line}`).join("\n"));
49
+ }
50
+ sections.push(lines.join("\n"));
51
+ }
52
+ return sections.join("\n\n");
53
+ }
54
+ /**
55
+ * Assemble the static, once-per-window boot block: the reset line for resets, the
56
+ * <context_window> identity block, the recent-notes index at window-open time, and
57
+ * the <context_window_protocol> teaching block. Nothing here is re-injected, so the
58
+ * head of the window stays cache-stable.
59
+ */
60
+ export function bootBlock(ctx, currentId, previousId, resetLine) {
61
+ const firstId = historyFromSession(ctx)[0]?.windowId ?? currentId;
62
+ const parts = [];
63
+ if (resetLine)
64
+ parts.push(RESET_SUMMARY);
65
+ parts.push(identityBlock(ctx.sessionManager.getSessionName() ?? "root", firstId, currentId, previousId));
66
+ const index = notesIndex(ctx);
67
+ if (index)
68
+ parts.push(index);
69
+ parts.push(PROTOCOL_BLOCK);
70
+ return parts.join("\n\n");
71
+ }
72
+ /**
73
+ * Codex-equivalent low-budget reminder. The measured remaining count is frozen into
74
+ * the text at the crossing that fires it, so each persisted copy is a snapshot true
75
+ * at write time; get_context_remaining remains the live source for the current figure.
76
+ */
77
+ export function tokenBudgetGuidance(remaining) {
78
+ return `${GUIDANCE_OPEN_TAG}\nYour brain is almost out of room — ${remaining} tokens left, and then your memory gets wiped. The wipe is automatic: there is no final turn to write then. Grab the notebook now — the goal, decisions, progress, learnings, next steps, the skills you still need, the window ID and item ID of every relevant user request still being solved, and important actions/tool calls for future reference. Replacing an older checkpoint? Mark it stale. Then end the window yourself — anything you do after the checkpoint isn't in it.\n${GUIDANCE_CLOSE_TAG}`;
79
+ }