@qearlyao/familiar 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/HEARTBEAT.md +1 -1
  2. package/README.md +35 -2
  3. package/config.example.toml +2 -0
  4. package/dist/{agent.js → agent/factory.js} +11 -11
  5. package/dist/agent/session-helpers.js +1 -1
  6. package/dist/agent/tools.js +4 -4
  7. package/dist/cli.js +26 -11
  8. package/dist/{config.js → config/index.js} +7 -7
  9. package/dist/config/model-refs.js +1 -1
  10. package/dist/{config-overrides.js → config/overrides.js} +1 -1
  11. package/dist/{config-registry.js → config/registry.js} +2 -2
  12. package/dist/{settings.js → config/settings.js} +2 -2
  13. package/dist/{chat-log.js → conversation/chat-log.js} +1 -1
  14. package/dist/{contact-note.js → conversation/contact-note.js} +1 -1
  15. package/dist/{owner-identity.js → conversation/owner-identity.js} +2 -2
  16. package/dist/discord/channel.js +1 -1
  17. package/dist/discord/commands.js +1 -1
  18. package/dist/{discord.js → discord/daemon.js} +17 -17
  19. package/dist/discord/inbound.js +1 -1
  20. package/dist/discord/send.js +29 -20
  21. package/dist/discord/turn.js +3 -3
  22. package/dist/index.js +12 -12
  23. package/dist/{data-retention.js → lifecycle/data-retention.js} +1 -1
  24. package/dist/{hot-reload.js → lifecycle/hot-reload.js} +2 -2
  25. package/dist/{service.js → lifecycle/service.js} +61 -6
  26. package/dist/media/attachment-limits.js +3 -0
  27. package/dist/{generated-media.js → media/generated-media.js} +1 -1
  28. package/dist/{image-gen.js → media/image-gen.js} +2 -2
  29. package/dist/{inbound-attachments.js → media/inbound-attachments.js} +47 -43
  30. package/dist/media/media-understanding.js +215 -0
  31. package/dist/memory/lcm/summarizer.js +1 -1
  32. package/dist/{added-models.js → models/added-models.js} +1 -1
  33. package/dist/{persona.js → prompting/persona.js} +1 -1
  34. package/dist/{agent-core.js → runtime/agent-core.js} +1 -1
  35. package/dist/{agent-events.js → runtime/agent-events.js} +1 -1
  36. package/dist/{agent-work-queue.js → runtime/agent-work-queue.js} +2 -2
  37. package/dist/{runtime.js → runtime/conversation-runtime.js} +3 -3
  38. package/dist/{runtime-manager.js → runtime/runtime-manager.js} +2 -2
  39. package/dist/{scheduler-runner.js → runtime/scheduler-runner.js} +1 -1
  40. package/dist/{scheduler.js → runtime/scheduler.js} +3 -3
  41. package/dist/{browser-tools.js → tools/browser-tools.js} +17 -26
  42. package/dist/util/fs.js +2 -1
  43. package/dist/web/agent-routes.js +104 -0
  44. package/dist/web/auth-routes.js +39 -0
  45. package/dist/web/auth.js +124 -30
  46. package/dist/web/config-routes.js +55 -0
  47. package/dist/web/conversation-routes.js +122 -0
  48. package/dist/web/daemon.js +108 -0
  49. package/dist/web/diary-routes.js +88 -0
  50. package/dist/web/errors.js +3 -0
  51. package/dist/web/event-hub.js +3 -3
  52. package/dist/web/messages.js +13 -10
  53. package/dist/web/multipart.js +7 -1
  54. package/dist/web/payloads.js +1 -1
  55. package/dist/web/request-context.js +25 -0
  56. package/dist/web/route-helpers.js +9 -0
  57. package/dist/web/routes.js +37 -0
  58. package/dist/web/runtime-actions.js +231 -0
  59. package/dist/web/session-store.js +161 -0
  60. package/dist/web/static.js +1 -1
  61. package/dist/web/stream.js +12 -3
  62. package/dist/{web-tools.js → web-tools/index.js} +8 -8
  63. package/npm-shrinkwrap.json +79 -2
  64. package/package.json +3 -1
  65. package/web/dist/assets/index-D5Kd_ara.js +8 -0
  66. package/web/dist/assets/index-DbJZ_MJn.css +2 -0
  67. package/web/dist/assets/markdown-kaIeGxdv.js +14 -0
  68. package/web/dist/assets/react-Bi_azaFt.js +9 -0
  69. package/web/dist/assets/rolldown-runtime-S-ySWqyJ.js +1 -0
  70. package/web/dist/assets/ui-C12-nN_X.js +51 -0
  71. package/web/dist/assets/vendor-D1QXMhXm.js +16 -0
  72. package/web/dist/index.html +7 -2
  73. package/dist/media-understanding.js +0 -120
  74. package/dist/web.js +0 -641
  75. package/web/dist/assets/index-CSkxUQCr.js +0 -63
  76. package/web/dist/assets/index-DllM6RqL.css +0 -2
  77. /package/dist/{ids.js → conversation/ids.js} +0 -0
  78. /package/dist/{control.js → lifecycle/control.js} +0 -0
  79. /package/dist/{image-derivatives.js → media/image-derivatives.js} +0 -0
  80. /package/dist/{tts.js → media/tts.js} +0 -0
  81. /package/dist/{models.js → models/index.js} +0 -0
  82. /package/dist/{skills.js → prompting/skills.js} +0 -0
  83. /package/dist/{silent-marker.js → runtime/silent-marker.js} +0 -0
