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 +13 -1
- package/bin/digital-brain.js +1 -1
- package/docs/AUTOMATIONS.md +19 -3
- package/docs/PRIVACY.md +1 -1
- package/package.json +1 -1
- package/whatsapp-web/auto-reply.mjs +241 -9
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
|
|
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
|
package/bin/digital-brain.js
CHANGED
|
@@ -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
|
}
|
package/docs/AUTOMATIONS.md
CHANGED
|
@@ -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
|
|
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,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 (!
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
}
|