@qearlyao/familiar 0.2.3 → 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 (58) hide show
  1. package/README.md +5 -2
  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 +12 -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 +2 -2
  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 +53 -22
  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 +6 -2
  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 +12 -24
  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 +2 -4
  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 +2 -14
  40. package/dist/runtime.js +2 -23
  41. package/dist/scheduler.js +15 -49
  42. package/dist/service.js +24 -14
  43. package/dist/settings.js +7 -32
  44. package/dist/tts.js +0 -6
  45. package/dist/util/fs.js +41 -0
  46. package/dist/util/guards.js +8 -0
  47. package/dist/util/image-mime.js +31 -0
  48. package/dist/util/time.js +29 -0
  49. package/dist/web-auth.js +4 -1
  50. package/dist/web-tools.js +8 -5
  51. package/dist/web.js +188 -62
  52. package/npm-shrinkwrap.json +2 -2
  53. package/package.json +1 -1
  54. package/web/dist/assets/index-B23WT77N.js +63 -0
  55. package/web/dist/assets/index-D3MotFzN.css +2 -0
  56. package/web/dist/index.html +2 -2
  57. package/web/dist/assets/index-C-w9fjBf.js +0 -61
  58. package/web/dist/assets/index-CcQ13VAY.css +0 -2
package/dist/settings.js CHANGED
@@ -1,13 +1,6 @@
1
- import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
- import { dirname, resolve } from "node:path";
3
- function isThinkingLevel(value) {
4
- return (value === "off" ||
5
- value === "minimal" ||
6
- value === "low" ||
7
- value === "medium" ||
8
- value === "high" ||
9
- value === "xhigh");
10
- }
1
+ import { resolve } from "node:path";
2
+ import { isThinkingLevel } from "./models.js";
3
+ import { atomicWriteJson, createWriteQueue, readFileOrNull } from "./util/fs.js";
11
4
  function isChannelTrigger(value) {
12
5
  return value === "mention" || value === "always";
13
6
  }
@@ -37,16 +30,8 @@ function normalizeSettingsFile(value) {
37
30
  return { version: 1, channels };
38
31
  }
39
32
  async function readSettingsFile(path) {
40
- try {
41
- const raw = await readFile(path, "utf8");
42
- return normalizeSettingsFile(JSON.parse(raw));
43
- }
44
- catch (error) {
45
- if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
46
- return { version: 1, channels: {} };
47
- }
48
- throw error;
49
- }
33
+ const raw = await readFileOrNull(path, "utf8");
34
+ return raw === null ? { version: 1, channels: {} } : normalizeSettingsFile(JSON.parse(raw));
50
35
  }
