@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
@@ -2,17 +2,14 @@ import assert from "node:assert/strict";
2
2
  import { mkdirSync, writeFileSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
4
  import test from "node:test";
5
- import { loadNotesSnapshot as loadNotesSnapshot } from "../src/notes/notes-snapshot.js";
5
+ import { loadNotesSnapshot } from "../src/pi/notes/snapshot.js";
6
6
  import { renderBootBlock } from "../src/context/prompts.js";
7
7
  import { localIso } from "../src/notes/frontmatter.js";
8
- import { physicalPath, scopeDir } from "../src/notes/paths.js";
9
- import { listNotes } from "../src/notes/store.js";
8
+ import { listNotes, physicalPath, scopeDir } from "./helpers/notes.js";
10
9
  import { TOOL_OUTPUT_MAX_BYTES } from "../src/tool-output.js";
11
- import { assertWithinBudget, call, context, explicitBoot, installExtensionTestEnvironment, makeExtension, manager, resultJson, resultRead, } from "./helpers/extension.js";
12
- const testEnvironment = installExtensionTestEnvironment("pi-context-integration");
13
- test.beforeEach(() => testEnvironment.beforeEach());
14
- test.afterEach(() => testEnvironment.afterEach());
15
- 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");
16
13
  test("notes_list is most-recently-updated first across merged scopes", async () => {
17
14
  const session = manager();
18
15
  const captured = makeExtension(session);
@@ -20,7 +17,7 @@ test("notes_list is most-recently-updated first across merged scopes", async ()
20
17
  const put = (scope, path, updated) => {
21
18
  const file = physicalPath(scope, path, ctx);
22
19
  mkdirSync(dirname(file), { recursive: true });
23
- writeFileSync(file, `---\nscope: ${scope}\norigin: self\nstatus: active\nstale: false\ncreated_at: ${localIso(updated - 1000)}\nupdated_at: ${localIso(updated)}\nlast_accessed: ${localIso(updated)}\naccess_count: 0\n---\n\nbody`);
20
+ writeFileSync(file, `---\nscope: ${scope}\norigin: self\nstatus: active\nstale: false\ncreatedAt: ${localIso(updated - 1000)}\nupdatedAt: ${localIso(updated)}\nlastAccessed: ${localIso(updated)}\naccessCount: 0\n---\n\nbody`);
24
21
  };
25
22
  const base = 1_700_000_000_000;
26
23
  put("session", "b.md", base + 10);
@@ -38,59 +35,58 @@ test("notes are real files that persist across sessions and round-trip Unicode",
38
35
  const original = manager();
39
36
  const captured = makeExtension(original);
40
37
  const ctx = context(original);
41
- 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);
42
39
  // A brand-new session over the same physical root sees the human note: nothing is replayed
43
40
  // from session entries, the file itself is the durable artifact.
44
41
  const restored = manager();
45
42
  const restoredCaptured = makeExtension(restored);
46
43
  const restoredCtx = context(restored);
47
- 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);
48
45
  const read = resultRead(rawRead);
49
46
  assert.equal(read.details.address, "@human/checkpoint/进度.md");
50
47
  assert.equal(read.content, "Café", "a negative offset reads the body tail in one call");
51
- 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));
52
49
  assert.equal(searched.files[0]?.matches[0]?.line, 2);
53
- 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));
54
51
  assert.equal(listedFiles.files.length, 1, "glob ** crosses into the checkpoint directory");
55
- assert.equal(listedFiles.files[0]?.path, "checkpoint/进度.md");
52
+ assert.equal(listedFiles.files[0]?.address, "@human/checkpoint/进度.md");
56
53
  // A single-segment * never crosses `/`, so a nested-only store matches nothing at the root.
57
- const rootOnly = resultJson(await call(restoredCaptured, "notes_list", { pattern: "*", scope: "human" }, restoredCtx));
54
+ const rootOnly = resultJson(await call(restoredCaptured, "notes_list", { pattern: "@human/*" }, restoredCtx));
58
55
  assert.equal(rootOnly.files.length, 0, "glob * stays within one segment");
59
- assert.equal(searched.files[0]?.created_at, listedFiles.files[0]?.created_at, "note tools agree on the timestamp format");
60
56
  assert.equal(searched.files[0]?.updated_at, listedFiles.files[0]?.updated_at);
61
- 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/);
62
58
  });
63
59
  test("stale lifecycle: writes and metadata-only edits close and revive a note", async () => {
64
60
  const sm = manager();
65
61
  const captured = makeExtension(sm);
66
62
  const ctx = context(sm);
67
- 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);
68
64
  // metadata-only: content unchanged, flag set, applied 0
69
- 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));
70
66
  assert.equal(markOnly.applied, 0);
71
- assert.equal(listNotes(ctx, { scope: "session" })[0]?.meta.stale, true);
72
- assert.equal(resultRead(await call(captured, "notes_read", { path: "journal.md" }, ctx)).content.endsWith("log line"), true, "mark-only leaves content unchanged");
67
+ assert.equal((await listNotes(ctx, { scope: "session" }))[0]?.meta.stale, true);
68
+ assert.equal(resultRead(await call(captured, "notes_read", { address: "journal.md" }, ctx)).content.endsWith("log line"), true, "mark-only leaves content unchanged");
73
69
  // explicit revive
74
- const revived = resultJson(await call(captured, "notes_edit", { path: "journal.md", stale: false }, ctx));
75
- assert.equal(listNotes(ctx, { scope: "session" })[0]?.meta.stale, false, "stale:false revives");
70
+ const revived = resultJson(await call(captured, "notes_edit", { address: "journal.md", stale: false }, ctx));
71
+ assert.equal((await listNotes(ctx, { scope: "session" }))[0]?.meta.stale, false, "stale:false revives");
76
72
  // write+stale closure then plain write revival
