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,43 @@
1
+ /**
2
+ * Media handling — the id → path registry the `/media` route serves from,
3
+ * and the uploads directory client attachments land in.
4
+ */
5
+
6
+ import { mkdir, writeFile } from "node:fs/promises";
7
+ import { basename, join } from "node:path";
8
+ import { dirs } from "../../util/paths.js";
9
+ import type { NativeRuntime } from "./runtime.js";
10
+
11
+ /** Register a file for serving and return its short media id. */
12
+ export function registerMedia(
13
+ runtime: NativeRuntime,
14
+ filePath: string,
15
+ ): string {
16
+ const id = `m${runtime.nextId().toString(36)}`;
17
+ runtime.media.set(id, filePath);
18
+ return id;
19
+ }
20
+
21
+ /** The relative bridge path a client renders a registered media id from. */
22
+ export function mediaUrl(mediaId: string): string {
23
+ return `/media?id=${encodeURIComponent(mediaId)}`;
24
+ }
25
+
26
+ /**
27
+ * Persist an uploaded attachment to the workspace uploads dir under a safe,
28
+ * unique name, and return its absolute path (handed to the model to read).
29
+ */
30
+ export async function saveUpload(
31
+ runtime: NativeRuntime,
32
+ filename: string,
33
+ bytes: Buffer,
34
+ ): Promise<string> {
35
+ await mkdir(dirs.uploads, { recursive: true });
36
+ const safe = basename(filename).replace(/[^\w.-]+/g, "_") || "upload";
37
+ const dest = join(
38
+ dirs.uploads,
39
+ `${Date.now()}-${runtime.nextId().toString(36)}-${safe}`,
40
+ );
41
+ await writeFile(dest, bytes);
42
+ return dest;
43
+ }
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Per-chat model, backend and effort selection — the pickers behind the
3
+ * companion app's model sheet. The persisted per-chat setting is always
4
+ * consulted before the live pool binding (see `toClientChat` for why).
5
+ */
6
+
7
+ import {
8
+ getChatSettings,
9
+ setChatEffort,
10
+ setChatModelForBackend,
11
+ getChatModelForBackend,
12
+ setChatBackend,
13
+ EFFORT_LEVELS,
14
+ type EffortLevel,
15
+ } from "../../storage/chat-settings.js";
16
+ import {
17
+ getBackendForChat,
18
+ getBackendIdForChat,
19
+ getPooledBackend,
20
+ acquireBackendInstance,
21
+ listAvailableBackends,
22
+ rebindChat,
23
+ } from "../../core/engine/backend-controller/index.js";
24
+ import { getActiveReasoningLevels } from "../shared/reasoning-levels.js";
25
+ import { broadcastChatUpdated } from "./chat-wire.js";
26
+ import { emitSystem } from "./emit.js";
27
+ import type { BackendOption, ModelOption } from "./protocol.js";
28
+ import { wipeChatConversation } from "./reset.js";
29
+ import type { NativeRuntime } from "./runtime.js";
30
+ import { broadcastStatus } from "./status.js";
31
+
32
+ export async function listModels(
33
+ runtime: NativeRuntime,
34
+ chatId?: string,
35
+ ): Promise<{ active: string; models: ModelOption[] }> {
36
+ const { config } = runtime;
37
+ // Resolve the chat's *own* backend so the model list tracks whatever
38
+ // backend the chat is currently bound to (fixes the list staying on the
39
+ // previous backend's models after a switch). Fall back to the global
40
+ // default backend when there's no chat / the pool isn't ready yet.
41
+ let backendId: string = config.backend;
42
+ let active = config.model;
43
+ if (chatId) {
44
+ try {
45
+ // Persisted setting first — see toClientChat.
46
+ backendId =
47
+ getChatSettings(chatId).backend ?? getBackendIdForChat(chatId);
48
+ active = getChatModelForBackend(chatId, backendId) ?? config.model;
49
+ } catch {
50
+ /* pool not ready — keep global defaults */
51
+ }
52
+ }
53
+
54
+ // Pull the models dynamically from the gateway for that backend. Prefer
55
+ // an already-pooled instance of the *resolved* backend (the persisted
56
+ // choice — the chat's live binding can lag it after a failed boot-time
57
+ // rebind, and listing the live binding's catalog here showed the wrong
58
+ // backend's models); otherwise boot the backend transiently to read its
59
+ // catalog, then release it so we never leak an instance — mirroring the
60
+ // shared `list_models` action.
61
+ let instance:
62
+ | Awaited<ReturnType<typeof acquireBackendInstance>>["backend"]
63
+ | null
64
+ | undefined = getPooledBackend(backendId);
65
+ let release: (() => Promise<void>) | null = null;
66
+ if (!instance) {
67
+ try {
68
+ const acquired = await acquireBackendInstance(backendId);
69
+ instance = acquired.backend;
70
+ release = acquired.release;
71
+ } catch {
72
+ return { active, models: [] };
73
+ }
74
+ }
75
+
76
+ try {
77
+ const catalog = instance.models;
78
+ if (!catalog?.listModels) return { active, models: [] };
79
+ const { models } = await catalog.listModels("all");
80
+ const options: ModelOption[] = models
81
+ .filter((m) => m.selectable)
82
+ .map((m) => ({
83
+ id: m.id,
84
+ displayName: m.displayName,
85
+ provider: m.provider,
86
+ reasoning:
87
+ Boolean(m.supportedReasoningLevels?.length) || Boolean(m.reasoning),
88
+ }));
89
+ return { active, models: options };
90
+ } catch {
91
+ return { active, models: [] };
92
+ } finally {
93
+ if (release) await release();
94
+ }
95
+ }
96
+
97
+ export function setModel(
98
+ runtime: NativeRuntime,
99
+ chatId: string,
100
+ model: string,
101
+ ): void {
102
+ const entry = runtime.chats.get(chatId);
103
+ if (!entry) return;
104
+ // Persisted setting first — see toClientChat. Writing the pick under the
105
+ // live binding's key would attach it to the wrong backend whenever the
106
+ // boot-time rebind to the persisted backend is still pending.
107
+ const backendId =
108
+ getChatSettings(chatId).backend ?? getBackendIdForChat(chatId);
109
+ setChatModelForBackend(chatId, backendId, model.trim() || undefined);
110
+ broadcastChatUpdated(runtime, entry);
111
+ }
112
+
113
+ export function listBackends(
114
+ runtime: NativeRuntime,
115
+ chatId: string,
116
+ ): { active: string; backends: BackendOption[] } {
117
+ const { config } = runtime;
118
+ const backends = listAvailableBackends(config);
119
+ let active: string = config.backend;
120
+ try {
121
+ if (chatId) {
122
+ // Persisted setting first — see toClientChat.
123
+ active = getChatSettings(chatId).backend ?? getBackendIdForChat(chatId);
124
+ }
125
+ } catch {
126
+ /* pool not ready — report the global default backend */
127
+ }
128
+ return { active, backends };
129
+ }
130
+
131
+ /**
132
+ * Switch a chat to another backend. Mirrors the Telegram `/model` backend
133
+ * submenu: verify the target is enabled, rebind the chat's pool holder, pin
134
+ * the override, and drop the previous backend's per-chat session state
135
+ * (sessions aren't portable across backends). Per-backend model picks are
136
+ * kept, so switching back restores the prior model automatically.
137
+ */
138
+ export async function setBackend(
139
+ runtime: NativeRuntime,
140
+ chatId: string,
141
+ backend: string,
142
+ ): Promise<{ ok: boolean; error?: string }> {
143
+ const { config } = runtime;
144
+ const entry = runtime.chats.get(chatId);
145
+ if (!entry) return { ok: false, error: "No such chat" };
146
+ const target = backend.trim();
147
+ const available = listAvailableBackends(config);
148
+ if (!available.some((b) => b.id === target)) {
149
+ return { ok: false, error: "Backend not available" };
150
+ }
151
+ const persisted = getChatSettings(chatId).backend;
152
+ if (getBackendIdForChat(chatId) === target) {
153
+ // Already live on the target — just make sure the choice is persisted.
154
+ if (persisted !== target && target !== config.backend) {
155
+ setChatBackend(chatId, target);
156
+ broadcastChatUpdated(runtime, entry);
157
+ }
158
+ return { ok: true };
159
+ }
160
+
161
+ const result = await rebindChat(chatId, target, config);
162
+ if (!result.ok) {
163
+ return { ok: false, error: result.error ?? "Rebind failed" };
164
+ }
165
+
166
+ // Re-selecting the backend this chat is already persisted to is a
167
+ // RE-ATTACH (e.g. the boot-time rebind failed transiently and the user —
168
+ // or a client retry — picks it again). The conversation belongs to that
169
+ // backend; resetting the session and wiping history here destroyed real
170
+ // conversations and surfaced as a phantom "backend reset" in clients.
171
+ if (persisted === target) {
172
+ broadcastChatUpdated(runtime, entry);
173
+ broadcastStatus(runtime);
174
+ return { ok: true };
175
+ }
176
+
177
+ setChatBackend(chatId, target);
178
+ wipeChatConversation(runtime, chatId);
179
+ emitSystem(
180
+ runtime,
181
+ entry,
182
+ `Switched to ${target} — starting a fresh conversation.`,
183
+ );
184
+ broadcastChatUpdated(runtime, entry);
185
+ broadcastStatus(runtime);
186
+ return { ok: true };
187
+ }
188
+
189
+ export function setEffort(
190
+ runtime: NativeRuntime,
191
+ chatId: string,
192
+ effort: string,
193
+ ): void {
194
+ const entry = runtime.chats.get(chatId);
195
+ if (!entry) return;
196
+ const level = EFFORT_LEVELS.includes(effort as EffortLevel)
197
+ ? (effort as EffortLevel)
198
+ : undefined;
199
+ setChatEffort(chatId, level);
200
+ broadcastChatUpdated(runtime, entry);
201
+ }
202
+
203
+ export async function effortLevels(
204
+ runtime: NativeRuntime,
205
+ chatId: string,
206
+ ): Promise<{ active: string; levels: string[] }> {
207
+ try {
208
+ const backend = getBackendForChat(chatId);
209
+ const backendId = getBackendIdForChat(chatId);
210
+ const { levels } = await getActiveReasoningLevels({
211
+ chatId,
212
+ backend,
213
+ backendId,
214
+ config: runtime.config,
215
+ });
216
+ const active = getChatSettings(chatId).effort ?? "adaptive";
217
+ return { active, levels };
218
+ } catch {
219
+ return { active: "adaptive", levels: [] };
220
+ }
221
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Queued follow-up bookkeeping — the one message a chat holds while a turn
3
+ * is running. Every mutation is synced to clients as a `chat_updated`.
4
+ */
5
+
6
+ import { broadcastChatUpdated } from "./chat-wire.js";
7
+ import type { ChatEntry } from "./chats.js";
8
+ import type { NativeRuntime, QueuedEntry } from "./runtime.js";
9
+
10
+ /**
11
+ * Set (or replace) a chat's queued follow-up and sync it to every client via
12
+ * chat_updated. Empty text with no attachment clears the queue.
13
+ */
14
+ export function setQueued(
15
+ runtime: NativeRuntime,
16
+ chatId: string,
17
+ next: QueuedEntry,
18
+ ): void {
19
+ const entry = runtime.chats.get(chatId);
20
+ if (!entry) return;
21
+ const text = next.text.trim();
22
+ if (!text && !next.attachmentPath) {
23
+ if (!runtime.queuedByChat.delete(chatId)) return;
24
+ } else {
25
+ runtime.queuedByChat.set(chatId, {
26
+ text,
27
+ imagePath: next.imagePath,
28
+ attachmentPath: next.attachmentPath,
29
+ });
30
+ }
31
+ broadcastChatUpdated(runtime, entry);
32
+ }
33
+
34
+ /**
35
+ * Remove and return a chat's queued follow-up (syncing the now-empty queue
36
+ * to clients), or undefined when nothing was queued.
37
+ */
38
+ export function takeQueued(
39
+ runtime: NativeRuntime,
40
+ entry: ChatEntry,
41
+ ): QueuedEntry | undefined {
42
+ const queued = runtime.queuedByChat.get(entry.id);
43
+ if (!queued) return undefined;
44
+ runtime.queuedByChat.delete(entry.id);
45
+ broadcastChatUpdated(runtime, entry);
46
+ return queued;
47
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Chat reset — drop a chat's conversation (session, history, turn meta,
3
+ * cached readouts, pulse checkpoint) while keeping the chat itself.
4
+ */
5
+
6
+ import { resetSession } from "../../storage/sessions.js";
7
+ import { clearHistory } from "../../storage/history.js";
8
+ import { resetPulseCheckpoint } from "../../core/background/pulse.js";
9
+ import { getBackendForChat } from "../../core/engine/backend-controller/index.js";
10
+ import { broadcastChatUpdated } from "./chat-wire.js";
11
+ import { emitSystem } from "./emit.js";
12
+ import type { NativeRuntime } from "./runtime.js";
13
+ import { clearTurnMeta } from "./turn-meta.js";
14
+
15
+ /**
16
+ * Forget everything the conversation accumulated. Shared by the explicit
17
+ * reset and a backend switch (sessions aren't portable across backends).
18
+ */
19
+ export function wipeChatConversation(
20
+ runtime: NativeRuntime,
21
+ chatId: string,
22
+ ): void {
23
+ resetSession(chatId);
24
+ clearHistory(chatId);
25
+ clearTurnMeta(chatId);
26
+ runtime.contextByChat.delete(chatId);
27
+ runtime.queuedByChat.delete(chatId);
28
+ resetPulseCheckpoint(chatId);
29
+ }
30
+
31
+ export function resetChat(runtime: NativeRuntime, chatId: string): boolean {
32
+ const entry = runtime.chats.get(chatId);
33
+ if (!entry) return false;
34
+ // Full reset, matching /reset on the other frontends: session,
35
+ // history (the app re-fetches its transcript from us), pulse
36
+ // checkpoint, and any in-process backend memory. Warm the fresh
37
+ // session in the background — the bridge handler is sync.
38
+ wipeChatConversation(runtime, chatId);
39
+ let backend = null;
40
+ try {
41
+ backend = getBackendForChat(chatId);
42
+ } catch {
43
+ // No pool binding — nothing to wipe or warm.
44
+ }
45
+ backend?.sessions?.resetChat?.(chatId);
46
+ void backend?.sessions?.warmSession?.(chatId)?.catch(() => {});
47
+ emitSystem(runtime, entry, "Session reset — starting a fresh conversation.");
48
+ broadcastChatUpdated(runtime, entry);
49
+ return true;
50
+ }
@@ -0,0 +1,115 @@
1
+ import type { RouteHost } from "./host.js";
2
+ import type { BridgeRoutes } from "./table.js";
3
+ import { asPositiveInt, asString, deviceIdParam } from "./params.js";
4
+
5
+ const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
6
+
7
+ export function chatRoutes(
8
+ host: RouteHost,
9
+ ): Pick<
10
+ BridgeRoutes,
11
+ | "GET /events"
12
+ | "GET /chats"
13
+ | "POST /chats"
14
+ | "POST /chats/rename"
15
+ | "POST /chats/delete"
16
+ | "POST /chats/reset"
17
+ | "POST /chats/interrupt"
18
+ | "POST /chats/pulse"
19
+ | "POST /queue"
20
+ | "GET /history"
21
+ | "GET /search"
22
+ | "POST /send"
23
+ | "POST /upload"
24
+ | "GET /media"
25
+ > {
26
+ const { json, readJson, handlers: h } = host;
27
+ return {
28
+ // ── Chats ──────────────────────────────────────────────────────────
29
+
30
+ // A mesh client names itself here so device-addressed events reach
31
+ // it alone (see sendToDevice); UI clients simply omit it.
32
+ "GET /events": ({ res, url }) => host.openStream(res, deviceIdParam(url)),
33
+ "GET /chats": ({ res }) => json(res, 200, { chats: h.listChats() }),
34
+ "POST /chats": async ({ req, res }) => {
35
+ const body = await readJson(req);
36
+ json(res, 200, { chat: h.createChat(asString(body.title)) });
37
+ },
38
+ "POST /chats/rename": async ({ req, res }) => {
39
+ const body = await readJson(req);
40
+ const chat = h.renameChat(
41
+ asString(body.chatId) ?? "",
42
+ asString(body.title) ?? "",
43
+ );
44
+ return chat
45
+ ? json(res, 200, { chat })
46
+ : json(res, 404, { ok: false, error: "No such chat" });
47
+ },
48
+ "POST /chats/delete": async ({ req, res }) => {
49
+ const body = await readJson(req);
50
+ json(res, 200, { ok: h.deleteChat(asString(body.chatId) ?? "") });
51
+ },
52
+ "POST /chats/reset": async ({ req, res }) => {
53
+ const body = await readJson(req);
54
+ json(res, 200, { ok: h.resetChat(asString(body.chatId) ?? "") });
55
+ },
56
+ "POST /chats/interrupt": async ({ req, res }) => {
57
+ const body = await readJson(req);
58
+ const ok = await h.interruptTurn(asString(body.chatId) ?? "");
59
+ json(res, 200, { ok });
60
+ },
61
+ "POST /chats/pulse": async ({ req, res }) => {
62
+ const body = await readJson(req);
63
+ h.setPulse(asString(body.chatId) ?? "", body.on === true);
64
+ json(res, 200, { ok: true });
65
+ },
66
+ "POST /queue": async ({ req, res }) => {
67
+ const body = await readJson(req);
68
+ h.queueMessage(asString(body.chatId) ?? "", asString(body.text) ?? "");
69
+ json(res, 200, { ok: true });
70
+ },
71
+ "GET /history": ({ res, url }) => {
72
+ const id = url.searchParams.get("chatId") ?? "";
73
+ const before = asPositiveInt(url.searchParams.get("before"));
74
+ const limit = asPositiveInt(url.searchParams.get("limit"));
75
+ json(res, 200, {
76
+ chatId: id,
77
+ messages: h.history(id, { before, limit }),
78
+ });
79
+ },
80
+ "GET /search": ({ res, url }) => {
81
+ const q = (url.searchParams.get("q") ?? "").trim();
82
+ if (!q) return json(res, 400, { ok: false, error: "q required" });
83
+ const chatId = url.searchParams.get("chatId") ?? undefined;
84
+ json(res, 200, { results: h.search(q, chatId) });
85
+ },
86
+ "POST /send": async ({ req, res }) => {
87
+ const body = await readJson(req);
88
+ const id = asString(body.chatId) ?? "";
89
+ const text = asString(body.text) ?? "";
90
+ const imagePath = asString(body.imagePath);
91
+ const attachmentPath = asString(body.attachmentPath);
92
+ // Text may be empty when an image is attached; require one or the
93
+ // other.
94
+ if (!id || (!text.trim() && !attachmentPath))
95
+ return json(res, 400, {
96
+ ok: false,
97
+ error: "chatId and text (or an attachment) required",
98
+ });
99
+ h.send(id, text, { imagePath, attachmentPath });
100
+ json(res, 202, { ok: true });
101
+ },
102
+ "POST /upload": async ({ req, res, url }) => {
103
+ const filename = url.searchParams.get("filename") ?? "upload";
104
+ const contentType =
105
+ req.headers["content-type"] ?? "application/octet-stream";
106
+ const bytes = await host.readRaw(req, MAX_UPLOAD_BYTES);
107
+ if (!bytes.length)
108
+ return json(res, 400, { ok: false, error: "Empty upload" });
109
+ const result = await h.upload(filename, contentType, bytes);
110
+ json(res, 200, { ok: true, ...result });
111
+ },
112
+ "GET /media": ({ res, url }) =>
113
+ host.serveMedia(res, url.searchParams.get("id") ?? ""),
114
+ };
115
+ }
@@ -0,0 +1,70 @@
1
+ import type { RouteHost } from "./host.js";
2
+ import type { BridgeRoutes } from "./table.js";
3
+ import { isLogLevel } from "../protocol.js";
4
+ import { asPositiveInt, asString } from "./params.js";
5
+
6
+ export function daemonRoutes(
7
+ host: RouteHost,
8
+ ): Pick<
9
+ BridgeRoutes,
10
+ | "GET /logs"
11
+ | "GET /plugins"
12
+ | "POST /plugins/toggle"
13
+ | "GET /skills"
14
+ | "POST /skills/toggle"
15
+ | "GET /config"
16
+ | "POST /config"
17
+ | "POST /control"
18
+ > {
19
+ const { json, readJson, handlers: h } = host;
20
+ return {
21
+ // ── Daemon ─────────────────────────────────────────────────────────
22
+
23
+ "GET /logs": ({ res, url }) => {
24
+ const lines = Math.min(
25
+ asPositiveInt(url.searchParams.get("lines")) ?? 200,
26
+ 1000,
27
+ );
28
+ const level = url.searchParams.get("level") ?? "";
29
+ const component = url.searchParams.get("component") ?? undefined;
30
+ json(res, 200, {
31
+ entries: h.logs({
32
+ lines,
33
+ minLevel: isLogLevel(level) ? level : undefined,
34
+ component,
35
+ }),
36
+ });
37
+ },
38
+ "GET /plugins": ({ res }) => json(res, 200, { plugins: h.listPlugins() }),
39
+ "POST /plugins/toggle": async ({ req, res }) => {
40
+ const body = await readJson(req);
41
+ // Always 200: ok/error is an application result the client renders
42
+ // (mirrors /backend).
43
+ const result = await h.setPluginEnabled(
44
+ asString(body.name) ?? "",
45
+ body.enabled === true,
46
+ );
47
+ json(res, 200, result);
48
+ },
49
+ "GET /skills": ({ res }) => json(res, 200, { skills: h.listSkills() }),
50
+ "POST /skills/toggle": async ({ req, res }) => {
51
+ const body = await readJson(req);
52
+ json(
53
+ res,
54
+ 200,
55
+ h.setSkillEnabled(asString(body.name) ?? "", body.enabled === true),
56
+ );
57
+ },
58
+ "GET /config": ({ res }) => json(res, 200, h.getConfig()),
59
+ "POST /config": async ({ req, res }) => {
60
+ const body = await readJson(req);
61
+ json(res, 200, h.setConfig(body));
62
+ },
63
+ "POST /control": async ({ req, res }) => {
64
+ const body = await readJson(req);
65
+ // Always 200: ok/message is an application result the client
66
+ // renders (mirrors /backend).
67
+ json(res, 200, await h.control(asString(body.action) ?? ""));
68
+ },
69
+ };
70
+ }