@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.
@@ -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.equal(result.scope, "session");
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(typeof result.meta.created_at, "string", "wire meta renders timestamps as ISO strings");
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.equal(rewrite.meta.origin, "self");
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.resolved_scope, "session", "the success return names the layer the file was resolved from");
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.resolved_scope, "session");
153
- assert.equal(bare.meta.stale, true, "stale is set without a body edit");
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.equal(revived.meta.stale, false, "a later metadata-only update revives the note");
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("list and search merge scopes and carry scope; the path jail rejects escapes", async () => {
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.scope).sort(), ["personal", "project", "session"], "every merged row carries its scope");
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.equal(typeof row.size_bytes, "number");
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.path), ["three.md"], "a scope filter narrows the set");
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.scope).sort(), ["personal", "project", "session"]);
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.equal(hit.offset_chars, 0);
314
+ assert.ok(hit.offset_chars > 0, "the offset includes serialized frontmatter");
262
315
  assert.equal(hit.truncated, false);
263
- assert.equal(hit.total_chars, searched.files[0].path === "three.md" ? "needle three".length : hit.text.length, "total_chars names the real line length");
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, Array.from("line one\n").length, "offset_chars addresses the query within the body");
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.match(read.header, /^\[@personal\/personal\.md /, "the raw read header echoes the full address");
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.scope, "project", "scope is derived from the file location");
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 path back to the expected store path. A non-truncated path must
296
- * equal it; a flagged path must be a visible middle-truncation of a legacy path that the
297
- * write cap could never have produced. The expected path is returned either way so the
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.path_truncated) {
305
- assert.ok(Buffer.byteLength(expectedPath, "utf8") > MAX_NOTE_PATH_BYTES, `${label} cursor=${cursor}: only a legacy path beyond the write cap may be truncated, got ${file.path}`);
306
- assertTruncatedIdentity(expectedPath, file.path, `${label} cursor=${cursor}`);
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.path, expectedPath, `${label} cursor=${cursor}: path is returned intact when its entry fits`);
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.path, 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.path);
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 storePath = expected[flat++];
417
- const row = all.get(storePath);
418
- assert.ok(row, `${label}: listed ${storePath} is not in the note store`);
419
- assert.equal(file.size_bytes, row.sizeBytes, `${label}: size_bytes for ${storePath}`);
420
- assert.equal(file.stale, row.meta.stale, `${label}: stale for ${storePath}`);
421
- assert.equal(Date.parse(file.created_at), row.meta.created_at, `${label}: created_at for ${storePath}`);
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 expected = expectedSearchRows(ctx, variant).map((row) => row.path);
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 storePath = expected[flat++];
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 lineBase = [];
459
- let lineOffset = 0;
460
- for (const text of lines) {
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
- // The documented address: body-absolute code points up to the line, plus the query's
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
- settle: () => { emit("agent_end"); idle = true; emit("agent_settled"); },
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("reset completion, duplicate callbacks, and duplicate tools cannot launch duplicate runs", () => {
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.emit("agent_settled");
66
- assert.deepEqual(h.messages, ["continue"]);
67
- assert.equal(h.requests.length, 1);
68
- });
69
- test("automatic threshold compactions reset on the spot, with no steer and no model turn", () => {
70
- const h = harness();
71
- assert.ok(h.before().compaction, "the native attempt becomes our reset immediately");
72
- assert.deepEqual(h.messages, [], "nothing is sent to the model");
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 native compaction failure is not treated as failure of an explicit reset", () => {
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
- test("failed resets release the request, retain history, and do not retry", () => {
84
- const h = harness();
85
- h.lifecycle.request();
86
- h.settle();
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("synchronous compact errors cannot leave a permanent in-flight request", () => {
100
+ test("automatic compactions reset on the spot, with no continuation", () => {
105
101
  const h = harness();
106
- h.setThrow();
107
- h.lifecycle.request();
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("user abort ends explicit work without resurrecting the run", () => {
114
- const h = harness();
115
- h.lifecycle.request();
116
- h.setSignal(AbortSignal.abort());
117
- h.settle();
118
- assert.equal(h.requests.length, 0);
119
- assert.equal(h.messages.includes("continue"), false);
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, restart, tree navigation and toggling off invalidate late callbacks", () => {
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
- assert.deepEqual(h.messages, [], boundary);
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
- h.lifecycle.request();
140
- h.settle();
141
- assert.equal(h.requests.length, 2, `${boundary}: a fresh request still works`);
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("do not interrupt another active run or duplicate a queued user prompt", () => {
168
- const h = harness();
169
- h.lifecycle.request();
170
- h.setIdle(false);
171
- h.emit("agent_settled");
172
- assert.equal(h.requests.length, 0, "another extension already started work");
173
- h.settle();
174
- h.success();
175
- h.setIdle(false);
176
- h.complete();
177
- assert.deepEqual(h.messages, [], "the active prompt owns continuation");
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
- assert.deepEqual(queued.messages, [], "do not add a competing prompt");
177
+ await queuedOuter;
178
+ assert.deepEqual(queued.messages, [], "queued user work is never duplicated");
185
179
  });
186
- test("foreign or unconfirmed reset events cannot trigger a successful continuation", () => {
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", "the ignored event did not complete or clear the attempt");
186
+ assert.equal(h.lifecycle.request(), "rollover_already_pending");
196
187
  h.complete();
197
- assert.deepEqual(h.messages, [], "an unconfirmed boundary never resumes the run");
198
- assert.equal(h.lifecycle.request(), "rollover_requested", "the request is released after its own completion");
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
  });
@@ -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; send continuation only for a confirmed boundary when idle with no queued messages. |
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.