digital-brain 1.0.5 → 1.1.1

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
@@ -82,6 +82,7 @@ node ./bin/digital-brain.js init ./Digital Brain\ Vault
82
82
  - Generate reply-ready person context that keeps WhatsApp, iMessage, Slack, and LinkedIn evidence separate under the same person.
83
83
  - Create AI-readable memory files for future prompts.
84
84
  - Draft WhatsApp sends by default, and only send with explicit `--yes`.
85
+ - Run an explicit WhatsApp auto-responder that uses local Ollama plus vault memory while the command is running.
85
86
  - Enforce an AI-disclosure guard after repeated AI-assisted sends.
86
87
 
87
88
  ## Core Commands
@@ -94,6 +95,7 @@ digital-brain sync-imessage --days 30
94
95
  digital-brain import-slack --input ./slack-export.zip
95
96
  digital-brain import-linkedin --input ./linkedin-archive.zip
96
97
  digital-brain send-whatsapp --to "Name" --message "text"
98
+ digital-brain auto-whatsapp --allow "Name" --model llama3.1
97
99
  ```
98
100
 
99
101
  `init` remembers your vault globally, so `run` works from anywhere. `run` syncs the live local sources you selected, extracts relationships, and writes interpreted memory in one command.
@@ -113,6 +115,13 @@ digital-brain interpret
113
115
 
114
116
  The sender drafts by default. Add `--yes` to actually send.
115
117
 
118
+ The auto-responder is opt-in and runs only while the command is active. It requires an allowlist unless you explicitly pass `--allow-all`. Without `--yes`, it only logs drafts:
119
+
120
+ ```bash
121
+ digital-brain auto-whatsapp --allow "Mom" --model llama3.1
122
+ digital-brain auto-whatsapp --allow "Mom" --model llama3.1 --yes
123
+ ```
124
+
116
125
  If Digital Brain has already sent two AI-assisted messages to the same chat in the last 24 hours, the next send must disclose that AI is helping unless you explicitly bypass the check.
117
126
 
118
127
  ## Automation
@@ -30,6 +30,7 @@ async function main() {
30
30
  else if (command === "extract") runPython("digital_brain_relationship_extractor.py", argv);
31
31
  else if (command === "interpret") runPython("digital_brain_relationship_interpreter.py", argv);
32
32
  else if (command === "send-whatsapp") runNode("whatsapp-web/send.mjs", argv);
33
+ else if (command === "auto-whatsapp") runNode("whatsapp-web/auto-reply.mjs", argv);
33
34
  else help();
34
35
  }
35
36
 
@@ -583,5 +584,6 @@ Usage:
583
584
  digital-brain extract --days 30
584
585
  digital-brain interpret --days 30
585
586
  digital-brain send-whatsapp --to "Name" --message "Text" [--yes]
587
+ digital-brain auto-whatsapp --allow "Name" --model llama3.1 [--yes]
586
588
  `);
587
589
  }
@@ -93,6 +93,33 @@ For 24/7 local polling, run:
93
93
 
94
94
  The generated script loops forever and sleeps for `refreshIntervalMinutes`. The minimum supported interval is 1 minute. A practical default is 5 minutes.
95
95
 
96
+ ## WhatsApp Auto-Reply
97
+
98
+ `digital-brain auto-whatsapp` is separate from refresh automation. It uses WhatsApp Web for live incoming messages and Ollama for local reply generation.
99
+
100
+ Draft-only:
101
+
102
+ ```bash
103
+ digital-brain auto-whatsapp --allow "Mom" --model llama3.1
104
+ ```
105
+
106
+ Auto-send while the command is running:
107
+
108
+ ```bash
109
+ digital-brain auto-whatsapp --allow "Mom" --model llama3.1 --yes
110
+ ```
111
+
112
+ Guardrails:
113
+
114
+ - requires Ollama running locally
115
+ - requires the selected model, for example `ollama pull llama3.1`
116
+ - requires `--allow "Name"` unless `--allow-all` is explicitly passed
117
+ - skips groups unless `--include-groups` is passed
118
+ - uses a per-chat cooldown, default 20 minutes
119
+ - caps replies per chat per run, default 5
120
+ - logs metadata by default, not full sent text
121
+ - still enforces the AI disclosure rule after repeated AI-assisted sends
122
+
96
123
  ## Local Cron
97
124
 
98
125
  Run every 30 minutes from 8-12:
