@7ots/cli 0.1.0

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 (97) hide show
  1. package/.env.example +140 -0
  2. package/LICENSE +21 -0
  3. package/README.md +655 -0
  4. package/brand/apple-touch-icon.png +0 -0
  5. package/brand/favicon.svg +4 -0
  6. package/brand/icon-512.png +0 -0
  7. package/brand/mark-mono.svg +3 -0
  8. package/brand/mark.svg +4 -0
  9. package/brand/og-es.png +0 -0
  10. package/brand/og-pt.png +0 -0
  11. package/brand/og.png +0 -0
  12. package/brand/ots.json +11 -0
  13. package/brand/tokens.css +59 -0
  14. package/brand/wordmark.svg +5 -0
  15. package/cli/7ots.mjs +400 -0
  16. package/cli/lib/brain.mjs +143 -0
  17. package/cli/lib/config.mjs +70 -0
  18. package/cli/lib/hooks.mjs +151 -0
  19. package/cli/lib/i18n.mjs +480 -0
  20. package/cli/lib/identity.mjs +51 -0
  21. package/cli/lib/install.mjs +79 -0
  22. package/cli/lib/lines.mjs +73 -0
  23. package/cli/lib/meet.mjs +166 -0
  24. package/cli/lib/open.mjs +19 -0
  25. package/cli/lib/orquesta.mjs +84 -0
  26. package/cli/lib/paths.mjs +49 -0
  27. package/cli/lib/pet-core.mjs +184 -0
  28. package/cli/lib/pet-server.mjs +296 -0
  29. package/cli/lib/prompts.mjs +75 -0
  30. package/cli/lib/terminal.mjs +145 -0
  31. package/cli/lib/ui.mjs +234 -0
  32. package/cli/lib/wizard.mjs +279 -0
  33. package/cli/pet/electron/main.cjs +86 -0
  34. package/cli/pet/pet.html +404 -0
  35. package/dist/7ots.esm.js +627 -0
  36. package/dist/7ots.esm.js.map +7 -0
  37. package/dist/7ots.iife.js +627 -0
  38. package/dist/7ots.iife.js.map +7 -0
  39. package/llms.txt +57 -0
  40. package/package.json +73 -0
  41. package/server/admin.mjs +213 -0
  42. package/server/apuchat-relay.mjs +170 -0
  43. package/server/channels/agent.mjs +136 -0
  44. package/server/channels/apuchat.mjs +364 -0
  45. package/server/channels/apumail.mjs +211 -0
  46. package/server/channels/index.mjs +69 -0
  47. package/server/contact.mjs +151 -0
  48. package/server/demo.mjs +154 -0
  49. package/server/i18n.mjs +54 -0
  50. package/server/identity.mjs +108 -0
  51. package/server/llm.mjs +413 -0
  52. package/server/platform/auth.mjs +290 -0
  53. package/server/platform/crypto.mjs +80 -0
  54. package/server/platform/db.mjs +130 -0
  55. package/server/platform/routes.mjs +388 -0
  56. package/server/platform/runtime.mjs +180 -0
  57. package/server/platform/store.mjs +331 -0
  58. package/server/sdk.mjs +125 -0
  59. package/server/server.mjs +456 -0
  60. package/server/settings.mjs +177 -0
  61. package/server/tts.mjs +186 -0
  62. package/skills/7ots/SKILL.md +59 -0
  63. package/src/actions/ActionRegistry.js +254 -0
  64. package/src/actions/PageTools.js +270 -0
  65. package/src/actions/builtins.js +565 -0
  66. package/src/auth/AuthManager.js +128 -0
  67. package/src/avatar/AvatarStage.js +473 -0
  68. package/src/character/AvatarEditor.js +829 -0
  69. package/src/character/Character.js +1191 -0
  70. package/src/character/motion.js +794 -0
  71. package/src/character/parts.js +559 -0
  72. package/src/context/ContextManager.js +472 -0
  73. package/src/core/AgentBrain.js +341 -0
  74. package/src/core/AgentWidget.js +1059 -0
  75. package/src/core/EventBus.js +61 -0
  76. package/src/core/Proactivity.js +363 -0
  77. package/src/core/storage.js +35 -0
  78. package/src/i18n/index.js +193 -0
  79. package/src/i18n/messages/character.js +543 -0
  80. package/src/i18n/messages/identity.js +141 -0
  81. package/src/i18n/messages/platform.js +114 -0
  82. package/src/i18n/messages/server.js +630 -0
  83. package/src/i18n/messages/widget.js +362 -0
  84. package/src/identity/ContactCard.js +351 -0
  85. package/src/identity/Face.js +148 -0
  86. package/src/identity/random.js +112 -0
  87. package/src/identity/schema.js +262 -0
  88. package/src/index.js +117 -0
  89. package/src/integrations/apuchat.js +212 -0
  90. package/src/integrations/apumail.js +88 -0
  91. package/src/llm/ProxyLLM.js +87 -0
  92. package/src/mcp/McpClient.js +153 -0
  93. package/src/ui/Companion.js +498 -0
  94. package/src/ui/UIManager.js +425 -0
  95. package/src/ui/VirtualPointer.js +746 -0
  96. package/src/ui/markdown.js +94 -0
  97. package/src/voice/VoiceEngine.js +227 -0
