@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.
Files changed (61) hide show
  1. package/README.md +3 -3
  2. package/cli/7ots.mjs +13 -1
  3. package/cli/lib/account.mjs +152 -2
  4. package/cli/lib/assistant.mjs +107 -38
  5. package/cli/lib/brain.mjs +13 -2
  6. package/cli/lib/config.mjs +5 -1
  7. package/cli/lib/inferences.mjs +30 -6
  8. package/cli/lib/integrations.mjs +69 -5
  9. package/cli/lib/orquesta.mjs +44 -1
  10. package/cli/lib/pet-server.mjs +352 -11
  11. package/cli/lib/prompts.mjs +31 -17
  12. package/cli/lib/prowl.mjs +110 -0
  13. package/cli/pet/electron/director.cjs +37 -5
  14. package/cli/pet/electron/main.cjs +92 -10
  15. package/cli/pet/pet.html +149 -10
  16. package/cli/pet/settings.html +549 -43
  17. package/dist/7ots.esm.js +216 -137
  18. package/dist/7ots.esm.js.map +4 -4
  19. package/dist/7ots.iife.js +216 -137
  20. package/dist/7ots.iife.js.map +4 -4
  21. package/llms.txt +1 -1
  22. package/package.json +17 -14
  23. package/server/channels/agent.mjs +7 -1
  24. package/server/channels/index.mjs +3 -2
  25. package/server/platform/connect.mjs +4 -1
  26. package/server/platform/db.mjs +14 -1
  27. package/server/platform/device.mjs +101 -4
  28. package/server/platform/mods.mjs +222 -0
  29. package/server/platform/orquesta.mjs +78 -14
  30. package/server/platform/profile.mjs +161 -0
  31. package/server/platform/routes.mjs +39 -33
  32. package/server/platform/runtime.mjs +5 -3
  33. package/server/platform/store.mjs +1 -1
  34. package/server/platform/vm.mjs +688 -17
  35. package/server/server.mjs +12 -2
  36. package/server/tts.mjs +17 -4
  37. package/src/avatar/three.js +3 -2
  38. package/src/character/AvatarEditor.js +95 -7
  39. package/src/character/Character.js +116 -17
  40. package/src/character/TradingCard.js +226 -0
  41. package/src/character/mods/index.js +4 -0
  42. package/src/character/mods/orc.js +64 -0
  43. package/src/character/mods.js +251 -0
  44. package/src/character/more/acc-head.js +213 -0
  45. package/src/character/more/acc-rest.js +342 -0
  46. package/src/character/more/brows.js +39 -0
  47. package/src/character/more/cheeks.js +78 -0
  48. package/src/character/more/eyes.js +288 -0
  49. package/src/character/more/mouths.js +259 -0
  50. package/src/character/more/patterns.js +120 -0
  51. package/src/character/parts.js +100 -0
  52. package/src/character/skin.js +166 -1
  53. package/src/character/styles.js +1285 -9
  54. package/src/character3d/Character3D.js +253 -37
  55. package/src/character3d/index.js +7 -1
  56. package/src/character3d/model.js +367 -0
  57. package/src/i18n/messages/character.js +37 -22
  58. package/src/i18n/messages/platform.js +33 -0
  59. package/src/identity/random.js +17 -3
  60. package/src/identity/schema.js +10 -0
  61. package/src/index.js +1 -0
@@ -32,7 +32,7 @@ import { createHash, randomBytes } from 'node:crypto';
32
32
  import { resolveLocale, translator } from '../../src/i18n/index.js';
33
33
  import { identityPrompt } from '../identity.mjs';
34
34
  import { i18nError } from '../i18n.mjs';
35
- import { TASK_FINAL, addTask, getIntegration, getPlatformState, getTask, openTasks, setIntegration, setPlatformState, tasksLastDay, updateTask } from './store.mjs';
35
+ import { TASK_FINAL, addTask, getIntegration, getPlatformState, getTask, listOts, openTasks, setIntegration, setPlatformState, tasksLastDay, updateTask } from './store.mjs';
36
36
 
37
37
  const penv = process.env;
38
38
  const ORQ = (penv.ORQUESTA_URL || 'https://getorquesta.com').replace(/\/+$/, '');
@@ -201,16 +201,21 @@ async function bearer(accountId) {
201
201
  return s.access;
202
202
  }
203
203
 
