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,456 @@
1
+ /**
2
+ * Scan env sources and import keys into the secrets store.
3
+ *
4
+ * Import is pattern-based (any secret-shaped env name) — NOT a hardcoded list of
5
+ * product APIs. Optional provider tags are best-effort labels for the catalog UI.
6
+ * Never logs or returns full secret values in scan results.
7
+ */
8
+
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import os from 'node:os';
12
+ import { setSecret, listSecretsMeta } from './secrets-store.mjs';
13
+
14
+ /**
15
+ * @deprecated Kept as empty/export for older callers. Import does NOT use this
16
+ * as a whitelist — any secret-shaped key is eligible.
17
+ */
18
+ export const KNOWN_SECRET_KEYS = [];
19
+
20
+ /**
21
+ * Name looks like a secret: ends with KEY/TOKEN/SECRET/PASSWORD/etc.
22
+ * Or contains those segments. Shell noise is skipped.
23
+ */
24
+ const SECRET_NAME_RE =
25
+ /(?:^|_)(API[_-]?KEY|API[_-]?TOKEN|ACCESS[_-]?TOKEN|ACCESS[_-]?KEY|SECRET[_-]?KEY|PRIVATE[_-]?KEY|AUTH[_-]?TOKEN|CLIENT[_-]?SECRET|WEBHOOK[_-]?SECRET|PASSWORD|PASSWD|TOKEN|SECRET|KEY)$/i;
26
+
27
+ /** Obvious non-secrets even if they match loosely */
28
+ const SKIP_KEYS = new Set([
29
+ 'PATH',
30
+ 'HOME',
31
+ 'USER',
32
+ 'SHELL',
33
+ 'PWD',
34
+ 'TMPDIR',
35
+ 'TERM',
36
+ 'LANG',
37
+ 'LC_ALL',
38
+ 'NODE_ENV',
39
+ 'COLORTERM',
40
+ 'SSH_AUTH_SOCK',
41
+ 'DISPLAY',
42
+ 'USERPROFILE',
43
+ 'HOSTNAME',
44
+ 'LOGNAME',
45
+ 'XPC_SERVICE_NAME',
46
+ 'COMMAND_MODE',
47
+ 'SHLVL',
48
+ 'OLDPWD',
49
+ 'TERM_PROGRAM',
50
+ 'TERM_SESSION_ID',
51
+ 'TMP',
52
+ 'TEMP',
53
+ 'EDITOR',
54
+ 'VISUAL',
55
+ 'PAGER',
56
+ 'MANPATH',
57
+ 'INFOPATH',
58
+ 'GOPATH',
59
+ 'GOROOT',
60
+ 'JAVA_HOME',
61
+ 'ANDROID_HOME',
62
+ 'NVM_DIR',
63
+ 'PNPM_HOME',
64
+ 'BUN_INSTALL',
65
+ 'ZSH',
66
+ 'FPATH',
67
+ 'LS_COLORS',
68
+ 'COLORFGBG',
69
+ 'ITERM_SESSION_ID',
70
+ 'TERM_PROGRAM_VERSION',
71
+ 'SSH_AGENT_PID',
72
+ 'XPC_FLAGS',
73
+ 'LaunchInstanceID',
74
+ 'SECURITYSESSIONID',
75
+ '__CFBundleIdentifier',
76
+ '__CF_USER_TEXT_ENCODING',
77
+ ]);
78
+
79
+ /**
80
+ * Infer a short provider *label* from the key name for catalog grouping.
81
+ * Not a whitelist — unknown keys still import; label may be null or derived.
82
+ *
83
+ * Strategy:
84
+ * 1. Optional match against provider-catalog key_patterns (data, not import gate)
85
+ * 2. Else first path segment of KEY_NAME before _API / _SECRET / _TOKEN / _KEY
86
+ *
87
+ * @param {string} key
88
+ * @returns {string|null}
89
+ */
90
+ export function inferProvider(key) {
91
+ if (!key) return null;
92
+ const k = String(key);
93
+
94
+ // Lazy catalog match (optional enrichment only)
95
+ try {
96
+ // Dynamic import avoided in sync path — inline thin match from catalog module if already loadable
97
+ // Use sync require-free approach: duplicate-free segment heuristic only here for speed.
98
+ } catch {
99
+ /* ignore */
100
+ }
101
+
102
+ // Strip common prefixes that are not the vendor
103
+ let base = k
104
+ .replace(/^(DEVELOPER_|VITE_|NEXT_PUBLIC_|PUBLIC_|NUXT_PUBLIC_)/i, '')
105
+ .toUpperCase();
106
+
107
+ // Take leading brand segment(s) before secret suffix
108
+ const stripped = base
109
+ .replace(
110
+ /(_?API_?KEY|_?API_?TOKEN|_?ACCESS_?TOKEN|_?ACCESS_?KEY|_?SECRET_?KEY|_?PRIVATE_?KEY|_?AUTH_?TOKEN|_?CLIENT_?SECRET|_?WEBHOOK_?SECRET|_?PASSWORD|_?PASSWD|_?TOKEN|_?SECRET|_?KEY)$/i,
111
+ '',
112
+ )
113
+ .replace(/^_+|_+$/g, '');
114
+
115
+ if (!stripped) return null;
116
+
117
+ // Normalize multi-word vendors into a slug (OPEN_ROUTER → open-router, GOOGLE_GENERATIVE_AI → google)
118
+ const parts = stripped.toLowerCase().split(/_+/).filter(Boolean);
119
+ if (!parts.length) return null;
120
+
121
+ // Prefer first 1–2 meaningful parts, drop noise words
122
+ const noise = new Set(['api', 'app', 'service', 'role', 'admin', 'user', 'test', 'public', 'private']);
123
+ const useful = parts.filter((p) => !noise.has(p));
124
+ const take = useful.length ? useful : parts;
125
+ if (take.length === 1) return take[0];
126
+ // e.g. google_generative → google; digital_ocean → digitalocean
127
+ if (take[0].length <= 3 && take.length >= 2) return `${take[0]}-${take[1]}`;
128
+ return take[0];
129
+ }
130
+
131
+ /**
132
+ * @param {string} value
133
+ */
134
+ export function maskSecret(value) {
135
+ const s = String(value || '');
136
+ if (s.length <= 8) return '••••••••';
137
+ return `${s.slice(0, 4)}…${s.slice(-4)} (${s.length} chars)`;
138
+ }
139
+
140
+ /**
141
+ * Parse .env-style text into key/value map.
142
+ * @param {string} text
143
+ * @returns {Record<string, string>}
144
+ */
145
+ export function parseEnvText(text) {
146
+ const out = {};
147
+ if (!text || typeof text !== 'string') return out;
148
+ for (const rawLine of text.split(/\r?\n/)) {
149
+ const line = rawLine.trim();
150
+ if (!line || line.startsWith('#')) continue;
151
+ const exportPrefix = line.startsWith('export ') ? line.slice(7).trim() : line;
152
+ const eq = exportPrefix.indexOf('=');
153
+ if (eq <= 0) continue;
154
+ const key = exportPrefix.slice(0, eq).trim();
155
+ let val = exportPrefix.slice(eq + 1).trim();
156
+ if (
157
+ (val.startsWith('"') && val.endsWith('"')) ||
158
+ (val.startsWith("'") && val.endsWith("'"))
159
+ ) {
160
+ val = val.slice(1, -1);
161
+ }
162
+ // strip trailing inline comments for unquoted values
163
+ if (!exportPrefix.slice(eq + 1).trim().startsWith('"') && !exportPrefix.slice(eq + 1).trim().startsWith("'")) {
164
+ val = val.replace(/\s+#.*$/, '').trim();
165
+ }
166
+ if (key && val) out[key] = val;
167
+ }
168
+ return out;
169
+ }
170
+
171
+ /**
172
+ * True if env var *name* looks secret-shaped. No product/API whitelist.
173
+ * @param {string} key
174
+ */
175
+ export function isCandidateKey(key) {
176
+ if (!key || typeof key !== 'string') return false;
177
+ if (SKIP_KEYS.has(key) || SKIP_KEYS.has(key.toUpperCase())) return false;
178
+ // skip pure PATH-like / numeric shell junk
179
+ if (key.length < 4 || key.length > 128) return false;
180
+ if (!/^[A-Za-z_][A-Za-z0-9_.-]*$/.test(key)) return false;
181
+ return SECRET_NAME_RE.test(key);
182
+ }
183
+
184
+ /**
185
+ * Discover .env* files under a directory (shallow, no node_modules).
186
+ * @param {string} root
187
+ * @param {number} maxDepth
188
+ * @param {string[]} out
189
+ * @param {number} depth
190
+ */
191
+ function walkEnvFiles(root, maxDepth, out, depth = 0) {
192
+ if (depth > maxDepth || !root) return;
193
+ let entries;
194
+ try {
195
+ entries = fs.readdirSync(root, { withFileTypes: true });
196
+ } catch {
197
+ return;
198
+ }
199
+ for (const ent of entries) {
200
+ if (ent.name === 'node_modules' || ent.name === '.git' || ent.name === 'dist' || ent.name === 'build') {
201
+ continue;
202
+ }
203
+ const full = path.join(root, ent.name);
204
+ if (ent.isFile() && /^\.env(\.|$)/.test(ent.name)) {
205
+ out.push(full);
206
+ } else if (ent.isDirectory() && !ent.name.startsWith('.') || ent.name === '.agent' || ent.name === '.config') {
207
+ if (ent.name.startsWith('.') && ent.name !== '.agent' && ent.name !== '.config') continue;
208
+ walkEnvFiles(full, maxDepth, out, depth + 1);
209
+ }
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Candidate env file paths — discovered, not a hardcoded product list.
215
+ * @param {{ brainDir?: string, projectRoot?: string, cwd?: string, extraFiles?: string[], discoverRoot?: string, maxDepth?: number }} opts
216
+ */
217
+ export function defaultEnvFilePaths(opts = {}) {
218
+ const home = os.homedir();
219
+ const cwd = opts.cwd || process.cwd();
220
+ const paths = [
221
+ path.join(home, '.env'),
222
+ path.join(home, '.agent', '.env'),
223
+ path.join(home, '.agent', 'skills', 'total-recall', '.env'),
224
+ path.join(home, '.agent', 'skills', 'total-recall', 'config', '.env'),
225
+ path.join(home, '.config', 'total-recall', '.env'),
226
+ path.join(cwd, '.env'),
227
+ path.join(cwd, '.env.local'),
228
+ path.join(cwd, '.env.development'),
229
+ path.join(cwd, '.env.production'),
230
+ ];
231
+ if (opts.brainDir) {
232
+ paths.push(path.join(opts.brainDir, '.env'));
233
+ paths.push(path.join(opts.brainDir, 'config', '.env'));
234
+ }
235
+ if (opts.projectRoot) {
236
+ paths.push(path.join(opts.projectRoot, '.env'));
237
+ paths.push(path.join(opts.projectRoot, '.env.local'));
238
+ }
239
+ if (opts.extraFiles && Array.isArray(opts.extraFiles)) {
240
+ paths.push(...opts.extraFiles);
241
+ }
242
+
243
+ // Auto-discover under ~/Github (or discoverRoot) without naming products
244
+ const discoverRoot = opts.discoverRoot ?? path.join(home, 'Github');
245
+ const maxDepth = opts.maxDepth ?? 3;
246
+ if (discoverRoot && fs.existsSync(discoverRoot)) {
247
+ walkEnvFiles(discoverRoot, maxDepth, paths, 0);
248
+ }
249
+ // Also discover under home/.agent
250
+ walkEnvFiles(path.join(home, '.agent'), 4, paths, 0);
251
+
252
+ return [...new Set(paths)];
253
+ }
254
+
255
+ /**
256
+ * @param {Record<string, string>} map
257
+ * @param {string} source
258
+ * @param {Map<string, object>} into
259
+ */
260
+ function mergeCandidates(map, source, into) {
261
+ for (const [key, value] of Object.entries(map)) {
262
+ if (!isCandidateKey(key)) continue;
263
+ if (!value || !String(value).trim()) continue;
264
+ const existing = into.get(key);
265
+ // Prefer first non-process.env file sources over later process.env unless only env
266
+ if (existing && existing.source !== 'process.env' && source === 'process.env') continue;
267
+ if (existing && existing.source === source) continue;
268
+ into.set(key, {
269
+ key,
270
+ source,
271
+ provider: inferProvider(key),
272
+ masked: maskSecret(value),
273
+ length: String(value).length,
274
+ known: true, // all pattern-matched secrets are first-class; no API whitelist
275
+ // internal only — stripped before API response
276
+ _value: String(value),
277
+ });
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Scan process.env + default env files.
283
+ * @param {{ brainDir?: string, projectRoot?: string, cwd?: string, includeProcessEnv?: boolean }} opts
284
+ */
285
+ export function scanEnvSources(opts = {}) {
286
+ const includeProcessEnv = opts.includeProcessEnv !== false;
287
+ const candidates = new Map();
288
+
289
+ for (const filePath of defaultEnvFilePaths(opts)) {
290
+ try {
291
+ if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) continue;
292
+ const text = fs.readFileSync(filePath, 'utf8');
293
+ mergeCandidates(parseEnvText(text), filePath, candidates);
294
+ } catch {
295
+ // unreadable path — skip
296
+ }
297
+ }
298
+
299
+ if (includeProcessEnv) {
300
+ const envMap = {};
301
+ for (const [k, v] of Object.entries(process.env)) {
302
+ if (typeof v === 'string' && v) envMap[k] = v;
303
+ }
304
+ mergeCandidates(envMap, 'process.env', candidates);
305
+ }
306
+
307
+ const list = [...candidates.values()].sort((a, b) => a.key.localeCompare(b.key));
308
+ return {
309
+ candidates: list,
310
+ sources_scanned: [
311
+ ...(includeProcessEnv ? ['process.env'] : []),
312
+ ...defaultEnvFilePaths(opts),
313
+ ],
314
+ };
315
+ }
316
+
317
+ /**
318
+ * Public scan result (no raw values).
319
+ */
320
+ export function publicScanResult(scan, existingKeys = new Set()) {
321
+ return {
322
+ candidates: scan.candidates.map(({ key, source, provider, masked, length, known }) => ({
323
+ key,
324
+ source: source === 'process.env' ? 'process.env' : path.basename(source) === source ? source : source,
325
+ source_label: source === 'process.env' ? 'shell environment' : shortenPath(source),
326
+ provider,
327
+ masked,
328
+ length,
329
+ known,
330
+ already_set: existingKeys.has(key),
331
+ })),
332
+ count: scan.candidates.length,
333
+ sources_scanned: scan.sources_scanned.map((s) =>
334
+ s === 'process.env' ? s : shortenPath(s),
335
+ ),
336
+ };
337
+ }
338
+
339
+ function shortenPath(p) {
340
+ const home = os.homedir();
341
+ if (p.startsWith(home)) return `~${p.slice(home.length)}`;
342
+ return p;
343
+ }
344
+
345
+ /**
346
+ * Import selected keys into secrets store.
347
+ * @param {string} brainDir
348
+ * @param {{ keys?: string[], pairs?: Record<string,string>, overwrite?: boolean, actor?: string }} opts
349
+ * - keys: import from scan (must re-scan for values)
350
+ * - pairs: explicit key→value (from paste)
351
+ */
352
+ export async function importEnvSecrets(brainDir, opts = {}) {
353
+ const overwrite = !!opts.overwrite;
354
+ const actor = opts.actor || 'import';
355
+ const existing = await listSecretsMeta(brainDir);
356
+ const existingSet = new Set(existing.map((e) => e.key));
357
+
358
+ /** @type {Record<string, string>} */
359
+ let values = {};
360
+
361
+ if (opts.pairs && typeof opts.pairs === 'object') {
362
+ for (const [k, v] of Object.entries(opts.pairs)) {
363
+ if (isCandidateKey(k) && v) values[k] = String(v);
364
+ }
365
+ }
366
+
367
+ /** @type {Record<string, string>} */
368
+ const sourceByKey = { ...(opts.sourceByKey || {}) };
369
+
370
+ if (Array.isArray(opts.keys) && opts.keys.length) {
371
+ const scan = scanEnvSources({ brainDir, includeProcessEnv: true });
372
+ const byKey = new Map(scan.candidates.map((c) => [c.key, c]));
373
+ for (const k of opts.keys) {
374
+ const c = byKey.get(k);
375
+ if (c?._value) {
376
+ values[k] = c._value;
377
+ sourceByKey[k] = c.source;
378
+ }
379
+ }
380
+ }
381
+
382
+ // If only "import all" with empty keys and no pairs — import everything from scan
383
+ if (!opts.pairs && (!opts.keys || opts.keys.length === 0) && opts.all) {
384
+ const scan = scanEnvSources({ brainDir, includeProcessEnv: true });
385
+ for (const c of scan.candidates) {
386
+ if (c._value) {
387
+ values[c.key] = c._value;
388
+ sourceByKey[c.key] = c.source;
389
+ }
390
+ }
391
+ }
392
+
393
+ const imported = [];
394
+ const skipped = [];
395
+ const errors = [];
396
+
397
+ for (const [key, value] of Object.entries(values)) {
398
+ if (existingSet.has(key) && !overwrite) {
399
+ skipped.push({ key, reason: 'already_set' });
400
+ continue;
401
+ }
402
+ try {
403
+ const src = sourceByKey[key];
404
+ let repos;
405
+ if (src && typeof src === 'string' && src !== 'process.env' && src !== 'paste') {
406
+ // e.g. .../Github/ultrachat-ai-powered/.env → ultrachat-ai-powered
407
+ const parts = src.replace(/\\/g, '/').split('/');
408
+ const envIdx = parts.findIndex((p) => p.startsWith('.env'));
409
+ if (envIdx > 0) repos = [parts[envIdx - 1]];
410
+ }
411
+ await setSecret(brainDir, key, value, {
412
+ provider: inferProvider(key),
413
+ scope: 'global',
414
+ repos,
415
+ actor,
416
+ });
417
+ imported.push({ key, provider: inferProvider(key), repos });
418
+ existingSet.add(key);
419
+ } catch (err) {
420
+ errors.push({ key, error: err.message });
421
+ }
422
+ }
423
+
424
+ return {
425
+ imported,
426
+ skipped,
427
+ errors,
428
+ imported_count: imported.length,
429
+ skipped_count: skipped.length,
430
+ };
431
+ }
432
+
433
+ /**
434
+ * Parse pasted .env and return public candidates + internal pairs for import.
435
+ * @param {string} text
436
+ */
437
+ export function candidatesFromPaste(text) {
438
+ const map = parseEnvText(text);
439
+ const candidates = [];
440
+ const pairs = {};
441
+ for (const [key, value] of Object.entries(map)) {
442
+ if (!isCandidateKey(key) || !value) continue;
443
+ candidates.push({
444
+ key,
445
+ source: 'paste',
446
+ source_label: 'pasted .env',
447
+ provider: inferProvider(key),
448
+ masked: maskSecret(value),
449
+ length: String(value).length,
450
+ known: true,
451
+ });
452
+ pairs[key] = value;
453
+ }
454
+ candidates.sort((a, b) => a.key.localeCompare(b.key));
455
+ return { candidates, pairs };
456
+ }
@@ -115,7 +115,7 @@ export function addToAgenda({ topic, priority = 50, source, rationale = '', tags
115
115
  if (directInstruction) existing.direct_instruction = true;
116
116
  } else {
117
117
  agenda.push({
118
- id: crypto.randomBytes(4).toString('hex'),
118
+ id: crypto.createHash('md5').update(topic).digest('hex').slice(0, 8),
119
119
  topic,
120
120
  priority,
121
121
  source,
@@ -668,10 +668,10 @@ async function writeAndSurfaceImmediately(topic, synthesis, sourceResults, {
668
668
 
669
669
  // Write supporting nodes to vault
670
670
  for (const node of supportingNodes) {
671
- writeNode({ ...node.frontmatter, body: node.body }, vaultDir);
671
+ await writeNode({ ...node.frontmatter, body: node.body }, vaultDir);
672
672
  }
673
673
  // Write master node to vault
674
- writeNode({ ...masterNode.frontmatter, body: masterNode.body }, vaultDir);
674
+ await writeNode({ ...masterNode.frontmatter, body: masterNode.body }, vaultDir);
675
675
 
676
676
  for (const sr of sourceResults) {
677
677
  try { registerSource(sr, masterNode.slug); } catch (err) { logger.debug('fact-seeker: registerSource fast-path failed', { err: err.message }); }
@@ -1138,7 +1138,7 @@ Output ONLY valid JSON.`;
1138
1138
  otherRelated.add(nodeSlug);
1139
1139
  otherNode.related = Array.from(otherRelated);
1140
1140
  otherNode.updated = new Date().toISOString();
1141
- writeNode(otherNode, vaultDir);
1141
+ await writeNode(otherNode, vaultDir);
1142
1142
  }
1143
1143
  }
1144
1144
  }
@@ -1159,7 +1159,7 @@ Output ONLY valid JSON.`;
1159
1159
  otherContradicts.add(nodeSlug);
1160
1160
  otherNode.contradicts = Array.from(otherContradicts);
1161
1161
  otherNode.updated = new Date().toISOString();
1162
- writeNode(otherNode, vaultDir);
1162
+ await writeNode(otherNode, vaultDir);
1163
1163
  }
1164
1164
  }
1165
1165
  }
@@ -1178,7 +1178,7 @@ Output ONLY valid JSON.`;
1178
1178
  if (otherNode) {
1179
1179
  otherNode.superseded_by = nodeSlug;
1180
1180
  otherNode.updated = new Date().toISOString();
1181
- writeNode(otherNode, vaultDir);
1181
+ await writeNode(otherNode, vaultDir);
1182
1182
  }
1183
1183
  }
1184
1184
  }
@@ -1200,7 +1200,7 @@ Output ONLY valid JSON.`;
1200
1200
  updated: new Date().toISOString()
1201
1201
  };
1202
1202
 
1203
- writeNode(updatedNode, vaultDir);
1203
+ await writeNode(updatedNode, vaultDir);
1204
1204
 
1205
1205
  const conclusions = [];
1206
1206
  if (result.insights && result.insights.length > 0) {
@@ -1221,7 +1221,7 @@ Output ONLY valid JSON.`;
1221
1221
 
1222
1222
  if (result.synthesized_instructions && result.synthesized_instructions.length > 0) {
1223
1223
  for (const inst of result.synthesized_instructions) {
1224
- const ruleSlug = `rule-synth-${crypto.randomBytes(5).toString('hex')}`;
1224
+ const ruleSlug = `rule-synth-${crypto.createHash('md5').update(inst.title || inst.content || '').digest('hex').slice(0, 10)}`;
1225
1225
  const ruleNode = {
1226
1226
  type: 'memory',
1227
1227
  slug: ruleSlug,
@@ -1267,7 +1267,7 @@ Output ONLY valid JSON.`;
1267
1267
  body: inst.body || ''
1268
1268
  };
1269
1269
 
1270
- writeNode(ruleNode, vaultDir);
1270
+ await writeNode(ruleNode, vaultDir);
1271
1271
  rulesSynthesized = true;
1272
1272
  logger.info({
1273
1273
  subsystem: 'fact-seeker',
@@ -1301,7 +1301,7 @@ Output ONLY valid JSON.`;
1301
1301
  const queueDir = path.join(localBrainDir, 'scheduler', 'queue');
1302
1302
  const { persistTaskToDisk } = await import('./scheduler.mjs');
1303
1303
  for (const t of result.autonomous_tasks) {
1304
- const taskSlug = `delib-task-${crypto.randomBytes(4).toString('hex')}`;
1304
+ const taskSlug = `delib-task-${crypto.createHash('md5').update(t.task || t.topic || t.description || '').digest('hex').slice(0, 8)}`;
1305
1305
  const taskNode = {
1306
1306
  type: 'task',
1307
1307
  slug: taskSlug,
@@ -1389,7 +1389,7 @@ Output ONLY the raw markdown of the polished body, starting immediately with no
1389
1389
  updated: new Date().toISOString()
1390
1390
  };
1391
1391
 
1392
- writeNode(updatedNode, vaultDir);
1392
+ await writeNode(updatedNode, vaultDir);
1393
1393
 
1394
1394
  return {
1395
1395
  success: true,
@@ -1486,7 +1486,7 @@ Output ONLY valid JSON.`;
1486
1486
  updated: new Date().toISOString()
1487
1487
  };
1488
1488
 
1489
- writeNode(updatedNode, vaultDir);
1489
+ await writeNode(updatedNode, vaultDir);
1490
1490
 
1491
1491
  // Trigger immediate surface recompilation if active rules/sources were added
1492
1492
  if (skillsDir && derivedDir && instructionsFile) {
@@ -1578,7 +1578,7 @@ Output ONLY valid JSON.`;
1578
1578
 
1579
1579
  // Also schedule a scheduler task to run proactive research for this topic
1580
1580
  const prio = tangent.priority === 'high' ? 65 : (tangent.priority === 'low' ? 45 : 55);
1581
- const taskSlug = `proactive-research-${crypto.randomBytes(4).toString('hex')}`;
1581
+ const taskSlug = `proactive-research-${crypto.createHash('md5').update(tangent.topic || '').digest('hex').slice(0, 8)}`;
1582
1582
  const taskNode = {
1583
1583
  type: 'task',
1584
1584
  slug: taskSlug,
@@ -1618,7 +1618,7 @@ Output ONLY valid JSON.`;
1618
1618
  updated: new Date().toISOString()
1619
1619
  };
1620
1620
 
1621
- writeNode(updatedNode, vaultDir);
1621
+ await writeNode(updatedNode, vaultDir);
1622
1622
 
1623
1623
  return {
1624
1624
  success: true,
@@ -208,7 +208,7 @@ export function detectRuleFiles(dirs) {
208
208
  * @param {{ force?: boolean, vaultDir?: string }} opts
209
209
  * @returns {{ imported: object[], skipped: object[], failed: object[] }}
210
210
  */
211
- export function importRuleFiles(files, { force = false, vaultDir = VAULT_DIR } = {}) {
211
+ export async function importRuleFiles(files, { force = false, vaultDir = VAULT_DIR } = {}) {
212
212
  const imported = [], skipped = [], failed = [];
213
213
 
214
214
  for (const file of files) {
@@ -225,23 +225,35 @@ export function importRuleFiles(files, { force = false, vaultDir = VAULT_DIR } =
225
225
  type: 'memory',
226
226
  slug: file.slug,
227
227
  title: `Imported rules: ${file.filename}`,
228
- category: 'instructions',
228
+ description: `Imported IDE rules from ${file.filename}`,
229
+ timestamp: new Date().toISOString(),
230
+ // Package memory.category enum — host folder preserved as tag folder:instructions
231
+ category: 'preferences',
229
232
  status: 'active',
230
233
  confidence: 1.0,
231
234
  importance: 5,
232
- tags: ['imported-rules', file.ide, path.basename(file.filename)],
235
+ schema_version: 2,
236
+ modality: 'must',
237
+ subject: 'agent',
238
+ predicate: 'follow',
239
+ object: 'imported-rules',
240
+ sentiment_polarity: 'directive_must',
241
+ sentiment_target: 'agent',
242
+ tags: ['imported-rules', 'folder:instructions', file.ide, path.basename(file.filename)].filter(Boolean),
233
243
  created: new Date().toISOString(),
234
244
  updated: new Date().toISOString(),
235
245
  last_accessed: new Date().toISOString(),
236
246
  source: {
237
247
  type: 'import',
248
+ session_id: `import-${file.slug}`,
249
+ evidence_count: 1,
238
250
  file: file.absolutePath,
239
251
  imported_at: new Date().toISOString(),
240
252
  },
241
253
  body: content,
242
254
  };
243
255
 
244
- writeNode(node, vaultDir);
256
+ await writeNode(node, vaultDir);
245
257
  imported.push({ slug: node.slug, title: node.title, file: file.absolutePath, size: file.size });
246
258
  } catch (err) {
247
259
  failed.push({ ...file, error: err.message });
@@ -256,7 +268,7 @@ export function importRuleFiles(files, { force = false, vaultDir = VAULT_DIR } =
256
268
  *
257
269
  * @param {{ dirs?: string[], force?: boolean, vaultDir?: string, dryRun?: boolean }} opts
258
270
  */
259
- export function detectAndImport({ dirs, force = false, vaultDir = VAULT_DIR, dryRun = false } = {}) {
271
+ export async function detectAndImport({ dirs, force = false, vaultDir = VAULT_DIR, dryRun = false } = {}) {
260
272
  const detected = detectRuleFiles(dirs);
261
273
  const toImport = detected.filter(f => !f.alreadyImported || force);
262
274
 
@@ -264,7 +276,7 @@ export function detectAndImport({ dirs, force = false, vaultDir = VAULT_DIR, dry
264
276
  return { detected, toImport, imported: [], skipped: detected.filter(f => f.alreadyImported && !force), failed: [], dryRun: true };
265
277
  }
266
278
 
267
- const { imported, skipped, failed } = importRuleFiles(toImport, { force, vaultDir });
279
+ const { imported, skipped, failed } = await importRuleFiles(toImport, { force, vaultDir });
268
280
  return { detected, imported, skipped, failed, dryRun: false };
269
281
  }
270
282
 
@@ -278,7 +290,8 @@ function makeSlug(filename, absPath) {
278
290
  }
279
291
 
280
292
  function healSymlinkTarget(target) {
281
- let healed = target.replace('/ultrachat/ultrachat-ai-powered/', '/ultrachat-ai-powered/');
293
+ // Generic heal only — never special-case product/repo path segments
294
+ let healed = target;
282
295
  if (fs.existsSync(healed)) return healed;
283
296
 
284
297
  const skillMatch = healed.match(/\/(\.agents?)\/skills\/([^\/]+)\/(SKILL\.md)$/i);
@@ -147,7 +147,7 @@ export async function runInferenceTask(slugs, { vaultDir, inboxDir, runtimeConfi
147
147
  // ─── Node Writers ───────────────────────────────────────────────────────────────
148
148
 
149
149
  function writeConclusionNode(inboxDir, conclusion, sourceSlugList, vaultDir) {
150
- const slug = `inference-${crypto.randomBytes(4).toString('hex')}`;
150
+ const slug = `inference-${crypto.createHash('md5').update(conclusion.title || conclusion.description || JSON.stringify(conclusion)).digest('hex').slice(0, 8)}`;
151
151
  const now = new Date().toISOString();
152
152
 
153
153
  let latestPublished = null;
@@ -239,7 +239,7 @@ function writeContradictionRecord(conflictsDir, contradiction) {
239
239
  fs.mkdirSync(conflictsDir, { recursive: true });
240
240
  }
241
241
 
242
- const slug = `contradiction-${crypto.randomBytes(4).toString('hex')}`;
242
+ const slug = `contradiction-${crypto.createHash('md5').update(contradiction.node_a + contradiction.node_b).digest('hex').slice(0, 8)}`;
243
243
  const now = new Date().toISOString();
244
244
 
245
245
  const data = {
@@ -264,7 +264,7 @@ function writeMergeProposal(proposalsDir, candidate) {
264
264
  fs.mkdirSync(proposalsDir, { recursive: true });
265
265
  }
266
266
 
267
- const slug = `merge-proposal-${crypto.randomBytes(4).toString('hex')}`;
267
+ const slug = `merge-proposal-${crypto.createHash('md5').update(candidate.node_a + candidate.node_b).digest('hex').slice(0, 8)}`;
268
268
  const now = new Date().toISOString();
269
269
 
270
270
  const data = {