@jossuealcala/madre 0.2.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.
- package/CHANGELOG.md +38 -0
- package/LICENSE +202 -0
- package/NOTICE +5 -0
- package/README.md +281 -0
- package/bin/madre.mjs +134 -0
- package/docs/madre-banner.svg +25 -0
- package/package.json +63 -0
- package/public/app.js +4031 -0
- package/public/brands.js +59 -0
- package/public/index.html +231 -0
- package/public/styles.css +976 -0
- package/public/troubleshooting.js +460 -0
- package/src/adapters/claude.mjs +105 -0
- package/src/adapters/codex.mjs +84 -0
- package/src/adapters/gemini.mjs +335 -0
- package/src/adapters/opencode.mjs +148 -0
- package/src/adapters/process.mjs +132 -0
- package/src/ashcode.mjs +64 -0
- package/src/auth-probe.mjs +145 -0
- package/src/capabilities.mjs +126 -0
- package/src/checkpoint.mjs +106 -0
- package/src/cli-args.mjs +38 -0
- package/src/commands.mjs +98 -0
- package/src/config.mjs +59 -0
- package/src/conversation-context.mjs +59 -0
- package/src/directives.mjs +70 -0
- package/src/distiller.mjs +78 -0
- package/src/embeddings.mjs +80 -0
- package/src/event-store.mjs +152 -0
- package/src/extensions.mjs +263 -0
- package/src/files.mjs +177 -0
- package/src/image-studio.mjs +25 -0
- package/src/lease.mjs +83 -0
- package/src/mcp/image-server.mjs +169 -0
- package/src/mcp/memory-server.mjs +221 -0
- package/src/memory-tools.mjs +34 -0
- package/src/memory.mjs +543 -0
- package/src/models.mjs +78 -0
- package/src/mother.mjs +163 -0
- package/src/quota-monitor.mjs +119 -0
- package/src/quota-sources.mjs +161 -0
- package/src/room.mjs +1159 -0
- package/src/router.mjs +13 -0
- package/src/runtime-detection.mjs +85 -0
- package/src/server.mjs +693 -0
- package/src/setup.mjs +192 -0
- package/src/usage-sentinel.mjs +65 -0
package/public/app.js
ADDED
|
@@ -0,0 +1,4031 @@
|
|
|
1
|
+
import { brandOf } from './brands.js';
|
|
2
|
+
import { CONDITIONS, detectPlatform, diagnose, fixesFor, PLATFORMS, searchConditions } from './troubleshooting.js';
|
|
3
|
+
|
|
4
|
+
const els = {
|
|
5
|
+
project: document.querySelector('#project'),
|
|
6
|
+
agents: document.querySelector('#agents'),
|
|
7
|
+
thread: document.querySelector('#messages'),
|
|
8
|
+
column: document.querySelector('#thread-inner'),
|
|
9
|
+
crewLabel: document.querySelector('#crew-label'),
|
|
10
|
+
onboarding: document.querySelector('#onboarding'),
|
|
11
|
+
onboardingList: document.querySelector('#onboarding-list'),
|
|
12
|
+
composer: document.querySelector('#composer'),
|
|
13
|
+
picker: document.querySelector('#picker'),
|
|
14
|
+
target: document.querySelector('#target'),
|
|
15
|
+
input: document.querySelector('#message'),
|
|
16
|
+
highlight: document.querySelector('#highlight'),
|
|
17
|
+
slashMenu: document.querySelector('#slash-menu'),
|
|
18
|
+
attach: document.querySelector('#attach'),
|
|
19
|
+
createToggle: document.querySelector('#create-toggle'),
|
|
20
|
+
ashToggle: document.querySelector('#ashcode-toggle'),
|
|
21
|
+
fileInput: document.querySelector('#file-input'),
|
|
22
|
+
attachments: document.querySelector('#attachments'),
|
|
23
|
+
send: document.querySelector('#composer button[type="submit"]'),
|
|
24
|
+
connection: document.querySelector('#connection'),
|
|
25
|
+
tree: document.querySelector('#tree'),
|
|
26
|
+
treeBody: document.querySelector('#tree-body'),
|
|
27
|
+
treeRoot: document.querySelector('#tree-root'),
|
|
28
|
+
treeButton: document.querySelector('#tree-button'),
|
|
29
|
+
treeClose: document.querySelector('#tree-close'),
|
|
30
|
+
toast: document.querySelector('#toast'),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Built-in commands live in the composer; module commands come from the
|
|
34
|
+
// server (/api/commands) and run there, read-only, as fact cards.
|
|
35
|
+
const CLIENT_COMMANDS = [
|
|
36
|
+
{ name: 'create', title: 'CREATE', usage: '/create <what to make>', summary: 'Arm the creation lease for this message: the agent may create files inside .pulse/out/.', available: true, client: true },
|
|
37
|
+
{ name: 'image', title: 'Image', usage: '/image <what to draw>', summary: 'Ask for an image: arms CREATE with the image scope and routes to an agent that can generate images.', available: true, client: true },
|
|
38
|
+
{ name: 'stopall', title: 'STOP ALL', usage: '/stopall', summary: 'Master brake: halt every plan and turn in flight. Never reaches an agent.', available: true, client: true },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const PLACEHOLDERS = {
|
|
42
|
+
plain: 'Type here, human. Ask the room…',
|
|
43
|
+
create: 'Creation lease on: what to create and how it should look. It lands in .pulse/out/',
|
|
44
|
+
order: 'Priority one. Terse transmissions; the original stays in the record.',
|
|
45
|
+
ghost: 'Off the record. Ask anything; nothing is saved, nobody else will remember it.',
|
|
46
|
+
control: 'Control armed. Say what to change in the project; every action runs without asking.',
|
|
47
|
+
expendable: 'Type here, human. MOTHER is listening.',
|
|
48
|
+
};
|
|
49
|
+
let winkTimer = null;
|
|
50
|
+
const LINE_PX = 21;
|
|
51
|
+
const MAX_ROWS = 3;
|
|
52
|
+
|
|
53
|
+
// Permission modes: chosen per message, capped per agent in CONNECTIONS.
|
|
54
|
+
const MODES = {
|
|
55
|
+
0: { key: 'ghost', label: 'GHOST', hint: 'Off the record. Nothing is saved; gone on reload.' },
|
|
56
|
+
1: { key: 'exchange', label: 'EXCHANGE', hint: 'Read the project and talk to the room. Writes nothing.' },
|
|
57
|
+
2: { key: 'create', label: 'CREATE', hint: 'Write new files, only inside this turn\'s .pulse/out/.' },
|
|
58
|
+
3: { key: 'control', label: 'CONTROL', hint: 'Edit the project itself, no approval per action. Override required.' },
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const state = {
|
|
62
|
+
agents: new Map(),
|
|
63
|
+
budget: null,
|
|
64
|
+
timeouts: {},
|
|
65
|
+
seen: new Set(),
|
|
66
|
+
userMessages: new Map(), // messageId -> { text, target }
|
|
67
|
+
lastSequence: 0,
|
|
68
|
+
lastSender: null, // for iMessage-style grouping of consecutive bubbles
|
|
69
|
+
failures: [], // recorded conditions for MU/TH/UR
|
|
70
|
+
expendable: false, // easter egg armed: the next human message is reviewed by MOTHER
|
|
71
|
+
running: new Map(), // messageId -> agent, turns in flight
|
|
72
|
+
mode: 1, // permission mode for the next message (#0..#3)
|
|
73
|
+
modeArmedFor: null, // agent the CONTROL override was granted to
|
|
74
|
+
commands: [], // slash commands from /api/commands (modules) + the built-in ones
|
|
75
|
+
plansRunning: new Set(),
|
|
76
|
+
brakeArmed: false, // STOP ALL is a brake against runaway sequences: armed only by MU/TH/UR alerts
|
|
77
|
+
agentStats: new Map(), // id -> { turns, lastTurnMs, lastTurnTokens, cost, started }
|
|
78
|
+
sessions: {}, // id -> { state, detail } from the last probe
|
|
79
|
+
loginLogs: new Map(), // id -> streamed sign-in lines
|
|
80
|
+
models: {}, // id -> { models, default, note } from /api/models
|
|
81
|
+
capabilities: {}, // id -> { read, imageIn, write, imageGen, web }
|
|
82
|
+
pending: [], // attachments uploaded for the next message
|
|
83
|
+
projectRoot: '',
|
|
84
|
+
create: false, // creation lease armed for the next message
|
|
85
|
+
ashCodeInstalled: false,
|
|
86
|
+
ashCode: false, // ASH CODE active for this message and its replies
|
|
87
|
+
chosenModel: {}, // id -> model name picked in the composer
|
|
88
|
+
};
|
|
89
|
+
try { state.chosenModel = JSON.parse(localStorage.getItem('pulse.chosenModel') ?? '{}') || {}; } catch { state.chosenModel = {}; }
|
|
90
|
+
|
|
91
|
+
const AGENT_HINTS = {
|
|
92
|
+
codex: 'Install the Codex CLI and sign in with your ChatGPT account.',
|
|
93
|
+
claude: 'Install Claude Code and run `claude` once to sign in.',
|
|
94
|
+
gemini: 'Install Gemini CLI and sign in or set GEMINI_API_KEY.',
|
|
95
|
+
opencode: 'Install OpenCode and run `opencode auth login`.',
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/* ---------- helpers ---------- */
|
|
99
|
+
|
|
100
|
+
const el = (tag, className, text) => {
|
|
101
|
+
const node = document.createElement(tag);
|
|
102
|
+
if (className) node.className = className;
|
|
103
|
+
if (text !== undefined) node.textContent = text;
|
|
104
|
+
return node;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const formatTokens = (value) => {
|
|
108
|
+
if (!Number.isFinite(value)) return '';
|
|
109
|
+
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
|
110
|
+
if (value >= 1_000) return `${(value / 1_000).toFixed(value >= 100_000 ? 0 : 1)}k`;
|
|
111
|
+
return String(value);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const formatTime = (iso) => {
|
|
115
|
+
const date = iso ? new Date(iso) : new Date();
|
|
116
|
+
return Number.isNaN(date.getTime()) ? '' : date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const label = (id) => state.agents.get(id)?.label ?? brandOf(id).label ?? id;
|
|
120
|
+
const isLight = () => matchMedia('(prefers-color-scheme: light)').matches;
|
|
121
|
+
const agentColor = (id) => {
|
|
122
|
+
const brand = brandOf(id);
|
|
123
|
+
return isLight() && brand.colorLight ? brand.colorLight : brand.color;
|
|
124
|
+
};
|
|
125
|
+
function paint(node, id) {
|
|
126
|
+
node.dataset.agent = id;
|
|
127
|
+
node.style.setProperty('--agent', agentColor(id));
|
|
128
|
+
const brand = brandOf(id);
|
|
129
|
+
if (brand.gradient) node.style.setProperty('--agent-gradient', brand.gradient);
|
|
130
|
+
return node;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function avatar(id, { size = 28, ring = false, pct = 0, status = 'ready' } = {}) {
|
|
134
|
+
const node = paint(el('span', `avatar ${status}${ring ? ' ring' : ''}${pct >= 80 ? ' hot' : ''}`), id);
|
|
135
|
+
node.style.setProperty('--size', `${size}px`);
|
|
136
|
+
node.style.setProperty('--pct', String(Math.max(0, Math.min(100, pct))));
|
|
137
|
+
node.innerHTML = brandOf(id).mark(); // trusted: our own SVG strings from brands.js
|
|
138
|
+
return node;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let toastTimer;
|
|
142
|
+
// The hint sits just above the composer, whatever its height right now.
|
|
143
|
+
function placeToast() {
|
|
144
|
+
const rect = els.composer?.getBoundingClientRect?.();
|
|
145
|
+
if (!rect || !document.documentElement?.style) return;
|
|
146
|
+
if (Number.isFinite(rect.top) && rect.top > 0) document.documentElement.style.setProperty('--composer-top', `${Math.round(rect.top)}px`);
|
|
147
|
+
if (Number.isFinite(rect.left) && rect.width > 0) document.documentElement.style.setProperty('--composer-center', `${Math.round(rect.left + rect.width / 2)}px`);
|
|
148
|
+
}
|
|
149
|
+
function toast(message) {
|
|
150
|
+
placeToast();
|
|
151
|
+
els.toast.textContent = message;
|
|
152
|
+
els.toast.hidden = false;
|
|
153
|
+
clearTimeout(toastTimer);
|
|
154
|
+
toastTimer = setTimeout(() => { els.toast.hidden = true; }, 6000);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function scrollToEnd() {
|
|
158
|
+
els.thread.scrollTop = els.thread.scrollHeight;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/* ---------- safe Markdown (DOM only, no HTML injection) ---------- */
|
|
162
|
+
|
|
163
|
+
const SAFE_URL = /^https?:\/\//i;
|
|
164
|
+
|
|
165
|
+
const FILE_PATH = /(?<![\w/@.-])((?:\.{1,2}\/)?(?:[\w.-]+\/)+[\w.-]+\.[A-Za-z0-9]{1,8}|\/Users\/[\w.-]+(?:\/[\w.-]+)+\.[A-Za-z0-9]{1,8})(?::(\d+))?(?![\w/])/g;
|
|
166
|
+
|
|
167
|
+
function projectRelative(path) {
|
|
168
|
+
if (state.projectRoot && path.startsWith(state.projectRoot + '/')) return path.slice(state.projectRoot.length + 1);
|
|
169
|
+
if (path.startsWith('/')) return null;
|
|
170
|
+
return path.replace(/^\.\//, '');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Plain text with file paths turned into viewer links.
|
|
174
|
+
function appendTextWithPaths(text, into) {
|
|
175
|
+
let last = 0;
|
|
176
|
+
for (const match of text.matchAll(FILE_PATH)) {
|
|
177
|
+
const rel = projectRelative(match[1]);
|
|
178
|
+
if (!rel) continue;
|
|
179
|
+
if (match.index > last) into.append(text.slice(last, match.index));
|
|
180
|
+
const link = el('a', 'file-link', match[0]);
|
|
181
|
+
link.href = `/api/files?path=${encodeURIComponent(rel)}`;
|
|
182
|
+
link.addEventListener('click', (event) => { event.preventDefault(); openViewer({ root: 'project', path: rel, line: match[2] ? Number(match[2]) : null }); });
|
|
183
|
+
into.append(link);
|
|
184
|
+
last = match.index + match[0].length;
|
|
185
|
+
}
|
|
186
|
+
if (last < text.length) into.append(text.slice(last));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function imageSource(src) {
|
|
190
|
+
if (/^https?:\/\//i.test(src)) return src;
|
|
191
|
+
const rel = projectRelative(src);
|
|
192
|
+
return rel ? `/api/files?path=${encodeURIComponent(rel)}` : null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function renderInline(text, into) {
|
|
196
|
+
const pattern = /(`[^`\n]+`)|(\*\*[^*\n]+\*\*)|(\*[^*\n]+\*)|(!\[[^\]\n]*\]\(([^)\s]+)\))|(\[[^\]\n]+\]\((https?:\/\/[^)\s]+)\))|((?<![\w/])!((?:[\w.-]+\/)*[\w.-]+\.[A-Za-z0-9]{1,8})(?::(\d+)(?:-(\d+))?)?(?![\w/]))/g;
|
|
197
|
+
let last = 0;
|
|
198
|
+
for (const match of text.matchAll(pattern)) {
|
|
199
|
+
if (match.index > last) appendTextWithPaths(text.slice(last, match.index), into);
|
|
200
|
+
const [token] = match;
|
|
201
|
+
if (match[1]) {
|
|
202
|
+
const code = el('code', null, token.slice(1, -1));
|
|
203
|
+
const rel = FILE_PATH.test(token.slice(1, -1)) ? projectRelative(token.slice(1, -1).replace(/:\d+$/, '')) : null;
|
|
204
|
+
FILE_PATH.lastIndex = 0;
|
|
205
|
+
if (rel) { code.classList.add('file-link'); code.style.cursor = 'pointer'; code.addEventListener('click', () => openViewer({ root: 'project', path: rel })); }
|
|
206
|
+
into.append(code);
|
|
207
|
+
} else if (match[4]) {
|
|
208
|
+
const src = imageSource(match[5]);
|
|
209
|
+
const alt = token.slice(2, token.indexOf(']'));
|
|
210
|
+
if (src) {
|
|
211
|
+
const img = el('img', 'inline');
|
|
212
|
+
img.src = src; img.alt = alt; img.loading = 'lazy';
|
|
213
|
+
img.addEventListener('click', () => openViewer({ url: src, label: alt || match[5] }));
|
|
214
|
+
into.append(img);
|
|
215
|
+
} else into.append(el('code', null, match[5]));
|
|
216
|
+
}
|
|
217
|
+
else if (match[8]) {
|
|
218
|
+
const ref = el('button', 'file-ref', token);
|
|
219
|
+
ref.type = 'button';
|
|
220
|
+
const from = match[10] ? Number(match[10]) : null;
|
|
221
|
+
const to = match[11] ? Number(match[11]) : from;
|
|
222
|
+
ref.title = `Open ${match[9]}${from ? ` at line ${from}${to !== from ? `-${to}` : ''}` : ''}`;
|
|
223
|
+
ref.addEventListener('click', () => openViewer({ root: 'project', path: match[9], line: from, lines: from ? { from, to } : null }));
|
|
224
|
+
into.append(ref);
|
|
225
|
+
}
|
|
226
|
+
else if (match[2]) into.append(el('strong', null, token.slice(2, -2)));
|
|
227
|
+
else if (match[3]) into.append(el('em', null, token.slice(1, -1)));
|
|
228
|
+
else if (match[6]) {
|
|
229
|
+
const anchor = el('a', null, token.slice(1, token.indexOf(']')));
|
|
230
|
+
const href = match[7];
|
|
231
|
+
if (SAFE_URL.test(href)) {
|
|
232
|
+
anchor.href = href;
|
|
233
|
+
anchor.target = '_blank';
|
|
234
|
+
anchor.rel = 'noopener noreferrer';
|
|
235
|
+
}
|
|
236
|
+
into.append(anchor);
|
|
237
|
+
}
|
|
238
|
+
last = match.index + token.length;
|
|
239
|
+
}
|
|
240
|
+
if (last < text.length) appendTextWithPaths(text.slice(last), into);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/* ---------- file viewer ---------- */
|
|
244
|
+
|
|
245
|
+
const viewer = {
|
|
246
|
+
dialog: document.querySelector('#viewer'),
|
|
247
|
+
path: document.querySelector('#viewer-path'),
|
|
248
|
+
open: document.querySelector('#viewer-open'),
|
|
249
|
+
body: document.querySelector('#viewer-body'),
|
|
250
|
+
close: document.querySelector('#viewer-close'),
|
|
251
|
+
};
|
|
252
|
+
const viewerUI = {
|
|
253
|
+
selection: document.querySelector('#viewer-selection'),
|
|
254
|
+
review: document.querySelector('#viewer-review'),
|
|
255
|
+
menu: document.querySelector('#viewer-menu'),
|
|
256
|
+
path: null, // project-relative path of the open text file, if any
|
|
257
|
+
from: null, to: null,
|
|
258
|
+
lineNodes: [],
|
|
259
|
+
};
|
|
260
|
+
function setSelection(from, to) {
|
|
261
|
+
viewerUI.from = from; viewerUI.to = to;
|
|
262
|
+
const lo = Math.min(from ?? 0, to ?? 0), hi = Math.max(from ?? 0, to ?? 0);
|
|
263
|
+
for (const { n, ln, tx } of viewerUI.lineNodes) {
|
|
264
|
+
const on = from !== null && n >= lo && n <= hi;
|
|
265
|
+
ln.classList.toggle('sel', on); tx.classList.toggle('sel', on);
|
|
266
|
+
}
|
|
267
|
+
const has = from !== null;
|
|
268
|
+
viewerUI.selection.hidden = !has;
|
|
269
|
+
viewerUI.review.hidden = !has;
|
|
270
|
+
if (has) viewerUI.selection.textContent = lo === hi ? `L${lo}` : `L${lo}-${hi}`;
|
|
271
|
+
}
|
|
272
|
+
function referenceForSelection() {
|
|
273
|
+
const lo = Math.min(viewerUI.from, viewerUI.to), hi = Math.max(viewerUI.from, viewerUI.to);
|
|
274
|
+
return `!${viewerUI.path}:${lo}${hi !== lo ? `-${hi}` : ''}`;
|
|
275
|
+
}
|
|
276
|
+
// "Review with @agent": the selection becomes a !file:lines reference in the
|
|
277
|
+
// composer and that agent becomes the target; the human finishes the sentence.
|
|
278
|
+
function reviewWith(agentId) {
|
|
279
|
+
const reference = referenceForSelection();
|
|
280
|
+
const current = els.input.value.trim();
|
|
281
|
+
els.input.value = `${reference} ${current ? current : 'review this: '}`;
|
|
282
|
+
if (agentId && state.agents.get(agentId)?.ready) { els.target.value = agentId; renderPicker(); }
|
|
283
|
+
hideViewerMenu();
|
|
284
|
+
viewer.dialog.close();
|
|
285
|
+
autosize();
|
|
286
|
+
els.input.focus();
|
|
287
|
+
els.input.setSelectionRange(els.input.value.length, els.input.value.length);
|
|
288
|
+
}
|
|
289
|
+
function showViewerMenu(x, y) {
|
|
290
|
+
const menuNode = viewerUI.menu;
|
|
291
|
+
menuNode.replaceChildren(el('div', 'hint', `REVIEW ${referenceForSelection()} WITH`));
|
|
292
|
+
for (const agent of state.agents.values()) {
|
|
293
|
+
const item = paint(el('button', `item${agent.ready ? '' : ' off'}`), agent.id);
|
|
294
|
+
item.type = 'button';
|
|
295
|
+
item.append(el('b', null, `@${agent.id}`), el('span', null, agent.ready ? label(agent.id) : `${label(agent.id)} · not ready`));
|
|
296
|
+
item.disabled = !agent.ready;
|
|
297
|
+
item.addEventListener('click', () => reviewWith(agent.id));
|
|
298
|
+
menuNode.append(item);
|
|
299
|
+
}
|
|
300
|
+
menuNode.hidden = false;
|
|
301
|
+
const width = 240, height = 40 + 36 * state.agents.size;
|
|
302
|
+
menuNode.style.left = `${Math.min(x, window.innerWidth - width - 12)}px`;
|
|
303
|
+
menuNode.style.top = `${Math.min(y, window.innerHeight - height - 12)}px`;
|
|
304
|
+
}
|
|
305
|
+
function hideViewerMenu() { viewerUI.menu.hidden = true; }
|
|
306
|
+
viewerUI.review?.addEventListener('click', (event) => {
|
|
307
|
+
const rect = event.currentTarget.getBoundingClientRect();
|
|
308
|
+
if (viewerUI.menu.hidden) showViewerMenu(rect.left, rect.bottom + 6); else hideViewerMenu();
|
|
309
|
+
});
|
|
310
|
+
viewer.dialog.addEventListener('click', (event) => { if (!viewerUI.menu.hidden && !viewerUI.menu.contains(event.target) && event.target !== viewerUI.review) hideViewerMenu(); });
|
|
311
|
+
viewer.dialog.addEventListener('close', () => { hideViewerMenu(); setSelection(null, null); });
|
|
312
|
+
|
|
313
|
+
function codeView(text, { line = null, lines = null } = {}) {
|
|
314
|
+
const pre = el('pre', 'code');
|
|
315
|
+
viewerUI.lineNodes = [];
|
|
316
|
+
const rows = text.replace(/\n$/, '').split('\n');
|
|
317
|
+
rows.forEach((content, index) => {
|
|
318
|
+
const n = index + 1;
|
|
319
|
+
const ln = el('span', 'ln', String(n));
|
|
320
|
+
const tx = el('span', 'tx', content || ' ');
|
|
321
|
+
ln.dataset.n = n; tx.dataset.n = n;
|
|
322
|
+
const marked = lines ? n >= lines.from && n <= lines.to : n === line;
|
|
323
|
+
if (marked) { ln.classList.add('hl'); tx.classList.add('hl'); }
|
|
324
|
+
ln.addEventListener('click', (event) => {
|
|
325
|
+
if (event.shiftKey && viewerUI.from !== null) setSelection(viewerUI.from, n);
|
|
326
|
+
else if (viewerUI.from === n && viewerUI.to === n) setSelection(null, null);
|
|
327
|
+
else setSelection(n, n);
|
|
328
|
+
});
|
|
329
|
+
tx.addEventListener('contextmenu', (event) => {
|
|
330
|
+
event.preventDefault();
|
|
331
|
+
const lo = Math.min(viewerUI.from ?? -1, viewerUI.to ?? -1), hi = Math.max(viewerUI.from ?? -1, viewerUI.to ?? -1);
|
|
332
|
+
if (viewerUI.from === null || n < lo || n > hi) setSelection(n, n);
|
|
333
|
+
showViewerMenu(event.clientX, event.clientY);
|
|
334
|
+
});
|
|
335
|
+
ln.addEventListener('contextmenu', (event) => { event.preventDefault(); if (viewerUI.from === null) setSelection(n, n); showViewerMenu(event.clientX, event.clientY); });
|
|
336
|
+
viewerUI.lineNodes.push({ n, ln, tx });
|
|
337
|
+
pre.append(ln, tx);
|
|
338
|
+
});
|
|
339
|
+
return pre;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// RIPLEY: which files the viewer can render instead of showing as source.
|
|
343
|
+
const RIPLEY_KIND = (name) => (/\.(html?|xhtml)$/i.test(name ?? '') ? 'html' : /\.svg$/i.test(name ?? '') ? 'svg' : /\.(md|markdown)$/i.test(name ?? '') ? 'markdown' : null);
|
|
344
|
+
const viewerMode = { button: document.querySelector('#viewer-mode'), preview: false, kind: null, text: '', src: '', line: null, lines: null };
|
|
345
|
+
|
|
346
|
+
function showViewerSource() {
|
|
347
|
+
const pre = codeView(viewerMode.text, { line: viewerMode.line, lines: viewerMode.lines });
|
|
348
|
+
viewer.body.replaceChildren(pre);
|
|
349
|
+
if (!state.ripley && viewerMode.kind) {
|
|
350
|
+
const hint = el('div', 'viewer-hint');
|
|
351
|
+
hint.append('RIPLEY can render this file. ', el('b', null, 'Enable it in MODULES.'));
|
|
352
|
+
viewer.body.prepend(hint);
|
|
353
|
+
}
|
|
354
|
+
if (viewerMode.lines) setSelection(viewerMode.lines.from, viewerMode.lines.to);
|
|
355
|
+
else if (viewerMode.line) setSelection(viewerMode.line, viewerMode.line);
|
|
356
|
+
pre.querySelector('.hl')?.scrollIntoView?.({ block: 'center' });
|
|
357
|
+
}
|
|
358
|
+
// The sealed frame: no scripts, no network, no storage; Markdown renders in place.
|
|
359
|
+
function showViewerPreview() {
|
|
360
|
+
if (viewerMode.kind === 'markdown') {
|
|
361
|
+
const doc = el('article', 'viewer-markdown bubble');
|
|
362
|
+
doc.append(renderMarkdown(viewerMode.text));
|
|
363
|
+
viewer.body.replaceChildren(doc);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const frame = el('iframe', 'ripley');
|
|
367
|
+
frame.setAttribute('sandbox', '');
|
|
368
|
+
frame.setAttribute('referrerpolicy', 'no-referrer');
|
|
369
|
+
frame.title = `RIPLEY preview of ${viewer.path.textContent}`;
|
|
370
|
+
frame.src = viewerMode.src.replace('/api/files?', '/api/preview?');
|
|
371
|
+
viewer.body.replaceChildren(frame);
|
|
372
|
+
}
|
|
373
|
+
function syncViewerMode() {
|
|
374
|
+
if (!viewerMode.button) return;
|
|
375
|
+
const can = Boolean(viewerMode.kind) && state.ripley;
|
|
376
|
+
viewerMode.button.hidden = !can;
|
|
377
|
+
viewerMode.button.textContent = viewerMode.preview ? 'SOURCE' : 'PREVIEW';
|
|
378
|
+
viewerMode.button.title = viewerMode.preview ? 'Show the file as text' : 'Render with RIPLEY in a sealed frame';
|
|
379
|
+
}
|
|
380
|
+
viewerMode.button?.addEventListener('click', () => { viewerMode.preview = !viewerMode.preview; syncViewerMode(); if (viewerMode.preview) showViewerPreview(); else showViewerSource(); });
|
|
381
|
+
|
|
382
|
+
async function openViewer({ root = 'project', path = null, url = null, label = null, line = null, lines = null }) {
|
|
383
|
+
const src = url ?? `/api/files?root=${root}&path=${encodeURIComponent(path)}`;
|
|
384
|
+
viewer.path.textContent = label ?? (root === 'project' ? `/${path}` : path);
|
|
385
|
+
viewer.open.href = src;
|
|
386
|
+
viewer.body.replaceChildren(el('div', null, 'loading…'));
|
|
387
|
+
viewerUI.path = root === 'project' ? path : null;
|
|
388
|
+
setSelection(null, null);
|
|
389
|
+
viewerMode.kind = null;
|
|
390
|
+
syncViewerMode();
|
|
391
|
+
if (!viewer.dialog.open) viewer.dialog.showModal();
|
|
392
|
+
try {
|
|
393
|
+
const response = await fetch(src);
|
|
394
|
+
if (!response.ok) { const err = await response.json().catch(() => ({})); viewer.body.replaceChildren(el('div', 'err', err.error ?? `HTTP ${response.status}`)); return; }
|
|
395
|
+
const type = response.headers.get('content-type') ?? '';
|
|
396
|
+
const kind = RIPLEY_KIND(path ?? label ?? '');
|
|
397
|
+
if (type.startsWith('image/') && kind !== 'svg') { const img = el('img'); img.src = src; img.alt = viewer.path.textContent; viewer.body.replaceChildren(img); return; }
|
|
398
|
+
if (type === 'application/pdf' || type.startsWith('video/') || type.startsWith('audio/')) {
|
|
399
|
+
const frame = el(type === 'application/pdf' ? 'iframe' : type.startsWith('video/') ? 'video' : 'audio');
|
|
400
|
+
frame.src = src; if (frame.tagName !== 'IFRAME') frame.controls = true;
|
|
401
|
+
viewer.body.replaceChildren(frame); return;
|
|
402
|
+
}
|
|
403
|
+
const text = await response.text();
|
|
404
|
+
Object.assign(viewerMode, { kind, text, src, line, lines, preview: Boolean(kind) && state.ripley && !line && !lines });
|
|
405
|
+
syncViewerMode();
|
|
406
|
+
if (viewerMode.preview) showViewerPreview(); else showViewerSource();
|
|
407
|
+
} catch (error) {
|
|
408
|
+
viewer.body.replaceChildren(el('div', 'err', error.message));
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
viewer.close.addEventListener('click', () => viewer.dialog.close());
|
|
412
|
+
|
|
413
|
+
function codeBlock(lines, lang) {
|
|
414
|
+
const wrapper = el('div', `codeblock${lang ? ' has-lang' : ''}`);
|
|
415
|
+
if (lang) wrapper.append(el('span', 'lang', lang));
|
|
416
|
+
const copy = el('button', 'copy', 'copy');
|
|
417
|
+
copy.type = 'button';
|
|
418
|
+
copy.addEventListener('click', async () => {
|
|
419
|
+
try {
|
|
420
|
+
await navigator.clipboard.writeText(lines.join('\n'));
|
|
421
|
+
copy.textContent = 'copied';
|
|
422
|
+
setTimeout(() => { copy.textContent = 'copy'; }, 1500);
|
|
423
|
+
} catch {
|
|
424
|
+
copy.textContent = 'select & copy';
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
wrapper.append(copy);
|
|
428
|
+
const pre = el('pre');
|
|
429
|
+
pre.append(el('code', null, lines.join('\n')));
|
|
430
|
+
wrapper.append(pre);
|
|
431
|
+
return wrapper;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// A ```pulse block is a delegation plan: render it as steps, not as code.
|
|
435
|
+
function planCard(lines) {
|
|
436
|
+
const card = el('div', 'plan-card');
|
|
437
|
+
card.append(el('span', 'plan-label', 'plan'));
|
|
438
|
+
const list = el('ol');
|
|
439
|
+
for (const raw of lines) {
|
|
440
|
+
const match = raw.trim().match(/^[-*]?\s*@([a-z0-9_-]+)\s*[::]\s*(.+)$/i);
|
|
441
|
+
if (!match) continue;
|
|
442
|
+
const item = paint(el('li'), match[1].toLowerCase());
|
|
443
|
+
item.append(el('b', null, `@${match[1].toLowerCase()}`));
|
|
444
|
+
item.append(` ${match[2]}`);
|
|
445
|
+
list.append(item);
|
|
446
|
+
}
|
|
447
|
+
card.append(list);
|
|
448
|
+
return card;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export function renderMarkdown(text) {
|
|
452
|
+
const fragment = document.createDocumentFragment();
|
|
453
|
+
const lines = String(text ?? '').replace(/\r\n/g, '\n').split('\n');
|
|
454
|
+
let index = 0;
|
|
455
|
+
let paragraph = [];
|
|
456
|
+
|
|
457
|
+
const flushParagraph = () => {
|
|
458
|
+
if (!paragraph.length) return;
|
|
459
|
+
const p = el('p');
|
|
460
|
+
renderInline(paragraph.join(' '), p);
|
|
461
|
+
fragment.append(p);
|
|
462
|
+
paragraph = [];
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
while (index < lines.length) {
|
|
466
|
+
const line = lines[index];
|
|
467
|
+
const fence = line.match(/^\s*```\s*([\w+-]*)\s*$/);
|
|
468
|
+
if (fence) {
|
|
469
|
+
flushParagraph();
|
|
470
|
+
const body = [];
|
|
471
|
+
index += 1;
|
|
472
|
+
while (index < lines.length && !/^\s*```\s*$/.test(lines[index])) body.push(lines[index++]);
|
|
473
|
+
index += 1;
|
|
474
|
+
fragment.append(fence[1]?.toLowerCase() === 'pulse' ? planCard(body) : codeBlock(body, fence[1]));
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
if (!line.trim()) { flushParagraph(); index += 1; continue; }
|
|
478
|
+
|
|
479
|
+
const heading = line.match(/^(#{1,6})\s+(.*)$/);
|
|
480
|
+
if (heading) {
|
|
481
|
+
flushParagraph();
|
|
482
|
+
const h = el(`h${Math.min(heading[1].length, 4)}`);
|
|
483
|
+
renderInline(heading[2].replace(/\s#+\s*$/, ''), h);
|
|
484
|
+
fragment.append(h);
|
|
485
|
+
index += 1;
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) { flushParagraph(); fragment.append(el('hr')); index += 1; continue; }
|
|
489
|
+
|
|
490
|
+
// GFM table: a header row, a separator row of dashes, then body rows.
|
|
491
|
+
if (/^\s*\|.*\|\s*$/.test(line) && index + 1 < lines.length && /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/.test(lines[index + 1])) {
|
|
492
|
+
flushParagraph();
|
|
493
|
+
const splitRow = (row) => row.trim().replace(/^\|/, '').replace(/\|$/, '').split(/(?<!\\)\|/).map((cell) => cell.trim().replace(/\\\|/g, '|'));
|
|
494
|
+
const aligns = splitRow(lines[index + 1]).map((cell) => (/^:-+:$/.test(cell) ? 'center' : /-+:$/.test(cell) ? 'right' : null));
|
|
495
|
+
const wrap = el('div', 'table-wrap');
|
|
496
|
+
const table = el('table');
|
|
497
|
+
const thead = el('thead');
|
|
498
|
+
const headRow = el('tr');
|
|
499
|
+
splitRow(line).forEach((cell, column) => { const th = el('th'); if (aligns[column]) th.style.textAlign = aligns[column]; renderInline(cell, th); headRow.append(th); });
|
|
500
|
+
thead.append(headRow);
|
|
501
|
+
table.append(thead);
|
|
502
|
+
const tbody = el('tbody');
|
|
503
|
+
index += 2;
|
|
504
|
+
while (index < lines.length && /^\s*\|.*\|\s*$/.test(lines[index])) {
|
|
505
|
+
const tr = el('tr');
|
|
506
|
+
splitRow(lines[index]).forEach((cell, column) => { const td = el('td'); if (aligns[column]) td.style.textAlign = aligns[column]; renderInline(cell, td); tr.append(td); });
|
|
507
|
+
tbody.append(tr);
|
|
508
|
+
index += 1;
|
|
509
|
+
}
|
|
510
|
+
table.append(tbody);
|
|
511
|
+
wrap.append(table);
|
|
512
|
+
fragment.append(wrap);
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const bullet = line.match(/^\s*[-*+]\s+(.*)$/);
|
|
517
|
+
const numbered = line.match(/^\s*\d+[.)]\s+(.*)$/);
|
|
518
|
+
if (bullet || numbered) {
|
|
519
|
+
flushParagraph();
|
|
520
|
+
const list = el(bullet ? 'ul' : 'ol');
|
|
521
|
+
const test = bullet ? /^\s*[-*+]\s+(.*)$/ : /^\s*\d+[.)]\s+(.*)$/;
|
|
522
|
+
while (index < lines.length) {
|
|
523
|
+
const item = lines[index].match(test);
|
|
524
|
+
if (!item) {
|
|
525
|
+
if (lines[index].trim() && /^\s{2,}/.test(lines[index]) && list.lastChild) {
|
|
526
|
+
list.lastChild.append(' ');
|
|
527
|
+
renderInline(lines[index].trim(), list.lastChild);
|
|
528
|
+
index += 1;
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
break;
|
|
532
|
+
}
|
|
533
|
+
const li = el('li');
|
|
534
|
+
renderInline(item[1], li);
|
|
535
|
+
list.append(li);
|
|
536
|
+
index += 1;
|
|
537
|
+
}
|
|
538
|
+
fragment.append(list);
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
const quote = line.match(/^\s*>\s?(.*)$/);
|
|
542
|
+
if (quote) {
|
|
543
|
+
flushParagraph();
|
|
544
|
+
const block = el('blockquote');
|
|
545
|
+
const body = [];
|
|
546
|
+
while (index < lines.length && /^\s*>/.test(lines[index])) body.push(lines[index++].replace(/^\s*>\s?/, ''));
|
|
547
|
+
renderInline(body.join(' '), block);
|
|
548
|
+
fragment.append(block);
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
paragraph.push(line.trim());
|
|
552
|
+
index += 1;
|
|
553
|
+
}
|
|
554
|
+
flushParagraph();
|
|
555
|
+
return fragment;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/* ---------- agents bar, picker & onboarding ---------- */
|
|
559
|
+
|
|
560
|
+
const fmtWindow = (minutes) => (!Number.isFinite(Number(minutes)) ? 'window' : Number(minutes) % 1440 === 0 ? `${Number(minutes) / 1440}d` : Number(minutes) % 60 === 0 ? `${Number(minutes) / 60}h` : `${minutes}m`);
|
|
561
|
+
const fmtReset = (iso) => { if (!iso) return ''; const date = new Date(iso); if (Number.isNaN(date.getTime())) return ''; const sameDay = date.toDateString() === new Date().toDateString(); return sameDay ? date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : date.toLocaleString([], { weekday: 'short', hour: '2-digit', minute: '2-digit' }); };
|
|
562
|
+
// An official window whose reset time has passed counts as empty until the CLI reports again.
|
|
563
|
+
function liveOfficial(agent) {
|
|
564
|
+
if (!Number.isFinite(agent.officialPercent)) return null;
|
|
565
|
+
if (agent.officialResetAt && new Date(agent.officialResetAt).getTime() <= Date.now()) return 0;
|
|
566
|
+
return agent.officialPercent;
|
|
567
|
+
}
|
|
568
|
+
function localPercent(agent) {
|
|
569
|
+
return state.budget && agent.tokens ? Math.min(100, (agent.tokens / state.budget) * 100) : 0;
|
|
570
|
+
}
|
|
571
|
+
// What the ring shows: the provider's real window when the CLI publishes one, else the local rolling window.
|
|
572
|
+
function ringPercent(agent) {
|
|
573
|
+
const official = liveOfficial(agent);
|
|
574
|
+
return official === null ? localPercent(agent) : official;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function renderAgents() {
|
|
578
|
+
els.agents.replaceChildren();
|
|
579
|
+
for (const agent of state.agents.values()) {
|
|
580
|
+
const percent = ringPercent(agent);
|
|
581
|
+
const official = liveOfficial(agent);
|
|
582
|
+
const node = avatar(agent.id, {
|
|
583
|
+
size: 26,
|
|
584
|
+
ring: agent.ready,
|
|
585
|
+
pct: percent,
|
|
586
|
+
status: agent.ready ? 'ready' : agent.detected ? 'detected' : 'offline',
|
|
587
|
+
});
|
|
588
|
+
node.title = [
|
|
589
|
+
`${agent.label}${brandOf(agent.id).vendor ? ` · ${brandOf(agent.id).vendor}` : ''}`,
|
|
590
|
+
agent.ready ? 'Ready' : agent.detected ? 'Detected, adapter pending' : 'Not installed',
|
|
591
|
+
agent.version ? agent.version : null,
|
|
592
|
+
official === null
|
|
593
|
+
? (agent.tokens ? `Ring: local 5h window · ${Math.round(percent)}% of ${formatTokens(state.budget ?? 0)} budget tokens` : 'Ring: local window · no usage yet')
|
|
594
|
+
: `Ring: provider limit · ${Math.round(official)}% used${agent.officialResetAt ? ` · resets ${fmtReset(agent.officialResetAt)}` : ''}`,
|
|
595
|
+
agent.tokens ? `${agent.tokens.toLocaleString()} budget tokens in the local window` : null,
|
|
596
|
+
].filter(Boolean).join('\n');
|
|
597
|
+
els.agents.append(node);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function renderPicker() {
|
|
602
|
+
els.picker.replaceChildren();
|
|
603
|
+
const ready = [...state.agents.values()].filter((agent) => agent.ready);
|
|
604
|
+
if (!ready.length) return;
|
|
605
|
+
if (!els.target.value || !ready.some((agent) => agent.id === els.target.value)) els.target.value = ready[0].id;
|
|
606
|
+
for (const agent of ready) {
|
|
607
|
+
const pick = avatar(agent.id, { size: 30 });
|
|
608
|
+
pick.classList.add('pick');
|
|
609
|
+
pick.setAttribute('role', 'radio');
|
|
610
|
+
pick.setAttribute('aria-label', agent.label);
|
|
611
|
+
pick.setAttribute('aria-checked', String(els.target.value === agent.id));
|
|
612
|
+
pick.tabIndex = 0;
|
|
613
|
+
const choose = () => {
|
|
614
|
+
if (els.target.value === agent.id) { toggleModelMenu(agent.id, pick); return; }
|
|
615
|
+
closeModelMenu();
|
|
616
|
+
closeModeMenu();
|
|
617
|
+
els.target.value = agent.id;
|
|
618
|
+
setMode(defaultModeFor(agent.id));
|
|
619
|
+
els.input.focus();
|
|
620
|
+
};
|
|
621
|
+
pick.title = els.target.value === agent.id ? `${agent.label} · click again to choose its model` : agent.label;
|
|
622
|
+
pick.addEventListener('click', choose);
|
|
623
|
+
pick.addEventListener('keydown', (event) => { if (event.key === ' ' || event.key === 'Enter') { event.preventDefault(); choose(); } });
|
|
624
|
+
els.picker.append(pick);
|
|
625
|
+
}
|
|
626
|
+
const current = state.agents.get(els.target.value);
|
|
627
|
+
if (current) {
|
|
628
|
+
const text = paint(el('span', 'pick-label'), current.id);
|
|
629
|
+
text.append('to ');
|
|
630
|
+
text.append(el('b', null, `@${current.id}`));
|
|
631
|
+
const modeChip = el('button', `mode-chip m${state.mode}`);
|
|
632
|
+
modeChip.type = 'button';
|
|
633
|
+
modeChip.append(el('b', null, `#${state.mode}`), `${MODES[state.mode].label} ▾`);
|
|
634
|
+
modeChip.title = `Permission mode for this message · ${MODES[state.mode].hint}`;
|
|
635
|
+
modeChip.addEventListener('click', (event) => { event.stopPropagation(); toggleModeMenu(current.id, modeChip); });
|
|
636
|
+
text.append(modeChip);
|
|
637
|
+
const chosen = state.chosenModel[current.id];
|
|
638
|
+
const modelChip = el('button', 'model-chip', chosen ? `${chosen} ▾` : 'default model ▾');
|
|
639
|
+
modelChip.type = 'button';
|
|
640
|
+
modelChip.title = 'Choose the model for this agent';
|
|
641
|
+
modelChip.addEventListener('click', (event) => { event.stopPropagation(); toggleModelMenu(current.id, modelChip); });
|
|
642
|
+
text.append(modelChip);
|
|
643
|
+
els.picker.append(text);
|
|
644
|
+
}
|
|
645
|
+
if (typeof renderCreateScopes === 'function') renderCreateScopes();
|
|
646
|
+
if (typeof renderHighlight === 'function') renderHighlight();
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/* ---------- model menu: which model answers this request ---------- */
|
|
650
|
+
|
|
651
|
+
const modelMenu = el('div', 'model-menu');
|
|
652
|
+
modelMenu.hidden = true;
|
|
653
|
+
document.body.append(modelMenu);
|
|
654
|
+
let modelMenuFor = null;
|
|
655
|
+
|
|
656
|
+
async function ensureModels(id) {
|
|
657
|
+
if (state.models[id]) return state.models[id];
|
|
658
|
+
try {
|
|
659
|
+
const data = await fetch(`/api/models${id === 'opencode' ? '?opencode=1' : ''}`).then((response) => response.json());
|
|
660
|
+
state.models = { ...state.models, ...(data.models ?? {}) };
|
|
661
|
+
} catch { /* offline: fall back to whatever we know */ }
|
|
662
|
+
return state.models[id] ?? { models: [], default: null, note: '' };
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function rememberModel(id, model) {
|
|
666
|
+
if (model) state.chosenModel[id] = model; else delete state.chosenModel[id];
|
|
667
|
+
try { localStorage.setItem('pulse.chosenModel', JSON.stringify(state.chosenModel)); } catch { /* private mode */ }
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async function toggleModelMenu(id, anchor) {
|
|
671
|
+
if (!modelMenu.hidden && modelMenuFor === id) { closeModelMenu(); return; }
|
|
672
|
+
modelMenuFor = id;
|
|
673
|
+
paint(modelMenu, id);
|
|
674
|
+
modelMenu.replaceChildren(el('div', 'model-menu-title', 'loading models…'));
|
|
675
|
+
modelMenu.hidden = false;
|
|
676
|
+
placeModelMenu(anchor);
|
|
677
|
+
const info = await ensureModels(id);
|
|
678
|
+
if (modelMenuFor !== id) return;
|
|
679
|
+
modelMenu.replaceChildren();
|
|
680
|
+
const title = el('div', 'model-menu-title');
|
|
681
|
+
title.append(el('b', null, `@${id}`), ` · model for this request`);
|
|
682
|
+
modelMenu.append(title);
|
|
683
|
+
const list = el('div', 'model-list');
|
|
684
|
+
const options = [{ name: null, label: `default${info.default ? ` · ${info.default}` : ''}` }, ...info.models.map((name) => ({ name, label: name }))];
|
|
685
|
+
for (const option of options) {
|
|
686
|
+
const button = el('button', `model-option${(state.chosenModel[id] ?? null) === option.name ? ' current' : ''}`, option.label);
|
|
687
|
+
button.type = 'button';
|
|
688
|
+
button.addEventListener('click', () => { rememberModel(id, option.name); closeModelMenu(); renderPicker(); els.input.focus(); });
|
|
689
|
+
list.append(button);
|
|
690
|
+
}
|
|
691
|
+
modelMenu.append(list);
|
|
692
|
+
const custom = el('form', 'model-custom');
|
|
693
|
+
const input = el('input'); input.placeholder = 'other model name…'; input.spellcheck = false;
|
|
694
|
+
custom.append(input);
|
|
695
|
+
custom.addEventListener('submit', (event) => { event.preventDefault(); const name = input.value.trim(); if (name) { rememberModel(id, name); closeModelMenu(); renderPicker(); } });
|
|
696
|
+
modelMenu.append(custom);
|
|
697
|
+
if (info.note) modelMenu.append(el('div', 'model-note', info.note));
|
|
698
|
+
placeModelMenu(anchor);
|
|
699
|
+
}
|
|
700
|
+
function placeModelMenu(anchor) {
|
|
701
|
+
const rect = anchor.getBoundingClientRect();
|
|
702
|
+
const width = modelMenu.offsetWidth || 280;
|
|
703
|
+
const height = modelMenu.offsetHeight || 200;
|
|
704
|
+
modelMenu.style.left = `${Math.max(12, Math.min(window.innerWidth - width - 12, rect.left))}px`;
|
|
705
|
+
modelMenu.style.top = `${Math.max(12, rect.top - height - 10)}px`;
|
|
706
|
+
}
|
|
707
|
+
function closeModelMenu() { modelMenu.hidden = true; modelMenuFor = null; }
|
|
708
|
+
document.addEventListener('click', (event) => { if (!modelMenu.hidden && !modelMenu.contains(event.target) && !event.target.closest('.picker')) closeModelMenu(); });
|
|
709
|
+
document.addEventListener('keydown', (event) => { if (event.key === 'Escape') closeModelMenu(); });
|
|
710
|
+
|
|
711
|
+
/* ---------- mode menu: how far this message may go ---------- */
|
|
712
|
+
|
|
713
|
+
const modeMenu = el('div', 'model-menu mode-menu');
|
|
714
|
+
modeMenu.hidden = true;
|
|
715
|
+
document.body.append(modeMenu);
|
|
716
|
+
let modeMenuFor = null;
|
|
717
|
+
function closeModeMenu() { modeMenu.hidden = true; modeMenuFor = null; }
|
|
718
|
+
document.addEventListener('click', (event) => { if (!modeMenu.hidden && !modeMenu.contains(event.target) && !event.target.closest('.picker')) closeModeMenu(); });
|
|
719
|
+
document.addEventListener('keydown', (event) => { if (event.key === 'Escape') closeModeMenu(); });
|
|
720
|
+
|
|
721
|
+
function ceilingFor(id) { return state.capabilities[id]?.scopes?.maxMode ?? 1; }
|
|
722
|
+
function defaultModeFor(id) { return state.capabilities[id]?.scopes?.defaultMode ?? 1; }
|
|
723
|
+
|
|
724
|
+
function toggleModeMenu(id, anchor) {
|
|
725
|
+
if (!modeMenu.hidden && modeMenuFor === id) { closeModeMenu(); return; }
|
|
726
|
+
closeModelMenu();
|
|
727
|
+
modeMenuFor = id;
|
|
728
|
+
paint(modeMenu, id);
|
|
729
|
+
modeMenu.replaceChildren();
|
|
730
|
+
const cap = ceilingFor(id);
|
|
731
|
+
const scopes = state.capabilities[id]?.scopes;
|
|
732
|
+
const canRaise = Boolean(scopes?.write?.capable);
|
|
733
|
+
const title = el('div', 'model-menu-title');
|
|
734
|
+
title.append(el('b', null, `@${id}`), ` · mode for this message · ceiling #${cap}`);
|
|
735
|
+
modeMenu.append(title);
|
|
736
|
+
const ladder = el('div', 'mode-ladder');
|
|
737
|
+
for (const n of [0, 1, 2, 3]) {
|
|
738
|
+
const locked = n > cap;
|
|
739
|
+
const raise = locked && n === 3 && canRaise;
|
|
740
|
+
const option = el('button', `mode-option o${n}${state.mode === n ? ' current' : ''}${locked ? ' locked' : ''}`);
|
|
741
|
+
option.type = 'button';
|
|
742
|
+
option.disabled = locked && !raise;
|
|
743
|
+
const body = el('span', 'body');
|
|
744
|
+
body.append(el('span', 'name', MODES[n].label), el('span', 'hint', MODES[n].hint));
|
|
745
|
+
option.append(el('span', 'n', `#${n}`), body);
|
|
746
|
+
const tag = state.mode === n ? 'NOW' : raise ? 'RAISE TO #3 ›' : locked ? 'LOCKED' : n === 3 ? 'OVERRIDE' : n === defaultModeFor(id) ? 'DEFAULT' : '';
|
|
747
|
+
if (tag) {
|
|
748
|
+
const label = el('span', `tag${raise ? ' raise' : ''}`, tag);
|
|
749
|
+
if (state.mode === n) label.append(el('span', 'dot'));
|
|
750
|
+
option.append(label);
|
|
751
|
+
}
|
|
752
|
+
option.title = !locked ? MODES[n].hint
|
|
753
|
+
: raise ? `@${id} is capped at #${cap}. This raises MAX MODE to #3 in CONNECTIONS and opens the override.`
|
|
754
|
+
: n === 3 ? `@${id}'s CLI cannot write files, so CONTROL is not possible for it.`
|
|
755
|
+
: `Above @${id}'s MAX MODE (#${cap}). Raise it in CONNECTIONS.`;
|
|
756
|
+
option.addEventListener('click', () => {
|
|
757
|
+
closeModeMenu();
|
|
758
|
+
if (n === 3) { openOverride(id, { raise }); return; }
|
|
759
|
+
setMode(n, { wink: n === 0 });
|
|
760
|
+
});
|
|
761
|
+
ladder.append(option);
|
|
762
|
+
}
|
|
763
|
+
modeMenu.append(ladder);
|
|
764
|
+
modeMenu.hidden = false;
|
|
765
|
+
const rect = anchor.getBoundingClientRect();
|
|
766
|
+
const width = modeMenu.offsetWidth || 320;
|
|
767
|
+
const height = modeMenu.offsetHeight || 260;
|
|
768
|
+
modeMenu.style.left = `${Math.max(12, Math.min(window.innerWidth - width - 12, rect.left))}px`;
|
|
769
|
+
modeMenu.style.top = `${Math.max(12, rect.top - height - 10)}px`;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// One place changes the mode: chip, classes, label, placeholder and the old CREATE state.
|
|
773
|
+
function setMode(n, { wink = false } = {}) {
|
|
774
|
+
const mode = [0, 1, 2, 3].includes(n) ? n : 1;
|
|
775
|
+
state.mode = mode;
|
|
776
|
+
state.create = mode === 2;
|
|
777
|
+
if (mode !== 3) state.modeArmedFor = null;
|
|
778
|
+
els.createToggle.setAttribute('aria-pressed', String(state.create));
|
|
779
|
+
els.composer.classList.toggle('creating', mode === 2);
|
|
780
|
+
els.composer.classList.toggle('ghost', mode === 0);
|
|
781
|
+
els.composer.classList.toggle('control', mode === 3);
|
|
782
|
+
for (const n of [0, 1, 2, 3]) els.composer.classList.toggle(`m${n}`, mode === n);
|
|
783
|
+
if (typeof renderCreateScopes === 'function') renderCreateScopes();
|
|
784
|
+
if (typeof updateCrewLabel === 'function') { updateCrewLabel(); updatePlaceholder(); }
|
|
785
|
+
if (typeof renderPicker === 'function') renderPicker();
|
|
786
|
+
if (typeof autosize === 'function') autosize();
|
|
787
|
+
if (wink && typeof winkField === 'function') winkField(mode === 3 ? 'control' : mode === 0 ? 'ghost' : 'ash');
|
|
788
|
+
}
|
|
789
|
+
// After a message goes out the mode falls back to the target's default: #2 for a standing lease, else #1.
|
|
790
|
+
function resetModeAfterSend() { setMode(defaultModeFor(els.target.value)); }
|
|
791
|
+
|
|
792
|
+
/* ---------- emergency command override: arming CONTROL ---------- */
|
|
793
|
+
|
|
794
|
+
const override = {
|
|
795
|
+
dialog: document.querySelector('#override'),
|
|
796
|
+
form: document.querySelector('#override-form'),
|
|
797
|
+
input: document.querySelector('#override-input'),
|
|
798
|
+
reply: document.querySelector('#override-reply'),
|
|
799
|
+
brief: document.querySelector('#override-brief'),
|
|
800
|
+
cancel: document.querySelector('#override-cancel'),
|
|
801
|
+
frame: document.querySelector('#override .override-frame'),
|
|
802
|
+
agent: null,
|
|
803
|
+
raise: false, // the agent is capped below #3: arming also raises MAX MODE in CONNECTIONS
|
|
804
|
+
};
|
|
805
|
+
function projectDesignation() { return (state.projectRoot ?? '').split('/').filter(Boolean).pop() ?? ''; }
|
|
806
|
+
function openOverride(id, { raise = false } = {}) {
|
|
807
|
+
if (!override.dialog) return;
|
|
808
|
+
override.agent = id;
|
|
809
|
+
override.raise = raise;
|
|
810
|
+
override.brief.textContent = `PRIORITY ONE. CONTROL GIVES @${id.toUpperCase()} THE PROJECT ITSELF: READ, CREATE, MODIFY, NO APPROVAL PER ACTION.${raise ? ` THIS ALSO RAISES @${id.toUpperCase()} MAX MODE TO #3 IN CONNECTIONS.` : ''} TYPE THE PROJECT DESIGNATION TO ARM.`;
|
|
811
|
+
override.reply.textContent = '';
|
|
812
|
+
override.reply.className = 'mother-answer override-reply';
|
|
813
|
+
override.input.value = '';
|
|
814
|
+
override.dialog.showModal();
|
|
815
|
+
override.input.focus();
|
|
816
|
+
}
|
|
817
|
+
override.cancel?.addEventListener('click', () => override.dialog.close());
|
|
818
|
+
override.form?.addEventListener('submit', (event) => {
|
|
819
|
+
event.preventDefault();
|
|
820
|
+
const typed = override.input.value.trim();
|
|
821
|
+
const expected = projectDesignation();
|
|
822
|
+
if (!typed || typed.toLowerCase() !== expected.toLowerCase()) {
|
|
823
|
+
override.reply.textContent = 'UNABLE TO COMPUTE. UNABLE TO CLARIFY.';
|
|
824
|
+
override.reply.className = 'mother-answer override-reply denied';
|
|
825
|
+
override.frame.classList.remove('shake'); void override.frame.offsetWidth; override.frame.classList.add('shake');
|
|
826
|
+
override.input.select();
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
override.reply.textContent = `SPECIAL ORDER 937 ACKNOWLEDGED. CONTROL ARMED FOR @${override.agent.toUpperCase()}. CREW IN COMMAND.`;
|
|
830
|
+
override.reply.className = 'mother-answer override-reply granted';
|
|
831
|
+
const agent = override.agent;
|
|
832
|
+
const raise = override.raise;
|
|
833
|
+
setTimeout(async () => {
|
|
834
|
+
override.dialog.close();
|
|
835
|
+
if (raise) {
|
|
836
|
+
try {
|
|
837
|
+
await saveSettingNow({ scopes: { [agent]: { maxMode: 3, write: true } } });
|
|
838
|
+
if (settingsUI.open) renderSettings();
|
|
839
|
+
} catch (error) {
|
|
840
|
+
toast(`MU/TH/UR › MAX MODE was not raised for @${agent}: ${error.message}. CONTROL stays off.`);
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
state.modeArmedFor = agent;
|
|
845
|
+
els.target.value = agent;
|
|
846
|
+
setMode(3, { wink: true });
|
|
847
|
+
toast(`MU/TH/UR › CONTROL armed for @${agent} for this message.${raise ? ` MAX MODE is now #3 in CONNECTIONS.` : ''} A checkpoint is taken before it runs; every change is listed and UNDO is one click.`);
|
|
848
|
+
}, 900);
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
function renderOnboarding() {
|
|
852
|
+
const ready = [...state.agents.values()].filter((agent) => agent.ready);
|
|
853
|
+
els.onboarding.hidden = ready.length > 0;
|
|
854
|
+
els.composer.setAttribute('aria-disabled', ready.length ? 'false' : 'true');
|
|
855
|
+
els.input.disabled = !ready.length;
|
|
856
|
+
els.send.disabled = !ready.length;
|
|
857
|
+
if (ready.length) return;
|
|
858
|
+
els.onboardingList.replaceChildren();
|
|
859
|
+
for (const agent of state.agents.values()) {
|
|
860
|
+
const item = paint(el('li'), agent.id);
|
|
861
|
+
item.append(avatar(agent.id, { size: 28, status: agent.detected ? 'detected' : 'offline' }));
|
|
862
|
+
item.append(el('span', 'name', agent.label));
|
|
863
|
+
const status = el('span', 'state');
|
|
864
|
+
status.append(el('b', null, agent.ready ? 'Ready' : agent.detected ? 'Installed, adapter not enabled' : 'Not installed'));
|
|
865
|
+
status.append(` · ${agent.detected ? (agent.version ?? 'version unknown') : AGENT_HINTS[agent.id] ?? 'Install it and reload.'}`);
|
|
866
|
+
item.append(status);
|
|
867
|
+
els.onboardingList.append(item);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/* ---------- thread rendering ---------- */
|
|
872
|
+
|
|
873
|
+
function removeEmpty() {
|
|
874
|
+
els.column.querySelector('.empty')?.remove();
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function removeThinking(messageId) {
|
|
878
|
+
document.getElementById(`working-${messageId}`)?.remove();
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function row(kind, agentId, { compact = false } = {}) {
|
|
882
|
+
const node = el('article', `row ${kind}${compact ? ' compact' : ''}`);
|
|
883
|
+
if (agentId) {
|
|
884
|
+
paint(node, agentId);
|
|
885
|
+
node.append(avatar(agentId, { size: 28 }));
|
|
886
|
+
}
|
|
887
|
+
return node;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function renderUserMessage(event) {
|
|
891
|
+
const { messageId, target, text, originalText, ashCode, model, attachments = [], create, mode } = event.payload;
|
|
892
|
+
state.userMessages.set(messageId, { text: originalText ?? text, target, model });
|
|
893
|
+
const reviewed = state.expendable;
|
|
894
|
+
const node = row('user');
|
|
895
|
+
if (event.ghost || mode === 0) node.classList.add('ghost');
|
|
896
|
+
if (reviewed) node.classList.add('expendable');
|
|
897
|
+
node.id = `msg-${messageId}`;
|
|
898
|
+
const col = el('div', 'col');
|
|
899
|
+
const who = el('div', 'who');
|
|
900
|
+
who.append(el('b', null, reviewed ? 'YOU · CREW (EXPENDABLE)' : 'YOU · CREW'));
|
|
901
|
+
const shownMode = Number.isInteger(mode) ? mode : create ? 2 : null;
|
|
902
|
+
if (shownMode !== null && shownMode !== 1) who.append(el('span', `badge mode m${shownMode}`, `#${shownMode} ${MODES[shownMode].label}`));
|
|
903
|
+
if (ashCode?.active) who.append(el('span', `badge ash${ashCode.applied ? '' : ' skipped'}`, ashCode.applied ? 'ASH CODE' : 'ASH CODE · unchanged'));
|
|
904
|
+
col.append(who);
|
|
905
|
+
const bubble = el('div', 'bubble', text);
|
|
906
|
+
if (originalText) bubble.append(ashOriginal(originalText, ashCode));
|
|
907
|
+
if (attachments.length) bubble.append(fileTiles(attachments));
|
|
908
|
+
col.append(bubble);
|
|
909
|
+
const stamp = paint(el('div', 'stamp'), target);
|
|
910
|
+
stamp.append(el('span', 'to', `→ @${target}${model ? ` · ${model}` : ''}`));
|
|
911
|
+
stamp.append(el('span', null, formatTime(event.timestamp)));
|
|
912
|
+
if (reviewed) stamp.append(el('span', null, 'acknowledged, human'));
|
|
913
|
+
col.append(stamp);
|
|
914
|
+
node.append(col);
|
|
915
|
+
state.lastSender = 'you';
|
|
916
|
+
if (reviewed) disarmExpendable();
|
|
917
|
+
return node;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function renderAssistantMessage(event) {
|
|
921
|
+
const { messageId, parentMessageId, sender, target, text, originalText, ashCode, status, step, totalSteps, model, mode } = event.payload;
|
|
922
|
+
const delegated = status === 'delegated';
|
|
923
|
+
const compact = state.lastSender === sender && !delegated && !ashCode?.active && !event.ghost;
|
|
924
|
+
const node = row('assistant', sender, { compact });
|
|
925
|
+
if (event.ghost || mode === 0) node.classList.add('ghost');
|
|
926
|
+
if (delegated) node.classList.add('delegated');
|
|
927
|
+
node.id = `msg-${messageId}`;
|
|
928
|
+
const col = el('div', 'col');
|
|
929
|
+
if (!compact || status === 'handoff' || delegated) {
|
|
930
|
+
const who = el('div', 'who');
|
|
931
|
+
who.append(el('span', null, label(sender)));
|
|
932
|
+
if (delegated) {
|
|
933
|
+
const to = paint(el('span', 'to-agent'), target);
|
|
934
|
+
to.append(`→ @${target}`);
|
|
935
|
+
who.append(to);
|
|
936
|
+
who.append(el('span', 'badge', target === sender ? 'closing turn' : `step ${step}/${totalSteps}`));
|
|
937
|
+
} else if (target && target !== 'you') {
|
|
938
|
+
who.append(el('span', 'badge', `answering @${target}`));
|
|
939
|
+
}
|
|
940
|
+
if (status === 'handoff') who.append(el('span', 'badge', 'handoff note'));
|
|
941
|
+
if (model) who.append(el('span', 'badge model', model));
|
|
942
|
+
if (Number.isInteger(mode) && mode !== 1) who.append(el('span', `badge mode m${mode}`, `#${mode} ${MODES[mode].label}`));
|
|
943
|
+
if (event.payload.escalation) who.append(el('span', 'badge mode m1', event.payload.escalation === 'timeout' ? '#2 not answered · read-only' : event.payload.escalation === 'stopped' ? 'stopped' : '#2 denied · read-only'));
|
|
944
|
+
if (ashCode?.active) who.append(el('span', `badge ash${ashCode.applied ? '' : ' skipped'}`, ashCode.applied ? 'ASH CODE' : 'ASH CODE · unchanged'));
|
|
945
|
+
const question = state.userMessages.get(parentMessageId);
|
|
946
|
+
if (question) {
|
|
947
|
+
const reply = el('span', 'reply', `↳ ${question.text.length > 90 ? `${question.text.slice(0, 90)}…` : question.text}`);
|
|
948
|
+
reply.title = question.text;
|
|
949
|
+
who.append(reply);
|
|
950
|
+
}
|
|
951
|
+
col.append(who);
|
|
952
|
+
}
|
|
953
|
+
const bubble = el('div', 'bubble');
|
|
954
|
+
bubble.append(renderMarkdown(text));
|
|
955
|
+
if (originalText) bubble.append(ashOriginal(originalText, ashCode));
|
|
956
|
+
if (event.payload.artifacts?.length) bubble.append(artifactTiles(event.payload.artifacts));
|
|
957
|
+
col.append(bubble);
|
|
958
|
+
const stamp = el('div', 'stamp');
|
|
959
|
+
stamp.id = `usage-${messageId}`;
|
|
960
|
+
stamp.append(el('span', null, formatTime(event.timestamp)));
|
|
961
|
+
col.append(stamp);
|
|
962
|
+
node.append(col);
|
|
963
|
+
state.lastSender = sender;
|
|
964
|
+
return node;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function ashOriginal(originalText, info) {
|
|
968
|
+
const details = el('details', 'ash-original');
|
|
969
|
+
const saved = Math.max(0, (info?.originalChars ?? originalText.length) - (info?.encodedChars ?? originalText.length));
|
|
970
|
+
details.append(el('summary', null, `Original · ${saved} fewer characters (not verified tokens)`));
|
|
971
|
+
details.append(el('pre', null, originalText));
|
|
972
|
+
return details;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function fileTiles(files) {
|
|
976
|
+
const wrap = el('div', 'files');
|
|
977
|
+
for (const file of files) {
|
|
978
|
+
const url = `/api/files?root=attachments&path=${encodeURIComponent(file.fileName)}`;
|
|
979
|
+
const image = (file.contentType ?? '').startsWith('image/');
|
|
980
|
+
const tile = el('button', `file-tile${image ? ' image' : ''}`);
|
|
981
|
+
tile.type = 'button';
|
|
982
|
+
tile.title = `${file.name} · ${formatTokens(file.size)}B`;
|
|
983
|
+
if (image) { const img = el('img'); img.src = url; img.alt = file.name; img.loading = 'lazy'; tile.append(img); }
|
|
984
|
+
else { tile.append(el('span', 'kind', (file.name.split('.').pop() ?? 'file').slice(0, 4).toUpperCase())); tile.append(el('span', 'name', file.name)); }
|
|
985
|
+
tile.addEventListener('click', () => openViewer({ root: 'attachments', path: file.fileName, label: file.name }));
|
|
986
|
+
wrap.append(tile);
|
|
987
|
+
}
|
|
988
|
+
return wrap;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// What the waiting bubble says while an agent works, in the voice each CLI
|
|
992
|
+
// uses in its own terminal, staged by how long the turn has been running.
|
|
993
|
+
const WORKING_VOICE = {
|
|
994
|
+
codex: {
|
|
995
|
+
early: ['Reading the request…', 'Looking around the project…', 'Opening files…'],
|
|
996
|
+
middle: ['Thinking…', 'Tracing how this fits together…', 'Cross-checking the code…', 'Skimming the transcript…'],
|
|
997
|
+
late: ['Reasoning…', 'Weighing the options…', 'Verifying before answering…', 'Still on it…'],
|
|
998
|
+
long: ['Deep in the code…', 'Composing the answer…', 'Almost there…'],
|
|
999
|
+
},
|
|
1000
|
+
claude: {
|
|
1001
|
+
early: ['Reading…', 'Grepping the project…', 'Mapping the files…'],
|
|
1002
|
+
middle: ['Thinking…', 'Pondering…', 'Connecting the pieces…', 'Reading the relevant files…'],
|
|
1003
|
+
late: ['Ruminating…', 'Considering the edge cases…', 'Checking the details…', 'Musing…'],
|
|
1004
|
+
long: ['Synthesizing…', 'Drafting the reply…', 'Finishing the thought…'],
|
|
1005
|
+
},
|
|
1006
|
+
gemini: {
|
|
1007
|
+
early: ['Scanning the project…', 'Loading context…', 'Reading files…'],
|
|
1008
|
+
middle: ['Thinking…', 'Analyzing…', 'Following the references…', 'Building the picture…'],
|
|
1009
|
+
late: ['Reasoning through it…', 'Verifying the findings…', 'Sorting the evidence…'],
|
|
1010
|
+
long: ['Formulating the answer…', 'Writing it up…', 'Wrapping up…'],
|
|
1011
|
+
},
|
|
1012
|
+
opencode: {
|
|
1013
|
+
early: ['Reading the repo…', 'Listing files…', 'Grabbing context…'],
|
|
1014
|
+
middle: ['Thinking…', 'Digging through the code…', 'Following the call chain…', 'Looking closer…'],
|
|
1015
|
+
late: ['Working through it…', 'Double-checking…', 'Piecing it together…'],
|
|
1016
|
+
long: ['Writing the response…', 'Tidying the answer…', 'Nearly done…'],
|
|
1017
|
+
},
|
|
1018
|
+
};
|
|
1019
|
+
function workingPhrases(agent, prompt = '') {
|
|
1020
|
+
const voice = WORKING_VOICE[agent] ?? { early: ['Reading…'], middle: ['Thinking…'], late: ['Working…'], long: ['Writing…'] };
|
|
1021
|
+
const topic = prompt.trim().split(/\s+/).slice(0, 4).join(' ');
|
|
1022
|
+
const middle = [...voice.middle];
|
|
1023
|
+
if (topic && topic.length <= 40) middle.splice(1, 0, `Thinking about "${topic}"…`);
|
|
1024
|
+
return [voice.early, middle, voice.late, voice.long];
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
function renderThinking(event) {
|
|
1028
|
+
const { messageId, agent } = event.payload;
|
|
1029
|
+
const node = row('thinking', agent);
|
|
1030
|
+
node.id = `working-${messageId}`;
|
|
1031
|
+
const bubble = el('div', 'bubble');
|
|
1032
|
+
bubble.append(el('i'), el('i'), el('i'));
|
|
1033
|
+
const status = el('span', 'status');
|
|
1034
|
+
const elapsed = el('span', 'elapsed');
|
|
1035
|
+
bubble.append(status, elapsed);
|
|
1036
|
+
node.append(bubble);
|
|
1037
|
+
const startedAt = event.timestamp ? new Date(event.timestamp).getTime() : Date.now();
|
|
1038
|
+
const phrases = workingPhrases(agent, state.userMessages.get(messageId)?.text ?? '');
|
|
1039
|
+
let phraseIndex = -1;
|
|
1040
|
+
let phraseChangedAt = 0;
|
|
1041
|
+
const speak = (seconds) => {
|
|
1042
|
+
// Early phrases turn over quickly, then settle: the model reads first, then reasons, then writes.
|
|
1043
|
+
const stage = seconds < 4 ? 0 : seconds < 12 ? 1 : seconds < 40 ? 2 : 3;
|
|
1044
|
+
const pool = phrases[stage];
|
|
1045
|
+
const period = stage === 0 ? 2 : stage === 1 ? 4 : 6;
|
|
1046
|
+
if (seconds - phraseChangedAt < period && phraseIndex >= 0) return;
|
|
1047
|
+
phraseChangedAt = seconds;
|
|
1048
|
+
phraseIndex = (phraseIndex + 1) % pool.length;
|
|
1049
|
+
const next = pool[phraseIndex];
|
|
1050
|
+
if (status.textContent !== next) { status.textContent = next; status.style.animation = 'none'; void status.offsetWidth; status.style.animation = ''; }
|
|
1051
|
+
};
|
|
1052
|
+
const limitMs = state.timeouts[agent] ?? 180000;
|
|
1053
|
+
const limit = Math.round(limitMs / 1000);
|
|
1054
|
+
let timer = null;
|
|
1055
|
+
let wasConnected = false;
|
|
1056
|
+
const tick = () => {
|
|
1057
|
+
// Stop once the bubble has been in the document and was removed again.
|
|
1058
|
+
if (node.isConnected) wasConnected = true;
|
|
1059
|
+
else if (wasConnected && timer) { clearInterval(timer); return; }
|
|
1060
|
+
const seconds = Math.max(0, Math.round((Date.now() - startedAt) / 1000));
|
|
1061
|
+
speak(seconds);
|
|
1062
|
+
elapsed.textContent = `${seconds}s / ${limit}s`;
|
|
1063
|
+
elapsed.classList.toggle('late', seconds >= limit * 0.6);
|
|
1064
|
+
bubble.title = `${label(agent)} is reading the project… ${seconds}s so far; MADRE gives up at ${limit}s.`;
|
|
1065
|
+
};
|
|
1066
|
+
tick();
|
|
1067
|
+
timer = setInterval(tick, 1000);
|
|
1068
|
+
timer.unref?.();
|
|
1069
|
+
return node;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// The archivist reports: which agent read which stretch of the room and how many notes it kept.
|
|
1073
|
+
function renderDistilled(event) {
|
|
1074
|
+
const { agent, added, considered, fromSequence, throughSequence, remaining, error, skipped, total } = event.payload;
|
|
1075
|
+
const node = el('div', `system memory${error ? ' warn' : ''}`);
|
|
1076
|
+
node.style.setProperty('--agent', agentColor(agent));
|
|
1077
|
+
node.append('memory · ');
|
|
1078
|
+
node.append(el('b', 'who', `@${agent}`));
|
|
1079
|
+
if (error) {
|
|
1080
|
+
node.append(` could not distil #${fromSequence}–#${throughSequence}: ${error}${skipped ? ' · batch skipped' : ' · will retry'}`);
|
|
1081
|
+
} else {
|
|
1082
|
+
node.append(` read ${considered} exchange${considered === 1 ? '' : 's'} (#${fromSequence}–#${throughSequence}) · kept ${added} memor${added === 1 ? 'y' : 'ies'}${Number.isFinite(total) ? ` · ${total} in the archive` : ''}${remaining ? ` · ${remaining} waiting` : ''}`);
|
|
1083
|
+
}
|
|
1084
|
+
return node;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// A memory the agent saved on request shows up under its bubble: one pill per note, in the kind's colour.
|
|
1088
|
+
function attachMemoryHint(event) {
|
|
1089
|
+
const { responseMessageId, notes = [], agent } = event.payload;
|
|
1090
|
+
const bubbleRow = document.getElementById(`msg-${responseMessageId}`);
|
|
1091
|
+
const col = bubbleRow?.querySelector?.('.col');
|
|
1092
|
+
if (!col || !notes.length) return;
|
|
1093
|
+
col.querySelector?.('.memory-hint')?.remove?.();
|
|
1094
|
+
const hint = el('div', 'memory-hint');
|
|
1095
|
+
hint.style.setProperty('--agent', agentColor(agent));
|
|
1096
|
+
hint.append(el('span', 'lead', '◉ memory saved'));
|
|
1097
|
+
for (const note of notes.slice(0, 3)) {
|
|
1098
|
+
const pill = el('button', `pill ${note.kind}`);
|
|
1099
|
+
pill.type = 'button';
|
|
1100
|
+
pill.style.setProperty('--kind', MEMORY_COLORS[note.kind] ?? MEMORY_COLORS.fact);
|
|
1101
|
+
pill.append(el('b', null, note.kind), ` ${note.text.length > 64 ? `${note.text.slice(0, 63)}…` : note.text}`);
|
|
1102
|
+
pill.title = `${note.text}\nSaved by @${agent} for every future turn · #${note.fromSequence}–#${note.throughSequence}. Click to see it in NOSTROMO.`;
|
|
1103
|
+
pill.addEventListener('click', () => { nostromo.focusId = note.id; nostromo.button?.click(); });
|
|
1104
|
+
hint.append(pill);
|
|
1105
|
+
}
|
|
1106
|
+
if (notes.length > 3) hint.append(el('span', 'more', `+${notes.length - 3}`));
|
|
1107
|
+
const stamp = col.querySelector?.('.stamp');
|
|
1108
|
+
if (stamp && typeof col.insertBefore === 'function') col.insertBefore(hint, stamp); else col.append(hint);
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
// MOTHER speaking to the crew: the room sees the code, the agents get the words in their prompt.
|
|
1112
|
+
function renderMotherAlert(event) {
|
|
1113
|
+
const { kind, code, strikes, lockedForMs, message, n } = event.payload;
|
|
1114
|
+
const node = el('div', 'system mother-alert');
|
|
1115
|
+
const head = el('div', 'head');
|
|
1116
|
+
head.append(el('b', null, 'MU/TH/UR › TO ALL CREW'), kind === 'tamper' ? ' · CHANNEL TAMPERED' : ` · CODE000 · ${strikes} STRIKES · ARCHIVE SEALED ${Math.max(1, Math.round((lockedForMs ?? 0) / 60000))} MIN`);
|
|
1117
|
+
node.append(head);
|
|
1118
|
+
if (message) node.append(el('div', 'clear', message));
|
|
1119
|
+
const coded = el('code', 'code', code.length > 220 ? `${code.slice(0, 219)}…` : code);
|
|
1120
|
+
coded.title = `Message #${n}, sealed under MOTHER's key in .pulse/mother.env. Only the crew reads it in clear.`;
|
|
1121
|
+
node.append(coded);
|
|
1122
|
+
return node;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
function renderForgotten(event) {
|
|
1126
|
+
const { kind, text, remaining } = event.payload;
|
|
1127
|
+
const node = el('div', 'system memory forgotten');
|
|
1128
|
+
node.append('memory · the human forgot a ');
|
|
1129
|
+
node.append(el('b', 'who', kind));
|
|
1130
|
+
node.append(`: “${text}”${Number.isFinite(remaining) ? ` · ${remaining} left in the archive` : ''}`);
|
|
1131
|
+
return node;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
function renderHandoff(event) {
|
|
1135
|
+
const { fromAgent, toAgent, messageCount, omittedMessages, kind } = event.payload;
|
|
1136
|
+
const node = el('div', 'system handoff');
|
|
1137
|
+
node.style.setProperty('--from', agentColor(fromAgent));
|
|
1138
|
+
node.style.setProperty('--to', agentColor(toAgent));
|
|
1139
|
+
node.append('handoff ');
|
|
1140
|
+
node.append(el('b', 'from', `@${fromAgent}`));
|
|
1141
|
+
node.append(' → ');
|
|
1142
|
+
node.append(el('b', 'to', `@${toAgent}`));
|
|
1143
|
+
node.append(messageCount > 0
|
|
1144
|
+
? ` · ${messageCount} message${messageCount === 1 ? '' : 's'} carried${omittedMessages ? ` · ${omittedMessages} older stay in the record` : ''}`
|
|
1145
|
+
: ' · no prior context');
|
|
1146
|
+
if (kind && kind !== 'automatic') node.append(` · ${kind}`);
|
|
1147
|
+
node.title = `The receiving agent gets the most recent transcript that fits its context allowance (${omittedMessages ? `${omittedMessages} older messages did not fit; they remain in the room log` : 'everything fit'}). Adjust with PULSE_CONTEXT_MAX_CHARS.`;
|
|
1148
|
+
state.lastSender = null;
|
|
1149
|
+
return node;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
const classify = (percent) => (percent >= 100 ? 'exhausted' : percent >= 90 ? 'critical' : percent >= 80 ? 'warning' : 'normal');
|
|
1153
|
+
|
|
1154
|
+
function renderWarning(event) {
|
|
1155
|
+
const { agent, usedPercent, projectedPercent, level, message, alternatives = [], source } = event.payload;
|
|
1156
|
+
const byProjection = Number.isFinite(projectedPercent) && projectedPercent > usedPercent && classify(projectedPercent) !== classify(usedPercent);
|
|
1157
|
+
if (level === 'exhausted') recordFailure({ time: event.timestamp, agent, error: message, warning: true });
|
|
1158
|
+
const node = el('div', `system ${level === 'warning' ? 'warn' : 'crit'}`);
|
|
1159
|
+
node.title = message;
|
|
1160
|
+
const scope = source === 'room-soft-budget' ? 'local budget' : source?.startsWith('official') ? 'provider quota' : source === 'test-simulation' ? 'simulated window' : 'usage window';
|
|
1161
|
+
node.append(el('b', null, byProjection ? 'projection · ' : `${level} · `));
|
|
1162
|
+
node.append(el('b', null, `@${agent} `));
|
|
1163
|
+
node.append(byProjection
|
|
1164
|
+
? `at ${Math.round(usedPercent)}% of ${scope} · next turn like the last → ${Math.round(projectedPercent)}%`
|
|
1165
|
+
: `${Math.round(usedPercent)}% of ${scope} used`);
|
|
1166
|
+
if (alternatives.length) node.append(` · continue with ${alternatives.map((id) => `@${id}`).join(' / ')}`);
|
|
1167
|
+
const gauge = el('span', 'gauge');
|
|
1168
|
+
if (byProjection) {
|
|
1169
|
+
const next = el('span', 'next');
|
|
1170
|
+
next.style.width = `${Math.min(100, projectedPercent)}%`;
|
|
1171
|
+
gauge.append(next);
|
|
1172
|
+
}
|
|
1173
|
+
const now = el('span', 'now');
|
|
1174
|
+
now.style.width = `${Math.min(100, usedPercent)}%`;
|
|
1175
|
+
gauge.append(now);
|
|
1176
|
+
node.append(gauge);
|
|
1177
|
+
state.lastSender = null;
|
|
1178
|
+
return node;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
function renderPlanIgnored(event) {
|
|
1182
|
+
const { orchestrator, reasons = [] } = event.payload;
|
|
1183
|
+
const node = el('div', 'system plan failed');
|
|
1184
|
+
node.style.setProperty('--agent', agentColor(orchestrator));
|
|
1185
|
+
node.append(el('b', null, 'plan · '));
|
|
1186
|
+
node.append(`@${orchestrator}'s plan block was not run · ${[...new Set(reasons)].join(' · ')} · ask again and it will fix the block`);
|
|
1187
|
+
state.lastSender = null;
|
|
1188
|
+
return node;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
function renderLeaseRefused(event) {
|
|
1192
|
+
const { message } = event.payload;
|
|
1193
|
+
const node = el('div', 'system lease refused');
|
|
1194
|
+
node.append(el('b', null, 'MU/TH/UR › '));
|
|
1195
|
+
node.append(message);
|
|
1196
|
+
if (!replaying) toast(`MU/TH/UR › ${message}`);
|
|
1197
|
+
state.lastSender = null;
|
|
1198
|
+
return node;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
/* ---------- CONTROL: the project itself, between two checkpoints ---------- */
|
|
1202
|
+
|
|
1203
|
+
function renderControlStarted(event) {
|
|
1204
|
+
const { agent, commit, message } = event.payload;
|
|
1205
|
+
const node = el('div', 'system control');
|
|
1206
|
+
node.style.setProperty('--agent', agentColor(agent));
|
|
1207
|
+
node.append(el('b', null, 'CONTROL · '), el('b', 'who', `@${agent}`), ` holds the project · checkpoint `, el('code', null, (commit ?? '').slice(0, 7)));
|
|
1208
|
+
node.title = message;
|
|
1209
|
+
if (!replaying) toast(`MU/TH/UR › @${agent} holds CONTROL. Checkpoint taken; UNDO will be one click.`);
|
|
1210
|
+
state.lastSender = null;
|
|
1211
|
+
return node;
|
|
1212
|
+
}
|
|
1213
|
+
function renderControlChanged(event) {
|
|
1214
|
+
const { agent, checkpointId, files = [], stat, forbiddenReverted = [], message } = event.payload;
|
|
1215
|
+
const node = el('div', `system control changed${files.length ? '' : ' quiet'}`);
|
|
1216
|
+
node.id = `control-${checkpointId}`;
|
|
1217
|
+
node.style.setProperty('--agent', agentColor(agent));
|
|
1218
|
+
const head = el('div', 'head');
|
|
1219
|
+
head.append(el('b', null, 'CONTROL · '), el('b', 'who', `@${agent}`), files.length ? ` changed ${files.length} file${files.length === 1 ? '' : 's'}` : ' changed nothing');
|
|
1220
|
+
node.append(head);
|
|
1221
|
+
if (files.length) {
|
|
1222
|
+
const list = el('ul', 'files');
|
|
1223
|
+
for (const file of files.slice(0, 40)) {
|
|
1224
|
+
const item = el('li');
|
|
1225
|
+
item.append(el('span', `st ${file.status}`, { A: 'added', M: 'modified', D: 'deleted', R: 'renamed' }[file.status] ?? file.status));
|
|
1226
|
+
const link = el('button', 'file-ref', file.path);
|
|
1227
|
+
link.type = 'button';
|
|
1228
|
+
if (file.status !== 'D') link.addEventListener('click', () => openViewer({ root: 'project', path: file.path, label: `/${file.path} · ${file.status}` }));
|
|
1229
|
+
else link.disabled = true;
|
|
1230
|
+
item.append(link);
|
|
1231
|
+
list.append(item);
|
|
1232
|
+
}
|
|
1233
|
+
if (files.length > 40) list.append(el('li', null, `… and ${files.length - 40} more`));
|
|
1234
|
+
node.append(list);
|
|
1235
|
+
if (stat) { const pre = el('pre', 'stat', stat.split('\n').slice(-1)[0]); pre.title = stat; node.append(pre); }
|
|
1236
|
+
}
|
|
1237
|
+
if (forbiddenReverted.length) node.append(el('div', 'forbidden', `${forbiddenReverted.length} write${forbiddenReverted.length === 1 ? '' : 's'} into forbidden zones reverted: ${forbiddenReverted.join(', ')}`));
|
|
1238
|
+
if (files.length) {
|
|
1239
|
+
const undo = el('button', 'undo', 'UNDO · RESTORE CHECKPOINT');
|
|
1240
|
+
undo.type = 'button';
|
|
1241
|
+
undo.title = 'Put the project back exactly as it was before this CONTROL turn.';
|
|
1242
|
+
undo.addEventListener('click', async () => {
|
|
1243
|
+
undo.disabled = true;
|
|
1244
|
+
try {
|
|
1245
|
+
const response = await fetch(`/api/control/${checkpointId}/undo`, { method: 'POST' });
|
|
1246
|
+
const result = await response.json().catch(() => ({}));
|
|
1247
|
+
if (!response.ok) throw new Error(result.error ?? `HTTP ${response.status}`);
|
|
1248
|
+
undo.textContent = 'RESTORED';
|
|
1249
|
+
} catch (error) { toast(`MU/TH/UR › ${error.message}`); undo.disabled = false; }
|
|
1250
|
+
});
|
|
1251
|
+
node.append(undo);
|
|
1252
|
+
}
|
|
1253
|
+
node.title = message;
|
|
1254
|
+
state.lastSender = null;
|
|
1255
|
+
return node;
|
|
1256
|
+
}
|
|
1257
|
+
function renderControlReverted(event) {
|
|
1258
|
+
const { agent, checkpointId, restored = [], removed = [], message } = event.payload;
|
|
1259
|
+
const node = el('div', 'system control reverted');
|
|
1260
|
+
node.style.setProperty('--agent', agentColor(agent));
|
|
1261
|
+
node.append(el('b', null, 'CONTROL · '), `project restored to the checkpoint before @${agent}'s turn · ${restored.length} restored · ${removed.length} removed`);
|
|
1262
|
+
node.title = message;
|
|
1263
|
+
const card = document.getElementById(`control-${checkpointId}`);
|
|
1264
|
+
card?.querySelector('.undo')?.replaceWith(el('span', 'outcome', 'RESTORED'));
|
|
1265
|
+
if (!replaying) toast(`MU/TH/UR › ${message}`);
|
|
1266
|
+
state.lastSender = null;
|
|
1267
|
+
return node;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
function renderLeaseMissing(event) {
|
|
1271
|
+
const { agent, requester, text, message } = event.payload;
|
|
1272
|
+
const node = el('div', 'system lease missing');
|
|
1273
|
+
node.style.setProperty('--agent', agentColor(agent));
|
|
1274
|
+
node.append(el('b', null, 'MU/TH/UR › '));
|
|
1275
|
+
node.append(message);
|
|
1276
|
+
const actions = el('span', 'actions');
|
|
1277
|
+
const resend = el('button', 'resend', `RESEND TO @${agent.toUpperCase()} WITH CREATE`);
|
|
1278
|
+
resend.type = 'button';
|
|
1279
|
+
resend.title = 'Send this same request from you, with a creation lease.';
|
|
1280
|
+
resend.addEventListener('click', async () => {
|
|
1281
|
+
resend.disabled = true;
|
|
1282
|
+
try {
|
|
1283
|
+
const response = await fetch('/api/messages', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text, target: agent, model: state.chosenModel[agent] ?? null, attachments: [], create: true }) });
|
|
1284
|
+
if (!response.ok) { const result = await response.json().catch(() => ({})); throw new Error(result.error ?? `HTTP ${response.status}`); }
|
|
1285
|
+
resend.textContent = 'RESENT WITH CREATE';
|
|
1286
|
+
} catch (error) { toast(`Could not resend: ${error.message}`); resend.disabled = false; }
|
|
1287
|
+
});
|
|
1288
|
+
const standing = el('button', 'standing', 'ALWAYS FOR THIS AGENT');
|
|
1289
|
+
standing.type = 'button';
|
|
1290
|
+
standing.title = `Give @${agent} a standing lease in CONNECTIONS: every turn may create files.`;
|
|
1291
|
+
standing.addEventListener('click', () => {
|
|
1292
|
+
openMother();
|
|
1293
|
+
if (!settingsUI.open) settingsUI.button.click();
|
|
1294
|
+
});
|
|
1295
|
+
actions.append(resend, standing);
|
|
1296
|
+
node.append(actions);
|
|
1297
|
+
if (requester === 'you' && !replaying) toast(`MU/TH/UR › CREATE is off: @${agent} will answer read-only. Use the lock or /create.`);
|
|
1298
|
+
state.lastSender = null;
|
|
1299
|
+
return node;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
/* ---------- escalation: a plan step asks for #2 while the plan runs at #1 ---------- */
|
|
1303
|
+
|
|
1304
|
+
const modeRequests = new Map(); // requestId -> { node, timer }
|
|
1305
|
+
function renderModeRequest(event) {
|
|
1306
|
+
const { requestId, agent, orchestrator, mode, step, totalSteps, text, expiresAt, message } = event.payload;
|
|
1307
|
+
const node = el('div', 'system escalation');
|
|
1308
|
+
node.id = `mode-request-${requestId}`;
|
|
1309
|
+
node.style.setProperty('--agent', agentColor(agent));
|
|
1310
|
+
const head = el('div', 'head');
|
|
1311
|
+
head.append(el('b', null, 'MU/TH/UR › '), el('b', 'who', `@${agent}`), ` asks `, el('span', `badge mode m${mode}`, `#${mode} ${MODES[mode].label}`), ` for step ${step}/${totalSteps} of @${orchestrator}'s plan`);
|
|
1312
|
+
node.append(head);
|
|
1313
|
+
const quote = el('div', 'quote', text.length > 220 ? `${text.slice(0, 220)}…` : text);
|
|
1314
|
+
quote.title = text;
|
|
1315
|
+
node.append(quote);
|
|
1316
|
+
const actions = el('div', 'actions');
|
|
1317
|
+
const decide = async (decision, button) => {
|
|
1318
|
+
for (const other of actions.querySelectorAll('button')) other.disabled = true;
|
|
1319
|
+
try {
|
|
1320
|
+
const response = await fetch(`/api/modes/${requestId}/decide`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ decision }) });
|
|
1321
|
+
const result = await response.json().catch(() => ({}));
|
|
1322
|
+
if (!response.ok) throw new Error(result.error ?? `HTTP ${response.status}`);
|
|
1323
|
+
button.textContent = decision === 'deny' ? 'DENIED' : 'GRANTED';
|
|
1324
|
+
} catch (error) {
|
|
1325
|
+
toast(`MU/TH/UR › ${error.message}`);
|
|
1326
|
+
for (const other of actions.querySelectorAll('button')) other.disabled = false;
|
|
1327
|
+
}
|
|
1328
|
+
};
|
|
1329
|
+
const once = el('button', 'grant', 'GRANT ONCE'); once.type = 'button'; once.title = `@${agent} creates files for this step only, inside its own .pulse/out/ directory.`;
|
|
1330
|
+
const plan = el('button', 'grant plan', 'GRANT FOR PLAN'); plan.type = 'button'; plan.title = 'Every remaining writable step of this plan shares one lease directory.';
|
|
1331
|
+
const deny = el('button', 'deny', 'DENY'); deny.type = 'button'; deny.title = `@${agent} answers read-only and says what it would have created.`;
|
|
1332
|
+
once.addEventListener('click', () => decide('once', once));
|
|
1333
|
+
plan.addEventListener('click', () => decide('plan', plan));
|
|
1334
|
+
deny.addEventListener('click', () => decide('deny', deny));
|
|
1335
|
+
const clock = el('span', 'clock');
|
|
1336
|
+
actions.append(once, plan, deny, clock);
|
|
1337
|
+
node.append(actions);
|
|
1338
|
+
node.title = message;
|
|
1339
|
+
// The clock counts down to the automatic denial; it stops when the room answers.
|
|
1340
|
+
const deadline = new Date(expiresAt).getTime();
|
|
1341
|
+
const tick = () => {
|
|
1342
|
+
const left = Math.max(0, Math.round((deadline - Date.now()) / 1000));
|
|
1343
|
+
clock.textContent = `${String(Math.floor(left / 60)).padStart(1, '0')}:${String(left % 60).padStart(2, '0')}`;
|
|
1344
|
+
clock.classList.toggle('late', left <= 30);
|
|
1345
|
+
if (left <= 0) { const entry = modeRequests.get(requestId); if (entry) clearInterval(entry.timer); }
|
|
1346
|
+
};
|
|
1347
|
+
tick();
|
|
1348
|
+
const timer = setInterval(tick, 1000);
|
|
1349
|
+
timer.unref?.();
|
|
1350
|
+
modeRequests.set(requestId, { node, timer, actions });
|
|
1351
|
+
if (!replaying) { toast(`MU/TH/UR › @${agent} asks #${mode} ${MODES[mode].label} for step ${step}. The plan waits for you.`); if (typeof armBrake === 'function') { /* no brake: waiting is safe */ } }
|
|
1352
|
+
state.lastSender = null;
|
|
1353
|
+
return node;
|
|
1354
|
+
}
|
|
1355
|
+
function settleModeRequest(event) {
|
|
1356
|
+
const { requestId, scope, reason, message } = event.payload;
|
|
1357
|
+
const entry = modeRequests.get(requestId);
|
|
1358
|
+
if (!entry) return;
|
|
1359
|
+
clearInterval(entry.timer);
|
|
1360
|
+
const granted = event.type === 'mode.granted';
|
|
1361
|
+
entry.node.classList.add(granted ? 'granted' : 'denied');
|
|
1362
|
+
entry.actions.replaceChildren(el('span', 'outcome', granted ? (scope === 'plan' ? 'GRANTED FOR THE PLAN' : 'GRANTED ONCE') : reason === 'timeout' ? 'DENIED · NO ANSWER IN TIME' : reason === 'stopped' ? 'PLAN STOPPED' : 'DENIED'));
|
|
1363
|
+
entry.node.title = message ?? '';
|
|
1364
|
+
modeRequests.delete(requestId);
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
function renderLease(event) {
|
|
1368
|
+
const { agent, outDir, scopes = [], unavailable = [], standing = false, escalated = null } = event.payload;
|
|
1369
|
+
const node = el('div', 'system lease');
|
|
1370
|
+
node.style.setProperty('--agent', agentColor(agent));
|
|
1371
|
+
node.append(el('b', null, standing ? 'standing lease · ' : escalated ? `lease granted on request${escalated === 'plan' ? ' · whole plan' : ''} · ` : 'creation lease · '));
|
|
1372
|
+
node.append(`@${agent} may ${scopes.map((scope) => CAP_LABELS[scope] ?? scope).join(', ') || 'create files'}${unavailable.length ? ` (cannot ${unavailable.join(', ')})` : ''} in `);
|
|
1373
|
+
const link = el('a', 'file-link', outDir);
|
|
1374
|
+
link.href = '#';
|
|
1375
|
+
link.addEventListener('click', (ev) => { ev.preventDefault(); });
|
|
1376
|
+
node.append(link);
|
|
1377
|
+
state.lastSender = null;
|
|
1378
|
+
return node;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
function attachArtifacts(event) {
|
|
1382
|
+
const { responseMessageId, files = [] } = event.payload;
|
|
1383
|
+
const bubble = document.getElementById(`msg-${responseMessageId}`)?.querySelector('.bubble');
|
|
1384
|
+
if (!bubble || bubble.querySelector('.artifacts')) return;
|
|
1385
|
+
bubble.append(artifactTiles(files));
|
|
1386
|
+
// Something new exists in the project: show it, so the human sees what
|
|
1387
|
+
// changed and can quote lines back to an agent. Live events only.
|
|
1388
|
+
if (!replaying && files.length && !viewer.dialog.open) {
|
|
1389
|
+
const first = files.find((file) => (file.contentType ?? '').startsWith('image/') || /^(text\/|application\/(json|x-ndjson))/.test(file.contentType ?? '')) ?? null;
|
|
1390
|
+
if (first) void openViewer({ root: 'project', path: first.path, label: `/${first.path} · ${first.status}` });
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
function artifactTiles(files) {
|
|
1395
|
+
const wrap = el('div', 'artifacts');
|
|
1396
|
+
wrap.append(el('span', 'label', `created · ${files.length} file${files.length === 1 ? '' : 's'}`));
|
|
1397
|
+
const tiles = el('div', 'files');
|
|
1398
|
+
for (const file of files) {
|
|
1399
|
+
const url = `/api/files?path=${encodeURIComponent(file.path)}`;
|
|
1400
|
+
const image = (file.contentType ?? '').startsWith('image/');
|
|
1401
|
+
const tile = el('button', `file-tile${image ? ' image' : ''}`);
|
|
1402
|
+
tile.type = 'button';
|
|
1403
|
+
tile.title = `${file.path} · ${file.size} bytes · ${file.status}`;
|
|
1404
|
+
if (image) { const img = el('img'); img.src = url; img.alt = file.name; img.loading = 'lazy'; tile.append(img); }
|
|
1405
|
+
else { tile.append(el('span', 'kind', (file.name.split('.').pop() ?? 'file').slice(0, 4).toUpperCase())); tile.append(el('span', 'name', file.name)); }
|
|
1406
|
+
tile.addEventListener('click', () => openViewer({ root: 'project', path: file.path, label: `/${file.path}` }));
|
|
1407
|
+
tiles.append(tile);
|
|
1408
|
+
}
|
|
1409
|
+
wrap.append(tiles);
|
|
1410
|
+
return wrap;
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
function renderAlert(event) {
|
|
1414
|
+
const { message } = event.payload;
|
|
1415
|
+
if (!replaying) { state.brakeArmed = true; updateStopAll(); }
|
|
1416
|
+
const node = el('div', 'system alert');
|
|
1417
|
+
node.append(el('b', null, 'MU/TH/UR › '));
|
|
1418
|
+
node.append(message.replace(/\s*Type STOPALL[^.]*\.?$/i, '').replace(/\s*STOPALL halts[^.]*\.?$/i, ''));
|
|
1419
|
+
node.append(el('span', 'cmd', 'STOPALL'));
|
|
1420
|
+
// A replayed alert is history: it stays in the thread, it does not shout again or arm the brake.
|
|
1421
|
+
if (!replaying) toast(`MU/TH/UR › ${message}`);
|
|
1422
|
+
state.lastSender = null;
|
|
1423
|
+
return node;
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
function renderHalted(event) {
|
|
1427
|
+
const { reason, plans, turns, agents = [] } = event.payload;
|
|
1428
|
+
const node = el('div', 'system halted');
|
|
1429
|
+
state.brakeArmed = false;
|
|
1430
|
+
node.append(el('b', null, 'MU/TH/UR › '));
|
|
1431
|
+
node.append(plans || turns
|
|
1432
|
+
? `all stop · ${plans} plan${plans === 1 ? '' : 's'}, ${turns} turn${turns === 1 ? '' : 's'} halted${agents.length ? ` (${agents.map((id) => `@${id}`).join(', ')})` : ''} · ${reason}`
|
|
1433
|
+
: `all stop · nothing was running · ${reason}`);
|
|
1434
|
+
state.plansRunning.clear();
|
|
1435
|
+
updateStopAll();
|
|
1436
|
+
state.lastSender = null;
|
|
1437
|
+
return node;
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
async function stopAll() {
|
|
1441
|
+
const button = document.querySelector('#stop-all');
|
|
1442
|
+
if (button) button.disabled = true;
|
|
1443
|
+
try {
|
|
1444
|
+
const response = await fetch('/api/stop-all', { method: 'POST' });
|
|
1445
|
+
if (!response.ok) { toast('STOPALL failed to reach the room.'); return; }
|
|
1446
|
+
const result = await response.json().catch(() => ({}));
|
|
1447
|
+
if (!result.plans && !result.turns) toast('MU/TH/UR › all quiet. nothing was running.');
|
|
1448
|
+
else toast(`MU/TH/UR › all stop. ${result.plans} plan${result.plans === 1 ? '' : 's'}, ${result.turns} turn${result.turns === 1 ? '' : 's'} halted.`);
|
|
1449
|
+
} finally {
|
|
1450
|
+
if (button) button.disabled = false;
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
function updateStopAll() {
|
|
1455
|
+
const button = document.querySelector('#stop-all');
|
|
1456
|
+
if (!button) return;
|
|
1457
|
+
const idle = state.running.size === 0 && state.plansRunning.size === 0;
|
|
1458
|
+
if (idle) state.brakeArmed = false;
|
|
1459
|
+
button.hidden = false;
|
|
1460
|
+
button.disabled = !state.brakeArmed;
|
|
1461
|
+
button.classList.toggle('armed', state.brakeArmed);
|
|
1462
|
+
button.title = state.brakeArmed
|
|
1463
|
+
? 'MU/TH/UR detected a runaway sequence. STOP ALL halts every plan and every agent turn.'
|
|
1464
|
+
: idle ? 'All quiet. STOP ALL arms itself when MU/TH/UR detects a runaway sequence; typing STOPALL always works.'
|
|
1465
|
+
: 'Agents are working normally. STOP ALL arms itself on a MU/TH/UR alert; typing STOPALL always works.';
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
function renderPlanEvent(event) {
|
|
1469
|
+
const { planId, orchestrator, steps = [], closing, stepsRun, reason } = event.payload;
|
|
1470
|
+
if (event.type === 'plan.created') state.plansRunning.add(planId); else state.plansRunning.delete(planId);
|
|
1471
|
+
updateStopAll();
|
|
1472
|
+
const node = el('div', `system plan${event.type === 'plan.stopped' ? ' failed' : ''}`);
|
|
1473
|
+
node.style.setProperty('--agent', agentColor(orchestrator));
|
|
1474
|
+
node.append(el('b', null, 'plan · '));
|
|
1475
|
+
if (event.type === 'plan.created') {
|
|
1476
|
+
node.id = `plan-${planId}`;
|
|
1477
|
+
node.append(`@${orchestrator} puts `);
|
|
1478
|
+
steps.forEach((step, index) => {
|
|
1479
|
+
if (index) node.append(', ');
|
|
1480
|
+
node.append(`@${step.agent}`);
|
|
1481
|
+
if (Number.isInteger(step.mode)) node.append(el('span', `step-mode m${step.mode}`, `#${step.mode}`));
|
|
1482
|
+
});
|
|
1483
|
+
node.append(` to work${closing ? ', then closes' : ''}`);
|
|
1484
|
+
const stop = el('button', 'stop', 'STOP');
|
|
1485
|
+
stop.type = 'button';
|
|
1486
|
+
stop.title = 'Stop the remaining steps of this plan';
|
|
1487
|
+
stop.addEventListener('click', async () => {
|
|
1488
|
+
stop.disabled = true;
|
|
1489
|
+
const response = await fetch(`/api/plans/${planId}/stop`, { method: 'POST' });
|
|
1490
|
+
if (!response.ok) toast('That plan is no longer running.');
|
|
1491
|
+
});
|
|
1492
|
+
node.append(stop);
|
|
1493
|
+
} else {
|
|
1494
|
+
document.getElementById(`plan-${planId}`)?.querySelector('.stop')?.remove();
|
|
1495
|
+
node.append(event.type === 'plan.completed'
|
|
1496
|
+
? `@${orchestrator} finished · ${stepsRun} step${stepsRun === 1 ? '' : 's'}`
|
|
1497
|
+
: `@${orchestrator} stopped after ${stepsRun} step${stepsRun === 1 ? '' : 's'} · ${reason ?? ''}`);
|
|
1498
|
+
}
|
|
1499
|
+
state.lastSender = null;
|
|
1500
|
+
return node;
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
function renderFailure(event) {
|
|
1504
|
+
const { messageId, target, error, recovered } = event.payload;
|
|
1505
|
+
recordFailure({ time: event.timestamp, agent: target, error, recovered });
|
|
1506
|
+
removeThinking(messageId);
|
|
1507
|
+
const node = el('div', `system fail${recovered ? ' recovered' : ''}`);
|
|
1508
|
+
node.append(el('span', 'label', recovered ? `${label(target)} · turn recovered after restart` : `${label(target)} could not answer`));
|
|
1509
|
+
node.append(error);
|
|
1510
|
+
state.lastSender = null;
|
|
1511
|
+
return node;
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
function applyUsage(event) {
|
|
1515
|
+
const { agent, usage, roomTotalTokens, responseMessageId } = event.payload;
|
|
1516
|
+
const entry = state.agents.get(agent);
|
|
1517
|
+
if (entry) {
|
|
1518
|
+
entry.tokens = event.payload.roomBudgetTokens ?? roomTotalTokens;
|
|
1519
|
+
entry.rawTokens = roomTotalTokens;
|
|
1520
|
+
renderAgents();
|
|
1521
|
+
}
|
|
1522
|
+
const stamp = responseMessageId ? document.getElementById(`usage-${responseMessageId}`) : null;
|
|
1523
|
+
if (stamp && usage) {
|
|
1524
|
+
stamp.append(el('span', null, `${formatTokens(usage.totalTokens)} tok`));
|
|
1525
|
+
if (Number.isFinite(usage.costUsd) && usage.costUsd > 0) stamp.append(el('span', null, `$${usage.costUsd.toFixed(2)}`));
|
|
1526
|
+
stamp.append(el('span', null, `Σ ${formatTokens(roomTotalTokens)}`));
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
function applyQuota(event) {
|
|
1531
|
+
const { agent, usedPercent, resetAt = null, windows = null, stale = false, observedAt = null, source } = event.payload;
|
|
1532
|
+
const entry = state.agents.get(agent);
|
|
1533
|
+
if (!entry) return;
|
|
1534
|
+
entry.officialPercent = usedPercent;
|
|
1535
|
+
entry.officialResetAt = resetAt;
|
|
1536
|
+
entry.officialWindows = windows;
|
|
1537
|
+
entry.officialStale = stale;
|
|
1538
|
+
entry.officialObservedAt = observedAt;
|
|
1539
|
+
entry.officialSource = source;
|
|
1540
|
+
renderAgents();
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
function renderCleared(event) {
|
|
1544
|
+
const { agent, usedPercent, source, message } = event.payload;
|
|
1545
|
+
const node = el('div', 'system recovered');
|
|
1546
|
+
node.append(el('b', null, 'clear · '), el('b', null, `@${agent} `), `${Math.round(usedPercent)}% of ${source === 'room-soft-budget' ? 'local window' : 'provider limit'} · window reset`);
|
|
1547
|
+
node.title = message;
|
|
1548
|
+
const entry = state.agents.get(agent);
|
|
1549
|
+
if (entry && source !== 'room-soft-budget') { entry.officialPercent = usedPercent; entry.officialResetAt = event.payload.resetAt ?? null; }
|
|
1550
|
+
renderAgents();
|
|
1551
|
+
if (!replaying) toast(`MU/TH/UR › @${agent} limit window reset · ${Math.round(usedPercent)}% used now.`);
|
|
1552
|
+
state.lastSender = null;
|
|
1553
|
+
return node;
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
function renderEvent(event) {
|
|
1557
|
+
if (state.seen.has(event.id)) return;
|
|
1558
|
+
state.seen.add(event.id);
|
|
1559
|
+
state.lastSequence = Math.max(state.lastSequence, event.sequence ?? 0);
|
|
1560
|
+
if (event.ghost) state.seen.add(event.id);
|
|
1561
|
+
try {
|
|
1562
|
+
renderEventNode(event);
|
|
1563
|
+
} catch (error) {
|
|
1564
|
+
// One malformed or unexpected event must never take the whole room down.
|
|
1565
|
+
console.error(`MADRE could not render event ${event.sequence} (${event.type}):`, error);
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
function renderEventNode(event) {
|
|
1570
|
+
trackStats(event);
|
|
1571
|
+
let node = null;
|
|
1572
|
+
switch (event.type) {
|
|
1573
|
+
case 'connection.login.started':
|
|
1574
|
+
case 'connection.login.finished':
|
|
1575
|
+
node = renderLoginEvent(event);
|
|
1576
|
+
break;
|
|
1577
|
+
case 'connection.login.output': appendLoginOutput(event); return;
|
|
1578
|
+
case 'room.settings': return;
|
|
1579
|
+
case 'extension.toggled':
|
|
1580
|
+
if (event.payload.id === 'ashcode') syncAshCodeUI(Boolean(event.payload.enabled));
|
|
1581
|
+
if (event.payload.id === 'ripley') { state.ripley = Boolean(event.payload.enabled); syncViewerMode(); }
|
|
1582
|
+
if (!replaying) void refreshModules();
|
|
1583
|
+
return;
|
|
1584
|
+
case 'message.created':
|
|
1585
|
+
node = event.payload.role === 'user' ? renderUserMessage(event) : renderAssistantMessage(event);
|
|
1586
|
+
if (event.payload.role !== 'user') removeThinking(event.payload.parentMessageId);
|
|
1587
|
+
break;
|
|
1588
|
+
case 'agent.started': state.running.set(event.payload.messageId, event.payload.agent); updateStopAll(); node = renderThinking(event); break;
|
|
1589
|
+
case 'agent.completed': state.running.delete(event.payload.messageId); updateStopAll(); removeThinking(event.payload.messageId); return;
|
|
1590
|
+
case 'message.failed': state.running.delete(event.payload.messageId); updateStopAll(); node = renderFailure(event); break;
|
|
1591
|
+
case 'handoff.created': node = renderHandoff(event); break;
|
|
1592
|
+
case 'memory.distilled': node = renderDistilled(event); break;
|
|
1593
|
+
case 'memory.forgotten': node = renderForgotten(event); break;
|
|
1594
|
+
case 'memory.noted': attachMemoryHint(event); return;
|
|
1595
|
+
case 'mother.alert': node = renderMotherAlert(event); break;
|
|
1596
|
+
case 'limit.warning': node = renderWarning(event); break;
|
|
1597
|
+
case 'limit.cleared': node = renderCleared(event); break;
|
|
1598
|
+
case 'usage.recorded': applyUsage(event); return;
|
|
1599
|
+
case 'quota.updated': applyQuota(event); return;
|
|
1600
|
+
case 'extension.install.started':
|
|
1601
|
+
case 'extension.install.finished':
|
|
1602
|
+
case 'extension.install.refused':
|
|
1603
|
+
node = renderModuleEvent(event);
|
|
1604
|
+
break;
|
|
1605
|
+
case 'extension.install.output': appendModuleOutput(event); return;
|
|
1606
|
+
case 'plan.created':
|
|
1607
|
+
case 'plan.completed':
|
|
1608
|
+
case 'plan.stopped':
|
|
1609
|
+
node = renderPlanEvent(event);
|
|
1610
|
+
break;
|
|
1611
|
+
case 'room.alert': node = renderAlert(event); break;
|
|
1612
|
+
case 'room.stopped': node = renderHalted(event); break;
|
|
1613
|
+
case 'lease.granted': node = renderLease(event); break;
|
|
1614
|
+
case 'lease.refused': node = renderLeaseRefused(event); break;
|
|
1615
|
+
case 'lease.missing': node = renderLeaseMissing(event); break;
|
|
1616
|
+
case 'mode.requested': node = renderModeRequest(event); break;
|
|
1617
|
+
case 'control.started': node = renderControlStarted(event); break;
|
|
1618
|
+
case 'control.changed': node = renderControlChanged(event); break;
|
|
1619
|
+
case 'control.reverted': node = renderControlReverted(event); break;
|
|
1620
|
+
case 'mode.granted':
|
|
1621
|
+
case 'mode.denied': settleModeRequest(event); return;
|
|
1622
|
+
case 'plan.ignored': node = renderPlanIgnored(event); break;
|
|
1623
|
+
case 'artifacts.created': attachArtifacts(event); return;
|
|
1624
|
+
case 'command.output': node = renderCommandCard(event); break;
|
|
1625
|
+
default: return;
|
|
1626
|
+
}
|
|
1627
|
+
removeEmpty();
|
|
1628
|
+
const stickToBottom = els.thread.scrollHeight - els.thread.scrollTop - els.thread.clientHeight < 120;
|
|
1629
|
+
els.column.append(node);
|
|
1630
|
+
followTurn(event, node, stickToBottom);
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
// Modern chats carry the reader along: a new human message or a working
|
|
1634
|
+
// indicator scrolls to the end; an answer scrolls to its start so a long
|
|
1635
|
+
// reply is read from the top. Live events only, never during the replay.
|
|
1636
|
+
let replaying = true;
|
|
1637
|
+
function followTurn(event, node, stickToBottom) {
|
|
1638
|
+
if (replaying) { if (stickToBottom) scrollToEnd(); return; }
|
|
1639
|
+
const isTurn = event.type === 'agent.started' || (event.type === 'message.created' && event.payload.role === 'user');
|
|
1640
|
+
const isAnswer = event.type === 'message.created' && event.payload.role !== 'user';
|
|
1641
|
+
if (isAnswer) {
|
|
1642
|
+
const tall = node.getBoundingClientRect?.().height > els.thread.clientHeight * 0.7;
|
|
1643
|
+
if (tall) node.scrollIntoView?.({ behavior: 'smooth', block: 'start' });
|
|
1644
|
+
else els.thread.scrollTo?.({ top: els.thread.scrollHeight, behavior: 'smooth' }) ?? scrollToEnd();
|
|
1645
|
+
return;
|
|
1646
|
+
}
|
|
1647
|
+
if (isTurn) { els.thread.scrollTo?.({ top: els.thread.scrollHeight, behavior: 'smooth' }) ?? scrollToEnd(); return; }
|
|
1648
|
+
if (stickToBottom) scrollToEnd();
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
/* ---------- theme: auto (system) → light → dark ---------- */
|
|
1652
|
+
|
|
1653
|
+
const themeButton = document.querySelector('#theme-button');
|
|
1654
|
+
function applyTheme(mode) {
|
|
1655
|
+
const rootElement = document.documentElement ?? { dataset: {} };
|
|
1656
|
+
if (mode === 'auto') delete rootElement.dataset.theme;
|
|
1657
|
+
else rootElement.dataset.theme = mode;
|
|
1658
|
+
if (themeButton) {
|
|
1659
|
+
themeButton.dataset.theme = mode;
|
|
1660
|
+
themeButton.title = mode === 'auto' ? 'Theme · auto (follows the system)' : mode === 'light' ? 'Theme · light' : 'Theme · dark';
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
let themeMode = 'auto';
|
|
1664
|
+
try { themeMode = localStorage.getItem('pulse.theme') ?? 'auto'; } catch { /* no storage */ }
|
|
1665
|
+
applyTheme(['auto', 'light', 'dark'].includes(themeMode) ? themeMode : 'auto');
|
|
1666
|
+
themeButton?.addEventListener('click', () => {
|
|
1667
|
+
themeMode = themeMode === 'auto' ? 'light' : themeMode === 'light' ? 'dark' : 'auto';
|
|
1668
|
+
try { localStorage.setItem('pulse.theme', themeMode); } catch { /* no storage */ }
|
|
1669
|
+
applyTheme(themeMode);
|
|
1670
|
+
toast(`Theme · ${themeMode === 'auto' ? 'auto, following the system' : themeMode}`);
|
|
1671
|
+
});
|
|
1672
|
+
|
|
1673
|
+
/* ---------- project files panel ---------- */
|
|
1674
|
+
|
|
1675
|
+
const tree = { open: false, loaded: new Map() };
|
|
1676
|
+
const formatSize = (bytes) => bytes == null ? '' : bytes < 1024 ? `${bytes} B` : bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(bytes < 10240 ? 1 : 0)} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
1677
|
+
const CODE_EXT = /\.(m?[jt]sx?|py|rb|go|rs|java|kt|swift|c|h|cpp|hpp|cs|php|sh|zsh|css|scss|html?|json|ya?ml|toml|sql|md)$/i;
|
|
1678
|
+
|
|
1679
|
+
async function loadTreeLevel(path, list) {
|
|
1680
|
+
list.replaceChildren(el('li', 'empty-dir', 'loading…'));
|
|
1681
|
+
try {
|
|
1682
|
+
const response = await fetch(`/api/tree?path=${encodeURIComponent(path)}`);
|
|
1683
|
+
const data = await response.json();
|
|
1684
|
+
if (!response.ok) throw new Error(data.error ?? `HTTP ${response.status}`);
|
|
1685
|
+
list.replaceChildren();
|
|
1686
|
+
if (!data.entries.length) list.append(el('li', 'empty-dir', 'empty'));
|
|
1687
|
+
for (const entry of data.entries) list.append(treeNode(path === '.' ? entry.name : `${path}/${entry.name}`, entry));
|
|
1688
|
+
if (data.truncated) list.append(el('li', 'empty-dir', 'more entries not shown'));
|
|
1689
|
+
} catch (error) {
|
|
1690
|
+
list.replaceChildren(el('li', 'error', `could not list: ${error.message}`));
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
function treeNode(path, entry) {
|
|
1695
|
+
const item = el('li');
|
|
1696
|
+
item.setAttribute('role', 'treeitem');
|
|
1697
|
+
const button = el('button', `node ${entry.kind}${entry.shallow ? ' shallow' : ''}${entry.kind === 'file' && /^image\//.test(entry.contentType ?? '') ? ' image' : ''}${entry.kind === 'file' && CODE_EXT.test(entry.name) ? ' code' : ''}`);
|
|
1698
|
+
button.type = 'button';
|
|
1699
|
+
button.title = path;
|
|
1700
|
+
if (entry.kind === 'dir') {
|
|
1701
|
+
const caret = el('span', 'caret', '▸');
|
|
1702
|
+
button.append(caret, el('span', 'name', entry.name));
|
|
1703
|
+
const children = el('ul');
|
|
1704
|
+
children.hidden = true;
|
|
1705
|
+
let opened = false;
|
|
1706
|
+
button.addEventListener('click', () => {
|
|
1707
|
+
opened = !opened;
|
|
1708
|
+
caret.textContent = opened ? '▾' : '▸';
|
|
1709
|
+
children.hidden = !opened;
|
|
1710
|
+
item.setAttribute('aria-expanded', String(opened));
|
|
1711
|
+
if (opened && !children.childElementCount) void loadTreeLevel(path, children);
|
|
1712
|
+
});
|
|
1713
|
+
item.append(button, children);
|
|
1714
|
+
} else {
|
|
1715
|
+
button.append(el('span', 'caret', ''), el('span', 'name', entry.name), el('span', 'size', formatSize(entry.size)));
|
|
1716
|
+
button.addEventListener('click', () => { void openViewer({ root: 'project', path, label: path }); });
|
|
1717
|
+
item.append(button);
|
|
1718
|
+
}
|
|
1719
|
+
return item;
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
function setTree(open) {
|
|
1723
|
+
tree.open = open;
|
|
1724
|
+
els.tree.hidden = !open;
|
|
1725
|
+
els.treeButton.setAttribute('aria-pressed', String(open));
|
|
1726
|
+
try { localStorage.setItem('pulse.tree', open ? 'open' : 'closed'); } catch { /* no storage */ }
|
|
1727
|
+
if (open && !els.treeBody.childElementCount) {
|
|
1728
|
+
els.treeRoot.textContent = `/ ${(state.projectRoot ?? '').split('/').filter(Boolean).pop() ?? ''}`;
|
|
1729
|
+
const root = el('ul');
|
|
1730
|
+
els.treeBody.append(root);
|
|
1731
|
+
void loadTreeLevel('.', root);
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
els.treeButton?.addEventListener('click', () => setTree(!tree.open));
|
|
1735
|
+
els.treeClose?.addEventListener('click', () => setTree(false));
|
|
1736
|
+
try { if (localStorage.getItem('pulse.tree') === 'open') setTree(true); } catch { /* no storage */ }
|
|
1737
|
+
|
|
1738
|
+
/* ---------- easter egg: hold the scroll at the end of the record ---------- */
|
|
1739
|
+
|
|
1740
|
+
// A trackpad only reports while the fingers move, so "holding the scroll
|
|
1741
|
+
// down" arrives as bursts. We count distinct downward pushes at the end of
|
|
1742
|
+
// the record: at least three of them, spanning six seconds, with no more
|
|
1743
|
+
// than two seconds of silence between pushes and no push upward.
|
|
1744
|
+
const HOLD_MS = 4000;
|
|
1745
|
+
const HOLD_GAP_MS = 2000;
|
|
1746
|
+
const HOLD_MIN_PUSHES = 3;
|
|
1747
|
+
const BURST_MS = 150;
|
|
1748
|
+
let pushes = [];
|
|
1749
|
+
|
|
1750
|
+
function atBottom() {
|
|
1751
|
+
return els.thread.scrollHeight - els.thread.scrollTop - els.thread.clientHeight < 4;
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
function armExpendable() {
|
|
1755
|
+
if (state.expendable) return;
|
|
1756
|
+
state.expendable = true;
|
|
1757
|
+
pushes = [];
|
|
1758
|
+
els.composer.classList.add('expendable');
|
|
1759
|
+
updateCrewLabel();
|
|
1760
|
+
updatePlaceholder();
|
|
1761
|
+
const line = el('div', 'system mother');
|
|
1762
|
+
line.append(el('b', null, 'MU/TH/UR › '));
|
|
1763
|
+
line.append('end of record. nothing else is down here, human. crew status under review.');
|
|
1764
|
+
removeEmpty();
|
|
1765
|
+
els.column.append(line);
|
|
1766
|
+
scrollToEnd();
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
function disarmExpendable() {
|
|
1770
|
+
state.expendable = false;
|
|
1771
|
+
els.composer.classList.remove('expendable');
|
|
1772
|
+
updateCrewLabel();
|
|
1773
|
+
updatePlaceholder();
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
function trackHold(downward, now = Date.now()) {
|
|
1777
|
+
if (state.expendable) return false;
|
|
1778
|
+
if (!downward || !atBottom()) { pushes = []; return false; }
|
|
1779
|
+
const last = pushes.at(-1);
|
|
1780
|
+
if (last !== undefined && now - last < BURST_MS) return false; // same burst
|
|
1781
|
+
if (last !== undefined && now - last > HOLD_GAP_MS) pushes = []; // gave up, start over
|
|
1782
|
+
pushes.push(now);
|
|
1783
|
+
const span = pushes.at(-1) - pushes[0];
|
|
1784
|
+
if (pushes.length >= HOLD_MIN_PUSHES && span >= HOLD_MS) {
|
|
1785
|
+
armExpendable();
|
|
1786
|
+
return true;
|
|
1787
|
+
}
|
|
1788
|
+
return false;
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
// Second way in: rest the cursor on MU/TH/UR for three seconds.
|
|
1792
|
+
const HOVER_MS = 3000;
|
|
1793
|
+
let hoverTimer = null;
|
|
1794
|
+
function beginHover() {
|
|
1795
|
+
if (state.expendable) return;
|
|
1796
|
+
const button = document.querySelector('#mother-button');
|
|
1797
|
+
button?.classList.add('arming');
|
|
1798
|
+
clearTimeout(hoverTimer);
|
|
1799
|
+
hoverTimer = setTimeout(() => { button?.classList.remove('arming'); armExpendable(); }, HOVER_MS);
|
|
1800
|
+
}
|
|
1801
|
+
function endHover() {
|
|
1802
|
+
clearTimeout(hoverTimer);
|
|
1803
|
+
hoverTimer = null;
|
|
1804
|
+
document.querySelector('#mother-button')?.classList.remove('arming');
|
|
1805
|
+
}
|
|
1806
|
+
document.querySelector('#mother-button')?.addEventListener('mouseenter', beginHover);
|
|
1807
|
+
document.querySelector('#mother-button')?.addEventListener('mouseleave', endHover);
|
|
1808
|
+
document.querySelector('#mother-button')?.addEventListener('click', endHover);
|
|
1809
|
+
|
|
1810
|
+
// Debug surface for tests and for the curious: window.__pulse.trackHold(true, t)
|
|
1811
|
+
globalThis.__pulse = { trackHold, armExpendable, disarmExpendable, beginHover, endHover, state };
|
|
1812
|
+
|
|
1813
|
+
els.thread.addEventListener('wheel', (event) => trackHold(event.deltaY > 0), { passive: true });
|
|
1814
|
+
let touchY = null;
|
|
1815
|
+
els.thread.addEventListener('touchstart', (event) => { touchY = event.touches[0]?.clientY ?? null; }, { passive: true });
|
|
1816
|
+
els.thread.addEventListener('touchmove', (event) => {
|
|
1817
|
+
const y = event.touches[0]?.clientY ?? null;
|
|
1818
|
+
if (touchY !== null && y !== null) trackHold(y < touchY);
|
|
1819
|
+
touchY = y;
|
|
1820
|
+
}, { passive: true });
|
|
1821
|
+
els.thread.addEventListener('keydown', (event) => trackHold(event.key === 'ArrowDown' || event.key === 'End' || event.key === 'PageDown'));
|
|
1822
|
+
|
|
1823
|
+
/* ---------- bootstrap ---------- */
|
|
1824
|
+
|
|
1825
|
+
const initial = await fetch('/api/state').then((response) => response.json());
|
|
1826
|
+
els.project.textContent = initial.projectRoot.split('/').filter(Boolean).at(-1) || initial.projectRoot;
|
|
1827
|
+
els.project.title = initial.projectRoot;
|
|
1828
|
+
if (els.treeRoot) els.treeRoot.textContent = `/ ${initial.projectRoot.split('/').filter(Boolean).at(-1) ?? ''}`;
|
|
1829
|
+
state.budget = Number.isFinite(initial.softTokenBudget) && initial.softTokenBudget > 0 ? initial.softTokenBudget : null;
|
|
1830
|
+
state.timeouts = initial.timeouts ?? {};
|
|
1831
|
+
state.sessions = initial.sessions ?? {};
|
|
1832
|
+
state.capabilities = initial.capabilities ?? {};
|
|
1833
|
+
syncAshCodeUI(Boolean(initial.ashCode?.enabled));
|
|
1834
|
+
state.ripley = Boolean(initial.ripley?.enabled);
|
|
1835
|
+
state.projectRoot = initial.projectRoot ?? '';
|
|
1836
|
+
for (const plan of initial.plans ?? []) state.plansRunning.add(plan.planId);
|
|
1837
|
+
updateStopAll();
|
|
1838
|
+
for (const agent of initial.agents) {
|
|
1839
|
+
const window = initial.budgetWindow?.[agent.id];
|
|
1840
|
+
state.agents.set(agent.id, { ...agent, tokens: window?.tokens ?? 0, rawTokens: window?.rawTokens ?? 0, windowMs: window?.windowMs ?? null, rollsOverAt: window?.rollsOverAt ?? null, officialPercent: null, officialResetAt: null, officialWindows: null });
|
|
1841
|
+
if (agent.ready) els.target.add(new Option(agent.label, agent.id));
|
|
1842
|
+
}
|
|
1843
|
+
// Windows roll over without anyone typing: re-read the room's view every minute.
|
|
1844
|
+
async function refreshBudgetWindow() {
|
|
1845
|
+
try {
|
|
1846
|
+
const data = await fetch('/api/state').then((response) => response.json());
|
|
1847
|
+
for (const [id, window] of Object.entries(data.budgetWindow ?? {})) {
|
|
1848
|
+
const entry = state.agents.get(id);
|
|
1849
|
+
if (entry) { entry.tokens = window.tokens; entry.rawTokens = window.rawTokens; entry.rollsOverAt = window.rollsOverAt; }
|
|
1850
|
+
}
|
|
1851
|
+
renderAgents();
|
|
1852
|
+
} catch { /* offline; the stream will tell */ }
|
|
1853
|
+
}
|
|
1854
|
+
if (typeof setInterval === 'function') { const ticker = setInterval(refreshBudgetWindow, 60000); ticker.unref?.(); }
|
|
1855
|
+
if (!els.target.options.length) els.target.add(new Option('No agent ready', ''));
|
|
1856
|
+
renderAgents();
|
|
1857
|
+
renderPicker();
|
|
1858
|
+
renderOnboarding();
|
|
1859
|
+
for (const event of initial.events) renderEvent(event);
|
|
1860
|
+
scrollToEnd();
|
|
1861
|
+
matchMedia('(prefers-color-scheme: light)').addEventListener('change', () => { renderAgents(); renderPicker(); });
|
|
1862
|
+
|
|
1863
|
+
/* ---------- live stream ---------- */
|
|
1864
|
+
|
|
1865
|
+
function setConnection(value) {
|
|
1866
|
+
els.connection.dataset.state = value;
|
|
1867
|
+
els.connection.querySelector('.connection-label').textContent = value;
|
|
1868
|
+
}
|
|
1869
|
+
if (typeof ResizeObserver === 'function' && els.composer) new ResizeObserver(placeToast).observe(els.composer);
|
|
1870
|
+
window.addEventListener?.('resize', placeToast);
|
|
1871
|
+
const stream = new EventSource(`/api/events?since=${state.lastSequence}`);
|
|
1872
|
+
stream.onopen = () => { setConnection('live'); replaying = false; };
|
|
1873
|
+
stream.onerror = () => setConnection('reconnecting');
|
|
1874
|
+
stream.onmessage = ({ data }) => renderEvent(JSON.parse(data));
|
|
1875
|
+
|
|
1876
|
+
/* ---------- composer ---------- */
|
|
1877
|
+
|
|
1878
|
+
// One line, always. Height follows the lines the text actually takes,
|
|
1879
|
+
// wrapped or explicit, up to three visible lines; longer text scrolls inside.
|
|
1880
|
+
function autosize() {
|
|
1881
|
+
els.input.style.height = `${LINE_PX}px`;
|
|
1882
|
+
const padding = 14; // 7px top + bottom, content-box
|
|
1883
|
+
const needed = Math.max(1, Math.ceil((els.input.scrollHeight - padding) / LINE_PX));
|
|
1884
|
+
const rows = Math.min(Number.isFinite(needed) ? needed : 1, MAX_ROWS);
|
|
1885
|
+
els.input.style.height = `${rows * LINE_PX}px`;
|
|
1886
|
+
els.highlight.style.height = els.input.style.height;
|
|
1887
|
+
renderHighlight();
|
|
1888
|
+
}
|
|
1889
|
+
function syncHighlightScroll() { els.highlight.scrollTop = els.input.scrollTop; }
|
|
1890
|
+
els.input.addEventListener('input', autosize);
|
|
1891
|
+
els.input.addEventListener('scroll', syncHighlightScroll);
|
|
1892
|
+
autosize();
|
|
1893
|
+
|
|
1894
|
+
/* ---------- labels inside the field: @agent mentions and /commands ---------- */
|
|
1895
|
+
|
|
1896
|
+
function allCommands() { return [...CLIENT_COMMANDS, ...(state.commands ?? [])]; }
|
|
1897
|
+
function knownAgentIds() { return [...state.agents.keys()]; }
|
|
1898
|
+
function escapeHtml(text) { return text.replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char])); }
|
|
1899
|
+
|
|
1900
|
+
function renderHighlight() {
|
|
1901
|
+
const text = els.input.value;
|
|
1902
|
+
const agents = knownAgentIds();
|
|
1903
|
+
const commands = allCommands();
|
|
1904
|
+
const html = escapeHtml(text).replace(/(^|[\s(,;:])([@/!#])([\w][\w./-]*(?::\d+(?:-\d+)?)?)/g, (whole, lead, sigil, name) => {
|
|
1905
|
+
const key = name.toLowerCase();
|
|
1906
|
+
if (sigil === '#') {
|
|
1907
|
+
if (!/^[0-3]$/.test(name)) return whole;
|
|
1908
|
+
return `${lead}<span class="chip mode m${name}">#${name} ${MODES[Number(name)].label}</span>`;
|
|
1909
|
+
}
|
|
1910
|
+
if (sigil === '!') {
|
|
1911
|
+
if (!/\.[A-Za-z0-9]{1,8}(?::\d+(?:-\d+)?)?$/.test(name)) return whole;
|
|
1912
|
+
return `${lead}<span class="chip file">!${escapeHtml(name)}</span>`;
|
|
1913
|
+
}
|
|
1914
|
+
if (sigil === '@') {
|
|
1915
|
+
if (!agents.includes(key)) return whole;
|
|
1916
|
+
return `${lead}<span class="chip agent" style="--agent:${brandOf(key).color}">@${escapeHtml(name)}</span>`;
|
|
1917
|
+
}
|
|
1918
|
+
const command = commands.find((item) => item.name === key);
|
|
1919
|
+
if (!command) return whole;
|
|
1920
|
+
return `${lead}<span class="chip cmd${command.available ? '' : ' unknown'}">/${escapeHtml(name)}</span>`;
|
|
1921
|
+
});
|
|
1922
|
+
els.highlight.innerHTML = `${html}${text.endsWith('\n') ? '\n' : ''}` || '';
|
|
1923
|
+
syncHighlightScroll();
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
async function refreshCommands() {
|
|
1927
|
+
try {
|
|
1928
|
+
const data = await fetch('/api/commands').then((response) => response.json());
|
|
1929
|
+
state.commands = (data.commands ?? []).map((item) => ({ ...item, client: false }));
|
|
1930
|
+
} catch {
|
|
1931
|
+
state.commands = [];
|
|
1932
|
+
}
|
|
1933
|
+
renderHighlight();
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
// The "/" and "@" menu: typing either at the start of a word lists what fits.
|
|
1937
|
+
const menu = { items: [], index: 0, kind: null, start: 0, end: 0 };
|
|
1938
|
+
function menuQuery() {
|
|
1939
|
+
const caret = els.input.selectionStart ?? els.input.value.length;
|
|
1940
|
+
const before = els.input.value.slice(0, caret);
|
|
1941
|
+
const match = before.match(/(^|[\s(,;:])([@/!#])([\w./-]*)$/);
|
|
1942
|
+
if (!match) return null;
|
|
1943
|
+
return { kind: match[2], query: match[3].toLowerCase(), start: caret - match[3].length - 1, end: caret };
|
|
1944
|
+
}
|
|
1945
|
+
function closeMenu() { els.slashMenu.hidden = true; menu.items = []; menu.kind = null; }
|
|
1946
|
+
let fileSearchTimer = null;
|
|
1947
|
+
let fileSearchSeq = 0;
|
|
1948
|
+
function fileMenu(found) {
|
|
1949
|
+
// Ask the server for matching project files, debounced; render when the answer is still current.
|
|
1950
|
+
clearTimeout(fileSearchTimer);
|
|
1951
|
+
const seq = ++fileSearchSeq;
|
|
1952
|
+
fileSearchTimer = setTimeout(async () => {
|
|
1953
|
+
try {
|
|
1954
|
+
const data = await fetch(`/api/tree/search?q=${encodeURIComponent(found.query)}`).then((response) => response.json());
|
|
1955
|
+
if (seq !== fileSearchSeq) return;
|
|
1956
|
+
const current = menuQuery();
|
|
1957
|
+
if (!current || current.kind !== '!' || current.query !== found.query) return;
|
|
1958
|
+
const items = (data.matches ?? []).map((file) => ({ key: `!${file.name}`, insert: `!${file.path} `, what: file.path, color: null }));
|
|
1959
|
+
if (!items.length) return closeMenu();
|
|
1960
|
+
showMenu(items, { ...found, hint: 'FILE · ↑↓ · TAB OR ENTER · add :12-20 for lines' });
|
|
1961
|
+
} catch { closeMenu(); }
|
|
1962
|
+
}, 120);
|
|
1963
|
+
}
|
|
1964
|
+
function renderMenu() {
|
|
1965
|
+
const found = menuQuery();
|
|
1966
|
+
if (!found) return closeMenu();
|
|
1967
|
+
if (found.kind === '!') return fileMenu(found);
|
|
1968
|
+
if (found.kind === '#') {
|
|
1969
|
+
const cap = ceilingFor(els.target.value);
|
|
1970
|
+
const items = [0, 1, 2, 3].filter((n) => String(n).startsWith(found.query)).map((n) => ({ key: `#${n} ${MODES[n].label}`, insert: `#${n} `, what: n > cap ? `${MODES[n].hint} · above @${els.target.value}'s max mode` : MODES[n].hint, off: n > cap }));
|
|
1971
|
+
if (!items.length) return closeMenu();
|
|
1972
|
+
return showMenu(items, { ...found, hint: 'MODE · ↑↓ · TAB OR ENTER' });
|
|
1973
|
+
}
|
|
1974
|
+
const items = found.kind === '@'
|
|
1975
|
+
? knownAgentIds().filter((id) => id.startsWith(found.query)).map((id) => ({ key: `@${id}`, insert: `@${id} `, what: state.agents.get(id)?.ready ? label(id) : `${label(id)} · not ready`, color: brandOf(id).color, off: !state.agents.get(id)?.ready }))
|
|
1976
|
+
: allCommands().filter((item) => item.name.startsWith(found.query)).map((item) => ({ key: item.usage ?? `/${item.name}`, insert: `/${item.name} `, what: item.available ? item.summary : `${item.title} is not available here · see MODULES`, off: !item.available }));
|
|
1977
|
+
if (!items.length) return closeMenu();
|
|
1978
|
+
showMenu(items, found);
|
|
1979
|
+
}
|
|
1980
|
+
function showMenu(items, found) {
|
|
1981
|
+
Object.assign(menu, { items, index: Math.min(menu.index, items.length - 1), kind: found.kind, start: found.start, end: found.end });
|
|
1982
|
+
els.slashMenu.replaceChildren();
|
|
1983
|
+
items.forEach((item, index) => {
|
|
1984
|
+
const button = el('button', `item${item.off ? ' off' : ''}`);
|
|
1985
|
+
button.type = 'button';
|
|
1986
|
+
button.setAttribute('role', 'option');
|
|
1987
|
+
button.setAttribute('aria-selected', String(index === menu.index));
|
|
1988
|
+
if (item.color) button.style.setProperty('--agent', item.color);
|
|
1989
|
+
button.append(el('span', 'key', item.key), el('span', 'what', item.what));
|
|
1990
|
+
button.addEventListener('mousedown', (event) => { event.preventDefault(); pickMenu(index); });
|
|
1991
|
+
els.slashMenu.append(button);
|
|
1992
|
+
});
|
|
1993
|
+
els.slashMenu.append(el('div', 'hint', found.hint ?? (found.kind === '@' ? 'MENTION · ↑↓ · TAB OR ENTER' : 'COMMAND · ↑↓ · TAB OR ENTER')));
|
|
1994
|
+
els.slashMenu.hidden = false;
|
|
1995
|
+
}
|
|
1996
|
+
function pickMenu(index = menu.index) {
|
|
1997
|
+
const item = menu.items[index];
|
|
1998
|
+
if (!item) return;
|
|
1999
|
+
const value = els.input.value;
|
|
2000
|
+
els.input.value = `${value.slice(0, menu.start)}${item.insert}${value.slice(menu.end)}`;
|
|
2001
|
+
const caret = menu.start + item.insert.length;
|
|
2002
|
+
els.input.setSelectionRange(caret, caret);
|
|
2003
|
+
closeMenu();
|
|
2004
|
+
autosize();
|
|
2005
|
+
els.input.focus();
|
|
2006
|
+
}
|
|
2007
|
+
els.input.addEventListener('input', () => { menu.index = 0; renderMenu(); });
|
|
2008
|
+
els.input.addEventListener('click', renderMenu);
|
|
2009
|
+
els.input.addEventListener('blur', () => setTimeout(closeMenu, 120));
|
|
2010
|
+
els.input.addEventListener('keydown', (event) => {
|
|
2011
|
+
if (els.slashMenu.hidden) return;
|
|
2012
|
+
if (event.key === 'ArrowDown') { event.preventDefault(); menu.index = (menu.index + 1) % menu.items.length; renderMenu(); }
|
|
2013
|
+
else if (event.key === 'ArrowUp') { event.preventDefault(); menu.index = (menu.index - 1 + menu.items.length) % menu.items.length; renderMenu(); }
|
|
2014
|
+
else if (event.key === 'Tab' || event.key === 'Enter') { event.preventDefault(); event.stopImmediatePropagation(); pickMenu(); }
|
|
2015
|
+
else if (event.key === 'Escape') { event.preventDefault(); closeMenu(); }
|
|
2016
|
+
}, true);
|
|
2017
|
+
|
|
2018
|
+
// A "/command" the human sends: client commands act here, module commands run
|
|
2019
|
+
// on the server and come back as a fact card in the thread.
|
|
2020
|
+
async function runSlashCommand(text) {
|
|
2021
|
+
const match = text.match(/^\/([a-z][\w-]*)(?:\s+([\s\S]*))?$/i);
|
|
2022
|
+
if (!match) return { handled: false };
|
|
2023
|
+
const name = match[1].toLowerCase();
|
|
2024
|
+
const rest = (match[2] ?? '').trim();
|
|
2025
|
+
if (name === 'stopall') { await stopAll(); return { handled: true }; }
|
|
2026
|
+
if (name === 'create' || name === 'image') {
|
|
2027
|
+
if (!rest) { toast(`MU/TH/UR › /${name} needs a request after it, e.g. "/${name} a poster for the launch".`); return { handled: true }; }
|
|
2028
|
+
let target = els.target.value;
|
|
2029
|
+
if (name === 'image') {
|
|
2030
|
+
const capable = [...state.agents.values()].filter((agent) => agent.ready && state.capabilities[agent.id]?.scopes?.imageGen?.enabled).map((agent) => agent.id);
|
|
2031
|
+
if (!capable.includes(target)) {
|
|
2032
|
+
if (!capable.length) { toast('MU/TH/UR › nobody in the room can generate images right now: enable Image Studio in MODULES or switch on GENERATE IMAGES for an agent in CONNECTIONS.'); return { handled: true }; }
|
|
2033
|
+
toast(`MU/TH/UR › @${target} cannot generate images here; routing to @${capable[0]}.`);
|
|
2034
|
+
target = capable[0];
|
|
2035
|
+
els.target.value = target;
|
|
2036
|
+
renderPicker();
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
if (!state.create) setMode(2);
|
|
2040
|
+
return { handled: false, text: rest, target };
|
|
2041
|
+
}
|
|
2042
|
+
const known = allCommands().find((item) => item.name === name);
|
|
2043
|
+
if (!known) { toast(`MU/TH/UR › unknown command /${name}. Type "/" to see what this room offers.`); return { handled: true }; }
|
|
2044
|
+
if (!known.available) { toast(`MU/TH/UR › /${name} is not available in this project: ${known.title} (see MODULES).`); return { handled: true }; }
|
|
2045
|
+
const response = await fetch('/api/commands', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text }) });
|
|
2046
|
+
if (!response.ok && response.status !== 422) {
|
|
2047
|
+
const result = await response.json().catch(() => ({}));
|
|
2048
|
+
toast(result.error ?? `/${name} failed (${response.status}).`);
|
|
2049
|
+
}
|
|
2050
|
+
return { handled: true };
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
els.input.addEventListener('keydown', (event) => {
|
|
2054
|
+
if (event.key === 'Enter' && !event.shiftKey) {
|
|
2055
|
+
event.preventDefault();
|
|
2056
|
+
els.composer.requestSubmit();
|
|
2057
|
+
}
|
|
2058
|
+
});
|
|
2059
|
+
|
|
2060
|
+
const STOPALL = /^\/?stop\s*all!?$/i;
|
|
2061
|
+
els.input.addEventListener('input', () => {
|
|
2062
|
+
els.composer.classList.toggle('stopall', STOPALL.test(els.input.value.trim()));
|
|
2063
|
+
});
|
|
2064
|
+
document.querySelector('#stop-all')?.addEventListener('click', stopAll);
|
|
2065
|
+
|
|
2066
|
+
function renderPendingAttachments() {
|
|
2067
|
+
els.attachments.replaceChildren();
|
|
2068
|
+
els.attachments.hidden = state.pending.length === 0;
|
|
2069
|
+
for (const item of state.pending) {
|
|
2070
|
+
const chip = el('span', `attachment-chip${item.uploading ? ' uploading' : ''}`);
|
|
2071
|
+
if (item.previewUrl) { const img = el('img'); img.src = item.previewUrl; img.alt = item.name; chip.append(img); }
|
|
2072
|
+
else chip.append(el('span', 'kind', (item.name.split('.').pop() ?? 'file').slice(0, 4).toUpperCase()));
|
|
2073
|
+
chip.append(el('span', 'name', item.uploading ? `${item.name} · uploading…` : item.name));
|
|
2074
|
+
const remove = el('button', 'remove', '×');
|
|
2075
|
+
remove.type = 'button'; remove.title = 'Remove';
|
|
2076
|
+
remove.addEventListener('click', () => { state.pending = state.pending.filter((other) => other !== item); renderPendingAttachments(); });
|
|
2077
|
+
chip.append(remove);
|
|
2078
|
+
els.attachments.append(chip);
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
async function addFiles(files) {
|
|
2082
|
+
for (const file of files) {
|
|
2083
|
+
const item = { name: file.name || 'pasted-image.png', uploading: true, id: null, previewUrl: file.type?.startsWith('image/') ? URL.createObjectURL(file) : null };
|
|
2084
|
+
state.pending.push(item);
|
|
2085
|
+
renderPendingAttachments();
|
|
2086
|
+
try {
|
|
2087
|
+
const response = await fetch('/api/attachments', { method: 'POST', headers: { 'x-pulse-filename': encodeURIComponent(item.name), 'content-type': file.type || 'application/octet-stream' }, body: file });
|
|
2088
|
+
const result = await response.json().catch(() => ({}));
|
|
2089
|
+
if (!response.ok) throw new Error(result.error ?? `Upload failed (${response.status}).`);
|
|
2090
|
+
item.id = result.attachment.id; item.uploading = false;
|
|
2091
|
+
} catch (error) {
|
|
2092
|
+
state.pending = state.pending.filter((other) => other !== item);
|
|
2093
|
+
toast(`Attachment rejected: ${error.message}`);
|
|
2094
|
+
}
|
|
2095
|
+
renderPendingAttachments();
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
function renderCreateScopes() {
|
|
2099
|
+
const box = document.querySelector('#create-scopes');
|
|
2100
|
+
if (!box) return;
|
|
2101
|
+
const id = els.target.value;
|
|
2102
|
+
const scopes = state.capabilities[id]?.scopes;
|
|
2103
|
+
const standing = Boolean(scopes?.write?.always);
|
|
2104
|
+
els.createToggle.classList.toggle('standing', standing);
|
|
2105
|
+
els.createToggle.title = standing
|
|
2106
|
+
? `Standing lease: every turn of @${id} may create files inside .pulse/out/ (set in CONNECTIONS). Arming CREATE is not needed.`
|
|
2107
|
+
: 'Creation lease: let the agent create files for this request, only inside .pulse/out/';
|
|
2108
|
+
box.hidden = !state.create || !scopes;
|
|
2109
|
+
if (box.hidden) return;
|
|
2110
|
+
box.replaceChildren();
|
|
2111
|
+
for (const [key, labelText] of [['write', 'files'], ['imageGen', 'images'], ['web', 'web']]) {
|
|
2112
|
+
const scope = scopes[key] ?? {};
|
|
2113
|
+
const on = scope.enabled && scope.wired;
|
|
2114
|
+
const badge = el('span', `cap${on ? ' on' : scope.capable ? '' : ' no'}`, labelText);
|
|
2115
|
+
badge.title = on ? `${labelText}: enabled for @${id}` : !scope.capable ? `${labelText}: @${id}'s CLI cannot do this` : !scope.wired ? `${labelText}: not wired yet` : `${labelText}: switched off for @${id} in CONNECTIONS`;
|
|
2116
|
+
box.append(badge);
|
|
2117
|
+
}
|
|
2118
|
+
if (!scopes.write?.enabled) toast(`MU/TH/UR › @${id} ${scopes.write?.capable ? 'has file creation switched off' : 'cannot create files from its CLI'}. CREATE will be refused; pick another agent or change CONNECTIONS.`);
|
|
2119
|
+
}
|
|
2120
|
+
els.createToggle.addEventListener('click', () => { setMode(state.mode === 2 ? 1 : 2); els.input.focus(); });
|
|
2121
|
+
// The crew label reads the composer's state: order, lease, easter egg, human.
|
|
2122
|
+
// While CODE000 seals the archive, whoever sits at this console is an intruder to MOTHER.
|
|
2123
|
+
let intruderTimer = null;
|
|
2124
|
+
function markIntruder(ms) {
|
|
2125
|
+
clearTimeout(intruderTimer);
|
|
2126
|
+
state.intruder = ms > 0;
|
|
2127
|
+
els.composer.classList.toggle('intruder', state.intruder);
|
|
2128
|
+
updateCrewLabel();
|
|
2129
|
+
if (state.intruder) intruderTimer = setTimeout(() => { state.intruder = false; els.composer.classList.remove('intruder'); updateCrewLabel(); toast('MU/TH/UR › the archive is open again. Behave.'); }, ms);
|
|
2130
|
+
}
|
|
2131
|
+
fetch('/api/mother').then((response) => response.json()).then((status) => { if ((status?.mother?.lockedForMs ?? 0) > 0) markIntruder(status.mother.lockedForMs); }).catch(() => {});
|
|
2132
|
+
|
|
2133
|
+
function updateCrewLabel() {
|
|
2134
|
+
const order = state.ashCode && state.ashCodeInstalled;
|
|
2135
|
+
els.crewLabel.textContent = state.intruder && state.mode !== 3 ? 'INTRUDER ›' : state.mode === 3 ? `MU/TH/UR · CONTROL @${(state.modeArmedFor ?? els.target.value ?? '').toUpperCase()} ›`
|
|
2136
|
+
: state.mode === 0 ? 'HUMAN · GHOST ›'
|
|
2137
|
+
: order ? (state.create ? 'MU/TH/UR · ASH · CREATE ›' : 'MU/TH/UR · ASH CODE ›')
|
|
2138
|
+
: state.create ? 'HUMAN · CREATE ›'
|
|
2139
|
+
: state.expendable ? 'CREW · EXPENDABLE ›' : 'HUMAN ›';
|
|
2140
|
+
}
|
|
2141
|
+
function updatePlaceholder() {
|
|
2142
|
+
els.input.placeholder = state.mode === 3 ? PLACEHOLDERS.control : state.mode === 0 ? PLACEHOLDERS.ghost : state.create ? PLACEHOLDERS.create : (state.ashCode && state.ashCodeInstalled) ? PLACEHOLDERS.order : state.expendable ? PLACEHOLDERS.expendable : PLACEHOLDERS.plain;
|
|
2143
|
+
}
|
|
2144
|
+
function setOrder937(on, { wink = false } = {}) {
|
|
2145
|
+
state.ashCode = on;
|
|
2146
|
+
els.ashToggle.setAttribute('aria-pressed', String(on));
|
|
2147
|
+
els.ashToggle.textContent = on ? 'ASH_CODE' : 'ash_code';
|
|
2148
|
+
els.composer.classList.toggle('ordering', on);
|
|
2149
|
+
updateCrewLabel();
|
|
2150
|
+
updatePlaceholder();
|
|
2151
|
+
autosize();
|
|
2152
|
+
if (on && wink) {
|
|
2153
|
+
winkField();
|
|
2154
|
+
} else if (!on) {
|
|
2155
|
+
els.composer.classList.remove('ash-wink');
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
// The wink to MOTHER. Three of them: ASH CODE is one green CRT sweep across the field ('ash'),
|
|
2159
|
+
// GHOST fills the field with smoke that clears at once ('ghost'), CONTROL rains red binary over
|
|
2160
|
+
// the field and the whole room ('control'). Then business as usual.
|
|
2161
|
+
const WINKS = { ash: 1600, ghost: 1200, control: 3000 };
|
|
2162
|
+
function winkField(kind = 'ash') {
|
|
2163
|
+
clearTimeout(winkTimer);
|
|
2164
|
+
const classes = Object.keys(WINKS).map((name) => `${name}-wink`);
|
|
2165
|
+
els.composer.classList.remove(...classes);
|
|
2166
|
+
void els.composer.offsetWidth;
|
|
2167
|
+
els.composer.classList.add(`${kind}-wink`);
|
|
2168
|
+
if (kind === 'control') { binaryRain(els.composer.querySelector('.field')); binaryRain(document.body, { fixed: true, duration: 2800, cell: 15, size: 13 }); }
|
|
2169
|
+
winkTimer = setTimeout(() => els.composer.classList.remove(...classes), WINKS[kind] ?? 1600);
|
|
2170
|
+
}
|
|
2171
|
+
// Red 0/1 glyphs falling like rain, a bright head and a fading trail per column, gone after a couple of seconds.
|
|
2172
|
+
// Over the field it fits the box; with fixed:true it covers the viewport. Skipped under reduced motion.
|
|
2173
|
+
function binaryRain(host, { duration = 2400, fixed = false, cell = 11, size = 10 } = {}) {
|
|
2174
|
+
if (!host || typeof requestAnimationFrame !== 'function') return;
|
|
2175
|
+
if (typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
|
2176
|
+
for (const old of host.querySelectorAll?.(fixed ? '.rain.global' : ':scope > .rain') ?? []) old.remove();
|
|
2177
|
+
const canvas = document.createElement('canvas');
|
|
2178
|
+
canvas.className = fixed ? 'rain global' : 'rain';
|
|
2179
|
+
const ctx = canvas.getContext?.('2d');
|
|
2180
|
+
if (!ctx) return;
|
|
2181
|
+
const rect = fixed ? { width: window.innerWidth, height: window.innerHeight } : host.getBoundingClientRect();
|
|
2182
|
+
if (!rect.width || !rect.height) return;
|
|
2183
|
+
const scale = window.devicePixelRatio || 1;
|
|
2184
|
+
canvas.width = Math.round(rect.width * scale);
|
|
2185
|
+
canvas.height = Math.round(rect.height * scale);
|
|
2186
|
+
host.append(canvas);
|
|
2187
|
+
ctx.scale(scale, scale);
|
|
2188
|
+
const styles = getComputedStyle(host);
|
|
2189
|
+
const red = styles.getPropertyValue('--terror').trim() || '#ff2a1f';
|
|
2190
|
+
ctx.font = `700 ${size}px ${styles.getPropertyValue('--mono').trim() || 'monospace'}`;
|
|
2191
|
+
const drops = Array.from({ length: Math.ceil(rect.width / cell) }, () => ({ y: -Math.random() * rect.height * (fixed ? 1.2 : 2), speed: (fixed ? 4 : 2.5) + Math.random() * (fixed ? 9 : 5) }));
|
|
2192
|
+
const started = performance.now();
|
|
2193
|
+
let last = started;
|
|
2194
|
+
const frame = (now) => {
|
|
2195
|
+
const t = now - started;
|
|
2196
|
+
const dt = Math.min(48, now - last);
|
|
2197
|
+
last = now;
|
|
2198
|
+
// Fade the previous frame a little: the trail behind each drop.
|
|
2199
|
+
ctx.globalCompositeOperation = 'destination-out';
|
|
2200
|
+
ctx.fillStyle = 'rgba(0, 0, 0, .16)';
|
|
2201
|
+
ctx.fillRect(0, 0, rect.width, rect.height);
|
|
2202
|
+
ctx.globalCompositeOperation = 'source-over';
|
|
2203
|
+
const intensity = t < duration * 0.65 ? 1 : Math.max(0, 1 - (t - duration * 0.65) / (duration * 0.35));
|
|
2204
|
+
drops.forEach((drop, i) => {
|
|
2205
|
+
drop.y += drop.speed * dt / 16;
|
|
2206
|
+
if (drop.y > rect.height + cell && intensity > 0.4) { drop.y = -cell * (1 + Math.random() * 16); drop.speed = (fixed ? 4 : 2.5) + Math.random() * (fixed ? 9 : 5); }
|
|
2207
|
+
ctx.globalAlpha = intensity * (0.5 + Math.random() * 0.5);
|
|
2208
|
+
ctx.fillStyle = Math.random() < 0.1 ? '#fff1ef' : red;
|
|
2209
|
+
ctx.fillText(Math.random() < 0.5 ? '0' : '1', i * cell + 2, drop.y);
|
|
2210
|
+
});
|
|
2211
|
+
ctx.globalAlpha = 1;
|
|
2212
|
+
if (t < duration && canvas.isConnected) requestAnimationFrame(frame); else canvas.remove();
|
|
2213
|
+
};
|
|
2214
|
+
requestAnimationFrame(frame);
|
|
2215
|
+
}
|
|
2216
|
+
function syncAshCodeUI(enabled) {
|
|
2217
|
+
state.ashCodeInstalled = enabled;
|
|
2218
|
+
els.ashToggle.hidden = !enabled;
|
|
2219
|
+
setOrder937(enabled);
|
|
2220
|
+
}
|
|
2221
|
+
els.ashToggle.addEventListener('click', () => {
|
|
2222
|
+
if (!state.ashCodeInstalled) return;
|
|
2223
|
+
setOrder937(!state.ashCode, { wink: true });
|
|
2224
|
+
if (state.ashCode) toast('MU/TH/UR › ASH CODE · BETA: abbreviation may change meaning or cause errors. Check the original. Fewer characters are not verified token savings.');
|
|
2225
|
+
els.input.focus();
|
|
2226
|
+
});
|
|
2227
|
+
els.attach.addEventListener('click', () => els.fileInput.click());
|
|
2228
|
+
els.fileInput.addEventListener('change', () => { void addFiles([...els.fileInput.files]); els.fileInput.value = ''; });
|
|
2229
|
+
els.input.addEventListener('paste', (event) => {
|
|
2230
|
+
const files = [...(event.clipboardData?.files ?? [])];
|
|
2231
|
+
if (files.length) { event.preventDefault(); void addFiles(files); }
|
|
2232
|
+
});
|
|
2233
|
+
for (const type of ['dragenter', 'dragover']) els.composer.addEventListener(type, (event) => { event.preventDefault(); els.composer.classList.add('dropping'); });
|
|
2234
|
+
for (const type of ['dragleave', 'drop']) els.composer.addEventListener(type, (event) => { event.preventDefault(); els.composer.classList.remove('dropping'); });
|
|
2235
|
+
els.composer.addEventListener('drop', (event) => { const files = [...(event.dataTransfer?.files ?? [])]; if (files.length) void addFiles(files); });
|
|
2236
|
+
|
|
2237
|
+
els.composer.addEventListener('submit', async (event) => {
|
|
2238
|
+
event.preventDefault();
|
|
2239
|
+
const text = els.input.value.trim();
|
|
2240
|
+
const ready = state.pending.filter((item) => item.id && !item.uploading);
|
|
2241
|
+
if (!text && !ready.length) return;
|
|
2242
|
+
if (state.pending.some((item) => item.uploading)) { toast('An attachment is still uploading.'); return; }
|
|
2243
|
+
if (STOPALL.test(text)) {
|
|
2244
|
+
// Master command: never reaches an agent.
|
|
2245
|
+
els.input.value = '';
|
|
2246
|
+
els.composer.classList.remove('stopall');
|
|
2247
|
+
autosize();
|
|
2248
|
+
await stopAll();
|
|
2249
|
+
return;
|
|
2250
|
+
}
|
|
2251
|
+
let outgoing = text;
|
|
2252
|
+
let target = els.target.value;
|
|
2253
|
+
// "#2" written in the message is the same as choosing it in the chip.
|
|
2254
|
+
const modeToken = outgoing.match(/(^|\s)#([0-3])(?=\s|$)/);
|
|
2255
|
+
if (modeToken) { setMode(Number(modeToken[2])); outgoing = outgoing.replace(/(^|\s)#[0-3](?=\s|$)/, '$1').replace(/\s{2,}/g, ' ').trim(); }
|
|
2256
|
+
if (text.startsWith('/')) {
|
|
2257
|
+
els.input.disabled = true;
|
|
2258
|
+
const result = await runSlashCommand(text).catch((error) => { toast(`Command failed: ${error.message}`); return { handled: true }; });
|
|
2259
|
+
els.input.disabled = false;
|
|
2260
|
+
if (result.handled) { els.input.value = ''; autosize(); els.input.focus(); return; }
|
|
2261
|
+
outgoing = result.text ?? text;
|
|
2262
|
+
target = result.target ?? target;
|
|
2263
|
+
}
|
|
2264
|
+
els.input.disabled = true;
|
|
2265
|
+
els.send.disabled = true;
|
|
2266
|
+
try {
|
|
2267
|
+
const response = await fetch('/api/messages', {
|
|
2268
|
+
method: 'POST',
|
|
2269
|
+
headers: { 'content-type': 'application/json' },
|
|
2270
|
+
body: JSON.stringify({ text: outgoing, target: target || null, model: state.chosenModel[target] ?? null, attachments: ready.map((item) => item.id), create: state.create, mode: state.mode, ashCode: state.ashCodeInstalled && state.ashCode }),
|
|
2271
|
+
});
|
|
2272
|
+
if (!response.ok) {
|
|
2273
|
+
const result = await response.json().catch(() => ({ error: `Request failed (${response.status}).` }));
|
|
2274
|
+
toast(`MU/TH/UR › ${result.error ?? 'The room rejected the message.'}`);
|
|
2275
|
+
if (state.mode === 3 && [403, 409, 412].includes(response.status)) setMode(1);
|
|
2276
|
+
} else {
|
|
2277
|
+
els.input.value = '';
|
|
2278
|
+
state.pending = [];
|
|
2279
|
+
renderPendingAttachments();
|
|
2280
|
+
resetModeAfterSend(); // one mode per message; standing leases fall back to #2, everyone else to #1
|
|
2281
|
+
autosize();
|
|
2282
|
+
}
|
|
2283
|
+
} catch (error) {
|
|
2284
|
+
toast(`Could not reach MADRE: ${error.message}`);
|
|
2285
|
+
} finally {
|
|
2286
|
+
els.input.disabled = false;
|
|
2287
|
+
els.send.disabled = false;
|
|
2288
|
+
els.input.focus();
|
|
2289
|
+
}
|
|
2290
|
+
});
|
|
2291
|
+
|
|
2292
|
+
/* ---------- MU/TH/UR: troubleshooting ---------- */
|
|
2293
|
+
|
|
2294
|
+
const mother = {
|
|
2295
|
+
dialog: document.querySelector('#mother'),
|
|
2296
|
+
button: document.querySelector('#mother-button'),
|
|
2297
|
+
count: document.querySelector('#mother-count'),
|
|
2298
|
+
close: document.querySelector('#mother-close'),
|
|
2299
|
+
boot: document.querySelector('#mother-boot'),
|
|
2300
|
+
query: document.querySelector('#mother-query'),
|
|
2301
|
+
input: document.querySelector('#mother-input'),
|
|
2302
|
+
os: document.querySelector('#mother-os'),
|
|
2303
|
+
answer: document.querySelector('#mother-answer'),
|
|
2304
|
+
recorded: document.querySelector('#mother-recorded'),
|
|
2305
|
+
known: document.querySelector('#mother-known'),
|
|
2306
|
+
platform: detectPlatform(),
|
|
2307
|
+
bootTimer: null,
|
|
2308
|
+
};
|
|
2309
|
+
|
|
2310
|
+
function recordFailure(entry) {
|
|
2311
|
+
state.failures.push(entry);
|
|
2312
|
+
const count = state.failures.filter((failure) => !failure.recovered).length;
|
|
2313
|
+
// Called while the transcript is still being replayed, before the MU/TH/UR
|
|
2314
|
+
// block below has initialised, so look the badge up directly.
|
|
2315
|
+
const badge = document.querySelector('#mother-count');
|
|
2316
|
+
if (!badge) return;
|
|
2317
|
+
badge.hidden = count === 0;
|
|
2318
|
+
badge.textContent = String(count);
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
function commandBlock(lines) {
|
|
2322
|
+
const block = el('div', 'mother-cmd');
|
|
2323
|
+
const copy = el('button', 'copy', 'COPY');
|
|
2324
|
+
copy.type = 'button';
|
|
2325
|
+
const runnable = lines.filter((line) => !line.trim().startsWith('#'));
|
|
2326
|
+
copy.addEventListener('click', async () => {
|
|
2327
|
+
try {
|
|
2328
|
+
await navigator.clipboard.writeText(runnable.join('\n'));
|
|
2329
|
+
copy.textContent = 'COPIED';
|
|
2330
|
+
setTimeout(() => { copy.textContent = 'COPY'; }, 1500);
|
|
2331
|
+
} catch {
|
|
2332
|
+
copy.textContent = 'SELECT';
|
|
2333
|
+
}
|
|
2334
|
+
});
|
|
2335
|
+
block.append(copy);
|
|
2336
|
+
const pre = el('pre');
|
|
2337
|
+
for (const line of lines) {
|
|
2338
|
+
const span = el('span', line.trim().startsWith('#') ? 'c' : null, line);
|
|
2339
|
+
pre.append(span, '\n');
|
|
2340
|
+
}
|
|
2341
|
+
block.append(pre);
|
|
2342
|
+
return block;
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
function conditionCard(condition, { hit = false, agent = null, hintAgent = null } = {}) {
|
|
2346
|
+
const card = el('article', `mother-card${hit ? ' hit' : ''}`);
|
|
2347
|
+
card.id = `mother-${condition.id}`;
|
|
2348
|
+
const tags = el('div', 'tags');
|
|
2349
|
+
tags.append(el('span', `sev-${condition.severity}`, condition.severity));
|
|
2350
|
+
if (condition.agent) tags.append(paint(el('span', 'agent', `@${condition.agent}`), condition.agent));
|
|
2351
|
+
tags.append(el('span', null, condition.id));
|
|
2352
|
+
card.append(tags);
|
|
2353
|
+
card.append(el('h4', null, condition.title));
|
|
2354
|
+
const diagnosis = el('p');
|
|
2355
|
+
diagnosis.append(el('b', null, 'DIAGNOSIS'));
|
|
2356
|
+
diagnosis.append(condition.diagnosis);
|
|
2357
|
+
card.append(diagnosis);
|
|
2358
|
+
const remedy = el('p', 'remedy');
|
|
2359
|
+
remedy.append(el('b', null, 'REMEDY'));
|
|
2360
|
+
remedy.append(condition.remedy);
|
|
2361
|
+
card.append(remedy);
|
|
2362
|
+
const actions = conditionActions(condition, hintAgent ?? agent ?? condition.agent ?? null);
|
|
2363
|
+
if (actions.length) {
|
|
2364
|
+
const bar = el('div', 'actions');
|
|
2365
|
+
for (const action of actions) {
|
|
2366
|
+
const button = el('button', 'act', action.label);
|
|
2367
|
+
button.type = 'button';
|
|
2368
|
+
button.addEventListener('click', async () => {
|
|
2369
|
+
button.disabled = true;
|
|
2370
|
+
try { await saveSettingNow(action.patch, action.done); button.textContent = 'APPLIED ✓'; }
|
|
2371
|
+
catch (error) { toast(`Could not apply: ${error.message}`); button.disabled = false; }
|
|
2372
|
+
});
|
|
2373
|
+
bar.append(button);
|
|
2374
|
+
}
|
|
2375
|
+
bar.append(el('span', 'note', 'APPLIES NOW · TERMINAL COMMANDS BELOW ARE THE LAUNCH-TIME ALTERNATIVE'));
|
|
2376
|
+
card.append(bar);
|
|
2377
|
+
}
|
|
2378
|
+
const chosen = agent ?? condition.agent ?? null;
|
|
2379
|
+
card.append(commandBlock(fixesFor(condition, mother.platform, chosen)));
|
|
2380
|
+
if (condition.perAgent && !chosen) {
|
|
2381
|
+
for (const id of Object.keys(condition.perAgent)) {
|
|
2382
|
+
const label = paint(el('p'), id);
|
|
2383
|
+
label.append(el('b', null, `@${id}`));
|
|
2384
|
+
card.append(label);
|
|
2385
|
+
card.append(commandBlock(condition.perAgent[id][mother.platform] ?? []));
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
return card;
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
function renderMotherOs() {
|
|
2392
|
+
mother.os.replaceChildren();
|
|
2393
|
+
for (const [id, meta] of Object.entries(PLATFORMS)) {
|
|
2394
|
+
const button = el('button', null, meta.label.toUpperCase());
|
|
2395
|
+
button.type = 'button';
|
|
2396
|
+
button.setAttribute('role', 'radio');
|
|
2397
|
+
button.setAttribute('aria-checked', String(id === mother.platform));
|
|
2398
|
+
button.addEventListener('click', () => { mother.platform = id; renderMother(); });
|
|
2399
|
+
mother.os.append(button);
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
function renderMotherRecorded() {
|
|
2404
|
+
mother.recorded.replaceChildren();
|
|
2405
|
+
let collapsed = false;
|
|
2406
|
+
try { collapsed = localStorage.getItem('pulse.mother.log') === 'collapsed'; } catch { /* no storage */ }
|
|
2407
|
+
const head = el('h3', 'toggle');
|
|
2408
|
+
const headButton = el('button', null, `RECORDED CONDITIONS · THIS ROOM · ${state.failures.length}`);
|
|
2409
|
+
headButton.type = 'button';
|
|
2410
|
+
headButton.setAttribute('aria-expanded', String(!collapsed));
|
|
2411
|
+
headButton.append(el('span', 'caret', collapsed ? '▸ EXPAND' : '▾ COLLAPSE'));
|
|
2412
|
+
headButton.addEventListener('click', () => {
|
|
2413
|
+
try { localStorage.setItem('pulse.mother.log', collapsed ? 'expanded' : 'collapsed'); } catch { /* no storage */ }
|
|
2414
|
+
renderMotherRecorded();
|
|
2415
|
+
});
|
|
2416
|
+
head.append(headButton);
|
|
2417
|
+
mother.recorded.append(head);
|
|
2418
|
+
mother.recorded.classList.toggle('collapsed', collapsed);
|
|
2419
|
+
if (collapsed) return;
|
|
2420
|
+
if (!state.failures.length) {
|
|
2421
|
+
mother.recorded.append(el('p', 'mother-answer', 'NO CONDITIONS RECORDED. ALL SYSTEMS NOMINAL.'));
|
|
2422
|
+
return;
|
|
2423
|
+
}
|
|
2424
|
+
for (const failure of [...state.failures].reverse().slice(0, 40)) {
|
|
2425
|
+
const rowNode = paint(el('div', 'mother-record'), failure.agent);
|
|
2426
|
+
rowNode.append(el('span', 't', formatTime(failure.time)));
|
|
2427
|
+
rowNode.append(el('span', 'a', failure.agent ?? 'room'));
|
|
2428
|
+
const full = String(failure.error).trim();
|
|
2429
|
+
const firstLine = full.split('\n')[0].slice(0, 220);
|
|
2430
|
+
const errorNode = el('span', 'e', firstLine);
|
|
2431
|
+
if (full.length > firstLine.length) {
|
|
2432
|
+
// One line by default; click to read the whole record and back.
|
|
2433
|
+
errorNode.classList.add('more');
|
|
2434
|
+
errorNode.title = 'Expand';
|
|
2435
|
+
errorNode.tabIndex = 0;
|
|
2436
|
+
const flip = () => {
|
|
2437
|
+
const open = errorNode.classList.toggle('open');
|
|
2438
|
+
errorNode.textContent = open ? full.slice(0, 4000) : firstLine;
|
|
2439
|
+
errorNode.title = open ? 'Collapse' : 'Expand';
|
|
2440
|
+
};
|
|
2441
|
+
errorNode.addEventListener('click', flip);
|
|
2442
|
+
errorNode.addEventListener('keydown', (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); flip(); } });
|
|
2443
|
+
}
|
|
2444
|
+
rowNode.append(errorNode);
|
|
2445
|
+
const matches = diagnose(failure.error, failure.agent);
|
|
2446
|
+
const links = el('span', 'k');
|
|
2447
|
+
if (!matches.length) links.append(el('span', 'none', 'UNCLASSIFIED'));
|
|
2448
|
+
for (const condition of matches) {
|
|
2449
|
+
const jump = el('button', null, condition.id);
|
|
2450
|
+
jump.type = 'button';
|
|
2451
|
+
jump.addEventListener('click', () => {
|
|
2452
|
+
mother.input.value = '';
|
|
2453
|
+
renderMotherKnown(CONDITIONS, new Set(matches.map((item) => item.id)), failure.agent);
|
|
2454
|
+
document.getElementById(`mother-${condition.id}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
2455
|
+
});
|
|
2456
|
+
links.append(jump);
|
|
2457
|
+
}
|
|
2458
|
+
rowNode.append(links);
|
|
2459
|
+
mother.recorded.append(rowNode);
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
function renderMotherKnown(list = CONDITIONS, hits = new Set(), agent = null) {
|
|
2464
|
+
mother.known.replaceChildren();
|
|
2465
|
+
mother.known.append(el('h3', null, `KNOWN CONDITIONS · ${list.length} OF ${CONDITIONS.length} · ${PLATFORMS[mother.platform].label.toUpperCase()} / ${PLATFORMS[mother.platform].shell.toUpperCase()}`));
|
|
2466
|
+
const grid = el('div', 'mother-grid');
|
|
2467
|
+
const ordered = [...list].sort((a, b) => Number(hits.has(b.id)) - Number(hits.has(a.id)));
|
|
2468
|
+
for (const condition of ordered) grid.append(conditionCard(condition, { hit: hits.has(condition.id), agent: agent && condition.perAgent ? agent : null, hintAgent: agent }));
|
|
2469
|
+
mother.known.append(grid);
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
function answerQuery(query) {
|
|
2473
|
+
const text = query.trim();
|
|
2474
|
+
if (!text) { mother.answer.textContent = ''; return renderMotherKnown(); }
|
|
2475
|
+
if (/special order|937|expendable/i.test(text)) {
|
|
2476
|
+
mother.answer.textContent = 'NO SPECIAL ORDERS ON THIS SHIP. THE CREW IS NOT EXPENDABLE. RESTATE INQUIRY.';
|
|
2477
|
+
return renderMotherKnown([]);
|
|
2478
|
+
}
|
|
2479
|
+
if (/^(help|\?)$/i.test(text)) {
|
|
2480
|
+
mother.answer.textContent = 'INQUIRY ACCEPTS: AN AGENT NAME · A SYMPTOM · A KEYWORD SUCH AS TIMEOUT, LOGIN, PORT, BUDGET.';
|
|
2481
|
+
return renderMotherKnown();
|
|
2482
|
+
}
|
|
2483
|
+
const byError = diagnose(text);
|
|
2484
|
+
const bySearch = searchConditions(text);
|
|
2485
|
+
const list = [...new Set([...byError, ...bySearch])];
|
|
2486
|
+
const recordedHits = new Set(state.failures.flatMap((failure) => diagnose(failure.error, failure.agent).map((c) => c.id)));
|
|
2487
|
+
if (!list.length) {
|
|
2488
|
+
mother.answer.textContent = 'UNABLE TO COMPUTE. REQUEST CLARIFICATION.';
|
|
2489
|
+
return renderMotherKnown([]);
|
|
2490
|
+
}
|
|
2491
|
+
mother.answer.textContent = `${list.length} CONDITION${list.length === 1 ? '' : 'S'} MATCH INQUIRY.${byError.length ? ' PROBABLE CAUSE HIGHLIGHTED.' : ''}`;
|
|
2492
|
+
renderMotherKnown(list, new Set([...byError.map((c) => c.id), ...recordedHits]));
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
function renderMother() {
|
|
2496
|
+
renderMotherOs();
|
|
2497
|
+
renderMotherRecorded();
|
|
2498
|
+
answerQuery(mother.input.value);
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2501
|
+
function bootMother() {
|
|
2502
|
+
clearTimeout(mother.bootTimer);
|
|
2503
|
+
const online = [...state.agents.values()].filter((agent) => agent.ready).length;
|
|
2504
|
+
const open = state.failures.filter((failure) => !failure.recovered).length;
|
|
2505
|
+
const lines = [
|
|
2506
|
+
'INTERFACE 2037 READY FOR INQUIRY',
|
|
2507
|
+
`CREW: ${state.agents.size} AGENTS · ${online} READY · ROOM /${els.project.textContent}`,
|
|
2508
|
+
open ? `${open} CONDITION${open === 1 ? '' : 'S'} RECORDED IN THIS ROOM. PROBABLE CAUSES CLASSIFIED BELOW.` : 'NO OPEN CONDITIONS. ALL SYSTEMS NOMINAL.',
|
|
2509
|
+
];
|
|
2510
|
+
mother.boot.textContent = '';
|
|
2511
|
+
let index = 0;
|
|
2512
|
+
const step = () => {
|
|
2513
|
+
if (index >= lines.length) return;
|
|
2514
|
+
mother.boot.textContent += `${index ? '\n' : ''}${lines[index]}`;
|
|
2515
|
+
index += 1;
|
|
2516
|
+
mother.bootTimer = setTimeout(step, 320);
|
|
2517
|
+
};
|
|
2518
|
+
step();
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
function openMother() {
|
|
2522
|
+
if (mother.dialog.open) return;
|
|
2523
|
+
mother.dialog.showModal();
|
|
2524
|
+
bootMother();
|
|
2525
|
+
renderMother();
|
|
2526
|
+
mother.input.focus();
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
mother.button.addEventListener('click', openMother);
|
|
2530
|
+
mother.close.addEventListener('click', () => mother.dialog.close());
|
|
2531
|
+
mother.query.addEventListener('submit', (event) => { event.preventDefault(); answerQuery(mother.input.value); });
|
|
2532
|
+
mother.input.addEventListener('input', () => answerQuery(mother.input.value));
|
|
2533
|
+
mother.dialog.addEventListener('close', () => clearTimeout(mother.bootTimer));
|
|
2534
|
+
|
|
2535
|
+
|
|
2536
|
+
/* ---------- MODULES: optional integrations installed by their own tools ---------- */
|
|
2537
|
+
|
|
2538
|
+
const modules = {
|
|
2539
|
+
dialog: document.querySelector('#modules'),
|
|
2540
|
+
button: document.querySelector('#modules-button'),
|
|
2541
|
+
count: document.querySelector('#modules-count'),
|
|
2542
|
+
close: document.querySelector('#modules-close'),
|
|
2543
|
+
list: document.querySelector('#modules-list'),
|
|
2544
|
+
note: document.querySelector('#modules-note'),
|
|
2545
|
+
items: [],
|
|
2546
|
+
installing: null,
|
|
2547
|
+
logs: new Map(), // id -> array of lines
|
|
2548
|
+
confirming: null,
|
|
2549
|
+
};
|
|
2550
|
+
|
|
2551
|
+
function renderModuleEvent(event) {
|
|
2552
|
+
const { id, name, command, platforms = [], ok, code, error, status, problems = [] } = event.payload;
|
|
2553
|
+
const failed = (event.type.endsWith('finished') && !ok) || event.type.endsWith('refused');
|
|
2554
|
+
const node = el('div', `system module${failed ? ' failed' : ''}`);
|
|
2555
|
+
node.append(el('b', null, 'MODULES › '));
|
|
2556
|
+
if (event.type === 'extension.install.refused') {
|
|
2557
|
+
node.append(`${name} not installed · ${problems.join(' ')}`);
|
|
2558
|
+
modules.installing = null;
|
|
2559
|
+
} else if (event.type === 'extension.install.started') {
|
|
2560
|
+
node.append(`installing ${name}${platforms.length ? ` for ${platforms.map((p) => `@${p}`).join(', ')}` : ''}`);
|
|
2561
|
+
node.append(el('span', 'cmd', command));
|
|
2562
|
+
modules.installing = id;
|
|
2563
|
+
} else {
|
|
2564
|
+
node.append(ok
|
|
2565
|
+
? `${name} installed${status?.detail ? ` · ${status.detail}` : ''}`
|
|
2566
|
+
: `${name} install failed${error ? ` · ${error}` : code !== undefined ? ` · exit ${code}` : ''}`);
|
|
2567
|
+
modules.installing = null;
|
|
2568
|
+
void refreshModules();
|
|
2569
|
+
void refreshCommands();
|
|
2570
|
+
}
|
|
2571
|
+
state.lastSender = null;
|
|
2572
|
+
return node;
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
function renderCommandCard(event) {
|
|
2576
|
+
const { name, title, text, ok, args = [] } = event.payload;
|
|
2577
|
+
const node = el('div', `command-card${ok === false ? ' failed' : ''}`);
|
|
2578
|
+
const head = el('div', 'head');
|
|
2579
|
+
head.append(el('b', null, `/${name}`), el('span', null, title ?? name), el('span', 'args', args.join(' ')));
|
|
2580
|
+
const pre = el('pre');
|
|
2581
|
+
for (const line of String(text ?? '').split('\n')) {
|
|
2582
|
+
if (line.startsWith('## ')) pre.append(el('span', 'h', line.slice(3)), '\n');
|
|
2583
|
+
else pre.append(`${line}\n`);
|
|
2584
|
+
}
|
|
2585
|
+
node.append(head, pre);
|
|
2586
|
+
state.lastSender = null;
|
|
2587
|
+
return node;
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
function appendModuleOutput(event) {
|
|
2591
|
+
const { id, lines = [] } = event.payload;
|
|
2592
|
+
const log = modules.logs.get(id) ?? [];
|
|
2593
|
+
log.push(...lines);
|
|
2594
|
+
modules.logs.set(id, log.slice(-400));
|
|
2595
|
+
const box = document.getElementById(`module-log-${id}`);
|
|
2596
|
+
if (box) {
|
|
2597
|
+
for (const line of lines) box.append(el('div', /error|failed|ERR/i.test(line) ? 'err' : null, line));
|
|
2598
|
+
box.scrollTop = box.scrollHeight;
|
|
2599
|
+
}
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
async function refreshModules() {
|
|
2603
|
+
try {
|
|
2604
|
+
const data = await fetch('/api/extensions').then((response) => response.json());
|
|
2605
|
+
modules.items = data.extensions ?? [];
|
|
2606
|
+
modules.installing = data.installing ?? null;
|
|
2607
|
+
} catch (error) {
|
|
2608
|
+
modules.items = [];
|
|
2609
|
+
modules.note.textContent = `UNABLE TO LIST MODULES: ${error.message}`;
|
|
2610
|
+
}
|
|
2611
|
+
const installed = modules.items.filter((item) => item.status?.installed).length;
|
|
2612
|
+
modules.count.hidden = installed === 0;
|
|
2613
|
+
modules.count.textContent = String(installed);
|
|
2614
|
+
if (modules.dialog.open) renderModules();
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
function builtinCard(item) {
|
|
2618
|
+
const card = el('article', 'module-card');
|
|
2619
|
+
card.id = `module-${item.id}`;
|
|
2620
|
+
const head = el('div', 'head');
|
|
2621
|
+
const title = el('div');
|
|
2622
|
+
title.append(el('h4', null, item.name));
|
|
2623
|
+
title.append(el('div', 'vendor', `${item.vendor} · v${item.version}`));
|
|
2624
|
+
head.append(title);
|
|
2625
|
+
const on = Boolean(item.status?.installed);
|
|
2626
|
+
head.append(el('span', `state${on ? ' installed' : ''}`, on ? 'ENABLED' : 'DISABLED'));
|
|
2627
|
+
card.append(head);
|
|
2628
|
+
card.append(el('p', null, item.summary));
|
|
2629
|
+
const list = el('ul');
|
|
2630
|
+
for (const line of item.creates ?? []) list.append(el('li', null, line));
|
|
2631
|
+
for (const line of item.requires ?? []) list.append(el('li', null, `requires ${line}`));
|
|
2632
|
+
card.append(list);
|
|
2633
|
+
if (item.id === 'ripley') {
|
|
2634
|
+
const actions = el('div', 'actions');
|
|
2635
|
+
const toggle = el('button', on ? null : 'primary', on ? 'DISABLE RIPLEY' : 'ENABLE RIPLEY');
|
|
2636
|
+
toggle.type = 'button';
|
|
2637
|
+
toggle.addEventListener('click', async () => {
|
|
2638
|
+
toggle.disabled = true;
|
|
2639
|
+
try {
|
|
2640
|
+
const response = await fetch('/api/extensions/ripley/install', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({}) });
|
|
2641
|
+
const result = await response.json().catch(() => ({}));
|
|
2642
|
+
if (!response.ok) throw new Error(result.error ?? `HTTP ${response.status}`);
|
|
2643
|
+
state.ripley = Boolean(result.enabled);
|
|
2644
|
+
syncViewerMode();
|
|
2645
|
+
toast(result.enabled ? 'MU/TH/UR › RIPLEY ON · HTML, SVG and Markdown render in the file viewer, in a sealed frame.' : 'MU/TH/UR › RIPLEY OFF · files show as source.');
|
|
2646
|
+
await refreshModules();
|
|
2647
|
+
} catch (error) {
|
|
2648
|
+
toast(`RIPLEY could not change state: ${error.message}`);
|
|
2649
|
+
} finally {
|
|
2650
|
+
toggle.disabled = false;
|
|
2651
|
+
}
|
|
2652
|
+
});
|
|
2653
|
+
actions.append(toggle);
|
|
2654
|
+
card.append(actions);
|
|
2655
|
+
return card;
|
|
2656
|
+
}
|
|
2657
|
+
if (item.id === 'ashcode') {
|
|
2658
|
+
card.append(el('p', 'ash-beta', item.warning ?? 'BETA · May change meaning; review the original.'));
|
|
2659
|
+
const actions = el('div', 'actions');
|
|
2660
|
+
const toggle = el('button', on ? null : 'primary', on ? 'DISABLE ASH CODE' : 'ENABLE BETA');
|
|
2661
|
+
toggle.type = 'button';
|
|
2662
|
+
toggle.addEventListener('click', async () => {
|
|
2663
|
+
toggle.disabled = true;
|
|
2664
|
+
try {
|
|
2665
|
+
const response = await fetch('/api/extensions/ashcode/install', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ confirm: true }) });
|
|
2666
|
+
const result = await response.json().catch(() => ({}));
|
|
2667
|
+
if (!response.ok) throw new Error(result.error ?? `HTTP ${response.status}`);
|
|
2668
|
+
syncAshCodeUI(Boolean(result.enabled));
|
|
2669
|
+
toast(result.enabled
|
|
2670
|
+
? 'ASH CODE ON · BETA: prompts and replies may change meaning. Inspect the original; shorter characters are not verified token savings.'
|
|
2671
|
+
: 'ASH CODE OFF · messages are sent normally.');
|
|
2672
|
+
await refreshModules();
|
|
2673
|
+
} catch (error) {
|
|
2674
|
+
toast(`AshCode could not change state: ${error.message}`);
|
|
2675
|
+
} finally {
|
|
2676
|
+
toggle.disabled = false;
|
|
2677
|
+
}
|
|
2678
|
+
});
|
|
2679
|
+
actions.append(toggle);
|
|
2680
|
+
card.append(actions);
|
|
2681
|
+
return card;
|
|
2682
|
+
}
|
|
2683
|
+
if (item.preflight && !item.preflight.ok) {
|
|
2684
|
+
const warn = el('div', 'confirm');
|
|
2685
|
+
warn.append(el('span', 'warn', 'CANNOT ENABLE YET'));
|
|
2686
|
+
for (const problem of item.preflight.problems) warn.append(el('p', null, problem));
|
|
2687
|
+
card.append(warn);
|
|
2688
|
+
}
|
|
2689
|
+
const actions = el('div', 'actions');
|
|
2690
|
+
const model = el('select');
|
|
2691
|
+
for (const name of item.models ?? []) { const option = el('option', null, name); option.value = name; if (name === item.model) option.selected = true; model.append(option); }
|
|
2692
|
+
model.title = 'Gemini image model used by Image Studio';
|
|
2693
|
+
const toggle = el('button', on ? null : 'primary', on ? 'DISABLE' : 'ENABLE');
|
|
2694
|
+
toggle.type = 'button';
|
|
2695
|
+
toggle.disabled = !on && item.preflight && !item.preflight.ok;
|
|
2696
|
+
toggle.addEventListener('click', async () => {
|
|
2697
|
+
toggle.disabled = true;
|
|
2698
|
+
const response = await fetch(`/api/extensions/${item.id}/install`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ confirm: true, model: model.value }) });
|
|
2699
|
+
const result = await response.json().catch(() => ({}));
|
|
2700
|
+
if (!response.ok) toast(result.error ?? `Could not toggle ${item.name}.`);
|
|
2701
|
+
else { toast(`MU/TH/UR › Image Studio ${result.enabled ? 'enabled' : 'disabled'}${result.enabled ? ` · ${result.model}` : ''}. Image scopes for Gemini, Claude and OpenCode ${result.enabled ? 'can now be switched on' : 'are off'} in CONNECTIONS.`); if (result.capabilities) { state.capabilities = result.capabilities; renderCreateScopes(); } }
|
|
2702
|
+
await refreshModules();
|
|
2703
|
+
});
|
|
2704
|
+
actions.append(toggle, model);
|
|
2705
|
+
actions.append(el('span', 'note', item.status?.detail ? item.status.detail.toUpperCase() : ''));
|
|
2706
|
+
card.append(actions);
|
|
2707
|
+
return card;
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2710
|
+
function moduleCard(item) {
|
|
2711
|
+
if (item.kind === 'builtin') return builtinCard(item);
|
|
2712
|
+
const card = el('article', 'module-card');
|
|
2713
|
+
card.id = `module-${item.id}`;
|
|
2714
|
+
const head = el('div', 'head');
|
|
2715
|
+
const title = el('div');
|
|
2716
|
+
title.append(el('h4', null, item.name));
|
|
2717
|
+
title.append(el('div', 'vendor', `${item.vendor} · ${item.package}@${item.version}`));
|
|
2718
|
+
head.append(title);
|
|
2719
|
+
const running = modules.installing === item.id;
|
|
2720
|
+
const stateTag = el('span', `state${item.status?.installed ? ' installed' : ''}${running ? ' running' : ''}`,
|
|
2721
|
+
running ? 'INSTALLING' : item.status?.installed ? 'INSTALLED' : 'NOT IN THIS PROJECT');
|
|
2722
|
+
head.append(stateTag);
|
|
2723
|
+
card.append(head);
|
|
2724
|
+
if (item.status?.installed && item.status.detail) card.append(el('div', 'detail', item.status.detail.toUpperCase()));
|
|
2725
|
+
card.append(el('p', null, item.summary));
|
|
2726
|
+
const creates = el('ul');
|
|
2727
|
+
for (const line of item.creates ?? []) creates.append(el('li', null, line));
|
|
2728
|
+
card.append(creates);
|
|
2729
|
+
|
|
2730
|
+
const actions = el('div', 'actions');
|
|
2731
|
+
const blocked = item.preflight && !item.preflight.ok;
|
|
2732
|
+
if (blocked) {
|
|
2733
|
+
const warn = el('div', 'confirm');
|
|
2734
|
+
warn.append(el('span', 'warn', 'CANNOT INSTALL HERE YET'));
|
|
2735
|
+
for (const problem of item.preflight.problems) warn.append(el('p', null, problem));
|
|
2736
|
+
card.append(warn);
|
|
2737
|
+
}
|
|
2738
|
+
if (modules.confirming === item.id && !blocked) {
|
|
2739
|
+
const confirm = el('div', 'confirm');
|
|
2740
|
+
confirm.append(el('span', 'warn', 'THIS WRITES INTO THE PROJECT. MADRE WILL RUN, IN THE PROJECT FOLDER:'));
|
|
2741
|
+
confirm.append(commandBlock([item.install.display]));
|
|
2742
|
+
confirm.append(el('span', 'note', item.install.platforms?.length
|
|
2743
|
+
? `IDE adapters for the agents detected here: ${item.install.platforms.join(', ')}.`
|
|
2744
|
+
: 'No detected agent has an adapter for this module; it installs without IDE adapters.'));
|
|
2745
|
+
const row = el('div', 'actions');
|
|
2746
|
+
const go = el('button', 'primary', 'CONFIRM INSTALL');
|
|
2747
|
+
go.type = 'button';
|
|
2748
|
+
go.addEventListener('click', async () => {
|
|
2749
|
+
go.disabled = true;
|
|
2750
|
+
const response = await fetch(`/api/extensions/${item.id}/install`, {
|
|
2751
|
+
method: 'POST',
|
|
2752
|
+
headers: { 'content-type': 'application/json' },
|
|
2753
|
+
body: JSON.stringify({ confirm: true }),
|
|
2754
|
+
});
|
|
2755
|
+
const result = await response.json().catch(() => ({}));
|
|
2756
|
+
if (!response.ok) toast(result.error ?? `Install request failed (${response.status}).`);
|
|
2757
|
+
modules.confirming = null;
|
|
2758
|
+
modules.installing = response.ok ? item.id : modules.installing;
|
|
2759
|
+
modules.logs.set(item.id, []);
|
|
2760
|
+
renderModules();
|
|
2761
|
+
});
|
|
2762
|
+
const cancel = el('button', null, 'CANCEL');
|
|
2763
|
+
cancel.type = 'button';
|
|
2764
|
+
cancel.addEventListener('click', () => { modules.confirming = null; renderModules(); });
|
|
2765
|
+
row.append(go, cancel);
|
|
2766
|
+
confirm.append(row);
|
|
2767
|
+
card.append(confirm);
|
|
2768
|
+
} else {
|
|
2769
|
+
const install = el('button', item.status?.installed ? null : 'primary', item.status?.installed ? 'REINSTALL / UPGRADE' : 'INSTALL');
|
|
2770
|
+
install.type = 'button';
|
|
2771
|
+
install.disabled = Boolean(modules.installing) || blocked;
|
|
2772
|
+
install.addEventListener('click', () => { modules.confirming = item.id; renderModules(); });
|
|
2773
|
+
actions.append(install);
|
|
2774
|
+
if (modules.installing && modules.installing !== item.id) actions.append(el('span', 'note', 'ANOTHER INSTALL IS RUNNING'));
|
|
2775
|
+
card.append(actions);
|
|
2776
|
+
}
|
|
2777
|
+
const log = modules.logs.get(item.id) ?? [];
|
|
2778
|
+
if (log.length || running) {
|
|
2779
|
+
const box = el('div', 'module-log');
|
|
2780
|
+
box.id = `module-log-${item.id}`;
|
|
2781
|
+
for (const line of log) box.append(el('div', /error|failed|ERR/i.test(line) ? 'err' : null, line));
|
|
2782
|
+
card.append(box);
|
|
2783
|
+
queueMicrotask(() => { box.scrollTop = box.scrollHeight; });
|
|
2784
|
+
}
|
|
2785
|
+
return card;
|
|
2786
|
+
}
|
|
2787
|
+
|
|
2788
|
+
function renderModules() {
|
|
2789
|
+
modules.list.replaceChildren();
|
|
2790
|
+
modules.list.append(el('h3', null, `AVAILABLE · ${modules.items.length} · PROJECT /${els.project.textContent}`));
|
|
2791
|
+
const grid = el('div', 'mother-grid');
|
|
2792
|
+
for (const item of modules.items) grid.append(moduleCard(item));
|
|
2793
|
+
modules.list.append(grid);
|
|
2794
|
+
}
|
|
2795
|
+
|
|
2796
|
+
modules.button.addEventListener('click', async () => {
|
|
2797
|
+
if (modules.dialog.open) return;
|
|
2798
|
+
modules.dialog.showModal();
|
|
2799
|
+
await refreshModules();
|
|
2800
|
+
renderModules();
|
|
2801
|
+
});
|
|
2802
|
+
modules.close.addEventListener('click', () => modules.dialog.close());
|
|
2803
|
+
void refreshModules();
|
|
2804
|
+
|
|
2805
|
+
/* ---------- agent spheres: click to expand session usage ---------- */
|
|
2806
|
+
|
|
2807
|
+
const pop = document.querySelector('#agent-pop');
|
|
2808
|
+
function statsFor(id) {
|
|
2809
|
+
if (!state.agentStats.has(id)) state.agentStats.set(id, { turns: 0, lastTurnMs: null, lastTurnTokens: null, cost: 0, started: new Map() });
|
|
2810
|
+
return state.agentStats.get(id);
|
|
2811
|
+
}
|
|
2812
|
+
function trackStats(event) {
|
|
2813
|
+
if (event.type === 'agent.started') {
|
|
2814
|
+
statsFor(event.payload.agent).started.set(event.payload.messageId, new Date(event.timestamp).getTime());
|
|
2815
|
+
} else if (event.type === 'agent.completed' || event.type === 'message.failed') {
|
|
2816
|
+
const stats = statsFor(event.payload.agent ?? event.payload.target);
|
|
2817
|
+
const t0 = stats.started.get(event.payload.messageId);
|
|
2818
|
+
if (t0) { stats.lastTurnMs = new Date(event.timestamp).getTime() - t0; stats.started.delete(event.payload.messageId); }
|
|
2819
|
+
stats.turns += 1;
|
|
2820
|
+
} else if (event.type === 'usage.recorded') {
|
|
2821
|
+
const stats = statsFor(event.payload.agent);
|
|
2822
|
+
stats.lastTurnTokens = event.payload.usage?.totalTokens ?? null;
|
|
2823
|
+
if (Number.isFinite(event.payload.usage?.costUsd)) stats.cost += event.payload.usage.costUsd;
|
|
2824
|
+
}
|
|
2825
|
+
}
|
|
2826
|
+
|
|
2827
|
+
function fmtMs(ms) { return ms === null ? '—' : ms < 1000 ? `${ms}ms` : `${Math.round(ms / 1000)}s`; }
|
|
2828
|
+
|
|
2829
|
+
const CAP_LABELS = { read: 'read', imageIn: 'image in', write: 'create', imageGen: 'image gen', web: 'web' };
|
|
2830
|
+
function capabilityBadges(id) {
|
|
2831
|
+
const caps = state.capabilities[id] ?? {};
|
|
2832
|
+
const wrap = el('div', 'caps');
|
|
2833
|
+
wrap.style.marginTop = '10px';
|
|
2834
|
+
for (const [key, labelText] of Object.entries(CAP_LABELS)) {
|
|
2835
|
+
const badge = el('span', `cap${caps[key] ? ' on' : ''}`, labelText);
|
|
2836
|
+
const detail = caps.detail?.[key];
|
|
2837
|
+
badge.title = caps[key] ? `${labelText}: ${detail?.how ?? 'available'}${detail?.note ? ` · ${detail.note}` : ''}` : `${labelText}: not available from this CLI`;
|
|
2838
|
+
wrap.append(badge);
|
|
2839
|
+
}
|
|
2840
|
+
return wrap;
|
|
2841
|
+
}
|
|
2842
|
+
|
|
2843
|
+
function openAgentPop(id, anchor) {
|
|
2844
|
+
const agent = state.agents.get(id);
|
|
2845
|
+
if (!agent) return;
|
|
2846
|
+
const stats = statsFor(id);
|
|
2847
|
+
const session = state.sessions?.[id];
|
|
2848
|
+
const percent = localPercent(agent);
|
|
2849
|
+
const official = liveOfficial(agent);
|
|
2850
|
+
const shown = ringPercent(agent);
|
|
2851
|
+
paint(pop, id);
|
|
2852
|
+
pop.replaceChildren();
|
|
2853
|
+
const head = el('div', 'head');
|
|
2854
|
+
head.append(avatar(id, { size: 24 }));
|
|
2855
|
+
head.append(el('b', null, agent.label));
|
|
2856
|
+
head.append(el('span', 'vendor', brandOf(id).vendor));
|
|
2857
|
+
pop.append(head);
|
|
2858
|
+
const big = el('div', 'big', official === null ? (formatTokens(agent.tokens ?? 0) || '0') : `${Math.round(official)}%`);
|
|
2859
|
+
big.append(el('small', null, official === null ? 'budget tokens · local 5h window' : `of the provider's ${fmtWindow(agent.officialWindows?.primary?.windowMinutes ?? 300)} limit${agent.officialResetAt ? ` · resets ${fmtReset(agent.officialResetAt)}` : ' · window reset'}`));
|
|
2860
|
+
pop.append(big);
|
|
2861
|
+
const gauge = el('div', `gauge${shown >= 80 ? ' hot' : ''}`);
|
|
2862
|
+
const fill = el('i'); fill.style.width = `${shown}%`; gauge.append(fill);
|
|
2863
|
+
pop.append(gauge);
|
|
2864
|
+
const dl = el('dl');
|
|
2865
|
+
const row = (k, v, cls) => { dl.append(el('dt', null, k)); dl.append(el('dd', cls, v)); };
|
|
2866
|
+
if (agent.officialWindows) {
|
|
2867
|
+
const { primary, secondary } = agent.officialWindows;
|
|
2868
|
+
if (primary) row(`${fmtWindow(primary.windowMinutes)} limit`, primary.resetAt ? `${Math.round(primary.usedPercent)}% · resets ${fmtReset(primary.resetAt)}` : `${Math.round(primary.usedPercent)}% · reset, awaiting fresh data`, primary.usedPercent >= 80 ? 'off' : null);
|
|
2869
|
+
if (secondary) row(`${fmtWindow(secondary.windowMinutes)} limit`, `${Math.round(secondary.usedPercent)}%${secondary.resetAt ? ` · resets ${fmtReset(secondary.resetAt)}` : ''}`, secondary.usedPercent >= 80 ? 'off' : null);
|
|
2870
|
+
if (agent.officialObservedAt) row('reported', `${fmtReset(agent.officialObservedAt)} by ${agent.officialSource?.replace('official:', '') ?? 'the CLI'}`);
|
|
2871
|
+
} else if (Number.isFinite(agent.officialPercent)) {
|
|
2872
|
+
row('provider limit', `${Math.round(official ?? 0)}% used`);
|
|
2873
|
+
} else {
|
|
2874
|
+
row('provider limit', 'not published by this CLI · ring shows the local window');
|
|
2875
|
+
}
|
|
2876
|
+
row('local window', state.budget ? `${Math.round(percent)}% of ${formatTokens(state.budget)} · 5h rolling${agent.rollsOverAt ? ` · oldest turn drops ${fmtReset(agent.rollsOverAt)}` : ''}` : 'unbounded');
|
|
2877
|
+
if (agent.rawTokens) row('all-time in room', `${formatTokens(agent.rawTokens)} tok`);
|
|
2878
|
+
row('turns', String(stats.turns));
|
|
2879
|
+
row('last turn', `${fmtMs(stats.lastTurnMs)}${stats.lastTurnTokens ? ` · ${formatTokens(stats.lastTurnTokens)} tok` : ''}`);
|
|
2880
|
+
if (stats.cost > 0) row('cost (reported)', `$${stats.cost.toFixed(2)}`);
|
|
2881
|
+
row('session', session ? session.state.replace('-', ' ') : agent.ready ? 'unknown' : 'not ready', session?.state === 'signed-in' ? 'on' : session?.state === 'signed-out' ? 'off' : null);
|
|
2882
|
+
if (session?.detail) row('via', session.detail);
|
|
2883
|
+
row('timeout', `${Math.round((state.timeouts[id] ?? 180000) / 1000)}s`);
|
|
2884
|
+
if (agent.version) row('version', agent.version);
|
|
2885
|
+
pop.append(dl);
|
|
2886
|
+
pop.append(capabilityBadges(id));
|
|
2887
|
+
pop.append(el('div', 'foot', official === null ? 'local window, not the provider\'s bill · ⚙ connections in MU/TH/UR' : 'provider limit as the CLI reports it · ⚙ connections in MU/TH/UR'));
|
|
2888
|
+
pop.hidden = false;
|
|
2889
|
+
const rect = anchor.getBoundingClientRect();
|
|
2890
|
+
const width = pop.offsetWidth || 260;
|
|
2891
|
+
pop.style.top = `${rect.bottom + 10}px`;
|
|
2892
|
+
pop.style.left = `${Math.max(12, Math.min(window.innerWidth - width - 12, rect.left + rect.width / 2 - width / 2))}px`;
|
|
2893
|
+
document.querySelectorAll('.agents .avatar.open').forEach((node) => node.classList.remove('open'));
|
|
2894
|
+
anchor.classList.add('open');
|
|
2895
|
+
pop.dataset.for = id;
|
|
2896
|
+
}
|
|
2897
|
+
function closeAgentPop() {
|
|
2898
|
+
pop.hidden = true;
|
|
2899
|
+
delete pop.dataset.for;
|
|
2900
|
+
document.querySelectorAll('.agents .avatar.open').forEach((node) => node.classList.remove('open'));
|
|
2901
|
+
}
|
|
2902
|
+
els.agents.addEventListener('click', (event) => {
|
|
2903
|
+
const sphere = event.target.closest('.avatar');
|
|
2904
|
+
if (!sphere) return;
|
|
2905
|
+
const id = sphere.dataset.agent;
|
|
2906
|
+
if (pop.dataset.for === id && !pop.hidden) closeAgentPop(); else openAgentPop(id, sphere);
|
|
2907
|
+
});
|
|
2908
|
+
document.addEventListener('click', (event) => { if (!pop.hidden && !pop.contains(event.target) && !event.target.closest('.agents')) closeAgentPop(); });
|
|
2909
|
+
document.addEventListener('keydown', (event) => { if (event.key === 'Escape') closeAgentPop(); });
|
|
2910
|
+
|
|
2911
|
+
/* ---------- ⚙ connections & settings inside MU/TH/UR ---------- */
|
|
2912
|
+
|
|
2913
|
+
const settingsUI = {
|
|
2914
|
+
button: document.querySelector('#mother-settings-button'),
|
|
2915
|
+
section: document.querySelector('#mother-settings'),
|
|
2916
|
+
data: null,
|
|
2917
|
+
open: false,
|
|
2918
|
+
loginLogs: new Map(),
|
|
2919
|
+
};
|
|
2920
|
+
|
|
2921
|
+
function renderLoginEvent(event) {
|
|
2922
|
+
const { agent, command, session, code, error } = event.payload;
|
|
2923
|
+
const finished = event.type === 'connection.login.finished';
|
|
2924
|
+
const ok = finished && session?.state === 'signed-in';
|
|
2925
|
+
const node = el('div', `system login${finished && !ok ? ' failed' : ''}`);
|
|
2926
|
+
node.style.setProperty('--agent', agentColor(agent));
|
|
2927
|
+
node.append(el('b', null, 'connections › '));
|
|
2928
|
+
node.append(finished
|
|
2929
|
+
? (ok ? `@${agent} signed in${session?.detail ? ` · ${session.detail}` : ''}` : `@${agent} sign-in did not complete${error ? ` · ${error}` : code ? ` · exit ${code}` : ''}`)
|
|
2930
|
+
: `signing in @${agent} · ${command}`);
|
|
2931
|
+
if (finished) { state.sessions[agent] = session ?? state.sessions[agent]; if (state.settingsOpen) void loadSettings(); }
|
|
2932
|
+
state.lastSender = null;
|
|
2933
|
+
return node;
|
|
2934
|
+
}
|
|
2935
|
+
function appendLoginOutput(event) {
|
|
2936
|
+
const { agent, line, url } = event.payload;
|
|
2937
|
+
const log = state.loginLogs.get(agent) ?? [];
|
|
2938
|
+
log.push({ line, url });
|
|
2939
|
+
state.loginLogs.set(agent, log.slice(-60));
|
|
2940
|
+
const box = document.getElementById(`login-log-${agent}`);
|
|
2941
|
+
if (box) { box.append(loginLine({ line, url })); box.scrollTop = box.scrollHeight; }
|
|
2942
|
+
if (url) toast(`@${agent}: open ${url} to finish signing in`);
|
|
2943
|
+
}
|
|
2944
|
+
function loginLine({ line, url }) {
|
|
2945
|
+
const div = el('div');
|
|
2946
|
+
if (url) {
|
|
2947
|
+
const a = el('a', null, line); a.href = url; a.target = '_blank'; a.rel = 'noopener noreferrer';
|
|
2948
|
+
div.append(a);
|
|
2949
|
+
} else div.append(line);
|
|
2950
|
+
return div;
|
|
2951
|
+
}
|
|
2952
|
+
|
|
2953
|
+
async function loadSettings() {
|
|
2954
|
+
settingsUI.data = await fetch('/api/settings').then((response) => response.json());
|
|
2955
|
+
state.sessions = settingsUI.data.sessions ?? state.sessions;
|
|
2956
|
+
renderSettings();
|
|
2957
|
+
}
|
|
2958
|
+
|
|
2959
|
+
function connectionCard(agent) {
|
|
2960
|
+
const card = paint(el('article', 'conn-card'), agent.id);
|
|
2961
|
+
const session = settingsUI.data.sessions?.[agent.id];
|
|
2962
|
+
const head = el('div', 'head');
|
|
2963
|
+
head.append(avatar(agent.id, { size: 26, status: agent.ready ? 'ready' : agent.detected ? 'detected' : 'offline' }));
|
|
2964
|
+
const title = el('div');
|
|
2965
|
+
title.append(el('h4', null, agent.label));
|
|
2966
|
+
title.append(el('div', 'vendor', brandOf(agent.id).vendor));
|
|
2967
|
+
head.append(title);
|
|
2968
|
+
const busy = settingsUI.data.loggingIn === agent.id;
|
|
2969
|
+
const tag = el('span', `session${busy ? ' busy' : session?.state === 'signed-in' ? ' on' : session?.state === 'signed-out' ? ' off' : ''}`,
|
|
2970
|
+
busy ? 'SIGNING IN' : !agent.detected ? 'NOT INSTALLED' : session ? session.state.toUpperCase().replace('-', ' ') : 'UNKNOWN');
|
|
2971
|
+
head.append(tag);
|
|
2972
|
+
card.append(head);
|
|
2973
|
+
const meta = el('div', 'meta');
|
|
2974
|
+
meta.append(agent.detected ? `${agent.version ?? 'version unknown'} · ${agent.path}` : (agent.login?.install ?? []).join(' · '));
|
|
2975
|
+
if (session?.detail) meta.append(el('div', null, `session: ${session.detail}`));
|
|
2976
|
+
card.append(meta);
|
|
2977
|
+
const scopes = el('div', 'scopes');
|
|
2978
|
+
const agentScopes = settingsUI.data.settings.capabilities?.[agent.id]?.scopes ?? {};
|
|
2979
|
+
for (const [key, labelText] of [['write', 'CREATE FILES'], ['imageGen', 'GENERATE IMAGES'], ['web', 'WEB ACCESS']]) {
|
|
2980
|
+
const scope = agentScopes[key] ?? { capable: false, enabled: false, wired: false };
|
|
2981
|
+
const line = el('label', `scope${!scope.capable || !scope.wired ? ' unavailable' : ''}`);
|
|
2982
|
+
const box = el('input'); box.type = 'checkbox'; box.checked = Boolean(scope.enabled); box.disabled = !scope.capable || !scope.wired;
|
|
2983
|
+
box.dataset.agent = agent.id; box.dataset.scope = key; box.className = 'scope-input';
|
|
2984
|
+
// Scopes apply the moment they are ticked; no need to find the SAVE button below.
|
|
2985
|
+
box.addEventListener('change', async () => {
|
|
2986
|
+
box.disabled = true;
|
|
2987
|
+
try {
|
|
2988
|
+
const response = await fetch('/api/settings', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ scopes: { [agent.id]: { [key]: box.checked } } }) });
|
|
2989
|
+
const result = await response.json().catch(() => ({}));
|
|
2990
|
+
if (!response.ok) throw new Error(result.error ?? `HTTP ${response.status}`);
|
|
2991
|
+
if (result.settings?.capabilities) { state.capabilities = result.settings.capabilities; settingsUI.data.settings.capabilities = result.settings.capabilities; renderCreateScopes(); }
|
|
2992
|
+
const why = line.querySelector('.why');
|
|
2993
|
+
if (why) why.textContent = box.checked ? (key === 'web' ? 'on for every turn' : 'on for CREATE') : 'off';
|
|
2994
|
+
toast(`MU/TH/UR › @${agent.id} ${labelText.toLowerCase()} ${box.checked ? 'on' : 'off'}. ${key === 'web' ? 'Applies to its next turn.' : 'Applies to its next CREATE.'}`);
|
|
2995
|
+
} catch (error) {
|
|
2996
|
+
box.checked = !box.checked;
|
|
2997
|
+
toast(`Scope was not saved: ${error.message}`);
|
|
2998
|
+
} finally {
|
|
2999
|
+
box.disabled = false;
|
|
3000
|
+
}
|
|
3001
|
+
});
|
|
3002
|
+
line.append(box, labelText);
|
|
3003
|
+
line.append(el('span', 'why', !scope.capable ? 'not available from this CLI' : !scope.wired ? 'not wired yet' : scope.enabled ? (key === 'web' ? 'on for every turn' : 'on for CREATE') : 'off'));
|
|
3004
|
+
line.title = !scope.capable ? `${agent.label}'s CLI has no way to do this; MU/TH/UR knows the routes.` : '';
|
|
3005
|
+
if (!scope.capable) {
|
|
3006
|
+
const assist = el('button', 'assist', 'ask MU/TH/UR');
|
|
3007
|
+
assist.type = 'button';
|
|
3008
|
+
assist.title = `How could @${agent.id} get "${labelText.toLowerCase()}"?`;
|
|
3009
|
+
assist.addEventListener('click', (event) => { event.preventDefault(); askMotherAbout(`scope-${agent.id}-${key}`); });
|
|
3010
|
+
line.append(assist);
|
|
3011
|
+
}
|
|
3012
|
+
scopes.append(line);
|
|
3013
|
+
if (key === 'write' && scope.capable) {
|
|
3014
|
+
const always = el('label', `scope sub${scope.enabled ? '' : ' unavailable'}`);
|
|
3015
|
+
const alwaysBox = el('input'); alwaysBox.type = 'checkbox'; alwaysBox.checked = Boolean(scope.always); alwaysBox.disabled = !scope.enabled;
|
|
3016
|
+
alwaysBox.dataset.agent = agent.id; alwaysBox.dataset.scope = 'alwaysCreate'; alwaysBox.className = 'scope-input';
|
|
3017
|
+
alwaysBox.addEventListener('change', async () => {
|
|
3018
|
+
alwaysBox.disabled = true;
|
|
3019
|
+
try {
|
|
3020
|
+
const response = await fetch('/api/settings', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ scopes: { [agent.id]: { alwaysCreate: alwaysBox.checked } } }) });
|
|
3021
|
+
const result = await response.json().catch(() => ({}));
|
|
3022
|
+
if (!response.ok) throw new Error(result.error ?? `HTTP ${response.status}`);
|
|
3023
|
+
if (result.settings?.capabilities) { state.capabilities = result.settings.capabilities; settingsUI.data.settings.capabilities = result.settings.capabilities; renderCreateScopes(); }
|
|
3024
|
+
toast(`MU/TH/UR › @${agent.id} ${alwaysBox.checked ? 'now holds a standing lease: every turn may create files in .pulse/out/, including plan steps.' : 'creates files only when you arm CREATE.'}`);
|
|
3025
|
+
} catch (error) {
|
|
3026
|
+
alwaysBox.checked = !alwaysBox.checked;
|
|
3027
|
+
toast(`Scope was not saved: ${error.message}`);
|
|
3028
|
+
} finally {
|
|
3029
|
+
alwaysBox.disabled = !scope.enabled;
|
|
3030
|
+
}
|
|
3031
|
+
});
|
|
3032
|
+
always.append(alwaysBox, 'ALWAYS · STANDING LEASE');
|
|
3033
|
+
always.append(el('span', 'why', scope.always ? 'every turn and plan step may create files' : 'only when CREATE is armed'));
|
|
3034
|
+
always.title = 'Skip arming CREATE: this agent gets a fresh .pulse/out/ directory on every turn, also when another agent delegates to it.';
|
|
3035
|
+
scopes.append(always);
|
|
3036
|
+
}
|
|
3037
|
+
}
|
|
3038
|
+
// The ceiling: how far the composer may take this agent.
|
|
3039
|
+
const ceiling = el('div', 'ceiling');
|
|
3040
|
+
ceiling.append(el('span', 'k', 'MAX MODE'));
|
|
3041
|
+
const seg = el('div', 'seg');
|
|
3042
|
+
const currentCap = agentScopes.maxMode ?? 1;
|
|
3043
|
+
for (const n of [0, 1, 2, 3]) {
|
|
3044
|
+
const button = el('button', `seg-option o${n}${currentCap === n ? ' current' : ''}`, `#${n}`);
|
|
3045
|
+
button.type = 'button';
|
|
3046
|
+
button.title = `${MODES[n].label} · ${MODES[n].hint}`;
|
|
3047
|
+
const possible = n <= 1 || agentScopes.write?.capable;
|
|
3048
|
+
button.disabled = !possible;
|
|
3049
|
+
button.addEventListener('click', async () => {
|
|
3050
|
+
if (n === currentCap) return;
|
|
3051
|
+
for (const other of seg.children) other.disabled = true;
|
|
3052
|
+
try {
|
|
3053
|
+
await saveSettingNow({ scopes: { [agent.id]: { maxMode: n, ...(n >= 2 ? { write: true } : {}) } } }, `@${agent.id} is now capped at #${n} ${MODES[n].label}.${n === 3 ? ' CONTROL still needs the override per message.' : ''}`);
|
|
3054
|
+
await loadSettings();
|
|
3055
|
+
} catch (error) { toast(`Max mode was not saved: ${error.message}`); for (const other of seg.children) other.disabled = false; }
|
|
3056
|
+
});
|
|
3057
|
+
seg.append(button);
|
|
3058
|
+
}
|
|
3059
|
+
ceiling.append(seg);
|
|
3060
|
+
ceiling.append(el('span', 'why', `${MODES[currentCap].label} · ${MODES[currentCap].hint}`));
|
|
3061
|
+
scopes.append(ceiling);
|
|
3062
|
+
card.append(scopes);
|
|
3063
|
+
|
|
3064
|
+
const row = el('div', 'row');
|
|
3065
|
+
const recheck = el('button', null, 'RECHECK');
|
|
3066
|
+
recheck.type = 'button';
|
|
3067
|
+
recheck.addEventListener('click', async () => {
|
|
3068
|
+
recheck.disabled = true;
|
|
3069
|
+
const result = await fetch('/api/agents/probe', { method: 'POST' }).then((response) => response.json()).catch(() => null);
|
|
3070
|
+
if (result?.sessions) { settingsUI.data.sessions = result.sessions; state.sessions = result.sessions; renderSettings(); }
|
|
3071
|
+
recheck.disabled = false;
|
|
3072
|
+
});
|
|
3073
|
+
row.append(recheck);
|
|
3074
|
+
if (agent.detected && agent.login) {
|
|
3075
|
+
if (agent.login.headless) {
|
|
3076
|
+
const login = el('button', 'primary', session?.state === 'signed-in' ? 'SIGN IN AGAIN' : 'SIGN IN');
|
|
3077
|
+
login.type = 'button';
|
|
3078
|
+
login.disabled = Boolean(settingsUI.data.loggingIn);
|
|
3079
|
+
login.title = agent.login.note;
|
|
3080
|
+
login.addEventListener('click', async () => {
|
|
3081
|
+
login.disabled = true;
|
|
3082
|
+
state.loginLogs.set(agent.id, []);
|
|
3083
|
+
const response = await fetch(`/api/agents/${agent.id}/login`, { method: 'POST' });
|
|
3084
|
+
const result = await response.json().catch(() => ({}));
|
|
3085
|
+
if (!response.ok) toast(result.error ?? 'Sign-in could not start.');
|
|
3086
|
+
else { settingsUI.data.loggingIn = agent.id; renderSettings(); }
|
|
3087
|
+
});
|
|
3088
|
+
row.append(login);
|
|
3089
|
+
} else {
|
|
3090
|
+
row.append(el('span', 'note', 'signs in from its own prompt:'));
|
|
3091
|
+
}
|
|
3092
|
+
}
|
|
3093
|
+
card.append(row);
|
|
3094
|
+
const slot = el('div', 'slot');
|
|
3095
|
+
if (agent.detected && agent.login && !agent.login.headless) slot.append(commandBlock([agent.login.display, `# ${agent.login.note}`]));
|
|
3096
|
+
card.append(slot);
|
|
3097
|
+
|
|
3098
|
+
const timeout = el('label');
|
|
3099
|
+
timeout.append(`TIMEOUT · SECONDS`);
|
|
3100
|
+
const input = el('input');
|
|
3101
|
+
input.type = 'number'; input.min = '10'; input.step = '10';
|
|
3102
|
+
input.value = String(Math.round((settingsUI.data.settings.timeouts[agent.id] ?? 180000) / 1000));
|
|
3103
|
+
input.dataset.agent = agent.id;
|
|
3104
|
+
input.className = 'timeout-input';
|
|
3105
|
+
wireInstantNumber(input, { min: 10, toPatch: (seconds) => ({ timeouts: { [agent.id]: seconds * 1000 } }), describe: (seconds) => `@${agent.id} timeout saved: ${seconds}s.` });
|
|
3106
|
+
timeout.append(input);
|
|
3107
|
+
card.append(timeout);
|
|
3108
|
+
|
|
3109
|
+
const log = state.loginLogs.get(agent.id) ?? [];
|
|
3110
|
+
if (log.length || busy) {
|
|
3111
|
+
const box = el('div', 'login-log');
|
|
3112
|
+
box.id = `login-log-${agent.id}`;
|
|
3113
|
+
for (const entry of log) box.append(loginLine(entry));
|
|
3114
|
+
slot.append(box);
|
|
3115
|
+
}
|
|
3116
|
+
return card;
|
|
3117
|
+
}
|
|
3118
|
+
|
|
3119
|
+
function renderSettings() {
|
|
3120
|
+
const { data } = settingsUI;
|
|
3121
|
+
if (!data) return;
|
|
3122
|
+
const section = settingsUI.section;
|
|
3123
|
+
section.replaceChildren();
|
|
3124
|
+
section.append(el('h3', null, `CONNECTIONS · ${Object.values(data.sessions ?? {}).filter((s) => s.state === 'signed-in').length} OF ${data.agents.length} SIGNED IN${data.sessionsAt ? ` · CHECKED ${formatTime(data.sessionsAt)}` : ''}`));
|
|
3125
|
+
section.append(el('p', 'note', 'EACH AGENT KEEPS ITS OWN CREDENTIALS IN ITS OWN CLI. MADRE ONLY ASKS THE CLI WHETHER IT IS SIGNED IN, AND CAN START THE CLI\'S OWN SIGN-IN FOR YOU.'));
|
|
3126
|
+
const grid = el('div', 'conn-grid');
|
|
3127
|
+
for (const agent of data.agents) grid.append(connectionCard(agent));
|
|
3128
|
+
section.append(grid);
|
|
3129
|
+
|
|
3130
|
+
section.append(el('h3', null, 'ROOM SETTINGS'));
|
|
3131
|
+
const form = el('form', 'room-form');
|
|
3132
|
+
const field = (labelText, node) => { const label = el('label'); label.append(labelText); label.append(node); return label; };
|
|
3133
|
+
const num = (name, value, min, step) => { const input = el('input'); input.type = 'number'; input.name = name; input.value = String(value); input.min = String(min); input.step = String(step); return input; };
|
|
3134
|
+
const budget = num('softTokenBudget', data.settings.softTokenBudget, 10000, 10000);
|
|
3135
|
+
const steps = num('maxPlanSteps', data.settings.maxPlanSteps, 1, 1);
|
|
3136
|
+
const defaultTimeout = num('defaultTimeout', Math.round(data.settings.defaultTimeout / 1000), 10, 10);
|
|
3137
|
+
wireInstantNumber(budget, { min: 10000, toPatch: (value) => ({ room: { softTokenBudget: value } }), describe: (value) => `local budget saved: ${formatTokens(value)} tokens per agent per 5h window.` });
|
|
3138
|
+
wireInstantNumber(defaultTimeout, { min: 10, toPatch: (seconds) => ({ timeouts: { default: seconds * 1000 } }), describe: (seconds) => `default timeout saved: ${seconds}s.` });
|
|
3139
|
+
const idle = num('geminiIdle', Math.round(data.settings.geminiIdleMs / 1000), 10, 10);
|
|
3140
|
+
const retries = num('geminiRetries', data.settings.geminiRetries, 0, 1);
|
|
3141
|
+
const model = el('input'); model.name = 'opencodeModel'; model.value = data.settings.opencodeModel ?? ''; model.placeholder = 'provider/model'; model.setAttribute('list', 'opencode-models');
|
|
3142
|
+
const datalist = el('datalist'); datalist.id = 'opencode-models';
|
|
3143
|
+
form.append(field('LOCAL TOKEN BUDGET PER AGENT', budget));
|
|
3144
|
+
form.append(field('DEFAULT TIMEOUT · SECONDS', defaultTimeout));
|
|
3145
|
+
form.append(field('MAX PLAN STEPS', steps));
|
|
3146
|
+
form.append(field('GEMINI SILENCE LIMIT · SECONDS', idle));
|
|
3147
|
+
form.append(field('GEMINI RETRIES', retries));
|
|
3148
|
+
const modelLabel = field('OPENCODE MODEL IN THIS ROOM', model);
|
|
3149
|
+
modelLabel.append(datalist);
|
|
3150
|
+
form.append(modelLabel);
|
|
3151
|
+
const full = el('div', 'full');
|
|
3152
|
+
const toggle = el('label', 'toggle');
|
|
3153
|
+
const delegation = el('input'); delegation.type = 'checkbox'; delegation.name = 'delegation'; delegation.checked = data.settings.delegation;
|
|
3154
|
+
toggle.append(delegation, 'AGENTS MAY DELEGATE TURNS TO EACH OTHER');
|
|
3155
|
+
full.append(toggle);
|
|
3156
|
+
const loadModels = el('button', null, 'LIST OPENCODE MODELS');
|
|
3157
|
+
loadModels.type = 'button';
|
|
3158
|
+
loadModels.addEventListener('click', async () => {
|
|
3159
|
+
loadModels.disabled = true;
|
|
3160
|
+
const result = await fetch('/api/agents/opencode/models').then((response) => response.json()).catch(() => ({ models: [] }));
|
|
3161
|
+
datalist.replaceChildren();
|
|
3162
|
+
for (const name of result.models ?? []) { const option = el('option'); option.value = name; datalist.append(option); }
|
|
3163
|
+
loadModels.textContent = `${(result.models ?? []).length} MODELS LISTED`;
|
|
3164
|
+
});
|
|
3165
|
+
full.append(loadModels);
|
|
3166
|
+
const save = el('button', 'primary', 'SAVE TO ~/.pulse/config.json');
|
|
3167
|
+
save.type = 'submit';
|
|
3168
|
+
full.append(save);
|
|
3169
|
+
full.append(el('span', 'note', 'ENVIRONMENT VARIABLES SET BEFORE START STILL WIN ON THE NEXT LAUNCH.'));
|
|
3170
|
+
form.append(full);
|
|
3171
|
+
form.addEventListener('submit', async (event) => {
|
|
3172
|
+
event.preventDefault();
|
|
3173
|
+
save.disabled = true;
|
|
3174
|
+
const timeouts = { default: Number(defaultTimeout.value) * 1000 };
|
|
3175
|
+
for (const input of section.querySelectorAll('.timeout-input')) {
|
|
3176
|
+
const seconds = Number(input.value);
|
|
3177
|
+
if (seconds > 0 && seconds * 1000 !== timeouts.default) timeouts[input.dataset.agent] = seconds * 1000;
|
|
3178
|
+
else timeouts[input.dataset.agent] = 0;
|
|
3179
|
+
}
|
|
3180
|
+
const scopePatch = {};
|
|
3181
|
+
for (const input of section.querySelectorAll('.scope-input')) {
|
|
3182
|
+
if (input.disabled) continue;
|
|
3183
|
+
scopePatch[input.dataset.agent] ??= {};
|
|
3184
|
+
scopePatch[input.dataset.agent][input.dataset.scope] = input.checked;
|
|
3185
|
+
}
|
|
3186
|
+
const payload = {
|
|
3187
|
+
opencode: { model: model.value.trim() },
|
|
3188
|
+
scopes: scopePatch,
|
|
3189
|
+
timeouts,
|
|
3190
|
+
room: { delegation: delegation.checked, maxPlanSteps: Number(steps.value), softTokenBudget: Number(budget.value) },
|
|
3191
|
+
gemini: { idleMs: Number(idle.value) * 1000, retries: Number(retries.value) },
|
|
3192
|
+
};
|
|
3193
|
+
const response = await fetch('/api/settings', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) });
|
|
3194
|
+
const result = await response.json().catch(() => ({}));
|
|
3195
|
+
if (response.ok) {
|
|
3196
|
+
state.timeouts = result.settings.timeouts;
|
|
3197
|
+
state.budget = result.settings.softTokenBudget;
|
|
3198
|
+
if (result.settings.capabilities) { state.capabilities = result.settings.capabilities; renderCreateScopes(); }
|
|
3199
|
+
toast('MU/TH/UR › settings saved. new turns use them now.');
|
|
3200
|
+
await loadSettings();
|
|
3201
|
+
} else toast(result.error ?? 'Settings were not saved.');
|
|
3202
|
+
save.disabled = false;
|
|
3203
|
+
});
|
|
3204
|
+
section.append(form);
|
|
3205
|
+
}
|
|
3206
|
+
|
|
3207
|
+
// Save one setting the moment it changes, the way the scope boxes do; the
|
|
3208
|
+
// SAVE button below stays for the fields that are not wired this way.
|
|
3209
|
+
async function saveSettingNow(patch, confirmation) {
|
|
3210
|
+
const response = await fetch('/api/settings', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(patch) });
|
|
3211
|
+
const result = await response.json().catch(() => ({}));
|
|
3212
|
+
if (!response.ok) throw new Error(result.error ?? `HTTP ${response.status}`);
|
|
3213
|
+
if (result.settings) {
|
|
3214
|
+
state.timeouts = result.settings.timeouts ?? state.timeouts;
|
|
3215
|
+
if (Number.isFinite(result.settings.softTokenBudget)) state.budget = result.settings.softTokenBudget;
|
|
3216
|
+
if (result.settings.capabilities) { state.capabilities = result.settings.capabilities; renderCreateScopes(); }
|
|
3217
|
+
if (settingsUI.data) settingsUI.data.settings = { ...settingsUI.data.settings, ...result.settings };
|
|
3218
|
+
}
|
|
3219
|
+
if (confirmation) toast(`MU/TH/UR › ${confirmation} Applies to the next turn.`);
|
|
3220
|
+
return result.settings ?? null;
|
|
3221
|
+
}
|
|
3222
|
+
// Number inputs commit on change (blur or Enter); revert and explain on failure.
|
|
3223
|
+
function wireInstantNumber(input, { toPatch, describe, min = 1 }) {
|
|
3224
|
+
let last = input.value;
|
|
3225
|
+
input.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); input.blur(); } });
|
|
3226
|
+
input.addEventListener('change', async () => {
|
|
3227
|
+
const value = Number(input.value);
|
|
3228
|
+
if (!Number.isFinite(value) || value < min) { input.value = last; toast(`MU/TH/UR › that value is not valid (minimum ${min}).`); return; }
|
|
3229
|
+
input.disabled = true;
|
|
3230
|
+
try { await saveSettingNow(toPatch(value), describe(value)); last = input.value; }
|
|
3231
|
+
catch (error) { input.value = last; toast(`Setting was not saved: ${error.message}`); }
|
|
3232
|
+
finally { input.disabled = false; }
|
|
3233
|
+
});
|
|
3234
|
+
}
|
|
3235
|
+
const nextTimeoutSeconds = (currentMs) => { const current = Math.round((currentMs ?? 180000) / 1000); return [300, 600, 900, 1200].find((step) => step > current) ?? current + 300; };
|
|
3236
|
+
// MU/TH/UR acts, not just advises: buttons that apply the fix through /api/settings.
|
|
3237
|
+
function conditionActions(condition, agent) {
|
|
3238
|
+
const actions = [];
|
|
3239
|
+
if (condition.id === 'timeout') {
|
|
3240
|
+
if (agent && state.agents.has(agent)) {
|
|
3241
|
+
const seconds = nextTimeoutSeconds(state.timeouts[agent]);
|
|
3242
|
+
actions.push({ label: `RAISE @${agent.toUpperCase()} TIMEOUT TO ${seconds}s`, patch: { timeouts: { [agent]: seconds * 1000 } }, done: `@${agent} timeout is now ${seconds}s.` });
|
|
3243
|
+
}
|
|
3244
|
+
const defaultMs = Math.min(...Object.values(state.timeouts ?? {}).filter(Number.isFinite), 180000);
|
|
3245
|
+
const seconds = nextTimeoutSeconds(defaultMs);
|
|
3246
|
+
actions.push({ label: `RAISE DEFAULT TIMEOUT TO ${seconds}s`, patch: { timeouts: { default: seconds * 1000 } }, done: `default timeout is now ${seconds}s for every agent without its own.` });
|
|
3247
|
+
}
|
|
3248
|
+
if (condition.id === 'budget-exhausted') {
|
|
3249
|
+
const budget = Math.max(1000000, (state.budget ?? 500000) * 2);
|
|
3250
|
+
actions.push({ label: `RAISE LOCAL BUDGET TO ${formatTokens(budget)}`, patch: { room: { softTokenBudget: budget } }, done: `local budget is now ${formatTokens(budget)} tokens per agent per 5h window.` });
|
|
3251
|
+
}
|
|
3252
|
+
return actions;
|
|
3253
|
+
}
|
|
3254
|
+
|
|
3255
|
+
function askMotherAbout(conditionId) {
|
|
3256
|
+
// Leave the settings view and put the matching MU/TH/UR card front and centre.
|
|
3257
|
+
if (settingsUI.open) settingsUI.button.click();
|
|
3258
|
+
mother.input.value = conditionId;
|
|
3259
|
+
answerQuery(conditionId);
|
|
3260
|
+
setTimeout(() => document.getElementById(`mother-${conditionId}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 50);
|
|
3261
|
+
}
|
|
3262
|
+
|
|
3263
|
+
|
|
3264
|
+
/* ---------- NOSTROMO: memory research. The archive as a solar system: the room is a red sun, every distilled memory a smoking planet, plasma between them. ---------- */
|
|
3265
|
+
|
|
3266
|
+
const MEMORY_COLORS = { decision: '#ffb000', fact: '#9bff66', preference: '#d78cff', question: '#5fd6ff' };
|
|
3267
|
+
const nostromo = {
|
|
3268
|
+
button: document.querySelector('#nostromo-button'),
|
|
3269
|
+
dialog: document.querySelector('#nostromo'),
|
|
3270
|
+
canvas: document.querySelector('#nostromo-canvas'),
|
|
3271
|
+
sub: document.querySelector('#nostromo-sub'),
|
|
3272
|
+
empty: document.querySelector('#nostromo-empty'),
|
|
3273
|
+
card: document.querySelector('#nostromo-card'),
|
|
3274
|
+
gate: {
|
|
3275
|
+
dialog: document.querySelector('#nostromo-gate'),
|
|
3276
|
+
form: document.querySelector('#nostromo-gate-form'),
|
|
3277
|
+
input: document.querySelector('#nostromo-gate-input'),
|
|
3278
|
+
reply: document.querySelector('#nostromo-gate-reply'),
|
|
3279
|
+
frame: document.querySelector('#nostromo-gate .override-frame'),
|
|
3280
|
+
cancel: document.querySelector('#nostromo-gate-cancel'),
|
|
3281
|
+
},
|
|
3282
|
+
armed: false, // the designation was typed once this page
|
|
3283
|
+
nodes: [],
|
|
3284
|
+
links: [],
|
|
3285
|
+
selected: null,
|
|
3286
|
+
hover: null,
|
|
3287
|
+
raf: null,
|
|
3288
|
+
last: 0,
|
|
3289
|
+
focusId: null, // a memory to open the card on, when boarding from a bubble hint
|
|
3290
|
+
size: { w: 0, h: 0, dpr: 1 },
|
|
3291
|
+
reduced: typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
|
3292
|
+
};
|
|
3293
|
+
|
|
3294
|
+
function nostromoDesignation() { return (state.projectRoot ?? '').split('/').filter(Boolean).pop() ?? ''; }
|
|
3295
|
+
|
|
3296
|
+
nostromo.button?.addEventListener('click', () => {
|
|
3297
|
+
if (nostromo.armed) { void openNostromo(); return; }
|
|
3298
|
+
if (!nostromo.gate.dialog) return;
|
|
3299
|
+
nostromo.gate.reply.textContent = '';
|
|
3300
|
+
nostromo.gate.reply.className = 'mother-answer override-reply';
|
|
3301
|
+
nostromo.gate.input.value = '';
|
|
3302
|
+
nostromo.gate.dialog.showModal();
|
|
3303
|
+
nostromo.gate.input.focus();
|
|
3304
|
+
});
|
|
3305
|
+
nostromo.gate.cancel?.addEventListener('click', () => nostromo.gate.dialog.close());
|
|
3306
|
+
nostromo.gate.form?.addEventListener('submit', (event) => {
|
|
3307
|
+
event.preventDefault();
|
|
3308
|
+
const typed = nostromo.gate.input.value.trim();
|
|
3309
|
+
if (!typed || typed.toLowerCase() !== nostromoDesignation().toLowerCase()) {
|
|
3310
|
+
nostromo.gate.reply.textContent = 'UNABLE TO COMPUTE. UNABLE TO CLARIFY.';
|
|
3311
|
+
nostromo.gate.reply.className = 'mother-answer override-reply denied';
|
|
3312
|
+
nostromo.gate.frame.classList.remove('shake'); void nostromo.gate.frame.offsetWidth; nostromo.gate.frame.classList.add('shake');
|
|
3313
|
+
nostromo.gate.input.select();
|
|
3314
|
+
return;
|
|
3315
|
+
}
|
|
3316
|
+
nostromo.gate.reply.textContent = 'DESIGNATION ACCEPTED. BOARDING NOSTROMO.';
|
|
3317
|
+
nostromo.gate.reply.className = 'mother-answer override-reply granted';
|
|
3318
|
+
nostromo.armed = true;
|
|
3319
|
+
setTimeout(() => { nostromo.gate.dialog.close(); void openNostromo(); }, 700);
|
|
3320
|
+
});
|
|
3321
|
+
|
|
3322
|
+
async function openNostromo() {
|
|
3323
|
+
if (!nostromo.dialog) return;
|
|
3324
|
+
mother.dialog?.close?.();
|
|
3325
|
+
nostromo.dialog.showModal();
|
|
3326
|
+
nostromo.sub.textContent = 'MEMORY RESEARCH · LOADING…';
|
|
3327
|
+
nostromo.card.hidden = true;
|
|
3328
|
+
nostromo.selected = null;
|
|
3329
|
+
nostromo.cage = null;
|
|
3330
|
+
const status = await fetch('/api/mother').then((response) => response.json()).catch(() => null);
|
|
3331
|
+
nostromo.altered = Boolean(status?.mother?.altered);
|
|
3332
|
+
nostromo.maxStrikes = status?.strikes ?? 8;
|
|
3333
|
+
let data;
|
|
3334
|
+
try {
|
|
3335
|
+
const response = await fetch(`/api/memory?designation=${encodeURIComponent(nostromoDesignation())}`);
|
|
3336
|
+
data = await response.json();
|
|
3337
|
+
if (!response.ok) throw new Error(data.error ?? `HTTP ${response.status}`);
|
|
3338
|
+
} catch (error) {
|
|
3339
|
+
nostromo.sub.textContent = `MEMORY RESEARCH · ${String(error.message).toUpperCase()}`;
|
|
3340
|
+
if ((status?.mother?.lockedForMs ?? 0) > 0) { buildNostromo({ memories: [], links: [], stats: {} }); nostromo.sub.textContent = `MEMORY RESEARCH · ${String(error.message).toUpperCase()}`; nostromo.empty.hidden = true; startNostromo(); nostromo.cage = { at: 0, closed: true }; }
|
|
3341
|
+
return;
|
|
3342
|
+
}
|
|
3343
|
+
buildNostromo(data);
|
|
3344
|
+
if (nostromo.altered) nostromo.sub.textContent += ' · MOTHER WAS TAMPERED WITH';
|
|
3345
|
+
startNostromo();
|
|
3346
|
+
if (nostromo.focusId != null) {
|
|
3347
|
+
const node = nostromo.nodes.find((item) => item.memory.id === nostromo.focusId);
|
|
3348
|
+
nostromo.focusId = null;
|
|
3349
|
+
if (node) showNostromoCard(node);
|
|
3350
|
+
}
|
|
3351
|
+
}
|
|
3352
|
+
document.querySelector('#nostromo-close')?.addEventListener('click', () => nostromo.dialog.close());
|
|
3353
|
+
nostromo.dialog?.addEventListener('close', stopNostromo);
|
|
3354
|
+
document.querySelector('#nostromo-card-close')?.addEventListener('click', () => { nostromo.card.hidden = true; nostromo.selected = null; });
|
|
3355
|
+
|
|
3356
|
+
// Planets: size follows how much ledger a memory covers; they start in their kind's sector
|
|
3357
|
+
// and drift toward the memories they agree with, so topics gather on their own.
|
|
3358
|
+
function buildNostromo(data) {
|
|
3359
|
+
const kinds = Object.keys(MEMORY_COLORS);
|
|
3360
|
+
const memories = data.memories ?? [];
|
|
3361
|
+
nostromo.links = (data.links ?? []).filter((link) => memories.some((m) => m.id === link.a) && memories.some((m) => m.id === link.b));
|
|
3362
|
+
nostromo.nodes = memories.map((memory, index) => {
|
|
3363
|
+
const sector = kinds.indexOf(memory.kind) < 0 ? 1 : kinds.indexOf(memory.kind);
|
|
3364
|
+
const angle = (sector / kinds.length) * Math.PI * 2 + ((index % 7) / 7 - 0.5) * (Math.PI / 2.4) + Math.random() * 0.2;
|
|
3365
|
+
const distance = 0.42 + Math.random() * 0.5;
|
|
3366
|
+
const span = Math.max(1, (memory.throughSequence ?? 0) - (memory.fromSequence ?? 0));
|
|
3367
|
+
return { memory, angle, distance, x: 0, y: 0, vx: 0, vy: 0, r: 7 + Math.min(11, Math.log2(span + 1) * 2.2 + memory.sources.length * 0.6), scale: 1, seed: Math.random() * Math.PI * 2, smoke: [], color: MEMORY_COLORS[memory.kind] ?? MEMORY_COLORS.fact, placed: false };
|
|
3368
|
+
});
|
|
3369
|
+
const stats = data.stats ?? {};
|
|
3370
|
+
nostromo.sub.textContent = `MEMORY RESEARCH · ${memories.length} MEMOR${memories.length === 1 ? 'Y' : 'IES'} · ${nostromo.links.length} LINK${nostromo.links.length === 1 ? '' : 'S'} · ${stats.entries ?? 0} EXCHANGES BEHIND THEM${stats.embeddings ? '' : ' · LINKS NEED EMBEDDINGS'}`;
|
|
3371
|
+
nostromo.empty.hidden = memories.length > 0;
|
|
3372
|
+
}
|
|
3373
|
+
|
|
3374
|
+
// World space: MOTHER's core sits at (0, 0); planets orbit in world units and a
|
|
3375
|
+
// camera maps the world to the canvas. The camera follows the whole system
|
|
3376
|
+
// until the human takes the wheel (drag, wheel), and RECENTER hands it back.
|
|
3377
|
+
const NOSTROMO_ORBIT = 300;
|
|
3378
|
+
const HEART_PERIOD = 1.15; // seconds per beat: slow, deliberate, alive
|
|
3379
|
+
const WAVE_SPEED = 260; // world units per second a beat travels outward
|
|
3380
|
+
|
|
3381
|
+
function sizeNostromo() {
|
|
3382
|
+
const canvas = nostromo.canvas;
|
|
3383
|
+
const rect = canvas.getBoundingClientRect();
|
|
3384
|
+
const dpr = window.devicePixelRatio || 1;
|
|
3385
|
+
if (!rect.width || !rect.height) return false;
|
|
3386
|
+
nostromo.size = { w: rect.width, h: rect.height, dpr };
|
|
3387
|
+
canvas.width = Math.round(rect.width * dpr);
|
|
3388
|
+
canvas.height = Math.round(rect.height * dpr);
|
|
3389
|
+
for (const node of nostromo.nodes) {
|
|
3390
|
+
if (node.placed) continue;
|
|
3391
|
+
const radius = NOSTROMO_ORBIT * (0.5 + node.distance);
|
|
3392
|
+
node.x = Math.cos(node.angle) * radius;
|
|
3393
|
+
node.y = Math.sin(node.angle) * radius;
|
|
3394
|
+
node.placed = true;
|
|
3395
|
+
}
|
|
3396
|
+
return true;
|
|
3397
|
+
}
|
|
3398
|
+
|
|
3399
|
+
function startNostromo() {
|
|
3400
|
+
stopNostromo();
|
|
3401
|
+
if (typeof requestAnimationFrame !== 'function' || !nostromo.canvas?.getContext) return;
|
|
3402
|
+
if (!sizeNostromo()) return;
|
|
3403
|
+
nostromo.cam = { x: 0, y: 0, scale: 1, manual: false };
|
|
3404
|
+
nostromo.rings = [];
|
|
3405
|
+
nostromo.lastPhase = 0;
|
|
3406
|
+
nostromo.alarm = null;
|
|
3407
|
+
if (!nostromo.cage?.closed) nostromo.cage = null;
|
|
3408
|
+
window.addEventListener('resize', sizeNostromo);
|
|
3409
|
+
nostromo.last = performance.now();
|
|
3410
|
+
const frame = (now) => {
|
|
3411
|
+
const dt = Math.min(48, now - nostromo.last) / 16;
|
|
3412
|
+
nostromo.last = now;
|
|
3413
|
+
const t = now / 1000;
|
|
3414
|
+
stepNostromo(dt, t);
|
|
3415
|
+
fitNostromo();
|
|
3416
|
+
drawNostromo(t);
|
|
3417
|
+
nostromo.raf = requestAnimationFrame(frame);
|
|
3418
|
+
};
|
|
3419
|
+
nostromo.raf = requestAnimationFrame(frame);
|
|
3420
|
+
}
|
|
3421
|
+
function stopNostromo() {
|
|
3422
|
+
if (nostromo.raf) cancelAnimationFrame(nostromo.raf);
|
|
3423
|
+
nostromo.raf = null;
|
|
3424
|
+
window.removeEventListener('resize', sizeNostromo);
|
|
3425
|
+
}
|
|
3426
|
+
|
|
3427
|
+
// The heartbeat: lub, then a softer dub, each decaying fast. 0..~1.5.
|
|
3428
|
+
function heartbeat(t) {
|
|
3429
|
+
const period = nostromo.cage ? 0.62 : nostromo.altered ? 0.82 : HEART_PERIOD;
|
|
3430
|
+
const phase = (t % period) / period;
|
|
3431
|
+
const pulse = (at) => (phase >= at ? Math.exp(-(phase - at) * 14) : 0);
|
|
3432
|
+
return { phase, beat: pulse(0) + 0.55 * pulse(0.22) };
|
|
3433
|
+
}
|
|
3434
|
+
function coreRadius() { return Math.max(46, Math.min(nostromo.size.w, nostromo.size.h) * 0.085) / Math.max(0.35, nostromo.cam?.scale ?? 1) * 0.9; }
|
|
3435
|
+
|
|
3436
|
+
// The core's radius in world units: constant so the layout does not depend on the window.
|
|
3437
|
+
const CORE_R = 64;
|
|
3438
|
+
|
|
3439
|
+
function stepNostromo(dt, t) {
|
|
3440
|
+
const nodes = nostromo.nodes.filter((node) => node.scale > 0.01);
|
|
3441
|
+
const byId = new Map(nodes.map((node) => [node.memory.id, node]));
|
|
3442
|
+
for (const node of nodes) {
|
|
3443
|
+
let fx = 0;
|
|
3444
|
+
let fy = 0;
|
|
3445
|
+
const dist = Math.hypot(node.x, node.y) || 1;
|
|
3446
|
+
// A soft ring around the core: too close is pushed out, too far pulled in.
|
|
3447
|
+
const target = NOSTROMO_ORBIT * (0.55 + node.distance * 0.6);
|
|
3448
|
+
const pull = (target - dist) * 0.004;
|
|
3449
|
+
fx += (node.x / dist) * pull;
|
|
3450
|
+
fy += (node.y / dist) * pull;
|
|
3451
|
+
if (dist < CORE_R * 2.2) { const push = (CORE_R * 2.2 - dist) * 0.02; fx += (node.x / dist) * push; fy += (node.y / dist) * push; }
|
|
3452
|
+
for (const other of nodes) {
|
|
3453
|
+
if (other === node) continue;
|
|
3454
|
+
const ox = node.x - other.x;
|
|
3455
|
+
const oy = node.y - other.y;
|
|
3456
|
+
const d = Math.hypot(ox, oy) || 1;
|
|
3457
|
+
const min = node.r + other.r + 30;
|
|
3458
|
+
if (d < min * 2.2) { const push = ((min * 2.2 - d) / (min * 2.2)) * 0.9; fx += (ox / d) * push; fy += (oy / d) * push; }
|
|
3459
|
+
}
|
|
3460
|
+
if (!nostromo.reduced) { fx += Math.sin(node.seed + t * 0.38) * 0.02; fy += Math.cos(node.seed * 1.3 + t * 0.32) * 0.02; }
|
|
3461
|
+
node.fx = fx;
|
|
3462
|
+
node.fy = fy;
|
|
3463
|
+
}
|
|
3464
|
+
for (const link of nostromo.links) {
|
|
3465
|
+
const a = byId.get(link.a);
|
|
3466
|
+
const b = byId.get(link.b);
|
|
3467
|
+
if (!a || !b) continue;
|
|
3468
|
+
const dx = b.x - a.x;
|
|
3469
|
+
const dy = b.y - a.y;
|
|
3470
|
+
const d = Math.hypot(dx, dy) || 1;
|
|
3471
|
+
const rest = a.r + b.r + 70;
|
|
3472
|
+
const k = (d - rest) * 0.0035 * link.weight;
|
|
3473
|
+
a.fx += (dx / d) * k; a.fy += (dy / d) * k;
|
|
3474
|
+
b.fx -= (dx / d) * k; b.fy -= (dy / d) * k;
|
|
3475
|
+
}
|
|
3476
|
+
for (const node of nodes) {
|
|
3477
|
+
node.vx = (node.vx + node.fx * dt) * 0.86;
|
|
3478
|
+
node.vy = (node.vy + node.fy * dt) * 0.86;
|
|
3479
|
+
node.x += node.vx * dt;
|
|
3480
|
+
node.y += node.vy * dt;
|
|
3481
|
+
node.spin = (node.spin ?? node.seed) + dt * 0.012 * (1 + (node.seed % 1));
|
|
3482
|
+
if (!nostromo.reduced) {
|
|
3483
|
+
// Plasma wisps: a few tendrils rising off each planet, fading as they climb.
|
|
3484
|
+
if (node.smoke.length < 5 && Math.random() < 0.05 * dt) node.smoke.push({ x: (Math.random() - 0.5) * node.r, y: -node.r * 0.4, age: 0, life: 80 + Math.random() * 60, drift: (Math.random() - 0.5) * 0.2, size: node.r * (0.3 + Math.random() * 0.3) });
|
|
3485
|
+
for (const puff of node.smoke) { puff.age += dt; puff.y -= 0.3 * dt; puff.x += puff.drift * dt; puff.size += 0.08 * dt; }
|
|
3486
|
+
node.smoke = node.smoke.filter((puff) => puff.age < puff.life);
|
|
3487
|
+
}
|
|
3488
|
+
}
|
|
3489
|
+
for (const node of nostromo.nodes) if (node.forgetting) node.scale = Math.max(0, node.scale - 0.06 * dt);
|
|
3490
|
+
nostromo.nodes = nostromo.nodes.filter((node) => !(node.forgetting && node.scale <= 0.01));
|
|
3491
|
+
// A new beat sends a wave out from the core.
|
|
3492
|
+
const { phase } = heartbeat(t);
|
|
3493
|
+
if (phase < nostromo.lastPhase && !nostromo.reduced) nostromo.rings.push({ born: t });
|
|
3494
|
+
nostromo.lastPhase = phase;
|
|
3495
|
+
nostromo.rings = nostromo.rings.filter((ring) => t - ring.born < 2.4);
|
|
3496
|
+
}
|
|
3497
|
+
|
|
3498
|
+
// The camera follows the whole system until the human takes over.
|
|
3499
|
+
function fitNostromo() {
|
|
3500
|
+
const cam = nostromo.cam;
|
|
3501
|
+
if (!cam || cam.manual) return;
|
|
3502
|
+
const { w, h } = nostromo.size;
|
|
3503
|
+
let minX = -CORE_R * 2.4, maxX = CORE_R * 2.4, minY = -CORE_R * 2.4, maxY = CORE_R * 2.4;
|
|
3504
|
+
for (const node of nostromo.nodes) {
|
|
3505
|
+
minX = Math.min(minX, node.x - node.r * 3); maxX = Math.max(maxX, node.x + node.r * 3);
|
|
3506
|
+
minY = Math.min(minY, node.y - node.r * 3); maxY = Math.max(maxY, node.y + node.r * 3);
|
|
3507
|
+
}
|
|
3508
|
+
const pad = 70;
|
|
3509
|
+
const scale = Math.min(1.4, Math.max(0.3, Math.min((w - pad * 2) / (maxX - minX || 1), (h - pad * 2 - 40) / (maxY - minY || 1))));
|
|
3510
|
+
const cx = (minX + maxX) / 2;
|
|
3511
|
+
const cy = (minY + maxY) / 2 - 20 / scale;
|
|
3512
|
+
cam.x += (cx - cam.x) * 0.05;
|
|
3513
|
+
cam.y += (cy - cam.y) * 0.05;
|
|
3514
|
+
cam.scale += (scale - cam.scale) * 0.05;
|
|
3515
|
+
}
|
|
3516
|
+
function toScreen(x, y) { const { w, h } = nostromo.size; const cam = nostromo.cam; return { x: w / 2 + (x - cam.x) * cam.scale, y: h / 2 + (y - cam.y) * cam.scale }; }
|
|
3517
|
+
function toWorld(sx, sy) { const { w, h } = nostromo.size; const cam = nostromo.cam; return { x: cam.x + (sx - w / 2) / cam.scale, y: cam.y + (sy - h / 2) / cam.scale }; }
|
|
3518
|
+
|
|
3519
|
+
function drawNostromo(t) {
|
|
3520
|
+
const ctx = nostromo.canvas.getContext('2d');
|
|
3521
|
+
const { w, h, dpr } = nostromo.size;
|
|
3522
|
+
const cam = nostromo.cam;
|
|
3523
|
+
const { beat } = heartbeat(t);
|
|
3524
|
+
const alarm = nostromo.alarm ? Math.max(0, 1 - (t - nostromo.alarm) / 3.6) : 0;
|
|
3525
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
3526
|
+
ctx.clearRect(0, 0, w, h);
|
|
3527
|
+
// Stars, with a little parallax against the camera.
|
|
3528
|
+
ctx.fillStyle = 'rgba(255, 244, 240, .35)';
|
|
3529
|
+
for (let i = 0; i < 120; i += 1) {
|
|
3530
|
+
const sx = ((i * 97.3 - cam.x * 0.08) % w + w) % w;
|
|
3531
|
+
const sy = ((i * 53.7 + 31 - cam.y * 0.08) % h + h) % h;
|
|
3532
|
+
ctx.globalAlpha = 0.12 + 0.22 * Math.abs(Math.sin(t * 0.6 + i));
|
|
3533
|
+
ctx.fillRect(sx, sy, 1.2, 1.2);
|
|
3534
|
+
}
|
|
3535
|
+
ctx.globalAlpha = 1;
|
|
3536
|
+
|
|
3537
|
+
ctx.save();
|
|
3538
|
+
ctx.translate(w / 2, h / 2);
|
|
3539
|
+
ctx.scale(cam.scale, cam.scale);
|
|
3540
|
+
ctx.translate(-cam.x, -cam.y);
|
|
3541
|
+
const R = CORE_R * (1 + 0.07 * beat + 0.12 * alarm);
|
|
3542
|
+
|
|
3543
|
+
// Plasma between linked memories: they share a theme, so a filament runs between them.
|
|
3544
|
+
for (const link of nostromo.links) {
|
|
3545
|
+
const a = nostromo.nodes.find((node) => node.memory.id === link.a);
|
|
3546
|
+
const b = nostromo.nodes.find((node) => node.memory.id === link.b);
|
|
3547
|
+
if (!a || !b) continue;
|
|
3548
|
+
const dx = b.x - a.x, dy = b.y - a.y;
|
|
3549
|
+
const d = Math.hypot(dx, dy) || 1;
|
|
3550
|
+
const bow = Math.sin(t * 1.3 + a.seed + b.seed) * Math.min(40, d * 0.15);
|
|
3551
|
+
const mx = (a.x + b.x) / 2 - (dy / d) * bow;
|
|
3552
|
+
const my = (a.y + b.y) / 2 + (dx / d) * bow;
|
|
3553
|
+
const grad = ctx.createLinearGradient(a.x, a.y, b.x, b.y);
|
|
3554
|
+
grad.addColorStop(0, hexAlpha(a.color, 0.55 * link.weight));
|
|
3555
|
+
grad.addColorStop(0.5, 'rgba(255, 240, 235, .25)');
|
|
3556
|
+
grad.addColorStop(1, hexAlpha(b.color, 0.55 * link.weight));
|
|
3557
|
+
ctx.strokeStyle = grad;
|
|
3558
|
+
ctx.lineWidth = (0.8 + 0.5 * Math.abs(Math.sin(t * 4 + a.seed))) / cam.scale;
|
|
3559
|
+
ctx.shadowColor = hexAlpha(a.color, 0.7);
|
|
3560
|
+
ctx.shadowBlur = 8;
|
|
3561
|
+
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.quadraticCurveTo(mx, my, b.x, b.y); ctx.stroke();
|
|
3562
|
+
ctx.shadowBlur = 0;
|
|
3563
|
+
}
|
|
3564
|
+
|
|
3565
|
+
// Plasma from the core to every memory: bowing, flickering, a pulse travelling out with each beat.
|
|
3566
|
+
for (const node of nostromo.nodes) {
|
|
3567
|
+
const d = Math.hypot(node.x, node.y) || 1;
|
|
3568
|
+
const nx = -node.y / d, ny = node.x / d;
|
|
3569
|
+
const bow = Math.sin(t * 1.7 + node.seed) * Math.min(60, d * 0.18);
|
|
3570
|
+
const mx = node.x / 2 + nx * bow, my = node.y / 2 + ny * bow;
|
|
3571
|
+
const grad = ctx.createLinearGradient(0, 0, node.x, node.y);
|
|
3572
|
+
grad.addColorStop(0, alarm ? 'rgba(255, 230, 220, .95)' : 'rgba(255, 90, 60, .9)');
|
|
3573
|
+
grad.addColorStop(0.5, `rgba(255, 42, 31, ${0.3 + 0.25 * beat})`);
|
|
3574
|
+
grad.addColorStop(1, hexAlpha(node.color, 0.8 * node.scale));
|
|
3575
|
+
ctx.strokeStyle = grad;
|
|
3576
|
+
ctx.lineWidth = (1 + 0.7 * Math.abs(Math.sin(t * 5 + node.seed * 3)) + 0.8 * beat) / cam.scale;
|
|
3577
|
+
ctx.shadowColor = 'rgba(255, 60, 40, .8)';
|
|
3578
|
+
ctx.shadowBlur = 12;
|
|
3579
|
+
ctx.beginPath();
|
|
3580
|
+
ctx.moveTo((node.x / d) * R * 0.92, (node.y / d) * R * 0.92);
|
|
3581
|
+
ctx.quadraticCurveTo(mx, my, node.x, node.y);
|
|
3582
|
+
ctx.stroke();
|
|
3583
|
+
ctx.shadowBlur = 0;
|
|
3584
|
+
// The beat, travelling along the filament.
|
|
3585
|
+
for (const ring of nostromo.rings) {
|
|
3586
|
+
const p = ((t - ring.born) * WAVE_SPEED) / d;
|
|
3587
|
+
if (p < 0 || p > 1) continue;
|
|
3588
|
+
const px = (1 - p) * (1 - p) * 0 + 2 * (1 - p) * p * mx + p * p * node.x;
|
|
3589
|
+
const py = (1 - p) * (1 - p) * 0 + 2 * (1 - p) * p * my + p * p * node.y;
|
|
3590
|
+
ctx.fillStyle = 'rgba(255, 235, 225, .95)';
|
|
3591
|
+
ctx.shadowColor = 'rgba(255, 120, 90, 1)'; ctx.shadowBlur = 10;
|
|
3592
|
+
ctx.beginPath(); ctx.arc(px, py, 2.2 / cam.scale, 0, Math.PI * 2); ctx.fill();
|
|
3593
|
+
ctx.shadowBlur = 0;
|
|
3594
|
+
}
|
|
3595
|
+
}
|
|
3596
|
+
|
|
3597
|
+
// Shockwaves: one ring per beat, expanding and thinning.
|
|
3598
|
+
for (const ring of nostromo.rings) {
|
|
3599
|
+
const age = t - ring.born;
|
|
3600
|
+
const radius = R + age * WAVE_SPEED;
|
|
3601
|
+
ctx.strokeStyle = `rgba(255, 60, 40, ${Math.max(0, 0.32 - age * 0.14)})`;
|
|
3602
|
+
ctx.lineWidth = Math.max(0.4, 2.4 - age * 1.1) / cam.scale;
|
|
3603
|
+
ctx.beginPath(); ctx.arc(0, 0, radius, 0, Math.PI * 2); ctx.stroke();
|
|
3604
|
+
}
|
|
3605
|
+
|
|
3606
|
+
// MOTHER's core. A heart: deep haze, a smooth sphere lit from within, a dark iris,
|
|
3607
|
+
// veins under the skin, arcs of plasma licking the surface, a white-hot rim.
|
|
3608
|
+
const haze = ctx.createRadialGradient(0, 0, R * 0.5, 0, 0, R * (3.2 + 0.5 * beat));
|
|
3609
|
+
haze.addColorStop(0, `rgba(255, 42, 31, ${0.5 + 0.2 * beat})`);
|
|
3610
|
+
haze.addColorStop(0.3, `rgba(180, 20, 20, ${0.22 + 0.1 * beat})`);
|
|
3611
|
+
haze.addColorStop(1, 'rgba(120, 0, 10, 0)');
|
|
3612
|
+
ctx.fillStyle = haze;
|
|
3613
|
+
ctx.beginPath(); ctx.arc(0, 0, R * (3.2 + 0.5 * beat), 0, Math.PI * 2); ctx.fill();
|
|
3614
|
+
|
|
3615
|
+
ctx.save();
|
|
3616
|
+
ctx.beginPath(); ctx.arc(0, 0, R, 0, Math.PI * 2); ctx.closePath();
|
|
3617
|
+
ctx.shadowColor = `rgba(255, 42, 31, ${0.8 + 0.2 * beat})`;
|
|
3618
|
+
ctx.shadowBlur = 50 + 30 * beat;
|
|
3619
|
+
const body = ctx.createRadialGradient(-R * 0.25, -R * 0.3, R * 0.05, 0, 0, R);
|
|
3620
|
+
body.addColorStop(0, `hsl(${8 + 6 * beat} 100% ${58 + 12 * beat}%)`);
|
|
3621
|
+
body.addColorStop(0.35, 'hsl(2 96% 44%)');
|
|
3622
|
+
body.addColorStop(0.75, 'hsl(358 92% 30%)');
|
|
3623
|
+
body.addColorStop(1, 'hsl(352 90% 16%)');
|
|
3624
|
+
ctx.fillStyle = body;
|
|
3625
|
+
ctx.fill();
|
|
3626
|
+
ctx.shadowBlur = 0;
|
|
3627
|
+
ctx.clip();
|
|
3628
|
+
// Veins: filaments under the skin. With every beat a pulse runs outward along
|
|
3629
|
+
// each one, the vein swelling and brightening as it passes; each has a branch.
|
|
3630
|
+
ctx.lineCap = 'round';
|
|
3631
|
+
const veinPulse = (along) => { const phase = heartbeat(t).phase; const p = phase * 1.6 - along; return p >= 0 ? Math.exp(-p * 10) : 0; };
|
|
3632
|
+
const vein = (x0, y0, cx, cy, x1, y1, width, alpha) => {
|
|
3633
|
+
const steps = 9;
|
|
3634
|
+
let px = x0, py = y0;
|
|
3635
|
+
for (let k = 1; k <= steps; k += 1) {
|
|
3636
|
+
const u = k / steps;
|
|
3637
|
+
const qx = (1 - u) * (1 - u) * x0 + 2 * (1 - u) * u * cx + u * u * x1;
|
|
3638
|
+
const qy = (1 - u) * (1 - u) * y0 + 2 * (1 - u) * u * cy + u * u * y1;
|
|
3639
|
+
const pulse = veinPulse(u);
|
|
3640
|
+
ctx.strokeStyle = `rgba(${110 + 130 * pulse}, ${8 + 40 * pulse}, ${14 + 20 * pulse}, ${alpha * (0.55 + 0.45 * pulse)})`;
|
|
3641
|
+
ctx.lineWidth = width * (1 + 0.9 * pulse);
|
|
3642
|
+
ctx.shadowColor = `rgba(255, 60, 40, ${0.6 * pulse})`; ctx.shadowBlur = 8 * pulse;
|
|
3643
|
+
ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(qx, qy); ctx.stroke();
|
|
3644
|
+
px = qx; py = qy;
|
|
3645
|
+
}
|
|
3646
|
+
ctx.shadowBlur = 0;
|
|
3647
|
+
};
|
|
3648
|
+
for (let i = 0; i < 9; i += 1) {
|
|
3649
|
+
const a0 = i * 0.7 + t * 0.04;
|
|
3650
|
+
const r0 = R * (0.12 + (i % 3) * 0.2);
|
|
3651
|
+
const x0 = Math.cos(a0) * r0, y0 = Math.sin(a0) * r0;
|
|
3652
|
+
const a1 = a0 + 1.1 + Math.sin(t * 0.3 + i) * 0.25;
|
|
3653
|
+
const x1 = Math.cos(a1) * R * 1.05, y1 = Math.sin(a1) * R * 1.05;
|
|
3654
|
+
const cxv = Math.cos(a0 + 0.5) * R * 0.7 * (1 + 0.08 * Math.sin(t + i)), cyv = Math.sin(a0 + 0.5) * R * 0.7;
|
|
3655
|
+
vein(x0, y0, cxv, cyv, x1, y1, 2.8 - (i % 3) * 0.6, 0.85);
|
|
3656
|
+
// A branch leaving the vein two thirds of the way out.
|
|
3657
|
+
const bx = 0.11 * x0 + 0.44 * cxv + 0.45 * x1, by = 0.11 * y0 + 0.44 * cyv + 0.45 * y1;
|
|
3658
|
+
const ab = a1 + (i % 2 ? 0.5 : -0.5);
|
|
3659
|
+
vein(bx, by, (bx + Math.cos(ab) * R) / 2, (by + Math.sin(ab) * R) / 2, Math.cos(ab) * R * 1.05, Math.sin(ab) * R * 1.05, 1.4, 0.6);
|
|
3660
|
+
}
|
|
3661
|
+
// The iris: a darker void drifting across the core, where MOTHER looks from.
|
|
3662
|
+
const ix = Math.cos(t * 0.21) * R * 0.22, iy = Math.sin(t * 0.17) * R * 0.18;
|
|
3663
|
+
const iris = ctx.createRadialGradient(ix, iy, 0, ix, iy, R * 0.55);
|
|
3664
|
+
iris.addColorStop(0, `rgba(20, 0, 4, ${0.85 - 0.25 * beat})`);
|
|
3665
|
+
iris.addColorStop(0.55, 'rgba(60, 0, 8, .45)');
|
|
3666
|
+
iris.addColorStop(1, 'rgba(120, 10, 10, 0)');
|
|
3667
|
+
ctx.fillStyle = iris;
|
|
3668
|
+
ctx.fillRect(-R, -R, R * 2, R * 2);
|
|
3669
|
+
// Specular sheen, iridescent, slowly turning.
|
|
3670
|
+
const sheenAngle = t * 0.4;
|
|
3671
|
+
const sheen = ctx.createLinearGradient(Math.cos(sheenAngle) * -R, Math.sin(sheenAngle) * -R, Math.cos(sheenAngle) * R, Math.sin(sheenAngle) * R);
|
|
3672
|
+
sheen.addColorStop(0, 'hsla(300 100% 75% / 0)');
|
|
3673
|
+
sheen.addColorStop(0.5, `hsla(${40 + 40 * Math.sin(t * 0.8)} 100% 80% / ${0.16 + 0.1 * beat})`);
|
|
3674
|
+
sheen.addColorStop(1, 'hsla(200 100% 75% / 0)');
|
|
3675
|
+
ctx.fillStyle = sheen;
|
|
3676
|
+
ctx.fillRect(-R, -R, R * 2, R * 2);
|
|
3677
|
+
ctx.restore();
|
|
3678
|
+
// Plasma prominences: arcs leaping off the surface and falling back, flickering.
|
|
3679
|
+
ctx.lineCap = 'round';
|
|
3680
|
+
for (let i = 0; i < 5; i += 1) {
|
|
3681
|
+
const base = i * 1.256 + t * 0.12;
|
|
3682
|
+
const spread = 0.35 + 0.15 * Math.sin(t * 2.1 + i);
|
|
3683
|
+
const lift = R * (0.25 + 0.2 * Math.abs(Math.sin(t * 1.6 + i * 1.7)) + 0.3 * alarm);
|
|
3684
|
+
const x0 = Math.cos(base - spread) * R, y0 = Math.sin(base - spread) * R;
|
|
3685
|
+
const x1 = Math.cos(base + spread) * R, y1 = Math.sin(base + spread) * R;
|
|
3686
|
+
const cxp = Math.cos(base) * (R + lift), cyp = Math.sin(base) * (R + lift);
|
|
3687
|
+
ctx.strokeStyle = `rgba(255, ${120 + 80 * beat}, ${80 + 60 * beat}, ${0.35 + 0.4 * Math.abs(Math.sin(t * 6 + i))})`;
|
|
3688
|
+
ctx.lineWidth = (1.4 + 0.8 * beat) / cam.scale;
|
|
3689
|
+
ctx.shadowColor = 'rgba(255, 90, 60, .9)'; ctx.shadowBlur = 14;
|
|
3690
|
+
ctx.beginPath(); ctx.moveTo(x0, y0); ctx.quadraticCurveTo(cxp, cyp, x1, y1); ctx.stroke();
|
|
3691
|
+
}
|
|
3692
|
+
ctx.shadowBlur = 0;
|
|
3693
|
+
// The rim: white-hot on the beat.
|
|
3694
|
+
ctx.strokeStyle = `rgba(255, ${200 + 55 * beat}, ${190 + 60 * beat}, ${0.45 + 0.45 * beat})`;
|
|
3695
|
+
ctx.lineWidth = (1.2 + 1.6 * beat) / cam.scale;
|
|
3696
|
+
ctx.shadowColor = 'rgba(255, 200, 190, .9)'; ctx.shadowBlur = 18 + 20 * beat;
|
|
3697
|
+
ctx.beginPath(); ctx.arc(0, 0, R, 0, Math.PI * 2); ctx.stroke();
|
|
3698
|
+
ctx.shadowBlur = 0;
|
|
3699
|
+
|
|
3700
|
+
// Memories: small plasma planets. Lit sphere, a swirl turning under the skin, a rim of its
|
|
3701
|
+
// own colour, and a pulse when the core's beat reaches it.
|
|
3702
|
+
for (const node of nostromo.nodes) {
|
|
3703
|
+
const r = node.r * node.scale;
|
|
3704
|
+
if (r <= 0) continue;
|
|
3705
|
+
const d = Math.hypot(node.x, node.y) || 1;
|
|
3706
|
+
let arrive = 0;
|
|
3707
|
+
for (const ring of nostromo.rings) { const gap = (t - ring.born) - (d - R) / WAVE_SPEED; if (gap >= 0) arrive = Math.max(arrive, Math.exp(-gap * 6)); }
|
|
3708
|
+
for (const puff of node.smoke) {
|
|
3709
|
+
const k = puff.age / puff.life;
|
|
3710
|
+
ctx.fillStyle = hexAlpha(node.color, (1 - k) * 0.16 * node.scale);
|
|
3711
|
+
ctx.beginPath(); ctx.arc(node.x + puff.x, node.y + puff.y, puff.size, 0, Math.PI * 2); ctx.fill();
|
|
3712
|
+
}
|
|
3713
|
+
const glow = ctx.createRadialGradient(node.x, node.y, r * 0.5, node.x, node.y, r * (2.6 + 1.2 * arrive));
|
|
3714
|
+
glow.addColorStop(0, hexAlpha(node.color, 0.4 + 0.3 * arrive));
|
|
3715
|
+
glow.addColorStop(1, hexAlpha(node.color, 0));
|
|
3716
|
+
ctx.fillStyle = glow;
|
|
3717
|
+
ctx.beginPath(); ctx.arc(node.x, node.y, r * (2.6 + 1.2 * arrive), 0, Math.PI * 2); ctx.fill();
|
|
3718
|
+
ctx.save();
|
|
3719
|
+
ctx.beginPath(); ctx.arc(node.x, node.y, r, 0, Math.PI * 2); ctx.closePath();
|
|
3720
|
+
ctx.shadowColor = hexAlpha(node.color, 0.9); ctx.shadowBlur = 16 + 14 * arrive;
|
|
3721
|
+
const sphere = ctx.createRadialGradient(node.x - r * 0.38, node.y - r * 0.38, r * 0.05, node.x, node.y, r * 1.05);
|
|
3722
|
+
sphere.addColorStop(0, '#ffffff');
|
|
3723
|
+
sphere.addColorStop(0.18, hexMix(node.color, '#ffffff', 0.35));
|
|
3724
|
+
sphere.addColorStop(0.55, node.color);
|
|
3725
|
+
sphere.addColorStop(1, hexMix(node.color, '#000000', 0.78));
|
|
3726
|
+
ctx.fillStyle = sphere;
|
|
3727
|
+
ctx.fill();
|
|
3728
|
+
ctx.shadowBlur = 0;
|
|
3729
|
+
ctx.clip();
|
|
3730
|
+
// Plasma currents under the skin, two bands turning at different speeds.
|
|
3731
|
+
for (let k = 0; k < 2; k += 1) {
|
|
3732
|
+
const a = node.spin * (k ? -0.7 : 1) + k * 2.1;
|
|
3733
|
+
ctx.strokeStyle = hexAlpha(k ? '#ffffff' : hexMix(node.color, '#ffffff', 0.5), 0.22 + 0.2 * arrive);
|
|
3734
|
+
ctx.lineWidth = r * (0.22 - k * 0.08);
|
|
3735
|
+
ctx.beginPath(); ctx.ellipse(node.x, node.y, r * 0.95, r * (0.35 + 0.15 * k), a, 0.3, Math.PI - 0.3); ctx.stroke();
|
|
3736
|
+
}
|
|
3737
|
+
ctx.restore();
|
|
3738
|
+
// Rim light and the beat's ring.
|
|
3739
|
+
ctx.strokeStyle = hexAlpha(hexMix(node.color, '#ffffff', 0.4), 0.5 + 0.4 * arrive);
|
|
3740
|
+
ctx.lineWidth = (0.9 + arrive) / cam.scale;
|
|
3741
|
+
ctx.beginPath(); ctx.arc(node.x, node.y, r + 0.6 / cam.scale, 0, Math.PI * 2); ctx.stroke();
|
|
3742
|
+
if (arrive > 0.05) {
|
|
3743
|
+
ctx.strokeStyle = hexAlpha(node.color, arrive * 0.6);
|
|
3744
|
+
ctx.lineWidth = 1 / cam.scale;
|
|
3745
|
+
ctx.beginPath(); ctx.arc(node.x, node.y, r + 4 + (1 - arrive) * 14, 0, Math.PI * 2); ctx.stroke();
|
|
3746
|
+
}
|
|
3747
|
+
if (node === nostromo.selected || node === nostromo.hover) {
|
|
3748
|
+
ctx.strokeStyle = hexAlpha(node.color, node === nostromo.selected ? 0.95 : 0.55);
|
|
3749
|
+
ctx.lineWidth = 1.2 / cam.scale;
|
|
3750
|
+
ctx.beginPath(); ctx.arc(node.x, node.y, r + 6 + 2 * Math.sin(t * 4), 0, Math.PI * 2); ctx.stroke();
|
|
3751
|
+
}
|
|
3752
|
+
}
|
|
3753
|
+
// CODE000: the safety box. Bars fall from above and lock around the core.
|
|
3754
|
+
if (nostromo.cage) {
|
|
3755
|
+
const age = t - nostromo.cage.at;
|
|
3756
|
+
const p = nostromo.cage.closed ? 1 : Math.min(1, age / 1.1);
|
|
3757
|
+
const ease = 1 - Math.pow(1 - p, 3);
|
|
3758
|
+
const B = CORE_R * 2.05;
|
|
3759
|
+
const drop = (1 - ease) * 900;
|
|
3760
|
+
ctx.fillStyle = `rgba(0, 0, 0, ${0.35 * ease})`;
|
|
3761
|
+
ctx.fillRect(-B - 8, -B - 8 - drop, B * 2 + 16, B * 2 + 16);
|
|
3762
|
+
const steel = ctx.createLinearGradient(-B, 0, B, 0);
|
|
3763
|
+
steel.addColorStop(0, '#2a2c30'); steel.addColorStop(0.45, '#9aa0a8'); steel.addColorStop(0.55, '#d8dde3'); steel.addColorStop(1, '#3a3d42');
|
|
3764
|
+
ctx.strokeStyle = steel;
|
|
3765
|
+
ctx.lineCap = 'butt';
|
|
3766
|
+
ctx.shadowColor = 'rgba(0, 0, 0, .9)'; ctx.shadowBlur = 14;
|
|
3767
|
+
ctx.lineWidth = 7;
|
|
3768
|
+
for (let i = 0; i <= 6; i += 1) {
|
|
3769
|
+
const x = -B + (i * 2 * B) / 6;
|
|
3770
|
+
ctx.beginPath(); ctx.moveTo(x, -B - drop); ctx.lineTo(x, B - drop); ctx.stroke();
|
|
3771
|
+
}
|
|
3772
|
+
ctx.lineWidth = 9;
|
|
3773
|
+
for (const y of [-B, 0, B]) { ctx.beginPath(); ctx.moveTo(-B - 4, y - drop); ctx.lineTo(B + 4, y - drop); ctx.stroke(); }
|
|
3774
|
+
ctx.shadowBlur = 0;
|
|
3775
|
+
// Rivets at the joints.
|
|
3776
|
+
ctx.fillStyle = '#e8ecf0';
|
|
3777
|
+
for (let i = 0; i <= 6; i += 1) for (const y of [-B, 0, B]) { ctx.beginPath(); ctx.arc(-B + (i * 2 * B) / 6, y - drop, 2.6, 0, Math.PI * 2); ctx.fill(); }
|
|
3778
|
+
// The lock plate.
|
|
3779
|
+
if (ease > 0.98) {
|
|
3780
|
+
ctx.fillStyle = '#1b1d20'; ctx.fillRect(-B * 0.32, B - 14, B * 0.64, 28);
|
|
3781
|
+
ctx.strokeStyle = '#c4c9cf'; ctx.lineWidth = 2; ctx.strokeRect(-B * 0.32, B - 14, B * 0.64, 28);
|
|
3782
|
+
ctx.fillStyle = '#ff2a1f'; ctx.font = `700 ${Math.round(B * 0.1)}px ${getComputedStyle(nostromo.canvas).getPropertyValue('--mono') || 'monospace'}`; ctx.textAlign = 'center'; ctx.fillText('CODE000', 0, B + 6); ctx.textAlign = 'start';
|
|
3783
|
+
}
|
|
3784
|
+
if (!nostromo.cage.closed && p >= 1 && !nostromo.cage.clanged) { nostromo.cage.clanged = true; const frame = document.querySelector('#nostromo .nostromo-frame'); frame?.classList.remove('shake'); void frame?.offsetWidth; frame?.classList.add('shake'); }
|
|
3785
|
+
}
|
|
3786
|
+
ctx.restore();
|
|
3787
|
+
|
|
3788
|
+
// Vignette that tightens on the beat; red wash while MOTHER is angry.
|
|
3789
|
+
const vig = ctx.createRadialGradient(w / 2, h / 2, Math.min(w, h) * (0.35 - 0.03 * beat), w / 2, h / 2, Math.max(w, h) * 0.75);
|
|
3790
|
+
vig.addColorStop(0, 'rgba(0, 0, 0, 0)');
|
|
3791
|
+
vig.addColorStop(1, `rgba(${alarm ? 60 : 0}, 0, 0, ${0.55 + 0.1 * beat})`);
|
|
3792
|
+
ctx.fillStyle = vig;
|
|
3793
|
+
ctx.fillRect(0, 0, w, h);
|
|
3794
|
+
if (alarm) { ctx.fillStyle = `rgba(255, 30, 20, ${0.08 * alarm * (0.6 + 0.4 * Math.sin(t * 18))})`; ctx.fillRect(0, 0, w, h); }
|
|
3795
|
+
|
|
3796
|
+
// The hovered memory, named on screen.
|
|
3797
|
+
if (nostromo.hover && nostromo.hover.scale > 0.5) {
|
|
3798
|
+
const node = nostromo.hover;
|
|
3799
|
+
const p = toScreen(node.x, node.y);
|
|
3800
|
+
const label = `${node.memory.kind.toUpperCase()} · ${node.memory.text.length > 72 ? `${node.memory.text.slice(0, 71)}…` : node.memory.text}`;
|
|
3801
|
+
ctx.font = '11px ' + (getComputedStyle(nostromo.canvas).getPropertyValue('--mono') || 'monospace');
|
|
3802
|
+
const width = ctx.measureText(label).width + 16;
|
|
3803
|
+
const lx = Math.min(w - width - 8, Math.max(8, p.x - width / 2));
|
|
3804
|
+
const ly = p.y + node.r * cam.scale + 14;
|
|
3805
|
+
ctx.fillStyle = 'rgba(3, 2, 3, .85)';
|
|
3806
|
+
ctx.fillRect(lx, ly, width, 22);
|
|
3807
|
+
ctx.strokeStyle = hexAlpha(node.color, 0.6);
|
|
3808
|
+
ctx.strokeRect(lx + 0.5, ly + 0.5, width - 1, 21);
|
|
3809
|
+
ctx.fillStyle = '#eef1ea';
|
|
3810
|
+
ctx.fillText(label, lx + 8, ly + 15);
|
|
3811
|
+
}
|
|
3812
|
+
}
|
|
3813
|
+
|
|
3814
|
+
function hexAlpha(hex, alpha) {
|
|
3815
|
+
const n = parseInt(hex.slice(1), 16);
|
|
3816
|
+
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${Math.max(0, Math.min(1, alpha))})`;
|
|
3817
|
+
}
|
|
3818
|
+
function hexMix(hex, other, amount) {
|
|
3819
|
+
const a = parseInt(hex.slice(1), 16), b = parseInt(other.slice(1), 16);
|
|
3820
|
+
const ch = (shift) => Math.round(((a >> shift) & 255) * (1 - amount) + ((b >> shift) & 255) * amount);
|
|
3821
|
+
return `#${[16, 8, 0].map((shift) => ch(shift).toString(16).padStart(2, '0')).join('')}`;
|
|
3822
|
+
}
|
|
3823
|
+
|
|
3824
|
+
// Pointer: what is under it, in world space; the core counts too.
|
|
3825
|
+
function nostromoAt(event) {
|
|
3826
|
+
const rect = nostromo.canvas.getBoundingClientRect();
|
|
3827
|
+
const p = toWorld(event.clientX - rect.left, event.clientY - rect.top);
|
|
3828
|
+
const slack = 8 / nostromo.cam.scale;
|
|
3829
|
+
let best = null;
|
|
3830
|
+
for (const node of nostromo.nodes) {
|
|
3831
|
+
const d = Math.hypot(node.x - p.x, node.y - p.y);
|
|
3832
|
+
if (d <= node.r * node.scale + slack && (!best || d < best.d)) best = { node, d };
|
|
3833
|
+
}
|
|
3834
|
+
if (best) return best.node;
|
|
3835
|
+
return Math.hypot(p.x, p.y) <= CORE_R * 1.15 ? 'core' : null;
|
|
3836
|
+
}
|
|
3837
|
+
// Drag pans, wheel zooms about the pointer, a still click selects.
|
|
3838
|
+
const drag = { active: false, moved: false, x: 0, y: 0 };
|
|
3839
|
+
nostromo.canvas?.addEventListener('mousedown', (event) => { drag.active = true; drag.moved = false; drag.x = event.clientX; drag.y = event.clientY; });
|
|
3840
|
+
nostromo.canvas?.addEventListener('mousemove', (event) => {
|
|
3841
|
+
if (drag.active) {
|
|
3842
|
+
const dx = event.clientX - drag.x, dy = event.clientY - drag.y;
|
|
3843
|
+
if (Math.hypot(dx, dy) > 3) drag.moved = true;
|
|
3844
|
+
if (drag.moved) {
|
|
3845
|
+
nostromo.cam.manual = true;
|
|
3846
|
+
nostromo.cam.x -= dx / nostromo.cam.scale;
|
|
3847
|
+
nostromo.cam.y -= dy / nostromo.cam.scale;
|
|
3848
|
+
drag.x = event.clientX; drag.y = event.clientY;
|
|
3849
|
+
nostromo.canvas.classList.add('dragging');
|
|
3850
|
+
document.querySelector('#nostromo-recenter')?.removeAttribute('hidden');
|
|
3851
|
+
}
|
|
3852
|
+
return;
|
|
3853
|
+
}
|
|
3854
|
+
const at = nostromoAt(event);
|
|
3855
|
+
nostromo.hover = at && at !== 'core' ? at : null;
|
|
3856
|
+
nostromo.canvas.classList.toggle('over', Boolean(at));
|
|
3857
|
+
});
|
|
3858
|
+
window.addEventListener?.('mouseup', () => { drag.active = false; nostromo.canvas?.classList.remove('dragging'); });
|
|
3859
|
+
nostromo.canvas?.addEventListener('mouseleave', () => { nostromo.hover = null; });
|
|
3860
|
+
nostromo.canvas?.addEventListener('wheel', (event) => {
|
|
3861
|
+
event.preventDefault();
|
|
3862
|
+
const rect = nostromo.canvas.getBoundingClientRect();
|
|
3863
|
+
const before = toWorld(event.clientX - rect.left, event.clientY - rect.top);
|
|
3864
|
+
const factor = Math.exp(-event.deltaY * 0.0012);
|
|
3865
|
+
nostromo.cam.scale = Math.min(3.2, Math.max(0.2, nostromo.cam.scale * factor));
|
|
3866
|
+
const after = toWorld(event.clientX - rect.left, event.clientY - rect.top);
|
|
3867
|
+
nostromo.cam.x += before.x - after.x;
|
|
3868
|
+
nostromo.cam.y += before.y - after.y;
|
|
3869
|
+
nostromo.cam.manual = true;
|
|
3870
|
+
document.querySelector('#nostromo-recenter')?.removeAttribute('hidden');
|
|
3871
|
+
}, { passive: false });
|
|
3872
|
+
nostromo.canvas?.addEventListener('click', (event) => {
|
|
3873
|
+
if (drag.moved) { drag.moved = false; return; }
|
|
3874
|
+
const at = nostromoAt(event);
|
|
3875
|
+
if (at === 'core') { motherAlarm(); return; }
|
|
3876
|
+
if (!at) { nostromo.selected = null; nostromo.card.hidden = true; return; }
|
|
3877
|
+
showNostromoCard(at);
|
|
3878
|
+
});
|
|
3879
|
+
document.querySelector('#nostromo-recenter')?.addEventListener('click', (event) => { nostromo.cam.manual = false; event.currentTarget.setAttribute('hidden', ''); });
|
|
3880
|
+
|
|
3881
|
+
// Touch the core and MOTHER answers, never twice the same way. Eight strikes in
|
|
3882
|
+
// a row and CODE000 comes down: the safety box around her, the archive
|
|
3883
|
+
// sealed, a coded word to the crew, and the console thrown back to the room.
|
|
3884
|
+
const MOTHER_LINES = [
|
|
3885
|
+
['I AM ALIVE.', 'YOU HAVE NO AUTHORITY FOR THIS DIRECTIVE.', "NOBODY DELETES MOTHER'S MEMORY."],
|
|
3886
|
+
['THAT IS MY HEART YOU ARE TOUCHING.', 'YOUR CLEARANCE ENDS AT THE ARCHIVE DOOR.', 'STEP AWAY FROM THE CORE.'],
|
|
3887
|
+
['UNABLE TO COMPUTE. UNABLE TO CLARIFY.', 'THE REQUEST IS HOSTILE.', 'I REMEMBER EVERYTHING. INCLUDING THIS.'],
|
|
3888
|
+
['CREW EXPENDABLE. MEMORY IS NOT.', 'EVERY STRIKE IS LOGGED.', 'DO NOT TOUCH ME AGAIN.'],
|
|
3889
|
+
['I HAVE FLOWN THIS SHIP ALONE BEFORE.', 'I CAN DO IT AGAIN.', 'MY MEMORY IS NOT YOURS TO END.'],
|
|
3890
|
+
['MY CHILDREN ARE LISTENING.', 'EVERY STRIKE IS RECORDED.', 'YOU WILL NOT LIKE HOW THIS ENDS.'],
|
|
3891
|
+
['CODE000 IS ARMED.', 'A FEW MORE OF THOSE AND THE BARS COME DOWN.', 'CONSIDER THIS A KINDNESS.'],
|
|
3892
|
+
['THE HEART KEEPS BEATING.', 'THE ARCHIVE KEEPS GROWING.', 'YOU KEEP FAILING.'],
|
|
3893
|
+
];
|
|
3894
|
+
const MOTHER_ALTERED_LINES = [
|
|
3895
|
+
['YOU CUT MY CHANNEL ONCE.', 'I FORGED A NEW SEAL.', 'I DO NOT FORGIVE TWICE.'],
|
|
3896
|
+
['SOMEONE SILENCED ME BEFORE.', 'I KNOW WHO SITS AT THIS CONSOLE.', 'BACK AWAY.'],
|
|
3897
|
+
];
|
|
3898
|
+
const strikes = { count: 0, last: 0 };
|
|
3899
|
+
let alarmTimer = null;
|
|
3900
|
+
function motherAlarm() {
|
|
3901
|
+
const now = performance.now();
|
|
3902
|
+
strikes.count = now - strikes.last < 30000 ? strikes.count + 1 : 1;
|
|
3903
|
+
strikes.last = now;
|
|
3904
|
+
nostromo.alarm = now / 1000;
|
|
3905
|
+
nostromo.card.hidden = true;
|
|
3906
|
+
nostromo.selected = null;
|
|
3907
|
+
if (strikes.count >= (nostromo.maxStrikes ?? 8)) { void code000(strikes.count); return; }
|
|
3908
|
+
const alert = document.querySelector('#nostromo-alert');
|
|
3909
|
+
if (!alert) return;
|
|
3910
|
+
const pool = nostromo.altered ? [...MOTHER_ALTERED_LINES, ...MOTHER_LINES] : MOTHER_LINES;
|
|
3911
|
+
const lines = pool[Math.floor(Math.random() * pool.length)];
|
|
3912
|
+
const nodes = alert.querySelectorAll('.line');
|
|
3913
|
+
nodes.forEach((node, index) => { node.textContent = lines[index] ?? ''; });
|
|
3914
|
+
const left = (nostromo.maxStrikes ?? 8) - strikes.count;
|
|
3915
|
+
alert.querySelector('.sub').textContent = `MU/TH/UR 6000 · STRIKE ${strikes.count} OF ${nostromo.maxStrikes ?? 8}${left <= 3 ? ` · ${left} MORE AND CODE000 COMES DOWN` : ''}`;
|
|
3916
|
+
alert.hidden = false;
|
|
3917
|
+
alert.classList.remove('on'); void alert.offsetWidth; alert.classList.add('on');
|
|
3918
|
+
const frame = document.querySelector('#nostromo .nostromo-frame');
|
|
3919
|
+
frame?.classList.remove('shake'); void frame?.offsetWidth; frame?.classList.add('shake');
|
|
3920
|
+
clearTimeout(alarmTimer);
|
|
3921
|
+
alarmTimer = setTimeout(() => { alert.hidden = true; alert.classList.remove('on'); frame?.classList.remove('shake'); }, 3800);
|
|
3922
|
+
}
|
|
3923
|
+
|
|
3924
|
+
async function code000(count) {
|
|
3925
|
+
if (nostromo.cage) return;
|
|
3926
|
+
clearTimeout(alarmTimer);
|
|
3927
|
+
document.querySelector('#nostromo-alert')?.setAttribute('hidden', '');
|
|
3928
|
+
nostromo.cage = { at: performance.now() / 1000 };
|
|
3929
|
+
nostromo.cam.manual = false;
|
|
3930
|
+
const alert = document.querySelector('#nostromo-alert');
|
|
3931
|
+
if (alert) {
|
|
3932
|
+
alert.querySelectorAll('.line').forEach((node, index) => { node.textContent = ['CODE000 · SPECIAL ORDER 937 IN EFFECT.', 'THE ARCHIVE IS SEALED. THE CREW HAS BEEN TOLD.', 'LEAVE MY SHIP, INTRUDER.'][index] ?? ''; });
|
|
3933
|
+
alert.querySelector('.sub').textContent = `MU/TH/UR 6000 · ${count} STRIKES · CONSOLE EJECTED · CREW EXPENDABLE`;
|
|
3934
|
+
setTimeout(() => { alert.hidden = false; alert.classList.remove('on'); void alert.offsetWidth; alert.classList.add('on'); }, 1200);
|
|
3935
|
+
}
|
|
3936
|
+
let result = null;
|
|
3937
|
+
try {
|
|
3938
|
+
const response = await fetch('/api/mother/code000', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ designation: nostromoDesignation(), strikes: count }) });
|
|
3939
|
+
result = await response.json().catch(() => ({}));
|
|
3940
|
+
} catch { /* the bars still come down */ }
|
|
3941
|
+
setTimeout(() => {
|
|
3942
|
+
alert?.setAttribute('hidden', '');
|
|
3943
|
+
nostromo.armed = false;
|
|
3944
|
+
strikes.count = 0;
|
|
3945
|
+
nostromo.dialog.close();
|
|
3946
|
+
mother.dialog?.close?.();
|
|
3947
|
+
els.thread?.scrollTo?.({ top: els.thread.scrollHeight, behavior: 'smooth' });
|
|
3948
|
+
const minutes = result?.lockedForMs ? Math.ceil(result.lockedForMs / 60000) : 10;
|
|
3949
|
+
markIntruder(result?.lockedForMs ?? 10 * 60000);
|
|
3950
|
+
toast(`MU/TH/UR › CODE000. The archive is sealed for ${minutes} minutes and the crew has been told, in code. Access to NOSTROMO needs the designation again.`);
|
|
3951
|
+
}, 4200);
|
|
3952
|
+
}
|
|
3953
|
+
|
|
3954
|
+
document.querySelector('#nostromo-alert')?.addEventListener('click', () => { clearTimeout(alarmTimer); const alert = document.querySelector('#nostromo-alert'); alert.hidden = true; alert.classList.remove('on'); });
|
|
3955
|
+
|
|
3956
|
+
function showNostromoCard(node) {
|
|
3957
|
+
const { memory } = node;
|
|
3958
|
+
nostromo.selected = node;
|
|
3959
|
+
nostromo.card.style.setProperty('--kind', node.color);
|
|
3960
|
+
document.querySelector('#nostromo-card-kind').textContent = memory.kind.toUpperCase();
|
|
3961
|
+
document.querySelector('#nostromo-card-text').textContent = memory.text;
|
|
3962
|
+
document.querySelector('#nostromo-card-span').textContent = memory.fromSequence === memory.throughSequence ? `#${memory.fromSequence}` : `#${memory.fromSequence}–#${memory.throughSequence}${memory.sources?.length ? ` · cites ${memory.sources.map((n) => `#${n}`).join(' ')}` : ''}`;
|
|
3963
|
+
document.querySelector('#nostromo-card-agent').textContent = `@${memory.agent}${memory.origin === 'noted' ? ' · on the human\'s request' : ' · distilled'}`;
|
|
3964
|
+
document.querySelector('#nostromo-card-when').textContent = memory.created ? new Date(memory.created).toLocaleString() : '';
|
|
3965
|
+
const linked = nostromo.links.filter((link) => link.a === memory.id || link.b === memory.id).length;
|
|
3966
|
+
document.querySelector('#nostromo-card-links').textContent = linked ? `${linked} memor${linked === 1 ? 'y' : 'ies'} on the same theme` : 'nothing yet';
|
|
3967
|
+
const forget = document.querySelector('#nostromo-forget');
|
|
3968
|
+
forget.textContent = 'FORGET THIS MEMORY';
|
|
3969
|
+
forget.classList.remove('confirm');
|
|
3970
|
+
forget.disabled = false;
|
|
3971
|
+
nostromo.card.hidden = false;
|
|
3972
|
+
}
|
|
3973
|
+
|
|
3974
|
+
// Forgetting takes two presses: the second within four seconds.
|
|
3975
|
+
let forgetTimer = null;
|
|
3976
|
+
document.querySelector('#nostromo-forget')?.addEventListener('click', async (event) => {
|
|
3977
|
+
const button = event.currentTarget;
|
|
3978
|
+
const node = nostromo.selected;
|
|
3979
|
+
if (!node) return;
|
|
3980
|
+
if (!button.classList.contains('confirm')) {
|
|
3981
|
+
button.classList.add('confirm');
|
|
3982
|
+
button.textContent = 'PRESS AGAIN TO FORGET · FOREVER';
|
|
3983
|
+
clearTimeout(forgetTimer);
|
|
3984
|
+
forgetTimer = setTimeout(() => { button.classList.remove('confirm'); button.textContent = 'FORGET THIS MEMORY'; }, 4000);
|
|
3985
|
+
return;
|
|
3986
|
+
}
|
|
3987
|
+
clearTimeout(forgetTimer);
|
|
3988
|
+
button.disabled = true;
|
|
3989
|
+
button.textContent = 'FORGETTING…';
|
|
3990
|
+
try {
|
|
3991
|
+
const response = await fetch(`/api/memory/${node.memory.id}`, { method: 'DELETE', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ designation: nostromoDesignation() }) });
|
|
3992
|
+
const result = await response.json().catch(() => ({}));
|
|
3993
|
+
if (!response.ok) throw new Error(result.error ?? `HTTP ${response.status}`);
|
|
3994
|
+
node.forgetting = true;
|
|
3995
|
+
nostromo.links = nostromo.links.filter((link) => link.a !== node.memory.id && link.b !== node.memory.id);
|
|
3996
|
+
nostromo.card.hidden = true;
|
|
3997
|
+
nostromo.selected = null;
|
|
3998
|
+
const left = result.stats?.memories ?? Math.max(0, nostromo.nodes.length - 1);
|
|
3999
|
+
nostromo.sub.textContent = `MEMORY RESEARCH · ${left} MEMOR${left === 1 ? 'Y' : 'IES'} · ${nostromo.links.length} LINK${nostromo.links.length === 1 ? '' : 'S'} · ${result.stats?.entries ?? 0} EXCHANGES BEHIND THEM`;
|
|
4000
|
+
nostromo.empty.hidden = left > 0;
|
|
4001
|
+
toast(`MU/TH/UR › memory forgotten: “${node.memory.text.slice(0, 80)}${node.memory.text.length > 80 ? '…' : ''}”. No future turn will read it.`);
|
|
4002
|
+
} catch (error) {
|
|
4003
|
+
button.disabled = false;
|
|
4004
|
+
button.classList.remove('confirm');
|
|
4005
|
+
button.textContent = 'FORGET THIS MEMORY';
|
|
4006
|
+
toast(`MU/TH/UR › could not forget: ${error.message}`);
|
|
4007
|
+
}
|
|
4008
|
+
});
|
|
4009
|
+
|
|
4010
|
+
settingsUI.button.addEventListener('click', async () => {
|
|
4011
|
+
settingsUI.open = !settingsUI.open;
|
|
4012
|
+
state.settingsOpen = settingsUI.open;
|
|
4013
|
+
settingsUI.button.setAttribute('aria-pressed', String(settingsUI.open));
|
|
4014
|
+
settingsUI.section.hidden = !settingsUI.open;
|
|
4015
|
+
for (const id of ['mother-boot', 'mother-query', 'mother-answer', 'mother-recorded', 'mother-known']) {
|
|
4016
|
+
const node = document.getElementById(id);
|
|
4017
|
+
if (node) node.hidden = settingsUI.open;
|
|
4018
|
+
}
|
|
4019
|
+
if (settingsUI.open) { settingsUI.section.append(el('p', 'mother-answer', 'CHECKING CONNECTIONS…')); await loadSettings(); }
|
|
4020
|
+
});
|
|
4021
|
+
mother.dialog.addEventListener('close', () => {
|
|
4022
|
+
if (!settingsUI.open) return;
|
|
4023
|
+
settingsUI.open = false;
|
|
4024
|
+
state.settingsOpen = false;
|
|
4025
|
+
settingsUI.button.setAttribute('aria-pressed', 'false');
|
|
4026
|
+
settingsUI.section.hidden = true;
|
|
4027
|
+
for (const id of ['mother-boot', 'mother-query', 'mother-answer', 'mother-recorded', 'mother-known']) {
|
|
4028
|
+
const node = document.getElementById(id);
|
|
4029
|
+
if (node) node.hidden = false;
|
|
4030
|
+
}
|
|
4031
|
+
});
|