@astrosheep/pi-context 0.19.0 → 0.21.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 (54) hide show
  1. package/dist/src/budget.js +65 -0
  2. package/dist/src/dream/cli.js +83 -0
  3. package/dist/src/dream/gates.js +22 -0
  4. package/dist/src/dream/git.js +28 -0
  5. package/dist/src/dream/lock.js +58 -0
  6. package/dist/src/dream/runner.js +115 -0
  7. package/dist/src/history-tools.js +105 -0
  8. package/dist/src/history.js +215 -0
  9. package/dist/src/index.js +98 -0
  10. package/dist/src/notes/address.js +31 -0
  11. package/dist/src/notes/frontmatter.js +136 -0
  12. package/dist/src/notes/model.js +101 -0
  13. package/dist/src/notes/paths.js +58 -0
  14. package/dist/src/notes/store.js +270 -0
  15. package/dist/src/notes/tools.js +153 -0
  16. package/dist/src/prompts.js +81 -0
  17. package/dist/src/protocol.js +56 -0
  18. package/dist/src/reset-lifecycle.js +101 -0
  19. package/dist/src/session-reader.js +1 -0
  20. package/dist/src/thresholds.js +75 -0
  21. package/dist/src/tool-output.js +175 -0
  22. package/dist/src/tool-schema.js +26 -0
  23. package/dist/src/warning.js +44 -0
  24. package/dist/test/agent-loop.test.js +214 -0
  25. package/dist/test/coherence.test.js +375 -0
  26. package/dist/test/dream.test.js +142 -0
  27. package/dist/test/history.test.js +26 -0
  28. package/dist/test/integration.test.js +1766 -0
  29. package/dist/test/notes.test.js +474 -0
  30. package/dist/test/pagination.property.test.js +476 -0
  31. package/dist/test/reset-lifecycle.test.js +199 -0
  32. package/package.json +13 -7
  33. package/playbook.md +32 -0
  34. package/src/budget.ts +11 -9
  35. package/src/dream/cli.ts +33 -0
  36. package/src/dream/gates.ts +20 -0
  37. package/src/dream/git.ts +27 -0
  38. package/src/dream/lock.ts +39 -0
  39. package/src/dream/runner.ts +111 -0
  40. package/src/history-tools.ts +5 -5
  41. package/src/history.ts +12 -7
  42. package/src/index.ts +13 -14
  43. package/src/notes/address.ts +33 -0
  44. package/src/{memory → notes}/frontmatter.ts +5 -3
  45. package/src/{notes.ts → notes/model.ts} +2 -2
  46. package/src/{memory → notes}/paths.ts +6 -1
  47. package/src/{memory → notes}/store.ts +62 -77
  48. package/src/notes/tools.ts +132 -0
  49. package/src/prompts.ts +31 -29
  50. package/src/protocol.ts +9 -5
  51. package/src/thresholds.ts +4 -1
  52. package/src/tool-output.ts +4 -1
  53. package/src/warning.ts +3 -3
  54. package/src/memory/tools.ts +0 -166