@@ -0,0 +1,404 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>7ots</title>
7
+ <style>
8
+ :root {
9
+ --ink: #17141f; --ink-2: #4a4458; --muted: #8a8399; --bg: #f3f1f8; --panel: #ffffff; --panel-2: #f8f7fb;
10
+ --line: #e8e4f0; --accent: #6d5dfc; --accent-ink: #fff; --ok: #12a150; --warn: #e08a00; --err: #e5484d;
11
+ --shadow: 0 1px 2px rgb(20 16 40 / .06), 0 12px 40px -12px rgb(20 16 40 / .18);
12
+ --r: 18px; color-scheme: light;
13
+ --font: ui-sans-serif, -apple-system, 'Segoe UI', Inter, Roboto, system-ui, sans-serif;
14
+ --mono: ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, Consolas, monospace;
15
+ }
16
+ @media (prefers-color-scheme: dark) {
17
+ :root { --ink: #f2f0f8; --ink-2: #c9c3d6; --muted: #8d86a0; --bg: #0f0d14; --panel: #18151f; --panel-2: #1e1a27; --line: #2a2535; --shadow: 0 1px 2px rgb(0 0 0 / .4), 0 18px 50px -16px rgb(0 0 0 / .7); color-scheme: dark; }
18
+ }
19
+ * { box-sizing: border-box; }
20
+ html, body { margin: 0; min-height: 100%; font: 14px/1.45 var(--font); color: var(--ink); background: var(--bg); -webkit-font-smoothing: antialiased; }
21
+ body { display: grid; place-items: start center; padding: 24px 16px; }
22
+ button { font: inherit; color: inherit; }
23
+ svg { display: block; }
24
+
25
+ .app { width: min(400px, 100%); background: var(--panel); border: 1px solid var(--line); border-radius: 24px; box-shadow: var(--shadow); overflow: hidden; }
26
+
27
+ /* top bar */
28
+ .top { display: flex; align-items: center; gap: 10px; padding: 12px 14px 12px 16px; border-bottom: 1px solid var(--line); }
29
+ .brand { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 13px; letter-spacing: -.01em; }
30
+ .brand i { width: 20px; height: 20px; border-radius: 6px; background: var(--ink); color: var(--panel); display: grid; place-items: center; font: 800 10px/1 var(--font); font-style: normal; }
31
+ .sp { flex: 1; }
32
+ .live { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
33
+ .live::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: var(--muted); }
34
+ .live.on::before { background: var(--ok); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ok) 22%, transparent); }
35
+
36
+ /* stage */
37
+ .hero { position: relative; padding: 64px 20px 18px; display: grid; justify-items: center;
38
+ background: radial-gradient(120% 80% at 50% 38%, color-mix(in srgb, var(--accent) 16%, transparent), transparent 70%); }
39
+ .stage { position: relative; width: 168px; height: 176px; }
40
+ .face { position: relative; z-index: 1; width: 100%; height: 100%; transition: transform .3s; }
41
+ .floor { position: absolute; left: 50%; bottom: 2px; width: 110px; height: 14px; transform: translateX(-50%); border-radius: 50%; background: radial-gradient(closest-side, rgb(0 0 0 / .18), transparent); }
42
+ .bubble { position: absolute; z-index: 2; left: 50%; bottom: calc(100% - 4px); width: max-content; max-width: min(300px, 86vw); padding: 10px 14px; border-radius: 16px;
43
+ background: var(--panel); color: var(--ink); border: 1px solid var(--line); box-shadow: var(--shadow); font-size: 13.5px; line-height: 1.4; text-align: left;
44
+ opacity: 0; transform: translate(-50%, 6px) scale(.97); transform-origin: 50% 100%; transition: opacity .22s, transform .22s; pointer-events: none; }
45
+ .bubble.on { opacity: 1; transform: translate(-50%, 0) scale(1); }
46
+ .bubble::after { content: ''; position: absolute; left: 50%; top: 100%; width: 12px; height: 12px; margin: -6px 0 0 -6px; background: inherit; border: inherit; border-width: 0 1px 1px 0; transform: rotate(45deg); }
47
+ .dots { display: inline-flex; gap: 4px; padding: 2px 0; }
48
+ .dots b { width: 6px; height: 6px; border-radius: 50%; background: var(--muted); animation: dot 1s infinite ease-in-out; }
49
+ .dots b:nth-child(2) { animation-delay: .15s; } .dots b:nth-child(3) { animation-delay: .3s; }
50
+ @keyframes dot { 0%, 80%, 100% { opacity: .3; transform: none; } 40% { opacity: 1; transform: translateY(-3px); } }
51
+ body.asleep .face { filter: saturate(.6) brightness(.95); }
52
+ .zz { position: absolute; right: 8px; top: 6px; font: 700 15px var(--font); color: var(--muted); opacity: 0; }
53
+ body.asleep .zz { opacity: 1; animation: zz 2.4s infinite ease-in-out; }
54
+ @keyframes zz { 50% { transform: translate(4px, -6px); opacity: .5; } }
55
+
56
+ .who { text-align: center; margin-top: 10px; }
57
+ h1 { margin: 0; font-size: 22px; line-height: 1.15; font-weight: 750; letter-spacing: -.025em; }
58
+ .sub { margin-top: 4px; display: flex; gap: 8px; align-items: center; justify-content: center; color: var(--muted); font-size: 13px; }
59
+ .mood { display: inline-flex; align-items: center; gap: 6px; padding: 2px 9px 2px 7px; border-radius: 99px; background: var(--panel-2); border: 1px solid var(--line); color: var(--ink-2); font-size: 12px; font-weight: 600; }
60
+ .mood::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: var(--mood, var(--muted)); }
61
+
62
+ /* level */
63
+ .level { padding: 14px 18px 4px; }
64
+ .level-h { display: flex; justify-content: space-between; align-items: baseline; font-size: 12px; color: var(--muted); margin-bottom: 6px; }
65
+ .level-h b { color: var(--ink); font-size: 13px; }
66
+ .track { height: 6px; border-radius: 99px; background: var(--panel-2); border: 1px solid var(--line); overflow: hidden; }
67
+ .track > span { display: block; height: 100%; border-radius: inherit; background: var(--accent); transition: width .5s cubic-bezier(.2, .8, .2, 1); }
68
+
69
+ /* stats */
70
+ .stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; padding: 12px 18px 4px; }
71
+ .stat { padding: 10px 10px 11px; border-radius: 14px; background: var(--panel-2); border: 1px solid var(--line); }
72
+ .stat-h { display: flex; align-items: center; gap: 6px; font-size: 11.5px; color: var(--muted); font-weight: 600; }
73
+ .stat-h svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
74
+ .stat-v { font-size: 18px; font-weight: 750; letter-spacing: -.02em; margin: 4px 0 6px; font-variant-numeric: tabular-nums; }
75
+ .stat .track { height: 4px; border: 0; background: var(--line); }
76
+ .stat .track > span { background: var(--ok); }
77
+ .stat.mid .track > span { background: var(--warn); } .stat.low .track > span { background: var(--err); }
78
+ .stat.low .stat-v { color: var(--err); }
79
+
80
+ /* actions */
81
+ .actions { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; padding: 12px 18px 16px; }
82
+ .act { display: flex; align-items: center; justify-content: center; gap: 7px; height: 38px; border-radius: 12px; border: 1px solid var(--line); background: var(--panel); font-weight: 650; font-size: 13px; cursor: pointer; transition: background .15s, border-color .15s, transform .1s; }
83
+ .act svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
84
+ .act:hover { border-color: color-mix(in srgb, var(--accent) 50%, var(--line)); background: color-mix(in srgb, var(--accent) 7%, var(--panel)); }
85
+ .act:active { transform: scale(.97); }
86
+ .act:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
87
+ .act.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
88
+ .act.primary:hover { background: color-mix(in srgb, var(--accent) 88%, #000); }
89
+
90
+ /* activity */
91
+ .feed { border-top: 1px solid var(--line); padding: 12px 18px 6px; }
92
+ .feed-h { display: flex; justify-content: space-between; align-items: center; font-size: 11.5px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); margin-bottom: 4px; }
93
+ .feed-h h2 { margin: 0; font: inherit; }
94
+ .feed-h #tally { letter-spacing: 0; text-transform: none; font-weight: 500; font-variant-numeric: tabular-nums; }
95
+ .feed ol { list-style: none; margin: 0; padding: 0; max-height: 196px; overflow: auto; }
96
+ .feed li { display: grid; grid-template-columns: 22px 1fr auto; gap: 8px; align-items: center; padding: 6px 0; border-bottom: 1px dashed var(--line); font-size: 12.5px; animation: in .3s ease-out; }
97
+ .feed li:last-child { border-bottom: 0; }
98
+ @keyframes in { from { opacity: 0; transform: translateY(-4px); } }
99
+ .ico { width: 22px; height: 22px; border-radius: 7px; display: grid; place-items: center; background: var(--panel-2); color: var(--ink-2); }
100
+ .ico svg { width: 12px; height: 12px; fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
101
+ .ok .ico { background: color-mix(in srgb, var(--ok) 14%, transparent); color: var(--ok); }
102
+ .bad .ico { background: color-mix(in srgb, var(--err) 14%, transparent); color: var(--err); }
103
+ .feed .txt { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink-2); }
104
+ .feed .txt code { font: 12px var(--mono); color: var(--ink); }
105
+ .feed time { color: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; }
106
+ .empty { color: var(--muted); font-size: 12.5px; padding: 8px 0 10px; }
107
+
108
+ .foot { display: flex; gap: 6px 12px; flex-wrap: wrap; justify-content: space-between; padding: 10px 18px 14px; color: var(--muted); font-size: 11.5px; border-top: 1px solid var(--line); background: var(--panel-2); }
109
+ .foot b { color: var(--ink-2); font-weight: 600; }
110
+
111
+ /* ── desktop: transparent frameless window walking along the bottom of the screen ── */
112
+ body.desktop { background: transparent; padding: 0; place-items: end center; height: 100vh; overflow: hidden; -webkit-app-region: drag; user-select: none; }
113
+ body.desktop .app { width: 100%; background: transparent; border: 0; box-shadow: none; border-radius: 0; overflow: visible; }
114
+ body.desktop .top, body.desktop .who, body.desktop .level, body.desktop .stats, body.desktop .feed, body.desktop .foot { display: none; }
115
+ body.desktop .hero { background: none; padding: 0 0 6px; }
116
+ body.desktop .stage { width: 140px; height: 148px; }
117
+ body.desktop .stage.flip .face { transform: scaleX(-1); }
118
+ body.desktop .bubble { max-width: 220px; font-size: 12.5px; padding: 8px 12px; }
119
+ body.desktop .actions { position: absolute; right: 8px; bottom: 14px; transform: translateX(6px); display: flex; flex-direction: column; gap: 4px; padding: 4px; border-radius: 99px;
120
+ background: color-mix(in srgb, var(--panel) 86%, transparent); border: 1px solid var(--line); box-shadow: var(--shadow); backdrop-filter: blur(10px);
121
+ opacity: 0; transition: opacity .18s, transform .18s; -webkit-app-region: no-drag; z-index: 3; }
122
+ body.desktop:hover .actions { opacity: 1; transform: none; }
123
+ body.desktop .act { width: 32px; height: 32px; padding: 0; border-radius: 50%; border: 0; background: transparent; }
124
+ body.desktop .act span { display: none; }
125
+ body.desktop .act.primary { background: var(--accent); }
126
+ body.desktop .badge { display: flex; }
127
+ .badge { display: none; position: absolute; z-index: 2; left: -6px; bottom: 18px; min-width: 22px; height: 22px; padding: 0 6px; border-radius: 99px; align-items: center; justify-content: center;
128
+ background: var(--panel); border: 1px solid var(--line); box-shadow: var(--shadow); font: 750 11px/1 var(--font); color: var(--ink); }
129
+ body.desktop .floor { bottom: 0; }
130
+
131
+ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } }
132
+ </style>
133
+ </head>
134
+ <body>
135
+ <main class="app">
136
+ <header class="top">
137
+ <span class="brand"><i>7</i>7ots</span>
138
+ <span class="sp"></span>
139
+ <span class="live" id="live"></span>
140
+ </header>
141
+
142
+ <section class="hero">
143
+ <div class="stage" id="stage">
144
+ <div class="bubble" id="bubble" role="status" aria-live="polite"></div>
145
+ <span class="zz" aria-hidden="true">z</span>
146
+ <div class="face" id="face"></div>
147
+ <div class="floor" aria-hidden="true"></div>
148
+ <span class="badge" id="badge"></span>
149
+ </div>
150
+ <div class="who">
151
+ <h1 id="name"></h1>
152
+ <div class="sub"><span id="role"></span><span class="mood" id="mood"></span></div>
153
+ </div>
154
+ </section>
155
+
156
+ <section class="level" aria-labelledby="lvl">
157
+ <div class="level-h"><b id="lvl"></b><span id="xp"></span></div>
158
+ <div class="track"><span id="xpbar" style="width:0"></span></div>
159
+ </section>
160
+
161
+ <section class="stats" id="stats"></section>
162
+
163
+ <nav class="actions">
164
+ <button class="act primary" data-act="feed"><svg viewBox="0 0 24 24"><path d="M12 7c-1.5-2.5-6-2.5-7 1.5S7 20 9.5 20c1.2 0 1.6-.6 2.5-.6s1.3.6 2.5.6C17 20 20 12.5 19 8.5S13.5 4.5 12 7z"/><path d="M12 7c0-2 1-3.5 3-4"/></svg><span data-t="feed"></span></button>
165
+ <button class="act" data-act="play"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M5 8.5c3 1.5 11 1.5 14 0M5 15.5c3-1.5 11-1.5 14 0"/></svg><span data-t="play"></span></button>
166
+ <button class="act" data-act="sleep" id="sleepBtn"><svg viewBox="0 0 24 24"><path d="M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5z"/></svg><span data-t="sleep"></span></button>
167
+ </nav>
168
+
169
+ <section class="feed" aria-labelledby="feed-h">
170
+ <div class="feed-h"><h2 id="feed-h" data-t="activity"></h2><span id="tally"></span></div>
171
+ <ol id="feed"></ol>
172
+ </section>
173
+
174
+ <footer class="foot"><span id="brain"></span><span id="age"></span></footer>
175
+ </main>
176
+
177
+ <script type="module">
178
+ const BOOT = __BOOT__;
179
+ const T = {
180
+ en: { feed: 'Feed', play: 'Play', sleep: 'Sleep', wake: 'Wake', food: 'Food', energy: 'Energy', fun: 'Fun', level: 'Level {l}', xp: '{x} / {n} xp', live: 'Live', off: 'Reconnecting…',
181
+ activity: 'Activity', empty: 'Nothing yet. Run a command or ask your coding agent something.', tally: '{ok} ok · {fail} failed · turns {turns}',
182
+ brain: 'Brain: {b}', age: 'Born {d}', lines: 'built-in phrases', now: 'now', m: '{n}m', h: '{n}h',
183
+ moods: { happy: 'Happy', neutral: 'Calm', sad: 'Sad', disgust: 'Bored', sleep: 'Asleep', thinking: 'Thinking' },
184
+ kind: { tool: 'Tool', shell: 'Shell', prompt: 'You asked', stop: 'Turn finished', notify: 'Needs you', start: 'Session started' } },
185
+ es: { feed: 'Comida', play: 'Jugar', sleep: 'Dormir', wake: 'Despertar', food: 'Comida', energy: 'Energía', fun: 'Diversión', level: 'Nivel {l}', xp: '{x} / {n} xp', live: 'En vivo', off: 'Reconectando…',
186
+ activity: 'Actividad', empty: 'Nada todavía. Ejecuta un comando o pregúntale algo a tu agente.', tally: '{ok} ok · {fail} fallos · turnos {turns}',
187
+ brain: 'Cerebro: {b}', age: 'Nació {d}', lines: 'frases propias', now: 'ahora', m: '{n} min', h: '{n} h',
188
+ moods: { happy: 'Feliz', neutral: 'Tranquilo', sad: 'Triste', disgust: 'Aburrido', sleep: 'Dormido', thinking: 'Pensando' },
189
+ kind: { tool: 'Herramienta', shell: 'Terminal', prompt: 'Preguntaste', stop: 'Turno terminado', notify: 'Te necesita', start: 'Sesión iniciada' } },
190
+ pt: { feed: 'Comida', play: 'Brincar', sleep: 'Dormir', wake: 'Acordar', food: 'Comida', energy: 'Energia', fun: 'Diversão', level: 'Nível {l}', xp: '{x} / {n} xp', live: 'Ao vivo', off: 'Reconectando…',
191
+ activity: 'Atividade', empty: 'Nada ainda. Rode um comando ou pergunte algo ao seu agente.', tally: '{ok} ok · {fail} falhas · turnos {turns}',
192
+ brain: 'Cérebro: {b}', age: 'Nasceu {d}', lines: 'frases próprias', now: 'agora', m: '{n} min', h: '{n} h',
193
+ moods: { happy: 'Feliz', neutral: 'Tranquilo', sad: 'Triste', disgust: 'Entediado', sleep: 'Dormindo', thinking: 'Pensando' },
194
+ kind: { tool: 'Ferramenta', shell: 'Terminal', prompt: 'Você pediu', stop: 'Turno concluído', notify: 'Precisa de você', start: 'Sessão iniciada' } },
195
+ };
196
+ const lang = T[BOOT.lang] ? BOOT.lang : 'en';
197
+ const L = T[lang];
198
+ const t = (k, v = {}) => String(k.split('.').reduce((o, p) => o?.[p], L) ?? k).replace(/\{(\w+)\}/g, (_, x) => v[x] ?? '');
199
+ const $ = (s) => document.querySelector(s);
200
+ const id = BOOT.identity;
201
+ const desktop = new URLSearchParams(location.search).has('desktop');
202
+
203
+ document.documentElement.lang = lang;
204
+ document.body.classList.toggle('desktop', desktop);
205
+ document.title = `${id.name} · 7ots`;
206
+ $('#name').textContent = id.name;
207
+ $('#role').textContent = id.role || '';
208
+ document.querySelectorAll('[data-t]').forEach((el) => (el.textContent = t(el.dataset.t)));
209
+ document.querySelectorAll('[data-act]').forEach((b) => b.setAttribute('aria-label', t(b.dataset.act)));
210
+ const accent = id.look?.character?.body?.color;
211
+ if (/^#[0-9a-f]{6}$/i.test(accent || '')) {
212
+ // Readable text on the accent: dark ink on light bodies.
213
+ const [r, g, b] = [1, 3, 5].map((i) => parseInt(accent.slice(i, i + 2), 16));
214
+ document.documentElement.style.setProperty('--accent', accent);
215
+ document.documentElement.style.setProperty('--accent-ink', 0.299 * r + 0.587 * g + 0.114 * b > 140 ? '#17141f' : '#ffffff');
216
+ }
217
+ $('#brain').innerHTML = '';
218
+ $('#brain').append(t('brain', { b: '' }), Object.assign(document.createElement('b'), { textContent: BOOT.brain === 'lines' ? t('lines') : BOOT.brain }));
219
+
220
+ const { createCharacter } = await import('/dist/7ots.esm.js');
221
+ const ch = createCharacter($('#face'), id.look?.character || {}, { locale: lang, title: id.name });
222
+ addEventListener('pointermove', (e) => ch.lookAtPoint(e.clientX, e.clientY));
223
+ $('#face').addEventListener('click', () => ch.gesture('bounce'));
224
+
225
+ const post = (path, body) => fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-7ots-Token': BOOT.token }, body: JSON.stringify(body) });
226
+ let asleep = false;
227
+ document.querySelectorAll('[data-act]').forEach((b) =>
228
+ b.addEventListener('click', () => post('/event', { type: b.dataset.act === 'sleep' && asleep ? 'wake' : b.dataset.act })),
229
+ );
230
+
231
+ // ── state ──
232
+ const ICON = {
233
+ food: '<path d="M12 7c-1.5-2.5-6-2.5-7 1.5S7 20 9.5 20c1.2 0 1.6-.6 2.5-.6s1.3.6 2.5.6C17 20 20 12.5 19 8.5S13.5 4.5 12 7z"/>',
234
+ energy: '<path d="M13 3 5 13.5h6L10 21l9-11h-6z"/>',
235
+ fun: '<path d="M12 20s-7.5-4.6-7.5-10A4.3 4.3 0 0 1 12 7.4 4.3 4.3 0 0 1 19.5 10c0 5.4-7.5 10-7.5 10z"/>',
236
+ };
237
+ const MOOD_COLOR = { happy: 'var(--ok)', neutral: 'var(--accent)', sad: 'var(--err)', disgust: 'var(--warn)', sleep: 'var(--muted)', thinking: 'var(--accent)' };
238
+ const stats = $('#stats');
239
+ for (const k of ['food', 'energy', 'fun']) {
240
+ stats.insertAdjacentHTML('beforeend', `<div class="stat" data-k="${k}"><div class="stat-h"><svg viewBox="0 0 24 24">${ICON[k]}</svg>${t(k)}</div><div class="stat-v">–</div><div class="track"><span style="width:0"></span></div></div>`);
241
+ }
242
+ let last = null;
243
+ let thinking = false;
244
+ function paintMood() {
245
+ const m = thinking ? 'thinking' : last?.mood || 'neutral';
246
+ $('#mood').textContent = t(`moods.${m}`);
247
+ $('#mood').style.setProperty('--mood', MOOD_COLOR[m] || 'var(--muted)');
248
+ }
249
+ function render(s) {
250
+ last = s;
251
+ asleep = s.asleep;
252
+ document.body.classList.toggle('asleep', asleep);
253
+ ch.mood(s.mood);
254
+ paintMood();
255
+ $('#sleepBtn [data-t]').textContent = t(asleep ? 'wake' : 'sleep');
256
+ $('#sleepBtn').setAttribute('aria-label', t(asleep ? 'wake' : 'sleep'));
257
+ for (const el of stats.children) {
258
+ const v = s[el.dataset.k];
259
+ el.className = `stat ${v < 25 ? 'low' : v < 50 ? 'mid' : ''}`;
260
+ el.querySelector('.stat-v').textContent = v;
261
+ el.querySelector('.track span').style.width = `${v}%`;
262
+ }
263
+ $('#lvl').textContent = t('level', { l: s.level });
264
+ $('#badge').textContent = s.level;
265
+ $('#xp').textContent = t('xp', { x: s.xp, n: s.next });
266
+ $('#xpbar').style.width = `${Math.min(100, (s.xp / s.next) * 100)}%`;
267
+ const st = s.stats || {};
268
+ $('#tally').textContent = t('tally', { ok: st.ok || 0, fail: st.fail || 0, turns: st.turns || 0 });
269
+ if (s.born) $('#age').textContent = t('age', { d: new Date(s.born).toLocaleDateString(lang, { month: 'short', day: 'numeric' }) });
270
+ }
271
+
272
+ // ── activity feed ──
273
+ const FEED_ICON = {
274
+ ok: '<path d="M5 12.5l4.5 4.5L19 7.5"/>', bad: '<path d="M7 7l10 10M17 7 7 17"/>',
275
+ prompt: '<path d="M5 6h14v9H9l-4 3z"/>', stop: '<path d="M5 12.5l4.5 4.5L19 7.5"/>',
276
+ notify: '<path d="M12 4a5 5 0 0 1 5 5v4l1.5 3h-13L7 13V9a5 5 0 0 1 5-5zM10 19a2 2 0 0 0 4 0"/>', start: '<path d="M8 5v14l11-7z"/>',
277
+ };
278
+ const ago = (at) => {
279
+ const s = (Date.now() - at) / 1000;
280
+ return s < 60 ? t('now') : s < 3600 ? t('m', { n: Math.floor(s / 60) }) : t('h', { n: Math.floor(s / 3600) });
281
+ };
282
+ const rows = [];
283
+ function addActivity(a) {
284
+ rows.unshift(a);
285
+ if (rows.length > 30) rows.pop();
286
+ paintFeed();
287
+ }
288
+ function paintFeed() {
289
+ const ol = $('#feed');
290
+ ol.textContent = '';
291
+ if (!rows.length) return ol.insertAdjacentHTML('beforeend', `<li class="empty" style="display:block;border:0">${t('empty')}</li>`);
292
+ for (const a of rows.slice(0, 20)) {
293
+ const li = document.createElement('li');
294
+ const tone = ['tool', 'shell'].includes(a.kind) ? (a.ok ? 'ok' : 'bad') : '';
295
+ li.className = tone;
296
+ const ic = ['tool', 'shell'].includes(a.kind) ? (a.ok ? 'ok' : 'bad') : a.kind;
297
+ li.innerHTML = `<span class="ico"><svg viewBox="0 0 24 24">${FEED_ICON[ic] || FEED_ICON.start}</svg></span><span class="txt"></span><time></time>`;
298
+ const txt = li.querySelector('.txt');
299
+ if (a.kind === 'shell' || a.kind === 'tool') txt.append(Object.assign(document.createElement('code'), { textContent: a.text }));
300
+ else txt.textContent = a.text ? `${t(`kind.${a.kind}`)} · ${a.text}` : t(`kind.${a.kind}`);
301
+ txt.title = a.text;
302
+ li.querySelector('time').textContent = ago(a.at);
303
+ ol.append(li);
304
+ }
305
+ }
306
+ for (const a of (BOOT.activity || []).slice().reverse()) rows.push(a);
307
+ paintFeed();
308
+ setInterval(paintFeed, 30_000);
309
+
310
+ // ── voice ──
311
+ const bubble = $('#bubble');
312
+ let hideT, lip;
313
+ function talk(on, text, ms) {
314
+ lip?.stop?.();
315
+ lip = null;
316
+ if (!on) return ch.mouth(0);
317
+ if (typeof ch.speak !== 'function') return; // older character bundle
318
+ lip = ch.speak(text, { durationMs: ms || 500 + text.length * 60 });
319
+ }
320
+ async function speak(text) {
321
+ if (BOOT.voice?.kind === 'none') return;
322
+ if (BOOT.voice?.kind && BOOT.voice.kind !== 'browser') {
323
+ try {
324
+ const r = await post('/tts', { text });
325
+ if (r.ok) {
326
+ const audio = new Audio(URL.createObjectURL(await r.blob()));
327
+ audio.onplay = () => {
328
+ // real audio drives the mouth when possible, else text-timed visemes
329
+ try {
330
+ const ctx = (speak.ctx ||= new AudioContext());
331
+ const src = ctx.createMediaElementSource(audio);
332
+ const an = ctx.createAnalyser();
333
+ src.connect(an);
334
+ an.connect(ctx.destination);
335
+ const stop = ch.lipsync?.(an, { text });
336
+ if (stop) return void (lip = { stop });
337
+ } catch {}
338
+ talk(true, text, (audio.duration || 0) * 1000);
339
+ };
340
+ audio.onended = audio.onerror = () => talk(false);
341
+ return await audio.play();
342
+ }
343
+ } catch {}
344
+ }
345
+ if (!('speechSynthesis' in window)) return;
346
+ const u = new SpeechSynthesisUtterance(text);
347
+ u.lang = id.voice?.lang || lang;
348
+ u.rate = id.voice?.rate || 1;
349
+ u.pitch = id.voice?.pitch || 1;
350
+ u.onstart = () => talk(true, text);
351
+ u.onboundary = (e) => lip?.sync?.(e.charIndex);
352
+ u.onend = u.onerror = () => talk(false);
353
+ speechSynthesis.cancel();
354
+ speechSynthesis.speak(u);
355
+ }
356
+ function showBubble(content, ms) {
357
+ bubble.replaceChildren(content);
358
+ bubble.classList.add('on');
359
+ clearTimeout(hideT);
360
+ if (ms) hideT = setTimeout(() => bubble.classList.remove('on'), ms);
361
+ }
362
+ function say(text) {
363
+ showBubble(document.createTextNode(text), Math.max(4500, text.length * 90));
364
+ speak(text);
365
+ }
366
+ function setThinking(on) {
367
+ if (on === thinking) return;
368
+ thinking = on;
369
+ paintMood();
370
+ if (on) {
371
+ const d = document.createElement('span');
372
+ d.className = 'dots';
373
+ d.innerHTML = '<b></b><b></b><b></b>';
374
+ showBubble(d, 0);
375
+ } else if (bubble.querySelector('.dots')) bubble.classList.remove('on');
376
+ }
377
+
378
+ // ── live updates ──
379
+ const live = $('#live');
380
+ function connect() {
381
+ const es = new EventSource(`/events?token=${encodeURIComponent(BOOT.token)}`);
382
+ es.onopen = () => { live.classList.add('on'); live.textContent = t('live'); };
383
+ es.onerror = () => { live.classList.remove('on'); live.textContent = t('off'); };
384
+ es.onmessage = (e) => {
385
+ const m = JSON.parse(e.data);
386
+ if (m.state) render(m.state);
387
+ if ('thinking' in m) setThinking(!!m.thinking);
388
+ if (m.activity) addActivity(m.activity);
389
+ if (m.gesture) ch.gesture(m.gesture);
390
+ if (m.morph) ch.morph?.(m.morph);
391
+ if (m.effect) ch.effect?.(m.effect);
392
+ if (m.say) say(m.say);
393
+ };
394
+ }
395
+ live.textContent = t('off');
396
+ connect();
397
+
398
+ // The desktop shell tells us which way we walk.
399
+ addEventListener('message', (e) => {
400
+ if (e.data?.walk != null) $('#stage').classList.toggle('flip', e.data.walk < 0);
401
+ });
402
+ </script>
403
+ </body>
404
+ </html>