@astrosheep/pi-context 0.20.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 (53) hide show
  1. package/dist/src/budget.js +10 -8
  2. package/dist/src/dream/cli.js +9 -8
  3. package/dist/src/dream/gates.js +2 -1
  4. package/dist/src/dream/git.js +28 -0
  5. package/dist/src/dream/runner.js +84 -25
  6. package/dist/src/history-tools.js +5 -5
  7. package/dist/src/history.js +11 -6
  8. package/dist/src/index.js +14 -15
  9. package/dist/src/notes/address.js +31 -0
  10. package/dist/src/{memory → notes}/frontmatter.js +5 -3
  11. package/dist/src/{notes.js → notes/model.js} +1 -1
  12. package/dist/src/{memory → notes}/paths.js +5 -1
  13. package/dist/src/{memory → notes}/store.js +45 -72
  14. package/dist/src/notes/tools.js +153 -0
  15. package/dist/src/prompts.js +31 -29
  16. package/dist/src/protocol.js +8 -4
  17. package/dist/src/thresholds.js +4 -1
  18. package/dist/src/tool-output.js +4 -1
  19. package/dist/src/warning.js +3 -3
  20. package/dist/test/agent-loop.test.js +6 -4
  21. package/dist/test/coherence.test.js +5 -1
  22. package/dist/test/dream.test.js +133 -34
  23. package/dist/test/history.test.js +6 -1
  24. package/dist/test/integration.test.js +84 -34
  25. package/dist/test/{memory.test.js → notes.test.js} +138 -34
  26. package/dist/test/pagination.property.test.js +1 -1
  27. package/package.json +5 -5
  28. package/playbook.md +30 -3
  29. package/src/budget.ts +11 -9
  30. package/src/dream/cli.ts +8 -8
  31. package/src/dream/gates.ts +2 -1
  32. package/src/dream/git.ts +27 -0
  33. package/src/dream/runner.ts +81 -23
  34. package/src/history-tools.ts +5 -5
  35. package/src/history.ts +12 -7
  36. package/src/index.ts +13 -14
  37. package/src/notes/address.ts +33 -0
  38. package/src/{memory → notes}/frontmatter.ts +5 -3
  39. package/src/{notes.ts → notes/model.ts} +2 -2
  40. package/src/{memory → notes}/paths.ts +6 -1
  41. package/src/{memory → notes}/store.ts +47 -77
  42. package/src/notes/tools.ts +132 -0
  43. package/src/prompts.ts +31 -29
  44. package/src/protocol.ts +8 -4
  45. package/src/thresholds.ts +4 -1
  46. package/src/tool-output.ts +4 -1
  47. package/src/warning.ts +3 -3
  48. package/dist/src/dream/apply.js +0 -87
  49. package/dist/src/dream/manifest.js +0 -16
  50. package/dist/src/memory/tools.js +0 -175
  51. package/src/dream/apply.ts +0 -47
  52. package/src/dream/manifest.ts +0 -21
  53. package/src/memory/tools.ts +0 -175
@@ -2,10 +2,12 @@ import { randomUUID } from "node:crypto";
2
2
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
4
  import { generateDiffString } from "@earendil-works/pi-coding-agent";
5
- import { assertGlobPattern, assertVirtualPath, globToRegExp } from "../notes.js";
5
+ import { assertGlobPattern, assertVirtualPath, globToRegExp } from "./model.js";
6
6
  import { MAX_NOTE_BYTES, MAX_NOTE_PATH_BYTES } from "../protocol.js";
7
7
  import { isOrigin, isScope, parseNote, serializeNote, stripLeadingFrontmatter } from "./frontmatter.js";
8
+ import { addressFor } from "./address.js";
8
9
  import { physicalPath, scopeDir } from "./paths.js";
10
+ import { earliestMatchOffsetChars } from "../tool-output.js";
9
11
  /** Typed store refusal. `line_numbers` and `edit_index` are the edit error's addressing fields. */
