total-recall-brain 3.22.0 → 3.23.1

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 (35) hide show
  1. package/bin/total-recall.mjs +2 -0
  2. package/frontend/src/api/headscale.ts +68 -1
  3. package/frontend/src/api/mesh.spec.ts +27 -0
  4. package/frontend/src/api/mesh.ts +85 -0
  5. package/frontend/src/components/EmbeddingProviderPanel.tsx +21 -3
  6. package/frontend/src/pages/MeshPage.spec.tsx +164 -0
  7. package/frontend/src/pages/MeshPage.tsx +294 -6
  8. package/frontend/src/pages/NetworkPage.spec.tsx +19 -4
  9. package/frontend/src/pages/OnboardingPage.spec.tsx +14 -2
  10. package/frontend/src/pages/SkillsPage.spec.tsx +1 -1
  11. package/package.json +2 -1
  12. package/scaffold/.agent/skills/total-recall/references/api-reference.md +1 -1
  13. package/scaffold/.agent/skills/total-recall/references/cli-reference.md +2 -1
  14. package/scaffold/.agent/skills/total-recall/skills-registry/index.yaml +6 -2574
  15. package/src/cli/mesh.mjs +416 -0
  16. package/src/cli/repo-expert-generate.mjs +5 -1
  17. package/src/cli/reset-password.mjs +75 -6
  18. package/src/cli/secret.mjs +250 -7
  19. package/src/core/browser-session.mjs +68 -13
  20. package/src/core/daemon-loop.mjs +26 -8
  21. package/src/core/headscale-client.mjs +87 -0
  22. package/src/core/mesh-access.mjs +261 -0
  23. package/src/core/mesh-enroll.mjs +74 -32
  24. package/src/core/mesh.mjs +164 -12
  25. package/src/core/pid-lock.mjs +65 -0
  26. package/src/core/schema.mjs +36 -0
  27. package/src/core/secrets-keychain.mjs +83 -0
  28. package/src/core/secrets-rekey.mjs +0 -0
  29. package/src/core/secrets-store.mjs +26 -6
  30. package/src/core/tailscale-cli.mjs +69 -0
  31. package/src/server/index.mjs +23 -4
  32. package/src/server/route-inventory.mjs +62 -0
  33. package/src/server/route-manifest.json +20 -0
  34. package/src/server/routes/headscale.mjs +32 -0
  35. package/src/server/routes/mesh.mjs +118 -0
@@ -12,8 +12,11 @@ import {
12
12
  logElectionObservation,
13
13
  fetchEnrollmentStatus,
14
14
  enrollThisNode,
15
+ fetchAccessProposals,
16
+ importAccessFromSshConfig,
17
+ setNodeAccess,
15
18
  } from '../api/mesh';
