@astrosheep/pi-context 0.23.1 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +52 -5
  2. package/dist/build-info.json +4 -0
  3. package/dist/extension.js +1861 -0
  4. package/dist/src/context/budget.js +150 -0
  5. package/dist/src/context/context-window.js +97 -0
  6. package/dist/src/context/prompts.js +94 -0
  7. package/dist/src/context/reset-lifecycle.js +134 -0
  8. package/dist/src/context/runtime.js +236 -0
  9. package/dist/src/context/thresholds.js +62 -0
  10. package/dist/src/dream/cli.js +1 -1
  11. package/dist/src/dream/doctor.js +34 -6
  12. package/dist/src/dream/runner.js +1 -1
  13. package/dist/src/dream/settings.js +30 -0
  14. package/dist/src/{history-tools.js → history/history-tools.js} +3 -3
  15. package/dist/src/{history.js → history/history.js} +8 -46
  16. package/dist/src/index.js +27 -94
  17. package/dist/src/notes/address.js +97 -16
  18. package/dist/src/notes/frontmatter.js +18 -3
  19. package/dist/src/notes/notes-snapshot.js +30 -0
  20. package/dist/src/notes/paths.js +64 -7
  21. package/dist/src/notes/session-replay.js +41 -0
  22. package/dist/src/notes/store.js +76 -22
  23. package/dist/src/notes/tools.js +7 -7
  24. package/dist/src/protocol.js +11 -9
  25. package/dist/src/settings.js +16 -0
  26. package/dist/src/tool-schema.js +1 -1
  27. package/dist/test/agent-loop.test.js +813 -213
  28. package/dist/test/boot.integration.test.js +167 -0
  29. package/dist/test/budget-settings.integration.test.js +126 -0
  30. package/dist/test/doctor.test.js +14 -36
  31. package/dist/test/dream.test.js +37 -380
  32. package/dist/test/helpers/extension.js +393 -0
  33. package/dist/test/history.integration.test.js +316 -0
  34. package/dist/test/notes.integration.test.js +273 -0
  35. package/dist/test/notes.test.js +40 -370
  36. package/dist/test/reset-lifecycle.test.js +248 -180
  37. package/docs/architecture.md +35 -18
  38. package/docs/reset-lifecycle.md +16 -14
  39. package/package.json +11 -10
  40. package/src/context/budget.ts +148 -0
  41. package/src/context/context-window.ts +103 -0
  42. package/src/context/prompts.ts +111 -0
  43. package/src/context/reset-lifecycle.ts +145 -0
  44. package/src/context/runtime.ts +246 -0
  45. package/src/context/thresholds.ts +78 -0
  46. package/src/dream/cli.ts +1 -1
  47. package/src/dream/doctor.ts +27 -6
  48. package/src/dream/runner.ts +1 -1
  49. package/src/dream/settings.ts +32 -0
  50. package/src/{history-tools.ts → history/history-tools.ts} +3 -3
  51. package/src/{history.ts → history/history.ts} +9 -48
  52. package/src/index.ts +27 -89
  53. package/src/notes/address.ts +82 -16
  54. package/src/notes/frontmatter.ts +20 -3
  55. package/src/notes/notes-snapshot.ts +40 -0
  56. package/src/notes/paths.ts +64 -7
  57. package/src/notes/session-replay.ts +53 -0
  58. package/src/notes/store.ts +78 -25
  59. package/src/notes/tools.ts +7 -7
  60. package/src/protocol.ts +11 -9
  61. package/src/settings.ts +20 -0
  62. package/src/tool-schema.ts +1 -2
  63. package/dist/src/budget.js +0 -65
  64. package/dist/src/notes/model.js +0 -101
  65. package/dist/src/prompts.js +0 -88
  66. package/dist/src/reset-lifecycle.js +0 -155
  67. package/dist/src/thresholds.js +0 -102
  68. package/dist/src/warning.js +0 -44
  69. package/dist/test/coherence.test.js +0 -371
  70. package/dist/test/history.test.js +0 -26
  71. package/dist/test/integration.test.js +0 -1775
  72. package/dist/test/pagination.property.test.js +0 -471
  73. package/src/budget.ts +0 -67
  74. package/src/notes/model.ts +0 -109
  75. package/src/prompts.ts +0 -91
  76. package/src/reset-lifecycle.ts +0 -173
  77. package/src/thresholds.ts +0 -110
  78. package/src/warning.ts +0 -46
@@ -7,19 +7,17 @@
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, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
11
- import { tmpdir } from "node:os";
10
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
12
11
  import { join } from "node:path";
13
12
  import test from "node:test";
14
- import { physicalPath, projectKey, scopeDir } from "../src/notes/paths.js";
15
- import { listNotes } from "../src/notes/store.js";
16
- import { MAX_NOTE_BYTES, MAX_NOTE_PATH_BYTES, PROTOCOL_BLOCK } from "../src/protocol.js";
17
- import { call, context, makeExtension, manager, resultJson, resultRead, runHandlers } from "./integration.test.js";
18
- process.env.PI_CODING_AGENT_DIR = mkdtempSync(join(tmpdir(), "pi-context-notes-agent-"));
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());
19
19
  function freshRoot() {
20
- const root = mkdtempSync(join(tmpdir(), "pi-context-notes-"));
21
- process.env.PI_NOTES_HOME = root;
22
- return root;
20
+ return testEnvironment.newNotesRoot();
23
21
  }
