@qearlyao/familiar 0.3.0 → 0.4.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 (83) hide show
  1. package/HEARTBEAT.md +1 -1
  2. package/README.md +29 -0
  3. package/config.example.toml +2 -0
  4. package/dist/{agent.js → agent/factory.js} +11 -11
  5. package/dist/agent/session-helpers.js +1 -1
  6. package/dist/agent/tools.js +4 -4
  7. package/dist/cli.js +11 -11
  8. package/dist/{config.js → config/index.js} +7 -7
  9. package/dist/config/model-refs.js +1 -1
  10. package/dist/{config-overrides.js → config/overrides.js} +1 -1
  11. package/dist/{config-registry.js → config/registry.js} +2 -2
  12. package/dist/{settings.js → config/settings.js} +2 -2
  13. package/dist/{chat-log.js → conversation/chat-log.js} +1 -1
  14. package/dist/{contact-note.js → conversation/contact-note.js} +1 -1
  15. package/dist/{owner-identity.js → conversation/owner-identity.js} +2 -2
  16. package/dist/discord/channel.js +1 -1
  17. package/dist/discord/commands.js +1 -1
  18. package/dist/{discord.js → discord/daemon.js} +17 -17
  19. package/dist/discord/inbound.js +1 -1
  20. package/dist/discord/send.js +29 -20
  21. package/dist/discord/turn.js +3 -3
  22. package/dist/index.js +12 -12
  23. package/dist/{data-retention.js → lifecycle/data-retention.js} +1 -1
  24. package/dist/{hot-reload.js → lifecycle/hot-reload.js} +2 -2
  25. package/dist/media/attachment-limits.js +3 -0
  26. package/dist/{generated-media.js → media/generated-media.js} +1 -1
  27. package/dist/{image-gen.js → media/image-gen.js} +2 -2
  28. package/dist/{inbound-attachments.js → media/inbound-attachments.js} +47 -43
  29. package/dist/media/media-understanding.js +215 -0
  30. package/dist/memory/lcm/summarizer.js +1 -1
  31. package/dist/{added-models.js → models/added-models.js} +1 -1
  32. package/dist/{persona.js → prompting/persona.js} +1 -1
  33. package/dist/{agent-core.js → runtime/agent-core.js} +1 -1
  34. package/dist/{agent-events.js → runtime/agent-events.js} +1 -1
  35. package/dist/{agent-work-queue.js → runtime/agent-work-queue.js} +2 -2
  36. package/dist/{runtime.js → runtime/conversation-runtime.js} +3 -3
  37. package/dist/{runtime-manager.js → runtime/runtime-manager.js} +2 -2
  38. package/dist/{scheduler-runner.js → runtime/scheduler-runner.js} +1 -1
  39. package/dist/{scheduler.js → runtime/scheduler.js} +3 -3
  40. package/dist/{browser-tools.js → tools/browser-tools.js} +17 -26
  41. package/dist/util/fs.js +2 -1
  42. package/dist/web/agent-routes.js +104 -0
  43. package/dist/web/auth-routes.js +39 -0
  44. package/dist/web/auth.js +124 -30
  45. package/dist/web/config-routes.js +55 -0
  46. package/dist/web/conversation-routes.js +122 -0
  47. package/dist/web/daemon.js +108 -0
  48. package/dist/web/diary-routes.js +88 -0
  49. package/dist/web/errors.js +3 -0
  50. package/dist/web/event-hub.js +3 -3
  51. package/dist/web/messages.js +13 -10
  52. package/dist/web/multipart.js +7 -1
  53. package/dist/web/payloads.js +1 -1
  54. package/dist/web/request-context.js +25 -0
  55. package/dist/web/route-helpers.js +9 -0
  56. package/dist/web/routes.js +37 -0
  57. package/dist/web/runtime-actions.js +231 -0
  58. package/dist/web/session-store.js +161 -0
  59. package/dist/web/static.js +1 -1
  60. package/dist/web/stream.js +12 -3
  61. package/dist/{web-tools.js → web-tools/index.js} +8 -8
  62. package/npm-shrinkwrap.json +79 -2
  63. package/package.json +3 -1
  64. package/web/dist/assets/index-C-k4O5Dz.js +6 -0
  65. package/web/dist/assets/index-Dj-L9nX4.css +2 -0
  66. package/web/dist/assets/markdown-kaIeGxdv.js +14 -0
  67. package/web/dist/assets/react-Bi_azaFt.js +9 -0
  68. package/web/dist/assets/rolldown-runtime-S-ySWqyJ.js +1 -0
  69. package/web/dist/assets/ui-C12-nN_X.js +51 -0
  70. package/web/dist/assets/vendor-D1QXMhXm.js +16 -0
  71. package/web/dist/index.html +7 -2
  72. package/dist/media-understanding.js +0 -120
  73. package/dist/web.js +0 -641
  74. package/web/dist/assets/index-CSkxUQCr.js +0 -63
  75. package/web/dist/assets/index-DllM6RqL.css +0 -2
  76. /package/dist/{ids.js → conversation/ids.js} +0 -0
  77. /package/dist/{control.js → lifecycle/control.js} +0 -0
  78. /package/dist/{service.js → lifecycle/service.js} +0 -0
  79. /package/dist/{image-derivatives.js → media/image-derivatives.js} +0 -0
  80. /package/dist/{tts.js → media/tts.js} +0 -0
  81. /package/dist/{models.js → models/index.js} +0 -0
  82. /package/dist/{skills.js → prompting/skills.js} +0 -0
  83. /package/dist/{silent-marker.js → runtime/silent-marker.js} +0 -0
