total-recall-brain 3.21.3 โ†’ 3.22.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.
@@ -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-BwUTxKNm.js"></script>
20
+ <script type="module" crossorigin src="/assets/index-DJjuZ3t3.js"></script>
21
21
  <link rel="stylesheet" crossorigin href="/assets/index-C7_Xpk27.css">
22
22
  </head>
23
23
  <body>
@@ -0,0 +1,46 @@
1
+ import { get, post } from './_base'
2
+
3
+ /** One model on the Ollama host, annotated with why it can or cannot be used. */
4
+ export interface EmbeddingCandidate {
5
+ name: string
6
+ size: number | null
7
+ family: string | null
8
+ capabilities: string[]
9
+ /** Whether the model declares the embedding capability at all. */
10
+ embedding: boolean
11
+ /** Vector width the model produces, or null when it is not an embedding model. */
12
+ dims: number | null
13
+ /** True only when it embeds AND its width matches the vault index. */
14
+ compatible: boolean
15
+ }
16
+
17
+ export interface LocalEmbeddingProvider {
18
+ available: boolean
19
+ endpoint: string | null
20
+ selected: string | null
21
+ preferred: string | null
22
+ dims: number
23
+ candidates: EmbeddingCandidate[]
24
+ reason: string | null
25
+ }
26
+
27
+ export interface EmbeddingProviderStatus {
28
+ dims: number
29
+ local: LocalEmbeddingProvider
30
+ fallbacks: { provider: string; configured: boolean }[]
31
+ }
32
+
33
+ export async function fetchEmbeddingProvider(): Promise<EmbeddingProviderStatus> {
34
+ return get<EmbeddingProviderStatus>('/api/embeddings/provider')
35
+ }
36
+
37
+ /** Pin a model, or pass null to return to automatic selection. */
38
+ export async function setEmbeddingModel(
39
+ model: string | null,
40
+ ): Promise<{ ok: boolean; pinned: string | null; selected: string | null }> {
41
+ return post('/api/embeddings/model', { model })
42
+ }
43
+
44
+ export async function rediscoverEmbeddingProvider(): Promise<{ ok: boolean; local: LocalEmbeddingProvider }> {
45
+ return post('/api/embeddings/rediscover', {})
46
+ }
@@ -0,0 +1,127 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { render, screen, waitFor } from '@testing-library/react'
3
+ import userEvent from '@testing-library/user-event'
4
+
5
+ import { EmbeddingProviderPanel } from './EmbeddingProviderPanel'
6
+ import * as api from '../api/embeddings'
7
+ import type { EmbeddingProviderStatus } from '../api/embeddings'
8
+
9
+ vi.mock('../api/embeddings')
10
+
11
+ // Annotated against the real contract: without it TS narrows `endpoint` to
12
+ // string and `reason` to null from the literal, so the "nothing reachable"
13
+ // case below cannot be expressed.
14
+ const LOCAL_OK: EmbeddingProviderStatus = {
15
+ dims: 768,
16
+ local: {
17
+ available: true,
18
+ endpoint: 'http://100.64.0.2:11434',
19
+ selected: 'nomic-embed-text:latest',
20
+ preferred: null,
21
+ dims: 768,
22
+ candidates: [
23
+ {
24
+ name: 'nomic-embed-text:latest',
25
+ size: 274_000_000,
26
+ family: 'nomic-bert',
27
+ capabilities: ['embedding'],
28
+ embedding: true,
29
+ dims: 768,
30
+ compatible: true,
31
+ },
32
+ {
33
+ name: 'gemma4:latest',
34
+ size: 9_608_000_000,
35
+ family: 'gemma4',
36
+ capabilities: ['completion'],
37
+ embedding: false,
38
+ dims: 2560,
39
+ compatible: false,
40
+ },
41
+ ],
42
+ reason: null,
43
+ },
44
+ fallbacks: [
45
+ { provider: 'openrouter', configured: true },
46
+ { provider: 'google', configured: false },
47
+ { provider: 'openai', configured: false },
48
+ ],
49
+ }
50
+
51
+ beforeEach(() => {
52
+ vi.mocked(api.fetchEmbeddingProvider).mockResolvedValue(structuredClone(LOCAL_OK))
53
+ vi.mocked(api.setEmbeddingModel).mockResolvedValue({ ok: true, pinned: null, selected: null })
54
+ vi.mocked(api.rediscoverEmbeddingProvider).mockResolvedValue({ ok: true, local: LOCAL_OK.local })
55
+ })
56
+
57
+ describe('EmbeddingProviderPanel', () => {
58
+ it('shows the resolved endpoint and selected model', async () => {
59
+ render(<EmbeddingProviderPanel />)
60
+ expect(await screen.findByTestId('embed-endpoint')).toHaveTextContent('http://100.64.0.2:11434')
61
+ expect(screen.getByTestId('embed-selected')).toHaveTextContent('nomic-embed-text:latest')
62
+ expect(screen.getByTestId('embed-selected')).toHaveTextContent('auto')
63
+ })
64
+
65
+ it('lists incompatible models with the reason instead of hiding them', async () => {
66
+ render(<EmbeddingProviderPanel />)
67
+ // An operator who pulled a chat model needs to see why it is unusable.
68
+ expect(await screen.findByText('gemma4:latest')).toBeInTheDocument()
69
+ expect(screen.getByText(/cannot embed/i)).toBeInTheDocument()
70
+ })
71
+
72
+ it('offers no "Use" button for a model that cannot embed', async () => {
73
+ render(<EmbeddingProviderPanel />)
74
+ await screen.findByText('gemma4:latest')
75
+ // Only compatible, non-selected models are pinnable; here that set is empty.
76
+ expect(screen.queryByRole('button', { name: /^use$/i })).not.toBeInTheDocument()
77
+ })
78
+
79
+ it('pins a model when the operator picks one', async () => {
80
+ const withOther = structuredClone(LOCAL_OK)
81
+ withOther.local.candidates.push({
82
+ name: 'other-embed:latest',
83
+ size: 100_000_000,
84
+ family: 'other',
85
+ capabilities: ['embedding'],
86
+ embedding: true,
87
+ dims: 768,
88
+ compatible: true,
89
+ })
90
+ vi.mocked(api.fetchEmbeddingProvider).mockResolvedValue(withOther)
91
+
92
+ render(<EmbeddingProviderPanel />)
93
+ const useBtn = await screen.findByRole('button', { name: /^use$/i })
94
+ await userEvent.click(useBtn)
95
+
96
+ await waitFor(() => expect(api.setEmbeddingModel).toHaveBeenCalledWith('other-embed:latest'))
97
+ })
98
+
99
+ it('reports the hosted fallback state when nothing local answers', async () => {
100
+ const down = structuredClone(LOCAL_OK)
101
+ down.local = {
102
+ ...down.local,
103
+ available: false,
104
+ endpoint: null,
105
+ selected: null,
106
+ candidates: [],
107
+ reason: 'No Ollama endpoint answered on this host or any online mesh peer.',
108
+ }
109
+ vi.mocked(api.fetchEmbeddingProvider).mockResolvedValue(down)
110
+
111
+ render(<EmbeddingProviderPanel />)
112
+ expect(await screen.findByTestId('embed-availability')).toHaveTextContent('hosted fallback')
113
+ expect(screen.getByRole('status')).toHaveTextContent(/No Ollama endpoint answered/)
114
+ })
115
+
116
+ it('surfaces a load failure rather than rendering an empty panel', async () => {
117
+ vi.mocked(api.fetchEmbeddingProvider).mockRejectedValue(new Error('Authentication required'))
118
+ render(<EmbeddingProviderPanel />)
119
+ expect(await screen.findByRole('alert')).toHaveTextContent('Authentication required')
120
+ })
121
+
122
+ it('re-discovers on demand', async () => {
123
+ render(<EmbeddingProviderPanel />)
124
+ await userEvent.click(await screen.findByRole('button', { name: /re-discover/i }))
125
+ await waitFor(() => expect(api.rediscoverEmbeddingProvider).toHaveBeenCalled())
126
+ })
127
+ })
@@ -0,0 +1,177 @@
1
+ import { useCallback, useEffect, useState } from 'react'
2
+
3
+ import {
4
+ fetchEmbeddingProvider,
5
+ setEmbeddingModel,
6
+ rediscoverEmbeddingProvider,
7
+ type EmbeddingProviderStatus,
8
+ type EmbeddingCandidate,
9
+ } from '../api/embeddings'
10
+
11
+ function formatSize(bytes: number | null): string {
12
+ if (!bytes) return 'โ€”'
13
+ if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)} GB`
14
+ return `${Math.round(bytes / 1e6)} MB`
15
+ }
16
+
17
+ /**
18
+ * Explain why a model cannot be used, rather than hiding it. An operator who
19
+ * pulled the wrong model needs to see it listed with the reason attached.
20
+ */
21
+ function incompatibilityReason(c: EmbeddingCandidate, dims: number): string {
22
+ if (!c.embedding) return 'chat model โ€” cannot embed'
23
+ if (c.dims !== dims) return `${c.dims ?? '?'} dims โ€” index needs ${dims}`
24
+ return ''
25
+ }
26
+
27
+ export function EmbeddingProviderPanel() {
28
+ const [status, setStatus] = useState<EmbeddingProviderStatus | null>(null)
29
+ const [error, setError] = useState<string | null>(null)
30
+ const [busy, setBusy] = useState(false)
31
+
32
+ const load = useCallback(async () => {
33
+ try {
34
+ setStatus(await fetchEmbeddingProvider())
35
+ setError(null)
36
+ } catch (err) {
37
+ setError(err instanceof Error ? err.message : String(err))
38
+ }
39
+ }, [])
40
+
41
+ useEffect(() => {
42
+ void load()
43
+ }, [load])
44
+
45
+ const onPin = useCallback(
46
+ async (model: string | null) => {
47
+ setBusy(true)
48
+ try {
49
+ await setEmbeddingModel(model)
50
+ await load()
51
+ setError(null)
52
+ } catch (err) {
53
+ setError(err instanceof Error ? err.message : String(err))
54
+ } finally {
55
+ setBusy(false)
56
+ }
57
+ },
58
+ [load],
59
+ )
60
+
61
+ const onRediscover = useCallback(async () => {
62
+ setBusy(true)
63
+ try {
64
+ await rediscoverEmbeddingProvider()
65
+ await load()
66
+ setError(null)
67
+ } catch (err) {
68
+ setError(err instanceof Error ? err.message : String(err))
69
+ } finally {
70
+ setBusy(false)
71
+ }
72
+ }, [load])
73
+
74
+ if (error && !status) {
75
+ return (
76
+ <div className="panel">
77
+ <h3 style={{ marginTop: 0 }}>Embedding Provider</h3>
78
+ <p role="alert">{error}</p>
79
+ </div>
80
+ )
81
+ }
82
+
83
+ if (!status) {
84
+ return (
85
+ <div className="panel">
86
+ <h3 style={{ marginTop: 0 }}>Embedding Provider</h3>
87
+ <p>Loadingโ€ฆ</p>
88
+ </div>
89
+ )
90
+ }
91
+
92
+ const { local, fallbacks, dims } = status
93
+
94
+ return (
95
+ <div className="panel">
96
+ <div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
97
+ <h3 style={{ margin: 0 }}>Embedding Provider</h3>
98
+ <span
99
+ data-testid="embed-availability"
100
+ style={{ color: local.available ? 'var(--ok, #2ea043)' : 'var(--warn, #d29922)' }}
101
+ >
102
+ {local.available ? 'โ— local' : 'โ—‹ hosted fallback'}
103
+ </span>
104
+ <button type="button" onClick={onRediscover} disabled={busy}>
105
+ Re-discover
106
+ </button>
107
+ </div>
108
+
109
+ <dl style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '4px 12px', margin: '12px 0' }}>
110
+ <dt>Endpoint</dt>
111
+ <dd data-testid="embed-endpoint" style={{ margin: 0 }}>
112
+ {local.endpoint ?? 'none reachable'}
113
+ </dd>
114
+ <dt>Model</dt>
115
+ <dd data-testid="embed-selected" style={{ margin: 0 }}>
116
+ {local.selected ?? 'โ€”'}
117
+ {local.preferred ? ' (pinned)' : local.selected ? ' (auto)' : ''}
118
+ </dd>
119
+ <dt>Vector width</dt>
120
+ <dd style={{ margin: 0 }}>{dims}</dd>
121
+ </dl>
122
+
123
+ {local.reason && <p role="status">{local.reason}</p>}
124
+ {error && <p role="alert">{error}</p>}
125
+
126
+ {local.candidates.length > 0 && (
127
+ <table style={{ width: '100%', borderCollapse: 'collapse' }}>
128
+ <thead>
129
+ <tr>
130
+ <th style={{ textAlign: 'left' }}>Model</th>
131
+ <th style={{ textAlign: 'left' }}>Size</th>
132
+ <th style={{ textAlign: 'left' }}>Status</th>
133
+ <th />
134
+ </tr>
135
+ </thead>
136
+ <tbody>
137
+ {local.candidates.map((c) => {
138
+ const reason = incompatibilityReason(c, dims)
139
+ const isSelected = c.name === local.selected
140
+ return (
141
+ <tr key={c.name}>
142
+ <td>{c.name}</td>
143
+ <td>{formatSize(c.size)}</td>
144
+ <td style={{ opacity: c.compatible ? 1 : 0.6 }}>
145
+ {c.compatible ? `${c.dims} dims` : reason}
146
+ </td>
147
+ <td style={{ textAlign: 'right' }}>
148
+ {c.compatible && !isSelected && (
149
+ <button type="button" onClick={() => onPin(c.name)} disabled={busy}>
150
+ Use
151
+ </button>
152
+ )}
153
+ {isSelected && local.preferred && (
154
+ <button type="button" onClick={() => onPin(null)} disabled={busy}>
155
+ Unpin
156
+ </button>
157
+ )}
158
+ </td>
159
+ </tr>
160
+ )
161
+ })}
162
+ </tbody>
163
+ </table>
164
+ )}
165
+
166
+ <div style={{ marginTop: 12, fontSize: '0.9em', opacity: 0.8 }}>
167
+ Fallback chain:{' '}
168
+ {fallbacks.map((f, i) => (
169
+ <span key={f.provider}>
170
+ {i > 0 && ' โ†’ '}
171
+ <span style={{ textDecoration: f.configured ? 'none' : 'line-through' }}>{f.provider}</span>
172
+ </span>
173
+ ))}
174
+ </div>
175
+ </div>
176
+ )
177
+ }
@@ -24,6 +24,7 @@ import type {
24
24
  } from '../api/mesh';
25
25
  import type { HeadscaleNode, PreAuthKey as HeadscalePreAuthKey, HeadscaleUser } from '../api/headscale';
26
26
  import { MeshTopology } from '../components/MeshTopology';
27
+ import { EmbeddingProviderPanel } from '../components/EmbeddingProviderPanel';
27
28
  import { LatencySparkline } from '../components/LatencySparkline';
28
29
  import './MeshPage.css';
29
30
 
@@ -359,6 +360,10 @@ export function MeshPage() {
359
360
 
360
361
  {activeTab === 'mesh' && (
361
362
  <div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
363
+ {/* Which mesh node actually serves embeddings, and which model it uses. */}
364
+ <div className="card">
365
+ <EmbeddingProviderPanel />
366
+ </div>
362
367
  <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
363
368
  <div className="card">
364
369
  <h3 style={{ marginTop: 0, marginBottom: 12 }}>Topology</h3>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "total-recall-brain",
3
- "version": "3.21.3",
3
+ "version": "3.22.0",
4
4
  "description": "Personal AI system โ€” database-free, SSSS-powered intelligence engine deployable to any POSIX host.",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -331,6 +331,45 @@ npx total-recall secret list # metadata only, no values
331
331
  npx total-recall secret export-env --path <repo> # SSOT โ†’ local .env.local
332
332
  ```
