total-recall-brain 3.18.1 → 3.18.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/frontend/dist/assets/index-CGqvdwHT.js +5742 -0
  2. package/frontend/dist/index.html +1 -1
  3. package/frontend/src/api/config.ts +16 -2
  4. package/frontend/src/api/keys.ts +111 -0
  5. package/frontend/src/api/notifications.ts +54 -6
  6. package/frontend/src/api/update.ts +12 -1
  7. package/frontend/src/components/BrainSelector.spec.tsx +71 -47
  8. package/frontend/src/components/BrainSelector.tsx +112 -35
  9. package/frontend/src/components/MobilePairing.spec.tsx +44 -14
  10. package/frontend/src/components/MobilePairing.tsx +229 -22
  11. package/frontend/src/pages/NotificationsPage.tsx +6 -3
  12. package/frontend/src/pages/RulesPage.spec.tsx +46 -15
  13. package/frontend/src/pages/RulesPage.tsx +41 -9
  14. package/frontend/src/pages/SecretsPage.tsx +316 -3
  15. package/frontend/src/pages/SettingsPage.spec.tsx +30 -6
  16. package/frontend/src/pages/SettingsPage.tsx +206 -85
  17. package/package.json +1 -1
  18. package/src/cli/agent-dir.mjs +2 -1
  19. package/src/cli/secret.mjs +180 -3
  20. package/src/core/daemon-loop.mjs +31 -5
  21. package/src/core/embeddings.mjs +111 -38
  22. package/src/core/notifications.mjs +204 -34
  23. package/src/core/package-auto-update.mjs +99 -3
  24. package/src/core/pairing.mjs +168 -0
  25. package/src/core/provider-account-sync.mjs +1145 -0
  26. package/src/core/provider-catalog.mjs +11 -4
  27. package/src/core/research.mjs +98 -24
  28. package/src/core/sandbox.mjs +37 -22
  29. package/src/core/scheduler.mjs +31 -9
  30. package/src/core/schema.mjs +23 -0
  31. package/src/core/secret-integration-research.mjs +325 -0
  32. package/src/core/secrets-store.mjs +278 -14
  33. package/src/core/secrets-sync.mjs +1 -1
  34. package/src/core/source-adapters.mjs +247 -22
  35. package/src/core/task-executors.mjs +161 -14
  36. package/src/core/webhook-handlers.mjs +123 -14
  37. package/src/server/auth.mjs +6 -0
  38. package/src/server/rest.mjs +2 -0
  39. package/src/server/route-manifest.json +56 -0
  40. package/src/server/routes/config.mjs +64 -23
  41. package/src/server/routes/mesh.mjs +2 -1
  42. package/src/server/routes/notifications.mjs +103 -0
  43. package/src/server/routes/rules.mjs +75 -20
  44. package/src/server/routes/secrets.mjs +110 -4
  45. package/src/server/routes/system.mjs +20 -1
  46. package/src/server/routes/update.mjs +11 -4
  47. package/frontend/dist/assets/index-D5xRw5uq.js +0 -5750
@@ -17,7 +17,7 @@
17
17
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
18
18
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
19
19
  <title>Total Recall — Portable Memory</title>
20
- <script type="module" crossorigin src="/assets/index-D5xRw5uq.js"></script>
20
+ <script type="module" crossorigin src="/assets/index-CGqvdwHT.js"></script>
21
21
  <link rel="stylesheet" crossorigin href="/assets/index-B4RASWbV.css">
22
22
  </head>
23
23
  <body>
@@ -21,7 +21,14 @@ export async function saveConfig(name: string, content: string): Promise<void> {
21
21
 
22
22
  export async function fetchConfigJson(): Promise<ConfigJson> {
23
23
  const res = await apiFetch(`${API_BASE}/api/config-json`)
24
- if (!res.ok) throw new Error(`Config JSON API error: ${res.status}`)
24
+ if (!res.ok) {
25
+ let detail = `Config JSON API error: ${res.status}`
26
+ try {
27
+ const body = await res.json()
28
+ if (body?.error) detail = body.error
29
+ } catch { /* ignore */ }
30
+ throw new Error(detail)
31
+ }
25
32
  return res.json()
26
33
  }
27
34
 
@@ -31,5 +38,12 @@ export async function saveConfigJson(config: ConfigJson): Promise<void> {
31
38
  headers: { 'Content-Type': 'application/json' },
32
39
  body: JSON.stringify(config),
33
40
  })
