total-recall-brain 3.32.2 → 3.32.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "total-recall-brain",
3
- "version": "3.32.2",
3
+ "version": "3.32.3",
4
4
  "description": "Personal AI system — database-free, SSSS-powered intelligence engine deployable to any POSIX host.",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -23,6 +23,7 @@
23
23
  "frontend/src/",
24
24
  "extension/",
25
25
  "plugins/",
26
+ "!plugins/**/*.spec.mjs",
26
27
  "templates/",
27
28
  "scaffold/",
28
29
  "docs/ARCHITECTURE.md",
@@ -0,0 +1,27 @@
1
+ # Operator Alerts
2
+
3
+ Operator alerts from agents, scripts and scheduled tasks.
4
+
5
+ **Channels:** `os` (terminal-notifier, osascript, notify-send), `email`
6
+ (SMTP2GO), `sms` (Telnyx), `webhook`, `github`. `slack`, `discord`, `telegram`
7
+ and `expo` are stubs: they report `stub` and send nothing.
8
+
9
+ **Config** (later wins): `~/.total-recall/notifications/config.json`,
10
+ `<repo>/.agent/notify.json`, `--config <file>`.
11
+
12
+ ```json
13
+ {
14
+ "email": { "key_secret": "SMTP2GO_KEY", "to": "secret:OPERATOR_EMAIL", "from": "alerts@your-verified-domain" },
15
+ "sms": { "key_secret": "TELNYX_KEY", "to": "secret:OPERATOR_PHONE", "from": "+1XXXXXXXXXX" }
16
+ }
17
+ ```
18
+
19
+ `*_secret` values name Total Recall secrets read with `secret get` in-process.
20
+ `to` is a literal or `secret:NAME`.
21
+
22
+ **Commands:** `alerts send|test|config|log`. `status: accepted` means the
23
+ provider took it, not that it arrived. Ledger: `sent.jsonl` beside the log in
24
+ `~/.total-recall/notifications/` (override with `TR_NOTIFY_HOME`).
25
+
26
+ **Network access:** api.smtp2go.com, api.telnyx.com, api.github.com, and the
27
+ webhook URL you configure. Nothing else.
@@ -0,0 +1,57 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { sendNotification, loadConfig } from './notify-core.mjs';
4
+
5
+ const USAGE = `usage:
6
+ total-recall alerts send --title T --message M [--channels os,email,sms,webhook,github] [--event-id ID]
7
+ [--severity info|high|critical] [--source S] [--quiet-hours 22-7] [--config file] [--dry-run]
8
+ total-recall alerts test [--channels ...] dry-run every configured channel
9
+ total-recall alerts config show the merged config (secret names only)
10
+ total-recall alerts log [--limit N] recent sends from the ledger`;
11
+
12
+ function flags(args) {
13
+ const a = { _: [] };
14
+ for (let i = 0; i < args.length; i++) {
15
+ const t = args[i];
16
+ if (t === '--dry-run') a.dry = true;
17
+ else if (t.startsWith('--')) a[t.slice(2)] = args[++i];
18
+ else a._.push(t);
19
+ }
20
+ return a;
21
+ }
22
+
23
+ export async function run(argv = []) {
24
+ const args = Array.isArray(argv) ? argv.slice(3) : [];
25
+ const sub = args[0] || 'help';
26
+ const a = flags(args.slice(1));
27
+ const config = loadConfig(process.cwd(), a.config || null);
28
+
29
+ if (sub === 'config') {
30
+ console.log(JSON.stringify(config, null, 2));
31
+ return;
32
+ }
33
+ if (sub === 'log') {
34
+ const home = process.env.TR_NOTIFY_HOME || path.join(process.env.HOME || '', '.total-recall', 'notifications');
35
+ const file = path.join(home, 'sent.jsonl');
36
+ const lines = fs.existsSync(file) ? fs.readFileSync(file, 'utf8').split('\n').filter(Boolean) : [];
37
+ console.log(lines.slice(-Number(a.limit || 20)).join('\n'));
38
+ return;
39
+ }
40
+ if (sub === 'send' || sub === 'test') {
41
+ const test = sub === 'test';
42
+ if (!test && (!a.title || !a.message)) { console.error(USAGE); process.exitCode = 2; return; }
43
+ const channels = (a.channels || (test ? Object.keys(config).join(',') : 'os')).split(',').filter(Boolean);
44
+ const results = await sendNotification({
45
+ title: a.title || 'Notification test', message: a.message || 'Dry run of every configured channel.',
46
+ channels, eventId: a['event-id'], severity: a.severity, source: a.source,
47
+ dry: test || a.dry, quietHours: a['quiet-hours'], config,
48
+ });
49
+ console.log(JSON.stringify(results, null, 2));
50
+ if (results.some((r) => r.status === 'failed')) process.exitCode = 1;
51
+ return;
52
+ }
53
+ console.log(USAGE);
54
+ if (sub !== 'help') process.exitCode = 2;
55
+ }
56
+
57
+ export default run;
@@ -0,0 +1,193 @@
1
+ /**
2
+ * notify-core: send an alert to the operator over one or more channels.
3
+ *
4
+ * Generic: no recipients, senders or key names are hardcoded. Everything comes
5
+ * from flags, a notify.json config, or the Total Recall secret store.
6
+ * See README.md.
7
+ */
8
+ import { execFile } from 'node:child_process';
9
+ import { promisify } from 'node:util';
10
+ import fs from 'node:fs';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+
14
+ const run = promisify(execFile);
15
+ const HOME_DIR = process.env.TR_NOTIFY_HOME || path.join(os.homedir(), '.total-recall', 'notifications');
16
+ const LEDGER = path.join(HOME_DIR, 'sent.jsonl');
17
+ const LOG = path.join(HOME_DIR, 'notification-log.md');
18
+
19
+ function readJson(p) {
20
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return {}; }
21
+ }
22
+
23
+ export function loadConfig(cwd = process.cwd(), explicit = null) {
24
+ const layers = [
25
+ readJson(path.join(HOME_DIR, 'config.json')),
26
+ readJson(path.join(cwd, '.agent', 'notify.json')),
27
+ explicit ? readJson(explicit) : {},
28
+ ];
29
+ const out = {};
30
+ for (const layer of layers) {
31
+ for (const [k, v] of Object.entries(layer)) out[k] = { ...(out[k] || {}), ...v };
32
+ }
33
+ return out;
34
+ }
35
+
36
+ async function secret(name, cwd) {
37
+ if (!name) return null;
38
+ if (process.env[name]) return process.env[name];
39
+ try {
40
+ const { stdout } = await run('total-recall', ['secret', 'get', name], { cwd, maxBuffer: 1 << 20 });
41
+ return stdout.trim() || null;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ /** A recipient is a literal (contains @ or starts with +) or `secret:NAME`. */
48
+ async function recipient(spec, cwd) {
49
+ if (!spec) return null;
50
+ if (spec.startsWith('secret:')) return secret(spec.slice(7), cwd);
51
+ return spec;
52
+ }
53
+
54
+ async function need(cfg, field, cwd, channel) {
55
+ const v = field.endsWith('_secret') ? await secret(cfg[field], cwd) : await recipient(cfg[field], cwd);
56
+ if (!v) throw new Error(`${channel}: missing ${field} (set it in notify.json or via flags)`);
57
+ return v;
58
+ }
59
+
60
+ async function post(url, body, headers = {}) {
61
+ const res = await fetch(url, {
62
+ method: 'POST',
63
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...headers },
64
+ body: JSON.stringify(body),
65
+ });
66
+ const text = await res.text();
67
+ if (!res.ok) throw new Error(`${res.status} ${text.slice(0, 200)}`);
68
+ try { return JSON.parse(text); } catch { return { raw: text }; }
69
+ }
70
+
71
+ const ADAPTERS = {
72
+ async os({ title, message }, cfg, ctx) {
73
+ if (ctx.dry) return { dry: true };
74
+ try {
75
+ await run('terminal-notifier', ['-title', title, '-message', message, '-open', `file://${LOG}`, '-group', `notify-${ctx.source || 'alert'}`]);
76
+ } catch {
77
+ if (process.platform === 'darwin') {
78
+ const q = (s) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
79
+ await run('osascript', ['-e', `display notification "${q(message)}" with title "${q(title)}"`]);
80
+ } else {
81
+ await run('notify-send', [title, message]);
82
+ }
83
+ }
84
+ return { accepted: true };
85
+ },
86
+
87
+ // SMTP2GO
88
+ async email({ title, message }, cfg, ctx) {
89
+ const to = await need(cfg, 'to', ctx.cwd, 'email');
90
+ const sender = cfg.from;
91
+ if (!sender) throw new Error('email: missing from (a verified SMTP2GO sender)');
92
+ if (ctx.dry) return { dry: true, to, sender };
93
+ const key = await need(cfg, 'key_secret', ctx.cwd, 'email');
94
+ const r = await post('https://api.smtp2go.com/v3/email/send', {
95
+ to: [to], sender, subject: title, text_body: message,
96
+ }, { 'X-Smtp2go-Api-Key': key });
97
+ if (r?.data?.succeeded < 1) throw new Error(`email: not accepted ${JSON.stringify(r.data)}`);
98
+ return { accepted: true, provider_id: r?.data?.email_id };
99
+ },
100
+
101
+ // Telnyx Messaging
102
+ async sms({ title, message }, cfg, ctx) {
103
+ const to = await need(cfg, 'to', ctx.cwd, 'sms');
104
+ const from = cfg.from;
105
+ if (!from) throw new Error('sms: missing from (a Telnyx number or messaging profile sender)');
106
+ if (ctx.dry) return { dry: true, to, from };
107
+ const key = await need(cfg, 'key_secret', ctx.cwd, 'sms');
108
+ const r = await post('https://api.telnyx.com/v2/messages', { from, to, text: `${title}\n${message}` }, { Authorization: `Bearer ${key}` });
109
+ return { accepted: true, provider_id: r?.data?.id };
110
+ },
111
+
112
+ // Stubs: not wired yet. They report `stub` instead of pretending to send.
113
+ async slack() { return { stub: true, note: 'slack adapter not implemented' }; },
114
+ async discord() { return { stub: true, note: 'discord adapter not implemented' }; },
115
+ async telegram() { return { stub: true, note: 'telegram adapter not implemented' }; },
116
+ async expo() { return { stub: true, note: 'expo adapter not implemented' }; },
117
+
118
+ async webhook({ title, message, eventId, severity, source }, cfg, ctx) {
119
+ if (ctx.dry) return { dry: true };
120
+ const url = await need(cfg, 'url_secret', ctx.cwd, 'webhook');
121
+ await post(url, { title, message, event_id: eventId, severity, source });
122
+ return { accepted: true };
123
+ },
124
+
125
+ async github({ title, message }, cfg, ctx) {
126
+ const repo = cfg.repo;
127
+ if (!repo) throw new Error('github: missing repo (owner/name)');
128
+ if (ctx.dry) return { dry: true, repo };
129
+ const token = await need(cfg, 'key_secret', ctx.cwd, 'github');
130
+ const r = await post(`https://api.github.com/repos/${repo}/issues`, { title, body: message, labels: cfg.labels || [] }, {
131
+ Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json',
132
+ });
133
+ return { accepted: true, provider_id: r?.number };
134
+ },
135
+ };
136
+
137
+ function inQuietHours(spec, now = new Date()) {
138
+ const m = /^(\d{1,2})-(\d{1,2})$/.exec(spec || '');
139
+ if (!m) return false;
140
+ const [s, e, h] = [Number(m[1]), Number(m[2]), now.getHours()];
141
+ return s <= e ? h >= s && h < e : h >= s || h < e;
142
+ }
143
+
144
+ function sentBefore(eventId, channel) {
145
+ if (!eventId || !fs.existsSync(LEDGER)) return false;
146
+ return fs.readFileSync(LEDGER, 'utf8').split('\n').some((l) => {
147
+ try { const r = JSON.parse(l); return r.event_id === eventId && r.channel === channel && r.status === 'accepted'; } catch { return false; }
148
+ });
149
+ }
150
+
151
+ function record(entry) {
152
+ fs.mkdirSync(HOME_DIR, { recursive: true });
153
+ fs.appendFileSync(LEDGER, JSON.stringify(entry) + '\n');
154
+ }
155
+
156
+ function logMarkdown(title, message, results, severity, source) {
157
+ try {
158
+ fs.mkdirSync(HOME_DIR, { recursive: true });
159
+ const summary = results.map((r) => `${r.channel}:${r.status}`).join(', ');
160
+ const entry = `### [${new Date().toISOString()}] ${title}\n> ${message}\n\n*${[severity, source].filter(Boolean).join(' · ')} | ${summary}*\n\n---\n\n`;
161
+ const old = fs.existsSync(LOG) ? fs.readFileSync(LOG, 'utf8') : '';
162
+ fs.writeFileSync(LOG, entry + old);
163
+ } catch { /* the log never blocks an alert */ }
164
+ }
165
+
166
+ export async function sendNotification(opts) {
167
+ const {
168
+ title, message, channels = ['os'], eventId = null, severity = 'info', source = '',
169
+ dry = false, quietHours = null, cwd = process.cwd(), config = null,
170
+ } = opts;
171
+ const cfg = config || loadConfig(cwd);
172
+ const critical = severity === 'critical' || severity === 'high';
173
+ const quiet = !critical && inQuietHours(quietHours);
174
+ const results = [];
175
+ for (const channel of channels) {
176
+ const base = { channel, event_id: eventId, at: new Date().toISOString() };
177
+ if (!ADAPTERS[channel]) { results.push({ ...base, status: 'failed', error: 'unknown channel' }); continue; }
178
+ if (quiet && channel !== 'os') { results.push({ ...base, status: 'suppressed', reason: 'quiet-hours' }); continue; }
179
+ if (!dry && sentBefore(eventId, channel)) { results.push({ ...base, status: 'duplicate' }); continue; }
180
+ try {
181
+ const r = await ADAPTERS[channel]({ title, message, eventId, severity, source }, cfg[channel] || {}, { dry, cwd, source });
182
+ const row = { ...base, status: r.stub ? 'stub' : dry ? 'dry-run' : 'accepted', ...r };
183
+ results.push(row);
184
+ if (!dry && !r.stub) record({ ...row, channel, event_id: eventId });
185
+ } catch (e) {
186
+ const row = { ...base, status: 'failed', error: String(e.message || e) };
187
+ results.push(row);
188
+ if (!dry) record(row);
189
+ }
190
+ }
191
+ if (!dry) logMarkdown(title, message, results, severity, source);
192
+ return results;
193
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "$schema": "https://github.com/gregiteen/total-recall/blob/main/metadata.plugin.schema.json",
3
+ "id": "operator-alerts",
4
+ "name": "Operator Alerts",
5
+ "version": "1.0.0",
6
+ "description": "Send operator alerts over desktop, email (SMTP2GO), SMS (Telnyx), webhook and issue-tracker alerts with dedupe, quiet hours and delivery evidence. Keys come from the secret store; recipients from config.",
7
+ "author": "Total Recall",
8
+ "license": "MIT",
9
+ "use_cases": ["alerting"],
10
+ "cli": {
11
+ "command": "alerts",
12
+ "handler": "./cli.mjs",
13
+ "subcommands": [
14
+ { "name": "send", "description": "Send an alert (--dry-run to resolve config without sending)" },
15
+ { "name": "test", "description": "Dry-run every configured channel" },
16
+ { "name": "config", "description": "Show the merged notify config" },
17
+ { "name": "log", "description": "Recent sends from the ledger" }
18
+ ]
19
+ }
20
+ }