@@ -0,0 +1,104 @@
1
+ import { getProviders } from "@earendil-works/pi-ai";
2
+ import { addModel, loadAddedModels, removeModel } from "../models/added-models.js";
3
+ import { PROVIDER_DEFAULTS, parseModelRef } from "../models/index.js";
4
+ import { isRecord } from "../util/guards.js";
5
+ import { errorMessage } from "./errors.js";
6
+ import { HttpError, readJsonBody, sendJson } from "./http.js";
7
+ import { agentSettingsPayload } from "./payloads.js";
8
+ import { getChannelKeyFromRequest } from "./route-helpers.js";
9
+ function agentModelsPayload(config) {
10
+ const models = [];
11
+ const added = [];
12
+ const seen = new Set();
13
+ for (const model of config.models.allow) {
14
+ if (seen.has(model))
15
+ continue;
16
+ seen.add(model);
17
+ models.push(model);
18
+ }
19
+ for (const model of loadAddedModels()) {
20
+ if (seen.has(model))
21
+ continue;
22
+ seen.add(model);
23
+ models.push(model);
24
+ added.push(model);
25
+ }
26
+ return { models, added };
27
+ }
28
+ function parseRequestedModel(value) {
29
+ if (typeof value !== "string")
30
+ throw new HttpError(400, "format must be provider/model-id");
31
+ const ref = parseModelRef(value);
32
+ if (!ref)
33
+ throw new HttpError(400, "format must be provider/model-id");
34
+ return { model: ref.key, ref };
35
+ }
36
+ export function registerWebAgentRoutes(options) {
37
+ const { route, config, familiarAgent, getRuntime, personaName, publish } = options;
38
+ route("GET", "/api/web/agent/settings", async (_request, response, url) => {
39
+ const runtime = await getRuntime(getChannelKeyFromRequest(url));
40
+ sendJson(response, 200, agentSettingsPayload(familiarAgent, runtime.channelKey, personaName));
41
+ });
42
+ route("GET", "/api/web/agent/models", async (_request, response) => {
43
+ sendJson(response, 200, agentModelsPayload(config));
44
+ });
45
+ route("POST", "/api/web/agent/models", async (request, response) => {
46
+ const body = await readJsonBody(request);
47
+ if (!isRecord(body)) {
48
+ throw new HttpError(400, "body is required");
49
+ }
50
+ const parsed = parseRequestedModel(body.model);
51
+ if (!Object.hasOwn(PROVIDER_DEFAULTS, parsed.ref.provider) &&
52
+ !getProviders().includes(parsed.ref.provider)) {
53
+ throw new HttpError(400, `unsupported provider: ${parsed.ref.provider}`);
54
+ }
55
+ if (config.models.allow.includes(parsed.model) || loadAddedModels().includes(parsed.model)) {
56
+ sendJson(response, 200, agentModelsPayload(config));
57
+ return;
58
+ }
59
+ await addModel(parsed.model);
60
+ sendJson(response, 200, agentModelsPayload(config));
61
+ });
62
+ route("DELETE", "/api/web/agent/models", async (request, response) => {
63
+ const body = await readJsonBody(request);
64
+ if (!isRecord(body)) {
65
+ throw new HttpError(400, "body is required");
66
+ }
67
+ const parsed = parseRequestedModel(body.model);
68
+ if (!loadAddedModels().includes(parsed.model)) {
69
+ throw new HttpError(400, "model is not user-added");
70
+ }
71
+ await removeModel(parsed.model);
72
+ sendJson(response, 200, agentModelsPayload(config));
73
+ });
74
+ route("POST", "/api/web/agent/settings", async (request, response, url) => {
75
+ const body = await readJsonBody(request);
76
+ const runtime = await getRuntime(getChannelKeyFromRequest(url, body));
77
+ if (!isRecord(body)) {
78
+ throw new HttpError(400, "body is required");
79
+ }
80
+ try {
81
+ if (typeof body.model === "string")
82
+ await familiarAgent.setModel(runtime.channelKey, body.model);
83
+ if (typeof body.thinking === "string")
84
+ await familiarAgent.setThinkingLevel(runtime.channelKey, body.thinking);
85
+ }
86
+ catch (error) {
87
+ throw new HttpError(400, errorMessage(error));
88
+ }
89
+ sendJson(response, 200, agentSettingsPayload(familiarAgent, runtime.channelKey, personaName));
90
+ });
91
+ route("POST", "/api/web/agent/new", async (request, response, url) => {
92
+ const body = await readJsonBody(request);
93
+ const runtime = await getRuntime(getChannelKeyFromRequest(url, body));
94
+ await familiarAgent.reset(runtime.channelKey);
95
+ await runtime.resetConversation("new conversation requested from web");
96
+ publish({
97
+ type: "status",
98
+ channelKey: runtime.channelKey,
99
+ kind: "idle",
100
+ detail: "started fresh from web",
101
+ });
102
+ sendJson(response, 200, { ok: true });
103
+ });
104
+ }
@@ -0,0 +1,39 @@
1
+ import { isRecord } from "../util/guards.js";
2
+ import { clearSessionCookie, requestAuthContext } from "./auth.js";
3
+ import { HttpError, readJsonBody, sendJson } from "./http.js";
4
+ export function registerWebAuthRoutes(route, auth, options) {
5
+ route("GET", "/api/web/auth/mode", async (_request, response) => {
6
+ sendJson(response, 200, { mode: options.authMode, personaName: options.personaName });
7
+ });
8
+ route("POST", "/api/web/auth/login", async (request, response) => {
9
+ const body = await readJsonBody(request);
10
+ const result = await auth.login(request, body);
11
+ sendJson(response, result.status, result.body, result.cookie ? { "set-cookie": result.cookie } : {});
12
+ });
13
+ route("GET", "/api/web/auth/session", async (request, response) => {
14
+ const device = await auth.currentDevice(request);
15
+ if (!device) {
16
+ throw new HttpError(401, "unauthorized");
17
+ }
18
+ sendJson(response, 200, { device });
19
+ });
20
+ route("GET", "/api/web/auth/devices", async (request, response) => {
21
+ sendJson(response, 200, { devices: auth.listDevices(request) });
22
+ });
23
+ route("DELETE", "/api/web/auth/devices", async (request, response) => {
24
+ const body = await readJsonBody(request);
25
+ if (!isRecord(body) || typeof body.id !== "string" || !body.id.trim()) {
26
+ throw new HttpError(400, "device id is required");
27
+ }
28
+ await auth.revokeDevice(body.id);
29
+ sendJson(response, 200, { ok: true });
30
+ });
31
+ route("POST", "/api/web/auth/devices/revoke-others", async (request, response) => {
32
+ const revoked = await auth.revokeOthers(request);
33
+ sendJson(response, 200, { ok: true, revoked });
34
+ });
35
+ route("POST", "/api/web/auth/logout", async (request, response) => {
36
+ await auth.logout(request);
37
+ sendJson(response, 200, { ok: true }, { "set-cookie": clearSessionCookie(requestAuthContext(request).secure) });
38
+ });
39
+ }
package/dist/web/auth.js CHANGED
@@ -1,5 +1,10 @@
1
- import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
- const SESSION_TTL_MS = 12 * 60 * 60 * 1000;
1
+ import { createHash, createHmac, timingSafeEqual } from "node:crypto";
2
+ import { isRecord } from "../util/guards.js";
3
+ import { requestAuthContext } from "./request-context.js";
4
+ import { SESSION_TTL_MS } from "./session-store.js";
5
+ const LOGIN_WINDOW_MS = 10 * 60 * 1000;
6
+ const LOGIN_LOCKOUT_MS = 30 * 60 * 1000;
7
+ const LOGIN_MAX_FAILURES = 5;
3
8
  function safeEqual(a, b) {
4
9
  const left = Buffer.from(a);
5
10
  const right = Buffer.from(b);
@@ -67,45 +72,134 @@ function readBearerToken(request) {
67
72
  const match = header.match(/^Bearer (.+)$/i);
68
73
  return match?.[1];
69
74
  }
70
- export function createAuth(config) {
71
- const sessions = new Map();
72
- const pruneSessions = () => {
73
- const now = Date.now();
74
- for (const [id, session] of sessions) {
75
- if (session.expiresAt <= now)
76
- sessions.delete(id);
77
- }
78
- };
75
+ function readSessionToken(request) {
76
+ return parseCookies(request.headers.cookie).familiar_session;
77
+ }
78
+ function sha256(value) {
79
+ return createHash("sha256").update(value).digest("hex");
80
+ }
81
+ function normalizeString(value) {
82
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
83
+ }
84
+ function tokenFingerprint(token) {
85
+ return sha256(token).slice(0, 24);
86
+ }
87
+ export function sessionCookie(sessionToken, secure, maxAgeMs = SESSION_TTL_MS) {
88
+ return [
89
+ `familiar_session=${encodeURIComponent(sessionToken)}`,
90
+ "HttpOnly",
91
+ "SameSite=Lax",
92
+ `Max-Age=${Math.floor(maxAgeMs / 1000)}`,
93
+ "Path=/api/web",
94
+ ...(secure ? ["Secure"] : []),
95
+ ].join("; ");
96
+ }
97
+ export function clearSessionCookie(secure) {
98
+ return [
99
+ "familiar_session=",
100
+ "HttpOnly",
101
+ "SameSite=Lax",
102
+ "Max-Age=0",
103
+ "Path=/api/web",
104
+ ...(secure ? ["Secure"] : []),
105
+ ].join("; ");
106
+ }
107
+ export function createAuth(config, sessions) {
108
+ const ipBuckets = new Map();
109
+ const tokenBuckets = new Map();
79
110
  const hasBearer = (request) => {
80
111
  if (!config.web.bearerToken)
81
112
  return false;
82
113
  const token = readBearerToken(request);
83
114
  return token !== undefined && safeEqual(token, config.web.bearerToken);
84
115
  };
85
- const hasSession = (request) => {
86
- pruneSessions();
87
- const sessionId = parseCookies(request.headers.cookie).familiar_session;
88
- if (!sessionId)
116
+ const checkBucket = (bucket, now) => {
117
+ if (!bucket)
89
118
  return false;
90
- return sessions.has(sessionId);
119
+ if (bucket.lockedUntil && bucket.lockedUntil > now)
120
+ return true;
121
+ if (now - bucket.windowStartedAt > LOGIN_WINDOW_MS) {
122
+ bucket.count = 0;
123
+ bucket.windowStartedAt = now;
124
+ bucket.lockedUntil = undefined;
125
+ }
126
+ return false;
91
127
  };
92
- const authorize = (request, pathname) => {
93
- if (pathname === "/api/web/auth/mode")
128
+ const recordFailure = (map, key, now) => {
129
+ const current = map.get(key);
130
+ const bucket = current && now - current.windowStartedAt <= LOGIN_WINDOW_MS ? current : { count: 0, windowStartedAt: now };
131
+ bucket.count += 1;
132
+ if (bucket.count >= LOGIN_MAX_FAILURES)
133
+ bucket.lockedUntil = now + LOGIN_LOCKOUT_MS;
134
+ map.set(key, bucket);
135
+ };
136
+ const clearFailures = (clientIp, token) => {
137
+ ipBuckets.delete(clientIp);
138
+ tokenBuckets.delete(tokenFingerprint(token));
139
+ };
140
+ const publicPath = (method, pathname) => {
141
+ if (method === "GET" && pathname === "/api/web/auth/mode")
142
+ return true;
143
+ if (method === "POST" && pathname === "/api/web/auth/login")
144
+ return config.web.authMode === "bearer";
145
+ return false;
146
+ };
147
+ const authorize = async (request, pathname) => {
148
+ if (publicPath(request.method, pathname))
94
149
  return true;
95
150
  if (config.web.authMode === "tailscale-only")
96
151
  return true;
97
- if (config.web.authMode === "bearer")
98
- return hasBearer(request);
99
- return hasSession(request) || hasBearer(request);
152
+ if (hasBearer(request))
153
+ return true;
154
+ const token = readSessionToken(request);
155
+ return !!(await sessions.authenticateSession(token, requestAuthContext(request)));
100
156
  };
101
- const createSession = () => {
102
- pruneSessions();
103
- const id = randomBytes(32).toString("base64url");
104
- sessions.set(id, { expiresAt: Date.now() + SESSION_TTL_MS });
105
- return id;
157
+ const currentDevice = (request) => sessions.authenticateSession(readSessionToken(request), requestAuthContext(request));
158
+ const login = async (request, body) => {
159
+ const context = requestAuthContext(request);
160
+ const token = isRecord(body) && typeof body.token === "string" ? body.token : "";
161
+ const fingerprint = tokenFingerprint(token);
162
+ if (checkBucket(ipBuckets.get(context.clientIp), context.now) ||
163
+ checkBucket(tokenBuckets.get(fingerprint), context.now)) {
164
+ return { status: 429, body: { error: "too many login attempts" } };
165
+ }
166
+ if (!config.web.bearerToken || !safeEqual(token, config.web.bearerToken)) {
167
+ recordFailure(ipBuckets, context.clientIp, context.now);
168
+ recordFailure(tokenBuckets, fingerprint, context.now);
169
+ return { status: 401, body: { error: "unauthorized" } };
170
+ }
171
+ clearFailures(context.clientIp, token);
172
+ const { token: sessionToken, device } = await sessions.createSession({
173
+ deviceName: isRecord(body) ? normalizeString(body.deviceName) : undefined,
174
+ context,
175
+ });
176
+ return {
177
+ status: 200,
178
+ body: { device },
179
+ cookie: sessionCookie(sessionToken, context.secure),
180
+ };
181
+ };
182
+ const createSession = (request, deviceName) => sessions.createSession({ deviceName, context: requestAuthContext(request) });
183
+ return {
184
+ authorize,
185
+ currentDevice,
186
+ createSession,
187
+ hasBearer,
188
+ login,
189
+ listDevices(request) {
190
+ return sessions.listDevices(readSessionToken(request));
191
+ },
192
+ revokeDevice(id) {
193
+ return sessions.revokeDevice(id);
194
+ },
195
+ revokeOthers(request) {
196
+ return sessions.revokeOthers(readSessionToken(request));
197
+ },
198
+ logout(request) {
199
+ return sessions.revokeCurrent(readSessionToken(request));
200
+ },
201
+ clearFailures,
106
202
  };
107
- return { authorize, createSession };
108
- }
109
- export function sessionCookie(sessionId) {
110
- return `familiar_session=${encodeURIComponent(sessionId)}; HttpOnly; SameSite=Lax; Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}; Path=/api/web`;
111
203
  }
204
+ export { requestAuthContext } from "./request-context.js";
205
+ export { loadWebSessionStore } from "./session-store.js";
@@ -0,0 +1,55 @@
1
+ import { loadConfigOverrides } from "../config/overrides.js";
2
+ import { CONFIG_KEYS, CONFIG_REGISTRY, clearConfigChange, commitConfigChange, isConfigKey, } from "../config/registry.js";
3
+ import { isRecord } from "../util/guards.js";
4
+ import { errorMessage } from "./errors.js";
5
+ import { HttpError, readJsonBody, sendJson } from "./http.js";
6
+ function configPayload(config) {
7
+ const overrides = loadConfigOverrides();
8
+ const values = {};
9
+ for (const key of CONFIG_KEYS) {
10
+ const entry = CONFIG_REGISTRY[key];
11
+ values[key] = {
12
+ value: entry.read(config),
13
+ source: key in overrides ? "override" : "config",
14
+ };
15
+ }
16
+ return { values };
17
+ }
18
+ function configChangeFromBody(body) {
19
+ if (!isRecord(body) || typeof body.key !== "string") {
20
+ throw new HttpError(400, "key is required");
21
+ }
22
+ if (!isConfigKey(body.key)) {
23
+ throw new HttpError(400, `unknown config key: ${body.key}`);
24
+ }
25
+ return { key: body.key, value: body.value };
26
+ }
27
+ export function registerWebConfigRoutes(route, config, agentCore) {
28
+ route("GET", "/api/web/config", async (_request, response) => {
29
+ sendJson(response, 200, configPayload(config));
30
+ });
31
+ route("POST", "/api/web/config", async (request, response) => {
32
+ const body = await readJsonBody(request);
33
+ const { key, value } = configChangeFromBody(body);
34
+ const entry = CONFIG_REGISTRY[key];
35
+ try {
36
+ const validated = entry.validate(value, config);
37
+ await commitConfigChange(key, validated, { config, scheduler: agentCore });
38
+ }
39
+ catch (error) {
40
+ throw new HttpError(400, errorMessage(error));
41
+ }
42
+ sendJson(response, 200, configPayload(config));
43
+ });
44
+ route("DELETE", "/api/web/config", async (request, response) => {
45
+ const body = await readJsonBody(request);
46
+ const { key } = configChangeFromBody(body);
47
+ try {
48
+ await clearConfigChange(key, { config, scheduler: agentCore });
49
+ }
50
+ catch (error) {
51
+ throw new HttpError(400, errorMessage(error));
52
+ }
53
+ sendJson(response, 200, configPayload(config));
54
+ });
55
+ }
@@ -0,0 +1,122 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { getContactNickname } from "../conversation/contact-note.js";
3
+ import { messageId } from "../conversation/ids.js";
4
+ import { materializeInboundAttachments } from "../media/inbound-attachments.js";
5
+ import { isRecord } from "../util/guards.js";
6
+ import { requestAuthContext, sessionCookie, verifyTotp } from "./auth.js";
7
+ import { HttpError, readJsonBody, sendJson } from "./http.js";
8
+ import { memeCatalogPath, parseMemeCatalog } from "./memes.js";
9
+ import { webHistoryPayload } from "./messages.js";
10
+ import { isMultipartContentType, isWebUploadAttachment, readMultipartBody, } from "./multipart.js";
11
+ import { commandArgs, sessionDto } from "./payloads.js";
12
+ import { getChannelKeyFromRequest } from "./route-helpers.js";
13
+ import { WEB_USER_NAME } from "./types.js";
14
+ export function registerWebConversationRoutes(options) {
15
+ const { route, config, auth, authMode, agentCore, getRuntime, personaName, actions } = options;
16
+ route("GET", "/api/web/sessions", async (_request, response) => {
17
+ if (!agentCore.hasSessionSource()) {
18
+ sendJson(response, 200, { sessions: [] });
19
+ return;
20
+ }
21
+ const sessions = await agentCore.getWebSessions();
22
+ sendJson(response, 200, { sessions: sessions.map(sessionDto) });
23
+ });
24
+ route("GET", "/api/web/history", async (_request, response, url) => {
25
+ const runtime = await getRuntime(getChannelKeyFromRequest(url));
26
+ const limit = Math.min(Math.max(Number(url.searchParams.get("limit") ?? 50) || 50, 1), 200);
27
+ const before = url.searchParams.get("before") ?? undefined;
28
+ sendJson(response, 200, webHistoryPayload(config, runtime.getRecords(), personaName, runtime.channelKey, { limit, before }));
29
+ });
30
+ route("GET", "/api/web/memes", async (_request, response) => {
31
+ try {
32
+ const markdown = await readFile(memeCatalogPath(config), "utf8");
33
+ sendJson(response, 200, { families: parseMemeCatalog(markdown) });
34
+ }
35
+ catch {
36
+ sendJson(response, 500, { error: "memes catalog unavailable" });
37
+ }
38
+ });
39
+ route("POST", "/api/web/send", async (request, response, url) => {
40
+ const contentType = request.headers["content-type"] ?? "";
41
+ const isMultipart = isMultipartContentType(contentType);
42
+ const body = isMultipart ? await readMultipartBody(request, contentType) : await readJsonBody(request);
43
+ const runtime = await getRuntime(getChannelKeyFromRequest(url, body));
44
+ if (!isRecord(body) || typeof body.text !== "string") {
45
+ throw new HttpError(400, "text is required");
46
+ }
47
+ if (!isMultipart && isRecord(body) && Array.isArray(body.attachments) && body.attachments.length > 0) {
48
+ throw new HttpError(400, "attachments require multipart form data");
49
+ }
50
+ const rawAttachments = Array.isArray(body.attachments) ? body.attachments : [];
51
+ const attachments = await materializeInboundAttachments(config, rawAttachments
52
+ .filter((attachment) => isWebUploadAttachment(attachment))
53
+ .map((attachment) => ({ ...attachment, source: "web" })));
54
+ if (!body.text.trim() && attachments.length === 0) {
55
+ throw new HttpError(400, "text or attachment is required");
56
+ }
57
+ const id = messageId("user");
58
+ const ts = Date.now();
59
+ const input = {
60
+ messageId: id,
61
+ authorId: config.discord.ownerId,
62
+ authorName: getContactNickname(WEB_USER_NAME),
63
+ text: body.text,
64
+ isBot: false,
65
+ mentionedBot: true,
66
+ remoteTimestamp: new Date(ts).toISOString(),
67
+ checkpoint: { messageId: id },
68
+ attachments,
69
+ };
70
+ await runtime.ingestInbound(input, { mode: "queue" });
71
+ void actions.drainJobs(runtime).catch((error) => console.error("Web job drain failed", error));
72
+ sendJson(response, 200, { id, ts, channelKey: runtime.channelKey });
73
+ });
74
+ route("POST", "/api/web/retry", async (request, response, url) => {
75
+ const body = await readJsonBody(request);
76
+ const runtime = await getRuntime(getChannelKeyFromRequest(url, body));
77
+ void actions.retryLatestAssistant(runtime).catch((error) => console.error("Web retry failed", error));
78
+ sendJson(response, 200, { ok: true, channelKey: runtime.channelKey });
79
+ });
80
+ route("POST", "/api/web/delete", async (request, response, url) => {
81
+ const body = await readJsonBody(request);
82
+ const runtime = await getRuntime(getChannelKeyFromRequest(url, body));
83
+ void actions.deleteLatestAssistant(runtime).catch((error) => console.error("Web delete failed", error));
84
+ sendJson(response, 200, { ok: true, channelKey: runtime.channelKey });
85
+ });
86
+ route("POST", "/api/web/control", async (request, response, url) => {
87
+ const body = await readJsonBody(request);
88
+ const runtime = await getRuntime(getChannelKeyFromRequest(url, body));
89
+ if (!isRecord(body) || typeof body.command !== "string") {
90
+ throw new HttpError(400, "command is required");
91
+ }
92
+ if (authMode === "public-2fa" && body.command === "login") {
93
+ const token = isRecord(body.args) && typeof body.args.token === "string" ? body.args.token : "";
94
+ if (!config.web.totpSecret || !verifyTotp(config.web.totpSecret, token)) {
95
+ sendJson(response, 401, { ok: false, message: "Invalid TOTP token." });
96
+ return;
97
+ }
98
+ const session = await auth.createSession(request, "2fa login");
99
+ sendJson(response, 200, { ok: true, message: "Authenticated.", device: session.device }, { "set-cookie": sessionCookie(session.token, requestAuthContext(request).secure) });
100
+ return;
101
+ }
102
+ const args = commandArgs(body.command, body.args);
103
+ const input = {
104
+ messageId: messageId("control"),
105
+ authorId: config.discord.ownerId,
106
+ authorName: getContactNickname(WEB_USER_NAME),
107
+ text: `/${body.command}${args ? ` ${args}` : ""}`,
108
+ isBot: false,
109
+ mentionedBot: true,
110
+ remoteTimestamp: new Date().toISOString(),
111
+ };
112
+ const control = runtime.parseControlCommand(input);
113
+ if (!control) {
114
+ sendJson(response, 400, { ok: false, message: "Unsupported command." });
115
+ return;
116
+ }
117
+ await runtime.noteControlCommand(input, control);
118
+ const message = await actions.applyControlCommand(runtime, control);
119
+ await runtime.noteOutbound({ text: message, messageIds: [], control: control.command });
120
+ sendJson(response, 200, { ok: true, message, channelKey: runtime.channelKey });
121
+ });
122
+ }
@@ -0,0 +1,108 @@
1
+ import { createServer } from "node:http";
2
+ import { refreshContactNote, setContactNotePath } from "../conversation/contact-note.js";
3
+ import { setAddedModelsPath } from "../models/added-models.js";
4
+ import { loadPersona, parsePersonaName } from "../prompting/persona.js";
5
+ import { registerWebAgentRoutes } from "./agent-routes.js";
6
+ import { createAuth, loadWebSessionStore } from "./auth.js";
7
+ import { registerWebAuthRoutes } from "./auth-routes.js";
8
+ import { registerWebConfigRoutes } from "./config-routes.js";
9
+ import { registerWebConversationRoutes } from "./conversation-routes.js";
10
+ import { registerWebDiaryRoutes } from "./diary-routes.js";
11
+ import { createWebEventHub } from "./event-hub.js";
12
+ import { HttpError, sendText } from "./http.js";
13
+ import { createWebRouteRegistry } from "./routes.js";
14
+ import { createWebRuntimeActions } from "./runtime-actions.js";
15
+ import { serveStatic } from "./static.js";
16
+ import { attachWebSocketStream } from "./stream.js";
17
+ export async function startWebDaemon(config, familiarAgent, agentCore, options = {}) {
18
+ setAddedModelsPath(config.workspace.dataDir);
19
+ setContactNotePath(config.persona.contact);
20
+ await refreshContactNote();
21
+ const persona = await loadPersona(config);
22
+ const personaName = parsePersonaName(persona.soul);
23
+ const webSessions = await loadWebSessionStore(config);
24
+ const auth = createAuth(config, webSessions);
25
+ const eventHub = createWebEventHub(config, personaName);
26
+ const getRuntime = async (channelKey) => {
27
+ if (!agentCore.hasSessionSource())
28
+ throw new HttpError(503, "Owner identity is not established yet.");
29
+ const runtime = await agentCore.getRuntimeForWebChannel(channelKey);
30
+ eventHub.subscribeRuntime(runtime);
31
+ return runtime;
32
+ };
33
+ const subscribeKnownRuntimes = async () => {
34
+ if (!agentCore.hasSessionSource())
35
+ return;
36
+ const sessions = await agentCore.getWebSessions();
37
+ await Promise.all(sessions.map(async (session) => {
38
+ const runtime = await agentCore.getRuntimeForWebChannel(session.key);
39
+ eventHub.subscribeRuntime(runtime);
40
+ }));
41
+ };
42
+ const { route, handleApi } = createWebRouteRegistry(config, auth);
43
+ const actions = createWebRuntimeActions({
44
+ config,
45
+ familiarAgent,
46
+ agentCore,
47
+ eventHub,
48
+ personaName,
49
+ restart: options.restart,
50
+ });
51
+ registerWebAuthRoutes(route, auth, { authMode: config.web.authMode, personaName });
52
+ registerWebConversationRoutes({
53
+ route,
54
+ config,
55
+ auth,
56
+ authMode: config.web.authMode,
57
+ agentCore,
58
+ getRuntime,
59
+ personaName,
60
+ actions,
61
+ });
62
+ registerWebAgentRoutes({
63
+ route,
64
+ config,
65
+ familiarAgent,
66
+ getRuntime,
67
+ personaName,
68
+ publish: eventHub.publish,
69
+ });
70
+ registerWebConfigRoutes(route, config, agentCore);
71
+ registerWebDiaryRoutes(route, config);
72
+ await subscribeKnownRuntimes();
73
+ const server = createServer((request, response) => {
74
+ const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
75
+ void handleApi(request, response, url).then(async (handled) => {
76
+ if (handled)
77
+ return;
78
+ if (await serveStatic(response, url.pathname))
79
+ return;
80
+ sendText(response, 404, "Not found");
81
+ });
82
+ });
83
+ attachWebSocketStream(server, {
84
+ authorize: (request, pathname) => auth.authorize(request, pathname),
85
+ eventHub,
86
+ getRuntime,
87
+ abort: (runtime) => familiarAgent.abort(runtime.channelKey),
88
+ retry: actions.retryLatestAssistant,
89
+ deleteLatest: actions.deleteLatestAssistant,
90
+ });
91
+ await new Promise((resolveListen, rejectListen) => {
92
+ server.once("error", rejectListen);
93
+ server.listen(config.web.port, config.web.bindAddress, () => {
94
+ server.off("error", rejectListen);
95
+ resolveListen();
96
+ });
97
+ });
98
+ console.log(`Web side-door listening on http://${config.web.bindAddress}:${config.web.port}`);
99
+ return {
100
+ server,
101
+ async stop() {
102
+ eventHub.stop();
103
+ await new Promise((resolveClose, rejectClose) => {
104
+ server.close((error) => (error ? rejectClose(error) : resolveClose()));
105
+ });
106
+ },
107
+ };
108
+ }