@7ots/cli 0.1.6 → 0.1.8
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 +3 -3
- package/cli/7ots.mjs +13 -1
- package/cli/lib/account.mjs +152 -2
- package/cli/lib/assistant.mjs +107 -38
- package/cli/lib/brain.mjs +13 -2
- package/cli/lib/config.mjs +5 -1
- package/cli/lib/inferences.mjs +30 -6
- package/cli/lib/integrations.mjs +69 -5
- package/cli/lib/orquesta.mjs +44 -1
- package/cli/lib/pet-server.mjs +352 -11
- package/cli/lib/prompts.mjs +31 -17
- package/cli/lib/prowl.mjs +110 -0
- package/cli/pet/electron/director.cjs +37 -5
- package/cli/pet/electron/main.cjs +92 -10
- package/cli/pet/pet.html +149 -10
- package/cli/pet/settings.html +549 -43
- package/dist/7ots.esm.js +216 -137
- package/dist/7ots.esm.js.map +4 -4
- package/dist/7ots.iife.js +216 -137
- package/dist/7ots.iife.js.map +4 -4
- package/llms.txt +1 -1
- package/package.json +17 -14
- package/server/channels/agent.mjs +7 -1
- package/server/channels/index.mjs +3 -2
- package/server/platform/connect.mjs +4 -1
- package/server/platform/db.mjs +14 -1
- package/server/platform/device.mjs +101 -4
- package/server/platform/mods.mjs +222 -0
- package/server/platform/orquesta.mjs +78 -14
- package/server/platform/profile.mjs +161 -0
- package/server/platform/routes.mjs +39 -33
- package/server/platform/runtime.mjs +5 -3
- package/server/platform/store.mjs +1 -1
- package/server/platform/vm.mjs +688 -17
- package/server/server.mjs +12 -2
- package/server/tts.mjs +17 -4
- package/src/avatar/three.js +3 -2
- package/src/character/AvatarEditor.js +95 -7
- package/src/character/Character.js +116 -17
- package/src/character/TradingCard.js +226 -0
- package/src/character/mods/index.js +4 -0
- package/src/character/mods/orc.js +64 -0
- package/src/character/mods.js +251 -0
- package/src/character/more/acc-head.js +213 -0
- package/src/character/more/acc-rest.js +342 -0
- package/src/character/more/brows.js +39 -0
- package/src/character/more/cheeks.js +78 -0
- package/src/character/more/eyes.js +288 -0
- package/src/character/more/mouths.js +259 -0
- package/src/character/more/patterns.js +120 -0
- package/src/character/parts.js +100 -0
- package/src/character/skin.js +166 -1
- package/src/character/styles.js +1285 -9
- package/src/character3d/Character3D.js +253 -37
- package/src/character3d/index.js +7 -1
- package/src/character3d/model.js +367 -0
- package/src/i18n/messages/character.js +37 -22
- package/src/i18n/messages/platform.js +33 -0
- package/src/identity/random.js +17 -3
- package/src/identity/schema.js +10 -0
- package/src/index.js +1 -0
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ npx @7ots/cli hooks install --claude # y escucha a Claude Code (aciertos, errore
|
|
|
10
10
|
```
|
|
11
11
|
|
|
12
12
|
- **Mascota de escritorio.** Ventana siempre visible, terminal o tmux; tiene necesidades, sube de nivel y comenta tu trabajo.
|
|
13
|
-
- **Cuida a tu agente de código.** Hooks de Claude Code
|
|
13
|
+
- **Cuida a tu agente de código.** Hooks de Claude Code y de la shell (bash/zsh): te avisa cuando tu agente termina, falla o te pide permiso, y cuando un comando falla.
|
|
14
14
|
- **Asistente.** Recordatorios, alertas de precios y procesos, conciencia de pantalla opcional (apagada por defecto) y voz local.
|
|
15
15
|
- **Videollamadas y Orquesta.** Entra a llamadas de apuchat con su avatar; conectado a Orquesta, vive en un proyecto y lanza tareas.
|
|
16
16
|
- **Para dueños de sitios, APIs y MCP.** Registra tu servicio en [7ots.com/app#sites](https://7ots.com/app#sites) con una guía de uso
|
|
@@ -232,7 +232,7 @@ El proxy sirve un panel en **`/backoffice/`** (por ejemplo `http://localhost:878
|
|
|
232
232
|
| Comportamiento | Proactividad (nivel, saludo, límites, señales), modo compañero, puntero, acciones y micrófono. |
|
|
233
233
|
| Widget y marca | Lado, redondeo, tipografía, color, y JSON avanzado de `init()`. |
|
|
234
234
|
| Privacidad y navegación | `privateSelectors`, `ignoreSelectors` y `navigation.allowedOrigins`. |
|
|
235
|
-
|
|
|
235
|
+
| Avanzado (servidor y claves) | Todas las variables del proxy: LLM, TTS, apumail/apuchat, límites, orígenes… |
|
|
236
236
|
| Instalar | El snippet de una línea (`embed.js`) y los de código, tarjeta y editor. |
|
|
237
237
|
|
|
238
238
|
- **Vista previa en vivo**: los cambios sin guardar se ven al momento en un widget de prueba.
|
|
@@ -630,7 +630,7 @@ src/
|
|
|
630
630
|
character/Character.js personaje 2D estilo Pou: render, animación, ánimos y gestos
|
|
631
631
|
character/parts.js catálogo: formas, ojos, bocas, cejas, accesorios, estilos
|
|
632
632
|
character/AvatarEditor.js <ots-avatar-editor>: editor visual del personaje (y 3D/retrato)
|
|
633
|
-
backoffice/ panel /backoffice/: index.html, app.js, app.css
|
|
633
|
+
backoffice/ panel /backoffice/: index.html, app.js, app.css
|
|
634
634
|
server/ proxy Node (sin frameworks): llm, tts, contact, demo
|
|
635
635
|
identity.mjs identidad vigente: entorno + data/identity.json
|
|
636
636
|
settings.mjs data/config.json (widget + servidor) y data/secrets.json sobre el entorno
|
package/cli/7ots.mjs
CHANGED
|
@@ -178,6 +178,18 @@ function ensureElectron() {
|
|
|
178
178
|
}
|
|
179
179
|
|
|
180
180
|
/** Electron window (detached). Resolves ok once it has stayed up for a few seconds. */
|
|
181
|
+
|
|
182
|
+
// The shell's menus speak the human's language (like the settings page): SEVENOTS_LANG, config.lang, the
|
|
183
|
+
// system's, and only then the ot's — cliLang() puts the ot's first, which left a Spanish desktop with English menus.
|
|
184
|
+
function menuLang() {
|
|
185
|
+
const pick = (v) => (['en', 'es', 'pt'].includes(v) ? v : '');
|
|
186
|
+
let lang = '';
|
|
187
|
+
try {
|
|
188
|
+
lang = loadConfig().lang;
|
|
189
|
+
} catch {}
|
|
190
|
+
return pick(process.env.SEVENOTS_LANG) || pick(lang) || pick(String(process.env.LC_ALL || process.env.LC_MESSAGES || process.env.LANG || '').slice(0, 2).toLowerCase()) || cliLang();
|
|
191
|
+
}
|
|
192
|
+
|
|
181
193
|
function openDesktop({ opaque = false, view3d = false } = {}) {
|
|
182
194
|
console.log(dim(t('pet.electron')));
|
|
183
195
|
const { bin, error } = ensureElectron();
|
|
@@ -186,7 +198,7 @@ function openDesktop({ opaque = false, view3d = false } = {}) {
|
|
|
186
198
|
const child = spawn(bin, [join(PKG_ROOT, 'cli', 'pet', 'electron', 'main.cjs')], {
|
|
187
199
|
stdio: ['ignore', log, log],
|
|
188
200
|
detached: true,
|
|
189
|
-
env: { ...process.env, SEVENOTS_PET_URL: `http://127.0.0.1:${PET_PORT}`, SEVENOTS_PET_TOKEN: petToken(), SEVENOTS_PET_OPAQUE: opaque ? '1' : '', SEVENOTS_PET_3D: view3d ? '1' : '', SEVENOTS_PET_LANG:
|
|
201
|
+
env: { ...process.env, SEVENOTS_PET_URL: `http://127.0.0.1:${PET_PORT}`, SEVENOTS_PET_TOKEN: petToken(), SEVENOTS_PET_OPAQUE: opaque ? '1' : '', SEVENOTS_PET_3D: view3d ? '1' : '', SEVENOTS_PET_LANG: menuLang() },
|
|
190
202
|
});
|
|
191
203
|
return new Promise((resolve) => {
|
|
192
204
|
const timer = setTimeout(() => {
|
package/cli/lib/account.mjs
CHANGED
|
@@ -10,12 +10,15 @@
|
|
|
10
10
|
* use(id) makes that ot this computer's pet (~/.7ots/identity.json; the previous one is
|
|
11
11
|
* kept in identity.prev.json) and points its home at 7ots.com
|
|
12
12
|
* logout() revokes the token on 7ots.com and forgets it here
|
|
13
|
+
* sync() keeps this pet and its ot on 7ots.com equal (look and mods, personality, character
|
|
14
|
+
* sheet): whichever side changed since the last sync wins; both → the newer one
|
|
13
15
|
*/
|
|
14
16
|
|
|
15
17
|
import { hostname } from 'node:os';
|
|
16
|
-
import {
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
17
20
|
import { env, loadConfig, saveConfig, saveKey } from './config.mjs';
|
|
18
|
-
import { homeFile, writeJson } from './paths.mjs';
|
|
21
|
+
import { homeFile, readJson, writeJson } from './paths.mjs';
|
|
19
22
|
import { defineIdentity } from '../../src/identity/schema.js';
|
|
20
23
|
|
|
21
24
|
export const SEVENOTS_URL = (process.env.SEVENOTS_URL || 'https://7ots.com').replace(/\/+$/, '');
|
|
@@ -87,3 +90,150 @@ export async function logout() {
|
|
|
87
90
|
delete config.account;
|
|
88
91
|
saveConfig(config);
|
|
89
92
|
}
|
|
93
|
+
|
|
94
|
+
// ── sync: the same ot here and on 7ots.com ──
|
|
95
|
+
const SYNCED = ['name', 'role', 'tagline', 'bio', 'language', 'languages'];
|
|
96
|
+
const SHEET = ['backstory', 'style', 'quirks', 'scenario', 'greeting', 'examples'];
|
|
97
|
+
const MODEL = /^\/mods\/([a-f0-9]{16})\.(glb|vrm)$/;
|
|
98
|
+
const hashOf = (v) => createHash('sha256').update(JSON.stringify(v)).digest('hex').slice(0, 16);
|
|
99
|
+
|
|
100
|
+
/** The part of an identity both sides keep equal. */
|
|
101
|
+
function syncView(identity) {
|
|
102
|
+
const id = defineIdentity(identity);
|
|
103
|
+
const { tone, traits, sheet } = id.personality;
|
|
104
|
+
return { ...Object.fromEntries(SYNCED.map((k) => [k, id[k]])), personality: { tone, traits, sheet }, look: { character: id.look.character } };
|
|
105
|
+
}
|
|
106
|
+
/** This pet's side: identity.json + the character sheet (kept in config.character). */
|
|
107
|
+
function localView() {
|
|
108
|
+
const raw = readJson(homeFile('identity.json')) || {};
|
|
109
|
+
const ch = loadConfig().character || {};
|
|
110
|
+
return syncView({ ...raw, personality: { ...raw.personality, sheet: Object.fromEntries(SHEET.filter((k) => ch[k]).map((k) => [k, ch[k]])) } });
|
|
111
|
+
}
|
|
112
|
+
const modelsOf = (view) => (view.look.character.mods || []).map((m) => m.model?.url).filter((u) => MODEL.test(u || ''));
|
|
113
|
+
const localMtime = () => Math.max(...['identity.json', 'config.json'].map((f) => (existsSync(homeFile(f)) ? statSync(homeFile(f)).mtimeMs : 0)));
|
|
114
|
+
|
|
115
|
+
async function pushModels(view) {
|
|
116
|
+
const token = env().SEVENOTS_TOKEN;
|
|
117
|
+
for (const url of modelsOf(view)) {
|
|
118
|
+
const file = homeFile(url.slice(1));
|
|
119
|
+
if (!existsSync(file)) continue;
|
|
120
|
+
const head = await fetch(`${SEVENOTS_URL}${url}`, { method: 'HEAD', signal: AbortSignal.timeout(15000) }).catch(() => null);
|
|
121
|
+
if (head?.ok) continue;
|
|
122
|
+
const [, , ext] = MODEL.exec(url);
|
|
123
|
+
const r = await fetch(`${SEVENOTS_URL}/api/device/ots/${encodeURIComponent(loadConfig().account.otsId)}/files?ext=${ext}`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/octet-stream' }, body: readFileSync(file), signal: AbortSignal.timeout(120000) });
|
|
124
|
+
if (!r.ok) throw new Error(`model upload: HTTP ${r.status}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async function pullModels(view) {
|
|
128
|
+
for (const url of modelsOf(view)) {
|
|
129
|
+
const file = homeFile(url.slice(1));
|
|
130
|
+
if (existsSync(file)) continue;
|
|
131
|
+
const r = await fetch(`${SEVENOTS_URL}${url}`, { signal: AbortSignal.timeout(120000) });
|
|
132
|
+
if (!r.ok) continue;
|
|
133
|
+
const buf = Buffer.from(await r.arrayBuffer());
|
|
134
|
+
// content-addressed: only keep it if it is what its name says
|
|
135
|
+
if (buf.toString('latin1', 0, 4) !== 'glTF' || createHash('sha256').update(buf).digest('hex').slice(0, 16) !== MODEL.exec(url)[1]) continue;
|
|
136
|
+
mkdirSync(homeFile('mods'), { recursive: true });
|
|
137
|
+
writeFileSync(file, buf);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** @returns {Promise<{action: 'off'|'same'|'push'|'pull'}>} */
|
|
142
|
+
export async function sync() {
|
|
143
|
+
const otsId = loadConfig().account?.otsId;
|
|
144
|
+
if (!signedIn() || !otsId) return { action: 'off' };
|
|
145
|
+
let state = readJson(homeFile('sync.json')) || {};
|
|
146
|
+
if (state.otsId !== otsId) state = { otsId };
|
|
147
|
+
const remote = await pull(otsId);
|
|
148
|
+
const cloud = syncView(remote.identity);
|
|
149
|
+
const local = localView();
|
|
150
|
+
const hc = hashOf(cloud);
|
|
151
|
+
const hl = hashOf(local);
|
|
152
|
+
const done = (action, extra = {}) => (writeJson(homeFile('sync.json'), { ...state, at: Date.now(), ...extra }), { action });
|
|
153
|
+
if (hc === hl) return done('same', { local: hl, cloud: hc });
|
|
154
|
+
const localChanged = hl !== state.local;
|
|
155
|
+
const cloudChanged = hc !== state.cloud;
|
|
156
|
+
if (!localChanged && !cloudChanged) return done('same'); // they differ only in how each side writes it
|
|
157
|
+
const push = localChanged && cloudChanged ? localMtime() > (remote.updatedAt || 0) : localChanged;
|
|
158
|
+
if (push) {
|
|
159
|
+
await pushModels(local);
|
|
160
|
+
const r = await api(`/ots/${encodeURIComponent(otsId)}/identity`, { method: 'PUT', body: { identity: local } });
|
|
161
|
+
return done('push', { local: hl, cloud: hashOf(syncView(r.identity)) });
|
|
162
|
+
}
|
|
163
|
+
await pullModels(cloud);
|
|
164
|
+
const file = homeFile('identity.json');
|
|
165
|
+
const raw = readJson(file) || {};
|
|
166
|
+
const { sheet = {}, ...personality } = cloud.personality;
|
|
167
|
+
writeJson(file, { ...raw, ...Object.fromEntries(SYNCED.map((k) => [k, cloud[k]])), personality: { ...raw.personality, ...personality }, look: { ...raw.look, character: cloud.look.character } });
|
|
168
|
+
const config = loadConfig();
|
|
169
|
+
config.character = { ...config.character, ...Object.fromEntries(SHEET.map((k) => [k, sheet[k] || ''])) };
|
|
170
|
+
saveConfig(config);
|
|
171
|
+
return done('pull', { local: hashOf(localView()), cloud: hc });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── community mods (server/platform/mods.mjs) ──
|
|
175
|
+
const MAX_SHARE = 20 * 1024 * 1024;
|
|
176
|
+
|
|
177
|
+
/** Downloads a mod's /mods/<hash> models into ~/.7ots/mods (checked against their hash). */
|
|
178
|
+
async function fetchModels(mod) {
|
|
179
|
+
const urls = [mod.model?.url].filter((u) => MODEL.test(u || ''));
|
|
180
|
+
for (const url of urls) {
|
|
181
|
+
const file = homeFile(url.slice(1));
|
|
182
|
+
if (existsSync(file)) continue;
|
|
183
|
+
const r = await fetch(`${SEVENOTS_URL}${url}`, { signal: AbortSignal.timeout(180000) });
|
|
184
|
+
if (!r.ok) throw Object.assign(new Error(`model: HTTP ${r.status}`), { status: 502 });
|
|
185
|
+
const buf = Buffer.from(await r.arrayBuffer());
|
|
186
|
+
if (buf.toString('latin1', 0, 4) !== 'glTF' || createHash('sha256').update(buf).digest('hex').slice(0, 16) !== MODEL.exec(url)[1]) throw Object.assign(new Error('model: bad file'), { status: 502 });
|
|
187
|
+
mkdirSync(homeFile('mods'), { recursive: true });
|
|
188
|
+
writeFileSync(file, buf);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** A data: glb (≤ 1.5 MB, inline in the mod) becomes a /mods/<hash> file here, so it can be shared as one. */
|
|
193
|
+
function inlineToFile(url) {
|
|
194
|
+
const m = /^data:model\/gltf-binary;base64,(.+)$/.exec(url || '');
|
|
195
|
+
if (!m) return url;
|
|
196
|
+
const buf = Buffer.from(m[1], 'base64');
|
|
197
|
+
const name = `${createHash('sha256').update(buf).digest('hex').slice(0, 16)}.glb`;
|
|
198
|
+
mkdirSync(homeFile('mods'), { recursive: true });
|
|
199
|
+
if (!existsSync(homeFile(`mods/${name}`))) writeFileSync(homeFile(`mods/${name}`), buf);
|
|
200
|
+
return `/mods/${name}`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export const community = {
|
|
204
|
+
async list({ sort = 'top', q = '' } = {}) {
|
|
205
|
+
const qs = `?sort=${encodeURIComponent(sort)}&q=${encodeURIComponent(q)}`;
|
|
206
|
+
if (signedIn()) return api(`/mods${qs}`);
|
|
207
|
+
const r = await fetch(`${SEVENOTS_URL}/api/mods${qs}`, { signal: AbortSignal.timeout(15000) });
|
|
208
|
+
if (!r.ok) throw Object.assign(new Error(`HTTP ${r.status}`), { status: r.status });
|
|
209
|
+
return r.json();
|
|
210
|
+
},
|
|
211
|
+
mine: () => api('/mods/mine'),
|
|
212
|
+
/** Counts the install (signed in), downloads its models and returns the mod to wear. */
|
|
213
|
+
async install(id) {
|
|
214
|
+
const { mod } = signedIn()
|
|
215
|
+
? await api(`/mods/${encodeURIComponent(id)}/install`, { method: 'POST', body: {} })
|
|
216
|
+
: await (await fetch(`${SEVENOTS_URL}/api/mods/${encodeURIComponent(id)}`, { signal: AbortSignal.timeout(15000) })).json();
|
|
217
|
+
if (!mod?.mod) throw Object.assign(new Error('not found'), { status: 404 });
|
|
218
|
+
await fetchModels(mod.mod);
|
|
219
|
+
return mod.mod;
|
|
220
|
+
},
|
|
221
|
+
rate: (id, stars) => api(`/mods/${encodeURIComponent(id)}/rate`, { method: 'POST', body: { stars } }),
|
|
222
|
+
report: (id, reason) => api(`/mods/${encodeURIComponent(id)}/report`, { method: 'POST', body: { reason } }),
|
|
223
|
+
remove: (id) => api(`/mods/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
|
224
|
+
/** Publishes a mod of this pet: first its models (as /mods/<hash> files on 7ots.com), then the mod. */
|
|
225
|
+
async share(mod) {
|
|
226
|
+
const out = { ...mod, ...(mod.model ? { model: { ...mod.model, url: inlineToFile(mod.model.url) } } : {}) };
|
|
227
|
+
const token = env().SEVENOTS_TOKEN;
|
|
228
|
+
for (const url of [out.model?.url].filter((u) => MODEL.test(u || ''))) {
|
|
229
|
+
const head = await fetch(`${SEVENOTS_URL}${url}`, { method: 'HEAD', signal: AbortSignal.timeout(15000) }).catch(() => null);
|
|
230
|
+
if (head?.ok) continue;
|
|
231
|
+
const file = homeFile(url.slice(1));
|
|
232
|
+
if (!existsSync(file)) throw Object.assign(new Error('model missing here'), { status: 404 });
|
|
233
|
+
if (statSync(file).size > MAX_SHARE) throw Object.assign(new Error('max 20 MB'), { status: 413 });
|
|
234
|
+
const r = await fetch(`${SEVENOTS_URL}/api/device/mods/files?ext=${MODEL.exec(url)[2]}`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/octet-stream' }, body: readFileSync(file), signal: AbortSignal.timeout(180000) });
|
|
235
|
+
if (!r.ok) throw Object.assign(new Error(`model upload: HTTP ${r.status}`), { status: r.status });
|
|
236
|
+
}
|
|
237
|
+
return api('/mods', { method: 'POST', body: { mod: out } });
|
|
238
|
+
},
|
|
239
|
+
};
|
package/cli/lib/assistant.mjs
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* integrations): what they bring back goes to the brain once more for the final reply.
|
|
16
16
|
* With access.vm and a cloud worker (vm.mjs) it can `offload` long work ("investiga…", "lee estas
|
|
17
17
|
* páginas…") to the ot's server: progress as `say` (≤1 every 20 s), result to memory/notification.
|
|
18
|
+
* `sites` (prowl.mjs, access.sites): Prowl.world usage guides for the domains in play, as untrusted reference.
|
|
18
19
|
*/
|
|
19
20
|
|
|
20
21
|
import { spawn } from 'node:child_process';
|
|
@@ -282,7 +283,7 @@ export function notify(title, body) {
|
|
|
282
283
|
* vm + vmKinds (offload to the ot's cloud worker; off unless explicitly true)
|
|
283
284
|
* cloud() the ot id of this computer (signed in to 7ots.com) or null · say(line) speaks later
|
|
284
285
|
*/
|
|
285
|
-
export function createAssistant({ brain, system, lang, name, onFire, watchers = null, procs = async () => '', access = () => ({}), extra = null, cloud = () => null, say = () => {}, log = () => {} }) {
|
|
286
|
+
export function createAssistant({ brain, system, lang, name, onFire, watchers = null, procs = async () => '', access = () => ({}), memoryTokens = () => 20000, extra = null, sites = null, cloud = () => null, say = () => {}, log = () => {} }) {
|
|
286
287
|
const may = (k) => access()[k] !== false;
|
|
287
288
|
const vmKinds = () => (access().vm === true && cloud() ? (access().vmKinds || []).filter(Boolean) : []);
|
|
288
289
|
let offloading = null;
|
|
@@ -324,6 +325,55 @@ export function createAssistant({ brain, system, lang, name, onFire, watchers =
|
|
|
324
325
|
const saveR = () => writeJson(remF, reminders);
|
|
325
326
|
const saveN = () => writeJson(memF, notes.slice(-200));
|
|
326
327
|
|
|
328
|
+
// Conversation memory: recent turns verbatim, older ones folded into a summary once past the token budget.
|
|
329
|
+
const convF = homeFile('conversation.json');
|
|
330
|
+
let conv = readJson(convF, null) || {};
|
|
331
|
+
conv = { summary: String(conv.summary || ''), turns: Array.isArray(conv.turns) ? conv.turns : [] };
|
|
332
|
+
const saveC = () => writeJson(convF, conv);
|
|
333
|
+
const tokensOf = (s) => Math.ceil(String(s).length / 4);
|
|
334
|
+
const convTokens = () => tokensOf(conv.summary) + conv.turns.reduce((n, t) => n + tokensOf(t.u) + tokensOf(t.a), 0);
|
|
335
|
+
let compacting = null;
|
|
336
|
+
function convBlock() {
|
|
337
|
+
if (!(memoryTokens() > 0) || (!conv.summary && !conv.turns.length)) return '';
|
|
338
|
+
const lines = conv.turns.map((t) => `Them: ${t.u}\nYou: ${t.a}`).join('\n');
|
|
339
|
+
return `Your conversation with them so far (context for follow-ups; do not repeat old answers):\n${conv.summary ? `[earlier, summarized] ${conv.summary}\n` : ''}${lines}`;
|
|
340
|
+
}
|
|
341
|
+
async function compact() {
|
|
342
|
+
const budget = memoryTokens();
|
|
343
|
+
if (!(budget > 0) || convTokens() <= budget || compacting) return;
|
|
344
|
+
// fold the oldest half (at least until it fits in ~70% of the budget) into the summary
|
|
345
|
+
let cut = Math.max(1, Math.floor(conv.turns.length / 2));
|
|
346
|
+
const keep = () => tokensOf(conv.summary) + conv.turns.slice(cut).reduce((n, t) => n + tokensOf(t.u) + tokensOf(t.a), 0);
|
|
347
|
+
while (cut < conv.turns.length - 1 && keep() > budget * 0.7) cut++;
|
|
348
|
+
const old = conv.turns.slice(0, cut);
|
|
349
|
+
const room = Math.max(300, Math.floor(budget * 0.25)) * 4;
|
|
350
|
+
compacting = (async () => {
|
|
351
|
+
let summary = '';
|
|
352
|
+
if (brain.kind !== 'lines') {
|
|
353
|
+
try {
|
|
354
|
+
const text = old.map((t) => `Them: ${t.u}\nYou: ${t.a}`).join('\n').slice(-room * 6);
|
|
355
|
+
summary = await brain.think({ purpose: 'compact', system: 'You compress chat history for a desktop pet assistant.', messages: [{ role: 'user', content: `Summary so far: ${conv.summary || '(none)'}\n\nNew turns:\n${text}\n\nWrite one updated summary (plain text, under ${Math.floor(room / 4)} tokens) keeping facts, decisions, open requests and the owner's preferences. No secrets.` }], timeoutMs: 60000 });
|
|
356
|
+
} catch (e) {
|
|
357
|
+
log(`compact: ${e.message}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
// brain off or failed: keep the gist of what they asked
|
|
361
|
+
if (!summary) summary = [conv.summary, ...old.map((t) => t.u.slice(0, 120))].filter(Boolean).join(' · ');
|
|
362
|
+
conv.summary = String(summary).trim().slice(-room);
|
|
363
|
+
conv.turns = conv.turns.slice(old.length);
|
|
364
|
+
saveC();
|
|
365
|
+
})().finally(() => {
|
|
366
|
+
compacting = null;
|
|
367
|
+
});
|
|
368
|
+
return compacting;
|
|
369
|
+
}
|
|
370
|
+
function remember(u, a) {
|
|
371
|
+
if (!(memoryTokens() > 0) || !a) return;
|
|
372
|
+
conv.turns.push({ u: String(u).slice(0, 4000), a: String(a).slice(0, 4000), at: Date.now() });
|
|
373
|
+
saveC();
|
|
374
|
+
compact().catch(() => {});
|
|
375
|
+
}
|
|
376
|
+
|
|
327
377
|
function fire(r) {
|
|
328
378
|
r.done = true;
|
|
329
379
|
const line = tr(r.lang || lang).fire(r);
|
|
@@ -382,6 +432,50 @@ export function createAssistant({ brain, system, lang, name, onFire, watchers =
|
|
|
382
432
|
return out;
|
|
383
433
|
}
|
|
384
434
|
|
|
435
|
+
/** Free text → { reply, actions }. Never throws. */
|
|
436
|
+
async function answer(text) {
|
|
437
|
+
const now = Date.now();
|
|
438
|
+
const detected = langOf(text);
|
|
439
|
+
const said = detected || lang;
|
|
440
|
+
L = tr(said);
|
|
441
|
+
const local = parseLocal(text, now);
|
|
442
|
+
let ai = null;
|
|
443
|
+
if (brain.kind !== 'lines') {
|
|
444
|
+
try {
|
|
445
|
+
// Prowl.world guides for the sites in play (access.sites; untrusted reference), fetched alongside the processes
|
|
446
|
+
const [running, siteRef] = await Promise.all([may('processes') ? procs().catch(() => '') : '', sites ? sites.prompt(text).catch(() => '') : '']);
|
|
447
|
+
const more = [extra?.prompt() || '', siteRef, convBlock()].filter(Boolean).join('\n');
|
|
448
|
+
const raw = await brain.think({ purpose: 'assistant', system: system(detected), messages: [{ role: 'user', content: aiPrompt(text, { lang: detected, notes: may('memory') ? notes.slice(-30) : [], reminders, watchers: watchers?.list || [], procs: running, more, vmKinds: vmKinds(), now }) }], timeoutMs: 45000, raw: true });
|
|
449
|
+
ai = parseAi(raw, now, extra, vmKinds());
|
|
450
|
+
} catch (e) {
|
|
451
|
+
log(`assistant: ${e.message}`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
if (ai && (ai.actions.length || !local.length)) {
|
|
455
|
+
const done = run(ai.actions);
|
|
456
|
+
// fetched things (logs, email, agents…): the brain reads them and answers once more
|
|
457
|
+
const fetched = [];
|
|
458
|
+
for (const a of ai.actions.filter((x) => x.extra).slice(0, 3)) {
|
|
459
|
+
const r = await extra.run(a, detected || lang);
|
|
460
|
+
if (r.data) fetched.push(r.data);
|
|
461
|
+
}
|
|
462
|
+
if (fetched.length) {
|
|
463
|
+
let said = '';
|
|
464
|
+
try {
|
|
465
|
+
said = await brain.think({ purpose: 'assistant', system: system(detected), messages: [{ role: 'user', content: `${convBlock() ? `${convBlock()}\n\n` : ''}Your human wrote to you: «${String(text).slice(0, 800)}»\nHere is what you fetched for them:\n${fetched.join('\n\n').slice(0, 9000)}\n\nNow answer them in 1-3 short sentences, in character, no JSON, no markdown. Never quote secrets.` }], timeoutMs: 45000 });
|
|
466
|
+
} catch (e) {
|
|
467
|
+
log(`assistant: ${e.message}`);
|
|
468
|
+
}
|
|
469
|
+
return { reply: [ai.reply, said].filter(Boolean).join(' ') || done.join(' ') || L.help, actions: ai.actions };
|
|
470
|
+
}
|
|
471
|
+
// a watch the AI asked for but that could not be set (unknown coin…): say so instead of a cheerful reply
|
|
472
|
+
const failed = ai.actions.some((a) => a.type.startsWith('watch_')) && !done.length;
|
|
473
|
+
return { reply: failed ? L.help : ai.reply || done.join(' ') || L.help, actions: ai.actions };
|
|
474
|
+
}
|
|
475
|
+
const done = run(local);
|
|
476
|
+
return { reply: done.join(' ') || L.help, actions: local };
|
|
477
|
+
}
|
|
478
|
+
|
|
385
479
|
return {
|
|
386
480
|
get reminders() {
|
|
387
481
|
return reminders.slice();
|
|
@@ -395,43 +489,18 @@ export function createAssistant({ brain, system, lang, name, onFire, watchers =
|
|
|
395
489
|
},
|
|
396
490
|
/** Free text → { reply, actions }. Never throws. */
|
|
397
491
|
async ask(text) {
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
if (ai && (ai.actions.length || !local.length)) {
|
|
413
|
-
const done = run(ai.actions);
|
|
414
|
-
// fetched things (logs, email, agents…): the brain reads them and answers once more
|
|
415
|
-
const fetched = [];
|
|
416
|
-
for (const a of ai.actions.filter((x) => x.extra).slice(0, 3)) {
|
|
417
|
-
const r = await extra.run(a, detected || lang);
|
|
418
|
-
if (r.data) fetched.push(r.data);
|
|
419
|
-
}
|
|
420
|
-
if (fetched.length) {
|
|
421
|
-
let said = '';
|
|
422
|
-
try {
|
|
423
|
-
said = await brain.think({ purpose: 'assistant', system: system(detected), messages: [{ role: 'user', content: `Your human wrote to you: «${String(text).slice(0, 800)}»\nHere is what you fetched for them:\n${fetched.join('\n\n').slice(0, 9000)}\n\nNow answer them in 1-3 short sentences, in character, no JSON, no markdown. Never quote secrets.` }], timeoutMs: 45000 });
|
|
424
|
-
} catch (e) {
|
|
425
|
-
log(`assistant: ${e.message}`);
|
|
426
|
-
}
|
|
427
|
-
return { reply: [ai.reply, said].filter(Boolean).join(' ') || done.join(' ') || L.help, actions: ai.actions };
|
|
428
|
-
}
|
|
429
|
-
// a watch the AI asked for but that could not be set (unknown coin…): say so instead of a cheerful reply
|
|
430
|
-
const failed = ai.actions.some((a) => a.type.startsWith('watch_')) && !done.length;
|
|
431
|
-
return { reply: failed ? L.help : ai.reply || done.join(' ') || L.help, actions: ai.actions };
|
|
432
|
-
}
|
|
433
|
-
const done = run(local);
|
|
434
|
-
return { reply: done.join(' ') || L.help, actions: local };
|
|
492
|
+
const r = await answer(text);
|
|
493
|
+
remember(text, r.reply);
|
|
494
|
+
return r;
|
|
495
|
+
},
|
|
496
|
+
/** What it remembers of the conversation: { tokens, budget, summary, turns }. */
|
|
497
|
+
get conversation() {
|
|
498
|
+
return { tokens: convTokens(), budget: memoryTokens(), summary: conv.summary, turns: conv.turns.slice() };
|
|
499
|
+
},
|
|
500
|
+
forgetConversation() {
|
|
501
|
+
conv = { summary: '', turns: [] };
|
|
502
|
+
saveC();
|
|
503
|
+
return true;
|
|
435
504
|
},
|
|
436
505
|
/** Forget one note (by its timestamp, stable across the list) or all of them. */
|
|
437
506
|
forget(at) {
|
package/cli/lib/brain.mjs
CHANGED
|
@@ -11,14 +11,25 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { spawn, spawnSync } from 'node:child_process';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
import { delimiter, dirname, join } from 'node:path';
|
|
14
16
|
import { identityPrompt } from '../../src/identity/schema.js';
|
|
15
17
|
import { createLLM } from '../../server/llm.mjs';
|
|
16
18
|
import { env, loadConfig, ORQUESTA_URL } from './config.mjs';
|
|
17
19
|
import { characterPrompt, integrationsPrompt } from './integrations.mjs';
|
|
18
20
|
import { ensureHome, PET_PORT } from './paths.mjs';
|
|
19
21
|
|
|
22
|
+
// Started from a desktop launcher or autostart, the daemon gets a bare PATH (/usr/bin:/bin) and can't find
|
|
23
|
+
// CLIs installed per user (claude lives in ~/.local/bin): add the usual places before looking for them.
|
|
24
|
+
{
|
|
25
|
+
const home = homedir();
|
|
26
|
+
const extra = [join(home, '.local', 'bin'), join(home, '.npm-global', 'bin'), join(home, '.bun', 'bin'), join(home, '.cargo', 'bin'), join(home, '.volta', 'bin'), dirname(process.execPath), '/usr/local/bin', '/opt/homebrew/bin', '/snap/bin'];
|
|
27
|
+
const have = (process.env.PATH || '').split(delimiter).filter(Boolean);
|
|
28
|
+
process.env.PATH = [...have, ...extra.filter((d) => !have.includes(d))].join(delimiter);
|
|
29
|
+
}
|
|
30
|
+
|
|
20
31
|
export const AI_CLIS = {
|
|
21
|
-
claude: { bin: 'claude', json: true, args: (p, m, files) => ['-p', p, '--output-format', 'json', ...(m ? ['--model', m] : []), ...(files ? ['--allowedTools', 'Read'] : [])], vision: true },
|
|
32
|
+
claude: { bin: 'claude', json: true, args: (p, m, files) => ['-p', p, '--output-format', 'json', ...(m ? ['--model', m] : []), ...(files ? ['--allowedTools', 'Read'] : [])], vision: true, minTimeoutMs: 120000 }, // claude -p can take ~30 s just to start (hooks, plugins, MCP servers)
|
|
22
33
|
codex: { bin: 'codex', args: (p, m) => ['exec', '--skip-git-repo-check', ...(m ? ['-m', m] : []), p] },
|
|
23
34
|
gemini: { bin: 'gemini', args: (p, m) => ['-p', p, ...(m ? ['-m', m] : [])] },
|
|
24
35
|
ollama: { bin: 'ollama', args: (p, m) => ['run', m || 'llama3.2', p] },
|
|
@@ -70,7 +81,7 @@ function runCli({ cli, model, command }, prompt, timeoutMs, files) {
|
|
|
70
81
|
const timer = setTimeout(() => {
|
|
71
82
|
child.kill('SIGTERM');
|
|
72
83
|
reject(new Error('timeout'));
|
|
73
|
-
}, timeoutMs);
|
|
84
|
+
}, Math.max(timeoutMs, spec.minTimeoutMs || 0));
|
|
74
85
|
child.stdout.on('data', (d) => (out += d).length > 20000 && child.kill('SIGTERM'));
|
|
75
86
|
child.on('error', (e) => {
|
|
76
87
|
clearTimeout(timer);
|
package/cli/lib/config.mjs
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* memory: true, it keeps notes about you and uses them when answering
|
|
20
20
|
* open: true, it may open sites in your browser (reminders, "open my mail")
|
|
21
21
|
* notify: true, desktop notifications when a reminder fires
|
|
22
|
+
* sites: true, for sites you mention, it reads their usage guide on Prowl.world (only the domain is sent; prowl.mjs)
|
|
22
23
|
* screen: false, it looks at the active window and, every screen.every minutes, a screenshot (screen.mjs)
|
|
23
24
|
* processes: false, it sees which programs run (name, cpu, memory) and can wait for one to finish
|
|
24
25
|
* logs: false, it may read the logs of running processes (journal, pm2, docker, a log file) when asked
|
|
@@ -48,8 +49,10 @@ export const CONFIG_DEFAULTS = Object.freeze({
|
|
|
48
49
|
brain: { kind: 'lines' },
|
|
49
50
|
voice: { kind: 'browser' },
|
|
50
51
|
pet: { mode: 'ask', annoy: 2 },
|
|
51
|
-
access: { agent: true, memory: true, open: true, notify: true, screen: false, processes: false, logs: false, inbox: false, vm: false, vmKinds: ['research', 'browse', 'long'] },
|
|
52
|
+
access: { agent: true, memory: true, open: true, notify: true, sites: true, screen: false, processes: false, logs: false, inbox: false, vm: false, vmKinds: ['research', 'browse', 'long'] },
|
|
52
53
|
screen: { every: 5 },
|
|
54
|
+
// conversation memory: recent turns up to `tokens` (≈chars/4), older ones compacted into a summary; 0 = off
|
|
55
|
+
memory: { tokens: 20000 },
|
|
53
56
|
integrations: { orquesta: { on: false }, apumail: { on: false, inbox: '' }, custom: [] },
|
|
54
57
|
character: {},
|
|
55
58
|
});
|
|
@@ -65,6 +68,7 @@ export function loadConfig() {
|
|
|
65
68
|
pet: { ...CONFIG_DEFAULTS.pet, ...c.pet },
|
|
66
69
|
access: { ...CONFIG_DEFAULTS.access, ...c.access },
|
|
67
70
|
screen: { ...CONFIG_DEFAULTS.screen, ...c.screen },
|
|
71
|
+
memory: { ...CONFIG_DEFAULTS.memory, ...c.memory },
|
|
68
72
|
integrations: {
|
|
69
73
|
orquesta: { ...CONFIG_DEFAULTS.integrations.orquesta, ...c.integrations?.orquesta },
|
|
70
74
|
apumail: { ...CONFIG_DEFAULTS.integrations.apumail, ...c.integrations?.apumail },
|
package/cli/lib/inferences.mjs
CHANGED
|
@@ -3,20 +3,44 @@
|
|
|
3
3
|
* the data it used) and what came back. Kept in ~/.7ots/inferences.jsonl (0600, last 300) and shown on /settings.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { appendFileSync, existsSync, readFileSync, writeFileSync, chmodSync } from 'node:fs';
|
|
7
|
-
import {
|
|
6
|
+
import { appendFileSync, existsSync, readFileSync, readdirSync, writeFileSync, chmodSync } from 'node:fs';
|
|
7
|
+
import { basename, dirname, join } from 'node:path';
|
|
8
|
+
import { HOME, homeFile, readJson } from './paths.mjs';
|
|
8
9
|
|
|
9
10
|
const MAX = 300;
|
|
10
11
|
const FILE = () => homeFile('inferences.jsonl');
|
|
11
12
|
const cut = (s, n) => (String(s ?? '').length > n ? `${String(s).slice(0, n)}…` : String(s ?? ''));
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
let rows = [];
|
|
14
|
+
const readRows = (file) => {
|
|
15
15
|
try {
|
|
16
|
-
|
|
16
|
+
return existsSync(file) ? readFileSync(file, 'utf8').trim().split('\n').filter(Boolean).slice(-MAX).map((l) => JSON.parse(l)) : [];
|
|
17
17
|
} catch {
|
|
18
|
-
|
|
18
|
+
return [];
|
|
19
19
|
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The history of the other ots on this computer: each visitor runs its own daemon with its home in
|
|
24
|
+
* ~/.7ots/ots/<id>/ (SEVENOTS_HOME), so their rows are read from there, tagged with who made them.
|
|
25
|
+
*/
|
|
26
|
+
export function otherInferences() {
|
|
27
|
+
// a visiting ot lives in <desktop home>/ots/<id>: it sees the host and its fellow visitors too
|
|
28
|
+
const companion = basename(dirname(HOME)) === 'ots';
|
|
29
|
+
const root = companion ? dirname(dirname(HOME)) : HOME;
|
|
30
|
+
const dir = join(root, 'ots');
|
|
31
|
+
let ids = [];
|
|
32
|
+
try {
|
|
33
|
+
ids = readdirSync(dir).filter((d) => /^ots_[a-z0-9]+$/.test(d) && join(dir, d) !== HOME);
|
|
34
|
+
} catch {}
|
|
35
|
+
const host = companion ? [{ home: root, id: readJson(join(root, 'config.json'))?.account?.otsId || 'local' }] : [];
|
|
36
|
+
return [...host, ...ids.map((id) => ({ home: join(dir, id), id }))].flatMap(({ home, id }) => {
|
|
37
|
+
const name = readJson(join(home, 'identity.json'))?.name || id;
|
|
38
|
+
return readRows(join(home, 'inferences.jsonl')).map((r) => ({ ...r, ot: { id, name } }));
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createInferenceLog() {
|
|
43
|
+
let rows = readRows(FILE());
|
|
20
44
|
let since = 0;
|
|
21
45
|
const add = (row) => {
|
|
22
46
|
rows.push(row);
|