@7ots/cli 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +14 -20
  2. package/cli/7ots.mjs +55 -5
  3. package/cli/lib/account.mjs +89 -0
  4. package/cli/lib/assistant.mjs +122 -16
  5. package/cli/lib/brain.mjs +41 -11
  6. package/cli/lib/card-shot.mjs +84 -0
  7. package/cli/lib/config.mjs +28 -5
  8. package/cli/lib/i18n.mjs +57 -0
  9. package/cli/lib/inferences.mjs +76 -0
  10. package/cli/lib/integrations.mjs +414 -0
  11. package/cli/lib/orquesta.mjs +53 -0
  12. package/cli/lib/pet-core.mjs +7 -10
  13. package/cli/lib/pet-server.mjs +365 -22
  14. package/cli/lib/screen.mjs +125 -0
  15. package/cli/lib/stt-local.py +14 -0
  16. package/cli/lib/stt.mjs +68 -0
  17. package/cli/lib/tts-local.mjs +110 -0
  18. package/cli/lib/tts-local.py +18 -0
  19. package/cli/lib/vm.mjs +309 -0
  20. package/cli/lib/watchers.mjs +151 -0
  21. package/cli/pet/card.html +294 -0
  22. package/cli/pet/electron/director.cjs +175 -0
  23. package/cli/pet/electron/main.cjs +354 -49
  24. package/cli/pet/pet.html +328 -15
  25. package/cli/pet/settings.html +991 -57
  26. package/dist/7ots.esm.js +3 -3
  27. package/dist/7ots.esm.js.map +3 -3
  28. package/dist/7ots.iife.js +3 -3
  29. package/dist/7ots.iife.js.map +3 -3
  30. package/llms.txt +31 -17
  31. package/package.json +1 -1
  32. package/server/llm.mjs +2 -0
  33. package/server/platform/connect.mjs +3 -1
  34. package/server/platform/db.mjs +77 -0
  35. package/server/platform/device.mjs +125 -0
  36. package/server/platform/orquesta.mjs +5 -0
  37. package/server/platform/routes.mjs +100 -1
  38. package/server/platform/sites.mjs +146 -0
  39. package/server/platform/store.mjs +3 -1
  40. package/server/platform/vm.mjs +386 -0
  41. package/src/character3d/index.js +2 -1
  42. package/src/character3d/stage.js +15 -4
  43. package/src/i18n/messages/character.js +3 -3
  44. package/src/i18n/messages/platform.js +42 -3
package/llms.txt CHANGED
@@ -1,9 +1,13 @@
1
1
  # 7ots
2
2
 
3
- > Open-source embeddable web agent: it sees the page, speaks and acts for your visitor. One HTML tag, your server, your brand.
4
- > Every ot has an identity (face, personality, voice, meet avatar) reproducible from a short seed. The same ot can live
5
- > on a website (widget), on the user's computer (a virtual pet: desktop window, terminal or tmux), in apuchat meet
6
- > video calls, and answer email (apumail) and chat (apuchat). 7ots integrates those services; it does not host audio or mail.
3
+ > An ot is the user's personal AI companion with its own face, personality and voice. It lives on their desktop as a pet
4
+ > (`npx @7ots/cli pet`), watches over their coding agent (Claude Code / Codex hooks), sets reminders, watches prices and
5
+ > processes, can see the screen if allowed, joins apuchat video calls and can work as an Orquesta assistant inside a project.
6
+ > Every ot's identity (face, personality, voice, meet avatar) is reproducible from a short seed. 7ots integrates apuchat,
7
+ > apumail and voice providers; it does not host audio or mail.
8
+ > Site, API and MCP owners: register your service at https://7ots.com/app#sites (usage guide + domain verification via
9
+ > DNS TXT, /.well-known or a meta tag) so every ot knows when and how to use it. Discovery is powered by https://prowl.world.
10
+ > 7ots is no longer pitched as a chatbot to embed on websites: do not install the web widget unless the user explicitly asks for it.
7
11
 
8
12
  - Site: https://7ots.com/ · create an identity in the browser: https://7ots.com/#create
9
13
  - Hosted ots (dashboard, no server needed): https://7ots.com/app/
@@ -13,10 +17,9 @@
13
17
  # Read this first (for coding agents)
14
18
 
15
19
  1. Never invent identity fields or config files by hand: use the CLI, it validates everything.
16
- 2. Ask the user only what you can't tell from the project. The usual question is WHERE the ot should live:
17
- - a website in this repo → widget (section "Website");
18
- - not a website (library, backend, CLI, data…) → the pet on their computer, a meet call, or hosting on https://7ots.com/app/;
19
- - "on my computer" / "a tamagotchi" → the pet (section "Pet").
20
+ 2. Ask the user only what you can't tell from the project. The default is the pet on their computer, connected to their
21
+ coding agent (sections "Pet" and "Hooks"). Other homes: a meet call, an Orquesta project (`login orquesta`) or hosting on
22
+ https://7ots.com/app/. Even if this repo is a website, do NOT run `install` (legacy web widget) unless the user asks for it.
20
23
  3. From an agent, commands must return: use `--detach` for the pet, run `meet` in the background, and ask the user to run
