@astrosheep/pi-context 0.22.1 → 0.23.1

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.
@@ -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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/pi-context",
3
- "version": "0.22.1",
3
+ "version": "0.23.1",
4
4
  "type": "module",
5
5
  "description": "Codex-style context windows for Pi: reset-style compaction, durable session history tools, and persistent notes.",
6
6
  "license": "MIT",
package/playbook.md CHANGED
@@ -24,9 +24,9 @@ access_count: 0
24
24
  2. **Merge threshold.** Supersede another note only when all three hold: same topic (name it in the survivor's body), same kind of note (checkpoint/design/log…), and the survivor is strictly newer or strictly more specific. Otherwise keep both and record the open conflict in the survivor.
25
25
  3. **Size budget.** Keep every note under ~200 lines / ~8KB. Oversized notes get split by topic with a one-line cross-link in each (`see also: @home/<vpath>`). Checkpoints may exceed the budget — trim prose, never facts.
26
26
  4. **Keep the maps.** Each home's `MAP.md` maps that home's durable notes: one line per entry — its address and a short gist in your own words, never a mechanical body slice. Project notes go on `@project/MAP.md`, cross-project knowledge on `@personal/MAP.md`; session notes are never mapped — the pocket covers them. When a note is promoted across homes, move its line to the destination map; when a note goes stale, drop its line. Maps obey the same size budget as any note.
27
- 5. **Jurisdiction.** Your mandate is the whole store every session home, every project home, personal. Nothing is skipped: notes are never physically deleted and every run is bracketed by git commits, so the human gate can audit and revert whatever you touch. Group your report by home so the gate can see what moved. In every home: map entry lines are yours to maintain, but prose that carries rules or guidance is not — flag it in your report instead of rewriting it.
27
+ 5. **Jurisdiction.** Read the whole store, including every session home, to extract durable knowledge. `pi/session/**` is a live agent's write-ahead log: never write or edit anything there, including frontmatter or stale markers. Promote useful facts by writing to project or personal instead. Other notes are never physically deleted and every run is bracketed by git commits, so the human gate can audit and revert whatever you touch. Group your report by home so the gate can see what moved. In writable homes: map entry lines are yours to maintain, but prose that carries rules or guidance is not — flag it in your report instead of rewriting it.
28
28
  6. **Leave stable notes alone.** Change notes to incorporate new evidence, resolve verified errors, merge genuine duplicates, or split oversized files—not merely to shorten or rephrase them. Preserve facts, conditions, exceptions, and uncertainty. No change is a valid outcome.
29
29
 
30
- Read the files and merge genuinely duplicate notes by editing the survivor, then set `stale: true` in the absorbed note's frontmatter. Nothing is physically deleted; stale notes remain readable. Promote durable cross-project knowledge by writing or editing at `@personal/<vpath>`. Keep notes compact and preserve useful provenance in the body.
30
+ Read the files and merge genuinely duplicate notes in writable homes by editing the survivor, then set `stale: true` in the absorbed note's frontmatter if it is writable. Session notes remain untouched even when promoted; record their source in the destination. Nothing is physically deleted; stale notes remain readable. Promote durable cross-project knowledge by writing or editing at `@personal/<vpath>`. Keep notes compact and preserve useful provenance in the body.
31
31
 
32
32
  Do not write skill ideas as files. Put skill ideas and unresolved questions in your final assistant message as proposals for the human. Your final message should be a concise report of what you inspected, changed, and left unresolved. If you made no file writes, say so.
package/src/dream/cli.ts CHANGED
@@ -12,7 +12,7 @@ import { notesRoot } from "../notes/paths.js";
12
12
 
13
13
  function args(argv: string[]) { const out: Record<string, string | boolean> = {}; for (let i=0;i<argv.length;i++) { const a=argv[i]!; if (a === "--force" || a === "--help") out[a.slice(2)] = true; else if (a.startsWith("--")) out[a.slice(2)] = argv[++i] ?? ""; } return out; }
14
14
  function packageRoot(): string {
15
- let dir = dirname(new URL(import.meta.url).pathname);
15
+ let dir = dirname(fileURLToPath(import.meta.url));
16
16
  while (true) { if (existsSync(join(dir, "package.json"))) return dir; const parent = dirname(dir); if (parent === dir) throw new Error("could not locate installed package root"); dir = parent; }
17
17
  }
18
18
 
@@ -1,6 +1,6 @@
1
1
  import { Type } from "@earendil-works/pi-ai";
2
2
  import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
- import { output, outputRaw, page, middleTruncate, prefixFit, earliestMatchOffsetChars, readCharacterWindow, characterWindowHeader, withinTextBudget, DEFAULT_READ_WINDOW_CHARS, HISTORY_PREVIEW_CHARS, MAX_READ_WINDOW_CHARS } from "./tool-output.js";
3
+ import { output, outputRaw, page, middleTruncate, prefixFit, earliestMatchOffsetChars, readCharacterWindow, readWindowBlock, withinTextBudget, DEFAULT_READ_WINDOW_CHARS, HISTORY_PREVIEW_CHARS, MAX_READ_WINDOW_CHARS } from "./tool-output.js";
4
4
  import { positiveInteger, recentFirst, nullableString, role, cursor, searchQuery, searchQueries } from "./tool-schema.js";
5
5
  import { historyFromSession, filteredItems, visibleItem, allItems, vacuousRoleToolCombo, unknownWindowId } from "./history.js";
6
6
 
@@ -61,7 +61,7 @@ export function registerHistoryTools(pi: ExtensionAPI) {
61
61
  pi.registerTool(defineTool({
62
62
  name: "history_read",
63
63
  label: "History read item",
64
- description: "Read a bounded character range from one session item. Each response delivers the longest contiguous prefix of the requested window that fits the wire budget: follow the resume cursor to reconstruct the item exactly. A negative offset_chars counts back from the item's end. Offsets and counts are code points (an emoji or CJK character counts as one). The response is the raw item text behind a one-line [bracketed] header naming the item, the resolved offset, the delivered char range, and the resume cursor (continue at offset_chars=N, or end).",
64
+ description: "Read a bounded character range from one session item. Each response delivers the longest contiguous prefix of the requested window that fits the wire budget: follow the resume cursor to reconstruct the item exactly. A negative offset_chars counts back from the item's end. Offsets and counts are code points (an emoji or CJK character counts as one). The response begins with the shared READ WINDOW block naming window_id and item_id; concatenate only the content after that block to reconstruct the item.",
65
65
  parameters: Type.Object({ item_id: Type.String(), offset_chars: Type.Optional(Type.Integer({ description: "Code-point offset to start from. A negative value counts back from the end; the response echoes the resolved absolute offset. Pass the previous next_offset_chars back unchanged to continue." })), limit_chars: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_READ_WINDOW_CHARS, description: `Largest requested window in code points (default ${DEFAULT_READ_WINDOW_CHARS}). A window too large for the wire budget is cut short; next_offset_chars names where the next read resumes.` })), window_id: Type.String() }, { additionalProperties: false }),
66
66
  async execute(_id, params, _signal, _update, ctx) {
67
67
  const item = allItems(ctx).find((candidate) => candidate.windowId === params.window_id && candidate.itemId === params.item_id);
@@ -72,10 +72,9 @@ export function registerHistoryTools(pi: ExtensionAPI) {
72
72
  if (typeof params.offset_chars === "number" && params.offset_chars > totalChars) {
73
73
  return output({ error: `offset_chars ${params.offset_chars} is past the end: the item has ${totalChars} chars; the largest legal offset is ${totalChars} (an empty end-read)`, window_id: item.windowId, item_id: item.itemId, offset_chars: params.offset_chars, total_chars: totalChars });
74
74
  }
75
- const limit_chars = Math.min(params.limit_chars ?? DEFAULT_READ_WINDOW_CHARS, MAX_READ_WINDOW_CHARS);
76
75
  return readCharacterWindow(item.content, params.offset_chars, params.limit_chars, (window) => {
77
76
  const { content, ...cursor } = window;
78
- return outputRaw(characterWindowHeader(`${item.windowId} · item ${item.itemId}`, window), content, { window_id: item.windowId, item_id: item.itemId, ...cursor, limit_chars });
77
+ return outputRaw(readWindowBlock([["window_id", item.windowId], ["item_id", item.itemId]], window), content, { window_id: item.windowId, item_id: item.itemId, ...cursor });
79
78
  }, (result) => withinTextBudget(result.content[0].text));
80
79
  },
81
80
  }));
