total-recall-brain 3.32.2 → 3.32.4

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.4",
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
+ }
@@ -170,7 +170,7 @@ function printHelp() {
170
170
  `);
171
171
  }
172
172
 
173
- function resolveTargetDir(isGlobal) {
173
+ export function resolveTargetDir(isGlobal) {
174
174
  if (isGlobal) {
175
175
  try {
176
176
  const gBrain = resolveBrainLayer('global');
@@ -235,7 +235,7 @@ function existingTag(file, key) {
235
235
  * Composable instructions: the surfaces list every command, so any change to
236
236
  * the command set recompiles them (project layer, or every project for global).
237
237
  */
238
- function recompileSurfaces(isGlobal) {
238
+ export function recompileSurfaces(isGlobal) {
239
239
  if (process.env.TR_COMMAND_NO_COMPILE === '1' || !process.argv[1]) return;
240
240
  try {
241
241
  const child = spawn(process.execPath, [process.argv[1], 'compile', isGlobal ? '--global' : '--project'], { detached: true, stdio: 'ignore' });
@@ -246,6 +246,48 @@ function recompileSurfaces(isGlobal) {
246
246
  }
247
247
  }
248
248
 
249
+ /**
250
+ * Register every `commands` entry of an installed plugin as a composable command.
251
+ * A command that already exists and was generated from this plugin is refreshed;
252
+ * one that exists from anywhere else is left alone and reported.
253
+ */
254
+ export function syncPluginCommands(pluginDir, { global: isGlobal = false } = {}) {
255
+ const manifest = JSON.parse(fs.readFileSync(path.join(fs.realpathSync(pluginDir), 'plugin.json'), 'utf8'));
256
+ const commandsDir = resolveTargetDir(isGlobal);
257
+ const result = { created: [], skipped: [] };
258
+ for (const entry of manifest.commands || []) {
259
+ const file = path.join(commandsDir, `${entry.name}.mjs`);
260
+ if (fs.existsSync(file)) {
261
+ if (!fs.readFileSync(file, 'utf8').startsWith(`// Generated from plugin ${manifest.id};`)) {
262
+ result.skipped.push({ name: entry.name, reason: 'a different command with this name exists' });
263
+ continue;
264
+ }
265
+ fs.rmSync(file);
266
+ }
267
+ generatePluginCommand(pluginDir, entry.name, commandsDir);
268
+ result.created.push(entry.name);
269
+ }
270
+ if (result.created.length) recompileSurfaces(isGlobal);
271
+ return result;
272
+ }
273
+
274
+ /** Remove the commands a plugin generated (only files carrying its generated header). */
275
+ export function removePluginCommands(pluginId, { global: isGlobal = false } = {}) {
276
+ const commandsDir = resolveTargetDir(isGlobal);
277
+ const removed = [];
278
+ if (!fs.existsSync(commandsDir)) return removed;
279
+ for (const name of fs.readdirSync(commandsDir)) {
280
+ if (!name.endsWith('.mjs')) continue;
281
+ const file = path.join(commandsDir, name);
282
+ if (fs.readFileSync(file, 'utf8').startsWith(`// Generated from plugin ${pluginId};`)) {
283
+ fs.rmSync(file);
284
+ removed.push(name.replace(/\.mjs$/, ''));
285
+ }
286
+ }
287
+ if (removed.length) recompileSurfaces(isGlobal);
288
+ return removed;
289
+ }
290
+
249
291
  export default async function commandCmd(rawArgs = []) {
250
292
  const isGlobal = rawArgs.includes('--global') || rawArgs.includes('-g');
251
293
  const asJson = rawArgs.includes('--json');
@@ -1,4 +1,5 @@
1
1
  import { installPlugin as install } from '../../core/plugin-store.mjs';
2
+ import { syncPluginCommands } from '../command.mjs';
2
3
 
3
4
  export async function installPlugin(args = []) {
4
5
  const isGlobal = args.includes('--global') || args.includes('-g');
@@ -16,7 +17,11 @@ export async function installPlugin(args = []) {
16
17
  const from = result.source.kind === 'peer' ? `peer ${result.source.peer_hostname}` : result.source.kind;
17
18
  console.log(`\n✅ Installed '${result.plugin.name}' (id: ${result.plugin.id}, v${result.plugin.version}) from ${from}`);
18
19
  console.log(` Location: ${result.plugin.dir}`);
19
- console.log(` sha256: ${result.sha256}\n`);
20
+ console.log(` sha256: ${result.sha256}`);
21
+ const cmds = syncPluginCommands(result.plugin.dir, { global: isGlobal });
22
+ if (cmds.created.length) console.log(` Commands: ${cmds.created.map((c) => `total-recall ${c}`).join(', ')}`);
23
+ for (const c of cmds.skipped) console.log(` Skipped command '${c.name}': ${c.reason}`);
24
+ console.log('');
20
25
  } catch (err) {
21
26
  console.error(`❌ ${err.message}`);
22
27
  process.exit(1);
@@ -1,4 +1,5 @@
1
1
  import { uninstallPlugin, setPluginShared } from '../../core/plugin-store.mjs';
2
+ import { removePluginCommands } from '../command.mjs';
2
3
  import { publicPluginShareUrl } from '../../core/plugin-public.mjs';
3
4
 
4
5
  export async function removePlugin(args = []) {
@@ -13,7 +14,10 @@ export async function removePlugin(args = []) {
13
14
 
14
15
  try {
15
16
  const result = await uninstallPlugin(id, { projectRoot: process.cwd(), global: isGlobal ? true : undefined });
16
- console.log(`\n✅ Removed plugin '${id}' (${result.scope}) from ${result.dir}\n`);
17
+ const removed = removePluginCommands(id, { global: result.scope === 'global' });
18
+ console.log(`\n✅ Removed plugin '${id}' (${result.scope}) from ${result.dir}`);
19
+ if (removed.length) console.log(` Removed commands: ${removed.join(', ')}`);
20
+ console.log('');
17
21
  } catch (err) {
18
22
  console.error(`❌ ${err.message}`);
19
23
  process.exit(1);
@@ -10,6 +10,7 @@ import fs from 'node:fs';
10
10
  import path from 'node:path';
11
11
  import os from 'node:os';
12
12
  import { setSecret, listSecretsMeta, updateSecretMeta } from './secrets-store.mjs';
13
+ import { providerForKeyName } from './provider-catalog.mjs';
13
14
 
14
15
  /**
15
16
  * @deprecated Kept as empty/export for older callers. Import does NOT use this
@@ -108,13 +109,9 @@ export function inferProvider(key) {
108
109
  if (!key) return null;
109
110
  const k = String(key);
110
111
 
111
- // Lazy catalog match (optional enrichment only)
112
- try {
113
- // Dynamic import avoided in sync path — inline thin match from catalog module if already loadable
114
- // Use sync require-free approach: duplicate-free segment heuristic only here for speed.
115
- } catch {
116
- /* ignore */
117
- }
112
+ // A known provider anywhere in the key name wins (JSN_SMTP2GO_API_KEY -> smtp2go).
113
+ const known = providerForKeyName(k);
114
+ if (known) return known.id;
118
115
 
119
116
  // Strip common prefixes that are not the vendor
120
117
  let base = k