digital-brain 1.1.9 → 1.1.11

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/README.md CHANGED
@@ -83,7 +83,7 @@ node ./bin/digital-brain.js init ./Digital Brain\ Vault
83
83
  - Generate reply-ready person context that keeps WhatsApp, iMessage, Slack, and LinkedIn evidence separate under the same person.
84
84
  - Create AI-readable memory files for future prompts.
85
85
  - Draft WhatsApp sends by default, send with explicit `--yes`, or configure auto-send mode during init.
86
- - Run an explicit WhatsApp auto-responder that uses local Ollama plus vault memory while the command is running.
86
+ - Run an explicit WhatsApp auto-responder that uses Ollama or a Codex command plus vault memory while the command is running.
87
87
  - Enforce an AI-disclosure guard after repeated AI-assisted sends.
88
88
 
89
89
  ## Core Commands
@@ -98,6 +98,7 @@ digital-brain import-linkedin --input ./linkedin-archive.zip
98
98
  digital-brain send-whatsapp --to "Name" --message "text"
99
99
  digital-brain auto-whatsapp --allow "Name" --model llama3.1
100
100
  digital-brain auto-whatsapp --contact "+15551234567" --model llama3.1
101
+ digital-brain auto-whatsapp --allow-all --provider codex --yes
101
102
  ```
102
103
 
103
104
  `init` remembers your vault globally, so `run` works from anywhere. `run` syncs the live local sources you selected, extracts relationships, and writes interpreted memory in one command.
@@ -124,10 +125,21 @@ digital-brain auto-whatsapp --allow "Mom" --model llama3.1
124
125
  digital-brain auto-whatsapp --allow "Mom" --model llama3.1 --yes
125
126
  digital-brain auto-whatsapp --allow "Mom" --model llama3.1 --yes --no-process-unread
126
127
  digital-brain auto-whatsapp --contact "+15551234567" --model llama3.1 --yes
128
+ digital-brain auto-whatsapp --allow-all --provider codex --yes
127
129
  ```
128
130
 
131
+ If you start without `--allow`, `--contact`, or `--allow-all` in an interactive terminal, Digital Brain asks whether to cover all contacts or select contacts from your WhatsApp chat list. With `--allow-all`, it still asks once before the first AI reply to each new chat and stores the decision in `08 Sources/WhatsApp/Outbound/auto-reply-whitelist.json`. Use `--auto-approve-new-chats` only if you intentionally want unattended first sends.
132
+
129
133
  Even with `--allow-all`, likely business, notification, OTP, delivery, bank, and support chats are skipped by default. Use explicit `--allow "Name"` or `--contact "+15551234567"` for trusted personal chats. Pass `--include-businesses` only if you intentionally want those chats included.
130
134
 
135
+ The default provider is local Ollama. To use Codex instead:
136
+
137
+ ```bash
138
+ digital-brain auto-whatsapp --allow-all --provider codex --yes
139
+ ```
140
+
141
+ If your Codex CLI needs a custom command, pass `--codex-command "..."` or set `DIGITAL_BRAIN_CODEX_COMMAND`. If the command contains `{promptFile}`, Digital Brain writes the reply prompt to a temp file and substitutes that path; otherwise it pipes the prompt to stdin.
142
+
131
143
  If Digital Brain has already sent two AI-assisted messages to the same chat in the last 24 hours, the next send must disclose that AI is helping. Once that chat has received an AI disclosure, Digital Brain will not keep repeating it.
132
144
 
133
145
  ## Automation
@@ -593,6 +593,6 @@ Usage:
593
593
  digital-brain extract --days 30
594
594
  digital-brain interpret --days 30
595
595
  digital-brain send-whatsapp --to "Name" --message "Text" [--yes]
