digital-brain 0.1.0 → 0.1.3

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 CHANGED
@@ -24,7 +24,27 @@ Digital Brain gives them a structured, local map of:
24
24
  npx digital-brain init
25
25
  ```
26
26
 
27
- The installer asks a short setup quiz: history window, primary focus, refresh cadence, active time window, outbound mode, and AI adapter setup.
27
+ The installer asks a short setup quiz: history window, primary focus, refresh cadence, always-on interval, active time window, outbound mode, and AI adapter setup.
28
+
29
+ The quiz is mostly multiple choice. Pick with `A/B/C`, `1/2/3`, the exact value, or press Enter to accept the default. If you skip the vault path, Digital Brain creates a new folder in the current directory:
30
+
31
+ ```text
32
+ ./Digital Brain Vault
33
+ ```
34
+
35
+ For a non-interactive setup:
36
+
37
+ ```bash
38
+ npx digital-brain init --yes
39
+ ```
40
+
41
+ For local always-on setup:
42
+
43
+ ```bash
44
+ npx digital-brain init --full-auto
45
+ ```
46
+
47
+ Full-auto means local repeated refreshes. It does not mean blind auto-send. WhatsApp sending still defaults to drafts or explicit confirmation, and the AI-disclosure guard stays enabled.
28
48
 
29
49
  For local development:
30
50
 
@@ -41,12 +61,14 @@ node ./bin/digital-brain.js init ./Digital Brain\ Vault
41
61
  - Relationship extraction and interpretation models.
42
62
  - Optional WhatsApp Web outbound sender.
43
63
  - A refresh script based on your install-time answers.
64
+ - An optional always-on watch script that can pull every N minutes.
44
65
 
45
66
  ## What It Can Do
46
67
 
47
68
  - Import recent WhatsApp history from the local macOS WhatsApp database.
48
69
  - Build relationship profiles from message patterns.
49
70
  - Infer provisional roles like parent, family group, work collaborator, close personal contact, or unlabeled contact.
71
+ - Extract relationship-specific typing style: casing, message length, punctuation, emoji, and slang.
50
72
  - Generate "how to continue this relationship" notes.
51
73
  - Create AI-readable memory files for future prompts.
52
74
  - Draft WhatsApp sends by default, and only send with explicit `--yes`.
@@ -74,7 +96,13 @@ Each vault gets:
74
96
  Tools/digital-brain-refresh.sh
75
97
  ```
76
98
 
77
- Use it with Codex automations, local cron, launchd, or another scheduler. See [docs/AUTOMATIONS.md](docs/AUTOMATIONS.md).
99
+ For 24/7 local polling:
100
+
101
+ ```bash
102
+ Tools/digital-brain-watch.sh
103
+ ```
104
+
105
+ Use these with Codex automations, local cron, launchd, or another scheduler. See [docs/AUTOMATIONS.md](docs/AUTOMATIONS.md).
78
106
 
79
107
  ## Example
80
108
 
@@ -106,6 +134,8 @@ WhatsApp support reads the local macOS WhatsApp database when available. This is
106
134
 
107
135
  Relationship labels are working notes, not truth. You can edit them with `relationship_overrides.json`.
108
136
 
137
+ Always-on and outbound modes depend on local app databases, WhatsApp Web, and third-party behavior that can change. You are responsible for consent, privacy, message content, and anything sent from your machine.
138
+
109
139
  ## Status
110
140
 
111
141
  Alpha. Expect rough edges.