package/dist/web.js DELETED
@@ -1,641 +0,0 @@
1
- import { randomUUID } from "node:crypto";
2
- import { readFile } from "node:fs/promises";
3
- import { createServer } from "node:http";
4
- import { getProviders } from "@earendil-works/pi-ai";
5
- import { addModel, loadAddedModels, removeModel, setAddedModelsPath } from "./added-models.js";
6
- import { createAgentEventRecorder, storedAgentEventFromAgentEvent, thinkingDurationMs, updateAgentEventSummary, } from "./agent-events.js";
7
- import { loadConfigOverrides } from "./config-overrides.js";
8
- import { CONFIG_KEYS, CONFIG_REGISTRY, clearConfigChange, commitConfigChange, isConfigKey, } from "./config-registry.js";
9
- import { getContactNickname, refreshContactNote, setContactNotePath } from "./contact-note.js";
10
- import { messageId } from "./ids.js";
11
- import { materializeInboundAttachments } from "./inbound-attachments.js";
12
- import { PROVIDER_DEFAULTS, parseModelRef } from "./models.js";
13
- import { loadPersona, parsePersonaName } from "./persona.js";
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);
29
- }
30
- export async function startWebDaemon(config, familiarAgent, agentCore, options = {}) {
31
- setAddedModelsPath(config.workspace.dataDir);
32
- setContactNotePath(config.persona.contact);
33
- await refreshContactNote();
34
- const persona = await loadPersona(config);
35
- const personaName = parsePersonaName(persona.soul);
36
- const auth = createAuth(config);
37
- const eventHub = createWebEventHub(config, personaName);
38
- const { appendAndPublishError, publish, publishDelta } = eventHub;
39
- const getRuntime = async (channelKey) => {
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);
44
- return runtime;
45
- };
46
- const subscribeKnownRuntimes = async () => {
47
- if (!agentCore.hasSessionSource())
48
- return;
49
- const sessions = await agentCore.getWebSessions();
50
- await Promise.all(sessions.map(async (session) => {
51
- const runtime = await agentCore.getRuntimeForWebChannel(session.key);
52
- eventHub.subscribeRuntime(runtime);
53
- }));
54
- };
55
- const getChannelKeyFromRequest = (url, body) => {
56
- const queryKey = url.searchParams.get("channelKey");
57
- if (queryKey)
58
- return queryKey;
59
- if (isRecord(body) && typeof body.channelKey === "string")
60
- return body.channelKey;
61
- return undefined;
62
- };
63
- const getAgentModelsPayload = () => {
64
- const models = [];
65
- const added = [];
66
- const seen = new Set();
67
- for (const model of config.models.allow) {
68
- if (seen.has(model))
69
- continue;
70
- seen.add(model);
71
- models.push(model);
72
- }
73
- for (const model of loadAddedModels()) {
74
- if (seen.has(model))
75
- continue;
76
- seen.add(model);
77
- models.push(model);
78
- added.push(model);
79
- }
80
- return { models, added };
81
- };
82
- const getConfigPayload = () => {
83
- const overrides = loadConfigOverrides();
84
- const values = {};
85
- for (const key of CONFIG_KEYS) {
86
- const entry = CONFIG_REGISTRY[key];
87
- values[key] = {
88
- value: entry.read(config),
89
- source: key in overrides ? "override" : "config",
90
- };
91
- }
92
- return { values };
93
- };
94
- const parseRequestedModel = (value) => {
95
- if (typeof value !== "string")
96
- return { ok: false, error: "format must be provider/model-id" };
97
- const ref = parseModelRef(value);
98
- if (!ref)
99
- return { ok: false, error: "format must be provider/model-id" };
100
- return { ok: true, model: ref.key, ref };
101
- };
102
- const promptForRuntime = async (runtime, jobId, prompt, attachments = [], onTurnEnd) => {
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;
112
- const summary = { thinking: "" };
113
- const recorder = createAgentEventRecorder((storedEvent) => runtime.noteAgentEvent(jobId, assistantMessageId, storedEvent, { notify: false }));
114
- let started = false;
115
- let reply;
116
- try {
117
- reply = await options.dispatch(async (event) => {
118
- if (event.type === "message_start" && event.message.role === "assistant" && !started) {
119
- started = true;
120
- options.onAssistantStart?.();
121
- }
122
- updateAgentEventSummary(summary, event);
123
- const storedEvent = storedAgentEventFromAgentEvent(event);
124
- if (storedEvent) {
125
- runtime.publishAgentEvent(jobId, assistantMessageId, storedEvent);
126
- await recorder.record(storedEvent);
127
- }
128
- });
129
- }
130
- finally {
131
- await recorder.flush();
132
- }
133
- const parsed = parseAgentReply(reply.text);
134
- const finalText = parsed.silent ? "" : reply.text;
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
- }
148
- }
149
- }
150
- const thinkingMs = thinkingDurationMs(summary);
151
- publish({
152
- type: "message_completed",
153
- channelKey: runtime.channelKey,
154
- messageId: assistantMessageId,
155
- thinkingMs,
156
- attachments: webAttachments(config, reply.attachments),
157
- silent: parsed.silent || undefined,
158
- });
159
- eventHub.markLocallyStreamed(assistantMessageId);
160
- return {
161
- text: finalText,
162
- messageId: assistantMessageId,
163
- thinking: summary.thinking,
164
- thinkingMs,
165
- attachments: reply.attachments,
166
- silent: parsed.silent,
167
- };
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
- };
244
- const drainJobs = async (runtime) => {
245
- for (;;) {
246
- const dispatch = runtime.beginNextJob();
247
- if (!dispatch)
248
- return;
249
- try {
250
- const reply = await promptForRuntime(runtime, dispatch.job.jobId, dispatch.prompt, dispatch.attachments, () => {
251
- publish({
252
- type: "status",
253
- channelKey: runtime.channelKey,
254
- kind: "idle",
255
- });
256
- });
257
- await runtime.completeActiveJob({
258
- text: reply.text,
259
- messageIds: [reply.messageId],
260
- webMessageId: reply.messageId,
261
- attachments: reply.attachments,
262
- thinking: reply.thinking,
263
- thinkingMs: reply.thinkingMs,
264
- silent: reply.silent,
265
- replyToMessageId: dispatch.triggerMessageId,
266
- });
267
- }
268
- catch (error) {
269
- if (!runtime.hasActiveJob(dispatch.job.jobId))
270
- return;
271
- const message = errorMessage(error);
272
- await runtime.failActiveJob(message);
273
- await appendAndPublishError(runtime, message);
274
- }
275
- }
276
- };
277
- const applyControlCommand = async (runtime, control) => {
278
- if (control.command === "stop") {
279
- await familiarAgent.abort(runtime.channelKey);
280
- return "Stopped current work.";
281
- }
282
- if (control.command === "new") {
283
- await familiarAgent.reset(runtime.channelKey);
284
- await runtime.resetConversation("new conversation requested");
285
- return "Started a fresh agent transcript for this channel.";
286
- }
287
- if (control.command === "reload") {
288
- return familiarAgent.reload();
289
- }
290
- if (control.command === "restart") {
291
- return options.restart
292
- ? await options.restart()
293
- : "Restart requested, but no restart handler is configured. Please restart the Familiar process manually.";
294
- }
295
- if (control.command === "model") {
296
- return control.args
297
- ? await familiarAgent.setModel(runtime.channelKey, control.args)
298
- : `Current model: ${formatSetting(familiarAgent.getModel(runtime.channelKey))}`;
299
- }
300
- if (control.command === "thinking") {
301
- return control.args
302
- ? await familiarAgent.setThinkingLevel(runtime.channelKey, control.args)
303
- : `Current thinking: ${formatSetting(familiarAgent.getThinkingLevel(runtime.channelKey))}`;
304
- }
305
- if (control.command === "channel-trigger")
306
- return "Use Discord /familiar channel-trigger in the channel for now.";
307
- if (control.command === "status") {
308
- return [
309
- runtime.formatStatus(),
310
- `model: ${formatSetting(familiarAgent.getModel(runtime.channelKey))}`,
311
- `thinking: ${formatSetting(familiarAgent.getThinkingLevel(runtime.channelKey))}`,
312
- ].join("\n");
313
- }
314
- return "Compact is not wired for this runtime yet. I logged the command, but I won't run lossy compaction here.";
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
- });
580
- const handleApi = async (request, response, url) => {
581
- if (!url.pathname.startsWith("/api/web/"))
582
- return false;
583
- if (!auth.authorize(request, url.pathname)) {
584
- sendJson(response, 401, { error: "unauthorized" });
585
- return true;
586
- }
587
- try {
588
- if (request.method === "GET" && url.pathname.startsWith("/api/web/attachments/")) {
589
- return serveAttachment(config, response, url.pathname, request.headers.range);
590
- }
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);
595
- sendJson(response, 404, { error: "not found" });
596
- return true;
597
- }
598
- catch (error) {
599
- const status = error instanceof HttpError ? error.status : 500;
600
- const message = errorMessage(error);
601
- sendJson(response, status, { error: message });
602
- return true;
603
- }
604
- };
605
- await subscribeKnownRuntimes();
606
- const server = createServer((request, response) => {
607
- const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
608
- void handleApi(request, response, url).then(async (handled) => {
609
- if (handled)
610
- return;
611
- if (await serveStatic(response, url.pathname))
612
- return;
613
- sendText(response, 404, "Not found");
614
- });
615
- });
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,
623
- });
624
- await new Promise((resolveListen, rejectListen) => {
625
- server.once("error", rejectListen);
626
- server.listen(config.web.port, config.web.bindAddress, () => {
627
- server.off("error", rejectListen);
628
- resolveListen();
629
- });
630
- });
631
- console.log(`Web side-door listening on http://${config.web.bindAddress}:${config.web.port}`);
632
- return {
633
- server,
634
- async stop() {
635
- eventHub.stop();
636
- await new Promise((resolveClose, rejectClose) => {
637
- server.close((error) => (error ? rejectClose(error) : resolveClose()));
638
- });
639
- },
640
- };
641
- }