204
- /** Orquesta API as the account (a 401 with OAuth renews the token once). */
205
- async function orq(accountId, method, path, body) {
206
- let r = await http(ORQ + path, { method, body, headers: { Authorization: `Bearer ${await bearer(accountId)}` } });
204
+ /**
205
+ * Orquesta API as the account (a 401 with OAuth renews the token once). With `ots`, the request acts
206
+ * for that ot: Orquesta narrows it to the projects linked to the ot (project → Identity → Connected
207
+ * ots) and to the scopes of each link (read / run / deploy) — never more than the account itself.
208
+ */
209
+ async function orq(accountId, method, path, body, timeoutMs, ots = null) {
210
+ const as = ots ? { 'X-Orquesta-External-Agent': `7ots/${ots.id}` } : {};
211
+ let r = await http(ORQ + path, { method, body, timeoutMs, headers: { Authorization: `Bearer ${await bearer(accountId)}`, ...as } });
207
212
  if (r.status === 401) {
208
213
  const s = getIntegration(accountId, 'orquesta');
209
214
  if (s?.kind !== 'oauth') {
210
215
  if (s) setIntegration(accountId, 'orquesta', { ...s, error: 'refused' });
211
216
  throw i18nError(409, 'platform.orquesta.reconnect');
212
217
  }
213
- r = await http(ORQ + path, { method, body, headers: { Authorization: `Bearer ${await refresh(accountId)}` } });
218
+ r = await http(ORQ + path, { method, body, timeoutMs, headers: { Authorization: `Bearer ${await refresh(accountId)}`, ...as } });
214
219
  if (r.status === 401) {
215
220
  setIntegration(accountId, 'orquesta', { ...getIntegration(accountId, 'orquesta'), error: 'refused' });
216
221
  throw i18nError(409, 'platform.orquesta.reconnect');
@@ -219,12 +224,69 @@ async function orq(accountId, method, path, body) {
219
224
  return r;
220
225
  }
221
226
 
227
+ // ───────────────────────────── the account's own VMs (vm.mjs, provider 'orquesta_user') ─────────────────────────────
228
+
229
+ /**
230
+ * Whether the connected account can create servers in its own Orquesta org. Orquesta's infrastructure
231
+ * routes (POST /api/v1/projects, /api/v1/projects/:id/vm, /api/v1/vms) accept only org API keys (oak_)
232
+ * with projects:write + vms:write, never OAuth tokens; a key's scopes are only known when Orquesta
233
+ * answers (403 → 'scopes').
234
+ */
235
+ export function orquestaVmAccess(accountId) {
236
+ const s = getIntegration(accountId, 'orquesta');
237
+ if (!s) return { connected: false, usable: false, reason: 'notConnected' };
238
+ if (s.error) return { connected: false, usable: false, reason: 'reconnect' };
239
+ if (s.kind !== 'key') return { connected: true, usable: false, reason: 'needsKey' };
240
+ return { connected: true, usable: true, reason: null };
241
+ }
242
+
243
+ /**
244
+ * Orquesta's infrastructure API as the account → { ok, status, data }. The account's credential stays
245
+ * sealed here and only ever goes to ORQUESTA_URL; vm.mjs never sees it.
246
+ */
247
+ export async function orquestaInfra(accountId, method, path, body, timeoutMs = 20_000) {
248
+ if (!/^\/api\/v1\/(projects|vms)(\/|$)/.test(path)) throw new Error('orquestaInfra: path not allowed');
249
+ return orq(accountId, method, path, body, timeoutMs);
250
+ }
251
+
222
252
  export async function listProjects(accountId) {
223
253
  const r = await orq(accountId, 'GET', '/api/v1/projects');
224
254
  if (!r.ok) throw i18nError(424, 'platform.orquesta.failed', { status: r.status });
225
255
  return (r.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, lastSeen: p.agentLastSeen || null }));
226
256
  }
227
257
 
258
+ /**
259
+ * Tells Orquesta which ots this account has (name + face), so a project admin can connect them to
260
+ * projects there. An ot with no link yet is seeded with the project chosen in its 7ots panel (read,
261
+ * plus run when tasks are on) — Orquesta ignores the seed once the ot has any link, and never seeds
262
+ * `deploy`. Re-sent when the list changes or hourly; failures only log (older Orquesta: 404).
263
+ */
264
+ const registered = new Map();
265
+ export async function registerOts(accountId, { force = false } = {}) {
266
+ const ots = listOts(accountId).filter((o) => /^[A-Za-z0-9._:-]{1,128}$/.test(o.id));
267
+ const agents = ots.map((o) => {
268
+ const cfg = otsOrquesta(o);
269
+ return { id: o.id, name: String(o.name || '').slice(0, 80), ...(cfg.project ? { seed: { projectId: cfg.project, scopes: cfg.tasks ? ['read', 'run'] : ['read'] } } : {}) };
270
+ });
271
+ const sig = JSON.stringify(agents);
272
+ const prev = registered.get(accountId);
273
+ if (!force && prev && prev.sig === sig && Date.now() - prev.at < 3600_000) return prev.agents;
274
+ const r = await orq(accountId, 'PUT', '/api/v1/external-agents', { provider: '7ots', agents });
275
+ if (!r.ok) {
276
+ console.warn(`[7ots] orquesta: registering ots → ${r.status}`);
277
+ return null;
278
+ }
279
+ registered.set(accountId, { sig, at: Date.now(), agents: r.data.agents || [] });
280
+ return r.data.agents || [];
281
+ }
282
+
283
+ /** The projects Orquesta lets this ot reach, with the scopes of each link ([] when unknown). */
284
+ export async function otLinks(ots) {
285
+ const agents = await registerOts(ots.accountId).catch(() => null);
286
+ const me = (agents || []).find((a) => a.id === ots.id);
287
+ return (me?.projects || []).map((p) => ({ id: String(p.projectId), name: String(p.projectName || p.projectId), scopes: (p.scopes || []).map(String) }));
288
+ }
289
+
228
290
  // ───────────────────────────── per-ot settings ─────────────────────────────
229
291
 
230
292
  /** Panel settings of an ot. */
@@ -298,19 +360,19 @@ function promptContent(ots, { task, who, channel }) {
298
360
  ].join('\n');
299
361
  }
300
362
 
301
- async function fetchPrompt(accountId, id) {
302
- const r = await orq(accountId, 'GET', `/api/v1/prompts/${encodeURIComponent(id)}`);
363
+ async function fetchPrompt(accountId, id, ots) {
364
+ const r = await orq(accountId, 'GET', `/api/v1/prompts/${encodeURIComponent(id)}`, undefined, undefined, ots);
303
365
  if (!r.ok) throw i18nError(424, 'platform.orquesta.failed', { status: r.status });
304
366
  return r.data.prompt || {};
305
367
  }
306
368
 
307
369
  /** Text the agent produced: `result` when Orquesta has it, else the 'output' log lines. */
308
- async function outputOf(accountId, id, prompt) {
370
+ async function outputOf(accountId, id, prompt, ots) {
309
371
  if (typeof prompt.result === 'string' && prompt.result.trim()) return prompt.result.trim().slice(0, 20000);
310
372
  const out = [];
311
373
  let after = null;
312
374
  for (let page = 0; page < 5; page++) {
313
- const r = await orq(accountId, 'GET', `/api/v1/logs?${new URLSearchParams({ promptId: id, ...(after != null ? { afterSequence: String(after) } : {}) })}`);
375
+ const r = await orq(accountId, 'GET', `/api/v1/logs?${new URLSearchParams({ promptId: id, ...(after != null ? { afterSequence: String(after) } : {}) })}`, undefined, undefined, ots);
314
376
  if (!r.ok) break;
315
377
  const logs = r.data.logs || [];
316
378
  for (const l of logs) if (l.category === 'output' && l.message) out.push(String(l.message));
@@ -323,10 +385,10 @@ async function outputOf(accountId, id, prompt) {
323
385
  /** Refreshes a stored task from Orquesta (only while it isn't finished). */
324
386
  async function refreshTask(ots, task) {
325
387
  if (TASK_FINAL.includes(task.status)) return task;
326
- const p = await fetchPrompt(ots.accountId, task.id);
388
+ const p = await fetchPrompt(ots.accountId, task.id, ots);
327
389
  const status = ['pending', 'running', 'completed', 'failed', 'cancelled'].includes(p.status) ? p.status : task.status;
328
390
  const patch = { status, error: p.error ? String(p.error) : undefined };
329
- if (TASK_FINAL.includes(status)) patch.result = await outputOf(ots.accountId, task.id, p).catch(() => '');
391
+ if (TASK_FINAL.includes(status)) patch.result = await outputOf(ots.accountId, task.id, p, ots).catch(() => '');
330
392
  updateTask(task.id, patch);
331
393
  return getTask(ots.id, task.id);
332
394
  }
@@ -362,17 +424,19 @@ export async function runTask(ots, { task, who, channel }) {
362
424
  if (!TASK_FINAL.includes(cur.status) && Date.now() - cur.createdAt < 24 * 3600_000) return { ok: false, error: t('platform.orquesta.busy', { id: cur.id }) };
363
425
  }
364
426
  offline = (await listProjects(ots.accountId).catch(() => [])).some((p) => p.id === cfg.project && !p.online);
427
+ await registerOts(ots.accountId).catch(() => null);
365
428
  const r = await orq(ots.accountId, 'POST', '/api/v1/prompts', {
366
429
  projectId: cfg.project,
367
430
  content: promptContent(ots, { task: text, who, channel }),
368
431
  context: `7ots ot "${ots.name}" · ${channel} · ${who}`.slice(0, 500),
369
432
  tags: ['7ots', channel],
370
433
  source: 'api',
371
- });
434
+ }, undefined, ots);
372
435
  if (!r.ok) {
373
436
  const code = String(r.data.code || r.data.error || '');
374
437
  if (/AGENT_NOT_CONFIGURED/i.test(code)) return { ok: false, error: t('platform.orquesta.noAgent', { url: projectUrl(cfg.project) }), project_url: projectUrl(cfg.project) };
375
- if (r.status === 404) return { ok: false, error: t('platform.orquesta.noProject') };
438
+ // the ot acts with its own links: not connected to that project, or connected without `run`
439
+ if (r.status === 404 || /EXTERNAL_AGENT_NOT_ALLOWED/.test(code)) return { ok: false, error: t('platform.orquesta.notLinked', { url: projectUrl(cfg.project) }), project_url: projectUrl(cfg.project) };
376
440
  if (r.status === 429) return { ok: false, error: t('platform.orquesta.rateLimited') };
377
441
  return { ok: false, error: t('platform.orquesta.submitFailed', { status: r.status }) };
378
442
  }
@@ -414,7 +478,7 @@ export async function cancelTask(ots, id) {
414
478
  const row = getTask(ots.id, id);
415
479
  if (!row) return null;
416
480
  if (TASK_FINAL.includes(row.status)) return row;
417
- const r = await orq(ots.accountId, 'POST', `/api/v1/prompts/${encodeURIComponent(row.id)}/cancel`);
481
+ const r = await orq(ots.accountId, 'POST', `/api/v1/prompts/${encodeURIComponent(row.id)}/cancel`, undefined, undefined, ots);
418
482
  if (!r.ok) throw i18nError(424, 'platform.orquesta.failed', { status: r.status });
419
483
  updateTask(row.id, { status: r.data.status || 'cancelled' });
420
484
  return getTask(ots.id, row.id);
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Perfil público de un ot: lo que ve una persona al abrir /api/o/<id>/identity en el navegador.
3
+ *
4
+ * Portada con el personaje (SVG renderizado aquí, así la primera pintura y la vista previa ya lo
5
+ * traen; luego el navegador lo anima), quién es, sus rasgos únicos (los mismos de traits.json) y la
6
+ * tarjeta de contacto <ots-identity> con sus acciones (escuchar, correo, @handle, llamada, vCard).
7
+ */
8
+ import { renderCharacter } from '../../src/character/Character.js';
9
+
10
+ const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
11
+ const hex = (v, def) => (/^#[0-9a-f]{3,8}$/i.test(v || '') ? v : def);
12
+
13
+ const L = {
14
+ en: { about: 'About', tone: 'Tone', speaks: 'Speaks', traits: 'Unique traits', traitsSub: 'No two ots share this face. The fingerprint is what makes it one of a kind.', print: 'Fingerprint', contact: 'Contact', json: 'JSON', make: 'Create your own ot', unique: 'One of a kind', soon: 'NFT soon', noBio: '{name} hasn’t written a bio yet.' },
15
+ es: { about: 'Sobre mí', tone: 'Tono', speaks: 'Habla', traits: 'Rasgos únicos', traitsSub: 'No hay dos ots con esta cara. La huella es lo que lo hace único.', print: 'Huella', contact: 'Contacto', json: 'JSON', make: 'Crea tu propio ot', unique: 'Único', soon: 'NFT pronto', noBio: '{name} todavía no escribió su bio.' },
16
+ pt: { about: 'Sobre', tone: 'Tom', speaks: 'Fala', traits: 'Traços únicos', traitsSub: 'Não existem dois ots com este rosto. A impressão é o que o torna único.', print: 'Impressão', contact: 'Contato', json: 'JSON', make: 'Crie seu próprio ot', unique: 'Único', soon: 'NFT em breve', noBio: '{name} ainda não escreveu a bio.' },
17
+ };
18
+
19
+ const LANG_NAMES = { es: 'Español', en: 'English', pt: 'Português', fr: 'Français', de: 'Deutsch', it: 'Italiano', ja: '日本語', zh: '中文', ko: '한국어', ru: 'Русский', ar: 'العربية', hi: 'हिन्दी', nl: 'Nederlands', pl: 'Polski', tr: 'Türkçe', et: 'Eesti' };
20
+
21
+ /** 32 hex → código de barras de colores: la huella se ve, no solo se lee. */
22
+ function printBars(print) {
23
+ if (!print) return '';
24
+ const bars = print.match(/../g).map((b, i) => {
25
+ const n = parseInt(b, 16);
26
+ return `<rect x="${i * 16 + 2}" y="${n % 3 === 0 ? 4 : 0}" width="${4 + (n % 8)}" height="${n % 3 === 0 ? 28 : 32}" rx="1.5" fill="hsl(${Math.round((n / 255) * 360)} 80% 65%)"/>`;
27
+ });
28
+ return `<svg class="bars" viewBox="0 0 256 32" preserveAspectRatio="none" aria-hidden="true">${bars.join('')}</svg>`;
29
+ }
30
+
31
+ /**
32
+ * card: publicIdentity(...) · meta: traitsMeta(...) · ep: base /api/o/<id> · locale: idioma del visitante.
33
+ */
34
+ export function profilePage(card, meta, ep, locale = 'en') {
35
+ const l = L[String(locale).slice(0, 2)] || L.en;
36
+ const name = esc(card.name);
37
+ const desc = esc(card.tagline || card.role || '');
38
+ const c1 = hex(card.look?.color, '#6d5dfc');
39
+ const c2 = hex(card.look?.accent, '#22d3ee');
40
+ const look = card.look || {};
41
+ let portrait = `<span class="initial">${esc((look.emoji || card.name.slice(0, 1)).toUpperCase())}</span>`;
42
+ if (look.kind === 'image' && look.image) portrait = `<img src="${esc(look.image)}" alt="" referrerpolicy="no-referrer">`;
43
+ else if (look.character && Object.keys(look.character).length) {
44
+ try {
45
+ portrait = renderCharacter(look.character, {}, { title: card.name });
46
+ } catch {}
47
+ }
48
+ const langs = [...new Set([card.language, ...(card.languages || [])].filter(Boolean))];
49
+ const traits = card.personality?.traits || [];
50
+ const handle = meta?.handle;
51
+ const attrs = (meta?.attributes || []).filter((a) => a.trait_type !== 'Kind');
52
+ const tile = (a) => {
53
+ const v = String(a.value);
54
+ const sw = /^#[0-9a-f]{3,8}$/i.test(v) ? `<i class="sw" style="background:${v}"></i>` : '';
55
+ return `<div class="tile"><dt>${esc(a.trait_type)}</dt><dd>${sw}${esc(sw ? v.toUpperCase() : v.replace(/[-_]/g, ' '))}</dd></div>`;
56
+ };
57
+ const jsonUrl = `${esc(ep)}/identity?format=json`;
58
+
59
+ return `<!doctype html><html lang="${esc(card.language || 'en')}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
60
+ <title>${name} · 7ots</title><meta name="description" content="${desc}">
61
+ <meta property="og:title" content="${name} · 7ots"><meta property="og:description" content="${desc}"><meta property="og:type" content="profile">
62
+ <meta name="theme-color" content="${c1}">
63
+ <link rel="alternate" type="application/json" href="${jsonUrl}"><link rel="icon" href="/favicon.ico">
64
+ <link rel="preconnect" href="https://fonts.googleapis.com"><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,600;12..96,800&family=Inter:wght@400;500;600&display=swap">
65
+ <style>
66
+ :root{color-scheme:dark;--c1:${c1};--c2:${c2};--bg:#0b0919;--panel:rgb(255 255 255/.045);--line:rgb(255 255 255/.09);--ink:#f3f1ff;--muted:#a7a2c9;--disp:"Bricolage Grotesque",system-ui,sans-serif}
67
+ *{box-sizing:border-box}html,body{margin:0}
68
+ body{min-height:100vh;font:15px/1.55 Inter,system-ui,-apple-system,"Segoe UI",sans-serif;color:var(--ink);background:var(--bg);overflow-x:hidden}
69
+ body::before{content:"";position:fixed;inset:-20%;z-index:-1;background:radial-gradient(700px 520px at 20% 8%,color-mix(in srgb,var(--c1) 42%,transparent),transparent 65%),radial-gradient(640px 520px at 92% 30%,color-mix(in srgb,var(--c2) 26%,transparent),transparent 65%),radial-gradient(900px 600px at 50% 120%,color-mix(in srgb,var(--c1) 22%,transparent),transparent 60%)}
70
+ a{color:inherit}
71
+ .top{max-width:1080px;margin:0 auto;padding:18px 20px;display:flex;align-items:center;justify-content:space-between;gap:12px}
72
+ .brand{display:flex;align-items:center;gap:8px;font:800 17px var(--disp);text-decoration:none;letter-spacing:-.02em}
73
+ .brand img{width:26px;height:26px;border-radius:7px}
74
+ .cta{font:600 13px Inter,sans-serif;text-decoration:none;padding:8px 14px;border-radius:999px;background:var(--ink);color:#120f2a}
75
+ .cta:hover{opacity:.9}
76
+ main{max-width:1080px;margin:0 auto;padding:8px 20px 56px;display:grid;gap:22px}
77
+ .hero{display:grid;grid-template-columns:minmax(0,300px) minmax(0,1fr);gap:36px;align-items:center;padding:28px;border:1px solid var(--line);border-radius:28px;background:linear-gradient(140deg,color-mix(in srgb,var(--c1) 18%,transparent),rgb(255 255 255/.02) 55%);backdrop-filter:blur(6px)}
78
+ .stage{position:relative;aspect-ratio:1;border-radius:24px;display:grid;place-items:center;background:radial-gradient(circle at 50% 42%,color-mix(in srgb,var(--c2) 30%,transparent),transparent 62%),rgb(0 0 0/.18);overflow:hidden}
79
+ .stage::after{content:"";position:absolute;left:18%;right:18%;bottom:9%;height:7%;border-radius:50%;background:rgb(0 0 0/.35);filter:blur(10px)}
80
+ .stage>.av{position:relative;z-index:1;width:82%;height:82%;display:grid;place-items:center}
81
+ .stage svg{width:100%;height:100%;overflow:visible}
82
+ .stage img{width:100%;height:100%;object-fit:cover;border-radius:18px}
83
+ .initial{font:800 120px var(--disp);color:var(--c1)}
84
+ .who{display:grid;gap:12px;align-content:center;min-width:0}
85
+ .badges{display:flex;flex-wrap:wrap;gap:8px}
86
+ .badge{display:inline-flex;align-items:center;gap:6px;font:600 12px Inter,sans-serif;padding:5px 10px;border-radius:999px;border:1px solid var(--line);background:rgb(255 255 255/.05);color:var(--muted)}
87
+ .badge.u{color:var(--ink);border-color:color-mix(in srgb,var(--c2) 55%,transparent);background:color-mix(in srgb,var(--c2) 14%,transparent)}
88
+ .badge svg{width:13px;height:13px}
89
+ h1{margin:0;font:800 clamp(40px,7vw,68px)/1 var(--disp);letter-spacing:-.035em;overflow-wrap:anywhere}
90
+ .handle{font:600 16px Inter,sans-serif;color:var(--c2)}
91
+ .role{margin:0;font-size:18px;color:var(--muted);text-transform:capitalize}
92
+ .tagline{margin:0;font:600 21px/1.35 var(--disp);letter-spacing:-.01em;max-width:34ch}
93
+ .grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,380px);gap:22px;align-items:start}
94
+ .col{display:grid;gap:22px;min-width:0}
95
+ .panel{border:1px solid var(--line);border-radius:22px;background:var(--panel);padding:22px;display:grid;gap:14px}
96
+ .panel h2{margin:0;font:700 13px Inter,sans-serif;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);display:flex;align-items:center;justify-content:space-between;gap:8px}
97
+ .panel h2 small{font:600 11px Inter,sans-serif;letter-spacing:0;text-transform:none;padding:3px 8px;border-radius:999px;background:rgb(255 255 255/.07)}
98
+ .bio{margin:0;font-size:16px;white-space:pre-line}
99
+ .bio.empty{color:var(--muted);font-style:italic}
100
+ .kv{display:grid;grid-template-columns:auto 1fr;gap:10px 16px;margin:0;align-items:baseline}
101
+ .kv dt{color:var(--muted);font-size:13px}
102
+ .kv dd{margin:0}
103
+ .chips{display:flex;flex-wrap:wrap;gap:6px;margin:0;padding:0;list-style:none}
104
+ .chips li{font-size:13px;padding:4px 10px;border-radius:999px;background:rgb(255 255 255/.07);border:1px solid var(--line)}
105
+ .sub{margin:-6px 0 0;color:var(--muted);font-size:13px}
106
+ .tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px;margin:0}
107
+ .tile{border:1px solid var(--line);border-radius:14px;padding:11px 13px;background:rgb(0 0 0/.15)}
108
+ .tile dt{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}
109
+ .tile dd{margin:3px 0 0;font-weight:600;text-transform:capitalize;display:flex;align-items:center;gap:7px;overflow-wrap:anywhere}
110
+ .sw{width:14px;height:14px;border-radius:5px;flex:none;box-shadow:inset 0 0 0 1px rgb(255 255 255/.25)}
111
+ .print{display:grid;gap:8px;padding:14px;border-radius:14px;background:rgb(0 0 0/.25);border:1px solid var(--line)}
112
+ .print span{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}
113
+ .print code{font:500 12.5px ui-monospace,SFMono-Regular,Menlo,monospace;overflow-wrap:anywhere;color:var(--ink)}
114
+ .bars{width:100%;height:30px}
115
+ ots-identity{display:block;--ots-bg:#16132e;--ots-fg:#f3f1ff;--ots-line:rgb(255 255 255/.1)}
116
+ .links{display:flex;gap:16px;flex-wrap:wrap;font-size:13px;color:var(--muted);justify-content:center}
117
+ .links a{text-decoration:none}.links a:hover{color:var(--ink)}
118
+ @media (max-width:820px){.hero{grid-template-columns:1fr;gap:22px;padding:20px;text-align:center}.stage{max-width:280px;width:100%;margin:0 auto}.badges,.who{justify-items:center;justify-content:center}.tagline{margin-inline:auto}.grid{grid-template-columns:1fr}}
119
+ @media (max-width:480px){main,.top{padding-inline:16px}.panel{padding:18px}}
120
+ @media (prefers-reduced-motion:no-preference){.stage>.av{animation:float 5s ease-in-out infinite}@keyframes float{50%{transform:translateY(-6px)}}}
121
+ </style></head><body>
122
+ <header class="top"><a class="brand" href="https://7ots.com"><img src="/brand/icon-512.png" alt="">7ots</a><a class="cta" href="https://7ots.com">${l.make}</a></header>
123
+ <main>
124
+ <section class="hero">
125
+ <div class="stage"><div class="av" id="av">${portrait}</div></div>
126
+ <div class="who">
127
+ <div class="badges">${meta?.fingerprint ? `<span class="badge u"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l2.6 5.3 5.9.9-4.3 4.1 1 5.8L12 16.4 6.8 19.1l1-5.8L3.5 9.2l5.9-.9z"/></svg>${l.unique}</span>` : ''}${langs.map((x) => `<span class="badge">${esc(LANG_NAMES[x] || x.toUpperCase())}</span>`).join('')}</div>
128
+ <h1>${name}</h1>
129
+ ${handle ? `<span class="handle">@${esc(handle)}</span>` : ''}
130
+ ${card.role ? `<p class="role">${esc(card.role)}</p>` : ''}
131
+ ${card.tagline ? `<p class="tagline">“${esc(card.tagline)}”</p>` : ''}
132
+ </div>
133
+ </section>
134
+ <div class="grid">
135
+ <div class="col">
136
+ <section class="panel"><h2>${l.about}</h2>
137
+ ${card.bio ? `<p class="bio">${esc(card.bio)}</p>` : `<p class="bio empty">${esc(l.noBio.replace('{name}', card.name))}</p>`}
138
+ <dl class="kv">
139
+ ${card.personality?.tone ? `<dt>${l.tone}</dt><dd>${esc(card.personality.tone)}</dd>` : ''}
140
+ ${langs.length ? `<dt>${l.speaks}</dt><dd>${esc(langs.map((x) => LANG_NAMES[x] || x).join(' · '))}</dd>` : ''}
141
+ </dl>
142
+ ${traits.length ? `<ul class="chips">${traits.map((x) => `<li>${esc(x)}</li>`).join('')}</ul>` : ''}
143
+ </section>
144
+ ${attrs.length || meta?.fingerprint ? `<section class="panel"><h2>${l.traits}<small>${l.soon}</small></h2><p class="sub">${l.traitsSub}</p>
145
+ ${meta?.fingerprint ? `<div class="print"><span>${l.print}</span>${printBars(meta.fingerprint)}<code>${esc(meta.fingerprint)}</code></div>` : ''}
146
+ <dl class="tiles">${attrs.map(tile).join('')}</dl>
147
+ </section>` : ''}
148
+ </div>
149
+ <div class="col">
150
+ <section class="panel"><h2>${l.contact}</h2><ots-identity src="${jsonUrl}" face lang="${esc(String(locale).slice(0, 2))}"></ots-identity></section>
151
+ <p class="links"><a href="${jsonUrl}">${l.json}</a><a href="${esc(ep)}/traits.json">traits.json</a><a href="${esc(ep)}/identity.vcf">vCard</a><a href="https://7ots.com">7ots.com</a></p>
152
+ </div>
153
+ </div>
154
+ </main>
155
+ <script src="/api/agent/7ots.js"></script>
156
+ <script>
157
+ (function(){var c=${JSON.stringify(look.kind !== 'image' && look.character ? look.character : null).replace(/</g, '\\u003c')};var el=document.getElementById('av');
158
+ if(!c||!window.SevenOts||!SevenOts.createCharacter)return;try{el.innerHTML='';SevenOts.createCharacter(el,c,{title:${JSON.stringify(card.name).replace(/</g, '\\u003c')}});}catch(e){}})();
159
+ </script>
160
+ </body></html>`;
161
+ }
@@ -14,6 +14,7 @@
14
14
  * GET /api/platform/ots/:id/usage
15
15
  * * /api/platform/ots/:id/admin/* misma forma que /api/agent/admin/* (el backoffice
16
16
  * edita un ots con ?ots=<id>)
17
+ * * /api/mods, /api/platform/mods/*, /api/device/mods/* community mods (mods.mjs)
17
18
  * GET /api/platform/sites POST { kind, url, name, guide } · POST …/:id/verify · DELETE …/:id (sites.mjs)
18
19
  * GET /api/platform/device/:code · POST { approve } GET /api/platform/devices · DELETE …/:id
19
20
  *
@@ -25,9 +26,11 @@
25
26
  * apuchat identity and Orquesta tasks (…/apuchat/*, …/orquesta/*, /auth/apuchat|orquesta): see connect.mjs
26
27
  */
27
28
 
29
+ import { createReadStream, existsSync, statSync } from 'node:fs';
30
+ import { join } from 'node:path';
28
31
  import { MEET_AVATARS, MEET_SCENES, OPENAI_VOICES, GROK_VOICES, defineIdentity, identityPrompt, publicIdentity, toVCard } from '../identity.mjs';
29
32
  import { errorText, reqT, requestLocale } from '../i18n.mjs';
30
- import { monthOf } from './db.mjs';
33
+ import { dataDir, monthOf } from './db.mjs';
31
34
  import { randomIdentity, cleanSeed } from '../../src/identity/random.js';
32
35
  import { currentAccount } from './auth.mjs';
33
36
  import {
@@ -39,6 +42,8 @@ import { createConnect } from './connect.mjs';
39
42
  import { createDevice, deviceFromBearer } from './device.mjs';
40
43
  import { createVm } from './vm.mjs';
41
44
  import { createSites } from './sites.mjs';
45
+ import { profilePage } from './profile.mjs';
46
+ import { createMods } from './mods.mjs';
42
47
  import '../../src/i18n/messages/platform.js';
43
48
 
44
49
  const penv = process.env;
@@ -119,7 +124,8 @@ export function createPlatform({ send, readJson, rateLimit, callLimit, originOf,
119
124
  res.setHeader('Vary', 'Accept');
120
125
  if (/text\/html/.test(req.headers.accept || '') && !/[?&]format=json\b/.test(req.url)) {
121
126
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' });
122
- return res.end(profilePage(card, `${originOf(req)}/api/o/${ots.id}`));
127
+ const ep = `${originOf(req)}/api/o/${ots.id}`;
128
+ return res.end(profilePage(card, traitsMeta(ots, ep), ep, requestLocale(req)));
123
129
  }
124
130
  return send(res, 200, card);
125
131
  }
@@ -325,6 +331,7 @@ export function createPlatform({ send, readJson, rateLimit, callLimit, originOf,
325
331
  if ((await connect.account(req, res, account, path)) !== false) return;
326
332
  if (await device.dashboard(req, res, account, path)) return;
327
333
  if (await sites(req, res, account, path)) return;
334
+ if ((path === '/mods' || path.startsWith('/mods/')) && (await mods.accountApi(req, res, account, path.slice('/mods'.length)))) return;
328
335
 
329
336
  if (path === '/ots' && req.method === 'GET') {
330
337
  const usage = usageByOts(account.id);
@@ -380,6 +387,7 @@ export function createPlatform({ send, readJson, rateLimit, callLimit, originOf,
380
387
  if (sub === '' && req.method === 'DELETE') {
381
388
  await stopOtsChannels(ots.id);
382
389
  await connect.release(account, ots);
390
+ await vm.releaseOts(ots.id); // its 7ots server (Orquesta VM) goes too; ots_vms cascades with the ot
383
391
  deleteOts(ots.id);
384
392
  return send(res, 200, { ok: true });
385
393
  }
@@ -402,14 +410,25 @@ export function createPlatform({ send, readJson, rateLimit, callLimit, originOf,
402
410
  }
403
411
 
404
412
  const connect = createConnect({ send, readJson, originOf, save });
405
- const device = createDevice({ send, readJson, rateLimit, base: (req) => (penv.PLATFORM_URL || originOf(req)).replace(/\/+$/, ''), summary: (o) => otsSummary(o) });
413
+ const device = createDevice({ send, readJson, rateLimit, base: (req) => (penv.PLATFORM_URL || originOf(req)).replace(/\/+$/, ''), summary: (o) => otsSummary(o), save });
406
414
  const sites = createSites({ send, readJson });
415
+ const mods = createMods({ send, readJson });
407
416
  const vm = createVm({ send, readJson, rateLimit, base: (req) => (penv.PLATFORM_URL || originOf(req)).replace(/\/+$/, '') });
408
417
 
409
418
  /** @returns {Promise<boolean>} true si atendió la ruta */
410
419
  return async function handle(req, res, url) {
411
420
  const p = url.pathname;
412
421
  if (await connect.auth(req, res, url)) return true;
422
+ // models of mods (uploaded by a pet when it syncs): public, content-addressed, immutable
423
+ const mf = /^\/mods\/([a-f0-9]{16})\.(glb|vrm)$/.exec(p);
424
+ if (mf && (req.method === 'GET' || req.method === 'HEAD')) {
425
+ const file = join(dataDir(), 'mods', `${mf[1]}.${mf[2]}`);
426
+ if (!existsSync(file)) return send(res, 404, { error: 'not found' }), true;
427
+ res.writeHead(200, { 'Content-Type': 'model/gltf-binary', 'Content-Length': statSync(file).size, 'Cache-Control': 'public, max-age=31536000, immutable', 'Access-Control-Allow-Origin': '*' });
428
+ if (req.method === 'HEAD') return res.end(), true;
429
+ createReadStream(file).pipe(res);
430
+ return true;
431
+ }
413
432
  const m = p.match(/^\/api\/o\/([a-z0-9_]{4,40})(\/.*)$/);
414
433
  if (m) {
415
434
  await publicApi(req, res, m[1], m[2]);
@@ -417,9 +436,12 @@ export function createPlatform({ send, readJson, rateLimit, callLimit, originOf,
417
436
  }
418
437
  // The landing asks before sending the visitor to sign in: each ot is unique, a repeated face can't be created.
419
438
  if (p === '/api/look/free' && req.method === 'POST') {
420
- const { character } = await readJson(req, 64 * 1024);
421
- const ok = character && typeof character === 'object' && !Array.isArray(character);
422
- send(res, 200, { free: ok ? !lookTaken({ look: { kind: 'character', character } }) : false });
439
+ const { character, characters } = await readJson(req, 256 * 1024);
440
+ const isObj = (c) => c && typeof c === 'object' && !Array.isArray(c);
441
+ const free = (c) => (isObj(c) ? !lookTaken({ look: { kind: 'character', character: c } }) : false);
442
+ // en lote (las sugerencias al azar del editor): { characters: [...] } → { free: [bool…] }
443
+ if (Array.isArray(characters)) send(res, 200, { free: characters.slice(0, 32).map(free) });
444
+ else send(res, 200, { free: free(character) });
423
445
  return true;
424
446
  }
425
447
  const dv = p.match(/^\/api\/device\/ots\/([a-z0-9_]{4,40})\/vm(\/.*)?$/);
@@ -429,6 +451,17 @@ export function createPlatform({ send, readJson, rateLimit, callLimit, originOf,
429
451
  else await vm.device(req, res, who, dv[1], dv[2] || '');
430
452
  return true;
431
453
  }
454
+ // community mods: public listing, and the same signed-in api for the pet (bearer) as for the dashboard
455
+ if (p === '/api/mods' || p.startsWith('/api/mods/')) {
456
+ if (!(await mods.publicApi(req, res, p.slice('/api/mods'.length)))) send(res, 404, { error: 'not found' });
457
+ return true;
458
+ }
459
+ if (p === '/api/device/mods' || p.startsWith('/api/device/mods/')) {
460
+ const who = deviceFromBearer(req);
461
+ if (!who) send(res, 401, { error: 'login' });
462
+ else if (!(await mods.accountApi(req, res, who.account, p.slice('/api/device/mods'.length)))) send(res, 404, { error: 'not found' });
463
+ return true;
464
+ }
432
465
  if (p.startsWith('/api/vm/')) {
433
466
  await vm.worker(req, res, p.slice('/api/vm'.length));
434
467
  return true;
@@ -475,33 +508,6 @@ function sanitizeWidget(w) {
475
508
  return out;
476
509
  }
477
510
 
478
- const htmlEsc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
479
- /** Public profile of an ot (what /api/o/<id>/identity shows in a browser): its contact card with the live face. */
480
- function profilePage(card, ep) {
481
- const name = htmlEsc(card.name);
482
- const desc = htmlEsc(card.tagline || card.role || '');
483
- const lang = htmlEsc(card.language || 'en');
484
- const c1 = /^#[0-9a-f]{3,8}$/i.test(card.look?.color || '') ? card.look.color : '#6d5dfc';
485
- const c2 = /^#[0-9a-f]{3,8}$/i.test(card.look?.accent || '') ? card.look.accent : '#22d3ee';
486
- return `<!doctype html><html lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
487
- <title>${name} · 7ots</title><meta name="description" content="${desc}">
488
- <meta property="og:title" content="${name} · 7ots"><meta property="og:description" content="${desc}"><meta property="og:type" content="profile">
489
- <link rel="alternate" type="application/json" href="${htmlEsc(ep)}/identity?format=json"><link rel="icon" href="/favicon.ico">
490
- <style>
491
- :root{color-scheme:dark;--c1:${c1};--c2:${c2}}
492
- *{box-sizing:border-box}html,body{margin:0;min-height:100%}
493
- body{min-height:100vh;display:grid;place-items:center;padding:32px 16px;font:15px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif;color:#ece9ff;
494
- background:radial-gradient(900px 600px at 15% 0%,color-mix(in srgb,var(--c1) 35%,transparent),transparent 60%),radial-gradient(800px 600px at 100% 100%,color-mix(in srgb,var(--c2) 22%,transparent),transparent 60%),#0d0a24}
495
- main{width:min(440px,100%);display:grid;gap:16px}
496
- ots-identity{display:block}
497
- .foot{display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;font-size:12px;color:#a9a3d6}
498
- .foot a{color:inherit}
499
- </style></head><body><main>
500
- <ots-identity src="${htmlEsc(ep)}/identity?format=json" face></ots-identity>
501
- <p class="foot"><a href="${htmlEsc(ep)}/identity?format=json">JSON</a><a href="${htmlEsc(ep)}/identity.vcf">vCard</a><a href="https://7ots.com">7ots.com</a></p>
502
- </main><script src="/api/agent/7ots.js"></script></body></html>`;
503
- }
504
-
505
511
  /**
506
512
  * The ot's unique traits as NFT-style metadata (ERC-721 / Metaplex shape). The face fingerprint is
507
513
  * what makes an ot unique (UNIQUE index); the permanent apuchat @handle is its unique name. When
@@ -14,12 +14,12 @@
14
14
  */
15
15
 
16
16
  import { createLLM } from '../llm.mjs';
17
- import { synthesize as synthesizeWith, ttsProvider } from '../tts.mjs';
17
+ import { synthesize as synthesizeWith, ttsProvider, openaiTtsKey } from '../tts.mjs';
18
18
  import { startAgentChannels } from '../channels/index.mjs';
19
19
  import { apumailConfigFromEnv } from '../channels/apumail.mjs';
20
20
  import { apuchatConfigFromEnv } from '../channels/apuchat.mjs';
21
21
  import { i18nError } from '../i18n.mjs';
22
- import { OTS_KEYS, accountUsage, addUsage, allLiveOts, getOts } from './store.mjs';
22
+ import { OTS_KEYS, accountUsage, addUsage, allLiveOts, getOts, logTurn } from './store.mjs';
23
23
  import { orquestaTools } from './orquesta.mjs';
24
24
 
25
25
  const penv = process.env;
@@ -75,7 +75,7 @@ export function otsEnv(ots) {
75
75
 
76
76
  // Voz: la suya o la de apuchat de la plataforma.
77
77
  let platformTts = false;
78
- if (!TTS_KEYS.some((k) => env[k]) && penv.PLATFORM_APUCHAT_VOICE_TOKEN && (!env.TTS_PROVIDER || env.TTS_PROVIDER === 'apuchat')) {
78
+ if (!TTS_KEYS.some((k) => (k === 'OPENAI_API_KEY' ? openaiTtsKey(env) : env[k])) && penv.PLATFORM_APUCHAT_VOICE_TOKEN && (!env.TTS_PROVIDER || env.TTS_PROVIDER === 'apuchat')) {
79
79
  platformTts = true;
80
80
  // On the platform's token the engine and model are the platform's, not the ot's.
81
81
  delete env.APUCHAT_VOICE_PROVIDER;
@@ -213,6 +213,8 @@ export function otsChannels(ots) {
213
213
  log: (...a) => console.log(`[7ots:${id}]`, ...a),
214
214
  // Orquesta tasks: only for senders on the ot's own list (see orquesta.mjs), read on every message.
215
215
  tools: (ctx) => orquestaTools(current(), ctx),
216
+ // mails and DMs go to its conversations (dashboard, and the desktop ot through /api/device/…/inbox)
217
+ onTurn: ({ key, channel, user, reply }) => logTurn(current(), { key, channel: channel === 'dm' ? 'apuchat' : 'apumail', visitor: key.replace(/^\w+:/, ''), user, reply }),
216
218
  });
217
219
  channels.set(id, { stamp, ch });
218
220
  return ch;
@@ -20,7 +20,7 @@ const parse = (s, fallback) => {
20
20
  };
21
21
 
22
22
  export const SESSION_TTL = 30 * 24 * 3600_000;
23
- export const MAX_OTS_PER_ACCOUNT = () => Number(process.env.PLATFORM_MAX_OTS || 10);
23
+ export const MAX_OTS_PER_ACCOUNT = () => Number(process.env.PLATFORM_MAX_OTS || 7);
24
24
 
25
25
  /**
26
26
  * Ajustes que cada ots puede cambiar. Lo que no está aquí lo fija la plataforma