@7ots/cli 0.1.5 → 0.1.6
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/README.md +14 -20
- package/cli/7ots.mjs +55 -5
- package/cli/lib/account.mjs +89 -0
- package/cli/lib/assistant.mjs +122 -16
- package/cli/lib/brain.mjs +41 -11
- package/cli/lib/card-shot.mjs +84 -0
- package/cli/lib/config.mjs +28 -5
- package/cli/lib/i18n.mjs +57 -0
- package/cli/lib/inferences.mjs +76 -0
- package/cli/lib/integrations.mjs +414 -0
- package/cli/lib/orquesta.mjs +53 -0
- package/cli/lib/pet-core.mjs +7 -10
- package/cli/lib/pet-server.mjs +365 -22
- package/cli/lib/screen.mjs +125 -0
- package/cli/lib/stt-local.py +14 -0
- package/cli/lib/stt.mjs +68 -0
- package/cli/lib/tts-local.mjs +110 -0
- package/cli/lib/tts-local.py +18 -0
- package/cli/lib/vm.mjs +309 -0
- package/cli/lib/watchers.mjs +151 -0
- package/cli/pet/card.html +294 -0
- package/cli/pet/electron/director.cjs +175 -0
- package/cli/pet/electron/main.cjs +354 -49
- package/cli/pet/pet.html +328 -15
- package/cli/pet/settings.html +991 -57
- package/dist/7ots.esm.js +3 -3
- package/dist/7ots.esm.js.map +3 -3
- package/dist/7ots.iife.js +3 -3
- package/dist/7ots.iife.js.map +3 -3
- package/llms.txt +31 -17
- package/package.json +1 -1
- package/server/llm.mjs +2 -0
- package/server/platform/connect.mjs +3 -1
- package/server/platform/db.mjs +77 -0
- package/server/platform/device.mjs +125 -0
- package/server/platform/orquesta.mjs +5 -0
- package/server/platform/routes.mjs +100 -1
- package/server/platform/sites.mjs +146 -0
- package/server/platform/store.mjs +3 -1
- package/server/platform/vm.mjs +386 -0
- package/src/character3d/index.js +2 -1
- package/src/character3d/stage.js +15 -4
- package/src/i18n/messages/character.js +3 -3
- package/src/i18n/messages/platform.js +42 -3
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Screen awareness (access.screen, off by default): so the pet knows what you are doing.
|
|
3
|
+
* · every 20 s: the active window's title and app (xdotool, X11) → "apps lately"
|
|
4
|
+
* · every N minutes (config.screen.every, default 5), if you are at the computer and something changed:
|
|
5
|
+
* a screenshot (scaled down, ~/.7ots/screen/now.jpg, 0600) that the brain looks at and sums up in one line,
|
|
6
|
+
* then the file is deleted. Brains that can't see images (or no screenshot tool) get the window titles instead.
|
|
7
|
+
* What it saw is kept in ~/.7ots/seen.json (last 60) and shown on /settings → Memory; it feeds the pet's comments.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { execFile } from 'node:child_process';
|
|
11
|
+
import { existsSync, mkdirSync, unlinkSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { homeFile, readJson, writeJson } from './paths.mjs';
|
|
14
|
+
|
|
15
|
+
const run = (cmd, args, ms = 4000) => new Promise((res) => execFile(cmd, args, { timeout: ms }, (e, out) => res(e ? '' : String(out).trim())));
|
|
16
|
+
const MAX = 60;
|
|
17
|
+
|
|
18
|
+
export function createScreenWatcher({ brain, enabled, every = () => 5, lang = 'en', log = () => {}, onSeen = () => {} }) {
|
|
19
|
+
const file = homeFile('seen.json');
|
|
20
|
+
const seen = readJson(file, null) || { looks: [], windows: [] };
|
|
21
|
+
const save = () => writeJson(file, seen, { secret: true });
|
|
22
|
+
let lastWin = '';
|
|
23
|
+
let lastMouse = '';
|
|
24
|
+
let lastLook = 0;
|
|
25
|
+
let changed = false;
|
|
26
|
+
let busy = false;
|
|
27
|
+
const x11 = process.platform === 'linux' && process.env.DISPLAY;
|
|
28
|
+
|
|
29
|
+
async function activeWindow() {
|
|
30
|
+
if (!x11) return null;
|
|
31
|
+
const id = await run('xdotool', ['getactivewindow']);
|
|
32
|
+
if (!id) return null;
|
|
33
|
+
const [title, cls] = await Promise.all([run('xdotool', ['getwindowname', id]), run('xprop', ['-id', id, 'WM_CLASS'])]);
|
|
34
|
+
const app = cls.match(/"([^"]*)"\s*$/)?.[1] || '';
|
|
35
|
+
if (/7ots/i.test(title)) return null;
|
|
36
|
+
return { title: title.slice(0, 160), app };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function tickWindows() {
|
|
40
|
+
if (!enabled()) return;
|
|
41
|
+
const w = await activeWindow();
|
|
42
|
+
const mouse = x11 ? await run('xdotool', ['getmouselocation']) : '';
|
|
43
|
+
if (mouse !== lastMouse) (changed = true), (lastMouse = mouse);
|
|
44
|
+
if (!w) return;
|
|
45
|
+
const key = `${w.app}|${w.title}`;
|
|
46
|
+
if (key === lastWin) return;
|
|
47
|
+
lastWin = key;
|
|
48
|
+
changed = true;
|
|
49
|
+
seen.windows.push({ at: Date.now(), ...w });
|
|
50
|
+
if (seen.windows.length > MAX) seen.windows.shift();
|
|
51
|
+
save();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function screenshot() {
|
|
55
|
+
const dir = homeFile('screen');
|
|
56
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
57
|
+
const f = join(dir, 'now.jpg');
|
|
58
|
+
if (process.platform === 'darwin') await run('screencapture', ['-x', '-t', 'jpg', f]);
|
|
59
|
+
else if (x11) await run('import', ['-silent', '-window', 'root', '-resize', '1280x1280>', '-quality', '70', f], 8000);
|
|
60
|
+
return existsSync(f) ? f : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function look({ force = false } = {}) {
|
|
64
|
+
if (!enabled() || busy || brain.kind === 'lines') return null;
|
|
65
|
+
if (!force && (!changed || Date.now() - lastLook < every() * 60_000)) return null;
|
|
66
|
+
busy = true;
|
|
67
|
+
changed = false;
|
|
68
|
+
lastLook = Date.now();
|
|
69
|
+
let f = null;
|
|
70
|
+
try {
|
|
71
|
+
f = brain.vision ? await screenshot() : null;
|
|
72
|
+
const recent = seen.windows.slice(-8).map((w) => `${w.app}: ${w.title}`).join('\n');
|
|
73
|
+
const ask = f
|
|
74
|
+
? `This is a screenshot of your human's screen right now (file ${f}). Recently active windows:\n${recent}`
|
|
75
|
+
: `You can't see the screen, only the recently active windows (app: title):\n${recent || '(none)'}`;
|
|
76
|
+
const raw = await brain.think({
|
|
77
|
+
purpose: 'screen',
|
|
78
|
+
system: `You quietly keep track of what your human is doing on their computer, to help them later. Never transcribe private content (passwords, messages, personal data): summarize the activity.`,
|
|
79
|
+
messages: [{ role: 'user', content: `${ask}\n\nAnswer ONLY with JSON: {"doing": "one short line: the app and the task they are on, in ${lang}", "say": "optional: one short, useful or playful remark to them in ${lang} if something deserves it (an error on screen, a long session, a tip); else empty"}` }],
|
|
80
|
+
files: f ? [f] : null,
|
|
81
|
+
timeoutMs: 90000,
|
|
82
|
+
raw: true,
|
|
83
|
+
});
|
|
84
|
+
const j = JSON.parse(String(raw).match(/\{[\s\S]*\}/)?.[0] || '{}');
|
|
85
|
+
if (!j.doing) return null;
|
|
86
|
+
const row = { at: Date.now(), doing: String(j.doing).slice(0, 200), say: String(j.say || '').slice(0, 200), how: f ? 'screenshot' : 'windows' };
|
|
87
|
+
seen.looks.push(row);
|
|
88
|
+
if (seen.looks.length > MAX) seen.looks.shift();
|
|
89
|
+
save();
|
|
90
|
+
onSeen(row);
|
|
91
|
+
return row;
|
|
92
|
+
} catch (e) {
|
|
93
|
+
log(`screen: ${e.message}`);
|
|
94
|
+
return null;
|
|
95
|
+
} finally {
|
|
96
|
+
busy = false;
|
|
97
|
+
if (f) {
|
|
98
|
+
try {
|
|
99
|
+
unlinkSync(f);
|
|
100
|
+
} catch {}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const t1 = setInterval(tickWindows, 20_000);
|
|
106
|
+
const t2 = setInterval(() => look(), 60_000);
|
|
107
|
+
t1.unref?.();
|
|
108
|
+
t2.unref?.();
|
|
109
|
+
return {
|
|
110
|
+
look,
|
|
111
|
+
get seen() {
|
|
112
|
+
return seen;
|
|
113
|
+
},
|
|
114
|
+
now: () => seen.looks.at(-1) || null,
|
|
115
|
+
forget() {
|
|
116
|
+
seen.looks = [];
|
|
117
|
+
seen.windows = [];
|
|
118
|
+
save();
|
|
119
|
+
},
|
|
120
|
+
stop() {
|
|
121
|
+
clearInterval(t1);
|
|
122
|
+
clearInterval(t2);
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Local speech to text for the 7ots pet: python stt-local.py <audio file> [lang] [model] → prints the text.
|
|
2
|
+
Runs in the venv ~/.7ots/stt (pip install faster-whisper); the model (base, ~140 MB) is cached on first use.
|
|
3
|
+
Audio is decoded with ffmpeg (16 kHz mono) instead of PyAV, whose newer releases break faster-whisper."""
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from faster_whisper import WhisperModel
|
|
9
|
+
|
|
10
|
+
pcm = subprocess.run(['ffmpeg', '-nostdin', '-loglevel', 'error', '-i', sys.argv[1], '-f', 'f32le', '-ac', '1', '-ar', '16000', '-'], capture_output=True, check=True).stdout
|
|
11
|
+
audio = np.frombuffer(pcm, dtype=np.float32)
|
|
12
|
+
model = WhisperModel(sys.argv[3] if len(sys.argv) > 3 else 'base', device='cpu', compute_type='int8')
|
|
13
|
+
segments, _ = model.transcribe(audio, language=(sys.argv[2] or None) if len(sys.argv) > 2 else None, vad_filter=True, beam_size=1)
|
|
14
|
+
print(' '.join(s.text.strip() for s in segments).strip())
|
package/cli/lib/stt.mjs
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Speech to text for voice control: the pet page records a short clip (webm/opus) and POSTs it to /stt.
|
|
3
|
+
* Local first: faster-whisper in the venv ~/.7ots/stt (free, the audio never leaves the machine):
|
|
4
|
+
* python3 -m venv ~/.7ots/stt && ~/.7ots/stt/bin/pip install faster-whisper
|
|
5
|
+
* otherwise whichever key is configured: OpenAI (OPENAI_API_KEY), then ElevenLabs (ELEVENLABS_API_KEY).
|
|
6
|
+
* Without any, voice control is off and the page says so.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { execFile } from 'node:child_process';
|
|
10
|
+
import { existsSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { homeFile } from './paths.mjs';
|
|
14
|
+
|
|
15
|
+
const LOCAL_PY = () => homeFile(join('stt', 'bin', 'python'));
|
|
16
|
+
const SCRIPT = new URL('./stt-local.py', import.meta.url).pathname;
|
|
17
|
+
|
|
18
|
+
const EXT = { 'audio/webm': 'webm', 'audio/ogg': 'ogg', 'audio/mp4': 'm4a', 'audio/mpeg': 'mp3', 'audio/wav': 'wav' };
|
|
19
|
+
|
|
20
|
+
export function sttProvider(keys) {
|
|
21
|
+
if (existsSync(LOCAL_PY())) return 'local';
|
|
22
|
+
if (keys.OPENAI_API_KEY) return 'openai';
|
|
23
|
+
if (keys.ELEVENLABS_API_KEY) return 'elevenlabs';
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** → { text } ; throws with .status on provider errors. */
|
|
28
|
+
export async function transcribe({ audio, contentType = 'audio/webm', lang = '', env = {} }) {
|
|
29
|
+
const provider = sttProvider(env);
|
|
30
|
+
if (!provider) throw Object.assign(new Error('no speech-to-text key (OPENAI_API_KEY or ELEVENLABS_API_KEY)'), { status: 501 });
|
|
31
|
+
const type = String(contentType).split(';')[0].trim();
|
|
32
|
+
if (provider === 'local') {
|
|
33
|
+
const f = join(tmpdir(), `7ots-voice-${process.pid}-${Date.now()}.${EXT[type] || 'webm'}`);
|
|
34
|
+
writeFileSync(f, audio, { mode: 0o600 });
|
|
35
|
+
try {
|
|
36
|
+
const text = await new Promise((res, rej) =>
|
|
37
|
+
execFile(LOCAL_PY(), [SCRIPT, f, lang || ''], { timeout: 60000 }, (e, out) => (e ? rej(Object.assign(new Error(`local stt: ${e.message.split('\n')[0]}`), { status: 502 })) : res(String(out).trim()))),
|
|
38
|
+
);
|
|
39
|
+
return { text, provider };
|
|
40
|
+
} finally {
|
|
41
|
+
try {
|
|
42
|
+
unlinkSync(f);
|
|
43
|
+
} catch {}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const file = new Blob([audio], { type });
|
|
47
|
+
const name = `voice.${EXT[type] || 'webm'}`;
|
|
48
|
+
const form = new FormData();
|
|
49
|
+
let url;
|
|
50
|
+
let headers;
|
|
51
|
+
if (provider === 'openai') {
|
|
52
|
+
url = 'https://api.openai.com/v1/audio/transcriptions';
|
|
53
|
+
headers = { Authorization: `Bearer ${env.OPENAI_API_KEY}` };
|
|
54
|
+
form.append('model', 'gpt-4o-mini-transcribe');
|
|
55
|
+
form.append('file', file, name);
|
|
56
|
+
if (lang) form.append('language', lang);
|
|
57
|
+
} else {
|
|
58
|
+
url = 'https://api.elevenlabs.io/v1/speech-to-text';
|
|
59
|
+
headers = { 'xi-api-key': env.ELEVENLABS_API_KEY };
|
|
60
|
+
form.append('model_id', 'scribe_v1');
|
|
61
|
+
form.append('file', file, name);
|
|
62
|
+
if (lang) form.append('language_code', lang);
|
|
63
|
+
}
|
|
64
|
+
const res = await fetch(url, { method: 'POST', headers, body: form, signal: AbortSignal.timeout(30000) });
|
|
65
|
+
if (!res.ok) throw Object.assign(new Error(`${provider} stt ${res.status}`), { status: 502 });
|
|
66
|
+
const j = await res.json();
|
|
67
|
+
return { text: String(j.text || '').trim(), provider };
|
|
68
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local voice: Piper (https://github.com/rhasspy/piper) in the venv ~/.7ots/stt, voices in ~/.7ots/voices.
|
|
3
|
+
* Free and offline; the text never leaves the machine. Set up with:
|
|
4
|
+
* ~/.7ots/stt/bin/pip install piper-tts
|
|
5
|
+
* ~/.7ots/stt/bin/python -m piper.download_voices es_MX-claude-high en_US-lessac-medium --download-dir ~/.7ots/voices
|
|
6
|
+
* The voice follows the language of each line (a Spanish line gets a Spanish voice), a bit higher-pitched for an ot.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { execFile, spawn } from 'node:child_process';
|
|
10
|
+
import { existsSync, readdirSync, readFileSync, unlinkSync } from 'node:fs';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { homeFile } from './paths.mjs';
|
|
14
|
+
|
|
15
|
+
const PY = () => homeFile(join('stt', 'bin', 'python'));
|
|
16
|
+
const DIR = () => homeFile('voices');
|
|
17
|
+
|
|
18
|
+
export function localVoices() {
|
|
19
|
+
if (!existsSync(PY()) || !existsSync(DIR())) return [];
|
|
20
|
+
return readdirSync(DIR()).filter((f) => f.endsWith('.onnx')).map((f) => f.slice(0, -5));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const ES = /[ñ¿¡áéíóú]|\b(que|el|la|los|las|de|es|por|para|con|una?|hola|estoy|tienes|aquí|qué|cómo|ya|pero|muy)\b/gi;
|
|
24
|
+
const PT = /[ãõç]|\b(você|não|está|obrigad[oa]|olá|muito|tudo)\b/gi;
|
|
25
|
+
function guessLang(text, fallback) {
|
|
26
|
+
const es = (text.match(ES) || []).length;
|
|
27
|
+
const pt = (text.match(PT) || []).length;
|
|
28
|
+
const words = text.split(/\s+/).length;
|
|
29
|
+
if (pt >= 2 && pt >= es / 2) return 'pt';
|
|
30
|
+
if (es >= Math.max(2, words * 0.2)) return 'es';
|
|
31
|
+
return /^(es|pt)$/.test(fallback) && es + pt > 0 ? fallback : 'en';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// One worker with the voices loaded, started on first use; jobs go one at a time.
|
|
35
|
+
let worker = null;
|
|
36
|
+
let queue = Promise.resolve();
|
|
37
|
+
function startWorker() {
|
|
38
|
+
const p = spawn(PY(), [new URL('./tts-local.py', import.meta.url).pathname], { stdio: ['pipe', 'pipe', 'ignore'] });
|
|
39
|
+
const w = { p, buf: '', waiting: null };
|
|
40
|
+
p.stdout.on('data', (d) => {
|
|
41
|
+
w.buf += d;
|
|
42
|
+
let i;
|
|
43
|
+
while ((i = w.buf.indexOf('\n')) >= 0) {
|
|
44
|
+
const line = w.buf.slice(0, i);
|
|
45
|
+
w.buf = w.buf.slice(i + 1);
|
|
46
|
+
w.waiting?.(line);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
p.on('exit', () => {
|
|
50
|
+
if (worker === w) worker = null;
|
|
51
|
+
w.waiting?.('error: worker exited');
|
|
52
|
+
});
|
|
53
|
+
p.unref();
|
|
54
|
+
return w;
|
|
55
|
+
}
|
|
56
|
+
function say(voice, text, out) {
|
|
57
|
+
const job = queue.then(
|
|
58
|
+
() =>
|
|
59
|
+
new Promise((res, rej) => {
|
|
60
|
+
worker ||= startWorker();
|
|
61
|
+
const w = worker;
|
|
62
|
+
const timer = setTimeout(() => (w.p.kill(), rej(Object.assign(new Error('piper timeout'), { status: 504 }))), 30000);
|
|
63
|
+
w.waiting = (line) => {
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
w.waiting = null;
|
|
66
|
+
line === 'ok' ? res() : rej(Object.assign(new Error(`piper ${line}`), { status: 502 }));
|
|
67
|
+
};
|
|
68
|
+
w.p.stdin.write(JSON.stringify({ voice, text, out }) + '\n');
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
queue = job.catch(() => {});
|
|
72
|
+
return job;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** → { audio: Buffer (wav), contentType } */
|
|
76
|
+
export async function synthesizeLocal(text, { lang = 'en', pitch = 1.12 } = {}) {
|
|
77
|
+
const voices = localVoices();
|
|
78
|
+
if (!voices.length) throw Object.assign(new Error('no local voice (see tts-local.mjs)'), { status: 501 });
|
|
79
|
+
const l = guessLang(text, lang);
|
|
80
|
+
const voice = voices.find((v) => v.startsWith(`${l}_`)) || voices.find((v) => v.startsWith('en_')) || voices[0];
|
|
81
|
+
const out = join(tmpdir(), `7ots-tts-${process.pid}-${Date.now()}.wav`);
|
|
82
|
+
try {
|
|
83
|
+
await say(join(DIR(), `${voice}.onnx`), text.replace(/[\u{1F300}-\u{1FAFF}\u2600-\u27BF]/gu, ''), out);
|
|
84
|
+
if (Math.abs(pitch - 1) < 0.01) return { audio: readFileSync(out), contentType: 'audio/wav' };
|
|
85
|
+
// higher voice, same speed: resample up, then stretch the tempo back
|
|
86
|
+
const audio = await new Promise((res, rej) =>
|
|
87
|
+
execFile('ffmpeg', ['-nostdin', '-loglevel', 'error', '-i', out, '-af', `asetrate=22050*${pitch},aresample=22050,atempo=${(1 / pitch).toFixed(4)}`, '-f', 'wav', '-'], { encoding: 'buffer', maxBuffer: 32 << 20 }, (e, stdout) =>
|
|
88
|
+
{
|
|
89
|
+
if (!e) return res(stdout);
|
|
90
|
+
// ffmpeg failed: the plain voice, if there is one (a throw here would take the whole pet down)
|
|
91
|
+
try {
|
|
92
|
+
res(readFileSync(out));
|
|
93
|
+
} catch (err) {
|
|
94
|
+
rej(err);
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
),
|
|
98
|
+
);
|
|
99
|
+
return { audio, contentType: 'audio/wav' };
|
|
100
|
+
} finally {
|
|
101
|
+
try {
|
|
102
|
+
unlinkSync(out);
|
|
103
|
+
} catch {}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Loads the voices ahead of time, so the first line isn't seconds late. */
|
|
108
|
+
export function warmLocal() {
|
|
109
|
+
for (const v of localVoices()) say(join(DIR(), `${v}.onnx`), '.', join(tmpdir(), `7ots-warm-${process.pid}.wav`)).catch(() => {});
|
|
110
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Local voice worker for the 7ots pet: keeps Piper voices loaded (loading one takes seconds; speaking a line, a
|
|
2
|
+
fraction). Reads JSON lines {"voice": "/path/x.onnx", "text": "...", "out": "/tmp/x.wav"}, answers "ok" or "error: …"."""
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
import wave
|
|
6
|
+
|
|
7
|
+
from piper import PiperVoice
|
|
8
|
+
|
|
9
|
+
voices = {}
|
|
10
|
+
for line in sys.stdin:
|
|
11
|
+
try:
|
|
12
|
+
job = json.loads(line)
|
|
13
|
+
v = voices.get(job['voice']) or voices.setdefault(job['voice'], PiperVoice.load(job['voice']))
|
|
14
|
+
with wave.open(job['out'], 'wb') as w:
|
|
15
|
+
v.synthesize_wav(job['text'], w)
|
|
16
|
+
print('ok', flush=True)
|
|
17
|
+
except Exception as e: # keep serving
|
|
18
|
+
print(f'error: {e}', flush=True)
|
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(/ /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
|
+
}
|