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.
Files changed (35) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +113 -0
  3. package/bin/digital-brain.js +209 -0
  4. package/docs/AUTOMATIONS.md +70 -0
  5. package/docs/PRIVACY.md +17 -0
  6. package/docs/ROADMAP.md +9 -0
  7. package/examples/sample-vault/04 People/Interpreted Relationships/Close Friend.md +29 -0
  8. package/examples/sample-vault/04 People/Interpreted Relationships/Mom.md +30 -0
  9. package/examples/sample-vault/04 People/Interpreted Relationships/Project Team.md +30 -0
  10. package/examples/sample-vault/06 AI Memory/Interpreted Relationship Memory.md +7 -0
  11. package/examples/sample-vault/08 Sources/WhatsApp/Analysis/Interpreted/Close Friend.md +29 -0
  12. package/examples/sample-vault/08 Sources/WhatsApp/Analysis/Interpreted/Mom.md +30 -0
  13. package/examples/sample-vault/08 Sources/WhatsApp/Analysis/Interpreted/Project Team.md +30 -0
  14. package/examples/sample-vault/08 Sources/WhatsApp/Analysis/Relationship Map.md +29 -0
  15. package/examples/sample-vault/08 Sources/WhatsApp/Analysis/interpreted_relationship_models.json +109 -0
  16. package/examples/sample-vault/08 Sources/WhatsApp/Analysis/relationship_profiles.json +68 -0
  17. package/examples/sample-vault/08 Sources/WhatsApp/Raw/2026-01-01.jsonl +6 -0
  18. package/examples/sample-vault/08 Sources/WhatsApp/relationship_overrides.json +7 -0
  19. package/lib/fs.js +32 -0
  20. package/package.json +43 -0
  21. package/scripts/digital_brain_relationship_extractor.py +165 -0
  22. package/scripts/digital_brain_relationship_interpreter.py +196 -0
  23. package/scripts/digital_brain_whatsapp_mac_sync.py +153 -0
  24. package/templates/vault/00 Home/How AI Should Use This Vault.md +11 -0
  25. package/templates/vault/00 Home/Start Here.md +11 -0
  26. package/templates/vault/01 Identity/Working Profile.md +11 -0
  27. package/templates/vault/04 People/Relationship Overrides.md +16 -0
  28. package/templates/vault/06 AI Memory/Interpreted Relationship Memory.md +4 -0
  29. package/templates/vault/06 AI Memory/What AI Should Remember.md +4 -0
  30. package/templates/vault/08 Sources/WhatsApp/Outbound/README.md +20 -0
  31. package/templates/vault/08 Sources/WhatsApp/relationship_overrides.example.json +8 -0
  32. package/templates/vault/AGENTS.md +19 -0
  33. package/templates/vault/CLAUDE.md +6 -0
  34. package/templates/vault/GEMINI.md +5 -0
  35. package/whatsapp-web/send.mjs +139 -0
@@ -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"}
@@ -0,0 +1,7 @@
1
+ {
2
+ "Mom": {
3
+ "role": "mother",
4
+ "confidence": "high",
5
+ "notes": "Sample override."
6
+ }
7
+ }
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()