digital-brain 1.0.5 → 1.1.9

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
@@ -78,10 +78,12 @@ node ./bin/digital-brain.js init ./Digital Brain\ Vault
78
78
  - Merge confirmed-looking same-person profiles across sources into a person context index.
79
79
  - Infer provisional roles like parent, family group, work collaborator, close personal contact, or unlabeled contact.
80
80
  - Extract relationship-specific typing style: casing, message length, punctuation, emoji, and slang.
81
+ - Extract your own outbound communication style so drafts can match your casing, slang, punctuation, lexical patterns, and common phrase shapes.
81
82
  - Generate "how to continue this relationship" notes.
82
83
  - Generate reply-ready person context that keeps WhatsApp, iMessage, Slack, and LinkedIn evidence separate under the same person.
83
84
  - Create AI-readable memory files for future prompts.
84
- - Draft WhatsApp sends by default, and only send with explicit `--yes`.
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.
85
87
  - Enforce an AI-disclosure guard after repeated AI-assisted sends.
86
88
 
87
89
  ## Core Commands
@@ -94,6 +96,8 @@ digital-brain sync-imessage --days 30
94
96
  digital-brain import-slack --input ./slack-export.zip
95
97
  digital-brain import-linkedin --input ./linkedin-archive.zip
96
98
  digital-brain send-whatsapp --to "Name" --message "text"
99
+ digital-brain auto-whatsapp --allow "Name" --model llama3.1
100
+ digital-brain auto-whatsapp --contact "+15551234567" --model llama3.1
97
101
  ```
98
102
 
99
103
  `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.
@@ -113,7 +117,18 @@ digital-brain interpret
113
117
 
114
118
  The sender drafts by default. Add `--yes` to actually send.
115
119
 
116
- 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 unless you explicitly bypass the check.
120
+ The auto-responder is opt-in and runs only while the command is active. On startup it scans unread WhatsApp Web chats, then listens for new messages. It requires an allowlist unless you explicitly pass `--allow-all`. Without `--yes`, it only logs drafts unless you selected `Auto-send while running` during init:
121
+
122
+ ```bash
123
+ digital-brain auto-whatsapp --allow "Mom" --model llama3.1
124
+ digital-brain auto-whatsapp --allow "Mom" --model llama3.1 --yes
125
+ digital-brain auto-whatsapp --allow "Mom" --model llama3.1 --yes --no-process-unread
126
+ digital-brain auto-whatsapp --contact "+15551234567" --model llama3.1 --yes
127
+ ```
128
+
129
+ 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
+
131
+ 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.
117
132
 
118
133
  ## Automation
119
134
 
@@ -30,6 +30,7 @@ async function main() {
30
30
  else if (command === "extract") runPython("digital_brain_relationship_extractor.py", argv);
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
+ else if (command === "auto-whatsapp") runNode("whatsapp-web/auto-reply.mjs", argv);
33
34
  else help();
34
35
  }
35
36
 
