@qearlyao/familiar 0.1.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 (72) hide show
  1. package/.env.example +31 -0
  2. package/HEARTBEAT.md +23 -0
  3. package/LICENSE +21 -0
  4. package/MEMORY.md +1 -0
  5. package/README.md +245 -0
  6. package/SOUL.md +13 -0
  7. package/USER.md +13 -0
  8. package/config.example.toml +221 -0
  9. package/dist/agent-events.js +167 -0
  10. package/dist/agent.js +590 -0
  11. package/dist/browser-tools.js +638 -0
  12. package/dist/chat-log.js +130 -0
  13. package/dist/cli.js +168 -0
  14. package/dist/config.js +804 -0
  15. package/dist/data-retention.js +54 -0
  16. package/dist/discord.js +1203 -0
  17. package/dist/generated-media.js +86 -0
  18. package/dist/image-derivatives.js +102 -0
  19. package/dist/image-gen.js +440 -0
  20. package/dist/inbound-attachments.js +266 -0
  21. package/dist/index.js +10 -0
  22. package/dist/media-understanding.js +120 -0
  23. package/dist/memory/diary/ambient-injector.js +180 -0
  24. package/dist/memory/diary/ambient.js +124 -0
  25. package/dist/memory/diary/chunks.js +231 -0
  26. package/dist/memory/diary/index.js +3 -0
  27. package/dist/memory/diary/indexer.js +93 -0
  28. package/dist/memory/doctor.js +250 -0
  29. package/dist/memory/index/chunk-indexer.js +151 -0
  30. package/dist/memory/index/embedding-provider.js +119 -0
  31. package/dist/memory/index/fts-query.js +18 -0
  32. package/dist/memory/index/retrieval.js +246 -0
  33. package/dist/memory/index/schema.js +157 -0
  34. package/dist/memory/index/store.js +513 -0
  35. package/dist/memory/index/vec.js +72 -0
  36. package/dist/memory/index/vector-codec.js +27 -0
  37. package/dist/memory/lcm/backfill.js +247 -0
  38. package/dist/memory/lcm/condense.js +146 -0
  39. package/dist/memory/lcm/context-transformer.js +662 -0
  40. package/dist/memory/lcm/context.js +421 -0
  41. package/dist/memory/lcm/eviction-score.js +38 -0
  42. package/dist/memory/lcm/index.js +6 -0
  43. package/dist/memory/lcm/indexer.js +200 -0
  44. package/dist/memory/lcm/normalize.js +235 -0
  45. package/dist/memory/lcm/schema.js +188 -0
  46. package/dist/memory/lcm/segment-manager.js +136 -0
  47. package/dist/memory/lcm/store.js +722 -0
  48. package/dist/memory/lcm/summarizer.js +258 -0
  49. package/dist/memory/lcm/types.js +1 -0
  50. package/dist/memory/operator.js +477 -0
  51. package/dist/memory/service.js +202 -0
  52. package/dist/memory/tools.js +205 -0
  53. package/dist/models.js +165 -0
  54. package/dist/persona.js +54 -0
  55. package/dist/runtime.js +493 -0
  56. package/dist/scheduler.js +200 -0
  57. package/dist/settings.js +116 -0
  58. package/dist/skills.js +38 -0
  59. package/dist/tts.js +143 -0
  60. package/dist/web-auth.js +105 -0
  61. package/dist/web-events.js +114 -0
  62. package/dist/web-http.js +29 -0
  63. package/dist/web-static.js +106 -0
  64. package/dist/web-tools.js +940 -0
  65. package/dist/web-types.js +2 -0
  66. package/dist/web.js +844 -0
  67. package/package.json +60 -0
  68. package/web/dist/assets/index-ClgkMgaq.css +2 -0
  69. package/web/dist/assets/index-Cu2QquuR.js +59 -0
  70. package/web/dist/favicon.svg +1 -0
  71. package/web/dist/icons.svg +24 -0
  72. package/web/dist/index.html +20 -0
