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 +9 -3
- package/bin/digital-brain.js +13 -4
- package/docs/AUTOMATIONS.md +23 -4
- package/docs/PRIVACY.md +1 -1
- package/package.json +1 -1
- package/scripts/digital_brain_relationship_extractor.py +229 -1
- package/templates/vault/00 Home/How AI Should Use This Vault.md +1 -0
- package/templates/vault/06 AI Memory/My Communication Style.md +5 -0
- package/templates/vault/08 Sources/WhatsApp/Outbound/README.md +1 -1
- package/templates/vault/AGENTS.md +2 -0
- package/templates/vault/CLAUDE.md +1 -1
- package/templates/vault/GEMINI.md +1 -1
- package/whatsapp-web/auto-reply.mjs +188 -28
- package/whatsapp-web/send.mjs +15 -10
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,
|
|
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
|
-
|
|
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
|
|
package/bin/digital-brain.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
}
|
package/docs/AUTOMATIONS.md
CHANGED
|
@@ -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
|
|
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
|
-
-
|
|
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
|
@@ -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`
|
|
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`
|
|
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
|
|
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." :
|
|
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
|
|
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
|
-
|
|
77
|
-
if (!
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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(
|
|
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
|
-
|
|
225
|
-
|
|
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
|
|
423
|
+
const records = fs
|
|
271
424
|
.readFileSync(logPath, "utf8")
|
|
272
425
|
.split("\n")
|
|
273
426
|
.filter(Boolean)
|
|
274
|
-
.map(
|
|
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
|
}
|
package/whatsapp-web/send.mjs
CHANGED
|
@@ -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
|
|
130
|
+
const records = fs
|
|
131
131
|
.readFileSync(logPath, "utf8")
|
|
132
132
|
.split("\n")
|
|
133
133
|
.filter(Boolean)
|
|
134
|
-
.map(
|
|
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) {
|