robopark 2.8.36 → 3.0.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.
Files changed (68) hide show
  1. package/README.md +88 -63
  2. package/bin/robopark.js +7 -17
  3. package/conversation/elevenlabs_agent.py +1985 -0
  4. package/conversation/requirements.txt +3 -0
  5. package/conversation/supervisor_store.py +189 -0
  6. package/dist/kernel/config-schema.js +37 -0
  7. package/dist/kernel/types.js +7 -0
  8. package/dist/robopark/access.js +99 -0
  9. package/dist/robopark/add-robot.js +188 -0
  10. package/dist/robopark/agent-ctl.js +305 -0
  11. package/dist/robopark/auto-start.js +289 -0
  12. package/dist/robopark/conversation.js +505 -0
  13. package/dist/robopark/deployment-commands.js +47 -0
  14. package/dist/robopark/discovery.js +180 -0
  15. package/dist/robopark/doctor.js +175 -0
  16. package/dist/robopark/enroll.js +68 -0
  17. package/dist/robopark/llm-set.js +87 -0
  18. package/dist/robopark/motor-control.js +195 -0
  19. package/dist/robopark/preview-agent-launcher.js +77 -0
  20. package/dist/robopark/probe.js +138 -0
  21. package/dist/robopark/profile.js +69 -0
  22. package/dist/robopark/python-env.js +162 -0
  23. package/dist/robopark/robot-runtime.js +489 -0
  24. package/dist/robopark/scan.js +97 -0
  25. package/dist/robopark/screen-control.js +55 -0
  26. package/dist/robopark/secrets.js +41 -0
  27. package/dist/robopark/serve.js +285 -0
  28. package/dist/robopark/server-add.js +114 -0
  29. package/dist/robopark/setup-livekit.js +300 -0
  30. package/dist/robopark/setup.js +286 -0
  31. package/dist/robopark/standalone.js +466 -0
  32. package/dist/robopark/stop-all.js +141 -0
  33. package/dist/robopark/verify.js +192 -0
  34. package/dist/robopark/vision-agent-launcher.js +98 -0
  35. package/dist/robopark/vision-control.js +81 -0
  36. package/dist/robopark-cli.js +799 -0
  37. package/package.json +21 -5
  38. package/pi-client/_install_steps.sh +29 -29
  39. package/pi-client/client.py +61 -2
  40. package/pi-client/install.sh +40 -40
  41. package/pi-client/join_convo.sh +54 -54
  42. package/pi-client/livekit_bridge.py +16 -7
  43. package/pi-client/motor_bridge.py +6 -3
  44. package/scheduler/fleet_config.json +75 -0
  45. package/scheduler/main.py +4505 -135
  46. package/scheduler/media_lock.py +57 -0
  47. package/scheduler/preview_agent.py +1465 -87
  48. package/scheduler/production_config.json +139 -0
  49. package/scheduler/robot_supervisor.py +1705 -0
  50. package/scheduler/scripts/install-robot-supervisor-linux.sh +33 -0
  51. package/scheduler/scripts/install-robot-supervisor-windows.ps1 +49 -0
  52. package/scheduler/scripts/robopark-supervisor.service +20 -0
  53. package/scheduler/scripts/start-scheduler-local.ps1 +50 -0
  54. package/scheduler/supervisor.example.json +26 -0
  55. package/scheduler/vision_motion_trigger.py +101 -0
  56. package/screen/screen_runtime.py +75 -0
  57. package/vision/app_pi_clean.py +253 -16
  58. package/vision/audio_server_pi.py +19 -0
  59. package/vision/install.sh +34 -34
  60. package/vision/motor_server.py +224 -61
  61. package/vision/requirements_camera.txt +6 -0
  62. package/vision/requirements_motor.txt +4 -0
  63. package/vision/requirements_pi_unified.txt +1 -0
  64. package/vision/requirements_vision_agent.txt +19 -0
  65. package/vision/run.sh +244 -244
  66. package/vision/services/services.sh +12 -12
  67. package/scheduler/__pycache__/main.cpython-312.pyc +0 -0
  68. package/scheduler/__pycache__/preview_agent.cpython-312.pyc +0 -0
