@astrosheep/pi-context 0.26.0 → 0.26.2

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 (37) hide show
  1. package/README.md +2 -2
  2. package/dist/build-info.json +2 -2
  3. package/dist/extension.js +69 -64
  4. package/dist/src/context/budget.js +37 -18
  5. package/dist/src/context/prompts.js +3 -3
  6. package/dist/src/dream/doctor.js +0 -4
  7. package/dist/src/notes/frontmatter.d.ts +1 -2
  8. package/dist/src/notes/frontmatter.js +1 -6
  9. package/dist/src/notes/store.js +24 -40
  10. package/dist/src/protocol.d.ts +4 -4
  11. package/dist/src/protocol.js +4 -4
  12. package/dist/src/text-match.d.ts +5 -0
  13. package/dist/src/text-match.js +15 -0
  14. package/dist/src/tool-output.d.ts +1 -5
  15. package/dist/src/tool-output.js +1 -15
  16. package/dist/test/agent-loop.test.js +78 -10
  17. package/dist/test/boot.integration.test.js +5 -7
  18. package/dist/test/budget-settings.integration.test.js +20 -5
  19. package/dist/test/doctor.test.js +4 -5
  20. package/dist/test/helpers/extension-test-environment.d.ts +1 -0
  21. package/dist/test/helpers/extension-test-environment.js +9 -0
  22. package/dist/test/helpers/extension.d.ts +0 -11
  23. package/dist/test/helpers/extension.js +1 -75
  24. package/dist/test/history.integration.test.js +17 -19
  25. package/dist/test/notes-library.test.js +17 -0
  26. package/dist/test/notes.integration.test.js +47 -31
  27. package/dist/test/notes.test.js +51 -44
  28. package/docs/architecture.md +1 -1
  29. package/package.json +1 -1
  30. package/src/context/budget.ts +35 -18
  31. package/src/context/prompts.ts +3 -3
  32. package/src/dream/doctor.ts +0 -3
  33. package/src/notes/frontmatter.ts +1 -5
  34. package/src/notes/store.ts +24 -37
  35. package/src/protocol.ts +4 -4
  36. package/src/text-match.ts +13 -0
  37. package/src/tool-output.ts +2 -14
@@ -7,11 +7,9 @@ import { renderBootBlock } from "../src/context/prompts.js";
7
7
  import { localIso } from "../src/notes/frontmatter.js";
8
8
  import { listNotes, physicalPath, scopeDir } from "./helpers/notes.js";
9
9
  import { TOOL_OUTPUT_MAX_BYTES } from "../src/tool-output.js";
