@astrosheep/pi-context 0.25.1 → 0.26.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 (112) hide show
  1. package/README.md +88 -7
  2. package/dist/build-info.json +2 -2
  3. package/dist/extension.js +616 -370
  4. package/dist/src/context/boot.d.ts +24 -0
  5. package/dist/src/context/boot.js +33 -24
  6. package/dist/src/context/budget.d.ts +9 -0
  7. package/dist/src/context/budget.js +19 -15
  8. package/dist/src/context/context-window.d.ts +41 -0
  9. package/dist/src/context/context-window.js +16 -1
  10. package/dist/src/context/prompts.d.ts +20 -0
  11. package/dist/src/context/prompts.js +1 -1
  12. package/dist/src/context/reset-artifacts.d.ts +26 -0
  13. package/dist/src/context/reset-artifacts.js +18 -17
  14. package/dist/src/context/reset-lifecycle.d.ts +89 -0
  15. package/dist/src/context/reset-lifecycle.js +103 -75
  16. package/dist/src/context/runtime.d.ts +3 -0
  17. package/dist/src/context/runtime.js +53 -21
  18. package/dist/src/context/thresholds.d.ts +33 -0
  19. package/dist/src/context/thresholds.js +1 -1
  20. package/dist/src/dream/cli.d.ts +10 -0
  21. package/dist/src/dream/cli.js +1 -1
  22. package/dist/src/dream/doctor.d.ts +2 -0
  23. package/dist/src/dream/doctor.js +6 -2
  24. package/dist/src/dream/gates.d.ts +10 -0
  25. package/dist/src/dream/git.d.ts +21 -0
  26. package/dist/src/dream/lock.d.ts +31 -0
  27. package/dist/src/dream/runner.d.ts +30 -0
  28. package/dist/src/dream/settings.d.ts +16 -0
  29. package/dist/src/history/history-tools.d.ts +2 -0
  30. package/dist/src/history/history.d.ts +57 -0
  31. package/dist/src/index.d.ts +39 -0
  32. package/dist/src/index.js +4 -4
  33. package/dist/src/notes/address.d.ts +26 -0
  34. package/dist/src/notes/address.js +8 -14
  35. package/dist/src/notes/constants.d.ts +3 -0
  36. package/dist/src/notes/constants.js +3 -0
  37. package/dist/src/notes/context.d.ts +10 -0
  38. package/dist/src/notes/context.js +33 -0
  39. package/dist/src/notes/frontmatter.d.ts +46 -0
  40. package/dist/src/notes/frontmatter.js +10 -5
  41. package/dist/src/notes/index.d.ts +4 -0
  42. package/dist/src/notes/index.js +2 -0
  43. package/dist/src/notes/paths.d.ts +21 -0
  44. package/dist/src/notes/paths.js +72 -76
  45. package/dist/src/notes/store.d.ts +94 -0
  46. package/dist/src/notes/store.js +298 -242
  47. package/dist/src/pi/notes/adapter.d.ts +12 -0
  48. package/dist/src/pi/notes/adapter.js +39 -0
  49. package/dist/src/pi/notes/session-replay.d.ts +16 -0
  50. package/dist/src/{notes → pi/notes}/session-replay.js +2 -2
  51. package/dist/src/pi/notes/snapshot.d.ts +33 -0
  52. package/dist/src/{notes/notes-snapshot.js → pi/notes/snapshot.js} +11 -3
  53. package/dist/src/pi/notes/tools.d.ts +2 -0
  54. package/dist/src/{notes → pi/notes}/tools.js +24 -21
  55. package/dist/src/protocol.d.ts +41 -0
  56. package/dist/src/protocol.js +4 -6
  57. package/dist/src/session-reader.d.ts +5 -0
  58. package/dist/src/settings.d.ts +6 -0
  59. package/dist/src/tool-output.d.ts +101 -0
  60. package/dist/src/tool-schema.d.ts +17 -0
  61. package/dist/test/agent-loop.test.d.ts +1 -0
  62. package/dist/test/agent-loop.test.js +318 -19
  63. package/dist/test/boot.integration.test.d.ts +1 -0
  64. package/dist/test/boot.integration.test.js +55 -29
  65. package/dist/test/budget-settings.integration.test.d.ts +1 -0
  66. package/dist/test/budget-settings.integration.test.js +8 -7
  67. package/dist/test/doctor.test.d.ts +1 -0
  68. package/dist/test/doctor.test.js +10 -2
  69. package/dist/test/dream-skill.test.d.ts +1 -0
  70. package/dist/test/dream-skill.test.js +69 -0
  71. package/dist/test/dream.test.d.ts +1 -0
  72. package/dist/test/helpers/extension.d.ts +115 -0
  73. package/dist/test/helpers/extension.js +6 -6
  74. package/dist/test/helpers/notes.d.ts +6 -0
  75. package/dist/test/helpers/notes.js +13 -0
  76. package/dist/test/history.integration.test.d.ts +1 -0
  77. package/dist/test/notes-library.test.d.ts +1 -0
  78. package/dist/test/notes-library.test.js +111 -0
  79. package/dist/test/notes.integration.test.d.ts +1 -0
  80. package/dist/test/notes.integration.test.js +22 -24
  81. package/dist/test/notes.test.d.ts +1 -0
  82. package/dist/test/notes.test.js +137 -7
  83. package/dist/test/reset-lifecycle.test.d.ts +1 -0
  84. package/dist/test/reset-lifecycle.test.js +142 -85
  85. package/docs/architecture.md +8 -8
  86. package/docs/reset-lifecycle.md +63 -79
  87. package/package.json +35 -2
  88. package/playbook.md +33 -32
  89. package/skills/dream/SKILL.md +12 -0
  90. package/src/context/boot.ts +44 -25
  91. package/src/context/budget.ts +25 -17
  92. package/src/context/context-window.ts +16 -1
  93. package/src/context/prompts.ts +2 -2
  94. package/src/context/reset-artifacts.ts +26 -24
  95. package/src/context/reset-lifecycle.ts +117 -111
  96. package/src/context/runtime.ts +50 -22
  97. package/src/context/thresholds.ts +1 -1
  98. package/src/dream/cli.ts +1 -1
  99. package/src/dream/doctor.ts +5 -2
  100. package/src/index.ts +4 -4
  101. package/src/notes/address.ts +9 -15
  102. package/src/notes/constants.ts +3 -0
  103. package/src/notes/context.ts +40 -0
  104. package/src/notes/frontmatter.ts +18 -12
  105. package/src/notes/index.ts +22 -0
  106. package/src/notes/paths.ts +64 -78
  107. package/src/notes/store.ts +308 -244
  108. package/src/pi/notes/adapter.ts +44 -0
  109. package/src/{notes → pi/notes}/session-replay.ts +3 -3
  110. package/src/{notes/notes-snapshot.ts → pi/notes/snapshot.ts} +13 -4
  111. package/src/{notes → pi/notes}/tools.ts +25 -23
  112. package/src/protocol.ts +5 -6
