@astrosheep/pi-context 0.22.1 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/src/history-tools.js +3 -4
- package/dist/src/notes/store.js +14 -8
- package/dist/src/notes/tools.js +14 -21
- package/dist/src/reset-lifecycle.js +82 -28
- package/dist/src/tool-output.js +8 -11
- package/dist/test/agent-loop.test.js +25 -9
- package/dist/test/coherence.test.js +32 -36
- package/dist/test/integration.test.js +26 -27
- package/dist/test/notes.test.js +106 -20
- package/dist/test/pagination.property.test.js +24 -29
- package/dist/test/reset-lifecycle.test.js +99 -102
- package/docs/reset-lifecycle.md +4 -2
- package/package.json +1 -1
- package/src/history-tools.ts +3 -4
- package/src/notes/store.ts +16 -9
- package/src/notes/tools.ts +16 -23
- package/src/reset-lifecycle.ts +89 -28
- package/src/tool-output.ts +8 -11
|
@@ -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/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
|
}
|
package/src/notes/tools.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { Type } from "@earendil-works/pi-ai";
|
|
2
2
|
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { localIso } from "./model.js";
|
|
4
|
-
import {
|
|
4
|
+
import { DEFAULT_READ_WINDOW_CHARS, MAX_READ_WINDOW_CHARS, middleTruncate, output, outputRaw, page, prefixFit, readCharacterWindow, readWindowBlock, withinTextBudget } from "../tool-output.js";
|
|
5
5
|
import { cursor, nullableString, positiveInteger, searchQueries, searchQuery } from "../tool-schema.js";
|
|
6
6
|
import { assertAddress } from "./address.js";
|
|
7
|
-
import {
|
|
7
|
+
import { type Origin } from "./frontmatter.js";
|
|
8
8
|
import { NoteError, editNote, listNotes, readNote, searchNotes, writeNote } from "./store.js";
|
|
9
9
|
|
|
10
10
|
const ORIGIN = Type.Optional(Type.Union([Type.Literal("user"), Type.Literal("self"), Type.Literal("external")], {
|
|
@@ -12,10 +12,6 @@ const ORIGIN = Type.Optional(Type.Union([Type.Literal("user"), Type.Literal("sel
|
|
|
12
12
|
}));
|
|
13
13
|
const ADDRESS_DESCRIPTION = "Address forms are bare `<vpath>` for this session, `@project/<vpath>` for this project's home, and `@personal/<vpath>` for the human's cross-project home. `@` means leaving home. Any other `@` prefix, or `@` inside a vpath, is a hard error: legal prefixes are `@project/` and `@personal/`; bare names are the session home. There is no cross-home fallback. Paths reject `..`, absolute paths, and backslashes.";
|
|
14
14
|
|
|
15
|
-
function wireMeta(meta: NoteMeta): Record<string, unknown> {
|
|
16
|
-
return { ...meta, created_at: localIso(meta.created_at), updated_at: localIso(meta.updated_at), last_accessed: localIso(meta.last_accessed) };
|
|
17
|
-
}
|
|
18
|
-
|
|
19
15
|
function failure(error: unknown) {
|
|
20
16
|
if (error instanceof NoteError) {
|
|
21
17
|
const payload: Record<string, unknown> = { error: error.message };
|
|
@@ -35,28 +31,28 @@ export function registerNotesTools(pi: ExtensionAPI) {
|
|
|
35
31
|
const content = params.content;
|
|
36
32
|
try {
|
|
37
33
|
const destination = assertAddress(params.address);
|
|
38
|
-
|
|
39
|
-
return output({ address: params.address,
|
|
34
|
+
writeNote(ctx, destination.path, content, { scope: destination.scope, origin: (params.origin ?? "self") as Origin, stale: params.stale });
|
|
35
|
+
return output({ address: params.address, written: true });
|
|
40
36
|
} catch (error) { return failure(error); }
|
|
41
37
|
},
|
|
42
38
|
}));
|
|
43
39
|
|
|
44
40
|
pi.registerTool(defineTool({
|
|
45
41
|
name: "notes_edit", label: "Notes edit",
|
|
46
|
-
description: `Edit a note body by exact-text replacement; frontmatter is never editable this way. ${ADDRESS_DESCRIPTION} Each oldText must occur exactly once unless replace_all is set; a multi-match anchor fails with its match line numbers and a zero-match anchor names the failing edit index. edits may be omitted (or empty) for a metadata-only update, which requires at least one of origin/stale. Moving while awake means notes_write at a new address and notes_edit at the old address with stale=true. The success return carries
|
|
42
|
+
description: `Edit a note body by exact-text replacement; frontmatter is never editable this way. ${ADDRESS_DESCRIPTION} Each oldText must occur exactly once unless replace_all is set; a multi-match anchor fails with its match line numbers and a zero-match anchor names the failing edit index. edits may be omitted (or empty) for a metadata-only update, which requires at least one of origin/stale. Moving while awake means notes_write at a new address and notes_edit at the old address with stale=true. The success return carries the address and a diff of what changed.`,
|
|
47
43
|
parameters: Type.Object({ address: Type.String(), edits: Type.Optional(Type.Array(Type.Object({ oldText: Type.String(), newText: Type.String() }, { additionalProperties: false }))), origin: ORIGIN, stale: Type.Optional(Type.Boolean()), replace_all: Type.Optional(Type.Boolean()) }, { additionalProperties: false }), executionMode: "sequential",
|
|
48
44
|
async execute(_id, params, _signal, _update, ctx) {
|
|
49
45
|
try {
|
|
50
46
|
const destination = assertAddress(params.address);
|
|
51
|
-
const {
|
|
52
|
-
return output({ address: params.address, applied,
|
|
47
|
+
const { applied, diff } = editNote(ctx, destination.path, destination.scope, params.edits, { origin: params.origin as Origin | undefined, stale: params.stale, replaceAll: params.replace_all });
|
|
48
|
+
return output({ address: params.address, applied, diff });
|
|
53
49
|
} catch (error) { return failure(error); }
|
|
54
50
|
},
|
|
55
51
|
}));
|
|
56
52
|
|
|
57
53
|
pi.registerTool(defineTool({
|
|
58
54
|
name: "notes_read", label: "Notes read",
|
|
59
|
-
description: `Read a character window of a note file, frontmatter included. ${ADDRESS_DESCRIPTION} offset_chars is the code-point offset to start from (default 0) — a negative value counts back from the end — and limit_chars caps the window (default ${DEFAULT_READ_WINDOW_CHARS}, max ${MAX_READ_WINDOW_CHARS}). Each response delivers the longest fitting prefix of that window
|
|
55
|
+
description: `Read a character window of a note file, frontmatter included. ${ADDRESS_DESCRIPTION} offset_chars is the code-point offset to start from (default 0) — a negative value counts back from the end — and limit_chars caps the window (default ${DEFAULT_READ_WINDOW_CHARS}, max ${MAX_READ_WINDOW_CHARS}). Each response delivers the longest fitting prefix of that window in the shared READ WINDOW block: concatenate only the content after the block to reconstruct the note.`,
|
|
60
56
|
parameters: Type.Object({ address: Type.String(), offset_chars: Type.Optional(Type.Integer({ description: "Code-point offset to start from (default 0). 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.` })) }, { additionalProperties: false }),
|
|
61
57
|
async execute(_id, params, _signal, _update, ctx) {
|
|
62
58
|
let note: ReturnType<typeof readNote>;
|
|
@@ -65,27 +61,24 @@ export function registerNotesTools(pi: ExtensionAPI) {
|
|
|
65
61
|
note = readNote(ctx, destination.path, destination.scope);
|
|
66
62
|
} catch (error) { return failure(error); }
|
|
67
63
|
if (!note) return output({ error: "note not found", address: params.address });
|
|
68
|
-
const text =
|
|
64
|
+
const text = note.text;
|
|
69
65
|
const totalChars = Array.from(text).length;
|
|
70
66
|
if (typeof params.offset_chars === "number" && params.offset_chars > totalChars) return output({ error: `offset_chars ${params.offset_chars} is past the end: the note has ${totalChars} chars; the largest legal offset is ${totalChars} (an empty end-read)`, address: params.address, offset_chars: params.offset_chars, total_chars: totalChars });
|
|
71
|
-
const created_at = localIso(note.meta.created_at);
|
|
72
|
-
const updated_at = localIso(note.meta.updated_at);
|
|
73
|
-
const limit_chars = Math.min(params.limit_chars ?? DEFAULT_READ_WINDOW_CHARS, MAX_READ_WINDOW_CHARS);
|
|
74
67
|
return readCharacterWindow(text, params.offset_chars, params.limit_chars, (window) => {
|
|
75
68
|
const { content, ...rest } = window;
|
|
76
|
-
return outputRaw(
|
|
69
|
+
return outputRaw(readWindowBlock([["address", params.address]], window), content, { address: params.address, ...rest });
|
|
77
70
|
}, (result) => withinTextBudget(result.content[0].text));
|
|
78
71
|
},
|
|
79
72
|
}));
|
|
80
73
|
|
|
81
74
|
pi.registerTool(defineTool({
|
|
82
75
|
name: "notes_list", label: "Notes list",
|
|
83
|
-
description: `List note files as rows carrying address,
|
|
76
|
+
description: `List note files as rows carrying address, updated_at, and stale, most recently updated first. ${ADDRESS_DESCRIPTION} All three homes are merged. A glob pattern (* within a path segment, ** across segments) filters full address strings: *.md is session-only, @project/** is project-only, and ** covers every home.`,
|
|
84
77
|
parameters: Type.Object({ pattern: nullableString(), cursor: cursor(), max_results: positiveInteger() }, { additionalProperties: false }),
|
|
85
78
|
async execute(_id, params, _signal, _update, ctx) {
|
|
86
79
|
let rows: ReturnType<typeof listNotes>;
|
|
87
80
|
try { rows = listNotes(ctx, { pattern: params.pattern ?? undefined }); } catch (error) { return failure(error); }
|
|
88
|
-
const files: Array<{ address: string;
|
|
81
|
+
const files: Array<{ address: string; stale: boolean; updated_at: string; address_truncated?: boolean }> = rows.map((row) => ({ address: row.address, stale: row.meta.stale, updated_at: localIso(row.meta.updated_at) }));
|
|
89
82
|
return output(page(files, params.cursor ?? 0, "files", params.max_results, (file, fits) => {
|
|
90
83
|
if (fits(file)) return file;
|
|
91
84
|
const address = middleTruncate(file.address, (candidate) => fits({ ...file, address: candidate, address_truncated: true }));
|
|
@@ -96,16 +89,16 @@ export function registerNotesTools(pi: ExtensionAPI) {
|
|
|
96
89
|
|
|
97
90
|
pi.registerTool(defineTool({
|
|
98
91
|
name: "notes_search", label: "Notes search",
|
|
99
|
-
description: `Case-sensitive literal substring search over note bodies; query is one string or several (OR), each matched line appears once. ${ADDRESS_DESCRIPTION} All three homes are merged and every entry carries its full address
|
|
92
|
+
description: `Case-sensitive literal substring search over note bodies; query is one string or several (OR), each matched line appears once. ${ADDRESS_DESCRIPTION} All three homes are merged and every entry carries its full address. Patterns glob over full address strings. Each file entry carries matches_total, its full match count before capping. Each match carries line, text, offset_chars (a code-point offset into the serialized note returned by notes_read, at the earliest query match), and truncated.`,
|
|
100
93
|
parameters: Type.Object({ query: searchQuery(), pattern: nullableString(), cursor: cursor(), max_matches_per_file: positiveInteger(), max_files: positiveInteger() }, { additionalProperties: false }),
|
|
101
94
|
async execute(_id, params, _signal, _update, ctx) {
|
|
102
95
|
const queries = searchQueries(params.query);
|
|
103
96
|
let rows: ReturnType<typeof searchNotes>;
|
|
104
97
|
try { rows = searchNotes(ctx, queries, { pattern: params.pattern ?? undefined }); } catch (error) { return failure(error); }
|
|
105
98
|
const maxPerFile = params.max_matches_per_file ?? Number.POSITIVE_INFINITY;
|
|
106
|
-
const result: Array<{ address: string;
|
|
107
|
-
const matches = row.matches.map((match) => ({ line: match.line, text: match.text, truncated: false,
|
|
108
|
-
return { address: row.address,
|
|
99
|
+
const result: Array<{ address: string; updated_at: string; stale: boolean; matches_total: number; matches: Array<{ line: number; text: string; truncated: boolean; offset_chars: number }>; address_truncated?: boolean }> = rows.map((row) => {
|
|
100
|
+
const matches = row.matches.map((match) => ({ line: match.line, text: match.text, truncated: false, offset_chars: match.offsetChars }));
|
|
101
|
+
return { address: row.address, updated_at: localIso(row.meta.updated_at), stale: row.meta.stale, matches_total: matches.length, matches: matches.slice(0, maxPerFile) };
|
|
109
102
|
});
|
|
110
103
|
const fitFile = (file: (typeof result)[number], fits: (candidate: (typeof result)[number]) => boolean) => {
|
|
111
104
|
if (fits(file)) return file;
|