@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
@@ -1,371 +0,0 @@
1
- /**
2
- * OWNER: pi-context (adopted).
3
- * STATUS: tracked acceptance spec for the history and notes read/search tools. No skip.
4
- * CLAIM: following the cursors these tools return must reconstruct the original text exactly,
5
- * or the result must name the skipped range. Both read tools are one character window over two
6
- * stores (notes_read and history_read share the cursor walk below). In v2 this failed
7
- * at 13 sites; the rows that demanded an over-budget line in a single call are rebuilt as
8
- * cursor-walking rows below (the CLAIM explicitly licenses that: reconstruct exactly by
9
- * following cursors, or name the skipped range).
10
- * HERMETIC: this file reads only its own in-memory session. Corpus replays of real sessions
11
- * are NOT hermetic, must be single-pass, and belong in a dev script, not npm test.
12
- */
13
- import assert from "node:assert/strict";
14
- import { mkdtempSync } from "node:fs";
15
- import { tmpdir } from "node:os";
16
- import { join } from "node:path";
17
- import test from "node:test";
18
- import { SessionManager } from "@earendil-works/pi-coding-agent";
19
- import piContext, { historyFromSession } from "../src/index.js";
20
- import { TOOL_OUTPUT_MAX_BYTES } from "../src/tool-output.js";
21
- import { stripLeadingFrontmatter } from "../src/notes/frontmatter.js";
22
- process.env.PI_CODING_AGENT_DIR = mkdtempSync(join(tmpdir(), "pc-coherence-agent-"));
23
- process.env.PI_NOTES_HOME = mkdtempSync(join(tmpdir(), "pc-coherence-notes-"));
24
- function makeExtension(sessionManager) {
25
- const captured = { tools: new Map() };
26
- const api = {
27
- registerFlag() { },
28
- registerTool(tool) { captured.tools.set(tool.name, tool); },
29
- registerCommand() { },
30
- on() { },
31
- appendEntry(customType, data) { sessionManager.appendCustomEntry(customType, data); },
32
- sendMessage(message) {
33
- sessionManager.appendCustomMessageEntry(message.customType, message.content, message.display, message.details);
34
- },
35
- };
36
- piContext(api);
37
- return captured;
38
- }
39
- function context(sessionManager) {
40
- const fake = {
41
- sessionManager,
42
- getContextUsage: () => undefined,
43
- compact: () => { },
44
- isIdle: () => true,
45
- hasPendingMessages: () => false,
46
- cwd: "/private/tmp/pi-context-test",
47
- isProjectTrusted: () => true,
48
- ui: { notify: () => { } },
49
- };
50
- return fake;
51
- }
52
- async function call(captured, name, params, ctx) {
53
- const tool = captured.tools.get(name);
54
- assert.ok(tool, `registered ${name}`);
55
- if ((name === "notes_write" || name === "notes_read") && "path" in params && !("address" in params)) {
56
- const { path, ...rest } = params;
57
- return tool.execute("call-1", { ...rest, address: path }, new AbortController().signal, () => { }, ctx);
58
- }
59
- return tool.execute("call-1", params, new AbortController().signal, () => { }, ctx);
60
- }
61
- function resultJson(result) {
62
- const text = result.content[0];
63
- assert.ok(text && text.type === "text");
64
- return JSON.parse(text.text);
65
- }
66
- function assertWithinBudget(result, label) {
67
- const bytes = Buffer.byteLength(result.content[0] && result.content[0].type === "text" ? result.content[0].text : "", "utf8");
68
- if (bytes > TOOL_OUTPUT_MAX_BYTES)
69
- failures.push(`${label}: response is ${bytes} bytes, over the ${TOOL_OUTPUT_MAX_BYTES}-byte budget`);
70
- }
71
- function appendText(sessionManager, text) {
72
- return sessionManager.appendMessage({ role: "user", content: [{ type: "text", text }], timestamp: Date.now() });
73
- }
74
- /** Decode either raw read through the shared READ WINDOW grammar without including metadata in the payload. */
75
- function resultRead(result) {
76
- const text = result.content[0];
77
- assert.ok(text && text.type === "text", "read result carries text");
78
- const block = /^(--- READ WINDOW ---\n(?:[a-z_]+: [^\n]*\n)+chars: \[(\d+),(\d+)\) of (\d+)\nnext_offset_chars: (null|\d+)\n)\n/.exec(text.text);
79
- assert.ok(block, "raw read carries one READ WINDOW block followed by exactly one blank line");
80
- const header = block[1];
81
- const content = text.text.slice(block[0].length);
82
- const offset_chars = Number(block[2]);
83
- const end = Number(block[3]);
84
- const total_chars = Number(block[4]);
85
- const next_offset_chars = block[5] === "null" ? null : Number(block[5]);
86
- assert.equal([...content].length, end - offset_chars, "READ WINDOW range matches the delivered payload");
87
- return { header, content, offset_chars, total_chars, next_offset_chars };
88
- }
89
- const PROFILES = [
90
- { name: "cjk", unit: "历" },
91
- { name: "emoji", unit: "🕷" },
92
- { name: "ascii", unit: "x" },
93
- ];
94
- const failures = [];
95
- const report = [];
96
- const codePoints = (text) => [...text].length;
97
- const codePointSlice = (text, start, end) => [...text].slice(start, end).join("");
98
- /**
99
- * Follow either read tool's cursor exactly as the protocol tells the model to, asserting the
100
- * cursor law on every page. Both tools are the same character window over two stores, so one
101
- * walker serves both; `address` carries the tool's own identity parameters, and `options.start`
102
- * lets a walk begin at a resolved address (a search hit's offset, or a negative tail read).
103
- */
104
- async function walkWindow(captured, ctx, tool, address, label, options = {}) {
105
- const parts = [];
106
- let offset = options.start ?? 0;
107
- let next = 0;
108
- let calls = 0;
109
- let total = 0;
110
- while (next !== null && calls < 400) {
111
- const params = { ...address, offset_chars: offset };
112
- if (options.limitChars !== undefined)
113
- params.limit_chars = options.limitChars;
114
- const result = await call(captured, tool, params, ctx);
115
- assertWithinBudget(result, `${label} offset=${offset}`);
116
- const page = resultRead(result);
117
- total = page.total_chars;
118
- const delivered = codePoints(page.content);
119
- if (page.offset_chars !== offset)
120
- failures.push(`cursor law: ${label} echoed offset_chars=${page.offset_chars} for request offset ${offset}`);
121
- if (page.next_offset_chars !== null && page.next_offset_chars !== offset + delivered) {
122
- failures.push(`cursor law: ${label} next_offset_chars=${page.next_offset_chars} but offset ${offset} + delivered ${delivered}`);
123
- }
124
- if (page.next_offset_chars === null && offset + delivered !== page.total_chars) {
125
- failures.push(`false exhaustion: ${label} returned next_offset_chars=null with ${page.total_chars - (offset + delivered)} characters undelivered`);
126
- }
127
- if (page.content.includes("…") || page.content.includes("[truncated"))
128
- failures.push(`honest payload: ${label} appended a marker at offset ${offset}`);
129
- parts.push(page.content);
130
- next = page.next_offset_chars;
131
- if (next !== null)
132
- offset = next;
133
- calls++;
134
- }
135
- if (offset !== total && calls >= 400)
136
- failures.push(`${label} never terminated`);
137
- return parts.join("");
138
- }
139
- const walkHistory = (captured, ctx, windowId, itemId, limitChars) => walkWindow(captured, ctx, "history_read", { window_id: windowId, item_id: itemId }, `history_read ${itemId}`, { limitChars });
140
- const walkNote = (captured, ctx, path, start = 0) => walkWindow(captured, ctx, "notes_read", { path }, `notes_read ${path}`, { start });
141
- test("coherence: following the returned cursors reconstructs the original text exactly", async () => {
142
- const session = SessionManager.inMemory("/private/tmp/pi-context-test");
143
- const captured = makeExtension(session);
144
- const ctx = context(session);
145
- const windowId = historyFromSession(ctx)[0].windowId;
146
- // --- history_read: every profile x length reconstructs; cursor law holds per page ---
147
- for (const profile of PROFILES) {
148
- for (const length of [12_000, 12_001, 20_000, 30_000]) {
149
- const original = profile.unit.repeat(length);
150
- const itemId = appendText(session, original);
151
- const reconstructed = await walkHistory(captured, ctx, windowId, itemId);
152
- const missing = codePoints(original) - codePoints(reconstructed);
153
- const line = `history_read default: ${profile.name} ${length} chars (${Buffer.byteLength(original, "utf8")} bytes) -> delivered ${codePoints(reconstructed)} chars, missing ${missing}, marker=${reconstructed.includes("[truncated")}`;
154
- report.push(line);
155
- if (reconstructed !== original)
156
- failures.push(line);
157
- session.appendMessage({ role: "assistant", content: [{ type: "text", text: `ack ${length}` }], timestamp: Date.now() });
158
- }
159
- }
160
- // --- notes_read: a 40,000-code-point single line plus a tail, three profiles ---
161
- for (const profile of PROFILES) {
162
- const hugeLine = profile.unit.repeat(40_000);
163
- const text = `${hugeLine}\ntail line`;
164
- const path = `huge-${profile.name}.md`;
165
- await call(captured, "notes_write", { path, content: text }, ctx);
166
- const reconstructed = stripLeadingFrontmatter(await walkNote(captured, ctx, path));
167
- const missing = codePoints(text) - codePoints(reconstructed);
168
- const line = `notes_read: ${profile.name} single line ${codePoints(hugeLine)} chars (${Buffer.byteLength(hugeLine, "utf8")} bytes) -> delivered ${codePoints(reconstructed)} chars, missing ${missing}, marker=${reconstructed.includes("[truncated")}`;
169
- report.push(line);
170
- if (reconstructed !== text)
171
- failures.push(line);
172
- }
173
- // --- The terminator itself can lie. A single line with no trailing newline is exactly what
174
- // notes_write { content } produces; the returned cursor must not be null while text remains.
175
- for (const profile of PROFILES) {
176
- const hugeLine = profile.unit.repeat(40_000);
177
- const path = `solo-${profile.name}.md`;
178
- await call(captured, "notes_write", { path, content: hugeLine }, ctx);
179
- const first = resultRead(await call(captured, "notes_read", { path }, ctx));
180
- const deliveredBody = first.content.startsWith("---\n") ? stripLeadingFrontmatter(first.content) : first.content;
181
- const undelivered = codePoints(hugeLine) - codePoints(deliveredBody);
182
- report.push(`notes_read no trailing newline: ${profile.name} ${codePoints(hugeLine)} chars -> first page delivered ${codePoints(deliveredBody)} body chars, undelivered ${undelivered}, offset_chars=${first.offset_chars}, next_offset_chars=${String(first.next_offset_chars)}, marker=${first.content.includes("[truncated")}`);
183
- if (first.offset_chars !== 0)
184
- failures.push(`window echo: ${profile.name} first page echoed offset_chars=${first.offset_chars}, expected 0`);
185
- if (first.total_chars < codePoints(hugeLine))
186
- failures.push(`window total: ${profile.name} total_chars=${first.total_chars}, expected at least the body length`);
187
- if (!first.content.startsWith("---\n"))
188
- failures.push(`frontmatter first: ${profile.name} first page does not open with frontmatter`);
189
- if (first.next_offset_chars === null && undelivered > 0)
190
- failures.push(`false exhaustion: ${profile.name} returns next_offset_chars=null while ${undelivered} characters were never delivered`);
191
- if (!hugeLine.startsWith(deliveredBody))
192
- failures.push(`prefix law: ${profile.name} first page body is not a prefix of the line`);
193
- const reconstructed = stripLeadingFrontmatter(await walkNote(captured, ctx, path));
194
- if (reconstructed !== hugeLine)
195
- failures.push(`notes_read single line is not reconstructible: ${profile.name}, ${codePoints(hugeLine) - codePoints(reconstructed)} chars missing`);
196
- }
197
- // --- The empty note must terminate: no self-feeding cursor, and a frontmatter-only window from 0.
198
- await call(captured, "notes_write", { path: "empty.md", content: "" }, ctx);
199
- const empty = resultRead(await call(captured, "notes_read", { path: "empty.md" }, ctx));
200
- report.push(`notes_read empty note: offset_chars=${empty.offset_chars} total_chars=${empty.total_chars} next_offset_chars=${String(empty.next_offset_chars)} content=${JSON.stringify(empty.content)}`);
201
- if (empty.offset_chars !== 0 || !empty.content.startsWith("---\n") || !empty.content.endsWith("---\n\n"))
202
- failures.push("the empty note is not a frontmatter-only window from 0");
203
- if (empty.next_offset_chars !== null)
204
- failures.push("pagination hole: the empty note is never exhausted");
205
- // --- notes_search: an over-budget matched line is named, then read back with cursors ---
206
- const hugeCjkLine = "历".repeat(40_000);
207
- const searched = resultJson(await call(captured, "notes_search", { query: "历", pattern: "huge-cjk.md" }, ctx));
208
- const matchedFile = searched.files[0];
209
- const matched = matchedFile?.matches[0];
210
- report.push(`notes_search: matches_total=${String(matchedFile?.matches_total)} returned=${String(matchedFile?.matches.length)} first match delivered ${codePoints(matched?.text ?? "")} chars, truncated=${String(matched?.truncated)}`);
211
- if (!matched)
212
- failures.push("notes_search dropped the over-budget matched line entirely");
213
- else {
214
- if (matched.truncated !== true)
215
- failures.push("notes_search does not flag the over-budget matched line as truncated");
216
- if (!hugeCjkLine.startsWith(matched.text))
217
- failures.push("notes_search delivered a non-prefix of the matched line");
218
- const atMatch = resultRead(await call(captured, "notes_read", { path: "huge-cjk.md", offset_chars: matched.offset_chars }, ctx));
219
- if (!atMatch.content.startsWith("历"))
220
- failures.push("notes_search offset does not start notes_read at the matched substring");
221
- const walked = stripLeadingFrontmatter(await walkNote(captured, ctx, "huge-cjk.md"));
222
- if (walked !== `${hugeCjkLine}\ntail line`)
223
- failures.push(`notes_search match line is not reconstructible from the note read: missing ${codePoints(`${hugeCjkLine}\ntail line`) - codePoints(walked)} chars`);
224
- }
225
- // --- history_search: a hit's visible text may be cut, but the offset it carries
226
- // must resolve to the query through history_read (addresses-only mode).
227
- const searchItemContent = `${"padding ".repeat(400)}历史内容${" trailing".repeat(400)}`;
228
- const searchItemId = appendText(session, searchItemContent);
229
- const hit = resultJson(await call(captured, "history_search", { query: "历史内容", max_chars_per_item: 400, window_id: windowId }, ctx));
230
- const first = hit.items.find((item) => item.item_id === searchItemId);
231
- report.push(`history_search: hit present=${Boolean(first)}, fields=${JSON.stringify(Object.keys(first ?? {}))}, match_offset_chars=${String(first?.match_offset_chars)}, truncated=${String(first?.truncated)}`);
232
- if (!first)
233
- failures.push("history_search did not return the matching item");
234
- else {
235
- if (first.truncated !== true)
236
- failures.push("history_search does not flag the capped item as truncated");
237
- if (first.total_chars !== codePoints(searchItemContent))
238
- failures.push(`history_search total_chars=${first.total_chars}, expected ${codePoints(searchItemContent)}`);
239
- if (!searchItemContent.startsWith(first.truncated_content))
240
- failures.push("history_search delivered a non-prefix of the item");
241
- if (first.truncated_content.includes("…"))
242
- failures.push("history_search appended a marker to the payload");
243
- if (!Number.isInteger(first.match_offset_chars))
244
- failures.push("history_search carries no match_offset_chars");
245
- else {
246
- const at = resultRead(await call(captured, "history_read", { window_id: windowId, item_id: searchItemId, offset_chars: first.match_offset_chars, limit_chars: 8 }, ctx));
247
- if (!at.content.includes("历史内容"))
248
- failures.push(`history_read at match_offset_chars=${first.match_offset_chars} does not show the query`);
249
- }
250
- }
251
- // max_chars_per_item: 1 is a real address page for both history tools.
252
- const addresses = resultJson(await call(captured, "history_search", { query: "历史内容", max_chars_per_item: 1, window_id: windowId }, ctx));
253
- const address = addresses.items.find((item) => item.item_id === searchItemId);
254
- report.push(`history_search max_chars_per_item=1: address=${JSON.stringify(address)}`);
255
- if (!address)
256
- failures.push("history_search max_chars_per_item=1 dropped the hit");
257
- else {
258
- if (codePoints(address.truncated_content) !== 1)
259
- failures.push(`max_chars_per_item=1 delivered ${codePoints(address.truncated_content)} code points`);
260
- if (address.truncated !== true || address.total_chars !== codePoints(searchItemContent))
261
- failures.push("max_chars_per_item=1 does not name the full length");
262
- if (!Number.isInteger(address.match_offset_chars))
263
- failures.push("max_chars_per_item=1 carries no address");
264
- }
265
- const listed = resultJson(await call(captured, "history_list", { window_id: windowId, max_chars_per_item: 1, recent_first: false, limit: 500 }, ctx));
266
- const listedAddress = listed.items.find((item) => item.item_id === searchItemId);
267
- report.push(`history_list max_chars_per_item=1: ${JSON.stringify(listedAddress)}`);
268
- if (!listedAddress)
269
- failures.push("history_list max_chars_per_item=1 dropped the item");
270
- else if (codePoints(listedAddress.truncated_content) !== 1 || listedAddress.truncated !== true || listedAddress.total_chars !== codePoints(searchItemContent)) {
271
- failures.push("history_list max_chars_per_item=1 is not an honest address page");
272
- }
273
- // --- brain-04: capping a file's matches to fit the wire budget must be named, never silent.
274
- const manyLines = Array.from({ length: 8_000 }, (_, index) => `needle ${index}`);
275
- await call(captured, "notes_write", { path: "many.md", content: manyLines.join("\n") }, ctx);
276
- const many = resultJson(await call(captured, "notes_search", { query: "needle", pattern: "many.md" }, ctx));
277
- const manyEntry = many.files[0];
278
- report.push(`brain-04: matches_total=${String(manyEntry?.matches_total)} returned=${String(manyEntry?.matches.length)}, next_cursor=${String(many.next_cursor)}`);
279
- if (!manyEntry)
280
- failures.push("brain-04: the many-match file is absent from the search result");
281
- else {
282
- if (manyEntry.matches_total !== manyLines.length)
283
- failures.push(`brain-04: matches_total=${manyEntry.matches_total}, expected ${manyLines.length}`);
284
- if (!(manyEntry.matches_total > manyEntry.matches.length))
285
- failures.push("brain-04: budget-capped matches were silently dropped (matches_total === matches.length)");
286
- if (manyEntry.matches_total - manyEntry.matches.length <= 0)
287
- failures.push("brain-04: the response names no dropped matches");
288
- }
289
- // A file whose first match alone is over budget with more matches behind it: dropping trailing
290
- // matches and cutting the kept line must still leave the whole response inside the wire budget.
291
- const manyHugeLines = Array.from({ length: 4 }, (_, index) => `needle ${index} ${"w".repeat(45_000)}`);
292
- await call(captured, "notes_write", { path: "huge-many.md", content: manyHugeLines.join("\n") }, ctx);
293
- const hugeManyResult = await call(captured, "notes_search", { query: "needle", pattern: "huge-many.md" }, ctx);
294
- assertWithinBudget(hugeManyResult, "notes_search huge-many");
295
- const hugeEntry = resultJson(hugeManyResult).files[0];
296
- report.push(`huge-many: matches_total=${String(hugeEntry?.matches_total)} returned=${String(hugeEntry?.matches.length)} truncated=${String(hugeEntry?.matches[0]?.truncated)}`);
297
- if (!hugeEntry)
298
- failures.push("huge-many: the many-huge-match file is absent from the search result");
299
- else {
300
- if (!(hugeEntry.matches_total > hugeEntry.matches.length))
301
- failures.push("huge-many: dropped matches are not named");
302
- if (hugeEntry.matches[0]?.truncated !== true)
303
- failures.push("huge-many: the kept match is not flagged as a prefix");
304
- }
305
- // --- notes_search addresses: each match offset directly starts notes_read at the earliest
306
- // query occurrence in its line, including multi-query OR.
307
- const addressLine1 = "pad ".repeat(50);
308
- const addressLine3 = `${"历".repeat(20)}needle-address here`;
309
- const addressLine4 = "zeta 历 needle-address";
310
- await call(captured, "notes_write", { path: "address.md", content: `${addressLine1}\nsecond\n${addressLine3}\n${addressLine4}` }, ctx);
311
- const addressHit = resultJson(await call(captured, "notes_search", { query: "needle-address", pattern: "address.md" }, ctx)).files[0]?.matches.find((match) => match.line === 3);
312
- report.push(`notes_search address: line=${String(addressHit?.line)} offset_chars=${String(addressHit?.offset_chars)}`);
313
- if (addressHit === undefined)
314
- failures.push("notes_search carries no line-three match");
315
- else {
316
- const atMatch = resultRead(await call(captured, "notes_read", { path: "address.md", offset_chars: addressHit.offset_chars }, ctx));
317
- if (!atMatch.content.startsWith("needle-address"))
318
- failures.push(`notes_search offset_chars=${addressHit.offset_chars} does not start at the line-three query`);
319
- }
320
- const orLine4 = resultJson(await call(captured, "notes_search", { query: ["needle-address", "zeta"], pattern: "address.md" }, ctx)).files[0]?.matches.find((match) => match.line === 4);
321
- report.push(`notes_search OR address: offset_chars=${String(orLine4?.offset_chars)}`);
322
- if (orLine4 === undefined)
323
- failures.push("notes_search carries no line-four OR match");
324
- else {
325
- const atMatch = resultRead(await call(captured, "notes_read", { path: "address.md", offset_chars: orLine4.offset_chars }, ctx));
326
- if (!atMatch.content.startsWith("zeta"))
327
- failures.push(`notes_search OR offset_chars=${orLine4.offset_chars} does not start at the earliest line-four query`);
328
- }
329
- // --- Negative offsets on both stores: a tail read reaches the end in one call, the response
330
- // echoes the resolved absolute offset, N >= total_chars reads from the start, and the cursor
331
- // law still holds when a negative-start page is cut short.
332
- for (const profile of PROFILES) {
333
- const tailText = `head${profile.unit.repeat(20)}TAIL${profile.unit.repeat(20)}`;
334
- const path = `tail-${profile.name}.md`;
335
- await call(captured, "notes_write", { path, content: tailText }, ctx);
336
- const full = resultRead(await call(captured, "notes_read", { path }, ctx));
337
- const total = full.total_chars;
338
- const bodyChars = codePoints(tailText);
339
- const tail = resultRead(await call(captured, "notes_read", { path, offset_chars: -10 }, ctx));
340
- report.push(`notes_read negative offset: ${profile.name} total=${total} -> offset_chars=${tail.offset_chars} next=${String(tail.next_offset_chars)} content=${JSON.stringify(tail.content)}`);
341
- if (tail.offset_chars !== total - 10)
342
- failures.push(`negative offset: notes_read ${profile.name} echoed ${tail.offset_chars}, expected ${total - 10}`);
343
- if (tail.content !== codePointSlice(tailText, bodyChars - 10))
344
- failures.push(`negative offset: notes_read ${profile.name} did not reach the body tail in one call`);
345
- if (tail.next_offset_chars !== null)
346
- failures.push(`negative offset: notes_read ${profile.name} tail read is not exhausted`);
347
- const fromStart = resultRead(await call(captured, "notes_read", { path, offset_chars: -(total + 5), limit_chars: 8 }, ctx));
348
- if (fromStart.offset_chars !== 0)
349
- failures.push(`negative offset: notes_read ${profile.name} with N >= total_chars echoed ${fromStart.offset_chars}, expected 0`);
350
- if (fromStart.content !== codePointSlice(full.content, 0, 8))
351
- failures.push(`negative offset: notes_read ${profile.name} with N >= total_chars did not read from the start`);
352
- const cut = resultRead(await call(captured, "notes_read", { path, offset_chars: -15, limit_chars: 4 }, ctx));
353
- if (cut.next_offset_chars !== cut.offset_chars + codePoints(cut.content))
354
- failures.push(`negative offset: notes_read ${profile.name} cut a negative-start read off the cursor law`);
355
- const resumed = resultRead(await call(captured, "notes_read", { path, offset_chars: cut.next_offset_chars }, ctx));
356
- if (resumed.offset_chars !== cut.next_offset_chars)
357
- failures.push(`negative offset: notes_read ${profile.name} resume echoed ${resumed.offset_chars}, expected ${String(cut.next_offset_chars)}`);
358
- }
359
- // history_read gains the identical sugar over a durable item.
360
- const historyTailText = `${"h".repeat(50)}END`;
361
- const historyTailId = appendText(session, historyTailText);
362
- const historyTail = resultRead(await call(captured, "history_read", { window_id: windowId, item_id: historyTailId, offset_chars: -3 }, ctx));
363
- report.push(`history_read negative offset: offset_chars=${historyTail.offset_chars} next=${String(historyTail.next_offset_chars)} content=${JSON.stringify(historyTail.content)}`);
364
- if (historyTail.offset_chars !== 50 || historyTail.content !== "END" || historyTail.next_offset_chars !== null)
365
- failures.push(`negative offset: history_read returned ${JSON.stringify(historyTail)}`);
366
- const historyFromStart = resultRead(await call(captured, "history_read", { window_id: windowId, item_id: historyTailId, offset_chars: -500, limit_chars: 4 }, ctx));
367
- if (historyFromStart.offset_chars !== 0 || historyFromStart.content !== "hhhh")
368
- failures.push(`negative offset: history_read with N >= total_chars returned ${JSON.stringify(historyFromStart)}`);
369
- console.log(report.map((line) => ` ${line}`).join("\n"));
370
- assert.deepEqual(failures, [], `cursor-following lost text at ${failures.length} site(s)`);
371
- });
@@ -1,26 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import test from "node:test";
3
- import { contentText, resetV2WindowId, rootWindowId, visibleItem } from "../src/history.js";
4
- test("visibleItem reports a plain fitting prefix and names the full length", () => {
5
- const item = visibleItem({ windowId: "w", itemId: "i", role: "user", content: "abcdef", createdAt: undefined }, 4);
6
- assert.equal(Array.from(item.truncated_content).length, 4);
7
- assert.equal(item.truncated_content, "abcd");
8
- assert.equal(item.truncated_content.includes("…"), false, "no marker is appended to the payload");
9
- assert.equal(item.truncated, true);
10
- assert.equal(item.total_chars, 6);
11
- const whole = visibleItem({ windowId: "w", itemId: "i", role: "user", content: "abcdef", createdAt: undefined }, 6);
12
- assert.equal(whole.truncated, false);
13
- assert.equal(whole.total_chars, 6);
14
- assert.equal(whole.truncated_content, "abcdef");
15
- });
16
- test("persisted reset IDs are opaque within the supported protocol version", () => {
17
- assert.equal(resetV2WindowId({ piContext: "reset-v2", windowId: "opaque-window-id" }), "opaque-window-id");
18
- assert.equal(resetV2WindowId({ piContext: "reset-v1", windowId: "opaque-window-id" }), undefined);
19
- assert.equal(resetV2WindowId({ piContext: "reset-v2", windowId: 123 }), undefined);
20
- assert.equal(resetV2WindowId(null), undefined);
21
- });
22
- test("text content projection and root window IDs have stable shared forms", () => {
23
- const content = [{ type: "text", text: "first" }, { type: "toolCall", name: "ignored" }, { type: "text", text: "second" }];
24
- assert.equal(contentText(content), "first\nsecond");
25
- assert.equal(rootWindowId("12345678-abcd"), "pcw:12345678:root");
26
- });