@astrosheep/pi-context 0.24.0 → 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 +7 -5
  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 -221
  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 -359
  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 +7 -5
  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 -1759
  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
@@ -0,0 +1,316 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { historyFromSession } from "../src/index.js";
4
+ import { TOOL_OUTPUT_MAX_BYTES } from "../src/tool-output.js";
5
+ import { appendText, assertWithinBudget, call, context, installExtensionTestEnvironment, makeExtension, manager, objectSchema, resultJson, resultRead, } from "./helpers/extension.js";
6
+ const testEnvironment = installExtensionTestEnvironment("pi-context-integration");
7
+ test.beforeEach(() => testEnvironment.beforeEach());
8
+ test.afterEach(() => testEnvironment.afterEach());
9
+ test.after(() => testEnvironment.dispose());
10
+ test("schemas cover the History/Notes actions plus reset controls", () => {
11
+ const captured = makeExtension(manager());
12
+ for (const name of [
13
+ "history_windows", "history_list", "history_read", "history_search",
14
+ "notes_list", "notes_read", "notes_search", "notes_edit", "notes_write",
15
+ "wipe_memory", "get_context_remaining",
16
+ ]) {
17
+ const tool = captured.tools.get(name);
18
+ assert.equal(objectSchema(tool)?.type, "object", name);
19
+ }
20
+ assert.equal(objectSchema(captured.tools.get("history_read"))?.required?.includes("item_id"), true);
21
+ // The write surface requires its body; the edit surface requires its anchors.
22
+ const writeSchema = captured.tools.get("notes_write")?.parameters;
23
+ assert.ok(writeSchema?.properties?.content, "notes_write exposes content");
24
+ assert.ok(writeSchema?.properties?.address, "notes_write exposes address");
25
+ assert.equal(writeSchema?.properties?.scope, undefined, "notes_write has no scope parameter");
26
+ assert.deepEqual([...(writeSchema?.required ?? [])].sort(), ["address", "content"], "notes_write requires address and content");
27
+ const editSchema = captured.tools.get("notes_edit")?.parameters;
28
+ assert.ok(editSchema?.properties?.edits, "notes_edit exposes edits");
29
+ assert.equal(editSchema?.properties?.scope, undefined, "notes_edit has no scope parameter");
30
+ assert.deepEqual([...(editSchema?.required ?? [])].sort(), ["address"], "notes_edit requires only address; edits are optional for metadata-only updates");
31
+ // Both read tools are the same character window: identical params, one offset sugar, no line surface.
32
+ for (const name of ["notes_read", "history_read"]) {
33
+ const schema = captured.tools.get(name)?.parameters;
34
+ assert.ok(schema?.properties?.offset_chars, `${name} exposes offset_chars`);
35
+ assert.ok(schema?.properties?.limit_chars, `${name} exposes limit_chars`);
36
+ assert.equal(schema?.properties?.offset_chars?.minimum, undefined, `${name} accepts negative offset_chars`);
37
+ assert.equal(schema?.properties?.limit_chars?.maximum, 50000, `${name} caps limit_chars at 50000`);
38
+ }
39
+ const noteReadSchema = captured.tools.get("notes_read")?.parameters;
40
+ assert.deepEqual(Object.keys(noteReadSchema?.properties ?? {}).sort(), ["address", "limit_chars", "offset_chars"], "notes_read exposes exactly address and character-window params");
41
+ for (const name of ["notes_write", "notes_edit", "notes_read", "notes_list", "notes_search"]) {
42
+ const schema = captured.tools.get(name)?.parameters;
43
+ assert.equal(schema?.properties?.scope, undefined, `${name} has no scope property`);
44
+ assert.equal(schema?.additionalProperties, false, `${name} rejects scope as an additional property`);
45
+ }
46
+ });
47
+ test("paged tool outputs stay bounded and cursors reconstruct history and notes", async () => {
48
+ const session = manager();
49
+ const captured = makeExtension(session);
50
+ const ctx = context(session);
51
+ const historyText = "历史内容-" + "x".repeat(50_000);
52
+ const historyIds = [appendText(session, "user", historyText), appendText(session, "user", historyText), appendText(session, "user", historyText)];
53
+ for (let index = 0; index < 10; index++)
54
+ appendText(session, "user", historyText);
55
+ const historyPages = [];
56
+ let cursor = 0;
57
+ let next = 0;
58
+ while (next !== null) {
59
+ const result = resultJson(await call(captured, "history_list", { recent_first: false, max_chars_per_item: 1200, cursor }, ctx));
60
+ assert.ok(Buffer.byteLength(JSON.stringify(result), "utf8") <= TOOL_OUTPUT_MAX_BYTES);
61
+ historyPages.push(...result.items);
62
+ next = result.next_cursor;
63
+ if (next !== null)
64
+ cursor = next;
65
+ }
66
+ assert.deepEqual(historyPages.filter((item) => historyIds.includes(item.item_id)).map((item) => item.item_id), historyIds);
67
+ const search = resultJson(await call(captured, "history_search", { query: "历史内容", recent_first: false, max_chars_per_item: 50_000 }, ctx));
68
+ assert.ok(Buffer.byteLength(JSON.stringify(search), "utf8") <= TOOL_OUTPUT_MAX_BYTES);
69
+ assert.notEqual(search.next_cursor, null);
70
+ const searchPages = [];
71
+ let searchOffset = 0;
72
+ let searchNext = 0;
73
+ while (searchNext !== null) {
74
+ const result = resultJson(await call(captured, "history_search", { query: "历史内容", recent_first: false, max_chars_per_item: 1200, cursor: searchOffset }, ctx));
75
+ assert.ok(Buffer.byteLength(JSON.stringify(result), "utf8") <= TOOL_OUTPUT_MAX_BYTES);
76
+ searchPages.push(...result.items);
77
+ searchNext = result.next_cursor;
78
+ if (searchNext !== null)
79
+ searchOffset = searchNext;
80
+ }
81
+ assert.equal(searchPages.length, 13);
82
+ assert.equal(searchNext, null);
83
+ const readParts = [];
84
+ let readOffset = 0;
85
+ let readNext = 0;
86
+ while (readNext !== null) {
87
+ const raw = await call(captured, "history_read", { window_id: historyFromSession(ctx)[0].windowId, item_id: historyIds[0], offset_chars: readOffset, limit_chars: 12000 }, ctx);
88
+ assertWithinBudget(raw, `history_read page at ${readOffset}`);
89
+ const result = resultRead(raw);
90
+ readParts.push(result.content);
91
+ readNext = result.next_offset_chars;
92
+ if (readNext !== null)
93
+ readOffset = readNext;
94
+ }
95
+ assert.equal(readParts.join(""), historyText);
96
+ for (let index = 0; index < 100; index++) {
97
+ await call(captured, "notes_write", { path: `page-${"x".repeat(120)}-${index}.md`, content: Array.from({ length: 1000 }, (_, line) => `needle ${line} ${"z".repeat(30)}`).join("\n") }, ctx);
98
+ }
99
+ const listPages = [];
100
+ let listOffset = 0;
101
+ let listNext = 0;
102
+ while (listNext !== null) {
103
+ const result = resultJson(await call(captured, "notes_list", { max_results: 300, cursor: listOffset }, ctx));
104
+ assert.ok(Buffer.byteLength(JSON.stringify(result), "utf8") <= TOOL_OUTPUT_MAX_BYTES);
105
+ listPages.push(...result.files.map((file) => file.path));
106
+ listNext = result.next_cursor;
107
+ if (listNext !== null)
108
+ listOffset = listNext;
109
+ }
110
+ assert.deepEqual([...listPages].sort((a, b) => a.localeCompare(b)), Array.from({ length: 100 }, (_, index) => `page-${"x".repeat(120)}-${index}.md`).sort((a, b) => a.localeCompare(b)));
111
+ assert.equal(listNext, null);
112
+ const searchFiles = [];
113
+ let notesSearchOffset = 0;
114
+ let notesSearchNext = 0;
115
+ while (notesSearchNext !== null) {
116
+ const result = resultJson(await call(captured, "notes_search", { query: "needle", max_matches_per_file: 100, max_files: 300, cursor: notesSearchOffset }, ctx));
117
+ assert.ok(Buffer.byteLength(JSON.stringify(result), "utf8") <= TOOL_OUTPUT_MAX_BYTES);
118
+ searchFiles.push(...result.files);
119
+ notesSearchNext = result.next_cursor;
120
+ if (notesSearchNext !== null)
121
+ notesSearchOffset = notesSearchNext;
122
+ }
123
+ assert.equal(searchFiles.length, 100);
124
+ assert.equal(notesSearchNext, null);
125
+ const bodyText = Array.from({ length: 1000 }, (_, line) => `needle ${line} ${"z".repeat(30)}`).join("\n");
126
+ const noteParts = [];
127
+ let noteOffset = 0;
128
+ let noteNext = 0;
129
+ while (noteNext !== null) {
130
+ const raw = await call(captured, "notes_read", { path: `page-${"x".repeat(120)}-0.md`, offset_chars: noteOffset }, ctx);
131
+ assertWithinBudget(raw, `notes_read page at ${noteOffset}`);
132
+ const result = resultRead(raw);
133
+ // The window is a plain prefix of the file, so the pages join by plain concatenation.
134
+ noteParts.push(result.content);
135
+ noteNext = result.next_offset_chars;
136
+ if (noteNext !== null)
137
+ noteOffset = noteNext;
138
+ }
139
+ const joined = noteParts.join("");
140
+ assert.ok(joined.startsWith("---\n"), "the frontmatter is delivered first");
141
+ assert.ok(joined.endsWith(bodyText), "cursor-following reconstructs the body");
142
+ assert.equal(noteNext, null);
143
+ });
144
+ test("a page cap limits the page, not the enumerable set: cursors stay truthful past the cap", async () => {
145
+ const session = manager();
146
+ const captured = makeExtension(session);
147
+ const ctx = context(session);
148
+ for (let index = 0; index < 60; index++) {
149
+ appendText(session, "user", `entry-${index}`);
150
+ appendText(session, "assistant", `reply-${index}`);
151
+ }
152
+ // history_list: 120 items with limit 50 page as 50/50/20, null only at the true end.
153
+ const windows = resultJson(await call(captured, "history_windows", {}, ctx));
154
+ assert.equal(windows.windows[0]?.item_count, 120);
155
+ const list = async (params) => resultJson(await call(captured, "history_list", params, ctx));
156
+ const first = await list({ limit: 50, recent_first: false, max_chars_per_item: 100 });
157
+ assert.equal(first.items.length, 50);
158
+ assert.equal(first.next_cursor, 50, "limit caps the page, not the enumerable set");
159
+ const second = await list({ limit: 50, cursor: 50, recent_first: false, max_chars_per_item: 100 });
160
+ assert.equal(second.items.length, 50);
161
+ assert.equal(second.next_cursor, 100);
162
+ const third = await list({ limit: 50, cursor: 100, recent_first: false, max_chars_per_item: 100 });
163
+ assert.equal(third.items.length, 20);
164
+ assert.equal(third.next_cursor, null, "null only at the true end");
165
+ // history_search: the same contract holds over the matching set.
166
+ const search = async (params) => resultJson(await call(captured, "history_search", params, ctx));
167
+ const searchFirst = await search({ query: "entry-", limit: 50, recent_first: false, max_chars_per_item: 100 });
168
+ assert.equal(searchFirst.items.length, 50);
169
+ assert.equal(searchFirst.next_cursor, 50);
170
+ const searchTail = await search({ query: "entry-", limit: 50, cursor: 50, recent_first: false, max_chars_per_item: 100 });
171
+ assert.equal(searchTail.items.length, 10);
172
+ assert.equal(searchTail.next_cursor, null);
173
+ // notes_search: max_files caps the page, not the matched files.
174
+ for (let index = 0; index < 7; index++)
175
+ await call(captured, "notes_write", { path: `needle-${index}.md`, content: "needle" }, ctx);
176
+ const notes = async (params) => resultJson(await call(captured, "notes_search", params, ctx));
177
+ const notesFirst = await notes({ query: "needle", max_files: 3 });
178
+ assert.equal(notesFirst.files.length, 3);
179
+ assert.equal(notesFirst.next_cursor, 3);
180
+ const notesSecond = await notes({ query: "needle", max_files: 3, cursor: 3 });
181
+ assert.equal(notesSecond.files.length, 3);
182
+ assert.equal(notesSecond.next_cursor, 6);
183
+ const notesTail = await notes({ query: "needle", max_files: 3, cursor: 6 });
184
+ assert.equal(notesTail.files.length, 1);
185
+ assert.equal(notesTail.next_cursor, null);
186
+ });
187
+ test("multi-query search: OR semantics, dedupe, and bare-string backward compatibility", async () => {
188
+ const session = manager();
189
+ const captured = makeExtension(session);
190
+ const ctx = context(session);
191
+ // One item matches both queries, one only the first, one only the second, one neither.
192
+ const bothId = appendText(session, "user", "alpha beta together");
193
+ const alphaId = appendText(session, "user", "alpha only");
194
+ const betaId = appendText(session, "assistant", "beta only");
195
+ const noneId = appendText(session, "user", "gamma only");
196
+ const historyIds = async (params) => resultJson(await call(captured, "history_search", { recent_first: false, ...params }, ctx)).items.map((item) => item.item_id);
197
+ const orIds = await historyIds({ query: ["alpha", "beta"] });
198
+ assert.deepEqual(orIds, [bothId, alphaId, betaId], "history: an item matching any query is returned once");
199
+ assert.equal(orIds.includes(noneId), false, "history: an item matching no query is not returned");
200
+ assert.deepEqual(await historyIds({ query: ["alpha"] }), [bothId, alphaId], "history: a one-element array searches that literal");
201
+ assert.deepEqual(await historyIds({ query: "alpha" }), orIds.filter((id) => id !== betaId), "history: a bare string still behaves exactly as before");
202
+ assert.deepEqual(await historyIds({ query: "alpha" }), await historyIds({ query: ["alpha"] }), "history: bare string equals the single-element list");
203
+ await call(captured, "notes_write", { path: "both.md", content: "alpha beta\nunrelated" }, ctx);
204
+ await call(captured, "notes_write", { path: "alpha.md", content: "alpha only" }, ctx);
205
+ await call(captured, "notes_write", { path: "beta.md", content: "beta only" }, ctx);
206
+ await call(captured, "notes_write", { path: "gamma.md", content: "gamma only" }, ctx);
207
+ const notesSearch = async (params) => resultJson(await call(captured, "notes_search", params, ctx)).files;
208
+ const orFiles = await notesSearch({ query: ["alpha", "beta"] });
209
+ assert.deepEqual(orFiles.map((file) => file.path), ["alpha.md", "beta.md", "both.md"], "notes: a file matching any query is returned once, path-ordered");
210
+ assert.equal(orFiles.find((file) => file.path === "both.md")?.matches.length, 1, "notes: one line containing both queries is reported once");
211
+ assert.deepEqual((await notesSearch({ query: ["alpha"] })).map((file) => file.path), ["alpha.md", "both.md"], "notes: a one-element array searches that literal");
212
+ assert.deepEqual((await notesSearch({ query: "alpha" })).map((file) => file.path), ["alpha.md", "both.md"], "notes: a bare string still behaves exactly as before");
213
+ assert.deepEqual((await notesSearch({ query: "alpha" })).map((file) => file.path), (await notesSearch({ query: ["alpha"] })).map((file) => file.path), "notes: bare string equals the single-element list");
214
+ assert.deepEqual((await notesSearch({ query: ["gamma"] })).map((file) => file.path), ["gamma.md"]);
215
+ // An empty array is an argument error, not a silently empty result set.
216
+ await assert.rejects(() => call(captured, "history_search", { query: [] }, ctx), /non-empty array of strings/, "history: empty query array is refused");
217
+ await assert.rejects(() => call(captured, "notes_search", { query: [] }, ctx), /non-empty array of strings/, "notes: empty query array is refused");
218
+ await assert.rejects(() => call(captured, "history_search", { query: ["alpha", 7] }, ctx), /elements must be strings/, "history: non-string query element is refused");
219
+ await assert.rejects(() => call(captured, "notes_search", { query: ["alpha", 7] }, ctx), /elements must be strings/, "notes: non-string query element is refused");
220
+ });
221
+ test("history_read delivers a prefix and next_offset_chars names the delivered count", async () => {
222
+ const session = manager();
223
+ const captured = makeExtension(session);
224
+ const ctx = context(session);
225
+ const original = "z".repeat(TOOL_OUTPUT_MAX_BYTES * 3);
226
+ const id = appendText(session, "user", original);
227
+ const rawRead = await call(captured, "history_read", { window_id: historyFromSession(ctx)[0].windowId, item_id: id, limit_chars: 50000 }, ctx);
228
+ assertWithinBudget(rawRead, "single history_read call");
229
+ const read = resultRead(rawRead);
230
+ assert.ok(read.content.length > 0, "the read is not empty");
231
+ assert.equal(read.content.includes("…"), false, "no marker is appended to the payload");
232
+ assert.ok(original.startsWith(read.content), "the delivered text is a prefix of the item");
233
+ assert.equal(read.total_chars, original.length);
234
+ assert.equal(read.header, `--- READ WINDOW ---\nwindow_id: ${historyFromSession(ctx)[0].windowId}\nitem_id: ${id}\nchars: [0,${read.next_offset_chars}) of ${read.total_chars}\nnext_offset_chars: ${read.next_offset_chars}\n`, "the paged history block names identities, half-open range, and resume cursor");
235
+ assert.deepEqual(Object.keys(read.details), ["window_id", "item_id", "offset_chars", "total_chars", "next_offset_chars"], "history_read details carries exactly the raw window identity and cursor metadata");
236
+ assert.equal("limit_chars" in read.details, false, "history_read details omits the request cap");
237
+ assert.equal("content" in read.details, false, "details never duplicates the payload");
238
+ assert.equal(read.next_offset_chars, read.offset_chars + Array.from(read.content).length, "the cursor is offset plus delivered code points");
239
+ assert.ok(read.next_offset_chars !== null && read.next_offset_chars < read.total_chars, "the cursor points at the first undelivered character");
240
+ // Following the cursor reaches the true end and reconstructs the item.
241
+ const parts = [read.content];
242
+ let offset = read.next_offset_chars;
243
+ let next = offset;
244
+ while (next !== null) {
245
+ const page = resultRead(await call(captured, "history_read", { window_id: historyFromSession(ctx)[0].windowId, item_id: id, offset_chars: offset, limit_chars: 50000 }, ctx));
246
+ assert.equal(page.header, `--- READ WINDOW ---\nwindow_id: ${historyFromSession(ctx)[0].windowId}\nitem_id: ${id}\nchars: [${page.offset_chars},${page.offset_chars + Array.from(page.content).length}) of ${page.total_chars}\nnext_offset_chars: ${page.next_offset_chars}\n`, "every history page retains the exact shared READ WINDOW block");
247
+ assert.equal(page.next_offset_chars, page.offset_chars + Array.from(page.content).length < page.total_chars ? page.offset_chars + Array.from(page.content).length : null, "the cursor is offset plus delivered, null only at item end");
248
+ parts.push(page.content);
249
+ next = page.next_offset_chars;
250
+ if (next !== null)
251
+ offset = next;
252
+ }
253
+ assert.equal(parts.join(""), original, "the cursors reconstruct the item exactly");
254
+ });
255
+ test("history items carry honest truncated/total_chars and max_chars_per_item:1 addresses them", async () => {
256
+ const session = manager();
257
+ const captured = makeExtension(session);
258
+ const ctx = context(session);
259
+ const content = `${'padding '.repeat(400)}NEEDLE${' trailing'.repeat(400)}`;
260
+ const id = appendText(session, "user", content);
261
+ const list = resultJson(await call(captured, "history_list", { recent_first: false, max_chars_per_item: 5 }, ctx));
262
+ const listed = list.items.find((item) => item.item_id === id);
263
+ assert.equal(listed.truncated, true, "a capped item is flagged truncated");
264
+ assert.equal(listed.total_chars, Array.from(content).length, "total_chars is the full code-point length");
265
+ assert.equal(listed.truncated_content, 'paddi', "the payload is the longest fitting prefix, with no marker");
266
+ assert.equal(listed.truncated_content.includes("…"), false);
267
+ const whole = resultJson(await call(captured, "history_list", { recent_first: false, max_chars_per_item: 50_000 }, ctx));
268
+ const untruncated = whole.items.find((item) => item.item_id === id);
269
+ assert.equal(untruncated.truncated, false, "an item that fits is not flagged truncated");
270
+ assert.equal(untruncated.truncated_content, content, "a fitting item is returned whole");
271
+ const addresses = resultJson(await call(captured, "history_search", { query: "NEEDLE", max_chars_per_item: 1 }, ctx));
272
+ const address = addresses.items.find((item) => item.item_id === id);
273
+ assert.equal(Array.from(address.truncated_content).length, 1, "max_chars_per_item:1 delivers one code point");
274
+ assert.equal(address.truncated, true);
275
+ assert.equal(address.total_chars, Array.from(content).length);
276
+ const resolved = resultRead(await call(captured, "history_read", { window_id: historyFromSession(ctx)[0].windowId, item_id: id, offset_chars: address.match_offset_chars, limit_chars: 6 }, ctx));
277
+ assert.ok(resolved.content.includes("NEEDLE"), "the address resolves to the query through history_read");
278
+ });
279
+ test("tool calls wear their own role and assistant text stays pure", async () => {
280
+ const session = manager();
281
+ const captured = makeExtension(session);
282
+ const ctx = context(session);
283
+ const turnId = session.appendMessage({
284
+ role: "assistant",
285
+ content: [
286
+ { type: "text", text: "on it" },
287
+ { type: "toolCall", id: "tc-1", name: "bash", arguments: { command: "keiyaku status" } },
288
+ { type: "toolCall", id: "tc-2", name: "notes_read", arguments: { path: "x.md" } },
289
+ ],
290
+ stopReason: "stop",
291
+ timestamp: Date.now(),
292
+ });
293
+ const windowId = historyFromSession(ctx)[0].windowId;
294
+ const listed = resultJson(await call(captured, "history_list", { recent_first: false, max_chars_per_item: 50_000 }, ctx));
295
+ const turn = listed.items.find((item) => item.item_id === turnId);
296
+ assert.equal(turn.role, "assistant");
297
+ assert.equal(turn.tool_name, null, "the turn's text item carries no tool identity");
298
+ assert.equal(turn.truncated_content, "on it", "the turn item keeps only the visible text");
299
+ const call1 = listed.items.find((item) => item.item_id === `${turnId}#0`);
300
+ assert.equal(call1.role, "tool_call");
301
+ assert.equal(call1.tool_name, "bash");
302
+ assert.equal(call1.truncated_content, JSON.stringify({ command: "keiyaku status" }), "a call item's content is the call's JSON arguments");
303
+ const call2 = listed.items.find((item) => item.item_id === `${turnId}#1`);
304
+ assert.equal(call2.tool_name, "notes_read");
305
+ // The invocation is searchable exactly where a searcher reaches for it: tool_call + tool_name.
306
+ const calls = resultJson(await call(captured, "history_search", { query: "keiyaku status", role: "tool_call", tool_name: "bash" }, ctx));
307
+ assert.deepEqual(calls.items.map((item) => item.item_id), [`${turnId}#0`], "the command line is found on the call item, not the turn");
308
+ const assistantCalls = resultJson(await call(captured, "history_search", { query: "keiyaku status", role: "assistant" }, ctx));
309
+ assert.equal(assistantCalls.items.length, 0, "calls never leak into assistant text");
310
+ const assistantText = resultJson(await call(captured, "history_search", { query: "on it", role: "assistant" }, ctx));
311
+ assert.deepEqual(assistantText.items.map((item) => item.item_id), [turnId], "assistant search returns the turn's text item only");
312
+ const outputs = resultJson(await call(captured, "history_search", { query: "keiyaku status", role: "tool" }, ctx));
313
+ assert.equal(outputs.items.length, 0, "nothing ran, so no output carries the command");
314
+ const resolved = resultRead(await call(captured, "history_read", { window_id: windowId, item_id: `${turnId}#0` }, ctx));
315
+ assert.equal(resolved.content, JSON.stringify({ command: "keiyaku status" }), "a call item resolves through history_read like any other");
316
+ });
@@ -0,0 +1,273 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdirSync, writeFileSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ import test from "node:test";
5
+ import { loadNotesSnapshot as loadNotesSnapshot } from "../src/notes/notes-snapshot.js";
6
+ import { renderBootBlock } from "../src/context/prompts.js";
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";
10
+ 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());
16
+ test("notes_list is most-recently-updated first across merged scopes", async () => {
17
+ const session = manager();
18
+ const captured = makeExtension(session);
19
+ const ctx = context(session);
20
+ const put = (scope, path, updated) => {
21
+ const file = physicalPath(scope, path, ctx);
22
+ 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`);
24
+ };
25
+ const base = 1_700_000_000_000;
26
+ put("session", "b.md", base + 10);
27
+ put("session", "a.md", base + 10);
28
+ put("project", "c.md", base + 5);
29
+ put("human", "e.md", base + 20);
30
+ const files = async (params) => resultJson(await call(captured, "notes_list", params, ctx)).files;
31
+ assert.deepEqual((await files({})).map((file) => file.address), ["@human/e.md", "a.md", "b.md", "@project/c.md"], "updated_at descending with address ascending as the tiebreak");
32
+ // A same-path pair in two scopes keeps both rows; equal timestamps tie-break by scope name.
33
+ put("human", "a.md", base + 10);
34
+ assert.deepEqual((await files({})).filter((file) => file.address.endsWith("a.md")).map((file) => file.address), ["@human/a.md", "a.md"], "equal timestamps tie-break by full address");
35
+ assert.deepEqual((await files({ pattern: "*.md" })).map((file) => file.address), ["a.md", "b.md"], "a bare pattern narrows to the session home");
36
+ });
37
+ test("notes are real files that persist across sessions and round-trip Unicode", async () => {
38
+ const original = manager();
39
+ const captured = makeExtension(original);
40
+ const ctx = context(original);
41
+ await call(captured, "notes_write", { path: "checkpoint/进度.md", content: "第一行\nneedle Café", scope: "human" }, ctx);
42
+ // A brand-new session over the same physical root sees the human note: nothing is replayed
43
+ // from session entries, the file itself is the durable artifact.
44
+ const restored = manager();
45
+ const restoredCaptured = makeExtension(restored);
46
+ const restoredCtx = context(restored);
47
+ const rawRead = await call(restoredCaptured, "notes_read", { path: "checkpoint/进度.md", scope: "human", offset_chars: -4 }, restoredCtx);
48
+ const read = resultRead(rawRead);
49
+ assert.equal(read.details.address, "@human/checkpoint/进度.md");
50
+ 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));
52
+ assert.equal(searched.files[0]?.matches[0]?.line, 2);
53
+ const listedFiles = resultJson(await call(restoredCaptured, "notes_list", { pattern: "checkpoint/**", scope: "human" }, restoredCtx));
54
+ assert.equal(listedFiles.files.length, 1, "glob ** crosses into the checkpoint directory");
55
+ assert.equal(listedFiles.files[0]?.path, "checkpoint/进度.md");
56
+ // 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));
58
+ 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
+ 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/);
62
+ });
63
+ test("stale lifecycle: writes and metadata-only edits close and revive a note", async () => {
64
+ const sm = manager();
65
+ const captured = makeExtension(sm);
66
+ const ctx = context(sm);
67
+ await call(captured, "notes_write", { path: "journal.md", content: "log line" }, ctx);
68
+ // metadata-only: content unchanged, flag set, applied 0
69
+ const markOnly = resultJson(await call(captured, "notes_edit", { path: "journal.md", stale: true }, ctx));
70
+ 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");
73
+ // 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");
76
+ // 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");
81
+ // 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));
83
+ assert.equal(missing.error, "note not found");
84
+ });
85
+ test("the filesystem notes loader treats an absent home as empty but surfaces a real directory read failure", () => {
86
+ const sm = manager();
87
+ const ctx = context(sm);
88
+ assert.deepEqual(listNotes(ctx, { scope: "human" }), [], "a home that has not been created is empty");
89
+ const blockedHome = scopeDir("human", ctx);
90
+ 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");
92
+ });
93
+ test("boot note acquisition is one closed snapshot and isolates one or all failed homes", () => {
94
+ const sm = manager();
95
+ const ctx = context(sm);
96
+ const updated = Date.now();
97
+ const note = (scope, path, address, body) => ({
98
+ address,
99
+ scope,
100
+ path,
101
+ body,
102
+ sizeBytes: Buffer.byteLength(body, "utf8"),
103
+ meta: {
104
+ scope,
105
+ origin: "self",
106
+ status: "active",
107
+ stale: false,
108
+ created_at: updated,
109
+ updated_at: updated,
110
+ last_accessed: updated,
111
+ access_count: 0,
112
+ },
113
+ });
114
+ const rows = new Map([
115
+ ["session", [note("session", "session.md", "session.md", "SESSION_POCKET_BODY")]],
116
+ ["project", [note("project", "MAP.md", "@project/MAP.md", "PROJECT_MAP_BODY")]],
117
+ ["human", [note("human", "human.md", "@human/human.md", "HUMAN_POCKET_BODY")]],
118
+ ["agent", [note("agent", "MAP.md", "@agents/root/MAP.md", "AGENT_MAP_BODY")]],
119
+ ["model", [note("model", "model.md", "@models/default/model.md", "MODEL_POCKET_BODY")]],
120
+ ]);
121
+ const calls = new Map();
122
+ const snapshot = loadNotesSnapshot(ctx, (_ctx, scope) => {
123
+ calls.set(scope, (calls.get(scope) ?? 0) + 1);
124
+ return rows.get(scope) ?? [];
125
+ });
126
+ assert.deepEqual([...calls.entries()], [["session", 1], ["project", 1], ["human", 1], ["agent", 1], ["model", 1]], "each selected home is loaded exactly once");
127
+ const renderData = {
128
+ agentName: "root",
129
+ modelName: "default",
130
+ firstWindowId: "pcw:test:root",
131
+ currentWindowId: "pcw:test:next",
132
+ previousWindowId: "pcw:test:root",
133
+ resetLine: true,
134
+ notes: snapshot,
135
+ };
136
+ const rendered = renderBootBlock(renderData);
137
+ assert.equal(renderBootBlock(renderData), rendered, "rendering the same boot data twice is deterministic");
138
+ assert.ok(rendered.includes("PROJECT_MAP_BODY") && rendered.includes("AGENT_MAP_BODY"), "MAP residency comes from the snapshot");
139
+ assert.ok(rendered.includes("session.md") && rendered.includes("@human/human.md"), "pocket rows come from the same snapshot");
140
+ assert.equal(rendered.includes("SESSION_POCKET_BODY"), false, "pocket bodies stay excluded");
141
+ const readFailure = (code) => Object.assign(new Error("scripted read failure"), { code });
142
+ const oneFailed = loadNotesSnapshot(ctx, (_ctx, scope) => {
143
+ if (scope === "human")
144
+ throw readFailure("EIO");
145
+ return rows.get(scope) ?? [];
146
+ });
147
+ assert.deepEqual(oneFailed.unavailable.map((home) => home.label), ["@human"]);
148
+ const oneFailedText = renderBootBlock({
149
+ agentName: "root",
150
+ modelName: "default",
151
+ firstWindowId: "pcw:test:root",
152
+ currentWindowId: "pcw:test:next",
153
+ resetLine: true,
154
+ notes: oneFailed,
155
+ });
156
+ assert.ok(oneFailedText.includes("PROJECT_MAP_BODY") && oneFailedText.includes("notes_list can retry after recovery"), "healthy homes and the recovery notice survive one failure");
157
+ assert.equal(oneFailedText.includes("HUMAN_POCKET_BODY"), false, "the failed home's index is omitted");
158
+ const allFailed = loadNotesSnapshot(ctx, (_ctx, scope) => {
159
+ throw readFailure(scope === "session" ? "EACCES" : "EIO");
160
+ });
161
+ assert.equal(allFailed.unavailable.length, 5);
162
+ const allFailedText = renderBootBlock({
163
+ agentName: "root",
164
+ modelName: "default",
165
+ firstWindowId: "pcw:test:root",
166
+ currentWindowId: "pcw:test:next",
167
+ previousWindowId: "pcw:test:root",
168
+ resetLine: true,
169
+ notes: allFailed,
170
+ });
171
+ assert.ok(allFailedText.includes("pcw:test:root") && allFailedText.includes("pcw:test:next"), "identity survives an all-home failure");
172
+ assert.ok(allFailedText.includes("Your memory resets whenever the context window fills"), "protocol survives an all-home failure");
173
+ assert.equal(allFailedText.includes("scripted read failure"), false, "the model-facing notice does not expose OS/error details");
174
+ assert.throws(() => loadNotesSnapshot(ctx, () => { throw new TypeError("programmer failure"); }), (error) => error instanceof TypeError, "unrelated TypeError construction failures remain visible");
175
+ 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");
176
+ });
177
+ test("the boot block gives awake agents the notes-home file layout", () => {
178
+ const session = manager();
179
+ const rendered = explicitBoot(context(session), "pcw:test:root", undefined, false);
180
+ assert.equal(rendered.includes(process.env.PI_NOTES_HOME ?? ""), false, "the absolute notes home is never exposed");
181
+ assert.match(rendered, /bare <vpath>.*@project\/<vpath>.*@human\/<vpath>/);
182
+ });
183
+ test("an over-budget note is delivered as a prefix and resumed by next_offset_chars", async () => {
184
+ const session = manager();
185
+ const captured = makeExtension(session);
186
+ const ctx = context(session);
187
+ const huge = `H${"x".repeat(TOOL_OUTPUT_MAX_BYTES * 2)}`;
188
+ const text = `${huge}\ntail line`;
189
+ await call(captured, "notes_write", { path: "huge.md", content: text }, ctx);
190
+ const rawFirst = await call(captured, "notes_read", { path: "huge.md" }, ctx);
191
+ assertWithinBudget(rawFirst, "single oversized note");
192
+ const first = resultRead(rawFirst);
193
+ assert.ok(first.content.length > 0, "the page is not empty");
194
+ assert.equal(first.content.includes("…"), false, "the payload is a plain prefix with no marker");
195
+ assert.ok(first.content.startsWith("---\n"), "the frontmatter is delivered first");
196
+ assert.equal(first.header, `--- READ WINDOW ---\naddress: huge.md\nchars: [0,${first.next_offset_chars}) of ${first.total_chars}\nnext_offset_chars: ${first.next_offset_chars}\n`, "the raw block names the address, half-open range, and resume cursor");
197
+ assert.deepEqual(Object.keys(first.details).sort(), ["address", "next_offset_chars", "offset_chars", "total_chars"], "notes_read details carries exactly the raw window address and cursor metadata");
198
+ assert.equal("content" in first.details, false, "details never duplicates the payload");
199
+ assert.equal(first.offset_chars, 0, "the default window starts at the resolved offset 0");
200
+ // Following the cursor reconstructs frontmatter + body by plain concatenation.
201
+ const parts = [first.content];
202
+ let offset = first.next_offset_chars;
203
+ while (offset !== null) {
204
+ const rawChunk = await call(captured, "notes_read", { path: "huge.md", offset_chars: offset }, ctx);
205
+ assertWithinBudget(rawChunk, `huge note chunk at ${offset}`);
206
+ const chunk = resultRead(rawChunk);
207
+ assert.equal(chunk.offset_chars, offset, "the response echoes the resolved absolute offset");
208
+ parts.push(chunk.content);
209
+ offset = chunk.next_offset_chars;
210
+ }
211
+ assert.ok(parts.join("").endsWith(text), "the cursors reconstruct the body exactly");
212
+ // A success carries structured details; an error stays a JSON envelope with no details.
213
+ const missingResult = await call(captured, "notes_read", { path: "no-such.md" }, ctx);
214
+ const missing = resultJson(missingResult);
215
+ assert.deepEqual(Object.keys(missing).sort(), ["address", "error"], "the read error carries exactly error and address");
216
+ assert.equal(missing.error, "note not found");
217
+ assert.equal(missingResult.details, undefined, "a JSON error carries no details metadata");
218
+ });
219
+ test("an over-budget note search match is a named prefix with an honest line address", async () => {
220
+ const session = manager();
221
+ const captured = makeExtension(session);
222
+ const ctx = context(session);
223
+ // The query sits behind a prefix, so its address is a real body-absolute offset, not line 1.
224
+ const hugeLine = `${'p'.repeat(500)}needle ${"y".repeat(TOOL_OUTPUT_MAX_BYTES * 2)}`;
225
+ await call(captured, "notes_write", { path: "a.md", content: "needle small" }, ctx);
226
+ await call(captured, "notes_write", { path: "search.md", content: hugeLine }, ctx);
227
+ const pages = [];
228
+ let cursor = 0;
229
+ let next = 0;
230
+ while (next !== null) {
231
+ const found = resultJson(await call(captured, "notes_search", { query: "needle", cursor }, ctx));
232
+ assert.ok(Buffer.byteLength(JSON.stringify(found), "utf8") <= TOOL_OUTPUT_MAX_BYTES, "match result stays within budget");
233
+ pages.push(...found.files);
234
+ next = found.next_cursor;
235
+ if (next !== null)
236
+ cursor = next;
237
+ }
238
+ assert.deepEqual(pages.map((file) => file.path), ["a.md", "search.md"], "pagination reaches the oversized file instead of looping");
239
+ const oversized = pages[1];
240
+ assert.equal(oversized.matches_total, 1, "the file's full match count is named even though the line was cut");
241
+ assert.equal(oversized.matches.length, 1);
242
+ const match = oversized.matches[0];
243
+ assert.equal(match.truncated, true, "the oversized match line is flagged as truncated");
244
+ assert.ok(hugeLine.startsWith(match.text), "the match text is a plain prefix of the line");
245
+ assert.equal(match.text.includes("…"), false, "no marker is appended to the match text");
246
+ assert.equal(match.line, 1, "the informational line number survives");
247
+ const atMatch = resultRead(await call(captured, "notes_read", { path: "search.md", offset_chars: match.offset_chars }, ctx));
248
+ assert.ok(atMatch.content.startsWith("needle"), "the search offset starts a read at the matched substring");
249
+ // The body is reconstructible by following notes_read's cursor from the start of the file.
250
+ const parts = [];
251
+ let offset = 0;
252
+ while (offset !== null) {
253
+ const rawChunk = await call(captured, "notes_read", { path: "search.md", offset_chars: offset }, ctx);
254
+ assertWithinBudget(rawChunk, `search.md chunk at ${offset}`);
255
+ const chunk = resultRead(rawChunk);
256
+ assert.equal(chunk.offset_chars, offset, "the read echoes the resolved address");
257
+ parts.push(chunk.content);
258
+ offset = chunk.next_offset_chars;
259
+ }
260
+ assert.ok(parts.join("").endsWith(hugeLine), "resuming across pages reconstructs the matched body line");
261
+ });
262
+ test("notes_search scopes by glob pattern; a non-matching pattern is an empty page, not an error", async () => {
263
+ const session = manager();
264
+ const captured = makeExtension(session);
265
+ const ctx = context(session);
266
+ await call(captured, "notes_write", { path: "deep/nested/a.md", content: "needle here" }, ctx);
267
+ await call(captured, "notes_write", { path: "top.md", content: "needle there" }, ctx);
268
+ const scoped = resultJson(await call(captured, "notes_search", { query: "needle", pattern: "deep/**" }, ctx));
269
+ assert.deepEqual(scoped.files.map((file) => file.path), ["deep/nested/a.md"], "a glob scopes the search to the subtree");
270
+ const none = resultJson(await call(captured, "notes_search", { query: "needle", pattern: "absent/**" }, ctx));
271
+ assert.equal(none.error, undefined, "a non-matching pattern is not an error");
272
+ assert.deepEqual(none.files, [], "a non-matching pattern is an empty page");
273
+ });