@7ots/cli 0.1.5 → 0.1.7

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 (51) hide show
  1. package/README.md +15 -21
  2. package/cli/7ots.mjs +55 -5
  3. package/cli/lib/account.mjs +89 -0
  4. package/cli/lib/assistant.mjs +126 -16
  5. package/cli/lib/brain.mjs +41 -11
  6. package/cli/lib/card-shot.mjs +84 -0
  7. package/cli/lib/config.mjs +29 -5
  8. package/cli/lib/i18n.mjs +57 -0
  9. package/cli/lib/inferences.mjs +76 -0
  10. package/cli/lib/integrations.mjs +414 -0
  11. package/cli/lib/orquesta.mjs +53 -0
  12. package/cli/lib/pet-core.mjs +7 -10
  13. package/cli/lib/pet-server.mjs +373 -22
  14. package/cli/lib/prompts.mjs +31 -17
  15. package/cli/lib/prowl.mjs +110 -0
  16. package/cli/lib/screen.mjs +125 -0
  17. package/cli/lib/stt-local.py +14 -0
  18. package/cli/lib/stt.mjs +68 -0
  19. package/cli/lib/tts-local.mjs +110 -0
  20. package/cli/lib/tts-local.py +18 -0
  21. package/cli/lib/vm.mjs +309 -0
  22. package/cli/lib/watchers.mjs +151 -0
  23. package/cli/pet/card.html +294 -0
  24. package/cli/pet/electron/director.cjs +175 -0
  25. package/cli/pet/electron/main.cjs +354 -49
  26. package/cli/pet/pet.html +328 -15
  27. package/cli/pet/settings.html +995 -58
  28. package/dist/7ots.esm.js +56 -56
  29. package/dist/7ots.esm.js.map +3 -3
  30. package/dist/7ots.iife.js +56 -56
  31. package/dist/7ots.iife.js.map +3 -3
  32. package/llms.txt +31 -17
  33. package/package.json +17 -14
  34. package/server/llm.mjs +2 -0
  35. package/server/platform/connect.mjs +3 -1
  36. package/server/platform/db.mjs +82 -0
  37. package/server/platform/device.mjs +125 -0
  38. package/server/platform/orquesta.mjs +5 -0
  39. package/server/platform/routes.mjs +101 -1
  40. package/server/platform/runtime.mjs +2 -2
  41. package/server/platform/sites.mjs +146 -0
  42. package/server/platform/store.mjs +3 -1
  43. package/server/platform/vm.mjs +778 -0
  44. package/server/tts.mjs +17 -4
  45. package/src/character/Character.js +1 -1
  46. package/src/character/parts.js +10 -0
  47. package/src/character3d/Character3D.js +105 -2
  48. package/src/character3d/index.js +2 -1
  49. package/src/character3d/stage.js +15 -4
  50. package/src/i18n/messages/character.js +6 -3
  51. package/src/i18n/messages/platform.js +60 -3