package/docs/PRIVACY.md CHANGED
@@ -7,6 +7,7 @@ Digital Brain is designed for local use.
7
7
  - No cloud API is called by default.
8
8
  - Ollama interpretation is local when enabled.
9
9
  - WhatsApp sending uses a local WhatsApp Web session.
10
+ - WhatsApp auto-reply uses local Ollama by default, runs only while the command is active, and requires an allowlist unless explicitly overridden.
10
11
  - Raw source data stays under `08 Sources/`; normal AI context should use `06 AI Memory/` and human notes under `04 People/`.
11
12
  - Same-person matching across sources is provisional and file-based; keep source evidence visible when using merged person context.
12
13
 
@@ -15,6 +16,7 @@ Things to be careful about:
15
16
  - Do not commit a generated vault.
16
17
  - Do not paste private message exports into GitHub issues.
17
18
  - Do not enable outbound sending without understanding the risk.
19
+ - Do not use auto-reply for sensitive, legal, medical, financial, emergency, or high-stakes conversations.
18
20
  - Treat WhatsApp database access, iMessage database access, and WhatsApp Web automation as local, permission-sensitive integrations that can change.
19
21
  - Always-on mode runs on your machine and inherits your local permissions.
20
22
  - You are responsible for consent, privacy, message content, and sends made from your machine.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "digital-brain",
3
- "version": "1.0.5",
3
+ "version": "1.1.1",
4
4
  "description": "Your private digital imprint for AI assistants.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,365 @@
