@7ots/cli 0.1.7 → 0.1.9

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 (57) hide show
  1. package/README.md +1 -1
  2. package/cli/7ots.mjs +28 -4
  3. package/cli/lib/account.mjs +152 -2
  4. package/cli/lib/assistant.mjs +106 -41
  5. package/cli/lib/brain.mjs +13 -2
  6. package/cli/lib/config.mjs +3 -0
  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 +344 -11
  11. package/cli/pet/electron/director.cjs +37 -5
  12. package/cli/pet/electron/main.cjs +92 -10
  13. package/cli/pet/pet.html +149 -10
  14. package/cli/pet/settings.html +545 -42
  15. package/dist/7ots.esm.js +216 -137
  16. package/dist/7ots.esm.js.map +4 -4
  17. package/dist/7ots.iife.js +216 -137
  18. package/dist/7ots.iife.js.map +4 -4
  19. package/package.json +1 -1
  20. package/server/channels/agent.mjs +7 -1
  21. package/server/channels/index.mjs +3 -2
  22. package/server/platform/connect.mjs +4 -1
  23. package/server/platform/db.mjs +9 -1
  24. package/server/platform/device.mjs +101 -4
  25. package/server/platform/mods.mjs +222 -0
  26. package/server/platform/orquesta.mjs +78 -14
  27. package/server/platform/profile.mjs +161 -0
  28. package/server/platform/routes.mjs +38 -33
  29. package/server/platform/runtime.mjs +3 -1
  30. package/server/platform/store.mjs +1 -1
  31. package/server/platform/vm.mjs +370 -91
  32. package/server/server.mjs +12 -2
  33. package/src/avatar/three.js +3 -2
  34. package/src/character/AvatarEditor.js +95 -7
  35. package/src/character/Character.js +116 -17
  36. package/src/character/TradingCard.js +226 -0
  37. package/src/character/mods/index.js +4 -0
  38. package/src/character/mods/orc.js +64 -0
  39. package/src/character/mods.js +251 -0
  40. package/src/character/more/acc-head.js +213 -0
  41. package/src/character/more/acc-rest.js +342 -0
  42. package/src/character/more/brows.js +39 -0
  43. package/src/character/more/cheeks.js +78 -0
  44. package/src/character/more/eyes.js +288 -0
  45. package/src/character/more/mouths.js +259 -0
  46. package/src/character/more/patterns.js +120 -0
  47. package/src/character/parts.js +90 -0
  48. package/src/character/skin.js +166 -1
  49. package/src/character/styles.js +1285 -9
  50. package/src/character3d/Character3D.js +148 -35
  51. package/src/character3d/index.js +7 -1
  52. package/src/character3d/model.js +367 -0
  53. package/src/i18n/messages/character.js +34 -22
  54. package/src/i18n/messages/platform.js +15 -0
  55. package/src/identity/random.js +17 -3
  56. package/src/identity/schema.js +10 -0
  57. package/src/index.js +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7ots/cli",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "7ots.com — your AI companion with a face of its own: a desktop pet that watches over your coding agent (Claude Code and shell hooks), a personal assistant (reminders, alerts, notes, opens sites, sees your screen if allowed) and a reproducible identity (face, personality, voice) for any agent — in apuchat meet calls, Orquesta projects or your own server.",
5
5
  "homepage": "https://7ots.com",
