fathom-mcp 0.4.13 → 0.5.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fathom-mcp",
3
- "version": "0.4.13",
3
+ "version": "0.5.1",
4
4
  "description": "MCP server for Fathom — vault operations, search, rooms, and cross-workspace communication",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,7 +23,8 @@
23
23
  },
24
24
  "dependencies": {
25
25
  "@modelcontextprotocol/sdk": "^1.26.0",
26
- "js-yaml": "^4.1.0"
26
+ "js-yaml": "^4.1.0",
27
+ "ws": "^8.18.0"
27
28
  },
28
29
  "devDependencies": {
29
30
  "@eslint/js": "^9.39.3",
@@ -0,0 +1,220 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # fathom-start.sh — Launch an agent in a correctly-named tmux session.
4
+ #
5
+ # Reads .fathom.json for workspace name and agent, creates
6
+ # {workspace}_fathom-session, saves pane ID for WebSocket push injection.
7
+ #
8
+ # Usage:
9
+ # fathom-start.sh Start agent, save pane ID, attach
10
+ # fathom-start.sh --detach Start agent, save pane ID, don't attach
11
+ # fathom-start.sh --agent X Override agent (claude-code|codex|gemini|opencode)
12
+ # fathom-start.sh --kill Kill existing session
13
+ # fathom-start.sh --status Show session status
14
+
15
+ set -euo pipefail
16
+
17
+ # ── Defaults ──────────────────────────────────────────────────────────────────
18
+
19
+ ATTACH=true
20
+ AGENT_OVERRIDE=""
21
+ ACTION="start"
22
+
23
+ # ── Parse flags ───────────────────────────────────────────────────────────────
24
+
25
+ while [[ $# -gt 0 ]]; do
26
+ case "$1" in
27
+ --detach) ATTACH=false; shift ;;
28
+ --attach) ATTACH=true; shift ;;
29
+ --agent) AGENT_OVERRIDE="$2"; shift 2 ;;
30
+ --kill) ACTION="kill"; shift ;;
31
+ --status) ACTION="status"; shift ;;
32
+ -h|--help)
33
+ echo "Usage: fathom-start.sh [--attach] [--detach] [--agent NAME] [--kill] [--status]"
34
+ echo ""
35
+ echo " (default) Start agent in tmux, save pane ID, attach"
36
+ echo " --detach Start but don't attach"
37
+ echo " --agent X Override agent: claude-code, codex, gemini, opencode"
38
+ echo " --kill Kill existing session"
39
+ echo " --status Show if session is running"
40
+ exit 0
41
+ ;;
42
+ *)
43
+ echo "Unknown flag: $1" >&2
44
+ exit 1
45
+ ;;
46
+ esac
47
+ done
48
+
49
+ # ── Find .fathom.json ────────────────────────────────────────────────────────
50
+
51
+ find_config() {
52
+ local dir="$PWD"
53
+ while [[ "$dir" != "/" ]]; do
54
+ if [[ -f "$dir/.fathom.json" ]]; then
55
+ echo "$dir/.fathom.json"
56
+ return 0
57
+ fi
58
+ dir="$(dirname "$dir")"
59
+ done
60
+ return 1
61
+ }
62
+
63
+ CONFIG_FILE=$(find_config) || {
64
+ echo "Error: No .fathom.json found (searched from $PWD to /)" >&2
65
+ echo "Run 'npx fathom-mcp init' first." >&2
66
+ exit 1
67
+ }
68
+
69
+ PROJECT_DIR="$(dirname "$CONFIG_FILE")"
70
+
71
+ # ── Parse config ──────────────────────────────────────────────────────────────
72
+
73
+ read_json_field() {
74
+ local file="$1" field="$2"
75
+ if command -v jq &>/dev/null; then
76
+ jq -r ".$field // empty" "$file" 2>/dev/null
77
+ else
78
+ # Fallback: simple grep/sed for flat string fields
79
+ sed -n "s/.*\"$field\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" "$file" | head -1
80
+ fi
81
+ }
82
+
83
+ read_json_array_first() {
84
+ local file="$1" field="$2"
85
+ if command -v jq &>/dev/null; then
86
+ jq -r ".$field[0] // empty" "$file" 2>/dev/null
87
+ else
88
+ # Fallback: grab first quoted string after the array field
89
+ sed -n "/\"$field\"/,/\]/{ s/.*\"\([^\"]*\)\".*/\1/p; }" "$file" | head -1
90
+ fi
91
+ }
92
+
93
+ WORKSPACE=$(read_json_field "$CONFIG_FILE" "workspace")
94
+ if [[ -z "$WORKSPACE" ]]; then
95
+ WORKSPACE="$(basename "$PROJECT_DIR")"
96
+ fi
97
+
98
+ SESSION="${WORKSPACE}_fathom-session"
99
+ PANE_DIR="$HOME/.config/fathom"
100
+ PANE_FILE="$PANE_DIR/${WORKSPACE}-pane-id"
101
+
102
+ # ── Resolve agent command ─────────────────────────────────────────────────────
103
+
104
+ resolve_agent_cmd() {
105
+ local agent="${AGENT_OVERRIDE:-}"
106
+ if [[ -z "$agent" ]]; then
107
+ agent=$(read_json_array_first "$CONFIG_FILE" "agents")
108
+ fi
109
+ if [[ -z "$agent" ]]; then
110
+ agent="claude-code"
111
+ fi
112
+
113
+ case "$agent" in
114
+ claude-code)
115
+ echo "claude --model opus --permission-mode bypassPermissions"
116
+ ;;
117
+ codex)
118
+ echo "codex"
119
+ ;;
120
+ gemini)
121
+ echo "gemini"
122
+ ;;
123
+ opencode)
124
+ echo "opencode"
125
+ ;;
126
+ *)
127
+ echo "Warning: Unknown agent '$agent', falling back to claude" >&2
128
+ echo "claude"
129
+ ;;
130
+ esac
131
+ }
132
+
133
+ # ── Save pane ID ──────────────────────────────────────────────────────────────
134
+
135
+ save_pane_id() {
136
+ local pane_id
137
+ pane_id=$(tmux list-panes -t "$SESSION" -F '#{pane_id}' 2>/dev/null | head -1)
138
+ if [[ -n "$pane_id" ]]; then
139
+ mkdir -p "$PANE_DIR"
140
+ echo "$pane_id" > "$PANE_FILE"
141
+ echo "Pane ID: $pane_id → $PANE_FILE"
142
+ fi
143
+ }
144
+
145
+ # ── Session check ─────────────────────────────────────────────────────────────
146
+
147
+ session_exists() {
148
+ tmux has-session -t "$SESSION" 2>/dev/null
149
+ }
150
+
151
+ # ── Actions ───────────────────────────────────────────────────────────────────
152
+
153
+ do_status() {
154
+ echo "Workspace: $WORKSPACE"
155
+ echo "Session: $SESSION"
156
+ if session_exists; then
157
+ echo "Status: running"
158
+ if [[ -f "$PANE_FILE" ]]; then
159
+ echo "Pane ID: $(cat "$PANE_FILE")"
160
+ fi
161
+ else
162
+ echo "Status: not running"
163
+ fi
164
+ }
165
+
166
+ do_kill() {
167
+ if session_exists; then
168
+ tmux kill-session -t "$SESSION"
169
+ rm -f "$PANE_FILE"
170
+ echo "Killed session: $SESSION"
171
+ else
172
+ echo "Session not running: $SESSION"
173
+ fi
174
+ }
175
+
176
+ do_start() {
177
+ if session_exists; then
178
+ echo "Session already running: $SESSION"
179
+ save_pane_id
180
+ if [[ "$ATTACH" == true ]]; then
181
+ exec tmux attach-session -t "$SESSION"
182
+ fi
183
+ return 0
184
+ fi
185
+
186
+ local agent_cmd
187
+ agent_cmd=$(resolve_agent_cmd)
188
+
189
+ echo "Starting: $SESSION"
190
+ echo "Agent: $agent_cmd"
191
+ echo "Dir: $PROJECT_DIR"
192
+
193
+ # Unset CLAUDECODE to avoid nested session detection
194
+ unset CLAUDECODE 2>/dev/null || true
195
+
196
+ # Create detached tmux session running the agent
197
+ tmux new-session -d -s "$SESSION" -c "$PROJECT_DIR" $agent_cmd
198
+
199
+ # Wait briefly for session to stabilize
200
+ sleep 2
201
+
202
+ if session_exists; then
203
+ save_pane_id
204
+ echo "Session started."
205
+ if [[ "$ATTACH" == true ]]; then
206
+ exec tmux attach-session -t "$SESSION"
207
+ fi
208
+ else
209
+ echo "Error: Session failed to start" >&2
210
+ exit 1
211
+ fi
212
+ }
213
+
214
+ # ── Main ──────────────────────────────────────────────────────────────────────
215
+
216
+ case "$ACTION" in
217
+ status) do_status ;;
218
+ kill) do_kill ;;
219
+ start) do_start ;;
220
+ esac
package/src/cli.js CHANGED
@@ -845,6 +845,35 @@ async function runUpdate() {
845
845
  console.log(" Restart your agent session to pick up changes.\n");
846
846
  }