1
+ import fs from "node:fs";
2
+ import crypto from "node:crypto";
3
+ import path from "node:path";
4
+ import qrcode from "qrcode-terminal";
5
+ import pkg from "whatsapp-web.js";
6
+
7
+ const { Client, LocalAuth } = pkg;
8
+ const args = parseArgs(process.argv.slice(2));
9
+
10
+ if (!args.vault) usage();
11
+
12
+ const vault = path.resolve(args.vault);
13
+ const whatsAppDir = path.join(vault, "08 Sources", "WhatsApp");
14
+ const outboundDir = path.join(whatsAppDir, "Outbound");
15
+ const sessionDir = path.join(whatsAppDir, ".session");
16
+ const statePath = path.join(outboundDir, "auto-reply-state.json");
17
+ const config = readConfig(vault);
18
+ const model = args.model || config.autoReplyModel || "llama3.1";
19
+ const allow = parseList(args.allow || "");
20
+ const deny = parseList(args.deny || "");
21
+ const allowAll = Boolean(args["allow-all"]);
22
+ const includeGroups = Boolean(args["include-groups"]);
23
+ const sendEnabled = Boolean(args.yes);
24
+ const cooldownMinutes = numberArg("cooldown-minutes", 20);
25
+ const maxRepliesPerChat = numberArg("max-replies-per-chat", 5);
26
+ const maxContextChars = numberArg("max-context-chars", 12000);
27
+ const outboundLogMode = args["log-mode"] || config.outboundLogMode || "metadata";
28
+ const state = loadState();
29
+
30
+ fs.mkdirSync(outboundDir, { recursive: true });
31
+
32
+ if (config.outboundMode === "disabled") {
33
+ console.error("WhatsApp outbound mode is disabled in this vault. Re-run init or edit digital-brain.config.json before using auto-whatsapp.");
34
+ process.exit(1);
35
+ }
36
+
37
+ if (!allowAll && allow.length === 0) {
38
+ console.error('Refusing to auto-reply without an allowlist. Add --allow "Name" or pass --allow-all explicitly.');
39
+ process.exit(1);
40
+ }
41
+
42
+ await assertOllamaModel(model);
43
+
44
+ const client = new Client({
45
+ authStrategy: new LocalAuth({ clientId: "digital-brain", dataPath: sessionDir }),
46
+ puppeteer: { headless: false, args: ["--no-sandbox", "--disable-setuid-sandbox"] },
47
+ });
48
+
49
+ client.on("qr", (qr) => {
50
+ console.log("Scan this QR in WhatsApp > Linked devices:");
51
+ qrcode.generate(qr, { small: true });
52
+ });
53
+
54
+ client.on("ready", () => {
55
+ console.log(`Digital Brain WhatsApp auto-reply running with Ollama model: ${model}`);
56
+ console.log(sendEnabled ? "Auto-send is enabled." : "Draft mode. Replies will be logged but not sent. Add --yes to send.");
57
+ console.log(allowAll ? "Allowlist: all chats." : `Allowlist: ${allow.join(", ")}`);
58
+ });
59
+
60
+ client.on("message", async (message) => {
61
+ try {
62
+ await handleMessage(message);
63
+ } catch (error) {
64
+ console.error(`Auto-reply error: ${error.message}`);
65
+ }
66
+ });
67
+
68
+ client.initialize();
69
+
70
+ async function handleMessage(message) {
71
+ if (message.fromMe || message.isStatus) return;
72
+ if (state.processedMessageIds.includes(message.id?._serialized)) return;
73
+
74
+ const chat = await message.getChat();
75
+ const name = chatName(chat);
76
+ if (chat.isGroup && !includeGroups) return;
77
+ if (!isAllowed(name)) return;
78
+ if (isDenied(name)) return;
79
+ if (isCoolingDown(name)) return;
80
+ if (replyCount(name) >= maxRepliesPerChat) return;
81
+
82
+ const recentMessages = await chat.fetchMessages({ limit: 12 });
83
+ const disclosure = disclosureStatus(name);
84
+ const prompt = buildPrompt({
85
+ chatName: name,
86
+ incomingBody: message.body || "",
87
+ recentMessages,
88
+ disclosureRequired: disclosure.required,
89
+ });
90
+ const reply = await generateReply(prompt);
91
+ const finalReply = disclosure.required && !containsDisclosure(reply)
92
+ ? `Just flagging this is my AI assistant helping draft/send this. ${reply}`
93
+ : reply;
94
+
95
+ if (!finalReply.trim()) return;
96
+ if (sendEnabled) {
97
+ const sent = await chat.sendMessage(finalReply);
98
+ logSent(name, finalReply, sent, message);
99
+ console.log(`Auto-sent to ${name}: ${summarize(finalReply)}`);
100
+ } else {
101
+ logDraft(name, finalReply, message);
102
+ console.log(`Drafted for ${name}: ${summarize(finalReply)}`);
103
+ }
104
+
105
+ markProcessed(message, name);
106
+ }
107
+
108
+ function buildPrompt({ chatName, incomingBody, recentMessages, disclosureRequired }) {
109
+ const memory = readMemoryContext(chatName);
110
+ const transcript = recentMessages
111
+ .slice(-12)
112
+ .map((item) => `${item.fromMe ? "Me" : chatName}: ${compact(item.body || "")}`)
113
+ .join("\n");
114
+ return [
115
+ "You are helping the user reply on WhatsApp.",
116
+ "Write exactly one message to send as the user.",
117
+ "Be natural, concise, and relationship-appropriate.",
118
+ "Use the user's local memory context, but do not reveal private notes or say you read a vault.",
119
+ "Do not invent facts, commitments, times, or promises.",
120
+ disclosureRequired ? "This send requires AI disclosure. Include a short clear disclosure in the message." : "Do not mention AI unless disclosure is required.",
121
+ "",
122
+ `Chat: ${chatName}`,
123
+ "",
124
+ "Relevant local memory:",
125
+ memory,
126
+ "",
127
+ "Recent chat:",
128
+ transcript,
129
+ "",
130
+ `Incoming message: ${incomingBody}`,
131
+ "",
132
+ "Reply:",
133
+ ].join("\n");
134
+ }
135
+
136
+ function readMemoryContext(chatName) {
137
+ const files = [
138
+ path.join(vault, "06 AI Memory", "Person Reply Context.md"),
139
+ path.join(vault, "06 AI Memory", "Person Context Index.md"),
140
+ path.join(vault, "06 AI Memory", "Interpreted Relationship Memory.md"),
141
+ path.join(vault, "06 AI Memory", "What AI Should Remember.md"),
142
+ ];
143
+ const chunks = files
144
+ .filter((file) => fs.existsSync(file))
145
+ .map((file) => `# ${path.basename(file)}\n${fs.readFileSync(file, "utf8")}`);
146
+ const text = chunks.join("\n\n");
147
+ const lower = chatName.toLowerCase();
148
+ const lines = text.split("\n");
149
+ const matchingWindow = [];
150
+ for (let index = 0; index < lines.length; index += 1) {
151
+ if (lines[index].toLowerCase().includes(lower)) {
152
+ matchingWindow.push(lines.slice(Math.max(0, index - 8), index + 18).join("\n"));
153
+ }
154
+ }
155
+ const focused = matchingWindow.join("\n\n") || text;
156
+ return focused.slice(0, maxContextChars);
157
+ }
158
+
159
+ async function generateReply(prompt) {
160
+ const response = await fetch("http://127.0.0.1:11434/api/generate", {
161
+ method: "POST",
162
+ headers: { "content-type": "application/json" },
163
+ body: JSON.stringify({
164
+ model,
165
+ prompt,
166
+ stream: false,
167
+ options: { temperature: 0.35, num_predict: 160 },
168
+ }),
169
+ });
170
+ if (!response.ok) throw new Error(`Ollama generate failed: ${response.status} ${await response.text()}`);
171
+ const body = await response.json();
172
+ return cleanReply(body.response || "");
173
+ }
174
+
175
+ async function assertOllamaModel(modelName) {
176
+ let response;
177
+ try {
178
+ response = await fetch("http://127.0.0.1:11434/api/tags");
179
+ } catch {
180
+ throw new Error("Ollama is not running. Start it with `ollama serve` or open the Ollama app.");
181
+ }
182
+ if (!response.ok) throw new Error(`Ollama tags failed: ${response.status}`);
183
+ const body = await response.json();
184
+ const names = (body.models || []).map((item) => item.name);
185
+ if (!names.some((name) => name === modelName || name.startsWith(`${modelName}:`))) {
186
+ throw new Error(`Ollama model "${modelName}" is not installed. Run: ollama pull ${modelName}`);
187
+ }
188
+ }
189
+
190
+ function cleanReply(value) {
191
+ return String(value)
192
+ .replace(/^["'\s]+|["'\s]+$/g, "")
193
+ .replace(/^Reply:\s*/i, "")
194
+ .split("\n")
195
+ .filter((line) => !/^[-*]\s+/.test(line.trim()))
196
+ .join(" ")
197
+ .replace(/\s+/g, " ")
198
+ .trim()
199
+ .slice(0, 1200);
200
+ }
201
+
202
+ function isAllowed(name) {
203
+ if (allowAll) return true;
204
+ return allow.some((item) => name.toLowerCase().includes(item.toLowerCase()));
205
+ }
206
+
207
+ function isDenied(name) {
208
+ return deny.some((item) => name.toLowerCase().includes(item.toLowerCase()));
209
+ }
210
+
211
+ function isCoolingDown(name) {
212
+ const last = state.lastSentAtByChat[name];
213
+ if (!last) return false;
214
+ return Date.now() - new Date(last).getTime() < cooldownMinutes * 60 * 1000;
215
+ }
216
+
217
+ function replyCount(name) {
218
+ return state.sentCountByChat[name] || 0;
219
+ }
220
+
221
+ function markProcessed(message, name) {
222
+ state.processedMessageIds.push(message.id?._serialized);
223
+ state.processedMessageIds = state.processedMessageIds.filter(Boolean).slice(-1000);
224
+ state.lastSentAtByChat[name] = new Date().toISOString();
225
+ state.sentCountByChat[name] = replyCount(name) + 1;
226
+ writeJsonAtomic(statePath, state);
227
+ }
228
+
229
+ function logDraft(name, reply, trigger) {
230
+ const record = baseRecord(name, reply, trigger);
231
+ appendLog("auto-drafts.jsonl", record);
232
+ fs.appendFileSync(path.join(outboundDir, "Auto Drafts.md"), `- ${record.timestamp} | ${name}: ${visibleMessage(record, reply)}\n`);
233
+ }
234
+
235
+ function logSent(name, reply, sent, trigger) {
236
+ const record = {
237
+ ...baseRecord(name, reply, trigger),
238
+ messageId: sent.id?._serialized || null,
239
+ };
240
+ if (outboundLogMode !== "off") {
241
+ appendLog("sent.jsonl", record);
242
+ fs.appendFileSync(path.join(outboundDir, "Sent.md"), `- ${record.timestamp} | ${name}: ${visibleMessage(record, reply)}\n`);
243
+ }
244
+ }
245
+
246
+ function baseRecord(name, reply, trigger) {
247
+ return {
248
+ timestamp: new Date().toISOString(),
249
+ to: name,
250
+ resolvedChatName: name,
251
+ message: outboundLogMode === "full" ? reply : undefined,
252
+ messageHash: hash(reply),
253
+ messageCharCount: reply.length,
254
+ triggerMessageId: trigger.id?._serialized || null,
255
+ aiAssisted: true,
256
+ autoReply: true,
257
+ disclosureIncluded: containsDisclosure(reply),
258
+ disclosureBypassed: false,
259
+ };
260
+ }
261
+
262
+ function appendLog(filename, record) {
263
+ fs.appendFileSync(path.join(outboundDir, filename), `${JSON.stringify(record)}\n`);
264
+ }
265
+
266
+ function disclosureStatus(chatNameValue) {
267
+ const logPath = path.join(outboundDir, "sent.jsonl");
268
+ if (!fs.existsSync(logPath)) return { required: false, count: 0 };
269
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
270
+ const count = fs
271
+ .readFileSync(logPath, "utf8")
272
+ .split("\n")
273
+ .filter(Boolean)
274
+ .map((line) => {
275
+ try {
276
+ return JSON.parse(line);
277
+ } catch {
278
+ return null;
279
+ }
280
+ })
281
+ .filter((record) => record)
282
+ .filter((record) => record.resolvedChatName === chatNameValue)
283
+ .filter((record) => record.aiAssisted !== false)
284
+ .filter((record) => new Date(record.timestamp).getTime() >= cutoff)
285
+ .filter((record) => !record.disclosureIncluded).length;
286
+ return { required: count >= 2, count };
287
+ }
288
+
289
+ function containsDisclosure(message) {
290
+ return /\b(ai|assistant|automated|bot)\b/i.test(message);
291
+ }
292
+
293
+ function loadState() {
294
+ if (!fs.existsSync(statePath)) return { processedMessageIds: [], lastSentAtByChat: {}, sentCountByChat: {} };
295
+ try {
296
+ return JSON.parse(fs.readFileSync(statePath, "utf8"));
297
+ } catch {
298
+ return { processedMessageIds: [], lastSentAtByChat: {}, sentCountByChat: {} };
299
+ }
300
+ }
301
+
302
+ function writeJsonAtomic(file, value) {
303
+ const temp = `${file}.${process.pid}.tmp`;
304
+ fs.writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`);
305
+ fs.renameSync(temp, file);
306
+ }
307
+
308
+ function visibleMessage(record, reply) {
309
+ return outboundLogMode === "full" ? reply : `[metadata only, ${record.messageCharCount} chars, ${record.messageHash.slice(0, 12)}]`;
310
+ }
311
+
312
+ function chatName(chat) {
313
+ return chat.name || chat.formattedTitle || chat.id?._serialized || "Unknown Chat";
314
+ }
315
+
316
+ function compact(value) {
317
+ return String(value).replace(/\s+/g, " ").trim().slice(0, 500);
318
+ }
319
+
320
+ function summarize(value) {
321
+ return compact(value).slice(0, 120);
322
+ }
323
+
324
+ function readConfig(vaultPath) {
325
+ const file = path.join(vaultPath, "digital-brain.config.json");
326
+ if (!fs.existsSync(file)) return {};
327
+ try {
328
+ return JSON.parse(fs.readFileSync(file, "utf8"));
329
+ } catch {
330
+ return {};
331
+ }
332
+ }
333
+
334
+ function hash(value) {
335
+ return crypto.createHash("sha256").update(value).digest("hex");
336
+ }
337
+
338
+ function parseList(value) {
339
+ return String(value || "").split(",").map((item) => item.trim()).filter(Boolean);
340
+ }
341
+
342
+ function numberArg(name, fallback) {
343
+ const parsed = Number(args[name] || fallback);
344
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
345
+ }
346
+
347
+ function parseArgs(argv) {
348
+ const out = { yes: false };
349
+ for (let i = 0; i < argv.length; i += 1) {
350
+ const arg = argv[i];
351
+ if (arg === "--yes") out.yes = true;
352
+ else if (arg === "--allow-all") out["allow-all"] = true;
353
+ else if (arg === "--include-groups") out["include-groups"] = true;
354
+ else if (arg.startsWith("--")) {
355
+ const key = arg.slice(2);
356
+ out[key] = argv[++i] || "";
357
+ }
358
+ }
359
+ return out;
360
+ }
361
+
362
+ function usage() {
363
+ console.error('Usage: digital-brain auto-whatsapp --allow "Name" --model llama3.1 [--yes] [--allow-all] [--include-groups]');
364
+ process.exit(1);
365
+ }