total-recall-brain 3.18.2 → 3.19.0

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 (83) hide show
  1. package/frontend/dist/assets/{index-D5xRw5uq.js → index-B6AfuKv9.js} +126 -134
  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 +41 -5
  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/HealthPage.tsx +12 -16
  12. package/frontend/src/pages/NotificationsPage.tsx +6 -3
  13. package/frontend/src/pages/RulesPage.spec.tsx +46 -15
  14. package/frontend/src/pages/RulesPage.tsx +41 -9
  15. package/frontend/src/pages/SecretsPage.tsx +316 -3
  16. package/frontend/src/pages/SettingsPage.spec.tsx +30 -6
  17. package/frontend/src/pages/SettingsPage.tsx +228 -86
  18. package/package.json +1 -1
  19. package/src/cli/agent-dir.mjs +17 -2
  20. package/src/cli/chat.mjs +1 -1
  21. package/src/cli/daemon.mjs +2 -1
  22. package/src/cli/dream.mjs +14 -10
  23. package/src/cli/forget.mjs +14 -2
  24. package/src/cli/friction.mjs +1 -1
  25. package/src/cli/import-rules.mjs +8 -3
  26. package/src/cli/ingest.mjs +3 -2
  27. package/src/cli/lint.mjs +9 -6
  28. package/src/cli/rebuild.mjs +9 -4
  29. package/src/cli/remember.mjs +21 -15
  30. package/src/cli/secret.mjs +184 -4
  31. package/src/cli/share.mjs +5 -3
  32. package/src/cli/skill-projection.mjs +21 -2
  33. package/src/cli/snapshot.mjs +10 -8
  34. package/src/cli/update.mjs +2 -1
  35. package/src/core/clarity-rewriter.mjs +31 -12
  36. package/src/core/config.mjs +10 -4
  37. package/src/core/daemon-loop.mjs +33 -6
  38. package/src/core/embeddings.mjs +51 -3
  39. package/src/core/memory-title.mjs +70 -0
  40. package/src/core/notifications.mjs +204 -34
  41. package/src/core/package-auto-update.mjs +99 -3
  42. package/src/core/pairing.mjs +168 -0
  43. package/src/core/provider-account-sync.mjs +1145 -0
  44. package/src/core/provider-catalog.mjs +11 -4
  45. package/src/core/research.mjs +98 -24
  46. package/src/core/runtime.mjs +11 -1
  47. package/src/core/scheduler.mjs +31 -9
  48. package/src/core/schema.mjs +23 -0
  49. package/src/core/secret-integration-research.mjs +325 -0
  50. package/src/core/secrets-store.mjs +284 -14
  51. package/src/core/secrets-sync.mjs +1 -1
  52. package/src/core/skills-registry.mjs +12 -0
  53. package/src/core/source-adapters.mjs +247 -22
  54. package/src/core/surface.mjs +4 -4
  55. package/src/core/task-executors.mjs +161 -14
  56. package/src/core/throttled-fetch.mjs +5 -0
  57. package/src/core/vault.mjs +7 -1
  58. package/src/core/webhook-handlers.mjs +123 -14
  59. package/src/server/auth.mjs +6 -0
  60. package/src/server/index.mjs +56 -0
  61. package/src/server/rest.mjs +2 -0
  62. package/src/server/route-manifest.json +40 -0
  63. package/src/server/routes/_shared.mjs +33 -1
  64. package/src/server/routes/config.mjs +64 -23
  65. package/src/server/routes/context.mjs +14 -9
  66. package/src/server/routes/dashboard.mjs +39 -18
  67. package/src/server/routes/docs.mjs +27 -13
  68. package/src/server/routes/dream.mjs +10 -11
  69. package/src/server/routes/export.mjs +43 -12
  70. package/src/server/routes/field.mjs +11 -9
  71. package/src/server/routes/graph.mjs +31 -13
  72. package/src/server/routes/import.mjs +35 -16
  73. package/src/server/routes/instructions.mjs +41 -5
  74. package/src/server/routes/memory.mjs +61 -21
  75. package/src/server/routes/mesh.mjs +2 -1
  76. package/src/server/routes/notifications.mjs +103 -0
  77. package/src/server/routes/rules.mjs +75 -20
  78. package/src/server/routes/secrets.mjs +110 -4
  79. package/src/server/routes/share.mjs +2 -1
  80. package/src/server/routes/ssss.mjs +11 -3
  81. package/src/server/routes/system.mjs +20 -1
  82. package/src/server/routes/update.mjs +33 -7
  83. package/src/server/routes/vault.mjs +53 -28
