@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
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
|
}
|
|
@@ -42,7 +42,10 @@ function harness() {
|
|
|
42
42
|
disable: () => { enabled = false; lifecycle.clear(); },
|
|
43
43
|
enable: () => { enabled = true; },
|
|
44
44
|
before: (reason = "threshold") => emit("session_before_compact", { reason, signal: new AbortController().signal }),
|
|
45
|
-
|
|
45
|
+
// Do not await this result until after the manually driven compact callbacks:
|
|
46
|
+
// real Pi awaits the originating handler while the continuation can emit its
|
|
47
|
+
// own nested agent_settled event.
|
|
48
|
+
settle: () => { emit("agent_end"); idle = true; return emit("agent_settled"); },
|
|
46
49
|
success: (id = "reset", willRetry = false) => {
|
|
47
50
|
currentReset = id;
|
|
48
51
|
emit("session_compact", { compactionEntry: { id }, willRetry });
|
|
@@ -50,150 +53,144 @@ function harness() {
|
|
|
50
53
|
complete: (index = 0) => requests[index].onComplete({}),
|
|
51
54
|
};
|
|
52
55
|
}
|
|
53
|
-
test("
|
|
56
|
+
test("the originating settled handler waits for its continuation's nested settlement", async () => {
|
|
54
57
|
const h = harness();
|
|
55
58
|
assert.equal(h.lifecycle.request(), "rollover_requested");
|
|
56
59
|
assert.equal(h.lifecycle.request(), "rollover_already_pending");
|
|
57
|
-
h.settle();
|
|
58
|
-
h.emit("agent_settled");
|
|
60
|
+
const outer = h.settle();
|
|
59
61
|
assert.equal(h.requests.length, 1);
|
|
60
62
|
h.success();
|
|
61
63
|
h.success();
|
|
62
|
-
assert.deepEqual(h.messages, [], "nothing starts inside session_compact");
|
|
63
64
|
h.complete();
|
|
64
65
|
h.complete();
|
|
65
|
-
h.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
assert.
|
|
66
|
+
assert.deepEqual(h.messages, ["continue"], "one continuation starts after compaction completion");
|
|
67
|
+
let released = false;
|
|
68
|
+
void Promise.resolve(outer).then(() => { released = true; });
|
|
69
|
+
await Promise.resolve();
|
|
70
|
+
assert.equal(released, false, "sending the continuation does not release the original handler");
|
|
71
|
+
await h.settle();
|
|
72
|
+
await outer;
|
|
73
|
+
assert.equal(released, true, "only the continuation's settled event releases its owner");
|
|
74
|
+
assert.equal(h.requests.length, 1, "duplicate compact and settled callbacks do not restart reset work");
|
|
73
75
|
});
|
|
74
|
-
test("a
|
|
76
|
+
test("a reset requested by a continuation completes before its predecessor releases", async () => {
|
|
75
77
|
const h = harness();
|
|
76
|
-
h.emit("session_compact_failed", { reason: "threshold", aborted: true });
|
|
77
78
|
h.lifecycle.request();
|
|
78
|
-
h.settle();
|
|
79
|
-
h.success();
|
|
79
|
+
const first = h.settle();
|
|
80
|
+
h.success("first");
|
|
80
81
|
h.complete();
|
|
81
82
|
assert.deepEqual(h.messages, ["continue"]);
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
h.
|
|
86
|
-
h.
|
|
87
|
-
h.emit("session_compact_failed", { reason: "manual", aborted: false });
|
|
88
|
-
h.requests[0].onError(new Error("Nothing to compact"));
|
|
89
|
-
h.requests[0].onError(new Error("duplicate callback"));
|
|
90
|
-
h.complete();
|
|
91
|
-
h.emit("agent_settled");
|
|
92
|
-
assert.equal(h.requests.length, 1);
|
|
93
|
-
assert.equal(h.notices.length, 1);
|
|
94
|
-
assert.deepEqual(h.messages, []);
|
|
95
|
-
h.setIdle(false);
|
|
96
|
-
assert.ok(h.before().compaction, "the next native attempt resets directly");
|
|
97
|
-
assert.equal(h.lifecycle.request(), "rollover_requested", "explicit retry is possible");
|
|
98
|
-
h.settle();
|
|
99
|
-
assert.equal(h.requests.length, 2);
|
|
100
|
-
h.success();
|
|
83
|
+
// This models new_context being called during the first continuation run.
|
|
84
|
+
assert.equal(h.lifecycle.request(), "rollover_requested");
|
|
85
|
+
const second = h.settle();
|
|
86
|
+
assert.equal(h.requests.length, 2, "the continuation's settled handler starts its requested reset");
|
|
87
|
+
h.success("second");
|
|
101
88
|
h.complete(1);
|
|
102
|
-
assert.deepEqual(h.messages, ["continue"]);
|
|
89
|
+
assert.deepEqual(h.messages, ["continue", "continue"]);
|
|
90
|
+
let firstReleased = false;
|
|
91
|
+
void Promise.resolve(first).then(() => { firstReleased = true; });
|
|
92
|
+
await Promise.resolve();
|
|
93
|
+
assert.equal(firstReleased, false, "the predecessor remains owned while the second continuation runs");
|
|
94
|
+
await h.settle();
|
|
95
|
+
await second;
|
|
96
|
+
await first;
|
|
97
|
+
assert.equal(firstReleased, true);
|
|
98
|
+
assert.equal(h.lifecycle.request(), "rollover_requested", "a later window can request another reset");
|
|
103
99
|
});
|
|
104
|
-
test("
|
|
100
|
+
test("automatic compactions reset on the spot, with no continuation", () => {
|
|
105
101
|
const h = harness();
|
|
106
|
-
h.
|
|
107
|
-
h.
|
|
108
|
-
h.settle();
|
|
109
|
-
h.emit("agent_settled");
|
|
110
|
-
assert.equal(h.notices.length, 1);
|
|
111
|
-
assert.equal(h.lifecycle.request(), "rollover_requested");
|
|
102
|
+
assert.ok(h.before().compaction, "the native attempt becomes our reset immediately");
|
|
103
|
+
assert.deepEqual(h.messages, []);
|
|
112
104
|
});
|
|
113
|
-
test("
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
105
|
+
test("failure, synchronous scheduling errors, and cancellation release their owners without retry", async () => {
|
|
106
|
+
const failed = harness();
|
|
107
|
+
failed.lifecycle.request();
|
|
108
|
+
const outer = failed.settle();
|
|
109
|
+
failed.requests[0].onError(new Error("Nothing to compact"));
|
|
110
|
+
failed.requests[0].onError(new Error("duplicate callback"));
|
|
111
|
+
failed.complete();
|
|
112
|
+
await outer;
|
|
113
|
+
assert.equal(failed.notices.length, 1);
|
|
114
|
+
assert.deepEqual(failed.messages, []);
|
|
115
|
+
assert.equal(failed.lifecycle.request(), "rollover_requested", "a later explicit request is possible");
|
|
116
|
+
const synchronous = harness();
|
|
117
|
+
synchronous.setThrow();
|
|
118
|
+
synchronous.lifecycle.request();
|
|
119
|
+
await synchronous.settle();
|
|
120
|
+
assert.equal(synchronous.notices.length, 1);
|
|
121
|
+
assert.equal(synchronous.lifecycle.request(), "rollover_requested");
|
|
122
|
+
const aborted = harness();
|
|
123
|
+
aborted.lifecycle.request();
|
|
124
|
+
aborted.setSignal(AbortSignal.abort());
|
|
125
|
+
await aborted.settle();
|
|
126
|
+
assert.equal(aborted.requests.length, 0);
|
|
127
|
+
assert.deepEqual(aborted.messages, []);
|
|
120
128
|
});
|
|
121
|
-
test("shutdown,
|
|
122
|
-
for (const boundary of ["session_shutdown", "session_start", "session_tree", "off"]) {
|
|
129
|
+
test("shutdown, tree invalidation, toggling off, and stale sessions release waiters safely", async () => {
|
|
130
|
+
for (const boundary of ["session_shutdown", "session_start", "session_tree", "off", "session-change"]) {
|
|
123
131
|
const h = harness();
|
|
124
132
|
h.lifecycle.request();
|
|
125
|
-
h.settle();
|
|
133
|
+
const outer = h.settle();
|
|
126
134
|
h.success();
|
|
135
|
+
h.complete();
|
|
127
136
|
if (boundary === "off") {
|
|
128
137
|
h.disable();
|
|
129
138
|
h.enable();
|
|
130
139
|
}
|
|
140
|
+
else if (boundary === "session-change") {
|
|
141
|
+
h.setSession("second");
|
|
142
|
+
h.complete();
|
|
143
|
+
h.emit("session_tree");
|
|
144
|
+
}
|
|
131
145
|
else
|
|
132
146
|
h.emit(boundary);
|
|
133
147
|
h.complete();
|
|
134
148
|
h.requests[0].onError(new Error("late error"));
|
|
135
|
-
|
|
149
|
+
await outer;
|
|
136
150
|
assert.deepEqual(h.notices, [], boundary);
|
|
151
|
+
assert.deepEqual(h.messages, ["continue"], boundary);
|
|
137
152
|
if (boundary === "session_shutdown")
|
|
138
153
|
h.emit("session_start");
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
assert.equal(h.
|
|
142
|
-
}
|
|
143
|
-
});
|
|
144
|
-
test("callback identity keeps an earlier failure from cancelling a newer request", () => {
|
|
145
|
-
const h = harness();
|
|
146
|
-
h.lifecycle.request();
|
|
147
|
-
h.settle();
|
|
148
|
-
h.requests[0].onError(new Error("first failure"));
|
|
149
|
-
h.lifecycle.request();
|
|
150
|
-
h.settle();
|
|
151
|
-
h.requests[0].onError(new Error("late first failure"));
|
|
152
|
-
h.success();
|
|
153
|
-
h.complete(1);
|
|
154
|
-
assert.deepEqual(h.messages, ["continue"]);
|
|
155
|
-
assert.equal(h.notices.length, 1);
|
|
156
|
-
});
|
|
157
|
-
test("native compaction satisfies a pending request without duplicating Pi's continuation", () => {
|
|
158
|
-
for (const willRetry of [false, true]) {
|
|
159
|
-
const h = harness();
|
|
160
|
-
h.lifecycle.request();
|
|
161
|
-
h.success("native", willRetry);
|
|
162
|
-
h.settle();
|
|
163
|
-
assert.equal(h.requests.length, 0);
|
|
164
|
-
assert.deepEqual(h.messages, []);
|
|
154
|
+
if (boundary === "session-change")
|
|
155
|
+
h.emit("session_tree");
|
|
156
|
+
assert.equal(h.lifecycle.request(), "rollover_requested", `${boundary}: a fresh request still works`);
|
|
165
157
|
}
|
|
166
158
|
});
|
|
167
|
-
test("
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
159
|
+
test("queued or competing work is not duplicated and releases an unneeded continuation owner", async () => {
|
|
160
|
+
const competing = harness();
|
|
161
|
+
competing.lifecycle.request();
|
|
162
|
+
competing.setIdle(false);
|
|
163
|
+
assert.equal(competing.emit("agent_settled"), undefined, "another run owns the first settled event");
|
|
164
|
+
competing.setIdle(true);
|
|
165
|
+
const outer = competing.settle();
|
|
166
|
+
competing.success();
|
|
167
|
+
competing.setIdle(false);
|
|
168
|
+
competing.complete();
|
|
169
|
+
await outer;
|
|
170
|
+
assert.deepEqual(competing.messages, [], "an active prompt owns continuation");
|
|
178
171
|
const queued = harness();
|
|
179
172
|
queued.lifecycle.request();
|
|
180
|
-
queued.settle();
|
|
173
|
+
const queuedOuter = queued.settle();
|
|
181
174
|
queued.success();
|
|
182
175
|
queued.setPending(true);
|
|
183
176
|
queued.complete();
|
|
184
|
-
|
|
177
|
+
await queuedOuter;
|
|
178
|
+
assert.deepEqual(queued.messages, [], "queued user work is never duplicated");
|
|
185
179
|
});
|
|
186
|
-
test("foreign
|
|
180
|
+
test("foreign boundaries and native compactions do not manufacture a continuation", async () => {
|
|
187
181
|
const h = harness();
|
|
188
182
|
h.lifecycle.request();
|
|
189
|
-
h.settle();
|
|
190
|
-
// isCurrentReset stands in for the reset-v2/window-id check index.ts runs against the
|
|
191
|
-
// compaction entry's details. Emitting the foreign boundary twice proves it is never
|
|
192
|
-
// marked handled, and the request stays in flight rather than completing.
|
|
183
|
+
const outer = h.settle();
|
|
193
184
|
h.emit("session_compact", { compactionEntry: { id: "foreign" }, willRetry: false });
|
|
194
185
|
h.emit("session_compact", { compactionEntry: { id: "foreign" }, willRetry: false });
|
|
195
|
-
assert.equal(h.lifecycle.request(), "rollover_already_pending"
|
|
186
|
+
assert.equal(h.lifecycle.request(), "rollover_already_pending");
|
|
196
187
|
h.complete();
|
|
197
|
-
|
|
198
|
-
assert.
|
|
188
|
+
await outer;
|
|
189
|
+
assert.deepEqual(h.messages, []);
|
|
190
|
+
const native = harness();
|
|
191
|
+
native.lifecycle.request();
|
|
192
|
+
native.success("native", false);
|
|
193
|
+
await native.settle();
|
|
194
|
+
assert.equal(native.requests.length, 0);
|
|
195
|
+
assert.deepEqual(native.messages, []);
|
|
199
196
|
});
|
package/docs/reset-lifecycle.md
CHANGED
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
| `new_context` | Mark explicit request; repeated calls report already pending. Tool returns terminal output. |
|
|
8
8
|
| Manual, threshold or overflow `session_before_compact`, idle or streaming | Build the reset boundary immediately and return it. Never cancel and never take a model turn; an aborted signal returns `{ cancel: true }`. |
|
|
9
9
|
| `agent_end` | No-op for an instant reset. |
|
|
10
|
-
| `agent_settled` | If idle and an explicit request is pending, create one identified attempt and request `ctx.compact`. |
|
|
10
|
+
| `agent_settled` | If idle and an explicit request is pending, create one identified attempt and request `ctx.compact`. The originating handler owns and awaits that attempt through its continuation's settlement. |
|
|
11
11
|
| Matching `session_compact` | Confirm boundary, persist window state. Native compaction retains its own scheduling. |
|
|
12
|
-
| Attempt `onComplete` | Consume attempt;
|
|
12
|
+
| Attempt `onComplete` | Consume attempt; for a confirmed boundary when idle with no queued messages, register continuation ownership before sending it. Its nested `agent_settled` settles that owner; a reset requested by the continuation completes its own handoff before releasing its predecessor. |
|
|
13
13
|
| Attempt `onError` or synchronous throw | Clear attempt/request, warn, retain history. No automatic retry loop. |
|
|
14
14
|
| Shutdown / start / tree / toggle off | Invalidate outstanding attempt. Identity checks reject callbacks from older attempts. |
|
|
15
15
|
|
|
@@ -17,6 +17,8 @@ The final checkpoint warning is steered earlier from the context hook (`warning.
|
|
|
17
17
|
|
|
18
18
|
The completion callback is the scheduling boundary: `session_compact` fires before Pi clears manual compaction state. Sending a prompt inside that hook is too early. An explicit reset uses the manual `ctx.compact` route and therefore needs this completion logic; an automatic compaction is already the reset and resumes through Pi's own caller.
|
|
19
19
|
|
|
20
|
+
`sendMessage(..., { triggerTurn: true })` starts its run detached from the extension API. The explicit attempt therefore retains an attempt-owned waiter before sending it, and its originating `agent_settled` handler awaits that waiter. The continuation's `agent_settled` releases the waiter without awaiting itself. If that continuation calls `new_context`, its settled handler starts and awaits the next attempt before it releases the prior waiter, forming a bounded reset chain. Failure, cancellation, shutdown, tree navigation, and toggling off release the relevant waiter exactly once.
|
|
21
|
+
|
|
20
22
|
Public APIs cannot guarantee immediate reset inside mixed tool batches or before queued steering/follow-up messages finish. `terminate` ends the tool-followup path; `agent_settled` remains the safe point to request compaction. The scheduler does not manipulate user queues. Pi also determines compaction eligibility before the extension hook; an uncompactable session produces a warning and waits for a new prompt.
|
|
21
23
|
|
|
22
24
|
Validation is split into persisted-data integration tests, isolated lifecycle event tests, and scripted SDK tests running Pi's actual agent loop. The SDK tests cover explicit success, instant automatic reset, and core compaction rejection followed by a user prompt. Lifecycle tests cover callback races and queue guards without pretending to exercise provider/network behavior.
|