@qearlyao/familiar 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/HEARTBEAT.md +1 -1
  2. package/README.md +29 -0
  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 +11 -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/media/attachment-limits.js +3 -0
  26. package/dist/{generated-media.js → media/generated-media.js} +1 -1
  27. package/dist/{image-gen.js → media/image-gen.js} +2 -2
  28. package/dist/{inbound-attachments.js → media/inbound-attachments.js} +47 -43
  29. package/dist/media/media-understanding.js +215 -0
  30. package/dist/memory/lcm/summarizer.js +1 -1
  31. package/dist/{added-models.js → models/added-models.js} +1 -1
  32. package/dist/{persona.js → prompting/persona.js} +1 -1
  33. package/dist/{agent-core.js → runtime/agent-core.js} +1 -1
  34. package/dist/{agent-events.js → runtime/agent-events.js} +1 -1
  35. package/dist/{agent-work-queue.js → runtime/agent-work-queue.js} +2 -2
  36. package/dist/{runtime.js → runtime/conversation-runtime.js} +3 -3
  37. package/dist/{runtime-manager.js → runtime/runtime-manager.js} +2 -2
  38. package/dist/{scheduler-runner.js → runtime/scheduler-runner.js} +1 -1
  39. package/dist/{scheduler.js → runtime/scheduler.js} +3 -3
  40. package/dist/{browser-tools.js → tools/browser-tools.js} +17 -26
  41. package/dist/util/fs.js +2 -1
  42. package/dist/web/agent-routes.js +104 -0
  43. package/dist/web/auth-routes.js +39 -0
  44. package/dist/web/auth.js +124 -30
  45. package/dist/web/config-routes.js +55 -0
  46. package/dist/web/conversation-routes.js +122 -0
  47. package/dist/web/daemon.js +108 -0
  48. package/dist/web/diary-routes.js +88 -0
  49. package/dist/web/errors.js +3 -0
  50. package/dist/web/event-hub.js +3 -3
  51. package/dist/web/messages.js +13 -10
  52. package/dist/web/multipart.js +7 -1
  53. package/dist/web/payloads.js +1 -1
  54. package/dist/web/request-context.js +25 -0
  55. package/dist/web/route-helpers.js +9 -0
  56. package/dist/web/routes.js +37 -0
  57. package/dist/web/runtime-actions.js +231 -0
  58. package/dist/web/session-store.js +161 -0
  59. package/dist/web/static.js +1 -1
  60. package/dist/web/stream.js +12 -3
  61. package/dist/{web-tools.js → web-tools/index.js} +8 -8
  62. package/npm-shrinkwrap.json +79 -2
  63. package/package.json +3 -1
  64. package/web/dist/assets/index-C-k4O5Dz.js +6 -0
  65. package/web/dist/assets/index-Dj-L9nX4.css +2 -0
  66. package/web/dist/assets/markdown-kaIeGxdv.js +14 -0
  67. package/web/dist/assets/react-Bi_azaFt.js +9 -0
  68. package/web/dist/assets/rolldown-runtime-S-ySWqyJ.js +1 -0
  69. package/web/dist/assets/ui-C12-nN_X.js +51 -0
  70. package/web/dist/assets/vendor-D1QXMhXm.js +16 -0
  71. package/web/dist/index.html +7 -2
  72. package/dist/media-understanding.js +0 -120
  73. package/dist/web.js +0 -641
  74. package/web/dist/assets/index-CSkxUQCr.js +0 -63
  75. package/web/dist/assets/index-DllM6RqL.css +0 -2
  76. /package/dist/{ids.js → conversation/ids.js} +0 -0
  77. /package/dist/{control.js → lifecycle/control.js} +0 -0
  78. /package/dist/{service.js → lifecycle/service.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,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
+ }
@@ -0,0 +1,88 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import { basename, resolve } from "node:path";
3
+ import { DIARY_INDEX_FILE_RE, listDiaryMarkdownFiles } from "../memory/diary/indexer.js";
4
+ import { isEnoent } from "../util/fs.js";
5
+ import { HttpError, sendJson } from "./http.js";
6
+ const FRONTMATTER_RE = /^\s*---\r?\n[\s\S]*?\r?\n---\s*(?:\r?\n|$)/;
7
+ const HEADING_RE = /^#{1,6}\s+(.+?)\s*$/m;
8
+ const DIARY_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
9
+ export function registerWebDiaryRoutes(route, config) {
10
+ route("GET", "/api/web/diaries", async (_request, response) => {
11
+ sendJson(response, 200, { diaries: await listWebDiaries(config) });
12
+ });
13
+ route("GET", "/api/web/diary", async (_request, response, url) => {
14
+ const date = url.searchParams.get("date") ?? "";
15
+ sendJson(response, 200, { diary: await readWebDiary(config, date) });
16
+ });
17
+ }
18
+ export async function listWebDiaries(config) {
19
+ const paths = await listDiaryMarkdownFiles(config);
20
+ const summaries = await Promise.all(paths.map(async (path) => toDiarySummary(await readDiaryPayload(path))));
21
+ return summaries.sort((a, b) => b.date.localeCompare(a.date));
22
+ }
23
+ export async function readWebDiary(config, date) {
24
+ if (!DIARY_DATE_RE.test(date))
25
+ throw new HttpError(400, "diary date must be YYYY-MM-DD");
26
+ const path = resolve(config.memory.diariesDir, `${date}.md`);
27
+ try {
28
+ return await readDiaryPayload(path);
29
+ }
30
+ catch (error) {
31
+ if (isEnoent(error))
32
+ throw new HttpError(404, "diary not found");
33
+ throw error;
34
+ }
35
+ }
36
+ async function readDiaryPayload(path) {
37
+ const sourceId = basename(path);
38
+ if (!DIARY_INDEX_FILE_RE.test(sourceId)) {
39
+ throw new HttpError(400, "diary file must be named YYYY-MM-DD.md");
40
+ }
41
+ const fileStat = await stat(path);
42
+ if (!fileStat.isFile())
43
+ throw new HttpError(404, "diary not found");
44
+ const markdown = await readFile(path, "utf8");
45
+ const content = stripFrontmatter(markdown).trim();
46
+ const date = sourceId.slice(0, -".md".length);
47
+ return {
48
+ date,
49
+ sourceId,
50
+ title: diaryTitle(content, date),
51
+ excerpt: diaryExcerpt(content),
52
+ mtimeMs: Math.floor(fileStat.mtimeMs),
53
+ sizeBytes: fileStat.size,
54
+ content,
55
+ };
56
+ }
57
+ function stripFrontmatter(markdown) {
58
+ return markdown.replace(FRONTMATTER_RE, "");
59
+ }
60
+ function toDiarySummary(entry) {
61
+ return {
62
+ date: entry.date,
63
+ sourceId: entry.sourceId,
64
+ title: entry.title,
65
+ excerpt: entry.excerpt,
66
+ mtimeMs: entry.mtimeMs,
67
+ sizeBytes: entry.sizeBytes,
68
+ };
69
+ }
70
+ function diaryTitle(content, date) {
71
+ return stripInlineMarkdown(HEADING_RE.exec(content)?.[1]?.trim() || date);
72
+ }
73
+ function diaryExcerpt(content) {
74
+ const body = content
75
+ .split(/\r?\n/)
76
+ .filter((line) => !HEADING_RE.test(line))
77
+ .join(" ")
78
+ .replace(/\s+/g, " ")
79
+ .trim();
80
+ return stripInlineMarkdown(body).slice(0, 180);
81
+ }
82
+ function stripInlineMarkdown(value) {
83
+ return value
84
+ .replace(/`([^`]+)`/g, "$1")
85
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
86
+ .replace(/[*_~#>]/g, "")
87
+ .trim();
88
+ }
@@ -0,0 +1,3 @@
1
+ export function errorMessage(error) {
2
+ return error instanceof Error ? error.message : String(error);
3
+ }
@@ -1,6 +1,6 @@
1
- import { getContactNickname } from "../contact-note.js";
2
- import { eventId, toUnixMs } from "../ids.js";
3
- import { consumeSilentDelta, createSilentFilterState, finalizeSilentFilter } from "../silent-marker.js";
1
+ import { getContactNickname } from "../conversation/contact-note.js";
2
+ import { eventId, toUnixMs } from "../conversation/ids.js";
3
+ import { consumeSilentDelta, createSilentFilterState, finalizeSilentFilter } from "../runtime/silent-marker.js";
4
4
  import { encodeFrame, replayEvents } from "./events.js";
5
5
  import { toolFromStoredAgentEvent, webAttachments } from "./messages.js";
6
6
  import { EVENT_REPLAY_LIMIT, WEB_USER_NAME } from "./types.js";
@@ -1,7 +1,7 @@
1
- import { hiddenWebMessageIds } from "../chat-log.js";
2
- import { getContactNickname } from "../contact-note.js";
3
- import { publicAttachmentPath } from "../generated-media.js";
4
- import { toUnixMs } from "../ids.js";
1
+ import { hiddenWebMessageIds, } from "../conversation/chat-log.js";
2
+ import { getContactNickname } from "../conversation/contact-note.js";
3
+ import { toUnixMs } from "../conversation/ids.js";
4
+ import { publicAttachmentPath } from "../media/generated-media.js";
5
5
  import { isRecord } from "../util/guards.js";
6
6
  import { WEB_USER_NAME } from "./types.js";
7
7
  export function isUserVisibleRuntimeRecord(record) {
@@ -17,12 +17,19 @@ export function webAttachments(config, attachments) {
17
17
  mimeType: attachment.mimeType,
18
18
  size: attachment.size,
19
19
  url: attachment.localPath ? publicAttachmentPath(config, attachment.localPath) : attachment.remoteUrl,
20
+ derivedText: attachmentDerivedText(attachment),
20
21
  }));
21
22
  }
22
23
  export function attachmentDerivedText(attachment) {
23
24
  if (attachment.derived?.text?.label === "preview")
24
25
  return undefined;
25
- return attachment.derived?.text?.text;
26
+ const text = attachment.derived?.text?.text.trim();
27
+ if (!text)
28
+ return undefined;
29
+ return {
30
+ label: attachment.derived?.text?.label,
31
+ text,
32
+ };
26
33
  }
27
34
  export function toolError(result) {
28
35
  if (typeof result === "string")
@@ -306,15 +313,11 @@ export function webMessageFromRecord(config, record, assistantName) {
306
313
  if (!isUserVisibleRuntimeRecord(record))
307
314
  return undefined;
308
315
  if (record.type === "inbound") {
309
- const attachmentText = record.attachments
310
- .map((attachment) => attachmentDerivedText(attachment))
311
- .filter((text) => !!text)
312
- .join("\n");
313
316
  return {
314
317
  id: record.messageId,
315
318
  role: "user",
316
319
  who: record.authorName || getContactNickname(WEB_USER_NAME),
317
- text: [record.text, attachmentText].filter(Boolean).join("\n"),
320
+ text: record.text,
318
321
  attachments: webAttachments(config, record.attachments),
319
322
  ts: toUnixMs(record.ts),
320
323
  };