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
package/examples/sample-vault/08 Sources/WhatsApp/Analysis/interpreted_relationship_models.json
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"chatName": "Mom",
|
|
4
|
+
"messageCount": 2,
|
|
5
|
+
"inbound": 1,
|
|
6
|
+
"outbound": 1,
|
|
7
|
+
"outboundShare": 0.5,
|
|
8
|
+
"firstSeen": "2026-01-01",
|
|
9
|
+
"lastSeen": "2026-01-01",
|
|
10
|
+
"isGroup": false,
|
|
11
|
+
"sentimentScore": 0.159,
|
|
12
|
+
"warmthScore": 0.5,
|
|
13
|
+
"frictionScore": 0.0,
|
|
14
|
+
"operationalScore": 1.5,
|
|
15
|
+
"questionCount": 0,
|
|
16
|
+
"relationshipGuess": "warm personal relationship",
|
|
17
|
+
"tags": [
|
|
18
|
+
"direct-chat",
|
|
19
|
+
"light",
|
|
20
|
+
"warm",
|
|
21
|
+
"logistics-heavy"
|
|
22
|
+
],
|
|
23
|
+
"role": "mother",
|
|
24
|
+
"roleConfidence": "high",
|
|
25
|
+
"roleReason": "manual override",
|
|
26
|
+
"closeness": "medium",
|
|
27
|
+
"conversationDifficulty": "low",
|
|
28
|
+
"reciprocity": "balanced",
|
|
29
|
+
"howToContinue": [
|
|
30
|
+
"Use warmer language than a work chat.",
|
|
31
|
+
"Do not make the interaction purely transactional.",
|
|
32
|
+
"Track open loops, plans, dates, and commitments."
|
|
33
|
+
],
|
|
34
|
+
"boundaries": [
|
|
35
|
+
"Do not expose private summaries unless asked."
|
|
36
|
+
]
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"chatName": "Project Team",
|
|
40
|
+
"messageCount": 2,
|
|
41
|
+
"inbound": 1,
|
|
42
|
+
"outbound": 1,
|
|
43
|
+
"outboundShare": 0.5,
|
|
44
|
+
"firstSeen": "2026-01-01",
|
|
45
|
+
"lastSeen": "2026-01-01",
|
|
46
|
+
"isGroup": true,
|
|
47
|
+
"sentimentScore": 0,
|
|
48
|
+
"warmthScore": 0.0,
|
|
49
|
+
"frictionScore": 0.0,
|
|
50
|
+
"operationalScore": 3.0,
|
|
51
|
+
"questionCount": 1,
|
|
52
|
+
"relationshipGuess": "group, likely social or mixed context",
|
|
53
|
+
"tags": [
|
|
54
|
+
"group-chat",
|
|
55
|
+
"light",
|
|
56
|
+
"logistics-heavy",
|
|
57
|
+
"question-heavy"
|
|
58
|
+
],
|
|
59
|
+
"role": "work collaborator",
|
|
60
|
+
"roleConfidence": "medium",
|
|
61
|
+
"roleReason": "matched chat name",
|
|
62
|
+
"closeness": "low/unclear",
|
|
63
|
+
"conversationDifficulty": "practical/low-emotional",
|
|
64
|
+
"reciprocity": "balanced",
|
|
65
|
+
"howToContinue": [
|
|
66
|
+
"Lead with context, next steps, and clear asks.",
|
|
67
|
+
"Keep emotional interpretation light.",
|
|
68
|
+
"Track open loops, plans, dates, and commitments."
|
|
69
|
+
],
|
|
70
|
+
"boundaries": [
|
|
71
|
+
"Do not expose private summaries unless asked."
|
|
72
|
+
]
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"chatName": "Close Friend",
|
|
76
|
+
"messageCount": 2,
|
|
77
|
+
"inbound": 1,
|
|
78
|
+
"outbound": 1,
|
|
79
|
+
"outboundShare": 0.5,
|
|
80
|
+
"firstSeen": "2026-01-01",
|
|
81
|
+
"lastSeen": "2026-01-01",
|
|
82
|
+
"isGroup": false,
|
|
83
|
+
"sentimentScore": 0.159,
|
|
84
|
+
"warmthScore": 1.0,
|
|
85
|
+
"frictionScore": 0.0,
|
|
86
|
+
"operationalScore": 0.5,
|
|
87
|
+
"questionCount": 0,
|
|
88
|
+
"relationshipGuess": "warm personal relationship",
|
|
89
|
+
"tags": [
|
|
90
|
+
"direct-chat",
|
|
91
|
+
"light",
|
|
92
|
+
"warm",
|
|
93
|
+
"logistics-heavy"
|
|
94
|
+
],
|
|
95
|
+
"role": "operational contact",
|
|
96
|
+
"roleConfidence": "low",
|
|
97
|
+
"roleReason": "logistics-heavy communication",
|
|
98
|
+
"closeness": "medium",
|
|
99
|
+
"conversationDifficulty": "low",
|
|
100
|
+
"reciprocity": "balanced",
|
|
101
|
+
"howToContinue": [
|
|
102
|
+
"Keep tone neutral until the user labels this relationship.",
|
|
103
|
+
"Track open loops, plans, dates, and commitments."
|
|
104
|
+
],
|
|
105
|
+
"boundaries": [
|
|
106
|
+
"Do not expose private summaries unless asked."
|
|
107
|
+
]
|
|
108
|
+
}
|
|
109
|
+
]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"chatName": "Mom",
|
|
4
|
+
"messageCount": 2,
|
|
5
|
+
"inbound": 1,
|
|
6
|
+
"outbound": 1,
|
|
7
|
+
"outboundShare": 0.5,
|
|
8
|
+
"firstSeen": "2026-01-01",
|
|
9
|
+
"lastSeen": "2026-01-01",
|
|
10
|
+
"isGroup": false,
|
|
11
|
+
"sentimentScore": 0.159,
|
|
12
|
+
"warmthScore": 0.5,
|
|
13
|
+
"frictionScore": 0.0,
|
|
14
|
+
"operationalScore": 1.5,
|
|
15
|
+
"questionCount": 0,
|
|
16
|
+
"relationshipGuess": "warm personal relationship",
|
|
17
|
+
"tags": [
|
|
18
|
+
"direct-chat",
|
|
19
|
+
"light",
|
|
20
|
+
"warm",
|
|
21
|
+
"logistics-heavy"
|
|
22
|
+
]
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"chatName": "Project Team",
|
|
26
|
+
"messageCount": 2,
|
|
27
|
+
"inbound": 1,
|
|
28
|
+
"outbound": 1,
|
|
29
|
+
"outboundShare": 0.5,
|
|
30
|
+
"firstSeen": "2026-01-01",
|
|
31
|
+
"lastSeen": "2026-01-01",
|
|
32
|
+
"isGroup": true,
|
|
33
|
+
"sentimentScore": 0,
|
|
34
|
+
"warmthScore": 0.0,
|
|
35
|
+
"frictionScore": 0.0,
|
|
36
|
+
"operationalScore": 3.0,
|
|
37
|
+
"questionCount": 1,
|
|
38
|
+
"relationshipGuess": "group, likely social or mixed context",
|
|
39
|
+
"tags": [
|
|
40
|
+
"group-chat",
|
|
41
|
+
"light",
|
|
42
|
+
"logistics-heavy",
|
|
43
|
+
"question-heavy"
|
|
44
|
+
]
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"chatName": "Close Friend",
|
|
48
|
+
"messageCount": 2,
|
|
49
|
+
"inbound": 1,
|
|
50
|
+
"outbound": 1,
|
|
51
|
+
"outboundShare": 0.5,
|
|
52
|
+
"firstSeen": "2026-01-01",
|
|
53
|
+
"lastSeen": "2026-01-01",
|
|
54
|
+
"isGroup": false,
|
|
55
|
+
"sentimentScore": 0.159,
|
|
56
|
+
"warmthScore": 1.0,
|
|
57
|
+
"frictionScore": 0.0,
|
|
58
|
+
"operationalScore": 0.5,
|
|
59
|
+
"questionCount": 0,
|
|
60
|
+
"relationshipGuess": "warm personal relationship",
|
|
61
|
+
"tags": [
|
|
62
|
+
"direct-chat",
|
|
63
|
+
"light",
|
|
64
|
+
"warm",
|
|
65
|
+
"logistics-heavy"
|
|
66
|
+
]
|
|
67
|
+
}
|
|
68
|
+
]
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{"id":"sample-1","source":"sample","timestamp":"2026-01-01T10:00:00+00:00","chatName":"Mom","isGroup":false,"fromMe":false,"author":"Mom","body":"Good morning, call me when you wake up"}
|
|
2
|
+
{"id":"sample-2","source":"sample","timestamp":"2026-01-01T10:05:00+00:00","chatName":"Mom","isGroup":false,"fromMe":true,"author":"Me","body":"Morning, will call in 10"}
|
|
3
|
+
{"id":"sample-3","source":"sample","timestamp":"2026-01-01T11:00:00+00:00","chatName":"Project Team","isGroup":true,"fromMe":false,"author":"Alex","body":"Can you send the deck before the client meeting?"}
|
|
4
|
+
{"id":"sample-4","source":"sample","timestamp":"2026-01-01T11:03:00+00:00","chatName":"Project Team","isGroup":true,"fromMe":true,"author":"Me","body":"Yes, sending the updated deck now"}
|
|
5
|
+
{"id":"sample-5","source":"sample","timestamp":"2026-01-01T20:00:00+00:00","chatName":"Close Friend","isGroup":false,"fromMe":false,"author":"Friend","body":"That was fun haha, let's plan another trip"}
|
|
6
|
+
{"id":"sample-6","source":"sample","timestamp":"2026-01-01T20:02:00+00:00","chatName":"Close Friend","isGroup":false,"fromMe":true,"author":"Me","body":"Yes absolutely, that was great"}
|
package/lib/fs.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
export function packageRoot(metaUrl) {
|
|
6
|
+
return path.resolve(path.dirname(fileURLToPath(metaUrl)), "..");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function ensureDir(dir) {
|
|
10
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function copyDir(source, target) {
|
|
14
|
+
ensureDir(target);
|
|
15
|
+
for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
|
|
16
|
+
const from = path.join(source, entry.name);
|
|
17
|
+
const to = path.join(target, entry.name);
|
|
18
|
+
if (entry.isDirectory()) copyDir(from, to);
|
|
19
|
+
else if (!fs.existsSync(to)) fs.copyFileSync(from, to);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function resolveVault(cwd) {
|
|
24
|
+
let current = path.resolve(cwd);
|
|
25
|
+
while (true) {
|
|
26
|
+
if (fs.existsSync(path.join(current, "digital-brain.config.json"))) return current;
|
|
27
|
+
const parent = path.dirname(current);
|
|
28
|
+
if (parent === current) break;
|
|
29
|
+
current = parent;
|
|
30
|
+
}
|
|
31
|
+
return path.resolve(cwd);
|
|
32
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "digital-brain",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Your private digital imprint for AI assistants.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"digital-brain": "bin/digital-brain.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"lib",
|
|
12
|
+
"scripts/*.py",
|
|
13
|
+
"templates",
|
|
14
|
+
"whatsapp-web",
|
|
15
|
+
"examples",
|
|
16
|
+
"docs",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test:sample": "node ./bin/digital-brain.js extract --vault ./examples/sample-vault --days 365 --min-messages 1 && node ./bin/digital-brain.js interpret --vault ./examples/sample-vault --days 365",
|
|
22
|
+
"check": "node --check bin/digital-brain.js && node --check lib/*.js && node --check whatsapp-web/*.mjs && python3 -m py_compile scripts/*.py",
|
|
23
|
+
"start": "node ./bin/digital-brain.js"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"ai",
|
|
27
|
+
"memory",
|
|
28
|
+
"personal-ai",
|
|
29
|
+
"obsidian",
|
|
30
|
+
"whatsapp",
|
|
31
|
+
"local-first",
|
|
32
|
+
"context"
|
|
33
|
+
],
|
|
34
|
+
"author": "Rushill Shah",
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=20"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"qrcode-terminal": "^0.12.0",
|
|
41
|
+
"whatsapp-web.js": "^1.34.7"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import argparse
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
import re
|
|
6
|
+
from collections import Counter, defaultdict
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
POSITIVE = {"love", "thanks", "thank", "amazing", "great", "good", "nice", "perfect", "proud", "happy", "haha", "lol", "miss", "excited", "best", "awesome", "appreciate", "congrats"}
|
|
11
|
+
NEGATIVE = {"angry", "annoyed", "upset", "sad", "bad", "hate", "sorry", "fight", "problem", "issue", "wrong", "stress", "fuck", "shit", "worried", "pain", "hurt", "confused"}
|
|
12
|
+
LOGISTICS = {"when", "where", "time", "today", "tomorrow", "meeting", "call", "send", "sent", "come", "coming", "reach", "book", "plan", "schedule"}
|
|
13
|
+
WORK = {"pr", "repo", "client", "customer", "meeting", "deck", "code", "ship", "product", "founder", "startup", "work", "office", "investor", "sales", "demo", "launch"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main():
|
|
17
|
+
args = parse_args()
|
|
18
|
+
vault = args.vault.resolve()
|
|
19
|
+
whatsapp = vault / "08 Sources" / "WhatsApp"
|
|
20
|
+
output_dir = whatsapp / "Analysis"
|
|
21
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
messages = load_messages(whatsapp / "Raw", args.days)
|
|
23
|
+
profiles = build_profiles(messages, args.min_messages)
|
|
24
|
+
(output_dir / "relationship_profiles.json").write_text(json.dumps(profiles, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
25
|
+
write_markdown(output_dir / "Relationship Map.md", profiles, args.days)
|
|
26
|
+
print(f"Analyzed {len(messages)} messages.")
|
|
27
|
+
print(f"Wrote {len(profiles)} relationship profiles.")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_messages(raw_dir, days):
|
|
31
|
+
cutoff = datetime.now(timezone.utc).timestamp() - days * 24 * 60 * 60 if days else None
|
|
32
|
+
messages = []
|
|
33
|
+
for path in sorted(raw_dir.glob("*.jsonl")):
|
|
34
|
+
with path.open("r", encoding="utf-8") as f:
|
|
35
|
+
for line in f:
|
|
36
|
+
if not line.strip():
|
|
37
|
+
continue
|
|
38
|
+
record = json.loads(line)
|
|
39
|
+
dt = datetime.fromisoformat(record["timestamp"].replace("Z", "+00:00"))
|
|
40
|
+
if cutoff and dt.timestamp() < cutoff:
|
|
41
|
+
continue
|
|
42
|
+
record["_dt"] = dt
|
|
43
|
+
messages.append(record)
|
|
44
|
+
return messages
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build_profiles(messages, min_messages):
|
|
48
|
+
by_chat = defaultdict(list)
|
|
49
|
+
for message in messages:
|
|
50
|
+
by_chat[message.get("chatName") or "Unknown Chat"].append(message)
|
|
51
|
+
profiles = [profile_chat(name, items) for name, items in by_chat.items() if len(items) >= min_messages]
|
|
52
|
+
profiles.sort(key=lambda p: (p["messageCount"], p["lastSeen"]), reverse=True)
|
|
53
|
+
return profiles
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def profile_chat(chat_name, messages):
|
|
57
|
+
count = len(messages)
|
|
58
|
+
outbound = sum(1 for m in messages if m.get("fromMe"))
|
|
59
|
+
inbound = count - outbound
|
|
60
|
+
dates = [m["_dt"] for m in messages]
|
|
61
|
+
text = "\n".join(m.get("body") or "" for m in messages)
|
|
62
|
+
words = Counter(re.findall(r"[a-zA-Z']+", text.lower()))
|
|
63
|
+
positive = score(words, POSITIVE)
|
|
64
|
+
negative = score(words, NEGATIVE)
|
|
65
|
+
logistics = score(words, LOGISTICS)
|
|
66
|
+
work = score(words, WORK)
|
|
67
|
+
warmth = (positive + text.count("!") * 0.25) / max(count, 1)
|
|
68
|
+
friction = negative / max(count, 1)
|
|
69
|
+
operational = (logistics + work) / max(count, 1)
|
|
70
|
+
sentiment = normalized_sentiment(positive, negative, count)
|
|
71
|
+
tags = infer_tags(any(m.get("isGroup") for m in messages), count, inbound, outbound, warmth, friction, operational, work, logistics, text.count("?"))
|
|
72
|
+
guess = infer_relationship(tags, count, warmth, friction, operational, work, outbound / count)
|
|
73
|
+
return {
|
|
74
|
+
"chatName": chat_name,
|
|
75
|
+
"messageCount": count,
|
|
76
|
+
"inbound": inbound,
|
|
77
|
+
"outbound": outbound,
|
|
78
|
+
"outboundShare": round(outbound / count, 2),
|
|
79
|
+
"firstSeen": min(dates).date().isoformat(),
|
|
80
|
+
"lastSeen": max(dates).date().isoformat(),
|
|
81
|
+
"isGroup": any(m.get("isGroup") for m in messages),
|
|
82
|
+
"sentimentScore": round(sentiment, 3),
|
|
83
|
+
"warmthScore": round(warmth, 3),
|
|
84
|
+
"frictionScore": round(friction, 3),
|
|
85
|
+
"operationalScore": round(operational, 3),
|
|
86
|
+
"questionCount": text.count("?"),
|
|
87
|
+
"relationshipGuess": guess,
|
|
88
|
+
"tags": tags,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def infer_tags(group, count, inbound, outbound, warmth, friction, operational, work, logistics, questions):
|
|
93
|
+
tags = ["group-chat" if group else "direct-chat"]
|
|
94
|
+
tags.append("high-volume" if count >= 500 else "active" if count >= 100 else "light")
|
|
95
|
+
if warmth > 0.08:
|
|
96
|
+
tags.append("warm")
|
|
97
|
+
if friction > 0.035:
|
|
98
|
+
tags.append("friction-present")
|
|
99
|
+
if operational > 0.12:
|
|
100
|
+
tags.append("logistics-heavy")
|
|
101
|
+
if work > logistics and work > 5:
|
|
102
|
+
tags.append("work-signal")
|
|
103
|
+
if questions / max(count, 1) > 0.25:
|
|
104
|
+
tags.append("question-heavy")
|
|
105
|
+
if outbound / max(count, 1) > 0.7:
|
|
106
|
+
tags.append("user-driving")
|
|
107
|
+
if inbound / max(count, 1) > 0.7:
|
|
108
|
+
tags.append("other-driving")
|
|
109
|
+
return tags
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def infer_relationship(tags, count, warmth, friction, operational, work, balance):
|
|
113
|
+
if "group-chat" in tags:
|
|
114
|
+
return "group, likely work/project or operational" if work > 10 else "group, likely social or mixed context"
|
|
115
|
+
if count >= 500 and warmth > 0.06:
|
|
116
|
+
return "close/high-context personal relationship"
|
|
117
|
+
if count >= 200 and operational > 0.1:
|
|
118
|
+
return "active operational relationship"
|
|
119
|
+
if work > 10 and operational > 0.08:
|
|
120
|
+
return "work or project relationship"
|
|
121
|
+
if warmth > 0.08:
|
|
122
|
+
return "warm personal relationship"
|
|
123
|
+
if friction > 0.05:
|
|
124
|
+
return "relationship with friction or emotionally charged moments"
|
|
125
|
+
if balance < 0.25 or balance > 0.75:
|
|
126
|
+
return "asymmetric communication pattern"
|
|
127
|
+
return "general relationship, needs human labeling"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def write_markdown(path, profiles, days):
|
|
131
|
+
lines = ["# Relationship Map", "", f"Window: last {days} days", "", "Generated signals. Treat as editable working notes.", ""]
|
|
132
|
+
for profile in profiles:
|
|
133
|
+
lines.extend([
|
|
134
|
+
f"## {profile['chatName']}",
|
|
135
|
+
"",
|
|
136
|
+
f"- Guess: {profile['relationshipGuess']}",
|
|
137
|
+
f"- Messages: {profile['messageCount']} ({profile['inbound']} inbound, {profile['outbound']} outbound)",
|
|
138
|
+
f"- Dates: {profile['firstSeen']} to {profile['lastSeen']}",
|
|
139
|
+
f"- Scores: sentiment {profile['sentimentScore']}, warmth {profile['warmthScore']}, friction {profile['frictionScore']}, operational {profile['operationalScore']}",
|
|
140
|
+
f"- Tags: {', '.join(profile['tags'])}",
|
|
141
|
+
"",
|
|
142
|
+
])
|
|
143
|
+
path.write_text("\n".join(lines), encoding="utf-8")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def score(words, lexicon):
|
|
147
|
+
return sum(words[word] for word in lexicon)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def normalized_sentiment(positive, negative, count):
|
|
151
|
+
if positive + negative == 0:
|
|
152
|
+
return 0
|
|
153
|
+
return ((positive - negative) / (positive + negative)) * min(1, math.log10(count + 1) / 3)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def parse_args():
|
|
157
|
+
parser = argparse.ArgumentParser()
|
|
158
|
+
parser.add_argument("--vault", type=Path, required=True)
|
|
159
|
+
parser.add_argument("--days", type=int, default=30)
|
|
160
|
+
parser.add_argument("--min-messages", type=int, default=20)
|
|
161
|
+
return parser.parse_args()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
if __name__ == "__main__":
|
|
165
|
+
main()
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import argparse
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
ROLE_KEYWORDS = [
|
|
8
|
+
("mother", ["mom", "mum", "mummy", "maa", "mother"]),
|
|
9
|
+
("father", ["dad", "papa", "father"]),
|
|
10
|
+
("sibling", ["brother", "sister", "bhai", "behen", "sis"]),
|
|
11
|
+
("family", ["family", "cousin", "uncle", "aunt", "mama", "maasi"]),
|
|
12
|
+
("romantic partner", ["girlfriend", "boyfriend", "babe", "baby"]),
|
|
13
|
+
("work collaborator", ["intern", "client", "work", "team", "project", "founder"]),
|
|
14
|
+
("friend", ["boys", "friends", "trip", "dance"]),
|
|
15
|
+
("medical/support", ["doctor", "dr ", "hospital"]),
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main():
|
|
20
|
+
args = parse_args()
|
|
21
|
+
vault = args.vault.resolve()
|
|
22
|
+
whatsapp = vault / "08 Sources" / "WhatsApp"
|
|
23
|
+
profile_path = whatsapp / "Analysis" / "relationship_profiles.json"
|
|
24
|
+
profiles = json.loads(profile_path.read_text(encoding="utf-8"))
|
|
25
|
+
overrides = load_json(whatsapp / "relationship_overrides.json", {})
|
|
26
|
+
out_dir = whatsapp / "Analysis" / "Interpreted"
|
|
27
|
+
people_dir = vault / "04 People" / "Interpreted Relationships"
|
|
28
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
people_dir.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
|
|
31
|
+
models = []
|
|
32
|
+
for profile in profiles:
|
|
33
|
+
model = build_model(profile, overrides.get(profile["chatName"], {}))
|
|
34
|
+
note = render_note(model)
|
|
35
|
+
filename = safe_filename(profile["chatName"]) + ".md"
|
|
36
|
+
(out_dir / filename).write_text(note, encoding="utf-8")
|
|
37
|
+
(people_dir / filename).write_text(note, encoding="utf-8")
|
|
38
|
+
models.append(model)
|
|
39
|
+
|
|
40
|
+
(whatsapp / "Analysis" / "interpreted_relationship_models.json").write_text(json.dumps(models, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
41
|
+
write_index(vault / "06 AI Memory" / "Interpreted Relationship Memory.md", models)
|
|
42
|
+
print(f"Wrote interpreted notes: {len(models)}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def build_model(profile, override):
|
|
46
|
+
role, confidence, reason = infer_role(profile, override)
|
|
47
|
+
return {
|
|
48
|
+
**profile,
|
|
49
|
+
"role": role,
|
|
50
|
+
"roleConfidence": confidence,
|
|
51
|
+
"roleReason": reason,
|
|
52
|
+
"closeness": infer_closeness(profile),
|
|
53
|
+
"conversationDifficulty": infer_difficulty(profile),
|
|
54
|
+
"reciprocity": infer_reciprocity(profile),
|
|
55
|
+
"howToContinue": infer_continuity(profile, role),
|
|
56
|
+
"boundaries": infer_boundaries(role, infer_difficulty(profile)),
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def infer_role(profile, override):
|
|
61
|
+
if override.get("role"):
|
|
62
|
+
return override["role"], override.get("confidence", "high"), "manual override"
|
|
63
|
+
name = profile["chatName"].lower()
|
|
64
|
+
for role, keywords in ROLE_KEYWORDS:
|
|
65
|
+
if any(keyword in name for keyword in keywords):
|
|
66
|
+
return role, "high" if role in {"mother", "father"} else "medium", "matched chat name"
|
|
67
|
+
if profile["isGroup"]:
|
|
68
|
+
return "work/project group" if "work-signal" in profile["tags"] else "group", "medium", "group chat"
|
|
69
|
+
if "work-signal" in profile["tags"]:
|
|
70
|
+
return "work collaborator", "medium", "work/project signal"
|
|
71
|
+
if "warm" in profile["tags"] and profile["messageCount"] > 300:
|
|
72
|
+
return "close personal contact", "low", "high volume and warmth, no explicit label"
|
|
73
|
+
if "logistics-heavy" in profile["tags"]:
|
|
74
|
+
return "operational contact", "low", "logistics-heavy communication"
|
|
75
|
+
return "unlabeled contact", "low", "no reliable role evidence"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def infer_closeness(profile):
|
|
79
|
+
if profile["messageCount"] >= 500 and profile["warmthScore"] > 0.06:
|
|
80
|
+
return "high"
|
|
81
|
+
if profile["messageCount"] >= 200:
|
|
82
|
+
return "medium-high"
|
|
83
|
+
if profile["messageCount"] >= 75 or profile["warmthScore"] > 0.08:
|
|
84
|
+
return "medium"
|
|
85
|
+
return "low/unclear"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def infer_difficulty(profile):
|
|
89
|
+
if profile["frictionScore"] > 0.06:
|
|
90
|
+
return "high"
|
|
91
|
+
if profile["frictionScore"] > 0.03 or "friction-present" in profile["tags"]:
|
|
92
|
+
return "medium"
|
|
93
|
+
if profile["operationalScore"] > 0.15 and profile["warmthScore"] < 0.03:
|
|
94
|
+
return "practical/low-emotional"
|
|
95
|
+
return "low"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def infer_reciprocity(profile):
|
|
99
|
+
share = profile["outboundShare"]
|
|
100
|
+
if share > 0.68:
|
|
101
|
+
return "user drives most of the conversation"
|
|
102
|
+
if share < 0.32:
|
|
103
|
+
return "other side drives most of the conversation"
|
|
104
|
+
return "balanced"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def infer_continuity(profile, role):
|
|
108
|
+
guidance = []
|
|
109
|
+
if role in {"mother", "father", "family"}:
|
|
110
|
+
guidance += ["Use warmer language than a work chat.", "Do not make the interaction purely transactional."]
|
|
111
|
+
elif "work" in role:
|
|
112
|
+
guidance += ["Lead with context, next steps, and clear asks.", "Keep emotional interpretation light."]
|
|
113
|
+
elif role == "romantic partner":
|
|
114
|
+
guidance += ["Use extra care with tone.", "Prefer warmth and specificity over generic advice."]
|
|
115
|
+
else:
|
|
116
|
+
guidance.append("Keep tone neutral until the user labels this relationship.")
|
|
117
|
+
if "logistics-heavy" in profile["tags"]:
|
|
118
|
+
guidance.append("Track open loops, plans, dates, and commitments.")
|
|
119
|
+
return guidance
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def infer_boundaries(role, difficulty):
|
|
123
|
+
boundaries = ["Do not expose private summaries unless asked."]
|
|
124
|
+
if role == "unlabeled contact":
|
|
125
|
+
boundaries.append("Do not invent a relationship category.")
|
|
126
|
+
if difficulty in {"medium", "high"}:
|
|
127
|
+
boundaries.append("Do not assume friction means the relationship is bad.")
|
|
128
|
+
return boundaries
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def render_note(model):
|
|
132
|
+
return f"""# {model['chatName']}
|
|
133
|
+
|
|
134
|
+
Generated: {datetime.now(timezone.utc).isoformat()}
|
|
135
|
+
Role: {model['role']}
|
|
136
|
+
Role confidence: {model['roleConfidence']}
|
|
137
|
+
Closeness: {model['closeness']}
|
|
138
|
+
Conversation difficulty: {model['conversationDifficulty']}
|
|
139
|
+
|
|
140
|
+
These are private working notes. Edit them where wrong.
|
|
141
|
+
|
|
142
|
+
## Role / Relationship Label
|
|
143
|
+
- {model['role']} ({model['roleConfidence']} confidence).
|
|
144
|
+
- Reason: {model['roleReason']}.
|
|
145
|
+
|
|
146
|
+
## Closeness And Importance
|
|
147
|
+
- Closeness: {model['closeness']}.
|
|
148
|
+
- Reciprocity: {model['reciprocity']}.
|
|
149
|
+
- Conversation difficulty: {model['conversationDifficulty']}.
|
|
150
|
+
|
|
151
|
+
## Communication Pattern
|
|
152
|
+
- Messages: {model['messageCount']} ({model['inbound']} inbound, {model['outbound']} outbound).
|
|
153
|
+
- Tags: {', '.join(model['tags'])}.
|
|
154
|
+
|
|
155
|
+
## How To Continue This Relationship
|
|
156
|
+
{bullets(model['howToContinue'])}
|
|
157
|
+
|
|
158
|
+
## What Not To Assume
|
|
159
|
+
{bullets(model['boundaries'])}
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def write_index(path, models):
|
|
164
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
165
|
+
lines = ["# Interpreted Relationship Memory", "", "Generated working notes. Treat as editable, not truth.", ""]
|
|
166
|
+
for model in models:
|
|
167
|
+
lines.append(f"- [[{safe_filename(model['chatName'])}]]: {model['role']} ({model['roleConfidence']}), closeness {model['closeness']}, difficulty {model['conversationDifficulty']}")
|
|
168
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def bullets(items):
|
|
172
|
+
return "\n".join(f"- {item}" for item in items)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def safe_filename(value):
|
|
176
|
+
cleaned = "".join("-" if char in '/:\\?%*"<>|' else char for char in value)
|
|
177
|
+
return (" ".join(cleaned.split()).strip() or "Unknown Chat")[:120]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def load_json(path, fallback):
|
|
181
|
+
if not path.exists():
|
|
182
|
+
return fallback
|
|
183
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def parse_args():
|
|
187
|
+
parser = argparse.ArgumentParser()
|
|
188
|
+
parser.add_argument("--vault", type=Path, required=True)
|
|
189
|
+
parser.add_argument("--days", type=int, default=30)
|
|
190
|
+
parser.add_argument("--max-messages", type=int, default=120)
|
|
191
|
+
parser.add_argument("--provider", choices=["heuristic"], default="heuristic")
|
|
192
|
+
return parser.parse_args()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
if __name__ == "__main__":
|
|
196
|
+
main()
|