@7ots/cli 0.1.1 → 0.1.3

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 (49) hide show
  1. package/.env.example +17 -1
  2. package/cli/7ots.mjs +133 -36
  3. package/cli/lib/assistant.mjs +337 -0
  4. package/cli/lib/brain.mjs +10 -7
  5. package/cli/lib/i18n.mjs +78 -11
  6. package/cli/lib/install.mjs +29 -1
  7. package/cli/lib/pet-core.mjs +3 -2
  8. package/cli/lib/pet-server.mjs +30 -0
  9. package/cli/lib/prompts.mjs +199 -7
  10. package/cli/lib/terminal.mjs +27 -6
  11. package/cli/pet/electron/main.cjs +79 -28
  12. package/cli/pet/pet.html +55 -6
  13. package/dist/7ots.esm.js +370 -142
  14. package/dist/7ots.esm.js.map +4 -4
  15. package/dist/7ots.iife.js +370 -142
  16. package/dist/7ots.iife.js.map +4 -4
  17. package/llms.txt +198 -6
  18. package/package.json +1 -1
  19. package/server/channels/apuchat.mjs +8 -5
  20. package/server/channels/apumail.mjs +13 -3
  21. package/server/channels/index.mjs +5 -3
  22. package/server/platform/apuchat.mjs +266 -0
  23. package/server/platform/auth.mjs +39 -10
  24. package/server/platform/connect.mjs +347 -0
  25. package/server/platform/db.mjs +55 -0
  26. package/server/platform/look.mjs +20 -0
  27. package/server/platform/orquesta.mjs +499 -0
  28. package/server/platform/routes.mjs +57 -8
  29. package/server/platform/runtime.mjs +75 -15
  30. package/server/platform/store.mjs +130 -9
  31. package/server/server.mjs +14 -3
  32. package/src/avatar/AvatarStage.js +54 -9
  33. package/src/avatar/three.js +62 -0
  34. package/src/character/AvatarEditor.js +174 -50
  35. package/src/character/Character.js +164 -26
  36. package/src/character/motion.js +95 -34
  37. package/src/character/parts.js +43 -12
  38. package/src/character/skin.js +412 -0
  39. package/src/character/styles.js +1429 -0
  40. package/src/character3d/Character3D.js +1693 -0
  41. package/src/character3d/body.js +154 -0
  42. package/src/character3d/fx.js +323 -0
  43. package/src/character3d/index.js +281 -0
  44. package/src/character3d/materials.js +429 -0
  45. package/src/character3d/stage.js +187 -0
  46. package/src/i18n/messages/character.js +18 -42
  47. package/src/i18n/messages/platform.js +135 -0
  48. package/src/identity/schema.js +16 -8
  49. package/src/index.js +1 -0
package/llms.txt CHANGED
@@ -1,13 +1,205 @@
1
1
  # 7ots
2
2
 
3
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, and can also live on
5
- > your computer as a virtual pet and join apuchat meet video calls.
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.
6
7
 