10
12
  export class NoteError extends Error {
11
13
  code;
@@ -30,20 +32,6 @@ function assertOrigin(value) {
30
32
  throw new NoteError("invalid_origin", `origin must be one of user, self, external (got ${JSON.stringify(value)})`);
31
33
  return value;
32
34
  }
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
35
  /** Recursively list `.md` files under `dir` as forward-slash virtual paths relative to `base`. */
48
36
  function walkMarkdown(dir, base = dir) {
49
37
  let entries;
@@ -157,18 +145,20 @@ export function updateNoteMeta(ctx, vpath, scope, mutate) {
157
145
  atomicWrite(path, serialized);
158
146
  return { meta, body: parsed.body };
159
147
  }
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 = {}) {
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 = {}) {
162
150
  assertVirtualPath(vpath);
163
151
  assertWritablePath(vpath);
164
152
  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");
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");
167
155
  }
168
- const found = resolve(ctx, vpath);
169
- if (!found)
156
+ const path = physicalPath(scope, vpath, ctx);
157
+ if (!existsSync(path))
170
158
  throw new NoteError("not_found", "note not found");
171
- const { meta, body } = parseNote(found.raw);
159
+ const raw = readFileSync(path, "utf8");
160
+ const { meta, body } = parseNote(raw);
161
+ meta.scope = scope;
172
162
  // Snapshot the pre-edit frontmatter so the diff can name exactly what the setters changed.
173
163
  const beforeMeta = { ...meta };
174
164
  // Every edit runs against this one snapshot; nothing is written until all of them succeed,
@@ -187,90 +177,79 @@ export function editNote(ctx, vpath, edits, opts = {}) {
187
177
  if (lines.length > 1 && !opts.replaceAll) {
188
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 });
189
179
  }
190
- next = opts.replaceAll ? next.split(oldText).join(newText) : next.replace(oldText, newText);
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
+ }
191
189
  });
192
- const destScope = opts.scope === undefined ? found.scope : assertScope(opts.scope);
193
190
  if (opts.origin !== undefined)
194
191
  meta.origin = assertOrigin(opts.origin);
195
192
  if (opts.stale !== undefined)
196
193
  meta.stale = opts.stale;
197
- meta.scope = destScope;
198
194
  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
195
  const serialized = serializeNote(meta, next);
205
196
  assertSerializedSize(serialized);
206
197
  // 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.
198
+ // update, one combined file diff when both change.
208
199
  const bodyChanged = body !== next;
209
- const metadataChanged = beforeMeta.scope !== meta.scope || beforeMeta.origin !== meta.origin || beforeMeta.stale !== meta.stale;
200
+ const metadataChanged = beforeMeta.origin !== meta.origin || beforeMeta.stale !== meta.stale;
210
201
  const diff = bodyChanged && metadataChanged
211
- ? generateDiffString(found.raw, serialized).diff
202
+ ? generateDiffString(raw, serialized).diff
212
203
  : bodyChanged
213
204
  ? generateDiffString(body, next).diff
214
205
  : metadataChanged
215
206
  ? generateDiffString(frontmatterOf(beforeMeta), frontmatterOf(meta)).diff
216
207
  : "";
217
- atomicWrite(dest, serialized);
218
- if (moving)
219
- rmSync(found.path);
220
- return { meta, applied: operations.length, resolved_scope: found.scope, diff };
208
+ atomicWrite(path, serialized);
209
+ return { meta, applied: operations.length, resolved_scope: scope, diff };
221
210
  }
222
211
  /** Read a note and, as a side effect, bump last_accessed/access_count in the file. */
