@qearlyao/familiar 0.1.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.
- package/.env.example +31 -0
- package/HEARTBEAT.md +23 -0
- package/LICENSE +21 -0
- package/MEMORY.md +1 -0
- package/README.md +245 -0
- package/SOUL.md +13 -0
- package/USER.md +13 -0
- package/config.example.toml +221 -0
- package/dist/agent-events.js +167 -0
- package/dist/agent.js +590 -0
- package/dist/browser-tools.js +638 -0
- package/dist/chat-log.js +130 -0
- package/dist/cli.js +168 -0
- package/dist/config.js +804 -0
- package/dist/data-retention.js +54 -0
- package/dist/discord.js +1203 -0
- package/dist/generated-media.js +86 -0
- package/dist/image-derivatives.js +102 -0
- package/dist/image-gen.js +440 -0
- package/dist/inbound-attachments.js +266 -0
- package/dist/index.js +10 -0
- package/dist/media-understanding.js +120 -0
- package/dist/memory/diary/ambient-injector.js +180 -0
- package/dist/memory/diary/ambient.js +124 -0
- package/dist/memory/diary/chunks.js +231 -0
- package/dist/memory/diary/index.js +3 -0
- package/dist/memory/diary/indexer.js +93 -0
- package/dist/memory/doctor.js +250 -0
- package/dist/memory/index/chunk-indexer.js +151 -0
- package/dist/memory/index/embedding-provider.js +119 -0
- package/dist/memory/index/fts-query.js +18 -0
- package/dist/memory/index/retrieval.js +246 -0
- package/dist/memory/index/schema.js +157 -0
- package/dist/memory/index/store.js +513 -0
- package/dist/memory/index/vec.js +72 -0
- package/dist/memory/index/vector-codec.js +27 -0
- package/dist/memory/lcm/backfill.js +247 -0
- package/dist/memory/lcm/condense.js +146 -0
- package/dist/memory/lcm/context-transformer.js +662 -0
- package/dist/memory/lcm/context.js +421 -0
- package/dist/memory/lcm/eviction-score.js +38 -0
- package/dist/memory/lcm/index.js +6 -0
- package/dist/memory/lcm/indexer.js +200 -0
- package/dist/memory/lcm/normalize.js +235 -0
- package/dist/memory/lcm/schema.js +188 -0
- package/dist/memory/lcm/segment-manager.js +136 -0
- package/dist/memory/lcm/store.js +722 -0
- package/dist/memory/lcm/summarizer.js +258 -0
- package/dist/memory/lcm/types.js +1 -0
- package/dist/memory/operator.js +477 -0
- package/dist/memory/service.js +202 -0
- package/dist/memory/tools.js +205 -0
- package/dist/models.js +165 -0
- package/dist/persona.js +54 -0
- package/dist/runtime.js +493 -0
- package/dist/scheduler.js +200 -0
- package/dist/settings.js +116 -0
- package/dist/skills.js +38 -0
- package/dist/tts.js +143 -0
- package/dist/web-auth.js +105 -0
- package/dist/web-events.js +114 -0
- package/dist/web-http.js +29 -0
- package/dist/web-static.js +106 -0
- package/dist/web-tools.js +940 -0
- package/dist/web-types.js +2 -0
- package/dist/web.js +844 -0
- package/package.json +60 -0
- package/web/dist/assets/index-ClgkMgaq.css +2 -0
- package/web/dist/assets/index-Cu2QquuR.js +59 -0
- package/web/dist/favicon.svg +1 -0
- package/web/dist/icons.svg +24 -0
- package/web/dist/index.html +20 -0
package/dist/web.js
ADDED
|
@@ -0,0 +1,844 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { createAgentEventRecorder, storedAgentEventFromAgentEvent, thinkingDurationMs, updateAgentEventSummary, } from "./agent-events.js";
|
|
4
|
+
import { publicAttachmentPath } from "./generated-media.js";
|
|
5
|
+
import { materializeInboundAttachments } from "./inbound-attachments.js";
|
|
6
|
+
import { supportedThinkingLevels } from "./models.js";
|
|
7
|
+
import { loadPersona, parsePersonaName } from "./persona.js";
|
|
8
|
+
import { createAuth, sessionCookie, verifyTotp } from "./web-auth.js";
|
|
9
|
+
import { acceptWebSocket, decodeFrames, encodeFrame, replayEvents } from "./web-events.js";
|
|
10
|
+
import { isObject, readJsonBody, sendJson, sendText } from "./web-http.js";
|
|
11
|
+
import { serveAttachment, serveStatic } from "./web-static.js";
|
|
12
|
+
import { EVENT_REPLAY_LIMIT, WEB_USER_NAME, } from "./web-types.js";
|
|
13
|
+
function toUnixMs(ts) {
|
|
14
|
+
const parsed = ts ? Date.parse(ts) : NaN;
|
|
15
|
+
return Number.isFinite(parsed) ? parsed : Date.now();
|
|
16
|
+
}
|
|
17
|
+
function eventId() {
|
|
18
|
+
return `evt_${randomUUID()}`;
|
|
19
|
+
}
|
|
20
|
+
function messageId(prefix = "msg") {
|
|
21
|
+
return `${prefix}_${randomUUID()}`;
|
|
22
|
+
}
|
|
23
|
+
function isUserVisibleRuntimeRecord(record) {
|
|
24
|
+
return record.type !== "runtime" || !["armed", "reset", "stopped"].includes(record.event);
|
|
25
|
+
}
|
|
26
|
+
function isWebUploadAttachment(value) {
|
|
27
|
+
return !!value && typeof value === "object" && Buffer.isBuffer(value.buffer);
|
|
28
|
+
}
|
|
29
|
+
async function readRawBody(request, maxBytes) {
|
|
30
|
+
const chunks = [];
|
|
31
|
+
let total = 0;
|
|
32
|
+
for await (const chunk of request) {
|
|
33
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
34
|
+
total += buffer.length;
|
|
35
|
+
if (total > maxBytes)
|
|
36
|
+
throw new Error("Request body too large");
|
|
37
|
+
chunks.push(buffer);
|
|
38
|
+
}
|
|
39
|
+
return Buffer.concat(chunks);
|
|
40
|
+
}
|
|
41
|
+
function multipartBoundary(contentType) {
|
|
42
|
+
const header = Array.isArray(contentType) ? contentType.find((value) => value.includes("boundary=")) : contentType;
|
|
43
|
+
const match = header?.match(/boundary=(?:"([^"]+)"|([^;]+))/i);
|
|
44
|
+
if (!match?.[1] && !match?.[2])
|
|
45
|
+
throw new Error("Missing multipart boundary");
|
|
46
|
+
return match[1] ?? match[2] ?? "";
|
|
47
|
+
}
|
|
48
|
+
function parseContentDisposition(header) {
|
|
49
|
+
const parts = header.split(";").map((part) => part.trim());
|
|
50
|
+
const values = {};
|
|
51
|
+
for (const part of parts.slice(1)) {
|
|
52
|
+
const [key, rawValue] = part.split("=");
|
|
53
|
+
if (!key || rawValue === undefined)
|
|
54
|
+
continue;
|
|
55
|
+
values[key.toLowerCase()] = rawValue.replace(/^"|"$/g, "");
|
|
56
|
+
}
|
|
57
|
+
return values;
|
|
58
|
+
}
|
|
59
|
+
async function readMultipartBody(request, contentType) {
|
|
60
|
+
const boundary = multipartBoundary(contentType);
|
|
61
|
+
const raw = await readRawBody(request, 32 * 1024 * 1024);
|
|
62
|
+
const binary = raw.toString("binary");
|
|
63
|
+
const marker = `--${boundary}`;
|
|
64
|
+
const attachments = [];
|
|
65
|
+
const body = { text: "" };
|
|
66
|
+
for (const section of binary.split(marker).slice(1)) {
|
|
67
|
+
if (!section || section === "--\r\n" || section === "--")
|
|
68
|
+
continue;
|
|
69
|
+
const trimmed = section.replace(/^\r\n/, "").replace(/\r\n--$/, "");
|
|
70
|
+
const headerEnd = trimmed.indexOf("\r\n\r\n");
|
|
71
|
+
if (headerEnd < 0)
|
|
72
|
+
continue;
|
|
73
|
+
const headerText = trimmed.slice(0, headerEnd);
|
|
74
|
+
let contentBinary = trimmed.slice(headerEnd + 4);
|
|
75
|
+
if (contentBinary.endsWith("\r\n"))
|
|
76
|
+
contentBinary = contentBinary.slice(0, -2);
|
|
77
|
+
const headers = Object.fromEntries(headerText.split("\r\n").map((line) => {
|
|
78
|
+
const colon = line.indexOf(":");
|
|
79
|
+
return colon >= 0
|
|
80
|
+
? [line.slice(0, colon).trim().toLowerCase(), line.slice(colon + 1).trim()]
|
|
81
|
+
: [line.toLowerCase(), ""];
|
|
82
|
+
}));
|
|
83
|
+
const disposition = parseContentDisposition(headers["content-disposition"] ?? "");
|
|
84
|
+
const name = disposition.name;
|
|
85
|
+
if (!name)
|
|
86
|
+
continue;
|
|
87
|
+
if (name === "text" || name === "channelKey" || name === "clientId") {
|
|
88
|
+
body[name] = Buffer.from(contentBinary, "binary").toString("utf8");
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (name !== "attachments")
|
|
92
|
+
continue;
|
|
93
|
+
const buffer = Buffer.from(contentBinary, "binary");
|
|
94
|
+
if (buffer.length === 0)
|
|
95
|
+
continue;
|
|
96
|
+
attachments.push({
|
|
97
|
+
name: disposition.filename,
|
|
98
|
+
mimeType: headers["content-type"],
|
|
99
|
+
size: buffer.length,
|
|
100
|
+
buffer,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
body.attachments = attachments;
|
|
104
|
+
return body;
|
|
105
|
+
}
|
|
106
|
+
function webAttachments(config, attachments) {
|
|
107
|
+
if (!attachments?.length)
|
|
108
|
+
return undefined;
|
|
109
|
+
return attachments.map((attachment) => ({
|
|
110
|
+
id: attachment.id,
|
|
111
|
+
name: attachment.name,
|
|
112
|
+
kind: attachment.kind,
|
|
113
|
+
mimeType: attachment.mimeType,
|
|
114
|
+
size: attachment.size,
|
|
115
|
+
url: attachment.localPath ? publicAttachmentPath(config, attachment.localPath) : attachment.remoteUrl,
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
function attachmentDerivedText(attachment) {
|
|
119
|
+
return attachment.derived?.text?.text;
|
|
120
|
+
}
|
|
121
|
+
function toolError(result) {
|
|
122
|
+
if (typeof result === "string")
|
|
123
|
+
return result;
|
|
124
|
+
if (!isObject(result))
|
|
125
|
+
return undefined;
|
|
126
|
+
if (typeof result.error === "string")
|
|
127
|
+
return result.error;
|
|
128
|
+
if (typeof result.message === "string")
|
|
129
|
+
return result.message;
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
function toolFromStoredAgentEvent(event, ts) {
|
|
133
|
+
if (event.type === "tool_execution_start") {
|
|
134
|
+
return {
|
|
135
|
+
id: event.toolCallId,
|
|
136
|
+
name: event.toolName,
|
|
137
|
+
status: "running",
|
|
138
|
+
args: event.args,
|
|
139
|
+
startedAt: ts,
|
|
140
|
+
updatedAt: ts,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
if (event.type === "tool_execution_update") {
|
|
144
|
+
return {
|
|
145
|
+
id: event.toolCallId,
|
|
146
|
+
name: event.toolName,
|
|
147
|
+
status: "running",
|
|
148
|
+
args: event.args,
|
|
149
|
+
partialResult: event.partialResult,
|
|
150
|
+
updatedAt: ts,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
if (event.type === "tool_execution_end") {
|
|
154
|
+
return {
|
|
155
|
+
id: event.toolCallId,
|
|
156
|
+
name: event.toolName,
|
|
157
|
+
status: event.isError ? "error" : "completed",
|
|
158
|
+
result: event.result,
|
|
159
|
+
error: event.isError ? toolError(event.result) : undefined,
|
|
160
|
+
completedAt: ts,
|
|
161
|
+
updatedAt: ts,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
if (event.type === "message_update" && event.assistantMessageEvent.type === "toolcall_end") {
|
|
165
|
+
return {
|
|
166
|
+
id: event.assistantMessageEvent.toolCall.id,
|
|
167
|
+
name: event.assistantMessageEvent.toolCall.name,
|
|
168
|
+
status: "pending",
|
|
169
|
+
args: event.assistantMessageEvent.toolCall.arguments,
|
|
170
|
+
updatedAt: ts,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
function mergeToolEvent(existing, patch) {
|
|
176
|
+
const terminal = patch.status === "completed" || patch.status === "error";
|
|
177
|
+
return {
|
|
178
|
+
...existing,
|
|
179
|
+
...patch,
|
|
180
|
+
args: patch.args ?? existing?.args,
|
|
181
|
+
partialResult: terminal ? undefined : (patch.partialResult ?? existing?.partialResult),
|
|
182
|
+
result: patch.result ?? existing?.result,
|
|
183
|
+
error: patch.error ?? existing?.error,
|
|
184
|
+
startedAt: existing?.startedAt ?? patch.startedAt,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
function applyStoredAgentEventToMessage(message, record, options) {
|
|
188
|
+
const event = record.event;
|
|
189
|
+
const ts = toUnixMs(record.ts);
|
|
190
|
+
if (event.type === "message_update") {
|
|
191
|
+
const assistantEvent = event.assistantMessageEvent;
|
|
192
|
+
if (assistantEvent.type === "text_delta") {
|
|
193
|
+
if (options.applyTextDeltas)
|
|
194
|
+
message.text += assistantEvent.delta;
|
|
195
|
+
}
|
|
196
|
+
if (assistantEvent.type === "thinking_delta" && options.applyThinkingDeltas) {
|
|
197
|
+
message.thinking = `${message.thinking ?? ""}${assistantEvent.delta}`;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (event.type === "message_end" && event.usage)
|
|
201
|
+
message.usage = event.usage;
|
|
202
|
+
const tool = toolFromStoredAgentEvent(event, ts);
|
|
203
|
+
if (tool) {
|
|
204
|
+
const tools = message.tools ?? [];
|
|
205
|
+
const index = tools.findIndex((candidate) => candidate.id === tool.id);
|
|
206
|
+
if (index >= 0) {
|
|
207
|
+
tools[index] = mergeToolEvent(tools[index], tool);
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
tools.push(tool);
|
|
211
|
+
}
|
|
212
|
+
message.tools = tools;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
function webMessagesFromRecords(config, records, assistantName) {
|
|
216
|
+
const messages = [];
|
|
217
|
+
const messagesById = new Map();
|
|
218
|
+
const pendingAgentEvents = new Map();
|
|
219
|
+
for (const record of records) {
|
|
220
|
+
const message = webMessageFromRecord(config, record, assistantName);
|
|
221
|
+
if (message) {
|
|
222
|
+
messages.push(message);
|
|
223
|
+
messagesById.set(message.id, message);
|
|
224
|
+
const pending = pendingAgentEvents.get(message.id) ?? [];
|
|
225
|
+
for (const pendingRecord of pending) {
|
|
226
|
+
applyStoredAgentEventToMessage(message, pendingRecord, {
|
|
227
|
+
applyTextDeltas: !message.text,
|
|
228
|
+
applyThinkingDeltas: !message.thinking,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
pendingAgentEvents.delete(message.id);
|
|
232
|
+
}
|
|
233
|
+
if (record.type === "agent_event") {
|
|
234
|
+
const existing = messagesById.get(record.messageId);
|
|
235
|
+
if (existing) {
|
|
236
|
+
applyStoredAgentEventToMessage(existing, record, {
|
|
237
|
+
applyTextDeltas: true,
|
|
238
|
+
applyThinkingDeltas: true,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
const pending = pendingAgentEvents.get(record.messageId) ?? [];
|
|
243
|
+
pending.push(record);
|
|
244
|
+
pendingAgentEvents.set(record.messageId, pending);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return messages;
|
|
249
|
+
}
|
|
250
|
+
function webMessageFromRecord(config, record, assistantName) {
|
|
251
|
+
if (!isUserVisibleRuntimeRecord(record))
|
|
252
|
+
return undefined;
|
|
253
|
+
if (record.type === "inbound") {
|
|
254
|
+
const attachmentText = record.attachments
|
|
255
|
+
.map((attachment) => attachmentDerivedText(attachment))
|
|
256
|
+
.filter((text) => !!text)
|
|
257
|
+
.join("\n");
|
|
258
|
+
return {
|
|
259
|
+
id: record.messageId,
|
|
260
|
+
role: "user",
|
|
261
|
+
who: record.authorName || WEB_USER_NAME,
|
|
262
|
+
text: [record.text, attachmentText].filter(Boolean).join("\n"),
|
|
263
|
+
attachments: webAttachments(config, record.attachments),
|
|
264
|
+
ts: toUnixMs(record.ts),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
if (record.type === "outbound" && !record.control) {
|
|
268
|
+
return {
|
|
269
|
+
id: record.webMessageId || record.messageIds[0] || `out_${record.recordId}`,
|
|
270
|
+
role: "assistant",
|
|
271
|
+
who: assistantName,
|
|
272
|
+
text: record.text,
|
|
273
|
+
attachments: webAttachments(config, record.attachments),
|
|
274
|
+
thinking: record.thinking,
|
|
275
|
+
thinkingMs: record.thinkingMs,
|
|
276
|
+
ts: toUnixMs(record.ts),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
if (record.type === "runtime" || record.type === "error") {
|
|
280
|
+
return {
|
|
281
|
+
id: `sys_${record.recordId}`,
|
|
282
|
+
role: "system",
|
|
283
|
+
who: "system",
|
|
284
|
+
text: record.type === "runtime" ? record.detail || record.event : record.message,
|
|
285
|
+
ts: toUnixMs(record.ts),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
return undefined;
|
|
289
|
+
}
|
|
290
|
+
function commandArgs(command, args) {
|
|
291
|
+
if (!isObject(args))
|
|
292
|
+
return "";
|
|
293
|
+
if (command === "model")
|
|
294
|
+
return typeof args.model === "string" ? args.model : "";
|
|
295
|
+
if (command === "thinking")
|
|
296
|
+
return typeof args.level === "string" ? args.level : "";
|
|
297
|
+
if (command === "channel-trigger")
|
|
298
|
+
return typeof args.trigger === "string" ? args.trigger : "";
|
|
299
|
+
return "";
|
|
300
|
+
}
|
|
301
|
+
function formatSetting(setting) {
|
|
302
|
+
return `${setting.value} (${setting.source})`;
|
|
303
|
+
}
|
|
304
|
+
function agentSettingsPayload(familiarAgent, channelKey, personaName) {
|
|
305
|
+
const { model } = familiarAgent.resolveChannelModel(channelKey);
|
|
306
|
+
return {
|
|
307
|
+
model: familiarAgent.getModel(channelKey),
|
|
308
|
+
thinking: familiarAgent.getThinkingLevel(channelKey),
|
|
309
|
+
supportedThinking: supportedThinkingLevels(model),
|
|
310
|
+
persona: { name: personaName },
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
function sessionDto(session) {
|
|
314
|
+
return {
|
|
315
|
+
key: session.key,
|
|
316
|
+
label: session.label,
|
|
317
|
+
service: session.channel.service,
|
|
318
|
+
scope: session.channel.scope,
|
|
319
|
+
channelId: session.channel.channelId,
|
|
320
|
+
channelName: session.channel.channelName,
|
|
321
|
+
threadId: session.channel.threadId,
|
|
322
|
+
isDefault: session.isDefault,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
export async function startWebDaemon(config, familiarAgent, discordDaemon) {
|
|
326
|
+
const persona = await loadPersona(config);
|
|
327
|
+
const personaName = parsePersonaName(persona.soul);
|
|
328
|
+
const auth = createAuth(config);
|
|
329
|
+
const clients = new Set();
|
|
330
|
+
const eventsByChannel = new Map();
|
|
331
|
+
const runtimeSubscriptions = new Map();
|
|
332
|
+
const locallyStreamedOutboundIds = new Set();
|
|
333
|
+
const publish = (event) => {
|
|
334
|
+
const fullEvent = { ...event, eventId: eventId(), ts: event.ts ?? Date.now() };
|
|
335
|
+
const events = eventsByChannel.get(fullEvent.channelKey ?? "") ?? [];
|
|
336
|
+
events.push(fullEvent);
|
|
337
|
+
if (events.length > EVENT_REPLAY_LIMIT)
|
|
338
|
+
events.shift();
|
|
339
|
+
eventsByChannel.set(fullEvent.channelKey ?? "", events);
|
|
340
|
+
const frame = encodeFrame(JSON.stringify(fullEvent));
|
|
341
|
+
for (const client of clients) {
|
|
342
|
+
if (client.channelKey === fullEvent.channelKey && !client.socket.destroyed) {
|
|
343
|
+
if (client.authed) {
|
|
344
|
+
client.socket.write(frame);
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
const pendingEvents = client.pendingEvents ?? [];
|
|
348
|
+
pendingEvents.push(fullEvent);
|
|
349
|
+
client.pendingEvents = pendingEvents;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return fullEvent;
|
|
354
|
+
};
|
|
355
|
+
const publishDelta = (channelKey, messageIdValue, part, text, ts) => publish({ type: "delta", channelKey, messageId: messageIdValue, part, content: text, text, ts });
|
|
356
|
+
const publishStoredAgentEvent = (channelKey, messageIdValue, storedEvent, ts) => {
|
|
357
|
+
if (storedEvent.type === "message_start" && storedEvent.role === "assistant") {
|
|
358
|
+
locallyStreamedOutboundIds.add(messageIdValue);
|
|
359
|
+
publish({
|
|
360
|
+
type: "message_started",
|
|
361
|
+
channelKey,
|
|
362
|
+
messageId: messageIdValue,
|
|
363
|
+
role: "assistant",
|
|
364
|
+
who: personaName,
|
|
365
|
+
ts,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
if (storedEvent.type === "message_update") {
|
|
369
|
+
const assistantEvent = storedEvent.assistantMessageEvent;
|
|
370
|
+
if (assistantEvent.type === "thinking_delta") {
|
|
371
|
+
publishDelta(channelKey, messageIdValue, "thinking", assistantEvent.delta, ts);
|
|
372
|
+
}
|
|
373
|
+
if (assistantEvent.type === "text_delta") {
|
|
374
|
+
publishDelta(channelKey, messageIdValue, "text", assistantEvent.delta, ts);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
if (storedEvent.type === "message_end" && storedEvent.role === "assistant") {
|
|
378
|
+
publish({
|
|
379
|
+
type: "message_completed",
|
|
380
|
+
channelKey,
|
|
381
|
+
messageId: messageIdValue,
|
|
382
|
+
usage: storedEvent.usage,
|
|
383
|
+
ts,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
const tool = toolFromStoredAgentEvent(storedEvent, ts ?? Date.now());
|
|
387
|
+
if (tool)
|
|
388
|
+
publish({ type: "tool_event", channelKey, messageId: messageIdValue, tool, ts });
|
|
389
|
+
};
|
|
390
|
+
const subscribeRuntime = (runtime) => {
|
|
391
|
+
if (runtimeSubscriptions.has(runtime.channelKey))
|
|
392
|
+
return;
|
|
393
|
+
const unsubscribeRecords = runtime.subscribe((record) => {
|
|
394
|
+
if (record.type === "inbound") {
|
|
395
|
+
publish({
|
|
396
|
+
type: "message_started",
|
|
397
|
+
channelKey: runtime.channelKey,
|
|
398
|
+
messageId: record.messageId,
|
|
399
|
+
role: "user",
|
|
400
|
+
who: record.authorName || WEB_USER_NAME,
|
|
401
|
+
ts: toUnixMs(record.ts),
|
|
402
|
+
});
|
|
403
|
+
publishDelta(runtime.channelKey, record.messageId, "text", record.text, toUnixMs(record.ts));
|
|
404
|
+
publish({
|
|
405
|
+
type: "message_completed",
|
|
406
|
+
channelKey: runtime.channelKey,
|
|
407
|
+
messageId: record.messageId,
|
|
408
|
+
ts: toUnixMs(record.ts),
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
if (record.type === "outbound" && !record.control) {
|
|
412
|
+
const outboundId = record.webMessageId || record.messageIds[0] || `out_${record.recordId}`;
|
|
413
|
+
const completion = {
|
|
414
|
+
type: "message_completed",
|
|
415
|
+
channelKey: runtime.channelKey,
|
|
416
|
+
messageId: outboundId,
|
|
417
|
+
thinkingMs: record.thinkingMs,
|
|
418
|
+
attachments: webAttachments(config, record.attachments),
|
|
419
|
+
ts: toUnixMs(record.ts),
|
|
420
|
+
};
|
|
421
|
+
if (locallyStreamedOutboundIds.delete(outboundId)) {
|
|
422
|
+
publish(completion);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
publish({
|
|
426
|
+
type: "message_started",
|
|
427
|
+
channelKey: runtime.channelKey,
|
|
428
|
+
messageId: outboundId,
|
|
429
|
+
role: "assistant",
|
|
430
|
+
who: personaName,
|
|
431
|
+
ts: toUnixMs(record.ts),
|
|
432
|
+
});
|
|
433
|
+
if (record.thinking)
|
|
434
|
+
publishDelta(runtime.channelKey, outboundId, "thinking", record.thinking, toUnixMs(record.ts));
|
|
435
|
+
if (record.text)
|
|
436
|
+
publishDelta(runtime.channelKey, outboundId, "text", record.text, toUnixMs(record.ts));
|
|
437
|
+
publish(completion);
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
const unsubscribeAgentEvents = runtime.subscribeAgentEvents((agentEvent) => {
|
|
441
|
+
publishStoredAgentEvent(runtime.channelKey, agentEvent.messageId, agentEvent.event, agentEvent.ts);
|
|
442
|
+
});
|
|
443
|
+
runtimeSubscriptions.set(runtime.channelKey, () => {
|
|
444
|
+
unsubscribeRecords();
|
|
445
|
+
unsubscribeAgentEvents();
|
|
446
|
+
});
|
|
447
|
+
};
|
|
448
|
+
const getRuntime = async (channelKey) => {
|
|
449
|
+
const runtime = await discordDaemon.getRuntimeForWebChannel(channelKey);
|
|
450
|
+
subscribeRuntime(runtime);
|
|
451
|
+
return runtime;
|
|
452
|
+
};
|
|
453
|
+
const subscribeKnownRuntimes = async () => {
|
|
454
|
+
const sessions = await discordDaemon.getWebSessions();
|
|
455
|
+
await Promise.all(sessions.map(async (session) => {
|
|
456
|
+
const runtime = await discordDaemon.getRuntimeForWebChannel(session.key);
|
|
457
|
+
subscribeRuntime(runtime);
|
|
458
|
+
}));
|
|
459
|
+
};
|
|
460
|
+
const getChannelKeyFromRequest = (url, body) => {
|
|
461
|
+
const queryKey = url.searchParams.get("channelKey");
|
|
462
|
+
if (queryKey)
|
|
463
|
+
return queryKey;
|
|
464
|
+
if (isObject(body) && typeof body.channelKey === "string")
|
|
465
|
+
return body.channelKey;
|
|
466
|
+
return undefined;
|
|
467
|
+
};
|
|
468
|
+
const replay = (client, channelKey, lastEventId) => {
|
|
469
|
+
const events = eventsByChannel.get(channelKey) ?? [];
|
|
470
|
+
replayEvents(client, events, lastEventId, () => publish({ type: "replay_window_lost", channelKey }));
|
|
471
|
+
};
|
|
472
|
+
const promptForRuntime = async (runtime, jobId, prompt, attachments = []) => {
|
|
473
|
+
const assistantMessageId = messageId();
|
|
474
|
+
const summary = { thinking: "" };
|
|
475
|
+
const recorder = createAgentEventRecorder((storedEvent) => runtime.noteAgentEvent(jobId, assistantMessageId, storedEvent, { notify: false }));
|
|
476
|
+
let started = false;
|
|
477
|
+
let reply;
|
|
478
|
+
try {
|
|
479
|
+
reply = await discordDaemon.runPromptForWeb(runtime, jobId, prompt, attachments, async (event) => {
|
|
480
|
+
if (event.type === "message_start" && event.message.role === "assistant" && !started) {
|
|
481
|
+
started = true;
|
|
482
|
+
}
|
|
483
|
+
updateAgentEventSummary(summary, event);
|
|
484
|
+
const storedEvent = storedAgentEventFromAgentEvent(event);
|
|
485
|
+
if (storedEvent) {
|
|
486
|
+
runtime.publishAgentEvent(jobId, assistantMessageId, storedEvent);
|
|
487
|
+
await recorder.record(storedEvent);
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
finally {
|
|
492
|
+
await recorder.flush();
|
|
493
|
+
}
|
|
494
|
+
if (!started) {
|
|
495
|
+
publish({
|
|
496
|
+
type: "message_started",
|
|
497
|
+
channelKey: runtime.channelKey,
|
|
498
|
+
messageId: assistantMessageId,
|
|
499
|
+
role: "assistant",
|
|
500
|
+
who: personaName,
|
|
501
|
+
});
|
|
502
|
+
publishDelta(runtime.channelKey, assistantMessageId, "text", reply.text);
|
|
503
|
+
}
|
|
504
|
+
const thinkingMs = thinkingDurationMs(summary);
|
|
505
|
+
publish({
|
|
506
|
+
type: "message_completed",
|
|
507
|
+
channelKey: runtime.channelKey,
|
|
508
|
+
messageId: assistantMessageId,
|
|
509
|
+
thinkingMs,
|
|
510
|
+
attachments: webAttachments(config, reply.attachments),
|
|
511
|
+
});
|
|
512
|
+
locallyStreamedOutboundIds.add(assistantMessageId);
|
|
513
|
+
return {
|
|
514
|
+
text: reply.text,
|
|
515
|
+
messageId: assistantMessageId,
|
|
516
|
+
thinking: summary.thinking,
|
|
517
|
+
thinkingMs,
|
|
518
|
+
attachments: reply.attachments,
|
|
519
|
+
};
|
|
520
|
+
};
|
|
521
|
+
const drainJobs = async (runtime) => {
|
|
522
|
+
for (;;) {
|
|
523
|
+
const dispatch = runtime.beginNextJob();
|
|
524
|
+
if (!dispatch)
|
|
525
|
+
return;
|
|
526
|
+
try {
|
|
527
|
+
const reply = await promptForRuntime(runtime, dispatch.job.jobId, dispatch.prompt, dispatch.attachments);
|
|
528
|
+
await runtime.completeActiveJob({
|
|
529
|
+
text: reply.text,
|
|
530
|
+
messageIds: [reply.messageId],
|
|
531
|
+
webMessageId: reply.messageId,
|
|
532
|
+
attachments: reply.attachments,
|
|
533
|
+
thinking: reply.thinking,
|
|
534
|
+
thinkingMs: reply.thinkingMs,
|
|
535
|
+
replyToMessageId: dispatch.triggerMessageId,
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
catch (error) {
|
|
539
|
+
if (!runtime.hasActiveJob(dispatch.job.jobId))
|
|
540
|
+
return;
|
|
541
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
542
|
+
await runtime.failActiveJob(message);
|
|
543
|
+
await runtime.appendError(message);
|
|
544
|
+
publish({ type: "error", channelKey: runtime.channelKey, code: "unknown", message });
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
const applyControlCommand = async (runtime, control) => {
|
|
549
|
+
if (control.command === "stop") {
|
|
550
|
+
discordDaemon.abortWebRuntime(runtime);
|
|
551
|
+
await runtime.resetConversation("stop requested");
|
|
552
|
+
publish({
|
|
553
|
+
type: "status",
|
|
554
|
+
channelKey: runtime.channelKey,
|
|
555
|
+
kind: "idle",
|
|
556
|
+
detail: "Stopped current work and cleared the chat queue.",
|
|
557
|
+
});
|
|
558
|
+
return "Stopped current work and cleared the chat queue.";
|
|
559
|
+
}
|
|
560
|
+
if (control.command === "new") {
|
|
561
|
+
await familiarAgent.reset(runtime.channelKey);
|
|
562
|
+
await runtime.resetConversation("new conversation requested");
|
|
563
|
+
return "Started a fresh agent transcript for this channel.";
|
|
564
|
+
}
|
|
565
|
+
if (control.command === "reload") {
|
|
566
|
+
return familiarAgent.reload();
|
|
567
|
+
}
|
|
568
|
+
if (control.command === "model") {
|
|
569
|
+
return control.args
|
|
570
|
+
? await familiarAgent.setModel(runtime.channelKey, control.args)
|
|
571
|
+
: `Current model: ${formatSetting(familiarAgent.getModel(runtime.channelKey))}`;
|
|
572
|
+
}
|
|
573
|
+
if (control.command === "thinking") {
|
|
574
|
+
return control.args
|
|
575
|
+
? await familiarAgent.setThinkingLevel(runtime.channelKey, control.args)
|
|
576
|
+
: `Current thinking: ${formatSetting(familiarAgent.getThinkingLevel(runtime.channelKey))}`;
|
|
577
|
+
}
|
|
578
|
+
if (control.command === "channel-trigger")
|
|
579
|
+
return "Use Discord /familiar channel-trigger in the channel for now.";
|
|
580
|
+
if (control.command === "status") {
|
|
581
|
+
return [
|
|
582
|
+
runtime.formatStatus(),
|
|
583
|
+
`model: ${formatSetting(familiarAgent.getModel(runtime.channelKey))}`,
|
|
584
|
+
`thinking: ${formatSetting(familiarAgent.getThinkingLevel(runtime.channelKey))}`,
|
|
585
|
+
].join("\n");
|
|
586
|
+
}
|
|
587
|
+
return "Compact is not wired for this runtime yet. I logged the command, but I won't run lossy compaction here.";
|
|
588
|
+
};
|
|
589
|
+
const handleApi = async (request, response, url) => {
|
|
590
|
+
if (!url.pathname.startsWith("/api/web/"))
|
|
591
|
+
return false;
|
|
592
|
+
if (!auth.authorize(request, url.pathname)) {
|
|
593
|
+
sendJson(response, 401, { error: "unauthorized" });
|
|
594
|
+
return true;
|
|
595
|
+
}
|
|
596
|
+
try {
|
|
597
|
+
if (request.method === "GET" && url.pathname.startsWith("/api/web/attachments/")) {
|
|
598
|
+
return serveAttachment(config, response, url.pathname);
|
|
599
|
+
}
|
|
600
|
+
if (request.method === "GET" && url.pathname === "/api/web/auth/mode") {
|
|
601
|
+
sendJson(response, 200, { mode: config.web.authMode, personaName });
|
|
602
|
+
return true;
|
|
603
|
+
}
|
|
604
|
+
if (request.method === "GET" && url.pathname === "/api/web/sessions") {
|
|
605
|
+
const sessions = await discordDaemon.getWebSessions();
|
|
606
|
+
sendJson(response, 200, { sessions: sessions.map(sessionDto) });
|
|
607
|
+
return true;
|
|
608
|
+
}
|
|
609
|
+
if (request.method === "GET" && url.pathname === "/api/web/history") {
|
|
610
|
+
const runtime = await getRuntime(getChannelKeyFromRequest(url));
|
|
611
|
+
const limit = Math.min(Math.max(Number(url.searchParams.get("limit") ?? 50) || 50, 1), 200);
|
|
612
|
+
const before = url.searchParams.get("before");
|
|
613
|
+
const messages = webMessagesFromRecords(config, runtime.getRecords(), personaName);
|
|
614
|
+
const end = before ? messages.findIndex((message) => message.id === before) : messages.length;
|
|
615
|
+
const safeEnd = end >= 0 ? end : messages.length;
|
|
616
|
+
const page = messages.slice(Math.max(0, safeEnd - limit), safeEnd);
|
|
617
|
+
sendJson(response, 200, { messages: page, hasMore: safeEnd - limit > 0, channelKey: runtime.channelKey });
|
|
618
|
+
return true;
|
|
619
|
+
}
|
|
620
|
+
if (request.method === "GET" && url.pathname === "/api/web/agent/settings") {
|
|
621
|
+
const runtime = await getRuntime(getChannelKeyFromRequest(url));
|
|
622
|
+
sendJson(response, 200, agentSettingsPayload(familiarAgent, runtime.channelKey, personaName));
|
|
623
|
+
return true;
|
|
624
|
+
}
|
|
625
|
+
if (request.method === "GET" && url.pathname === "/api/web/agent/models") {
|
|
626
|
+
sendJson(response, 200, { models: config.models.allow });
|
|
627
|
+
return true;
|
|
628
|
+
}
|
|
629
|
+
if (request.method === "POST" && url.pathname === "/api/web/send") {
|
|
630
|
+
const contentType = request.headers["content-type"] ?? "";
|
|
631
|
+
const isMultipart = Array.isArray(contentType)
|
|
632
|
+
? contentType.some((value) => value.includes("multipart/form-data"))
|
|
633
|
+
: contentType.includes("multipart/form-data");
|
|
634
|
+
const body = isMultipart ? await readMultipartBody(request, contentType) : await readJsonBody(request);
|
|
635
|
+
const runtime = await getRuntime(getChannelKeyFromRequest(url, body));
|
|
636
|
+
if (!isObject(body) || typeof body.text !== "string") {
|
|
637
|
+
sendJson(response, 400, { error: "text is required" });
|
|
638
|
+
return true;
|
|
639
|
+
}
|
|
640
|
+
if (!isMultipart && isObject(body) && Array.isArray(body.attachments) && body.attachments.length > 0) {
|
|
641
|
+
sendJson(response, 400, { error: "attachments require multipart form data" });
|
|
642
|
+
return true;
|
|
643
|
+
}
|
|
644
|
+
const rawAttachments = Array.isArray(body.attachments) ? body.attachments : [];
|
|
645
|
+
const attachments = await materializeInboundAttachments(config, rawAttachments
|
|
646
|
+
.filter((attachment) => isWebUploadAttachment(attachment))
|
|
647
|
+
.map((attachment) => ({ ...attachment, source: "web" })));
|
|
648
|
+
if (!body.text.trim() && attachments.length === 0) {
|
|
649
|
+
sendJson(response, 400, { error: "text or attachment is required" });
|
|
650
|
+
return true;
|
|
651
|
+
}
|
|
652
|
+
const id = messageId("user");
|
|
653
|
+
const ts = Date.now();
|
|
654
|
+
const input = {
|
|
655
|
+
messageId: id,
|
|
656
|
+
authorId: config.discord.ownerId,
|
|
657
|
+
authorName: WEB_USER_NAME,
|
|
658
|
+
text: body.text,
|
|
659
|
+
isBot: false,
|
|
660
|
+
mentionedBot: true,
|
|
661
|
+
remoteTimestamp: new Date(ts).toISOString(),
|
|
662
|
+
checkpoint: { messageId: id },
|
|
663
|
+
attachments,
|
|
664
|
+
};
|
|
665
|
+
await runtime.ingestInbound(input, { mode: "queue" });
|
|
666
|
+
void drainJobs(runtime).catch((error) => console.error("Web job drain failed", error));
|
|
667
|
+
sendJson(response, 200, { id, ts, channelKey: runtime.channelKey });
|
|
668
|
+
return true;
|
|
669
|
+
}
|
|
670
|
+
if (request.method === "POST" && url.pathname === "/api/web/agent/settings") {
|
|
671
|
+
const body = await readJsonBody(request);
|
|
672
|
+
const runtime = await getRuntime(getChannelKeyFromRequest(url, body));
|
|
673
|
+
if (!isObject(body)) {
|
|
674
|
+
sendJson(response, 400, { error: "body is required" });
|
|
675
|
+
return true;
|
|
676
|
+
}
|
|
677
|
+
try {
|
|
678
|
+
if (typeof body.model === "string")
|
|
679
|
+
await familiarAgent.setModel(runtime.channelKey, body.model);
|
|
680
|
+
if (typeof body.thinking === "string")
|
|
681
|
+
await familiarAgent.setThinkingLevel(runtime.channelKey, body.thinking);
|
|
682
|
+
}
|
|
683
|
+
catch (error) {
|
|
684
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
685
|
+
sendJson(response, 400, { error: message });
|
|
686
|
+
return true;
|
|
687
|
+
}
|
|
688
|
+
sendJson(response, 200, agentSettingsPayload(familiarAgent, runtime.channelKey, personaName));
|
|
689
|
+
return true;
|
|
690
|
+
}
|
|
691
|
+
if (request.method === "POST" && url.pathname === "/api/web/agent/new") {
|
|
692
|
+
const body = await readJsonBody(request);
|
|
693
|
+
const runtime = await getRuntime(getChannelKeyFromRequest(url, body));
|
|
694
|
+
await familiarAgent.reset(runtime.channelKey);
|
|
695
|
+
await runtime.resetConversation("new conversation requested from web");
|
|
696
|
+
publish({
|
|
697
|
+
type: "status",
|
|
698
|
+
channelKey: runtime.channelKey,
|
|
699
|
+
kind: "idle",
|
|
700
|
+
detail: "started fresh from web",
|
|
701
|
+
});
|
|
702
|
+
sendJson(response, 200, { ok: true });
|
|
703
|
+
return true;
|
|
704
|
+
}
|
|
705
|
+
if (request.method === "POST" && url.pathname === "/api/web/control") {
|
|
706
|
+
const body = await readJsonBody(request);
|
|
707
|
+
const runtime = await getRuntime(getChannelKeyFromRequest(url, body));
|
|
708
|
+
if (!isObject(body) || typeof body.command !== "string") {
|
|
709
|
+
sendJson(response, 400, { error: "command is required" });
|
|
710
|
+
return true;
|
|
711
|
+
}
|
|
712
|
+
if (config.web.authMode === "public-2fa" && body.command === "login") {
|
|
713
|
+
const token = isObject(body.args) && typeof body.args.token === "string" ? body.args.token : "";
|
|
714
|
+
if (!config.web.totpSecret || !verifyTotp(config.web.totpSecret, token)) {
|
|
715
|
+
sendJson(response, 401, { ok: false, message: "Invalid TOTP token." });
|
|
716
|
+
return true;
|
|
717
|
+
}
|
|
718
|
+
const sessionId = auth.createSession();
|
|
719
|
+
sendJson(response, 200, { ok: true, message: "Authenticated." }, { "set-cookie": sessionCookie(sessionId) });
|
|
720
|
+
return true;
|
|
721
|
+
}
|
|
722
|
+
const args = commandArgs(body.command, body.args);
|
|
723
|
+
const input = {
|
|
724
|
+
messageId: messageId("control"),
|
|
725
|
+
authorId: config.discord.ownerId,
|
|
726
|
+
authorName: WEB_USER_NAME,
|
|
727
|
+
text: `/${body.command}${args ? ` ${args}` : ""}`,
|
|
728
|
+
isBot: false,
|
|
729
|
+
mentionedBot: true,
|
|
730
|
+
remoteTimestamp: new Date().toISOString(),
|
|
731
|
+
};
|
|
732
|
+
const control = runtime.parseControlCommand(input);
|
|
733
|
+
if (!control) {
|
|
734
|
+
sendJson(response, 400, { ok: false, message: "Unsupported command." });
|
|
735
|
+
return true;
|
|
736
|
+
}
|
|
737
|
+
await runtime.noteControlCommand(input, control);
|
|
738
|
+
const message = await applyControlCommand(runtime, control);
|
|
739
|
+
await runtime.noteOutbound({ text: message, messageIds: [], control: control.command });
|
|
740
|
+
sendJson(response, 200, { ok: true, message, channelKey: runtime.channelKey });
|
|
741
|
+
return true;
|
|
742
|
+
}
|
|
743
|
+
sendJson(response, 404, { error: "not found" });
|
|
744
|
+
return true;
|
|
745
|
+
}
|
|
746
|
+
catch (error) {
|
|
747
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
748
|
+
sendJson(response, 500, { error: message });
|
|
749
|
+
return true;
|
|
750
|
+
}
|
|
751
|
+
};
|
|
752
|
+
await subscribeKnownRuntimes();
|
|
753
|
+
const server = createServer((request, response) => {
|
|
754
|
+
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
|
|
755
|
+
void handleApi(request, response, url).then(async (handled) => {
|
|
756
|
+
if (handled)
|
|
757
|
+
return;
|
|
758
|
+
if (await serveStatic(response, url.pathname))
|
|
759
|
+
return;
|
|
760
|
+
sendText(response, 404, "Not found");
|
|
761
|
+
});
|
|
762
|
+
});
|
|
763
|
+
server.on("upgrade", (request, socket) => {
|
|
764
|
+
const netSocket = socket;
|
|
765
|
+
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
|
|
766
|
+
if (url.pathname !== "/api/web/stream" || !auth.authorize(request, url.pathname)) {
|
|
767
|
+
netSocket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
|
768
|
+
netSocket.destroy();
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
if (!acceptWebSocket(request, netSocket))
|
|
772
|
+
return;
|
|
773
|
+
netSocket.setNoDelay(true);
|
|
774
|
+
const requestedChannelKey = url.searchParams.get("channelKey") || undefined;
|
|
775
|
+
const client = { socket: netSocket, channelKey: requestedChannelKey, authed: false };
|
|
776
|
+
clients.add(client);
|
|
777
|
+
let frameBuffer = Buffer.alloc(0);
|
|
778
|
+
void getRuntime(requestedChannelKey)
|
|
779
|
+
.then((runtime) => {
|
|
780
|
+
client.channelKey = runtime.channelKey;
|
|
781
|
+
})
|
|
782
|
+
.catch((error) => {
|
|
783
|
+
console.error("WebSocket runtime lookup failed", error);
|
|
784
|
+
netSocket.destroy();
|
|
785
|
+
});
|
|
786
|
+
netSocket.on("data", (chunk) => {
|
|
787
|
+
try {
|
|
788
|
+
frameBuffer = Buffer.concat([frameBuffer, chunk]);
|
|
789
|
+
const decoded = decodeFrames(frameBuffer);
|
|
790
|
+
frameBuffer = decoded.remaining;
|
|
791
|
+
if (decoded.close)
|
|
792
|
+
netSocket.destroy();
|
|
793
|
+
for (const raw of decoded.messages) {
|
|
794
|
+
const message = JSON.parse(raw);
|
|
795
|
+
if (isObject(message) && message.type === "hello") {
|
|
796
|
+
if (!client.channelKey)
|
|
797
|
+
continue;
|
|
798
|
+
replay(client, client.channelKey, typeof message.lastEventId === "string" ? message.lastEventId : null);
|
|
799
|
+
}
|
|
800
|
+
if (isObject(message) && message.type === "abort") {
|
|
801
|
+
void getRuntime(client.channelKey).then(async (runtime) => {
|
|
802
|
+
discordDaemon.abortWebRuntime(runtime);
|
|
803
|
+
await runtime.resetConversation("web abort requested");
|
|
804
|
+
publish({
|
|
805
|
+
type: "error",
|
|
806
|
+
channelKey: runtime.channelKey,
|
|
807
|
+
code: "abort",
|
|
808
|
+
message: "Aborted current work.",
|
|
809
|
+
});
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
catch (error) {
|
|
815
|
+
console.error("WebSocket frame handling failed", error);
|
|
816
|
+
netSocket.destroy();
|
|
817
|
+
}
|
|
818
|
+
});
|
|
819
|
+
netSocket.on("close", () => clients.delete(client));
|
|
820
|
+
netSocket.on("error", () => clients.delete(client));
|
|
821
|
+
});
|
|
822
|
+
await new Promise((resolveListen, rejectListen) => {
|
|
823
|
+
server.once("error", rejectListen);
|
|
824
|
+
server.listen(config.web.port, config.web.bindAddress, () => {
|
|
825
|
+
server.off("error", rejectListen);
|
|
826
|
+
resolveListen();
|
|
827
|
+
});
|
|
828
|
+
});
|
|
829
|
+
console.log(`Web side-door listening on http://${config.web.bindAddress}:${config.web.port}`);
|
|
830
|
+
return {
|
|
831
|
+
server,
|
|
832
|
+
async stop() {
|
|
833
|
+
for (const client of clients)
|
|
834
|
+
client.socket.destroy();
|
|
835
|
+
clients.clear();
|
|
836
|
+
for (const unsubscribe of runtimeSubscriptions.values())
|
|
837
|
+
unsubscribe();
|
|
838
|
+
runtimeSubscriptions.clear();
|
|
839
|
+
await new Promise((resolveClose, rejectClose) => {
|
|
840
|
+
server.close((error) => (error ? rejectClose(error) : resolveClose()));
|
|
841
|
+
});
|
|
842
|
+
},
|
|
843
|
+
};
|
|
844
|
+
}
|