total-recall-brain 3.18.2 → 3.18.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.
Files changed (45) hide show
  1. package/frontend/dist/assets/index-CGqvdwHT.js +5742 -0
  2. package/frontend/dist/index.html +1 -1
  3. package/frontend/src/api/config.ts +16 -2
  4. package/frontend/src/api/keys.ts +111 -0
  5. package/frontend/src/api/notifications.ts +54 -6
  6. package/frontend/src/api/update.ts +12 -1
  7. package/frontend/src/components/BrainSelector.spec.tsx +71 -47
  8. package/frontend/src/components/BrainSelector.tsx +112 -35
  9. package/frontend/src/components/MobilePairing.spec.tsx +44 -14
  10. package/frontend/src/components/MobilePairing.tsx +229 -22
  11. package/frontend/src/pages/NotificationsPage.tsx +6 -3
  12. package/frontend/src/pages/RulesPage.spec.tsx +46 -15
  13. package/frontend/src/pages/RulesPage.tsx +41 -9
  14. package/frontend/src/pages/SecretsPage.tsx +316 -3
  15. package/frontend/src/pages/SettingsPage.spec.tsx +30 -6
  16. package/frontend/src/pages/SettingsPage.tsx +206 -85
  17. package/package.json +1 -1
  18. package/src/cli/agent-dir.mjs +2 -1
  19. package/src/cli/secret.mjs +180 -3
  20. package/src/core/daemon-loop.mjs +31 -5
  21. package/src/core/notifications.mjs +204 -34
  22. package/src/core/package-auto-update.mjs +99 -3
  23. package/src/core/pairing.mjs +168 -0
  24. package/src/core/provider-account-sync.mjs +1145 -0
  25. package/src/core/provider-catalog.mjs +11 -4
  26. package/src/core/research.mjs +98 -24
  27. package/src/core/scheduler.mjs +31 -9
  28. package/src/core/schema.mjs +23 -0
  29. package/src/core/secret-integration-research.mjs +325 -0
  30. package/src/core/secrets-store.mjs +278 -14
  31. package/src/core/secrets-sync.mjs +1 -1
  32. package/src/core/source-adapters.mjs +247 -22
  33. package/src/core/task-executors.mjs +161 -14
  34. package/src/core/webhook-handlers.mjs +123 -14
  35. package/src/server/auth.mjs +6 -0
  36. package/src/server/rest.mjs +2 -0
  37. package/src/server/route-manifest.json +24 -0
  38. package/src/server/routes/config.mjs +64 -23
  39. package/src/server/routes/mesh.mjs +2 -1
  40. package/src/server/routes/notifications.mjs +103 -0
  41. package/src/server/routes/rules.mjs +75 -20
  42. package/src/server/routes/secrets.mjs +110 -4
  43. package/src/server/routes/system.mjs +20 -1
  44. package/src/server/routes/update.mjs +11 -4
  45. package/frontend/dist/assets/index-D5xRw5uq.js +0 -5750
@@ -28,6 +28,7 @@ import {
28
28
  getSecretsCatalog,
29
29
  updateSecretMeta,
30
30
  listRotationDue,
31
+ getSharedValueHealth,
31
32
  } from '../core/secrets-store.mjs';