@@ -1,55 +1,70 @@
1
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 { MAX_NOTE_BYTES, MAX_NOTE_PATH_BYTES } from "../protocol.js";
2
+ import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { assertAddress, assertGlobPattern, addressFor, globToRegExp } from "./address.js";
5
+ import { snapshotNotesContext } from "./context.js";
6
+ import { MAX_NOTE_BYTES, MAX_NOTE_PATH_BYTES } from "./constants.js";
6
7
  import { isOrigin, isScope, parseNote, serializeNote, stripLeadingFrontmatter } from "./frontmatter.js";
7
- import { addressFor, assertGlobPattern, assertVirtualPath, globToRegExp } from "./address.js";
8
- import { agentSlug, modelSlug, namespaceSlugs, physicalPath, scopeDir } from "./paths.js";
9
- import { earliestMatchOffsetChars } from "../tool-output.js";
10
- /** Typed store refusal. `line_numbers` and `edit_index` are the edit error's addressing fields. */
8
+ import { namespaceSlugs, physicalPath, scopeDir, SLUG_PATTERN } from "./paths.js";
9
+ /** Typed store refusal. Edit locations are exposed in camelCase. */
11
10
  export class NoteError extends Error {
12
11
  code;
13
- line_numbers;
14
- edit_index;
12
+ lineNumbers;
13
+ editIndex;
15
14
  constructor(code, message, extra = {}) {
16
15
  super(message);
17
16
  this.name = "NoteError";
18
17
  this.code = code;
19
- this.line_numbers = extra.line_numbers;
20
- this.edit_index = extra.edit_index;
18
+ this.lineNumbers = extra.lineNumbers;
19
+ this.editIndex = extra.editIndex;
21
20
  }
22
21
  }
23
22
  const SCOPE_ORDER = ["session", "project", "human", "agent", "model"];