@@ -51,7 +52,7 @@ async function init(argv, args) {
51
52
  let privacyMode = args["privacy-mode"] || "standard";
52
53
  let sourceMarkdownMode = args["source-markdown-mode"] || "none";
53
54
  let selectedSources = parseList(args.sources || "whatsapp");
54
- let responsibilityAccepted = fullAuto || schedule === "always-on";
55
+ let responsibilityAccepted = toBoolean(args["responsibility-accepted"]) || fullAuto || schedule === "always-on";
55
56
 
56
57
  if (!args.yes) {
57
58
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
@@ -102,10 +103,11 @@ async function init(argv, args) {
102
103
  ["disabled", "Disabled", "Never prepares WhatsApp sends.", "🔒"],
103
104
  ["draft", "Draft only", "Prepares text and requires you to send it.", "✍️"],
104
105
  ["send-with-confirmation", "Send with confirmation", "Can send only after explicit command confirmation.", "✅"],
106
+ ["auto-send", "Auto-send while running", "Lets auto-whatsapp send from allowlisted chats while it is running.", "🚦"],
105
107
  ], outboundMode);
106
108
  connectAi = await confirm(rl, "🔗 Add global AI pointers for Codex/Claude/Gemini?", true);
107
109
  responsibilityAccepted = await responsibilityGate(rl, { schedule, outboundMode });
108
- if (!responsibilityAccepted && (schedule === "always-on" || outboundMode === "send-with-confirmation")) {
110
+ if (!responsibilityAccepted && needsResponsibilityGate({ schedule, outboundMode })) {
109
111
  console.log("Full-auto/outbound confirmation was not accepted. Using manual refresh and draft-only outbound.");
110
112
  schedule = "manual";
111
113
  outboundMode = "draft";
@@ -114,6 +116,10 @@ async function init(argv, args) {
114
116
  rl.close();
115
117
  }
116
118
 
119
+ if (args.yes && needsResponsibilityGate({ schedule, outboundMode }) && !responsibilityAccepted) {
120
+ throw new Error("This mode requires explicit responsibility acceptance. Re-run with --responsibility-accepted=true or use draft mode.");
121
+ }
122
+
117
123
  ensureDir(vault);
118
124
  copyDir(path.join(root, "templates", "vault"), vault);
119
125
  const config = {
@@ -502,7 +508,7 @@ async function confirm(rl, label, fallback) {
502
508
  }
503
509
 
504
510
  async function responsibilityGate(rl, { schedule, outboundMode }) {
505
- const needsGate = schedule === "always-on" || outboundMode === "send-with-confirmation";
511
+ const needsGate = needsResponsibilityGate({ schedule, outboundMode });
506
512
  if (!needsGate) return true;
507
513
  console.log("");
508
514
  console.log("⚠️ Responsibility check:");
@@ -512,6 +518,10 @@ async function responsibilityGate(rl, { schedule, outboundMode }) {
512
518
  return confirm(rl, "I understand and want this mode enabled", false);
513
519
  }
514
520
 
521
+ function needsResponsibilityGate({ schedule, outboundMode }) {
522
+ return schedule === "always-on" || ["send-with-confirmation", "auto-send"].includes(outboundMode);
523
+ }
524
+
515
525
  function letterFor(index) {
516
526
  return String.fromCharCode(65 + index);
517
527
  }
@@ -583,5 +593,6 @@ Usage:
583
593
  digital-brain extract --days 30
584
594
  digital-brain interpret --days 30
585
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]
586
597
  `);
587
598
  }
@@ -45,6 +45,7 @@ Important defaults:
45
45
  - skipped always-on interval uses 5 minutes, with a hard minimum of 1 minute
46
46
  - skipped active window uses `08:00-12:00`
47
47
  - skipped outbound mode uses draft-only
48
+ - auto-send mode can be selected during init, but only after the responsibility check
48
49
  - skipped AI pointers are added during the guided quiz
49
50
 
50
51
  The answers are saved in:
@@ -63,7 +64,7 @@ digital-brain init --full-auto
63
64
 
64
65
  Auto mode configures local always-on refreshes with a 5 minute default interval. It still uses the local watch script, so it only runs while the machine and runner are awake.
65
66
 
66
- During the guided quiz, always-on and send-with-confirmation require an explicit responsibility check. Pressing Enter does not approve that check. If it is skipped, Digital Brain falls back to manual refresh and draft-only outbound.
67
+ During the guided quiz, always-on, send-with-confirmation, and auto-send require an explicit responsibility check. Pressing Enter does not approve that check. If it is skipped, Digital Brain falls back to manual refresh and draft-only outbound.
67
68
 
68
69
  ## Codex App
69
70
 
@@ -93,6 +94,51 @@ For 24/7 local polling, run:
93
94
 
94
95
  The generated script loops forever and sleeps for `refreshIntervalMinutes`. The minimum supported interval is 1 minute. A practical default is 5 minutes.
95
96
 
97
+ ## WhatsApp Auto-Reply
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.
100
+
101
+ Draft-only:
102
+
103
+ ```bash
104
+ digital-brain auto-whatsapp --allow "Mom" --model llama3.1
105
+ digital-brain auto-whatsapp --contact "+15551234567" --model llama3.1
106
+ ```
107
+
108
+ Auto-send while the command is running:
109
+
110
+ ```bash
111
+ digital-brain auto-whatsapp --allow "Mom" --model llama3.1 --yes
112
+ digital-brain auto-whatsapp --contact "+15551234567" --model llama3.1 --yes
113
+ ```
114
+
115
+ Broad auto-send for personal chats:
116
+
117
+ ```bash
118
+ digital-brain auto-whatsapp --allow-all --model llama3.1 --yes
119
+ ```
120
+
121
+ `--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
+
123
+ If you selected `Auto-send while running` during init, `auto-whatsapp` can send without `--yes` while it is running:
124
+
125
+ ```bash
126
+ digital-brain auto-whatsapp --allow "Mom" --model llama3.1
127
+ ```
128
+
129
+ Guardrails:
130
+
131
+ - requires Ollama running locally
132
+ - requires the selected model, for example `ollama pull llama3.1`
133
+ - requires `--allow "Name"` or `--contact "+15551234567"` unless `--allow-all` is explicitly passed
134
+ - 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
+ - processes unread chats on startup unless `--no-process-unread` is passed
136
+ - skips groups unless `--include-groups` is passed
137
+ - uses a per-chat cooldown, default 20 minutes
138
+ - caps replies per chat per run, default 5
139
+ - logs metadata by default, not full sent text
140
+ - enforces the AI disclosure rule after repeated AI-assisted sends, but does not repeat it after that chat has already received a disclosure
141
+
96
142
  ## Local Cron
97
143
 
98
144
  Run every 30 minutes from 8-12:
package/docs/PRIVACY.md CHANGED
@@ -7,6 +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
11
  - Raw source data stays under `08 Sources/`; normal AI context should use `06 AI Memory/` and human notes under `04 People/`.
11
12
  - Same-person matching across sources is provisional and file-based; keep source evidence visible when using merged person context.
12
13
 
@@ -15,6 +16,7 @@ Things to be careful about:
15
16
  - Do not commit a generated vault.
16
17
  - Do not paste private message exports into GitHub issues.
17
18
  - Do not enable outbound sending without understanding the risk.
19
+ - Do not use auto-reply for sensitive, legal, medical, financial, emergency, or high-stakes conversations.
18
20
  - Treat WhatsApp database access, iMessage database access, and WhatsApp Web automation as local, permission-sensitive integrations that can change.
19
21
  - Always-on mode runs on your machine and inherits your local permissions.
20
22
  - You are responsible for consent, privacy, message content, and sends made from your machine.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "digital-brain",
3
- "version": "1.0.5",
3
+ "version": "1.1.9",
4
4
  "description": "Your private digital imprint for AI assistants.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,6 +13,14 @@ NEGATIVE = {"angry", "annoyed", "upset", "sad", "bad", "hate", "sorry", "fight",
13
13
  LOGISTICS = {"when", "where", "time", "today", "tomorrow", "meeting", "call", "send", "sent", "come", "coming", "reach", "book", "plan", "schedule"}
14
14
  WORK = {"pr", "repo", "client", "customer", "meeting", "deck", "code", "ship", "product", "founder", "startup", "work", "office", "investor", "sales", "demo", "launch"}
15
15
  SLANG = {"lol", "lmao", "haha", "hahaha", "bro", "bruh", "wtf", "omg", "ngl", "idk", "rn", "btw", "bc", "pls", "plz", "ya", "yeah", "yep", "nah", "fuck", "shit"}
16
+ DISCOURSE_MARKERS = {"actually", "basically", "bro", "cool", "like", "literally", "no", "okay", "so", "wait", "yeah"}
17
+ STOPWORDS = {
18
+ "a", "about", "all", "am", "an", "and", "are", "as", "at", "be", "been", "but", "by", "can", "could", "did", "do", "does",
19
+ "for", "from", "get", "got", "had", "has", "have", "he", "her", "here", "him", "his", "how", "i", "if", "in", "is", "it",
20
+ "its", "just", "me", "my", "not", "of", "on", "or", "our", "she", "so", "that", "the", "their", "them", "then", "there",
21
+ "they", "this", "to", "too", "up", "was", "we", "were", "what", "when", "where", "who", "why", "will", "with", "would",
22
+ "you", "your",
23
+ }
16
24
 
17
25
 
18
26
  def main():
@@ -24,9 +32,12 @@ def main():
24
32
  messages = load_messages(sources, args.days)
25
33
  profiles = build_profiles(messages, args.min_messages)
26
34
  people = build_people(profiles)
35
+ self_profile = build_self_profile(messages)
27
36
  write_json_atomic(output_dir / "relationship_profiles.json", profiles)
28
37
  write_json_atomic(output_dir / "person_identity_map.json", people)
38
+ write_json_atomic(output_dir / "self_profile.json", self_profile)
29
39
  write_markdown(output_dir / "Relationship Map.md", profiles, args.days)
40
+ write_self_memory(vault / "06 AI Memory" / "My Communication Style.md", self_profile, args.days)
30
41
  write_people_memory(vault / "06 AI Memory" / "Person Context Index.md", people, args.days)
31
42
  write_legacy_whatsapp_outputs(vault, output_dir)
32
43
  print(f"Analyzed {len(messages)} messages.")
@@ -246,6 +257,33 @@ def build_people(profiles):
246
257
  return people
247
258
 
248
259
 
260
+ def build_self_profile(messages):
261
+ outbound = [m for m in messages if m.get("fromMe") and (m.get("body") or "").strip()]
262
+ by_source = defaultdict(list)
263
+ for message in outbound:
264
+ by_source[message.get("sourceSystem") or source_system(message)].append(message)
265
+ dates = [m["_dt"] for m in outbound]
266
+ style = typing_style(outbound)
267
+ lexical = lexical_profile(outbound)
268
+ return {
269
+ "profileType": "self_communication_style",
270
+ "messageCount": len(outbound),
271
+ "firstSeen": min(dates).date().isoformat() if dates else None,
272
+ "lastSeen": max(dates).date().isoformat() if dates else None,
273
+ "sources": sorted(by_source.keys()),
274
+ "sourceBreakdown": {
275
+ source: {
276
+ "messageCount": len(items),
277
+ "typingStyle": typing_style(items),
278
+ }
279
+ for source, items in sorted(by_source.items())
280
+ },
281
+ "typingStyle": style,
282
+ "lexicalProfile": lexical,
283
+ "replyGuidance": self_reply_guidance(style, lexical),
284
+ }
285
+
286
+
249
287
  def write_markdown(path, profiles, days):
250
288
  lines = ["# Relationship Map", "", f"Window: last {days} days", "", "Generated signals. Treat as editable working notes.", ""]
251
289
  for profile in profiles:
@@ -265,6 +303,52 @@ def write_markdown(path, profiles, days):
265
303
  write_text_atomic(path, "\n".join(lines))
266
304
 
267
305
 
306
+ def write_self_memory(path, profile, days):
307
+ path.parent.mkdir(parents=True, exist_ok=True)
308
+ style = profile.get("typingStyle", {})
309
+ lexical = profile.get("lexicalProfile", {})
310
+ lines = [
311
+ "# My Communication Style",
312
+ "",
313
+ f"Window: last {days} days",
314
+ "",
315
+ "Generated from the user's own outbound messages. Treat this as the default voice for drafts and auto-replies.",
316
+ "",
317
+ f"- Outbound sample: {profile.get('messageCount', 0)} messages",
318
+ f"- Sources: {', '.join(profile.get('sources', [])) or 'none'}",
319
+ f"- Dates: {profile.get('firstSeen') or 'n/a'} to {profile.get('lastSeen') or 'n/a'}",
320
+ f"- Signature: {style.get('signature', 'unknown')}",
321
+ f"- Average length: {style.get('avgWords', 0)} words / {style.get('avgChars', 0)} chars",
322
+ f"- Lowercase share: {style.get('lowercaseShare', 0)}",
323
+ f"- Questions: {style.get('questionShare', 0)}",
324
+ f"- Exclamations: {style.get('exclamationShare', 0)}",
325
+ f"- Emoji share: {style.get('emojiShare', 0)}",
326
+ f"- Slang: {', '.join(style.get('slang', [])) or 'none'}",
327
+ "",
328
+ "## Lexical Profile",
329
+ "",
330
+ f"- Vocabulary density: {lexical.get('uniqueTokenShare', 0)} unique-token share",
331
+ f"- Common style words: {', '.join(lexical.get('commonStyleWords', [])) or 'none'}",
332
+ f"- Common content words: {', '.join(lexical.get('topContentWords', [])) or 'none'}",
333
+ f"- Common phrases: {', '.join(lexical.get('commonPhrases', [])) or 'none'}",
334
+ f"- Common openers: {', '.join(lexical.get('messageOpeners', [])) or 'none'}",
335
+ f"- Common endings: {', '.join(lexical.get('messageEndings', [])) or 'none'}",
336
+ f"- Contractions: {', '.join(lexical.get('contractions', [])) or 'none'}",
337
+ f"- Abbreviations: {', '.join(lexical.get('abbreviations', [])) or 'none'}",
338
+ f"- Punctuation habits: {', '.join(lexical.get('punctuationHabits', [])) or 'none'}",
339
+ f"- Lowercase `i` share: {lexical.get('lowercaseIShare', 0)}",
340
+ "",
341
+ "## Reply Guidance",
342
+ "",
343
+ ]
344
+ for item in profile.get("replyGuidance", []):
345
+ lines.append(f"- {item}")
346
+ lines.extend(["", "## Source Breakdown", ""])
347
+ for source, data in profile.get("sourceBreakdown", {}).items():
348
+ lines.append(f"- {source}: {data.get('messageCount', 0)} outbound messages; {typing_style_summary(data.get('typingStyle', {}))}")
349
+ write_text_atomic(path, "\n".join(lines) + "\n")
350
+
351
+
268
352
  def write_people_memory(path, people, days):
269
353
  path.parent.mkdir(parents=True, exist_ok=True)
270
354
  lines = ["# Person Context Index", "", f"Window: last {days} days", "", "Canonical people matched across sources. Treat matches as provisional unless manually confirmed.", ""]
@@ -289,7 +373,7 @@ def write_people_memory(path, people, days):
289
373
  def write_legacy_whatsapp_outputs(vault, output_dir):
290
374
  legacy_dir = vault / "08 Sources" / "WhatsApp" / "Analysis"
291
375
  legacy_dir.mkdir(parents=True, exist_ok=True)
292
- for name in ("relationship_profiles.json", "person_identity_map.json", "Relationship Map.md"):
376
+ for name in ("relationship_profiles.json", "person_identity_map.json", "self_profile.json", "Relationship Map.md"):
293
377
  source = output_dir / name
294
378
  target = legacy_dir / name
295
379
  if source.exists():
@@ -368,6 +452,115 @@ def typing_style(messages):
368
452
  }
369
453
 
370
454
 
455
+ def lexical_profile(messages):
456
+ bodies = [(m.get("body") or "").strip() for m in messages if (m.get("body") or "").strip()]
457
+ tokens_by_body = [tokenize_words(body) for body in bodies]
458
+ tokens = [token for body_tokens in tokens_by_body for token in body_tokens]
459
+ if not bodies or not tokens:
460
+ return empty_lexical_profile()
461
+
462
+ content_counts = Counter(token for token in tokens if token not in STOPWORDS and not token.isdigit() and len(token) > 1)
463
+ style_counts = Counter(token for token in tokens if token in SLANG or token in DISCOURSE_MARKERS)
464
+ bigrams = Counter(ngram for body_tokens in tokens_by_body for ngram in ngrams(body_tokens, 2))
465
+ trigrams = Counter(ngram for body_tokens in tokens_by_body for ngram in ngrams(body_tokens, 3))
466
+ openers = Counter(message_opener(body_tokens) for body_tokens in tokens_by_body if body_tokens)
467
+ endings = Counter(message_ending(body) for body in bodies if message_ending(body))
468
+ contractions = Counter(token for token in tokens if "'" in token)
469
+ abbreviations = Counter(token for token in tokens if token in SLANG or (token.isupper() and len(token) <= 5))
470
+ lowercase_i = sum(1 for body in bodies if re.search(r"(^|[^A-Za-z])i([^A-Za-z]|$)", body))
471
+ uppercase_i = sum(1 for body in bodies if re.search(r"(^|[^A-Za-z])I([^A-Za-z]|$)", body))
472
+
473
+ return {
474
+ "sampleSize": len(bodies),
475
+ "tokenCount": len(tokens),
476
+ "uniqueTokenShare": round(len(set(tokens)) / max(len(tokens), 1), 2),
477
+ "commonStyleWords": top_keys(style_counts, 12),
478
+ "topContentWords": top_keys(content_counts, 12),
479
+ "commonPhrases": top_phrases(bigrams, trigrams),
480
+ "messageOpeners": top_keys(openers, 8),
481
+ "messageEndings": top_keys(endings, 8),
482
+ "contractions": top_keys(contractions, 8),
483
+ "abbreviations": top_keys(abbreviations, 8),
484
+ "punctuationHabits": punctuation_habits(bodies),
485
+ "lowercaseIShare": round(lowercase_i / max(lowercase_i + uppercase_i, 1), 2),
486
+ }
487
+
488
+
489
+ def empty_lexical_profile():
490
+ return {
491
+ "sampleSize": 0,
492
+ "tokenCount": 0,
493
+ "uniqueTokenShare": 0,
494
+ "commonStyleWords": [],
495
+ "topContentWords": [],
496
+ "commonPhrases": [],
497
+ "messageOpeners": [],
498
+ "messageEndings": [],
499
+ "contractions": [],
500
+ "abbreviations": [],
501
+ "punctuationHabits": [],
502
+ "lowercaseIShare": 0,
503
+ }
504
+
505
+
506
+ def tokenize_words(text):
507
+ return re.findall(r"[A-Za-z][A-Za-z']*|\d+", text.lower())
508
+
509
+
510
+ def ngrams(tokens, size):
511
+ return [" ".join(tokens[index:index + size]) for index in range(0, max(len(tokens) - size + 1, 0))]
512
+
513
+
514
+ def message_opener(tokens):
515
+ return " ".join(tokens[:min(3, len(tokens))])
516
+
517
+
518
+ def message_ending(text):
519
+ stripped = text.strip()
520
+ if not stripped:
521
+ return ""
522
+ match = re.search(r"([!?.,]+)$", stripped)
523
+ if match:
524
+ return match.group(1)
525
+ words = tokenize_words(stripped)
526
+ return words[-1] if words else ""
527
+
528
+
529
+ def top_keys(counter, limit):
530
+ return [key for key, _ in counter.most_common(limit)]
531
+
532
+
533
+ def top_phrases(bigrams, trigrams):
534
+ phrases = []
535
+ for counter in (trigrams, bigrams):
536
+ for phrase, count in counter.most_common(10):
537
+ tokens = phrase.split()
538
+ if count < 2 and len(phrases) >= 3:
539
+ continue
540
+ if all(token in STOPWORDS for token in tokens):
541
+ continue
542
+ phrases.append(phrase)
543
+ if len(phrases) >= 8:
544
+ return phrases
545
+ return phrases
546
+
547
+
548
+ def punctuation_habits(bodies):
549
+ habits = []
550
+ checks = [
551
+ ("no terminal punctuation", lambda body: bool(body) and body[-1].isalnum()),
552
+ ("question marks", lambda body: "?" in body),
553
+ ("exclamation marks", lambda body: "!" in body),
554
+ ("ellipsis", lambda body: "..." in body),
555
+ ("repeated punctuation", lambda body: bool(re.search(r"[!?.,]{2,}", body))),
556
+ ]
557
+ for label, predicate in checks:
558
+ share = sum(1 for body in bodies if predicate(body)) / max(len(bodies), 1)
559
+ if share >= 0.2:
560
+ habits.append(f"{label} ({round(share, 2)})")
561
+ return habits
562
+
563
+
371
564
  def typing_style_summary(style):
372
565
  slang = ", ".join(style.get("slang", [])) or "none"
373
566
  return (
@@ -376,6 +569,41 @@ def typing_style_summary(style):
376
569
  )
377
570
 
378
571
 
572
+ def self_reply_guidance(style, lexical=None):
573
+ lexical = lexical or empty_lexical_profile()
574
+ guidance = []
575
+ signature = style.get("signature") or ""
576
+ if style.get("sampleSize", 0) == 0:
577
+ return ["No outbound sample yet; keep replies concise and natural."]
578
+ if style.get("lowercaseShare", 0) >= 0.45 or "lowercase-heavy" in signature:
579
+ guidance.append("Prefer undercapitalized/lowercase casual texting unless the context is formal.")
580
+ if style.get("avgWords", 0) <= 8:
581
+ guidance.append("Keep most replies short. Avoid polished essay-like phrasing.")
582
+ else:
583
+ guidance.append("Longer replies are normal when the topic needs context, but avoid sounding corporate.")
584
+ if style.get("questionShare", 0) >= 0.2:
585
+ guidance.append("It is natural to ask direct follow-up questions.")
586
+ if style.get("exclamationShare", 0) < 0.1:
587
+ guidance.append("Use exclamation marks sparingly.")
588
+ if style.get("emojiShare", 0) < 0.1:
589
+ guidance.append("Do not add emoji unless the recent chat already uses them.")
590
+ slang = style.get("slang", [])
591
+ if slang:
592
+ guidance.append(f"Known casual words/slang from the user's style: {', '.join(slang)}.")
593
+ style_words = lexical.get("commonStyleWords", [])
594
+ phrases = lexical.get("commonPhrases", [])
595
+ openers = lexical.get("messageOpeners", [])
596
+ if style_words:
597
+ guidance.append(f"Prefer the user's recurring style words when natural: {', '.join(style_words[:8])}.")
598
+ if phrases:
599
+ guidance.append(f"Reuse short recurring phrase shapes only when they fit: {', '.join(phrases[:5])}.")
600
+ if openers:
601
+ guidance.append(f"Common message starts include: {', '.join(openers[:5])}.")
602
+ if lexical.get("lowercaseIShare", 0) >= 0.45:
603
+ guidance.append("The user often types lowercase `i`; preserve that in casual replies.")
604
+ return guidance
605
+
606
+
379
607
  def infer_typing_signature(avg_words, lowercase, questions, exclaims, emojis, slang):
380
608
  parts = []
381
609
  if avg_words <= 4:
@@ -7,5 +7,6 @@ Rules:
7
7
  - Prefer local evidence over assumptions.
8
8
  - Be clear when a claim is inferred.
9
9
  - Treat generated relationship notes as provisional.
10
+ - When writing as the user, check `06 AI Memory/My Communication Style.md` for voice, casing, slang, and punctuation.
10
11
  - When replying to a person, check `06 AI Memory/Person Reply Context.md` first.
11
12
  - Do not send messages without explicit permission.
@@ -0,0 +1,5 @@
1
+ # My Communication Style
2
+
3
+ Generated from the user's outbound messages after `digital-brain run`.
4
+
5
+ Use this as the default voice for drafts and auto-replies. It includes typing averages plus lexical patterns like recurring words, openers, phrase shapes, punctuation, and casing. Keep relationship-specific context from `Person Reply Context.md` separate.
@@ -17,4 +17,4 @@ digital-brain send-whatsapp --to "Name" --message "Text" --yes
17
17
  Disclosure rule:
18
18
 
19
19
  - If Digital Brain sends multiple AI-assisted messages in the same chat, it must disclose that AI is helping after roughly 2 messages.
20
- - The sender enforces this on the third AI-assisted send within 24 hours unless the message already includes a disclosure.
20
+ - The sender enforces this on the third AI-assisted send within 24 hours unless the message already includes a disclosure. Once a chat has received that disclosure, Digital Brain will not keep repeating it.
@@ -8,6 +8,7 @@ Before answering personal-context questions, read:
8
8
  - `00 Home/How AI Should Use This Vault.md`
9
9
  - `01 Identity/Working Profile.md`
10
10
  - `06 AI Memory/What AI Should Remember.md`
11
+ - `06 AI Memory/My Communication Style.md`
11
12
  - `06 AI Memory/Person Context Index.md`
12
13
  - `06 AI Memory/Person Reply Context.md`
13
14
  - `06 AI Memory/Interpreted Relationship Memory.md`
@@ -18,6 +19,7 @@ Rules:
18
19
  - Separate facts from interpretation.
19
20
  - Treat relationship labels as editable working notes.
20
21
  - Do not expose private summaries unless asked.
22
+ - For writing as the user, prefer the user's own style profile before generic assistant tone.
21
23
  - For reply help, prefer person-level context over source-specific notes when available.
22
24
  - Do not read `08 Sources/` raw files unless the user explicitly asks you to inspect source data.
23
25
  - Prefer `06 AI Memory/` summaries and `04 People/` human notes for normal context.
@@ -4,6 +4,6 @@ This is a Digital Brain vault. Use it as local personal context when relevant.
4
4
 
5
5
  Start with `00 Home/Start Here.md`.
6
6
 
7
- For reply help, prefer `06 AI Memory/Person Reply Context.md` when available.
7
+ For reply help, prefer `06 AI Memory/My Communication Style.md` for the user's voice and `06 AI Memory/Person Reply Context.md` for the relationship.
8
8
 
9
9
  Do not read `08 Sources/` raw files unless the user explicitly asks for source-level evidence.
@@ -4,6 +4,6 @@ This is a Digital Brain vault. Use it as local personal context when relevant.
4
4
 
5
5
  Start with `00 Home/Start Here.md`.
6
6
 
7
- For reply help, prefer `06 AI Memory/Person Reply Context.md` when available.
7
+ For reply help, prefer `06 AI Memory/My Communication Style.md` for the user's voice and `06 AI Memory/Person Reply Context.md` for the relationship.
8
8
 
9
9
  Do not read `08 Sources/` raw files unless the user explicitly asks for source-level evidence.
@@ -0,0 +1,525 @@
1
+ import fs from "node:fs";
2
+ import crypto from "node:crypto";
3
+ import path from "node:path";
4
+ import qrcode from "qrcode-terminal";
5
+ import pkg from "whatsapp-web.js";
6
+
7
+ const { Client, LocalAuth } = pkg;
8
+ const BUSINESS_NAME_RE = /\b(amazon|flipkart|myntra|nykaa|swiggy|zomato|blinkit|zepto|uber|ola|rapido|delivery|courier|dunzo|paytm|phonepe|gpay|google pay|hdfc|icici|sbi|axis|kotak|amex|bank|airtel|jio|vodafone|vi|support|helpdesk|customer care|official|verified|business|meta ai|whatsapp|no[\s-]?reply|noreply|otp|login|security|alerts?|notifications?|offers?|promo|marketing)\b/i;
9
+ const BUSINESS_BODY_RE = /\b(otp|one time password|verification code|login code|security code|do not share|order|delivered|delivery|shipment|tracking|invoice|payment|transaction|debited|credited|refund|ticket|support|unsubscribe|offer|coupon|sale|valid till|automated message)\b/i;
10
+ const args = parseArgs(process.argv.slice(2));
11
+
12
+ if (!args.vault) usage();
13
+
14
+ const vault = path.resolve(args.vault);
15
+ const whatsAppDir = path.join(vault, "08 Sources", "WhatsApp");
16
+ const outboundDir = path.join(whatsAppDir, "Outbound");
17
+ const sessionDir = path.join(whatsAppDir, ".session");
18
+ const statePath = path.join(outboundDir, "auto-reply-state.json");
19
+ const config = readConfig(vault);
20
+ const model = args.model || config.autoReplyModel || "llama3.1";
21
+ const allow = parseList(args.allow || "");
22
+ const deny = parseList(args.deny || "");
23
+ const contactNumbers = parseList([args.contact, args.phone, args["contact-number"]].filter(Boolean).join(","))
24
+ .map(normalizePhone)
25
+ .filter(Boolean);
26
+ const allowAll = Boolean(args["allow-all"]);
27
+ const includeGroups = Boolean(args["include-groups"]);
28
+ const includeBusinesses = Boolean(args["include-businesses"]);
29
+ const sendEnabled = Boolean(args.yes) || config.outboundMode === "auto-send";
30
+ const processUnreadOnStart = !Boolean(args["no-process-unread"]);
31
+ const cooldownMinutes = numberArg("cooldown-minutes", 20);
32
+ const maxRepliesPerChat = numberArg("max-replies-per-chat", 5);
33
+ const maxContextChars = numberArg("max-context-chars", 12000);
34
+ const outboundLogMode = args["log-mode"] || config.outboundLogMode || "metadata";
35
+ const state = loadState();
36
+
37
+ fs.mkdirSync(outboundDir, { recursive: true });
38
+
39
+ if (config.outboundMode === "disabled") {
40
+ console.error("WhatsApp outbound mode is disabled in this vault. Re-run init or edit digital-brain.config.json before using auto-whatsapp.");
41
+ process.exit(1);
42
+ }
43
+
44
+ if (!allowAll && allow.length === 0 && contactNumbers.length === 0) {
45
+ console.error('Refusing to auto-reply without an allowlist. Add --allow "Name", --contact "+15551234567", or pass --allow-all explicitly.');
46
+ process.exit(1);
47
+ }
48
+
49
+ await assertOllamaModel(model);
50
+
51
+ const client = new Client({
52
+ authStrategy: new LocalAuth({ clientId: "digital-brain", dataPath: sessionDir }),
53
+ puppeteer: { headless: false, args: ["--no-sandbox", "--disable-setuid-sandbox"] },
54
+ });
55
+
56
+ client.on("qr", (qr) => {
57
+ console.log("Scan this QR in WhatsApp > Linked devices:");
58
+ qrcode.generate(qr, { small: true });
59
+ });
60
+
61
+ client.on("ready", async () => {
62
+ console.log(`Digital Brain WhatsApp auto-reply running with Ollama model: ${model}`);
63
+ 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());
65
+ if (!includeBusinesses) console.log("Likely business, notification, OTP, and service chats are skipped by default.");
66
+ try {
67
+ if (processUnreadOnStart) {
68
+ await processUnreadChats();
69
+ } else {
70
+ console.log("Startup unread scan disabled.");
71
+ }
72
+ } catch (error) {
73
+ console.error(`Startup unread scan failed: ${error.message}`);
74
+ }
75
+ console.log("Listening for new WhatsApp messages...");
76
+ });
77
+
78
+ client.on("message", async (message) => {
79
+ try {
80
+ console.log(`Received WhatsApp message event: ${summarize(message.body || "[non-text message]")}`);
81
+ await handleMessage(message);
82
+ } catch (error) {
83
+ console.error(`Auto-reply error: ${error.message}`);
84
+ }
85
+ });
86
+
87
+ client.initialize();
88
+
89
+ async function processUnreadChats() {
90
+ const chats = await client.getChats();
91
+ const unreadChats = chats.filter((chat) => Number(chat.unreadCount || 0) > 0);
92
+ console.log(`Startup unread scan: ${unreadChats.length} unread chat(s).`);
93
+ for (const chat of unreadChats) {
94
+ const name = chatName(chat);
95
+ if (chat.isGroup && !includeGroups) {
96
+ console.log(`Skipping unread group chat: ${name}`);
97
+ continue;
98
+ }
99
+ if (!isAllowed(chat) || isDenied(name)) {
100
+ console.log(`Skipping unread chat outside allowlist: ${name}`);
101
+ continue;
102
+ }
103
+ if (isCoolingDown(name)) {
104
+ console.log(`Skipping unread chat during cooldown: ${name}`);
105
+ continue;
106
+ }
107
+ if (replyCount(name) >= maxRepliesPerChat) {
108
+ console.log(`Skipping unread chat at reply cap: ${name}`);
109
+ continue;
110
+ }
111
+ const recentMessages = await chat.fetchMessages({ limit: Math.max(12, Number(chat.unreadCount || 0) + 3) });
112
+ const latestInbound = recentMessages.filter((message) => !message.fromMe && !message.isStatus).at(-1);
113
+ if (!latestInbound) {
114
+ console.log(`No inbound unread candidate found for: ${name}`);
115
+ continue;
116
+ }
117
+ console.log(`Processing unread chat: ${name}`);
118
+ await handleMessage(latestInbound, chat);
119
+ }
120
+ }
121
+
122
+ async function handleMessage(message, knownChat = null) {
123
+ if (message.fromMe || message.isStatus) return;
124
+ if (state.processedMessageIds.includes(message.id?._serialized)) return;
125
+
126
+ const chat = knownChat || await message.getChat();
127
+ const name = chatName(chat);
128
+ console.log(`Received from ${name}: ${summarize(message.body || "[non-text message]")}`);
129
+ if (chat.isGroup && !includeGroups) {
130
+ console.log(`Skipping group chat: ${name}`);
131
+ return;
132
+ }
133
+ if (!isAllowed(chat)) {
134
+ console.log(`Skipping chat outside allowlist: ${name}`);
135
+ return;
136
+ }
137
+ if (isDenied(name)) {
138
+ console.log(`Skipping denied chat: ${name}`);
139
+ return;
140
+ }
141
+ if (isCoolingDown(name)) {
142
+ console.log(`Skipping chat during cooldown: ${name}`);
143
+ return;
144
+ }
145
+ if (replyCount(name) >= maxRepliesPerChat) {
146
+ console.log(`Skipping chat at reply cap: ${name}`);
147
+ return;
148
+ }
149
+
150
+ console.log(`Fetching context for ${name}...`);
151
+ const recentMessages = await chat.fetchMessages({ limit: 12 });
152
+ if (shouldSkipNonPersonalChat(chat, recentMessages)) {
153
+ console.log(`Skipping likely business/notification chat: ${name}`);
154
+ markProcessed(message, name, { sent: false });
155
+ return;
156
+ }
157
+ const disclosure = disclosureStatus(name);
158
+ const prompt = buildPrompt({
159
+ chatName: name,
160
+ incomingBody: message.body || "",
161
+ recentMessages,
162
+ disclosureRequired: disclosure.required,
163
+ });
164
+ console.log(`Generating reply for ${name} with ${model}...`);
165
+ const startedAt = Date.now();
166
+ const reply = await generateReply(prompt);
167
+ console.log(`Generated reply for ${name} in ${Date.now() - startedAt}ms: ${summarize(reply || "[empty reply]")}`);
168
+ const finalReply = disclosure.required && !containsDisclosure(reply)
169
+ ? `Just flagging this is my AI assistant helping draft/send this. ${reply}`
170
+ : reply;
171
+
172
+ if (!finalReply.trim()) return;
173
+ if (sendEnabled) {
174
+ const sent = await chat.sendMessage(finalReply);
175
+ logSent(name, finalReply, sent, message);
176
+ console.log(`Auto-sent to ${name}: ${summarize(finalReply)}`);
177
+ } else {
178
+ logDraft(name, finalReply, message);
179
+ console.log(`Drafted for ${name}: ${summarize(finalReply)}`);
180
+ }
181
+
182
+ markProcessed(message, name);
183
+ }
184
+
185
+ function buildPrompt({ chatName, incomingBody, recentMessages, disclosureRequired }) {
186
+ const memory = readMemoryContext(chatName);
187
+ const transcript = recentMessages
188
+ .slice(-12)
189
+ .map((item) => `${item.fromMe ? "Me" : chatName}: ${compact(item.body || "")}`)
190
+ .join("\n");
191
+ return [
192
+ "You are helping the user reply on WhatsApp.",
193
+ "Write exactly one message to send as the user.",
194
+ "Be natural, concise, and relationship-appropriate.",
195
+ "Match the user's own communication style from My Communication Style. If it says lowercase-heavy or undercapitalized, prefer lowercase casual texting.",
196
+ "Use lexical signals from My Communication Style: recurring style words, openers, short phrase shapes, punctuation habits, and lowercase-i behavior.",
197
+ "Do not sound like customer support, corporate email, or a generic AI assistant.",
198
+ "Use the user's local memory context, but do not reveal private notes or say you read a vault.",
199
+ "Do not invent facts, commitments, times, or promises.",
200
+ disclosureRequired ? "This send requires AI disclosure. Include a short clear disclosure in the message." : "Do not mention AI unless disclosure is required.",
201
+ "",
202
+ `Chat: ${chatName}`,
203
+ "",
204
+ "Relevant local memory:",
205
+ memory,
206
+ "",
207
+ "Recent chat:",
208
+ transcript,
209
+ "",
210
+ `Incoming message: ${incomingBody}`,
211
+ "",
212
+ "Reply:",
213
+ ].join("\n");
214
+ }
215
+
216
+ function readMemoryContext(chatName) {
217
+ const files = [
218
+ path.join(vault, "06 AI Memory", "My Communication Style.md"),
219
+ path.join(vault, "06 AI Memory", "Person Reply Context.md"),
220
+ path.join(vault, "06 AI Memory", "Person Context Index.md"),
221
+ path.join(vault, "06 AI Memory", "Interpreted Relationship Memory.md"),
222
+ path.join(vault, "06 AI Memory", "What AI Should Remember.md"),
223
+ ];
224
+ const chunks = files
225
+ .filter((file) => fs.existsSync(file))
226
+ .map((file) => `# ${path.basename(file)}\n${fs.readFileSync(file, "utf8")}`);
227
+ const text = chunks.join("\n\n");
228
+ const lower = chatName.toLowerCase();
229
+ const lines = text.split("\n");
230
+ const matchingWindow = [];
231
+ for (let index = 0; index < lines.length; index += 1) {
232
+ if (lines[index].toLowerCase().includes(lower)) {
233
+ matchingWindow.push(lines.slice(Math.max(0, index - 8), index + 18).join("\n"));
234
+ }
235
+ }
236
+ const focused = matchingWindow.join("\n\n") || text;
237
+ return focused.slice(0, maxContextChars);
238
+ }
239
+
240
+ async function generateReply(prompt) {
241
+ const response = await fetch("http://127.0.0.1:11434/api/generate", {
242
+ method: "POST",
243
+ headers: { "content-type": "application/json" },
244
+ body: JSON.stringify({
245
+ model,
246
+ prompt,
247
+ stream: false,
248
+ options: { temperature: 0.35, num_predict: 160 },
249
+ }),
250
+ });
251
+ if (!response.ok) throw new Error(`Ollama generate failed: ${response.status} ${await response.text()}`);
252
+ const body = await response.json();
253
+ return cleanReply(body.response || "");
254
+ }
255
+
256
+ async function assertOllamaModel(modelName) {
257
+ let response;
258
+ try {
259
+ response = await fetch("http://127.0.0.1:11434/api/tags");
260
+ } catch {
261
+ throw new Error("Ollama is not running. Start it with `ollama serve` or open the Ollama app.");
262
+ }
263
+ if (!response.ok) throw new Error(`Ollama tags failed: ${response.status}`);
264
+ const body = await response.json();
265
+ const names = (body.models || []).map((item) => item.name);
266
+ if (!names.some((name) => name === modelName || name.startsWith(`${modelName}:`))) {
267
+ throw new Error(`Ollama model "${modelName}" is not installed. Run: ollama pull ${modelName}`);
268
+ }
269
+ }
270
+
271
+ function cleanReply(value) {
272
+ return String(value)
273
+ .replace(/^["'\s]+|["'\s]+$/g, "")
274
+ .replace(/^Reply:\s*/i, "")
275
+ .split("\n")
276
+ .filter((line) => !/^[-*]\s+/.test(line.trim()))
277
+ .join(" ")
278
+ .replace(/\s+/g, " ")
279
+ .trim()
280
+ .slice(0, 1200);
281
+ }
282
+
283
+ function isAllowed(chatOrName) {
284
+ if (allowAll) return true;
285
+ const name = typeof chatOrName === "string" ? chatOrName : chatName(chatOrName);
286
+ if (contactNumbers.some((number) => chatMatchesContact(chatOrName, number))) return true;
287
+ return allow.some((item) => name.toLowerCase().includes(item.toLowerCase()));
288
+ }
289
+
290
+ function isExplicitlyAllowed(chatOrName) {
291
+ const name = typeof chatOrName === "string" ? chatOrName : chatName(chatOrName);
292
+ if (contactNumbers.some((number) => chatMatchesContact(chatOrName, number))) return true;
293
+ return allow.some((item) => name.toLowerCase().includes(item.toLowerCase()));
294
+ }
295
+
296
+ function isDenied(name) {
297
+ return deny.some((item) => name.toLowerCase().includes(item.toLowerCase()));
298
+ }
299
+
300
+ function shouldSkipNonPersonalChat(chatOrName, recentMessages) {
301
+ if (includeBusinesses || isExplicitlyAllowed(chatOrName)) return false;
302
+ const name = typeof chatOrName === "string" ? chatOrName : chatName(chatOrName);
303
+ return isLikelyBusinessOrAutomation(name, recentMessages);
304
+ }
305
+
306
+ function isLikelyBusinessOrAutomation(name, recentMessages = []) {
307
+ const nameText = normalizeBusinessText(name);
308
+ const recentText = recentMessages
309
+ .slice(-8)
310
+ .map((message) => normalizeBusinessText(message.body || ""))
311
+ .join(" ");
312
+ if (isShortCodeOrServiceNumber(nameText)) return true;
313
+ if (BUSINESS_NAME_RE.test(nameText)) return true;
314
+ if (BUSINESS_BODY_RE.test(recentText)) return true;
315
+ return false;
316
+ }
317
+
318
+ function isShortCodeOrServiceNumber(text) {
319
+ const compacted = text.replace(/\D/g, "");
320
+ return compacted.length >= 4 && compacted.length <= 8 && compacted.length / Math.max(text.length, 1) > 0.7;
321
+ }
322
+
323
+ function normalizeBusinessText(value) {
324
+ return String(value || "").toLowerCase().replace(/\s+/g, " ").trim();
325
+ }
326
+
327
+ function chatMatchesContact(chatOrName, normalizedPhone) {
328
+ if (!normalizedPhone) return false;
329
+ if (typeof chatOrName === "string") {
330
+ return phoneCandidateMatches(normalizePhone(chatOrName), normalizedPhone);
331
+ }
332
+ const candidates = [
333
+ chatOrName.id?.user,
334
+ chatOrName.id?._serialized,
335
+ chatOrName.name,
336
+ chatOrName.formattedTitle,
337
+ ].map(normalizePhone).filter(Boolean);
338
+ return candidates.some((candidate) => phoneCandidateMatches(candidate, normalizedPhone));
339
+ }
340
+
341
+ function phoneCandidateMatches(candidate, expected) {
342
+ if (!candidate || !expected) return false;
343
+ return candidate.endsWith(expected) || expected.endsWith(candidate);
344
+ }
345
+
346
+ function normalizePhone(input) {
347
+ const digits = String(input || "").replace(/[^\d]/g, "");
348
+ return digits.length >= 8 ? digits : "";
349
+ }
350
+
351
+ function maskPhone(value) {
352
+ return value.length <= 4 ? "****" : `****${value.slice(-4)}`;
353
+ }
354
+
355
+ function allowlistSummary() {
356
+ const parts = [];
357
+ if (allow.length) parts.push(`names: ${allow.join(", ")}`);
358
+ if (contactNumbers.length) parts.push(`contacts: ${contactNumbers.map(maskPhone).join(", ")}`);
359
+ return `Allowlist: ${parts.join("; ")}`;
360
+ }
361
+
362
+ function isCoolingDown(name) {
363
+ const last = state.lastSentAtByChat[name];
364
+ if (!last) return false;
365
+ return Date.now() - new Date(last).getTime() < cooldownMinutes * 60 * 1000;
366
+ }
367
+
368
+ function replyCount(name) {
369
+ return state.sentCountByChat[name] || 0;
370
+ }
371
+
372
+ function markProcessed(message, name, options = { sent: true }) {
373
+ state.processedMessageIds.push(message.id?._serialized);
374
+ state.processedMessageIds = state.processedMessageIds.filter(Boolean).slice(-1000);
375
+ if (options.sent !== false) {
376
+ state.lastSentAtByChat[name] = new Date().toISOString();
377
+ state.sentCountByChat[name] = replyCount(name) + 1;
378
+ }
379
+ writeJsonAtomic(statePath, state);
380
+ }
381
+
382
+ function logDraft(name, reply, trigger) {
383
+ const record = baseRecord(name, reply, trigger);
384
+ appendLog("auto-drafts.jsonl", record);
385
+ fs.appendFileSync(path.join(outboundDir, "Auto Drafts.md"), `- ${record.timestamp} | ${name}: ${visibleMessage(record, reply)}\n`);
386
+ }
387
+
388
+ function logSent(name, reply, sent, trigger) {
389
+ const record = {
390
+ ...baseRecord(name, reply, trigger),
391
+ messageId: sent.id?._serialized || null,
392
+ };
393
+ if (outboundLogMode !== "off") {
394
+ appendLog("sent.jsonl", record);
395
+ fs.appendFileSync(path.join(outboundDir, "Sent.md"), `- ${record.timestamp} | ${name}: ${visibleMessage(record, reply)}\n`);
396
+ }
397
+ }
398
+
399
+ function baseRecord(name, reply, trigger) {
400
+ return {
401
+ timestamp: new Date().toISOString(),
402
+ to: name,
403
+ resolvedChatName: name,
404
+ message: outboundLogMode === "full" ? reply : undefined,
405
+ messageHash: hash(reply),
406
+ messageCharCount: reply.length,
407
+ triggerMessageId: trigger.id?._serialized || null,
408
+ aiAssisted: true,
409
+ autoReply: true,
410
+ disclosureIncluded: containsDisclosure(reply),
411
+ disclosureBypassed: false,
412
+ };
413
+ }
414
+
415
+ function appendLog(filename, record) {
416
+ fs.appendFileSync(path.join(outboundDir, filename), `${JSON.stringify(record)}\n`);
417
+ }
418
+
419
+ function disclosureStatus(chatNameValue) {
420
+ const logPath = path.join(outboundDir, "sent.jsonl");
421
+ if (!fs.existsSync(logPath)) return { required: false, count: 0 };
422
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
423
+ const records = fs
424
+ .readFileSync(logPath, "utf8")
425
+ .split("\n")
426
+ .filter(Boolean)
427
+ .map(parseJsonLine)
428
+ .filter((record) => record)
429
+ .filter((record) => record.resolvedChatName === chatNameValue)
430
+ .filter((record) => record.aiAssisted !== false);
431
+ const alreadyDisclosed = records.some((record) => record.disclosureIncluded);
432
+ if (alreadyDisclosed) return { required: false, count: 0, alreadyDisclosed: true };
433
+ const count = records
434
+ .filter((record) => new Date(record.timestamp).getTime() >= cutoff)
435
+ .filter((record) => !record.disclosureIncluded).length;
436
+ return { required: count >= 2, count, alreadyDisclosed: false };
437
+ }
438
+
439
+ function parseJsonLine(line) {
440
+ try {
441
+ return JSON.parse(line);
442
+ } catch {
443
+ return null;
444
+ }
445
+ }
446
+
447
+ function containsDisclosure(message) {
448
+ return /\b(ai|assistant|automated|bot)\b/i.test(message);
449
+ }
450
+
451
+ function loadState() {
452
+ if (!fs.existsSync(statePath)) return { processedMessageIds: [], lastSentAtByChat: {}, sentCountByChat: {} };
453
+ try {
454
+ return JSON.parse(fs.readFileSync(statePath, "utf8"));
455
+ } catch {
456
+ return { processedMessageIds: [], lastSentAtByChat: {}, sentCountByChat: {} };
457
+ }
458
+ }
459
+
460
+ function writeJsonAtomic(file, value) {
461
+ const temp = `${file}.${process.pid}.tmp`;
462
+ fs.writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`);
463
+ fs.renameSync(temp, file);
464
+ }
465
+
466
+ function visibleMessage(record, reply) {
467
+ return outboundLogMode === "full" ? reply : `[metadata only, ${record.messageCharCount} chars, ${record.messageHash.slice(0, 12)}]`;
468
+ }
469
+
470
+ function chatName(chat) {
471
+ return chat.name || chat.formattedTitle || chat.id?._serialized || "Unknown Chat";
472
+ }
473
+
474
+ function compact(value) {
475
+ return String(value).replace(/\s+/g, " ").trim().slice(0, 500);
476
+ }
477
+
478
+ function summarize(value) {
479
+ return compact(value).slice(0, 120);
480
+ }
481
+
482
+ function readConfig(vaultPath) {
483
+ const file = path.join(vaultPath, "digital-brain.config.json");
484
+ if (!fs.existsSync(file)) return {};
485
+ try {
486
+ return JSON.parse(fs.readFileSync(file, "utf8"));
487
+ } catch {
488
+ return {};
489
+ }
490
+ }
491
+
492
+ function hash(value) {
493
+ return crypto.createHash("sha256").update(value).digest("hex");
494
+ }
495
+
496
+ function parseList(value) {
497
+ return String(value || "").split(",").map((item) => item.trim()).filter(Boolean);
498
+ }
499
+
500
+ function numberArg(name, fallback) {
501
+ const parsed = Number(args[name] || fallback);
502
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
503
+ }
504
+
505
+ function parseArgs(argv) {
506
+ const out = { yes: false };
507
+ for (let i = 0; i < argv.length; i += 1) {
508
+ const arg = argv[i];
509
+ if (arg === "--yes") out.yes = true;
510
+ else if (arg === "--allow-all") out["allow-all"] = true;
511
+ else if (arg === "--include-groups") out["include-groups"] = true;
512
+ else if (arg === "--include-businesses") out["include-businesses"] = true;
513
+ else if (arg === "--no-process-unread") out["no-process-unread"] = true;
514
+ else if (arg.startsWith("--")) {
515
+ const key = arg.slice(2);
516
+ out[key] = argv[++i] || "";
517
+ }
518
+ }
519
+ return out;
520
+ }
521
+
522
+ function usage() {
523
+ console.error('Usage: digital-brain auto-whatsapp --allow "Name" --contact "+15551234567" --model llama3.1 [--yes] [--allow-all] [--include-groups] [--include-businesses]');
524
+ process.exit(1);
525
+ }
@@ -127,24 +127,29 @@ function disclosureStatus(chat) {
127
127
 
128
128
  const cutoff = Date.now() - 24 * 60 * 60 * 1000;
129
129
  const name = chatName(chat);
130
- const count = fs
130
+ const records = fs
131
131
  .readFileSync(logPath, "utf8")
132
132
  .split("\n")
133
133
  .filter(Boolean)
134
- .map((line) => {
135
- try {
136
- return JSON.parse(line);
137
- } catch {
138
- return null;
139
- }
140
- })
134
+ .map(parseJsonLine)
141
135
  .filter((record) => record)
142
136
  .filter((record) => record.resolvedChatName === name)
143
- .filter((record) => record.aiAssisted !== false)
137
+ .filter((record) => record.aiAssisted !== false);
138
+ const alreadyDisclosed = records.some((record) => record.disclosureIncluded);
139
+ if (alreadyDisclosed) return { required: false, count: 0, alreadyDisclosed: true };
140
+ const count = records
144
141
  .filter((record) => new Date(record.timestamp).getTime() >= cutoff)
145
142
  .filter((record) => !record.disclosureIncluded).length;
146
143
 
147
- return { required: count >= 2, count };
144
+ return { required: count >= 2, count, alreadyDisclosed: false };
145
+ }
146
+
147
+ function parseJsonLine(line) {
148
+ try {
149
+ return JSON.parse(line);
150
+ } catch {
151
+ return null;
152
+ }
148
153
  }
149
154
 
150
155
  function containsDisclosure(message) {