7
- - Site: https://7ots.com/
8
- - Create an identity in the browser: https://7ots.com/#create
9
- - Hosted ots (dashboard): https://7ots.com/app/
10
- - npm: https://www.npmjs.com/package/7ots (CLI: `npx @7ots/cli help`)
8
+ - Site: https://7ots.com/ · create an identity in the browser: https://7ots.com/#create
9
+ - Hosted ots (dashboard, no server needed): https://7ots.com/app/
10
+ - CLI: npm `@7ots/cli` (https://www.npmjs.com/package/@7ots/cli) → `npx -y @7ots/cli help`, `npx -y @7ots/cli <cmd> --help`
11
+ - This file: https://7ots.com/llms.txt (also `npx -y @7ots/cli docs`)
12
+
13
+ # Read this first (for coding agents)
14
+
15
+ 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
+ 3. From an agent, commands must return: use `--detach` for the pet, run `meet` in the background, and ask the user to run
21
+ interactive commands themselves (`setup`, the terminal pet) — in Claude Code: `! npx -y @7ots/cli setup`.
22
+ 4. Language: `--lang en|es|pt` on `new` sets the ot's language, and from then on every CLI text follows it
23
+ (order: SEVENOTS_LANG > ~/.7ots/config.json "lang" > the ot's language > LANG > English).
24
+ 5. Secrets (API keys, tokens) never go in HTML, in identity.json or in your reply: they go in env vars / .env of a server,
25
+ in ~/.7ots/keys.json (written by `setup`, 0600) or in the https://7ots.com/app/ dashboard.
26
+ 6. When done, tell the user: the ot's name and seed, where it lives, and how to see it (URL, `7ots pet`, …).
27
+
28
+ # Typical flows
29
+
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
+ `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
+ - "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
+
37
+ # CLI reference (`npx -y @7ots/cli <command>`, or `7ots <command>` once installed globally)
38
+
39
+ Every command accepts `--help` (prints usage, never launches anything) and `--lang en|es|pt`.
40
+
41
+ | Command | What it does |
42
+ |---|---|
43
+ | `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
+ | `show [--json\|--prompt]` | The identity in use (the project's, else the global one). `--prompt`: as a system prompt. |
45
+ | `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"). |
47
+ | `setup` (alias `wizard`, `init`) | Interactive wizard: where the ot lives, brain, voice, pet mode, chattiness, hooks. Writes `~/.7ots/config.json` and `keys.json`. |
48
+ | `pet [--mode desktop\|terminal\|browser\|auto] [--detach] [--opaque] [--tmux] [--compact]` | Wakes the pet (see "Pet"). |
49
+ | `feed` · `play` · `sleep` · `wake` · `say <text>` | Care for the pet (works even if it is not running: the state is saved). |
50
+ | `status [--line\|--json]` | Level, food, energy, fun. `--line`: one short line for status bars. |
51
+ | `stop` | Stops the pet daemon (and its windows). |
52
+ | `hooks install\|remove [--claude] [--shell] [--global]` | Lets the pet react to your work (see "Hooks"). |
53
+ | `meet --new [--minutes n]` · `meet "<invite or link>"` | The ot joins an apuchat meet video call. |
54
+ | `prompt [identity\|install\|pet\|meet\|all]` | Short recipes for coding agents. |
55
+ | `docs` | Prints this file. |
56
+ | `login orquesta` | Connects a getorquesta.com account (brain "batuta", home "orquesta"). |
57
+ | `server` | Runs the 7ots proxy (`/api/agent`) with the env vars below. |
58
+ | `version` · `help` | |
59
+
60
+ # Files
61
+
62
+ | Path | Content |
63
+ |---|---|
64
+ | `.7ots/identity.json` (project) / `~/.7ots/identity.json` (global) | The identity. Public data only (name, role, look, voice, personality, contact). Commit it if you want. |
65
+ | `~/.7ots/config.json` | Written by `setup`: `lang`, `home`, `brain`, `voice`, `pet` (below). |
66
+ | `~/.7ots/keys.json` (0600) | Keys typed in `setup`, under their env-var names. Env vars always win. Never print it. |
67
+ | `~/.7ots/pet.json` · `pet.log` · `pet.token` | Pet state, daemon log, local auth token. |
68
+ | `SEVENOTS_HOME` | Moves `~/.7ots` elsewhere. `SEVENOTS_PET_PORT` (default 7717) moves the pet daemon. `SEVENOTS_LANG` forces the CLI language. |
69
+
70
+ Identity fields: `id, name, role, tagline, seed, bio, language, languages, personality{tone, traits, instructions}, look{color, …},
71
+ voice{provider, id, lang}, contact{…}`. Change them with `new --force`, the web editor (https://7ots.com/#create) or the backoffice, not by hand.
72
+
73
+ ## config.json
74
+
75
+ ```
76
+ home { kind: 'local' } only on this computer
77
+ { kind: '7ots', url: 'https://7ots.com/api/o/<id>' } hosted on 7ots.com
78
+ { kind: 'server', url: 'https://site/api/agent' } the user's own 7ots server
79
+ { kind: 'orquesta', projectId, projectName } a getorquesta.com project
80
+ brain { kind: 'lines' } built-in phrases, no AI (default)
81
+ { kind: 'cli', cli: 'claude'|'codex'|'gemini'|'ollama'|'custom', model?, command? } the user's own AI CLI
82
+ { kind: 'api', provider: 'anthropic'|'openai', model?, baseUrl? } a key in keys.json/env
83
+ { kind: '7ots' } the home's /chat (7ots.com or own server)
84
+ { kind: 'batuta', model? } Orquesta's Batuta
85
+ voice { kind: 'none'|'browser'|'apuchat'|'elevenlabs'|'grok'|'fish'|'openai', voiceId? }
86
+ pet { mode: 'ask'|'desktop'|'terminal'|'browser', annoy: 0..3, opaque?: true }
87
+ annoy: 0 silent · 1 rarely · 2 normal · 3 chatty (+ terminal bell)
88
+ ```
89
+ Keys by name: ANTHROPIC_API_KEY, OPENAI_API_KEY, ORQUESTA_TOKEN, APUCHAT_VOICE_TOKEN, ELEVENLABS_API_KEY, XAI_API_KEY, FISH_API_KEY.
90
+
91
+ # Pet
92
+
93
+ - `desktop`: a small always-on-top window that walks along the bottom of the screen (Electron via npx, ~100 MB the first time).
94
+ Find it bottom-right; menu on right click or the tray icon (Show / Hide / Feed / Play / Sleep / Quit). Hide keeps it alive.
95
+ - `terminal`: drawn in the current terminal, keys f feed · p play · s sleep/wake · q quit. Needs its own terminal: ask the user.
96
+ - `browser`: a tab at http://127.0.0.1:7717/pet.
97
+ - `auto`: desktop if there is a screen, else terminal.
98
+ - `--detach`: return immediately (always from agents). The daemon keeps running until `7ots stop`.
99
+ - tmux: `7ots pet --tmux` opens the terminal pet in a side pane (`--compact` for a narrow one); for the status bar add
100
+ `set -g status-right "#(7ots status --line)"` to ~/.tmux.conf.
101
+ - Needs: food, energy and fun decay with time (time away counts at most 8 h); it gains XP and levels from passing
102
+ commands and finished agent turns.
103
+
104
+ Troubleshooting:
105
+ - A grey/black box behind the pet (Linux without a compositor, some GPUs): `7ots pet --opaque` (or `"pet": {"opaque": true}` in config.json).
106
+ - Can't see it: look at the tray icon, or `7ots stop && 7ots pet --mode desktop` (it comes back bottom-right).
107
+ - Electron can't start (no display, headless, WSL): it falls back to the browser tab; or use `--mode terminal`.
108
+ - Port busy: `SEVENOTS_PET_PORT=7720`.
109
+
110
+ # Hooks
111
+
112
+ - `hooks install --claude`: Claude Code hooks in `.claude/settings.local.json` of this project (`--global`: ~/.claude/settings.json).
113
+ The pet hears tool calls, errors and finished turns (`7ots event --from claude`, reads the hook JSON on stdin; never fails).
114
+ - `hooks install --shell`: bash/zsh hook in ~/.bashrc / ~/.zshrc: the pet hears commands and exit codes (secrets are redacted).
115
+ - `hooks remove` undoes both. Without a running pet the events still change its state silently.
116
+
117
+ # Website (widget)
118
+
119
+ `npx -y @7ots/cli install` detects the project:
120
+ - plain HTML (index.html in ., public/, src/, static/, www/, site/) → inserts the widget before </body> between
121
+ `<!-- 7ots -->` markers (idempotent: re-running replaces the block);
122
+ - a framework (Next, Nuxt, Astro, SvelteKit, Remix, Gatsby, Vite, Django, Rails, Laravel, Hugo, Jekyll…) → prints the snippet and
123
+ which file to paste it in (root layout/template, before </body>);
124
+ - not a website → says so and suggests the pet, meet or hosting;
125
+ - `--target print` only prints the snippet; `--target self` prepares the identity for the user's own server.
126
+
127
+ Snippet for an ot hosted on 7ots.com (simplest; allowed domains are set in the dashboard):
128
+ ```html
129
+ <script src="https://7ots.com/api/o/<id>/embed.js" defer></script>
130
+ ```
131
+ Snippet with your own proxy:
132
+ ```html
133
+ <script src="https://cdn.jsdelivr.net/npm/@7ots/cli/dist/7ots.iife.js" defer></script>
134
+ <script>
135
+ addEventListener('DOMContentLoaded', () => SevenOts.init({ endpoint: '/api/agent', identity: true }));
136
+ </script>
137
+ ```
138
+ Declarative: `<ots-agent endpoint="/api/agent" site-key="pk_x" name="Ana"></ots-agent>`. ES module: `import { init } from '@7ots/cli'`.
139
+
140
+ `SevenOts.init(options)`:
141
+ | Option | Meaning |
142
+ |---|---|
143
+ | `endpoint` | Proxy URL (`/api/agent`, `https://7ots.com/api/o/<id>`, …). Required. |
144
+ | `siteKey` | Public site id the proxy may require (`SITE_KEYS`). |
145
+ | `identity` | `true` (ask the endpoint), a URL, or an inline identity object (public fields only). |
146
+ | `agent` | `{ name, role, siteName, language, instructions, expressive }`. |
147
+ | `avatar` | `{ url, body: 'F'\|'M', mood, cameraView }` (3D, needs an importmap for three) or `false`; default is the 2D character. |
148
+ | `voice` | `{ tts: 'proxy'\|'browser'\|false, stt: true, lang }` or `false`. |
149
+ | `auth` | `{ token }`, `{ getToken, getUser }` or `{ credentials: 'include' }`; `exposeClaims`. |
150
+ | `mcp` | `[{ url, name, requiresAuth, filter, confirm }]` MCP servers (Streamable HTTP). |
151
+ | `actions` | Custom tools `[{ name, description, parameters, handler, confirm }]`. |
152
+ | `templates` | `{ name: (data, { escape }) => html }` for show_modal / open_sidebar. |
153
+ | `navigation` | `{ allowedOrigins, router }` (`router` for SPAs). |
154
+ | `proactive` | `{ level: 'quiet'\|'normal'\|'bold', greetDelayMs, dwellMs, idleMs, cooldownMs, maxPerSession, … }` or `false`. |
155
+ | `context` | `{ privateSelectors, ignoreSelectors, extra: () => ({...}) }` — what it may read on the page. |
156
+ | `contact` | `{ apuchat: true, apumail: true \| { categories } }` or `false` — hand off to a human. |
157
+ | `builtins` | `{ exclude: ['click', …], confirmClicks: 'submit'\|'all'\|'none' }`. |
158
+ | `pointer` | `{ speed, visible }` or `false` — the virtual mouse/keyboard. |
159
+ | `pageTools` | `true` (default): forms/buttons with `data-ots-tool` become tools. |
160
+ | `mode` / `companion` | `'panel'` (default) or `'companion'` (the avatar walks the page); `{ size, idleHomeMs, follow, wanderMs, watchCursor }`. |
161
+ | `theme` | `{ primary, radius, position: 'right'\|'left', font }`. |
162
+
163
+ HTML tools without JS: `<form data-ots-tool="invite_member" data-ots-description="…" data-ots-confirm="Invite {email}?" data-ots-auth>`
164
+ (named fields become parameters). Runtime API: `agent.ask(text)`, `say`, `notify`, `registerAction`, `registerMcp`,
165
+ `setAuthToken`, `setIdentity`, `setProactivity`, `open/close/reset/destroy`, `on(event, fn)`.
166
+
167
+ # Own server (`npx -y @7ots/cli server`, or `node server/server.mjs` from the repo)
168
+
169
+ Reads env vars / `.env`. Keys live only here. Admin UI at /backoffice/ (localhost only unless ADMIN_PASSWORD).
170
+ - Core: PORT (8787), ALLOWED_ORIGINS (comma list), SITE_KEYS, RATE_LIMIT_PER_MIN (40), TRUST_PROXY, SERVER_INSTRUCTIONS, LOCALE (es|en|pt), LOG_LEVEL.
171
+ - LLM: LLM_PROVIDER (anthropic | openai | mock), ANTHROPIC_API_KEY, OPENAI_API_KEY, LLM_MODEL, LLM_BASE_URL (OpenAI-compatible:
172
+ DeepSeek, Groq, Ollama, OpenRouter…), LLM_EFFORT (low…max), LLM_MAX_TOKENS, LLM_FALLBACKS (on|off).
173
+ - Voice (TTS): TTS_PROVIDER (apuchat | elevenlabs | grok | fish | openai | none = browser voice; empty = first with a key);
174
+ APUCHAT_VOICE_URL, APUCHAT_VOICE_TOKEN, APUCHAT_VOICE_ID, APUCHAT_VOICE_PROVIDER; ELEVENLABS_API_KEY, ELEVENLABS_VOICE_ID,
175
+ ELEVENLABS_MODEL; XAI_API_KEY, XAI_TTS_VOICE; FISH_API_KEY, FISH_VOICE_ID, FISH_MODEL; OPENAI_TTS_MODEL, OPENAI_TTS_VOICE.
176
+ - Email tickets (apumail): APUMAIL_API, APUMAIL_INBOX, APUMAIL_INBOX_TOKEN, APUMAIL_TO.
177
+ - Human handoff (apuchat): APUCHAT_HUB, APUCHAT_NOTIFIER_IDENTITY_KEY, APUCHAT_OPERATOR_HANDLE, APUCHAT_TRANSCRIBE.
178
+ - Backoffice: ADMIN_PASSWORD, CONFIG_FILE, SECRETS_FILE, IDENTITY_FILE (default data/identity.json).
179
+ - The agent outside the web: AGENT_NAME, AGENT_ROLE, AGENT_SITE_URL, AGENT_INSTRUCTIONS, AGENT_LANG; own mailbox APUMAIL_AGENT_INBOX,
180
+ APUMAIL_AGENT_TOKEN, APUMAIL_AGENT_WEBHOOK_SECRET, APUMAIL_AGENT_DAILY; own apuchat/meet identity APUCHAT_AGENT_IDENTITY_KEY,
181
+ APUCHAT_AGENT_AVATAR, APUCHAT_AGENT_SCENE, APUCHAT_AGENT_ALLOW, AGENT_MAX_CALLS, AGENT_CALL_MAX_MINUTES, AGENT_CALL_MAX_TURNS.
182
+ - Demo: DEMO, SERVE_STATIC, DEMO_JWT_SECRET.
183
+ - Platform (multi-tenant, what https://7ots.com runs): PLATFORM=true, PLATFORM_URL, PLATFORM_DB, PLATFORM_SECRET, PLATFORM_MAX_OTS,
184
+ PLATFORM_OTS_RATE_PER_MIN, PLATFORM_OTS_DAILY_LIMIT, PLATFORM_NEW_ACCOUNTS_PER_IP_DAY, PLATFORM_MAX_INFLIGHT,
185
+ PLATFORM_LLM_PROVIDER/API_KEY/BASE_URL/MODEL, PLATFORM_FREE_MESSAGES, PLATFORM_APUCHAT_VOICE_TOKEN, PLATFORM_FREE_TTS,
186
+ PLATFORM_MAIL_INBOX/TOKEN, NOTLOGIN_URL/CLIENT_ID/CLIENT_SECRET, PLATFORM_APUCHAT_IDENTITIES_PER_DAY (free apuchat
187
+ identities per account and day, default 5), ORQUESTA_URL (default https://getorquesta.com).
188
+ Endpoints: POST /api/agent/chat, POST /api/agent/tts, GET /api/agent/identity, /api/agent/contact/*, /llms.txt.
189
+
190
+ # Hosted on https://7ots.com (no server)
191
+
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.
193
+ - Each ot's public API: `https://7ots.com/api/o/<id>/` — embed.js, config, identity, identity.vcf, chat, tts, contact/apumail, contact/apuchat.
194
+ Point the CLI at it with `setup` → home "7ots" (brain "7ots" then uses its /chat).
195
+ - 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.
196
+ Same for voice. Keys are encrypted and never shown again. Per-ot daily and per-minute limits apply.
197
+ - Settings per ot in /backoffice/?ots=<id>: instructions, LLM/voice keys, apumail/apuchat channels.
198
+ - apuchat tab of an ot: a free @handle (expires after 24h without activity, e.g. while the ot is paused), made permanent
199
+ by paying once by card on apuchat; connect your apuchat account to keep paid identities under it, or paste an identity key you own.
200
+ - Orquesta tab of an ot: connect Orquesta (OAuth or an oak_ key), pick a project, enable tasks. Then the ot can start Orquesta
201
+ tasks (run_task, task_status) only for its owner in the dashboard ("Ask your ot to do something") and for apuchat DMs /
202
+ apumail emails from senders on the ot's allowlist — never from the public chat (/api/o/<id>/chat). Daily cap per ot (default 20).
11
203
 
12
204
  # Recipes for coding agents
13
205
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7ots/cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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": {
@@ -55,9 +55,10 @@ export function apuchatConfigured(cfg = apuchatConfigFromEnv()) {
55
55
  }
56
56
 
57
57
  /**
58
- * @param {{ agent: ReturnType<import('./agent.mjs').createServerAgent>, persona: Function, log: Function, config?: object }} deps
58
+ * @param {{ agent: ReturnType<import('./agent.mjs').createServerAgent>, persona: Function, log: Function, config?: object, tools?: Function }} deps
59
+ * tools: extra tools for a DM, decided by sender ({ channel:'dm', from } → { tools, run } | null)
59
60
  */
60
- export function createApuchatChannel({ agent, persona, log, config = apuchatConfigFromEnv() }) {
61
+ export function createApuchatChannel({ agent, persona, log, config = apuchatConfigFromEnv(), tools: extraTools = null }) {
61
62
  const cfg = { ...apuchatConfigFromEnv({}), ...config };
62
63
  const key = cfg.identityKey;
63
64
  const hub = String(cfg.hub).replace(/\/$/, '');
@@ -169,11 +170,13 @@ export function createApuchatChannel({ agent, persona, log, config = apuchatConf
169
170
  }
170
171
  if (backlog && age > BACKLOG_MAX_AGE_MS) return;
171
172
  if (!allowed(from)) return;
173
+ const extra = extraTools?.({ channel: 'dm', from }) || null;
172
174
  const reply = await agent.respond(`dm:${from}`, 'dm', text, {
173
- tools: [ringTool],
175
+ tools: [ringTool, ...(extra?.tools || [])],
174
176
  run: async (name, args) => {
175
- if (name !== 'llamar_por_apuchat') throw new Error(`Herramienta desconocida: ${name}`);
176
- return ring(from, args.motivo);
177
+ if (name === 'llamar_por_apuchat') return ring(from, args.motivo);
178
+ if (extra?.tools.some((x) => x.name === name)) return extra.run(name, args);
179
+ throw new Error(`Herramienta desconocida: ${name}`);
177
180
  },
178
181
  });
179
182
  if (reply) await dm(from, reply);
@@ -43,9 +43,10 @@ export function apumailConfigured(cfg = apumailConfigFromEnv()) {
43
43
  }
44
44
 
45
45
  /**
46
- * @param {{ agent: ReturnType<import('./agent.mjs').createServerAgent>, log: Function, config?: object }} deps
46
+ * @param {{ agent: ReturnType<import('./agent.mjs').createServerAgent>, log: Function, config?: object, tools?: Function }} deps
47
+ * tools: extra tools for a mail, decided by sender ({ channel:'email', from, verified } → { tools, run } | null)
47
48
  */
48
- export function createApumailChannel({ agent, log, config = apumailConfigFromEnv() }) {
49
+ export function createApumailChannel({ agent, log, config = apumailConfigFromEnv(), tools = null }) {
49
50
  const cfg = { ...apumailConfigFromEnv({}), ...config };
50
51
  const inbox = String(cfg.inbox || '').toLowerCase();
51
52
  const token = cfg.token;
@@ -87,7 +88,8 @@ export function createApumailChannel({ agent, log, config = apumailConfigFromEnv
87
88
  }
88
89
  const subject = String(mail.subject || '').slice(0, 200);
89
90
  const body = (mail.text || stripHtml(mail.html) || '').split('\n').filter((l) => !l.startsWith('>')).join('\n').trim().slice(0, 6000);
90
- const reply = await agent.respond(`mail:${from}`, 'email', `Asunto: ${subject || '(sin asunto)'}\n\n${body || '(vacío)'}`);
91
+ const extra = tools?.({ channel: 'email', from, verified: senderVerified(mail) }) || null;
92
+ const reply = await agent.respond(`mail:${from}`, 'email', `Asunto: ${subject || '(sin asunto)'}\n\n${body || '(vacío)'}`, extra || {});
91
93
  if (!reply) return;
92
94
  const res = await fetch(`${base}/send`, {
93
95
  method: 'POST',
@@ -175,6 +177,14 @@ export function emailOf(from) {
175
177
  return (s.match(/<([^>]+)>/)?.[1] || s).trim().toLowerCase();
176
178
  }
177
179
 
180
+ /**
181
+ * Did apumail verify the sender (DMARC pass for the From domain)? Only a structured verdict from
182
+ * apumail counts; headers inside the mail can be written by whoever sent it.
183
+ */
184
+ export function senderVerified(mail) {
185
+ return mail?.auth?.dmarc === 'pass';
186
+ }
187
+
178
188
  /** Correos que nunca se contestan: propios, automáticos, rebotes, listas. */
179
189
  export function skipReason(mail, from, inbox) {
180
190
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(from)) return 'remitente inválido';
@@ -18,14 +18,16 @@ import { apuchatConfigFromEnv, apuchatConfigured, createApuchatChannel } from '.
18
18
  * @param {() => string} [deps.instructions] instrucciones fijas del servidor (defecto: SERVER_INSTRUCTIONS)
19
19
  * @param {{ apumail?: object|false, apuchat?: object|false }} [deps.config] por defecto, el entorno
20
20
  * @param {Function} [deps.log]
21
+ * @param {(ctx: { channel: 'dm'|'email', from: string, verified?: boolean }) => ({ tools: object[], run: Function }|null)} [deps.tools]
22
+ * extra server tools for one conversation (DMs and mails, never voice calls), decided per sender
21
23
  */
22
- export function startAgentChannels({ llm, brain, identity = getIdentity, instructions, config = {}, log = (...a) => console.log('[7ots]', ...a) }) {
24
+ export function startAgentChannels({ llm, brain, identity = getIdentity, instructions, config = {}, log = (...a) => console.log('[7ots]', ...a), tools = null }) {
23
25
  const agent = createServerAgent({ llm, brain, identity, instructions });
24
26
  const persona = () => personaOf(identity);
25
27
  const mailCfg = config.apumail === false ? null : { ...apumailConfigFromEnv(), ...(config.apumail || {}) };
26
28
  const chatCfg = config.apuchat === false ? null : { ...apuchatConfigFromEnv(), ...(config.apuchat || {}) };
27
- const apumail = mailCfg && apumailConfigured(mailCfg) ? createApumailChannel({ agent, log, config: mailCfg }) : null;
28
- const apuchat = chatCfg && apuchatConfigured(chatCfg) ? createApuchatChannel({ agent, persona, log, config: chatCfg }) : null;
29
+ const apumail = mailCfg && apumailConfigured(mailCfg) ? createApumailChannel({ agent, log, config: mailCfg, tools }) : null;
30
+ const apuchat = chatCfg && apuchatConfigured(chatCfg) ? createApuchatChannel({ agent, persona, log, config: chatCfg, tools }) : null;
29
31
  apumail?.start();
30
32
  apuchat?.start();
31
33
 
@@ -0,0 +1,266 @@
1
+ /**
2
+ * apuchat for the platform: an apuchat identity (@callsign) for each ot, made from the dashboard.
3
+ *
4
+ * Every 7ots account can hold two apuchat accounts (sealed in `integrations`, kind 'apuchat'):
5
+ * managed an anonymous apuchat account the platform creates on first use (no sign-up): it owns the
6
+ * identities made for the user's ots. Its session lasts 90 days; when it is refused, the
7
+ * recovery token gets a new one (and if even that fails, a new anonymous account is made).
8
+ * linked the user's own apuchat account, connected with apuchat's /link flow (app "7ots"): new
9
+ * identities go there and the ot's identity can be claimed into it.
10
+ *
11
+ * Hub contract (APUCHAT_HUB, read once at boot; server to server only):
12
+ * POST /api/account → { account_id, recovery_token, session_token }
13
+ * POST /api/account/recover {recovery_token} → { session_token }
14
+ * POST /api/account/identities Bearer session|apt_ → { callsign, identity_key, free_identity, … }
15
+ * DELETE /api/account/identities/:callsign
16
+ * GET /api/identities/mint/quote?callsign= Bearer → { valid, taken, reason, tier, tier_label, price_usd, … }
17
+ * POST /api/identities/mint/checkout { callsign, return_url } → { checkout_id, url, price_usd, tier }
18
+ * GET /api/identities/mint/checkout/:id → { status, callsign, upgraded, identity_key? }
19
+ * POST /api/v1/link/exchange { code, app } → { account_id, identities, app_token (apt_, shown once), scopes, notice }
20
+ * POST /api/account/identities/claim { identity_key } Bearer apt_ → { ok, callsign, moved, paid }
21
+ * (409 *_not_transferable when it is paid/verified elsewhere)
22
+ * apt_ errors: 401 invalid_app_token (the link is forgotten) · 403 insufficient_scope. Paying needs
23
+ * Stripe on apuchat: 503 not_configured until then (we point to minting with USDC on apuchat instead).
24
+ * return_url must be on 7ots.com (or localhost:8787 in development); apuchat appends ?apuchat_checkout=<id>&status=….
25
+ * GET /api/dm/wait?timeout=1 X-Identity-Key → { callsign, … } (checks a pasted key)
26
+ *
27
+ * Free identities expire after 24 h without DM activity; the ot's channel long-polls /api/dm/wait,
28
+ * which counts as activity, so they live while the ot is on.
29
+ */
30
+
31
+ import { i18nError } from '../i18n.mjs';
32
+ import { getIntegration, setIntegration } from './store.mjs';
33
+
34
+ const penv = process.env;
35
+ const HUB = (penv.APUCHAT_HUB || 'https://apuchat.com').replace(/\/+$/, '');
36
+ const SESSION_REFRESH_MS = 80 * 24 * 3600_000; // sessions last 90 days: renew a bit before
37
+ export const CALLSIGN_RE = /^[a-z0-9][a-z0-9_-]{0,31}$/;
38
+ const IDENTITIES_PER_DAY = () => Number(penv.PLATFORM_APUCHAT_IDENTITIES_PER_DAY || 5);
39
+
40
+ export const apuchatHub = () => HUB;
41
+
42
+ async function hub(method, path, { bearer, identityKey, body, timeoutMs = 15000 } = {}) {
43
+ let res;
44
+ try {
45
+ res = await fetch(HUB + path, {
46
+ method,
47
+ headers: {
48
+ Accept: 'application/json',
49
+ ...(body ? { 'Content-Type': 'application/json' } : {}),
50
+ ...(bearer ? { Authorization: `Bearer ${bearer}` } : {}),
51
+ ...(identityKey ? { 'X-Identity-Key': identityKey } : {}),
52
+ },
53
+ body: body ? JSON.stringify(body) : undefined,
54
+ redirect: 'manual',
55
+ signal: AbortSignal.timeout(timeoutMs),
56
+ });
57
+ } catch {
58
+ throw i18nError(424, 'platform.apuchat.unreachable');
59
+ }
60
+ let data = null;
61
+ try {
62
+ data = await res.json();
63
+ } catch {}
64
+ return { ok: res.ok, status: res.status, data: data || {} };
65
+ }
66
+
67
+ /** Upstream error → our error (the hub's own text is never shown; its code helps a few cases). */
68
+ function hubError(r) {
69
+ const code = String(r.data.code || r.data.error || '');
70
+ if (r.status === 503 && /not_configured/.test(code)) return i18nError(501, 'platform.apuchat.paymentsOff', { url: `${HUB}/account/mint` });
71
+ if (r.status === 429) return i18nError(429, 'platform.apuchat.hubBusy');
72
+ if (r.status === 403 && /insufficient_scope/.test(code)) return i18nError(403, 'platform.apuchat.insufficientScope');
73
+ if (r.status === 409 && /not_transferable/.test(code)) return i18nError(409, 'platform.apuchat.notTransferable');
74
+ if (r.status === 409) return i18nError(409, 'platform.apuchat.taken');
75
+ return i18nError(424, 'platform.apuchat.failed', { status: r.status });
76
+ }
77
+
78
+ // ───────────────────────────── account state ─────────────────────────────
79
+
80
+ const state = (accountId) => getIntegration(accountId, 'apuchat') || {};
81
+ const saveState = (accountId, s) => setIntegration(accountId, 'apuchat', s.managed || s.linked || s.checkouts?.length ? s : null);
82
+ const locks = new Map(); // accountId → Promise (one session refresh / account creation at a time)
83
+
84
+ function locked(accountId, fn) {
85
+ const prev = locks.get(accountId) || Promise.resolve();
86
+ const run = prev.catch(() => {}).then(fn);
87
+ const tail = run.catch(() => {});
88
+ locks.set(accountId, tail);
89
+ tail.then(() => locks.get(accountId) === tail && locks.delete(accountId));
90
+ return run;
91
+ }
92
+
93
+ /** Public view for the dashboard (never tokens). */
94
+ export function apuchatAccountView(accountId) {
95
+ const s = state(accountId);
96
+ return { managed: !!s.managed, linked: s.linked ? { accountId: s.linked.account_id || '', canClaim: can(s.linked, 'identities:write') } : null };
97
+ }
98
+
99
+ /** Whether the app token of the link was granted a scope (older links without a scope list: assume yes). */
100
+ const can = (linked, scope) => !!linked?.app_token && (!Array.isArray(linked.scopes) || linked.scopes.includes(scope));
101
+
102
+ async function createManaged(accountId) {
103
+ const r = await hub('POST', '/api/account', { body: {} });
104
+ if (!r.ok || !r.data.session_token || !r.data.recovery_token) throw hubError(r);
105
+ const s = state(accountId);
106
+ s.managed = { account_id: r.data.account_id, recovery_token: r.data.recovery_token, session_token: r.data.session_token, at: Date.now() };
107
+ saveState(accountId, s);
108
+ return s.managed.session_token;
109
+ }
110
+
111
+ /** Gets a fresh session with the recovery token; if apuchat no longer knows it, starts a new anonymous account. */
112
+ async function recoverManaged(accountId) {
113
+ const s = state(accountId);
114
+ if (!s.managed?.recovery_token) return createManaged(accountId);
115
+ const r = await hub('POST', '/api/account/recover', { body: { recovery_token: s.managed.recovery_token } });
116
+ if (r.ok && r.data.session_token) {
117
+ s.managed = { ...s.managed, session_token: r.data.session_token, at: Date.now() };
118
+ saveState(accountId, s);
119
+ return s.managed.session_token;
120
+ }
121
+ if (r.status === 401 || r.status === 403 || r.status === 404) {
122
+ console.warn('[7ots] apuchat: recovery refused, new anonymous account for', accountId);
123
+ return createManaged(accountId);
124
+ }
125
+ throw hubError(r);
126
+ }
127
+
128
+ /** Session of the managed account (made on first use, renewed when old). */
129
+ function managedSession(accountId, { create = true } = {}) {
130
+ return locked(accountId, async () => {
131
+ const m = state(accountId).managed;
132
+ if (!m) {
133
+ if (!create) return null;
134
+ return createManaged(accountId);
135
+ }
136
+ if (Date.now() - (m.at || 0) > SESSION_REFRESH_MS) return recoverManaged(accountId);
137
+ return m.session_token;
138
+ });
139
+ }
140
+
141
+ /**
142
+ * A call as one of the account's apuchat accounts. 'managed' renews its session once on 401;
143
+ * 'linked' uses the app token (a 401 means the user revoked it: the link is forgotten).
144
+ */
145
+ async function as(accountId, owner, method, path, o = {}) {
146
+ if (owner === 'linked') {
147
+ const l = state(accountId).linked;
148
+ if (!l?.app_token) throw i18nError(409, 'platform.apuchat.notLinked');
149
+ const r = await hub(method, path, { ...o, bearer: l.app_token });
150
+ if (r.status === 401) {
151
+ const s = state(accountId);
152
+ delete s.linked;
153
+ saveState(accountId, s);
154
+ throw i18nError(409, 'platform.apuchat.linkExpired');
155
+ }
156
+ return r;
157
+ }
158
+ let token = await managedSession(accountId);
159
+ let r = await hub(method, path, { ...o, bearer: token });
160
+ if (r.status === 401) {
161
+ token = await locked(accountId, () => recoverManaged(accountId));
162
+ r = await hub(method, path, { ...o, bearer: token });
163
+ }
164
+ return r;
165
+ }
166
+
167
+ /** Which apuchat account new identities go to: the user's own when linked with a token, else the managed one. */
168
+ export const defaultOwner = (accountId, scope = 'identities:write') => (can(state(accountId).linked, scope) ? 'linked' : 'managed');
169
+
170
+ // ───────────────────────────── identities ─────────────────────────────
171
+
172
+ const madeToday = new Map(); // accountId → timestamps (free identities made in the last day)
173
+
174
+ /** A new free identity. Limited per account and day (each one is a handle taken on apuchat). */
175
+ export async function createFreeIdentity(accountId) {
176
+ const now = Date.now();
177
+ const recent = (madeToday.get(accountId) || []).filter((t) => now - t < 86400_000);
178
+ if (recent.length >= IDENTITIES_PER_DAY()) throw i18nError(429, 'platform.apuchat.tooManyIdentities', { max: IDENTITIES_PER_DAY() });
179
+ const owner = defaultOwner(accountId);
180
+ const r = await as(accountId, owner, 'POST', '/api/account/identities', { body: {} });
181
+ if (!r.ok || !r.data.identity_key || !r.data.callsign) throw hubError(r);
182
+ recent.push(now);
183
+ madeToday.set(accountId, recent);
184
+ if (madeToday.size > 5000) madeToday.clear();
185
+ return { callsign: String(r.data.callsign).toLowerCase(), identityKey: r.data.identity_key, free: r.data.free_identity !== false, owner };
186
+ }
187
+
188
+ /** Deletes an identity on apuchat (only the free ones the platform made; best effort). */
189
+ export async function deleteIdentity(accountId, owner, callsign) {
190
+ if (!CALLSIGN_RE.test(callsign || '') || (owner !== 'managed' && owner !== 'linked')) return false;
191
+ const r = await as(accountId, owner, 'DELETE', `/api/account/identities/${encodeURIComponent(callsign)}`).catch(() => null);
192
+ return !!r?.ok;
193
+ }
194
+
195
+ /** The @callsign of an identity key, or null if apuchat does not accept it. */
196
+ export async function callsignOf(identityKey) {
197
+ const r = await hub('GET', '/api/dm/wait?timeout=1', { identityKey, timeoutMs: 20000 });
198
+ if (r.status === 401 || r.status === 403 || r.status === 404) return null;
199
+ if (!r.ok) throw hubError(r);
200
+ return r.data.callsign ? String(r.data.callsign).toLowerCase() : null;
201
+ }
202
+
203
+ // ───────────────────────────── permanent handle (paid) ─────────────────────────────
204
+
205
+ export async function quote(accountId, owner, callsign) {
206
+ const r = await as(accountId, owner, 'GET', `/api/identities/mint/quote?callsign=${encodeURIComponent(callsign)}`);
207
+ if (!r.ok) throw hubError(r);
208
+ const q = r.data;
209
+ return { callsign, valid: !!q.valid, taken: !!q.taken, reason: q.reason || null, tier: q.tier || null, tierLabel: q.tier_label || null, priceUsd: q.price_usd ?? null };
210
+ }
211
+
212
+ /** Starts a card checkout; the checkout is remembered so only this ot can read its result. */
213
+ export async function checkout(accountId, owner, { otsId, callsign, returnUrl }) {
214
+ const r = await as(accountId, owner, 'POST', '/api/identities/mint/checkout', { body: { callsign, return_url: returnUrl } });
215
+ if (!r.ok || !r.data.checkout_id || !r.data.url) throw hubError(r);
216
+ const s = state(accountId);
217
+ s.checkouts = [{ id: String(r.data.checkout_id), otsId, callsign, owner, at: Date.now() }, ...(s.checkouts || []).filter((c) => Date.now() - c.at < 7 * 86400_000)].slice(0, 20);
218
+ saveState(accountId, s);
219
+ return { checkoutId: String(r.data.checkout_id), url: String(r.data.url), priceUsd: r.data.price_usd ?? null, tier: r.data.tier || null };
220
+ }
221
+
222
+ export const pendingCheckouts = (accountId, otsId) => (state(accountId).checkouts || []).filter((c) => c.otsId === otsId).map(({ id, callsign, at }) => ({ id, callsign, at }));
223
+
224
+ /** Status of a checkout started for this ot (null if it isn't one of its own). Fulfilled/failed ones are forgotten. */
225
+ export async function checkoutStatus(accountId, otsId, id) {
226
+ const c = (state(accountId).checkouts || []).find((x) => x.id === String(id) && x.otsId === otsId);
227
+ if (!c) return null;
228
+ const r = await as(accountId, c.owner, 'GET', `/api/identities/mint/checkout/${encodeURIComponent(c.id)}`);
229
+ if (!r.ok) throw hubError(r);
230
+ const status = ['pending', 'fulfilled', 'failed'].includes(r.data.status) ? r.data.status : 'pending';
231
+ if (status !== 'pending') {
232
+ const s = state(accountId);
233
+ s.checkouts = (s.checkouts || []).filter((x) => x.id !== c.id);
234
+ saveState(accountId, s);
235
+ }
236
+ return { status, callsign: String(r.data.callsign || c.callsign).toLowerCase(), upgraded: !!r.data.upgraded, identityKey: r.data.identity_key || null, owner: c.owner };
237
+ }
238
+
239
+ // ───────────────────────────── linking the user's apuchat account ─────────────────────────────
240
+
241
+ /** Where to send the browser to link (apuchat appends `code`; it doesn't echo `state`, so it rides in the return URL). */
242
+ export const linkUrl = (returnUrl) => `${HUB}/link?${new URLSearchParams({ app: '7ots', return: returnUrl })}`;
243
+
244
+ export async function exchangeLink(accountId, code) {
245
+ const r = await hub('POST', '/api/v1/link/exchange', { body: { code, app: '7ots' } });
246
+ if (!r.ok || !r.data.account_id) throw hubError(r);
247
+ const s = state(accountId);
248
+ const scopes = Array.isArray(r.data.scopes) ? r.data.scopes.map(String) : typeof r.data.scopes === 'string' ? r.data.scopes.split(/[\s,]+/).filter(Boolean) : null;
249
+ // The app token is shown once: it lives only sealed in the database and is never logged.
250
+ s.linked = { account_id: String(r.data.account_id), app_token: r.data.app_token || '', scopes, at: Date.now() };
251
+ saveState(accountId, s);
252
+ return { accountId: s.linked.account_id, canClaim: can(s.linked, 'identities:write') };
253
+ }
254
+
255
+ export function unlink(accountId) {
256
+ const s = state(accountId);
257
+ delete s.linked;
258
+ saveState(accountId, s);
259
+ }
260
+
261
+ /** Moves an identity (by its key) into the linked account. */
262
+ export async function claimIdentity(accountId, identityKey) {
263
+ const r = await as(accountId, 'linked', 'POST', '/api/account/identities/claim', { body: { identity_key: identityKey } });
264
+ if (!r.ok) throw hubError(r);
265
+ return { callsign: r.data.callsign ? String(r.data.callsign).toLowerCase() : null, moved: r.data.moved !== false, paid: !!r.data.paid };
266
+ }