total-recall-brain 3.28.2 → 3.30.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 (134) hide show
  1. package/bin/total-recall.mjs +24 -1
  2. package/docs/ARCHITECTURE.md +26 -18
  3. package/extension/background.js +259 -84
  4. package/extension/content-script.css +7 -13
  5. package/extension/content-script.js +252 -477
  6. package/extension/icons/icon-128.png +0 -0
  7. package/extension/icons/icon-16.png +0 -0
  8. package/extension/icons/icon-32.png +0 -0
  9. package/extension/icons/icon-48.png +0 -0
  10. package/extension/icons/mark.svg +6 -0
  11. package/extension/lib/brain-client.js +160 -73
  12. package/extension/lib/tokens.css +187 -0
  13. package/extension/lib/ui-helpers.js +229 -0
  14. package/extension/lib/ui-helpers.spec.mjs +94 -0
  15. package/extension/manifest.json +20 -5
  16. package/extension/options/options.css +64 -218
  17. package/extension/options/options.html +91 -43
  18. package/extension/options/options.js +208 -85
  19. package/extension/sidepanel/sidepanel.css +268 -970
  20. package/extension/sidepanel/sidepanel.html +200 -166
  21. package/extension/sidepanel/sidepanel.js +788 -942
  22. package/frontend/dist/assets/index-DGMNZi_1.js +5742 -0
  23. package/frontend/dist/index.html +1 -1
  24. package/frontend/src/api/plugins.ts +124 -92
  25. package/frontend/src/components/ResearchAgendaTab.spec.tsx +11 -0
  26. package/frontend/src/components/ResearchAgendaTab.tsx +19 -14
  27. package/frontend/src/pages/PluginsPage.spec.tsx +87 -16
  28. package/frontend/src/pages/PluginsPage.tsx +454 -1218
  29. package/package.json +2 -1
  30. package/plugins/code-quality/README.md +25 -0
  31. package/plugins/code-quality/cli.mjs +25 -0
  32. package/plugins/code-quality/plugin.json +17 -0
  33. package/plugins/git-sentinel/README.md +27 -0
  34. package/{scaffold/.agent/plugins → plugins}/git-sentinel/cli.mjs +8 -5
  35. package/plugins/git-sentinel/plugin.json +18 -0
  36. package/plugins/system-monitor/README.md +26 -0
  37. package/{scaffold/.agent/plugins → plugins}/system-monitor/cli.mjs +5 -4
  38. package/{scaffold/.agent/plugins → plugins}/system-monitor/generator.mjs +4 -5
  39. package/plugins/system-monitor/plugin.json +29 -0
  40. package/scaffold/.agent/skills/meta-harness/SKILL.md +4 -4
  41. package/scaffold/.agent/skills/plugins/SKILL.md +53 -0
  42. package/scaffold/.agent/skills/total-recall/SKILL.md +22 -6
  43. package/scaffold/.agent/skills/total-recall/scripts/brain-state.json +1 -0
  44. package/src/cli/agent.mjs +1 -1
  45. package/src/cli/app/index.mjs +493 -0
  46. package/src/cli/brain.mjs +33 -3
  47. package/src/cli/command.mjs +80 -41
  48. package/src/cli/connect.mjs +10 -1
  49. package/src/cli/harness.mjs +1 -1
  50. package/src/cli/init.mjs +3 -1
  51. package/src/cli/mesh.mjs +118 -12
  52. package/src/cli/plugin/create.mjs +144 -25
  53. package/src/cli/plugin/index.mjs +83 -53
  54. package/src/cli/plugin/install.mjs +10 -127
  55. package/src/cli/plugin/list.mjs +44 -44
  56. package/src/cli/plugin/remove.mjs +23 -30
  57. package/src/cli/plugin/scaffold-cmd.mjs +30 -0
  58. package/src/cli/plugin/search.mjs +108 -23
  59. package/src/cli/rebuild.mjs +37 -1
  60. package/src/cli/recall.mjs +17 -2
  61. package/src/cli/research.mjs +4 -6
  62. package/src/cli/share.mjs +2 -0
  63. package/src/core/app-deploy/apply.mjs +386 -0
  64. package/src/core/app-deploy/design-tokens.mjs +217 -0
  65. package/src/core/app-deploy/plan.mjs +249 -0
  66. package/src/core/app-deploy/resolve.mjs +295 -0
  67. package/src/core/app-deploy/source.mjs +337 -0
  68. package/src/core/app-deploy/standalone.mjs +248 -0
  69. package/src/core/app-deploy/upgrade.mjs +256 -0
  70. package/src/core/app-deploy/verify.mjs +213 -0
  71. package/src/core/bound-hosts.mjs +1 -1
  72. package/src/core/brain-registry.mjs +146 -0
  73. package/src/core/brain-state.json +1 -0
  74. package/src/core/daemon-loop.mjs +30 -26
  75. package/src/core/dream.mjs +5 -17
  76. package/src/core/fact-seeker.mjs +53 -368
  77. package/src/core/lan-discovery.mjs +3 -3
  78. package/src/core/mesh-access-sync.mjs +134 -0
  79. package/src/core/mesh-access.mjs +18 -3
  80. package/src/core/mesh-discover.mjs +173 -0
  81. package/src/core/mesh.mjs +131 -32
  82. package/src/core/optimizer.mjs +4 -41
  83. package/src/core/plugin-bundle.mjs +167 -0
  84. package/src/core/plugin-context.mjs +8 -13
  85. package/src/core/plugin-loader.mjs +272 -68
  86. package/src/core/plugin-peers.mjs +163 -0
  87. package/src/core/plugin-public.mjs +85 -0
  88. package/src/core/plugin-runner-child.mjs +24 -0
  89. package/src/core/plugin-runner.mjs +102 -0
  90. package/src/core/plugin-store.mjs +384 -0
  91. package/src/core/plugin-tasks.mjs +212 -0
  92. package/src/core/post-mortem.mjs +40 -9
  93. package/src/core/research-gate.mjs +182 -0
  94. package/src/core/research-queue.mjs +47 -7
  95. package/src/core/research-surface.mjs +211 -0
  96. package/src/core/research.mjs +25 -32
  97. package/src/core/scheduler.mjs +67 -153
  98. package/src/core/schema.mjs +129 -0
  99. package/src/core/search.mjs +12 -1
  100. package/src/core/secret-integration-research.mjs +12 -8
  101. package/src/core/secrets-store.mjs +76 -2
  102. package/src/core/session-watcher.mjs +6 -0
  103. package/src/core/surface.mjs +64 -3
  104. package/src/core/task-executors.mjs +8 -45
  105. package/src/core/vault-cache.mjs +70 -4
  106. package/src/server/api.mjs +21 -0
  107. package/src/server/rest.mjs +2 -0
  108. package/src/server/route-manifest.json +21 -5
  109. package/src/server/routes/collab.mjs +38 -11
  110. package/src/server/routes/extension.mjs +3 -2
  111. package/src/server/routes/memory.mjs +24 -2
  112. package/src/server/routes/mesh.mjs +7 -7
  113. package/src/server/routes/plugins-mesh.mjs +49 -0
  114. package/src/server/routes/plugins.mjs +91 -426
  115. package/src/server/routes/research.mjs +5 -2
  116. package/src/server/routes/share.mjs +2 -0
  117. package/src/server/tools.mjs +29 -2
  118. package/extension/icons/README.md +0 -27
  119. package/extension/lib/preconfigured.js +0 -5
  120. package/extension/popup/popup.css +0 -227
  121. package/extension/popup/popup.html +0 -50
  122. package/extension/popup/popup.js +0 -175
  123. package/frontend/dist/assets/index-U7o-qU99.js +0 -5742
  124. package/scaffold/.agent/plugins/.gitkeep +0 -0
  125. package/scaffold/.agent/plugins/code-quality/README.md +0 -16
  126. package/scaffold/.agent/plugins/code-quality/cli.mjs +0 -15
  127. package/scaffold/.agent/plugins/code-quality/plugin.json +0 -34
  128. package/scaffold/.agent/plugins/git-sentinel/README.md +0 -16
  129. package/scaffold/.agent/plugins/git-sentinel/plugin.json +0 -34
  130. package/scaffold/.agent/plugins/meta-harness/README.md +0 -16
  131. package/scaffold/.agent/plugins/meta-harness/cli.mjs +0 -34
  132. package/scaffold/.agent/plugins/meta-harness/plugin.json +0 -34
  133. package/scaffold/.agent/plugins/system-monitor/README.md +0 -16
  134. package/scaffold/.agent/plugins/system-monitor/plugin.json +0 -38
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Sharing mesh login accounts between nodes.
3
+ *
4
+ * A node is only as reachable as its login account is known, and that account
5
+ * used to be typed in by hand, per brain, per machine. Every node now records
6
+ * its own account on its own entity (see `patchOwnMeshNode`), and this module
7
+ * spreads those records: it reads the node entities of each peer this machine
8
+ * can already reach and fills in accounts it does not know yet. One reachable
9
+ * peer is enough to learn the rest.
10
+ *
11
+ * Only accounts and ports travel. Key paths are local facts and are never
12
+ * copied: a path that exists here means nothing anywhere else. Nothing here
13
+ * guesses — an account is only ever taken from a node's own record or from a
14
+ * record another node verified.
15
+ *
16
+ * Portability (open source): no hostname, account or path is hardcoded.
17
+ */
18
+
19
+ import matter from 'gray-matter';
20
+
21
+ const DOC_MARKER = '@@TR-MESH-NODE-DOC@@';
22
+
23
+ /** Shell command that prints a peer's global-brain node entities, one per marker. */
24
+ export function remoteNodeDocsCommand() {
25
+ const dir = '"$HOME/.agent/skills/total-recall/memory-vault/system/mesh-nodes"';
26
+ return `for f in ${dir}/*.md; do [ -f "$f" ] && printf '\\n${DOC_MARKER}\\n' && cat "$f"; done; true`;
27
+ }
28
+
29
+ /** Parse the output of `remoteNodeDocsCommand` into node entity objects. */
30
+ export function parseRemoteNodeDocs(output = '') {
31
+ return String(output)
32
+ .split(DOC_MARKER)
33
+ .map((chunk) => chunk.trim())
34
+ .filter(Boolean)
35
+ .map((chunk) => {
36
+ try {
37
+ return matter(chunk).data;
38
+ } catch {
39
+ return null;
40
+ }
41
+ })
42
+ .filter((doc) => doc && doc.type === 'mesh_node' && doc.hostname);
43
+ }
44
+
45
+ function hostKey(value) {
46
+ return String(value || '').toLowerCase().split('.')[0].replace(/[^a-z0-9]/g, '');
47
+ }
48
+
49
+ /**
50
+ * Decide which accounts a peer's entities can teach this machine.
51
+ *
52
+ * A peer's record fills a gap; it never overrides an account this machine has
53
+ * already verified. The one exception is a node describing itself
54
+ * (`source: 'self'`), which is authoritative over any unverified entry.
55
+ */
56
+ export function planAccessFromPeerDocs(localNodes, peerDocs, peerName) {
57
+ const selfFirst = [...peerDocs].sort(
58
+ (a, b) =>
59
+ Number(hostKey(b.hostname) === hostKey(peerName)) -
60
+ Number(hostKey(a.hostname) === hostKey(peerName)),
61
+ );
62
+ const byHost = new Map();
63
+ for (const doc of selfFirst) {
64
+ const remote = doc.access || {};
65
+ if (!remote.ssh_user) continue;
66
+ const describesItself = hostKey(doc.hostname) === hostKey(peerName);
67
+ // A third party's entry is only worth copying if someone verified it.
68
+ if (!describesItself && !remote.verified_at && remote.source !== 'self') continue;
69
+
70
+ const local = localNodes.find(
71
+ (n) => hostKey(n.hostname) === hostKey(doc.hostname) || (doc.ip && String(n.ip) === String(doc.ip)),
72
+ );
73
+ if (!local || local.self || byHost.has(local.hostname)) continue;
74
+
75
+ const current = local.access || {};
76
+ const fillsGap = !current.ssh_user;
77
+ const correctsEntry =
78
+ describesItself && remote.source === 'self' && !current.verified_at && current.ssh_user !== remote.ssh_user;
79
+ if (!fillsGap && !correctsEntry) continue;
80
+
81
+ byHost.set(local.hostname, {
82
+ hostname: local.hostname,
83
+ access: {
84
+ ssh_user: remote.ssh_user,
85
+ ...(remote.ssh_port ? { ssh_port: remote.ssh_port } : {}),
86
+ source: `mesh:${hostKey(peerName) || 'peer'}`,
87
+ },
88
+ });
89
+ }
90
+ return [...byHost.values()];
91
+ }
92
+
93
+ /**
94
+ * Learn login accounts from every peer this machine can already reach.
95
+ *
96
+ * @returns {Promise<{ peers: object[], learned: object[] }>}
97
+ */
98
+ export async function syncAccessFromPeers(options = {}) {
99
+ const { vaultRoot } = options;
100
+ const mesh = await import('./mesh.mjs');
101
+ const { resolveNodeAccess } = await import('./mesh-access.mjs');
102
+ const exec = options.exec || mesh.execMeshCommand;
103
+ const list = options.listNodes || (() => mesh.listEnrichedMeshNodes(vaultRoot));
104
+ const save =
105
+ options.save || ((hostname, patch) => mesh.setMeshNodeAccess(hostname, patch, { vaultRoot }));
106
+
107
+ const peers = [];
108
+ const learned = [];
109
+ for (const node of list().filter((n) => !n.self && n.online !== false)) {
110
+ if (!resolveNodeAccess(node).complete) {
111
+ peers.push({ hostname: node.hostname, read: false, reason: 'no login recorded' });
112
+ continue;
113
+ }
114
+ let res;
115
+ try {
116
+ res = await exec(node.hostname, remoteNodeDocsCommand(), { vaultRoot, timeoutMs: 15_000 });
117
+ } catch (err) {
118
+ peers.push({ hostname: node.hostname, read: false, reason: err.message });
119
+ continue;
120
+ }
121
+ if (!res?.success) {
122
+ peers.push({ hostname: node.hostname, read: false, reason: res?.stderr || 'failed' });
123
+ continue;
124
+ }
125
+ const docs = parseRemoteNodeDocs(res.stdout);
126
+ peers.push({ hostname: node.hostname, read: true, documents: docs.length });
127
+
128
+ for (const update of planAccessFromPeerDocs(list(), docs, node.hostname)) {
129
+ const result = await save(update.hostname, update.access);
130
+ learned.push({ ...update, from: node.hostname, written: !!result?.written });
131
+ }
132
+ }
133
+ return { peers, learned };
134
+ }
@@ -83,7 +83,7 @@ export function readSshConfig(configPath = path.join(os.homedir(), '.ssh', 'conf
83
83
  }
84
84
  }
