@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.
- package/HEARTBEAT.md +1 -1
- package/README.md +29 -0
- package/config.example.toml +2 -0
- package/dist/{agent.js → agent/factory.js} +11 -11
- package/dist/agent/session-helpers.js +1 -1
- package/dist/agent/tools.js +4 -4
- package/dist/cli.js +11 -11
- package/dist/{config.js → config/index.js} +7 -7
- package/dist/config/model-refs.js +1 -1
- package/dist/{config-overrides.js → config/overrides.js} +1 -1
- package/dist/{config-registry.js → config/registry.js} +2 -2
- package/dist/{settings.js → config/settings.js} +2 -2
- package/dist/{chat-log.js → conversation/chat-log.js} +1 -1
- package/dist/{contact-note.js → conversation/contact-note.js} +1 -1
- package/dist/{owner-identity.js → conversation/owner-identity.js} +2 -2
- package/dist/discord/channel.js +1 -1
- package/dist/discord/commands.js +1 -1
- package/dist/{discord.js → discord/daemon.js} +17 -17
- package/dist/discord/inbound.js +1 -1
- package/dist/discord/send.js +29 -20
- package/dist/discord/turn.js +3 -3
- package/dist/index.js +12 -12
- package/dist/{data-retention.js → lifecycle/data-retention.js} +1 -1
- package/dist/{hot-reload.js → lifecycle/hot-reload.js} +2 -2
- package/dist/media/attachment-limits.js +3 -0
- package/dist/{generated-media.js → media/generated-media.js} +1 -1
- package/dist/{image-gen.js → media/image-gen.js} +2 -2
- package/dist/{inbound-attachments.js → media/inbound-attachments.js} +47 -43
- package/dist/media/media-understanding.js +215 -0
- package/dist/memory/lcm/summarizer.js +1 -1
- package/dist/{added-models.js → models/added-models.js} +1 -1
- package/dist/{persona.js → prompting/persona.js} +1 -1
- package/dist/{agent-core.js → runtime/agent-core.js} +1 -1
- package/dist/{agent-events.js → runtime/agent-events.js} +1 -1
- package/dist/{agent-work-queue.js → runtime/agent-work-queue.js} +2 -2
- package/dist/{runtime.js → runtime/conversation-runtime.js} +3 -3
- package/dist/{runtime-manager.js → runtime/runtime-manager.js} +2 -2
- package/dist/{scheduler-runner.js → runtime/scheduler-runner.js} +1 -1
- package/dist/{scheduler.js → runtime/scheduler.js} +3 -3
- package/dist/{browser-tools.js → tools/browser-tools.js} +17 -26
- package/dist/util/fs.js +2 -1
- package/dist/web/agent-routes.js +104 -0
- package/dist/web/auth-routes.js +39 -0
- package/dist/web/auth.js +124 -30
- package/dist/web/config-routes.js +55 -0
- package/dist/web/conversation-routes.js +122 -0
- package/dist/web/daemon.js +108 -0
- package/dist/web/diary-routes.js +88 -0
- package/dist/web/errors.js +3 -0
- package/dist/web/event-hub.js +3 -3
- package/dist/web/messages.js +13 -10
- package/dist/web/multipart.js +7 -1
- package/dist/web/payloads.js +1 -1
- package/dist/web/request-context.js +25 -0
- package/dist/web/route-helpers.js +9 -0
- package/dist/web/routes.js +37 -0
- package/dist/web/runtime-actions.js +231 -0
- package/dist/web/session-store.js +161 -0
- package/dist/web/static.js +1 -1
- package/dist/web/stream.js +12 -3
- package/dist/{web-tools.js → web-tools/index.js} +8 -8
- package/npm-shrinkwrap.json +79 -2
- package/package.json +3 -1
- package/web/dist/assets/index-C-k4O5Dz.js +6 -0
- package/web/dist/assets/index-Dj-L9nX4.css +2 -0
- package/web/dist/assets/markdown-kaIeGxdv.js +14 -0
- package/web/dist/assets/react-Bi_azaFt.js +9 -0
- package/web/dist/assets/rolldown-runtime-S-ySWqyJ.js +1 -0
- package/web/dist/assets/ui-C12-nN_X.js +51 -0
- package/web/dist/assets/vendor-D1QXMhXm.js +16 -0
- package/web/dist/index.html +7 -2
- package/dist/media-understanding.js +0 -120
- package/dist/web.js +0 -641
- package/web/dist/assets/index-CSkxUQCr.js +0 -63
- package/web/dist/assets/index-DllM6RqL.css +0 -2
- /package/dist/{ids.js → conversation/ids.js} +0 -0
- /package/dist/{control.js → lifecycle/control.js} +0 -0
- /package/dist/{service.js → lifecycle/service.js} +0 -0
- /package/dist/{image-derivatives.js → media/image-derivatives.js} +0 -0
- /package/dist/{tts.js → media/tts.js} +0 -0
- /package/dist/{models.js → models/index.js} +0 -0
- /package/dist/{skills.js → prompting/skills.js} +0 -0
- /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 {
|
|
2
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
86
|
-
|
|
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
|
-
|
|
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
|
|
93
|
-
|
|
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 (
|
|
98
|
-
return
|
|
99
|
-
|
|
152
|
+
if (hasBearer(request))
|
|
153
|
+
return true;
|
|
154
|
+
const token = readSessionToken(request);
|
|
155
|
+
return !!(await sessions.authenticateSession(token, requestAuthContext(request)));
|
|
100
156
|
};
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
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
|
+
}
|
package/dist/web/event-hub.js
CHANGED
|
@@ -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";
|
package/dist/web/messages.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { hiddenWebMessageIds } from "../chat-log.js";
|
|
2
|
-
import { getContactNickname } from "../contact-note.js";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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
|
-
|
|
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:
|
|
320
|
+
text: record.text,
|
|
318
321
|
attachments: webAttachments(config, record.attachments),
|
|
319
322
|
ts: toUnixMs(record.ts),
|
|
320
323
|
};
|