talon-agent 3.33.4 → 3.34.1

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.
Files changed (98) hide show
  1. package/package.json +5 -2
  2. package/src/app.ts +16 -8
  3. package/src/backend/claude-sdk/handler.ts +428 -415
  4. package/src/backend/claude-sdk/stream.ts +76 -53
  5. package/src/backend/codex/handler/message.ts +275 -366
  6. package/src/backend/codex/handler/rollout-accounting.ts +137 -0
  7. package/src/backend/openai-agents/handler/message.ts +282 -356
  8. package/src/backend/remote-server/chat-turn.ts +66 -122
  9. package/src/backend/remote-server/index.ts +4 -0
  10. package/src/backend/remote-server/mcp.ts +73 -9
  11. package/src/backend/remote-server/model-catalog/presentation.ts +269 -228
  12. package/src/backend/remote-server/sessions.ts +2 -2
  13. package/src/backend/remote-server/turn.ts +58 -55
  14. package/src/backend/shared/cache-telemetry.ts +17 -1
  15. package/src/backend/shared/handler-to-events.ts +11 -16
  16. package/src/backend/shared/index.ts +14 -15
  17. package/src/backend/shared/result-events.ts +30 -0
  18. package/src/backend/shared/turn-phases.ts +277 -0
  19. package/src/bootstrap.ts +120 -79
  20. package/src/cli/setup.ts +375 -349
  21. package/src/core/background/cron-spec.ts +273 -0
  22. package/src/core/background/heartbeat/agent.ts +167 -104
  23. package/src/core/background/triggers/exit.ts +19 -2
  24. package/src/core/background/triggers/resume.ts +14 -7
  25. package/src/core/background/triggers/state.ts +9 -0
  26. package/src/core/engine/backend-controller/pool.ts +2 -0
  27. package/src/core/engine/backend-controller/state.ts +25 -12
  28. package/src/core/engine/gateway-actions/cron.ts +28 -292
  29. package/src/core/engine/gateway-routes.ts +239 -0
  30. package/src/core/engine/gateway.ts +66 -238
  31. package/src/core/mcp-hub/child-transport.ts +215 -0
  32. package/src/core/mcp-hub/children.ts +72 -13
  33. package/src/core/mcp-hub/index.ts +32 -13
  34. package/src/core/models/active-model.ts +29 -6
  35. package/src/core/vfs/mounts/files.ts +128 -115
  36. package/src/core/weaver/shuttle.ts +33 -2
  37. package/src/core/weaver/weaver.ts +37 -6
  38. package/src/frontend/discord/callbacks/components/agent-buttons.ts +82 -0
  39. package/src/frontend/discord/callbacks/components/backend-select.ts +149 -0
  40. package/src/frontend/discord/callbacks/components/effort.ts +72 -0
  41. package/src/frontend/discord/callbacks/components/index.ts +120 -0
  42. package/src/frontend/discord/callbacks/components/metrics.ts +24 -0
  43. package/src/frontend/discord/callbacks/components/model-nav.ts +93 -0
  44. package/src/frontend/discord/callbacks/components/model-select.ts +118 -0
  45. package/src/frontend/discord/callbacks/components/model.ts +33 -0
  46. package/src/frontend/discord/callbacks/components/pulse.ts +93 -0
  47. package/src/frontend/discord/callbacks/components/settings.ts +243 -0
  48. package/src/frontend/discord/callbacks/components/types.ts +34 -0
  49. package/src/frontend/discord/callbacks/index.ts +4 -4
  50. package/src/frontend/discord/connection.ts +36 -0
  51. package/src/frontend/discord/diagnostics.ts +62 -0
  52. package/src/frontend/discord/guild-policy.ts +89 -0
  53. package/src/frontend/discord/index.ts +44 -305
  54. package/src/frontend/discord/outbound.ts +61 -0
  55. package/src/frontend/discord/ready.ts +73 -0
  56. package/src/frontend/discord/runtime.ts +55 -0
  57. package/src/frontend/native/chat-lifecycle.ts +39 -0
  58. package/src/frontend/native/chat-wire.ts +69 -0
  59. package/src/frontend/native/context.ts +106 -0
  60. package/src/frontend/native/control.ts +78 -0
  61. package/src/frontend/native/emit.ts +167 -0
  62. package/src/frontend/native/empty-chat-sweep.ts +50 -0
  63. package/src/frontend/native/handlers.ts +121 -0
  64. package/src/frontend/native/history.ts +101 -0
  65. package/src/frontend/native/index.ts +90 -1293
  66. package/src/frontend/native/media.ts +43 -0
  67. package/src/frontend/native/models.ts +221 -0
  68. package/src/frontend/native/queue.ts +47 -0
  69. package/src/frontend/native/reset.ts +50 -0
  70. package/src/frontend/native/routes/chats.ts +115 -0
  71. package/src/frontend/native/routes/daemon.ts +70 -0
  72. package/src/frontend/native/routes/host.ts +151 -0
  73. package/src/frontend/native/routes/index.ts +22 -0
  74. package/src/frontend/native/routes/mesh.ts +72 -0
  75. package/src/frontend/native/routes/models.ts +54 -0
  76. package/src/frontend/native/routes/params.ts +29 -0
  77. package/src/frontend/native/routes/pre-auth.ts +94 -0
  78. package/src/frontend/native/routes/table.ts +92 -0
  79. package/src/frontend/native/runtime.ts +109 -0
  80. package/src/frontend/native/server.ts +30 -555
  81. package/src/frontend/native/status.ts +26 -0
  82. package/src/frontend/native/tool-result.ts +48 -0
  83. package/src/frontend/native/turn.ts +341 -0
  84. package/src/frontend/telegram/admin.ts +75 -52
  85. package/src/frontend/whatsapp/access.ts +67 -0
  86. package/src/frontend/whatsapp/connection.ts +280 -0
  87. package/src/frontend/whatsapp/inbound.ts +327 -0
  88. package/src/frontend/whatsapp/index.ts +28 -599
  89. package/src/frontend/whatsapp/runtime.ts +74 -0
  90. package/src/storage/metrics.ts +18 -0
  91. package/src/storage/session-record.ts +29 -0
  92. package/src/storage/sessions.ts +24 -0
  93. package/src/storage/trigger-store.ts +8 -0
  94. package/src/util/boot-timer.ts +31 -0
  95. package/src/util/concurrency.ts +28 -0
  96. package/src/util/watchdog.ts +32 -7
  97. package/src/frontend/discord/callbacks/components.ts +0 -793
  98. package/src/frontend/discord/callbacks/shared.ts +0 -22
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Context-window readout — compute a chat's fill from its session usage,
3
+ * cache it on the runtime, and push changes to clients.
4
+ */
5
+
6
+ import { getSessionInfo } from "../../storage/sessions.js";
7
+ import { buildContextDisplay } from "../shared/status-context.js";
8
+ import { resolveActiveModelForChat } from "../../core/models/active-model.js";
9
+ import {
10
+ getBackendForChat,
11
+ getBackendIdForChat,
12
+ } from "../../core/engine/backend-controller/index.js";
13
+ import { broadcastChatUpdated } from "./chat-wire.js";
14
+ import type { ChatEntry } from "./chats.js";
15
+ import type { ContextInfo } from "./protocol.js";
16
+ import type { NativeRuntime } from "./runtime.js";
17
+
18
+ type ContextOptions = { resolveModel?: boolean };
19
+
20
+ /**
21
+ * Compute the current context-window fill for a chat from its session
22
+ * usage, reusing the same `buildContextDisplay` the terminal/Discord
23
+ * `/status` line uses. When the session's usage doesn't carry a window
24
+ * size yet (fresh session), fall back to the resolved model's window so
25
+ * the readout still lands. Returns undefined when nothing is known — the
26
+ * clients hide the indicator rather than render a bogus 0%.
27
+ */
28
+ async function computeContext(
29
+ runtime: NativeRuntime,
30
+ chatId: string,
31
+ options?: ContextOptions,
32
+ ): Promise<ContextInfo | undefined> {
33
+ try {
34
+ const info = getSessionInfo(chatId);
35
+ const u = info.usage;
36
+ let ctxMax = u.contextWindow;
37
+ // The model fallback touches the backend pool, which is fine for one
38
+ // chat the user just opened and wrong for every chat at boot — the
39
+ // startup warm asks for the cheap path and lets anything it can't
40
+ // resolve fill in when that chat is next opened.
41
+ if (!ctxMax && options?.resolveModel !== false) {
42
+ try {
43
+ const backend = getBackendForChat(chatId);
44
+ const backendId = getBackendIdForChat(chatId);
45
+ const { ref } = await resolveActiveModelForChat(
46
+ chatId,
47
+ backend,
48
+ backendId,
49
+ runtime.config,
50
+ );
51
+ if (ref?.contextWindow) ctxMax = ref.contextWindow;
52
+ } catch {
53
+ /* backend pool not ready — leave max unknown */
54
+ }
55
+ }
56
+ const ctx = buildContextDisplay({
57
+ contextTokens: u.contextTokens,
58
+ lastPromptTokens: u.lastPromptTokens,
59
+ contextWindow: ctxMax,
60
+ });
61
+ if (!ctx.known && ctx.max === 0) return undefined;
62
+ return {
63
+ known: ctx.known,
64
+ used: ctx.used,
65
+ max: ctx.max,
66
+ pct: ctx.pct,
67
+ warn: ctx.warn,
68
+ };
69
+ } catch {
70
+ return undefined;
71
+ }
72
+ }
73
+
74
+ /** Recompute a chat's context fill and, if it changed, push it to clients. */
75
+ export async function refreshContext(
76
+ runtime: NativeRuntime,
77
+ entry: ChatEntry,
78
+ options?: ContextOptions,
79
+ ): Promise<void> {
80
+ const next = await computeContext(runtime, entry.id, options);
81
+ if (!next) return;
82
+ const prev = runtime.contextByChat.get(entry.id);
83
+ if (prev && prev.used === next.used && prev.max === next.max) return;
84
+ runtime.contextByChat.set(entry.id, next);
85
+ broadcastChatUpdated(runtime, entry);
86
+ }
87
+
88
+ /**
89
+ * Fill the context cache for chats restored at startup.
90
+ *
91
+ * The readout is served from an in-memory map that was only ever written at
92
+ * turn end, so after a restart every existing chat reported no context at
93
+ * all — the header chip vanished until that chat ran another turn, which
94
+ * read as "context usage doesn't save". The numbers themselves are
95
+ * persisted with the session; only the cache was cold. This re-reads them
96
+ * for the most recently active chats (bounded, and on the cheap path that
97
+ * never touches the backend pool); anything it skips or can't resolve is
98
+ * filled in the moment the chat is opened.
99
+ */
100
+ const CONTEXT_WARM_LIMIT = 40;
101
+
102
+ export async function warmContextCache(runtime: NativeRuntime): Promise<void> {
103
+ for (const entry of runtime.chats.list().slice(0, CONTEXT_WARM_LIMIT)) {
104
+ await refreshContext(runtime, entry, { resolveModel: false });
105
+ }
106
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Daemon control — the restart / dream actions the app fires from Settings.
3
+ */
4
+
5
+ import { dirname, resolve } from "node:path";
6
+ import { spawn } from "node:child_process";
7
+ import { isBunRuntime } from "../../util/runtime.js";
8
+ import { logError } from "../../util/log.js";
9
+ import { PKG_ROOT } from "../../cli/context.js";
10
+ import { forceDream } from "../../core/background/dream.js";
11
+
12
+ /**
13
+ * Restart the daemon by spawning a detached `talon restart` — the same
14
+ * command a human runs. It must be an independent, detached process: the
15
+ * restart stops *this* process, so doing it in-process would kill us before
16
+ * the successor is spawned. The child outlives us, tears the daemon down,
17
+ * and brings a fresh one up. Mirrors `core/daemon/control.ts`'s own
18
+ * source-vs-compiled-binary spawn recipe.
19
+ */
20
+ function spawnDaemonRestart(): void {
21
+ const isBunBinary =
22
+ (process.argv[1] ?? "").includes("~BUN") ||
23
+ (process.argv[1] ?? "").includes("$bunfs");
24
+ const cmd = process.execPath;
25
+ const args = isBunBinary
26
+ ? ["restart"]
27
+ : isBunRuntime()
28
+ ? [resolve(PKG_ROOT, "src", "cli.ts"), "restart"]
29
+ : [
30
+ resolve(PKG_ROOT, "node_modules", "tsx", "dist", "cli.mjs"),
31
+ resolve(PKG_ROOT, "src", "cli.ts"),
32
+ "restart",
33
+ ];
34
+ const cwd = isBunBinary ? dirname(process.execPath) : PKG_ROOT;
35
+ const child = spawn(cmd, args, {
36
+ cwd,
37
+ detached: true,
38
+ stdio: "ignore",
39
+ env: { ...process.env },
40
+ windowsHide: true,
41
+ });
42
+ child.unref();
43
+ }
44
+
45
+ /**
46
+ * Daemon-level control actions the app fires from Settings. Kept minimal and
47
+ * explicit — each maps to a well-understood operation the CLI already
48
+ * exposes, so there's no new privileged surface beyond "what a local admin
49
+ * could already do".
50
+ */
51
+ export async function control(
52
+ action: string,
53
+ ): Promise<{ ok: boolean; message: string }> {
54
+ switch (action) {
55
+ case "restart":
56
+ spawnDaemonRestart();
57
+ return {
58
+ ok: true,
59
+ message: "Restarting Talon — back online in a few seconds.",
60
+ };
61
+ case "dream":
62
+ // Fire-and-forget: a dream run can take a while; the app just needs
63
+ // to know it started. forceDream throws if one is already running.
64
+ try {
65
+ void forceDream().catch((err) =>
66
+ logError("native", "Manual dream run failed", err),
67
+ );
68
+ return { ok: true, message: "Dream started — consolidating memory." };
69
+ } catch (err) {
70
+ return {
71
+ ok: false,
72
+ message: err instanceof Error ? err.message : String(err),
73
+ };
74
+ }
75
+ default:
76
+ return { ok: false, message: `Unknown control action: ${action}` };
77
+ }
78
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Outbound message helpers — persist a message to history (where it should
3
+ * persist), touch the chat, and broadcast it to every client.
4
+ */
5
+
6
+ import { pushMessage } from "../../storage/history.js";
7
+ import { extractSessionName } from "../../util/session-name.js";
8
+ import { broadcastChatUpdated } from "./chat-wire.js";
9
+ import { DEFAULT_CHAT_TITLE, type ChatEntry } from "./chats.js";
10
+ import { mediaUrl, registerMedia } from "./media.js";
11
+ import {
12
+ BOT_SENDER_ID,
13
+ USER_SENDER_ID,
14
+ type ClientButton,
15
+ type ClientMessage,
16
+ } from "./protocol.js";
17
+ import type { NativeRuntime } from "./runtime.js";
18
+
19
+ /**
20
+ * Name a chat from its first user message — instantly and for free.
21
+ *
22
+ * The title is a trimmed slice of the first message (no model call), so it
23
+ * lands the moment the user hits send instead of staying "New chat" until
24
+ * the turn finishes and a restart re-hydrates the persisted name. Only fires
25
+ * while the chat still carries the placeholder title, so a user's manual
26
+ * rename is never clobbered. Callers broadcast `chat_updated` afterwards, so
27
+ * the change propagates to every connected client live.
28
+ */
29
+ function maybeAutoTitle(
30
+ runtime: NativeRuntime,
31
+ entry: ChatEntry,
32
+ text: string,
33
+ ): void {
34
+ if (entry.title && entry.title !== DEFAULT_CHAT_TITLE) return;
35
+ const title = extractSessionName(text);
36
+ if (title) runtime.chats.rename(entry.id, title);
37
+ }
38
+
39
+ export function emitAssistant(
40
+ runtime: NativeRuntime,
41
+ entry: ChatEntry,
42
+ text: string,
43
+ buttons?: ClientButton[][],
44
+ ): number {
45
+ const id = runtime.nextId();
46
+ const ts = Date.now();
47
+ const message: ClientMessage = {
48
+ id: String(id),
49
+ chatId: entry.id,
50
+ role: "assistant",
51
+ text,
52
+ ts,
53
+ ...(buttons ? { buttons } : {}),
54
+ };
55
+ pushMessage(entry.id, {
56
+ msgId: id,
57
+ senderId: BOT_SENDER_ID,
58
+ senderName: runtime.botName,
59
+ text,
60
+ timestamp: ts,
61
+ });
62
+ runtime.chats.touch(entry.id, text);
63
+ runtime.lastAssistantId.set(entry.id, String(id));
64
+ runtime.broadcast({ kind: "message", chatId: entry.id, message });
65
+ broadcastChatUpdated(runtime, entry);
66
+ return id;
67
+ }
68
+
69
+ /** Persist + broadcast an assistant photo message (image + optional caption). */
70
+ export function emitPhoto(
71
+ runtime: NativeRuntime,
72
+ entry: ChatEntry,
73
+ filePath: string,
74
+ caption?: string,
75
+ ): number {
76
+ const id = runtime.nextId();
77
+ const ts = Date.now();
78
+ const text = caption?.trim() ?? "";
79
+ const mediaId = registerMedia(runtime, filePath);
80
+ const message: ClientMessage = {
81
+ id: String(id),
82
+ chatId: entry.id,
83
+ role: "assistant",
84
+ text,
85
+ ts,
86
+ imagePath: mediaUrl(mediaId),
87
+ };
88
+ // Persist the image reference so it re-renders when history reloads (the
89
+ // filePath is re-registered into the media map on read). The caption is
90
+ // stored as plain text; the `photo` mediaType carries the image marker.
91
+ pushMessage(entry.id, {
92
+ msgId: id,
93
+ senderId: BOT_SENDER_ID,
94
+ senderName: runtime.botName,
95
+ text,
96
+ timestamp: ts,
97
+ mediaType: "photo",
98
+ filePath,
99
+ });
100
+ runtime.chats.touch(entry.id, text || "[photo]");
101
+ runtime.lastAssistantId.set(entry.id, String(id));
102
+ runtime.broadcast({ kind: "message", chatId: entry.id, message });
103
+ broadcastChatUpdated(runtime, entry);
104
+ return id;
105
+ }
106
+
107
+ /** Persist + broadcast a user message; returns its numeric id so the turn
108
+ * hands the model that same id (as `[msg_id:N]`) to react/reply to.
109
+ * An optional imagePath renders an attached image inline. */
110
+ export function emitUser(
111
+ runtime: NativeRuntime,
112
+ entry: ChatEntry,
113
+ text: string,
114
+ imagePath?: string,
115
+ attachmentPath?: string,
116
+ ): number {
117
+ const id = runtime.nextId();
118
+ const ts = Date.now();
119
+ const message: ClientMessage = {
120
+ id: String(id),
121
+ chatId: entry.id,
122
+ role: "user",
123
+ text,
124
+ ts,
125
+ ...(imagePath ? { imagePath } : {}),
126
+ };
127
+ // For an attached image, persist the on-disk path + a `photo` mediaType so
128
+ // it re-renders on history reload (rehydrated in the `history` handler)
129
+ // instead of vanishing to a text-only placeholder. Caption stays as text.
130
+ pushMessage(entry.id, {
131
+ msgId: id,
132
+ senderId: USER_SENDER_ID,
133
+ senderName: "User",
134
+ text,
135
+ timestamp: ts,
136
+ ...(imagePath
137
+ ? {
138
+ mediaType: "photo" as const,
139
+ ...(attachmentPath ? { filePath: attachmentPath } : {}),
140
+ }
141
+ : {}),
142
+ });
143
+ runtime.chats.touch(entry.id, imagePath ? text || "[photo]" : text);
144
+ maybeAutoTitle(runtime, entry, text);
145
+ runtime.broadcast({ kind: "message", chatId: entry.id, message });
146
+ broadcastChatUpdated(runtime, entry);
147
+ return id;
148
+ }
149
+
150
+ /** Transient, non-persisted notice (e.g. "session reset"). */
151
+ export function emitSystem(
152
+ runtime: NativeRuntime,
153
+ entry: ChatEntry,
154
+ text: string,
155
+ ): void {
156
+ runtime.broadcast({
157
+ kind: "message",
158
+ chatId: entry.id,
159
+ message: {
160
+ id: `sys-${runtime.nextId()}`,
161
+ chatId: entry.id,
162
+ role: "system",
163
+ text,
164
+ ts: Date.now(),
165
+ },
166
+ });
167
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Empty-chat sweeper.
3
+ *
4
+ * "New chat" creates a real chat on the daemon the instant it's tapped, so
5
+ * opening one and backing out of it leaves an empty row in every client's
6
+ * list forever. The app deletes an untouched chat as soon as you leave it,
7
+ * which covers the normal case immediately; this is the backstop for the
8
+ * ones no client got to clean up — the app was killed, the network dropped,
9
+ * or the chat was created by something that never came back.
10
+ *
11
+ * Deliberately conservative: only chats that never carried a single message
12
+ * (see ChatEntry.used, which a reset does NOT clear), only after an hour,
13
+ * never one with a turn running or a message queued, and never one whose
14
+ * history says otherwise. Deleting via the same path every client uses
15
+ * means the removal is broadcast, so open lists update in place.
16
+ */
17
+
18
+ import { log, logError } from "../../util/log.js";
19
+ import { getRecentHistory } from "../../storage/history.js";
20
+ import { deleteChat } from "./chat-lifecycle.js";
21
+ import type { NativeRuntime } from "./runtime.js";
22
+ import { isBusy } from "./turn.js";
23
+
24
+ const EMPTY_CHAT_MIN_AGE_MS = 60 * 60_000;
25
+ const EMPTY_CHAT_SWEEP_INTERVAL_MS = 30 * 60_000;
26
+
27
+ function sweepEmptyChats(runtime: NativeRuntime): void {
28
+ for (const entry of runtime.chats.unused(EMPTY_CHAT_MIN_AGE_MS)) {
29
+ if (isBusy(runtime, entry.id) || runtime.queuedByChat.has(entry.id))
30
+ continue;
31
+ if (getRecentHistory(entry.id, 1).length > 0) continue;
32
+ if (deleteChat(runtime, entry.id)) {
33
+ log("native", `Swept empty chat ${entry.id}`);
34
+ }
35
+ }
36
+ }
37
+
38
+ /** Run the sweep on its interval; returns the function that stops it. */
39
+ export function startEmptyChatSweep(runtime: NativeRuntime): () => void {
40
+ const timer = setInterval(() => {
41
+ try {
42
+ sweepEmptyChats(runtime);
43
+ } catch (err) {
44
+ logError("native", "Empty-chat sweep failed", err);
45
+ }
46
+ }, EMPTY_CHAT_SWEEP_INTERVAL_MS);
47
+ // Housekeeping must never be the reason the process stays alive.
48
+ timer.unref?.();
49
+ return () => clearInterval(timer);
50
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Bridge server handlers — the table that binds the transport's routes
3
+ * (server.ts) to the runtime modules. Each entry is a one-line delegation;
4
+ * anything with logic of its own lives in the module named for it.
5
+ */
6
+
7
+ import { files } from "../../util/paths.js";
8
+ import { setChatPulse } from "../../storage/chat-settings.js";
9
+ import { getPooledBackend } from "../../core/engine/backend-controller/index.js";
10
+ import { createChat, deleteChat, renameChat } from "./chat-lifecycle.js";
11
+ import { broadcastChatUpdated, toClientChat } from "./chat-wire.js";
12
+ import { control } from "./control.js";
13
+ import {
14
+ pluginItems,
15
+ skillItems,
16
+ togglePlugin,
17
+ toggleSkill,
18
+ } from "./extensions.js";
19
+ import { historyPage, searchHistory } from "./history.js";
20
+ import { readLogEntries } from "./logs.js";
21
+ import { mediaUrl, registerMedia, saveUpload } from "./media.js";
22
+ import {
23
+ effortLevels,
24
+ listBackends,
25
+ listModels,
26
+ setBackend,
27
+ setEffort,
28
+ setModel,
29
+ } from "./models.js";
30
+ import { setQueued } from "./queue.js";
31
+ import { resetChat } from "./reset.js";
32
+ import type { NativeRuntime } from "./runtime.js";
33
+ import type { BridgeServerHandlers } from "./server.js";
34
+ import { configSnapshot, applyConfigUpdate } from "./settings.js";
35
+ import { bridgeStatus, broadcastStatus } from "./status.js";
36
+ import { interruptTurn, isBusy, liveTurnEvents, startTurn } from "./turn.js";
37
+
38
+ export function buildBridgeHandlers(
39
+ runtime: NativeRuntime,
40
+ ): BridgeServerHandlers {
41
+ const { chats, config, mesh } = runtime;
42
+ return {
43
+ status: () => bridgeStatus(runtime),
44
+ listChats: () => chats.list().map((entry) => toClientChat(runtime, entry)),
45
+ createChat: (title) => createChat(runtime, title),
46
+ renameChat: (id, title) => renameChat(runtime, id, title),
47
+ deleteChat: (id) => deleteChat(runtime, id),
48
+ history: (id, opts) => historyPage(runtime, id, opts),
49
+ search: (query, chatId) => searchHistory(runtime, query, chatId),
50
+ send: (id, text, opts) => {
51
+ const entry = chats.get(id) ?? chats.ensure(id);
52
+ // A turn is already running for this chat — don't interrupt it. Park the
53
+ // message as the single queued follow-up (synced to every client); it
54
+ // auto-sends when the running turn ends. `isBusy` reads `liveTurns`,
55
+ // which `runTurn` sets synchronously, so even a rapid second /send from
56
+ // any client is caught here rather than starting a concurrent turn.
57
+ if (isBusy(runtime, entry.id)) {
58
+ setQueued(runtime, entry.id, {
59
+ text,
60
+ imagePath: opts?.imagePath,
61
+ attachmentPath: opts?.attachmentPath,
62
+ });
63
+ return;
64
+ }
65
+ startTurn(runtime, entry, text, opts);
66
+ },
67
+ queueMessage: (id, text) => {
68
+ // Edit/replace the queued follow-up (text-only). Empty clears it.
69
+ const entry = chats.get(id);
70
+ if (entry) setQueued(runtime, entry.id, { text });
71
+ },
72
+ upload: async (filename, _contentType, bytes) => {
73
+ const path = await saveUpload(runtime, filename, bytes);
74
+ return { imagePath: mediaUrl(registerMedia(runtime, path)), path };
75
+ },
76
+ listModels: (chatId) => listModels(runtime, chatId),
77
+ setModel: (id, model) => setModel(runtime, id, model),
78
+ listBackends: (id) => listBackends(runtime, id),
79
+ setBackend: (id, backend) => setBackend(runtime, id, backend),
80
+ setEffort: (id, effort) => setEffort(runtime, id, effort),
81
+ effortLevels: (id) => effortLevels(runtime, id),
82
+ interruptTurn: (id) => interruptTurn(runtime, id),
83
+ resetChat: (id) => resetChat(runtime, id),
84
+ setPulse: (id, on) => {
85
+ const entry = chats.get(id);
86
+ if (!entry) return;
87
+ setChatPulse(id, on);
88
+ broadcastChatUpdated(runtime, entry);
89
+ },
90
+ getConfig: () => configSnapshot(config),
91
+ setConfig: (update) => {
92
+ const snap = applyConfigUpdate(config, update);
93
+ broadcastStatus(runtime);
94
+ return snap;
95
+ },
96
+ listPlugins: () => pluginItems(config),
97
+ setPluginEnabled: (name, enabled) =>
98
+ togglePlugin(config, getPooledBackend(config.backend), name, enabled),
99
+ listSkills: () => skillItems(),
100
+ setSkillEnabled: (name, enabled) =>
101
+ toggleSkill(config, getPooledBackend(config.backend), name, enabled),
102
+ control,
103
+ logs: ({ lines, minLevel, component }) =>
104
+ readLogEntries(files.log, { limit: lines, minLevel, component }),
105
+ liveTurnEvents: () => liveTurnEvents(runtime),
106
+ mediaPath: (id) => runtime.media.get(id) ?? null,
107
+ // Mesh routes are thin transport shims over the shared core service —
108
+ // storeLocation wakes any pending fresh-fix waiters inside the service.
109
+ registerDevice: (body) => mesh.register(body),
110
+ storeLocation: (body) => mesh.storeLocation(body),
111
+ listDevices: () => mesh.list(),
112
+ completeCommand: (body) => mesh.completeCommand(body),
113
+ acceptFileUpload: (token, body, fromDeviceId) =>
114
+ mesh.acceptFileUpload(token, body, fromDeviceId),
115
+ openFileDownload: (token, fromDeviceId) =>
116
+ mesh.openFileDownload(token, fromDeviceId),
117
+ openCompanionPair: (token, format) => mesh.openCompanionPair(token, format),
118
+ openNodeInstall: (token) => mesh.openNodeInstall(token),
119
+ openNodeBinary: (token) => mesh.openNodeBinary(token),
120
+ };
121
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * History pages + search — persisted rows re-hydrated into the wire shape
3
+ * (attached images get a fresh /media URL, assistant rows their turn meta).
4
+ */
5
+
6
+ import {
7
+ getRecentHistory,
8
+ getHistoryBefore,
9
+ searchHistoryMessages,
10
+ type HistoryMessage,
11
+ } from "../../storage/history.js";
12
+ import { isDeliveryTool } from "../../core/tools/index.js";
13
+ import type { ChatEntry } from "./chats.js";
14
+ import { refreshContext } from "./context.js";
15
+ import { mediaUrl, registerMedia } from "./media.js";
16
+ import {
17
+ historyToClientMessage,
18
+ type ClientMessage,
19
+ type SearchResult,
20
+ } from "./protocol.js";
21
+ import type { NativeRuntime } from "./runtime.js";
22
+ import { getTurnMeta } from "./turn-meta.js";
23
+
24
+ export type HistoryPageOptions = { before?: number; limit?: number };
25
+
26
+ function hydrateHistoryRow(
27
+ runtime: NativeRuntime,
28
+ chatId: string,
29
+ row: HistoryMessage,
30
+ ): ClientMessage {
31
+ const msg = historyToClientMessage(row, chatId);
32
+ // Re-hydrate an attached image: re-register its on-disk path into
33
+ // the media map and hand back a fresh /media URL so the image shows
34
+ // in reloaded history (survives restarts as long as the file exists).
35
+ if (row.mediaType === "photo" && row.filePath) {
36
+ msg.imagePath = mediaUrl(registerMedia(runtime, row.filePath));
37
+ }
38
+ // Re-hydrate turn meta (tool timeline + stats) for assistant rows.
39
+ if (msg.role === "assistant") {
40
+ const meta = getTurnMeta(chatId, msg.id);
41
+ if (meta) {
42
+ // Delivery tools are excluded at record time, but metas
43
+ // persisted by older daemons still carry them — filter on
44
+ // the way out so upgraded installs get clean history too.
45
+ const tools = meta.tools?.filter((t) => !isDeliveryTool(t.name));
46
+ if (tools?.length) msg.tools = tools;
47
+ if (meta.durationMs) msg.durationMs = meta.durationMs;
48
+ if (meta.tokensIn) msg.tokensIn = meta.tokensIn;
49
+ if (meta.tokensOut) msg.tokensOut = meta.tokensOut;
50
+ }
51
+ }
52
+ return msg;
53
+ }
54
+
55
+ /** A page of history: newest window, or the window before `before`. */
56
+ export function historyPage(
57
+ runtime: NativeRuntime,
58
+ chatId: string,
59
+ options?: HistoryPageOptions,
60
+ ): ClientMessage[] {
61
+ // Opening a chat is the one moment we know a client wants its numbers:
62
+ // refresh the context readout on the full path (model fallback and
63
+ // all), which covers every chat the startup warm skipped or couldn't
64
+ // resolve. Fire-and-forget — the figure arrives as a chat_updated.
65
+ const entry = runtime.chats.get(chatId);
66
+ if (entry && options?.before === undefined) {
67
+ void refreshContext(runtime, entry).catch(() => {});
68
+ }
69
+ const limit = Math.min(Math.max(options?.limit ?? 200, 1), 500);
70
+ const rows =
71
+ options?.before !== undefined
72
+ ? getHistoryBefore(chatId, options.before, limit)
73
+ : getRecentHistory(chatId, limit);
74
+ return rows
75
+ .map((m) => hydrateHistoryRow(runtime, chatId, m))
76
+ .sort((a, b) => Number(a.id) - Number(b.id));
77
+ }
78
+
79
+ /** Full-text search across chats (or one chat when `chatId` is given). */
80
+ export function searchHistory(
81
+ runtime: NativeRuntime,
82
+ query: string,
83
+ chatId?: string,
84
+ ): SearchResult[] {
85
+ const targets = chatId
86
+ ? [runtime.chats.get(chatId)].filter((e): e is ChatEntry => e != null)
87
+ : runtime.chats.list();
88
+ const results: SearchResult[] = [];
89
+ for (const entry of targets) {
90
+ for (const m of searchHistoryMessages(entry.id, query, 10)) {
91
+ results.push({
92
+ chatId: entry.id,
93
+ chatTitle: entry.title,
94
+ message: historyToClientMessage(m, entry.id),
95
+ });
96
+ }
97
+ }
98
+ // Newest hits first, bounded across all chats.
99
+ results.sort((a, b) => b.message.ts - a.message.ts);
100
+ return results.slice(0, 50);
101
+ }