333
333
 
334
+ ### Executed rotation โ€” `rotate-auto` / `rotate-browser` (added 3.22.0)
335
+
336
+ Rotation used to only ever **print instructions**. It now executes. Every key
337
+ resolves to one of four rotation classes, so nothing is silently unrotatable:
338
+
339
+ | Class | How it rotates | Human needed? |
340
+ |---|---|---|
341
+ | `self_generated` | TR mints the value itself (`JWT_SECRET`, `DB_PASSWORD`, `*_WEBHOOK_SECRET`) | **No** |
342
+ | `provider_browser` | TR drives the provider console in its own browser profile | Login/2FA once |
343
+ | `provider_api` | Provider key-management API | No |
344
+ | `manual` | Human-only artifact (SSH keys, recovery codes, OAuth app secrets) | Yes, with reason |
345
+
346
+ ```bash
347
+ npx total-recall secret rotation-status # coverage for every key
348
+ npx total-recall secret rotation-status --json
349
+ npx total-recall secret rotate-auto <key> # pick the right method automatically
350
+ npx total-recall secret rotate-browser <key> # force the console path
351
+ npx total-recall secret rotate-browser <key> --print-only # legacy: just print steps
352
+ npx total-recall secret browser-logout # wipe TR's browser profile
353
+ ```
354
+
355
+ **Browser profile.** Console rotation uses a persistent Chromium profile at
356
+ `<brain>/browser-profile` (created `0700`). You log in once per provider; the
357
+ daemon can then rotate unattended. That directory holds live Stripe/GitHub
358
+ sessions โ€” treat it as **equally sensitive to `secrets.enc`**: never sync it,
359
+ never back it up off-machine, and `browser-logout` when you're done.
360
+
361
+ **Safety invariants** (enforced by `rotation-capability.spec.mjs`):
362
+
363
+ - The new value is read from the browser's own clipboard **inside TR's process** โ€”
364
+ it never transits an argv string, an env var, or a chat transcript.
365
+ - Only recipes marked `verified: true` auto-click. Unverified providers run
366
+ **supervised** โ€” TR opens the console, tells you what to click, and detects the
367
+ result. It never blind-clicks guessed selectors on a billing dashboard.
368
+ - A captured value is shape-checked against the provider pattern and, where an
369
+ API probe exists, **verified to authenticate before it replaces the old one**.
370
+ A failed check stores nothing.
371
+ - The old credential is never revoked until the new one is stored and exported.
372
+
334
373
  ### Remote (production) deploy โ€” `secret remote` (added 3.21.1)
