@qearlyao/familiar 0.2.2 → 0.2.4

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 (60) hide show
  1. package/README.md +6 -14
  2. package/config.example.toml +1 -1
  3. package/dist/added-models.js +6 -15
  4. package/dist/agent-events.js +1 -3
  5. package/dist/agent.js +3 -4
  6. package/dist/browser-tools.js +15 -11
  7. package/dist/chat-log.js +3 -2
  8. package/dist/cli.js +2 -2
  9. package/dist/config-overrides.js +5 -14
  10. package/dist/config-registry.js +1 -4
  11. package/dist/config.js +45 -113
  12. package/dist/contact-note.js +2 -12
  13. package/dist/data-retention.js +1 -3
  14. package/dist/discord.js +72 -19
  15. package/dist/generated-media.js +3 -2
  16. package/dist/hot-reload.js +1 -3
  17. package/dist/image-gen.js +12 -51
  18. package/dist/inbound-attachments.js +64 -23
  19. package/dist/memory/diary/ambient-injector.js +1 -3
  20. package/dist/memory/diary/ambient.js +1 -3
  21. package/dist/memory/diary/chunks.js +1 -3
  22. package/dist/memory/diary/indexer.js +1 -3
  23. package/dist/memory/doctor.js +3 -8
  24. package/dist/memory/index/chunk-indexer.js +27 -6
  25. package/dist/memory/index/retrieval.js +1 -3
  26. package/dist/memory/index/store.js +47 -19
  27. package/dist/memory/lcm/backfill.js +19 -16
  28. package/dist/memory/lcm/context-transformer.js +17 -29
  29. package/dist/memory/lcm/context.js +10 -4
  30. package/dist/memory/lcm/eviction-score.js +25 -13
  31. package/dist/memory/lcm/indexer.js +1 -5
  32. package/dist/memory/lcm/normalize.js +22 -1
  33. package/dist/memory/lcm/store.js +27 -24
  34. package/dist/memory/operator.js +3 -31
  35. package/dist/memory/service.js +1 -3
  36. package/dist/memory/tools.js +0 -4
  37. package/dist/memory/util.js +6 -0
  38. package/dist/models.js +3 -0
  39. package/dist/persona.js +3 -15
  40. package/dist/runtime.js +12 -23
  41. package/dist/scheduler.js +15 -49
  42. package/dist/service.js +39 -27
  43. package/dist/settings.js +7 -32
  44. package/dist/silent-marker.js +64 -0
  45. package/dist/tts.js +0 -6
  46. package/dist/util/fs.js +41 -0
  47. package/dist/util/guards.js +8 -0
  48. package/dist/util/image-mime.js +31 -0
  49. package/dist/util/time.js +29 -0
  50. package/dist/web-auth.js +4 -1
  51. package/dist/web-static.js +36 -1
  52. package/dist/web-tools.js +8 -5
  53. package/dist/web.js +253 -69
  54. package/npm-shrinkwrap.json +5139 -0
  55. package/package.json +5 -4
  56. package/web/dist/assets/index-B23WT77N.js +63 -0
  57. package/web/dist/assets/index-D3MotFzN.css +2 -0
  58. package/web/dist/index.html +2 -2
  59. package/web/dist/assets/index-BPZQbZh5.js +0 -61
  60. package/web/dist/assets/index-CcQ13VAY.css +0 -2
package/dist/web.js CHANGED
@@ -12,6 +12,7 @@ import { publicAttachmentPath } from "./generated-media.js";
12
12
  import { materializeInboundAttachments } from "./inbound-attachments.js";
13
13
  import { PROVIDER_DEFAULTS, parseModelRef, supportedThinkingLevels } from "./models.js";
14
14
  import { loadPersona, parsePersonaName } from "./persona.js";
15
+ import { consumeSilentDelta, createSilentFilterState, finalizeSilentFilter, parseAgentReply } from "./silent-marker.js";
15
16
  import { createAuth, sessionCookie, verifyTotp } from "./web-auth.js";
16
17
  import { acceptWebSocket, decodeFrames, encodeFrame, replayEvents } from "./web-events.js";
17
18
  import { isObject, readJsonBody, sendJson, sendText } from "./web-http.js";
@@ -147,6 +148,8 @@ function webAttachments(config, attachments) {
147
148
  }));
148
149
  }
149
150
  function attachmentDerivedText(attachment) {
151
+ if (attachment.derived?.text?.label === "preview")
152
+ return undefined;
150
153
  return attachment.derived?.text?.text;
151
154
  }