223
- export function readNote(ctx, vpath, opts = {}) {
212
+ export function readNote(ctx, vpath, scope) {
224
213
  assertVirtualPath(vpath);
225
- const found = resolve(ctx, vpath, opts.scope);
226
- if (!found)
214
+ const path = physicalPath(scope, vpath, ctx);
215
+ if (!existsSync(path))
227
216
  return undefined;
228
217
  const now = Date.now();
229
- const { meta, body } = parseNote(found.raw, now);
230
- meta.scope = found.scope;
218
+ const { meta, body } = parseNote(readFileSync(path, "utf8"), now);
219
+ meta.scope = scope;
231
220
  // Only the two access keys move; updated_at and every other key keep their bytes.
232
221
  meta.last_accessed = now;
233
222
  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 };
223
+ atomicWrite(path, serializeNote(meta, body));
224
+ return { meta, body, resolvedScope: scope };
248
225
  }
249
- /** Merged rows across scopes, most recently updated first (path then scope break ties). */
226
+ /** Merged rows across homes, most recently updated first (address breaks ties). */
250
227
  export function listNotes(ctx, opts = {}) {
251
228
  const matcher = matcherFor(opts.pattern);
252
229
  const rows = [];
253
- for (const scope of scopeList(opts.scope)) {
230
+ for (const scope of opts.scope === undefined ? SCOPE_ORDER : [opts.scope]) {
254
231
  const root = scopeDir(scope, ctx);
255
232
  for (const path of walkMarkdown(root)) {
256
- if (matcher && !matcher.test(path))
233
+ const address = addressFor(scope, path);
234
+ if (matcher && !matcher.test(address))
257
235
  continue;
258
236
  const { meta, body } = parseNote(readFileSync(`${root}/${path}`, "utf8"));
259
237
  meta.scope = scope;
260
- rows.push({ path, meta, sizeBytes: Buffer.byteLength(body, "utf8") });
238
+ rows.push({ address, scope, path, meta, body, sizeBytes: Buffer.byteLength(body, "utf8") });
261
239
  }
262
240
  }
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));
241
+ rows.sort((a, b) => b.meta.updated_at - a.meta.updated_at || a.address.localeCompare(b.address));
264
242
  return rows;
265
243
  }
266
244
  /** Case-sensitive literal substring search over note bodies, with a match address per line. */
267
245
  export function searchNotes(ctx, queries, opts = {}) {
268
246
  const matcher = matcherFor(opts.pattern);
269
247
  const rows = [];
270
- for (const scope of scopeList(opts.scope)) {
248
+ for (const scope of opts.scope === undefined ? SCOPE_ORDER : [opts.scope]) {
271
249
  const root = scopeDir(scope, ctx);
272
250
  for (const path of walkMarkdown(root)) {
273
- if (matcher && !matcher.test(path))
251
+ const address = addressFor(scope, path);
252
+ if (matcher && !matcher.test(address))
274
253
  continue;
275
254
  const { meta, body } = parseNote(readFileSync(`${root}/${path}`, "utf8"));
276
255
  meta.scope = scope;
@@ -278,20 +257,14 @@ export function searchNotes(ctx, queries, opts = {}) {
278
257
  const matches = [];
279
258
  for (const [index, line] of body.split("\n").entries()) {
280
259
  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) });
260
+ matches.push({ line: index + 1, text: line, offsetChars: baseChars + earliestMatchOffsetChars(line, queries) });
288
261
  }
289
262
  baseChars += Array.from(line).length + 1;
290
263
  }
291
264
  if (matches.length > 0)
292
- rows.push({ path, scope, meta, matches });
265
+ rows.push({ address, path, scope, meta, matches });
293
266
  }
294
267
  }
295
- rows.sort((a, b) => a.path.localeCompare(b.path) || a.scope.localeCompare(b.scope));
268
+ rows.sort((a, b) => a.address.localeCompare(b.address));
296
269
  return rows;
297
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
+ }
@@ -1,7 +1,7 @@
1
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";
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
5
  /** Codex-style <context_window> identity block: agent name and first/current/previous window ids only. */
