@qearlyao/familiar 0.2.5 → 0.3.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/README.md +4 -0
- package/config.example.toml +2 -2
- package/dist/agent/payload-normalizers.js +52 -0
- package/dist/agent/session-helpers.js +86 -0
- package/dist/agent/tool-descriptions.js +4 -0
- package/dist/agent/tools.js +30 -0
- package/dist/agent/transcript-log.js +93 -0
- package/dist/agent/types.js +1 -0
- package/dist/agent-core.js +82 -0
- package/dist/agent-work-queue.js +55 -0
- package/dist/agent.js +91 -322
- package/dist/browser-tools.js +7 -8
- package/dist/chat-log.js +15 -3
- package/dist/cli.js +36 -6
- package/dist/config/enums.js +35 -0
- package/dist/config/interpolate.js +15 -0
- package/dist/config/model-refs.js +11 -0
- package/dist/config/readers.js +116 -0
- package/dist/config/sections.js +113 -0
- package/dist/config/types.js +1 -0
- package/dist/config-registry.js +26 -7
- package/dist/config.js +8 -271
- package/dist/discord/channel.js +32 -0
- package/dist/discord/chunking.js +163 -0
- package/dist/discord/client.js +44 -0
- package/dist/discord/commands.js +181 -0
- package/dist/discord/inbound.js +44 -0
- package/dist/discord/send.js +106 -0
- package/dist/discord/turn.js +55 -0
- package/dist/discord.js +266 -1186
- package/dist/ids.js +11 -0
- package/dist/index.js +1 -0
- package/dist/memory/index/store.js +21 -17
- package/dist/memory/index/vector-codec.js +2 -2
- package/dist/memory/lcm/context-transformer.js +6 -2
- package/dist/memory/lcm/segment-manager.js +6 -2
- package/dist/memory/lcm/store/index-ids.js +6 -0
- package/dist/memory/lcm/store/inserts.js +31 -0
- package/dist/memory/lcm/store/normalizers.js +91 -0
- package/dist/memory/lcm/store/row-mappers.js +114 -0
- package/dist/memory/lcm/store/row-types.js +1 -0
- package/dist/memory/lcm/store/serialization.js +37 -0
- package/dist/memory/lcm/store/snapshots.js +73 -0
- package/dist/memory/lcm/store.js +20 -360
- package/dist/owner-identity.js +29 -0
- package/dist/runtime-manager.js +51 -0
- package/dist/runtime.js +89 -41
- package/dist/scheduler-runner.js +243 -0
- package/dist/scheduler.js +1 -1
- package/dist/service.js +1 -0
- package/dist/settings.js +3 -0
- package/dist/web/event-hub.js +246 -0
- package/dist/{web-http.js → web/http.js} +19 -5
- package/dist/web/memes.js +25 -0
- package/dist/web/messages.js +345 -0
- package/dist/web/multipart.js +80 -0
- package/dist/web/payloads.js +34 -0
- package/dist/{web-static.js → web/static.js} +19 -14
- package/dist/web/stream.js +69 -0
- package/dist/web-tools/cache.js +42 -0
- package/dist/web-tools/config.js +16 -0
- package/dist/web-tools/fetch-providers.js +119 -0
- package/dist/web-tools/format.js +88 -0
- package/dist/web-tools/http.js +81 -0
- package/dist/web-tools/routing.js +29 -0
- package/dist/web-tools/safety.js +73 -0
- package/dist/web-tools/search-providers.js +277 -0
- package/dist/web-tools/types.js +54 -0
- package/dist/web-tools/util.js +23 -0
- package/dist/web-tools.js +9 -798
- package/dist/web.js +416 -984
- package/npm-shrinkwrap.json +242 -201
- package/package.json +4 -4
- package/web/dist/assets/index-CSkxUQCr.js +63 -0
- package/web/dist/assets/index-DllM6RqL.css +2 -0
- package/web/dist/index.html +6 -3
- package/web/dist/assets/index-B23WT77N.js +0 -63
- package/web/dist/assets/index-D3MotFzN.css +0 -2
- /package/dist/{web-auth.js → web/auth.js} +0 -0
- /package/dist/{web-events.js → web/events.js} +0 -0
- /package/dist/{web-types.js → web/types.js} +0 -0
package/dist/web.js
CHANGED
|
@@ -1,674 +1,62 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { createServer } from "node:http";
|
|
4
|
-
import { join } from "node:path";
|
|
5
4
|
import { getProviders } from "@earendil-works/pi-ai";
|
|
6
5
|
import { addModel, loadAddedModels, removeModel, setAddedModelsPath } from "./added-models.js";
|
|
7
6
|
import { createAgentEventRecorder, storedAgentEventFromAgentEvent, thinkingDurationMs, updateAgentEventSummary, } from "./agent-events.js";
|
|
8
|
-
import {
|
|
9
|
-
import { CONFIG_KEYS, CONFIG_REGISTRY,
|
|
7
|
+
import { loadConfigOverrides } from "./config-overrides.js";
|
|
8
|
+
import { CONFIG_KEYS, CONFIG_REGISTRY, clearConfigChange, commitConfigChange, isConfigKey, } from "./config-registry.js";
|
|
10
9
|
import { getContactNickname, refreshContactNote, setContactNotePath } from "./contact-note.js";
|
|
11
|
-
import {
|
|
10
|
+
import { messageId } from "./ids.js";
|
|
12
11
|
import { materializeInboundAttachments } from "./inbound-attachments.js";
|
|
13
|
-
import { PROVIDER_DEFAULTS, parseModelRef
|
|
12
|
+
import { PROVIDER_DEFAULTS, parseModelRef } from "./models.js";
|
|
14
13
|
import { loadPersona, parsePersonaName } from "./persona.js";
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
14
|
+
import { formatSetting } from "./settings.js";
|
|
15
|
+
import { parseAgentReply } from "./silent-marker.js";
|
|
16
|
+
import { isRecord } from "./util/guards.js";
|
|
17
|
+
import { createAuth, sessionCookie, verifyTotp } from "./web/auth.js";
|
|
18
|
+
import { createWebEventHub } from "./web/event-hub.js";
|
|
19
|
+
import { HttpError, readJsonBody, sendJson, sendText } from "./web/http.js";
|
|
20
|
+
import { memeCatalogPath, parseMemeCatalog } from "./web/memes.js";
|
|
21
|
+
import { webAttachments, webHistoryPayload } from "./web/messages.js";
|
|
22
|
+
import { isWebUploadAttachment, readMultipartBody } from "./web/multipart.js";
|
|
23
|
+
import { agentSettingsPayload, commandArgs, sessionDto } from "./web/payloads.js";
|
|
24
|
+
import { serveAttachment, serveStatic } from "./web/static.js";
|
|
25
|
+
import { attachWebSocketStream } from "./web/stream.js";
|
|
26
|
+
import { WEB_USER_NAME } from "./web/types.js";
|
|
27
|
+
function errorMessage(error) {
|
|
28
|
+
return error instanceof Error ? error.message : String(error);
|
|
24
29
|
}
|
|
25
|
-
function
|
|
26
|
-
return `evt_${randomUUID()}`;
|
|
27
|
-
}
|
|
28
|
-
function messageId(prefix = "msg") {
|
|
29
|
-
return `${prefix}_${randomUUID()}`;
|
|
30
|
-
}
|
|
31
|
-
function isUserVisibleRuntimeRecord(record) {
|
|
32
|
-
return record.type !== "runtime" || !["armed", "reset", "stopped"].includes(record.event);
|
|
33
|
-
}
|
|
34
|
-
function parseMemeCatalog(markdown) {
|
|
35
|
-
const families = [];
|
|
36
|
-
let currentFamily;
|
|
37
|
-
for (const line of markdown.split(/\r?\n/)) {
|
|
38
|
-
const familyMatch = line.match(/^## (.+)$/);
|
|
39
|
-
if (familyMatch) {
|
|
40
|
-
currentFamily = { name: familyMatch[1]?.trim() ?? "", memes: [] };
|
|
41
|
-
families.push(currentFamily);
|
|
42
|
-
continue;
|
|
43
|
-
}
|
|
44
|
-
if (!currentFamily || !line.startsWith("- ") || !line.includes(" — "))
|
|
45
|
-
continue;
|
|
46
|
-
const separator = line.indexOf(" — ");
|
|
47
|
-
const name = line.slice(2, separator).trim();
|
|
48
|
-
const suffix = line.slice(separator + " — ".length).trim();
|
|
49
|
-
if (!name || !suffix)
|
|
50
|
-
continue;
|
|
51
|
-
currentFamily.memes.push({ name, url: `https://files.catbox.moe/${suffix}` });
|
|
52
|
-
}
|
|
53
|
-
return families;
|
|
54
|
-
}
|
|
55
|
-
function memeCatalogPath(config) {
|
|
56
|
-
return join(config.workspacePath, "skills", "memes", "SKILL.md");
|
|
57
|
-
}
|
|
58
|
-
function isWebUploadAttachment(value) {
|
|
59
|
-
return !!value && typeof value === "object" && Buffer.isBuffer(value.buffer);
|
|
60
|
-
}
|
|
61
|
-
async function readRawBody(request, maxBytes) {
|
|
62
|
-
const chunks = [];
|
|
63
|
-
let total = 0;
|
|
64
|
-
for await (const chunk of request) {
|
|
65
|
-
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
66
|
-
total += buffer.length;
|
|
67
|
-
if (total > maxBytes)
|
|
68
|
-
throw new Error("Request body too large");
|
|
69
|
-
chunks.push(buffer);
|
|
70
|
-
}
|
|
71
|
-
return Buffer.concat(chunks);
|
|
72
|
-
}
|
|
73
|
-
function multipartBoundary(contentType) {
|
|
74
|
-
const header = Array.isArray(contentType) ? contentType.find((value) => value.includes("boundary=")) : contentType;
|
|
75
|
-
const match = header?.match(/boundary=(?:"([^"]+)"|([^;]+))/i);
|
|
76
|
-
if (!match?.[1] && !match?.[2])
|
|
77
|
-
throw new Error("Missing multipart boundary");
|
|
78
|
-
return match[1] ?? match[2] ?? "";
|
|
79
|
-
}
|
|
80
|
-
function parseContentDisposition(header) {
|
|
81
|
-
const parts = header.split(";").map((part) => part.trim());
|
|
82
|
-
const values = {};
|
|
83
|
-
for (const part of parts.slice(1)) {
|
|
84
|
-
const [key, rawValue] = part.split("=");
|
|
85
|
-
if (!key || rawValue === undefined)
|
|
86
|
-
continue;
|
|
87
|
-
values[key.toLowerCase()] = rawValue.replace(/^"|"$/g, "");
|
|
88
|
-
}
|
|
89
|
-
return values;
|
|
90
|
-
}
|
|
91
|
-
async function readMultipartBody(request, contentType) {
|
|
92
|
-
const boundary = multipartBoundary(contentType);
|
|
93
|
-
const raw = await readRawBody(request, 32 * 1024 * 1024);
|
|
94
|
-
const binary = raw.toString("binary");
|
|
95
|
-
const marker = `--${boundary}`;
|
|
96
|
-
const attachments = [];
|
|
97
|
-
const body = { text: "" };
|
|
98
|
-
for (const section of binary.split(marker).slice(1)) {
|
|
99
|
-
if (!section || section === "--\r\n" || section === "--")
|
|
100
|
-
continue;
|
|
101
|
-
const trimmed = section.replace(/^\r\n/, "").replace(/\r\n--$/, "");
|
|
102
|
-
const headerEnd = trimmed.indexOf("\r\n\r\n");
|
|
103
|
-
if (headerEnd < 0)
|
|
104
|
-
continue;
|
|
105
|
-
const headerText = trimmed.slice(0, headerEnd);
|
|
106
|
-
let contentBinary = trimmed.slice(headerEnd + 4);
|
|
107
|
-
if (contentBinary.endsWith("\r\n"))
|
|
108
|
-
contentBinary = contentBinary.slice(0, -2);
|
|
109
|
-
const headers = Object.fromEntries(headerText.split("\r\n").map((line) => {
|
|
110
|
-
const colon = line.indexOf(":");
|
|
111
|
-
return colon >= 0
|
|
112
|
-
? [line.slice(0, colon).trim().toLowerCase(), line.slice(colon + 1).trim()]
|
|
113
|
-
: [line.toLowerCase(), ""];
|
|
114
|
-
}));
|
|
115
|
-
const disposition = parseContentDisposition(headers["content-disposition"] ?? "");
|
|
116
|
-
const name = disposition.name;
|
|
117
|
-
if (!name)
|
|
118
|
-
continue;
|
|
119
|
-
if (name === "text" || name === "channelKey" || name === "clientId") {
|
|
120
|
-
body[name] = Buffer.from(contentBinary, "binary").toString("utf8");
|
|
121
|
-
continue;
|
|
122
|
-
}
|
|
123
|
-
if (name !== "attachments")
|
|
124
|
-
continue;
|
|
125
|
-
const buffer = Buffer.from(contentBinary, "binary");
|
|
126
|
-
if (buffer.length === 0)
|
|
127
|
-
continue;
|
|
128
|
-
attachments.push({
|
|
129
|
-
name: disposition.filename,
|
|
130
|
-
mimeType: headers["content-type"],
|
|
131
|
-
size: buffer.length,
|
|
132
|
-
buffer,
|
|
133
|
-
});
|
|
134
|
-
}
|
|
135
|
-
body.attachments = attachments;
|
|
136
|
-
return body;
|
|
137
|
-
}
|
|
138
|
-
function webAttachments(config, attachments) {
|
|
139
|
-
if (!attachments?.length)
|
|
140
|
-
return undefined;
|
|
141
|
-
return attachments.map((attachment) => ({
|
|
142
|
-
id: attachment.id,
|
|
143
|
-
name: attachment.name,
|
|
144
|
-
kind: attachment.kind,
|
|
145
|
-
mimeType: attachment.mimeType,
|
|
146
|
-
size: attachment.size,
|
|
147
|
-
url: attachment.localPath ? publicAttachmentPath(config, attachment.localPath) : attachment.remoteUrl,
|
|
148
|
-
}));
|
|
149
|
-
}
|
|
150
|
-
function attachmentDerivedText(attachment) {
|
|
151
|
-
if (attachment.derived?.text?.label === "preview")
|
|
152
|
-
return undefined;
|
|
153
|
-
return attachment.derived?.text?.text;
|
|
154
|
-
}
|
|
155
|
-
function toolError(result) {
|
|
156
|
-
if (typeof result === "string")
|
|
157
|
-
return result;
|
|
158
|
-
if (!isObject(result))
|
|
159
|
-
return undefined;
|
|
160
|
-
if (typeof result.error === "string")
|
|
161
|
-
return result.error;
|
|
162
|
-
if (typeof result.message === "string")
|
|
163
|
-
return result.message;
|
|
164
|
-
return undefined;
|
|
165
|
-
}
|
|
166
|
-
function toolFromStoredAgentEvent(event, ts) {
|
|
167
|
-
if (event.type === "tool_execution_start") {
|
|
168
|
-
return {
|
|
169
|
-
id: event.toolCallId,
|
|
170
|
-
name: event.toolName,
|
|
171
|
-
status: "running",
|
|
172
|
-
args: event.args,
|
|
173
|
-
startedAt: ts,
|
|
174
|
-
updatedAt: ts,
|
|
175
|
-
};
|
|
176
|
-
}
|
|
177
|
-
if (event.type === "tool_execution_update") {
|
|
178
|
-
return {
|
|
179
|
-
id: event.toolCallId,
|
|
180
|
-
name: event.toolName,
|
|
181
|
-
status: "running",
|
|
182
|
-
args: event.args,
|
|
183
|
-
partialResult: event.partialResult,
|
|
184
|
-
updatedAt: ts,
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
|
-
if (event.type === "tool_execution_end") {
|
|
188
|
-
return {
|
|
189
|
-
id: event.toolCallId,
|
|
190
|
-
name: event.toolName,
|
|
191
|
-
status: event.isError ? "error" : "completed",
|
|
192
|
-
result: event.result,
|
|
193
|
-
error: event.isError ? toolError(event.result) : undefined,
|
|
194
|
-
completedAt: ts,
|
|
195
|
-
updatedAt: ts,
|
|
196
|
-
};
|
|
197
|
-
}
|
|
198
|
-
if (event.type === "message_update" && event.assistantMessageEvent.type === "toolcall_end") {
|
|
199
|
-
return {
|
|
200
|
-
id: event.assistantMessageEvent.toolCall.id,
|
|
201
|
-
name: event.assistantMessageEvent.toolCall.name,
|
|
202
|
-
status: "pending",
|
|
203
|
-
args: event.assistantMessageEvent.toolCall.arguments,
|
|
204
|
-
updatedAt: ts,
|
|
205
|
-
};
|
|
206
|
-
}
|
|
207
|
-
return undefined;
|
|
208
|
-
}
|
|
209
|
-
function mergeToolEvent(existing, patch) {
|
|
210
|
-
const terminal = patch.status === "completed" || patch.status === "error";
|
|
211
|
-
return {
|
|
212
|
-
...existing,
|
|
213
|
-
...patch,
|
|
214
|
-
args: patch.args ?? existing?.args,
|
|
215
|
-
partialResult: terminal ? undefined : (patch.partialResult ?? existing?.partialResult),
|
|
216
|
-
result: patch.result ?? existing?.result,
|
|
217
|
-
error: patch.error ?? existing?.error,
|
|
218
|
-
startedAt: existing?.startedAt ?? patch.startedAt,
|
|
219
|
-
};
|
|
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
|
-
}
|
|
268
|
-
function applyStoredAgentEventToMessage(message, record, options) {
|
|
269
|
-
const event = record.event;
|
|
270
|
-
const ts = toUnixMs(record.ts);
|
|
271
|
-
message.steps ??= [];
|
|
272
|
-
const steps = message.steps;
|
|
273
|
-
if (event.type === "message_update") {
|
|
274
|
-
const assistantEvent = event.assistantMessageEvent;
|
|
275
|
-
if (assistantEvent.type === "text_delta") {
|
|
276
|
-
appendDeltaStep(steps, message.id, "text", assistantEvent.delta, ts);
|
|
277
|
-
if (options.applyTextDeltas)
|
|
278
|
-
message.text += assistantEvent.delta;
|
|
279
|
-
}
|
|
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}`;
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
if (event.type === "message_end") {
|
|
287
|
-
closeOpenContentSteps(steps, ts);
|
|
288
|
-
if (event.usage)
|
|
289
|
-
message.usage = event.usage;
|
|
290
|
-
}
|
|
291
|
-
const tool = toolFromStoredAgentEvent(event, ts);
|
|
292
|
-
if (tool) {
|
|
293
|
-
upsertToolStep(steps, tool, ts);
|
|
294
|
-
const tools = message.tools ?? [];
|
|
295
|
-
const index = tools.findIndex((candidate) => candidate.id === tool.id);
|
|
296
|
-
if (index >= 0) {
|
|
297
|
-
tools[index] = mergeToolEvent(tools[index], tool);
|
|
298
|
-
}
|
|
299
|
-
else {
|
|
300
|
-
tools.push(tool);
|
|
301
|
-
}
|
|
302
|
-
message.tools = tools;
|
|
303
|
-
}
|
|
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
|
-
}
|
|
328
|
-
function webMessagesFromRecords(config, records, assistantName) {
|
|
329
|
-
const messages = [];
|
|
330
|
-
const messagesById = new Map();
|
|
331
|
-
const pendingAgentEvents = new Map();
|
|
332
|
-
for (const record of records) {
|
|
333
|
-
const message = webMessageFromRecord(config, record, assistantName);
|
|
334
|
-
if (message) {
|
|
335
|
-
messages.push(message);
|
|
336
|
-
messagesById.set(message.id, message);
|
|
337
|
-
const pending = pendingAgentEvents.get(message.id) ?? [];
|
|
338
|
-
for (const pendingRecord of pending) {
|
|
339
|
-
applyStoredAgentEventToMessage(message, pendingRecord, {
|
|
340
|
-
applyTextDeltas: !message.text && !message.silent,
|
|
341
|
-
applyThinkingDeltas: !message.thinking,
|
|
342
|
-
});
|
|
343
|
-
}
|
|
344
|
-
pendingAgentEvents.delete(message.id);
|
|
345
|
-
}
|
|
346
|
-
if (record.type === "agent_event") {
|
|
347
|
-
const existing = messagesById.get(record.messageId);
|
|
348
|
-
if (existing) {
|
|
349
|
-
applyStoredAgentEventToMessage(existing, record, {
|
|
350
|
-
applyTextDeltas: !existing.silent,
|
|
351
|
-
applyThinkingDeltas: true,
|
|
352
|
-
});
|
|
353
|
-
}
|
|
354
|
-
else {
|
|
355
|
-
const pending = pendingAgentEvents.get(record.messageId) ?? [];
|
|
356
|
-
pending.push(record);
|
|
357
|
-
pendingAgentEvents.set(record.messageId, pending);
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
for (const message of messages)
|
|
362
|
-
ensureFallbackSteps(message);
|
|
363
|
-
return messages;
|
|
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
|
-
}
|
|
372
|
-
function webMessageFromRecord(config, record, assistantName) {
|
|
373
|
-
if (!isUserVisibleRuntimeRecord(record))
|
|
374
|
-
return undefined;
|
|
375
|
-
if (record.type === "inbound") {
|
|
376
|
-
const attachmentText = record.attachments
|
|
377
|
-
.map((attachment) => attachmentDerivedText(attachment))
|
|
378
|
-
.filter((text) => !!text)
|
|
379
|
-
.join("\n");
|
|
380
|
-
return {
|
|
381
|
-
id: record.messageId,
|
|
382
|
-
role: "user",
|
|
383
|
-
who: record.authorName || getContactNickname(WEB_USER_NAME),
|
|
384
|
-
text: [record.text, attachmentText].filter(Boolean).join("\n"),
|
|
385
|
-
attachments: webAttachments(config, record.attachments),
|
|
386
|
-
ts: toUnixMs(record.ts),
|
|
387
|
-
};
|
|
388
|
-
}
|
|
389
|
-
if (record.type === "outbound" && !record.control) {
|
|
390
|
-
return {
|
|
391
|
-
id: record.webMessageId || record.messageIds[0] || `out_${record.recordId}`,
|
|
392
|
-
role: "assistant",
|
|
393
|
-
who: assistantName,
|
|
394
|
-
text: record.text,
|
|
395
|
-
attachments: webAttachments(config, record.attachments),
|
|
396
|
-
thinking: record.thinking,
|
|
397
|
-
thinkingMs: record.thinkingMs,
|
|
398
|
-
silent: record.silent || undefined,
|
|
399
|
-
ts: toUnixMs(record.ts),
|
|
400
|
-
};
|
|
401
|
-
}
|
|
402
|
-
if (record.type === "runtime" || record.type === "error") {
|
|
403
|
-
return {
|
|
404
|
-
id: `sys_${record.recordId}`,
|
|
405
|
-
role: "system",
|
|
406
|
-
who: "system",
|
|
407
|
-
text: record.type === "runtime" ? record.detail || record.event : record.message,
|
|
408
|
-
ts: toUnixMs(record.ts),
|
|
409
|
-
};
|
|
410
|
-
}
|
|
411
|
-
return undefined;
|
|
412
|
-
}
|
|
413
|
-
function commandArgs(command, args) {
|
|
414
|
-
if (!isObject(args))
|
|
415
|
-
return "";
|
|
416
|
-
if (command === "model")
|
|
417
|
-
return typeof args.model === "string" ? args.model : "";
|
|
418
|
-
if (command === "thinking")
|
|
419
|
-
return typeof args.level === "string" ? args.level : "";
|
|
420
|
-
if (command === "channel-trigger")
|
|
421
|
-
return typeof args.trigger === "string" ? args.trigger : "";
|
|
422
|
-
return "";
|
|
423
|
-
}
|
|
424
|
-
function formatSetting(setting) {
|
|
425
|
-
return `${setting.value} (${setting.source})`;
|
|
426
|
-
}
|
|
427
|
-
function agentSettingsPayload(familiarAgent, channelKey, personaName) {
|
|
428
|
-
const { model } = familiarAgent.resolveChannelModel(channelKey);
|
|
429
|
-
return {
|
|
430
|
-
model: familiarAgent.getModel(channelKey),
|
|
431
|
-
thinking: familiarAgent.getThinkingLevel(channelKey),
|
|
432
|
-
supportedThinking: supportedThinkingLevels(model),
|
|
433
|
-
persona: { name: personaName },
|
|
434
|
-
};
|
|
435
|
-
}
|
|
436
|
-
function sessionDto(session) {
|
|
437
|
-
return {
|
|
438
|
-
key: session.key,
|
|
439
|
-
label: session.label,
|
|
440
|
-
service: session.channel.service,
|
|
441
|
-
scope: session.channel.scope,
|
|
442
|
-
channelId: session.channel.channelId,
|
|
443
|
-
channelName: session.channel.channelName,
|
|
444
|
-
threadId: session.channel.threadId,
|
|
445
|
-
isDefault: session.isDefault,
|
|
446
|
-
};
|
|
447
|
-
}
|
|
448
|
-
export async function startWebDaemon(config, familiarAgent, discordDaemon, options = {}) {
|
|
30
|
+
export async function startWebDaemon(config, familiarAgent, agentCore, options = {}) {
|
|
449
31
|
setAddedModelsPath(config.workspace.dataDir);
|
|
450
32
|
setContactNotePath(config.persona.contact);
|
|
451
33
|
await refreshContactNote();
|
|
452
34
|
const persona = await loadPersona(config);
|
|
453
35
|
const personaName = parsePersonaName(persona.soul);
|
|
454
36
|
const auth = createAuth(config);
|
|
455
|
-
const
|
|
456
|
-
const
|
|
457
|
-
const runtimeSubscriptions = 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?.();
|
|
484
|
-
const publish = (event) => {
|
|
485
|
-
const fullEvent = { ...event, eventId: eventId(), ts: event.ts ?? Date.now() };
|
|
486
|
-
const events = eventsByChannel.get(fullEvent.channelKey ?? "") ?? [];
|
|
487
|
-
events.push(fullEvent);
|
|
488
|
-
if (events.length > EVENT_REPLAY_LIMIT)
|
|
489
|
-
events.shift();
|
|
490
|
-
eventsByChannel.set(fullEvent.channelKey ?? "", events);
|
|
491
|
-
const frame = encodeFrame(JSON.stringify(fullEvent));
|
|
492
|
-
for (const client of clients) {
|
|
493
|
-
if (client.channelKey === fullEvent.channelKey && !client.socket.destroyed) {
|
|
494
|
-
if (client.authed) {
|
|
495
|
-
client.socket.write(frame);
|
|
496
|
-
}
|
|
497
|
-
else {
|
|
498
|
-
const pendingEvents = client.pendingEvents ?? [];
|
|
499
|
-
pendingEvents.push(fullEvent);
|
|
500
|
-
client.pendingEvents = pendingEvents;
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
return fullEvent;
|
|
505
|
-
};
|
|
506
|
-
const publishDelta = (channelKey, messageIdValue, part, text, ts) => publish({ type: "delta", channelKey, messageId: messageIdValue, part, content: text, text, ts });
|
|
507
|
-
const publishStoredAgentEvent = (channelKey, messageIdValue, storedEvent, ts) => {
|
|
508
|
-
touchInFlight(messageIdValue);
|
|
509
|
-
if (storedEvent.type === "message_start" && storedEvent.role === "assistant") {
|
|
510
|
-
const entry = getOrCreateInFlight(messageIdValue);
|
|
511
|
-
entry.locallyStreamed = true;
|
|
512
|
-
entry.silentFilter = createSilentFilterState();
|
|
513
|
-
entry.pendingStartTs = ts;
|
|
514
|
-
entry.startedSilent = false;
|
|
515
|
-
}
|
|
516
|
-
const startedSilentMessage = () => {
|
|
517
|
-
const entry = inFlightMessages.get(messageIdValue);
|
|
518
|
-
if (!entry || entry.startedSilent)
|
|
519
|
-
return false;
|
|
520
|
-
const startTs = entry.pendingStartTs;
|
|
521
|
-
entry.pendingStartTs = undefined;
|
|
522
|
-
entry.startedSilent = true;
|
|
523
|
-
publish({
|
|
524
|
-
type: "message_started",
|
|
525
|
-
channelKey,
|
|
526
|
-
messageId: messageIdValue,
|
|
527
|
-
role: "assistant",
|
|
528
|
-
who: personaName,
|
|
529
|
-
ts: startTs,
|
|
530
|
-
});
|
|
531
|
-
return true;
|
|
532
|
-
};
|
|
533
|
-
if (storedEvent.type === "message_update") {
|
|
534
|
-
const assistantEvent = storedEvent.assistantMessageEvent;
|
|
535
|
-
if (assistantEvent.type === "thinking_delta") {
|
|
536
|
-
startedSilentMessage();
|
|
537
|
-
publishDelta(channelKey, messageIdValue, "thinking", assistantEvent.delta, ts);
|
|
538
|
-
}
|
|
539
|
-
if (assistantEvent.type === "text_delta") {
|
|
540
|
-
const filter = inFlightMessages.get(messageIdValue)?.silentFilter;
|
|
541
|
-
if (!filter) {
|
|
542
|
-
startedSilentMessage();
|
|
543
|
-
publishDelta(channelKey, messageIdValue, "text", assistantEvent.delta, ts);
|
|
544
|
-
}
|
|
545
|
-
else {
|
|
546
|
-
const result = consumeSilentDelta(filter, assistantEvent.delta);
|
|
547
|
-
if (result.kind === "emit" && result.text) {
|
|
548
|
-
startedSilentMessage();
|
|
549
|
-
publishDelta(channelKey, messageIdValue, "text", result.text, ts);
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
if (storedEvent.type === "tool_execution_start") {
|
|
555
|
-
startedSilentMessage();
|
|
556
|
-
}
|
|
557
|
-
if (storedEvent.type === "message_end" && storedEvent.role === "assistant") {
|
|
558
|
-
const entry = inFlightMessages.get(messageIdValue);
|
|
559
|
-
const filter = entry?.silentFilter;
|
|
560
|
-
let silent = false;
|
|
561
|
-
if (filter && entry) {
|
|
562
|
-
const final = finalizeSilentFilter(filter);
|
|
563
|
-
silent = final.silent;
|
|
564
|
-
if (!silent) {
|
|
565
|
-
startedSilentMessage();
|
|
566
|
-
if (final.flush) {
|
|
567
|
-
publishDelta(channelKey, messageIdValue, "text", final.flush, ts);
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
else {
|
|
571
|
-
entry.startedSilent = true;
|
|
572
|
-
entry.pendingStartTs = undefined;
|
|
573
|
-
}
|
|
574
|
-
entry.silentFilter = undefined;
|
|
575
|
-
}
|
|
576
|
-
else {
|
|
577
|
-
startedSilentMessage();
|
|
578
|
-
}
|
|
579
|
-
publish({
|
|
580
|
-
type: "message_completed",
|
|
581
|
-
channelKey,
|
|
582
|
-
messageId: messageIdValue,
|
|
583
|
-
usage: storedEvent.usage,
|
|
584
|
-
silent: silent || undefined,
|
|
585
|
-
ts,
|
|
586
|
-
});
|
|
587
|
-
}
|
|
588
|
-
const tool = toolFromStoredAgentEvent(storedEvent, ts ?? Date.now());
|
|
589
|
-
if (tool)
|
|
590
|
-
publish({ type: "tool_event", channelKey, messageId: messageIdValue, tool, ts });
|
|
591
|
-
};
|
|
592
|
-
const subscribeRuntime = (runtime) => {
|
|
593
|
-
if (runtimeSubscriptions.has(runtime.channelKey))
|
|
594
|
-
return;
|
|
595
|
-
const unsubscribeRecords = runtime.subscribe((record) => {
|
|
596
|
-
if (record.type === "inbound") {
|
|
597
|
-
publish({
|
|
598
|
-
type: "message_started",
|
|
599
|
-
channelKey: runtime.channelKey,
|
|
600
|
-
messageId: record.messageId,
|
|
601
|
-
role: "user",
|
|
602
|
-
who: record.authorName || getContactNickname(WEB_USER_NAME),
|
|
603
|
-
ts: toUnixMs(record.ts),
|
|
604
|
-
});
|
|
605
|
-
publishDelta(runtime.channelKey, record.messageId, "text", record.text, toUnixMs(record.ts));
|
|
606
|
-
publish({
|
|
607
|
-
type: "message_completed",
|
|
608
|
-
channelKey: runtime.channelKey,
|
|
609
|
-
messageId: record.messageId,
|
|
610
|
-
attachments: webAttachments(config, record.attachments),
|
|
611
|
-
ts: toUnixMs(record.ts),
|
|
612
|
-
});
|
|
613
|
-
}
|
|
614
|
-
if (record.type === "outbound" && !record.control) {
|
|
615
|
-
const outboundId = record.webMessageId || record.messageIds[0] || `out_${record.recordId}`;
|
|
616
|
-
const completion = {
|
|
617
|
-
type: "message_completed",
|
|
618
|
-
channelKey: runtime.channelKey,
|
|
619
|
-
messageId: outboundId,
|
|
620
|
-
thinkingMs: record.thinkingMs,
|
|
621
|
-
attachments: webAttachments(config, record.attachments),
|
|
622
|
-
silent: record.silent || undefined,
|
|
623
|
-
ts: toUnixMs(record.ts),
|
|
624
|
-
};
|
|
625
|
-
if (inFlightMessages.get(outboundId)?.locallyStreamed) {
|
|
626
|
-
inFlightMessages.delete(outboundId);
|
|
627
|
-
publish(completion);
|
|
628
|
-
return;
|
|
629
|
-
}
|
|
630
|
-
if (!record.silent) {
|
|
631
|
-
publish({
|
|
632
|
-
type: "message_started",
|
|
633
|
-
channelKey: runtime.channelKey,
|
|
634
|
-
messageId: outboundId,
|
|
635
|
-
role: "assistant",
|
|
636
|
-
who: personaName,
|
|
637
|
-
ts: toUnixMs(record.ts),
|
|
638
|
-
});
|
|
639
|
-
if (record.thinking)
|
|
640
|
-
publishDelta(runtime.channelKey, outboundId, "thinking", record.thinking, toUnixMs(record.ts));
|
|
641
|
-
if (record.text)
|
|
642
|
-
publishDelta(runtime.channelKey, outboundId, "text", record.text, toUnixMs(record.ts));
|
|
643
|
-
}
|
|
644
|
-
publish(completion);
|
|
645
|
-
}
|
|
646
|
-
});
|
|
647
|
-
const unsubscribeAgentEvents = runtime.subscribeAgentEvents((agentEvent) => {
|
|
648
|
-
publishStoredAgentEvent(runtime.channelKey, agentEvent.messageId, agentEvent.event, agentEvent.ts);
|
|
649
|
-
});
|
|
650
|
-
runtimeSubscriptions.set(runtime.channelKey, () => {
|
|
651
|
-
unsubscribeRecords();
|
|
652
|
-
unsubscribeAgentEvents();
|
|
653
|
-
});
|
|
654
|
-
};
|
|
37
|
+
const eventHub = createWebEventHub(config, personaName);
|
|
38
|
+
const { appendAndPublishError, publish, publishDelta } = eventHub;
|
|
655
39
|
const getRuntime = async (channelKey) => {
|
|
656
|
-
|
|
657
|
-
|
|
40
|
+
if (!agentCore.hasSessionSource())
|
|
41
|
+
throw new HttpError(503, "Owner identity is not established yet.");
|
|
42
|
+
const runtime = await agentCore.getRuntimeForWebChannel(channelKey);
|
|
43
|
+
eventHub.subscribeRuntime(runtime);
|
|
658
44
|
return runtime;
|
|
659
45
|
};
|
|
660
46
|
const subscribeKnownRuntimes = async () => {
|
|
661
|
-
|
|
47
|
+
if (!agentCore.hasSessionSource())
|
|
48
|
+
return;
|
|
49
|
+
const sessions = await agentCore.getWebSessions();
|
|
662
50
|
await Promise.all(sessions.map(async (session) => {
|
|
663
|
-
const runtime = await
|
|
664
|
-
subscribeRuntime(runtime);
|
|
51
|
+
const runtime = await agentCore.getRuntimeForWebChannel(session.key);
|
|
52
|
+
eventHub.subscribeRuntime(runtime);
|
|
665
53
|
}));
|
|
666
54
|
};
|
|
667
55
|
const getChannelKeyFromRequest = (url, body) => {
|
|
668
56
|
const queryKey = url.searchParams.get("channelKey");
|
|
669
57
|
if (queryKey)
|
|
670
58
|
return queryKey;
|
|
671
|
-
if (
|
|
59
|
+
if (isRecord(body) && typeof body.channelKey === "string")
|
|
672
60
|
return body.channelKey;
|
|
673
61
|
return undefined;
|
|
674
62
|
};
|
|
@@ -711,20 +99,25 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
|
|
|
711
99
|
return { ok: false, error: "format must be provider/model-id" };
|
|
712
100
|
return { ok: true, model: ref.key, ref };
|
|
713
101
|
};
|
|
714
|
-
const replay = (client, channelKey, lastEventId) => {
|
|
715
|
-
const events = eventsByChannel.get(channelKey) ?? [];
|
|
716
|
-
replayEvents(client, events, lastEventId, () => publish({ type: "replay_window_lost", channelKey }));
|
|
717
|
-
};
|
|
718
102
|
const promptForRuntime = async (runtime, jobId, prompt, attachments = [], onTurnEnd) => {
|
|
719
|
-
|
|
103
|
+
return promptAssistantMessage({
|
|
104
|
+
runtime,
|
|
105
|
+
jobId,
|
|
106
|
+
assistantMessageId: messageId(),
|
|
107
|
+
dispatch: (onEvent) => agentCore.promptForRuntime(runtime, jobId, prompt, attachments, onEvent, onTurnEnd),
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
const promptAssistantMessage = async (options) => {
|
|
111
|
+
const { runtime, jobId, assistantMessageId } = options;
|
|
720
112
|
const summary = { thinking: "" };
|
|
721
113
|
const recorder = createAgentEventRecorder((storedEvent) => runtime.noteAgentEvent(jobId, assistantMessageId, storedEvent, { notify: false }));
|
|
722
114
|
let started = false;
|
|
723
115
|
let reply;
|
|
724
116
|
try {
|
|
725
|
-
reply = await
|
|
117
|
+
reply = await options.dispatch(async (event) => {
|
|
726
118
|
if (event.type === "message_start" && event.message.role === "assistant" && !started) {
|
|
727
119
|
started = true;
|
|
120
|
+
options.onAssistantStart?.();
|
|
728
121
|
}
|
|
729
122
|
updateAgentEventSummary(summary, event);
|
|
730
123
|
const storedEvent = storedAgentEventFromAgentEvent(event);
|
|
@@ -732,23 +125,26 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
|
|
|
732
125
|
runtime.publishAgentEvent(jobId, assistantMessageId, storedEvent);
|
|
733
126
|
await recorder.record(storedEvent);
|
|
734
127
|
}
|
|
735
|
-
}
|
|
128
|
+
});
|
|
736
129
|
}
|
|
737
130
|
finally {
|
|
738
131
|
await recorder.flush();
|
|
739
132
|
}
|
|
740
133
|
const parsed = parseAgentReply(reply.text);
|
|
741
134
|
const finalText = parsed.silent ? "" : reply.text;
|
|
742
|
-
if (!started
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
135
|
+
if (!started) {
|
|
136
|
+
options.onAssistantStart?.();
|
|
137
|
+
if (!parsed.silent) {
|
|
138
|
+
publish({
|
|
139
|
+
type: "message_started",
|
|
140
|
+
channelKey: runtime.channelKey,
|
|
141
|
+
messageId: assistantMessageId,
|
|
142
|
+
role: "assistant",
|
|
143
|
+
who: personaName,
|
|
144
|
+
});
|
|
145
|
+
if (finalText) {
|
|
146
|
+
publishDelta(runtime.channelKey, assistantMessageId, "text", finalText);
|
|
147
|
+
}
|
|
752
148
|
}
|
|
753
149
|
}
|
|
754
150
|
const thinkingMs = thinkingDurationMs(summary);
|
|
@@ -760,8 +156,7 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
|
|
|
760
156
|
attachments: webAttachments(config, reply.attachments),
|
|
761
157
|
silent: parsed.silent || undefined,
|
|
762
158
|
});
|
|
763
|
-
|
|
764
|
-
entry.locallyStreamed = true;
|
|
159
|
+
eventHub.markLocallyStreamed(assistantMessageId);
|
|
765
160
|
return {
|
|
766
161
|
text: finalText,
|
|
767
162
|
messageId: assistantMessageId,
|
|
@@ -771,6 +166,81 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
|
|
|
771
166
|
silent: parsed.silent,
|
|
772
167
|
};
|
|
773
168
|
};
|
|
169
|
+
const retryLatestAssistant = async (runtime) => {
|
|
170
|
+
if (runtime.hasActiveJob())
|
|
171
|
+
throw new Error("Cannot retry while a turn is running");
|
|
172
|
+
const target = runtime.latestAssistantRetryTarget();
|
|
173
|
+
if (!target)
|
|
174
|
+
throw new Error("No assistant message to retry");
|
|
175
|
+
const jobId = randomUUID();
|
|
176
|
+
const assistantMessageId = messageId();
|
|
177
|
+
const replaceMessage = () => {
|
|
178
|
+
publish({
|
|
179
|
+
type: "message_replaced",
|
|
180
|
+
channelKey: runtime.channelKey,
|
|
181
|
+
oldMessageId: target.messageId,
|
|
182
|
+
newMessageId: assistantMessageId,
|
|
183
|
+
});
|
|
184
|
+
};
|
|
185
|
+
try {
|
|
186
|
+
const reply = await promptAssistantMessage({
|
|
187
|
+
runtime,
|
|
188
|
+
jobId,
|
|
189
|
+
assistantMessageId,
|
|
190
|
+
onAssistantStart: replaceMessage,
|
|
191
|
+
dispatch: (onEvent) => familiarAgent.retryLastAssistant(runtime.channelKey, onEvent, {
|
|
192
|
+
onTurnEnd: () => {
|
|
193
|
+
publish({
|
|
194
|
+
type: "status",
|
|
195
|
+
channelKey: runtime.channelKey,
|
|
196
|
+
kind: "idle",
|
|
197
|
+
});
|
|
198
|
+
},
|
|
199
|
+
}),
|
|
200
|
+
});
|
|
201
|
+
await runtime.noteAssistantRetry({
|
|
202
|
+
oldMessageId: target.messageId,
|
|
203
|
+
newMessageId: assistantMessageId,
|
|
204
|
+
jobId,
|
|
205
|
+
triggerRecordId: target.triggerRecordId,
|
|
206
|
+
});
|
|
207
|
+
await runtime.noteOutbound({
|
|
208
|
+
text: reply.text,
|
|
209
|
+
messageIds: [reply.messageId],
|
|
210
|
+
webMessageId: reply.messageId,
|
|
211
|
+
attachments: reply.attachments,
|
|
212
|
+
thinking: reply.thinking,
|
|
213
|
+
thinkingMs: reply.thinkingMs,
|
|
214
|
+
silent: reply.silent,
|
|
215
|
+
replyToMessageId: target.messageId,
|
|
216
|
+
jobId,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
const message = errorMessage(error);
|
|
221
|
+
await appendAndPublishError(runtime, message);
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
const deleteLatestAssistant = async (runtime) => {
|
|
225
|
+
if (runtime.hasActiveJob())
|
|
226
|
+
throw new Error("Cannot delete while a turn is running");
|
|
227
|
+
const target = runtime.latestAssistantDeleteTarget();
|
|
228
|
+
if (!target)
|
|
229
|
+
throw new Error("No assistant message to delete");
|
|
230
|
+
try {
|
|
231
|
+
await familiarAgent.deleteLastAssistant(runtime.channelKey);
|
|
232
|
+
await runtime.noteMessageDelete(target.messageId);
|
|
233
|
+
publish({
|
|
234
|
+
type: "message_deleted",
|
|
235
|
+
channelKey: runtime.channelKey,
|
|
236
|
+
messageId: target.messageId,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
const message = errorMessage(error);
|
|
241
|
+
await appendAndPublishError(runtime, message);
|
|
242
|
+
}
|
|
243
|
+
};
|
|
774
244
|
const drainJobs = async (runtime) => {
|
|
775
245
|
for (;;) {
|
|
776
246
|
const dispatch = runtime.beginNextJob();
|
|
@@ -798,17 +268,16 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
|
|
|
798
268
|
catch (error) {
|
|
799
269
|
if (!runtime.hasActiveJob(dispatch.job.jobId))
|
|
800
270
|
return;
|
|
801
|
-
const message =
|
|
271
|
+
const message = errorMessage(error);
|
|
802
272
|
await runtime.failActiveJob(message);
|
|
803
|
-
await runtime
|
|
804
|
-
publish({ type: "error", channelKey: runtime.channelKey, code: "unknown", message });
|
|
273
|
+
await appendAndPublishError(runtime, message);
|
|
805
274
|
}
|
|
806
275
|
}
|
|
807
276
|
};
|
|
808
277
|
const applyControlCommand = async (runtime, control) => {
|
|
809
278
|
if (control.command === "stop") {
|
|
810
|
-
familiarAgent.
|
|
811
|
-
return "Stopped
|
|
279
|
+
await familiarAgent.abort(runtime.channelKey);
|
|
280
|
+
return "Stopped current work.";
|
|
812
281
|
}
|
|
813
282
|
if (control.command === "new") {
|
|
814
283
|
await familiarAgent.reset(runtime.channelKey);
|
|
@@ -844,6 +313,270 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
|
|
|
844
313
|
}
|
|
845
314
|
return "Compact is not wired for this runtime yet. I logged the command, but I won't run lossy compaction here.";
|
|
846
315
|
};
|
|
316
|
+
const webRoutes = new Map();
|
|
317
|
+
const route = (method, pathname, handler) => {
|
|
318
|
+
webRoutes.set(`${method} ${pathname}`, handler);
|
|
319
|
+
};
|
|
320
|
+
route("GET", "/api/web/auth/mode", async (_request, response) => {
|
|
321
|
+
sendJson(response, 200, { mode: config.web.authMode, personaName });
|
|
322
|
+
return true;
|
|
323
|
+
});
|
|
324
|
+
route("GET", "/api/web/sessions", async (_request, response) => {
|
|
325
|
+
if (!agentCore.hasSessionSource()) {
|
|
326
|
+
sendJson(response, 200, { sessions: [] });
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
const sessions = await agentCore.getWebSessions();
|
|
330
|
+
sendJson(response, 200, { sessions: sessions.map(sessionDto) });
|
|
331
|
+
return true;
|
|
332
|
+
});
|
|
333
|
+
route("GET", "/api/web/history", async (_request, response, url) => {
|
|
334
|
+
const runtime = await getRuntime(getChannelKeyFromRequest(url));
|
|
335
|
+
const limit = Math.min(Math.max(Number(url.searchParams.get("limit") ?? 50) || 50, 1), 200);
|
|
336
|
+
const before = url.searchParams.get("before") ?? undefined;
|
|
337
|
+
sendJson(response, 200, webHistoryPayload(config, runtime.getRecords(), personaName, runtime.channelKey, { limit, before }));
|
|
338
|
+
return true;
|
|
339
|
+
});
|
|
340
|
+
route("GET", "/api/web/agent/settings", async (_request, response, url) => {
|
|
341
|
+
const runtime = await getRuntime(getChannelKeyFromRequest(url));
|
|
342
|
+
sendJson(response, 200, agentSettingsPayload(familiarAgent, runtime.channelKey, personaName));
|
|
343
|
+
return true;
|
|
344
|
+
});
|
|
345
|
+
route("GET", "/api/web/agent/models", async (_request, response) => {
|
|
346
|
+
sendJson(response, 200, getAgentModelsPayload());
|
|
347
|
+
return true;
|
|
348
|
+
});
|
|
349
|
+
route("POST", "/api/web/agent/models", async (request, response) => {
|
|
350
|
+
const body = await readJsonBody(request);
|
|
351
|
+
if (!isRecord(body)) {
|
|
352
|
+
sendJson(response, 400, { error: "body is required" });
|
|
353
|
+
return true;
|
|
354
|
+
}
|
|
355
|
+
const parsed = parseRequestedModel(body.model);
|
|
356
|
+
if (!parsed.ok) {
|
|
357
|
+
sendJson(response, 400, { error: parsed.error });
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
if (!Object.hasOwn(PROVIDER_DEFAULTS, parsed.ref.provider) &&
|
|
361
|
+
!getProviders().includes(parsed.ref.provider)) {
|
|
362
|
+
sendJson(response, 400, { error: `unsupported provider: ${parsed.ref.provider}` });
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
if (config.models.allow.includes(parsed.model) || loadAddedModels().includes(parsed.model)) {
|
|
366
|
+
sendJson(response, 200, getAgentModelsPayload());
|
|
367
|
+
return true;
|
|
368
|
+
}
|
|
369
|
+
await addModel(parsed.model);
|
|
370
|
+
sendJson(response, 200, getAgentModelsPayload());
|
|
371
|
+
return true;
|
|
372
|
+
});
|
|
373
|
+
route("DELETE", "/api/web/agent/models", async (request, response) => {
|
|
374
|
+
const body = await readJsonBody(request);
|
|
375
|
+
if (!isRecord(body)) {
|
|
376
|
+
sendJson(response, 400, { error: "body is required" });
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
379
|
+
const parsed = parseRequestedModel(body.model);
|
|
380
|
+
if (!parsed.ok) {
|
|
381
|
+
sendJson(response, 400, { error: parsed.error });
|
|
382
|
+
return true;
|
|
383
|
+
}
|
|
384
|
+
if (!loadAddedModels().includes(parsed.model)) {
|
|
385
|
+
sendJson(response, 400, { error: "model is not user-added" });
|
|
386
|
+
return true;
|
|
387
|
+
}
|
|
388
|
+
await removeModel(parsed.model);
|
|
389
|
+
sendJson(response, 200, getAgentModelsPayload());
|
|
390
|
+
return true;
|
|
391
|
+
});
|
|
392
|
+
route("GET", "/api/web/config", async (_request, response) => {
|
|
393
|
+
sendJson(response, 200, getConfigPayload());
|
|
394
|
+
return true;
|
|
395
|
+
});
|
|
396
|
+
route("POST", "/api/web/config", async (request, response) => {
|
|
397
|
+
const body = await readJsonBody(request);
|
|
398
|
+
if (!isRecord(body) || typeof body.key !== "string") {
|
|
399
|
+
sendJson(response, 400, { error: "key is required" });
|
|
400
|
+
return true;
|
|
401
|
+
}
|
|
402
|
+
if (!isConfigKey(body.key)) {
|
|
403
|
+
sendJson(response, 400, { error: `unknown config key: ${body.key}` });
|
|
404
|
+
return true;
|
|
405
|
+
}
|
|
406
|
+
const key = body.key;
|
|
407
|
+
const entry = CONFIG_REGISTRY[key];
|
|
408
|
+
try {
|
|
409
|
+
const validated = entry.validate(body.value, config);
|
|
410
|
+
await commitConfigChange(key, validated, { config, scheduler: agentCore });
|
|
411
|
+
}
|
|
412
|
+
catch (error) {
|
|
413
|
+
const message = errorMessage(error);
|
|
414
|
+
sendJson(response, 400, { error: message });
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
417
|
+
sendJson(response, 200, getConfigPayload());
|
|
418
|
+
return true;
|
|
419
|
+
});
|
|
420
|
+
route("DELETE", "/api/web/config", async (request, response) => {
|
|
421
|
+
const body = await readJsonBody(request);
|
|
422
|
+
if (!isRecord(body) || typeof body.key !== "string") {
|
|
423
|
+
sendJson(response, 400, { error: "key is required" });
|
|
424
|
+
return true;
|
|
425
|
+
}
|
|
426
|
+
if (!isConfigKey(body.key)) {
|
|
427
|
+
sendJson(response, 400, { error: `unknown config key: ${body.key}` });
|
|
428
|
+
return true;
|
|
429
|
+
}
|
|
430
|
+
const key = body.key;
|
|
431
|
+
try {
|
|
432
|
+
await clearConfigChange(key, { config, scheduler: agentCore });
|
|
433
|
+
}
|
|
434
|
+
catch (error) {
|
|
435
|
+
const message = errorMessage(error);
|
|
436
|
+
sendJson(response, 400, { error: message });
|
|
437
|
+
return true;
|
|
438
|
+
}
|
|
439
|
+
sendJson(response, 200, getConfigPayload());
|
|
440
|
+
return true;
|
|
441
|
+
});
|
|
442
|
+
route("GET", "/api/web/memes", async (_request, response) => {
|
|
443
|
+
try {
|
|
444
|
+
const markdown = await readFile(memeCatalogPath(config), "utf8");
|
|
445
|
+
sendJson(response, 200, { families: parseMemeCatalog(markdown) });
|
|
446
|
+
}
|
|
447
|
+
catch {
|
|
448
|
+
sendJson(response, 500, { error: "memes catalog unavailable" });
|
|
449
|
+
}
|
|
450
|
+
return true;
|
|
451
|
+
});
|
|
452
|
+
route("POST", "/api/web/send", async (request, response, url) => {
|
|
453
|
+
const contentType = request.headers["content-type"] ?? "";
|
|
454
|
+
const isMultipart = Array.isArray(contentType)
|
|
455
|
+
? contentType.some((value) => value.includes("multipart/form-data"))
|
|
456
|
+
: contentType.includes("multipart/form-data");
|
|
457
|
+
const body = isMultipart ? await readMultipartBody(request, contentType) : await readJsonBody(request);
|
|
458
|
+
const runtime = await getRuntime(getChannelKeyFromRequest(url, body));
|
|
459
|
+
if (!isRecord(body) || typeof body.text !== "string") {
|
|
460
|
+
sendJson(response, 400, { error: "text is required" });
|
|
461
|
+
return true;
|
|
462
|
+
}
|
|
463
|
+
if (!isMultipart && isRecord(body) && Array.isArray(body.attachments) && body.attachments.length > 0) {
|
|
464
|
+
sendJson(response, 400, { error: "attachments require multipart form data" });
|
|
465
|
+
return true;
|
|
466
|
+
}
|
|
467
|
+
const rawAttachments = Array.isArray(body.attachments) ? body.attachments : [];
|
|
468
|
+
const attachments = await materializeInboundAttachments(config, rawAttachments
|
|
469
|
+
.filter((attachment) => isWebUploadAttachment(attachment))
|
|
470
|
+
.map((attachment) => ({ ...attachment, source: "web" })));
|
|
471
|
+
if (!body.text.trim() && attachments.length === 0) {
|
|
472
|
+
sendJson(response, 400, { error: "text or attachment is required" });
|
|
473
|
+
return true;
|
|
474
|
+
}
|
|
475
|
+
const id = messageId("user");
|
|
476
|
+
const ts = Date.now();
|
|
477
|
+
const input = {
|
|
478
|
+
messageId: id,
|
|
479
|
+
authorId: config.discord.ownerId,
|
|
480
|
+
authorName: getContactNickname(WEB_USER_NAME),
|
|
481
|
+
text: body.text,
|
|
482
|
+
isBot: false,
|
|
483
|
+
mentionedBot: true,
|
|
484
|
+
remoteTimestamp: new Date(ts).toISOString(),
|
|
485
|
+
checkpoint: { messageId: id },
|
|
486
|
+
attachments,
|
|
487
|
+
};
|
|
488
|
+
await runtime.ingestInbound(input, { mode: "queue" });
|
|
489
|
+
void drainJobs(runtime).catch((error) => console.error("Web job drain failed", error));
|
|
490
|
+
sendJson(response, 200, { id, ts, channelKey: runtime.channelKey });
|
|
491
|
+
return true;
|
|
492
|
+
});
|
|
493
|
+
route("POST", "/api/web/retry", async (request, response, url) => {
|
|
494
|
+
const body = await readJsonBody(request);
|
|
495
|
+
const runtime = await getRuntime(getChannelKeyFromRequest(url, body));
|
|
496
|
+
void retryLatestAssistant(runtime).catch((error) => console.error("Web retry failed", error));
|
|
497
|
+
sendJson(response, 200, { ok: true, channelKey: runtime.channelKey });
|
|
498
|
+
return true;
|
|
499
|
+
});
|
|
500
|
+
route("POST", "/api/web/delete", async (request, response, url) => {
|
|
501
|
+
const body = await readJsonBody(request);
|
|
502
|
+
const runtime = await getRuntime(getChannelKeyFromRequest(url, body));
|
|
503
|
+
void deleteLatestAssistant(runtime).catch((error) => console.error("Web delete failed", error));
|
|
504
|
+
sendJson(response, 200, { ok: true, channelKey: runtime.channelKey });
|
|
505
|
+
return true;
|
|
506
|
+
});
|
|
507
|
+
route("POST", "/api/web/agent/settings", async (request, response, url) => {
|
|
508
|
+
const body = await readJsonBody(request);
|
|
509
|
+
const runtime = await getRuntime(getChannelKeyFromRequest(url, body));
|
|
510
|
+
if (!isRecord(body)) {
|
|
511
|
+
sendJson(response, 400, { error: "body is required" });
|
|
512
|
+
return true;
|
|
513
|
+
}
|
|
514
|
+
try {
|
|
515
|
+
if (typeof body.model === "string")
|
|
516
|
+
await familiarAgent.setModel(runtime.channelKey, body.model);
|
|
517
|
+
if (typeof body.thinking === "string")
|
|
518
|
+
await familiarAgent.setThinkingLevel(runtime.channelKey, body.thinking);
|
|
519
|
+
}
|
|
520
|
+
catch (error) {
|
|
521
|
+
const message = errorMessage(error);
|
|
522
|
+
sendJson(response, 400, { error: message });
|
|
523
|
+
return true;
|
|
524
|
+
}
|
|
525
|
+
sendJson(response, 200, agentSettingsPayload(familiarAgent, runtime.channelKey, personaName));
|
|
526
|
+
return true;
|
|
527
|
+
});
|
|
528
|
+
route("POST", "/api/web/agent/new", async (request, response, url) => {
|
|
529
|
+
const body = await readJsonBody(request);
|
|
530
|
+
const runtime = await getRuntime(getChannelKeyFromRequest(url, body));
|
|
531
|
+
await familiarAgent.reset(runtime.channelKey);
|
|
532
|
+
await runtime.resetConversation("new conversation requested from web");
|
|
533
|
+
publish({
|
|
534
|
+
type: "status",
|
|
535
|
+
channelKey: runtime.channelKey,
|
|
536
|
+
kind: "idle",
|
|
537
|
+
detail: "started fresh from web",
|
|
538
|
+
});
|
|
539
|
+
sendJson(response, 200, { ok: true });
|
|
540
|
+
return true;
|
|
541
|
+
});
|
|
542
|
+
route("POST", "/api/web/control", async (request, response, url) => {
|
|
543
|
+
const body = await readJsonBody(request);
|
|
544
|
+
const runtime = await getRuntime(getChannelKeyFromRequest(url, body));
|
|
545
|
+
if (!isRecord(body) || typeof body.command !== "string") {
|
|
546
|
+
sendJson(response, 400, { error: "command is required" });
|
|
547
|
+
return true;
|
|
548
|
+
}
|
|
549
|
+
if (config.web.authMode === "public-2fa" && body.command === "login") {
|
|
550
|
+
const token = isRecord(body.args) && typeof body.args.token === "string" ? body.args.token : "";
|
|
551
|
+
if (!config.web.totpSecret || !verifyTotp(config.web.totpSecret, token)) {
|
|
552
|
+
sendJson(response, 401, { ok: false, message: "Invalid TOTP token." });
|
|
553
|
+
return true;
|
|
554
|
+
}
|
|
555
|
+
const sessionId = auth.createSession();
|
|
556
|
+
sendJson(response, 200, { ok: true, message: "Authenticated." }, { "set-cookie": sessionCookie(sessionId) });
|
|
557
|
+
return true;
|
|
558
|
+
}
|
|
559
|
+
const args = commandArgs(body.command, body.args);
|
|
560
|
+
const input = {
|
|
561
|
+
messageId: messageId("control"),
|
|
562
|
+
authorId: config.discord.ownerId,
|
|
563
|
+
authorName: getContactNickname(WEB_USER_NAME),
|
|
564
|
+
text: `/${body.command}${args ? ` ${args}` : ""}`,
|
|
565
|
+
isBot: false,
|
|
566
|
+
mentionedBot: true,
|
|
567
|
+
remoteTimestamp: new Date().toISOString(),
|
|
568
|
+
};
|
|
569
|
+
const control = runtime.parseControlCommand(input);
|
|
570
|
+
if (!control) {
|
|
571
|
+
sendJson(response, 400, { ok: false, message: "Unsupported command." });
|
|
572
|
+
return true;
|
|
573
|
+
}
|
|
574
|
+
await runtime.noteControlCommand(input, control);
|
|
575
|
+
const message = await applyControlCommand(runtime, control);
|
|
576
|
+
await runtime.noteOutbound({ text: message, messageIds: [], control: control.command });
|
|
577
|
+
sendJson(response, 200, { ok: true, message, channelKey: runtime.channelKey });
|
|
578
|
+
return true;
|
|
579
|
+
});
|
|
847
580
|
const handleApi = async (request, response, url) => {
|
|
848
581
|
if (!url.pathname.startsWith("/api/web/"))
|
|
849
582
|
return false;
|
|
@@ -855,258 +588,17 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
|
|
|
855
588
|
if (request.method === "GET" && url.pathname.startsWith("/api/web/attachments/")) {
|
|
856
589
|
return serveAttachment(config, response, url.pathname, request.headers.range);
|
|
857
590
|
}
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
if (request.method === "GET" && url.pathname === "/api/web/sessions") {
|
|
863
|
-
const sessions = await discordDaemon.getWebSessions();
|
|
864
|
-
sendJson(response, 200, { sessions: sessions.map(sessionDto) });
|
|
865
|
-
return true;
|
|
866
|
-
}
|
|
867
|
-
if (request.method === "GET" && url.pathname === "/api/web/history") {
|
|
868
|
-
const runtime = await getRuntime(getChannelKeyFromRequest(url));
|
|
869
|
-
const limit = Math.min(Math.max(Number(url.searchParams.get("limit") ?? 50) || 50, 1), 200);
|
|
870
|
-
const before = url.searchParams.get("before") ?? undefined;
|
|
871
|
-
sendJson(response, 200, webHistoryPayload(config, runtime.getRecords(), personaName, runtime.channelKey, { limit, before }));
|
|
872
|
-
return true;
|
|
873
|
-
}
|
|
874
|
-
if (request.method === "GET" && url.pathname === "/api/web/agent/settings") {
|
|
875
|
-
const runtime = await getRuntime(getChannelKeyFromRequest(url));
|
|
876
|
-
sendJson(response, 200, agentSettingsPayload(familiarAgent, runtime.channelKey, personaName));
|
|
877
|
-
return true;
|
|
878
|
-
}
|
|
879
|
-
if (request.method === "GET" && url.pathname === "/api/web/agent/models") {
|
|
880
|
-
sendJson(response, 200, getAgentModelsPayload());
|
|
881
|
-
return true;
|
|
882
|
-
}
|
|
883
|
-
if (request.method === "POST" && url.pathname === "/api/web/agent/models") {
|
|
884
|
-
const body = await readJsonBody(request);
|
|
885
|
-
if (!isObject(body)) {
|
|
886
|
-
sendJson(response, 400, { error: "body is required" });
|
|
887
|
-
return true;
|
|
888
|
-
}
|
|
889
|
-
const parsed = parseRequestedModel(body.model);
|
|
890
|
-
if (!parsed.ok) {
|
|
891
|
-
sendJson(response, 400, { error: parsed.error });
|
|
892
|
-
return true;
|
|
893
|
-
}
|
|
894
|
-
if (!Object.hasOwn(PROVIDER_DEFAULTS, parsed.ref.provider) &&
|
|
895
|
-
!getProviders().includes(parsed.ref.provider)) {
|
|
896
|
-
sendJson(response, 400, { error: `unsupported provider: ${parsed.ref.provider}` });
|
|
897
|
-
return true;
|
|
898
|
-
}
|
|
899
|
-
if (config.models.allow.includes(parsed.model) || loadAddedModels().includes(parsed.model)) {
|
|
900
|
-
sendJson(response, 200, getAgentModelsPayload());
|
|
901
|
-
return true;
|
|
902
|
-
}
|
|
903
|
-
await addModel(parsed.model);
|
|
904
|
-
sendJson(response, 200, getAgentModelsPayload());
|
|
905
|
-
return true;
|
|
906
|
-
}
|
|
907
|
-
if (request.method === "DELETE" && url.pathname === "/api/web/agent/models") {
|
|
908
|
-
const body = await readJsonBody(request);
|
|
909
|
-
if (!isObject(body)) {
|
|
910
|
-
sendJson(response, 400, { error: "body is required" });
|
|
911
|
-
return true;
|
|
912
|
-
}
|
|
913
|
-
const parsed = parseRequestedModel(body.model);
|
|
914
|
-
if (!parsed.ok) {
|
|
915
|
-
sendJson(response, 400, { error: parsed.error });
|
|
916
|
-
return true;
|
|
917
|
-
}
|
|
918
|
-
if (!loadAddedModels().includes(parsed.model)) {
|
|
919
|
-
sendJson(response, 400, { error: "model is not user-added" });
|
|
920
|
-
return true;
|
|
921
|
-
}
|
|
922
|
-
await removeModel(parsed.model);
|
|
923
|
-
sendJson(response, 200, getAgentModelsPayload());
|
|
924
|
-
return true;
|
|
925
|
-
}
|
|
926
|
-
if (request.method === "GET" && url.pathname === "/api/web/config") {
|
|
927
|
-
sendJson(response, 200, getConfigPayload());
|
|
928
|
-
return true;
|
|
929
|
-
}
|
|
930
|
-
if (request.method === "POST" && url.pathname === "/api/web/config") {
|
|
931
|
-
const body = await readJsonBody(request);
|
|
932
|
-
if (!isObject(body) || typeof body.key !== "string") {
|
|
933
|
-
sendJson(response, 400, { error: "key is required" });
|
|
934
|
-
return true;
|
|
935
|
-
}
|
|
936
|
-
if (!isConfigKey(body.key)) {
|
|
937
|
-
sendJson(response, 400, { error: `unknown config key: ${body.key}` });
|
|
938
|
-
return true;
|
|
939
|
-
}
|
|
940
|
-
const entry = CONFIG_REGISTRY[body.key];
|
|
941
|
-
try {
|
|
942
|
-
const validated = entry.validate(body.value, config);
|
|
943
|
-
entry.write(config, validated);
|
|
944
|
-
await setConfigOverride(body.key, validated);
|
|
945
|
-
await entry.apply?.({ config, discordDaemon });
|
|
946
|
-
}
|
|
947
|
-
catch (error) {
|
|
948
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
949
|
-
sendJson(response, 400, { error: message });
|
|
950
|
-
return true;
|
|
951
|
-
}
|
|
952
|
-
sendJson(response, 200, getConfigPayload());
|
|
953
|
-
return true;
|
|
954
|
-
}
|
|
955
|
-
if (request.method === "DELETE" && url.pathname === "/api/web/config") {
|
|
956
|
-
const body = await readJsonBody(request);
|
|
957
|
-
if (!isObject(body) || typeof body.key !== "string") {
|
|
958
|
-
sendJson(response, 400, { error: "key is required" });
|
|
959
|
-
return true;
|
|
960
|
-
}
|
|
961
|
-
if (!isConfigKey(body.key)) {
|
|
962
|
-
sendJson(response, 400, { error: `unknown config key: ${body.key}` });
|
|
963
|
-
return true;
|
|
964
|
-
}
|
|
965
|
-
const entry = CONFIG_REGISTRY[body.key];
|
|
966
|
-
try {
|
|
967
|
-
const fallback = getConfigDefault(body.key);
|
|
968
|
-
entry.write(config, fallback);
|
|
969
|
-
await clearConfigOverride(body.key);
|
|
970
|
-
await entry.apply?.({ config, discordDaemon });
|
|
971
|
-
}
|
|
972
|
-
catch (error) {
|
|
973
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
974
|
-
sendJson(response, 400, { error: message });
|
|
975
|
-
return true;
|
|
976
|
-
}
|
|
977
|
-
sendJson(response, 200, getConfigPayload());
|
|
978
|
-
return true;
|
|
979
|
-
}
|
|
980
|
-
if (request.method === "GET" && url.pathname === "/api/web/memes") {
|
|
981
|
-
try {
|
|
982
|
-
const markdown = await readFile(memeCatalogPath(config), "utf8");
|
|
983
|
-
sendJson(response, 200, { families: parseMemeCatalog(markdown) });
|
|
984
|
-
}
|
|
985
|
-
catch {
|
|
986
|
-
sendJson(response, 500, { error: "memes catalog unavailable" });
|
|
987
|
-
}
|
|
988
|
-
return true;
|
|
989
|
-
}
|
|
990
|
-
if (request.method === "POST" && url.pathname === "/api/web/send") {
|
|
991
|
-
const contentType = request.headers["content-type"] ?? "";
|
|
992
|
-
const isMultipart = Array.isArray(contentType)
|
|
993
|
-
? contentType.some((value) => value.includes("multipart/form-data"))
|
|
994
|
-
: contentType.includes("multipart/form-data");
|
|
995
|
-
const body = isMultipart ? await readMultipartBody(request, contentType) : await readJsonBody(request);
|
|
996
|
-
const runtime = await getRuntime(getChannelKeyFromRequest(url, body));
|
|
997
|
-
if (!isObject(body) || typeof body.text !== "string") {
|
|
998
|
-
sendJson(response, 400, { error: "text is required" });
|
|
999
|
-
return true;
|
|
1000
|
-
}
|
|
1001
|
-
if (!isMultipart && isObject(body) && Array.isArray(body.attachments) && body.attachments.length > 0) {
|
|
1002
|
-
sendJson(response, 400, { error: "attachments require multipart form data" });
|
|
1003
|
-
return true;
|
|
1004
|
-
}
|
|
1005
|
-
const rawAttachments = Array.isArray(body.attachments) ? body.attachments : [];
|
|
1006
|
-
const attachments = await materializeInboundAttachments(config, rawAttachments
|
|
1007
|
-
.filter((attachment) => isWebUploadAttachment(attachment))
|
|
1008
|
-
.map((attachment) => ({ ...attachment, source: "web" })));
|
|
1009
|
-
if (!body.text.trim() && attachments.length === 0) {
|
|
1010
|
-
sendJson(response, 400, { error: "text or attachment is required" });
|
|
1011
|
-
return true;
|
|
1012
|
-
}
|
|
1013
|
-
const id = messageId("user");
|
|
1014
|
-
const ts = Date.now();
|
|
1015
|
-
const input = {
|
|
1016
|
-
messageId: id,
|
|
1017
|
-
authorId: config.discord.ownerId,
|
|
1018
|
-
authorName: getContactNickname(WEB_USER_NAME),
|
|
1019
|
-
text: body.text,
|
|
1020
|
-
isBot: false,
|
|
1021
|
-
mentionedBot: true,
|
|
1022
|
-
remoteTimestamp: new Date(ts).toISOString(),
|
|
1023
|
-
checkpoint: { messageId: id },
|
|
1024
|
-
attachments,
|
|
1025
|
-
};
|
|
1026
|
-
await runtime.ingestInbound(input, { mode: "queue" });
|
|
1027
|
-
void drainJobs(runtime).catch((error) => console.error("Web job drain failed", error));
|
|
1028
|
-
sendJson(response, 200, { id, ts, channelKey: runtime.channelKey });
|
|
1029
|
-
return true;
|
|
1030
|
-
}
|
|
1031
|
-
if (request.method === "POST" && url.pathname === "/api/web/agent/settings") {
|
|
1032
|
-
const body = await readJsonBody(request);
|
|
1033
|
-
const runtime = await getRuntime(getChannelKeyFromRequest(url, body));
|
|
1034
|
-
if (!isObject(body)) {
|
|
1035
|
-
sendJson(response, 400, { error: "body is required" });
|
|
1036
|
-
return true;
|
|
1037
|
-
}
|
|
1038
|
-
try {
|
|
1039
|
-
if (typeof body.model === "string")
|
|
1040
|
-
await familiarAgent.setModel(runtime.channelKey, body.model);
|
|
1041
|
-
if (typeof body.thinking === "string")
|
|
1042
|
-
await familiarAgent.setThinkingLevel(runtime.channelKey, body.thinking);
|
|
1043
|
-
}
|
|
1044
|
-
catch (error) {
|
|
1045
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1046
|
-
sendJson(response, 400, { error: message });
|
|
1047
|
-
return true;
|
|
1048
|
-
}
|
|
1049
|
-
sendJson(response, 200, agentSettingsPayload(familiarAgent, runtime.channelKey, personaName));
|
|
1050
|
-
return true;
|
|
1051
|
-
}
|
|
1052
|
-
if (request.method === "POST" && url.pathname === "/api/web/agent/new") {
|
|
1053
|
-
const body = await readJsonBody(request);
|
|
1054
|
-
const runtime = await getRuntime(getChannelKeyFromRequest(url, body));
|
|
1055
|
-
await familiarAgent.reset(runtime.channelKey);
|
|
1056
|
-
await runtime.resetConversation("new conversation requested from web");
|
|
1057
|
-
publish({
|
|
1058
|
-
type: "status",
|
|
1059
|
-
channelKey: runtime.channelKey,
|
|
1060
|
-
kind: "idle",
|
|
1061
|
-
detail: "started fresh from web",
|
|
1062
|
-
});
|
|
1063
|
-
sendJson(response, 200, { ok: true });
|
|
1064
|
-
return true;
|
|
1065
|
-
}
|
|
1066
|
-
if (request.method === "POST" && url.pathname === "/api/web/control") {
|
|
1067
|
-
const body = await readJsonBody(request);
|
|
1068
|
-
const runtime = await getRuntime(getChannelKeyFromRequest(url, body));
|
|
1069
|
-
if (!isObject(body) || typeof body.command !== "string") {
|
|
1070
|
-
sendJson(response, 400, { error: "command is required" });
|
|
1071
|
-
return true;
|
|
1072
|
-
}
|
|
1073
|
-
if (config.web.authMode === "public-2fa" && body.command === "login") {
|
|
1074
|
-
const token = isObject(body.args) && typeof body.args.token === "string" ? body.args.token : "";
|
|
1075
|
-
if (!config.web.totpSecret || !verifyTotp(config.web.totpSecret, token)) {
|
|
1076
|
-
sendJson(response, 401, { ok: false, message: "Invalid TOTP token." });
|
|
1077
|
-
return true;
|
|
1078
|
-
}
|
|
1079
|
-
const sessionId = auth.createSession();
|
|
1080
|
-
sendJson(response, 200, { ok: true, message: "Authenticated." }, { "set-cookie": sessionCookie(sessionId) });
|
|
1081
|
-
return true;
|
|
1082
|
-
}
|
|
1083
|
-
const args = commandArgs(body.command, body.args);
|
|
1084
|
-
const input = {
|
|
1085
|
-
messageId: messageId("control"),
|
|
1086
|
-
authorId: config.discord.ownerId,
|
|
1087
|
-
authorName: getContactNickname(WEB_USER_NAME),
|
|
1088
|
-
text: `/${body.command}${args ? ` ${args}` : ""}`,
|
|
1089
|
-
isBot: false,
|
|
1090
|
-
mentionedBot: true,
|
|
1091
|
-
remoteTimestamp: new Date().toISOString(),
|
|
1092
|
-
};
|
|
1093
|
-
const control = runtime.parseControlCommand(input);
|
|
1094
|
-
if (!control) {
|
|
1095
|
-
sendJson(response, 400, { ok: false, message: "Unsupported command." });
|
|
1096
|
-
return true;
|
|
1097
|
-
}
|
|
1098
|
-
await runtime.noteControlCommand(input, control);
|
|
1099
|
-
const message = await applyControlCommand(runtime, control);
|
|
1100
|
-
await runtime.noteOutbound({ text: message, messageIds: [], control: control.command });
|
|
1101
|
-
sendJson(response, 200, { ok: true, message, channelKey: runtime.channelKey });
|
|
1102
|
-
return true;
|
|
1103
|
-
}
|
|
591
|
+
const handler = webRoutes.get(`${request.method} ${url.pathname}`);
|
|
592
|
+
// await is load-bearing: it keeps handler rejections inside this try so the catch maps HttpError to a status.
|
|
593
|
+
if (handler)
|
|
594
|
+
return await handler(request, response, url);
|
|
1104
595
|
sendJson(response, 404, { error: "not found" });
|
|
1105
596
|
return true;
|
|
1106
597
|
}
|
|
1107
598
|
catch (error) {
|
|
1108
|
-
const
|
|
1109
|
-
|
|
599
|
+
const status = error instanceof HttpError ? error.status : 500;
|
|
600
|
+
const message = errorMessage(error);
|
|
601
|
+
sendJson(response, status, { error: message });
|
|
1110
602
|
return true;
|
|
1111
603
|
}
|
|
1112
604
|
};
|
|
@@ -1121,61 +613,13 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
|
|
|
1121
613
|
sendText(response, 404, "Not found");
|
|
1122
614
|
});
|
|
1123
615
|
});
|
|
1124
|
-
server
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
}
|
|
1132
|
-
const requestedChannelKey = url.searchParams.get("channelKey") || undefined;
|
|
1133
|
-
void getRuntime(requestedChannelKey)
|
|
1134
|
-
.then((runtime) => {
|
|
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));
|
|
1171
|
-
})
|
|
1172
|
-
.catch((error) => {
|
|
1173
|
-
console.error("WebSocket runtime lookup failed", error);
|
|
1174
|
-
if (!netSocket.destroyed) {
|
|
1175
|
-
netSocket.write("HTTP/1.1 503 Service Unavailable\r\n\r\n");
|
|
1176
|
-
netSocket.destroy();
|
|
1177
|
-
}
|
|
1178
|
-
});
|
|
616
|
+
attachWebSocketStream(server, {
|
|
617
|
+
authorize: (request, pathname) => auth.authorize(request, pathname),
|
|
618
|
+
eventHub,
|
|
619
|
+
getRuntime,
|
|
620
|
+
abort: (runtime) => familiarAgent.abort(runtime.channelKey),
|
|
621
|
+
retry: retryLatestAssistant,
|
|
622
|
+
deleteLatest: deleteLatestAssistant,
|
|
1179
623
|
});
|
|
1180
624
|
await new Promise((resolveListen, rejectListen) => {
|
|
1181
625
|
server.once("error", rejectListen);
|
|
@@ -1188,22 +632,10 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
|
|
|
1188
632
|
return {
|
|
1189
633
|
server,
|
|
1190
634
|
async stop() {
|
|
1191
|
-
|
|
1192
|
-
for (const client of clients)
|
|
1193
|
-
client.socket.destroy();
|
|
1194
|
-
clients.clear();
|
|
1195
|
-
for (const unsubscribe of runtimeSubscriptions.values())
|
|
1196
|
-
unsubscribe();
|
|
1197
|
-
runtimeSubscriptions.clear();
|
|
635
|
+
eventHub.stop();
|
|
1198
636
|
await new Promise((resolveClose, rejectClose) => {
|
|
1199
637
|
server.close((error) => (error ? rejectClose(error) : resolveClose()));
|
|
1200
638
|
});
|
|
1201
639
|
},
|
|
1202
640
|
};
|
|
1203
641
|
}
|
|
1204
|
-
export const __webTest = {
|
|
1205
|
-
memeCatalogPath,
|
|
1206
|
-
parseMemeCatalog,
|
|
1207
|
-
webHistoryPayload,
|
|
1208
|
-
webMessagesFromRecords,
|
|
1209
|
-
};
|