@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
@@ -1,6 +1,12 @@
1
+ import { MAX_INBOUND_TOTAL_BYTES } from "../media/attachment-limits.js";
1
2
  export function isWebUploadAttachment(value) {
2
3
  return !!value && typeof value === "object" && Buffer.isBuffer(value.buffer);
3
4
  }
5
+ export function isMultipartContentType(contentType) {
6
+ return Array.isArray(contentType)
7
+ ? contentType.some((value) => value.includes("multipart/form-data"))
8
+ : contentType.includes("multipart/form-data");
9
+ }
4
10
  export async function readRawBody(request, maxBytes) {
5
11
  const chunks = [];
6
12
  let total = 0;
@@ -33,7 +39,7 @@ export function parseContentDisposition(header) {
33
39
  }
34
40
  export async function readMultipartBody(request, contentType) {
35
41
  const boundary = multipartBoundary(contentType);
36
- const raw = await readRawBody(request, 32 * 1024 * 1024);
42
+ const raw = await readRawBody(request, MAX_INBOUND_TOTAL_BYTES);
37
43
  const binary = raw.toString("binary");
38
44
  const marker = `--${boundary}`;
39
45
  const attachments = [];
@@ -1,4 +1,4 @@
1
- import { supportedThinkingLevels } from "../models.js";
1
+ import { supportedThinkingLevels } from "../models/index.js";
2
2
  import { isRecord } from "../util/guards.js";
3
3
  export function commandArgs(command, args) {
4
4
  if (!isRecord(args))
@@ -0,0 +1,25 @@
1
+ function normalizeRemoteAddress(address) {
2
+ if (!address)
3
+ return undefined;
4
+ if (address.startsWith("::ffff:"))
5
+ return address.slice("::ffff:".length);
6
+ return address;
7
+ }
8
+ function isLoopbackAddress(address) {
9
+ const normalized = normalizeRemoteAddress(address);
10
+ return normalized === "127.0.0.1" || normalized === "::1" || normalized === "localhost";
11
+ }
12
+ function firstHeaderValue(value) {
13
+ return Array.isArray(value) ? value[0] : value;
14
+ }
15
+ export function requestAuthContext(request, now = Date.now()) {
16
+ const remoteAddress = normalizeRemoteAddress(request.socket.remoteAddress);
17
+ const trustedProxy = isLoopbackAddress(remoteAddress);
18
+ const forwardedFor = trustedProxy ? firstHeaderValue(request.headers["x-forwarded-for"]) : undefined;
19
+ const forwardedProto = trustedProxy ? firstHeaderValue(request.headers["x-forwarded-proto"]) : undefined;
20
+ const clientIp = forwardedFor?.split(",")[0]?.trim() || remoteAddress || "unknown";
21
+ const socket = request.socket;
22
+ const secure = forwardedProto?.split(",")[0]?.trim().toLowerCase() === "https" || socket.encrypted === true;
23
+ const userAgent = firstHeaderValue(request.headers["user-agent"]);
24
+ return { clientIp, secure, now, ...(userAgent ? { userAgent } : {}) };
25
+ }
@@ -0,0 +1,9 @@
1
+ import { isRecord } from "../util/guards.js";
2
+ export function getChannelKeyFromRequest(url, body) {
3
+ const queryKey = url.searchParams.get("channelKey");
4
+ if (queryKey)
5
+ return queryKey;
6
+ if (isRecord(body) && typeof body.channelKey === "string")
7
+ return body.channelKey;
8
+ return undefined;
9
+ }
@@ -0,0 +1,37 @@
1
+ import { errorMessage } from "./errors.js";
2
+ import { HttpError, sendJson } from "./http.js";
3
+ import { serveAttachment } from "./static.js";
4
+ export function createWebRouteRegistry(config, auth) {
5
+ const webRoutes = new Map();
6
+ const route = (method, pathname, handler) => {
7
+ webRoutes.set(`${method} ${pathname}`, handler);
8
+ };
9
+ const handleApi = async (request, response, url) => {
10
+ if (!url.pathname.startsWith("/api/web/"))
11
+ return false;
12
+ if (!(await auth.authorize(request, url.pathname))) {
13
+ sendJson(response, 401, { error: "unauthorized" });
14
+ return true;
15
+ }
16
+ try {
17
+ if (request.method === "GET" && url.pathname.startsWith("/api/web/attachments/")) {
18
+ return serveAttachment(config, response, url.pathname, request.headers.range);
19
+ }
20
+ const handler = webRoutes.get(`${request.method} ${url.pathname}`);
21
+ // await is load-bearing: it keeps handler rejections inside this try so the catch maps HttpError to a status.
22
+ if (handler) {
23
+ await handler(request, response, url);
24
+ return true;
25
+ }
26
+ sendJson(response, 404, { error: "not found" });
27
+ return true;
28
+ }
29
+ catch (error) {
30
+ const status = error instanceof HttpError ? error.status : 500;
31
+ const message = errorMessage(error);
32
+ sendJson(response, status, { error: message });
33
+ return true;
34
+ }
35
+ };
36
+ return { route, handleApi };
37
+ }
@@ -0,0 +1,231 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { formatSetting } from "../config/settings.js";
3
+ import { messageId } from "../conversation/ids.js";
4
+ import { createAgentEventRecorder, storedAgentEventFromAgentEvent, thinkingDurationMs, updateAgentEventSummary, } from "../runtime/agent-events.js";
5
+ import { parseAgentReply } from "../runtime/silent-marker.js";
6
+ import { errorMessage } from "./errors.js";
7
+ import { webAttachments } from "./messages.js";
8
+ function replyPersistenceFields(reply) {
9
+ return {
10
+ text: reply.text,
11
+ messageIds: [reply.messageId],
12
+ webMessageId: reply.messageId,
13
+ attachments: reply.attachments,
14
+ thinking: reply.thinking,
15
+ thinkingMs: reply.thinkingMs,
16
+ silent: reply.silent,
17
+ };
18
+ }
19
+ export function createWebRuntimeActions(options) {
20
+ const { config, familiarAgent, agentCore, eventHub, personaName, restart } = options;
21
+ const { appendAndPublishError, publish, publishDelta } = eventHub;
22
+ const promptAssistantMessage = async (input) => {
23
+ const { runtime, jobId, assistantMessageId } = input;
24
+ const summary = { thinking: "" };
25
+ const recorder = createAgentEventRecorder((storedEvent) => runtime.noteAgentEvent(jobId, assistantMessageId, storedEvent, { notify: false }));
26
+ let started = false;
27
+ let reply;
28
+ try {
29
+ reply = await input.dispatch(async (event) => {
30
+ if (event.type === "message_start" && event.message.role === "assistant" && !started) {
31
+ started = true;
32
+ input.onAssistantStart?.();
33
+ }
34
+ updateAgentEventSummary(summary, event);
35
+ const storedEvent = storedAgentEventFromAgentEvent(event);
36
+ if (storedEvent) {
37
+ runtime.publishAgentEvent(jobId, assistantMessageId, storedEvent);
38
+ await recorder.record(storedEvent);
39
+ }
40
+ });
41
+ }
42
+ finally {
43
+ await recorder.flush();
44
+ }
45
+ const parsed = parseAgentReply(reply.text);
46
+ const finalText = parsed.silent ? "" : reply.text;
47
+ if (!started) {
48
+ input.onAssistantStart?.();
49
+ if (!parsed.silent) {
50
+ publish({
51
+ type: "message_started",
52
+ channelKey: runtime.channelKey,
53
+ messageId: assistantMessageId,
54
+ role: "assistant",
55
+ who: personaName,
56
+ });
57
+ if (finalText) {
58
+ publishDelta(runtime.channelKey, assistantMessageId, "text", finalText);
59
+ }
60
+ }
61
+ }
62
+ const thinkingMs = thinkingDurationMs(summary);
63
+ publish({
64
+ type: "message_completed",
65
+ channelKey: runtime.channelKey,
66
+ messageId: assistantMessageId,
67
+ thinkingMs,
68
+ attachments: webAttachments(config, reply.attachments),
69
+ silent: parsed.silent || undefined,
70
+ });
71
+ eventHub.markLocallyStreamed(assistantMessageId);
72
+ return {
73
+ text: finalText,
74
+ messageId: assistantMessageId,
75
+ thinking: summary.thinking,
76
+ thinkingMs,
77
+ attachments: reply.attachments,
78
+ silent: parsed.silent,
79
+ };
80
+ };
81
+ const promptForRuntime = async (runtime, jobId, prompt, attachments = [], onTurnEnd) => {
82
+ return promptAssistantMessage({
83
+ runtime,
84
+ jobId,
85
+ assistantMessageId: messageId(),
86
+ dispatch: (onEvent) => agentCore.promptForRuntime(runtime, jobId, prompt, attachments, onEvent, onTurnEnd),
87
+ });
88
+ };
89
+ const retryLatestAssistant = async (runtime) => {
90
+ if (runtime.hasActiveJob())
91
+ throw new Error("Cannot retry while a turn is running");
92
+ const target = runtime.latestAssistantRetryTarget();
93
+ if (!target)
94
+ throw new Error("No assistant message to retry");
95
+ const jobId = randomUUID();
96
+ const assistantMessageId = messageId();
97
+ const replaceMessage = () => {
98
+ publish({
99
+ type: "message_replaced",
100
+ channelKey: runtime.channelKey,
101
+ oldMessageId: target.messageId,
102
+ newMessageId: assistantMessageId,
103
+ });
104
+ };
105
+ try {
106
+ const reply = await promptAssistantMessage({
107
+ runtime,
108
+ jobId,
109
+ assistantMessageId,
110
+ onAssistantStart: replaceMessage,
111
+ dispatch: (onEvent) => familiarAgent.retryLastAssistant(runtime.channelKey, onEvent, {
112
+ onTurnEnd: () => {
113
+ publish({
114
+ type: "status",
115
+ channelKey: runtime.channelKey,
116
+ kind: "idle",
117
+ });
118
+ },
119
+ }),
120
+ });
121
+ await runtime.noteAssistantRetry({
122
+ oldMessageId: target.messageId,
123
+ newMessageId: assistantMessageId,
124
+ jobId,
125
+ triggerRecordId: target.triggerRecordId,
126
+ });
127
+ await runtime.noteOutbound({
128
+ ...replyPersistenceFields(reply),
129
+ replyToMessageId: target.messageId,
130
+ jobId,
131
+ });
132
+ }
133
+ catch (error) {
134
+ const message = errorMessage(error);
135
+ await appendAndPublishError(runtime, message);
136
+ }
137
+ };
138
+ const deleteLatestAssistant = async (runtime) => {
139
+ if (runtime.hasActiveJob())
140
+ throw new Error("Cannot delete while a turn is running");
141
+ const target = runtime.latestAssistantDeleteTarget();
142
+ if (!target)
143
+ throw new Error("No assistant message to delete");
144
+ try {
145
+ await familiarAgent.deleteLastAssistant(runtime.channelKey);
146
+ await runtime.noteMessageDelete(target.messageId);
147
+ publish({
148
+ type: "message_deleted",
149
+ channelKey: runtime.channelKey,
150
+ messageId: target.messageId,
151
+ });
152
+ }
153
+ catch (error) {
154
+ const message = errorMessage(error);
155
+ await appendAndPublishError(runtime, message);
156
+ }
157
+ };
158
+ const drainJobs = async (runtime) => {
159
+ for (;;) {
160
+ const dispatch = runtime.beginNextJob();
161
+ if (!dispatch)
162
+ return;
163
+ try {
164
+ const reply = await promptForRuntime(runtime, dispatch.job.jobId, dispatch.prompt, dispatch.attachments, () => {
165
+ publish({
166
+ type: "status",
167
+ channelKey: runtime.channelKey,
168
+ kind: "idle",
169
+ });
170
+ });
171
+ await runtime.completeActiveJob({
172
+ ...replyPersistenceFields(reply),
173
+ replyToMessageId: dispatch.triggerMessageId,
174
+ });
175
+ }
176
+ catch (error) {
177
+ if (!runtime.hasActiveJob(dispatch.job.jobId))
178
+ return;
179
+ const message = errorMessage(error);
180
+ await runtime.failActiveJob(message);
181
+ await appendAndPublishError(runtime, message);
182
+ }
183
+ }
184
+ };
185
+ const applyControlCommand = async (runtime, control) => {
186
+ if (control.command === "stop") {
187
+ await familiarAgent.abort(runtime.channelKey);
188
+ return "Stopped current work.";
189
+ }
190
+ if (control.command === "new") {
191
+ await familiarAgent.reset(runtime.channelKey);
192
+ await runtime.resetConversation("new conversation requested");
193
+ return "Started a fresh agent transcript for this channel.";
194
+ }
195
+ if (control.command === "reload") {
196
+ return familiarAgent.reload();
197
+ }
198
+ if (control.command === "restart") {
199
+ return restart
200
+ ? await restart()
201
+ : "Restart requested, but no restart handler is configured. Please restart the Familiar process manually.";
202
+ }
203
+ if (control.command === "model") {
204
+ return control.args
205
+ ? await familiarAgent.setModel(runtime.channelKey, control.args)
206
+ : `Current model: ${formatSetting(familiarAgent.getModel(runtime.channelKey))}`;
207
+ }
208
+ if (control.command === "thinking") {
209
+ return control.args
210
+ ? await familiarAgent.setThinkingLevel(runtime.channelKey, control.args)
211
+ : `Current thinking: ${formatSetting(familiarAgent.getThinkingLevel(runtime.channelKey))}`;
212
+ }
213
+ if (control.command === "channel-trigger")
214
+ return "Use Discord /familiar channel-trigger in the channel for now.";
215
+ if (control.command === "status") {
216
+ return [
217
+ runtime.formatStatus(),
218
+ `model: ${formatSetting(familiarAgent.getModel(runtime.channelKey))}`,
219
+ `thinking: ${formatSetting(familiarAgent.getThinkingLevel(runtime.channelKey))}`,
220
+ ].join("\n");
221
+ }
222
+ return "Compact is not wired for this runtime yet. I logged the command, but I won't run lossy compaction here.";
223
+ };
224
+ return {
225
+ promptForRuntime,
226
+ retryLatestAssistant,
227
+ deleteLatestAssistant,
228
+ drainJobs,
229
+ applyControlCommand,
230
+ };
231
+ }
@@ -0,0 +1,161 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { resolve } from "node:path";
3
+ import { atomicWriteJson, createWriteQueue, readFileOrNull } from "../util/fs.js";
4
+ import { isRecord } from "../util/guards.js";
5
+ export const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
6
+ const SESSION_TOUCH_INTERVAL_MS = 60 * 1000;
7
+ function sha256(value) {
8
+ return createHash("sha256").update(value).digest("hex");
9
+ }
10
+ function normalizeString(value) {
11
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
12
+ }
13
+ function normalizeDeviceName(value, fallback) {
14
+ const name = normalizeString(value) ?? fallback;
15
+ return name.slice(0, 80);
16
+ }
17
+ function deviceDto(session, current = false) {
18
+ return {
19
+ id: session.id,
20
+ deviceName: session.deviceName,
21
+ createdAt: session.createdAt,
22
+ lastSeenAt: session.lastSeenAt,
23
+ expiresAt: session.expiresAt,
24
+ ...(session.lastIp ? { lastIp: session.lastIp } : {}),
25
+ ...(session.userAgent ? { userAgent: session.userAgent } : {}),
26
+ ...(current ? { current: true } : {}),
27
+ };
28
+ }
29
+ function isActive(session, now) {
30
+ return !session.revokedAt && Date.parse(session.expiresAt) > now;
31
+ }
32
+ function normalizeSession(value) {
33
+ if (!isRecord(value))
34
+ return undefined;
35
+ const id = normalizeString(value.id);
36
+ const tokenHash = normalizeString(value.tokenHash);
37
+ const deviceName = normalizeString(value.deviceName);
38
+ const createdAt = normalizeString(value.createdAt);
39
+ const lastSeenAt = normalizeString(value.lastSeenAt);
40
+ const expiresAt = normalizeString(value.expiresAt);
41
+ if (!id || !tokenHash || !deviceName || !createdAt || !lastSeenAt || !expiresAt)
42
+ return undefined;
43
+ return {
44
+ id,
45
+ tokenHash,
46
+ deviceName,
47
+ createdAt,
48
+ lastSeenAt,
49
+ expiresAt,
50
+ lastIp: normalizeString(value.lastIp),
51
+ userAgent: normalizeString(value.userAgent),
52
+ revokedAt: normalizeString(value.revokedAt),
53
+ };
54
+ }
55
+ function normalizeSessionsFile(value, now) {
56
+ if (!isRecord(value) || !Array.isArray(value.sessions))
57
+ return { version: 1, sessions: [] };
58
+ return {
59
+ version: 1,
60
+ sessions: value.sessions
61
+ .map(normalizeSession)
62
+ .filter((session) => !!session && isActive(session, now)),
63
+ };
64
+ }
65
+ async function readSessionsFile(path, now) {
66
+ const raw = await readFileOrNull(path, "utf8");
67
+ return raw === null ? { version: 1, sessions: [] } : normalizeSessionsFile(JSON.parse(raw), now);
68
+ }
69
+ export async function loadWebSessionStore(config, now = Date.now()) {
70
+ const path = resolve(config.workspace.dataDir, "settings", "web-sessions.json");
71
+ let file = await readSessionsFile(path, now);
72
+ const enqueueWrite = createWriteQueue("web sessions");
73
+ const enqueuePersist = () => enqueueWrite(() => atomicWriteJson(path, file));
74
+ const findByToken = (token, now) => {
75
+ if (!token)
76
+ return undefined;
77
+ const tokenHash = sha256(token);
78
+ return file.sessions.find((session) => session.tokenHash === tokenHash && isActive(session, now));
79
+ };
80
+ return {
81
+ path,
82
+ async createSession(input) {
83
+ const now = input.context.now;
84
+ const ts = new Date(now).toISOString();
85
+ const token = randomBytes(32).toString("base64url");
86
+ const session = {
87
+ id: randomBytes(12).toString("base64url"),
88
+ tokenHash: sha256(token),
89
+ deviceName: normalizeDeviceName(input.deviceName, "browser"),
90
+ createdAt: ts,
91
+ lastSeenAt: ts,
92
+ expiresAt: new Date(now + SESSION_TTL_MS).toISOString(),
93
+ lastIp: input.context.clientIp,
94
+ userAgent: input.context.userAgent,
95
+ };
96
+ file = { version: 1, sessions: [...file.sessions.filter((item) => isActive(item, now)), session] };
97
+ await enqueuePersist();
98
+ return { token, device: deviceDto(session, true) };
99
+ },
100
+ async authenticateSession(token, context) {
101
+ const session = findByToken(token, context.now);
102
+ if (!session)
103
+ return undefined;
104
+ const touchedAt = Date.parse(session.lastSeenAt);
105
+ const nextExpiresAt = new Date(context.now + SESSION_TTL_MS).toISOString();
106
+ const changed = context.now - touchedAt >= SESSION_TOUCH_INTERVAL_MS ||
107
+ session.lastIp !== context.clientIp ||
108
+ session.userAgent !== context.userAgent;
109
+ if (changed) {
110
+ session.lastSeenAt = new Date(context.now).toISOString();
111
+ session.expiresAt = nextExpiresAt;
112
+ session.lastIp = context.clientIp;
113
+ session.userAgent = context.userAgent;
114
+ await enqueuePersist();
115
+ }
116
+ return deviceDto(session, true);
117
+ },
118
+ currentDevice(token) {
119
+ const session = findByToken(token, Date.now());
120
+ return session ? deviceDto(session, true) : undefined;
121
+ },
122
+ listDevices(currentToken) {
123
+ const now = Date.now();
124
+ const currentHash = currentToken ? sha256(currentToken) : undefined;
125
+ file = { version: 1, sessions: file.sessions.filter((session) => isActive(session, now)) };
126
+ return file.sessions.map((session) => deviceDto(session, session.tokenHash === currentHash));
127
+ },
128
+ async revokeDevice(id) {
129
+ const session = file.sessions.find((item) => item.id === id && !item.revokedAt);
130
+ if (!session)
131
+ return false;
132
+ session.revokedAt = new Date().toISOString();
133
+ await enqueuePersist();
134
+ return true;
135
+ },
136
+ async revokeCurrent(token) {
137
+ const session = findByToken(token, Date.now());
138
+ if (!session)
139
+ return false;
140
+ session.revokedAt = new Date().toISOString();
141
+ await enqueuePersist();
142
+ return true;
143
+ },
144
+ async revokeOthers(token) {
145
+ const session = findByToken(token, Date.now());
146
+ if (!session)
147
+ return 0;
148
+ const now = new Date().toISOString();
149
+ let revoked = 0;
150
+ for (const item of file.sessions) {
151
+ if (item.tokenHash === session.tokenHash || item.revokedAt)
152
+ continue;
153
+ item.revokedAt = now;
154
+ revoked += 1;
155
+ }
156
+ if (revoked > 0)
157
+ await enqueuePersist();
158
+ return revoked;
159
+ },
160
+ };
161
+ }
@@ -2,7 +2,7 @@ import { createReadStream, existsSync } from "node:fs";
2
2
  import { lstat, realpath, stat } from "node:fs/promises";
3
3
  import { dirname, extname, join, relative, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { attachmentsDir, browserScreenshotsDir, generatedAttachmentsDir } from "../generated-media.js";
5
+ import { attachmentsDir, browserScreenshotsDir, generatedAttachmentsDir } from "../media/generated-media.js";
6
6
  import { sendText } from "./http.js";
7
7
  const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
8
8
  const DIST_DIR = resolve(PROJECT_ROOT, "web/dist");
@@ -23,14 +23,23 @@ export function attachWebSocketStream(server, options) {
23
23
  server.on("upgrade", (request, socket) => {
24
24
  const netSocket = socket;
25
25
  const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
26
- if (url.pathname !== "/api/web/stream" || !authorize(request, url.pathname)) {
26
+ if (url.pathname !== "/api/web/stream") {
27
27
  netSocket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
28
28
  netSocket.destroy();
29
29
  return;
30
30
  }
31
- const requestedChannelKey = url.searchParams.get("channelKey") || undefined;
32
- void getRuntime(requestedChannelKey)
31
+ void authorize(request, url.pathname)
32
+ .then((authorized) => {
33
+ if (!authorized) {
34
+ netSocket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
35
+ netSocket.destroy();
36
+ return;
37
+ }
38
+ return getRuntime(url.searchParams.get("channelKey") || undefined);
39
+ })
33
40
  .then((runtime) => {
41
+ if (!runtime)
42
+ return;
34
43
  if (netSocket.destroyed)
35
44
  return;
36
45
  if (!acceptWebSocket(request, netSocket))
@@ -1,11 +1,11 @@
1
- import { PageCache } from "./web-tools/cache.js";
2
- import { loadWebConfig } from "./web-tools/config.js";
3
- import { createJinaProvider, createTinyfishProvider } from "./web-tools/fetch-providers.js";
4
- import { formatFetchContent, formatSearchResults, paginateContent } from "./web-tools/format.js";
5
- import { resolveSearchProviders } from "./web-tools/routing.js";
6
- import { isTransientProviderError, validateFetchUrl } from "./web-tools/safety.js";
7
- import { createBraveProvider, createExaProvider, createTavilyProvider, normalizeDomains, } from "./web-tools/search-providers.js";
8
- import { FETCH_DEFAULT_MAX_CHARS, WEB_UNTRUSTED_PREFIX, webFetchSchema, webSearchSchema, } from "./web-tools/types.js";
1
+ import { PageCache } from "./cache.js";
2
+ import { loadWebConfig } from "./config.js";
3
+ import { createJinaProvider, createTinyfishProvider } from "./fetch-providers.js";
4
+ import { formatFetchContent, formatSearchResults, paginateContent } from "./format.js";
5
+ import { resolveSearchProviders } from "./routing.js";
6
+ import { isTransientProviderError, validateFetchUrl } from "./safety.js";
7
+ import { createBraveProvider, createExaProvider, createTavilyProvider, normalizeDomains } from "./search-providers.js";
8
+ import { FETCH_DEFAULT_MAX_CHARS, WEB_UNTRUSTED_PREFIX, webFetchSchema, webSearchSchema, } from "./types.js";
9
9
  const pageCache = new PageCache();
10
10
  function makeSearchTool(config) {
11
11
  const providers = {};