pi-posthorse 0.8.0 → 0.8.2
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 +20 -6
- package/index.ts +42 -1555
- package/package.json +33 -7
- package/src/budget.ts +168 -0
- package/src/context-tools.ts +113 -0
- package/src/contracts.ts +124 -0
- package/src/files.ts +182 -0
- package/src/history-entries.ts +251 -0
- package/src/history-pages.ts +173 -0
- package/src/history-search.ts +308 -0
- package/src/history.ts +146 -0
- package/src/message-text.ts +139 -0
- package/src/notes.ts +314 -0
- package/src/paging.ts +156 -0
- package/src/recovery-results.ts +111 -0
- package/src/recovery.ts +377 -0
- package/src/reminders.ts +116 -0
- package/src/rollover.ts +330 -0
- package/src/session-files.ts +264 -0
- package/src/ui/cards.ts +355 -0
- package/src/ui/display.ts +312 -0
- package/src/ui/text.ts +53 -0
- package/ui.ts +44 -163
package/src/notes.ts
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import {
|
|
2
|
+
appendFileSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
realpathSync,
|
|
8
|
+
statSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import { dirname, isAbsolute, join, normalize, sep } from "node:path";
|
|
11
|
+
import {
|
|
12
|
+
formatSize,
|
|
13
|
+
withFileMutationQueue,
|
|
14
|
+
type ExtensionAPI,
|
|
15
|
+
type ExtensionContext,
|
|
16
|
+
} from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { Type } from "typebox";
|
|
18
|
+
import { toolCards } from "../ui.ts";
|
|
19
|
+
import {
|
|
20
|
+
MAX_PAGE_CHARS,
|
|
21
|
+
requireValue,
|
|
22
|
+
textResult,
|
|
23
|
+
type PolicyContext,
|
|
24
|
+
type TextResult,
|
|
25
|
+
} from "./contracts.ts";
|
|
26
|
+
import { isBinaryFile, notesRoot, publishFile, readText } from "./files.ts";
|
|
27
|
+
import { excerpt, excerptAround } from "./message-text.ts";
|
|
28
|
+
import type { PageAccess } from "./paging.ts";
|
|
29
|
+
|
|
30
|
+
export type NotesParams = {
|
|
31
|
+
readonly op: "list" | "read" | "write" | "append" | "search";
|
|
32
|
+
readonly path?: string;
|
|
33
|
+
readonly content?: string;
|
|
34
|
+
readonly query?: string;
|
|
35
|
+
readonly offset?: number;
|
|
36
|
+
};
|
|
37
|
+
type NoteFile = { readonly path: string; readonly size: number; readonly mtimeMs: number };
|
|
38
|
+
type NoteHits = { readonly rows: string[]; readonly more: boolean; readonly skipped: number };
|
|
39
|
+
/** Larger files are logs or evidence rather than notes; reading them would dominate a search. */
|
|
40
|
+
const SEARCH_FILE_BYTES = 1024 * 1024;
|
|
41
|
+
/** Overlapping reads cut a full search of a 212,000-file notes tree from 39 s to 14 s. */
|
|
42
|
+
const SEARCH_BATCH = 16;
|
|
43
|
+
/** Search rescans up to the requested offset, so this also caps the matches one call keeps. */
|
|
44
|
+
const SEARCH_OFFSET_LIMIT = 1_000_000;
|
|
45
|
+
function safeJoin(dir: string, path: string): string {
|
|
46
|
+
const relative = normalize(path.replace(/^[/\\]+/, ""));
|
|
47
|
+
if (relative === ".." || relative.startsWith(`..${sep}`) || isAbsolute(relative)) {
|
|
48
|
+
throw new Error(`Invalid path "${path}": must stay inside .pi/notes/.`);
|
|
49
|
+
}
|
|
50
|
+
return join(dir, relative);
|
|
51
|
+
}
|
|
52
|
+
function walk(directory: string, ancestors: readonly string[] = []): NoteFile[] {
|
|
53
|
+
const root = realpathSync(directory);
|
|
54
|
+
if (ancestors.includes(root)) {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
const next = [...ancestors, root];
|
|
58
|
+
return readdirSync(directory).flatMap((file) => {
|
|
59
|
+
const path = join(directory, file);
|
|
60
|
+
const info = statSync(path, { throwIfNoEntry: false });
|
|
61
|
+
if (info === undefined) {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
return info.isDirectory()
|
|
65
|
+
? walk(path, next)
|
|
66
|
+
: [{ path, size: info.size, mtimeMs: info.mtimeMs }];
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
function notesUnder(target: string): NoteFile[] {
|
|
70
|
+
const info = statSync(target, { throwIfNoEntry: false });
|
|
71
|
+
if (info === undefined) {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
const files = info.isDirectory()
|
|
75
|
+
? walk(target)
|
|
76
|
+
: [{ path: target, size: info.size, mtimeMs: info.mtimeMs }];
|
|
77
|
+
return files.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
78
|
+
}
|
|
79
|
+
function listNotes(
|
|
80
|
+
dir: string,
|
|
81
|
+
params: NotesParams,
|
|
82
|
+
host: PolicyContext,
|
|
83
|
+
pages: PageAccess,
|
|
84
|
+
): TextResult {
|
|
85
|
+
const folder = params.path;
|
|
86
|
+
const target = folder === undefined || folder === "" ? dir : safeJoin(dir, folder);
|
|
87
|
+
const rows = notesUnder(target).map(
|
|
88
|
+
(file) =>
|
|
89
|
+
`${file.path.slice(dir.length + 1)} ${formatSize(file.size)} ${new Date(file.mtimeMs).toISOString().slice(0, 16)}Z`,
|
|
90
|
+
);
|
|
91
|
+
return pages.notes(host, {
|
|
92
|
+
rows,
|
|
93
|
+
offset: params.offset ?? 0,
|
|
94
|
+
kind: "notes-list",
|
|
95
|
+
empty: folder === undefined || folder === "" ? "(no notes yet)" : `(no notes in ${folder})`,
|
|
96
|
+
header: `Notes directory: ${dir}\n`,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
async function readNote(
|
|
100
|
+
dir: string,
|
|
101
|
+
params: NotesParams,
|
|
102
|
+
getHost: () => PolicyContext,
|
|
103
|
+
pages: PageAccess,
|
|
104
|
+
): Promise<TextResult> {
|
|
105
|
+
const relative = requireValue(params.path, "path", params.op);
|
|
106
|
+
const path = safeJoin(dir, relative);
|
|
107
|
+
const info = statSync(path, { throwIfNoEntry: false });
|
|
108
|
+
if (info === undefined) {
|
|
109
|
+
throw new Error(`No note at ${relative}. Use op "list" to see available notes.`);
|
|
110
|
+
}
|
|
111
|
+
if (info.isDirectory()) {
|
|
112
|
+
return listNotes(dir, params, getHost(), pages);
|
|
113
|
+
}
|
|
114
|
+
if (await isBinaryFile(path)) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`${relative} is a binary file (${formatSize(info.size)}); notes read returns text only.`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
const text = readFileSync(path, "utf8");
|
|
120
|
+
const offset = params.offset ?? 0;
|
|
121
|
+
if (offset > 0 && offset >= text.length) {
|
|
122
|
+
throw new Error(`Offset ${offset} is past the end of ${relative} (${text.length} chars).`);
|
|
123
|
+
}
|
|
124
|
+
const header = `File: ${path}\n`;
|
|
125
|
+
const host = getHost();
|
|
126
|
+
const chars = pages.size(host, { offset }) - header.length;
|
|
127
|
+
if (chars <= 0) {
|
|
128
|
+
pages.error(
|
|
129
|
+
host,
|
|
130
|
+
`Too little context remains to include the note path. Call new_context first, then retry with offset ${offset}.`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
const end = Math.min(text.length, offset + chars);
|
|
134
|
+
const more =
|
|
135
|
+
end < text.length
|
|
136
|
+
? `\n[chars ${offset}-${end} of ${text.length}; continue with offset ${end}]`
|
|
137
|
+
: "";
|
|
138
|
+
return pages.result(`${header}${text.slice(offset, end)}${more}`, [], {
|
|
139
|
+
kind: "note-read",
|
|
140
|
+
headerLength: header.length,
|
|
141
|
+
offset,
|
|
142
|
+
end,
|
|
143
|
+
total: text.length,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
async function writeNote(
|
|
147
|
+
dir: string,
|
|
148
|
+
params: NotesParams,
|
|
149
|
+
signal?: AbortSignal,
|
|
150
|
+
): Promise<TextResult> {
|
|
151
|
+
const relative = requireValue(params.path, "path", params.op);
|
|
152
|
+
const content = params.content;
|
|
153
|
+
if (content === undefined) {
|
|
154
|
+
throw new Error('"content" is required for op "write" (use "" to clear a note).');
|
|
155
|
+
}
|
|
156
|
+
const path = safeJoin(dir, relative);
|
|
157
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
158
|
+
await withFileMutationQueue(path, () => publishFile(path, content, signal));
|
|
159
|
+
return textResult(`Wrote ${path}`, [], { kind: "note-write" });
|
|
160
|
+
}
|
|
161
|
+
async function appendNote(
|
|
162
|
+
dir: string,
|
|
163
|
+
params: NotesParams,
|
|
164
|
+
signal?: AbortSignal,
|
|
165
|
+
): Promise<TextResult> {
|
|
166
|
+
const relative = requireValue(params.path, "path", params.op);
|
|
167
|
+
const content = requireValue(params.content, "content", params.op);
|
|
168
|
+
const path = safeJoin(dir, relative);
|
|
169
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
170
|
+
await withFileMutationQueue(path, async () => {
|
|
171
|
+
signal?.throwIfAborted();
|
|
172
|
+
const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
173
|
+
const separator = existing !== "" && !existing.endsWith("\n") ? "\n" : "";
|
|
174
|
+
// One O_APPEND write keeps records separate across processes. A racing
|
|
175
|
+
// unterminated write can at worst introduce an extra blank separator.
|
|
176
|
+
appendFileSync(path, `${separator}${content.replace(/\n?$/, "\n")}`);
|
|
177
|
+
});
|
|
178
|
+
return textResult(`Appended to ${path}`, [], { kind: "note-append" });
|
|
179
|
+
}
|
|
180
|
+
/** One note's matching lines as `path:line: excerpt` rows. */
|
|
181
|
+
function matchingLines(name: string, text: string, query: string): string[] {
|
|
182
|
+
if (!text.toLowerCase().includes(query)) {
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
return text.split("\n").flatMap((line, index) => {
|
|
186
|
+
const trimmed = line.trim();
|
|
187
|
+
const match = trimmed.toLowerCase().indexOf(query);
|
|
188
|
+
return match === -1
|
|
189
|
+
? []
|
|
190
|
+
: [`${name}:${index + 1}: ${excerptAround(trimmed, match, 50, 200)}`];
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
/** Matches in newest-first note order, stopping once they pass `limit` characters. */
|
|
194
|
+
async function noteHits(
|
|
195
|
+
dir: string,
|
|
196
|
+
query: string,
|
|
197
|
+
limit: number,
|
|
198
|
+
signal?: AbortSignal,
|
|
199
|
+
): Promise<NoteHits> {
|
|
200
|
+
const notes = notesUnder(dir);
|
|
201
|
+
const rows: string[] = [];
|
|
202
|
+
let length = -1;
|
|
203
|
+
let skipped = 0;
|
|
204
|
+
for (let start = 0; start < notes.length; start += SEARCH_BATCH) {
|
|
205
|
+
// Batches keep note order, so a search stops within one batch of the page limit.
|
|
206
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
207
|
+
const batch = await Promise.all(
|
|
208
|
+
notes
|
|
209
|
+
.slice(start, start + SEARCH_BATCH)
|
|
210
|
+
.map(async ({ path, size }) =>
|
|
211
|
+
size > SEARCH_FILE_BYTES
|
|
212
|
+
? undefined
|
|
213
|
+
: matchingLines(
|
|
214
|
+
path.slice(dir.length + 1),
|
|
215
|
+
(await readText(path, signal)) ?? "",
|
|
216
|
+
query,
|
|
217
|
+
),
|
|
218
|
+
),
|
|
219
|
+
);
|
|
220
|
+
skipped += batch.filter((found) => found === undefined).length;
|
|
221
|
+
for (const row of batch.flatMap((found) => found ?? [])) {
|
|
222
|
+
rows.push(row);
|
|
223
|
+
length += row.length + 1;
|
|
224
|
+
if (length > limit) {
|
|
225
|
+
return { rows, more: true, skipped };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return { rows, more: false, skipped };
|
|
230
|
+
}
|
|
231
|
+
async function searchNotes(
|
|
232
|
+
dir: string,
|
|
233
|
+
params: NotesParams,
|
|
234
|
+
access: { readonly host: PolicyContext; readonly pages: PageAccess },
|
|
235
|
+
signal?: AbortSignal,
|
|
236
|
+
): Promise<TextResult> {
|
|
237
|
+
const { host, pages } = access;
|
|
238
|
+
const query = requireValue(params.query, "query", params.op);
|
|
239
|
+
const offset = params.offset ?? 0;
|
|
240
|
+
if (offset >= SEARCH_OFFSET_LIMIT) {
|
|
241
|
+
pages.error(
|
|
242
|
+
host,
|
|
243
|
+
`Notes search pages end before offset ${SEARCH_OFFSET_LIMIT}; search for something more specific.`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
const hits = await noteHits(dir, query.toLowerCase(), offset + MAX_PAGE_CHARS, signal);
|
|
247
|
+
const skipped = hits.skipped === 1 ? "1 note" : `${hits.skipped} notes`;
|
|
248
|
+
return pages.notes(host, {
|
|
249
|
+
rows: hits.rows,
|
|
250
|
+
more: hits.more,
|
|
251
|
+
offset,
|
|
252
|
+
kind: "notes-search",
|
|
253
|
+
empty: `No notes match "${excerpt(query, 200)}".`,
|
|
254
|
+
header: hits.skipped > 0 ? `[Not searched: ${skipped} over 1 MiB.]\n` : "",
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
export function registerNotes(
|
|
258
|
+
pi: ExtensionAPI,
|
|
259
|
+
policy: (ctx: ExtensionContext) => PolicyContext,
|
|
260
|
+
pages: PageAccess,
|
|
261
|
+
): void {
|
|
262
|
+
pi.registerTool({
|
|
263
|
+
name: "notes",
|
|
264
|
+
label: "Notes",
|
|
265
|
+
...toolCards("notes"),
|
|
266
|
+
description:
|
|
267
|
+
"Durable notes shared across Git worktrees and context resets. Ops: list (newest first, size/date; optional folder), read, search (case-insensitive text), write (replace; empty clears), append (one newline-terminated record). list/read/search are paged; continue with the returned offset. list shows the absolute notes directory; read shows the absolute file path. Prefer available file-editing tools for changed sections of one concise current-state note; write for creation or substantial restructuring. Link fuller evidence instead of copying them.",
|
|
268
|
+
promptSnippet: "save and recall durable state that survives context resets",
|
|
269
|
+
parameters: Type.Object({
|
|
270
|
+
op: Type.Union(
|
|
271
|
+
[
|
|
272
|
+
Type.Literal("list"),
|
|
273
|
+
Type.Literal("read"),
|
|
274
|
+
Type.Literal("write"),
|
|
275
|
+
Type.Literal("append"),
|
|
276
|
+
Type.Literal("search"),
|
|
277
|
+
],
|
|
278
|
+
{ description: "Operation to perform" },
|
|
279
|
+
),
|
|
280
|
+
path: Type.Optional(
|
|
281
|
+
Type.String({ description: "Note or folder path relative to .pi/notes/" }),
|
|
282
|
+
),
|
|
283
|
+
content: Type.Optional(
|
|
284
|
+
Type.String({ description: "Full file content (write) or text to add (append)" }),
|
|
285
|
+
),
|
|
286
|
+
query: Type.Optional(
|
|
287
|
+
Type.String({ description: "Substring to find in notes (search)" }),
|
|
288
|
+
),
|
|
289
|
+
offset: Type.Optional(
|
|
290
|
+
Type.Integer({
|
|
291
|
+
description: "Character offset for list/read/search (default 0)",
|
|
292
|
+
minimum: 0,
|
|
293
|
+
}),
|
|
294
|
+
),
|
|
295
|
+
}),
|
|
296
|
+
// Pi's fixed tool contract supplies id, params, signal, updates, and context.
|
|
297
|
+
// oxlint-disable-next-line max-params
|
|
298
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
299
|
+
const dir = join(notesRoot(ctx.cwd), ".pi", "notes");
|
|
300
|
+
switch (params.op) {
|
|
301
|
+
case "list":
|
|
302
|
+
return listNotes(dir, params, policy(ctx), pages);
|
|
303
|
+
case "read":
|
|
304
|
+
return readNote(dir, params, () => policy(ctx), pages);
|
|
305
|
+
case "write":
|
|
306
|
+
return writeNote(dir, params, signal);
|
|
307
|
+
case "append":
|
|
308
|
+
return appendNote(dir, params, signal);
|
|
309
|
+
case "search":
|
|
310
|
+
return searchNotes(dir, params, { host: policy(ctx), pages }, signal);
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
}
|
package/src/paging.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { PosthorseDisplay } from "../ui.ts";
|
|
2
|
+
import { pageCapacity } from "./budget.ts";
|
|
3
|
+
import {
|
|
4
|
+
ESTIMATED_IMAGE_CHARS,
|
|
5
|
+
MAX_PAGE_CHARS,
|
|
6
|
+
MIN_PAGE_CHARS,
|
|
7
|
+
PAGE_MARGIN_TOKENS,
|
|
8
|
+
textResult,
|
|
9
|
+
type ImageLike,
|
|
10
|
+
type PolicyContext,
|
|
11
|
+
type TextResult,
|
|
12
|
+
} from "./contracts.ts";
|
|
13
|
+
|
|
14
|
+
export type PageRequest = {
|
|
15
|
+
readonly offset: number;
|
|
16
|
+
readonly imageCount?: number;
|
|
17
|
+
readonly cursor?: string;
|
|
18
|
+
readonly imageOffset?: number;
|
|
19
|
+
};
|
|
20
|
+
export type NotesPage = {
|
|
21
|
+
readonly rows: readonly string[];
|
|
22
|
+
readonly offset: number;
|
|
23
|
+
readonly kind: "notes-list" | "notes-search";
|
|
24
|
+
readonly empty: string;
|
|
25
|
+
readonly header?: string;
|
|
26
|
+
/** Rows stop before the last match, so their joined length is only a lower bound. */
|
|
27
|
+
readonly more?: boolean;
|
|
28
|
+
};
|
|
29
|
+
export type PageAccess = {
|
|
30
|
+
readonly size: (ctx: PolicyContext, request: PageRequest) => number;
|
|
31
|
+
readonly result: (
|
|
32
|
+
text: string,
|
|
33
|
+
images: readonly ImageLike[],
|
|
34
|
+
display: PosthorseDisplay,
|
|
35
|
+
) => TextResult;
|
|
36
|
+
readonly error: (ctx: PolicyContext, message: string, cursor?: string) => never;
|
|
37
|
+
readonly notes: (ctx: PolicyContext, page: NotesPage) => TextResult;
|
|
38
|
+
};
|
|
39
|
+
/** Shared by note and history tools. Reservations precede delivery and reset at a turn boundary. */
|
|
40
|
+
export class PageReservations {
|
|
41
|
+
private pendingTokens = 0;
|
|
42
|
+
private previousUsage: number | null | undefined;
|
|
43
|
+
private readonly toolTokens: () => number;
|
|
44
|
+
constructor(toolTokens: () => number) {
|
|
45
|
+
this.toolTokens = toolTokens;
|
|
46
|
+
}
|
|
47
|
+
reset(): void {
|
|
48
|
+
this.pendingTokens = 0;
|
|
49
|
+
this.previousUsage = undefined;
|
|
50
|
+
}
|
|
51
|
+
size(ctx: PolicyContext, request: PageRequest): number {
|
|
52
|
+
const usage = ctx.getContextUsage()?.tokens;
|
|
53
|
+
// Native usage may already include serial results, but not parallel siblings.
|
|
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;
|
|
67
|
+
const chars = Math.min(
|
|
68
|
+
MAX_PAGE_CHARS,
|
|
69
|
+
Math.max(0, remaining - PAGE_MARGIN_TOKENS) * 4 -
|
|
70
|
+
(request.imageCount ?? 0) * ESTIMATED_IMAGE_CHARS,
|
|
71
|
+
);
|
|
72
|
+
if (chars < MIN_PAGE_CHARS) {
|
|
73
|
+
this.refuse(remaining, request);
|
|
74
|
+
}
|
|
75
|
+
return chars;
|
|
76
|
+
}
|
|
77
|
+
private refuse(remaining: number, request: PageRequest): never {
|
|
78
|
+
const position =
|
|
79
|
+
request.cursor !== undefined && request.cursor !== ""
|
|
80
|
+
? "with the same cursor"
|
|
81
|
+
: `with offset ${request.offset}${request.imageOffset === undefined ? "" : ` and imageOffset ${request.imageOffset}`}`;
|
|
82
|
+
const message = `Too little context remains to read a page safely. Call new_context first, then retry ${position}.`;
|
|
83
|
+
// Even refusals consume context; omit repeated guidance when it no longer fits.
|
|
84
|
+
const text = Math.ceil(message.length / 4) <= remaining ? message : "";
|
|
85
|
+
this.pendingTokens += Math.ceil(text.length / 4);
|
|
86
|
+
throw new Error(text);
|
|
87
|
+
}
|
|
88
|
+
result(text: string, images: readonly ImageLike[], display: PosthorseDisplay): TextResult {
|
|
89
|
+
this.pendingTokens +=
|
|
90
|
+
Math.ceil(text.length / 4) + images.length * (ESTIMATED_IMAGE_CHARS / 4);
|
|
91
|
+
return textResult(text, images, display);
|
|
92
|
+
}
|
|
93
|
+
error(ctx: PolicyContext, message: string, cursor?: string): never {
|
|
94
|
+
this.size(ctx, { offset: 0, cursor });
|
|
95
|
+
this.pendingTokens += Math.ceil(message.length / 4);
|
|
96
|
+
throw new Error(message);
|
|
97
|
+
}
|
|
98
|
+
notes(ctx: PolicyContext, page: NotesPage): TextResult {
|
|
99
|
+
const { rows, offset, kind, empty, header = "", more } = page;
|
|
100
|
+
const text = rows.length > 0 ? rows.join("\n") : empty;
|
|
101
|
+
const chars = this.size(ctx, { offset }) - header.length;
|
|
102
|
+
const footer = (end: number) =>
|
|
103
|
+
`\n[chars ${offset}-${end}${more === true ? "; more matches follow" : ` of ${text.length}`}; continue with offset ${end}]`;
|
|
104
|
+
if (chars <= footer(text.length).length) {
|
|
105
|
+
this.error(
|
|
106
|
+
ctx,
|
|
107
|
+
`Too little context remains to include the notes path. Call new_context first, then retry with offset ${offset}.`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
if (offset > 0 && offset >= text.length) {
|
|
111
|
+
this.error(ctx, "Offset is past the end; restart with offset 0.");
|
|
112
|
+
}
|
|
113
|
+
const range = notesRange(rows, {
|
|
114
|
+
offset,
|
|
115
|
+
end: Math.min(text.length, offset + chars),
|
|
116
|
+
maxRows: kind === "notes-search" ? 20 : 100,
|
|
117
|
+
});
|
|
118
|
+
const end =
|
|
119
|
+
range.end < text.length
|
|
120
|
+
? Math.min(range.end, offset + chars - footer(text.length).length)
|
|
121
|
+
: range.end;
|
|
122
|
+
return this.result(
|
|
123
|
+
header + text.slice(offset, end) + (end < text.length ? footer(end) : ""),
|
|
124
|
+
[],
|
|
125
|
+
{
|
|
126
|
+
kind,
|
|
127
|
+
headerLength: header.length,
|
|
128
|
+
count: range.starts.filter((start) => start < end).length,
|
|
129
|
+
page: { offset, end, total: text.length },
|
|
130
|
+
...(more === true ? { more } : {}),
|
|
131
|
+
},
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function notesRange(
|
|
136
|
+
rows: readonly string[],
|
|
137
|
+
range: { readonly offset: number; readonly end: number; readonly maxRows: number },
|
|
138
|
+
): { end: number; starts: number[] } {
|
|
139
|
+
let end = range.end;
|
|
140
|
+
let start = 0;
|
|
141
|
+
const starts: number[] = [];
|
|
142
|
+
for (const row of rows) {
|
|
143
|
+
if (start >= end) {
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
if (start + row.length > range.offset) {
|
|
147
|
+
starts.push(start);
|
|
148
|
+
}
|
|
149
|
+
start += row.length + 1;
|
|
150
|
+
if (starts.length === range.maxRows) {
|
|
151
|
+
end = Math.min(end, start - 1);
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return { end, starts };
|
|
156
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { EntryLike, ToolCallLike } from "./contracts.ts";
|
|
2
|
+
import {
|
|
3
|
+
executionArgumentsText,
|
|
4
|
+
imageSummary,
|
|
5
|
+
imagesOf,
|
|
6
|
+
safeJsonStringify,
|
|
7
|
+
textOf,
|
|
8
|
+
toolCalls,
|
|
9
|
+
toolIdentity,
|
|
10
|
+
} from "./message-text.ts";
|
|
11
|
+
|
|
12
|
+
type LinkedCall = { readonly entry: EntryLike; readonly block: ToolCallLike };
|
|
13
|
+
export type RecoveryBlock = { readonly header: string; readonly text: string };
|
|
14
|
+
export type RecoveryBatch = { readonly callId?: string; readonly blocks: readonly RecoveryBlock[] };
|
|
15
|
+
function callsById(entries: readonly EntryLike[]): Map<string, LinkedCall> {
|
|
16
|
+
const calls = new Map<string, LinkedCall>();
|
|
17
|
+
for (const entry of entries) {
|
|
18
|
+
if (entry.message?.role !== "assistant") {
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
for (const block of toolCalls(entry.message.content)) {
|
|
22
|
+
if (block.id !== undefined) {
|
|
23
|
+
calls.set(block.id, { entry, block });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return calls;
|
|
28
|
+
}
|
|
29
|
+
function trailingStart(entries: readonly EntryLike[]): number {
|
|
30
|
+
let hasTrailingResult = false;
|
|
31
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
32
|
+
const message = entries[i].message;
|
|
33
|
+
if (message?.role === "toolResult") {
|
|
34
|
+
hasTrailingResult = true;
|
|
35
|
+
} else if (message?.role === "assistant") {
|
|
36
|
+
const invalid =
|
|
37
|
+
message.stopReason === "error" ||
|
|
38
|
+
message.stopReason === "aborted" ||
|
|
39
|
+
message.stopReason === "length";
|
|
40
|
+
if (!invalid || hasTrailingResult) {
|
|
41
|
+
return i;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return -1;
|
|
46
|
+
}
|
|
47
|
+
function resultOutput(result: EntryLike): string {
|
|
48
|
+
const message = result.message ?? {};
|
|
49
|
+
const resultId = (result.id ?? "unknown").slice(0, 120);
|
|
50
|
+
const images = imageSummary(imagesOf(message.content));
|
|
51
|
+
const output = [
|
|
52
|
+
textOf(message).trim(),
|
|
53
|
+
images === "" ? "" : `${images} — recover with history read id ${resultId}`,
|
|
54
|
+
]
|
|
55
|
+
.filter((part) => part !== "")
|
|
56
|
+
.join("\n");
|
|
57
|
+
return output === "" ? "(empty result)" : output;
|
|
58
|
+
}
|
|
59
|
+
function callDetails(matching: LinkedCall | undefined, allLinked: boolean): string {
|
|
60
|
+
if (matching === undefined) {
|
|
61
|
+
return "\nNo matching projected call";
|
|
62
|
+
}
|
|
63
|
+
const entry = allLinked
|
|
64
|
+
? ""
|
|
65
|
+
: `\nCall entry: ${(matching.entry.id ?? "unknown").slice(0, 120)}`;
|
|
66
|
+
return `${entry}\nCall arguments: ${safeJsonStringify(matching.block.arguments ?? {})}${executionArgumentsText(matching.block)}`;
|
|
67
|
+
}
|
|
68
|
+
function resultBlock(
|
|
69
|
+
result: EntryLike,
|
|
70
|
+
matching: LinkedCall | undefined,
|
|
71
|
+
allLinked: boolean,
|
|
72
|
+
): RecoveryBlock {
|
|
73
|
+
const message = result.message ?? {};
|
|
74
|
+
const name = toolIdentity(
|
|
75
|
+
message.toolName ?? matching?.block.name,
|
|
76
|
+
message.namespace ?? matching?.block.namespace,
|
|
77
|
+
);
|
|
78
|
+
const resultId = (result.id ?? "unknown").slice(0, 120);
|
|
79
|
+
return {
|
|
80
|
+
header: `[${message.isError === true ? "error" : "result"} entry ${resultId}]`,
|
|
81
|
+
text: `${resultOutput(result)}\n\nTool: ${name}\nCall ID: ${message.toolCallId ?? "unknown"}${callDetails(matching, allLinked)}`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/** Async receipts may arrive after a response; completion does not establish receipt delivery. */
|
|
85
|
+
export function recoverableToolResults(entries: readonly EntryLike[]): RecoveryBatch | undefined {
|
|
86
|
+
const calls = callsById(entries);
|
|
87
|
+
const start = trailingStart(entries);
|
|
88
|
+
const results = entries.filter((entry, index) => {
|
|
89
|
+
const message = entry.message;
|
|
90
|
+
if (message?.role !== "toolResult" || message.toolCallId === undefined) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
const call = calls.get(message.toolCallId);
|
|
94
|
+
// Only projected provenance is eligible. Orphans never resurrect a raw call.
|
|
95
|
+
return index > start || call === undefined || call.block.async === true;
|
|
96
|
+
});
|
|
97
|
+
const first = results.at(0);
|
|
98
|
+
if (first === undefined) {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
const call = calls.get(first.message?.toolCallId ?? "")?.entry;
|
|
102
|
+
const allLinked =
|
|
103
|
+
call !== undefined &&
|
|
104
|
+
results.every((result) => calls.get(result.message?.toolCallId ?? "")?.entry === call);
|
|
105
|
+
return {
|
|
106
|
+
callId: allLinked ? (call.id ?? "unknown").slice(0, 120) : undefined,
|
|
107
|
+
blocks: results.map((result) =>
|
|
108
|
+
resultBlock(result, calls.get(result.message?.toolCallId ?? ""), allLinked),
|
|
109
|
+
),
|
|
110
|
+
};
|
|
111
|
+
}
|