tinker-agent 1.0.65
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 +173 -0
- package/package.json +78 -0
- package/patches/markdansi@0.3.2.patch +37 -0
- package/src/agent/context-builder.ts +43 -0
- package/src/agent/context-meter.ts +310 -0
- package/src/agent/loop.ts +525 -0
- package/src/agent/runtime-session.ts +1212 -0
- package/src/agent/session-ledger.ts +828 -0
- package/src/agent/turn-cancellation.ts +44 -0
- package/src/agent/types.ts +77 -0
- package/src/cli/config.ts +283 -0
- package/src/cli/index.ts +29 -0
- package/src/cli/model-profiles.ts +289 -0
- package/src/cli/run-runner.ts +107 -0
- package/src/cli/tui-runner.tsx +290 -0
- package/src/context/compiled-context-hash.ts +138 -0
- package/src/context/compiled-context-validator.ts +209 -0
- package/src/context/context-manager.ts +362 -0
- package/src/context/context-policy.ts +8 -0
- package/src/context/context-protocol-validator.ts +463 -0
- package/src/context/context-revision-compiler.ts +281 -0
- package/src/context/context-revision.ts +111 -0
- package/src/context/context-source.ts +30 -0
- package/src/context/context-swap-renderer.ts +272 -0
- package/src/context/protocol-frame.ts +240 -0
- package/src/context/swap-planner.ts +725 -0
- package/src/events/append-private-file.ts +16 -0
- package/src/events/bash-result-detail.ts +70 -0
- package/src/events/composite-event-sink.ts +82 -0
- package/src/events/event-sink.ts +16 -0
- package/src/events/jsonl-event-log.ts +13 -0
- package/src/events/observation-text-log.ts +195 -0
- package/src/events/stdout-event-printer.ts +396 -0
- package/src/events/types.ts +263 -0
- package/src/ids/runtime-id.ts +68 -0
- package/src/ids/uuid-v7.ts +5 -0
- package/src/instructions/project-instructions.ts +242 -0
- package/src/mcp/mcp-config.ts +144 -0
- package/src/mcp/mcp-manager.ts +216 -0
- package/src/mcp/mcp-tool-executor.ts +178 -0
- package/src/model/committed-prefix-auditor.ts +68 -0
- package/src/model/fake-model-client.ts +280 -0
- package/src/model/model-client.ts +64 -0
- package/src/model/model-context-profile.ts +134 -0
- package/src/model/model-request-preflight.ts +120 -0
- package/src/model/openai-chat-mapping.ts +444 -0
- package/src/model/openai-chat-model-client.ts +190 -0
- package/src/model/prompt-prefix-hash.ts +47 -0
- package/src/model/token-estimator.ts +148 -0
- package/src/observation/observation-builder.ts +481 -0
- package/src/session/resume-projection.ts +616 -0
- package/src/session/session-catalog.ts +270 -0
- package/src/session/session-errors.ts +121 -0
- package/src/session/session-history-reader.ts +535 -0
- package/src/session/session-lock.ts +291 -0
- package/src/session/session-schema.ts +741 -0
- package/src/session/session-store.ts +3067 -0
- package/src/session/sqlite-session-ledger.ts +153 -0
- package/src/tools/bash-task.ts +617 -0
- package/src/tools/bash.ts +450 -0
- package/src/tools/cwd-state.ts +22 -0
- package/src/tools/edit.ts +428 -0
- package/src/tools/file-diff.ts +116 -0
- package/src/tools/glob.ts +202 -0
- package/src/tools/grep.ts +550 -0
- package/src/tools/hash.ts +9 -0
- package/src/tools/path-safety.ts +33 -0
- package/src/tools/read.ts +319 -0
- package/src/tools/recall.ts +400 -0
- package/src/tools/registry.ts +213 -0
- package/src/tools/ripgrep.ts +220 -0
- package/src/tools/task-list.ts +59 -0
- package/src/tools/task-output-snapshot.ts +47 -0
- package/src/tools/task-output-tool.ts +62 -0
- package/src/tools/task-output.ts +159 -0
- package/src/tools/task-stop.ts +59 -0
- package/src/tools/task-tool-args.ts +29 -0
- package/src/tools/types.ts +330 -0
- package/src/tools/web-fetch/backend.ts +27 -0
- package/src/tools/web-fetch/browser-backend.ts +126 -0
- package/src/tools/web-fetch/exa-backend.ts +172 -0
- package/src/tools/web-fetch/index.ts +298 -0
- package/src/tools/web-fetch/local-backend.ts +267 -0
- package/src/tools/web-fetch/refiner.ts +78 -0
- package/src/tools/web-fetch/route.ts +95 -0
- package/src/tools/web-search.ts +300 -0
- package/src/tools/write.ts +244 -0
- package/src/tui/app.tsx +497 -0
- package/src/tui/components/assistant-markdown.tsx +47 -0
- package/src/tui/components/background-tasks.tsx +92 -0
- package/src/tui/components/bash-result-view.tsx +47 -0
- package/src/tui/components/context-status.tsx +127 -0
- package/src/tui/components/diff-view.tsx +151 -0
- package/src/tui/components/file-viewer.tsx +212 -0
- package/src/tui/components/footer.tsx +60 -0
- package/src/tui/components/header.tsx +21 -0
- package/src/tui/components/model-picker.tsx +142 -0
- package/src/tui/components/prompt-input.tsx +432 -0
- package/src/tui/components/resume-session-picker.tsx +273 -0
- package/src/tui/components/timeline.tsx +121 -0
- package/src/tui/context-format.ts +24 -0
- package/src/tui/event-store.ts +865 -0
- package/src/tui/git-branch.ts +23 -0
- package/src/tui/line-editor.ts +157 -0
- package/src/tui/prompt-history.ts +94 -0
- package/src/tui/slash-commands.ts +126 -0
- package/src/tui/tui-projection-policy.ts +35 -0
- package/src/tui/tui-projection-store.ts +123 -0
- package/src/tui/tui-session-controller.ts +170 -0
- package/src/tui/view-file.ts +122 -0
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import {
|
|
3
|
+
formatMessageSource,
|
|
4
|
+
parseMessageSource,
|
|
5
|
+
type MessageSource,
|
|
6
|
+
} from "../context/context-source";
|
|
7
|
+
import { contentHash } from "../context/protocol-frame";
|
|
8
|
+
import type { MessageId, SessionId } from "../ids/runtime-id";
|
|
9
|
+
import { SessionError, sessionReadError } from "./session-errors";
|
|
10
|
+
|
|
11
|
+
export type RecallRole = "user" | "assistant" | "tool";
|
|
12
|
+
|
|
13
|
+
export type RecallSearchInput = {
|
|
14
|
+
query: string;
|
|
15
|
+
roles?: readonly RecallRole[];
|
|
16
|
+
toolNames?: readonly string[];
|
|
17
|
+
turnFrom?: number;
|
|
18
|
+
turnTo?: number;
|
|
19
|
+
limit: number;
|
|
20
|
+
offset: number;
|
|
21
|
+
snapshotThroughOrdinal?: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type RecallSearchFilters = {
|
|
25
|
+
roles?: readonly RecallRole[];
|
|
26
|
+
toolNames?: readonly string[];
|
|
27
|
+
turnFrom?: number;
|
|
28
|
+
turnTo?: number;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type RecallSearchHit = {
|
|
32
|
+
source: MessageSource;
|
|
33
|
+
messageId: MessageId;
|
|
34
|
+
ordinal: number;
|
|
35
|
+
role: RecallRole;
|
|
36
|
+
origin: "user" | "model" | "tool" | "runtime";
|
|
37
|
+
toolName?: string;
|
|
38
|
+
turnNumber: number;
|
|
39
|
+
iterationNumber?: number;
|
|
40
|
+
createdAt: string;
|
|
41
|
+
contentSha256: string;
|
|
42
|
+
excerpt: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type RecallSearchPage = {
|
|
46
|
+
strategy: "fts5_trigram" | "substring";
|
|
47
|
+
snapshotThroughOrdinal: number;
|
|
48
|
+
offset: number;
|
|
49
|
+
limit: number;
|
|
50
|
+
hits: readonly RecallSearchHit[];
|
|
51
|
+
nextOffset?: number;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type RecallGetInput = {
|
|
55
|
+
source: MessageSource;
|
|
56
|
+
byteOffset: number;
|
|
57
|
+
byteLimit: number;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type RecallGetPage = {
|
|
61
|
+
source: MessageSource;
|
|
62
|
+
messageId: MessageId;
|
|
63
|
+
ordinal: number;
|
|
64
|
+
role: RecallRole;
|
|
65
|
+
origin: "user" | "model" | "tool" | "runtime";
|
|
66
|
+
toolName?: string;
|
|
67
|
+
turnNumber: number;
|
|
68
|
+
iterationNumber?: number;
|
|
69
|
+
createdAt: string;
|
|
70
|
+
contentSha256: string;
|
|
71
|
+
totalBytes: number;
|
|
72
|
+
byteOffset: number;
|
|
73
|
+
returnedBytes: number;
|
|
74
|
+
content: string;
|
|
75
|
+
nextByteOffset?: number;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export interface SessionHistoryReader {
|
|
79
|
+
readonly sessionId: SessionId;
|
|
80
|
+
search(input: RecallSearchInput): RecallSearchPage;
|
|
81
|
+
get(input: RecallGetInput): RecallGetPage;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type RecallHistoryErrorCode =
|
|
85
|
+
| "RECALL_SOURCE_NOT_FOUND"
|
|
86
|
+
| "RECALL_PAGE_INVALID"
|
|
87
|
+
| "RECALL_SNAPSHOT_INVALID";
|
|
88
|
+
|
|
89
|
+
export class RecallHistoryError extends Error {
|
|
90
|
+
constructor(
|
|
91
|
+
readonly code: RecallHistoryErrorCode,
|
|
92
|
+
message: string,
|
|
93
|
+
) {
|
|
94
|
+
super(message);
|
|
95
|
+
this.name = "RecallHistoryError";
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
type RecallRow = {
|
|
100
|
+
message_id: unknown;
|
|
101
|
+
ordinal: unknown;
|
|
102
|
+
role: unknown;
|
|
103
|
+
origin: unknown;
|
|
104
|
+
name: unknown;
|
|
105
|
+
turn_number: unknown;
|
|
106
|
+
iteration_number: unknown;
|
|
107
|
+
created_at: unknown;
|
|
108
|
+
content_sha256: unknown;
|
|
109
|
+
content: unknown;
|
|
110
|
+
observation_sha256?: unknown;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export function createSessionHistoryReader(input: {
|
|
114
|
+
database: Database;
|
|
115
|
+
sessionId: SessionId;
|
|
116
|
+
requireOpen: () => void;
|
|
117
|
+
}): SessionHistoryReader {
|
|
118
|
+
return Object.freeze(new SqliteSessionHistoryReader(input));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function isRecallableMessage(input: {
|
|
122
|
+
role: string;
|
|
123
|
+
content: string | null;
|
|
124
|
+
toolName?: string | null;
|
|
125
|
+
}): boolean {
|
|
126
|
+
return (
|
|
127
|
+
(input.role === "user" || input.role === "assistant" || input.role === "tool") &&
|
|
128
|
+
input.content !== null &&
|
|
129
|
+
input.content.length > 0 &&
|
|
130
|
+
!(input.role === "tool" && input.toolName === "Recall")
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
class SqliteSessionHistoryReader implements SessionHistoryReader {
|
|
135
|
+
readonly sessionId: SessionId;
|
|
136
|
+
private readonly database: Database;
|
|
137
|
+
private readonly requireStoreOpen: () => void;
|
|
138
|
+
|
|
139
|
+
constructor(input: {
|
|
140
|
+
database: Database;
|
|
141
|
+
sessionId: SessionId;
|
|
142
|
+
requireOpen: () => void;
|
|
143
|
+
}) {
|
|
144
|
+
this.database = input.database;
|
|
145
|
+
this.sessionId = input.sessionId;
|
|
146
|
+
this.requireStoreOpen = input.requireOpen;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
search(input: RecallSearchInput): RecallSearchPage {
|
|
150
|
+
const snapshot = this.resolveSnapshot(input.snapshotThroughOrdinal);
|
|
151
|
+
const strategy = [...input.query].length >= 3 ? "fts5_trigram" : "substring";
|
|
152
|
+
const { predicates, values } = searchPredicates(input, snapshot);
|
|
153
|
+
const matchPredicate =
|
|
154
|
+
strategy === "fts5_trigram"
|
|
155
|
+
? "message_fts MATCH ?"
|
|
156
|
+
: `(instr(m.content, ?) > 0 OR
|
|
157
|
+
instr(lower(m.content), lower(?)) > 0)`;
|
|
158
|
+
const matchValues =
|
|
159
|
+
strategy === "fts5_trigram"
|
|
160
|
+
? [`"${input.query.replaceAll('"', '""')}"`]
|
|
161
|
+
: [input.query, input.query];
|
|
162
|
+
// Pin FTS as the outer loop; otherwise SQLite prefers the session/ordinal
|
|
163
|
+
// index and executes one virtual-table scan per canonical message.
|
|
164
|
+
const source =
|
|
165
|
+
strategy === "fts5_trigram"
|
|
166
|
+
? `message_fts
|
|
167
|
+
CROSS JOIN messages m ON m.rowid = message_fts.rowid`
|
|
168
|
+
: `recall_documents rd
|
|
169
|
+
JOIN messages m ON m.rowid = rd.docid`;
|
|
170
|
+
|
|
171
|
+
let rows: RecallRow[];
|
|
172
|
+
try {
|
|
173
|
+
this.requireStoreOpen();
|
|
174
|
+
rows = this.database
|
|
175
|
+
.query(
|
|
176
|
+
`SELECT
|
|
177
|
+
m.message_id, m.ordinal, m.role, m.origin, m.name,
|
|
178
|
+
t.turn_number, i.iteration_number, m.created_at,
|
|
179
|
+
m.content_sha256, m.content
|
|
180
|
+
FROM ${source}
|
|
181
|
+
JOIN turns t ON t.turn_id = m.turn_id
|
|
182
|
+
LEFT JOIN iterations i ON i.iteration_id = m.iteration_id
|
|
183
|
+
WHERE ${matchPredicate}
|
|
184
|
+
AND ${predicates.join(" AND ")}
|
|
185
|
+
ORDER BY
|
|
186
|
+
CASE WHEN instr(m.content, ?) > 0 THEN 0 ELSE 1 END ASC,
|
|
187
|
+
length(m.content) ASC,
|
|
188
|
+
m.ordinal DESC,
|
|
189
|
+
m.message_id ASC
|
|
190
|
+
LIMIT ? OFFSET ?`,
|
|
191
|
+
)
|
|
192
|
+
.all(
|
|
193
|
+
...matchValues,
|
|
194
|
+
this.sessionId,
|
|
195
|
+
...values,
|
|
196
|
+
input.query,
|
|
197
|
+
input.limit + 1,
|
|
198
|
+
input.offset,
|
|
199
|
+
) as RecallRow[];
|
|
200
|
+
} catch (error) {
|
|
201
|
+
throw sessionReadError("recall_search", this.sessionId, error);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
let decoded: Array<RecallSearchHit & { content: string }>;
|
|
205
|
+
try {
|
|
206
|
+
decoded = rows.map((row) => {
|
|
207
|
+
const metadata = decodeRecallRow(row);
|
|
208
|
+
return {
|
|
209
|
+
...metadata,
|
|
210
|
+
excerpt: buildExcerpt(metadata.content, input.query),
|
|
211
|
+
};
|
|
212
|
+
});
|
|
213
|
+
} catch (error) {
|
|
214
|
+
throw sessionReadError("decode_recall_search", this.sessionId, error);
|
|
215
|
+
}
|
|
216
|
+
const hasNext = decoded.length > input.limit;
|
|
217
|
+
const hits = decoded.slice(0, input.limit).map(({ content, ...hit }) => {
|
|
218
|
+
void content;
|
|
219
|
+
return hit;
|
|
220
|
+
});
|
|
221
|
+
return Object.freeze({
|
|
222
|
+
strategy,
|
|
223
|
+
snapshotThroughOrdinal: snapshot,
|
|
224
|
+
offset: input.offset,
|
|
225
|
+
limit: input.limit,
|
|
226
|
+
hits: Object.freeze(hits),
|
|
227
|
+
...(hasNext ? { nextOffset: input.offset + input.limit } : {}),
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
get(input: RecallGetInput): RecallGetPage {
|
|
232
|
+
const messageId = parseMessageSource(input.source);
|
|
233
|
+
let row: RecallRow | null;
|
|
234
|
+
try {
|
|
235
|
+
this.requireStoreOpen();
|
|
236
|
+
row = this.database
|
|
237
|
+
.query(
|
|
238
|
+
`SELECT
|
|
239
|
+
m.message_id, m.ordinal, m.role, m.origin, m.name,
|
|
240
|
+
t.turn_number, i.iteration_number, m.created_at,
|
|
241
|
+
m.content_sha256, m.content, tr.observation_sha256
|
|
242
|
+
FROM recall_documents rd
|
|
243
|
+
JOIN messages m ON m.rowid = rd.docid
|
|
244
|
+
JOIN turns t ON t.turn_id = m.turn_id
|
|
245
|
+
LEFT JOIN iterations i ON i.iteration_id = m.iteration_id
|
|
246
|
+
LEFT JOIN tool_results tr ON tr.tool_message_id = m.message_id
|
|
247
|
+
WHERE m.session_id = ? AND m.message_id = ?`,
|
|
248
|
+
)
|
|
249
|
+
.get(this.sessionId, messageId) as RecallRow | null;
|
|
250
|
+
} catch (error) {
|
|
251
|
+
throw sessionReadError("recall_get", this.sessionId, error);
|
|
252
|
+
}
|
|
253
|
+
if (row === null) {
|
|
254
|
+
throw new RecallHistoryError(
|
|
255
|
+
"RECALL_SOURCE_NOT_FOUND",
|
|
256
|
+
"The source was not found in recallable history for the current session.",
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
let metadata: ReturnType<typeof decodeRecallRow>;
|
|
261
|
+
try {
|
|
262
|
+
metadata = decodeRecallRow(row);
|
|
263
|
+
} catch (error) {
|
|
264
|
+
throw sessionReadError("decode_recall_get", this.sessionId, error);
|
|
265
|
+
}
|
|
266
|
+
const actualHash = contentHash(metadata.content);
|
|
267
|
+
const observationHash = row.observation_sha256;
|
|
268
|
+
if (
|
|
269
|
+
actualHash !== metadata.contentSha256 ||
|
|
270
|
+
(metadata.role === "tool" && observationHash !== metadata.contentSha256)
|
|
271
|
+
) {
|
|
272
|
+
throw new SessionError(
|
|
273
|
+
"SESSION_READ_FAILED",
|
|
274
|
+
"verify_recall_content",
|
|
275
|
+
"Canonical Recall content failed its integrity check.",
|
|
276
|
+
{ sessionId: this.sessionId, messageId },
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const page = sliceUtf8Page(metadata.content, input.byteOffset, input.byteLimit);
|
|
281
|
+
return Object.freeze({
|
|
282
|
+
source: formatMessageSource(metadata.messageId),
|
|
283
|
+
messageId: metadata.messageId,
|
|
284
|
+
ordinal: metadata.ordinal,
|
|
285
|
+
role: metadata.role,
|
|
286
|
+
origin: metadata.origin,
|
|
287
|
+
...(metadata.toolName === undefined ? {} : { toolName: metadata.toolName }),
|
|
288
|
+
turnNumber: metadata.turnNumber,
|
|
289
|
+
...(metadata.iterationNumber === undefined
|
|
290
|
+
? {}
|
|
291
|
+
: { iterationNumber: metadata.iterationNumber }),
|
|
292
|
+
createdAt: metadata.createdAt,
|
|
293
|
+
contentSha256: metadata.contentSha256,
|
|
294
|
+
...page,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private resolveSnapshot(requested: number | undefined): number {
|
|
299
|
+
let maximum: number;
|
|
300
|
+
try {
|
|
301
|
+
this.requireStoreOpen();
|
|
302
|
+
const row = this.database
|
|
303
|
+
.query("SELECT MAX(ordinal) AS maximum FROM messages WHERE session_id = ?")
|
|
304
|
+
.get(this.sessionId) as { maximum: unknown } | null;
|
|
305
|
+
maximum = safeInteger(row?.maximum, "maximum message ordinal");
|
|
306
|
+
if (requested !== undefined) {
|
|
307
|
+
const exists = this.database
|
|
308
|
+
.query(
|
|
309
|
+
"SELECT 1 AS present FROM messages WHERE session_id = ? AND ordinal = ?",
|
|
310
|
+
)
|
|
311
|
+
.get(this.sessionId, requested);
|
|
312
|
+
if (exists === null) {
|
|
313
|
+
throw new RecallHistoryError(
|
|
314
|
+
"RECALL_SNAPSHOT_INVALID",
|
|
315
|
+
"The supplied search snapshot is not valid for the current session.",
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
} catch (error) {
|
|
320
|
+
if (error instanceof RecallHistoryError) {
|
|
321
|
+
throw error;
|
|
322
|
+
}
|
|
323
|
+
throw sessionReadError("resolve_recall_snapshot", this.sessionId, error);
|
|
324
|
+
}
|
|
325
|
+
return requested ?? maximum;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function searchPredicates(
|
|
330
|
+
input: RecallSearchInput,
|
|
331
|
+
snapshot: number,
|
|
332
|
+
): { predicates: string[]; values: Array<string | number> } {
|
|
333
|
+
const predicates = ["m.session_id = ?", "m.ordinal <= ?"];
|
|
334
|
+
const values: Array<string | number> = [snapshot];
|
|
335
|
+
|
|
336
|
+
if (input.roles !== undefined) {
|
|
337
|
+
predicates.push(`m.role IN (${input.roles.map(() => "?").join(", ")})`);
|
|
338
|
+
values.push(...input.roles);
|
|
339
|
+
}
|
|
340
|
+
if (input.toolNames !== undefined) {
|
|
341
|
+
predicates.push("m.role = 'tool'");
|
|
342
|
+
predicates.push(`m.name IN (${input.toolNames.map(() => "?").join(", ")})`);
|
|
343
|
+
values.push(...input.toolNames);
|
|
344
|
+
}
|
|
345
|
+
if (input.turnFrom !== undefined) {
|
|
346
|
+
predicates.push("t.turn_number >= ?");
|
|
347
|
+
values.push(input.turnFrom);
|
|
348
|
+
}
|
|
349
|
+
if (input.turnTo !== undefined) {
|
|
350
|
+
predicates.push("t.turn_number <= ?");
|
|
351
|
+
values.push(input.turnTo);
|
|
352
|
+
}
|
|
353
|
+
return { predicates, values };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function decodeRecallRow(row: RecallRow): {
|
|
357
|
+
source: MessageSource;
|
|
358
|
+
messageId: MessageId;
|
|
359
|
+
ordinal: number;
|
|
360
|
+
role: RecallRole;
|
|
361
|
+
origin: "user" | "model" | "tool" | "runtime";
|
|
362
|
+
toolName?: string;
|
|
363
|
+
turnNumber: number;
|
|
364
|
+
iterationNumber?: number;
|
|
365
|
+
createdAt: string;
|
|
366
|
+
contentSha256: string;
|
|
367
|
+
content: string;
|
|
368
|
+
} {
|
|
369
|
+
const messageId = nonEmptyString(row.message_id, "message_id") as MessageId;
|
|
370
|
+
const role = enumValue(
|
|
371
|
+
row.role,
|
|
372
|
+
["user", "assistant", "tool"] as const,
|
|
373
|
+
"message role",
|
|
374
|
+
);
|
|
375
|
+
const origin = enumValue(
|
|
376
|
+
row.origin,
|
|
377
|
+
["user", "model", "tool", "runtime"] as const,
|
|
378
|
+
"message origin",
|
|
379
|
+
);
|
|
380
|
+
const content = nonEmptyString(row.content, "message content");
|
|
381
|
+
const toolName =
|
|
382
|
+
row.name === null ? undefined : nonEmptyString(row.name, "tool name");
|
|
383
|
+
if (!isRecallableMessage({ role, content, toolName })) {
|
|
384
|
+
throw new Error("Recall query returned a message outside the allowlist.");
|
|
385
|
+
}
|
|
386
|
+
const iterationNumber =
|
|
387
|
+
row.iteration_number === null
|
|
388
|
+
? undefined
|
|
389
|
+
: safeInteger(row.iteration_number, "iteration_number");
|
|
390
|
+
return {
|
|
391
|
+
source: formatMessageSource(messageId),
|
|
392
|
+
messageId,
|
|
393
|
+
ordinal: safeInteger(row.ordinal, "ordinal"),
|
|
394
|
+
role,
|
|
395
|
+
origin,
|
|
396
|
+
...(toolName === undefined ? {} : { toolName }),
|
|
397
|
+
turnNumber: safeInteger(row.turn_number, "turn_number"),
|
|
398
|
+
...(iterationNumber === undefined ? {} : { iterationNumber }),
|
|
399
|
+
createdAt: nonEmptyString(row.created_at, "created_at"),
|
|
400
|
+
contentSha256: nonEmptyString(row.content_sha256, "content_sha256"),
|
|
401
|
+
content,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function sliceUtf8Page(
|
|
406
|
+
content: string,
|
|
407
|
+
byteOffset: number,
|
|
408
|
+
byteLimit: number,
|
|
409
|
+
): {
|
|
410
|
+
totalBytes: number;
|
|
411
|
+
byteOffset: number;
|
|
412
|
+
returnedBytes: number;
|
|
413
|
+
content: string;
|
|
414
|
+
nextByteOffset?: number;
|
|
415
|
+
} {
|
|
416
|
+
const bytes = Buffer.from(content, "utf8");
|
|
417
|
+
if (
|
|
418
|
+
!Number.isSafeInteger(byteOffset) ||
|
|
419
|
+
byteOffset < 0 ||
|
|
420
|
+
byteOffset >= bytes.length ||
|
|
421
|
+
!isUtf8Boundary(bytes, byteOffset) ||
|
|
422
|
+
!Number.isSafeInteger(byteLimit) ||
|
|
423
|
+
byteLimit < 1
|
|
424
|
+
) {
|
|
425
|
+
throw new RecallHistoryError(
|
|
426
|
+
"RECALL_PAGE_INVALID",
|
|
427
|
+
"The requested byte page is outside the content or not on a UTF-8 boundary.",
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
let end = Math.min(bytes.length, byteOffset + byteLimit);
|
|
431
|
+
while (end > byteOffset && end < bytes.length && !isUtf8Boundary(bytes, end)) {
|
|
432
|
+
end -= 1;
|
|
433
|
+
}
|
|
434
|
+
if (end === byteOffset) {
|
|
435
|
+
throw new RecallHistoryError(
|
|
436
|
+
"RECALL_PAGE_INVALID",
|
|
437
|
+
"The byte limit is too small to return a complete UTF-8 code point.",
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
const returnedBytes = end - byteOffset;
|
|
441
|
+
return {
|
|
442
|
+
totalBytes: bytes.length,
|
|
443
|
+
byteOffset,
|
|
444
|
+
returnedBytes,
|
|
445
|
+
content: bytes.subarray(byteOffset, end).toString("utf8"),
|
|
446
|
+
...(end < bytes.length ? { nextByteOffset: end } : {}),
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function buildExcerpt(content: string, query: string): string {
|
|
451
|
+
const maximumBytes = 480;
|
|
452
|
+
const bytes = Buffer.from(content, "utf8");
|
|
453
|
+
if (bytes.length <= maximumBytes) {
|
|
454
|
+
return content;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
let occurrence = content.indexOf(query);
|
|
458
|
+
if (occurrence < 0 && isAscii(query)) {
|
|
459
|
+
occurrence = asciiCaseInsensitiveIndex(content, query);
|
|
460
|
+
}
|
|
461
|
+
const occurrenceByte =
|
|
462
|
+
occurrence < 0 ? 0 : Buffer.byteLength(content.slice(0, occurrence), "utf8");
|
|
463
|
+
const queryBytes = Buffer.byteLength(query, "utf8");
|
|
464
|
+
const contentBudget = maximumBytes - 6;
|
|
465
|
+
const center = occurrenceByte + Math.min(queryBytes, contentBudget) / 2;
|
|
466
|
+
let start = Math.max(0, Math.floor(center - contentBudget / 2));
|
|
467
|
+
let end = Math.min(bytes.length, start + contentBudget);
|
|
468
|
+
if (end === bytes.length) {
|
|
469
|
+
start = Math.max(0, end - contentBudget);
|
|
470
|
+
}
|
|
471
|
+
while (start < end && !isUtf8Boundary(bytes, start)) {
|
|
472
|
+
start += 1;
|
|
473
|
+
}
|
|
474
|
+
while (end > start && end < bytes.length && !isUtf8Boundary(bytes, end)) {
|
|
475
|
+
end -= 1;
|
|
476
|
+
}
|
|
477
|
+
return `${start > 0 ? "…" : ""}${bytes.subarray(start, end).toString("utf8")}${end < bytes.length ? "…" : ""}`;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function isUtf8Boundary(bytes: Buffer, offset: number): boolean {
|
|
481
|
+
return offset === bytes.length || (bytes[offset] & 0xc0) !== 0x80;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function isAscii(value: string): boolean {
|
|
485
|
+
return [...value].every((character) => character.charCodeAt(0) <= 0x7f);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function asciiCaseInsensitiveIndex(content: string, query: string): number {
|
|
489
|
+
for (let start = 0; start <= content.length - query.length; start += 1) {
|
|
490
|
+
let matches = true;
|
|
491
|
+
for (let offset = 0; offset < query.length; offset += 1) {
|
|
492
|
+
if (
|
|
493
|
+
asciiFold(content.charCodeAt(start + offset)) !==
|
|
494
|
+
asciiFold(query.charCodeAt(offset))
|
|
495
|
+
) {
|
|
496
|
+
matches = false;
|
|
497
|
+
break;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
if (matches) {
|
|
501
|
+
return start;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return -1;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function asciiFold(code: number): number {
|
|
508
|
+
return code >= 0x41 && code <= 0x5a ? code + 0x20 : code;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function nonEmptyString(value: unknown, name: string): string {
|
|
512
|
+
if (typeof value !== "string" || value === "") {
|
|
513
|
+
throw new Error(`${name} must be a non-empty string.`);
|
|
514
|
+
}
|
|
515
|
+
return value;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function safeInteger(value: unknown, name: string): number {
|
|
519
|
+
const number = typeof value === "bigint" ? Number(value) : value;
|
|
520
|
+
if (typeof number !== "number" || !Number.isSafeInteger(number) || number < 1) {
|
|
521
|
+
throw new Error(`${name} must be a positive safe integer.`);
|
|
522
|
+
}
|
|
523
|
+
return number;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function enumValue<const TValues extends readonly string[]>(
|
|
527
|
+
value: unknown,
|
|
528
|
+
values: TValues,
|
|
529
|
+
name: string,
|
|
530
|
+
): TValues[number] {
|
|
531
|
+
if (typeof value !== "string" || !values.includes(value)) {
|
|
532
|
+
throw new Error(`${name} has an invalid value.`);
|
|
533
|
+
}
|
|
534
|
+
return value;
|
|
535
|
+
}
|