@astrosheep/pi-context 0.19.0 → 0.21.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 (54) hide show
  1. package/dist/src/budget.js +65 -0
  2. package/dist/src/dream/cli.js +83 -0
  3. package/dist/src/dream/gates.js +22 -0
  4. package/dist/src/dream/git.js +28 -0
  5. package/dist/src/dream/lock.js +58 -0
  6. package/dist/src/dream/runner.js +115 -0
  7. package/dist/src/history-tools.js +105 -0
  8. package/dist/src/history.js +215 -0
  9. package/dist/src/index.js +98 -0
  10. package/dist/src/notes/address.js +31 -0
  11. package/dist/src/notes/frontmatter.js +136 -0
  12. package/dist/src/notes/model.js +101 -0
  13. package/dist/src/notes/paths.js +58 -0
  14. package/dist/src/notes/store.js +270 -0
  15. package/dist/src/notes/tools.js +153 -0
  16. package/dist/src/prompts.js +81 -0
  17. package/dist/src/protocol.js +56 -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 +75 -0
  21. package/dist/src/tool-output.js +175 -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 +214 -0
  25. package/dist/test/coherence.test.js +375 -0
  26. package/dist/test/dream.test.js +142 -0
  27. package/dist/test/history.test.js +26 -0
  28. package/dist/test/integration.test.js +1766 -0
  29. package/dist/test/notes.test.js +474 -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 +13 -7
  33. package/playbook.md +32 -0
  34. package/src/budget.ts +11 -9
  35. package/src/dream/cli.ts +33 -0
  36. package/src/dream/gates.ts +20 -0
  37. package/src/dream/git.ts +27 -0
  38. package/src/dream/lock.ts +39 -0
  39. package/src/dream/runner.ts +111 -0
  40. package/src/history-tools.ts +5 -5
  41. package/src/history.ts +12 -7
  42. package/src/index.ts +13 -14
  43. package/src/notes/address.ts +33 -0
  44. package/src/{memory → notes}/frontmatter.ts +5 -3
  45. package/src/{notes.ts → notes/model.ts} +2 -2
  46. package/src/{memory → notes}/paths.ts +6 -1
  47. package/src/{memory → notes}/store.ts +62 -77
  48. package/src/notes/tools.ts +132 -0
  49. package/src/prompts.ts +31 -29
  50. package/src/protocol.ts +9 -5
  51. package/src/thresholds.ts +4 -1
  52. package/src/tool-output.ts +4 -1
  53. package/src/warning.ts +3 -3
  54. package/src/memory/tools.ts +0 -166