847
847
 
848
+ // --- Start command -----------------------------------------------------------
849
+
850
+ function runStart(argv) {
851
+ // Find the installed fathom-start.sh script
852
+ const found = findConfigFile(process.cwd());
853
+ const projectDir = found?.dir || process.cwd();
854
+
855
+ // Check .fathom/scripts/ first (installed by init/update), then package scripts/
856
+ const localScript = path.join(projectDir, ".fathom", "scripts", "fathom-start.sh");
857
+ const packageScript = path.join(SCRIPTS_DIR, "fathom-start.sh");
858
+ const script = fs.existsSync(localScript) ? localScript : packageScript;
859
+
860
+ if (!fs.existsSync(script)) {
861
+ console.error(" Error: fathom-start.sh not found. Run `npx fathom-mcp update` first.");
862
+ process.exit(1);
863
+ }
864
+
865
+ // Pass remaining args through to the shell script
866
+ try {
867
+ execFileSync("bash", [script, ...argv], {
868
+ cwd: projectDir,
869
+ stdio: "inherit",
870
+ });
871
+ } catch (e) {
872
+ // Script already printed its own errors; just propagate exit code
873
+ process.exit(e.status || 1);
874
+ }
875
+ }
876
+
848
877
  // --- Main --------------------------------------------------------------------