@@ -30,26 +30,56 @@ async function main() {
30
30
  async function init(argv, args) {
31
31
  const positional = argv.filter((arg) => !arg.startsWith("--"));
32
32
  const defaultVault = path.resolve(process.cwd(), "Digital Brain Vault");
33
+ const fullAuto = toBoolean(args["full-auto"]);
33
34
  let vault = positional[0] ? path.resolve(positional[0]) : args.yes ? defaultVault : "";
34
35
  let selfName = args["self-name"] || "";
35
36
  let connectAi = toBoolean(args["connect-ai"]);
36
37
  let dataWindowDays = Number(args["data-window-days"] || 30);
37
38
  let focus = args.focus || "";
38
- let schedule = args.schedule || "manual";
39
+ let schedule = args.schedule || (fullAuto ? "always-on" : "manual");
40
+ let refreshIntervalMinutes = clampInterval(args["refresh-interval-minutes"] || 5);
39
41
  let activeWindow = args["active-window"] || "08:00-12:00";
40
42
  let timezone = args.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || "local";
41
43
  let outboundMode = args["outbound-mode"] || "draft";
44
+ let responsibilityAccepted = fullAuto || schedule === "always-on";
42
45
 
43
46
  if (!args.yes) {
44
47
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
45
- vault ||= path.resolve(await ask(rl, "Vault path", defaultVault));
46
- selfName ||= await ask(rl, "Your name", "Me");
47
- dataWindowDays = Number(await ask(rl, "How much history should Digital Brain import by default? Days", String(dataWindowDays)));
48
- focus ||= await ask(rl, "What should it optimize for? relationship-memory / reply-help / work-context", "relationship-memory");
49
- schedule = await ask(rl, "How often should refresh guidance run? manual / hourly / every-30-min / daily", schedule);
50
- activeWindow = await ask(rl, "Active window for frequent refreshes", activeWindow);
51
- outboundMode = await ask(rl, "WhatsApp outbound mode? disabled / draft / send-with-confirmation", outboundMode);
52
- connectAi = /^y/i.test(await ask(rl, "Add global AI pointers for Codex/Claude/Gemini?", "y"));
48
+ printSetupHeader(defaultVault);
49
+ vault ||= path.resolve(await ask(rl, "📁 Vault path", defaultVault, "Enter creates this folder if it does not exist."));
50
+ selfName ||= await ask(rl, "👤 Your name", "Me");
51
+ dataWindowDays = await askNumber(rl, "🕰️ History to import", dataWindowDays, { suffix: "days", min: 1 });
52
+ focus ||= await select(rl, "Primary focus", [
53
+ ["relationship-memory", "Relationship memory", "Map people, tone, and recurring patterns.", "🧠"],
54
+ ["reply-help", "Reply help", "Prioritize drafting guidance and typing-style matching.", "💬"],
55
+ ["work-context", "Work context", "Prioritize collaborators, projects, and operational notes.", "💼"],
56
+ ], "relationship-memory");
57
+ schedule = await select(rl, "Refresh cadence", [
58
+ ["manual", "Manual", "Only runs when you run a command.", "🖐️"],
59
+ ["daily", "Daily", "Good for a low-maintenance personal vault.", "🌅"],
60
+ ["hourly", "Hourly", "Keeps memory warm without running constantly.", "⏱️"],
61
+ ["every-30-min", "Every 30 minutes", "Useful for morning or work-window guidance.", "🔁"],
62
+ ["always-on", "Always-on local loop", "Runs repeatedly while your computer is awake.", "⚡"],
63
+ ], schedule);
64
+ if (schedule === "always-on") {
65
+ refreshIntervalMinutes = await askNumber(rl, "⏳ Always-on pull interval", refreshIntervalMinutes, {
66
+ suffix: "minutes",
67
+ min: 1,
68
+ });
69
+ }
70
+ activeWindow = await ask(rl, "🪟 Active window for frequent refreshes", activeWindow);
71
+ outboundMode = await select(rl, "WhatsApp outbound mode", [
72
+ ["disabled", "Disabled", "Never prepares WhatsApp sends.", "🔒"],
73
+ ["draft", "Draft only", "Prepares text and requires you to send it.", "✍️"],
74
+ ["send-with-confirmation", "Send with confirmation", "Can send only after explicit command confirmation.", "✅"],
75
+ ], outboundMode);
76
+ connectAi = await confirm(rl, "🔗 Add global AI pointers for Codex/Claude/Gemini?", true);
77
+ responsibilityAccepted = await responsibilityGate(rl, { schedule, outboundMode });
78
+ if (!responsibilityAccepted && (schedule === "always-on" || outboundMode === "send-with-confirmation")) {
79
+ console.log("Full-auto/outbound confirmation was not accepted. Using manual refresh and draft-only outbound.");
80
+ schedule = "manual";
81
+ outboundMode = "draft";
82
+ }
53
83
  rl.close();
54
84
  }
55
85
 
@@ -60,9 +90,18 @@ async function init(argv, args) {
60
90
  dataWindowDays,
61
91
  focus: focus || "relationship-memory",
62
92
  schedule,
93
+ refreshIntervalMinutes,
63
94
  activeWindow,
64
95
  timezone,
65
96
  outboundMode,
97
+ setupMode: fullAuto ? "full-auto" : "guided",
98
+ responsibilityAccepted,
99
+ defaults: {
100
+ enterUsesDefault: true,
101
+ defaultVault,
102
+ skippedVaultCreates: defaultVault,
103
+ minimumRefreshIntervalMinutes: 1,
104
+ },
66
105
  disclosureRule: {
67
106
  enabled: true,
68
107
  discloseAfterAiAssistedSends: 2,
@@ -71,6 +110,7 @@ async function init(argv, args) {
71
110
  };
72
111
  writeConfig(vault, config);
73
112
  writeRefreshScript(vault, config);
113
+ writeWatchScript(vault, config);
74
114
 
75
115
  if (connectAi) {
76
116
  addGlobalPointer(path.join(os.homedir(), ".codex", "AGENTS.md"), vault, "Codex");
@@ -81,10 +121,12 @@ async function init(argv, args) {
81
121
  console.log(`Digital Brain vault created: ${vault}`);
82
122
  console.log(`Config: ${path.join(vault, "digital-brain.config.json")}`);
83
123
  console.log(`Refresh script: ${path.join(vault, "Tools", "digital-brain-refresh.sh")}`);
124
+ console.log(`Always-on script: ${path.join(vault, "Tools", "digital-brain-watch.sh")}`);
84
125
  console.log("Next:");
85
126
  console.log(` digital-brain sync-whatsapp --vault "${vault}" --days ${dataWindowDays}`);
86
127
  console.log(` digital-brain extract --vault "${vault}" --days ${dataWindowDays}`);
87
128
  console.log(` digital-brain interpret --vault "${vault}" --days ${dataWindowDays}`);
129
+ if (schedule === "always-on") console.log(` "${path.join(vault, "Tools", "digital-brain-watch.sh")}"`);
88
130
  }
89
131
 
90
132
  function doctor() {
@@ -142,6 +184,29 @@ echo "Digital Brain refresh complete for $VAULT"
142
184
  fs.chmodSync(scriptPath, 0o755);
143
185
  }
144
186
 
187
+ function writeWatchScript(vault, config) {
188
+ const toolsDir = path.join(vault, "Tools");
189
+ ensureDir(toolsDir);
190
+ const interval = clampInterval(config.refreshIntervalMinutes || 5);
191
+ const content = `#!/usr/bin/env bash
192
+ set -euo pipefail
193
+
194
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
195
+ INTERVAL_MINUTES="${interval}"
196
+
197
+ echo "Digital Brain watch loop started. Interval: $INTERVAL_MINUTES minute(s)."
198
+ echo "Press Ctrl+C to stop."
199
+
200
+ while true; do
201
+ "$SCRIPT_DIR/digital-brain-refresh.sh"
202
+ sleep "$((INTERVAL_MINUTES * 60))"
203
+ done
204
+ `;
205
+ const scriptPath = path.join(toolsDir, "digital-brain-watch.sh");
206
+ fs.writeFileSync(scriptPath, content);
207
+ fs.chmodSync(scriptPath, 0o755);
208
+ }
209
+
145
210
  function addGlobalPointer(file, vault, label) {
146
211
  ensureDir(path.dirname(file));
147
212
  const block = `
@@ -159,11 +224,81 @@ Use it as local personal context when the user asks about preferences, relations
159
224
  console.log(`${label} pointer: ${file}`);
160
225
  }
161
226
 
162
- async function ask(rl, label, fallback) {
163
- const answer = await rl.question(`${label} (${fallback}): `);
227
+ function printSetupHeader(defaultVault) {
228
+ console.log("");
229
+ console.log("╭────────────────────────────────────────╮");
230
+ console.log("│ 🧠 Digital Brain setup │");
231
+ console.log("╰────────────────────────────────────────╯");
232
+ console.log("Pick with A/B/C, 1/2/3, exact value, or press Enter for the default.");
233
+ console.log(`Skipping the vault path creates: ${defaultVault}`);
234
+ console.log("");
235
+ }
236
+
237
+ async function ask(rl, label, fallback, helpText = "") {
238
+ if (helpText) console.log(` ${helpText}`);
239
+ const answer = await rl.question(`${label} [${fallback}]: `);
164
240
  return answer.trim() || fallback;
165
241
  }
166
242
 
243
+ async function askNumber(rl, label, fallback, options = {}) {
244
+ const suffix = options.suffix ? ` ${options.suffix}` : "";
245
+ const answer = await ask(rl, `${label}${suffix}`, String(fallback));
246
+ const parsed = Number(answer);
247
+ if (!Number.isFinite(parsed)) return Number(fallback);
248
+ if (Number.isFinite(options.min) && parsed < options.min) return options.min;
249
+ return Math.floor(parsed);
250
+ }
251
+
252
+ async function select(rl, label, options, fallback) {
253
+ const defaultIndex = Math.max(0, options.findIndex(([value]) => value === fallback));
254
+ console.log("");
255
+ console.log(`◇ ${label}`);
256
+ options.forEach(([, title, description, icon = "•"], index) => {
257
+ const marker = index === defaultIndex ? " ← default" : "";
258
+ const letter = letterFor(index);
259
+ console.log(` ${letter}) ${icon} ${title}${marker}`);
260
+ console.log(` ${description}`);
261
+ });
262
+ const answer = await rl.question(`Choose ${letterFor(defaultIndex)}/${defaultIndex + 1} [${letterFor(defaultIndex)}]: `);
263
+ const trimmed = answer.trim();
264
+ if (!trimmed) return options[defaultIndex][0];
265
+ const letterIndex = indexFromLetter(trimmed);
266
+ if (letterIndex >= 0 && letterIndex < options.length) return options[letterIndex][0];
267
+ const selected = Number(trimmed);
268
+ if (Number.isInteger(selected) && selected >= 1 && selected <= options.length) return options[selected - 1][0];
269
+ const lower = trimmed.toLowerCase();
270
+ const exact = options.find(([value, title]) => value.toLowerCase() === lower || title.toLowerCase() === lower);
271
+ return exact ? exact[0] : options[defaultIndex][0];
272
+ }
273
+
274
+ async function confirm(rl, label, fallback) {
275
+ const hint = fallback ? "Y/n" : "y/N";
276
+ const answer = (await rl.question(`${label} [${hint}]: `)).trim().toLowerCase();
277
+ if (!answer) return fallback;
278
+ return ["y", "yes", "true", "1"].includes(answer);
279
+ }
280
+
281
+ async function responsibilityGate(rl, { schedule, outboundMode }) {
282
+ const needsGate = schedule === "always-on" || outboundMode === "send-with-confirmation";
283
+ if (!needsGate) return true;
284
+ console.log("");
285
+ console.log("⚠️ Responsibility check:");
286
+ console.log(" Digital Brain may use local databases, WhatsApp Web, and black-box third-party app behavior.");
287
+ console.log(" You are responsible for consent, privacy, message content, and any sends triggered from this machine.");
288
+ console.log(" Enter does not approve this mode.");
289
+ return confirm(rl, "I understand and want this mode enabled", false);
290
+ }
291
+
292
+ function letterFor(index) {
293
+ return String.fromCharCode(65 + index);
294
+ }
295
+
296
+ function indexFromLetter(value) {
297
+ const normalized = value.trim().toUpperCase();
298
+ if (!/^[A-Z]$/.test(normalized)) return -1;
299
+ return normalized.charCodeAt(0) - 65;
300
+ }
301
+
167
302
  function shell(command, args, optional = false) {
168
303
  const result = spawnSync(command, args, { encoding: "utf8" });
169
304
  if (result.error && optional) return "";
@@ -195,6 +330,12 @@ function toBoolean(value) {
195
330
  return !["false", "0", "no", "off"].includes(String(value).toLowerCase());
196
331
  }
197
332
 
333
+ function clampInterval(value) {
334
+ const parsed = Number(value);
335
+ if (!Number.isFinite(parsed) || parsed < 1) return 1;
336
+ return Math.floor(parsed);
337
+ }
338
+
198
339
  function help() {
199
340
  console.log(`Digital Brain
200
341
 
@@ -18,17 +18,44 @@ That script runs sync, extract, and interpret using the install-time config.
18
18
  - your name
19
19
  - history window in days
20
20
  - primary focus: relationship memory, reply help, work context
21
- - refresh cadence
21
+ - refresh cadence: manual, daily, hourly, every 30 minutes, or always-on
22
+ - refresh interval in minutes for always-on mode, clamped to a minimum of 1
22
23
  - active time window
23
24
  - WhatsApp outbound mode
24
25
  - whether to add AI adapter pointers
25
26
 
27
+ Most questions are multiple choice. Pick with `A/B/C`, `1/2/3`, the exact value, or press Enter to use the displayed default.
28
+
29
+ Important defaults:
30
+
31
+ - skipped vault path creates `./Digital Brain Vault` in the current directory
32
+ - skipped name uses `Me`
33
+ - skipped history window uses 30 days
34
+ - skipped focus uses relationship memory
35
+ - skipped schedule uses manual refresh
36
+ - skipped always-on interval uses 5 minutes, with a hard minimum of 1 minute
37
+ - skipped active window uses `08:00-12:00`
38
+ - skipped outbound mode uses draft-only
39
+ - skipped AI pointers are added during the guided quiz
40
+
26
41
  The answers are saved in:
27
42
 
28
43
  ```bash
29
44
  digital-brain.config.json
30
45
  ```
31
46
 
47
+ ## Full Auto
48
+
49
+ Use:
50
+
51
+ ```bash
52
+ digital-brain init --full-auto
53
+ ```
54
+
55
+ Full-auto 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.
56
+
57
+ During the guided quiz, always-on and send-with-confirmation 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.
58
+
32
59
  ## Codex App
33
60
 
34
61
  Use a Codex cron automation pointed at the generated vault.
@@ -45,6 +72,18 @@ Example schedule ideas:
45
72
  - Every 30 minutes from 8-12: `FREQ=DAILY;BYHOUR=8,9,10,11;BYMINUTE=0,30;BYSECOND=0`
46
73
  - Weekly: `FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=0;BYSECOND=0`
47
74
 
75
+ If Codex supports minute-based schedules in your environment, point it at `Tools/digital-brain-refresh.sh`. Otherwise, use the generated local watch script below.
76
+
77
+ ## Always-On Local Watch
78
+
79
+ For 24/7 local polling, run:
80
+
81
+ ```bash
82
+ /path/to/vault/Tools/digital-brain-watch.sh
83
+ ```
84
+
85
+ The generated script loops forever and sleeps for `refreshIntervalMinutes`. The minimum supported interval is 1 minute. A practical default is 5 minutes.
86
+
48
87
  ## Local Cron
49
88
 
50
89
  Run every 30 minutes from 8-12:
@@ -53,6 +92,12 @@ Run every 30 minutes from 8-12:
53
92
  0,30 8-11 * * * /path/to/vault/Tools/digital-brain-refresh.sh >> /path/to/vault/08\ Sources/WhatsApp/.sync-state/cron.log 2>&1
54
93
  ```
55
94
 
95
+ Run every 5 minutes all day:
96
+
97
+ ```cron
98
+ */5 * * * * /path/to/vault/Tools/digital-brain-refresh.sh >> /path/to/vault/08\ Sources/WhatsApp/.sync-state/cron.log 2>&1
99
+ ```
100
+
56
101
  ## macOS launchd
57
102
 
58
103
  Use launchd if you want a native background job. Be aware macOS privacy permissions may block background access to app databases unless Terminal or the runner has Full Disk Access.
package/docs/PRIVACY.md CHANGED
@@ -13,5 +13,7 @@ Things to be careful about:
13
13
  - Do not commit a generated vault.
14
14
  - Do not paste private message exports into GitHub issues.
15
15
  - Do not enable outbound sending without understanding the risk.
16
+ - Treat WhatsApp database access and WhatsApp Web automation as unofficial, changeable integrations.
17
+ - Always-on mode runs on your machine and inherits your local permissions.
18
+ - You are responsible for consent, privacy, message content, and sends made from your machine.
16
19
  - Treat relationship labels as editable working notes, not truth.
17
-
@@ -1,10 +1,11 @@
1
1
  # Close Friend
2
2
 
3
- Generated: 2026-06-09T22:06:49.488719+00:00
3
+ Generated: 2026-06-09T22:26:26.131803+00:00
4
4
  Role: operational contact
5
5
  Role confidence: low
6
6
  Closeness: medium
7
7
  Conversation difficulty: low
8
+ Typing style: short
8
9
 
9
10
  These are private working notes. Edit them where wrong.
10
11
 
@@ -21,9 +22,22 @@ These are private working notes. Edit them where wrong.
21
22
  - Messages: 2 (1 inbound, 1 outbound).
22
23
  - Tags: direct-chat, light, warm, logistics-heavy.
23
24
 
25
+ ## Typing Style To Match
26
+ - Signature: short.
27
+ - Average length: 5.0 words / 30.0 chars.
28
+ - Lowercase share: 0.0.
29
+ - Question share: 0.0.
30
+ - Exclamation share: 0.0.
31
+ - Emoji share: 0.0.
32
+ - Slang: none detected.
33
+
24
34
  ## How To Continue This Relationship
25
35
  - Keep tone neutral until the user labels this relationship.
26
36
  - Track open loops, plans, dates, and commitments.
27
37
 
38
+ ## Reply Guidance
39
+ - Keep replies very short unless context demands detail.
40
+ - Match style without exaggerating or parodying the person.
41
+
28
42
  ## What Not To Assume
29
43
  - Do not expose private summaries unless asked.
@@ -1,10 +1,11 @@
1
1
  # Mom
2
2
 
3
- Generated: 2026-06-09T22:06:49.488023+00:00
3
+ Generated: 2026-06-09T22:26:26.131148+00:00
4
4
  Role: mother
5
5
  Role confidence: high
6
6
  Closeness: medium
7
7
  Conversation difficulty: low
8
+ Typing style: very short
8
9
 
9
10
  These are private working notes. Edit them where wrong.
10
11
 
@@ -21,10 +22,23 @@ These are private working notes. Edit them where wrong.
21
22
  - Messages: 2 (1 inbound, 1 outbound).
22
23
  - Tags: direct-chat, light, warm, logistics-heavy.
23
24
 
25
+ ## Typing Style To Match
26
+ - Signature: very short.
27
+ - Average length: 4.0 words / 24.0 chars.
28
+ - Lowercase share: 0.0.
29
+ - Question share: 0.0.
30
+ - Exclamation share: 0.0.
31
+ - Emoji share: 0.0.
32
+ - Slang: none detected.
33
+
24
34
  ## How To Continue This Relationship
25
35
  - Use warmer language than a work chat.
26
36
  - Do not make the interaction purely transactional.
27
37
  - Track open loops, plans, dates, and commitments.
28
38
 
39
+ ## Reply Guidance
40
+ - Keep replies very short unless context demands detail.
41
+ - Match style without exaggerating or parodying the person.
42
+
29
43
  ## What Not To Assume
30
44
  - Do not expose private summaries unless asked.
@@ -1,10 +1,11 @@
1
1
  # Project Team
2
2
 
3
- Generated: 2026-06-09T22:06:49.488560+00:00
3
+ Generated: 2026-06-09T22:26:26.131626+00:00
4
4
  Role: work collaborator
5
5
  Role confidence: medium
6
6
  Closeness: low/unclear
7
7
  Conversation difficulty: practical/low-emotional
8
+ Typing style: short
8
9
 
9
10
  These are private working notes. Edit them where wrong.
10
11
 
@@ -21,10 +22,23 @@ These are private working notes. Edit them where wrong.
21
22
  - Messages: 2 (1 inbound, 1 outbound).
22
23
  - Tags: group-chat, light, logistics-heavy, question-heavy.
23
24
 
25
+ ## Typing Style To Match
26
+ - Signature: short.
27
+ - Average length: 6.0 words / 33.0 chars.
28
+ - Lowercase share: 0.0.
29
+ - Question share: 0.0.
30
+ - Exclamation share: 0.0.
31
+ - Emoji share: 0.0.
32
+ - Slang: none detected.
33
+
24
34
  ## How To Continue This Relationship
25
35
  - Lead with context, next steps, and clear asks.
26
36
  - Keep emotional interpretation light.
27
37
  - Track open loops, plans, dates, and commitments.
28
38
 
39
+ ## Reply Guidance
40
+ - Use concise replies with one clear point.
41
+ - Match style without exaggerating or parodying the person.
42
+
29
43
  ## What Not To Assume
30
44
  - Do not expose private summaries unless asked.
@@ -2,6 +2,6 @@
2
2
 
3
3
  Generated working notes. Treat as editable, not truth.
4
4
 
5
- - [[Mom]]: mother (high), closeness medium, difficulty low
6
- - [[Project Team]]: work collaborator (medium), closeness low/unclear, difficulty practical/low-emotional
7
- - [[Close Friend]]: operational contact (low), closeness medium, difficulty low
5
+ - [[Mom]]: mother (high), closeness medium, difficulty low, style very short
6
+ - [[Project Team]]: work collaborator (medium), closeness low/unclear, difficulty practical/low-emotional, style short
7
+ - [[Close Friend]]: operational contact (low), closeness medium, difficulty low, style short
@@ -1,10 +1,11 @@
1
1
  # Close Friend
2
2
 
3
- Generated: 2026-06-09T22:06:49.488719+00:00
3
+ Generated: 2026-06-09T22:26:26.131803+00:00
4
4
  Role: operational contact
5
5
  Role confidence: low
6
6
  Closeness: medium
7
7
  Conversation difficulty: low
8
+ Typing style: short
8
9
 
9
10
  These are private working notes. Edit them where wrong.
10
11
 
@@ -21,9 +22,22 @@ These are private working notes. Edit them where wrong.
21
22
  - Messages: 2 (1 inbound, 1 outbound).
22
23
  - Tags: direct-chat, light, warm, logistics-heavy.
23
24
 
25
+ ## Typing Style To Match
26
+ - Signature: short.
27
+ - Average length: 5.0 words / 30.0 chars.
28
+ - Lowercase share: 0.0.
29
+ - Question share: 0.0.
30
+ - Exclamation share: 0.0.
31
+ - Emoji share: 0.0.
32
+ - Slang: none detected.
33
+
24
34
  ## How To Continue This Relationship
25
35
  - Keep tone neutral until the user labels this relationship.
26
36
  - Track open loops, plans, dates, and commitments.
27
37
 
38
+ ## Reply Guidance
39
+ - Keep replies very short unless context demands detail.
40
+ - Match style without exaggerating or parodying the person.
41
+
28
42
  ## What Not To Assume
29
43
  - Do not expose private summaries unless asked.
@@ -1,10 +1,11 @@
1
1
  # Mom
2
2
 
3
- Generated: 2026-06-09T22:06:49.488023+00:00
3
+ Generated: 2026-06-09T22:26:26.131148+00:00
4
4
  Role: mother
5
5
  Role confidence: high
6
6
  Closeness: medium
7
7
  Conversation difficulty: low
8
+ Typing style: very short
8
9
 
9
10
  These are private working notes. Edit them where wrong.
10
11
 
@@ -21,10 +22,23 @@ These are private working notes. Edit them where wrong.
21
22
  - Messages: 2 (1 inbound, 1 outbound).
22
23
  - Tags: direct-chat, light, warm, logistics-heavy.
23
24
 
25
+ ## Typing Style To Match
26
+ - Signature: very short.
27
+ - Average length: 4.0 words / 24.0 chars.
28
+ - Lowercase share: 0.0.
29
+ - Question share: 0.0.
30
+ - Exclamation share: 0.0.
31
+ - Emoji share: 0.0.
32
+ - Slang: none detected.
33
+
24
34
  ## How To Continue This Relationship
25
35
  - Use warmer language than a work chat.
26
36
  - Do not make the interaction purely transactional.
27
37
  - Track open loops, plans, dates, and commitments.
28
38
 
39
+ ## Reply Guidance
40
+ - Keep replies very short unless context demands detail.
41
+ - Match style without exaggerating or parodying the person.
42
+
29
43
  ## What Not To Assume
30
44
  - Do not expose private summaries unless asked.
@@ -1,10 +1,11 @@
1
1
  # Project Team
2
2
 
3
- Generated: 2026-06-09T22:06:49.488560+00:00
3
+ Generated: 2026-06-09T22:26:26.131626+00:00
4
4
  Role: work collaborator
5
5
  Role confidence: medium
6
6
  Closeness: low/unclear
7
7
  Conversation difficulty: practical/low-emotional
8
+ Typing style: short
8
9
 
9
10
  These are private working notes. Edit them where wrong.
10
11
 
@@ -21,10 +22,23 @@ These are private working notes. Edit them where wrong.
21
22
  - Messages: 2 (1 inbound, 1 outbound).
22
23
  - Tags: group-chat, light, logistics-heavy, question-heavy.
23
24
 
25
+ ## Typing Style To Match
26
+ - Signature: short.
27
+ - Average length: 6.0 words / 33.0 chars.
28
+ - Lowercase share: 0.0.
29
+ - Question share: 0.0.
30
+ - Exclamation share: 0.0.
31
+ - Emoji share: 0.0.
32
+ - Slang: none detected.
33
+
24
34
  ## How To Continue This Relationship
25
35
  - Lead with context, next steps, and clear asks.
26
36
  - Keep emotional interpretation light.
27
37
  - Track open loops, plans, dates, and commitments.
28
38
 
39
+ ## Reply Guidance
40
+ - Use concise replies with one clear point.
41
+ - Match style without exaggerating or parodying the person.
42
+
29
43
  ## What Not To Assume
30
44
  - Do not expose private summaries unless asked.
@@ -11,6 +11,7 @@ Generated signals. Treat as editable working notes.
11
11
  - Dates: 2026-01-01 to 2026-01-01
12
12
  - Scores: sentiment 0.159, warmth 0.5, friction 0.0, operational 1.5
13
13
  - Tags: direct-chat, light, warm, logistics-heavy
14
+ - Typing style: very short; avg 4.0 words; lowercase 0.0; emoji 0.0; slang none
14
15
 
15
16
  ## Project Team
16
17
 
@@ -19,6 +20,7 @@ Generated signals. Treat as editable working notes.
19
20
  - Dates: 2026-01-01 to 2026-01-01
20
21
  - Scores: sentiment 0, warmth 0.0, friction 0.0, operational 3.0
21
22
  - Tags: group-chat, light, logistics-heavy, question-heavy
23
+ - Typing style: short; avg 6.0 words; lowercase 0.0; emoji 0.0; slang none
22
24
 
23
25
  ## Close Friend
24
26
 
@@ -27,3 +29,4 @@ Generated signals. Treat as editable working notes.
27
29
  - Dates: 2026-01-01 to 2026-01-01
28
30
  - Scores: sentiment 0.159, warmth 1.0, friction 0.0, operational 0.5
29
31
  - Tags: direct-chat, light, warm, logistics-heavy
32
+ - Typing style: short; avg 5.0 words; lowercase 0.0; emoji 0.0; slang none
@@ -20,6 +20,18 @@
20
20
  "warm",
21
21
  "logistics-heavy"
22
22
  ],
23
+ "typingStyle": {
24
+ "sampleSize": 1,
25
+ "avgChars": 24.0,
26
+ "avgWords": 4.0,
27
+ "lowercaseShare": 0.0,
28
+ "uppercaseShare": 0.0,
29
+ "questionShare": 0.0,
30
+ "exclamationShare": 0.0,
31
+ "emojiShare": 0.0,
32
+ "slang": [],
33
+ "signature": "very short"
34
+ },
23
35
  "role": "mother",
24
36
  "roleConfidence": "high",
25
37
  "roleReason": "manual override",
@@ -33,6 +45,10 @@
33
45
  ],
34
46
  "boundaries": [
35
47
  "Do not expose private summaries unless asked."
48
+ ],
49
+ "replyStyle": [
50
+ "Keep replies very short unless context demands detail.",
51
+ "Match style without exaggerating or parodying the person."
36
52
  ]
37
53
  },
38
54
  {
@@ -56,6 +72,18 @@
56
72
  "logistics-heavy",
57
73
  "question-heavy"
58
74
  ],
75
+ "typingStyle": {
76
+ "sampleSize": 1,
77
+ "avgChars": 33.0,
78
+ "avgWords": 6.0,
79
+ "lowercaseShare": 0.0,
80
+ "uppercaseShare": 0.0,
81
+ "questionShare": 0.0,
82
+ "exclamationShare": 0.0,
83
+ "emojiShare": 0.0,
84
+ "slang": [],
85
+ "signature": "short"
86
+ },
59
87
  "role": "work collaborator",
60
88
  "roleConfidence": "medium",
61
89
  "roleReason": "matched chat name",
@@ -69,6 +97,10 @@
69
97
  ],
70
98
  "boundaries": [
71
99
  "Do not expose private summaries unless asked."
100
+ ],
101
+ "replyStyle": [
102
+ "Use concise replies with one clear point.",
103
+ "Match style without exaggerating or parodying the person."
72
104
  ]
73
105
  },
74
106
  {
@@ -92,6 +124,18 @@
92
124
  "warm",
93
125
  "logistics-heavy"
94
126
  ],
127
+ "typingStyle": {
128
+ "sampleSize": 1,
129
+ "avgChars": 30.0,
130
+ "avgWords": 5.0,
131
+ "lowercaseShare": 0.0,
132
+ "uppercaseShare": 0.0,
133
+ "questionShare": 0.0,
134
+ "exclamationShare": 0.0,
135
+ "emojiShare": 0.0,
136
+ "slang": [],
137
+ "signature": "short"
138
+ },
95
139
  "role": "operational contact",
96
140
  "roleConfidence": "low",
97
141
  "roleReason": "logistics-heavy communication",
@@ -104,6 +148,10 @@
104
148
  ],
105
149
  "boundaries": [
106
150
  "Do not expose private summaries unless asked."
151
+ ],
152
+ "replyStyle": [
153
+ "Keep replies very short unless context demands detail.",
154
+ "Match style without exaggerating or parodying the person."
107
155
  ]
108
156
  }
109
157
  ]
@@ -19,7 +19,19 @@
19
19
  "light",
20
20
  "warm",
21
21
  "logistics-heavy"
22
- ]
22
+ ],
23
+ "typingStyle": {
24
+ "sampleSize": 1,
25
+ "avgChars": 24.0,
26
+ "avgWords": 4.0,
27
+ "lowercaseShare": 0.0,
28
+ "uppercaseShare": 0.0,
29
+ "questionShare": 0.0,
30
+ "exclamationShare": 0.0,
31
+ "emojiShare": 0.0,
32
+ "slang": [],
33
+ "signature": "very short"
34
+ }
23
35
  },
24
36
  {
25
37
  "chatName": "Project Team",
@@ -41,7 +53,19 @@
41
53
  "light",
42
54
  "logistics-heavy",
43
55
  "question-heavy"
44
- ]
56
+ ],
57
+ "typingStyle": {
58
+ "sampleSize": 1,
59
+ "avgChars": 33.0,
60
+ "avgWords": 6.0,
61
+ "lowercaseShare": 0.0,
62
+ "uppercaseShare": 0.0,
63
+ "questionShare": 0.0,
64
+ "exclamationShare": 0.0,
65
+ "emojiShare": 0.0,
66
+ "slang": [],
67
+ "signature": "short"
68
+ }
45
69
  },
46
70
  {
47
71
  "chatName": "Close Friend",
@@ -63,6 +87,18 @@
63
87
  "light",
64
88
  "warm",
65
89
  "logistics-heavy"
66
- ]
90
+ ],
91
+ "typingStyle": {
92
+ "sampleSize": 1,
93
+ "avgChars": 30.0,
94
+ "avgWords": 5.0,
95
+ "lowercaseShare": 0.0,
96
+ "uppercaseShare": 0.0,
97
+ "questionShare": 0.0,
98
+ "exclamationShare": 0.0,
99
+ "emojiShare": 0.0,
100
+ "slang": [],
101
+ "signature": "short"
102
+ }
67
103
  }
68
104
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "digital-brain",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
4
4
  "description": "Your private digital imprint for AI assistants.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,6 +19,7 @@
19
19
  ],
20
20
  "scripts": {
21
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
+ "test": "node --test tests/*.test.mjs",
22
23
  "check": "node --check bin/digital-brain.js && node --check lib/*.js && node --check whatsapp-web/*.mjs && python3 -m py_compile scripts/*.py",
23
24
  "start": "node ./bin/digital-brain.js"
24
25
  },
@@ -11,6 +11,7 @@ POSITIVE = {"love", "thanks", "thank", "amazing", "great", "good", "nice", "perf
11
11
  NEGATIVE = {"angry", "annoyed", "upset", "sad", "bad", "hate", "sorry", "fight", "problem", "issue", "wrong", "stress", "fuck", "shit", "worried", "pain", "hurt", "confused"}
12
12
  LOGISTICS = {"when", "where", "time", "today", "tomorrow", "meeting", "call", "send", "sent", "come", "coming", "reach", "book", "plan", "schedule"}
13
13
  WORK = {"pr", "repo", "client", "customer", "meeting", "deck", "code", "ship", "product", "founder", "startup", "work", "office", "investor", "sales", "demo", "launch"}
14
+ SLANG = {"lol", "lmao", "haha", "hahaha", "bro", "bruh", "wtf", "omg", "ngl", "idk", "rn", "btw", "bc", "pls", "plz", "ya", "yeah", "yep", "nah", "fuck", "shit"}
14
15
 
15
16
 
16
17
  def main():
@@ -59,6 +60,7 @@ def profile_chat(chat_name, messages):
59
60
  inbound = count - outbound
60
61
  dates = [m["_dt"] for m in messages]
61
62
  text = "\n".join(m.get("body") or "" for m in messages)
63
+ outbound_messages = [m for m in messages if m.get("fromMe") and (m.get("body") or "").strip()]
62
64
  words = Counter(re.findall(r"[a-zA-Z']+", text.lower()))
63
65
  positive = score(words, POSITIVE)
64
66
  negative = score(words, NEGATIVE)
@@ -86,6 +88,7 @@ def profile_chat(chat_name, messages):
86
88
  "questionCount": text.count("?"),
87
89
  "relationshipGuess": guess,
88
90
  "tags": tags,
91
+ "typingStyle": typing_style(outbound_messages),
89
92
  }
90
93
 
91
94
 
@@ -138,6 +141,7 @@ def write_markdown(path, profiles, days):
138
141
  f"- Dates: {profile['firstSeen']} to {profile['lastSeen']}",
139
142
  f"- Scores: sentiment {profile['sentimentScore']}, warmth {profile['warmthScore']}, friction {profile['frictionScore']}, operational {profile['operationalScore']}",
140
143
  f"- Tags: {', '.join(profile['tags'])}",
144
+ f"- Typing style: {typing_style_summary(profile['typingStyle'])}",
141
145
  "",
142
146
  ])
143
147
  path.write_text("\n".join(lines), encoding="utf-8")
@@ -147,6 +151,83 @@ def score(words, lexicon):
147
151
  return sum(words[word] for word in lexicon)
148
152
 
149
153
 
154
+ def typing_style(messages):
155
+ bodies = [(m.get("body") or "").strip() for m in messages if (m.get("body") or "").strip()]
156
+ count = len(bodies)
157
+ if not count:
158
+ return {
159
+ "sampleSize": 0,
160
+ "avgChars": 0,
161
+ "avgWords": 0,
162
+ "lowercaseShare": 0,
163
+ "uppercaseShare": 0,
164
+ "questionShare": 0,
165
+ "exclamationShare": 0,
166
+ "emojiShare": 0,
167
+ "slang": [],
168
+ "signature": "no outbound sample",
169
+ }
170
+ word_lists = [re.findall(r"[A-Za-z']+", body) for body in bodies]
171
+ all_words = [word.lower() for words in word_lists for word in words]
172
+ slang = Counter(word for word in all_words if word in SLANG)
173
+ avg_chars = sum(len(body) for body in bodies) / count
174
+ avg_words = sum(len(words) for words in word_lists) / count
175
+ lowercase = sum(1 for body in bodies if has_letters(body) and body == body.lower()) / count
176
+ uppercase = sum(1 for body in bodies if has_letters(body) and body == body.upper()) / count
177
+ questions = sum(1 for body in bodies if "?" in body) / count
178
+ exclaims = sum(1 for body in bodies if "!" in body) / count
179
+ emojis = sum(1 for body in bodies if has_emoji(body)) / count
180
+ return {
181
+ "sampleSize": count,
182
+ "avgChars": round(avg_chars, 1),
183
+ "avgWords": round(avg_words, 1),
184
+ "lowercaseShare": round(lowercase, 2),
185
+ "uppercaseShare": round(uppercase, 2),
186
+ "questionShare": round(questions, 2),
187
+ "exclamationShare": round(exclaims, 2),
188
+ "emojiShare": round(emojis, 2),
189
+ "slang": [word for word, _ in slang.most_common(8)],
190
+ "signature": infer_typing_signature(avg_words, lowercase, questions, exclaims, emojis, slang),
191
+ }
192
+
193
+
194
+ def typing_style_summary(style):
195
+ slang = ", ".join(style.get("slang", [])) or "none"
196
+ return (
197
+ f"{style.get('signature', 'unknown')}; avg {style.get('avgWords', 0)} words; "
198
+ f"lowercase {style.get('lowercaseShare', 0)}; emoji {style.get('emojiShare', 0)}; slang {slang}"
199
+ )
200
+
201
+
202
+ def infer_typing_signature(avg_words, lowercase, questions, exclaims, emojis, slang):
203
+ parts = []
204
+ if avg_words <= 4:
205
+ parts.append("very short")
206
+ elif avg_words <= 10:
207
+ parts.append("short")
208
+ else:
209
+ parts.append("longer-form")
210
+ if lowercase > 0.55:
211
+ parts.append("lowercase-heavy")
212
+ if questions > 0.25:
213
+ parts.append("question-heavy")
214
+ if exclaims > 0.2:
215
+ parts.append("expressive")
216
+ if emojis > 0.2:
217
+ parts.append("emoji-friendly")
218
+ if slang:
219
+ parts.append("slangy")
220
+ return ", ".join(parts)
221
+
222
+
223
+ def has_letters(text):
224
+ return any(char.isalpha() for char in text)
225
+
226
+
227
+ def has_emoji(text):
228
+ return any(ord(char) > 10000 for char in text)
229
+
230
+
150
231
  def normalized_sentiment(positive, negative, count):
151
232
  if positive + negative == 0:
152
233
  return 0
@@ -54,6 +54,7 @@ def build_model(profile, override):
54
54
  "reciprocity": infer_reciprocity(profile),
55
55
  "howToContinue": infer_continuity(profile, role),
56
56
  "boundaries": infer_boundaries(role, infer_difficulty(profile)),
57
+ "replyStyle": infer_reply_style(profile),
57
58
  }
58
59
 
59
60
 
@@ -136,6 +137,7 @@ Role: {model['role']}
136
137
  Role confidence: {model['roleConfidence']}
137
138
  Closeness: {model['closeness']}
138
139
  Conversation difficulty: {model['conversationDifficulty']}
140
+ Typing style: {model['typingStyle'].get('signature', 'unknown')}
139
141
 
140
142
  These are private working notes. Edit them where wrong.
141
143
 
@@ -152,9 +154,15 @@ These are private working notes. Edit them where wrong.
152
154
  - Messages: {model['messageCount']} ({model['inbound']} inbound, {model['outbound']} outbound).
153
155
  - Tags: {', '.join(model['tags'])}.
154
156
 
157
+ ## Typing Style To Match
158
+ {render_typing_style(model)}
159
+
155
160
  ## How To Continue This Relationship
156
161
  {bullets(model['howToContinue'])}
157
162
 
163
+ ## Reply Guidance
164
+ {bullets(model['replyStyle'])}
165
+
158
166
  ## What Not To Assume
159
167
  {bullets(model['boundaries'])}
160
168
  """
@@ -164,7 +172,8 @@ def write_index(path, models):
164
172
  path.parent.mkdir(parents=True, exist_ok=True)
165
173
  lines = ["# Interpreted Relationship Memory", "", "Generated working notes. Treat as editable, not truth.", ""]
166
174
  for model in models:
167
- lines.append(f"- [[{safe_filename(model['chatName'])}]]: {model['role']} ({model['roleConfidence']}), closeness {model['closeness']}, difficulty {model['conversationDifficulty']}")
175
+ style = model.get("typingStyle", {}).get("signature", "unknown style")
176
+ lines.append(f"- [[{safe_filename(model['chatName'])}]]: {model['role']} ({model['roleConfidence']}), closeness {model['closeness']}, difficulty {model['conversationDifficulty']}, style {style}")
168
177
  path.write_text("\n".join(lines) + "\n", encoding="utf-8")
169
178
 
170
179
 
@@ -172,6 +181,43 @@ def bullets(items):
172
181
  return "\n".join(f"- {item}" for item in items)
173
182
 
174
183
 
184
+ def render_typing_style(model):
185
+ style = model.get("typingStyle", {})
186
+ slang = ", ".join(style.get("slang", [])) or "none detected"
187
+ lines = [
188
+ f"- Signature: {style.get('signature', 'unknown')}.",
189
+ f"- Average length: {style.get('avgWords', 0)} words / {style.get('avgChars', 0)} chars.",
190
+ f"- Lowercase share: {style.get('lowercaseShare', 0)}.",
191
+ f"- Question share: {style.get('questionShare', 0)}.",
192
+ f"- Exclamation share: {style.get('exclamationShare', 0)}.",
193
+ f"- Emoji share: {style.get('emojiShare', 0)}.",
194
+ f"- Slang: {slang}.",
195
+ ]
196
+ return "\n".join(lines)
197
+
198
+
199
+ def infer_reply_style(profile):
200
+ style = profile.get("typingStyle", {})
201
+ guidance = []
202
+ avg_words = style.get("avgWords", 0)
203
+ if avg_words and avg_words <= 5:
204
+ guidance.append("Keep replies very short unless context demands detail.")
205
+ elif avg_words <= 12:
206
+ guidance.append("Use concise replies with one clear point.")
207
+ else:
208
+ guidance.append("Longer replies are acceptable in this relationship.")
209
+ if style.get("lowercaseShare", 0) > 0.55:
210
+ guidance.append("Lowercase is acceptable; avoid making it too formal.")
211
+ if style.get("emojiShare", 0) > 0.2:
212
+ guidance.append("A light emoji can fit this relationship.")
213
+ if style.get("questionShare", 0) > 0.25:
214
+ guidance.append("Asking a direct question fits the existing rhythm.")
215
+ if style.get("slang"):
216
+ guidance.append(f"Some familiar slang appears here: {', '.join(style['slang'][:4])}.")
217
+ guidance.append("Match style without exaggerating or parodying the person.")
218
+ return guidance
219
+
220
+
175
221
  def safe_filename(value):
176
222
  cleaned = "".join("-" if char in '/:\\?%*"<>|' else char for char in value)
177
223
  return (" ".join(cleaned.split()).strip() or "Unknown Chat")[:120]