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