total-recall-brain 3.26.0 → 3.28.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 +31 -1
- package/docs/ARCHITECTURE.md +76 -9
- package/frontend/dist/assets/index-U7o-qU99.js +5742 -0
- package/frontend/dist/index.html +1 -1
- package/frontend/src/App.tsx +39 -1
- package/frontend/src/api/index.ts +1 -0
- package/frontend/src/api/plugins.ts +167 -0
- package/frontend/src/pages/ChatPage.tsx +44 -17
- package/frontend/src/pages/IntegrationsPage.tsx +7 -0
- package/frontend/src/pages/PluginsPage.spec.tsx +17 -0
- package/frontend/src/pages/PluginsPage.tsx +1331 -0
- package/package.json +1 -1
- package/scaffold/.agent/plugins/.gitkeep +0 -0
- package/scaffold/.agent/plugins/code-quality/README.md +16 -0
- package/scaffold/.agent/plugins/code-quality/cli.mjs +15 -0
- package/scaffold/.agent/plugins/code-quality/plugin.json +34 -0
- package/scaffold/.agent/plugins/git-sentinel/README.md +16 -0
- package/scaffold/.agent/plugins/git-sentinel/cli.mjs +86 -0
- package/scaffold/.agent/plugins/git-sentinel/plugin.json +34 -0
- package/scaffold/.agent/plugins/meta-harness/README.md +16 -0
- package/scaffold/.agent/plugins/meta-harness/cli.mjs +34 -0
- package/scaffold/.agent/plugins/meta-harness/plugin.json +34 -0
- package/scaffold/.agent/plugins/system-monitor/README.md +16 -0
- package/scaffold/.agent/plugins/system-monitor/cli.mjs +79 -0
- package/scaffold/.agent/plugins/system-monitor/generator.mjs +20 -0
- package/scaffold/.agent/plugins/system-monitor/plugin.json +38 -0
- package/scaffold/.agent/skills/meta-harness/SKILL.md +113 -0
- package/scaffold/.agent/skills/total-recall/references/cli-reference.md +23 -0
- package/src/cli/agent.mjs +173 -0
- package/src/cli/connect.mjs +89 -4
- package/src/cli/deploy-ui.mjs +6 -0
- package/src/cli/harness.mjs +135 -0
- package/src/cli/ingest-openwiki.mjs +71 -2
- package/src/cli/init.mjs +1 -0
- package/src/cli/lint.mjs +47 -3
- package/src/cli/mesh.mjs +286 -0
- package/src/cli/plugin/create.mjs +186 -0
- package/src/cli/plugin/index.mjs +139 -0
- package/src/cli/plugin/install.mjs +141 -0
- package/src/cli/plugin/list.mjs +83 -0
- package/src/cli/plugin/remove.mjs +51 -0
- package/src/cli/plugin/search.mjs +37 -0
- package/src/cli/skill-projection.mjs +14 -0
- package/src/cli/wizard.html +9 -0
- package/src/core/agent-manager.mjs +290 -0
- package/src/core/cache-prune.mjs +341 -0
- package/src/core/context-cache.mjs +130 -0
- package/src/core/daemon-loop.mjs +47 -1
- package/src/core/evolving-context.mjs +171 -0
- package/src/core/mesh-activity.mjs +140 -0
- package/src/core/mesh.mjs +174 -1
- package/src/core/meta-harness.mjs +251 -0
- package/src/core/plugin-context.mjs +206 -0
- package/src/core/plugin-loader.mjs +210 -0
- package/src/core/runtime.mjs +65 -13
- package/src/core/surface.mjs +32 -10
- package/src/server/api.mjs +18 -2
- package/src/server/index.mjs +8 -2
- package/src/server/rest.mjs +4 -0
- package/src/server/routes/integrations.mjs +5 -1
- package/src/server/routes/mesh-presence.mjs +143 -0
- package/src/server/routes/models.mjs +42 -30
- package/src/server/routes/plugins.mjs +517 -0
- package/src/server/tools.mjs +266 -0
- package/frontend/dist/assets/index-Bl3I11B0.js +0 -5742
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
getCachedSection,
|
|
5
|
+
loadSectionCache,
|
|
6
|
+
saveSectionCache,
|
|
7
|
+
computeSectionHash,
|
|
8
|
+
manageContextBudget
|
|
9
|
+
} from './context-cache.mjs';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Compile evolving context blocks across multi-domain SSSS nodes and plugins.
|
|
13
|
+
* Supports deep token windows up to 500,000 tokens with section-level caching.
|
|
14
|
+
*
|
|
15
|
+
* @param {object} options
|
|
16
|
+
* @param {string} [options.derivedDir] - Target directory for memory-derived artifacts
|
|
17
|
+
* @param {Array<object>} [options.nodes] - SSSS memory nodes
|
|
18
|
+
* @param {Array<object>} [options.plugins] - Active plugin descriptors
|
|
19
|
+
* @param {number} [options.maxChars=500000] - Token/character budget ceiling
|
|
20
|
+
* @param {number} [options.slidingWindowLimit=50] - Number of recent historical items to retain
|
|
21
|
+
* @returns {Promise<{ content: string, cacheHits: number, cacheMisses: number, totalSections: number }>}
|
|
22
|
+
*/
|
|
23
|
+
export async function compileEvolvingContext(options = {}) {
|
|
24
|
+
const {
|
|
25
|
+
derivedDir,
|
|
26
|
+
nodes = [],
|
|
27
|
+
plugins = [],
|
|
28
|
+
maxChars = 500_000,
|
|
29
|
+
slidingWindowLimit = 50
|
|
30
|
+
} = options;
|
|
31
|
+
|
|
32
|
+
let cacheHits = 0;
|
|
33
|
+
let cacheMisses = 0;
|
|
34
|
+
|
|
35
|
+
const activeNodes = nodes.filter((n) => n && n.status !== 'deprecated' && n.status !== 'archived');
|
|
36
|
+
|
|
37
|
+
// Partition nodes by domain
|
|
38
|
+
const userProjects = activeNodes.filter((n) => n.category === 'user-projects');
|
|
39
|
+
const benchmarks = activeNodes.filter((n) => n.category === 'benchmarks');
|
|
40
|
+
const researchNodes = activeNodes.filter((n) => n.category === 'research' || n.category === 'frontier-capabilities');
|
|
41
|
+
|
|
42
|
+
// Section 1: User Projects & Active Blockers (Priority 100 - unevictable)
|
|
43
|
+
const projectsSection = await getCachedSection({
|
|
44
|
+
derivedDir,
|
|
45
|
+
sectionKey: 'user_projects',
|
|
46
|
+
nodes: userProjects,
|
|
47
|
+
generatorFn: async () => {
|
|
48
|
+
if (userProjects.length === 0) return '';
|
|
49
|
+
const lines = ['### 1. Grounded User Science & Active Blockers\n'];
|
|
50
|
+
for (const p of userProjects) {
|
|
51
|
+
lines.push(`* **Project**: \`${p.title || p.slug}\` (Path: \`${p.source?.repo_path || 'local'}\`)`);
|
|
52
|
+
if (p.description) lines.push(` * **Mission**: ${p.description}`);
|
|
53
|
+
if (Array.isArray(p.enables) && p.enables.length > 0) {
|
|
54
|
+
lines.push(` * **Required Capabilities**: ${p.enables.join(', ')}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return lines.join('\n');
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
if (projectsSection.cached) cacheHits++; else cacheMisses++;
|
|
61
|
+
|
|
62
|
+
// Section 2: Benchmark Ledger (Priority 90)
|
|
63
|
+
const benchmarkSection = await getCachedSection({
|
|
64
|
+
derivedDir,
|
|
65
|
+
sectionKey: 'benchmark_ledger',
|
|
66
|
+
nodes: benchmarks,
|
|
67
|
+
generatorFn: async () => {
|
|
68
|
+
if (benchmarks.length === 0) return '';
|
|
69
|
+
const lines = [
|
|
70
|
+
'### 2. State-of-the-Art Benchmark Ledger & Pareto Frontiers\n',
|
|
71
|
+
'| Domain / Metric | Current World Record | Baseline | Verification Tier | Source / Lab |',
|
|
72
|
+
'|---|---|---|---|---|'
|
|
73
|
+
];
|
|
74
|
+
for (const b of benchmarks) {
|
|
75
|
+
const src = b.source || {};
|
|
76
|
+
const metricCode = src.metric_name || b.slug;
|
|
77
|
+
const metricDisplay = b.title && metricCode && b.title !== metricCode
|
|
78
|
+
? `${b.title} (\`${metricCode}\`)`
|
|
79
|
+
: `\`${metricCode || b.title}\``;
|
|
80
|
+
const current = src.current_record ? `**${src.current_record} ${src.unit || ''}**`.trim() : '—';
|
|
81
|
+
const baseline = src.previous_record ? `${src.previous_record} ${src.unit || ''}`.trim() : '—';
|
|
82
|
+
const tier = src.verification_status || 'unverified';
|
|
83
|
+
const ref = src.doi ? `[${src.doi}](https://doi.org/${src.doi})` : (src.lab || 'empirical');
|
|
84
|
+
lines.push(`| ${metricDisplay} | ${current} | ${baseline} | ${tier} | ${ref} |`);
|
|
85
|
+
}
|
|
86
|
+
return lines.join('\n');
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
if (benchmarkSection.cached) cacheHits++; else cacheMisses++;
|
|
90
|
+
|
|
91
|
+
// Section 3: Verified Capability Breakthroughs (Priority 80)
|
|
92
|
+
const breakthroughsSection = await getCachedSection({
|
|
93
|
+
derivedDir,
|
|
94
|
+
sectionKey: 'breakthroughs',
|
|
95
|
+
nodes: researchNodes,
|
|
96
|
+
generatorFn: async () => {
|
|
97
|
+
if (researchNodes.length === 0) return '';
|
|
98
|
+
const lines = ['### 3. Verified Capability Breakthroughs (What Is Now Possible)\n'];
|
|
99
|
+
// Apply sliding window to prioritize the most recent breakthroughs
|
|
100
|
+
const sorted = [...researchNodes].sort((a, b) => new Date(b.timestamp || 0).getTime() - new Date(a.timestamp || 0).getTime());
|
|
101
|
+
const windowed = sorted.slice(0, slidingWindowLimit);
|
|
102
|
+
|
|
103
|
+
for (const r of windowed) {
|
|
104
|
+
const src = r.source || {};
|
|
105
|
+
const tier = src.epistemic_tier ? `[Tier ${src.epistemic_tier}] ` : '';
|
|
106
|
+
const modality = src.empirical_modality ? `(\`${src.empirical_modality}\`)` : '';
|
|
107
|
+
lines.push(`#### ${tier}${r.title || r.slug} ${modality}`);
|
|
108
|
+
if (r.description) lines.push(`> **Capability**: ${r.description}\n`);
|
|
109
|
+
if (src.doi) lines.push(`* **Primary DOI**: https://doi.org/${src.doi}`);
|
|
110
|
+
if (Array.isArray(r.enables) && r.enables.length > 0) {
|
|
111
|
+
lines.push(`* **Unlocks Subgraph**: ${r.enables.join(', ')}`);
|
|
112
|
+
}
|
|
113
|
+
lines.push('');
|
|
114
|
+
}
|
|
115
|
+
return lines.join('\n');
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
if (breakthroughsSection.cached) cacheHits++; else cacheMisses++;
|
|
119
|
+
|
|
120
|
+
// Section 4: Dynamic Plugin Injections
|
|
121
|
+
const pluginSections = [];
|
|
122
|
+
for (const plug of plugins) {
|
|
123
|
+
const plugNodes = activeNodes.filter(n => Array.isArray(plug.categories) && plug.categories.includes(n.category));
|
|
124
|
+
const pSec = await getCachedSection({
|
|
125
|
+
derivedDir,
|
|
126
|
+
sectionKey: `plugin_${plug.id}`,
|
|
127
|
+
nodes: plugNodes,
|
|
128
|
+
extraInput: JSON.stringify(plug.manifest || {}),
|
|
129
|
+
generatorFn: async () => {
|
|
130
|
+
const lines = [`### Plugin: ${plug.name || plug.id}\n`];
|
|
131
|
+
if (plug.description) lines.push(`> ${plug.description}\n`);
|
|
132
|
+
for (const pn of plugNodes) {
|
|
133
|
+
lines.push(`- **${pn.title || pn.slug}**: ${pn.body || pn.description || ''}`);
|
|
134
|
+
}
|
|
135
|
+
return lines.join('\n');
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
if (pSec.cached) cacheHits++; else cacheMisses++;
|
|
139
|
+
pluginSections.push({ priority: 60, content: pSec.content });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Manage total context budget
|
|
143
|
+
const allSections = [
|
|
144
|
+
{ priority: 100, content: projectsSection.content, allowTruncation: false },
|
|
145
|
+
{ priority: 90, content: benchmarkSection.content },
|
|
146
|
+
{ priority: 80, content: breakthroughsSection.content },
|
|
147
|
+
...pluginSections
|
|
148
|
+
].filter(s => s.content && s.content.trim().length > 0);
|
|
149
|
+
|
|
150
|
+
const assembledContent = manageContextBudget(allSections, { maxChars });
|
|
151
|
+
|
|
152
|
+
// Persist to memory-derived/evolving-context.md if derivedDir is provided
|
|
153
|
+
if (derivedDir) {
|
|
154
|
+
try {
|
|
155
|
+
if (!fs.existsSync(derivedDir)) {
|
|
156
|
+
fs.mkdirSync(derivedDir, { recursive: true });
|
|
157
|
+
}
|
|
158
|
+
const targetPath = path.join(derivedDir, 'evolving-context.md');
|
|
159
|
+
fs.writeFileSync(targetPath, assembledContent, 'utf8');
|
|
160
|
+
} catch {
|
|
161
|
+
// Best-effort write
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
content: assembledContent,
|
|
167
|
+
cacheHits,
|
|
168
|
+
cacheMisses,
|
|
169
|
+
totalSections: allSections.length
|
|
170
|
+
};
|
|
171
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { getMeshIp, getMeshHostname } from './mesh.mjs';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Measure user idle time in seconds on the local operating system.
|
|
9
|
+
* macOS uses IOHIDSystem (HIDIdleTime nanoseconds).
|
|
10
|
+
* Linux falls back to w/proc.
|
|
11
|
+
* @returns {number} Idle duration in seconds
|
|
12
|
+
*/
|
|
13
|
+
export function getLocalIdleSeconds() {
|
|
14
|
+
const platform = process.platform;
|
|
15
|
+
|
|
16
|
+
if (platform === 'darwin') {
|
|
17
|
+
try {
|
|
18
|
+
const res = spawnSync('ioreg', ['-c', 'IOHIDSystem'], {
|
|
19
|
+
encoding: 'utf8',
|
|
20
|
+
timeout: 1000,
|
|
21
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
22
|
+
});
|
|
23
|
+
if (res.status === 0 && res.stdout) {
|
|
24
|
+
const match = res.stdout.match(/"HIDIdleTime"\s*=\s*(\d+)/);
|
|
25
|
+
if (match && match[1]) {
|
|
26
|
+
const nanos = BigInt(match[1]);
|
|
27
|
+
const secs = Number(nanos / 1_000_000_000n);
|
|
28
|
+
return Math.max(0, secs);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
} catch {
|
|
32
|
+
// Fallback
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Linux or fallback: check w / who idle time
|
|
37
|
+
try {
|
|
38
|
+
const res = spawnSync('w', ['-u'], {
|
|
39
|
+
encoding: 'utf8',
|
|
40
|
+
timeout: 1000,
|
|
41
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
42
|
+
});
|
|
43
|
+
if (res.status === 0 && res.stdout) {
|
|
44
|
+
const lines = res.stdout.trim().split('\n').slice(2);
|
|
45
|
+
if (lines.length > 0) {
|
|
46
|
+
// Just report 0 if user is logged into an active TTY session
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} catch {}
|
|
51
|
+
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Identify the active editor or developer surface on the local machine.
|
|
57
|
+
* @returns {string} E.g. 'antigravity', 'cursor', 'claude', 'vscode', 'terminal'
|
|
58
|
+
*/
|
|
59
|
+
export function detectActiveSurface() {
|
|
60
|
+
try {
|
|
61
|
+
const res = spawnSync('ps', ['-ax', '-o', 'comm'], {
|
|
62
|
+
encoding: 'utf8',
|
|
63
|
+
timeout: 1500,
|
|
64
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
if (res.status === 0 && res.stdout) {
|
|
68
|
+
const procs = res.stdout.toLowerCase();
|
|
69
|
+
if (procs.includes('antigravity')) return 'antigravity';
|
|
70
|
+
if (procs.includes('cursor')) return 'cursor';
|
|
71
|
+
if (procs.includes('code') || procs.includes('electron')) return 'vscode';
|
|
72
|
+
if (procs.includes('claude')) return 'claude';
|
|
73
|
+
}
|
|
74
|
+
} catch {}
|
|
75
|
+
|
|
76
|
+
return 'terminal';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Generate a complete presence heartbeat for the local node.
|
|
81
|
+
* @returns {object} Presence record
|
|
82
|
+
*/
|
|
83
|
+
export function getLocalPresence() {
|
|
84
|
+
const idleSecs = getLocalIdleSeconds();
|
|
85
|
+
const isActive = idleSecs < 300; // Active if interacted within last 5 minutes
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
const lastInteraction = now - (idleSecs * 1000);
|
|
88
|
+
|
|
89
|
+
let hostname = 'unknown-node';
|
|
90
|
+
try {
|
|
91
|
+
hostname = getMeshHostname() || os.hostname();
|
|
92
|
+
} catch {
|
|
93
|
+
hostname = os.hostname();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let meshIp = null;
|
|
97
|
+
try {
|
|
98
|
+
meshIp = getMeshIp();
|
|
99
|
+
} catch {}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
node_id: hostname,
|
|
103
|
+
mesh_ip: meshIp,
|
|
104
|
+
user_active: isActive,
|
|
105
|
+
idle_seconds: idleSecs,
|
|
106
|
+
last_interaction: lastInteraction,
|
|
107
|
+
active_surface: detectActiveSurface(),
|
|
108
|
+
timestamp: new Date(now).toISOString()
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* "Follow the User" dynamic dispatch resolver.
|
|
114
|
+
* Evaluates presence records across mesh nodes to determine the user's currently active device.
|
|
115
|
+
* @param {Array<object>} presenceList - Array of presence records from cluster nodes
|
|
116
|
+
* @returns {object} The most actively interacted node
|
|
117
|
+
*/
|
|
118
|
+
export function resolveActiveDevice(presenceList = []) {
|
|
119
|
+
if (!Array.isArray(presenceList) || presenceList.length === 0) {
|
|
120
|
+
return {
|
|
121
|
+
node_id: 'local',
|
|
122
|
+
mesh_ip: null,
|
|
123
|
+
user_active: true,
|
|
124
|
+
active_surface: 'local'
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Filter for active nodes
|
|
129
|
+
const activeNodes = presenceList.filter(p => p && p.user_active);
|
|
130
|
+
|
|
131
|
+
if (activeNodes.length === 0) {
|
|
132
|
+
// If no nodes are actively interacted with within threshold, select the most recently interacted
|
|
133
|
+
const sorted = [...presenceList].sort((a, b) => (b.last_interaction || 0) - (a.last_interaction || 0));
|
|
134
|
+
return sorted[0] || { node_id: 'local', mesh_ip: null, user_active: false };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Return the active node with the most recent interaction timestamp
|
|
138
|
+
activeNodes.sort((a, b) => (b.last_interaction || 0) - (a.last_interaction || 0));
|
|
139
|
+
return activeNodes[0];
|
|
140
|
+
}
|
package/src/core/mesh.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { spawnSync } from 'node:child_process';
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import crypto from 'node:crypto';
|
|
4
4
|
import {
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from './vfs-documents.mjs';
|
|
9
9
|
import { logger } from './logger.mjs';
|
|
10
10
|
import { STATUS_TIMEOUT_MS, resolveTailscaleBinary } from './tailscale-cli.mjs';
|
|
11
|
+
import { buildSshArgs, resolveNodeAccess } from './mesh-access.mjs';
|
|
11
12
|
|
|
12
13
|
const CACHE_MS = 2_000;
|
|
13
14
|
|
|
@@ -355,6 +356,67 @@ export async function setMeshNodeAccess(nameOrAddress, accessPatch, options = {}
|
|
|
355
356
|
return { written: !!result?.success, path: vfsPath, access: merged, result };
|
|
356
357
|
}
|
|
357
358
|
|
|
359
|
+
/**
|
|
360
|
+
* Ensure every live mesh peer has an entity document.
|
|
361
|
+
*
|
|
362
|
+
* `patchOwnMeshNode()` describes only the machine it runs on, so a peer that
|
|
363
|
+
* will never run a brain can never be described: a phone cannot run one, a
|
|
364
|
+
* control-plane droplet may not, and a machine that is simply switched off
|
|
365
|
+
* never gets the chance. On a five-node tailnet that left three nodes with no
|
|
366
|
+
* entity at all — including the always-on host, which is the one an agent is
|
|
367
|
+
* most likely to need access details for.
|
|
368
|
+
*
|
|
369
|
+
* Offline peers are deliberately skipped rather than created. A node that has
|
|
370
|
+
* not been seen in weeks is usually a dead registration, and minting an entity
|
|
371
|
+
* for it re-legitimises something the operator may be trying to retire. They
|
|
372
|
+
* are reported instead, so the choice stays with the person.
|
|
373
|
+
*
|
|
374
|
+
* Creation goes through `setMeshNodeAccess`, so it is the same Operation
|
|
375
|
+
* Contract path as every other entity write — never a raw file write.
|
|
376
|
+
*
|
|
377
|
+
* Discovery and the writer are injectable so this is testable without a live
|
|
378
|
+
* tailnet or a real vault; production callers pass neither.
|
|
379
|
+
*
|
|
380
|
+
* @param {{vaultRoot?: string, includeOffline?: boolean, peers?: object[],
|
|
381
|
+
* entities?: object[], write?: Function}} [options]
|
|
382
|
+
* @returns {Promise<{created: string[], skipped_offline: string[], existing: number}>}
|
|
383
|
+
*/
|
|
384
|
+
export async function ensureMeshNodeEntities(options = {}) {
|
|
385
|
+
const vaultRoot = options.vaultRoot || defaultVaultRoot();
|
|
386
|
+
const peers = options.peers || getMeshPeers({ includeSelf: true });
|
|
387
|
+
const entities = options.entities || listMeshNodeEntities(vaultRoot);
|
|
388
|
+
const write = options.write || ((host) => setMeshNodeAccess(host, {}, { vaultRoot }));
|
|
389
|
+
|
|
390
|
+
const knownKeys = new Set(entities.map((e) => meshNodeKey(e.hostname)).filter(Boolean));
|
|
391
|
+
const knownIps = new Set(entities.map((e) => e.ip).filter(Boolean).map(String));
|
|
392
|
+
|
|
393
|
+
const created = [];
|
|
394
|
+
const skippedOffline = [];
|
|
395
|
+
|
|
396
|
+
for (const peer of peers) {
|
|
397
|
+
if (!peer.hostname) continue;
|
|
398
|
+
// Match on either identity: the same host can be recorded under a bare or
|
|
399
|
+
// suffixed name, and creating a second document for it is the duplicate
|
|
400
|
+
// problem meshNodeKey exists to prevent.
|
|
401
|
+
if (knownKeys.has(meshNodeKey(peer.hostname))) continue;
|
|
402
|
+
if (peer.ip && knownIps.has(String(peer.ip))) continue;
|
|
403
|
+
|
|
404
|
+
if (!peer.online && !options.includeOffline) {
|
|
405
|
+
skippedOffline.push(peer.hostname);
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const res = await write(peer.hostname);
|
|
410
|
+
if (res.written) {
|
|
411
|
+
created.push(peer.hostname);
|
|
412
|
+
knownKeys.add(meshNodeKey(peer.hostname));
|
|
413
|
+
if (peer.ip) knownIps.add(String(peer.ip));
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return { created, skipped_offline: skippedOffline, existing: entities.length };
|
|
418
|
+
}
|
|
419
|
+
|
|
358
420
|
/**
|
|
359
421
|
* Upsert **this** node's mesh_node entity from live discovery.
|
|
360
422
|
* Does not hardcode device names — slug and fields come from Tailscale self.
|
|
@@ -507,3 +569,114 @@ export async function patchOwnMeshNode(options = {}) {
|
|
|
507
569
|
result,
|
|
508
570
|
};
|
|
509
571
|
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Execute a command on a remote mesh node over SSH.
|
|
575
|
+
*
|
|
576
|
+
* @param {string} target - Node hostname or IP
|
|
577
|
+
* @param {string} command - Shell command to execute
|
|
578
|
+
* @param {object} options
|
|
579
|
+
* @param {string} [options.vaultRoot]
|
|
580
|
+
* @param {number} [options.timeoutMs=60000]
|
|
581
|
+
* @param {boolean} [options.loginShell=true]
|
|
582
|
+
* @returns {Promise<{ node: string, hostname: string, ip: string, exitCode: number, success: boolean, stdout: string, stderr: string }>}
|
|
583
|
+
*/
|
|
584
|
+
export async function execMeshCommand(target, command, options = {}) {
|
|
585
|
+
const { vaultRoot = defaultVaultRoot(), timeoutMs = 60_000, loginShell = true } = options;
|
|
586
|
+
const node = findMeshNode(target, vaultRoot);
|
|
587
|
+
if (!node) {
|
|
588
|
+
throw new Error(`No mesh node matches "${target}".`);
|
|
589
|
+
}
|
|
590
|
+
const resolved = resolveNodeAccess(node);
|
|
591
|
+
if (!resolved.complete) {
|
|
592
|
+
throw new Error(
|
|
593
|
+
`No login account recorded for ${node.hostname}. Run 'total-recall mesh access ${target} --user <login>'`,
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const standardPath = 'export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:$HOME/.local/bin:$PATH";';
|
|
598
|
+
const remoteCommand = `${standardPath} ${command}`;
|
|
599
|
+
const sshArgs = buildSshArgs(resolved, {
|
|
600
|
+
command: remoteCommand,
|
|
601
|
+
extraOptions: ['BatchMode=yes', 'ConnectTimeout=10'],
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
return new Promise((resolve, reject) => {
|
|
605
|
+
const proc = spawn('ssh', sshArgs);
|
|
606
|
+
let stdout = '';
|
|
607
|
+
let stderr = '';
|
|
608
|
+
let timedOut = false;
|
|
609
|
+
|
|
610
|
+
const timer = setTimeout(() => {
|
|
611
|
+
timedOut = true;
|
|
612
|
+
proc.kill('SIGTERM');
|
|
613
|
+
reject(new Error(`Command on "${target}" timed out after ${timeoutMs}ms.`));
|
|
614
|
+
}, timeoutMs);
|
|
615
|
+
|
|
616
|
+
proc.stdout.on('data', (d) => { stdout += d.toString(); });
|
|
617
|
+
proc.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
618
|
+
|
|
619
|
+
proc.on('close', (code) => {
|
|
620
|
+
clearTimeout(timer);
|
|
621
|
+
if (timedOut) return;
|
|
622
|
+
resolve({
|
|
623
|
+
node: target,
|
|
624
|
+
hostname: node.hostname,
|
|
625
|
+
ip: node.ip,
|
|
626
|
+
exitCode: code ?? 1,
|
|
627
|
+
success: code === 0,
|
|
628
|
+
stdout: stdout.trim(),
|
|
629
|
+
stderr: stderr.trim(),
|
|
630
|
+
});
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
proc.on('error', (err) => {
|
|
634
|
+
clearTimeout(timer);
|
|
635
|
+
reject(err);
|
|
636
|
+
});
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Resolves the primary or Cloudflare Tunnel fallback URL for mesh control plane access.
|
|
642
|
+
* Returns { url, source, is_fallback }
|
|
643
|
+
*
|
|
644
|
+
* @param {object} [options]
|
|
645
|
+
* @param {string} [options.url]
|
|
646
|
+
* @param {string} [options.loginServer]
|
|
647
|
+
* @returns {{ url: string|null, source: string, is_fallback: boolean }}
|
|
648
|
+
*/
|
|
649
|
+
export function resolveMeshControlUrl(options = {}) {
|
|
650
|
+
const explicit = options.url || options.loginServer;
|
|
651
|
+
if (explicit && String(explicit).trim()) {
|
|
652
|
+
const clean = String(explicit).trim();
|
|
653
|
+
return {
|
|
654
|
+
url: clean.endsWith('/') ? clean.slice(0, -1) : clean,
|
|
655
|
+
source: 'explicit',
|
|
656
|
+
is_fallback: false
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const envPrimary = process.env.TR_LOGIN_SERVER || process.env.TR_HEADSCALE_URL;
|
|
661
|
+
if (envPrimary && envPrimary.trim()) {
|
|
662
|
+
const clean = envPrimary.trim();
|
|
663
|
+
return {
|
|
664
|
+
url: clean.endsWith('/') ? clean.slice(0, -1) : clean,
|
|
665
|
+
source: 'env_primary',
|
|
666
|
+
is_fallback: false
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const envTunnel = process.env.TR_CLOUDFLARE_TUNNEL_URL || process.env.CF_TUNNEL_URL;
|
|
671
|
+
if (envTunnel && envTunnel.trim()) {
|
|
672
|
+
const clean = envTunnel.trim();
|
|
673
|
+
return {
|
|
674
|
+
url: clean.endsWith('/') ? clean.slice(0, -1) : clean,
|
|
675
|
+
source: 'cloudflare_tunnel_fallback',
|
|
676
|
+
is_fallback: true
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
return { url: null, source: 'none', is_fallback: false };
|
|
681
|
+
}
|
|
682
|
+
|