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 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).
@@ -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
  }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "digital-brain",
3
- "version": "1.1.26",
3
+ "version": "1.1.30",
4
4
  "description": "Your private digital imprint for AI assistants.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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.isGroup && !includeGroups) {
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?.[chatKey(chatOrName)]);
714
+ return chatKeys(chatOrName).some((key) => Boolean(whitelist.allowedChats?.[key]));
654
715
  }
655
716
 
656
717
  function isChatDenied(chatOrName) {
657
- return Boolean(whitelist.deniedChats?.[chatKey(chatOrName)]);
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
- if (typeof chatOrName === "string") return `name:${chatOrName.toLowerCase()}`;
680
- return chatOrName.id?._serialized || `name:${chatName(chatOrName).toLowerCase()}`;
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
  }