24
22
  function setUpdatedAt(scope, path, ctx, timestamp) {
25
23
  const file = physicalPath(scope, path, ctx);
@@ -48,10 +46,6 @@ test("exactly the five notes tools are registered; the legacy five are gone", ()
48
46
  for (const legacy of ["notes_write_file", "notes_append_to_file", "notes_read_file", "notes_search_contents", "notes_list_files"]) {
49
47
  assert.equal(captured.tools.get(legacy), undefined, `${legacy} is unregistered`);
50
48
  }
51
- for (const name of ["notes_write", "notes_edit", "notes_read", "notes_list", "notes_search"]) {
52
- const description = captured.tools.get(name)?.description ?? "";
53
- assert.equal(/resolved_scope|\bscope\b/.test(description), false, `${name} describes home identity through address only`);
54
- }
55
49
  assert.equal(captured.tools.get("notes_write")?.executionMode, "sequential");
56
50
  assert.equal(captured.tools.get("notes_edit")?.executionMode, "sequential");
57
51
  assert.equal(captured.tools.get("notes_read")?.executionMode, undefined);
@@ -80,42 +74,11 @@ test("write lands a real markdown file with harness frontmatter and a pure body"
80
74
  assert.equal(result.address, "a/b.md");
81
75
  assert.equal(result.written, true);
82
76
  // A leading YAML block in user content is stripped from the body.
83
- await call(captured, "notes_write", { path: "stripped.md", content: "---\nscope: personal\nnonsense: true\n---\nreal body" }, ctx);
77
+ await call(captured, "notes_write", { path: "stripped.md", content: "---\nscope: human\nnonsense: true\n---\nreal body" }, ctx);
84
78
  const stripped = readFileSync(physicalPath("session", "stripped.md", ctx), "utf8");
85
79
  assert.match(stripped, /\n---\n\nreal body$/, "the injected block is not part of the body");
86
80
  assert.equal(stripped.includes("nonsense"), false, "the injected block never reaches the file");
87
81
  });
88
- test("overwrite preserves created_at and unknown keys, bumps updated_at, and clears stale", async () => {
89
- freshRoot();
90
- const session = manager();
91
- const captured = makeExtension(session);
92
- const ctx = context(session);
93
- const file = physicalPath("session", "keep.md", ctx);
94
- await call(captured, "notes_write", { path: "keep.md", content: "first", stale: true }, ctx);
95
- const created = listNotes(ctx, { scope: "session" })[0].meta.created_at;
96
- // Inject an unknown frontmatter key the way the sleep-shift layer will.
97
- const raw = readFileSync(file, "utf8");
98
- writeFileSync(file, raw.replace(/\n---\n\n/, "\nsleep_shift_key: \"keep-me\"\nrecurrence_count: 4\n---\n\n"));
99
- const rewrite = resultJson(await call(captured, "notes_write", { path: "keep.md", content: "second" }, ctx));
100
- const after = readFileSync(file, "utf8");
101
- assert.equal(after.includes("sleep_shift_key: keep-me"), true, "an unknown key survives a rewrite");
102
- assert.equal(after.includes("recurrence_count: 4"), true, "a known sleep-shift key survives a rewrite");
103
- assert.match(after, /\n---\n\nsecond$/, "the body is replaced");
104
- assert.deepEqual(Object.keys(rewrite).sort(), ["address", "written"]);
105
- const listed = listNotes(ctx, { scope: "session" })[0];
106
- assert.equal(listed.meta.created_at, created, "created_at is preserved across an overwrite");
107
- assert.ok(listed.meta.updated_at >= created, "updated_at is bumped");
108
- assert.equal(listed.meta.stale, false, "a plain rewrite clears stale");
109
- assert.equal(listed.meta.status, "active");
110
- });
111
- test("listNotes retains each parsed body for MAP injection", async () => {
112
- freshRoot();
113
- const session = manager();
114
- const captured = makeExtension(session);
115
- const ctx = context(session);
116
- await call(captured, "notes_write", { path: "retained.md", content: "parsed once" }, ctx);
117
- assert.equal(listNotes(ctx, { scope: "session" })[0]?.body, "parsed once");
118
- });
119
82
  test("edit is body-scoped with named failures and a replace_all escape hatch", async () => {
120
83
  freshRoot();
121
84
  const session = manager();
@@ -136,70 +99,6 @@ test("edit is body-scoped with named failures and a replace_all escape hatch", a
136
99
  const frontmatterOnly = resultJson(await call(captured, "notes_edit", { path: "edit.md", edits: [{ oldText: "scope", newText: "x" }] }, ctx));
137
100
  assert.equal(frontmatterOnly.edit_index, 0, "a frontmatter-only anchor is not a body match");
138
101
  });
139
- test("a single edit inserts newText byte-for-byte: no $-pattern substitution", async () => {
140
- freshRoot();
141
- const session = manager();
142
- const captured = makeExtension(session);
143
- const ctx = context(session);
144
- // Each pattern is a JS String.replace replacement token. With positional splicing the whole
145
- // two-character (or two-dollar) sequence lands literally; with String.replace it would expand,
146
- // and the prefix token ($`) would splice in the entire document prefix.
147
- const cases = ["$&", "$`", "$'", "$1", "$$"];
148
- for (const token of cases) {
149
- const newText = `pre${token}post`;
150
- await call(captured, "notes_write", { path: "literal.md", content: "alpha\nbeta\ngamma" }, ctx);
151
- const edited = resultJson(await call(captured, "notes_edit", { path: "literal.md", edits: [{ oldText: "beta", newText }] }, ctx));
152
- assert.equal(edited.applied, 1, `the single edit for ${JSON.stringify(token)} applied`);
153
- const body = resultRead(await call(captured, "notes_read", { path: "literal.md" }, ctx)).content;
154
- assert.equal(body.endsWith(`alpha\n${newText}\ngamma`), true, `${JSON.stringify(token)} is inserted literally`);
155
- assert.equal(body.endsWith(`alpha\nalpha\npre${token}post\ngamma`), false, `${JSON.stringify(token)} does not splice in the document prefix`);
156
- }
157
- // The replace_all branch (split/join) is likewise literal, so both branches agree.
158
- await call(captured, "notes_write", { path: "literal-all.md", content: "one X two X three" }, ctx);
159
- await call(captured, "notes_edit", { path: "literal-all.md", edits: [{ oldText: "X", newText: "$`$&$1$$" }], replace_all: true }, ctx);
160
- const allBody = resultRead(await call(captured, "notes_read", { path: "literal-all.md" }, ctx)).content;
161
- assert.equal(allBody.endsWith("one $`$&$1$$ two $`$&$1$$ three"), true, "replace_all inserts $-patterns literally too");
162
- });
163
- test("metadata-only edit updates setters without touching the body", async () => {
164
- freshRoot();
165
- const session = manager();
166
- const captured = makeExtension(session);
167
- const ctx = context(session);
168
- await call(captured, "notes_write", { path: "journal.md", content: "log line" }, ctx);
169
- const bare = resultJson(await call(captured, "notes_edit", { path: "journal.md", stale: true }, ctx));
170
- assert.equal(bare.applied, 0, "a metadata-only update applies no edits");
171
- assert.equal(bare.address, "journal.md");
172
- assert.deepEqual(Object.keys(bare).sort(), ["address", "applied", "diff"]);
173
- assert.equal(listNotes(ctx, { scope: "session" })[0].meta.stale, true, "stale is set without a body edit");
174
- assert.equal(resultRead(await call(captured, "notes_read", { path: "journal.md" }, ctx)).content.endsWith("log line"), true, "the body is untouched");
175
- const revived = resultJson(await call(captured, "notes_edit", { path: "journal.md", stale: false }, ctx));
176
- assert.deepEqual(Object.keys(revived).sort(), ["address", "applied", "diff"]);
177
- assert.equal(listNotes(ctx, { scope: "session" })[0].meta.stale, false, "a later metadata-only update revives the note");
178
- });
179
- test("notes_edit returns a pi-edit-style diff of what changed", async () => {
180
- freshRoot();
181
- const session = manager();
182
- const captured = makeExtension(session);
183
- const ctx = context(session);
184
- await call(captured, "notes_write", { path: "d.md", content: "alpha\nbeta" }, ctx);
185
- // Content edit → body diff.
186
- const bodyEdit = resultJson(await call(captured, "notes_edit", { path: "d.md", edits: [{ oldText: "beta", newText: "B" }] }, ctx));
187
- assert.match(bodyEdit.diff, /- *\d+ beta/);
188
- assert.match(bodyEdit.diff, /\+ *\d+ B/);
189
- assert.equal(bodyEdit.diff.includes("scope:"), false, "a content-only diff does not drag frontmatter in");
190
- // Metadata-only → frontmatter diff.
191
- const metaOnly = resultJson(await call(captured, "notes_edit", { path: "d.md", stale: true }, ctx));
192
- assert.equal(metaOnly.applied, 0);
193
- assert.match(metaOnly.diff, /- *\d+ stale: false/);
194
- assert.match(metaOnly.diff, /\+ *\d+ stale: true/);
195
- assert.equal(metaOnly.diff.includes("alpha"), false, "a metadata-only diff does not drag the body in");
196
- // Both → one combined diff naming body and frontmatter changes, without moving homes.
197
- const combined = resultJson(await call(captured, "notes_edit", { path: "d.md", edits: [{ oldText: "alpha", newText: "ALPHA" }], stale: false }, ctx));
198
- assert.match(combined.diff, /- *\d+ alpha/);
199
- assert.match(combined.diff, /\+ *\d+ ALPHA/);
200
- assert.match(combined.diff, /- *\d+ stale: true/);
201
- assert.match(combined.diff, /\+ *\d+ stale: false/);
202
- });
203
102
  test("nothing-to-do, not-found, atomic batches, and replace_all zero-match are named", async () => {
204
103
  freshRoot();
205
104
  const session = manager();
@@ -226,35 +125,6 @@ test("nothing-to-do, not-found, atomic batches, and replace_all zero-match are n
226
125
  const zero = resultJson(await call(captured, "notes_edit", { path: "edit.md", edits: [{ oldText: "zzz", newText: "y" }], replace_all: true }, ctx));
227
126
  assert.equal(zero.edit_index, 0, "replace_all with zero matches is the same zero-match error, not a silent no-op");
228
127
  });
229
- test.skip("scope resolution and movement are superseded by explicit address tests", async () => {
230
- freshRoot();
231
- const session = manager();
232
- const captured = makeExtension(session);
233
- const ctx = context(session);
234
- await call(captured, "notes_write", { path: "shared.md", content: "personal body", scope: "personal" }, ctx);
235
- await call(captured, "notes_write", { path: "shared.md", content: "session body", scope: "session" }, ctx);
236
- const first = resultRead(await call(captured, "notes_read", { path: "shared.md" }, ctx));
237
- assert.equal(first.details.scope, "session", "session wins the precedence over personal");
238
- const readAgain = resultRead(await call(captured, "notes_read", { path: "shared.md", scope: "session" }, ctx));
239
- assert.ok(readAgain.content.includes("session body"));
240
- const sessionMeta = listNotes(ctx, { scope: "session" })[0].meta;
241
- assert.equal(sessionMeta.access_count, 2, "each read bumps access_count");
242
- const personalMeta = listNotes(ctx, { scope: "personal" })[0].meta;
243
- assert.equal(personalMeta.access_count, 0, "the personal copy is untouched");
244
- // Move the session copy to project; the personal copy is untouched.
245
- const moved = resultJson(await call(captured, "notes_edit", { path: "shared.md", edits: [{ oldText: "session", newText: "moved" }], scope: "project" }, ctx));
246
- assert.equal(moved.meta.scope, "project");
247
- assert.equal(moved.resolved_scope, "session", "resolved_scope names the layer the file moved from");
248
- assert.equal(existsSync(physicalPath("session", "shared.md", ctx)), false, "the source file moved away");
249
- assert.equal(existsSync(physicalPath("project", "shared.md", ctx)), true, "the file now lives in the project scope");
250
- // A move onto an existing target is refused and both files survive unchanged.
251
- await call(captured, "notes_write", { path: "clash.md", content: "session stay", scope: "session" }, ctx);
252
- await call(captured, "notes_write", { path: "clash.md", content: "personal stay", scope: "personal" }, ctx);
253
- const beforePersonal = readFileSync(physicalPath("personal", "clash.md", ctx), "utf8");
254
- const refusal = resultJson(await call(captured, "notes_edit", { path: "clash.md", edits: [{ oldText: "stay", newText: "moved" }], scope: "personal" }, ctx));
255
- assert.match(refusal.error, /already exists/);
256
- assert.equal(readFileSync(physicalPath("personal", "clash.md", ctx), "utf8"), beforePersonal, "the target survives a refused move");
257
- });
258
128
  test("all notes tool results use address as the only home identity", async () => {
259
129
  freshRoot();
260
130
  const session = manager();
@@ -263,7 +133,7 @@ test("all notes tool results use address as the only home identity", async () =>
263
133
  const notes = [
264
134
  { address: "session.md", body: "session needle" },
265
135
  { address: "@project/project.md", body: "project needle" },
266
- { address: "@personal/personal.md", body: "personal needle" },
136
+ { address: "@human/human.md", body: "human needle" },
267
137
  ];
268
138
  for (const note of notes) {
269
139
  const written = resultJson(await call(captured, "notes_write", { address: note.address, content: note.body }, ctx));
@@ -294,18 +164,18 @@ test("list and search merge scopes and carry addresses; the path jail rejects es
294
164
  const ctx = context(session);
295
165
  await call(captured, "notes_write", { path: "one.md", content: "needle one", scope: "session" }, ctx);
296
166
  await call(captured, "notes_write", { path: "two.md", content: "needle two", scope: "project" }, ctx);
297
- await call(captured, "notes_write", { path: "three.md", content: "needle three", scope: "personal" }, ctx);
167
+ await call(captured, "notes_write", { path: "three.md", content: "needle three", scope: "human" }, ctx);
298
168
  const listed = resultJson(await call(captured, "notes_list", {}, ctx));
299
- assert.deepEqual([...listed.files].map((file) => file.address).sort(), ["@personal/three.md", "@project/two.md", "one.md"], "every merged row carries its full address");
169
+ assert.deepEqual([...listed.files].map((file) => file.address).sort(), ["@human/three.md", "@project/two.md", "one.md"], "every merged row carries its full address");
300
170
  for (const row of listed.files) {
301
171
  assert.deepEqual(Object.keys(row).sort(), ["address", "stale", "updated_at"]);
302
172
  assert.equal(row.stale, false);
303
173
  }
304
- const scoped = resultJson(await call(captured, "notes_list", { scope: "personal" }, ctx));
305
- assert.deepEqual(scoped.files.map((file) => file.address), ["@personal/three.md"], "an address-pattern filter narrows the set");
174
+ const scoped = resultJson(await call(captured, "notes_list", { scope: "human" }, ctx));
175
+ assert.deepEqual(scoped.files.map((file) => file.address), ["@human/three.md"], "an address-pattern filter narrows the set");
306
176
  const searched = resultJson(await call(captured, "notes_search", { query: "needle" }, ctx));
307
177
  assert.equal(searched.files.length, 3, "literal search finds matches in every scope");
308
- assert.deepEqual([...searched.files].map((file) => file.address).sort(), ["@personal/three.md", "@project/two.md", "one.md"]);
178
+ assert.deepEqual([...searched.files].map((file) => file.address).sort(), ["@human/three.md", "@project/two.md", "one.md"]);
309
179
  for (const row of [...listed.files, ...searched.files])
310
180
  assertNoPublicScope(row, "notes_list/search");
311
181
  assert.equal(searched.files.every((file) => file.matches_total === 1), true);
@@ -323,182 +193,6 @@ test("list and search merge scopes and carry addresses; the path jail rejects es
323
193
  await assert.rejects(() => call(captured, "notes_list", { pattern: "bad\\glob" }, ctx), /backslash/);
324
194
  await assert.rejects(() => call(captured, "notes_search", { query: "needle", pattern: "bad\\glob" }, ctx), /backslash/);
325
195
  });
326
- test("the boot index reads the physical store across scopes and excludes stale notes", async () => {
327
- freshRoot();
328
- const session = manager();
329
- const captured = makeExtension(session);
330
- const ctx = context(session);
331
- await call(captured, "notes_write", { path: "fresh.md", content: "fresh content" }, ctx);
332
- await call(captured, "notes_write", { path: "personal.md", content: "personal content", scope: "personal" }, ctx);
333
- await call(captured, "notes_write", { path: "old.md", content: "stale content", stale: true }, ctx);
334
- runHandlers(captured, "session_start", {}, ctx);
335
- const text = typeof captured.sent[0]?.message.content === "string" ? captured.sent[0].message.content : "";
336
- assert.ok(text.includes("fresh.md"), "a fresh session note is indexed");
337
- assert.ok(text.includes("personal.md"), "a fresh personal note is indexed");
338
- assert.equal(text.includes("old.md"), false, "a stale note leaves the index");
339
- assert.equal(text.includes("stale content"), false, "the stale note's body is absent from boot");
340
- for (const name of ["notes_write", "notes_edit", "notes_read", "notes_search", "notes_list"]) {
341
- assert.ok(PROTOCOL_BLOCK.includes(name), `the protocol block names ${name}`);
342
- }
343
- for (const legacy of ["notes_write_file", "notes_append_to_file", "notes_read_file", "notes_search_contents", "notes_list_files"]) {
344
- assert.equal(PROTOCOL_BLOCK.includes(legacy), false, `the protocol block no longer names ${legacy}`);
345
- }
346
- });
347
- test("search offsets start reads at Unicode matches across homes without mutating search results", async () => {
348
- freshRoot();
349
- const session = manager();
350
- const captured = makeExtension(session);
351
- const ctx = context(session);
352
- const notes = [
353
- { address: "session.md", scope: "session", body: "first line\n前置 🐉 needle-session\nend", needle: "needle-session" },
354
- { address: "@project/crossing.md", scope: "project", body: "prefix\nneedle-project 😀", needle: "needle-project" },
355
- { address: "@personal/legacy.md", scope: "personal", body: "legacy 😺 needle-personal", needle: "needle-personal" },
356
- ];
357
- for (const note of notes)
358
- await call(captured, "notes_write", { address: note.address, content: note.body }, ctx);
359
- const crossing = physicalPath("project", "crossing.md", ctx);
360
- writeFileSync(crossing, readFileSync(crossing, "utf8").replace(/^access_count: 0$/m, "access_count: 9"));
361
- const legacy = physicalPath("personal", "legacy.md", ctx);
362
- writeFileSync(legacy, notes[2].body);
363
- const before = new Map(notes.map((note) => [note.address, readFileSync(physicalPath(note.scope, note.address.replace(/^@(?:project|personal)\//, ""), ctx), "utf8")]));
364
- const searched = resultJson(await call(captured, "notes_search", { query: notes.map((note) => note.needle), pattern: "**" }, ctx));
365
- for (const note of notes) {
366
- assert.equal(readFileSync(physicalPath(note.scope, note.address.replace(/^@(?:project|personal)\//, ""), ctx), "utf8"), before.get(note.address), `search leaves ${note.address} byte-identical`);
367
- const file = searched.files.find((candidate) => candidate.address === note.address);
368
- assert.ok(file, `search returns ${note.address}`);
369
- assert.deepEqual(Object.keys(file).sort(), ["address", "matches", "matches_total", "stale", "updated_at"]);
370
- const hit = file.matches[0];
371
- assert.deepEqual(Object.keys(hit).sort(), ["line", "offset_chars", "text", "truncated"]);
372
- const read = resultRead(await call(captured, "notes_read", { address: note.address, offset_chars: hit.offset_chars }, ctx));
373
- assert.ok(read.content.startsWith(note.needle), `search offset starts notes_read at ${note.needle}`);
374
- assert.deepEqual(Object.keys(read.details).sort(), ["address", "next_offset_chars", "offset_chars", "total_chars"]);
375
- }
376
- assert.match(readFileSync(crossing, "utf8"), /^access_count: 10$/m, "the predicted read crosses access_count from 9 to 10");
377
- assert.match(resultRead(await call(captured, "notes_read", { address: "@personal/legacy.md" }, ctx)).content, /^---\n/, "a missing-frontmatter note is normalized only by read");
378
- });
379
- test("notes_read surfaces frontmatter and search reports body lines", async () => {
380
- freshRoot();
381
- const session = manager();
382
- const captured = makeExtension(session);
383
- const ctx = context(session);
384
- await call(captured, "notes_write", { path: "compose.md", content: "line one\nneedle address\nline three" }, ctx);
385
- const read = resultRead(await call(captured, "notes_read", { path: "compose.md" }, ctx));
386
- assert.match(read.content, /^---\n/, "the model sees the frontmatter first");
387
- assert.ok(read.content.includes("needle address"));
388
- const searched = resultJson(await call(captured, "notes_search", { query: "needle" }, ctx));
389
- const match = searched.files[0].matches[0];
390
- assert.equal(match.line, 2, "search reports the body line number");
391
- assert.equal(match.offset_chars, read.content.indexOf("needle"), "offset_chars addresses the query in the serialized read stream");
392
- });
393
- test("mutations are atomic, leave no temp files, and a read bumps only the access keys", async () => {
394
- freshRoot();
395
- const session = manager();
396
- const captured = makeExtension(session);
397
- const ctx = context(session);
398
- await call(captured, "notes_write", { path: "atomic.md", content: "alpha\nbeta" }, ctx);
399
- await call(captured, "notes_edit", { path: "atomic.md", edits: [{ oldText: "alpha", newText: "A" }] }, ctx);
400
- await call(captured, "notes_read", { path: "atomic.md" }, ctx);
401
- const first = readFileSync(physicalPath("session", "atomic.md", ctx), "utf8");
402
- assert.equal(first.includes("alpha"), false, "the edit landed");
403
- const secondRead = resultRead(await call(captured, "notes_read", { path: "atomic.md" }, ctx));
404
- assert.ok(secondRead.content.endsWith("A\nbeta"), "a second read still delivers the body");
405
- const afterRead = readFileSync(physicalPath("session", "atomic.md", ctx), "utf8");
406
- const lineOf = (text, key) => text.split("\n").find((line) => line.startsWith(`${key}:`));
407
- for (const key of ["created_at", "updated_at", "origin", "status", "scope"]) {
408
- assert.equal(lineOf(afterRead, key), lineOf(first, key), `${key} stays byte-stable across a read`);
409
- }
410
- assert.equal(listNotes(ctx, { scope: "session" })[0].meta.access_count, 2, "two reads bump access_count twice");
411
- // No torn-write temp files survive any mutation.
412
- const temps = [];
413
- const walk = (dir) => {
414
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
415
- if (entry.isDirectory())
416
- walk(join(dir, entry.name));
417
- else if (entry.name.endsWith(".tmp"))
418
- temps.push(join(dir, entry.name));
419
- }
420
- };
421
- walk(process.env.PI_NOTES_HOME);
422
- assert.deepEqual(temps, [], "tmp files are renamed away, never left behind");
423
- });
424
- test("write-time caps refuse an oversized vpath or serialized file, and edit refuses an oversized result", async () => {
425
- freshRoot();
426
- const session = manager();
427
- const captured = makeExtension(session);
428
- const ctx = context(session);
429
- const seg = "b".repeat(120);
430
- const okPath = [seg, seg, seg, seg, "x"].join("/");
431
- const overPath = `${okPath}${seg}`;
432
- assert.ok(Buffer.byteLength(okPath, "utf8") <= MAX_NOTE_PATH_BYTES);
433
- assert.ok(Buffer.byteLength(overPath, "utf8") > MAX_NOTE_PATH_BYTES);
434
- const refusedPath = resultJson(await call(captured, "notes_write", { path: overPath, content: "x" }, ctx));
435
- assert.match(refusedPath.error, new RegExp(String(MAX_NOTE_PATH_BYTES)), "an over-cap vpath names the cap");
436
- const accepted = resultJson(await call(captured, "notes_write", { path: okPath, content: "x" }, ctx));
437
- assert.equal(accepted.path, okPath, "a path at the cap is accepted");
438
- const refusedBody = resultJson(await call(captured, "notes_write", { path: "big.md", content: "x".repeat(MAX_NOTE_BYTES) }, ctx));
439
- assert.match(refusedBody.error, new RegExp(String(MAX_NOTE_BYTES)), "an over-cap body names the size cap");
440
- await call(captured, "notes_write", { path: "small.md", content: "small" }, ctx);
441
- const refusedEdit = resultJson(await call(captured, "notes_edit", { path: "small.md", edits: [{ oldText: "small", newText: "y".repeat(MAX_NOTE_BYTES) }] }, ctx));
442
- assert.match(refusedEdit.error, new RegExp(String(MAX_NOTE_BYTES)), "an edit that would exceed the cap is refused");
443
- });
444
- test("fresh personal and project MAP.md bodies are both resident before the pocket", async () => {
445
- freshRoot();
446
- const session = manager();
447
- const captured = makeExtension(session);
448
- const ctx = context(session);
449
- await call(captured, "notes_write", { address: "MAP.md", content: "MAP: session" }, ctx);
450
- await call(captured, "notes_write", { address: "@project/MAP.md", content: "MAP: project" }, ctx);
451
- await call(captured, "notes_write", { address: "@personal/MAP.md", content: "MAP: personal\nMAP: second" }, ctx);
452
- await call(captured, "notes_write", { path: "recent.md", content: "recent body" }, ctx);
453
- runHandlers(captured, "session_start", {}, ctx);
454
- const boot = typeof captured.sent.at(-1)?.message.content === "string" ? captured.sent.at(-1).message.content : "";
455
- assert.ok(boot.includes("MAP: personal"), "the personal map is injected");
456
- assert.ok(boot.includes("MAP: project"), "the project map is injected");
457
- assert.equal(boot.includes("MAP: session"), false, "the session map is never injected");
458
- assert.ok(boot.indexOf("MAP: personal") < boot.indexOf("MAP: project"), "the personal map precedes the project map");
459
- assert.ok(boot.indexOf("MAP: project") < boot.indexOf("crumpled note"), "both map bodies precede the pocket");
460
- assert.ok(PROTOCOL_BLOCK.includes("notes_write"), "the protocol text still rides along");
461
- });
462
- test("the boot pocket applies per-home quotas in session, project, personal order", async () => {
463
- freshRoot();
464
- const session = manager();
465
- const captured = makeExtension(session);
466
- const ctx = context(session);
467
- const base = Date.parse("2026-01-01T00:00:00.000Z");
468
- for (const [scope, count] of [["session", 6], ["project", 3], ["personal", 3]]) {
469
- for (let index = 0; index < count; index++) {
470
- const path = `${scope}-${index}.md`;
471
- await call(captured, "notes_write", { path, content: `${scope} body`, scope }, ctx);
472
- setUpdatedAt(scope, path, ctx, base + index * 1_000);
473
- }
474
- }
475
- await call(captured, "notes_write", { address: "MAP.md", content: "MAP: session" }, ctx);
476
- await call(captured, "notes_write", { address: "@project/MAP.md", content: "MAP: project" }, ctx);
477
- await call(captured, "notes_write", { address: "@personal/MAP.md", content: "MAP: personal" }, ctx);
478
- runHandlers(captured, "session_start", {}, ctx);
479
- const boot = typeof captured.sent.at(-1)?.message.content === "string" ? captured.sent.at(-1).message.content : "";
480
- assert.ok(boot.includes("You find 9 crumpled notes in your pocket (by home, most recent first within each: up to 5 from this session, 2 from this project, 2 from personal). A note's content never appears here, so its name has to say what the note is about:"), "the pocket line matches the dictated copy");
481
- for (const name of ["session-5.md", "session-4.md", "session-3.md", "session-2.md", "session-1.md", "@project/project-2.md", "@project/project-1.md", "@personal/personal-2.md", "@personal/personal-1.md"]) {
482
- assert.ok(boot.includes(name), `${name} stays in the pocket`);
483
- }
484
- for (const name of ["session-0.md", "@project/project-0.md", "@personal/personal-0.md", "MAP.md", "MAP: session"]) {
485
- assert.equal(boot.includes(name), false, `${name} is not a pocket entry`);
486
- }
487
- assert.ok(boot.indexOf("session-5.md") < boot.indexOf("session-4.md"), "session notes are most-recent-first");
488
- assert.ok(boot.indexOf("@project/project-2.md") < boot.indexOf("@project/project-1.md"), "project notes are most-recent-first");
489
- assert.ok(boot.indexOf("@personal/personal-2.md") < boot.indexOf("@personal/personal-1.md"), "personal notes are most-recent-first");
490
- assert.ok(boot.indexOf("session-1.md") < boot.indexOf("@project/project-2.md"), "session notes precede project notes");
491
- assert.ok(boot.indexOf("@project/project-1.md") < boot.indexOf("@personal/personal-2.md"), "project notes precede personal notes");
492
- });
493
- test("project scope keys off the git root basename and sha1 prefix", () => {
494
- const root = mkdtempSync(join(tmpdir(), "pi-context-proj-"));
495
- process.env.PI_NOTES_HOME = mkdtempSync(join(tmpdir(), "pi-context-notes-"));
496
- const session = manager();
497
- const ctx = context(session, undefined, undefined, true, root);
498
- const key = projectKey(root);
499
- assert.match(key, /^pi-context-proj-[^-]+-[0-9a-f]{8}$/, "the project key is basename plus an 8-hex sha1 prefix");
500
- assert.equal(scopeDir("project", ctx), join(process.env.PI_NOTES_HOME, "project", key));
501
- });
502
196
  test("@ addresses select one home, reject illegal sigils, and never fall back", async () => {
503
197
  const root = freshRoot();
504
198
  const session = manager();
@@ -506,55 +200,31 @@ test("@ addresses select one home, reject illegal sigils, and never fall back",
506
200
  const ctx = context(session);
507
201
  await call(captured, "notes_write", { address: "same.md", content: "session" }, ctx);
508
202
  await call(captured, "notes_write", { address: "@project/same.md", content: "project" }, ctx);
509
- await call(captured, "notes_write", { address: "@personal/same.md", content: "personal" }, ctx);
203
+ await call(captured, "notes_write", { address: "@human/same.md", content: "human" }, ctx);
510
204
  assert.ok(existsSync(physicalPath("project", "same.md", ctx)), "@project writes to the current project home");
511
- assert.ok(existsSync(physicalPath("personal", "same.md", ctx)), "@personal writes to the personal home");
205
+ assert.ok(existsSync(physicalPath("human", "same.md", ctx)), "@human writes to the human home");
512
206
  assert.match(resultRead(await call(captured, "notes_read", { address: "same.md" }, ctx)).content, /session$/);
513
207
  assert.equal(resultJson(await call(captured, "notes_read", { address: "@project/missing.md" }, ctx)).error, "note not found");
514
- await assert.rejects(() => call(captured, "notes_read", { address: "@glboal/same.md" }, ctx), /@project\/.*@personal\/.*bare names are the session home/);
515
- await assert.rejects(() => call(captured, "notes_write", { address: "bad@name.md", content: "no" }, ctx), /@project\/.*@personal\/.*bare names are the session home/);
516
- assert.equal(existsSync(join(root, "personal", "bad@name.md")), false, "a bad sigil creates nothing anywhere");
517
- });
518
- test("full addresses drive outputs and patterns; on-disk scope is read then dropped", async () => {
519
- freshRoot();
520
- const session = manager();
521
- const captured = makeExtension(session);
522
- const ctx = context(session);
523
- await call(captured, "notes_write", { address: "root.md", content: "needle" }, ctx);
524
- await call(captured, "notes_write", { address: "@project/project.md", content: "needle" }, ctx);
525
- await call(captured, "notes_write", { address: "@personal/personal.md", content: "needle" }, ctx);
526
- const list = resultJson(await call(captured, "notes_list", { pattern: "**" }, ctx));
527
- assert.deepEqual(list.files.map((file) => file.address).sort(), ["@personal/personal.md", "@project/project.md", "root.md"]);
528
- assert.deepEqual(resultJson(await call(captured, "notes_list", { pattern: "*.md" }, ctx)).files.map((file) => file.address), ["root.md"]);
529
- assert.deepEqual(resultJson(await call(captured, "notes_search", { query: "needle", pattern: "@project/**" }, ctx)).files.map((file) => file.address), ["@project/project.md"]);
530
- const read = resultRead(await call(captured, "notes_read", { address: "@personal/personal.md" }, ctx));
531
- assert.equal(read.header, "--- READ WINDOW ---\naddress: @personal/personal.md\nchars: [0," + read.total_chars + ") of " + read.total_chars + "\nnext_offset_chars: null\n", "the raw READ WINDOW block echoes the full address");
532
- const legacy = physicalPath("project", "legacy.md", ctx);
533
- writeFileSync(legacy, "---\nscope: personal\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\nlegacy");
534
- const legacyRead = resultRead(await call(captured, "notes_read", { address: "@project/legacy.md" }, ctx));
535
- assert.equal(legacyRead.details.address, "@project/legacy.md", "the read keeps the requested address while deriving its home internally");
536
- assertNoPublicScope(legacyRead.details, "legacy notes_read");
537
- await call(captured, "notes_edit", { address: "@project/legacy.md", stale: true }, ctx);
538
- assert.equal(/^scope:/m.test(readFileSync(legacy, "utf8")), false, "the next write removes legacy scope frontmatter");
539
- });
540
- test("stale project and personal maps are skipped independently", async () => {
541
- freshRoot();
542
- const session = manager();
543
- const captured = makeExtension(session);
544
- const ctx = context(session);
545
- await call(captured, "notes_write", { address: "@project/MAP.md", content: "project fresh" }, ctx);
546
- await call(captured, "notes_write", { address: "@personal/MAP.md", content: "personal stale", stale: true }, ctx);
547
- runHandlers(captured, "session_start", {}, ctx);
548
- let boot = String(captured.sent.at(-1)?.message.content ?? "");
549
- assert.ok(boot.includes("project fresh"), "a fresh project map survives a stale personal map");
550
- assert.equal(boot.includes("personal stale"), false, "the stale personal map is skipped");
551
- const second = manager();
552
- const secondCaptured = makeExtension(second);
553
- const secondCtx = context(second);
554
- await call(secondCaptured, "notes_edit", { address: "@project/MAP.md", stale: true }, secondCtx);
555
- await call(secondCaptured, "notes_edit", { address: "@personal/MAP.md", stale: false }, secondCtx);
556
- runHandlers(secondCaptured, "session_start", {}, secondCtx);
557
- boot = String(secondCaptured.sent.at(-1)?.message.content ?? "");
558
- assert.equal(boot.includes("project fresh"), false, "the stale project map is skipped");
559
- assert.ok(boot.includes("personal stale"), "a fresh personal map survives a stale project map");
560
- });
208
+ await assert.rejects(() => call(captured, "notes_read", { address: "@glboal/same.md" }, ctx), /@project\/.*@human\/.*bare names are the session home/);
209
+ await assert.rejects(() => call(captured, "notes_write", { address: "bad@name.md", content: "no" }, ctx), /@project\/.*@human\/.*bare names are the session home/);
210
+ assert.equal(existsSync(join(root, "human", "bad@name.md")), false, "a bad sigil creates nothing anywhere");
211
+ });
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}`;
213
+ async function withAgent(name, run) {
214
+ const previous = process.env.PI_NOTES_AGENT;
215
+ if (name === undefined)
216
+ delete process.env.PI_NOTES_AGENT;
217
+ else
218
+ process.env.PI_NOTES_AGENT = name;
219
+ try {
220
+ // A plain `return run()` would run the finally block the moment the promise pends,
221
+ // restoring the env while the callback still has awaits ahead of it.
222
+ await run();
223
+ }
224
+ finally {
225
+ if (previous === undefined)
226
+ delete process.env.PI_NOTES_AGENT;
227
+ else
228
+ process.env.PI_NOTES_AGENT = previous;
229
+ }
230
+ }