pi-posthorse 0.10.9 → 0.10.11
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/package.json +4 -4
- package/src/context-tools.ts +2 -6
- package/src/contracts.ts +2 -3
- package/src/files.ts +8 -1
- package/src/history-pages.ts +19 -10
- package/src/history-search.ts +17 -12
- package/src/history.ts +1 -3
- package/src/note-guards.ts +1 -1
- package/src/note-snapshots.ts +51 -17
- package/src/note-store.ts +28 -14
- package/src/note-tool-effects.ts +10 -3
- package/src/notes.ts +1 -3
- package/src/paging.ts +28 -23
- package/src/records.ts +3 -0
- package/src/recovery-note-touches.ts +10 -9
- package/src/recovery-notes.ts +75 -69
- package/src/reminders.ts +75 -1
- package/src/reset-requests.ts +88 -0
- package/src/rollover.ts +113 -147
- package/src/ui/text.ts +3 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-posthorse",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.11",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Posthorse: fresh context, same journey. No-summary context rollover for Pi, with sparse budget reminders, handoffs, notes, and history.",
|
|
6
6
|
"keywords": [
|
|
@@ -71,9 +71,9 @@
|
|
|
71
71
|
"@earendil-works/pi-coding-agent": "1.5.0",
|
|
72
72
|
"@earendil-works/pi-tui": "1.5.0",
|
|
73
73
|
"@types/node": "24.19.1",
|
|
74
|
-
"oxc-parser": "0.
|
|
75
|
-
"oxfmt": "0.
|
|
76
|
-
"oxlint": "1.
|
|
74
|
+
"oxc-parser": "0.157.0",
|
|
75
|
+
"oxfmt": "0.76.0",
|
|
76
|
+
"oxlint": "1.91.0",
|
|
77
77
|
"oxlint-tsgolint": "7.0.2005",
|
|
78
78
|
"typebox": "1.3.27",
|
|
79
79
|
"typescript": "7.0.2",
|
package/src/context-tools.ts
CHANGED
|
@@ -70,9 +70,7 @@ export function registerContextTools(
|
|
|
70
70
|
}),
|
|
71
71
|
),
|
|
72
72
|
}),
|
|
73
|
-
|
|
74
|
-
// oxlint-disable-next-line max-params -- QX-002: Pi supplies five fixed positional tool arguments.
|
|
75
|
-
async execute(id, { handoff }, _signal, _onUpdate, ctx) {
|
|
73
|
+
async execute(...[id, { handoff }, , , ctx]) {
|
|
76
74
|
const trimmed = handoff?.trim();
|
|
77
75
|
const limit = freshPayloadChars(policy(ctx), activeToolTokens());
|
|
78
76
|
if (trimmed !== undefined && trimmed !== "" && trimmed.length > limit) {
|
|
@@ -98,9 +96,7 @@ export function registerContextTools(
|
|
|
98
96
|
promptSnippet:
|
|
99
97
|
"verify remaining context before reporting a token count or changing plans because of context limits",
|
|
100
98
|
parameters: Type.Object({}),
|
|
101
|
-
|
|
102
|
-
// oxlint-disable-next-line max-params -- QX-002: Pi supplies five fixed positional tool arguments.
|
|
103
|
-
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
99
|
+
async execute(...[, , , , ctx]) {
|
|
104
100
|
return contextReport(policy(ctx));
|
|
105
101
|
},
|
|
106
102
|
});
|
package/src/contracts.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { PosthorseDisplay } from "../ui.ts";
|
|
3
3
|
|
|
4
|
+
export { isRecord } from "./records.ts";
|
|
5
|
+
|
|
4
6
|
export type CompactionPolicy = {
|
|
5
7
|
readonly enabled: boolean;
|
|
6
8
|
readonly reserveTokens: number;
|
|
@@ -108,9 +110,6 @@ export const REMINDER_TYPE = "posthorse-reminder";
|
|
|
108
110
|
export const AUTO_HANDOFF_PREFIX = "Automatic context rollover recovery record.";
|
|
109
111
|
export const EMPTY_HANDOFF = "Fresh context. Restore relevant notes and history before continuing.";
|
|
110
112
|
|
|
111
|
-
export function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
|
|
112
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
113
|
-
}
|
|
114
113
|
export function unknownArray(value: unknown): readonly unknown[] | undefined {
|
|
115
114
|
return Array.isArray(value) ? value : undefined;
|
|
116
115
|
}
|
package/src/files.ts
CHANGED
|
@@ -286,13 +286,20 @@ export function notesDir(cwd: string): string {
|
|
|
286
286
|
const target = migrationTarget(moved);
|
|
287
287
|
try {
|
|
288
288
|
renameSync(local, target);
|
|
289
|
+
} catch (error) {
|
|
290
|
+
if (lstatSync(local, { throwIfNoEntry: false })?.isDirectory() === true) {
|
|
291
|
+
throw error;
|
|
292
|
+
}
|
|
293
|
+
return moved;
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
289
296
|
// Sessions still running an older Posthorse keep reaching the notes until they reload.
|
|
290
297
|
symlinkSync(moved, local, "junction");
|
|
291
298
|
} catch (error) {
|
|
292
299
|
if (lstatSync(local, { throwIfNoEntry: false })?.isDirectory() === true) {
|
|
293
300
|
throw error;
|
|
294
301
|
}
|
|
295
|
-
//
|
|
302
|
+
// Notes are already outside the checkout; the compatibility link is optional.
|
|
296
303
|
}
|
|
297
304
|
}
|
|
298
305
|
return moved;
|
package/src/history-pages.ts
CHANGED
|
@@ -10,10 +10,13 @@ import { excerpt, shortKey, sourceTag } from "./message-text.ts";
|
|
|
10
10
|
import type { HistoryMatches, HistoryParams } from "./history-search.ts";
|
|
11
11
|
import type { PageAccess } from "./paging.ts";
|
|
12
12
|
|
|
13
|
+
type Continuation<Position> =
|
|
14
|
+
| { readonly kind: "complete" }
|
|
15
|
+
| { readonly kind: "more"; readonly next: Position };
|
|
13
16
|
type SearchPage = {
|
|
14
17
|
readonly parts: readonly string[];
|
|
15
18
|
readonly spans: readonly { readonly headerLength: number; readonly length: number }[];
|
|
16
|
-
readonly
|
|
19
|
+
readonly continuation: Continuation<string>;
|
|
17
20
|
};
|
|
18
21
|
function cursorFor(matches: HistoryMatches, hit: HistoryHit, offset: number): string {
|
|
19
22
|
return Buffer.from(JSON.stringify([hit.id, hit.priority, offset, matches.key])).toString(
|
|
@@ -56,7 +59,7 @@ function searchParts(matches: HistoryMatches, chars: number): SearchPage {
|
|
|
56
59
|
const parts: string[] = [];
|
|
57
60
|
const spans: Array<{ headerLength: number; length: number }> = [];
|
|
58
61
|
let available = chars;
|
|
59
|
-
let
|
|
62
|
+
let continuation: Continuation<string> = { kind: "complete" };
|
|
60
63
|
for (const hit of matches.hits) {
|
|
61
64
|
const fragment = hitFragment(hit, matches.cursor, available, parts.length === 0);
|
|
62
65
|
if (fragment === undefined) {
|
|
@@ -65,15 +68,15 @@ function searchParts(matches: HistoryMatches, chars: number): SearchPage {
|
|
|
65
68
|
parts.push(fragment.part);
|
|
66
69
|
spans.push({ headerLength: fragment.headerLength, length: fragment.part.length });
|
|
67
70
|
available -= fragment.part.length + 1;
|
|
68
|
-
|
|
71
|
+
continuation =
|
|
69
72
|
fragment.end < hit.text.length || parts.length < matches.hits.length
|
|
70
|
-
? cursorFor(matches, hit, fragment.end)
|
|
71
|
-
:
|
|
73
|
+
? { kind: "more", next: cursorFor(matches, hit, fragment.end) }
|
|
74
|
+
: { kind: "complete" };
|
|
72
75
|
if (available <= 0 || parts.length >= matches.limit) {
|
|
73
76
|
break;
|
|
74
77
|
}
|
|
75
78
|
}
|
|
76
|
-
return { parts, spans,
|
|
79
|
+
return { parts, spans, continuation };
|
|
77
80
|
}
|
|
78
81
|
export function searchPage(
|
|
79
82
|
matches: HistoryMatches,
|
|
@@ -98,12 +101,14 @@ export function searchPage(
|
|
|
98
101
|
page.parts.length > 0
|
|
99
102
|
? page.parts.join("\n")
|
|
100
103
|
: `No history matches "${excerpt(params.query ?? "", 200)}".`;
|
|
101
|
-
const tail =
|
|
104
|
+
const tail =
|
|
105
|
+
skippedNote +
|
|
106
|
+
(page.continuation.kind === "more" ? searchFooter(page.continuation.next) : "");
|
|
102
107
|
return pages.result(body + tail, [], {
|
|
103
108
|
kind: "history-search",
|
|
104
109
|
entries: [...page.spans],
|
|
105
110
|
footerLength: tail.length,
|
|
106
|
-
more: page.
|
|
111
|
+
more: page.continuation.kind === "more",
|
|
107
112
|
skipped: matches.skipped,
|
|
108
113
|
});
|
|
109
114
|
}
|
|
@@ -155,9 +160,13 @@ export function historyReadPage(
|
|
|
155
160
|
item.text.length,
|
|
156
161
|
offset + chars - (images.length - firstImage) * ESTIMATED_IMAGE_CHARS,
|
|
157
162
|
);
|
|
158
|
-
const
|
|
163
|
+
const continuation: Continuation<{ readonly offset: number; readonly imageOffset: number }> =
|
|
159
164
|
end < item.text.length || imageEnd < item.images.length
|
|
160
|
-
?
|
|
165
|
+
? { kind: "more", next: { offset: end, imageOffset: imageEnd } }
|
|
166
|
+
: { kind: "complete" };
|
|
167
|
+
const more =
|
|
168
|
+
continuation.kind === "more"
|
|
169
|
+
? `\nMore remains; call history read with id "${shownId}" and offset ${continuation.next.offset}${item.images.length > 0 ? ` and imageOffset ${continuation.next.imageOffset}` : ""}.`
|
|
161
170
|
: "";
|
|
162
171
|
const header = `${item.entry.timestamp ?? ""}${sourceTag(source)} [window ${item.windowId}] [${shownId}] [chars ${offset}-${end} of ${item.text.length}] `;
|
|
163
172
|
return access.pages.result(`${header}${item.text.slice(offset, end)}${more}`, images, {
|
package/src/history-search.ts
CHANGED
|
@@ -115,12 +115,14 @@ type SearchProgress = {
|
|
|
115
115
|
readonly seeking: (priority: 0 | 1) => boolean;
|
|
116
116
|
readonly enough: () => boolean;
|
|
117
117
|
};
|
|
118
|
+
type CursorProgress =
|
|
119
|
+
| { readonly kind: "seeking"; readonly cursor: HistoryCursor }
|
|
120
|
+
| { readonly kind: "collecting"; readonly cursor?: HistoryCursor };
|
|
118
121
|
/** Owns ranking, lookahead and one cursor's progress; never holds the whole archive. */
|
|
119
122
|
class SearchAccumulator {
|
|
120
123
|
readonly groups: [HistoryHit[], HistoryHit[]] = [[], []];
|
|
121
|
-
readonly cursor: HistoryCursor | undefined;
|
|
122
124
|
readonly limit: number;
|
|
123
|
-
private
|
|
125
|
+
private progress: CursorProgress;
|
|
124
126
|
private readonly fail: (message: string) => never;
|
|
125
127
|
constructor(
|
|
126
128
|
limit: number,
|
|
@@ -128,24 +130,27 @@ class SearchAccumulator {
|
|
|
128
130
|
fail: (message: string) => never,
|
|
129
131
|
) {
|
|
130
132
|
this.limit = limit;
|
|
131
|
-
this.
|
|
132
|
-
this.found = cursor === undefined;
|
|
133
|
+
this.progress = cursor === undefined ? { kind: "collecting" } : { kind: "seeking", cursor };
|
|
133
134
|
this.fail = fail;
|
|
134
135
|
}
|
|
136
|
+
get cursor(): HistoryCursor | undefined {
|
|
137
|
+
return this.progress.cursor;
|
|
138
|
+
}
|
|
135
139
|
add(hit: HistoryHit): void {
|
|
136
|
-
const
|
|
140
|
+
const progress = this.progress;
|
|
141
|
+
const cursor = progress.cursor;
|
|
137
142
|
if (cursor !== undefined && hit.priority < cursor[1]) {
|
|
138
143
|
return;
|
|
139
144
|
}
|
|
140
|
-
if (
|
|
141
|
-
if (hit.id !== cursor[0]) {
|
|
145
|
+
if (progress.kind === "seeking" && hit.priority === progress.cursor[1]) {
|
|
146
|
+
if (hit.id !== progress.cursor[0]) {
|
|
142
147
|
return;
|
|
143
148
|
}
|
|
144
|
-
|
|
145
|
-
if (cursor[2] > hit.text.length) {
|
|
149
|
+
if (progress.cursor[2] > hit.text.length) {
|
|
146
150
|
this.fail("History cursor is past the entry; restart the search without it.");
|
|
147
151
|
}
|
|
148
|
-
|
|
152
|
+
this.progress = { kind: "collecting", cursor: progress.cursor };
|
|
153
|
+
if (progress.cursor[2] === hit.text.length) {
|
|
149
154
|
return;
|
|
150
155
|
}
|
|
151
156
|
}
|
|
@@ -154,13 +159,13 @@ class SearchAccumulator {
|
|
|
154
159
|
}
|
|
155
160
|
}
|
|
156
161
|
seeking(priority: 0 | 1): boolean {
|
|
157
|
-
return this.
|
|
162
|
+
return this.progress.kind === "seeking" && priority === this.progress.cursor[1];
|
|
158
163
|
}
|
|
159
164
|
enough(): boolean {
|
|
160
165
|
return this.groups[0].length > this.limit;
|
|
161
166
|
}
|
|
162
167
|
results(): HistoryHit[] {
|
|
163
|
-
if (
|
|
168
|
+
if (this.progress.kind === "seeking") {
|
|
164
169
|
this.fail("History cursor entry no longer matches; restart the search without it.");
|
|
165
170
|
}
|
|
166
171
|
return this.groups.flat();
|
package/src/history.ts
CHANGED
|
@@ -127,9 +127,7 @@ export function registerHistory(
|
|
|
127
127
|
}),
|
|
128
128
|
),
|
|
129
129
|
}),
|
|
130
|
-
|
|
131
|
-
// oxlint-disable-next-line max-params -- QX-002: Pi supplies five fixed positional tool arguments.
|
|
132
|
-
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
130
|
+
async execute(...[, params, signal, , ctx]) {
|
|
133
131
|
const source = historySource(ctx);
|
|
134
132
|
if (params.op === "search") {
|
|
135
133
|
const fail = (message: string): never =>
|
package/src/note-guards.ts
CHANGED
|
@@ -102,7 +102,7 @@ async function guardOperation(
|
|
|
102
102
|
}
|
|
103
103
|
if (existingEdit(tool, operation) && !session.owns(address.path)) {
|
|
104
104
|
const copied = await withFileMutationQueue(path, () => session.adopt(address.path, call));
|
|
105
|
-
if (copied) {
|
|
105
|
+
if (copied === "adopted") {
|
|
106
106
|
return `Private copy created at ${path}; no requested edit ran. Read this copy, then retry the edit.`;
|
|
107
107
|
}
|
|
108
108
|
}
|
package/src/note-snapshots.ts
CHANGED
|
@@ -108,30 +108,45 @@ export async function mapBatch<Input, Output>(
|
|
|
108
108
|
}
|
|
109
109
|
return results;
|
|
110
110
|
}
|
|
111
|
-
|
|
111
|
+
type CopyResult =
|
|
112
|
+
| { readonly kind: "verified"; readonly seed: NoteSeed }
|
|
113
|
+
| { readonly kind: "unverified"; readonly error: Error };
|
|
114
|
+
async function copyNote(note: SnapshotNote, destination: string): Promise<CopyResult> {
|
|
112
115
|
const source = notePath(note.source, note.source.path);
|
|
113
116
|
const [before] = await Promise.all([
|
|
114
117
|
stat(source),
|
|
115
118
|
mkdir(dirname(destination), { recursive: true }),
|
|
116
119
|
]);
|
|
117
120
|
await copyFile(source, destination, constants.COPYFILE_EXCL);
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
+
try {
|
|
122
|
+
const after = await stat(source);
|
|
123
|
+
if (!sameIdentity(before, after)) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`Source note changed during snapshot: ${source}. Retry initialization.`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
await utimes(destination, before.atimeMs / 1000, before.mtimeMs / 1000);
|
|
129
|
+
const [digest, destInfo] = await Promise.all([fileDigest(destination), stat(destination)]);
|
|
130
|
+
return { kind: "verified", seed: { ...note, digest, identity: fileIdentity(destInfo) } };
|
|
131
|
+
} catch (error) {
|
|
132
|
+
return {
|
|
133
|
+
kind: "unverified",
|
|
134
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
135
|
+
};
|
|
121
136
|
}
|
|
122
|
-
await utimes(destination, before.atimeMs / 1000, before.mtimeMs / 1000);
|
|
123
|
-
const [digest, destInfo] = await Promise.all([fileDigest(destination), stat(destination)]);
|
|
124
|
-
return { ...note, digest, identity: fileIdentity(destInfo) };
|
|
125
137
|
}
|
|
126
|
-
|
|
127
|
-
|
|
138
|
+
type SnapshotPreparation =
|
|
139
|
+
| { readonly kind: "existing"; readonly binding: ForkBinding }
|
|
140
|
+
| { readonly kind: "empty" }
|
|
141
|
+
| { readonly kind: "staging"; readonly stage: string; readonly container: string };
|
|
142
|
+
async function prepareSnapshot(
|
|
128
143
|
binding: ForkBinding,
|
|
129
144
|
notes: readonly SnapshotNote[],
|
|
130
|
-
): Promise<
|
|
145
|
+
): Promise<SnapshotPreparation> {
|
|
131
146
|
const directory = scopeDirectory(binding);
|
|
132
147
|
const existing = await readManifest(directory);
|
|
133
148
|
if (existing !== undefined && sameOwner(existing.owner, binding.owner)) {
|
|
134
|
-
return existing;
|
|
149
|
+
return { kind: "existing", binding: existing };
|
|
135
150
|
}
|
|
136
151
|
const container = dirname(directory);
|
|
137
152
|
if (
|
|
@@ -145,22 +160,41 @@ export async function snapshot(
|
|
|
145
160
|
throw new Error(`Incomplete notes snapshot: preserving unexpected data at ${container}.`);
|
|
146
161
|
}
|
|
147
162
|
if (notes.length === 0) {
|
|
148
|
-
return
|
|
163
|
+
return { kind: "empty" };
|
|
149
164
|
}
|
|
150
165
|
await mkdir(dirname(container), { recursive: true });
|
|
151
166
|
const stage = await mkdtemp(join(dirname(container), ".staging-"));
|
|
167
|
+
return { kind: "staging", stage, container };
|
|
168
|
+
}
|
|
169
|
+
/** Publication is the ownership boundary. An existing unrelated directory is never overwritten. */
|
|
170
|
+
export async function snapshot(
|
|
171
|
+
binding: ForkBinding,
|
|
172
|
+
notes: readonly SnapshotNote[],
|
|
173
|
+
): Promise<ForkBinding> {
|
|
174
|
+
const preparation = await prepareSnapshot(binding, notes);
|
|
175
|
+
if (preparation.kind !== "staging") {
|
|
176
|
+
return preparation.kind === "existing" ? preparation.binding : binding;
|
|
177
|
+
}
|
|
178
|
+
const { stage, container } = preparation;
|
|
152
179
|
try {
|
|
153
180
|
// Keep each file's identity and digest paired with the bytes actually copied.
|
|
154
|
-
const seeds = await mapBatch(notes, (note) =>
|
|
155
|
-
|
|
156
|
-
|
|
181
|
+
const seeds = await mapBatch(notes, async (note) => {
|
|
182
|
+
const result = await copyNote(
|
|
183
|
+
note,
|
|
184
|
+
notePath({ root: stage, scope: "notes" }, note.path),
|
|
185
|
+
);
|
|
186
|
+
if (result.kind === "unverified") {
|
|
187
|
+
throw result.error;
|
|
188
|
+
}
|
|
189
|
+
return result.seed;
|
|
190
|
+
});
|
|
157
191
|
const complete: ForkBinding = { ...binding, seeds };
|
|
158
192
|
await writeFile(join(stage, "manifest.json"), JSON.stringify(complete), { flag: "wx" });
|
|
159
193
|
await rename(stage, container);
|
|
160
194
|
return complete;
|
|
161
195
|
} catch (error) {
|
|
162
196
|
throw new Error(
|
|
163
|
-
`Incomplete notes snapshot for ${
|
|
197
|
+
`Incomplete notes snapshot for ${scopeDirectory(binding)}: ${error instanceof Error ? error.message : String(error)}`,
|
|
164
198
|
{ cause: error },
|
|
165
199
|
);
|
|
166
200
|
} finally {
|
|
@@ -168,7 +202,7 @@ export async function snapshot(
|
|
|
168
202
|
}
|
|
169
203
|
}
|
|
170
204
|
/** Called inside the destination's native mutation queue, never queues that path recursively. */
|
|
171
|
-
export async function adoptNote(binding: NoteBinding, note: SnapshotNote): Promise<
|
|
205
|
+
export async function adoptNote(binding: NoteBinding, note: SnapshotNote): Promise<CopyResult> {
|
|
172
206
|
return copyNote(note, notePath(binding, note.path));
|
|
173
207
|
}
|
|
174
208
|
/** Empty initialized scopes publish their manifest when the first note is created. */
|
package/src/note-store.ts
CHANGED
|
@@ -35,6 +35,7 @@ import type { NotesRecovery } from "./note-evidence.ts";
|
|
|
35
35
|
|
|
36
36
|
type NotesManager = ExtensionContext["sessionManager"];
|
|
37
37
|
export type NoteCall = { readonly id: string; readonly rootId: string };
|
|
38
|
+
type Adoption = "owned" | "unavailable" | "adopted";
|
|
38
39
|
export type NoteRead = {
|
|
39
40
|
readonly address: NoteAddress;
|
|
40
41
|
readonly path: string;
|
|
@@ -172,16 +173,16 @@ export class SessionNotes {
|
|
|
172
173
|
};
|
|
173
174
|
return { address, path: notePath(address, relative), writable: reference === undefined };
|
|
174
175
|
}
|
|
175
|
-
async adopt(path: string, call: NoteCall): Promise<
|
|
176
|
+
async adopt(path: string, call: NoteCall): Promise<Adoption> {
|
|
176
177
|
const relative = noteRelative(path);
|
|
177
178
|
if (this.owns(relative)) {
|
|
178
|
-
return
|
|
179
|
+
return "owned";
|
|
179
180
|
}
|
|
180
181
|
const source = await this.#reference(relative);
|
|
181
182
|
if (source === undefined) {
|
|
182
|
-
return
|
|
183
|
+
return "unavailable";
|
|
183
184
|
}
|
|
184
|
-
const
|
|
185
|
+
const result = await adoptNote(this.binding, {
|
|
185
186
|
path: relative,
|
|
186
187
|
source,
|
|
187
188
|
lineage: source,
|
|
@@ -194,20 +195,26 @@ export class SessionNotes {
|
|
|
194
195
|
scope: this.binding.scope,
|
|
195
196
|
path: relative,
|
|
196
197
|
kind: "adopt",
|
|
197
|
-
seed,
|
|
198
|
+
...(result.kind === "verified" ? { seed: result.seed } : {}),
|
|
198
199
|
},
|
|
199
200
|
],
|
|
200
201
|
call,
|
|
201
202
|
);
|
|
203
|
+
if (result.kind === "unverified") {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`Private copy created at ${notePath(this.binding, relative)}; source not edited, but copy verification failed. Read the private copy before retrying.`,
|
|
206
|
+
{ cause: result.error },
|
|
207
|
+
);
|
|
208
|
+
}
|
|
202
209
|
try {
|
|
203
|
-
await saveAdoption(this.binding, seed);
|
|
210
|
+
await saveAdoption(this.binding, result.seed);
|
|
204
211
|
} catch (error) {
|
|
205
212
|
throw new Error(
|
|
206
213
|
`Private copy created at ${notePath(this.binding, relative)}; source not edited, but its adoption manifest could not be saved. Read the private copy before retrying.`,
|
|
207
214
|
{ cause: error },
|
|
208
215
|
);
|
|
209
216
|
}
|
|
210
|
-
return
|
|
217
|
+
return "adopted";
|
|
211
218
|
}
|
|
212
219
|
async prepareWrite(): Promise<void> {
|
|
213
220
|
await ensureManifest(this.binding);
|
|
@@ -215,7 +222,7 @@ export class SessionNotes {
|
|
|
215
222
|
async guardReference(path: string, call: NoteCall): Promise<string> {
|
|
216
223
|
const destination = notePath(this.binding, path);
|
|
217
224
|
const copied = await withFileMutationQueue(destination, () => this.adopt(path, call));
|
|
218
|
-
return copied
|
|
225
|
+
return copied === "adopted"
|
|
219
226
|
? `Private copy created at ${destination}; the source was not edited. Read the private copy, then retry the edit on that path.`
|
|
220
227
|
: `This session writes only ${this.directory}. Read ${destination}, then retry there; the foreign path was not edited.`;
|
|
221
228
|
}
|
|
@@ -232,15 +239,19 @@ export type NotesStore = {
|
|
|
232
239
|
readonly roots: () => readonly string[];
|
|
233
240
|
readonly recovery: (ctx: ExtensionContext) => NotesRecovery | undefined;
|
|
234
241
|
};
|
|
242
|
+
type SessionState =
|
|
243
|
+
| { readonly kind: "initializing"; readonly value: Promise<SessionNotes> }
|
|
244
|
+
| { readonly kind: "ready"; readonly value: SessionNotes };
|
|
235
245
|
export function createNotesStore(pi: ExtensionAPI): NotesStore {
|
|
236
|
-
const sessions = new Map<string,
|
|
237
|
-
const ready = new Map<string, SessionNotes>();
|
|
246
|
+
const sessions = new Map<string, SessionState>();
|
|
238
247
|
const roots = new Set<string>();
|
|
239
248
|
const get = (ctx: ExtensionContext): Promise<SessionNotes> => {
|
|
240
249
|
const key = sessionKey(ctx);
|
|
241
250
|
const existing = sessions.get(key);
|
|
242
251
|
if (existing !== undefined) {
|
|
243
|
-
return existing
|
|
252
|
+
return existing.kind === "initializing"
|
|
253
|
+
? existing.value
|
|
254
|
+
: Promise.resolve(existing.value);
|
|
244
255
|
}
|
|
245
256
|
const bindings = bindingsOf(ctx.sessionManager.getEntries());
|
|
246
257
|
const header = ctx.sessionManager.getHeader();
|
|
@@ -260,19 +271,22 @@ export function createNotesStore(pi: ExtensionAPI): NotesStore {
|
|
|
260
271
|
for (const scope of session.scopes) {
|
|
261
272
|
roots.add(scope.root);
|
|
262
273
|
}
|
|
263
|
-
|
|
274
|
+
sessions.set(key, { kind: "ready", value: session });
|
|
264
275
|
return session;
|
|
265
276
|
})
|
|
266
277
|
.catch((error: unknown) => {
|
|
267
278
|
sessions.delete(key);
|
|
268
279
|
throw error;
|
|
269
280
|
});
|
|
270
|
-
sessions.set(key, initialized);
|
|
281
|
+
sessions.set(key, { kind: "initializing", value: initialized });
|
|
271
282
|
return initialized;
|
|
272
283
|
};
|
|
273
284
|
return {
|
|
274
285
|
get,
|
|
275
286
|
roots: () => [...roots],
|
|
276
|
-
recovery: (ctx: ExtensionContext) =>
|
|
287
|
+
recovery: (ctx: ExtensionContext) => {
|
|
288
|
+
const state = sessions.get(sessionKey(ctx));
|
|
289
|
+
return state?.kind === "ready" ? state.value.recovery : undefined;
|
|
290
|
+
},
|
|
277
291
|
};
|
|
278
292
|
}
|
package/src/note-tool-effects.ts
CHANGED
|
@@ -23,6 +23,7 @@ function patchOperations(input: unknown): NoteOperation[] {
|
|
|
23
23
|
return [];
|
|
24
24
|
}
|
|
25
25
|
const operations: NoteOperation[] = [];
|
|
26
|
+
let update: { readonly path: string; readonly end: number } | undefined;
|
|
26
27
|
for (const match of input.matchAll(
|
|
27
28
|
/^\*\*\* (Update File|Add File|Delete File|Move to): ([^\r\n]+)$/gmu,
|
|
28
29
|
)) {
|
|
@@ -31,10 +32,15 @@ function patchOperations(input: unknown): NoteOperation[] {
|
|
|
31
32
|
continue;
|
|
32
33
|
}
|
|
33
34
|
if (match[1] === "Move to") {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
if (
|
|
36
|
+
update !== undefined &&
|
|
37
|
+
/^(?:\r\n|\r|\n)$/u.test(input.slice(update.end, match.index))
|
|
38
|
+
) {
|
|
39
|
+
operations[operations.length - 1] = { path: update.path, action: "delete" };
|
|
40
|
+
operations.push({ path, action: "create" });
|
|
37
41
|
}
|
|
42
|
+
update = undefined;
|
|
43
|
+
continue;
|
|
38
44
|
}
|
|
39
45
|
let action: NoteAction = "create";
|
|
40
46
|
if (match[1] === "Update File") {
|
|
@@ -43,6 +49,7 @@ function patchOperations(input: unknown): NoteOperation[] {
|
|
|
43
49
|
action = "delete";
|
|
44
50
|
}
|
|
45
51
|
operations.push({ path, action });
|
|
52
|
+
update = action === "edit" ? { path, end: match.index + match[0].length } : undefined;
|
|
46
53
|
}
|
|
47
54
|
return operations;
|
|
48
55
|
}
|
package/src/notes.ts
CHANGED
|
@@ -330,9 +330,7 @@ export function registerNotes(
|
|
|
330
330
|
}),
|
|
331
331
|
),
|
|
332
332
|
}),
|
|
333
|
-
|
|
334
|
-
// oxlint-disable-next-line max-params -- QX-002: Pi supplies five fixed positional tool arguments.
|
|
335
|
-
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
333
|
+
async execute(...[_id, params, signal, , ctx]) {
|
|
336
334
|
// Migrate newly returned legacy package notes without changing this session’s binding.
|
|
337
335
|
notesDir(ctx.cwd);
|
|
338
336
|
const session = await store.get(ctx);
|
package/src/paging.ts
CHANGED
|
@@ -36,34 +36,31 @@ export type PageAccess = {
|
|
|
36
36
|
readonly error: (ctx: PolicyContext, message: string, cursor?: string) => never;
|
|
37
37
|
readonly notes: (ctx: PolicyContext, page: NotesPage) => TextResult;
|
|
38
38
|
};
|
|
39
|
+
type PageAccounting = {
|
|
40
|
+
readonly pendingTokens: number;
|
|
41
|
+
readonly usage: number | null | undefined;
|
|
42
|
+
};
|
|
43
|
+
function reconcile(accounting: PageAccounting, usage: PageAccounting["usage"]): PageAccounting {
|
|
44
|
+
// Native usage may already include serial results, but not parallel siblings.
|
|
45
|
+
const counted =
|
|
46
|
+
typeof usage === "number" && typeof accounting.usage === "number"
|
|
47
|
+
? Math.max(0, usage - accounting.usage)
|
|
48
|
+
: 0;
|
|
49
|
+
return { pendingTokens: Math.max(0, accounting.pendingTokens - counted), usage };
|
|
50
|
+
}
|
|
39
51
|
/** Shared by note and history tools. Reservations precede delivery and reset at a turn boundary. */
|
|
40
52
|
export class PageReservations {
|
|
41
|
-
private
|
|
42
|
-
private previousUsage: number | null | undefined;
|
|
53
|
+
private accounting: PageAccounting = { pendingTokens: 0, usage: undefined };
|
|
43
54
|
private readonly toolTokens: () => number;
|
|
44
55
|
constructor(toolTokens: () => number) {
|
|
45
56
|
this.toolTokens = toolTokens;
|
|
46
57
|
}
|
|
47
58
|
reset(): void {
|
|
48
|
-
this.
|
|
49
|
-
this.previousUsage = undefined;
|
|
59
|
+
this.accounting = { pendingTokens: 0, usage: undefined };
|
|
50
60
|
}
|
|
51
61
|
size(ctx: PolicyContext, request: PageRequest): number {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if (
|
|
55
|
-
usage !== undefined &&
|
|
56
|
-
usage !== null &&
|
|
57
|
-
this.previousUsage !== undefined &&
|
|
58
|
-
this.previousUsage !== null
|
|
59
|
-
) {
|
|
60
|
-
this.pendingTokens = Math.max(
|
|
61
|
-
0,
|
|
62
|
-
this.pendingTokens - Math.max(0, usage - this.previousUsage),
|
|
63
|
-
);
|
|
64
|
-
}
|
|
65
|
-
this.previousUsage = usage;
|
|
66
|
-
const remaining = pageCapacity(ctx, this.toolTokens) - this.pendingTokens;
|
|
62
|
+
this.accounting = reconcile(this.accounting, ctx.getContextUsage()?.tokens);
|
|
63
|
+
const remaining = pageCapacity(ctx, this.toolTokens) - this.accounting.pendingTokens;
|
|
67
64
|
const chars = Math.min(
|
|
68
65
|
MAX_PAGE_CHARS,
|
|
69
66
|
Math.max(0, remaining - PAGE_MARGIN_TOKENS) * 4 -
|
|
@@ -74,6 +71,15 @@ export class PageReservations {
|
|
|
74
71
|
}
|
|
75
72
|
return chars;
|
|
76
73
|
}
|
|
74
|
+
private reserve(text: string, imageCount = 0): void {
|
|
75
|
+
this.accounting = {
|
|
76
|
+
...this.accounting,
|
|
77
|
+
pendingTokens:
|
|
78
|
+
this.accounting.pendingTokens +
|
|
79
|
+
Math.ceil(text.length / 4) +
|
|
80
|
+
imageCount * (ESTIMATED_IMAGE_CHARS / 4),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
77
83
|
private refuse(remaining: number, request: PageRequest): never {
|
|
78
84
|
const position =
|
|
79
85
|
request.cursor !== undefined && request.cursor !== ""
|
|
@@ -82,17 +88,16 @@ export class PageReservations {
|
|
|
82
88
|
const message = `Too little context remains to read a page safely. Call new_context first, then retry ${position}.`;
|
|
83
89
|
// Even refusals consume context; omit repeated guidance when it no longer fits.
|
|
84
90
|
const text = Math.ceil(message.length / 4) <= remaining ? message : "";
|
|
85
|
-
this.
|
|
91
|
+
this.reserve(text);
|
|
86
92
|
throw new Error(text);
|
|
87
93
|
}
|
|
88
94
|
result(text: string, images: readonly ImageLike[], display: PosthorseDisplay): TextResult {
|
|
89
|
-
this.
|
|
90
|
-
Math.ceil(text.length / 4) + images.length * (ESTIMATED_IMAGE_CHARS / 4);
|
|
95
|
+
this.reserve(text, images.length);
|
|
91
96
|
return textResult(text, images, display);
|
|
92
97
|
}
|
|
93
98
|
error(ctx: PolicyContext, message: string, cursor?: string): never {
|
|
94
99
|
this.size(ctx, { offset: 0, cursor });
|
|
95
|
-
this.
|
|
100
|
+
this.reserve(message);
|
|
96
101
|
throw new Error(message);
|
|
97
102
|
}
|
|
98
103
|
notes(ctx: PolicyContext, page: NotesPage): TextResult {
|
package/src/records.ts
ADDED