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/bin/pinpoint-bot.js +9 -0
- package/index.js +2592 -0
- package/package.json +43 -0
- package/src/llm.js +254 -0
- package/src/skills.js +163 -0
- package/src/tools.js +2013 -0
- package/test/skills-paths.test.js +24 -0
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
|
+
}
|