@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.
- package/README.md +1 -1
- package/dist/src/dream/cli.js +1 -1
- package/dist/src/history-tools.js +3 -4
- package/dist/src/notes/store.js +14 -8
- package/dist/src/notes/tools.js +14 -21
- package/dist/src/reset-lifecycle.js +82 -28
- package/dist/src/tool-output.js +8 -11
- package/dist/test/agent-loop.test.js +25 -9
- package/dist/test/coherence.test.js +32 -36
- package/dist/test/dream.test.js +23 -3
- 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/playbook.md +2 -2
- package/src/dream/cli.ts +1 -1
- package/src/history-tools.ts +3 -4
- package/src/notes/store.ts +16 -9
- package/src/notes/tools.ts +16 -23
- package/src/reset-lifecycle.ts +89 -28
- package/src/tool-output.ts +8 -11
|
@@ -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.
|
package/package.json
CHANGED
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.**
|
|
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(
|
|
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
|
|
package/src/history-tools.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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(
|
|
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
|
}));
|
package/src/notes/store.ts
CHANGED
|
@@ -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
|
|
230
|
-
meta
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
}
|