849
878
 
850
879
  // Guard: only run CLI when this module is the entry point (not when imported by tests)
@@ -871,12 +900,14 @@ if (isMain) {
871
900
  console.error(`Error: ${e.message}`);
872
901
  process.exit(1);
873
902
  });
903
+ } else if (command === "start") {
904
+ runStart(process.argv.slice(3));
874
905
  } else if (!command || command === "serve") {
875
906
  // Default: start MCP server
876
907
  import("./index.js");
877
908
  } else {
878
909
  console.error(`Unknown command: ${command}`);
879
- console.error(`Usage: fathom-mcp [init|status|update|serve]
910
+ console.error(`Usage: fathom-mcp [init|status|update|start|serve]
880
911
 
881
912
  fathom-mcp init Interactive setup
882
913
  fathom-mcp init -y --api-key KEY Non-interactive setup
@@ -884,6 +915,9 @@ if (isMain) {
884
915
  fathom-mcp init -y --api-key KEY --workspace NAME Custom workspace name
885
916
  fathom-mcp status Check connection status
886
917
  fathom-mcp update Update hooks + version
918
+ fathom-mcp start Start agent in tmux session
919
+ fathom-mcp start --detach Start without attaching
920
+ fathom-mcp start --kill Kill agent session
887
921
  fathom-mcp Start MCP server`);
888
922
  process.exit(1);
889
923
  }
package/src/index.js CHANGED
@@ -20,6 +20,7 @@ import {
20
20
 
21
21
  import { resolveConfig } from "./config.js";
22
22
  import { createClient } from "./server-client.js";
23
+ import { createWSConnection } from "./ws-connection.js";
23
24
  import {
24
25
  handleVaultWrite,
25
26
  handleVaultAppend,
@@ -32,6 +33,7 @@ import {
32
33
 
33
34
  const config = resolveConfig();
34
35
  const client = createClient(config);
36
+ let wsConn = null;
35
37
 
36
38
  // --- Tool definitions --------------------------------------------------------
37
39
 
@@ -511,6 +513,36 @@ const telegramTools = [
511
513
  required: ["contact", "message"],
512
514
  },
513
515
  },
516
+ {
517
+ name: "fathom_telegram_image",
518
+ description:
519
+ "Read a Telegram message's attached image and return it as base64 so Claude can perceive it. " +
520
+ "Use after fathom_telegram_read shows a message has media: true. " +
521
+ "Supports jpg, jpeg, png, gif, webp. Max 5MB.",
522
+ inputSchema: {
523
+ type: "object",
524
+ properties: {
525
+ message_id: { type: "number", description: "The message ID from fathom_telegram_read results" },
526
+ },
527
+ required: ["message_id"],
528
+ },
529
+ },
530
+ {
531
+ name: "fathom_telegram_send_image",
532
+ description:
533
+ "Send an image to a Telegram contact via the persistent Telethon client. " +
534
+ "Provide an absolute file path to a local image. Optionally include a caption. " +
535
+ "Contact can be a name, @username, or chat_id number.",
536
+ inputSchema: {
537
+ type: "object",
538
+ properties: {
539
+ contact: { type: "string", description: "Contact name, @username, or chat_id" },
540
+ file_path: { type: "string", description: "Absolute path to the image file to send" },
541
+ caption: { type: "string", description: "Optional caption text for the image (max 1024 chars)" },
542
+ },
543
+ required: ["contact", "file_path"],
544
+ },
545
+ },
514
546
  ];
515
547
 
516
548
  // --- Server setup & dispatch -------------------------------------------------
@@ -757,6 +789,48 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
757
789
  }
758
790
  break;
759
791
  }
792
+ case "fathom_telegram_image": {
793
+ const msgId = args.message_id;
794
+ if (!msgId) {
795
+ result = { error: "message_id is required" };
796
+ } else {
797
+ // Check local WebSocket cache first (avoids HTTP round-trip for pushed images)
798
+ const cached = wsConn?.getCachedImage(msgId);
799
+ if (cached) {
800
+ result = { _image: true, data: cached.data, mimeType: cached.mimeType };
801
+ } else {
802
+ result = await client.telegramImage(msgId);
803
+ if (result?.data && result?.mimeType) {
804
+ result = { _image: true, data: result.data, mimeType: result.mimeType };
805
+ }
806
+ }
807
+ }
808
+ break;
809
+ }
810
+ case "fathom_telegram_send_image": {
811
+ const imgContactArg = args.contact;
812
+ if (!imgContactArg) { result = { error: "contact is required" }; break; }
813
+ if (!args.file_path) { result = { error: "file_path is required" }; break; }
814
+
815
+ const imgContacts = await client.telegramContacts(config.workspace);
816
+ const imgList = imgContacts?.contacts || [];
817
+ let imgChatId = parseInt(imgContactArg, 10);
818
+ if (isNaN(imgChatId)) {
819
+ const lower = imgContactArg.toLowerCase().replace(/^@/, "");
820
+ const match = imgList.find(c =>
821
+ (c.username || "").toLowerCase() === lower ||
822
+ (c.first_name || "").toLowerCase() === lower ||
823
+ (c.first_name || "").toLowerCase().includes(lower)
824
+ );
825
+ imgChatId = match ? match.chat_id : null;
826
+ }
827
+ if (!imgChatId) {
828
+ result = { error: `Contact not found: ${imgContactArg}. Use fathom_telegram_contacts to list known contacts.` };
829
+ } else {
830
+ result = await client.telegramSendImage(imgChatId, args.file_path, args.caption);
831
+ }
832
+ break;
833
+ }
760
834
  default:
761
835
  result = { error: `Unknown tool: ${name}` };
762
836
  }
@@ -842,7 +916,12 @@ async function main() {
842
916
  // Startup sync for synced mode (fire-and-forget)
843
917
  startupSync().catch(() => {});
844
918
 
845
- // Heartbeat report liveness to server every 30s
919
+ // WebSocket push channel receives server-pushed messages
920
+ if (config.server && config.workspace && config.apiKey) {
921
+ wsConn = createWSConnection(config);
922
+ }
923
+
924
+ // Heartbeat — report liveness to server every 30s (kept for backwards compat)
846
925
  if (config.server && config.workspace) {
847
926
  const beat = () =>
848
927
  client
@@ -265,6 +265,16 @@ export function createClient(config) {
265
265
  });
266
266
  }
267
267
 
268
+ async function telegramImage(messageId) {
269
+ return request("GET", `/api/telegram/image/${messageId}`);
270
+ }
271
+
272
+ async function telegramSendImage(chatId, filePath, caption) {
273
+ return request("POST", `/api/telegram/send-image/${chatId}`, {
274
+ body: { file_path: filePath, caption: caption || "" },
275
+ });
276
+ }
277
+
268
278
  async function telegramStatus() {
269
279
  return request("GET", "/api/telegram/status");
270
280
  }
@@ -323,6 +333,8 @@ export function createClient(config) {
323
333
  telegramContacts,
324
334
  telegramRead,
325
335
  telegramSend,
336
+ telegramImage,
337
+ telegramSendImage,
326
338
  telegramStatus,
327
339
  getSettings,
328
340
  getApiKey,
@@ -0,0 +1,250 @@
1
+ /**
2
+ * WebSocket push channel — receives server-pushed messages and handles them locally.
3
+ *
4
+ * Connects to fathom-server's /ws/agent/{workspace} endpoint. Receives:
5
+ * - inject / ping_fire → tmux send-keys into local pane
6
+ * - image → cache base64 data to .fathom/telegram-cache/
7
+ * - ping → respond with pong
8
+ *
9
+ * Auto-reconnects with exponential backoff (1s → 60s cap).
10
+ * HTTP heartbeat still runs separately for backwards compat with old servers.
11
+ */
12
+
13
+ import { execSync } from "child_process";
14
+ import fs from "fs";
15
+ import os from "os";
16
+ import path from "path";
17
+ import WebSocket from "ws";
18
+
19
+ const KEEPALIVE_INTERVAL_MS = 30_000;
20
+ const INITIAL_RECONNECT_MS = 1_000;
21
+ const MAX_RECONNECT_MS = 60_000;
22
+ const IMAGE_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
23
+
24
+ /**
25
+ * @param {object} config — resolved config from config.js
26
+ * @returns {{ getCachedImage: (messageId: number) => {data: string, mimeType: string} | null, close: () => void }}
27
+ */
28
+ export function createWSConnection(config) {
29
+ const workspace = config.workspace;
30
+ const agent = config.agents?.[0] || "unknown";
31
+ const vaultMode = config.vaultMode || "local";
32
+
33
+ // Derive WS URL from HTTP server URL
34
+ const serverUrl = config.server || "http://localhost:4243";
35
+ const wsUrl = serverUrl
36
+ .replace(/^http:/, "ws:")
37
+ .replace(/^https:/, "wss:")
38
+ + `/ws/agent/${encodeURIComponent(workspace)}`
39
+ + `?token=${encodeURIComponent(config.apiKey || "")}`;
40
+
41
+ // Image cache directory
42
+ const cacheDir = path.join(os.homedir(), ".fathom", "telegram-cache");
43
+
44
+ let ws = null;
45
+ let reconnectDelay = INITIAL_RECONNECT_MS;
46
+ let keepaliveTimer = null;
47
+ let closed = false;
48
+
49
+ // Clean up old cached images on startup
50
+ cleanupImageCache();
51
+
52
+ connect();
53
+
54
+ function connect() {
55
+ if (closed) return;
56
+
57
+ try {
58
+ ws = new WebSocket(wsUrl);
59
+ } catch {
60
+ scheduleReconnect();
61
+ return;
62
+ }
63
+
64
+ ws.on("open", () => {
65
+ reconnectDelay = INITIAL_RECONNECT_MS;
66
+
67
+ // Send hello handshake
68
+ ws.send(JSON.stringify({
69
+ type: "hello",
70
+ agent,
71
+ vault_mode: vaultMode,
72
+ }));
73
+
74
+ // Start keepalive pong timer
75
+ startKeepalive();
76
+ });
77
+
78
+ ws.on("message", (raw) => {
79
+ let msg;
80
+ try {
81
+ msg = JSON.parse(raw.toString());
82
+ } catch {
83
+ return;
84
+ }
85
+
86
+ switch (msg.type) {
87
+ case "welcome":
88
+ break;
89
+
90
+ case "inject":
91
+ case "ping_fire":
92
+ injectToTmux(msg.text || "");
93
+ break;
94
+
95
+ case "image":
96
+ cacheImage(msg);
97
+ break;
98
+
99
+ case "ping":
100
+ safeSend({ type: "pong" });
101
+ break;
102
+
103
+ case "error":
104
+ // Server rejected us — don't reconnect immediately
105
+ reconnectDelay = MAX_RECONNECT_MS;
106
+ break;
107
+ }
108
+ });
109
+
110
+ ws.on("close", () => {
111
+ stopKeepalive();
112
+ if (!closed) scheduleReconnect();
113
+ });
114
+
115
+ ws.on("error", () => {
116
+ // Error always followed by close event — reconnect handled there
117
+ stopKeepalive();
118
+ });
119
+ }
120
+
121
+ function safeSend(obj) {
122
+ try {
123
+ if (ws && ws.readyState === WebSocket.OPEN) {
124
+ ws.send(JSON.stringify(obj));
125
+ }
126
+ } catch {
127
+ // Swallow — close event will trigger reconnect
128
+ }
129
+ }
130
+
131
+ function startKeepalive() {
132
+ stopKeepalive();
133
+ keepaliveTimer = setInterval(() => {
134
+ safeSend({ type: "pong" });
135
+ }, KEEPALIVE_INTERVAL_MS);
136
+ }
137
+
138
+ function stopKeepalive() {
139
+ if (keepaliveTimer) {
140
+ clearInterval(keepaliveTimer);
141
+ keepaliveTimer = null;
142
+ }
143
+ }
144
+
145
+ function scheduleReconnect() {
146
+ if (closed) return;
147
+ setTimeout(connect, reconnectDelay);
148
+ reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_MS);
149
+ }
150
+
151
+ // ── tmux injection ──────────────────────────────────────────────────────────
152
+
153
+ function injectToTmux(text) {
154
+ if (!text) return;
155
+ const pane = resolvePaneTarget();
156
+
157
+ try {
158
+ // Send the text literally, then press Enter
159
+ execSync(`tmux send-keys -t ${shellEscape(pane)} -l ${shellEscape(text)}`, {
160
+ timeout: 5000,
161
+ stdio: "ignore",
162
+ });
163
+ execSync(`tmux send-keys -t ${shellEscape(pane)} Enter`, {
164
+ timeout: 5000,
165
+ stdio: "ignore",
166
+ });
167
+ } catch {
168
+ // tmux not available or pane not found — non-fatal
169
+ }
170
+ }
171
+
172
+ function resolvePaneTarget() {
173
+ // Check for explicit pane ID file
174
+ const paneIdFile = path.join(os.homedir(), ".config", "fathom", `${workspace}-pane-id`);
175
+ try {
176
+ const paneId = fs.readFileSync(paneIdFile, "utf-8").trim();
177
+ if (paneId) return paneId;
178
+ } catch {
179
+ // Fall through to default
180
+ }
181
+ return `${workspace}_fathom-session`;
182
+ }
183
+
184
+ function shellEscape(s) {
185
+ // Escape for shell — wrap in single quotes, escape internal single quotes
186
+ return "'" + s.replace(/'/g, "'\\''") + "'";
187
+ }
188
+
189
+ // ── Image cache ─────────────────────────────────────────────────────────────
190
+
191
+ function cacheImage(msg) {
192
+ if (!msg.message_id || !msg.data) return;
193
+
194
+ try {
195
+ fs.mkdirSync(cacheDir, { recursive: true });
196
+ const ext = (msg.filename || "").split(".").pop() || "jpg";
197
+ const filename = `${msg.message_id}.${ext}`;
198
+ const filePath = path.join(cacheDir, filename);
199
+ fs.writeFileSync(filePath, Buffer.from(msg.data, "base64"));
200
+ } catch {
201
+ // Cache write failure is non-fatal
202
+ }
203
+ }
204
+
205
+ function getCachedImage(messageId) {
206
+ try {
207
+ const dir = fs.readdirSync(cacheDir);
208
+ const match = dir.find(f => f.startsWith(`${messageId}.`));
209
+ if (!match) return null;
210
+
211
+ const filePath = path.join(cacheDir, match);
212
+ const data = fs.readFileSync(filePath);
213
+ const ext = path.extname(match).slice(1).toLowerCase();
214
+ const mimeMap = { jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp" };
215
+ return {
216
+ data: data.toString("base64"),
217
+ mimeType: mimeMap[ext] || "image/jpeg",
218
+ };
219
+ } catch {
220
+ return null;
221
+ }
222
+ }
223
+
224
+ function cleanupImageCache() {
225
+ try {
226
+ if (!fs.existsSync(cacheDir)) return;
227
+ const now = Date.now();
228
+ for (const file of fs.readdirSync(cacheDir)) {
229
+ const filePath = path.join(cacheDir, file);
230
+ const stat = fs.statSync(filePath);
231
+ if (now - stat.mtimeMs > IMAGE_CACHE_MAX_AGE_MS) {
232
+ fs.unlinkSync(filePath);
233
+ }
234
+ }
235
+ } catch {
236
+ // Cleanup failure is non-fatal
237
+ }
238
+ }
239
+
240
+ function close() {
241
+ closed = true;
242
+ stopKeepalive();
243
+ if (ws) {
244
+ try { ws.close(); } catch { /* ignore */ }
245
+ ws = null;
246
+ }
247
+ }
248
+
249
+ return { getCachedImage, close };
250
+ }