51
36
  function pruneChannel(settings) {
52
37
  const pruned = {};
@@ -61,18 +46,8 @@ function pruneChannel(settings) {
61
46
  export async function loadSettingsStore(config) {
62
47
  const path = resolve(config.workspace.dataDir, "settings", "channel-overrides.json");
63
48
  let file = await readSettingsFile(path);
64
- let writeQueue = Promise.resolve();
65
- const persist = async () => {
66
- await mkdir(dirname(path), { recursive: true });
67
- const tmpPath = `${path}.${process.pid}.${Date.now()}.tmp`;
68
- await writeFile(tmpPath, `${JSON.stringify(file, null, 2)}\n`, "utf8");
69
- await rename(tmpPath, path);
70
- };
71
- const enqueuePersist = () => {
72
- const run = writeQueue.then(persist, () => persist());
73
- writeQueue = run.then(() => undefined, () => undefined);
74
- return run;
75
- };
49
+ const enqueueWrite = createWriteQueue("channel settings");
50
+ const enqueuePersist = () => enqueueWrite(() => atomicWriteJson(path, file));
76
51
  const updateChannel = async (channelKey, patch) => {
77
52
  const next = pruneChannel({ ...file.channels[channelKey], ...patch });
78
53
  const channels = { ...file.channels };
package/dist/tts.js CHANGED
@@ -129,13 +129,7 @@ export function createTtsTool(config, mediaSink) {
129
129
  return {
130
130
  content: [{ type: "text", text: formatTtsNotice(name) }],
131
131
  details: {
132
- provider: "elevenlabs",
133
- voiceId,
134
- modelId: config.tts.modelId,
135
- outputFormat,
136
132
  localPath,
137
- mimeType,
138
- size: buffer.length,
139
133
  },
140
134
  };
141
135
  },
@@ -0,0 +1,41 @@
1
+ import { mkdir, open, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ export function isEnoent(error) {
4
+ return !!error && typeof error === "object" && "code" in error && error.code === "ENOENT";
5
+ }
6
+ export async function readFileOrNull(path, encoding) {
7
+ try {
8
+ return await readFile(path, encoding);
9
+ }
10
+ catch (error) {
11
+ if (isEnoent(error))
12
+ return null;
13
+ throw error;
14
+ }
15
+ }
16
+ async function fsyncFile(path) {
17
+ const handle = await open(path, "r");
18
+ try {
19
+ await handle.sync();
20
+ }
21
+ finally {
22
+ await handle.close();
23
+ }
24
+ }
25
+ export async function atomicWriteJson(path, value) {
26
+ await mkdir(dirname(path), { recursive: true });
27
+ const tmpPath = `${path}.${process.pid}.${Date.now()}.tmp`;
28
+ await writeFile(tmpPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
29
+ await fsyncFile(tmpPath);
30
+ await rename(tmpPath, path);
31
+ }
32
+ export function createWriteQueue(logLabel) {
33
+ let queue = Promise.resolve();
34
+ return async (write) => {
35
+ const run = queue.then(write, write);
36
+ queue = run.then(() => undefined, (error) => {
37
+ console.error(`${logLabel} write failed`, error);
38
+ });
39
+ return run;
40
+ };
41
+ }
@@ -0,0 +1,8 @@
1
+ export function isRecord(value) {
2
+ return !!value && typeof value === "object" && !Array.isArray(value);
3
+ }
4
+ export function readEnum(value, path, allowed) {
5
+ if (typeof value === "string" && allowed.includes(value))
6
+ return value;
7
+ throw new Error(`Config value ${path} must be one of ${allowed.map((item) => JSON.stringify(item)).join(", ")}`);
8
+ }
@@ -0,0 +1,31 @@
1
+ import { extname } from "node:path";
2
+ export const IMAGE_MIME_BY_EXTENSION = {
3
+ ".jpg": "image/jpeg",
4
+ ".jpeg": "image/jpeg",
5
+ ".png": "image/png",
6
+ ".gif": "image/gif",
7
+ ".webp": "image/webp",
8
+ };
9
+ export const IMAGE_EXTENSION_BY_MIME = {
10
+ "image/jpeg": ".jpg",
11
+ "image/png": ".png",
12
+ "image/gif": ".gif",
13
+ "image/webp": ".webp",
14
+ };
15
+ export function imageMimeTypeFromPath(path) {
16
+ return IMAGE_MIME_BY_EXTENSION[extname(path).toLowerCase()];
17
+ }
18
+ export function sniffImageMimeType(buffer) {
19
+ if (buffer.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff])))
20
+ return "image/jpeg";
21
+ if (buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
22
+ return "image/png";
23
+ }
24
+ if (buffer.subarray(0, 6).toString("ascii") === "GIF87a" || buffer.subarray(0, 6).toString("ascii") === "GIF89a") {
25
+ return "image/gif";
26
+ }
27
+ if (buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP") {
28
+ return "image/webp";
29
+ }
30
+ return undefined;
31
+ }
@@ -0,0 +1,29 @@
1
+ export function toDate(value) {
2
+ if (value instanceof Date)
3
+ return value;
4
+ return new Date(value);
5
+ }
6
+ function formatOffset(date) {
7
+ const offsetMinutes = -date.getTimezoneOffset();
8
+ const sign = offsetMinutes >= 0 ? "+" : "-";
9
+ const absolute = Math.abs(offsetMinutes);
10
+ const hours = Math.floor(absolute / 60);
11
+ const minutes = absolute % 60;
12
+ return minutes === 0 ? `GMT${sign}${hours}` : `GMT${sign}${hours}:${String(minutes).padStart(2, "0")}`;
13
+ }
14
+ export function formatLocalTimestamp(value) {
15
+ const date = toDate(value);
16
+ if (Number.isNaN(date.getTime()))
17
+ return String(value);
18
+ const localDate = [
19
+ date.getFullYear(),
20
+ String(date.getMonth() + 1).padStart(2, "0"),
21
+ String(date.getDate()).padStart(2, "0"),
22
+ ].join("-");
23
+ const localTime = [
24
+ String(date.getHours()).padStart(2, "0"),
25
+ String(date.getMinutes()).padStart(2, "0"),
26
+ String(date.getSeconds()).padStart(2, "0"),
27
+ ].join(":");
28
+ return `${localDate} ${localTime} ${formatOffset(date)}`;
29
+ }
package/dist/web-auth.js CHANGED
@@ -11,7 +11,10 @@ function parseCookies(header) {
11
11
  const [name, ...valueParts] = part.trim().split("=");
12
12
  if (!name)
13
13
  continue;
14
- cookies[name] = decodeURIComponent(valueParts.join("="));
14
+ try {
15
+ cookies[name] = decodeURIComponent(valueParts.join("="));
16
+ }
17
+ catch { }
15
18
  }
16
19
  return cookies;
17
20
  }
package/dist/web-tools.js CHANGED
@@ -69,7 +69,7 @@ class PageCache {
69
69
  this.entries.delete(url);
70
70
  return undefined;
71
71
  }
72
- entry.fetchedAt = Date.now();
72
+ entry.lastAccessed = Date.now();
73
73
  this.entries.delete(url);
74
74
  this.entries.set(url, entry);
75
75
  return entry;
@@ -79,10 +79,12 @@ class PageCache {
79
79
  return;
80
80
  if (this.entries.has(url))
81
81
  this.entries.delete(url);
82
+ const now = Date.now();
82
83
  this.entries.set(url, {
83
84
  content,
84
85
  provider,
85
- fetchedAt: Date.now(),
86
+ fetchedAt: now,
87
+ lastAccessed: now,
86
88
  });
87
89
  while (this.entries.size > this.capacity) {
88
90
  const oldest = this.entries.keys().next().value;
@@ -382,7 +384,7 @@ function formatFetchContent(url, provider, chunk) {
382
384
  chunk.text,
383
385
  ];
384
386
  if (chunk.hasMore && chunk.nextOffset !== undefined) {
385
- lines.push("", `[More content available. Next chunk: web_fetch(url="${url}", offset=${chunk.nextOffset})]`);
387
+ lines.push("", `[More content available. Next chunk: fetch_web(url="${url}", offset=${chunk.nextOffset})]`);
386
388
  }
387
389
  return prefixUntrustedWebContent(lines.join("\n"));
388
390
  }
@@ -778,7 +780,7 @@ function makeSearchTool(config) {
778
780
  if (config.apiKeys.EXA_API_KEY)
779
781
  providers.exa = createExaProvider(config.apiKeys.EXA_API_KEY);
780
782
  return {
781
- name: "web_search",
783
+ name: "search_web",
782
784
  label: "Web Search",
783
785
  description: "look something up on the open web. returns titles, urls, snippets, and dates when present. depth=thorough swaps brevity for inline excerpts.",
784
786
  parameters: webSearchSchema,
@@ -847,7 +849,7 @@ function makeSearchTool(config) {
847
849
  function makeFetchTool(config) {
848
850
  const providers = createFetchProviders(config);
849
851
  return {
850
- name: "web_fetch",
852
+ name: "fetch_web",
851
853
  label: "Web Fetch",
852
854
  description: "pull a webpage down as clean markdown.",
853
855
  parameters: webFetchSchema,
@@ -923,6 +925,7 @@ export function createWebTools(_config) {
923
925
  }
924
926
  export const __webToolsTest = {
925
927
  PageCache,
928
+ createWebTools,
926
929
  createTestSearchProvider,
927
930
  createFetchProviders,
928
931
  formatFetchContent,
package/dist/web.js CHANGED
@@ -148,6 +148,8 @@ function webAttachments(config, attachments) {
148
148
  }));
149
149
  }
150
150
  function attachmentDerivedText(attachment) {
151
+ if (attachment.derived?.text?.label === "preview")
152
+ return undefined;
151
153
  return attachment.derived?.text?.text;
152
154
  }
153
155
  function toolError(result) {
@@ -216,23 +218,79 @@ function mergeToolEvent(existing, patch) {
216
218
  startedAt: existing?.startedAt ?? patch.startedAt,
217
219
  };
218
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
+ }
219
268
  function applyStoredAgentEventToMessage(message, record, options) {
220
269
  const event = record.event;
221
270
  const ts = toUnixMs(record.ts);
271
+ message.steps ??= [];
272
+ const steps = message.steps;
222
273
  if (event.type === "message_update") {
223
274
  const assistantEvent = event.assistantMessageEvent;
224
275
  if (assistantEvent.type === "text_delta") {
276
+ appendDeltaStep(steps, message.id, "text", assistantEvent.delta, ts);
225
277
  if (options.applyTextDeltas)
226
278
  message.text += assistantEvent.delta;
227
279
  }
228
- if (assistantEvent.type === "thinking_delta" && options.applyThinkingDeltas) {
229
- 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}`;
230
284
  }
231
285
  }
232
- if (event.type === "message_end" && event.usage)
233
- message.usage = event.usage;
286
+ if (event.type === "message_end") {
287
+ closeOpenContentSteps(steps, ts);
288
+ if (event.usage)
289
+ message.usage = event.usage;
290
+ }
234
291
  const tool = toolFromStoredAgentEvent(event, ts);
235
292
  if (tool) {
293
+ upsertToolStep(steps, tool, ts);
236
294
  const tools = message.tools ?? [];
237
295
  const index = tools.findIndex((candidate) => candidate.id === tool.id);
238
296
  if (index >= 0) {
@@ -244,6 +302,29 @@ function applyStoredAgentEventToMessage(message, record, options) {
244
302
  message.tools = tools;
245
303
  }
246
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
+ }
247
328
  function webMessagesFromRecords(config, records, assistantName) {
248
329
  const messages = [];
249
330
  const messagesById = new Map();
@@ -256,7 +337,7 @@ function webMessagesFromRecords(config, records, assistantName) {
256
337
  const pending = pendingAgentEvents.get(message.id) ?? [];
257
338
  for (const pendingRecord of pending) {
258
339
  applyStoredAgentEventToMessage(message, pendingRecord, {
259
- applyTextDeltas: !message.text,
340
+ applyTextDeltas: !message.text && !message.silent,
260
341
  applyThinkingDeltas: !message.thinking,
261
342
  });
262
343
  }
@@ -266,7 +347,7 @@ function webMessagesFromRecords(config, records, assistantName) {
266
347
  const existing = messagesById.get(record.messageId);
267
348
  if (existing) {
268
349
  applyStoredAgentEventToMessage(existing, record, {
269
- applyTextDeltas: true,
350
+ applyTextDeltas: !existing.silent,
270
351
  applyThinkingDeltas: true,
271
352
  });
272
353
  }
@@ -277,8 +358,17 @@ function webMessagesFromRecords(config, records, assistantName) {
277
358
  }
278
359
  }
279
360
  }
361
+ for (const message of messages)
362
+ ensureFallbackSteps(message);
280
363
  return messages;
281
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
+ }
282
372
  function webMessageFromRecord(config, record, assistantName) {
283
373
  if (!isUserVisibleRuntimeRecord(record))
284
374
  return undefined;
@@ -365,9 +455,32 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
365
455
  const clients = new Set();
366
456
  const eventsByChannel = new Map();
367
457
  const runtimeSubscriptions = new Map();
368
- const locallyStreamedOutboundIds = new Set();
369
- const silentFilters = new Map();
370
- const pendingMessageStarts = 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?.();
371
484
  const publish = (event) => {
372
485
  const fullEvent = { ...event, eventId: eventId(), ts: event.ts ?? Date.now() };
373
486
  const events = eventsByChannel.get(fullEvent.channelKey ?? "") ?? [];
@@ -392,16 +505,21 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
392
505
  };
393
506
  const publishDelta = (channelKey, messageIdValue, part, text, ts) => publish({ type: "delta", channelKey, messageId: messageIdValue, part, content: text, text, ts });
394
507
  const publishStoredAgentEvent = (channelKey, messageIdValue, storedEvent, ts) => {
508
+ touchInFlight(messageIdValue);
395
509
  if (storedEvent.type === "message_start" && storedEvent.role === "assistant") {
396
- locallyStreamedOutboundIds.add(messageIdValue);
397
- silentFilters.set(messageIdValue, createSilentFilterState());
398
- pendingMessageStarts.set(messageIdValue, ts);
510
+ const entry = getOrCreateInFlight(messageIdValue);
511
+ entry.locallyStreamed = true;
512
+ entry.silentFilter = createSilentFilterState();
513
+ entry.pendingStartTs = ts;
514
+ entry.startedSilent = false;
399
515
  }
400
516
  const startedSilentMessage = () => {
401
- if (!pendingMessageStarts.has(messageIdValue))
517
+ const entry = inFlightMessages.get(messageIdValue);
518
+ if (!entry || entry.startedSilent)
402
519
  return false;
403
- const startTs = pendingMessageStarts.get(messageIdValue);
404
- pendingMessageStarts.delete(messageIdValue);
520
+ const startTs = entry.pendingStartTs;
521
+ entry.pendingStartTs = undefined;
522
+ entry.startedSilent = true;
405
523
  publish({
406
524
  type: "message_started",
407
525
  channelKey,
@@ -419,7 +537,7 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
419
537
  publishDelta(channelKey, messageIdValue, "thinking", assistantEvent.delta, ts);
420
538
  }
421
539
  if (assistantEvent.type === "text_delta") {
422
- const filter = silentFilters.get(messageIdValue);
540
+ const filter = inFlightMessages.get(messageIdValue)?.silentFilter;
423
541
  if (!filter) {
424
542
  startedSilentMessage();
425
543
  publishDelta(channelKey, messageIdValue, "text", assistantEvent.delta, ts);
@@ -437,9 +555,10 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
437
555
  startedSilentMessage();
438
556
  }
439
557
  if (storedEvent.type === "message_end" && storedEvent.role === "assistant") {
440
- const filter = silentFilters.get(messageIdValue);
558
+ const entry = inFlightMessages.get(messageIdValue);
559
+ const filter = entry?.silentFilter;
441
560
  let silent = false;
442
- if (filter) {
561
+ if (filter && entry) {
443
562
  const final = finalizeSilentFilter(filter);
444
563
  silent = final.silent;
445
564
  if (!silent) {
@@ -449,9 +568,10 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
449
568
  }
450
569
  }
451
570
  else {
452
- pendingMessageStarts.delete(messageIdValue);
571
+ entry.startedSilent = true;
572
+ entry.pendingStartTs = undefined;
453
573
  }
454
- silentFilters.delete(messageIdValue);
574
+ entry.silentFilter = undefined;
455
575
  }
456
576
  else {
457
577
  startedSilentMessage();
@@ -487,6 +607,7 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
487
607
  type: "message_completed",
488
608
  channelKey: runtime.channelKey,
489
609
  messageId: record.messageId,
610
+ attachments: webAttachments(config, record.attachments),
490
611
  ts: toUnixMs(record.ts),
491
612
  });
492
613
  }
@@ -501,7 +622,8 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
501
622
  silent: record.silent || undefined,
502
623
  ts: toUnixMs(record.ts),
503
624
  };
504
- if (locallyStreamedOutboundIds.delete(outboundId)) {
625
+ if (inFlightMessages.get(outboundId)?.locallyStreamed) {
626
+ inFlightMessages.delete(outboundId);
505
627
  publish(completion);
506
628
  return;
507
629
  }
@@ -638,7 +760,8 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
638
760
  attachments: webAttachments(config, reply.attachments),
639
761
  silent: parsed.silent || undefined,
640
762
  });
641
- locallyStreamedOutboundIds.add(assistantMessageId);
763
+ const entry = getOrCreateInFlight(assistantMessageId);
764
+ entry.locallyStreamed = true;
642
765
  return {
643
766
  text: finalText,
644
767
  messageId: assistantMessageId,
@@ -744,12 +867,8 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
744
867
  if (request.method === "GET" && url.pathname === "/api/web/history") {
745
868
  const runtime = await getRuntime(getChannelKeyFromRequest(url));
746
869
  const limit = Math.min(Math.max(Number(url.searchParams.get("limit") ?? 50) || 50, 1), 200);
747
- const before = url.searchParams.get("before");
748
- const messages = webMessagesFromRecords(config, runtime.getRecords(), personaName);
749
- const end = before ? messages.findIndex((message) => message.id === before) : messages.length;
750
- const safeEnd = end >= 0 ? end : messages.length;
751
- const page = messages.slice(Math.max(0, safeEnd - limit), safeEnd);
752
- 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 }));
753
872
  return true;
754
873
  }
755
874
  if (request.method === "GET" && url.pathname === "/api/web/agent/settings") {
@@ -1010,49 +1129,53 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
1010
1129
  netSocket.destroy();
1011
1130
  return;
1012
1131
  }
1013
- if (!acceptWebSocket(request, netSocket))
1014
- return;
1015
- netSocket.setNoDelay(true);
1016
1132
  const requestedChannelKey = url.searchParams.get("channelKey") || undefined;
1017
- const client = { socket: netSocket, channelKey: requestedChannelKey, authed: false };
1018
- clients.add(client);
1019
- let frameBuffer = Buffer.alloc(0);
1020
1133
  void getRuntime(requestedChannelKey)
1021
1134
  .then((runtime) => {
1022
- 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));
1023
1171
  })
1024
1172
  .catch((error) => {
1025
1173
  console.error("WebSocket runtime lookup failed", error);
1026
- netSocket.destroy();
1027
- });
1028
- netSocket.on("data", (chunk) => {
1029
- try {
1030
- frameBuffer = Buffer.concat([frameBuffer, chunk]);
1031
- const decoded = decodeFrames(frameBuffer);
1032
- frameBuffer = decoded.remaining;
1033
- if (decoded.close)
1034
- netSocket.destroy();
1035
- for (const raw of decoded.messages) {
1036
- const message = JSON.parse(raw);
1037
- if (isObject(message) && message.type === "hello") {
1038
- if (!client.channelKey)
1039
- continue;
1040
- replay(client, client.channelKey, typeof message.lastEventId === "string" ? message.lastEventId : null);
1041
- }
1042
- if (isObject(message) && message.type === "abort") {
1043
- void getRuntime(client.channelKey).then(async (runtime) => {
1044
- familiarAgent.requestSoftStop(runtime.channelKey);
1045
- });
1046
- }
1047
- }
1048
- }
1049
- catch (error) {
1050
- console.error("WebSocket frame handling failed", error);
1174
+ if (!netSocket.destroyed) {
1175
+ netSocket.write("HTTP/1.1 503 Service Unavailable\r\n\r\n");
1051
1176
  netSocket.destroy();
1052
1177
  }
1053
1178
  });
1054
- netSocket.on("close", () => clients.delete(client));
1055
- netSocket.on("error", () => clients.delete(client));
1056
1179
  });
1057
1180
  await new Promise((resolveListen, rejectListen) => {
1058
1181
  server.once("error", rejectListen);
@@ -1065,6 +1188,7 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
1065
1188
  return {
1066
1189
  server,
1067
1190
  async stop() {
1191
+ clearInterval(inFlightGcTimer);
1068
1192
  for (const client of clients)
1069
1193
  client.socket.destroy();
1070
1194
  clients.clear();
@@ -1080,4 +1204,6 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
1080
1204
  export const __webTest = {
1081
1205
  memeCatalogPath,
1082
1206
  parseMemeCatalog,
1207
+ webHistoryPayload,
1208
+ webMessagesFromRecords,
1083
1209
  };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@qearlyao/familiar",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@qearlyao/familiar",
9
- "version": "0.2.3",
9
+ "version": "0.2.4",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@earendil-works/pi-agent-core": "0.75.5",