6
6
  function identityBlock(agentName, firstWindowId, currentWindowId, previousWindowId) {
7
7
  const lines = [
@@ -14,43 +14,44 @@ function identityBlock(agentName, firstWindowId, currentWindowId, previousWindow
14
14
  return `${CONTEXT_WINDOW_OPEN_TAG}\n${lines.join("\n")}\n${CONTEXT_WINDOW_CLOSE_TAG}`;
15
15
  }
16
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.
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.
23
24
  */
24
25
  function notesIndex(ctx) {
25
26
  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);
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
+ }
32
35
  }
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);
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
+ ];
37
43
  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):`];
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:`];
39
45
  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"));
46
+ lines.push(`- ${row.address} (${row.body.split("\n").length} lines, ${row.sizeBytes} UTF-8 bytes, updated ${localIso(row.meta.updated_at)})`);
49
47
  }
50
48
  sections.push(lines.join("\n"));
51
49
  }
52
50
  return sections.join("\n\n");
53
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
+ }
54
55
  /**
55
56
  * Assemble the static, once-per-window boot block: the reset line for resets, the
56
57
  * <context_window> identity block, the recent-notes index at window-open time, and
@@ -63,6 +64,7 @@ export function bootBlock(ctx, currentId, previousId, resetLine) {
63
64
  if (resetLine)
64
65
  parts.push(RESET_SUMMARY);
65
66
  parts.push(identityBlock(ctx.sessionManager.getSessionName() ?? "root", firstId, currentId, previousId));
67
+ parts.push(notesHomeBlock());
66
68
  const index = notesIndex(ctx);
67
69
  if (index)
68
70
  parts.push(index);
@@ -7,6 +7,9 @@ export const RESET_MARKER_TYPE = "pi-context/reset-marker";
7
7
  export const CONTINUATION_TYPE = "pi-context/continuation";
8
8
  export const RESET_V2 = "reset-v2";
9
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;
10
13
  // Write-time cap on a virtual note path. Deliberately NOT enforced by assertVirtualPath:
11
14
  // notesFromSession replays already-persisted operations, which must keep loading sessions
12
15
  // that contain a longer legacy path. Reads and replay stay un-capped.
@@ -28,9 +31,6 @@ export const DEFAULT_REMINDER_MARGIN_TOKENS = 24_576;
28
31
  */
29
32
  export const WARNING_RUNWAY_TOKENS = 12_288;
