@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,414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the ot can reach beyond reminders and notes (settings → Permissions → Integrations), and
|
|
3
|
+
* its character sheet (settings → Personality).
|
|
4
|
+
*
|
|
5
|
+
* config.integrations
|
|
6
|
+
* orquesta { on, projectId?, projectName? } talk to your Orquesta agents (ORQUESTA_TOKEN): list them, send a message,
|
|
7
|
+
* and it tells you what the agent answered when it finishes
|
|
8
|
+
* apumail { on, inbox } read your email (APUMAIL_TOKEN) — also needs access.inbox
|
|
9
|
+
* custom [{ id, name, instructions, url?, command?, envVar?, on }]
|
|
10
|
+
* told to the brain; with a url (GET, Bearer $envVar) or a command (/bin/sh -c, 20 s) it can fetch it
|
|
11
|
+
* access.logs it may read the logs of running processes: journal (systemd unit), pm2, docker, a log file,
|
|
12
|
+
* or a running process (its stdout file, else its journal). Secrets are redacted, ~80 lines.
|
|
13
|
+
* config.character { backstory, style, quirks, examples, scenario, greeting, letta?: { on, agentId } }
|
|
14
|
+
* a chara_card_v2-style sheet; it weighs more than the identity's traits in every prompt.
|
|
15
|
+
* With Letta (LETTA_API_KEY) the persona lives in a Letta agent's memory too, and what Letta
|
|
16
|
+
* remembers (persona + human blocks) comes back into the prompt.
|
|
17
|
+
* cloudStatus() link with 7ots.com (account.mjs) + the cloud assistant's VM: GET /api/device/ots/:id/vm (device token; see docs/VM-PLAN.md)
|
|
18
|
+
* (404 = not available yet).
|
|
19
|
+
*
|
|
20
|
+
* Extra assistant actions (assistant.mjs `extra`): read_logs · read_mail · orquesta_list · orquesta · integration.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { spawn } from 'node:child_process';
|
|
24
|
+
import { randomBytes } from 'node:crypto';
|
|
25
|
+
import { closeSync, openSync, readSync, readlinkSync, realpathSync, statSync } from 'node:fs';
|
|
26
|
+
import { homedir } from 'node:os';
|
|
27
|
+
import { env, loadConfig, loadKeys } from './config.mjs';
|
|
28
|
+
import { SEVENOTS_URL, me as accountMe, signedIn } from './account.mjs';
|
|
29
|
+
|
|
30
|
+
const LETTA_URL = (process.env.LETTA_BASE_URL || 'https://api.letta.com').replace(/\/+$/, '');
|
|
31
|
+
const APUMAIL_API = (process.env.APUMAIL_API || 'https://api.apumail.com').replace(/\/+$/, '');
|
|
32
|
+
const MAX_OUT = 4000;
|
|
33
|
+
|
|
34
|
+
const str = (v, max) => String(v ?? '').replace(/\r/g, '').trim().slice(0, max);
|
|
35
|
+
const bool = (v, d = false) => (typeof v === 'boolean' ? v : d);
|
|
36
|
+
|
|
37
|
+
// ── secrets never reach the brain ──
|
|
38
|
+
export function redact(s) {
|
|
39
|
+
return String(s ?? '')
|
|
40
|
+
.replace(/(--?[\w-]*(?:token|key|secret|pass(?:word)?|auth)[\w-]*[= ])\S+/gi, '$1***')
|
|
41
|
+
.replace(/\b([A-Z0-9_]*(?:TOKEN|KEY|SECRET|PASSWORD|PASS|AUTH)[A-Z0-9_]*\s*[=:]\s*)\S+/g, '$1***')
|
|
42
|
+
.replace(/("(?:[\w-]*(?:token|key|secret|password|authorization)[\w-]*)"\s*:\s*")[^"]*"/gi, '$1***"')
|
|
43
|
+
.replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi, '$1 ***')
|
|
44
|
+
.replace(/\b(sk|pk|rk|ghp|gho|ghs|xox[abpr]|oclt|oek|7d|7vm|7ve)[-_][A-Za-z0-9_-]{8,}/g, '$1_***')
|
|
45
|
+
.replace(/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+/g, 'jwt_***')
|
|
46
|
+
.replace(/(\/\/[^/\s:@]+:)[^@\s]+@/g, '$1***@');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── settings: validation ──
|
|
50
|
+
|
|
51
|
+
export function normalizeCharacter(d = {}, prev = {}) {
|
|
52
|
+
const out = { ...prev };
|
|
53
|
+
for (const [k, max] of [['backstory', 3000], ['style', 1500], ['quirks', 1500], ['examples', 4000], ['scenario', 1500], ['greeting', 600]]) if (d[k] !== undefined) out[k] = str(d[k], max);
|
|
54
|
+
if (d.letta) {
|
|
55
|
+
const agentId = str(d.letta.agentId ?? prev.letta?.agentId, 80);
|
|
56
|
+
if (agentId && !/^[\w-]{1,80}$/.test(agentId)) throw new Error('lettaAgent');
|
|
57
|
+
out.letta = { on: bool(d.letta.on, !!prev.letta?.on), ...(agentId ? { agentId } : {}) };
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function normalizeIntegrations(d = {}, prev = {}) {
|
|
63
|
+
const out = { orquesta: { ...prev.orquesta }, apumail: { ...prev.apumail }, custom: [...(prev.custom || [])] };
|
|
64
|
+
if (d.orquesta) {
|
|
65
|
+
const o = d.orquesta;
|
|
66
|
+
const projectId = o.projectId === undefined ? out.orquesta.projectId : str(o.projectId, 80);
|
|
67
|
+
if (projectId && !/^[\w-]{1,80}$/.test(projectId)) throw new Error('project');
|
|
68
|
+
out.orquesta = { on: bool(o.on, !!out.orquesta.on), ...(projectId ? { projectId, projectName: str(o.projectName ?? out.orquesta.projectName, 120) } : {}) };
|
|
69
|
+
}
|
|
70
|
+
if (d.apumail) {
|
|
71
|
+
const inbox = d.apumail.inbox === undefined ? out.apumail.inbox || '' : str(d.apumail.inbox, 120).toLowerCase();
|
|
72
|
+
if (inbox && !/^[^\s@/]+@[^\s@/]+\.[^\s@/]{2,}$/.test(inbox)) throw new Error('inbox');
|
|
73
|
+
out.apumail = { on: bool(d.apumail.on, !!out.apumail.on), inbox };
|
|
74
|
+
}
|
|
75
|
+
if (Array.isArray(d.custom)) {
|
|
76
|
+
if (d.custom.length > 20) throw new Error('custom');
|
|
77
|
+
out.custom = d.custom.map((c) => {
|
|
78
|
+
const name = str(c?.name, 60);
|
|
79
|
+
if (!name) throw new Error('customName');
|
|
80
|
+
const url = str(c.url, 300);
|
|
81
|
+
if (url && !/^https?:\/\/[^\s"'<>]+$/i.test(url)) throw new Error('customUrl');
|
|
82
|
+
const envVar = str(c.envVar, 64);
|
|
83
|
+
if (envVar && !/^[A-Z][A-Z0-9_]{1,63}$/.test(envVar)) throw new Error('customEnv');
|
|
84
|
+
const id = /^[\w-]{1,40}$/.test(c.id || '') ? c.id : randomBytes(4).toString('hex');
|
|
85
|
+
return { id, name, instructions: str(c.instructions, 1500), ...(url ? { url } : {}), ...(c.command ? { command: str(c.command, 500) } : {}), ...(envVar ? { envVar } : {}), on: bool(c.on, true) };
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── what goes into the system prompt ──
|
|
92
|
+
|
|
93
|
+
const lettaCache = { at: 0, agentId: '', text: '', busy: false };
|
|
94
|
+
|
|
95
|
+
/** The character sheet as prompt text ('' when empty). */
|
|
96
|
+
export function characterPrompt(ch = {}) {
|
|
97
|
+
if (!ch || typeof ch !== 'object') return '';
|
|
98
|
+
const parts = [
|
|
99
|
+
ch.backstory && `Backstory: ${ch.backstory}`,
|
|
100
|
+
ch.style && `How you speak: ${ch.style}`,
|
|
101
|
+
ch.quirks && `Quirks: ${ch.quirks}`,
|
|
102
|
+
ch.scenario && `Scenario: ${ch.scenario}`,
|
|
103
|
+
ch.examples && `Example dialogues (for your voice only; never repeat them word for word):\n${ch.examples}`,
|
|
104
|
+
].filter(Boolean);
|
|
105
|
+
if (ch.letta?.on && ch.letta.agentId) {
|
|
106
|
+
refreshLetta(ch.letta.agentId);
|
|
107
|
+
if (lettaCache.agentId === ch.letta.agentId && lettaCache.text) parts.push(lettaCache.text);
|
|
108
|
+
}
|
|
109
|
+
if (!parts.length) return '';
|
|
110
|
+
return `YOUR CHARACTER SHEET — this matters more than the traits above; stay true to it:\n${parts.join('\n')}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Custom integrations the human described (assistant prompts only). */
|
|
114
|
+
export function integrationsPrompt(config = {}) {
|
|
115
|
+
const custom = (config.integrations?.custom || []).filter((c) => c.on !== false);
|
|
116
|
+
if (!custom.length) return '';
|
|
117
|
+
return `Integrations your human set up for you:\n${custom.map((c) => `- ${c.name} (id ${c.id})${c.instructions ? `: ${c.instructions}` : ''}`).join('\n')}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── logs of running processes (access.logs) ──
|
|
121
|
+
|
|
122
|
+
const NAME_RE = /^[\w@.:+-]{1,80}$/;
|
|
123
|
+
const SENSITIVE = /(\/\.ssh\/|\/\.gnupg\/|\/\.aws\/|\/\.config\/gcloud\/|keys\.json|\.env(\.|$)|credential|secret|token|llaves|\.pem$|\.key$|\.p8$|id_[rd]sa|shadow$|\.kdbx$)/i;
|
|
124
|
+
|
|
125
|
+
function exec(bin, args, { timeoutMs = 10000, max = 64000 } = {}) {
|
|
126
|
+
return new Promise((resolve) => {
|
|
127
|
+
let out = '';
|
|
128
|
+
let child;
|
|
129
|
+
try {
|
|
130
|
+
child = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, NO_COLOR: '1', PAGER: 'cat' } });
|
|
131
|
+
} catch (e) {
|
|
132
|
+
return resolve({ ok: false, out: e.message });
|
|
133
|
+
}
|
|
134
|
+
const timer = setTimeout(() => child.kill('SIGTERM'), timeoutMs);
|
|
135
|
+
const take = (d) => {
|
|
136
|
+
out += d;
|
|
137
|
+
if (out.length > max) (out = out.slice(-max)), child.kill('SIGTERM');
|
|
138
|
+
};
|
|
139
|
+
child.stdout.on('data', take);
|
|
140
|
+
child.stderr.on('data', take);
|
|
141
|
+
child.on('error', (e) => (clearTimeout(timer), resolve({ ok: false, out: e.code === 'ENOENT' ? `${bin} is not installed` : e.message })));
|
|
142
|
+
child.on('close', (code) => (clearTimeout(timer), resolve({ ok: code === 0, out })));
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function tailFile(file, bytes = 16000) {
|
|
147
|
+
const real = realpathSync(file.replace(/^~(?=\/)/, homedir()));
|
|
148
|
+
if (SENSITIVE.test(real)) throw new Error('that file is private');
|
|
149
|
+
const st = statSync(real);
|
|
150
|
+
if (!st.isFile()) throw new Error('not a file');
|
|
151
|
+
const fd = openSync(real, 'r');
|
|
152
|
+
try {
|
|
153
|
+
const len = Math.min(bytes, st.size);
|
|
154
|
+
const buf = Buffer.alloc(len);
|
|
155
|
+
readSync(fd, buf, 0, len, st.size - len);
|
|
156
|
+
return buf.toString('utf8').split('\n').slice(len < st.size ? 1 : 0).join('\n');
|
|
157
|
+
} finally {
|
|
158
|
+
closeSync(fd);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const lastLines = (s, n = 80) => String(s).trimEnd().split('\n').slice(-n).join('\n');
|
|
163
|
+
|
|
164
|
+
/** The last lines of a process's logs. source: journal | pm2 | docker | file | process. */
|
|
165
|
+
export async function readLogs({ source, target }) {
|
|
166
|
+
const t = String(target || '').trim();
|
|
167
|
+
let text;
|
|
168
|
+
if (source === 'file') {
|
|
169
|
+
if (!/^(~|\/)[^\0]{1,300}$/.test(t) || !/(\.log(\.\d+)?|\.out|\.err|\.txt)$|\/log(s)?\//i.test(t)) throw new Error('only log files (*.log, …/logs/…)');
|
|
170
|
+
text = tailFile(t);
|
|
171
|
+
} else if (source === 'journal') {
|
|
172
|
+
if (!NAME_RE.test(t)) throw new Error('bad unit');
|
|
173
|
+
let r = await exec('journalctl', ['--user', '-u', t, '-n', '80', '--no-pager', '-o', 'short-iso']);
|
|
174
|
+
if (!r.ok || /No entries/i.test(r.out)) r = await exec('journalctl', ['-u', t, '-n', '80', '--no-pager', '-o', 'short-iso']);
|
|
175
|
+
text = r.out;
|
|
176
|
+
} else if (source === 'pm2') {
|
|
177
|
+
if (!NAME_RE.test(t)) throw new Error('bad name');
|
|
178
|
+
text = (await exec('pm2', ['logs', t, '--lines', '80', '--nostream', '--raw'])).out;
|
|
179
|
+
} else if (source === 'docker') {
|
|
180
|
+
if (!NAME_RE.test(t)) throw new Error('bad container');
|
|
181
|
+
text = (await exec('docker', ['logs', '--tail', '80', '--timestamps', t])).out;
|
|
182
|
+
} else if (source === 'process') {
|
|
183
|
+
if (!/^[\w .@:/=+-]{1,80}$/.test(t)) throw new Error('bad process');
|
|
184
|
+
const pids = (await exec('pgrep', ['-f', '--', t])).out.split('\n').map(Number).filter((p) => p && p !== process.pid).slice(0, 3);
|
|
185
|
+
if (!pids.length) throw new Error(`no running process matches "${t}"`);
|
|
186
|
+
for (const pid of pids) {
|
|
187
|
+
for (const fd of [1, 2]) {
|
|
188
|
+
try {
|
|
189
|
+
const f = readlinkSync(`/proc/${pid}/fd/${fd}`);
|
|
190
|
+
if (f.startsWith('/') && !f.startsWith('/dev/')) {
|
|
191
|
+
text = tailFile(f);
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
} catch {}
|
|
195
|
+
}
|
|
196
|
+
if (text) break;
|
|
197
|
+
}
|
|
198
|
+
if (!text) text = (await exec('journalctl', ['_PID=' + pids[0], '-n', '80', '--no-pager', '-o', 'short-iso'])).out;
|
|
199
|
+
if (!text.trim() || /No entries/i.test(text)) throw new Error(`"${t}" writes to a terminal; there is no log to read`);
|
|
200
|
+
} else throw new Error('unknown log source');
|
|
201
|
+
return redact(lastLines(text)).slice(-MAX_OUT);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── email (apumail) ──
|
|
205
|
+
|
|
206
|
+
export async function readMail(inbox, { n = 8 } = {}) {
|
|
207
|
+
const token = env().APUMAIL_TOKEN;
|
|
208
|
+
if (!token || !inbox) throw new Error('email is not connected');
|
|
209
|
+
const res = await fetch(`${APUMAIL_API}/api/v1/inbox/${encodeURIComponent(inbox)}/wait?timeout=1`, { headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(15000) });
|
|
210
|
+
if (!res.ok) throw new Error(`apumail ${res.status}`);
|
|
211
|
+
const { messages = [] } = await res.json();
|
|
212
|
+
return messages
|
|
213
|
+
.slice(-n)
|
|
214
|
+
.reverse()
|
|
215
|
+
.map((m) => `- ${new Date(Number(m.received_at) || m.received_at).toLocaleString()} · ${str(m.from, 120)} · ${str(m.subject, 160) || '(no subject)'}\n ${str(m.text, 300).replace(/\s+/g, ' ')}`)
|
|
216
|
+
.join('\n');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── custom integrations ──
|
|
220
|
+
|
|
221
|
+
async function runCustom(c) {
|
|
222
|
+
if (c.url) {
|
|
223
|
+
const tok = c.envVar ? process.env[c.envVar] || loadKeys()[c.envVar] : '';
|
|
224
|
+
const res = await fetch(c.url, { headers: { Accept: 'application/json, text/plain;q=0.9, */*;q=0.5', ...(tok ? { Authorization: `Bearer ${tok}` } : {}) }, signal: AbortSignal.timeout(15000) });
|
|
225
|
+
const body = (await res.text()).slice(0, 20000);
|
|
226
|
+
return redact(`HTTP ${res.status}\n${body}`).slice(0, MAX_OUT);
|
|
227
|
+
}
|
|
228
|
+
if (c.command) {
|
|
229
|
+
const r = await exec('/bin/sh', ['-c', c.command], { timeoutMs: 20000 });
|
|
230
|
+
return redact(lastLines(r.out, 120)).slice(-MAX_OUT);
|
|
231
|
+
}
|
|
232
|
+
return '';
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ── 7ots.com: link and cloud assistant ──
|
|
236
|
+
|
|
237
|
+
export async function cloudStatus(config = loadConfig()) {
|
|
238
|
+
const out = { url: SEVENOTS_URL, signedIn: signedIn(), current: config.account || null, vm: { status: 'soon' } };
|
|
239
|
+
if (!out.signedIn) return out;
|
|
240
|
+
try {
|
|
241
|
+
const m = await accountMe();
|
|
242
|
+
const acc = m.account || {};
|
|
243
|
+
out.user = { name: str(acc.name || acc.email, 80), email: str(acc.email, 120) };
|
|
244
|
+
const twin = (m.ots || []).find((o) => o.id === config.account?.otsId);
|
|
245
|
+
if (twin) out.twin = { id: twin.id, name: str(twin.name, 60) };
|
|
246
|
+
} catch (e) {
|
|
247
|
+
out.error = e.message;
|
|
248
|
+
if (e.status === 401) out.signedIn = false;
|
|
249
|
+
}
|
|
250
|
+
const id = config.account?.otsId;
|
|
251
|
+
if (!id || !out.signedIn) return out;
|
|
252
|
+
try {
|
|
253
|
+
const res = await fetch(`${SEVENOTS_URL}/api/device/ots/${encodeURIComponent(id)}/vm`, { headers: { Authorization: `Bearer ${env().SEVENOTS_TOKEN}` }, signal: AbortSignal.timeout(10000) });
|
|
254
|
+
if (res.ok) {
|
|
255
|
+
const v = await res.json().catch(() => ({}));
|
|
256
|
+
const vm = v.vm || v;
|
|
257
|
+
out.vm = { status: str(vm.status || 'unknown', 40), ...(vm.region ? { region: str(vm.region, 40) } : {}), ...(vm.since || vm.createdAt ? { since: vm.since || vm.createdAt } : {}) };
|
|
258
|
+
} else out.vm = { status: [404, 405, 501].includes(res.status) ? 'soon' : res.status === 401 || res.status === 403 ? 'denied' : 'error' };
|
|
259
|
+
} catch {
|
|
260
|
+
out.vm = { status: 'error' };
|
|
261
|
+
}
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── Letta (persona memory) ──
|
|
266
|
+
// Contract (docs.letta.com): Bearer LETTA_API_KEY
|
|
267
|
+
// POST /v1/agents { name, memory_blocks: [{ label, value }], model, embedding } → { id }
|
|
268
|
+
// GET /v1/agents/:id/core-memory/blocks/:label → { value }
|
|
269
|
+
// PATCH /v1/agents/:id/core-memory/blocks/:label { value }
|
|
270
|
+
|
|
271
|
+
async function letta(path, { method = 'GET', body } = {}) {
|
|
272
|
+
const key = env().LETTA_API_KEY;
|
|
273
|
+
if (!key) throw Object.assign(new Error('LETTA_API_KEY is missing'), { status: 400 });
|
|
274
|
+
const res = await fetch(`${LETTA_URL}${path}`, { method, headers: { Authorization: `Bearer ${key}`, ...(body ? { 'Content-Type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(20000) });
|
|
275
|
+
const data = await res.json().catch(() => ({}));
|
|
276
|
+
if (!res.ok) throw Object.assign(new Error(`Letta ${res.status}${data.detail ? `: ${String(typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail)).slice(0, 160)}` : ''}`), { status: res.status });
|
|
277
|
+
return data;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const personaText = (identity, ch) => [`I am ${identity.name}, a 7ots desktop ot.`, ch.backstory, ch.style && `I speak like this: ${ch.style}`, ch.quirks && `Quirks: ${ch.quirks}`].filter(Boolean).join('\n').slice(0, 4500);
|
|
281
|
+
|
|
282
|
+
/** Writes the sheet into Letta's persona block (creates the agent the first time). Returns { agentId, created }. */
|
|
283
|
+
export async function lettaSync(identity, ch = {}) {
|
|
284
|
+
const value = personaText(identity, ch);
|
|
285
|
+
if (ch.letta?.agentId) {
|
|
286
|
+
await letta(`/v1/agents/${encodeURIComponent(ch.letta.agentId)}/core-memory/blocks/persona`, { method: 'PATCH', body: { value } });
|
|
287
|
+
lettaCache.at = 0;
|
|
288
|
+
return { agentId: ch.letta.agentId, created: false };
|
|
289
|
+
}
|
|
290
|
+
const a = await letta('/v1/agents', { method: 'POST', body: { name: `7ots-${String(identity.name).replace(/[^\w-]/g, '').slice(0, 30) || 'ot'}`, memory_blocks: [{ label: 'persona', value }, { label: 'human', value: 'My human, who keeps me on their computer.' }], model: process.env.LETTA_MODEL || 'openai/gpt-4o-mini', embedding: process.env.LETTA_EMBEDDING || 'openai/text-embedding-3-small' } });
|
|
291
|
+
if (!a.id) throw new Error('Letta did not return an agent id');
|
|
292
|
+
return { agentId: String(a.id), created: true };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function refreshLetta(agentId) {
|
|
296
|
+
if (lettaCache.busy || (lettaCache.agentId === agentId && Date.now() - lettaCache.at < 10 * 60_000) || !env().LETTA_API_KEY) return;
|
|
297
|
+
lettaCache.busy = true;
|
|
298
|
+
const block = (l) => letta(`/v1/agents/${encodeURIComponent(agentId)}/core-memory/blocks/${l}`).then((b) => str(b.value, 2000), () => '');
|
|
299
|
+
Promise.all([block('persona'), block('human')])
|
|
300
|
+
.then(([persona, human]) => {
|
|
301
|
+
lettaCache.agentId = agentId;
|
|
302
|
+
lettaCache.text = [persona && `Your persona as Letta remembers it: ${persona}`, human && `What Letta remembers about your human: ${human}`].filter(Boolean).join('\n');
|
|
303
|
+
})
|
|
304
|
+
.finally(() => ((lettaCache.at = Date.now()), (lettaCache.busy = false)));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ── extra assistant actions ──
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* @param {{ config: () => object, brain: object, system: (lang) => string, onLater: (line: string) => void, log?: Function }} o
|
|
311
|
+
* onLater an Orquesta agent answered (or failed) after the reply: say it
|
|
312
|
+
*/
|
|
313
|
+
export function createExtras({ config, brain, system, onLater, log = () => {} }) {
|
|
314
|
+
let projCache = { at: 0, list: [] };
|
|
315
|
+
const orq = () => import('./orquesta.mjs');
|
|
316
|
+
const orqOn = () => config().integrations?.orquesta?.on && Boolean(env().ORQUESTA_TOKEN);
|
|
317
|
+
const mailOn = () => config().access?.inbox === true && config().integrations?.apumail?.on && config().integrations.apumail.inbox && Boolean(env().APUMAIL_TOKEN);
|
|
318
|
+
const customs = () => (config().integrations?.custom || []).filter((c) => c.on !== false && (c.url || c.command));
|
|
319
|
+
|
|
320
|
+
async function projects() {
|
|
321
|
+
if (Date.now() - projCache.at < 5 * 60_000 && projCache.list.length) return projCache.list;
|
|
322
|
+
projCache = { at: Date.now(), list: await (await orq()).agentProjects() };
|
|
323
|
+
return projCache.list;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function follow(id, project, lang) {
|
|
327
|
+
const { agentTask } = await orq();
|
|
328
|
+
const end = Date.now() + 30 * 60_000;
|
|
329
|
+
while (Date.now() < end) {
|
|
330
|
+
await new Promise((r) => setTimeout(r, 10_000));
|
|
331
|
+
let t;
|
|
332
|
+
try {
|
|
333
|
+
t = await agentTask(id);
|
|
334
|
+
} catch (e) {
|
|
335
|
+
log(`orquesta: ${e.message}`);
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (!['completed', 'failed', 'cancelled'].includes(t.status)) continue;
|
|
339
|
+
let line = `${project}: ${t.status === 'completed' ? cut(t.result || 'done', 240) : t.error || t.status}`;
|
|
340
|
+
if (t.status === 'completed' && t.result && brain.kind !== 'lines') {
|
|
341
|
+
try {
|
|
342
|
+
const said = await brain.think({ purpose: 'assistant', system: system(lang), messages: [{ role: 'user', content: `Your human asked their Orquesta agent in project "${project}" for something and it just finished. Its answer:\n"""${redact(t.result).slice(-3000)}"""\nTell your human in 1-2 short sentences what it did or answered, in character.` }], timeoutMs: 45000 });
|
|
343
|
+
if (said) line = said;
|
|
344
|
+
} catch {}
|
|
345
|
+
}
|
|
346
|
+
return onLater(line);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return {
|
|
351
|
+
/** Action docs for the assistant prompt (only what is on). */
|
|
352
|
+
prompt() {
|
|
353
|
+
const c = config();
|
|
354
|
+
const lines = [];
|
|
355
|
+
if (c.access?.logs === true) lines.push(`{"type":"read_logs","source":"journal|pm2|docker|file|process","target":"unit / pm2 app / container / path to a .log file / part of a running command"} (read the last lines of a program's logs, then you explain them)`);
|
|
356
|
+
if (mailOn()) lines.push(`{"type":"read_mail"} (read the latest emails in their inbox ${c.integrations.apumail.inbox}, then you sum them up)`);
|
|
357
|
+
if (orqOn()) {
|
|
358
|
+
const def = c.integrations.orquesta.projectName ? ` (default project: "${c.integrations.orquesta.projectName}")` : '';
|
|
359
|
+
lines.push(`{"type":"orquesta_list"} (list their Orquesta projects and whether each agent is online) · {"type":"orquesta","project":"name or id, or null for the default","text":"the message for that project's coding agent"}${def} (send a task or question to one of their Orquesta agents; you tell them the answer when it finishes)`);
|
|
360
|
+
}
|
|
361
|
+
for (const x of customs()) lines.push(`{"type":"integration","id":"${x.id}"} (fetch "${x.name}")`);
|
|
362
|
+
return lines.length ? `More actions you have:\n${lines.join('\n')}` : '';
|
|
363
|
+
},
|
|
364
|
+
/** A raw AI action → a validated one, or null. */
|
|
365
|
+
parse(a) {
|
|
366
|
+
if (a?.type === 'read_logs' && ['journal', 'pm2', 'docker', 'file', 'process'].includes(a.source) && a.target) return { type: 'read_logs', extra: true, source: a.source, target: String(a.target).slice(0, 300) };
|
|
367
|
+
if (a?.type === 'read_mail') return { type: 'read_mail', extra: true };
|
|
368
|
+
if (a?.type === 'orquesta_list') return { type: 'orquesta_list', extra: true };
|
|
369
|
+
if (a?.type === 'orquesta' && a.text) return { type: 'orquesta', extra: true, project: a.project ? String(a.project).slice(0, 120) : null, text: String(a.text).slice(0, 4000) };
|
|
370
|
+
if (a?.type === 'integration' && a.id) return { type: 'integration', extra: true, id: String(a.id).slice(0, 40) };
|
|
371
|
+
return null;
|
|
372
|
+
},
|
|
373
|
+
/** Runs one: { say?, data? }. data goes back to the brain for the final reply. Never throws. */
|
|
374
|
+
async run(a, lang) {
|
|
375
|
+
const c = config();
|
|
376
|
+
try {
|
|
377
|
+
if (a.type === 'read_logs') {
|
|
378
|
+
if (c.access?.logs !== true) return { data: '(you are not allowed to read logs: settings → Permissions)' };
|
|
379
|
+
return { data: `Logs (${a.source} ${a.target}):\n${await readLogs(a)}` };
|
|
380
|
+
}
|
|
381
|
+
if (a.type === 'read_mail') {
|
|
382
|
+
if (!mailOn()) return { data: '(email is not connected or not allowed: settings → Permissions)' };
|
|
383
|
+
return { data: `Latest emails:\n${(await readMail(c.integrations.apumail.inbox)) || '(none)'}` };
|
|
384
|
+
}
|
|
385
|
+
if (a.type === 'orquesta_list') {
|
|
386
|
+
if (!orqOn()) return { data: '(Orquesta is not connected)' };
|
|
387
|
+
const ps = await projects();
|
|
388
|
+
return { data: `Orquesta projects:\n${ps.map((p) => `- ${p.name} (id ${p.id}) agent ${p.online === null ? 'unknown' : p.online ? 'online' : 'offline'}`).join('\n') || '(none)'}` };
|
|
389
|
+
}
|
|
390
|
+
if (a.type === 'orquesta') {
|
|
391
|
+
if (!orqOn()) return { data: '(Orquesta is not connected)' };
|
|
392
|
+
const ps = await projects();
|
|
393
|
+
const want = String(a.project || '').toLowerCase();
|
|
394
|
+
const p = want ? ps.find((x) => x.id === a.project) || ps.find((x) => x.name.toLowerCase() === want) || ps.find((x) => x.name.toLowerCase().includes(want)) : ps.find((x) => x.id === c.integrations.orquesta.projectId) || (ps.length === 1 ? ps[0] : null);
|
|
395
|
+
if (!p) return { data: `(which project? ${ps.map((x) => x.name).join(', ')})` };
|
|
396
|
+
const { sendToAgent } = await orq();
|
|
397
|
+
const { id } = await sendToAgent(p.id, a.text);
|
|
398
|
+
follow(id, p.name, lang).catch((e) => log(`orquesta: ${e.message}`));
|
|
399
|
+
return { data: `Sent to the agent of "${p.name}"${p.online === false ? ' (its agent is offline now; it runs when it comes back)' : ''}. Tell them you'll let them know when it answers.` };
|
|
400
|
+
}
|
|
401
|
+
if (a.type === 'integration') {
|
|
402
|
+
const x = customs().find((y) => y.id === a.id);
|
|
403
|
+
if (!x) return { data: '(that integration is off or does not exist)' };
|
|
404
|
+
return { data: `${x.name}:\n${(await runCustom(x)) || '(empty)'}` };
|
|
405
|
+
}
|
|
406
|
+
} catch (e) {
|
|
407
|
+
return { data: `(${a.type} failed: ${redact(e.message).slice(0, 200)})` };
|
|
408
|
+
}
|
|
409
|
+
return {};
|
|
410
|
+
},
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const cut = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
|
package/cli/lib/orquesta.mjs
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* projects() GET /api/orquesta-cli/projects
|
|
8
8
|
* watchLogs() polls GET /api/v1/logs?projectId= so the pet can comment on what the project's
|
|
9
9
|
* agent is doing (there is no `since`: we drop ids already seen).
|
|
10
|
+
* agentProjects() · sendToAgent() · agentTask() talk to the projects' agents (integrations.mjs)
|
|
10
11
|
* Batuta (the brain) is OpenAI-compatible at /api/v1 with the same token (see brain.mjs).
|
|
11
12
|
*/
|
|
12
13
|
|
|
@@ -82,3 +83,55 @@ export function watchLogs(projectId, onLog, { everyMs = 30_000, onError = () =>
|
|
|
82
83
|
poll();
|
|
83
84
|
return () => (stopped = true);
|
|
84
85
|
}
|
|
86
|
+
|
|
87
|
+
// ── talking to the user's Orquesta agents (the same /api/v1 the 7ots.com platform uses) ──
|
|
88
|
+
// GET /api/v1/projects → { projects: [{ id, name, agentOnline, agentLastSeen }] } (oclt_ CLI tokens: /api/orquesta-cli/projects)
|
|
89
|
+
// POST /api/v1/prompts { projectId, content, context, tags, source:'api' } → 201 { prompt: { id } }
|
|
90
|
+
// 400 AGENT_NOT_CONFIGURED when the project has no agent
|
|
91
|
+
// GET /api/v1/prompts/:id → { prompt: { status, result?, error } }
|
|
92
|
+
// GET /api/v1/logs?promptId=&afterSequence= → { logs: [{ category, message, sequence }] } (text in 'output')
|
|
93
|
+
|
|
94
|
+
const FINAL = ['completed', 'failed', 'cancelled'];
|
|
95
|
+
|
|
96
|
+
/** Projects the token reaches, with whether their agent is online. */
|
|
97
|
+
export async function agentProjects() {
|
|
98
|
+
let data;
|
|
99
|
+
try {
|
|
100
|
+
data = await api('/api/v1/projects');
|
|
101
|
+
} catch (e) {
|
|
102
|
+
if (![401, 403, 404].includes(e.status)) throw e;
|
|
103
|
+
data = await api('/api/orquesta-cli/projects');
|
|
104
|
+
}
|
|
105
|
+
return (data.projects || []).slice(0, 200).map((p) => ({ id: String(p.id), name: String(p.name || p.slug || p.id).slice(0, 120), online: p.agentOnline == null ? null : !!p.agentOnline }));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Hands a message to a project's agent. Returns { id }. */
|
|
109
|
+
export async function sendToAgent(projectId, content, { context = '7ots desktop ot' } = {}) {
|
|
110
|
+
try {
|
|
111
|
+
const data = await api('/api/v1/prompts', { method: 'POST', body: { projectId, content: String(content).slice(0, 4000), context: String(context).slice(0, 500), tags: ['7ots', 'desktop'], source: 'api' } });
|
|
112
|
+
const id = data.prompt?.id;
|
|
113
|
+
if (!id) throw new Error('no prompt id');
|
|
114
|
+
return { id: String(id) };
|
|
115
|
+
} catch (e) {
|
|
116
|
+
if (/AGENT_NOT_CONFIGURED/i.test(e.message)) throw Object.assign(new Error('this project has no agent connected'), { status: 400 });
|
|
117
|
+
throw e;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Where a message to an agent is: { status, result?, error? } (the agent's output once it finished). */
|
|
122
|
+
export async function agentTask(id) {
|
|
123
|
+
const { prompt: p = {} } = await api(`/api/v1/prompts/${encodeURIComponent(id)}`);
|
|
124
|
+
const status = String(p.status || 'pending');
|
|
125
|
+
if (!FINAL.includes(status)) return { status };
|
|
126
|
+
if (status !== 'completed') return { status, error: String(p.error || status).slice(0, 500) };
|
|
127
|
+
if (typeof p.result === 'string' && p.result.trim()) return { status, result: p.result.trim().slice(0, 8000) };
|
|
128
|
+
const out = [];
|
|
129
|
+
let after = null;
|
|
130
|
+
for (let page = 0; page < 5; page++) {
|
|
131
|
+
const { logs = [] } = await api(`/api/v1/logs?${new URLSearchParams({ promptId: id, ...(after != null ? { afterSequence: String(after) } : {}) })}`).catch(() => ({}));
|
|
132
|
+
for (const l of logs) if (l.category === 'output' && l.message) out.push(String(l.message));
|
|
133
|
+
if (logs.length < 50) break;
|
|
134
|
+
after = logs.at(-1).sequence;
|
|
135
|
+
}
|
|
136
|
+
return { status, result: out.join('\n').trim().slice(-8000) };
|
|
137
|
+
}
|
package/cli/lib/pet-core.mjs
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* food, energy, fun 0..100 (100 = full / rested / happy). They decay with time.
|
|
5
5
|
* xp, level grow with work that goes well (passing commands, finished agent turns).
|
|
6
|
-
* asleep while asleep energy recovers and it stays quiet.
|
|
6
|
+
* asleep while asleep energy recovers and it stays quiet. It sleeps until you wake it
|
|
7
|
+
* (wake, feed, play); urgent things only stir it (r.stir) and it dozes off again.
|
|
7
8
|
*
|
|
8
9
|
* Events come from hooks (Claude Code, the shell) and from the user (feed, play, sleep).
|
|
9
10
|
*/
|
|
@@ -38,7 +39,6 @@ export function tick(p, now = Date.now()) {
|
|
|
38
39
|
if (p.asleep) {
|
|
39
40
|
p.energy = clamp(p.energy + 30 * h);
|
|
40
41
|
p.food = clamp(p.food - 2 * h);
|
|
41
|
-
if (p.energy >= 100) p.asleep = false;
|
|
42
42
|
} else {
|
|
43
43
|
p.food = clamp(p.food - 5 * h);
|
|
44
44
|
p.fun = clamp(p.fun - 7 * h);
|
|
@@ -89,8 +89,9 @@ export function applyEvent(p, ev, now = Date.now()) {
|
|
|
89
89
|
const happy = () => (p._happy = now);
|
|
90
90
|
const sad = () => (p._sad = now);
|
|
91
91
|
switch (ev.type) {
|
|
92
|
-
case 'feed':
|
|
92
|
+
case 'feed': // one "treat" button: food and fun together
|
|
93
93
|
p.food = clamp(p.food + 30);
|
|
94
|
+
p.fun = clamp(p.fun + 20);
|
|
94
95
|
p.asleep = false;
|
|
95
96
|
happy();
|
|
96
97
|
Object.assign(r, { line: 'fed', gesture: 'bounce', effect: 'hearts', important: true });
|
|
@@ -138,7 +139,6 @@ export function applyEvent(p, ev, now = Date.now()) {
|
|
|
138
139
|
break;
|
|
139
140
|
case 'prompt':
|
|
140
141
|
p.energy = clamp(p.energy - 1);
|
|
141
|
-
p.asleep = false;
|
|
142
142
|
r.line = 'prompt';
|
|
143
143
|
break;
|
|
144
144
|
case 'stop':
|
|
@@ -147,19 +147,16 @@ export function applyEvent(p, ev, now = Date.now()) {
|
|
|
147
147
|
p.stats.turns++;
|
|
148
148
|
happy();
|
|
149
149
|
r.levelUp = gainXp(p, 5);
|
|
150
|
-
Object.assign(r, { line: 'stop', gesture: 'jump', important: true });
|
|
150
|
+
Object.assign(r, { line: 'stop', gesture: 'jump', important: true, stir: p.asleep }); // background work finished
|
|
151
151
|
break;
|
|
152
152
|
case 'notify':
|
|
153
|
-
p.asleep
|
|
154
|
-
Object.assign(r, { line: 'waiting', gesture: 'wave', important: true });
|
|
153
|
+
Object.assign(r, { line: 'waiting', gesture: 'wave', important: true, stir: p.asleep });
|
|
155
154
|
break;
|
|
156
155
|
case 'start':
|
|
157
|
-
p.asleep = false;
|
|
158
156
|
Object.assign(r, { line: 'hello', gesture: 'wave', important: true });
|
|
159
157
|
break;
|
|
160
158
|
case 'say':
|
|
161
|
-
p.asleep
|
|
162
|
-
Object.assign(r, { important: true });
|
|
159
|
+
Object.assign(r, { important: true, stir: p.asleep });
|
|
163
160
|
break;
|
|
164
161
|
default:
|
|
165
162
|
break;
|