@astrosheep/pi-context 0.25.2 → 0.26.1

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