digital-brain 1.1.1 → 1.1.11
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 +22 -4
- package/bin/digital-brain.js +13 -4
- package/docs/AUTOMATIONS.md +41 -6
- 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 +424 -32
- package/whatsapp-web/send.mjs +15 -10
package/README.md
CHANGED
|
@@ -78,11 +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
|
-
- Run an explicit WhatsApp auto-responder that uses
|
|
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 Ollama or a Codex command plus vault memory while the command is running.
|
|
86
87
|
- Enforce an AI-disclosure guard after repeated AI-assisted sends.
|
|
87
88
|
|
|
88
89
|
## Core Commands
|
|
@@ -96,6 +97,8 @@ 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
|
|
101
|
+
digital-brain auto-whatsapp --allow-all --provider codex --yes
|
|
99
102
|
```
|
|
100
103
|
|
|
101
104
|
`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 +118,29 @@ digital-brain interpret
|
|
|
115
118
|
|
|
116
119
|
The sender drafts by default. Add `--yes` to actually send.
|
|
117
120
|
|
|
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:
|
|
121
|
+
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
122
|
|
|
120
123
|
```bash
|
|
121
124
|
digital-brain auto-whatsapp --allow "Mom" --model llama3.1
|
|
122
125
|
digital-brain auto-whatsapp --allow "Mom" --model llama3.1 --yes
|
|
126
|
+
digital-brain auto-whatsapp --allow "Mom" --model llama3.1 --yes --no-process-unread
|
|
127
|
+
digital-brain auto-whatsapp --contact "+15551234567" --model llama3.1 --yes
|
|
128
|
+
digital-brain auto-whatsapp --allow-all --provider codex --yes
|
|
123
129
|
```
|
|
124
130
|
|
|
125
|
-
If
|
|
131
|
+
If you start without `--allow`, `--contact`, or `--allow-all` in an interactive terminal, Digital Brain asks whether to cover all contacts or select contacts from your WhatsApp chat list. With `--allow-all`, it still asks once before the first AI reply to each new chat and stores the decision in `08 Sources/WhatsApp/Outbound/auto-reply-whitelist.json`. Use `--auto-approve-new-chats` only if you intentionally want unattended first sends.
|
|
132
|
+
|
|
133
|
+
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.
|
|
134
|
+
|
|
135
|
+
The default provider is local Ollama. To use Codex instead:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
digital-brain auto-whatsapp --allow-all --provider codex --yes
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
If your Codex CLI needs a custom command, pass `--codex-command "..."` or set `DIGITAL_BRAIN_CODEX_COMMAND`. If the command contains `{promptFile}`, Digital Brain writes the reply prompt to a temp file and substitutes that path; otherwise it pipes the prompt to stdin.
|
|
142
|
+
|
|
143
|
+
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
144
|
|
|
127
145
|
## Automation
|
|
128
146
|
|
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" --provider ollama|codex --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,64 @@ 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
|
|
99
|
+
`digital-brain auto-whatsapp` is separate from refresh automation. It uses WhatsApp Web for live incoming messages and either Ollama or a Codex command for 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
|
|
106
|
+
digital-brain auto-whatsapp --allow-all --provider codex
|
|
104
107
|
```
|
|
105
108
|
|
|
106
109
|
Auto-send while the command is running:
|
|
107
110
|
|
|
108
111
|
```bash
|
|
109
112
|
digital-brain auto-whatsapp --allow "Mom" --model llama3.1 --yes
|
|
113
|
+
digital-brain auto-whatsapp --contact "+15551234567" --model llama3.1 --yes
|
|
114
|
+
digital-brain auto-whatsapp --allow-all --provider codex --yes
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Broad auto-send for personal chats:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
digital-brain auto-whatsapp --allow-all --model llama3.1 --yes
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`--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.
|
|
124
|
+
|
|
125
|
+
When `--allow-all` is used, Digital Brain asks once before the first AI reply to each new chat and stores allow/deny decisions in `08 Sources/WhatsApp/Outbound/auto-reply-whitelist.json`. Use `--auto-approve-new-chats` only for fully unattended first sends.
|
|
126
|
+
|
|
127
|
+
Provider options:
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
digital-brain auto-whatsapp --allow "Mom" --provider ollama --model llama3.1 --yes
|
|
131
|
+
digital-brain auto-whatsapp --allow "Mom" --provider codex --yes
|
|
132
|
+
digital-brain auto-whatsapp --allow "Mom" --provider codex --codex-command "codex exec --skip-git-repo-check" --yes
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`--provider codex` runs a local Codex command. If `--codex-command` contains `{promptFile}`, Digital Brain writes the prompt to a temp file and substitutes the path; otherwise it pipes the prompt to stdin.
|
|
136
|
+
|
|
137
|
+
If you selected `Auto-send while running` during init, `auto-whatsapp` can send without `--yes` while it is running:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
digital-brain auto-whatsapp --allow "Mom" --model llama3.1
|
|
110
141
|
```
|
|
111
142
|
|
|
112
143
|
Guardrails:
|
|
113
144
|
|
|
114
|
-
- requires Ollama running locally
|
|
115
|
-
- requires the selected model, for example `ollama pull llama3.1`
|
|
116
|
-
-
|
|
145
|
+
- with `--provider ollama`, requires Ollama running locally
|
|
146
|
+
- with `--provider ollama`, requires the selected model, for example `ollama pull llama3.1`
|
|
147
|
+
- with `--provider codex`, requires a working local Codex command
|
|
148
|
+
- requires `--allow "Name"` or `--contact "+15551234567"` unless `--allow-all` is explicitly passed
|
|
149
|
+
- single-threads reply generation so multiple incoming chats do not trigger overlapping sends
|
|
150
|
+
- skips likely business, notification, OTP, and service chats unless `--include-businesses` is passed or the chat is explicitly allowlisted by name or contact number
|
|
151
|
+
- processes unread chats on startup unless `--no-process-unread` is passed
|
|
117
152
|
- skips groups unless `--include-groups` is passed
|
|
118
153
|
- uses a per-chat cooldown, default 20 minutes
|
|
119
154
|
- caps replies per chat per run, default 5
|
|
120
155
|
- logs metadata by default, not full sent text
|
|
121
|
-
-
|
|
156
|
+
- enforces the AI disclosure rule after repeated AI-assisted sends, but does not repeat it after that chat has already received a disclosure
|
|
122
157
|
|
|
123
158
|
## Local Cron
|
|
124
159
|
|
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 or a configured local Codex command, 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.
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import readline from "node:readline/promises";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
4
7
|
import qrcode from "qrcode-terminal";
|
|
5
8
|
import pkg from "whatsapp-web.js";
|
|
6
9
|
|
|
7
10
|
const { Client, LocalAuth } = pkg;
|
|
11
|
+
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;
|
|
12
|
+
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
13
|
const args = parseArgs(process.argv.slice(2));
|
|
9
14
|
|
|
10
15
|
if (!args.vault) usage();
|
|
@@ -14,18 +19,34 @@ const whatsAppDir = path.join(vault, "08 Sources", "WhatsApp");
|
|
|
14
19
|
const outboundDir = path.join(whatsAppDir, "Outbound");
|
|
15
20
|
const sessionDir = path.join(whatsAppDir, ".session");
|
|
16
21
|
const statePath = path.join(outboundDir, "auto-reply-state.json");
|
|
22
|
+
const whitelistPath = path.join(outboundDir, "auto-reply-whitelist.json");
|
|
17
23
|
const config = readConfig(vault);
|
|
24
|
+
const provider = args.provider || config.autoReplyProvider || "ollama";
|
|
18
25
|
const model = args.model || config.autoReplyModel || "llama3.1";
|
|
26
|
+
const codexCommand = args["codex-command"] || process.env.DIGITAL_BRAIN_CODEX_COMMAND || config.codexCommand || "codex exec --skip-git-repo-check";
|
|
19
27
|
const allow = parseList(args.allow || "");
|
|
20
28
|
const deny = parseList(args.deny || "");
|
|
29
|
+
const contactNumbers = parseList([args.contact, args.phone, args["contact-number"]].filter(Boolean).join(","))
|
|
30
|
+
.map(normalizePhone)
|
|
31
|
+
.filter(Boolean);
|
|
21
32
|
const allowAll = Boolean(args["allow-all"]);
|
|
33
|
+
let runtimeAllowAll = allowAll;
|
|
34
|
+
const autoApproveNewChats = Boolean(args["auto-approve-new-chats"]);
|
|
22
35
|
const includeGroups = Boolean(args["include-groups"]);
|
|
23
|
-
const
|
|
36
|
+
const includeBusinesses = Boolean(args["include-businesses"]);
|
|
37
|
+
const sendEnabled = Boolean(args.yes) || config.outboundMode === "auto-send";
|
|
38
|
+
const processUnreadOnStart = !Boolean(args["no-process-unread"]);
|
|
24
39
|
const cooldownMinutes = numberArg("cooldown-minutes", 20);
|
|
25
40
|
const maxRepliesPerChat = numberArg("max-replies-per-chat", 5);
|
|
26
41
|
const maxContextChars = numberArg("max-context-chars", 12000);
|
|
27
42
|
const outboundLogMode = args["log-mode"] || config.outboundLogMode || "metadata";
|
|
28
43
|
const state = loadState();
|
|
44
|
+
const whitelist = loadWhitelist();
|
|
45
|
+
const hasStoredScope = Object.keys(whitelist.allowedChats || {}).length > 0;
|
|
46
|
+
const hasInitialScope = allowAll || allow.length > 0 || contactNumbers.length > 0 || hasStoredScope;
|
|
47
|
+
const interactiveTerminal = Boolean(input.isTTY && output.isTTY);
|
|
48
|
+
let queueTail = Promise.resolve();
|
|
49
|
+
const rl = interactiveTerminal ? readline.createInterface({ input, output }) : null;
|
|
29
50
|
|
|
30
51
|
fs.mkdirSync(outboundDir, { recursive: true });
|
|
31
52
|
|
|
@@ -34,12 +55,16 @@ if (config.outboundMode === "disabled") {
|
|
|
34
55
|
process.exit(1);
|
|
35
56
|
}
|
|
36
57
|
|
|
37
|
-
if (!
|
|
38
|
-
console.error('Refusing to auto-reply without an allowlist. Add --allow "Name" or pass --allow-all explicitly.');
|
|
58
|
+
if (!hasInitialScope && !interactiveTerminal) {
|
|
59
|
+
console.error('Refusing to auto-reply without an allowlist. Add --allow "Name", --contact "+15551234567", or pass --allow-all explicitly.');
|
|
39
60
|
process.exit(1);
|
|
40
61
|
}
|
|
41
62
|
|
|
42
|
-
|
|
63
|
+
if (provider === "ollama") {
|
|
64
|
+
await assertOllamaModel(model);
|
|
65
|
+
} else if (!["codex"].includes(provider)) {
|
|
66
|
+
throw new Error(`Unsupported auto-reply provider "${provider}". Use "ollama" or "codex".`);
|
|
67
|
+
}
|
|
43
68
|
|
|
44
69
|
const client = new Client({
|
|
45
70
|
authStrategy: new LocalAuth({ clientId: "digital-brain", dataPath: sessionDir }),
|
|
@@ -51,15 +76,29 @@ client.on("qr", (qr) => {
|
|
|
51
76
|
qrcode.generate(qr, { small: true });
|
|
52
77
|
});
|
|
53
78
|
|
|
54
|
-
client.on("ready", () => {
|
|
55
|
-
console.log(`Digital Brain WhatsApp auto-reply running with
|
|
56
|
-
console.log(sendEnabled ? "Auto-send is enabled." : "Draft mode. Replies will be logged but not sent. Add --yes to send.");
|
|
57
|
-
|
|
79
|
+
client.on("ready", async () => {
|
|
80
|
+
console.log(`Digital Brain WhatsApp auto-reply running with provider: ${provider}${provider === "ollama" ? ` (${model})` : ""}`);
|
|
81
|
+
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.");
|
|
82
|
+
if (!hasInitialScope) await configureInteractiveScope();
|
|
83
|
+
console.log(runtimeAllowAll ? "Allowlist: all chats, with first-send approval per new chat." : allowlistSummary());
|
|
84
|
+
if (provider === "codex") console.log(`Codex command: ${codexCommand}`);
|
|
85
|
+
if (!includeBusinesses) console.log("Likely business, notification, OTP, and service chats are skipped by default.");
|
|
86
|
+
try {
|
|
87
|
+
if (processUnreadOnStart) {
|
|
88
|
+
await processUnreadChats();
|
|
89
|
+
} else {
|
|
90
|
+
console.log("Startup unread scan disabled.");
|
|
91
|
+
}
|
|
92
|
+
} catch (error) {
|
|
93
|
+
console.error(`Startup unread scan failed: ${error.message}`);
|
|
94
|
+
}
|
|
95
|
+
console.log("Listening for new WhatsApp messages...");
|
|
58
96
|
});
|
|
59
97
|
|
|
60
98
|
client.on("message", async (message) => {
|
|
61
99
|
try {
|
|
62
|
-
|
|
100
|
+
console.log(`Received WhatsApp message event: ${summarize(message.body || "[non-text message]")}`);
|
|
101
|
+
enqueueMessage(message);
|
|
63
102
|
} catch (error) {
|
|
64
103
|
console.error(`Auto-reply error: ${error.message}`);
|
|
65
104
|
}
|
|
@@ -67,19 +106,156 @@ client.on("message", async (message) => {
|
|
|
67
106
|
|
|
68
107
|
client.initialize();
|
|
69
108
|
|
|
70
|
-
async function
|
|
109
|
+
async function processUnreadChats() {
|
|
110
|
+
const chats = await client.getChats();
|
|
111
|
+
const unreadChats = chats.filter((chat) => Number(chat.unreadCount || 0) > 0);
|
|
112
|
+
console.log(`Startup unread scan: ${unreadChats.length} unread chat(s).`);
|
|
113
|
+
for (const chat of unreadChats) {
|
|
114
|
+
const name = chatName(chat);
|
|
115
|
+
if (chat.isGroup && !includeGroups) {
|
|
116
|
+
console.log(`Skipping unread group chat: ${name}`);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (!isAllowed(chat) || isDenied(name)) {
|
|
120
|
+
console.log(`Skipping unread chat outside allowlist: ${name}`);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (isCoolingDown(name)) {
|
|
124
|
+
console.log(`Skipping unread chat during cooldown: ${name}`);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (replyCount(name) >= maxRepliesPerChat) {
|
|
128
|
+
console.log(`Skipping unread chat at reply cap: ${name}`);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const recentMessages = await chat.fetchMessages({ limit: Math.max(12, Number(chat.unreadCount || 0) + 3) });
|
|
132
|
+
const latestInbound = recentMessages.filter((message) => !message.fromMe && !message.isStatus).at(-1);
|
|
133
|
+
if (!latestInbound) {
|
|
134
|
+
console.log(`No inbound unread candidate found for: ${name}`);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
console.log(`Processing unread chat: ${name}`);
|
|
138
|
+
await enqueueMessage(latestInbound, chat);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function configureInteractiveScope() {
|
|
143
|
+
console.log("");
|
|
144
|
+
console.log("Auto-reply scope:");
|
|
145
|
+
console.log(" A) all contacts, but ask once before the first AI reply to each new chat");
|
|
146
|
+
console.log(" S) select contacts from your WhatsApp chat list");
|
|
147
|
+
console.log(" Q) quit");
|
|
148
|
+
const answer = (await rl.question("Choose A/S/Q [S]: ")).trim().toLowerCase() || "s";
|
|
149
|
+
if (answer.startsWith("q")) {
|
|
150
|
+
await shutdown(0);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (answer.startsWith("a")) {
|
|
154
|
+
runtimeAllowAll = true;
|
|
155
|
+
console.log("Scope set to all contacts with first-send approval.");
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const chats = (await client.getChats())
|
|
159
|
+
.filter((chat) => includeGroups || !chat.isGroup)
|
|
160
|
+
.sort((a, b) => Number(b.timestamp || 0) - Number(a.timestamp || 0))
|
|
161
|
+
.slice(0, numberArg("select-limit", 60));
|
|
162
|
+
if (chats.length === 0) {
|
|
163
|
+
console.log("No WhatsApp chats found to select.");
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
console.log("");
|
|
167
|
+
console.log("Select contacts:");
|
|
168
|
+
chats.forEach((chat, index) => {
|
|
169
|
+
console.log(` ${index + 1}) ${chatName(chat)}`);
|
|
170
|
+
});
|
|
171
|
+
const selection = await rl.question("Numbers to whitelist, comma-separated: ");
|
|
172
|
+
const selected = parseIndexSelection(selection, chats.length).map((index) => chats[index]);
|
|
173
|
+
for (const chat of selected) {
|
|
174
|
+
setWhitelistDecision(chat, "allow", "startup-select");
|
|
175
|
+
}
|
|
176
|
+
console.log(`Whitelisted ${selected.length} chat(s).`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function enqueueMessage(message, knownChat = null) {
|
|
180
|
+
queueTail = queueTail
|
|
181
|
+
.catch(() => undefined)
|
|
182
|
+
.then(() => handleMessage(message, knownChat))
|
|
183
|
+
.catch((error) => console.error(`Auto-reply error: ${error.message}`));
|
|
184
|
+
return queueTail;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function ensureChatApproved(chat, recentMessages) {
|
|
188
|
+
const name = chatName(chat);
|
|
189
|
+
if (isExplicitlyAllowed(chat) || isChatWhitelisted(chat)) return true;
|
|
190
|
+
if (isChatDenied(chat)) {
|
|
191
|
+
console.log(`Skipping stored denied chat: ${name}`);
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
if (!runtimeAllowAll) return false;
|
|
195
|
+
if (autoApproveNewChats) {
|
|
196
|
+
setWhitelistDecision(chat, "allow", "auto-approve-new-chats");
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
if (!interactiveTerminal) {
|
|
200
|
+
console.log(`Skipping ${name}: first-send approval required but no interactive terminal is available.`);
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
console.log("");
|
|
204
|
+
console.log(`New chat needs AI approval: ${name}`);
|
|
205
|
+
const inbound = recentMessages.filter((item) => !item.fromMe && !item.isStatus).slice(-3);
|
|
206
|
+
for (const item of inbound) {
|
|
207
|
+
console.log(` ${name}: ${summarize(item.body || "[non-text message]")}`);
|
|
208
|
+
}
|
|
209
|
+
const answer = (await rl.question("Whitelist this chat for AI auto-replies? [y/N]: ")).trim().toLowerCase();
|
|
210
|
+
if (answer === "y" || answer === "yes") {
|
|
211
|
+
setWhitelistDecision(chat, "allow", "first-send-prompt");
|
|
212
|
+
console.log(`Whitelisted ${name}.`);
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
setWhitelistDecision(chat, "deny", "first-send-prompt");
|
|
216
|
+
console.log(`Denied ${name}.`);
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function handleMessage(message, knownChat = null) {
|
|
71
221
|
if (message.fromMe || message.isStatus) return;
|
|
72
222
|
if (state.processedMessageIds.includes(message.id?._serialized)) return;
|
|
73
223
|
|
|
74
|
-
const chat = await message.getChat();
|
|
224
|
+
const chat = knownChat || await message.getChat();
|
|
75
225
|
const name = chatName(chat);
|
|
76
|
-
|
|
77
|
-
if (!
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
226
|
+
console.log(`Received from ${name}: ${summarize(message.body || "[non-text message]")}`);
|
|
227
|
+
if (chat.isGroup && !includeGroups) {
|
|
228
|
+
console.log(`Skipping group chat: ${name}`);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (!isAllowed(chat)) {
|
|
232
|
+
console.log(`Skipping chat outside allowlist: ${name}`);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (isDenied(name)) {
|
|
236
|
+
console.log(`Skipping denied chat: ${name}`);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (isCoolingDown(name)) {
|
|
240
|
+
console.log(`Skipping chat during cooldown: ${name}`);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (replyCount(name) >= maxRepliesPerChat) {
|
|
244
|
+
console.log(`Skipping chat at reply cap: ${name}`);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
81
247
|
|
|
248
|
+
console.log(`Fetching context for ${name}...`);
|
|
82
249
|
const recentMessages = await chat.fetchMessages({ limit: 12 });
|
|
250
|
+
if (shouldSkipNonPersonalChat(chat, recentMessages)) {
|
|
251
|
+
console.log(`Skipping likely business/notification chat: ${name}`);
|
|
252
|
+
markProcessed(message, name, { sent: false });
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (!(await ensureChatApproved(chat, recentMessages))) {
|
|
256
|
+
markProcessed(message, name, { sent: false });
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
83
259
|
const disclosure = disclosureStatus(name);
|
|
84
260
|
const prompt = buildPrompt({
|
|
85
261
|
chatName: name,
|
|
@@ -87,7 +263,10 @@ async function handleMessage(message) {
|
|
|
87
263
|
recentMessages,
|
|
88
264
|
disclosureRequired: disclosure.required,
|
|
89
265
|
});
|
|
266
|
+
console.log(`Generating reply for ${name} with ${provider}${provider === "ollama" ? `:${model}` : ""}...`);
|
|
267
|
+
const startedAt = Date.now();
|
|
90
268
|
const reply = await generateReply(prompt);
|
|
269
|
+
console.log(`Generated reply for ${name} in ${Date.now() - startedAt}ms: ${summarize(reply || "[empty reply]")}`);
|
|
91
270
|
const finalReply = disclosure.required && !containsDisclosure(reply)
|
|
92
271
|
? `Just flagging this is my AI assistant helping draft/send this. ${reply}`
|
|
93
272
|
: reply;
|
|
@@ -115,6 +294,12 @@ function buildPrompt({ chatName, incomingBody, recentMessages, disclosureRequire
|
|
|
115
294
|
"You are helping the user reply on WhatsApp.",
|
|
116
295
|
"Write exactly one message to send as the user.",
|
|
117
296
|
"Be natural, concise, and relationship-appropriate.",
|
|
297
|
+
"First infer the immediate intent of the current conversation from the recent chat. Continue that thread only.",
|
|
298
|
+
"Do not start with hey/hi unless the recent chat itself uses that greeting pattern.",
|
|
299
|
+
"Mirror the vocabulary, register, and pacing already present in this chat.",
|
|
300
|
+
"Match the user's own communication style from My Communication Style. If it says lowercase-heavy or undercapitalized, prefer lowercase casual texting.",
|
|
301
|
+
"Use lexical signals from My Communication Style: recurring style words, openers, short phrase shapes, punctuation habits, and lowercase-i behavior.",
|
|
302
|
+
"Do not sound like customer support, corporate email, or a generic AI assistant.",
|
|
118
303
|
"Use the user's local memory context, but do not reveal private notes or say you read a vault.",
|
|
119
304
|
"Do not invent facts, commitments, times, or promises.",
|
|
120
305
|
disclosureRequired ? "This send requires AI disclosure. Include a short clear disclosure in the message." : "Do not mention AI unless disclosure is required.",
|
|
@@ -135,6 +320,7 @@ function buildPrompt({ chatName, incomingBody, recentMessages, disclosureRequire
|
|
|
135
320
|
|
|
136
321
|
function readMemoryContext(chatName) {
|
|
137
322
|
const files = [
|
|
323
|
+
path.join(vault, "06 AI Memory", "My Communication Style.md"),
|
|
138
324
|
path.join(vault, "06 AI Memory", "Person Reply Context.md"),
|
|
139
325
|
path.join(vault, "06 AI Memory", "Person Context Index.md"),
|
|
140
326
|
path.join(vault, "06 AI Memory", "Interpreted Relationship Memory.md"),
|
|
@@ -157,6 +343,11 @@ function readMemoryContext(chatName) {
|
|
|
157
343
|
}
|
|
158
344
|
|
|
159
345
|
async function generateReply(prompt) {
|
|
346
|
+
if (provider === "codex") return generateCodexReply(prompt);
|
|
347
|
+
return generateOllamaReply(prompt);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function generateOllamaReply(prompt) {
|
|
160
351
|
const response = await fetch("http://127.0.0.1:11434/api/generate", {
|
|
161
352
|
method: "POST",
|
|
162
353
|
headers: { "content-type": "application/json" },
|
|
@@ -172,6 +363,44 @@ async function generateReply(prompt) {
|
|
|
172
363
|
return cleanReply(body.response || "");
|
|
173
364
|
}
|
|
174
365
|
|
|
366
|
+
async function generateCodexReply(prompt) {
|
|
367
|
+
return runReplyCommand(codexCommand, prompt, "codex");
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function runReplyCommand(command, prompt, label) {
|
|
371
|
+
const timeoutMs = numberArg("provider-timeout-ms", 120000);
|
|
372
|
+
const usesPromptFile = command.includes("{promptFile}");
|
|
373
|
+
const promptFile = usesPromptFile ? path.join(outboundDir, `reply-prompt-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`) : "";
|
|
374
|
+
if (promptFile) fs.writeFileSync(promptFile, prompt, "utf8");
|
|
375
|
+
const renderedCommand = promptFile ? command.replaceAll("{promptFile}", shellQuote(promptFile)) : command;
|
|
376
|
+
return await new Promise((resolve, reject) => {
|
|
377
|
+
const child = spawn(renderedCommand, { shell: true, cwd: vault, env: process.env });
|
|
378
|
+
let stdout = "";
|
|
379
|
+
let stderr = "";
|
|
380
|
+
const timer = setTimeout(() => {
|
|
381
|
+
child.kill("SIGTERM");
|
|
382
|
+
reject(new Error(`${label} reply command timed out after ${timeoutMs}ms`));
|
|
383
|
+
}, timeoutMs);
|
|
384
|
+
child.stdout.on("data", (chunk) => { stdout += chunk.toString(); });
|
|
385
|
+
child.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
|
|
386
|
+
child.on("error", (error) => {
|
|
387
|
+
clearTimeout(timer);
|
|
388
|
+
cleanupPromptFile(promptFile);
|
|
389
|
+
reject(error);
|
|
390
|
+
});
|
|
391
|
+
child.on("close", (code) => {
|
|
392
|
+
clearTimeout(timer);
|
|
393
|
+
cleanupPromptFile(promptFile);
|
|
394
|
+
if (code !== 0) {
|
|
395
|
+
reject(new Error(`${label} reply command failed with ${code}: ${summarize(stderr || stdout)}`));
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
resolve(cleanReply(stdout));
|
|
399
|
+
});
|
|
400
|
+
if (!usesPromptFile) child.stdin.end(prompt);
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
175
404
|
async function assertOllamaModel(modelName) {
|
|
176
405
|
let response;
|
|
177
406
|
try {
|
|
@@ -199,8 +428,18 @@ function cleanReply(value) {
|
|
|
199
428
|
.slice(0, 1200);
|
|
200
429
|
}
|
|
201
430
|
|
|
202
|
-
function isAllowed(
|
|
203
|
-
if (
|
|
431
|
+
function isAllowed(chatOrName) {
|
|
432
|
+
if (isChatDenied(chatOrName)) return false;
|
|
433
|
+
if (isChatWhitelisted(chatOrName)) return true;
|
|
434
|
+
if (runtimeAllowAll) return true;
|
|
435
|
+
const name = typeof chatOrName === "string" ? chatOrName : chatName(chatOrName);
|
|
436
|
+
if (contactNumbers.some((number) => chatMatchesContact(chatOrName, number))) return true;
|
|
437
|
+
return allow.some((item) => name.toLowerCase().includes(item.toLowerCase()));
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function isExplicitlyAllowed(chatOrName) {
|
|
441
|
+
const name = typeof chatOrName === "string" ? chatOrName : chatName(chatOrName);
|
|
442
|
+
if (contactNumbers.some((number) => chatMatchesContact(chatOrName, number))) return true;
|
|
204
443
|
return allow.some((item) => name.toLowerCase().includes(item.toLowerCase()));
|
|
205
444
|
}
|
|
206
445
|
|
|
@@ -208,6 +447,130 @@ function isDenied(name) {
|
|
|
208
447
|
return deny.some((item) => name.toLowerCase().includes(item.toLowerCase()));
|
|
209
448
|
}
|
|
210
449
|
|
|
450
|
+
function shouldSkipNonPersonalChat(chatOrName, recentMessages) {
|
|
451
|
+
if (includeBusinesses || isExplicitlyAllowed(chatOrName) || isChatWhitelisted(chatOrName)) return false;
|
|
452
|
+
const name = typeof chatOrName === "string" ? chatOrName : chatName(chatOrName);
|
|
453
|
+
return isLikelyBusinessOrAutomation(name, recentMessages);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function isLikelyBusinessOrAutomation(name, recentMessages = []) {
|
|
457
|
+
const nameText = normalizeBusinessText(name);
|
|
458
|
+
const recentText = recentMessages
|
|
459
|
+
.slice(-8)
|
|
460
|
+
.map((message) => normalizeBusinessText(message.body || ""))
|
|
461
|
+
.join(" ");
|
|
462
|
+
if (isShortCodeOrServiceNumber(nameText)) return true;
|
|
463
|
+
if (BUSINESS_NAME_RE.test(nameText)) return true;
|
|
464
|
+
if (BUSINESS_BODY_RE.test(recentText)) return true;
|
|
465
|
+
return false;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function isShortCodeOrServiceNumber(text) {
|
|
469
|
+
const compacted = text.replace(/\D/g, "");
|
|
470
|
+
return compacted.length >= 4 && compacted.length <= 8 && compacted.length / Math.max(text.length, 1) > 0.7;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function normalizeBusinessText(value) {
|
|
474
|
+
return String(value || "").toLowerCase().replace(/\s+/g, " ").trim();
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function chatMatchesContact(chatOrName, normalizedPhone) {
|
|
478
|
+
if (!normalizedPhone) return false;
|
|
479
|
+
if (typeof chatOrName === "string") {
|
|
480
|
+
return phoneCandidateMatches(normalizePhone(chatOrName), normalizedPhone);
|
|
481
|
+
}
|
|
482
|
+
const candidates = [
|
|
483
|
+
chatOrName.id?.user,
|
|
484
|
+
chatOrName.id?._serialized,
|
|
485
|
+
chatOrName.name,
|
|
486
|
+
chatOrName.formattedTitle,
|
|
487
|
+
].map(normalizePhone).filter(Boolean);
|
|
488
|
+
return candidates.some((candidate) => phoneCandidateMatches(candidate, normalizedPhone));
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function phoneCandidateMatches(candidate, expected) {
|
|
492
|
+
if (!candidate || !expected) return false;
|
|
493
|
+
return candidate.endsWith(expected) || expected.endsWith(candidate);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function normalizePhone(input) {
|
|
497
|
+
const digits = String(input || "").replace(/[^\d]/g, "");
|
|
498
|
+
return digits.length >= 8 ? digits : "";
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function maskPhone(value) {
|
|
502
|
+
return value.length <= 4 ? "****" : `****${value.slice(-4)}`;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function allowlistSummary() {
|
|
506
|
+
const parts = [];
|
|
507
|
+
if (allow.length) parts.push(`names: ${allow.join(", ")}`);
|
|
508
|
+
if (contactNumbers.length) parts.push(`contacts: ${contactNumbers.map(maskPhone).join(", ")}`);
|
|
509
|
+
const storedCount = Object.keys(whitelist.allowedChats || {}).length;
|
|
510
|
+
if (storedCount) parts.push(`stored: ${storedCount} chat(s)`);
|
|
511
|
+
return `Allowlist: ${parts.join("; ")}`;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function isChatWhitelisted(chatOrName) {
|
|
515
|
+
return Boolean(whitelist.allowedChats?.[chatKey(chatOrName)]);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function isChatDenied(chatOrName) {
|
|
519
|
+
return Boolean(whitelist.deniedChats?.[chatKey(chatOrName)]);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function setWhitelistDecision(chat, decision, source) {
|
|
523
|
+
const key = chatKey(chat);
|
|
524
|
+
const record = {
|
|
525
|
+
chatName: chatName(chat),
|
|
526
|
+
chatId: typeof chat === "string" ? null : chat.id?._serialized || null,
|
|
527
|
+
source,
|
|
528
|
+
updatedAt: new Date().toISOString(),
|
|
529
|
+
};
|
|
530
|
+
if (decision === "allow") {
|
|
531
|
+
whitelist.allowedChats[key] = record;
|
|
532
|
+
delete whitelist.deniedChats[key];
|
|
533
|
+
} else {
|
|
534
|
+
whitelist.deniedChats[key] = record;
|
|
535
|
+
delete whitelist.allowedChats[key];
|
|
536
|
+
}
|
|
537
|
+
writeJsonAtomic(whitelistPath, whitelist);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function chatKey(chatOrName) {
|
|
541
|
+
if (typeof chatOrName === "string") return `name:${chatOrName.toLowerCase()}`;
|
|
542
|
+
return chatOrName.id?._serialized || `name:${chatName(chatOrName).toLowerCase()}`;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function loadWhitelist() {
|
|
546
|
+
if (!fs.existsSync(whitelistPath)) return emptyWhitelist();
|
|
547
|
+
try {
|
|
548
|
+
return normalizeWhitelist(JSON.parse(fs.readFileSync(whitelistPath, "utf8")));
|
|
549
|
+
} catch {
|
|
550
|
+
return emptyWhitelist();
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function normalizeWhitelist(value) {
|
|
555
|
+
return {
|
|
556
|
+
schemaVersion: 1,
|
|
557
|
+
allowedChats: value?.allowedChats || {},
|
|
558
|
+
deniedChats: value?.deniedChats || {},
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function emptyWhitelist() {
|
|
563
|
+
return { schemaVersion: 1, allowedChats: {}, deniedChats: {} };
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function parseIndexSelection(value, max) {
|
|
567
|
+
return [...new Set(String(value || "")
|
|
568
|
+
.split(/[,\s]+/)
|
|
569
|
+
.map((item) => Number(item.trim()))
|
|
570
|
+
.filter((item) => Number.isInteger(item) && item >= 1 && item <= max)
|
|
571
|
+
.map((item) => item - 1))];
|
|
572
|
+
}
|
|
573
|
+
|
|
211
574
|
function isCoolingDown(name) {
|
|
212
575
|
const last = state.lastSentAtByChat[name];
|
|
213
576
|
if (!last) return false;
|
|
@@ -218,11 +581,13 @@ function replyCount(name) {
|
|
|
218
581
|
return state.sentCountByChat[name] || 0;
|
|
219
582
|
}
|
|
220
583
|
|
|
221
|
-
function markProcessed(message, name) {
|
|
584
|
+
function markProcessed(message, name, options = { sent: true }) {
|
|
222
585
|
state.processedMessageIds.push(message.id?._serialized);
|
|
223
586
|
state.processedMessageIds = state.processedMessageIds.filter(Boolean).slice(-1000);
|
|
224
|
-
|
|
225
|
-
|
|
587
|
+
if (options.sent !== false) {
|
|
588
|
+
state.lastSentAtByChat[name] = new Date().toISOString();
|
|
589
|
+
state.sentCountByChat[name] = replyCount(name) + 1;
|
|
590
|
+
}
|
|
226
591
|
writeJsonAtomic(statePath, state);
|
|
227
592
|
}
|
|
228
593
|
|
|
@@ -267,23 +632,28 @@ function disclosureStatus(chatNameValue) {
|
|
|
267
632
|
const logPath = path.join(outboundDir, "sent.jsonl");
|
|
268
633
|
if (!fs.existsSync(logPath)) return { required: false, count: 0 };
|
|
269
634
|
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
|
270
|
-
const
|
|
635
|
+
const records = fs
|
|
271
636
|
.readFileSync(logPath, "utf8")
|
|
272
637
|
.split("\n")
|
|
273
638
|
.filter(Boolean)
|
|
274
|
-
.map(
|
|
275
|
-
try {
|
|
276
|
-
return JSON.parse(line);
|
|
277
|
-
} catch {
|
|
278
|
-
return null;
|
|
279
|
-
}
|
|
280
|
-
})
|
|
639
|
+
.map(parseJsonLine)
|
|
281
640
|
.filter((record) => record)
|
|
282
641
|
.filter((record) => record.resolvedChatName === chatNameValue)
|
|
283
|
-
.filter((record) => record.aiAssisted !== false)
|
|
642
|
+
.filter((record) => record.aiAssisted !== false);
|
|
643
|
+
const alreadyDisclosed = records.some((record) => record.disclosureIncluded);
|
|
644
|
+
if (alreadyDisclosed) return { required: false, count: 0, alreadyDisclosed: true };
|
|
645
|
+
const count = records
|
|
284
646
|
.filter((record) => new Date(record.timestamp).getTime() >= cutoff)
|
|
285
647
|
.filter((record) => !record.disclosureIncluded).length;
|
|
286
|
-
return { required: count >= 2, count };
|
|
648
|
+
return { required: count >= 2, count, alreadyDisclosed: false };
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function parseJsonLine(line) {
|
|
652
|
+
try {
|
|
653
|
+
return JSON.parse(line);
|
|
654
|
+
} catch {
|
|
655
|
+
return null;
|
|
656
|
+
}
|
|
287
657
|
}
|
|
288
658
|
|
|
289
659
|
function containsDisclosure(message) {
|
|
@@ -305,6 +675,26 @@ function writeJsonAtomic(file, value) {
|
|
|
305
675
|
fs.renameSync(temp, file);
|
|
306
676
|
}
|
|
307
677
|
|
|
678
|
+
function cleanupPromptFile(file) {
|
|
679
|
+
if (file) {
|
|
680
|
+
try {
|
|
681
|
+
fs.unlinkSync(file);
|
|
682
|
+
} catch {
|
|
683
|
+
// ignore cleanup errors
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function shellQuote(value) {
|
|
689
|
+
return `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
async function shutdown(code) {
|
|
693
|
+
if (rl) rl.close();
|
|
694
|
+
await client.destroy().catch(() => undefined);
|
|
695
|
+
process.exit(code);
|
|
696
|
+
}
|
|
697
|
+
|
|
308
698
|
function visibleMessage(record, reply) {
|
|
309
699
|
return outboundLogMode === "full" ? reply : `[metadata only, ${record.messageCharCount} chars, ${record.messageHash.slice(0, 12)}]`;
|
|
310
700
|
}
|
|
@@ -351,6 +741,8 @@ function parseArgs(argv) {
|
|
|
351
741
|
if (arg === "--yes") out.yes = true;
|
|
352
742
|
else if (arg === "--allow-all") out["allow-all"] = true;
|
|
353
743
|
else if (arg === "--include-groups") out["include-groups"] = true;
|
|
744
|
+
else if (arg === "--include-businesses") out["include-businesses"] = true;
|
|
745
|
+
else if (arg === "--no-process-unread") out["no-process-unread"] = true;
|
|
354
746
|
else if (arg.startsWith("--")) {
|
|
355
747
|
const key = arg.slice(2);
|
|
356
748
|
out[key] = argv[++i] || "";
|
|
@@ -360,6 +752,6 @@ function parseArgs(argv) {
|
|
|
360
752
|
}
|
|
361
753
|
|
|
362
754
|
function usage() {
|
|
363
|
-
console.error('Usage: digital-brain auto-whatsapp --allow "Name" --model llama3.1 [--yes] [--allow-all] [--include-groups]');
|
|
755
|
+
console.error('Usage: digital-brain auto-whatsapp --allow "Name" --contact "+15551234567" --model llama3.1 [--yes] [--allow-all] [--include-groups] [--include-businesses]');
|
|
364
756
|
process.exit(1);
|
|
365
757
|
}
|
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) {
|