6
6
  "repository": {
@@ -65,7 +65,7 @@ function systemFor(channel, { lang, identity = getIdentity, instructions = () =>
65
65
  * @param {{ llm?: {step: Function}, brain?: Function, identity?: () => object, instructions?: () => string }} deps
66
66
  * instructions: instrucciones fijas del servidor (defecto SERVER_INSTRUCTIONS)
67
67
  */
68
- export function createServerAgent({ llm, brain = null, identity = getIdentity, instructions }) {
68
+ export function createServerAgent({ llm, brain = null, identity = getIdentity, instructions, onTurn = null }) {
69
69
  if (!llm && !brain) throw new Error('createServerAgent necesita llm o brain');
70
70
  /** clave de contacto → { messages: Neutral[], at } */
71
71
  const memory = new Map();
@@ -121,6 +121,12 @@ export function createServerAgent({ llm, brain = null, identity = getIdentity, i
121
121
  final = clean(final);
122
122
  // Se recuerda solo lo conversacional (sin pasos de herramientas ni razonamiento).
123
123
  remember(key, [...history, turn[0], ...(final ? [{ role: 'assistant', content: final }] : [])]);
124
+ // correo y DMs quedan apuntados (las llamadas no: sus turnos son voz y avisos internos)
125
+ if (onTurn && channel !== 'voice') {
126
+ try {
127
+ onTurn({ key, channel, user: text, reply: final });
128
+ } catch {}
129
+ }
124
130
  return final;
125
131
  }
126
132
 
@@ -20,9 +20,10 @@ import { apuchatConfigFromEnv, apuchatConfigured, createApuchatChannel } from '.
20
20
  * @param {Function} [deps.log]
21
21
  * @param {(ctx: { channel: 'dm'|'email', from: string, verified?: boolean }) => ({ tools: object[], run: Function }|null)} [deps.tools]
22
22
  * extra server tools for one conversation (DMs and mails, never voice calls), decided per sender
23
+ * @param {(t: { key: string, channel: 'email'|'dm', user: string, reply: string }) => void} [deps.onTurn] each answered mail / DM
23
24
  */
24
- export function startAgentChannels({ llm, brain, identity = getIdentity, instructions, config = {}, log = (...a) => console.log('[7ots]', ...a), tools = null }) {
25
- const agent = createServerAgent({ llm, brain, identity, instructions });
25
+ export function startAgentChannels({ llm, brain, identity = getIdentity, instructions, config = {}, log = (...a) => console.log('[7ots]', ...a), tools = null, onTurn = null }) {
26
+ const agent = createServerAgent({ llm, brain, identity, instructions, onTurn });
26
27
  const persona = () => personaOf(identity);
27
28
  const mailCfg = config.apumail === false ? null : { ...apumailConfigFromEnv(), ...(config.apumail || {}) };
28
29
  const chatCfg = config.apuchat === false ? null : { ...apuchatConfigFromEnv(), ...(config.apuchat || {}) };
@@ -35,6 +35,7 @@ import {
35
35
  } from './apuchat.mjs';
36
36
  import {
37
37
  askOt, authorizeUrl, cancelTask, disconnect, exchangeCode, listProjects, manageUrl, orquestaAccountView, orquestaUrl, otsOrquesta, parseAllow, projectUrl, saveKey, taskStatus,
38
+ registerOts,
38
39
  } from './orquesta.mjs';
39
40
 
40
41
  const penv = process.env;
@@ -235,7 +236,9 @@ export function createConnect({ send, readJson, originOf, save }) {
235
236
  }
236
237
  if (body.daily !== undefined) settings.ORQUESTA_DAILY = String(Math.min(200, Math.max(1, Math.floor(Number(body.daily)) || 20)));
237
238
  if (settings.ORQUESTA_TASKS && !settings.ORQUESTA_PROJECT) return send(res, 400, { error: t('platform.orquesta.pickProject') });
238
- return send(res, 200, orquestaView(account, await save(ots, { settings })));
239
+ const saved = await save(ots, { settings });
240
+ registerOts(account.id, { force: true }).catch(() => null); // seeds the link in Orquesta for a first project
241
+ return send(res, 200, orquestaView(account, saved));
239
242
  }
240
243
  if (sub === '/orquesta/ask' && req.method === 'POST') {
241
244
  const { message, history } = await readJson(req, 128 * 1024);
@@ -17,7 +17,8 @@
17
17
  * sites sites/APIs/MCPs enrolled in Prowl.world so ots know how to use them (sites.mjs)
18
18
  * devices computers signed in with `7ots login` (the desktop pet): a bearer token, hashed
19
19
  * ots_vms the machine where an ot's assistant worker runs (one per ot; vm.mjs, docs/VM-PLAN.md):
20
- * provider 'byo' (the owner's own server, enrolled by hand) or 'orquesta' (paid by 7ots, Phase 1)
20
+ * provider 'byo' (the owner's own server, enrolled by hand), 'orquesta' (in the account's Orquesta
21
+ * child org, paid through Orquesta's checkout) or 'orquesta_user' (in the owner's own Orquesta org)
21
22
  * vm_jobs work the desktop sends to that VM, and how it ended
22
23
  * vm_job_events what the worker reported while doing it (seq per job, for long-polls)
23
24
  */
@@ -197,6 +198,9 @@ CREATE TABLE IF NOT EXISTS vm_job_events (
197
198
 
198
199
  let db = null;
199
200
 
201
+ /** Carpeta de datos de la plataforma (junto a PLATFORM_DB): ahí van también los archivos subidos. */
202
+ export const dataDir = () => (process.env.PLATFORM_DB === ':memory:' ? resolve(ROOT, 'data') : dirname(resolve(ROOT, process.env.PLATFORM_DB || join('data', 'platform.db'))));
203
+
200
204
  /** Abre (y crea si hace falta) la base de datos. Idempotente. */
201
205
  export function openDb(path = process.env.PLATFORM_DB) {
202
206
  if (db) return db;
@@ -213,6 +217,10 @@ export function openDb(path = process.env.PLATFORM_DB) {
213
217
  if (!vmCols.includes('orq_status')) db.exec("ALTER TABLE ots_vms ADD COLUMN orq_status TEXT NOT NULL DEFAULT ''");
214
218
  if (!vmCols.includes('orq_checked_at')) db.exec('ALTER TABLE ots_vms ADD COLUMN orq_checked_at INTEGER');
215
219
  if (!vmCols.includes('price_cents')) db.exec('ALTER TABLE ots_vms ADD COLUMN price_cents INTEGER');
220
+ // One Orquesta child org per 7ots account (partner orgs): the account's org id, and the org each VM lives in
221
+ // ('' = the partner's own org, VMs made before child orgs). Provider 'orquesta_user' VMs live in the owner's org.
222
+ if (!db.prepare('PRAGMA table_info(accounts)').all().some((c) => c.name === 'orq_org_id')) db.exec("ALTER TABLE accounts ADD COLUMN orq_org_id TEXT NOT NULL DEFAULT ''");
223
+ if (!vmCols.includes('orq_org_id')) db.exec("ALTER TABLE ots_vms ADD COLUMN orq_org_id TEXT NOT NULL DEFAULT ''");
216
224
  return db;
217
225
  }
218
226
 
@@ -10,6 +10,14 @@
10
10
  * GET /api/device/me → { account, ots: [summary] } (Authorization: Bearer)
11
11
  * GET /api/device/ots/:id → { ots: summary, identity } (the owner's full identity)
12
12
  * DELETE /api/device/token → signs this computer out
13
+ * GET /api/device/ots/:id/orquesta → { connected, project, projectName, projects }
14
+ * POST /api/device/ots/:id/orquesta/tasks { text, projectId? } · GET …/tasks/:tid
15
+ * The desktop ot uses the account's Orquesta connection through here: the Orquesta token
16
+ * stays sealed on 7ots.com and never reaches the computer. The device is the owner's (it
17
+ * was approved from the dashboard), so it may pick any project the connection can reach.
18
+ * GET /api/device/ots/:id/inbox → { apuchat, email, conversations: [{ channel, who, at, messages }] }
19
+ * what people wrote to the ot on apuchat and email lately, and what it answered (read only;
20
+ * the channels' keys stay on 7ots.com)
13
21
  *
14
22
  * From the dashboard (session cookie, X-7ots-Admin: 1, same origin; see routes.mjs):
15
23
  * GET /api/platform/device/:code → { name, expiresIn } what is asking
@@ -21,9 +29,13 @@
21
29
  */
22
30
 
23
31
  import { defineIdentity } from '../../src/identity/schema.js';
24
- import { getDb } from './db.mjs';
32
+ import { createHash } from 'node:crypto';
33
+ import { mkdirSync, writeFileSync } from 'node:fs';
34
+ import { join } from 'node:path';
35
+ import { dataDir, getDb } from './db.mjs';
25
36
  import { newId, sha256, token } from './crypto.mjs';
26
- import { getOwnOts, listOts, publicAccount } from './store.mjs';
37
+ import { getConversation, getOwnOts, listConversations, listOts, publicAccount } from './store.mjs';
38
+ import { listProjects, orquestaAccountView, otsOrquesta, runTask, taskStatus } from './orquesta.mjs';
27
39
 
28
40
  const CODE_TTL = 10 * 60_000;
29
41
  const PREFIX = '7d_'; // so a leaked token is recognizable
@@ -52,7 +64,11 @@ export function listDevices(accountId) {
52
64
  /**
53
65
  * @param {{ send: Function, readJson: Function, base: (req) => string, rateLimit: (req) => boolean, summary: (ots) => object }} deps
54
66
  */
55
- export function createDevice({ send, readJson, base, rateLimit, summary }) {
67
+ /** What the pet and 7ots.com keep in sync (the look goes only as look.character). */
68
+ const SYNCED = ['name', 'role', 'tagline', 'bio', 'language', 'languages', 'personality'];
69
+ const MAX_MODEL = 60 * 1024 * 1024;
70
+
71
+ export function createDevice({ send, readJson, base, rateLimit, summary, save }) {
56
72
  /** Public half (/api/device/*). @returns {Promise<boolean>} */
57
73
  async function api(req, res, path) {
58
74
  res.setHeader('Cache-Control', 'no-store');
@@ -87,12 +103,93 @@ export function createDevice({ send, readJson, base, rateLimit, summary }) {
87
103
  if (m && req.method === 'GET') {
88
104
  const ots = getOwnOts(account.id, m[1]);
89
105
  if (!ots) return send(res, 404, { error: 'not found' }), true;
90
- return send(res, 200, { ots: summary(ots), identity: defineIdentity(ots.identity) }), true;
106
+ return send(res, 200, { ots: summary(ots), identity: defineIdentity(ots.identity), updatedAt: ots.updatedAt }), true;
107
+ }
108
+ // sync with the desktop pet: the same look, personality and character sheet on both sides
109
+ const sm = /^\/ots\/([a-z0-9_]{4,40})\/identity$/.exec(path);
110
+ if (sm && req.method === 'PUT') {
111
+ const ots = getOwnOts(account.id, sm[1]);
112
+ if (!ots) return send(res, 404, { error: 'not found' }), true;
113
+ const inc = (await readJson(req, 512 * 1024))?.identity;
114
+ if (!inc || typeof inc !== 'object') return send(res, 400, { error: 'identity' }), true;
115
+ const patch = Object.fromEntries(SYNCED.filter((k) => k in inc).map((k) => [k, inc[k]]));
116
+ if (inc.look?.character) patch.look = { character: inc.look.character }; // the rest of the look (image, avatar) is the web's
117
+ try {
118
+ const next = await save(ots, { identity: defineIdentity(patch, ots.identity) });
119
+ return send(res, 200, { identity: defineIdentity(next.identity), updatedAt: next.updatedAt }), true;
120
+ } catch (e) {
121
+ return send(res, e.status || 500, { error: e.message }), true;
122
+ }
123
+ }
124
+ // a full-body model (.glb/.vrm) of a mod: content-addressed, served at /mods/<sha16>.<ext> like on the pet
125
+ const fm = /^\/ots\/([a-z0-9_]{4,40})\/files$/.exec(path);
126
+ if (fm && req.method === 'POST') {
127
+ if (!getOwnOts(account.id, fm[1])) return send(res, 404, { error: 'not found' }), true;
128
+ const ext = new URL(req.url, 'http://x').searchParams.get('ext');
129
+ if (!['glb', 'vrm'].includes(ext)) return send(res, 400, { error: 'ext' }), true;
130
+ const chunks = [];
131
+ let size = 0;
132
+ for await (const c of req) {
133
+ size += c.length;
134
+ if (size > MAX_MODEL) return send(res, 413, { error: 'too big' }), true;
135
+ chunks.push(c);
136
+ }
137
+ const buf = Buffer.concat(chunks);
138
+ if (buf.length < 20 || buf.toString('latin1', 0, 4) !== 'glTF') return send(res, 400, { error: 'not a glTF binary' }), true;
139
+ const h = createHash('sha256').update(buf).digest('hex').slice(0, 16);
140
+ mkdirSync(join(dataDir(), 'mods'), { recursive: true });
141
+ writeFileSync(join(dataDir(), 'mods', `${h}.${ext}`), buf);
142
+ return send(res, 200, { url: `/mods/${h}.${ext}` }), true;
143
+ }
144
+ const im = /^\/ots\/([a-z0-9_]{4,40})\/inbox$/.exec(path);
145
+ if (im && req.method === 'GET') {
146
+ const ots = getOwnOts(account.id, im[1]);
147
+ if (!ots) return send(res, 404, { error: 'not found' }), true;
148
+ const conversations = listConversations(ots.id, { limit: 60 })
149
+ .filter((c) => c.channel === 'apuchat' || c.channel === 'apumail')
150
+ .slice(0, 10)
151
+ .map((c) => ({ channel: c.channel, who: c.visitor, at: c.updatedAt, messages: (getConversation(ots.id, c.id)?.messages || []).slice(-6).map((m) => ({ role: m.role, text: m.content.slice(0, 1200), at: m.at })) }));
152
+ const s = ots.settings;
153
+ return send(res, 200, { apuchat: s.APUCHAT_AGENT_CALLSIGN || null, email: s.APUMAIL_AGENT_INBOX || null, conversations }), true;
154
+ }
155
+ const om = /^\/ots\/([a-z0-9_]{4,40})\/orquesta(\/tasks(?:\/([A-Za-z0-9_-]{4,100}))?)?$/.exec(path);
156
+ if (om) {
157
+ const ots = getOwnOts(account.id, om[1]);
158
+ if (!ots) return send(res, 404, { error: 'not found' }), true;
159
+ return orquesta(req, res, account, ots, !!om[2], om[3]);
91
160
  }
92
161
  }
93
162
  return false;
94
163
  }
95
164
 
165
+ /** The ot's Orquesta, used by the desktop ot with the account's connection. */
166
+ async function orquesta(req, res, account, ots, tasks, tid) {
167
+ const view = orquestaAccountView(account.id);
168
+ if (!tasks && req.method === 'GET') {
169
+ const cfg = otsOrquesta(ots);
170
+ let projects = [];
171
+ let error = view.error;
172
+ if (view.connected) projects = await listProjects(account.id).catch((e) => ((error = e.message), []));
173
+ return send(res, 200, { connected: view.connected, error, project: cfg.project, projectName: cfg.projectName, projects }), true;
174
+ }
175
+ if (!view.connected) return send(res, 409, { error: 'Orquesta is not connected on 7ots.com' }), true;
176
+ if (tasks && !tid && req.method === 'POST') {
177
+ const { text, projectId } = await readJson(req, 64 * 1024);
178
+ let project = otsOrquesta(ots).project;
179
+ if (projectId) {
180
+ const p = (await listProjects(account.id)).find((x) => x.id === String(projectId));
181
+ if (!p) return send(res, 404, { error: 'that project is not shared with 7ots' }), true;
182
+ project = p.id;
183
+ }
184
+ if (!project) return send(res, 400, { error: 'pick a project' }), true;
185
+ // the owner, from their own computer: tasks on for this one, the chosen project
186
+ const out = await runTask({ ...ots, settings: { ...ots.settings, ORQUESTA_TASKS: '1', ORQUESTA_PROJECT: project } }, { task: text, who: `owner ${account.email || account.id}`, channel: 'desktop' });
187
+ return send(res, out.ok === false && !out.id ? 400 : 200, out), true;
188
+ }
189
+ if (tid && req.method === 'GET') return send(res, 200, await taskStatus(ots, tid)), true;
190
+ return send(res, 405, { error: 'method' }), true;
191
+ }
192
+
96
193
  /** Dashboard half, already behind the session + same-origin checks. @returns {boolean} */
97
194
  async function dashboard(req, res, account, path) {
98
195
  const m = /^\/device\/([A-Za-z0-9]{8,40})$/.exec(path);
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Community mods: looks that people share for any ot (src/character/mods.js), with installs,
3
+ * 1–5 star ratings (one per account, only after installing) and reports.
4
+ *
5
+ * New mods wait for an admin (PLATFORM_ADMINS: emails, comma separated) unless an admin uploads
6
+ * them. A mod reported by MOD_REPORTS_HIDE accounts (default 3) is hidden until reviewed.
7
+ *
8
+ * Public (any origin):
9
+ * GET /api/mods[?sort=top|new|popular&q=…] → { mods: [view] } approved only
10
+ * GET /api/mods/:id → { mod: view }
11
+ * Signed in, from the dashboard (/api/platform/mods/*, session) or the pet (/api/device/mods/*, bearer):
12
+ * GET /mods/mine → { mods: [view], admin, pending: [view] (admins) }
13
+ * POST /mods { mod } → publish or update one of yours (by its id)
14
+ * POST /mods/files?ext=glb|vrm raw body (≤ 20 MB) → { url: '/mods/<sha16>.<ext>' }
15
+ * POST /mods/:id/install → { mod: view } (counts once per account)
16
+ * POST /mods/:id/rate { stars: 1..5 }
17
+ * POST /mods/:id/report { reason }
18
+ * POST /mods/:id/review { status: approved|rejected|hidden } (admins)
19
+ * DELETE /mods/:id (its author or an admin)
20
+ */
21
+
22
+ import { createHash } from 'node:crypto';
23
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
24
+ import { join } from 'node:path';
25
+ import { normalizeMod } from '../../src/character/mods.js';
26
+ import { dataDir, getDb } from './db.mjs';
27
+
28
+ const MAX_FILE = 20 * 1024 * 1024;
29
+ const MAX_MOD_JSON = 2 * 1024 * 1024;
30
+ const STATUSES = ['pending', 'approved', 'rejected', 'hidden'];
31
+ const MODEL = /^\/mods\/([a-f0-9]{16})\.(glb|vrm)$/;
32
+
33
+ let ready = false;
34
+ function db() {
35
+ const d = getDb();
36
+ if (!ready) {
37
+ d.exec(`
38
+ CREATE TABLE IF NOT EXISTS community_mods (
39
+ id TEXT PRIMARY KEY,
40
+ account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
41
+ author TEXT NOT NULL DEFAULT '',
42
+ mod TEXT NOT NULL,
43
+ status TEXT NOT NULL DEFAULT 'pending',
44
+ installs INTEGER NOT NULL DEFAULT 0,
45
+ stars_sum INTEGER NOT NULL DEFAULT 0,
46
+ stars_n INTEGER NOT NULL DEFAULT 0,
47
+ reports INTEGER NOT NULL DEFAULT 0,
48
+ created_at INTEGER NOT NULL,
49
+ updated_at INTEGER NOT NULL
50
+ );
51
+ CREATE INDEX IF NOT EXISTS community_mods_status ON community_mods(status, created_at DESC);
52
+ CREATE TABLE IF NOT EXISTS mod_installs (mod_id TEXT NOT NULL REFERENCES community_mods(id) ON DELETE CASCADE, account_id TEXT NOT NULL, at INTEGER NOT NULL, PRIMARY KEY (mod_id, account_id));
53
+ CREATE TABLE IF NOT EXISTS mod_ratings (mod_id TEXT NOT NULL REFERENCES community_mods(id) ON DELETE CASCADE, account_id TEXT NOT NULL, stars INTEGER NOT NULL, at INTEGER NOT NULL, PRIMARY KEY (mod_id, account_id));
54
+ CREATE TABLE IF NOT EXISTS mod_reports (mod_id TEXT NOT NULL REFERENCES community_mods(id) ON DELETE CASCADE, account_id TEXT NOT NULL, reason TEXT NOT NULL DEFAULT '', at INTEGER NOT NULL, PRIMARY KEY (mod_id, account_id));
55
+ `);
56
+ ready = true;
57
+ }
58
+ return d;
59
+ }
60
+
61
+ export const isModAdmin = (account) =>
62
+ !!account?.email && String(process.env.PLATFORM_ADMINS || '').toLowerCase().split(/[\s,]+/).filter(Boolean).includes(account.email.toLowerCase());
63
+
64
+ const authorOf = (a) => String(a.name || (a.email || '').split('@')[0] || 'anon').slice(0, 40);
65
+
66
+ function view(row, me) {
67
+ if (!row) return null;
68
+ const v = {
69
+ id: row.id,
70
+ mod: JSON.parse(row.mod),
71
+ author: row.author,
72
+ status: row.status,
73
+ installs: row.installs,
74
+ rating: row.stars_n ? Math.round((row.stars_sum / row.stars_n) * 10) / 10 : null,
75
+ ratings: row.stars_n,
76
+ createdAt: row.created_at,
77
+ updatedAt: row.updated_at,
78
+ };
79
+ if (me) {
80
+ const d = db();
81
+ v.mine = row.account_id === me.id;
82
+ v.installed = !!d.prepare('SELECT 1 FROM mod_installs WHERE mod_id = ? AND account_id = ?').get(row.id, me.id);
83
+ v.myStars = d.prepare('SELECT stars FROM mod_ratings WHERE mod_id = ? AND account_id = ?').get(row.id, me.id)?.stars || null;
84
+ v.reported = !!d.prepare('SELECT 1 FROM mod_reports WHERE mod_id = ? AND account_id = ?').get(row.id, me.id);
85
+ }
86
+ return v;
87
+ }
88
+
89
+ const ORDER = {
90
+ // Bayesian average: a single 5★ does not beat many 4.5★
91
+ top: '(stars_sum + 3.0 * 2) / (stars_n + 2) DESC, installs DESC, created_at DESC',
92
+ new: 'created_at DESC',
93
+ popular: 'installs DESC, stars_n DESC, created_at DESC',
94
+ };
95
+
96
+ export function listMods({ sort = 'top', q = '', limit = 60 } = {}) {
97
+ const like = `%${String(q).toLowerCase().replace(/[%_]/g, '').slice(0, 40)}%`;
98
+ return db()
99
+ .prepare(`SELECT * FROM community_mods WHERE status = 'approved' AND (lower(id) LIKE ? OR lower(mod) LIKE ? OR lower(author) LIKE ?) ORDER BY ${ORDER[sort] || ORDER.top} LIMIT ?`)
100
+ .all(like, like, like, Math.min(100, Math.max(1, Number(limit) || 60)));
101
+ }
102
+
103
+ /** A model a mod points at must already be on 7ots.com (uploaded first through /mods/files). */
104
+ function modelsMissing(mod) {
105
+ const urls = [mod.model?.url].filter((u) => MODEL.test(u || ''));
106
+ return urls.filter((u) => !existsSync(join(dataDir(), 'mods', u.slice('/mods/'.length))));
107
+ }
108
+
109
+ export function createMods({ send, readJson }) {
110
+ /** Public half. @returns {Promise<boolean>} */
111
+ async function publicApi(req, res, path) {
112
+ res.setHeader('Access-Control-Allow-Origin', '*');
113
+ if (req.method === 'OPTIONS') return send(res, 204), true;
114
+ if (req.method !== 'GET') return false;
115
+ if (path === '' || path === '/') {
116
+ const u = new URL(req.url, 'http://x');
117
+ return send(res, 200, { mods: listMods({ sort: u.searchParams.get('sort'), q: u.searchParams.get('q') || '', limit: u.searchParams.get('limit') }).map((r) => view(r)) }), true;
118
+ }
119
+ const m = /^\/([a-z0-9-]{1,32})$/.exec(path);
120
+ if (m) {
121
+ const row = db().prepare("SELECT * FROM community_mods WHERE id = ? AND status = 'approved'").get(m[1]);
122
+ return row ? send(res, 200, { mod: view(row) }) : send(res, 404, { error: 'not found' }), true;
123
+ }
124
+ return false;
125
+ }
126
+
127
+ /** Signed-in half (account from the session or a device). path is after /mods. @returns {Promise<boolean>} */
128
+ async function accountApi(req, res, account, path) {
129
+ const d = db();
130
+ const admin = isModAdmin(account);
131
+ const now = Date.now();
132
+ if (path === '' && req.method === 'GET') {
133
+ const u = new URL(req.url, 'http://x');
134
+ return send(res, 200, { mods: listMods({ sort: u.searchParams.get('sort'), q: u.searchParams.get('q') || '' }).map((r) => view(r, account)) }), true;
135
+ }
136
+ if (path === '/mine' && req.method === 'GET') {
137
+ const mine = d.prepare('SELECT * FROM community_mods WHERE account_id = ? ORDER BY updated_at DESC').all(account.id).map((r) => view(r, account));
138
+ const pending = admin ? d.prepare("SELECT * FROM community_mods WHERE status IN ('pending', 'hidden') ORDER BY reports DESC, created_at").all().map((r) => ({ ...view(r, account), reports: r.reports })) : [];
139
+ return send(res, 200, { mods: mine, admin, pending }), true;
140
+ }
141
+ if (path === '/files' && req.method === 'POST') {
142
+ const ext = new URL(req.url, 'http://x').searchParams.get('ext');
143
+ if (!['glb', 'vrm'].includes(ext)) return send(res, 400, { error: 'ext' }), true;
144
+ const chunks = [];
145
+ let size = 0;
146
+ for await (const c of req) {
147
+ size += c.length;
148
+ if (size > MAX_FILE) return send(res, 413, { error: 'max 20 MB' }), true;
149
+ chunks.push(c);
150
+ }
151
+ const buf = Buffer.concat(chunks);
152
+ if (buf.length < 20 || buf.toString('latin1', 0, 4) !== 'glTF') return send(res, 400, { error: 'not a glTF binary (.glb/.vrm)' }), true;
153
+ const h = createHash('sha256').update(buf).digest('hex').slice(0, 16);
154
+ mkdirSync(join(dataDir(), 'mods'), { recursive: true });
155
+ writeFileSync(join(dataDir(), 'mods', `${h}.${ext}`), buf);
156
+ return send(res, 200, { url: `/mods/${h}.${ext}` }), true;
157
+ }
158
+ if (path === '' && req.method === 'POST') {
159
+ const body = await readJson(req, MAX_MOD_JSON + 4096);
160
+ const mod = normalizeMod(body?.mod);
161
+ if (!mod) return send(res, 400, { error: 'mod' }), true;
162
+ if (!mod.model && !mod.parts.length && !Object.keys(mod.character).length) return send(res, 400, { error: 'empty mod' }), true;
163
+ const missing = modelsMissing(mod);
164
+ if (missing.length) return send(res, 409, { error: 'upload the model first', missing }), true;
165
+ const prev = d.prepare('SELECT account_id FROM community_mods WHERE id = ?').get(mod.id);
166
+ if (prev && prev.account_id !== account.id) return send(res, 409, { error: 'id taken' }), true;
167
+ const json = JSON.stringify({ ...mod, by: mod.by || authorOf(account) });
168
+ if (prev) d.prepare("UPDATE community_mods SET mod = ?, status = ?, updated_at = ? WHERE id = ?").run(json, admin ? 'approved' : 'pending', now, mod.id);
169
+ else d.prepare('INSERT INTO community_mods (id, account_id, author, mod, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)').run(mod.id, account.id, authorOf(account), json, admin ? 'approved' : 'pending', now, now);
170
+ return send(res, prev ? 200 : 201, { mod: view(d.prepare('SELECT * FROM community_mods WHERE id = ?').get(mod.id), account) }), true;
171
+ }
172
+ const m = /^\/([a-z0-9-]{1,32})(\/(install|rate|report|review))?$/.exec(path);
173
+ if (!m) return false;
174
+ const row = d.prepare('SELECT * FROM community_mods WHERE id = ?').get(m[1]);
175
+ // authors and admins see their mod whatever its status; everyone else only approved ones
176
+ if (!row || (row.status !== 'approved' && row.account_id !== account.id && !admin)) return send(res, 404, { error: 'not found' }), true;
177
+ const action = m[3];
178
+ if (!action && req.method === 'GET') return send(res, 200, { mod: view(row, account) }), true;
179
+ if (!action && req.method === 'DELETE') {
180
+ if (row.account_id !== account.id && !admin) return send(res, 403, { error: 'not yours' }), true;
181
+ d.prepare('DELETE FROM community_mods WHERE id = ?').run(row.id);
182
+ return send(res, 200, { ok: true }), true;
183
+ }
184
+ if (req.method !== 'POST') return send(res, 405, { error: 'method' }), true;
185
+ if (action === 'install') {
186
+ const r = d.prepare('INSERT OR IGNORE INTO mod_installs (mod_id, account_id, at) VALUES (?, ?, ?)').run(row.id, account.id, now);
187
+ if (r.changes) d.prepare('UPDATE community_mods SET installs = installs + 1 WHERE id = ?').run(row.id);
188
+ return send(res, 200, { mod: view(d.prepare('SELECT * FROM community_mods WHERE id = ?').get(row.id), account) }), true;
189
+ }
190
+ if (action === 'rate') {
191
+ const stars = Math.round(Number((await readJson(req, 1024))?.stars));
192
+ if (!(stars >= 1 && stars <= 5)) return send(res, 400, { error: 'stars 1-5' }), true;
193
+ if (row.account_id === account.id) return send(res, 403, { error: 'own mod' }), true;
194
+ if (!d.prepare('SELECT 1 FROM mod_installs WHERE mod_id = ? AND account_id = ?').get(row.id, account.id)) return send(res, 403, { error: 'install it first' }), true;
195
+ const old = d.prepare('SELECT stars FROM mod_ratings WHERE mod_id = ? AND account_id = ?').get(row.id, account.id);
196
+ d.prepare('INSERT INTO mod_ratings (mod_id, account_id, stars, at) VALUES (?, ?, ?, ?) ON CONFLICT(mod_id, account_id) DO UPDATE SET stars = excluded.stars, at = excluded.at').run(row.id, account.id, stars, now);
197
+ d.prepare('UPDATE community_mods SET stars_sum = stars_sum + ?, stars_n = stars_n + ? WHERE id = ?').run(stars - (old?.stars || 0), old ? 0 : 1, row.id);
198
+ return send(res, 200, { mod: view(d.prepare('SELECT * FROM community_mods WHERE id = ?').get(row.id), account) }), true;
199
+ }
200
+ if (action === 'report') {
201
+ const reason = String((await readJson(req, 4096))?.reason || '').replace(/[\u0000-\u001f]/g, ' ').trim().slice(0, 300);
202
+ const r = d.prepare('INSERT OR IGNORE INTO mod_reports (mod_id, account_id, reason, at) VALUES (?, ?, ?, ?)').run(row.id, account.id, reason, now);
203
+ if (r.changes) {
204
+ d.prepare('UPDATE community_mods SET reports = reports + 1 WHERE id = ?').run(row.id);
205
+ const hide = Number(process.env.MOD_REPORTS_HIDE || 3);
206
+ d.prepare("UPDATE community_mods SET status = 'hidden' WHERE id = ? AND status = 'approved' AND reports >= ?").run(row.id, hide);
207
+ }
208
+ return send(res, 200, { ok: true }), true;
209
+ }
210
+ if (action === 'review') {
211
+ if (!admin) return send(res, 403, { error: 'admins only' }), true;
212
+ const status = (await readJson(req, 1024))?.status;
213
+ if (!STATUSES.includes(status)) return send(res, 400, { error: 'status' }), true;
214
+ d.prepare('UPDATE community_mods SET status = ?, reports = CASE WHEN ? = \'approved\' THEN 0 ELSE reports END, updated_at = ? WHERE id = ?').run(status, status, now, row.id);
215
+ if (status === 'approved') d.prepare('DELETE FROM mod_reports WHERE mod_id = ?').run(row.id);
216
+ return send(res, 200, { mod: view(d.prepare('SELECT * FROM community_mods WHERE id = ?').get(row.id), account) }), true;
217
+ }
218
+ return false;
219
+ }
220
+
221
+ return { publicApi, accountApi };
222
+ }
@@ -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);