digital-brain 0.1.0
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/LICENSE +21 -0
- package/README.md +113 -0
- package/bin/digital-brain.js +209 -0
- package/docs/AUTOMATIONS.md +70 -0
- package/docs/PRIVACY.md +17 -0
- package/docs/ROADMAP.md +9 -0
- package/examples/sample-vault/04 People/Interpreted Relationships/Close Friend.md +29 -0
- package/examples/sample-vault/04 People/Interpreted Relationships/Mom.md +30 -0
- package/examples/sample-vault/04 People/Interpreted Relationships/Project Team.md +30 -0
- package/examples/sample-vault/06 AI Memory/Interpreted Relationship Memory.md +7 -0
- package/examples/sample-vault/08 Sources/WhatsApp/Analysis/Interpreted/Close Friend.md +29 -0
- package/examples/sample-vault/08 Sources/WhatsApp/Analysis/Interpreted/Mom.md +30 -0
- package/examples/sample-vault/08 Sources/WhatsApp/Analysis/Interpreted/Project Team.md +30 -0
- package/examples/sample-vault/08 Sources/WhatsApp/Analysis/Relationship Map.md +29 -0
- package/examples/sample-vault/08 Sources/WhatsApp/Analysis/interpreted_relationship_models.json +109 -0
- package/examples/sample-vault/08 Sources/WhatsApp/Analysis/relationship_profiles.json +68 -0
- package/examples/sample-vault/08 Sources/WhatsApp/Raw/2026-01-01.jsonl +6 -0
- package/examples/sample-vault/08 Sources/WhatsApp/relationship_overrides.json +7 -0
- package/lib/fs.js +32 -0
- package/package.json +43 -0
- package/scripts/digital_brain_relationship_extractor.py +165 -0
- package/scripts/digital_brain_relationship_interpreter.py +196 -0
- package/scripts/digital_brain_whatsapp_mac_sync.py +153 -0
- package/templates/vault/00 Home/How AI Should Use This Vault.md +11 -0
- package/templates/vault/00 Home/Start Here.md +11 -0
- package/templates/vault/01 Identity/Working Profile.md +11 -0
- package/templates/vault/04 People/Relationship Overrides.md +16 -0
- package/templates/vault/06 AI Memory/Interpreted Relationship Memory.md +4 -0
- package/templates/vault/06 AI Memory/What AI Should Remember.md +4 -0
- package/templates/vault/08 Sources/WhatsApp/Outbound/README.md +20 -0
- package/templates/vault/08 Sources/WhatsApp/relationship_overrides.example.json +8 -0
- package/templates/vault/AGENTS.md +19 -0
- package/templates/vault/CLAUDE.md +6 -0
- package/templates/vault/GEMINI.md +5 -0
- package/whatsapp-web/send.mjs +139 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import argparse
|
|
3
|
+
import json
|
|
4
|
+
import sqlite3
|
|
5
|
+
import time
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
CORE_DATA_EPOCH_OFFSET = 978_307_200
|
|
10
|
+
DEFAULT_DB = Path.home() / "Library/Group Containers/group.net.whatsapp.WhatsApp.shared/ChatStorage.sqlite"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main():
|
|
14
|
+
args = parse_args()
|
|
15
|
+
vault = args.vault.resolve()
|
|
16
|
+
whats_app = vault / "08 Sources" / "WhatsApp"
|
|
17
|
+
raw_dir = whats_app / "Raw"
|
|
18
|
+
chats_dir = whats_app / "Chats"
|
|
19
|
+
state_dir = whats_app / ".sync-state"
|
|
20
|
+
seen_path = state_dir / "mac-seen-message-ids.json"
|
|
21
|
+
for directory in (raw_dir, chats_dir, state_dir):
|
|
22
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
|
|
24
|
+
seen = load_seen(seen_path)
|
|
25
|
+
total = 0
|
|
26
|
+
while True:
|
|
27
|
+
added = sync_once(args, seen, raw_dir, chats_dir, whats_app)
|
|
28
|
+
total += added
|
|
29
|
+
save_seen(seen_path, seen)
|
|
30
|
+
print(f"Added {added} new messages. Total this run: {total}.")
|
|
31
|
+
if not args.live:
|
|
32
|
+
break
|
|
33
|
+
time.sleep(args.interval)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def sync_once(args, seen, raw_dir, chats_dir, whats_app):
|
|
37
|
+
if not args.db.exists():
|
|
38
|
+
raise SystemExit(f"WhatsApp database not found: {args.db}")
|
|
39
|
+
|
|
40
|
+
cutoff = time.time() - args.days * 24 * 60 * 60 - CORE_DATA_EPOCH_OFFSET
|
|
41
|
+
conn = sqlite3.connect(f"file:{args.db}?mode=ro", uri=True)
|
|
42
|
+
conn.row_factory = sqlite3.Row
|
|
43
|
+
rows = conn.execute(
|
|
44
|
+
"""
|
|
45
|
+
SELECT m.Z_PK message_pk, m.ZSTANZAID stanza_id, m.ZMESSAGEDATE message_date,
|
|
46
|
+
m.ZISFROMME is_from_me, m.ZFROMJID from_jid, m.ZTOJID to_jid,
|
|
47
|
+
m.ZPUSHNAME push_name, m.ZTEXT text, m.ZMESSAGETYPE message_type,
|
|
48
|
+
c.Z_PK chat_pk, c.ZPARTNERNAME chat_name, c.ZCONTACTJID chat_jid,
|
|
49
|
+
c.ZSESSIONTYPE session_type
|
|
50
|
+
FROM ZWAMESSAGE m
|
|
51
|
+
LEFT JOIN ZWACHATSESSION c ON c.Z_PK = m.ZCHATSESSION
|
|
52
|
+
WHERE m.ZMESSAGEDATE >= ?
|
|
53
|
+
AND m.ZTEXT IS NOT NULL
|
|
54
|
+
AND length(m.ZTEXT) > 0
|
|
55
|
+
ORDER BY m.ZMESSAGEDATE ASC, m.Z_PK ASC
|
|
56
|
+
""",
|
|
57
|
+
(cutoff,),
|
|
58
|
+
).fetchall()
|
|
59
|
+
conn.close()
|
|
60
|
+
|
|
61
|
+
added = 0
|
|
62
|
+
for row in rows:
|
|
63
|
+
record = row_to_record(row, args.self_name)
|
|
64
|
+
if args.no_groups and record["isGroup"]:
|
|
65
|
+
continue
|
|
66
|
+
if args.chat and args.chat.lower() not in record["chatName"].lower():
|
|
67
|
+
continue
|
|
68
|
+
if record["id"] in seen:
|
|
69
|
+
continue
|
|
70
|
+
append_jsonl(raw_dir, record)
|
|
71
|
+
append_markdown(chats_dir, whats_app, record, args.markdown_mode)
|
|
72
|
+
seen.add(record["id"])
|
|
73
|
+
added += 1
|
|
74
|
+
return added
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def row_to_record(row, self_name):
|
|
78
|
+
timestamp = datetime.fromtimestamp(float(row["message_date"]) + CORE_DATA_EPOCH_OFFSET, tz=timezone.utc).isoformat()
|
|
79
|
+
chat_name = row["chat_name"] or row["chat_jid"] or "Unknown Chat"
|
|
80
|
+
from_me = bool(row["is_from_me"])
|
|
81
|
+
return {
|
|
82
|
+
"id": str(row["stanza_id"] or f"mac-db-{row['message_pk']}"),
|
|
83
|
+
"source": "WhatsApp Mac app ChatStorage.sqlite",
|
|
84
|
+
"timestamp": timestamp,
|
|
85
|
+
"chatPk": row["chat_pk"],
|
|
86
|
+
"chatName": chat_name,
|
|
87
|
+
"chatJid": row["chat_jid"],
|
|
88
|
+
"isGroup": bool(row["chat_jid"] and "@g.us" in row["chat_jid"]) or row["session_type"] not in (None, 0),
|
|
89
|
+
"fromMe": from_me,
|
|
90
|
+
"author": self_name if from_me else (row["push_name"] or row["from_jid"] or "Unknown"),
|
|
91
|
+
"fromJid": row["from_jid"],
|
|
92
|
+
"toJid": row["to_jid"],
|
|
93
|
+
"messageType": row["message_type"],
|
|
94
|
+
"body": row["text"] or "",
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def append_jsonl(raw_dir, record):
|
|
99
|
+
with (raw_dir / f"{record['timestamp'][:10]}.jsonl").open("a", encoding="utf-8") as f:
|
|
100
|
+
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def append_markdown(chats_dir, whats_app, record, mode):
|
|
104
|
+
if mode == "none":
|
|
105
|
+
return
|
|
106
|
+
if mode == "month":
|
|
107
|
+
directory = whats_app / "ChatsByMonth" / record["timestamp"][:7]
|
|
108
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
file_path = directory / f"{safe_filename(record['chatName'])}.md"
|
|
110
|
+
else:
|
|
111
|
+
file_path = chats_dir / f"{safe_filename(record['chatName'])}.md"
|
|
112
|
+
if not file_path.exists():
|
|
113
|
+
file_path.write_text(f"# {record['chatName']}\n\nSynced from WhatsApp Mac app.\n\n", encoding="utf-8")
|
|
114
|
+
speaker = record["author"]
|
|
115
|
+
body = " ".join(record["body"].split())
|
|
116
|
+
with file_path.open("a", encoding="utf-8") as f:
|
|
117
|
+
f.write(f"- {record['timestamp']} | {speaker}: {body}\n")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def load_seen(path):
|
|
121
|
+
if not path.exists():
|
|
122
|
+
return set()
|
|
123
|
+
try:
|
|
124
|
+
return set(json.loads(path.read_text(encoding="utf-8")))
|
|
125
|
+
except Exception:
|
|
126
|
+
return set()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def save_seen(path, seen):
|
|
130
|
+
path.write_text(json.dumps(sorted(seen), indent=2), encoding="utf-8")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def safe_filename(value):
|
|
134
|
+
cleaned = "".join("-" if char in '/:\\?%*"<>|' else char for char in value)
|
|
135
|
+
return (" ".join(cleaned.split()).strip() or "Unknown Chat")[:120]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def parse_args():
|
|
139
|
+
parser = argparse.ArgumentParser()
|
|
140
|
+
parser.add_argument("--vault", type=Path, required=True)
|
|
141
|
+
parser.add_argument("--days", type=int, default=30)
|
|
142
|
+
parser.add_argument("--live", action="store_true")
|
|
143
|
+
parser.add_argument("--interval", type=int, default=60)
|
|
144
|
+
parser.add_argument("--chat", default="")
|
|
145
|
+
parser.add_argument("--no-groups", action="store_true")
|
|
146
|
+
parser.add_argument("--db", type=Path, default=DEFAULT_DB)
|
|
147
|
+
parser.add_argument("--self-name", default="Me")
|
|
148
|
+
parser.add_argument("--markdown-mode", choices=["chat", "month", "none"], default="chat")
|
|
149
|
+
return parser.parse_args()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
if __name__ == "__main__":
|
|
153
|
+
main()
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# How AI Should Use This Vault
|
|
2
|
+
|
|
3
|
+
Use this vault to understand the user's preferences, relationships, communication patterns, and working context.
|
|
4
|
+
|
|
5
|
+
Rules:
|
|
6
|
+
|
|
7
|
+
- Prefer local evidence over assumptions.
|
|
8
|
+
- Be clear when a claim is inferred.
|
|
9
|
+
- Treat generated relationship notes as provisional.
|
|
10
|
+
- Do not send messages without explicit permission.
|
|
11
|
+
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Relationship Overrides
|
|
2
|
+
|
|
3
|
+
Use `08 Sources/WhatsApp/relationship_overrides.json` to manually label important people.
|
|
4
|
+
|
|
5
|
+
Example:
|
|
6
|
+
|
|
7
|
+
```json
|
|
8
|
+
{
|
|
9
|
+
"Mom": {
|
|
10
|
+
"role": "mother",
|
|
11
|
+
"confidence": "high",
|
|
12
|
+
"notes": "Manual label."
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
```
|
|
16
|
+
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# WhatsApp Outbound
|
|
2
|
+
|
|
3
|
+
Outbound sending is disabled unless you explicitly run the sender with `--yes`.
|
|
4
|
+
|
|
5
|
+
Draft:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
digital-brain send-whatsapp --vault . --to "Name" --message "Text"
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Send:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
digital-brain send-whatsapp --vault . --to "Name" --message "Text" --yes
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Disclosure rule:
|
|
18
|
+
|
|
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.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Digital Brain Context
|
|
2
|
+
|
|
3
|
+
This is a Digital Brain vault.
|
|
4
|
+
|
|
5
|
+
Before answering personal-context questions, read:
|
|
6
|
+
|
|
7
|
+
- `00 Home/Start Here.md`
|
|
8
|
+
- `00 Home/How AI Should Use This Vault.md`
|
|
9
|
+
- `01 Identity/Working Profile.md`
|
|
10
|
+
- `06 AI Memory/What AI Should Remember.md`
|
|
11
|
+
- `06 AI Memory/Interpreted Relationship Memory.md`
|
|
12
|
+
|
|
13
|
+
Rules:
|
|
14
|
+
|
|
15
|
+
- Keep answers direct.
|
|
16
|
+
- Separate facts from interpretation.
|
|
17
|
+
- Treat relationship labels as editable working notes.
|
|
18
|
+
- Do not expose private summaries unless asked.
|
|
19
|
+
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import qrcode from "qrcode-terminal";
|
|
4
|
+
import pkg from "whatsapp-web.js";
|
|
5
|
+
|
|
6
|
+
const { Client, LocalAuth } = pkg;
|
|
7
|
+
const args = parseArgs(process.argv.slice(2));
|
|
8
|
+
|
|
9
|
+
if (!args.vault || !args.to || !args.message) usage();
|
|
10
|
+
|
|
11
|
+
const vault = path.resolve(args.vault);
|
|
12
|
+
const whatsAppDir = path.join(vault, "08 Sources", "WhatsApp");
|
|
13
|
+
const outboundDir = path.join(whatsAppDir, "Outbound");
|
|
14
|
+
const sessionDir = path.join(whatsAppDir, ".session");
|
|
15
|
+
fs.mkdirSync(outboundDir, { recursive: true });
|
|
16
|
+
|
|
17
|
+
if (!args.yes) {
|
|
18
|
+
console.log("Draft only. Nothing sent.");
|
|
19
|
+
console.log(`To: ${args.to}`);
|
|
20
|
+
console.log(`Message: ${args.message}`);
|
|
21
|
+
console.log("Re-run with --yes to send.");
|
|
22
|
+
process.exit(2);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const client = new Client({
|
|
26
|
+
authStrategy: new LocalAuth({ clientId: "digital-brain", dataPath: sessionDir }),
|
|
27
|
+
puppeteer: { headless: false, args: ["--no-sandbox", "--disable-setuid-sandbox"] },
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
client.on("qr", (qr) => {
|
|
31
|
+
console.log("Scan this QR in WhatsApp > Linked devices:");
|
|
32
|
+
qrcode.generate(qr, { small: true });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
client.on("ready", async () => {
|
|
36
|
+
try {
|
|
37
|
+
const chat = await resolveChat(args.to);
|
|
38
|
+
const disclosure = disclosureStatus(chat);
|
|
39
|
+
if (disclosure.required && !containsDisclosure(args.message) && !args["skip-disclosure-check"]) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
'AI disclosure required before sending. Add a clear disclosure like: "Just flagging this is my AI assistant helping draft/send this."',
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
const sent = await chat.sendMessage(args.message);
|
|
45
|
+
const record = {
|
|
46
|
+
timestamp: new Date().toISOString(),
|
|
47
|
+
to: args.to,
|
|
48
|
+
resolvedChatName: chatName(chat),
|
|
49
|
+
message: args.message,
|
|
50
|
+
messageId: sent.id?._serialized || null,
|
|
51
|
+
aiAssisted: !args.human,
|
|
52
|
+
disclosureIncluded: containsDisclosure(args.message),
|
|
53
|
+
};
|
|
54
|
+
fs.appendFileSync(path.join(outboundDir, "sent.jsonl"), `${JSON.stringify(record)}\n`);
|
|
55
|
+
fs.appendFileSync(path.join(outboundDir, "Sent.md"), `- ${record.timestamp} | ${record.resolvedChatName}: ${record.message}\n`);
|
|
56
|
+
console.log(`Sent to ${record.resolvedChatName}`);
|
|
57
|
+
await client.destroy();
|
|
58
|
+
process.exit(0);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
console.error(`Send failed: ${error.message}`);
|
|
61
|
+
await client.destroy();
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
client.initialize();
|
|
67
|
+
|
|
68
|
+
async function resolveChat(target) {
|
|
69
|
+
const number = normalizePhone(target);
|
|
70
|
+
if (number) {
|
|
71
|
+
const id = await client.getNumberId(number);
|
|
72
|
+
if (!id) throw new Error(`No WhatsApp account found for phone ${target}`);
|
|
73
|
+
return await client.getChatById(id._serialized);
|
|
74
|
+
}
|
|
75
|
+
const chats = await client.getChats();
|
|
76
|
+
const matches = chats.filter((chat) => chatName(chat).toLowerCase().includes(target.toLowerCase()));
|
|
77
|
+
if (matches.length === 0) throw new Error(`No chat matched "${target}"`);
|
|
78
|
+
if (matches.length > 1) throw new Error(`Multiple chats matched "${target}": ${matches.slice(0, 10).map(chatName).join(", ")}`);
|
|
79
|
+
return matches[0];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function normalizePhone(input) {
|
|
83
|
+
if (!input.startsWith("+") && !/^\d{8,}$/.test(input)) return "";
|
|
84
|
+
return input.replace(/[^\d]/g, "");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function chatName(chat) {
|
|
88
|
+
return chat.name || chat.formattedTitle || chat.id?._serialized || "Unknown Chat";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function parseArgs(argv) {
|
|
92
|
+
const out = { yes: false };
|
|
93
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
94
|
+
const arg = argv[i];
|
|
95
|
+
if (arg === "--yes") out.yes = true;
|
|
96
|
+
else if (arg === "--human") out.human = true;
|
|
97
|
+
else if (arg === "--skip-disclosure-check") out["skip-disclosure-check"] = true;
|
|
98
|
+
else if (arg.startsWith("--")) {
|
|
99
|
+
const key = arg.slice(2);
|
|
100
|
+
out[key] = argv[++i] || "";
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function usage() {
|
|
107
|
+
console.error('Usage: digital-brain send-whatsapp --vault <path> --to "Name" --message "Text" [--yes]');
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function disclosureStatus(chat) {
|
|
112
|
+
const logPath = path.join(outboundDir, "sent.jsonl");
|
|
113
|
+
if (!fs.existsSync(logPath)) return { required: false, count: 0 };
|
|
114
|
+
|
|
115
|
+
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
|
116
|
+
const name = chatName(chat);
|
|
117
|
+
const count = fs
|
|
118
|
+
.readFileSync(logPath, "utf8")
|
|
119
|
+
.split("\n")
|
|
120
|
+
.filter(Boolean)
|
|
121
|
+
.map((line) => {
|
|
122
|
+
try {
|
|
123
|
+
return JSON.parse(line);
|
|
124
|
+
} catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
.filter((record) => record)
|
|
129
|
+
.filter((record) => record.resolvedChatName === name)
|
|
130
|
+
.filter((record) => record.aiAssisted !== false)
|
|
131
|
+
.filter((record) => new Date(record.timestamp).getTime() >= cutoff)
|
|
132
|
+
.filter((record) => !record.disclosureIncluded).length;
|
|
133
|
+
|
|
134
|
+
return { required: count >= 2, count };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function containsDisclosure(message) {
|
|
138
|
+
return /\b(ai|assistant|automated|bot)\b/i.test(message);
|
|
139
|
+
}
|