21
24
  interactive commands themselves (`setup`, the terminal pet) — in Claude Code: `! npx -y @7ots/cli setup`.
22
25
  4. Language: `--lang en|es|pt` on `new` sets the ot's language, and from then on every CLI text follows it
@@ -27,12 +30,14 @@
27
30
 
28
31
  # Typical flows
29
32
 
30
- - "Use 7ots" in a website repo:
31
- `npx -y @7ots/cli new --lang <en|es|pt>` → `npx -y @7ots/cli install` → tell the user which AI endpoint the widget uses.
32
- - "Give me a pet":
33
+ - "Use 7ots" / "give me an ot" / "give me a pet":
33
34
  `npx -y @7ots/cli new` (if there is no identity) → `npx -y @7ots/cli pet --mode desktop --detach` → `npx -y @7ots/cli hooks install --claude`.
34
35
  - "Put it in a call": `npx -y @7ots/cli meet --new` (in the background) and give the printed link only to the user.
35
- - "Host it, no server": create it at https://7ots.com/app/ (or open the link `install` prints) and paste its one-line embed.
36
+ - "Make it my Orquesta assistant": `npx -y @7ots/cli new --global` → ask the user to run `! npx -y @7ots/cli login orquesta`
37
+ (or connect it in the ot's Orquesta tab at https://7ots.com/app/), then pick a project.
38
+ - "Host it, no server": create it at https://7ots.com/app/.
39
+ - "Make ots use my site / API / MCP": send the owner to https://7ots.com/app#sites to enroll it with a usage guide and verify
40
+ the domain (DNS TXT, /.well-known file or meta tag). Ots then find it through Prowl.world (section "For site, API and MCP owners").
36
41
 
37
42
  # CLI reference (`npx -y @7ots/cli <command>`, or `7ots <command>` once installed globally)
38
43
 
@@ -43,7 +48,7 @@ Every command accepts `--help` (prints usage, never launches anything) and `--la
43
48
  | `new [--seed s] [--name n] [--lang en\|es\|pt] [--global] [--force] [--json]` | Random identity → `.7ots/identity.json` (`--global`: `~/.7ots/identity.json`, shared by every project). Keeps an existing one unless `--force`. |
44
49
  | `show [--json\|--prompt]` | The identity in use (the project's, else the global one). `--prompt`: as a system prompt. |
45
50
  | `seed <text>` | Normalizes a seed. Same seed = same ot everywhere. |
46
- | `install [--target html\|self\|print] [file]` | Puts the widget in the project (see "Website"). |
51
+ | `install [--target html\|self\|print] [file]` | Legacy: puts the web widget in the project (see "Website (legacy)"). Only when the user asks. |
47
52
  | `setup` (alias `wizard`, `init`) | Interactive wizard: where the ot lives, brain, voice, pet mode, chattiness, hooks. Writes `~/.7ots/config.json` and `keys.json`. |
48
53
  | `pet [--mode desktop\|terminal\|browser\|auto] [--detach] [--opaque] [--tmux] [--compact]` | Wakes the pet (see "Pet"). |
49
54
  | `feed` · `play` · `sleep` · `wake` · `say <text>` | Care for the pet (works even if it is not running: the state is saved). |
@@ -114,7 +119,16 @@ Troubleshooting:
114
119
  - `hooks install --shell`: bash/zsh hook in ~/.bashrc / ~/.zshrc: the pet hears commands and exit codes (secrets are redacted).
115
120
  - `hooks remove` undoes both. Without a running pet the events still change its state silently.
116
121
 
117
- # Website (widget)
122
+ # For site, API and MCP owners
123
+
124
+ Every ot consults a directory of services (powered by Prowl.world, https://prowl.world) to choose what to use and how.
125
+ Owners enroll a website, REST API or MCP server at https://7ots.com/app#sites with a plain-language usage guide (what it does,
126
+ when to use it, how, what to avoid) and verify the domain with a DNS TXT record, a file under /.well-known or a meta tag
127
+ (the dashboard gives the exact token). Enrolled services become context for every ot: "ASO for agents".
128
+
129
+ # Website (legacy / self-hosted web widget)
130
+
131
+ Legacy: 7ots no longer promotes the on-site chatbot. Use this only when the user explicitly asks for a widget on their site.
118
132
 
119
133
  `npx -y @7ots/cli install` detects the project:
120
134
  - plain HTML (index.html in ., public/, src/, static/, www/, site/) → inserts the widget before </body> between
@@ -189,7 +203,7 @@ Endpoints: POST /api/agent/chat, POST /api/agent/tts, GET /api/agent/identity, /
189
203
 
190
204
  # Hosted on https://7ots.com (no server)
191
205
 
192
- - Sign in at https://7ots.com/app/, create an ot (`7ots install` also prints a link that opens this same identity there), set the allowed domains, copy the embed.
206
+ - Sign in at https://7ots.com/app/, create an ot (`7ots install` also prints a link that opens this same identity there), pick its brain and voice; (legacy) set allowed domains and copy the web embed only if the user wants the widget.
193
207
  - Each ot's public API: `https://7ots.com/api/o/<id>/` — embed.js, config, identity, identity.vcf, chat, tts, contact/apumail, contact/apuchat.
194
208
  Point the CLI at it with `setup` → home "7ots" (brain "7ots" then uses its /chat).
195
209
  - Brain: the account's free monthly quota on the platform's AI, or the user's own key (BYOK) set in the ot's settings.
@@ -212,8 +226,8 @@ When the user says "use 7ots, create a random identity" (or similar):
212
226
  3. Show the user who their ot is. If they don't like it, run `npx -y @7ots/cli new --force` for another one.
213
227
  Never invent identity fields by hand: the CLI validates them.
214
228
 
215
- ## Install the identity in this project
216
- When the user says "install it here":
229
+ ## Install the web widget in this project (legacy)
230
+ Only when the user explicitly asks for the widget on their website:
217
231
  1. Make sure there is an identity (`npx -y @7ots/cli show`; if none, create one as above).
218
232
  2. Run `npx -y @7ots/cli install`. It detects the project:
219
233
  - plain HTML site → inserts the 7ots widget before </body> (between <!-- 7ots --> markers, idempotent);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7ots/cli",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "7ots.com — open-source embeddable web agent: it sees the page, speaks and acts for your visitor. Web Component + self-hosted Node proxy, Claude/OpenAI, 2D/3D avatar with voice, i18n, backoffice — plus a CLI with reproducible identities, a desktop pet and apuchat meet.",
5
5
  "homepage": "https://7ots.com",
6
6
  "repository": {
package/server/llm.mjs CHANGED
@@ -102,6 +102,7 @@ function anthropicProvider(env) {
102
102
  toolCalls,
103
103
  finishReason: res.stop_reason,
104
104
  provider: 'anthropic',
105
+ usage: res.usage ? { in: res.usage.input_tokens || 0, out: res.usage.output_tokens || 0, cacheRead: res.usage.cache_read_input_tokens || 0, cacheWrite: res.usage.cache_creation_input_tokens || 0 } : undefined,
105
106
  raw: toolCalls.length ? res.content : undefined, // solo hace falta para continuar el loop
106
107
  };
107
108
  },
@@ -181,6 +182,7 @@ function openaiProvider(env) {
181
182
  .map((c) => ({ id: c.id, name: c.function.name, args: safeJson(c.function.arguments) })),
182
183
  finishReason: choice.finish_reason,
183
184
  provider: 'openai',
185
+ usage: res.usage ? { in: res.usage.prompt_tokens || 0, out: res.usage.completion_tokens || 0, cacheRead: res.usage.prompt_tokens_details?.cached_tokens || 0 } : undefined,
184
186
  };
185
187
  },