@@ -48,6 +48,7 @@ const configSchema = z.object({
48
48
  cliModel: z.string().optional(),
49
49
  cliTimeout: z.preprocess((val) => val === undefined || val === '' ? 300 : parseInt(String(val), 10), z.number().int().default(300)),
50
50
  googleApiKey: z.string().optional(),
51
+ openRouterApiKey: z.string().optional(),
51
52
  embedModel: z.string().default('gemini-embedding-2'),
52
53
  searxngBaseUrl: z.string().optional(),
53
54
  braveApiKey: z.string().optional(),
@@ -151,10 +152,13 @@ try {
151
152
  const isConfigPath = p.includes(path.join('config', 'secrets.enc'));
152
153
  const brainDir = isConfigPath ? path.dirname(path.dirname(p)) : path.dirname(p);
153
154
  const parsed = loadSecretsSync(brainDir);
154
- if (Object.keys(parsed).length > 0) {
155
- secrets = parsed;
156
- break; // Found valid secrets!
157
- }
155
+ // Merge (don't stop at the first non-empty file): an earlier, sparser
156
+ // store (e.g. the global ~/.agent/secrets.enc root) was previously
157
+ // short-circuiting the search and silently hiding real secrets that
158
+ // only existed in a later, richer store (e.g. the total-recall
159
+ // skill's own config/secrets.enc). Earlier candidates still win on
160
+ // key collisions — this only fills in keys the earlier ones lack.
161
+ secrets = { ...parsed, ...secrets };
158
162
  } catch {}
159
163
  }
160
164
  }
