digital-brain 1.1.26 → 1.1.30
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 +4 -0
- package/bin/digital-brain.js +62 -0
- package/docs/AUTOMATIONS.md +14 -0
- package/package.json +1 -1
- package/whatsapp-web/auto-reply.mjs +130 -7
package/README.md
CHANGED
|
@@ -102,8 +102,12 @@ digital-brain auto-whatsapp --contact "+15551234567" --model llama3.1
|
|
|
102
102
|
OPENAI_API_KEY="sk-..." digital-brain auto-whatsapp --allow-all --provider openai --model gpt-4.1-mini --yes
|
|
103
103
|
digital-brain auto-whatsapp --allow-all --provider codex --yes
|
|
104
104
|
digital-brain auto-whatsapp --allow-all --provider codex-app --yes
|
|
105
|
+
digital-brain pause-whatsapp
|
|
106
|
+
digital-brain resume-whatsapp --chat "Name"
|
|
105
107
|
```
|
|
106
108
|
|
|
109
|
+
While `auto-whatsapp` is running in a focused terminal, press `Space` to pause/resume globally.
|
|
110
|
+
|
|
107
111
|
`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.
|
|
108
112
|
|
|
109
113
|
Slack and LinkedIn are import-based. Digital Brain reads official export archives; it does not scrape LinkedIn or automate private app UIs. See [docs/INTEGRATIONS.md](docs/INTEGRATIONS.md).
|
package/bin/digital-brain.js
CHANGED
|
@@ -31,6 +31,9 @@ async function main() {
|
|
|
31
31
|
else if (command === "interpret") runPython("digital_brain_relationship_interpreter.py", argv);
|
|
32
32
|
else if (command === "send-whatsapp") runNode("whatsapp-web/send.mjs", argv);
|
|
33
33
|
else if (command === "auto-whatsapp") runNode("whatsapp-web/auto-reply.mjs", argv);
|
|
34
|
+
else if (command === "pause-whatsapp") pauseWhatsApp(argv, args);
|
|
35
|
+
else if (command === "resume-whatsapp") resumeWhatsApp(argv, args);
|
|
36
|
+
else if (command === "whatsapp-status") whatsappStatus(argv);
|
|
34
37
|
else help();
|
|
35
38
|
}
|
|
36
39
|
|
|
@@ -265,6 +268,62 @@ function runNode(script, argv) {
|
|
|
265
268
|
process.exit(result.status ?? 1);
|
|
266
269
|
}
|
|
267
270
|
|
|
271
|
+
function pauseWhatsApp(argv, args) {
|
|
272
|
+
const vault = getVaultFromArgs(argv);
|
|
273
|
+
const file = pauseFile(vault);
|
|
274
|
+
ensureDir(path.dirname(file));
|
|
275
|
+
const state = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : { schemaVersion: 1, paused: false, pausedChats: {} };
|
|
276
|
+
state.schemaVersion = 1;
|
|
277
|
+
state.pausedChats ||= {};
|
|
278
|
+
const chat = args.chat || args.name || "";
|
|
279
|
+
if (chat) {
|
|
280
|
+
state.pausedChats[`name:${chat.toLowerCase()}`] = {
|
|
281
|
+
chatName: chat,
|
|
282
|
+
reason: args.reason || "",
|
|
283
|
+
updatedAt: new Date().toISOString(),
|
|
284
|
+
};
|
|
285
|
+
console.log(`Paused WhatsApp auto-replies for: ${chat}`);
|
|
286
|
+
} else {
|
|
287
|
+
state.paused = true;
|
|
288
|
+
state.reason = args.reason || "";
|
|
289
|
+
state.updatedAt = new Date().toISOString();
|
|
290
|
+
console.log("Paused WhatsApp auto-replies globally.");
|
|
291
|
+
}
|
|
292
|
+
writeFileAtomic(file, `${JSON.stringify(state, null, 2)}\n`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function resumeWhatsApp(argv, args) {
|
|
296
|
+
const vault = getVaultFromArgs(argv);
|
|
297
|
+
const file = pauseFile(vault);
|
|
298
|
+
const state = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : { schemaVersion: 1, paused: false, pausedChats: {} };
|
|
299
|
+
state.schemaVersion = 1;
|
|
300
|
+
state.pausedChats ||= {};
|
|
301
|
+
const chat = args.chat || args.name || "";
|
|
302
|
+
if (chat) {
|
|
303
|
+
delete state.pausedChats[`name:${chat.toLowerCase()}`];
|
|
304
|
+
console.log(`Resumed WhatsApp auto-replies for: ${chat}`);
|
|
305
|
+
} else {
|
|
306
|
+
state.paused = false;
|
|
307
|
+
state.reason = "";
|
|
308
|
+
state.updatedAt = new Date().toISOString();
|
|
309
|
+
console.log("Resumed WhatsApp auto-replies globally.");
|
|
310
|
+
}
|
|
311
|
+
writeFileAtomic(file, `${JSON.stringify(state, null, 2)}\n`);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function whatsappStatus(argv) {
|
|
315
|
+
const vault = getVaultFromArgs(argv);
|
|
316
|
+
const file = pauseFile(vault);
|
|
317
|
+
const state = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : { paused: false, pausedChats: {} };
|
|
318
|
+
console.log(`WhatsApp auto-reply: ${state.paused ? "paused" : "running/not globally paused"}`);
|
|
319
|
+
const pausedChats = Object.values(state.pausedChats || {});
|
|
320
|
+
if (pausedChats.length) console.log(`Paused chats: ${pausedChats.map((chat) => chat.chatName).join(", ")}`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function pauseFile(vault) {
|
|
324
|
+
return path.join(vault, "08 Sources", "WhatsApp", "Outbound", "auto-reply-pause.json");
|
|
325
|
+
}
|
|
326
|
+
|
|
268
327
|
function withVault(argv) {
|
|
269
328
|
if (argv.includes("--vault") || argv.some((arg) => arg.startsWith("--vault="))) return argv;
|
|
270
329
|
return ["--vault", getVaultFromArgs(argv), ...argv];
|
|
@@ -688,5 +747,8 @@ Usage:
|
|
|
688
747
|
digital-brain interpret --days 30
|
|
689
748
|
digital-brain send-whatsapp --to "Name" --message "Text" [--yes]
|
|
690
749
|
digital-brain auto-whatsapp --allow "Name" --contact "+15551234567" --provider ollama|openai|codex|codex-app --model llama3.1 [--yes] [--no-process-unread]
|
|
750
|
+
digital-brain pause-whatsapp [--chat "Name"]
|
|
751
|
+
digital-brain resume-whatsapp [--chat "Name"]
|
|
752
|
+
digital-brain whatsapp-status
|
|
691
753
|
`);
|
|
692
754
|
}
|
package/docs/AUTOMATIONS.md
CHANGED
|
@@ -142,6 +142,18 @@ digital-brain auto-whatsapp --allow "Mom" --provider codex --codex-command "code
|
|
|
142
142
|
|
|
143
143
|
`--provider openai` sends the same Digital Brain prompt and relevant vault context to the OpenAI API. Set `OPENAI_API_KEY`, pass `--openai-api-key`, or choose OpenAI during `digital-brain init` and paste the key into the local vault config.
|
|
144
144
|
|
|
145
|
+
Pause/resume while `auto-whatsapp` is running:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
digital-brain pause-whatsapp
|
|
149
|
+
digital-brain resume-whatsapp
|
|
150
|
+
digital-brain pause-whatsapp --chat "Mom"
|
|
151
|
+
digital-brain resume-whatsapp --chat "Mom"
|
|
152
|
+
digital-brain whatsapp-status
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The running bot checks the pause file before each reply, so these commands do not require restarting the bot. When the `auto-whatsapp` terminal is focused, press `Space` to pause/resume globally and `Ctrl+C` to exit.
|
|
156
|
+
|
|
145
157
|
`--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.
|
|
146
158
|
|
|
147
159
|
`--provider codex-app` does not use the Codex CLI. It writes request JSON files to `08 Sources/WhatsApp/Outbound/Codex App Bridge/requests` and waits for response JSON files in `responses`. A Codex desktop automation or live Codex thread must process those request files and write `{"reply":"..."}` to the provided `responsePath`.
|
|
@@ -164,6 +176,8 @@ Guardrails:
|
|
|
164
176
|
- requires `--allow "Name"` or `--contact "+15551234567"` unless `--allow-all` is explicitly passed
|
|
165
177
|
- single-threads reply generation so multiple incoming chats do not trigger overlapping sends
|
|
166
178
|
- debounces live messages per chat, default 12000ms, so message bursts get one combined reply; override with `--reply-debounce-ms <ms>`
|
|
179
|
+
- supports global and per-chat pause with `pause-whatsapp` / `resume-whatsapp`
|
|
180
|
+
- supports a focused-terminal hotkey: `Space` toggles global pause/resume
|
|
167
181
|
- skips likely business, notification, OTP, and service chats unless `--include-businesses` is passed or the chat is explicitly allowlisted by name or contact number
|
|
168
182
|
- processes unread chats on startup unless `--no-process-unread` is passed
|
|
169
183
|
- skips groups unless `--include-groups` is passed
|
package/package.json
CHANGED
|
@@ -20,6 +20,7 @@ const outboundDir = path.join(whatsAppDir, "Outbound");
|
|
|
20
20
|
const sessionDir = path.join(whatsAppDir, ".session");
|
|
21
21
|
const statePath = path.join(outboundDir, "auto-reply-state.json");
|
|
22
22
|
const whitelistPath = path.join(outboundDir, "auto-reply-whitelist.json");
|
|
23
|
+
const pausePath = path.join(outboundDir, "auto-reply-pause.json");
|
|
23
24
|
const codexAppBridgeDir = path.join(outboundDir, "Codex App Bridge");
|
|
24
25
|
const config = readConfig(vault);
|
|
25
26
|
const provider = args.provider || config.autoReplyProvider || "ollama";
|
|
@@ -51,6 +52,7 @@ const interactiveTerminal = Boolean(input.isTTY && output.isTTY);
|
|
|
51
52
|
let queueTail = Promise.resolve();
|
|
52
53
|
const pendingLiveReplies = new Map();
|
|
53
54
|
const rl = interactiveTerminal ? readline.createInterface({ input, output }) : null;
|
|
55
|
+
let keyboardControlsStarted = false;
|
|
54
56
|
|
|
55
57
|
fs.mkdirSync(outboundDir, { recursive: true });
|
|
56
58
|
|
|
@@ -92,6 +94,7 @@ client.on("ready", async () => {
|
|
|
92
94
|
if (!includeBusinesses) console.log("Likely business, notification, OTP, and service chats are skipped by default.");
|
|
93
95
|
console.log(maxRepliesPerChat > 0 ? `Reply cap: ${maxRepliesPerChat} per chat per run.` : "Reply cap: unlimited.");
|
|
94
96
|
console.log(`Live reply debounce: ${replyDebounceMs}ms.`);
|
|
97
|
+
startKeyboardControls();
|
|
95
98
|
try {
|
|
96
99
|
if (processUnreadOnStart) {
|
|
97
100
|
await processUnreadChats();
|
|
@@ -121,7 +124,7 @@ async function processUnreadChats() {
|
|
|
121
124
|
console.log(`Startup unread scan: ${unreadChats.length} unread chat(s).`);
|
|
122
125
|
for (const chat of unreadChats) {
|
|
123
126
|
const name = chatName(chat);
|
|
124
|
-
if (chat.isGroup && !includeGroups) {
|
|
127
|
+
if (chat.isGroup && !includeGroups && !isChatWhitelisted(chat)) {
|
|
125
128
|
console.log(`Skipping unread group chat: ${name}`);
|
|
126
129
|
continue;
|
|
127
130
|
}
|
|
@@ -247,7 +250,12 @@ async function handleMessage(message, knownChat = null) {
|
|
|
247
250
|
const chat = knownChat || await message.getChat();
|
|
248
251
|
const name = chatName(chat);
|
|
249
252
|
console.log(`Received from ${name}: ${summarize(message.body || "[non-text message]")}`);
|
|
250
|
-
if (chat
|
|
253
|
+
if (isAutoReplyPaused(chat)) {
|
|
254
|
+
console.log(`Skipping paused chat: ${name}`);
|
|
255
|
+
markProcessed(message, name, { sent: false });
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (chat.isGroup && !includeGroups && !isChatWhitelisted(chat)) {
|
|
251
259
|
console.log(`Skipping group chat: ${name}`);
|
|
252
260
|
return;
|
|
253
261
|
}
|
|
@@ -288,7 +296,7 @@ async function handleMessage(message, knownChat = null) {
|
|
|
288
296
|
});
|
|
289
297
|
console.log(`Generating reply for ${name} with ${provider}${provider === "ollama" ? `:${model}` : ""}...`);
|
|
290
298
|
const startedAt = Date.now();
|
|
291
|
-
const reply = await generateReply(prompt);
|
|
299
|
+
const reply = matchPunctuationStyle(await generateReply(prompt), recentMessages);
|
|
292
300
|
console.log(`Generated reply for ${name} in ${Date.now() - startedAt}ms: ${summarize(reply || "[empty reply]")}`);
|
|
293
301
|
const finalReply = disclosure.required && !containsDisclosure(reply)
|
|
294
302
|
? `btw ai is helping me reply rn. ${reply}`
|
|
@@ -331,6 +339,7 @@ function buildPrompt({ chatName, incomingBody, recentMessages, disclosureRequire
|
|
|
331
339
|
"Primary style source: the user's recent messages in this exact chat. Match their length, casing, bluntness, and punctuation from those examples.",
|
|
332
340
|
"Secondary style source: My Communication Style. Use it only to break ties, not to add extra slang.",
|
|
333
341
|
"Do not perform a persona. Do not intensify the tone beyond the user's examples.",
|
|
342
|
+
"Avoid polished punctuation. Do not add commas, apostrophes, semicolons, or final periods unless the user's recent examples use them.",
|
|
334
343
|
"Do not force bro, lol, haha, lmao, wild, rn, emojis, or question marks. Use them only if the user's recent examples in this chat use them naturally.",
|
|
335
344
|
"Avoid assistant-like niceness and filler such as sounds perfect, happy to, sure thing, smooth, quick, no worries, no demon stuff, digital prep chef, digital neil, spitting facts, living in the future, or let’s unless that exact energy is already in the chat.",
|
|
336
345
|
"If the recipient asks about AI, answer directly in the user's casual tone and do not overexplain.",
|
|
@@ -566,6 +575,58 @@ function cleanReply(value) {
|
|
|
566
575
|
.slice(0, 1200);
|
|
567
576
|
}
|
|
568
577
|
|
|
578
|
+
function matchPunctuationStyle(reply, recentMessages) {
|
|
579
|
+
const exampleMessages = recentMessages
|
|
580
|
+
.filter((item) => item.fromMe && compact(item.body || ""))
|
|
581
|
+
.map((item) => item.body || "");
|
|
582
|
+
const local = punctuationProfile(exampleMessages);
|
|
583
|
+
const global = readSelfPunctuationStyle();
|
|
584
|
+
let output = reply;
|
|
585
|
+
if (!prefersPunctuation(local, global, "apostrophe")) output = output.replace(/['’]/g, "");
|
|
586
|
+
if (!prefersPunctuation(local, global, "semicolon")) output = output.replace(/;/g, "");
|
|
587
|
+
if (!prefersPunctuation(local, global, "period")) output = output.replace(/\.+$/g, "");
|
|
588
|
+
if (!prefersPunctuation(local, global, "comma")) output = output.replace(/,/g, "");
|
|
589
|
+
return output.replace(/\s+/g, " ").trim();
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function punctuationProfile(messages) {
|
|
593
|
+
const text = messages.join(" ");
|
|
594
|
+
const chars = text.length;
|
|
595
|
+
const count = (pattern) => (text.match(pattern) || []).length;
|
|
596
|
+
return {
|
|
597
|
+
hasLocalEvidence: messages.length >= 3 || chars >= 80,
|
|
598
|
+
apostropheRate: chars ? count(/['’]/g) / chars : 0,
|
|
599
|
+
commaRate: chars ? count(/,/g) / chars : 0,
|
|
600
|
+
semicolonRate: chars ? count(/;/g) / chars : 0,
|
|
601
|
+
periodRate: chars ? count(/\./g) / chars : 0,
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function prefersPunctuation(local, global, kind) {
|
|
606
|
+
const field = `${kind}Rate`;
|
|
607
|
+
if (local.hasLocalEvidence) return local[field] > 0.001;
|
|
608
|
+
if (kind === "period" && global.noTerminalPunctuationShare >= 0.7) return false;
|
|
609
|
+
return global[field] > 0.001;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function readSelfPunctuationStyle() {
|
|
613
|
+
const profilePath = path.join(vault, "08 Sources", "Analysis", "self_profile.json");
|
|
614
|
+
if (!fs.existsSync(profilePath)) return {};
|
|
615
|
+
const profile = parseJsonLine(fs.readFileSync(profilePath, "utf8")) || {};
|
|
616
|
+
const habits = profile.lexicalProfile?.punctuationHabits || [];
|
|
617
|
+
const noTerminal = habits
|
|
618
|
+
.map((habit) => String(habit).match(/no terminal punctuation \(([\d.]+)\)/))
|
|
619
|
+
.find(Boolean);
|
|
620
|
+
const contractions = profile.lexicalProfile?.contractions || [];
|
|
621
|
+
return {
|
|
622
|
+
apostropheRate: contractions.length ? 0.01 : 0,
|
|
623
|
+
commaRate: 0,
|
|
624
|
+
semicolonRate: 0,
|
|
625
|
+
periodRate: noTerminal && Number(noTerminal[1]) >= 0.7 ? 0 : 0.01,
|
|
626
|
+
noTerminalPunctuationShare: noTerminal ? Number(noTerminal[1]) : 0,
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
569
630
|
function isAllowed(chatOrName) {
|
|
570
631
|
if (isChatDenied(chatOrName)) return false;
|
|
571
632
|
if (isChatWhitelisted(chatOrName)) return true;
|
|
@@ -650,11 +711,65 @@ function allowlistSummary() {
|
|
|
650
711
|
}
|
|
651
712
|
|
|
652
713
|
function isChatWhitelisted(chatOrName) {
|
|
653
|
-
return Boolean(whitelist.allowedChats?.[
|
|
714
|
+
return chatKeys(chatOrName).some((key) => Boolean(whitelist.allowedChats?.[key]));
|
|
654
715
|
}
|
|
655
716
|
|
|
656
717
|
function isChatDenied(chatOrName) {
|
|
657
|
-
return Boolean(whitelist.deniedChats?.[
|
|
718
|
+
return chatKeys(chatOrName).some((key) => Boolean(whitelist.deniedChats?.[key]));
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function isAutoReplyPaused(chatOrName) {
|
|
722
|
+
const pause = readPauseState();
|
|
723
|
+
if (pause.paused) return true;
|
|
724
|
+
return chatKeys(chatOrName).some((key) => Boolean(pause.pausedChats?.[key]));
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function readPauseState() {
|
|
728
|
+
if (!fs.existsSync(pausePath)) return { schemaVersion: 1, paused: false, pausedChats: {} };
|
|
729
|
+
try {
|
|
730
|
+
const pause = JSON.parse(fs.readFileSync(pausePath, "utf8"));
|
|
731
|
+
return {
|
|
732
|
+
schemaVersion: 1,
|
|
733
|
+
paused: Boolean(pause.paused),
|
|
734
|
+
pausedChats: pause.pausedChats || {},
|
|
735
|
+
};
|
|
736
|
+
} catch {
|
|
737
|
+
return { schemaVersion: 1, paused: false, pausedChats: {} };
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function writePauseState(pause) {
|
|
742
|
+
writeJsonAtomic(pausePath, {
|
|
743
|
+
schemaVersion: 1,
|
|
744
|
+
paused: Boolean(pause.paused),
|
|
745
|
+
reason: pause.reason || "",
|
|
746
|
+
updatedAt: new Date().toISOString(),
|
|
747
|
+
pausedChats: pause.pausedChats || {},
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function toggleGlobalPause() {
|
|
752
|
+
const pause = readPauseState();
|
|
753
|
+
pause.paused = !pause.paused;
|
|
754
|
+
pause.reason = pause.paused ? "keyboard-space" : "";
|
|
755
|
+
writePauseState(pause);
|
|
756
|
+
return pause.paused;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function startKeyboardControls() {
|
|
760
|
+
if (keyboardControlsStarted || !interactiveTerminal || typeof input.setRawMode !== "function") return;
|
|
761
|
+
keyboardControlsStarted = true;
|
|
762
|
+
input.setRawMode(true);
|
|
763
|
+
input.resume();
|
|
764
|
+
input.on("data", async (chunk) => {
|
|
765
|
+
const value = chunk.toString("utf8");
|
|
766
|
+
if (value === "\u0003") await shutdown(0);
|
|
767
|
+
if (value === " ") {
|
|
768
|
+
const paused = toggleGlobalPause();
|
|
769
|
+
console.log(paused ? "Paused WhatsApp auto-replies. Press Space to resume." : "Resumed WhatsApp auto-replies. Press Space to pause.");
|
|
770
|
+
}
|
|
771
|
+
});
|
|
772
|
+
console.log("Keyboard controls: Space toggles pause/resume. Ctrl+C exits.");
|
|
658
773
|
}
|
|
659
774
|
|
|
660
775
|
function setWhitelistDecision(chat, decision, source) {
|
|
@@ -676,8 +791,15 @@ function setWhitelistDecision(chat, decision, source) {
|
|
|
676
791
|
}
|
|
677
792
|
|
|
678
793
|
function chatKey(chatOrName) {
|
|
679
|
-
|
|
680
|
-
|
|
794
|
+
return chatKeys(chatOrName)[0];
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function chatKeys(chatOrName) {
|
|
798
|
+
if (typeof chatOrName === "string") return [`name:${chatOrName.toLowerCase()}`];
|
|
799
|
+
return [
|
|
800
|
+
chatOrName.id?._serialized,
|
|
801
|
+
`name:${chatName(chatOrName).toLowerCase()}`,
|
|
802
|
+
].filter(Boolean);
|
|
681
803
|
}
|
|
682
804
|
|
|
683
805
|
function loadWhitelist() {
|
|
@@ -837,6 +959,7 @@ function shellQuote(value) {
|
|
|
837
959
|
|
|
838
960
|
async function shutdown(code) {
|
|
839
961
|
if (rl) rl.close();
|
|
962
|
+
if (typeof input.setRawMode === "function") input.setRawMode(false);
|
|
840
963
|
await client.destroy().catch(() => undefined);
|
|
841
964
|
process.exit(code);
|
|
842
965
|
}
|