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
|
@@ -1,19 +1,128 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { buildTaskEnvelope } from './task-envelope.mjs';
|
|
4
|
+
import { persistTaskToDisk } from './scheduler.mjs';
|
|
5
|
+
import { brainDir } from './config.mjs';
|
|
1
6
|
import { logger } from './logger.mjs';
|
|
2
7
|
|
|
8
|
+
const QUEUE_DIR = path.join(brainDir, 'scheduler', 'queue');
|
|
9
|
+
|
|
10
|
+
/** Fixed slug so re-pushes overwrite one card instead of flooding the queue. */
|
|
11
|
+
const DEPLOY_SLUG = 'deploy-auto';
|
|
12
|
+
const SKILL_SYNC_SLUG = 'skillsync-auto';
|
|
13
|
+
const NPM_UPDATE_SLUG = 'npm-update-auto';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* True when a queue file already exists for this slug (pending/in-progress).
|
|
17
|
+
* Avoids N× "Auto-deploy" cards for every GitHub push delivery / multi-ref push.
|
|
18
|
+
*/
|
|
19
|
+
function hasPendingTask(slug) {
|
|
20
|
+
try {
|
|
21
|
+
return fs.existsSync(path.join(QUEUE_DIR, `${slug}.md`));
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
3
27
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* those events through an explicitly reviewed workflow with its own gates.
|
|
28
|
+
* GitHub may fire multiple push webhooks (multi-repo, multi-ref, redeliveries).
|
|
29
|
+
* Only production/main refs matter for deploy notices; always coalesce to one task.
|
|
7
30
|
*/
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
31
|
+
function pushRefIsDeployable(payload = {}) {
|
|
32
|
+
const ref = String(payload.ref || '');
|
|
33
|
+
if (!ref) return true; // tests / bare payload still queue once
|
|
34
|
+
return (
|
|
35
|
+
ref === 'refs/heads/production' ||
|
|
36
|
+
ref === 'refs/heads/main' ||
|
|
37
|
+
ref.endsWith('/production') ||
|
|
38
|
+
ref.endsWith('/main')
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function handleWebhook(provider, eventType, payload = {}) {
|
|
43
|
+
logger.info({ subsystem: 'webhooks', message: `Handling ${provider}.${eventType} webhook` });
|
|
44
|
+
|
|
45
|
+
if (provider === 'github' && eventType === 'push') {
|
|
46
|
+
if (!pushRefIsDeployable(payload)) {
|
|
47
|
+
logger.info({
|
|
48
|
+
subsystem: 'webhooks',
|
|
49
|
+
message: `Skipping deploy queue for non-deploy ref ${payload.ref || '(none)'}`,
|
|
50
|
+
});
|
|
51
|
+
return { queued: false, reason: 'ref_not_deployable' };
|
|
52
|
+
}
|
|
53
|
+
if (hasPendingTask(DEPLOY_SLUG)) {
|
|
54
|
+
logger.info({
|
|
55
|
+
subsystem: 'webhooks',
|
|
56
|
+
message: 'Deploy task already pending — not queuing another (coalesce)',
|
|
57
|
+
});
|
|
58
|
+
return { queued: false, reason: 'already_pending', slug: DEPLOY_SLUG };
|
|
59
|
+
}
|
|
60
|
+
// Notify-only card: real production deploy is the droplet watcher on push to production.
|
|
61
|
+
// Do NOT run bin/deploy.sh here — that caused multi-deploy spam and wrong deploy path.
|
|
62
|
+
const env = buildTaskEnvelope({
|
|
63
|
+
intent: 'Auto-deploy triggered by push',
|
|
64
|
+
slug: DEPLOY_SLUG,
|
|
65
|
+
kind: 'system',
|
|
66
|
+
executor: 'command',
|
|
67
|
+
category: 'deployment',
|
|
68
|
+
priority: 80,
|
|
69
|
+
system: true,
|
|
70
|
+
origin: { agent: 'daemon', created_by: 'webhook-github' },
|
|
71
|
+
capabilities: ['vault:read'],
|
|
72
|
+
payload: {
|
|
73
|
+
command: 'echo "Deploy notice only — production droplet auto-deploy handles the real ship"',
|
|
74
|
+
ref: payload.ref || null,
|
|
75
|
+
after: payload.after || null,
|
|
76
|
+
repository: payload.repository?.full_name || null,
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
persistTaskToDisk(env, QUEUE_DIR);
|
|
80
|
+
logger.info({ subsystem: 'webhooks', message: 'Queued single coalesced deploy notice task' });
|
|
81
|
+
return { queued: true, slug: DEPLOY_SLUG };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (provider === 'github' && eventType === 'release') {
|
|
85
|
+
if (hasPendingTask(SKILL_SYNC_SLUG)) {
|
|
86
|
+
return { queued: false, reason: 'already_pending', slug: SKILL_SYNC_SLUG };
|
|
87
|
+
}
|
|
88
|
+
const env = buildTaskEnvelope({
|
|
89
|
+
intent: 'Skill sync triggered by release',
|
|
90
|
+
slug: SKILL_SYNC_SLUG,
|
|
91
|
+
kind: 'system',
|
|
92
|
+
executor: 'command',
|
|
93
|
+
category: 'sync',
|
|
94
|
+
priority: 70,
|
|
95
|
+
system: true,
|
|
96
|
+
origin: { agent: 'daemon', created_by: 'webhook-github' },
|
|
97
|
+
capabilities: ['vault:read'],
|
|
98
|
+
payload: { command: 'npx total-recall skill sync' },
|
|
99
|
+
});
|
|
100
|
+
persistTaskToDisk(env, QUEUE_DIR);
|
|
101
|
+
logger.info({ subsystem: 'webhooks', message: 'Queued skill-sync task' });
|
|
102
|
+
return { queued: true, slug: SKILL_SYNC_SLUG };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (provider === 'npm' && eventType === 'package-publish') {
|
|
106
|
+
if (hasPendingTask(NPM_UPDATE_SLUG)) {
|
|
107
|
+
return { queued: false, reason: 'already_pending', slug: NPM_UPDATE_SLUG };
|
|
108
|
+
}
|
|
109
|
+
const env = buildTaskEnvelope({
|
|
110
|
+
intent: 'Package update triggered by npm publish',
|
|
111
|
+
slug: NPM_UPDATE_SLUG,
|
|
112
|
+
kind: 'system',
|
|
113
|
+
executor: 'command',
|
|
114
|
+
category: 'package',
|
|
115
|
+
priority: 60,
|
|
116
|
+
system: true,
|
|
117
|
+
origin: { agent: 'daemon', created_by: 'webhook-npm' },
|
|
118
|
+
capabilities: ['vault:read'],
|
|
119
|
+
payload: { command: 'npm update' },
|
|
120
|
+
});
|
|
121
|
+
persistTaskToDisk(env, QUEUE_DIR);
|
|
122
|
+
logger.info({ subsystem: 'webhooks', message: 'Queued npm update task' });
|
|
123
|
+
return { queued: true, slug: NPM_UPDATE_SLUG };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
logger.info({ subsystem: 'webhooks', message: `No specific handler for ${provider}.${eventType}` });
|
|
127
|
+
return { queued: false, reason: 'no_handler' };
|
|
19
128
|
}
|
package/src/server/auth.mjs
CHANGED
|
@@ -246,6 +246,12 @@ export function requireAuthOrLocal(req, res, next) {
|
|
|
246
246
|
const config = loadSecurityConfig();
|
|
247
247
|
if (config.network?.public_health === true) return next();
|
|
248
248
|
if (isLocalRequest(req)) return next();
|
|
249
|
+
// Mesh peers probe /health for latency + LAN discovery. Traffic is already on
|
|
250
|
+
// WireGuard; treat mesh socket addresses like local for this gate only.
|
|
251
|
+
// Never trust X-Forwarded-For for this exemption (same as requireHttps mesh rule).
|
|
252
|
+
if (isMeshIp(req.socket?.remoteAddress) && forwardedClientIps(req).length === 0) {
|
|
253
|
+
return next();
|
|
254
|
+
}
|
|
249
255
|
return requireAuth(req, res, next);
|
|
250
256
|
}
|
|
251
257
|
|
package/src/server/rest.mjs
CHANGED
|
@@ -120,6 +120,7 @@ import scriptsRouter from './routes/scripts.mjs';
|
|
|
120
120
|
import ttsRouter from './routes/tts.mjs';
|
|
121
121
|
import updateRouter from './routes/update.mjs';
|
|
122
122
|
import { rulesRouter } from './routes/rules.mjs';
|
|
123
|
+
import notificationsRouter from './routes/notifications.mjs';
|
|
123
124
|
// ollamaUrl removed — CLI agents replace Ollama
|
|
124
125
|
import {
|
|
125
126
|
AGENT_DIR,
|
|
@@ -233,6 +234,7 @@ router.use(ttsRouter);
|
|
|
233
234
|
router.use(updateRouter);
|
|
234
235
|
router.use(ssssRouter);
|
|
235
236
|
router.use(rulesRouter);
|
|
237
|
+
router.use(notificationsRouter);
|
|
236
238
|
|
|
237
239
|
export { router as restRouter };
|
|
238
240
|
export default router;
|
|
@@ -327,6 +327,26 @@
|
|
|
327
327
|
"method": "GET",
|
|
328
328
|
"path": "/api/network/stats"
|
|
329
329
|
},
|
|
330
|
+
{
|
|
331
|
+
"method": "GET",
|
|
332
|
+
"path": "/api/notifications/history"
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
"method": "GET",
|
|
336
|
+
"path": "/api/notifications/rules"
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
"method": "POST",
|
|
340
|
+
"path": "/api/notifications/rules"
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
"method": "DELETE",
|
|
344
|
+
"path": "/api/notifications/rules/:id"
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
"method": "POST",
|
|
348
|
+
"path": "/api/notifications/test"
|
|
349
|
+
},
|
|
330
350
|
{
|
|
331
351
|
"method": "GET",
|
|
332
352
|
"path": "/api/openai-models"
|
|
@@ -335,6 +355,10 @@
|
|
|
335
355
|
"method": "GET",
|
|
336
356
|
"path": "/api/openrouter-models"
|
|
337
357
|
},
|
|
358
|
+
{
|
|
359
|
+
"method": "GET",
|
|
360
|
+
"path": "/api/pairing"
|
|
361
|
+
},
|
|
338
362
|
{
|
|
339
363
|
"method": "GET",
|
|
340
364
|
"path": "/api/research"
|
|
@@ -68,7 +68,7 @@ router.get('/api/config', requireAuth, requireScope('config:read'), async (req,
|
|
|
68
68
|
* GET /api/config-json
|
|
69
69
|
* Returns full config bundle: security, budget, brain, and allowed secrets keys.
|
|
70
70
|
*/
|
|
71
|
-
router.get('/api/config-json', requireAuth, requireScope('config:read'), (req, res) => {
|
|
71
|
+
router.get('/api/config-json', requireAuth, requireScope('config:read'), async (req, res) => {
|
|
72
72
|
try {
|
|
73
73
|
const securityPath = path.join(CONFIG_DIR, 'security.yml');
|
|
74
74
|
const budgetPath = path.join(CONFIG_DIR, 'budget.yml');
|
|
@@ -89,8 +89,40 @@ router.get('/api/config-json', requireAuth, requireScope('config:read'), (req, r
|
|
|
89
89
|
if (fs.existsSync(brainPath)) {
|
|
90
90
|
try { brain = JSON.parse(fs.readFileSync(brainPath, 'utf8')) || {}; } catch {}
|
|
91
91
|
}
|
|
92
|
-
|
|
93
|
-
|
|
92
|
+
// Prefer brain secrets store (AES). Never return raw secret values to the dashboard.
|
|
93
|
+
const allowedKeys = [
|
|
94
|
+
'google_api_key',
|
|
95
|
+
'anthropic_api_key',
|
|
96
|
+
'openai_api_key',
|
|
97
|
+
'openrouter_api_key',
|
|
98
|
+
'tavily_api_key',
|
|
99
|
+
'brave_api_key',
|
|
100
|
+
'exa_api_key',
|
|
101
|
+
'serper_api_key',
|
|
102
|
+
'github_token',
|
|
103
|
+
];
|
|
104
|
+
const safeSecrets = {};
|
|
105
|
+
try {
|
|
106
|
+
const { listSecretsMeta } = await import('../../core/secrets-store.mjs');
|
|
107
|
+
const meta = await listSecretsMeta(BRAIN_DIR);
|
|
108
|
+
for (const row of meta) {
|
|
109
|
+
if (!allowedKeys.includes(row.key)) continue;
|
|
110
|
+
// Mask only — Settings/Secrets UIs use presence indicators, not values.
|
|
111
|
+
safeSecrets[row.key] = row.set ? '••••••••' : '';
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
if (fs.existsSync(secretsPath)) {
|
|
115
|
+
try {
|
|
116
|
+
secrets = JSON.parse(fs.readFileSync(secretsPath, 'utf8')) || {};
|
|
117
|
+
} catch {
|
|
118
|
+
/* ignore AES/binary */
|
|
119
|
+
}
|
|
120
|
+
for (const key of allowedKeys) {
|
|
121
|
+
if (secrets[key] !== undefined && secrets[key] !== null && secrets[key] !== '') {
|
|
122
|
+
safeSecrets[key] = '••••••••';
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
94
126
|
}
|
|
95
127
|
|
|
96
128
|
const safeBrain = { ...brain };
|
|
@@ -99,14 +131,6 @@ router.get('/api/config-json', requireAuth, requireScope('config:read'), (req, r
|
|
|
99
131
|
delete safeBrain.token;
|
|
100
132
|
}
|
|
101
133
|
|
|
102
|
-
const allowedKeys = ['google_api_key', 'anthropic_api_key', 'openai_api_key', 'openrouter_api_key', 'tavily_api_key', 'brave_api_key', 'exa_api_key', 'serper_api_key', 'github_token'];
|
|
103
|
-
const safeSecrets = {};
|
|
104
|
-
for (const key of allowedKeys) {
|
|
105
|
-
if (secrets[key] !== undefined) {
|
|
106
|
-
safeSecrets[key] = secrets[key];
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
134
|
res.json({ security, budget, brain: safeBrain, secrets: safeSecrets });
|
|
111
135
|
} catch (err) { serverError(res, err); }
|
|
112
136
|
});
|
|
@@ -146,22 +170,39 @@ router.post('/api/config-json', requireAuth, requireScope('config:write'), async
|
|
|
146
170
|
fs.writeFileSync(brainPath, JSON.stringify(nextBrain, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
147
171
|
}
|
|
148
172
|
if (secrets) {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
173
|
+
// Only accept real secret values — ignore masked placeholders from GET /api/config-json.
|
|
174
|
+
const allowedKeys = [
|
|
175
|
+
'google_api_key',
|
|
176
|
+
'anthropic_api_key',
|
|
177
|
+
'openai_api_key',
|
|
178
|
+
'openrouter_api_key',
|
|
179
|
+
'tavily_api_key',
|
|
180
|
+
'brave_api_key',
|
|
181
|
+
'exa_api_key',
|
|
182
|
+
'serper_api_key',
|
|
183
|
+
'github_token',
|
|
184
|
+
];
|
|
185
|
+
const { setSecret, deleteSecret } = await import('../../core/secrets-store.mjs');
|
|
154
186
|
for (const key of allowedKeys) {
|
|
155
|
-
if (secrets[key]
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
187
|
+
if (secrets[key] === undefined) continue;
|
|
188
|
+
const value = secrets[key];
|
|
189
|
+
if (value === '' || value == null) {
|
|
190
|
+
try {
|
|
191
|
+
await deleteSecret(BRAIN_DIR, key, { action: 'delete', actor: 'settings-config' });
|
|
192
|
+
} catch {
|
|
193
|
+
/* key may not exist */
|
|
160
194
|
}
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (typeof value === 'string' && (value.includes('•') || value === '[redacted]')) {
|
|
198
|
+
// Masked echo from the UI — do not overwrite the store.
|
|
199
|
+
continue;
|
|
161
200
|
}
|
|
201
|
+
await setSecret(BRAIN_DIR, key, String(value), {
|
|
202
|
+
action: 'set',
|
|
203
|
+
actor: 'settings-config',
|
|
204
|
+
});
|
|
162
205
|
}
|
|
163
|
-
const { saveSecrets } = await import('../../core/secrets-store.mjs');
|
|
164
|
-
await saveSecrets(AGENT_DIR, existingSecrets);
|
|
165
206
|
}
|
|
166
207
|
|
|
167
208
|
res.json({ success: true });
|
|
@@ -281,7 +281,8 @@ router.get('/api/mesh/latency', requireAuth, requireScope('config:read'), async
|
|
|
281
281
|
}
|
|
282
282
|
const start = Date.now();
|
|
283
283
|
try {
|
|
284
|
-
|
|
284
|
+
// WAN mesh RTT can exceed 3s under load; gate + Tailscale need headroom.
|
|
285
|
+
const response = await throttledFetch(meshPeerUrl(peer.ip, '/health'), {}, 10_000);
|
|
285
286
|
const ms = Date.now() - start;
|
|
286
287
|
const ok = response.ok || response.status === 200;
|
|
287
288
|
latency_ms[peer.hostname] = ok ? ms : null;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /api/notifications/* — alert rules + delivery history for the Notifications page.
|
|
3
|
+
*
|
|
4
|
+
* Rules: VFS docs at system/notification-rules/*.md
|
|
5
|
+
* History: SSSS event workspace `notifications`
|
|
6
|
+
*/
|
|
7
|
+
import { Router } from 'express';
|
|
8
|
+
import { requireAuth, requireScope } from '../auth.mjs';
|
|
9
|
+
import {
|
|
10
|
+
createNotificationRule,
|
|
11
|
+
deleteNotificationRule,
|
|
12
|
+
listNotificationHistory,
|
|
13
|
+
listNotificationRules,
|
|
14
|
+
sendTestNotification,
|
|
15
|
+
} from '../../core/notifications.mjs';
|
|
16
|
+
import { logger } from '../../core/logger.mjs';
|
|
17
|
+
|
|
18
|
+
const router = Router();
|
|
19
|
+
|
|
20
|
+
router.get(
|
|
21
|
+
'/api/notifications/rules',
|
|
22
|
+
requireAuth,
|
|
23
|
+
requireScope('config:read'),
|
|
24
|
+
async (_req, res) => {
|
|
25
|
+
try {
|
|
26
|
+
const rules = listNotificationRules();
|
|
27
|
+
res.json(rules);
|
|
28
|
+
} catch (err) {
|
|
29
|
+
logger.error('notifications', 'list rules failed', { error: err.message });
|
|
30
|
+
res.status(500).json({ error: err.message || 'Failed to list notification rules' });
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
router.post(
|
|
36
|
+
'/api/notifications/rules',
|
|
37
|
+
requireAuth,
|
|
38
|
+
requireScope('config:write'),
|
|
39
|
+
async (req, res) => {
|
|
40
|
+
try {
|
|
41
|
+
const rule = await createNotificationRule(req.body || {});
|
|
42
|
+
res.status(201).json(rule);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
const status = err.status || 500;
|
|
45
|
+
if (status >= 500) {
|
|
46
|
+
logger.error('notifications', 'create rule failed', { error: err.message });
|
|
47
|
+
}
|
|
48
|
+
res.status(status).json({ error: err.message || 'Failed to create notification rule' });
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
router.delete(
|
|
54
|
+
'/api/notifications/rules/:id',
|
|
55
|
+
requireAuth,
|
|
56
|
+
requireScope('config:write'),
|
|
57
|
+
async (req, res) => {
|
|
58
|
+
try {
|
|
59
|
+
const result = await deleteNotificationRule(req.params.id);
|
|
60
|
+
res.json(result);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
const status = err.status || 500;
|
|
63
|
+
if (status >= 500) {
|
|
64
|
+
logger.error('notifications', 'delete rule failed', { error: err.message });
|
|
65
|
+
}
|
|
66
|
+
res.status(status).json({ error: err.message || 'Failed to delete notification rule' });
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
router.get(
|
|
72
|
+
'/api/notifications/history',
|
|
73
|
+
requireAuth,
|
|
74
|
+
requireScope('config:read'),
|
|
75
|
+
async (req, res) => {
|
|
76
|
+
try {
|
|
77
|
+
const limit = req.query.limit ? Number(req.query.limit) : 100;
|
|
78
|
+
const history = await listNotificationHistory({ limit });
|
|
79
|
+
res.json(history);
|
|
80
|
+
} catch (err) {
|
|
81
|
+
logger.error('notifications', 'list history failed', { error: err.message });
|
|
82
|
+
res.status(500).json({ error: err.message || 'Failed to list notification history' });
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
router.post(
|
|
88
|
+
'/api/notifications/test',
|
|
89
|
+
requireAuth,
|
|
90
|
+
requireScope('config:write'),
|
|
91
|
+
async (_req, res) => {
|
|
92
|
+
try {
|
|
93
|
+
const entry = await sendTestNotification();
|
|
94
|
+
res.json({ success: true, entry });
|
|
95
|
+
} catch (err) {
|
|
96
|
+
logger.error('notifications', 'test notification failed', { error: err.message });
|
|
97
|
+
res.status(500).json({ error: err.message || 'Failed to send test notification' });
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
export default router;
|
|
103
|
+
export { router as notificationsRouter };
|
|
@@ -1,32 +1,87 @@
|
|
|
1
1
|
import express from 'express';
|
|
2
2
|
import { getBothBrains } from '../../cli/agent-dir.mjs';
|
|
3
3
|
import { getNodes } from '../../core/vault-cache.mjs';
|
|
4
|
+
import { logger } from '../../core/logger.mjs';
|
|
4
5
|
|
|
5
6
|
import path from 'node:path';
|
|
6
7
|
|
|
7
8
|
export const rulesRouter = express.Router();
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
10
|
+
/** Rule categories that surface on the Agent Rules page / instruction compiler. */
|
|
11
|
+
const RULE_CATEGORIES = new Set(['invariants', 'preferences', 'anti-patterns', 'corrections']);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Resolve the vault root for a brain layer.
|
|
15
|
+
* getBothBrains() returns `brainDir` (not `brainRoot`).
|
|
16
|
+
* Project layer also carries `agentDir` — fall back to skills/total-recall under it.
|
|
17
|
+
*/
|
|
18
|
+
function resolveBrainDir(layer) {
|
|
19
|
+
if (!layer) return null;
|
|
20
|
+
if (typeof layer.brainDir === 'string' && layer.brainDir) return layer.brainDir;
|
|
21
|
+
if (typeof layer.brainRoot === 'string' && layer.brainRoot) return layer.brainRoot; // legacy alias
|
|
22
|
+
if (typeof layer.agentDir === 'string' && layer.agentDir) {
|
|
23
|
+
return path.join(layer.agentDir, 'skills', 'total-recall');
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function serializeRule(node, scope) {
|
|
29
|
+
const importanceRaw = Number(node.importance);
|
|
30
|
+
const importance = Number.isFinite(importanceRaw)
|
|
31
|
+
? Math.min(5, Math.max(0, Math.round(importanceRaw)))
|
|
32
|
+
: 0;
|
|
33
|
+
const body =
|
|
34
|
+
typeof node.body === 'string'
|
|
35
|
+
? node.body
|
|
36
|
+
: typeof node.content === 'string'
|
|
37
|
+
? node.content
|
|
38
|
+
: '';
|
|
39
|
+
return {
|
|
40
|
+
slug: node.slug,
|
|
41
|
+
category: node.category === 'corrections' ? 'anti-patterns' : node.category,
|
|
42
|
+
title: node.title || node.slug || 'Untitled rule',
|
|
43
|
+
status: node.status || 'active',
|
|
44
|
+
importance,
|
|
45
|
+
body,
|
|
46
|
+
scope,
|
|
26
47
|
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
rulesRouter.get('/api/rules', (req, res) => {
|
|
51
|
+
try {
|
|
52
|
+
const { global: globalBrain, project: projectBrain } = getBothBrains();
|
|
53
|
+
const rules = [];
|
|
54
|
+
const seenVaults = new Set();
|
|
55
|
+
|
|
56
|
+
const addRules = (brainDir, scope) => {
|
|
57
|
+
if (!brainDir) return;
|
|
58
|
+
const vaultDir = path.join(brainDir, 'memory-vault');
|
|
59
|
+
const vaultKey = path.resolve(vaultDir);
|
|
60
|
+
// Avoid double-listing when project brainDir incorrectly points at global.
|
|
61
|
+
if (seenVaults.has(vaultKey)) return;
|
|
62
|
+
seenVaults.add(vaultKey);
|
|
63
|
+
|
|
64
|
+
let nodes = [];
|
|
65
|
+
try {
|
|
66
|
+
nodes = getNodes(vaultDir) || [];
|
|
67
|
+
} catch (err) {
|
|
68
|
+
logger.warn('rules', 'Failed to load vault nodes', { vaultDir, error: err.message });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const n of nodes) {
|
|
73
|
+
if (!RULE_CATEGORIES.has(n.category)) continue;
|
|
74
|
+
// Prefer active rules first in natural order; archived still included for restore UI
|
|
75
|
+
rules.push(serializeRule(n, scope));
|
|
76
|
+
}
|
|
77
|
+
};
|
|
27
78
|
|
|
28
|
-
|
|
29
|
-
|
|
79
|
+
addRules(resolveBrainDir(globalBrain), 'global');
|
|
80
|
+
addRules(resolveBrainDir(projectBrain), 'project');
|
|
30
81
|
|
|
31
|
-
|
|
82
|
+
res.json({ rules, count: rules.length });
|
|
83
|
+
} catch (err) {
|
|
84
|
+
logger.error('rules', 'GET /api/rules failed', { error: err.message, stack: err.stack });
|
|
85
|
+
res.status(500).json({ error: 'Failed to load agent rules', message: err.message });
|
|
86
|
+
}
|
|
32
87
|
});
|