talon-agent 3.33.3 → 3.34.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 +6 -5
- package/src/app.ts +16 -8
- package/src/backend/claude-sdk/stream.ts +76 -53
- package/src/backend/remote-server/turn.ts +58 -55
- package/src/bootstrap.ts +120 -79
- package/src/cli/setup.ts +375 -349
- package/src/core/background/cron-spec.ts +273 -0
- package/src/core/background/heartbeat/agent.ts +167 -104
- package/src/core/engine/backend-controller/pool.ts +2 -0
- package/src/core/engine/backend-controller/state.ts +25 -12
- package/src/core/engine/gateway-actions/cron.ts +28 -292
- package/src/core/engine/gateway-routes.ts +239 -0
- package/src/core/engine/gateway.ts +66 -238
- package/src/core/vfs/mounts/files.ts +128 -115
- package/src/core/weaver/shuttle.ts +29 -2
- package/src/core/weaver/weaver.ts +37 -6
- package/src/frontend/discord/callbacks/components/agent-buttons.ts +82 -0
- package/src/frontend/discord/callbacks/components/backend-select.ts +149 -0
- package/src/frontend/discord/callbacks/components/effort.ts +72 -0
- package/src/frontend/discord/callbacks/components/index.ts +120 -0
- package/src/frontend/discord/callbacks/components/metrics.ts +24 -0
- package/src/frontend/discord/callbacks/components/model-nav.ts +93 -0
- package/src/frontend/discord/callbacks/components/model-select.ts +118 -0
- package/src/frontend/discord/callbacks/components/model.ts +33 -0
- package/src/frontend/discord/callbacks/components/pulse.ts +93 -0
- package/src/frontend/discord/callbacks/components/settings.ts +243 -0
- package/src/frontend/discord/callbacks/components/types.ts +34 -0
- package/src/frontend/discord/callbacks/index.ts +4 -4
- package/src/frontend/discord/connection.ts +36 -0
- package/src/frontend/discord/diagnostics.ts +62 -0
- package/src/frontend/discord/guild-policy.ts +89 -0
- package/src/frontend/discord/index.ts +44 -305
- package/src/frontend/discord/outbound.ts +61 -0
- package/src/frontend/discord/ready.ts +73 -0
- package/src/frontend/discord/runtime.ts +55 -0
- package/src/frontend/native/chat-lifecycle.ts +39 -0
- package/src/frontend/native/chat-wire.ts +69 -0
- package/src/frontend/native/context.ts +106 -0
- package/src/frontend/native/control.ts +78 -0
- package/src/frontend/native/emit.ts +167 -0
- package/src/frontend/native/empty-chat-sweep.ts +50 -0
- package/src/frontend/native/handlers.ts +121 -0
- package/src/frontend/native/history.ts +101 -0
- package/src/frontend/native/index.ts +90 -1293
- package/src/frontend/native/media.ts +43 -0
- package/src/frontend/native/models.ts +221 -0
- package/src/frontend/native/queue.ts +47 -0
- package/src/frontend/native/reset.ts +50 -0
- package/src/frontend/native/routes/chats.ts +115 -0
- package/src/frontend/native/routes/daemon.ts +70 -0
- package/src/frontend/native/routes/host.ts +151 -0
- package/src/frontend/native/routes/index.ts +22 -0
- package/src/frontend/native/routes/mesh.ts +72 -0
- package/src/frontend/native/routes/models.ts +54 -0
- package/src/frontend/native/routes/params.ts +29 -0
- package/src/frontend/native/routes/pre-auth.ts +94 -0
- package/src/frontend/native/routes/table.ts +92 -0
- package/src/frontend/native/runtime.ts +109 -0
- package/src/frontend/native/server.ts +30 -555
- package/src/frontend/native/status.ts +26 -0
- package/src/frontend/native/tool-result.ts +48 -0
- package/src/frontend/native/turn.ts +341 -0
- package/src/frontend/whatsapp/access.ts +67 -0
- package/src/frontend/whatsapp/connection.ts +280 -0
- package/src/frontend/whatsapp/inbound.ts +327 -0
- package/src/frontend/whatsapp/index.ts +28 -599
- package/src/frontend/whatsapp/runtime.ts +74 -0
- package/src/plugins/github/provision.ts +1 -1
- package/src/storage/metrics.ts +18 -0
- package/src/storage/session-record.ts +29 -0
- package/src/storage/sessions.ts +24 -0
- package/src/util/boot-timer.ts +31 -0
- package/src/util/concurrency.ts +28 -0
- package/src/frontend/discord/callbacks/components.ts +0 -793
- package/src/frontend/discord/callbacks/shared.ts +0 -22
|
@@ -8,137 +8,28 @@
|
|
|
8
8
|
* a remote Android app over a token-authed LAN connection, a web client —
|
|
9
9
|
* works identically.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* backends) — mirroring the terminal renderer's delivery semantics.
|
|
11
|
+
* This file is wiring only: it constructs the shared runtime (runtime.ts),
|
|
12
|
+
* binds the bridge handlers (handlers.ts) to a server, and owns the
|
|
13
|
+
* frontend lifecycle. The behaviour lives in the modules the handlers
|
|
14
|
+
* delegate to — chat-wire, context, emit, turn, models, history, ….
|
|
16
15
|
*/
|
|
17
16
|
|
|
18
17
|
import type { TalonConfig } from "../../util/config.js";
|
|
19
|
-
import { isBunRuntime } from "../../util/runtime.js";
|
|
20
18
|
import type { ContextManager } from "../../core/types.js";
|
|
21
19
|
import type { Gateway } from "../../core/engine/gateway.js";
|
|
22
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
23
|
-
import { basename, dirname, join, resolve } from "node:path";
|
|
24
|
-
import { spawn } from "node:child_process";
|
|
25
20
|
import { log, logError } from "../../util/log.js";
|
|
26
|
-
import { dirs, files } from "../../util/paths.js";
|
|
27
|
-
import { PKG_ROOT } from "../../cli/context.js";
|
|
28
|
-
import { getSessionInfo } from "../../storage/sessions.js";
|
|
29
|
-
import { buildContextDisplay } from "../shared/status-context.js";
|
|
30
|
-
import { resolveActiveModelForChat } from "../../core/models/active-model.js";
|
|
31
|
-
import { forceDream } from "../../core/background/dream.js";
|
|
32
|
-
import { execute } from "../../core/engine/dispatcher.js";
|
|
33
|
-
import { toolInputToRecord } from "../../core/agent-runtime/events.js";
|
|
34
|
-
import { isDeliveryTool } from "../../core/tools/index.js";
|
|
35
|
-
import {
|
|
36
|
-
pushMessage,
|
|
37
|
-
getRecentHistory,
|
|
38
|
-
getHistoryBefore,
|
|
39
|
-
searchHistoryMessages,
|
|
40
|
-
clearHistory,
|
|
41
|
-
} from "../../storage/history.js";
|
|
42
|
-
import { recordTurnMeta, getTurnMeta, clearTurnMeta } from "./turn-meta.js";
|
|
43
|
-
import {
|
|
44
|
-
getChatSettings,
|
|
45
|
-
setChatEffort,
|
|
46
|
-
setChatModelForBackend,
|
|
47
|
-
getChatModelForBackend,
|
|
48
|
-
setChatBackend,
|
|
49
|
-
setChatPulse,
|
|
50
|
-
EFFORT_LEVELS,
|
|
51
|
-
type EffortLevel,
|
|
52
|
-
} from "../../storage/chat-settings.js";
|
|
53
|
-
import { resetSession } from "../../storage/sessions.js";
|
|
54
|
-
import { resetPulseCheckpoint } from "../../core/background/pulse.js";
|
|
55
|
-
import { configSnapshot, applyConfigUpdate } from "./settings.js";
|
|
56
|
-
import {
|
|
57
|
-
pluginItems,
|
|
58
|
-
skillItems,
|
|
59
|
-
togglePlugin,
|
|
60
|
-
toggleSkill,
|
|
61
|
-
} from "./extensions.js";
|
|
62
|
-
import { resolveModel } from "../../core/models/catalog.js";
|
|
63
|
-
import {
|
|
64
|
-
getBackendForChat,
|
|
65
|
-
getBackendIdForChat,
|
|
66
|
-
getPooledBackend,
|
|
67
|
-
acquireBackendInstance,
|
|
68
|
-
listAvailableBackends,
|
|
69
|
-
rebindChat,
|
|
70
|
-
} from "../../core/engine/backend-controller/index.js";
|
|
71
|
-
import { getActiveReasoningLevels } from "../shared/reasoning-levels.js";
|
|
72
|
-
import { NativeChats, DEFAULT_CHAT_TITLE, type ChatEntry } from "./chats.js";
|
|
73
|
-
import { extractSessionName } from "../../util/session-name.js";
|
|
74
|
-
import { BridgeServer, type BridgeServerHandlers } from "./server.js";
|
|
75
21
|
import { createNativeActionHandler } from "./actions.js";
|
|
76
|
-
import {
|
|
22
|
+
import { loadOrCreateBridgeToken } from "./auth.js";
|
|
23
|
+
import { warmContextCache } from "./context.js";
|
|
77
24
|
import { removeBridgeDiscovery, writeBridgeDiscovery } from "./discovery.js";
|
|
25
|
+
import { emitAssistant, emitPhoto } from "./emit.js";
|
|
26
|
+
import { startEmptyChatSweep } from "./empty-chat-sweep.js";
|
|
27
|
+
import { buildBridgeHandlers } from "./handlers.js";
|
|
28
|
+
import { createNativeRuntime, type NativeRuntime } from "./runtime.js";
|
|
29
|
+
import { BridgeServer } from "./server.js";
|
|
78
30
|
import { isLoopbackHost, loadOrCreateBridgeTlsIdentity } from "./tls.js";
|
|
79
|
-
import { loadOrCreateBridgeToken } from "./auth.js";
|
|
80
|
-
import { readLogEntries } from "./logs.js";
|
|
81
|
-
import {
|
|
82
|
-
BRIDGE_PROTOCOL_VERSION,
|
|
83
|
-
BOT_SENDER_ID,
|
|
84
|
-
USER_SENDER_ID,
|
|
85
|
-
historyToClientMessage,
|
|
86
|
-
type BackendOption,
|
|
87
|
-
type BridgeEvent,
|
|
88
|
-
type BridgeStatus,
|
|
89
|
-
type ClientButton,
|
|
90
|
-
type ClientChat,
|
|
91
|
-
type ClientMessage,
|
|
92
|
-
type ClientToolCall,
|
|
93
|
-
type ContextInfo,
|
|
94
|
-
type ModelOption,
|
|
95
|
-
type QueuedMessage,
|
|
96
|
-
type SearchResult,
|
|
97
|
-
} from "./protocol.js";
|
|
98
|
-
|
|
99
|
-
/** Best-effort JSON stringify that never throws (circular refs → String()). */
|
|
100
|
-
function safeJson(value: unknown): string {
|
|
101
|
-
try {
|
|
102
|
-
return JSON.stringify(value, null, 2);
|
|
103
|
-
} catch {
|
|
104
|
-
return String(value);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
31
|
|
|
108
|
-
|
|
109
|
-
* Reduce a tool's raw result to a readable, bounded string for the app's
|
|
110
|
-
* expanded tool view. MCP results are typically
|
|
111
|
-
* `{ content: [{ type: "text", text }] }`, so pull the text parts out; fall
|
|
112
|
-
* back to pretty JSON for anything else. Truncated so a huge file read or
|
|
113
|
-
* search dump can't bloat the wire payload or the history sidecar.
|
|
114
|
-
*/
|
|
115
|
-
export function summarizeToolResult(result: unknown): string | undefined {
|
|
116
|
-
if (result == null) return undefined;
|
|
117
|
-
let text: string;
|
|
118
|
-
if (typeof result === "string") {
|
|
119
|
-
text = result;
|
|
120
|
-
} else {
|
|
121
|
-
const content = (result as { content?: unknown }).content;
|
|
122
|
-
if (Array.isArray(content)) {
|
|
123
|
-
const parts = content
|
|
124
|
-
.map((c) =>
|
|
125
|
-
c &&
|
|
126
|
-
typeof c === "object" &&
|
|
127
|
-
typeof (c as { text?: unknown }).text === "string"
|
|
128
|
-
? (c as { text: string }).text
|
|
129
|
-
: "",
|
|
130
|
-
)
|
|
131
|
-
.filter((s) => s.length > 0);
|
|
132
|
-
text = parts.length > 0 ? parts.join("\n") : safeJson(result);
|
|
133
|
-
} else {
|
|
134
|
-
text = safeJson(result);
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
text = text.trim();
|
|
138
|
-
if (!text) return undefined;
|
|
139
|
-
const MAX = 4000;
|
|
140
|
-
return text.length > MAX ? `${text.slice(0, MAX)}\n… (truncated)` : text;
|
|
141
|
-
}
|
|
32
|
+
export { summarizeToolResult } from "./tool-result.js";
|
|
142
33
|
|
|
143
34
|
export type NativeFrontend = {
|
|
144
35
|
name: "native";
|
|
@@ -151,1128 +42,20 @@ export type NativeFrontend = {
|
|
|
151
42
|
stop: () => Promise<void>;
|
|
152
43
|
};
|
|
153
44
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
const chats = new NativeChats();
|
|
161
|
-
// Daemon-wide mesh service (core/mesh). This frontend is its transport:
|
|
162
|
-
// companions register/report/answer through the bridge routes below, and
|
|
163
|
-
// the SSE transport (wired in init) pushes locate requests and device
|
|
164
|
-
// commands out. The model's mesh tools are served by the shared gateway
|
|
165
|
-
// actions, so they work from every frontend.
|
|
166
|
-
const mesh = getMeshService();
|
|
167
|
-
let unregisterMeshTransport: (() => void) | null = null;
|
|
168
|
-
|
|
169
|
-
// Monotonic message-id minter. Seeded from the wall clock so ids stay
|
|
170
|
-
// unique and ascending across restarts (history rows persist their ids).
|
|
171
|
-
let seq = Date.now();
|
|
172
|
-
const nextId = (): number => ++seq;
|
|
173
|
-
|
|
174
|
-
// Ephemeral registry mapping a short media id → absolute file path, so the
|
|
175
|
-
// bridge can serve images the bot attaches without exposing raw paths in the
|
|
176
|
-
// URL. Lives for the process; history rows keep only a text placeholder, so
|
|
177
|
-
// images render live in-session (mirroring how the chat frontends behave).
|
|
178
|
-
const media = new Map<string, string>();
|
|
179
|
-
const registerMedia = (filePath: string): string => {
|
|
180
|
-
const id = `m${nextId().toString(36)}`;
|
|
181
|
-
media.set(id, filePath);
|
|
182
|
-
return id;
|
|
183
|
-
};
|
|
184
|
-
|
|
185
|
-
// Persist an uploaded attachment to the workspace uploads dir under a safe,
|
|
186
|
-
// unique name, and return its absolute path (handed to the model to read).
|
|
187
|
-
const saveUpload = async (
|
|
188
|
-
filename: string,
|
|
189
|
-
bytes: Buffer,
|
|
190
|
-
): Promise<string> => {
|
|
191
|
-
await mkdir(dirs.uploads, { recursive: true });
|
|
192
|
-
const safe = basename(filename).replace(/[^\w.-]+/g, "_") || "upload";
|
|
193
|
-
const dest = join(
|
|
194
|
-
dirs.uploads,
|
|
195
|
-
`${Date.now()}-${nextId().toString(36)}-${safe}`,
|
|
196
|
-
);
|
|
197
|
-
await writeFile(dest, bytes);
|
|
198
|
-
return dest;
|
|
199
|
-
};
|
|
200
|
-
|
|
201
|
-
// Live context-window fill per chat, refreshed at the end of each turn.
|
|
202
|
-
// Cached (not computed inline) so the sync `toClientChat` projection stays
|
|
203
|
-
// sync — it just reads the last computed value.
|
|
204
|
-
const contextByChat = new Map<string, ContextInfo>();
|
|
205
|
-
|
|
206
|
-
// In-progress turns, keyed by chat id, so a client that (re)connects mid-turn
|
|
207
|
-
// can be replayed the turn's tool activity instead of waiting for the turn to
|
|
208
|
-
// finish. Holds the same live tool map `runTurn` mutates; cleared at turn end.
|
|
209
|
-
type LiveToolEntry = {
|
|
210
|
-
call: ClientToolCall;
|
|
211
|
-
startedAt: number;
|
|
212
|
-
done?: boolean;
|
|
213
|
-
};
|
|
214
|
-
const liveTurns = new Map<string, Map<string, LiveToolEntry>>();
|
|
215
|
-
|
|
216
|
-
/** True when a turn is currently running for a chat (used to decide queue). */
|
|
217
|
-
const isBusy = (chatId: string): boolean => liveTurns.has(chatId);
|
|
218
|
-
|
|
219
|
-
// The single queued follow-up per chat, held server-side so every connected
|
|
220
|
-
// client shares (and can edit/cancel) the same queue. Keeps the attachment
|
|
221
|
-
// paths so a queued image sends intact when the turn ends.
|
|
222
|
-
type QueuedEntry = {
|
|
223
|
-
text: string;
|
|
224
|
-
imagePath?: string;
|
|
225
|
-
attachmentPath?: string;
|
|
226
|
-
};
|
|
227
|
-
const queuedByChat = new Map<string, QueuedEntry>();
|
|
228
|
-
|
|
229
|
-
/** Project the stored queue entry to its wire shape (text + attachment flag). */
|
|
230
|
-
function toQueued(chatId: string): QueuedMessage | undefined {
|
|
231
|
-
const q = queuedByChat.get(chatId);
|
|
232
|
-
if (!q) return undefined;
|
|
233
|
-
return { text: q.text, hasAttachment: Boolean(q.attachmentPath) };
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/**
|
|
237
|
-
* Set (or replace) a chat's queued follow-up and sync it to every client via
|
|
238
|
-
* chat_updated. Empty text with no attachment clears the queue.
|
|
239
|
-
*/
|
|
240
|
-
function setQueued(chatId: string, next: QueuedEntry): void {
|
|
241
|
-
const entry = chats.get(chatId);
|
|
242
|
-
if (!entry) return;
|
|
243
|
-
const text = next.text.trim();
|
|
244
|
-
if (!text && !next.attachmentPath) {
|
|
245
|
-
if (!queuedByChat.delete(chatId)) return;
|
|
246
|
-
} else {
|
|
247
|
-
queuedByChat.set(chatId, {
|
|
248
|
-
text,
|
|
249
|
-
imagePath: next.imagePath,
|
|
250
|
-
attachmentPath: next.attachmentPath,
|
|
251
|
-
});
|
|
252
|
-
}
|
|
253
|
-
broadcastChatUpdated(entry);
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/**
|
|
257
|
-
* Events that reconstruct every in-progress turn for a freshly-connected
|
|
258
|
-
* client: a turn_start (so the live turn renders), a typing indicator, and
|
|
259
|
-
* each tool's call — plus its result when it has already finished. Replayed
|
|
260
|
-
* right after `hello` so a reconnect mid-turn shows the full tool timeline
|
|
261
|
-
* immediately rather than only the tools that happen to fire afterwards.
|
|
262
|
-
*/
|
|
263
|
-
function liveTurnEvents(): BridgeEvent[] {
|
|
264
|
-
const events: BridgeEvent[] = [];
|
|
265
|
-
for (const [chatId, tools] of liveTurns) {
|
|
266
|
-
events.push({ kind: "turn_start", chatId });
|
|
267
|
-
events.push({ kind: "typing", chatId, on: true });
|
|
268
|
-
for (const { call, done } of tools.values()) {
|
|
269
|
-
events.push({
|
|
270
|
-
kind: "tool",
|
|
271
|
-
chatId,
|
|
272
|
-
id: call.id,
|
|
273
|
-
name: call.name,
|
|
274
|
-
phase: "call",
|
|
275
|
-
...(call.input ? { input: call.input } : {}),
|
|
276
|
-
});
|
|
277
|
-
if (done) {
|
|
278
|
-
events.push({
|
|
279
|
-
kind: "tool",
|
|
280
|
-
chatId,
|
|
281
|
-
id: call.id,
|
|
282
|
-
name: call.name,
|
|
283
|
-
phase: "result",
|
|
284
|
-
...(call.error ? { error: call.error } : {}),
|
|
285
|
-
...(call.output ? { output: call.output } : {}),
|
|
286
|
-
});
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
return events;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
// ── Per-chat wire projection ─────────────────────────────────────────────
|
|
294
|
-
|
|
295
|
-
function toClientChat(entry: ChatEntry): ClientChat {
|
|
296
|
-
const settings = getChatSettings(entry.id);
|
|
297
|
-
let model: string | undefined;
|
|
298
|
-
let backend: string | undefined;
|
|
299
|
-
try {
|
|
300
|
-
// The persisted per-chat setting is the source of truth for what the
|
|
301
|
-
// user picked; the in-memory binding can lag it (boot-time rebind
|
|
302
|
-
// still pending or transiently failed). Reporting the binding here
|
|
303
|
-
// made clients show a chat "reset" to the default backend after a
|
|
304
|
-
// daemon restart even though the user's choice was intact.
|
|
305
|
-
backend = settings.backend ?? getBackendIdForChat(entry.id);
|
|
306
|
-
model = getChatModelForBackend(entry.id, backend);
|
|
307
|
-
} catch {
|
|
308
|
-
backend = settings.backend;
|
|
309
|
-
/* backend pool not ready (early boot) — omit model */
|
|
310
|
-
}
|
|
311
|
-
return {
|
|
312
|
-
id: entry.id,
|
|
313
|
-
title: entry.title,
|
|
314
|
-
createdAt: entry.createdAt,
|
|
315
|
-
lastActive: entry.lastActive,
|
|
316
|
-
preview: entry.preview,
|
|
317
|
-
model,
|
|
318
|
-
backend,
|
|
319
|
-
effort: settings.effort,
|
|
320
|
-
pulse: settings.pulse,
|
|
321
|
-
context: contextByChat.get(entry.id),
|
|
322
|
-
queued: toQueued(entry.id),
|
|
323
|
-
};
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
/**
|
|
327
|
-
* Compute the current context-window fill for a chat from its session
|
|
328
|
-
* usage, reusing the same `buildContextDisplay` the terminal/Discord
|
|
329
|
-
* `/status` line uses. When the session's usage doesn't carry a window
|
|
330
|
-
* size yet (fresh session), fall back to the resolved model's window so
|
|
331
|
-
* the readout still lands. Returns undefined when nothing is known — the
|
|
332
|
-
* clients hide the indicator rather than render a bogus 0%.
|
|
333
|
-
*/
|
|
334
|
-
async function computeContext(
|
|
335
|
-
chatId: string,
|
|
336
|
-
opts?: { resolveModel?: boolean },
|
|
337
|
-
): Promise<ContextInfo | undefined> {
|
|
338
|
-
try {
|
|
339
|
-
const info = getSessionInfo(chatId);
|
|
340
|
-
const u = info.usage;
|
|
341
|
-
let ctxMax = u.contextWindow;
|
|
342
|
-
// The model fallback touches the backend pool, which is fine for one
|
|
343
|
-
// chat the user just opened and wrong for every chat at boot — the
|
|
344
|
-
// startup warm asks for the cheap path and lets anything it can't
|
|
345
|
-
// resolve fill in when that chat is next opened.
|
|
346
|
-
if (!ctxMax && opts?.resolveModel !== false) {
|
|
347
|
-
try {
|
|
348
|
-
const backend = getBackendForChat(chatId);
|
|
349
|
-
const backendId = getBackendIdForChat(chatId);
|
|
350
|
-
const { ref } = await resolveActiveModelForChat(
|
|
351
|
-
chatId,
|
|
352
|
-
backend,
|
|
353
|
-
backendId,
|
|
354
|
-
config,
|
|
355
|
-
);
|
|
356
|
-
if (ref?.contextWindow) ctxMax = ref.contextWindow;
|
|
357
|
-
} catch {
|
|
358
|
-
/* backend pool not ready — leave max unknown */
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
const ctx = buildContextDisplay({
|
|
362
|
-
contextTokens: u.contextTokens,
|
|
363
|
-
lastPromptTokens: u.lastPromptTokens,
|
|
364
|
-
contextWindow: ctxMax,
|
|
365
|
-
});
|
|
366
|
-
if (!ctx.known && ctx.max === 0) return undefined;
|
|
367
|
-
return {
|
|
368
|
-
known: ctx.known,
|
|
369
|
-
used: ctx.used,
|
|
370
|
-
max: ctx.max,
|
|
371
|
-
pct: ctx.pct,
|
|
372
|
-
warn: ctx.warn,
|
|
373
|
-
};
|
|
374
|
-
} catch {
|
|
375
|
-
return undefined;
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
/** Recompute a chat's context fill and, if it changed, push it to clients. */
|
|
380
|
-
async function refreshContext(
|
|
381
|
-
entry: ChatEntry,
|
|
382
|
-
opts?: { resolveModel?: boolean },
|
|
383
|
-
): Promise<void> {
|
|
384
|
-
const next = await computeContext(entry.id, opts);
|
|
385
|
-
if (!next) return;
|
|
386
|
-
const prev = contextByChat.get(entry.id);
|
|
387
|
-
if (prev && prev.used === next.used && prev.max === next.max) return;
|
|
388
|
-
contextByChat.set(entry.id, next);
|
|
389
|
-
broadcastChatUpdated(entry);
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
/**
|
|
393
|
-
* Fill the context cache for chats restored at startup.
|
|
394
|
-
*
|
|
395
|
-
* The readout is served from an in-memory map that was only ever written at
|
|
396
|
-
* turn end, so after a restart every existing chat reported no context at
|
|
397
|
-
* all — the header chip vanished until that chat ran another turn, which
|
|
398
|
-
* read as "context usage doesn't save". The numbers themselves are
|
|
399
|
-
* persisted with the session; only the cache was cold. This re-reads them
|
|
400
|
-
* for the most recently active chats (bounded, and on the cheap path that
|
|
401
|
-
* never touches the backend pool); anything it skips or can't resolve is
|
|
402
|
-
* filled in the moment the chat is opened.
|
|
403
|
-
*/
|
|
404
|
-
const CONTEXT_WARM_LIMIT = 40;
|
|
405
|
-
|
|
406
|
-
async function warmContextCache(): Promise<void> {
|
|
407
|
-
for (const entry of chats.list().slice(0, CONTEXT_WARM_LIMIT)) {
|
|
408
|
-
await refreshContext(entry, { resolveModel: false });
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
const broadcast = (event: BridgeEvent): void => server.broadcast(event);
|
|
413
|
-
|
|
414
|
-
// The most recent assistant message id per chat — turn_end attaches the
|
|
415
|
-
// turn's meta (tools/stats) to this message so history hydration can show
|
|
416
|
-
// what the model did after a reload.
|
|
417
|
-
const lastAssistantId = new Map<string, string>();
|
|
418
|
-
|
|
419
|
-
const broadcastChatUpdated = (entry: ChatEntry): void =>
|
|
420
|
-
broadcast({ kind: "chat_updated", chat: toClientChat(entry) });
|
|
421
|
-
|
|
422
|
-
/**
|
|
423
|
-
* Name a chat from its first user message — instantly and for free.
|
|
424
|
-
*
|
|
425
|
-
* The title is a trimmed slice of the first message (no model call), so it
|
|
426
|
-
* lands the moment the user hits send instead of staying "New chat" until
|
|
427
|
-
* the turn finishes and a restart re-hydrates the persisted name. Only fires
|
|
428
|
-
* while the chat still carries the placeholder title, so a user's manual
|
|
429
|
-
* rename is never clobbered. Callers broadcast `chat_updated` afterwards, so
|
|
430
|
-
* the change propagates to every connected client live.
|
|
431
|
-
*/
|
|
432
|
-
function maybeAutoTitle(entry: ChatEntry, text: string): void {
|
|
433
|
-
if (entry.title && entry.title !== DEFAULT_CHAT_TITLE) return;
|
|
434
|
-
const title = extractSessionName(text);
|
|
435
|
-
if (title) chats.rename(entry.id, title);
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
// ── Outbound message helpers (persist + broadcast) ───────────────────────
|
|
439
|
-
|
|
440
|
-
function emitAssistant(
|
|
441
|
-
entry: ChatEntry,
|
|
442
|
-
text: string,
|
|
443
|
-
buttons?: ClientButton[][],
|
|
444
|
-
): number {
|
|
445
|
-
const id = nextId();
|
|
446
|
-
const ts = Date.now();
|
|
447
|
-
const message: ClientMessage = {
|
|
448
|
-
id: String(id),
|
|
449
|
-
chatId: entry.id,
|
|
450
|
-
role: "assistant",
|
|
451
|
-
text,
|
|
452
|
-
ts,
|
|
453
|
-
...(buttons ? { buttons } : {}),
|
|
454
|
-
};
|
|
455
|
-
pushMessage(entry.id, {
|
|
456
|
-
msgId: id,
|
|
457
|
-
senderId: BOT_SENDER_ID,
|
|
458
|
-
senderName: botName,
|
|
459
|
-
text,
|
|
460
|
-
timestamp: ts,
|
|
461
|
-
});
|
|
462
|
-
chats.touch(entry.id, text);
|
|
463
|
-
lastAssistantId.set(entry.id, String(id));
|
|
464
|
-
broadcast({ kind: "message", chatId: entry.id, message });
|
|
465
|
-
broadcastChatUpdated(entry);
|
|
466
|
-
return id;
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
/** Persist + broadcast an assistant photo message (image + optional caption). */
|
|
470
|
-
function emitPhoto(
|
|
471
|
-
entry: ChatEntry,
|
|
472
|
-
filePath: string,
|
|
473
|
-
caption?: string,
|
|
474
|
-
): number {
|
|
475
|
-
const id = nextId();
|
|
476
|
-
const ts = Date.now();
|
|
477
|
-
const text = caption?.trim() ?? "";
|
|
478
|
-
const mediaId = registerMedia(filePath);
|
|
479
|
-
const message: ClientMessage = {
|
|
480
|
-
id: String(id),
|
|
481
|
-
chatId: entry.id,
|
|
482
|
-
role: "assistant",
|
|
483
|
-
text,
|
|
484
|
-
ts,
|
|
485
|
-
imagePath: `/media?id=${encodeURIComponent(mediaId)}`,
|
|
486
|
-
};
|
|
487
|
-
// Persist the image reference so it re-renders when history reloads (the
|
|
488
|
-
// filePath is re-registered into the media map on read). The caption is
|
|
489
|
-
// stored as plain text; the `photo` mediaType carries the image marker.
|
|
490
|
-
pushMessage(entry.id, {
|
|
491
|
-
msgId: id,
|
|
492
|
-
senderId: BOT_SENDER_ID,
|
|
493
|
-
senderName: botName,
|
|
494
|
-
text,
|
|
495
|
-
timestamp: ts,
|
|
496
|
-
mediaType: "photo",
|
|
497
|
-
filePath,
|
|
498
|
-
});
|
|
499
|
-
chats.touch(entry.id, text || "[photo]");
|
|
500
|
-
lastAssistantId.set(entry.id, String(id));
|
|
501
|
-
broadcast({ kind: "message", chatId: entry.id, message });
|
|
502
|
-
broadcastChatUpdated(entry);
|
|
503
|
-
return id;
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
/** Persist + broadcast a user message; returns its numeric id so the turn
|
|
507
|
-
* hands the model that same id (as `[msg_id:N]`) to react/reply to.
|
|
508
|
-
* An optional imagePath renders an attached image inline. */
|
|
509
|
-
function emitUser(
|
|
510
|
-
entry: ChatEntry,
|
|
511
|
-
text: string,
|
|
512
|
-
imagePath?: string,
|
|
513
|
-
attachmentPath?: string,
|
|
514
|
-
): number {
|
|
515
|
-
const id = nextId();
|
|
516
|
-
const ts = Date.now();
|
|
517
|
-
const message: ClientMessage = {
|
|
518
|
-
id: String(id),
|
|
519
|
-
chatId: entry.id,
|
|
520
|
-
role: "user",
|
|
521
|
-
text,
|
|
522
|
-
ts,
|
|
523
|
-
...(imagePath ? { imagePath } : {}),
|
|
524
|
-
};
|
|
525
|
-
// For an attached image, persist the on-disk path + a `photo` mediaType so
|
|
526
|
-
// it re-renders on history reload (rehydrated in the `history` handler)
|
|
527
|
-
// instead of vanishing to a text-only placeholder. Caption stays as text.
|
|
528
|
-
pushMessage(entry.id, {
|
|
529
|
-
msgId: id,
|
|
530
|
-
senderId: USER_SENDER_ID,
|
|
531
|
-
senderName: "User",
|
|
532
|
-
text,
|
|
533
|
-
timestamp: ts,
|
|
534
|
-
...(imagePath
|
|
535
|
-
? {
|
|
536
|
-
mediaType: "photo" as const,
|
|
537
|
-
...(attachmentPath ? { filePath: attachmentPath } : {}),
|
|
538
|
-
}
|
|
539
|
-
: {}),
|
|
540
|
-
});
|
|
541
|
-
chats.touch(entry.id, imagePath ? text || "[photo]" : text);
|
|
542
|
-
maybeAutoTitle(entry, text);
|
|
543
|
-
broadcast({ kind: "message", chatId: entry.id, message });
|
|
544
|
-
broadcastChatUpdated(entry);
|
|
545
|
-
return id;
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
/** Transient, non-persisted notice (e.g. "session reset"). */
|
|
549
|
-
function emitSystem(entry: ChatEntry, text: string): void {
|
|
550
|
-
broadcast({
|
|
551
|
-
kind: "message",
|
|
552
|
-
chatId: entry.id,
|
|
553
|
-
message: {
|
|
554
|
-
id: `sys-${nextId()}`,
|
|
555
|
-
chatId: entry.id,
|
|
556
|
-
role: "system",
|
|
557
|
-
text,
|
|
558
|
-
ts: Date.now(),
|
|
559
|
-
},
|
|
560
|
-
});
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
// ── A user turn ──────────────────────────────────────────────────────────
|
|
564
|
-
|
|
565
|
-
async function runTurn(
|
|
566
|
-
entry: ChatEntry,
|
|
567
|
-
text: string,
|
|
568
|
-
messageId: number,
|
|
569
|
-
attachmentPath?: string,
|
|
570
|
-
): Promise<void> {
|
|
571
|
-
const start = Date.now();
|
|
572
|
-
// Tool calls observed during this turn, in call order — recorded into the
|
|
573
|
-
// turn-meta sidecar at turn_end so history can replay the timeline. Also
|
|
574
|
-
// registered in `liveTurns` so a client connecting mid-turn can be replayed
|
|
575
|
-
// the activity so far (cleared in the `finally`).
|
|
576
|
-
const turnTools = new Map<string, LiveToolEntry>();
|
|
577
|
-
liveTurns.set(entry.id, turnTools);
|
|
578
|
-
// Safety net: any tool the backend announced but never resolved
|
|
579
|
-
// (a crash can eat a tool_result; callback backends historically
|
|
580
|
-
// never emitted one — handler-to-events now pairs each tool_call
|
|
581
|
-
// with an immediate synthetic result) gets a synthetic result at
|
|
582
|
-
// turn end — a spinner the app opened on phase:"call" must always
|
|
583
|
-
// see a phase:"result".
|
|
584
|
-
const flushOpenTools = () => {
|
|
585
|
-
for (const [id, live] of turnTools) {
|
|
586
|
-
if (live.done) continue;
|
|
587
|
-
live.done = true;
|
|
588
|
-
live.call.durationMs = Date.now() - live.startedAt;
|
|
589
|
-
broadcast({
|
|
590
|
-
kind: "tool",
|
|
591
|
-
chatId: entry.id,
|
|
592
|
-
id,
|
|
593
|
-
name: live.call.name,
|
|
594
|
-
phase: "result",
|
|
595
|
-
});
|
|
596
|
-
}
|
|
597
|
-
};
|
|
598
|
-
// Point the model at an attached image so it can read the file itself.
|
|
599
|
-
const prompt = attachmentPath
|
|
600
|
-
? `${text ? `${text}\n\n` : ""}[Attached image: ${attachmentPath}]`
|
|
601
|
-
: text;
|
|
602
|
-
try {
|
|
603
|
-
const result = await execute({
|
|
604
|
-
chatId: entry.id,
|
|
605
|
-
numericChatId: entry.numericId,
|
|
606
|
-
prompt,
|
|
607
|
-
senderName: "User",
|
|
608
|
-
isGroup: false,
|
|
609
|
-
// Give the model the user message's id so `[msg_id:N]` is present and
|
|
610
|
-
// react/reply/edit target the user's message — not the bot's.
|
|
611
|
-
messageId,
|
|
612
|
-
source: "message",
|
|
613
|
-
onEvent: async (event) => {
|
|
614
|
-
switch (event.type) {
|
|
615
|
-
case "reasoning":
|
|
616
|
-
if (event.text)
|
|
617
|
-
broadcast({
|
|
618
|
-
kind: "reasoning",
|
|
619
|
-
chatId: entry.id,
|
|
620
|
-
text: event.text,
|
|
621
|
-
});
|
|
622
|
-
break;
|
|
623
|
-
case "text_delta":
|
|
624
|
-
broadcast({ kind: "delta", chatId: entry.id, text: event.text });
|
|
625
|
-
break;
|
|
626
|
-
case "tool_call": {
|
|
627
|
-
// Delivery plumbing (end_turn / send_message / react) never
|
|
628
|
-
// enters the tool timeline — its effect arrives as the
|
|
629
|
-
// `message`/reaction itself. Skipping here keeps the live
|
|
630
|
-
// stream, the mid-turn replay to late joiners, and the
|
|
631
|
-
// persisted turn meta consistent from one choke point.
|
|
632
|
-
if (isDeliveryTool(event.name)) break;
|
|
633
|
-
const input = toolInputToRecord(event.name, event.input);
|
|
634
|
-
turnTools.set(event.id, {
|
|
635
|
-
call: { id: event.id, name: event.name, input },
|
|
636
|
-
startedAt: Date.now(),
|
|
637
|
-
});
|
|
638
|
-
broadcast({
|
|
639
|
-
kind: "tool",
|
|
640
|
-
chatId: entry.id,
|
|
641
|
-
id: event.id,
|
|
642
|
-
name: event.name,
|
|
643
|
-
phase: "call",
|
|
644
|
-
input,
|
|
645
|
-
});
|
|
646
|
-
break;
|
|
647
|
-
}
|
|
648
|
-
case "tool_result": {
|
|
649
|
-
if (isDeliveryTool(event.name)) break;
|
|
650
|
-
const output = summarizeToolResult(event.result);
|
|
651
|
-
const live = turnTools.get(event.id);
|
|
652
|
-
if (live) {
|
|
653
|
-
live.done = true;
|
|
654
|
-
live.call.durationMs = Date.now() - live.startedAt;
|
|
655
|
-
if (event.error) live.call.error = event.error;
|
|
656
|
-
if (output) live.call.output = output;
|
|
657
|
-
}
|
|
658
|
-
broadcast({
|
|
659
|
-
kind: "tool",
|
|
660
|
-
chatId: entry.id,
|
|
661
|
-
id: event.id,
|
|
662
|
-
name: event.name,
|
|
663
|
-
phase: "result",
|
|
664
|
-
...(event.error ? { error: event.error } : {}),
|
|
665
|
-
...(output ? { output } : {}),
|
|
666
|
-
});
|
|
667
|
-
break;
|
|
668
|
-
}
|
|
669
|
-
case "error":
|
|
670
|
-
broadcast({
|
|
671
|
-
kind: "error",
|
|
672
|
-
chatId: entry.id,
|
|
673
|
-
message: event.error.message,
|
|
674
|
-
});
|
|
675
|
-
break;
|
|
676
|
-
}
|
|
677
|
-
},
|
|
678
|
-
});
|
|
679
|
-
|
|
680
|
-
// Trailing-prose fallback: text-mode backends (kilo/opencode/codex)
|
|
681
|
-
// deliver the reply as plain text rather than a bridge send. When no
|
|
682
|
-
// delivery tool fired this turn, surface result.text as the message —
|
|
683
|
-
// exactly what the terminal renderer does.
|
|
684
|
-
let delivered = result.bridgeMessageCount;
|
|
685
|
-
if (delivered === 0 && result.text.trim()) {
|
|
686
|
-
emitAssistant(entry, result.text.trim());
|
|
687
|
-
delivered = 1;
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
// Resolve open tool spinners BEFORE persisting turn meta, so the
|
|
691
|
-
// synthetic durations land in the recorded timeline too.
|
|
692
|
-
flushOpenTools();
|
|
693
|
-
|
|
694
|
-
// Persist what this turn did against its final assistant message, so
|
|
695
|
-
// reloaded history keeps the tool timeline + stats footer. Only when
|
|
696
|
-
// the turn actually delivered — otherwise the "last assistant id"
|
|
697
|
-
// belongs to a previous turn and the meta would land on the wrong row.
|
|
698
|
-
if (delivered > 0) {
|
|
699
|
-
const msgId = lastAssistantId.get(entry.id);
|
|
700
|
-
if (msgId) {
|
|
701
|
-
const tools = [...turnTools.values()].map((t) => t.call);
|
|
702
|
-
recordTurnMeta(entry.id, msgId, {
|
|
703
|
-
durationMs: result.durationMs,
|
|
704
|
-
tokensIn: result.inputTokens,
|
|
705
|
-
tokensOut: result.outputTokens,
|
|
706
|
-
...(tools.length ? { tools } : {}),
|
|
707
|
-
});
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
broadcast({ kind: "typing", chatId: entry.id, on: false });
|
|
712
|
-
broadcast({
|
|
713
|
-
kind: "turn_end",
|
|
714
|
-
chatId: entry.id,
|
|
715
|
-
delivered,
|
|
716
|
-
durationMs: result.durationMs,
|
|
717
|
-
usage: { input: result.inputTokens, output: result.outputTokens },
|
|
718
|
-
});
|
|
719
|
-
|
|
720
|
-
// Refresh the live context-window readout now the turn's usage has
|
|
721
|
-
// settled, and push it to clients via chat_updated. Best-effort — a
|
|
722
|
-
// failure here must never surface as a turn error.
|
|
723
|
-
void refreshContext(entry).catch(() => {});
|
|
724
|
-
} catch (err) {
|
|
725
|
-
flushOpenTools();
|
|
726
|
-
broadcast({ kind: "typing", chatId: entry.id, on: false });
|
|
727
|
-
broadcast({
|
|
728
|
-
kind: "error",
|
|
729
|
-
chatId: entry.id,
|
|
730
|
-
message: err instanceof Error ? err.message : String(err),
|
|
731
|
-
});
|
|
732
|
-
broadcast({
|
|
733
|
-
kind: "turn_end",
|
|
734
|
-
chatId: entry.id,
|
|
735
|
-
delivered: 0,
|
|
736
|
-
durationMs: Date.now() - start,
|
|
737
|
-
});
|
|
738
|
-
} finally {
|
|
739
|
-
// The turn is over (delivered or errored) — stop replaying it to new
|
|
740
|
-
// clients. Any late tool spinner has already been flushed above.
|
|
741
|
-
liveTurns.delete(entry.id);
|
|
742
|
-
// Flush a queued follow-up as a fresh turn: "once it's done it will
|
|
743
|
-
// send". Deferred so we don't re-enter runTurn inside its own finally.
|
|
744
|
-
const queued = queuedByChat.get(entry.id);
|
|
745
|
-
if (queued) {
|
|
746
|
-
queuedByChat.delete(entry.id);
|
|
747
|
-
broadcastChatUpdated(entry);
|
|
748
|
-
setImmediate(() =>
|
|
749
|
-
startTurn(entry, queued.text, {
|
|
750
|
-
imagePath: queued.imagePath,
|
|
751
|
-
attachmentPath: queued.attachmentPath,
|
|
752
|
-
}),
|
|
753
|
-
);
|
|
754
|
-
}
|
|
755
|
-
}
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
/** Emit the user message, open the turn, and drive it. Shared by the /send
|
|
759
|
-
* handler and the queued-follow-up flush. */
|
|
760
|
-
function startTurn(
|
|
761
|
-
entry: ChatEntry,
|
|
762
|
-
text: string,
|
|
763
|
-
opts?: { imagePath?: string; attachmentPath?: string },
|
|
764
|
-
): void {
|
|
765
|
-
const messageId = emitUser(
|
|
766
|
-
entry,
|
|
767
|
-
text,
|
|
768
|
-
opts?.imagePath,
|
|
769
|
-
opts?.attachmentPath,
|
|
770
|
-
);
|
|
771
|
-
broadcast({ kind: "turn_start", chatId: entry.id });
|
|
772
|
-
broadcast({ kind: "typing", chatId: entry.id, on: true });
|
|
773
|
-
void runTurn(entry, text, messageId, opts?.attachmentPath);
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
// ── Status + model helpers ───────────────────────────────────────────────
|
|
777
|
-
|
|
778
|
-
function status(): BridgeStatus {
|
|
779
|
-
return {
|
|
780
|
-
app: "talon-bridge",
|
|
781
|
-
protocol: BRIDGE_PROTOCOL_VERSION,
|
|
782
|
-
capabilities: ["mesh", "mesh-commands", "plugins-skills"],
|
|
783
|
-
botName,
|
|
784
|
-
backend: config.backend,
|
|
785
|
-
model: resolveModel(config.model)?.displayName ?? config.model,
|
|
786
|
-
activeChats: chats.count(),
|
|
787
|
-
startedAt,
|
|
788
|
-
};
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
async function listModels(chatId?: string): Promise<{
|
|
792
|
-
active: string;
|
|
793
|
-
models: ModelOption[];
|
|
794
|
-
}> {
|
|
795
|
-
// Resolve the chat's *own* backend so the model list tracks whatever
|
|
796
|
-
// backend the chat is currently bound to (fixes the list staying on the
|
|
797
|
-
// previous backend's models after a switch). Fall back to the global
|
|
798
|
-
// default backend when there's no chat / the pool isn't ready yet.
|
|
799
|
-
let backendId: string = config.backend;
|
|
800
|
-
let active = config.model;
|
|
801
|
-
if (chatId) {
|
|
802
|
-
try {
|
|
803
|
-
// Persisted setting first — see toClientChat.
|
|
804
|
-
backendId =
|
|
805
|
-
getChatSettings(chatId).backend ?? getBackendIdForChat(chatId);
|
|
806
|
-
active = getChatModelForBackend(chatId, backendId) ?? config.model;
|
|
807
|
-
} catch {
|
|
808
|
-
/* pool not ready — keep global defaults */
|
|
809
|
-
}
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
// Pull the models dynamically from the gateway for that backend. Prefer
|
|
813
|
-
// an already-pooled instance of the *resolved* backend (the persisted
|
|
814
|
-
// choice — the chat's live binding can lag it after a failed boot-time
|
|
815
|
-
// rebind, and listing the live binding's catalog here showed the wrong
|
|
816
|
-
// backend's models); otherwise boot the backend transiently to read its
|
|
817
|
-
// catalog, then release it so we never leak an instance — mirroring the
|
|
818
|
-
// shared `list_models` action.
|
|
819
|
-
let instance:
|
|
820
|
-
| Awaited<ReturnType<typeof acquireBackendInstance>>["backend"]
|
|
821
|
-
| null
|
|
822
|
-
| undefined = getPooledBackend(backendId);
|
|
823
|
-
let release: (() => Promise<void>) | null = null;
|
|
824
|
-
if (!instance) {
|
|
825
|
-
try {
|
|
826
|
-
const acquired = await acquireBackendInstance(backendId);
|
|
827
|
-
instance = acquired.backend;
|
|
828
|
-
release = acquired.release;
|
|
829
|
-
} catch {
|
|
830
|
-
return { active, models: [] };
|
|
831
|
-
}
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
try {
|
|
835
|
-
const catalog = instance.models;
|
|
836
|
-
if (!catalog?.listModels) return { active, models: [] };
|
|
837
|
-
const { models } = await catalog.listModels("all");
|
|
838
|
-
const options: ModelOption[] = models
|
|
839
|
-
.filter((m) => m.selectable)
|
|
840
|
-
.map((m) => ({
|
|
841
|
-
id: m.id,
|
|
842
|
-
displayName: m.displayName,
|
|
843
|
-
provider: m.provider,
|
|
844
|
-
reasoning:
|
|
845
|
-
Boolean(m.supportedReasoningLevels?.length) || Boolean(m.reasoning),
|
|
846
|
-
}));
|
|
847
|
-
return { active, models: options };
|
|
848
|
-
} catch {
|
|
849
|
-
return { active, models: [] };
|
|
850
|
-
} finally {
|
|
851
|
-
if (release) await release();
|
|
852
|
-
}
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
function setModel(chatId: string, model: string): void {
|
|
856
|
-
const entry = chats.get(chatId);
|
|
857
|
-
if (!entry) return;
|
|
858
|
-
// Persisted setting first — see toClientChat. Writing the pick under the
|
|
859
|
-
// live binding's key would attach it to the wrong backend whenever the
|
|
860
|
-
// boot-time rebind to the persisted backend is still pending.
|
|
861
|
-
const backendId =
|
|
862
|
-
getChatSettings(chatId).backend ?? getBackendIdForChat(chatId);
|
|
863
|
-
setChatModelForBackend(chatId, backendId, model.trim() || undefined);
|
|
864
|
-
broadcastChatUpdated(entry);
|
|
865
|
-
}
|
|
866
|
-
|
|
867
|
-
function listBackends(chatId: string): {
|
|
868
|
-
active: string;
|
|
869
|
-
backends: BackendOption[];
|
|
870
|
-
} {
|
|
871
|
-
const backends = listAvailableBackends(config);
|
|
872
|
-
let active: string = config.backend;
|
|
873
|
-
try {
|
|
874
|
-
if (chatId) {
|
|
875
|
-
// Persisted setting first — see toClientChat.
|
|
876
|
-
active = getChatSettings(chatId).backend ?? getBackendIdForChat(chatId);
|
|
877
|
-
}
|
|
878
|
-
} catch {
|
|
879
|
-
/* pool not ready — report the global default backend */
|
|
880
|
-
}
|
|
881
|
-
return { active, backends };
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
/**
|
|
885
|
-
* Switch a chat to another backend. Mirrors the Telegram `/model` backend
|
|
886
|
-
* submenu: verify the target is enabled, rebind the chat's pool holder, pin
|
|
887
|
-
* the override, and drop the previous backend's per-chat session state
|
|
888
|
-
* (sessions aren't portable across backends). Per-backend model picks are
|
|
889
|
-
* kept, so switching back restores the prior model automatically.
|
|
890
|
-
*/
|
|
891
|
-
async function setBackend(
|
|
892
|
-
chatId: string,
|
|
893
|
-
backend: string,
|
|
894
|
-
): Promise<{ ok: boolean; error?: string }> {
|
|
895
|
-
const entry = chats.get(chatId);
|
|
896
|
-
if (!entry) return { ok: false, error: "No such chat" };
|
|
897
|
-
const target = backend.trim();
|
|
898
|
-
const available = listAvailableBackends(config);
|
|
899
|
-
if (!available.some((b) => b.id === target)) {
|
|
900
|
-
return { ok: false, error: "Backend not available" };
|
|
901
|
-
}
|
|
902
|
-
const persisted = getChatSettings(chatId).backend;
|
|
903
|
-
if (getBackendIdForChat(chatId) === target) {
|
|
904
|
-
// Already live on the target — just make sure the choice is persisted.
|
|
905
|
-
if (persisted !== target && target !== config.backend) {
|
|
906
|
-
setChatBackend(chatId, target);
|
|
907
|
-
broadcastChatUpdated(entry);
|
|
908
|
-
}
|
|
909
|
-
return { ok: true };
|
|
910
|
-
}
|
|
911
|
-
|
|
912
|
-
const result = await rebindChat(chatId, target, config);
|
|
913
|
-
if (!result.ok) {
|
|
914
|
-
return { ok: false, error: result.error ?? "Rebind failed" };
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
// Re-selecting the backend this chat is already persisted to is a
|
|
918
|
-
// RE-ATTACH (e.g. the boot-time rebind failed transiently and the user —
|
|
919
|
-
// or a client retry — picks it again). The conversation belongs to that
|
|
920
|
-
// backend; resetting the session and wiping history here destroyed real
|
|
921
|
-
// conversations and surfaced as a phantom "backend reset" in clients.
|
|
922
|
-
if (persisted === target) {
|
|
923
|
-
broadcastChatUpdated(entry);
|
|
924
|
-
broadcast({ kind: "status", status: status() });
|
|
925
|
-
return { ok: true };
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
setChatBackend(chatId, target);
|
|
929
|
-
resetSession(chatId);
|
|
930
|
-
clearHistory(chatId);
|
|
931
|
-
clearTurnMeta(chatId);
|
|
932
|
-
contextByChat.delete(chatId);
|
|
933
|
-
queuedByChat.delete(chatId);
|
|
934
|
-
resetPulseCheckpoint(chatId);
|
|
935
|
-
emitSystem(entry, `Switched to ${target} — starting a fresh conversation.`);
|
|
936
|
-
broadcastChatUpdated(entry);
|
|
937
|
-
broadcast({ kind: "status", status: status() });
|
|
938
|
-
return { ok: true };
|
|
939
|
-
}
|
|
940
|
-
|
|
941
|
-
function setEffort(chatId: string, effort: string): void {
|
|
942
|
-
const entry = chats.get(chatId);
|
|
943
|
-
if (!entry) return;
|
|
944
|
-
const level = EFFORT_LEVELS.includes(effort as EffortLevel)
|
|
945
|
-
? (effort as EffortLevel)
|
|
946
|
-
: undefined;
|
|
947
|
-
setChatEffort(chatId, level);
|
|
948
|
-
broadcastChatUpdated(entry);
|
|
949
|
-
}
|
|
950
|
-
|
|
951
|
-
async function effortLevels(
|
|
952
|
-
chatId: string,
|
|
953
|
-
): Promise<{ active: string; levels: string[] }> {
|
|
954
|
-
try {
|
|
955
|
-
const backend = getBackendForChat(chatId);
|
|
956
|
-
const backendId = getBackendIdForChat(chatId);
|
|
957
|
-
const { levels } = await getActiveReasoningLevels({
|
|
958
|
-
chatId,
|
|
959
|
-
backend,
|
|
960
|
-
backendId,
|
|
961
|
-
config,
|
|
962
|
-
});
|
|
963
|
-
const active = getChatSettings(chatId).effort ?? "adaptive";
|
|
964
|
-
return { active, levels };
|
|
965
|
-
} catch {
|
|
966
|
-
return { active: "adaptive", levels: [] };
|
|
967
|
-
}
|
|
968
|
-
}
|
|
969
|
-
|
|
970
|
-
// ── Daemon control (restart / dream) ─────────────────────────────────────
|
|
971
|
-
|
|
972
|
-
/**
|
|
973
|
-
* Restart the daemon by spawning a detached `talon restart` — the same
|
|
974
|
-
* command a human runs. It must be an independent, detached process: the
|
|
975
|
-
* restart stops *this* process, so doing it in-process would kill us before
|
|
976
|
-
* the successor is spawned. The child outlives us, tears the daemon down,
|
|
977
|
-
* and brings a fresh one up. Mirrors `core/daemon/control.ts`'s own
|
|
978
|
-
* source-vs-compiled-binary spawn recipe.
|
|
979
|
-
*/
|
|
980
|
-
function spawnDaemonRestart(): void {
|
|
981
|
-
const isBunBinary =
|
|
982
|
-
(process.argv[1] ?? "").includes("~BUN") ||
|
|
983
|
-
(process.argv[1] ?? "").includes("$bunfs");
|
|
984
|
-
const cmd = process.execPath;
|
|
985
|
-
const args = isBunBinary
|
|
986
|
-
? ["restart"]
|
|
987
|
-
: isBunRuntime()
|
|
988
|
-
? [resolve(PKG_ROOT, "src", "cli.ts"), "restart"]
|
|
989
|
-
: [
|
|
990
|
-
resolve(PKG_ROOT, "node_modules", "tsx", "dist", "cli.mjs"),
|
|
991
|
-
resolve(PKG_ROOT, "src", "cli.ts"),
|
|
992
|
-
"restart",
|
|
993
|
-
];
|
|
994
|
-
const cwd = isBunBinary ? dirname(process.execPath) : PKG_ROOT;
|
|
995
|
-
const child = spawn(cmd, args, {
|
|
996
|
-
cwd,
|
|
997
|
-
detached: true,
|
|
998
|
-
stdio: "ignore",
|
|
999
|
-
env: { ...process.env },
|
|
1000
|
-
windowsHide: true,
|
|
1001
|
-
});
|
|
1002
|
-
child.unref();
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
|
-
/**
|
|
1006
|
-
* Daemon-level control actions the app fires from Settings. Kept minimal and
|
|
1007
|
-
* explicit — each maps to a well-understood operation the CLI already
|
|
1008
|
-
* exposes, so there's no new privileged surface beyond "what a local admin
|
|
1009
|
-
* could already do".
|
|
1010
|
-
*/
|
|
1011
|
-
async function control(
|
|
1012
|
-
action: string,
|
|
1013
|
-
): Promise<{ ok: boolean; message: string }> {
|
|
1014
|
-
switch (action) {
|
|
1015
|
-
case "restart":
|
|
1016
|
-
spawnDaemonRestart();
|
|
1017
|
-
return {
|
|
1018
|
-
ok: true,
|
|
1019
|
-
message: "Restarting Talon — back online in a few seconds.",
|
|
1020
|
-
};
|
|
1021
|
-
case "dream":
|
|
1022
|
-
// Fire-and-forget: a dream run can take a while; the app just needs
|
|
1023
|
-
// to know it started. forceDream throws if one is already running.
|
|
1024
|
-
try {
|
|
1025
|
-
void forceDream().catch((err) =>
|
|
1026
|
-
logError("native", "Manual dream run failed", err),
|
|
1027
|
-
);
|
|
1028
|
-
return { ok: true, message: "Dream started — consolidating memory." };
|
|
1029
|
-
} catch (err) {
|
|
1030
|
-
return {
|
|
1031
|
-
ok: false,
|
|
1032
|
-
message: err instanceof Error ? err.message : String(err),
|
|
1033
|
-
};
|
|
1034
|
-
}
|
|
1035
|
-
default:
|
|
1036
|
-
return { ok: false, message: `Unknown control action: ${action}` };
|
|
1037
|
-
}
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
|
-
// ── Empty-chat sweeper ───────────────────────────────────────────────────
|
|
1041
|
-
//
|
|
1042
|
-
// "New chat" creates a real chat on the daemon the instant it's tapped, so
|
|
1043
|
-
// opening one and backing out of it leaves an empty row in every client's
|
|
1044
|
-
// list forever. The app deletes an untouched chat as soon as you leave it,
|
|
1045
|
-
// which covers the normal case immediately; this is the backstop for the
|
|
1046
|
-
// ones no client got to clean up — the app was killed, the network dropped,
|
|
1047
|
-
// or the chat was created by something that never came back.
|
|
1048
|
-
//
|
|
1049
|
-
// Deliberately conservative: only chats that never carried a single message
|
|
1050
|
-
// (see ChatEntry.used, which a reset does NOT clear), only after an hour,
|
|
1051
|
-
// never one with a turn running or a message queued, and never one whose
|
|
1052
|
-
// history says otherwise. Deleting via the same handler every client uses
|
|
1053
|
-
// means the removal is broadcast, so open lists update in place.
|
|
1054
|
-
const EMPTY_CHAT_MIN_AGE_MS = 60 * 60_000;
|
|
1055
|
-
const EMPTY_CHAT_SWEEP_INTERVAL_MS = 30 * 60_000;
|
|
1056
|
-
let emptyChatSweep: ReturnType<typeof setInterval> | null = null;
|
|
1057
|
-
|
|
1058
|
-
function sweepEmptyChats(): void {
|
|
1059
|
-
for (const entry of chats.unused(EMPTY_CHAT_MIN_AGE_MS)) {
|
|
1060
|
-
if (isBusy(entry.id) || queuedByChat.has(entry.id)) continue;
|
|
1061
|
-
if (getRecentHistory(entry.id, 1).length > 0) continue;
|
|
1062
|
-
if (handlers.deleteChat(entry.id)) {
|
|
1063
|
-
log("native", `Swept empty chat ${entry.id}`);
|
|
1064
|
-
}
|
|
1065
|
-
}
|
|
1066
|
-
}
|
|
1067
|
-
|
|
1068
|
-
// ── Bridge server wiring ─────────────────────────────────────────────────
|
|
1069
|
-
|
|
1070
|
-
const handlers: BridgeServerHandlers = {
|
|
1071
|
-
status,
|
|
1072
|
-
listChats: () => chats.list().map(toClientChat),
|
|
1073
|
-
createChat: (title) => {
|
|
1074
|
-
const entry = chats.create(title);
|
|
1075
|
-
const chat = toClientChat(entry);
|
|
1076
|
-
broadcast({ kind: "chat_created", chat });
|
|
1077
|
-
return chat;
|
|
1078
|
-
},
|
|
1079
|
-
renameChat: (id, title) => {
|
|
1080
|
-
const entry = chats.rename(id, title);
|
|
1081
|
-
if (!entry) return null;
|
|
1082
|
-
const chat = toClientChat(entry);
|
|
1083
|
-
broadcast({ kind: "chat_updated", chat });
|
|
1084
|
-
return chat;
|
|
1085
|
-
},
|
|
1086
|
-
deleteChat: (id) => {
|
|
1087
|
-
const ok = chats.remove(id);
|
|
1088
|
-
if (ok) {
|
|
1089
|
-
clearTurnMeta(id);
|
|
1090
|
-
contextByChat.delete(id);
|
|
1091
|
-
queuedByChat.delete(id);
|
|
1092
|
-
broadcast({ kind: "chat_deleted", chatId: id });
|
|
1093
|
-
}
|
|
1094
|
-
return ok;
|
|
1095
|
-
},
|
|
1096
|
-
history: (id, opts) => {
|
|
1097
|
-
// Opening a chat is the one moment we know a client wants its numbers:
|
|
1098
|
-
// refresh the context readout on the full path (model fallback and
|
|
1099
|
-
// all), which covers every chat the startup warm skipped or couldn't
|
|
1100
|
-
// resolve. Fire-and-forget — the figure arrives as a chat_updated.
|
|
1101
|
-
const entry = chats.get(id);
|
|
1102
|
-
if (entry && opts?.before === undefined) {
|
|
1103
|
-
void refreshContext(entry).catch(() => {});
|
|
1104
|
-
}
|
|
1105
|
-
const limit = Math.min(Math.max(opts?.limit ?? 200, 1), 500);
|
|
1106
|
-
const rows =
|
|
1107
|
-
opts?.before !== undefined
|
|
1108
|
-
? getHistoryBefore(id, opts.before, limit)
|
|
1109
|
-
: getRecentHistory(id, limit);
|
|
1110
|
-
return rows
|
|
1111
|
-
.map((m) => {
|
|
1112
|
-
const msg = historyToClientMessage(m, id);
|
|
1113
|
-
// Re-hydrate an attached image: re-register its on-disk path into
|
|
1114
|
-
// the media map and hand back a fresh /media URL so the image shows
|
|
1115
|
-
// in reloaded history (survives restarts as long as the file exists).
|
|
1116
|
-
if (m.mediaType === "photo" && m.filePath) {
|
|
1117
|
-
const mediaId = registerMedia(m.filePath);
|
|
1118
|
-
msg.imagePath = `/media?id=${encodeURIComponent(mediaId)}`;
|
|
1119
|
-
}
|
|
1120
|
-
// Re-hydrate turn meta (tool timeline + stats) for assistant rows.
|
|
1121
|
-
if (msg.role === "assistant") {
|
|
1122
|
-
const meta = getTurnMeta(id, msg.id);
|
|
1123
|
-
if (meta) {
|
|
1124
|
-
// Delivery tools are excluded at record time, but metas
|
|
1125
|
-
// persisted by older daemons still carry them — filter on
|
|
1126
|
-
// the way out so upgraded installs get clean history too.
|
|
1127
|
-
const tools = meta.tools?.filter((t) => !isDeliveryTool(t.name));
|
|
1128
|
-
if (tools?.length) msg.tools = tools;
|
|
1129
|
-
if (meta.durationMs) msg.durationMs = meta.durationMs;
|
|
1130
|
-
if (meta.tokensIn) msg.tokensIn = meta.tokensIn;
|
|
1131
|
-
if (meta.tokensOut) msg.tokensOut = meta.tokensOut;
|
|
1132
|
-
}
|
|
1133
|
-
}
|
|
1134
|
-
return msg;
|
|
1135
|
-
})
|
|
1136
|
-
.sort((a, b) => Number(a.id) - Number(b.id));
|
|
1137
|
-
},
|
|
1138
|
-
search: (query, chatId) => {
|
|
1139
|
-
const targets = chatId
|
|
1140
|
-
? [chats.get(chatId)].filter((e): e is ChatEntry => e != null)
|
|
1141
|
-
: chats.list();
|
|
1142
|
-
const results: SearchResult[] = [];
|
|
1143
|
-
for (const entry of targets) {
|
|
1144
|
-
for (const m of searchHistoryMessages(entry.id, query, 10)) {
|
|
1145
|
-
results.push({
|
|
1146
|
-
chatId: entry.id,
|
|
1147
|
-
chatTitle: entry.title,
|
|
1148
|
-
message: historyToClientMessage(m, entry.id),
|
|
1149
|
-
});
|
|
1150
|
-
}
|
|
1151
|
-
}
|
|
1152
|
-
// Newest hits first, bounded across all chats.
|
|
1153
|
-
results.sort((a, b) => b.message.ts - a.message.ts);
|
|
1154
|
-
return results.slice(0, 50);
|
|
1155
|
-
},
|
|
1156
|
-
send: (id, text, opts) => {
|
|
1157
|
-
const entry = chats.get(id) ?? chats.ensure(id);
|
|
1158
|
-
// A turn is already running for this chat — don't interrupt it. Park the
|
|
1159
|
-
// message as the single queued follow-up (synced to every client); it
|
|
1160
|
-
// auto-sends when the running turn ends. `isBusy` reads `liveTurns`,
|
|
1161
|
-
// which `runTurn` sets synchronously, so even a rapid second /send from
|
|
1162
|
-
// any client is caught here rather than starting a concurrent turn.
|
|
1163
|
-
if (isBusy(entry.id)) {
|
|
1164
|
-
setQueued(entry.id, {
|
|
1165
|
-
text,
|
|
1166
|
-
imagePath: opts?.imagePath,
|
|
1167
|
-
attachmentPath: opts?.attachmentPath,
|
|
1168
|
-
});
|
|
1169
|
-
return;
|
|
1170
|
-
}
|
|
1171
|
-
startTurn(entry, text, opts);
|
|
1172
|
-
},
|
|
1173
|
-
queueMessage: (id, text) => {
|
|
1174
|
-
// Edit/replace the queued follow-up (text-only). Empty clears it.
|
|
1175
|
-
const entry = chats.get(id);
|
|
1176
|
-
if (entry) setQueued(entry.id, { text });
|
|
1177
|
-
},
|
|
1178
|
-
upload: async (filename, _contentType, bytes) => {
|
|
1179
|
-
const path = await saveUpload(filename, bytes);
|
|
1180
|
-
const mediaId = registerMedia(path);
|
|
1181
|
-
return { imagePath: `/media?id=${encodeURIComponent(mediaId)}`, path };
|
|
1182
|
-
},
|
|
1183
|
-
listModels,
|
|
1184
|
-
setModel,
|
|
1185
|
-
listBackends,
|
|
1186
|
-
setBackend,
|
|
1187
|
-
setEffort,
|
|
1188
|
-
effortLevels,
|
|
1189
|
-
interruptTurn: async (id) => {
|
|
1190
|
-
const entry = chats.get(id);
|
|
1191
|
-
if (!entry) return false;
|
|
1192
|
-
// Only meaningful while a turn is actually running.
|
|
1193
|
-
if (!isBusy(id)) return false;
|
|
1194
|
-
let backend = null;
|
|
1195
|
-
try {
|
|
1196
|
-
backend = getBackendForChat(id);
|
|
1197
|
-
} catch {
|
|
1198
|
-
return false;
|
|
1199
|
-
}
|
|
1200
|
-
const interrupt = backend?.chat?.interruptChatTurn;
|
|
1201
|
-
if (!interrupt) return false;
|
|
1202
|
-
try {
|
|
1203
|
-
return await interrupt(id);
|
|
1204
|
-
} catch {
|
|
1205
|
-
return false;
|
|
1206
|
-
}
|
|
1207
|
-
},
|
|
1208
|
-
resetChat: (id) => {
|
|
1209
|
-
const entry = chats.get(id);
|
|
1210
|
-
if (!entry) return false;
|
|
1211
|
-
// Full reset, matching /reset on the other frontends: session,
|
|
1212
|
-
// history (the app re-fetches its transcript from us), pulse
|
|
1213
|
-
// checkpoint, and any in-process backend memory. Warm the fresh
|
|
1214
|
-
// session in the background — the bridge handler is sync.
|
|
1215
|
-
resetSession(id);
|
|
1216
|
-
clearHistory(id);
|
|
1217
|
-
clearTurnMeta(id);
|
|
1218
|
-
contextByChat.delete(id);
|
|
1219
|
-
queuedByChat.delete(id);
|
|
1220
|
-
resetPulseCheckpoint(id);
|
|
1221
|
-
let backend = null;
|
|
1222
|
-
try {
|
|
1223
|
-
backend = getBackendForChat(id);
|
|
1224
|
-
} catch {
|
|
1225
|
-
// No pool binding — nothing to wipe or warm.
|
|
1226
|
-
}
|
|
1227
|
-
backend?.sessions?.resetChat?.(id);
|
|
1228
|
-
void backend?.sessions?.warmSession?.(id)?.catch(() => {});
|
|
1229
|
-
emitSystem(entry, "Session reset — starting a fresh conversation.");
|
|
1230
|
-
broadcastChatUpdated(entry);
|
|
1231
|
-
return true;
|
|
1232
|
-
},
|
|
1233
|
-
setPulse: (id, on) => {
|
|
1234
|
-
const entry = chats.get(id);
|
|
1235
|
-
if (!entry) return;
|
|
1236
|
-
setChatPulse(id, on);
|
|
1237
|
-
broadcastChatUpdated(entry);
|
|
1238
|
-
},
|
|
1239
|
-
getConfig: () => configSnapshot(config),
|
|
1240
|
-
setConfig: (update) => {
|
|
1241
|
-
const snap = applyConfigUpdate(config, update);
|
|
1242
|
-
broadcast({ kind: "status", status: status() });
|
|
1243
|
-
return snap;
|
|
1244
|
-
},
|
|
1245
|
-
listPlugins: () => pluginItems(config),
|
|
1246
|
-
setPluginEnabled: (name, enabled) =>
|
|
1247
|
-
togglePlugin(config, getPooledBackend(config.backend), name, enabled),
|
|
1248
|
-
listSkills: () => skillItems(),
|
|
1249
|
-
setSkillEnabled: (name, enabled) =>
|
|
1250
|
-
toggleSkill(config, getPooledBackend(config.backend), name, enabled),
|
|
1251
|
-
control,
|
|
1252
|
-
logs: ({ lines, minLevel, component }) =>
|
|
1253
|
-
readLogEntries(files.log, { limit: lines, minLevel, component }),
|
|
1254
|
-
liveTurnEvents,
|
|
1255
|
-
mediaPath: (id) => media.get(id) ?? null,
|
|
1256
|
-
// Mesh routes are thin transport shims over the shared core service —
|
|
1257
|
-
// storeLocation wakes any pending fresh-fix waiters inside the service.
|
|
1258
|
-
registerDevice: (body) => mesh.register(body),
|
|
1259
|
-
storeLocation: (body) => mesh.storeLocation(body),
|
|
1260
|
-
listDevices: () => mesh.list(),
|
|
1261
|
-
completeCommand: (body) => mesh.completeCommand(body),
|
|
1262
|
-
acceptFileUpload: (token, body, fromDeviceId) =>
|
|
1263
|
-
mesh.acceptFileUpload(token, body, fromDeviceId),
|
|
1264
|
-
openFileDownload: (token, fromDeviceId) =>
|
|
1265
|
-
mesh.openFileDownload(token, fromDeviceId),
|
|
1266
|
-
openCompanionPair: (token, format) => mesh.openCompanionPair(token, format),
|
|
1267
|
-
openNodeInstall: (token) => mesh.openNodeInstall(token),
|
|
1268
|
-
openNodeBinary: (token) => mesh.openNodeBinary(token),
|
|
1269
|
-
};
|
|
45
|
+
type BridgeListen = {
|
|
46
|
+
host: string;
|
|
47
|
+
port: number;
|
|
48
|
+
token: string | undefined;
|
|
49
|
+
tls: boolean;
|
|
50
|
+
};
|
|
1270
51
|
|
|
52
|
+
/** Where the bridge listens, and how it is secured, from `config.native`. */
|
|
53
|
+
function bridgeListen(config: TalonConfig): BridgeListen {
|
|
1271
54
|
const nativeCfg = config.native ?? { port: 19880, host: "127.0.0.1" };
|
|
1272
|
-
const
|
|
55
|
+
const host = nativeCfg.host ?? "127.0.0.1";
|
|
1273
56
|
// Encrypted by default the moment the bridge leaves the machine; loopback
|
|
1274
57
|
// stays plain HTTP unless explicitly opted in (`native.tls`).
|
|
1275
|
-
const
|
|
58
|
+
const tls = nativeCfg.tls ?? !isLoopbackHost(host);
|
|
1276
59
|
// Never serve the agent API to the network unauthenticated: a non-loopback
|
|
1277
60
|
// bind with no configured token gets a persistent auto-minted one instead.
|
|
1278
61
|
// `?? ` alone would treat an empty string as a configured token and skip
|
|
@@ -1282,22 +65,61 @@ export function createNativeFrontend(
|
|
|
1282
65
|
nativeCfg.token !== undefined && nativeCfg.token !== ""
|
|
1283
66
|
? nativeCfg.token
|
|
1284
67
|
: undefined;
|
|
1285
|
-
const
|
|
68
|
+
const token =
|
|
1286
69
|
configuredToken ??
|
|
1287
|
-
(isLoopbackHost(
|
|
70
|
+
(isLoopbackHost(host) ? undefined : loadOrCreateBridgeToken());
|
|
71
|
+
return { host, port: nativeCfg.port ?? 19880, token, tls };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Plug this bridge in as the mesh's transport: locates and device commands
|
|
76
|
+
* (from ANY frontend's mesh tool calls) leave as SSE events.
|
|
77
|
+
*
|
|
78
|
+
* A locate is a bare "who's there?" — no secret in the frame, and
|
|
79
|
+
* pre-command app builds rely on receiving it — so it still fans out.
|
|
80
|
+
* A command is the opposite: its params carry transfer tokens, exec
|
|
81
|
+
* command lines and (on the chunked fallback) file bodies, so it goes
|
|
82
|
+
* to the target device's own client(s) only.
|
|
83
|
+
*/
|
|
84
|
+
function registerMeshTransport(
|
|
85
|
+
runtime: NativeRuntime,
|
|
86
|
+
server: BridgeServer,
|
|
87
|
+
): () => void {
|
|
88
|
+
return runtime.mesh.registerTransport({
|
|
89
|
+
locate: (deviceId) => runtime.broadcast({ kind: "locate", deviceId }),
|
|
90
|
+
command: (command) =>
|
|
91
|
+
server.sendToDevice(command.deviceId, {
|
|
92
|
+
kind: "device_command",
|
|
93
|
+
id: command.id,
|
|
94
|
+
deviceId: command.deviceId,
|
|
95
|
+
name: command.name,
|
|
96
|
+
params: command.params,
|
|
97
|
+
}),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function createNativeFrontend(
|
|
102
|
+
config: TalonConfig,
|
|
103
|
+
gateway: Gateway,
|
|
104
|
+
): NativeFrontend {
|
|
105
|
+
const runtime = createNativeRuntime(config, gateway, (event) =>
|
|
106
|
+
server.broadcast(event),
|
|
107
|
+
);
|
|
108
|
+
const { chats, mesh } = runtime;
|
|
109
|
+
const listen = bridgeListen(config);
|
|
1288
110
|
const server = new BridgeServer(
|
|
1289
111
|
{
|
|
1290
|
-
host:
|
|
1291
|
-
port:
|
|
1292
|
-
token:
|
|
1293
|
-
allowedOrigins:
|
|
1294
|
-
startedAt,
|
|
1295
|
-
...(
|
|
1296
|
-
},
|
|
1297
|
-
|
|
112
|
+
host: listen.host,
|
|
113
|
+
port: listen.port,
|
|
114
|
+
token: listen.token,
|
|
115
|
+
allowedOrigins: config.native?.allowedOrigins,
|
|
116
|
+
startedAt: runtime.startedAt,
|
|
117
|
+
...(listen.tls ? { tls: () => loadOrCreateBridgeTlsIdentity() } : {}),
|
|
118
|
+
},
|
|
119
|
+
buildBridgeHandlers(runtime),
|
|
1298
120
|
);
|
|
1299
|
-
|
|
1300
|
-
|
|
121
|
+
let unregisterMeshTransport: (() => void) | null = null;
|
|
122
|
+
let stopEmptyChatSweep: (() => void) | null = null;
|
|
1301
123
|
|
|
1302
124
|
const context: ContextManager = {
|
|
1303
125
|
acquire: (chatId: number, stringId?: string) =>
|
|
@@ -1312,39 +134,22 @@ export function createNativeFrontend(
|
|
|
1312
134
|
|
|
1313
135
|
sendTyping: async (chatId: number) => {
|
|
1314
136
|
const entry = chats.byNumeric(chatId);
|
|
1315
|
-
if (entry)
|
|
137
|
+
if (entry)
|
|
138
|
+
runtime.broadcast({ kind: "typing", chatId: entry.id, on: true });
|
|
1316
139
|
},
|
|
1317
140
|
|
|
1318
141
|
// Used by cron / pulse / heartbeat to reach a chat outside a user turn.
|
|
1319
142
|
sendMessage: async (chatId: number, text: string) => {
|
|
1320
143
|
if (!text.trim()) return;
|
|
1321
144
|
const entry = chats.byNumeric(chatId);
|
|
1322
|
-
if (entry) emitAssistant(entry, text);
|
|
145
|
+
if (entry) emitAssistant(runtime, entry, text);
|
|
1323
146
|
},
|
|
1324
147
|
|
|
1325
148
|
getBridgePort: () => gateway.getPort(),
|
|
1326
149
|
|
|
1327
150
|
async init() {
|
|
1328
151
|
await mesh.load();
|
|
1329
|
-
|
|
1330
|
-
// commands (from ANY frontend's mesh tool calls) leave as SSE events.
|
|
1331
|
-
//
|
|
1332
|
-
// A locate is a bare "who's there?" — no secret in the frame, and
|
|
1333
|
-
// pre-command app builds rely on receiving it — so it still fans out.
|
|
1334
|
-
// A command is the opposite: its params carry transfer tokens, exec
|
|
1335
|
-
// command lines and (on the chunked fallback) file bodies, so it goes
|
|
1336
|
-
// to the target device's own client(s) only.
|
|
1337
|
-
unregisterMeshTransport = mesh.registerTransport({
|
|
1338
|
-
locate: (deviceId) => broadcast({ kind: "locate", deviceId }),
|
|
1339
|
-
command: (command) =>
|
|
1340
|
-
server.sendToDevice(command.deviceId, {
|
|
1341
|
-
kind: "device_command",
|
|
1342
|
-
id: command.id,
|
|
1343
|
-
deviceId: command.deviceId,
|
|
1344
|
-
name: command.name,
|
|
1345
|
-
params: command.params,
|
|
1346
|
-
}),
|
|
1347
|
-
});
|
|
152
|
+
unregisterMeshTransport = registerMeshTransport(runtime, server);
|
|
1348
153
|
// Mesh tool actions (list_devices / get_device_location) are shared
|
|
1349
154
|
// gateway actions now — no native-only cases here.
|
|
1350
155
|
gateway.registerFrontendHandler(
|
|
@@ -1352,44 +157,38 @@ export function createNativeFrontend(
|
|
|
1352
157
|
createNativeActionHandler({
|
|
1353
158
|
chats,
|
|
1354
159
|
gateway,
|
|
1355
|
-
emitAssistant,
|
|
1356
|
-
|
|
1357
|
-
|
|
160
|
+
emitAssistant: (entry, text, buttons) =>
|
|
161
|
+
emitAssistant(runtime, entry, text, buttons),
|
|
162
|
+
emitPhoto: (entry, filePath, caption) =>
|
|
163
|
+
emitPhoto(runtime, entry, filePath, caption),
|
|
164
|
+
broadcast: runtime.broadcast,
|
|
1358
165
|
}),
|
|
1359
166
|
);
|
|
1360
167
|
const gatewayPort = await gateway.start(19876);
|
|
1361
168
|
log("native", `Gateway on :${gatewayPort}`);
|
|
1362
169
|
chats.restore();
|
|
1363
170
|
// Non-blocking: a cold cache costs a missing chip, not a broken boot.
|
|
1364
|
-
void warmContextCache().catch((err) =>
|
|
171
|
+
void warmContextCache(runtime).catch((err) =>
|
|
1365
172
|
logError("native", "Context cache warm failed", err),
|
|
1366
173
|
);
|
|
1367
|
-
|
|
1368
|
-
try {
|
|
1369
|
-
sweepEmptyChats();
|
|
1370
|
-
} catch (err) {
|
|
1371
|
-
logError("native", "Empty-chat sweep failed", err);
|
|
1372
|
-
}
|
|
1373
|
-
}, EMPTY_CHAT_SWEEP_INTERVAL_MS);
|
|
1374
|
-
// Housekeeping must never be the reason the process stays alive.
|
|
1375
|
-
emptyChatSweep.unref?.();
|
|
174
|
+
stopEmptyChatSweep = startEmptyChatSweep(runtime);
|
|
1376
175
|
await server.start();
|
|
1377
176
|
const fingerprint = server.getFingerprint();
|
|
1378
177
|
// Tell the mesh how this bridge is reachable — everything a generated
|
|
1379
178
|
// node installer needs (make_node_install_link fails cleanly without it).
|
|
1380
179
|
mesh.setBridgeInfo({
|
|
1381
180
|
scheme: server.getScheme(),
|
|
1382
|
-
host:
|
|
181
|
+
host: listen.host,
|
|
1383
182
|
port: server.getPort(),
|
|
1384
|
-
...(
|
|
183
|
+
...(listen.token ? { token: listen.token } : {}),
|
|
1385
184
|
...(fingerprint ? { fingerprint } : {}),
|
|
1386
185
|
});
|
|
1387
186
|
await writeBridgeDiscovery({
|
|
1388
187
|
port: server.getPort(),
|
|
1389
|
-
token:
|
|
188
|
+
token: listen.token,
|
|
1390
189
|
scheme: server.getScheme(),
|
|
1391
190
|
...(fingerprint ? { fingerprint } : {}),
|
|
1392
|
-
startedAt: Date.parse(startedAt),
|
|
191
|
+
startedAt: Date.parse(runtime.startedAt),
|
|
1393
192
|
});
|
|
1394
193
|
},
|
|
1395
194
|
|
|
@@ -1401,10 +200,8 @@ export function createNativeFrontend(
|
|
|
1401
200
|
},
|
|
1402
201
|
|
|
1403
202
|
async stop() {
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
emptyChatSweep = null;
|
|
1407
|
-
}
|
|
203
|
+
stopEmptyChatSweep?.();
|
|
204
|
+
stopEmptyChatSweep = null;
|
|
1408
205
|
unregisterMeshTransport?.();
|
|
1409
206
|
unregisterMeshTransport = null;
|
|
1410
207
|
mesh.setBridgeInfo(null);
|