596
- digital-brain auto-whatsapp --allow "Name" --contact "+15551234567" --model llama3.1 [--yes] [--no-process-unread]
596
+ digital-brain auto-whatsapp --allow "Name" --contact "+15551234567" --provider ollama|codex --model llama3.1 [--yes] [--no-process-unread]
597
597
  `);
598
598
  }
@@ -96,13 +96,14 @@ The generated script loops forever and sleeps for `refreshIntervalMinutes`. The
96
96
 
97
97
  ## WhatsApp Auto-Reply
98
98
 
99
- `digital-brain auto-whatsapp` is separate from refresh automation. It uses WhatsApp Web for live incoming messages and Ollama for local reply generation. On startup it scans unread WhatsApp Web chats, then continues listening for new messages.
99
+ `digital-brain auto-whatsapp` is separate from refresh automation. It uses WhatsApp Web for live incoming messages and either Ollama or a Codex command for reply generation. On startup it scans unread WhatsApp Web chats, then continues listening for new messages.
100
100
 
101
101
  Draft-only:
102
102
 
103
103
  ```bash
104
104
  digital-brain auto-whatsapp --allow "Mom" --model llama3.1
105
105
  digital-brain auto-whatsapp --contact "+15551234567" --model llama3.1
106
+ digital-brain auto-whatsapp --allow-all --provider codex
106
107
  ```
107
108
 
108
109
  Auto-send while the command is running:
@@ -110,6 +111,7 @@ Auto-send while the command is running:
110
111
  ```bash
111
112
  digital-brain auto-whatsapp --allow "Mom" --model llama3.1 --yes
112
113
  digital-brain auto-whatsapp --contact "+15551234567" --model llama3.1 --yes
114
+ digital-brain auto-whatsapp --allow-all --provider codex --yes
113
115
  ```
114
116
 
115
117
  Broad auto-send for personal chats:
@@ -120,6 +122,18 @@ digital-brain auto-whatsapp --allow-all --model llama3.1 --yes
120
122
 
121
123
  `--allow-all` still skips likely business, notification, OTP, bank, delivery, and support chats by default. Use `--include-businesses` only when you intentionally want those chats included. Prefer explicit `--allow "Name"` or `--contact "+15551234567"` for friends and family.
122
124
 
125
+ When `--allow-all` is used, Digital Brain asks once before the first AI reply to each new chat and stores allow/deny decisions in `08 Sources/WhatsApp/Outbound/auto-reply-whitelist.json`. Use `--auto-approve-new-chats` only for fully unattended first sends.
126
+
127
+ Provider options:
128
+
129
+ ```bash
130
+ digital-brain auto-whatsapp --allow "Mom" --provider ollama --model llama3.1 --yes
131
+ digital-brain auto-whatsapp --allow "Mom" --provider codex --yes
132
+ digital-brain auto-whatsapp --allow "Mom" --provider codex --codex-command "codex exec --skip-git-repo-check" --yes
133
+ ```
134
+
135
+ `--provider codex` runs a local Codex command. If `--codex-command` contains `{promptFile}`, Digital Brain writes the prompt to a temp file and substitutes the path; otherwise it pipes the prompt to stdin.
136
+
123
137
  If you selected `Auto-send while running` during init, `auto-whatsapp` can send without `--yes` while it is running:
124
138
 
125
139
  ```bash
@@ -128,9 +142,11 @@ digital-brain auto-whatsapp --allow "Mom" --model llama3.1
128
142
 
129
143
  Guardrails:
130
144
 
131
- - requires Ollama running locally
132
- - requires the selected model, for example `ollama pull llama3.1`
145
+ - with `--provider ollama`, requires Ollama running locally
146
+ - with `--provider ollama`, requires the selected model, for example `ollama pull llama3.1`
147
+ - with `--provider codex`, requires a working local Codex command
133
148
  - requires `--allow "Name"` or `--contact "+15551234567"` unless `--allow-all` is explicitly passed
149
+ - single-threads reply generation so multiple incoming chats do not trigger overlapping sends
134
150
  - skips likely business, notification, OTP, and service chats unless `--include-businesses` is passed or the chat is explicitly allowlisted by name or contact number
135
151
  - processes unread chats on startup unless `--no-process-unread` is passed
136
152
  - skips groups unless `--include-groups` is passed
package/docs/PRIVACY.md CHANGED
@@ -7,7 +7,7 @@ Digital Brain is designed for local use.
7
7
  - No cloud API is called by default.
