@qearlyao/familiar 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/HEARTBEAT.md +1 -1
  2. package/README.md +35 -2
  3. package/config.example.toml +2 -0
  4. package/dist/{agent.js → agent/factory.js} +11 -11
  5. package/dist/agent/session-helpers.js +1 -1
  6. package/dist/agent/tools.js +4 -4
  7. package/dist/cli.js +26 -11
  8. package/dist/{config.js → config/index.js} +7 -7
  9. package/dist/config/model-refs.js +1 -1
  10. package/dist/{config-overrides.js → config/overrides.js} +1 -1
  11. package/dist/{config-registry.js → config/registry.js} +2 -2
  12. package/dist/{settings.js → config/settings.js} +2 -2
  13. package/dist/{chat-log.js → conversation/chat-log.js} +1 -1
  14. package/dist/{contact-note.js → conversation/contact-note.js} +1 -1
  15. package/dist/{owner-identity.js → conversation/owner-identity.js} +2 -2
  16. package/dist/discord/channel.js +1 -1
  17. package/dist/discord/commands.js +1 -1
  18. package/dist/{discord.js → discord/daemon.js} +17 -17
  19. package/dist/discord/inbound.js +1 -1
  20. package/dist/discord/send.js +29 -20
  21. package/dist/discord/turn.js +3 -3
  22. package/dist/index.js +12 -12
  23. package/dist/{data-retention.js → lifecycle/data-retention.js} +1 -1
  24. package/dist/{hot-reload.js → lifecycle/hot-reload.js} +2 -2
  25. package/dist/{service.js → lifecycle/service.js} +61 -6
  26. package/dist/media/attachment-limits.js +3 -0
  27. package/dist/{generated-media.js → media/generated-media.js} +1 -1
  28. package/dist/{image-gen.js → media/image-gen.js} +2 -2
  29. package/dist/{inbound-attachments.js → media/inbound-attachments.js} +47 -43
  30. package/dist/media/media-understanding.js +215 -0
  31. package/dist/memory/lcm/summarizer.js +1 -1
  32. package/dist/{added-models.js → models/added-models.js} +1 -1
  33. package/dist/{persona.js → prompting/persona.js} +1 -1
  34. package/dist/{agent-core.js → runtime/agent-core.js} +1 -1
  35. package/dist/{agent-events.js → runtime/agent-events.js} +1 -1
  36. package/dist/{agent-work-queue.js → runtime/agent-work-queue.js} +2 -2
  37. package/dist/{runtime.js → runtime/conversation-runtime.js} +3 -3
  38. package/dist/{runtime-manager.js → runtime/runtime-manager.js} +2 -2
  39. package/dist/{scheduler-runner.js → runtime/scheduler-runner.js} +1 -1
  40. package/dist/{scheduler.js → runtime/scheduler.js} +3 -3
  41. package/dist/{browser-tools.js → tools/browser-tools.js} +17 -26
  42. package/dist/util/fs.js +2 -1
  43. package/dist/web/agent-routes.js +104 -0
  44. package/dist/web/auth-routes.js +39 -0
  45. package/dist/web/auth.js +124 -30
  46. package/dist/web/config-routes.js +55 -0
  47. package/dist/web/conversation-routes.js +122 -0
  48. package/dist/web/daemon.js +108 -0
  49. package/dist/web/diary-routes.js +88 -0
  50. package/dist/web/errors.js +3 -0
  51. package/dist/web/event-hub.js +3 -3
  52. package/dist/web/messages.js +13 -10
  53. package/dist/web/multipart.js +7 -1
  54. package/dist/web/payloads.js +1 -1
  55. package/dist/web/request-context.js +25 -0
  56. package/dist/web/route-helpers.js +9 -0
  57. package/dist/web/routes.js +37 -0
  58. package/dist/web/runtime-actions.js +231 -0
  59. package/dist/web/session-store.js +161 -0
  60. package/dist/web/static.js +1 -1
  61. package/dist/web/stream.js +12 -3
  62. package/dist/{web-tools.js → web-tools/index.js} +8 -8
  63. package/npm-shrinkwrap.json +79 -2
  64. package/package.json +3 -1
  65. package/web/dist/assets/index-D5Kd_ara.js +8 -0
  66. package/web/dist/assets/index-DbJZ_MJn.css +2 -0
  67. package/web/dist/assets/markdown-kaIeGxdv.js +14 -0
  68. package/web/dist/assets/react-Bi_azaFt.js +9 -0
  69. package/web/dist/assets/rolldown-runtime-S-ySWqyJ.js +1 -0
  70. package/web/dist/assets/ui-C12-nN_X.js +51 -0
  71. package/web/dist/assets/vendor-D1QXMhXm.js +16 -0
  72. package/web/dist/index.html +7 -2
  73. package/dist/media-understanding.js +0 -120
  74. package/dist/web.js +0 -641
  75. package/web/dist/assets/index-CSkxUQCr.js +0 -63
  76. package/web/dist/assets/index-DllM6RqL.css +0 -2
  77. /package/dist/{ids.js → conversation/ids.js} +0 -0
  78. /package/dist/{control.js → lifecycle/control.js} +0 -0
  79. /package/dist/{image-derivatives.js → media/image-derivatives.js} +0 -0
  80. /package/dist/{tts.js → media/tts.js} +0 -0
  81. /package/dist/{models.js → models/index.js} +0 -0
  82. /package/dist/{skills.js → prompting/skills.js} +0 -0
  83. /package/dist/{silent-marker.js → runtime/silent-marker.js} +0 -0
@@ -0,0 +1,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
  };
@@ -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");