335
374
 
336
375
  `export-env` only ever wrote a **local** file โ€” there was no path from the
@@ -1 +0,0 @@
1
- {"id":"0efa65c2-0231-4267-a304-e0285e50011e","topic":"Total Recall repository improvement research: SSSS validated write pipeline, filesystem-native memory engines, local-first semantic indexing, Express 5 API hardening, 2026 agent memory architecture patterns","status":"pending","priority":"high","notes":"Queued from repo-wide analysis request on 2026-06-01. Focus on current best practices and meta-patterns for filesystem-native memory OS, validated operation envelopes, append-only logs, cache invalidation, research queue architecture, and secure local-first AI tools.","node_slug":null,"research_phase":"acquisition","created_at":"2026-06-01T06:07:40.102Z","updated_at":"2026-06-01T06:07:40.126Z","completed_at":null,"summary":{"id":"0efa65c2-0231-4267-a304-e0285e50011e","topic":"Total Recall repository improvement research: SSSS validated write pipeline, filesystem-native memory engines, local-first semantic indexing, Express 5 API hardening, 2026 agent memory architecture patterns","status":"pending","phase":"acquisition","last_updated":"2026-06-01T06:07:40.126Z","conclusions":[],"ongoing_directions":["Research project is enqueued / in progress in phase: acquisition"],"sources_count":0}}
@@ -29,6 +29,7 @@ import {
29
29
  updateSecretMeta,
30
30
  listRotationDue,
31
31
  getSharedValueHealth,
32
+ loadSecrets,
32
33
  } from '../core/secrets-store.mjs';