8
8
  - Ollama interpretation is local when enabled.
9
9
  - WhatsApp sending uses a local WhatsApp Web session.
10
- - WhatsApp auto-reply uses local Ollama by default, runs only while the command is active, and requires an allowlist unless explicitly overridden. If init is configured for auto-send mode, it can send without `--yes`.
10
+ - WhatsApp auto-reply uses local Ollama by default or a configured local Codex command, runs only while the command is active, and requires an allowlist unless explicitly overridden. If init is configured for auto-send mode, it can send without `--yes`.
11
11
  - Raw source data stays under `08 Sources/`; normal AI context should use `06 AI Memory/` and human notes under `04 People/`.
12
12
  - Same-person matching across sources is provisional and file-based; keep source evidence visible when using merged person context.
13
13
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "digital-brain",
3
- "version": "1.1.9",
3
+ "version": "1.1.11",
4
4
  "description": "Your private digital imprint for AI assistants.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,9 @@
1
1
  import fs from "node:fs";
2
2
  import crypto from "node:crypto";
3
3
  import path from "node:path";
4
+ import readline from "node:readline/promises";
5
+ import { spawn } from "node:child_process";
6
+ import { stdin as input, stdout as output } from "node:process";
4
7
  import qrcode from "qrcode-terminal";
5
8
  import pkg from "whatsapp-web.js";
6
9
 
@@ -16,14 +19,19 @@ const whatsAppDir = path.join(vault, "08 Sources", "WhatsApp");
16
19
  const outboundDir = path.join(whatsAppDir, "Outbound");
17
20
  const sessionDir = path.join(whatsAppDir, ".session");
18
21
  const statePath = path.join(outboundDir, "auto-reply-state.json");
22
+ const whitelistPath = path.join(outboundDir, "auto-reply-whitelist.json");
19
23
  const config = readConfig(vault);
24
+ const provider = args.provider || config.autoReplyProvider || "ollama";
20
25
  const model = args.model || config.autoReplyModel || "llama3.1";
26
+ const codexCommand = args["codex-command"] || process.env.DIGITAL_BRAIN_CODEX_COMMAND || config.codexCommand || "codex exec --skip-git-repo-check";
21
27
  const allow = parseList(args.allow || "");
22
28
  const deny = parseList(args.deny || "");
23
29
  const contactNumbers = parseList([args.contact, args.phone, args["contact-number"]].filter(Boolean).join(","))
24
30
  .map(normalizePhone)
25
31
  .filter(Boolean);
26
32
  const allowAll = Boolean(args["allow-all"]);
33
+ let runtimeAllowAll = allowAll;
34
+ const autoApproveNewChats = Boolean(args["auto-approve-new-chats"]);
27
35
  const includeGroups = Boolean(args["include-groups"]);
28
36
  const includeBusinesses = Boolean(args["include-businesses"]);
29
37
  const sendEnabled = Boolean(args.yes) || config.outboundMode === "auto-send";
@@ -33,6 +41,12 @@ const maxRepliesPerChat = numberArg("max-replies-per-chat", 5);
33
41
  const maxContextChars = numberArg("max-context-chars", 12000);
34
42
  const outboundLogMode = args["log-mode"] || config.outboundLogMode || "metadata";
35
43
  const state = loadState();
44
+ const whitelist = loadWhitelist();
45
+ const hasStoredScope = Object.keys(whitelist.allowedChats || {}).length > 0;
46
+ const hasInitialScope = allowAll || allow.length > 0 || contactNumbers.length > 0 || hasStoredScope;
47
+ const interactiveTerminal = Boolean(input.isTTY && output.isTTY);
48
+ let queueTail = Promise.resolve();
49
+ const rl = interactiveTerminal ? readline.createInterface({ input, output }) : null;
36
50
 
37
51
  fs.mkdirSync(outboundDir, { recursive: true });
38
52
 
@@ -41,12 +55,16 @@ if (config.outboundMode === "disabled") {
41
55
  process.exit(1);
42
56
  }
43
57
 
