@qearlyao/familiar 0.2.5 → 0.3.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.
Files changed (81) hide show
  1. package/README.md +4 -0
  2. package/config.example.toml +2 -2
  3. package/dist/agent/payload-normalizers.js +52 -0
  4. package/dist/agent/session-helpers.js +86 -0
  5. package/dist/agent/tool-descriptions.js +4 -0
  6. package/dist/agent/tools.js +30 -0
  7. package/dist/agent/transcript-log.js +93 -0
  8. package/dist/agent/types.js +1 -0
  9. package/dist/agent-core.js +82 -0
  10. package/dist/agent-work-queue.js +55 -0
  11. package/dist/agent.js +91 -322
  12. package/dist/browser-tools.js +7 -8
  13. package/dist/chat-log.js +15 -3
  14. package/dist/cli.js +36 -6
  15. package/dist/config/enums.js +35 -0
  16. package/dist/config/interpolate.js +15 -0
  17. package/dist/config/model-refs.js +11 -0
  18. package/dist/config/readers.js +116 -0
  19. package/dist/config/sections.js +113 -0
  20. package/dist/config/types.js +1 -0
  21. package/dist/config-registry.js +26 -7
  22. package/dist/config.js +8 -271
  23. package/dist/discord/channel.js +32 -0
  24. package/dist/discord/chunking.js +163 -0
  25. package/dist/discord/client.js +44 -0
  26. package/dist/discord/commands.js +181 -0
  27. package/dist/discord/inbound.js +44 -0
  28. package/dist/discord/send.js +106 -0
  29. package/dist/discord/turn.js +55 -0
  30. package/dist/discord.js +266 -1186
  31. package/dist/ids.js +11 -0
  32. package/dist/index.js +1 -0
  33. package/dist/memory/index/store.js +21 -17
  34. package/dist/memory/index/vector-codec.js +2 -2
  35. package/dist/memory/lcm/context-transformer.js +6 -2
  36. package/dist/memory/lcm/segment-manager.js +6 -2
  37. package/dist/memory/lcm/store/index-ids.js +6 -0
  38. package/dist/memory/lcm/store/inserts.js +31 -0
  39. package/dist/memory/lcm/store/normalizers.js +91 -0
  40. package/dist/memory/lcm/store/row-mappers.js +114 -0
  41. package/dist/memory/lcm/store/row-types.js +1 -0
  42. package/dist/memory/lcm/store/serialization.js +37 -0
  43. package/dist/memory/lcm/store/snapshots.js +73 -0
  44. package/dist/memory/lcm/store.js +20 -360
  45. package/dist/owner-identity.js +29 -0
  46. package/dist/runtime-manager.js +51 -0
  47. package/dist/runtime.js +89 -41
  48. package/dist/scheduler-runner.js +243 -0
  49. package/dist/scheduler.js +1 -1
  50. package/dist/service.js +1 -0
  51. package/dist/settings.js +3 -0
  52. package/dist/web/event-hub.js +246 -0
  53. package/dist/{web-http.js → web/http.js} +19 -5
  54. package/dist/web/memes.js +25 -0
  55. package/dist/web/messages.js +345 -0
  56. package/dist/web/multipart.js +80 -0
  57. package/dist/web/payloads.js +34 -0
  58. package/dist/{web-static.js → web/static.js} +19 -14
  59. package/dist/web/stream.js +69 -0
  60. package/dist/web-tools/cache.js +42 -0
  61. package/dist/web-tools/config.js +16 -0
  62. package/dist/web-tools/fetch-providers.js +119 -0
  63. package/dist/web-tools/format.js +88 -0
  64. package/dist/web-tools/http.js +81 -0
  65. package/dist/web-tools/routing.js +29 -0
  66. package/dist/web-tools/safety.js +73 -0
  67. package/dist/web-tools/search-providers.js +277 -0
  68. package/dist/web-tools/types.js +54 -0
  69. package/dist/web-tools/util.js +23 -0
  70. package/dist/web-tools.js +9 -798
  71. package/dist/web.js +416 -984
  72. package/npm-shrinkwrap.json +242 -201
  73. package/package.json +4 -4
  74. package/web/dist/assets/index-CSkxUQCr.js +63 -0
  75. package/web/dist/assets/index-DllM6RqL.css +2 -0
  76. package/web/dist/index.html +6 -3
  77. package/web/dist/assets/index-B23WT77N.js +0 -63
  78. package/web/dist/assets/index-D3MotFzN.css +0 -2
  79. /package/dist/{web-auth.js → web/auth.js} +0 -0
  80. /package/dist/{web-events.js → web/events.js} +0 -0
  81. /package/dist/{web-types.js → web/types.js} +0 -0
package/dist/web.js CHANGED
@@ -1,674 +1,62 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { createServer } from "node:http";
4
- import { join } from "node:path";
5
4
  import { getProviders } from "@earendil-works/pi-ai";
6
5
  import { addModel, loadAddedModels, removeModel, setAddedModelsPath } from "./added-models.js";
7
6
  import { createAgentEventRecorder, storedAgentEventFromAgentEvent, thinkingDurationMs, updateAgentEventSummary, } from "./agent-events.js";
8
- import { clearConfigOverride, loadConfigOverrides, setConfigOverride } from "./config-overrides.js";
9
- import { CONFIG_KEYS, CONFIG_REGISTRY, getConfigDefault, isConfigKey } from "./config-registry.js";
7
+ import { loadConfigOverrides } from "./config-overrides.js";
8
+ import { CONFIG_KEYS, CONFIG_REGISTRY, clearConfigChange, commitConfigChange, isConfigKey, } from "./config-registry.js";
10
9
  import { getContactNickname, refreshContactNote, setContactNotePath } from "./contact-note.js";
11
- import { publicAttachmentPath } from "./generated-media.js";
10
+ import { messageId } from "./ids.js";
12
11
  import { materializeInboundAttachments } from "./inbound-attachments.js";
13
- import { PROVIDER_DEFAULTS, parseModelRef, supportedThinkingLevels } from "./models.js";
12
+ import { PROVIDER_DEFAULTS, parseModelRef } from "./models.js";
14
13
  import { loadPersona, parsePersonaName } from "./persona.js";
