talon-agent 3.35.0 → 3.37.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/package.json +1 -1
- package/src/core/plugin/mcp.ts +13 -0
- package/src/core/plugin/registry.ts +12 -1
- package/src/core/plugin/types.ts +12 -0
- package/src/core/weaver/typing-loop.ts +39 -8
- package/src/frontend/native/chat-wire.ts +6 -2
- package/src/frontend/native/emit.ts +30 -11
- package/src/frontend/native/handlers.ts +79 -13
- package/src/frontend/native/history.ts +30 -6
- package/src/frontend/native/media.ts +271 -15
- package/src/frontend/native/protocol.ts +34 -1
- package/src/frontend/native/queue.ts +4 -7
- package/src/frontend/native/routes/chats.ts +45 -12
- package/src/frontend/native/routes/host.ts +17 -5
- package/src/frontend/native/routes/params.ts +21 -0
- package/src/frontend/native/runtime.ts +16 -4
- package/src/frontend/native/server.ts +2 -35
- package/src/frontend/native/status.ts +3 -1
- package/src/frontend/native/turn.ts +39 -17
- package/src/plugins/playwright/index.ts +18 -8
- package/src/storage/db.ts +7 -1
- package/src/storage/history.ts +4 -1
- package/src/storage/repositories/history-repo.ts +47 -1
- package/src/storage/sql/db.sql +6 -0
- package/src/storage/sql/history.sql +11 -10
- package/src/storage/sql/schema.sql +6 -1
- package/src/storage/sql/statements.generated.ts +21 -11
- package/src/util/paths.ts +11 -0
package/package.json
CHANGED
package/src/core/plugin/mcp.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { resolve } from "node:path";
|
|
8
|
+
import { logWarn } from "../../util/log.js";
|
|
8
9
|
import { wrapMcpServer } from "../../util/mcp-launcher.js";
|
|
9
10
|
import { isBunRuntime } from "../../util/runtime.js";
|
|
10
11
|
import { registry, reloadState } from "./registry.js";
|
|
@@ -51,6 +52,18 @@ export function getPluginMcpServers(
|
|
|
51
52
|
for (const { plugin, envVars } of registry.all) {
|
|
52
53
|
// Skip plugins not in the allow-list when filtering
|
|
53
54
|
if (only !== undefined && !only.includes(plugin.name)) continue;
|
|
55
|
+
|
|
56
|
+
// Let the plugin re-materialize on-disk state the child will read. Runs
|
|
57
|
+
// on every build so a deleted file heals within a turn; never fatal.
|
|
58
|
+
try {
|
|
59
|
+
if (plugin.mcpServer || plugin.mcpServerPath) plugin.prepareMcpSpawn?.();
|
|
60
|
+
} catch (err) {
|
|
61
|
+
logWarn(
|
|
62
|
+
"plugin",
|
|
63
|
+
`${plugin.name}: prepareMcpSpawn failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
54
67
|
const baseEnv = buildBridgeEnv(bridgeUrl, chatId, envVars);
|
|
55
68
|
|
|
56
69
|
if (plugin.mcpServer) {
|
|
@@ -119,6 +119,17 @@ export const _deps = {
|
|
|
119
119
|
// dynamic import() rejects bare drive-letter paths (e.g. C:\...).
|
|
120
120
|
// Leave node: specifiers, relative paths, and URLs unchanged.
|
|
121
121
|
const isAbsFilePath = /^[a-zA-Z]:[/\\]/.test(path) || path.startsWith("/");
|
|
122
|
-
|
|
122
|
+
if (!isAbsFilePath) return import(path);
|
|
123
|
+
|
|
124
|
+
// Cache-bust with the reload timestamp. ESM caches modules by resolved
|
|
125
|
+
// URL forever, so without a changing query a hot reload re-imports the
|
|
126
|
+
// *old* module object and the plugin's own source edits are invisible
|
|
127
|
+
// until a full restart — silently, since the stale module still loads
|
|
128
|
+
// fine (observed 2026-09-16: a rewritten plugin kept reporting its
|
|
129
|
+
// previous version through three reloads). The timestamp only moves per
|
|
130
|
+
// reload, so every module in one load cycle shares a key.
|
|
131
|
+
const url = pathToFileURL(path);
|
|
132
|
+
url.searchParams.set("talonReloadAt", reloadState.lastReloadAt);
|
|
133
|
+
return import(url.href);
|
|
123
134
|
},
|
|
124
135
|
};
|
package/src/core/plugin/types.ts
CHANGED
|
@@ -85,6 +85,18 @@ export interface TalonPlugin {
|
|
|
85
85
|
readonly args: readonly string[];
|
|
86
86
|
};
|
|
87
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Materialize anything the MCP child needs on disk before it is spawned.
|
|
90
|
+
*
|
|
91
|
+
* MCP children are spawned lazily and respawned on demand for the lifetime
|
|
92
|
+
* of the daemon, so state written once at load time can go missing long
|
|
93
|
+
* before the last spawn. This hook runs every time the MCP server map is
|
|
94
|
+
* built, making such state self-healing instead of broken-until-restart.
|
|
95
|
+
* Must be synchronous, cheap, and idempotent; throwing is swallowed and
|
|
96
|
+
* logged (a failed prepare must not take the whole server map down).
|
|
97
|
+
*/
|
|
98
|
+
prepareMcpSpawn?(): void;
|
|
99
|
+
|
|
88
100
|
/**
|
|
89
101
|
* Map plugin config to env vars for the MCP subprocess and action handlers.
|
|
90
102
|
* Called once at load time. Values are set on process.env for the main
|
|
@@ -10,6 +10,16 @@ import { logWarn } from "../../util/log.js";
|
|
|
10
10
|
/** Platforms expire typing indicators after ~5s; refresh under that. */
|
|
11
11
|
const TYPING_REFRESH_MS = 4000;
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Consecutive failures after which the loop gives up for the rest of the
|
|
15
|
+
* turn. A chat that has gone away (deleted, kicked, migrated) fails every
|
|
16
|
+
* single refresh, and a long turn then logs one warning every 4 seconds for
|
|
17
|
+
* a cosmetic indicator — 55 of them in 30 seconds on 2026-09-15, all
|
|
18
|
+
* "sendChatAction failed (400: chat not found)". Three strikes is enough to
|
|
19
|
+
* tell a transient blip from a chat that is simply gone.
|
|
20
|
+
*/
|
|
21
|
+
const MAX_CONSECUTIVE_FAILURES = 3;
|
|
22
|
+
|
|
13
23
|
export type SendTyping = (
|
|
14
24
|
numericChatId: number,
|
|
15
25
|
stringId?: string,
|
|
@@ -22,15 +32,36 @@ export function startTypingLoop(
|
|
|
22
32
|
stringId: string,
|
|
23
33
|
intervalMs = TYPING_REFRESH_MS,
|
|
24
34
|
): () => void {
|
|
35
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
36
|
+
let consecutiveFailures = 0;
|
|
37
|
+
|
|
38
|
+
const stop = () => {
|
|
39
|
+
if (timer !== undefined) clearInterval(timer);
|
|
40
|
+
timer = undefined;
|
|
41
|
+
};
|
|
42
|
+
|
|
25
43
|
const send = (label: string) => {
|
|
26
|
-
sendTyping(numericChatId, stringId).
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
)
|
|
31
|
-
|
|
44
|
+
sendTyping(numericChatId, stringId).then(
|
|
45
|
+
() => {
|
|
46
|
+
consecutiveFailures = 0;
|
|
47
|
+
},
|
|
48
|
+
(err: unknown) => {
|
|
49
|
+
consecutiveFailures++;
|
|
50
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
51
|
+
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
52
|
+
stop();
|
|
53
|
+
logWarn(
|
|
54
|
+
"dispatcher",
|
|
55
|
+
`${label} failed ${consecutiveFailures}x in a row — no typing indicator for the rest of this turn: ${reason}`,
|
|
56
|
+
);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
logWarn("dispatcher", `${label} failed: ${reason}`);
|
|
60
|
+
},
|
|
61
|
+
);
|
|
32
62
|
};
|
|
63
|
+
|
|
33
64
|
send("sendTyping");
|
|
34
|
-
|
|
35
|
-
return
|
|
65
|
+
timer = setInterval(() => send("sendTyping interval"), intervalMs);
|
|
66
|
+
return stop;
|
|
36
67
|
}
|
|
@@ -14,14 +14,18 @@ import type { ChatEntry } from "./chats.js";
|
|
|
14
14
|
import type { ClientChat, QueuedMessage } from "./protocol.js";
|
|
15
15
|
import type { NativeRuntime } from "./runtime.js";
|
|
16
16
|
|
|
17
|
-
/** Project the stored queue entry to its wire shape (text +
|
|
17
|
+
/** Project the stored queue entry to its wire shape (text + attachments). */
|
|
18
18
|
function toQueued(
|
|
19
19
|
runtime: NativeRuntime,
|
|
20
20
|
chatId: string,
|
|
21
21
|
): QueuedMessage | undefined {
|
|
22
22
|
const q = runtime.queuedByChat.get(chatId);
|
|
23
23
|
if (!q) return undefined;
|
|
24
|
-
return {
|
|
24
|
+
return {
|
|
25
|
+
text: q.text,
|
|
26
|
+
hasAttachment: q.attachments.length > 0,
|
|
27
|
+
attachmentCount: q.attachments.length,
|
|
28
|
+
};
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
export function toClientChat(
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
BOT_SENDER_ID,
|
|
13
13
|
USER_SENDER_ID,
|
|
14
14
|
type ClientButton,
|
|
15
|
+
type ClientAttachment,
|
|
15
16
|
type ClientMessage,
|
|
16
17
|
} from "./protocol.js";
|
|
17
18
|
import type { NativeRuntime } from "./runtime.js";
|
|
@@ -104,43 +105,61 @@ export function emitPhoto(
|
|
|
104
105
|
return id;
|
|
105
106
|
}
|
|
106
107
|
|
|
108
|
+
/** The sidebar preview for a message whose real content is its files. */
|
|
109
|
+
function attachmentPreview(attachments: ClientAttachment[]): string {
|
|
110
|
+
if (attachments.length === 1) {
|
|
111
|
+
const only = attachments[0]!;
|
|
112
|
+
return only.image ? "[photo]" : `[${only.name}]`;
|
|
113
|
+
}
|
|
114
|
+
return `[${attachments.length} files]`;
|
|
115
|
+
}
|
|
116
|
+
|
|
107
117
|
/** Persist + broadcast a user message; returns its numeric id so the turn
|
|
108
118
|
* hands the model that same id (as `[msg_id:N]`) to react/reply to.
|
|
109
|
-
*
|
|
119
|
+
* Attached files ride along on the message and are persisted with it. */
|
|
110
120
|
export function emitUser(
|
|
111
121
|
runtime: NativeRuntime,
|
|
112
122
|
entry: ChatEntry,
|
|
113
123
|
text: string,
|
|
114
|
-
|
|
115
|
-
attachmentPath?: string,
|
|
124
|
+
attachments: ClientAttachment[] = [],
|
|
116
125
|
): number {
|
|
117
126
|
const id = runtime.nextId();
|
|
118
127
|
const ts = Date.now();
|
|
128
|
+
// `imagePath` stays in sync with the first image so clients written against
|
|
129
|
+
// the single-image shape keep rendering it.
|
|
130
|
+
const firstImage = attachments.find((a) => a.image);
|
|
119
131
|
const message: ClientMessage = {
|
|
120
132
|
id: String(id),
|
|
121
133
|
chatId: entry.id,
|
|
122
134
|
role: "user",
|
|
123
135
|
text,
|
|
124
136
|
ts,
|
|
125
|
-
...(
|
|
137
|
+
...(firstImage ? { imagePath: firstImage.url } : {}),
|
|
138
|
+
...(attachments.length ? { attachments } : {}),
|
|
126
139
|
};
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
140
|
+
// Persist the attachment records so the files re-render on history reload
|
|
141
|
+
// (rehydrated in `history.ts`) instead of vanishing to a text-only
|
|
142
|
+
// placeholder. mediaType/filePath keep the pre-multi-file row shape
|
|
143
|
+
// populated from the first attachment. Caption stays as text.
|
|
144
|
+
const first = attachments[0];
|
|
130
145
|
pushMessage(entry.id, {
|
|
131
146
|
msgId: id,
|
|
132
147
|
senderId: USER_SENDER_ID,
|
|
133
148
|
senderName: "User",
|
|
134
149
|
text,
|
|
135
150
|
timestamp: ts,
|
|
136
|
-
...(
|
|
151
|
+
...(first
|
|
137
152
|
? {
|
|
138
|
-
mediaType: "photo" as const,
|
|
139
|
-
|
|
153
|
+
mediaType: first.image ? ("photo" as const) : ("document" as const),
|
|
154
|
+
filePath: first.path,
|
|
155
|
+
attachments,
|
|
140
156
|
}
|
|
141
157
|
: {}),
|
|
142
158
|
});
|
|
143
|
-
runtime.chats.touch(
|
|
159
|
+
runtime.chats.touch(
|
|
160
|
+
entry.id,
|
|
161
|
+
attachments.length ? text || attachmentPreview(attachments) : text,
|
|
162
|
+
);
|
|
144
163
|
maybeAutoTitle(runtime, entry, text);
|
|
145
164
|
runtime.broadcast({ kind: "message", chatId: entry.id, message });
|
|
146
165
|
broadcastChatUpdated(runtime, entry);
|
|
@@ -18,7 +18,12 @@ import {
|
|
|
18
18
|
} from "./extensions.js";
|
|
19
19
|
import { historyPage, searchHistory } from "./history.js";
|
|
20
20
|
import { readLogEntries } from "./logs.js";
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
describeAttachment,
|
|
23
|
+
MAX_UPLOAD_BYTES,
|
|
24
|
+
resolveUpload,
|
|
25
|
+
saveUploadStream,
|
|
26
|
+
} from "./media.js";
|
|
22
27
|
import {
|
|
23
28
|
effortLevels,
|
|
24
29
|
listBackends,
|
|
@@ -30,11 +35,57 @@ import {
|
|
|
30
35
|
import { setQueued } from "./queue.js";
|
|
31
36
|
import { resetChat } from "./reset.js";
|
|
32
37
|
import type { NativeRuntime } from "./runtime.js";
|
|
33
|
-
import type { BridgeServerHandlers } from "./server.js";
|
|
38
|
+
import type { BridgeServerHandlers, SendOptions } from "./server.js";
|
|
39
|
+
import type { ClientAttachment } from "./protocol.js";
|
|
34
40
|
import { configSnapshot, applyConfigUpdate } from "./settings.js";
|
|
35
41
|
import { bridgeStatus, broadcastStatus } from "./status.js";
|
|
42
|
+
import { logWarn } from "../../util/log.js";
|
|
36
43
|
import { interruptTurn, isBusy, liveTurnEvents, startTurn } from "./turn.js";
|
|
37
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Turn a `/send` body's attachment references into the records this daemon
|
|
47
|
+
* minted at upload time. Both wire shapes land here: the `attachments` list
|
|
48
|
+
* multi-file clients send, and the `imagePath`/`attachmentPath` pair older
|
|
49
|
+
* single-image clients send. References the daemon cannot account for are
|
|
50
|
+
* dropped — a message can only ever point the model at a file the daemon
|
|
51
|
+
* itself wrote to its uploads dir.
|
|
52
|
+
*/
|
|
53
|
+
function resolveAttachments(
|
|
54
|
+
runtime: NativeRuntime,
|
|
55
|
+
opts: SendOptions | undefined,
|
|
56
|
+
): ClientAttachment[] {
|
|
57
|
+
const refs = [...(opts?.attachments ?? [])];
|
|
58
|
+
if (!refs.length && (opts?.imagePath || opts?.attachmentPath)) {
|
|
59
|
+
refs.push({ url: opts.imagePath, path: opts.attachmentPath });
|
|
60
|
+
}
|
|
61
|
+
const resolved: ClientAttachment[] = [];
|
|
62
|
+
const dropped: string[] = [];
|
|
63
|
+
for (const ref of refs) {
|
|
64
|
+
const found = resolveUpload(runtime, ref);
|
|
65
|
+
if (!found) {
|
|
66
|
+
dropped.push(ref.url ?? ref.path ?? "(empty reference)");
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
// Same file referenced twice (a re-send of a queued message, say) stays
|
|
70
|
+
// one attachment.
|
|
71
|
+
if (!resolved.some((a) => a.path === found.path)) {
|
|
72
|
+
resolved.push(found);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// A dropped reference is how "I attached a file and the agent never saw it"
|
|
76
|
+
// happens, and it used to be entirely silent. The usual cause is a media id
|
|
77
|
+
// minted by a previous daemon run — `runtime.uploads` is per-run, so a
|
|
78
|
+
// client that staged a file before a restart and sent it after points at
|
|
79
|
+
// nothing.
|
|
80
|
+
if (dropped.length) {
|
|
81
|
+
logWarn(
|
|
82
|
+
"native",
|
|
83
|
+
`dropped ${dropped.length} unknown attachment reference(s) from /send: ${dropped.join(", ")} — re-upload the file (ids do not survive a daemon restart)`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return resolved;
|
|
87
|
+
}
|
|
88
|
+
|
|
38
89
|
export function buildBridgeHandlers(
|
|
39
90
|
runtime: NativeRuntime,
|
|
40
91
|
): BridgeServerHandlers {
|
|
@@ -49,29 +100,44 @@ export function buildBridgeHandlers(
|
|
|
49
100
|
search: (query, chatId) => searchHistory(runtime, query, chatId),
|
|
50
101
|
send: (id, text, opts) => {
|
|
51
102
|
const entry = chats.get(id) ?? chats.ensure(id);
|
|
103
|
+
// Resolve the client's references into the records this daemon minted
|
|
104
|
+
// at upload time — dropping anything it can't account for.
|
|
105
|
+
const attachments = resolveAttachments(runtime, opts);
|
|
52
106
|
// A turn is already running for this chat — don't interrupt it. Park the
|
|
53
107
|
// message as the single queued follow-up (synced to every client); it
|
|
54
108
|
// auto-sends when the running turn ends. `isBusy` reads `liveTurns`,
|
|
55
109
|
// which `runTurn` sets synchronously, so even a rapid second /send from
|
|
56
110
|
// any client is caught here rather than starting a concurrent turn.
|
|
57
111
|
if (isBusy(runtime, entry.id)) {
|
|
58
|
-
setQueued(runtime, entry.id, {
|
|
59
|
-
text,
|
|
60
|
-
imagePath: opts?.imagePath,
|
|
61
|
-
attachmentPath: opts?.attachmentPath,
|
|
62
|
-
});
|
|
112
|
+
setQueued(runtime, entry.id, { text, attachments });
|
|
63
113
|
return;
|
|
64
114
|
}
|
|
65
|
-
startTurn(runtime, entry, text,
|
|
115
|
+
startTurn(runtime, entry, text, { attachments });
|
|
66
116
|
},
|
|
67
117
|
queueMessage: (id, text) => {
|
|
68
|
-
// Edit/replace the queued follow-up
|
|
118
|
+
// Edit/replace the queued follow-up's text, keeping whatever files were
|
|
119
|
+
// queued with it. Empty text cancels the whole follow-up, attachments
|
|
120
|
+
// included — the client's queue editor offers no other way to drop it.
|
|
69
121
|
const entry = chats.get(id);
|
|
70
|
-
if (entry)
|
|
122
|
+
if (!entry) return;
|
|
123
|
+
const attachments = text.trim()
|
|
124
|
+
? (runtime.queuedByChat.get(entry.id)?.attachments ?? [])
|
|
125
|
+
: [];
|
|
126
|
+
setQueued(runtime, entry.id, { text, attachments });
|
|
71
127
|
},
|
|
72
|
-
upload: async (filename,
|
|
73
|
-
const
|
|
74
|
-
|
|
128
|
+
upload: async (filename, contentType, body) => {
|
|
129
|
+
const saved = await saveUploadStream(
|
|
130
|
+
runtime,
|
|
131
|
+
filename,
|
|
132
|
+
body,
|
|
133
|
+
MAX_UPLOAD_BYTES,
|
|
134
|
+
);
|
|
135
|
+
return describeAttachment(runtime, {
|
|
136
|
+
path: saved.path,
|
|
137
|
+
name: filename,
|
|
138
|
+
size: saved.size,
|
|
139
|
+
contentType,
|
|
140
|
+
});
|
|
75
141
|
},
|
|
76
142
|
listModels: (chatId) => listModels(runtime, chatId),
|
|
77
143
|
setModel: (id, model) => setModel(runtime, id, model),
|
|
@@ -3,18 +3,21 @@
|
|
|
3
3
|
* (attached images get a fresh /media URL, assistant rows their turn meta).
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { basename } from "node:path";
|
|
6
7
|
import {
|
|
7
8
|
getRecentHistory,
|
|
8
9
|
getHistoryBefore,
|
|
9
10
|
searchHistoryMessages,
|
|
10
11
|
type HistoryMessage,
|
|
12
|
+
type MessageAttachment,
|
|
11
13
|
} from "../../storage/history.js";
|
|
12
14
|
import { isDeliveryTool } from "../../core/tools/index.js";
|
|
13
15
|
import type { ChatEntry } from "./chats.js";
|
|
14
16
|
import { refreshContext } from "./context.js";
|
|
15
|
-
import {
|
|
17
|
+
import { contentTypeFor, rehydrateAttachment } from "./media.js";
|
|
16
18
|
import {
|
|
17
19
|
historyToClientMessage,
|
|
20
|
+
type ClientAttachment,
|
|
18
21
|
type ClientMessage,
|
|
19
22
|
type SearchResult,
|
|
20
23
|
} from "./protocol.js";
|
|
@@ -29,11 +32,32 @@ function hydrateHistoryRow(
|
|
|
29
32
|
row: HistoryMessage,
|
|
30
33
|
): ClientMessage {
|
|
31
34
|
const msg = historyToClientMessage(row, chatId);
|
|
32
|
-
// Re-hydrate
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
// Re-hydrate attached files: media ids are per-daemon-run, so each stored
|
|
36
|
+
// attachment is re-registered and handed a fresh /media URL. Rows written
|
|
37
|
+
// before multi-file attachments carry only mediaType + filePath, which is
|
|
38
|
+
// projected into the same one-element list so every client renders history
|
|
39
|
+
// the same way regardless of when it was recorded.
|
|
40
|
+
const stored: ClientAttachment[] = row.attachments?.length
|
|
41
|
+
? row.attachments.map((a: MessageAttachment) =>
|
|
42
|
+
rehydrateAttachment(runtime, { ...a, url: "" }),
|
|
43
|
+
)
|
|
44
|
+
: row.filePath &&
|
|
45
|
+
(row.mediaType === "photo" || row.mediaType === "document")
|
|
46
|
+
? [
|
|
47
|
+
rehydrateAttachment(runtime, {
|
|
48
|
+
path: row.filePath,
|
|
49
|
+
name: basename(row.filePath),
|
|
50
|
+
size: 0,
|
|
51
|
+
mimeType: contentTypeFor(row.filePath),
|
|
52
|
+
image: row.mediaType === "photo",
|
|
53
|
+
url: "",
|
|
54
|
+
}),
|
|
55
|
+
]
|
|
56
|
+
: [];
|
|
57
|
+
if (stored.length) {
|
|
58
|
+
msg.attachments = stored;
|
|
59
|
+
const firstImage = stored.find((a) => a.image);
|
|
60
|
+
if (firstImage) msg.imagePath = firstImage.url;
|
|
37
61
|
}
|
|
38
62
|
// Re-hydrate turn meta (tool timeline + stats) for assistant rows.
|
|
39
63
|
if (msg.role === "assistant") {
|