33
34
  import {
34
35
  scanEnvSources,
@@ -140,7 +141,13 @@ function printHelp() {
140
141
  --headscale-url <https-url>
141
142
  rotation-due List keys overdue for rotation
142
143
  --enqueue Create daemon tasks + browser-use prompts for each due key
143
- rotate-browser <key> Print supervised browser-use rotation instructions
144
+ rotate-browser <key> Drive the provider console in TR's browser and rotate
145
+ --print-only Only print instructions (legacy behaviour)
146
+ --headless Allow headless for verified recipes
147
+ rotate-auto <key> Rotate by whatever method the key's class supports
148
+ rotation-status Rotation coverage for every key in the vault
149
+ --json Machine-readable output
150
+ browser-logout Delete TR's persistent browser profile (all sessions)
144
151
  usage Cost/events (add --key-ref for per-key)
145
152
 
146
153
  rotate options:
@@ -239,9 +246,18 @@ function parseArgs(args) {
239
246
  }
240
247
  }
241
248
  if (
242
- ['set', 'get', 'rotate', 'delete', 'meta', 'rotate-browser', 'rm', 'generate', 'account-sync'].includes(
243
- out.command,
244
- ) &&
249
+ [
250
+ 'set',
251
+ 'get',
252
+ 'rotate',
253
+ 'delete',
254
+ 'meta',
255
+ 'rotate-browser',
256
+ 'rotate-auto',
257
+ 'rm',
258
+ 'generate',
259
+ 'account-sync',
260
+ ].includes(out.command) &&
245
261
  args[i] &&
246
262
  !args[i].startsWith('--')
247
263
  ) {
@@ -376,6 +392,15 @@ function parseArgs(args) {
376
392
  case '--enqueue':
377
393
  out.enqueue = true;
378
394
  break;
395
+ case '--print-only':
396
+ out.printOnly = true;
397
+ break;
398
+ case '--headless':
399
+ out.headless = true;
400
+ break;
401
+ case '--json':
402
+ out.json = true;
403
+ break;
379
404
  case '--bytes':
380
405
  out.bytes = parseInt(args[++i], 10) || 32;
381
406
  break;
@@ -945,17 +970,73 @@ export default async function secretCli(argv) {
945
970
  return;
946
971
  }
947
972
 
948
- if (opts.command === 'rotate-browser') {
973
+ if (opts.command === 'rotate-browser' || opts.command === 'rotate-auto') {
949
974
  if (!opts.key) {
950
- console.error('Usage: total-recall secret rotate-browser <key>');
975
+ console.error(`Usage: total-recall secret ${opts.command} <key>`);
976
+ process.exit(1);
977
+ }
978
+
979
+ if (opts.printOnly) {
980
+ const assist = await getBrowserRotateAssist(brainDir, opts.key);
981
+ console.log(`\n ๐ŸŒ Supervised browser rotation โ€” ${assist.key}\n`);
982
+ if (assist.console_url) console.log(` Console: ${assist.console_url}`);
983
+ if (assist.docs_url) console.log(` Docs: ${assist.docs_url}`);
984
+ console.log(` Overdue: ${assist.overdue ? 'YES' : 'no'}`);
985
+ console.log('\n' + assist.prompt + '\n');
986
+ return;
987
+ }
988
+
989
+ const { rotateViaBrowser, rotateAuto } = await import('../core/secrets-rotate.mjs');
990
+ const run = opts.command === 'rotate-auto' ? rotateAuto : rotateViaBrowser;
991
+ console.log(`\n ๐Ÿ” Rotating ${opts.key}โ€ฆ\n`);
992
+ const r = await run(brainDir, opts.key, {
993
+ headless: !!opts.headless,
994
+ exportEnv: opts.exportEnv !== false,
995
+ onStatus: (m) => console.log(` ยท ${m}`),
996
+ });
997
+
998
+ if (!r.ok) {
999
+ console.error(`\n โŒ ${r.error}`);
1000
+ if (r.console_url) console.error(` Console: ${r.console_url}`);
951
1001
  process.exit(1);
952
1002
  }
953
- const assist = await getBrowserRotateAssist(brainDir, opts.key);
954
- console.log(`\n ๐ŸŒ Supervised browser rotation โ€” ${assist.key}\n`);
955
- if (assist.console_url) console.log(` Console: ${assist.console_url}`);
956
- if (assist.docs_url) console.log(` Docs: ${assist.docs_url}`);
957
- console.log(` Overdue: ${assist.overdue ? 'YES' : 'no'}`);
958
- console.log('\n' + assist.prompt + '\n');
1003
+ console.log(`\n โœ… Rotated ${r.key}` + (r.method ? ` via ${r.method}` : ''));
1004
+ if (r.verified === true) console.log(' verified against provider API');
1005
+ if (r.supervised) console.log(' (supervised โ€” recipe not yet auto-verified)');
1006
+ if (r.exports?.length) console.log(` exported to ${r.exports.length} project(s)`);
1007
+ if (r.revoke_hint) console.log(`\n โš ๏ธ ${r.revoke_hint}`);
1008
+ return;
1009
+ }
1010
+
1011
+ if (opts.command === 'rotation-status') {
1012
+ const { planAll, summarizePlans } = await import('../core/rotation-capability.mjs');
1013
+ const all = await loadSecrets(brainDir);
1014
+ const keys = Object.keys(all || {}).filter((k) => k !== 'meta');
1015
+ const plans = planAll(keys, all.meta || {});
1016
+ const sum = summarizePlans(plans);
1017
+
1018
+ if (opts.json) {
1019
+ console.log(JSON.stringify({ summary: sum, plans }, null, 2));
1020
+ return;
1021
+ }
1022
+
1023
+ console.log(`\n ๐Ÿ”„ Rotation coverage โ€” ${sum.automatable}/${sum.total} automatable\n`);
1024
+ for (const [cls, n] of Object.entries(sum.byClass)) {
1025
+ console.log(` ${cls.padEnd(18)} ${n}`);
1026
+ }
1027
+ const manual = plans.filter((p) => !p.automatable && p.class !== 'non_secret');
1028
+ if (manual.length) {
1029
+ console.log(`\n Needs a human (${manual.length}):`);
1030
+ for (const p of manual) console.log(` ${p.key.padEnd(34)} ${p.reason}`);
1031
+ }
1032
+ console.log('');
1033
+ return;
1034
+ }
1035
+
1036
+ if (opts.command === 'browser-logout') {
1037
+ const { clearProfile } = await import('../core/browser-session.mjs');
1038
+ const r = clearProfile(brainDir);
1039
+ console.log(r.removed ? ` โœ… Cleared browser profile: ${r.path}` : ` (no profile at ${r.path})`);
959
1040
  return;
960
1041
  }
961
1042