34
- if (!res.ok) throw new Error(`Config JSON API error: ${res.status}`)
41
+ if (!res.ok) {
42
+ let detail = `Config JSON API error: ${res.status}`
43
+ try {
44
+ const body = await res.json()
45
+ if (body?.error) detail = body.error
46
+ } catch { /* ignore */ }
47
+ throw new Error(detail)
48
+ }
35
49
  }
@@ -146,6 +146,27 @@ export interface SecretCatalogKey {
146
146
  headscale_url?: string | null
147
147
  usage_30d: { events: number; cost_usd: number; input_tokens: number; output_tokens: number }
148
148
  tiers: { id: string; label: string; monthly_usd?: number | null }[]
149
+ /** Live provider account/usage tracking */
150
+ tracking_status?: 'ok' | 'partial' | 'error' | 'exempt' | 'never' | string | null
151
+ tracking_error?: string | null
152
+ tracking_synced_at?: string | null
153
+ tracking_probe?: string | null
154
+ tracking_account?: unknown
155
+ tracking_usage?: unknown
156
+ tracking_subscription?: unknown
157
+ tracking_exempt?: boolean
158
+ account_api?: boolean | null
159
+ usage_api?: boolean | null
160
+ subscription_api?: boolean | null
161
+ key_valid?: boolean | null
162
+ /** Same credential value under multiple secret names / apps */
163
+ shared_value?: boolean
164
+ shared_with?: { key: string; app: string; repo?: string | null; provider?: string | null }[]
165
+ shared_apps?: string[]
166
+ shared_value_error?: string | null
167
+ shared_value_severity?: 'error' | 'ok' | string | null
168
+ shared_fingerprint?: string | null
169
+ shared_value_ok?: boolean
149
170
  }
150
171
 
151
172
  export interface SecretCatalog {
@@ -162,17 +183,107 @@ export interface SecretCatalog {
162
183
  usage_30d: { events: number; cost_usd: number }
163
184
  rotation_overdue: number
164
185
  budget: Record<string, unknown>
186
+ tracking_healthy?: boolean
187
+ tracking_ok?: number
188
+ tracking_exempt?: number
189
+ tracking_errors?: number
190
+ tracking_error_keys?: {
191
+ key: string
192
+ provider?: string | null
193
+ tracking_status?: string
194
+ error?: string
195
+ }[]
196
+ shared_value_healthy?: boolean
197
+ shared_value_keys?: number
198
+ shared_value_multi_app_keys?: number
199
+ shared_value_groups?: number
200
+ shared_value_errors?: {
201
+ fingerprint?: string
202
+ apps?: string[]
203
+ keys?: string[]
204
+ error?: string | null
205
+ }[]
165
206
  }
166
207
  by_provider: { provider: string; keys: number; cost_30d: number; monthly_cost: number }[]
167
208
  store: string
168
209
  }
169
210
 
211
+ export interface SharedValueHealth {
212
+ healthy: boolean
213
+ message: string
214
+ error_groups: number
215
+ multi_app_groups: number
216
+ shared_keys: number
217
+ groups: {
218
+ fingerprint: string
219
+ apps: string[]
220
+ keys: string[]
221
+ severity: string
222
+ error?: string | null
223
+ members?: { key: string; app: string; provider?: string | null; masked?: string | null }[]
224
+ }[]
225
+ errors?: { fingerprint: string; apps: string[]; keys: string[]; error?: string | null }[]
226
+ }
227
+
228
+ export interface TrackingHealth {
229
+ healthy: boolean
230
+ message: string
231
+ ok: number
232
+ exempt: number
233
+ errors: number
234
+ error_keys: {
235
+ key: string
236
+ provider?: string | null
237
+ tracking_status?: string
238
+ error?: string
239
+ }[]
240
+ }
241
+
170
242
  export async function fetchSecretsCatalog(): Promise<SecretCatalog> {
171
243
  const res = await apiFetch(`${API_BASE}/api/secrets`)
172
244
  if (!res.ok) throw new Error(`Secrets catalog error: ${res.status}`)
173
245
  return res.json()
174
246
  }
175
247
 