44
- if (!allowAll && allow.length === 0 && contactNumbers.length === 0) {
58
+ if (!hasInitialScope && !interactiveTerminal) {
45
59
  console.error('Refusing to auto-reply without an allowlist. Add --allow "Name", --contact "+15551234567", or pass --allow-all explicitly.');
46
60
  process.exit(1);
47
61
  }
48
62
 
49
- await assertOllamaModel(model);
63
+ if (provider === "ollama") {
64
+ await assertOllamaModel(model);
65
+ } else if (!["codex"].includes(provider)) {
66
+ throw new Error(`Unsupported auto-reply provider "${provider}". Use "ollama" or "codex".`);
67
+ }
50
68
 
51
69
  const client = new Client({
52
70
  authStrategy: new LocalAuth({ clientId: "digital-brain", dataPath: sessionDir }),
@@ -59,9 +77,11 @@ client.on("qr", (qr) => {
59
77
  });
60
78
 
61
79
  client.on("ready", async () => {
62
- console.log(`Digital Brain WhatsApp auto-reply running with Ollama model: ${model}`);
80
+ console.log(`Digital Brain WhatsApp auto-reply running with provider: ${provider}${provider === "ollama" ? ` (${model})` : ""}`);
63
81
  console.log(sendEnabled ? "Auto-send is enabled." : "Draft mode. Replies will be logged but not sent. Add --yes or set outboundMode=auto-send to send.");
64
- console.log(allowAll ? "Allowlist: all chats." : allowlistSummary());
82
+ if (!hasInitialScope) await configureInteractiveScope();
83
+ console.log(runtimeAllowAll ? "Allowlist: all chats, with first-send approval per new chat." : allowlistSummary());
84
+ if (provider === "codex") console.log(`Codex command: ${codexCommand}`);
65
85
  if (!includeBusinesses) console.log("Likely business, notification, OTP, and service chats are skipped by default.");
66
86
  try {
67
87
  if (processUnreadOnStart) {
@@ -78,7 +98,7 @@ client.on("ready", async () => {
78
98
  client.on("message", async (message) => {
79
99
  try {
80
100
  console.log(`Received WhatsApp message event: ${summarize(message.body || "[non-text message]")}`);
81
- await handleMessage(message);
101
+ enqueueMessage(message);
82
102
  } catch (error) {
83
103
  console.error(`Auto-reply error: ${error.message}`);
84
104
  }
@@ -115,8 +135,86 @@ async function processUnreadChats() {
115
135
  continue;
116
136
  }
117
137
  console.log(`Processing unread chat: ${name}`);
118
- await handleMessage(latestInbound, chat);
138
+ await enqueueMessage(latestInbound, chat);
139
+ }
140
+ }
141
+
142
+ async function configureInteractiveScope() {
143
+ console.log("");
144
+ console.log("Auto-reply scope:");
145
+ console.log(" A) all contacts, but ask once before the first AI reply to each new chat");
146
+ console.log(" S) select contacts from your WhatsApp chat list");
147
+ console.log(" Q) quit");
148
+ const answer = (await rl.question("Choose A/S/Q [S]: ")).trim().toLowerCase() || "s";
149
+ if (answer.startsWith("q")) {
150
+ await shutdown(0);
151
+ return;
152
+ }
153
+ if (answer.startsWith("a")) {
154
+ runtimeAllowAll = true;
155
+ console.log("Scope set to all contacts with first-send approval.");
156
+ return;
157
+ }
158
+ const chats = (await client.getChats())
159
+ .filter((chat) => includeGroups || !chat.isGroup)
160
+ .sort((a, b) => Number(b.timestamp || 0) - Number(a.timestamp || 0))
161
+ .slice(0, numberArg("select-limit", 60));
162
+ if (chats.length === 0) {
163
+ console.log("No WhatsApp chats found to select.");
164
+ return;
165
+ }
166
+ console.log("");
167
+ console.log("Select contacts:");
168
+ chats.forEach((chat, index) => {
169
+ console.log(` ${index + 1}) ${chatName(chat)}`);
170
+ });
171
+ const selection = await rl.question("Numbers to whitelist, comma-separated: ");
172
+ const selected = parseIndexSelection(selection, chats.length).map((index) => chats[index]);
173
+ for (const chat of selected) {
174
+ setWhitelistDecision(chat, "allow", "startup-select");
175
+ }
176
+ console.log(`Whitelisted ${selected.length} chat(s).`);
177
+ }
178
+
179
+ function enqueueMessage(message, knownChat = null) {
180
+ queueTail = queueTail
181
+ .catch(() => undefined)
182
+ .then(() => handleMessage(message, knownChat))
183
+ .catch((error) => console.error(`Auto-reply error: ${error.message}`));
184
+ return queueTail;
185
+ }
186
+
187
+ async function ensureChatApproved(chat, recentMessages) {
188
+ const name = chatName(chat);
189
+ if (isExplicitlyAllowed(chat) || isChatWhitelisted(chat)) return true;
190
+ if (isChatDenied(chat)) {
191
+ console.log(`Skipping stored denied chat: ${name}`);
192
+ return false;
193
+ }
194
+ if (!runtimeAllowAll) return false;
195
+ if (autoApproveNewChats) {
196
+ setWhitelistDecision(chat, "allow", "auto-approve-new-chats");
197
+ return true;
198
+ }
199
+ if (!interactiveTerminal) {
200
+ console.log(`Skipping ${name}: first-send approval required but no interactive terminal is available.`);
201
+ return false;
202
+ }
203
+ console.log("");
204
+ console.log(`New chat needs AI approval: ${name}`);
205
+ const inbound = recentMessages.filter((item) => !item.fromMe && !item.isStatus).slice(-3);
206
+ for (const item of inbound) {
207
+ console.log(` ${name}: ${summarize(item.body || "[non-text message]")}`);
119
208
  }
209
+ const answer = (await rl.question("Whitelist this chat for AI auto-replies? [y/N]: ")).trim().toLowerCase();
210
+ if (answer === "y" || answer === "yes") {
211
+ setWhitelistDecision(chat, "allow", "first-send-prompt");
212
+ console.log(`Whitelisted ${name}.`);
213
+ return true;
214
+ }
215
+ setWhitelistDecision(chat, "deny", "first-send-prompt");
216
+ console.log(`Denied ${name}.`);
217
+ return false;
120
218
  }
121
219
 
122
220
  async function handleMessage(message, knownChat = null) {
@@ -154,6 +252,10 @@ async function handleMessage(message, knownChat = null) {
154
252
  markProcessed(message, name, { sent: false });
155
253
  return;
156
254
  }
255
+ if (!(await ensureChatApproved(chat, recentMessages))) {
256
+ markProcessed(message, name, { sent: false });
257
+ return;
258
+ }
157
259
  const disclosure = disclosureStatus(name);
158
260
  const prompt = buildPrompt({
159
261
  chatName: name,
@@ -161,7 +263,7 @@ async function handleMessage(message, knownChat = null) {
161
263
  recentMessages,
162
264
  disclosureRequired: disclosure.required,
163
265
  });
164
- console.log(`Generating reply for ${name} with ${model}...`);
266
+ console.log(`Generating reply for ${name} with ${provider}${provider === "ollama" ? `:${model}` : ""}...`);
165
267
  const startedAt = Date.now();
166
268
  const reply = await generateReply(prompt);
167
269
  console.log(`Generated reply for ${name} in ${Date.now() - startedAt}ms: ${summarize(reply || "[empty reply]")}`);
@@ -192,6 +294,9 @@ function buildPrompt({ chatName, incomingBody, recentMessages, disclosureRequire
192
294
  "You are helping the user reply on WhatsApp.",
193
295
  "Write exactly one message to send as the user.",
194
296
  "Be natural, concise, and relationship-appropriate.",
297
+ "First infer the immediate intent of the current conversation from the recent chat. Continue that thread only.",
298
+ "Do not start with hey/hi unless the recent chat itself uses that greeting pattern.",
299
+ "Mirror the vocabulary, register, and pacing already present in this chat.",
195
300
  "Match the user's own communication style from My Communication Style. If it says lowercase-heavy or undercapitalized, prefer lowercase casual texting.",
196
301
  "Use lexical signals from My Communication Style: recurring style words, openers, short phrase shapes, punctuation habits, and lowercase-i behavior.",
197
302
  "Do not sound like customer support, corporate email, or a generic AI assistant.",
@@ -238,6 +343,11 @@ function readMemoryContext(chatName) {
238
343
  }
239
344
 
240
345
  async function generateReply(prompt) {
346
+ if (provider === "codex") return generateCodexReply(prompt);
347
+ return generateOllamaReply(prompt);
348
+ }
349
+
350
+ async function generateOllamaReply(prompt) {
241
351
  const response = await fetch("http://127.0.0.1:11434/api/generate", {
242
352
  method: "POST",
243
353
  headers: { "content-type": "application/json" },
@@ -253,6 +363,44 @@ async function generateReply(prompt) {
253
363
  return cleanReply(body.response || "");
254
364
  }
255
365
 
366
+ async function generateCodexReply(prompt) {
367
+ return runReplyCommand(codexCommand, prompt, "codex");
368
+ }
369
+
370
+ async function runReplyCommand(command, prompt, label) {
371
+ const timeoutMs = numberArg("provider-timeout-ms", 120000);
372
+ const usesPromptFile = command.includes("{promptFile}");
373
+ const promptFile = usesPromptFile ? path.join(outboundDir, `reply-prompt-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`) : "";
374
+ if (promptFile) fs.writeFileSync(promptFile, prompt, "utf8");
375
+ const renderedCommand = promptFile ? command.replaceAll("{promptFile}", shellQuote(promptFile)) : command;
376
+ return await new Promise((resolve, reject) => {
377
+ const child = spawn(renderedCommand, { shell: true, cwd: vault, env: process.env });
378
+ let stdout = "";
379
+ let stderr = "";
380
+ const timer = setTimeout(() => {
381
+ child.kill("SIGTERM");
382
+ reject(new Error(`${label} reply command timed out after ${timeoutMs}ms`));
383
+ }, timeoutMs);
384
+ child.stdout.on("data", (chunk) => { stdout += chunk.toString(); });
385
+ child.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
386
+ child.on("error", (error) => {
387
+ clearTimeout(timer);
388
+ cleanupPromptFile(promptFile);
389
+ reject(error);
390
+ });
391
+ child.on("close", (code) => {
392
+ clearTimeout(timer);
393
+ cleanupPromptFile(promptFile);
394
+ if (code !== 0) {
395
+ reject(new Error(`${label} reply command failed with ${code}: ${summarize(stderr || stdout)}`));
396
+ return;
397
+ }
398
+ resolve(cleanReply(stdout));
399
+ });
400
+ if (!usesPromptFile) child.stdin.end(prompt);
401
+ });
402
+ }
403
+
256
404
  async function assertOllamaModel(modelName) {
257
405
  let response;
258
406
  try {
@@ -281,7 +429,9 @@ function cleanReply(value) {
281
429
  }
282
430
 
283
431
  function isAllowed(chatOrName) {
284
- if (allowAll) return true;
432
+ if (isChatDenied(chatOrName)) return false;
433
+ if (isChatWhitelisted(chatOrName)) return true;
434
+ if (runtimeAllowAll) return true;
285
435
  const name = typeof chatOrName === "string" ? chatOrName : chatName(chatOrName);
286
436
  if (contactNumbers.some((number) => chatMatchesContact(chatOrName, number))) return true;
287
437
  return allow.some((item) => name.toLowerCase().includes(item.toLowerCase()));
@@ -298,7 +448,7 @@ function isDenied(name) {
298
448
  }
299
449
 
300
450
  function shouldSkipNonPersonalChat(chatOrName, recentMessages) {
301
- if (includeBusinesses || isExplicitlyAllowed(chatOrName)) return false;
451
+ if (includeBusinesses || isExplicitlyAllowed(chatOrName) || isChatWhitelisted(chatOrName)) return false;
302
452
  const name = typeof chatOrName === "string" ? chatOrName : chatName(chatOrName);
303
453
  return isLikelyBusinessOrAutomation(name, recentMessages);
304
454
  }
@@ -356,9 +506,71 @@ function allowlistSummary() {
356
506
  const parts = [];
357
507
  if (allow.length) parts.push(`names: ${allow.join(", ")}`);
358
508
  if (contactNumbers.length) parts.push(`contacts: ${contactNumbers.map(maskPhone).join(", ")}`);
509
+ const storedCount = Object.keys(whitelist.allowedChats || {}).length;
510
+ if (storedCount) parts.push(`stored: ${storedCount} chat(s)`);
359
511
  return `Allowlist: ${parts.join("; ")}`;
360
512
  }
361
513
 
514
+ function isChatWhitelisted(chatOrName) {
515
+ return Boolean(whitelist.allowedChats?.[chatKey(chatOrName)]);
516
+ }
517
+
518
+ function isChatDenied(chatOrName) {
519
+ return Boolean(whitelist.deniedChats?.[chatKey(chatOrName)]);
520
+ }
521
+
522
+ function setWhitelistDecision(chat, decision, source) {
523
+ const key = chatKey(chat);
524
+ const record = {
525
+ chatName: chatName(chat),
526
+ chatId: typeof chat === "string" ? null : chat.id?._serialized || null,
527
+ source,
528
+ updatedAt: new Date().toISOString(),
529
+ };
530
+ if (decision === "allow") {
531
+ whitelist.allowedChats[key] = record;
532
+ delete whitelist.deniedChats[key];
533
+ } else {
534
+ whitelist.deniedChats[key] = record;
535
+ delete whitelist.allowedChats[key];
536
+ }
537
+ writeJsonAtomic(whitelistPath, whitelist);
538
+ }
539
+
540
+ function chatKey(chatOrName) {
541
+ if (typeof chatOrName === "string") return `name:${chatOrName.toLowerCase()}`;
542
+ return chatOrName.id?._serialized || `name:${chatName(chatOrName).toLowerCase()}`;
543
+ }
544
+
545
+ function loadWhitelist() {
546
+ if (!fs.existsSync(whitelistPath)) return emptyWhitelist();
547
+ try {
548
+ return normalizeWhitelist(JSON.parse(fs.readFileSync(whitelistPath, "utf8")));
549
+ } catch {
550
+ return emptyWhitelist();
551
+ }
552
+ }
553
+
554
+ function normalizeWhitelist(value) {
555
+ return {
556
+ schemaVersion: 1,
557
+ allowedChats: value?.allowedChats || {},
558
+ deniedChats: value?.deniedChats || {},
559
+ };
560
+ }
561
+
562
+ function emptyWhitelist() {
563
+ return { schemaVersion: 1, allowedChats: {}, deniedChats: {} };
564
+ }
565
+
566
+ function parseIndexSelection(value, max) {
567
+ return [...new Set(String(value || "")
568
+ .split(/[,\s]+/)
569
+ .map((item) => Number(item.trim()))
570
+ .filter((item) => Number.isInteger(item) && item >= 1 && item <= max)
571
+ .map((item) => item - 1))];
572
+ }
573
+
362
574
  function isCoolingDown(name) {
363
575
  const last = state.lastSentAtByChat[name];
364
576
  if (!last) return false;
@@ -463,6 +675,26 @@ function writeJsonAtomic(file, value) {
463
675
  fs.renameSync(temp, file);
464
676
  }
465
677
 
678
+ function cleanupPromptFile(file) {
679
+ if (file) {
680
+ try {
681
+ fs.unlinkSync(file);
682
+ } catch {
683
+ // ignore cleanup errors
684
+ }
685
+ }
686
+ }
687
+
688
+ function shellQuote(value) {
689
+ return `'${String(value).replaceAll("'", "'\\''")}'`;
690
+ }
691
+
692
+ async function shutdown(code) {
693
+ if (rl) rl.close();
694
+ await client.destroy().catch(() => undefined);
695
+ process.exit(code);
696
+ }
697
+
466
698
  function visibleMessage(record, reply) {
467
699
  return outboundLogMode === "full" ? reply : `[metadata only, ${record.messageCharCount} chars, ${record.messageHash.slice(0, 12)}]`;
468
700
  }