32
33
  import {
33
34
  scanEnvSources,
@@ -79,9 +80,22 @@ function printHelp() {
79
80
  --dry-run Show keys that would be written
80
81
  import-env One-time migrate: scan local .env → store (not the steady-state path)
81
82
  --all / --file / --overwrite / --dry-run
82
- catalog Full catalog: keys, providers, usage, rotation, cost
83
+ catalog Full catalog: keys, providers, usage, rotation, cost, tracking
83
84
  providers List provider registry (docs/schema/tiers)
85
+ account-sync [key] Live-probe vendor account/usage/subscription APIs
86
+ --all Sync every set secret (default if no key)
87
+ --strict partial (no $ API) → ERROR (default)
88
+ --no-strict Allow partial status
89
+ --use-ai AI-classify unknown keys (slow)
90
+ --exempt Mark key tracking_exempt (with optional --monthly-cost)
91
+ tracking-health Fail (exit 1) if any set secret is untracked
92
+ shared List credential values reused across keys/apps (exit 1 if any)
93
+ --shared-ok Mark key as intentional share (waive) — use with meta
84
94
  meta <key> Update metadata (no value change)
95
+ --tracking-exempt Waive live account API requirement
96
+ --no-tracking-exempt
97
+ --shared-ok Allow this key to share its value with other secret names
98
+ --no-shared-ok
85
99
  --repo <name> Bind to exactly ONE product repo (not multi)
86
100
  --repos <name> Same as --repo (single name only; multi rejected)
87
101
  --tier <id> Subscription tier
@@ -167,6 +181,11 @@ function parseArgs(args) {
167
181
  enqueue: false,
168
182
  bytes: 32,
169
183
  format: 'base64url',
184
+ strict: true,
185
+ use_ai: false,
186
+ exempt: false,
187
+ tracking_exempt: null,
188
+ shared_ok: null,
170
189
  };
171
190
  if (!args.length || args[0] === '--help' || args[0] === '-h') {
172
191
  out.help = true;
@@ -175,7 +194,9 @@ function parseArgs(args) {
175
194
  out.command = args[0];
176
195
  let i = 1;
177
196
  if (
178
- ['set', 'get', 'rotate', 'delete', 'meta', 'rotate-browser', 'rm', 'generate'].includes(out.command) &&
197
+ ['set', 'get', 'rotate', 'delete', 'meta', 'rotate-browser', 'rm', 'generate', 'account-sync'].includes(
198
+ out.command,
199
+ ) &&
179
200
  args[i] &&
180
201
  !args[i].startsWith('--')
181
202
  ) {
@@ -301,6 +322,30 @@ function parseArgs(args) {
301
322
  case '--format':
302
323
  out.format = args[++i] || 'base64url';
303
324
  break;
325
+ case '--strict':
326
+ out.strict = true;
327
+ break;
328
+ case '--no-strict':
329
+ out.strict = false;
330
+ break;
331
+ case '--use-ai':
332
+ out.use_ai = true;
333
+ break;
334
+ case '--exempt':
335
+ out.exempt = true;
336
+ break;
337
+ case '--tracking-exempt':
338
+ out.tracking_exempt = true;
339
+ break;
340
+ case '--no-tracking-exempt':
341
+ out.tracking_exempt = false;
342
+ break;
343
+ case '--shared-ok':
344
+ out.shared_ok = true;
345
+ break;
346
+ case '--no-shared-ok':
347
+ out.shared_ok = false;
348
+ break;
304
349
  case '--help':
305
350
  case '-h':
306
351
  out.help = true;
@@ -457,6 +502,121 @@ export default async function secretCli(argv) {
457
502
  return;
458
503
  }
459
504
 
505
+ if (opts.command === 'account-sync' || opts.command === 'sync-accounts') {
506
+ const {
507
+ syncSecretAccount,
508
+ syncAllSecretAccounts,
509
+ getTrackingHealth,
510
+ } = await import('../core/provider-account-sync.mjs');
511
+ const syncOpts = {
512
+ strict: opts.strict !== false,
513
+ use_ai: !!opts.use_ai,
514
+ force_exempt: !!opts.exempt,
515
+ };
516
+ if (opts.key) {
517
+ if (opts.exempt) {
518
+ await updateSecretMeta(
519
+ brainDir,
520
+ opts.key,
521
+ {
522
+ tracking_exempt: true,
523
+ monthly_cost_usd: opts.monthly_cost ?? undefined,
524
+ },
525
+ { actor: 'cli' },
526
+ );
527
+ }
528
+ const r = await syncSecretAccount(brainDir, opts.key, syncOpts);
529
+ const icon =
530
+ r.tracking_status === 'ok' ? '✅' : r.tracking_status === 'exempt' ? '⏭' : '❌';
531
+ console.log(`\n ${icon} ${r.key} status=${r.tracking_status} provider=${r.provider || '-'}`);
532
+ if (r.probe) console.log(` probe: ${r.probe}`);
533
+ if (r.error) console.log(` error: ${r.error}`);
534
+ if (r.account) console.log(` account: ${JSON.stringify(r.account)}`);
535
+ if (r.usage) console.log(` usage: ${JSON.stringify(r.usage)}`);
536
+ if (r.subscription) console.log(` subscription: ${JSON.stringify(r.subscription)}`);
537
+ console.log('');
538
+ if (r.tracking_status === 'error') process.exit(1);
539
+ return;
540
+ }
541
+ console.log('\n 🔄 Live provider account/usage sync (all set secrets)…\n');
542
+ const report = await syncAllSecretAccounts(brainDir, syncOpts);
543
+ console.log(` total=${report.total} ok/exempt=${report.ok} errors=${report.errors}`);
544
+ console.log(` ${report.healthy ? '✅' : '❌'} ${report.message}`);
545
+ if (report.error_keys?.length) {
546
+ console.log('\n Untracked / failed:');
547
+ for (const e of report.error_keys.slice(0, 50)) {
548
+ console.log(` • ${e.key}: ${e.error}`);
549
+ }
550
+ if (report.error_keys.length > 50) {
551
+ console.log(` … and ${report.error_keys.length - 50} more`);
552
+ }
553
+ }
554
+ const health = await getTrackingHealth(brainDir);
555
+ console.log(
556
+ `\n tracking-health: healthy=${health.healthy} ok=${health.ok} exempt=${health.exempt} errors=${health.errors}\n`,
557
+ );
558
+ if (!report.healthy) process.exit(1);
559
+ return;
560
+ }
561
+
562
+ if (opts.command === 'tracking-health' || opts.command === 'tracking') {
563
+ const { getTrackingHealth } = await import('../core/provider-account-sync.mjs');
564
+ const health = await getTrackingHealth(brainDir);
565
+ console.log(`\n ${health.healthy ? '✅' : '❌'} ${health.message}`);
566
+ console.log(` ok=${health.ok} exempt=${health.exempt} errors=${health.errors}`);
567
+ if (health.error_keys?.length) {
568
+ console.log('');
569
+ for (const e of health.error_keys) {
570
+ console.log(` • ${e.key.padEnd(28)} ${(e.provider || '-').padEnd(14)} ${e.error}`);
571
+ }
572
+ }
573
+ const shared = await getSharedValueHealth(brainDir);
574
+ console.log(`\n ${shared.healthy ? '✅' : '❌'} ${shared.message}`);
575
+ if (shared.errors?.length) {
576
+ for (const e of shared.errors) {
577
+ console.log(` • fp=${e.fingerprint} apps=[${e.apps.join(', ')}]`);
578
+ console.log(` keys: ${e.keys.join(', ')}`);
579
+ }
580
+ }
581
+ console.log('');
582
+ if (!health.healthy || !shared.healthy) process.exit(1);
583
+ return;
584
+ }
585
+
586
+ if (opts.command === 'shared' || opts.command === 'shared-values' || opts.command === 'duplicates') {
587
+ const shared = await getSharedValueHealth(brainDir);
588
+ console.log(`\n ${shared.healthy ? '✅' : '❌'} ${shared.message}`);
589
+ console.log(
590
+ ` groups=${shared.groups.length} error_groups=${shared.error_groups} multi_app=${shared.multi_app_groups} keys_in_error=${shared.shared_keys}`,
591
+ );
592
+ if (shared.groups.length) {
593
+ console.log('');
594
+ for (const g of shared.groups) {
595
+ const icon = g.severity === 'error' ? '❌' : '⏭';
596
+ console.log(
597
+ ` ${icon} fingerprint ${g.fingerprint} ×${g.count} apps=[${g.apps.join(', ')}]`,
598
+ );
599
+ for (const m of g.members) {
600
+ console.log(
601
+ ` • ${m.key.padEnd(36)} app=${String(m.app).padEnd(16)} provider=${m.provider || '-'} ${m.masked || ''}`,
602
+ );
603
+ }
604
+ if (g.error) console.log(` → ${g.error}`);
605
+ console.log(
606
+ ` fix: rotate each key to a unique value, then: secret rotate <key> <new> --export-env`,
607
+ );
608
+ console.log(
609
+ ` waive (rare): secret meta <key> --shared-ok # only if intentional mirror`,
610
+ );
611
+ console.log('');
612
+ }
613
+ } else {
614
+ console.log('\n No shared credential values detected.\n');
615
+ }
616
+ if (!shared.healthy) process.exit(1);
617
+ return;
618
+ }
619
+
460
620
  if (opts.command === 'meta') {
461
621
  if (!opts.key) {
462
622
  console.error('Usage: total-recall secret meta <key> [--repo ...] [--tier ...]');
@@ -475,6 +635,11 @@ export default async function secretCli(argv) {
475
635
  if (opts.project) patch.project_path = opts.project;
476
636
  if (opts.provider) patch.provider = opts.provider;
477
637
  if (opts.scope) patch.scope = opts.scope;
638
+ if (opts.tracking_exempt != null) {
639
+ patch.tracking_exempt = opts.tracking_exempt;
640
+ if (opts.tracking_exempt) patch.tracking_status = 'exempt';
641
+ }
642
+ if (opts.shared_ok != null) patch.shared_value_ok = opts.shared_ok;
478
643
  // Fill docs from catalog if provider set and no docs
479
644
  if (patch.provider && !patch.api_docs_url) {
480
645
  const p = getProvider(patch.provider);
@@ -538,10 +703,22 @@ export default async function secretCli(argv) {
538
703
  console.log(`\n Secrets metadata — ${resolveSecretsPath(brainDir)}\n`);
539
704
  for (const r of rows) {
540
705
  const repos = r.repos?.length ? r.repos.join(',') : '-';
706
+ const tr = r.tracking_status || 'never';
707
+ const trIcon = tr === 'ok' ? '✅' : tr === 'exempt' ? '⏭' : '❌';
708
+ const share = r.shared_value ? ' 🔗SHARED' : '';
541
709
  console.log(
542
- ` • ${r.key.padEnd(28)} len=${String(r.length).padStart(4)} ${(r.provider || '-').padEnd(12)} tier=${r.subscription_tier || '-'} $${r.monthly_cost_usd ?? '-'} repos=${repos}`,
710
+ ` • ${r.key.padEnd(28)} len=${String(r.length).padStart(4)} ${(r.provider || '-').padEnd(12)} ${trIcon}${tr.padEnd(7)} tier=${r.subscription_tier || '-'} $${r.monthly_cost_usd ?? '-'} repos=${repos}${share}`,
543
711
  );
544
712
  if (r.api_docs_url) console.log(` docs: ${r.api_docs_url}`);
713
+ if (r.tracking_error && tr === 'error') console.log(` tracking: ${r.tracking_error}`);
714
+ if (r.shared_value_error) {
715
+ console.log(` shared: ${r.shared_value_error}`);
716
+ if (r.shared_with?.length) {
717
+ console.log(
718
+ ` same value as: ${r.shared_with.map((s) => `${s.key}(${s.app})`).join(', ')}`,
719
+ );
720
+ }
721
+ }
545
722
  }
546
723
  console.log('');
547
724
  return;
@@ -469,19 +469,28 @@ async function main() {
469
469
  status: result.success ? 'pending' : 'failed',
470
470
  notes: result.output ? `Phase output: ${result.output}` : (result.error || null),
471
471
  };
472
- if (result.factSlug) {
472
+ if (result.factSlug && result.factSlug !== 'pending') {
473
473
  patch.node_slug = result.factSlug;
474
474
  }
475
+ const nodeSlug = patch.node_slug || task._node_slug;
475
476
  if (result.skippedLLM) {
476
477
  patch.status = 'pending';
477
478
  } else if (result.success) {
479
+ // Never advance past acquisition without a real memory/draft node
478
480
  if (task._research_phase === 'acquisition' && !result.factSlug) {
479
481
  patch.status = 'failed';
480
- patch.notes = 'No insights found during acquisition.';
482
+ patch.notes =
483
+ result.error ||
484
+ 'No insights found during acquisition (no web results / synthesis failed).';
481
485
  } else if (task._research_phase === 'acquisition') {
482
486
  patch.status = 'pending';
483
487
  patch.research_phase = 'deliberation';
484
- patch.completed_at = new Date().toISOString();
488
+ patch.completed_at = null;
489
+ } else if (!nodeSlug || nodeSlug === 'pending') {
490
+ // Later phase without content → force back to acquisition
491
+ patch.status = 'pending';
492
+ patch.research_phase = 'acquisition';
493
+ patch.notes = `Phase ${task._research_phase} had no node_slug — re-running acquisition.`;
485
494
  } else if (task._research_phase === 'deliberation') {
486
495
  patch.status = 'pending';
487
496
  patch.research_phase = 'improvement';
@@ -492,8 +501,25 @@ async function main() {
492
501
  patch.status = 'pending';
493
502
  patch.research_phase = 'expansion';
494
503
  } else if (task._research_phase === 'expansion') {
495
- patch.status = 'done';
496
- patch.completed_at = new Date().toISOString();
504
+ // Only mark done when we still have a real node
505
+ if (nodeSlug && nodeSlug !== 'pending') {
506
+ patch.status = 'done';
507
+ patch.node_slug = nodeSlug;
508
+ patch.completed_at = new Date().toISOString();
509
+ } else {
510
+ patch.status = 'failed';
511
+ patch.notes = 'Expansion finished without a research memory node.';
512
+ }
513
+ }
514
+ } else {
515
+ // Failure on later phase: if no node, reset to acquisition for retry
516
+ if (
517
+ (!task._node_slug || task._node_slug === 'pending') &&
518
+ task._research_phase !== 'acquisition'
519
+ ) {
520
+ patch.status = 'pending';
521
+ patch.research_phase = 'acquisition';
522
+ patch.notes = `Reset to acquisition after ${task._research_phase} failure: ${result.error || 'unknown'}`;
497
523
  }
498
524
  }
499
525
  updateQueueItem(task._research_id, patch);
@@ -1,77 +1,247 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
+ import crypto from 'node:crypto';
3
4
  import { logger } from './logger.mjs';
5
+ import { findVfsDocumentByPath, listVfsDocumentsUnder } from './vfs-documents.mjs';
6
+ import {
7
+ appendVfsEvent,
8
+ deleteVfsDocument,
9
+ listVfsEvents,
10
+ writeVfsDocument,
11
+ } from './ssss-operation-service.mjs';
4
12
 
5
13
  const execFileAsync = promisify(execFile);
6
14
 
15
+ /** Supported mesh / system alert events (Agent Rules / dashboard selector). */
16
+ export const NOTIFICATION_EVENTS = new Set([
17
+ 'node_offline',
18
+ 'leader_change',
19
+ 'webhook_failed',
20
+ 'secret_sync_failed',
21
+ 'daemon_error',
22
+ 'research_complete',
23
+ 'test',
24
+ ]);
25
+
26
+ export const NOTIFICATION_CHANNELS = new Set(['desktop', 'webhook', 'email']);
27
+ export const NOTIFICATION_PRIORITIES = new Set(['critical', 'high', 'low']);
28
+
29
+ const RULES_PREFIX = 'system/notification-rules';
30
+ const HISTORY_WORKSPACE = 'notifications';
31
+ const MAX_HISTORY = 100;
32
+
33
+ function rulePath(id) {
34
+ return `${RULES_PREFIX}/${id}.md`;
35
+ }
36
+
37
+ function eventOpts(intent) {
38
+ return {
39
+ actorRole: 'system',
40
+ intent,
41
+ workspaceId: HISTORY_WORKSPACE,
42
+ };
43
+ }
44
+
7
45
  /**
8
46
  * Sends a macOS desktop notification using terminal-notifier if available,
9
47
  * falling back to osascript.
10
48
  *
11
- * @param {string} title - The notification title
12
- * @param {string} message - The notification message content
13
- * @param {object} [options] - Additional parameters
14
- * @param {string} [options.open] - Absolute path or URL to open on clicking "Open" or "Show"
15
- * @param {string} [options.sound] - macOS sound name (e.g., 'Glass', 'Hero', 'Sosumi', 'default')
16
- * @param {string} [options.subtitle] - Subtitle text to show
17
- * @param {string} [options.group] - Group ID to allow notification replacement rather than stacking
49
+ * @param {string} title
50
+ * @param {string} message
51
+ * @param {object} [options]
52
+ * @returns {Promise<{ delivered: boolean, channel: string, error?: string }>}
18
53
  */
19
54
  export async function sendSystemNotification(title, message, options = {}) {
20
- // Bypassed in tests
21
55
  const isTest = process.env.VITEST || process.env._TR_TEST_AGENT_DIR || process.env.NODE_ENV === 'test';
22
56
  if (isTest) {
23
57
  logger.debug('notifications: bypassing system notification in test environment');
24
- return;
58
+ return { delivered: true, channel: 'desktop', bypassed: true };
25
59
  }
26
60
 
27
61
  const {
28
62
  open,
29
63
  sound = 'default',
30
64
  subtitle = 'Total Recall',
31
- group = 'total-recall-system'
65
+ group = 'total-recall-system',
32
66
  } = options;
33
67
 
34
- // Sanitize inputs to prevent unexpected arguments/escaping issues
35
68
  const cleanMessage = String(message).replace(/[^ -~]/g, '').replace(/["'`$\\]/g, '').slice(0, 200);
36
69
  const cleanTitle = String(title).replace(/[^ -~]/g, '').replace(/["'`$\\]/g, '').slice(0, 100);
37
- const cleanSubtitle = subtitle ? String(subtitle).replace(/[^ -~]/g, '').replace(/["'`$\\]/g, '').slice(0, 100) : '';
70
+ const cleanSubtitle = subtitle
71
+ ? String(subtitle).replace(/[^ -~]/g, '').replace(/["'`$\\]/g, '').slice(0, 100)
72
+ : '';
38
73
 
39
74
  try {
40
- // 1. Try terminal-notifier first (supports click actions, custom sounds, subtitles, grouping)
41
- const args = [
42
- '-title', cleanTitle,
43
- '-message', cleanMessage,
44
- '-group', group,
45
- ];
46
- if (cleanSubtitle) {
47
- args.push('-subtitle', cleanSubtitle);
48
- }
75
+ const args = ['-title', cleanTitle, '-message', cleanMessage, '-group', group];
76
+ if (cleanSubtitle) args.push('-subtitle', cleanSubtitle);
49
77
  if (open) {
50
- // Ensure file url is correct format or HTTP(S) URL
51
- const openTarget = (open.startsWith('/') || open.startsWith('~')) ? `file://${open}` : open;
52
- args.push('-open', openTarget);
53
- args.push('-actions', 'Open');
54
- }
55
- if (sound && sound !== 'default') {
56
- args.push('-sound', sound);
78
+ const openTarget = open.startsWith('/') || open.startsWith('~') ? `file://${open}` : open;
79
+ args.push('-open', openTarget, '-actions', 'Open');
57
80
  }
81
+ if (sound && sound !== 'default') args.push('-sound', sound);
58
82
 
59
83
  await execFileAsync('terminal-notifier', args, { timeout: 3000 });
60
- logger.debug('notifications: successfully sent notification via terminal-notifier', { title, message });
61
- } catch (err) {
62
- // 2. Fall back to plain AppleScript display notification if terminal-notifier fails
84
+ logger.debug('notifications: sent via terminal-notifier', { title });
85
+ return { delivered: true, channel: 'desktop' };
86
+ } catch {
63
87
  try {
64
88
  const appleScriptParts = [
65
89
  `display notification "${cleanMessage.replace(/"/g, '\\"')}"`,
66
- `with title "${cleanTitle.replace(/"/g, '\\"')}"`
90
+ `with title "${cleanTitle.replace(/"/g, '\\"')}"`,
67
91
  ];
68
92
  if (sound && sound !== 'default') {
69
93
  appleScriptParts.push(`sound name "${sound}"`);
70
94
  }
71
95
  await execFileAsync('osascript', ['-e', appleScriptParts.join(' ')], { timeout: 3000 });
72
- logger.debug('notifications: successfully sent fallback osascript notification', { title, message });
96
+ logger.debug('notifications: sent via osascript', { title });
97
+ return { delivered: true, channel: 'desktop' };
73
98
  } catch (fallbackErr) {
74
- logger.debug('notifications: both terminal-notifier and osascript failed', { err: fallbackErr.message });
99
+ logger.debug('notifications: desktop delivery failed', { err: fallbackErr.message });
100
+ return { delivered: false, channel: 'desktop', error: fallbackErr.message };
75
101
  }
76
102
  }
77
103
  }
104
+
105
+ function publicRule(doc) {
106
+ const fm = doc.frontmatter || doc;
107
+ return {
108
+ id: String(fm.id || doc.vfs_path?.replace(/^.*\//, '').replace(/\.md$/, '') || ''),
109
+ event: String(fm.event || ''),
110
+ channel: String(fm.channel || 'desktop'),
111
+ priority: String(fm.priority || 'high'),
112
+ enabled: fm.enabled !== false && fm.enabled !== 'false',
113
+ quietHours: fm.quietHours === true || fm.quietHours === 'true',
114
+ };
115
+ }
116
+
117
+ export function listNotificationRules() {
118
+ return listVfsDocumentsUnder(RULES_PREFIX)
119
+ .filter((doc) => (doc.type || doc.frontmatter?.type) === 'notification_rule' || doc.event || doc.frontmatter?.event)
120
+ .map(publicRule)
121
+ .filter((r) => r.id && r.event);
122
+ }
123
+
124
+ /**
125
+ * @param {object} input
126
+ * @param {string} input.event
127
+ * @param {string} input.channel
128
+ * @param {string} input.priority
129
+ * @param {boolean} [input.enabled]
130
+ * @param {boolean} [input.quietHours]
131
+ */
132
+ export async function createNotificationRule(input = {}) {
133
+ const event = String(input.event || '').trim();
134
+ const channel = String(input.channel || 'desktop').trim();
135
+ const priority = String(input.priority || 'high').trim();
136
+ if (!NOTIFICATION_EVENTS.has(event) || event === 'test') {
137
+ throw Object.assign(new Error(`Unsupported event: ${event}`), { status: 400 });
138
+ }
139
+ if (!NOTIFICATION_CHANNELS.has(channel)) {
140
+ throw Object.assign(new Error(`Unsupported channel: ${channel}`), { status: 400 });
141
+ }
142
+ if (!NOTIFICATION_PRIORITIES.has(priority)) {
143
+ throw Object.assign(new Error(`Unsupported priority: ${priority}`), { status: 400 });
144
+ }
145
+
146
+ const id = crypto.randomUUID();
147
+ const now = new Date().toISOString();
148
+ const title = `Notify: ${event} → ${channel}`;
149
+ const description = `Alert on ${event} via ${channel} (${priority})`;
150
+ const frontmatter = {
151
+ type: 'notification_rule',
152
+ title,
153
+ description,
154
+ timestamp: now,
155
+ id,
156
+ event,
157
+ channel,
158
+ priority,
159
+ enabled: input.enabled !== false,
160
+ quietHours: !!input.quietHours,
161
+ created: now,
162
+ updated: now,
163
+ };
164
+
165
+ await writeVfsDocument(rulePath(id), frontmatter, description, {
166
+ actorRole: 'admin',
167
+ intent: 'Create notification rule',
168
+ });
169
+
170
+ return publicRule({ frontmatter });
171
+ }
172
+
173
+ export async function deleteNotificationRule(id) {
174
+ const ruleId = String(id || '').trim();
175
+ if (!ruleId) throw Object.assign(new Error('Rule id is required'), { status: 400 });
176
+ const path = rulePath(ruleId);
177
+ const existing = findVfsDocumentByPath(path);
178
+ if (!existing) throw Object.assign(new Error('Notification rule not found'), { status: 404 });
179
+ await deleteVfsDocument(path, {
180
+ actorRole: 'admin',
181
+ intent: 'Delete notification rule',
182
+ });
183
+ return { success: true, id: ruleId };
184
+ }
185
+
186
+ /**
187
+ * @param {object} entry
188
+ * @param {string} entry.title
189
+ * @param {string} entry.message
190
+ * @param {string} entry.channel
191
+ * @param {'delivered'|'failed'} entry.status
192
+ */
193
+ export async function recordNotificationDelivery(entry) {
194
+ const id = crypto.randomUUID();
195
+ const timestamp = new Date().toISOString();
196
+ const payload = {
197
+ kind: 'notification_delivery',
198
+ id,
199
+ title: String(entry.title || 'Notification').slice(0, 200),
200
+ message: String(entry.message || '').slice(0, 1000),
201
+ channel: String(entry.channel || 'desktop'),
202
+ status: entry.status === 'failed' ? 'failed' : 'delivered',
203
+ timestamp,
204
+ };
205
+ await appendVfsEvent(`notifications/delivery/${id}`, payload, eventOpts('Record notification delivery'));
206
+ return payload;
207
+ }
208
+
209
+ export async function listNotificationHistory({ limit = MAX_HISTORY } = {}) {
210
+ const events = await listVfsEvents({ workspaceId: HISTORY_WORKSPACE });
211
+ const entries = events
212
+ .filter((event) => event.payload?.kind === 'notification_delivery')
213
+ .map((event) => {
214
+ const p = event.payload || {};
215
+ return {
216
+ id: p.id || event.event_id || crypto.randomUUID(),
217
+ title: p.title || 'Notification',
218
+ message: p.message || '',
219
+ channel: p.channel || 'desktop',
220
+ status: p.status === 'failed' ? 'failed' : 'delivered',
221
+ timestamp: p.timestamp || event.timestamp || new Date().toISOString(),
222
+ };
223
+ })
224
+ .sort((a, b) => String(b.timestamp).localeCompare(String(a.timestamp)));
225
+
226
+ const n = Number(limit);
227
+ return Number.isFinite(n) && n > 0 ? entries.slice(0, n) : entries.slice(0, MAX_HISTORY);
228
+ }
229
+
230
+ /**
231
+ * Deliver a test notification on the desktop channel and record history.
232
+ */
233
+ export async function sendTestNotification() {
234
+ const title = 'Total Recall';
235
+ const message = 'Test notification from the dashboard';
236
+ const result = await sendSystemNotification(title, message, {
237
+ subtitle: 'Notifications',
238
+ group: 'total-recall-test',
239
+ });
240
+ const entry = await recordNotificationDelivery({
241
+ title,
242
+ message,
243
+ channel: 'desktop',
244
+ status: result.delivered ? 'delivered' : 'failed',
245
+ });
246
+ return { ...entry, delivery: result };
247
+ }