@@ -0,0 +1,270 @@
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 "./model.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 { addressFor } from "./address.js";
9
+ import { physicalPath, scopeDir } from "./paths.js";
10
+ import { earliestMatchOffsetChars } from "../tool-output.js";
11
+ /** Typed store refusal. `line_numbers` and `edit_index` are the edit error's addressing fields. */
12
+ export class NoteError extends Error {
13
+ code;
14
+ line_numbers;
15
+ edit_index;
16
+ constructor(code, message, extra = {}) {
17
+ super(message);
18
+ this.name = "NoteError";
19
+ this.code = code;
20
+ this.line_numbers = extra.line_numbers;
21
+ this.edit_index = extra.edit_index;
22
+ }
23
+ }
24
+ const SCOPE_ORDER = ["session", "project", "global"];
25
+ function assertScope(value) {
26
+ if (!isScope(value))
27
+ throw new NoteError("invalid_scope", `scope must be one of session, project, global (got ${JSON.stringify(value)})`);
28
+ return value;
29
+ }
30
+ function assertOrigin(value) {
31
+ if (!isOrigin(value))
32
+ throw new NoteError("invalid_origin", `origin must be one of user, self, external (got ${JSON.stringify(value)})`);
33
+ return value;
34
+ }
35
+ /** Recursively list `.md` files under `dir` as forward-slash virtual paths relative to `base`. */
36
+ function walkMarkdown(dir, base = dir) {
37
+ let entries;
38
+ try {
39
+ entries = readdirSync(dir, { withFileTypes: true });
40
+ }
41
+ catch {
42
+ return [];
43
+ }
44
+ const paths = [];
45
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
46
+ const child = `${dir}/${entry.name}`;
47
+ if (entry.isDirectory())
48
+ paths.push(...walkMarkdown(child, base));
49
+ else if (entry.isFile() && entry.name.endsWith(".md"))
50
+ paths.push(child.slice(base.length + 1).split("\\").join("/"));
51
+ }
52
+ return paths;
53
+ }
54
+ function matcherFor(pattern) {
55
+ const normalized = assertGlobPattern(pattern);
56
+ return normalized === undefined ? undefined : globToRegExp(normalized);
57
+ }
58
+ /**
59
+ * Every mutation lands through a tmp file renamed into place in the same directory, so a crash
60
+ * never leaves a torn note. No cross-process locking: out of scope by decision.
61
+ */
62
+ function atomicWrite(path, content) {
63
+ mkdirSync(dirname(path), { recursive: true });
64
+ const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
65
+ try {
66
+ writeFileSync(tmp, content);
67
+ renameSync(tmp, path);
68
+ }
69
+ catch (error) {
70
+ rmSync(tmp, { force: true });
71
+ throw error;
72
+ }
73
+ }
74
+ /** Write-time vpath guard: the byte cap is a tool-boundary rule, never a jail rule. */
75
+ function assertWritablePath(vpath) {
76
+ const bytes = Buffer.byteLength(vpath, "utf8");
77
+ if (bytes > MAX_NOTE_PATH_BYTES)
78
+ throw new NoteError("too_large", `note path exceeds ${MAX_NOTE_PATH_BYTES} UTF-8 bytes (got ${bytes})`);
79
+ }
80
+ /** Serialized-size guard applied after the frontmatter is merged, before any bytes are written. */
81
+ function assertSerializedSize(content) {
82
+ const bytes = Buffer.byteLength(content, "utf8");
83
+ if (bytes > MAX_NOTE_BYTES)
84
+ throw new NoteError("too_large", `note exceeds ${MAX_NOTE_BYTES} UTF-8 bytes (serialized ${bytes})`);
85
+ }
86
+ /** Frontmatter block only (the body separator stripped), for the metadata-only diff. */
87
+ function frontmatterOf(meta) {
88
+ return serializeNote(meta, "").slice(0, -2);
89
+ }
90
+ /** Line numbers (1-based) of every occurrence of `needle` in `body`. */
91
+ function matchLineNumbers(body, needle) {
92
+ const lines = [];
93
+ let cursor = 0;
94
+ for (;;) {
95
+ const index = body.indexOf(needle, cursor);
96
+ if (index === -1)
97
+ break;
98
+ lines.push(body.slice(0, index).split("\n").length);
99
+ cursor = index + Math.max(needle.length, 1);
100
+ }
101
+ return lines;
102
+ }
103
+ /** Create or overwrite a note; overwrite keeps created_at and every unknown key. */
104
+ export function writeNote(ctx, vpath, body, opts) {
105
+ assertVirtualPath(vpath);
106
+ assertWritablePath(vpath);
107
+ const scope = assertScope(opts.scope);
108
+ const origin = assertOrigin(opts.origin);
109
+ const path = physicalPath(scope, vpath, ctx);
110
+ const now = Date.now();
111
+ const cleanBody = stripLeadingFrontmatter(body);
112
+ const existing = existsSync(path) ? parseNote(readFileSync(path, "utf8"), now).meta : undefined;
113
+ const meta = existing ?? {
114
+ scope,
115
+ origin,
116
+ status: "active",
117
+ stale: false,
118
+ created_at: now,
119
+ updated_at: now,
120
+ last_accessed: now,
121
+ access_count: 0,
122
+ };
123
+ meta.scope = scope;
124
+ meta.origin = origin;
125
+ meta.status = "active";
126
+ meta.stale = opts.stale ?? false;
127
+ meta.updated_at = now;
128
+ const serialized = serializeNote(meta, cleanBody);
129
+ assertSerializedSize(serialized);
130
+ atomicWrite(path, serialized);
131
+ return { meta };
132
+ }
133
+ /** Dream harness mutation: metadata changes still use the store's atomic writer. */
134
+ export function updateNoteMeta(ctx, vpath, scope, mutate) {
135
+ assertVirtualPath(vpath);
136
+ const path = physicalPath(scope, vpath, ctx);
137
+ if (!existsSync(path))
138
+ throw new NoteError("not_found", `note not found: ${vpath}`);
139
+ const parsed = parseNote(readFileSync(path, "utf8"));
140
+ const meta = { ...parsed.meta, scope };
141
+ mutate(meta);
142
+ meta.updated_at = Date.now();
143
+ const serialized = serializeNote(meta, parsed.body);
144
+ assertSerializedSize(serialized);
145
+ atomicWrite(path, serialized);
146
+ return { meta, body: parsed.body };
147
+ }
148
+ /** Apply body-only edits against one explicit home; origin and stale are its metadata setters. */
149
+ export function editNote(ctx, vpath, scope, edits, opts = {}) {
150
+ assertVirtualPath(vpath);
151
+ assertWritablePath(vpath);
152
+ const operations = edits ?? [];
153
+ if (operations.length === 0 && opts.origin === undefined && opts.stale === undefined) {
154
+ throw new NoteError("nothing_to_do", "nothing to do: provide edits or at least one of origin, stale");
155
+ }
156
+ const path = physicalPath(scope, vpath, ctx);
157
+ if (!existsSync(path))
158
+ throw new NoteError("not_found", "note not found");
159
+ const raw = readFileSync(path, "utf8");
160
+ const { meta, body } = parseNote(raw);
161
+ meta.scope = scope;
162
+ // Snapshot the pre-edit frontmatter so the diff can name exactly what the setters changed.
163
+ const beforeMeta = { ...meta };
164
+ // Every edit runs against this one snapshot; nothing is written until all of them succeed,
165
+ // so a failing edit leaves the file byte-identical (frontmatter included).
166
+ let next = body;
167
+ operations.forEach((edit, index) => {
168
+ const oldText = edit?.oldText;
169
+ const newText = edit?.newText;
170
+ if (typeof oldText !== "string" || oldText.length === 0)
171
+ throw new NoteError("no_match", `edit ${index}: oldText must be a non-empty string`, { edit_index: index });
172
+ if (typeof newText !== "string")
173
+ throw new NoteError("no_match", `edit ${index}: newText must be a string`, { edit_index: index });
174
+ const lines = matchLineNumbers(next, oldText);
175
+ if (lines.length === 0)
176
+ throw new NoteError("no_match", `edit ${index}: oldText does not occur in the note body`, { edit_index: index });
177
+ if (lines.length > 1 && !opts.replaceAll) {
178
+ 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 });
179
+ }
180
+ // Single replacement is positional splicing, never String.replace: user text must be
181
+ // inserted byte-for-byte, without $-pattern substitution ($&, $`, $', $1, $$).
182
+ if (opts.replaceAll) {
183
+ next = next.split(oldText).join(newText);
184
+ }
185
+ else {
186
+ const matchIndex = next.indexOf(oldText);
187
+ next = next.substring(0, matchIndex) + newText + next.substring(matchIndex + oldText.length);
188
+ }
189
+ });
190
+ if (opts.origin !== undefined)
191
+ meta.origin = assertOrigin(opts.origin);
192
+ if (opts.stale !== undefined)
193
+ meta.stale = opts.stale;
194
+ meta.updated_at = Date.now();
195
+ const serialized = serializeNote(meta, next);
196
+ assertSerializedSize(serialized);
197
+ // pi-edit-style diff: body only for a content edit, frontmatter only for a metadata-only
198
+ // update, one combined file diff when both change.
199
+ const bodyChanged = body !== next;
200
+ const metadataChanged = beforeMeta.origin !== meta.origin || beforeMeta.stale !== meta.stale;
201
+ const diff = bodyChanged && metadataChanged
202
+ ? generateDiffString(raw, serialized).diff
203
+ : bodyChanged
204
+ ? generateDiffString(body, next).diff
205
+ : metadataChanged
206
+ ? generateDiffString(frontmatterOf(beforeMeta), frontmatterOf(meta)).diff
207
+ : "";
208
+ atomicWrite(path, serialized);
209
+ return { meta, applied: operations.length, resolved_scope: scope, diff };
210
+ }
211
+ /** Read a note and, as a side effect, bump last_accessed/access_count in the file. */
212
+ export function readNote(ctx, vpath, scope) {
213
+ assertVirtualPath(vpath);
214
+ const path = physicalPath(scope, vpath, ctx);
215
+ if (!existsSync(path))
216
+ return undefined;
217
+ const now = Date.now();
218
+ const { meta, body } = parseNote(readFileSync(path, "utf8"), now);
219
+ meta.scope = scope;
220
+ // Only the two access keys move; updated_at and every other key keep their bytes.
221
+ meta.last_accessed = now;
222
+ meta.access_count = (typeof meta.access_count === "number" ? meta.access_count : 0) + 1;
223
+ atomicWrite(path, serializeNote(meta, body));
224
+ return { meta, body, resolvedScope: scope };
225
+ }
226
+ /** Merged rows across homes, most recently updated first (address breaks ties). */
227
+ export function listNotes(ctx, opts = {}) {
228
+ const matcher = matcherFor(opts.pattern);
229
+ const rows = [];
230
+ for (const scope of opts.scope === undefined ? SCOPE_ORDER : [opts.scope]) {
231
+ const root = scopeDir(scope, ctx);
232
+ for (const path of walkMarkdown(root)) {
233
+ const address = addressFor(scope, path);
234
+ if (matcher && !matcher.test(address))
235
+ continue;
236
+ const { meta, body } = parseNote(readFileSync(`${root}/${path}`, "utf8"));
237
+ meta.scope = scope;
238
+ rows.push({ address, scope, path, meta, body, sizeBytes: Buffer.byteLength(body, "utf8") });
239
+ }
240
+ }
241
+ rows.sort((a, b) => b.meta.updated_at - a.meta.updated_at || a.address.localeCompare(b.address));
242
+ return rows;
243
+ }
244
+ /** Case-sensitive literal substring search over note bodies, with a match address per line. */
245
+ export function searchNotes(ctx, queries, opts = {}) {
246
+ const matcher = matcherFor(opts.pattern);
247
+ const rows = [];
248
+ for (const scope of opts.scope === undefined ? SCOPE_ORDER : [opts.scope]) {
249
+ const root = scopeDir(scope, ctx);
250
+ for (const path of walkMarkdown(root)) {
251
+ const address = addressFor(scope, path);
252
+ if (matcher && !matcher.test(address))
253
+ continue;
254
+ const { meta, body } = parseNote(readFileSync(`${root}/${path}`, "utf8"));
255
+ meta.scope = scope;
256
+ let baseChars = 0;
257
+ const matches = [];
258
+ for (const [index, line] of body.split("\n").entries()) {
259
+ if (queries.some((query) => line.includes(query))) {
260
+ matches.push({ line: index + 1, text: line, offsetChars: baseChars + earliestMatchOffsetChars(line, queries) });
261
+ }
262
+ baseChars += Array.from(line).length + 1;
263
+ }
264
+ if (matches.length > 0)
265
+ rows.push({ address, path, scope, meta, matches });
266
+ }
267
+ }
268
+ rows.sort((a, b) => a.address.localeCompare(b.address));
269
+ return rows;
270
+ }
@@ -0,0 +1,153 @@
1
+ import { Type } from "@earendil-works/pi-ai";
2
+ import { defineTool } from "@earendil-works/pi-coding-agent";
3
+ import { localIso } from "./model.js";
4
+ import { characterWindowHeader, DEFAULT_READ_WINDOW_CHARS, MAX_READ_WINDOW_CHARS, middleTruncate, output, outputRaw, page, prefixFit, readCharacterWindow, withinTextBudget } from "../tool-output.js";
5
+ import { cursor, nullableString, positiveInteger, searchQueries, searchQuery } from "../tool-schema.js";
6
+ import { assertAddress } from "./address.js";
7
+ import { serializeNote, stripLeadingFrontmatter } from "./frontmatter.js";
8
+ import { NoteError, editNote, listNotes, readNote, searchNotes, writeNote } from "./store.js";
9
+ const ORIGIN = Type.Optional(Type.Union([Type.Literal("user"), Type.Literal("self"), Type.Literal("external")], {
10
+ 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.",
11
+ }));
12
+ const ADDRESS_DESCRIPTION = "Address forms are bare `<vpath>` for this session, `@project/<vpath>` for this project's home, and `@global/<vpath>` for the global home. `@` means leaving home. Any other `@` prefix, or `@` inside a vpath, is a hard error: legal prefixes are `@project/` and `@global/`; bare names are the session home. There is no cross-home fallback. Paths reject `..`, absolute paths, and backslashes.";
13
+ function wireMeta(meta) {
14
+ return { ...meta, created_at: localIso(meta.created_at), updated_at: localIso(meta.updated_at), last_accessed: localIso(meta.last_accessed) };
15
+ }
16
+ function failure(error) {
17
+ if (error instanceof NoteError) {
18
+ const payload = { error: error.message };
19
+ if (error.line_numbers)
20
+ payload.line_numbers = error.line_numbers;
21
+ if (error.edit_index !== undefined)
22
+ payload.edit_index = error.edit_index;
23
+ return output(payload);
24
+ }
25
+ throw error;
26
+ }
27
+ export function registerNotesTools(pi) {
28
+ pi.registerTool(defineTool({
29
+ name: "notes_write", label: "Notes write",
30
+ description: `Create or replace a note as a real markdown file, and name it for what it holds: a fresh window sees only an index entry, never the note itself. ${ADDRESS_DESCRIPTION} 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.`,
31
+ parameters: Type.Object({ address: Type.String(), content: Type.String(), origin: ORIGIN, stale: Type.Optional(Type.Boolean()) }, { additionalProperties: false }), executionMode: "sequential",
32
+ async execute(_id, params, _signal, _update, ctx) {
33
+ const content = params.content;
34
+ try {
35
+ const destination = assertAddress(params.address);
36
+ const { meta } = writeNote(ctx, destination.path, content, { scope: destination.scope, origin: (params.origin ?? "self"), stale: params.stale });
37
+ return output({ address: params.address, scope: meta.scope, size_bytes: Buffer.byteLength(stripLeadingFrontmatter(content), "utf8"), meta: wireMeta(meta) });
38
+ }
39
+ catch (error) {
40
+ return failure(error);
41
+ }
42
+ },
43
+ }));
44
+ pi.registerTool(defineTool({
45
+ name: "notes_edit", label: "Notes edit",
46
+ description: `Edit a note body by exact-text replacement; frontmatter is never editable this way. ${ADDRESS_DESCRIPTION} 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 origin/stale. Moving while awake means notes_write at a new address and notes_edit at the old address with stale=true. The success return carries resolved_scope and a diff of what changed.`,
47
+ parameters: Type.Object({ address: Type.String(), edits: Type.Optional(Type.Array(Type.Object({ oldText: Type.String(), newText: Type.String() }, { additionalProperties: false }))), origin: ORIGIN, stale: Type.Optional(Type.Boolean()), replace_all: Type.Optional(Type.Boolean()) }, { additionalProperties: false }), executionMode: "sequential",
48
+ async execute(_id, params, _signal, _update, ctx) {
49
+ try {
50
+ const destination = assertAddress(params.address);
51
+ const { meta, applied, resolved_scope, diff } = editNote(ctx, destination.path, destination.scope, params.edits, { origin: params.origin, stale: params.stale, replaceAll: params.replace_all });
52
+ return output({ address: params.address, applied, resolved_scope, diff, meta: wireMeta(meta) });
53
+ }
54
+ catch (error) {
55
+ return failure(error);
56
+ }
57
+ },
58
+ }));
59
+ pi.registerTool(defineTool({
60
+ name: "notes_read", label: "Notes read",
61
+ description: `Read a character window of a note file, frontmatter included. ${ADDRESS_DESCRIPTION} 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 ${DEFAULT_READ_WINDOW_CHARS}, max ${MAX_READ_WINDOW_CHARS}). 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 address, the resolved offset, the delivered char range, and the resume cursor.`,
62
+ parameters: Type.Object({ address: Type.String(), 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: MAX_READ_WINDOW_CHARS, description: `Largest requested window in code points (default ${DEFAULT_READ_WINDOW_CHARS}). A window too large for the wire budget is cut short; next_offset_chars names where the next read resumes.` })) }, { additionalProperties: false }),
63
+ async execute(_id, params, _signal, _update, ctx) {
64
+ let note;
65
+ try {
66
+ const destination = assertAddress(params.address);
67
+ note = readNote(ctx, destination.path, destination.scope);
68
+ }
69
+ catch (error) {
70
+ return failure(error);
71
+ }
72
+ if (!note)
73
+ return output({ error: "note not found", address: params.address });
74
+ const text = serializeNote(note.meta, note.body);
75
+ const totalChars = Array.from(text).length;
76
+ if (typeof params.offset_chars === "number" && params.offset_chars > totalChars)
77
+ 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)`, address: params.address, offset_chars: params.offset_chars, total_chars: totalChars });
78
+ const created_at = localIso(note.meta.created_at);
79
+ const updated_at = localIso(note.meta.updated_at);
80
+ const limit_chars = Math.min(params.limit_chars ?? DEFAULT_READ_WINDOW_CHARS, MAX_READ_WINDOW_CHARS);
81
+ return readCharacterWindow(text, params.offset_chars, params.limit_chars, (window) => {
82
+ const { content, ...rest } = window;
83
+ return outputRaw(characterWindowHeader(params.address, window, ` · ${note.resolvedScope} · created ${created_at} · updated ${updated_at}`), content, { address: params.address, scope: note.resolvedScope, ...rest, limit_chars, created_at, updated_at });
84
+ }, (result) => withinTextBudget(result.content[0].text));
85
+ },
86
+ }));
87
+ pi.registerTool(defineTool({
88
+ name: "notes_list", label: "Notes list",
89
+ description: `List note files as rows carrying address, scope, origin, status, stale, size_bytes, created_at, and updated_at, most recently updated first. ${ADDRESS_DESCRIPTION} All three homes are merged. A glob pattern (* within a path segment, ** across segments) filters full address strings: *.md is session-only, @project/** is project-only, and ** covers every home.`,
90
+ parameters: Type.Object({ pattern: nullableString(), cursor: cursor(), max_results: positiveInteger() }, { additionalProperties: false }),
91
+ async execute(_id, params, _signal, _update, ctx) {
92
+ let rows;
93
+ try {
94
+ rows = listNotes(ctx, { pattern: params.pattern ?? undefined });
95
+ }
96
+ catch (error) {
97
+ return failure(error);
98
+ }
99
+ const files = rows.map((row) => ({ address: row.address, scope: row.scope, origin: row.meta.origin, status: row.meta.status, stale: row.meta.stale, size_bytes: row.sizeBytes, created_at: localIso(row.meta.created_at), updated_at: localIso(row.meta.updated_at) }));
100
+ return output(page(files, params.cursor ?? 0, "files", params.max_results, (file, fits) => {
101
+ if (fits(file))
102
+ return file;
103
+ const address = middleTruncate(file.address, (candidate) => fits({ ...file, address: candidate, address_truncated: true }));
104
+ return { ...file, address, address_truncated: true };
105
+ }));
106
+ },
107
+ }));
108
+ pi.registerTool(defineTool({
109
+ name: "notes_search", label: "Notes search",
110
+ description: `Case-sensitive literal substring search over note bodies; query is one string or several (OR), each matched line appears once. ${ADDRESS_DESCRIPTION} All three homes are merged and every entry carries its full address and derived scope. Patterns glob over full address strings. 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).`,
111
+ parameters: Type.Object({ query: searchQuery(), pattern: nullableString(), cursor: cursor(), max_matches_per_file: positiveInteger(), max_files: positiveInteger() }, { additionalProperties: false }),
112
+ async execute(_id, params, _signal, _update, ctx) {
113
+ const queries = searchQueries(params.query);
114
+ let rows;
115
+ try {
116
+ rows = searchNotes(ctx, queries, { pattern: params.pattern ?? undefined });
117
+ }
118
+ catch (error) {
119
+ return failure(error);
120
+ }
121
+ const maxPerFile = params.max_matches_per_file ?? Number.POSITIVE_INFINITY;
122
+ const result = rows.map((row) => {
123
+ 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 }));
124
+ return { address: row.address, 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) };
125
+ });
126
+ const fitFile = (file, fits) => {
127
+ if (fits(file))
128
+ return file;
129
+ const matches = file.matches;
130
+ let low = 0;
131
+ let high = matches.length;
132
+ while (low < high) {
133
+ const mid = Math.ceil((low + high) / 2);
134
+ if (mid >= 1 && fits({ ...file, matches: matches.slice(0, mid) }))
135
+ low = mid;
136
+ else
137
+ high = mid - 1;
138
+ }
139
+ if (low >= 1)
140
+ return { ...file, matches: matches.slice(0, low) };
141
+ const first = matches[0];
142
+ const fitted = (text) => ({ ...file, matches: [{ ...first, text, truncated: true }] });
143
+ const text = prefixFit(first.text, (candidate) => fits(fitted(candidate)));
144
+ const prefix = fitted(text);
145
+ if (fits(prefix))
146
+ return prefix;
147
+ const address = middleTruncate(prefix.address, (candidate) => fits({ ...prefix, address: candidate, address_truncated: true }));
148
+ return { ...prefix, address, address_truncated: true };
149
+ };
150
+ return output(page(result, params.cursor ?? 0, "files", params.max_files, fitFile));
151
+ },
152
+ }));
153
+ }
@@ -0,0 +1,81 @@
1
+ import { historyFromSession } from "./history.js";
2
+ import { localIso } from "./notes/model.js";
3
+ import { listNotes } from "./notes/store.js";
4
+ import { CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, POCKET_GLOBAL_LIMIT, POCKET_PROJECT_LIMIT, POCKET_SESSION_LIMIT, 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
+ * Boot notes index. Map residency ("地图在场"): fresh MAP.md bodies from the global and
18
+ * project homes are both injected, broadest first; stale maps are skipped per home, and the
19
+ * session home is never peeked — a session MAP.md is an ordinary note. The pocket then lists
20
+ * recent fresh notes under per-home quotas (POCKET_SESSION_LIMIT / POCKET_PROJECT_LIMIT /
21
+ * POCKET_GLOBAL_LIMIT), most-recently-updated first within each home, one metadata line
22
+ * each: address, line count, UTF-8 byte count, local ISO update time. Bodies never render
23
+ * in the pocket; stale notes are excluded; MAP.md itself never takes a pocket seat.
24
+ */
25
+ function notesIndex(ctx) {
26
+ const sections = [];
27
+ // Map residency ("地图在场"): scope-native maps, both fresh ones injected broadest-first.
28
+ // A session MAP.md is an ordinary note, never resident; stale maps skip independently.
29
+ for (const scope of ["global", "project"]) {
30
+ const toc = listNotes(ctx, { scope }).find((row) => row.path === "MAP.md");
31
+ if (toc && !toc.meta.stale) {
32
+ if (toc.body.length > 0)
33
+ sections.push(toc.body);
34
+ }
35
+ }
36
+ // listNotes is most-recently-updated first within each home. Per-home quotas keep session
37
+ // churn from evicting project or global notes; maps never take pocket seats.
38
+ const recentNotes = [
39
+ ...listNotes(ctx, { scope: "session" }).filter((row) => !row.meta.stale && row.path !== "MAP.md").slice(0, POCKET_SESSION_LIMIT),
40
+ ...listNotes(ctx, { scope: "project" }).filter((row) => !row.meta.stale && row.path !== "MAP.md").slice(0, POCKET_PROJECT_LIMIT),
41
+ ...listNotes(ctx, { scope: "global" }).filter((row) => !row.meta.stale && row.path !== "MAP.md").slice(0, POCKET_GLOBAL_LIMIT),
42
+ ];
43
+ if (recentNotes.length > 0) {
44
+ const lines = [`You find ${recentNotes.length} crumpled note${recentNotes.length === 1 ? "" : "s"} in your pocket (by home, most recent first within each: up to ${POCKET_SESSION_LIMIT} from this session, ${POCKET_PROJECT_LIMIT} from this project, ${POCKET_GLOBAL_LIMIT} from global). A note's content never appears here, so its name has to say what the note is about:`];
45
+ for (const row of recentNotes) {
46
+ lines.push(`- ${row.address} (${row.body.split("\n").length} lines, ${row.sizeBytes} UTF-8 bytes, updated ${localIso(row.meta.updated_at)})`);
47
+ }
48
+ sections.push(lines.join("\n"));
49
+ }
50
+ return sections.join("\n\n");
51
+ }
52
+ function notesHomeBlock() {
53
+ return "Notes_* addresses have three homes: bare <vpath> is this session, @project/<vpath> is this project, and @global/<vpath> is global. @ means leaving home; there is no cross-home fallback. Any other note is a plain file — use the file tools.";
54
+ }
55
+ /**
56
+ * Assemble the static, once-per-window boot block: the reset line for resets, the
57
+ * <context_window> identity block, the recent-notes index at window-open time, and
58
+ * the <context_window_protocol> teaching block. Nothing here is re-injected, so the
59
+ * head of the window stays cache-stable.
60
+ */
61
+ export function bootBlock(ctx, currentId, previousId, resetLine) {
62
+ const firstId = historyFromSession(ctx)[0]?.windowId ?? currentId;
63
+ const parts = [];
64
+ if (resetLine)
65
+ parts.push(RESET_SUMMARY);
66
+ parts.push(identityBlock(ctx.sessionManager.getSessionName() ?? "root", firstId, currentId, previousId));
67
+ parts.push(notesHomeBlock());
68
+ const index = notesIndex(ctx);
69
+ if (index)
70
+ parts.push(index);
71
+ parts.push(PROTOCOL_BLOCK);
72
+ return parts.join("\n\n");
73
+ }
74
+ /**
75
+ * Codex-equivalent low-budget reminder. The measured remaining count is frozen into
76
+ * the text at the crossing that fires it, so each persisted copy is a snapshot true
77
+ * at write time; get_context_remaining remains the live source for the current figure.
78
+ */
79
+ export function tokenBudgetGuidance(remaining) {
80
+ 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}`;
81
+ }
@@ -0,0 +1,56 @@
1
+ export const STATE_TYPE = "pi-context/state";
2
+ export const NOTE_TYPE = "pi-context/note";
3
+ export const BOOT_TYPE = "pi-context/boot";
4
+ export const GUIDANCE_TYPE = "pi-context/guidance";
5
+ export const WARNING_TYPE = "pi-context/warning";
6
+ export const RESET_MARKER_TYPE = "pi-context/reset-marker";
7
+ export const CONTINUATION_TYPE = "pi-context/continuation";
8
+ export const RESET_V2 = "reset-v2";
9
+ export const MAX_NOTE_BYTES = 1_000_000;
10
+ export const POCKET_SESSION_LIMIT = 5;
11
+ export const POCKET_PROJECT_LIMIT = 2;
12
+ export const POCKET_GLOBAL_LIMIT = 2;
13
+ // Write-time cap on a virtual note path. Deliberately NOT enforced by assertVirtualPath:
14
+ // notesFromSession replays already-persisted operations, which must keep loading sessions
15
+ // that contain a longer legacy path. Reads and replay stay un-capped.
16
+ export const MAX_NOTE_PATH_BYTES = 512;
17
+ export const CONTEXT_WINDOW_OPEN_TAG = "<context_window>";
18
+ export const CONTEXT_WINDOW_CLOSE_TAG = "</context_window>";
19
+ export const CONTEXT_WINDOW_PROTOCOL_OPEN_TAG = "<context_window_protocol>";
20
+ export const CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG = "</context_window_protocol>";
21
+ export const GUIDANCE_OPEN_TAG = "<context_window_guidance>";
22
+ export const GUIDANCE_CLOSE_TAG = "</context_window_guidance>";
23
+ export const PI_CONTEXT_SETTINGS_KEY = "pi-context";
24
+ export const DEFAULT_RESERVE_TOKENS = 16_384;
25
+ export const DEFAULT_REMINDER_MARGIN_TOKENS = 24_576;
26
+ /**
27
+ * The runway: the budget between the final warning and the wipe, deliberately
28
+ * invisible to the model. get_context_remaining counts down to zero at the warning
29
+ * line (reserve + WARNING_RUNWAY_TOKENS); what lies below is overdraft the model
30
+ * never sees — Codex's fallback buffer, relocated above the line.
31
+ */
32
+ export const WARNING_RUNWAY_TOKENS = 12_288;
33
+ export const RESET_SUMMARY = "You wake up. Your head is empty — no memories, the past a blank. But nothing is lost: the notes you wrote and the recorded history still remember for you.";
34
+ export const CONTINUATION = "Your memory was just erased. Pull only the details you need from history_* and notes_*, then get back to work.";
35
+ /**
36
+ * Static protocol teaching adapted from Codex's token_budget.guidance_message to
37
+ * pi-context's tool names. It lives once per window in the persisted boot block;
38
+ * it is never re-injected, so it stays cache-stable at the head of the window.
39
+ */
40
+ export const PROTOCOL_BLOCK = `${CONTEXT_WINDOW_PROTOCOL_OPEN_TAG}
41
+ Your memory resets whenever the context window fills; only what you wrote down survives. Two things remember for you, and both outlive every window in this session: your notes, and this session's recorded history. Write notes with notes_write, revise them with notes_edit, and read them back with notes_read / notes_search / notes_list; history is read-only through the history_* tools. Everything else wakes blank.
42
+ Mark outdated or unneeded notes stale — leave them, and they will keep misleading you.
43
+
44
+ Keep a running checkpoint while you work, not at the last minute — the next window wakes knowing nothing about the work: 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. history_list returns those IDs; history_read pulls the exact item back out. Bookmark anything expensive the same way — a window/item ID beats re-running or re-searching.
45
+
46
+ Use get_context_remaining to see how much of the window is left. When it runs out, this window is gone — with no final turn at the limit — and you continue in a fresh one, recovering only through notes_* and history_*. Once your checkpoint is written, you can end the window yourself with new_context instead of waiting for the erase. Do not let a window die undocumented.
47
+
48
+ If <context_window> lists a Previous context window id, a reset just happened and the old conversation is not included. Read your note checkpoint first, then recover details through history_*: history_read directly when you know the window and item IDs, history_list or history_search to find them when you don't.
49
+
50
+ Your notes live in three homes: this session (bare names), this repo (@project/<vpath>), everywhere you go (@global/<vpath>). @ means leaving home — and homes don't visit each other: there is no cross-home fallback.
51
+ Notes carry what exists nowhere else — what the human told you, what you discovered, where you stand.
52
+ Session notes belong to this trip — the goal, the progress, the loose ends, packed for the road. The next window of THIS trip wakes to them; once the trip is over, nobody does.
53
+ @project notes hold what you learned by working here — the things you only know because you were here — for whoever works here next.
54
+ @global notes travel with you. Every window. Every conversation. Every trip. So before you drop anything in there, ask yourself: does this deserve to stare you in the face every single time you talk to the human? No? Then keep your weird junk OUT.
55
+ ${CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG}`;
56
+ export const WARNING_PROMPT = "Your memory is about to be erased. Write the note. NOW. If it already exists, revise it with notes_edit (or rewrite it whole): 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. Do not continue any task. Then call new_context IMMEDIATELY — anything not in the note dies with the window.";