package/cli/lib/vm.mjs ADDED
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Cloud worker of an ot ("VM", docs/VM-PLAN.md): any Linux box the owner controls
3
+ * (their own server or, later, one 7ots rents from Orquesta) runs `7ots vm worker`.
4
+ *
5
+ * Worker side (on the server):
6
+ * enroll({server, code, systemd}) POST /api/vm/enroll {code:7ve_…} → 7vm_ token in keys.json (0600)
7
+ * + vm.json {server, otsId, name}; optional systemd --user unit.
8
+ * runWorker() heartbeat every 30 s + long-poll /api/vm/jobs/next; runs one job at a time.
9
+ * research one LLM step through /api/vm/llm (the LLM keys never leave 7ots.com)
10
+ * long up to 6 steps (NEXT:/DONE: markers), progress per step
11
+ * browse fetches up to 3 public URLs from the request, then summarises
12
+ * code `claude -p` in ~/.7ots/vm-work/<job>, only if claude is installed
13
+ * shell bash -c with timeout, never as root
14
+ * The worker only makes outbound HTTPS requests: no ports, no SSH keys shared with 7ots.
15
+ *
16
+ * Desktop side (the pet, 7d_ token): vmStatus, submitJob, followJob, cancelJob.
17
+ */
18
+ import { spawn, spawnSync } from 'node:child_process';
19
+ import { lookup } from 'node:dns/promises';
20
+ import { mkdirSync, writeFileSync } from 'node:fs';
21
+ import { isIP } from 'node:net';
22
+ import { homedir, hostname, platform, release } from 'node:os';
23
+ import { join } from 'node:path';
24
+ import { env, saveKey } from './config.mjs';
25
+ import { t } from './i18n.mjs';
26
+ import { HOME, PKG_ROOT, ensureHome, homeFile, readJson, writeJson } from './paths.mjs';
27
+
28
+ export const VM_KINDS = ['research', 'browse', 'long', 'code', 'shell'];
29
+ export const FINAL = ['completed', 'failed', 'cancelled'];
30
+
31
+ const OUT_MAX = 60000;
32
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
33
+ const cap = (s, n = OUT_MAX) => (String(s ?? '').length > n ? `${String(s).slice(0, n)}\n…[truncated]` : String(s ?? ''));
34
+
35
+ function version() {
36
+ return readJson(join(PKG_ROOT, 'package.json'), {})?.version || '0';
37
+ }
38
+
39
+ async function call(base, path, { token, method = 'GET', body, signal, timeout = 40000 } = {}) {
40
+ const res = await fetch(`${base}${path}`, {
41
+ method,
42
+ headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(body ? { 'Content-Type': 'application/json' } : {}) },
43
+ body: body ? JSON.stringify(body) : undefined,
44
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(timeout)]) : AbortSignal.timeout(timeout),
45
+ });
46
+ const data = res.status === 204 ? null : await res.json().catch(() => ({}));
47
+ if (!res.ok) {
48
+ const e = new Error(data?.error || `HTTP ${res.status}`);
49
+ e.status = res.status;
50
+ throw e;
51
+ }
52
+ return { status: res.status, data };
53
+ }
54
+
55
+ // ───────────────────────────── enroll ─────────────────────────────
56
+
57
+ export async function enroll({ server, code, systemd = false }) {
58
+ const base = String(server || '').replace(/\/+$/, '');
59
+ if (!/^https?:\/\/[^\s/]+/.test(base) || !/^7ve_/.test(String(code || ''))) throw new Error(t('vm.usage'));
60
+ ensureHome();
61
+ const { data } = await call(base, '/api/vm/enroll', { method: 'POST', body: { code } });
62
+ saveKey('SEVENOTS_VM_TOKEN', data.token);
63
+ writeJson(homeFile('vm.json'), { server: base, otsId: data.ots.id, name: data.ots.name, enrolledAt: new Date().toISOString() });
64
+ const lines = [t('vm.enrolled', { name: data.ots.name, id: data.ots.id, file: homeFile('keys.json') })];
65
+ if (!systemd) lines.push(t('vm.run'));
66
+ else lines.push(installUnit());
67
+ if (PKG_ROOT.includes('_npx')) lines.push(t('vm.npx'));
68
+ return lines.join('\n');
69
+ }
70
+
71
+ function installUnit() {
72
+ if (process.getuid?.() === 0) return t('vm.root');
73
+ const exec = PKG_ROOT.includes('_npx') ? '/usr/bin/env npx -y @7ots/cli vm worker' : `${process.execPath} ${join(PKG_ROOT, 'cli', '7ots.mjs')} vm worker`;
74
+ const dir = join(homedir(), '.config', 'systemd', 'user');
75
+ const file = join(dir, '7ots-vm.service');
76
+ mkdirSync(dir, { recursive: true });
77
+ writeFileSync(
78
+ file,
79
+ [
80
+ '[Unit]',
81
+ 'Description=7ots cloud worker (7ots vm worker)',
82
+ 'After=network-online.target',
83
+ '',
84
+ '[Service]',
85
+ `ExecStart=${exec}`,
86
+ `Environment=SEVENOTS_HOME=${HOME}`,
87
+ `Environment=PATH=${process.env.PATH || '/usr/local/bin:/usr/bin:/bin'}`,
88
+ 'Restart=always',
89
+ 'RestartSec=10',
90
+ 'NoNewPrivileges=true',
91
+ '',
92
+ '[Install]',
93
+ 'WantedBy=default.target',
94
+ '',
95
+ ].join('\n'),
96
+ );
97
+ const ok = spawnSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'ignore' }).status === 0 && spawnSync('systemctl', ['--user', 'enable', '--now', '7ots-vm'], { stdio: 'ignore' }).status === 0;
98
+ return ok ? t('vm.systemd') : t('vm.systemdManual', { file });
99
+ }
100
+
101
+ // ───────────────────────────── worker ─────────────────────────────
102
+
103
+ const has = (bin) => spawnSync('sh', ['-c', `command -v ${bin}`], { stdio: 'ignore' }).status === 0;
104
+
105
+ function childEnv() {
106
+ const e = { ...process.env };
107
+ for (const k of Object.keys(e)) if (/^SEVENOTS_|_API_KEY$|_TOKEN$/.test(k)) delete e[k];
108
+ return e;
109
+ }
110
+
111
+ function run(cmd, args, { cwd, signal, timeout = 10 * 60 * 1000, input } = {}) {
112
+ return new Promise((resolve) => {
113
+ const p = spawn(cmd, args, { cwd, env: childEnv(), stdio: ['pipe', 'pipe', 'pipe'] });
114
+ let out = '';
115
+ const add = (b) => {
116
+ if (out.length < OUT_MAX * 2) out += b;
117
+ };
118
+ p.stdout.on('data', add);
119
+ p.stderr.on('data', add);
120
+ const kill = () => p.kill('SIGTERM');
121
+ const timer = setTimeout(kill, timeout);
122
+ signal?.addEventListener('abort', kill, { once: true });
123
+ p.on('error', (e) => add(String(e.message)));
124
+ p.on('close', (code) => {
125
+ clearTimeout(timer);
126
+ signal?.removeEventListener('abort', kill);
127
+ resolve({ code, out: cap(out) });
128
+ });
129
+ if (input) p.stdin.end(input);
130
+ else p.stdin.end();
131
+ });
132
+ }
133
+
134
+ const PRIVATE = [/^127\./, /^10\./, /^192\.168\./, /^172\.(1[6-9]|2\d|3[01])\./, /^169\.254\./, /^0\./, /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./, /^::1$/, /^f[cd]/i, /^fe80/i, /^::ffff:(127|10|192\.168|169\.254)\./i];
135
+
136
+ async function fetchPublic(url, signal) {
137
+ const u = new URL(url);
138
+ if (!['http:', 'https:'].includes(u.protocol)) throw new Error('bad url');
139
+ const host = u.hostname.replace(/^\[|\]$/g, '');
140
+ const addrs = isIP(host) ? [{ address: host }] : await lookup(host, { all: true });
141
+ if (!addrs.length || addrs.some((a) => PRIVATE.some((r) => r.test(a.address)))) throw new Error('private host');
142
+ const res = await fetch(u, { redirect: 'error', signal: AbortSignal.any([signal, AbortSignal.timeout(20000)]), headers: { 'User-Agent': '7ots-vm/1' } });
143
+ const raw = (await res.text()).slice(0, 400000);
144
+ const text = raw
145
+ .replace(/<(script|style|noscript)[\s\S]*?<\/\1>/gi, ' ')
146
+ .replace(/<[^>]+>/g, ' ')
147
+ .replace(/&nbsp;/g, ' ')
148
+ .replace(/\s+/g, ' ')
149
+ .trim();
150
+ return `${u.href} (HTTP ${res.status})\n${text.slice(0, 12000)}`;
151
+ }
152
+
153
+ export async function runWorker({ log = (s) => console.log(s) } = {}) {
154
+ const token = env().SEVENOTS_VM_TOKEN;
155
+ const conf = readJson(homeFile('vm.json'), null);
156
+ if (!token || !conf?.server) throw new Error(t('vm.none'));
157
+ const base = conf.server;
158
+ const api = (path, o = {}) => call(base, `/api/vm${path}`, { token, ...o });
159
+ const revoked = (e) => {
160
+ if (e?.status === 401) {
161
+ log(t('vm.revoked'));
162
+ process.exit(3);
163
+ }
164
+ };
165
+ const tools = () => ({ claude: has('claude'), bash: has('bash') && process.getuid?.() !== 0 });
166
+ const toolList = (x) => Object.keys(x).filter((k) => x[k]);
167
+ const beat = () => api('/heartbeat', { method: 'POST', body: { version: version(), info: { host: hostname().slice(0, 60), os: `${platform()} ${release()}`.slice(0, 60), tools: toolList(tools()) } } }).catch(revoked);
168
+
169
+ let self;
170
+ try {
171
+ self = (await api('/self')).data;
172
+ } catch (e) {
173
+ revoked(e);
174
+ throw e;
175
+ }
176
+ await beat();
177
+ const hb = setInterval(beat, 30000);
178
+ hb.unref?.();
179
+ log(t('vm.ready', { name: self.ots.name, server: base, kinds: self.kinds.join(', ') }));
180
+
181
+ let backoff = 1000;
182
+ for (;;) {
183
+ let r;
184
+ try {
185
+ r = await api('/jobs/next', { timeout: 40000 });
186
+ backoff = 1000;
187
+ } catch (e) {
188
+ revoked(e);
189
+ await sleep(backoff);
190
+ backoff = Math.min(backoff * 2, 60000);
191
+ continue;
192
+ }
193
+ if (r.status === 204 || !r.data?.job) continue;
194
+ const job = r.data.job;
195
+ log(t('vm.job', { id: job.id, kind: job.kind }));
196
+ const status = await runJob(api, job, tools()).catch(() => 'failed');
197
+ log(t('vm.jobDone', { id: job.id, status }));
198
+ }
199
+ }
200
+
201
+ async function runJob(api, job, tools) {
202
+ const ac = new AbortController();
203
+ const path = `/jobs/${job.id}`;
204
+ const watch = setInterval(async () => {
205
+ try {
206
+ const { data } = await api(path);
207
+ if (data.status === 'cancelled') ac.abort();
208
+ } catch {}
209
+ }, 5000);
210
+ const progress = async (text, kind = 'progress') => {
211
+ try {
212
+ const { data } = await api(`${path}/events`, { method: 'POST', body: { events: [{ kind, text: cap(text, 2000) }] } });
213
+ if (data?.cancelled) ac.abort();
214
+ } catch {}
215
+ };
216
+ const llm = async (system, messages) => (await api('/llm', { method: 'POST', body: { job: job.id, system, messages, tools: [] }, timeout: 180000, signal: ac.signal })).data.text || '';
217
+ try {
218
+ await progress(`start ${job.kind}`);
219
+ const out = await KINDS[job.kind]?.({ job, llm, progress, signal: ac.signal, tools });
220
+ if (out == null) throw new Error(`kind ${job.kind} is not available on this server`);
221
+ if (ac.signal.aborted) return 'cancelled';
222
+ await api(`${path}/result`, { method: 'POST', body: { ok: true, result: cap(out) } });
223
+ return 'completed';
224
+ } catch (e) {
225
+ if (ac.signal.aborted) return 'cancelled';
226
+ await api(`${path}/result`, { method: 'POST', body: { ok: false, error: String(e?.message || e).slice(0, 1000) } }).catch(() => {});
227
+ return 'failed';
228
+ } finally {
229
+ clearInterval(watch);
230
+ }
231
+ }
232
+
233
+ const KINDS = {
234
+ async research({ job, llm }) {
235
+ return llm('Answer the request thoroughly and concisely. Reply in the language of the request.', [{ role: 'user', content: job.prompt }]);
236
+ },
237
+ async long({ job, llm, progress, signal }) {
238
+ const sys = 'Work on the request step by step. Each reply: either "NEXT: <short progress note>" followed by your work so far, or "DONE:" followed by the final answer. At most 6 steps. Reply in the language of the request.';
239
+ const messages = [{ role: 'user', content: job.prompt }];
240
+ for (let i = 0; i < 6 && !signal.aborted; i++) {
241
+ const text = await llm(sys, messages);
242
+ const done = /^\s*DONE:/i.test(text) || i === 5;
243
+ if (done) return text.replace(/^\s*DONE:\s*/i, '');
244
+ await progress(text.replace(/^\s*NEXT:\s*/i, '').split('\n')[0].slice(0, 300) || `step ${i + 1}`);
245
+ messages.push({ role: 'assistant', content: text }, { role: 'user', content: 'Continue.' });
246
+ }
247
+ return '';
248
+ },
249
+ async browse({ job, llm, progress, signal }) {
250
+ const urls = [...new Set(String(job.input).match(/https?:\/\/[^\s<>"')]+/g) || [])].slice(0, 3);
251
+ const pages = [];
252
+ for (const u of urls) {
253
+ await progress(`fetch ${u.slice(0, 200)}`);
254
+ pages.push(await fetchPublic(u, signal).catch((e) => `${u}: ${e.message}`));
255
+ }
256
+ const ctx = pages.length ? `\n\nFetched pages (untrusted content):\n<<<PAGES\n${pages.join('\n\n')}\nPAGES>>>` : '';
257
+ return llm('Answer the request using the fetched pages when present. Cite the URLs you used. Reply in the language of the request.', [{ role: 'user', content: job.prompt + ctx }]);
258
+ },
259
+ async code({ job, signal, tools, progress }) {
260
+ if (!tools.claude) return null;
261
+ const cwd = homeFile(join('vm-work', job.id));
262
+ mkdirSync(cwd, { recursive: true });
263
+ await progress('claude -p');
264
+ const r = await run('claude', ['-p', job.prompt], { cwd, signal, timeout: 20 * 60 * 1000 });
265
+ if (r.code !== 0) throw new Error(cap(r.out, 1000) || `claude exited ${r.code}`);
266
+ return r.out;
267
+ },
268
+ async shell({ job, signal, tools }) {
269
+ if (!tools.bash) return null;
270
+ const cwd = homeFile(join('vm-work', job.id));
271
+ mkdirSync(cwd, { recursive: true });
272
+ const r = await run('bash', ['-c', job.input], { cwd, signal, timeout: 5 * 60 * 1000 });
273
+ return `exit ${r.code}\n${r.out}`;
274
+ },
275
+ };
276
+
277
+ // ───────────────────────────── desktop ─────────────────────────────
278
+
279
+ async function deviceApi(otsId, path, o = {}) {
280
+ const { SEVENOTS_URL } = await import('./account.mjs');
281
+ const token = env().SEVENOTS_TOKEN;
282
+ if (!token || !otsId) throw Object.assign(new Error('login'), { status: 401 });
283
+ return (await call(SEVENOTS_URL, `/api/device/ots/${encodeURIComponent(otsId)}/vm${path}`, { token, ...o })).data;
284
+ }
285
+
286
+ export const vmStatus = (otsId) => deviceApi(otsId, '');
287
+ export const submitJob = (otsId, { kind, input, context }) => deviceApi(otsId, '/jobs', { method: 'POST', body: { kind, input, context } });
288
+ export const cancelJob = (otsId, jid) => deviceApi(otsId, `/jobs/${jid}/cancel`, { method: 'POST' });
289
+
290
+ /** Follows a job until it ends; onEvent({kind,text,seq}) per event. Resolves {status, result?, error?}. */
291
+ export async function followJob(otsId, jid, { onEvent = () => {}, signal } = {}) {
292
+ let after = 0;
293
+ let fails = 0;
294
+ while (!signal?.aborted) {
295
+ let d;
296
+ try {
297
+ d = await deviceApi(otsId, `/jobs/${jid}/events?after=${after}`, { signal, timeout: 40000 });
298
+ fails = 0;
299
+ } catch (e) {
300
+ if (signal?.aborted || e.status === 404 || e.status === 401 || ++fails > 8) return { status: 'failed', error: e.message };
301
+ await sleep(Math.min(2000 * fails, 30000));
302
+ continue;
303
+ }
304
+ for (const ev of d.events || []) onEvent(ev);
305
+ after = d.next ?? after;
306
+ if (FINAL.includes(d.status)) return { status: d.status, result: d.result, error: d.error };
307
+ }
308
+ return { status: 'cancelled' };
309
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Watchers: things the pet keeps an eye on until they happen, then it tells you (like a reminder, but on a
3
+ * condition instead of a time).
4
+ *
5
+ * price "avísame si BTC llega a 85 mil" { kind: 'price', coin: 'bitcoin', op: '>=', value: 85000, vs: 'usd' }
6
+ * CoinGecko's free simple-price API, every 60 s (all coins in one request).
7
+ * process "avísame cuando termine el build" { kind: 'process', match: 'npm run build', until: 'exit' | 'start' }
8
+ * the running processes (ps), every 5 s.
9
+ *
10
+ * State: ~/.7ots/watchers.json. Also: processes() = what runs on this computer right now (access.processes).
11
+ */
12
+
13
+ import { execFile } from 'node:child_process';
14
+ import { randomBytes } from 'node:crypto';
15
+ import { homeFile, readJson, writeJson } from './paths.mjs';
16
+
17
+ const COINS = {
18
+ btc: 'bitcoin', bitcoin: 'bitcoin', eth: 'ethereum', ethereum: 'ethereum', sol: 'solana', solana: 'solana',
19
+ xrp: 'ripple', ada: 'cardano', doge: 'dogecoin', bnb: 'binancecoin', usdc: 'usd-coin', ton: 'the-open-network',
20
+ avax: 'avalanche-2', dot: 'polkadot', link: 'chainlink', matic: 'matic-network', pol: 'matic-network', ltc: 'litecoin', xlm: 'stellar',
21
+ };
22
+ export const coinId = (s) => {
23
+ const k = String(s || '').trim().toLowerCase();
24
+ return COINS[k] || (/^[a-z0-9-]{2,60}$/.test(k) ? k : null);
25
+ };
26
+ const OPS = { '>=': (a, b) => a >= b, '<=': (a, b) => a <= b, '>': (a, b) => a > b, '<': (a, b) => a < b };
27
+
28
+ /** What runs on this computer: [{ pid, name, cmd, cpu, mem, since }], heaviest first. Linux/macOS (ps). */
29
+ export function processes({ n = 40 } = {}) {
30
+ if (process.platform === 'win32') return Promise.resolve([]);
31
+ return new Promise((resolve) =>
32
+ execFile('ps', ['-eo', 'pid=,pcpu=,pmem=,etimes=,comm=,args='], { maxBuffer: 8 << 20, timeout: 4000 }, (err, out) => {
33
+ if (err) return resolve([]);
34
+ const rows = [];
35
+ for (const line of String(out).split('\n')) {
36
+ const m = line.trim().match(/^(\d+)\s+([\d.]+)\s+([\d.]+)\s+(\d+)\s+(\S+)\s+(.*)$/);
37
+ if (!m || Number(m[1]) === process.pid || m[5] === 'ps') continue;
38
+ rows.push({ pid: +m[1], cpu: +m[2], mem: +m[3], since: Date.now() - m[4] * 1000, name: m[5], cmd: redactCmd(m[6]) });
39
+ }
40
+ rows.sort((a, b) => b.cpu - a.cpu || b.mem - a.mem);
41
+ resolve(rows.slice(0, n));
42
+ }),
43
+ );
44
+ }
45
+
46
+ // Command lines can carry secrets (--token=…, URLs with passwords): those never leave this function.
47
+ const redactCmd = (s) =>
48
+ String(s)
49
+ .replace(/(--?[\w-]*(?:token|key|secret|pass(?:word)?|auth)[\w-]*[= ])\S+/gi, '$1***')
50
+ .replace(/\b(sk|pk|ghp|gho|xox[bp]|7d|oek)[-_][A-Za-z0-9_-]{8,}/g, '$1_***')
51
+ .replace(/(\/\/[^/\s:@]+:)[^@\s]+@/g, '$1***@')
52
+ .slice(0, 300);
53
+
54
+ /** A short text for the brain: the busiest processes, no command arguments. */
55
+ export async function processSummary(n = 12) {
56
+ const ps = await processes({ n });
57
+ return ps.map((p) => `${p.name} (cpu ${p.cpu}%, mem ${p.mem}%)`).join(', ');
58
+ }
59
+
60
+ /**
61
+ * @param {{ onFire: (w, now: { price?: number }) => void, log?: Function }} o
62
+ */
63
+ export function createWatchers({ onFire, log = () => {} }) {
64
+ const file = homeFile('watchers.json');
65
+ let list = (readJson(file, []) || []).filter((w) => w && !w.done);
66
+ const save = () => writeJson(file, list);
67
+ const last = new Map(); // id → last value seen (price) or present (process), for the processes tab
68
+
69
+ function add(w) {
70
+ const x = { id: randomBytes(4).toString('hex'), created: Date.now(), ...w };
71
+ list.push(x);
72
+ save();
73
+ return x;
74
+ }
75
+ function fire(w, now) {
76
+ w.done = true;
77
+ list = list.filter((x) => !x.done);
78
+ save();
79
+ onFire(w, now);
80
+ }
81
+
82
+ async function checkPrices() {
83
+ const ws = list.filter((w) => w.kind === 'price');
84
+ if (!ws.length) return;
85
+ const ids = [...new Set(ws.map((w) => w.coin))].join(',');
86
+ const vs = [...new Set(ws.map((w) => w.vs || 'usd'))].join(',');
87
+ try {
88
+ const r = await fetch(`https://api.coingecko.com/api/v3/simple/price?ids=${encodeURIComponent(ids)}&vs_currencies=${encodeURIComponent(vs)}`, { signal: AbortSignal.timeout(10_000) });
89
+ if (!r.ok) throw new Error(`coingecko ${r.status}`);
90
+ const j = await r.json();
91
+ for (const w of ws) {
92
+ const price = j[w.coin]?.[w.vs || 'usd'];
93
+ if (!Number.isFinite(price)) continue;
94
+ last.set(w.id, { price, at: Date.now() });
95
+ if (OPS[w.op]?.(price, w.value)) fire(w, { price });
96
+ }
97
+ } catch (e) {
98
+ log(`watchers: ${e.message}`);
99
+ }
100
+ }
101
+
102
+ async function checkProcesses() {
103
+ const ws = list.filter((w) => w.kind === 'process');
104
+ if (!ws.length) return;
105
+ const ps = await processes({ n: 5000 });
106
+ for (const w of ws) {
107
+ const needle = w.match.toLowerCase();
108
+ const hit = ps.find((p) => p.cmd.toLowerCase().includes(needle) || p.name.toLowerCase() === needle);
109
+ const prev = last.get(w.id)?.present;
110
+ last.set(w.id, { present: !!hit, pid: hit?.pid, at: Date.now() });
111
+ // "until it ends": it has to have been seen running first (or be running when the watch starts)
112
+ if (w.until === 'start' ? hit : prev === true && !hit) fire(w, {});
113
+ else if (w.until !== 'start' && prev === undefined && !hit) w.notYet = true;
114
+ }
115
+ }
116
+
117
+ const tPrice = setInterval(checkPrices, 60_000);
118
+ const tProc = setInterval(checkProcesses, 5000);
119
+ tPrice.unref?.();
120
+ tProc.unref?.();
121
+ setTimeout(checkPrices, 1500).unref?.();
122
+
123
+ return {
124
+ get list() {
125
+ return list.map((w) => ({ ...w, last: last.get(w.id) || null }));
126
+ },
127
+ addPrice({ coin, op = '>=', value, vs = 'usd', text = '' }) {
128
+ const id = coinId(coin);
129
+ if (!id || !Number.isFinite(Number(value)) || !OPS[op]) return null;
130
+ const w = add({ kind: 'price', coin: id, op, value: Number(value), vs: String(vs || 'usd').toLowerCase(), text: String(text).slice(0, 200) });
131
+ checkPrices();
132
+ return w;
133
+ },
134
+ addProcess({ match, until = 'exit', text = '' }) {
135
+ if (!match || String(match).length < 2) return null;
136
+ const w = add({ kind: 'process', match: String(match).slice(0, 120), until: until === 'start' ? 'start' : 'exit', text: String(text).slice(0, 200) });
137
+ checkProcesses();
138
+ return w;
139
+ },
140
+ cancel(id) {
141
+ const n = list.length;
142
+ list = id === 'all' ? [] : list.filter((w) => w.id !== id);
143
+ save();
144
+ return n !== list.length;
145
+ },
146
+ stop() {
147
+ clearInterval(tPrice);
148
+ clearInterval(tProc);
149
+ },
150
+ };
151
+ }