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
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { relative, resolve } from "node:path";
|
|
2
|
+
import {
|
|
3
|
+
isReminderType,
|
|
4
|
+
requireValue,
|
|
5
|
+
unknownArray,
|
|
6
|
+
type EntryLike,
|
|
7
|
+
type HistoryHit,
|
|
8
|
+
type ProjectedEntry,
|
|
9
|
+
} from "./contracts.ts";
|
|
10
|
+
import { historyHit, seenText, reverseWindowEntries } from "./history-entries.ts";
|
|
11
|
+
import { shortKey } from "./message-text.ts";
|
|
12
|
+
import {
|
|
13
|
+
filesContaining,
|
|
14
|
+
prefilterNeedles,
|
|
15
|
+
scopedSessionFiles,
|
|
16
|
+
sessionWindowEntries,
|
|
17
|
+
} from "./session-files.ts";
|
|
18
|
+
|
|
19
|
+
export type HistoryParams = {
|
|
20
|
+
readonly op: "search" | "read";
|
|
21
|
+
readonly query?: string;
|
|
22
|
+
readonly id?: string;
|
|
23
|
+
readonly all?: boolean;
|
|
24
|
+
readonly limit?: number;
|
|
25
|
+
readonly cursor?: string;
|
|
26
|
+
readonly offset?: number;
|
|
27
|
+
readonly imageOffset?: number;
|
|
28
|
+
};
|
|
29
|
+
export type HistorySource = {
|
|
30
|
+
readonly dir: string;
|
|
31
|
+
readonly cwd: string;
|
|
32
|
+
readonly currentFile?: string;
|
|
33
|
+
readonly branch: () => readonly EntryLike[];
|
|
34
|
+
readonly projection: () => readonly ProjectedEntry[];
|
|
35
|
+
};
|
|
36
|
+
export type HistoryCursor = readonly [id: string, priority: 0 | 1, offset: number, key: string];
|
|
37
|
+
export type HistoryMatches = {
|
|
38
|
+
readonly hits: readonly HistoryHit[];
|
|
39
|
+
readonly skipped: number;
|
|
40
|
+
readonly cursor?: HistoryCursor;
|
|
41
|
+
readonly key: string;
|
|
42
|
+
readonly limit: number;
|
|
43
|
+
};
|
|
44
|
+
function cursorOffset(value: unknown, storedKey: unknown, key: string): number {
|
|
45
|
+
if (
|
|
46
|
+
typeof value !== "number" ||
|
|
47
|
+
!Number.isSafeInteger(value) ||
|
|
48
|
+
value < 0 ||
|
|
49
|
+
storedKey !== key
|
|
50
|
+
) {
|
|
51
|
+
throw new Error("Invalid cursor offset or scope.");
|
|
52
|
+
}
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
function decodeCursor(encoded: string | undefined, key: string): HistoryCursor | undefined {
|
|
56
|
+
if (encoded === undefined || encoded === "") {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
const value: unknown = JSON.parse(Buffer.from(encoded, "base64url").toString());
|
|
60
|
+
const parts = unknownArray(value);
|
|
61
|
+
if (parts === undefined || parts.length !== 4) {
|
|
62
|
+
throw new Error("Invalid cursor tuple.");
|
|
63
|
+
}
|
|
64
|
+
const [id, priority, offset, storedKey] = parts;
|
|
65
|
+
if (typeof id !== "string" || id === "" || (priority !== 0 && priority !== 1)) {
|
|
66
|
+
throw new Error("Invalid cursor anchor.");
|
|
67
|
+
}
|
|
68
|
+
return [id, priority, cursorOffset(offset, storedKey, key), key];
|
|
69
|
+
}
|
|
70
|
+
function entryMessage(entry: EntryLike) {
|
|
71
|
+
switch (entry.type) {
|
|
72
|
+
case "message":
|
|
73
|
+
return entry.message ?? {};
|
|
74
|
+
case "context_edit":
|
|
75
|
+
return { content: entry.replacement?.content };
|
|
76
|
+
case "context_window":
|
|
77
|
+
return { content: entry.handoff };
|
|
78
|
+
case undefined:
|
|
79
|
+
return { content: entry.content, summary: entry.summary };
|
|
80
|
+
default:
|
|
81
|
+
return { content: entry.content, summary: entry.summary };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function contextVisibility(projected: readonly ProjectedEntry[]): (entry: EntryLike) => boolean {
|
|
85
|
+
const visible = new Map(
|
|
86
|
+
projected.flatMap(({ sourceEntry, messages }) =>
|
|
87
|
+
sourceEntry.id !== undefined && sourceEntry.id !== "" && messages.length > 0
|
|
88
|
+
? [[sourceEntry.id, messages.map(seenText).join("\n")]]
|
|
89
|
+
: [],
|
|
90
|
+
),
|
|
91
|
+
);
|
|
92
|
+
return (entry) => {
|
|
93
|
+
if (entry.type === "custom_message" && isReminderType(entry.customType)) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
const own = entryMessage(entry);
|
|
97
|
+
const id = entry.type === "context_edit" ? entry.targetId : entry.id;
|
|
98
|
+
return visible.get(id ?? "")?.includes(seenText(own)) === true;
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
type SearchProgress = {
|
|
102
|
+
readonly cursor?: HistoryCursor;
|
|
103
|
+
readonly limit: number;
|
|
104
|
+
readonly add: (hit: HistoryHit) => void;
|
|
105
|
+
readonly seeking: (priority: 0 | 1) => boolean;
|
|
106
|
+
readonly enough: () => boolean;
|
|
107
|
+
};
|
|
108
|
+
/** Owns ranking, lookahead and one cursor's progress; never holds the whole archive. */
|
|
109
|
+
class SearchAccumulator {
|
|
110
|
+
readonly groups: [HistoryHit[], HistoryHit[]] = [[], []];
|
|
111
|
+
readonly cursor: HistoryCursor | undefined;
|
|
112
|
+
readonly limit: number;
|
|
113
|
+
private found: boolean;
|
|
114
|
+
private readonly fail: (message: string) => never;
|
|
115
|
+
constructor(
|
|
116
|
+
limit: number,
|
|
117
|
+
cursor: HistoryCursor | undefined,
|
|
118
|
+
fail: (message: string) => never,
|
|
119
|
+
) {
|
|
120
|
+
this.limit = limit;
|
|
121
|
+
this.cursor = cursor;
|
|
122
|
+
this.found = cursor === undefined;
|
|
123
|
+
this.fail = fail;
|
|
124
|
+
}
|
|
125
|
+
add(hit: HistoryHit): void {
|
|
126
|
+
const cursor = this.cursor;
|
|
127
|
+
if (cursor !== undefined && hit.priority < cursor[1]) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (cursor !== undefined && hit.priority === cursor[1] && !this.found) {
|
|
131
|
+
if (hit.id !== cursor[0]) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
this.found = true;
|
|
135
|
+
if (cursor[2] > hit.text.length) {
|
|
136
|
+
this.fail("History cursor is past the entry; restart the search without it.");
|
|
137
|
+
}
|
|
138
|
+
if (cursor[2] === hit.text.length) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (this.groups[hit.priority].length <= this.limit) {
|
|
143
|
+
this.groups[hit.priority].push(hit);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
seeking(priority: 0 | 1): boolean {
|
|
147
|
+
return this.cursor !== undefined && !this.found && priority === this.cursor[1];
|
|
148
|
+
}
|
|
149
|
+
enough(): boolean {
|
|
150
|
+
return this.groups[0].length > this.limit;
|
|
151
|
+
}
|
|
152
|
+
results(): HistoryHit[] {
|
|
153
|
+
if (!this.found) {
|
|
154
|
+
this.fail("History cursor entry no longer matches; restart the search without it.");
|
|
155
|
+
}
|
|
156
|
+
return this.groups.flat();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function archiveCollector(accumulator: SearchProgress): {
|
|
160
|
+
readonly add: (hit: HistoryHit) => void;
|
|
161
|
+
readonly finish: () => readonly (readonly HistoryHit[])[];
|
|
162
|
+
} {
|
|
163
|
+
const recent: [HistoryHit[], HistoryHit[]] = [[], []];
|
|
164
|
+
let anchorHere = false;
|
|
165
|
+
return {
|
|
166
|
+
add: (hit) => {
|
|
167
|
+
const seeking = accumulator.seeking(hit.priority);
|
|
168
|
+
if (seeking && anchorHere) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (seeking && hit.id === accumulator.cursor?.[0]) {
|
|
172
|
+
anchorHere = true;
|
|
173
|
+
}
|
|
174
|
+
const group = recent[hit.priority];
|
|
175
|
+
group.push(hit);
|
|
176
|
+
if (group.length > accumulator.limit + 2) {
|
|
177
|
+
group.shift();
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
finish: () => {
|
|
181
|
+
const cursor = accumulator.cursor;
|
|
182
|
+
if (cursor !== undefined && accumulator.seeking(cursor[1]) && !anchorHere) {
|
|
183
|
+
recent[cursor[1]] = [];
|
|
184
|
+
}
|
|
185
|
+
return recent;
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
async function scanArchive(
|
|
190
|
+
file: string,
|
|
191
|
+
options: {
|
|
192
|
+
readonly source: string;
|
|
193
|
+
readonly query: string;
|
|
194
|
+
readonly signal?: AbortSignal;
|
|
195
|
+
readonly inContext: (entry: EntryLike) => boolean;
|
|
196
|
+
},
|
|
197
|
+
accumulator: SearchProgress,
|
|
198
|
+
): Promise<{ readonly groups: readonly (readonly HistoryHit[])[]; readonly skipped: number }> {
|
|
199
|
+
const collector = archiveCollector(accumulator);
|
|
200
|
+
let skipped = 0;
|
|
201
|
+
for await (const item of sessionWindowEntries(file, options.signal)) {
|
|
202
|
+
const hit = historyHit(item, options.query, options.source);
|
|
203
|
+
if (hit === undefined) {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (options.inContext(item.entry)) {
|
|
207
|
+
skipped++;
|
|
208
|
+
} else {
|
|
209
|
+
collector.add(hit);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return { groups: collector.finish(), skipped };
|
|
213
|
+
}
|
|
214
|
+
async function archivedMatches(
|
|
215
|
+
source: HistorySource,
|
|
216
|
+
search: {
|
|
217
|
+
readonly query: string;
|
|
218
|
+
readonly signal?: AbortSignal;
|
|
219
|
+
readonly inContext: (entry: EntryLike) => boolean;
|
|
220
|
+
},
|
|
221
|
+
accumulator: SearchProgress,
|
|
222
|
+
): Promise<number> {
|
|
223
|
+
const needles = prefilterNeedles(search.query);
|
|
224
|
+
const candidates =
|
|
225
|
+
needles === undefined
|
|
226
|
+
? undefined
|
|
227
|
+
: await filesContaining(source.dir, needles, search.signal);
|
|
228
|
+
const current =
|
|
229
|
+
source.currentFile === undefined || source.currentFile === ""
|
|
230
|
+
? undefined
|
|
231
|
+
: resolve(source.currentFile);
|
|
232
|
+
let skipped = 0;
|
|
233
|
+
for await (const file of scopedSessionFiles({
|
|
234
|
+
dir: source.dir,
|
|
235
|
+
cwd: source.cwd,
|
|
236
|
+
currentFile: source.currentFile,
|
|
237
|
+
signal: search.signal,
|
|
238
|
+
candidates: candidates === undefined ? undefined : (candidate) => candidates.has(candidate),
|
|
239
|
+
})) {
|
|
240
|
+
const inCurrent = resolve(file) === current;
|
|
241
|
+
const recent = await scanArchive(
|
|
242
|
+
file,
|
|
243
|
+
{
|
|
244
|
+
source: relative(source.dir, file),
|
|
245
|
+
query: search.query,
|
|
246
|
+
signal: search.signal,
|
|
247
|
+
inContext: (entry) => inCurrent && search.inContext(entry),
|
|
248
|
+
},
|
|
249
|
+
accumulator,
|
|
250
|
+
);
|
|
251
|
+
skipped += recent.skipped;
|
|
252
|
+
for (const group of recent.groups) {
|
|
253
|
+
for (const hit of [...group].reverse()) {
|
|
254
|
+
accumulator.add(hit);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (accumulator.enough()) {
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return skipped;
|
|
262
|
+
}
|
|
263
|
+
function branchMatches(
|
|
264
|
+
source: HistorySource,
|
|
265
|
+
query: string,
|
|
266
|
+
inContext: (entry: EntryLike) => boolean,
|
|
267
|
+
accumulator: SearchProgress,
|
|
268
|
+
): number {
|
|
269
|
+
let skipped = 0;
|
|
270
|
+
for (const item of reverseWindowEntries(source.branch())) {
|
|
271
|
+
const hit = historyHit(item, query);
|
|
272
|
+
if (hit === undefined) {
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (inContext(item.entry)) {
|
|
276
|
+
skipped++;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
accumulator.add(hit);
|
|
280
|
+
if (accumulator.enough()) {
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return skipped;
|
|
285
|
+
}
|
|
286
|
+
export async function historyMatches(
|
|
287
|
+
source: HistorySource,
|
|
288
|
+
params: HistoryParams,
|
|
289
|
+
signal: AbortSignal | undefined,
|
|
290
|
+
fail: (message: string) => never,
|
|
291
|
+
): Promise<HistoryMatches> {
|
|
292
|
+
const query = requireValue(params.query, "query", params.op).toLowerCase();
|
|
293
|
+
const limit = params.limit ?? 10;
|
|
294
|
+
const key = shortKey(JSON.stringify([query, params.all === true]));
|
|
295
|
+
let cursor: HistoryCursor | undefined;
|
|
296
|
+
try {
|
|
297
|
+
cursor = decodeCursor(params.cursor, key);
|
|
298
|
+
} catch {
|
|
299
|
+
fail("Invalid history cursor or changed query/scope; restart the search without it.");
|
|
300
|
+
}
|
|
301
|
+
const accumulator = new SearchAccumulator(limit, cursor, fail);
|
|
302
|
+
const inContext = contextVisibility(source.projection());
|
|
303
|
+
const skipped =
|
|
304
|
+
params.all === true
|
|
305
|
+
? await archivedMatches(source, { query, signal, inContext }, accumulator)
|
|
306
|
+
: branchMatches(source, query, inContext, accumulator);
|
|
307
|
+
return { hits: accumulator.results(), skipped, cursor, key, limit };
|
|
308
|
+
}
|
package/src/history.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { relative, resolve } from "node:path";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
import { toolCards } from "../ui.ts";
|
|
5
|
+
import { requireValue, type PolicyContext, type WindowedEntry } from "./contracts.ts";
|
|
6
|
+
import { toWindowedEntry, windowProjection } from "./history-entries.ts";
|
|
7
|
+
import { historyReadPage, searchPage } from "./history-pages.ts";
|
|
8
|
+
import { historyMatches, type HistorySource } from "./history-search.ts";
|
|
9
|
+
import type { PageAccess } from "./paging.ts";
|
|
10
|
+
import { filesContaining, scopedSessionFiles, sessionWindowEntries } from "./session-files.ts";
|
|
11
|
+
|
|
12
|
+
type LocatedEntry = { readonly item: WindowedEntry; readonly source: string };
|
|
13
|
+
function entryReference(id: string): { readonly entryId: string; readonly fileKey?: string } {
|
|
14
|
+
const separator = id.indexOf("@");
|
|
15
|
+
return {
|
|
16
|
+
entryId: separator < 0 ? id : id.slice(0, separator),
|
|
17
|
+
fileKey: separator < 0 ? undefined : id.slice(separator + 1),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function branchEntry(source: HistorySource, id: string): WindowedEntry | undefined {
|
|
21
|
+
const project = windowProjection();
|
|
22
|
+
for (const entry of source.branch()) {
|
|
23
|
+
const windowId = project(entry);
|
|
24
|
+
const item = entry.id === id ? toWindowedEntry(entry, windowId) : undefined;
|
|
25
|
+
if (item !== undefined) {
|
|
26
|
+
return item;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
async function findEntry(
|
|
32
|
+
source: HistorySource,
|
|
33
|
+
id: string,
|
|
34
|
+
signal?: AbortSignal,
|
|
35
|
+
): Promise<LocatedEntry> {
|
|
36
|
+
const { entryId, fileKey } = entryReference(id);
|
|
37
|
+
const current = fileKey === undefined ? branchEntry(source, entryId) : undefined;
|
|
38
|
+
if (current !== undefined) {
|
|
39
|
+
return { item: current, source: "" };
|
|
40
|
+
}
|
|
41
|
+
const candidates =
|
|
42
|
+
fileKey === undefined && /^[\w-]+$/.test(entryId)
|
|
43
|
+
? await filesContaining(source.dir, [`"id":"${entryId}"`], signal)
|
|
44
|
+
: undefined;
|
|
45
|
+
for await (const file of scopedSessionFiles({
|
|
46
|
+
dir: source.dir,
|
|
47
|
+
cwd: source.cwd,
|
|
48
|
+
currentFile: source.currentFile,
|
|
49
|
+
signal,
|
|
50
|
+
fileKey,
|
|
51
|
+
candidates: candidates === undefined ? undefined : (candidate) => candidates.has(candidate),
|
|
52
|
+
})) {
|
|
53
|
+
for await (const item of sessionWindowEntries(file, signal, entryId)) {
|
|
54
|
+
return { item, source: relative(source.dir, file) };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
throw new Error(
|
|
58
|
+
`No history entry "${id}" in this session or this project's other sessions. Pass an id exactly as history search prints it, such as 1a2f07de, or 1a2f07de@Ab3dE5fG7h from another session; search first when you only have text or a timestamp.`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
function historySource(ctx: ExtensionContext): HistorySource {
|
|
62
|
+
const manager = ctx.sessionManager;
|
|
63
|
+
return {
|
|
64
|
+
dir: manager.getSessionDir(),
|
|
65
|
+
cwd: resolve(ctx.cwd),
|
|
66
|
+
currentFile: manager.getSessionFile(),
|
|
67
|
+
branch: () => manager.getBranch(),
|
|
68
|
+
projection: () => manager.buildSessionProjection().entries,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
export function registerHistory(
|
|
72
|
+
pi: ExtensionAPI,
|
|
73
|
+
policy: (ctx: ExtensionContext) => PolicyContext,
|
|
74
|
+
pages: PageAccess,
|
|
75
|
+
): void {
|
|
76
|
+
pi.registerTool({
|
|
77
|
+
name: "history",
|
|
78
|
+
label: "History",
|
|
79
|
+
...toolCards("history"),
|
|
80
|
+
description:
|
|
81
|
+
"Search or read earlier session entries, including previous context windows. search skips entries still in your active context and lists original content before recovery echoes, newest first; all=true also searches this project's other sessions and subagent runs. read takes an entry id exactly as search printed it and pages text and images.",
|
|
82
|
+
promptSnippet: "recover earlier conversation that left the active context window",
|
|
83
|
+
promptGuidelines: [
|
|
84
|
+
"Use history search first, then history read with the entry id exactly as printed",
|
|
85
|
+
],
|
|
86
|
+
parameters: Type.Object({
|
|
87
|
+
op: Type.Union([Type.Literal("search"), Type.Literal("read")], {
|
|
88
|
+
description: "Operation to perform",
|
|
89
|
+
}),
|
|
90
|
+
query: Type.Optional(
|
|
91
|
+
Type.String({ description: "Case-insensitive text to find (search)" }),
|
|
92
|
+
),
|
|
93
|
+
id: Type.Optional(
|
|
94
|
+
Type.String({ description: "Entry id exactly as search printed it (read)" }),
|
|
95
|
+
),
|
|
96
|
+
all: Type.Optional(
|
|
97
|
+
Type.Boolean({
|
|
98
|
+
description: "Also search this project's other sessions and subagent runs",
|
|
99
|
+
}),
|
|
100
|
+
),
|
|
101
|
+
limit: Type.Optional(
|
|
102
|
+
Type.Integer({
|
|
103
|
+
description: "Maximum search results per page (default 10, max 50)",
|
|
104
|
+
minimum: 1,
|
|
105
|
+
maximum: 50,
|
|
106
|
+
}),
|
|
107
|
+
),
|
|
108
|
+
cursor: Type.Optional(
|
|
109
|
+
Type.String({
|
|
110
|
+
description:
|
|
111
|
+
"Continuation from the previous search page; keep query and all unchanged",
|
|
112
|
+
maxLength: 512,
|
|
113
|
+
}),
|
|
114
|
+
),
|
|
115
|
+
offset: Type.Optional(
|
|
116
|
+
Type.Integer({ description: "Character offset for read (default 0)", minimum: 0 }),
|
|
117
|
+
),
|
|
118
|
+
imageOffset: Type.Optional(
|
|
119
|
+
Type.Integer({
|
|
120
|
+
description:
|
|
121
|
+
"First image to return (read); use the value from the continuation",
|
|
122
|
+
minimum: 0,
|
|
123
|
+
}),
|
|
124
|
+
),
|
|
125
|
+
}),
|
|
126
|
+
// Pi's fixed tool contract supplies id, params, signal, updates, and context.
|
|
127
|
+
// oxlint-disable-next-line max-params
|
|
128
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
129
|
+
const source = historySource(ctx);
|
|
130
|
+
if (params.op === "search") {
|
|
131
|
+
const fail = (message: string): never =>
|
|
132
|
+
pages.error(policy(ctx), message, params.cursor);
|
|
133
|
+
const matches = await historyMatches(source, params, signal, fail);
|
|
134
|
+
return searchPage(matches, params, policy(ctx), pages);
|
|
135
|
+
}
|
|
136
|
+
const id = requireValue(params.id, "id", params.op);
|
|
137
|
+
const located = await findEntry(source, id, signal);
|
|
138
|
+
return historyReadPage(
|
|
139
|
+
located.item,
|
|
140
|
+
params,
|
|
141
|
+
{ host: policy(ctx), pages },
|
|
142
|
+
located.source,
|
|
143
|
+
);
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { sep } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
isRecord,
|
|
5
|
+
optionalString,
|
|
6
|
+
unknownArray,
|
|
7
|
+
type ImageLike,
|
|
8
|
+
type MessageLike,
|
|
9
|
+
type ToolCallLike,
|
|
10
|
+
} from "./contracts.ts";
|
|
11
|
+
|
|
12
|
+
export function isImage(part: unknown): part is ImageLike {
|
|
13
|
+
return (
|
|
14
|
+
isRecord(part) &&
|
|
15
|
+
part.type === "image" &&
|
|
16
|
+
typeof part.data === "string" &&
|
|
17
|
+
typeof part.mimeType === "string"
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
export function imagesOf(content: unknown): ImageLike[] {
|
|
21
|
+
return unknownArray(content)?.filter(isImage) ?? [];
|
|
22
|
+
}
|
|
23
|
+
export function imageSummary(images: readonly ImageLike[]): string {
|
|
24
|
+
if (images.length === 0) {
|
|
25
|
+
return "";
|
|
26
|
+
}
|
|
27
|
+
const types = [
|
|
28
|
+
...new Set(
|
|
29
|
+
images.map((image) => (image.mimeType === "" ? "unknown type" : image.mimeType)),
|
|
30
|
+
),
|
|
31
|
+
].join(", ");
|
|
32
|
+
return `[${images.length} image${images.length === 1 ? "" : "s"}: ${types}]`;
|
|
33
|
+
}
|
|
34
|
+
export function safeJsonStringify(value: unknown): string {
|
|
35
|
+
try {
|
|
36
|
+
const encoded: unknown = JSON.stringify(value);
|
|
37
|
+
return typeof encoded === "string" ? encoded : "undefined";
|
|
38
|
+
} catch {
|
|
39
|
+
return "[unserializable]";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export function toolIdentity(name?: string, namespace?: string): string {
|
|
43
|
+
return `${name ?? "tool"}${namespace === undefined ? "" : ` (namespace: ${safeJsonStringify(namespace)})`}`;
|
|
44
|
+
}
|
|
45
|
+
export function executionArgumentsText(call: ToolCallLike): string {
|
|
46
|
+
return call.executionArguments === undefined
|
|
47
|
+
? ""
|
|
48
|
+
: `\nExecution arguments: ${safeJsonStringify(call.executionArguments)}`;
|
|
49
|
+
}
|
|
50
|
+
export function toolCallOf(part: unknown): ToolCallLike | undefined {
|
|
51
|
+
if (!isRecord(part) || part.type !== "toolCall") {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
type: "toolCall",
|
|
56
|
+
id: optionalString(part.id),
|
|
57
|
+
name: optionalString(part.name),
|
|
58
|
+
namespace: optionalString(part.namespace),
|
|
59
|
+
arguments: part.arguments,
|
|
60
|
+
executionArguments: part.executionArguments,
|
|
61
|
+
async: part.async === true,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export function toolCalls(content: unknown): ToolCallLike[] {
|
|
65
|
+
return (unknownArray(content) ?? []).flatMap((part) => {
|
|
66
|
+
const call = toolCallOf(part);
|
|
67
|
+
return call === undefined ? [] : [call];
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
function blockText(part: unknown): string {
|
|
71
|
+
if (!isRecord(part)) {
|
|
72
|
+
return "";
|
|
73
|
+
}
|
|
74
|
+
if (part.type === "text") {
|
|
75
|
+
return optionalString(part.text) ?? "";
|
|
76
|
+
}
|
|
77
|
+
if (part.type === "thinking") {
|
|
78
|
+
return optionalString(part.thinking) ?? "";
|
|
79
|
+
}
|
|
80
|
+
const call = toolCallOf(part);
|
|
81
|
+
if (call === undefined) {
|
|
82
|
+
return "";
|
|
83
|
+
}
|
|
84
|
+
return `${toolIdentity(call.name, call.namespace)} ${safeJsonStringify(call.arguments ?? {})}${call.id === undefined ? "" : `\nCall ID: ${call.id}`}${executionArgumentsText(call)}`;
|
|
85
|
+
}
|
|
86
|
+
export function textOf(message: MessageLike): string {
|
|
87
|
+
if (typeof message.content === "string") {
|
|
88
|
+
return message.content;
|
|
89
|
+
}
|
|
90
|
+
return (unknownArray(message.content) ?? [])
|
|
91
|
+
.map(blockText)
|
|
92
|
+
.filter((text) => text !== "")
|
|
93
|
+
.join("\n");
|
|
94
|
+
}
|
|
95
|
+
export function assistantFailure(message: MessageLike): string {
|
|
96
|
+
if (message.role !== "assistant") {
|
|
97
|
+
return "";
|
|
98
|
+
}
|
|
99
|
+
const error = message.errorMessage ?? "";
|
|
100
|
+
if (message.stopReason !== "error" && message.stopReason !== "aborted" && error === "") {
|
|
101
|
+
return "";
|
|
102
|
+
}
|
|
103
|
+
return `[${message.stopReason ?? "error"}]${error === "" ? "" : ` ${error}`}`;
|
|
104
|
+
}
|
|
105
|
+
export function isRecoveryTool(name: unknown): boolean {
|
|
106
|
+
return name === "history" || name === "notes" || name === "new_context";
|
|
107
|
+
}
|
|
108
|
+
export function isRecoveryCall(part: unknown): boolean {
|
|
109
|
+
const block = toolCallOf(part);
|
|
110
|
+
return block !== undefined && isRecoveryTool(block.name);
|
|
111
|
+
}
|
|
112
|
+
/** Ten base64url characters of SHA-256; legacy whole-digest keys start with these. */
|
|
113
|
+
export function shortKey(text: string): string {
|
|
114
|
+
return createHash("sha256").update(text).digest("base64url").slice(0, 10);
|
|
115
|
+
}
|
|
116
|
+
/** Nested session files are subagent runs, whose input came from a parent agent. */
|
|
117
|
+
export function sourceTag(source: string): string {
|
|
118
|
+
return source.includes(sep) ? " [subagent]" : "";
|
|
119
|
+
}
|
|
120
|
+
export function excerptAround(text: string, index: number, before: number, length: number): string {
|
|
121
|
+
const start = Math.max(0, index - before);
|
|
122
|
+
const end = Math.min(text.length, start + length);
|
|
123
|
+
return `${start > 0 ? "…" : ""}${text.slice(start, end)}${end < text.length ? "…" : ""}`;
|
|
124
|
+
}
|
|
125
|
+
export function excerpt(text: string, limit: number): string {
|
|
126
|
+
if (text.length <= limit) {
|
|
127
|
+
return text;
|
|
128
|
+
}
|
|
129
|
+
const marker = "\n… middle omitted …\n";
|
|
130
|
+
if (limit <= marker.length) {
|
|
131
|
+
return text.slice(0, limit);
|
|
132
|
+
}
|
|
133
|
+
const head = Math.floor((limit - marker.length) / 2);
|
|
134
|
+
return `${text.slice(0, head)}${marker}${text.slice(text.length - (limit - marker.length - head))}`;
|
|
135
|
+
}
|
|
136
|
+
export function boundedBlock(header: string, text: string, limit: number): string {
|
|
137
|
+
const textLimit = Math.max(0, limit - header.length - 1);
|
|
138
|
+
return textLimit > 0 ? `${header}\n${excerpt(text, textLimit)}` : header;
|
|
139
|
+
}
|