10
- import { assertWithinBudget, call, context, explicitBoot, installExtensionTestEnvironment, makeExtension, manager, resultJson, resultRead, } from "./helpers/extension.js";
11
- const testEnvironment = installExtensionTestEnvironment("pi-context-integration");
12
- test.beforeEach(() => testEnvironment.beforeEach());
13
- test.afterEach(() => testEnvironment.afterEach());
14
- test.after(() => testEnvironment.dispose());
10
+ import { assertWithinBudget, call, context, explicitBoot, makeExtension, manager, resultJson, resultRead, } from "./helpers/extension.js";
11
+ import { installExtensionTestHooks } from "./helpers/extension-test-environment.js";
12
+ const testEnvironment = installExtensionTestHooks("pi-context-integration");
15
13
  test("notes_list is most-recently-updated first across merged scopes", async () => {
16
14
  const session = manager();
17
15
  const captured = makeExtension(session);
@@ -37,47 +35,47 @@ test("notes are real files that persist across sessions and round-trip Unicode",
37
35
  const original = manager();
38
36
  const captured = makeExtension(original);
39
37
  const ctx = context(original);
40
- await call(captured, "notes_write", { path: "checkpoint/进度.md", content: "第一行\nneedle Café", scope: "human" }, ctx);
38
+ await call(captured, "notes_write", { address: "@human/checkpoint/进度.md", content: "第一行\nneedle Café" }, ctx);
41
39
  // A brand-new session over the same physical root sees the human note: nothing is replayed
42
40
  // from session entries, the file itself is the durable artifact.
43
41
  const restored = manager();
44
42
  const restoredCaptured = makeExtension(restored);
45
43
  const restoredCtx = context(restored);
46
- const rawRead = await call(restoredCaptured, "notes_read", { path: "checkpoint/进度.md", scope: "human", offset_chars: -4 }, restoredCtx);
44
+ const rawRead = await call(restoredCaptured, "notes_read", { address: "@human/checkpoint/进度.md", offset_chars: -4 }, restoredCtx);
47
45
  const read = resultRead(rawRead);
48
46
  assert.equal(read.details.address, "@human/checkpoint/进度.md");
49
47
  assert.equal(read.content, "Café", "a negative offset reads the body tail in one call");
50
- const searched = resultJson(await call(restoredCaptured, "notes_search", { query: "Café", scope: "human" }, restoredCtx));
48
+ const searched = resultJson(await call(restoredCaptured, "notes_search", { pattern: "@human/**", query: "Café" }, restoredCtx));
51
49
  assert.equal(searched.files[0]?.matches[0]?.line, 2);
52
- const listedFiles = resultJson(await call(restoredCaptured, "notes_list", { pattern: "checkpoint/**", scope: "human" }, restoredCtx));
50
+ const listedFiles = resultJson(await call(restoredCaptured, "notes_list", { pattern: "@human/checkpoint/**" }, restoredCtx));
53
51
  assert.equal(listedFiles.files.length, 1, "glob ** crosses into the checkpoint directory");
54
- assert.equal(listedFiles.files[0]?.path, "checkpoint/进度.md");
52
+ assert.equal(listedFiles.files[0]?.address, "@human/checkpoint/进度.md");
55
53
  // A single-segment * never crosses `/`, so a nested-only store matches nothing at the root.
56
- const rootOnly = resultJson(await call(restoredCaptured, "notes_list", { pattern: "*", scope: "human" }, restoredCtx));
54
+ const rootOnly = resultJson(await call(restoredCaptured, "notes_list", { pattern: "@human/*" }, restoredCtx));
57
55
  assert.equal(rootOnly.files.length, 0, "glob * stays within one segment");
58
56
  assert.equal(searched.files[0]?.updated_at, listedFiles.files[0]?.updated_at);
59
- await assert.rejects(() => call(captured, "notes_write", { path: "../escape", content: "x" }, ctx), /unsupported component/);
57
+ await assert.rejects(() => call(captured, "notes_write", { address: "../escape", content: "x" }, ctx), /unsupported component/);
60
58
  });
61
59
  test("stale lifecycle: writes and metadata-only edits close and revive a note", async () => {
62
60
  const sm = manager();
63
61
  const captured = makeExtension(sm);
64
62
  const ctx = context(sm);
65
- await call(captured, "notes_write", { path: "journal.md", content: "log line" }, ctx);
63
+ await call(captured, "notes_write", { address: "journal.md", content: "log line" }, ctx);
66
64
  // metadata-only: content unchanged, flag set, applied 0
67
- const markOnly = resultJson(await call(captured, "notes_edit", { path: "journal.md", stale: true }, ctx));
65
+ const markOnly = resultJson(await call(captured, "notes_edit", { address: "journal.md", stale: true }, ctx));
68
66
  assert.equal(markOnly.applied, 0);
69
67
  assert.equal((await listNotes(ctx, { scope: "session" }))[0]?.meta.stale, true);
70
- assert.equal(resultRead(await call(captured, "notes_read", { path: "journal.md" }, ctx)).content.endsWith("log line"), true, "mark-only leaves content unchanged");
68
+ assert.equal(resultRead(await call(captured, "notes_read", { address: "journal.md" }, ctx)).content.endsWith("log line"), true, "mark-only leaves content unchanged");
71
69
  // explicit revive
72
- const revived = resultJson(await call(captured, "notes_edit", { path: "journal.md", stale: false }, ctx));
70
+ const revived = resultJson(await call(captured, "notes_edit", { address: "journal.md", stale: false }, ctx));
73
71
  assert.equal((await listNotes(ctx, { scope: "session" }))[0]?.meta.stale, false, "stale:false revives");
74
72
  // write+stale closure then plain write revival
75
- await call(captured, "notes_write", { path: "journal.md", content: "final", stale: true }, ctx);
73
+ await call(captured, "notes_write", { address: "journal.md", content: "final", stale: true }, ctx);
76
74
  assert.equal((await listNotes(ctx, { scope: "session" }))[0]?.meta.stale, true);
77
- await call(captured, "notes_write", { path: "journal.md", content: "reopened" }, ctx);
75
+ await call(captured, "notes_write", { address: "journal.md", content: "reopened" }, ctx);
78
76
  assert.equal((await listNotes(ctx, { scope: "session" }))[0]?.meta.stale, false, "writing without stale revives");
79
77
  // metadata-only on a missing path is the typed not-found arm
80
- const missing = resultJson(await call(captured, "notes_edit", { path: "missing.md", stale: true }, ctx));
78
+ const missing = resultJson(await call(captured, "notes_edit", { address: "missing.md", stale: true }, ctx));
81
79
  assert.equal(missing.error, "note not found");
82
80
  });
83
81
  test("the filesystem notes loader treats an absent home as empty but surfaces a real directory read failure", async () => {
@@ -135,6 +133,24 @@ test("boot note acquisition is one closed snapshot and isolates one or all faile
135
133
  assert.ok(rendered.includes("PROJECT_MAP_BODY") && rendered.includes("AGENT_MAP_BODY"), "MAP residency comes from the snapshot");
136
134
  assert.ok(rendered.includes("session.md") && rendered.includes("@human/human.md"), "pocket rows come from the same snapshot");
137
135
  assert.equal(rendered.includes("SESSION_POCKET_BODY"), false, "pocket bodies stay excluded");
136
+ assert.match(rendered, /- session\.md · 19 chars · \d+s ago/);
137
+ assert.equal(rendered.includes("UTF-8 bytes"), false, "pocket rows omit implementation-oriented byte counts");
138
+ const expanded = await loadNotesSnapshot(ctx, (_ctx, scope) => {
139
+ if (scope === "project" || scope === "human" || scope === "agent" || scope === "model") {
140
+ return Array.from({ length: 6 }, (_, i) => note(scope, `note-${i}.md`, `@${scope === "agent" ? "agents/root" : scope === "model" ? "models/default" : scope}/note-${i}.md`, `body ${i}`));
141
+ }
142
+ return rows.get(scope) ?? [];
143
+ });
144
+ const expandedText = renderBootBlock({ ...renderData, notes: expanded });
145
+ for (const prefix of ["@project", "@human", "@agents/root"]) {
146
+ for (let i = 0; i < 5; i++)
147
+ assert.ok(expandedText.includes(`- ${prefix}/note-${i}.md · `), `${prefix} includes note ${i}`);
148
+ assert.equal(expandedText.includes(`- ${prefix}/note-5.md · `), false, `${prefix} is capped at five`);
149
+ }
150
+ for (let i = 0; i < 3; i++)
151
+ assert.ok(expandedText.includes(`- @models/default/note-${i}.md · `), `@model includes note ${i}`);
152
+ assert.equal(expandedText.includes("- @models/default/note-3.md · "), false, "@model is capped at three");
153
+ assert.match(expandedText, /5 from @project, 5 from @human, 5 from @self, 3 from @model/);
138
154
  const readFailure = (code) => Object.assign(new Error("scripted read failure"), { code });
139
155
  const oneFailed = await loadNotesSnapshot(ctx, (_ctx, scope) => {
140
156
  if (scope === "human")
@@ -181,8 +197,8 @@ test("an over-budget note is delivered as a prefix and resumed by next_offset_ch
181
197
  const ctx = context(session);
182
198
  const huge = `H${"x".repeat(TOOL_OUTPUT_MAX_BYTES * 2)}`;
183
199
  const text = `${huge}\ntail line`;
184
- await call(captured, "notes_write", { path: "huge.md", content: text }, ctx);
185
- const rawFirst = await call(captured, "notes_read", { path: "huge.md" }, ctx);
200
+ await call(captured, "notes_write", { address: "huge.md", content: text }, ctx);
201
+ const rawFirst = await call(captured, "notes_read", { address: "huge.md" }, ctx);
186
202
  assertWithinBudget(rawFirst, "single oversized note");
187
203
  const first = resultRead(rawFirst);
188
204
  assert.ok(first.content.length > 0, "the page is not empty");
@@ -196,7 +212,7 @@ test("an over-budget note is delivered as a prefix and resumed by next_offset_ch
196
212
  const parts = [first.content];
197
213
  let offset = first.next_offset_chars;
198
214
  while (offset !== null) {
199
- const rawChunk = await call(captured, "notes_read", { path: "huge.md", offset_chars: offset }, ctx);
215
+ const rawChunk = await call(captured, "notes_read", { address: "huge.md", offset_chars: offset }, ctx);
200
216
  assertWithinBudget(rawChunk, `huge note chunk at ${offset}`);
201
217
  const chunk = resultRead(rawChunk);
202
218
  assert.equal(chunk.offset_chars, offset, "the response echoes the resolved absolute offset");
@@ -205,7 +221,7 @@ test("an over-budget note is delivered as a prefix and resumed by next_offset_ch
205
221
  }
206
222
  assert.ok(parts.join("").endsWith(text), "the cursors reconstruct the body exactly");
207
223
  // A success carries structured details; an error stays a JSON envelope with no details.
208
- const missingResult = await call(captured, "notes_read", { path: "no-such.md" }, ctx);
224
+ const missingResult = await call(captured, "notes_read", { address: "no-such.md" }, ctx);
209
225
  const missing = resultJson(missingResult);
210
226
  assert.deepEqual(Object.keys(missing).sort(), ["address", "error"], "the read error carries exactly error and address");
211
227
  assert.equal(missing.error, "note not found");
@@ -217,8 +233,8 @@ test("an over-budget note search match is a named prefix with an honest line add
217
233
  const ctx = context(session);
218
234
  // The query sits behind a prefix, so its address is a real body-absolute offset, not line 1.
219
235
  const hugeLine = `${'p'.repeat(500)}needle ${"y".repeat(TOOL_OUTPUT_MAX_BYTES * 2)}`;
220
- await call(captured, "notes_write", { path: "a.md", content: "needle small" }, ctx);
221
- await call(captured, "notes_write", { path: "search.md", content: hugeLine }, ctx);
236
+ await call(captured, "notes_write", { address: "a.md", content: "needle small" }, ctx);
237
+ await call(captured, "notes_write", { address: "search.md", content: hugeLine }, ctx);
222
238
  const pages = [];
223
239
  let cursor = 0;
224
240
  let next = 0;
@@ -230,7 +246,7 @@ test("an over-budget note search match is a named prefix with an honest line add
230
246
  if (next !== null)
231
247
  cursor = next;
232
248
  }
233
- assert.deepEqual(pages.map((file) => file.path), ["a.md", "search.md"], "pagination reaches the oversized file instead of looping");
249
+ assert.deepEqual(pages.map((file) => file.address), ["a.md", "search.md"], "pagination reaches the oversized file instead of looping");
234
250
  const oversized = pages[1];
235
251
  assert.equal(oversized.matches_total, 1, "the file's full match count is named even though the line was cut");
236
252
  assert.equal(oversized.matches.length, 1);
@@ -239,13 +255,13 @@ test("an over-budget note search match is a named prefix with an honest line add
239
255
  assert.ok(hugeLine.startsWith(match.text), "the match text is a plain prefix of the line");
240
256
  assert.equal(match.text.includes("…"), false, "no marker is appended to the match text");
241
257
  assert.equal(match.line, 1, "the informational line number survives");
242
- const atMatch = resultRead(await call(captured, "notes_read", { path: "search.md", offset_chars: match.offset_chars }, ctx));
258
+ const atMatch = resultRead(await call(captured, "notes_read", { address: "search.md", offset_chars: match.offset_chars }, ctx));
243
259
  assert.ok(atMatch.content.startsWith("needle"), "the search offset starts a read at the matched substring");
244
260
  // The body is reconstructible by following notes_read's cursor from the start of the file.
245
261
  const parts = [];
246
262
  let offset = 0;
247
263
  while (offset !== null) {
248
- const rawChunk = await call(captured, "notes_read", { path: "search.md", offset_chars: offset }, ctx);
264
+ const rawChunk = await call(captured, "notes_read", { address: "search.md", offset_chars: offset }, ctx);
249
265
  assertWithinBudget(rawChunk, `search.md chunk at ${offset}`);
250
266
  const chunk = resultRead(rawChunk);
251
267
  assert.equal(chunk.offset_chars, offset, "the read echoes the resolved address");
@@ -258,10 +274,10 @@ test("notes_search scopes by glob pattern; a non-matching pattern is an empty pa
258
274
  const session = manager();
259
275
  const captured = makeExtension(session);
260
276
  const ctx = context(session);
261
- await call(captured, "notes_write", { path: "deep/nested/a.md", content: "needle here" }, ctx);
262
- await call(captured, "notes_write", { path: "top.md", content: "needle there" }, ctx);
277
+ await call(captured, "notes_write", { address: "deep/nested/a.md", content: "needle here" }, ctx);
278
+ await call(captured, "notes_write", { address: "top.md", content: "needle there" }, ctx);
263
279
  const scoped = resultJson(await call(captured, "notes_search", { query: "needle", pattern: "deep/**" }, ctx));
264
- assert.deepEqual(scoped.files.map((file) => file.path), ["deep/nested/a.md"], "a glob scopes the search to the subtree");
280
+ assert.deepEqual(scoped.files.map((file) => file.address), ["deep/nested/a.md"], "a glob scopes the search to the subtree");
265
281
  const none = resultJson(await call(captured, "notes_search", { query: "needle", pattern: "absent/**" }, ctx));
266
282
  assert.equal(none.error, undefined, "a non-matching pattern is not an error");
267
283
  assert.deepEqual(none.files, [], "a non-matching pattern is an empty page");
@@ -14,11 +14,9 @@ import test from "node:test";
14
14
  import { parseNote } from "../src/notes/frontmatter.js";
15
15
  import { projectKey } from "../src/notes/paths.js";
16
16
  import { listNotes, physicalPath, scopeDir } from "./helpers/notes.js";
17
- import { call, context, explicitBoot, installExtensionTestEnvironment, makeExtension, manager, resultJson, resultRead } from "./helpers/extension.js";
18
- const testEnvironment = installExtensionTestEnvironment("pi-context-notes");
19
- test.beforeEach(() => testEnvironment.beforeEach());
20
- test.afterEach(() => testEnvironment.afterEach());
21
- test.after(() => testEnvironment.dispose());
17
+ import { call, context, explicitBoot, makeExtension, manager, resultJson, resultRead } from "./helpers/extension.js";
18
+ import { installExtensionTestHooks } from "./helpers/extension-test-environment.js";
19
+ const testEnvironment = installExtensionTestHooks("pi-context-notes");
22
20
  function freshRoot() {
23
21
  return testEnvironment.newNotesRoot();
24
22
  }
@@ -59,7 +57,7 @@ test("write lands a real markdown file with harness frontmatter and a pure body"
59
57
  const captured = makeExtension(session);
60
58
  const ctx = context(session);
61
59
  const sessionId = session.getSessionId();
62
- const result = resultJson(await call(captured, "notes_write", { path: "a/b.md", content: "hello" }, ctx));
60
+ const result = resultJson(await call(captured, "notes_write", { address: "a/b.md", content: "hello" }, ctx));
63
61
  assert.deepEqual(Object.keys(result).sort(), ["address", "written"]);
64
62
  const file = physicalPath("session", "a/b.md", ctx);
65
63
  assert.equal(file, join(root, "pi", "session", sessionId, "a", "b.md"));
@@ -79,7 +77,7 @@ test("write lands a real markdown file with harness frontmatter and a pure body"
79
77
  assert.equal(result.written, true);
80
78
  assert.equal(existsSync(join(scopeDir("session", ctx), ".session.json")), false, "ownership is stored in note frontmatter, not a sidecar");
81
79
  // A leading YAML block in user content is stripped from the body.
82
- await call(captured, "notes_write", { path: "stripped.md", content: "---\nscope: human\nnonsense: true\n---\nreal body" }, ctx);
80
+ await call(captured, "notes_write", { address: "stripped.md", content: "---\nscope: human\nnonsense: true\n---\nreal body" }, ctx);
83
81
  const stripped = readFileSync(physicalPath("session", "stripped.md", ctx), "utf8");
84
82
  assert.match(stripped, /\n---\n\nreal body$/, "the injected block is not part of the body");
85
83
  assert.equal(stripped.includes("nonsense"), false, "the injected block never reaches the file");
@@ -96,33 +94,33 @@ test("session-note project ownership is per note, persistent across sessions, an
96
94
  const firstSession = manager();
97
95
  const firstCaptured = makeExtension(firstSession);
98
96
  const firstCtx = context(firstSession, undefined, undefined, true, cwdA);
99
- await call(firstCaptured, "notes_write", { path: "first.md", content: "first project session" }, firstCtx);
97
+ await call(firstCaptured, "notes_write", { address: "first.md", content: "first project session" }, firstCtx);
100
98
  const firstFile = physicalPath("session", "first.md", firstCtx);
101
99
  assert.equal(parseNote(readFileSync(firstFile, "utf8")).meta.project, projectA);
102
100
  const secondSession = manager();
103
101
  const secondCaptured = makeExtension(secondSession);
104
102
  const secondCtx = context(secondSession, undefined, undefined, true, cwdA);
105
- await call(secondCaptured, "notes_write", { path: "second.md", content: "same project, another session" }, secondCtx);
103
+ await call(secondCaptured, "notes_write", { address: "second.md", content: "same project, another session" }, secondCtx);
106
104
  const secondFile = physicalPath("session", "second.md", secondCtx);
107
105
  assert.equal(parseNote(readFileSync(secondFile, "utf8")).meta.project, projectA, "another session in the same project carries the matching key");
108
106
  const thirdSession = manager();
109
107
  const thirdCaptured = makeExtension(thirdSession);
110
108
  const thirdCtx = context(thirdSession, undefined, undefined, true, cwdB);
111
- await call(thirdCaptured, "notes_write", { path: "third.md", content: "different project" }, thirdCtx);
109
+ await call(thirdCaptured, "notes_write", { address: "third.md", content: "different project" }, thirdCtx);
112
110
  const thirdFile = physicalPath("session", "third.md", thirdCtx);
113
111
  assert.equal(parseNote(readFileSync(thirdFile, "utf8")).meta.project, projectB);
114
112
  const projectASessions = [firstFile, secondFile, thirdFile].filter((file) => parseNote(readFileSync(file, "utf8")).meta.project === projectA);
115
113
  assert.deepEqual(projectASessions.sort(), [firstFile, secondFile].sort(), "exact frontmatter project matching recognizes only sessions from the same project");
116
114
  const movedContext = context(firstSession, undefined, undefined, true, cwdB);
117
- await call(firstCaptured, "notes_write", { path: "first.md", content: "overwritten from another cwd" }, movedContext);
115
+ await call(firstCaptured, "notes_write", { address: "first.md", content: "overwritten from another cwd" }, movedContext);
118
116
  assert.equal(parseNote(readFileSync(firstFile, "utf8")).meta.project, projectA, "overwriting an existing note does not silently reassign it");
119
- await call(firstCaptured, "notes_edit", { path: "first.md", edits: [{ oldText: "overwritten", newText: "edited" }] }, movedContext);
117
+ await call(firstCaptured, "notes_edit", { address: "first.md", edits: [{ oldText: "overwritten", newText: "edited" }] }, movedContext);
120
118
  assert.equal(parseNote(readFileSync(firstFile, "utf8")).meta.project, projectA, "editing an existing note preserves its original project key");
121
- await call(firstCaptured, "notes_read", { path: "first.md" }, movedContext);
119
+ await call(firstCaptured, "notes_read", { address: "first.md" }, movedContext);
122
120
  assert.equal(parseNote(readFileSync(firstFile, "utf8")).meta.project, projectA, "reading preserves existing project ownership");
123
- await call(firstCaptured, "notes_write", { path: "new-from-project-b.md", content: "new note", scope: "session" }, movedContext);
121
+ await call(firstCaptured, "notes_write", { address: "new-from-project-b.md", content: "new note" }, movedContext);
124
122
  assert.equal(parseNote(readFileSync(physicalPath("session", "new-from-project-b.md", movedContext), "utf8")).meta.project, projectB, "only a newly-created session note uses the current project key");
125
- await call(firstCaptured, "notes_write", { path: "project-note.md", content: "project home note", scope: "project" }, movedContext);
123
+ await call(firstCaptured, "notes_write", { address: "@project/project-note.md", content: "project home note" }, movedContext);
126
124
  assert.equal(parseNote(readFileSync(physicalPath("project", "project-note.md", movedContext), "utf8")).meta.project, undefined, "project-home notes do not receive session ownership metadata");
127
125
  assert.equal((await listNotes(movedContext, { scope: "session" })).length, 2, "project ownership remains frontmatter, not a separate note");
128
126
  });
@@ -144,7 +142,7 @@ test("linked git worktrees share the main checkout's project key", () => {
144
142
  assert.equal(projectKey(join(worktree, "gone", "deeper")), projectKey(main), "a nonexistent subdirectory still resolves through its worktree");
145
143
  assert.equal(scopeDir("project", context(manager(), undefined, undefined, true, worktree)), scopeDir("project", context(manager(), undefined, undefined, true, main)), "@project uses the same physical home from both checkouts");
146
144
  });
147
- test("legacy metadata is refused for explicit manual migration; invalid project ownership stays unknown", async () => {
145
+ test("unrecognized metadata remains ordinary frontmatter; invalid project ownership stays unknown", async () => {
148
146
  freshRoot();
149
147
  const session = manager();
150
148
  const captured = makeExtension(session);
@@ -159,15 +157,24 @@ created_at: 2026-01-01T00:00:00.000+00:00
159
157
  updated_at: 2026-01-01T00:00:00.000+00:00
160
158
  last_accessed: 2026-01-01T00:00:00.000+00:00
161
159
  access_count: 0
160
+ source_window: old-window
161
+ recurrence_count: 2
162
+ recurrence_windows: old-window
162
163
  ---
163
164
 
164
165
  legacy body`);
165
- assert.throws(() => parseNote(readFileSync(legacyFile, "utf8")), /legacy note metadata .*requires manual migration/);
166
- const legacyBytes = readFileSync(legacyFile, "utf8");
167
- await assert.rejects(() => listNotes(ctx, { scope: "session" }), /requires manual migration/);
168
- await assert.rejects(() => call(captured, "notes_read", { path: "legacy.md" }, ctx), /requires manual migration/);
169
- await assert.rejects(() => call(captured, "notes_write", { path: "legacy.md", content: "legacy overwritten" }, ctx), /requires manual migration/);
170
- assert.equal(readFileSync(legacyFile, "utf8"), legacyBytes, "refusal preserves the unmigrated file byte-for-byte");
166
+ const parsed = parseNote(readFileSync(legacyFile, "utf8"), Date.parse("2026-02-01T00:00:00Z"));
167
+ assert.equal(parsed.meta.createdAt, Date.parse("2026-02-01T00:00:00Z"), "missing canonical timestamp takes the normal default");
168
+ assert.equal(parsed.meta.created_at, "2026-01-01T00:00:00.000+00:00", "unrecognized fields remain ordinary extras");
169
+ assert.equal((await listNotes(ctx, { scope: "session" }))[0]?.address, "legacy.md");
170
+ assert.match(resultRead(await call(captured, "notes_read", { address: "legacy.md" }, ctx)).content, /legacy body$/);
171
+ await call(captured, "notes_edit", { address: "legacy.md", edits: [{ oldText: "legacy body", newText: "edited body" }] }, ctx);
172
+ await call(captured, "notes_write", { address: "legacy.md", content: "overwritten body" }, ctx);
173
+ const rewritten = parseNote(readFileSync(legacyFile, "utf8"));
174
+ assert.equal(rewritten.body, "overwritten body");
175
+ for (const key of ["created_at", "updated_at", "last_accessed", "access_count", "source_window", "recurrence_count", "recurrence_windows"]) {
176
+ assert.deepEqual(rewritten.meta[key], parsed.meta[key], `${key} is preserved as unrecognized frontmatter, not migrated`);
177
+ }
171
178
  const invalidFile = physicalPath("session", "invalid.md", ctx);
172
179
  writeFileSync(invalidFile, `---
173
180
  origin: self
@@ -183,7 +190,7 @@ project: 17
183
190
  invalid owner`);
184
191
  assert.equal(parseNote(readFileSync(invalidFile, "utf8")).meta.project, 17);
185
192
  assert.notEqual(parseNote(readFileSync(invalidFile, "utf8")).meta.project, projectKey(ctx.cwd), "invalid ownership does not match the current project key");
186
- await call(captured, "notes_write", { path: "invalid.md", content: "still invalid" }, ctx);
193
+ await call(captured, "notes_write", { address: "invalid.md", content: "still invalid" }, ctx);
187
194
  assert.equal(parseNote(readFileSync(invalidFile, "utf8")).meta.project, 17, "an invalid value remains unknown and is not replaced with cwd-derived ownership");
188
195
  assert.equal(existsSync(join(scopeDir("session", ctx), ".session.json")), false, "new notes use no ownership sidecar");
189
196
  });
@@ -192,19 +199,19 @@ test("edit is body-scoped with named failures and a replace_all escape hatch", a
192
199
  const session = manager();
193
200
  const captured = makeExtension(session);
194
201
  const ctx = context(session);
195
- await call(captured, "notes_write", { path: "edit.md", content: "alpha\nbeta\nbeta\ngamma" }, ctx);
196
- const ambiguous = resultJson(await call(captured, "notes_edit", { path: "edit.md", edits: [{ oldText: "beta", newText: "B" }] }, ctx));
202
+ await call(captured, "notes_write", { address: "edit.md", content: "alpha\nbeta\nbeta\ngamma" }, ctx);
203
+ const ambiguous = resultJson(await call(captured, "notes_edit", { address: "edit.md", edits: [{ oldText: "beta", newText: "B" }] }, ctx));
197
204
  assert.match(ambiguous.error, /occurs 2 times/);
198
205
  assert.deepEqual(ambiguous.line_numbers, [2, 3], "the multi-match error carries every match line number");
199
- const missing = resultJson(await call(captured, "notes_edit", { path: "edit.md", edits: [{ oldText: "absent", newText: "x" }] }, ctx));
206
+ const missing = resultJson(await call(captured, "notes_edit", { address: "edit.md", edits: [{ oldText: "absent", newText: "x" }] }, ctx));
200
207
  assert.equal(missing.edit_index, 0, "a zero-match anchor names the failing edit index");
201
- const all = resultJson(await call(captured, "notes_edit", { path: "edit.md", edits: [{ oldText: "beta", newText: "B" }], replace_all: true }, ctx));
208
+ const all = resultJson(await call(captured, "notes_edit", { address: "edit.md", edits: [{ oldText: "beta", newText: "B" }], replace_all: true }, ctx));
202
209
  assert.equal(all.applied, 1);
203
210
  assert.equal(all.address, "edit.md");
204
211
  assertNoPublicScope(all, "notes_edit");
205
- assert.equal(resultRead(await call(captured, "notes_read", { path: "edit.md" }, ctx)).content.endsWith("alpha\nB\nB\ngamma"), true, "replace_all replaces every occurrence");
212
+ assert.equal(resultRead(await call(captured, "notes_read", { address: "edit.md" }, ctx)).content.endsWith("alpha\nB\nB\ngamma"), true, "replace_all replaces every occurrence");
206
213
  // An anchor that occurs only in frontmatter is not matched: edits are body-only.
207
- const frontmatterOnly = resultJson(await call(captured, "notes_edit", { path: "edit.md", edits: [{ oldText: "scope", newText: "x" }] }, ctx));
214
+ const frontmatterOnly = resultJson(await call(captured, "notes_edit", { address: "edit.md", edits: [{ oldText: "scope", newText: "x" }] }, ctx));
208
215
  assert.equal(frontmatterOnly.edit_index, 0, "a frontmatter-only anchor is not a body match");
209
216
  });
210
217
  test("nothing-to-do, not-found, atomic batches, and replace_all zero-match are named", async () => {
@@ -212,25 +219,25 @@ test("nothing-to-do, not-found, atomic batches, and replace_all zero-match are n
212
219
  const session = manager();
213
220
  const captured = makeExtension(session);
214
221
  const ctx = context(session);
215
- const nameOnly = resultJson(await call(captured, "notes_edit", { path: "edit.md" }, ctx));
222
+ const nameOnly = resultJson(await call(captured, "notes_edit", { address: "edit.md" }, ctx));
216
223
  assert.match(nameOnly.error, /nothing to do/, "neither edits nor setters is a named error");
217
- await call(captured, "notes_write", { path: "edit.md", content: "alpha\nbeta" }, ctx);
218
- const empty = resultJson(await call(captured, "notes_edit", { path: "edit.md", edits: [] }, ctx));
224
+ await call(captured, "notes_write", { address: "edit.md", content: "alpha\nbeta" }, ctx);
225
+ const empty = resultJson(await call(captured, "notes_edit", { address: "edit.md", edits: [] }, ctx));
219
226
  assert.match(empty.error, /nothing to do/, "an empty edits list with no setters is also nothing to do");
220
- const editMissing = resultJson(await call(captured, "notes_edit", { path: "missing.md", stale: true }, ctx));
227
+ const editMissing = resultJson(await call(captured, "notes_edit", { address: "missing.md", stale: true }, ctx));
221
228
  assert.equal(editMissing.error, "note not found");
222
- const readMissing = resultJson(await call(captured, "notes_read", { path: "missing.md" }, ctx));
229
+ const readMissing = resultJson(await call(captured, "notes_read", { address: "missing.md" }, ctx));
223
230
  assert.equal(readMissing.error, "note not found");
224
- assert.equal(readMissing.path, "missing.md");
231
+ assert.equal(readMissing.address, "missing.md");
225
232
  const file = physicalPath("session", "edit.md", ctx);
226
233
  const before = readFileSync(file, "utf8");
227
- const failed = resultJson(await call(captured, "notes_edit", { path: "edit.md", edits: [{ oldText: "alpha", newText: "A" }, { oldText: "absent", newText: "x" }] }, ctx));
234
+ const failed = resultJson(await call(captured, "notes_edit", { address: "edit.md", edits: [{ oldText: "alpha", newText: "A" }, { oldText: "absent", newText: "x" }] }, ctx));
228
235
  assert.equal(failed.edit_index, 1, "the failing edit is named");
229
236
  assert.equal(readFileSync(file, "utf8"), before, "a failing batch leaves the file byte-identical, frontmatter included");
230
- const applied = resultJson(await call(captured, "notes_edit", { path: "edit.md", edits: [{ oldText: "alpha", newText: "A" }, { oldText: "beta", newText: "B" }] }, ctx));
237
+ const applied = resultJson(await call(captured, "notes_edit", { address: "edit.md", edits: [{ oldText: "alpha", newText: "A" }, { oldText: "beta", newText: "B" }] }, ctx));
231
238
  assert.equal(applied.applied, 2);
232
- assert.equal(resultRead(await call(captured, "notes_read", { path: "edit.md" }, ctx)).content.endsWith("A\nB"), true);
233
- const zero = resultJson(await call(captured, "notes_edit", { path: "edit.md", edits: [{ oldText: "zzz", newText: "y" }], replace_all: true }, ctx));
239
+ assert.equal(resultRead(await call(captured, "notes_read", { address: "edit.md" }, ctx)).content.endsWith("A\nB"), true);
240
+ const zero = resultJson(await call(captured, "notes_edit", { address: "edit.md", edits: [{ oldText: "zzz", newText: "y" }], replace_all: true }, ctx));
234
241
  assert.equal(zero.edit_index, 0, "replace_all with zero matches is the same zero-match error, not a silent no-op");
235
242
  });
236
243
  test("all notes tool results use address as the only home identity", async () => {
@@ -270,16 +277,16 @@ test("list and search merge scopes and carry addresses; the path jail rejects es
270
277
  const session = manager();
271
278
  const captured = makeExtension(session);
272
279
  const ctx = context(session);
273
- await call(captured, "notes_write", { path: "one.md", content: "needle one", scope: "session" }, ctx);
274
- await call(captured, "notes_write", { path: "two.md", content: "needle two", scope: "project" }, ctx);
275
- await call(captured, "notes_write", { path: "three.md", content: "needle three", scope: "human" }, ctx);
280
+ await call(captured, "notes_write", { address: "one.md", content: "needle one" }, ctx);
281
+ await call(captured, "notes_write", { address: "@project/two.md", content: "needle two" }, ctx);
282
+ await call(captured, "notes_write", { address: "@human/three.md", content: "needle three" }, ctx);
276
283
  const listed = resultJson(await call(captured, "notes_list", {}, ctx));
277
284
  assert.deepEqual([...listed.files].map((file) => file.address).sort(), ["@human/three.md", "@project/two.md", "one.md"], "every merged row carries its full address");
278
285
  for (const row of listed.files) {
279
286
  assert.deepEqual(Object.keys(row).sort(), ["address", "stale", "updated_at"]);
280
287
  assert.equal(row.stale, false);
281
288
  }
282
- const scoped = resultJson(await call(captured, "notes_list", { scope: "human" }, ctx));
289
+ const scoped = resultJson(await call(captured, "notes_list", { pattern: "@human/**" }, ctx));
283
290
  assert.deepEqual(scoped.files.map((file) => file.address), ["@human/three.md"], "an address-pattern filter narrows the set");
284
291
  const searched = resultJson(await call(captured, "notes_search", { query: "needle" }, ctx));
285
292
  assert.equal(searched.files.length, 3, "literal search finds matches in every scope");
@@ -295,7 +302,7 @@ test("list and search merge scopes and carry addresses; the path jail rejects es
295
302
  const escaped = ["../evil", "/abs", "a\\b"];
296
303
  for (const tool of ["notes_write", "notes_edit", "notes_read"]) {
297
304
  for (const path of escaped) {
298
- await assert.rejects(() => call(captured, tool, { path, content: "x", edits: [{ oldText: "a", newText: "b" }] }, ctx), `${tool} rejects ${path}`);
305
+ await assert.rejects(() => call(captured, tool, { address: path, content: "x", edits: [{ oldText: "a", newText: "b" }] }, ctx), `${tool} rejects ${path}`);
299
306
  }
300
307
  }
301
308
  await assert.rejects(() => call(captured, "notes_list", { pattern: "bad\\glob" }, ctx), /backslash/);
@@ -43,7 +43,7 @@ Boot is a fixed snapshot for its window, stored as an extension custom message a
43
43
 
44
44
  History reads reconstruct the selected session branch on demand without a cache, so branch navigation cannot expose history from a sibling.
45
45
 
46
- The boot notes index in `pi/notes/snapshot.ts` is a closed snapshot: the current session, project, human, agent, and model homes are each loaded at most once while constructing a boot. `context/prompts.ts` then renders that explicit snapshot without reading the filesystem or consulting the clock. MAP bodies and pocket metadata are derived from the same snapshot, so a boot cannot mix two filesystem reads. Note storage and home traversal use `node:fs/promises`; same-file operations queue by absolute physical filename across store instances in this process, without promising symlink/case-alias or cross-process locking. Known persisted metadata is camelCase, and a known legacy snake_case key refuses use until the root-coordinated manual migration; startup does not migrate notes. A missing home (`ENOENT`) is normal. A real read failure omits only that home's index, preserves healthy homes, adds a model-facing `notes_list` recovery notice, and notifies the human once for that window. Boot/reset construction captures session, agent, and model identity before awaiting the snapshot, then checks lifecycle generation, active window, enabled state, and abort status before sending or returning artifacts; stale completions cannot commit into a switched or shut-down session. Note reads never mutate files or create fallback state.
46
+ The boot notes index in `pi/notes/snapshot.ts` is a closed snapshot: the current session, project, human, agent, and model homes are each loaded at most once while constructing a boot. `context/prompts.ts` then renders that explicit snapshot without reading the filesystem or consulting the clock. MAP bodies and pocket metadata are derived from the same snapshot, so a boot cannot mix two filesystem reads. Note storage and home traversal use `node:fs/promises`; same-file operations queue by absolute physical filename across store instances in this process, without promising symlink/case-alias or cross-process locking. Known persisted metadata is camelCase. Old snake_case keys are unrecognized extras, not interpreted or migrated; startup does not migrate notes. A missing home (`ENOENT`) is normal. A real read failure omits only that home's index, preserves healthy homes, adds a model-facing `notes_list` recovery notice, and notifies the human once for that window. Boot/reset construction captures session, agent, and model identity before awaiting the snapshot, then checks lifecycle generation, active window, enabled state, and abort status before sending or returning artifacts; stale completions cannot commit into a switched or shut-down session. Note reads never mutate files or create fallback state.
47
47
 
48
48
  `notes/session-replay.ts` accepts only supported operations, safe virtual paths, representable timestamps and results within the UTF-8 size limit. Invalid operations are ignored; they cannot replace a valid note. Notes remain in their filesystem-backed homes, unchanged by session branch navigation.
49
49
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/pi-context",
3
- "version": "0.26.0",
3
+ "version": "0.26.2",
4
4
  "type": "module",
5
5
  "main": "./dist/src/index.js",
6
6
  "types": "./dist/src/index.d.ts",
@@ -2,7 +2,7 @@ import { Type } from "@earendil-works/pi-ai";
2
2
  import { defineTool, type ExtensionAPI, type ExtensionContext, type SessionBoundaryDraft, type SettingsManager } from "@earendil-works/pi-coding-agent";
3
3
  import { GUIDANCE_TYPE, WARNING_CONTENT, WARNING_TYPE } from "../protocol.js";
4
4
  import { readThresholdSettings, type ResolvedThresholds, type ThresholdSettingsResolution } from "./thresholds.js";
5
- import { currentWindowId, hasWindowMessage, windowUsage } from "./context-window.js";
5
+ import { currentWindowId, hasWindowMessage, isWindowMarker, rootWindowId, windowUsage } from "./context-window.js";
6
6
  import { tokenBudgetGuidance } from "./prompts.js";
7
7
  import { output } from "../tool-output.js";
8
8
 
@@ -45,16 +45,30 @@ export function registerBudget(
45
45
  const invalidateThresholds = () => { cachedPolicy = undefined; };
46
46
  let pendingGuidance: { windowId: string; content: string; remaining: number } | undefined;
47
47
  let pendingWarning: { windowId: string; content: string; remaining: number } | undefined;
48
- let pendingNotices: Array<{ windowId: string; customType: string; remaining: number }> = [];
49
- const notifyCommittedReminders = (ctx: ExtensionContext) => {
50
- const windowId = currentWindowId(ctx);
51
- for (const notice of pendingNotices) {
52
- if (notice.windowId !== windowId || !hasWindowMessage(ctx, notice.customType)) continue;
53
- ctx.ui.notify(notice.customType === WARNING_TYPE
54
- ? "pi-context: Context almost full; close out the current memory window."
55
- : "pi-context: Context running low; checkpoint your notes soon.", "warning");
48
+ let pendingNotices = new Map<string, { sessionId: string; windowId: string }>();
49
+ const noticeKey = (sessionId: string, windowId: string) => `${sessionId}:${windowId}`;
50
+ const warningCommittedInWindow = (ctx: ExtensionContext, notice: { sessionId: string; windowId: string }): boolean => {
51
+ if (ctx.sessionManager.getSessionId() !== notice.sessionId) return false;
52
+ let windowId = rootWindowId(notice.sessionId);
53
+ for (const entry of ctx.sessionManager.getBranch()) {
54
+ if (isWindowMarker(entry)) {
55
+ windowId = entry.data.windowId;
56
+ continue;
57
+ }
58
+ if (windowId === notice.windowId && entry.type === "custom_message" && entry.customType === WARNING_TYPE) return true;
59
+ }
60
+ return false;
61
+ };
62
+ const notifyCommittedWarnings = (ctx: ExtensionContext, settled = false) => {
63
+ for (const [key, notice] of pendingNotices) {
64
+ if (warningCommittedInWindow(ctx, notice)) {
65
+ pendingNotices.delete(key);
66
+ ctx.ui.notify("pi-context: Context almost full; close out the current memory window.", "warning");
67
+ } else if (settled) {
68
+ // An uncommitted draft must not be matched to a later manual warning.
69
+ pendingNotices.delete(key);
70
+ }
56
71
  }
57
- pendingNotices = [];
58
72
  };
59
73
 
60
74
  const clearStaged = () => {
@@ -63,7 +77,7 @@ export function registerBudget(
63
77
  };
64
78
  const resetForTransition = () => {
65
79
  clearStaged();
66
- pendingNotices = [];
80
+ pendingNotices.clear();
67
81
  invalidateThresholds();
68
82
  notifiedWarnings.clear();
69
83
  };
@@ -76,7 +90,10 @@ export function registerBudget(
76
90
  clearStaged();
77
91
  const windowId = currentWindowId(ctx);
78
92
  const drafts = staged.filter((draft): draft is NonNullable<typeof draft> => draft !== undefined && draft.windowId === windowId);
79
- pendingNotices = drafts.map(({ windowId, customType, remaining }) => ({ windowId, customType, remaining }));
93
+ if (drafts.some((draft) => draft.customType === WARNING_TYPE)) {
94
+ const sessionId = ctx.sessionManager.getSessionId();
95
+ pendingNotices.set(noticeKey(sessionId, windowId), { sessionId, windowId });
96
+ }
80
97
  return drafts.map((draft) => ({
81
98
  type: "custom_message" as const,
82
99
  customType: draft.customType,
@@ -89,12 +106,12 @@ export function registerBudget(
89
106
  pi.on("session_tree", resetForTransition);
90
107
  pi.on("model_select", resetForTransition);
91
108
  pi.on("session_shutdown", resetForTransition);
92
- // A request can fail before Pi emits turn_end. agent_settled is the public
93
- // lifecycle point that must discard an uncommitted draft before the next prompt.
94
- // UI notices follow committed reminders. Aborted requests can retry their drafts
95
- // without showing the same low-budget notification twice.
109
+ // A warning can be committed at turn_end, before a tool turn or a reset changes the
110
+ // active window. Observe the active branch at public lifecycle boundaries and match
111
+ // the candidate against its originating window segment, not only the current window.
112
+ pi.on("turn_start", (_event, ctx) => notifyCommittedWarnings(ctx));
96
113
  pi.on("agent_settled", (_event, ctx) => {
97
- notifyCommittedReminders(ctx);
114
+ notifyCommittedWarnings(ctx, true);
98
115
  clearStaged();
99
116
  });
100
117
  pi.on("context", (_event, ctx) => {
@@ -151,6 +168,6 @@ export function registerBudget(
151
168
  automaticResetEnabled,
152
169
  hardReserveDue,
153
170
  consumeTurnEnd,
154
- clear: () => { clearStaged(); pendingNotices = []; },
171
+ clear: () => { clearStaged(); pendingNotices.clear(); },
155
172
  };
156
173
  }
@@ -36,8 +36,8 @@ function notesUnavailableNotice(snapshot: NotesSnapshot): string | undefined {
36
36
  * per home, and the session home is never peeked — a session MAP.md is an ordinary note. The
37
37
  * pocket then lists recent fresh notes under per-home quotas (POCKET_SESSION_LIMIT /
38
38
  * POCKET_PROJECT_LIMIT / POCKET_HUMAN_LIMIT / POCKET_AGENT_LIMIT / POCKET_MODEL_LIMIT),
39
- * most-recently-updated first within each home, one metadata line each: address, line count,
40
- * UTF-8 byte count, relative update time at window open. Bodies never render
39
+ * most-recently-updated first within each home, one metadata line each: address, body character
40
+ * count, relative update time at window open. Bodies never render
41
41
  * in the pocket; stale notes are excluded; MAP.md itself never takes a pocket seat.
42
42
  */
43
43
  function notesIndex(snapshot: NotesSnapshot): string {
@@ -62,7 +62,7 @@ function notesIndex(snapshot: NotesSnapshot): string {
62
62
  if (recentNotes.length > 0) {
63
63
  const lines = [`You find ${recentNotes.length} crumpled note${recentNotes.length === 1 ? "" : "s"} in your pocket (by prefix, most recent first within each: up to ${POCKET_SESSION_LIMIT} from this session, ${POCKET_PROJECT_LIMIT} from @project, ${POCKET_HUMAN_LIMIT} from @human, ${POCKET_AGENT_LIMIT} from @self, ${POCKET_MODEL_LIMIT} from @model). A note's content never appears here, so its name has to say what the note is about:`];
64
64
  for (const row of recentNotes) {
65
- lines.push(`- ${row.address} (${row.body.split("\n").length} lines, ${row.sizeBytes} UTF-8 bytes, updated ${relativeTime(row.meta.updatedAt, snapshot.openedAt)})`);
65
+ lines.push(`- ${row.address} · ${Array.from(row.body).length} chars · ${relativeTime(row.meta.updatedAt, snapshot.openedAt)}`);
66
66
  }
67
67
  sections.push(lines.join("\n"));
68
68
  }
@@ -30,9 +30,6 @@ export function doctor(home: string): string[] {
30
30
  for (const [key, valid] of Object.entries({ origin: /^(user|self|external)$/, status: /^(active|superseded|pending|archived)$/, stale: /^(true|false)$/, accessCount: /^\d+$/ })) {
31
31
  if (!valid.test(fields.get(key) ?? "")) report(path, `missing/invalid ${key}; repair frontmatter`);
32
32
  }
33
- for (const [legacy, current] of [["created_at", "createdAt"], ["updated_at", "updatedAt"], ["last_accessed", "lastAccessed"], ["access_count", "accessCount"], ["source_window", "sourceWindow"], ["recurrence_count", "recurrenceCount"], ["recurrence_windows", "recurrenceWindows"]]) {
34
- if (fields.has(legacy)) report(path, `legacy metadata key ${legacy}; manually migrate to ${current}`);
35
- }
36
33
  for (const key of ["createdAt", "updatedAt", "lastAccessed"]) {
37
34
  const value = fields.get(key);
38
35
  if (!value || !Number.isFinite(Date.parse(value))) report(path, `missing/invalid ${key}; use an ISO timestamp`);
@@ -30,7 +30,6 @@ const SCOPES: readonly Scope[] = ["session", "project", "human", "agent", "model
30
30
  const ORIGINS: readonly Origin[] = ["user", "self", "external"];
31
31
  const STATUSES: readonly NoteStatus[] = ["active", "superseded", "pending", "archived"];
32
32
  const TIMESTAMP_KEYS = ["createdAt", "updatedAt", "lastAccessed"] as const;
33
- const LEGACY_KNOWN_KEYS = ["created_at", "updated_at", "last_accessed", "access_count", "source_window", "recurrence_count", "recurrence_windows"] as const;
34
33
  /** Emission order, exactly the Design's key list. */
35
34
  const KNOWN_KEYS = ["origin", "status", "stale", "createdAt", "updatedAt", "lastAccessed", "accessCount", "sourceWindow", "supersedes", "recurrenceCount", "recurrenceWindows"] as const;
36
35
 
@@ -125,13 +124,10 @@ function parseFrontmatter(raw: string): { fields: Record<string, unknown>; body:
125
124
 
126
125
  /**
127
126
  * Parse a note file. Missing known keys take the Design defaults (status active, stale false,
128
- * accessCount 0, timestamps now); unknown keys are carried through untouched. Known
129
- * snake_case metadata is refused because it requires the explicit manual migration.
127
+ * accessCount 0, timestamps now); unknown keys are carried through untouched.
130
128
  */
131
129
  export function parseNote(raw: string, now = Date.now()): { meta: NoteMeta; body: string } {
132
130
  const { fields, body } = parseFrontmatter(raw);
133
- const legacyKeys = LEGACY_KNOWN_KEYS.filter((key) => Object.hasOwn(fields, key));
134
- if (legacyKeys.length > 0) throw new Error(`legacy note metadata ${legacyKeys.join(", ")} requires manual migration to camelCase before this note can be used`);
135
131
  const meta = { ...fields } as Record<string, unknown>;
136
132
  // scope is a legacy on-disk field: store callers derive it from the file's home and
137
133
  // overwrite it after parsing, so an absent or outdated value just falls back.