@qearlyao/familiar 0.2.5 → 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 (123) hide show
  1. package/HEARTBEAT.md +1 -1
  2. package/README.md +33 -0
  3. package/config.example.toml +4 -2
  4. package/dist/{agent.js → agent/factory.js} +97 -328
  5. package/dist/agent/payload-normalizers.js +52 -0
  6. package/dist/agent/session-helpers.js +86 -0
  7. package/dist/agent/tool-descriptions.js +4 -0
  8. package/dist/agent/tools.js +30 -0
  9. package/dist/agent/transcript-log.js +93 -0
  10. package/dist/cli.js +45 -15
  11. package/dist/config/enums.js +35 -0
  12. package/dist/{config.js → config/index.js} +9 -272
  13. package/dist/config/interpolate.js +15 -0
  14. package/dist/config/model-refs.js +11 -0
  15. package/dist/{config-overrides.js → config/overrides.js} +1 -1
  16. package/dist/config/readers.js +116 -0
  17. package/dist/{config-registry.js → config/registry.js} +27 -8
  18. package/dist/config/sections.js +113 -0
  19. package/dist/{settings.js → config/settings.js} +5 -2
  20. package/dist/config/types.js +1 -0
  21. package/dist/{chat-log.js → conversation/chat-log.js} +16 -4
  22. package/dist/{contact-note.js → conversation/contact-note.js} +1 -1
  23. package/dist/conversation/ids.js +11 -0
  24. package/dist/conversation/owner-identity.js +29 -0
  25. package/dist/discord/channel.js +32 -0
  26. package/dist/discord/chunking.js +163 -0
  27. package/dist/discord/client.js +44 -0
  28. package/dist/discord/commands.js +181 -0
  29. package/dist/discord/daemon.js +379 -0
  30. package/dist/discord/inbound.js +44 -0
  31. package/dist/discord/send.js +115 -0
  32. package/dist/discord/turn.js +55 -0
  33. package/dist/index.js +12 -11
  34. package/dist/lifecycle/control.js +1 -0
  35. package/dist/{data-retention.js → lifecycle/data-retention.js} +1 -1
  36. package/dist/{hot-reload.js → lifecycle/hot-reload.js} +2 -2
  37. package/dist/{service.js → lifecycle/service.js} +1 -0
  38. package/dist/media/attachment-limits.js +3 -0
  39. package/dist/{generated-media.js → media/generated-media.js} +1 -1
  40. package/dist/{image-gen.js → media/image-gen.js} +2 -2
  41. package/dist/{inbound-attachments.js → media/inbound-attachments.js} +47 -43
  42. package/dist/media/media-understanding.js +215 -0
  43. package/dist/memory/index/store.js +21 -17
  44. package/dist/memory/index/vector-codec.js +2 -2
  45. package/dist/memory/lcm/context-transformer.js +6 -2
  46. package/dist/memory/lcm/segment-manager.js +6 -2
  47. package/dist/memory/lcm/store/index-ids.js +6 -0
  48. package/dist/memory/lcm/store/inserts.js +31 -0
  49. package/dist/memory/lcm/store/normalizers.js +91 -0
  50. package/dist/memory/lcm/store/row-mappers.js +114 -0
  51. package/dist/memory/lcm/store/row-types.js +1 -0
  52. package/dist/memory/lcm/store/serialization.js +37 -0
  53. package/dist/memory/lcm/store/snapshots.js +73 -0
  54. package/dist/memory/lcm/store.js +20 -360
  55. package/dist/memory/lcm/summarizer.js +1 -1
  56. package/dist/{added-models.js → models/added-models.js} +1 -1
  57. package/dist/{persona.js → prompting/persona.js} +1 -1
  58. package/dist/runtime/agent-core.js +82 -0
  59. package/dist/{agent-events.js → runtime/agent-events.js} +1 -1
  60. package/dist/runtime/agent-work-queue.js +55 -0
  61. package/dist/{runtime.js → runtime/conversation-runtime.js} +91 -43
  62. package/dist/runtime/runtime-manager.js +51 -0
  63. package/dist/runtime/scheduler-runner.js +243 -0
  64. package/dist/{scheduler.js → runtime/scheduler.js} +4 -4
  65. package/dist/{browser-tools.js → tools/browser-tools.js} +24 -34
  66. package/dist/util/fs.js +2 -1
  67. package/dist/web/agent-routes.js +104 -0
  68. package/dist/web/auth-routes.js +39 -0
  69. package/dist/web/auth.js +205 -0
  70. package/dist/web/config-routes.js +55 -0
  71. package/dist/web/conversation-routes.js +122 -0
  72. package/dist/web/daemon.js +108 -0
  73. package/dist/web/diary-routes.js +88 -0
  74. package/dist/web/errors.js +3 -0
  75. package/dist/web/event-hub.js +246 -0
  76. package/dist/{web-http.js → web/http.js} +19 -5
  77. package/dist/web/memes.js +25 -0
  78. package/dist/web/messages.js +348 -0
  79. package/dist/web/multipart.js +86 -0
  80. package/dist/web/payloads.js +34 -0
  81. package/dist/web/request-context.js +25 -0
  82. package/dist/web/route-helpers.js +9 -0
  83. package/dist/web/routes.js +37 -0
  84. package/dist/web/runtime-actions.js +231 -0
  85. package/dist/web/session-store.js +161 -0
  86. package/dist/{web-static.js → web/static.js} +19 -14
  87. package/dist/web/stream.js +78 -0
  88. package/dist/web-tools/cache.js +42 -0
  89. package/dist/web-tools/config.js +16 -0
  90. package/dist/web-tools/fetch-providers.js +119 -0
  91. package/dist/web-tools/format.js +88 -0
  92. package/dist/web-tools/http.js +81 -0
  93. package/dist/web-tools/index.js +152 -0
  94. package/dist/web-tools/routing.js +29 -0
  95. package/dist/web-tools/safety.js +73 -0
  96. package/dist/web-tools/search-providers.js +277 -0
  97. package/dist/web-tools/types.js +54 -0
  98. package/dist/web-tools/util.js +23 -0
  99. package/npm-shrinkwrap.json +319 -201
  100. package/package.json +6 -4
  101. package/web/dist/assets/index-C-k4O5Dz.js +6 -0
  102. package/web/dist/assets/index-Dj-L9nX4.css +2 -0
  103. package/web/dist/assets/markdown-kaIeGxdv.js +14 -0
  104. package/web/dist/assets/react-Bi_azaFt.js +9 -0
  105. package/web/dist/assets/rolldown-runtime-S-ySWqyJ.js +1 -0
  106. package/web/dist/assets/ui-C12-nN_X.js +51 -0
  107. package/web/dist/assets/vendor-D1QXMhXm.js +16 -0
  108. package/web/dist/index.html +11 -3
  109. package/dist/discord.js +0 -1299
  110. package/dist/media-understanding.js +0 -120
  111. package/dist/web-auth.js +0 -111
  112. package/dist/web-tools.js +0 -941
  113. package/dist/web.js +0 -1209
  114. package/web/dist/assets/index-B23WT77N.js +0 -63
  115. package/web/dist/assets/index-D3MotFzN.css +0 -2
  116. /package/dist/{control.js → agent/types.js} +0 -0
  117. /package/dist/{image-derivatives.js → media/image-derivatives.js} +0 -0
  118. /package/dist/{tts.js → media/tts.js} +0 -0
  119. /package/dist/{models.js → models/index.js} +0 -0
  120. /package/dist/{skills.js → prompting/skills.js} +0 -0
  121. /package/dist/{silent-marker.js → runtime/silent-marker.js} +0 -0
  122. /package/dist/{web-events.js → web/events.js} +0 -0
  123. /package/dist/{web-types.js → web/types.js} +0 -0
