@astrosheep/pi-context 0.22.1 → 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 +1 -1
- 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/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/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
|
@@ -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");
|
package/dist/test/notes.test.js
CHANGED
|
@@ -26,6 +26,20 @@ function setUpdatedAt(scope, path, ctx, timestamp) {
|
|
|
26
26
|
const raw = readFileSync(file, "utf8");
|
|
27
27
|
writeFileSync(file, raw.replace(/^updated_at: .*$/m, `updated_at: ${new Date(timestamp).toISOString()}`));
|
|
28
28
|
}
|
|
29
|
+
function assertNoPublicScope(value, label) {
|
|
30
|
+
if (Array.isArray(value)) {
|
|
31
|
+
for (const item of value)
|
|
32
|
+
assertNoPublicScope(item, label);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (!value || typeof value !== "object")
|
|
36
|
+
return;
|
|
37
|
+
for (const [key, child] of Object.entries(value)) {
|
|
38
|
+
assert.notEqual(key, "scope", `${label} does not expose scope`);
|
|
39
|
+
assert.notEqual(key, "resolved_scope", `${label} does not expose resolved_scope`);
|
|
40
|
+
assertNoPublicScope(child, label);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
29
43
|
test("exactly the five notes tools are registered; the legacy five are gone", () => {
|
|
30
44
|
const captured = makeExtension(manager());
|
|
31
45
|
for (const name of ["notes_write", "notes_edit", "notes_read", "notes_list", "notes_search"]) {
|
|
@@ -34,6 +48,10 @@ test("exactly the five notes tools are registered; the legacy five are gone", ()
|
|
|
34
48
|
for (const legacy of ["notes_write_file", "notes_append_to_file", "notes_read_file", "notes_search_contents", "notes_list_files"]) {
|
|
35
49
|
assert.equal(captured.tools.get(legacy), undefined, `${legacy} is unregistered`);
|
|
36
50
|
}
|
|
51
|
+
for (const name of ["notes_write", "notes_edit", "notes_read", "notes_list", "notes_search"]) {
|
|
52
|
+
const description = captured.tools.get(name)?.description ?? "";
|
|
53
|
+
assert.equal(/resolved_scope|\bscope\b/.test(description), false, `${name} describes home identity through address only`);
|
|
54
|
+
}
|
|
37
55
|
assert.equal(captured.tools.get("notes_write")?.executionMode, "sequential");
|
|
38
56
|
assert.equal(captured.tools.get("notes_edit")?.executionMode, "sequential");
|
|
39
57
|
assert.equal(captured.tools.get("notes_read")?.executionMode, undefined);
|
|
@@ -45,8 +63,7 @@ test("write lands a real markdown file with harness frontmatter and a pure body"
|
|
|
45
63
|
const ctx = context(session);
|
|
46
64
|
const sessionId = session.getSessionId();
|
|
47
65
|
const result = resultJson(await call(captured, "notes_write", { path: "a/b.md", content: "hello" }, ctx));
|
|
48
|
-
assert.
|
|
49
|
-
assert.equal(result.size_bytes, 5);
|
|
66
|
+
assert.deepEqual(Object.keys(result).sort(), ["address", "written"]);
|
|
50
67
|
const file = physicalPath("session", "a/b.md", ctx);
|
|
51
68
|
assert.equal(file, join(root, "pi", "session", sessionId, "a", "b.md"));
|
|
52
69
|
assert.ok(existsSync(file), "the note is a real file under the session scope dir");
|
|
@@ -60,7 +77,8 @@ test("write lands a real markdown file with harness frontmatter and a pure body"
|
|
|
60
77
|
for (const key of ["created_at", "updated_at", "last_accessed"]) {
|
|
61
78
|
assert.match(raw, new RegExp(`^${key}: \\d{4}-\\d{2}-\\d{2}T`, "m"), `frontmatter renders ${key} via localIso`);
|
|
62
79
|
}
|
|
63
|
-
assert.equal(
|
|
80
|
+
assert.equal(result.address, "a/b.md");
|
|
81
|
+
assert.equal(result.written, true);
|
|
64
82
|
// A leading YAML block in user content is stripped from the body.
|
|
65
83
|
await call(captured, "notes_write", { path: "stripped.md", content: "---\nscope: personal\nnonsense: true\n---\nreal body" }, ctx);
|
|
66
84
|
const stripped = readFileSync(physicalPath("session", "stripped.md", ctx), "utf8");
|
|
@@ -83,7 +101,7 @@ test("overwrite preserves created_at and unknown keys, bumps updated_at, and cle
|
|
|
83
101
|
assert.equal(after.includes("sleep_shift_key: keep-me"), true, "an unknown key survives a rewrite");
|
|
84
102
|
assert.equal(after.includes("recurrence_count: 4"), true, "a known sleep-shift key survives a rewrite");
|
|
85
103
|
assert.match(after, /\n---\n\nsecond$/, "the body is replaced");
|
|
86
|
-
assert.
|
|
104
|
+
assert.deepEqual(Object.keys(rewrite).sort(), ["address", "written"]);
|
|
87
105
|
const listed = listNotes(ctx, { scope: "session" })[0];
|
|
88
106
|
assert.equal(listed.meta.created_at, created, "created_at is preserved across an overwrite");
|
|
89
107
|
assert.ok(listed.meta.updated_at >= created, "updated_at is bumped");
|
|
@@ -111,7 +129,8 @@ test("edit is body-scoped with named failures and a replace_all escape hatch", a
|
|
|
111
129
|
assert.equal(missing.edit_index, 0, "a zero-match anchor names the failing edit index");
|
|
112
130
|
const all = resultJson(await call(captured, "notes_edit", { path: "edit.md", edits: [{ oldText: "beta", newText: "B" }], replace_all: true }, ctx));
|
|
113
131
|
assert.equal(all.applied, 1);
|
|
114
|
-
assert.equal(all.
|
|
132
|
+
assert.equal(all.address, "edit.md");
|
|
133
|
+
assertNoPublicScope(all, "notes_edit");
|
|
115
134
|
assert.equal(resultRead(await call(captured, "notes_read", { path: "edit.md" }, ctx)).content.endsWith("alpha\nB\nB\ngamma"), true, "replace_all replaces every occurrence");
|
|
116
135
|
// An anchor that occurs only in frontmatter is not matched: edits are body-only.
|
|
117
136
|
const frontmatterOnly = resultJson(await call(captured, "notes_edit", { path: "edit.md", edits: [{ oldText: "scope", newText: "x" }] }, ctx));
|
|
@@ -149,11 +168,13 @@ test("metadata-only edit updates setters without touching the body", async () =>
|
|
|
149
168
|
await call(captured, "notes_write", { path: "journal.md", content: "log line" }, ctx);
|
|
150
169
|
const bare = resultJson(await call(captured, "notes_edit", { path: "journal.md", stale: true }, ctx));
|
|
151
170
|
assert.equal(bare.applied, 0, "a metadata-only update applies no edits");
|
|
152
|
-
assert.equal(bare.
|
|
153
|
-
assert.
|
|
171
|
+
assert.equal(bare.address, "journal.md");
|
|
172
|
+
assert.deepEqual(Object.keys(bare).sort(), ["address", "applied", "diff"]);
|
|
173
|
+
assert.equal(listNotes(ctx, { scope: "session" })[0].meta.stale, true, "stale is set without a body edit");
|
|
154
174
|
assert.equal(resultRead(await call(captured, "notes_read", { path: "journal.md" }, ctx)).content.endsWith("log line"), true, "the body is untouched");
|
|
155
175
|
const revived = resultJson(await call(captured, "notes_edit", { path: "journal.md", stale: false }, ctx));
|
|
156
|
-
assert.
|
|
176
|
+
assert.deepEqual(Object.keys(revived).sort(), ["address", "applied", "diff"]);
|
|
177
|
+
assert.equal(listNotes(ctx, { scope: "session" })[0].meta.stale, false, "a later metadata-only update revives the note");
|
|
157
178
|
});
|
|
158
179
|
test("notes_edit returns a pi-edit-style diff of what changed", async () => {
|
|
159
180
|
freshRoot();
|
|
@@ -234,7 +255,39 @@ test.skip("scope resolution and movement are superseded by explicit address test
|
|
|
234
255
|
assert.match(refusal.error, /already exists/);
|
|
235
256
|
assert.equal(readFileSync(physicalPath("personal", "clash.md", ctx), "utf8"), beforePersonal, "the target survives a refused move");
|
|
236
257
|
});
|
|
237
|
-
test("
|
|
258
|
+
test("all notes tool results use address as the only home identity", async () => {
|
|
259
|
+
freshRoot();
|
|
260
|
+
const session = manager();
|
|
261
|
+
const captured = makeExtension(session);
|
|
262
|
+
const ctx = context(session);
|
|
263
|
+
const notes = [
|
|
264
|
+
{ address: "session.md", body: "session needle" },
|
|
265
|
+
{ address: "@project/project.md", body: "project needle" },
|
|
266
|
+
{ address: "@personal/personal.md", body: "personal needle" },
|
|
267
|
+
];
|
|
268
|
+
for (const note of notes) {
|
|
269
|
+
const written = resultJson(await call(captured, "notes_write", { address: note.address, content: note.body }, ctx));
|
|
270
|
+
assert.equal(written.address, note.address, `notes_write returns ${note.address}`);
|
|
271
|
+
assertNoPublicScope(written, `notes_write ${note.address}`);
|
|
272
|
+
const edited = resultJson(await call(captured, "notes_edit", { address: note.address, edits: [{ oldText: "needle", newText: "match" }] }, ctx));
|
|
273
|
+
assert.equal(edited.address, note.address, `notes_edit returns ${note.address}`);
|
|
274
|
+
assertNoPublicScope(edited, `notes_edit ${note.address}`);
|
|
275
|
+
const rawRead = await call(captured, "notes_read", { address: note.address }, ctx);
|
|
276
|
+
const read = resultRead(rawRead);
|
|
277
|
+
assert.equal(read.details.address, note.address, `notes_read details returns ${note.address}`);
|
|
278
|
+
assert.equal(read.header.startsWith("--- READ WINDOW ---\naddress: "), true, `notes_read starts a READ WINDOW block for ${note.address}`);
|
|
279
|
+
assert.equal(read.header.includes("scope"), false, `notes_read header omits scope for ${note.address}`);
|
|
280
|
+
assert.equal(read.header.includes("resolved_scope"), false, `notes_read header omits resolved_scope for ${note.address}`);
|
|
281
|
+
assertNoPublicScope(read.details, `notes_read ${note.address}`);
|
|
282
|
+
}
|
|
283
|
+
const listed = resultJson(await call(captured, "notes_list", { pattern: "**" }, ctx));
|
|
284
|
+
assert.deepEqual(listed.files.map((file) => file.address).sort(), notes.map((note) => note.address).sort(), "notes_list returns each full address");
|
|
285
|
+
assertNoPublicScope(listed, "notes_list");
|
|
286
|
+
const searched = resultJson(await call(captured, "notes_search", { query: "match", pattern: "**" }, ctx));
|
|
287
|
+
assert.deepEqual(searched.files.map((file) => file.address), notes.map((note) => note.address).sort(), "notes_search returns each full address");
|
|
288
|
+
assertNoPublicScope(searched, "notes_search");
|
|
289
|
+
});
|
|
290
|
+
test("list and search merge scopes and carry addresses; the path jail rejects escapes", async () => {
|
|
238
291
|
freshRoot();
|
|
239
292
|
const session = manager();
|
|
240
293
|
const captured = makeExtension(session);
|
|
@@ -243,24 +296,24 @@ test("list and search merge scopes and carry scope; the path jail rejects escape
|
|
|
243
296
|
await call(captured, "notes_write", { path: "two.md", content: "needle two", scope: "project" }, ctx);
|
|
244
297
|
await call(captured, "notes_write", { path: "three.md", content: "needle three", scope: "personal" }, ctx);
|
|
245
298
|
const listed = resultJson(await call(captured, "notes_list", {}, ctx));
|
|
246
|
-
assert.deepEqual([...listed.files].map((file) => file.
|
|
299
|
+
assert.deepEqual([...listed.files].map((file) => file.address).sort(), ["@personal/three.md", "@project/two.md", "one.md"], "every merged row carries its full address");
|
|
247
300
|
for (const row of listed.files) {
|
|
248
|
-
assert.
|
|
249
|
-
assert.equal(row.origin, "self");
|
|
250
|
-
assert.equal(row.status, "active");
|
|
301
|
+
assert.deepEqual(Object.keys(row).sort(), ["address", "stale", "updated_at"]);
|
|
251
302
|
assert.equal(row.stale, false);
|
|
252
303
|
}
|
|
253
304
|
const scoped = resultJson(await call(captured, "notes_list", { scope: "personal" }, ctx));
|
|
254
|
-
assert.deepEqual(scoped.files.map((file) => file.
|
|
305
|
+
assert.deepEqual(scoped.files.map((file) => file.address), ["@personal/three.md"], "an address-pattern filter narrows the set");
|
|
255
306
|
const searched = resultJson(await call(captured, "notes_search", { query: "needle" }, ctx));
|
|
256
307
|
assert.equal(searched.files.length, 3, "literal search finds matches in every scope");
|
|
257
|
-
assert.deepEqual([...searched.files].map((file) => file.
|
|
308
|
+
assert.deepEqual([...searched.files].map((file) => file.address).sort(), ["@personal/three.md", "@project/two.md", "one.md"]);
|
|
309
|
+
for (const row of [...listed.files, ...searched.files])
|
|
310
|
+
assertNoPublicScope(row, "notes_list/search");
|
|
258
311
|
assert.equal(searched.files.every((file) => file.matches_total === 1), true);
|
|
259
312
|
const hit = searched.files[0].matches[0];
|
|
260
313
|
assert.equal(hit.line, 1);
|
|
261
|
-
assert.
|
|
314
|
+
assert.ok(hit.offset_chars > 0, "the offset includes serialized frontmatter");
|
|
262
315
|
assert.equal(hit.truncated, false);
|
|
263
|
-
assert.
|
|
316
|
+
assert.deepEqual(Object.keys(hit).sort(), ["line", "offset_chars", "text", "truncated"]);
|
|
264
317
|
const escaped = ["../evil", "/abs", "a\\b"];
|
|
265
318
|
for (const tool of ["notes_write", "notes_edit", "notes_read"]) {
|
|
266
319
|
for (const path of escaped) {
|
|
@@ -291,6 +344,38 @@ test("the boot index reads the physical store across scopes and excludes stale n
|
|
|
291
344
|
assert.equal(PROTOCOL_BLOCK.includes(legacy), false, `the protocol block no longer names ${legacy}`);
|
|
292
345
|
}
|
|
293
346
|
});
|
|
347
|
+
test("search offsets start reads at Unicode matches across homes without mutating search results", async () => {
|
|
348
|
+
freshRoot();
|
|
349
|
+
const session = manager();
|
|
350
|
+
const captured = makeExtension(session);
|
|
351
|
+
const ctx = context(session);
|
|
352
|
+
const notes = [
|
|
353
|
+
{ address: "session.md", scope: "session", body: "first line\n前置 🐉 needle-session\nend", needle: "needle-session" },
|
|
354
|
+
{ address: "@project/crossing.md", scope: "project", body: "prefix\nneedle-project 😀", needle: "needle-project" },
|
|
355
|
+
{ address: "@personal/legacy.md", scope: "personal", body: "legacy 😺 needle-personal", needle: "needle-personal" },
|
|
356
|
+
];
|
|
357
|
+
for (const note of notes)
|
|
358
|
+
await call(captured, "notes_write", { address: note.address, content: note.body }, ctx);
|
|
359
|
+
const crossing = physicalPath("project", "crossing.md", ctx);
|
|
360
|
+
writeFileSync(crossing, readFileSync(crossing, "utf8").replace(/^access_count: 0$/m, "access_count: 9"));
|
|
361
|
+
const legacy = physicalPath("personal", "legacy.md", ctx);
|
|
362
|
+
writeFileSync(legacy, notes[2].body);
|
|
363
|
+
const before = new Map(notes.map((note) => [note.address, readFileSync(physicalPath(note.scope, note.address.replace(/^@(?:project|personal)\//, ""), ctx), "utf8")]));
|
|
364
|
+
const searched = resultJson(await call(captured, "notes_search", { query: notes.map((note) => note.needle), pattern: "**" }, ctx));
|
|
365
|
+
for (const note of notes) {
|
|
366
|
+
assert.equal(readFileSync(physicalPath(note.scope, note.address.replace(/^@(?:project|personal)\//, ""), ctx), "utf8"), before.get(note.address), `search leaves ${note.address} byte-identical`);
|
|
367
|
+
const file = searched.files.find((candidate) => candidate.address === note.address);
|
|
368
|
+
assert.ok(file, `search returns ${note.address}`);
|
|
369
|
+
assert.deepEqual(Object.keys(file).sort(), ["address", "matches", "matches_total", "stale", "updated_at"]);
|
|
370
|
+
const hit = file.matches[0];
|
|
371
|
+
assert.deepEqual(Object.keys(hit).sort(), ["line", "offset_chars", "text", "truncated"]);
|
|
372
|
+
const read = resultRead(await call(captured, "notes_read", { address: note.address, offset_chars: hit.offset_chars }, ctx));
|
|
373
|
+
assert.ok(read.content.startsWith(note.needle), `search offset starts notes_read at ${note.needle}`);
|
|
374
|
+
assert.deepEqual(Object.keys(read.details).sort(), ["address", "next_offset_chars", "offset_chars", "total_chars"]);
|
|
375
|
+
}
|
|
376
|
+
assert.match(readFileSync(crossing, "utf8"), /^access_count: 10$/m, "the predicted read crosses access_count from 9 to 10");
|
|
377
|
+
assert.match(resultRead(await call(captured, "notes_read", { address: "@personal/legacy.md" }, ctx)).content, /^---\n/, "a missing-frontmatter note is normalized only by read");
|
|
378
|
+
});
|
|
294
379
|
test("notes_read surfaces frontmatter and search reports body lines", async () => {
|
|
295
380
|
freshRoot();
|
|
296
381
|
const session = manager();
|
|
@@ -303,7 +388,7 @@ test("notes_read surfaces frontmatter and search reports body lines", async () =
|
|
|
303
388
|
const searched = resultJson(await call(captured, "notes_search", { query: "needle" }, ctx));
|
|
304
389
|
const match = searched.files[0].matches[0];
|
|
305
390
|
assert.equal(match.line, 2, "search reports the body line number");
|
|
306
|
-
assert.equal(match.offset_chars,
|
|
391
|
+
assert.equal(match.offset_chars, read.content.indexOf("needle"), "offset_chars addresses the query in the serialized read stream");
|
|
307
392
|
});
|
|
308
393
|
test("mutations are atomic, leave no temp files, and a read bumps only the access keys", async () => {
|
|
309
394
|
freshRoot();
|
|
@@ -443,11 +528,12 @@ test("full addresses drive outputs and patterns; on-disk scope is read then drop
|
|
|
443
528
|
assert.deepEqual(resultJson(await call(captured, "notes_list", { pattern: "*.md" }, ctx)).files.map((file) => file.address), ["root.md"]);
|
|
444
529
|
assert.deepEqual(resultJson(await call(captured, "notes_search", { query: "needle", pattern: "@project/**" }, ctx)).files.map((file) => file.address), ["@project/project.md"]);
|
|
445
530
|
const read = resultRead(await call(captured, "notes_read", { address: "@personal/personal.md" }, ctx));
|
|
446
|
-
assert.
|
|
531
|
+
assert.equal(read.header, "--- READ WINDOW ---\naddress: @personal/personal.md\nchars: [0," + read.total_chars + ") of " + read.total_chars + "\nnext_offset_chars: null\n", "the raw READ WINDOW block echoes the full address");
|
|
447
532
|
const legacy = physicalPath("project", "legacy.md", ctx);
|
|
448
533
|
writeFileSync(legacy, "---\nscope: personal\norigin: self\nstatus: active\nstale: false\ncreated_at: 2026-01-01T00:00:00.000+00:00\nupdated_at: 2026-01-01T00:00:00.000+00:00\nlast_accessed: 2026-01-01T00:00:00.000+00:00\naccess_count: 0\n---\n\nlegacy");
|
|
449
534
|
const legacyRead = resultRead(await call(captured, "notes_read", { address: "@project/legacy.md" }, ctx));
|
|
450
|
-
assert.equal(legacyRead.details.
|
|
535
|
+
assert.equal(legacyRead.details.address, "@project/legacy.md", "the read keeps the requested address while deriving its home internally");
|
|
536
|
+
assertNoPublicScope(legacyRead.details, "legacy notes_read");
|
|
451
537
|
await call(captured, "notes_edit", { address: "@project/legacy.md", stale: true }, ctx);
|
|
452
538
|
assert.equal(/^scope:/m.test(readFileSync(legacy, "utf8")), false, "the next write removes legacy scope frontmatter");
|
|
453
539
|
});
|
|
@@ -292,21 +292,21 @@ function assertTruncatedIdentity(expectedPath, actual, label) {
|
|
|
292
292
|
assert.ok(headChars.length + tailChars.length < expectedChars.length, `${label}: truncation actually removes characters`);
|
|
293
293
|
}
|
|
294
294
|
/**
|
|
295
|
-
* Map a notes page entry's
|
|
296
|
-
* equal it; a flagged
|
|
297
|
-
* write cap could never have produced. The expected
|
|
295
|
+
* Map a notes page entry's address back to the expected address. A non-truncated address must
|
|
296
|
+
* equal it; a flagged address must be a visible middle-truncation of a legacy address that the
|
|
297
|
+
* write cap could never have produced. The expected address is returned either way so the
|
|
298
298
|
* pagination invariants compare like with like.
|
|
299
299
|
*/
|
|
300
300
|
function notePathIdentity(expectedPaths, cursor, label, page, key) {
|
|
301
301
|
return page[key].map((file, index) => {
|
|
302
302
|
const expectedPath = expectedPaths[cursor + index];
|
|
303
303
|
assert.ok(expectedPath !== undefined, `${label} cursor=${cursor}: page returned more entries than the store holds`);
|
|
304
|
-
if (file.
|
|
305
|
-
assert.ok(Buffer.byteLength(expectedPath, "utf8") > MAX_NOTE_PATH_BYTES, `${label} cursor=${cursor}: only a legacy
|
|
306
|
-
assertTruncatedIdentity(expectedPath, file.
|
|
304
|
+
if (file.address_truncated) {
|
|
305
|
+
assert.ok(Buffer.byteLength(expectedPath, "utf8") > MAX_NOTE_PATH_BYTES, `${label} cursor=${cursor}: only a legacy address beyond the write cap may be truncated, got ${file.address}`);
|
|
306
|
+
assertTruncatedIdentity(expectedPath, file.address, `${label} cursor=${cursor}`);
|
|
307
307
|
}
|
|
308
308
|
else {
|
|
309
|
-
assert.equal(file.
|
|
309
|
+
assert.equal(file.address, expectedPath, `${label} cursor=${cursor}: address is returned intact when its entry fits`);
|
|
310
310
|
}
|
|
311
311
|
return expectedPath;
|
|
312
312
|
});
|
|
@@ -398,10 +398,10 @@ test("notes_list enumerates every note file across seeded mixes", async () => {
|
|
|
398
398
|
const captured = makeExtension(session);
|
|
399
399
|
const ctx = context(session);
|
|
400
400
|
await materializeNotes(plan, captured, ctx);
|
|
401
|
-
const all = new Map(listNotes(ctx, {}).map((row) => [row.
|
|
401
|
+
const all = new Map(listNotes(ctx, {}).map((row) => [row.address, row]));
|
|
402
402
|
for (const variant of plan.list) {
|
|
403
403
|
const params = { pattern: variant.pattern, max_results: variant.maxResults };
|
|
404
|
-
const expected = expectedListRows(ctx, variant).map((row) => row.
|
|
404
|
+
const expected = expectedListRows(ctx, variant).map((row) => row.address);
|
|
405
405
|
const label = `notes_list seed=${seed} ${variant.label} pattern=${JSON.stringify(variant.pattern)} max_results=${variant.maxResults}`;
|
|
406
406
|
const pages = await walkPages({
|
|
407
407
|
captured, ctx, tool: "notes_list", params,
|
|
@@ -413,13 +413,12 @@ test("notes_list enumerates every note file across seeded mixes", async () => {
|
|
|
413
413
|
let flat = 0;
|
|
414
414
|
for (const page of pages) {
|
|
415
415
|
for (const file of page.files) {
|
|
416
|
-
const
|
|
417
|
-
const row = all.get(
|
|
418
|
-
assert.ok(row, `${label}: listed ${
|
|
419
|
-
assert.
|
|
420
|
-
assert.equal(file.stale, row.meta.stale, `${label}: stale for ${
|
|
421
|
-
assert.equal(Date.parse(file.
|
|
422
|
-
assert.equal(Date.parse(file.updated_at), row.meta.updated_at, `${label}: updated_at for ${storePath}`);
|
|
416
|
+
const address = expected[flat++];
|
|
417
|
+
const row = all.get(address);
|
|
418
|
+
assert.ok(row, `${label}: listed ${address} is not in the note store`);
|
|
419
|
+
assert.deepEqual(Object.keys(file).sort(), file.address_truncated ? ["address", "address_truncated", "stale", "updated_at"] : ["address", "stale", "updated_at"]);
|
|
420
|
+
assert.equal(file.stale, row.meta.stale, `${label}: stale for ${address}`);
|
|
421
|
+
assert.equal(Date.parse(file.updated_at), row.meta.updated_at, `${label}: updated_at for ${address}`);
|
|
423
422
|
}
|
|
424
423
|
}
|
|
425
424
|
}
|
|
@@ -435,7 +434,9 @@ test("notes_search enumerates every matching file across seeded mixes", async ()
|
|
|
435
434
|
const bodies = new Map(plan.writes.map((write) => [write.path, write.body]));
|
|
436
435
|
for (const variant of plan.search) {
|
|
437
436
|
const params = { query: variant.query, pattern: variant.pattern, max_files: variant.maxFiles, max_matches_per_file: variant.maxMatchesPerFile };
|
|
438
|
-
const
|
|
437
|
+
const expectedRows = expectedSearchRows(ctx, variant);
|
|
438
|
+
const expected = expectedRows.map((row) => row.address);
|
|
439
|
+
const expectedByAddress = new Map(expectedRows.map((row) => [row.address, row]));
|
|
439
440
|
const label = `notes_search seed=${seed} ${variant.label} query=${JSON.stringify(variant.query)} pattern=${JSON.stringify(variant.pattern)} max_files=${variant.maxFiles} max_matches_per_file=${variant.maxMatchesPerFile}`;
|
|
440
441
|
const pages = await walkPages({
|
|
441
442
|
captured, ctx, tool: "notes_search", params,
|
|
@@ -447,7 +448,8 @@ test("notes_search enumerates every matching file across seeded mixes", async ()
|
|
|
447
448
|
let flat = 0;
|
|
448
449
|
for (const page of pages) {
|
|
449
450
|
for (const file of page.files) {
|
|
450
|
-
const
|
|
451
|
+
const address = expected[flat++];
|
|
452
|
+
const storePath = address;
|
|
451
453
|
const body = bodies.get(storePath);
|
|
452
454
|
assert.ok(body !== undefined, `${label}: reported ${storePath} was never written`);
|
|
453
455
|
const lines = body.split("\n");
|
|
@@ -455,19 +457,12 @@ test("notes_search enumerates every matching file across seeded mixes", async ()
|
|
|
455
457
|
assert.ok(file.matches.length >= 1, `${label}: ${storePath} reports no matches but appears in the result`);
|
|
456
458
|
assert.ok(file.matches.length <= Math.min(matchingLines.length, variant.maxMatchesPerFile), `${label}: ${storePath} reports ${file.matches.length} matches beyond its cap`);
|
|
457
459
|
assert.deepEqual(file.matches.map((match) => match.line), matchingLines.slice(0, file.matches.length), `${label}: ${storePath} match lines are not the first matching lines`);
|
|
458
|
-
const
|
|
459
|
-
|
|
460
|
-
for (const
|
|
461
|
-
lineBase.push(lineOffset);
|
|
462
|
-
lineOffset += Array.from(text).length + 1;
|
|
463
|
-
}
|
|
464
|
-
for (const match of file.matches) {
|
|
460
|
+
const expectedMatches = expectedByAddress.get(address)?.matches;
|
|
461
|
+
assert.ok(expectedMatches, `${label}: ${address} is absent from the store search`);
|
|
462
|
+
for (const [index, match] of file.matches.entries()) {
|
|
465
463
|
const line = lines[match.line - 1];
|
|
466
464
|
assert.ok(line.includes(variant.query), `${label}: ${storePath}:${match.line} does not contain the query`);
|
|
467
|
-
|
|
468
|
-
// earliest occurrence inside it.
|
|
469
|
-
const earliest = line.indexOf(variant.query);
|
|
470
|
-
assert.equal(match.offset_chars, lineBase[match.line - 1] + Array.from(line.slice(0, earliest)).length, `${label}: ${storePath}:${match.line} offset_chars does not address the query`);
|
|
465
|
+
assert.equal(match.offset_chars, expectedMatches[index]?.offsetChars, `${label}: ${storePath}:${match.line} offset_chars does not address the serialized read stream`);
|
|
471
466
|
}
|
|
472
467
|
}
|
|
473
468
|
}
|