@@ -0,0 +1,116 @@
1
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { dirname, resolve } from "node:path";
3
+ function isThinkingLevel(value) {
4
+ return (value === "off" ||
5
+ value === "minimal" ||
6
+ value === "low" ||
7
+ value === "medium" ||
8
+ value === "high" ||
9
+ value === "xhigh");
10
+ }
11
+ function isChannelTrigger(value) {
12
+ return value === "mention" || value === "always";
13
+ }
14
+ function normalizeChannelSettings(value) {
15
+ if (!value || typeof value !== "object" || Array.isArray(value))
16
+ return {};
17
+ const input = value;
18
+ return {
19
+ model: typeof input.model === "string" && input.model.trim() ? input.model.trim() : undefined,
20
+ thinkingLevel: isThinkingLevel(input.thinkingLevel) ? input.thinkingLevel : undefined,
21
+ channelTrigger: isChannelTrigger(input.channelTrigger) ? input.channelTrigger : undefined,
22
+ };
23
+ }
24
+ function normalizeSettingsFile(value) {
25
+ if (!value || typeof value !== "object" || Array.isArray(value))
26
+ return { version: 1, channels: {} };
27
+ const input = value;
28
+ const channelsInput = input.channels && typeof input.channels === "object" && !Array.isArray(input.channels)
29
+ ? input.channels
30
+ : {};
31
+ const channels = {};
32
+ for (const [channelKey, settings] of Object.entries(channelsInput)) {
33
+ const normalized = normalizeChannelSettings(settings);
34
+ if (normalized.model || normalized.thinkingLevel || normalized.channelTrigger)
35
+ channels[channelKey] = normalized;
36
+ }
37
+ return { version: 1, channels };
38
+ }
39
+ async function readSettingsFile(path) {
40
+ try {
41
+ const raw = await readFile(path, "utf8");
42
+ return normalizeSettingsFile(JSON.parse(raw));
43
+ }
44
+ catch (error) {
45
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
46
+ return { version: 1, channels: {} };
47
+ }
48
+ throw error;
49
+ }
50
+ }
51
+ function pruneChannel(settings) {
52
+ const pruned = {};
53
+ if (settings.model)
54
+ pruned.model = settings.model;
55
+ if (settings.thinkingLevel)
56
+ pruned.thinkingLevel = settings.thinkingLevel;
57
+ if (settings.channelTrigger)
58
+ pruned.channelTrigger = settings.channelTrigger;
59
+ return pruned.model || pruned.thinkingLevel || pruned.channelTrigger ? pruned : undefined;
60
+ }
61
+ export async function loadSettingsStore(config) {
62
+ const path = resolve(config.workspace.dataDir, "settings", "channel-overrides.json");
63
+ let file = await readSettingsFile(path);
64
+ let writeQueue = Promise.resolve();
65
+ const persist = async () => {
66
+ await mkdir(dirname(path), { recursive: true });
67
+ const tmpPath = `${path}.${process.pid}.${Date.now()}.tmp`;
68
+ await writeFile(tmpPath, `${JSON.stringify(file, null, 2)}\n`, "utf8");
69
+ await rename(tmpPath, path);
70
+ };
71
+ const enqueuePersist = () => {
72
+ const run = writeQueue.then(persist, () => persist());
73
+ writeQueue = run.then(() => undefined, () => undefined);
74
+ return run;
75
+ };
76
+ const updateChannel = async (channelKey, patch) => {
77
+ const next = pruneChannel({ ...file.channels[channelKey], ...patch });
78
+ const channels = { ...file.channels };
79
+ if (next)
80
+ channels[channelKey] = next;
81
+ else
82
+ delete channels[channelKey];
83
+ file = {
84
+ version: 1,
85
+ channels,
86
+ };
87
+ await enqueuePersist();
88
+ };
89
+ return {
90
+ path,
91
+ getChannelSettings(channelKey) {
92
+ return { ...file.channels[channelKey] };
93
+ },
94
+ getChannelModel(channelKey) {
95
+ const model = file.channels[channelKey]?.model;
96
+ return model ? { value: model, source: "override" } : { value: undefined, source: "config" };
97
+ },
98
+ getChannelThinkingLevel(channelKey, fallback) {
99
+ const thinkingLevel = file.channels[channelKey]?.thinkingLevel;
100
+ return thinkingLevel ? { value: thinkingLevel, source: "override" } : { value: fallback, source: "config" };
101
+ },
102
+ getChannelTrigger(channelKey, fallback) {
103
+ const channelTrigger = file.channels[channelKey]?.channelTrigger;
104
+ return channelTrigger ? { value: channelTrigger, source: "override" } : { value: fallback, source: "config" };
105
+ },
106
+ async setChannelModel(channelKey, model) {
107
+ await updateChannel(channelKey, { model });
108
+ },
109
+ async setChannelThinkingLevel(channelKey, thinkingLevel) {
110
+ await updateChannel(channelKey, { thinkingLevel });
111
+ },
112
+ async setChannelTrigger(channelKey, channelTrigger) {
113
+ await updateChannel(channelKey, { channelTrigger });
114
+ },
115
+ };
116
+ }
package/dist/skills.js ADDED
@@ -0,0 +1,38 @@
1
+ import { resolve } from "node:path";
2
+ import { loadSkills } from "@earendil-works/pi-coding-agent";
3
+ function escapeXml(value) {
4
+ return value
5
+ .replace(/&/g, "&")
6
+ .replace(/</g, "&lt;")
7
+ .replace(/>/g, "&gt;")
8
+ .replace(/"/g, "&quot;")
9
+ .replace(/'/g, "&apos;");
10
+ }
11
+ export function loadFamiliarSkills(config) {
12
+ return loadSkills({
13
+ cwd: config.workspacePath,
14
+ agentDir: config.workspacePath,
15
+ skillPaths: [resolve(config.workspacePath, "skills")],
16
+ includeDefaults: false,
17
+ });
18
+ }
19
+ export function formatFamiliarSkillsForPrompt(skills) {
20
+ const visibleSkills = skills.filter((skill) => !skill.disableModelInvocation);
21
+ if (visibleSkills.length === 0)
22
+ return "";
23
+ const lines = ["<available_skills>"];
24
+ for (const skill of visibleSkills) {
25
+ lines.push(" <skill>");
26
+ lines.push(` <name>${escapeXml(skill.name)}</name>`);
27
+ lines.push(` <description>${escapeXml(skill.description)}</description>`);
28
+ lines.push(` <location>${escapeXml(skill.filePath)}</location>`);
29
+ lines.push(" </skill>");
30
+ }
31
+ lines.push("</available_skills>");
32
+ return lines.join("\n");
33
+ }
34
+ export function logSkillDiagnostics(result) {
35
+ for (const diagnostic of result.diagnostics) {
36
+ console.warn(`skill ${diagnostic.type}: ${diagnostic.path}: ${diagnostic.message}`);
37
+ }
38
+ }
package/dist/tts.js ADDED
@@ -0,0 +1,143 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { writeFile } from "node:fs/promises";
3
+ import { resolve } from "node:path";
4
+ import { Type } from "typebox";
5
+ import { ensureGeneratedAttachmentsDir } from "./generated-media.js";
6
+ const ELEVENLABS_TTS_BASE_URL = "https://api.elevenlabs.io/v1/text-to-speech";
7
+ const AUDIO_EXTENSIONS = ["mp3", "opus", "pcm", "ulaw", "alaw"];
8
+ const TTS_NOTICE_PREFIX = "Generated speech audio attachment:";
9
+ const ttsSchema = Type.Object({
10
+ text: Type.String({ description: "Text to synthesize as speech." }),
11
+ voiceId: Type.Optional(Type.String({ description: "Optional ElevenLabs voice ID. Overrides the configured voice_id." })),
12
+ }, { additionalProperties: false });
13
+ export function audioExtension(outputFormat) {
14
+ for (const extension of AUDIO_EXTENSIONS) {
15
+ if (outputFormat.startsWith(`${extension}_`))
16
+ return extension;
17
+ }
18
+ return "mp3";
19
+ }
20
+ function formatTtsNotice(name) {
21
+ return `${TTS_NOTICE_PREFIX} ${name}`;
22
+ }
23
+ export function audioMimeType(outputFormat) {
24
+ if (outputFormat.startsWith("pcm_"))
25
+ return "audio/L16";
26
+ if (outputFormat.startsWith("ulaw_"))
27
+ return "audio/basic";
28
+ if (outputFormat.startsWith("alaw_"))
29
+ return "audio/basic";
30
+ if (outputFormat.startsWith("opus_"))
31
+ return "audio/ogg";
32
+ return "audio/mpeg";
33
+ }
34
+ export function isElevenLabsV3Model(modelId) {
35
+ return modelId === "eleven_v3" || modelId.startsWith("eleven_v3_");
36
+ }
37
+ export function buildElevenLabsVoiceSettings(config) {
38
+ const settings = config.tts.voiceSettings;
39
+ if (isElevenLabsV3Model(config.tts.modelId)) {
40
+ return {
41
+ stability: settings.stability,
42
+ };
43
+ }
44
+ return {
45
+ stability: settings.stability,
46
+ similarity_boost: settings.similarityBoost,
47
+ style: settings.style,
48
+ speed: settings.speed,
49
+ use_speaker_boost: settings.useSpeakerBoost,
50
+ };
51
+ }
52
+ async function readElevenLabsError(response) {
53
+ const text = await response.text().catch(() => "");
54
+ if (!text.trim())
55
+ return `${response.status} ${response.statusText}`.trim();
56
+ try {
57
+ const parsed = JSON.parse(text);
58
+ if (parsed && typeof parsed === "object" && "detail" in parsed) {
59
+ const detail = parsed.detail;
60
+ if (typeof detail === "string")
61
+ return detail;
62
+ if (detail && typeof detail === "object" && "message" in detail) {
63
+ const message = detail.message;
64
+ if (typeof message === "string")
65
+ return message;
66
+ }
67
+ }
68
+ }
69
+ catch { }
70
+ return text;
71
+ }
72
+ export function createTtsTool(config, mediaSink) {
73
+ return {
74
+ name: "tts",
75
+ label: "tts",
76
+ description: "synthesize text into a voice message. bracketed tags steer delivery — voice tags like [laughs] or [whispers], sound effects like [applause] or [gunshot], special tags like [sings] or [strong Manchester accent]. tags go before the text they affect; combine when useful, like [excited][laughs]. keep them short.",
77
+ parameters: ttsSchema,
78
+ executionMode: "sequential",
79
+ async execute(_toolCallId, input, signal) {
80
+ const text = input.text.trim();
81
+ if (!text)
82
+ throw new Error("tts text is required.");
83
+ if (text.length > config.tts.maxInputChars) {
84
+ throw new Error(`tts text is too long (${text.length}/${config.tts.maxInputChars} chars).`);
85
+ }
86
+ const voiceId = input.voiceId?.trim() || config.tts.voiceId.trim();
87
+ if (!voiceId)
88
+ throw new Error("tts.voice_id is not configured and no voiceId was provided.");
89
+ const apiKey = process.env[config.tts.apiKeyEnv];
90
+ if (!apiKey)
91
+ throw new Error(`Missing ElevenLabs API key env: ${config.tts.apiKeyEnv}`);
92
+ const outputFormat = config.tts.outputFormat;
93
+ const url = new URL(`${ELEVENLABS_TTS_BASE_URL}/${encodeURIComponent(voiceId)}`);
94
+ url.searchParams.set("output_format", outputFormat);
95
+ const response = await fetch(url, {
96
+ method: "POST",
97
+ headers: {
98
+ "content-type": "application/json",
99
+ "xi-api-key": apiKey,
100
+ },
101
+ body: JSON.stringify({
102
+ text,
103
+ model_id: config.tts.modelId,
104
+ voice_settings: buildElevenLabsVoiceSettings(config),
105
+ }),
106
+ signal,
107
+ });
108
+ if (!response.ok) {
109
+ throw new Error(`ElevenLabs TTS failed: ${await readElevenLabsError(response)}`);
110
+ }
111
+ const buffer = Buffer.from(await response.arrayBuffer());
112
+ const attachmentDir = await ensureGeneratedAttachmentsDir(config);
113
+ const extension = audioExtension(outputFormat);
114
+ const id = `tts_${randomUUID()}`;
115
+ const name = `${id}.${extension}`;
116
+ const localPath = resolve(attachmentDir, name);
117
+ await writeFile(localPath, buffer);
118
+ const mimeType = audioMimeType(outputFormat);
119
+ const attachment = {
120
+ id,
121
+ name,
122
+ mimeType,
123
+ size: buffer.length,
124
+ localPath,
125
+ provider: "elevenlabs",
126
+ toolName: "tts",
127
+ };
128
+ mediaSink.add(attachment);
129
+ return {
130
+ content: [{ type: "text", text: formatTtsNotice(name) }],
131
+ details: {
132
+ provider: "elevenlabs",
133
+ voiceId,
134
+ modelId: config.tts.modelId,
135
+ outputFormat,
136
+ localPath,
137
+ mimeType,
138
+ size: buffer.length,
139
+ },
140
+ };
141
+ },
142
+ };
143
+ }
@@ -0,0 +1,105 @@
1
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
+ const SESSION_TTL_MS = 12 * 60 * 60 * 1000;
3
+ function safeEqual(a, b) {
4
+ const left = Buffer.from(a);
5
+ const right = Buffer.from(b);
6
+ return left.length === right.length && timingSafeEqual(left, right);
7
+ }
8
+ function parseCookies(header) {
9
+ const cookies = {};
10
+ for (const part of (header ?? "").split(";")) {
11
+ const [name, ...valueParts] = part.trim().split("=");
12
+ if (!name)
13
+ continue;
14
+ cookies[name] = decodeURIComponent(valueParts.join("="));
15
+ }
16
+ return cookies;
17
+ }
18
+ function decodeTotpSecret(secret) {
19
+ const normalized = secret.replace(/\s+/g, "").replace(/=+$/g, "").toUpperCase();
20
+ if (!/^[A-Z2-7]+$/.test(normalized))
21
+ return Buffer.from(secret, "utf8");
22
+ const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
23
+ let bits = "";
24
+ for (const char of normalized) {
25
+ const value = alphabet.indexOf(char);
26
+ if (value < 0)
27
+ return Buffer.from(secret, "utf8");
28
+ bits += value.toString(2).padStart(5, "0");
29
+ }
30
+ const bytes = [];
31
+ for (let offset = 0; offset + 8 <= bits.length; offset += 8) {
32
+ bytes.push(Number.parseInt(bits.slice(offset, offset + 8), 2));
33
+ }
34
+ return Buffer.from(bytes);
35
+ }
36
+ export function verifyTotp(secret, token, now = Date.now()) {
37
+ const normalized = token.replace(/\s+/g, "");
38
+ if (!/^\d{6}$/.test(normalized))
39
+ return false;
40
+ const secretBuffer = decodeTotpSecret(secret);
41
+ const counter = Math.floor(now / 30000);
42
+ for (let offset = -1; offset <= 1; offset++) {
43
+ const counterBuffer = Buffer.alloc(8);
44
+ counterBuffer.writeBigUInt64BE(BigInt(counter + offset));
45
+ const hmac = createHmac("sha1", secretBuffer).update(counterBuffer).digest();
46
+ const digestOffset = hmac[hmac.length - 1] & 0x0f;
47
+ const code = (((hmac[digestOffset] & 0x7f) << 24) |
48
+ ((hmac[digestOffset + 1] & 0xff) << 16) |
49
+ ((hmac[digestOffset + 2] & 0xff) << 8) |
50
+ (hmac[digestOffset + 3] & 0xff)) %
51
+ 1000000;
52
+ if (safeEqual(code.toString().padStart(6, "0"), normalized))
53
+ return true;
54
+ }
55
+ return false;
56
+ }
57
+ function readBearerToken(request) {
58
+ const header = request.headers.authorization;
59
+ if (!header)
60
+ return undefined;
61
+ const match = header.match(/^Bearer\s+(.+)$/i);
62
+ return match?.[1];
63
+ }
64
+ export function createAuth(config) {
65
+ const sessions = new Map();
66
+ const pruneSessions = () => {
67
+ const now = Date.now();
68
+ for (const [id, session] of sessions) {
69
+ if (session.expiresAt <= now)
70
+ sessions.delete(id);
71
+ }
72
+ };
73
+ const hasBearer = (request) => {
74
+ if (!config.web.bearerToken)
75
+ return false;
76
+ const token = readBearerToken(request);
77
+ return token !== undefined && safeEqual(token, config.web.bearerToken);
78
+ };
79
+ const hasSession = (request) => {
80
+ pruneSessions();
81
+ const sessionId = parseCookies(request.headers.cookie).familiar_session;
82
+ if (!sessionId)
83
+ return false;
84
+ return sessions.has(sessionId);
85
+ };
86
+ const authorize = (request, pathname) => {
87
+ if (pathname === "/api/web/auth/mode")
88
+ return true;
89
+ if (config.web.authMode === "tailscale-only")
90
+ return true;
91
+ if (config.web.authMode === "bearer")
92
+ return hasBearer(request);
93
+ return hasSession(request) || hasBearer(request);
94
+ };
95
+ const createSession = () => {
96
+ pruneSessions();
97
+ const id = randomBytes(32).toString("base64url");
98
+ sessions.set(id, { expiresAt: Date.now() + SESSION_TTL_MS });
99
+ return id;
100
+ };
101
+ return { authorize, createSession };
102
+ }
103
+ export function sessionCookie(sessionId) {
104
+ return `familiar_session=${encodeURIComponent(sessionId)}; HttpOnly; SameSite=Lax; Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}; Path=/api/web`;
105
+ }
@@ -0,0 +1,114 @@
1
+ import { createHash } from "node:crypto";
2
+ export function encodeFrame(text) {
3
+ const payload = Buffer.from(text, "utf8");
4
+ let header;
5
+ if (payload.length < 126) {
6
+ header = Buffer.from([0x81, payload.length]);
7
+ }
8
+ else if (payload.length <= 0xffff) {
9
+ header = Buffer.alloc(4);
10
+ header[0] = 0x81;
11
+ header[1] = 126;
12
+ header.writeUInt16BE(payload.length, 2);
13
+ }
14
+ else {
15
+ header = Buffer.alloc(10);
16
+ header[0] = 0x81;
17
+ header[1] = 127;
18
+ header.writeBigUInt64BE(BigInt(payload.length), 2);
19
+ }
20
+ return Buffer.concat([header, payload]);
21
+ }
22
+ export function decodeFrames(buffer) {
23
+ const messages = [];
24
+ let offset = 0;
25
+ let close = false;
26
+ while (offset + 2 <= buffer.length) {
27
+ const first = buffer[offset];
28
+ const second = buffer[offset + 1];
29
+ const opcode = first & 0x0f;
30
+ const masked = (second & 0x80) !== 0;
31
+ let length = second & 0x7f;
32
+ let headerLength = 2;
33
+ if (length === 126) {
34
+ if (offset + 4 > buffer.length)
35
+ break;
36
+ length = buffer.readUInt16BE(offset + 2);
37
+ headerLength = 4;
38
+ }
39
+ else if (length === 127) {
40
+ if (offset + 10 > buffer.length)
41
+ break;
42
+ const bigLength = buffer.readBigUInt64BE(offset + 2);
43
+ if (bigLength > BigInt(Number.MAX_SAFE_INTEGER))
44
+ throw new Error("WebSocket frame too large");
45
+ length = Number(bigLength);
46
+ headerLength = 10;
47
+ }
48
+ const maskLength = masked ? 4 : 0;
49
+ const frameEnd = offset + headerLength + maskLength + length;
50
+ if (frameEnd > buffer.length)
51
+ break;
52
+ const payloadStart = offset + headerLength + maskLength;
53
+ const payload = Buffer.from(buffer.subarray(payloadStart, frameEnd));
54
+ if (masked) {
55
+ const mask = buffer.subarray(offset + headerLength, offset + headerLength + 4);
56
+ for (let index = 0; index < payload.length; index++)
57
+ payload[index] ^= mask[index % 4];
58
+ }
59
+ if (opcode === 0x8)
60
+ close = true;
61
+ if (opcode === 0x1)
62
+ messages.push(payload.toString("utf8"));
63
+ offset = frameEnd;
64
+ }
65
+ return { messages, remaining: buffer.subarray(offset), close };
66
+ }
67
+ export function acceptWebSocket(request, socket) {
68
+ const key = request.headers["sec-websocket-key"];
69
+ if (typeof key !== "string") {
70
+ socket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
71
+ socket.destroy();
72
+ return false;
73
+ }
74
+ const responseKey = createHash("sha1").update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`).digest("base64");
75
+ socket.write([
76
+ "HTTP/1.1 101 Switching Protocols",
77
+ "Upgrade: websocket",
78
+ "Connection: Upgrade",
79
+ `Sec-WebSocket-Accept: ${responseKey}`,
80
+ "",
81
+ "",
82
+ ].join("\r\n"));
83
+ return true;
84
+ }
85
+ export function replayEvents(client, events, lastEventId, onReplayWindowLost) {
86
+ if (!lastEventId) {
87
+ client.authed = true;
88
+ for (const event of client.pendingEvents ?? []) {
89
+ client.socket.write(encodeFrame(JSON.stringify(event)));
90
+ }
91
+ client.pendingEvents = undefined;
92
+ return;
93
+ }
94
+ const pending = client.pendingEvents ?? [];
95
+ const index = events.findIndex((event) => event.eventId === lastEventId);
96
+ if (index < 0) {
97
+ client.authed = true;
98
+ client.socket.write(encodeFrame(JSON.stringify(onReplayWindowLost())));
99
+ for (const event of pending) {
100
+ client.socket.write(encodeFrame(JSON.stringify(event)));
101
+ }
102
+ client.pendingEvents = undefined;
103
+ return;
104
+ }
105
+ client.authed = true;
106
+ const eventIds = new Set();
107
+ for (const event of [...events.slice(index + 1), ...pending]) {
108
+ if (eventIds.has(event.eventId))
109
+ continue;
110
+ eventIds.add(event.eventId);
111
+ client.socket.write(encodeFrame(JSON.stringify(event)));
112
+ }
113
+ client.pendingEvents = undefined;
114
+ }
@@ -0,0 +1,29 @@
1
+ export const MAX_BODY_BYTES = 64 * 1024;
2
+ export function sendJson(response, status, body, headers = {}) {
3
+ response.writeHead(status, {
4
+ "content-type": "application/json; charset=utf-8",
5
+ "cache-control": "no-store",
6
+ ...headers,
7
+ });
8
+ response.end(JSON.stringify(body));
9
+ }
10
+ export function sendText(response, status, text) {
11
+ response.writeHead(status, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" });
12
+ response.end(text);
13
+ }
14
+ export async function readJsonBody(request) {
15
+ const chunks = [];
16
+ let total = 0;
17
+ for await (const chunk of request) {
18
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
19
+ total += buffer.length;
20
+ if (total > MAX_BODY_BYTES)
21
+ throw new Error("Request body too large");
22
+ chunks.push(buffer);
23
+ }
24
+ const raw = Buffer.concat(chunks).toString("utf8").trim();
25
+ return raw ? JSON.parse(raw) : {};
26
+ }
27
+ export function isObject(value) {
28
+ return !!value && typeof value === "object" && !Array.isArray(value);
29
+ }
@@ -0,0 +1,106 @@
1
+ import { createReadStream, existsSync } from "node:fs";
2
+ import { lstat, realpath, stat } from "node:fs/promises";
3
+ import { extname, join, relative, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { attachmentsDir, browserScreenshotsDir, generatedAttachmentsDir } from "./generated-media.js";
6
+ import { sendText } from "./web-http.js";
7
+ function getProjectRoot() {
8
+ return resolve(fileURLToPath(import.meta.url), "../..");
9
+ }
10
+ function mimeType(path) {
11
+ const extension = extname(path).toLowerCase();
12
+ if (extension === ".html")
13
+ return "text/html; charset=utf-8";
14
+ if (extension === ".js")
15
+ return "text/javascript; charset=utf-8";
16
+ if (extension === ".css")
17
+ return "text/css; charset=utf-8";
18
+ if (extension === ".svg")
19
+ return "image/svg+xml";
20
+ if (extension === ".png")
21
+ return "image/png";
22
+ if (extension === ".jpg" || extension === ".jpeg")
23
+ return "image/jpeg";
24
+ if (extension === ".gif")
25
+ return "image/gif";
26
+ if (extension === ".webp")
27
+ return "image/webp";
28
+ if (extension === ".ico")
29
+ return "image/x-icon";
30
+ if (extension === ".mp3")
31
+ return "audio/mpeg";
32
+ if (extension === ".opus" || extension === ".ogg")
33
+ return "audio/ogg";
34
+ if (extension === ".wav")
35
+ return "audio/wav";
36
+ return "application/octet-stream";
37
+ }
38
+ export async function serveStatic(response, requestPath) {
39
+ const distDir = resolve(getProjectRoot(), "web/dist");
40
+ if (!existsSync(distDir))
41
+ return false;
42
+ const pathname = decodeURIComponent(requestPath.split("?")[0] || "/");
43
+ const candidate = resolve(distDir, pathname === "/" ? "index.html" : pathname.slice(1));
44
+ if (!candidate.startsWith(distDir)) {
45
+ sendText(response, 403, "Forbidden");
46
+ return true;
47
+ }
48
+ let filePath = candidate;
49
+ const fileStat = await stat(filePath).catch(() => undefined);
50
+ if (!fileStat?.isFile())
51
+ filePath = join(distDir, "index.html");
52
+ const stream = createReadStream(filePath);
53
+ response.writeHead(200, { "content-type": mimeType(filePath) });
54
+ stream.pipe(response);
55
+ return true;
56
+ }
57
+ export async function serveAttachment(config, response, requestPath) {
58
+ const relativePath = decodeURIComponent(requestPath.replace(/^\/api\/web\/attachments\/?/, ""));
59
+ if (!relativePath) {
60
+ sendText(response, 404, "Not found");
61
+ return true;
62
+ }
63
+ const candidates = [
64
+ { root: generatedAttachmentsDir(config), relativePath },
65
+ { root: attachmentsDir(config), relativePath },
66
+ { root: browserScreenshotsDir(config), relativePath: relativePath.replace(/^screenshot[\\/]/, "") },
67
+ ];
68
+ for (const { root, relativePath: candidateRelativePath } of candidates) {
69
+ const rootRealPath = await realpath(root).catch(() => undefined);
70
+ if (!rootRealPath)
71
+ continue;
72
+ const filePath = resolve(root, candidateRelativePath);
73
+ const rel = relative(root, filePath);
74
+ if (rel.startsWith("..") || rel.startsWith("/") || rel.startsWith("\\") || rel === "") {
75
+ sendText(response, 403, "Forbidden");
76
+ return true;
77
+ }
78
+ const linkStat = await lstat(filePath).catch(() => undefined);
79
+ if (!linkStat)
80
+ continue;
81
+ if (linkStat.isSymbolicLink()) {
82
+ sendText(response, 403, "Forbidden");
83
+ return true;
84
+ }
85
+ const fileRealPath = await realpath(filePath).catch(() => undefined);
86
+ if (!fileRealPath)
87
+ continue;
88
+ const realRel = relative(rootRealPath, fileRealPath);
89
+ if (realRel.startsWith("..") || realRel.startsWith("/") || realRel.startsWith("\\") || realRel === "") {
90
+ sendText(response, 403, "Forbidden");
91
+ return true;
92
+ }
93
+ const fileStat = await stat(fileRealPath).catch(() => undefined);
94
+ if (!fileStat?.isFile())
95
+ continue;
96
+ response.writeHead(200, {
97
+ "content-type": mimeType(filePath),
98
+ "content-length": String(fileStat.size),
99
+ "cache-control": "private, max-age=31536000, immutable",
100
+ });
101
+ createReadStream(fileRealPath).pipe(response);
102
+ return true;
103
+ }
104
+ sendText(response, 404, "Not found");
105
+ return true;
106
+ }