total-recall-brain 3.13.0 → 3.13.2

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 (152) hide show
  1. package/README.md +91 -108
  2. package/bin/antigravity.mjs +81 -52
  3. package/bin/total-recall.mjs +32 -57
  4. package/extension/background.js +1 -1
  5. package/extension/options/options.js +9 -3
  6. package/extension/sidepanel/sidepanel.js +0 -2
  7. package/frontend/index.html +4 -3
  8. package/frontend/package.json +1 -0
  9. package/frontend/src/App.tsx +109 -152
  10. package/frontend/src/api.ts +605 -23
  11. package/frontend/src/components/BrainSelector.tsx +211 -199
  12. package/frontend/src/components/DocumentEditorModal.tsx +57 -0
  13. package/frontend/src/components/DocumentTable.tsx +47 -0
  14. package/frontend/src/components/Graph3D.tsx +120 -71
  15. package/frontend/src/components/UsageChart.tsx +3 -3
  16. package/frontend/src/components/brand/BrandMark.tsx +95 -0
  17. package/frontend/src/index.css +286 -86
  18. package/frontend/src/pages/ApiKeysPage.tsx +1878 -215
  19. package/frontend/src/pages/ChatPage.tsx +103 -45
  20. package/frontend/src/pages/DesignDocsPage.tsx +5 -5
  21. package/frontend/src/pages/FilesPage.tsx +10 -6
  22. package/frontend/src/pages/GraphPage.tsx +5 -3
  23. package/frontend/src/pages/HelpPage.tsx +2 -2
  24. package/frontend/src/pages/InboxPage.tsx +82 -0
  25. package/frontend/src/pages/InstructionsPage.tsx +1 -1
  26. package/frontend/src/pages/IntegrationsPage.tsx +21 -10
  27. package/frontend/src/pages/LoginPage.tsx +95 -77
  28. package/frontend/src/pages/MemoryPage.tsx +31 -8
  29. package/frontend/src/pages/OnboardingPage.tsx +690 -0
  30. package/frontend/src/pages/OpenWikiPage.tsx +412 -323
  31. package/frontend/src/pages/SettingsPage.tsx +825 -922
  32. package/frontend/src/pages/SkillsPage.tsx +272 -147
  33. package/frontend/src/pages/UsagePage.tsx +23 -31
  34. package/frontend/src/pages/VaultPage.tsx +223 -0
  35. package/frontend/src/types.ts +13 -0
  36. package/package.json +4 -4
  37. package/scaffold/.agent/skills/total-recall/SKILL.md +43 -4
  38. package/scaffold/.agent/skills/total-recall/modules/README.md +10 -0
  39. package/scaffold/.agent/skills/total-recall/modules/agents/README.md +5 -0
  40. package/scaffold/.agent/skills/total-recall/{skills/tr-cli-agents → modules/agents}/agents.yml +8 -0
  41. package/scaffold/.agent/skills/total-recall/modules/skill-deploy/README.md +9 -0
  42. package/scaffold/.agent/skills/total-recall/openwiki/README.md +10 -0
  43. package/scaffold/.agent/skills/total-recall/openwiki/architecture.md +5 -0
  44. package/scaffold/.agent/skills/total-recall/openwiki/ide.md +10 -0
  45. package/scaffold/.agent/skills/total-recall/openwiki/memory.md +11 -0
  46. package/scaffold/.agent/skills/total-recall/openwiki/secrets.md +7 -0
  47. package/scaffold/.agent/skills/total-recall/openwiki/skills.md +7 -0
  48. package/src/cli/brain.mjs +63 -5
  49. package/src/cli/connect.mjs +11 -19
  50. package/src/cli/daemon.mjs +5 -4
  51. package/src/cli/deploy-ui.mjs +8 -3
  52. package/src/cli/deploy.mjs +84 -27
  53. package/src/cli/ingest/index.mjs +1 -1
  54. package/src/cli/init.mjs +63 -34
  55. package/src/cli/remember.mjs +3 -3
  56. package/src/cli/secret.mjs +812 -0
  57. package/src/cli/setup.mjs +34 -7
  58. package/src/cli/share.mjs +2 -2
  59. package/src/cli/skill.mjs +396 -35
  60. package/src/cli/sync.mjs +4 -4
  61. package/src/cli/task.mjs +287 -0
  62. package/src/core/clarity-rewriter.mjs +5 -5
  63. package/src/core/config.mjs +44 -2
  64. package/src/core/conflict-detector.mjs +5 -5
  65. package/src/core/crons.mjs +54 -0
  66. package/src/core/daemon-loop.mjs +87 -235
  67. package/src/core/dream.mjs +170 -10
  68. package/src/core/env-import.mjs +456 -0
  69. package/src/core/fact-seeker.mjs +14 -14
  70. package/src/core/import-rules.mjs +20 -7
  71. package/src/core/inference-engine.mjs +3 -3
  72. package/src/core/migrate.mjs +14 -8
  73. package/src/core/okf-adapter.mjs +2 -2
  74. package/src/core/operation-validator.mjs +39 -331
  75. package/src/core/optimizer.mjs +7 -0
  76. package/src/core/post-mortem.mjs +2 -2
  77. package/src/core/project-brain.mjs +349 -0
  78. package/src/core/provider-catalog.mjs +296 -0
  79. package/src/core/quick-capture.mjs +1 -1
  80. package/src/core/remote-vault-sync.mjs +194 -0
  81. package/src/core/research-queue.mjs +7 -11
  82. package/src/core/runtime.mjs +33 -9
  83. package/src/core/scheduler.mjs +59 -5
  84. package/src/core/schema.mjs +14 -0
  85. package/src/core/search.mjs +1 -1
  86. package/src/core/secrets-env-export.mjs +323 -0
  87. package/src/core/secrets-rotate.mjs +245 -0
  88. package/src/core/secrets-store.mjs +645 -0
  89. package/src/core/semantic-index.mjs +3 -3
  90. package/src/core/skills-registry.mjs +1062 -0
  91. package/src/core/source-adapters.mjs +4 -1
  92. package/src/core/ssss-host-extension.mjs +94 -0
  93. package/src/core/ssss-kernel-bridge.mjs +530 -0
  94. package/src/core/surface.mjs +14 -0
  95. package/src/core/task-envelope.mjs +298 -0
  96. package/src/core/task-executors.mjs +600 -0
  97. package/src/core/validated-write.mjs +124 -56
  98. package/src/core/vault.mjs +21 -34
  99. package/src/core/webauthn-store.mjs +272 -0
  100. package/src/server/api.mjs +26 -16
  101. package/src/server/auth.mjs +51 -0
  102. package/src/server/index.mjs +3 -3
  103. package/src/server/keys.mjs +30 -4
  104. package/src/server/rest.mjs +198 -25
  105. package/src/server/route-manifest.json +156 -0
  106. package/src/server/routes/_shared.mjs +40 -1
  107. package/src/server/routes/docs.mjs +260 -0
  108. package/src/server/routes/memory.mjs +145 -32
  109. package/src/server/routes/research.mjs +30 -0
  110. package/src/server/routes/secrets.mjs +470 -0
  111. package/src/server/routes/share.mjs +2 -2
  112. package/src/server/routes/skills.mjs +452 -54
  113. package/src/server/routes/sync.mjs +48 -0
  114. package/src/server/routes/webauthn.mjs +184 -0
  115. package/src/server/update-route-manifest.mjs +39 -0
  116. package/templates/onboarding-interview.md +32 -22
  117. package/templates/openwiki/README.md +10 -0
  118. package/templates/openwiki/architecture.md +5 -0
  119. package/templates/openwiki/ide.md +10 -0
  120. package/templates/openwiki/memory.md +11 -0
  121. package/templates/openwiki/secrets.md +7 -0
  122. package/templates/openwiki/skills.md +7 -0
  123. package/scaffold/.agent/skills/total-recall/skills/tr-cli-agents/SKILL.md +0 -121
  124. package/scaffold/.agent/skills/total-recall/skills/tr-cli-agents/references/antigravity.txt +0 -20
  125. package/scaffold/.agent/skills/total-recall/skills/tr-cli-agents/references/claude.txt +0 -58
  126. package/scaffold/.agent/skills/total-recall/skills/tr-cli-agents/references/codex.txt +0 -81
  127. package/scaffold/.agent/skills/total-recall/skills/tr-cli-agents/references/gemini.txt +0 -44
  128. package/scaffold/.agent/skills/total-recall/skills/tr-cli-agents/scripts/.gitkeep +0 -0
  129. package/scaffold/.agent/skills/total-recall/skills/tr-cli-agents/subagents/.gitkeep +0 -0
  130. package/scaffold/.agent/skills/total-recall/skills/tr-research/SKILL.md +0 -338
  131. package/scaffold/.agent/skills/total-recall/skills/tr-skill/SKILL.md +0 -282
  132. package/scaffold/.agent/skills/total-recall/skills/tr-skill/assets/skill-template/SKILL.md +0 -20
  133. package/scaffold/.agent/skills/total-recall/skills/tr-skill/evals/evals.json +0 -20
  134. package/scaffold/.agent/skills/total-recall/skills/tr-skill/hooks/on-skill-add.sh +0 -14
  135. package/scaffold/.agent/skills/total-recall/skills/tr-skill/hooks/on-skill-delete.sh +0 -14
  136. package/scaffold/.agent/skills/total-recall/skills/tr-skill/learnings/.gitkeep +0 -0
  137. package/scaffold/.agent/skills/total-recall/skills/tr-skill/references/.gitkeep +0 -0
  138. package/scaffold/.agent/skills/total-recall/skills/tr-skill/scripts/.gitkeep +0 -0
  139. package/scaffold/.agent/skills/total-recall/skills/tr-skill/scripts/create-skill.sh +0 -65
  140. package/scaffold/.agent/skills/total-recall/skills/tr-skill/scripts/enforce-skill-optimization.mjs +0 -135
  141. package/scaffold/.agent/skills/total-recall/skills/tr-skill/scripts/skill-watcher.mjs +0 -64
  142. package/scaffold/.agent/skills/total-recall/skills/tr-skill/subagents/audit-all-skills.md +0 -62
  143. package/scaffold/.agent/skills/total-recall/skills/tr-ssss/SKILL.md +0 -298
  144. package/scaffold/.agent/skills/total-recall/skills/tr-ssss/evals/evals.json +0 -17
  145. package/scaffold/.agent/skills/total-recall/skills/tr-ssss/references/CHANGELOG.md +0 -124
  146. package/scaffold/.agent/skills/total-recall/skills/tr-ssss/references/ssss-spec.md +0 -1325
  147. package/scaffold/.agent/skills/total-recall/skills/tr-ssss/scripts/validate-schema.mjs +0 -232
  148. package/scaffold/.agent/skills/total-recall/skills/tr-ssss/subagents/audit-ssss-node.md +0 -9
  149. /package/scaffold/.agent/skills/total-recall/{skills/tr-cli-agents/evals → modules/skill-deploy/scripts}/.gitkeep +0 -0
  150. /package/scaffold/.agent/skills/total-recall/{skills/tr-skill → modules/skill-deploy}/scripts/find-skills.mjs +0 -0
  151. /package/scaffold/.agent/skills/total-recall/{skills/tr-skill → modules/skill-deploy}/scripts/install-skill.mjs +0 -0
  152. /package/scaffold/.agent/skills/total-recall/{skills/tr-skill → modules/skill-deploy}/scripts/scan-skill.mjs +0 -0