248
+ /** Same credential value reused across secret names / apps — 409 when unhealthy. */
249
+ export async function fetchSharedValues(): Promise<SharedValueHealth> {
250
+ const res = await apiFetch(`${API_BASE}/api/secrets/shared-values`)
251
+ const body = await res.json().catch(() => ({}))
252
+ if (!res.ok && res.status !== 409) {
253
+ throw new Error(body.error || `Shared values error: ${res.status}`)
254
+ }
255
+ return body as SharedValueHealth
256
+ }
257
+
258
+ /** Provider account/usage tracking health — 409 when untracked keys remain. */
259
+ export async function fetchTrackingHealth(): Promise<TrackingHealth> {
260
+ const res = await apiFetch(`${API_BASE}/api/secrets/tracking-health`)
261
+ const body = await res.json().catch(() => ({}))
262
+ if (!res.ok && res.status !== 409) {
263
+ throw new Error(body.error || `Tracking health error: ${res.status}`)
264
+ }
265
+ return body as TrackingHealth
266
+ }
267
+
268
+ /** Live-probe vendor account/usage APIs and persist tracking meta. */
269
+ export async function runAccountSync(opts: {
270
+ key?: string
271
+ keys?: string[]
272
+ strict?: boolean
273
+ use_ai?: boolean
274
+ } = {}): Promise<Record<string, unknown>> {
275
+ const res = await apiFetch(`${API_BASE}/api/secrets/account-sync`, {
276
+ method: 'POST',
277
+ headers: { 'Content-Type': 'application/json' },
278
+ body: JSON.stringify(opts),
279
+ })
280
+ const body = await res.json().catch(() => ({}))
281
+ if (!res.ok && res.status !== 409) {
282
+ throw new Error(body.error || `Account sync failed: ${res.status}`)
283
+ }
284
+ return body
285
+ }
286
+
176
287
  export async function updateSecretMeta(key: string, patch: Record<string, unknown>): Promise<SecretCatalogKey> {
177
288
  const res = await apiFetch(`${API_BASE}/api/secrets/${encodeURIComponent(key)}`, {
178
289
  method: 'PATCH',
@@ -1,4 +1,4 @@
1
- import { get, post, del } from './_base';
1
+ import { apiFetch, getApiBase } from './_base';
2
2
 
3
3
  export interface NotificationRule {
4
4
  id: string;
@@ -18,24 +18,72 @@ export interface NotificationEntry {
18
18
  timestamp: string;
19
19
  }
20
20
 
21
+ async function parseJsonOrThrow(res: Response, label: string): Promise<any> {
22
+ const text = await res.text();
23
+ let data: any = null;
24
+ if (text) {
25
+ try {
26
+ data = JSON.parse(text);
27
+ } catch {
28
+ data = null;
29
+ }
30
+ }
31
+ if (!res.ok) {
32
+ const msg =
33
+ (data && (data.error || data.message)) ||
34
+ `${label} failed: ${res.status}`;
35
+ throw new Error(typeof msg === 'string' ? msg : `${label} failed: ${res.status}`);
36
+ }
37
+ return data;
38
+ }
39
+
40
+ function asArray<T>(data: unknown): T[] {
41
+ if (Array.isArray(data)) return data as T[];
42
+ if (data && typeof data === 'object') {
43
+ const obj = data as Record<string, unknown>;
44
+ if (Array.isArray(obj.rules)) return obj.rules as T[];
45
+ if (Array.isArray(obj.history)) return obj.history as T[];
46
+ if (Array.isArray(obj.entries)) return obj.entries as T[];
47
+ }
48
+ return [];
49
+ }
50
+
21
51
  export async function listNotificationRules(): Promise<NotificationRule[]> {
22
- return get('/api/notifications/rules');
52
+ const res = await apiFetch(`${getApiBase()}/api/notifications/rules`);
53
+ const data = await parseJsonOrThrow(res, 'GET /api/notifications/rules');
54
+ return asArray<NotificationRule>(data);
23
55
  }
24
56
 
25
57
  export async function createNotificationRule(
26
58
  rule: Omit<NotificationRule, 'id'>,
27
59
  ): Promise<NotificationRule> {
28
- return post('/api/notifications/rules', rule);
60
+ const res = await apiFetch(`${getApiBase()}/api/notifications/rules`, {
61
+ method: 'POST',
62
+ headers: { 'Content-Type': 'application/json' },
63
+ body: JSON.stringify(rule),
64
+ });
65
+ return parseJsonOrThrow(res, 'POST /api/notifications/rules');
29
66
  }
30
67
 
31
68
  export async function deleteNotificationRule(id: string): Promise<void> {
32
- return del(`/api/notifications/rules/${encodeURIComponent(id)}`);
69
+ const res = await apiFetch(
70
+ `${getApiBase()}/api/notifications/rules/${encodeURIComponent(id)}`,
71
+ { method: 'DELETE' },
72
+ );
73
+ await parseJsonOrThrow(res, 'DELETE /api/notifications/rules');
33
74
  }
34
75
 
35
76
  export async function getNotificationHistory(): Promise<NotificationEntry[]> {
36
- return get('/api/notifications/history');
77
+ const res = await apiFetch(`${getApiBase()}/api/notifications/history`);
78
+ const data = await parseJsonOrThrow(res, 'GET /api/notifications/history');
79
+ return asArray<NotificationEntry>(data);
37
80
  }
38
81
 
39
82
  export async function sendTestNotification(): Promise<void> {
40
- return post('/api/notifications/test', {});
83
+ const res = await apiFetch(`${getApiBase()}/api/notifications/test`, {
84
+ method: 'POST',
85
+ headers: { 'Content-Type': 'application/json' },
86
+ body: JSON.stringify({}),
87
+ });
88
+ await parseJsonOrThrow(res, 'POST /api/notifications/test');
41
89
  }
@@ -11,7 +11,18 @@ export interface UpdateCheckResult {
11
11
  export async function checkUpdate(): Promise<UpdateCheckResult> {
12
12
  const res = await apiFetch(`${API_BASE}/api/update/check`)
13
13
  if (!res.ok) throw new Error(`Check update API error: ${res.status}`)
14
- return res.json()
14
+ const data = await res.json()
15
+ // Normalize snake_case API fields for the Settings UI
16
+ const currentVersion = data.currentVersion ?? data.current ?? ''
17
+ const latestVersion = data.latestVersion ?? data.latest ?? ''
18
+ // Prefer host package comparison; fall back to API flag (includes consumers behind).
19
+ const hostBehind =
20
+ Boolean(latestVersion && currentVersion && latestVersion !== currentVersion)
21
+ return {
22
+ currentVersion,
23
+ latestVersion,
24
+ updateAvailable: hostBehind || Boolean(data.updateAvailable ?? data.update_available),
25
+ }
15
26
  }
16
27
 
17
28
  export async function runUpdate(): Promise<{ success: boolean; message: string }> {
@@ -1,73 +1,97 @@
1
- // @ts-nocheck
2
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
3
2
  import { render, screen, waitFor, fireEvent } from '@testing-library/react';
4
3
  import BrainSelector from './BrainSelector';
5
- import * as api from '../api';
6
4
 
7
- vi.mock('../api');
5
+ vi.mock('../api', () => ({
6
+ apiFetch: vi.fn(),
7
+ getApiBase: vi.fn(() => ''),
8
+ }));
9
+
10
+ import { apiFetch } from '../api';
8
11
 
9
12
  describe('BrainSelector', () => {
10
13
  beforeEach(() => {
11
- vi.resetAllMocks();
12
- (global as any).fetch = vi.fn();
13
- });
14
-
15
- it('renders loading state initially', () => {
16
- vi.mocked(api.getApiBase).mockReturnValue('http://localhost:3000');
17
- // Promise that never resolves for testing loading state
18
- (global.fetch as any).mockReturnValue(new Promise(() => {}));
19
-
20
- render(<BrainSelector activeBrainId="default" onBrainChange={() => {}} />);
21
- expect(screen.getByText(/Brains…/i)).toBeInTheDocument();
14
+ vi.clearAllMocks();
22
15
  });
23
16
 
24
- it('renders brains after fetch', async () => {
25
- vi.mocked(api.getApiBase).mockReturnValue('http://localhost:3000');
26
- (global.fetch as any).mockResolvedValue({
27
- json: () => Promise.resolve({
17
+ it('shows loading then brains from API', async () => {
18
+ vi.mocked(apiFetch).mockResolvedValue({
19
+ ok: true,
20
+ json: async () => ({
28
21
  brains: [
29
- { id: 'default', name: 'Global Brain', layer: 'global', exists: true, node_count: 10 }
30
- ]
31
- })
32
- });
22
+ { id: 'global', name: 'Global Brain', layer: 'global', exists: true, node_count: 10 },
23
+ {
24
+ id: 'project:demo',
25
+ name: 'demo',
26
+ layer: 'project',
27
+ exists: true,
28
+ node_count: 5,
29
+ project_root: '/tmp/demo',
30
+ },
31
+ ],
32
+ }),
33
+ } as any);
33
34
 
34
- render(<BrainSelector activeBrainId="default" onBrainChange={() => {}} />);
35
+ const onBrainChange = vi.fn();
36
+ render(<BrainSelector activeBrainId="global" onBrainChange={onBrainChange} />);
35
37
 
38
+ expect(screen.getByTestId('brain-selector-loading')).toBeInTheDocument();
36
39
  await waitFor(() => {
37
- expect(screen.getByText(/Global Brain/i)).toBeInTheDocument();
40
+ expect(screen.getByTestId('brain-selector')).toBeInTheDocument();
38
41
  });
42
+ expect(screen.getByText(/Global Brain/i)).toBeInTheDocument();
39
43
  });
40
44
 
41
- it('toggles brain selection', async () => {
42
- vi.mocked(api.getApiBase).mockReturnValue('http://localhost:3000');
43
- (global.fetch as any).mockResolvedValue({
44
- json: () => Promise.resolve({
45
+ it('selects a single brain on row click', async () => {
46
+ vi.mocked(apiFetch).mockResolvedValue({
47
+ ok: true,
48
+ json: async () => ({
45
49
  brains: [
46
- { id: 'default', name: 'Global Brain', layer: 'global', exists: true, node_count: 10 },
47
- { id: 'proj', name: 'Project Brain', layer: 'project', exists: true, node_count: 5 }
48
- ]
49
- })
50
- });
50
+ { id: 'global', name: 'Global Brain', layer: 'global', exists: true, node_count: 10 },
51
+ { id: 'project:demo', name: 'demo', layer: 'project', exists: true, node_count: 5 },
52
+ ],
53
+ }),
54
+ } as any);
51
55
 
52
56
  const onBrainChange = vi.fn();
53
- render(<BrainSelector activeBrainId="default" onBrainChange={onBrainChange} />);
57
+ render(<BrainSelector activeBrainId="global" onBrainChange={onBrainChange} />);
54
58
 
55
- // Wait for load
56
- await waitFor(() => {
57
- expect(screen.getByText(/Global Brain/i)).toBeInTheDocument();
58
- });
59
+ await waitFor(() => expect(screen.getByTestId('brain-selector-trigger')).toBeInTheDocument());
60
+ fireEvent.click(screen.getByTestId('brain-selector-trigger'));
61
+ fireEvent.click(screen.getByTestId('brain-option-project:demo'));
62
+ expect(onBrainChange).toHaveBeenCalledWith('project:demo');
63
+ });
59
64
 
60
- // Expand dropdown
61
- const trigger = screen.getByTitle('Select brain layers');
62
- fireEvent.click(trigger);
65
+ it('multi-select via checkbox', async () => {
66
+ vi.mocked(apiFetch).mockResolvedValue({
67
+ ok: true,
68
+ json: async () => ({
69
+ brains: [
70
+ { id: 'global', name: 'Global Brain', layer: 'global', exists: true, node_count: 10 },
71
+ { id: 'project:demo', name: 'demo', layer: 'project', exists: true, node_count: 5 },
72
+ ],
73
+ }),
74
+ } as any);
63
75
 
64
- // Click checkbox
65
- const checkboxes = screen.getAllByRole('checkbox');
66
- expect(checkboxes.length).toBe(2);
76
+ const onBrainChange = vi.fn();
77
+ render(<BrainSelector activeBrainId="global" onBrainChange={onBrainChange} />);
78
+ await waitFor(() => expect(screen.getByTestId('brain-selector-trigger')).toBeInTheDocument());
79
+ fireEvent.click(screen.getByTestId('brain-selector-trigger'));
80
+ const checkbox = screen.getByLabelText('Include demo');
81
+ fireEvent.click(checkbox);
82
+ expect(onBrainChange).toHaveBeenCalledWith('global,project:demo');
83
+ });
67
84
 
68
- // Toggle the second one
69
- fireEvent.click(checkboxes[1]);
85
+ it('shows error with retry when API fails', async () => {
86
+ vi.mocked(apiFetch).mockResolvedValue({
87
+ ok: false,
88
+ status: 401,
89
+ json: async () => ({ error: 'Authentication required' }),
90
+ } as any);
70
91
 
71
- expect(onBrainChange).toHaveBeenCalledWith('default,proj');
92
+ render(<BrainSelector activeBrainId="global" onBrainChange={vi.fn()} />);
93
+ await waitFor(() => {
94
+ expect(screen.getByTestId('brain-selector-error')).toBeInTheDocument();
95
+ });
72
96
  });
73
97
  });