16
- import { fetchHeadscaleNodes, fetchPreAuthKeys, fetchHeadscaleUsers, createPreAuthKey, deleteHeadscaleNode } from '../api/headscale';
19
+ import { fetchHeadscaleNodes, fetchPreAuthKeys, fetchHeadscaleUsers, createPreAuthKey, deleteHeadscaleNode, fetchHeadscalePolicy, saveHeadscalePolicy, buildMeshSshPolicy } from '../api/headscale';
17
20
  import type {
18
21
  MeshNode,
19
22
  LeaderInfo,
@@ -21,6 +24,7 @@ import type {
21
24
  MeshInterfaceSummary,
22
25
  DeviceIoProfile,
23
26
  EnrollmentStatus,
27
+ AccessProposal,
24
28
  } from '../api/mesh';
25
29
  import type { HeadscaleNode, PreAuthKey as HeadscalePreAuthKey, HeadscaleUser } from '../api/headscale';
26
30
  import { MeshTopology } from '../components/MeshTopology';
@@ -39,13 +43,43 @@ interface ElectionLogEntry {
39
43
  note: string;
40
44
  }
41
45
 
46
+ /** Editable access fields, as strings so an empty box can clear a value. */
47
+ interface AccessDraft {
48
+ ssh_user: string;
49
+ ssh_port: string;
50
+ identity_file: string;
51
+ ssh_host: string;
52
+ }
53
+
54
+ const EMPTY_ACCESS_DRAFT: AccessDraft = {
55
+ ssh_user: '',
56
+ ssh_port: '',
57
+ identity_file: '',
58
+ ssh_host: '',
59
+ };
60
+
61
+ function accessDraftFromNode(node: MeshNode | null): AccessDraft {
62
+ const access = node?.access;
63
+ return {
64
+ ssh_user: access?.ssh_user || '',
65
+ ssh_port: access?.ssh_port ? String(access.ssh_port) : '',
66
+ identity_file: access?.identity_file || '',
67
+ ssh_host: access?.ssh_host || '',
68
+ };
69
+ }
70
+
42
71
  export function MeshPage() {
43
72
  const [activeTab, setActiveTab] = useState<'mesh'|'headscale-nodes'|'preauthkeys'|'users'>('mesh');
44
73
 
45
74
  // Mesh State
46
75
  const [meshNodes, setMeshNodes] = useState<MeshNode[]>([]);
47
76
  const [leader, setLeader] = useState<LeaderInfo | null>(null);
48
- const [selectedNode, setSelectedNode] = useState<MeshNode | null>(null);
77
+ // Selection is held as a hostname, not a node object: polling replaces the
78
+ // node list every few seconds, and a captured object goes stale the moment it
79
+ // does — the detail pane would keep showing the state at the time of click,
80
+ // including right after an edit was saved.
81
+ const [selectedHostname, setSelectedHostname] = useState<string | null>(null);
82
+ const selectedNode = meshNodes.find((node) => node.hostname === selectedHostname) || null;
49
83
  const [latencyMs, setLatencyMs] = useState<Record<string, number | null>>({});
50
84
  /** Rolling RTT samples per hostname for sparklines (from this node). */
51
85
  const [latencyHistory, setLatencyHistory] = useState<Record<string, number[]>>({});
@@ -58,8 +92,21 @@ export function MeshPage() {
58
92
  const [enrollment, setEnrollment] = useState<EnrollmentStatus | null>(null);
59
93
  const [enrollBusy, setEnrollBusy] = useState(false);
60
94
  const [enrollMsg, setEnrollMsg] = useState<string | null>(null);
95
+ // Mesh SSH is a property of the control server, not of this node, so it is
96
+ // tracked separately from enrollment.
97
+ const [sshPolicy, setSshPolicy] = useState<{ configured: boolean; fileMode?: boolean } | null>(null);
98
+ const [sshBusy, setSshBusy] = useState(false);
99
+ const [sshMsg, setSshMsg] = useState<string | null>(null);
61
100
  const [lanRegisterBusy, setLanRegisterBusy] = useState(false);
62
101
  const [lanRegisterMsg, setLanRegisterMsg] = useState<string | null>(null);
102
+ // A node whose login account is unknown refuses every connection in exactly
103
+ // the way an unreachable node does, so the gap is surfaced, not just stored.
104
+ const [accessProposals, setAccessProposals] = useState<AccessProposal[]>([]);
105
+ const [accessBusy, setAccessBusy] = useState(false);
106
+ const [accessMsg, setAccessMsg] = useState<string | null>(null);
107
+ const [accessDraft, setAccessDraft] = useState<AccessDraft>(EMPTY_ACCESS_DRAFT);
108
+ const [accessSaveBusy, setAccessSaveBusy] = useState(false);
109
+ const [accessSaveMsg, setAccessSaveMsg] = useState<string | null>(null);
63
110
  const prevLeaderRef = useRef<string | null>(null);
64
111
  const pollMsRef = useRef(POLL_BASE_MS);
65
112
  const electionHistoryLoadedRef = useRef(false);
@@ -78,6 +125,8 @@ export function MeshPage() {
78
125
  const [newKeyEphemeral, setNewKeyEphemeral] = useState(false);
79
126
  const [newKeyExpiration, setNewKeyExpiration] = useState('2099-12-31T23:59:59Z');
80
127
 
128
+ const nodesMissingAccess = meshNodes.filter((node) => node.access_resolved?.complete === false);
129
+
81
130
  const recordLeaderChange = useCallback((l: LeaderInfo | null, note: string) => {
82
131
  if (!l) return;
83
132
  const key = `${l.hostname}|${l.ip}`;
@@ -94,6 +143,15 @@ export function MeshPage() {
94
143
  }
95
144
  }, []);
96
145
 
146
+ // Selecting is the only thing that may overwrite the edit form. Doing it in a
147
+ // poll-driven effect instead would wipe whatever the user was typing every
148
+ // few seconds.
149
+ const selectNode = useCallback((node: MeshNode) => {
150
+ setSelectedHostname(node.hostname);
151
+ setAccessDraft(accessDraftFromNode(node));
152
+ setAccessSaveMsg(null);
153
+ }, []);
154
+
97
155
  const pushLatencySamples = useCallback((sample: Record<string, number | null>) => {
98
156
  setLatencyMs(sample);
99
157
  setLatencyHistory((prev) => {
@@ -158,10 +216,23 @@ export function MeshPage() {
158
216
  setUiHints(ioRes.ui_hints || ioRes.io?.ui_hints || []);
159
217
  })
160
218
  .catch(() => { /* optional */ });
219
+ // Neither of these should ever take the page down — both are optional
220
+ // capabilities that may simply not be configured yet.
221
+ void (async () => {
222
+ try {
223
+ const p = await fetchHeadscalePolicy();
224
+ setSshPolicy({ configured: p.configured, fileMode: p.fileMode });
225
+ } catch {
226
+ setSshPolicy(null);
227
+ }
228
+ })();
161
229
  // Enrollment state drives the join banner; never fail the page on it.
162
230
  fetchEnrollmentStatus()
163
231
  .then(setEnrollment)
164
232
  .catch(() => { /* optional */ });
233
+ fetchAccessProposals()
234
+ .then((res) => setAccessProposals(res.proposals || []))
235
+ .catch(() => { /* no ssh config on this host is normal */ });
165
236
  pollMsRef.current = POLL_BASE_MS;
166
237
  } else if (activeTab === 'headscale-nodes') {
167
238
  const nodes = await fetchHeadscaleNodes();
@@ -226,6 +297,55 @@ export function MeshPage() {
226
297
  }
227
298
  }
228
299
 
300
+ async function handleImportAccess() {
301
+ setAccessBusy(true);
302
+ setAccessMsg(null);
303
+ try {
304
+ const res = await importAccessFromSshConfig();
305
+ setAccessMsg(
306
+ res.failed
307
+ ? `Recorded ${res.saved} of ${res.attempted}; ${res.failed} could not be saved.`
308
+ : `Recorded a login account for ${res.saved} node(s).`,
309
+ );
310
+ await loadData();
311
+ } catch (err) {
312
+ setAccessMsg(err instanceof Error ? err.message : 'Could not import from ssh config');
313
+ } finally {
314
+ setAccessBusy(false);
315
+ }
316
+ }
317
+
318
+ async function handleSaveAccess(e: React.FormEvent) {
319
+ e.preventDefault();
320
+ if (!selectedNode) return;
321
+ setAccessSaveBusy(true);
322
+ setAccessSaveMsg(null);
323
+ try {
324
+ await setNodeAccess({ node: selectedNode.hostname, ...accessDraft });
325
+ setAccessSaveMsg('Saved to the node entity.');
326
+ await loadData();
327
+ } catch (err) {
328
+ setAccessSaveMsg(err instanceof Error ? err.message : 'Could not save access');
329
+ } finally {
330
+ setAccessSaveBusy(false);
331
+ }
332
+ }
333
+
334
+ async function handleEnableMeshSsh() {
335
+ setSshBusy(true);
336
+ setSshMsg(null);
337
+ try {
338
+ await saveHeadscalePolicy(buildMeshSshPolicy());
339
+ const next = await fetchHeadscalePolicy();
340
+ setSshPolicy({ configured: next.configured, fileMode: next.fileMode });
341
+ setSshMsg('Mesh SSH enabled. Nodes pick this up automatically.');
342
+ } catch (err) {
343
+ setSshMsg(err instanceof Error ? err.message : 'Could not enable mesh SSH');
344
+ } finally {
345
+ setSshBusy(false);
346
+ }
347
+ }
348
+
229
349
  async function handleEnroll() {
230
350
  setEnrollBusy(true);
231
351
  setEnrollMsg(null);
@@ -360,6 +480,81 @@ export function MeshPage() {
360
480
 
361
481
  {activeTab === 'mesh' && (
362
482
  <div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
483
+ {/* Without a policy the control server routes packets but authorises
484
+ nothing, so every machine falls back to its own SSH keys. */}
485
+ {sshPolicy && !sshPolicy.configured && (
486
+ <div className="alert alert-warning" data-testid="mesh-ssh-banner">
487
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, flexWrap: 'wrap' }}>
488
+ <div>
489
+ <strong>Mesh SSH is off</strong>
490
+ <div style={{ fontSize: 13, marginTop: 4 }}>
491
+ {sshPolicy.fileMode
492
+ ? 'The control server stores its policy in a file, so it cannot be managed here. Set policy.mode: database in the control server config to enable this.'
493
+ : 'Nodes can reach each other, but each still needs its own SSH keys. Enabling mesh SSH lets the control server authorise connections instead.'}
494
+ </div>
495
+ {sshMsg && (
496
+ <div style={{ fontSize: 13, marginTop: 8, color: 'var(--accent-hover)' }}>{sshMsg}</div>
497
+ )}
498
+ </div>
499
+ {!sshPolicy.fileMode && (
500
+ <button type="button" className="btn btn-primary" onClick={handleEnableMeshSsh} disabled={sshBusy}>
501
+ {sshBusy ? 'Enabling…' : 'Enable mesh SSH'}
502
+ </button>
503
+ )}
504
+ </div>
505
+ </div>
506
+ )}
507
+ {sshPolicy?.configured && sshMsg && (
508
+ <div className="alert alert-success" data-testid="mesh-ssh-ok">{sshMsg}</div>
509
+ )}
510
+ {/* The control server can say a node is up but not which account you
511
+ log in as, and connecting as the wrong one is refused exactly like
512
+ an unreachable host — so the gap is stated rather than discovered. */}
513
+ {nodesMissingAccess.length > 0 && (
514
+ <div className="alert alert-warning" data-testid="mesh-access-banner">
515
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, flexWrap: 'wrap' }}>
516
+ <div>
517
+ <strong>
518
+ {nodesMissingAccess.length === 1
519
+ ? '1 node has no recorded login account'
520
+ : `${nodesMissingAccess.length} nodes have no recorded login account`}
521
+ </strong>
522
+ <div style={{ fontSize: 13, marginTop: 4 }}>
523
+ Any node that tries to reach one of these is refused, in exactly the way an unreachable
524
+ machine refuses. {accessProposals.length > 0
525
+ ? `This host's ssh config can supply ${accessProposals.length} of them.`
526
+ : 'Select a node below to record one.'}
527
+ </div>
528
+ <div style={{ fontSize: 12, marginTop: 6, color: 'var(--text-secondary)' }}>
529
+ {nodesMissingAccess.map((node) => node.hostname).join(', ')}
530
+ </div>
531
+ {accessProposals.length > 0 && (
532
+ <ul style={{ margin: '8px 0 0', paddingLeft: 18, fontSize: 12 }} data-testid="access-proposals">
533
+ {accessProposals.map((proposal) => (
534
+ <li key={proposal.hostname}>
535
+ <strong>{proposal.hostname}</strong> → {proposal.access.ssh_user}{' '}
536
+ <span style={{ color: 'var(--text-secondary)' }}>(from Host {proposal.matched_host})</span>
537
+ </li>
538
+ ))}
539
+ </ul>
540
+ )}
541
+ {accessMsg && (
542
+ <div style={{ fontSize: 13, marginTop: 8, color: 'var(--accent-hover)' }}>{accessMsg}</div>
543
+ )}
544
+ </div>
545
+ {accessProposals.length > 0 && (
546
+ <button
547
+ type="button"
548
+ className="btn btn-primary"
549
+ onClick={handleImportAccess}
550
+ disabled={accessBusy}
551
+ >
552
+ {accessBusy ? 'Importing…' : 'Import from ssh config'}
553
+ </button>
554
+ )}
555
+ </div>
556
+ </div>
557
+ )}
363
558
  {/* Which mesh node actually serves embeddings, and which model it uses. */}
364
559
  <div className="card">
365
560
  <EmbeddingProviderPanel />
@@ -371,8 +566,8 @@ export function MeshPage() {
371
566
  nodes={meshNodes}
372
567
  leader={leader}
373
568
  latencyMs={latencyMs}
374
- selectedHostname={selectedNode?.hostname}
375
- onSelectNode={setSelectedNode}
569
+ selectedHostname={selectedHostname}
570
+ onSelectNode={selectNode}
376
571
  />
377
572
  </div>
378
573
  <div className="card">
@@ -455,10 +650,92 @@ export function MeshPage() {
455
650
  </dd>
456
651
  <dt style={{ color: 'var(--text-secondary)' }}>Scope</dt>
457
652
  <dd style={{ margin: 0 }}>{selectedNode.self ? 'This node' : 'Peer'}</dd>
653
+ <dt style={{ color: 'var(--text-secondary)' }}>Login</dt>
654
+ <dd style={{ margin: 0, fontFamily: 'monospace', fontSize: 12 }} data-testid="node-access-target">
655
+ {selectedNode.access_resolved?.complete
656
+ ? selectedNode.access_resolved.target
657
+ : <span className="badge badge-offline">no login recorded</span>}
658
+ </dd>
659
+ <dt style={{ color: 'var(--text-secondary)' }}>Access source</dt>
660
+ <dd style={{ margin: 0 }}>
661
+ {selectedNode.access?.source && selectedNode.access.source !== 'unknown'
662
+ ? selectedNode.access.source.replace('_', ' ')
663
+ : '—'}
664
+ </dd>
665
+ {/* Only the open-source build can serve control-server-authorised
666
+ SSH; the sandboxed GUI builds never start the server, so a
667
+ node running one still needs its own keys. */}
668
+ <dt style={{ color: 'var(--text-secondary)' }}>Mesh SSH</dt>
669
+ <dd style={{ margin: 0 }} data-testid="node-mesh-ssh">
670
+ {selectedNode.access_resolved?.mesh_ssh === 'available'
671
+ ? 'authorised by the control server'
672
+ : selectedNode.access_resolved?.mesh_ssh === 'unsupported'
673
+ ? `not served by this build (${selectedNode.access_resolved.tailscale_variant}) — needs its own keys`
674
+ : 'unknown'}
675
+ </dd>
458
676
  </dl>
459
677
  ) : (
460
678
  <p style={{ color: 'var(--text-secondary)' }}>Click a node in the topology to inspect it.</p>
461
679
  )}
680
+
681
+ {selectedNode && (
682
+ <form onSubmit={handleSaveAccess} style={{ marginTop: 16, borderTop: '1px solid var(--border)', paddingTop: 12 }}>
683
+ <h4 style={{ margin: '0 0 4px' }}>Access</h4>
684
+ <p style={{ fontSize: 12, color: 'var(--text-secondary)', margin: '0 0 12px' }}>
685
+ Stored on the node entity, so every machine and agent resolves it the same way.
686
+ Leave a box empty to clear it. The address comes from the mesh unless you override it.
687
+ </p>
688
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
689
+ <label style={{ fontSize: 12 }}>
690
+ Login account
691
+ <input
692
+ className="input"
693
+ value={accessDraft.ssh_user}
694
+ placeholder="e.g. the account you ssh as"
695
+ onChange={(e) => setAccessDraft({ ...accessDraft, ssh_user: e.target.value })}
696
+ />
697
+ </label>
698
+ <label style={{ fontSize: 12 }}>
699
+ Port
700
+ <input
701
+ className="input"
702
+ value={accessDraft.ssh_port}
703
+ placeholder="22"
704
+ inputMode="numeric"
705
+ onChange={(e) => setAccessDraft({ ...accessDraft, ssh_port: e.target.value })}
706
+ />
707
+ </label>
708
+ <label style={{ fontSize: 12 }}>
709
+ Key file
710
+ <input
711
+ className="input"
712
+ value={accessDraft.identity_file}
713
+ placeholder="~/.ssh/id_ed25519"
714
+ onChange={(e) => setAccessDraft({ ...accessDraft, identity_file: e.target.value })}
715
+ />
716
+ </label>
717
+ <label style={{ fontSize: 12 }}>
718
+ Address override
719
+ <input
720
+ className="input"
721
+ value={accessDraft.ssh_host}
722
+ placeholder={selectedNode.ip || 'mesh address'}
723
+ onChange={(e) => setAccessDraft({ ...accessDraft, ssh_host: e.target.value })}
724
+ />
725
+ </label>
726
+ </div>
727
+ <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 12 }}>
728
+ <button type="submit" className="btn btn-primary btn-sm" disabled={accessSaveBusy}>
729
+ {accessSaveBusy ? 'Saving…' : 'Save access'}
730
+ </button>
731
+ {accessSaveMsg && (
732
+ <span style={{ fontSize: 12, color: 'var(--accent-hover)' }} data-testid="access-save-msg">
733
+ {accessSaveMsg}
734
+ </span>
735
+ )}
736
+ </div>
737
+ </form>
738
+ )}
462
739
  </div>
463
740
  </div>
464
741
 
@@ -608,6 +885,7 @@ export function MeshPage() {
608
885
  <th>Role</th>
609
886
  <th>Status</th>
610
887
  <th>Latency</th>
888
+ <th>Login</th>
611
889
  <th>Operating System</th>
612
890
  <th>Node</th>
613
891
  </tr>
@@ -618,8 +896,9 @@ export function MeshPage() {
618
896
  return (
619
897
  <tr
620
898
  key={node.hostname}
899
+ data-testid={`node-row-${node.hostname}`}
621
900
  className={isNodeLeader ? 'is-leader' : ''}
622
- onClick={() => setSelectedNode(node)}
901
+ onClick={() => selectNode(node)}
623
902
  style={{ cursor: 'pointer' }}
624
903
  >
625
904
  <td style={{ fontWeight: 500, display: 'flex', alignItems: 'center', gap: '8px' }}>
@@ -640,6 +919,15 @@ export function MeshPage() {
640
919
  <td style={{ color: 'var(--text-secondary)' }}>
641
920
  {node.self ? '—' : latencyMs[node.hostname] != null ? `${latencyMs[node.hostname]} ms` : '—'}
642
921
  </td>
922
+ <td style={{ fontFamily: 'monospace', fontSize: 12 }}>
923
+ {node.access_resolved?.complete ? (
924
+ node.access_resolved.target
925
+ ) : (
926
+ <span className="badge badge-offline" title="Connecting will fail as though the node were unreachable">
927
+ no login
928
+ </span>
929
+ )}
930
+ </td>
643
931
  <td style={{ color: 'var(--text-secondary)' }}>{node.os || 'Unknown'}</td>
644
932
  <td style={{ color: 'var(--text-secondary)', fontSize: '13px' }}>{node.self ? 'This node' : 'Peer'}</td>
645
933
  </tr>
@@ -647,7 +935,7 @@ export function MeshPage() {
647
935
  })}
648
936
  {meshNodes.length === 0 && !loading && (
649
937
  <tr>
650
- <td colSpan={7} style={{ textAlign: 'center', color: 'var(--text-secondary)', padding: '32px' }}>
938
+ <td colSpan={8} style={{ textAlign: 'center', color: 'var(--text-secondary)', padding: '32px' }}>
651
939
  No mesh nodes found
652
940
  </td>
653
941
  </tr>
@@ -34,12 +34,27 @@ describe('NetworkPage', () => {
34
34
  beforeEach(() => {
35
35
  vi.clearAllMocks();
36
36
  vi.mocked(getNetworkStats).mockResolvedValue({
37
- stats: { active: 2, queueLength: 0, errors: 0, timeouts: 0 } as any
38
- } as any);
37
+ stats: {
38
+ total: 2,
39
+ blocked: 0,
40
+ queueLength: 0,
41
+ active: 2,
42
+ completed: 0,
43
+ errors: 0,
44
+ timeouts: 0,
45
+ peakActive: 2,
46
+ peakQueue: 0,
47
+ domains: {},
48
+ },
49
+ audit_count: 0,
50
+ });
39
51
  vi.mocked(getNetworkPolicy).mockResolvedValue({
52
+ id: 'test-policy',
40
53
  blocked_domains: ['evil.com'],
41
- max_global_concurrency: 20
42
- } as any);
54
+ max_global_concurrency: 20,
55
+ max_per_domain_concurrency: 4,
56
+ default_timeout_ms: 10000,
57
+ });
43
58
  vi.mocked(getAuditLog).mockResolvedValue({
44
59
  audit: []
45
60
  });
@@ -44,8 +44,20 @@ describe('OnboardingPage', () => {
44
44
  it('handles scan import mode', async () => {
45
45
  vi.mocked(api.listMemory).mockResolvedValue([] as never);
46
46
  vi.mocked(api.scanEnvSecrets).mockResolvedValue({
47
- candidates: [{ key: 'TEST_KEY', provider: 'test', masked: 'sk-***', source_label: '.env' } as any]
48
- } as any);
47
+ candidates: [
48
+ {
49
+ key: 'TEST_KEY',
50
+ provider: 'test',
51
+ masked: 'sk-***',
52
+ source_label: '.env',
53
+ source: '.env',
54
+ length: 12,
55
+ known: true,
56
+ },
57
+ ],
58
+ count: 1,
59
+ sources_scanned: ['.env'],
60
+ });
49
61
 
50
62
  renderWithRouter(<OnboardingPage />);
51
63
 
@@ -9,7 +9,7 @@ describe('SkillsPage', () => {
9
9
  beforeEach(() => {
10
10
  vi.resetAllMocks();
11
11
  vi.mocked(api.listSkills).mockResolvedValue([
12
- { name: 'test-skill', description: 'A test skill', repo: 'test' } as any
12
+ { id: 'test-skill', name: 'test-skill', repo: 'test', source: 'A test skill' },
13
13
  ]);
14
14
  });
15
15
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "total-recall-brain",
3
- "version": "3.22.0",
3
+ "version": "3.23.1",
4
4
  "description": "Personal AI system — database-free, SSSS-powered intelligence engine deployable to any POSIX host.",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -39,6 +39,7 @@
39
39
  "dev": "node src/server/index.mjs",
40
40
  "start": "node src/server/index.mjs",
41
41
  "test": "vitest run",
42
+ "routes:manifest": "node scripts/generate-route-manifest.mjs",
42
43
  "clean": "rm -rf scratch/* .remember/* *.log"
43
44
  },
44
45
  "repository": {
@@ -43,7 +43,7 @@ Returns the status of the vault, embeddings index, and LLM reachability.
43
43
  "vfs": {
44
44
  "exists": true,
45
45
  "skill_exists": true,
46
- "path": "/Users/greg/.agent"
46
+ "path": "~/.agent"
47
47
  }
48
48
  }
49
49
  ```
@@ -23,7 +23,8 @@ Rebuild the derived indexes and compile memory surfaces.
23
23
  ### 1.3 `connect`
24
24
  Configure an IDE or external client to bind to the Total Recall local server.
25
25
  * **Syntax**: `npx total-recall connect <client> [options]`
26
- * **Supported Clients**: `cursor`, `claude-code`, `antigravity`, `gemini`, `vscode`, `windsurf`, `aider`, `ultrachat`, `obsidian`.
26
+ * **Supported Clients**: `vscode`, `pi`, `hermes`, `openclaw`, `cursor`, `cline`, `claude-code`, `codex`, `antigravity`, `gemini`, `aider`, `http-api`, `obsidian`, `generic`.
27
+ Any host application not listed here connects through the generic `http-api` client.
27
28
  * **Options**:
28
29
  * `--brain <url>`: Override default brain server URL.
29
30
  * `--token <PAT>`: Inject bearer PAT credentials for authenticating requests.