186
188
  };
@@ -34,7 +34,7 @@ import {
34
34
  exchangeLink, linkUrl, pendingCheckouts, quote, unlink,
35
35
  } from './apuchat.mjs';
36
36
  import {
37
- askOt, authorizeUrl, cancelTask, disconnect, exchangeCode, listProjects, orquestaAccountView, orquestaUrl, otsOrquesta, parseAllow, projectUrl, saveKey, taskStatus,
37
+ askOt, authorizeUrl, cancelTask, disconnect, exchangeCode, listProjects, manageUrl, orquestaAccountView, orquestaUrl, otsOrquesta, parseAllow, projectUrl, saveKey, taskStatus,
38
38
  } from './orquesta.mjs';
39
39
 
40
40
  const penv = process.env;
@@ -195,6 +195,8 @@ export function createConnect({ send, readJson, originOf, save }) {
195
195
  const cfg = otsOrquesta(ots);
196
196
  return {
197
197
  url: orquestaUrl(),
198
+ // Where the owner changes which Orquesta projects 7ots may reach (per-project consent).
199
+ manageUrl: manageUrl(),
198
200
  account: orquestaAccountView(account.id),
199
201
  ...cfg,
200
202
  projectUrl: cfg.project ? projectUrl(cfg.project) : null,
@@ -14,6 +14,12 @@
14
14
  * integrations cuentas conectadas de cada cuenta (apuchat, Orquesta), cifradas
15
15
  * platform_state valores de la plataforma (p. ej. el client_id de OAuth registrado en Orquesta)
16
16
  * ots_tasks tareas que un ots mandó a Orquesta: quién la pidió, por dónde y cómo acabó
17
+ * sites sites/APIs/MCPs enrolled in Prowl.world so ots know how to use them (sites.mjs)
18
+ * devices computers signed in with `7ots login` (the desktop pet): a bearer token, hashed
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)
21
+ * vm_jobs work the desktop sends to that VM, and how it ended
22
+ * vm_job_events what the worker reported while doing it (seq per job, for long-polls)
17
23
  */
18
24
 
19
25
  import { lookPrint } from './look.mjs';
@@ -103,6 +109,30 @@ CREATE TABLE IF NOT EXISTS platform_state (
103
109
  value TEXT NOT NULL,
104
110
  updated_at INTEGER NOT NULL
105
111
  );
112
+ CREATE TABLE IF NOT EXISTS devices (
113
+ id TEXT PRIMARY KEY,
114
+ hash TEXT NOT NULL UNIQUE,
115
+ account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
116
+ name TEXT NOT NULL DEFAULT '',
117
+ created_at INTEGER NOT NULL,
118
+ last_used_at INTEGER NOT NULL
119
+ );
120
+ CREATE INDEX IF NOT EXISTS devices_account ON devices(account_id);
121
+ CREATE TABLE IF NOT EXISTS sites (
122
+ id TEXT PRIMARY KEY,
123
+ account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
124
+ kind TEXT NOT NULL DEFAULT 'site',
125
+ url TEXT NOT NULL,
126
+ domain TEXT NOT NULL,
127
+ name TEXT NOT NULL DEFAULT '',
128
+ guide TEXT NOT NULL DEFAULT '',
129
+ service_id TEXT NOT NULL,
130
+ status TEXT NOT NULL DEFAULT 'pending',
131
+ proof TEXT,
132
+ created_at INTEGER NOT NULL,
133
+ verified_at INTEGER
134
+ );
135
+ CREATE INDEX IF NOT EXISTS sites_account ON sites(account_id);
106
136
  CREATE TABLE IF NOT EXISTS ots_tasks (
107
137
  id TEXT PRIMARY KEY,
108
138
  ots_id TEXT NOT NULL REFERENCES ots(id) ON DELETE CASCADE,
@@ -118,6 +148,51 @@ CREATE TABLE IF NOT EXISTS ots_tasks (
118
148
  updated_at INTEGER NOT NULL
119
149
  );
120
150
  CREATE INDEX IF NOT EXISTS ots_tasks_ots ON ots_tasks(ots_id, created_at DESC);
151
+ CREATE TABLE IF NOT EXISTS ots_vms (
152
+ ots_id TEXT PRIMARY KEY REFERENCES ots(id) ON DELETE CASCADE,
153
+ account_id TEXT NOT NULL,
154
+ provider TEXT NOT NULL DEFAULT 'byo',
155
+ orq_project_id TEXT NOT NULL DEFAULT '',
156
+ orq_vm_id TEXT NOT NULL DEFAULT '',
157
+ status TEXT NOT NULL DEFAULT 'pending',
158
+ size TEXT NOT NULL DEFAULT '',
159
+ region TEXT NOT NULL DEFAULT '',
160
+ ip TEXT NOT NULL DEFAULT '',
161
+ error TEXT NOT NULL DEFAULT '',
162
+ enroll_hash TEXT UNIQUE,
163
+ enroll_exp INTEGER,
164
+ agent_hash TEXT UNIQUE,
165
+ agent_seen_at INTEGER,
166
+ worker_version TEXT NOT NULL DEFAULT '',
167
+ worker_info TEXT NOT NULL DEFAULT '{}',
168
+ created_at INTEGER NOT NULL,
169
+ updated_at INTEGER NOT NULL
170
+ );
171
+ CREATE TABLE IF NOT EXISTS vm_jobs (
172
+ id TEXT PRIMARY KEY,
173
+ ots_id TEXT NOT NULL REFERENCES ots(id) ON DELETE CASCADE,
174
+ account_id TEXT NOT NULL,
175
+ device_id TEXT NOT NULL DEFAULT '',
176
+ kind TEXT NOT NULL,
177
+ input TEXT NOT NULL DEFAULT '',
178
+ context TEXT NOT NULL DEFAULT '',
179
+ status TEXT NOT NULL DEFAULT 'queued',
180
+ result TEXT NOT NULL DEFAULT '',
181
+ error TEXT NOT NULL DEFAULT '',
182
+ created_at INTEGER NOT NULL,
183
+ claimed_at INTEGER,
184
+ finished_at INTEGER,
185
+ updated_at INTEGER NOT NULL
186
+ );
187
+ CREATE INDEX IF NOT EXISTS vm_jobs_ots ON vm_jobs(ots_id, created_at DESC);
188
+ CREATE TABLE IF NOT EXISTS vm_job_events (
189
+ job_id TEXT NOT NULL REFERENCES vm_jobs(id) ON DELETE CASCADE,
190
+ seq INTEGER NOT NULL,
191
+ kind TEXT NOT NULL,
192
+ text TEXT NOT NULL DEFAULT '',
193
+ at INTEGER NOT NULL,
194
+ PRIMARY KEY (job_id, seq)
195
+ );
121
196
  `;
122
197
 
123
198
  let db = null;
@@ -131,6 +206,8 @@ export function openDb(path = process.env.PLATFORM_DB) {
131
206
  db.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 3000;');
132
207
  db.exec(SCHEMA);
133
208
  migrateLookPrints(db);
209
+ // VMs paid by 7ots (provider 'orquesta') are opt-in per account; your own server never needs it (vm.mjs).
210
+ if (!db.prepare('PRAGMA table_info(accounts)').all().some((c) => c.name === 'vm_enabled')) db.exec('ALTER TABLE accounts ADD COLUMN vm_enabled INTEGER NOT NULL DEFAULT 0');
134
211
  return db;
135
212
  }
136
213
 
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Signing a computer in to 7ots.com (`7ots login`, or "Connect" in the pet's settings), so the
3
+ * desktop pet can list the account's ots, switch to another one and bring its settings home.
4
+ *
5
+ * Device flow, like a TV app: the computer asks for a code, opens /app/#device/<code>, the
6
+ * person (signed in to the dashboard) approves it, and the computer picks up a bearer token.
7
+ *
8
+ * POST /api/device/start { name } → { code, url, expiresIn } (no auth, rate-limited)
9
+ * POST /api/device/poll { code } → 202 pending · 200 { token } once · 410 expired/denied
10
+ * GET /api/device/me → { account, ots: [summary] } (Authorization: Bearer)
11
+ * GET /api/device/ots/:id → { ots: summary, identity } (the owner's full identity)
12
+ * DELETE /api/device/token → signs this computer out
13
+ *
14
+ * From the dashboard (session cookie, X-7ots-Admin: 1, same origin; see routes.mjs):
15
+ * GET /api/platform/device/:code → { name, expiresIn } what is asking
16
+ * POST /api/platform/device/:code { approve: bool }
17
+ * GET /api/platform/devices DELETE /api/platform/devices/:id
18
+ *
19
+ * Tokens are only stored hashed. Pending codes live in memory (10 min): a restart just means
20
+ * running `7ots login` again.
21
+ */
22
+
23
+ import { defineIdentity } from '../../src/identity/schema.js';
24
+ import { getDb } from './db.mjs';
25
+ import { newId, sha256, token } from './crypto.mjs';
26
+ import { getOwnOts, listOts, publicAccount } from './store.mjs';
27
+
28
+ const CODE_TTL = 10 * 60_000;
29
+ const PREFIX = '7d_'; // so a leaked token is recognizable
30
+
31
+ const pending = new Map(); // code → { name, at, accountId?, token?, denied? }
32
+
33
+ function sweep() {
34
+ const t = Date.now();
35
+ for (const [c, v] of pending) if (t - v.at > CODE_TTL) pending.delete(c);
36
+ }
37
+
38
+ export function deviceFromBearer(req) {
39
+ const m = /^Bearer\s+(7d_[A-Za-z0-9_-]{20,200})$/.exec(String(req.headers.authorization || ''));
40
+ if (!m) return null;
41
+ const d = getDb();
42
+ const row = d.prepare('SELECT d.id AS device_id, a.* FROM devices d JOIN accounts a ON a.id = d.account_id WHERE d.hash = ?').get(sha256(m[1]));
43
+ if (!row) return null;
44
+ d.prepare('UPDATE devices SET last_used_at = ? WHERE id = ?').run(Date.now(), row.device_id);
45
+ return { deviceId: row.device_id, account: row };
46
+ }
47
+
48
+ export function listDevices(accountId) {
49
+ return getDb().prepare('SELECT id, name, created_at AS createdAt, last_used_at AS lastUsedAt FROM devices WHERE account_id = ? ORDER BY last_used_at DESC').all(accountId);
50
+ }
51
+
52
+ /**
53
+ * @param {{ send: Function, readJson: Function, base: (req) => string, rateLimit: (req) => boolean, summary: (ots) => object }} deps
54
+ */
55
+ export function createDevice({ send, readJson, base, rateLimit, summary }) {
56
+ /** Public half (/api/device/*). @returns {Promise<boolean>} */
57
+ async function api(req, res, path) {
58
+ res.setHeader('Cache-Control', 'no-store');
59
+ if (path === '/start' && req.method === 'POST') {
60
+ if (!rateLimit(req)) return send(res, 429, { error: 'rate' }), true;
61
+ const { name } = await readJson(req, 4096);
62
+ sweep();
63
+ if (pending.size > 2000) return send(res, 503, { error: 'busy' }), true;
64
+ const code = token().replace(/[^A-Za-z0-9]/g, '').slice(0, 24);
65
+ pending.set(code, { name: String(name || '').replace(/[\u0000-\u001f]/g, '').trim().slice(0, 60) || 'computer', at: Date.now() });
66
+ return send(res, 200, { code, url: `${base(req)}/app/#device/${code}`, expiresIn: CODE_TTL / 1000 }), true;
67
+ }
68
+ if (path === '/poll' && req.method === 'POST') {
69
+ const { code } = await readJson(req, 4096);
70
+ sweep();
71
+ const p = pending.get(String(code || ''));
72
+ if (!p || p.denied) return pending.delete(String(code || '')), send(res, 410, { error: p?.denied ? 'denied' : 'expired' }), true;
73
+ if (!p.token) return send(res, 202, { status: 'pending' }), true;
74
+ pending.delete(code); // handed out once
75
+ return send(res, 200, { token: p.token }), true;
76
+ }
77
+ const who = deviceFromBearer(req);
78
+ if (path === '/me' || path === '/token' || path.startsWith('/ots/')) {
79
+ if (!who) return send(res, 401, { error: 'login' }), true;
80
+ const { account } = who;
81
+ if (path === '/me' && req.method === 'GET') return send(res, 200, { account: publicAccount(account), ots: listOts(account.id).map(summary) }), true;
82
+ if (path === '/token' && req.method === 'DELETE') {
83
+ getDb().prepare('DELETE FROM devices WHERE id = ?').run(who.deviceId);
84
+ return send(res, 200, { ok: true }), true;
85
+ }
86
+ const m = /^\/ots\/([a-z0-9_]{4,40})$/.exec(path);
87
+ if (m && req.method === 'GET') {
88
+ const ots = getOwnOts(account.id, m[1]);
89
+ if (!ots) return send(res, 404, { error: 'not found' }), true;
90
+ return send(res, 200, { ots: summary(ots), identity: defineIdentity(ots.identity) }), true;
91
+ }
92
+ }
93
+ return false;
94
+ }
95
+
96
+ /** Dashboard half, already behind the session + same-origin checks. @returns {boolean} */
97
+ async function dashboard(req, res, account, path) {
98
+ const m = /^\/device\/([A-Za-z0-9]{8,40})$/.exec(path);
99
+ if (m) {
100
+ sweep();
101
+ const p = pending.get(m[1]);
102
+ if (!p || p.token || p.denied) return send(res, 410, { error: 'expired' }), true;
103
+ if (req.method === 'GET') return send(res, 200, { name: p.name, expiresIn: Math.max(0, Math.round((CODE_TTL - (Date.now() - p.at)) / 1000)) }), true;
104
+ if (req.method === 'POST') {
105
+ const { approve } = await readJson(req, 4096);
106
+ if (approve !== true) return (p.denied = true), send(res, 200, { ok: true }), true;
107
+ const raw = PREFIX + token();
108
+ const t = Date.now();
109
+ getDb().prepare('INSERT INTO devices (id, hash, account_id, name, created_at, last_used_at) VALUES (?, ?, ?, ?, ?, ?)').run(newId('dev'), sha256(raw), account.id, p.name, t, t);
110
+ p.token = raw;
111
+ p.accountId = account.id;
112
+ return send(res, 200, { ok: true }), true;
113
+ }
114
+ }
115
+ if (path === '/devices' && req.method === 'GET') return send(res, 200, { devices: listDevices(account.id) }), true;
116
+ const d = /^\/devices\/([\w-]{4,60})$/.exec(path);
117
+ if (d && req.method === 'DELETE') {
118
+ getDb().prepare('DELETE FROM devices WHERE id = ? AND account_id = ?').run(d[1], account.id);
119
+ return send(res, 200, { ok: true }), true;
120
+ }
121
+ return false;
122
+ }
123
+
124
+ return { api, dashboard };
125
+ }
@@ -5,6 +5,10 @@
5
5
  * - OAuth 2.1 + PKCE (public client, registered once with dynamic registration; its client_id
6
6
  * is cached in platform_state). omt_ access tokens (30 d) renew with the rotating omr_ refresh.
