pinpoint-bot 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js ADDED
@@ -0,0 +1,2592 @@
1
+ /**
2
+ * Pinpoint — WhatsApp Bot with Gemini AI (60+ tools + skills system + file receive)
3
+ *
4
+ * Self-chat mode: message yourself to search documents.
5
+ * Gemini understands natural language → calls tools → replies naturally.
6
+ * Echo prevention: 3 layers (fromMe, message ID dedup, sent-text tracker).
7
+ * Conversation memory: last 20 messages passed to Gemini for context.
8
+ * File receive: send files from phone → saved to computer (Downloads/Pinpoint/).
9
+ *
10
+ * Tools (38): search, files, faces, images, data, write/create, PDF, archive, download, smart-ops, facts
11
+ * See skills/*.md for full tool descriptions
12
+ */
13
+
14
+ const {
15
+ default: makeWASocket,
16
+ useMultiFileAuthState,
17
+ fetchLatestBaileysVersion,
18
+ DisconnectReason,
19
+ makeCacheableSignalKeyStore,
20
+ downloadMediaMessage,
21
+ getContentType,
22
+ } = require("@whiskeysockets/baileys");
23
+ const { GoogleGenAI } = require("@google/genai");
24
+ const pino = require("pino");
25
+ const qrcode = require("qrcode-terminal");
26
+ const { createHash } = require("crypto");
27
+ const { readFileSync, writeFileSync, statSync, existsSync, mkdirSync, readdirSync, unlinkSync } = require("fs");
28
+ const os = require("os");
29
+ const pathModule = require("path");
30
+
31
+ const DEFAULT_USER_DIR = pathModule.join(os.homedir(), ".pinpoint");
32
+ const USER_DATA_DIR = process.env.PINPOINT_USER_DIR || DEFAULT_USER_DIR;
33
+ const DEFAULT_ENV_PATH = pathModule.join(USER_DATA_DIR, ".env");
34
+ const REPO_ENV_PATH = pathModule.join(__dirname, "..", ".env");
35
+ const ENV_PATH = process.env.PINPOINT_ENV_PATH || (existsSync(DEFAULT_ENV_PATH) ? DEFAULT_ENV_PATH : REPO_ENV_PATH);
36
+
37
+ // Load config from explicit/user-data path first, then fall back to repo root for dev workflow.
38
+ require("dotenv").config({ path: ENV_PATH });
39
+
40
+ // --- Extracted modules ---
41
+ const {
42
+ TOOL_DECLARATIONS,
43
+ getToolsForIntent,
44
+ clearIntentCache,
45
+ hasActiveIntent,
46
+ buildToolRoutes,
47
+ preValidate,
48
+ summarizeToolResult,
49
+ } = require("./src/tools");
50
+ const { getSystemPrompt, USER_HOME, DOWNLOADS, DOCUMENTS, DESKTOP, PICTURES, SKILLS_DIR } = require("./src/skills");
51
+ const llm = require("./src/llm");
52
+
53
+ // Silent logger for Baileys
54
+ const logger = pino({ level: "silent" });
55
+
56
+ // --- Config ---
57
+ const API_URL = process.env.PINPOINT_API_URL || "http://localhost:5123";
58
+ const PREFIX = "[pinpoint]";
59
+ const AUTH_DIR = process.env.PINPOINT_AUTH_DIR || pathModule.join(USER_DATA_DIR, "bot-auth");
60
+ const LOG_DIR = process.env.PINPOINT_LOG_DIR || pathModule.join(USER_DATA_DIR, "logs");
61
+ const QR_DIR = process.env.PINPOINT_QR_DIR || pathModule.join(USER_DATA_DIR, "qr");
62
+ const MAX_RESULTS = 5;
63
+ const MAX_FILES_TO_SEND = 3;
64
+ const MAX_IMAGE_SIZE = 16 * 1024 * 1024;
65
+ const MAX_DOC_SIZE = 100 * 1024 * 1024;
66
+ const TEXT_CHUNK_LIMIT = 4000;
67
+ const GEMINI_MODEL = process.env.GEMINI_MODEL || "gemini-3.1-flash-lite-preview";
68
+ // No tool-calling timeout — batch mode handles long operations in single calls (not token-burning loops)
69
+ const IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 60 minutes — auto-reset conversation
70
+ const MAX_HISTORY_MESSAGES = 50; // last 50 messages passed to Gemini (Claude Code sends ALL — we cap for token budget)
71
+ const MAX_INLINE_IMAGES = 5; // Max images sent as visual data per turn
72
+
73
+ const DEBOUNCE_MS = 1500; // Combine rapid messages within 1.5s
74
+
75
+ // Log to file so we can check what happened (tee stdout → file)
76
+ mkdirSync(LOG_DIR, { recursive: true });
77
+ mkdirSync(AUTH_DIR, { recursive: true });
78
+ mkdirSync(QR_DIR, { recursive: true });
79
+ const logFile = pathModule.join(LOG_DIR, "bot.log");
80
+ const logStream = require("fs").createWriteStream(logFile, { flags: "a" });
81
+ const origLog = console.log,
82
+ origWarn = console.warn,
83
+ origErr = console.error;
84
+ function _ts() {
85
+ return new Date().toISOString().slice(11, 19);
86
+ }
87
+
88
+ function qrFilePath() {
89
+ return pathModule.join(QR_DIR, "whatsapp-qr.txt");
90
+ }
91
+
92
+ function writeQrPayload(qr) {
93
+ try {
94
+ mkdirSync(QR_DIR, { recursive: true });
95
+ writeFileSync(qrFilePath(), qr, "utf-8");
96
+ console.log(`[Pinpoint] QR payload saved to ${qrFilePath()}`);
97
+ } catch (err) {
98
+ console.warn("[Pinpoint] Failed to write QR payload:", err.message);
99
+ }
100
+ }
101
+
102
+ function clearQrPayload() {
103
+ try {
104
+ const qrPath = qrFilePath();
105
+ if (existsSync(qrPath)) unlinkSync(qrPath);
106
+ } catch (_) {}
107
+ }
108
+
109
+ console.log = (...a) => {
110
+ const s = a.map(String).join(" ");
111
+ origLog(s);
112
+ logStream.write(`${_ts()} ${s}\n`);
113
+ };
114
+ console.warn = (...a) => {
115
+ const s = a.map(String).join(" ");
116
+ origWarn(s);
117
+ logStream.write(`${_ts()} WARN ${s}\n`);
118
+ };
119
+ console.error = (...a) => {
120
+ const s = a.map(String).join(" ");
121
+ origErr(s);
122
+ logStream.write(`${_ts()} ERR ${s}\n`);
123
+ };
124
+
125
+ // Processing lock: prevent concurrent Gemini calls per chat (causes context loss)
126
+ const activeRequests = new Map(); // chatJid → { msg, startTime, id }
127
+ const lastImage = new Map(); // chatJid → { mimeType, data (base64), path, ts } — for follow-up re-injection (2 min TTL)
128
+ let requestCounter = 0;
129
+ let currentSock = null; // Module-level sock reference for reminders (survives reconnects)
130
+
131
+ // Persistent memory: on by default, per-chat state
132
+ const memoryEnabled = new Map(); // chatJid → boolean (default from _default key or true)
133
+ const memoryContext = new Map(); // chatJid → string (loaded from API)
134
+ function isMemoryEnabled(chatJid) {
135
+ return memoryEnabled.get(chatJid) ?? memoryEnabled.get("_default") ?? true;
136
+ }
137
+ function getMemoryContext(chatJid) {
138
+ return memoryContext.get(chatJid) || memoryContext.get("_default") || "";
139
+ }
140
+
141
+ // Allowed users: phone numbers/LIDs that can use Pinpoint (managed via /allow, /revoke)
142
+ const allowedUsers = new Set();
143
+ const allowedSessions = new Map(); // chatJid → last activity timestamp (active session)
144
+
145
+ // Cost tracking: per-session token usage (OpenCode-inspired)
146
+ const sessionCosts = {}; // chatJid → { input, output, rounds, started }
147
+ const TOKEN_COST_INPUT = 0.25 / 1_000_000; // gemini-3.1-flash-lite-preview $/token (input)
148
+ const TOKEN_COST_OUTPUT = 1.5 / 1_000_000; // gemini-3.1-flash-lite-preview $/token (output, includes thinking)
149
+
150
+ // --- Action Ledger: structural truth enforcement (OpenClaw-inspired) ---
151
+ // Tracks every mutating tool call + real outcome. Injected into every LLM call.
152
+ // The LLM sees "## Actions Taken" with exact counts — cannot invent outcomes.
153
+ const actionLedger = {}; // chatJid → [{ tool, summary, outcome, ts }]
154
+ const MUTATING_TOOLS = new Set([
155
+ "batch_move",
156
+ "move_file",
157
+ "copy_file",
158
+ "delete_file",
159
+ "write_file",
160
+ "batch_rename",
161
+ "create_folder",
162
+ "generate_excel",
163
+ "merge_pdf",
164
+ "split_pdf",
165
+ "resize_image",
166
+ "convert_image",
167
+ "crop_image",
168
+ "compress_files",
169
+ "extract_archive",
170
+ "images_to_pdf",
171
+ "pdf_to_images",
172
+ "download_url",
173
+ "compress_pdf",
174
+ "add_page_numbers",
175
+ "pdf_to_word",
176
+ "organize_pdf",
177
+ "pdf_to_excel",
178
+ "cull_photos",
179
+ "group_photos",
180
+ "gmail_send",
181
+ "calendar_create",
182
+ "drive_upload",
183
+ ]);
184
+
185
+ function recordAction(chatJid, toolName, args, result) {
186
+ if (!actionLedger[chatJid]) actionLedger[chatJid] = [];
187
+ const entry = { tool: toolName, ts: Date.now() };
188
+
189
+ // Build a truthful one-line summary from the ACTUAL result
190
+ if (result?.error) {
191
+ entry.outcome = "FAILED";
192
+ entry.summary = `${toolName} → ERROR: ${String(result.error).slice(0, 100)}`;
193
+ } else if (toolName === "batch_move") {
194
+ const moved = result?.moved_count ?? 0;
195
+ const skipped = result?.skipped_count ?? 0;
196
+ const errors = result?.error_count ?? 0;
197
+ const action = result?.action || "moved";
198
+ const dest = args?.destination || "?";
199
+ entry.outcome = moved > 0 ? "OK" : "NOTHING_DONE";
200
+ entry.summary = `batch_move → ${moved} ${action}, ${skipped} skipped, ${errors} errors → ${dest}`;
201
+ } else if (toolName === "move_file" || toolName === "copy_file") {
202
+ const action = result?.action || toolName.replace("_file", "d");
203
+ const src = args?.source ? pathModule.basename(args.source) : "?";
204
+ const dest = args?.destination || "?";
205
+ entry.outcome = result?.success ? "OK" : "FAILED";
206
+ entry.summary = `${toolName} → ${action} ${src} → ${dest}`;
207
+ } else if (toolName === "delete_file") {
208
+ entry.outcome = result?.success ? "OK" : "FAILED";
209
+ entry.summary = `delete_file → ${result?.success ? "deleted" : "FAILED"} ${args?.path ? pathModule.basename(args.path) : "?"}`;
210
+ } else if (toolName === "write_file") {
211
+ entry.outcome = result?.success ? "OK" : "FAILED";
212
+ entry.summary = `write_file → ${result?.success ? "created" : "FAILED"} ${result?.path ? pathModule.basename(result.path) : args?.path ? pathModule.basename(args.path) : "?"}`;
213
+ } else if (toolName === "create_folder") {
214
+ entry.outcome = "OK";
215
+ entry.summary = `create_folder → ${result?.already_existed ? "already existed" : "created"} ${result?.path || args?.path || "?"}`;
216
+ } else if (toolName === "batch_rename") {
217
+ const renamed = result?.renamed_count ?? result?.renamed ?? 0;
218
+ entry.outcome = renamed > 0 ? "OK" : "NOTHING_DONE";
219
+ entry.summary = `batch_rename → ${renamed} renamed, ${result?.error_count ?? 0} errors`;
220
+ } else if (toolName === "generate_excel") {
221
+ entry.outcome = result?.success ? "OK" : "FAILED";
222
+ entry.summary = `generate_excel → ${result?.path ? pathModule.basename(result.path) : "?"}`;
223
+ } else if (toolName === "compress_files") {
224
+ entry.outcome = result?.success ? "OK" : "FAILED";
225
+ entry.summary = `compress_files → ${result?.path ? pathModule.basename(result.path) : "?"} (${result?.file_count ?? "?"} files)`;
226
+ } else if (toolName === "cull_photos") {
227
+ entry.outcome = result?.started ? "STARTED" : "FAILED";
228
+ entry.summary = `cull_photos → ${result?.started ? `started culling ${result.total_images} photos (keep ${result.keep_pct}%)` : "FAILED"}`;
229
+ } else if (toolName === "group_photos") {
230
+ entry.outcome = result?.started ? "STARTED" : "FAILED";
231
+ entry.summary = `group_photos → ${result?.started ? `started grouping ${result.total_images} photos into ${result.categories?.length ?? "?"} categories` : "FAILED"}`;
232
+ } else {
233
+ // Generic mutating tool
234
+ entry.outcome = result?.success !== false ? "OK" : "FAILED";
235
+ entry.summary = `${toolName} → ${result?.success !== false ? "done" : "FAILED"}`;
236
+ if (result?.path) entry.summary += ` → ${pathModule.basename(result.path)}`;
237
+ }
238
+
239
+ actionLedger[chatJid].push(entry);
240
+ // Cap at 50 entries per session (oldest dropped)
241
+ if (actionLedger[chatJid].length > 50) actionLedger[chatJid].shift();
242
+ }
243
+
244
+ function getActionLedgerText(chatJid) {
245
+ const entries = actionLedger[chatJid];
246
+ if (!entries || entries.length === 0) return "";
247
+ return (
248
+ "\n\n## Actions Taken This Session\nThese are the ACTUAL outcomes of every action you performed. Report ONLY these results.\n" +
249
+ entries.map((e) => `- ${e.summary}`).join("\n")
250
+ );
251
+ }
252
+
253
+ function isAllowedUser(jid) {
254
+ const number = jid?.split("@")[0];
255
+ return allowedUsers.has(number);
256
+ }
257
+
258
+ async function loadAllowedUsers() {
259
+ try {
260
+ const setting = await apiGet("/setting?key=allowed_users");
261
+ if (setting.value) {
262
+ const numbers = setting.value.split(",").filter((n) => n.trim());
263
+ numbers.forEach((n) => allowedUsers.add(n.trim()));
264
+ }
265
+ } catch (_) {}
266
+ }
267
+
268
+ async function saveAllowedUsers() {
269
+ try {
270
+ await apiPost(`/setting?key=allowed_users&value=${[...allowedUsers].join(",")}`, {});
271
+ } catch (_) {}
272
+ }
273
+
274
+ const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".bmp", ".webp"]);
275
+
276
+ // Mime type → extension (for received files without filename)
277
+ const MIME_TO_EXT = {
278
+ "image/jpeg": ".jpg",
279
+ "image/png": ".png",
280
+ "image/webp": ".webp",
281
+ "image/bmp": ".bmp",
282
+ "image/gif": ".gif",
283
+ "image/tiff": ".tiff",
284
+ "video/mp4": ".mp4",
285
+ "video/mkv": ".mkv",
286
+ "video/avi": ".avi",
287
+ "video/quicktime": ".mov",
288
+ "video/webm": ".webm",
289
+ "audio/mpeg": ".mp3",
290
+ "audio/ogg": ".ogg",
291
+ "audio/wav": ".wav",
292
+ "audio/mp4": ".m4a",
293
+ "audio/aac": ".aac",
294
+ "application/pdf": ".pdf",
295
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
296
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
297
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
298
+ "text/plain": ".txt",
299
+ "text/csv": ".csv",
300
+ };
301
+
302
+ // --- System paths (from skills module) ---
303
+ const DEFAULT_SAVE_FOLDER = pathModule.join(DOWNLOADS, "Pinpoint");
304
+
305
+ console.log(`[Pinpoint] User home: ${USER_HOME}`);
306
+
307
+ // --- LLM setup (Gemini default, Ollama optional) ---
308
+ const OLLAMA_MODEL = process.env.OLLAMA_MODEL || ""; // e.g. "qwen3.5:9b" — set to use local LLM instead of Gemini
309
+ const OLLAMA_URL = process.env.OLLAMA_URL || "http://localhost:11434";
310
+ const OLLAMA_THINK = process.env.OLLAMA_THINK === "true"; // Enable thinking for Ollama (slower but smarter tool picks)
311
+ const USE_OLLAMA = !!OLLAMA_MODEL;
312
+
313
+ const ai = USE_OLLAMA ? null : new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
314
+ const LLM_TAG = USE_OLLAMA ? "Ollama" : "Gemini";
315
+ if (USE_OLLAMA) console.log(`[Pinpoint] Using Ollama: ${OLLAMA_MODEL} at ${OLLAMA_URL}`);
316
+ else console.log(`[Pinpoint] Using Gemini: ${GEMINI_MODEL}`);
317
+
318
+ // Initialize LLM module with runtime config
319
+ llm.init({
320
+ ai,
321
+ OLLAMA_MODEL,
322
+ OLLAMA_URL,
323
+ OLLAMA_THINK,
324
+ USE_OLLAMA,
325
+ GEMINI_MODEL,
326
+ sessionCosts,
327
+ TOKEN_COST_INPUT,
328
+ TOKEN_COST_OUTPUT,
329
+ });
330
+
331
+ // Build TOOL_ROUTES from extracted tools module
332
+ const TOOL_ROUTES = buildToolRoutes(MAX_RESULTS);
333
+
334
+ // (Tool declarations moved to src/tools.js — TOOL_DECLARATIONS array)
335
+ // --- Reminders system (persistent via API) ---
336
+ const reminders = []; // { id, chatJid, message, triggerAt (ms), repeat?, createdAt }
337
+
338
+ function parseReminderTime(timeStr) {
339
+ const now = new Date();
340
+ const lower = timeStr.toLowerCase().trim();
341
+
342
+ // "in X hours/minutes"
343
+ const inMatch = lower.match(/^in\s+(\d+)\s*(hours?|hrs?|minutes?|mins?|seconds?|secs?)$/);
344
+ if (inMatch) {
345
+ const n = parseInt(inMatch[1]);
346
+ const unit = inMatch[2];
347
+ if (unit.startsWith("h")) return new Date(now.getTime() + n * 3600000);
348
+ if (unit.startsWith("m")) return new Date(now.getTime() + n * 60000);
349
+ if (unit.startsWith("s")) return new Date(now.getTime() + n * 1000);
350
+ }
351
+
352
+ // ISO format or full date string
353
+ const parsed = new Date(timeStr);
354
+ if (!isNaN(parsed.getTime())) return parsed;
355
+
356
+ // "5pm", "17:00", "5:30pm" — treat as today
357
+ const timeMatch = lower.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/);
358
+ if (timeMatch) {
359
+ let h = parseInt(timeMatch[1]);
360
+ const m = parseInt(timeMatch[2] || "0");
361
+ const ampm = timeMatch[3];
362
+ if (ampm === "pm" && h < 12) h += 12;
363
+ if (ampm === "am" && h === 12) h = 0;
364
+ const target = new Date(now);
365
+ target.setHours(h, m, 0, 0);
366
+ if (target <= now) target.setDate(target.getDate() + 1);
367
+ return target;
368
+ }
369
+
370
+ // "tomorrow 9am"
371
+ const tmrMatch = lower.match(/^tomorrow\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/);
372
+ if (tmrMatch) {
373
+ let h = parseInt(tmrMatch[1]);
374
+ const m = parseInt(tmrMatch[2] || "0");
375
+ const ampm = tmrMatch[3];
376
+ if (ampm === "pm" && h < 12) h += 12;
377
+ if (ampm === "am" && h === 12) h = 0;
378
+ const target = new Date(now);
379
+ target.setDate(target.getDate() + 1);
380
+ target.setHours(h, m, 0, 0);
381
+ return target;
382
+ }
383
+
384
+ return null;
385
+ }
386
+
387
+ function getNextOccurrence(triggerAt, repeat) {
388
+ const d = new Date(triggerAt);
389
+ const now = Date.now();
390
+ switch (repeat) {
391
+ case "daily":
392
+ while (d.getTime() <= now) d.setDate(d.getDate() + 1);
393
+ break;
394
+ case "weekly":
395
+ while (d.getTime() <= now) d.setDate(d.getDate() + 7);
396
+ break;
397
+ case "monthly":
398
+ while (d.getTime() <= now) d.setMonth(d.getMonth() + 1);
399
+ break;
400
+ case "weekdays":
401
+ do {
402
+ d.setDate(d.getDate() + 1);
403
+ } while (d.getDay() === 0 || d.getDay() === 6 || d.getTime() <= now);
404
+ break;
405
+ default:
406
+ return null;
407
+ }
408
+ return d;
409
+ }
410
+
411
+ async function loadReminders() {
412
+ try {
413
+ const res = await apiGet("/reminders");
414
+ reminders.length = 0; // clear in-memory
415
+ for (const r of res.reminders || []) {
416
+ reminders.push({
417
+ id: r.id,
418
+ chatJid: r.chat_jid,
419
+ message: r.message,
420
+ triggerAt: new Date(r.trigger_at).getTime(),
421
+ repeat: r.repeat || null,
422
+ createdAt: new Date(r.created_at).getTime(),
423
+ });
424
+ }
425
+ if (reminders.length > 0) console.log(`[Reminder] Loaded ${reminders.length} reminders from DB`);
426
+ } catch (e) {
427
+ console.error(`[Reminder] Failed to load: ${e.message}`);
428
+ }
429
+ }
430
+
431
+ // --- Reconnect policy (matches OpenClaw) ---
432
+ const RECONNECT = { initialMs: 2000, maxMs: 30000, factor: 1.8, jitter: 0.25, maxAttempts: 12 };
433
+ let reconnectAttempt = 0;
434
+
435
+ // --- TempStore: large tool results stored server-side, Gemini gets ref ID ---
436
+ const tempStore = new Map();
437
+ let refCounter = 0;
438
+ const REF_EXPIRE_MS = 30 * 60 * 1000; // 30 min safety net
439
+ const REF_THRESHOLD = 2000; // Only store if JSON > 2000 chars
440
+
441
+ // Tools whose results should be stored as refs when large
442
+ const REF_TOOLS = new Set([
443
+ "list_files",
444
+ "search_images_visual",
445
+ "find_person",
446
+ "find_person_by_face",
447
+ "detect_faces",
448
+ "count_faces",
449
+ "ocr",
450
+ "find_duplicates",
451
+ "pdf_to_images",
452
+ "search_documents",
453
+ ]);
454
+
455
+ // Preview limits per tool (how many items to show Gemini)
456
+ const REF_PREVIEW = {
457
+ list_files: 20,
458
+ search_images_visual: 10,
459
+ find_person: 5,
460
+ find_person_by_face: 5,
461
+ detect_faces: 10,
462
+ count_faces: 10,
463
+ ocr: 3,
464
+ find_duplicates: 5,
465
+ pdf_to_images: 10,
466
+ search_documents: 5,
467
+ };
468
+
469
+ function storeRef(data) {
470
+ const id = ++refCounter;
471
+ const key = `@ref:${id}`;
472
+ tempStore.set(key, { data, createdAt: Date.now() });
473
+ return key;
474
+ }
475
+
476
+ function resolveRef(key) {
477
+ const entry = tempStore.get(key);
478
+ if (!entry) return null;
479
+ // Don't delete — refs may be used multiple times (e.g. retry, follow-up messages)
480
+ // Expiry timer handles cleanup
481
+ return entry.data;
482
+ }
483
+
484
+ // Clean expired refs (called periodically)
485
+ function cleanExpiredRefs() {
486
+ const now = Date.now();
487
+ for (const [key, entry] of tempStore) {
488
+ if (now - entry.createdAt > REF_EXPIRE_MS) tempStore.delete(key);
489
+ }
490
+ }
491
+ setInterval(cleanExpiredRefs, 5 * 60 * 1000); // Check every 5 min
492
+
493
+ // GC for stale per-chat state (prevents unbounded growth over days/weeks)
494
+ setInterval(() => {
495
+ const now = Date.now();
496
+ const staleMs = 2 * 60 * 60 * 1000; // 2 hours
497
+ for (const [jid, ts] of allowedSessions) {
498
+ if (now - ts > staleMs) {
499
+ // Skip if a request is actively running — don't yank state mid-processing
500
+ if (activeRequests.has(jid)) continue;
501
+ allowedSessions.delete(jid);
502
+ lastImage.delete(jid);
503
+ delete sessionCosts[jid];
504
+ delete actionLedger[jid];
505
+ clearIntentCache(jid);
506
+ memoryEnabled.delete(jid);
507
+ memoryContext.delete(jid);
508
+ }
509
+ }
510
+ }, 15 * 60 * 1000); // Check every 15 min
511
+
512
+ // Create a preview summary for Gemini (array of items → first N + count + ref)
513
+ function makeRefPreview(toolName, result, refKey) {
514
+ const limit = REF_PREVIEW[toolName] || 10;
515
+
516
+ // Handle array results (list_files, find_person, pdf_to_images, etc.)
517
+ if (Array.isArray(result)) {
518
+ const total = result.length;
519
+ const preview = result.slice(0, limit);
520
+ return {
521
+ _ref: refKey,
522
+ total,
523
+ showing: preview.length,
524
+ preview,
525
+ note: `${total} items stored. Use ${refKey} in subsequent tool calls to reference all items.`,
526
+ };
527
+ }
528
+
529
+ // Handle multi-query search_images_visual: { "query1": { results: [...] }, "query2": { results: [...] } }
530
+ // Show category summary + grouping file path so Gemini can read it and batch_move
531
+ if (toolName === "search_images_visual") {
532
+ const queryKeys = Object.keys(result).filter((k) => !k.startsWith("_") && result[k]?.results);
533
+ if (queryKeys.length > 1) {
534
+ const summary = {};
535
+ let totalFiles = 0;
536
+ for (const q of queryKeys) {
537
+ const files = result[q].results || [];
538
+ summary[q] = { count: files.length, top3: files.slice(0, 3).map((r) => r.filename || r.path) };
539
+ totalFiles += files.length;
540
+ }
541
+ const groupFile = result._grouping_file || null;
542
+ return {
543
+ _ref: refKey,
544
+ categories: summary,
545
+ total_files: totalFiles,
546
+ grouping_file: groupFile,
547
+ note: groupFile
548
+ ? `Grouped ${totalFiles} files into ${queryKeys.length} categories. Full mapping saved at ${groupFile}. Read that file to get all paths, then batch_move per category.`
549
+ : `Grouped ${totalFiles} files into ${queryKeys.length} categories.`,
550
+ };
551
+ }
552
+ }
553
+
554
+ // Handle object with results/matches/files array
555
+ for (const arrayKey of ["entries", "results", "matches", "files", "images", "groups", "pages", "faces"]) {
556
+ if (result[arrayKey] && Array.isArray(result[arrayKey]) && result[arrayKey].length > 0) {
557
+ const arr = result[arrayKey];
558
+ const total = arr.length;
559
+ const preview = arr.slice(0, limit);
560
+ const rest = { ...result, [arrayKey]: preview };
561
+ return {
562
+ _ref: refKey,
563
+ total_items: total,
564
+ showing: preview.length,
565
+ ...rest,
566
+ note: `${total} ${arrayKey} stored. Use ${refKey} to reference all.`,
567
+ };
568
+ }
569
+ }
570
+
571
+ // Fallback: just mark it as ref'd
572
+ return {
573
+ _ref: refKey,
574
+ note: `Large result stored. Use ${refKey} to reference it.`,
575
+ summary: JSON.stringify(result).slice(0, 300) + "...",
576
+ };
577
+ }
578
+
579
+ // Resolve a single @ref:N value to its stored data, extracting paths for sources/paths keys
580
+ function _resolveOneRef(refKey, argName) {
581
+ const data = resolveRef(refKey);
582
+ if (!data) {
583
+ console.warn(`[TempStore] ${refKey} not found (expired)`);
584
+ return null;
585
+ }
586
+ // If the stored data is already an array, use it directly
587
+ if (Array.isArray(data)) {
588
+ console.log(`[TempStore] Resolved ${refKey} → ${data.length} items (array)`);
589
+ return data;
590
+ }
591
+ // If it's an object with a results/matches/files array, extract paths
592
+ for (const arrayKey of ["entries", "results", "matches", "files", "images", "groups", "pages"]) {
593
+ if (data[arrayKey] && Array.isArray(data[arrayKey])) {
594
+ let items;
595
+ if (argName === "sources" || argName === "paths") {
596
+ items = data[arrayKey].map((item) => item.path || item.file || item);
597
+ } else {
598
+ items = data[arrayKey];
599
+ }
600
+ console.log(`[TempStore] Resolved ${refKey} → ${items.length} items (from .${arrayKey})`);
601
+ return items;
602
+ }
603
+ }
604
+ // No array found, return whole object
605
+ console.log(`[TempStore] Resolved ${refKey} → object`);
606
+ return data;
607
+ }
608
+
609
+ // Resolve @ref:N in tool args before execution
610
+ function resolveRefsInArgs(args) {
611
+ if (!args) return args;
612
+ const resolved = { ...args };
613
+ for (const [key, value] of Object.entries(resolved)) {
614
+ // Case 1: direct string ref — sources: "@ref:1"
615
+ if (typeof value === "string" && value.startsWith("@ref:")) {
616
+ const result = _resolveOneRef(value, key);
617
+ if (result) resolved[key] = result;
618
+ }
619
+ // Case 2: array containing refs — sources: ["@ref:1"] or sources: ["@ref:1", "@ref:2"]
620
+ // This is the common case: Gemini puts refs inside arrays for batch_move sources
621
+ else if (Array.isArray(value)) {
622
+ const expanded = [];
623
+ for (const item of value) {
624
+ if (typeof item === "string" && item.startsWith("@ref:")) {
625
+ const result = _resolveOneRef(item, key);
626
+ if (result) {
627
+ // Flatten: if resolved to array, spread it into the parent array
628
+ if (Array.isArray(result)) expanded.push(...result);
629
+ else expanded.push(result);
630
+ }
631
+ } else {
632
+ expanded.push(item);
633
+ }
634
+ }
635
+ if (expanded.length !== value.length || expanded.some((v, i) => v !== value[i])) {
636
+ resolved[key] = expanded;
637
+ }
638
+ }
639
+ }
640
+ return resolved;
641
+ }
642
+
643
+ // --- Echo prevention (3 layers) ---
644
+ const processedIds = new Set();
645
+ const MAX_PROCESSED_IDS = 5000;
646
+ const sentTexts = new Set();
647
+ const MAX_SENT_TEXTS = 100;
648
+
649
+ function hashText(text) {
650
+ return createHash("sha256").update(text).digest("hex").slice(0, 16);
651
+ }
652
+ function rememberSent(text) {
653
+ const h = hashText(text);
654
+ sentTexts.add(h);
655
+ if (sentTexts.size > MAX_SENT_TEXTS) sentTexts.delete(sentTexts.values().next().value);
656
+ }
657
+ function wasSentByUs(text) {
658
+ return sentTexts.has(hashText(text));
659
+ }
660
+ function markProcessed(id) {
661
+ processedIds.add(id);
662
+ if (processedIds.size > MAX_PROCESSED_IDS) processedIds.delete(processedIds.values().next().value);
663
+ }
664
+
665
+ // --- Markdown → WhatsApp formatting ---
666
+ // Gemini uses markdown (**bold**, ```code```). WhatsApp uses *bold*, ```code```.
667
+ function markdownToWhatsApp(text) {
668
+ // Protect code blocks first (don't modify inside them)
669
+ const codeBlocks = [];
670
+ text = text.replace(/```[\s\S]*?```/g, (m) => {
671
+ codeBlocks.push(m);
672
+ return `__CODE_BLOCK_${codeBlocks.length - 1}__`;
673
+ });
674
+ // Protect inline code
675
+ const inlineCode = [];
676
+ text = text.replace(/`[^`]+`/g, (m) => {
677
+ inlineCode.push(m);
678
+ return `__INLINE_CODE_${inlineCode.length - 1}__`;
679
+ });
680
+ // **bold** → *bold* (WhatsApp bold)
681
+ text = text.replace(/\*\*(.+?)\*\*/g, "*$1*");
682
+ // ### Header → *Header* (WhatsApp has no headers, bold them)
683
+ text = text.replace(/^#{1,6}\s+(.+)$/gm, "*$1*");
684
+ // [text](url) → text (url) — WhatsApp doesn't support links
685
+ text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)");
686
+ // Restore code blocks and inline code
687
+ text = text.replace(/__INLINE_CODE_(\d+)__/g, (_, i) => inlineCode[i]);
688
+ text = text.replace(/__CODE_BLOCK_(\d+)__/g, (_, i) => codeBlocks[i]);
689
+ return text;
690
+ }
691
+
692
+ // --- Message debouncer (combine rapid messages per chat) ---
693
+ const pendingMessages = new Map(); // chatJid → { texts: [], timer, resolvers[] }
694
+
695
+ function debounceMessage(chatJid, text, sock) {
696
+ return new Promise((resolve) => {
697
+ const existing = pendingMessages.get(chatJid);
698
+ if (existing) {
699
+ existing.texts.push(text);
700
+ existing.resolvers.push(resolve);
701
+ clearTimeout(existing.timer);
702
+ } else {
703
+ pendingMessages.set(chatJid, { texts: [text], sock, resolvers: [resolve] });
704
+ }
705
+ const entry = pendingMessages.get(chatJid);
706
+ entry.timer = setTimeout(() => {
707
+ const combined = entry.texts.join("\n");
708
+ pendingMessages.delete(chatJid);
709
+ // First resolver gets the combined text (processes it), rest get null (skip)
710
+ entry.resolvers[0](combined);
711
+ for (let i = 1; i < entry.resolvers.length; i++) entry.resolvers[i](null);
712
+ }, DEBOUNCE_MS);
713
+ });
714
+ }
715
+
716
+ // --- API helpers ---
717
+
718
+ const API_SECRET = process.env.API_SECRET || "";
719
+ const _apiHeaders = API_SECRET ? { "X-API-Secret": API_SECRET } : {};
720
+
721
+ function fetchWithTimeout(url, opts = {}, timeoutMs = 120000) {
722
+ const controller = new AbortController();
723
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
724
+ return fetch(url, { ...opts, signal: controller.signal }).finally(() => clearTimeout(timer));
725
+ }
726
+
727
+ async function apiGet(path) {
728
+ const resp = await fetchWithTimeout(`${API_URL}${path}`, { headers: _apiHeaders });
729
+ if (!resp.ok) throw new Error(`API ${resp.status}: ${await resp.text()}`);
730
+ return resp.json();
731
+ }
732
+
733
+ async function apiPost(path, body) {
734
+ const resp = await fetchWithTimeout(`${API_URL}${path}`, {
735
+ method: "POST",
736
+ headers: { "Content-Type": "application/json", ..._apiHeaders },
737
+ body: JSON.stringify(body),
738
+ });
739
+ if (!resp.ok) throw new Error(`API ${resp.status}: ${await resp.text()}`);
740
+ return resp.json();
741
+ }
742
+
743
+ async function apiDelete(path) {
744
+ const resp = await fetchWithTimeout(`${API_URL}${path}`, { method: "DELETE", headers: _apiHeaders });
745
+ if (!resp.ok) throw new Error(`API ${resp.status}: ${await resp.text()}`);
746
+ return resp.json();
747
+ }
748
+
749
+ async function apiPut(path, body) {
750
+ const resp = await fetchWithTimeout(`${API_URL}${path}`, {
751
+ method: "PUT",
752
+ headers: { "Content-Type": "application/json", ..._apiHeaders },
753
+ body: JSON.stringify(body),
754
+ });
755
+ if (!resp.ok) throw new Error(`API ${resp.status}: ${await resp.text()}`);
756
+ return resp.json();
757
+ }
758
+
759
+ async function apiPing() {
760
+ try {
761
+ return (await fetch(`${API_URL}/ping`)).ok;
762
+ } catch {
763
+ return false;
764
+ }
765
+ }
766
+
767
+ // --- Execute a tool call from Gemini ---
768
+ // (preValidate + TOOL_ROUTES moved to src/tools.js)
769
+ // TOOL_ROUTES was built above via buildToolRoutes(MAX_RESULTS)
770
+
771
+ // Legacy enc used in executeTool switch cases
772
+ const enc = encodeURIComponent;
773
+
774
+ async function executeTool(functionCall, sock, chatJid, sentFiles) {
775
+ const { name } = functionCall;
776
+ const args = resolveRefsInArgs(functionCall.args);
777
+ console.log(`[${LLM_TAG}] Tool call: ${name}(${JSON.stringify(args).slice(0, 200)})`);
778
+
779
+ // Pre-validate args before hitting the API
780
+ const validationError = preValidate(name, args);
781
+ if (validationError) {
782
+ console.log(`[Validate] ${name}: ${validationError}`);
783
+ return { error: validationError };
784
+ }
785
+
786
+ try {
787
+ // --- Routing table dispatch (covers ~45 tools) ---
788
+ const route = TOOL_ROUTES[name];
789
+ if (route) {
790
+ const path = typeof route.p === "function" ? route.p(args) : route.p;
791
+ if (route.m === "GET") return await apiGet(path);
792
+ return await apiPost(path, route.b ? route.b(args) : args);
793
+ }
794
+
795
+ // --- Custom handlers (tools with side effects or complex logic) ---
796
+ switch (name) {
797
+ case "send_file": {
798
+ const filePath = args.path;
799
+ // Dedup: don't send the same file twice in one Gemini run
800
+ if (sentFiles.has(filePath)) {
801
+ return { success: true, file: pathModule.basename(filePath), _hint: "Already sent this file." };
802
+ }
803
+ const caption = args.caption || `${PREFIX} ${pathModule.basename(filePath)}`;
804
+ const sent = await sendFile(sock, chatJid, filePath, `${PREFIX} ${caption}`);
805
+ if (sent) {
806
+ sentFiles.add(filePath);
807
+ console.log(`[Pinpoint] Sent: ${pathModule.basename(filePath)}`);
808
+ return { success: true, file: pathModule.basename(filePath) };
809
+ }
810
+ return { error: "File not found or too large to send" };
811
+ }
812
+ case "search_images_visual": {
813
+ if (!args.folder) return { error: "folder is required. Pass the absolute path to the image folder." };
814
+ const queries = args.queries || (args.query ? [args.query] : []);
815
+ if (queries.length <= 1) {
816
+ return await apiPost("/search-images-visual", {
817
+ folder: args.folder,
818
+ query: queries[0] || "",
819
+ limit: args.limit || 10,
820
+ });
821
+ }
822
+ // Multi-query = grouping mode: classify ALL images, no limit
823
+ const groupLimit = 9999;
824
+ const firstResult = await apiPost("/search-images-visual", {
825
+ folder: args.folder,
826
+ query: queries[0],
827
+ limit: groupLimit,
828
+ });
829
+ if (firstResult.status === "embedding") return firstResult;
830
+ const restPromises = queries
831
+ .slice(1)
832
+ .map((q) => apiPost("/search-images-visual", { folder: args.folder, query: q, limit: groupLimit }));
833
+ const restSettled = await Promise.allSettled(restPromises);
834
+ const allResults = { [queries[0]]: firstResult };
835
+ queries.slice(1).forEach((q, i) => {
836
+ allResults[q] = restSettled[i].status === "fulfilled" ? restSettled[i].value : { results: [] };
837
+ });
838
+
839
+ // Deduplicate: each image → best matching category only
840
+ const bestCategory = {};
841
+ for (const [q, r] of Object.entries(allResults)) {
842
+ if (!r?.results) continue;
843
+ for (const item of r.results) {
844
+ const p = item.path || item.filename;
845
+ const score = item.match_pct || 0;
846
+ if (!bestCategory[p] || score > bestCategory[p].score) {
847
+ bestCategory[p] = { query: q, score };
848
+ }
849
+ }
850
+ }
851
+ const catMap = {};
852
+ for (const [p, { query }] of Object.entries(bestCategory)) {
853
+ if (!catMap[query]) catMap[query] = [];
854
+ catMap[query].push(p);
855
+ }
856
+ const totalMapped = Object.values(catMap).reduce((sum, arr) => sum + arr.length, 0);
857
+
858
+ if (totalMapped > 0) {
859
+ const tmpDir = pathModule.join(os.tmpdir(), "pinpoint");
860
+ if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true });
861
+ const mapFile = pathModule.join(tmpDir, `visual_group_${Date.now()}.json`);
862
+ writeFileSync(mapFile, JSON.stringify(catMap, null, 2));
863
+
864
+ const summary = {};
865
+ for (const [q, files] of Object.entries(catMap)) {
866
+ const refKey = storeRef(files);
867
+ summary[q] = {
868
+ count: files.length,
869
+ sources_ref: refKey,
870
+ top3: files.slice(0, 3).map((f) => pathModule.basename(f)),
871
+ };
872
+ }
873
+ const catList = Object.entries(summary)
874
+ .map(([q, s]) => `${q}: ${s.count} files (${s.sources_ref})`)
875
+ .join(", ");
876
+ console.log(
877
+ `[Visual] Classified ${totalMapped} images into ${Object.keys(catMap).length} categories → ${mapFile}`,
878
+ );
879
+ return {
880
+ total_classified: totalMapped,
881
+ categories: summary,
882
+ _grouping_file: mapFile,
883
+ _hint: `Classified ALL ${totalMapped} images into ${Object.keys(catMap).length} categories: ${catList}. To move: call batch_move({ sources: "<sources_ref>", destination: "<folder>" }) for each category. The sources_ref values are ready to use.`,
884
+ };
885
+ }
886
+ return allResults;
887
+ }
888
+ case "web_search": {
889
+ if (args.url) {
890
+ // Read a specific URL
891
+ return await apiGet(`/web-read?url=${enc(args.url)}${args.start ? `&start=${args.start}` : ""}`);
892
+ }
893
+ // Search the web via LangSearch/Jina
894
+ return await apiPost("/web-search", { query: args.query, count: args.count || 10, freshness: args.freshness || "noLimit" });
895
+ }
896
+ case "memory_save": {
897
+ if (!(isMemoryEnabled(chatJid))) return { error: "Memory is disabled. User can enable it with /memory on." };
898
+ const res = await apiPost("/memory", { fact: args.fact, category: args.category || "general" });
899
+ try {
900
+ const ctx = await apiGet("/memory/context");
901
+ memoryContext.set(chatJid, ctx.text || "");
902
+ } catch (_) {}
903
+ return res;
904
+ }
905
+ case "memory_search": {
906
+ if (!(isMemoryEnabled(chatJid))) return { error: "Memory is disabled. User can enable it with /memory on." };
907
+ return await apiGet(`/memory/search?q=${enc(args.query)}&limit=10`);
908
+ }
909
+ case "memory_delete": {
910
+ if (!(isMemoryEnabled(chatJid))) return { error: "Memory is disabled. User can enable it with /memory on." };
911
+ const res = await apiDelete(`/memory/${args.id}`);
912
+ try {
913
+ const ctx = await apiGet("/memory/context");
914
+ memoryContext.set(chatJid, ctx.text || "");
915
+ } catch (_) {}
916
+ return res;
917
+ }
918
+ case "memory_forget": {
919
+ if (!(isMemoryEnabled(chatJid))) return { error: "Memory is disabled. User can enable it with /memory on." };
920
+ const res = await apiPost("/memory/forget", { description: args.description });
921
+ if (res.success) {
922
+ try {
923
+ const ctx = await apiGet("/memory/context");
924
+ memoryContext.set(chatJid, ctx.text || "");
925
+ } catch (_) {}
926
+ }
927
+ return res;
928
+ }
929
+ case "set_reminder": {
930
+ const triggerAt = parseReminderTime(args.time);
931
+ if (!triggerAt)
932
+ return {
933
+ error: `Could not parse time: "${args.time}". Use format like "5pm", "in 2 hours", or "2026-02-27T17:00:00".`,
934
+ };
935
+ if (triggerAt.getTime() <= Date.now()) return { error: "That time is in the past." };
936
+ const repeat = args.repeat || null;
937
+ if (repeat && !["daily", "weekly", "monthly", "weekdays"].includes(repeat)) {
938
+ return { error: `Invalid repeat: "${repeat}". Use: daily, weekly, monthly, or weekdays.` };
939
+ }
940
+ const tz = USER_TZ;
941
+ const saved = await apiPost("/reminders", {
942
+ chat_jid: chatJid,
943
+ message: args.message,
944
+ trigger_at: triggerAt.toISOString(),
945
+ repeat,
946
+ });
947
+ reminders.push({
948
+ id: saved.id,
949
+ chatJid,
950
+ message: args.message,
951
+ triggerAt: triggerAt.getTime(),
952
+ repeat,
953
+ createdAt: Date.now(),
954
+ });
955
+ const result = {
956
+ success: true,
957
+ id: saved.id,
958
+ message: args.message,
959
+ trigger_at: triggerAt.toLocaleString("en-IN", {
960
+ timeZone: tz,
961
+ hour: "2-digit",
962
+ minute: "2-digit",
963
+ hour12: true,
964
+ day: "numeric",
965
+ month: "short",
966
+ }),
967
+ };
968
+ if (repeat) result.repeat = repeat;
969
+ return result;
970
+ }
971
+ case "list_reminders": {
972
+ const tz = USER_TZ;
973
+ const pending = reminders.filter((r) => r.triggerAt > Date.now());
974
+ if (pending.length === 0) return { count: 0, reminders: [], note: "No pending reminders." };
975
+ return {
976
+ count: pending.length,
977
+ reminders: pending.map((r) => ({
978
+ id: r.id,
979
+ message: r.message,
980
+ repeat: r.repeat || null,
981
+ trigger_at: new Date(r.triggerAt).toLocaleString("en-IN", {
982
+ timeZone: tz,
983
+ hour: "2-digit",
984
+ minute: "2-digit",
985
+ hour12: true,
986
+ day: "numeric",
987
+ month: "short",
988
+ }),
989
+ })),
990
+ };
991
+ }
992
+ case "cancel_reminder": {
993
+ const idx = reminders.findIndex((r) => r.id === args.id);
994
+ if (idx === -1) return { error: `Reminder #${args.id} not found.` };
995
+ const removed = reminders.splice(idx, 1)[0];
996
+ try {
997
+ await apiDelete(`/reminders/${removed.id}`);
998
+ } catch (_) {}
999
+ return { success: true, cancelled: removed.message };
1000
+ }
1001
+ default:
1002
+ return { error: `Unknown tool: ${name}` };
1003
+ }
1004
+ } catch (err) {
1005
+ return { error: err.message };
1006
+ }
1007
+ }
1008
+
1009
+ // --- Conversation memory helpers ---
1010
+
1011
+ async function loadHistory(sessionId) {
1012
+ try {
1013
+ const data = await apiGet(
1014
+ `/conversation/history?session_id=${encodeURIComponent(sessionId)}&limit=${MAX_HISTORY_MESSAGES}`,
1015
+ );
1016
+ return data;
1017
+ } catch {
1018
+ return { messages: [], updated_at: null, message_count: 0 };
1019
+ }
1020
+ }
1021
+
1022
+ async function saveMessage(sessionId, role, content) {
1023
+ try {
1024
+ await apiPost("/conversation", { session_id: sessionId, role, content });
1025
+ } catch (err) {
1026
+ console.error("[Memory] Save failed:", err.message);
1027
+ }
1028
+ }
1029
+
1030
+ const TEMP_MEDIA_DIR = pathModule.join(require("os").tmpdir(), "pinpoint_tmp");
1031
+
1032
+ function cleanupTempMedia() {
1033
+ try {
1034
+ if (!existsSync(TEMP_MEDIA_DIR)) return;
1035
+ const files = readdirSync(TEMP_MEDIA_DIR);
1036
+ for (const f of files) {
1037
+ try {
1038
+ unlinkSync(pathModule.join(TEMP_MEDIA_DIR, f));
1039
+ } catch (_) {}
1040
+ }
1041
+ if (files.length > 0) console.log(`[Pinpoint] Cleaned up ${files.length} temp media file(s)`);
1042
+ } catch (_) {}
1043
+ }
1044
+
1045
+ async function resetSession(sessionId) {
1046
+ try {
1047
+ const data = await apiPost("/conversation/reset", { session_id: sessionId });
1048
+ cleanupTempMedia();
1049
+ lastImage.delete(sessionId);
1050
+ activeRequests.delete(sessionId);
1051
+ delete sessionCosts[sessionId];
1052
+ delete actionLedger[sessionId];
1053
+ clearIntentCache(sessionId);
1054
+ memoryEnabled.delete(sessionId);
1055
+ memoryContext.delete(sessionId);
1056
+ return data.deleted_count || 0;
1057
+ } catch {
1058
+ return 0;
1059
+ }
1060
+ }
1061
+
1062
+ function isSessionIdle(updatedAt) {
1063
+ if (!updatedAt) return false;
1064
+ const lastActivity = new Date(updatedAt + "Z").getTime(); // ISO 8601 UTC
1065
+ return Date.now() - lastActivity > IDLE_TIMEOUT_MS;
1066
+ }
1067
+
1068
+ // --- Context compaction: summarize old messages instead of dropping them ---
1069
+ const COMPACT_THRESHOLD = 40; // Compact when total DB messages exceed this
1070
+ const COMPACT_KEEP = 15; // Keep this many recent messages after compacting
1071
+ const CONTENTS_COMPACT_THRESHOLD = 24; // Compact in-memory contents when entries exceed this (≈12 tool rounds, matches MAX_ROUNDS)
1072
+
1073
+ async function compactHistory(sessionId) {
1074
+ try {
1075
+ // Load ALL messages (up to 100)
1076
+ const data = await apiGet(`/conversation/history?session_id=${encodeURIComponent(sessionId)}&limit=100`);
1077
+ const msgs = data.messages || [];
1078
+ if (msgs.length < COMPACT_THRESHOLD) return; // Not enough to compact
1079
+
1080
+ // Split: old messages to summarize, recent to keep
1081
+ const toSummarize = msgs.slice(0, msgs.length - COMPACT_KEEP);
1082
+ const toKeep = msgs.slice(msgs.length - COMPACT_KEEP);
1083
+
1084
+ // Build a structured summary via Gemini (adapted from Claude Code compaction prompt)
1085
+ let summaryInput = `Summarize this conversation into a structured snapshot that preserves all context needed to continue the task.
1086
+
1087
+ Format:
1088
+ USER REQUEST: What the user explicitly asked for (1-2 lines, include exact quotes if important)
1089
+ KEY FACTS: File paths, search results, data discovered (bullet points)
1090
+ ACTIONS TAKEN: What tools were called and what they did (bullet points)
1091
+ CURRENT TASK: What is being worked on RIGHT NOW (1 line — critical for follow-up messages like "okay go ahead")
1092
+ PENDING: What still needs to be done (bullet points)
1093
+
1094
+ Conversation:
1095
+ `;
1096
+ for (const m of toSummarize) {
1097
+ summaryInput += `${m.role}: ${m.content.slice(0, 300)}\n`;
1098
+ }
1099
+
1100
+ const response = await llm.llmGenerate({
1101
+ model: GEMINI_MODEL,
1102
+ contents: [{ role: "user", parts: [{ text: summaryInput }] }],
1103
+ });
1104
+ llm.trackTokens(sessionId, response);
1105
+ const summary = response.text || "Previous conversation context unavailable.";
1106
+
1107
+ // DB-only reset: clear messages but DON'T clear in-memory state (actionLedger, sessionCosts, etc.)
1108
+ // because runGemini() may still be active for this chat
1109
+ try { await apiPost("/conversation/reset", { session_id: sessionId }); } catch {}
1110
+ await saveMessage(sessionId, "user", `[Previous conversation context]\n${summary}`);
1111
+ for (const m of toKeep) {
1112
+ await saveMessage(sessionId, m.role, m.content);
1113
+ }
1114
+ console.log(`[Memory] Compacted ${toSummarize.length} msgs → summary + ${toKeep.length} recent`);
1115
+ } catch (err) {
1116
+ console.error("[Memory] Compaction failed:", err.message);
1117
+ }
1118
+ }
1119
+
1120
+ // --- Token-based in-memory compaction ---
1121
+ // When prompt tokens exceed threshold mid-conversation, summarize old turns in the contents array
1122
+ // This prevents token burn from tool-heavy conversations that hit 300K+ tokens in just a few messages
1123
+ async function compactContents(contents, chatJid) {
1124
+ // Keep last 6 entries (3 user-model pairs) + current user message
1125
+ const keepCount = Math.min(7, Math.ceil(contents.length / 2));
1126
+ if (contents.length <= keepCount + 2) return false; // Not enough to compact
1127
+
1128
+ const toSummarize = contents.slice(0, contents.length - keepCount);
1129
+ const toKeep = contents.slice(contents.length - keepCount);
1130
+
1131
+ // Build a text summary of old turns — strip tool results to bare minimum (saves tokens on summary call)
1132
+ // Claude Code pattern: clear old tool results BEFORE summarization
1133
+ let summaryParts = [];
1134
+ for (const entry of toSummarize) {
1135
+ if (!entry?.parts) continue;
1136
+ for (const part of entry.parts) {
1137
+ if (part.text) {
1138
+ summaryParts.push(`${entry.role}: ${part.text.slice(0, 300)}`);
1139
+ } else if (part.functionCall) {
1140
+ summaryParts.push(
1141
+ `tool: ${part.functionCall.name}(${JSON.stringify(part.functionCall.args || {}).slice(0, 100)})`,
1142
+ );
1143
+ } else if (part.functionResponse) {
1144
+ const r = part.functionResponse.response?.result;
1145
+ const toolName = part.functionResponse.name;
1146
+ let status;
1147
+ if (r?.error) {
1148
+ status = `error: ${String(r.error).slice(0, 60)}`;
1149
+ } else if (MUTATING_TOOLS.has(toolName)) {
1150
+ // Preserve action outcomes through compaction (OpenClaw: tool failures survive compaction)
1151
+ status = summarizeToolResult(toolName, null, r) || "ok";
1152
+ } else {
1153
+ status = "ok";
1154
+ }
1155
+ summaryParts.push(`result: ${toolName} → ${status}`);
1156
+ }
1157
+ }
1158
+ }
1159
+
1160
+ // Inject action ledger into compaction input (OpenClaw: action outcomes survive compaction)
1161
+ const ledgerForCompaction = chatJid ? getActionLedgerText(chatJid) : "";
1162
+ const summaryInput = `Summarize this tool-calling session. Preserve all context needed to continue the current task.
1163
+
1164
+ Format:
1165
+ USER REQUEST: What the user asked for (1 line, quote key phrases)
1166
+ RESULTS: Key findings from tools — file paths, counts, search results (bullet points)
1167
+ ACTIONS TAKEN: What mutating actions were performed and their EXACT outcomes (moved_count, errors, etc.)
1168
+ CURRENT TASK: What is being worked on RIGHT NOW (1 line — critical)
1169
+ REMAINING: What still needs to be done to answer the user (1 line)
1170
+
1171
+ Conversation:
1172
+ ${summaryParts.join("\n")}${ledgerForCompaction}`;
1173
+
1174
+ try {
1175
+ const response = await llm.llmGenerate({
1176
+ model: GEMINI_MODEL,
1177
+ contents: [{ role: "user", parts: [{ text: summaryInput }] }],
1178
+ });
1179
+ llm.trackTokens(chatJid, response);
1180
+ const summary = response.text || "Previous context unavailable.";
1181
+
1182
+ // Replace contents array in-place: summary + kept entries
1183
+ contents.length = 0;
1184
+ contents.push({
1185
+ role: "user",
1186
+ parts: [
1187
+ {
1188
+ text: `[Previous conversation context]\n${summary}\n\nContinue from where you left off. Do not ask the user to repeat — use the context above.`,
1189
+ },
1190
+ ],
1191
+ });
1192
+ contents.push({ role: "model", parts: [{ text: "I have the full context. Continuing with the current task." }] });
1193
+ for (const entry of toKeep) contents.push(entry);
1194
+
1195
+ console.log(`[Memory] Token compaction: ${toSummarize.length} entries → summary + ${toKeep.length} recent`);
1196
+ // Compact DB history (awaited to prevent race with saveMessage)
1197
+ await compactHistory(chatJid).catch(() => {});
1198
+ return true;
1199
+ } catch (err) {
1200
+ console.error("[Memory] Token compaction failed:", err.message);
1201
+ return false;
1202
+ }
1203
+ }
1204
+
1205
+ // (summarizeToolResult moved to src/tools.js)
1206
+
1207
+ // --- Run Gemini agent loop (may do multiple tool calls) ---
1208
+ async function runGemini(userMessage, sock, chatJid, opts = {}) {
1209
+ // opts.inlineImage: { mimeType, data (base64) } — image already visible to Gemini
1210
+ // Refresh memory context if enabled — pass query for relevant retrieval (Supermemory pattern)
1211
+ if (isMemoryEnabled(chatJid)) {
1212
+ try {
1213
+ const qParam = encodeURIComponent(userMessage.slice(0, 200));
1214
+ const ctx = await apiGet(`/memory/context?q=${qParam}`);
1215
+ memoryContext.set(chatJid, ctx.text || "");
1216
+ } catch (_) {}
1217
+ }
1218
+
1219
+ // Load conversation history for context
1220
+ const history = await loadHistory(chatJid);
1221
+
1222
+ // Auto-reset if idle for 60+ minutes
1223
+ // Note: use lightweight reset here — don't call resetSession() which clears activeRequests
1224
+ // (we ARE the active request, clearing our own lock would cause "Stopped by user" at round 0)
1225
+ if (history.updated_at && isSessionIdle(history.updated_at)) {
1226
+ let deleted = 0;
1227
+ try {
1228
+ const data = await apiPost("/conversation/reset", { session_id: chatJid });
1229
+ deleted = data.deleted_count || 0;
1230
+ } catch (_) {}
1231
+ delete sessionCosts[chatJid];
1232
+ clearIntentCache(chatJid);
1233
+ delete actionLedger[chatJid];
1234
+ if (deleted > 0)
1235
+ console.log(`[Memory] Auto-reset session (idle ${IDLE_TIMEOUT_MS / 60000} min), cleared ${deleted} messages`);
1236
+ history.messages = [];
1237
+ }
1238
+
1239
+ // Compact if history is getting long (awaited to prevent race with saveMessage)
1240
+ if (history.message_count > COMPACT_THRESHOLD) {
1241
+ await compactHistory(chatJid).catch(() => {});
1242
+ }
1243
+
1244
+ // Build contents: history + current message
1245
+ const contents = [];
1246
+ for (const msg of history.messages) {
1247
+ contents.push({
1248
+ role: msg.role === "assistant" ? "model" : "user",
1249
+ parts: [{ text: msg.content }],
1250
+ });
1251
+ }
1252
+ // Current message — include inline image if provided (Gemini sees it immediately)
1253
+ const userParts = [{ text: userMessage }];
1254
+ if (opts.inlineImage) {
1255
+ userParts.push({ inlineData: { mimeType: opts.inlineImage.mimeType, data: opts.inlineImage.data } });
1256
+ }
1257
+ contents.push({ role: "user", parts: userParts });
1258
+
1259
+ const config = {
1260
+ systemInstruction: getSystemPrompt(userMessage, chatJid, {
1261
+ memoryEnabled: isMemoryEnabled(chatJid),
1262
+ memoryContext: getMemoryContext(chatJid),
1263
+ actionLedgerText: getActionLedgerText(chatJid),
1264
+ }),
1265
+ thinkingConfig: { thinkingLevel: "low" },
1266
+ mediaResolution: "MEDIA_RESOLUTION_LOW",
1267
+ };
1268
+ let activeTools = null;
1269
+ if (!opts.noTools) {
1270
+ // getToolsForIntent handles follow-up intent carry-forward via lastIntentCats
1271
+ activeTools = getToolsForIntent(userMessage, chatJid);
1272
+ }
1273
+
1274
+ const toolCache = new Map(); // Dedup: cache tool results within this turn
1275
+ const toolLog = []; // Track tool calls for conversation context
1276
+ let inlineImageCount = 0; // Track images sent as visual data
1277
+ let notifiedUser = false; // Track if we sent "working on it" message
1278
+ const toolStartTime = Date.now(); // For elapsed time logging
1279
+
1280
+ // Snapshot token counts at start of this message (for per-message budget)
1281
+ const sc0 = sessionCosts[chatJid];
1282
+ const msgStartTokens = { input: sc0?.input || 0, output: sc0?.output || 0 };
1283
+
1284
+ // Loop detection: track consecutive identical tool calls (from Gemini CLI + OpenCode)
1285
+ const LOOP_THRESHOLD = 3; // Same exact call N times → stop (OpenCode uses 3)
1286
+ const MAX_ROUNDS = 12; // Max rounds per prompt (Claude Code uses 1-20 depending on task)
1287
+ let lastCallHash = null;
1288
+ let lastCallCount = 0;
1289
+ let didTokenCompact = false; // Only compact once per runGemini call
1290
+ let sentFiles = new Set(); // Dedup: prevent sending same file twice
1291
+
1292
+ for (let round = 0; round < MAX_ROUNDS; round++) {
1293
+ // Check if user sent "stop" / "cancel" (lock was released)
1294
+ if (!activeRequests.has(chatJid)) {
1295
+ console.log(`[${LLM_TAG}] Stopped by user after ${round} rounds`);
1296
+ return { text: "Request stopped.", toolLog };
1297
+ }
1298
+
1299
+ // Cost-based circuit breaker: per-message budget (not cumulative session)
1300
+ const MESSAGE_BUDGET_USD = 0.1;
1301
+ const sc = sessionCosts[chatJid];
1302
+ if (sc && round > 0) {
1303
+ const msgCost =
1304
+ (sc.input - msgStartTokens.input) * TOKEN_COST_INPUT + (sc.output - msgStartTokens.output) * TOKEN_COST_OUTPUT;
1305
+ if (msgCost >= MESSAGE_BUDGET_USD) {
1306
+ console.warn(
1307
+ `[${LLM_TAG}] Budget exceeded ($${msgCost.toFixed(4)} >= $${MESSAGE_BUDGET_USD}) after ${round} rounds`,
1308
+ );
1309
+ return {
1310
+ text: `I've used my token budget for this request. Here's what I found so far — let me know if you need more.`,
1311
+ toolLog,
1312
+ };
1313
+ }
1314
+ }
1315
+
1316
+ // Microcompact: clear old tool results, keep last N (Claude Code pattern)
1317
+ // Never clear: expensive search results + mutating action results (accountability — OpenClaw pattern)
1318
+ if (round > 0) {
1319
+ const KEEP_LAST = 5;
1320
+ const PRESERVE_TOOLS = new Set(["search_images_visual", "search_documents", "detect_faces"]);
1321
+ let toolResultCount = 0;
1322
+ for (let i = contents.length - 1; i >= 0; i--) {
1323
+ const entry = contents[i];
1324
+ if (!entry?.parts) continue;
1325
+ for (const part of entry.parts) {
1326
+ if (part.functionResponse?.response?.result) toolResultCount++;
1327
+ }
1328
+ }
1329
+ if (toolResultCount > KEEP_LAST) {
1330
+ let clearCount = toolResultCount - KEEP_LAST;
1331
+ for (let i = 0; i < contents.length && clearCount > 0; i++) {
1332
+ const entry = contents[i];
1333
+ if (!entry?.parts) continue;
1334
+ for (const part of entry.parts) {
1335
+ if (part.functionResponse?.response?.result && clearCount > 0) {
1336
+ const toolName = part.functionResponse?.name;
1337
+ // Never clear expensive search/visual results
1338
+ if (PRESERVE_TOOLS.has(toolName)) continue;
1339
+ // Never clear mutating tool results — accountability (OpenClaw: action outcomes persist)
1340
+ if (MUTATING_TOOLS.has(toolName)) continue;
1341
+ part.functionResponse.response.result = "[Result cleared]";
1342
+ clearCount--;
1343
+ }
1344
+ }
1345
+ }
1346
+ }
1347
+ }
1348
+
1349
+ const response = await llm.llmGenerate({
1350
+ model: GEMINI_MODEL,
1351
+ contents,
1352
+ config,
1353
+ tools: activeTools,
1354
+ });
1355
+
1356
+ // Track token usage
1357
+ const roundTokens = llm.trackTokens(chatJid, response);
1358
+
1359
+ // Mid-call compaction: if contents array is getting large (many tool rounds), summarize old turns
1360
+ // Prevents token burn from long tool chains (e.g. 24 rounds searching for a file)
1361
+ if (!didTokenCompact && contents.length > CONTENTS_COMPACT_THRESHOLD) {
1362
+ console.log(
1363
+ `[Memory] Contents ${contents.length} entries > ${CONTENTS_COMPACT_THRESHOLD} threshold — compacting...`,
1364
+ );
1365
+ didTokenCompact = await compactContents(contents, chatJid);
1366
+ }
1367
+
1368
+ // Check for function calls
1369
+ if (response.functionCalls && response.functionCalls.length > 0) {
1370
+ // Notify user on first tool call so they know we're working
1371
+ if (!notifiedUser) {
1372
+ notifiedUser = true;
1373
+ try {
1374
+ await sock.sendMessage(chatJid, { text: "⏳ Working on it..." });
1375
+ rememberSent("⏳ Working on it...");
1376
+ } catch (e) {
1377
+ /* ignore send errors */
1378
+ }
1379
+ }
1380
+
1381
+ // Add model's response to conversation
1382
+ if (!response.candidates?.[0]?.content) {
1383
+ const reason = response.candidates?.[0]?.finishReason || "unknown";
1384
+ console.error(`[${LLM_TAG}] Empty candidate during tool calling. finishReason=${reason}`);
1385
+ return `Sorry, I couldn't process that request (safety filter or empty response).`;
1386
+ }
1387
+ contents.push(response.candidates[0].content);
1388
+
1389
+ // Execute each tool call
1390
+ const elapsed = Math.round((Date.now() - toolStartTime) / 1000);
1391
+ const thinkInfo = roundTokens?.thinking ? `, ${llm.formatTokens(roundTokens.thinking)} think` : "";
1392
+ const tokenInfo = roundTokens
1393
+ ? `, ${llm.formatTokens(roundTokens.input)} in / ${llm.formatTokens(roundTokens.output)} out${thinkInfo}`
1394
+ : "";
1395
+ console.log(
1396
+ `[${LLM_TAG}] Round ${round + 1}, ${response.functionCalls.length} tool(s), ${elapsed}s elapsed${tokenInfo}`,
1397
+ );
1398
+ const functionResponses = [];
1399
+ const imageParts = []; // For read_file images — Gemini sees them visually
1400
+ for (const fc of response.functionCalls) {
1401
+ // Loop detection: same tool+args called N times consecutively → stop
1402
+ const callHash = fc.name + ":" + JSON.stringify(fc.args || {});
1403
+ if (callHash === lastCallHash) {
1404
+ lastCallCount++;
1405
+ if (lastCallCount >= LOOP_THRESHOLD) {
1406
+ console.warn(`[${LLM_TAG}] Loop detected: ${fc.name} called ${lastCallCount}x with same args`);
1407
+ functionResponses.push({
1408
+ functionResponse: {
1409
+ name: fc.name,
1410
+ response: {
1411
+ result: {
1412
+ error: `Loop detected: you've called ${fc.name} ${lastCallCount} times with identical args. Stop retrying and answer with what you have — if the data wasn't found, tell the user.`,
1413
+ },
1414
+ },
1415
+ },
1416
+ });
1417
+ continue;
1418
+ }
1419
+ } else {
1420
+ lastCallHash = callHash;
1421
+ lastCallCount = 1;
1422
+ }
1423
+
1424
+ // Dedup: skip if same tool+args already called, or same-folder list_files
1425
+ let result;
1426
+ // Smart dedup: list_files on same folder = same result (sort/limit/recursive don't matter)
1427
+ const folderKey = fc.name === "list_files" && fc.args?.folder ? `list_files:${fc.args.folder}` : null;
1428
+ if (toolCache.has(callHash)) {
1429
+ result = toolCache.get(callHash);
1430
+ console.log(`[${LLM_TAG}] Dedup skip: ${fc.name} (cached)`);
1431
+ } else if (folderKey && toolCache.has(folderKey)) {
1432
+ result = toolCache.get(folderKey);
1433
+ console.log(`[${LLM_TAG}] Dedup skip: ${fc.name} (same folder already listed)`);
1434
+ } else {
1435
+ result = await executeTool(fc, sock, chatJid, sentFiles);
1436
+ toolCache.set(callHash, result);
1437
+ if (folderKey) toolCache.set(folderKey, result); // Smart dedup for same-folder
1438
+ }
1439
+
1440
+ // Post-tool error guidance: help Gemini recover instead of retrying blindly
1441
+ if (result?.error && !result._hint) {
1442
+ const err = String(result.error).toLowerCase();
1443
+ if (err.includes("not found") || err.includes("no such") || err.includes("does not exist")) {
1444
+ result._hint = "Path not found. Try list_files on the parent folder to check what exists.";
1445
+ } else if (err.includes("permission") || err.includes("access denied") || err.includes("read-only")) {
1446
+ result._hint = "Permission denied. File may be in use or read-only.";
1447
+ } else if (err.includes("no images") || err.includes("no results") || err.includes("no match")) {
1448
+ result._hint = "No matches. Try broader search terms or a different folder.";
1449
+ }
1450
+ }
1451
+
1452
+ // Empty search results guidance: nudge Gemini to ask the user instead of giving up
1453
+ if (!result?.error && !result?._hint) {
1454
+ const n = fc.name;
1455
+ const isEmpty =
1456
+ (n === "search_documents" && (result?.results?.length ?? 0) === 0) ||
1457
+ (n === "search_facts" && (result?.results?.length ?? result?.count ?? 0) === 0) ||
1458
+ (n === "list_files" && (result?.total ?? 0) === 0) ||
1459
+ (n === "search_generated_files" && (result?.count ?? 0) === 0) ||
1460
+ (n === "find_file" && (result?.count ?? 0) === 0) ||
1461
+ (n === "grep_files" && (result?.total_matches ?? 0) === 0);
1462
+ if (isEmpty) {
1463
+ result._hint = n === "search_documents"
1464
+ ? "0 results. Try: find_file (searches all common folders by filename), memory_search (user may have mentioned it before), search_generated_files (if you created it). If still nothing, ASK the user."
1465
+ : "0 results. Try find_file (searches all common folders by filename), broader terms, or ASK the user — they may know where it is.";
1466
+ }
1467
+ }
1468
+
1469
+ // Action Ledger: record every mutating tool's REAL outcome (OpenClaw pattern)
1470
+ // This gets injected into every subsequent LLM call as "## Actions Taken"
1471
+ if (MUTATING_TOOLS.has(fc.name)) {
1472
+ toolCache.clear(); // Invalidate cached results — filesystem may have changed
1473
+ recordAction(chatJid, fc.name, fc.args, result);
1474
+ // Independent result verification (isToolResultError — OpenClaw 4-layer check)
1475
+ // Detect "success but nothing done" — the root cause of Gemini lying
1476
+ if (!result?.error) {
1477
+ if (fc.name === "batch_move" && (result?.moved_count ?? 0) === 0) {
1478
+ result._warning = `⚠️ 0 files were actually ${result?.action || "moved"}. ${result?.skipped_count || 0} skipped, ${result?.error_count || 0} errors. Tell the user NO files were moved.`;
1479
+ console.log(
1480
+ `[Trust] batch_move: 0 files moved (${result?.skipped_count || 0} skipped) → ${fc.args?.destination}`,
1481
+ );
1482
+ } else if (fc.name === "batch_rename" && (result?.renamed_count ?? result?.renamed ?? 0) === 0) {
1483
+ result._warning = `⚠️ 0 files were actually renamed. Tell the user no files were renamed.`;
1484
+ }
1485
+ }
1486
+ }
1487
+
1488
+ // Tail tool calls: auto-send generated files to user without LLM round-trip
1489
+ // (extract_frame, crop_face, generate_chart, cull/group reports → auto send_file)
1490
+ if (!result?.error) {
1491
+ const autoSendPath =
1492
+ (fc.name === "extract_frame" && result.path) ||
1493
+ (fc.name === "crop_face" && result.path) ||
1494
+ (fc.name === "generate_chart" && result.path) ||
1495
+ ((fc.name === "cull_status" || fc.name === "group_status") && result.status === "done" && result.report_path);
1496
+ if (autoSendPath) {
1497
+ const sent = await sendFile(sock, chatJid, autoSendPath, `${PREFIX} ${pathModule.basename(autoSendPath)}`);
1498
+ if (sent) {
1499
+ result._auto_sent = true;
1500
+ console.log(`[${LLM_TAG}] Tail call: auto-sent ${pathModule.basename(autoSendPath)}`);
1501
+ }
1502
+ }
1503
+ }
1504
+
1505
+ // Layer 1: TempStore — store large results as refs, give Gemini preview
1506
+ let geminiResult = result; // What Gemini sees (may be preview)
1507
+ if (REF_TOOLS.has(fc.name) && !result?.error) {
1508
+ const resultJson = JSON.stringify(result);
1509
+ if (resultJson.length > REF_THRESHOLD) {
1510
+ // Multi-query results: only search_images_visual with explicit queries:[] returns { "query1": result, "query2": result }
1511
+ // Split into separate refs per query so Gemini can reference each individually
1512
+ const isMultiQuery =
1513
+ fc.name === "search_images_visual" &&
1514
+ fc.args?.queries &&
1515
+ fc.args.queries.length > 1 &&
1516
+ typeof result === "object" &&
1517
+ !Array.isArray(result);
1518
+ if (isMultiQuery) {
1519
+ const multiPreview = {};
1520
+ for (const [queryStr, queryResult] of Object.entries(result)) {
1521
+ const subJson = JSON.stringify(queryResult);
1522
+ if (subJson.length > REF_THRESHOLD) {
1523
+ const subRefKey = storeRef(queryResult);
1524
+ multiPreview[queryStr] = makeRefPreview(fc.name, queryResult, subRefKey);
1525
+ console.log(`[TempStore] ${fc.name} "${queryStr.slice(0, 40)}..." → ${subRefKey}`);
1526
+ } else {
1527
+ multiPreview[queryStr] = queryResult;
1528
+ }
1529
+ }
1530
+ geminiResult = multiPreview;
1531
+ } else {
1532
+ const refKey = storeRef(result);
1533
+ geminiResult = makeRefPreview(fc.name, result, refKey);
1534
+ console.log(`[TempStore] ${fc.name} → ${refKey} (${resultJson.length} chars → preview)`);
1535
+ }
1536
+ }
1537
+ }
1538
+
1539
+ // Track tool calls + results for conversation memory context
1540
+ const summary = summarizeToolResult(fc.name, fc.args, result);
1541
+ toolLog.push(summary || `${fc.name}: done`);
1542
+
1543
+ // If read_file returned an image, include as inlineData so Gemini SEES it (capped)
1544
+ if (fc.name === "read_file" && result.type === "image" && result.data && inlineImageCount < MAX_INLINE_IMAGES) {
1545
+ inlineImageCount++;
1546
+ functionResponses.push({
1547
+ functionResponse: {
1548
+ name: fc.name,
1549
+ response: {
1550
+ result: {
1551
+ type: "image",
1552
+ path: result.path,
1553
+ size: result.size,
1554
+ note: "Image included as visual data — analyze it directly.",
1555
+ },
1556
+ },
1557
+ },
1558
+ });
1559
+ imageParts.push({
1560
+ inlineData: {
1561
+ mimeType: result.mime_type,
1562
+ data: result.data,
1563
+ },
1564
+ });
1565
+ } else if (fc.name === "read_file" && result.type === "image" && inlineImageCount >= MAX_INLINE_IMAGES) {
1566
+ functionResponses.push({
1567
+ functionResponse: {
1568
+ name: fc.name,
1569
+ response: {
1570
+ result: {
1571
+ type: "image",
1572
+ path: result.path,
1573
+ size: result.size,
1574
+ note: `Image limit reached (${MAX_INLINE_IMAGES}). Use detect_faces or run_python for batch image analysis.`,
1575
+ },
1576
+ },
1577
+ },
1578
+ });
1579
+ } else {
1580
+ functionResponses.push({
1581
+ functionResponse: {
1582
+ name: fc.name,
1583
+ response: { result: geminiResult },
1584
+ },
1585
+ });
1586
+ }
1587
+ }
1588
+
1589
+ // Send tool results back to Gemini (with images if any)
1590
+ const parts = [...functionResponses, ...imageParts];
1591
+
1592
+ // Inject tool result summaries (Claude Code pattern: model trusts summaries, doesn't re-verify raw data)
1593
+ const summaries = response.functionCalls
1594
+ .map((fc) => {
1595
+ const r = toolCache.get(fc.name + ":" + JSON.stringify(fc.args || {}));
1596
+ return summarizeToolResult(fc.name, fc.args, r);
1597
+ })
1598
+ .filter(Boolean);
1599
+ if (summaries.length > 0) {
1600
+ parts.push({ text: `[Tool summaries: ${summaries.join(". ")}]` });
1601
+ }
1602
+
1603
+ // Loop hard-break: if loop was detected, force model to answer next round
1604
+ if (lastCallCount >= LOOP_THRESHOLD) {
1605
+ parts.push({
1606
+ text: "[System: LOOP DETECTED. You've called the same tool multiple times with identical args. STOP calling tools. Answer with what you have NOW.]",
1607
+ });
1608
+ // Send the error results back, then set round to max-1 so next iteration exits
1609
+ contents.push({ role: "user", parts });
1610
+ round = MAX_ROUNDS - 2; // Next round will be the last — forces text response
1611
+ continue;
1612
+ }
1613
+
1614
+ // Round-based efficiency nudges (adapted from Claude Code patterns)
1615
+ if (round === 3) {
1616
+ parts.push({
1617
+ text: "[System: Go straight to the point. Try the simplest approach first without going in circles. Do not overdo it. If you have search results, answer now.]",
1618
+ });
1619
+ } else if (round === 6) {
1620
+ parts.push({
1621
+ text: "[System: You've used 7 rounds. Do what was asked, nothing more. Answer with what you have NOW. Do not call more tools.]",
1622
+ });
1623
+ } else if (round === 9) {
1624
+ parts.push({ text: "[System: 10 rounds used. STOP calling tools. Give your answer immediately.]" });
1625
+ }
1626
+
1627
+ contents.push({ role: "user", parts });
1628
+ } else {
1629
+ // No more tool calls — return the text response
1630
+ const text = response.text;
1631
+ if (!text) {
1632
+ const finishReason = response.candidates?.[0]?.finishReason;
1633
+ const safetyRatings = response.candidates?.[0]?.safetyRatings;
1634
+ console.error(
1635
+ `[${LLM_TAG}] Empty response. finishReason=${finishReason}, safety=${JSON.stringify(safetyRatings || [])}`,
1636
+ );
1637
+ // MALFORMED_FUNCTION_CALL in no-tools mode: Gemini tried to call a tool
1638
+ // Only re-enable tools if this was a text message (not a bare image/file)
1639
+ if (finishReason === "MALFORMED_FUNCTION_CALL" && opts.noTools && round === 0 && !opts.inlineImage) {
1640
+ console.log(`[${LLM_TAG}] MALFORMED_FUNCTION_CALL in no-tools mode — retrying with tools`);
1641
+ opts.noTools = false;
1642
+ activeTools = getToolsForIntent(userMessage, chatJid);
1643
+ continue; // retry this round
1644
+ }
1645
+ }
1646
+ return {
1647
+ text: text || "Sorry, I couldn't process that. Please try rephrasing your question.",
1648
+ toolLog,
1649
+ };
1650
+ }
1651
+ }
1652
+
1653
+ // Max rounds reached — return what we have
1654
+ console.warn(`[${LLM_TAG}] Max rounds (${MAX_ROUNDS}) reached`);
1655
+ return {
1656
+ text: `I've completed ${MAX_ROUNDS} rounds of work. Here's what I did so far — let me know if you need me to continue.`,
1657
+ toolLog,
1658
+ };
1659
+ }
1660
+
1661
+ // --- Detect non-search messages (greetings, thanks, etc.) ---
1662
+ const NON_SEARCH = /^(hi|hello|hey|thanks|thank you|ok|okay|bye|good morning|good night|gm|gn|yo|sup)[\s!?.]*$/i;
1663
+
1664
+ function isGreeting(text) {
1665
+ return NON_SEARCH.test(text.trim());
1666
+ }
1667
+
1668
+ // --- Fallback: direct FTS search (when Gemini is unavailable) ---
1669
+ async function fallbackSearch(query) {
1670
+ // Don't search for greetings
1671
+ if (isGreeting(query)) {
1672
+ return {
1673
+ text: 'Hello! Smart search is temporarily unavailable (rate limit). Try a search query like "find reliance invoice".',
1674
+ files: [],
1675
+ };
1676
+ }
1677
+
1678
+ const data = await apiGet(`/search?q=${encodeURIComponent(query)}&limit=${MAX_RESULTS}`);
1679
+ if (!data.results || data.results.length === 0) {
1680
+ return { text: `No results found for "${query}"`, files: [] };
1681
+ }
1682
+
1683
+ let msg = `Found ${data.results.length} result(s) for "${query}":\n`;
1684
+ for (let i = 0; i < data.results.length; i++) {
1685
+ const r = data.results[i];
1686
+ const score = Math.round(r.score * 100);
1687
+ const snippet = r.snippet.length > 150 ? r.snippet.slice(0, 150) + "…" : r.snippet;
1688
+ msg += `\n*${i + 1}. ${r.title}* (${r.file_type.toUpperCase()}, ${score}%)\n${snippet}\n`;
1689
+ }
1690
+
1691
+ const files = data.results.slice(0, MAX_FILES_TO_SEND).map((r) => ({
1692
+ path: r.path,
1693
+ title: r.title,
1694
+ score: r.score,
1695
+ file_type: r.file_type,
1696
+ }));
1697
+
1698
+ return { text: msg, files };
1699
+ }
1700
+
1701
+ // --- Text chunking ---
1702
+ function chunkText(text, limit = TEXT_CHUNK_LIMIT) {
1703
+ if (text.length <= limit) return [text];
1704
+ const chunks = [];
1705
+ let remaining = text;
1706
+ while (remaining.length > 0) {
1707
+ if (remaining.length <= limit) {
1708
+ chunks.push(remaining);
1709
+ break;
1710
+ }
1711
+ let breakAt = remaining.lastIndexOf("\n", limit);
1712
+ if (breakAt < limit * 0.5) breakAt = remaining.lastIndexOf(" ", limit);
1713
+ if (breakAt < limit * 0.5) breakAt = limit;
1714
+ chunks.push(remaining.slice(0, breakAt));
1715
+ remaining = remaining.slice(breakAt).trimStart();
1716
+ }
1717
+ return chunks;
1718
+ }
1719
+
1720
+ // --- Send a file via WhatsApp ---
1721
+ async function sendFile(sock, chatJid, filePath, caption) {
1722
+ if (!existsSync(filePath)) return false;
1723
+
1724
+ const ext = pathModule.extname(filePath).toLowerCase();
1725
+ const fileName = pathModule.basename(filePath);
1726
+ let fileSize = statSync(filePath).size;
1727
+ const isImage = IMAGE_EXTENSIONS.has(ext);
1728
+
1729
+ // Auto-resize large images to fit WhatsApp limit
1730
+ let tempResizedPath = null;
1731
+ if (isImage && fileSize > MAX_IMAGE_SIZE) {
1732
+ try {
1733
+ const sharp = (await import("sharp")).default;
1734
+ tempResizedPath = pathModule.join("/tmp", `pinpoint_send_${Date.now()}.jpg`);
1735
+ await sharp(filePath)
1736
+ .resize({ width: 2048, height: 2048, fit: "inside" })
1737
+ .jpeg({ quality: 80 })
1738
+ .toFile(tempResizedPath);
1739
+ filePath = tempResizedPath;
1740
+ fileSize = statSync(filePath).size;
1741
+ console.log(`[Pinpoint] Auto-resized large image for sending (${(fileSize / 1024 / 1024).toFixed(1)}MB)`);
1742
+ } catch (e) {
1743
+ console.warn(`[Pinpoint] Auto-resize failed: ${e.message}`);
1744
+ if (tempResizedPath) try { unlinkSync(tempResizedPath); } catch (_) {}
1745
+ return false;
1746
+ }
1747
+ }
1748
+ if (isImage && fileSize > MAX_IMAGE_SIZE) return false;
1749
+ if (!isImage && fileSize > MAX_DOC_SIZE) return false;
1750
+
1751
+ const buffer = readFileSync(filePath);
1752
+
1753
+ let sentMsg;
1754
+ if (isImage) {
1755
+ sentMsg = await sock.sendMessage(chatJid, {
1756
+ image: buffer,
1757
+ caption,
1758
+ mimetype: ext === ".png" ? "image/png" : "image/jpeg",
1759
+ });
1760
+ } else {
1761
+ const mimeMap = {
1762
+ ".pdf": "application/pdf",
1763
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
1764
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
1765
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
1766
+ ".txt": "text/plain",
1767
+ ".csv": "text/csv",
1768
+ ".epub": "application/epub+zip",
1769
+ };
1770
+ sentMsg = await sock.sendMessage(chatJid, {
1771
+ document: buffer,
1772
+ mimetype: mimeMap[ext] || "application/octet-stream",
1773
+ fileName,
1774
+ caption,
1775
+ });
1776
+ }
1777
+ // Mark sent media as processed so echo doesn't trigger handleMedia (self-chat echo prevention)
1778
+ if (sentMsg?.key?.id) markProcessed(sentMsg.key.id);
1779
+ // Clean up temp resized file
1780
+ if (tempResizedPath) try { unlinkSync(tempResizedPath); } catch (_) {}
1781
+ return true;
1782
+ }
1783
+
1784
+ // --- Main bot ---
1785
+ async function startBot() {
1786
+ // Check API key
1787
+ if (!process.env.GEMINI_API_KEY) {
1788
+ console.log("[Pinpoint] WARNING: GEMINI_API_KEY not set in .env — using fallback keyword search");
1789
+ } else {
1790
+ const toolCount = TOOL_DECLARATIONS.length;
1791
+ console.log(`[Pinpoint] Gemini AI enabled (${GEMINI_MODEL}) — ${toolCount} tools + skills system`);
1792
+ }
1793
+ console.log(`[Pinpoint] Env file: ${ENV_PATH}`);
1794
+ console.log(`[Pinpoint] API URL: ${API_URL}`);
1795
+ console.log(`[Pinpoint] Auth dir: ${AUTH_DIR}`);
1796
+ console.log(`[Pinpoint] Skills dir: ${SKILLS_DIR}`);
1797
+
1798
+ const apiOk = await apiPing();
1799
+ if (!apiOk) {
1800
+ console.log("[Pinpoint] WARNING: Python API not reachable at " + API_URL);
1801
+ } else {
1802
+ console.log("[Pinpoint] Python API connected at " + API_URL);
1803
+ // Load memory state from settings
1804
+ try {
1805
+ const setting = await apiGet("/setting?key=memory_enabled");
1806
+ // Default ON: only disable if explicitly set to "false"
1807
+ const memDefault = setting.value !== "false";
1808
+ memoryEnabled.set("_default", memDefault);
1809
+ if (memDefault) {
1810
+ const ctx = await apiGet("/memory/context");
1811
+ memoryContext.set("_default", ctx.text || "");
1812
+ console.log(`[Pinpoint] Memory ON (${ctx.count || 0} memories loaded)`);
1813
+ } else {
1814
+ console.log("[Pinpoint] Memory OFF (enable with /memory on)");
1815
+ }
1816
+ } catch (_) {
1817
+ console.log("[Pinpoint] Memory ON (default)");
1818
+ }
1819
+
1820
+ // Load allowed users list
1821
+ await loadAllowedUsers();
1822
+ if (allowedUsers.size > 0) {
1823
+ console.log(`[Pinpoint] Allowed users: ${[...allowedUsers].join(", ")}`);
1824
+ }
1825
+ }
1826
+
1827
+ // Restore creds from backup if main file is corrupted (OpenClaw pattern)
1828
+ const credsPath = pathModule.join(AUTH_DIR, "creds.json");
1829
+ const credsBackup = credsPath + ".bak";
1830
+ if (!existsSync(credsPath) && existsSync(credsBackup)) {
1831
+ try {
1832
+ const { copyFileSync } = require("fs");
1833
+ copyFileSync(credsBackup, credsPath);
1834
+ console.log("[Pinpoint] Restored creds from backup");
1835
+ } catch (_) {}
1836
+ }
1837
+
1838
+ const { state, saveCreds: _saveCreds } = await useMultiFileAuthState(AUTH_DIR);
1839
+ const { version } = await fetchLatestBaileysVersion();
1840
+
1841
+ // Queued credential saves — prevent concurrent writes corrupting creds.json (OpenClaw pattern)
1842
+ let credsSaveQueue = Promise.resolve();
1843
+ const saveCreds = () => {
1844
+ credsSaveQueue = credsSaveQueue
1845
+ .then(async () => {
1846
+ try {
1847
+ // Backup before saving — only if current creds are valid JSON
1848
+ if (existsSync(credsPath)) {
1849
+ try {
1850
+ const raw = readFileSync(credsPath, "utf-8");
1851
+ JSON.parse(raw); // validate before backup
1852
+ const { copyFileSync } = require("fs");
1853
+ copyFileSync(credsPath, credsBackup);
1854
+ } catch (_) {} // keep existing backup if invalid
1855
+ }
1856
+ } catch (_) {}
1857
+ return _saveCreds();
1858
+ })
1859
+ .catch((err) => {
1860
+ console.error("[Pinpoint] Creds save error:", err.message);
1861
+ });
1862
+ };
1863
+
1864
+ // Clear stale reminder interval before creating new socket (prevents stale sock reference)
1865
+ if (global._reminderInterval) {
1866
+ clearInterval(global._reminderInterval);
1867
+ global._reminderInterval = null;
1868
+ }
1869
+
1870
+ const sock = makeWASocket({
1871
+ auth: {
1872
+ creds: state.creds,
1873
+ keys: makeCacheableSignalKeyStore(state.keys, logger),
1874
+ },
1875
+ version,
1876
+ logger,
1877
+ printQRInTerminal: false,
1878
+ browser: ["Pinpoint", "CLI", "1.0"],
1879
+ syncFullHistory: false,
1880
+ markOnlineOnConnect: false,
1881
+ });
1882
+ currentSock = sock; // Update module-level reference for reminders
1883
+
1884
+ // Handle WebSocket errors to prevent unhandled exceptions (OpenClaw pattern)
1885
+ if (sock.ws && typeof sock.ws.on === "function") {
1886
+ sock.ws.on("error", (err) => {
1887
+ console.error("[Pinpoint] WebSocket error:", err.message);
1888
+ });
1889
+ }
1890
+
1891
+ sock.ev.on("creds.update", saveCreds);
1892
+
1893
+ sock.ev.on("connection.update", (update) => {
1894
+ const { connection, lastDisconnect, qr } = update;
1895
+ if (qr) {
1896
+ console.log("\n[Pinpoint] Scan this QR code with WhatsApp:\n");
1897
+ qrcode.generate(qr, { small: true });
1898
+ writeQrPayload(qr);
1899
+ }
1900
+ if (connection === "open") {
1901
+ reconnectAttempt = 0;
1902
+ clearQrPayload();
1903
+ console.log(`[Pinpoint] Connected! JID: ${sock.user?.id}`);
1904
+ console.log("[Pinpoint] Send yourself a message to search.\n");
1905
+
1906
+ // Load reminders from DB on connect
1907
+ loadReminders().catch((e) => console.error("[Reminder] Load failed:", e.message));
1908
+
1909
+ // Start reminder checker (every 30 seconds) — always clear+set to prevent duplicates
1910
+ if (global._reminderInterval) {
1911
+ clearInterval(global._reminderInterval);
1912
+ global._reminderInterval = null;
1913
+ }
1914
+ {
1915
+ global._reminderInterval = setInterval(async () => {
1916
+ if (!currentSock) return;
1917
+ const now = Date.now();
1918
+ const due = reminders.filter((r) => r.triggerAt <= now && !r._firing);
1919
+ for (const r of due) r._firing = true; // Mark to prevent double-fire
1920
+ for (const r of due) {
1921
+ try {
1922
+ const label = r.repeat ? `⏰ *Reminder (${r.repeat}):* ${r.message}` : `⏰ *Reminder:* ${r.message}`;
1923
+ await currentSock.sendMessage(r.chatJid, { text: label });
1924
+ console.log(`[Reminder] Sent: "${r.message}" to ${r.chatJid}${r.repeat ? ` (${r.repeat})` : ""}`);
1925
+ } catch (e) {
1926
+ console.error(`[Reminder] Failed to send: ${e.message}`);
1927
+ }
1928
+ }
1929
+ // Handle sent reminders: reschedule recurring, remove one-time
1930
+ const toRemove = new Set();
1931
+ for (const r of due) {
1932
+ delete r._firing;
1933
+ if (r.repeat) {
1934
+ const next = getNextOccurrence(r.triggerAt, r.repeat);
1935
+ if (next) {
1936
+ r.triggerAt = next.getTime();
1937
+ try {
1938
+ await apiPut(`/reminders/${r.id}?trigger_at=${encodeURIComponent(next.toISOString())}`, {});
1939
+ } catch (_) {}
1940
+ console.log(`[Reminder] Rescheduled "${r.message}" → ${next.toISOString()}`);
1941
+ } else {
1942
+ console.log(`[Reminder] Cannot reschedule "${r.message}" (unknown repeat: ${r.repeat}) — removing`);
1943
+ toRemove.add(r);
1944
+ try { await apiDelete(`/reminders/${r.id}`); } catch (_) {}
1945
+ }
1946
+ } else {
1947
+ toRemove.add(r);
1948
+ try { await apiDelete(`/reminders/${r.id}`); } catch (_) {}
1949
+ }
1950
+ }
1951
+ if (toRemove.size > 0) {
1952
+ const kept = reminders.filter(r => !toRemove.has(r));
1953
+ reminders.length = 0;
1954
+ reminders.push(...kept);
1955
+ }
1956
+ }, 30000);
1957
+ }
1958
+ }
1959
+ if (connection === "close") {
1960
+ const code = lastDisconnect?.error?.output?.statusCode;
1961
+ const shouldReconnect = code !== DisconnectReason.loggedOut;
1962
+ console.log(`[Pinpoint] Disconnected (${code}). ${shouldReconnect ? "Reconnecting..." : "Logged out."}`);
1963
+ // Only clear auth on explicit logout (401) — never on 515
1964
+ // 515 means stream error, just reconnect with existing creds
1965
+ if (code === DisconnectReason.loggedOut) {
1966
+ console.log("[Pinpoint] Logged out — clearing auth for fresh login...");
1967
+ clearQrPayload();
1968
+ try {
1969
+ const files = readdirSync(AUTH_DIR);
1970
+ for (const f of files) unlinkSync(pathModule.join(AUTH_DIR, f));
1971
+ } catch (_) {}
1972
+ }
1973
+ if (shouldReconnect && reconnectAttempt < RECONNECT.maxAttempts) {
1974
+ const baseDelay = Math.min(RECONNECT.initialMs * Math.pow(RECONNECT.factor, reconnectAttempt), RECONNECT.maxMs);
1975
+ const jitter = baseDelay * RECONNECT.jitter * (Math.random() * 2 - 1); // ±25%
1976
+ const delay = Math.max(1000, Math.round(baseDelay + jitter));
1977
+ reconnectAttempt++;
1978
+ console.log(
1979
+ `[Pinpoint] Reconnecting in ${(delay / 1000).toFixed(1)}s (attempt ${reconnectAttempt}/${RECONNECT.maxAttempts})`,
1980
+ );
1981
+ setTimeout(startBot, delay);
1982
+ }
1983
+ }
1984
+ });
1985
+
1986
+ sock.ev.on("messages.upsert", async ({ messages, type }) => {
1987
+ if (type !== "notify" && type !== "append") return;
1988
+ for (const msg of messages) {
1989
+ try {
1990
+ await handleMessage(sock, msg);
1991
+ } catch (err) {
1992
+ console.error("[Pinpoint] Error:", err.message);
1993
+ }
1994
+ }
1995
+ });
1996
+
1997
+ return sock;
1998
+ }
1999
+
2000
+ // --- Handle received media files (save to computer) ---
2001
+
2002
+ function generateFilename(mediaType, mimetype) {
2003
+ const now = new Date();
2004
+ const ts =
2005
+ now.getFullYear().toString() +
2006
+ String(now.getMonth() + 1).padStart(2, "0") +
2007
+ String(now.getDate()).padStart(2, "0") +
2008
+ "_" +
2009
+ String(now.getHours()).padStart(2, "0") +
2010
+ String(now.getMinutes()).padStart(2, "0") +
2011
+ String(now.getSeconds()).padStart(2, "0");
2012
+ const prefixes = { imageMessage: "IMG", videoMessage: "VID", audioMessage: "AUD" };
2013
+ const prefix = prefixes[mediaType] || "FILE";
2014
+ const ext = MIME_TO_EXT[mimetype] || ".bin";
2015
+ return `${prefix}_${ts}${ext}`;
2016
+ }
2017
+
2018
+ function parseSaveFolder(caption) {
2019
+ if (!caption) return null;
2020
+ // Match patterns like "save to Desktop/work" or "save in Documents"
2021
+ const match = caption.match(/save\s+(?:to|in)\s+(.+)/i);
2022
+ if (!match) return null;
2023
+ let folder = match[1].trim();
2024
+ // Resolve common folder names
2025
+ const lower = folder.toLowerCase();
2026
+ if (lower.startsWith("downloads")) folder = folder.replace(/^downloads/i, DOWNLOADS);
2027
+ else if (lower.startsWith("desktop")) folder = folder.replace(/^desktop/i, DESKTOP);
2028
+ else if (lower.startsWith("documents")) folder = folder.replace(/^documents/i, DOCUMENTS);
2029
+ else if (lower.startsWith("pictures")) folder = folder.replace(/^pictures/i, PICTURES);
2030
+ else if (!pathModule.isAbsolute(folder)) folder = pathModule.join(DEFAULT_SAVE_FOLDER, folder);
2031
+ return folder;
2032
+ }
2033
+
2034
+ function uniquePath(filePath) {
2035
+ if (!existsSync(filePath)) return filePath;
2036
+ const dir = pathModule.dirname(filePath);
2037
+ const ext = pathModule.extname(filePath);
2038
+ const base = pathModule.basename(filePath, ext);
2039
+ let i = 1;
2040
+ while (existsSync(pathModule.join(dir, `${base} (${i})${ext}`))) i++;
2041
+ return pathModule.join(dir, `${base} (${i})${ext}`);
2042
+ }
2043
+
2044
+ async function handleMedia(sock, msg, chatJid) {
2045
+ const msgType = getContentType(msg.message);
2046
+ if (!msgType) return false;
2047
+
2048
+ // Only handle media types
2049
+ const mediaTypes = new Set(["imageMessage", "documentMessage", "videoMessage", "audioMessage"]);
2050
+ if (!mediaTypes.has(msgType)) return false;
2051
+
2052
+ const media = msg.message[msgType];
2053
+ if (!media) return false;
2054
+
2055
+ // Get metadata
2056
+ const caption = media.caption || "";
2057
+ const mimetype = media.mimetype || "application/octet-stream";
2058
+ const fileSize = Number(media.fileLength || 0);
2059
+
2060
+ // Determine filename
2061
+ let fileName;
2062
+ if (msgType === "documentMessage" && media.fileName) {
2063
+ fileName = media.fileName;
2064
+ } else {
2065
+ fileName = generateFilename(msgType, mimetype);
2066
+ }
2067
+
2068
+ // Determine save folder
2069
+ // If user said "save to X" → save permanently there
2070
+ // If caption has a question (processing request) → save to temp, cleaned up on session reset
2071
+ // If no caption + image → save to temp (will be auto-captioned)
2072
+ // If no caption + non-image → save permanently to default
2073
+ const customFolder = parseSaveFolder(caption);
2074
+ const cleanCaption_ = caption.replace(/save\s+(?:to|in)\s+.+/i, "").trim();
2075
+ const hasCaptionText = cleanCaption_.length > 2;
2076
+ const isImageMsg = msgType === "imageMessage";
2077
+ const isAudioMsg = msgType === "audioMessage";
2078
+ const isProcessingOnly = !customFolder && (hasCaptionText || isImageMsg || isAudioMsg);
2079
+ const saveFolder = customFolder || (isProcessingOnly ? TEMP_MEDIA_DIR : DEFAULT_SAVE_FOLDER);
2080
+
2081
+ // Create folder if needed
2082
+ mkdirSync(saveFolder, { recursive: true });
2083
+
2084
+ // Download the file
2085
+ let buffer;
2086
+ try {
2087
+ buffer = await downloadMediaMessage(
2088
+ msg,
2089
+ "buffer",
2090
+ {},
2091
+ {
2092
+ logger,
2093
+ reuploadRequest: sock.updateMediaMessage,
2094
+ },
2095
+ );
2096
+ } catch (err) {
2097
+ console.error("[Pinpoint] Media download failed:", err.message);
2098
+ const reply = `${PREFIX} Failed to download file: ${err.message}`;
2099
+ await sock.sendMessage(chatJid, { text: reply });
2100
+ rememberSent(reply);
2101
+ return true;
2102
+ }
2103
+
2104
+ // Save to disk (unique name to avoid overwrite)
2105
+ const savePath = uniquePath(pathModule.join(saveFolder, fileName));
2106
+ writeFileSync(savePath, buffer);
2107
+
2108
+ const sizeStr = _humanSize(buffer.length);
2109
+ const relFolder = saveFolder.replace(USER_HOME, "~");
2110
+ console.log(`[Pinpoint] Saved file: ${fileName} (${sizeStr}) → ${saveFolder}${isProcessingOnly ? " (temp)" : ""}`);
2111
+
2112
+ // Only show "Saved" confirmation for permanent saves (not processing-only temp files)
2113
+ if (!isProcessingOnly) {
2114
+ const reply = `${PREFIX} Saved *${pathModule.basename(savePath)}* to ${relFolder} (${sizeStr})`;
2115
+ await sock.sendMessage(chatJid, { text: reply });
2116
+ rememberSent(reply);
2117
+
2118
+ // Save file receipt to conversation memory so Gemini knows about it in future messages
2119
+ // (e.g. user sends photo now, then says "find this person" later)
2120
+ await saveMessage(chatJid, "user", `[Sent a file: ${pathModule.basename(savePath)} saved at ${savePath}]`);
2121
+ await saveMessage(chatJid, "assistant", `Saved ${pathModule.basename(savePath)} to ${relFolder} (${sizeStr})`);
2122
+ }
2123
+
2124
+ // Build the message for Gemini
2125
+ const cleanCaption = caption.replace(/save\s+(?:to|in)\s+.+/i, "").trim();
2126
+ const isImage = msgType === "imageMessage";
2127
+ const hasCaption = cleanCaption && cleanCaption.length > 2;
2128
+
2129
+ // For images/audio: send inline to Gemini (it sees/hears it directly)
2130
+ // For non-media with caption: send file path + caption to Gemini
2131
+ const isAudio = msgType === "audioMessage";
2132
+ const shouldProcess = hasCaption || isImage || isAudio;
2133
+
2134
+ if (shouldProcess && !activeRequests.has(chatJid)) {
2135
+ let userMsg;
2136
+ if (isAudio) {
2137
+ userMsg = hasCaption
2138
+ ? `[Voice note at ${savePath}]\n${cleanCaption}`
2139
+ : `[Voice note at ${savePath}]\nUser sent a voice message. Listen and respond to what they said.`;
2140
+ } else if (hasCaption) {
2141
+ userMsg = `[File: ${pathModule.basename(savePath)} at ${savePath}]\n${cleanCaption}`;
2142
+ } else {
2143
+ userMsg = `[Photo: ${pathModule.basename(savePath)} at ${savePath}]\nUser sent this with no instruction. Just ask what they want to do with it.`;
2144
+ }
2145
+ const myRequestId = ++requestCounter;
2146
+ activeRequests.set(chatJid, { msg: hasCaption ? cleanCaption : isAudio ? "[voice]" : "[photo]", startTime: Date.now(), id: myRequestId });
2147
+ try {
2148
+ await sock.sendPresenceUpdate("composing", chatJid);
2149
+
2150
+ // If image or audio, read as base64 and send inline — Gemini sees/hears it directly
2151
+ let inlineImage = null;
2152
+ if (isImage || isAudio) {
2153
+ try {
2154
+ const mediaData = readFileSync(savePath).toString("base64");
2155
+ inlineImage = { mimeType: mimetype, data: mediaData };
2156
+ if (isImage) {
2157
+ lastImage.set(chatJid, { mimeType: mimetype, data: mediaData, path: savePath, ts: Date.now() });
2158
+ }
2159
+ console.log(`[Pinpoint] Sending ${isAudio ? "audio" : "image"} inline to ${LLM_TAG} (${sizeStr})`);
2160
+ } catch (e) {
2161
+ console.error(`[Pinpoint] Failed to read ${isAudio ? "audio" : "image"} for inline:`, e.message);
2162
+ }
2163
+ }
2164
+
2165
+ // No caption + image → disable tools (Gemini just describes, no fishing expedition)
2166
+ // Audio always gets tools (user might ask to do something)
2167
+ const noTools = isImage && !hasCaption;
2168
+ const result = await runGemini(userMsg, sock, chatJid, { inlineImage, noTools });
2169
+ const current = activeRequests.get(chatJid);
2170
+ if (!current || current.id !== myRequestId) {
2171
+ console.log(`[Pinpoint] Discarded media result (stopped)`);
2172
+ } else {
2173
+ console.log(`[${LLM_TAG}] Response: ${result.text.length} chars`);
2174
+ const replyText = `${PREFIX} ${markdownToWhatsApp(result.text)}`;
2175
+ const chunks = chunkText(replyText);
2176
+ for (const chunk of chunks) {
2177
+ await sock.sendMessage(chatJid, { text: chunk });
2178
+ rememberSent(chunk);
2179
+ }
2180
+ let assistantSave = result.text;
2181
+ if (result.toolLog && result.toolLog.length > 0) {
2182
+ assistantSave = `[Actions: ${result.toolLog.join(", ")}]\n${result.text}`;
2183
+ }
2184
+ await saveMessage(
2185
+ chatJid,
2186
+ "user",
2187
+ hasCaption ? cleanCaption : `[Sent photo: ${pathModule.basename(savePath)}]`,
2188
+ );
2189
+ await saveMessage(chatJid, "assistant", assistantSave);
2190
+ }
2191
+ } catch (err) {
2192
+ console.error(`[${LLM_TAG}] Error on media processing:`, err.message);
2193
+ } finally {
2194
+ const current = activeRequests.get(chatJid);
2195
+ if (current && current.id === myRequestId) {
2196
+ activeRequests.delete(chatJid);
2197
+ }
2198
+ }
2199
+ } else if (shouldProcess) {
2200
+ console.log(`[Pinpoint] Skipped media processing — Gemini already running`);
2201
+ // Save file receipt to conversation memory so Gemini knows about it later
2202
+ await saveMessage(chatJid, "user", `[Sent file: ${pathModule.basename(savePath)} saved at ${savePath}]`);
2203
+ // Don't set lastImage here — skipped images are usually bot echoes (send_file → WhatsApp echo)
2204
+ }
2205
+
2206
+ await sock.sendPresenceUpdate("paused", chatJid);
2207
+ return true; // handled
2208
+ }
2209
+
2210
+ function _humanSize(bytes) {
2211
+ if (bytes < 1024) return `${bytes} B`;
2212
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
2213
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
2214
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
2215
+ }
2216
+
2217
+ async function handleMessage(sock, msg) {
2218
+ const key = msg.key;
2219
+ const chatJid = key.remoteJid;
2220
+ if (chatJid === "status@broadcast") return;
2221
+
2222
+ const msgId = key.id;
2223
+ if (processedIds.has(msgId)) return;
2224
+ markProcessed(msgId);
2225
+
2226
+ // Access control: self-chat always allowed, others need /allow permission
2227
+ const myNumber = sock.user?.id?.split(":")[0]?.split("@")[0];
2228
+ const myLid = sock.user?.lid?.split(":")[0]?.split("@")[0];
2229
+ const chatNumber = chatJid?.split("@")[0];
2230
+ const isGroup = chatJid?.endsWith("@g.us");
2231
+ const isSelfChat =
2232
+ !isGroup && ((myNumber && chatNumber && myNumber === chatNumber) || (myLid && chatNumber && myLid === chatNumber));
2233
+
2234
+ // Layer 0: Skip our own messages in non-self chats (prevents infinite echo loops)
2235
+ if (key.fromMe && !isSelfChat) return;
2236
+
2237
+ if (!isSelfChat && !isAllowedUser(chatJid)) {
2238
+ if (!isGroup)
2239
+ console.log(
2240
+ `[Pinpoint] Ignored message from: ${chatJid} (not allowed). To allow: /allow ${chatJid.split("@")[0]}`,
2241
+ );
2242
+ return;
2243
+ }
2244
+
2245
+ // Allowed users: "pinpoint" starts session, "bye/stop pinpoint" ends it, 60min idle timeout
2246
+ const isAllowed = !isSelfChat && isAllowedUser(chatJid);
2247
+ if (isAllowed) {
2248
+ const peekText = (
2249
+ msg.message?.conversation ||
2250
+ msg.message?.extendedTextMessage?.text ||
2251
+ msg.message?.imageMessage?.caption ||
2252
+ msg.message?.documentMessage?.caption ||
2253
+ ""
2254
+ ).toLowerCase();
2255
+ const hasSession = allowedSessions.has(chatJid) && Date.now() - allowedSessions.get(chatJid) < IDLE_TIMEOUT_MS;
2256
+
2257
+ // End session: "bye pinpoint" / "by pinpoint" / "stop pinpoint" / "exit pinpoint" / "pinpoint bye/stop"
2258
+ const isEndCmd =
2259
+ /\b(bye|by|stop|exit|quit|close)\b.*\bpinpoint\b|\bpinpoint\b.*\b(bye|by|stop|exit|quit|close)\b/.test(peekText);
2260
+ if (hasSession && isEndCmd) {
2261
+ allowedSessions.delete(chatJid);
2262
+ lastImage.delete(chatJid);
2263
+ activeRequests.delete(chatJid);
2264
+ clearIntentCache(chatJid);
2265
+ memoryEnabled.delete(chatJid);
2266
+ memoryContext.delete(chatJid);
2267
+ const endMsg = `${PREFIX} Session ended. Say "pinpoint" anytime to start again.`;
2268
+ await sock.sendMessage(chatJid, { text: endMsg });
2269
+ rememberSent(endMsg);
2270
+ return;
2271
+ }
2272
+
2273
+ // No active session — need "pinpoint" to start
2274
+ if (!hasSession) {
2275
+ if (!peekText.includes("pinpoint")) return;
2276
+ // Starting new session — greet and return (don't send "pinpoint" to Gemini)
2277
+ allowedSessions.set(chatJid, Date.now());
2278
+ const greetMsg = `${PREFIX} Hi! I'm Pinpoint. How can I help? (Say "bye pinpoint" when done)`;
2279
+ await sock.sendMessage(chatJid, { text: greetMsg });
2280
+ rememberSent(greetMsg);
2281
+ return;
2282
+ }
2283
+
2284
+ // Refresh session timer
2285
+ allowedSessions.set(chatJid, Date.now());
2286
+ }
2287
+
2288
+ // Send read receipt (blue ticks) so user knows bot received it
2289
+ try {
2290
+ await sock.readMessages([key]);
2291
+ } catch (_) {}
2292
+
2293
+ // Check for media messages FIRST (before text check)
2294
+ const msgType = getContentType(msg.message);
2295
+ if (msgType && msgType !== "conversation" && msgType !== "extendedTextMessage") {
2296
+ const handled = await handleMedia(sock, msg, chatJid);
2297
+ if (handled) return;
2298
+ }
2299
+
2300
+ const rawText = msg.message?.conversation || msg.message?.extendedTextMessage?.text || "";
2301
+ if (!rawText.trim()) return;
2302
+ if (wasSentByUs(rawText.trim())) return;
2303
+ if (rawText.trim().startsWith(PREFIX)) return;
2304
+
2305
+ // Debounce: combine rapid messages within 1.5s (null = merged into another message)
2306
+ const userMsg = await debounceMessage(chatJid, rawText.trim(), sock);
2307
+ if (!userMsg) return; // This message was merged into another — skip
2308
+ console.log(`[Pinpoint] Message: "${userMsg}"`);
2309
+
2310
+ // Handle stop/cancel — abort in-progress request
2311
+ const cmdLower = userMsg.toLowerCase();
2312
+ if ((cmdLower === "stop" || cmdLower === "cancel" || cmdLower === "/stop") && activeRequests.has(chatJid)) {
2313
+ const active = activeRequests.get(chatJid);
2314
+ activeRequests.delete(chatJid); // Release lock immediately — next message can proceed
2315
+ const reply = `${PREFIX} Stopped.`;
2316
+ await sock.sendMessage(chatJid, { text: reply });
2317
+ rememberSent(reply);
2318
+ console.log(`[Pinpoint] Aborted request: "${active.msg}"`);
2319
+ return;
2320
+ }
2321
+
2322
+ // --- Admin commands (self-chat only) ---
2323
+ if (isSelfChat && cmdLower.startsWith("/allow ")) {
2324
+ const number = userMsg
2325
+ .slice(7)
2326
+ .trim()
2327
+ .replace(/[^0-9]/g, "");
2328
+ if (!number || number.length < 10) {
2329
+ await sock.sendMessage(chatJid, {
2330
+ text: `${PREFIX} Invalid number. Use: /allow 919876543210 (include country code)`,
2331
+ });
2332
+ return;
2333
+ }
2334
+ allowedUsers.add(number);
2335
+ await saveAllowedUsers();
2336
+ const reply = `${PREFIX} Allowed ${number}. They can message "pinpoint" to use it.`;
2337
+ await sock.sendMessage(chatJid, { text: reply });
2338
+ rememberSent(reply);
2339
+ console.log(`[Pinpoint] Allowed user: ${number}`);
2340
+ return;
2341
+ }
2342
+ if (isSelfChat && cmdLower.startsWith("/revoke ")) {
2343
+ const number = userMsg
2344
+ .slice(8)
2345
+ .trim()
2346
+ .replace(/[^0-9]/g, "");
2347
+ // Remove all matching entries (phone number + any resolved LIDs)
2348
+ const removed = [];
2349
+ for (const id of [...allowedUsers]) {
2350
+ if (id === number || id.startsWith(number)) {
2351
+ allowedUsers.delete(id);
2352
+ removed.push(id);
2353
+ }
2354
+ }
2355
+ if (removed.length > 0) {
2356
+ await saveAllowedUsers();
2357
+ const reply = `${PREFIX} Revoked access for ${removed.join(", ")}.`;
2358
+ await sock.sendMessage(chatJid, { text: reply });
2359
+ rememberSent(reply);
2360
+ console.log(`[Pinpoint] Revoked user: ${removed.join(", ")}`);
2361
+ } else {
2362
+ await sock.sendMessage(chatJid, { text: `${PREFIX} ${number} is not in the allowed list.` });
2363
+ }
2364
+ return;
2365
+ }
2366
+ if (isSelfChat && cmdLower === "/allowed") {
2367
+ const list = allowedUsers.size > 0 ? [...allowedUsers].join(", ") : "None";
2368
+ const reply = `${PREFIX} Allowed users: ${list}`;
2369
+ await sock.sendMessage(chatJid, { text: reply });
2370
+ rememberSent(reply);
2371
+ return;
2372
+ }
2373
+
2374
+ // Handle slash commands (before Gemini)
2375
+ if (cmdLower === "/new" || cmdLower === "/reset") {
2376
+ const deleted = await resetSession(chatJid);
2377
+ delete sessionCosts[chatJid];
2378
+ clearIntentCache(chatJid);
2379
+ delete actionLedger[chatJid];
2380
+ const reply = `${PREFIX} Conversation reset. Fresh start! (cleared ${deleted} messages)`;
2381
+ await sock.sendMessage(chatJid, { text: reply });
2382
+ rememberSent(reply);
2383
+ console.log(`[Pinpoint] Session reset: ${deleted} messages cleared`);
2384
+ return;
2385
+ }
2386
+ if (cmdLower === "/history") {
2387
+ const hist = await loadHistory(chatJid);
2388
+ if (hist.messages.length === 0) {
2389
+ const reply = `${PREFIX} No conversation history.`;
2390
+ await sock.sendMessage(chatJid, { text: reply });
2391
+ rememberSent(reply);
2392
+ } else {
2393
+ let reply = `${PREFIX} Last ${hist.messages.length} messages (${hist.message_count} total):\n`;
2394
+ for (const m of hist.messages.slice(-10)) {
2395
+ const role = m.role === "user" ? "You" : "Bot";
2396
+ const snippet = m.content.length > 80 ? m.content.slice(0, 80) + "…" : m.content;
2397
+ reply += `\n*${role}:* ${snippet}`;
2398
+ }
2399
+ const chunks = chunkText(reply);
2400
+ for (const chunk of chunks) {
2401
+ await sock.sendMessage(chatJid, { text: chunk });
2402
+ rememberSent(chunk);
2403
+ }
2404
+ }
2405
+ return;
2406
+ }
2407
+ if (cmdLower === "/memory on" || cmdLower === "/memory off") {
2408
+ const on = cmdLower === "/memory on";
2409
+ memoryEnabled.set(chatJid, on);
2410
+ try {
2411
+ await apiPost(`/setting?key=memory_enabled&value=${on}`, {});
2412
+ } catch (_) {}
2413
+ if (on) {
2414
+ try {
2415
+ const ctx = await apiGet("/memory/context");
2416
+ memoryContext.set(chatJid, ctx.text || "");
2417
+ } catch (_) {}
2418
+ }
2419
+ const reply = `${PREFIX} Memory ${on ? "enabled" : "disabled"}.${on ? " I'll remember personal facts you share." : " I won't save or recall memories."}`;
2420
+ await sock.sendMessage(chatJid, { text: reply });
2421
+ rememberSent(reply);
2422
+ console.log(`[Pinpoint] Memory ${on ? "ON" : "OFF"} for ${chatJid}`);
2423
+ return;
2424
+ }
2425
+ if (cmdLower === "/memory" || cmdLower === "/memory status") {
2426
+ let reply = `${PREFIX} Memory is ${isMemoryEnabled(chatJid) ? "ON" : "OFF"}.`;
2427
+ if (isMemoryEnabled(chatJid)) {
2428
+ try {
2429
+ const list = await apiGet("/memory/list?limit=50");
2430
+ reply += ` ${list.count} memories saved.`;
2431
+ if (list.count > 0) {
2432
+ reply += "\n";
2433
+ for (const m of list.memories.slice(0, 10)) {
2434
+ reply += `\n[${m.category}] ${m.fact}`;
2435
+ }
2436
+ if (list.count > 10) reply += `\n\n…and ${list.count - 10} more.`;
2437
+ }
2438
+ } catch (_) {}
2439
+ } else {
2440
+ reply += " Use /memory on to enable.";
2441
+ }
2442
+ await sock.sendMessage(chatJid, { text: reply });
2443
+ rememberSent(reply);
2444
+ return;
2445
+ }
2446
+
2447
+ if (cmdLower === "/cost") {
2448
+ const reply = `${PREFIX} ${llm.getCostSummary(chatJid)}`;
2449
+ await sock.sendMessage(chatJid, { text: reply });
2450
+ rememberSent(reply);
2451
+ return;
2452
+ }
2453
+
2454
+ if (cmdLower === "/help") {
2455
+ const reply = `${PREFIX} *Commands:*
2456
+ /new or /reset — Fresh conversation
2457
+ /history — Recent messages
2458
+ /memory on — Enable persistent memory
2459
+ /memory off — Disable memory
2460
+ /memory — Show saved memories
2461
+ /cost — Token usage & estimated cost
2462
+ /allow 91XXXXXXXXXX — Give someone Pinpoint access
2463
+ /revoke 91XXXXXXXXXX — Remove their access
2464
+ /allowed — List allowed users
2465
+ /help — This list
2466
+ stop — Cancel current request`;
2467
+ await sock.sendMessage(chatJid, { text: reply });
2468
+ rememberSent(reply);
2469
+ return;
2470
+ }
2471
+ // Catch unknown / commands — don't send to Gemini
2472
+ if (cmdLower.startsWith("/")) {
2473
+ const reply = `${PREFIX} Unknown command: ${userMsg}\nType /help for available commands.`;
2474
+ await sock.sendMessage(chatJid, { text: reply });
2475
+ rememberSent(reply);
2476
+ return;
2477
+ }
2478
+
2479
+ // Processing lock: if already running Gemini for this chat, save to memory and skip
2480
+ if (activeRequests.has(chatJid)) {
2481
+ const active = activeRequests.get(chatJid);
2482
+ const elapsed = Math.round((Date.now() - active.startTime) / 1000);
2483
+ const snippet = active.msg.length > 60 ? active.msg.slice(0, 60) + "…" : active.msg;
2484
+ const reply = `${PREFIX} Still working on your request (${elapsed}s)… "${snippet}"`;
2485
+ await sock.sendMessage(chatJid, { text: reply });
2486
+ rememberSent(reply);
2487
+ // Save blocked message to conversation memory so Gemini sees it in the next call
2488
+ await saveMessage(chatJid, "user", userMsg);
2489
+ console.log(`[Pinpoint] Blocked concurrent request (active: "${snippet}", ${elapsed}s) — saved to memory`);
2490
+ return;
2491
+ }
2492
+
2493
+ const myRequestId = ++requestCounter;
2494
+ activeRequests.set(chatJid, { msg: userMsg, startTime: Date.now(), id: myRequestId });
2495
+
2496
+ try {
2497
+ await sock.sendPresenceUpdate("composing", chatJid);
2498
+
2499
+ // Run Gemini (or fallback to direct search)
2500
+ let result;
2501
+ try {
2502
+ if (process.env.GEMINI_API_KEY) {
2503
+ // Simple messages: only skip tools for greetings/reactions with NO active conversation
2504
+ // Confirmations ("yes", "ok", "go ahead") WITH recent context are NOT simple — they need tools
2505
+ // Claude Code pattern: never strip tools when there's active context
2506
+ const isGreeting = /^(hi|hello|hey|good morning|good night|gm|gn|bye)\s*[.!?]*$/i.test(userMsg.trim());
2507
+ const isReaction =
2508
+ /^(thanks|thank you|lol|haha|cool|nice|great|awesome|amazing|wow|perfect|oh|damn|omg|hehe|bruh|whoa|dope|sick|sweet|beautiful|wonderful|brilliant|excellent|fantastic|superb|impressive|neat|solid|lit|fire|legit|bet|word|ooh|aah|yay|woah|geez|ty|thx|np|gg|kk|ikr|imo|fyi|asap|🔥|💯|👏|😍|🤩|🥳|💪|🎉|✅|🙌|👌|😭|🤣|😎|💀|🫡|👍|🙏|❤️|😂|😊)\s*[.!?]*$/i.test(
2509
+ userMsg.trim(),
2510
+ );
2511
+ const hasContext = hasActiveIntent(chatJid);
2512
+ const isSimple = isGreeting || (isReaction && !hasContext);
2513
+ const noTools = isSimple;
2514
+ // Re-inject last image only if recent (< 2 min TTL) — prevents stale image re-injection
2515
+ const prevImg = lastImage.get(chatJid);
2516
+ const imgAge = prevImg ? Date.now() - (prevImg.ts || 0) : Infinity;
2517
+ const inlineImage = prevImg && imgAge < 120000 ? prevImg : null;
2518
+ // Prepend image path so Gemini knows where it is (for tools like crop_image)
2519
+ let geminiMsg = userMsg;
2520
+ if (inlineImage && prevImg.path) {
2521
+ geminiMsg = `[Image: ${pathModule.basename(prevImg.path)} at ${prevImg.path}]\n${userMsg}`;
2522
+ }
2523
+ result = await runGemini(geminiMsg, sock, chatJid, { noTools, inlineImage });
2524
+ // Clear lastImage after use (one-shot re-injection)
2525
+ if (inlineImage) lastImage.delete(chatJid);
2526
+ console.log(`[${LLM_TAG}] Response: ${result.text.length} chars`);
2527
+ } else {
2528
+ result = await fallbackSearch(userMsg);
2529
+ }
2530
+ } catch (err) {
2531
+ console.error(`[${LLM_TAG}] Error:`, err.message);
2532
+ // Fallback to direct keyword search
2533
+ try {
2534
+ result = await fallbackSearch(userMsg);
2535
+ console.log("[Pinpoint] Fell back to keyword search");
2536
+ } catch (err2) {
2537
+ const errMsg = `${PREFIX} Search failed. Is the Python API running?`;
2538
+ await sock.sendMessage(chatJid, { text: errMsg });
2539
+ rememberSent(errMsg);
2540
+ await sock.sendPresenceUpdate("paused", chatJid);
2541
+ return;
2542
+ }
2543
+ }
2544
+
2545
+ // If this request was stopped (lock released by "stop"), discard result silently
2546
+ const current = activeRequests.get(chatJid);
2547
+ if (!current || current.id !== myRequestId) {
2548
+ console.log(`[Pinpoint] Discarded result for stopped request: "${userMsg.slice(0, 40)}"`);
2549
+ return;
2550
+ }
2551
+
2552
+ // Send text reply (markdown → WhatsApp format, chunked)
2553
+ const replyText = `${PREFIX} ${markdownToWhatsApp(result.text)}`;
2554
+ const chunks = chunkText(replyText);
2555
+ for (const chunk of chunks) {
2556
+ await sock.sendMessage(chatJid, { text: chunk });
2557
+ rememberSent(chunk);
2558
+ }
2559
+
2560
+ // Save conversation to memory with tool context for continuity
2561
+ await saveMessage(chatJid, "user", userMsg);
2562
+ // Enrich assistant message with tool context so Gemini knows what it did last turn
2563
+ let assistantSave = result.text;
2564
+ if (result.toolLog && result.toolLog.length > 0) {
2565
+ const toolSummary = result.toolLog.join(", ");
2566
+ assistantSave = `[Actions: ${toolSummary}]\n${result.text}`;
2567
+ }
2568
+ await saveMessage(chatJid, "assistant", assistantSave);
2569
+
2570
+ await sock.sendPresenceUpdate("paused", chatJid);
2571
+ const sc = sessionCosts[chatJid];
2572
+ const tokenSummary = sc ? `, ${llm.formatTokens(sc.input + sc.output)} tokens` : "";
2573
+ console.log(`[Pinpoint] Done (${replyText.length} chars${tokenSummary}, session: ${chatJid.slice(0, 12)}…)`);
2574
+ } finally {
2575
+ // Only release lock if we still own it (stop handler may have already released)
2576
+ const current = activeRequests.get(chatJid);
2577
+ if (current && current.id === myRequestId) {
2578
+ activeRequests.delete(chatJid);
2579
+ }
2580
+ }
2581
+ }
2582
+
2583
+ module.exports = { startBot };
2584
+
2585
+ // --- Start ---
2586
+ if (require.main === module) {
2587
+ console.log("=== Pinpoint WhatsApp Bot ===\n");
2588
+ startBot().catch((err) => {
2589
+ console.error("[Pinpoint] Fatal error:", err);
2590
+ process.exit(1);
2591
+ });
2592
+ }