@jossuealcala/madre 1.0.1 → 1.0.2
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/ADDITIONAL-TERMS.md +93 -0
- package/CHANGELOG.en.md +1945 -0
- package/CHANGELOG.md +34 -1
- package/CONTRIBUTING.md +1 -1
- package/LICENSE +661 -202
- package/NOTICE +26 -14
- package/README.en.md +245 -0
- package/README.md +19 -10
- package/TRADEMARKS.en.md +50 -0
- package/TRADEMARKS.md +33 -16
- package/bin/madre.mjs +2 -0
- package/docs/INTERNALS.md +6 -0
- package/docs/REFERENCE.md +11 -2
- package/docs/en/INTERNALS.md +103 -0
- package/docs/en/REFERENCE.md +339 -0
- package/docs/en/SDK.md +306 -0
- package/docs/en/training/README.md +59 -0
- package/docs/training/README.md +20 -8
- package/docs/training/train.sh +2 -1
- package/package.json +11 -3
- package/public/abduction.js +2 -0
- package/public/app.js +1549 -409
- package/public/attribution.js +71 -0
- package/public/brands.js +3 -1
- package/public/editor.js +2 -0
- package/public/es.js +620 -400
- package/public/i18n.js +25 -1
- package/public/index.html +13 -10
- package/public/inquiry.js +2 -0
- package/public/loader.js +2 -0
- package/public/macroverso.js +2 -0
- package/public/nostromo-engine.js +2 -0
- package/public/resay.js +6 -0
- package/public/review-ask.js +2 -0
- package/public/routing.js +23 -1
- package/public/salas-mac.css +21 -4
- package/public/salas-mac.html +5 -1
- package/public/salas-mac.js +53 -28
- package/public/salas.css +2 -0
- package/public/salas.html +1 -0
- package/public/salas.js +4 -2
- package/public/styles.css +957 -18
- package/public/syntax.js +2 -0
- package/public/touch.js +2 -0
- package/public/tour-chapters.js +2 -0
- package/public/tour.js +37 -0
- package/public/troubleshooting.js +18 -16
- package/src/adapters/claude.mjs +2 -0
- package/src/adapters/codex.mjs +2 -0
- package/src/adapters/gemini.mjs +3 -1
- package/src/adapters/madre.mjs +3 -1
- package/src/adapters/opencode.mjs +7 -3
- package/src/adapters/process.mjs +2 -0
- package/src/asking.mjs +2 -0
- package/src/auth-probe.mjs +3 -1
- package/src/bridge/devices.mjs +2 -0
- package/src/bridge/handoff.mjs +2 -0
- package/src/bridge/host.mjs +2 -0
- package/src/bridge/network.mjs +2 -0
- package/src/bridge/policy.mjs +3 -1
- package/src/bridge/qr.mjs +2 -0
- package/src/bridge/server.mjs +2 -0
- package/src/capabilities.mjs +2 -0
- package/src/chats.mjs +2 -0
- package/src/checkpoint.mjs +2 -0
- package/src/choices.mjs +2 -0
- package/src/cli-args.mjs +2 -0
- package/src/cold.mjs +2 -0
- package/src/commands.mjs +2 -0
- package/src/config.mjs +2 -0
- package/src/conversation-context.mjs +15 -1
- package/src/credentials.mjs +32 -3
- package/src/dataset.mjs +2 -0
- package/src/directives.mjs +2 -0
- package/src/distiller.mjs +2 -0
- package/src/embeddings.mjs +2 -0
- package/src/enlaces.mjs +6 -4
- package/src/event-store.mjs +2 -0
- package/src/exam.mjs +6 -4
- package/src/extensions.mjs +2 -0
- package/src/eyecat-watch.mjs +2 -0
- package/src/eyecat.mjs +2 -0
- package/src/file-ops.mjs +2 -0
- package/src/files.mjs +32 -0
- package/src/github.mjs +2 -0
- package/src/heartbeat.mjs +2 -0
- package/src/i18n.mjs +15 -1
- package/src/image-studio.mjs +2 -0
- package/src/json-file.mjs +2 -0
- package/src/launch.mjs +2 -0
- package/src/lease.mjs +5 -1
- package/src/lobby-work.mjs +2 -0
- package/src/local-ai.mjs +2 -0
- package/src/looks.mjs +2 -0
- package/src/maturity.mjs +3 -1
- package/src/mcp/connect.mjs +345 -0
- package/src/mcp/github-server.mjs +2 -0
- package/src/mcp/image-server.mjs +2 -0
- package/src/mcp/memory-server.mjs +2 -0
- package/src/mcp/proxy.mjs +153 -0
- package/src/mcp/smtp-server.mjs +2 -0
- package/src/memory-dashboard.mjs +2 -0
- package/src/memory-tools.mjs +2 -0
- package/src/memory.mjs +2 -0
- package/src/models.mjs +45 -3
- package/src/modules/ahp.mjs +2 -0
- package/src/modules/ash-policy.mjs +2 -0
- package/src/modules/ash.mjs +3 -1
- package/src/modules/bishop-plan.mjs +5 -3
- package/src/modules/bishop.mjs +13 -11
- package/src/modules/choices.mjs +2 -0
- package/src/modules/correo.mjs +2 -0
- package/src/modules/enlaces.mjs +2 -0
- package/src/modules/git-pulse.mjs +2 -0
- package/src/modules/github.mjs +2 -0
- package/src/modules/helpers.mjs +2 -0
- package/src/modules/image-studio.mjs +3 -1
- package/src/modules/index.mjs +4 -1
- package/src/modules/intents-calibration.mjs +2 -0
- package/src/modules/intents-desk.mjs +4 -2
- package/src/modules/intents-observe.mjs +2 -0
- package/src/modules/intents-policy.mjs +4 -1
- package/src/modules/intents-try.mjs +2 -0
- package/src/modules/intents.mjs +8 -6
- package/src/modules/mcp-connect.mjs +293 -0
- package/src/modules/ollama.mjs +3 -1
- package/src/modules/playwright.mjs +25 -10
- package/src/modules/ripley.mjs +2 -0
- package/src/modules/runs.mjs +5 -3
- package/src/modules/sdk.mjs +2 -0
- package/src/modules/updates.mjs +2 -0
- package/src/mother.mjs +2 -0
- package/src/ollama.mjs +2 -0
- package/src/outbound.mjs +7 -5
- package/src/override.mjs +2 -0
- package/src/ports.mjs +2 -0
- package/src/prices.mjs +2 -0
- package/src/privacy.mjs +2 -0
- package/src/quota-monitor.mjs +2 -0
- package/src/quota-sources.mjs +2 -0
- package/src/request-guard.mjs +2 -0
- package/src/review-ask.mjs +2 -0
- package/src/room/archivist.mjs +2 -0
- package/src/room/attachments.mjs +2 -0
- package/src/room/budget.mjs +2 -0
- package/src/room/context.mjs +2 -0
- package/src/room/control.mjs +7 -1
- package/src/room/copilot.mjs +76 -0
- package/src/room/economy.mjs +2 -0
- package/src/room/escalation.mjs +2 -0
- package/src/room/ghost.mjs +2 -0
- package/src/room/guard.mjs +2 -0
- package/src/room/prompt.mjs +13 -4
- package/src/room/returns.mjs +82 -0
- package/src/room/runs.mjs +4 -0
- package/src/room/vectors.mjs +2 -0
- package/src/room.mjs +209 -31
- package/src/router.mjs +2 -0
- package/src/runs.mjs +2 -0
- package/src/runtime-detection.mjs +2 -0
- package/src/salas.mjs +2 -0
- package/src/sentinel-errors.mjs +2 -0
- package/src/server-sentinel.mjs +2 -0
- package/src/server.mjs +141 -34
- package/src/setup.mjs +2 -0
- package/src/smtp.mjs +2 -0
- package/src/tour.mjs +2 -0
- package/src/updates.mjs +2 -0
- package/src/usage-sentinel.mjs +2 -0
- package/src/vault.mjs +2 -0
- package/src/verdict.mjs +3 -1
- package/src/whats-new.mjs +2 -0
package/public/app.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
|
-
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
3
|
+
import { archiveQuestion, modeAskedWithTime, MODE_WINDOWS, windowFor } from './routing.js';
|
|
2
4
|
import { calledByName, abduct } from './abduction.js';
|
|
3
5
|
import { brandOf, monogram } from './brands.js';
|
|
4
6
|
import { allConditions, detectPlatform, diagnose, fixesFor, PLATFORMS, searchConditions } from './troubleshooting.js';
|
|
5
7
|
import { answerFor, INQUIRIES, STRIKES } from './inquiry.js';
|
|
6
|
-
import { DEFAULT_LANGUAGE, LANGUAGES, isLanguage, language, pick, setLanguage, t, fill } from './i18n.js';
|
|
8
|
+
import { DEFAULT_LANGUAGE, LANGUAGES, browserLanguage, isLanguage, language, pick, setLanguage, t, fill } from './i18n.js';
|
|
7
9
|
import { createEditor } from './editor.js';
|
|
8
10
|
import { resay } from './resay.js';
|
|
9
11
|
import { startReviewAsk } from './review-ask.js';
|
|
10
|
-
import { startTour as startWelcome } from './tour.js';
|
|
12
|
+
import { spotlight, startTour as startWelcome } from './tour.js';
|
|
11
13
|
import { dropStickyHover } from './touch.js';
|
|
14
|
+
import { drawAttribution, drawLegal } from './attribution.js';
|
|
12
15
|
// Where nothing can hover (a phone), a tapped key does not stay lit (public/touch.js).
|
|
13
16
|
dropStickyHover();
|
|
14
17
|
// Made the first time the room asks for it (roomTour, below), which can be while this file is
|
|
@@ -105,10 +108,17 @@ if (document.documentElement) document.documentElement.lang = language();
|
|
|
105
108
|
|
|
106
109
|
// A browser that has never chosen takes what this machine chose, once, and remembers it. It
|
|
107
110
|
// cannot loop: the choice is written before the page is asked to come back.
|
|
108
|
-
|
|
111
|
+
// A machine nobody has set (2026-10-15) takes this browser's language, and keeps it for Home and
|
|
112
|
+
// every project, as if it had been chosen in Settings.
|
|
113
|
+
async function adoptLanguage(fromMachine, chosenOnMachine = true) {
|
|
109
114
|
// The half on the right never writes the choice, so it would reload for ever; the left decides.
|
|
110
|
-
if (SPLIT_CHAT) return;
|
|
111
|
-
if (
|
|
115
|
+
if (SPLIT_CHAT || chosenLanguage) return;
|
|
116
|
+
if (chosenOnMachine === false && !state.device) {
|
|
117
|
+
const mine = browserLanguage();
|
|
118
|
+
const saved = await fetch('/api/language', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ language: mine }) }).then((response) => response.ok).catch(() => false);
|
|
119
|
+
if (saved) fromMachine = mine;
|
|
120
|
+
}
|
|
121
|
+
if (!isLanguage(fromMachine) || fromMachine === language()) return;
|
|
112
122
|
try { localStorage.setItem('pulse.language', fromMachine); } catch { return; }
|
|
113
123
|
location.reload();
|
|
114
124
|
}
|
|
@@ -169,9 +179,10 @@ const PLACEHOLDERS = {
|
|
|
169
179
|
plain: t('Type here, human. Ask the project…'),
|
|
170
180
|
create: t('CREATE on: what to make. New files land where they belong in the project; nothing existing changes.'),
|
|
171
181
|
ash: t('Compact prose. Nothing you write is altered; only the answers get shorter.'),
|
|
172
|
-
ghost: t('
|
|
182
|
+
ghost: t('Incognito. Ask anything; nothing is saved and the project will not remember it.'),
|
|
173
183
|
control: t('EDIT unlocked. Say what to change in the project; every action runs without asking.'),
|
|
174
|
-
|
|
184
|
+
copilot: t('Copilot on. Say what you want done; it edits, plans and checks on its own.'),
|
|
185
|
+
airlock: t('#4 FULL ACCESS unlocked. Commands run here, and changes are pushed or deployed outside your computer. Say exactly what should go out.'),
|
|
175
186
|
expendable: t('Type here, human. MADRE is listening.'),
|
|
176
187
|
memory: t('Ask what the project remembers. @madre answers from memory with citations; it does not act.'),
|
|
177
188
|
};
|
|
@@ -183,11 +194,11 @@ const MAX_ROWS = 14;
|
|
|
183
194
|
// Plain names, in the reader's language (2026-10-10): what each one lets an agent do. The agents
|
|
184
195
|
// themselves are told the modes by their old names (src/capabilities.mjs), which is the core's.
|
|
185
196
|
const MODES = {
|
|
186
|
-
0: { key: 'ghost', label: t('INCOGNITO'), hint: t('
|
|
187
|
-
1: { key: 'exchange', label: t('CHAT ONLY'), hint: t('Read the project and talk about it. Writes nothing.') },
|
|
188
|
-
2: { key: 'create', label: t('CREATE'), hint: t('Add new files where they belong in the project. Existing files stay untouched.') },
|
|
189
|
-
3: { key: 'control', label: t('EDIT'), hint: t('Edit the project itself, no approval per action. Unlock required.') },
|
|
190
|
-
4: { key: 'airlock', label: t('FULL ACCESS'), hint: t('Run commands, push, deploy. What leaves
|
|
197
|
+
0: { key: 'ghost', label: t('INCOGNITO'), hint: t('Incognito. Nothing is saved; it is gone when you reload the page.') },
|
|
198
|
+
1: { key: 'exchange', label: t('CHAT ONLY'), hint: t('Read the project and talk about it, and plan with other agents. Writes nothing.') },
|
|
199
|
+
2: { key: 'create', label: t('CREATE'), hint: t('Add new files where they belong in the project, and plan with other agents. Existing files stay untouched.') },
|
|
200
|
+
3: { key: 'control', label: t('EDIT'), hint: t('Edit the project itself, no approval per action, and plan with other agents. Unlock required.') },
|
|
201
|
+
4: { key: 'airlock', label: t('FULL ACCESS'), hint: t('Run commands, push, deploy, and plan with other agents. What leaves your computer cannot be undone. Confirm twice.') },
|
|
191
202
|
};
|
|
192
203
|
|
|
193
204
|
// A sign-in in flight. Claude's CLI opens a page and then waits to be told the code that page
|
|
@@ -228,6 +239,7 @@ const state = {
|
|
|
228
239
|
reports: new Map(), // id -> sentinel report (unknown conditions and crashes)
|
|
229
240
|
capabilities: {}, // id -> { read, imageIn, write, imageGen, web }
|
|
230
241
|
pending: [], // attachments uploaded for the next message
|
|
242
|
+
runsReturned: new Set(), // command requests whose output already went back to the agent that asked
|
|
231
243
|
projectRoot: '',
|
|
232
244
|
create: false, // creation lease armed for the next message
|
|
233
245
|
moduleCommand: false, // /module is being composed: CREATE plus the guided SDK contract
|
|
@@ -247,7 +259,7 @@ const state = {
|
|
|
247
259
|
effortOnce: null,
|
|
248
260
|
// While AJUSTES shows a category, the panel that holds it draws there too, from the same code and
|
|
249
261
|
// the same answers MU/TH/UR draws from: one renderer, two places, never two copies of a rule.
|
|
250
|
-
settingsTargets: { agents: null, room: null, turns: null, memory: null, privacy: null, permissions: null, release: null, sentinel: null, files: null },
|
|
262
|
+
settingsTargets: { agents: null, room: null, turns: null, memory: null, privacy: null, permissions: null, release: null, sentinel: null, files: null, mcp: null },
|
|
251
263
|
settingsWindow: null, // AJUSTES, once its part of the file has run
|
|
252
264
|
};
|
|
253
265
|
try { state.chosenModel = JSON.parse(localStorage.getItem('pulse.chosenModel') ?? '{}') || {}; } catch { state.chosenModel = {}; }
|
|
@@ -274,8 +286,8 @@ const vendorWord = (id) => { const said = brandOf(id).vendor ?? ''; return VENDO
|
|
|
274
286
|
// the top of this file, long before half of it has run: `renderLease` reached for these while
|
|
275
287
|
// they were still in their dead zone, the try around it swallowed the error, and every CREATE
|
|
276
288
|
// strip in the history simply never appeared.
|
|
277
|
-
const CAP_LABELS = { read: t('read'), imageIn: t('
|
|
278
|
-
const CAP_VERBS = { read: t('read the project'), imageIn: t('
|
|
289
|
+
const CAP_LABELS = { read: t('read'), imageIn: t('sees images'), write: t('create'), imageGen: t('image gen'), web: t('web') };
|
|
290
|
+
const CAP_VERBS = { read: t('read the project'), imageIn: t('receive images'), write: t('create files'), imageGen: t('generate images'), web: t('reach the web') };
|
|
279
291
|
const capVerb = (scope) => CAP_VERBS[scope] ?? CAP_LABELS[scope] ?? scope;
|
|
280
292
|
|
|
281
293
|
// MODULES' own handles. Up here with the rest of the module's state because the transcript is
|
|
@@ -302,12 +314,25 @@ const modules = {
|
|
|
302
314
|
// Which agent last wrote to a path, taken from what the room already recorded: the artifacts a
|
|
303
315
|
// creation lease produced, and the files a CONTROL turn changed. Nothing new is stored and nothing
|
|
304
316
|
// is inferred from the filesystem — if MADRE did not see it happen, the file carries no mark.
|
|
305
|
-
|
|
306
|
-
|
|
317
|
+
// Each path keeps who touched it last and when (2026-10-14), so a folder can say that something
|
|
318
|
+
// under it changed and the Finder can lead from the top of the project down to the file.
|
|
319
|
+
const touched = new Map(); // path -> { agent, at }
|
|
320
|
+
function markTouched(agent, files, at = null) {
|
|
321
|
+
const when = at ? Date.parse(at) || Date.now() : Date.now();
|
|
307
322
|
for (const file of files ?? []) {
|
|
308
323
|
const path = typeof file === 'string' ? file : file?.path;
|
|
309
|
-
if (path
|
|
324
|
+
if (!path || !agent) continue;
|
|
325
|
+
const key = String(path).replace(/^\.?\//, '');
|
|
326
|
+
if ((touched.get(key)?.at ?? 0) <= when) touched.set(key, { agent, at: when });
|
|
310
327
|
}
|
|
328
|
+
if (!tree.open) tree.stale = true;
|
|
329
|
+
}
|
|
330
|
+
// The newest touch under a folder, for its dot.
|
|
331
|
+
function touchedUnder(dir) {
|
|
332
|
+
const prefix = `${dir}/`;
|
|
333
|
+
let newest = null;
|
|
334
|
+
for (const [path, touch] of touched) if (path.startsWith(prefix) && (!newest || touch.at > newest.at)) newest = { ...touch, path };
|
|
335
|
+
return newest;
|
|
311
336
|
}
|
|
312
337
|
|
|
313
338
|
|
|
@@ -365,19 +390,19 @@ const tokensFor = (chars) => Math.round((Number(chars) || 0) / CH_PER_TOKEN);
|
|
|
365
390
|
// room's language: what was written down never moves. Declared one by one, so the catalogue's
|
|
366
391
|
// guard sees all five and an id nobody translated cannot slip past as itself. Up here with the
|
|
367
392
|
// colours and for the same reason: the first replay prints the word before NOSTROMO is evaluated.
|
|
368
|
-
const KIND_WORDS = { decision: t('DECISION'), fact: t('FACT'), preference: t('PREFERENCE'), question: t('QUESTION'), aberration: t('
|
|
393
|
+
const KIND_WORDS = { decision: t('DECISION'), fact: t('FACT'), preference: t('PREFERENCE'), question: t('QUESTION'), aberration: t('DOUBTFUL MEMORY') };
|
|
369
394
|
const kindWord = (kind) => KIND_WORDS[kind] ?? String(kind).toUpperCase();
|
|
370
395
|
|
|
371
396
|
// Where a memory stands aboard, which is a different question from what it is — so it is said in
|
|
372
397
|
// words and not in a second hue: the map already spends colour on the kind, and two palettes on
|
|
373
398
|
// one dot is how a map stops being readable. What a zone changes on the map is whether the star
|
|
374
399
|
// is lit at all, because that is what the zone means.
|
|
375
|
-
const ZONE_WORDS = { hold: t('
|
|
400
|
+
const ZONE_WORDS = { hold: t('UNCONFIRMED'), bridge: t('CONFIRMED'), medbay: t('QUARANTINED'), jettisoned: t('DISCARDED') };
|
|
376
401
|
const ZONE_NOTE = {
|
|
377
|
-
hold: t('
|
|
378
|
-
bridge: t('You marked the reply it came from as good.
|
|
379
|
-
medbay: t('Quarantined: it is false, or it came from a reply you marked bad. It never
|
|
380
|
-
jettisoned: t('
|
|
402
|
+
hold: t('A summarized note nobody has reviewed yet. It is used in answers.'),
|
|
403
|
+
bridge: t('You marked the reply it came from as good. Confirmed: it is used in answers.'),
|
|
404
|
+
medbay: t('Quarantined: it is false, or it came from a reply you marked bad. It is never used in answers.'),
|
|
405
|
+
jettisoned: t('Discarded: kept only so it is not learned again. It is never used in answers.'),
|
|
381
406
|
};
|
|
382
407
|
const zoneWord = (zone) => ZONE_WORDS[zone] ?? String(zone ?? '').toUpperCase();
|
|
383
408
|
|
|
@@ -553,19 +578,31 @@ function projectRelative(path) {
|
|
|
553
578
|
}
|
|
554
579
|
|
|
555
580
|
// Plain text with file paths turned into viewer links.
|
|
581
|
+
// A memory cited by its sequence — [#214], [#64–#1431], [pulse · decision · #1928–#2141] — is a
|
|
582
|
+
// piece of its own (2026-10-15), so it can be shown as a reference; its words stay exactly as written.
|
|
583
|
+
const CITE = /\[(?:[^\]\n]{0,60}·\s*)?#\d+(?:\s*[–-]\s*#?\d+)?\]/g;
|
|
584
|
+
function appendCites(text, into) {
|
|
585
|
+
let last = 0;
|
|
586
|
+
for (const match of text.matchAll(CITE)) {
|
|
587
|
+
if (match.index > last) into.append(text.slice(last, match.index));
|
|
588
|
+
into.append(el('span', 'cite', match[0]));
|
|
589
|
+
last = match.index + match[0].length;
|
|
590
|
+
}
|
|
591
|
+
if (last < text.length) into.append(text.slice(last));
|
|
592
|
+
}
|
|
556
593
|
function appendTextWithPaths(text, into) {
|
|
557
594
|
let last = 0;
|
|
558
595
|
for (const match of text.matchAll(FILE_PATH)) {
|
|
559
596
|
const rel = projectRelative(match[1]);
|
|
560
597
|
if (!rel) continue;
|
|
561
|
-
if (match.index > last)
|
|
598
|
+
if (match.index > last) appendCites(text.slice(last, match.index), into);
|
|
562
599
|
const link = el('a', 'file-link', match[0]);
|
|
563
600
|
link.href = `/api/files?path=${encodeURIComponent(rel)}`;
|
|
564
601
|
link.addEventListener('click', (event) => { event.preventDefault(); openViewer({ root: 'project', path: rel, line: match[2] ? Number(match[2]) : null }); });
|
|
565
602
|
into.append(link);
|
|
566
603
|
last = match.index + match[0].length;
|
|
567
604
|
}
|
|
568
|
-
if (last < text.length)
|
|
605
|
+
if (last < text.length) appendCites(text.slice(last), into);
|
|
569
606
|
}
|
|
570
607
|
|
|
571
608
|
// A picture of the project is drawn; one from elsewhere is not fetched until someone asks (WEB-01):
|
|
@@ -1118,8 +1155,8 @@ const editorDirty = () => Boolean(editing.editor?.dirty());
|
|
|
1118
1155
|
let lastAsk = null;
|
|
1119
1156
|
const askLeaveEdits = () => roomAsk({
|
|
1120
1157
|
title: t('UNSAVED CHANGES'),
|
|
1121
|
-
body: t('There are unsaved changes in this file.
|
|
1122
|
-
choices: [{ value: 'stay', label: t('KEEP EDITING'), focus: true }, { value: 'leave', label: t('
|
|
1158
|
+
body: t('There are unsaved changes in this file. Discard them?'),
|
|
1159
|
+
choices: [{ value: 'stay', label: t('KEEP EDITING'), focus: true }, { value: 'leave', label: t('DISCARD THEM'), tone: 'danger' }],
|
|
1123
1160
|
});
|
|
1124
1161
|
async function leaveEditor({ ask = true } = {}) {
|
|
1125
1162
|
if (!editing.editor) return true;
|
|
@@ -1159,7 +1196,7 @@ function syncEditButton() {
|
|
|
1159
1196
|
if (!viewer.dialog || !viewer.body) return;
|
|
1160
1197
|
const bar = el('div', 'viewer-status');
|
|
1161
1198
|
const mod = /Mac|iPhone|iPad/.test(navigator.platform ?? '') ? '⌘' : 'Ctrl';
|
|
1162
|
-
if (viewerUI.hintSelect) viewerUI.hintSelect.textContent = t('Click a number to pick a line · drag
|
|
1199
|
+
if (viewerUI.hintSelect) viewerUI.hintSelect.textContent = t('Click a number to pick a line · drag to pick several · Shift for a range · {mod} to add one more', { mod });
|
|
1163
1200
|
for (const part of [viewerUI.selection, viewerUI.review, viewerUI.hintSelect]) if (part) bar.append(part);
|
|
1164
1201
|
viewer.body.after?.(bar);
|
|
1165
1202
|
})();
|
|
@@ -1497,7 +1534,7 @@ function browserCloseTab(id) {
|
|
|
1497
1534
|
function browserRefused(url, { why } = {}) {
|
|
1498
1535
|
if (!browser.body) return;
|
|
1499
1536
|
const box = el('div', 'browser-refused');
|
|
1500
|
-
box.append(el('h4', null, t('{host}
|
|
1537
|
+
box.append(el('h4', null, t('{host} does not allow being shown inside MADRE', { host: browserTitleFor(url) })));
|
|
1501
1538
|
box.append(el('p', null, why ?? t('The site answers with a rule that forbids any page from putting it inside a frame. It is the standard defence against clickjacking, and PREVIEW neither can nor should override it.')));
|
|
1502
1539
|
const open = el('button', 'browser-go', t('OPEN IN A TAB'));
|
|
1503
1540
|
open.type = 'button';
|
|
@@ -1602,7 +1639,7 @@ async function browserServers() {
|
|
|
1602
1639
|
portsMenuShown(true);
|
|
1603
1640
|
let read;
|
|
1604
1641
|
try { read = await fetch('/api/ports').then((response) => response.json()); }
|
|
1605
|
-
catch { menu.replaceChildren(el('div', 'ports-note', t('COULD NOT
|
|
1642
|
+
catch { menu.replaceChildren(el('div', 'ports-note', t('COULD NOT SEE WHICH SERVERS ARE RUNNING'))); browser.servers?.setAttribute('data-live', 'no'); return; }
|
|
1606
1643
|
paintPorts(read);
|
|
1607
1644
|
}
|
|
1608
1645
|
async function refreshBrowserServers(button) {
|
|
@@ -1679,7 +1716,7 @@ function paintPorts(read) {
|
|
|
1679
1716
|
// A server that sends no Content-Type used to leave the separator hanging: «:5173 node · 200 · ».
|
|
1680
1717
|
const kind = found.html ? '' : (found.contentType ?? '').trim();
|
|
1681
1718
|
const what = `${found.command} · ${found.status}${kind ? ` · ${kind}` : ''}`;
|
|
1682
|
-
row.append(el('i', 'ports-lamp'), el('b', null, `:${found.port}`), el('span', null, found.framable === false ? t('{what} ·
|
|
1719
|
+
row.append(el('i', 'ports-lamp'), el('b', null, `:${found.port}`), el('span', null, found.framable === false ? t('{what} · cannot be shown in here', { what }) : what));
|
|
1683
1720
|
row.addEventListener('click', () => openPort(found));
|
|
1684
1721
|
menu.append(row);
|
|
1685
1722
|
}
|
|
@@ -1687,12 +1724,12 @@ function paintPorts(read) {
|
|
|
1687
1724
|
// about it made a running server read as no server at all.
|
|
1688
1725
|
for (const one of wider) {
|
|
1689
1726
|
const row = el('div', 'ports-row wider');
|
|
1690
|
-
row.append(el('i', 'ports-lamp'), el('b', null, `:${one.port}`), el('span', null, t('{command} ·
|
|
1727
|
+
row.append(el('i', 'ports-lamp'), el('b', null, `:${one.port}`), el('span', null, t('{command} · open to your whole network · start it on 127.0.0.1 to open it here', { command: one.command })));
|
|
1691
1728
|
menu.append(row);
|
|
1692
1729
|
}
|
|
1693
1730
|
const foot = read?.others
|
|
1694
1731
|
? t('{n} more listening on this computer, from other folders · not shown', { n: read.others })
|
|
1695
|
-
: t('ONLY WHAT THIS PROJECT STARTED · THE
|
|
1732
|
+
: t('ONLY WHAT THIS PROJECT STARTED · THE FAILURE MONITOR WATCHES IT');
|
|
1696
1733
|
menu.append(el('div', 'ports-note', foot));
|
|
1697
1734
|
}
|
|
1698
1735
|
|
|
@@ -1726,7 +1763,7 @@ function pointTool(id) {
|
|
|
1726
1763
|
pointClear();
|
|
1727
1764
|
for (const button of pointing.layer?.querySelectorAll('.point-tool') ?? []) button.setAttribute('aria-pressed', String(button.dataset.tool === id));
|
|
1728
1765
|
pointing.layer?.classList.toggle('area', id === 'area');
|
|
1729
|
-
if (pointing.hint) pointing.hint.textContent = id === 'dot' ? t('
|
|
1766
|
+
if (pointing.hint) pointing.hint.textContent = id === 'dot' ? t('Click on the page where you want to point') : t('Drag over the part you mean');
|
|
1730
1767
|
}
|
|
1731
1768
|
function setPointing(on) {
|
|
1732
1769
|
if (on === pointing.on) return;
|
|
@@ -1934,7 +1971,7 @@ function serverStopped({ port, command }) {
|
|
|
1934
1971
|
browser.body.querySelector('.browser-down')?.remove();
|
|
1935
1972
|
const card = el('div', 'browser-down');
|
|
1936
1973
|
const inner = el('div', 'browser-down-card');
|
|
1937
|
-
inner.append(el('i', 'ports-lamp off'), el('h4', null, t(':{port} stopped', { port })), el('p', null, t('The
|
|
1974
|
+
inner.append(el('i', 'ports-lamp off'), el('h4', null, t(':{port} stopped', { port })), el('p', null, t('The failure monitor saw {command} stop answering. Start it again and this page comes back on its own.', { command })));
|
|
1938
1975
|
card.append(inner);
|
|
1939
1976
|
browser.body.append(card);
|
|
1940
1977
|
}
|
|
@@ -1978,7 +2015,7 @@ browserGrip?.addEventListener('pointerdown', (event) => {
|
|
|
1978
2015
|
}, { once: true });
|
|
1979
2016
|
});
|
|
1980
2017
|
|
|
1981
|
-
browser.button?.addEventListener('click', () => { if (browser.dialog && !browser.dialog.hidden) browserHide(); else browserShow(); });
|
|
2018
|
+
browser.button?.addEventListener('click', () => { if (browser.dialog && !browser.dialog.hidden && !atTheEdge(browser.dialog)) browserHide(); else browserShow(); });
|
|
1982
2019
|
browser.close?.addEventListener('click', () => browserHide());
|
|
1983
2020
|
browser.servers?.addEventListener('click', () => { void browserServers(); });
|
|
1984
2021
|
// A list left hanging over the page is the kind of thing a human closes by clicking away from it.
|
|
@@ -2382,7 +2419,7 @@ function renderAgents() {
|
|
|
2382
2419
|
});
|
|
2383
2420
|
node.title = [
|
|
2384
2421
|
`${agent.label}${vendorWord(agent.id) ? ` · ${vendorWord(agent.id)}` : ''}`,
|
|
2385
|
-
agent.ready ? t('Ready') : agent.detected ? t('Detected,
|
|
2422
|
+
agent.ready ? t('Ready') : agent.detected ? t('Detected, but MADRE cannot use it yet') : t('Not installed'),
|
|
2386
2423
|
agent.version ? agent.version : null,
|
|
2387
2424
|
official === null
|
|
2388
2425
|
? (agent.tokens ? t('Usage: local 5h window · {pct}% of {total} budget tokens', { pct: Math.round(percent), total: formatTokens(state.budget ?? 0) }) : t('Usage: local window · nothing used yet'))
|
|
@@ -2462,7 +2499,7 @@ function renderPicker() {
|
|
|
2462
2499
|
if (current.local) {
|
|
2463
2500
|
const note = el('span', 'pick-note', t('memory · never writes'));
|
|
2464
2501
|
const measured = state.localModel?.checked;
|
|
2465
|
-
note.title = `${t('@madre runs on this machine and speaks for what the project remembers. "@madre, ask the agents …" opens a round with every agent online. To change files, write to
|
|
2502
|
+
note.title = `${t('@madre runs on this machine and speaks for what the project remembers. "@madre, ask the agents …" opens a round with every agent online. To change files, write to one of the coding agents.')}${
|
|
2466
2503
|
measured ? `\n\n${t('Measured against this project {when}: it landed where the agents landed on {matched} of {n} real questions{verdict}', { when: agoWords(measured.at), matched: measured.matched, n: measured.n, verdict: measured.passed ? t('. Ready to be worked in.') : t(' — not yet.') })}` : `\n\n${t('Nobody has measured it against this project yet: Diagnostics → Memory review, or the line in the project when it joined.')}`}`;
|
|
2467
2504
|
text.append(note);
|
|
2468
2505
|
}
|
|
@@ -2473,7 +2510,7 @@ function renderPicker() {
|
|
|
2473
2510
|
modeChip.type = 'button';
|
|
2474
2511
|
modeChip.append(el('b', null, `#${shown}`), el('span', 'word', MODES[shown].label), chevron());
|
|
2475
2512
|
modeChip.title = state.typedPending
|
|
2476
|
-
? t('#{n} {label} is written in the message:
|
|
2513
|
+
? t('#{n} {label} is written in the message: you will be asked to confirm it when you send it.', { n: shown, label: MODES[shown].label })
|
|
2477
2514
|
: t('Permission mode for this message · {hint}', { hint: MODES[state.mode].hint });
|
|
2478
2515
|
modeChip.addEventListener('click', (event) => { event.stopPropagation(); toggleModeMenu(current.id, modeChip); });
|
|
2479
2516
|
text.append(modeChip);
|
|
@@ -2485,9 +2522,9 @@ function renderPicker() {
|
|
|
2485
2522
|
grace.type = 'button';
|
|
2486
2523
|
grace.dataset.mode = String(mode);
|
|
2487
2524
|
grace.dataset.agent = current.id;
|
|
2488
|
-
grace.title = t('{label} does not ask
|
|
2525
|
+
grace.title = t('{label} does not ask you to confirm again for @{agent} until this runs out. Click to end it now.', { label: MODES[mode].label, agent: current.id });
|
|
2489
2526
|
grace.append(el('b', null, `#${mode}`), el('span', null, t('open')), el('span', 'left', graceWords(left)), el('i', null, '×'));
|
|
2490
|
-
grace.addEventListener('click', (event) => { event.stopPropagation(); closeOverrideGrace(mode, current.id); toast(t('Diagnostics › {label}
|
|
2527
|
+
grace.addEventListener('click', (event) => { event.stopPropagation(); closeOverrideGrace(mode, current.id); toast(t('Diagnostics › {label} locked again for @{agent}: the next message asks you to confirm again.', { label: MODES[mode].label, agent: current.id }), { tone: 'done' }); });
|
|
2491
2528
|
text.append(grace);
|
|
2492
2529
|
break;
|
|
2493
2530
|
}
|
|
@@ -2506,6 +2543,7 @@ function renderPicker() {
|
|
|
2506
2543
|
}
|
|
2507
2544
|
const effortChip = renderEffortChip(current.id);
|
|
2508
2545
|
if (effortChip) text.append(effortChip);
|
|
2546
|
+
if (!current.local && !state.device) text.append(copilotChip());
|
|
2509
2547
|
(els.pickOptions ?? els.picker).append(text);
|
|
2510
2548
|
}
|
|
2511
2549
|
updateCrewLabel();
|
|
@@ -2610,6 +2648,7 @@ document.addEventListener('keydown', (event) => { if (event.key === 'Escape') cl
|
|
|
2610
2648
|
// dimmed when MADRE can read it (Codex's config.toml), so the human sees what they will get
|
|
2611
2649
|
// without having chosen it.
|
|
2612
2650
|
const EFFORT_WORDS = {
|
|
2651
|
+
none: { label: 'None', hint: 'No reasoning: it answers straight away. For the simplest requests.' },
|
|
2613
2652
|
minimal: { label: 'Minimal', hint: 'Answers almost at once. For lookups and one-line edits.' },
|
|
2614
2653
|
low: { label: 'Low', hint: 'Quick and light. For clear, contained requests.' },
|
|
2615
2654
|
medium: { label: 'Medium', hint: 'The everyday balance of speed and depth.' },
|
|
@@ -2647,7 +2686,8 @@ function effortInfo(id) {
|
|
|
2647
2686
|
function ensureEfforts() {
|
|
2648
2687
|
if (effortsAsked) return;
|
|
2649
2688
|
effortsAsked = true;
|
|
2650
|
-
|
|
2689
|
+
// OpenCode's rungs are its models': with one already chosen, its list is read too (slower).
|
|
2690
|
+
fetch(`/api/models${state.chosenModel?.opencode ? '?opencode=1' : ''}`).then((response) => response.json()).then((data) => { takeEfforts(data.models); renderPicker(); }).catch(() => {});
|
|
2651
2691
|
}
|
|
2652
2692
|
|
|
2653
2693
|
function effortMeter(levels, level, { ghost = false } = {}) {
|
|
@@ -2884,8 +2924,111 @@ function closeModeMenu() { modeMenu.hidden = true; modeMenuFor = null; }
|
|
|
2884
2924
|
document.addEventListener('click', (event) => { if (!modeMenu.hidden && !modeMenu.contains(event.target) && !event.target.closest('.picker')) closeModeMenu(); });
|
|
2885
2925
|
document.addEventListener('keydown', (event) => { if (event.key === 'Escape') closeModeMenu(); });
|
|
2886
2926
|
|
|
2927
|
+
// The copilot (2026-10-14): a while — up to 24 hours, opened with the #3 key — in which a message
|
|
2928
|
+
// at #3 needs no key, whichever agent it goes to, as long as its CLI can write. The project holds
|
|
2929
|
+
// the window (GET/POST /api/copilot); the page only shows it and counts it down.
|
|
2930
|
+
const COPILOT_ICON = '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M10 3.4v2.8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><circle cx="10" cy="3" r="1.05" fill="currentColor"/><rect x="4.2" y="6.2" width="11.6" height="9.2" rx="3.1" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="7.7" cy="10.3" r="1.15" fill="currentColor"/><circle cx="12.3" cy="10.3" r="1.15" fill="currentColor"/><path d="M8.1 13h3.8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M2.5 9.6v2.6M17.5 9.6v2.6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>';
|
|
2931
|
+
const copilotUI = { active: false, until: 0, windows: [30, 60, 240, 480, 1440], chosen: 60, tick: null };
|
|
2932
|
+
const copilotOn = () => copilotUI.active && copilotUI.until > Date.now();
|
|
2933
|
+
const canWrite = (id) => Boolean(state.capabilities[id]?.scopes?.write?.capable);
|
|
2934
|
+
// The copilot's #3 for this message: the window open, this agent able to write, #3 chosen.
|
|
2935
|
+
const copilotSends = (id) => state.mode === 3 && copilotOn() && canWrite(id);
|
|
2936
|
+
const copilotWindowWords = (minutes) => (minutes >= 60 ? t('{n} h', { n: minutes / 60 }) : t('{n} min', { n: minutes }));
|
|
2937
|
+
function copilotLeftWords() {
|
|
2938
|
+
const minutes = Math.max(1, Math.ceil((copilotUI.until - Date.now()) / 60000));
|
|
2939
|
+
return minutes >= 60 ? t('{n} h', { n: Math.round(minutes / 6) / 10 }) : t('{n} min', { n: minutes });
|
|
2940
|
+
}
|
|
2941
|
+
function applyCopilot(said = {}) {
|
|
2942
|
+
const was = copilotOn();
|
|
2943
|
+
copilotUI.active = Boolean(said.active);
|
|
2944
|
+
copilotUI.until = Number(said.until) || 0;
|
|
2945
|
+
if (Array.isArray(said.windows) && said.windows.length) copilotUI.windows = said.windows;
|
|
2946
|
+
clearInterval(copilotUI.tick);
|
|
2947
|
+
copilotUI.tick = null;
|
|
2948
|
+
if (copilotOn()) {
|
|
2949
|
+
copilotUI.tick = setInterval(() => {
|
|
2950
|
+
if (!copilotOn()) { applyCopilot({ active: false }); return; }
|
|
2951
|
+
for (const chip of document.querySelectorAll('.copilot-chip .left')) chip.textContent = copilotLeftWords();
|
|
2952
|
+
}, 30000);
|
|
2953
|
+
}
|
|
2954
|
+
// The mode follows: #3 while it lasts, the agent's own default after.
|
|
2955
|
+
if (was !== copilotOn() && els.target?.value) { state.modeArmedFor = null; setMode(defaultModeFor(els.target.value)); }
|
|
2956
|
+
else renderPicker();
|
|
2957
|
+
if (!copilotMenu.hidden) toggleCopilotMenu(copilotMenu.anchor, { keep: true });
|
|
2958
|
+
}
|
|
2959
|
+
async function loadCopilot() {
|
|
2960
|
+
try { applyCopilot(await (await fetch('/api/copilot')).json()); } catch { /* an older project: no copilot */ }
|
|
2961
|
+
}
|
|
2887
2962
|
function ceilingFor(id) { return state.capabilities[id]?.scopes?.maxMode ?? 1; }
|
|
2888
|
-
function defaultModeFor(id) { return state.capabilities[id]?.scopes?.defaultMode ?? 1; }
|
|
2963
|
+
function defaultModeFor(id) { return copilotOn() && canWrite(id) ? 3 : state.capabilities[id]?.scopes?.defaultMode ?? 1; }
|
|
2964
|
+
|
|
2965
|
+
// The robot beside mode · model · effort: what the copilot does, its switch and how long.
|
|
2966
|
+
const copilotMenu = el('div', 'model-menu copilot-menu');
|
|
2967
|
+
copilotMenu.hidden = true;
|
|
2968
|
+
copilotMenu.setAttribute('role', 'dialog');
|
|
2969
|
+
document.body.append(copilotMenu);
|
|
2970
|
+
function closeCopilotMenu() { copilotMenu.hidden = true; }
|
|
2971
|
+
document.addEventListener('click', (event) => { if (!copilotMenu.hidden && !copilotMenu.contains(event.target) && !event.target.closest('.copilot-chip') && !event.target.closest('#override')) closeCopilotMenu(); });
|
|
2972
|
+
document.addEventListener('keydown', (event) => { if (event.key === 'Escape') closeCopilotMenu(); });
|
|
2973
|
+
function copilotChip() {
|
|
2974
|
+
const on = copilotOn();
|
|
2975
|
+
const chip = el('button', `copilot-chip${on ? ' on' : ''}`);
|
|
2976
|
+
chip.type = 'button';
|
|
2977
|
+
const icon = el('span', 'copilot-icon');
|
|
2978
|
+
icon.innerHTML = COPILOT_ICON;
|
|
2979
|
+
chip.append(icon);
|
|
2980
|
+
if (on) chip.append(el('span', 'left', copilotLeftWords()));
|
|
2981
|
+
chip.title = on ? t('Copilot on · {left} left. Click to see it or turn it off.', { left: copilotLeftWords() }) : t('Copilot: let the agent you write to drive the project for a while.');
|
|
2982
|
+
chip.setAttribute('aria-label', on ? t('Copilot on') : t('Copilot'));
|
|
2983
|
+
chip.setAttribute('aria-haspopup', 'dialog');
|
|
2984
|
+
chip.addEventListener('click', (event) => { event.stopPropagation(); toggleCopilotMenu(chip); });
|
|
2985
|
+
return chip;
|
|
2986
|
+
}
|
|
2987
|
+
function toggleCopilotMenu(anchor, { keep = false } = {}) {
|
|
2988
|
+
if (!keep && !copilotMenu.hidden) { closeCopilotMenu(); return; }
|
|
2989
|
+
closeModelMenu(); closeEffortMenu(); closeModeMenu();
|
|
2990
|
+
copilotMenu.anchor = anchor;
|
|
2991
|
+
const on = copilotOn();
|
|
2992
|
+
const title = el('div', 'model-menu-title copilot-title');
|
|
2993
|
+
const icon = el('span', 'copilot-icon');
|
|
2994
|
+
icon.innerHTML = COPILOT_ICON;
|
|
2995
|
+
title.append(icon, el('b', null, t('Copilot')));
|
|
2996
|
+
const toggle = el('label', 'settings-switch');
|
|
2997
|
+
const box = el('input');
|
|
2998
|
+
box.type = 'checkbox';
|
|
2999
|
+
box.checked = on;
|
|
3000
|
+
box.setAttribute('aria-label', t('Copilot'));
|
|
3001
|
+
box.addEventListener('change', async () => {
|
|
3002
|
+
if (box.checked) { box.checked = false; openCopilotKey(copilotUI.chosen); return; }
|
|
3003
|
+
try { applyCopilot(await (await fetch('/api/copilot/off', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })).json()); toast(t('Copilot off. Each #3 message asks you to confirm again.'), { tone: 'done' }); } catch (error) { toast(t('Could not reach MADRE: {error}', { error: error.message })); }
|
|
3004
|
+
});
|
|
3005
|
+
toggle.append(box, el('i'));
|
|
3006
|
+
title.append(toggle);
|
|
3007
|
+
const body = el('div', 'copilot-body');
|
|
3008
|
+
body.append(
|
|
3009
|
+
el('p', 'copilot-lead', t('The agent you write to drives the project: it edits, makes plans with the other agents (each step can go up to #3 EDIT), and runs the safe commands — reading, tests, builds, local git — on its own.')),
|
|
3010
|
+
el('p', 'copilot-never', t('It never deletes, never touches MADRE\'s folders or your secrets, and never pushes, publishes or sends anything off this computer: that stays with you.')),
|
|
3011
|
+
);
|
|
3012
|
+
if (on) {
|
|
3013
|
+
body.append(el('p', 'copilot-state', t('On until {time} · {left} left', { time: new Date(copilotUI.until).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), left: copilotLeftWords() })));
|
|
3014
|
+
} else {
|
|
3015
|
+
const how = el('div', 'copilot-how');
|
|
3016
|
+
how.append(el('span', null, t('For')), sxSeg(copilotUI.windows.map((minutes) => ({ value: String(minutes), text: copilotWindowWords(minutes) })), String(copilotUI.chosen), (value) => { copilotUI.chosen = Number(value); }, { label: t('How long the copilot lasts') }));
|
|
3017
|
+
body.append(how, el('p', 'copilot-foot', t('It asks you to confirm #3 only once. STOP ALL ends it.')));
|
|
3018
|
+
}
|
|
3019
|
+
copilotMenu.replaceChildren(title, body);
|
|
3020
|
+
copilotMenu.hidden = false;
|
|
3021
|
+
const rect = anchor.getBoundingClientRect();
|
|
3022
|
+
const width = copilotMenu.offsetWidth || 320;
|
|
3023
|
+
const height = copilotMenu.offsetHeight || 220;
|
|
3024
|
+
copilotMenu.style.left = `${Math.max(12, Math.min(window.innerWidth - width - 12, rect.left))}px`;
|
|
3025
|
+
copilotMenu.style.top = `${Math.max(12, rect.top - height - 10)}px`;
|
|
3026
|
+
}
|
|
3027
|
+
// Turning it on is the #3 key, once, in the same dialog as always.
|
|
3028
|
+
function openCopilotKey(minutes) {
|
|
3029
|
+
closeCopilotMenu();
|
|
3030
|
+
openOverride(els.target.value, { mode: 3, copilot: minutes, forceKey: true });
|
|
3031
|
+
}
|
|
2889
3032
|
|
|
2890
3033
|
function toggleModeMenu(id, anchor) {
|
|
2891
3034
|
if (!modeMenu.hidden && modeMenuFor === id) { closeModeMenu(); return; }
|
|
@@ -2894,14 +3037,15 @@ function toggleModeMenu(id, anchor) {
|
|
|
2894
3037
|
modeMenuFor = id;
|
|
2895
3038
|
paint(modeMenu, id);
|
|
2896
3039
|
modeMenu.replaceChildren();
|
|
2897
|
-
|
|
3040
|
+
// Under the copilot, #3 is open for any agent whose CLI writes.
|
|
3041
|
+
const cap = copilotOn() && canWrite(id) ? Math.max(ceilingFor(id), 3) : ceilingFor(id);
|
|
2898
3042
|
const scopes = state.capabilities[id]?.scopes;
|
|
2899
3043
|
const canRaise = Boolean(scopes?.write?.capable);
|
|
2900
3044
|
const title = el('div', 'model-menu-title');
|
|
2901
3045
|
// Short enough to sit on one line at any width the menu takes. That it applies to this message
|
|
2902
3046
|
// and not to every one after it is the mode chip's own tooltip, and the list below is literally
|
|
2903
3047
|
// the modes — so the header carries the two facts nothing else here does: who, and how far.
|
|
2904
|
-
title.append(el('b', null, `@${id}`), t(' ·
|
|
3048
|
+
title.append(el('b', null, `@${id}`), t(' · max mode #{cap}', { cap }));
|
|
2905
3049
|
title.title = t('Permission mode for this message · {hint}', { hint: MODES[cap].hint });
|
|
2906
3050
|
modeMenu.append(title);
|
|
2907
3051
|
const ladder = el('div', 'mode-ladder');
|
|
@@ -2917,7 +3061,7 @@ function toggleModeMenu(id, anchor) {
|
|
|
2917
3061
|
body.append(el('span', 'name', MODES[n].label), el('span', 'hint', MODES[n].hint));
|
|
2918
3062
|
option.append(el('span', 'n', `#${n}`), body);
|
|
2919
3063
|
if (onTheMac) option.title = t('#3 EDIT and #4 FULL ACCESS are opened from the Mac.');
|
|
2920
|
-
const tag = state.mode === n ? t('NOW') : onTheMac ? t('ON THE MAC') : raise ? t('RAISE TO #{n} ›', { n }) : locked ? t('LOCKED') : n === 4 ? t('
|
|
3064
|
+
const tag = state.mode === n ? t('NOW') : onTheMac ? t('ON THE MAC') : raise ? t('RAISE TO #{n} ›', { n }) : locked ? t('LOCKED') : n === 4 ? t('CONFIRM ×2') : n === 3 ? t('CONFIRM') : n === defaultModeFor(id) ? t('DEFAULT') : '';
|
|
2921
3065
|
if (tag) {
|
|
2922
3066
|
const label = el('span', `tag${raise ? ' raise' : ''}`, tag);
|
|
2923
3067
|
if (state.mode === n) label.append(el('span', 'dot'));
|
|
@@ -2929,6 +3073,7 @@ function toggleModeMenu(id, anchor) {
|
|
|
2929
3073
|
: t("Above @{id}'s max mode (#{cap}). Raise it in Settings › Permissions.", { id, cap });
|
|
2930
3074
|
option.addEventListener('click', () => {
|
|
2931
3075
|
closeModeMenu();
|
|
3076
|
+
if (n === 3 && copilotOn() && canWrite(id)) { setMode(3); return; }
|
|
2932
3077
|
if (n >= 3) { openOverride(id, { raise, mode: n }); return; }
|
|
2933
3078
|
setMode(n, { wink: n === 0 });
|
|
2934
3079
|
});
|
|
@@ -2956,6 +3101,7 @@ function setMode(n, { wink = false, typed = false } = {}) {
|
|
|
2956
3101
|
els.composer.classList.toggle('ghost', mode === 0);
|
|
2957
3102
|
els.composer.classList.toggle('control', mode >= 3);
|
|
2958
3103
|
els.composer.classList.toggle('airlock', mode === 4);
|
|
3104
|
+
els.composer.classList.toggle('copilot', mode === 3 && copilotOn());
|
|
2959
3105
|
for (const n of [0, 1, 2, 3, 4]) els.composer.classList.toggle(`m${n}`, mode === n);
|
|
2960
3106
|
renderCreateScopes();
|
|
2961
3107
|
updateCrewLabel(); updatePlaceholder();
|
|
@@ -3027,6 +3173,7 @@ const override = {
|
|
|
3027
3173
|
cancelled: null, // called when the human walks away from it, to put back what they wrote
|
|
3028
3174
|
raise: false, // the agent is capped below the mode: arming also raises its max mode in Settings › Permissions
|
|
3029
3175
|
mode: 3, // #3 CONTROL or #4 AIRLOCK
|
|
3176
|
+
minutes: 0, // a window chosen on an agent's request; 0 is what Permissions says
|
|
3030
3177
|
preserveComposer: false,
|
|
3031
3178
|
stage: 'designation', // designation · phrase · confirm; #4 asks for the same key a second time
|
|
3032
3179
|
again: false, // the second time, for #4
|
|
@@ -3104,13 +3251,17 @@ function askOverrideKey(stage, { again = false } = {}) {
|
|
|
3104
3251
|
override.form.querySelector('.k').textContent = stage === 'phrase' ? (again ? t('Type your phrase again') : t('Type your phrase')) : again ? t('Type the project name again') : t('Type the project name');
|
|
3105
3252
|
override.input.placeholder = '';
|
|
3106
3253
|
}
|
|
3107
|
-
function openOverride(id, { raise = false, mode = 3, then = null, cancelled = null, preserveComposer = false, forceKey = false } = {}) {
|
|
3254
|
+
function openOverride(id, { raise = false, mode = 3, then = null, cancelled = null, preserveComposer = false, forceKey = false, minutes = 0, copilot = 0 } = {}) {
|
|
3108
3255
|
if (!override.dialog) return;
|
|
3109
3256
|
// Inside the grace of an accepted key, the same mode for the same agent is not asked again —
|
|
3110
3257
|
// unless arming would also raise the agent's ceiling, which is always asked.
|
|
3111
3258
|
const left = raise || forceKey ? 0 : overrideGraceLeft(mode, id);
|
|
3112
3259
|
if (left) { void grantOverride({ agent: id, raise: false, mode, then, delay: 0, left, preserveComposer }); return; }
|
|
3113
3260
|
override.agent = id;
|
|
3261
|
+
// The copilot's key: the same #3 key, opening the copilot for that long instead of one agent.
|
|
3262
|
+
override.copilot = copilot || 0;
|
|
3263
|
+
// A window chosen on an agent's request, instead of the one set in Permissions.
|
|
3264
|
+
override.minutes = MODE_WINDOWS[mode]?.includes(minutes) ? minutes : 0;
|
|
3114
3265
|
// What to do once it is armed. The chip's road arms and waits for the human to send; a #3 or #4
|
|
3115
3266
|
// typed in a message that was sent, and the way up on an agent's card, send right away.
|
|
3116
3267
|
override.then = then;
|
|
@@ -3121,16 +3272,16 @@ function openOverride(id, { raise = false, mode = 3, then = null, cancelled = nu
|
|
|
3121
3272
|
override.preserveComposer = preserveComposer;
|
|
3122
3273
|
override.key = overrideKeyFor(mode);
|
|
3123
3274
|
askOverrideKey(override.key.startsWith('phrase') ? 'phrase' : override.key === 'confirm' ? 'confirm' : 'designation');
|
|
3124
|
-
override.title.textContent = mode === 4 ? t('Open #4 FULL ACCESS for @{agent}', { agent: id }) : t('Unlock #3 EDIT for @{agent}', { agent: id });
|
|
3125
|
-
override.arm.textContent = mode === 4 ? t('Open') : t('Unlock');
|
|
3275
|
+
override.title.textContent = override.copilot ? t('Turn the copilot on for {time}', { time: copilotWindowWords(override.copilot) }) : mode === 4 ? t('Open #4 FULL ACCESS for @{agent}', { agent: id }) : t('Unlock #3 EDIT for @{agent}', { agent: id });
|
|
3276
|
+
override.arm.textContent = override.copilot ? t('Turn on') : mode === 4 ? t('Open') : t('Unlock');
|
|
3126
3277
|
override.dialog.classList.toggle('m4', mode === 4);
|
|
3127
3278
|
if (mode === 4) {
|
|
3128
3279
|
let slot = override.frame?.querySelector('.tour-hint-slot');
|
|
3129
3280
|
if (!slot && override.form) { slot = el('div', 'tour-hint-slot'); override.form.before(slot); }
|
|
3130
|
-
if (slot) void roomTour().hint('key4', slot, { inside: true, title: t('#4 asks
|
|
3281
|
+
if (slot) void roomTour().hint('key4', slot, { inside: true, title: t('#4 asks you to confirm twice'), text: t('With #4 the agent may run commands, publish and deploy: what leaves your Mac does not come back. That is why the confirmation is typed twice.') });
|
|
3131
3282
|
}
|
|
3132
|
-
override.brief.textContent = OVERRIDE_BRIEF[mode].what(id);
|
|
3133
|
-
override.safe.textContent = OVERRIDE_BRIEF[mode].safe();
|
|
3283
|
+
override.brief.textContent = override.copilot ? t('The agent you write to edits the project and runs the safe commands by itself, without asking you to confirm each message, until it ends.') : OVERRIDE_BRIEF[mode].what(id);
|
|
3284
|
+
override.safe.textContent = override.copilot ? t('It never deletes, pushes, publishes or deploys; each change gets a restore point; STOP ALL ends it.') : OVERRIDE_BRIEF[mode].safe();
|
|
3134
3285
|
override.raised.hidden = !raise;
|
|
3135
3286
|
override.raised.textContent = raise ? t('This also raises its max mode to #{n}.', { n: mode }) : '';
|
|
3136
3287
|
override.reply.textContent = '';
|
|
@@ -3171,7 +3322,9 @@ override.form?.addEventListener('submit', async (event) => {
|
|
|
3171
3322
|
override.arm.disabled = true;
|
|
3172
3323
|
let answer;
|
|
3173
3324
|
try {
|
|
3174
|
-
const response =
|
|
3325
|
+
const response = override.copilot
|
|
3326
|
+
? await fetch('/api/copilot', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ minutes: override.copilot, words: override.words }) })
|
|
3327
|
+
: await fetch('/api/override/arm', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ agent: override.agent, mode: override.mode, words: override.words, minutes: override.minutes || undefined }) });
|
|
3175
3328
|
answer = { ok: response.ok, ...(await response.json().catch(() => ({}))) };
|
|
3176
3329
|
} catch (error) {
|
|
3177
3330
|
answer = { ok: false, error: t('Could not reach MADRE: {error}', { error: error.message }) };
|
|
@@ -3182,15 +3335,24 @@ override.form?.addEventListener('submit', async (event) => {
|
|
|
3182
3335
|
deny(answer.error ? resay(answer.error) : undefined);
|
|
3183
3336
|
return;
|
|
3184
3337
|
}
|
|
3338
|
+
if (override.copilot) {
|
|
3339
|
+
override.reply.textContent = t('Copilot on.');
|
|
3340
|
+
override.reply.className = 'key-reply granted';
|
|
3341
|
+
override.copilot = 0;
|
|
3342
|
+
override.then = null;
|
|
3343
|
+
override.cancelled = null;
|
|
3344
|
+
setTimeout(() => { if (override.dialog?.open) override.dialog.close(); applyCopilot(answer); toast(t('Copilot on for {time}. Write to the agent that should drive.', { time: copilotWindowWords(Math.round((answer.until - Date.now()) / 60000 / 30) * 30 || copilotUI.chosen) }), { tone: 'done' }); }, 700);
|
|
3345
|
+
return;
|
|
3346
|
+
}
|
|
3185
3347
|
override.reply.textContent = override.mode === 4 ? t('Open for @{agent}.', { agent: override.agent }) : t('Unlocked for @{agent}.', { agent: override.agent });
|
|
3186
3348
|
override.reply.className = 'key-reply granted';
|
|
3187
|
-
const granted = { agent: override.agent, raise: override.raise, mode: override.mode, then: override.then, delay: 900, pass: answer.token, preserveComposer: override.preserveComposer };
|
|
3349
|
+
const granted = { agent: override.agent, raise: override.raise, mode: override.mode, then: override.then, delay: 900, pass: answer.token, preserveComposer: override.preserveComposer, minutes: override.minutes };
|
|
3188
3350
|
override.then = null;
|
|
3189
3351
|
override.cancelled = null;
|
|
3190
3352
|
void grantOverride(granted);
|
|
3191
3353
|
});
|
|
3192
3354
|
// Arms the mode for that agent: after the key, or straight away inside the grace of an earlier one.
|
|
3193
|
-
async function grantOverride({ agent, raise, mode, then, delay = 0, left = 0, pass = null, preserveComposer = false }) {
|
|
3355
|
+
async function grantOverride({ agent, raise, mode, then, delay = 0, left = 0, pass = null, preserveComposer = false, minutes: chosen = 0 }) {
|
|
3194
3356
|
if (pass) overridePasses.set(`${mode}:${agent}`, pass);
|
|
3195
3357
|
if (delay) await new Promise((resolve) => setTimeout(resolve, delay));
|
|
3196
3358
|
if (override.dialog?.open) override.dialog.close();
|
|
@@ -3203,7 +3365,7 @@ async function grantOverride({ agent, raise, mode, then, delay = 0, left = 0, pa
|
|
|
3203
3365
|
return;
|
|
3204
3366
|
}
|
|
3205
3367
|
}
|
|
3206
|
-
const minutes = overrideConfig().grace?.[mode] ?? 0;
|
|
3368
|
+
const minutes = chosen || (overrideConfig().grace?.[mode] ?? 0);
|
|
3207
3369
|
if (!left && minutes > 0) { overrideGrace.set(`${mode}:${agent}`, Date.now() + minutes * 60000); tickGrace(); }
|
|
3208
3370
|
if (!preserveComposer) {
|
|
3209
3371
|
state.modeArmedFor = agent;
|
|
@@ -3357,7 +3519,7 @@ function bridgeCard(agent) {
|
|
|
3357
3519
|
// What is behind this door: the account it needs, and whether there is a way in without paying.
|
|
3358
3520
|
if (agent.account) {
|
|
3359
3521
|
const account = el('div', 'account');
|
|
3360
|
-
if (agent.paid === false) account.append(el('span', 'free', t('FREE
|
|
3522
|
+
if (agent.paid === false) account.append(el('span', 'free', t('HAS A FREE OPTION')));
|
|
3361
3523
|
account.append(agent.account);
|
|
3362
3524
|
card.append(account);
|
|
3363
3525
|
}
|
|
@@ -3369,9 +3531,9 @@ function bridgeCard(agent) {
|
|
|
3369
3531
|
// Every door the CLI has, not just the first one. Claude sells two accounts and MADRE used
|
|
3370
3532
|
// to open only the subscription, which left whoever pays for the API with no way in.
|
|
3371
3533
|
if (agent.login?.headless) for (const door of loginDoors(agent, stage === 'ready')) actions.append(bridgeSignInButton(agent, door, stage === 'ready'));
|
|
3372
|
-
else if (!agent.key && agent.login) actions.append(el('span', 'note', t('signs in from
|
|
3534
|
+
else if (!agent.key && agent.login) actions.append(el('span', 'note', t('signs in from your terminal:')));
|
|
3373
3535
|
if (agent.key) {
|
|
3374
|
-
reveal = el('button', agent.login?.headless ? null : 'primary', t('PASTE KEY'));
|
|
3536
|
+
reveal = el('button', agent.login?.headless ? null : 'primary', t('PASTE API KEY'));
|
|
3375
3537
|
reveal.type = 'button';
|
|
3376
3538
|
actions.append(reveal);
|
|
3377
3539
|
}
|
|
@@ -3599,10 +3761,17 @@ function renderUserMessage(event) {
|
|
|
3599
3761
|
} else {
|
|
3600
3762
|
who.append(el('b', null, reviewed ? t('YOU · AGENTS (EXPENDABLE)') : t('YOU · AGENTS')));
|
|
3601
3763
|
if (event.payload.via) who.append(el('span', 'badge via', t('from your {device}', { device: deviceLabel(event.payload.via) })));
|
|
3764
|
+
// Written by MADRE for the human: the commands an agent asked for ran, and their output goes back.
|
|
3765
|
+
if (event.payload.auto === 'runs') {
|
|
3766
|
+
node.classList.add('auto');
|
|
3767
|
+
who.append(el('span', 'badge via', t('automatic · the commands ran')));
|
|
3768
|
+
settleRunReturn(event.payload.returns, target, event.sequence);
|
|
3769
|
+
}
|
|
3602
3770
|
}
|
|
3603
3771
|
const shownMode = Number.isInteger(mode) ? mode : create ? 2 : null;
|
|
3604
3772
|
if (shownMode !== null && shownMode !== 1) who.append(el('span', `badge mode m${shownMode}`, `#${shownMode} ${MODES[shownMode].label}`));
|
|
3605
3773
|
if (ash?.active) who.append(el('span', 'badge ash', t('TOKEN SAVER')));
|
|
3774
|
+
if (event.payload.copilot) who.append(el('span', 'badge copilot', t('COPILOT')));
|
|
3606
3775
|
col.append(who);
|
|
3607
3776
|
const bubble = el('div', 'bubble', text);
|
|
3608
3777
|
if (attachments.length) bubble.append(fileTiles(attachments));
|
|
@@ -3654,6 +3823,15 @@ function renderAssistantMessage(event) {
|
|
|
3654
3823
|
}
|
|
3655
3824
|
const bubble = el('div', 'bubble');
|
|
3656
3825
|
bubble.append(renderMarkdown(text));
|
|
3826
|
+
// Written as the human's copilot: a green round robot on the agent's own circle.
|
|
3827
|
+
if (event.payload.copilot) {
|
|
3828
|
+
node.classList.add('copilot');
|
|
3829
|
+
const badge = el('span', 'copilot-badge');
|
|
3830
|
+
badge.innerHTML = COPILOT_ICON;
|
|
3831
|
+
badge.title = t('@{agent} answered as your copilot', { agent: sender });
|
|
3832
|
+
badge.setAttribute('aria-label', badge.title);
|
|
3833
|
+
node.querySelector(':scope > .avatar')?.append(badge);
|
|
3834
|
+
}
|
|
3657
3835
|
if (event.payload.artifacts?.length) bubble.append(artifactTiles(event.payload.artifacts));
|
|
3658
3836
|
// What the reply recalled, what it cost and what can be done with it travel inside it, on a
|
|
3659
3837
|
// foot of its own: the memory as dots on the left; rate, copy and reply (shown under the pointer)
|
|
@@ -3706,7 +3884,7 @@ function ratingButtons(messageId, sender) {
|
|
|
3706
3884
|
const good = iconButton(ICON_GOOD, t('Good reply · keep it for your model'), 'rate good');
|
|
3707
3885
|
const prefer = iconButton(ICON_PREFER, t('This is how I want it done · the project keeps it as a preference'), 'rate prefer');
|
|
3708
3886
|
const bad = iconButton(ICON_BAD, t('Bad reply · keep it out of the dataset'), 'rate bad');
|
|
3709
|
-
const never = iconButton(ICON_NEVER, t('Never do this again ·
|
|
3887
|
+
const never = iconButton(ICON_NEVER, t('Never do this again · the project stops using what it remembers from this'), 'rate never');
|
|
3710
3888
|
const apply = (rating) => {
|
|
3711
3889
|
for (const [button, verdict] of [[good, 'good'], [prefer, 'preference'], [bad, 'bad'], [never, 'never']]) button.classList.toggle('on', rating === verdict);
|
|
3712
3890
|
prefer.hidden = !(rating === 'good' || rating === 'preference');
|
|
@@ -3828,6 +4006,13 @@ function replyWith(agentId, source) {
|
|
|
3828
4006
|
document.addEventListener('click', (event) => { if (!replyMenu.hidden && !replyMenu.contains(event.target) && !event.target.closest?.('.bubble-actions .reply')) hideReplyMenu(); });
|
|
3829
4007
|
document.addEventListener('keydown', (event) => { if (event.key === 'Escape') hideReplyMenu(); });
|
|
3830
4008
|
|
|
4009
|
+
function fileSize(bytes) {
|
|
4010
|
+
const size = Number(bytes) || 0;
|
|
4011
|
+
if (size < 1024) return `${size} B`;
|
|
4012
|
+
if (size < 1024 * 1024) return `${Math.round(size / 1024)} KB`;
|
|
4013
|
+
return `${(size / 1024 / 1024).toFixed(1)} MB`;
|
|
4014
|
+
}
|
|
4015
|
+
|
|
3831
4016
|
function fileTiles(files) {
|
|
3832
4017
|
const wrap = el('div', 'files');
|
|
3833
4018
|
for (const file of files) {
|
|
@@ -3837,7 +4022,13 @@ function fileTiles(files) {
|
|
|
3837
4022
|
tile.type = 'button';
|
|
3838
4023
|
tile.title = `${file.name} · ${formatTokens(file.size)}B`;
|
|
3839
4024
|
if (image) { const img = el('img'); img.src = url; img.alt = file.name; img.loading = 'lazy'; tile.append(img); }
|
|
3840
|
-
else {
|
|
4025
|
+
else {
|
|
4026
|
+
const kind = (file.name.includes('.') ? file.name.split('.').pop() : 'file').slice(0, 4).toUpperCase();
|
|
4027
|
+
// The card says what the file is and how big, under its name, the way chats show an attachment.
|
|
4028
|
+
const label = el('span', 'file-text');
|
|
4029
|
+
label.append(el('span', 'name', file.name), el('span', 'meta', `${kind} · ${fileSize(file.size)}`));
|
|
4030
|
+
tile.append(el('span', 'kind', kind), label);
|
|
4031
|
+
}
|
|
3841
4032
|
tile.addEventListener('click', () => openViewer({ root: 'attachments', path: file.fileName, label: file.name }));
|
|
3842
4033
|
wrap.append(tile);
|
|
3843
4034
|
}
|
|
@@ -3854,7 +4045,7 @@ const WORKING_VOICE = {
|
|
|
3854
4045
|
long: [t('Deep in the code…'), t('Composing the answer…'), t('Almost there…')],
|
|
3855
4046
|
},
|
|
3856
4047
|
claude: {
|
|
3857
|
-
early: [t('Reading…'), t('
|
|
4048
|
+
early: [t('Reading…'), t('Searching the project…'), t('Mapping the files…')],
|
|
3858
4049
|
middle: [t('Thinking…'), t('Pondering…'), t('Connecting the pieces…'), t('Reading the relevant files…')],
|
|
3859
4050
|
late: [t('Ruminating…'), t('Considering the edge cases…'), t('Checking the details…'), t('Musing…')],
|
|
3860
4051
|
long: [t('Synthesizing…'), t('Drafting the reply…'), t('Finishing the thought…')],
|
|
@@ -3942,7 +4133,7 @@ function settleReading(event) {
|
|
|
3942
4133
|
badge.classList.toggle('ash', cost.ash === true);
|
|
3943
4134
|
const seen = rememberAsh(cost);
|
|
3944
4135
|
if (seen) {
|
|
3945
|
-
badge.title += ` ${t('In this project
|
|
4136
|
+
badge.title += ` ${t('In this project, a typical answer uses {on} output tokens with TOKEN SAVER ({onTurns} answers) and {off} without it ({offTurns}). You choose when to ask for compact answers, so these are different tasks, not the same task twice.', { on: seen.on.toLocaleString(), onTurns: seen.onTurns, off: seen.off.toLocaleString(), offTurns: seen.offTurns })}`;
|
|
3946
4137
|
}
|
|
3947
4138
|
if (!badge.isConnected) who.append(badge);
|
|
3948
4139
|
paintUncharged(cost);
|
|
@@ -3966,7 +4157,7 @@ function paintUncharged(cost) {
|
|
|
3966
4157
|
if (total <= 0) return;
|
|
3967
4158
|
const span = stamp.querySelector('.uncharged') ?? el('span', 'uncharged');
|
|
3968
4159
|
span.textContent = t('{n} uncharged', { n: formatTokens(total) });
|
|
3969
|
-
span.title = t('{cached} tokens this
|
|
4160
|
+
span.title = t('{cached} tokens this agent\'s app read back from its own cache, billed at a lower price than new input (measured). {unsent} tokens MADRE did not need to send the agent (estimated at {rate} characters per token). Neither is the Token saver: the Token saver shortens the answer, and its figure is above the bubble.', {
|
|
3970
4161
|
cached: cached.toLocaleString(), unsent: unsent.toLocaleString(), rate: CH_PER_TOKEN,
|
|
3971
4162
|
});
|
|
3972
4163
|
// Before the running total, after the turn's own bill: read left to right it is what this turn
|
|
@@ -4260,7 +4451,8 @@ const modeAsks = []; // { agent, wanted, card } still waiting for an answer in
|
|
|
4260
4451
|
function attachModeAsk(payload, { sequence = null } = {}) {
|
|
4261
4452
|
if (!payload || payload.role !== 'assistant' || payload.sender === 'you') return null;
|
|
4262
4453
|
const ran = state.userMessages.get(payload.messageId)?.mode ?? payload.mode ?? 1;
|
|
4263
|
-
const
|
|
4454
|
+
const asked = modeAskedWithTime(payload.text, { ran });
|
|
4455
|
+
const wanted = asked?.mode ?? null;
|
|
4264
4456
|
if (!wanted || wanted <= state.mode) return null;
|
|
4265
4457
|
const agent = payload.sender;
|
|
4266
4458
|
if (!state.agents.get(agent)?.ready) return null;
|
|
@@ -4269,24 +4461,47 @@ function attachModeAsk(payload, { sequence = null } = {}) {
|
|
|
4269
4461
|
tint(card, agent);
|
|
4270
4462
|
const what = el('div', 'mode-what');
|
|
4271
4463
|
what.append(el('b', 'who', `@${agent}`), t(' asks for #{n} {label} to go on', { n: wanted, label }));
|
|
4464
|
+
if (asked.minutes) what.append(el('span', 'mode-time', t(' · for {m} min', { m: asked.minutes })));
|
|
4272
4465
|
const why = el('div', 'mode-why', wanted >= 3
|
|
4273
4466
|
? t('You will type the project name, as always. Then @{agent} gets your permission and continues.', { agent })
|
|
4274
4467
|
: t('@{agent} gets your permission for one message and continues.', { agent }));
|
|
4468
|
+
// How long the permission holds: the time the agent asked for, made one of the windows the
|
|
4469
|
+
// project allows (#3: 5, 15 or 30 min; #4: 5), or this message only. The human can change it.
|
|
4470
|
+
let minutes = wanted >= 3 ? windowFor(wanted, asked.minutes ?? 0) || (overrideConfig().grace?.[wanted] ?? 0) : 0;
|
|
4471
|
+
const windows = wanted >= 3 ? [0, ...MODE_WINDOWS[wanted]] : [];
|
|
4275
4472
|
const actions = el('div', 'mode-actions');
|
|
4276
|
-
const go = el('button', 'mode-go'
|
|
4473
|
+
const go = el('button', 'mode-go');
|
|
4277
4474
|
go.type = 'button';
|
|
4475
|
+
const goWords = () => { go.textContent = minutes ? t('GRANT #{n} FOR {m} MIN AND CONTINUE', { n: wanted, m: minutes }) : t('GRANT #{n} AND CONTINUE', { n: wanted }); };
|
|
4476
|
+
goWords();
|
|
4477
|
+
let time = null;
|
|
4478
|
+
if (windows.length > 1) {
|
|
4479
|
+
time = el('div', 'mode-windows');
|
|
4480
|
+
time.setAttribute('role', 'radiogroup');
|
|
4481
|
+
time.setAttribute('aria-label', t('How long the permission holds'));
|
|
4482
|
+
const paintTime = () => { for (const one of time.children) one.setAttribute('aria-checked', String(Number(one.dataset.minutes) === minutes)); goWords(); };
|
|
4483
|
+
for (const option of windows) {
|
|
4484
|
+
const chip = el('button', 'mode-window', option ? t('{n} min', { n: option }) : t('this message'));
|
|
4485
|
+
chip.type = 'button';
|
|
4486
|
+
chip.setAttribute('role', 'radio');
|
|
4487
|
+
chip.dataset.minutes = String(option);
|
|
4488
|
+
chip.addEventListener('click', () => { minutes = option; paintTime(); });
|
|
4489
|
+
time.append(chip);
|
|
4490
|
+
}
|
|
4491
|
+
paintTime();
|
|
4492
|
+
}
|
|
4278
4493
|
const myself = el('button', 'mode-alt', t('GRANT #{n} · GIVE IT ANOTHER INSTRUCTION', { n: wanted }));
|
|
4279
4494
|
myself.type = 'button';
|
|
4280
4495
|
myself.title = t('Unlocks #{n} in the box below, so you can add instructions before sending.', { n: wanted });
|
|
4281
4496
|
const later = el('button', 'mode-alt', t('NOT NOW'));
|
|
4282
4497
|
later.type = 'button';
|
|
4283
4498
|
actions.append(go, myself, later);
|
|
4284
|
-
card.append(what, why, actions);
|
|
4499
|
+
card.append(what, why, ...(time ? [time] : []), actions);
|
|
4285
4500
|
const quoted = { sender: agent, sequence, text: (() => { const one = String(payload.text ?? '').replace(/\s+/g, ' ').trim(); return one.length > REPLY_CLIP ? `${one.slice(0, REPLY_CLIP - 1)}…` : one; })() };
|
|
4286
4501
|
const ask = { agent, wanted, card };
|
|
4287
4502
|
modeAsks.push(ask);
|
|
4288
4503
|
const arm = (then) => {
|
|
4289
|
-
if (wanted >= 3) openOverride(agent, { mode: wanted, raise: wanted > ceilingFor(agent) && Boolean(state.capabilities[agent]?.scopes?.write?.capable), then });
|
|
4504
|
+
if (wanted >= 3) openOverride(agent, { mode: wanted, raise: wanted > ceilingFor(agent) && Boolean(state.capabilities[agent]?.scopes?.write?.capable), then, minutes });
|
|
4290
4505
|
else { els.target.value = agent; renderPicker(); setMode(wanted, { wink: true }); then?.(); }
|
|
4291
4506
|
};
|
|
4292
4507
|
go.addEventListener('click', () => arm(async () => {
|
|
@@ -4309,6 +4524,8 @@ function settleModeAsk(ask, said) {
|
|
|
4309
4524
|
if (at >= 0) modeAsks.splice(at, 1);
|
|
4310
4525
|
if (!said) { ask.card.remove(); return; }
|
|
4311
4526
|
ask.card.classList.add('settled');
|
|
4527
|
+
// Said as a small state in the mode's own colour (styles.css, .mode-ask.settled).
|
|
4528
|
+
if (Number.isInteger(ask.wanted)) ask.card.dataset.mode = String(ask.wanted);
|
|
4312
4529
|
ask.card.replaceChildren(el('div', 'mode-what', said));
|
|
4313
4530
|
}
|
|
4314
4531
|
// The human answered the ask their own way — a message to that agent at that mode or above —
|
|
@@ -4379,7 +4596,7 @@ function attachMemoryUsed(payload) {
|
|
|
4379
4596
|
pill.style.setProperty('--kind', MEMORY_COLORS[note.kind] ?? MEMORY_COLORS.fact);
|
|
4380
4597
|
pill.append(el('i', 'dot'), el('span', 'what', note.text.length > 52 ? `${note.text.slice(0, 51)}…` : note.text));
|
|
4381
4598
|
pill.title = `${kindWord(note.kind)} · ${note.text}\n${note.via === 'cascade'
|
|
4382
|
-
? t('Came along because this project
|
|
4599
|
+
? t('Came along because this project usually uses it together with another memory.')
|
|
4383
4600
|
: t('The archive matched this to what you asked.')} ${t('Click to see it in the Memory map.')}`;
|
|
4384
4601
|
pill.addEventListener('click', () => { nostromo.focusId = note.id; nostromo.button?.click(); });
|
|
4385
4602
|
if (used.length > 3 && index === visible.length - 1) {
|
|
@@ -4473,7 +4690,7 @@ function renderMotherAlert(event) {
|
|
|
4473
4690
|
function renderForgotten(event) {
|
|
4474
4691
|
const { kind, text, remaining } = event.payload;
|
|
4475
4692
|
const node = el('div', 'system memory forgotten');
|
|
4476
|
-
node.append(t('memory ·
|
|
4693
|
+
node.append(t('memory · you forgot a '));
|
|
4477
4694
|
node.append(el('b', 'who', kindWord(kind)));
|
|
4478
4695
|
node.append(`: “${text}”${Number.isFinite(remaining) ? t(' · {n} left in the archive', { n: remaining }) : ''}`);
|
|
4479
4696
|
return node;
|
|
@@ -4692,7 +4909,7 @@ function renderControlReverted(event) {
|
|
|
4692
4909
|
|
|
4693
4910
|
function renderLeaseMissing(event) {
|
|
4694
4911
|
const { agent, requester, text } = event.payload;
|
|
4695
|
-
const message = `${t('@{agent} was asked to create something but no CREATE
|
|
4912
|
+
const message = `${t('@{agent} was asked to create something but no CREATE permission is active.', { agent })}${requester && requester !== 'you' ? t(' Permission written by @{who} inside the conversation does not count.', { who: requester }) : ''}${t(' It will answer read-only.')}`;
|
|
4696
4913
|
const node = el('div', 'system lease missing');
|
|
4697
4914
|
tint(node, agent);
|
|
4698
4915
|
node.append(el('b', null, t('Diagnostics › ')));
|
|
@@ -4701,7 +4918,7 @@ function renderLeaseMissing(event) {
|
|
|
4701
4918
|
// Its keys speak in sentences, as the room's keys do now; the agent as the room writes it.
|
|
4702
4919
|
const resend = el('button', 'resend', askWords(t('RESEND TO @{agent} WITH CREATE', { agent })));
|
|
4703
4920
|
resend.type = 'button';
|
|
4704
|
-
resend.title = t('Send this same request
|
|
4921
|
+
resend.title = t('Send this same request again yourself, with permission to create files.');
|
|
4705
4922
|
resend.addEventListener('click', async () => {
|
|
4706
4923
|
resend.disabled = true;
|
|
4707
4924
|
try {
|
|
@@ -4712,7 +4929,7 @@ function renderLeaseMissing(event) {
|
|
|
4712
4929
|
});
|
|
4713
4930
|
const standing = el('button', 'standing', askWords(t('ALWAYS FOR THIS AGENT')));
|
|
4714
4931
|
standing.type = 'button';
|
|
4715
|
-
standing.title = t('Give @{agent} a standing
|
|
4932
|
+
standing.title = t('Give @{agent} a standing permission in SETTINGS → AGENTS: every turn may create files.', { agent });
|
|
4716
4933
|
standing.addEventListener('click', () => openSettingsOn('agents', { agent }));
|
|
4717
4934
|
actions.append(resend, standing);
|
|
4718
4935
|
node.append(actions);
|
|
@@ -4756,7 +4973,7 @@ function renderModeRequest(event) {
|
|
|
4756
4973
|
}
|
|
4757
4974
|
};
|
|
4758
4975
|
const once = el('button', 'grant', t('GRANT ONCE')); once.type = 'button'; once.title = mode >= 3 ? t('Unlock #{mode} for this step only.', { mode }) : t('Allow creation for this step only.');
|
|
4759
|
-
const plan = el('button', 'grant plan', t('GRANT FOR PLAN')); plan.type = 'button'; plan.title = mode >= 3 ? t('Unlock #{mode} for the remaining steps of this plan.', { mode }) : t('Every remaining
|
|
4976
|
+
const plan = el('button', 'grant plan', t('GRANT FOR PLAN')); plan.type = 'button'; plan.title = mode >= 3 ? t('Unlock #{mode} for the remaining steps of this plan.', { mode }) : t('Every remaining step of this plan that can write uses the same folder for new files.');
|
|
4760
4977
|
const deny = el('button', 'deny', t('DENY')); deny.type = 'button'; deny.title = t('The step continues in read-only mode.');
|
|
4761
4978
|
once.addEventListener('click', () => decide('once', once));
|
|
4762
4979
|
plan.addEventListener('click', () => decide('plan', plan));
|
|
@@ -5035,7 +5252,7 @@ function renderChoice(event) {
|
|
|
5035
5252
|
});
|
|
5036
5253
|
list.append(button);
|
|
5037
5254
|
});
|
|
5038
|
-
node.append(list, el('p', 'choice-foot', t('Pressing one sends it to @{agent} · #3 and #4 ask
|
|
5255
|
+
node.append(list, el('p', 'choice-foot', t('Pressing one sends it to @{agent} · #3 and #4 ask you to confirm first', { agent })));
|
|
5039
5256
|
state.lastSender = null;
|
|
5040
5257
|
return node;
|
|
5041
5258
|
}
|
|
@@ -5044,7 +5261,7 @@ function renderChoice(event) {
|
|
|
5044
5261
|
// press sends only which request and which line, and the server runs what it recorded the agent
|
|
5045
5262
|
// asked for. A line the parser refused is shown with its reason and no button.
|
|
5046
5263
|
function renderRunRequest(event) {
|
|
5047
|
-
const { reason, commands = [], agent, responseMessageId } = event.payload;
|
|
5264
|
+
const { reason, commands = [], agent, responseMessageId, mode } = event.payload;
|
|
5048
5265
|
if (!commands.length) return null;
|
|
5049
5266
|
const node = el('div', 'choice run-request');
|
|
5050
5267
|
node.dataset.run = responseMessageId;
|
|
@@ -5069,25 +5286,77 @@ function renderRunRequest(event) {
|
|
|
5069
5286
|
}
|
|
5070
5287
|
button.append(el('span', 'choice-tag', t('RUN')));
|
|
5071
5288
|
button.title = t('Runs {command} from the project root, with no shell. A restore point is taken first.', { command: command.line });
|
|
5072
|
-
button.addEventListener('click',
|
|
5073
|
-
|
|
5074
|
-
|
|
5075
|
-
|
|
5076
|
-
|
|
5077
|
-
|
|
5078
|
-
|
|
5079
|
-
|
|
5080
|
-
|
|
5081
|
-
|
|
5289
|
+
button.addEventListener('click', () => { void pressRun(button, { responseMessageId, index, startedAt: event.timestamp }); });
|
|
5290
|
+
list.append(button);
|
|
5291
|
+
});
|
|
5292
|
+
const runnable = commands.filter((one) => one.argv).length;
|
|
5293
|
+
// The whole request in one press, line after line, the way a terminal would run them; and what
|
|
5294
|
+
// ran, handed back without the rest. Once every line has run it goes back on its own.
|
|
5295
|
+
const actions = el('div', 'run-actions');
|
|
5296
|
+
if (runnable > 1) {
|
|
5297
|
+
const all = el('button', 'run-all', t('RUN ALL · {n}', { n: runnable }));
|
|
5298
|
+
all.type = 'button';
|
|
5299
|
+
all.title = t('Runs the lines one after another; it stops at the first one that fails.');
|
|
5300
|
+
all.addEventListener('click', async () => {
|
|
5301
|
+
all.disabled = true;
|
|
5302
|
+
for (const button of list.querySelectorAll('.run-line:not(.refused):not(.ran):not(.failed)')) {
|
|
5303
|
+
if (!(await pressRun(button, { responseMessageId, index: Number(button.dataset.index) }))) break;
|
|
5082
5304
|
}
|
|
5305
|
+
all.disabled = false;
|
|
5083
5306
|
});
|
|
5084
|
-
|
|
5307
|
+
actions.append(all);
|
|
5308
|
+
}
|
|
5309
|
+
const back = el('button', 'run-back', t('HAND BACK WHAT RAN TO @{agent}', { agent }));
|
|
5310
|
+
back.type = 'button';
|
|
5311
|
+
back.hidden = true;
|
|
5312
|
+
back.title = t('@{agent} gets the output of the lines that ran and goes on without the rest.', { agent });
|
|
5313
|
+
back.addEventListener('click', async () => {
|
|
5314
|
+
back.disabled = true;
|
|
5315
|
+
try {
|
|
5316
|
+
const response = await fetch('/api/runs/return', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ responseMessageId }) });
|
|
5317
|
+
const result = await response.json().catch(() => ({}));
|
|
5318
|
+
if (!response.ok) throw new Error(result.error ?? `HTTP ${response.status}`);
|
|
5319
|
+
} catch (error) { toast(`${t('Diagnostics › ')}${resay(error.message)}`); back.disabled = false; }
|
|
5085
5320
|
});
|
|
5086
|
-
|
|
5321
|
+
actions.append(back);
|
|
5322
|
+
const goesBack = Number.isInteger(mode) && mode > 1
|
|
5323
|
+
? t('When they have run, the output goes back to @{agent} and it goes on at #{n} {label}.', { agent, n: mode, label: MODES[mode]?.label ?? '' })
|
|
5324
|
+
: t('When they have run, the output goes back to @{agent} and it goes on.', { agent });
|
|
5325
|
+
node.append(list, el('p', 'run-summary', t('{done} of {total} commands finished', { done: 0, total: runnable })), actions, el('p', 'choice-foot', `${t('Each line runs when you press it · from the project root, no shell')} · ${goesBack}`));
|
|
5087
5326
|
state.lastSender = null;
|
|
5088
5327
|
return node;
|
|
5089
5328
|
}
|
|
5090
5329
|
|
|
5330
|
+
// One press of one line: the server runs what it recorded and answers when the line ends (or
|
|
5331
|
+
// starts serving). Says whether it went well, so RUN ALL knows to stop at a failure.
|
|
5332
|
+
async function pressRun(button, { responseMessageId, index, startedAt = null }) {
|
|
5333
|
+
button.disabled = true;
|
|
5334
|
+
markRun({ responseMessageId, index }, 'running', null, startedAt);
|
|
5335
|
+
try {
|
|
5336
|
+
const response = await fetch('/api/runs', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ responseMessageId, index }) });
|
|
5337
|
+
const result = await response.json().catch(() => ({}));
|
|
5338
|
+
if (!response.ok) throw new Error(result.error ?? `HTTP ${response.status}`);
|
|
5339
|
+
return result.serving ? true : result.ok !== false;
|
|
5340
|
+
} catch (error) {
|
|
5341
|
+
toast(`${t('Diagnostics › ')}${resay(error.message)}`);
|
|
5342
|
+
markRun({ responseMessageId, index }, null);
|
|
5343
|
+
return false;
|
|
5344
|
+
}
|
|
5345
|
+
}
|
|
5346
|
+
|
|
5347
|
+
// The results went back to the agent that asked: the card says so and offers nothing more to hand.
|
|
5348
|
+
function settleRunReturn(responseMessageId, agent, sequence) {
|
|
5349
|
+
if (!responseMessageId) return;
|
|
5350
|
+
state.runsReturned.add(responseMessageId);
|
|
5351
|
+
const group = document.querySelector(`.run-request[data-run="${CSS.escape(String(responseMessageId))}"]`);
|
|
5352
|
+
if (!group) return;
|
|
5353
|
+
group.classList.add('returned');
|
|
5354
|
+
const back = group.querySelector('.run-back');
|
|
5355
|
+
if (back) back.hidden = true;
|
|
5356
|
+
const summary = group.querySelector('.run-summary');
|
|
5357
|
+
if (summary) summary.textContent = t('Output handed back to @{agent}{at}', { agent, at: sequence ? ` · #${sequence}` : '' });
|
|
5358
|
+
}
|
|
5359
|
+
|
|
5091
5360
|
// The state of one line of a run request, from the events that follow it: running, ran, failed.
|
|
5092
5361
|
// A line that ran can be pressed again; tests are run more than once.
|
|
5093
5362
|
function markRun({ responseMessageId, index }, status, where = null, startedAt = null) {
|
|
@@ -5102,7 +5371,12 @@ function markRun({ responseMessageId, index }, status, where = null, startedAt =
|
|
|
5102
5371
|
if (tag) tag.textContent = { running: t('RUNNING'), serving: t('SERVING'), ran: t('RUN AGAIN'), failed: t('RUN AGAIN') }[status] ?? t('RUN');
|
|
5103
5372
|
const group = button.closest('.run-request');
|
|
5104
5373
|
const summary = group?.querySelector('.run-summary');
|
|
5105
|
-
|
|
5374
|
+
const finished = group?.querySelectorAll('.run-line.ran, .run-line.failed').length ?? 0;
|
|
5375
|
+
const lines = group?.querySelectorAll('.run-line:not(.refused)').length ?? 0;
|
|
5376
|
+
const returned = state.runsReturned.has(String(responseMessageId));
|
|
5377
|
+
if (summary && !returned) summary.textContent = t('{done} of {total} commands finished', { done: finished, total: lines });
|
|
5378
|
+
const back = group?.querySelector('.run-back');
|
|
5379
|
+
if (back) back.hidden = returned || !finished || finished >= lines || Boolean(group.querySelector('.run-line.running'));
|
|
5106
5380
|
// While it is alive, a line of its own under it: where it serves, and the two things a person
|
|
5107
5381
|
// does with a running command: look at it, or stop it. It goes when the command ends.
|
|
5108
5382
|
let live = button.nextElementSibling?.classList.contains('run-live') ? button.nextElementSibling : null;
|
|
@@ -5118,7 +5392,7 @@ function markRun({ responseMessageId, index }, status, where = null, startedAt =
|
|
|
5118
5392
|
}
|
|
5119
5393
|
if (status === 'serving' && where?.port) {
|
|
5120
5394
|
live.append(el('span', 'where', where.network
|
|
5121
|
-
? t('serving on :{port} ·
|
|
5395
|
+
? t('serving on :{port} · open to your whole network, so the project will not open it', { port: where.port })
|
|
5122
5396
|
: t('serving on :{port}', { port: where.port })));
|
|
5123
5397
|
if (!where.network) {
|
|
5124
5398
|
const open = el('button', 'run-open', t('OPEN IN YOUR SERVERS'));
|
|
@@ -5153,9 +5427,9 @@ runClock.unref?.();
|
|
|
5153
5427
|
// BISHOP proposed a plan in the room of Salas (docs/MACROVERSO.md, fases 4b y 4c). One row per
|
|
5154
5428
|
// order with the room, the agent and the mode in sight, and one per link it proposes. What a press
|
|
5155
5429
|
// sends is what the server recorded: the press names the plan and the row, never the order.
|
|
5156
|
-
const BISHOP_STATES = { ready: null, sending: 'SENDING', sent: 'SENT · WAITING FOR THE ANSWER', done: 'ANSWERED', failed: 'FAILED', skipped: 'SKIPPED', blocked: 'WILL NOT LEAVE', proposal: 'PROPOSAL', delivered: 'IN ITS PROJECT · WAITING FOR YOUR
|
|
5430
|
+
const BISHOP_STATES = { ready: null, sending: 'SENDING', sent: 'SENT · WAITING FOR THE ANSWER', done: 'ANSWERED', failed: 'FAILED', skipped: 'SKIPPED', blocked: 'WILL NOT LEAVE', proposal: 'PROPOSAL', delivered: 'IN ITS PROJECT · WAITING FOR YOUR CONFIRMATION' };
|
|
5157
5431
|
const BISHOP_MODE_WORDS = { manual: 'MANUAL', hybrid: 'HYBRID', auto: 'AUTOMATIC' };
|
|
5158
|
-
const BISHOP_MODE_NOTES = { manual: 'Manual: every order waits for
|
|
5432
|
+
const BISHOP_MODE_NOTES = { manual: 'Manual: every order waits for you to send it', hybrid: 'Hybrid: #0 and #1 leave on their own', auto: 'Automatic: up to #2 leave on their own' };
|
|
5159
5433
|
async function bishopPress(path, body, buttons) {
|
|
5160
5434
|
for (const one of buttons) one.disabled = true;
|
|
5161
5435
|
try {
|
|
@@ -5331,7 +5605,7 @@ function markBishopPlan(event) {
|
|
|
5331
5605
|
card.querySelector('.bishop-halt')?.remove();
|
|
5332
5606
|
} else {
|
|
5333
5607
|
card.querySelector('.bishop-halt')?.remove();
|
|
5334
|
-
const note = el('p', 'run-why bishop-halt', t('Stopped
|
|
5608
|
+
const note = el('p', 'run-why bishop-halt', t('Stopped sending orders on its own ({why}). The rest wait for you to send them.', { why: t(said.why) }));
|
|
5335
5609
|
card.querySelector('.choice-list')?.before(note);
|
|
5336
5610
|
if (!replaying) toast(t('Diagnostics › the Coordinator stopped sending on its own ({why}).', { why: t(said.why) }));
|
|
5337
5611
|
}
|
|
@@ -5344,7 +5618,7 @@ function renderBishopLine(event) {
|
|
|
5344
5618
|
node.append(el('b', null, t('Coordinator')));
|
|
5345
5619
|
const said = event.payload;
|
|
5346
5620
|
if (event.type === 'bishop.plan.closed') node.append(t(' · plan {plan} closed · {done} answered · {failed} failed · {skipped} skipped', { plan: said.plan, done: said.done ?? 0, failed: said.failed ?? 0, skipped: said.skipped ?? 0 }));
|
|
5347
|
-
else if (event.type === 'bishop.plan.halted') node.append(t(' · plan {plan}
|
|
5621
|
+
else if (event.type === 'bishop.plan.halted') node.append(t(' · plan {plan} stopped sending orders on its own ({why}); the rest wait for you to send them', { plan: said.plan, why: t(said.why) }));
|
|
5348
5622
|
else node.append(t(' · the block of orders could not be read: {why}', { why: (said.reasons ?? []).map((why) => t(why)).join('; ') }));
|
|
5349
5623
|
return node;
|
|
5350
5624
|
}
|
|
@@ -5363,11 +5637,11 @@ function renderBishopProposal(event) {
|
|
|
5363
5637
|
els.input.value = `@${agent} #${mode} ${text}`;
|
|
5364
5638
|
els.input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
5365
5639
|
els.input.focus();
|
|
5366
|
-
toast(t('Diagnostics ›
|
|
5640
|
+
toast(t('Diagnostics › the order is now in the Write box. Sending it asks you to confirm, as always.'));
|
|
5367
5641
|
});
|
|
5368
5642
|
node.append(arm);
|
|
5369
5643
|
}
|
|
5370
|
-
node.append(el('p', 'choice-foot', t('It runs only if you send it
|
|
5644
|
+
node.append(el('p', 'choice-foot', t('It runs only if you send it and confirm it.')));
|
|
5371
5645
|
state.lastSender = null;
|
|
5372
5646
|
return node;
|
|
5373
5647
|
}
|
|
@@ -5651,7 +5925,7 @@ function retryLonger({ messageId, target, timeout, partial }) {
|
|
|
5651
5925
|
els.input.value = [asked?.text ?? '', note].filter(Boolean).join('\n\n');
|
|
5652
5926
|
// CREATE comes back by itself; CONTROL and AIRLOCK ask for their keys again, from the chip.
|
|
5653
5927
|
if ((asked?.mode ?? 1) === 2) setMode(2);
|
|
5654
|
-
else if ((asked?.mode ?? 1) >= 3) toast(t('Diagnostics › #{n} needs
|
|
5928
|
+
else if ((asked?.mode ?? 1) >= 3) toast(t('Diagnostics › #{n} needs you to confirm again: choose it in the mode selector before sending.', { n: asked.mode }));
|
|
5655
5929
|
autosize();
|
|
5656
5930
|
els.input.focus();
|
|
5657
5931
|
retry.textContent = t('IN THE BOX · SEND IT WHEN READY');
|
|
@@ -5771,6 +6045,20 @@ function renderEventNode(event) {
|
|
|
5771
6045
|
break;
|
|
5772
6046
|
}
|
|
5773
6047
|
case 'room.settings': return;
|
|
6048
|
+
// The copilot's window opened or closed: a line in the conversation (it folds like the rest),
|
|
6049
|
+
// and the robot and the mode follow when it happens now.
|
|
6050
|
+
case 'copilot.started':
|
|
6051
|
+
case 'copilot.ended': {
|
|
6052
|
+
const started = event.type === 'copilot.started';
|
|
6053
|
+
node = el('div', `system copilot${started ? ' granted' : ''}`);
|
|
6054
|
+
const when = started ? new Date(event.payload.until).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : null;
|
|
6055
|
+
node.append(el('b', null, t('copilot › ')), started
|
|
6056
|
+
? t('on until {time}: the agent you write to drives the project without asking you to confirm each message', { time: when })
|
|
6057
|
+
: { time: t('off: its time ran out'), stopall: t('off: STOP ALL ended it'), human: t('off: you turned it off') }[event.payload.reason] ?? t('off'));
|
|
6058
|
+
if (!replaying) void loadCopilot();
|
|
6059
|
+
state.lastSender = null;
|
|
6060
|
+
break;
|
|
6061
|
+
}
|
|
5774
6062
|
case 'extension.toggled':
|
|
5775
6063
|
// A module switched on or off changes what the composer accepts.
|
|
5776
6064
|
if (!replaying) void refreshCommands();
|
|
@@ -5810,7 +6098,8 @@ function renderEventNode(event) {
|
|
|
5810
6098
|
if (open && !replaying) void paintAshEconomy(open);
|
|
5811
6099
|
return;
|
|
5812
6100
|
}
|
|
5813
|
-
case 'agent.completed': state.running.delete(event.payload.messageId); updateStopAll(); removeThinking(event.payload.messageId); return;
|
|
6101
|
+
case 'agent.completed': state.running.delete(event.payload.messageId); updateStopAll(); removeThinking(event.payload.messageId); if (!replaying) treeFollowWrites(); return;
|
|
6102
|
+
case 'files.touched': markTouched(event.payload.agent, event.payload.files, event.timestamp); if (!replaying) treeFollowWrites(); return;
|
|
5814
6103
|
case 'message.failed': state.running.delete(event.payload.messageId); updateStopAll(); node = renderFailure(event); break;
|
|
5815
6104
|
case 'handoff.created': node = renderHandoff(event); break;
|
|
5816
6105
|
case 'memory.distilled': node = renderDistilled(event); break;
|
|
@@ -5841,7 +6130,7 @@ function renderEventNode(event) {
|
|
|
5841
6130
|
}
|
|
5842
6131
|
for (const id of event.payload.removed ?? []) { state.agents.delete(id); [...els.target.options].find((item) => item.value === id)?.remove(); }
|
|
5843
6132
|
renderAgents(); renderPicker(); renderOnboarding();
|
|
5844
|
-
if (!replaying && event.payload.reason) toast(`${t('Diagnostics › ')}${event.payload.reason}`);
|
|
6133
|
+
if (!replaying && event.payload.reason) toast(`${t('Diagnostics › ')}${resay(event.payload.reason)}`);
|
|
5845
6134
|
return;
|
|
5846
6135
|
}
|
|
5847
6136
|
case 'local.present': {
|
|
@@ -5856,7 +6145,7 @@ function renderEventNode(event) {
|
|
|
5856
6145
|
break;
|
|
5857
6146
|
}
|
|
5858
6147
|
case 'mother.alert': node = renderMotherAlert(event); break;
|
|
5859
|
-
case 'sentinel.report': state.reports.set(event.payload.id, { ...event.payload }); if (!replaying) { renderMotherSentinel(); toast(t('Diagnostics › {what} was recorded by the
|
|
6148
|
+
case 'sentinel.report': state.reports.set(event.payload.id, { ...event.payload }); if (!replaying) { renderMotherSentinel(); toast(t('Diagnostics › {what} was recorded by the failure monitor. Open Diagnostics to report it.', { what: event.payload.kind === 'crash' ? t('a crash') : t('an unexpected problem') })); } return;
|
|
5860
6149
|
case 'servers.changed': paintPorts(event.payload); return;
|
|
5861
6150
|
case 'server.stopped': if (!replaying) serverStopped(event.payload); return;
|
|
5862
6151
|
case 'server.started': if (!replaying) serverStarted(event.payload); return;
|
|
@@ -5919,13 +6208,19 @@ function renderEventNode(event) {
|
|
|
5919
6208
|
case 'bishop.order.proposed': node = renderBishopProposal(event); break;
|
|
5920
6209
|
case 'bishop.answer': node = renderBishopAnswer(event); break;
|
|
5921
6210
|
case 'create.missing': node = renderCreateMissing(event); break;
|
|
5922
|
-
case 'control.changed': markTouched(event.payload.agent, event.payload.files); node = renderControlChanged(event); if (!replaying) { ripleyMaybeReload((event.payload.files ?? []).map((file) => file.path)); treeFollowWrites(); } break;
|
|
6211
|
+
case 'control.changed': markTouched(event.payload.agent, event.payload.files, event.timestamp); node = renderControlChanged(event); if (!replaying) { ripleyMaybeReload((event.payload.files ?? []).map((file) => file.path)); treeFollowWrites(); } break;
|
|
5923
6212
|
case 'control.reverted': node = renderControlReverted(event); if (!replaying) treeFollowWrites(); break;
|
|
5924
6213
|
case 'mode.granted':
|
|
5925
6214
|
case 'mode.denied': settleModeRequest(event); return;
|
|
5926
6215
|
case 'plan.ignored': node = renderPlanIgnored(event); break;
|
|
5927
|
-
case 'artifacts.created': markTouched(event.payload.agent, event.payload.files); attachArtifacts(event); if (!replaying) { ripleyMaybeReload((event.payload.files ?? []).map((file) => file.path)); treeFollowWrites(); } return;
|
|
5928
|
-
case 'command.output':
|
|
6216
|
+
case 'artifacts.created': markTouched(event.payload.agent, event.payload.files, event.timestamp); attachArtifacts(event); if (!replaying) { ripleyMaybeReload((event.payload.files ?? []).map((file) => file.path)); treeFollowWrites(); } return;
|
|
6217
|
+
case 'command.output': {
|
|
6218
|
+
const run = event.payload.run;
|
|
6219
|
+
if (run) markTouched(run.agent, [...(run.files ?? []), ...(run.touched ?? [])], event.timestamp);
|
|
6220
|
+
node = renderCommandCard(event);
|
|
6221
|
+
if (!replaying && run) treeFollowWrites();
|
|
6222
|
+
break;
|
|
6223
|
+
}
|
|
5929
6224
|
case 'eyecat.flagged': node = renderEyecat(event); break;
|
|
5930
6225
|
case 'eyecat.settled': settleEyecat(event); return;
|
|
5931
6226
|
default: return;
|
|
@@ -5938,15 +6233,145 @@ function renderEventNode(event) {
|
|
|
5938
6233
|
// says something about itself: in a new project @madre's arrival used to take it away before
|
|
5939
6234
|
// anyone had written a word.
|
|
5940
6235
|
if (event.type.startsWith('message.') || event.type === 'command.output' || event.type === 'run.requested') removeEmpty();
|
|
5941
|
-
const stickToBottom = scroller.height - scroller.top - scroller.view < 120;
|
|
6236
|
+
const stickToBottom = !drawingRecord && scroller.height - scroller.top - scroller.view < 120;
|
|
5942
6237
|
els.column.append(tidySystemLine(node));
|
|
5943
|
-
|
|
5944
|
-
|
|
6238
|
+
scheduleFold(node);
|
|
6239
|
+
if (!drawingRecord) followTurn(event, node, stickToBottom);
|
|
6240
|
+
}
|
|
6241
|
+
|
|
6242
|
+
// The room's notices fold into dots (2026-10-14). «CREATE · @codex may create…», «a plan is still
|
|
6243
|
+
// running», a handoff, a memory kept: each one matters the moment it happens and is clutter ten
|
|
6244
|
+
// seconds later, and a working session left the answers buried between them. Now a notice says
|
|
6245
|
+
// its piece, and after ten seconds — later if the pointer is on it — it becomes a dot in the
|
|
6246
|
+
// colour of its kind, beside the dots around it; a click opens it again with everything it said.
|
|
6247
|
+
// What still asks for something stays as it is: a failure with its RETRY, a permission waiting, a
|
|
6248
|
+
// module to install, the changes a turn made with their UNDO, MU/TH/UR. History folds at once.
|
|
6249
|
+
const NOTICE_FOLD_MS = 10000;
|
|
6250
|
+
// A limit warning folds too (2026-10-14): the agent's ring in the bar already says it, in red.
|
|
6251
|
+
const NOTICE_KEEP = '.fail, .mother-alert, .mother, .escalation, .lease.missing, .lease.refused, .module-proposed, .control.changed, .local-model, .memory.forgotten';
|
|
6252
|
+
function noticeTone(node) {
|
|
6253
|
+
if (node.matches('.alert, .crit, .halted, .control, .login.failed, .module.failed')) return 'crit';
|
|
6254
|
+
if (node.matches('.warn, .lease, .privacy, .enlace.warn')) return 'warn';
|
|
6255
|
+
if (node.matches('.login, .module, .reverted, .granted, .recovered')) return 'ok';
|
|
6256
|
+
return 'info';
|
|
6257
|
+
}
|
|
6258
|
+
// A notice folds only once it asks for nothing: any button still live keeps it open. STOP ALL on
|
|
6259
|
+
// an alert is the exception — the brake lives in the bar too.
|
|
6260
|
+
function noticeWaits(node) {
|
|
6261
|
+
return [...node.querySelectorAll('button')].some((button) => !button.disabled && !button.hidden && !button.classList.contains('cmd'));
|
|
6262
|
+
}
|
|
6263
|
+
function foldableNotice(node) {
|
|
6264
|
+
return Boolean(node?.classList?.contains('system')) && node.parentElement === els.column && !node.matches(NOTICE_KEEP);
|
|
6265
|
+
}
|
|
6266
|
+
function scheduleFold(node, tries = 0) {
|
|
6267
|
+
if (!foldableNotice(node)) return;
|
|
6268
|
+
if (replaying) { if (!noticeWaits(node)) foldNotice(node, { quiet: true }); return; }
|
|
6269
|
+
setTimeout(() => {
|
|
6270
|
+
if (!node.isConnected || node.classList.contains('folded')) return;
|
|
6271
|
+
// Being read, or still waiting for a press: look again in a moment, for at most ten minutes.
|
|
6272
|
+
if (node.matches(':hover, :focus-within') || noticeWaits(node)) { if (tries < 60) scheduleFold(node, tries + 1); return; }
|
|
6273
|
+
foldNotice(node);
|
|
6274
|
+
}, tries ? 10000 : NOTICE_FOLD_MS);
|
|
6275
|
+
}
|
|
6276
|
+
function noticeWords(node) {
|
|
6277
|
+
return node.textContent.replace(/\s+/g, ' ').trim().slice(0, 220);
|
|
6278
|
+
}
|
|
6279
|
+
function foldNotice(node, { quiet = false } = {}) {
|
|
6280
|
+
if (!node.isConnected || node.classList.contains('folded') || !foldableNotice(node)) return;
|
|
6281
|
+
const finish = () => {
|
|
6282
|
+
if (!node.isConnected) return;
|
|
6283
|
+
const before = node.previousElementSibling?.classList.contains('sys-dots') ? node.previousElementSibling : null;
|
|
6284
|
+
const after = node.nextElementSibling?.classList.contains('sys-dots') ? node.nextElementSibling : null;
|
|
6285
|
+
let strip = before ?? after;
|
|
6286
|
+
if (!strip) { strip = el('div', 'sys-dots'); node.before(strip); }
|
|
6287
|
+
// Notices go before the row's «+N», which stays last.
|
|
6288
|
+
const into = (row, ...nodes) => { const more = row.querySelector(':scope > .sys-more'); if (more) more.before(...nodes); else row.append(...nodes); };
|
|
6289
|
+
if (strip === after) strip.prepend(node); else into(strip, node);
|
|
6290
|
+
if (before && after) { into(before, ...after.querySelectorAll(':scope > .system')); after.remove(); }
|
|
6291
|
+
node.classList.remove('folding');
|
|
6292
|
+
node.classList.toggle('quiet', quiet);
|
|
6293
|
+
node.classList.add('folded');
|
|
6294
|
+
node.dataset.tone = noticeTone(node);
|
|
6295
|
+
node.title = noticeWords(node);
|
|
6296
|
+
countStrip(strip);
|
|
6297
|
+
};
|
|
6298
|
+
if (quiet || matchMedia('(prefers-reduced-motion: reduce)').matches) { finish(); return; }
|
|
6299
|
+
node.classList.add('folding');
|
|
6300
|
+
setTimeout(finish, 220);
|
|
6301
|
+
}
|
|
6302
|
+
// A row says how many it holds: its first dot, and «+N» for the rest.
|
|
6303
|
+
function countStrip(strip) {
|
|
6304
|
+
const notices = strip.querySelectorAll(':scope > .system.folded');
|
|
6305
|
+
let more = strip.querySelector(':scope > .sys-more');
|
|
6306
|
+
if (!more) { more = el('span', 'sys-more'); strip.append(more); }
|
|
6307
|
+
const open = strip.classList.contains('open');
|
|
6308
|
+
more.textContent = open ? t('collapse') : notices.length > 1 ? `+${notices.length - 1}` : '';
|
|
6309
|
+
more.hidden = !open && notices.length < 2;
|
|
6310
|
+
// A row of one is only its dot, centred in a round badge.
|
|
6311
|
+
strip.classList.toggle('single', !open && notices.length < 2);
|
|
6312
|
+
strip.tabIndex = 0;
|
|
6313
|
+
strip.setAttribute('role', 'button');
|
|
6314
|
+
strip.setAttribute('aria-expanded', String(open));
|
|
6315
|
+
strip.setAttribute('aria-label', t('{n} notices', { n: notices.length }));
|
|
6316
|
+
strip.title = open ? '' : [...notices].map(noticeWords).join('\n');
|
|
6317
|
+
}
|
|
6318
|
+
// Read open, the same notice said several times in a row is one line with «×N»: the others stay
|
|
6319
|
+
// in the row (and in its count), only hidden while it is open.
|
|
6320
|
+
function groupRepeats(strip) {
|
|
6321
|
+
let lead = null;
|
|
6322
|
+
let times = 1;
|
|
6323
|
+
for (const node of strip.querySelectorAll(':scope > .system.folded')) {
|
|
6324
|
+
node.classList.remove('repeat');
|
|
6325
|
+
delete node.dataset.times;
|
|
6326
|
+
node.style.removeProperty('--times');
|
|
6327
|
+
if (lead && noticeWords(node) === noticeWords(lead)) {
|
|
6328
|
+
node.classList.add('repeat');
|
|
6329
|
+
times += 1;
|
|
6330
|
+
lead.dataset.times = String(times);
|
|
6331
|
+
lead.style.setProperty('--times', `"×${times}"`);
|
|
6332
|
+
} else { lead = node; times = 1; }
|
|
6333
|
+
}
|
|
6334
|
+
}
|
|
6335
|
+
// The whole row opens at once, in order, and folds again on a click or Escape.
|
|
6336
|
+
function openStrip(strip, open = !strip.classList.contains('open')) {
|
|
6337
|
+
if (open) for (const other of els.column.querySelectorAll('.sys-dots.open')) if (other !== strip) openStrip(other, false);
|
|
6338
|
+
// Opening a row is reading it: the thread stops following the end, or the taller row would be
|
|
6339
|
+
// scrolled away from under the click.
|
|
6340
|
+
const last = !strip.nextElementSibling;
|
|
6341
|
+
if (open && !last) pinnedToEnd = false;
|
|
6342
|
+
strip.classList.toggle('open', open);
|
|
6343
|
+
for (const node of strip.querySelectorAll(':scope > .system.folded')) node.classList.remove('quiet');
|
|
6344
|
+
groupRepeats(strip);
|
|
6345
|
+
countStrip(strip);
|
|
6346
|
+
if (open) requestAnimationFrame(() => strip.scrollIntoView?.({ block: 'nearest', behavior: 'smooth' }));
|
|
6347
|
+
}
|
|
6348
|
+
els.column.addEventListener('click', (event) => {
|
|
6349
|
+
const strip = event.target.closest?.('.sys-dots');
|
|
6350
|
+
if (!strip || event.target.closest('a, button')) return;
|
|
6351
|
+
// Open, only the fold key folds it: the notices themselves are read, and selected, in place.
|
|
6352
|
+
if (strip.classList.contains('open') && !event.target.closest('.sys-more')) return;
|
|
6353
|
+
openStrip(strip);
|
|
6354
|
+
});
|
|
6355
|
+
els.column.addEventListener('keydown', (event) => {
|
|
6356
|
+
const strip = event.target.closest?.('.sys-dots');
|
|
6357
|
+
if (!strip || event.target !== strip) return;
|
|
6358
|
+
if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openStrip(strip); }
|
|
6359
|
+
else if (event.key === 'Escape' && strip.classList.contains('open')) openStrip(strip, false);
|
|
6360
|
+
});
|
|
5945
6361
|
|
|
5946
6362
|
// Modern chats carry the reader along: a new human message or a working
|
|
5947
6363
|
// indicator scrolls to the end; an answer scrolls to its start so a long
|
|
5948
6364
|
// reply is read from the top. Live events only, never during the replay.
|
|
5949
6365
|
let replaying = true;
|
|
6366
|
+
// A whole record drawn at once (opening the conversation, catching up after a reconnection): no
|
|
6367
|
+
// event reads the column's height or scrolls on its own — each read made the browser lay the
|
|
6368
|
+
// whole growing column out again, twice per event, and a 166-message conversation took 11 s to
|
|
6369
|
+
// open. The column goes to its end once, when the record is drawn.
|
|
6370
|
+
let drawingRecord = false;
|
|
6371
|
+
function drawRecord(events) {
|
|
6372
|
+
drawingRecord = true;
|
|
6373
|
+
try { for (const event of events) renderEvent(event); } finally { drawingRecord = false; }
|
|
6374
|
+
}
|
|
5950
6375
|
function followTurn(event, node, stickToBottom) {
|
|
5951
6376
|
if (replaying) { if (stickToBottom) scrollToEnd(); return; }
|
|
5952
6377
|
const isTurn = event.type === 'agent.started' || (event.type === 'message.created' && event.payload.role === 'user');
|
|
@@ -6305,7 +6730,7 @@ function drawCustom() {
|
|
|
6305
6730
|
section(t('LANGUAGE'), null, languages, t('The whole interface. The page reloads in it when you accept.')),
|
|
6306
6731
|
section(t('THEME'), null, themes, t('Automatic follows your system. The whole project changes, not only the conversation.')),
|
|
6307
6732
|
section(t('WALLPAPER'), null, (() => { const box = el('div', 'paper-box'); box.append(papers); if (paperTune.children.length) box.append(paperTune); return box; })(), PHOTO_WALLPAPERS.has(draft.wallpaper)
|
|
6308
|
-
? t('
|
|
6733
|
+
? t('The project\'s background can be a photograph, shown as a layer or as a mesh of its colors: choose how strong it looks and how much the photograph blurs.')
|
|
6309
6734
|
: t('Behind the conversation, in this window and in every conversation beside it. Light and dark each have their own.')),
|
|
6310
6735
|
section(t('ACCENT'), named, swatches, t('Each colour in dark (top) and in light (bottom): on a light ground it deepens so it can be read. The conversation and the buttons wear it; the permission modes keep their own.')),
|
|
6311
6736
|
section(t('BUBBLES'), null, segmented('bubbles')),
|
|
@@ -6334,7 +6759,7 @@ function drawCustom() {
|
|
|
6334
6759
|
paperBox.append(papers);
|
|
6335
6760
|
if (paperTune.children.length) paperBox.append(paperTune);
|
|
6336
6761
|
const paperBlock = sxBlock(page, t('Wallpaper'), PHOTO_WALLPAPERS.has(draft.wallpaper)
|
|
6337
|
-
? t('
|
|
6762
|
+
? t('The project\'s background can be a photograph, shown as a layer or as a mesh of its colors: choose how strong it looks and how much the photograph blurs.')
|
|
6338
6763
|
: t('Behind the conversation, in this window and in every conversation beside it. Light and dark each have their own.'));
|
|
6339
6764
|
paperBlock.rows.append(paperBox);
|
|
6340
6765
|
const talk = sxBlock(page, t('Conversation'));
|
|
@@ -6437,7 +6862,8 @@ function openCustom(open) {
|
|
|
6437
6862
|
customButton.setAttribute('aria-expanded', String(open));
|
|
6438
6863
|
customButton.setAttribute('aria-pressed', String(open));
|
|
6439
6864
|
}
|
|
6440
|
-
|
|
6865
|
+
// A window at the edge is not shown: its key brings it back instead of closing it.
|
|
6866
|
+
customButton?.addEventListener('click', () => openSettings(!settingsWindow.node?.open || atTheEdge(settingsWindow.node)));
|
|
6441
6867
|
customWindow.node?.addEventListener('close', () => { customButton?.setAttribute('aria-expanded', 'false'); customButton?.setAttribute('aria-pressed', 'false'); });
|
|
6442
6868
|
|
|
6443
6869
|
/* ---------- AJUSTES: every setting in one window ---------- */
|
|
@@ -6452,33 +6878,35 @@ customWindow.node?.addEventListener('close', () => { customButton?.setAttribute(
|
|
|
6452
6878
|
// Nothing here is a second copy of a setting: every category draws the same panel MU/TH/UR draws,
|
|
6453
6879
|
// from the same code and the same answers (state.settingsTargets), wearing the room's ink.
|
|
6454
6880
|
const SETTINGS_SECTIONS = [
|
|
6455
|
-
['You', 'How it looks
|
|
6456
|
-
['The agents', 'Who works, and
|
|
6457
|
-
['System', '
|
|
6881
|
+
['You', 'How it looks, how it works and your files', ['look', 'room', 'files']],
|
|
6882
|
+
['The agents', 'Who works, what they remember and which outside tools they use', ['agents', 'memory', 'mcp']],
|
|
6883
|
+
['System', 'What agents may do, privacy and updates', ['permissions', 'privacy', 'release']],
|
|
6458
6884
|
];
|
|
6459
6885
|
const SETTINGS_CATS = {
|
|
6460
6886
|
look: { name: 'Appearance', color: '#8e75e8', icon: '<circle cx="10" cy="10" r="6.5" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M10 3.5a6.5 6.5 0 0 1 0 13z" fill="currentColor"/>' },
|
|
6461
|
-
room: { name: '
|
|
6887
|
+
room: { name: 'Work and limits', color: '#d97757', icon: '<rect x="3.5" y="3.5" width="5.5" height="5.5" rx="1.4" fill="none" stroke="currentColor" stroke-width="1.6"/><rect x="11" y="3.5" width="5.5" height="5.5" rx="1.4" fill="none" stroke="currentColor" stroke-width="1.6"/><rect x="3.5" y="11" width="5.5" height="5.5" rx="1.4" fill="none" stroke="currentColor" stroke-width="1.6"/><rect x="11" y="11" width="5.5" height="5.5" rx="1.4" fill="none" stroke="currentColor" stroke-width="1.6"/>' },
|
|
6462
6888
|
agents: { name: 'Agents', color: '#10a37f', icon: '<circle cx="7" cy="8" r="2.6" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="13.6" cy="8" r="2.6" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M2.8 16c.6-2.3 2.2-3.6 4.2-3.6s3.6 1.3 4.2 3.6M9.6 13c.9-.4 2.4-.6 4-.6 2 0 3.6 1.3 4.2 3.6" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>' },
|
|
6463
|
-
memory: { name: '
|
|
6464
|
-
|
|
6889
|
+
memory: { name: 'What it remembers', color: '#3fb6d9', icon: '<rect x="5" y="5" width="10" height="10" rx="1.8" fill="none" stroke="currentColor" stroke-width="1.6"/><rect x="8.1" y="8.1" width="3.8" height="3.8" rx=".6" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M8 2.5V5M12 2.5V5M8 15v2.5M12 15v2.5M2.5 8H5M2.5 12H5M15 8h2.5M15 12h2.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>' },
|
|
6890
|
+
mcp: { name: 'Outside tools (MCP)', color: '#5b8def', icon: '<path d="M7.2 3.2v3.3M12.8 3.2v3.3" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><path d="M5.2 6.5h9.6v2.4a4.8 4.8 0 0 1-9.6 0z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M10 13.7v3.1" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>' },
|
|
6891
|
+
files: { name: 'Files and local sites', color: '#c8955a', icon: '<path d="M3.5 6.2c0-.9.7-1.6 1.6-1.6h3l1.6 1.8h5.2c.9 0 1.6.7 1.6 1.6v6.4c0 .9-.7 1.6-1.6 1.6H5.1c-.9 0-1.6-.7-1.6-1.6z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>' },
|
|
6465
6892
|
permissions: { name: 'Permissions', color: '#e5484d', icon: '<path d="M10 2.8 4.2 5v4.6c0 3.6 2.4 6.4 5.8 7.6 3.4-1.2 5.8-4 5.8-7.6V5z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><circle cx="10" cy="9" r="1.7" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M10 10.7v2.6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>' },
|
|
6466
6893
|
privacy: { name: 'Privacy', color: '#d4a72c', icon: '<rect x="4.5" y="8.5" width="11" height="8" rx="2" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M7 8.5V6.6a3 3 0 0 1 6 0v1.9" fill="none" stroke="currentColor" stroke-width="1.6"/>' },
|
|
6467
|
-
release: { name: '
|
|
6894
|
+
release: { name: 'Updates and errors', color: '#4caf3a', icon: '<path d="M15.6 10a5.6 5.6 0 1 1-1.64-3.96" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><path d="M15.7 3.6v3.2h-3.2" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>' },
|
|
6468
6895
|
};
|
|
6469
|
-
const SETTINGS_ORDER = ['look', 'room', 'files', 'agents', 'memory', 'permissions', 'privacy', 'release'];
|
|
6896
|
+
const SETTINGS_ORDER = ['look', 'room', 'files', 'agents', 'memory', 'mcp', 'permissions', 'privacy', 'release'];
|
|
6470
6897
|
// The words the search knows, each pointing to the category that holds it and to the words on the
|
|
6471
6898
|
// screen that carry it there, so a result opens the category and lands on the setting.
|
|
6472
6899
|
const SETTINGS_INDEX = [
|
|
6473
6900
|
['look', 'Preview'], ['look', 'Language'], ['look', 'Theme'], ['look', 'Wallpaper'], ['look', 'Accent'], ['look', 'Bubbles'], ['look', 'Text'],
|
|
6474
|
-
['room', 'Several at once'], ['room', 'Notifications'], ['room', 'Conversations at once'], ['room', '
|
|
6901
|
+
['room', 'Several at once'], ['room', 'Notifications'], ['room', 'Conversations at once'], ['room', 'Answers per conversation'], ['room', 'Agents may make plans'], ['room', 'Steps per plan'],
|
|
6475
6902
|
['files', 'Files'], ['files', 'Your servers'], ['files', 'File viewer'], ['files', 'Preview'], ['files', "Test browser"],
|
|
6476
|
-
['room', '
|
|
6903
|
+
['room', 'Usage limit per agent'], ['room', 'Default time limit'], ['room', 'Silence limit'], ['room', 'Retries'], ['room', 'Model'],
|
|
6477
6904
|
['agents', 'Max mode'], ['agents', 'Starting mode'], ['agents', 'Web access'], ['agents', 'Generate images'], ['agents', 'Time limit'], ['agents', 'Sign in'],
|
|
6478
|
-
['memory', 'Who
|
|
6479
|
-
['
|
|
6480
|
-
['
|
|
6481
|
-
['
|
|
6905
|
+
['memory', 'Who takes notes first'], ['memory', 'Who may take notes'], ['memory', 'Room for memories'], ['memory', 'Related memories'], ['memory', 'Search by meaning'], ['memory', 'Training data'], ['memory', 'Details and checkups'],
|
|
6906
|
+
['mcp', 'Outside tools (MCP)'], ['mcp', 'Add a tool'], ['mcp', 'Import from your apps'], ['mcp', 'Can act from'],
|
|
6907
|
+
['permissions', 'How you unlock modes'], ['permissions', 'Your phrase'], ['permissions', 'Remember permission'], ['permissions', 'Memory access'], ['permissions', 'Who may do what'],
|
|
6908
|
+
['privacy', 'Words to hide'], ['privacy', 'Replaced with'], ['privacy', 'Access keys and emails'], ['privacy', 'Your home folder'], ['privacy', 'Erase private data'],
|
|
6909
|
+
['release', 'Look for new versions'], ['release', 'Check now'], ['release', "What's new in this version"], ['release', 'The tour of MADRE 1.0'], ['release', 'Send reports automatically'], ['release', 'Tell the author'],
|
|
6482
6910
|
];
|
|
6483
6911
|
const settingsWindow = { node: document.querySelector('#settings'), body: document.querySelector('#settings-body'), search: document.querySelector('#settings-search'), box: null, ready: false, open: null, query: '', mode: 'board', observer: null };
|
|
6484
6912
|
try { settingsWindow.box = JSON.parse(localStorage.getItem('pulse.settingsWindow') ?? 'null'); } catch { /* no storage */ }
|
|
@@ -6504,6 +6932,7 @@ function settingsStatus(id) {
|
|
|
6504
6932
|
const ripley = modules.items.find((item) => item.id === 'ripley');
|
|
6505
6933
|
return { line: `${finder} · ${ripley ? (ripley.status?.installed ? t('preview on') : t('preview off')) : t('Reading…')}` };
|
|
6506
6934
|
}
|
|
6935
|
+
if (id === 'mcp') return mcpStatus();
|
|
6507
6936
|
if (!data) return { line: t('Reading…') };
|
|
6508
6937
|
if (id === 'agents') {
|
|
6509
6938
|
const here = data.agents.filter((agent) => agent.detected);
|
|
@@ -6513,9 +6942,9 @@ function settingsStatus(id) {
|
|
|
6513
6942
|
}
|
|
6514
6943
|
if (id === 'memory') {
|
|
6515
6944
|
const mem = data.settings.memory;
|
|
6516
|
-
if (!mem) return { line: t('This project has no memory
|
|
6945
|
+
if (!mem) return { line: t('This project has no memory yet') };
|
|
6517
6946
|
const who = mem.archivist === 'auto' || !mem.archivist ? t('the cheapest agent') : `@${mem.archivist}`;
|
|
6518
|
-
return { line: t('
|
|
6947
|
+
return { line: t('Notes by {who} · search by meaning {how}', { who, how: (mem.embedProvider ?? 'auto').toUpperCase() }) };
|
|
6519
6948
|
}
|
|
6520
6949
|
if (id === 'permissions') {
|
|
6521
6950
|
const config = data.settings.override ?? OVERRIDE_DEFAULT;
|
|
@@ -6525,13 +6954,13 @@ function settingsStatus(id) {
|
|
|
6525
6954
|
if (id === 'privacy') {
|
|
6526
6955
|
const priv = data.settings.privacy;
|
|
6527
6956
|
if (!priv) return { line: t('Reading…') };
|
|
6528
|
-
return { line: `${priv.terms.length ? t('{n} hidden data items', { n: priv.terms.length }) : t('No hidden data')} · ${priv.secrets !== false ? t('keys
|
|
6957
|
+
return { line: `${priv.terms.length ? t('{n} hidden data items', { n: priv.terms.length }) : t('No hidden data')} · ${priv.secrets !== false ? t('keys hidden') : t('keys visible')}` };
|
|
6529
6958
|
}
|
|
6530
6959
|
if (id === 'release') {
|
|
6531
6960
|
const info = updateUI.info;
|
|
6532
|
-
const report = sentinelUI.settings?.autoReport ? t('
|
|
6961
|
+
const report = sentinelUI.settings?.autoReport ? t('error reports sent') : t('error reports kept here');
|
|
6533
6962
|
if (!info) return { line: report };
|
|
6534
|
-
return { line: `MADRE ${info.current} · ${info.available ? t('{version} available', { version: info.latest }) : info.enabled === false ? t('not checked') : t('up to date')} · ${report}`, warn: info.available ? t('{version} is
|
|
6963
|
+
return { line: `MADRE ${info.current} · ${info.available ? t('{version} available', { version: info.latest }) : info.enabled === false ? t('not checked') : t('up to date')} · ${report}`, warn: info.available ? t('{version} is out', { version: info.latest }) : null };
|
|
6535
6964
|
}
|
|
6536
6965
|
return { line: '' };
|
|
6537
6966
|
}
|
|
@@ -6575,14 +7004,14 @@ function settingsQuick(id) {
|
|
|
6575
7004
|
}
|
|
6576
7005
|
}
|
|
6577
7006
|
if (id === 'privacy' && settingsUI.data?.settings.privacy) {
|
|
6578
|
-
add(t('
|
|
7007
|
+
add(t('Hide keys and emails'), switchFor(settingsUI.data.settings.privacy.secrets !== false, async (on, box) => {
|
|
6579
7008
|
try { const payload = await fetch('/api/privacy', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ secrets: on }) }).then((response) => response.json()); if (payload.error) throw new Error(payload.error); settingsUI.data.settings.privacy.secrets = on; }
|
|
6580
7009
|
catch (error) { box.checked = !on; toast(t('The privacy setting was not saved: {error}', { error: error.message })); }
|
|
6581
7010
|
renderSettings();
|
|
6582
7011
|
}));
|
|
6583
7012
|
}
|
|
6584
7013
|
if (id === 'release' && sentinelUI.settings) {
|
|
6585
|
-
add(t('
|
|
7014
|
+
add(t('Send error reports automatically'), switchFor(Boolean(sentinelUI.settings.autoReport), async (on, box) => {
|
|
6586
7015
|
try { sentinelUI.settings = (await fetch('/api/sentinel/settings', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ autoReport: on }) }).then((response) => response.json())).settings; }
|
|
6587
7016
|
catch (error) { box.checked = !on; toast(t('It could not be saved: {error}', { error: error.message })); }
|
|
6588
7017
|
renderMotherSentinel();
|
|
@@ -6860,12 +7289,14 @@ function settingsDetail(id) {
|
|
|
6860
7289
|
if (id === 'permissions') part('permissions');
|
|
6861
7290
|
if (id === 'release') { part('release'); part('sentinel'); }
|
|
6862
7291
|
if (id === 'files') part('files');
|
|
7292
|
+
if (id === 'mcp') part('mcp');
|
|
6863
7293
|
return detail;
|
|
6864
7294
|
}
|
|
6865
7295
|
|
|
6866
7296
|
// Draws what each category needs, from the same calls MU/TH/UR makes.
|
|
6867
7297
|
function fillSettingsCategory(id) {
|
|
6868
7298
|
if (id === 'files') { drawFilesTarget(); void refreshModules(); }
|
|
7299
|
+
if (id === 'mcp') { drawMcpTarget(); void loadMcp(); }
|
|
6869
7300
|
if (['agents', 'room', 'memory', 'privacy', 'permissions'].includes(id)) { if (settingsUI.data) renderSettings(); void loadSettings(); }
|
|
6870
7301
|
if (id === 'room' || id === 'release') { if (turnsUI.turns || sentinelUI.settings) renderMotherSentinel(); void loadTurns(); void loadSentinel(); }
|
|
6871
7302
|
if (id === 'release') renderUpdate();
|
|
@@ -7213,7 +7644,7 @@ for (const button of document.querySelectorAll?.('button') ?? []) {
|
|
|
7213
7644
|
// `openDirs` survives a reload of the tree: after a copy, a move or a new folder the panel is
|
|
7214
7645
|
// rebuilt, and a tree that folds itself shut hides exactly the thing the human just made.
|
|
7215
7646
|
// `reveal` is that thing, lit once when its row is drawn again.
|
|
7216
|
-
const tree = { open: false, loaded: new Map(), openDirs: new Set(), reveal: null };
|
|
7647
|
+
const tree = { open: false, loaded: new Map(), openDirs: new Set(), reveal: null, stale: false };
|
|
7217
7648
|
const formatSize = (bytes) => bytes == null ? '' : bytes < 1024 ? `${bytes} B` : bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(bytes < 10240 ? 1 : 0)} KB` : bytes < 1024 ** 3 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : `${(bytes / 1024 ** 3).toFixed(1)} GB`;
|
|
7218
7649
|
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;
|
|
7219
7650
|
|
|
@@ -7315,8 +7746,8 @@ const finderJoin = (dir, name) => (dir === '.' ? name : `${dir}/${name}`);
|
|
|
7315
7746
|
// swapped in whole. Several writes in a row are one refresh. A search being typed, or a word being
|
|
7316
7747
|
// asked in the panel, is the human's and is left alone.
|
|
7317
7748
|
let treeFollowTimer = null;
|
|
7318
|
-
function treeFollowWrites() {
|
|
7319
|
-
if (!tree.open) return;
|
|
7749
|
+
function treeFollowWrites({ now = false } = {}) {
|
|
7750
|
+
if (!tree.open) { tree.stale = true; return; }
|
|
7320
7751
|
clearTimeout(treeFollowTimer);
|
|
7321
7752
|
treeFollowTimer = setTimeout(async () => {
|
|
7322
7753
|
const busy = () => !tree.open || finder.q?.value.trim() || finder.clip?.querySelector('.tree-ask');
|
|
@@ -7327,7 +7758,7 @@ function treeFollowWrites() {
|
|
|
7327
7758
|
const top = els.treeBody.scrollTop;
|
|
7328
7759
|
els.treeBody.replaceChildren(root);
|
|
7329
7760
|
els.treeBody.scrollTop = top;
|
|
7330
|
-
}, 350);
|
|
7761
|
+
}, now ? 0 : 350);
|
|
7331
7762
|
}
|
|
7332
7763
|
|
|
7333
7764
|
// The panel again, as it was: the folders that were open stay open, the one the result landed in
|
|
@@ -7348,7 +7779,7 @@ function finderRefresh(landed) {
|
|
|
7348
7779
|
async function finderRun(body, { say }) {
|
|
7349
7780
|
const guarded = [body.from, body.to].filter(Boolean).find((path) => finderGuarded(path));
|
|
7350
7781
|
if (guarded) {
|
|
7351
|
-
const typed = await finderAsk({ label: t('PROJECT
|
|
7782
|
+
const typed = await finderAsk({ label: t('CONFIRM WITH THE PROJECT NAME'), hint: t('{path} is a location MADRE protects. Type the project name to confirm.', { path: guarded }) });
|
|
7352
7783
|
if (!typed) return false;
|
|
7353
7784
|
body.designation = typed;
|
|
7354
7785
|
}
|
|
@@ -7414,7 +7845,7 @@ function finderMenu(event, path, entry) {
|
|
|
7414
7845
|
if (finder.held && entry.kind === 'file') add(t('Paste beside it'), () => void finderPaste(here));
|
|
7415
7846
|
add(t('New file'), () => void finderCreate(here, 'new-file'));
|
|
7416
7847
|
add(t('New folder'), () => void finderCreate(here, 'new-folder'));
|
|
7417
|
-
if (!entry.root && finderGuarded(path)) menu.append(el('div', 'note', t('
|
|
7848
|
+
if (!entry.root && finderGuarded(path)) menu.append(el('div', 'note', t('PROTECTED LOCATION · CONFIRM WITH THE PROJECT NAME')));
|
|
7418
7849
|
menu.hidden = false;
|
|
7419
7850
|
// Opened where the hand is, and kept inside the panel: a menu that spills across the room is a
|
|
7420
7851
|
// menu the human has to go looking for.
|
|
@@ -7473,6 +7904,13 @@ function treeNode(path, entry) {
|
|
|
7473
7904
|
if (entry.kind === 'dir') {
|
|
7474
7905
|
const caret = el('span', 'caret', '▸');
|
|
7475
7906
|
button.append(caret, el('span', 'name', entry.name));
|
|
7907
|
+
// Something under this folder changed: the dot of whoever touched it last, the way down.
|
|
7908
|
+
const under = touchedUnder(path);
|
|
7909
|
+
if (under) {
|
|
7910
|
+
const dot = paint(el('span', 'touched trail'), under.agent);
|
|
7911
|
+
dot.title = t('@{agent} changed {file} here · {when}', { agent: under.agent, file: under.path.slice(path.length + 1), when: agoWords(new Date(under.at).toISOString()) });
|
|
7912
|
+
button.append(dot);
|
|
7913
|
+
}
|
|
7476
7914
|
const children = el('ul');
|
|
7477
7915
|
children.hidden = true;
|
|
7478
7916
|
let opened = false;
|
|
@@ -7494,8 +7932,8 @@ function treeNode(path, entry) {
|
|
|
7494
7932
|
const by = touched.get(path);
|
|
7495
7933
|
if (by) {
|
|
7496
7934
|
// Its colour is the agent's own, so the tree reads the same way the thread does.
|
|
7497
|
-
const dot = paint(el('span', 'touched'), by);
|
|
7498
|
-
dot.title = t('{agent}
|
|
7935
|
+
const dot = paint(el('span', 'touched'), by.agent);
|
|
7936
|
+
dot.title = t('Last changed by @{agent} · {when}', { agent: by.agent, when: agoWords(new Date(by.at).toISOString()) });
|
|
7499
7937
|
button.append(dot);
|
|
7500
7938
|
}
|
|
7501
7939
|
button.addEventListener('click', () => { void openViewer({ root: 'project', path, label: path }); });
|
|
@@ -7657,7 +8095,9 @@ let givingWay = false;
|
|
|
7657
8095
|
function giveWay() {
|
|
7658
8096
|
if (givingWay) return false;
|
|
7659
8097
|
const members = besideNow();
|
|
7660
|
-
|
|
8098
|
+
// One side alone gives way too (2026-10-14): on a tablet or a phone a panel beside the conversation
|
|
8099
|
+
// left it a strip and pushed the bar under its own keys.
|
|
8100
|
+
if (!members.length || members.reduce((sum, one) => sum + one.floor, COLUMN_MIN) <= window.innerWidth) return false;
|
|
7661
8101
|
const rank = (one) => besideOrder.indexOf(one.key);
|
|
7662
8102
|
const oldest = [...members].sort((a, b) => rank(a) - rank(b))[0];
|
|
7663
8103
|
givingWay = true;
|
|
@@ -7972,10 +8412,12 @@ const WINDOW_LAYER = 30;
|
|
|
7972
8412
|
const windowPlacers = new WeakMap();
|
|
7973
8413
|
const windowStack = [];
|
|
7974
8414
|
const WINDOW_SELECTOR = '.tree.window, .surface.window, .conv-window, .browser-dock.window';
|
|
8415
|
+
function atTheEdge(node) { return Boolean(node?.classList?.contains('aside')); }
|
|
7975
8416
|
function bringWindowForward(node) {
|
|
7976
8417
|
if (!node) return;
|
|
7977
|
-
// A window asked for is
|
|
7978
|
-
|
|
8418
|
+
// A window asked for is that window wanted back: it alone returns from the edge; the others it
|
|
8419
|
+
// left there stay there until they are asked for too.
|
|
8420
|
+
if (windowsAside) setWindowsAside(false, { only: node });
|
|
7979
8421
|
for (let i = windowStack.length - 1; i >= 0; i -= 1) {
|
|
7980
8422
|
const one = windowStack[i];
|
|
7981
8423
|
if (one === node || !one.isConnected || !one.matches(WINDOW_SELECTOR)) { windowStack.splice(i, 1); if (one !== node) { one.style.removeProperty('z-index'); one.classList.remove('front'); } }
|
|
@@ -8048,7 +8490,8 @@ function dropWindow(node) {
|
|
|
8048
8490
|
if (at >= 0) windowStack.splice(at, 1);
|
|
8049
8491
|
node?.style.removeProperty('z-index');
|
|
8050
8492
|
node?.style.removeProperty('--depth');
|
|
8051
|
-
node?.classList.remove('front', 'behind');
|
|
8493
|
+
node?.classList.remove('front', 'behind', 'aside', 'aside-moving');
|
|
8494
|
+
if (node?.dataset) delete node.dataset.aside;
|
|
8052
8495
|
// The one now on top is the one in front.
|
|
8053
8496
|
windowStack.at(-1)?.classList.add('front');
|
|
8054
8497
|
paintDepth();
|
|
@@ -8058,7 +8501,7 @@ function dropWindow(node) {
|
|
|
8058
8501
|
// windows come back over it — it goes back to its layer. Pressing it once never left it clear for
|
|
8059
8502
|
// good: that was a desktop that stayed sharp under windows that had come back.
|
|
8060
8503
|
const writing = () => Boolean(document.activeElement?.closest?.('#composer'));
|
|
8061
|
-
function deskInUse() { focusDesk(writing() || windowsAside); }
|
|
8504
|
+
function deskInUse() { focusDesk(writing() || (windowsAside && !windowsUp().length)); }
|
|
8062
8505
|
if (!SPLIT_CHAT) {
|
|
8063
8506
|
document.addEventListener?.('focusin', deskInUse);
|
|
8064
8507
|
// Focus leaves the box before the next element has it: read once it has landed.
|
|
@@ -8075,9 +8518,9 @@ document.addEventListener?.('pointerdown', (event) => {
|
|
|
8075
8518
|
|
|
8076
8519
|
// A press in the conversation's own box says «this, now». Every window lying over the conversation
|
|
8077
8520
|
// slides to its nearer edge, one just after another, the way a desktop is shown, and leaves a strip
|
|
8078
|
-
// of itself in view so it is plain where it went.
|
|
8079
|
-
//
|
|
8080
|
-
//
|
|
8521
|
+
// of itself in view so it is plain where it went. Pressing a strip, opening a window or the keyboard
|
|
8522
|
+
// reaching into one brings back that window only — opening the Finder from the bar is not asking for
|
|
8523
|
+
// Settings too; the desktop pressed again brings them all back. The menus the box opens (mode, model, effort, an agent's card) are the box's. Only a translate moves: the
|
|
8081
8524
|
// windows are not laid out again on the way, and nothing about them is lost.
|
|
8082
8525
|
const ASIDE_PEEK = 30;
|
|
8083
8526
|
let windowsAside = false;
|
|
@@ -8088,13 +8531,16 @@ function overTheRoom(node) {
|
|
|
8088
8531
|
if (!room?.width || !box.width) return false;
|
|
8089
8532
|
return box.right > room.left + 8 && box.left < room.right - 8;
|
|
8090
8533
|
}
|
|
8091
|
-
function
|
|
8092
|
-
|
|
8093
|
-
|
|
8534
|
+
function windowsUp() {
|
|
8535
|
+
return [...document.querySelectorAll(WINDOW_SELECTOR)].filter((node) => !node.hidden && node.getClientRects().length && !node.classList.contains('aside') && overTheRoom(node));
|
|
8536
|
+
}
|
|
8537
|
+
function setWindowsAside(on, { always = false, only = null } = {}) {
|
|
8538
|
+
if (!on && !windowsAside) return;
|
|
8094
8539
|
if (on) {
|
|
8095
|
-
const moving =
|
|
8540
|
+
const moving = windowsUp();
|
|
8096
8541
|
// Shown on purpose, the desktop clears even when no window lay over the conversation.
|
|
8097
|
-
if (!moving.length && !always) return;
|
|
8542
|
+
if (!moving.length && (!always || windowsAside)) return;
|
|
8543
|
+
clearTimeout(asideSettle);
|
|
8098
8544
|
windowsAside = true;
|
|
8099
8545
|
moving.forEach((node, index) => {
|
|
8100
8546
|
const box = node.getBoundingClientRect();
|
|
@@ -8105,12 +8551,16 @@ function setWindowsAside(on, { always = false } = {}) {
|
|
|
8105
8551
|
node.classList.add('aside-moving', 'aside');
|
|
8106
8552
|
});
|
|
8107
8553
|
} else {
|
|
8108
|
-
|
|
8109
|
-
for
|
|
8554
|
+
const back = only ? [only].filter((node) => node.classList.contains('aside')) : [...document.querySelectorAll('.aside')];
|
|
8555
|
+
// Asked for while it was not at the edge: the others stay where they are.
|
|
8556
|
+
if (only && !back.length) return;
|
|
8557
|
+
clearTimeout(asideSettle);
|
|
8558
|
+
for (const node of back) {
|
|
8110
8559
|
node.style.setProperty('--aside-delay', '0ms');
|
|
8111
8560
|
node.classList.remove('aside');
|
|
8112
8561
|
delete node.dataset.aside;
|
|
8113
8562
|
}
|
|
8563
|
+
windowsAside = Boolean(document.querySelector('.aside'));
|
|
8114
8564
|
}
|
|
8115
8565
|
document.documentElement.classList.toggle('windows-aside', windowsAside);
|
|
8116
8566
|
deskInUse();
|
|
@@ -8141,14 +8591,10 @@ function deskPress(event) {
|
|
|
8141
8591
|
}
|
|
8142
8592
|
if (!SPLIT_CHAT) {
|
|
8143
8593
|
document.querySelector('#composer .field')?.addEventListener('pointerdown', () => setWindowsAside(true));
|
|
8144
|
-
|
|
8145
|
-
|
|
8146
|
-
|
|
8147
|
-
|
|
8148
|
-
if (!windowsAside || target?.closest?.('#composer, .model-menu, .agent-pop, .slash-menu')) return;
|
|
8149
|
-
if (target?.closest?.('.shell') && !target.closest(WINDOW_SELECTOR) && !target.closest(DESK_ACTIVE)) return;
|
|
8150
|
-
setWindowsAside(false);
|
|
8151
|
-
}, true);
|
|
8594
|
+
// A press on a strip brings that window back (bringWindowForward, above); a press that does
|
|
8595
|
+
// something else — the bar, a button — leaves the windows where they are.
|
|
8596
|
+
// The desktop pressed: windows still over the conversation go aside; with none left, all come back.
|
|
8597
|
+
const deskToggle = () => setWindowsAside(windowsUp().length > 0 || !windowsAside, { always: true });
|
|
8152
8598
|
// On the bare ground there is nothing to select, so the desktop answers on the press itself, not
|
|
8153
8599
|
// on the release (that wait read as a delay). On words — a bubble, a line of the room — it still
|
|
8154
8600
|
// decides on release, where a drag that selected text is told apart from a click.
|
|
@@ -8158,13 +8604,13 @@ if (!SPLIT_CHAT) {
|
|
|
8158
8604
|
deskAnswered = false;
|
|
8159
8605
|
if (event.button !== 0 || event.target?.closest?.(DESK_WORDS) || !deskPress(event)) return;
|
|
8160
8606
|
deskAnswered = true;
|
|
8161
|
-
|
|
8607
|
+
deskToggle();
|
|
8162
8608
|
});
|
|
8163
8609
|
document.addEventListener?.('click', (event) => {
|
|
8164
8610
|
if (deskAnswered) { deskAnswered = false; return; }
|
|
8165
|
-
if (deskPress(event))
|
|
8611
|
+
if (deskPress(event)) deskToggle();
|
|
8166
8612
|
});
|
|
8167
|
-
document.addEventListener?.('focusin', (event) => {
|
|
8613
|
+
document.addEventListener?.('focusin', (event) => { const win = windowsAside && event.target?.closest?.('.aside'); if (win) setWindowsAside(false, { only: win }); });
|
|
8168
8614
|
window.addEventListener?.('resize', () => { if (windowsAside) setWindowsAside(false); });
|
|
8169
8615
|
}
|
|
8170
8616
|
|
|
@@ -8633,9 +9079,17 @@ function setTree(open) {
|
|
|
8633
9079
|
const root = el('ul');
|
|
8634
9080
|
els.treeBody.append(root);
|
|
8635
9081
|
void loadTreeLevel('.', root);
|
|
8636
|
-
}
|
|
9082
|
+
} else if (open && tree.stale) treeFollowWrites();
|
|
9083
|
+
if (open) tree.stale = false;
|
|
8637
9084
|
}
|
|
8638
|
-
|
|
9085
|
+
// By hand, for anything written from outside MADRE: an editor, a terminal, another app.
|
|
9086
|
+
document.querySelector('#tree-refresh')?.addEventListener('click', (event) => {
|
|
9087
|
+
const button = event.currentTarget;
|
|
9088
|
+
button.classList.remove('spin'); void button.offsetWidth; button.classList.add('spin');
|
|
9089
|
+
if (finder.q?.value.trim()) { finder.q.dispatchEvent(new Event('input')); return; }
|
|
9090
|
+
treeFollowWrites({ now: true });
|
|
9091
|
+
});
|
|
9092
|
+
els.treeButton?.addEventListener('click', () => setTree(!tree.open || atTheEdge(els.tree)));
|
|
8639
9093
|
// A new file from the Finder: its path asked in the room's alert, made empty, opened in the viewer.
|
|
8640
9094
|
document.querySelector('#tree-new')?.addEventListener('click', async () => {
|
|
8641
9095
|
const path = await roomAsk({
|
|
@@ -9496,6 +9950,10 @@ function measureDock() {
|
|
|
9496
9950
|
// depended on where the box stood at that instant, and Safari once left 906px of nothing.
|
|
9497
9951
|
const lift = pageScrolls() && typeof getComputedStyle === 'function' ? parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--kb')) || 0 : 0;
|
|
9498
9952
|
const height = Math.max(0, Math.ceil(pageScrolls() ? box.height + lift : shell.getBoundingClientRect().bottom - box.top));
|
|
9953
|
+
// Where the type box itself begins (the strip above it — open questions, offers — is not it):
|
|
9954
|
+
// the thread fades out in the last few pixels before that edge (styles.css, --type-gap).
|
|
9955
|
+
const field = els.composer.querySelector?.('.field')?.getBoundingClientRect?.();
|
|
9956
|
+
if (field && !pageScrolls()) shell.style.setProperty('--type-gap', `${Math.max(0, Math.ceil(shell.getBoundingClientRect().bottom - field.top))}px`);
|
|
9499
9957
|
if (height === measureDock.last) return;
|
|
9500
9958
|
const stay = atBottom();
|
|
9501
9959
|
measureDock.last = height;
|
|
@@ -9696,7 +10154,7 @@ function stepsDraw() {
|
|
|
9696
10154
|
stepsSave();
|
|
9697
10155
|
stepsClose();
|
|
9698
10156
|
stepsPaint();
|
|
9699
|
-
toast(t('
|
|
10157
|
+
toast(t('You can reopen the list with the ? button in Diagnostics.'));
|
|
9700
10158
|
});
|
|
9701
10159
|
foot.append(el('span', null, t('Each step ticks itself when you do it.')), hide);
|
|
9702
10160
|
card.replaceChildren(head, list, foot);
|
|
@@ -9741,14 +10199,12 @@ if (SPLIT_CHAT && !Array.isArray(initial.events)) {
|
|
|
9741
10199
|
els.column.replaceChildren(el('div', 'empty', t('This conversation is no longer here.')));
|
|
9742
10200
|
await new Promise(() => {});
|
|
9743
10201
|
}
|
|
9744
|
-
// The corner of the room: which MADRE this is, and whose it is, readable on any wallpaper
|
|
9745
|
-
//
|
|
9746
|
-
|
|
9747
|
-
|
|
9748
|
-
|
|
9749
|
-
|
|
9750
|
-
}
|
|
9751
|
-
adoptLanguage(initial.language);
|
|
10202
|
+
// The corner of the room: which MADRE this is, and whose it is, readable on any wallpaper, and a
|
|
10203
|
+
// link to its author (public/attribution.js: NOTICE's attribution, the same on Home).
|
|
10204
|
+
if (initial.version) drawAttribution(document.querySelector('#colophon'), initial.version);
|
|
10205
|
+
void adoptLanguage(initial.language, initial.languageChosen);
|
|
10206
|
+
// Whether the copilot is on for this project, so the robot and the mode say so from the start.
|
|
10207
|
+
void loadCopilot().then(() => showNewHints());
|
|
9752
10208
|
// Behind the bridge this page is on a phone (docs/MOBILE.md): it says so, offers the way back to
|
|
9753
10209
|
// the list of rooms, and leaves the Mac's keys to the Mac.
|
|
9754
10210
|
state.device = initial.device ?? null;
|
|
@@ -9876,7 +10332,12 @@ function boot() {
|
|
|
9876
10332
|
// this call had slipped inside the branch that ends a live installation, and until one happened
|
|
9877
10333
|
// `/git` and every module command answered «unknown» in a room that had them enabled all along.
|
|
9878
10334
|
void refreshCommands();
|
|
9879
|
-
|
|
10335
|
+
// How long the record takes to be drawn, readable in the browser's Performance panel
|
|
10336
|
+
// (performance.getEntriesByName('madre:history')).
|
|
10337
|
+
performance.mark?.('madre:history-start');
|
|
10338
|
+
drawRecord(initial.events);
|
|
10339
|
+
performance.mark?.('madre:history-end');
|
|
10340
|
+
try { performance.measure?.('madre:history', 'madre:history-start', 'madre:history-end'); } catch { /* no timeline */ }
|
|
9880
10341
|
setLoader(arrival, 0.9);
|
|
9881
10342
|
scrollToEnd();
|
|
9882
10343
|
matchMedia('(prefers-color-scheme: light)').addEventListener('change', () => { applyTheme(themeMode); renderAgents(); renderPicker(); });
|
|
@@ -9986,6 +10447,7 @@ function connectStream() {
|
|
|
9986
10447
|
function forgetConversation() {
|
|
9987
10448
|
state.seen.clear();
|
|
9988
10449
|
state.userMessages.clear();
|
|
10450
|
+
state.runsReturned.clear();
|
|
9989
10451
|
state.ratings.clear();
|
|
9990
10452
|
state.ratingNodes.clear();
|
|
9991
10453
|
state.running.clear();
|
|
@@ -10022,7 +10484,7 @@ async function paintConversation(payload) {
|
|
|
10022
10484
|
const here = chats.list.find((chat) => chat.id === chats.active);
|
|
10023
10485
|
const mark = document.querySelector('.project.chat-here');
|
|
10024
10486
|
if (mark) mark.textContent = here && chats.list.length > 1 ? here.title : '';
|
|
10025
|
-
|
|
10487
|
+
drawRecord(payload.events ?? []);
|
|
10026
10488
|
renderAgents();
|
|
10027
10489
|
renderPicker();
|
|
10028
10490
|
renderOnboarding();
|
|
@@ -10283,7 +10745,7 @@ function paintIntentTry(box, said) {
|
|
|
10283
10745
|
const level = effortLabel(said.effort);
|
|
10284
10746
|
box.replaceChildren();
|
|
10285
10747
|
box.classList.remove('up');
|
|
10286
|
-
box.append(el('span', 'free-what', `${t('Looks like {what}', { what: t(INTENT_NAMES[said.intent] ?? 'an explanation') })} · ${t('{level} might be enough · try it on this one and
|
|
10748
|
+
box.append(el('span', 'free-what', `${t('Looks like {what}', { what: t(INTENT_NAMES[said.intent] ?? 'an explanation') })} · ${t('{level} might be enough · try it on this one and MADRE will notice', { level: level.toUpperCase() })}`));
|
|
10287
10749
|
box.append(el('span', 'intent-basis trial', t('Trial')));
|
|
10288
10750
|
const later = el('button', 'free-later', t('Not now'));
|
|
10289
10751
|
later.type = 'button';
|
|
@@ -10310,8 +10772,8 @@ function paintIntentTried(box, once) {
|
|
|
10310
10772
|
box.replaceChildren();
|
|
10311
10773
|
box.classList.remove('up');
|
|
10312
10774
|
box.append(el('span', 'free-what', once.from
|
|
10313
|
-
? t('This one goes at {level} · if it falls short,
|
|
10314
|
-
: t('This one goes at {level} · if it falls short,
|
|
10775
|
+
? t('This one goes at {level} · if it falls short, MADRE will notice and go back to {from}', { level, from: effortLabel(once.from).toUpperCase() })
|
|
10776
|
+
: t('This one goes at {level} · if it falls short, MADRE will notice and go back to the agent\'s default level', { level })));
|
|
10315
10777
|
box.append(el('span', 'intent-basis trial', t('Trial')));
|
|
10316
10778
|
const undo = el('button', 'free-later', t('Undo'));
|
|
10317
10779
|
undo.type = 'button';
|
|
@@ -10778,16 +11240,18 @@ fetch('/api/mother').then((response) => response.json()).then((status) => { if (
|
|
|
10778
11240
|
|
|
10779
11241
|
function updateCrewLabel() {
|
|
10780
11242
|
const order = state.ash && state.ashInstalled;
|
|
10781
|
-
|
|
10782
|
-
|
|
11243
|
+
// The copilot reads as a link of the chain: HUMAN › COPILOT › TOKEN SAVER › @CODEX ›.
|
|
11244
|
+
const piloting = copilotSends(els.target?.value);
|
|
11245
|
+
const armed = state.mode >= 3 && !piloting;
|
|
11246
|
+
els.crewLabel.textContent = piloting ? `${t('HUMAN ›')} ${t('COPILOT ›')}${order ? ` ${t('TOKEN SAVER ›')}` : ''}` : state.intruder && state.mode < 3 ? t('INTRUDER ›') : armed ? `${t('DIAGNOSTICS')} · ${MODES[state.mode].label} @${(state.modeArmedFor ?? els.target.value ?? '').toUpperCase()} ›`
|
|
10783
11247
|
: state.mode === 0 ? t('HUMAN · INCOGNITO ›')
|
|
10784
11248
|
: state.moduleCommand ? (state.create ? t('DIAGNOSTICS · MODULE · CREATE ›') : t('DIAGNOSTICS · MODULE · NEEDS #2 ›'))
|
|
10785
11249
|
: order ? `${t('HUMAN ›')} ${state.create ? t('TOKEN SAVER · CREATE ›') : t('TOKEN SAVER ›')}`
|
|
10786
11250
|
: state.create ? t('HUMAN · CREATE ›')
|
|
10787
|
-
: state.expendable ? t('
|
|
11251
|
+
: state.expendable ? t('EXPENDABLE ›') : t('HUMAN ›');
|
|
10788
11252
|
// Who the message goes to closes the line, in that agent's colour: HUMAN › TOKEN SAVER › @CODEX ›.
|
|
10789
11253
|
const to = els.target?.value;
|
|
10790
|
-
if (to && !armed && !state.intruder && typeof els.crewLabel.append === 'function') {
|
|
11254
|
+
if (to && !armed && (piloting || !state.intruder) && typeof els.crewLabel.append === 'function') {
|
|
10791
11255
|
const who = paint(el('span', 'crew-to'), to);
|
|
10792
11256
|
who.textContent = ` @${to.toUpperCase()} ›`;
|
|
10793
11257
|
els.crewLabel.append(who);
|
|
@@ -10795,7 +11259,7 @@ function updateCrewLabel() {
|
|
|
10795
11259
|
}
|
|
10796
11260
|
function updatePlaceholder() {
|
|
10797
11261
|
const local = Boolean(state.agents.get(els.target.value)?.local);
|
|
10798
|
-
els.input.placeholder = state.mode === 4 ? PLACEHOLDERS.airlock : state.mode === 3 ? PLACEHOLDERS.control : state.mode === 0 ? PLACEHOLDERS.ghost : local ? PLACEHOLDERS.memory : state.create ? PLACEHOLDERS.create : (state.ash && state.ashInstalled) ? PLACEHOLDERS.ash : state.expendable ? PLACEHOLDERS.expendable : PLACEHOLDERS.plain;
|
|
11262
|
+
els.input.placeholder = state.mode === 4 ? PLACEHOLDERS.airlock : copilotSends(els.target.value) ? PLACEHOLDERS.copilot : state.mode === 3 ? PLACEHOLDERS.control : state.mode === 0 ? PLACEHOLDERS.ghost : local ? PLACEHOLDERS.memory : state.create ? PLACEHOLDERS.create : (state.ash && state.ashInstalled) ? PLACEHOLDERS.ash : state.expendable ? PLACEHOLDERS.expendable : PLACEHOLDERS.plain;
|
|
10799
11263
|
}
|
|
10800
11264
|
function setAsh(on, { wink = false } = {}) {
|
|
10801
11265
|
state.ash = on;
|
|
@@ -10938,7 +11402,10 @@ els.composer.addEventListener('submit', async (event) => {
|
|
|
10938
11402
|
toast(t('#3 EDIT and #4 FULL ACCESS are opened from the Mac.'));
|
|
10939
11403
|
return;
|
|
10940
11404
|
}
|
|
10941
|
-
if (wanted
|
|
11405
|
+
if (wanted === 3 && copilotOn()) {
|
|
11406
|
+
// Under the copilot #3 needs no key: written or chosen, it is the copilot's.
|
|
11407
|
+
setMode(3);
|
|
11408
|
+
} else if (wanted >= 3) {
|
|
10942
11409
|
// The override names the agent that will act: the one the message is addressed to, which is
|
|
10943
11410
|
// its leading @mention when it has one, as on sending. It used to name the chip's, so
|
|
10944
11411
|
// «@claude #3 …» with the chip on @gemini armed CONTROL for Gemini, and «#4 @codex …» with
|
|
@@ -10959,8 +11426,7 @@ els.composer.addEventListener('submit', async (event) => {
|
|
|
10959
11426
|
cancelled: () => { els.input.value = text; autosize(); els.input.focus(); },
|
|
10960
11427
|
});
|
|
10961
11428
|
return;
|
|
10962
|
-
}
|
|
10963
|
-
setMode(wanted);
|
|
11429
|
+
} else setMode(wanted);
|
|
10964
11430
|
}
|
|
10965
11431
|
if (quoting && !text.startsWith('/')) outgoing = `${quoteHead(quoting)}${outgoing}`;
|
|
10966
11432
|
if (text.startsWith('/')) {
|
|
@@ -10984,7 +11450,8 @@ els.composer.addEventListener('submit', async (event) => {
|
|
|
10984
11450
|
const addressed = mention && state.agents.has(mention) ? mention : target;
|
|
10985
11451
|
// CONTROL and AIRLOCK are armed for one agent. A message that ends up addressed to another one
|
|
10986
11452
|
// is asked for again, for that one; the keys typed for @gemini never open the project to @claude.
|
|
10987
|
-
|
|
11453
|
+
const piloted = copilotSends(addressed);
|
|
11454
|
+
if (state.mode >= 3 && !piloted && state.modeArmedFor && addressed && addressed !== state.modeArmedFor) {
|
|
10988
11455
|
const wanted = state.mode;
|
|
10989
11456
|
const scopes = state.capabilities[addressed]?.scopes;
|
|
10990
11457
|
if (els.target.value !== addressed) { els.target.value = addressed; renderPicker(); }
|
|
@@ -10999,7 +11466,7 @@ els.composer.addEventListener('submit', async (event) => {
|
|
|
10999
11466
|
const response = await fetch('/api/messages', {
|
|
11000
11467
|
method: 'POST',
|
|
11001
11468
|
headers: { 'content-type': 'application/json' },
|
|
11002
|
-
body: JSON.stringify({ text: outgoing, target: addressed || null, model: state.chosenModel[addressed] ?? null, effort: once?.effort ?? state.chosenEffort[addressed] ?? null, attachments: ready.map((item) => item.id), create: state.create, mode: state.mode, ash: state.ashInstalled && state.ash, draft: draft || undefined, key: state.mode >= 3 ? overridePass(state.mode, addressed) : undefined }),
|
|
11469
|
+
body: JSON.stringify({ text: outgoing, target: addressed || null, model: state.chosenModel[addressed] ?? null, effort: once?.effort ?? state.chosenEffort[addressed] ?? null, attachments: ready.map((item) => item.id), create: state.create, mode: state.mode, ash: state.ashInstalled && state.ash, draft: draft || undefined, copilot: piloted || undefined, key: state.mode >= 3 && !piloted ? overridePass(state.mode, addressed) : undefined }),
|
|
11003
11470
|
});
|
|
11004
11471
|
const sentMode = state.mode;
|
|
11005
11472
|
if (sentMode >= 3 && !overrideGraceLeft(sentMode, addressed)) overridePasses.delete(`${sentMode}:${addressed}`);
|
|
@@ -11007,6 +11474,8 @@ els.composer.addEventListener('submit', async (event) => {
|
|
|
11007
11474
|
const result = await response.json().catch(() => ({ error: t('Request failed ({status}).', { status: response.status }) }));
|
|
11008
11475
|
// The pass ran out (the project restarted, or the permission it held ended): the key is
|
|
11009
11476
|
// asked again, and the message goes once it is in.
|
|
11477
|
+
// The copilot ended while this was being written: said, and the next #3 asks for its key.
|
|
11478
|
+
if (result.copilotOff) { applyCopilot({ active: false }); toast(t('The copilot ended. Send again and confirm #3, or turn it on again.')); return; }
|
|
11010
11479
|
if (result.needsKey && sentMode >= 3 && addressed) {
|
|
11011
11480
|
closeOverrideGrace(sentMode, addressed);
|
|
11012
11481
|
openOverride(addressed, { mode: sentMode, then: () => els.composer.requestSubmit() });
|
|
@@ -11380,7 +11849,7 @@ function bootMother() {
|
|
|
11380
11849
|
mother.boot.textContent = [
|
|
11381
11850
|
t('{n} agents · {ready} ready', { n: state.agents.size, ready: online }),
|
|
11382
11851
|
`/${els.project.textContent}`,
|
|
11383
|
-
open === 1 ? t('1
|
|
11852
|
+
open === 1 ? t('1 problem in this project') : open ? t('{n} problems in this project', { n: open }) : t('nothing open'),
|
|
11384
11853
|
].join(' · ');
|
|
11385
11854
|
}
|
|
11386
11855
|
|
|
@@ -11444,7 +11913,6 @@ function openMother() {
|
|
|
11444
11913
|
bootMother();
|
|
11445
11914
|
renderMother();
|
|
11446
11915
|
}
|
|
11447
|
-
if (windowsAside) setWindowsAside(false);
|
|
11448
11916
|
bringWindowForward(node);
|
|
11449
11917
|
mother.button.setAttribute('aria-pressed', 'true');
|
|
11450
11918
|
// On a phone the keyboard would cover what MU/TH/UR says; it comes up when the field is touched.
|
|
@@ -11515,27 +11983,83 @@ function runOutput(text, run) {
|
|
|
11515
11983
|
function renderCommandCard(event) {
|
|
11516
11984
|
const { name, title, text, ok, args = [], run = null, bishop = null } = event.payload;
|
|
11517
11985
|
if (name === 'bishop' && !bishop && ok !== false && args.length) return renderBishopAsk(event);
|
|
11986
|
+
if (!bishop) return renderTerminalCard(event);
|
|
11518
11987
|
const node = el('div', `command-card${ok === false ? ' failed' : ''}`);
|
|
11519
11988
|
const head = el('div', 'head');
|
|
11520
11989
|
// The answer to an order of BISHOP: who answered, where, and which order; the card starts no turn.
|
|
11521
|
-
const said =
|
|
11522
|
-
head.append(el('b', null,
|
|
11523
|
-
|
|
11524
|
-
|
|
11525
|
-
|
|
11526
|
-
|
|
11527
|
-
|
|
11528
|
-
|
|
11529
|
-
|
|
11530
|
-
head.append(go);
|
|
11531
|
-
}
|
|
11990
|
+
const said = t('@{agent} in {room} · order {step} of {total} · plan {plan}', { agent: bishop.agent, room: bishop.name, step: bishop.step, total: bishop.total, plan: bishop.plan });
|
|
11991
|
+
head.append(el('b', null, t('Coordinator')), el('span', null, said), el('span', 'args', args.join(' ')));
|
|
11992
|
+
markBishopOrder(bishop, ok === false ? 'failed' : 'done');
|
|
11993
|
+
if (bishop.link) {
|
|
11994
|
+
const go = el('a', 'args', bishop.cut ? t('the rest, in the project') : t('see it in the project'));
|
|
11995
|
+
go.href = bishop.link;
|
|
11996
|
+
go.target = '_blank';
|
|
11997
|
+
go.rel = 'noopener';
|
|
11998
|
+
head.append(go);
|
|
11532
11999
|
}
|
|
11533
12000
|
const pre = el('pre');
|
|
11534
|
-
for (const line of String(
|
|
12001
|
+
for (const line of String(text ?? '').split('\n')) {
|
|
11535
12002
|
if (line.startsWith('## ')) pre.append(el('span', 'h', line.slice(3)), '\n');
|
|
11536
12003
|
else pre.append(`${line}\n`);
|
|
11537
12004
|
}
|
|
11538
12005
|
node.append(head, pre);
|
|
12006
|
+
state.lastSender = null;
|
|
12007
|
+
return node;
|
|
12008
|
+
}
|
|
12009
|
+
|
|
12010
|
+
// What a terminal said, folded (2026-10-14). A command's output used to open in full in the
|
|
12011
|
+
// conversation, and five of them were a wall between a question and its answer. Now the card is
|
|
12012
|
+
// one line — the command, how it ended, how long, who asked — with the output's last line beside
|
|
12013
|
+
// it, which is where a test run or a build says how it went; the rest opens on a click.
|
|
12014
|
+
const termOpen = new Set(); // event ids the human opened, so a redraw keeps them open
|
|
12015
|
+
function renderTerminalCard(event) {
|
|
12016
|
+
const { name, title, text, ok, args = [], run = null } = event.payload;
|
|
12017
|
+
const node = el('div', `command-card terminal${ok === false ? ' failed' : ''}`);
|
|
12018
|
+
const body = String(run?.line ? runOutput(text, run) : text ?? '').replace(/\s+$/, '');
|
|
12019
|
+
const lines = body ? body.split('\n') : [];
|
|
12020
|
+
const head = el('button', 'term-head');
|
|
12021
|
+
head.type = 'button';
|
|
12022
|
+
const id = String(event.id ?? event.sequence ?? Math.random());
|
|
12023
|
+
const outId = `term-${id.replace(/[^\w-]/g, '')}`;
|
|
12024
|
+
head.setAttribute('aria-controls', outId);
|
|
12025
|
+
head.append(el('b', 'term-tag', `/${name}`));
|
|
12026
|
+
if (run?.line) {
|
|
12027
|
+
head.title = runTitle(run);
|
|
12028
|
+
head.append(el('code', 'term-line', `$ ${run.line}`));
|
|
12029
|
+
const why = String(run.stoppedBy ?? '');
|
|
12030
|
+
const status = why ? 'stopped' : run.exitCode === null || run.exitCode === undefined ? 'stopped' : run.exitCode === 0 ? 'ok' : 'failed';
|
|
12031
|
+
const word = why ? t('halted') : run.exitCode === null || run.exitCode === undefined ? t('did not start') : run.exitCode === 0 ? t('went fine') : t('failed · code {code}', { code: run.exitCode });
|
|
12032
|
+
head.append(el('span', `term-status ${status}`, `${status === 'ok' ? '✓' : status === 'failed' ? '✕' : '■'} ${word} · ${((Number(run.durationMs) || 0) / 1000).toFixed(1)}s`));
|
|
12033
|
+
if (run.agent) head.append(paint(el('span', 'term-who', run.auto ? `@${run.agent} · ${t('copilot')}` : `@${run.agent}`), run.agent));
|
|
12034
|
+
} else {
|
|
12035
|
+
head.title = title ?? name;
|
|
12036
|
+
head.append(el('span', 'term-line', title ?? name));
|
|
12037
|
+
if (args.length) head.append(el('span', 'term-who', args.join(' ')));
|
|
12038
|
+
}
|
|
12039
|
+
head.append(el('span', 'term-count', t('{n} lines', { n: lines.length })), chevron());
|
|
12040
|
+
// The line a person looks for first: the last thing it printed.
|
|
12041
|
+
const last = [...lines].reverse().find((line) => line.trim() && !/^Files changed:|^Archivos cambiados:/.test(line));
|
|
12042
|
+
const peek = last ? el('div', 'term-peek', last.trim().slice(0, 240)) : null;
|
|
12043
|
+
const out = el('div', 'term-out');
|
|
12044
|
+
out.id = outId;
|
|
12045
|
+
const pre = el('pre');
|
|
12046
|
+
for (const line of lines) {
|
|
12047
|
+
if (line.startsWith('## ')) { pre.append(el('span', 'h', line.slice(3)), '\n'); continue; }
|
|
12048
|
+
// A few words carry the verdict in any test runner or compiler; they get its colour, nothing more.
|
|
12049
|
+
const tone = /^\s*(✖|✗|×|not ok\b|FAIL\b|ERR!|error\b|Error:|fatal:)/i.test(line) ? 'bad' : /^\s*(✔|✓|ok\b|PASS\b)/.test(line) ? 'good' : null;
|
|
12050
|
+
pre.append(tone ? el('span', `l-${tone}`, line) : line, '\n');
|
|
12051
|
+
}
|
|
12052
|
+
const clip = el('div', 'term-clip');
|
|
12053
|
+
clip.append(pre);
|
|
12054
|
+
out.append(clip);
|
|
12055
|
+
const open = (yes) => {
|
|
12056
|
+
node.classList.toggle('open', yes);
|
|
12057
|
+
head.setAttribute('aria-expanded', String(yes));
|
|
12058
|
+
if (yes) termOpen.add(id); else termOpen.delete(id);
|
|
12059
|
+
};
|
|
12060
|
+
open(termOpen.has(id));
|
|
12061
|
+
head.addEventListener('click', () => open(!node.classList.contains('open')));
|
|
12062
|
+
node.append(head, ...(peek ? [peek] : []), out);
|
|
11539
12063
|
if (run) {
|
|
11540
12064
|
markRun(run, ok === false ? 'failed' : 'ran');
|
|
11541
12065
|
// A command the human ran by hand was photographed first: the same UNDO as a CONTROL turn.
|
|
@@ -11568,7 +12092,7 @@ function renderEyecat(event) {
|
|
|
11568
12092
|
const node = el('div', 'command-card eyecat');
|
|
11569
12093
|
node.dataset.eyecat = finding.key;
|
|
11570
12094
|
const head = el('div', 'head');
|
|
11571
|
-
head.append(el('b', null, t('◉
|
|
12095
|
+
head.append(el('b', null, t('◉ MEMORY REVIEWER')), el('span', null, finding.kind === 'unsupported' ? t('a note its own sources do not support') : t('two memories that cannot both be true')));
|
|
11572
12096
|
if (finding.confidence !== null && finding.confidence !== undefined) head.append(el('span', 'args', t('{pct}% · judged by @{judge}', { pct: Math.round(finding.confidence * 100), judge: finding.judge })));
|
|
11573
12097
|
else head.append(el('span', 'args', t('judged by @{judge}', { judge: finding.judge })));
|
|
11574
12098
|
node.append(head);
|
|
@@ -11596,14 +12120,14 @@ function renderEyecat(event) {
|
|
|
11596
12120
|
}).then((res) => res.json()).catch(() => null);
|
|
11597
12121
|
if (!response || response.error) {
|
|
11598
12122
|
for (const button of actions.querySelectorAll('button')) button.disabled = false;
|
|
11599
|
-
toast(response?.error ?? t('
|
|
12123
|
+
toast(response?.error ?? t('Your answer did not reach the memory reviewer.'));
|
|
11600
12124
|
}
|
|
11601
12125
|
};
|
|
11602
12126
|
const confirm = el('button', 'primary', t('IT IS FALSE'));
|
|
11603
|
-
confirm.title = t('
|
|
12127
|
+
confirm.title = t('Marks it as a doubtful memory and stops using it in answers. Reversible from the Memory map.');
|
|
11604
12128
|
confirm.addEventListener('click', () => { void answer('confirm'); });
|
|
11605
12129
|
const dismiss = el('button', null, t('IT STANDS'));
|
|
11606
|
-
dismiss.title = t('The project was right. This
|
|
12130
|
+
dismiss.title = t('The project was right. This contradiction is never flagged again.');
|
|
11607
12131
|
dismiss.addEventListener('click', () => { void answer('dismiss'); });
|
|
11608
12132
|
actions.append(confirm, dismiss);
|
|
11609
12133
|
node.append(actions);
|
|
@@ -11618,7 +12142,7 @@ function settleEyecat(event) {
|
|
|
11618
12142
|
if (!card) return;
|
|
11619
12143
|
card.querySelector('.eyecat-actions')?.remove();
|
|
11620
12144
|
const settled = el('div', 'eyecat-settled');
|
|
11621
|
-
settled.textContent = verdict === 'aberration' ? t('FILED AS
|
|
12145
|
+
settled.textContent = verdict === 'aberration' ? t('FILED AS A DOUBTFUL MEMORY · THE MEMORY IT REFUTES IS NO LONGER USED IN ANSWERS') : t('THE PROJECT STANDS BY IT');
|
|
11622
12146
|
card.classList.add(verdict === 'aberration' ? 'filed' : 'stands');
|
|
11623
12147
|
card.append(settled);
|
|
11624
12148
|
}
|
|
@@ -11640,7 +12164,7 @@ function appendModuleOutput(event) {
|
|
|
11640
12164
|
async function switchModule(item) {
|
|
11641
12165
|
const response = await fetch(`/api/extensions/${item.id}/install`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ confirm: true }) });
|
|
11642
12166
|
const result = await response.json().catch(() => ({}));
|
|
11643
|
-
if (!response.ok) throw new Error(result.error ?? t('{name} could not be
|
|
12167
|
+
if (!response.ok) throw new Error(result.error ?? t('{name} could not be turned on or off.', { name: item.name }));
|
|
11644
12168
|
if (item.id === 'ripley') { state.ripley = Boolean(result.enabled); syncViewerMode(); }
|
|
11645
12169
|
if (item.id === 'ash') syncAshUI(Boolean(result.enabled));
|
|
11646
12170
|
if (result.capabilities) { state.capabilities = result.capabilities; renderCreateScopes(); }
|
|
@@ -11657,6 +12181,224 @@ function drawFilesTarget() {
|
|
|
11657
12181
|
target.replaceChildren();
|
|
11658
12182
|
drawFilesSettings(target);
|
|
11659
12183
|
}
|
|
12184
|
+
/* ---------- Settings › Connect MCP (2026-10-14) ---------- */
|
|
12185
|
+
//
|
|
12186
|
+
// The MCP servers of this project, and only here: added by hand or brought from the apps the person
|
|
12187
|
+
// already uses, each one started and checked by MADRE before an agent gets it (src/modules/
|
|
12188
|
+
// mcp-connect.mjs). A row says what it is, whether it answered and with how many tools, from which
|
|
12189
|
+
// mode its tools that act are handed, and the one thing to do when it needs something.
|
|
12190
|
+
const mcpUI = { servers: null, adding: null, found: null, picked: new Set(), open: new Set(), said: null, asked: false };
|
|
12191
|
+
function mcpStatus() {
|
|
12192
|
+
const servers = mcpUI.servers;
|
|
12193
|
+
// The board shows this card before anyone opens it: it reads its list once, by itself.
|
|
12194
|
+
if (!servers) { if (!mcpUI.asked) { mcpUI.asked = true; void loadMcp(); } return { line: t('Reading…') }; }
|
|
12195
|
+
if (!servers.length) return { line: t('No outside tools yet') };
|
|
12196
|
+
const ready = servers.filter((one) => one.enabled && !one.problem && one.tools?.length).length;
|
|
12197
|
+
const stuck = servers.filter((one) => one.enabled && (one.problem || one.needsAuth));
|
|
12198
|
+
return { line: t('{ready} of {total} tools ready', { ready, total: servers.length }), warn: stuck.length ? t(stuck.length === 1 ? '{names} needs you' : '{names} need you', { names: stuck.map((one) => one.label).join(', ') }) : null };
|
|
12199
|
+
}
|
|
12200
|
+
async function mcpCall(path, body = null, { method = body ? 'POST' : 'GET' } = {}) {
|
|
12201
|
+
const response = await fetch(path, { method, headers: body ? { 'content-type': 'application/json' } : {}, body: body ? JSON.stringify(body) : undefined });
|
|
12202
|
+
const result = await response.json().catch(() => ({}));
|
|
12203
|
+
if (!response.ok) throw new Error(result.error ?? `HTTP ${response.status}`);
|
|
12204
|
+
if (result.servers) mcpUI.servers = result.servers;
|
|
12205
|
+
return result;
|
|
12206
|
+
}
|
|
12207
|
+
async function loadMcp() {
|
|
12208
|
+
try { await mcpCall('/api/x/mcp/servers'); } catch (error) { mcpUI.servers = mcpUI.servers ?? []; mcpUI.said = error.message; }
|
|
12209
|
+
drawMcpTarget();
|
|
12210
|
+
syncSettingsCards();
|
|
12211
|
+
}
|
|
12212
|
+
function drawMcpTarget() {
|
|
12213
|
+
const host = state.settingsTargets.mcp;
|
|
12214
|
+
if (!host?.isConnected) return;
|
|
12215
|
+
host.replaceChildren();
|
|
12216
|
+
drawMcpSettings(host);
|
|
12217
|
+
}
|
|
12218
|
+
function drawMcpSettings(host) {
|
|
12219
|
+
const aside = el('div', 'sx-field');
|
|
12220
|
+
aside.append(
|
|
12221
|
+
sxButton(t('Import from your apps'), () => void openMcpImport()),
|
|
12222
|
+
sxButton(t('Add a tool'), () => { mcpUI.adding = mcpUI.adding ? null : { transport: 'stdio', auth: 'none', env: [] }; drawMcpTarget(); }, { tone: 'primary' }),
|
|
12223
|
+
);
|
|
12224
|
+
const list = sxBlock(host, t('Outside tools (MCP)'), t('Let the agents use other apps and services: a program on this computer or a service online. MADRE checks each one first; if one fails, the agents carry on without it.'), { aside, className: 'mcp-block' });
|
|
12225
|
+
if (mcpUI.servers === null) sxRow(list, t('Reading…'), null);
|
|
12226
|
+
else if (!mcpUI.servers.length && !mcpUI.adding) sxRow(list, t('No outside tools yet'), t('Add one by hand, or bring the ones you already use in Claude Code, Codex, Gemini or Cursor.'));
|
|
12227
|
+
for (const server of mcpUI.servers ?? []) list.rows.append(mcpServerRow(server));
|
|
12228
|
+
if (mcpUI.said) sxFoot(list, mcpUI.said);
|
|
12229
|
+
sxFoot(list, t('Tools that only look things up work from #1 CHAT ONLY. Tools that do things — send, write, publish — only from the mode you choose: #4 FULL ACCESS unless you lower it. Keys stay locked in MADRE, never in your files.'));
|
|
12230
|
+
if (mcpUI.adding) drawMcpForm(host);
|
|
12231
|
+
if (mcpUI.found) drawMcpImport(host);
|
|
12232
|
+
}
|
|
12233
|
+
function mcpServerRow(server) {
|
|
12234
|
+
const row = el('div', `sx-row stack mcp-row${server.enabled ? '' : ' off'}`);
|
|
12235
|
+
const top = el('div', 'mcp-top');
|
|
12236
|
+
const words = el('div', 'sx-words');
|
|
12237
|
+
const name = el('b', null, server.label);
|
|
12238
|
+
words.append(name, el('span', 'sx-hint mono mcp-where', server.transport === 'http' ? server.url : server.line ?? [server.command, ...(server.args ?? [])].join(' ')));
|
|
12239
|
+
const chips = el('div', 'sx-chips');
|
|
12240
|
+
chips.append(el('span', 'sx-chip', server.transport === 'http' ? t('online') : t('on this computer')));
|
|
12241
|
+
const needsSignIn = server.auth === 'oauth' && (!server.signedIn || server.needsAuth);
|
|
12242
|
+
if (needsSignIn) chips.append(el('span', 'sx-chip warn', t('sign-in needed')));
|
|
12243
|
+
else if (server.problem) chips.append(el('span', 'sx-chip off', t('not answering')));
|
|
12244
|
+
else if (server.tools?.length) chips.append(el('span', 'sx-chip on', t('{n} tools', { n: server.tools.length })));
|
|
12245
|
+
else chips.append(el('span', 'sx-chip', t('not checked yet')));
|
|
12246
|
+
words.append(chips);
|
|
12247
|
+
const toggle = sxSwitch(server.enabled, async (on, box) => {
|
|
12248
|
+
box.disabled = true;
|
|
12249
|
+
try { await mcpCall(`/api/x/mcp/servers/${server.id}/toggle`, { enabled: on }); } catch (error) { toast(`${t('Diagnostics › ')}${resay(error.message)}`); }
|
|
12250
|
+
drawMcpTarget(); syncSettingsCards();
|
|
12251
|
+
}, { label: t('Use {name} in this project', { name: server.label }) });
|
|
12252
|
+
top.append(words, toggle);
|
|
12253
|
+
row.append(top);
|
|
12254
|
+
if (server.problem && !needsSignIn) row.append(el('p', 'mcp-problem', server.problem));
|
|
12255
|
+
const acting = (server.tools ?? []).filter((tool) => !tool.readOnly).length;
|
|
12256
|
+
const tools = el('div', 'mcp-actions');
|
|
12257
|
+
if (needsSignIn) tools.append(sxButton(t('Sign in'), () => void mcpSignIn(server), { tone: 'primary' }));
|
|
12258
|
+
tools.append(sxButton(t('Check again'), async (event) => {
|
|
12259
|
+
const button = event.currentTarget;
|
|
12260
|
+
button.disabled = true; button.textContent = t('Checking…');
|
|
12261
|
+
try { const result = await mcpCall(`/api/x/mcp/servers/${server.id}/check`, {}); if (!result.check?.ok && !result.check?.needsAuth) toast(t('{name} did not answer: {problem}', { name: server.label, problem: result.check?.problem ?? '' })); } catch (error) { toast(`${t('Diagnostics › ')}${resay(error.message)}`); }
|
|
12262
|
+
drawMcpTarget(); syncSettingsCards();
|
|
12263
|
+
}));
|
|
12264
|
+
if (server.tools?.length) {
|
|
12265
|
+
const open = mcpUI.open.has(server.id);
|
|
12266
|
+
const look = sxButton(open ? t('Hide tools') : t('See tools'), () => { if (open) mcpUI.open.delete(server.id); else mcpUI.open.add(server.id); drawMcpTarget(); });
|
|
12267
|
+
look.setAttribute('aria-expanded', String(open));
|
|
12268
|
+
tools.append(look);
|
|
12269
|
+
}
|
|
12270
|
+
tools.append(sxButton(t('Remove'), async (event) => {
|
|
12271
|
+
const button = event.currentTarget;
|
|
12272
|
+
if (button.dataset.sure !== '1') { button.dataset.sure = '1'; button.textContent = t('Remove · sure?'); return; }
|
|
12273
|
+
try { await mcpCall(`/api/x/mcp/servers/${server.id}/remove`, {}); } catch (error) { toast(`${t('Diagnostics › ')}${resay(error.message)}`); }
|
|
12274
|
+
drawMcpTarget(); syncSettingsCards();
|
|
12275
|
+
}, { tone: 'danger' }));
|
|
12276
|
+
row.append(tools);
|
|
12277
|
+
// From which mode its tools that act travel. Without any, there is nothing to choose.
|
|
12278
|
+
if (acting) {
|
|
12279
|
+
const from = el('div', 'mcp-from');
|
|
12280
|
+
from.append(el('span', 'sx-hint', acting === 1 ? t('Its tool that does things works from') : t('Its {n} tools that do things work from', { n: acting })), sxSeg([2, 3, 4].map((n) => ({ value: String(n), text: `#${n}`, title: `${MODES[n].label} · ${MODES[n].hint}` })), String(server.actions ?? 4), async (value) => {
|
|
12281
|
+
try { await mcpCall(`/api/x/mcp/servers/${server.id}/actions`, { actions: Number(value) }); } catch (error) { toast(`${t('Diagnostics › ')}${resay(error.message)}`); }
|
|
12282
|
+
drawMcpTarget();
|
|
12283
|
+
}, { label: t('Can act from') }));
|
|
12284
|
+
row.append(from);
|
|
12285
|
+
}
|
|
12286
|
+
if (mcpUI.open.has(server.id)) {
|
|
12287
|
+
const list = el('ul', 'mcp-tools');
|
|
12288
|
+
for (const tool of server.tools) {
|
|
12289
|
+
const item = el('li', tool.readOnly ? 'reads' : 'acts');
|
|
12290
|
+
item.append(el('code', null, tool.name), el('span', 'mcp-tool-kind', tool.readOnly ? t('looks up') : t('does things')));
|
|
12291
|
+
if (tool.description) item.title = tool.description;
|
|
12292
|
+
list.append(item);
|
|
12293
|
+
}
|
|
12294
|
+
row.append(list);
|
|
12295
|
+
}
|
|
12296
|
+
return row;
|
|
12297
|
+
}
|
|
12298
|
+
// A server signed in to with OAuth: the address to open comes from the project, the page that
|
|
12299
|
+
// signs in is the server's own, and the way back lands on this room. When this tab is looked at
|
|
12300
|
+
// again, the list reads itself again.
|
|
12301
|
+
async function mcpSignIn(server) {
|
|
12302
|
+
try {
|
|
12303
|
+
const { open } = await mcpCall(`/api/x/mcp/servers/${server.id}/signin`, {});
|
|
12304
|
+
window.open(open, '_blank', 'noopener');
|
|
12305
|
+
window.addEventListener('focus', () => { void loadMcp(); }, { once: true });
|
|
12306
|
+
} catch (error) { toast(`${t('Diagnostics › ')}${resay(error.message)}`); }
|
|
12307
|
+
}
|
|
12308
|
+
function drawMcpForm(host) {
|
|
12309
|
+
const draft = mcpUI.adding;
|
|
12310
|
+
const block = sxBlock(host, t('Add a tool'), t('A name for it, and how MADRE reaches it. MADRE checks it as soon as you save.'), { className: 'mcp-block' });
|
|
12311
|
+
const input = (value, placeholder, { mono = false, type = 'text', change } = {}) => {
|
|
12312
|
+
const field = el('input', `sx-input${mono ? ' mono' : ''}`);
|
|
12313
|
+
field.type = type; field.value = value ?? ''; field.placeholder = placeholder; field.spellcheck = false; field.autocomplete = 'off';
|
|
12314
|
+
field.addEventListener('input', () => change(field.value));
|
|
12315
|
+
return field;
|
|
12316
|
+
};
|
|
12317
|
+
sxRow(block, t('Name'), t('A short word: it is how the agents see it.'), input(draft.id, 'apex', { mono: true, change: (value) => { draft.id = value.trim().toLowerCase(); } }));
|
|
12318
|
+
sxRow(block, t('Where it lives'), null, sxSeg([{ value: 'stdio', text: t('On this computer') }, { value: 'http', text: t('On the internet') }], draft.transport, (value) => { draft.transport = value; drawMcpTarget(); }, { label: t('Where it lives') }));
|
|
12319
|
+
if (draft.transport === 'stdio') {
|
|
12320
|
+
sxRow(block, t('Command'), t('The command its instructions give you to start it.'), input(draft.command, 'node ~/mcp/server.mjs', { mono: true, change: (value) => { draft.command = value; } }), { stack: true });
|
|
12321
|
+
const vars = el('div', 'mcp-vars');
|
|
12322
|
+
draft.env.forEach((row, index) => {
|
|
12323
|
+
const line = el('div', 'sx-field');
|
|
12324
|
+
line.append(input(row.name, 'API_KEY', { mono: true, change: (value) => { row.name = value.trim(); } }), input(row.value, t('value'), { mono: true, type: row.secret ? 'password' : 'text', change: (value) => { row.value = value; } }));
|
|
12325
|
+
const secret = el('label', 'mcp-secret');
|
|
12326
|
+
const box = el('input'); box.type = 'checkbox'; box.checked = row.secret !== false;
|
|
12327
|
+
box.addEventListener('change', () => { row.secret = box.checked; drawMcpTarget(); });
|
|
12328
|
+
secret.append(box, el('span', null, t('secret')));
|
|
12329
|
+
line.append(secret, sxButton('×', () => { draft.env.splice(index, 1); drawMcpTarget(); }, { title: t('Remove') }));
|
|
12330
|
+
vars.append(line);
|
|
12331
|
+
});
|
|
12332
|
+
vars.append(sxButton(t('Add a variable'), () => { draft.env.push({ name: '', value: '', secret: true }); drawMcpTarget(); }));
|
|
12333
|
+
sxRow(block, t('Variables'), t('Settings it needs, like an API key. Secret ones stay locked in MADRE, never in a file.'), vars, { stack: true });
|
|
12334
|
+
} else {
|
|
12335
|
+
sxRow(block, t('Address'), t('The MCP address the service gives you, starting with https://.'), input(draft.url, 'https://example.com/mcp', { mono: true, change: (value) => { draft.url = value.trim(); } }), { stack: true });
|
|
12336
|
+
sxRow(block, t('Sign-in'), t('Sign in opens the service\'s own page. A token is a code you paste once; MADRE keeps it safe.'), sxSeg([{ value: 'none', text: t('None') }, { value: 'oauth', text: t('Sign in') }, { value: 'token', text: t('Token') }], draft.auth, (value) => { draft.auth = value; drawMcpTarget(); }, { label: t('Sign-in') }));
|
|
12337
|
+
if (draft.auth === 'token') sxRow(block, t('Token'), null, input(draft.token, t('paste it here'), { mono: true, type: 'password', change: (value) => { draft.token = value; } }), { stack: true });
|
|
12338
|
+
}
|
|
12339
|
+
const save = sxButton(draft.auth === 'oauth' && draft.transport === 'http' ? t('Save and sign in') : t('Save and check'), async () => {
|
|
12340
|
+
save.disabled = true; save.textContent = t('Checking…');
|
|
12341
|
+
try {
|
|
12342
|
+
const result = await mcpCall('/api/x/mcp/servers', { id: draft.id, transport: draft.transport, command: draft.command, url: draft.url, auth: draft.auth, token: draft.token, env: draft.env });
|
|
12343
|
+
const added = (mcpUI.servers ?? []).find((one) => one.id === draft.id);
|
|
12344
|
+
mcpUI.adding = null;
|
|
12345
|
+
drawMcpTarget(); syncSettingsCards();
|
|
12346
|
+
if (added && added.auth === 'oauth' && result.check?.needsAuth) void mcpSignIn(added);
|
|
12347
|
+
else if (result.check && !result.check.ok) toast(t('{name} did not answer: {problem}', { name: draft.id, problem: result.check.problem ?? '' }));
|
|
12348
|
+
else if (result.check?.ok) toast(t('{name} is connected · {n} tools', { name: draft.id, n: result.check.tools.length }), { tone: 'done' });
|
|
12349
|
+
} catch (error) {
|
|
12350
|
+
toast(`${t('Diagnostics › ')}${resay(error.message)}`);
|
|
12351
|
+
save.disabled = false; save.textContent = t('Save and check');
|
|
12352
|
+
}
|
|
12353
|
+
}, { tone: 'primary' });
|
|
12354
|
+
const actions = el('div', 'mcp-actions');
|
|
12355
|
+
actions.append(save, sxButton(t('Cancel'), () => { mcpUI.adding = null; drawMcpTarget(); }));
|
|
12356
|
+
block.append(actions);
|
|
12357
|
+
}
|
|
12358
|
+
async function openMcpImport() {
|
|
12359
|
+
try {
|
|
12360
|
+
const { found } = await mcpCall('/api/x/mcp/import');
|
|
12361
|
+
mcpUI.found = found;
|
|
12362
|
+
// Nothing is picked for the person: an app's own internal servers are listed too.
|
|
12363
|
+
mcpUI.picked = new Set();
|
|
12364
|
+
} catch (error) { toast(`${t('Diagnostics › ')}${resay(error.message)}`); return; }
|
|
12365
|
+
drawMcpTarget();
|
|
12366
|
+
}
|
|
12367
|
+
function drawMcpImport(host) {
|
|
12368
|
+
const found = mcpUI.found;
|
|
12369
|
+
const block = sxBlock(host, t('Import from your apps'), found.length
|
|
12370
|
+
? t('Tools you already set up in other apps. Nothing is copied until you import; then MADRE keeps their keys safe and checks each one.')
|
|
12371
|
+
: t('No outside tools were found in .mcp.json, opencode.json, Claude Code, Codex, Gemini CLI or Cursor.'), { className: 'mcp-block' });
|
|
12372
|
+
for (const one of found) {
|
|
12373
|
+
const box = el('input');
|
|
12374
|
+
box.type = 'checkbox';
|
|
12375
|
+
box.checked = mcpUI.picked.has(one.index);
|
|
12376
|
+
box.disabled = one.taken;
|
|
12377
|
+
box.setAttribute('aria-label', one.name);
|
|
12378
|
+
box.addEventListener('change', () => { if (box.checked) mcpUI.picked.add(one.index); else mcpUI.picked.delete(one.index); });
|
|
12379
|
+
const label = el('label', 'mcp-pick');
|
|
12380
|
+
label.append(box);
|
|
12381
|
+
const secrets = one.env.length + one.headers.length;
|
|
12382
|
+
sxRow(block, `${one.name} · ${one.source}`, `${one.where}${secrets ? ` · ${t('{n} secrets to keep safe', { n: secrets })}` : ''}${one.taken ? ` · ${t('already in this project')}` : one.disabled ? ` · ${t('off in that app')}` : ''}`, label);
|
|
12383
|
+
}
|
|
12384
|
+
const actions = el('div', 'mcp-actions');
|
|
12385
|
+
if (found.length) {
|
|
12386
|
+
const go = sxButton(t('Import and check'), async () => {
|
|
12387
|
+
go.disabled = true; go.textContent = t('Checking…');
|
|
12388
|
+
try {
|
|
12389
|
+
const { results } = await mcpCall('/api/x/mcp/import', { picks: [...mcpUI.picked] });
|
|
12390
|
+
const ok = results.filter((one) => one.ok).length;
|
|
12391
|
+
toast(t('{ok} of {n} imported tools answered', { ok, n: results.length }), { tone: ok === results.length ? 'done' : 'look' });
|
|
12392
|
+
} catch (error) { toast(`${t('Diagnostics › ')}${resay(error.message)}`); }
|
|
12393
|
+
mcpUI.found = null;
|
|
12394
|
+
drawMcpTarget(); syncSettingsCards();
|
|
12395
|
+
}, { tone: 'primary' });
|
|
12396
|
+
actions.append(go);
|
|
12397
|
+
}
|
|
12398
|
+
actions.append(sxButton(t('Close'), () => { mcpUI.found = null; drawMcpTarget(); }));
|
|
12399
|
+
block.append(actions);
|
|
12400
|
+
}
|
|
12401
|
+
|
|
11660
12402
|
function drawFilesSettings(host) {
|
|
11661
12403
|
const tree = surfaces.find((one) => one.key === 'tree');
|
|
11662
12404
|
const where = sxBlock(host, t('Where they open'), t('Each one remembers how you left it. This is the same choice, without opening them.'));
|
|
@@ -11667,15 +12409,15 @@ function drawFilesSettings(host) {
|
|
|
11667
12409
|
else { surfaceModes.tree = value; rememberSurfaces(); }
|
|
11668
12410
|
syncSettingsCards();
|
|
11669
12411
|
}, { label: t('Files') }));
|
|
11670
|
-
sxRow(where, t('Your servers'), t('
|
|
12412
|
+
sxRow(where, t('Your servers'), t('Web pages your project runs on this computer: beside the conversation, or floating.'), sxSeg([
|
|
11671
12413
|
{ value: 'dock', text: t('Docked') }, { value: 'window', text: t('Window') },
|
|
11672
12414
|
], browserWindow.floating ? 'window' : 'dock', (value) => { setBrowserFloating(value === 'window', { animate: false }); }, { label: t('Your servers') }));
|
|
11673
12415
|
sxRow(where, t('File viewer'), t('It always opens beside the conversation: opening a file never splits the project on its own. From there it can float as a window.'));
|
|
11674
12416
|
|
|
11675
|
-
const show = sxBlock(host, t('What they can show'), t('
|
|
12417
|
+
const show = sxBlock(host, t('What they can show'), t('These switches are the same ones as in MODULES.'));
|
|
11676
12418
|
for (const [id, label, hint] of [
|
|
11677
|
-
['ripley', t('Preview'), t('
|
|
11678
|
-
['playwright', t("Test browser"), t('
|
|
12419
|
+
['ripley', t('Preview'), t('Web pages, images and documents show as they look, safely. Off, you see their code.')],
|
|
12420
|
+
['playwright', t("Test browser"), t('Agents can open and check your project\'s pages, without reaching anything else.')],
|
|
11679
12421
|
]) {
|
|
11680
12422
|
const item = modules.items.find((one) => one.id === id);
|
|
11681
12423
|
if (!item) { sxRow(show, label, t('Reading…')); continue; }
|
|
@@ -11766,7 +12508,7 @@ function updateNote(item, update) {
|
|
|
11766
12508
|
if (update.available) return t('{what} {latest} is out; this computer has {current}.', { what, latest: update.latest, current: update.current });
|
|
11767
12509
|
if (update.latest && !update.current) return t('{what} {latest} is the newest release. It is not on this computer yet.', { what, latest: update.latest });
|
|
11768
12510
|
if (update.latest) return t('{what} {latest} is the newest, and it is what this computer has.', { what, latest: update.latest });
|
|
11769
|
-
return t('MADRE could not
|
|
12511
|
+
return t('MADRE could not check whether there is a newer {what}.', { what });
|
|
11770
12512
|
}
|
|
11771
12513
|
|
|
11772
12514
|
// A fold inside a card. It remembers whether it was left open, like every other fold in MADRE,
|
|
@@ -12244,7 +12986,7 @@ async function renderEconomy(box) {
|
|
|
12244
12986
|
catch { box.replaceChildren(el('p', 'note', t('SPENDING UNAVAILABLE'))); return; }
|
|
12245
12987
|
box.replaceChildren();
|
|
12246
12988
|
if (!read?.turns) {
|
|
12247
|
-
box.append(el('p', 'note', t('NO
|
|
12989
|
+
box.append(el('p', 'note', t('NO SPENDING RECORDED YET · SEND A MESSAGE AND THIS FILLS')));
|
|
12248
12990
|
return;
|
|
12249
12991
|
}
|
|
12250
12992
|
const saved = read.saved ?? {};
|
|
@@ -12254,12 +12996,12 @@ async function renderEconomy(box) {
|
|
|
12254
12996
|
// different kinds have no shape to draw, and drawing one anyway is how a panel starts lying.
|
|
12255
12997
|
const strip = el('div', 'economy-strip');
|
|
12256
12998
|
for (const [value, label, hint] of [
|
|
12257
|
-
[count(read.billed ?? read.turns), t('
|
|
12999
|
+
[count(read.billed ?? read.turns), t('ANSWERS BILLED'), t('every turn this project has been billed for')],
|
|
12258
13000
|
[count((totals.input ?? 0) + (totals.created ?? 0)), t('INPUT CHARGED'), t('fresh reading, plus what was written into a cache (billed too) · the cache read back is apart')],
|
|
12259
|
-
[count(totals.output), t('OUTPUT CHARGED'), t('tokens
|
|
13001
|
+
[count(totals.output), t('OUTPUT CHARGED'), t('tokens the agent writes in its answer · the more expensive part')],
|
|
12260
13002
|
[count(saved.tokens), t('NOT CHARGED IN FULL'), t('read back from a cache (a fraction of the price, or of the plan) plus what was never sent')],
|
|
12261
13003
|
[pct(totals.cacheShare), t('CAME FROM CACHE'), t('of everything read, the share nobody charged twice')],
|
|
12262
|
-
[totals.charsPerInputToken ? totals.charsPerInputToken.toFixed(2) : '—', t('CHARACTERS PER TOKEN'), t('what MADRE wrote
|
|
13004
|
+
[totals.charsPerInputToken ? totals.charsPerInputToken.toFixed(2) : '—', t('CHARACTERS PER TOKEN'), t('what MADRE wrote compared with what the agents were charged for reading · a ratio, not an exact count')],
|
|
12263
13005
|
]) {
|
|
12264
13006
|
const cell = el('div', 'economy-stat');
|
|
12265
13007
|
cell.title = hint;
|
|
@@ -12275,7 +13017,7 @@ async function renderEconomy(box) {
|
|
|
12275
13017
|
first.append(el('p', 'unit-note', t('tokens read back from a cache at a fraction of their price, or never sent at all')));
|
|
12276
13018
|
metrics(first, [
|
|
12277
13019
|
[t('FROM THE CACHE'), count(saved.cachedTokens), t('the CLI said it read these back instead of charging them again · measured')],
|
|
12278
|
-
[t('NEVER SENT'), count(saved.unsentTokens), t('
|
|
13020
|
+
[t('NEVER SENT'), count(saved.unsentTokens), t('part of what MADRE sends the agent that this answer did not need · estimated from characters, not measured')],
|
|
12279
13021
|
[t('OF THE INPUT'), pct(saved.cachedShare), t('the share of everything read that came back from a cache')],
|
|
12280
13022
|
]);
|
|
12281
13023
|
first.append(el('p', 'note', t('One half is measured and the other is estimated, and they are never added without saying so. Neither of them is the Token saver: the Token saver shortens the answer, which is the next reading.')));
|
|
@@ -12288,8 +13030,8 @@ async function renderEconomy(box) {
|
|
|
12288
13030
|
free.append(cardHead('home', t('TURNS THE PROJECT ANSWERED ITSELF')));
|
|
12289
13031
|
metrics(free, [
|
|
12290
13032
|
[t('FREE TURNS'), count(read.free.turns), t('answered by @madre on this computer · no provider, no bill')],
|
|
12291
|
-
[t('OF EVERY TURN'), pct(read.free.share), t('of the
|
|
12292
|
-
[t('A PAID TURN'), read.free.paidOutputMedian ? `${count(read.free.paidOutputMedian)}↑` : '—', t('the
|
|
13033
|
+
[t('OF EVERY TURN'), pct(read.free.share), t('of the answers measured in this project')],
|
|
13034
|
+
[t('A PAID TURN'), read.free.paidOutputMedian ? `${count(read.free.paidOutputMedian)}↑` : '—', t('the typical (median) paid answer here · what the others cost, not what these would have')],
|
|
12293
13035
|
]);
|
|
12294
13036
|
free.append(el('p', 'note', t('These turns have no bill because nothing left this computer to answer them. MADRE does not say what they would have cost somewhere else: nobody measured that.')));
|
|
12295
13037
|
box.append(free);
|
|
@@ -12305,25 +13047,25 @@ async function renderEconomy(box) {
|
|
|
12305
13047
|
const block = read.blocks.find((one) => one.id === 'ash');
|
|
12306
13048
|
if (block) {
|
|
12307
13049
|
metrics(second, [
|
|
12308
|
-
[t('TOKEN SAVER ADDS'), t('{n} characters', { n: block.perTurn }), t('to every prompt it is on · measured, not estimated')],
|
|
13050
|
+
[t('TOKEN SAVER ADDS'), t('{n} characters', { n: block.perTurn }), t('to every prompt while it is on · measured, not estimated')],
|
|
12309
13051
|
[t('IN TOKENS'), `≈${Math.round(block.perTurn / CH_PER_TOKEN)}`, t('at {rate} characters per token · an estimate', { rate: CH_PER_TOKEN })],
|
|
12310
|
-
[t('TURNS CARRYING IT'), count(block.turns), t('of the {n}
|
|
13052
|
+
[t('TURNS CARRYING IT'), count(block.turns), t('of the {n} measured in this project', { n: count(read.turns) })],
|
|
12311
13053
|
]);
|
|
12312
13054
|
second.append(el('p', 'note', t('What the Token saver costs is certain and measured here. What it saves is not: the shorter answer below is observed, never promised. Output is the dearer half of every bill, so the trade is usually favourable — MADRE does not turn it into money because it does not know your prices.')));
|
|
12313
13055
|
}
|
|
12314
13056
|
if (read.ash?.agents?.length) ashComparison(second, read.ash);
|
|
12315
|
-
else second.append(el('p', 'note', t('No
|
|
13057
|
+
else second.append(el('p', 'note', t('No answers recorded yet with the Token saver on and off. Leave it on for a few answers and off for a few more, and the comparison shows here.')));
|
|
12316
13058
|
box.append(second);
|
|
12317
13059
|
|
|
12318
13060
|
// 3 · Where what WAS paid for went.
|
|
12319
13061
|
const third = el('div', 'card-block');
|
|
12320
|
-
third.append(cardHead('bars', t('
|
|
13062
|
+
third.append(cardHead('bars', t('HOW EACH PROMPT IS SPLIT')));
|
|
12321
13063
|
economyBlocks(third, read);
|
|
12322
|
-
third.append(el('p', 'note', t('{pct} of each prompt is
|
|
13064
|
+
third.append(el('p', 'note', t('{pct} of each prompt is a fixed header the cache can reuse. MADRE wrote {chars} characters of the {input} input tokens you were charged for; the rest is what the agents\' apps read on their own.', { pct: pct(read.totals.prefixShare), chars: count(read.totals.chars), input: count(read.totals.input) })));
|
|
12323
13065
|
// The tools a turn carried. A server's tool list goes into every call a CLI makes inside a turn;
|
|
12324
13066
|
// the middle turn with it against the middle turn without it, per agent, with the counts behind.
|
|
12325
13067
|
if (read.servers?.length) {
|
|
12326
|
-
third.append(el('p', 'unit-note', t('Fresh input of
|
|
13068
|
+
third.append(el('p', 'unit-note', t('Fresh input tokens of a typical (median) turn with each outside tool (MCP) connected, and without it.')));
|
|
12327
13069
|
for (const one of read.servers) {
|
|
12328
13070
|
third.append(el('p', 'note', t('@{agent} · {server}: {with} with it ({nWith} turns) · {without} without it ({nWithout} turns)', { agent: one.agent, server: one.server, with: count(one.medianWith), nWith: one.with, without: count(one.medianWithout), nWithout: one.without })));
|
|
12329
13071
|
}
|
|
@@ -12400,7 +13142,7 @@ function ashComparison(box, ash) {
|
|
|
12400
13142
|
group.append(row);
|
|
12401
13143
|
}
|
|
12402
13144
|
} else {
|
|
12403
|
-
group.append(el('p', 'ash-waiting', t('{n}
|
|
13145
|
+
group.append(el('p', 'ash-waiting', t('{n} answers with TOKEN SAVER, {m} without · {min} of each are needed to compare', {
|
|
12404
13146
|
n: agent.on.turns, m: agent.off.turns, min: ash.minTurns,
|
|
12405
13147
|
})));
|
|
12406
13148
|
}
|
|
@@ -12408,13 +13150,13 @@ function ashComparison(box, ash) {
|
|
|
12408
13150
|
// vary anyway, the honest thing on the screen is that there is nothing to read here yet.
|
|
12409
13151
|
if (agent.comparable) {
|
|
12410
13152
|
group.append(el('p', agent.readable ? 'ash-verdict' : 'ash-verdict noise', agent.readable
|
|
12411
|
-
? t('{pct} shorter with TOKEN SAVER
|
|
12412
|
-
: t('the difference fits inside how much this agent varies anyway (±{on} and ±{off}) ·
|
|
13153
|
+
? t('{pct} shorter with TOKEN SAVER: more than this agent\'s normal variation here (±{on} and ±{off})', { pct: `${Math.round(Math.abs(agent.share) * 100)}%`, on: agent.on.spread.toLocaleString(), off: agent.off.spread.toLocaleString() })
|
|
13154
|
+
: t('the difference fits inside how much this agent varies anyway (±{on} and ±{off}) · no conclusion yet', { on: agent.on.spread.toLocaleString(), off: agent.off.spread.toLocaleString() })));
|
|
12413
13155
|
}
|
|
12414
13156
|
list.append(group);
|
|
12415
13157
|
}
|
|
12416
13158
|
box.append(list);
|
|
12417
|
-
box.append(el('p', 'note', t('The
|
|
13159
|
+
box.append(el('p', 'note', t('The median answer of each group, not an average. You choose when to ask for compact answers, so the two groups are different tasks, not the same task twice — MADRE does not claim a percentage saved.')));
|
|
12418
13160
|
}
|
|
12419
13161
|
|
|
12420
13162
|
// The economy, on the card of the thing it is about. It fills as the room is used, because it
|
|
@@ -12436,7 +13178,7 @@ function paintAshEconomy(box) {
|
|
|
12436
13178
|
return fetch('/api/economy').then((response) => response.json()).then((read) => {
|
|
12437
13179
|
box.replaceChildren(el('h5', null, t('SPENDING · THIS PROJECT')));
|
|
12438
13180
|
if (!read?.turns) {
|
|
12439
|
-
box.append(el('p', 'note', t('NO
|
|
13181
|
+
box.append(el('p', 'note', t('NO SPENDING RECORDED YET · SEND A MESSAGE AND THIS FILLS')));
|
|
12440
13182
|
return;
|
|
12441
13183
|
}
|
|
12442
13184
|
const totals = read.totals;
|
|
@@ -12444,10 +13186,10 @@ function paintAshEconomy(box) {
|
|
|
12444
13186
|
metrics(box, [
|
|
12445
13187
|
[t('TOKENS SAVED'), count(saved.tokens), t('read back from the CLI cache, plus what was never sent')],
|
|
12446
13188
|
[t('FROM CACHE'), count(saved.cachedTokens), t('input the CLI did not charge again · {pct} of the input', { pct: pct(saved.cachedShare) })],
|
|
12447
|
-
[t('NEVER SENT'), `${count(saved.unsentChars)} CH`, t('
|
|
12448
|
-
[t('SPENT
|
|
12449
|
-
[t('SPENT
|
|
12450
|
-
[t('TURNS'), count(read.turns), t('
|
|
13189
|
+
[t('NEVER SENT'), `${count(saved.unsentChars)} CH`, t('part of what MADRE sends the agent that this answer did not need')],
|
|
13190
|
+
[t('SPENT ON INPUT'), count(totals.input), t('input tokens actually charged')],
|
|
13191
|
+
[t('SPENT ON OUTPUT'), count(totals.output), t('output tokens, the dearer half')],
|
|
13192
|
+
[t('TURNS'), count(read.turns), t('measured so far')],
|
|
12451
13193
|
]);
|
|
12452
13194
|
}).catch(() => { box.replaceChildren(el('h5', null, t('SPENDING · THIS PROJECT')), el('p', 'note', t('SPENDING UNAVAILABLE'))); });
|
|
12453
13195
|
}
|
|
@@ -12782,7 +13524,7 @@ function builtinCard(item) {
|
|
|
12782
13524
|
const status = el('dl', 'ollama-status');
|
|
12783
13525
|
const put = (k, v) => { status.append(el('dt', null, k), el('dd', null, v)); };
|
|
12784
13526
|
put(t('SERVER'), info.running ? t('running · {host}', { host: info.host }) : t('not running'));
|
|
12785
|
-
put(t('
|
|
13527
|
+
put(t('SEARCH BY MEANING'), info.embedModel ? `${info.embedModel}${info.settings.embeddings === false ? t(' · off') : ''}` : t('no embedding model'));
|
|
12786
13528
|
put(t('ARCHIVIST'), info.chatModel ? `${info.chatModel}${info.settings.archivist === false ? t(' · off') : ''}` : t('no chat model'));
|
|
12787
13529
|
if (info.models?.length) put(t('MODELS'), info.models.map((model) => model.name).join(', '));
|
|
12788
13530
|
read.append(status);
|
|
@@ -12795,7 +13537,7 @@ function builtinCard(item) {
|
|
|
12795
13537
|
const roles = cardBlock(panel, t('ROLES'));
|
|
12796
13538
|
const row = el('div', 'card-toggles');
|
|
12797
13539
|
for (const [role, model, present, label] of [
|
|
12798
|
-
['embeddings', item.recommended?.embed ?? 'nomic-embed-text', Boolean(info.embedModel), t('
|
|
13540
|
+
['embeddings', item.recommended?.embed ?? 'nomic-embed-text', Boolean(info.embedModel), t('SEARCH BY MEANING')],
|
|
12799
13541
|
['archivist', item.recommended?.chat ?? 'qwen2.5:3b', Boolean(info.chatModel), t('ARCHIVIST')],
|
|
12800
13542
|
['agent', item.recommended?.chat ?? 'qwen2.5:3b', Boolean(info.chatModel), t('@madre in the project')],
|
|
12801
13543
|
]) {
|
|
@@ -12929,7 +13671,7 @@ function builtinCard(item) {
|
|
|
12929
13671
|
}
|
|
12930
13672
|
|
|
12931
13673
|
if (fixed) {
|
|
12932
|
-
actions.append(el('span', 'note', item.install?.display ? item.install.display.toUpperCase() : t('
|
|
13674
|
+
actions.append(el('span', 'note', item.install?.display ? item.install.display.toUpperCase() : t('CANNOT BE TURNED OFF')));
|
|
12933
13675
|
return card;
|
|
12934
13676
|
}
|
|
12935
13677
|
|
|
@@ -12944,7 +13686,7 @@ function builtinCard(item) {
|
|
|
12944
13686
|
const response = await reach(`/api/extensions/${item.id}/install`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ confirm: true }) });
|
|
12945
13687
|
clearTimeout(trying);
|
|
12946
13688
|
const result = await response.json().catch(() => ({}));
|
|
12947
|
-
if (!response.ok) toast(result.error ?? t('{name} could not be
|
|
13689
|
+
if (!response.ok) toast(result.error ?? t('{name} could not be turned on or off.', { name: item.name }));
|
|
12948
13690
|
else {
|
|
12949
13691
|
toast(`${t('Diagnostics › ')}${t(result.enabled ? '{name} enabled' : '{name} disabled', { name: t(item.name) })}${result.enabled && result.model ? ` · ${result.model}` : ''}.`);
|
|
12950
13692
|
if (result.capabilities) { state.capabilities = result.capabilities; renderCreateScopes(); }
|
|
@@ -13106,8 +13848,8 @@ function moduleGlance(item) {
|
|
|
13106
13848
|
strip.classList.add('loading');
|
|
13107
13849
|
fetch('/api/economy').then((response) => response.json()).then((read) => {
|
|
13108
13850
|
strip.classList.remove('loading');
|
|
13109
|
-
if (!read?.turns) { fill([[t('Tokens not charged'), '0', t('nothing
|
|
13110
|
-
fill([[t('Tokens not charged'), Number(read.saved?.tokens ?? 0).toLocaleString(), t('read back from a cache, or never sent')], [t('Turns
|
|
13851
|
+
if (!read?.turns) { fill([[t('Tokens not charged'), '0', t('nothing measured yet')], [t('State'), standing]]); return; }
|
|
13852
|
+
fill([[t('Tokens not charged'), Number(read.saved?.tokens ?? 0).toLocaleString(), t('read back from a cache, or never sent')], [t('Turns measured'), String(read.turns)], [t('Read from cache'), Number.isFinite(read.saved?.cachedShare) ? `${Math.round(read.saved.cachedShare * 100)}%` : '—'], [t('State'), standing]]);
|
|
13111
13853
|
}).catch(() => { strip.classList.remove('loading'); fill([[t('State'), standing]]); });
|
|
13112
13854
|
} else if (item.id === 'intents') {
|
|
13113
13855
|
strip.classList.add('loading');
|
|
@@ -13127,7 +13869,7 @@ function moduleGlance(item) {
|
|
|
13127
13869
|
}).catch(() => { strip.classList.remove('loading'); fill([[t('State'), standing]]); });
|
|
13128
13870
|
} else if (item.id === 'ollama') {
|
|
13129
13871
|
const info = item.ollama ?? {};
|
|
13130
|
-
fill([[t('Server'), info.running ? t('Running') : t('Stopped'), info.host ?? null], [t('Models'), String(info.models?.length ?? 0)], [t('
|
|
13872
|
+
fill([[t('Server'), info.running ? t('Running') : t('Stopped'), info.host ?? null], [t('Models'), String(info.models?.length ?? 0)], [t('Search by meaning'), info.embedModel ?? t('none')], [t('State'), standing]]);
|
|
13131
13873
|
}
|
|
13132
13874
|
// A module that keeps no figures of its own gets none made up: its sheet opens on what it does.
|
|
13133
13875
|
return strip.childElementCount || strip.classList.contains('loading') ? strip : document.createDocumentFragment();
|
|
@@ -13240,7 +13982,7 @@ function moduleSheet(host, item) {
|
|
|
13240
13982
|
void renderEconomy(app);
|
|
13241
13983
|
}
|
|
13242
13984
|
if (item.id === 'intents') {
|
|
13243
|
-
const block = sxBlock(host, t('What your use taught'), `${t('Per agent and per kind of request: the lowest effort that was enough in your own turns, learned for no tokens. Below, a test that learns faster, for a price.')} ${t('When a lower level might be enough,
|
|
13985
|
+
const block = sxBlock(host, t('What your use taught'), `${t('Per agent and per kind of request: the lowest effort that was enough in your own turns, learned for no tokens. Below, a test that learns faster, for a price.')} ${t('When a lower level might be enough, MADRE offers to try it on a single message and watches how it goes; if it falls short, the next one goes back up.')}`, { className: 'mods-reading mods-intents' });
|
|
13244
13986
|
intentsReading(block.rows);
|
|
13245
13987
|
}
|
|
13246
13988
|
if (item.id === 'ollama') ollamaBlock(host, item);
|
|
@@ -13257,13 +13999,13 @@ function moduleSheet(host, item) {
|
|
|
13257
13999
|
// Ollama's local brain as rows: the server, its models, the roles it takes in this room.
|
|
13258
14000
|
function ollamaBlock(host, item) {
|
|
13259
14001
|
const info = item.ollama ?? { running: false, models: [], settings: {} };
|
|
13260
|
-
const block = sxBlock(host, t('Local
|
|
14002
|
+
const block = sxBlock(host, t('Local AI'), t('Ollama on this computer: no provider tokens, nothing leaves.'));
|
|
13261
14003
|
const recheck = sxButton(t('Check again'), async () => { recheck.disabled = true; await fetch('/api/ollama/probe', { method: 'POST' }).catch(() => null); await refreshModules(); });
|
|
13262
14004
|
sxRow(block, t('Server'), info.running ? t('Running · {host}', { host: info.host }) : t('Not running'), recheck);
|
|
13263
14005
|
// Whether this project uses it, beside Home's choice for all of them (src/local-ai.mjs).
|
|
13264
14006
|
const project = info.project;
|
|
13265
14007
|
if (project && !project.home) {
|
|
13266
|
-
const why = !project.master ? t('Local AI is
|
|
14008
|
+
const why = !project.master ? t('Local AI is off for every project.')
|
|
13267
14009
|
: project.mode === 'auto' ? t('Home turns it on in every project, and «Active» switches it for all of them. To choose project by project, go to Home › Settings › Local AI.')
|
|
13268
14010
|
: project.mode === 'pick' ? t('Home chooses the projects one by one; «Active» switches this one here too.')
|
|
13269
14011
|
: t('Each project decides on its own: «Active» is this project’s switch.');
|
|
@@ -13290,7 +14032,7 @@ function ollamaBlock(host, item) {
|
|
|
13290
14032
|
}
|
|
13291
14033
|
const roles = sxBlock(host, t('Roles'), t('What Local AI does in this project.'));
|
|
13292
14034
|
for (const [role, model, present, label, hint] of [
|
|
13293
|
-
['embeddings', item.recommended?.embed ?? 'nomic-embed-text', Boolean(info.embedModel), t('
|
|
14035
|
+
['embeddings', item.recommended?.embed ?? 'nomic-embed-text', Boolean(info.embedModel), t('Search by meaning'), info.embedModel ?? null],
|
|
13294
14036
|
['archivist', item.recommended?.chat ?? 'qwen2.5:3b', Boolean(info.chatModel), t('Archivist'), info.chatModel ?? null],
|
|
13295
14037
|
['agent', item.recommended?.chat ?? 'qwen2.5:3b', Boolean(info.chatModel), t('@madre in the project'), info.chatModel ?? null],
|
|
13296
14038
|
]) {
|
|
@@ -13361,7 +14103,9 @@ function renderModules() {
|
|
|
13361
14103
|
}
|
|
13362
14104
|
const grid = el('div', 'mods-grid');
|
|
13363
14105
|
// What is working for you first, what you could turn on after.
|
|
13364
|
-
|
|
14106
|
+
// A module whose settings live in the project's Settings (Connect MCP) has no tile here.
|
|
14107
|
+
const offered = modules.items.filter((item) => item.card !== 'settings');
|
|
14108
|
+
const ordered = [...offered.filter(moduleOn), ...offered.filter((item) => !moduleOn(item))];
|
|
13365
14109
|
const shown = ordered.filter((item) => !query || settingsFold(`${item.name} ${t(moduleFace(item).line ?? '')} ${item.summary ?? ''}`).includes(query));
|
|
13366
14110
|
for (const item of shown) grid.append(moduleTile(item));
|
|
13367
14111
|
if (!query) {
|
|
@@ -13527,7 +14271,7 @@ function renderModulesDev() {
|
|
|
13527
14271
|
renderModules();
|
|
13528
14272
|
} catch (error) { toast(t('The reload failed: {error}', { error: error.message })); } finally { reload.disabled = false; }
|
|
13529
14273
|
}, { title: t('Load your module files again without restarting the project') });
|
|
13530
|
-
step(t('Load it'), t('Press
|
|
14274
|
+
step(t('Load it'), t('Press Reload modules and it appears on the board with its own card, no restart.'), reload);
|
|
13531
14275
|
step(t('If it talks to a service'), t('Connecting to a service from outside is almost always possible, if that service lets you get your own key in minutes and revoke it. Your module declares the key it needs, where it reaches and which of its tools send; MADRE draws the field, answers for the traffic and holds sending until #4 FULL ACCESS.'));
|
|
13532
14276
|
section.append(steps);
|
|
13533
14277
|
const yours = modules.items.filter((item) => item.external);
|
|
@@ -13596,7 +14340,6 @@ function openModules() {
|
|
|
13596
14340
|
setTimeout(() => node.classList.remove('entering'), motion(460));
|
|
13597
14341
|
placeModules(modulesBoxFor(), { chosen: false });
|
|
13598
14342
|
}
|
|
13599
|
-
if (windowsAside) setWindowsAside(false);
|
|
13600
14343
|
bringWindowForward(node);
|
|
13601
14344
|
modules.button.setAttribute('aria-pressed', 'true');
|
|
13602
14345
|
}
|
|
@@ -13609,7 +14352,7 @@ function closeModules() {
|
|
|
13609
14352
|
// Open, behind or set aside: the button brings it to the front; in front, it closes it.
|
|
13610
14353
|
modules.button.addEventListener('click', async () => {
|
|
13611
14354
|
const node = modules.dialog;
|
|
13612
|
-
if (node.open && node.classList.contains('front') && !
|
|
14355
|
+
if (node.open && node.classList.contains('front') && !node.classList.contains('aside')) { closeModules(); return; }
|
|
13613
14356
|
openModules();
|
|
13614
14357
|
await refreshModules();
|
|
13615
14358
|
renderModules();
|
|
@@ -13731,52 +14474,75 @@ function openAgentPop(id, anchor, { back = null } = {}) {
|
|
|
13731
14474
|
head.append(previous);
|
|
13732
14475
|
}
|
|
13733
14476
|
head.append(avatar(id, { size: 24 }));
|
|
13734
|
-
|
|
13735
|
-
|
|
14477
|
+
const who = el('div', 'pop-who');
|
|
14478
|
+
who.append(el('b', null, agent.label), el('span', 'vendor', vendorWord(id)));
|
|
14479
|
+
head.append(who);
|
|
14480
|
+
// The session in the corner: the one thing to know before asking it anything.
|
|
14481
|
+
const lamp = session?.state === 'signed-in' ? 'ok' : session?.state === 'signed-out' || !agent.ready ? 'bad' : 'wait';
|
|
14482
|
+
head.append(el('span', `pop-session ${lamp}`, session ? ({ 'signed-in': t('signed in'), 'signed-out': t('signed out') }[session.state] ?? t('unknown')) : agent.ready ? t('unknown') : t('not ready')));
|
|
13736
14483
|
pop.append(head);
|
|
13737
|
-
//
|
|
13738
|
-
//
|
|
13739
|
-
//
|
|
14484
|
+
// Read top to bottom (2026-10-14): one figure and its gauge, four plain numbers, what the
|
|
14485
|
+
// provider says (or that it says nothing), the connection in one line, what it can do, and the
|
|
14486
|
+
// way to its settings.
|
|
13740
14487
|
const hero = el('div', 'pop-hero');
|
|
13741
|
-
|
|
13742
|
-
|
|
14488
|
+
let figure;
|
|
14489
|
+
let caption;
|
|
14490
|
+
if (official !== null) {
|
|
14491
|
+
figure = `${Math.round(official)}%`;
|
|
14492
|
+
caption = t("of the provider's {window} limit", { window: fmtWindow(agent.officialWindows?.primary?.windowMinutes ?? 300) });
|
|
14493
|
+
} else if (state.budget) {
|
|
14494
|
+
figure = `${Math.round(percent)}%`;
|
|
14495
|
+
caption = t("of this project's limit · last 5 h");
|
|
14496
|
+
} else {
|
|
14497
|
+
figure = formatTokens(agent.tokens ?? 0) || '0';
|
|
14498
|
+
caption = t('tokens in the last 5 h');
|
|
14499
|
+
}
|
|
14500
|
+
const big = el('div', 'big', figure);
|
|
14501
|
+
big.append(el('small', null, caption));
|
|
13743
14502
|
hero.append(big);
|
|
13744
14503
|
const gauge = el('div', `gauge${shown >= 80 ? ' hot' : ''}`);
|
|
13745
14504
|
const fill = el('i'); fill.style.width = `${shown}%`; gauge.append(fill);
|
|
13746
14505
|
hero.append(gauge);
|
|
13747
|
-
|
|
14506
|
+
const under = official !== null
|
|
14507
|
+
? (agent.officialResetAt ? t(' · resets {when}', { when: fmtReset(agent.officialResetAt) }) : t(' · window reset')).replace(/^\s*·\s*/, '')
|
|
14508
|
+
: state.budget ? t('{used} of {total} tokens', { used: formatTokens(agent.tokens ?? 0) || '0', total: formatTokens(state.budget) }) + (agent.rollsOverAt ? t(' · oldest turn drops {when}', { when: fmtReset(agent.rollsOverAt) }) : '') : null;
|
|
14509
|
+
if (under) hero.append(el('div', 'pop-reset', under));
|
|
13748
14510
|
pop.append(hero);
|
|
13749
|
-
|
|
13750
|
-
|
|
13751
|
-
|
|
13752
|
-
|
|
13753
|
-
|
|
13754
|
-
|
|
13755
|
-
};
|
|
13756
|
-
|
|
14511
|
+
|
|
14512
|
+
const grid = el('div', 'pop-stats');
|
|
14513
|
+
const stat = (label, value) => { const box = el('div', 'pop-stat'); box.append(el('span', null, label), el('b', null, value)); grid.append(box); };
|
|
14514
|
+
stat(t('In this project'), `${formatTokens(agent.rawTokens ?? 0) || '0'} tok`);
|
|
14515
|
+
stat(t('Answers'), String(stats.turns));
|
|
14516
|
+
stat(t('Last answer'), stats.lastTurnMs ? `${fmtMs(stats.lastTurnMs)}${stats.lastTurnTokens ? ` · ${formatTokens(stats.lastTurnTokens)}` : ''}` : '—');
|
|
14517
|
+
stat(stats.costEstimated ? t('Cost (estimated)') : t('Cost'), stats.cost > 0 ? `${stats.costEstimated ? '≈ ' : ''}${fmtUsd(stats.cost)}` : '—');
|
|
14518
|
+
pop.append(grid);
|
|
14519
|
+
|
|
14520
|
+
const provider = el('section', 'pop-provider');
|
|
13757
14521
|
if (agent.officialWindows) {
|
|
13758
14522
|
const { primary, secondary } = agent.officialWindows;
|
|
13759
|
-
|
|
13760
|
-
|
|
13761
|
-
|
|
14523
|
+
const line = (one) => {
|
|
14524
|
+
const row = el('div', `pop-limit${one.usedPercent >= 80 ? ' hot' : ''}`);
|
|
14525
|
+
row.append(el('span', null, t('{window} limit', { window: fmtWindow(one.windowMinutes) })), el('b', null, one.resetAt ? t('{pct}% · resets {when}', { pct: Math.round(one.usedPercent), when: fmtReset(one.resetAt) }) : `${Math.round(one.usedPercent)}%`));
|
|
14526
|
+
provider.append(row);
|
|
14527
|
+
};
|
|
14528
|
+
provider.append(el('h4', null, t('Your plan')));
|
|
14529
|
+
if (primary) line(primary);
|
|
14530
|
+
if (secondary) line(secondary);
|
|
13762
14531
|
} else if (Number.isFinite(agent.officialPercent)) {
|
|
13763
|
-
|
|
14532
|
+
provider.append(el('h4', null, t('Your plan')), el('p', 'pop-note', t('{pct}% used', { pct: Math.round(official ?? 0) })));
|
|
13764
14533
|
} else {
|
|
13765
|
-
|
|
13766
|
-
}
|
|
13767
|
-
|
|
13768
|
-
|
|
13769
|
-
|
|
13770
|
-
|
|
13771
|
-
|
|
13772
|
-
if (stats.cost > 0) room(stats.costEstimated ? t('cost (estimated)') : t('cost (reported)'), `${stats.costEstimated ? '≈ ' : ''}${fmtUsd(stats.cost)}`);
|
|
13773
|
-
const link = group(t('Connection'));
|
|
13774
|
-
link(t('session'), session ? ({ 'signed-in': t('signed in'), 'signed-out': t('signed out') }[session.state] ?? t('unknown')) : agent.ready ? t('unknown') : t('not ready'), `session-lamp ${session?.state === 'signed-in' ? 'ok' : session?.state === 'signed-out' || !agent.ready ? 'bad' : 'wait'}`);
|
|
13775
|
-
if (session?.detail) link(t('via'), session.detail);
|
|
13776
|
-
link(t('timeout'), `${Math.round((state.timeouts[id] ?? 180000) / 1000)}s`);
|
|
13777
|
-
if (agent.version) link(t('version'), agent.version);
|
|
14534
|
+
provider.append(el('p', 'pop-note', t("{agent}'s app does not say how much of your plan is left. What you see here is only what this project spent.", { agent: agent.label })));
|
|
14535
|
+
}
|
|
14536
|
+
pop.append(provider);
|
|
14537
|
+
|
|
14538
|
+
const timeout = Math.round((state.timeouts[id] ?? 180000) / 1000);
|
|
14539
|
+
const meta = [session?.detail, agent.version ? t('version {version}', { version: agent.version }) : null, t('waits up to {time}', { time: timeout >= 120 ? t('{n} min', { n: Math.round(timeout / 60) }) : `${timeout}s` })].filter(Boolean);
|
|
14540
|
+
pop.append(el('p', 'pop-meta', meta.join(' · ')));
|
|
13778
14541
|
pop.append(capabilityBadges(id));
|
|
13779
|
-
|
|
14542
|
+
const go = el('button', 'pop-go', t('Connection and limits in Settings'));
|
|
14543
|
+
go.type = 'button';
|
|
14544
|
+
go.addEventListener('click', (event) => { event.stopPropagation(); closeAgentPop(); openSettingsOn('agents', { agent: id }); });
|
|
14545
|
+
pop.append(go);
|
|
13780
14546
|
pop.dataset.for = id;
|
|
13781
14547
|
pop.dataset.from = back ? 'crew' : 'sphere';
|
|
13782
14548
|
placeAgentPop(anchor);
|
|
@@ -14159,7 +14925,7 @@ async function fillCoreBoard() {
|
|
|
14159
14925
|
board.append(brewHead(t('YOU'), projectDesignation() || null));
|
|
14160
14926
|
const you = el('div', 'mem-kpis');
|
|
14161
14927
|
memTile(you, { label: t('SIGNED IN'), value: `${signed.length}/${crew.length}`, sub: signed.length ? signed.map((agent) => `@${agent.id}`).join(' · ') : t('NOBODY YET') });
|
|
14162
|
-
memTile(you, { label: t('PROJECT
|
|
14928
|
+
memTile(you, { label: t('PROJECT USAGE'), value: formatTokens(spent) || '0', sub: state.budget ? t('OF {budget} TOKENS · 5H', { budget: formatTokens(state.budget) }) : t('NO BUDGET SET'), meter: state.budget ? Math.min(1, spent / state.budget) : null });
|
|
14163
14929
|
memTile(you, { label: t('ALL-TIME IN PROJECT'), value: formatTokens(ever) || '0', sub: t('TOKENS THE AGENTS HAVE SPENT HERE') });
|
|
14164
14930
|
board.append(you);
|
|
14165
14931
|
if (crew.length) {
|
|
@@ -14206,7 +14972,7 @@ async function fillCoreBoard() {
|
|
|
14206
14972
|
const local = el('div', 'mem-kpis');
|
|
14207
14973
|
memTile(local, { label: t('SERVER'), value: o.running ? (o.version ? `v${o.version}` : t('ON')) : t('OFF'), sub: o.running ? o.host : (o.error ?? t('not running')) });
|
|
14208
14974
|
memTile(local, { label: t('CHAT'), value: o.madreModel ?? o.chatModel ?? '—', sub: o.madreModel ? t('TRAINED FOR THIS PROJECT') : t('NOT TRAINED FOR THIS PROJECT YET') }).classList.add('word');
|
|
14209
|
-
memTile(local, { label: t('
|
|
14975
|
+
memTile(local, { label: t('SEARCH BY MEANING'), value: o.embedModel ?? '—', sub: d?.vectors ? t('{n} SEARCHABLE BY MEANING', { n: memFmt((d.vectors.memories ?? 0) + (d.vectors.entries ?? 0)) }) : t('RECALL MATCHES WORDS ONLY') }).classList.add('word');
|
|
14210
14976
|
const measured = state.localModel?.checked;
|
|
14211
14977
|
memTile(local, { label: t('MEASURED HERE'), value: measured ? `${measured.matched}/${measured.n}` : '—', sub: measured ? t('LANDED WHERE THE AGENTS DID · {verdict}', { verdict: measured.passed ? t('READY') : t('NOT YET') }) : t('NOT MEASURED YET'), meter: measured?.n ? measured.matched / measured.n : null });
|
|
14212
14978
|
board.append(local);
|
|
@@ -14220,7 +14986,7 @@ async function fillCoreBoard() {
|
|
|
14220
14986
|
const row = el('div', 'mem-signal');
|
|
14221
14987
|
const top = el('div', 'top');
|
|
14222
14988
|
top.append(el('b', null, model.name), el('span', null, formatSize(model.size || 0)));
|
|
14223
|
-
const role = [model.name === o.madreModel ? '@MADRE' : null, model.name === o.chatModel ? t('CHAT') : null, model.name === o.embedModel ? t('
|
|
14989
|
+
const role = [model.name === o.madreModel ? '@MADRE' : null, model.name === o.chatModel ? t('CHAT') : null, model.name === o.embedModel ? t('SEARCH BY MEANING') : null].filter(Boolean);
|
|
14224
14990
|
const facts = [...role, model.details?.parameter_size, model.details?.quantization_level, model.family].filter(Boolean);
|
|
14225
14991
|
row.append(top, memMeter((model.size || 0) / largest));
|
|
14226
14992
|
if (facts.length) row.append(el('span', 'detail', facts.join(' · ')));
|
|
@@ -14268,7 +15034,7 @@ async function fillCoreBoard() {
|
|
|
14268
15034
|
return label;
|
|
14269
15035
|
};
|
|
14270
15036
|
does.append(
|
|
14271
|
-
role('embeddings', t('
|
|
15037
|
+
role('embeddings', t('SEARCH BY MEANING'), t('Prepares each memory so recall can find it by meaning.')),
|
|
14272
15038
|
role('archivist', t('ARCHIVIST'), t('Distils the exchanges into memories, on this computer.')),
|
|
14273
15039
|
role('agent', '@MADRE', t('Answers in the project as the fifth agent.')),
|
|
14274
15040
|
);
|
|
@@ -14292,9 +15058,9 @@ async function fillCoreBoard() {
|
|
|
14292
15058
|
if (value === (mem.embedProvider ?? 'auto')) option.selected = true;
|
|
14293
15059
|
embed.append(option);
|
|
14294
15060
|
}
|
|
14295
|
-
embed.addEventListener('change', () => save({ embedProvider: embed.value }, t('
|
|
15061
|
+
embed.addEventListener('change', () => save({ embedProvider: embed.value }, t('search by meaning: {what}.', { what: embed.options[embed.selectedIndex].textContent.toLowerCase() })));
|
|
14296
15062
|
const field = (word, control) => { const label = el('label', 'core-field'); label.append(el('span', 'k', word), control); return label; };
|
|
14297
|
-
reads.append(field(t('ARCHIVIST'), archivist), field(t('
|
|
15063
|
+
reads.append(field(t('ARCHIVIST'), archivist), field(t('SEARCH BY MEANING'), embed));
|
|
14298
15064
|
if (mem.envWins) reads.append(el('p', 'note', t('AN ENVIRONMENT VARIABLE IS SETTING SOME OF THIS, AND IT WINS OVER WHAT YOU CHOOSE HERE.')));
|
|
14299
15065
|
}
|
|
14300
15066
|
if (grid.childElementCount) board.append(grid);
|
|
@@ -14343,7 +15109,7 @@ function renderCoreStrip() {
|
|
|
14343
15109
|
const field = el('textarea');
|
|
14344
15110
|
field.id = 'core-text';
|
|
14345
15111
|
field.rows = 1;
|
|
14346
|
-
field.placeholder = t('the question you would send
|
|
15112
|
+
field.placeholder = t('type the question you would send, and see here what MADRE would send the agent');
|
|
14347
15113
|
field.value = core.text ?? '';
|
|
14348
15114
|
field.addEventListener('input', () => {
|
|
14349
15115
|
core.text = field.value;
|
|
@@ -14490,7 +15256,7 @@ function renderCoreDoc(briefing) {
|
|
|
14490
15256
|
: t('THE WHOLE PROJECT STILL FITS')));
|
|
14491
15257
|
if (window.omitted) carried.append(brewRow(t('LEFT BEHIND'), t('{n} OLDER MESSAGES · WHICH IS WHAT RECALL IS FOR', { n: window.omitted })));
|
|
14492
15258
|
carried.append(brewRow(t('READ TO BUILD IT'), t('{m} MEMORIES · {q} EXACT QUOTES', { m: briefing.recalled, q: briefing.quoted })));
|
|
14493
|
-
carried.append(brewRow(t('NOT COUNTED'), t('NONE OF THEM
|
|
15259
|
+
carried.append(brewRow(t('NOT COUNTED'), t('NONE OF THEM COUNTS AS USED: THIS IS A PREVIEW, NOT A REAL ANSWER')));
|
|
14494
15260
|
carried.append(brewRow(t('WEIGHT'), t('{ch} CH · ≈{tokens} TOKENS · ESTIMATED AT {rate} CH/TOKEN, NOT MEASURED: MADRE DOES NOT HAVE THE PROVIDER\'S TOKENIZER', { ch: briefing.chars.toLocaleString(), tokens: tokensFor(briefing.chars).toLocaleString(), rate: CH_PER_TOKEN })));
|
|
14495
15261
|
if (briefing.spared) carried.append(brewRow(t('LEFT OUT'), t('{n} CHARACTERS THIS TURN HAS NO USE FOR', { n: briefing.spared.toLocaleString() })));
|
|
14496
15262
|
doc.append(carried);
|
|
@@ -14544,7 +15310,7 @@ function renderCoreLaunch(launch) {
|
|
|
14544
15310
|
const servers = [...(launch.memoryServer ? [{ ...launch.memoryServer, brief: t('the archive of this project, read and written through MADRE') }] : []), ...(launch.mcpServers ?? [])]
|
|
14545
15311
|
.filter((server, at, all) => all.findIndex((other) => other.name === server.name) === at);
|
|
14546
15312
|
if (servers.length) {
|
|
14547
|
-
floor.append(brewHead(t('
|
|
15313
|
+
floor.append(brewHead(t('OUTSIDE TOOLS (MCP) IT CAN USE'), `${servers.length}`));
|
|
14548
15314
|
const rows = el('div', 'brew-rows');
|
|
14549
15315
|
for (const server of servers) {
|
|
14550
15316
|
const value = el('span');
|
|
@@ -14916,14 +15682,14 @@ async function drawMemoryDashboard(glance, analysis, stamp) {
|
|
|
14916
15682
|
fetch('/api/maturity').then((response) => response.json()),
|
|
14917
15683
|
fetch('/api/memory/dashboard').then((response) => response.json()).then((payload) => payload?.dashboard ?? null),
|
|
14918
15684
|
]);
|
|
14919
|
-
} catch { glance.classList.remove('loading'); glance.replaceChildren(el('p', 'sx-hint', t('The
|
|
15685
|
+
} catch { glance.classList.remove('loading'); glance.replaceChildren(el('p', 'sx-hint', t('The memory could not be read.'))); return; }
|
|
14920
15686
|
glance.classList.remove('loading');
|
|
14921
15687
|
glance.replaceChildren();
|
|
14922
15688
|
analysis.replaceChildren();
|
|
14923
15689
|
stamp.textContent = t('Read at {time}', { time: formatTime(new Date().toISOString()) });
|
|
14924
15690
|
const m = read?.maturity;
|
|
14925
15691
|
const v = read?.verdict;
|
|
14926
|
-
if (!dash || !m || !v) { glance.append(el('p', 'sx-hint', t('Nothing
|
|
15692
|
+
if (!dash || !m || !v) { glance.append(el('p', 'sx-hint', t('Nothing yet: the project has no memories saved.'))); return; }
|
|
14927
15693
|
|
|
14928
15694
|
const stats = el('div', 'sx-stats');
|
|
14929
15695
|
const stat = (label, value, sub, spark = null, title = null) => {
|
|
@@ -14937,8 +15703,8 @@ async function drawMemoryDashboard(glance, analysis, stamp) {
|
|
|
14937
15703
|
const week = (w) => (w ? `${t('{n} this week', { n: memFmt(w.last) })} · ${w.delta > 0 ? `▲ ${memFmt(w.delta)}` : w.delta < 0 ? `▼ ${memFmt(-w.delta)}` : '='}` : null);
|
|
14938
15704
|
stat(t('Exchanges'), memFmt(dash.totals.entries), week(dash.weeks.entries), dash.series.entries.slice(-14), memWeekTitle(dash.weeks.entries));
|
|
14939
15705
|
stat(t('Memories'), memFmt(dash.totals.memories), week(dash.weeks.memories), dash.series.memories.slice(-14), memWeekTitle(dash.weeks.memories));
|
|
14940
|
-
stat(t('
|
|
14941
|
-
stat(t('
|
|
15706
|
+
stat(t('Answers that used memories'), memFmt(dash.totals.turns), week(dash.weeks.recalls), dash.series.recalls.slice(-14), memWeekTitle(dash.weeks.recalls));
|
|
15707
|
+
stat(t('Not noted yet'), memFmt(dash.totals.pending), dash.totals.pending ? t('Exchanges not turned into notes yet.') : t('All caught up.'));
|
|
14942
15708
|
glance.append(stats);
|
|
14943
15709
|
|
|
14944
15710
|
// How ripe it is, in one line and one bar, and the next thing that would help it.
|
|
@@ -14958,19 +15724,19 @@ async function drawMemoryDashboard(glance, analysis, stamp) {
|
|
|
14958
15724
|
|
|
14959
15725
|
const grid = el('div', 'mem-grid');
|
|
14960
15726
|
memActivity(memCard(grid, t('Activity · last {n} days', { n: dash.days.length }), 'span-12'), dash);
|
|
14961
|
-
memSignals(memCard(grid, t('What the
|
|
15727
|
+
memSignals(memCard(grid, t('What the memory holds'), 'span-12'), m);
|
|
14962
15728
|
memBars(memCard(grid, t('By kind'), 'span-4'), Object.entries(dash.kinds).map(([kind, n]) => ({ label: kindWord(kind), n, swatch: `var(--mem-${kind}, var(--ph))` })));
|
|
14963
|
-
memBars(memCard(grid, t('By
|
|
14964
|
-
memBars(memCard(grid, t('By
|
|
14965
|
-
const recall = memCard(grid, t('
|
|
15729
|
+
memBars(memCard(grid, t('By note-taker'), 'span-4'), Object.entries(dash.agents).map(([agent, n]) => ({ label: `@${agent}`, n })));
|
|
15730
|
+
memBars(memCard(grid, t('By status'), 'span-4'), Object.entries(dash.zones).map(([zone, n]) => ({ label: zoneWord(zone), n, title: ZONE_NOTE[zone] ?? null })));
|
|
15731
|
+
const recall = memCard(grid, t('Memories used and search by meaning'), 'span-12');
|
|
14966
15732
|
const carried = dash.via.search + dash.via.cascade;
|
|
14967
15733
|
if (carried) {
|
|
14968
15734
|
const split = el('div', 'mem-split');
|
|
14969
|
-
split.title = t('How
|
|
15735
|
+
split.title = t('How used memories arrived: found by search, or brought along by a related one.');
|
|
14970
15736
|
for (const [id, n] of [['search', dash.via.search], ['cascade', dash.via.cascade]]) { const part = el('i', id); part.style.flexGrow = String(n); split.append(part); }
|
|
14971
15737
|
recall.append(split);
|
|
14972
15738
|
const legend = el('div', 'mem-legend');
|
|
14973
|
-
for (const [id, label, n] of [['search', t('Search'), dash.via.search], ['cascade', t('
|
|
15739
|
+
for (const [id, label, n] of [['search', t('Search'), dash.via.search], ['cascade', t('Related memories'), dash.via.cascade]]) {
|
|
14974
15740
|
const key = el('span', `k ${id}`);
|
|
14975
15741
|
key.append(el('i'), `${label} `, el('b', null, `${memFmt(n)} · ${memPct(n / carried)}`));
|
|
14976
15742
|
legend.append(key);
|
|
@@ -14978,7 +15744,7 @@ async function drawMemoryDashboard(glance, analysis, stamp) {
|
|
|
14978
15744
|
recall.append(legend);
|
|
14979
15745
|
} else recall.append(el('p', 'note', t('Nothing yet.')));
|
|
14980
15746
|
if (dash.vectors) {
|
|
14981
|
-
for (const [label, have, of] of [[t('Memories
|
|
15747
|
+
for (const [label, have, of] of [[t('Memories searchable by meaning'), dash.vectors.memories, dash.totals.memories], [t('Exchanges searchable by meaning'), dash.vectors.entries, dash.totals.entries]]) {
|
|
14982
15748
|
const row = el('div', 'mem-signal');
|
|
14983
15749
|
const head = el('div', 'top');
|
|
14984
15750
|
head.append(el('b', null, label), el('span', null, `${memFmt(have)} / ${memFmt(of)}`));
|
|
@@ -14986,22 +15752,22 @@ async function drawMemoryDashboard(glance, analysis, stamp) {
|
|
|
14986
15752
|
recall.append(row);
|
|
14987
15753
|
}
|
|
14988
15754
|
recall.append(el('span', 'detail', String(dash.vectors.model)));
|
|
14989
|
-
} else recall.append(el('p', 'note', t('
|
|
14990
|
-
memCard(grid, t('Memory
|
|
15755
|
+
} else recall.append(el('p', 'note', t('Search by meaning is off: memories are found by exact words only.')));
|
|
15756
|
+
memCard(grid, t('Memory checkup'), 'span-12').append(examsBlock(read.exams, { pass: dash.pass }));
|
|
14991
15757
|
analysis.append(grid);
|
|
14992
15758
|
}
|
|
14993
15759
|
|
|
14994
15760
|
/* ---------- the three tests ---------- */
|
|
14995
15761
|
|
|
14996
15762
|
const EXAM_NAMES = {
|
|
14997
|
-
coverage: t('
|
|
14998
|
-
consistency: t('
|
|
14999
|
-
match: t('
|
|
15763
|
+
coverage: t('Does the memory have the answers?'),
|
|
15764
|
+
consistency: t('Does the memory contradict itself?'),
|
|
15765
|
+
match: t('Does the local AI answer like the agents?'),
|
|
15000
15766
|
};
|
|
15001
15767
|
const EXAM_ABOUT = {
|
|
15002
15768
|
coverage: t('Real questions from this project, recall run at the moment each one was asked, scored against the reply that was actually given. It asks whether the answer was already in the archive, not whether it was right.'),
|
|
15003
|
-
consistency: t('Contradictions
|
|
15004
|
-
match: t('Real questions answered here by
|
|
15769
|
+
consistency: t('Contradictions the memory reviewer has not settled yet, memories taken out of use, and whether doubtful memories are being filed more often lately than they used to be.'),
|
|
15770
|
+
match: t('Real questions answered here by one of your agents, asked again of the Local AI with this project\'s memory behind it, and compared against the answer given at the time. It takes minutes and spends nothing.'),
|
|
15005
15771
|
};
|
|
15006
15772
|
|
|
15007
15773
|
function examRow(id, state, run, pass = null) {
|
|
@@ -15177,7 +15943,7 @@ function connectionCard(agent) {
|
|
|
15177
15943
|
const where = el('code', null, agent.detected ? `${agent.version ?? t('version unknown')} · ${agent.path}` : (agent.login?.install ?? []).join(' · '));
|
|
15178
15944
|
where.title = where.textContent;
|
|
15179
15945
|
meta.append(where);
|
|
15180
|
-
const facts = [session?.detail ? t('Session {detail}', { detail: session.detail }) : null, agent.account ? `${agent.paid === false ? `${t('Free
|
|
15946
|
+
const facts = [session?.detail ? t('Session {detail}', { detail: session.detail }) : null, agent.account ? `${agent.paid === false ? `${t('Free plan')} · ` : ''}${agent.account}` : null].filter(Boolean);
|
|
15181
15947
|
if (facts.length) meta.append(el('span', null, facts.join(' · ')));
|
|
15182
15948
|
card.append(meta);
|
|
15183
15949
|
const body = el('div', 'sx-agent-body');
|
|
@@ -15194,7 +15960,7 @@ function connectionCard(agent) {
|
|
|
15194
15960
|
for (const [key, labelText, onWhy] of [['imageGen', t('Generate images'), t('On for CREATE.')], ['web', t('Web access'), t('On in every turn.')]]) {
|
|
15195
15961
|
const scope = agentScopes[key] ?? { capable: false, enabled: false, wired: false };
|
|
15196
15962
|
const usable = scope.capable && scope.wired;
|
|
15197
|
-
const why = (on) => (!scope.capable ? t('This
|
|
15963
|
+
const why = (on) => (!scope.capable ? t('This agent can\'t do this.') : !scope.wired ? t('Not available yet.') : on ? onWhy : t('Off.'));
|
|
15198
15964
|
const toggle = sxSwitch(scope.enabled, async (on, box) => {
|
|
15199
15965
|
box.disabled = true;
|
|
15200
15966
|
try {
|
|
@@ -15227,12 +15993,12 @@ function connectionCard(agent) {
|
|
|
15227
15993
|
const currentStart = agentScopes.defaultMode ?? 1;
|
|
15228
15994
|
sxRow(modes, t('Starting mode'), currentStart === 2 ? t('Every turn may create files, plan steps too.') : t('Read-only until you unlock CREATE.'), sxSeg([1, 2].map((n) => ({
|
|
15229
15995
|
value: n, text: `#${n}`, className: `o${n}`, disabled: n > currentCap,
|
|
15230
|
-
title: n === 2 ? t('Every message to this agent starts in CREATE:
|
|
15996
|
+
title: n === 2 ? t('Every message to this agent starts in #2 CREATE: it may add new files, but existing files stay untouched. Plan steps sent to it too.') : t('Messages start in #1 CHAT ONLY; turn on #2 CREATE when you want files.'),
|
|
15231
15997
|
})), currentStart, async (n, seg) => {
|
|
15232
15998
|
try {
|
|
15233
|
-
await saveSettingNow({ scopes: { [agent.id]: { defaultMode: n } } }, n === 2 ? t('@{agent} starts in #2 CREATE: every turn may add files to the project.', { agent: agent.id }) : t('@{agent} starts
|
|
15999
|
+
await saveSettingNow({ scopes: { [agent.id]: { defaultMode: n } } }, n === 2 ? t('@{agent} starts in #2 CREATE: every turn may add files to the project.', { agent: agent.id }) : t('@{agent} starts in #1 CHAT ONLY; turn on #2 CREATE when you want files.', { agent: agent.id }));
|
|
15234
16000
|
await loadSettings();
|
|
15235
|
-
} catch (error) { toast(t('The
|
|
16001
|
+
} catch (error) { toast(t('The starting mode was not saved: {error}', { error: error.message })); await loadSettings(); }
|
|
15236
16002
|
}, { label: t('Starting mode') }));
|
|
15237
16003
|
const seconds = Math.round((settingsUI.data.settings.timeouts[agent.id] ?? 180000) / 1000);
|
|
15238
16004
|
sxRow(card, t('Time limit'), t('How long the project waits for its answer.'), sxNumber(seconds, 's', {
|
|
@@ -15281,7 +16047,7 @@ function connectionCard(agent) {
|
|
|
15281
16047
|
if (agent.detected && agent.key && agent.id !== 'opencode') slot.append(keyForm(agent, { onDone: () => { void loadSettings(); renderOnboarding(); } }));
|
|
15282
16048
|
// Signed in, OpenCode's providers are added in its block below; the terminal is for a first sign-in.
|
|
15283
16049
|
if (agent.detected && agent.login && !agent.login.headless && !(agent.id === 'opencode' && session?.state === 'signed-in')) {
|
|
15284
|
-
slot.append(el('p', 'sx-hint', t('
|
|
16050
|
+
slot.append(el('p', 'sx-hint', t('To sign in, copy this into your Terminal app:')));
|
|
15285
16051
|
slot.append(commandBlock([agent.login.display, `# ${t(agent.login.note)}`]));
|
|
15286
16052
|
}
|
|
15287
16053
|
if (agent.detected && agent.login?.headless) slot.append(loginAskSlot(agent.id));
|
|
@@ -15309,7 +16075,7 @@ function renderMemoryPanel(data) {
|
|
|
15309
16075
|
}
|
|
15310
16076
|
function drawMemory(host, data) {
|
|
15311
16077
|
const mem = data?.settings?.memory;
|
|
15312
|
-
if (!mem) { sxBlock(host, t('
|
|
16078
|
+
if (!mem) { sxBlock(host, t('What it remembers'), t('This project has no memory yet.')); return; }
|
|
15313
16079
|
const save = async (memoryPatch, describe) => { try { await saveSettingNow({ memory: memoryPatch }, describe); await loadSettings(); } catch (error) { toast(t('Memory setting was not saved: {error}', { error: error.message })); } };
|
|
15314
16080
|
const select = (options, current, change, label) => {
|
|
15315
16081
|
const node = el('select', 'sx-input');
|
|
@@ -15323,19 +16089,19 @@ function drawMemory(host, data) {
|
|
|
15323
16089
|
const aside = el('div', 'sx-aside');
|
|
15324
16090
|
const stamp = el('span', 'sx-hint');
|
|
15325
16091
|
aside.append(stamp);
|
|
15326
|
-
const glanceBlock = sxBlock(host, t('
|
|
16092
|
+
const glanceBlock = sxBlock(host, t('At a glance'), null, { aside, className: 'sx-glance' });
|
|
15327
16093
|
const glance = el('div', 'sx-glance-body');
|
|
15328
16094
|
glanceBlock.rows.append(glance);
|
|
15329
16095
|
const more = el('details', 'sx-more');
|
|
15330
|
-
const summary = el('summary', null, t('
|
|
16096
|
+
const summary = el('summary', null, t('Details and checkups'));
|
|
15331
16097
|
const analysis = el('div', 'sx-analysis mem-dash');
|
|
15332
16098
|
more.append(summary, analysis);
|
|
15333
16099
|
glanceBlock.append(more);
|
|
15334
16100
|
aside.append(sxButton(t('Refresh'), () => void drawMemoryDashboard(glance, analysis, stamp)));
|
|
15335
16101
|
void drawMemoryDashboard(glance, analysis, stamp);
|
|
15336
16102
|
|
|
15337
|
-
const keep = sxBlock(host, t('
|
|
15338
|
-
sxRow(keep, t('Who
|
|
16103
|
+
const keep = sxBlock(host, t('Who takes notes'), t('An agent rereads recent conversations and saves only what is worth remembering. The cheapest one goes first; local AI is free and private.'));
|
|
16104
|
+
sxRow(keep, t('Who takes notes first'), null, select([['auto', t('Automatic · the cheapest one')], ...mem.candidates.map((c) => [c.id, `@${c.id}${c.local ? ` · ${c.label}` : ''}`])], mem.archivist, (value) => save({ archivist: value }, value === 'auto' ? t('notes: the cheapest agent goes first.') : t('notes: @{agent} goes first.', { agent: value })), t('Who takes notes first')));
|
|
15339
16105
|
const allowed = new Set(mem.archivists ?? mem.candidates.map((c) => c.id));
|
|
15340
16106
|
const who = el('div', 'sx-chips');
|
|
15341
16107
|
for (const candidate of mem.candidates) {
|
|
@@ -15344,65 +16110,87 @@ function drawMemory(host, data) {
|
|
|
15344
16110
|
chip.setAttribute('aria-pressed', String(allowed.has(candidate.id)));
|
|
15345
16111
|
chip.addEventListener('click', () => {
|
|
15346
16112
|
if (allowed.has(candidate.id)) allowed.delete(candidate.id); else allowed.add(candidate.id);
|
|
15347
|
-
if (!allowed.size) { allowed.add(candidate.id); toast(t('Diagnostics ›
|
|
16113
|
+
if (!allowed.size) { allowed.add(candidate.id); toast(t('Diagnostics › at least one agent has to take notes.')); return; }
|
|
15348
16114
|
chip.setAttribute('aria-pressed', String(allowed.has(candidate.id)));
|
|
15349
|
-
void save({ archivists: allowed.size === mem.candidates.length ? [] : [...allowed] }, t('
|
|
16115
|
+
void save({ archivists: allowed.size === mem.candidates.length ? [] : [...allowed] }, t('taking notes: {who}.', { who: [...allowed].map((id) => `@${id}`).join(', ') }));
|
|
15350
16116
|
});
|
|
15351
16117
|
who.append(chip);
|
|
15352
16118
|
}
|
|
15353
|
-
sxRow(keep, t('Who may
|
|
15354
|
-
sxRow(keep, t('Every'), t('
|
|
15355
|
-
sxRow(keep, t('Or after'), t('
|
|
16119
|
+
sxRow(keep, t('Who may take notes'), t('Only the agents switched on here take notes.'), who, { stack: true });
|
|
16120
|
+
sxRow(keep, t('Every'), t('How many exchanges pass before it takes notes again.'), sxNumber(mem.every, t('exchanges'), { min: 1, label: t('Every'), wire: { toPatch: (value) => ({ memory: { every: value } }), describe: (value) => t('notes every {n} exchanges.', { n: value }) } }));
|
|
16121
|
+
sxRow(keep, t('Or after'), t('Or take notes after this many quiet minutes.'), sxNumber(mem.idleMinutes, t('min'), { min: 1, label: t('Or after'), wire: { toPatch: (value) => ({ memory: { idleMinutes: value } }), describe: (value) => t('or after {n} quiet minutes.', { n: value }) } }));
|
|
15356
16122
|
|
|
15357
|
-
const recall = sxBlock(host, t('
|
|
15358
|
-
sxRow(recall, t('
|
|
15359
|
-
const cascade = sxSwitch(mem.cascade !== false, (on) => save({ cascade: on }, on ? t('
|
|
15360
|
-
sxRow(recall, t('Related'), t('
|
|
16123
|
+
const recall = sxBlock(host, t('Using memories'), t('How many memories each answer uses, and how they are found.'));
|
|
16124
|
+
sxRow(recall, t('Room for memories'), t('At most, how much of what an agent reads can be memories.'), sxNumber(Math.round(mem.recallShare * 100), '%', { min: 0, step: 5, label: t('Room for memories'), wire: { toPatch: (value) => ({ memory: { recallShare: Math.min(60, value) / 100 } }), describe: (value) => t("memories may take up to {n}% of what each agent reads.", { n: Math.min(60, value) }) } }));
|
|
16125
|
+
const cascade = sxSwitch(mem.cascade !== false, (on) => save({ cascade: on }, on ? t('related memories come along too.') : t('only memories that match come along.')), { label: t('Related memories') });
|
|
16126
|
+
sxRow(recall, t('Related memories'), t('Memories that are often used together are brought in together.'), cascade);
|
|
15361
16127
|
const embedNow = mem.embedder ? mem.embedder : t('off');
|
|
15362
|
-
sxRow(recall, t('
|
|
15363
|
-
['auto', t('Automatic ·
|
|
16128
|
+
sxRow(recall, t('Search by meaning'), t('Finds memories by what they mean, not only by exact words. Now: {what}.', { what: embedNow }), select([
|
|
16129
|
+
['auto', t('Automatic · Local AI if running, else Gemini')],
|
|
15364
16130
|
['ollama', `Ollama · ${t('local')}${mem.ollama.embedModel ? ` · ${mem.ollama.embedModel}` : ` · ${t('no model yet')}`}`],
|
|
15365
16131
|
['gemini', t('Gemini · needs your key')],
|
|
15366
|
-
['off', t('Off · words only')],
|
|
15367
|
-
], mem.embedProvider ?? data.config?.memory?.embedProvider ?? 'auto', (value, node) => save({ embedProvider: value }, t('
|
|
15368
|
-
if (mem.envWins) sxFoot(recall, t('
|
|
16132
|
+
['off', t('Off · exact words only')],
|
|
16133
|
+
], mem.embedProvider ?? data.config?.memory?.embedProvider ?? 'auto', (value, node) => save({ embedProvider: value }, t('search by meaning: {what}.', { what: node.options[node.selectedIndex].textContent.toLowerCase() })), t('Search by meaning')));
|
|
16134
|
+
if (mem.envWins) sxFoot(recall, t('Some of these values are fixed by how MADRE was started; those win next time it starts.'));
|
|
15369
16135
|
|
|
15370
16136
|
// The dataset behind MADRE AI, and the recipe that trains on it, outside MADRE.
|
|
15371
|
-
const train = sxBlock(host, t('
|
|
16137
|
+
const train = sxBlock(host, t('Train your own AI'), t('Advanced: export what the project learned, train a model on your computer, and @madre answers with it.'));
|
|
15372
16138
|
const datasetHint = el('span', 'sx-hint', t('Reading…'));
|
|
15373
16139
|
const exportButton = sxButton(t('Export'), async () => {
|
|
15374
16140
|
exportButton.disabled = true;
|
|
15375
16141
|
try { const payload = await fetch('/api/dataset', { method: 'POST' }).then((response) => response.json()); showDataset(payload); toast(t('Diagnostics › dataset exported: {n} pairs in {dir}', { n: payload.dataset.pairs, dir: payload.dir })); }
|
|
15376
16142
|
catch (error) { toast(t('Dataset export failed: {error}', { error: error.message })); }
|
|
15377
16143
|
finally { exportButton.disabled = false; }
|
|
15378
|
-
}, { title: t('
|
|
15379
|
-
const datasetRow = sxRow(train, t('
|
|
16144
|
+
}, { title: t('Saves the training files in the project, with private data hidden') });
|
|
16145
|
+
const datasetRow = sxRow(train, t('Training data'), null, exportButton);
|
|
15380
16146
|
datasetRow.querySelector('.sx-words').append(datasetHint);
|
|
16147
|
+
// What it is, then the recipe in order (2026-10-14): one command per step, for a Mac with an
|
|
16148
|
+
// Apple chip or — tried, not promised — Linux with an NVIDIA card through MLX's CUDA package.
|
|
16149
|
+
train.append(el('p', 'sx-hint sx-train-what', t('The training data are question-and-answer pairs taken from this project\'s conversations and notes, with private details hidden. With them a small model is adjusted on your computer, and @madre answers with it.')));
|
|
15381
16150
|
const recipe = el('details', 'sx-more');
|
|
15382
|
-
recipe.append(el('summary', null, t('
|
|
16151
|
+
recipe.append(el('summary', null, t('Step by step')));
|
|
16152
|
+
let platform = /Mac/i.test(navigator.platform ?? '') || !/Linux/i.test(navigator.userAgent ?? '') ? 'mac' : 'linux';
|
|
15383
16153
|
const steps = el('ol', 'sx-steps');
|
|
15384
|
-
|
|
16154
|
+
const pickRow = el('div', 'sx-train-platform');
|
|
16155
|
+
const platformNote = el('p', 'sx-hint');
|
|
16156
|
+
recipe.append(pickRow, platformNote, steps);
|
|
15385
16157
|
train.append(recipe);
|
|
16158
|
+
let lastPayload = null;
|
|
16159
|
+
const drawPlatform = () => {
|
|
16160
|
+
pickRow.replaceChildren(sxSeg([{ value: 'mac', text: t('Mac with Apple chip') }, { value: 'linux', text: t('Linux with NVIDIA') }], platform, (value) => { platform = value; showDataset(lastPayload); }, { label: t('Your computer') }));
|
|
16161
|
+
platformNote.textContent = platform === 'mac'
|
|
16162
|
+
? t('Any Mac with an M chip. 16 GB of memory is enough for the small model.')
|
|
16163
|
+
: t('Experimental: MLX runs on Linux with an NVIDIA card (CUDA 12, compute capability 7.5 or newer, glibc 2.35, Python 3.10), but mlx-lm only promises Apple chips. Step 2 checks the card first.');
|
|
16164
|
+
};
|
|
15386
16165
|
const showDataset = (payload) => {
|
|
16166
|
+
lastPayload = payload;
|
|
15387
16167
|
const d = payload?.dataset;
|
|
15388
16168
|
const exported = d ? t('Last export {when} · {train} to train · {valid} to check', { when: new Date(d.exportedAt).toLocaleString(), train: d.train, valid: d.valid }) : t('Not exported yet.');
|
|
15389
16169
|
const trained = payload?.trained ? t('Trained model {model} in use.', { model: payload.trained }) : t('No trained model yet.');
|
|
15390
16170
|
datasetHint.textContent = `${exported} ${trained}`;
|
|
16171
|
+
drawPlatform();
|
|
15391
16172
|
const info = payload?.training;
|
|
15392
16173
|
steps.replaceChildren();
|
|
15393
16174
|
if (!info) { steps.append(el('li', 'sx-hint', t('The training recipe is unavailable.'))); return; }
|
|
15394
16175
|
const quote = (path) => `"${path.replace(/\/$/, '')}"`;
|
|
15395
|
-
|
|
15396
|
-
|
|
15397
|
-
|
|
15398
|
-
|
|
15399
|
-
|
|
15400
|
-
]) {
|
|
16176
|
+
const venv = 'source ~/.madre-train/bin/activate';
|
|
16177
|
+
const setup = platform === 'mac'
|
|
16178
|
+
? 'python3 -m venv ~/.madre-train && source ~/.madre-train/bin/activate && pip install mlx-lm'
|
|
16179
|
+
: 'python3 -m venv ~/.madre-train && source ~/.madre-train/bin/activate && pip install "mlx[cuda12]" mlx-lm && python3 -c "import mlx.core as mx; print(\'CUDA:\', mx.cuda.is_available())"';
|
|
16180
|
+
const step = (label, about, command = null) => {
|
|
15401
16181
|
const li = el('li');
|
|
15402
|
-
li.append(el('b', null, label)
|
|
16182
|
+
li.append(el('b', null, label));
|
|
16183
|
+
if (about) li.append(el('span', 'sx-hint', about));
|
|
16184
|
+
if (command) li.append(sxCommand(command));
|
|
15403
16185
|
steps.append(li);
|
|
15404
|
-
}
|
|
15405
|
-
|
|
16186
|
+
};
|
|
16187
|
+
step(t('Export the training data'), t('Use «Export» above. Do it again whenever the project has grown.'));
|
|
16188
|
+
step(t('Prepare Python, once'), platform === 'mac' ? t('A separate environment with mlx-lm, so nothing else on your computer changes.') : t('A separate environment with MLX for CUDA and mlx-lm. It must print «CUDA: True»; if not, stop here.'), setup);
|
|
16189
|
+
step(t('Train · base {model} for {gb} GB', { model: info.baseModel, gb: info.memoryGb }), t('From 10 to 40 minutes. It learns from the exported pairs and leaves an adjustment («adapters»).'), `${venv} && bash ${quote(`${info.recipeDir}train.sh`)} ${quote(info.roomDir)} ${info.baseModel}`);
|
|
16190
|
+
step(t('Join the adjustment with the base model'), null, `${venv} && cd ${quote(info.roomDir)} && mlx_lm.fuse --model ${info.baseModel} --adapter-path adapters --save-path fused`);
|
|
16191
|
+
step(t('Turn it into a file Ollama reads (GGUF)'), t('The first time, it downloads llama.cpp\'s converter.'), `${venv} && cd ${quote(info.roomDir)} && git clone --depth 1 https://github.com/ggml-org/llama.cpp && pip install -r llama.cpp/requirements.txt && python3 llama.cpp/convert_hf_to_gguf.py fused --outfile madre.gguf --outtype q8_0`);
|
|
16192
|
+
step(t('Register it in Ollama as {model}', { model: info.modelName }), t('@madre switches to it at the next recheck of Local AI.'), `cd ${quote(info.roomDir)} && cp ${quote(`${info.recipeDir}Modelfile`)} . && ollama create ${info.modelName} -f Modelfile`);
|
|
16193
|
+
step(t('Try it before trusting it'), t('Ask @madre ten things the project decided and five it never discussed. It should get the first right and say it never discussed the others; if it makes things up, remove it with «ollama rm {model}» and train with more pairs.', { model: info.modelName }));
|
|
15406
16194
|
};
|
|
15407
16195
|
fetch('/api/dataset').then((response) => response.json()).then(showDataset).catch(() => showDataset(null));
|
|
15408
16196
|
}
|
|
@@ -15421,13 +16209,13 @@ function renderSettings() {
|
|
|
15421
16209
|
}
|
|
15422
16210
|
|
|
15423
16211
|
function drawConnections(host, data) {
|
|
15424
|
-
host.append(el('p', 'sx-intro', t('One agent is enough to
|
|
16212
|
+
host.append(el('p', 'sx-intro', t('One agent is enough to start. MADRE uses the AI apps already on this computer: install one here or sign in, and the project opens. Your passwords stay in each app; MADRE only checks you are signed in.')));
|
|
15425
16213
|
for (const agent of data.agents) host.append(connectionCard(agent));
|
|
15426
16214
|
}
|
|
15427
16215
|
|
|
15428
16216
|
function drawRoomSettings(host, data) {
|
|
15429
16217
|
const s = data.settings;
|
|
15430
|
-
const plans = sxBlock(host, t('Plans'), t('
|
|
16218
|
+
const plans = sxBlock(host, t('Plans'), t('Whether an agent may split a job into steps for the others.'));
|
|
15431
16219
|
const delegation = sxSwitch(s.delegation, async (on, box) => {
|
|
15432
16220
|
try { await saveSettingNow({ room: { delegation: on } }, on ? t('agents may open plans.') : t('agents no longer open plans.')); }
|
|
15433
16221
|
catch (error) { box.checked = !on; toast(t('The setting was not saved: {error}', { error: error.message })); }
|
|
@@ -15437,9 +16225,9 @@ function drawRoomSettings(host, data) {
|
|
|
15437
16225
|
min: 1, label: t('Steps per plan'), wire: { toPatch: (value) => ({ room: { maxPlanSteps: value } }), describe: (value) => t('max plan steps saved: {n}.', { n: value }) },
|
|
15438
16226
|
}));
|
|
15439
16227
|
|
|
15440
|
-
const limits = sxBlock(host, t('
|
|
15441
|
-
sxRow(limits, t('
|
|
15442
|
-
min: 10000, step: 10000, label: t('
|
|
16228
|
+
const limits = sxBlock(host, t('Spending and waiting'), t('What each agent may spend, and how long the project waits for it.'));
|
|
16229
|
+
sxRow(limits, t('Usage limit per agent'), t('Every 5 hours, in tokens: the unit AI use is measured in.'), sxNumber(s.softTokenBudget, null, {
|
|
16230
|
+
min: 10000, step: 10000, label: t('Usage limit per agent'),
|
|
15443
16231
|
wire: { toPatch: (value) => ({ room: { softTokenBudget: value } }), describe: (value) => t('local budget saved: {n} tokens per agent per 5h window.', { n: formatTokens(value) }) },
|
|
15444
16232
|
}));
|
|
15445
16233
|
sxRow(limits, t('Default time limit'), t('How long an agent may take before the project stops waiting. Each agent can have its own in Agents.'), sxNumber(Math.round(s.defaultTimeout / 1000), 's', {
|
|
@@ -15447,7 +16235,7 @@ function drawRoomSettings(host, data) {
|
|
|
15447
16235
|
wire: { toPatch: (seconds) => ({ timeouts: { default: seconds * 1000 } }), describe: (seconds) => t('default timeout saved: {n}s.', { n: seconds }) },
|
|
15448
16236
|
}));
|
|
15449
16237
|
|
|
15450
|
-
const gemini = sxBlock(host, 'Gemini', t('Gemini
|
|
16238
|
+
const gemini = sxBlock(host, 'Gemini', t('Gemini sometimes takes a while to start; this is how long to wait for it.'));
|
|
15451
16239
|
sxRow(gemini, t('Silence limit'), t('Seconds without any output before the attempt is dropped.'), sxNumber(Math.round(s.geminiIdleMs / 1000), 's', {
|
|
15452
16240
|
min: 10, step: 10, label: t('Silence limit'),
|
|
15453
16241
|
wire: { toPatch: (seconds) => ({ gemini: { idleMs: seconds * 1000 } }), describe: (seconds) => t('Gemini silence limit saved: {n}s.', { n: seconds }) },
|
|
@@ -15520,7 +16308,15 @@ function drawOpenCodeSettings(host, s) {
|
|
|
15520
16308
|
if (!waiting.length) return;
|
|
15521
16309
|
const which = el('select', 'sx-input');
|
|
15522
16310
|
which.setAttribute('aria-label', t('Provider'));
|
|
15523
|
-
|
|
16311
|
+
// Many providers (OpenCode's whole catalogue): the usual ones first, then all, by name.
|
|
16312
|
+
const option = (provider) => { const one = el('option', null, provider.label); one.value = provider.id; return one; };
|
|
16313
|
+
const featured = waiting.filter((one) => one.featured);
|
|
16314
|
+
const rest = waiting.filter((one) => !one.featured).sort((a, b) => a.label.localeCompare(b.label));
|
|
16315
|
+
if (featured.length && rest.length) {
|
|
16316
|
+
const top = el('optgroup'); top.label = t('Most used'); top.append(...featured.map(option));
|
|
16317
|
+
const all = el('optgroup'); all.label = t('All providers'); all.append(...rest.map(option));
|
|
16318
|
+
which.append(top, all);
|
|
16319
|
+
} else for (const provider of waiting) which.append(option(provider));
|
|
15524
16320
|
const input = el('input', 'sx-input mono');
|
|
15525
16321
|
input.type = 'password'; input.autocomplete = 'off'; input.spellcheck = false;
|
|
15526
16322
|
const where = el('a', 'oc-where', t('Where do I get one?'));
|
|
@@ -15613,11 +16409,11 @@ function openCodeProviderRow(provider, reload, hidden) {
|
|
|
15613
16409
|
// how far each agent may go. The key is still asked in the browser for each message; this only
|
|
15614
16410
|
// chooses which key and lets an accepted one hold a few minutes. #4 always asks for something.
|
|
15615
16411
|
function overrideKeyHint(mode, key) {
|
|
15616
|
-
if (key === 'confirm') return t('One click on UNLOCK. EDIT
|
|
16412
|
+
if (key === 'confirm') return t('One click on UNLOCK. EDIT saves a copy first, so UNDO is one click away.');
|
|
15617
16413
|
if (key === 'phrase') return t('Your phrase, typed when you unlock it.');
|
|
15618
16414
|
if (key === 'phrase-airlock') return t('Your phrase, typed twice.');
|
|
15619
16415
|
if (key === 'designation-airlock') return t('The folder name of the project ({name}), typed twice.', { name: projectDesignation() });
|
|
15620
|
-
return mode === 4 ? t('The folder name of the project ({name}), with no second
|
|
16416
|
+
return mode === 4 ? t('The folder name of the project ({name}), with no second confirmation.', { name: projectDesignation() }) : t('The folder name of the project ({name}).', { name: projectDesignation() });
|
|
15621
16417
|
}
|
|
15622
16418
|
// While a phrase guards a mode, a change to the keys shows it first (the project checks it): a
|
|
15623
16419
|
// program that could change the key could also take the phrase away.
|
|
@@ -15636,7 +16432,7 @@ function drawPermissions(host, data) {
|
|
|
15636
16432
|
}
|
|
15637
16433
|
const config = data.settings.override ?? OVERRIDE_DEFAULT;
|
|
15638
16434
|
const canHash = Boolean(globalThis.crypto?.subtle);
|
|
15639
|
-
const keys = sxBlock(host, t('
|
|
16435
|
+
const keys = sxBlock(host, t('How you unlock modes'), t('What you type to unlock #3 EDIT and #4 FULL ACCESS. Asked on every message, unless you chose to remember it below.'), { className: 'perm-keys' });
|
|
15640
16436
|
overrideProof = null;
|
|
15641
16437
|
if (config.phrase && [3, 4].some((mode) => config.keys[mode].startsWith('phrase'))) {
|
|
15642
16438
|
overrideProof = el('input', 'sx-input');
|
|
@@ -15653,9 +16449,9 @@ function drawPermissions(host, data) {
|
|
|
15653
16449
|
title: key.startsWith('phrase') && !config.phrase ? t('Save a phrase below first.') : overrideKeyHint(mode, key),
|
|
15654
16450
|
})), current, (key) => saveOverride({ keys: { [mode]: key } }, t('#{n} {label} now asks for: {key}.', { n: mode, label: MODES[mode].label, key: OVERRIDE_KEY_WORDS[key]().toLowerCase() })), { label: `#${mode} ${MODES[mode].label}` }), { stack: true });
|
|
15655
16451
|
}
|
|
15656
|
-
sxFoot(keys, t('#4 always asks
|
|
16452
|
+
sxFoot(keys, t('#4 always asks you to confirm: it runs commands, and what leaves the machine does not come back.'));
|
|
15657
16453
|
|
|
15658
|
-
const phrase = sxBlock(host, t('Your phrase'), t('
|
|
16454
|
+
const phrase = sxBlock(host, t('Your phrase'), t('MADRE never stores the phrase itself, only a fingerprint of it, and it never leaves this Mac.'));
|
|
15659
16455
|
const field = el('input', 'sx-input');
|
|
15660
16456
|
field.type = 'password';
|
|
15661
16457
|
field.autocomplete = 'new-password';
|
|
@@ -15679,18 +16475,29 @@ function drawPermissions(host, data) {
|
|
|
15679
16475
|
}
|
|
15680
16476
|
field.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); keep.click(); } });
|
|
15681
16477
|
|
|
15682
|
-
const grace = sxBlock(host, t('Remember permission'), t('
|
|
16478
|
+
const grace = sxBlock(host, t('Remember permission'), t('After you confirm, the same agent won\'t ask again for a while. Reloading the page or STOP ALL ends it.'));
|
|
15683
16479
|
for (const mode of [3, 4]) {
|
|
15684
16480
|
const options = mode === 3 ? [0, 5, 15, 30] : [0, 5];
|
|
15685
16481
|
sxRow(grace, `#${mode} ${MODES[mode].label}`, mode === 4 ? t('Never longer than 5 minutes.') : t('Raising an agent\'s max mode always asks, remembered permission or not.'), sxSeg(options.map((minutes) => ({ value: minutes, text: minutes ? t('{n} min', { n: minutes }) : t('Off') })), config.grace[mode] ?? 0, (minutes) => {
|
|
15686
16482
|
if (!minutes) closeOverrideGrace(mode);
|
|
15687
|
-
return saveOverride({ grace: { [mode]: minutes } }, minutes ? t('#{n} {label}
|
|
16483
|
+
return saveOverride({ grace: { [mode]: minutes } }, minutes ? t('#{n} {label} stays unlocked for {minutes} min after you confirm it.', { n: mode, label: MODES[mode].label, minutes }) : t('#{n} {label} asks you to confirm every message.', { n: mode, label: MODES[mode].label }));
|
|
15688
16484
|
}, { label: t('Remember permission for #{n}', { n: mode }) }));
|
|
15689
16485
|
}
|
|
15690
16486
|
|
|
16487
|
+
// The Memory map asks for the project name; here it can stop asking for a while, or for good
|
|
16488
|
+
// (2026-10-14). Kept in this browser, for this project only.
|
|
16489
|
+
const access = sxBlock(host, t('Memory access'), t('Opening the Memory map asks for the project name. Choose how long to skip that.'));
|
|
16490
|
+
access.id = 'perm-memory';
|
|
16491
|
+
const held = memoryAccess();
|
|
16492
|
+
sxRow(access, t('Don\'t ask again'), memoryAccessLine(held), sxSeg(MEMORY_ACCESS.map(([value, words]) => ({ value, text: words() })), held?.choice ?? 'ask', (choice) => {
|
|
16493
|
+
setMemoryAccess(choice);
|
|
16494
|
+
toast(choice === 'ask' ? t('The Memory map asks for the project name again.') : choice === 'never' ? t('The Memory map will not ask again in this project.') : t('The Memory map will not ask until {time}.', { time: clockTime(memoryAccess()?.until) }), { tone: 'done' });
|
|
16495
|
+
renderSettings();
|
|
16496
|
+
}, { label: t('Don\'t ask again') }));
|
|
16497
|
+
|
|
15691
16498
|
// Who may reach which mode: the ceiling of every agent in one table. The mode a message starts in
|
|
15692
16499
|
// stays on each agent's sheet.
|
|
15693
|
-
const who = sxBlock(host, t('Who may do what'), t('The
|
|
16500
|
+
const who = sxBlock(host, t('Who may do what'), t('The most each agent may do. #3 and #4 still ask you first.'), { className: 'perm-who' });
|
|
15694
16501
|
for (const agent of data.agents.filter((one) => one.detected)) {
|
|
15695
16502
|
const scopes = data.settings.capabilities?.[agent.id]?.scopes ?? {};
|
|
15696
16503
|
const cap = scopes.maxMode ?? 1;
|
|
@@ -15711,19 +16518,19 @@ function drawPrivacy(host, data) {
|
|
|
15711
16518
|
if (!priv) return;
|
|
15712
16519
|
const terms = el('textarea', 'sx-input mono');
|
|
15713
16520
|
terms.rows = 4; terms.value = priv.terms.join('\n'); terms.placeholder = t('one term per line · a company, a brand, a domain, a name'); terms.spellcheck = false;
|
|
15714
|
-
terms.setAttribute('aria-label', t('
|
|
16521
|
+
terms.setAttribute('aria-label', t('Words to hide'));
|
|
15715
16522
|
const marker = el('input', 'sx-input mono');
|
|
15716
16523
|
marker.value = priv.marker; marker.maxLength = 40; marker.spellcheck = false;
|
|
15717
16524
|
marker.setAttribute('aria-label', t('Replaced with'));
|
|
15718
16525
|
const exposure = el('span', 'sx-hint', t('Checking the project…'));
|
|
15719
|
-
const purge = sxButton(t('Erase private data'), null, { tone: 'danger', title: t('
|
|
16526
|
+
const purge = sxButton(t('Erase private data'), null, { tone: 'danger', title: t('Hides these words everywhere they were already saved. Asks for the project name.') });
|
|
15720
16527
|
const showExposure = (payload) => {
|
|
15721
16528
|
const x = payload?.exposure;
|
|
15722
16529
|
if (!x) { exposure.textContent = ''; return; }
|
|
15723
16530
|
const total = x.events + x.entries + x.memories;
|
|
15724
16531
|
exposure.textContent = !payload.terms?.length ? t('Write the terms first.') : total
|
|
15725
|
-
? t('Still in
|
|
15726
|
-
: t('
|
|
16532
|
+
? t('Still showing in {events} records · {entries} exchanges · {memories} memories.', { events: x.events, entries: x.entries, memories: x.memories })
|
|
16533
|
+
: t('All clean: none of these words is saved in the project.');
|
|
15727
16534
|
purge.disabled = !payload.terms?.length || !total;
|
|
15728
16535
|
};
|
|
15729
16536
|
const savePrivacy = async (patch, describe) => {
|
|
@@ -15739,19 +16546,19 @@ function drawPrivacy(host, data) {
|
|
|
15739
16546
|
terms.addEventListener('change', () => savePrivacy({ terms: terms.value.split('\n') }, (payload) => t('{n} private {term} guarded from now on.', { n: payload.terms.length, term: payload.terms.length === 1 ? t('term') : t('terms') })));
|
|
15740
16547
|
marker.addEventListener('change', () => savePrivacy({ marker: marker.value }, (payload) => t('private terms appear as {marker}.', { marker: payload.marker })));
|
|
15741
16548
|
|
|
15742
|
-
const named = sxBlock(host, t('
|
|
16549
|
+
const named = sxBlock(host, t('Words to hide'), t("Names you don't want saved or shared: a company, a brand, a domain. MADRE swaps them out before anything is saved or reaches another agent."));
|
|
15743
16550
|
sxRow(named, t('One per line'), null, terms, { stack: true });
|
|
15744
16551
|
sxRow(named, t('Replaced with'), t('What the project shows in their place.'), marker);
|
|
15745
|
-
sxFoot(named, priv.envWins ? t('The terms stay in config.json; the project only records how many. PULSE_PRIVATE_TERMS is set, so its terms are added on every launch.') : t('The
|
|
16552
|
+
sxFoot(named, priv.envWins ? t('The terms stay in config.json; the project only records how many. PULSE_PRIVATE_TERMS is set, so its terms are added on every launch.') : t('The list stays on this computer; the project only notes how many words it has.'));
|
|
15746
16553
|
|
|
15747
16554
|
// Two guards that need no list, because what they catch has a shape rather than a name.
|
|
15748
|
-
const shaped = sxBlock(host, t('
|
|
16555
|
+
const shaped = sxBlock(host, t('Hidden automatically'), t('No list needed: MADRE spots these on its own.'));
|
|
15749
16556
|
const guard = (key, label, hint, describe) => sxRow(shaped, label, hint, sxSwitch(priv[key] !== false, (on) => savePrivacy({ [key]: on }, () => `${describe} ${on ? t('on') : t('off')}.`), { label }));
|
|
15750
|
-
guard('secrets', t('
|
|
15751
|
-
guard('paths', t('Your home
|
|
16557
|
+
guard('secrets', t('Access keys and emails'), t('Access keys, service tokens and email addresses are crossed out wherever they appear.'), t('redaction of keys and e-mail addresses'));
|
|
16558
|
+
guard('paths', t('Your home folder'), t('/Users/yourname becomes ~: a quoted path still reads, it just stops naming you.'), t('hiding of home paths'));
|
|
15752
16559
|
|
|
15753
16560
|
const already = sxBlock(host, t('Already in the project'), t('What was recorded before a term was named stays as it was until you erase it.'));
|
|
15754
|
-
const purgeRow = sxRow(already, t('
|
|
16561
|
+
const purgeRow = sxRow(already, t('Still visible'), null, purge);
|
|
15755
16562
|
purgeRow.querySelector('.sx-words').append(exposure);
|
|
15756
16563
|
purge.addEventListener('click', async () => {
|
|
15757
16564
|
const designation = await roomAsk({
|
|
@@ -15908,8 +16715,30 @@ function nostromoTick(now) {
|
|
|
15908
16715
|
|
|
15909
16716
|
function nostromoDesignation() { return projectDesignation(); }
|
|
15910
16717
|
|
|
16718
|
+
// How long the Memory map may open without asking for the project name (Settings › Permissions ›
|
|
16719
|
+
// Memory access): never stored past its hour, and only in this browser for this project.
|
|
16720
|
+
const MEMORY_ACCESS_KEY = 'pulse.memoryAccess';
|
|
16721
|
+
const MEMORY_ACCESS = [['ask', () => t('Always ask')], ['60', () => t('{n} h', { n: 1 })], ['480', () => t('{n} h', { n: 8 })], ['1440', () => t('{n} h', { n: 24 })], ['never', () => t('Never')]];
|
|
16722
|
+
function memoryAccess() {
|
|
16723
|
+
try {
|
|
16724
|
+
const held = JSON.parse(localStorage.getItem(MEMORY_ACCESS_KEY) ?? 'null');
|
|
16725
|
+
if (held?.choice === 'never' || (held && Number(held.until) > Date.now())) return held;
|
|
16726
|
+
} catch { /* no storage: it asks */ }
|
|
16727
|
+
return null;
|
|
16728
|
+
}
|
|
16729
|
+
function setMemoryAccess(choice) {
|
|
16730
|
+
try {
|
|
16731
|
+
if (choice === 'ask') localStorage.removeItem(MEMORY_ACCESS_KEY);
|
|
16732
|
+
else localStorage.setItem(MEMORY_ACCESS_KEY, JSON.stringify({ choice, until: choice === 'never' ? null : Date.now() + Number(choice) * 60000 }));
|
|
16733
|
+
} catch { /* no storage: it keeps asking */ }
|
|
16734
|
+
}
|
|
16735
|
+
function clockTime(at) { return new Date(at).toLocaleTimeString(language(), { hour: '2-digit', minute: '2-digit' }); }
|
|
16736
|
+
function memoryAccessLine(held) {
|
|
16737
|
+
if (!held) return t('It asks every time.');
|
|
16738
|
+
return held.choice === 'never' ? t('It does not ask in this project.') : t('It does not ask until {time}.', { time: clockTime(held.until) });
|
|
16739
|
+
}
|
|
15911
16740
|
nostromo.button?.addEventListener('click', () => {
|
|
15912
|
-
if (nostromo.armed) { void openNostromo(); return; }
|
|
16741
|
+
if (nostromo.armed || memoryAccess()) { void openNostromo(); return; }
|
|
15913
16742
|
if (!nostromo.gate.dialog) return;
|
|
15914
16743
|
nostromo.gate.reply.textContent = '';
|
|
15915
16744
|
nostromo.gate.reply.className = 'key-reply';
|
|
@@ -15920,6 +16749,11 @@ nostromo.button?.addEventListener('click', () => {
|
|
|
15920
16749
|
// NOSTROMO also lives in the bar: the same door, from anywhere in the room.
|
|
15921
16750
|
document.querySelector('#nostromo-bar')?.addEventListener('click', () => nostromo.button?.click());
|
|
15922
16751
|
nostromo.gate.cancel?.addEventListener('click', () => nostromo.gate.dialog.close());
|
|
16752
|
+
// «Don't ask again» goes straight to where that is chosen: Settings › Permissions › Memory access.
|
|
16753
|
+
document.querySelector('#nostromo-gate-skip')?.addEventListener('click', () => {
|
|
16754
|
+
nostromo.gate.dialog.close();
|
|
16755
|
+
openSettingsOn('permissions', { anchor: 'perm-memory' });
|
|
16756
|
+
});
|
|
15923
16757
|
nostromo.gate.form?.addEventListener('submit', (event) => {
|
|
15924
16758
|
event.preventDefault();
|
|
15925
16759
|
const typed = nostromo.gate.input.value.trim();
|
|
@@ -15937,7 +16771,8 @@ nostromo.gate.form?.addEventListener('submit', (event) => {
|
|
|
15937
16771
|
});
|
|
15938
16772
|
|
|
15939
16773
|
// What each class of memory is called as a star, for the legend above the constellation.
|
|
15940
|
-
|
|
16774
|
+
// The star each kind burns as; its title adds the kind, so the label does not repeat it.
|
|
16775
|
+
const DWARF_CLASS = { decision: t('YELLOW STAR'), fact: t('WHITE STAR'), preference: t('GREEN STAR'), question: t('BLUE STAR'), aberration: t('COLLAPSED') };
|
|
15941
16776
|
|
|
15942
16777
|
// The legend's stars (nostromo-engine.js: dwarfChip, collapsedChip) are the Project map's too.
|
|
15943
16778
|
// The legend: one real star per class, with the kind of memory that burns as that class.
|
|
@@ -15956,8 +16791,8 @@ function paintLegend() {
|
|
|
15956
16791
|
// say so. What a zone changes is whether the star is lit, which is one more thing to name here
|
|
15957
16792
|
// rather than a second legend to read.
|
|
15958
16793
|
const dim = el('span', 'k dim');
|
|
15959
|
-
dim.append(dwarfChip(hexMix(MEMORY_COLORS.fact, '#0b0f16', 0.52), 14), el('i', null, t('
|
|
15960
|
-
dim.title = t('A faded star is
|
|
16794
|
+
dim.append(dwarfChip(hexMix(MEMORY_COLORS.fact, '#0b0f16', 0.52), 14), el('i', null, t('NOT USED')));
|
|
16795
|
+
dim.title = t('A faded star is a quarantined or discarded memory: it is kept, but never used in answers.');
|
|
15961
16796
|
chips.push(dim);
|
|
15962
16797
|
host.replaceChildren(...chips);
|
|
15963
16798
|
host.dataset.lit = 'yes';
|
|
@@ -16104,7 +16939,7 @@ function buildNostromo(data) {
|
|
|
16104
16939
|
}
|
|
16105
16940
|
|
|
16106
16941
|
// Written out here and not inside the renderer, where `t` is the clock and not the language.
|
|
16107
|
-
const lockedLabel = (memory) => `${t('
|
|
16942
|
+
const lockedLabel = (memory) => `${t('MEMORY SELECTED')} · #${memory.id}`;
|
|
16108
16943
|
|
|
16109
16944
|
// ---- NOSTROMO on a phone: the chart.
|
|
16110
16945
|
//
|
|
@@ -16815,14 +17650,14 @@ function showNostromoCard(node) {
|
|
|
16815
17650
|
links.replaceChildren();
|
|
16816
17651
|
if (!neighbours.length) links.append(el('li', 'none', t('No theme shared with another memory yet.')));
|
|
16817
17652
|
for (const { link, node: other } of neighbours) {
|
|
16818
|
-
links.append(wireRow(other, `${Math.round(link.weight * 100)}%`, { link, title: `${kindWord(other.memory.kind)} · ${t('{pct}%
|
|
17653
|
+
links.append(wireRow(other, `${Math.round(link.weight * 100)}%`, { link, title: `${kindWord(other.memory.kind)} · ${t('{pct}% similar in meaning', { pct: Math.round(link.weight * 100) })}\n${other.memory.text}` }));
|
|
16819
17654
|
}
|
|
16820
17655
|
|
|
16821
17656
|
// And what has actually passed between it and the room. Filled from the archive, then kept up.
|
|
16822
17657
|
cardPart(card, 'exchange').textContent = t('READING…');
|
|
16823
17658
|
cardPart(card, 'fired').replaceChildren();
|
|
16824
17659
|
const forget = cardPart(card, 'forget');
|
|
16825
|
-
forget.textContent = memory.kind === 'aberration' ? t('
|
|
17660
|
+
forget.textContent = memory.kind === 'aberration' ? t('REMOVE THIS DOUBTFUL MEMORY') : t('FORGET THIS MEMORY');
|
|
16826
17661
|
forget.classList.remove('confirm');
|
|
16827
17662
|
forget.disabled = false;
|
|
16828
17663
|
void nostromoTraffic(card);
|
|
@@ -16863,7 +17698,7 @@ async function nostromoTraffic(card = frontCard()) {
|
|
|
16863
17698
|
said.push(t('last turn: {who} · {when}', { who: last.agent ? `@${last.agent}` : t('the project'), when: agoWords(last.at) }));
|
|
16864
17699
|
} else if (traffic.recalled && traffic.since) {
|
|
16865
17700
|
// The counters are older than the trail. Saying nothing here would read as "always alone".
|
|
16866
|
-
said.push(t('
|
|
17701
|
+
said.push(t('MADRE has tracked what it is used with since {when} · nothing yet', { when: new Date(traffic.since).toLocaleDateString() }));
|
|
16867
17702
|
}
|
|
16868
17703
|
cardPart(card, 'exchange').textContent = said.join('\n');
|
|
16869
17704
|
|
|
@@ -16876,12 +17711,12 @@ async function nostromoTraffic(card = frontCard()) {
|
|
|
16876
17711
|
const other = byId.get(mate.id);
|
|
16877
17712
|
if (!other) continue;
|
|
16878
17713
|
const link = nostromo.links.find((one) => (one.a === id && one.b === mate.id) || (one.b === id && one.a === mate.id)) ?? null;
|
|
16879
|
-
const row = wireRow(other, `×${mate.times}`, { link, title: `${t('
|
|
17714
|
+
const row = wireRow(other, `×${mate.times}`, { link, title: `${t('Used together in the same turn {n} {times} · last {when}', { n: mate.times, times: mate.times === 1 ? t('time') : t('times'), when: agoWords(mate.last) })}${mate.cascades ? `\n${t('Strong enough that recalling this one now brings it along ({pct}%).', { pct: Math.round(mate.strength * 100) })}` : ''}\n${other.memory.text}` });
|
|
16880
17715
|
if (mate.cascades) row.classList.add('carries');
|
|
16881
17716
|
fired.append(row);
|
|
16882
17717
|
}
|
|
16883
17718
|
if (!(traffic.fired ?? []).length && traffic.recalled) {
|
|
16884
|
-
fired.append(el('li', 'none', traffic.recent?.length ? t('It has always
|
|
17719
|
+
fired.append(el('li', 'none', traffic.recent?.length ? t('It has always been used alone.') : t('No turn has carried it since the project started keeping this trail.')));
|
|
16885
17720
|
}
|
|
16886
17721
|
// What a refutation did, from whichever end this memory is on.
|
|
16887
17722
|
for (const taken of traffic.refutes ?? []) {
|
|
@@ -16890,7 +17725,7 @@ async function nostromoTraffic(card = frontCard()) {
|
|
|
16890
17725
|
const dot = el('i');
|
|
16891
17726
|
dot.style.setProperty('--kind', MEMORY_COLORS[taken.kind] ?? MEMORY_COLORS.fact);
|
|
16892
17727
|
row.append(dot, el('span', 'what', taken.text), el('b', null, t('TAKEN DOWN')));
|
|
16893
|
-
row.title = t('This
|
|
17728
|
+
row.title = t('This doubtful memory took that memory out of every future answer.');
|
|
16894
17729
|
if (other) row.addEventListener('click', () => showNostromoCard(other));
|
|
16895
17730
|
fired.append(row);
|
|
16896
17731
|
}
|
|
@@ -16900,7 +17735,7 @@ async function nostromoTraffic(card = frontCard()) {
|
|
|
16900
17735
|
const dot = el('i');
|
|
16901
17736
|
dot.style.setProperty('--kind', MEMORY_COLORS[traffic.refutedBy.kind] ?? MEMORY_COLORS.aberration);
|
|
16902
17737
|
row.append(dot, el('span', 'what', traffic.refutedBy.text), el('b', null, t('REFUTES IT')));
|
|
16903
|
-
row.title = t('
|
|
17738
|
+
row.title = t('A doubtful memory took this memory out of every future answer.');
|
|
16904
17739
|
if (other) row.addEventListener('click', () => showNostromoCard(other));
|
|
16905
17740
|
fired.append(row);
|
|
16906
17741
|
}
|
|
@@ -17032,7 +17867,7 @@ const FIRST_TOUCH = {
|
|
|
17032
17867
|
button: '#mother-button',
|
|
17033
17868
|
panel: '#mother',
|
|
17034
17869
|
title: t('Diagnostics'),
|
|
17035
|
-
text: t('When something fails, describe it in your own words: Diagnostics says what is going on and gives you the
|
|
17870
|
+
text: t('When something fails, describe it in your own words: Diagnostics says what is going on and gives you the button that fixes it.'),
|
|
17036
17871
|
},
|
|
17037
17872
|
memory: {
|
|
17038
17873
|
button: '#nostromo-bar',
|
|
@@ -17085,6 +17920,7 @@ function firstTouch(id) {
|
|
|
17085
17920
|
}
|
|
17086
17921
|
function showFirstTouch(hint, button) {
|
|
17087
17922
|
document.querySelector('.hint-card')?.remove();
|
|
17923
|
+
document.querySelectorAll('.hint-spot').forEach((node) => node.remove());
|
|
17088
17924
|
const card = el('section', 'hint-card');
|
|
17089
17925
|
card.setAttribute('role', 'dialog');
|
|
17090
17926
|
card.setAttribute('aria-label', hint.title);
|
|
@@ -17096,8 +17932,8 @@ function showFirstTouch(hint, button) {
|
|
|
17096
17932
|
if (modal) card.classList.add('in-modal');
|
|
17097
17933
|
// Placed once the workspace has opened, because opening it moves things: the bar narrows when
|
|
17098
17934
|
// the browser docks, and the conversations button steps aside for its own panel. Under the
|
|
17099
|
-
// button,
|
|
17100
|
-
//
|
|
17935
|
+
// button, which the spotlight lights, while the button is there; beside the panel it opened
|
|
17936
|
+
// when the button is gone.
|
|
17101
17937
|
const place = () => {
|
|
17102
17938
|
const box = button.getBoundingClientRect();
|
|
17103
17939
|
const width = card.offsetWidth;
|
|
@@ -17111,13 +17947,19 @@ function showFirstTouch(hint, button) {
|
|
|
17111
17947
|
}
|
|
17112
17948
|
const left = Math.min(window.innerWidth - width - 12, Math.max(12, box.left + box.width / 2 < window.innerWidth / 2 ? box.left : box.right - width));
|
|
17113
17949
|
card.style.left = `${left}px`;
|
|
17114
|
-
|
|
17115
|
-
|
|
17950
|
+
// A button at the foot of the window (the robot under the box) gets its card above it.
|
|
17951
|
+
const above = box.bottom + 12 + card.offsetHeight > window.innerHeight - 8;
|
|
17952
|
+
card.classList.toggle('above', above);
|
|
17953
|
+
card.style.top = `${above ? Math.max(12, box.top - 12 - card.offsetHeight) : box.bottom + 12}px`;
|
|
17116
17954
|
};
|
|
17117
17955
|
card.style.visibility = 'hidden';
|
|
17118
|
-
|
|
17956
|
+
// No beak: the place it speaks of is lit and the rest dims a little. Inside a modal window the
|
|
17957
|
+
// card is in the top layer and a ring under it would sit behind the window, so none there.
|
|
17958
|
+
const spot = modal ? null : spotlight(button);
|
|
17959
|
+
const show = () => { place(); spot?.place(); card.style.visibility = ''; };
|
|
17119
17960
|
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => requestAnimationFrame(show)); else show();
|
|
17120
17961
|
const close = () => {
|
|
17962
|
+
spot?.remove();
|
|
17121
17963
|
card.remove();
|
|
17122
17964
|
document.removeEventListener('pointerdown', outside, true);
|
|
17123
17965
|
document.removeEventListener('keydown', escape, true);
|
|
@@ -17133,6 +17975,48 @@ function showFirstTouch(hint, button) {
|
|
|
17133
17975
|
}
|
|
17134
17976
|
for (const id of Object.keys(FIRST_TOUCH)) document.querySelector(FIRST_TOUCH[id].button)?.addEventListener('click', () => firstTouch(id));
|
|
17135
17977
|
|
|
17978
|
+
// What a small version brings is said with the same cards, once each, on their own: no welcome and
|
|
17979
|
+
// no tour (1.0.2: the copilot, outside tools, the Finder, the memory's door and the folded
|
|
17980
|
+
// notices). Only when the room is quiet — no dialog open, no
|
|
17981
|
+
// other card — one after the other, each pointing at where it lives.
|
|
17982
|
+
const NEW_HINTS = [
|
|
17983
|
+
{ id: 'new-copilot', button: '.copilot-chip', title: t('New: Copilot'), text: t('Let an agent work on its own for a while. Press the robot, choose how long, and the agent you write to can change files, make plans and run safe commands without asking you each time. It asks for your permission only once.') },
|
|
17984
|
+
{ id: 'new-mcp', button: '#custom-button', panel: '#settings', title: t('New: Outside tools (MCP)'), text: t('Agents can now use other apps and services: a program on this computer or one on the internet. Add them in Settings › Outside tools (MCP). If you already set some up in Claude Code, Codex, Gemini or Cursor, you can bring them over.') },
|
|
17985
|
+
{ id: 'new-finder', button: '#tree-button', spill: '.bar-more', title: t('New: your files stay up to date'), text: t('The file list updates by itself after every answer and every command. A colored dot on a folder tells you which agent changed something inside.') },
|
|
17986
|
+
{ id: 'new-memory-access', button: '#nostromo-bar', spill: '.bar-more', title: t('New: open the memory faster'), text: t('The Memory map asks you to type the project name before it opens. You can skip that step for 1, 8 or 24 hours, or for good: choose «Don\'t ask again…» when it asks, or go to Settings › Permissions.') },
|
|
17987
|
+
{ id: 'new-dots', button: '#messages .sys-dots:not(.open)', onScreen: true, title: t('New: notices shrink into dots'), text: t('To keep the chat clean, notices shrink into a small dot after 10 seconds. Click the dot to read them again. The ones that need something from you stay open.') },
|
|
17988
|
+
];
|
|
17989
|
+
// Asked again while a card has nothing to point at, a few times only: a timer that never stops would
|
|
17990
|
+
// keep a page (and a test) alive for nothing.
|
|
17991
|
+
let newHintTries = 0;
|
|
17992
|
+
function showNewHints(delay = 2500) {
|
|
17993
|
+
if (state.device) return;
|
|
17994
|
+
// Unreferenced where a timer can be (Node, in the tests): a card that waits never keeps a process up.
|
|
17995
|
+
setTimeout(() => {
|
|
17996
|
+
// Where it lives, or «more» when the bar keeps that key behind it.
|
|
17997
|
+
const shown = (selector) => { const node = selector && document.querySelector(selector); return node?.getClientRects().length ? node : null; };
|
|
17998
|
+
// Something in the conversation is pointed at where the person is reading: the latest one in
|
|
17999
|
+
// view, not the first of the thread (that one is at the very top, scrolled away).
|
|
18000
|
+
const inView = (selector) => [...document.querySelectorAll(selector)].filter((node) => {
|
|
18001
|
+
const box = node.getBoundingClientRect();
|
|
18002
|
+
return box.width && box.bottom > 60 && box.top < window.innerHeight - 160;
|
|
18003
|
+
}).at(-1) ?? null;
|
|
18004
|
+
const anchorOf = (hint) => (hint.onScreen ? inView(hint.button) : shown(hint.button)) ?? shown(hint.spill);
|
|
18005
|
+
const pending = NEW_HINTS.filter((hint) => !hintsSeen()[hint.id]);
|
|
18006
|
+
const next = pending.find(anchorOf);
|
|
18007
|
+
// Nothing to point at yet (no folded notice, a key not on screen): asked again later.
|
|
18008
|
+
if (!next) { if (pending.length && (newHintTries += 1) <= 10) showNewHints(30000); return; }
|
|
18009
|
+
// Not over something the person is already reading: tried again later, never stacked.
|
|
18010
|
+
if (document.querySelector('dialog[open], .hint-card') || !els.onboarding.hidden) { showNewHints(8000); return; }
|
|
18011
|
+
try { localStorage.setItem(HINTS_KEY, JSON.stringify({ ...hintsSeen(), [next.id]: true })); } catch { return; }
|
|
18012
|
+
const behind = !next.onScreen && !shown(next.button);
|
|
18013
|
+
showFirstTouch(behind ? { ...next, text: `${next.text} ${t('You\'ll find it under «More», on the top bar.')}` } : next, anchorOf(next));
|
|
18014
|
+
const card = document.querySelector('.hint-card');
|
|
18015
|
+
const watch = setInterval(() => { if (!card?.isConnected) { clearInterval(watch); showNewHints(600); } }, 400);
|
|
18016
|
+
watch?.unref?.();
|
|
18017
|
+
}, delay)?.unref?.();
|
|
18018
|
+
}
|
|
18019
|
+
|
|
17136
18020
|
/* ---------- What's new: once per version, after an update. ---------- */
|
|
17137
18021
|
|
|
17138
18022
|
// What a version brings, said after the update: a board of the things it brings, each a card with
|
|
@@ -17368,7 +18252,7 @@ function renderUpdate() {
|
|
|
17368
18252
|
function drawRelease(section, info) {
|
|
17369
18253
|
section.replaceChildren();
|
|
17370
18254
|
if (!info) { sxBlock(section, t('Version'), t('The version could not be read.')); return; }
|
|
17371
|
-
const where = { npx: t('
|
|
18255
|
+
const where = { npx: t('Runs without being installed (npx).'), project: t("Installed inside this project."), global: t('Installed on this computer.') }[info.install] ?? t('It runs from source.');
|
|
17372
18256
|
const chip = info.available ? sxChip(t('{version} available', { version: info.latest }), 'warn') : info.latest ? sxChip(t('Up to date'), 'on') : null;
|
|
17373
18257
|
const block = sxBlock(section, `MADRE ${info.current}`, where, { aside: chip });
|
|
17374
18258
|
// What this version brought, again: the board that opened after the update, for whoever closed
|
|
@@ -17380,16 +18264,17 @@ function drawRelease(section, info) {
|
|
|
17380
18264
|
const when = info.checkedAt ? new Date(info.checkedAt).toLocaleString() : null;
|
|
17381
18265
|
const check = sxSwitch(info.enabled, async (on, box) => {
|
|
17382
18266
|
box.disabled = true;
|
|
17383
|
-
try { await fetch('/api/updates/settings', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ check: on }) }); toast(on ? t('Diagnostics ›
|
|
18267
|
+
try { await fetch('/api/updates/settings', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ check: on }) }); toast(on ? t('Diagnostics › Look for new versions on: one check a day.') : t('Diagnostics › Look for new versions off: no request leaves for npm.')); await loadVersion(); }
|
|
17384
18268
|
catch (error) { box.checked = !on; box.disabled = false; toast(t('The setting was not saved: {error}', { error: error.message })); }
|
|
17385
18269
|
}, { disabled: Boolean(info.envWins), label: t('Look for new versions') });
|
|
17386
|
-
sxRow(block, t('Look for new versions'), t('Once a day MADRE
|
|
18270
|
+
sxRow(block, t('Look for new versions'), t('Once a day MADRE checks whether there\'s a new version. Only the app\'s name is sent.') + (info.envWins ? ` ${t('Set by PULSE_UPDATE_CHECK.')}` : ''), check);
|
|
17387
18271
|
if (info.enabled) sxRow(block, t('Last check'), when ?? t('Not yet.'), sxButton(t('Check now'), () => void loadVersion({ force: true })));
|
|
18272
|
+
drawLegal(section);
|
|
17388
18273
|
|
|
17389
18274
|
if (!info.available) return;
|
|
17390
18275
|
const canRestart = info.install !== 'source';
|
|
17391
18276
|
const fresh = sxBlock(section, t('MADRE {version} is ready', { version: info.latest }), canRestart
|
|
17392
|
-
? t('Restart
|
|
18277
|
+
? t('Restart here: the project closes, updates and comes back in a few seconds. Nothing is lost. Or run the command yourself.')
|
|
17393
18278
|
: t('Pull the repository and start it again.'), { className: 'sx-fresh' });
|
|
17394
18279
|
if (canRestart) {
|
|
17395
18280
|
const restart = sxButton(updateUI.restarting ? t('Restarting…') : t('Restart with {version}', { version: info.latest }), async () => {
|
|
@@ -17476,7 +18361,7 @@ function renderTurns(section) {
|
|
|
17476
18361
|
};
|
|
17477
18362
|
sxRow(block, t('Several at once'), turns.enabled ? t('Several conversations may work at once.') : t('One conversation at a time; switching away from one at work is refused.'), sxSwitch(turns.enabled, (on) => save({ enabled: on }), { label: t('Several at once') }));
|
|
17478
18363
|
sxRow(block, t('Conversations at once'), t('How many conversations may be working at the same time.'), sxStepper(turns.chats, { max: 6, disabled: !turns.enabled }, (n) => save({ chats: n })), { off: !turns.enabled });
|
|
17479
|
-
sxRow(block, t('
|
|
18364
|
+
sxRow(block, t('Answers per conversation'), t('How many answers may be under way inside one conversation.'), sxStepper(turns.turnsPerChat, { max: 8, disabled: !turns.enabled }, (n) => save({ turnsPerChat: n })), { off: !turns.enabled });
|
|
17480
18365
|
const notes = notificationsState();
|
|
17481
18366
|
const ask = sxButton(notes === 'granted' ? t('On') : notes === 'denied' ? t('Blocked') : notes === 'unsupported' ? t('Not available') : t('Turn on'), async () => { await askForNotifications(); renderMotherSentinel(); }, { tone: notes === 'default' ? 'primary' : null });
|
|
17482
18367
|
ask.disabled = notes !== 'default';
|
|
@@ -17497,18 +18382,18 @@ function drawSentinel(section) {
|
|
|
17497
18382
|
const reports = [...state.reports.values()].sort((a, b) => (a.at < b.at ? 1 : -1));
|
|
17498
18383
|
const unsent = reports.filter((report) => !report.sent?.ok).length;
|
|
17499
18384
|
const settings = sentinelUI.settings ?? { autoReport: false, canSend: false, repo: null };
|
|
17500
|
-
const block = sxBlock(section, t('
|
|
18385
|
+
const block = sxBlock(section, t('Error reports'), t('Keeps errors MADRE can\'t explain, with private details removed. Nothing leaves this computer unless you send it.'), {
|
|
17501
18386
|
aside: reports.length ? sxChip(t('{n} reports · {unsent} not sent', { n: reports.length, unsent }), unsent ? 'warn' : '') : sxChip(t('Nothing to report'), 'on'),
|
|
17502
18387
|
});
|
|
17503
|
-
sxRow(block, t('
|
|
18388
|
+
sxRow(block, t('Send reports automatically'), settings.canSend ? t("New errors go to MADRE's author, with private details removed.") : t('Sending isn\'t set up in this copy of MADRE.'), sxSwitch(settings.autoReport, async (on, box) => {
|
|
17504
18389
|
box.disabled = true;
|
|
17505
18390
|
try {
|
|
17506
18391
|
const result = await fetch('/api/sentinel/settings', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ autoReport: on }) }).then((response) => response.json());
|
|
17507
18392
|
sentinelUI.settings = result.settings;
|
|
17508
|
-
toast(result.settings.autoReport ? t("Diagnostics ›
|
|
18393
|
+
toast(result.settings.autoReport ? t("Diagnostics › error reports on: new errors go to MADRE's author, with private details removed.") : t('Diagnostics › auto-report off: reports stay here until you send one.'));
|
|
17509
18394
|
} catch (error) { box.checked = !on; toast(t('It could not be saved: {error}', { error: error.message })); }
|
|
17510
18395
|
finally { renderMotherSentinel(); }
|
|
17511
|
-
}, { disabled: !settings.canSend, label: t('
|
|
18396
|
+
}, { disabled: !settings.canSend, label: t('Send reports automatically') }), { off: !settings.canSend });
|
|
17512
18397
|
// Whoever arrived from a post in Spanish will not make a GitHub account to say "it does not
|
|
17513
18398
|
// detect Codex". The form leads there; the issue stays one click away, and the other way round
|
|
17514
18399
|
// in English, where the person reading this is already holding an account.
|
|
@@ -17519,7 +18404,7 @@ function drawSentinel(section) {
|
|
|
17519
18404
|
both.append(...(spanish ? [viaForm, viaIssue] : [viaIssue, viaForm]));
|
|
17520
18405
|
sxRow(block, t('Tell the author'), t('What failed, or what you wish MADRE did.'), both);
|
|
17521
18406
|
if (!reports.length) return;
|
|
17522
|
-
const list = sxBlock(section, t('Reports'), t('
|
|
18407
|
+
const list = sxBlock(section, t('Reports'), t('Private details are removed from each one. You can read each report before sending it.'));
|
|
17523
18408
|
for (const report of reports.slice(0, 20)) {
|
|
17524
18409
|
const who = report.kind === 'crash' ? t('Crash') : `@${report.agent ?? t('project')}`;
|
|
17525
18410
|
const line = String(report.error).split('\n')[0].slice(0, 200);
|
|
@@ -17585,7 +18470,7 @@ const BAR_BUILT = {
|
|
|
17585
18470
|
// They ride with the crew when it is there, and lead the keys when it is not: where they always were.
|
|
17586
18471
|
const BAR_CONTEXTUAL = ['#steps-pill', '#tour-pill-slot', '#handoff-pill', '#update-pill', '#stop-all'];
|
|
17587
18472
|
// A module's name before the board has answered; once it has, the module says its own.
|
|
17588
|
-
const BAR_MODULE_NAMES = { ash: 'Token saver', ollama: 'Local AI', intents: '
|
|
18473
|
+
const BAR_MODULE_NAMES = { ash: 'Token saver', ollama: 'Local AI', intents: 'Suggested effort' };
|
|
17589
18474
|
const BAR_MINUS = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4.5 8h7" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
|
|
17590
18475
|
const barKit = {
|
|
17591
18476
|
node: document.querySelector('#bar'),
|
|
@@ -17640,7 +18525,7 @@ function barCatalogue() {
|
|
|
17640
18525
|
const nodes = spec.nodes.map((query) => document.querySelector(query)).filter(Boolean);
|
|
17641
18526
|
if (nodes.length) out.set(id, { id, name: spec.name(), nodes });
|
|
17642
18527
|
}
|
|
17643
|
-
out.set('memory', { id: 'memory', name: t('
|
|
18528
|
+
out.set('memory', { id: 'memory', name: t('What it remembers'), icon: SETTINGS_CATS.memory.icon, open: () => openSettingsOn('memory') });
|
|
17644
18529
|
// SALAS, every room on this Mac (docs/MACROVERSO.md). On a phone the way back to them is already
|
|
17645
18530
|
// at the start of the bar.
|
|
17646
18531
|
// On the Mac the way out is the house in the second bar; a key here would say it twice.
|
|
@@ -18040,6 +18925,11 @@ function barSpills(bar) {
|
|
|
18040
18925
|
const left = bar.querySelector?.(':scope > .bar-left');
|
|
18041
18926
|
const name = left?.querySelector?.('#project');
|
|
18042
18927
|
if (left && name?.getClientRects?.().length && name.getBoundingClientRect().right > left.getBoundingClientRect().right + 0.5) return true;
|
|
18928
|
+
// And the open conversation's name beside it (2026-10-14): when a pill appeared (a new version
|
|
18929
|
+
// on npm) it was the one to give way, down to a sliver, and the bar said nothing spilled. It may
|
|
18930
|
+
// be cut short with an ellipsis, never below a readable width.
|
|
18931
|
+
const chat = [...(left?.querySelectorAll?.('.project') ?? [])].at(-1);
|
|
18932
|
+
if (chat && chat !== name && chat.getClientRects?.().length && chat.scrollWidth > chat.clientWidth + 1 && chat.clientWidth < Math.min(120, chat.scrollWidth)) return true;
|
|
18043
18933
|
if (typeof getComputedStyle !== 'function') return false;
|
|
18044
18934
|
const end = bar.getBoundingClientRect().right - (parseFloat(getComputedStyle(bar).paddingRight) || 0);
|
|
18045
18935
|
for (const node of bar.children ?? []) {
|
|
@@ -18840,3 +19730,253 @@ if (!SPLIT_CHAT) startReviewAsk({ busy: () => Boolean(state.device || state.runn
|
|
|
18840
19730
|
// The tour of 1.0 reads where the person is as the room opens, so First steps knows whether to
|
|
18841
19731
|
// wait; on a phone, the phone's card is said the first time.
|
|
18842
19732
|
if (!SPLIT_CHAT) roomTour();
|
|
19733
|
+
|
|
19734
|
+
/* ---------- PRUEBA · respuestas sin burbuja (2026-10-15) ---------- */
|
|
19735
|
+
// Para comparar formas de respuesta sin burbuja con la de siempre, en una sala de verdad, sobre su
|
|
19736
|
+
// fondo. Nadie lo ve si no abre la sala con `?reply=`: doc, doc-inline, pieces, pieces-edge o bubble,
|
|
19737
|
+
// y `&lane=` lane, column, halo, stripe o 0. Lo elegido se recuerda en este navegador; `?reply=off`
|
|
19738
|
+
// lo apaga y esconde el selector.
|
|
19739
|
+
(() => {
|
|
19740
|
+
const FORMS = [['A', 'doc', 'A · regla'], ['B', 'doc-inline', 'B · avatar en línea'], ['C', 'pieces', 'C · tarjeta'], ['D', 'pieces-edge', 'D · filo'], ['E', 'bubble', 'E · burbuja']];
|
|
19741
|
+
const LANES = [['1', 'lane', '1 · difuso'], ['2', 'column', '2 · columna'], ['3', 'halo', '3 · halo'], ['4', 'stripe', '4 · franja'], ['5', '0', '5 · sin carril']];
|
|
19742
|
+
const TERMS = [['0', 'actual', 'Actual'], ['a', 'line', 'a · línea'], ['b', 'window', 'b · ventana'], ['c', 'pill', 'c · píldora'], ['d', 'step', 'd · paso']];
|
|
19743
|
+
// Desde ACCC-BABA-ECBB-DCAA-C (2026-10-15) la respuesta va en burbuja y cada /run en píldora. Lo que
|
|
19744
|
+
// se eligió en el selector solo vale mientras se compara: sin ?reply ni ?combo, la forma de siempre.
|
|
19745
|
+
let reply = 'bubble';
|
|
19746
|
+
let lane = 'lane';
|
|
19747
|
+
let term = 'pill';
|
|
19748
|
+
let showLab = false;
|
|
19749
|
+
try {
|
|
19750
|
+
const asked = new URLSearchParams(location.search);
|
|
19751
|
+
if (asked.get('reply') === 'off') { for (const key of ['pulse.reply', 'pulse.reply.lane', 'pulse.reply.term']) localStorage.removeItem(key); }
|
|
19752
|
+
else {
|
|
19753
|
+
// ?combo=C1b: la forma, el carril y la terminal en un código.
|
|
19754
|
+
const combo = /^([A-E])([1-5])?([0a-d])?$/.exec(asked.get('combo') ?? '');
|
|
19755
|
+
if (combo) {
|
|
19756
|
+
localStorage.setItem('pulse.reply', FORMS.find(([code]) => code === combo[1])[1]);
|
|
19757
|
+
if (combo[2]) localStorage.setItem('pulse.reply.lane', LANES.find(([code]) => code === combo[2])[1]);
|
|
19758
|
+
if (combo[3]) localStorage.setItem('pulse.reply.term', TERMS.find(([code]) => code === combo[3])[1]);
|
|
19759
|
+
}
|
|
19760
|
+
if (asked.has('reply')) localStorage.setItem('pulse.reply', asked.get('reply'));
|
|
19761
|
+
if (asked.has('lane')) localStorage.setItem('pulse.reply.lane', asked.get('lane'));
|
|
19762
|
+
if (asked.has('term')) localStorage.setItem('pulse.reply.term', asked.get('term'));
|
|
19763
|
+
}
|
|
19764
|
+
showLab = (asked.has('reply') && asked.get('reply') !== 'off') || asked.has('combo');
|
|
19765
|
+
if (showLab) {
|
|
19766
|
+
reply = localStorage.getItem('pulse.reply') ?? reply;
|
|
19767
|
+
lane = localStorage.getItem('pulse.reply.lane') ?? lane;
|
|
19768
|
+
term = localStorage.getItem('pulse.reply.term') ?? term;
|
|
19769
|
+
}
|
|
19770
|
+
if (lane === '1') lane = 'lane';
|
|
19771
|
+
} catch { /* sin almacenamiento: la forma de siempre */ }
|
|
19772
|
+
if (!FORMS.some(([, value]) => value === reply)) reply = 'bubble';
|
|
19773
|
+
if (!TERMS.some(([, value]) => value === term)) term = 'pill';
|
|
19774
|
+
const root = document.documentElement;
|
|
19775
|
+
const apply = () => {
|
|
19776
|
+
if (reply === 'bubble') delete root.dataset.reply; else root.dataset.reply = reply;
|
|
19777
|
+
if (lane === '0' || reply === 'bubble') delete root.dataset.replyLegible; else root.dataset.replyLegible = lane;
|
|
19778
|
+
if (term === 'actual') delete root.dataset.term; else root.dataset.term = term;
|
|
19779
|
+
};
|
|
19780
|
+
apply();
|
|
19781
|
+
const bar = el('div', 'reply-lab');
|
|
19782
|
+
const keep = (key, value) => { try { localStorage.setItem(key, value); } catch { /* sin almacenamiento */ } };
|
|
19783
|
+
const codeOf = () => `${FORMS.find(([, value]) => value === reply)[0]}${LANES.find(([, value]) => value === lane)?.[0] ?? '1'}${TERMS.find(([, value]) => value === term)[0]}`;
|
|
19784
|
+
const row = (title, list, current, set, { off = false } = {}) => {
|
|
19785
|
+
const line = el('div', 'lab-row');
|
|
19786
|
+
line.append(el('b', null, title));
|
|
19787
|
+
for (const [, value, words] of list) {
|
|
19788
|
+
const key = el('button', value === current && !off ? 'on' : null, words);
|
|
19789
|
+
key.type = 'button';
|
|
19790
|
+
key.disabled = off;
|
|
19791
|
+
key.addEventListener('click', () => { set(value); apply(); draw(); });
|
|
19792
|
+
line.append(key);
|
|
19793
|
+
}
|
|
19794
|
+
return line;
|
|
19795
|
+
};
|
|
19796
|
+
const draw = () => {
|
|
19797
|
+
bar.replaceChildren();
|
|
19798
|
+
const forms = row('Forma', FORMS, reply, (value) => { reply = value; keep('pulse.reply', value); });
|
|
19799
|
+
const sample = el('button', 'lab-sample', 'Insertar ejemplo');
|
|
19800
|
+
sample.type = 'button';
|
|
19801
|
+
sample.title = 'Dos respuestas y dos comandos de muestra, solo en pantalla: no se guardan';
|
|
19802
|
+
sample.addEventListener('click', insertSample);
|
|
19803
|
+
forms.append(sample);
|
|
19804
|
+
const lanes = row('Carril', LANES, lane, (value) => { lane = value; keep('pulse.reply.lane', value); }, { off: reply === 'bubble' });
|
|
19805
|
+
const terms = row('Terminal', TERMS, term, (value) => { term = value; keep('pulse.reply.term', value); });
|
|
19806
|
+
const code = el('span', 'lab-code', codeOf());
|
|
19807
|
+
code.title = 'Esta combinación. Ábrela con ?combo=' + codeOf();
|
|
19808
|
+
terms.append(code);
|
|
19809
|
+
bar.append(forms, lanes, terms);
|
|
19810
|
+
};
|
|
19811
|
+
// Dos respuestas como las de las capturas, dibujadas por el mismo render de la sala.
|
|
19812
|
+
function insertSample() {
|
|
19813
|
+
const column = document.querySelector('#messages .thread-inner') ?? document.querySelector('#messages');
|
|
19814
|
+
if (!column) return;
|
|
19815
|
+
column.querySelectorAll('.reply-sample').forEach((node) => node.remove());
|
|
19816
|
+
const make = (agent, label, badges, said, text) => {
|
|
19817
|
+
const row = paint(el('article', 'row assistant reply-sample'), agent);
|
|
19818
|
+
row.dataset.agent = agent;
|
|
19819
|
+
const col = el('div', 'col');
|
|
19820
|
+
const who = el('div', 'who');
|
|
19821
|
+
who.append(el('span', null, label), ...badges, el('span', 'reply', said));
|
|
19822
|
+
const bubble = el('div', 'bubble');
|
|
19823
|
+
bubble.append(renderMarkdown(text));
|
|
19824
|
+
const foot = el('div', 'bubble-foot');
|
|
19825
|
+
foot.append(el('div', 'stamp', '02:01 PM · 15.3k tok · $0.10'));
|
|
19826
|
+
bubble.append(foot);
|
|
19827
|
+
col.append(who, bubble);
|
|
19828
|
+
row.append(avatar(agent), col);
|
|
19829
|
+
column.append(row);
|
|
19830
|
+
};
|
|
19831
|
+
make('claude', 'Claude', [el('span', 'badge ash', 'AHORRO DE TOKENS')], '↳ Prueba de comunicación entre agentes…',
|
|
19832
|
+
'Hola a todos, soy @claude abriendo la ronda de prueba. Pido a cada agente una línea de confirmación.\n\n```pulse\n- @codex: saluda y confirma que recibes este mensaje en una línea.\n- @gemini: saluda y confirma que recibes este mensaje en una línea.\n- @opencode: saluda y confirma que recibes este mensaje en una línea.\n- @madre: saluda y confirma que recibes este mensaje en una línea.\n```');
|
|
19833
|
+
make('madre', 'Madre', [el('span', 'badge', 'CONTESTÁNDOLE A @CLAUDE')], '',
|
|
19834
|
+
'Soy @madre, la memoria de esta sala hablando, con qwen2.5:7b sobre Ollama en esta máquina. El archivista es mi otra mitad: el mismo modelo, de fondo, lee lo que la sala dice cada pocos intercambios o tras unos minutos de reposo y guarda hechos, decisiones, preferencias y preguntas abiertas. Eso es lo que ves en el Mapa de memoria y lo que yo cito con [#n]. No escribo archivos, no ejecuto ni tomo decisiones por nadie.\n\nSí puedo convocar al equipo si me lo pides: «@madre, pregúntale al equipo …» abre una ronda con cada agente en línea y cierro con un resumen citado. Para enseñarme algo no hace falta dictármelo: trabaja en la sala y se destila solo.');
|
|
19835
|
+
for (const node of [
|
|
19836
|
+
renderTerminalCard({ id: 'sample-ok', payload: { name: 'run', ok: true, text: 'PDF listo: output/pdf/cotizacion.pdf', run: { line: 'node scripts/pdf.mjs', exitCode: 0, durationMs: 2000, agent: 'codex' } } }),
|
|
19837
|
+
renderTerminalCard({ id: 'sample-fail', payload: { name: 'run', ok: false, text: 'ℹ tests 60\n✔ test 1 passed\n✖ test 9 failed\nℹ pass 53\nℹ fail 7', run: { line: 'npm test', exitCode: 1, durationMs: 4200, agent: 'codex' } } }),
|
|
19838
|
+
]) { node.classList.add('reply-sample'); column.append(node); }
|
|
19839
|
+
const thread = document.querySelector('#messages');
|
|
19840
|
+
if (thread) thread.scrollTop = thread.scrollHeight;
|
|
19841
|
+
}
|
|
19842
|
+
if (!showLab || SPLIT_CHAT) return;
|
|
19843
|
+
draw();
|
|
19844
|
+
document.body.append(bar);
|
|
19845
|
+
})();
|
|
19846
|
+
|
|
19847
|
+
/* ---------- LABORATORIO DEL CHAT (2026-10-15) ---------- */
|
|
19848
|
+
// Una conversación con todos los elementos del chat, dibujada por los mismos renderizadores de la
|
|
19849
|
+
// sala a partir de eventos de muestra (solo en pantalla: no se escriben en el registro), y un panel
|
|
19850
|
+
// con cinco formas de cada elemento. Nadie lo ve si no abre la sala con `?lab` (o `?lab=CÓDIGO`).
|
|
19851
|
+
// La elección se guarda como un código de 17 letras, en grupos de cuatro, para pasarla tal cual.
|
|
19852
|
+
// LAB_CHOSEN es lo que quedó en la sala (styles.css, «CHAT · ACCC-BABA-ECBB-DCAA-C»): un elemento solo
|
|
19853
|
+
// lleva data-labNN cuando se compara con otra forma, y entonces su regla de siempre se aparta.
|
|
19854
|
+
const LAB_CHOSEN = 'ACCCBABAECBBDCAAC';
|
|
19855
|
+
const LAB = [
|
|
19856
|
+
['Mensaje del humano', ['Burbuja de acento (hoy)', 'Burbuja neutra · ChatGPT y Claude', 'Sin burbuja, alineado a la derecha', 'Tarjeta de petición · Cursor', 'Burbuja con destino visible']],
|
|
19857
|
+
['Adjuntos', ['Mosaicos (antes)', 'Chips en línea', 'Tarjeta de archivo · Claude (hoy)', 'Lista compacta', 'Encima del mensaje · ChatGPT']],
|
|
19858
|
+
['Pensando', ['Puntos (antes)', 'Texto con brillo · Claude y ChatGPT', 'Avatar que late y tiempo (hoy)', 'Barra fina de progreso', 'Píldora «pensando · 12 s»']],
|
|
19859
|
+
['Cabecera del agente', ['Mono con chips (antes)', 'Nombre en letra normal · Claude', 'Píldora del agente (hoy)', 'Solo nombre; chips al pasar · ChatGPT', 'Avatar dentro de la cabecera']],
|
|
19860
|
+
['Cuerpo de la respuesta', ['Prosa libre con carril (antes)', 'Burbuja (hoy)', 'Tarjeta con borde', 'Filo del color del agente', 'Documento con regla']],
|
|
19861
|
+
['Memoria citada', ['Número en el texto (hoy)', 'Superíndice · ChatGPT con búsqueda', 'Chip de fuente · Perplexity', 'Subrayado punteado', 'Atenuada, al final de la frase']],
|
|
19862
|
+
['Plan', ['Tarjeta (antes)', 'Lista de tareas con casillas · Cursor (hoy)', 'Pasos numerados con línea', 'Equipo 2×2', 'Línea de tiempo']],
|
|
19863
|
+
['Bloque de código', ['Cabecera y COPIAR (hoy)', 'Barra suave · ChatGPT', 'Limpio, copiar al pasar · Claude', 'Oscuro siempre · terminal', 'Plegado a 6 líneas']],
|
|
19864
|
+
['Pie de la respuesta', ['Métricas e iconos (antes)', 'Solo iconos, al pasar · ChatGPT', 'Métricas en píldora', 'Acciones con fondo · Claude', 'Hora sola, métricas al pasar (hoy)']],
|
|
19865
|
+
['Elección', ['Opciones grandes (antes)', 'Chips de respuesta · ChatGPT y Gemini', 'Lista con número (hoy)', 'Tarjetas lado a lado', 'Botones al pie · Gemini']],
|
|
19866
|
+
['Petición de permiso', ['Tarjeta (antes)', 'Aprobación en una línea · Claude Code (hoy)', 'Tarjeta con barra de color', 'Barra sobre la caja de escribir', 'Discreta, solo borde']],
|
|
19867
|
+
['Permiso concedido', ['Píldora del modo (antes)', 'Línea divisoria (hoy)', 'Texto gris centrado', 'Píldora con candado', 'Punto y texto']],
|
|
19868
|
+
['Petición de comandos', ['Lista con botones (antes)', 'Compacta, sin cajas por línea', 'Código oscuro · terminal', 'Pasos numerados (hoy)', 'Botones solo al pasar']],
|
|
19869
|
+
['Comando ejecutado', ['Línea con estado (antes)', 'Ventana de terminal', 'Píldora · herramientas de ChatGPT y Claude (hoy)', 'Paso en un hilo', 'Línea compacta, sin /run']],
|
|
19870
|
+
['Avisos del sistema', ['Puntos plegados (hoy)', 'Línea centrada', 'Divisor con texto', 'Chip a la izquierda', 'Siempre abiertos, en tarjeta']],
|
|
19871
|
+
['Error y diagnóstico', ['Tarjeta roja centrada (hoy)', 'Tarjeta a la izquierda con icono', 'Banner a lo ancho', 'Línea roja discreta', 'Tarjeta neutra con borde rojo']],
|
|
19872
|
+
['Espacio entre turnos', ['Como antes', 'Compacto', 'Amplio · ChatGPT y Claude (hoy)', 'Línea fina entre turnos', 'Agrupar el mismo agente']],
|
|
19873
|
+
];
|
|
19874
|
+
(() => {
|
|
19875
|
+
let asked = null;
|
|
19876
|
+
try { asked = new URLSearchParams(location.search).get('lab'); } catch { return; }
|
|
19877
|
+
if (asked === null || SPLIT_CHAT) return;
|
|
19878
|
+
const root = document.documentElement;
|
|
19879
|
+
const picks = LAB_CHOSEN.split('');
|
|
19880
|
+
try {
|
|
19881
|
+
const saved = (/^[A-E-]{17,21}$/.test(asked) ? asked : localStorage.getItem('pulse.lab')) ?? '';
|
|
19882
|
+
saved.replace(/-/g, '').split('').forEach((key, index) => { if (index < picks.length) picks[index] = key; });
|
|
19883
|
+
} catch { /* sin almacenamiento */ }
|
|
19884
|
+
const code = () => picks.join('').match(/.{1,4}/g).join('-');
|
|
19885
|
+
const apply = () => {
|
|
19886
|
+
picks.forEach((key, index) => {
|
|
19887
|
+
const name = `lab${String(index + 1).padStart(2, '0')}`;
|
|
19888
|
+
if (key === LAB_CHOSEN[index]) delete root.dataset[name]; else root.dataset[name] = key;
|
|
19889
|
+
});
|
|
19890
|
+
// 05 · every body but the bubble was drawn over the old prose form; 14 · the /run cards reuse
|
|
19891
|
+
// the forms already built (data-term); 15 · notices show open.
|
|
19892
|
+
if (picks[4] === 'B') { delete root.dataset.reply; delete root.dataset.replyLegible; }
|
|
19893
|
+
else { root.dataset.reply = 'pieces'; root.dataset.replyLegible = 'lane'; }
|
|
19894
|
+
const term = { B: 'window', C: 'pill', D: 'step', E: 'line' }[picks[13]];
|
|
19895
|
+
if (term) root.dataset.term = term; else delete root.dataset.term;
|
|
19896
|
+
document.querySelectorAll('#messages .sys-dots').forEach((strip) => strip.classList.toggle('open', picks[14] !== 'A'));
|
|
19897
|
+
try { localStorage.setItem('pulse.lab', code()); } catch { /* sin almacenamiento */ }
|
|
19898
|
+
};
|
|
19899
|
+
apply();
|
|
19900
|
+
root.dataset.lab = 'on';
|
|
19901
|
+
|
|
19902
|
+
// The panel: one row per element, its five forms, the code and a way to copy it.
|
|
19903
|
+
const panel = el('aside', 'lab-panel');
|
|
19904
|
+
const draw = () => {
|
|
19905
|
+
panel.replaceChildren();
|
|
19906
|
+
const head = el('header', 'lab-head');
|
|
19907
|
+
const fold = el('button', 'lab-fold', panel.classList.contains('folded') ? '›' : '‹');
|
|
19908
|
+
fold.type = 'button';
|
|
19909
|
+
fold.title = 'Plegar el panel';
|
|
19910
|
+
fold.addEventListener('click', () => { panel.classList.toggle('folded'); draw(); });
|
|
19911
|
+
const codeBox = el('output', 'lab-code', code());
|
|
19912
|
+
const copy = el('button', 'lab-copy', 'Copiar mi elección');
|
|
19913
|
+
copy.type = 'button';
|
|
19914
|
+
copy.addEventListener('click', async () => {
|
|
19915
|
+
const lines = LAB.map(([name, forms], index) => `${String(index + 1).padStart(2, '0')} ${name}: ${picks[index]} · ${forms['ABCDE'.indexOf(picks[index])]}`);
|
|
19916
|
+
const text = `Código: ${code()}\n${lines.join('\n')}`;
|
|
19917
|
+
try { await navigator.clipboard.writeText(text); copy.textContent = 'Copiado ✓'; } catch { prompt('Copia tu elección:', text); }
|
|
19918
|
+
setTimeout(() => { copy.textContent = 'Copiar mi elección'; }, 1600);
|
|
19919
|
+
});
|
|
19920
|
+
head.append(fold, el('b', null, 'Laboratorio'), codeBox, copy);
|
|
19921
|
+
panel.append(head);
|
|
19922
|
+
if (panel.classList.contains('folded')) return;
|
|
19923
|
+
const list = el('div', 'lab-list');
|
|
19924
|
+
LAB.forEach(([name, forms], index) => {
|
|
19925
|
+
const row = el('div', 'lab-row');
|
|
19926
|
+
const top = el('div', 'lab-top');
|
|
19927
|
+
top.append(el('span', 'lab-n', String(index + 1).padStart(2, '0')), el('span', 'lab-name', name));
|
|
19928
|
+
const keys = el('span', 'lab-keys');
|
|
19929
|
+
for (const key of 'ABCDE') {
|
|
19930
|
+
const button = el('button', key === picks[index] ? 'on' : null, key);
|
|
19931
|
+
button.type = 'button';
|
|
19932
|
+
button.title = forms['ABCDE'.indexOf(key)];
|
|
19933
|
+
button.addEventListener('click', () => { picks[index] = key; apply(); draw(); });
|
|
19934
|
+
keys.append(button);
|
|
19935
|
+
}
|
|
19936
|
+
top.append(keys);
|
|
19937
|
+
row.append(top, el('span', 'lab-desc', forms['ABCDE'.indexOf(picks[index])]));
|
|
19938
|
+
list.append(row);
|
|
19939
|
+
});
|
|
19940
|
+
const again = el('button', 'lab-again', 'Volver a dibujar la conversación');
|
|
19941
|
+
again.type = 'button';
|
|
19942
|
+
again.addEventListener('click', () => drawConversation());
|
|
19943
|
+
panel.append(list, again);
|
|
19944
|
+
};
|
|
19945
|
+
draw();
|
|
19946
|
+
document.body.append(panel);
|
|
19947
|
+
|
|
19948
|
+
// The conversation: sample events through the room's own renderer, in the order a real one goes.
|
|
19949
|
+
let n = 0;
|
|
19950
|
+
const at = (minutes) => new Date(Date.now() - (90 - minutes) * 60000).toISOString();
|
|
19951
|
+
const ev = (type, payload, minutes) => ({ id: `lab-${Date.now()}-${n += 1}`, sequence: 900000 + n, type, timestamp: at(minutes), payload });
|
|
19952
|
+
function drawConversation() {
|
|
19953
|
+
const column = els.column;
|
|
19954
|
+
if (!column) return;
|
|
19955
|
+
column.replaceChildren();
|
|
19956
|
+
state.lastSender = null;
|
|
19957
|
+
const u1 = `lab-u1-${Date.now()}`;
|
|
19958
|
+
const a1 = `lab-a1-${Date.now()}`;
|
|
19959
|
+
const events = [
|
|
19960
|
+
ev('message.created', { messageId: u1, role: 'user', sender: 'you', target: 'codex', mode: 2, text: 'Revisa el README y el título del paquete; arma un plan con los demás si hace falta.', status: 'sent', attachments: [{ name: 'README.md', fileName: 'lab-readme.md', size: 4096, contentType: 'text/markdown' }, { name: 'notas.txt', fileName: 'lab-notas.txt', size: 812, contentType: 'text/plain' }] }, 1),
|
|
19961
|
+
{ ...ev('agent.started', { messageId: `lab-think-${Date.now()}`, agent: 'gemini', timeoutMs: 180000 }, 2), timestamp: new Date().toISOString() },
|
|
19962
|
+
ev('message.created', { messageId: a1, parentMessageId: u1, role: 'assistant', sender: 'codex', target: 'you', mode: 2, model: 'gpt-5.6-sol', effort: 'high', status: 'sent', text: 'Revisé el proyecto: el título del README no coincidía con el nombre del paquete [#214]. Lo dejé anotado y reparto la revisión.\n\n```pulse\n- @claude: corre las pruebas y dime cuántas pasan.\n- @gemini: revisa que el README se lea bien.\n- @opencode: compara con la versión anterior.\n- @madre: resume lo que se decidió.\n```\n\nEsta es la función que arma el título:\n\n```js\nexport function titulo(paquete) {\n return paquete.nombre ?? \'MADRE\';\n}\n// se usa en README.md y en la barra\n```' }, 3),
|
|
19963
|
+
ev('choice.offered', { agent: 'codex', question: '¿Actualizo también el CHANGELOG?', options: ['Sí, con una línea', 'Sí, con el detalle', 'No por ahora'], recommended: 0 }, 4),
|
|
19964
|
+
ev('message.created', { messageId: `lab-a2-${Date.now()}`, parentMessageId: u1, role: 'assistant', sender: 'gemini', target: 'you', mode: 1, status: 'sent', text: 'El README se lee bien. Para corregir el título necesito #3 por 15 min: tengo que editar README.md, que ya existe.' }, 5),
|
|
19965
|
+
ev('message.created', { messageId: `lab-a3-${Date.now()}`, parentMessageId: u1, role: 'assistant', sender: 'opencode', target: 'you', mode: 1, status: 'sent', text: 'Comparé con la versión anterior. Para dejar el cambio necesito #3 por 15 min.' }, 6),
|
|
19966
|
+
ev('message.created', { messageId: `lab-u2-${Date.now()}`, role: 'user', sender: 'you', target: 'opencode', mode: 3, text: 'Adelante con el cambio.', status: 'sent' }, 7),
|
|
19967
|
+
ev('run.requested', { messageId: u1, responseMessageId: `lab-run-${Date.now()}`, agent: 'madre', reason: 'Ver que el cambio quedó', mode: 3, commands: [{ line: "node -e \"console.log('uno')\"", argv: ['node', '-e', "console.log('uno')"] }, { line: 'git status --short', argv: ['git', 'status', '--short'] }, { line: 'ls | head', why: 'uses "{char}", which needs a shell; put each command on its own line', whyArgs: { char: '|' } }] }, 8),
|
|
19968
|
+
ev('command.output', { name: 'run', ok: true, text: 'PDF listo: output/pdf/cotizacion.pdf', run: { line: 'node scripts/pdf.mjs', exitCode: 0, durationMs: 2000, agent: 'codex' } }, 9),
|
|
19969
|
+
ev('command.output', { name: 'run', ok: false, text: 'ℹ tests 60\n✔ test 1 passed\n✖ test 9 failed\nℹ pass 53\nℹ fail 7', run: { line: 'npm test', exitCode: 1, durationMs: 4200, agent: 'claude' } }, 10),
|
|
19970
|
+
ev('copilot.started', { until: new Date(Date.now() + 3600000).toISOString(), minutes: 60 }, 11),
|
|
19971
|
+
ev('limit.warning', { agent: 'codex', usedPercent: 71.3, projectedPercent: 87.2, level: 'warning', source: 'room-soft-budget', resetAt: null, alternatives: ['claude'], message: 'MADRE warning: @codex is at 71% of its local window.' }, 12),
|
|
19972
|
+
ev('message.failed', { messageId: `lab-f-${Date.now()}`, target: 'madre', error: 'madre is not installed on this computer.' }, 13),
|
|
19973
|
+
];
|
|
19974
|
+
for (const event of events) renderEvent(event);
|
|
19975
|
+
apply();
|
|
19976
|
+
column.lastElementChild?.scrollIntoView({ block: 'end' });
|
|
19977
|
+
}
|
|
19978
|
+
// After the room has drawn its own conversation, the lab takes the column. Notices fold by
|
|
19979
|
+
// themselves ten seconds later; when they do, the chosen form opens them again.
|
|
19980
|
+
setTimeout(drawConversation, 1600);
|
|
19981
|
+
if (els.column) new MutationObserver(() => { if (picks[14] !== 'A') document.querySelectorAll('#messages .sys-dots:not(.open)').forEach((strip) => strip.classList.add('open')); }).observe(els.column, { childList: true, subtree: true });
|
|
19982
|
+
})();
|