digital-brain 1.1.1 → 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,11 @@ 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.
85
86
  - Run an explicit WhatsApp auto-responder that uses local Ollama plus vault memory while the command is running.
86
87
  - Enforce an AI-disclosure guard after repeated AI-assisted sends.
87
88
 
@@ -96,6 +97,7 @@ digital-brain import-slack --input ./slack-export.zip
96
97
  digital-brain import-linkedin --input ./linkedin-archive.zip
97
98
  digital-brain send-whatsapp --to "Name" --message "text"
98
99
  digital-brain auto-whatsapp --allow "Name" --model llama3.1
100
+ digital-brain auto-whatsapp --contact "+15551234567" --model llama3.1
99
101
  ```
100
102
 
101
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.
@@ -115,14 +117,18 @@ digital-brain interpret
115
117
 
116
118
  The sender drafts by default. Add `--yes` to actually send.
117
119
 
118
- The auto-responder is opt-in and runs only while the command is active. It requires an allowlist unless you explicitly pass `--allow-all`. Without `--yes`, it only logs drafts:
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:
119
121
 
120
122
  ```bash
121
123
  digital-brain auto-whatsapp --allow "Mom" --model llama3.1
122
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
123
127
  ```
124
128
 
125
- 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.
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.
126
132
 
127
133
  ## Automation
128
134
 
@@ -52,7 +52,7 @@ async function init(argv, args) {
52
52
  let privacyMode = args["privacy-mode"] || "standard";
53
53
  let sourceMarkdownMode = args["source-markdown-mode"] || "none";
54
54
  let selectedSources = parseList(args.sources || "whatsapp");
55
- let responsibilityAccepted = fullAuto || schedule === "always-on";
55
+ let responsibilityAccepted = toBoolean(args["responsibility-accepted"]) || fullAuto || schedule === "always-on";
56
56
 
57
57
  if (!args.yes) {
58
58
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
@@ -103,10 +103,11 @@ async function init(argv, args) {
103
103
  ["disabled", "Disabled", "Never prepares WhatsApp sends.", "🔒"],
104
104
  ["draft", "Draft only", "Prepares text and requires you to send it.", "✍️"],
105
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.", "🚦"],
106
107
  ], outboundMode);
107
108
  connectAi = await confirm(rl, "🔗 Add global AI pointers for Codex/Claude/Gemini?", true);
108
109
  responsibilityAccepted = await responsibilityGate(rl, { schedule, outboundMode });
109
- if (!responsibilityAccepted && (schedule === "always-on" || outboundMode === "send-with-confirmation")) {
110
+ if (!responsibilityAccepted && needsResponsibilityGate({ schedule, outboundMode })) {
110
111
  console.log("Full-auto/outbound confirmation was not accepted. Using manual refresh and draft-only outbound.");
111
112
  schedule = "manual";
112
113
  outboundMode = "draft";
@@ -115,6 +116,10 @@ async function init(argv, args) {
115
116
  rl.close();
116
117
  }
117
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
+
118
123
  ensureDir(vault);
119
124
  copyDir(path.join(root, "templates", "vault"), vault);
120
125
  const config = {
@@ -503,7 +508,7 @@ async function confirm(rl, label, fallback) {
503
508
  }
504
509
 
505
510
  async function responsibilityGate(rl, { schedule, outboundMode }) {
506
- const needsGate = schedule === "always-on" || outboundMode === "send-with-confirmation";
511
+ const needsGate = needsResponsibilityGate({ schedule, outboundMode });
507
512
  if (!needsGate) return true;
508
513
  console.log("");
509
514
  console.log("⚠️ Responsibility check:");
@@ -513,6 +518,10 @@ async function responsibilityGate(rl, { schedule, outboundMode }) {
513
518
  return confirm(rl, "I understand and want this mode enabled", false);
514
519
  }
515
520
 
521
+ function needsResponsibilityGate({ schedule, outboundMode }) {
522
+ return schedule === "always-on" || ["send-with-confirmation", "auto-send"].includes(outboundMode);
523
+ }
524
+
516
525
  function letterFor(index) {
517
526
  return String.fromCharCode(65 + index);
518
527
  }
@@ -584,6 +593,6 @@ Usage:
584
593
  digital-brain extract --days 30
585
594
  digital-brain interpret --days 30
586
595
  digital-brain send-whatsapp --to "Name" --message "Text" [--yes]
587
- digital-brain auto-whatsapp --allow "Name" --model llama3.1 [--yes]
596
+ digital-brain auto-whatsapp --allow "Name" --contact "+15551234567" --model llama3.1 [--yes] [--no-process-unread]
588
597
  `);
589
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
 
@@ -95,30 +96,48 @@ The generated script loops forever and sleeps for `refreshIntervalMinutes`. The
95
96
 
96
97
  ## WhatsApp Auto-Reply
97
98
 
98
- `digital-brain auto-whatsapp` is separate from refresh automation. It uses WhatsApp Web for live incoming messages and Ollama for local reply generation.
99
+ `digital-brain auto-whatsapp` is separate from refresh automation. It uses WhatsApp Web for live incoming messages and Ollama for local reply generation. On startup it scans unread WhatsApp Web chats, then continues listening for new messages.
99
100
 