77
- await call(captured, "notes_write", { path: "journal.md", content: "final", stale: true }, ctx);
78
- assert.equal(listNotes(ctx, { scope: "session" })[0]?.meta.stale, true);
79
- await call(captured, "notes_write", { path: "journal.md", content: "reopened" }, ctx);
80
- assert.equal(listNotes(ctx, { scope: "session" })[0]?.meta.stale, false, "writing without stale revives");
73
+ await call(captured, "notes_write", { address: "journal.md", content: "final", stale: true }, ctx);
74
+ assert.equal((await listNotes(ctx, { scope: "session" }))[0]?.meta.stale, true);
75
+ await call(captured, "notes_write", { address: "journal.md", content: "reopened" }, ctx);
76
+ assert.equal((await listNotes(ctx, { scope: "session" }))[0]?.meta.stale, false, "writing without stale revives");
81
77
  // metadata-only on a missing path is the typed not-found arm
82
- 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));
83
79
  assert.equal(missing.error, "note not found");
84
80
  });
85
- test("the filesystem notes loader treats an absent home as empty but surfaces a real directory read failure", () => {
81
+ test("the filesystem notes loader treats an absent home as empty but surfaces a real directory read failure", async () => {
86
82
  const sm = manager();
87
83
  const ctx = context(sm);
88
- assert.deepEqual(listNotes(ctx, { scope: "human" }), [], "a home that has not been created is empty");
84
+ assert.deepEqual(await listNotes(ctx, { scope: "human" }), [], "a home that has not been created is empty");
89
85
  const blockedHome = scopeDir("human", ctx);
90
86
  writeFileSync(blockedHome, "not a directory");
91
- assert.throws(() => listNotes(ctx, { scope: "human" }), (error) => error.code === "ENOTDIR", "a non-ENOENT directory failure is not swallowed as an empty home");
87
+ await assert.rejects(() => listNotes(ctx, { scope: "human" }), (error) => error.code === "ENOTDIR", "a non-ENOENT directory failure is not swallowed as an empty home");
92
88
  });
93
- test("boot note acquisition is one closed snapshot and isolates one or all failed homes", () => {
89
+ test("boot note acquisition is one closed snapshot and isolates one or all failed homes", async () => {
94
90
  const sm = manager();
95
91
  const ctx = context(sm);
96
92
  const updated = Date.now();
@@ -105,10 +101,10 @@ test("boot note acquisition is one closed snapshot and isolates one or all faile
105
101
  origin: "self",
106
102
  status: "active",
107
103
  stale: false,
108
- created_at: updated,
109
- updated_at: updated,
110
- last_accessed: updated,
111
- access_count: 0,
104
+ createdAt: updated,
105
+ updatedAt: updated,
106
+ lastAccessed: updated,
107
+ accessCount: 0,
112
108
  },
113
109
  });
114
110
  const rows = new Map([
@@ -119,7 +115,7 @@ test("boot note acquisition is one closed snapshot and isolates one or all faile
119
115
  ["model", [note("model", "model.md", "@models/default/model.md", "MODEL_POCKET_BODY")]],
120
116
  ]);
121
117
  const calls = new Map();
122
- const snapshot = loadNotesSnapshot(ctx, (_ctx, scope) => {
118
+ const snapshot = await loadNotesSnapshot(ctx, (_ctx, scope) => {
123
119
  calls.set(scope, (calls.get(scope) ?? 0) + 1);
124
120
  return rows.get(scope) ?? [];
125
121
  });
@@ -138,7 +134,7 @@ test("boot note acquisition is one closed snapshot and isolates one or all faile
138
134
  assert.ok(rendered.includes("session.md") && rendered.includes("@human/human.md"), "pocket rows come from the same snapshot");
139
135
  assert.equal(rendered.includes("SESSION_POCKET_BODY"), false, "pocket bodies stay excluded");
140
136
  const readFailure = (code) => Object.assign(new Error("scripted read failure"), { code });
141
- const oneFailed = loadNotesSnapshot(ctx, (_ctx, scope) => {
137
+ const oneFailed = await loadNotesSnapshot(ctx, (_ctx, scope) => {
142
138
  if (scope === "human")
143
139
  throw readFailure("EIO");
144
140
  return rows.get(scope) ?? [];
@@ -153,7 +149,7 @@ test("boot note acquisition is one closed snapshot and isolates one or all faile
153
149
  });
154
150
  assert.ok(oneFailedText.includes("PROJECT_MAP_BODY") && oneFailedText.includes("notes_list can retry after recovery"), "healthy homes and the recovery notice survive one failure");
155
151
  assert.equal(oneFailedText.includes("HUMAN_POCKET_BODY"), false, "the failed home's index is omitted");
156
- const allFailed = loadNotesSnapshot(ctx, (_ctx, scope) => {
152
+ const allFailed = await loadNotesSnapshot(ctx, (_ctx, scope) => {
157
153
  throw readFailure(scope === "session" ? "EACCES" : "EIO");
158
154
  });
159
155
  assert.equal(allFailed.unavailable.length, 5);
@@ -168,12 +164,12 @@ test("boot note acquisition is one closed snapshot and isolates one or all faile
168
164
  assert.ok(allFailedText.includes("pcw:test:root") && allFailedText.includes("pcw:test:next"), "identity survives an all-home failure");
169
165
  assert.ok(allFailedText.includes("Your memory resets whenever the context window fills"), "protocol survives an all-home failure");
170
166
  assert.equal(allFailedText.includes("scripted read failure"), false, "the model-facing notice does not expose OS/error details");
171
- assert.throws(() => loadNotesSnapshot(ctx, () => { throw new TypeError("programmer failure"); }), (error) => error instanceof TypeError, "unrelated TypeError construction failures remain visible");
172
- assert.throws(() => loadNotesSnapshot(ctx, () => { throw Object.assign(new Error("invalid argument"), { code: "ERR_INVALID_ARG_TYPE" }); }), (error) => error.code === "ERR_INVALID_ARG_TYPE", "Node ERR_* failures are not treated as filesystem errno failures");
167
+ await assert.rejects(() => loadNotesSnapshot(ctx, () => { throw new TypeError("programmer failure"); }), (error) => error instanceof TypeError, "unrelated TypeError construction failures remain visible");
168
+ await assert.rejects(() => loadNotesSnapshot(ctx, () => { throw Object.assign(new Error("invalid argument"), { code: "ERR_INVALID_ARG_TYPE" }); }), (error) => error.code === "ERR_INVALID_ARG_TYPE", "Node ERR_* failures are not treated as filesystem errno failures");
173
169
  });
174
- test("the boot block gives awake agents the notes-home file layout", () => {
170
+ test("the boot block gives awake agents the notes-home file layout", async () => {
175
171
  const session = manager();
176
- const rendered = explicitBoot(context(session), "pcw:test:root", undefined);
172
+ const rendered = await explicitBoot(context(session), "pcw:test:root", undefined);
177
173
  assert.equal(rendered.includes(process.env.PI_NOTES_HOME ?? ""), false, "the absolute notes home is never exposed");
178
174
  assert.match(rendered, /bare <vpath>.*@project\/<vpath>.*@human\/<vpath>/);
179
175
  });
@@ -183,8 +179,8 @@ test("an over-budget note is delivered as a prefix and resumed by next_offset_ch
183
179
  const ctx = context(session);
184
180
  const huge = `H${"x".repeat(TOOL_OUTPUT_MAX_BYTES * 2)}`;
185
181
  const text = `${huge}\ntail line`;
186
- await call(captured, "notes_write", { path: "huge.md", content: text }, ctx);
187
- const rawFirst = await call(captured, "notes_read", { path: "huge.md" }, ctx);
182
+ await call(captured, "notes_write", { address: "huge.md", content: text }, ctx);
183
+ const rawFirst = await call(captured, "notes_read", { address: "huge.md" }, ctx);
188
184
  assertWithinBudget(rawFirst, "single oversized note");
189
185
  const first = resultRead(rawFirst);
190
186
  assert.ok(first.content.length > 0, "the page is not empty");
@@ -198,7 +194,7 @@ test("an over-budget note is delivered as a prefix and resumed by next_offset_ch
198
194
  const parts = [first.content];
199
195
  let offset = first.next_offset_chars;
200
196
  while (offset !== null) {
201
- const rawChunk = await call(captured, "notes_read", { path: "huge.md", offset_chars: offset }, ctx);
197
+ const rawChunk = await call(captured, "notes_read", { address: "huge.md", offset_chars: offset }, ctx);
202
198
  assertWithinBudget(rawChunk, `huge note chunk at ${offset}`);
203
199
  const chunk = resultRead(rawChunk);
204
200
  assert.equal(chunk.offset_chars, offset, "the response echoes the resolved absolute offset");
@@ -207,7 +203,7 @@ test("an over-budget note is delivered as a prefix and resumed by next_offset_ch
207
203
  }
208
204
  assert.ok(parts.join("").endsWith(text), "the cursors reconstruct the body exactly");
209
205
  // A success carries structured details; an error stays a JSON envelope with no details.
210
- const missingResult = await call(captured, "notes_read", { path: "no-such.md" }, ctx);
206
+ const missingResult = await call(captured, "notes_read", { address: "no-such.md" }, ctx);
211
207
  const missing = resultJson(missingResult);
212
208
  assert.deepEqual(Object.keys(missing).sort(), ["address", "error"], "the read error carries exactly error and address");
213
209
  assert.equal(missing.error, "note not found");
@@ -219,8 +215,8 @@ test("an over-budget note search match is a named prefix with an honest line add
219
215
  const ctx = context(session);
220
216
  // The query sits behind a prefix, so its address is a real body-absolute offset, not line 1.
221
217
  const hugeLine = `${'p'.repeat(500)}needle ${"y".repeat(TOOL_OUTPUT_MAX_BYTES * 2)}`;
222
- await call(captured, "notes_write", { path: "a.md", content: "needle small" }, ctx);
223
- await call(captured, "notes_write", { path: "search.md", content: hugeLine }, ctx);
218
+ await call(captured, "notes_write", { address: "a.md", content: "needle small" }, ctx);
219
+ await call(captured, "notes_write", { address: "search.md", content: hugeLine }, ctx);
224
220
  const pages = [];
225
221
  let cursor = 0;
226
222
  let next = 0;
@@ -232,7 +228,7 @@ test("an over-budget note search match is a named prefix with an honest line add
232
228
  if (next !== null)
233
229
  cursor = next;
234
230
  }
235
- assert.deepEqual(pages.map((file) => file.path), ["a.md", "search.md"], "pagination reaches the oversized file instead of looping");
231
+ assert.deepEqual(pages.map((file) => file.address), ["a.md", "search.md"], "pagination reaches the oversized file instead of looping");
236
232
  const oversized = pages[1];
237
233
  assert.equal(oversized.matches_total, 1, "the file's full match count is named even though the line was cut");
238
234
  assert.equal(oversized.matches.length, 1);
@@ -241,13 +237,13 @@ test("an over-budget note search match is a named prefix with an honest line add
241
237
  assert.ok(hugeLine.startsWith(match.text), "the match text is a plain prefix of the line");
242
238
  assert.equal(match.text.includes("…"), false, "no marker is appended to the match text");
243
239
  assert.equal(match.line, 1, "the informational line number survives");
244
- const atMatch = resultRead(await call(captured, "notes_read", { path: "search.md", offset_chars: match.offset_chars }, ctx));
240
+ const atMatch = resultRead(await call(captured, "notes_read", { address: "search.md", offset_chars: match.offset_chars }, ctx));
245
241
  assert.ok(atMatch.content.startsWith("needle"), "the search offset starts a read at the matched substring");
246
242
  // The body is reconstructible by following notes_read's cursor from the start of the file.
247
243
  const parts = [];
248
244
  let offset = 0;
249
245
  while (offset !== null) {
250
- const rawChunk = await call(captured, "notes_read", { path: "search.md", offset_chars: offset }, ctx);
246
+ const rawChunk = await call(captured, "notes_read", { address: "search.md", offset_chars: offset }, ctx);
251
247
  assertWithinBudget(rawChunk, `search.md chunk at ${offset}`);
252
248
  const chunk = resultRead(rawChunk);
253
249
  assert.equal(chunk.offset_chars, offset, "the read echoes the resolved address");
@@ -260,10 +256,10 @@ test("notes_search scopes by glob pattern; a non-matching pattern is an empty pa
260
256
  const session = manager();
261
257
  const captured = makeExtension(session);
262
258
  const ctx = context(session);
263
- await call(captured, "notes_write", { path: "deep/nested/a.md", content: "needle here" }, ctx);
264
- await call(captured, "notes_write", { path: "top.md", content: "needle there" }, ctx);
259
+ await call(captured, "notes_write", { address: "deep/nested/a.md", content: "needle here" }, ctx);
260
+ await call(captured, "notes_write", { address: "top.md", content: "needle there" }, ctx);
265
261
  const scoped = resultJson(await call(captured, "notes_search", { query: "needle", pattern: "deep/**" }, ctx));
266
- assert.deepEqual(scoped.files.map((file) => file.path), ["deep/nested/a.md"], "a glob scopes the search to the subtree");
262
+ assert.deepEqual(scoped.files.map((file) => file.address), ["deep/nested/a.md"], "a glob scopes the search to the subtree");
267
263
  const none = resultJson(await call(captured, "notes_search", { query: "needle", pattern: "absent/**" }, ctx));
268
264
  assert.equal(none.error, undefined, "a non-matching pattern is not an error");
269
265
  assert.deepEqual(none.files, [], "a non-matching pattern is an empty page");
@@ -0,0 +1 @@
1
+ export {};
@@ -7,22 +7,23 @@
7
7
  * HERMETIC: every test points PI_NOTES_HOME at its own temp root; no real ~/.agents is touched.
8
8
  */
9
9
  import assert from "node:assert/strict";
10
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { execFileSync } from "node:child_process";
11
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
11
12
  import { join } from "node:path";
12
13
  import test from "node:test";
13
- import { physicalPath } from "../src/notes/paths.js";
14
- import { call, context, installExtensionTestEnvironment, makeExtension, manager, resultJson, resultRead } from "./helpers/extension.js";
15
- const testEnvironment = installExtensionTestEnvironment("pi-context-notes");
16
- test.beforeEach(() => testEnvironment.beforeEach());
17
- test.afterEach(() => testEnvironment.afterEach());
18
- test.after(() => testEnvironment.dispose());
14
+ import { parseNote } from "../src/notes/frontmatter.js";
15
+ import { projectKey } from "../src/notes/paths.js";
16
+ import { listNotes, physicalPath, scopeDir } from "./helpers/notes.js";
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");
19
20
  function freshRoot() {
20
21
  return testEnvironment.newNotesRoot();
21
22
  }
22
23
  function setUpdatedAt(scope, path, ctx, timestamp) {
23
24
  const file = physicalPath(scope, path, ctx);
24
25
  const raw = readFileSync(file, "utf8");
25
- writeFileSync(file, raw.replace(/^updated_at: .*$/m, `updated_at: ${new Date(timestamp).toISOString()}`));
26
+ writeFileSync(file, raw.replace(/^updatedAt: .*$/m, `updatedAt: ${new Date(timestamp).toISOString()}`));
26
27
  }
27
28
  function assertNoPublicScope(value, label) {
28
29
  if (Array.isArray(value)) {
@@ -56,7 +57,7 @@ test("write lands a real markdown file with harness frontmatter and a pure body"
56
57
  const captured = makeExtension(session);
57
58
  const ctx = context(session);
58
59
  const sessionId = session.getSessionId();
59
- 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));
60
61
  assert.deepEqual(Object.keys(result).sort(), ["address", "written"]);
61
62
  const file = physicalPath("session", "a/b.md", ctx);
62
63
  assert.equal(file, join(root, "pi", "session", sessionId, "a", "b.md"));
@@ -64,39 +65,144 @@ test("write lands a real markdown file with harness frontmatter and a pure body"
64
65
  const raw = readFileSync(file, "utf8");
65
66
  assert.match(raw, /^---\n/, "the file opens with frontmatter");
66
67
  assert.match(raw, /\n---\n\nhello$/, "frontmatter is followed by a blank line and the exact body");
67
- for (const [key, value] of [["origin", "self"], ["status", "active"], ["stale", "false"], ["access_count", "0"]]) {
68
+ for (const [key, value] of [["origin", "self"], ["status", "active"], ["stale", "false"], ["accessCount", "0"]]) {
68
69
  assert.match(raw, new RegExp(`^${key}: ${value}$`, "m"), `frontmatter carries ${key}=${value}`);
69
70
  }
70
71
  assert.equal(/^scope:/m.test(raw), false, "scope is derived from the file home, never persisted");
71
- for (const key of ["created_at", "updated_at", "last_accessed"]) {
72
+ for (const key of ["createdAt", "updatedAt", "lastAccessed"]) {
72
73
  assert.match(raw, new RegExp(`^${key}: \\d{4}-\\d{2}-\\d{2}T`, "m"), `frontmatter renders ${key} via localIso`);
73
74
  }
75
+ assert.equal(parseNote(raw).meta.project, projectKey(ctx.cwd), "a newly-created session note records its existing project key");
74
76
  assert.equal(result.address, "a/b.md");
75
77
  assert.equal(result.written, true);
78
+ assert.equal(existsSync(join(scopeDir("session", ctx), ".session.json")), false, "ownership is stored in note frontmatter, not a sidecar");
76
79
  // A leading YAML block in user content is stripped from the body.
77
- 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);
78
81
  const stripped = readFileSync(physicalPath("session", "stripped.md", ctx), "utf8");
79
82
  assert.match(stripped, /\n---\n\nreal body$/, "the injected block is not part of the body");
80
83
  assert.equal(stripped.includes("nonsense"), false, "the injected block never reaches the file");
81
84
  });
85
+ test("session-note project ownership is per note, persistent across sessions, and not reassigned by cwd", async () => {
86
+ freshRoot();
87
+ const cwdA = join(testEnvironment.cwd, "project-a");
88
+ const cwdB = join(testEnvironment.cwd, "project-b");
89
+ mkdirSync(cwdA, { recursive: true });
90
+ mkdirSync(cwdB, { recursive: true });
91
+ const projectA = projectKey(cwdA);
92
+ const projectB = projectKey(cwdB);
93
+ assert.notEqual(projectA, projectB);
94
+ const firstSession = manager();
95
+ const firstCaptured = makeExtension(firstSession);
96
+ const firstCtx = context(firstSession, undefined, undefined, true, cwdA);
97
+ await call(firstCaptured, "notes_write", { address: "first.md", content: "first project session" }, firstCtx);
98
+ const firstFile = physicalPath("session", "first.md", firstCtx);
99
+ assert.equal(parseNote(readFileSync(firstFile, "utf8")).meta.project, projectA);
100
+ const secondSession = manager();
101
+ const secondCaptured = makeExtension(secondSession);
102
+ const secondCtx = context(secondSession, undefined, undefined, true, cwdA);
103
+ await call(secondCaptured, "notes_write", { address: "second.md", content: "same project, another session" }, secondCtx);
104
+ const secondFile = physicalPath("session", "second.md", secondCtx);
105
+ assert.equal(parseNote(readFileSync(secondFile, "utf8")).meta.project, projectA, "another session in the same project carries the matching key");
106
+ const thirdSession = manager();
107
+ const thirdCaptured = makeExtension(thirdSession);
108
+ const thirdCtx = context(thirdSession, undefined, undefined, true, cwdB);
109
+ await call(thirdCaptured, "notes_write", { address: "third.md", content: "different project" }, thirdCtx);
110
+ const thirdFile = physicalPath("session", "third.md", thirdCtx);
111
+ assert.equal(parseNote(readFileSync(thirdFile, "utf8")).meta.project, projectB);
112
+ const projectASessions = [firstFile, secondFile, thirdFile].filter((file) => parseNote(readFileSync(file, "utf8")).meta.project === projectA);
113
+ assert.deepEqual(projectASessions.sort(), [firstFile, secondFile].sort(), "exact frontmatter project matching recognizes only sessions from the same project");
114
+ const movedContext = context(firstSession, undefined, undefined, true, cwdB);
115
+ await call(firstCaptured, "notes_write", { address: "first.md", content: "overwritten from another cwd" }, movedContext);
116
+ assert.equal(parseNote(readFileSync(firstFile, "utf8")).meta.project, projectA, "overwriting an existing note does not silently reassign it");
117
+ await call(firstCaptured, "notes_edit", { address: "first.md", edits: [{ oldText: "overwritten", newText: "edited" }] }, movedContext);
118
+ assert.equal(parseNote(readFileSync(firstFile, "utf8")).meta.project, projectA, "editing an existing note preserves its original project key");
119
+ await call(firstCaptured, "notes_read", { address: "first.md" }, movedContext);
120
+ assert.equal(parseNote(readFileSync(firstFile, "utf8")).meta.project, projectA, "reading preserves existing project ownership");
121
+ await call(firstCaptured, "notes_write", { address: "new-from-project-b.md", content: "new note" }, movedContext);
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");
123
+ await call(firstCaptured, "notes_write", { address: "@project/project-note.md", content: "project home note" }, movedContext);
124
+ assert.equal(parseNote(readFileSync(physicalPath("project", "project-note.md", movedContext), "utf8")).meta.project, undefined, "project-home notes do not receive session ownership metadata");
125
+ assert.equal((await listNotes(movedContext, { scope: "session" })).length, 2, "project ownership remains frontmatter, not a separate note");
126
+ });
127
+ test("linked git worktrees share the main checkout's project key", () => {
128
+ freshRoot();
129
+ // realpath keeps the fixture's gitdir pointer and the asserted paths on one spelling
130
+ // (macOS temp roots live under the /var -> /private/var symlink).
131
+ const root = realpathSync(testEnvironment.cwd);
132
+ const main = join(root, "repo");
133
+ mkdirSync(main, { recursive: true });
134
+ const git = (args) => {
135
+ execFileSync("git", ["-c", "user.email=test@test", "-c", "user.name=test", ...args], { cwd: main, stdio: "ignore" });
136
+ };
137
+ git(["init", "-q"]);
138
+ git(["commit", "-q", "--allow-empty", "-m", "init"]);
139
+ const worktree = join(root, "wt");
140
+ git(["worktree", "add", "-q", "--detach", worktree]);
141
+ assert.equal(projectKey(worktree), projectKey(main), "a linked worktree resolves to the main checkout's key");
142
+ assert.equal(projectKey(join(worktree, "gone", "deeper")), projectKey(main), "a nonexistent subdirectory still resolves through its worktree");
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");
144
+ });
145
+ test("legacy metadata is refused for explicit manual migration; invalid project ownership stays unknown", async () => {
146
+ freshRoot();
147
+ const session = manager();
148
+ const captured = makeExtension(session);
149
+ const ctx = context(session);
150
+ const legacyFile = physicalPath("session", "legacy.md", ctx);
151
+ mkdirSync(scopeDir("session", ctx), { recursive: true });
152
+ writeFileSync(legacyFile, `---
153
+ origin: self
154
+ status: active
155
+ stale: false
156
+ created_at: 2026-01-01T00:00:00.000+00:00
157
+ updated_at: 2026-01-01T00:00:00.000+00:00
158
+ last_accessed: 2026-01-01T00:00:00.000+00:00
159
+ access_count: 0
160
+ ---
161
+
162
+ legacy body`);
163
+ assert.throws(() => parseNote(readFileSync(legacyFile, "utf8")), /legacy note metadata .*requires manual migration/);
164
+ const legacyBytes = readFileSync(legacyFile, "utf8");
165
+ await assert.rejects(() => listNotes(ctx, { scope: "session" }), /requires manual migration/);
166
+ await assert.rejects(() => call(captured, "notes_read", { address: "legacy.md" }, ctx), /requires manual migration/);
167
+ await assert.rejects(() => call(captured, "notes_write", { address: "legacy.md", content: "legacy overwritten" }, ctx), /requires manual migration/);
168
+ assert.equal(readFileSync(legacyFile, "utf8"), legacyBytes, "refusal preserves the unmigrated file byte-for-byte");
169
+ const invalidFile = physicalPath("session", "invalid.md", ctx);
170
+ writeFileSync(invalidFile, `---
171
+ origin: self
172
+ status: active
173
+ stale: false
174
+ createdAt: 2026-01-01T00:00:00.000+00:00
175
+ updatedAt: 2026-01-01T00:00:00.000+00:00
176
+ lastAccessed: 2026-01-01T00:00:00.000+00:00
177
+ accessCount: 0
178
+ project: 17
179
+ ---
180
+
181
+ invalid owner`);
182
+ assert.equal(parseNote(readFileSync(invalidFile, "utf8")).meta.project, 17);
183
+ assert.notEqual(parseNote(readFileSync(invalidFile, "utf8")).meta.project, projectKey(ctx.cwd), "invalid ownership does not match the current project key");
184
+ await call(captured, "notes_write", { address: "invalid.md", content: "still invalid" }, ctx);
185
+ assert.equal(parseNote(readFileSync(invalidFile, "utf8")).meta.project, 17, "an invalid value remains unknown and is not replaced with cwd-derived ownership");
186
+ assert.equal(existsSync(join(scopeDir("session", ctx), ".session.json")), false, "new notes use no ownership sidecar");
187
+ });
82
188
  test("edit is body-scoped with named failures and a replace_all escape hatch", async () => {
83
189
  freshRoot();
84
190
  const session = manager();
85
191
  const captured = makeExtension(session);
86
192
  const ctx = context(session);
87
- await call(captured, "notes_write", { path: "edit.md", content: "alpha\nbeta\nbeta\ngamma" }, ctx);
88
- const ambiguous = resultJson(await call(captured, "notes_edit", { path: "edit.md", edits: [{ oldText: "beta", newText: "B" }] }, ctx));
193
+ await call(captured, "notes_write", { address: "edit.md", content: "alpha\nbeta\nbeta\ngamma" }, ctx);
194
+ const ambiguous = resultJson(await call(captured, "notes_edit", { address: "edit.md", edits: [{ oldText: "beta", newText: "B" }] }, ctx));
89
195
  assert.match(ambiguous.error, /occurs 2 times/);
90
196
  assert.deepEqual(ambiguous.line_numbers, [2, 3], "the multi-match error carries every match line number");
91
- const missing = resultJson(await call(captured, "notes_edit", { path: "edit.md", edits: [{ oldText: "absent", newText: "x" }] }, ctx));
197
+ const missing = resultJson(await call(captured, "notes_edit", { address: "edit.md", edits: [{ oldText: "absent", newText: "x" }] }, ctx));
92
198
  assert.equal(missing.edit_index, 0, "a zero-match anchor names the failing edit index");
93
- const all = resultJson(await call(captured, "notes_edit", { path: "edit.md", edits: [{ oldText: "beta", newText: "B" }], replace_all: true }, ctx));
199
+ const all = resultJson(await call(captured, "notes_edit", { address: "edit.md", edits: [{ oldText: "beta", newText: "B" }], replace_all: true }, ctx));
94
200
  assert.equal(all.applied, 1);
95
201
  assert.equal(all.address, "edit.md");
96
202
  assertNoPublicScope(all, "notes_edit");
97
- assert.equal(resultRead(await call(captured, "notes_read", { path: "edit.md" }, ctx)).content.endsWith("alpha\nB\nB\ngamma"), true, "replace_all replaces every occurrence");
203
+ assert.equal(resultRead(await call(captured, "notes_read", { address: "edit.md" }, ctx)).content.endsWith("alpha\nB\nB\ngamma"), true, "replace_all replaces every occurrence");
98
204
  // An anchor that occurs only in frontmatter is not matched: edits are body-only.
99
- const frontmatterOnly = resultJson(await call(captured, "notes_edit", { path: "edit.md", edits: [{ oldText: "scope", newText: "x" }] }, ctx));
205
+ const frontmatterOnly = resultJson(await call(captured, "notes_edit", { address: "edit.md", edits: [{ oldText: "scope", newText: "x" }] }, ctx));
100
206
  assert.equal(frontmatterOnly.edit_index, 0, "a frontmatter-only anchor is not a body match");
101
207
  });
102
208
  test("nothing-to-do, not-found, atomic batches, and replace_all zero-match are named", async () => {
@@ -104,25 +210,25 @@ test("nothing-to-do, not-found, atomic batches, and replace_all zero-match are n
104
210
  const session = manager();
105
211
  const captured = makeExtension(session);
106
212
  const ctx = context(session);
107
- const nameOnly = resultJson(await call(captured, "notes_edit", { path: "edit.md" }, ctx));
213
+ const nameOnly = resultJson(await call(captured, "notes_edit", { address: "edit.md" }, ctx));
108
214
  assert.match(nameOnly.error, /nothing to do/, "neither edits nor setters is a named error");
109
- await call(captured, "notes_write", { path: "edit.md", content: "alpha\nbeta" }, ctx);
110
- const empty = resultJson(await call(captured, "notes_edit", { path: "edit.md", edits: [] }, ctx));
215
+ await call(captured, "notes_write", { address: "edit.md", content: "alpha\nbeta" }, ctx);
216
+ const empty = resultJson(await call(captured, "notes_edit", { address: "edit.md", edits: [] }, ctx));
111
217
  assert.match(empty.error, /nothing to do/, "an empty edits list with no setters is also nothing to do");
112
- const editMissing = resultJson(await call(captured, "notes_edit", { path: "missing.md", stale: true }, ctx));
218
+ const editMissing = resultJson(await call(captured, "notes_edit", { address: "missing.md", stale: true }, ctx));
113
219
  assert.equal(editMissing.error, "note not found");
114
- const readMissing = resultJson(await call(captured, "notes_read", { path: "missing.md" }, ctx));
220
+ const readMissing = resultJson(await call(captured, "notes_read", { address: "missing.md" }, ctx));
115
221
  assert.equal(readMissing.error, "note not found");
116
- assert.equal(readMissing.path, "missing.md");
222
+ assert.equal(readMissing.address, "missing.md");
117
223
  const file = physicalPath("session", "edit.md", ctx);
118
224
  const before = readFileSync(file, "utf8");
119
- const failed = resultJson(await call(captured, "notes_edit", { path: "edit.md", edits: [{ oldText: "alpha", newText: "A" }, { oldText: "absent", newText: "x" }] }, ctx));
225
+ const failed = resultJson(await call(captured, "notes_edit", { address: "edit.md", edits: [{ oldText: "alpha", newText: "A" }, { oldText: "absent", newText: "x" }] }, ctx));
120
226
  assert.equal(failed.edit_index, 1, "the failing edit is named");
121
227
  assert.equal(readFileSync(file, "utf8"), before, "a failing batch leaves the file byte-identical, frontmatter included");
122
- const applied = resultJson(await call(captured, "notes_edit", { path: "edit.md", edits: [{ oldText: "alpha", newText: "A" }, { oldText: "beta", newText: "B" }] }, ctx));
228
+ const applied = resultJson(await call(captured, "notes_edit", { address: "edit.md", edits: [{ oldText: "alpha", newText: "A" }, { oldText: "beta", newText: "B" }] }, ctx));
123
229
  assert.equal(applied.applied, 2);
124
- assert.equal(resultRead(await call(captured, "notes_read", { path: "edit.md" }, ctx)).content.endsWith("A\nB"), true);
125
- const zero = resultJson(await call(captured, "notes_edit", { path: "edit.md", edits: [{ oldText: "zzz", newText: "y" }], replace_all: true }, ctx));
230
+ assert.equal(resultRead(await call(captured, "notes_read", { address: "edit.md" }, ctx)).content.endsWith("A\nB"), true);
231
+ const zero = resultJson(await call(captured, "notes_edit", { address: "edit.md", edits: [{ oldText: "zzz", newText: "y" }], replace_all: true }, ctx));
126
232
  assert.equal(zero.edit_index, 0, "replace_all with zero matches is the same zero-match error, not a silent no-op");
127
233
  });
128
234
  test("all notes tool results use address as the only home identity", async () => {
@@ -162,16 +268,16 @@ test("list and search merge scopes and carry addresses; the path jail rejects es
162
268
  const session = manager();
163
269
  const captured = makeExtension(session);
164
270
  const ctx = context(session);
165
- await call(captured, "notes_write", { path: "one.md", content: "needle one", scope: "session" }, ctx);
166
- await call(captured, "notes_write", { path: "two.md", content: "needle two", scope: "project" }, ctx);
167
- await call(captured, "notes_write", { path: "three.md", content: "needle three", scope: "human" }, ctx);
271
+ await call(captured, "notes_write", { address: "one.md", content: "needle one" }, ctx);
272
+ await call(captured, "notes_write", { address: "@project/two.md", content: "needle two" }, ctx);
273
+ await call(captured, "notes_write", { address: "@human/three.md", content: "needle three" }, ctx);
168
274
  const listed = resultJson(await call(captured, "notes_list", {}, ctx));
169
275
  assert.deepEqual([...listed.files].map((file) => file.address).sort(), ["@human/three.md", "@project/two.md", "one.md"], "every merged row carries its full address");
170
276
  for (const row of listed.files) {
171
277
  assert.deepEqual(Object.keys(row).sort(), ["address", "stale", "updated_at"]);
172
278
  assert.equal(row.stale, false);
173
279
  }
174
- const scoped = resultJson(await call(captured, "notes_list", { scope: "human" }, ctx));
280
+ const scoped = resultJson(await call(captured, "notes_list", { pattern: "@human/**" }, ctx));
175
281
  assert.deepEqual(scoped.files.map((file) => file.address), ["@human/three.md"], "an address-pattern filter narrows the set");
176
282
  const searched = resultJson(await call(captured, "notes_search", { query: "needle" }, ctx));
177
283
  assert.equal(searched.files.length, 3, "literal search finds matches in every scope");
@@ -187,7 +293,7 @@ test("list and search merge scopes and carry addresses; the path jail rejects es
187
293
  const escaped = ["../evil", "/abs", "a\\b"];
188
294
  for (const tool of ["notes_write", "notes_edit", "notes_read"]) {
189
295
  for (const path of escaped) {
190
- await assert.rejects(() => call(captured, tool, { path, content: "x", edits: [{ oldText: "a", newText: "b" }] }, ctx), `${tool} rejects ${path}`);
296
+ await assert.rejects(() => call(captured, tool, { address: path, content: "x", edits: [{ oldText: "a", newText: "b" }] }, ctx), `${tool} rejects ${path}`);
191
297
  }
192
298
  }
193
299
  await assert.rejects(() => call(captured, "notes_list", { pattern: "bad\\glob" }, ctx), /backslash/);
@@ -209,7 +315,29 @@ test("@ addresses select one home, reject illegal sigils, and never fall back",
209
315
  await assert.rejects(() => call(captured, "notes_write", { address: "bad@name.md", content: "no" }, ctx), /@project\/.*@human\/.*bare names are this session/);
210
316
  assert.equal(existsSync(join(root, "human", "bad@name.md")), false, "a bad sigil creates nothing anywhere");
211
317
  });
212
- const FRONTMATTER = (body) => `---\norigin: self\nstatus: active\nstale: false\ncreated_at: 2026-01-01T00:00:00.000+00:00\nupdated_at: 2026-01-01T00:00:00.000+00:00\nlast_accessed: 2026-01-01T00:00:00.000+00:00\naccess_count: 0\n---\n\n${body}`;
318
+ test("Pi adapter resolves agent and switched model identity on each notes call and boot", async () => {
319
+ const root = freshRoot();
320
+ const session = manager();
321
+ const captured = makeExtension(session);
322
+ const ctx = context(session, undefined, undefined, true, testEnvironment.cwd, true, "provider/First.Model");
323
+ await withAgent("Test Agent", async () => {
324
+ await call(captured, "notes_write", { address: "@self/private.md", content: "agent note" }, ctx);
325
+ await call(captured, "notes_write", { address: "@model/private.md", content: "first model note" }, ctx);
326
+ ctx.model = context(session, undefined, undefined, true, testEnvironment.cwd, true, "other/Second.Model").model;
327
+ assert.equal(resultJson(await call(captured, "notes_read", { address: "@model/private.md" }, ctx)).error, "note not found", "switched model does not fall back to previous home");
328
+ await call(captured, "notes_write", { address: "@model/private.md", content: "second model note" }, ctx);
329
+ const listed = resultJson(await call(captured, "notes_list", {}, ctx));
330
+ assert.deepEqual(listed.files.map((row) => row.address).sort(), ["@agents/test-agent/private.md", "@models/second-model/private.md"]);
331
+ const boot = await explicitBoot(ctx, "test-window", undefined);
332
+ assert.ok(boot.includes("@models/second-model/private.md"));
333
+ assert.equal(boot.includes("@models/first-model/private.md"), false);
334
+ assert.match(resultRead(await call(captured, "notes_read", { address: "@models/first-model/private.md" }, ctx)).content, /first model note$/);
335
+ const refused = resultJson(await call(captured, "notes_edit", { address: "@models/first-model/private.md", stale: true }, ctx));
336
+ assert.match(refused.error, /not your home/);
337
+ assert.match(readFileSync(join(root, "models/first-model/private.md"), "utf8"), /first model note$/);
338
+ });
339
+ });
340
+ const FRONTMATTER = (body) => `---\norigin: self\nstatus: active\nstale: false\ncreatedAt: 2026-01-01T00:00:00.000+00:00\nupdatedAt: 2026-01-01T00:00:00.000+00:00\nlastAccessed: 2026-01-01T00:00:00.000+00:00\naccessCount: 0\n---\n\n${body}`;
213
341
  async function withAgent(name, run) {
214
342
  const previous = process.env.PI_NOTES_AGENT;
215
343
  if (name === undefined)
@@ -0,0 +1 @@
1
+ export {};