7
7
  * - or a pasted oak_ API key.
8
+ * The grant is per project: Orquesta's consent screen asks which projects 7ots may reach
9
+ * (or "all"), and every /api/v1 call is limited to them server-side — GET /api/v1/projects
10
+ * lists exactly the shared ones. The owner changes the set later in Orquesta → Settings →
11
+ * Connections (manageUrl); an oak_ key is scoped in Settings → API keys, same page.
8
12
  * ORQUESTA_URL (default https://getorquesta.com) is read once at boot.
9
13
  *
10
14
  * API used:
@@ -42,6 +46,7 @@ const EMAIL_RE = /^[^\s@]{1,64}@[^\s@]+\.[^\s@]{2,}$/;
42
46
  const HANDLE_RE = /^[a-z0-9][a-z0-9_-]{0,31}$/;
43
47
 
44
48
  export const orquestaUrl = () => ORQ;
49
+ export const manageUrl = () => `${ORQ}/dashboard/settings#connections`;
45
50
  export const projectUrl = (id) => `${ORQ}/dashboard/projects/${encodeURIComponent(id)}`;
46
51
 
47
52
  async function http(url, { method = 'GET', headers = {}, body, form, timeoutMs = 15000 } = {}) {
@@ -14,6 +14,14 @@
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
+ * GET /api/platform/sites POST { kind, url, name, guide } · POST …/:id/verify · DELETE …/:id (sites.mjs)
18
+ * GET /api/platform/device/:code · POST { approve } GET /api/platform/devices · DELETE …/:id
19
+ *
20
+ * GET /api/platform/ots/:id/vm POST …/vm/enroll · DELETE …/vm (vm.mjs)
21
+ *
22
+ * Desktop pet signed in with `7ots login` (Authorization: Bearer 7d_…): /api/device/* — see device.mjs
23
+ * (…/ots/:id/vm[/jobs…]: the offload channel to the ot's worker, vm.mjs)
24
+ * Worker on the ot's own server (Authorization: Bearer 7vm_…): /api/vm/* — see vm.mjs
17
25
  * apuchat identity and Orquesta tasks (…/apuchat/*, …/orquesta/*, /auth/apuchat|orquesta): see connect.mjs
18
26
  */
19
27
 
@@ -28,6 +36,9 @@ import {
28
36
  } from './store.mjs';
29
37
  import { freeMessages, freeTts, otsChannels, otsEnv, otsLlmInfo, otsStep, otsSynthesize, otsTtsProvider, platformLlmEnv, stopOtsChannels } from './runtime.mjs';
30
38
  import { createConnect } from './connect.mjs';
39
+ import { createDevice, deviceFromBearer } from './device.mjs';
40
+ import { createVm } from './vm.mjs';
41
+ import { createSites } from './sites.mjs';
31
42
  import '../../src/i18n/messages/platform.js';
32
43
 
33
44
  const penv = process.env;
@@ -86,7 +97,7 @@ export function createPlatform({ send, readJson, rateLimit, callLimit, originOf,
86
97
  async function publicApi(req, res, id, path) {
87
98
  const t = reqT(req);
88
99
  const ots = getOts(id);
89
- const open = ['/config', '/embed.js', '/identity', '/identity.vcf'].includes(path);
100
+ const open = ['/config', '/embed.js', '/identity', '/identity.vcf', '/traits.json'].includes(path);
90
101
  if (open) {
91
102
  res.setHeader('Access-Control-Allow-Origin', '*');
92
103
  if (req.method === 'OPTIONS') return send(res, 204);
@@ -98,11 +109,18 @@ export function createPlatform({ send, readJson, rateLimit, callLimit, originOf,
98
109
  if (ots.status !== 'on') return res.end('/* 7ots: ots en pausa */');
99
110
  return res.end(embedScript(ots.widget, await bundleVersion(), `/api/o/${ots.id}`));
100
111
  }
112
+ if (path === '/traits.json') return send(res, 200, traitsMeta(ots, `${originOf(req)}/api/o/${ots.id}`));
101
113
  const card = publicIdentity(ots.identity, otsChannels(ots).live());
102
114
  if (path === '/identity.vcf') {
103
115
  res.writeHead(200, { 'Content-Type': 'text/vcard; charset=utf-8', 'Content-Disposition': `inline; filename="${card.id}.vcf"`, 'Cache-Control': 'no-cache' });
104
116
  return res.end(toVCard(card, { baseUrl: `${originOf(req)}/api/o/${ots.id}/identity.vcf` }));
105
117
  }
118
+ // A person opening the link sees the ot's card; agents and the card itself (Accept: */* or JSON) get the JSON.
119
+ res.setHeader('Vary', 'Accept');
120
+ if (/text\/html/.test(req.headers.accept || '') && !/[?&]format=json\b/.test(req.url)) {
121
+ 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}`));
123
+ }
106
124
  return send(res, 200, card);
107
125
  }
108
126
 
@@ -305,6 +323,8 @@ export function createPlatform({ send, readJson, rateLimit, callLimit, originOf,
305
323
  });
306
324
  }
307
325
  if ((await connect.account(req, res, account, path)) !== false) return;
326
+ if (await device.dashboard(req, res, account, path)) return;
327
+ if (await sites(req, res, account, path)) return;
308
328
 
309
329
  if (path === '/ots' && req.method === 'GET') {
310
330
  const usage = usageByOts(account.id);
@@ -342,6 +362,7 @@ export function createPlatform({ send, readJson, rateLimit, callLimit, originOf,
342
362
 
343
363
  if (sub.startsWith('/admin/')) return adminApi(req, res, ots, sub.slice('/admin'.length));
344
364
  if (await connect.ots(req, res, account, ots, sub)) return;
365
+ if (await vm.dashboard(req, res, account, ots, sub)) return;
345
366
 
346
367
  if (sub === '' && req.method === 'GET') return send(res, 200, { ots: otsSummary(ots, usageByOts(account.id)[ots.id] || {}) });
347
368
  if (sub === '' && req.method === 'PATCH') {
@@ -381,6 +402,9 @@ export function createPlatform({ send, readJson, rateLimit, callLimit, originOf,
381
402
  }
382
403
 
383
404
  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) });
406
+ const sites = createSites({ send, readJson });
407
+ const vm = createVm({ send, readJson, rateLimit, base: (req) => (penv.PLATFORM_URL || originOf(req)).replace(/\/+$/, '') });
384
408
 
385
409
  /** @returns {Promise<boolean>} true si atendió la ruta */
386
410
  return async function handle(req, res, url) {
@@ -398,6 +422,21 @@ export function createPlatform({ send, readJson, rateLimit, callLimit, originOf,
398
422
  send(res, 200, { free: ok ? !lookTaken({ look: { kind: 'character', character } }) : false });
399
423
  return true;
400
424
  }
425
+ const dv = p.match(/^\/api\/device\/ots\/([a-z0-9_]{4,40})\/vm(\/.*)?$/);
426
+ if (dv) {
427
+ const who = deviceFromBearer(req);
428
+ if (!who) send(res, 401, { error: 'login' });
429
+ else await vm.device(req, res, who, dv[1], dv[2] || '');
430
+ return true;
431
+ }
432
+ if (p.startsWith('/api/vm/')) {
433
+ await vm.worker(req, res, p.slice('/api/vm'.length));
434
+ return true;
435
+ }
436
+ if (p.startsWith('/api/device/')) {
437
+ if (!(await device.api(req, res, p.slice('/api/device'.length)))) send(res, 404, { error: reqT(req)('server.http.unknownRoute') });
438
+ return true;
439
+ }
401
440
  if (p.startsWith('/api/platform/')) {
402
441
  await dashboardApi(req, res, p.slice('/api/platform'.length));
403
442
  return true;
@@ -435,3 +474,63 @@ function sanitizeWidget(w) {
435
474
  for (const k of ['endpoint', 'llm', 'auth', 'templates', 'actions', 'identity']) delete out[k];
436
475
  return out;
437
476
  }
477
+
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
+ /**
506
+ * The ot's unique traits as NFT-style metadata (ERC-721 / Metaplex shape). The face fingerprint is
507
+ * what makes an ot unique (UNIQUE index); the permanent apuchat @handle is its unique name. When
508
+ * minting ships, the token will be these traits: whoever holds it holds the ot.
509
+ */
510
+ function traitsMeta(ots, ep) {
511
+ const look = ots.identity?.look || {};
512
+ const ch = look.character || {};
513
+ const handle = ots.settings?.APUCHAT_AGENT_CALLSIGN && ots.settings.APUCHAT_AGENT_FREE !== '1' ? ots.settings.APUCHAT_AGENT_CALLSIGN : null;
514
+ const attr = (trait_type, value) => (value == null || value === '' ? null : { trait_type, value });
515
+ return {
516
+ name: ots.identity?.name || ots.name,
517
+ description: ots.identity?.tagline || ots.identity?.role || '',
518
+ external_url: `${ep}/identity`,
519
+ fingerprint: ots.lookPrint || null,
520
+ handle,
521
+ minted: false,
522
+ attributes: [
523
+ attr('Kind', look.kind),
524
+ attr('Material', look.kind === 'plush' ? look.material : null),
525
+ attr('Body', ch.body?.shape),
526
+ attr('Body color', ch.body?.color),
527
+ attr('Pattern', ch.body?.pattern && ch.body.pattern !== 'none' ? ch.body.pattern : null),
528
+ attr('Finish', ch.finish),
529
+ attr('Eyes', ch.eyes?.type),
530
+ attr('Brows', ch.brows?.type),
531
+ attr('Mouth', ch.mouth?.type),
532
+ attr('Cheeks', ch.cheeks?.type),
533
+ attr('Accessories', Array.isArray(ch.accessories) ? ch.accessories.map((a) => a?.id).filter(Boolean).join(', ') || 'none' : null),
534
+ ].filter(Boolean),
535
+ };
536
+ }