15
- import { consumeSilentDelta, createSilentFilterState, finalizeSilentFilter, parseAgentReply } from "./silent-marker.js";
16
- import { createAuth, sessionCookie, verifyTotp } from "./web-auth.js";
17
- import { acceptWebSocket, decodeFrames, encodeFrame, replayEvents } from "./web-events.js";
18
- import { isObject, readJsonBody, sendJson, sendText } from "./web-http.js";
19
- import { serveAttachment, serveStatic } from "./web-static.js";
20
- import { EVENT_REPLAY_LIMIT, WEB_USER_NAME, } from "./web-types.js";
21
- function toUnixMs(ts) {
22
- const parsed = ts ? Date.parse(ts) : NaN;
23
- return Number.isFinite(parsed) ? parsed : Date.now();
14
+ import { formatSetting } from "./settings.js";
15
+ import { parseAgentReply } from "./silent-marker.js";
16
+ import { isRecord } from "./util/guards.js";
17
+ import { createAuth, sessionCookie, verifyTotp } from "./web/auth.js";
18
+ import { createWebEventHub } from "./web/event-hub.js";
19
+ import { HttpError, readJsonBody, sendJson, sendText } from "./web/http.js";
20
+ import { memeCatalogPath, parseMemeCatalog } from "./web/memes.js";
21
+ import { webAttachments, webHistoryPayload } from "./web/messages.js";
22
+ import { isWebUploadAttachment, readMultipartBody } from "./web/multipart.js";
23
+ import { agentSettingsPayload, commandArgs, sessionDto } from "./web/payloads.js";
24
+ import { serveAttachment, serveStatic } from "./web/static.js";
25
+ import { attachWebSocketStream } from "./web/stream.js";
26
+ import { WEB_USER_NAME } from "./web/types.js";
27
+ function errorMessage(error) {
28
+ return error instanceof Error ? error.message : String(error);
24
29
  }
25
- function eventId() {
26
- return `evt_${randomUUID()}`;
27
- }
28
- function messageId(prefix = "msg") {
29
- return `${prefix}_${randomUUID()}`;
30
- }
31
- function isUserVisibleRuntimeRecord(record) {
32
- return record.type !== "runtime" || !["armed", "reset", "stopped"].includes(record.event);
33
- }
34
- function parseMemeCatalog(markdown) {
35
- const families = [];
36
- let currentFamily;
37
- for (const line of markdown.split(/\r?\n/)) {
38
- const familyMatch = line.match(/^## (.+)$/);
39
- if (familyMatch) {
40
- currentFamily = { name: familyMatch[1]?.trim() ?? "", memes: [] };
41
- families.push(currentFamily);
42
- continue;
43
- }
44
- if (!currentFamily || !line.startsWith("- ") || !line.includes(" — "))
45
- continue;
46
- const separator = line.indexOf(" — ");
47
- const name = line.slice(2, separator).trim();
48
- const suffix = line.slice(separator + " — ".length).trim();
49
- if (!name || !suffix)
50
- continue;
51
- currentFamily.memes.push({ name, url: `https://files.catbox.moe/${suffix}` });
52
- }
53
- return families;
54
- }
55
- function memeCatalogPath(config) {
56
- return join(config.workspacePath, "skills", "memes", "SKILL.md");
57
- }
58
- function isWebUploadAttachment(value) {
59
- return !!value && typeof value === "object" && Buffer.isBuffer(value.buffer);
60
- }
61
- async function readRawBody(request, maxBytes) {
62
- const chunks = [];
63
- let total = 0;
64
- for await (const chunk of request) {
65
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
66
- total += buffer.length;
67
- if (total > maxBytes)
68
- throw new Error("Request body too large");
69
- chunks.push(buffer);
70
- }
71
- return Buffer.concat(chunks);
72
- }
73
- function multipartBoundary(contentType) {
74
- const header = Array.isArray(contentType) ? contentType.find((value) => value.includes("boundary=")) : contentType;
75
- const match = header?.match(/boundary=(?:"([^"]+)"|([^;]+))/i);
76
- if (!match?.[1] && !match?.[2])
77
- throw new Error("Missing multipart boundary");
78
- return match[1] ?? match[2] ?? "";
79
- }
80
- function parseContentDisposition(header) {
81
- const parts = header.split(";").map((part) => part.trim());
82
- const values = {};
83
- for (const part of parts.slice(1)) {
84
- const [key, rawValue] = part.split("=");
85
- if (!key || rawValue === undefined)
86
- continue;
87
- values[key.toLowerCase()] = rawValue.replace(/^"|"$/g, "");
88
- }
89
- return values;
90
- }
91
- async function readMultipartBody(request, contentType) {
92
- const boundary = multipartBoundary(contentType);
93
- const raw = await readRawBody(request, 32 * 1024 * 1024);
94
- const binary = raw.toString("binary");
95
- const marker = `--${boundary}`;
96
- const attachments = [];
97
- const body = { text: "" };
98
- for (const section of binary.split(marker).slice(1)) {
99
- if (!section || section === "--\r\n" || section === "--")
100
- continue;
101
- const trimmed = section.replace(/^\r\n/, "").replace(/\r\n--$/, "");
102
- const headerEnd = trimmed.indexOf("\r\n\r\n");
103
- if (headerEnd < 0)
104
- continue;
105
- const headerText = trimmed.slice(0, headerEnd);
106
- let contentBinary = trimmed.slice(headerEnd + 4);
107
- if (contentBinary.endsWith("\r\n"))
108
- contentBinary = contentBinary.slice(0, -2);
109
- const headers = Object.fromEntries(headerText.split("\r\n").map((line) => {
110
- const colon = line.indexOf(":");
111
- return colon >= 0
112
- ? [line.slice(0, colon).trim().toLowerCase(), line.slice(colon + 1).trim()]
113
- : [line.toLowerCase(), ""];
114
- }));
115
- const disposition = parseContentDisposition(headers["content-disposition"] ?? "");
116
- const name = disposition.name;
117
- if (!name)
118
- continue;
119
- if (name === "text" || name === "channelKey" || name === "clientId") {
120
- body[name] = Buffer.from(contentBinary, "binary").toString("utf8");
121
- continue;
122
- }
123
- if (name !== "attachments")
124
- continue;
125
- const buffer = Buffer.from(contentBinary, "binary");
126
- if (buffer.length === 0)
127
- continue;
128
- attachments.push({
129
- name: disposition.filename,
130
- mimeType: headers["content-type"],
131
- size: buffer.length,
132
- buffer,
133
- });
134
- }
135
- body.attachments = attachments;
136
- return body;
137
- }
138
- function webAttachments(config, attachments) {
139
- if (!attachments?.length)
140
- return undefined;
141
- return attachments.map((attachment) => ({
142
- id: attachment.id,
143
- name: attachment.name,
144
- kind: attachment.kind,
145
- mimeType: attachment.mimeType,
146
- size: attachment.size,
147
- url: attachment.localPath ? publicAttachmentPath(config, attachment.localPath) : attachment.remoteUrl,
148
- }));
149
- }
150
- function attachmentDerivedText(attachment) {
151
- if (attachment.derived?.text?.label === "preview")
152
- return undefined;
153
- return attachment.derived?.text?.text;
154
- }
155
- function toolError(result) {
156
- if (typeof result === "string")
157
- return result;
158
- if (!isObject(result))
159
- return undefined;
160
- if (typeof result.error === "string")
161
- return result.error;
162
- if (typeof result.message === "string")
163
- return result.message;
164
- return undefined;
165
- }
166
- function toolFromStoredAgentEvent(event, ts) {
167
- if (event.type === "tool_execution_start") {
168
- return {
169
- id: event.toolCallId,
170
- name: event.toolName,
171
- status: "running",
172
- args: event.args,
173
- startedAt: ts,
174
- updatedAt: ts,
175
- };
176
- }
177
- if (event.type === "tool_execution_update") {
178
- return {
179
- id: event.toolCallId,
180
- name: event.toolName,
181
- status: "running",
182
- args: event.args,
183
- partialResult: event.partialResult,
184
- updatedAt: ts,
185
- };
186
- }
187
- if (event.type === "tool_execution_end") {
188
- return {
189
- id: event.toolCallId,
190
- name: event.toolName,
191
- status: event.isError ? "error" : "completed",
192
- result: event.result,
193
- error: event.isError ? toolError(event.result) : undefined,
194
- completedAt: ts,
195
- updatedAt: ts,
196
- };
197
- }
198
- if (event.type === "message_update" && event.assistantMessageEvent.type === "toolcall_end") {
199
- return {
200
- id: event.assistantMessageEvent.toolCall.id,
201
- name: event.assistantMessageEvent.toolCall.name,
202
- status: "pending",
203
- args: event.assistantMessageEvent.toolCall.arguments,
204
- updatedAt: ts,
205
- };
206
- }
207
- return undefined;
208
- }
209
- function mergeToolEvent(existing, patch) {
210
- const terminal = patch.status === "completed" || patch.status === "error";
211
- return {
212
- ...existing,
213
- ...patch,
214
- args: patch.args ?? existing?.args,
215
- partialResult: terminal ? undefined : (patch.partialResult ?? existing?.partialResult),
216
- result: patch.result ?? existing?.result,
217
- error: patch.error ?? existing?.error,
218
- startedAt: existing?.startedAt ?? patch.startedAt,
219
- };
220
- }
221
- function stepId(messageId, kind, index) {
222
- return `${messageId}-${kind}-${index}`;
223
- }
224
- function closeOpenContentSteps(steps, now) {
225
- for (const step of steps) {
226
- if (step.kind === "thinking" && !step.complete) {
227
- step.complete = true;
228
- step.endedAt ??= now;
229
- }
230
- if (step.kind === "text" && !step.complete)
231
- step.complete = true;
232
- }
233
- }
234
- function appendDeltaStep(steps, messageId, part, content, now) {
235
- const last = steps.at(-1);
236
- if (part === "thinking") {
237
- if (last?.kind === "thinking" && !last.complete) {
238
- last.text += content;
239
- return;
240
- }
241
- closeOpenContentSteps(steps, now);
242
- steps.push({
243
- kind: "thinking",
244
- id: stepId(messageId, "thinking", steps.length),
245
- text: content,
246
- startedAt: now,
247
- });
248
- return;
249
- }
250
- if (last?.kind === "text" && !last.complete) {
251
- last.text += content;
252
- return;
253
- }
254
- closeOpenContentSteps(steps, now);
255
- steps.push({ kind: "text", id: stepId(messageId, "text", steps.length), text: content });
256
- }
257
- function upsertToolStep(steps, tool, now) {
258
- const index = steps.findIndex((step) => step.kind === "tool" && step.tool.id === tool.id);
259
- if (index >= 0) {
260
- const existing = steps[index];
261
- if (existing?.kind === "tool")
262
- existing.tool = mergeToolEvent(existing.tool, tool);
263
- return;
264
- }
265
- closeOpenContentSteps(steps, now);
266
- steps.push({ kind: "tool", id: tool.id, tool });
267
- }
268
- function applyStoredAgentEventToMessage(message, record, options) {
269
- const event = record.event;
270
- const ts = toUnixMs(record.ts);
271
- message.steps ??= [];
272
- const steps = message.steps;
273
- if (event.type === "message_update") {
274
- const assistantEvent = event.assistantMessageEvent;
275
- if (assistantEvent.type === "text_delta") {
276
- appendDeltaStep(steps, message.id, "text", assistantEvent.delta, ts);
277
- if (options.applyTextDeltas)
278
- message.text += assistantEvent.delta;
279
- }
280
- if (assistantEvent.type === "thinking_delta") {
281
- appendDeltaStep(steps, message.id, "thinking", assistantEvent.delta, ts);
282
- if (options.applyThinkingDeltas)
283
- message.thinking = `${message.thinking ?? ""}${assistantEvent.delta}`;
284
- }
285
- }
286
- if (event.type === "message_end") {
287
- closeOpenContentSteps(steps, ts);
288
- if (event.usage)
289
- message.usage = event.usage;
290
- }
291
- const tool = toolFromStoredAgentEvent(event, ts);
292
- if (tool) {
293
- upsertToolStep(steps, tool, ts);
294
- const tools = message.tools ?? [];
295
- const index = tools.findIndex((candidate) => candidate.id === tool.id);
296
- if (index >= 0) {
297
- tools[index] = mergeToolEvent(tools[index], tool);
298
- }
299
- else {
300
- tools.push(tool);
301
- }
302
- message.tools = tools;
303
- }
304
- }
305
- function ensureFallbackSteps(message) {
306
- if (message.steps?.length)
307
- return;
308
- const steps = [];
309
- if (message.thinking || message.thinkingMs != null) {
310
- const endedAt = message.ts;
311
- steps.push({
312
- kind: "thinking",
313
- id: stepId(message.id, "thinking", steps.length),
314
- text: message.thinking ?? "",
315
- startedAt: endedAt - (message.thinkingMs ?? 0),
316
- endedAt,
317
- complete: true,
318
- });
319
- }
320
- for (const tool of message.tools ?? [])
321
- steps.push({ kind: "tool", id: tool.id, tool });
322
- if (message.text) {
323
- steps.push({ kind: "text", id: stepId(message.id, "text", steps.length), text: message.text, complete: true });
324
- }
325
- if (steps.length)
326
- message.steps = steps;
327
- }
328
- function webMessagesFromRecords(config, records, assistantName) {
329
- const messages = [];
330
- const messagesById = new Map();
331
- const pendingAgentEvents = new Map();
332
- for (const record of records) {
333
- const message = webMessageFromRecord(config, record, assistantName);
334
- if (message) {
335
- messages.push(message);
336
- messagesById.set(message.id, message);
337
- const pending = pendingAgentEvents.get(message.id) ?? [];
338
- for (const pendingRecord of pending) {
339
- applyStoredAgentEventToMessage(message, pendingRecord, {
340
- applyTextDeltas: !message.text && !message.silent,
341
- applyThinkingDeltas: !message.thinking,
342
- });
343
- }
344
- pendingAgentEvents.delete(message.id);
345
- }
346
- if (record.type === "agent_event") {
347
- const existing = messagesById.get(record.messageId);
348
- if (existing) {
349
- applyStoredAgentEventToMessage(existing, record, {
350
- applyTextDeltas: !existing.silent,
351
- applyThinkingDeltas: true,
352
- });
353
- }
354
- else {
355
- const pending = pendingAgentEvents.get(record.messageId) ?? [];
356
- pending.push(record);
357
- pendingAgentEvents.set(record.messageId, pending);
358
- }
359
- }
360
- }
361
- for (const message of messages)
362
- ensureFallbackSteps(message);
363
- return messages;
364
- }
365
- function webHistoryPayload(config, records, assistantName, channelKey, options) {
366
- const messages = webMessagesFromRecords(config, records, assistantName);
367
- const end = options.before ? messages.findIndex((message) => message.id === options.before) : messages.length;
368
- const safeEnd = end >= 0 ? end : messages.length;
369
- const page = messages.slice(Math.max(0, safeEnd - options.limit), safeEnd);
370
- return { messages: page, hasMore: safeEnd - options.limit > 0, channelKey };
371
- }
372
- function webMessageFromRecord(config, record, assistantName) {
373
- if (!isUserVisibleRuntimeRecord(record))
374
- return undefined;
375
- if (record.type === "inbound") {
376
- const attachmentText = record.attachments
377
- .map((attachment) => attachmentDerivedText(attachment))
378
- .filter((text) => !!text)
379
- .join("\n");
380
- return {
381
- id: record.messageId,
382
- role: "user",
383
- who: record.authorName || getContactNickname(WEB_USER_NAME),
384
- text: [record.text, attachmentText].filter(Boolean).join("\n"),
385
- attachments: webAttachments(config, record.attachments),
386
- ts: toUnixMs(record.ts),
387
- };
388
- }
389
- if (record.type === "outbound" && !record.control) {
390
- return {
391
- id: record.webMessageId || record.messageIds[0] || `out_${record.recordId}`,
392
- role: "assistant",
393
- who: assistantName,
394
- text: record.text,
395
- attachments: webAttachments(config, record.attachments),
396
- thinking: record.thinking,
397
- thinkingMs: record.thinkingMs,
398
- silent: record.silent || undefined,
399
- ts: toUnixMs(record.ts),
400
- };
401
- }
402
- if (record.type === "runtime" || record.type === "error") {
403
- return {
404
- id: `sys_${record.recordId}`,
405
- role: "system",
406
- who: "system",
407
- text: record.type === "runtime" ? record.detail || record.event : record.message,
408
- ts: toUnixMs(record.ts),
409
- };
410
- }
411
- return undefined;
412
- }
413
- function commandArgs(command, args) {
414
- if (!isObject(args))
415
- return "";
416
- if (command === "model")
417
- return typeof args.model === "string" ? args.model : "";
418
- if (command === "thinking")
419
- return typeof args.level === "string" ? args.level : "";
420
- if (command === "channel-trigger")
421
- return typeof args.trigger === "string" ? args.trigger : "";
422
- return "";
423
- }
424
- function formatSetting(setting) {
425
- return `${setting.value} (${setting.source})`;
426
- }
427
- function agentSettingsPayload(familiarAgent, channelKey, personaName) {
428
- const { model } = familiarAgent.resolveChannelModel(channelKey);
429
- return {
430
- model: familiarAgent.getModel(channelKey),
431
- thinking: familiarAgent.getThinkingLevel(channelKey),
432
- supportedThinking: supportedThinkingLevels(model),
433
- persona: { name: personaName },
434
- };
435
- }
436
- function sessionDto(session) {
437
- return {
438
- key: session.key,
439
- label: session.label,
440
- service: session.channel.service,
441
- scope: session.channel.scope,
442
- channelId: session.channel.channelId,
443
- channelName: session.channel.channelName,
444
- threadId: session.channel.threadId,
445
- isDefault: session.isDefault,
446
- };
447
- }
448
- export async function startWebDaemon(config, familiarAgent, discordDaemon, options = {}) {
30
+ export async function startWebDaemon(config, familiarAgent, agentCore, options = {}) {
449
31
  setAddedModelsPath(config.workspace.dataDir);
450
32
  setContactNotePath(config.persona.contact);
451
33
  await refreshContactNote();
452
34
  const persona = await loadPersona(config);
453
35
  const personaName = parsePersonaName(persona.soul);
454
36
  const auth = createAuth(config);
455
- const clients = new Set();
456
- const eventsByChannel = new Map();
457
- const runtimeSubscriptions = new Map();
458
- const IN_FLIGHT_TTL_MS = 10 * 60 * 1000;
459
- const inFlightMessages = new Map();
460
- const getOrCreateInFlight = (messageIdValue) => {
461
- let entry = inFlightMessages.get(messageIdValue);
462
- if (!entry) {
463
- entry = { locallyStreamed: false, startedSilent: false, lastActiveAt: Date.now() };
464
- inFlightMessages.set(messageIdValue, entry);
465
- }
466
- else {
467
- entry.lastActiveAt = Date.now();
468
- }
469
- return entry;
470
- };
471
- const touchInFlight = (messageIdValue) => {
472
- const entry = inFlightMessages.get(messageIdValue);
473
- if (entry)
474
- entry.lastActiveAt = Date.now();
475
- };
476
- const inFlightGcTimer = setInterval(() => {
477
- const cutoff = Date.now() - IN_FLIGHT_TTL_MS;
478
- for (const [id, entry] of inFlightMessages) {
479
- if (entry.lastActiveAt < cutoff)
480
- inFlightMessages.delete(id);
481
- }
482
- }, 60 * 1000);
483
- inFlightGcTimer.unref?.();
484
- const publish = (event) => {
485
- const fullEvent = { ...event, eventId: eventId(), ts: event.ts ?? Date.now() };
486
- const events = eventsByChannel.get(fullEvent.channelKey ?? "") ?? [];
487
- events.push(fullEvent);
488
- if (events.length > EVENT_REPLAY_LIMIT)
489
- events.shift();
490
- eventsByChannel.set(fullEvent.channelKey ?? "", events);
491
- const frame = encodeFrame(JSON.stringify(fullEvent));
492
- for (const client of clients) {
493
- if (client.channelKey === fullEvent.channelKey && !client.socket.destroyed) {
494
- if (client.authed) {
495
- client.socket.write(frame);
496
- }
497
- else {
498
- const pendingEvents = client.pendingEvents ?? [];
499
- pendingEvents.push(fullEvent);
500
- client.pendingEvents = pendingEvents;
501
- }
502
- }
503
- }
504
- return fullEvent;
505
- };
506
- const publishDelta = (channelKey, messageIdValue, part, text, ts) => publish({ type: "delta", channelKey, messageId: messageIdValue, part, content: text, text, ts });
507
- const publishStoredAgentEvent = (channelKey, messageIdValue, storedEvent, ts) => {
508
- touchInFlight(messageIdValue);
509
- if (storedEvent.type === "message_start" && storedEvent.role === "assistant") {
510
- const entry = getOrCreateInFlight(messageIdValue);
511
- entry.locallyStreamed = true;
512
- entry.silentFilter = createSilentFilterState();
513
- entry.pendingStartTs = ts;
514
- entry.startedSilent = false;
515
- }
516
- const startedSilentMessage = () => {
517
- const entry = inFlightMessages.get(messageIdValue);
518
- if (!entry || entry.startedSilent)
519
- return false;
520
- const startTs = entry.pendingStartTs;
521
- entry.pendingStartTs = undefined;
522
- entry.startedSilent = true;
523
- publish({
524
- type: "message_started",
525
- channelKey,
526
- messageId: messageIdValue,
527
- role: "assistant",
528
- who: personaName,
529
- ts: startTs,
530
- });
531
- return true;
532
- };
533
- if (storedEvent.type === "message_update") {
534
- const assistantEvent = storedEvent.assistantMessageEvent;
535
- if (assistantEvent.type === "thinking_delta") {
536
- startedSilentMessage();
537
- publishDelta(channelKey, messageIdValue, "thinking", assistantEvent.delta, ts);
538
- }
539
- if (assistantEvent.type === "text_delta") {
540
- const filter = inFlightMessages.get(messageIdValue)?.silentFilter;
541
- if (!filter) {
542
- startedSilentMessage();
543
- publishDelta(channelKey, messageIdValue, "text", assistantEvent.delta, ts);
544
- }
545
- else {
546
- const result = consumeSilentDelta(filter, assistantEvent.delta);
547
- if (result.kind === "emit" && result.text) {
548
- startedSilentMessage();
549
- publishDelta(channelKey, messageIdValue, "text", result.text, ts);
550
- }
551
- }
552
- }
553
- }
554
- if (storedEvent.type === "tool_execution_start") {
555
- startedSilentMessage();
556
- }
557
- if (storedEvent.type === "message_end" && storedEvent.role === "assistant") {
558
- const entry = inFlightMessages.get(messageIdValue);
559
- const filter = entry?.silentFilter;
560
- let silent = false;
561
- if (filter && entry) {
562
- const final = finalizeSilentFilter(filter);
563
- silent = final.silent;
564
- if (!silent) {
565
- startedSilentMessage();
566
- if (final.flush) {
567
- publishDelta(channelKey, messageIdValue, "text", final.flush, ts);
568
- }
569
- }
570
- else {
571
- entry.startedSilent = true;
572
- entry.pendingStartTs = undefined;
573
- }
574
- entry.silentFilter = undefined;
575
- }
576
- else {
577
- startedSilentMessage();
578
- }
579
- publish({
580
- type: "message_completed",
581
- channelKey,
582
- messageId: messageIdValue,
583
- usage: storedEvent.usage,
584
- silent: silent || undefined,
585
- ts,
586
- });
587
- }
588
- const tool = toolFromStoredAgentEvent(storedEvent, ts ?? Date.now());
589
- if (tool)
590
- publish({ type: "tool_event", channelKey, messageId: messageIdValue, tool, ts });
591
- };
592
- const subscribeRuntime = (runtime) => {
593
- if (runtimeSubscriptions.has(runtime.channelKey))
594
- return;
595
- const unsubscribeRecords = runtime.subscribe((record) => {
596
- if (record.type === "inbound") {
597
- publish({
598
- type: "message_started",
599
- channelKey: runtime.channelKey,
600
- messageId: record.messageId,
601
- role: "user",
602
- who: record.authorName || getContactNickname(WEB_USER_NAME),
603
- ts: toUnixMs(record.ts),
604
- });
605
- publishDelta(runtime.channelKey, record.messageId, "text", record.text, toUnixMs(record.ts));
606
- publish({
607
- type: "message_completed",
608
- channelKey: runtime.channelKey,
609
- messageId: record.messageId,
610
- attachments: webAttachments(config, record.attachments),
611
- ts: toUnixMs(record.ts),
612
- });
613
- }
614
- if (record.type === "outbound" && !record.control) {
615
- const outboundId = record.webMessageId || record.messageIds[0] || `out_${record.recordId}`;
616
- const completion = {
617
- type: "message_completed",
618
- channelKey: runtime.channelKey,
619
- messageId: outboundId,
620
- thinkingMs: record.thinkingMs,
621
- attachments: webAttachments(config, record.attachments),
622
- silent: record.silent || undefined,
623
- ts: toUnixMs(record.ts),
624
- };
625
- if (inFlightMessages.get(outboundId)?.locallyStreamed) {
626
- inFlightMessages.delete(outboundId);
627
- publish(completion);
628
- return;
629
- }
630
- if (!record.silent) {
631
- publish({
632
- type: "message_started",
633
- channelKey: runtime.channelKey,
634
- messageId: outboundId,
635
- role: "assistant",
636
- who: personaName,
637
- ts: toUnixMs(record.ts),
638
- });
639
- if (record.thinking)
640
- publishDelta(runtime.channelKey, outboundId, "thinking", record.thinking, toUnixMs(record.ts));
641
- if (record.text)
642
- publishDelta(runtime.channelKey, outboundId, "text", record.text, toUnixMs(record.ts));
643
- }
644
- publish(completion);
645
- }
646
- });
647
- const unsubscribeAgentEvents = runtime.subscribeAgentEvents((agentEvent) => {
648
- publishStoredAgentEvent(runtime.channelKey, agentEvent.messageId, agentEvent.event, agentEvent.ts);
649
- });
650
- runtimeSubscriptions.set(runtime.channelKey, () => {
651
- unsubscribeRecords();
652
- unsubscribeAgentEvents();
653
- });
654
- };
37
+ const eventHub = createWebEventHub(config, personaName);
38
+ const { appendAndPublishError, publish, publishDelta } = eventHub;
655
39
  const getRuntime = async (channelKey) => {
656
- const runtime = await discordDaemon.getRuntimeForWebChannel(channelKey);
657
- subscribeRuntime(runtime);
40
+ if (!agentCore.hasSessionSource())
41
+ throw new HttpError(503, "Owner identity is not established yet.");
42
+ const runtime = await agentCore.getRuntimeForWebChannel(channelKey);
43
+ eventHub.subscribeRuntime(runtime);
658
44
  return runtime;
659
45
  };
660
46
  const subscribeKnownRuntimes = async () => {
661
- const sessions = await discordDaemon.getWebSessions();
47
+ if (!agentCore.hasSessionSource())
48
+ return;
49
+ const sessions = await agentCore.getWebSessions();
662
50
  await Promise.all(sessions.map(async (session) => {
663
- const runtime = await discordDaemon.getRuntimeForWebChannel(session.key);
664
- subscribeRuntime(runtime);
51
+ const runtime = await agentCore.getRuntimeForWebChannel(session.key);
52
+ eventHub.subscribeRuntime(runtime);
665
53
  }));
666
54
  };
667
55
  const getChannelKeyFromRequest = (url, body) => {
668
56
  const queryKey = url.searchParams.get("channelKey");
669
57
  if (queryKey)
670
58
  return queryKey;
671
- if (isObject(body) && typeof body.channelKey === "string")
59
+ if (isRecord(body) && typeof body.channelKey === "string")
672
60
  return body.channelKey;
673
61
  return undefined;
674
62
  };
@@ -711,20 +99,25 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
711
99
  return { ok: false, error: "format must be provider/model-id" };
712
100
  return { ok: true, model: ref.key, ref };
713
101
  };
714
- const replay = (client, channelKey, lastEventId) => {
715
- const events = eventsByChannel.get(channelKey) ?? [];
716
- replayEvents(client, events, lastEventId, () => publish({ type: "replay_window_lost", channelKey }));
717
- };
718
102
  const promptForRuntime = async (runtime, jobId, prompt, attachments = [], onTurnEnd) => {
719
- const assistantMessageId = messageId();
103
+ return promptAssistantMessage({
104
+ runtime,
105
+ jobId,
106
+ assistantMessageId: messageId(),
107
+ dispatch: (onEvent) => agentCore.promptForRuntime(runtime, jobId, prompt, attachments, onEvent, onTurnEnd),
108
+ });
109
+ };
110
+ const promptAssistantMessage = async (options) => {
111
+ const { runtime, jobId, assistantMessageId } = options;
720
112
  const summary = { thinking: "" };
721
113
  const recorder = createAgentEventRecorder((storedEvent) => runtime.noteAgentEvent(jobId, assistantMessageId, storedEvent, { notify: false }));
722
114
  let started = false;
723
115
  let reply;
724
116
  try {
725
- reply = await discordDaemon.runPromptForWeb(runtime, jobId, prompt, attachments, async (event) => {
117
+ reply = await options.dispatch(async (event) => {
726
118
  if (event.type === "message_start" && event.message.role === "assistant" && !started) {
727
119
  started = true;
120
+ options.onAssistantStart?.();
728
121
  }
729
122
  updateAgentEventSummary(summary, event);
730
123
  const storedEvent = storedAgentEventFromAgentEvent(event);
@@ -732,23 +125,26 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
732
125
  runtime.publishAgentEvent(jobId, assistantMessageId, storedEvent);
733
126
  await recorder.record(storedEvent);
734
127
  }
735
- }, onTurnEnd);
128
+ });
736
129
  }
737
130
  finally {
738
131
  await recorder.flush();
739
132
  }
740
133
  const parsed = parseAgentReply(reply.text);
741
134
  const finalText = parsed.silent ? "" : reply.text;
742
- if (!started && !parsed.silent) {
743
- publish({
744
- type: "message_started",
745
- channelKey: runtime.channelKey,
746
- messageId: assistantMessageId,
747
- role: "assistant",
748
- who: personaName,
749
- });
750
- if (finalText) {
751
- publishDelta(runtime.channelKey, assistantMessageId, "text", finalText);
135
+ if (!started) {
136
+ options.onAssistantStart?.();
137
+ if (!parsed.silent) {
138
+ publish({
139
+ type: "message_started",
140
+ channelKey: runtime.channelKey,
141
+ messageId: assistantMessageId,
142
+ role: "assistant",
143
+ who: personaName,
144
+ });
145
+ if (finalText) {
146
+ publishDelta(runtime.channelKey, assistantMessageId, "text", finalText);
147
+ }
752
148
  }
753
149
  }
754
150
  const thinkingMs = thinkingDurationMs(summary);
@@ -760,8 +156,7 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
760
156
  attachments: webAttachments(config, reply.attachments),
761
157
  silent: parsed.silent || undefined,
762
158
  });
763
- const entry = getOrCreateInFlight(assistantMessageId);
764
- entry.locallyStreamed = true;
159
+ eventHub.markLocallyStreamed(assistantMessageId);
765
160
  return {
766
161
  text: finalText,
767
162
  messageId: assistantMessageId,
@@ -771,6 +166,81 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
771
166
  silent: parsed.silent,
772
167
  };
773
168
  };
169
+ const retryLatestAssistant = async (runtime) => {
170
+ if (runtime.hasActiveJob())
171
+ throw new Error("Cannot retry while a turn is running");
172
+ const target = runtime.latestAssistantRetryTarget();
173
+ if (!target)
174
+ throw new Error("No assistant message to retry");
175
+ const jobId = randomUUID();
176
+ const assistantMessageId = messageId();
177
+ const replaceMessage = () => {
178
+ publish({
179
+ type: "message_replaced",
180
+ channelKey: runtime.channelKey,
181
+ oldMessageId: target.messageId,
182
+ newMessageId: assistantMessageId,
183
+ });
184
+ };
185
+ try {
186
+ const reply = await promptAssistantMessage({
187
+ runtime,
188
+ jobId,
189
+ assistantMessageId,
190
+ onAssistantStart: replaceMessage,
191
+ dispatch: (onEvent) => familiarAgent.retryLastAssistant(runtime.channelKey, onEvent, {
192
+ onTurnEnd: () => {
193
+ publish({
194
+ type: "status",
195
+ channelKey: runtime.channelKey,
196
+ kind: "idle",
197
+ });
198
+ },
199
+ }),
200
+ });
201
+ await runtime.noteAssistantRetry({
202
+ oldMessageId: target.messageId,
203
+ newMessageId: assistantMessageId,
204
+ jobId,
205
+ triggerRecordId: target.triggerRecordId,
206
+ });
207
+ await runtime.noteOutbound({
208
+ text: reply.text,
209
+ messageIds: [reply.messageId],
210
+ webMessageId: reply.messageId,
211
+ attachments: reply.attachments,
212
+ thinking: reply.thinking,
213
+ thinkingMs: reply.thinkingMs,
214
+ silent: reply.silent,
215
+ replyToMessageId: target.messageId,
216
+ jobId,
217
+ });
218
+ }
219
+ catch (error) {
220
+ const message = errorMessage(error);
221
+ await appendAndPublishError(runtime, message);
222
+ }
223
+ };
224
+ const deleteLatestAssistant = async (runtime) => {
225
+ if (runtime.hasActiveJob())
226
+ throw new Error("Cannot delete while a turn is running");
227
+ const target = runtime.latestAssistantDeleteTarget();
228
+ if (!target)
229
+ throw new Error("No assistant message to delete");
230
+ try {
231
+ await familiarAgent.deleteLastAssistant(runtime.channelKey);
232
+ await runtime.noteMessageDelete(target.messageId);
233
+ publish({
234
+ type: "message_deleted",
235
+ channelKey: runtime.channelKey,
236
+ messageId: target.messageId,
237
+ });
238
+ }
239
+ catch (error) {
240
+ const message = errorMessage(error);
241
+ await appendAndPublishError(runtime, message);
242
+ }
243
+ };
774
244
  const drainJobs = async (runtime) => {
775
245
  for (;;) {
776
246
  const dispatch = runtime.beginNextJob();
@@ -798,17 +268,16 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
798
268
  catch (error) {
799
269
  if (!runtime.hasActiveJob(dispatch.job.jobId))
800
270
  return;
801
- const message = error instanceof Error ? error.message : String(error);
271
+ const message = errorMessage(error);
802
272
  await runtime.failActiveJob(message);
803
- await runtime.appendError(message);
804
- publish({ type: "error", channelKey: runtime.channelKey, code: "unknown", message });
273
+ await appendAndPublishError(runtime, message);
805
274
  }
806
275
  }
807
276
  };
808
277
  const applyControlCommand = async (runtime, control) => {
809
278
  if (control.command === "stop") {
810
- familiarAgent.requestSoftStop(runtime.channelKey);
811
- return "Stopped after current step. Conversation preserved.";
279
+ await familiarAgent.abort(runtime.channelKey);
280
+ return "Stopped current work.";
812
281
  }
813
282
  if (control.command === "new") {
814
283
  await familiarAgent.reset(runtime.channelKey);
@@ -844,6 +313,270 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
844
313
  }
845
314
  return "Compact is not wired for this runtime yet. I logged the command, but I won't run lossy compaction here.";
846
315
  };
316
+ const webRoutes = new Map();
317
+ const route = (method, pathname, handler) => {
318
+ webRoutes.set(`${method} ${pathname}`, handler);
319
+ };
320
+ route("GET", "/api/web/auth/mode", async (_request, response) => {
321
+ sendJson(response, 200, { mode: config.web.authMode, personaName });
322
+ return true;
323
+ });
324
+ route("GET", "/api/web/sessions", async (_request, response) => {
325
+ if (!agentCore.hasSessionSource()) {
326
+ sendJson(response, 200, { sessions: [] });
327
+ return true;
328
+ }
329
+ const sessions = await agentCore.getWebSessions();
330
+ sendJson(response, 200, { sessions: sessions.map(sessionDto) });
331
+ return true;
332
+ });
333
+ route("GET", "/api/web/history", async (_request, response, url) => {
334
+ const runtime = await getRuntime(getChannelKeyFromRequest(url));
335
+ const limit = Math.min(Math.max(Number(url.searchParams.get("limit") ?? 50) || 50, 1), 200);
336
+ const before = url.searchParams.get("before") ?? undefined;
337
+ sendJson(response, 200, webHistoryPayload(config, runtime.getRecords(), personaName, runtime.channelKey, { limit, before }));
338
+ return true;
339
+ });
340
+ route("GET", "/api/web/agent/settings", async (_request, response, url) => {
341
+ const runtime = await getRuntime(getChannelKeyFromRequest(url));
342
+ sendJson(response, 200, agentSettingsPayload(familiarAgent, runtime.channelKey, personaName));
343
+ return true;
344
+ });
345
+ route("GET", "/api/web/agent/models", async (_request, response) => {
346
+ sendJson(response, 200, getAgentModelsPayload());
347
+ return true;
348
+ });
349
+ route("POST", "/api/web/agent/models", async (request, response) => {
350
+ const body = await readJsonBody(request);
351
+ if (!isRecord(body)) {
352
+ sendJson(response, 400, { error: "body is required" });
353
+ return true;
354
+ }
355
+ const parsed = parseRequestedModel(body.model);
356
+ if (!parsed.ok) {
357
+ sendJson(response, 400, { error: parsed.error });
358
+ return true;
359
+ }
360
+ if (!Object.hasOwn(PROVIDER_DEFAULTS, parsed.ref.provider) &&
361
+ !getProviders().includes(parsed.ref.provider)) {
362
+ sendJson(response, 400, { error: `unsupported provider: ${parsed.ref.provider}` });
363
+ return true;
364
+ }
365
+ if (config.models.allow.includes(parsed.model) || loadAddedModels().includes(parsed.model)) {
366
+ sendJson(response, 200, getAgentModelsPayload());
367
+ return true;
368
+ }
369
+ await addModel(parsed.model);
370
+ sendJson(response, 200, getAgentModelsPayload());
371
+ return true;
372
+ });
373
+ route("DELETE", "/api/web/agent/models", async (request, response) => {
374
+ const body = await readJsonBody(request);
375
+ if (!isRecord(body)) {
376
+ sendJson(response, 400, { error: "body is required" });
377
+ return true;
378
+ }
379
+ const parsed = parseRequestedModel(body.model);
380
+ if (!parsed.ok) {
381
+ sendJson(response, 400, { error: parsed.error });
382
+ return true;
383
+ }
384
+ if (!loadAddedModels().includes(parsed.model)) {
385
+ sendJson(response, 400, { error: "model is not user-added" });
386
+ return true;
387
+ }
388
+ await removeModel(parsed.model);
389
+ sendJson(response, 200, getAgentModelsPayload());
390
+ return true;
391
+ });
392
+ route("GET", "/api/web/config", async (_request, response) => {
393
+ sendJson(response, 200, getConfigPayload());
394
+ return true;
395
+ });
396
+ route("POST", "/api/web/config", async (request, response) => {
397
+ const body = await readJsonBody(request);
398
+ if (!isRecord(body) || typeof body.key !== "string") {
399
+ sendJson(response, 400, { error: "key is required" });
400
+ return true;
401
+ }
402
+ if (!isConfigKey(body.key)) {
403
+ sendJson(response, 400, { error: `unknown config key: ${body.key}` });
404
+ return true;
405
+ }
406
+ const key = body.key;
407
+ const entry = CONFIG_REGISTRY[key];
408
+ try {
409
+ const validated = entry.validate(body.value, config);
410
+ await commitConfigChange(key, validated, { config, scheduler: agentCore });
411
+ }
412
+ catch (error) {
413
+ const message = errorMessage(error);
414
+ sendJson(response, 400, { error: message });
415
+ return true;
416
+ }
417
+ sendJson(response, 200, getConfigPayload());
418
+ return true;
419
+ });
420
+ route("DELETE", "/api/web/config", async (request, response) => {
421
+ const body = await readJsonBody(request);
422
+ if (!isRecord(body) || typeof body.key !== "string") {
423
+ sendJson(response, 400, { error: "key is required" });
424
+ return true;
425
+ }
426
+ if (!isConfigKey(body.key)) {
427
+ sendJson(response, 400, { error: `unknown config key: ${body.key}` });
428
+ return true;
429
+ }
430
+ const key = body.key;
431
+ try {
432
+ await clearConfigChange(key, { config, scheduler: agentCore });
433
+ }
434
+ catch (error) {
435
+ const message = errorMessage(error);
436
+ sendJson(response, 400, { error: message });
437
+ return true;
438
+ }
439
+ sendJson(response, 200, getConfigPayload());
440
+ return true;
441
+ });
442
+ route("GET", "/api/web/memes", async (_request, response) => {
443
+ try {
444
+ const markdown = await readFile(memeCatalogPath(config), "utf8");
445
+ sendJson(response, 200, { families: parseMemeCatalog(markdown) });
446
+ }
447
+ catch {
448
+ sendJson(response, 500, { error: "memes catalog unavailable" });
449
+ }
450
+ return true;
451
+ });
452
+ route("POST", "/api/web/send", async (request, response, url) => {
453
+ const contentType = request.headers["content-type"] ?? "";
454
+ const isMultipart = Array.isArray(contentType)
455
+ ? contentType.some((value) => value.includes("multipart/form-data"))
456
+ : contentType.includes("multipart/form-data");
457
+ const body = isMultipart ? await readMultipartBody(request, contentType) : await readJsonBody(request);
458
+ const runtime = await getRuntime(getChannelKeyFromRequest(url, body));
459
+ if (!isRecord(body) || typeof body.text !== "string") {
460
+ sendJson(response, 400, { error: "text is required" });
461
+ return true;
462
+ }
463
+ if (!isMultipart && isRecord(body) && Array.isArray(body.attachments) && body.attachments.length > 0) {
464
+ sendJson(response, 400, { error: "attachments require multipart form data" });
465
+ return true;
466
+ }
467
+ const rawAttachments = Array.isArray(body.attachments) ? body.attachments : [];
468
+ const attachments = await materializeInboundAttachments(config, rawAttachments
469
+ .filter((attachment) => isWebUploadAttachment(attachment))
470
+ .map((attachment) => ({ ...attachment, source: "web" })));
471
+ if (!body.text.trim() && attachments.length === 0) {
472
+ sendJson(response, 400, { error: "text or attachment is required" });
473
+ return true;
474
+ }
475
+ const id = messageId("user");
476
+ const ts = Date.now();
477
+ const input = {
478
+ messageId: id,
479
+ authorId: config.discord.ownerId,
480
+ authorName: getContactNickname(WEB_USER_NAME),
481
+ text: body.text,
482
+ isBot: false,
483
+ mentionedBot: true,
484
+ remoteTimestamp: new Date(ts).toISOString(),
485
+ checkpoint: { messageId: id },
486
+ attachments,
487
+ };
488
+ await runtime.ingestInbound(input, { mode: "queue" });
489
+ void drainJobs(runtime).catch((error) => console.error("Web job drain failed", error));
490
+ sendJson(response, 200, { id, ts, channelKey: runtime.channelKey });
491
+ return true;
492
+ });
493
+ route("POST", "/api/web/retry", async (request, response, url) => {
494
+ const body = await readJsonBody(request);
495
+ const runtime = await getRuntime(getChannelKeyFromRequest(url, body));
496
+ void retryLatestAssistant(runtime).catch((error) => console.error("Web retry failed", error));
497
+ sendJson(response, 200, { ok: true, channelKey: runtime.channelKey });
498
+ return true;
499
+ });
500
+ route("POST", "/api/web/delete", async (request, response, url) => {
501
+ const body = await readJsonBody(request);
502
+ const runtime = await getRuntime(getChannelKeyFromRequest(url, body));
503
+ void deleteLatestAssistant(runtime).catch((error) => console.error("Web delete failed", error));
504
+ sendJson(response, 200, { ok: true, channelKey: runtime.channelKey });
505
+ return true;
506
+ });
507
+ route("POST", "/api/web/agent/settings", async (request, response, url) => {
508
+ const body = await readJsonBody(request);
509
+ const runtime = await getRuntime(getChannelKeyFromRequest(url, body));
510
+ if (!isRecord(body)) {
511
+ sendJson(response, 400, { error: "body is required" });
512
+ return true;
513
+ }
514
+ try {
515
+ if (typeof body.model === "string")
516
+ await familiarAgent.setModel(runtime.channelKey, body.model);
517
+ if (typeof body.thinking === "string")
518
+ await familiarAgent.setThinkingLevel(runtime.channelKey, body.thinking);
519
+ }
520
+ catch (error) {
521
+ const message = errorMessage(error);
522
+ sendJson(response, 400, { error: message });
523
+ return true;
524
+ }
525
+ sendJson(response, 200, agentSettingsPayload(familiarAgent, runtime.channelKey, personaName));
526
+ return true;
527
+ });
528
+ route("POST", "/api/web/agent/new", async (request, response, url) => {
529
+ const body = await readJsonBody(request);
530
+ const runtime = await getRuntime(getChannelKeyFromRequest(url, body));
531
+ await familiarAgent.reset(runtime.channelKey);
532
+ await runtime.resetConversation("new conversation requested from web");
533
+ publish({
534
+ type: "status",
535
+ channelKey: runtime.channelKey,
536
+ kind: "idle",
537
+ detail: "started fresh from web",
538
+ });
539
+ sendJson(response, 200, { ok: true });
540
+ return true;
541
+ });
542
+ route("POST", "/api/web/control", async (request, response, url) => {
543
+ const body = await readJsonBody(request);
544
+ const runtime = await getRuntime(getChannelKeyFromRequest(url, body));
545
+ if (!isRecord(body) || typeof body.command !== "string") {
546
+ sendJson(response, 400, { error: "command is required" });
547
+ return true;
548
+ }
549
+ if (config.web.authMode === "public-2fa" && body.command === "login") {
550
+ const token = isRecord(body.args) && typeof body.args.token === "string" ? body.args.token : "";
551
+ if (!config.web.totpSecret || !verifyTotp(config.web.totpSecret, token)) {
552
+ sendJson(response, 401, { ok: false, message: "Invalid TOTP token." });
553
+ return true;
554
+ }
555
+ const sessionId = auth.createSession();
556
+ sendJson(response, 200, { ok: true, message: "Authenticated." }, { "set-cookie": sessionCookie(sessionId) });
557
+ return true;
558
+ }
559
+ const args = commandArgs(body.command, body.args);
560
+ const input = {
561
+ messageId: messageId("control"),
562
+ authorId: config.discord.ownerId,
563
+ authorName: getContactNickname(WEB_USER_NAME),
564
+ text: `/${body.command}${args ? ` ${args}` : ""}`,
565
+ isBot: false,
566
+ mentionedBot: true,
567
+ remoteTimestamp: new Date().toISOString(),
568
+ };
569
+ const control = runtime.parseControlCommand(input);
570
+ if (!control) {
571
+ sendJson(response, 400, { ok: false, message: "Unsupported command." });
572
+ return true;
573
+ }
574
+ await runtime.noteControlCommand(input, control);
575
+ const message = await applyControlCommand(runtime, control);
576
+ await runtime.noteOutbound({ text: message, messageIds: [], control: control.command });
577
+ sendJson(response, 200, { ok: true, message, channelKey: runtime.channelKey });
578
+ return true;
579
+ });
847
580
  const handleApi = async (request, response, url) => {
848
581
  if (!url.pathname.startsWith("/api/web/"))
849
582
  return false;
@@ -855,258 +588,17 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
855
588
  if (request.method === "GET" && url.pathname.startsWith("/api/web/attachments/")) {
856
589
  return serveAttachment(config, response, url.pathname, request.headers.range);
857
590
  }
858
- if (request.method === "GET" && url.pathname === "/api/web/auth/mode") {
859
- sendJson(response, 200, { mode: config.web.authMode, personaName });
860
- return true;
861
- }
862
- if (request.method === "GET" && url.pathname === "/api/web/sessions") {
863
- const sessions = await discordDaemon.getWebSessions();
864
- sendJson(response, 200, { sessions: sessions.map(sessionDto) });
865
- return true;
866
- }
867
- if (request.method === "GET" && url.pathname === "/api/web/history") {
868
- const runtime = await getRuntime(getChannelKeyFromRequest(url));
869
- const limit = Math.min(Math.max(Number(url.searchParams.get("limit") ?? 50) || 50, 1), 200);
870
- const before = url.searchParams.get("before") ?? undefined;
871
- sendJson(response, 200, webHistoryPayload(config, runtime.getRecords(), personaName, runtime.channelKey, { limit, before }));
872
- return true;
873
- }
874
- if (request.method === "GET" && url.pathname === "/api/web/agent/settings") {
875
- const runtime = await getRuntime(getChannelKeyFromRequest(url));
876
- sendJson(response, 200, agentSettingsPayload(familiarAgent, runtime.channelKey, personaName));
877
- return true;
878
- }
879
- if (request.method === "GET" && url.pathname === "/api/web/agent/models") {
880
- sendJson(response, 200, getAgentModelsPayload());
881
- return true;
882
- }
883
- if (request.method === "POST" && url.pathname === "/api/web/agent/models") {
884
- const body = await readJsonBody(request);
885
- if (!isObject(body)) {
886
- sendJson(response, 400, { error: "body is required" });
887
- return true;
888
- }
889
- const parsed = parseRequestedModel(body.model);
890
- if (!parsed.ok) {
891
- sendJson(response, 400, { error: parsed.error });
892
- return true;
893
- }
894
- if (!Object.hasOwn(PROVIDER_DEFAULTS, parsed.ref.provider) &&
895
- !getProviders().includes(parsed.ref.provider)) {
896
- sendJson(response, 400, { error: `unsupported provider: ${parsed.ref.provider}` });
897
- return true;
898
- }
899
- if (config.models.allow.includes(parsed.model) || loadAddedModels().includes(parsed.model)) {
900
- sendJson(response, 200, getAgentModelsPayload());
901
- return true;
902
- }
903
- await addModel(parsed.model);
904
- sendJson(response, 200, getAgentModelsPayload());
905
- return true;
906
- }
907
- if (request.method === "DELETE" && url.pathname === "/api/web/agent/models") {
908
- const body = await readJsonBody(request);
909
- if (!isObject(body)) {
910
- sendJson(response, 400, { error: "body is required" });
911
- return true;
912
- }
913
- const parsed = parseRequestedModel(body.model);
914
- if (!parsed.ok) {
915
- sendJson(response, 400, { error: parsed.error });
916
- return true;
917
- }
918
- if (!loadAddedModels().includes(parsed.model)) {
919
- sendJson(response, 400, { error: "model is not user-added" });
920
- return true;
921
- }
922
- await removeModel(parsed.model);
923
- sendJson(response, 200, getAgentModelsPayload());
924
- return true;
925
- }
926
- if (request.method === "GET" && url.pathname === "/api/web/config") {
927
- sendJson(response, 200, getConfigPayload());
928
- return true;
929
- }
930
- if (request.method === "POST" && url.pathname === "/api/web/config") {
931
- const body = await readJsonBody(request);
932
- if (!isObject(body) || typeof body.key !== "string") {
933
- sendJson(response, 400, { error: "key is required" });
934
- return true;
935
- }
936
- if (!isConfigKey(body.key)) {
937
- sendJson(response, 400, { error: `unknown config key: ${body.key}` });
938
- return true;
939
- }
940
- const entry = CONFIG_REGISTRY[body.key];
941
- try {
942
- const validated = entry.validate(body.value, config);
943
- entry.write(config, validated);
944
- await setConfigOverride(body.key, validated);
945
- await entry.apply?.({ config, discordDaemon });
946
- }
947
- catch (error) {
948
- const message = error instanceof Error ? error.message : String(error);
949
- sendJson(response, 400, { error: message });
950
- return true;
951
- }
952
- sendJson(response, 200, getConfigPayload());
953
- return true;
954
- }
955
- if (request.method === "DELETE" && url.pathname === "/api/web/config") {
956
- const body = await readJsonBody(request);
957
- if (!isObject(body) || typeof body.key !== "string") {
958
- sendJson(response, 400, { error: "key is required" });
959
- return true;
960
- }
961
- if (!isConfigKey(body.key)) {
962
- sendJson(response, 400, { error: `unknown config key: ${body.key}` });
963
- return true;
964
- }
965
- const entry = CONFIG_REGISTRY[body.key];
966
- try {
967
- const fallback = getConfigDefault(body.key);
968
- entry.write(config, fallback);
969
- await clearConfigOverride(body.key);
970
- await entry.apply?.({ config, discordDaemon });
971
- }
972
- catch (error) {
973
- const message = error instanceof Error ? error.message : String(error);
974
- sendJson(response, 400, { error: message });
975
- return true;
976
- }
977
- sendJson(response, 200, getConfigPayload());
978
- return true;
979
- }
980
- if (request.method === "GET" && url.pathname === "/api/web/memes") {
981
- try {
982
- const markdown = await readFile(memeCatalogPath(config), "utf8");
983
- sendJson(response, 200, { families: parseMemeCatalog(markdown) });
984
- }
985
- catch {
986
- sendJson(response, 500, { error: "memes catalog unavailable" });
987
- }
988
- return true;
989
- }
990
- if (request.method === "POST" && url.pathname === "/api/web/send") {
991
- const contentType = request.headers["content-type"] ?? "";
992
- const isMultipart = Array.isArray(contentType)
993
- ? contentType.some((value) => value.includes("multipart/form-data"))
994
- : contentType.includes("multipart/form-data");
995
- const body = isMultipart ? await readMultipartBody(request, contentType) : await readJsonBody(request);
996
- const runtime = await getRuntime(getChannelKeyFromRequest(url, body));
997
- if (!isObject(body) || typeof body.text !== "string") {
998
- sendJson(response, 400, { error: "text is required" });
999
- return true;
1000
- }
1001
- if (!isMultipart && isObject(body) && Array.isArray(body.attachments) && body.attachments.length > 0) {
1002
- sendJson(response, 400, { error: "attachments require multipart form data" });
1003
- return true;
1004
- }
1005
- const rawAttachments = Array.isArray(body.attachments) ? body.attachments : [];
1006
- const attachments = await materializeInboundAttachments(config, rawAttachments
1007
- .filter((attachment) => isWebUploadAttachment(attachment))
1008
- .map((attachment) => ({ ...attachment, source: "web" })));
1009
- if (!body.text.trim() && attachments.length === 0) {
1010
- sendJson(response, 400, { error: "text or attachment is required" });
1011
- return true;
1012
- }
1013
- const id = messageId("user");
1014
- const ts = Date.now();
1015
- const input = {
1016
- messageId: id,
1017
- authorId: config.discord.ownerId,
1018
- authorName: getContactNickname(WEB_USER_NAME),
1019
- text: body.text,
1020
- isBot: false,
1021
- mentionedBot: true,
1022
- remoteTimestamp: new Date(ts).toISOString(),
1023
- checkpoint: { messageId: id },
1024
- attachments,
1025
- };
1026
- await runtime.ingestInbound(input, { mode: "queue" });
1027
- void drainJobs(runtime).catch((error) => console.error("Web job drain failed", error));
1028
- sendJson(response, 200, { id, ts, channelKey: runtime.channelKey });
1029
- return true;
1030
- }
1031
- if (request.method === "POST" && url.pathname === "/api/web/agent/settings") {
1032
- const body = await readJsonBody(request);
1033
- const runtime = await getRuntime(getChannelKeyFromRequest(url, body));
1034
- if (!isObject(body)) {
1035
- sendJson(response, 400, { error: "body is required" });
1036
- return true;
1037
- }
1038
- try {
1039
- if (typeof body.model === "string")
1040
- await familiarAgent.setModel(runtime.channelKey, body.model);
1041
- if (typeof body.thinking === "string")
1042
- await familiarAgent.setThinkingLevel(runtime.channelKey, body.thinking);
1043
- }
1044
- catch (error) {
1045
- const message = error instanceof Error ? error.message : String(error);
1046
- sendJson(response, 400, { error: message });
1047
- return true;
1048
- }
1049
- sendJson(response, 200, agentSettingsPayload(familiarAgent, runtime.channelKey, personaName));
1050
- return true;
1051
- }
1052
- if (request.method === "POST" && url.pathname === "/api/web/agent/new") {
1053
- const body = await readJsonBody(request);
1054
- const runtime = await getRuntime(getChannelKeyFromRequest(url, body));
1055
- await familiarAgent.reset(runtime.channelKey);
1056
- await runtime.resetConversation("new conversation requested from web");
1057
- publish({
1058
- type: "status",
1059
- channelKey: runtime.channelKey,
1060
- kind: "idle",
1061
- detail: "started fresh from web",
1062
- });
1063
- sendJson(response, 200, { ok: true });
1064
- return true;
1065
- }
1066
- if (request.method === "POST" && url.pathname === "/api/web/control") {
1067
- const body = await readJsonBody(request);
1068
- const runtime = await getRuntime(getChannelKeyFromRequest(url, body));
1069
- if (!isObject(body) || typeof body.command !== "string") {
1070
- sendJson(response, 400, { error: "command is required" });
1071
- return true;
1072
- }
1073
- if (config.web.authMode === "public-2fa" && body.command === "login") {
1074
- const token = isObject(body.args) && typeof body.args.token === "string" ? body.args.token : "";
1075
- if (!config.web.totpSecret || !verifyTotp(config.web.totpSecret, token)) {
1076
- sendJson(response, 401, { ok: false, message: "Invalid TOTP token." });
1077
- return true;
1078
- }
1079
- const sessionId = auth.createSession();
1080
- sendJson(response, 200, { ok: true, message: "Authenticated." }, { "set-cookie": sessionCookie(sessionId) });
1081
- return true;
1082
- }
1083
- const args = commandArgs(body.command, body.args);
1084
- const input = {
1085
- messageId: messageId("control"),
1086
- authorId: config.discord.ownerId,
1087
- authorName: getContactNickname(WEB_USER_NAME),
1088
- text: `/${body.command}${args ? ` ${args}` : ""}`,
1089
- isBot: false,
1090
- mentionedBot: true,
1091
- remoteTimestamp: new Date().toISOString(),
1092
- };
1093
- const control = runtime.parseControlCommand(input);
1094
- if (!control) {
1095
- sendJson(response, 400, { ok: false, message: "Unsupported command." });
1096
- return true;
1097
- }
1098
- await runtime.noteControlCommand(input, control);
1099
- const message = await applyControlCommand(runtime, control);
1100
- await runtime.noteOutbound({ text: message, messageIds: [], control: control.command });
1101
- sendJson(response, 200, { ok: true, message, channelKey: runtime.channelKey });
1102
- return true;
1103
- }
591
+ const handler = webRoutes.get(`${request.method} ${url.pathname}`);
592
+ // await is load-bearing: it keeps handler rejections inside this try so the catch maps HttpError to a status.
593
+ if (handler)
594
+ return await handler(request, response, url);
1104
595
  sendJson(response, 404, { error: "not found" });
1105
596
  return true;
1106
597
  }
1107
598
  catch (error) {
1108
- const message = error instanceof Error ? error.message : String(error);
1109
- sendJson(response, 500, { error: message });
599
+ const status = error instanceof HttpError ? error.status : 500;
600
+ const message = errorMessage(error);
601
+ sendJson(response, status, { error: message });
1110
602
  return true;
1111
603
  }
1112
604
  };
@@ -1121,61 +613,13 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
1121
613
  sendText(response, 404, "Not found");
1122
614
  });