@@ -0,0 +1,323 @@
1
+ /**
2
+ * Materialize .env files FROM the Total Recall secrets store.
3
+ *
4
+ * Model: TR secrets.enc is the SSOT for all user API keys.
5
+ * Repo / deploy .env files are projections — never the other way around.
6
+ *
7
+ * Never write secret values into vault markdown, openwiki, or surfaces.
8
+ */
9
+
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import os from 'node:os';
13
+ import { loadSecrets, listSecretsMeta, resolveSecretsPath } from './secrets-store.mjs';
14
+
15
+ const META_KEY = '__tr_secrets_meta';
16
+
17
+ const BEGIN_MARK = '# BEGIN TOTAL-RECALL-SECRETS';
18
+ const END_MARK = '# END TOTAL-RECALL-SECRETS';
19
+
20
+ function envHeader() {
21
+ return `${BEGIN_MARK}
22
+ # Generated by Total Recall — do not hand-edit secret values here.
23
+ # SSOT: secrets store (npx total-recall secret …)
24
+ # Re-export: npx total-recall secret export-env --path <repo>
25
+ # ${new Date().toISOString()}
26
+ `;
27
+ }
28
+
29
+ /**
30
+ * Merge a managed TR secrets block into an existing .env without wiping
31
+ * non-secret / hand-managed keys outside the BEGIN/END markers.
32
+ *
33
+ * @param {string} existing - current file contents (or '')
34
+ * @param {string} managedBody - full block including BEGIN/END marks
35
+ * @returns {string}
36
+ */
37
+ export function mergeEnvManagedBlock(existing, managedBody) {
38
+ const managed = String(managedBody || '').trimEnd() + '\n';
39
+ const text = existing == null ? '' : String(existing);
40
+
41
+ if (!text.trim()) return managed;
42
+
43
+ const beginIdx = text.indexOf(BEGIN_MARK);
44
+ const endIdx = text.indexOf(END_MARK);
45
+
46
+ if (beginIdx !== -1 && endIdx !== -1 && endIdx > beginIdx) {
47
+ const before = text.slice(0, beginIdx).replace(/\s+$/, '');
48
+ const after = text.slice(endIdx + END_MARK.length).replace(/^\r?\n?/, '');
49
+ const parts = [];
50
+ if (before) parts.push(before.endsWith('\n') ? before : `${before}\n`);
51
+ parts.push(managed.endsWith('\n') ? managed : `${managed}\n`);
52
+ if (after.trim()) {
53
+ parts.push(after.startsWith('\n') ? after : `\n${after}`);
54
+ }
55
+ return parts.join('').replace(/\n{3,}/g, '\n\n');
56
+ }
57
+
58
+ // No prior TR block — append managed section after existing content
59
+ const base = text.endsWith('\n') ? text : `${text}\n`;
60
+ return `${base}\n${managed}`;
61
+ }
62
+
63
+ /**
64
+ * Resolve a project slug from a filesystem path (last segment).
65
+ * @param {string} projectPath
66
+ */
67
+ export function projectSlugFromPath(projectPath) {
68
+ if (!projectPath) return null;
69
+ const abs = path.resolve(projectPath);
70
+ return path.basename(abs);
71
+ }
72
+
73
+ /**
74
+ * Load project-registry entries (if any).
75
+ */
76
+ export function loadProjectRegistry() {
77
+ const registryPath = path.join(
78
+ os.homedir(),
79
+ '.agent',
80
+ 'skills',
81
+ 'total-recall',
82
+ 'config',
83
+ 'project-registry.json',
84
+ );
85
+ if (!fs.existsSync(registryPath)) return [];
86
+ try {
87
+ const data = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
88
+ return Array.isArray(data) ? data : [];
89
+ } catch {
90
+ return [];
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Decide whether a secret meta row applies to a target project.
96
+ *
97
+ * @param {object} metaRow - from listSecretsMeta
98
+ * @param {{ projectPath?: string, projectSlug?: string, includeGlobal?: boolean, keys?: string[] }} target
99
+ */
100
+ export function secretMatchesTarget(metaRow, target = {}) {
101
+ if (target.keys?.length && !target.keys.includes(metaRow.key)) return false;
102
+
103
+ const slug = target.projectSlug || projectSlugFromPath(target.projectPath);
104
+ const repos = Array.isArray(metaRow.repos) ? metaRow.repos : [];
105
+ const projectPath = metaRow.project_path || '';
106
+
107
+ // Explicit key list mode
108
+ if (target.keys?.length) return true;
109
+
110
+ // Bound to this repo by slug or path (policy: at most one product repo per key)
111
+ // Multi-repo keys are invalid — do not project them until fixed
112
+ if (repos.length > 1) return false;
113
+ if (slug && repos.some((r) => r === slug || r === '*' || r.endsWith(`/${slug}`))) {
114
+ return true;
115
+ }
116
+ if (target.projectPath && projectPath) {
117
+ const a = path.resolve(target.projectPath);
118
+ const b = path.resolve(projectPath);
119
+ if (a === b || a.startsWith(b + path.sep) || b.startsWith(a + path.sep)) return true;
120
+ }
121
+
122
+ // Unbound secrets: include when includeGlobal (default true for full SSOT export)
123
+ if (target.includeGlobal !== false && repos.length === 0 && !projectPath) {
124
+ return true;
125
+ }
126
+
127
+ // Also include when tagged global scope with empty repos
128
+ if (target.includeGlobal !== false && metaRow.scope === 'global' && repos.length === 0) {
129
+ return true;
130
+ }
131
+
132
+ return false;
133
+ }
134
+
135
+ /**
136
+ * Build dotenv body (KEY=value lines) for matching secrets.
137
+ * @returns {{ body: string, keys: string[], exampleBody: string, count: number }}
138
+ */
139
+ export async function buildEnvProjection(brainDir, target = {}) {
140
+ const secrets = await loadSecrets(brainDir);
141
+ const metaRows = await listSecretsMeta(brainDir);
142
+ const byKey = new Map(metaRows.map((m) => [m.key, m]));
143
+
144
+ const selected = [];
145
+ for (const [key, value] of Object.entries(secrets)) {
146
+ if (key === META_KEY) continue;
147
+ if (value == null || value === '') continue;
148
+ const meta = byKey.get(key) || { key, repos: [], scope: 'global' };
149
+ if (!secretMatchesTarget(meta, target)) continue;
150
+ selected.push({ key, value: String(value), meta });
151
+ }
152
+
153
+ selected.sort((a, b) => a.key.localeCompare(b.key));
154
+
155
+ const lines = selected.map(({ key, value }) => {
156
+ // Quote if needed
157
+ const needsQuote = /[\s#"'$\\]/.test(value);
158
+ const v = needsQuote ? JSON.stringify(value) : value;
159
+ return `${key}=${v}`;
160
+ });
161
+
162
+ const exampleLines = selected.map(({ key }) => `${key}=`);
163
+
164
+ const body = `${envHeader()}${lines.join('\n')}${lines.length ? '\n' : ''}${END_MARK}\n`;
165
+ const exampleBody = `# Example env — names only. Values live in Total Recall secrets store.
166
+ # Generate real .env: npx total-recall secret export-env --path .
167
+ ${exampleLines.join('\n')}${exampleLines.length ? '\n' : ''}`;
168
+
169
+ return {
170
+ body,
171
+ exampleBody,
172
+ keys: selected.map((s) => s.key),
173
+ count: selected.length,
174
+ store: resolveSecretsPath(brainDir),
175
+ };
176
+ }
177
+
178
+ /**
179
+ * Write .env (and optional .env.example) into a project directory.
180
+ *
181
+ * @param {string} brainDir
182
+ * @param {string} projectPath - repo root
183
+ * @param {{
184
+ * filename?: string,
185
+ * example?: boolean,
186
+ * dryRun?: boolean,
187
+ * includeGlobal?: boolean,
188
+ * keys?: string[],
189
+ * mode?: number,
190
+ * }} opts
191
+ */
192
+ export async function exportEnvToProject(brainDir, projectPath, opts = {}) {
193
+ const abs = path.resolve(projectPath);
194
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) {
195
+ throw new Error(`Project path is not a directory: ${abs}`);
196
+ }
197
+
198
+ const filename = opts.filename || '.env';
199
+ const target = {
200
+ projectPath: abs,
201
+ projectSlug: projectSlugFromPath(abs),
202
+ includeGlobal: opts.includeGlobal !== false,
203
+ keys: opts.keys,
204
+ };
205
+
206
+ const projection = await buildEnvProjection(brainDir, target);
207
+ const envPath = path.join(abs, filename);
208
+ const examplePath = path.join(abs, `${filename}.example`);
209
+
210
+ if (opts.dryRun) {
211
+ return {
212
+ dryRun: true,
213
+ envPath,
214
+ examplePath: opts.example !== false ? examplePath : null,
215
+ keys: projection.keys,
216
+ count: projection.count,
217
+ store: projection.store,
218
+ };
219
+ }
220
+
221
+ const mode = opts.mode ?? 0o600;
222
+ // Merge by default so non-secret APP_NAME=… etc. outside the TR block survive
223
+ const replaceAll = opts.replaceAll === true;
224
+ let finalBody = projection.body;
225
+ let merged = false;
226
+ if (!replaceAll && fs.existsSync(envPath)) {
227
+ try {
228
+ const existing = fs.readFileSync(envPath, 'utf8');
229
+ finalBody = mergeEnvManagedBlock(existing, projection.body);
230
+ merged = true;
231
+ } catch {
232
+ finalBody = projection.body;
233
+ }
234
+ }
235
+ fs.writeFileSync(envPath, finalBody, { encoding: 'utf8', mode });
236
+ try {
237
+ fs.chmodSync(envPath, mode);
238
+ } catch {
239
+ /* windows / fs without chmod */
240
+ }
241
+
242
+ let wroteExample = false;
243
+ if (opts.example !== false) {
244
+ fs.writeFileSync(examplePath, projection.exampleBody, { encoding: 'utf8', mode: 0o644 });
245
+ wroteExample = true;
246
+ }
247
+
248
+ // Ensure .env is gitignored
249
+ ensureGitignoreHasEnv(abs);
250
+
251
+ return {
252
+ dryRun: false,
253
+ envPath,
254
+ examplePath: wroteExample ? examplePath : null,
255
+ keys: projection.keys,
256
+ count: projection.count,
257
+ store: projection.store,
258
+ projectSlug: target.projectSlug,
259
+ merged,
260
+ };
261
+ }
262
+
263
+ /**
264
+ * Export to multiple registered projects.
265
+ */
266
+ export async function exportEnvToRegistry(brainDir, opts = {}) {
267
+ const registry = loadProjectRegistry();
268
+ const results = [];
269
+ for (const entry of registry) {
270
+ const projectPath = entry.path || entry.project_root;
271
+ if (!projectPath || !fs.existsSync(projectPath)) {
272
+ results.push({
273
+ name: entry.name,
274
+ path: projectPath,
275
+ ok: false,
276
+ error: 'path missing',
277
+ });
278
+ continue;
279
+ }
280
+ try {
281
+ const r = await exportEnvToProject(brainDir, projectPath, opts);
282
+ results.push({ name: entry.name, ...r, ok: true });
283
+ } catch (err) {
284
+ results.push({ name: entry.name, path: projectPath, ok: false, error: err.message });
285
+ }
286
+ }
287
+ return results;
288
+ }
289
+
290
+ /**
291
+ * Build a deploy payload: dotenv text + optional JSON map (no disk write).
292
+ */
293
+ export async function buildDeploySecretsPayload(brainDir, target = {}) {
294
+ const projection = await buildEnvProjection(brainDir, {
295
+ includeGlobal: true,
296
+ ...target,
297
+ });
298
+ const secrets = await loadSecrets(brainDir);
299
+ const map = {};
300
+ for (const key of projection.keys) {
301
+ if (secrets[key] != null) map[key] = String(secrets[key]);
302
+ }
303
+ return {
304
+ dotenv: projection.body,
305
+ map,
306
+ keys: projection.keys,
307
+ count: projection.count,
308
+ store: projection.store,
309
+ };
310
+ }
311
+
312
+ function ensureGitignoreHasEnv(projectPath) {
313
+ const gi = path.join(projectPath, '.gitignore');
314
+ const linesNeeded = ['.env', '.env.local', '.env.*.local'];
315
+ let content = '';
316
+ if (fs.existsSync(gi)) {
317
+ content = fs.readFileSync(gi, 'utf8');
318
+ }
319
+ const missing = linesNeeded.filter((l) => !content.split(/\r?\n/).some((line) => line.trim() === l));
320
+ if (!missing.length) return;
321
+ const block = `\n# Total Recall secrets projections\n${missing.join('\n')}\n`;
322
+ fs.appendFileSync(gi, content.endsWith('\n') || !content ? block : `\n${block}`);
323
+ }
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Secrets rotation orchestration.
3
+ *
4
+ * - Detect overdue keys (schedule metadata)
5
+ * - Enqueue assisted rotation tasks (optional browser-use)
6
+ * - After rotate: re-export .env to bound repos
7
+ * - TR PATs can be auto-rotated when explicitly requested
8
+ *
9
+ * Never log secret values. Never write values into vault/openwiki.
10
+ */
11
+
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import {
15
+ listRotationDue,
16
+ listSecretsMeta,
17
+ rotateSecret,
18
+ getSecret,
19
+ updateSecretMeta,
20
+ } from './secrets-store.mjs';
21
+ import { exportEnvToProject, exportEnvToRegistry, loadProjectRegistry, projectSlugFromPath } from './secrets-env-export.mjs';
22
+ import { getProvider, providerForKeyName } from './provider-catalog.mjs';
23
+ import { atomicWrite, safeStringify } from './vault.mjs';
24
+
25
+ /**
26
+ * Build a supervised browser-use prompt for rotating one key.
27
+ * Does not perform automation itself.
28
+ */
29
+ export function buildBrowserRotatePrompt(secretMeta) {
30
+ const catalog = getProvider(secretMeta.provider) || providerForKeyName(secretMeta.key);
31
+ const consoleUrl = secretMeta.console_url || catalog?.console_url || '(provider console)';
32
+ const docsUrl = secretMeta.api_docs_url || catalog?.docs_url || '';
33
+
34
+ return [
35
+ `Rotate API credential for secret key: ${secretMeta.key}`,
36
+ `Provider: ${secretMeta.provider || catalog?.id || 'unknown'}`,
37
+ '',
38
+ 'SUPERVISED browser workflow (do NOT auto-revoke until TR confirms store+export):',
39
+ `1. Open: ${consoleUrl}`,
40
+ '2. Complete login / 2FA with the human if needed.',
41
+ '3. Create a NEW API key (do not delete the old one yet).',
42
+ '4. Copy the new key value ONCE — never paste into chat logs or vault markdown.',
43
+ '5. Tell the human to run (or run via tool if value is only in a secure channel):',
44
+ ` npx total-recall secret rotate ${secretMeta.key} "<NEW_VALUE>" --export-env`,
45
+ '6. After TR confirms export succeeded, revoke/disable the OLD key in the console.',
46
+ '',
47
+ docsUrl ? `API docs: ${docsUrl}` : '',
48
+ secretMeta.repos?.length ? `Bound repos: ${secretMeta.repos.join(', ')}` : 'Repos: (unbound / global projection)',
49
+ secretMeta.next_rotate_due ? `Was due: ${secretMeta.next_rotate_due}` : '',
50
+ '',
51
+ 'Safety: never screenshot the full key; never write the value into remember/openwiki.',
52
+ ]
53
+ .filter(Boolean)
54
+ .join('\n');
55
+ }
56
+
57
+ /**
58
+ * Enqueue one queue task per overdue secret (or refresh existing pending).
59
+ * @param {string} brainDir
60
+ * @param {{ autoOnly?: boolean, queueDir?: string }} opts
61
+ */
62
+ export async function enqueueRotationDueTasks(brainDir, opts = {}) {
63
+ const due = await listRotationDue(brainDir, { autoOnly: !!opts.autoOnly });
64
+ const queueDir =
65
+ opts.queueDir || path.join(brainDir, 'scheduler', 'queue');
66
+ if (!fs.existsSync(queueDir)) fs.mkdirSync(queueDir, { recursive: true });
67
+
68
+ const created = [];
69
+ const now = new Date().toISOString();
70
+
71
+ for (const secret of due) {
72
+ const slug = `secret-rotate-${secret.key}`
73
+ .toLowerCase()
74
+ .replace(/[^a-z0-9_-]+/g, '-')
75
+ .slice(0, 80);
76
+ const filePath = path.join(queueDir, `${slug}.md`);
77
+ if (fs.existsSync(filePath)) {
78
+ // leave existing pending task
79
+ created.push({ key: secret.key, slug, status: 'exists' });
80
+ continue;
81
+ }
82
+
83
+ const prompt = buildBrowserRotatePrompt(secret);
84
+ const front = {
85
+ type: 'task',
86
+ slug,
87
+ category: 'secrets-rotation',
88
+ title: `Rotate secret: ${secret.key}`,
89
+ status: 'pending',
90
+ priority: secret.auto_rotate ? 1 : 2,
91
+ executor: 'secrets-rotation-assist',
92
+ capabilities: ['secrets:rotate', 'notify'],
93
+ intent: `Rotate overdue secret ${secret.key} (supervised browser or provider API)`,
94
+ payload: {
95
+ secret_key: secret.key,
96
+ provider: secret.provider,
97
+ auto_rotate: !!secret.auto_rotate,
98
+ console_url: secret.console_url,
99
+ browser_prompt: true,
100
+ },
101
+ created: now,
102
+ updated: now,
103
+ };
104
+
105
+ const body = [
106
+ `## Objective`,
107
+ ``,
108
+ `Rotate \`${secret.key}\` — schedule overdue.`,
109
+ ``,
110
+ `## Browser-use instructions`,
111
+ ``,
112
+ '```',
113
+ prompt,
114
+ '```',
115
+ ``,
116
+ `## After new value is obtained`,
117
+ ``,
118
+ '```bash',
119
+ `npx total-recall secret rotate ${secret.key} "<NEW_VALUE>" --export-env`,
120
+ '```',
121
+ ].join('\n');
122
+
123
+ atomicWrite(filePath, safeStringify(body, front));
124
+ created.push({ key: secret.key, slug, status: 'created', path: filePath });
125
+ }
126
+
127
+ return { due: due.length, tasks: created };
128
+ }
129
+
130
+ /**
131
+ * Rotate value then re-export .env to bound repos (and optionally all registry).
132
+ */
133
+ export async function rotateSecretAndExport(brainDir, key, newValue, opts = {}) {
134
+ const result = await rotateSecret(brainDir, key, newValue, {
135
+ actor: opts.actor || 'cli',
136
+ provider: opts.provider,
137
+ });
138
+
139
+ const metaRows = await listSecretsMeta(brainDir);
140
+ const row = metaRows.find((r) => r.key === key);
141
+ const exports = [];
142
+
143
+ if (opts.exportEnv !== false) {
144
+ const registry = loadProjectRegistry();
145
+ const repos = row?.repos || [];
146
+ const boundPaths = new Set();
147
+
148
+ for (const r of repos) {
149
+ // match registry by name/slug
150
+ for (const entry of registry) {
151
+ if (entry.name === r || projectSlugFromPath(entry.path) === r) {
152
+ if (entry.path && fs.existsSync(entry.path)) boundPaths.add(path.resolve(entry.path));
153
+ }
154
+ }
155
+ // also allow absolute path in repos
156
+ if (r.startsWith('/') && fs.existsSync(r)) boundPaths.add(path.resolve(r));
157
+ }
158
+
159
+ if (row?.project_path && fs.existsSync(row.project_path)) {
160
+ boundPaths.add(path.resolve(row.project_path));
161
+ }
162
+
163
+ if (opts.exportAllProjects) {
164
+ const all = await exportEnvToRegistry(brainDir, {
165
+ includeGlobal: true,
166
+ example: true,
167
+ });
168
+ for (const r of all) exports.push(r);
169
+ } else if (boundPaths.size) {
170
+ for (const p of boundPaths) {
171
+ try {
172
+ const r = await exportEnvToProject(brainDir, p, {
173
+ includeGlobal: opts.includeGlobal !== false,
174
+ example: true,
175
+ });
176
+ exports.push({ ok: true, ...r });
177
+ } catch (err) {
178
+ exports.push({ ok: false, path: p, error: err.message });
179
+ }
180
+ }
181
+ } else if (opts.exportCwd) {
182
+ const r = await exportEnvToProject(brainDir, process.cwd(), {
183
+ includeGlobal: true,
184
+ example: true,
185
+ });
186
+ exports.push({ ok: true, ...r });
187
+ }
188
+ }
189
+
190
+ return {
191
+ ...result,
192
+ secret: row,
193
+ exports,
194
+ };
195
+ }
196
+
197
+ /**
198
+ * Daemon executor: scan due secrets and enqueue assist tasks.
199
+ */
200
+ export async function runSecretsRotationCheck(ctx) {
201
+ const result = await enqueueRotationDueTasks(ctx.brainDir, {
202
+ autoOnly: false,
203
+ queueDir: ctx.queueDir,
204
+ });
205
+ return {
206
+ success: true,
207
+ output: `Rotation check: ${result.due} due, ${result.tasks.filter((t) => t.status === 'created').length} tasks created`,
208
+ executor: 'secrets-rotation-check',
209
+ ...result,
210
+ };
211
+ }
212
+
213
+ /**
214
+ * Daemon executor: re-export all project envs from SSOT.
215
+ */
216
+ export async function runSecretsExportAll(ctx) {
217
+ const results = await exportEnvToRegistry(ctx.brainDir, {
218
+ includeGlobal: true,
219
+ example: true,
220
+ });
221
+ const ok = results.filter((r) => r.ok).length;
222
+ return {
223
+ success: true,
224
+ output: `export-env: ${ok}/${results.length} projects updated`,
225
+ executor: 'secrets-export-env',
226
+ results,
227
+ };
228
+ }
229
+
230
+ /**
231
+ * Print browser prompt for a key (CLI helper).
232
+ */
233
+ export async function getBrowserRotateAssist(brainDir, key) {
234
+ const rows = await listSecretsMeta(brainDir);
235
+ const row = rows.find((r) => r.key === key);
236
+ if (!row) throw new Error(`Secret not found: ${key}`);
237
+ return {
238
+ key,
239
+ prompt: buildBrowserRotatePrompt(row),
240
+ console_url: row.console_url,
241
+ docs_url: row.api_docs_url,
242
+ overdue: row.rotation_overdue,
243
+ next_rotate_due: row.next_rotate_due,
244
+ };
245
+ }