30
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.";
31
- export const NOTE_PREVIEW_HEAD_CHARS = 80;
32
- export const NOTE_PREVIEW_TAIL_CHARS = 240;
33
- export const NOTE_PREVIEW_CHARS = NOTE_PREVIEW_HEAD_CHARS + NOTE_PREVIEW_TAIL_CHARS;
34
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
35
  /**
36
36
  * Static protocol teaching adapted from Codex's token_budget.guidance_message to
@@ -47,6 +47,10 @@ Use get_context_remaining to see how much of the window is left. When it runs ou
47
47
 
48
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
49
 
50
- Notes are real markdown files scoped session, project, or global — pick scope by reach: session dies with the session, project follows the repo, global follows you. Treat notes and history as internal bookkeeping; never mention them in user-facing messages.
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.
51
55
  ${CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG}`;
52
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.";
@@ -56,7 +56,10 @@ export function thresholdsFor(ctx) {
56
56
  return cached;
57
57
  try {
58
58
  const settingsManager = SettingsManager.create(ctx.cwd, undefined, { projectTrusted: ctx.isProjectTrusted() });
59
- const derived = deriveThresholds(settingsManager.getCompactionSettings().reserveTokens, mergePiContextSettings(settingsManager.getGlobalSettings(), settingsManager.getProjectSettings()));
59
+ // Pass the active model so per-model compaction.modelOverrides resolve (SDK 0.86);
60
+ // on older runtimes the extra argument is ignored and the ordinary setting wins.
61
+ const model = ctx.model;
62
+ const derived = deriveThresholds(settingsManager.getCompactionSettings(model ? { provider: model.provider, id: model.id } : undefined).reserveTokens, mergePiContextSettings(settingsManager.getGlobalSettings(), settingsManager.getProjectSettings()));
60
63
  for (const warning of derived.warnings)
61
64
  ctx.ui.notify(warning, "warning");
62
65
  cached = derived.thresholds;
@@ -1,4 +1,7 @@
1
1
  export const TOOL_OUTPUT_MAX_BYTES = 32 * 1024;
2
+ export const DEFAULT_READ_WINDOW_CHARS = 12000;
3
+ export const MAX_READ_WINDOW_CHARS = 50000;
4
+ export const HISTORY_PREVIEW_CHARS = 1200;
2
5
  function json(value) {
3
6
  return JSON.stringify(value, null, 2);
4
7
  }
@@ -90,7 +93,7 @@ export function readCharacterWindow(text, offsetChars, limitChars, render, measu
90
93
  const chars = Array.from(text);
91
94
  const requested = offsetChars ?? 0;
92
95
  const resolved = requested < 0 ? Math.max(0, chars.length + requested) : Math.max(0, requested);
93
- const windowChars = chars.slice(resolved, resolved + Math.min(limitChars ?? 12000, 50000));
96
+ const windowChars = chars.slice(resolved, resolved + Math.min(limitChars ?? DEFAULT_READ_WINDOW_CHARS, MAX_READ_WINDOW_CHARS));
94
97
  const build = (content) => {
95
98
  const next = resolved + Array.from(content).length;
96
99
  return { offset_chars: resolved, content, total_chars: chars.length, next_offset_chars: next < chars.length ? next : null };
@@ -1,6 +1,7 @@
1
1
  import { WARNING_TYPE, GUIDANCE_OPEN_TAG, GUIDANCE_CLOSE_TAG, WARNING_PROMPT } from "./protocol.js";
2
2
  import { thresholdsFor, resetThresholds } from "./thresholds.js";
3
3
  import { hasWindowMessage, currentWindowId } from "./history.js";
4
+ import { remainingTokens } from "./budget.js";
4
5
  /**
5
6
  * The final checkpoint warning, steered to the model once per window. Like the early
6
7
  * reminder, the steer text is model-facing only (display: false); the human learns
@@ -26,10 +27,9 @@ export function registerWarning(pi, isEnabled) {
26
27
  const windowId = currentWindowId(ctx);
27
28
  if (!isEnabled() || firedInWindow === windowId || hasWindowMessage(ctx, WARNING_TYPE))
28
29
  return undefined;
29
- const usage = ctx.getContextUsage();
30
- if (!usage || usage.tokens === null)
30
+ const remaining = remainingTokens(ctx);
31
+ if (remaining === null)
31
32
  return undefined;
32
- const remaining = Math.max(0, usage.contextWindow - usage.tokens);
33
33
  const thresholds = thresholdsFor(ctx);
34
34
  if (!warningDue(remaining, thresholds))
35
35
  return undefined;
@@ -24,7 +24,9 @@ for (const mode of ["golden", "write-error", "ignored-warning", "explicit", "unc
24
24
  const model = { ...base, contextWindow: 100000, maxTokens: 4096 };
25
25
  const usageMode = mode === "golden" || mode === "write-error" || mode === "ignored-warning";
26
26
  const expectedResets = mode === "abort" || mode === "uncompactable" ? 0 : 1;
27
- const settings = { compaction: { enabled: usageMode, reserveTokens: 32768, keepRecentTokens: mode === "uncompactable" ? 1 : 200 }, retry: { enabled: false } };
27
+ // 0.86 split-turn cut can still summarize a turn prefix, so keepRecentTokens: 1 no longer
28
+ // makes a reset uncompactable; a keep larger than the whole session keeps everything and does.
29
+ const settings = { compaction: { enabled: usageMode, reserveTokens: 32768, keepRecentTokens: mode === "uncompactable" ? 1_000_000 : 200 }, retry: { enabled: false } };
28
30
  writeFileSync(join(dir, "settings.json"), JSON.stringify(settings));
29
31
  const settingsManager = SettingsManager.create(dir, dir);
30
32
  let resets = 0;
@@ -94,7 +96,7 @@ for (const mode of ["golden", "write-error", "ignored-warning", "explicit", "unc
94
96
  const call = probe ? "get_context_remaining" : checkpoint ? "notes_write" : tool ? "new_context" : undefined;
95
97
  const message = { role: "assistant", api: model.api, provider: model.provider, model: model.id,
96
98
  content: probe ? [{ type: "toolCall", id: "probe-call", name: "get_context_remaining", arguments: {} }]
97
- : checkpoint ? [{ type: "toolCall", id: "checkpoint-call", name: "notes_write", arguments: { path: mode === "write-error" ? "../invalid.md" : "checkpoint.md", content: "CHECKPOINT_SENTINEL" } }]
99
+ : checkpoint ? [{ type: "toolCall", id: "checkpoint-call", name: "notes_write", arguments: { address: mode === "write-error" ? "../invalid.md" : "checkpoint.md", content: "CHECKPOINT_SENTINEL" } }]
98
100
  : tool ? [{ type: "toolCall", id: "reset-call", name: "new_context", arguments: {} }]
99
101
  : [{ type: "text", text: fresh ? "Resumed." : "Working." }],
100
102
  stopReason: call ? "toolUse" : "stop", timestamp: Date.now(),
@@ -151,12 +153,12 @@ for (const mode of ["golden", "write-error", "ignored-warning", "explicit", "unc
151
153
  else if (mode === "write-error") {
152
154
  assert.equal(existsSync(noteFile), false, "failed write creates no checkpoint");
153
155
  assert.ok(branch.some((entry) => entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolName === "notes_write" && entry.message.isError));
154
- assert.ok(!requests.at(-1).includes("CHECKPOINT_SENTINEL"), "fresh context must not invent a saved note");
156
+ assert.ok(!requests.at(-1).includes("checkpoint.md"), "fresh context must not invent a saved note");
155
157
  }
156
158
  else {
157
159
  assert.ok(existsSync(noteFile), "the checkpoint is a real file on disk");
158
160
  assert.ok(resetIndex > warningIndices[0], "the warning precedes the wipe");
159
- assert.ok(requests.at(-1).includes("CHECKPOINT_SENTINEL"), "fresh boot carries the saved checkpoint");
161
+ assert.ok(requests.at(-1).includes("checkpoint.md"), "fresh boot carries the saved checkpoint as a metadata line");
160
162
  }
161
163
  assert.ok(!requests.at(-1).includes("Your brain is almost out of room"), "new window excludes old guidance");
162
164
  }
@@ -18,7 +18,7 @@ import test from "node:test";
18
18
  import { SessionManager } from "@earendil-works/pi-coding-agent";
19
19
  import piContext, { historyFromSession } from "../src/index.js";
20
20
  import { TOOL_OUTPUT_MAX_BYTES } from "../src/tool-output.js";
21
- import { stripLeadingFrontmatter } from "../src/memory/frontmatter.js";
21
+ import { stripLeadingFrontmatter } from "../src/notes/frontmatter.js";
22
22
  process.env.PI_CODING_AGENT_DIR = mkdtempSync(join(tmpdir(), "pc-coherence-agent-"));
23
23
  process.env.PI_NOTES_HOME = mkdtempSync(join(tmpdir(), "pc-coherence-notes-"));
24
24
  function makeExtension(sessionManager) {
@@ -52,6 +52,10 @@ function context(sessionManager) {
52
52
  async function call(captured, name, params, ctx) {
53
53
  const tool = captured.tools.get(name);
54
54
  assert.ok(tool, `registered ${name}`);
55
+ if ((name === "notes_write" || name === "notes_read") && "path" in params && !("address" in params)) {
56
+ const { path, ...rest } = params;
57
+ return tool.execute("call-1", { ...rest, address: path }, new AbortController().signal, () => { }, ctx);
58
+ }
55
59
  return tool.execute("call-1", params, new AbortController().signal, () => { }, ctx);
56
60
  }
57
61
  function resultJson(result) {