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.
- package/frontend/dist/assets/index-CGqvdwHT.js +5742 -0
- package/frontend/dist/index.html +1 -1
- package/frontend/src/api/config.ts +16 -2
- package/frontend/src/api/keys.ts +111 -0
- package/frontend/src/api/notifications.ts +54 -6
- package/frontend/src/api/update.ts +12 -1
- package/frontend/src/components/BrainSelector.spec.tsx +71 -47
- package/frontend/src/components/BrainSelector.tsx +112 -35
- package/frontend/src/components/MobilePairing.spec.tsx +44 -14
- package/frontend/src/components/MobilePairing.tsx +229 -22
- package/frontend/src/pages/NotificationsPage.tsx +6 -3
- package/frontend/src/pages/RulesPage.spec.tsx +46 -15
- package/frontend/src/pages/RulesPage.tsx +41 -9
- package/frontend/src/pages/SecretsPage.tsx +316 -3
- package/frontend/src/pages/SettingsPage.spec.tsx +30 -6
- package/frontend/src/pages/SettingsPage.tsx +206 -85
- package/package.json +1 -1
- package/src/cli/agent-dir.mjs +2 -1
- package/src/cli/secret.mjs +180 -3
- package/src/core/daemon-loop.mjs +31 -5
- package/src/core/notifications.mjs +204 -34
- package/src/core/package-auto-update.mjs +99 -3
- package/src/core/pairing.mjs +168 -0
- package/src/core/provider-account-sync.mjs +1145 -0
- package/src/core/provider-catalog.mjs +11 -4
- package/src/core/research.mjs +98 -24
- package/src/core/scheduler.mjs +31 -9
- package/src/core/schema.mjs +23 -0
- package/src/core/secret-integration-research.mjs +325 -0
- package/src/core/secrets-store.mjs +278 -14
- package/src/core/secrets-sync.mjs +1 -1
- package/src/core/source-adapters.mjs +247 -22
- package/src/core/task-executors.mjs +161 -14
- package/src/core/webhook-handlers.mjs +123 -14
- package/src/server/auth.mjs +6 -0
- package/src/server/rest.mjs +2 -0
- package/src/server/route-manifest.json +24 -0
- package/src/server/routes/config.mjs +64 -23
- package/src/server/routes/mesh.mjs +2 -1
- package/src/server/routes/notifications.mjs +103 -0
- package/src/server/routes/rules.mjs +75 -20
- package/src/server/routes/secrets.mjs +110 -4
- package/src/server/routes/system.mjs +20 -1
- package/src/server/routes/update.mjs +11 -4
- package/frontend/dist/assets/index-D5xRw5uq.js +0 -5750
package/src/cli/secret.mjs
CHANGED
|
@@ -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(
|
|
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;
|
package/src/core/daemon-loop.mjs
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
-
|
|
496
|
-
|
|
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
|
|
12
|
-
* @param {string} message
|
|
13
|
-
* @param {object} [options]
|
|
14
|
-
* @
|
|
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
|
|
70
|
+
const cleanSubtitle = subtitle
|
|
71
|
+
? String(subtitle).replace(/[^ -~]/g, '').replace(/["'`$\\]/g, '').slice(0, 100)
|
|
72
|
+
: '';
|
|
38
73
|
|
|
39
74
|
try {
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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:
|
|
61
|
-
|
|
62
|
-
|
|
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:
|
|
96
|
+
logger.debug('notifications: sent via osascript', { title });
|
|
97
|
+
return { delivered: true, channel: 'desktop' };
|
|
73
98
|
} catch (fallbackErr) {
|
|
74
|
-
logger.debug('notifications:
|
|
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
|
+
}
|