85
85
 
86
- /** Loose host comparison: `mac-mini`, `Mac_Mini` and `macmini` name one machine. */
86
+ /** Loose host comparison: `build-box`, `Build_Box` and `buildbox` name one machine. */
87
87
  function hostKey(value) {
88
88
  if (value == null) return '';
89
89
  return String(value).toLowerCase().replace(/\.$/, '').replace(/[^a-z0-9]/g, '');
@@ -183,7 +183,7 @@ export function meshSshFromVariant(variant) {
183
183
  * anywhere, while a LAN address is only correct when both ends happen to sit on
184
184
  * the same network.
185
185
  */
186
- export function resolveNodeAccess(node, { fallbackUser = null } = {}) {
186
+ export function resolveNodeAccess(node, { fallbackUser = null, fileExists = fs.existsSync } = {}) {
187
187
  const access = node?.access || {};
188
188
  const host = access.ssh_host || node?.ip || node?.lan_ip || node?.hostname || null;
189
189
  const user = access.ssh_user || fallbackUser || null;
@@ -192,7 +192,7 @@ export function resolveNodeAccess(node, { fallbackUser = null } = {}) {
192
192
  user,
193
193
  host,
194
194
  port: access.ssh_port || DEFAULT_SSH_PORT,
195
- identity_file: access.identity_file || null,
195
+ identity_file: localIdentityFile(access.identity_file, fileExists),
196
196
  mesh_ssh: access.mesh_ssh || 'unknown',
197
197
  tailscale_variant: access.tailscale_variant || TAILSCALE_VARIANTS.UNKNOWN,
198
198
  source: access.source || 'unknown',
@@ -204,6 +204,21 @@ export function resolveNodeAccess(node, { fallbackUser = null } = {}) {
204
204
  };
205
205
  }
206
206
 
207
+ /**
208
+ * A recorded key path, but only when this machine actually has that file.
209
+ *
210
+ * Access records are shared across the mesh, while a key path is only true on
211
+ * the machine that wrote it. Passing another machine's path with
212
+ * IdentitiesOnly makes ssh refuse to try any key at all, so a record that
213
+ * works from one node would block the same login from every other one.
214
+ * Without it, ssh falls back to this machine's own keys and agent.
215
+ */
216
+ export function localIdentityFile(identityFile, fileExists = fs.existsSync) {
217
+ if (!identityFile) return null;
218
+ const expanded = String(identityFile).replace(/^~(?=$|\/)/, os.homedir());
219
+ return fileExists(expanded) ? expanded : null;
220
+ }
221
+
207
222
  /** Human-readable `user@host[:port]`, or a clear marker of what is missing. */
208
223
  export function formatAccessTarget(resolved) {
209
224
  if (!resolved?.host) return '(no address)';
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Finding a working mesh login with this machine's own keys.
3
+ *
4
+ * `mesh access sync` learns accounts other nodes already know. This covers the
5
+ * case where nobody knows yet: it tries the plausible accounts with the keys
6
+ * this machine holds and records the first one that logs in, verified.
7
+ *
8
+ * It stops early when the failure is not about credentials. An untrusted host
9
+ * key or an unreachable host fails identically for every account and key, so
10
+ * trying the rest would only add delay and noise to the far end's auth log.
11
+ * Host keys are never auto-accepted; that case is reported with the command
12
+ * that confirms the key.
13
+ *
14
+ * Portability (open source): candidates come from the node entity, the
15
+ * operator's ssh config and the local account — nothing is hardcoded.
16
+ */
17
+
18
+ import { spawn } from 'node:child_process';
19
+ import fs from 'node:fs';
20
+ import os from 'node:os';
21
+ import path from 'node:path';
22
+ import {
23
+ DEFAULT_SSH_PORT,
24
+ findSshConfigEntryForNode,
25
+ localIdentityFile,
26
+ parseSshConfig,
27
+ readSshConfig,
28
+ } from './mesh-access.mjs';
29
+
30
+ /** Accounts worth trying for a node, most likely first, without duplicates. */
31
+ export function candidateUsers(node, { sshConfigText = '', localUser = safeLocalUser() } = {}) {
32
+ const entry = findSshConfigEntryForNode(parseSshConfig(sshConfigText), node);
33
+ return [...new Set([node?.access?.ssh_user, entry?.user, localUser].filter(Boolean))];
34
+ }
35
+
36
+ /**
37
+ * Private keys this machine holds in `~/.ssh`: every file with a matching
38
+ * `.pub` beside it. `null` comes last and means "ssh defaults and agent".
39
+ */
40
+ export function localPrivateKeys(sshDir = path.join(os.homedir(), '.ssh')) {
41
+ let names = [];
42
+ try {
43
+ names = fs.readdirSync(sshDir);
44
+ } catch {
45
+ return [null];
46
+ }
47
+ const keys = names
48
+ .filter((name) => name.endsWith('.pub'))
49
+ .map((name) => path.join(sshDir, name.slice(0, -4)))
50
+ .filter((file) => {
51
+ try {
52
+ return fs.statSync(file).isFile();
53
+ } catch {
54
+ return false;
55
+ }
56
+ })
57
+ .sort();
58
+ return [...keys, null];
59
+ }
60
+
61
+ /** Why a login attempt failed, from ssh's stderr. */
62
+ export function classifyProbeFailure(stderr = '') {
63
+ const text = String(stderr);
64
+ if (/Host key verification failed|REMOTE HOST IDENTIFICATION HAS CHANGED/i.test(text)) {
65
+ return 'host-key';
66
+ }
67
+ if (/Permission denied|Too many authentication failures/i.test(text)) return 'auth';
68
+ if (/timed out|No route to host|Connection refused|Could not resolve/i.test(text)) {
69
+ return 'unreachable';
70
+ }
71
+ return 'unknown';
72
+ }
73
+
74
+ /** One non-interactive login attempt. Resolves `{ ok, reason, stderr }`. */
75
+ export function probeLogin({ user, host, port = DEFAULT_SSH_PORT, identity = null }, options = {}) {
76
+ const { timeoutMs = 12_000, spawnImpl = spawn } = options;
77
+ const args = ['-o', 'BatchMode=yes', '-o', `ConnectTimeout=${Math.ceil(timeoutMs / 1000)}`];
78
+ if (port && port !== DEFAULT_SSH_PORT) args.push('-p', String(port));
79
+ if (identity) args.push('-i', identity, '-o', 'IdentitiesOnly=yes');
80
+ args.push(`${user}@${host}`, 'true');
81
+
82
+ return new Promise((resolve) => {
83
+ let stderr = '';
84
+ let settled = false;
85
+ let timer = null;
86
+ const done = (value) => {
87
+ if (settled) return;
88
+ settled = true;
89
+ clearTimeout(timer);
90
+ resolve(value);
91
+ };
92
+ const proc = spawnImpl('ssh', args, { stdio: ['ignore', 'ignore', 'pipe'] });
93
+ timer = setTimeout(() => {
94
+ proc.kill('SIGTERM');
95
+ done({ ok: false, reason: 'unreachable', stderr: 'timed out' });
96
+ }, timeoutMs + 2_000);
97
+ proc.stderr?.on('data', (chunk) => {
98
+ stderr += chunk.toString();
99
+ });
100
+ proc.on('error', (err) => done({ ok: false, reason: 'unknown', stderr: err.message }));
101
+ proc.on('close', (code) =>
102
+ done(
103
+ code === 0
104
+ ? { ok: true, reason: null, stderr: '' }
105
+ : { ok: false, reason: classifyProbeFailure(stderr), stderr: stderr.trim() },
106
+ ),
107
+ );
108
+ });
109
+ }
110
+
111
+ /**
112
+ * Find a working login for one node and record it.
113
+ *
114
+ * @returns {Promise<{ hostname: string, found: boolean, access?: object,
115
+ * reason?: string, attempts: number, written?: boolean }>}
116
+ */
117
+ export async function discoverNodeAccess(node, options = {}) {
118
+ const {
119
+ vaultRoot,
120
+ sshConfigText = readSshConfig(),
121
+ keys = localPrivateKeys(),
122
+ probe = probeLogin,
123
+ save = null,
124
+ timeoutMs = 12_000,
125
+ } = options;
126
+
127
+ const host = node?.access?.ssh_host || node?.ip || node?.lan_ip || null;
128
+ if (!host) return { hostname: node?.hostname, found: false, reason: 'no-address', attempts: 0 };
129
+
130
+ const port = node?.access?.ssh_port || DEFAULT_SSH_PORT;
131
+ const recordedKey = localIdentityFile(node?.access?.identity_file);
132
+ const identities = [...new Set([recordedKey, ...keys].filter((k) => k !== undefined))];
133
+ if (!identities.includes(null)) identities.push(null);
134
+ const users = candidateUsers(node, { sshConfigText });
135
+
136
+ let attempts = 0;
137
+ let lastReason = users.length ? 'auth' : 'no-candidate-user';
138
+ for (const user of users) {
139
+ for (const identity of identities) {
140
+ attempts += 1;
141
+ const result = await probe({ user, host, port, identity }, { timeoutMs });
142
+ if (result.ok) {
143
+ const access = {
144
+ ssh_user: user,
145
+ ...(identity ? { identity_file: identity } : {}),
146
+ source: 'discovered',
147
+ verified_at: new Date().toISOString(),
148
+ };
149
+ const writer =
150
+ save ||
151
+ (async (hostname, patch) => {
152
+ const { setMeshNodeAccess } = await import('./mesh.mjs');
153
+ return setMeshNodeAccess(hostname, patch, { vaultRoot });
154
+ });
155
+ const written = await writer(node.hostname, access);
156
+ return { hostname: node.hostname, found: true, access, attempts, written: !!written?.written };
157
+ }
158
+ lastReason = result.reason;
159
+ if (result.reason === 'host-key' || result.reason === 'unreachable') {
160
+ return { hostname: node.hostname, found: false, reason: result.reason, attempts };
161
+ }
162
+ }
163
+ }
164
+ return { hostname: node.hostname, found: false, reason: lastReason, attempts };
165
+ }
166
+
167
+ function safeLocalUser() {
168
+ try {
169
+ return os.userInfo().username || null;
170
+ } catch {
171
+ return process.env.USER || process.env.USERNAME || null;
172
+ }
173
+ }
package/src/core/mesh.mjs CHANGED
@@ -1,4 +1,6 @@
1
1
  import { spawn, spawnSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
2
4
  import path from 'node:path';
3
5
  import crypto from 'node:crypto';
4
6
  import {
@@ -9,6 +11,7 @@ import {
9
11
  import { logger } from './logger.mjs';
10
12
  import { STATUS_TIMEOUT_MS, resolveTailscaleBinary } from './tailscale-cli.mjs';
11
13
  import { buildSshArgs, resolveNodeAccess } from './mesh-access.mjs';
14
+ import { port as configuredBrainPort, detectProjectBrain, globalBrainDir } from './config.mjs';
12
15
 
13
16
  const CACHE_MS = 2_000;
14
17
 
@@ -120,30 +123,99 @@ export function getMeshPeers({ includeSelf = false } = {}) {
120
123
  return peers.filter((peer) => peer.hostname && peer.ip);
121
124
  }
122
125
 
123
- /** Read vault mesh_node entity documents (install-specific variables). */
124
- export function listMeshNodeEntities(vaultRoot = defaultVaultRoot()) {
125
- return listVfsDocumentsUnder('system/mesh-nodes', vaultRoot).filter(
126
- (doc) => doc.type === 'mesh_node',
127
- );
126
+ /**
127
+ * Where mesh node entities live: the machine's global brain.
128
+ *
129
+ * How to reach a machine is a fact about the machine, not about whichever
130
+ * repository a command happened to run in. Resolving it from the active
131
+ * (usually project) brain meant a login recorded in one repo was invisible from
132
+ * every other repo on the same machine, so each one rediscovered it from
133
+ * scratch. `AGENT_DIR` still pins the root, matching `resolveBrainDir`.
134
+ */
135
+ export function meshVaultRoot() {
136
+ if (process.env.AGENT_DIR) {
137
+ return path.join(process.env.AGENT_DIR, 'skills', 'total-recall', 'memory-vault');
138
+ }
139
+ return path.join(globalBrainDir, 'memory-vault');
140
+ }
141
+
142
+ function readMeshNodeDocs(vaultRoot) {
143
+ return listVfsDocumentsUnder('system/mesh-nodes', vaultRoot)
144
+ .filter((doc) => doc.type === 'mesh_node')
145
+ .map((doc) => ({ ...doc, _vault_root: vaultRoot }));
146
+ }
147
+
148
+ /**
149
+ * Read vault mesh_node entity documents (install-specific variables).
150
+ *
151
+ * Reads the mesh root and, when it differs, the active brain too, so access
152
+ * recorded before entities moved to the global brain is still honoured. The
153
+ * mesh root wins per field; the other layer only fills gaps in `access`.
154
+ */
155
+ export function listMeshNodeEntities(vaultRoot = meshVaultRoot()) {
156
+ const primary = readMeshNodeDocs(vaultRoot);
157
+ const project = detectProjectBrain();
158
+ const legacyRoots = [
159
+ ...new Set(
160
+ [defaultVaultRoot(), project && path.join(project.brainDir, 'memory-vault')]
161
+ .filter(Boolean)
162
+ .map((root) => path.resolve(root)),
163
+ ),
164
+ ].filter((root) => root !== path.resolve(vaultRoot));
165
+ if (!legacyRoots.length) return primary;
166
+
167
+ const merged = [...primary];
168
+ for (const doc of legacyRoots.flatMap((root) => readMeshNodeDocs(root))) {
169
+ const match = merged.find(
170
+ (e) =>
171
+ meshNodeKey(e.hostname) === meshNodeKey(doc.hostname) ||
172
+ (e.ip && doc.ip && String(e.ip) === String(doc.ip)),
173
+ );
174
+ if (!match) {
175
+ merged.push(doc);
176
+ } else if (doc.access && typeof doc.access === 'object') {
177
+ match.access = { ...doc.access, ...(match.access || {}) };
178
+ }
179
+ }
180
+ return merged;
128
181
  }
129
182
 
130
183
  /**
131
184
  * Merge live control-plane peers with vault mesh_node entity variables.
132
185
  * Live online/ip always win; role/labels/capabilities/notes come from the entity.
133
186
  */
134
- export function mergeLivePeersWithEntities(peers, entities = []) {
135
- const byHost = new Map();
136
- const byIp = new Map();
137
- for (const ent of entities) {
138
- const host = meshNodeKey(ent.hostname);
139
- if (host) byHost.set(host, ent);
140
- if (ent.ip) byIp.set(String(ent.ip), ent);
187
+ /**
188
+ * The entity describing a peer: by hostname first, then by mesh address.
189
+ *
190
+ * The address fallback exists for a machine whose hostname changed. It must
191
+ * not claim an entity whose hostname is a DIFFERENT machine: Headscale hands a
192
+ * freed address to the next node that enrolls, so a stale entity at that
193
+ * address belongs to whoever held it before. Matching it attached a new
194
+ * Chromebook to an old MacBook's record, login included.
195
+ */
196
+ export function entityForPeer(peer, entities = [], otherHosts = new Set()) {
197
+ const host = meshNodeKey(peer?.hostname);
198
+ if (host) {
199
+ const byName = entities.find((e) => meshNodeKey(e.hostname) === host);
200
+ if (byName) return byName;
141
201
  }
202
+ if (!peer?.ip) return null;
203
+ return (
204
+ entities.find((e) => {
205
+ if (!e.ip || String(e.ip) !== String(peer.ip)) return false;
206
+ const entHost = meshNodeKey(e.hostname);
207
+ return !entHost || !otherHosts.has(entHost);
208
+ }) || null
209
+ );
210
+ }
211
+
212
+ export function mergeLivePeersWithEntities(peers, entities = []) {
213
+ const liveHosts = new Set(peers.map((p) => meshNodeKey(p.hostname)).filter(Boolean));
142
214
 
143
215
  const merged = peers.map((peer) => {
144
216
  const host = meshNodeKey(peer.hostname);
145
- const ent = (host && byHost.get(host)) || (peer.ip && byIp.get(String(peer.ip))) || null;
146
- return enrichPeerWithEntity(peer, ent);
217
+ const others = new Set([...liveHosts].filter((h) => h !== host));
218
+ return enrichPeerWithEntity(peer, entityForPeer(peer, entities, others));
147
219
  });
148
220
 
149
221
  const seen = new Set(
@@ -185,6 +257,7 @@ function enrichPeerWithEntity(peer, ent, extra = {}) {
185
257
  hostname: peer.hostname,
186
258
  ip: peer.ip || ent?.ip || null,
187
259
  lan_ip: peer.lan_ip || ent?.lan_ip || null,
260
+ brain_port: ent?.brain_port || null,
188
261
  online: !!peer.online,
189
262
  self: !!peer.self,
190
263
  os: peer.os || ent?.os || null,
@@ -212,7 +285,7 @@ function enrichPeerWithEntity(peer, ent, extra = {}) {
212
285
  }
213
286
 
214
287
  /** Live peers + vault entity variables for API/UI. */
215
- export function listEnrichedMeshNodes(vaultRoot = defaultVaultRoot()) {
288
+ export function listEnrichedMeshNodes(vaultRoot = meshVaultRoot()) {
216
289
  const live = getMeshPeers({ includeSelf: true }).map((p) => ({
217
290
  ...p,
218
291
  transports: p.ip ? ['mesh'] : [],
@@ -249,18 +322,23 @@ export function attachSelfInterfaces(nodes, interfacesSummary = null) {
249
322
  });
250
323
  }
251
324
 
325
+ function isInVault(doc, vaultRoot) {
326
+ return !doc?._vault_root || path.resolve(doc._vault_root) === path.resolve(vaultRoot);
327
+ }
328
+
252
329
  function findEntityForSelf(self, vaultRoot) {
253
330
  const entities = listMeshNodeEntities(vaultRoot);
254
331
  const host = meshNodeKey(self.hostname);
255
- return (
256
- entities.find((e) => meshNodeKey(e.hostname) === host) ||
257
- entities.find((e) => e.ip && self.ip && String(e.ip) === String(self.ip)) ||
258
- null
332
+ const otherHosts = new Set(
333
+ getMeshPeers({ includeSelf: true })
334
+ .map((p) => meshNodeKey(p.hostname))
335
+ .filter((h) => h && h !== host),
259
336
  );
337
+ return entityForPeer(self, entities, otherHosts);
260
338
  }
261
339
 
262
340
  /** Find an enriched node (live + vault) by hostname or address. */
263
- export function findMeshNode(nameOrAddress, vaultRoot = defaultVaultRoot()) {
341
+ export function findMeshNode(nameOrAddress, vaultRoot = meshVaultRoot()) {
264
342
  const wanted = String(nameOrAddress || '').trim();
265
343
  if (!wanted) return null;
266
344
  const key = meshNodeKey(wanted);
@@ -281,18 +359,24 @@ export function findMeshNode(nameOrAddress, vaultRoot = defaultVaultRoot()) {
281
359
  * thing anyone knows about a machine they did not install Total Recall on.
282
360
  */
283
361
  export async function setMeshNodeAccess(nameOrAddress, accessPatch, options = {}) {
284
- const vaultRoot = options.vaultRoot || defaultVaultRoot();
362
+ const vaultRoot = options.vaultRoot || meshVaultRoot();
285
363
  const node = findMeshNode(nameOrAddress, vaultRoot);
286
364
  if (!node) return { written: false, reason: 'node-not-found' };
287
365
 
288
366
  const entities = listMeshNodeEntities(vaultRoot);
289
- const existing =
290
- entities.find((e) => meshNodeKey(e.hostname) === meshNodeKey(node.hostname)) ||
291
- entities.find((e) => e.ip && node.ip && String(e.ip) === String(node.ip)) ||
292
- null;
367
+ const otherHosts = new Set(
368
+ listEnrichedMeshNodes(vaultRoot)
369
+ .filter((n) => !n.vault_only)
370
+ .map((n) => meshNodeKey(n.hostname))
371
+ .filter((h) => h && h !== meshNodeKey(node.hostname)),
372
+ );
373
+ const known = entityForPeer(node, entities, otherHosts);
374
+ // A record read from another brain layer is merged, but the write always
375
+ // lands in this vault, so only a document that lives here can be patched.
376
+ const existing = known && isInVault(known, vaultRoot) ? known : null;
293
377
 
294
378
  // Merge rather than replace: setting only a port must not erase the user.
295
- const merged = { ...(existing?.access || {}), ...accessPatch };
379
+ const merged = { ...(known?.access || {}), ...accessPatch };
296
380
  for (const [key, value] of Object.entries(merged)) {
297
381
  if (value === null || value === undefined) delete merged[key];
298
382
  }
@@ -382,7 +466,7 @@ export async function setMeshNodeAccess(nameOrAddress, accessPatch, options = {}
382
466
  * @returns {Promise<{created: string[], skipped_offline: string[], existing: number}>}
383
467
  */
384
468
  export async function ensureMeshNodeEntities(options = {}) {
385
- const vaultRoot = options.vaultRoot || defaultVaultRoot();
469
+ const vaultRoot = options.vaultRoot || meshVaultRoot();
386
470
  const peers = options.peers || getMeshPeers({ includeSelf: true });
387
471
  const entities = options.entities || listMeshNodeEntities(vaultRoot);
388
472
  const write = options.write || ((host) => setMeshNodeAccess(host, {}, { vaultRoot }));
@@ -422,14 +506,23 @@ export async function ensureMeshNodeEntities(options = {}) {
422
506
  * Does not hardcode device names — slug and fields come from Tailscale self.
423
507
  * Preserves existing entity variables (role, labels, capabilities, notes, body).
424
508
  */
509
+ function localLoginUser() {
510
+ try {
511
+ return os.userInfo().username || null;
512
+ } catch {
513
+ return process.env.USER || process.env.USERNAME || null;
514
+ }
515
+ }
516
+
425
517
  export async function patchOwnMeshNode(options = {}) {
426
518
  const self = getMeshSelf();
427
519
  if (!self?.hostname) {
428
520
  return { self: null, written: false, reason: 'mesh-unavailable' };
429
521
  }
430
522
 
431
- const vaultRoot = options.vaultRoot || defaultVaultRoot();
432
- const existing = findEntityForSelf(self, vaultRoot);
523
+ const vaultRoot = options.vaultRoot || meshVaultRoot();
524
+ const known = findEntityForSelf(self, vaultRoot);
525
+ const existing = known && isInVault(known, vaultRoot) ? known : null;
433
526
  const slug = existing
434
527
  ? path.basename(existing.vfs_path, '.md')
435
528
  : meshNodeKey(self.hostname);
@@ -484,8 +577,14 @@ export async function patchOwnMeshNode(options = {}) {
484
577
  hasClient: true,
485
578
  hasDaemon: hasTailscaleDaemon(),
486
579
  });
580
+ // This machine is the one authority on its own login account. Recording it
581
+ // here is what lets every other node reach it without guessing: the account
582
+ // is a property of the host that the control server cannot supply. An
583
+ // account an operator set deliberately (a dedicated ssh user) is kept.
584
+ const priorAccess = known?.access || {};
487
585
  const access = {
488
- ...(existing?.access || {}),
586
+ ...priorAccess,
587
+ ...(priorAccess.ssh_user ? {} : { ssh_user: localLoginUser(), source: 'self' }),
489
588
  tailscale_variant: variant,
490
589
  mesh_ssh: meshSshFromVariant(variant),
491
590
  };
@@ -498,6 +597,7 @@ export async function patchOwnMeshNode(options = {}) {
498
597
  os: self.os,
499
598
  interfaces: interfacesSummary,
500
599
  lan_ip: lanIp,
600
+ brain_port: options.brainPort || configuredBrainPort,
501
601
  transports: [...new Set(['mesh', ...(lanIp ? ['lan'] : [])])],
502
602
  io: ioProfile,
503
603
  capabilities,
@@ -582,7 +682,7 @@ export async function patchOwnMeshNode(options = {}) {
582
682
  * @returns {Promise<{ node: string, hostname: string, ip: string, exitCode: number, success: boolean, stdout: string, stderr: string }>}
583
683
  */
584
684
  export async function execMeshCommand(target, command, options = {}) {
585
- const { vaultRoot = defaultVaultRoot(), timeoutMs = 60_000, loginShell = true } = options;
685
+ const { vaultRoot = meshVaultRoot(), timeoutMs = 60_000, loginShell = true } = options;
586
686
  const node = findMeshNode(target, vaultRoot);
587
687
  if (!node) {
588
688
  throw new Error(`No mesh node matches "${target}".`);
@@ -679,4 +779,3 @@ export function resolveMeshControlUrl(options = {}) {
679
779
 
680
780
  return { url: null, source: 'none', is_fallback: false };
681
781
  }
682
-
@@ -311,47 +311,10 @@ export function findStaleNodes(vaultDir, { days = 30, minImportance = 4 } = {})
311
311
  }
312
312
 
313
313
  /**
314
- * Hand stale high-importance memories to the research daemon, which can actually
315
- * re-verify them and commit the result — instead of filing a ticket asking
316
- * someone else to.
317
- *
318
- * Rate-limited per cycle: re-researching hundreds of nodes at once would swamp
319
- * the daemon and burn provider budget. Most-stale-first means the backlog drains
320
- * in a sensible order across cycles. `addToQueue` dedupes by topic, so a node
321
- * already queued is never enqueued twice.
322
- *
323
- * @returns {{ enqueued: string[], stale: number }}
324
- */
325
- export async function refreshStaleKnowledge(vaultDir, { limit = 3, days = 30, minImportance = 4 } = {}) {
326
- const { addToQueue } = await import('./research-queue.mjs');
327
- const stale = findStaleNodes(vaultDir, { days, minImportance });
328
- const enqueued = [];
329
-
330
- for (const node of stale.slice(0, limit)) {
331
- const ageDays = Math.floor((Date.now() - new Date(node.last_accessed).getTime()) / 86400000);
332
- try {
333
- addToQueue({
334
- topic: `Verify still-current: ${node.title}`,
335
- priority: node.importance >= 5 ? 'high' : 'medium',
336
- notes: `Memory node "${node.slug}" (importance ${node.importance}) has not been accessed in ${ageDays} days. Re-verify its claim against current sources and update or archive it.`,
337
- });
338
- enqueued.push(node.slug);
339
- } catch (err) {
340
- logger.warn('optimizer', `Could not enqueue staleness research for ${node.slug}: ${err.message}`);
341
- }
342
- }
343
-
344
- if (stale.length > enqueued.length) {
345
- logger.info('optimizer', `${stale.length - enqueued.length} stale nodes deferred to a later cycle (limit ${limit}).`);
346
- }
347
- return { enqueued, stale: stale.length };
348
- }
349
-
350
- /**
351
- * @deprecated Superseded by {@link refreshStaleKnowledge}, which queues real work
352
- * instead of writing tickets nothing reads. Retained because the dream cycle's
353
- * master switch still references it and because removing it would silently change
354
- * behavior for anyone who flips that switch back on.
314
+ * @deprecated Wrote tickets nothing reads. Retained because the dream cycle's
315
+ * master switch (ENABLE_STALE_KNOWLEDGE_REFRESH, off) still references it.
316
+ * Stale nodes are surfaced to a human via findStaleNodes instead; nothing
317
+ * re-researches them automatically (RESEARCH_SYSTEM2).
355
318
  */
356
319
  export async function generateStaleKnowledgeRefreshProposals(vaultDir) {
357
320
  return findStaleNodes(vaultDir, { days: 30, minImportance: 4 }).map(node => createProposal(