digital-brain 1.0.5 → 1.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -2
- package/bin/digital-brain.js +14 -3
- package/docs/AUTOMATIONS.md +47 -1
- package/docs/PRIVACY.md +2 -0
- 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 +525 -0
- package/whatsapp-web/send.mjs +15 -10
package/README.md
CHANGED
|
@@ -78,10 +78,12 @@ node ./bin/digital-brain.js init ./Digital Brain\ Vault
|
|
|
78
78
|
- Merge confirmed-looking same-person profiles across sources into a person context index.
|
|
79
79
|
- Infer provisional roles like parent, family group, work collaborator, close personal contact, or unlabeled contact.
|
|
80
80
|
- Extract relationship-specific typing style: casing, message length, punctuation, emoji, and slang.
|
|
81
|
+
- Extract your own outbound communication style so drafts can match your casing, slang, punctuation, lexical patterns, and common phrase shapes.
|
|
81
82
|
- Generate "how to continue this relationship" notes.
|
|
82
83
|
- Generate reply-ready person context that keeps WhatsApp, iMessage, Slack, and LinkedIn evidence separate under the same person.
|
|
83
84
|
- Create AI-readable memory files for future prompts.
|
|
84
|
-
- Draft WhatsApp sends by default,
|
|
85
|
+
- Draft WhatsApp sends by default, send with explicit `--yes`, or configure auto-send mode during init.
|
|
86
|
+
- Run an explicit WhatsApp auto-responder that uses local Ollama plus vault memory while the command is running.
|
|
85
87
|
- Enforce an AI-disclosure guard after repeated AI-assisted sends.
|
|
86
88
|
|
|
87
89
|
## Core Commands
|
|
@@ -94,6 +96,8 @@ digital-brain sync-imessage --days 30
|
|
|
94
96
|
digital-brain import-slack --input ./slack-export.zip
|
|
95
97
|
digital-brain import-linkedin --input ./linkedin-archive.zip
|
|
96
98
|
digital-brain send-whatsapp --to "Name" --message "text"
|
|
99
|
+
digital-brain auto-whatsapp --allow "Name" --model llama3.1
|
|
100
|
+
digital-brain auto-whatsapp --contact "+15551234567" --model llama3.1
|
|
97
101
|
```
|
|
98
102
|
|
|
99
103
|
`init` remembers your vault globally, so `run` works from anywhere. `run` syncs the live local sources you selected, extracts relationships, and writes interpreted memory in one command.
|
|
@@ -113,7 +117,18 @@ digital-brain interpret
|
|
|
113
117
|
|
|
114
118
|
The sender drafts by default. Add `--yes` to actually send.
|
|
115
119
|
|
|
116
|
-
|
|
120
|
+
The auto-responder is opt-in and runs only while the command is active. On startup it scans unread WhatsApp Web chats, then listens for new messages. It requires an allowlist unless you explicitly pass `--allow-all`. Without `--yes`, it only logs drafts unless you selected `Auto-send while running` during init:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
digital-brain auto-whatsapp --allow "Mom" --model llama3.1
|
|
124
|
+
digital-brain auto-whatsapp --allow "Mom" --model llama3.1 --yes
|
|
125
|
+
digital-brain auto-whatsapp --allow "Mom" --model llama3.1 --yes --no-process-unread
|
|
126
|
+
digital-brain auto-whatsapp --contact "+15551234567" --model llama3.1 --yes
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Even with `--allow-all`, likely business, notification, OTP, delivery, bank, and support chats are skipped by default. Use explicit `--allow "Name"` or `--contact "+15551234567"` for trusted personal chats. Pass `--include-businesses` only if you intentionally want those chats included.
|
|
130
|
+
|
|
131
|
+
If Digital Brain has already sent two AI-assisted messages to the same chat in the last 24 hours, the next send must disclose that AI is helping. Once that chat has received an AI disclosure, Digital Brain will not keep repeating it.
|
|
117
132
|
|
|
118
133
|
## Automation
|
|
119
134
|
|
package/bin/digital-brain.js
CHANGED
|
@@ -30,6 +30,7 @@ async function main() {
|
|
|
30
30
|
else if (command === "extract") runPython("digital_brain_relationship_extractor.py", argv);
|
|
31
31
|
else if (command === "interpret") runPython("digital_brain_relationship_interpreter.py", argv);
|
|
32
32
|
else if (command === "send-whatsapp") runNode("whatsapp-web/send.mjs", argv);
|
|
33
|
+
else if (command === "auto-whatsapp") runNode("whatsapp-web/auto-reply.mjs", argv);
|
|
33
34
|
else help();
|
|
34
35
|
}
|
|
35
36
|
|
|
@@ -51,7 +52,7 @@ async function init(argv, args) {
|
|
|
51
52
|
let privacyMode = args["privacy-mode"] || "standard";
|
|
52
53
|
let sourceMarkdownMode = args["source-markdown-mode"] || "none";
|
|
53
54
|
let selectedSources = parseList(args.sources || "whatsapp");
|
|
54
|
-
let responsibilityAccepted = fullAuto || schedule === "always-on";
|
|
55
|
+
let responsibilityAccepted = toBoolean(args["responsibility-accepted"]) || fullAuto || schedule === "always-on";
|
|
55
56
|
|
|
56
57
|
if (!args.yes) {
|
|
57
58
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
@@ -102,10 +103,11 @@ async function init(argv, args) {
|
|
|
102
103
|
["disabled", "Disabled", "Never prepares WhatsApp sends.", "🔒"],
|
|
103
104
|
["draft", "Draft only", "Prepares text and requires you to send it.", "✍️"],
|
|
104
105
|
["send-with-confirmation", "Send with confirmation", "Can send only after explicit command confirmation.", "✅"],
|
|
106
|
+
["auto-send", "Auto-send while running", "Lets auto-whatsapp send from allowlisted chats while it is running.", "🚦"],
|
|
105
107
|
], outboundMode);
|
|
106
108
|
connectAi = await confirm(rl, "🔗 Add global AI pointers for Codex/Claude/Gemini?", true);
|
|
107
109
|
responsibilityAccepted = await responsibilityGate(rl, { schedule, outboundMode });
|
|
108
|
-
if (!responsibilityAccepted && (schedule
|
|
110
|
+
if (!responsibilityAccepted && needsResponsibilityGate({ schedule, outboundMode })) {
|
|
109
111
|
console.log("Full-auto/outbound confirmation was not accepted. Using manual refresh and draft-only outbound.");
|
|
110
112
|
schedule = "manual";
|
|
111
113
|
outboundMode = "draft";
|
|
@@ -114,6 +116,10 @@ async function init(argv, args) {
|
|
|
114
116
|
rl.close();
|
|
115
117
|
}
|
|
116
118
|
|
|
119
|
+
if (args.yes && needsResponsibilityGate({ schedule, outboundMode }) && !responsibilityAccepted) {
|
|
120
|
+
throw new Error("This mode requires explicit responsibility acceptance. Re-run with --responsibility-accepted=true or use draft mode.");
|
|
121
|
+
}
|
|
122
|
+
|
|
117
123
|
ensureDir(vault);
|
|
118
124
|
copyDir(path.join(root, "templates", "vault"), vault);
|
|
119
125
|
const config = {
|
|
@@ -502,7 +508,7 @@ async function confirm(rl, label, fallback) {
|
|
|
502
508
|
}
|
|
503
509
|
|
|
504
510
|
async function responsibilityGate(rl, { schedule, outboundMode }) {
|
|
505
|
-
const needsGate = schedule
|
|
511
|
+
const needsGate = needsResponsibilityGate({ schedule, outboundMode });
|
|
506
512
|
if (!needsGate) return true;
|
|
507
513
|
console.log("");
|
|
508
514
|
console.log("⚠️ Responsibility check:");
|
|
@@ -512,6 +518,10 @@ async function responsibilityGate(rl, { schedule, outboundMode }) {
|
|
|
512
518
|
return confirm(rl, "I understand and want this mode enabled", false);
|
|
513
519
|
}
|
|
514
520
|
|
|
521
|
+
function needsResponsibilityGate({ schedule, outboundMode }) {
|
|
522
|
+
return schedule === "always-on" || ["send-with-confirmation", "auto-send"].includes(outboundMode);
|
|
523
|
+
}
|
|
524
|
+
|
|
515
525
|
function letterFor(index) {
|
|
516
526
|
return String.fromCharCode(65 + index);
|
|
517
527
|
}
|
|
@@ -583,5 +593,6 @@ Usage:
|
|
|
583
593
|
digital-brain extract --days 30
|
|
584
594
|
digital-brain interpret --days 30
|
|
585
595
|
digital-brain send-whatsapp --to "Name" --message "Text" [--yes]
|
|
596
|
+
digital-brain auto-whatsapp --allow "Name" --contact "+15551234567" --model llama3.1 [--yes] [--no-process-unread]
|
|
586
597
|
`);
|
|
587
598
|
}
|
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
|
|
|
@@ -93,6 +94,51 @@ For 24/7 local polling, run:
|
|
|
93
94
|
|
|
94
95
|
The generated script loops forever and sleeps for `refreshIntervalMinutes`. The minimum supported interval is 1 minute. A practical default is 5 minutes.
|
|
95
96
|
|
|
97
|
+
## WhatsApp Auto-Reply
|
|
98
|
+
|
|
99
|
+
`digital-brain auto-whatsapp` is separate from refresh automation. It uses WhatsApp Web for live incoming messages and Ollama for local reply generation. On startup it scans unread WhatsApp Web chats, then continues listening for new messages.
|
|
100
|
+
|
|
101
|
+
Draft-only:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
digital-brain auto-whatsapp --allow "Mom" --model llama3.1
|
|
105
|
+
digital-brain auto-whatsapp --contact "+15551234567" --model llama3.1
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Auto-send while the command is running:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
digital-brain auto-whatsapp --allow "Mom" --model llama3.1 --yes
|
|
112
|
+
digital-brain auto-whatsapp --contact "+15551234567" --model llama3.1 --yes
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Broad auto-send for personal chats:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
digital-brain auto-whatsapp --allow-all --model llama3.1 --yes
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`--allow-all` still skips likely business, notification, OTP, bank, delivery, and support chats by default. Use `--include-businesses` only when you intentionally want those chats included. Prefer explicit `--allow "Name"` or `--contact "+15551234567"` for friends and family.
|
|
122
|
+
|
|
123
|
+
If you selected `Auto-send while running` during init, `auto-whatsapp` can send without `--yes` while it is running:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
digital-brain auto-whatsapp --allow "Mom" --model llama3.1
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Guardrails:
|
|
130
|
+
|
|
131
|
+
- requires Ollama running locally
|
|
132
|
+
- requires the selected model, for example `ollama pull llama3.1`
|
|
133
|
+
- requires `--allow "Name"` or `--contact "+15551234567"` unless `--allow-all` is explicitly passed
|
|
134
|
+
- skips likely business, notification, OTP, and service chats unless `--include-businesses` is passed or the chat is explicitly allowlisted by name or contact number
|
|
135
|
+
- processes unread chats on startup unless `--no-process-unread` is passed
|
|
136
|
+
- skips groups unless `--include-groups` is passed
|
|
137
|
+
- uses a per-chat cooldown, default 20 minutes
|
|
138
|
+
- caps replies per chat per run, default 5
|
|
139
|
+
- logs metadata by default, not full sent text
|
|
140
|
+
- enforces the AI disclosure rule after repeated AI-assisted sends, but does not repeat it after that chat has already received a disclosure
|
|
141
|
+
|
|
96
142
|
## Local Cron
|
|
97
143
|
|
|
98
144
|
Run every 30 minutes from 8-12:
|
package/docs/PRIVACY.md
CHANGED
|
@@ -7,6 +7,7 @@ Digital Brain is designed for local use.
|
|
|
7
7
|
- No cloud API is called by default.
|
|
8
8
|
- Ollama interpretation is local when enabled.
|
|
9
9
|
- WhatsApp sending uses a local WhatsApp Web session.
|
|
10
|
+
- WhatsApp auto-reply uses local Ollama by default, runs only while the command is active, and requires an allowlist unless explicitly overridden. If init is configured for auto-send mode, it can send without `--yes`.
|
|
10
11
|
- Raw source data stays under `08 Sources/`; normal AI context should use `06 AI Memory/` and human notes under `04 People/`.
|
|
11
12
|
- Same-person matching across sources is provisional and file-based; keep source evidence visible when using merged person context.
|
|
12
13
|
|
|
@@ -15,6 +16,7 @@ Things to be careful about:
|
|
|
15
16
|
- Do not commit a generated vault.
|
|
16
17
|
- Do not paste private message exports into GitHub issues.
|
|
17
18
|
- Do not enable outbound sending without understanding the risk.
|
|
19
|
+
- Do not use auto-reply for sensitive, legal, medical, financial, emergency, or high-stakes conversations.
|
|
18
20
|
- Treat WhatsApp database access, iMessage database access, and WhatsApp Web automation as local, permission-sensitive integrations that can change.
|
|
19
21
|
- Always-on mode runs on your machine and inherits your local permissions.
|
|
20
22
|
- You are responsible for consent, privacy, message content, and sends made from your machine.
|
package/package.json
CHANGED
|
@@ -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.
|
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import qrcode from "qrcode-terminal";
|
|
5
|
+
import pkg from "whatsapp-web.js";
|
|
6
|
+
|
|
7
|
+
const { Client, LocalAuth } = pkg;
|
|
8
|
+
const BUSINESS_NAME_RE = /\b(amazon|flipkart|myntra|nykaa|swiggy|zomato|blinkit|zepto|uber|ola|rapido|delivery|courier|dunzo|paytm|phonepe|gpay|google pay|hdfc|icici|sbi|axis|kotak|amex|bank|airtel|jio|vodafone|vi|support|helpdesk|customer care|official|verified|business|meta ai|whatsapp|no[\s-]?reply|noreply|otp|login|security|alerts?|notifications?|offers?|promo|marketing)\b/i;
|
|
9
|
+
const BUSINESS_BODY_RE = /\b(otp|one time password|verification code|login code|security code|do not share|order|delivered|delivery|shipment|tracking|invoice|payment|transaction|debited|credited|refund|ticket|support|unsubscribe|offer|coupon|sale|valid till|automated message)\b/i;
|
|
10
|
+
const args = parseArgs(process.argv.slice(2));
|
|
11
|
+
|
|
12
|
+
if (!args.vault) usage();
|
|
13
|
+
|
|
14
|
+
const vault = path.resolve(args.vault);
|
|
15
|
+
const whatsAppDir = path.join(vault, "08 Sources", "WhatsApp");
|
|
16
|
+
const outboundDir = path.join(whatsAppDir, "Outbound");
|
|
17
|
+
const sessionDir = path.join(whatsAppDir, ".session");
|
|
18
|
+
const statePath = path.join(outboundDir, "auto-reply-state.json");
|
|
19
|
+
const config = readConfig(vault);
|
|
20
|
+
const model = args.model || config.autoReplyModel || "llama3.1";
|
|
21
|
+
const allow = parseList(args.allow || "");
|
|
22
|
+
const deny = parseList(args.deny || "");
|
|
23
|
+
const contactNumbers = parseList([args.contact, args.phone, args["contact-number"]].filter(Boolean).join(","))
|
|
24
|
+
.map(normalizePhone)
|
|
25
|
+
.filter(Boolean);
|
|
26
|
+
const allowAll = Boolean(args["allow-all"]);
|
|
27
|
+
const includeGroups = Boolean(args["include-groups"]);
|
|
28
|
+
const includeBusinesses = Boolean(args["include-businesses"]);
|
|
29
|
+
const sendEnabled = Boolean(args.yes) || config.outboundMode === "auto-send";
|
|
30
|
+
const processUnreadOnStart = !Boolean(args["no-process-unread"]);
|
|
31
|
+
const cooldownMinutes = numberArg("cooldown-minutes", 20);
|
|
32
|
+
const maxRepliesPerChat = numberArg("max-replies-per-chat", 5);
|
|
33
|
+
const maxContextChars = numberArg("max-context-chars", 12000);
|
|
34
|
+
const outboundLogMode = args["log-mode"] || config.outboundLogMode || "metadata";
|
|
35
|
+
const state = loadState();
|
|
36
|
+
|
|
37
|
+
fs.mkdirSync(outboundDir, { recursive: true });
|
|
38
|
+
|
|
39
|
+
if (config.outboundMode === "disabled") {
|
|
40
|
+
console.error("WhatsApp outbound mode is disabled in this vault. Re-run init or edit digital-brain.config.json before using auto-whatsapp.");
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!allowAll && allow.length === 0 && contactNumbers.length === 0) {
|
|
45
|
+
console.error('Refusing to auto-reply without an allowlist. Add --allow "Name", --contact "+15551234567", or pass --allow-all explicitly.');
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
await assertOllamaModel(model);
|
|
50
|
+
|
|
51
|
+
const client = new Client({
|
|
52
|
+
authStrategy: new LocalAuth({ clientId: "digital-brain", dataPath: sessionDir }),
|
|
53
|
+
puppeteer: { headless: false, args: ["--no-sandbox", "--disable-setuid-sandbox"] },
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
client.on("qr", (qr) => {
|
|
57
|
+
console.log("Scan this QR in WhatsApp > Linked devices:");
|
|
58
|
+
qrcode.generate(qr, { small: true });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
client.on("ready", async () => {
|
|
62
|
+
console.log(`Digital Brain WhatsApp auto-reply running with Ollama model: ${model}`);
|
|
63
|
+
console.log(sendEnabled ? "Auto-send is enabled." : "Draft mode. Replies will be logged but not sent. Add --yes or set outboundMode=auto-send to send.");
|
|
64
|
+
console.log(allowAll ? "Allowlist: all chats." : allowlistSummary());
|
|
65
|
+
if (!includeBusinesses) console.log("Likely business, notification, OTP, and service chats are skipped by default.");
|
|
66
|
+
try {
|
|
67
|
+
if (processUnreadOnStart) {
|
|
68
|
+
await processUnreadChats();
|
|
69
|
+
} else {
|
|
70
|
+
console.log("Startup unread scan disabled.");
|
|
71
|
+
}
|
|
72
|
+
} catch (error) {
|
|
73
|
+
console.error(`Startup unread scan failed: ${error.message}`);
|
|
74
|
+
}
|
|
75
|
+
console.log("Listening for new WhatsApp messages...");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
client.on("message", async (message) => {
|
|
79
|
+
try {
|
|
80
|
+
console.log(`Received WhatsApp message event: ${summarize(message.body || "[non-text message]")}`);
|
|
81
|
+
await handleMessage(message);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
console.error(`Auto-reply error: ${error.message}`);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
client.initialize();
|
|
88
|
+
|
|
89
|
+
async function processUnreadChats() {
|
|
90
|
+
const chats = await client.getChats();
|
|
91
|
+
const unreadChats = chats.filter((chat) => Number(chat.unreadCount || 0) > 0);
|
|
92
|
+
console.log(`Startup unread scan: ${unreadChats.length} unread chat(s).`);
|
|
93
|
+
for (const chat of unreadChats) {
|
|
94
|
+
const name = chatName(chat);
|
|
95
|
+
if (chat.isGroup && !includeGroups) {
|
|
96
|
+
console.log(`Skipping unread group chat: ${name}`);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (!isAllowed(chat) || isDenied(name)) {
|
|
100
|
+
console.log(`Skipping unread chat outside allowlist: ${name}`);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (isCoolingDown(name)) {
|
|
104
|
+
console.log(`Skipping unread chat during cooldown: ${name}`);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (replyCount(name) >= maxRepliesPerChat) {
|
|
108
|
+
console.log(`Skipping unread chat at reply cap: ${name}`);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const recentMessages = await chat.fetchMessages({ limit: Math.max(12, Number(chat.unreadCount || 0) + 3) });
|
|
112
|
+
const latestInbound = recentMessages.filter((message) => !message.fromMe && !message.isStatus).at(-1);
|
|
113
|
+
if (!latestInbound) {
|
|
114
|
+
console.log(`No inbound unread candidate found for: ${name}`);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
console.log(`Processing unread chat: ${name}`);
|
|
118
|
+
await handleMessage(latestInbound, chat);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function handleMessage(message, knownChat = null) {
|
|
123
|
+
if (message.fromMe || message.isStatus) return;
|
|
124
|
+
if (state.processedMessageIds.includes(message.id?._serialized)) return;
|
|
125
|
+
|
|
126
|
+
const chat = knownChat || await message.getChat();
|
|
127
|
+
const name = chatName(chat);
|
|
128
|
+
console.log(`Received from ${name}: ${summarize(message.body || "[non-text message]")}`);
|
|
129
|
+
if (chat.isGroup && !includeGroups) {
|
|
130
|
+
console.log(`Skipping group chat: ${name}`);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (!isAllowed(chat)) {
|
|
134
|
+
console.log(`Skipping chat outside allowlist: ${name}`);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (isDenied(name)) {
|
|
138
|
+
console.log(`Skipping denied chat: ${name}`);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (isCoolingDown(name)) {
|
|
142
|
+
console.log(`Skipping chat during cooldown: ${name}`);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (replyCount(name) >= maxRepliesPerChat) {
|
|
146
|
+
console.log(`Skipping chat at reply cap: ${name}`);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
console.log(`Fetching context for ${name}...`);
|
|
151
|
+
const recentMessages = await chat.fetchMessages({ limit: 12 });
|
|
152
|
+
if (shouldSkipNonPersonalChat(chat, recentMessages)) {
|
|
153
|
+
console.log(`Skipping likely business/notification chat: ${name}`);
|
|
154
|
+
markProcessed(message, name, { sent: false });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const disclosure = disclosureStatus(name);
|
|
158
|
+
const prompt = buildPrompt({
|
|
159
|
+
chatName: name,
|
|
160
|
+
incomingBody: message.body || "",
|
|
161
|
+
recentMessages,
|
|
162
|
+
disclosureRequired: disclosure.required,
|
|
163
|
+
});
|
|
164
|
+
console.log(`Generating reply for ${name} with ${model}...`);
|
|
165
|
+
const startedAt = Date.now();
|
|
166
|
+
const reply = await generateReply(prompt);
|
|
167
|
+
console.log(`Generated reply for ${name} in ${Date.now() - startedAt}ms: ${summarize(reply || "[empty reply]")}`);
|
|
168
|
+
const finalReply = disclosure.required && !containsDisclosure(reply)
|
|
169
|
+
? `Just flagging this is my AI assistant helping draft/send this. ${reply}`
|
|
170
|
+
: reply;
|
|
171
|
+
|
|
172
|
+
if (!finalReply.trim()) return;
|
|
173
|
+
if (sendEnabled) {
|
|
174
|
+
const sent = await chat.sendMessage(finalReply);
|
|
175
|
+
logSent(name, finalReply, sent, message);
|
|
176
|
+
console.log(`Auto-sent to ${name}: ${summarize(finalReply)}`);
|
|
177
|
+
} else {
|
|
178
|
+
logDraft(name, finalReply, message);
|
|
179
|
+
console.log(`Drafted for ${name}: ${summarize(finalReply)}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
markProcessed(message, name);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function buildPrompt({ chatName, incomingBody, recentMessages, disclosureRequired }) {
|
|
186
|
+
const memory = readMemoryContext(chatName);
|
|
187
|
+
const transcript = recentMessages
|
|
188
|
+
.slice(-12)
|
|
189
|
+
.map((item) => `${item.fromMe ? "Me" : chatName}: ${compact(item.body || "")}`)
|
|
190
|
+
.join("\n");
|
|
191
|
+
return [
|
|
192
|
+
"You are helping the user reply on WhatsApp.",
|
|
193
|
+
"Write exactly one message to send as the user.",
|
|
194
|
+
"Be natural, concise, and relationship-appropriate.",
|
|
195
|
+
"Match the user's own communication style from My Communication Style. If it says lowercase-heavy or undercapitalized, prefer lowercase casual texting.",
|
|
196
|
+
"Use lexical signals from My Communication Style: recurring style words, openers, short phrase shapes, punctuation habits, and lowercase-i behavior.",
|
|
197
|
+
"Do not sound like customer support, corporate email, or a generic AI assistant.",
|
|
198
|
+
"Use the user's local memory context, but do not reveal private notes or say you read a vault.",
|
|
199
|
+
"Do not invent facts, commitments, times, or promises.",
|
|
200
|
+
disclosureRequired ? "This send requires AI disclosure. Include a short clear disclosure in the message." : "Do not mention AI unless disclosure is required.",
|
|
201
|
+
"",
|
|
202
|
+
`Chat: ${chatName}`,
|
|
203
|
+
"",
|
|
204
|
+
"Relevant local memory:",
|
|
205
|
+
memory,
|
|
206
|
+
"",
|
|
207
|
+
"Recent chat:",
|
|
208
|
+
transcript,
|
|
209
|
+
"",
|
|
210
|
+
`Incoming message: ${incomingBody}`,
|
|
211
|
+
"",
|
|
212
|
+
"Reply:",
|
|
213
|
+
].join("\n");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function readMemoryContext(chatName) {
|
|
217
|
+
const files = [
|
|
218
|
+
path.join(vault, "06 AI Memory", "My Communication Style.md"),
|
|
219
|
+
path.join(vault, "06 AI Memory", "Person Reply Context.md"),
|
|
220
|
+
path.join(vault, "06 AI Memory", "Person Context Index.md"),
|
|
221
|
+
path.join(vault, "06 AI Memory", "Interpreted Relationship Memory.md"),
|
|
222
|
+
path.join(vault, "06 AI Memory", "What AI Should Remember.md"),
|
|
223
|
+
];
|
|
224
|
+
const chunks = files
|
|
225
|
+
.filter((file) => fs.existsSync(file))
|
|
226
|
+
.map((file) => `# ${path.basename(file)}\n${fs.readFileSync(file, "utf8")}`);
|
|
227
|
+
const text = chunks.join("\n\n");
|
|
228
|
+
const lower = chatName.toLowerCase();
|
|
229
|
+
const lines = text.split("\n");
|
|
230
|
+
const matchingWindow = [];
|
|
231
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
232
|
+
if (lines[index].toLowerCase().includes(lower)) {
|
|
233
|
+
matchingWindow.push(lines.slice(Math.max(0, index - 8), index + 18).join("\n"));
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const focused = matchingWindow.join("\n\n") || text;
|
|
237
|
+
return focused.slice(0, maxContextChars);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function generateReply(prompt) {
|
|
241
|
+
const response = await fetch("http://127.0.0.1:11434/api/generate", {
|
|
242
|
+
method: "POST",
|
|
243
|
+
headers: { "content-type": "application/json" },
|
|
244
|
+
body: JSON.stringify({
|
|
245
|
+
model,
|
|
246
|
+
prompt,
|
|
247
|
+
stream: false,
|
|
248
|
+
options: { temperature: 0.35, num_predict: 160 },
|
|
249
|
+
}),
|
|
250
|
+
});
|
|
251
|
+
if (!response.ok) throw new Error(`Ollama generate failed: ${response.status} ${await response.text()}`);
|
|
252
|
+
const body = await response.json();
|
|
253
|
+
return cleanReply(body.response || "");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function assertOllamaModel(modelName) {
|
|
257
|
+
let response;
|
|
258
|
+
try {
|
|
259
|
+
response = await fetch("http://127.0.0.1:11434/api/tags");
|
|
260
|
+
} catch {
|
|
261
|
+
throw new Error("Ollama is not running. Start it with `ollama serve` or open the Ollama app.");
|
|
262
|
+
}
|
|
263
|
+
if (!response.ok) throw new Error(`Ollama tags failed: ${response.status}`);
|
|
264
|
+
const body = await response.json();
|
|
265
|
+
const names = (body.models || []).map((item) => item.name);
|
|
266
|
+
if (!names.some((name) => name === modelName || name.startsWith(`${modelName}:`))) {
|
|
267
|
+
throw new Error(`Ollama model "${modelName}" is not installed. Run: ollama pull ${modelName}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function cleanReply(value) {
|
|
272
|
+
return String(value)
|
|
273
|
+
.replace(/^["'\s]+|["'\s]+$/g, "")
|
|
274
|
+
.replace(/^Reply:\s*/i, "")
|
|
275
|
+
.split("\n")
|
|
276
|
+
.filter((line) => !/^[-*]\s+/.test(line.trim()))
|
|
277
|
+
.join(" ")
|
|
278
|
+
.replace(/\s+/g, " ")
|
|
279
|
+
.trim()
|
|
280
|
+
.slice(0, 1200);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function isAllowed(chatOrName) {
|
|
284
|
+
if (allowAll) return true;
|
|
285
|
+
const name = typeof chatOrName === "string" ? chatOrName : chatName(chatOrName);
|
|
286
|
+
if (contactNumbers.some((number) => chatMatchesContact(chatOrName, number))) return true;
|
|
287
|
+
return allow.some((item) => name.toLowerCase().includes(item.toLowerCase()));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function isExplicitlyAllowed(chatOrName) {
|
|
291
|
+
const name = typeof chatOrName === "string" ? chatOrName : chatName(chatOrName);
|
|
292
|
+
if (contactNumbers.some((number) => chatMatchesContact(chatOrName, number))) return true;
|
|
293
|
+
return allow.some((item) => name.toLowerCase().includes(item.toLowerCase()));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function isDenied(name) {
|
|
297
|
+
return deny.some((item) => name.toLowerCase().includes(item.toLowerCase()));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function shouldSkipNonPersonalChat(chatOrName, recentMessages) {
|
|
301
|
+
if (includeBusinesses || isExplicitlyAllowed(chatOrName)) return false;
|
|
302
|
+
const name = typeof chatOrName === "string" ? chatOrName : chatName(chatOrName);
|
|
303
|
+
return isLikelyBusinessOrAutomation(name, recentMessages);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function isLikelyBusinessOrAutomation(name, recentMessages = []) {
|
|
307
|
+
const nameText = normalizeBusinessText(name);
|
|
308
|
+
const recentText = recentMessages
|
|
309
|
+
.slice(-8)
|
|
310
|
+
.map((message) => normalizeBusinessText(message.body || ""))
|
|
311
|
+
.join(" ");
|
|
312
|
+
if (isShortCodeOrServiceNumber(nameText)) return true;
|
|
313
|
+
if (BUSINESS_NAME_RE.test(nameText)) return true;
|
|
314
|
+
if (BUSINESS_BODY_RE.test(recentText)) return true;
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function isShortCodeOrServiceNumber(text) {
|
|
319
|
+
const compacted = text.replace(/\D/g, "");
|
|
320
|
+
return compacted.length >= 4 && compacted.length <= 8 && compacted.length / Math.max(text.length, 1) > 0.7;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function normalizeBusinessText(value) {
|
|
324
|
+
return String(value || "").toLowerCase().replace(/\s+/g, " ").trim();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function chatMatchesContact(chatOrName, normalizedPhone) {
|
|
328
|
+
if (!normalizedPhone) return false;
|
|
329
|
+
if (typeof chatOrName === "string") {
|
|
330
|
+
return phoneCandidateMatches(normalizePhone(chatOrName), normalizedPhone);
|
|
331
|
+
}
|
|
332
|
+
const candidates = [
|
|
333
|
+
chatOrName.id?.user,
|
|
334
|
+
chatOrName.id?._serialized,
|
|
335
|
+
chatOrName.name,
|
|
336
|
+
chatOrName.formattedTitle,
|
|
337
|
+
].map(normalizePhone).filter(Boolean);
|
|
338
|
+
return candidates.some((candidate) => phoneCandidateMatches(candidate, normalizedPhone));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function phoneCandidateMatches(candidate, expected) {
|
|
342
|
+
if (!candidate || !expected) return false;
|
|
343
|
+
return candidate.endsWith(expected) || expected.endsWith(candidate);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function normalizePhone(input) {
|
|
347
|
+
const digits = String(input || "").replace(/[^\d]/g, "");
|
|
348
|
+
return digits.length >= 8 ? digits : "";
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function maskPhone(value) {
|
|
352
|
+
return value.length <= 4 ? "****" : `****${value.slice(-4)}`;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function allowlistSummary() {
|
|
356
|
+
const parts = [];
|
|
357
|
+
if (allow.length) parts.push(`names: ${allow.join(", ")}`);
|
|
358
|
+
if (contactNumbers.length) parts.push(`contacts: ${contactNumbers.map(maskPhone).join(", ")}`);
|
|
359
|
+
return `Allowlist: ${parts.join("; ")}`;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function isCoolingDown(name) {
|
|
363
|
+
const last = state.lastSentAtByChat[name];
|
|
364
|
+
if (!last) return false;
|
|
365
|
+
return Date.now() - new Date(last).getTime() < cooldownMinutes * 60 * 1000;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function replyCount(name) {
|
|
369
|
+
return state.sentCountByChat[name] || 0;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function markProcessed(message, name, options = { sent: true }) {
|
|
373
|
+
state.processedMessageIds.push(message.id?._serialized);
|
|
374
|
+
state.processedMessageIds = state.processedMessageIds.filter(Boolean).slice(-1000);
|
|
375
|
+
if (options.sent !== false) {
|
|
376
|
+
state.lastSentAtByChat[name] = new Date().toISOString();
|
|
377
|
+
state.sentCountByChat[name] = replyCount(name) + 1;
|
|
378
|
+
}
|
|
379
|
+
writeJsonAtomic(statePath, state);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function logDraft(name, reply, trigger) {
|
|
383
|
+
const record = baseRecord(name, reply, trigger);
|
|
384
|
+
appendLog("auto-drafts.jsonl", record);
|
|
385
|
+
fs.appendFileSync(path.join(outboundDir, "Auto Drafts.md"), `- ${record.timestamp} | ${name}: ${visibleMessage(record, reply)}\n`);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function logSent(name, reply, sent, trigger) {
|
|
389
|
+
const record = {
|
|
390
|
+
...baseRecord(name, reply, trigger),
|
|
391
|
+
messageId: sent.id?._serialized || null,
|
|
392
|
+
};
|
|
393
|
+
if (outboundLogMode !== "off") {
|
|
394
|
+
appendLog("sent.jsonl", record);
|
|
395
|
+
fs.appendFileSync(path.join(outboundDir, "Sent.md"), `- ${record.timestamp} | ${name}: ${visibleMessage(record, reply)}\n`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function baseRecord(name, reply, trigger) {
|
|
400
|
+
return {
|
|
401
|
+
timestamp: new Date().toISOString(),
|
|
402
|
+
to: name,
|
|
403
|
+
resolvedChatName: name,
|
|
404
|
+
message: outboundLogMode === "full" ? reply : undefined,
|
|
405
|
+
messageHash: hash(reply),
|
|
406
|
+
messageCharCount: reply.length,
|
|
407
|
+
triggerMessageId: trigger.id?._serialized || null,
|
|
408
|
+
aiAssisted: true,
|
|
409
|
+
autoReply: true,
|
|
410
|
+
disclosureIncluded: containsDisclosure(reply),
|
|
411
|
+
disclosureBypassed: false,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function appendLog(filename, record) {
|
|
416
|
+
fs.appendFileSync(path.join(outboundDir, filename), `${JSON.stringify(record)}\n`);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function disclosureStatus(chatNameValue) {
|
|
420
|
+
const logPath = path.join(outboundDir, "sent.jsonl");
|
|
421
|
+
if (!fs.existsSync(logPath)) return { required: false, count: 0 };
|
|
422
|
+
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
|
423
|
+
const records = fs
|
|
424
|
+
.readFileSync(logPath, "utf8")
|
|
425
|
+
.split("\n")
|
|
426
|
+
.filter(Boolean)
|
|
427
|
+
.map(parseJsonLine)
|
|
428
|
+
.filter((record) => record)
|
|
429
|
+
.filter((record) => record.resolvedChatName === chatNameValue)
|
|
430
|
+
.filter((record) => record.aiAssisted !== false);
|
|
431
|
+
const alreadyDisclosed = records.some((record) => record.disclosureIncluded);
|
|
432
|
+
if (alreadyDisclosed) return { required: false, count: 0, alreadyDisclosed: true };
|
|
433
|
+
const count = records
|
|
434
|
+
.filter((record) => new Date(record.timestamp).getTime() >= cutoff)
|
|
435
|
+
.filter((record) => !record.disclosureIncluded).length;
|
|
436
|
+
return { required: count >= 2, count, alreadyDisclosed: false };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function parseJsonLine(line) {
|
|
440
|
+
try {
|
|
441
|
+
return JSON.parse(line);
|
|
442
|
+
} catch {
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function containsDisclosure(message) {
|
|
448
|
+
return /\b(ai|assistant|automated|bot)\b/i.test(message);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function loadState() {
|
|
452
|
+
if (!fs.existsSync(statePath)) return { processedMessageIds: [], lastSentAtByChat: {}, sentCountByChat: {} };
|
|
453
|
+
try {
|
|
454
|
+
return JSON.parse(fs.readFileSync(statePath, "utf8"));
|
|
455
|
+
} catch {
|
|
456
|
+
return { processedMessageIds: [], lastSentAtByChat: {}, sentCountByChat: {} };
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function writeJsonAtomic(file, value) {
|
|
461
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
462
|
+
fs.writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`);
|
|
463
|
+
fs.renameSync(temp, file);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function visibleMessage(record, reply) {
|
|
467
|
+
return outboundLogMode === "full" ? reply : `[metadata only, ${record.messageCharCount} chars, ${record.messageHash.slice(0, 12)}]`;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function chatName(chat) {
|
|
471
|
+
return chat.name || chat.formattedTitle || chat.id?._serialized || "Unknown Chat";
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function compact(value) {
|
|
475
|
+
return String(value).replace(/\s+/g, " ").trim().slice(0, 500);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function summarize(value) {
|
|
479
|
+
return compact(value).slice(0, 120);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function readConfig(vaultPath) {
|
|
483
|
+
const file = path.join(vaultPath, "digital-brain.config.json");
|
|
484
|
+
if (!fs.existsSync(file)) return {};
|
|
485
|
+
try {
|
|
486
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
487
|
+
} catch {
|
|
488
|
+
return {};
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function hash(value) {
|
|
493
|
+
return crypto.createHash("sha256").update(value).digest("hex");
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function parseList(value) {
|
|
497
|
+
return String(value || "").split(",").map((item) => item.trim()).filter(Boolean);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function numberArg(name, fallback) {
|
|
501
|
+
const parsed = Number(args[name] || fallback);
|
|
502
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function parseArgs(argv) {
|
|
506
|
+
const out = { yes: false };
|
|
507
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
508
|
+
const arg = argv[i];
|
|
509
|
+
if (arg === "--yes") out.yes = true;
|
|
510
|
+
else if (arg === "--allow-all") out["allow-all"] = true;
|
|
511
|
+
else if (arg === "--include-groups") out["include-groups"] = true;
|
|
512
|
+
else if (arg === "--include-businesses") out["include-businesses"] = true;
|
|
513
|
+
else if (arg === "--no-process-unread") out["no-process-unread"] = true;
|
|
514
|
+
else if (arg.startsWith("--")) {
|
|
515
|
+
const key = arg.slice(2);
|
|
516
|
+
out[key] = argv[++i] || "";
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return out;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function usage() {
|
|
523
|
+
console.error('Usage: digital-brain auto-whatsapp --allow "Name" --contact "+15551234567" --model llama3.1 [--yes] [--allow-all] [--include-groups] [--include-businesses]');
|
|
524
|
+
process.exit(1);
|
|
525
|
+
}
|
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) {
|