@@ -0,0 +1,180 @@
1
+ /**
2
+ * RoboPark — auto-discovery helpers.
3
+ *
4
+ * Scans the environment so operators never type IPs, URLs, or tokens:
5
+ * - Tailscale status → find the RoboPark hub and InfiniBot gateway
6
+ * - LAN beacons → find robots
7
+ * - Known config files → read Tailscale auth key, InfiniBot gateway token
8
+ * - Running infinicode nodes → reuse mesh identity + token
9
+ */
10
+ import { execa } from 'execa';
11
+ import dgram from 'node:dgram';
12
+ import { existsSync, readFileSync } from 'node:fs';
13
+ import { homedir, hostname } from 'node:os';
14
+ import { join } from 'node:path';
15
+ const HUB_HINTS = /livekit|hub|scheduler|robopark/i;
16
+ const GATEWAY_HINTS = /infinibot|gateway/i;
17
+ const ROBOT_HINTS = /robo|panda|bear|car|dragon|frog|bmw/i;
18
+ /** Best IPv4-ish address from a Tailscale peer. */
19
+ function ipv4(peer) {
20
+ return (peer.TailscaleIPs ?? []).find(ip => ip.includes('.'));
21
+ }
22
+ /** Run `tailscale status --json` and parse. */
23
+ export async function tailscaleStatus() {
24
+ try {
25
+ const { stdout } = await execa('tailscale', ['status', '--json'], { timeout: 5000 });
26
+ return JSON.parse(stdout);
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
32
+ /** Discover peers from Tailscale. */
33
+ export async function discoverTailscale() {
34
+ const status = await tailscaleStatus();
35
+ if (!status?.Peer)
36
+ return [];
37
+ return Object.values(status.Peer)
38
+ .filter(p => p.Online)
39
+ .map(p => ({
40
+ name: p.HostName ?? p.DNSName?.split('.')[0] ?? 'unknown',
41
+ ip: ipv4(p) ?? 'unknown',
42
+ tags: p.Tags,
43
+ online: true,
44
+ source: 'tailscale',
45
+ }))
46
+ .filter(p => p.ip !== 'unknown');
47
+ }
48
+ /** Scan LAN UDP beacons on the default mesh discovery port. */
49
+ export async function discoverLan(port = 47915, timeoutMs = 6000) {
50
+ return new Promise(resolve => {
51
+ const peers = new Map();
52
+ const socket = dgram.createSocket({ type: 'udp4', reuseAddr: true });
53
+ let settled = false;
54
+ const finish = () => {
55
+ if (settled)
56
+ return;
57
+ settled = true;
58
+ try {
59
+ socket.close();
60
+ }
61
+ catch { /* already closed */ }
62
+ resolve([...peers.values()]);
63
+ };
64
+ socket.on('message', (buffer, remote) => {
65
+ try {
66
+ const beacon = JSON.parse(buffer.toString('utf8'));
67
+ if (beacon.t !== 'ICMESH1' || !beacon.nodeId || !Number.isInteger(beacon.meshPort))
68
+ return;
69
+ peers.set(beacon.nodeId, {
70
+ name: beacon.name ?? beacon.nodeId,
71
+ ip: remote.address,
72
+ tags: beacon.tag ? [beacon.tag] : undefined,
73
+ online: true,
74
+ source: 'lan',
75
+ });
76
+ }
77
+ catch {
78
+ // Ignore unrelated UDP traffic on the discovery port.
79
+ }
80
+ });
81
+ socket.once('error', finish);
82
+ socket.bind({ port, exclusive: false });
83
+ setTimeout(finish, timeoutMs).unref();
84
+ });
85
+ }
86
+ /** Read a Tailscale auth key from common locations. */
87
+ export function findTailscaleAuthKey() {
88
+ const paths = [
89
+ join(homedir(), '.robopark', 'tailscale.key'),
90
+ join(homedir(), '.config', 'robopark', 'tailscale.key'),
91
+ process.env.ROBOPARK_TAILSCALE_KEY ? '__env__' : undefined,
92
+ ].filter(Boolean);
93
+ for (const p of paths) {
94
+ if (p === '__env__')
95
+ return process.env.ROBOPARK_TAILSCALE_KEY;
96
+ if (existsSync(p)) {
97
+ const key = readFileSync(p, 'utf8').trim();
98
+ if (key.startsWith('tskey-'))
99
+ return key;
100
+ }
101
+ }
102
+ return undefined;
103
+ }
104
+ /** Read InfiniBot gateway config from common locations. */
105
+ export function findInfinibotGateway() {
106
+ const candidates = [
107
+ join(homedir(), '.infinibot', 'config.json'),
108
+ join(homedir(), '.config', 'infinibot', 'config.json'),
109
+ join(homedir(), 'AppData', 'Roaming', 'infinibot', 'config.json'),
110
+ ];
111
+ for (const p of candidates) {
112
+ if (!existsSync(p))
113
+ continue;
114
+ try {
115
+ const cfg = JSON.parse(readFileSync(p, 'utf8'));
116
+ const gateway = cfg.gatewayUrl ?? cfg.gateway ?? cfg.gateway_url;
117
+ const token = cfg.gatewayToken ?? cfg.gateway_token ?? cfg.token;
118
+ if (typeof gateway === 'string') {
119
+ const u = new URL(gateway);
120
+ return { host: u.hostname, port: parseInt(u.port || '18789', 10), token: typeof token === 'string' ? token : undefined };
121
+ }
122
+ }
123
+ catch {
124
+ // ignore malformed
125
+ }
126
+ }
127
+ return undefined;
128
+ }
129
+ /** Read existing infinicode mesh token/port from config. */
130
+ export function readInfinicodeFederation() {
131
+ const paths = [
132
+ join(homedir(), 'AppData', 'Roaming', 'infinicode-nodejs', 'Config', 'config.json'),
133
+ join(homedir(), '.config', 'infinicode-nodejs', 'config.json'),
134
+ join(homedir(), '.infinicode-nodejs', 'config.json'),
135
+ ];
136
+ for (const p of paths) {
137
+ if (!existsSync(p))
138
+ continue;
139
+ try {
140
+ const cfg = JSON.parse(readFileSync(p, 'utf8'));
141
+ return { token: cfg.federation?.token, port: cfg.federation?.port };
142
+ }
143
+ catch {
144
+ // ignore
145
+ }
146
+ }
147
+ return {};
148
+ }
149
+ /** Build the full discovered context for this environment. */
150
+ export async function discoverContext(options = {}) {
151
+ const [tailnetPeers, lanPeers] = await Promise.all([
152
+ discoverTailscale(),
153
+ options.lan ? discoverLan() : Promise.resolve([]),
154
+ ]);
155
+ const peers = [...lanPeers, ...tailnetPeers];
156
+ // Exclude this machine from the fleet tables.
157
+ const selfName = hostname();
158
+ const others = peers.filter(p => p.name !== selfName && p.online);
159
+ const hub = others.find(p => HUB_HINTS.test(p.name));
160
+ const robots = others.filter(p => ROBOT_HINTS.test(p.name) || p.tags?.some(t => /robopark/i.test(t)));
161
+ const gateway = findInfinibotGateway();
162
+ const fed = readInfinicodeFederation();
163
+ return {
164
+ hub,
165
+ gateway,
166
+ robots,
167
+ tailscaleAuthKey: findTailscaleAuthKey(),
168
+ meshToken: fed.token,
169
+ meshPort: fed.port,
170
+ localName: hostname(),
171
+ };
172
+ }
173
+ /** Generate a random token for mesh auth or scheduler enrollment. */
174
+ export function generateToken(length = 32) {
175
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
176
+ let out = '';
177
+ for (let i = 0; i < length; i++)
178
+ out += chars[Math.floor(Math.random() * chars.length)];
179
+ return out;
180
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * RoboPark — `robopark doctor`.
3
+ *
4
+ * A fast, all-in-one health check that replaces the manual curl-by-curl
5
+ * triage session: hub federation status, scheduler reachability, LiveKit
6
+ * server registration, device heartbeat freshness, and production mode.
7
+ *
8
+ * Usage:
9
+ * robopark doctor
10
+ * robopark doctor --hub-url http://100.64.1.2:47913 --scheduler-url http://100.64.1.2:8080
11
+ */
12
+ import chalk from 'chalk';
13
+ import { discoverContext } from './discovery.js';
14
+ async function fetchJson(url, init) {
15
+ const res = await fetch(url, { ...init, signal: AbortSignal.timeout(5000) });
16
+ if (!res.ok)
17
+ throw new Error(`${res.status} ${res.statusText}`);
18
+ return res.json();
19
+ }
20
+ const STALE_HEARTBEAT_MS = 60_000;
21
+ function parseTimestamp(value) {
22
+ if (typeof value !== 'string' || !value)
23
+ return undefined;
24
+ // Scheduler stores naive UTC timestamps (no trailing 'Z'); normalize so
25
+ // Date.parse treats them as UTC instead of local time.
26
+ const iso = /[zZ]|[+-]\d\d:\d\d$/.test(value) ? value : `${value}Z`;
27
+ const ms = Date.parse(iso);
28
+ return Number.isNaN(ms) ? undefined : ms;
29
+ }
30
+ export async function roboparkDoctor(opts) {
31
+ const ctx = await discoverContext();
32
+ const hubUrl = (opts.hubUrl ?? (ctx.hub ? `http://${ctx.hub.ip}:47913` : 'http://localhost:47913')).replace(/\/$/, '');
33
+ const schedulerUrl = (opts.schedulerUrl ?? (ctx.hub ? `http://${ctx.hub.ip}:8080` : 'http://localhost:8080')).replace(/\/$/, '');
34
+ const authHeaders = opts.token ? { Authorization: `Bearer ${opts.token}` } : {};
35
+ console.log(chalk.bold('\n robopark doctor'));
36
+ console.log(chalk.dim(' ' + '─'.repeat(52)));
37
+ console.log(` hub: ${chalk.cyan(hubUrl)}`);
38
+ console.log(` scheduler: ${chalk.cyan(schedulerUrl)}`);
39
+ console.log();
40
+ const checks = [];
41
+ // 1. Hub /fed/status reachable + connected nodes.
42
+ try {
43
+ const status = (await fetchJson(`${hubUrl}/fed/status`, { headers: authHeaders }));
44
+ const nodes = status.nodes ?? [];
45
+ const connected = nodes.filter(n => n.connected);
46
+ checks.push({
47
+ name: 'hub reachable',
48
+ status: 'pass',
49
+ message: `self=${status.self?.displayName ?? '?'} (${status.self?.role ?? '?'})`,
50
+ });
51
+ if (nodes.length === 0) {
52
+ checks.push({ name: 'mesh peers', status: 'warn', message: 'no peers known to this hub', detail: 'Nothing has joined the mesh yet, or this node has not discovered peers.' });
53
+ }
54
+ else if (connected.length === 0) {
55
+ checks.push({ name: 'mesh peers', status: 'fail', message: `${nodes.length} known peer(s), 0 connected`, detail: 'Check Tailscale connectivity and that peer processes are running.' });
56
+ }
57
+ else {
58
+ const list = connected.map(n => `${n.displayName ?? n.nodeId}(${n.role ?? '?'})`).join(', ');
59
+ checks.push({ name: 'mesh peers', status: 'pass', message: `${connected.length}/${nodes.length} connected — ${list}` });
60
+ }
61
+ }
62
+ catch (e) {
63
+ checks.push({ name: 'hub reachable', status: 'fail', message: `could not reach ${hubUrl}/fed/status: ${e.message}`, detail: 'Is the hub node running? Is it on the same tailnet? Try --hub-url.' });
64
+ }
65
+ // 2. Scheduler /api/robots + /api/devices reachable.
66
+ let robots = [];
67
+ let devices = [];
68
+ try {
69
+ robots = (await fetchJson(`${schedulerUrl}/api/robots`));
70
+ checks.push({ name: 'scheduler /api/robots', status: 'pass', message: `${robots.length} robot(s)` });
71
+ }
72
+ catch (e) {
73
+ checks.push({ name: 'scheduler /api/robots', status: 'fail', message: `error: ${e.message}`, detail: 'Is robopark serve running? Check --scheduler-url.' });
74
+ }
75
+ try {
76
+ devices = (await fetchJson(`${schedulerUrl}/api/devices`));
77
+ checks.push({ name: 'scheduler /api/devices', status: 'pass', message: `${devices.length} device(s)` });
78
+ }
79
+ catch (e) {
80
+ checks.push({ name: 'scheduler /api/devices', status: 'fail', message: `error: ${e.message}` });
81
+ }
82
+ // 3. LiveKit servers registered + online.
83
+ try {
84
+ const servers = (await fetchJson(`${schedulerUrl}/api/servers`));
85
+ if (servers.length === 0) {
86
+ checks.push({ name: 'livekit servers', status: 'fail', message: 'no servers in scheduler', detail: 'Add one via the dashboard or robopark setup-livekit.' });
87
+ }
88
+ else {
89
+ for (const s of servers) {
90
+ const label = s.name ?? s.id;
91
+ if (!s.url) {
92
+ checks.push({ name: `server ${label}`, status: 'fail', message: 'no url configured' });
93
+ }
94
+ else if (s.status === 'online') {
95
+ checks.push({ name: `server ${label}`, status: 'pass', message: `online @ ${s.url}` });
96
+ }
97
+ else if (s.status === 'offline') {
98
+ checks.push({ name: `server ${label}`, status: 'fail', message: `offline @ ${s.url}`, detail: 'Check that the LiveKit container/process is running and reachable.' });
99
+ }
100
+ else {
101
+ checks.push({ name: `server ${label}`, status: 'warn', message: `status=${s.status ?? 'unknown'} @ ${s.url}` });
102
+ }
103
+ }
104
+ }
105
+ }
106
+ catch (e) {
107
+ checks.push({ name: 'livekit servers', status: 'fail', message: `error: ${e.message}` });
108
+ }
109
+ // 4. Stale device heartbeats.
110
+ const now = Date.now();
111
+ const staleDevices = devices.filter(d => {
112
+ const hb = parseTimestamp(d.last_heartbeat);
113
+ return hb !== undefined && now - hb > STALE_HEARTBEAT_MS;
114
+ });
115
+ const noHeartbeatDevices = devices.filter(d => parseTimestamp(d.last_heartbeat) === undefined);
116
+ if (devices.length === 0) {
117
+ // covered by the /api/devices check above
118
+ }
119
+ else if (staleDevices.length === 0 && noHeartbeatDevices.length === 0) {
120
+ checks.push({ name: 'device heartbeats', status: 'pass', message: `all ${devices.length} device(s) heartbeating within ${STALE_HEARTBEAT_MS / 1000}s` });
121
+ }
122
+ else {
123
+ const parts = [];
124
+ if (staleDevices.length)
125
+ parts.push(`${staleDevices.length} stale: ${staleDevices.map(d => d.name ?? d.id).join(', ')}`);
126
+ if (noHeartbeatDevices.length)
127
+ parts.push(`${noHeartbeatDevices.length} never heartbeat: ${noHeartbeatDevices.map(d => d.name ?? d.id).join(', ')}`);
128
+ checks.push({ name: 'device heartbeats', status: 'warn', message: parts.join('; '), detail: 'Is robopark preview-agent (or the device process) running on those machines?' });
129
+ }
130
+ // 5. Production mode.
131
+ try {
132
+ const settings = (await fetchJson(`${schedulerUrl}/api/settings`));
133
+ if (settings.production_mode) {
134
+ checks.push({ name: 'production mode', status: 'pass', message: 'ON' });
135
+ }
136
+ else {
137
+ checks.push({ name: 'production mode', status: 'warn', message: 'OFF', detail: 'New device enrollment and preview sessions are blocked until production mode is ON (toggle in the dashboard or PUT /api/settings).' });
138
+ }
139
+ if (settings.default_enrollment_token_set === false) {
140
+ checks.push({ name: 'enrollment token', status: 'warn', message: 'no default enrollment token set', detail: 'Rotate one via POST /api/settings/enrollment-token/rotate before enrolling new devices.' });
141
+ }
142
+ }
143
+ catch (e) {
144
+ checks.push({ name: 'production mode', status: 'fail', message: `error: ${e.message}` });
145
+ }
146
+ // Print report.
147
+ let pass = 0;
148
+ let warn = 0;
149
+ let fail = 0;
150
+ for (const c of checks) {
151
+ const icon = c.status === 'pass' ? chalk.green('✓') : c.status === 'warn' ? chalk.yellow('⚠') : chalk.red('✗');
152
+ const color = c.status === 'pass' ? chalk.green : c.status === 'warn' ? chalk.yellow : chalk.red;
153
+ console.log(` ${icon} ${color(c.name)} — ${c.message}`);
154
+ if (c.detail)
155
+ console.log(chalk.dim(` → ${c.detail}`));
156
+ if (c.status === 'pass')
157
+ pass++;
158
+ else if (c.status === 'warn')
159
+ warn++;
160
+ else
161
+ fail++;
162
+ }
163
+ console.log();
164
+ const total = checks.length;
165
+ if (fail === 0 && warn === 0) {
166
+ console.log(chalk.bold.green(` ${pass}/${total} checks passed — fleet is healthy.`));
167
+ }
168
+ else if (fail === 0) {
169
+ console.log(chalk.bold.yellow(` ${pass}/${total} passed, ${warn} warning(s) — review the items above.`));
170
+ }
171
+ else {
172
+ console.log(chalk.bold.red(` ${pass}/${total} passed, ${warn} warning(s), ${fail} failed — fix the items above.`));
173
+ process.exitCode = 1;
174
+ }
175
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * RoboPark — `robopark enroll`.
3
+ *
4
+ * Enrolls this machine as a scheduler device. Either uses an explicit
5
+ * enrollment token, or reads it from ~/.robopark/enrollment_token.
6
+ */
7
+ import { spawn } from 'node:child_process';
8
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
9
+ import { homedir, hostname } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import chalk from 'chalk';
12
+ import { findPython, findSchedulerPath, prepareRobotPython } from './python-env.js';
13
+ const ROBOPARK_DIR = join(homedir(), '.robopark');
14
+ const ENROLLMENT_FILE = join(ROBOPARK_DIR, 'enrollment_token');
15
+ function ensureDir() {
16
+ mkdirSync(ROBOPARK_DIR, { recursive: true });
17
+ }
18
+ function readEnrollmentToken() {
19
+ if (existsSync(ENROLLMENT_FILE)) {
20
+ return readFileSync(ENROLLMENT_FILE, 'utf8').trim();
21
+ }
22
+ return undefined;
23
+ }
24
+ function saveEnrollmentToken(token) {
25
+ ensureDir();
26
+ writeFileSync(ENROLLMENT_FILE, token, { mode: 0o600 });
27
+ }
28
+ export async function roboparkEnroll(opts) {
29
+ const token = opts.enrollmentToken ?? readEnrollmentToken();
30
+ if (!token) {
31
+ console.log(chalk.red(' ✗ pass --enrollment-token <token> or write it to ~/.robopark/enrollment_token'));
32
+ process.exit(1);
33
+ }
34
+ saveEnrollmentToken(token);
35
+ const robotId = opts.robotId ?? hostname().split('.')[0];
36
+ const schedulerUrl = opts.schedulerUrl;
37
+ const script = await findSchedulerPath();
38
+ if (!script) {
39
+ console.log(chalk.red(' ✗ could not find preview_agent.py'));
40
+ process.exit(1);
41
+ }
42
+ const python = prepareRobotPython(findPython(), script);
43
+ if (!python)
44
+ process.exit(1);
45
+ const args = [
46
+ script,
47
+ '--scheduler-url', schedulerUrl,
48
+ '--robot-id', robotId,
49
+ '--enrollment-token', token,
50
+ ];
51
+ if (opts.saveConfig)
52
+ args.push('--save-config');
53
+ console.log(chalk.bold('\n robopark enroll'));
54
+ console.log(chalk.dim(' ' + '─'.repeat(52)));
55
+ console.log(` robot: ${chalk.cyan(robotId)}`);
56
+ console.log(` scheduler: ${chalk.cyan(schedulerUrl)}`);
57
+ console.log();
58
+ const proc = spawn(python, args, { stdio: 'inherit' });
59
+ await new Promise((resolve, reject) => {
60
+ proc.on('error', reject);
61
+ proc.on('close', (code) => {
62
+ if (code === 0)
63
+ resolve();
64
+ else
65
+ reject(new Error(`enrollment exited ${code ?? ''}`));
66
+ });
67
+ });
68
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * RoboPark — `robopark llm set`.
3
+ *
4
+ * Upserts LLM provider config into a ROBOVOICE .env file. ROBOVOICE lives in
5
+ * a separate repo, so the target .env path is always explicit (--env-path) —
6
+ * this command never guesses or writes infinicode's own config.
7
+ *
8
+ * Known keys (from ROBOVOICE's .env.example and src/caal/settings.py /
9
+ * src/caal/webhooks.py, which read OLLAMA_HOST and OLLAMA_MODEL):
10
+ * LLM_PROVIDER — "ollama" | "groq" (and whatever else ROBOVOICE adds)
11
+ * OLLAMA_HOST — Ollama server URL (only meaningful for ollama providers)
12
+ * OLLAMA_MODEL — Ollama model name (only meaningful for ollama providers)
13
+ * OLLAMA_API_KEY — NOT currently read by ROBOVOICE. src/caal/llm/providers/
14
+ * ollama_provider.py constructs `ollama.Client(host=base_url)`
15
+ * with no api_key/auth support, and no OLLAMA_API_KEY lookup
16
+ * exists anywhere in src/caal. We still write the key (for
17
+ * forward-compat with Ollama Cloud, which needs an API key)
18
+ * but it is a no-op until ROBOVOICE's ollama_provider.py is
19
+ * updated to read it — see the gap noted in the CLI output.
20
+ */
21
+ import chalk from 'chalk';
22
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
23
+ /** true for any provider name that talks to an Ollama-compatible server. */
24
+ function isOllamaProvider(provider) {
25
+ return provider === 'ollama' || provider === 'ollama-cloud';
26
+ }
27
+ /** Mask a secret so only the first/last 4 chars are visible, e.g. `abcd…wxyz`. */
28
+ function maskSecret(value) {
29
+ if (value.length <= 8)
30
+ return '*'.repeat(value.length);
31
+ return `${value.slice(0, 4)}…${value.slice(-4)}`;
32
+ }
33
+ /** Replace an existing `KEY=...` line, or append a new one if absent. */
34
+ function upsertEnvVar(lines, key, value) {
35
+ const pattern = new RegExp(`^${key}=`);
36
+ const idx = lines.findIndex(line => pattern.test(line));
37
+ const entry = `${key}=${value}`;
38
+ if (idx >= 0) {
39
+ lines[idx] = entry;
40
+ return lines;
41
+ }
42
+ return [...lines, entry];
43
+ }
44
+ export async function roboparkLlmSet(opts) {
45
+ if (!opts.envPath) {
46
+ console.log(chalk.red(' ✗ --env-path is required (points at the target ROBOVOICE .env file)'));
47
+ process.exit(1);
48
+ }
49
+ if (!existsSync(opts.envPath)) {
50
+ console.log(chalk.red(` ✗ .env file not found: ${opts.envPath}`));
51
+ process.exit(1);
52
+ }
53
+ if (!opts.provider) {
54
+ console.log(chalk.red(' ✗ --provider is required (e.g. ollama, ollama-cloud, groq)'));
55
+ process.exit(1);
56
+ }
57
+ const raw = readFileSync(opts.envPath, 'utf8');
58
+ const hadTrailingNewline = raw.endsWith('\n');
59
+ let lines = raw.split('\n');
60
+ // Drop a single trailing empty entry produced by the final newline so we
61
+ // don't accumulate blank lines across repeated `llm set` runs.
62
+ if (hadTrailingNewline && lines[lines.length - 1] === '')
63
+ lines = lines.slice(0, -1);
64
+ lines = upsertEnvVar(lines, 'LLM_PROVIDER', opts.provider);
65
+ const ollama = isOllamaProvider(opts.provider);
66
+ if (ollama && opts.host)
67
+ lines = upsertEnvVar(lines, 'OLLAMA_HOST', opts.host);
68
+ if (ollama && opts.model)
69
+ lines = upsertEnvVar(lines, 'OLLAMA_MODEL', opts.model);
70
+ if (ollama && opts.apiKey)
71
+ lines = upsertEnvVar(lines, 'OLLAMA_API_KEY', opts.apiKey);
72
+ writeFileSync(opts.envPath, lines.join('\n') + (hadTrailingNewline ? '\n' : ''), 'utf8');
73
+ console.log(chalk.bold('\n robopark llm set'));
74
+ console.log(chalk.dim(' ' + '─'.repeat(52)));
75
+ console.log(` file: ${chalk.cyan(opts.envPath)}`);
76
+ console.log(` provider: ${chalk.yellow(opts.provider)}`);
77
+ if (ollama && opts.host)
78
+ console.log(` host: ${chalk.cyan(opts.host)}`);
79
+ if (ollama && opts.model)
80
+ console.log(` model: ${chalk.cyan(opts.model)}`);
81
+ if (ollama && opts.apiKey) {
82
+ console.log(` api key: ${chalk.cyan(maskSecret(opts.apiKey))} ${chalk.dim('(masked)')}`);
83
+ console.log(chalk.yellow(' ⚠ note: ROBOVOICE\'s ollama_provider.py does not read OLLAMA_API_KEY yet —'));
84
+ console.log(chalk.yellow(' this value is written for forward-compat but is currently unused.'));
85
+ }
86
+ console.log(chalk.green('\n ✓ .env updated\n'));
87
+ }