@@ -178,6 +182,7 @@ const rawConfig = {
178
182
  cliModel: process.env.TR_CLI_MODEL,
179
183
  cliTimeout: process.env.TR_CLI_TIMEOUT,
180
184
  googleApiKey: process.env.GOOGLE_API_KEY || secrets.google_api_key,
185
+ openRouterApiKey: process.env.OPENROUTER_API_KEY || secrets.openrouter_api_key || secrets.OPENROUTER_API_KEY,
181
186
  embedModel: process.env.TR_EMBED_MODEL,
182
187
  searxngBaseUrl: process.env.SEARXNG_BASE_URL,
183
188
  braveApiKey: process.env.BRAVE_SEARCH_API_KEY || process.env.BRAVE_API_KEY || secrets.brave_api_key,
@@ -225,6 +230,7 @@ export const {
225
230
  cliModel,
226
231
  cliTimeout,
227
232
  googleApiKey,
233
+ openRouterApiKey,
228
234
  embedModel,
229
235
  searxngBaseUrl,
230
236
  braveApiKey,
@@ -31,7 +31,8 @@ const DERIVED_DIR = path.join(BRAIN_DIR, 'memory-derived');
31
31
  const CONFLICTS_DIR = path.join(BRAIN_DIR, 'memory-inbox', 'conflicts');
32
32
  const SESSIONS_DIR = path.join(BRAIN_DIR, 'sessions');
33
33
  const QUEUE_DIR = path.join(BRAIN_DIR, 'scheduler', 'queue');
34
- const INSTRUCTIONS_FILE = path.join(BRAIN_DIR, 'INSTRUCTIONS.md');
34
+ // Surface shims resolve from the agent root (.agent/), not brainDir (…/skills/total-recall/)
35
+ const INSTRUCTIONS_FILE = path.join(AGENT_DIR, 'INSTRUCTIONS.md');
35
36
 
36
37
  // How often to recompile surfaces (every N task ticks)
37
38
  const RECOMPILE_EVERY_N_TASKS = 20;
@@ -469,19 +470,28 @@ async function main() {
469
470
  status: result.success ? 'pending' : 'failed',
470
471
  notes: result.output ? `Phase output: ${result.output}` : (result.error || null),
471
472
  };
472
- if (result.factSlug) {
473
+ if (result.factSlug && result.factSlug !== 'pending') {
473
474
  patch.node_slug = result.factSlug;
474
475
  }
476
+ const nodeSlug = patch.node_slug || task._node_slug;
475
477
  if (result.skippedLLM) {
476
478
  patch.status = 'pending';
477
479
  } else if (result.success) {
480
+ // Never advance past acquisition without a real memory/draft node
478
481
  if (task._research_phase === 'acquisition' && !result.factSlug) {
479
482
  patch.status = 'failed';
480
- patch.notes = 'No insights found during acquisition.';
483
+ patch.notes =
484
+ result.error ||
485
+ 'No insights found during acquisition (no web results / synthesis failed).';
481
486
  } else if (task._research_phase === 'acquisition') {
482
487
  patch.status = 'pending';
483
488
  patch.research_phase = 'deliberation';
484
- patch.completed_at = new Date().toISOString();
489
+ patch.completed_at = null;
490
+ } else if (!nodeSlug || nodeSlug === 'pending') {
491
+ // Later phase without content → force back to acquisition
492
+ patch.status = 'pending';
493
+ patch.research_phase = 'acquisition';
494
+ patch.notes = `Phase ${task._research_phase} had no node_slug — re-running acquisition.`;
485
495
  } else if (task._research_phase === 'deliberation') {
486
496
  patch.status = 'pending';
487
497
  patch.research_phase = 'improvement';
@@ -492,8 +502,25 @@ async function main() {
492
502
  patch.status = 'pending';
493
503
  patch.research_phase = 'expansion';
494
504
  } else if (task._research_phase === 'expansion') {
495
- patch.status = 'done';
496
- patch.completed_at = new Date().toISOString();
505
+ // Only mark done when we still have a real node
506
+ if (nodeSlug && nodeSlug !== 'pending') {
507
+ patch.status = 'done';
508
+ patch.node_slug = nodeSlug;
509
+ patch.completed_at = new Date().toISOString();
510
+ } else {
511
+ patch.status = 'failed';
512
+ patch.notes = 'Expansion finished without a research memory node.';
513
+ }
514
+ }
515
+ } else {
516
+ // Failure on later phase: if no node, reset to acquisition for retry
517
+ if (
518
+ (!task._node_slug || task._node_slug === 'pending') &&
519
+ task._research_phase !== 'acquisition'
520
+ ) {
521
+ patch.status = 'pending';
522
+ patch.research_phase = 'acquisition';
523
+ patch.notes = `Reset to acquisition after ${task._research_phase} failure: ${result.error || 'unknown'}`;
497
524
  }
498
525
  }
499
526
  updateQueueItem(task._research_id, patch);
@@ -20,7 +20,7 @@ import {
20
20
  getVssLoadablePath,
21
21
  } from 'sqlite-vss';
22
22
 
23
- import { googleApiKey, embedModel, brainDir as defaultBrainDir } from './config.mjs';
23
+ import { googleApiKey, openRouterApiKey, embedModel, brainDir as defaultBrainDir } from './config.mjs';
24
24
  import { logger } from './logger.mjs';
25
25
 
26
26
  const DEFAULT_EMBED_MODEL = embedModel;
@@ -34,6 +34,10 @@ function resolveOpenAiApiKey() {
34
34
  return process.env.OPENAI_API_KEY || null;
35
35
  }
36
36
 
37
+ function resolveOpenRouterApiKey() {
38
+ return openRouterApiKey || process.env.OPENROUTER_API_KEY || null;
39
+ }
40
+
37
41
  let dbInstance = null;
38
42
  let currentDbPath = null;
39
43
  /** @type {WeakMap<object, boolean>} */
@@ -227,6 +231,37 @@ async function getGoogleEmbedding(text, model) {
227
231
  return data.embedding.values;
228
232
  }
229
233
 
234
+ async function getOpenRouterEmbedding(text, model = 'openai/text-embedding-3-small') {
235
+ const openRouterKey = resolveOpenRouterApiKey();
236
+ if (!openRouterKey) {
237
+ throw new Error('OPENROUTER_API_KEY not set. Cannot generate embeddings.');
238
+ }
239
+
240
+ const res = await throttledFetch('https://openrouter.ai/api/v1/embeddings', {
241
+ method: 'POST',
242
+ headers: {
243
+ 'Content-Type': 'application/json',
244
+ 'Authorization': `Bearer ${openRouterKey}`,
245
+ },
246
+ // Request 768 dims to match the existing vault vss0(embedding(768)) schema
247
+ // and stay comparable against previously-stored Google embeddings.
248
+ body: JSON.stringify({ model, input: text, dimensions: 768 }),
249
+ signal: AbortSignal.timeout(30000),
250
+ }, 20000);
251
+
252
+ if (!res.ok) {
253
+ const body = await res.text().catch(() => '');
254
+ throw new Error(`OpenRouter embedding API error ${res.status}: ${body || 'no response body'}`);
255
+ }
256
+
257
+ const data = await res.json();
258
+ if (!data.data?.[0]?.embedding) {
259
+ throw new Error('OpenRouter returned no embedding vector');
260
+ }
261
+
262
+ return data.data[0].embedding;
263
+ }
264
+
230
265
  async function getOpenAIEmbedding(text, model = 'text-embedding-3-small') {
231
266
  const openaiKey = resolveOpenAiApiKey();
232
267
  if (!openaiKey) {
@@ -258,11 +293,24 @@ async function getOpenAIEmbedding(text, model = 'text-embedding-3-small') {
258
293
 
259
294
  export async function getEmbedding(text, _unused, model = DEFAULT_EMBED_MODEL) {
260
295
  const input = String(text).slice(0, 8000);
296
+ const openRouterKey = resolveOpenRouterApiKey();
261
297
  const googleKey = resolveGoogleApiKey();
262
298
  const openaiKey = resolveOpenAiApiKey();
263
299
 
264
300
  let embedding;
265
- if (googleKey) {
301
+
302
+ // OpenRouter first: standing preference (Gemini's budget is exhausted, so
303
+ // Google is a near-guaranteed slow failure right now — trying it first just
304
+ // taxes every call with a ~15-20s timeout before falling through).
305
+ if (openRouterKey) {
306
+ try {
307
+ embedding = await getOpenRouterEmbedding(input);
308
+ } catch (err) {
309
+ logger.debug('embeddings: OpenRouter failed, trying Google fallback', { err: err.message });
310
+ }
311
+ }
312
+
313
+ if (!embedding && googleKey) {
266
314
  try {
267
315
  embedding = await getGoogleEmbedding(input, model);
268
316
  } catch (err) {
@@ -280,7 +328,7 @@ export async function getEmbedding(text, _unused, model = DEFAULT_EMBED_MODEL) {
280
328
  }
281
329
 
282
330
  if (!embedding) {
283
- throw new Error('No embedding provider available. Set GOOGLE_API_KEY or OPENAI_API_KEY.');
331
+ throw new Error('No embedding provider available. Set OPENROUTER_API_KEY, GOOGLE_API_KEY, or OPENAI_API_KEY.');
284
332
  }
285
333
 
286
334
  return embedding;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Memory title helpers — provenance belongs in source/tags, not a title prefix.
3
+ */
4
+
5
+ const SELF_CAPTURED_PREFIX_RE = /^Self-captured memory:\s*/i;
6
+
7
+ /**
8
+ * Strip legacy auto-title prefix if present.
9
+ * @param {string} title
10
+ * @returns {string}
11
+ */
12
+ export function stripSelfCapturedTitlePrefix(title) {
13
+ return String(title || '').replace(SELF_CAPTURED_PREFIX_RE, '').trim();
14
+ }
15
+
16
+ /**
17
+ * True when title is the old auto-generated echo of the body.
18
+ * @param {string} title
19
+ * @param {string} [body]
20
+ */
21
+ export function isSelfCapturedEchoTitle(title, body = '') {
22
+ const raw = String(title || '').trim();
23
+ if (SELF_CAPTURED_PREFIX_RE.test(raw)) return true;
24
+ const stripped = stripSelfCapturedTitlePrefix(raw);
25
+ const text = String(body || '').replace(/\s+/g, ' ').trim();
26
+ if (!stripped || !text) return false;
27
+ const head = stripped.replace(/\.\.\.$/, '').trim().toLowerCase();
28
+ if (head.length < 12) return false;
29
+ return text.toLowerCase().startsWith(head.slice(0, Math.min(40, head.length)));
30
+ }
31
+
32
+ /**
33
+ * Build a short human title from body content (no provenance prefix).
34
+ * Uses first sentence when reasonable, else first ~100 chars.
35
+ * @param {string} body
36
+ * @returns {string}
37
+ */
38
+ export function defaultTitleFromBody(body) {
39
+ const oneLine = String(body || '')
40
+ .replace(/\s+/g, ' ')
41
+ .trim();
42
+ if (!oneLine) return 'Untitled memory';
43
+
44
+ // Prefer first sentence
45
+ const m = oneLine.match(/^(.{12,}?[.!?])(\s|$)/);
46
+ let candidate = m ? m[1].trim() : oneLine;
47
+
48
+ // Avoid absurdly long single-sentence titles
49
+ if (candidate.length > 100) {
50
+ candidate = candidate.slice(0, 97).replace(/\s+\S*$/, '').trim() + '...';
51
+ }
52
+ return candidate || oneLine.slice(0, 100);
53
+ }
54
+
55
+ /**
56
+ * Normalize an existing title that may carry the legacy prefix.
57
+ * If after strip the title is empty or still an echo of body, rebuild from body.
58
+ * @param {string} title
59
+ * @param {string} body
60
+ * @returns {string}
61
+ */
62
+ export function normalizeMemoryTitle(title, body) {
63
+ const stripped = stripSelfCapturedTitlePrefix(title);
64
+ if (!stripped) return defaultTitleFromBody(body);
65
+ if (isSelfCapturedEchoTitle(title, body) || isSelfCapturedEchoTitle(stripped, body)) {
66
+ // Prefer full first sentence from body over truncated echo
67
+ return defaultTitleFromBody(body);
68
+ }
69
+ return stripped;
70
+ }
@@ -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
+ }