24
- function assertScope(value) {
25
- if (!isScope(value))
26
- throw new NoteError("invalid_scope", `scope must be one of session, project, human, agent, model (got ${JSON.stringify(value)})`);
27
- return value;
23
+ /** Mutations and read-modify-write reads serialize by physical file across all store instances. */
24
+ const pathQueues = new Map();
25
+ function withPathQueue(path, operation) {
26
+ const key = resolve(path);
27
+ const previous = pathQueues.get(key) ?? Promise.resolve();
28
+ const result = previous.then(operation);
29
+ const tail = result.then(() => undefined, () => undefined);
30
+ pathQueues.set(key, tail);
31
+ void tail.then(() => {
32
+ if (pathQueues.get(key) === tail)
33
+ pathQueues.delete(key);
34
+ });
35
+ return result;
28
36
  }
29
- function assertOrigin(value) {
30
- if (!isOrigin(value))
31
- throw new NoteError("invalid_origin", `origin must be one of user, self, external (got ${JSON.stringify(value)})`);
32
- return value;
37
+ function errno(error) {
38
+ return typeof error === "object" && error !== null ? error.code : undefined;
39
+ }
40
+ async function readFileIfExists(path) {
41
+ try {
42
+ return await readFile(path, "utf8");
43
+ }
44
+ catch (error) {
45
+ if (errno(error) === "ENOENT")
46
+ return undefined;
47
+ throw error;
48
+ }
33
49
  }
34
50
  /** Recursively list `.md` files under `dir` as forward-slash virtual paths relative to `base`. */
35
- function walkMarkdown(dir, base = dir) {
51
+ async function walkMarkdown(dir, base = dir) {
36
52
  let entries;
37
53
  try {
38
- entries = readdirSync(dir, { withFileTypes: true });
54
+ entries = await readdir(dir, { withFileTypes: true });
39
55
  }
40
56
  catch (error) {
41
- // A home that has never been created is normal. Every other directory
42
- // failure must reach the boot snapshot boundary instead of masquerading as
43
- // an empty home.
44
- if (typeof error === "object" && error !== null && error.code === "ENOENT")
57
+ // A home that has never been created is normal. Other failures must reach the
58
+ // boot snapshot boundary instead of masquerading as an empty home.
59
+ if (errno(error) === "ENOENT")
45
60
  return [];
46
61
  throw error;
47
62
  }
48
63
  const paths = [];
49
64
  for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
50
- const child = `${dir}/${entry.name}`;
65
+ const child = join(dir, entry.name);
51
66
  if (entry.isDirectory())
52
- paths.push(...walkMarkdown(child, base));
67
+ paths.push(...await walkMarkdown(child, base));
53
68
  else if (entry.isFile() && entry.name.endsWith(".md"))
54
69
  paths.push(child.slice(base.length + 1).split("\\").join("/"));
55
70
  }
@@ -59,49 +74,7 @@ function matcherFor(pattern) {
59
74
  const normalized = assertGlobPattern(pattern);
60
75
  return normalized === undefined ? undefined : globToRegExp(normalized);
61
76
  }
62
- /**
63
- * Every mutation lands through a tmp file renamed into place in the same directory, so a crash
64
- * never leaves a torn note. No cross-process locking: out of scope by decision.
65
- */
66
- function atomicWrite(path, content) {
67
- mkdirSync(dirname(path), { recursive: true });
68
- const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
69
- try {
70
- writeFileSync(tmp, content);
71
- renameSync(tmp, path);
72
- }
73
- catch (error) {
74
- rmSync(tmp, { force: true });
75
- throw error;
76
- }
77
- }
78
- /** Write-time vpath guard: the byte cap is a tool-boundary rule, never a jail rule. */
79
- function assertWritablePath(vpath) {
80
- const bytes = Buffer.byteLength(vpath, "utf8");
81
- if (bytes > MAX_NOTE_PATH_BYTES)
82
- throw new NoteError("too_large", `note path exceeds ${MAX_NOTE_PATH_BYTES} UTF-8 bytes (got ${bytes})`);
83
- }
84
- /** Serialized-size guard applied after the frontmatter is merged, before any bytes are written. */
85
- function assertSerializedSize(content) {
86
- const bytes = Buffer.byteLength(content, "utf8");
87
- if (bytes > MAX_NOTE_BYTES)
88
- throw new NoteError("too_large", `note exceeds ${MAX_NOTE_BYTES} UTF-8 bytes (serialized ${bytes})`);
89
- }
90
- /** Frontmatter block only (the body separator stripped), for the metadata-only diff. */
91
- function frontmatterOf(meta) {
92
- return serializeNote(meta, "").slice(0, -2);
93
- }
94
- /** Named agent/model homes are read-only to whoever is not running there. */
95
- function assertWritableHome(scope, who, ctx) {
96
- if (who === undefined)
97
- return;
98
- const current = scope === "agent" ? agentSlug(ctx) : modelSlug(ctx);
99
- if (who === current)
100
- return;
101
- const home = scope === "agent" ? `@agents/${who}/` : `@models/${who}/`;
102
- throw new NoteError("invalid_scope", `${home} is not your home: writable homes are this session, @project/, @human/, @self/, and the current @model/ home`);
103
- }
104
- function homesForPattern(pattern) {
77
+ async function homesForPattern(pattern, context) {
105
78
  if (!pattern || !pattern.startsWith("@"))
106
79
  return undefined;
107
80
  const head = /^@([^/]+)\//.exec(pattern)?.[1];
@@ -117,25 +90,51 @@ function homesForPattern(pattern) {
117
90
  const scope = head === "agents" ? "agent" : "model";
118
91
  const name = pattern.slice(head.length + 2).split("/")[0] ?? "";
119
92
  if (name.length > 0 && !/[*?]/.test(name))
120
- return [{ scope, who: name }];
121
- return namespaceSlugs(head).map((who) => ({ scope, who }));
93
+ return [{ scope, who: assertWho(name) }];
94
+ return (await namespaceSlugs(head, context.home)).map((who) => ({ scope, who }));
122
95
  }
123
96
  return [];
124
97
  }
125
98
  /** Relative pattern heads resolve to canonical names, so they match rendered addresses. */
126
- function normalizePattern(pattern, ctx) {
99
+ function normalizePattern(pattern, context) {
127
100
  if (!pattern)
128
101
  return pattern;
129
102
  if (pattern.startsWith("@self/"))
130
- return `@agents/${agentSlug(ctx)}/${pattern.slice("@self/".length)}`;
103
+ return `@agents/${context.agent}/${pattern.slice("@self/".length)}`;
131
104
  if (pattern.startsWith("@model/"))
132
- return `@models/${modelSlug(ctx)}/${pattern.slice("@model/".length)}`;
105
+ return `@models/${context.model}/${pattern.slice("@model/".length)}`;
133
106
  return pattern;
134
107
  }
135
- function homesFor(ctx, opts) {
136
- if (opts.scope !== undefined)
137
- return [{ scope: opts.scope, who: opts.who }];
138
- return homesForPattern(opts.pattern) ?? SCOPE_ORDER.map((scope) => ({ scope }));
108
+ function assertScope(value) {
109
+ if (!isScope(value))
110
+ throw new NoteError("invalid_scope", `scope must be one of session, project, human, agent, model (got ${JSON.stringify(value)})`);
111
+ return value;
112
+ }
113
+ function assertOrigin(value) {
114
+ if (!isOrigin(value))
115
+ throw new NoteError("invalid_origin", `origin must be one of user, self, external (got ${JSON.stringify(value)})`);
116
+ return value;
117
+ }
118
+ function assertWho(value) {
119
+ if (typeof value !== "string" || !SLUG_PATTERN.test(value)) {
120
+ throw new NoteError("invalid_scope", "who must be a canonical lowercase slug");
121
+ }
122
+ return value;
123
+ }
124
+ async function homesFor(context, opts) {
125
+ if (opts.scope !== undefined) {
126
+ const scope = assertScope(opts.scope);
127
+ if (opts.who !== undefined) {
128
+ const who = assertWho(opts.who);
129
+ if (scope !== "agent" && scope !== "model")
130
+ throw new NoteError("invalid_scope", "who is only valid with agent or model scope");
131
+ return [{ scope, who }];
132
+ }
133
+ return [{ scope }];
134
+ }
135
+ if (opts.who !== undefined)
136
+ throw new NoteError("invalid_scope", "who requires agent or model scope");
137
+ return await homesForPattern(normalizePattern(opts.pattern, context), context) ?? SCOPE_ORDER.map((scope) => ({ scope }));
139
138
  }
140
139
  /** Line numbers (1-based) of every occurrence of `needle` in `body`. */
141
140
  function matchLineNumbers(body, needle) {
@@ -150,181 +149,238 @@ function matchLineNumbers(body, needle) {
150
149
  }
151
150
  return lines;
152
151
  }
153
- /** Create or overwrite a note; overwrite keeps created_at and every unknown key. */
154
- export function writeNote(ctx, vpath, body, opts) {
155
- assertVirtualPath(vpath);
156
- assertWritablePath(vpath);
157
- const scope = assertScope(opts.scope);
158
- assertWritableHome(scope, opts.who, ctx);
159
- const origin = assertOrigin(opts.origin);
160
- const path = physicalPath(scope, vpath, ctx, opts.who);
161
- const now = Date.now();
162
- const cleanBody = stripLeadingFrontmatter(body);
163
- const existing = existsSync(path) ? parseNote(readFileSync(path, "utf8"), now).meta : undefined;
164
- const meta = existing ?? {
165
- scope,
166
- origin,
167
- status: "active",
168
- stale: false,
169
- created_at: now,
170
- updated_at: now,
171
- last_accessed: now,
172
- access_count: 0,
173
- };
174
- meta.scope = scope;
175
- meta.origin = origin;
176
- meta.status = "active";
177
- meta.stale = opts.stale ?? false;
178
- meta.updated_at = now;
179
- const serialized = serializeNote(meta, cleanBody);
180
- assertSerializedSize(serialized);
181
- atomicWrite(path, serialized);
182
- return { meta };
183
- }
184
- /** Dream harness mutation: metadata changes still use the store's atomic writer. */
185
- export function updateNoteMeta(ctx, vpath, scope, mutate, who) {
186
- assertVirtualPath(vpath);
187
- const path = physicalPath(scope, vpath, ctx, who);
188
- if (!existsSync(path))
189
- throw new NoteError("not_found", `note not found: ${vpath}`);
190
- const parsed = parseNote(readFileSync(path, "utf8"));
191
- const meta = { ...parsed.meta, scope };
192
- mutate(meta);
193
- meta.updated_at = Date.now();
194
- const serialized = serializeNote(meta, parsed.body);
195
- assertSerializedSize(serialized);
196
- atomicWrite(path, serialized);
197
- return { meta, body: parsed.body };
152
+ /** Code-point offset of the earliest query occurrence, matching the serialized read text. */
153
+ function earliestMatchOffsetChars(text, queries) {
154
+ let earliest = -1;
155
+ for (const query of queries) {
156
+ const index = text.indexOf(query);
157
+ if (index < 0)
158
+ continue;
159
+ if (earliest < 0 || index < earliest)
160
+ earliest = index;
161
+ }
162
+ return earliest <= 0 ? 0 : Array.from(text.slice(0, earliest)).length;
198
163
  }
199
- /** Apply body-only edits against one explicit home; origin and stale are its metadata setters. */
200
- export function editNote(ctx, vpath, scope, edits, opts = {}, who) {
201
- assertVirtualPath(vpath);
202
- assertWritablePath(vpath);
203
- assertWritableHome(scope, who, ctx);
204
- const operations = edits ?? [];
205
- if (operations.length === 0 && opts.origin === undefined && opts.stale === undefined) {
206
- throw new NoteError("nothing_to_do", "nothing to do: provide edits or at least one of origin, stale");
164
+ /** Every mutation uses a tmp file renamed into place in the same directory. */
165
+ async function atomicWrite(path, content) {
166
+ await mkdir(dirname(path), { recursive: true });
167
+ const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
168
+ try {
169
+ await writeFile(tmp, content);
170
+ await rename(tmp, path);
207
171
  }
208
- const path = physicalPath(scope, vpath, ctx, who);
209
- if (!existsSync(path))
210
- throw new NoteError("not_found", "note not found");
211
- const raw = readFileSync(path, "utf8");
212
- const { meta, body } = parseNote(raw);
213
- meta.scope = scope;
214
- // Snapshot the pre-edit frontmatter so the diff can name exactly what the setters changed.
215
- const beforeMeta = { ...meta };
216
- // Every edit runs against this one snapshot; nothing is written until all of them succeed,
217
- // so a failing edit leaves the file byte-identical (frontmatter included).
218
- let next = body;
219
- operations.forEach((edit, index) => {
220
- const oldText = edit?.oldText;
221
- const newText = edit?.newText;
222
- if (typeof oldText !== "string" || oldText.length === 0)
223
- throw new NoteError("no_match", `edit ${index}: oldText must be a non-empty string`, { edit_index: index });
224
- if (typeof newText !== "string")
225
- throw new NoteError("no_match", `edit ${index}: newText must be a string`, { edit_index: index });
226
- const lines = matchLineNumbers(next, oldText);
227
- if (lines.length === 0)
228
- throw new NoteError("no_match", `edit ${index}: oldText does not occur in the note body`, { edit_index: index });
229
- if (lines.length > 1 && !opts.replaceAll) {
230
- 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 });
231
- }
232
- // Single replacement is positional splicing, never String.replace: user text must be
233
- // inserted byte-for-byte, without $-pattern substitution ($&, $`, $', $1, $$).
234
- if (opts.replaceAll) {
235
- next = next.split(oldText).join(newText);
236
- }
237
- else {
238
- const matchIndex = next.indexOf(oldText);
239
- next = next.substring(0, matchIndex) + newText + next.substring(matchIndex + oldText.length);
172
+ catch (error) {
173
+ try {
174
+ await rm(tmp, { force: true });
240
175
  }
241
- });
242
- if (opts.origin !== undefined)
243
- meta.origin = assertOrigin(opts.origin);
244
- if (opts.stale !== undefined)
245
- meta.stale = opts.stale;
246
- meta.updated_at = Date.now();
247
- const serialized = serializeNote(meta, next);
248
- assertSerializedSize(serialized);
249
- // pi-edit-style diff: body only for a content edit, frontmatter only for a metadata-only
250
- // update, one combined file diff when both change.
251
- const bodyChanged = body !== next;
252
- const metadataChanged = beforeMeta.origin !== meta.origin || beforeMeta.stale !== meta.stale;
253
- const diff = bodyChanged && metadataChanged
254
- ? generateDiffString(raw, serialized).diff
255
- : bodyChanged
256
- ? generateDiffString(body, next).diff
257
- : metadataChanged
258
- ? generateDiffString(frontmatterOf(beforeMeta), frontmatterOf(meta)).diff
259
- : "";
260
- atomicWrite(path, serialized);
261
- return { meta, applied: operations.length, resolved_scope: scope, diff };
176
+ catch { /* Preserve the original write/rename failure. */ }
177
+ throw error;
178
+ }
179
+ }
180
+ /** The byte cap is a write-time boundary rule, never a path-jail rule. */
181
+ function assertWritablePath(vpath) {
182
+ const bytes = Buffer.byteLength(vpath, "utf8");
183
+ if (bytes > MAX_NOTE_PATH_BYTES)
184
+ throw new NoteError("too_large", `note path exceeds ${MAX_NOTE_PATH_BYTES} UTF-8 bytes (got ${bytes})`);
185
+ }
186
+ function assertSerializedSize(content) {
187
+ const bytes = Buffer.byteLength(content, "utf8");
188
+ if (bytes > MAX_NOTE_BYTES)
189
+ throw new NoteError("too_large", `note exceeds ${MAX_NOTE_BYTES} UTF-8 bytes (serialized ${bytes})`);
190
+ }
191
+ /** Frontmatter block only (the body separator stripped), for metadata-only change inputs. */
192
+ function frontmatterOf(meta) {
193
+ return serializeNote(meta, "").slice(0, -2);
194
+ }
195
+ /** Named agent/model homes are read-only to whoever is not running there. */
196
+ function assertWritableHome(scope, who, context) {
197
+ if (who === undefined)
198
+ return;
199
+ const current = scope === "agent" ? context.agent : context.model;
200
+ if (who === current)
201
+ return;
202
+ const home = scope === "agent" ? `@agents/${who}/` : `@models/${who}/`;
203
+ throw new NoteError("invalid_scope", `${home} is not your home: writable homes are this session, @project/, @human/, @self/, and the current @model/ home`);
262
204
  }
263
- /** Normalize a parsed note exactly as a read does, including its access metadata mutation. */
205
+ /** Normalize parsed metadata exactly as a read does, including its access mutation. */
264
206
  function accessedMeta(meta, scope, now) {
265
207
  const next = { ...meta, scope };
266
- next.last_accessed = now;
267
- next.access_count = (typeof next.access_count === "number" ? next.access_count : 0) + 1;
208
+ next.lastAccessed = now;
209
+ next.accessCount = (typeof next.accessCount === "number" ? next.accessCount : 0) + 1;
268
210
  return next;
269
211
  }
270
- /** Read a note and, as a side effect, bump last_accessed/access_count in the file. */
271
- export function readNote(ctx, vpath, scope, who) {
272
- assertVirtualPath(vpath);
273
- const path = physicalPath(scope, vpath, ctx, who);
274
- if (!existsSync(path))
275
- return undefined;
276
- const now = Date.now();
277
- const parsed = parseNote(readFileSync(path, "utf8"), now);
278
- const meta = accessedMeta(parsed.meta, scope, now);
279
- const text = serializeNote(meta, parsed.body);
280
- atomicWrite(path, text);
281
- return { meta, body: parsed.body, text, resolvedScope: scope };
282
- }
283
- /** Merged rows across homes, most recently updated first (address breaks ties). */
284
- export function listNotes(ctx, opts = {}) {
285
- const matcher = matcherFor(normalizePattern(opts.pattern, ctx));
286
- const rows = [];
287
- for (const home of homesFor(ctx, opts)) {
288
- const scope = home.scope;
289
- const root = scopeDir(scope, ctx, home.who);
290
- for (const path of walkMarkdown(root)) {
291
- const address = addressFor(ctx, scope, path, home.who);
292
- if (matcher && !matcher.test(address))
293
- continue;
294
- const { meta, body } = parseNote(readFileSync(`${root}/${path}`, "utf8"));
212
+ /** Create a store over one validated, immutable snapshot of the supplied explicit identity. */
213
+ export function createNotesStore(input) {
214
+ const context = snapshotNotesContext(input);
215
+ async function write(address, content, options = {}) {
216
+ const stableAddress = address;
217
+ const stableContent = content;
218
+ const stableOptions = { ...options };
219
+ const destination = assertAddress(stableAddress);
220
+ assertWritablePath(destination.path);
221
+ const scope = assertScope(destination.scope);
222
+ assertWritableHome(scope, destination.who, context);
223
+ const origin = assertOrigin(stableOptions.origin ?? "self");
224
+ const path = physicalPath(scope, destination.path, context, destination.who);
225
+ return withPathQueue(path, async () => {
226
+ const now = Date.now();
227
+ const cleanBody = stripLeadingFrontmatter(stableContent);
228
+ const existingRaw = await readFileIfExists(path);
229
+ const existing = existingRaw === undefined ? undefined : parseNote(existingRaw, now).meta;
230
+ const meta = existing ?? {
231
+ scope,
232
+ origin,
233
+ status: "active",
234
+ stale: false,
235
+ createdAt: now,
236
+ updatedAt: now,
237
+ lastAccessed: now,
238
+ accessCount: 0,
239
+ ...(scope === "session" ? { project: context.projectKey } : {}),
240
+ };
295
241
  meta.scope = scope;
296
- rows.push({ address, scope, path, meta, body, sizeBytes: Buffer.byteLength(body, "utf8") });
297
- }
242
+ meta.origin = origin;
243
+ meta.status = "active";
244
+ meta.stale = stableOptions.stale ?? false;
245
+ meta.updatedAt = now;
246
+ const serialized = serializeNote(meta, cleanBody);
247
+ assertSerializedSize(serialized);
248
+ await atomicWrite(path, serialized);
249
+ return { meta };
250
+ });
298
251
  }
299
- rows.sort((a, b) => b.meta.updated_at - a.meta.updated_at || a.address.localeCompare(b.address));
300
- return rows;
301
- }
302
- /** Case-sensitive literal substring search over note bodies, with a match address per line. */
303
- export function searchNotes(ctx, queries, opts = {}) {
304
- const matcher = matcherFor(normalizePattern(opts.pattern, ctx));
305
- const rows = [];
306
- for (const home of homesFor(ctx, opts)) {
307
- const scope = home.scope;
308
- const root = scopeDir(scope, ctx, home.who);
309
- for (const path of walkMarkdown(root)) {
310
- const address = addressFor(ctx, scope, path, home.who);
311
- if (matcher && !matcher.test(address))
312
- continue;
313
- const { meta, body } = parseNote(readFileSync(`${root}/${path}`, "utf8"));
252
+ async function read(address) {
253
+ const stableAddress = address;
254
+ const destination = assertAddress(stableAddress);
255
+ const scope = assertScope(destination.scope);
256
+ const path = physicalPath(scope, destination.path, context, destination.who);
257
+ return withPathQueue(path, async () => {
258
+ const raw = await readFileIfExists(path);
259
+ if (raw === undefined)
260
+ return undefined;
261
+ const now = Date.now();
262
+ const parsed = parseNote(raw, now);
263
+ const meta = accessedMeta(parsed.meta, scope, now);
264
+ const text = serializeNote(meta, parsed.body);
265
+ await atomicWrite(path, text);
266
+ return { meta, body: parsed.body, text, resolvedScope: scope };
267
+ });
268
+ }
269
+ async function edit(address, edits, options = {}) {
270
+ const stableAddress = address;
271
+ const operations = edits === undefined ? [] : edits.map((operation) => ({ ...operation }));
272
+ const stableOptions = { ...options };
273
+ const destination = assertAddress(stableAddress);
274
+ assertWritablePath(destination.path);
275
+ const scope = assertScope(destination.scope);
276
+ assertWritableHome(scope, destination.who, context);
277
+ if (operations.length === 0 && stableOptions.origin === undefined && stableOptions.stale === undefined) {
278
+ throw new NoteError("nothing_to_do", "nothing to do: provide edits or at least one of origin, stale");
279
+ }
280
+ const path = physicalPath(scope, destination.path, context, destination.who);
281
+ return withPathQueue(path, async () => {
282
+ const raw = await readFileIfExists(path);
283
+ if (raw === undefined)
284
+ throw new NoteError("not_found", "note not found");
285
+ const { meta, body } = parseNote(raw);
314
286
  meta.scope = scope;
315
- const serializedBodyOffset = Array.from(serializeNote(accessedMeta(meta, scope, Date.now()), "")).length;
316
- let baseChars = 0;
317
- const matches = [];
318
- for (const [index, line] of body.split("\n").entries()) {
319
- if (queries.some((query) => line.includes(query))) {
320
- matches.push({ line: index + 1, text: line, offsetChars: serializedBodyOffset + baseChars + earliestMatchOffsetChars(line, queries) });
287
+ const beforeMeta = { ...meta };
288
+ let next = body;
289
+ operations.forEach((operation, index) => {
290
+ const oldText = operation?.oldText;
291
+ const newText = operation?.newText;
292
+ if (typeof oldText !== "string" || oldText.length === 0)
293
+ throw new NoteError("no_match", `edit ${index}: oldText must be a non-empty string`, { editIndex: index });
294
+ if (typeof newText !== "string")
295
+ throw new NoteError("no_match", `edit ${index}: newText must be a string`, { editIndex: index });
296
+ const lines = matchLineNumbers(next, oldText);
297
+ if (lines.length === 0)
298
+ throw new NoteError("no_match", `edit ${index}: oldText does not occur in the note body`, { editIndex: index });
299
+ if (lines.length > 1 && !stableOptions.replaceAll) {
300
+ throw new NoteError("ambiguous_edit", `edit ${index}: oldText occurs ${lines.length} times (lines ${lines.join(", ")}); pass replace_all to replace every occurrence`, { lineNumbers: lines, editIndex: index });
301
+ }
302
+ // Positional splicing preserves user replacement text byte-for-byte.
303
+ if (stableOptions.replaceAll)
304
+ next = next.split(oldText).join(newText);
305
+ else {
306
+ const matchIndex = next.indexOf(oldText);
307
+ next = next.substring(0, matchIndex) + newText + next.substring(matchIndex + oldText.length);
308
+ }
309
+ });
310
+ if (stableOptions.origin !== undefined)
311
+ meta.origin = assertOrigin(stableOptions.origin);
312
+ if (stableOptions.stale !== undefined)
313
+ meta.stale = stableOptions.stale;
314
+ meta.updatedAt = Date.now();
315
+ const serialized = serializeNote(meta, next);
316
+ assertSerializedSize(serialized);
317
+ const bodyChanged = body !== next;
318
+ const metadataChanged = beforeMeta.origin !== meta.origin || beforeMeta.stale !== meta.stale;
319
+ let change;
320
+ if (bodyChanged && metadataChanged)
321
+ change = { kind: "file", before: raw, after: serialized };
322
+ else if (bodyChanged)
323
+ change = { kind: "body", before: body, after: next };
324
+ else if (metadataChanged)
325
+ change = { kind: "metadata", before: frontmatterOf(beforeMeta), after: frontmatterOf(meta) };
326
+ else
327
+ change = { kind: "none", before: "", after: "" };
328
+ await atomicWrite(path, serialized);
329
+ return { meta, applied: operations.length, resolvedScope: scope, change };
330
+ });
331
+ }
332
+ async function list(options = {}) {
333
+ const stableOptions = { ...options };
334
+ const matcher = matcherFor(normalizePattern(stableOptions.pattern, context));
335
+ const rows = [];
336
+ for (const home of await homesFor(context, stableOptions)) {
337
+ const scope = home.scope;
338
+ const root = scopeDir(scope, context, home.who);
339
+ for (const path of await walkMarkdown(root)) {
340
+ const address = addressFor(context, scope, path, home.who);
341
+ if (matcher && !matcher.test(address))
342
+ continue;
343
+ const fullPath = join(root, path);
344
+ const raw = await withPathQueue(fullPath, () => readFile(fullPath, "utf8"));
345
+ const { meta, body } = parseNote(raw);
346
+ meta.scope = scope;
347
+ rows.push({ address, scope, path, meta, body, sizeBytes: Buffer.byteLength(body, "utf8") });
348
+ }
349
+ }
350
+ rows.sort((a, b) => b.meta.updatedAt - a.meta.updatedAt || a.address.localeCompare(b.address));
351
+ return rows;
352
+ }
353
+ async function search(queries, options = {}) {
354
+ const stableQueries = [...queries];
355
+ const stableOptions = { ...options };
356
+ const matcher = matcherFor(normalizePattern(stableOptions.pattern, context));
357
+ const rows = [];
358
+ for (const home of await homesFor(context, stableOptions)) {
359
+ const scope = home.scope;
360
+ const root = scopeDir(scope, context, home.who);
361
+ for (const path of await walkMarkdown(root)) {
362
+ const address = addressFor(context, scope, path, home.who);
363
+ if (matcher && !matcher.test(address))
364
+ continue;
365
+ const fullPath = join(root, path);
366
+ const raw = await withPathQueue(fullPath, () => readFile(fullPath, "utf8"));
367
+ const { meta, body } = parseNote(raw);
368
+ meta.scope = scope;
369
+ const serializedBodyOffset = Array.from(serializeNote(accessedMeta(meta, scope, Date.now()), "")).length;
370
+ let baseChars = 0;
371
+ const matches = [];
372
+ for (const [index, line] of body.split("\n").entries()) {
373
+ if (stableQueries.some((query) => line.includes(query))) {
374
+ matches.push({ line: index + 1, text: line, offsetChars: serializedBodyOffset + baseChars + earliestMatchOffsetChars(line, stableQueries) });
375
+ }
376
+ baseChars += Array.from(line).length + 1;
321
377
  }
322
- baseChars += Array.from(line).length + 1;
378
+ if (matches.length > 0)
379
+ rows.push({ address, path, scope, meta, matches });
323
380
  }
324
- if (matches.length > 0)
325
- rows.push({ address, path, scope, meta, matches });
326
381
  }
382
+ rows.sort((a, b) => a.address.localeCompare(b.address));
383
+ return rows;
327
384
  }
328
- rows.sort((a, b) => a.address.localeCompare(b.address));
329
- return rows;
385
+ return { write, read, edit, list, search };
330
386
  }
@@ -0,0 +1,12 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { NotesContext } from "../../notes/context.js";
3
+ /** Pi's default notes root; environment access stays on the host side of the library boundary. */
4
+ export declare function notesRoot(): string;
5
+ /** Translate the current Pi runtime into a fresh, explicit identity snapshot. */
6
+ export declare function agentSlug(_ctx: ExtensionContext): string;
7
+ /** Pi's active model identity is resolved live so a mid-session switch retargets @model. */
8
+ export declare function modelSlug(ctx: ExtensionContext): string;
9
+ /** Translate the current Pi runtime into one explicit notes identity snapshot. */
10
+ export declare function notesContextFromPi(ctx: ExtensionContext, home?: string): NotesContext;
11
+ /** One-time host activation migration from the legacy personal/ directory. */
12
+ export declare function migrateLegacyHomes(home?: string): string | undefined;