@@ -0,0 +1,108 @@
1
+ import { createServer } from "node:http";
2
+ import { refreshContactNote, setContactNotePath } from "../conversation/contact-note.js";
3
+ import { setAddedModelsPath } from "../models/added-models.js";
4
+ import { loadPersona, parsePersonaName } from "../prompting/persona.js";
5
+ import { registerWebAgentRoutes } from "./agent-routes.js";
6
+ import { createAuth, loadWebSessionStore } from "./auth.js";
7
+ import { registerWebAuthRoutes } from "./auth-routes.js";
8
+ import { registerWebConfigRoutes } from "./config-routes.js";
9
+ import { registerWebConversationRoutes } from "./conversation-routes.js";
10
+ import { registerWebDiaryRoutes } from "./diary-routes.js";
11
+ import { createWebEventHub } from "./event-hub.js";
12
+ import { HttpError, sendText } from "./http.js";
13
+ import { createWebRouteRegistry } from "./routes.js";
14
+ import { createWebRuntimeActions } from "./runtime-actions.js";
15
+ import { serveStatic } from "./static.js";
16
+ import { attachWebSocketStream } from "./stream.js";
17
+ export async function startWebDaemon(config, familiarAgent, agentCore, options = {}) {
18
+ setAddedModelsPath(config.workspace.dataDir);
19
+ setContactNotePath(config.persona.contact);
20
+ await refreshContactNote();
21
+ const persona = await loadPersona(config);
22
+ const personaName = parsePersonaName(persona.soul);
23
+ const webSessions = await loadWebSessionStore(config);
24
+ const auth = createAuth(config, webSessions);
25
+ const eventHub = createWebEventHub(config, personaName);
26
+ const getRuntime = async (channelKey) => {
27
+ if (!agentCore.hasSessionSource())
28
+ throw new HttpError(503, "Owner identity is not established yet.");
29
+ const runtime = await agentCore.getRuntimeForWebChannel(channelKey);
30
+ eventHub.subscribeRuntime(runtime);
31
+ return runtime;
32
+ };
33
+ const subscribeKnownRuntimes = async () => {
34
+ if (!agentCore.hasSessionSource())
35
+ return;
36
+ const sessions = await agentCore.getWebSessions();
37
+ await Promise.all(sessions.map(async (session) => {
38
+ const runtime = await agentCore.getRuntimeForWebChannel(session.key);
39
+ eventHub.subscribeRuntime(runtime);
40
+ }));
41
+ };
42
+ const { route, handleApi } = createWebRouteRegistry(config, auth);
43
+ const actions = createWebRuntimeActions({
44
+ config,
45
+ familiarAgent,
46
+ agentCore,
47
+ eventHub,
48
+ personaName,
49
+ restart: options.restart,
50
+ });
51
+ registerWebAuthRoutes(route, auth, { authMode: config.web.authMode, personaName });
52
+ registerWebConversationRoutes({
53
+ route,
54
+ config,
55
+ auth,
56
+ authMode: config.web.authMode,
57
+ agentCore,
58
+ getRuntime,
59
+ personaName,
60
+ actions,
61
+ });
62
+ registerWebAgentRoutes({
63
+ route,
64
+ config,
65
+ familiarAgent,
66
+ getRuntime,
67
+ personaName,
68
+ publish: eventHub.publish,
69
+ });
70
+ registerWebConfigRoutes(route, config, agentCore);
71
+ registerWebDiaryRoutes(route, config);
72
+ await subscribeKnownRuntimes();
73
+ const server = createServer((request, response) => {
74
+ const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
75
+ void handleApi(request, response, url).then(async (handled) => {
76
+ if (handled)
77
+ return;
78
+ if (await serveStatic(response, url.pathname))
79
+ return;
80
+ sendText(response, 404, "Not found");
81
+ });
82
+ });
83
+ attachWebSocketStream(server, {
84
+ authorize: (request, pathname) => auth.authorize(request, pathname),
85
+ eventHub,
86
+ getRuntime,
87
+ abort: (runtime) => familiarAgent.abort(runtime.channelKey),
88
+ retry: actions.retryLatestAssistant,
89
+ deleteLatest: actions.deleteLatestAssistant,
90
+ });
91
+ await new Promise((resolveListen, rejectListen) => {
92
+ server.once("error", rejectListen);
93
+ server.listen(config.web.port, config.web.bindAddress, () => {
94
+ server.off("error", rejectListen);
95
+ resolveListen();
96
+ });
97
+ });
98
+ console.log(`Web side-door listening on http://${config.web.bindAddress}:${config.web.port}`);
99
+ return {
100
+ server,
101
+ async stop() {
102
+ eventHub.stop();
103
+ await new Promise((resolveClose, rejectClose) => {
104
+ server.close((error) => (error ? rejectClose(error) : resolveClose()));
105
+ });
106
+ },
107
+ };
108
+ }
@@ -0,0 +1,88 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import { basename, resolve } from "node:path";
3
+ import { DIARY_INDEX_FILE_RE, listDiaryMarkdownFiles } from "../memory/diary/indexer.js";
4
+ import { isEnoent } from "../util/fs.js";
5
+ import { HttpError, sendJson } from "./http.js";
6
+ const FRONTMATTER_RE = /^\s*---\r?\n[\s\S]*?\r?\n---\s*(?:\r?\n|$)/;
7
+ const HEADING_RE = /^#{1,6}\s+(.+?)\s*$/m;
8
+ const DIARY_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
9
+ export function registerWebDiaryRoutes(route, config) {
10
+ route("GET", "/api/web/diaries", async (_request, response) => {
11
+ sendJson(response, 200, { diaries: await listWebDiaries(config) });
12
+ });
13
+ route("GET", "/api/web/diary", async (_request, response, url) => {
14
+ const date = url.searchParams.get("date") ?? "";
15
+ sendJson(response, 200, { diary: await readWebDiary(config, date) });
16
+ });
17
+ }
18
+ export async function listWebDiaries(config) {
19
+ const paths = await listDiaryMarkdownFiles(config);
20
+ const summaries = await Promise.all(paths.map(async (path) => toDiarySummary(await readDiaryPayload(path))));
21
+ return summaries.sort((a, b) => b.date.localeCompare(a.date));
22
+ }
23
+ export async function readWebDiary(config, date) {
24
+ if (!DIARY_DATE_RE.test(date))
25
+ throw new HttpError(400, "diary date must be YYYY-MM-DD");
26
+ const path = resolve(config.memory.diariesDir, `${date}.md`);
27
+ try {
28
+ return await readDiaryPayload(path);
29
+ }
30
+ catch (error) {
31
+ if (isEnoent(error))
32
+ throw new HttpError(404, "diary not found");
33
+ throw error;
34
+ }
35
+ }
36
+ async function readDiaryPayload(path) {
37
+ const sourceId = basename(path);
38
+ if (!DIARY_INDEX_FILE_RE.test(sourceId)) {
39
+ throw new HttpError(400, "diary file must be named YYYY-MM-DD.md");
40
+ }
41
+ const fileStat = await stat(path);
42
+ if (!fileStat.isFile())
43
+ throw new HttpError(404, "diary not found");
44
+ const markdown = await readFile(path, "utf8");
45
+ const content = stripFrontmatter(markdown).trim();
46
+ const date = sourceId.slice(0, -".md".length);
47
+ return {
48
+ date,
49
+ sourceId,
50
+ title: diaryTitle(content, date),
51
+ excerpt: diaryExcerpt(content),
52
+ mtimeMs: Math.floor(fileStat.mtimeMs),
53
+ sizeBytes: fileStat.size,
54
+ content,
55
+ };
56
+ }
57
+ function stripFrontmatter(markdown) {
58
+ return markdown.replace(FRONTMATTER_RE, "");
59
+ }
60
+ function toDiarySummary(entry) {
61
+ return {
62
+ date: entry.date,
63
+ sourceId: entry.sourceId,
64
+ title: entry.title,
65
+ excerpt: entry.excerpt,
66
+ mtimeMs: entry.mtimeMs,
67
+ sizeBytes: entry.sizeBytes,
68
+ };
69
+ }
70
+ function diaryTitle(content, date) {
71
+ return stripInlineMarkdown(HEADING_RE.exec(content)?.[1]?.trim() || date);
72
+ }
73
+ function diaryExcerpt(content) {
74
+ const body = content
75
+ .split(/\r?\n/)
76
+ .filter((line) => !HEADING_RE.test(line))
77
+ .join(" ")
78
+ .replace(/\s+/g, " ")
79
+ .trim();
80
+ return stripInlineMarkdown(body).slice(0, 180);
81
+ }
82
+ function stripInlineMarkdown(value) {
83
+ return value
84
+ .replace(/`([^`]+)`/g, "$1")
85
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
86
+ .replace(/[*_~#>]/g, "")
87
+ .trim();
88
+ }
@@ -0,0 +1,3 @@
1
+ export function errorMessage(error) {
2
+ return error instanceof Error ? error.message : String(error);
3
+ }
@@ -0,0 +1,246 @@
1
+ import { getContactNickname } from "../conversation/contact-note.js";
2
+ import { eventId, toUnixMs } from "../conversation/ids.js";
3
+ import { consumeSilentDelta, createSilentFilterState, finalizeSilentFilter } from "../runtime/silent-marker.js";
4
+ import { encodeFrame, replayEvents } from "./events.js";
5
+ import { toolFromStoredAgentEvent, webAttachments } from "./messages.js";
6
+ import { EVENT_REPLAY_LIMIT, WEB_USER_NAME } from "./types.js";
7
+ const IN_FLIGHT_TTL_MS = 10 * 60 * 1000;
8
+ export function createWebEventHub(config, personaName) {
9
+ const clients = new Set();
10
+ const eventsByChannel = new Map();
11
+ const runtimeSubscriptions = new Map();
12
+ const inFlightMessages = new Map();
13
+ const getOrCreateInFlight = (messageIdValue) => {
14
+ let entry = inFlightMessages.get(messageIdValue);
15
+ if (!entry) {
16
+ entry = { locallyStreamed: false, startedSilent: false, lastActiveAt: Date.now() };
17
+ inFlightMessages.set(messageIdValue, entry);
18
+ }
19
+ else {
20
+ entry.lastActiveAt = Date.now();
21
+ }
22
+ return entry;
23
+ };
24
+ const touchInFlight = (messageIdValue) => {
25
+ const entry = inFlightMessages.get(messageIdValue);
26
+ if (entry)
27
+ entry.lastActiveAt = Date.now();
28
+ };
29
+ const inFlightGcTimer = setInterval(() => {
30
+ const cutoff = Date.now() - IN_FLIGHT_TTL_MS;
31
+ for (const [id, entry] of inFlightMessages) {
32
+ if (entry.lastActiveAt < cutoff)
33
+ inFlightMessages.delete(id);
34
+ }
35
+ }, 60 * 1000);
36
+ inFlightGcTimer.unref?.();
37
+ const publish = (event) => {
38
+ const fullEvent = { ...event, eventId: eventId(), ts: event.ts ?? Date.now() };
39
+ const events = eventsByChannel.get(fullEvent.channelKey ?? "") ?? [];
40
+ events.push(fullEvent);
41
+ if (events.length > EVENT_REPLAY_LIMIT)
42
+ events.shift();
43
+ eventsByChannel.set(fullEvent.channelKey ?? "", events);
44
+ const frame = encodeFrame(JSON.stringify(fullEvent));
45
+ for (const client of clients) {
46
+ if (client.channelKey === fullEvent.channelKey && !client.socket.destroyed) {
47
+ if (client.authed) {
48
+ client.socket.write(frame);
49
+ }
50
+ else {
51
+ const pendingEvents = client.pendingEvents ?? [];
52
+ pendingEvents.push(fullEvent);
53
+ client.pendingEvents = pendingEvents;
54
+ }
55
+ }
56
+ }
57
+ return fullEvent;
58
+ };
59
+ const publishDelta = (channelKey, messageIdValue, part, text, ts) => publish({ type: "delta", channelKey, messageId: messageIdValue, part, content: text, text, ts });
60
+ const publishStoredAgentEvent = (channelKey, messageIdValue, storedEvent, ts) => {
61
+ touchInFlight(messageIdValue);
62
+ if (storedEvent.type === "message_start" && storedEvent.role === "assistant") {
63
+ const entry = getOrCreateInFlight(messageIdValue);
64
+ entry.locallyStreamed = true;
65
+ entry.silentFilter = createSilentFilterState();
66
+ entry.pendingStartTs = ts;
67
+ entry.startedSilent = false;
68
+ }
69
+ const startedSilentMessage = () => {
70
+ const entry = inFlightMessages.get(messageIdValue);
71
+ if (!entry || entry.startedSilent)
72
+ return false;
73
+ const startTs = entry.pendingStartTs;
74
+ entry.pendingStartTs = undefined;
75
+ entry.startedSilent = true;
76
+ publish({
77
+ type: "message_started",
78
+ channelKey,
79
+ messageId: messageIdValue,
80
+ role: "assistant",
81
+ who: personaName,
82
+ ts: startTs,
83
+ });
84
+ return true;
85
+ };
86
+ if (storedEvent.type === "message_update") {
87
+ const assistantEvent = storedEvent.assistantMessageEvent;
88
+ if (assistantEvent.type === "thinking_delta") {
89
+ startedSilentMessage();
90
+ publishDelta(channelKey, messageIdValue, "thinking", assistantEvent.delta, ts);
91
+ }
92
+ if (assistantEvent.type === "text_delta") {
93
+ const filter = inFlightMessages.get(messageIdValue)?.silentFilter;
94
+ if (!filter) {
95
+ startedSilentMessage();
96
+ publishDelta(channelKey, messageIdValue, "text", assistantEvent.delta, ts);
97
+ }
98
+ else {
99
+ const result = consumeSilentDelta(filter, assistantEvent.delta);
100
+ if (result.kind === "emit" && result.text) {
101
+ startedSilentMessage();
102
+ publishDelta(channelKey, messageIdValue, "text", result.text, ts);
103
+ }
104
+ }
105
+ }
106
+ }
107
+ if (storedEvent.type === "tool_execution_start") {
108
+ startedSilentMessage();
109
+ }
110
+ if (storedEvent.type === "message_end" && storedEvent.role === "assistant") {
111
+ const entry = inFlightMessages.get(messageIdValue);
112
+ const filter = entry?.silentFilter;
113
+ let silent = false;
114
+ if (filter && entry) {
115
+ const final = finalizeSilentFilter(filter);
116
+ silent = final.silent;
117
+ if (!silent) {
118
+ startedSilentMessage();
119
+ if (final.flush) {
120
+ publishDelta(channelKey, messageIdValue, "text", final.flush, ts);
121
+ }
122
+ }
123
+ else {
124
+ entry.startedSilent = true;
125
+ entry.pendingStartTs = undefined;
126
+ }
127
+ entry.silentFilter = undefined;
128
+ }
129
+ else {
130
+ startedSilentMessage();
131
+ }
132
+ publish({
133
+ type: "message_completed",
134
+ channelKey,
135
+ messageId: messageIdValue,
136
+ usage: storedEvent.usage,
137
+ silent: silent || undefined,
138
+ ts,
139
+ });
140
+ if (storedEvent.errorMessage) {
141
+ publish({
142
+ type: "model_error",
143
+ channelKey,
144
+ messageId: messageIdValue,
145
+ message: storedEvent.errorMessage,
146
+ ts,
147
+ });
148
+ }
149
+ }
150
+ const tool = toolFromStoredAgentEvent(storedEvent, ts ?? Date.now());
151
+ if (tool)
152
+ publish({ type: "tool_event", channelKey, messageId: messageIdValue, tool, ts });
153
+ };
154
+ const subscribeRuntime = (runtime) => {
155
+ if (runtimeSubscriptions.has(runtime.channelKey))
156
+ return;
157
+ const unsubscribeRecords = runtime.subscribe((record) => {
158
+ if (record.type === "inbound") {
159
+ publish({
160
+ type: "message_started",
161
+ channelKey: runtime.channelKey,
162
+ messageId: record.messageId,
163
+ role: "user",
164
+ who: record.authorName || getContactNickname(WEB_USER_NAME),
165
+ ts: toUnixMs(record.ts),
166
+ });
167
+ publishDelta(runtime.channelKey, record.messageId, "text", record.text, toUnixMs(record.ts));
168
+ publish({
169
+ type: "message_completed",
170
+ channelKey: runtime.channelKey,
171
+ messageId: record.messageId,
172
+ attachments: webAttachments(config, record.attachments),
173
+ ts: toUnixMs(record.ts),
174
+ });
175
+ }
176
+ if (record.type === "outbound" && !record.control) {
177
+ const outboundId = record.webMessageId || record.messageIds[0] || `out_${record.recordId}`;
178
+ const completion = {
179
+ type: "message_completed",
180
+ channelKey: runtime.channelKey,
181
+ messageId: outboundId,
182
+ thinkingMs: record.thinkingMs,
183
+ attachments: webAttachments(config, record.attachments),
184
+ silent: record.silent || undefined,
185
+ ts: toUnixMs(record.ts),
186
+ };
187
+ if (inFlightMessages.get(outboundId)?.locallyStreamed) {
188
+ inFlightMessages.delete(outboundId);
189
+ publish(completion);
190
+ return;
191
+ }
192
+ if (!record.silent) {
193
+ publish({
194
+ type: "message_started",
195
+ channelKey: runtime.channelKey,
196
+ messageId: outboundId,
197
+ role: "assistant",
198
+ who: personaName,
199
+ ts: toUnixMs(record.ts),
200
+ });
201
+ if (record.thinking)
202
+ publishDelta(runtime.channelKey, outboundId, "thinking", record.thinking, toUnixMs(record.ts));
203
+ if (record.text)
204
+ publishDelta(runtime.channelKey, outboundId, "text", record.text, toUnixMs(record.ts));
205
+ }
206
+ publish(completion);
207
+ }
208
+ });
209
+ const unsubscribeAgentEvents = runtime.subscribeAgentEvents((agentEvent) => {
210
+ publishStoredAgentEvent(runtime.channelKey, agentEvent.messageId, agentEvent.event, agentEvent.ts);
211
+ });
212
+ runtimeSubscriptions.set(runtime.channelKey, () => {
213
+ unsubscribeRecords();
214
+ unsubscribeAgentEvents();
215
+ });
216
+ };
217
+ return {
218
+ publish,
219
+ publishDelta,
220
+ async appendAndPublishError(runtime, message) {
221
+ await runtime.appendError(message);
222
+ publish({ type: "error", channelKey: runtime.channelKey, code: "unknown", message });
223
+ },
224
+ markLocallyStreamed(messageIdValue) {
225
+ getOrCreateInFlight(messageIdValue).locallyStreamed = true;
226
+ },
227
+ subscribeRuntime,
228
+ replay(client, channelKey, lastEventId) {
229
+ const events = eventsByChannel.get(channelKey) ?? [];
230
+ replayEvents(client, events, lastEventId, () => publish({ type: "replay_window_lost", channelKey }));
231
+ },
232
+ registerClient(client) {
233
+ clients.add(client);
234
+ return () => clients.delete(client);
235
+ },
236
+ stop() {
237
+ clearInterval(inFlightGcTimer);
238
+ for (const client of clients)
239
+ client.socket.destroy();
240
+ clients.clear();
241
+ for (const unsubscribe of runtimeSubscriptions.values())
242
+ unsubscribe();
243
+ runtimeSubscriptions.clear();
244
+ },
245
+ };
246
+ }
@@ -1,4 +1,14 @@
1
1
  export const MAX_BODY_BYTES = 64 * 1024;
2
+ // Thrown where a request is malformed so the dispatcher can answer with the right
3
+ // client-error status instead of a blanket 500.
4
+ export class HttpError extends Error {
5
+ status;
6
+ constructor(status, message) {
7
+ super(message);
8
+ this.status = status;
9
+ this.name = "HttpError";
10
+ }
11
+ }
2
12
  export function sendJson(response, status, body, headers = {}) {
3
13
  response.writeHead(status, {
4
14
  "content-type": "application/json; charset=utf-8",
@@ -18,12 +28,16 @@ export async function readJsonBody(request) {
18
28
  const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
19
29
  total += buffer.length;
20
30
  if (total > MAX_BODY_BYTES)
21
- throw new Error("Request body too large");
31
+ throw new HttpError(413, "Request body too large");
22
32
  chunks.push(buffer);
23
33
  }
24
34
  const raw = Buffer.concat(chunks).toString("utf8").trim();
25
- return raw ? JSON.parse(raw) : {};
26
- }
27
- export function isObject(value) {
28
- return !!value && typeof value === "object" && !Array.isArray(value);
35
+ if (!raw)
36
+ return {};
37
+ try {
38
+ return JSON.parse(raw);
39
+ }
40
+ catch {
41
+ throw new HttpError(400, "Request body must be valid JSON");
42
+ }
29
43
  }
@@ -0,0 +1,25 @@
1
+ import { join } from "node:path";
2
+ export function parseMemeCatalog(markdown) {
3
+ const families = [];
4
+ let currentFamily;
5
+ for (const line of markdown.split(/\r?\n/)) {
6
+ const familyMatch = line.match(/^## (.+)$/);
7
+ if (familyMatch) {
8
+ currentFamily = { name: familyMatch[1]?.trim() ?? "", memes: [] };
9
+ families.push(currentFamily);
10
+ continue;
11
+ }
12
+ if (!currentFamily || !line.startsWith("- ") || !line.includes(" — "))
13
+ continue;
14
+ const separator = line.indexOf(" — ");
15
+ const name = line.slice(2, separator).trim();
16
+ const suffix = line.slice(separator + " — ".length).trim();
17
+ if (!name || !suffix)
18
+ continue;
19
+ currentFamily.memes.push({ name, url: `https://files.catbox.moe/${suffix}` });
20
+ }
21
+ return families;
22
+ }
23
+ export function memeCatalogPath(config) {
24
+ return join(config.workspacePath, "skills", "memes", "SKILL.md");
25
+ }