@astrosheep/pi-context 0.22.0 → 0.23.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.
- package/README.md +5 -1
- package/dist/src/dream/cli.js +13 -1
- package/dist/src/dream/doctor.js +138 -0
- package/dist/src/history-tools.js +3 -4
- package/dist/src/notes/store.js +14 -8
- package/dist/src/notes/tools.js +14 -21
- package/dist/src/reset-lifecycle.js +82 -28
- package/dist/src/tool-output.js +8 -11
- package/dist/test/agent-loop.test.js +25 -9
- package/dist/test/coherence.test.js +32 -36
- package/dist/test/doctor.test.js +44 -0
- package/dist/test/integration.test.js +26 -27
- package/dist/test/notes.test.js +106 -20
- package/dist/test/pagination.property.test.js +24 -29
- package/dist/test/reset-lifecycle.test.js +99 -102
- package/docs/reset-lifecycle.md +4 -2
- package/package.json +1 -1
- package/src/dream/cli.ts +10 -1
- package/src/dream/doctor.ts +97 -0
- package/src/history-tools.ts +3 -4
- package/src/notes/store.ts +16 -9
- package/src/notes/tools.ts +16 -23
- package/src/reset-lifecycle.ts +89 -28
- package/src/tool-output.ts +8 -11
|
@@ -7,7 +7,7 @@ import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
|
|
|
7
7
|
import { createAgentSession, DefaultResourceLoader, ModelRuntime, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import piContext from "../src/index.js";
|
|
9
9
|
import { WARNING_TYPE, GUIDANCE_TYPE } from "../src/protocol.js";
|
|
10
|
-
for (const mode of ["golden", "write-error", "ignored-warning", "explicit", "uncompactable", "followup", "steering", "repeat", "abort"]) {
|
|
10
|
+
for (const mode of ["golden", "write-error", "ignored-warning", "explicit", "uncompactable", "followup", "steering", "repeat", "nested", "immediate-dispose", "abort"]) {
|
|
11
11
|
test(`real Pi loop: ${mode} reset preserves history and handles completion`, { timeout: 15000 }, async () => {
|
|
12
12
|
const dir = mkdtempSync(join(tmpdir(), "pi-context-loop-"));
|
|
13
13
|
const previousDir = process.env.PI_CODING_AGENT_DIR;
|
|
@@ -16,6 +16,7 @@ for (const mode of ["golden", "write-error", "ignored-warning", "explicit", "unc
|
|
|
16
16
|
const notesRoot = mkdtempSync(join(tmpdir(), "pi-context-loop-notes-"));
|
|
17
17
|
process.env.PI_NOTES_HOME = notesRoot;
|
|
18
18
|
let session;
|
|
19
|
+
let disposed = false;
|
|
19
20
|
try {
|
|
20
21
|
const runtime = await ModelRuntime.create({ authPath: join(dir, "auth.json"), modelsPath: null, modelsStorePath: join(dir, "models"), refreshOnCreate: false });
|
|
21
22
|
await runtime.setRuntimeApiKey("openai", "scripted-test-key");
|
|
@@ -23,7 +24,7 @@ for (const mode of ["golden", "write-error", "ignored-warning", "explicit", "unc
|
|
|
23
24
|
assert.ok(base);
|
|
24
25
|
const model = { ...base, contextWindow: 100000, maxTokens: 4096 };
|
|
25
26
|
const usageMode = mode === "golden" || mode === "write-error" || mode === "ignored-warning";
|
|
26
|
-
const expectedResets = mode === "abort" || mode === "uncompactable" ? 0 : 1;
|
|
27
|
+
const expectedResets = mode === "abort" || mode === "uncompactable" ? 0 : mode === "nested" ? 2 : 1;
|
|
27
28
|
// 0.86 split-turn cut can still summarize a turn prefix, so keepRecentTokens: 1 no longer
|
|
28
29
|
// makes a reset uncompactable; a keep larger than the whole session keeps everything and does.
|
|
29
30
|
const settings = { compaction: { enabled: usageMode, reserveTokens: 32768, keepRecentTokens: mode === "uncompactable" ? 1_000_000 : 200 }, retry: { enabled: false } };
|
|
@@ -36,7 +37,7 @@ for (const mode of ["golden", "write-error", "ignored-warning", "explicit", "unc
|
|
|
36
37
|
let finish;
|
|
37
38
|
let failFinish;
|
|
38
39
|
const finished = new Promise((resolve, reject) => { finish = resolve; failFinish = reject; });
|
|
39
|
-
const finishTimeout = setTimeout(() => failFinish(new Error(`timed out waiting for ${mode} agent settlement`)), 5000);
|
|
40
|
+
const finishTimeout = setTimeout(() => failFinish(new Error(`timed out waiting for ${mode} agent settlement (resets=${resets}, settled=${settled}, requests=${requests.length})`)), 5000);
|
|
40
41
|
const loader = new DefaultResourceLoader({ cwd: dir, agentDir: dir, settingsManager,
|
|
41
42
|
noExtensions: true, noSkills: true, noThemes: true, noPromptTemplates: true,
|
|
42
43
|
systemPromptOverride: () => "Use the tools as requested.", agentsFilesOverride: () => ({ agentsFiles: [] }),
|
|
@@ -83,7 +84,8 @@ for (const mode of ["golden", "write-error", "ignored-warning", "explicit", "unc
|
|
|
83
84
|
freshTurns++;
|
|
84
85
|
const sawWarning = request.includes("Your memory is about to be erased");
|
|
85
86
|
const sawGuidance = request.includes("Your brain is almost out of room");
|
|
86
|
-
const explicitReset = (n === 1 && !usageMode && mode !== "uncompactable") || (mode === "repeat" && (n === 1 || n === 3));
|
|
87
|
+
const explicitReset = (n === 1 && !usageMode && mode !== "uncompactable") || (mode === "repeat" && (n === 1 || n === 3)) || (mode === "nested" && n === 3);
|
|
88
|
+
const nestedCheckpoint = mode === "nested" && n === 2;
|
|
87
89
|
const checkpoint = usageMode && sawWarning && !checkpointed && mode !== "ignored-warning";
|
|
88
90
|
if (checkpoint)
|
|
89
91
|
checkpointed = true;
|
|
@@ -92,11 +94,11 @@ for (const mode of ["golden", "write-error", "ignored-warning", "explicit", "unc
|
|
|
92
94
|
// window's reminder. "ignored-warning" keeps working instead of checkpointing.
|
|
93
95
|
const probe = usageMode && !checkpoint && ((!fresh && !sawWarning) || (mode === "ignored-warning" && sawWarning) || (fresh && freshTurns === 2 && !sawGuidance));
|
|
94
96
|
const tokens = usageMode ? (fresh ? (freshTurns === 1 ? 100 : 50000) : sawWarning ? 70000 : n === 1 ? 50000 : 60000) : 100;
|
|
95
|
-
const tool = explicitReset || (mode === "uncompactable" && n === 1);
|
|
96
|
-
const call = probe ? "get_context_remaining" : checkpoint ? "notes_write" : tool ? "new_context" : undefined;
|
|
97
|
+
const tool = explicitReset || nestedCheckpoint || (mode === "uncompactable" && n === 1);
|
|
98
|
+
const call = probe ? "get_context_remaining" : checkpoint || nestedCheckpoint ? "notes_write" : tool ? "new_context" : undefined;
|
|
97
99
|
const message = { role: "assistant", api: model.api, provider: model.provider, model: model.id,
|
|
98
100
|
content: probe ? [{ type: "toolCall", id: "probe-call", name: "get_context_remaining", arguments: {} }]
|
|
99
|
-
: checkpoint ? [{ type: "toolCall", id: "checkpoint-call", name: "notes_write", arguments: { address: mode === "write-error" ? "../invalid.md" : "checkpoint.md", content: "CHECKPOINT_SENTINEL" } }]
|
|
101
|
+
: checkpoint || nestedCheckpoint ? [{ type: "toolCall", id: "checkpoint-call", name: "notes_write", arguments: { address: mode === "write-error" ? "../invalid.md" : "checkpoint.md", content: nestedCheckpoint ? "NESTED_RESET_PADDING ".repeat(300) : "CHECKPOINT_SENTINEL" } }]
|
|
100
102
|
: tool ? [{ type: "toolCall", id: "reset-call", name: "new_context", arguments: {} }]
|
|
101
103
|
: [{ type: "text", text: fresh ? "Resumed." : "Working." }],
|
|
102
104
|
stopReason: call ? "toolUse" : "stop", timestamp: Date.now(),
|
|
@@ -116,9 +118,18 @@ for (const mode of ["golden", "write-error", "ignored-warning", "explicit", "unc
|
|
|
116
118
|
}
|
|
117
119
|
});
|
|
118
120
|
await session.prompt("OLD_CONTEXT_SENTINEL: save progress and continue the task.");
|
|
121
|
+
if (mode === "immediate-dispose") {
|
|
122
|
+
// prompt() must not resolve after compaction merely because sendMessage is
|
|
123
|
+
// detached: by this point the continuation has settled and answered.
|
|
124
|
+
session.dispose();
|
|
125
|
+
disposed = true;
|
|
126
|
+
assert.ok(settled >= 2, "the continuation settles before the originating prompt resolves");
|
|
127
|
+
assert.ok(requests.length >= 2 && !requests.at(-1).includes("OLD_CONTEXT_SENTINEL"), "the resumed answer exists before immediate disposal");
|
|
128
|
+
}
|
|
119
129
|
await finished;
|
|
120
130
|
clearTimeout(finishTimeout);
|
|
121
|
-
|
|
131
|
+
if (!disposed)
|
|
132
|
+
await session.waitForIdle();
|
|
122
133
|
if (mode === "abort") {
|
|
123
134
|
assert.equal(resets, 0, "user cancellation clears pending rollover");
|
|
124
135
|
assert.equal(requests.length, 1, "no continuation resurrects the cancelled run");
|
|
@@ -169,6 +180,10 @@ for (const mode of ["golden", "write-error", "ignored-warning", "explicit", "unc
|
|
|
169
180
|
const queuedEntries = sm.getBranch().filter((entry) => entry.type === "message" && JSON.stringify(entry.message).includes("QUEUED_INPUT_SENTINEL"));
|
|
170
181
|
assert.equal(queuedEntries.length, 1, "one durable user input");
|
|
171
182
|
}
|
|
183
|
+
if (mode === "nested") {
|
|
184
|
+
assert.equal(resets, 2, "a continuation-requested reset forms a second completed handoff");
|
|
185
|
+
assert.equal(new Set(sm.getBranch().filter((entry) => entry.type === "compaction").map((entry) => JSON.stringify(entry.details))).size, 2);
|
|
186
|
+
}
|
|
172
187
|
if (mode === "repeat") {
|
|
173
188
|
const nextFinished = new Promise((resolve) => { finish = resolve; });
|
|
174
189
|
targetResets = 2;
|
|
@@ -198,7 +213,8 @@ for (const mode of ["golden", "write-error", "ignored-warning", "explicit", "unc
|
|
|
198
213
|
}
|
|
199
214
|
}
|
|
200
215
|
finally {
|
|
201
|
-
|
|
216
|
+
if (!disposed)
|
|
217
|
+
session?.dispose();
|
|
202
218
|
if (previousDir === undefined)
|
|
203
219
|
delete process.env.PI_CODING_AGENT_DIR;
|
|
204
220
|
else
|
|
@@ -71,25 +71,19 @@ function assertWithinBudget(result, label) {
|
|
|
71
71
|
function appendText(sessionManager, text) {
|
|
72
72
|
return sessionManager.appendMessage({ role: "user", content: [{ type: "text", text }], timestamp: Date.now() });
|
|
73
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
|
-
*/
|
|
74
|
+
/** Decode either raw read through the shared READ WINDOW grammar without including metadata in the payload. */
|
|
78
75
|
function resultRead(result) {
|
|
79
76
|
const text = result.content[0];
|
|
80
77
|
assert.ok(text && text.type === "text", "read result carries text");
|
|
81
|
-
const
|
|
82
|
-
assert.ok(
|
|
83
|
-
const header =
|
|
84
|
-
const content = text.text.slice(
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
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");
|
|
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");
|
|
93
87
|
return { header, content, offset_chars, total_chars, next_offset_chars };
|
|
94
88
|
}
|
|
95
89
|
const PROFILES = [
|
|
@@ -213,18 +207,17 @@ test("coherence: following the returned cursors reconstructs the original text e
|
|
|
213
207
|
const searched = resultJson(await call(captured, "notes_search", { query: "历", pattern: "huge-cjk.md" }, ctx));
|
|
214
208
|
const matchedFile = searched.files[0];
|
|
215
209
|
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 ?? "")}
|
|
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)}`);
|
|
217
211
|
if (!matched)
|
|
218
212
|
failures.push("notes_search dropped the over-budget matched line entirely");
|
|
219
213
|
else {
|
|
220
214
|
if (matched.truncated !== true)
|
|
221
215
|
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
216
|
if (!hugeCjkLine.startsWith(matched.text))
|
|
225
217
|
failures.push("notes_search delivered a non-prefix of the matched line");
|
|
226
|
-
|
|
227
|
-
|
|
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");
|
|
228
221
|
const walked = stripLeadingFrontmatter(await walkNote(captured, ctx, "huge-cjk.md"));
|
|
229
222
|
if (walked !== `${hugeCjkLine}\ntail line`)
|
|
230
223
|
failures.push(`notes_search match line is not reconstructible from the note read: missing ${codePoints(`${hugeCjkLine}\ntail line`) - codePoints(walked)} chars`);
|
|
@@ -309,27 +302,30 @@ test("coherence: following the returned cursors reconstructs the original text e
|
|
|
309
302
|
if (hugeEntry.matches[0]?.truncated !== true)
|
|
310
303
|
failures.push("huge-many: the kept match is not flagged as a prefix");
|
|
311
304
|
}
|
|
312
|
-
// --- notes_search addresses:
|
|
313
|
-
//
|
|
314
|
-
// history's match_offset_chars two-stage.
|
|
305
|
+
// --- notes_search addresses: each match offset directly starts notes_read at the earliest
|
|
306
|
+
// query occurrence in its line, including multi-query OR.
|
|
315
307
|
const addressLine1 = "pad ".repeat(50);
|
|
316
308
|
const addressLine3 = `${"历".repeat(20)}needle-address here`;
|
|
317
309
|
const addressLine4 = "zeta 历 needle-address";
|
|
318
310
|
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
311
|
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)}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
+
}
|
|
329
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);
|
|
330
|
-
report.push(`notes_search OR address: offset_chars=${String(orLine4?.offset_chars)}
|
|
331
|
-
if (orLine4
|
|
332
|
-
failures.push(
|
|
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
|
+
}
|
|
333
329
|
// --- Negative offsets on both stores: a tail read reaches the end in one call, the response
|
|
334
330
|
// echoes the resolved absolute offset, N >= total_chars reads from the start, and the cursor
|
|
335
331
|
// law still holds when a negative-start page is cut short.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, symlinkSync, readdirSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { doctor } from "../src/dream/doctor.js";
|
|
7
|
+
import { main } from "../src/dream/cli.js";
|
|
8
|
+
const note = (body = "") => `---\norigin: self\nstatus: active\nstale: false\ncreated_at: 2026-01-01T00:00:00Z\nupdated_at: 2026-01-01T00:00:00Z\nlast_accessed: 2026-01-01T00:00:00Z\naccess_count: 0\n---\n\n${body}`;
|
|
9
|
+
const home = () => mkdtempSync(join(tmpdir(), "dream-doctor-"));
|
|
10
|
+
test("doctor validates note homes and references without changing files", async () => {
|
|
11
|
+
const root = home();
|
|
12
|
+
mkdirSync(join(root, "personal"));
|
|
13
|
+
writeFileSync(join(root, "personal/a.md"), note());
|
|
14
|
+
writeFileSync(join(root, "personal/MAP.md"), note("- `a.md`\n- `@personal/a.md`"));
|
|
15
|
+
const before = readFileSync(join(root, "personal/a.md"));
|
|
16
|
+
assert.deepEqual(doctor(root), []);
|
|
17
|
+
assert.equal(await main(["doctor", "--notes-home", root], { runDreamer: async () => { throw new Error("must not run"); } }), 0);
|
|
18
|
+
assert.deepEqual(readFileSync(join(root, "personal/a.md")), before);
|
|
19
|
+
assert.deepEqual(readdirSync(root), ["personal"]);
|
|
20
|
+
});
|
|
21
|
+
test("doctor reports layout, metadata, links and locks; never repairs", () => {
|
|
22
|
+
const root = home();
|
|
23
|
+
mkdirSync(join(root, "global"));
|
|
24
|
+
mkdirSync(join(root, "project/bad"), { recursive: true });
|
|
25
|
+
writeFileSync(join(root, "project/bad/MAP.md"), note("`missing.md` `@global/old.md`"));
|
|
26
|
+
writeFileSync(join(root, "project/bad/broken.md"), "---\norigin: nope\n---\n");
|
|
27
|
+
writeFileSync(join(root, ".dream.lock"), "garbage");
|
|
28
|
+
symlinkSync(join(root, "project"), join(root, "project/bad/link"));
|
|
29
|
+
const output = doctor(root).join("\n");
|
|
30
|
+
for (const expected of ["legacy home", "invalid project key", "target missing", "invalid address", "invalid origin", "invalid created_at", "malformed lock", "symlink"])
|
|
31
|
+
assert.ok(output.includes(expected), expected);
|
|
32
|
+
assert.equal(readFileSync(join(root, ".dream.lock"), "utf8"), "garbage");
|
|
33
|
+
assert.ok(existsSync(join(root, "global")));
|
|
34
|
+
});
|
|
35
|
+
test("doctor reports missing homes without creating them, including CLI", async () => {
|
|
36
|
+
const missing = join(home(), "absent");
|
|
37
|
+
assert.equal(await main(["doctor", "--notes-home", missing]), 1);
|
|
38
|
+
assert.equal(existsSync(missing), false);
|
|
39
|
+
});
|
|
40
|
+
test("doctor reports valid lock presence without claiming it is stale", () => {
|
|
41
|
+
const root = home();
|
|
42
|
+
writeFileSync(join(root, ".dream.lock"), "123 12345678-1234-1234-1234-123456789abc");
|
|
43
|
+
assert.match(doctor(root).join("\n"), /lock present.*liveness not inferred/);
|
|
44
|
+
});
|
|
@@ -152,26 +152,19 @@ export function assertWithinBudget(result, message) {
|
|
|
152
152
|
const bytes = text && text.type === "text" ? Buffer.byteLength(text.text, "utf8") : 0;
|
|
153
153
|
assert.ok(bytes <= TOOL_OUTPUT_MAX_BYTES, `${message}: ${bytes} bytes over the ${TOOL_OUTPUT_MAX_BYTES}-byte budget`);
|
|
154
154
|
}
|
|
155
|
-
/**
|
|
156
|
-
* Decode a raw read (notes_read / history_read): a one-line bracketed header, then
|
|
157
|
-
* the payload verbatim (which may itself contain newlines), so split on the first newline only.
|
|
158
|
-
*/
|
|
155
|
+
/** Decode either raw read without including its shared metadata block in the payload. */
|
|
159
156
|
export function resultRead(result) {
|
|
160
157
|
const text = result.content[0];
|
|
161
158
|
assert.ok(text && text.type === "text", "read result carries text");
|
|
162
|
-
const
|
|
163
|
-
assert.ok(
|
|
164
|
-
const header =
|
|
165
|
-
const content = text.text.slice(
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const end = Number(match[2]);
|
|
172
|
-
const total_chars = Number(match[3]);
|
|
173
|
-
const next_offset_chars = match[4] === "end" ? null : Number(match[5]);
|
|
174
|
-
assert.equal(Array.from(content).length, end - offset_chars, "the header range matches the delivered payload");
|
|
159
|
+
const block = /^(--- READ WINDOW ---\n(?:[a-z_]+: [^\n]*\n)+chars: \[(\d+),(\d+)\) of (\d+)\nnext_offset_chars: (null|\d+)\n)\n/.exec(text.text);
|
|
160
|
+
assert.ok(block, "raw read carries one READ WINDOW block followed by exactly one blank line");
|
|
161
|
+
const header = block[1];
|
|
162
|
+
const content = text.text.slice(block[0].length);
|
|
163
|
+
const offset_chars = Number(block[2]);
|
|
164
|
+
const end = Number(block[3]);
|
|
165
|
+
const total_chars = Number(block[4]);
|
|
166
|
+
const next_offset_chars = block[5] === "null" ? null : Number(block[5]);
|
|
167
|
+
assert.equal(Array.from(content).length, end - offset_chars, "READ WINDOW range matches the delivered payload");
|
|
175
168
|
return { header, content, offset_chars, total_chars, next_offset_chars, details: (result.details ?? {}) };
|
|
176
169
|
}
|
|
177
170
|
/**
|
|
@@ -328,7 +321,7 @@ test("notes_list is most-recently-updated first across merged scopes", async ()
|
|
|
328
321
|
assert.deepEqual((await files({})).map((file) => file.address), ["@personal/e.md", "a.md", "b.md", "@project/c.md"], "updated_at descending with address ascending as the tiebreak");
|
|
329
322
|
// A same-path pair in two scopes keeps both rows; equal timestamps tie-break by scope name.
|
|
330
323
|
put("personal", "a.md", base + 10);
|
|
331
|
-
assert.deepEqual((await files({})).filter((file) => file.address.endsWith("a.md")).map((file) => file.
|
|
324
|
+
assert.deepEqual((await files({})).filter((file) => file.address.endsWith("a.md")).map((file) => file.address), ["@personal/a.md", "a.md"], "equal timestamps tie-break by full address");
|
|
332
325
|
assert.deepEqual((await files({ pattern: "*.md" })).map((file) => file.address), ["a.md", "b.md"], "a bare pattern narrows to the session home");
|
|
333
326
|
});
|
|
334
327
|
test("notes are real files that persist across sessions and round-trip Unicode", async () => {
|
|
@@ -345,7 +338,6 @@ test("notes are real files that persist across sessions and round-trip Unicode",
|
|
|
345
338
|
const read = resultRead(rawRead);
|
|
346
339
|
assert.equal(read.details.address, "@personal/checkpoint/进度.md");
|
|
347
340
|
assert.equal(read.content, "Café", "a negative offset reads the body tail in one call");
|
|
348
|
-
assert.equal(read.details.scope, "personal");
|
|
349
341
|
const searched = resultJson(await call(restoredCaptured, "notes_search", { query: "Café", scope: "personal" }, restoredCtx));
|
|
350
342
|
assert.equal(searched.files[0]?.matches[0]?.line, 2);
|
|
351
343
|
const listedFiles = resultJson(await call(restoredCaptured, "notes_list", { pattern: "checkpoint/**", scope: "personal" }, restoredCtx));
|
|
@@ -366,11 +358,11 @@ test("stale lifecycle: writes and metadata-only edits close and revive a note",
|
|
|
366
358
|
// metadata-only: content unchanged, flag set, applied 0
|
|
367
359
|
const markOnly = resultJson(await call(captured, "notes_edit", { path: "journal.md", stale: true }, ctx));
|
|
368
360
|
assert.equal(markOnly.applied, 0);
|
|
369
|
-
assert.equal(
|
|
361
|
+
assert.equal(listNotes(ctx, { scope: "session" })[0]?.meta.stale, true);
|
|
370
362
|
assert.equal(resultRead(await call(captured, "notes_read", { path: "journal.md" }, ctx)).content.endsWith("log line"), true, "mark-only leaves content unchanged");
|
|
371
363
|
// explicit revive
|
|
372
364
|
const revived = resultJson(await call(captured, "notes_edit", { path: "journal.md", stale: false }, ctx));
|
|
373
|
-
assert.equal(
|
|
365
|
+
assert.equal(listNotes(ctx, { scope: "session" })[0]?.meta.stale, false, "stale:false revives");
|
|
374
366
|
// write+stale closure then plain write revival
|
|
375
367
|
await call(captured, "notes_write", { path: "journal.md", content: "final", stale: true }, ctx);
|
|
376
368
|
assert.equal(listNotes(ctx, { scope: "session" })[0]?.meta.stale, true);
|
|
@@ -705,8 +697,8 @@ test("an over-budget note is delivered as a prefix and resumed by next_offset_ch
|
|
|
705
697
|
assert.ok(first.content.length > 0, "the page is not empty");
|
|
706
698
|
assert.equal(first.content.includes("…"), false, "the payload is a plain prefix with no marker");
|
|
707
699
|
assert.ok(first.content.startsWith("---\n"), "the frontmatter is delivered first");
|
|
708
|
-
assert.equal(first.header,
|
|
709
|
-
assert.deepEqual(Object.keys(first.details).sort(), ["address", "
|
|
700
|
+
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");
|
|
701
|
+
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");
|
|
710
702
|
assert.equal("content" in first.details, false, "details never duplicates the payload");
|
|
711
703
|
assert.equal(first.offset_chars, 0, "the default window starts at the resolved offset 0");
|
|
712
704
|
// Following the cursor reconstructs frontmatter + body by plain concatenation.
|
|
@@ -753,11 +745,11 @@ test("an over-budget note search match is a named prefix with an honest line add
|
|
|
753
745
|
assert.equal(oversized.matches.length, 1);
|
|
754
746
|
const match = oversized.matches[0];
|
|
755
747
|
assert.equal(match.truncated, true, "the oversized match line is flagged as truncated");
|
|
756
|
-
assert.equal(match.total_chars, Array.from(hugeLine).length, "total_chars names the full line length");
|
|
757
748
|
assert.ok(hugeLine.startsWith(match.text), "the match text is a plain prefix of the line");
|
|
758
749
|
assert.equal(match.text.includes("…"), false, "no marker is appended to the match text");
|
|
759
|
-
assert.equal(match.offset_chars, 500, "the match carries the body-absolute offset of the query");
|
|
760
750
|
assert.equal(match.line, 1, "the informational line number survives");
|
|
751
|
+
const atMatch = resultRead(await call(captured, "notes_read", { path: "search.md", offset_chars: match.offset_chars }, ctx));
|
|
752
|
+
assert.ok(atMatch.content.startsWith("needle"), "the search offset starts a read at the matched substring");
|
|
761
753
|
// The body is reconstructible by following notes_read's cursor from the start of the file.
|
|
762
754
|
const parts = [];
|
|
763
755
|
let offset = 0;
|
|
@@ -784,7 +776,9 @@ test("history_read delivers a prefix and next_offset_chars names the delivered c
|
|
|
784
776
|
assert.equal(read.content.includes("…"), false, "no marker is appended to the payload");
|
|
785
777
|
assert.ok(original.startsWith(read.content), "the delivered text is a prefix of the item");
|
|
786
778
|
assert.equal(read.total_chars, original.length);
|
|
787
|
-
assert.
|
|
779
|
+
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");
|
|
780
|
+
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");
|
|
781
|
+
assert.equal("limit_chars" in read.details, false, "history_read details omits the request cap");
|
|
788
782
|
assert.equal("content" in read.details, false, "details never duplicates the payload");
|
|
789
783
|
assert.equal(read.next_offset_chars, read.offset_chars + Array.from(read.content).length, "the cursor is offset plus delivered code points");
|
|
790
784
|
assert.ok(read.next_offset_chars !== null && read.next_offset_chars < read.total_chars, "the cursor points at the first undelivered character");
|
|
@@ -794,6 +788,7 @@ test("history_read delivers a prefix and next_offset_chars names the delivered c
|
|
|
794
788
|
let next = offset;
|
|
795
789
|
while (next !== null) {
|
|
796
790
|
const page = resultRead(await call(captured, "history_read", { window_id: historyFromSession(ctx)[0].windowId, item_id: id, offset_chars: offset, limit_chars: 50000 }, ctx));
|
|
791
|
+
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");
|
|
797
792
|
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");
|
|
798
793
|
parts.push(page.content);
|
|
799
794
|
next = page.next_offset_chars;
|
|
@@ -813,6 +808,7 @@ test("an empty body is a frontmatter-only file that terminates cleanly", async (
|
|
|
813
808
|
assert.ok(empty.content.endsWith("---\n\n"), "an empty body leaves frontmatter and the blank separator only");
|
|
814
809
|
assert.ok(empty.total_chars > 0, "the file is not zero-length once the harness frontmatter is written");
|
|
815
810
|
assert.equal(empty.next_offset_chars, null, "a note that fits terminates instead of self-feeding");
|
|
811
|
+
assert.equal(empty.header, `--- READ WINDOW ---\naddress: empty.md\nchars: [0,${empty.total_chars}) of ${empty.total_chars}\nnext_offset_chars: null\n`, "an exhausted window writes literal null");
|
|
816
812
|
// An offset beyond the file is an addressing error that names the real length,
|
|
817
813
|
// not a silent empty page.
|
|
818
814
|
const beyond = resultJson(await call(captured, "notes_read", { path: "empty.md", offset_chars: empty.total_chars + 9 }, ctx));
|
|
@@ -1727,13 +1723,16 @@ test("argument footguns die loudly and tool-run metadata surfaces (A1/A2/A3/B4/B
|
|
|
1727
1723
|
const noteEnd = resultRead(await call(captured, "notes_read", { path: "a.md", offset_chars: noteTotal }, ctx));
|
|
1728
1724
|
assert.equal(noteEnd.content, "", "notes: offset == total is the legal empty end-read");
|
|
1729
1725
|
assert.equal(noteEnd.next_offset_chars, null, "notes: the end-read terminates");
|
|
1726
|
+
assert.equal(noteEnd.header, `--- READ WINDOW ---\naddress: a.md\nchars: [${noteTotal},${noteTotal}) of ${noteTotal}\nnext_offset_chars: null\n`, "notes: empty end-read retains the exact READ WINDOW block");
|
|
1727
|
+
const itemFull = resultRead(await call(captured, "history_read", { window_id: windowId, item_id: target.item_id }, ctx));
|
|
1728
|
+
assert.equal(itemFull.header, `--- READ WINDOW ---\nwindow_id: ${windowId}\nitem_id: ${target.item_id}\nchars: [0,11) of 11\nnext_offset_chars: null\n`, "history: a fitting read retains the exact shared READ WINDOW block");
|
|
1730
1729
|
const itemPastEnd = resultJson(await call(captured, "history_read", { window_id: windowId, item_id: target.item_id, offset_chars: 12 }, ctx));
|
|
1731
1730
|
assert.match(itemPastEnd.error ?? "", /past the end/, "history: past-end offset is a named error");
|
|
1732
1731
|
assert.equal(itemPastEnd.total_chars, 11, "history: the error names the real length");
|
|
1733
1732
|
assert.equal(itemPastEnd.item_id, target.item_id, "history: the error echoes the item_id");
|
|
1734
1733
|
const itemEnd = resultRead(await call(captured, "history_read", { window_id: windowId, item_id: target.item_id, offset_chars: 11 }, ctx));
|
|
1735
1734
|
assert.equal(itemEnd.content, "", "history: offset == total is the legal empty end-read");
|
|
1736
|
-
assert.equal(itemEnd.
|
|
1735
|
+
assert.equal(itemEnd.header, `--- READ WINDOW ---\nwindow_id: ${windowId}\nitem_id: ${target.item_id}\nchars: [11,11) of 11\nnext_offset_chars: null\n`, "history: empty end-read retains the exact READ WINDOW block");
|
|
1737
1736
|
// A3: editing a missing note is the typed not-found arm; write still creates it.
|
|
1738
1737
|
const editMissing = resultJson(await call(captured, "notes_edit", { path: "missing.md", stale: true }, ctx));
|
|
1739
1738
|
assert.equal(editMissing.error, "note not found", "an edit of a missing note is named");
|