100
101
  Draft-only:
101
102
 
102
103
  ```bash
103
104
  digital-brain auto-whatsapp --allow "Mom" --model llama3.1
105
+ digital-brain auto-whatsapp --contact "+15551234567" --model llama3.1
104
106
  ```
105
107
 
106
108
  Auto-send while the command is running:
107
109
 
108
110
  ```bash
109
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
110
127
  ```
111
128
 
112
129
  Guardrails:
113
130
 
114
131
  - requires Ollama running locally
115
132
  - requires the selected model, for example `ollama pull llama3.1`
116
- - requires `--allow "Name"` unless `--allow-all` is explicitly passed
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
117
136
  - skips groups unless `--include-groups` is passed
118
137
  - uses a per-chat cooldown, default 20 minutes
119
138
  - caps replies per chat per run, default 5
120
139
  - logs metadata by default, not full sent text
121
- - still enforces the AI disclosure rule after repeated AI-assisted sends
140
+ - enforces the AI disclosure rule after repeated AI-assisted sends, but does not repeat it after that chat has already received a disclosure
122
141
 
123
142
  ## Local Cron
124
143
 
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.
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`.
11
11
  - Raw source data stays under `08 Sources/`; normal AI context should use `06 AI Memory/` and human notes under `04 People/`.
12
12
  - Same-person matching across sources is provisional and file-based; keep source evidence visible when using merged person context.
13
13
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "digital-brain",
3
- "version": "1.1.1",
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.
@@ -5,6 +5,8 @@ import qrcode from "qrcode-terminal";
5
5
  import pkg from "whatsapp-web.js";
6
6
 
7
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;
8
10
  const args = parseArgs(process.argv.slice(2));
9
11
 
10
12
  if (!args.vault) usage();
@@ -18,9 +20,14 @@ const config = readConfig(vault);
18
20
  const model = args.model || config.autoReplyModel || "llama3.1";
19
21
  const allow = parseList(args.allow || "");
20
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);
21
26
  const allowAll = Boolean(args["allow-all"]);
22
27
  const includeGroups = Boolean(args["include-groups"]);
23
- const sendEnabled = Boolean(args.yes);
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"]);
24
31
  const cooldownMinutes = numberArg("cooldown-minutes", 20);
25
32
  const maxRepliesPerChat = numberArg("max-replies-per-chat", 5);
26
33
  const maxContextChars = numberArg("max-context-chars", 12000);
@@ -34,8 +41,8 @@ if (config.outboundMode === "disabled") {
34
41
  process.exit(1);
35
42
  }
36
43
 
37
- if (!allowAll && allow.length === 0) {
38
- console.error('Refusing to auto-reply without an allowlist. Add --allow "Name" or pass --allow-all explicitly.');
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.');
39
46
  process.exit(1);
40
47
  }
41
48
 
@@ -51,14 +58,26 @@ client.on("qr", (qr) => {
51
58
  qrcode.generate(qr, { small: true });
52
59
  });
53
60
 
54
- client.on("ready", () => {
61
+ client.on("ready", async () => {
55
62
  console.log(`Digital Brain WhatsApp auto-reply running with Ollama model: ${model}`);
56
- console.log(sendEnabled ? "Auto-send is enabled." : "Draft mode. Replies will be logged but not sent. Add --yes to send.");
57
- console.log(allowAll ? "Allowlist: all chats." : `Allowlist: ${allow.join(", ")}`);
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...");
58
76
  });
59
77
 
60
78
  client.on("message", async (message) => {
61
79
  try {
80
+ console.log(`Received WhatsApp message event: ${summarize(message.body || "[non-text message]")}`);
62
81
  await handleMessage(message);
63
82
  } catch (error) {
64
83
  console.error(`Auto-reply error: ${error.message}`);
@@ -67,19 +86,74 @@ client.on("message", async (message) => {
67
86
 
68
87
  client.initialize();
69
88
 
70
- async function handleMessage(message) {
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) {
71
123
  if (message.fromMe || message.isStatus) return;
72
124
  if (state.processedMessageIds.includes(message.id?._serialized)) return;
73
125
 
74
- const chat = await message.getChat();
126
+ const chat = knownChat || await message.getChat();
75
127
  const name = chatName(chat);
76
- if (chat.isGroup && !includeGroups) return;
77
- if (!isAllowed(name)) return;
78
- if (isDenied(name)) return;
79
- if (isCoolingDown(name)) return;
80
- if (replyCount(name) >= maxRepliesPerChat) return;
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
+ }
81
149
 
150
+ console.log(`Fetching context for ${name}...`);
82
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
+ }
83
157
  const disclosure = disclosureStatus(name);
84
158
  const prompt = buildPrompt({
85
159
  chatName: name,
@@ -87,7 +161,10 @@ async function handleMessage(message) {
87
161
  recentMessages,
88
162
  disclosureRequired: disclosure.required,
89
163
  });
164
+ console.log(`Generating reply for ${name} with ${model}...`);
165
+ const startedAt = Date.now();
90
166
  const reply = await generateReply(prompt);
167
+ console.log(`Generated reply for ${name} in ${Date.now() - startedAt}ms: ${summarize(reply || "[empty reply]")}`);
91
168
  const finalReply = disclosure.required && !containsDisclosure(reply)
92
169
  ? `Just flagging this is my AI assistant helping draft/send this. ${reply}`
93
170
  : reply;
@@ -115,6 +192,9 @@ function buildPrompt({ chatName, incomingBody, recentMessages, disclosureRequire
115
192
  "You are helping the user reply on WhatsApp.",
116
193
  "Write exactly one message to send as the user.",
117
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.",
118
198
  "Use the user's local memory context, but do not reveal private notes or say you read a vault.",
119
199
  "Do not invent facts, commitments, times, or promises.",
120
200
  disclosureRequired ? "This send requires AI disclosure. Include a short clear disclosure in the message." : "Do not mention AI unless disclosure is required.",
@@ -135,6 +215,7 @@ function buildPrompt({ chatName, incomingBody, recentMessages, disclosureRequire
135
215
 
136
216
  function readMemoryContext(chatName) {
137
217
  const files = [
218
+ path.join(vault, "06 AI Memory", "My Communication Style.md"),
138
219
  path.join(vault, "06 AI Memory", "Person Reply Context.md"),
139
220
  path.join(vault, "06 AI Memory", "Person Context Index.md"),
140
221
  path.join(vault, "06 AI Memory", "Interpreted Relationship Memory.md"),
@@ -199,8 +280,16 @@ function cleanReply(value) {
199
280
  .slice(0, 1200);
200
281
  }
201
282
 
202
- function isAllowed(name) {
283
+ function isAllowed(chatOrName) {
203
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;
204
293
  return allow.some((item) => name.toLowerCase().includes(item.toLowerCase()));
205
294
  }
206
295
 
@@ -208,6 +297,68 @@ function isDenied(name) {
208
297
  return deny.some((item) => name.toLowerCase().includes(item.toLowerCase()));
209
298
  }
210
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
+
211
362
  function isCoolingDown(name) {
212
363
  const last = state.lastSentAtByChat[name];
213
364
  if (!last) return false;
@@ -218,11 +369,13 @@ function replyCount(name) {
218
369
  return state.sentCountByChat[name] || 0;
219
370
  }
220
371
 
221
- function markProcessed(message, name) {
372
+ function markProcessed(message, name, options = { sent: true }) {
222
373
  state.processedMessageIds.push(message.id?._serialized);
223
374
  state.processedMessageIds = state.processedMessageIds.filter(Boolean).slice(-1000);
224
- state.lastSentAtByChat[name] = new Date().toISOString();
225
- state.sentCountByChat[name] = replyCount(name) + 1;
375
+ if (options.sent !== false) {
376
+ state.lastSentAtByChat[name] = new Date().toISOString();
377
+ state.sentCountByChat[name] = replyCount(name) + 1;
378
+ }
226
379
  writeJsonAtomic(statePath, state);
227
380
  }
228
381
 
@@ -267,23 +420,28 @@ function disclosureStatus(chatNameValue) {
267
420
  const logPath = path.join(outboundDir, "sent.jsonl");
268
421
  if (!fs.existsSync(logPath)) return { required: false, count: 0 };
269
422
  const cutoff = Date.now() - 24 * 60 * 60 * 1000;
270
- const count = fs
423
+ const records = fs
271
424
  .readFileSync(logPath, "utf8")
272
425
  .split("\n")
273
426
  .filter(Boolean)
274
- .map((line) => {
275
- try {
276
- return JSON.parse(line);
277
- } catch {
278
- return null;
279
- }
280
- })
427
+ .map(parseJsonLine)
281
428
  .filter((record) => record)
282
429
  .filter((record) => record.resolvedChatName === chatNameValue)
283
- .filter((record) => record.aiAssisted !== false)
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
284
434
  .filter((record) => new Date(record.timestamp).getTime() >= cutoff)
285
435
  .filter((record) => !record.disclosureIncluded).length;
286
- return { required: count >= 2, count };
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
+ }
287
445
  }
288
446
 
289
447
  function containsDisclosure(message) {
@@ -351,6 +509,8 @@ function parseArgs(argv) {
351
509
  if (arg === "--yes") out.yes = true;
352
510
  else if (arg === "--allow-all") out["allow-all"] = true;
353
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;
354
514
  else if (arg.startsWith("--")) {
355
515
  const key = arg.slice(2);
356
516
  out[key] = argv[++i] || "";
@@ -360,6 +520,6 @@ function parseArgs(argv) {
360
520
  }
361
521
 
362
522
  function usage() {
363
- console.error('Usage: digital-brain auto-whatsapp --allow "Name" --model llama3.1 [--yes] [--allow-all] [--include-groups]');
523
+ console.error('Usage: digital-brain auto-whatsapp --allow "Name" --contact "+15551234567" --model llama3.1 [--yes] [--allow-all] [--include-groups] [--include-businesses]');
364
524
  process.exit(1);
365
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) {