152
155
  function toolError(result) {
@@ -215,23 +218,79 @@ function mergeToolEvent(existing, patch) {
215
218
  startedAt: existing?.startedAt ?? patch.startedAt,
216
219
  };
217
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
+ }
218
268
  function applyStoredAgentEventToMessage(message, record, options) {
219
269
  const event = record.event;
220
270
  const ts = toUnixMs(record.ts);
271
+ message.steps ??= [];
272
+ const steps = message.steps;
221
273
  if (event.type === "message_update") {
222
274
  const assistantEvent = event.assistantMessageEvent;
223
275
  if (assistantEvent.type === "text_delta") {
276
+ appendDeltaStep(steps, message.id, "text", assistantEvent.delta, ts);
224
277
  if (options.applyTextDeltas)
225
278
  message.text += assistantEvent.delta;
226
279
  }
227
- if (assistantEvent.type === "thinking_delta" && options.applyThinkingDeltas) {
228
- message.thinking = `${message.thinking ?? ""}${assistantEvent.delta}`;
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}`;
229
284
  }
230
285
  }
231
- if (event.type === "message_end" && event.usage)
232
- message.usage = event.usage;
286
+ if (event.type === "message_end") {
287
+ closeOpenContentSteps(steps, ts);
288
+ if (event.usage)
289
+ message.usage = event.usage;
290
+ }
233
291
  const tool = toolFromStoredAgentEvent(event, ts);
234
292
  if (tool) {
293
+ upsertToolStep(steps, tool, ts);
235
294
  const tools = message.tools ?? [];
236
295
  const index = tools.findIndex((candidate) => candidate.id === tool.id);
237
296
  if (index >= 0) {
@@ -243,6 +302,29 @@ function applyStoredAgentEventToMessage(message, record, options) {
243
302
  message.tools = tools;
244
303
  }
245
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
+ }
246
328
  function webMessagesFromRecords(config, records, assistantName) {
247
329
  const messages = [];
248
330
  const messagesById = new Map();
@@ -255,7 +337,7 @@ function webMessagesFromRecords(config, records, assistantName) {
255
337
  const pending = pendingAgentEvents.get(message.id) ?? [];
256
338
  for (const pendingRecord of pending) {
257
339
  applyStoredAgentEventToMessage(message, pendingRecord, {
258
- applyTextDeltas: !message.text,
340
+ applyTextDeltas: !message.text && !message.silent,
259
341
  applyThinkingDeltas: !message.thinking,
260
342
  });
261
343
  }
@@ -265,7 +347,7 @@ function webMessagesFromRecords(config, records, assistantName) {
265
347
  const existing = messagesById.get(record.messageId);
266
348
  if (existing) {
267
349
  applyStoredAgentEventToMessage(existing, record, {
268
- applyTextDeltas: true,
350
+ applyTextDeltas: !existing.silent,
269
351
  applyThinkingDeltas: true,
270
352
  });
271
353
  }
@@ -276,8 +358,17 @@ function webMessagesFromRecords(config, records, assistantName) {
276
358
  }
277
359
  }
278
360
  }
361
+ for (const message of messages)
362
+ ensureFallbackSteps(message);
279
363
  return messages;
280
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
+ }
281
372
  function webMessageFromRecord(config, record, assistantName) {
282
373
  if (!isUserVisibleRuntimeRecord(record))
283
374
  return undefined;
@@ -304,6 +395,7 @@ function webMessageFromRecord(config, record, assistantName) {
304
395
  attachments: webAttachments(config, record.attachments),
305
396
  thinking: record.thinking,
306
397
  thinkingMs: record.thinkingMs,
398
+ silent: record.silent || undefined,
307
399
  ts: toUnixMs(record.ts),
308
400
  };
309
401
  }
@@ -363,7 +455,32 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
363
455
  const clients = new Set();
364
456
  const eventsByChannel = new Map();
365
457
  const runtimeSubscriptions = new Map();
366
- const locallyStreamedOutboundIds = new Set();
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?.();
367
484
  const publish = (event) => {
368
485
  const fullEvent = { ...event, eventId: eventId(), ts: event.ts ?? Date.now() };
369
486
  const events = eventsByChannel.get(fullEvent.channelKey ?? "") ?? [];
@@ -388,32 +505,83 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
388
505
  };
389
506
  const publishDelta = (channelKey, messageIdValue, part, text, ts) => publish({ type: "delta", channelKey, messageId: messageIdValue, part, content: text, text, ts });
390
507
  const publishStoredAgentEvent = (channelKey, messageIdValue, storedEvent, ts) => {
508
+ touchInFlight(messageIdValue);
391
509
  if (storedEvent.type === "message_start" && storedEvent.role === "assistant") {
392
- locallyStreamedOutboundIds.add(messageIdValue);
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;
393
523
  publish({
394
524
  type: "message_started",
395
525
  channelKey,
396
526
  messageId: messageIdValue,
397
527
  role: "assistant",
398
528
  who: personaName,
399
- ts,
529
+ ts: startTs,
400
530
  });
401
- }
531
+ return true;
532
+ };
402
533
  if (storedEvent.type === "message_update") {
403
534
  const assistantEvent = storedEvent.assistantMessageEvent;
404
535
  if (assistantEvent.type === "thinking_delta") {
536
+ startedSilentMessage();
405
537
  publishDelta(channelKey, messageIdValue, "thinking", assistantEvent.delta, ts);
406
538
  }
407
539
  if (assistantEvent.type === "text_delta") {
408
- publishDelta(channelKey, messageIdValue, "text", assistantEvent.delta, ts);
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
+ }
409
552
  }
410
553
  }
554
+ if (storedEvent.type === "tool_execution_start") {
555
+ startedSilentMessage();
556
+ }
411
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
+ }
412
579
  publish({
413
580
  type: "message_completed",
414
581
  channelKey,
415
582
  messageId: messageIdValue,
416
583
  usage: storedEvent.usage,
584
+ silent: silent || undefined,
417
585
  ts,
418
586
  });
419
587
  }
@@ -439,6 +607,7 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
439
607
  type: "message_completed",
440
608
  channelKey: runtime.channelKey,
441
609
  messageId: record.messageId,
610
+ attachments: webAttachments(config, record.attachments),
442
611
  ts: toUnixMs(record.ts),
443
612
  });
444
613
  }
@@ -450,24 +619,28 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
450
619
  messageId: outboundId,
451
620
  thinkingMs: record.thinkingMs,
452
621
  attachments: webAttachments(config, record.attachments),
622
+ silent: record.silent || undefined,
453
623
  ts: toUnixMs(record.ts),
454
624
  };
455
- if (locallyStreamedOutboundIds.delete(outboundId)) {
625
+ if (inFlightMessages.get(outboundId)?.locallyStreamed) {
626
+ inFlightMessages.delete(outboundId);
456
627
  publish(completion);
457
628
  return;
458
629
  }
459
- publish({
460
- type: "message_started",
461
- channelKey: runtime.channelKey,
462
- messageId: outboundId,
463
- role: "assistant",
464
- who: personaName,
465
- ts: toUnixMs(record.ts),
466
- });
467
- if (record.thinking)
468
- publishDelta(runtime.channelKey, outboundId, "thinking", record.thinking, toUnixMs(record.ts));
469
- if (record.text)
470
- publishDelta(runtime.channelKey, outboundId, "text", record.text, toUnixMs(record.ts));
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
+ }
471
644
  publish(completion);
472
645
  }
473
646
  });
@@ -564,7 +737,9 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
564
737
  finally {
565
738
  await recorder.flush();
566
739
  }
567
- if (!started) {
740
+ const parsed = parseAgentReply(reply.text);
741
+ const finalText = parsed.silent ? "" : reply.text;
742
+ if (!started && !parsed.silent) {
568
743
  publish({
569
744
  type: "message_started",
570
745
  channelKey: runtime.channelKey,
@@ -572,7 +747,9 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
572
747
  role: "assistant",
573
748
  who: personaName,
574
749
  });
575
- publishDelta(runtime.channelKey, assistantMessageId, "text", reply.text);
750
+ if (finalText) {
751
+ publishDelta(runtime.channelKey, assistantMessageId, "text", finalText);
752
+ }
576
753
  }
577
754
  const thinkingMs = thinkingDurationMs(summary);
578
755
  publish({
@@ -581,14 +758,17 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
581
758
  messageId: assistantMessageId,
582
759
  thinkingMs,
583
760
  attachments: webAttachments(config, reply.attachments),
761
+ silent: parsed.silent || undefined,
584
762
  });
585
- locallyStreamedOutboundIds.add(assistantMessageId);
763
+ const entry = getOrCreateInFlight(assistantMessageId);
764
+ entry.locallyStreamed = true;
586
765
  return {
587
- text: reply.text,
766
+ text: finalText,
588
767
  messageId: assistantMessageId,
589
768
  thinking: summary.thinking,
590
769
  thinkingMs,
591
770
  attachments: reply.attachments,
771
+ silent: parsed.silent,
592
772
  };
593
773
  };
594
774
  const drainJobs = async (runtime) => {
@@ -611,6 +791,7 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
611
791
  attachments: reply.attachments,
612
792
  thinking: reply.thinking,
613
793
  thinkingMs: reply.thinkingMs,
794
+ silent: reply.silent,
614
795
  replyToMessageId: dispatch.triggerMessageId,
615
796
  });
616
797
  }
@@ -672,7 +853,7 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
672
853
  }
673
854
  try {
674
855
  if (request.method === "GET" && url.pathname.startsWith("/api/web/attachments/")) {
675
- return serveAttachment(config, response, url.pathname);
856
+ return serveAttachment(config, response, url.pathname, request.headers.range);
676
857
  }
677
858
  if (request.method === "GET" && url.pathname === "/api/web/auth/mode") {
678
859
  sendJson(response, 200, { mode: config.web.authMode, personaName });
@@ -686,12 +867,8 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
686
867
  if (request.method === "GET" && url.pathname === "/api/web/history") {
687
868
  const runtime = await getRuntime(getChannelKeyFromRequest(url));
688
869
  const limit = Math.min(Math.max(Number(url.searchParams.get("limit") ?? 50) || 50, 1), 200);
689
- const before = url.searchParams.get("before");
690
- const messages = webMessagesFromRecords(config, runtime.getRecords(), personaName);
691
- const end = before ? messages.findIndex((message) => message.id === before) : messages.length;
692
- const safeEnd = end >= 0 ? end : messages.length;
693
- const page = messages.slice(Math.max(0, safeEnd - limit), safeEnd);
694
- sendJson(response, 200, { messages: page, hasMore: safeEnd - limit > 0, channelKey: runtime.channelKey });
870
+ const before = url.searchParams.get("before") ?? undefined;
871
+ sendJson(response, 200, webHistoryPayload(config, runtime.getRecords(), personaName, runtime.channelKey, { limit, before }));
695
872
  return true;
696
873
  }
697
874
  if (request.method === "GET" && url.pathname === "/api/web/agent/settings") {
@@ -952,49 +1129,53 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
952
1129
  netSocket.destroy();
953
1130
  return;
954
1131
  }
955
- if (!acceptWebSocket(request, netSocket))
956
- return;
957
- netSocket.setNoDelay(true);
958
1132
  const requestedChannelKey = url.searchParams.get("channelKey") || undefined;
959
- const client = { socket: netSocket, channelKey: requestedChannelKey, authed: false };
960
- clients.add(client);
961
- let frameBuffer = Buffer.alloc(0);
962
1133
  void getRuntime(requestedChannelKey)
963
1134
  .then((runtime) => {
964
- client.channelKey = runtime.channelKey;
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));
965
1171
  })
966
1172
  .catch((error) => {
967
1173
  console.error("WebSocket runtime lookup failed", error);
968
- netSocket.destroy();
969
- });
970
- netSocket.on("data", (chunk) => {
971
- try {
972
- frameBuffer = Buffer.concat([frameBuffer, chunk]);
973
- const decoded = decodeFrames(frameBuffer);
974
- frameBuffer = decoded.remaining;
975
- if (decoded.close)
976
- netSocket.destroy();
977
- for (const raw of decoded.messages) {
978
- const message = JSON.parse(raw);
979
- if (isObject(message) && message.type === "hello") {
980
- if (!client.channelKey)
981
- continue;
982
- replay(client, client.channelKey, typeof message.lastEventId === "string" ? message.lastEventId : null);
983
- }
984
- if (isObject(message) && message.type === "abort") {
985
- void getRuntime(client.channelKey).then(async (runtime) => {
986
- familiarAgent.requestSoftStop(runtime.channelKey);
987
- });
988
- }
989
- }
990
- }
991
- catch (error) {
992
- console.error("WebSocket frame handling failed", error);
1174
+ if (!netSocket.destroyed) {
1175
+ netSocket.write("HTTP/1.1 503 Service Unavailable\r\n\r\n");
993
1176
  netSocket.destroy();
994
1177
  }
995
1178
  });
996
- netSocket.on("close", () => clients.delete(client));
997
- netSocket.on("error", () => clients.delete(client));
998
1179
  });
999
1180
  await new Promise((resolveListen, rejectListen) => {
1000
1181
  server.once("error", rejectListen);
@@ -1007,6 +1188,7 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
1007
1188
  return {
1008
1189
  server,
1009
1190
  async stop() {
1191
+ clearInterval(inFlightGcTimer);
1010
1192
  for (const client of clients)
1011
1193
  client.socket.destroy();
1012
1194
  clients.clear();
@@ -1022,4 +1204,6 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
1022
1204
  export const __webTest = {
1023
1205
  memeCatalogPath,
1024
1206
  parseMemeCatalog,
1207
+ webHistoryPayload,
1208
+ webMessagesFromRecords,
1025
1209
  };