1123
615
  });
1124
- server.on("upgrade", (request, socket) => {
1125
- const netSocket = socket;
1126
- const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
1127
- if (url.pathname !== "/api/web/stream" || !auth.authorize(request, url.pathname)) {
1128
- netSocket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
1129
- netSocket.destroy();
1130
- return;
1131
- }
1132
- const requestedChannelKey = url.searchParams.get("channelKey") || undefined;
1133
- void getRuntime(requestedChannelKey)
1134
- .then((runtime) => {
1135
- if (netSocket.destroyed)
1136
- return;
1137
- if (!acceptWebSocket(request, netSocket))
1138
- return;
1139
- netSocket.setNoDelay(true);
1140
- const client = { socket: netSocket, channelKey: runtime.channelKey, authed: false };
1141
- clients.add(client);
1142
- let frameBuffer = Buffer.alloc(0);
1143
- netSocket.on("data", (chunk) => {
1144
- try {
1145
- frameBuffer = Buffer.concat([frameBuffer, chunk]);
1146
- const decoded = decodeFrames(frameBuffer);
1147
- frameBuffer = decoded.remaining;
1148
- if (decoded.close)
1149
- netSocket.destroy();
1150
- for (const raw of decoded.messages) {
1151
- const message = JSON.parse(raw);
1152
- if (isObject(message) && message.type === "hello") {
1153
- if (!client.channelKey)
1154
- continue;
1155
- replay(client, client.channelKey, typeof message.lastEventId === "string" ? message.lastEventId : null);
1156
- }
1157
- if (isObject(message) && message.type === "abort") {
1158
- void getRuntime(client.channelKey).then(async (runtime) => {
1159
- familiarAgent.requestSoftStop(runtime.channelKey);
1160
- });
1161
- }
1162
- }
1163
- }
1164
- catch (error) {
1165
- console.error("WebSocket frame handling failed", error);
1166
- netSocket.destroy();
1167
- }
1168
- });
1169
- netSocket.on("close", () => clients.delete(client));
1170
- netSocket.on("error", () => clients.delete(client));
1171
- })
1172
- .catch((error) => {
1173
- console.error("WebSocket runtime lookup failed", error);
1174
- if (!netSocket.destroyed) {
1175
- netSocket.write("HTTP/1.1 503 Service Unavailable\r\n\r\n");
1176
- netSocket.destroy();
1177
- }
1178
- });
616
+ attachWebSocketStream(server, {
617
+ authorize: (request, pathname) => auth.authorize(request, pathname),
618
+ eventHub,
619
+ getRuntime,
620
+ abort: (runtime) => familiarAgent.abort(runtime.channelKey),
621
+ retry: retryLatestAssistant,
622
+ deleteLatest: deleteLatestAssistant,
1179
623
  });
1180
624
  await new Promise((resolveListen, rejectListen) => {
1181
625
  server.once("error", rejectListen);
@@ -1188,22 +632,10 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
1188
632
  return {
1189
633
  server,
1190
634
  async stop() {
1191
- clearInterval(inFlightGcTimer);
1192
- for (const client of clients)
1193
- client.socket.destroy();
1194
- clients.clear();
1195
- for (const unsubscribe of runtimeSubscriptions.values())
1196
- unsubscribe();
1197
- runtimeSubscriptions.clear();
635
+ eventHub.stop();
1198
636
  await new Promise((resolveClose, rejectClose) => {
1199
637
  server.close((error) => (error ? rejectClose(error) : resolveClose()));
1200
638
  });
1201
639
  },
1202
640
  };
1203
641
  }
1204
- export const __webTest = {
1205
- memeCatalogPath,
1206
- parseMemeCatalog,
1207
- webHistoryPayload,
1208
- webMessagesFromRecords,
1209
- };