@@ -0,0 +1,375 @@
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
+ /**
75
+ * Decode a raw read (notes_read / history_read): a one-line bracketed header, then
76
+ * the payload verbatim (which may itself contain newlines), so split on the first newline only.
77
+ */
78
+ function resultRead(result) {
79
+ const text = result.content[0];
80
+ assert.ok(text && text.type === "text", "read result carries text");
81
+ const newline = text.text.indexOf("\n");
82
+ assert.ok(newline !== -1, "raw read carries a header line and a payload");
83
+ const header = text.text.slice(0, newline);
84
+ const content = text.text.slice(newline + 1);
85
+ assert.match(header, /^\[/, "the header is bracketed");
86
+ assert.match(header, /\]$/, "the header closes its bracket");
87
+ const match = header.match(/ · chars (\d+)-(\d+) of (\d+) · (end|continue at offset_chars=(\d+))/);
88
+ assert.ok(match, `read header names the char range and resume cursor: ${header}`);
89
+ const offset_chars = Number(match[1]);
90
+ const total_chars = Number(match[3]);
91
+ const next_offset_chars = match[4] === "end" ? null : Number(match[5]);
92
+ assert.equal([...content].length, Number(match[2]) - offset_chars, "the header range matches the delivered payload");
93
+ return { header, content, offset_chars, total_chars, next_offset_chars };
94
+ }
95
+ const PROFILES = [
96
+ { name: "cjk", unit: "历" },
97
+ { name: "emoji", unit: "🕷" },
98
+ { name: "ascii", unit: "x" },
99
+ ];
100
+ const failures = [];
101
+ const report = [];
102
+ const codePoints = (text) => [...text].length;
103
+ const codePointSlice = (text, start, end) => [...text].slice(start, end).join("");
104
+ /**
105
+ * Follow either read tool's cursor exactly as the protocol tells the model to, asserting the
106
+ * cursor law on every page. Both tools are the same character window over two stores, so one
107
+ * walker serves both; `address` carries the tool's own identity parameters, and `options.start`
108
+ * lets a walk begin at a resolved address (a search hit's offset, or a negative tail read).
109
+ */
110
+ async function walkWindow(captured, ctx, tool, address, label, options = {}) {
111
+ const parts = [];
112
+ let offset = options.start ?? 0;
113
+ let next = 0;
114
+ let calls = 0;
115
+ let total = 0;
116
+ while (next !== null && calls < 400) {
117
+ const params = { ...address, offset_chars: offset };
118
+ if (options.limitChars !== undefined)
119
+ params.limit_chars = options.limitChars;
120
+ const result = await call(captured, tool, params, ctx);
121
+ assertWithinBudget(result, `${label} offset=${offset}`);
122
+ const page = resultRead(result);
123
+ total = page.total_chars;
124
+ const delivered = codePoints(page.content);
125
+ if (page.offset_chars !== offset)
126
+ failures.push(`cursor law: ${label} echoed offset_chars=${page.offset_chars} for request offset ${offset}`);
127
+ if (page.next_offset_chars !== null && page.next_offset_chars !== offset + delivered) {
128
+ failures.push(`cursor law: ${label} next_offset_chars=${page.next_offset_chars} but offset ${offset} + delivered ${delivered}`);
129
+ }
130
+ if (page.next_offset_chars === null && offset + delivered !== page.total_chars) {
131
+ failures.push(`false exhaustion: ${label} returned next_offset_chars=null with ${page.total_chars - (offset + delivered)} characters undelivered`);
132
+ }
133
+ if (page.content.includes("…") || page.content.includes("[truncated"))
134
+ failures.push(`honest payload: ${label} appended a marker at offset ${offset}`);
135
+ parts.push(page.content);
136
+ next = page.next_offset_chars;
137
+ if (next !== null)
138
+ offset = next;
139
+ calls++;
140
+ }
141
+ if (offset !== total && calls >= 400)
142
+ failures.push(`${label} never terminated`);
143
+ return parts.join("");
144
+ }
145
+ const walkHistory = (captured, ctx, windowId, itemId, limitChars) => walkWindow(captured, ctx, "history_read", { window_id: windowId, item_id: itemId }, `history_read ${itemId}`, { limitChars });
146
+ const walkNote = (captured, ctx, path, start = 0) => walkWindow(captured, ctx, "notes_read", { path }, `notes_read ${path}`, { start });
147
+ test("coherence: following the returned cursors reconstructs the original text exactly", async () => {
148
+ const session = SessionManager.inMemory("/private/tmp/pi-context-test");
149
+ const captured = makeExtension(session);
150
+ const ctx = context(session);
151
+ const windowId = historyFromSession(ctx)[0].windowId;
152
+ // --- history_read: every profile x length reconstructs; cursor law holds per page ---
153
+ for (const profile of PROFILES) {
154
+ for (const length of [12_000, 12_001, 20_000, 30_000]) {
155
+ const original = profile.unit.repeat(length);
156
+ const itemId = appendText(session, original);
157
+ const reconstructed = await walkHistory(captured, ctx, windowId, itemId);
158
+ const missing = codePoints(original) - codePoints(reconstructed);
159
+ const line = `history_read default: ${profile.name} ${length} chars (${Buffer.byteLength(original, "utf8")} bytes) -> delivered ${codePoints(reconstructed)} chars, missing ${missing}, marker=${reconstructed.includes("[truncated")}`;
160
+ report.push(line);
161
+ if (reconstructed !== original)
162
+ failures.push(line);
163
+ session.appendMessage({ role: "assistant", content: [{ type: "text", text: `ack ${length}` }], timestamp: Date.now() });
164
+ }
165
+ }
166
+ // --- notes_read: a 40,000-code-point single line plus a tail, three profiles ---
167
+ for (const profile of PROFILES) {
168
+ const hugeLine = profile.unit.repeat(40_000);
169
+ const text = `${hugeLine}\ntail line`;
170
+ const path = `huge-${profile.name}.md`;
171
+ await call(captured, "notes_write", { path, content: text }, ctx);
172
+ const reconstructed = stripLeadingFrontmatter(await walkNote(captured, ctx, path));
173
+ const missing = codePoints(text) - codePoints(reconstructed);
174
+ 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")}`;
175
+ report.push(line);
176
+ if (reconstructed !== text)
177
+ failures.push(line);
178
+ }
179
+ // --- The terminator itself can lie. A single line with no trailing newline is exactly what
180
+ // notes_write { content } produces; the returned cursor must not be null while text remains.
181
+ for (const profile of PROFILES) {
182
+ const hugeLine = profile.unit.repeat(40_000);
183
+ const path = `solo-${profile.name}.md`;
184
+ await call(captured, "notes_write", { path, content: hugeLine }, ctx);
185
+ const first = resultRead(await call(captured, "notes_read", { path }, ctx));
186
+ const deliveredBody = first.content.startsWith("---\n") ? stripLeadingFrontmatter(first.content) : first.content;
187
+ const undelivered = codePoints(hugeLine) - codePoints(deliveredBody);
188
+ 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")}`);
189
+ if (first.offset_chars !== 0)
190
+ failures.push(`window echo: ${profile.name} first page echoed offset_chars=${first.offset_chars}, expected 0`);
191
+ if (first.total_chars < codePoints(hugeLine))
192
+ failures.push(`window total: ${profile.name} total_chars=${first.total_chars}, expected at least the body length`);
193
+ if (!first.content.startsWith("---\n"))
194
+ failures.push(`frontmatter first: ${profile.name} first page does not open with frontmatter`);
195
+ if (first.next_offset_chars === null && undelivered > 0)
196
+ failures.push(`false exhaustion: ${profile.name} returns next_offset_chars=null while ${undelivered} characters were never delivered`);
197
+ if (!hugeLine.startsWith(deliveredBody))
198
+ failures.push(`prefix law: ${profile.name} first page body is not a prefix of the line`);
199
+ const reconstructed = stripLeadingFrontmatter(await walkNote(captured, ctx, path));
200
+ if (reconstructed !== hugeLine)
201
+ failures.push(`notes_read single line is not reconstructible: ${profile.name}, ${codePoints(hugeLine) - codePoints(reconstructed)} chars missing`);
202
+ }
203
+ // --- The empty note must terminate: no self-feeding cursor, and a frontmatter-only window from 0.
204
+ await call(captured, "notes_write", { path: "empty.md", content: "" }, ctx);
205
+ const empty = resultRead(await call(captured, "notes_read", { path: "empty.md" }, ctx));
206
+ 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)}`);
207
+ if (empty.offset_chars !== 0 || !empty.content.startsWith("---\n") || !empty.content.endsWith("---\n\n"))
208
+ failures.push("the empty note is not a frontmatter-only window from 0");
209
+ if (empty.next_offset_chars !== null)
210
+ failures.push("pagination hole: the empty note is never exhausted");
211
+ // --- notes_search: an over-budget matched line is named, then read back with cursors ---
212
+ const hugeCjkLine = "历".repeat(40_000);
213
+ const searched = resultJson(await call(captured, "notes_search", { query: "历", pattern: "huge-cjk.md" }, ctx));
214
+ const matchedFile = searched.files[0];
215
+ const matched = matchedFile?.matches[0];
216
+ report.push(`notes_search: matches_total=${String(matchedFile?.matches_total)} returned=${String(matchedFile?.matches.length)} first match delivered ${codePoints(matched?.text ?? "")} of ${String(matched?.total_chars)} chars, truncated=${String(matched?.truncated)}`);
217
+ if (!matched)
218
+ failures.push("notes_search dropped the over-budget matched line entirely");
219
+ else {
220
+ if (matched.truncated !== true)
221
+ failures.push("notes_search does not flag the over-budget matched line as truncated");
222
+ if (matched.total_chars !== codePoints(hugeCjkLine))
223
+ failures.push(`notes_search match total_chars=${matched.total_chars}, expected ${codePoints(hugeCjkLine)}`);
224
+ if (!hugeCjkLine.startsWith(matched.text))
225
+ failures.push("notes_search delivered a non-prefix of the matched line");
226
+ if (codePoints(matched.text) >= matched.total_chars)
227
+ failures.push("notes_search claims the over-budget line fits in one response");
228
+ const walked = stripLeadingFrontmatter(await walkNote(captured, ctx, "huge-cjk.md"));
229
+ if (walked !== `${hugeCjkLine}\ntail line`)
230
+ failures.push(`notes_search match line is not reconstructible from the note read: missing ${codePoints(`${hugeCjkLine}\ntail line`) - codePoints(walked)} chars`);
231
+ }
232
+ // --- history_search: a hit's visible text may be cut, but the offset it carries
233
+ // must resolve to the query through history_read (addresses-only mode).
234
+ const searchItemContent = `${"padding ".repeat(400)}历史内容${" trailing".repeat(400)}`;
235
+ const searchItemId = appendText(session, searchItemContent);
236
+ const hit = resultJson(await call(captured, "history_search", { query: "历史内容", max_chars_per_item: 400, window_id: windowId }, ctx));
237
+ const first = hit.items.find((item) => item.item_id === searchItemId);
238
+ 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)}`);
239
+ if (!first)
240
+ failures.push("history_search did not return the matching item");
241
+ else {
242
+ if (first.truncated !== true)
243
+ failures.push("history_search does not flag the capped item as truncated");
244
+ if (first.total_chars !== codePoints(searchItemContent))
245
+ failures.push(`history_search total_chars=${first.total_chars}, expected ${codePoints(searchItemContent)}`);
246
+ if (!searchItemContent.startsWith(first.truncated_content))
247
+ failures.push("history_search delivered a non-prefix of the item");
248
+ if (first.truncated_content.includes("…"))
249
+ failures.push("history_search appended a marker to the payload");
250
+ if (!Number.isInteger(first.match_offset_chars))
251
+ failures.push("history_search carries no match_offset_chars");
252
+ else {
253
+ const at = resultRead(await call(captured, "history_read", { window_id: windowId, item_id: searchItemId, offset_chars: first.match_offset_chars, limit_chars: 8 }, ctx));
254
+ if (!at.content.includes("历史内容"))
255
+ failures.push(`history_read at match_offset_chars=${first.match_offset_chars} does not show the query`);
256
+ }
257
+ }
258
+ // max_chars_per_item: 1 is a real address page for both history tools.
259
+ const addresses = resultJson(await call(captured, "history_search", { query: "历史内容", max_chars_per_item: 1, window_id: windowId }, ctx));
260
+ const address = addresses.items.find((item) => item.item_id === searchItemId);
261
+ report.push(`history_search max_chars_per_item=1: address=${JSON.stringify(address)}`);
262
+ if (!address)
263
+ failures.push("history_search max_chars_per_item=1 dropped the hit");
264
+ else {
265
+ if (codePoints(address.truncated_content) !== 1)
266
+ failures.push(`max_chars_per_item=1 delivered ${codePoints(address.truncated_content)} code points`);
267
+ if (address.truncated !== true || address.total_chars !== codePoints(searchItemContent))
268
+ failures.push("max_chars_per_item=1 does not name the full length");
269
+ if (!Number.isInteger(address.match_offset_chars))
270
+ failures.push("max_chars_per_item=1 carries no address");
271
+ }
272
+ const listed = resultJson(await call(captured, "history_list", { window_id: windowId, max_chars_per_item: 1, recent_first: false, limit: 500 }, ctx));
273
+ const listedAddress = listed.items.find((item) => item.item_id === searchItemId);
274
+ report.push(`history_list max_chars_per_item=1: ${JSON.stringify(listedAddress)}`);
275
+ if (!listedAddress)
276
+ failures.push("history_list max_chars_per_item=1 dropped the item");
277
+ else if (codePoints(listedAddress.truncated_content) !== 1 || listedAddress.truncated !== true || listedAddress.total_chars !== codePoints(searchItemContent)) {
278
+ failures.push("history_list max_chars_per_item=1 is not an honest address page");
279
+ }
280
+ // --- brain-04: capping a file's matches to fit the wire budget must be named, never silent.
281
+ const manyLines = Array.from({ length: 8_000 }, (_, index) => `needle ${index}`);
282
+ await call(captured, "notes_write", { path: "many.md", content: manyLines.join("\n") }, ctx);
283
+ const many = resultJson(await call(captured, "notes_search", { query: "needle", pattern: "many.md" }, ctx));
284
+ const manyEntry = many.files[0];
285
+ report.push(`brain-04: matches_total=${String(manyEntry?.matches_total)} returned=${String(manyEntry?.matches.length)}, next_cursor=${String(many.next_cursor)}`);
286
+ if (!manyEntry)
287
+ failures.push("brain-04: the many-match file is absent from the search result");
288
+ else {
289
+ if (manyEntry.matches_total !== manyLines.length)
290
+ failures.push(`brain-04: matches_total=${manyEntry.matches_total}, expected ${manyLines.length}`);
291
+ if (!(manyEntry.matches_total > manyEntry.matches.length))
292
+ failures.push("brain-04: budget-capped matches were silently dropped (matches_total === matches.length)");
293
+ if (manyEntry.matches_total - manyEntry.matches.length <= 0)
294
+ failures.push("brain-04: the response names no dropped matches");
295
+ }
296
+ // A file whose first match alone is over budget with more matches behind it: dropping trailing
297
+ // matches and cutting the kept line must still leave the whole response inside the wire budget.
298
+ const manyHugeLines = Array.from({ length: 4 }, (_, index) => `needle ${index} ${"w".repeat(45_000)}`);
299
+ await call(captured, "notes_write", { path: "huge-many.md", content: manyHugeLines.join("\n") }, ctx);
300
+ const hugeManyResult = await call(captured, "notes_search", { query: "needle", pattern: "huge-many.md" }, ctx);
301
+ assertWithinBudget(hugeManyResult, "notes_search huge-many");
302
+ const hugeEntry = resultJson(hugeManyResult).files[0];
303
+ report.push(`huge-many: matches_total=${String(hugeEntry?.matches_total)} returned=${String(hugeEntry?.matches.length)} truncated=${String(hugeEntry?.matches[0]?.truncated)}`);
304
+ if (!hugeEntry)
305
+ failures.push("huge-many: the many-huge-match file is absent from the search result");
306
+ else {
307
+ if (!(hugeEntry.matches_total > hugeEntry.matches.length))
308
+ failures.push("huge-many: dropped matches are not named");
309
+ if (hugeEntry.matches[0]?.truncated !== true)
310
+ failures.push("huge-many: the kept match is not flagged as a prefix");
311
+ }
312
+ // --- notes_search addresses: a match's offset_chars is the body-absolute code-point
313
+ // position of the earliest query occurrence in its line, so search → read composes exactly like
314
+ // history's match_offset_chars two-stage.
315
+ const addressLine1 = "pad ".repeat(50);
316
+ const addressLine3 = `${"历".repeat(20)}needle-address here`;
317
+ const addressLine4 = "zeta 历 needle-address";
318
+ await call(captured, "notes_write", { path: "address.md", content: `${addressLine1}\nsecond\n${addressLine3}\n${addressLine4}` }, ctx);
319
+ const expectedAddress = codePoints(addressLine1) + 1 + codePoints("second") + 1 + 20;
320
+ const addressHit = resultJson(await call(captured, "notes_search", { query: "needle-address", pattern: "address.md" }, ctx)).files[0]?.matches.find((match) => match.line === 3);
321
+ report.push(`notes_search address: line=${String(addressHit?.line)} offset_chars=${String(addressHit?.offset_chars)} expected=${expectedAddress}`);
322
+ const addressOffset = addressHit?.offset_chars;
323
+ if (typeof addressOffset !== "number")
324
+ failures.push("notes_search carries no offset_chars");
325
+ else if (addressOffset !== expectedAddress)
326
+ failures.push(`notes_search offset_chars=${addressOffset}, expected ${expectedAddress} (body-absolute, at the query)`);
327
+ // Multi-query OR: a line's address is the earliest occurrence of any query inside that line.
328
+ const line4Base = expectedAddress - 20 + codePoints(addressLine3) + 1;
329
+ const orLine4 = resultJson(await call(captured, "notes_search", { query: ["needle-address", "zeta"], pattern: "address.md" }, ctx)).files[0]?.matches.find((match) => match.line === 4);
330
+ report.push(`notes_search OR address: offset_chars=${String(orLine4?.offset_chars)} expected=${line4Base}`);
331
+ if (orLine4?.offset_chars !== line4Base)
332
+ failures.push(`notes_search OR offset_chars=${String(orLine4?.offset_chars)}, expected ${line4Base} (earliest of any query)`);
333
+ // --- Negative offsets on both stores: a tail read reaches the end in one call, the response
334
+ // echoes the resolved absolute offset, N >= total_chars reads from the start, and the cursor
335
+ // law still holds when a negative-start page is cut short.
336
+ for (const profile of PROFILES) {
337
+ const tailText = `head${profile.unit.repeat(20)}TAIL${profile.unit.repeat(20)}`;
338
+ const path = `tail-${profile.name}.md`;
339
+ await call(captured, "notes_write", { path, content: tailText }, ctx);
340
+ const full = resultRead(await call(captured, "notes_read", { path }, ctx));
341
+ const total = full.total_chars;
342
+ const bodyChars = codePoints(tailText);
343
+ const tail = resultRead(await call(captured, "notes_read", { path, offset_chars: -10 }, ctx));
344
+ 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)}`);
345
+ if (tail.offset_chars !== total - 10)
346
+ failures.push(`negative offset: notes_read ${profile.name} echoed ${tail.offset_chars}, expected ${total - 10}`);
347
+ if (tail.content !== codePointSlice(tailText, bodyChars - 10))
348
+ failures.push(`negative offset: notes_read ${profile.name} did not reach the body tail in one call`);
349
+ if (tail.next_offset_chars !== null)
350
+ failures.push(`negative offset: notes_read ${profile.name} tail read is not exhausted`);
351
+ const fromStart = resultRead(await call(captured, "notes_read", { path, offset_chars: -(total + 5), limit_chars: 8 }, ctx));
352
+ if (fromStart.offset_chars !== 0)
353
+ failures.push(`negative offset: notes_read ${profile.name} with N >= total_chars echoed ${fromStart.offset_chars}, expected 0`);
354
+ if (fromStart.content !== codePointSlice(full.content, 0, 8))
355
+ failures.push(`negative offset: notes_read ${profile.name} with N >= total_chars did not read from the start`);
356
+ const cut = resultRead(await call(captured, "notes_read", { path, offset_chars: -15, limit_chars: 4 }, ctx));
357
+ if (cut.next_offset_chars !== cut.offset_chars + codePoints(cut.content))
358
+ failures.push(`negative offset: notes_read ${profile.name} cut a negative-start read off the cursor law`);
359
+ const resumed = resultRead(await call(captured, "notes_read", { path, offset_chars: cut.next_offset_chars }, ctx));
360
+ if (resumed.offset_chars !== cut.next_offset_chars)
361
+ failures.push(`negative offset: notes_read ${profile.name} resume echoed ${resumed.offset_chars}, expected ${String(cut.next_offset_chars)}`);
362
+ }
363
+ // history_read gains the identical sugar over a durable item.
364
+ const historyTailText = `${"h".repeat(50)}END`;
365
+ const historyTailId = appendText(session, historyTailText);
366
+ const historyTail = resultRead(await call(captured, "history_read", { window_id: windowId, item_id: historyTailId, offset_chars: -3 }, ctx));
367
+ report.push(`history_read negative offset: offset_chars=${historyTail.offset_chars} next=${String(historyTail.next_offset_chars)} content=${JSON.stringify(historyTail.content)}`);
368
+ if (historyTail.offset_chars !== 50 || historyTail.content !== "END" || historyTail.next_offset_chars !== null)
369
+ failures.push(`negative offset: history_read returned ${JSON.stringify(historyTail)}`);
370
+ const historyFromStart = resultRead(await call(captured, "history_read", { window_id: windowId, item_id: historyTailId, offset_chars: -500, limit_chars: 4 }, ctx));
371
+ if (historyFromStart.offset_chars !== 0 || historyFromStart.content !== "hhhh")
372
+ failures.push(`negative offset: history_read with N >= total_chars returned ${JSON.stringify(historyFromStart)}`);
373
+ console.log(report.map((line) => ` ${line}`).join("\n"));
374
+ assert.deepEqual(failures, [], `cursor-following lost text at ${failures.length} site(s)`);
375
+ });
@@ -0,0 +1,142 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { existsSync, linkSync, mkdirSync, readFileSync, statSync, symlinkSync, utimesSync, writeFileSync } from "node:fs";
4
+ import { mkdtempSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { acquireLock, failLock } from "../src/dream/lock.js";
8
+ import { materialGate, timeGate } from "../src/dream/gates.js";
9
+ import { defaultDreamerSessionFactory, dreamerWriteToolDefinitions, runDreamer, DREAMER_TOOLS } from "../src/dream/runner.js";
10
+ import { gitCommit } from "../src/dream/git.js";
11
+ import { execFileSync } from "node:child_process";
12
+ import { contentText } from "../src/history.js";
13
+ function fixture() { return mkdtempSync(join(tmpdir(), "dream-")); }
14
+ function old(path) { const d = new Date(Date.now() - 48 * 3600_000); utimesSync(path, d, d); }
15
+ test("time and material gates preserve skip decisions and reasons", () => {
16
+ const home = fixture();
17
+ const lock = join(home, ".dream.lock");
18
+ writeFileSync(lock, "999999");
19
+ const fresh = timeGate(lock, 24);
20
+ assert.equal(fresh.ok, false);
21
+ assert.equal(fresh.reason, "time gate: lock is too fresh");
22
+ old(lock);
23
+ assert.deepEqual(timeGate(lock, 24).ok, true);
24
+ mkdirSync(join(home, "pi/session/one"), { recursive: true });
25
+ writeFileSync(join(home, "pi/session/one/a.md"), "a");
26
+ const material = materialGate(home, statSync(lock).mtimeMs, 1);
27
+ assert.equal(material.ok, true);
28
+ assert.match(material.reason, /material gate: 1 changed sessions/);
29
+ assert.equal(materialGate(home, Date.now(), 2).ok, false);
30
+ });
31
+ test("live lock is excluded, dead lock is reclaimed, and failures restore mtime", () => {
32
+ const home = fixture();
33
+ const lock = join(home, ".dream.lock");
34
+ writeFileSync(lock, String(process.pid));
35
+ const live = acquireLock(lock);
36
+ assert.equal(live.held, false);
37
+ assert.equal(live.reason, "lock gate: live process holds the lock");
38
+ writeFileSync(lock, "999999");
39
+ old(lock);
40
+ const prior = statSync(lock).mtimeMs;
41
+ const reclaimed = acquireLock(lock);
42
+ assert.equal(reclaimed.held, true);
43
+ utimesSync(lock, new Date(), new Date());
44
+ failLock(reclaimed);
45
+ assert.ok(Math.abs(statSync(lock).mtimeMs - prior) < 2000);
46
+ });
47
+ test("dreamer write jail accepts home files and refuses escapes", async () => {
48
+ const home = fixture();
49
+ const tools = new Map(dreamerWriteToolDefinitions(home).map((tool) => [tool.name, tool]));
50
+ const ctx = { cwd: home };
51
+ await tools.get("write").execute("write", { path: "global/x.md", content: "one" }, undefined, undefined, ctx);
52
+ assert.equal(readFileSync(join(home, "global/x.md"), "utf8"), "one");
53
+ const rejectsOutsideHome = async (tool, path) => {
54
+ const params = tool === "write" ? { path, content: "outside" } : { path, edits: [{ oldText: "one", newText: "outside" }] };
55
+ await assert.rejects(() => tools.get(tool).execute("escape", params, undefined, undefined, ctx), (error) => error.message.includes(home));
56
+ };
57
+ for (const tool of ["write", "edit"]) {
58
+ await rejectsOutsideHome(tool, "/tmp/dream-jail-outside.md");
59
+ await rejectsOutsideHome(tool, "../dream-jail-outside.md");
60
+ }
61
+ const outside = fixture();
62
+ symlinkSync(outside, join(home, "escape"));
63
+ await rejectsOutsideHome("write", "escape/outside.md");
64
+ await rejectsOutsideHome("edit", "escape/outside.md");
65
+ assert.equal(existsSync(join(outside, "outside.md")), false, "the jail does not write through an in-home symlink");
66
+ const outsideFile = join(outside, "outside.md");
67
+ writeFileSync(outsideFile, "outside");
68
+ symlinkSync(outsideFile, join(home, "global/outside-link.md"));
69
+ await rejectsOutsideHome("write", "global/outside-link.md");
70
+ assert.equal(readFileSync(outsideFile, "utf8"), "outside", "the jail does not write through a symlinked file outside home");
71
+ linkSync(outsideFile, join(home, "global/hardlink.md"));
72
+ await rejectsOutsideHome("write", "global/hardlink.md");
73
+ assert.equal(readFileSync(outsideFile, "utf8"), "outside", "the jail does not write through a hardlinked file outside home");
74
+ const insideFile = join(home, "global/inside.md");
75
+ writeFileSync(insideFile, "inside");
76
+ symlinkSync(insideFile, join(home, "global/inside-link.md"));
77
+ await tools.get("write").execute("write", { path: "global/inside-link.md", content: "updated" }, undefined, undefined, ctx);
78
+ assert.equal(readFileSync(insideFile, "utf8"), "updated", "the jail permits a symlinked file that resolves inside home");
79
+ });
80
+ test("dreamer allowlist contains only the file tools and reports their writes", async () => {
81
+ let configured = [];
82
+ const session = {
83
+ subscribe(handler) { this.handler = handler; return () => { }; },
84
+ handler: (_event) => { },
85
+ async prompt(_text) { this.handler({ type: "tool_execution_start", toolName: "write", args: { path: "global/a.md", content: "a" } }); this.handler({ type: "tool_execution_start", toolName: "edit", args: { path: "project/p.md", edits: [] } }); this.handler({ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "done" }, { type: "text", text: "again" }] } }); },
86
+ dispose() { },
87
+ };
88
+ const result = await runDreamer("playbook", "/tmp/notes", { sessionFactory: async (options) => { configured = options.tools; return session; } });
89
+ assert.deepEqual(configured, DREAMER_TOOLS);
90
+ assert.deepEqual(configured, ["read", "grep", "find", "ls", "write", "edit"]);
91
+ assert.equal(configured.some((tool) => tool.startsWith("notes_")), false);
92
+ assert.deepEqual(result.writes, [{ tool: "write", path: "global/a.md" }, { tool: "edit", path: "project/p.md" }]);
93
+ assert.equal(result.report, "done\nagain");
94
+ assert.equal(result.report, contentText([{ type: "text", text: "done" }, { type: "text", text: "again" }]), "dream and history share the text projection");
95
+ });
96
+ test("dreamer session has exactly the jailed file-tool allowlist", async () => {
97
+ const session = await defaultDreamerSessionFactory({ cwd: fixture(), tools: DREAMER_TOOLS });
98
+ try {
99
+ assert.deepEqual(session.agent.state.tools.map((tool) => tool.name).sort(), [...DREAMER_TOOLS].sort());
100
+ }
101
+ finally {
102
+ session.dispose();
103
+ }
104
+ });
105
+ test("playbook describes plain files and the retained frontmatter", () => {
106
+ const playbook = readFileSync(join(process.cwd(), "playbook.md"), "utf8");
107
+ assert.equal(playbook.includes("notes_"), false);
108
+ for (const field of ["origin", "status", "stale", "created_at", "updated_at", "last_accessed", "access_count"])
109
+ assert.match(playbook, new RegExp(`^${field}:`, "m"));
110
+ assert.equal(/^scope:/m.test(playbook), false, "scope is derived from the address rather than persisted");
111
+ assert.match(playbook, /Nothing is physically deleted/);
112
+ });
113
+ test("provider errors propagate without parsing a response", async () => {
114
+ const session = {
115
+ subscribe(handler) { this.handler = handler; return () => { }; },
116
+ handler: (_event) => { },
117
+ async prompt(_text) { this.handler({ type: "message_end", message: { role: "assistant", stopReason: "error", errorMessage: "Insufficient Balance" } }); },
118
+ dispose() { },
119
+ };
120
+ await assert.rejects(() => runDreamer("playbook", "/tmp/notes", { sessionFactory: async () => session }), /Insufficient Balance/);
121
+ });
122
+ test("default dreamer rejects an unresolvable model pattern", async () => {
123
+ await assert.rejects(() => defaultDreamerSessionFactory({ cwd: "/tmp/notes", modelPattern: "definitely-not-a-real-model", tools: DREAMER_TOOLS }), /definitely-not-a-real-model/);
124
+ });
125
+ test("git audit layer commits baseline and dream, stays silent when clean, keeps file content", () => {
126
+ const home = fixture();
127
+ writeFileSync(join(home, "a.md"), "one");
128
+ gitCommit(home, "baseline t");
129
+ gitCommit(home, "dream t"); // clean tree — no empty commit
130
+ const log1 = execFileSync("git", ["log", "--format=%s"], { cwd: home, encoding: "utf8" }).trim();
131
+ assert.equal(log1, "baseline t");
132
+ writeFileSync(join(home, "a.md"), "two");
133
+ gitCommit(home, "dream t2");
134
+ const log2 = execFileSync("git", ["log", "--format=%s"], { cwd: home, encoding: "utf8" }).trim();
135
+ assert.equal(log2, "dream t2\nbaseline t");
136
+ assert.equal(readFileSync(join(home, "a.md"), "utf8"), "two"); // notes themselves untouched by the layer
137
+ const before = execFileSync("git", ["show", "HEAD~1:a.md"], { cwd: home, encoding: "utf8" }).trim();
138
+ assert.equal(before, "one"); // rollback information actually recorded
139
+ });
140
+ test("git audit layer never breaks the run when git itself fails", () => {
141
+ gitCommit(join(fixture(), "missing", "home"), "x"); // init on a missing cwd throws inside — swallowed
142
+ });
@@ -0,0 +1,26 @@
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
+ });