@@ -220,19 +220,25 @@ export function editNote(ctx: ExtensionContext, vpath: string, scope: Scope, edi
220
220
  return { meta, applied: operations.length, resolved_scope: scope, diff };
221
221
  }
222
222
 
223
+ /** Normalize a parsed note exactly as a read does, including its access metadata mutation. */
224
+ function accessedMeta(meta: NoteMeta, scope: Scope, now: number): NoteMeta {
225
+ const next = { ...meta, scope };
226
+ next.last_accessed = now;
227
+ next.access_count = (typeof next.access_count === "number" ? next.access_count : 0) + 1;
228
+ return next;
229
+ }
230
+
223
231
  /** Read a note and, as a side effect, bump last_accessed/access_count in the file. */
224
- export function readNote(ctx: ExtensionContext, vpath: string, scope: Scope): { meta: NoteMeta; body: string; resolvedScope: Scope } | undefined {
232
+ export function readNote(ctx: ExtensionContext, vpath: string, scope: Scope): { meta: NoteMeta; body: string; text: string; resolvedScope: Scope } | undefined {
225
233
  assertVirtualPath(vpath);
226
234
  const path = physicalPath(scope, vpath, ctx);
227
235
  if (!existsSync(path)) return undefined;
228
236
  const now = Date.now();
229
- const { meta, body } = parseNote(readFileSync(path, "utf8"), now);
230
- meta.scope = scope;
231
- // Only the two access keys move; updated_at and every other key keep their bytes.
232
- meta.last_accessed = now;
233
- meta.access_count = (typeof meta.access_count === "number" ? meta.access_count : 0) + 1;
234
- atomicWrite(path, serializeNote(meta, body));
235
- return { meta, body, resolvedScope: scope };
237
+ const parsed = parseNote(readFileSync(path, "utf8"), now);
238
+ const meta = accessedMeta(parsed.meta, scope, now);
239
+ const text = serializeNote(meta, parsed.body);
240
+ atomicWrite(path, text);
241
+ return { meta, body: parsed.body, text, resolvedScope: scope };
236
242
  }
237
243
 
238
244
  /** Merged rows across homes, most recently updated first (address breaks ties). */
@@ -264,11 +270,12 @@ export function searchNotes(ctx: ExtensionContext, queries: string[], opts: { sc
264
270
  if (matcher && !matcher.test(address)) continue;
265
271
  const { meta, body } = parseNote(readFileSync(`${root}/${path}`, "utf8"));
266
272
  meta.scope = scope;
273
+ const serializedBodyOffset = Array.from(serializeNote(accessedMeta(meta, scope, Date.now()), "")).length;
267
274
  let baseChars = 0;
268
275
  const matches: NoteMatch[] = [];
269
276
  for (const [index, line] of body.split("\n").entries()) {
270
277
  if (queries.some((query) => line.includes(query))) {
271
- matches.push({ line: index + 1, text: line, offsetChars: baseChars + earliestMatchOffsetChars(line, queries) });
278
+ matches.push({ line: index + 1, text: line, offsetChars: serializedBodyOffset + baseChars + earliestMatchOffsetChars(line, queries) });
272
279
  }
273
280
  baseChars += Array.from(line).length + 1;
274
281
  }