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.
- package/frontend/dist/assets/{index-D5xRw5uq.js → index-B6AfuKv9.js} +126 -134
- 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 +41 -5
- 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/HealthPage.tsx +12 -16
- 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 +228 -86
- package/package.json +1 -1
- package/src/cli/agent-dir.mjs +17 -2
- package/src/cli/chat.mjs +1 -1
- package/src/cli/daemon.mjs +2 -1
- package/src/cli/dream.mjs +14 -10
- package/src/cli/forget.mjs +14 -2
- package/src/cli/friction.mjs +1 -1
- package/src/cli/import-rules.mjs +8 -3
- package/src/cli/ingest.mjs +3 -2
- package/src/cli/lint.mjs +9 -6
- package/src/cli/rebuild.mjs +9 -4
- package/src/cli/remember.mjs +21 -15
- package/src/cli/secret.mjs +184 -4
- package/src/cli/share.mjs +5 -3
- package/src/cli/skill-projection.mjs +21 -2
- package/src/cli/snapshot.mjs +10 -8
- package/src/cli/update.mjs +2 -1
- package/src/core/clarity-rewriter.mjs +31 -12
- package/src/core/config.mjs +10 -4
- package/src/core/daemon-loop.mjs +33 -6
- package/src/core/embeddings.mjs +51 -3
- package/src/core/memory-title.mjs +70 -0
- 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/runtime.mjs +11 -1
- 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 +284 -14
- package/src/core/secrets-sync.mjs +1 -1
- package/src/core/skills-registry.mjs +12 -0
- package/src/core/source-adapters.mjs +247 -22
- package/src/core/surface.mjs +4 -4
- package/src/core/task-executors.mjs +161 -14
- package/src/core/throttled-fetch.mjs +5 -0
- package/src/core/vault.mjs +7 -1
- package/src/core/webhook-handlers.mjs +123 -14
- package/src/server/auth.mjs +6 -0
- package/src/server/index.mjs +56 -0
- package/src/server/rest.mjs +2 -0
- package/src/server/route-manifest.json +40 -0
- package/src/server/routes/_shared.mjs +33 -1
- package/src/server/routes/config.mjs +64 -23
- package/src/server/routes/context.mjs +14 -9
- package/src/server/routes/dashboard.mjs +39 -18
- package/src/server/routes/docs.mjs +27 -13
- package/src/server/routes/dream.mjs +10 -11
- package/src/server/routes/export.mjs +43 -12
- package/src/server/routes/field.mjs +11 -9
- package/src/server/routes/graph.mjs +31 -13
- package/src/server/routes/import.mjs +35 -16
- package/src/server/routes/instructions.mjs +41 -5
- package/src/server/routes/memory.mjs +61 -21
- 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/share.mjs +2 -1
- package/src/server/routes/ssss.mjs +11 -3
- package/src/server/routes/system.mjs +20 -1
- package/src/server/routes/update.mjs +33 -7
- package/src/server/routes/vault.mjs +53 -28
package/src/core/config.mjs
CHANGED
|
@@ -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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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,
|
package/src/core/daemon-loop.mjs
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
496
|
-
|
|
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);
|
package/src/core/embeddings.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
+
}
|