total-recall-brain 3.28.2 → 3.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/total-recall.mjs +24 -1
- package/docs/ARCHITECTURE.md +26 -18
- package/extension/background.js +259 -84
- package/extension/content-script.css +7 -13
- package/extension/content-script.js +252 -477
- package/extension/icons/icon-128.png +0 -0
- package/extension/icons/icon-16.png +0 -0
- package/extension/icons/icon-32.png +0 -0
- package/extension/icons/icon-48.png +0 -0
- package/extension/icons/mark.svg +6 -0
- package/extension/lib/brain-client.js +160 -73
- package/extension/lib/tokens.css +187 -0
- package/extension/lib/ui-helpers.js +229 -0
- package/extension/lib/ui-helpers.spec.mjs +94 -0
- package/extension/manifest.json +20 -5
- package/extension/options/options.css +64 -218
- package/extension/options/options.html +91 -43
- package/extension/options/options.js +208 -85
- package/extension/sidepanel/sidepanel.css +268 -970
- package/extension/sidepanel/sidepanel.html +200 -166
- package/extension/sidepanel/sidepanel.js +788 -942
- package/frontend/dist/assets/index-DGMNZi_1.js +5742 -0
- package/frontend/dist/index.html +1 -1
- package/frontend/src/api/plugins.ts +124 -92
- package/frontend/src/components/ResearchAgendaTab.spec.tsx +11 -0
- package/frontend/src/components/ResearchAgendaTab.tsx +19 -14
- package/frontend/src/pages/PluginsPage.spec.tsx +87 -16
- package/frontend/src/pages/PluginsPage.tsx +454 -1218
- package/package.json +2 -1
- package/plugins/code-quality/README.md +25 -0
- package/plugins/code-quality/cli.mjs +25 -0
- package/plugins/code-quality/plugin.json +17 -0
- package/plugins/git-sentinel/README.md +27 -0
- package/{scaffold/.agent/plugins → plugins}/git-sentinel/cli.mjs +8 -5
- package/plugins/git-sentinel/plugin.json +18 -0
- package/plugins/system-monitor/README.md +26 -0
- package/{scaffold/.agent/plugins → plugins}/system-monitor/cli.mjs +5 -4
- package/{scaffold/.agent/plugins → plugins}/system-monitor/generator.mjs +4 -5
- package/plugins/system-monitor/plugin.json +29 -0
- package/scaffold/.agent/skills/meta-harness/SKILL.md +4 -4
- package/scaffold/.agent/skills/plugins/SKILL.md +53 -0
- package/scaffold/.agent/skills/total-recall/scripts/brain-state.json +1 -0
- package/src/cli/agent.mjs +1 -1
- package/src/cli/app/index.mjs +493 -0
- package/src/cli/brain.mjs +33 -3
- package/src/cli/command.mjs +80 -41
- package/src/cli/harness.mjs +1 -1
- package/src/cli/init.mjs +3 -1
- package/src/cli/mesh.mjs +118 -12
- package/src/cli/plugin/create.mjs +144 -25
- package/src/cli/plugin/index.mjs +83 -53
- package/src/cli/plugin/install.mjs +10 -127
- package/src/cli/plugin/list.mjs +44 -44
- package/src/cli/plugin/remove.mjs +23 -30
- package/src/cli/plugin/scaffold-cmd.mjs +30 -0
- package/src/cli/plugin/search.mjs +108 -23
- package/src/cli/rebuild.mjs +37 -1
- package/src/cli/recall.mjs +17 -2
- package/src/cli/research.mjs +4 -6
- package/src/cli/share.mjs +2 -0
- package/src/core/app-deploy/apply.mjs +386 -0
- package/src/core/app-deploy/design-tokens.mjs +217 -0
- package/src/core/app-deploy/plan.mjs +249 -0
- package/src/core/app-deploy/resolve.mjs +295 -0
- package/src/core/app-deploy/source.mjs +337 -0
- package/src/core/app-deploy/standalone.mjs +248 -0
- package/src/core/app-deploy/upgrade.mjs +256 -0
- package/src/core/app-deploy/verify.mjs +213 -0
- package/src/core/bound-hosts.mjs +1 -1
- package/src/core/brain-registry.mjs +146 -0
- package/src/core/brain-state.json +1 -0
- package/src/core/daemon-loop.mjs +30 -26
- package/src/core/dream.mjs +5 -17
- package/src/core/fact-seeker.mjs +53 -368
- package/src/core/lan-discovery.mjs +3 -3
- package/src/core/mesh-access-sync.mjs +134 -0
- package/src/core/mesh-access.mjs +18 -3
- package/src/core/mesh-discover.mjs +173 -0
- package/src/core/mesh.mjs +131 -32
- package/src/core/optimizer.mjs +4 -41
- package/src/core/plugin-bundle.mjs +167 -0
- package/src/core/plugin-context.mjs +8 -13
- package/src/core/plugin-loader.mjs +272 -68
- package/src/core/plugin-peers.mjs +163 -0
- package/src/core/plugin-public.mjs +85 -0
- package/src/core/plugin-runner-child.mjs +24 -0
- package/src/core/plugin-runner.mjs +102 -0
- package/src/core/plugin-store.mjs +384 -0
- package/src/core/plugin-tasks.mjs +212 -0
- package/src/core/post-mortem.mjs +40 -9
- package/src/core/research-gate.mjs +182 -0
- package/src/core/research-queue.mjs +47 -7
- package/src/core/research-surface.mjs +211 -0
- package/src/core/research.mjs +25 -32
- package/src/core/scheduler.mjs +67 -153
- package/src/core/schema.mjs +129 -0
- package/src/core/search.mjs +12 -1
- package/src/core/secret-integration-research.mjs +12 -8
- package/src/core/secrets-store.mjs +32 -2
- package/src/core/session-watcher.mjs +6 -0
- package/src/core/surface.mjs +64 -3
- package/src/core/task-executors.mjs +8 -45
- package/src/core/vault-cache.mjs +70 -4
- package/src/server/api.mjs +21 -0
- package/src/server/rest.mjs +2 -0
- package/src/server/route-manifest.json +21 -5
- package/src/server/routes/collab.mjs +38 -11
- package/src/server/routes/extension.mjs +3 -2
- package/src/server/routes/memory.mjs +24 -2
- package/src/server/routes/mesh.mjs +7 -7
- package/src/server/routes/plugins-mesh.mjs +49 -0
- package/src/server/routes/plugins.mjs +91 -426
- package/src/server/routes/research.mjs +5 -2
- package/src/server/routes/share.mjs +2 -0
- package/src/server/tools.mjs +29 -2
- package/extension/icons/README.md +0 -27
- package/extension/lib/preconfigured.js +0 -5
- package/extension/popup/popup.css +0 -227
- package/extension/popup/popup.html +0 -50
- package/extension/popup/popup.js +0 -175
- package/frontend/dist/assets/index-U7o-qU99.js +0 -5742
- package/scaffold/.agent/plugins/.gitkeep +0 -0
- package/scaffold/.agent/plugins/code-quality/README.md +0 -16
- package/scaffold/.agent/plugins/code-quality/cli.mjs +0 -15
- package/scaffold/.agent/plugins/code-quality/plugin.json +0 -34
- package/scaffold/.agent/plugins/git-sentinel/README.md +0 -16
- package/scaffold/.agent/plugins/git-sentinel/plugin.json +0 -34
- package/scaffold/.agent/plugins/meta-harness/README.md +0 -16
- package/scaffold/.agent/plugins/meta-harness/cli.mjs +0 -34
- package/scaffold/.agent/plugins/meta-harness/plugin.json +0 -34
- package/scaffold/.agent/plugins/system-monitor/README.md +0 -16
- 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
|
+
}
|
package/src/core/mesh-access.mjs
CHANGED
|
@@ -83,7 +83,7 @@ export function readSshConfig(configPath = path.join(os.homedir(), '.ssh', 'conf
|
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
/** Loose host comparison: `
|
|
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
|
|
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
|
-
/**
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
146
|
-
return enrichPeerWithEntity(peer,
|
|
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 =
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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 =
|
|
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 ||
|
|
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
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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 = { ...(
|
|
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 ||
|
|
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 ||
|
|
432
|
-
const
|
|
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
|
-
...
|
|
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 =
|
|
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
|
-
|
package/src/core/optimizer.mjs
CHANGED
|
@@ -311,47 +311,10 @@ export function findStaleNodes(vaultDir, { days = 30, minImportance = 4 } = {})
|
|
|
311
311
|
}
|
|
312
312
|
|
|
313
313
|
/**
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
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(
|