opencode-engram 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +259 -0
- package/README.zh.md +257 -0
- package/README.zht.md +255 -0
- package/package.json +48 -0
- package/src/common/charting.ts +107 -0
- package/src/common/common.ts +74 -0
- package/src/common/config.ts +963 -0
- package/src/common/history-prompt.ts +72 -0
- package/src/common/plugin.ts +593 -0
- package/src/common/upstream-navigator-prompt.ts +131 -0
- package/src/core/index.ts +33 -0
- package/src/core/sdk-bridge.ts +73 -0
- package/src/core/session.ts +196 -0
- package/src/core/turn-index.ts +219 -0
- package/src/domain/adapter.ts +386 -0
- package/src/domain/clip-text.ts +86 -0
- package/src/domain/domain.ts +618 -0
- package/src/domain/preview.ts +132 -0
- package/src/domain/serialize.ts +409 -0
- package/src/domain/types.ts +321 -0
- package/src/runtime/charting.ts +73 -0
- package/src/runtime/debug.ts +155 -0
- package/src/runtime/logger.ts +34 -0
- package/src/runtime/message-io.ts +224 -0
- package/src/runtime/runtime.ts +1033 -0
- package/src/runtime/search.ts +1280 -0
- package/src/runtime/turn-resolve.ts +111 -0
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* domain.ts - Pure Domain Logic
|
|
3
|
+
*
|
|
4
|
+
* This module contains pure functions for computing domain values.
|
|
5
|
+
* It operates on normalized types, not SDK types directly.
|
|
6
|
+
*
|
|
7
|
+
* Responsibilities:
|
|
8
|
+
* - Turn computation (global stable turns)
|
|
9
|
+
* - Notes generation
|
|
10
|
+
* - Tool call summary aggregation
|
|
11
|
+
* - File references extraction
|
|
12
|
+
* - Section building with truncation
|
|
13
|
+
* - Preview computation
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type {
|
|
17
|
+
MessageRole,
|
|
18
|
+
ToolCallSummary,
|
|
19
|
+
ToolOutcome,
|
|
20
|
+
NormalizedMessage,
|
|
21
|
+
NormalizedPart,
|
|
22
|
+
NormalizedToolPart,
|
|
23
|
+
NormalizedFilePart,
|
|
24
|
+
Section,
|
|
25
|
+
SectionConvertContext,
|
|
26
|
+
AnyMessageMeta,
|
|
27
|
+
UserMessageMeta,
|
|
28
|
+
AssistantMessageMeta,
|
|
29
|
+
} from "./types.ts";
|
|
30
|
+
|
|
31
|
+
import { clipText } from "./clip-text.ts";
|
|
32
|
+
|
|
33
|
+
function shouldShowToolInput(part: NormalizedToolPart, ctx: SectionConvertContext) {
|
|
34
|
+
return ctx.visibleToolInputs.has(part.tool);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function shouldShowToolOutput(part: NormalizedToolPart, ctx: SectionConvertContext) {
|
|
38
|
+
return ctx.visibleToolOutputs.has(part.tool);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function truncateToolInputValue(
|
|
42
|
+
value: unknown,
|
|
43
|
+
maxLength: number,
|
|
44
|
+
): { value: unknown; truncated: boolean } {
|
|
45
|
+
if (typeof value === "string") {
|
|
46
|
+
if (value.length <= maxLength) {
|
|
47
|
+
return { value, truncated: false };
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
value: clipText(value, maxLength),
|
|
51
|
+
truncated: true,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (Array.isArray(value)) {
|
|
56
|
+
let truncated = false;
|
|
57
|
+
const next = value.map((item) => {
|
|
58
|
+
const result = truncateToolInputValue(item, maxLength);
|
|
59
|
+
truncated = truncated || result.truncated;
|
|
60
|
+
return result.value;
|
|
61
|
+
});
|
|
62
|
+
return { value: next, truncated };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (value && typeof value === "object") {
|
|
66
|
+
let truncated = false;
|
|
67
|
+
const nextEntries = Object.entries(value).map(([key, item]) => {
|
|
68
|
+
const result = truncateToolInputValue(item, maxLength);
|
|
69
|
+
truncated = truncated || result.truncated;
|
|
70
|
+
return [key, result.value] satisfies [string, unknown];
|
|
71
|
+
});
|
|
72
|
+
return {
|
|
73
|
+
value: Object.fromEntries(nextEntries),
|
|
74
|
+
truncated,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { value, truncated: false };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function buildToolInputPreview(
|
|
82
|
+
part: NormalizedToolPart,
|
|
83
|
+
ctx: SectionConvertContext,
|
|
84
|
+
): { input: Record<string, unknown> | undefined; truncated: boolean } {
|
|
85
|
+
if (!shouldShowToolInput(part, ctx)) {
|
|
86
|
+
return { input: undefined, truncated: false };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const result = truncateToolInputValue(part.input, ctx.maxToolInputLength);
|
|
90
|
+
return {
|
|
91
|
+
input: result.value as Record<string, unknown>,
|
|
92
|
+
truncated: result.truncated,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// =============================================================================
|
|
97
|
+
// Turn Computation
|
|
98
|
+
// =============================================================================
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Minimal message info required for turn computation.
|
|
102
|
+
*/
|
|
103
|
+
export interface TurnComputeItem {
|
|
104
|
+
id: string;
|
|
105
|
+
role: MessageRole;
|
|
106
|
+
time: number | undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Compute global stable turn numbers for a list of messages.
|
|
111
|
+
*
|
|
112
|
+
* Turn semantics:
|
|
113
|
+
* - Turn increments by 1 each time a "user" message appears
|
|
114
|
+
* - First "user" message is turn 1
|
|
115
|
+
* - Assistant messages share the turn of the preceding user message
|
|
116
|
+
*
|
|
117
|
+
* The input can be in any order. Items are normalized to chronological order first.
|
|
118
|
+
* For equal timestamps, messages use a deterministic tie-break:
|
|
119
|
+
* user before assistant, then message id, then original input order.
|
|
120
|
+
*
|
|
121
|
+
* @param items Messages in any order
|
|
122
|
+
* @returns Map from message id to turn number
|
|
123
|
+
*/
|
|
124
|
+
export function computeTurns(items: TurnComputeItem[]): Map<string, number> {
|
|
125
|
+
const ordered = items
|
|
126
|
+
.map((item, index) => ({ item, index }))
|
|
127
|
+
.sort((left, right) => {
|
|
128
|
+
const leftTime = left.item.time ?? Number.POSITIVE_INFINITY;
|
|
129
|
+
const rightTime = right.item.time ?? Number.POSITIVE_INFINITY;
|
|
130
|
+
const timeDiff = leftTime - rightTime;
|
|
131
|
+
if (timeDiff !== 0) {
|
|
132
|
+
return timeDiff;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (left.item.role !== right.item.role) {
|
|
136
|
+
return left.item.role === "user" ? -1 : 1;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const idDiff = left.item.id.localeCompare(right.item.id);
|
|
140
|
+
if (idDiff !== 0) {
|
|
141
|
+
return idDiff;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return left.index - right.index;
|
|
145
|
+
})
|
|
146
|
+
.map((entry) => entry.item);
|
|
147
|
+
|
|
148
|
+
const result = new Map<string, number>();
|
|
149
|
+
let turn = 0;
|
|
150
|
+
const fallbackTurn = 1;
|
|
151
|
+
|
|
152
|
+
for (const item of ordered) {
|
|
153
|
+
if (item.role === "user") {
|
|
154
|
+
turn += 1;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Preserve stable output for malformed sequences where no user appears yet.
|
|
158
|
+
result.set(item.id, turn === 0 ? fallbackTurn : turn);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// =============================================================================
|
|
165
|
+
// Notes Generation
|
|
166
|
+
// =============================================================================
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Compute short label notes for a message.
|
|
170
|
+
*
|
|
171
|
+
* Notes are derived from:
|
|
172
|
+
* - Compaction summary: "compacted summary"
|
|
173
|
+
* - Image attachments: "N image(s) attached"
|
|
174
|
+
*
|
|
175
|
+
* @param msg Normalized message metadata
|
|
176
|
+
* @param normalizedParts Normalized parts of the message
|
|
177
|
+
* @returns Array of short labels (empty array if no notes)
|
|
178
|
+
*/
|
|
179
|
+
export function computeNotes(
|
|
180
|
+
msg: NormalizedMessage,
|
|
181
|
+
normalizedParts: NormalizedPart[],
|
|
182
|
+
): string[] {
|
|
183
|
+
const notes: string[] = [];
|
|
184
|
+
|
|
185
|
+
if (msg.role === "assistant" && msg.summary === true) {
|
|
186
|
+
notes.push("compacted summary");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Count image attachments
|
|
190
|
+
const imageCount = normalizedParts.filter((p) => p.type === "image").length;
|
|
191
|
+
if (imageCount > 0) {
|
|
192
|
+
notes.push(imageCount === 1 ? "1 image attached" : `${imageCount} images attached`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return notes;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// =============================================================================
|
|
199
|
+
// Tool Call Summary
|
|
200
|
+
// =============================================================================
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Aggregate tool call statistics from normalized tool parts.
|
|
204
|
+
*
|
|
205
|
+
* @param parts All normalized parts (filters to tool parts internally)
|
|
206
|
+
* @returns Array of tool call summaries in appearance order
|
|
207
|
+
*/
|
|
208
|
+
export function computeToolCalls(parts: NormalizedPart[]): ToolCallSummary[] {
|
|
209
|
+
const toolParts = parts.filter(
|
|
210
|
+
(p): p is NormalizedToolPart => p.type === "tool",
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
if (toolParts.length === 0) {
|
|
214
|
+
return [];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Use Map to preserve insertion order while aggregating
|
|
218
|
+
const summaryMap = new Map<string, ToolCallSummary>();
|
|
219
|
+
|
|
220
|
+
for (const part of toolParts) {
|
|
221
|
+
const existing = summaryMap.get(part.tool);
|
|
222
|
+
if (existing) {
|
|
223
|
+
existing.total += 1;
|
|
224
|
+
if (part.status === "error") {
|
|
225
|
+
existing.errors += 1;
|
|
226
|
+
}
|
|
227
|
+
} else {
|
|
228
|
+
summaryMap.set(part.tool, {
|
|
229
|
+
tool: part.tool,
|
|
230
|
+
total: 1,
|
|
231
|
+
errors: part.status === "error" ? 1 : 0,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return Array.from(summaryMap.values());
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Format tool call summaries into display strings.
|
|
241
|
+
*
|
|
242
|
+
* Format:
|
|
243
|
+
* - "{count}× {tool}" when no errors
|
|
244
|
+
* - "{count}× {tool}: {errorCount}× error" when errors exist
|
|
245
|
+
*/
|
|
246
|
+
export function formatToolCallSummaries(summaries: ToolCallSummary[]): string[] {
|
|
247
|
+
return summaries.map((s) => {
|
|
248
|
+
if (s.errors > 0) {
|
|
249
|
+
return `${s.total}× ${s.tool}: ${s.errors}× error`;
|
|
250
|
+
}
|
|
251
|
+
return `${s.total}× ${s.tool}`;
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// =============================================================================
|
|
256
|
+
// File References
|
|
257
|
+
// =============================================================================
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Extract unique file paths from normalized parts.
|
|
261
|
+
*
|
|
262
|
+
* Only extracts from "file" type parts (not images).
|
|
263
|
+
* Preserves first-occurrence order.
|
|
264
|
+
*/
|
|
265
|
+
export function computeFileRefs(parts: NormalizedPart[]): string[] {
|
|
266
|
+
const seen = new Set<string>();
|
|
267
|
+
const refs: string[] = [];
|
|
268
|
+
|
|
269
|
+
for (const part of parts) {
|
|
270
|
+
if (part.type === "file") {
|
|
271
|
+
const p = part as NormalizedFilePart;
|
|
272
|
+
if (!seen.has(p.path)) {
|
|
273
|
+
seen.add(p.path);
|
|
274
|
+
refs.push(p.path);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return refs;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Extract user-facing attachment labels from normalized parts.
|
|
284
|
+
*
|
|
285
|
+
* Format:
|
|
286
|
+
* - Image attachments summary: "1 image" or "N images"
|
|
287
|
+
* - File references: path strings in first-seen order
|
|
288
|
+
*/
|
|
289
|
+
export function computeAttachments(parts: NormalizedPart[]): string[] {
|
|
290
|
+
const attachments: string[] = [];
|
|
291
|
+
const imageCount = parts.filter((p) => p.type === "image").length;
|
|
292
|
+
if (imageCount > 0) {
|
|
293
|
+
attachments.push(imageCount === 1 ? "1 image" : `${imageCount} images`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
attachments.push(...computeFileRefs(parts));
|
|
297
|
+
return attachments;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// =============================================================================
|
|
301
|
+
// Tool Outcome
|
|
302
|
+
// =============================================================================
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Derive the success trajectory from tool parts in a turn.
|
|
306
|
+
*
|
|
307
|
+
* Algorithm:
|
|
308
|
+
* 1. If the last tool part is pending/running → "running"
|
|
309
|
+
* 2. Filter to settled parts (completed/error).
|
|
310
|
+
* If none settled → "running" (all pending/running)
|
|
311
|
+
* 3. No errors among settled → "completed"
|
|
312
|
+
* 4. Errors exist, but a completed call follows the last error → "recovered"
|
|
313
|
+
* 5. Otherwise → "error"
|
|
314
|
+
*
|
|
315
|
+
* Caller is responsible for omitting the tool block entirely when
|
|
316
|
+
* there are no tool parts at all.
|
|
317
|
+
*/
|
|
318
|
+
export function computeOutcome(toolParts: NormalizedToolPart[]): ToolOutcome {
|
|
319
|
+
if (toolParts.length === 0) {
|
|
320
|
+
return "completed";
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const lastPart = toolParts[toolParts.length - 1]!;
|
|
324
|
+
if (lastPart.status === "pending" || lastPart.status === "running") {
|
|
325
|
+
return "running";
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const settled = toolParts.filter(
|
|
329
|
+
(p) => p.status === "completed" || p.status === "error",
|
|
330
|
+
);
|
|
331
|
+
if (settled.length === 0) {
|
|
332
|
+
return "running";
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const hasError = settled.some((p) => p.status === "error");
|
|
336
|
+
if (!hasError) {
|
|
337
|
+
return "completed";
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
let lastErrorIndex = -1;
|
|
341
|
+
for (let i = settled.length - 1; i >= 0; i--) {
|
|
342
|
+
if (settled[i]!.status === "error") {
|
|
343
|
+
lastErrorIndex = i;
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const hasCompletedAfterError = settled
|
|
349
|
+
.slice(lastErrorIndex + 1)
|
|
350
|
+
.some((p) => p.status === "completed");
|
|
351
|
+
|
|
352
|
+
return hasCompletedAfterError ? "recovered" : "error";
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// =============================================================================
|
|
356
|
+
// Modified Files
|
|
357
|
+
// =============================================================================
|
|
358
|
+
|
|
359
|
+
/** Tool names whose invocations modify files (OpenCode built-ins). */
|
|
360
|
+
const WRITE_TOOL_NAMES: ReadonlySet<string> = new Set(["edit", "write", "apply_patch"]);
|
|
361
|
+
|
|
362
|
+
const applyPatchFileHeaderRe = /^\*\*\* (?:Add|Update|Delete) File:\s+(.+)$/;
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Try to extract a file path from a tool call's input parameters.
|
|
366
|
+
*
|
|
367
|
+
* Supports common parameter names across OpenCode built-in tools.
|
|
368
|
+
*/
|
|
369
|
+
function extractFilePath(input: Readonly<Record<string, unknown>>): string | undefined {
|
|
370
|
+
for (const key of ["file_path", "path", "file"]) {
|
|
371
|
+
const val = input[key];
|
|
372
|
+
if (typeof val === "string" && val.length > 0) {
|
|
373
|
+
return val;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return undefined;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Extract modified file paths from apply_patch patch text.
|
|
381
|
+
*/
|
|
382
|
+
function extractApplyPatchPaths(input: Readonly<Record<string, unknown>>): string[] {
|
|
383
|
+
const patchText = input.patchText;
|
|
384
|
+
if (typeof patchText !== "string" || patchText.length === 0) {
|
|
385
|
+
return [];
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const paths: string[] = [];
|
|
389
|
+
const seen = new Set<string>();
|
|
390
|
+
|
|
391
|
+
for (const line of patchText.split(/\r?\n/)) {
|
|
392
|
+
const match = line.match(applyPatchFileHeaderRe);
|
|
393
|
+
if (match === null) {
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const filePath = match[1]!.trim();
|
|
398
|
+
if (filePath.length === 0 || seen.has(filePath)) {
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
seen.add(filePath);
|
|
403
|
+
paths.push(filePath);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return paths;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Extract unique file paths modified by write-type tool calls.
|
|
411
|
+
*
|
|
412
|
+
* Only captures paths from completed write tools (edit, write, apply_patch).
|
|
413
|
+
* Preserves first-occurrence order.
|
|
414
|
+
*/
|
|
415
|
+
export function computeModifiedFiles(toolParts: NormalizedToolPart[]): string[] {
|
|
416
|
+
const seen = new Set<string>();
|
|
417
|
+
const paths: string[] = [];
|
|
418
|
+
|
|
419
|
+
for (const part of toolParts) {
|
|
420
|
+
if (part.status !== "completed") {
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (!WRITE_TOOL_NAMES.has(part.tool)) {
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (part.tool === "apply_patch") {
|
|
429
|
+
const patchPaths = extractApplyPatchPaths(part.input);
|
|
430
|
+
for (const filePath of patchPaths) {
|
|
431
|
+
if (!seen.has(filePath)) {
|
|
432
|
+
seen.add(filePath);
|
|
433
|
+
paths.push(filePath);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (patchPaths.length > 0) {
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const filePath = extractFilePath(part.input);
|
|
443
|
+
if (filePath !== undefined && !seen.has(filePath)) {
|
|
444
|
+
seen.add(filePath);
|
|
445
|
+
paths.push(filePath);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return paths;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// =============================================================================
|
|
453
|
+
// Section Building
|
|
454
|
+
// =============================================================================
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Build a Section from a NormalizedPart with truncation handling.
|
|
458
|
+
*
|
|
459
|
+
* @param part Normalized part
|
|
460
|
+
* @param ctx Truncation context with max lengths
|
|
461
|
+
* @returns Section with truncation flag
|
|
462
|
+
*/
|
|
463
|
+
export function buildSection(
|
|
464
|
+
part: NormalizedPart,
|
|
465
|
+
ctx: SectionConvertContext,
|
|
466
|
+
): Section {
|
|
467
|
+
switch (part.type) {
|
|
468
|
+
case "text": {
|
|
469
|
+
const truncated = part.text.length > ctx.maxTextLength;
|
|
470
|
+
return {
|
|
471
|
+
type: "text",
|
|
472
|
+
partId: part.partId,
|
|
473
|
+
content: truncated ? clipText(part.text, ctx.maxTextLength) : part.text,
|
|
474
|
+
truncated,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
case "reasoning": {
|
|
479
|
+
const truncated = part.text.length > ctx.maxReasoningLength;
|
|
480
|
+
return {
|
|
481
|
+
type: "reasoning",
|
|
482
|
+
partId: part.partId,
|
|
483
|
+
content: truncated ? clipText(part.text, ctx.maxReasoningLength) : part.text,
|
|
484
|
+
truncated,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
case "tool": {
|
|
489
|
+
const visibleContent = shouldShowToolOutput(part, ctx) ? part.content : undefined;
|
|
490
|
+
const hasContent = visibleContent !== undefined;
|
|
491
|
+
const contentTruncated = hasContent && visibleContent.length > ctx.maxToolOutputLength;
|
|
492
|
+
const inputPreview = buildToolInputPreview(part, ctx);
|
|
493
|
+
return {
|
|
494
|
+
type: "tool",
|
|
495
|
+
partId: part.partId,
|
|
496
|
+
tool: part.tool,
|
|
497
|
+
title: part.title,
|
|
498
|
+
status: part.status,
|
|
499
|
+
input: inputPreview.input,
|
|
500
|
+
content: hasContent
|
|
501
|
+
? contentTruncated
|
|
502
|
+
? clipText(visibleContent, ctx.maxToolOutputLength)
|
|
503
|
+
: visibleContent
|
|
504
|
+
: undefined,
|
|
505
|
+
truncated: contentTruncated || inputPreview.truncated,
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
case "image": {
|
|
510
|
+
return {
|
|
511
|
+
type: "image",
|
|
512
|
+
partId: part.partId,
|
|
513
|
+
mime: part.mime,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
case "file": {
|
|
518
|
+
return {
|
|
519
|
+
type: "file",
|
|
520
|
+
partId: part.partId,
|
|
521
|
+
path: part.path,
|
|
522
|
+
mime: part.mime,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Build all sections from normalized parts.
|
|
530
|
+
*
|
|
531
|
+
* Filters out:
|
|
532
|
+
* - Ignored text parts
|
|
533
|
+
* - Empty text parts
|
|
534
|
+
*/
|
|
535
|
+
export function buildSections(
|
|
536
|
+
parts: NormalizedPart[],
|
|
537
|
+
ctx: SectionConvertContext,
|
|
538
|
+
): Section[] {
|
|
539
|
+
const sections: Section[] = [];
|
|
540
|
+
|
|
541
|
+
for (const part of parts) {
|
|
542
|
+
// Skip ignored or empty text
|
|
543
|
+
if (part.type === "text") {
|
|
544
|
+
if (part.ignored || !part.text.trim()) {
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
sections.push(buildSection(part, ctx));
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return sections;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// =============================================================================
|
|
556
|
+
// Message Metadata Building
|
|
557
|
+
// =============================================================================
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Build message metadata for a user message.
|
|
561
|
+
*/
|
|
562
|
+
export function buildUserMessageMeta(
|
|
563
|
+
msg: NormalizedMessage,
|
|
564
|
+
turn: number,
|
|
565
|
+
parts: NormalizedPart[],
|
|
566
|
+
): UserMessageMeta {
|
|
567
|
+
return {
|
|
568
|
+
id: msg.id,
|
|
569
|
+
role: "user",
|
|
570
|
+
turn,
|
|
571
|
+
time: msg.time,
|
|
572
|
+
notes: computeNotes(msg, parts),
|
|
573
|
+
attachments: computeAttachments(parts),
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Build message metadata for an assistant message.
|
|
579
|
+
*/
|
|
580
|
+
export function buildAssistantMessageMeta(
|
|
581
|
+
msg: NormalizedMessage,
|
|
582
|
+
turn: number,
|
|
583
|
+
parts: NormalizedPart[],
|
|
584
|
+
): AssistantMessageMeta {
|
|
585
|
+
const toolParts = parts.filter(
|
|
586
|
+
(part): part is NormalizedToolPart => part.type === "tool",
|
|
587
|
+
);
|
|
588
|
+
const toolCalls = computeToolCalls(toolParts);
|
|
589
|
+
|
|
590
|
+
const meta: AssistantMessageMeta = {
|
|
591
|
+
id: msg.id,
|
|
592
|
+
role: "assistant",
|
|
593
|
+
turn,
|
|
594
|
+
time: msg.time,
|
|
595
|
+
notes: computeNotes(msg, parts),
|
|
596
|
+
toolCalls,
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
if (toolParts.length > 0) {
|
|
600
|
+
meta.toolOutcome = computeOutcome(toolParts);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
return meta;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Build message metadata based on role.
|
|
608
|
+
*/
|
|
609
|
+
export function buildMessageMeta(
|
|
610
|
+
msg: NormalizedMessage,
|
|
611
|
+
turn: number,
|
|
612
|
+
parts: NormalizedPart[],
|
|
613
|
+
): AnyMessageMeta {
|
|
614
|
+
if (msg.role === "user") {
|
|
615
|
+
return buildUserMessageMeta(msg, turn, parts);
|
|
616
|
+
}
|
|
617
|
+
return buildAssistantMessageMeta(msg, turn, parts);
|
|
618
|
+
}
|