@jossuealcala/madre 0.7.2 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +817 -1
- package/CONTRIBUTING.md +5 -1
- package/NOTICE +20 -1
- package/README.md +99 -34
- package/TRADEMARKS.md +65 -0
- package/bin/madre.mjs +30 -10
- package/docs/INTERNALS.md +2 -2
- package/docs/REFERENCE.md +159 -82
- package/docs/SDK.md +23 -19
- package/docs/madre-banner.png +0 -0
- package/package.json +4 -3
- package/public/app.js +11668 -4219
- package/public/assets/brand/Geist-latin-ext.woff2 +0 -0
- package/public/assets/brand/Geist-latin.woff2 +0 -0
- package/public/assets/brand/GeistMono-latin-ext.woff2 +0 -0
- package/public/assets/brand/GeistMono-latin.woff2 +0 -0
- package/public/assets/brand/JetBrainsMono-latin-ext.woff2 +0 -0
- package/public/assets/brand/JetBrainsMono-latin.woff2 +0 -0
- package/public/assets/brand/OFL-Geist.txt +93 -0
- package/public/assets/brand/OFL-GeistMono.txt +93 -0
- package/public/assets/brand/OFL-JetBrainsMono.txt +93 -0
- package/public/assets/brand/OFL-Sora.txt +93 -0
- package/public/assets/brand/Sora-latin-ext.woff2 +0 -0
- package/public/assets/brand/Sora-latin.woff2 +0 -0
- package/public/assets/brand/madre-app-180.png +0 -0
- package/public/assets/brand/madre-app-192.png +0 -0
- package/public/assets/brand/madre-app-512.png +0 -0
- package/public/assets/brand/madre-app.svg +1 -0
- package/public/assets/brand/madre-favicon.svg +1 -0
- package/public/assets/news/1.0/alerts.webp +0 -0
- package/public/assets/news/1.0/bar.webp +0 -0
- package/public/assets/news/1.0/finder.webp +0 -0
- package/public/assets/news/1.0/github.webp +0 -0
- package/public/assets/news/1.0/intents.webp +0 -0
- package/public/assets/news/1.0/looks.webp +0 -0
- package/public/assets/news/1.0/modules.webp +0 -0
- package/public/assets/news/1.0/mother.webp +0 -0
- package/public/assets/news/1.0/nostromo.webp +0 -0
- package/public/assets/news/1.0/permissions.webp +0 -0
- package/public/assets/news/1.0/room.webp +0 -0
- package/public/assets/news/1.0/settings.webp +0 -0
- package/public/assets/wallpapers/biznaga.webp +0 -0
- package/public/assets/wallpapers/canela.webp +0 -0
- package/public/assets/wallpapers/jarrito.webp +0 -0
- package/public/assets/wallpapers/magna.webp +0 -0
- package/public/assets/wallpapers/oceano.webp +0 -0
- package/public/assets/wallpapers/paraiso.webp +0 -0
- package/public/assets/wallpapers/quila.webp +0 -0
- package/public/assets/wallpapers/tradicion.webp +0 -0
- package/public/assets/wallpapers/tropical.webp +0 -0
- package/public/assets/wallpapers/urbania.webp +0 -0
- package/public/assets/wallpapers/ventura.webp +0 -0
- package/public/assets/wallpapers/xico.webp +0 -0
- package/public/brands.js +28 -2
- package/public/editor.js +744 -0
- package/public/es.js +3443 -1425
- package/public/i18n.js +1 -1
- package/public/index.html +201 -175
- package/public/inquiry.js +11 -11
- package/public/loader.js +79 -0
- package/public/macroverso.js +1517 -0
- package/public/manifest.webmanifest +14 -0
- package/public/nostromo-engine.js +2222 -0
- package/public/resay.js +24 -0
- package/public/review-ask.js +85 -0
- package/public/salas-mac.css +1281 -0
- package/public/salas-mac.html +158 -0
- package/public/salas-mac.js +4593 -0
- package/public/salas.css +85 -0
- package/public/salas.html +47 -0
- package/public/salas.js +200 -0
- package/public/styles.css +6372 -426
- package/public/touch.js +83 -0
- package/public/tour-chapters.js +452 -0
- package/public/tour.js +546 -0
- package/public/troubleshooting.js +160 -141
- package/src/adapters/codex.mjs +46 -6
- package/src/adapters/gemini.mjs +30 -10
- package/src/adapters/madre.mjs +110 -18
- package/src/adapters/process.mjs +42 -2
- package/src/auth-probe.mjs +31 -8
- package/src/bridge/devices.mjs +202 -0
- package/src/bridge/handoff.mjs +45 -0
- package/src/bridge/host.mjs +82 -0
- package/src/bridge/network.mjs +133 -0
- package/src/bridge/policy.mjs +89 -0
- package/src/bridge/qr.mjs +287 -0
- package/src/bridge/server.mjs +269 -0
- package/src/capabilities.mjs +5 -9
- package/src/chats.mjs +59 -22
- package/src/checkpoint.mjs +137 -35
- package/src/choices.mjs +18 -3
- package/src/cli-args.mjs +12 -1
- package/src/commands.mjs +15 -15
- package/src/config.mjs +73 -9
- package/src/conversation-context.mjs +5 -2
- package/src/credentials.mjs +80 -0
- package/src/dataset.mjs +3 -1
- package/src/directives.mjs +6 -11
- package/src/enlaces.mjs +451 -0
- package/src/event-store.mjs +55 -10
- package/src/exam.mjs +5 -5
- package/src/extensions.mjs +31 -12
- package/src/file-ops.mjs +23 -0
- package/src/files.mjs +52 -2
- package/src/github.mjs +407 -0
- package/src/heartbeat.mjs +55 -0
- package/src/json-file.mjs +63 -0
- package/src/lease.mjs +2 -2
- package/src/lobby-work.mjs +103 -0
- package/src/local-ai.mjs +48 -0
- package/src/looks.mjs +88 -0
- package/src/maturity.mjs +3 -3
- package/src/mcp/github-server.mjs +164 -0
- package/src/mcp/image-server.mjs +1 -1
- package/src/mcp/memory-server.mjs +11 -3
- package/src/memory.mjs +38 -12
- package/src/models.mjs +127 -5
- package/src/modules/ahp.mjs +4 -4
- package/src/modules/ash.mjs +1 -1
- package/src/modules/bishop-plan.mjs +207 -0
- package/src/modules/bishop.mjs +645 -0
- package/src/modules/choices.mjs +2 -2
- package/src/modules/correo.mjs +1 -1
- package/src/modules/enlaces.mjs +255 -0
- package/src/modules/git-pulse.mjs +2 -2
- package/src/modules/github.mjs +322 -0
- package/src/modules/image-studio.mjs +3 -3
- package/src/modules/index.mjs +18 -4
- package/src/modules/intents-calibration.mjs +338 -0
- package/src/modules/intents-desk.mjs +481 -0
- package/src/modules/intents-observe.mjs +266 -0
- package/src/modules/intents-policy.mjs +414 -0
- package/src/modules/intents-try.mjs +91 -0
- package/src/modules/intents.mjs +145 -0
- package/src/modules/ollama.mjs +18 -6
- package/src/modules/playwright.mjs +121 -11
- package/src/modules/ripley.mjs +1 -1
- package/src/modules/runs.mjs +2 -2
- package/src/modules/sdk.mjs +10 -4
- package/src/ollama.mjs +6 -1
- package/src/outbound.mjs +36 -17
- package/src/override.mjs +100 -0
- package/src/ports.mjs +23 -1
- package/src/prices.mjs +72 -0
- package/src/privacy.mjs +30 -11
- package/src/quota-sources.mjs +0 -8
- package/src/request-guard.mjs +1 -1
- package/src/review-ask.mjs +111 -0
- package/src/room/archivist.mjs +55 -37
- package/src/room/budget.mjs +17 -5
- package/src/room/control.mjs +70 -7
- package/src/room/economy.mjs +35 -7
- package/src/room/escalation.mjs +3 -0
- package/src/room/guard.mjs +26 -2
- package/src/room/prompt.mjs +22 -15
- package/src/room/runs.mjs +84 -21
- package/src/room/vectors.mjs +19 -4
- package/src/room.mjs +411 -84
- package/src/router.mjs +3 -1
- package/src/runs.mjs +23 -5
- package/src/salas.mjs +313 -0
- package/src/sentinel-errors.mjs +1 -1
- package/src/server-sentinel.mjs +124 -0
- package/src/server.mjs +1864 -178
- package/src/setup.mjs +1 -0
- package/src/tour.mjs +174 -0
- package/src/updates.mjs +48 -8
- package/src/usage-sentinel.mjs +1 -1
- package/src/verdict.mjs +7 -7
- package/src/whats-new.mjs +160 -0
- package/docs/madre-banner.svg +0 -25
package/src/memory.mjs
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// match meaning and not only words. Filled in the background.
|
|
11
11
|
// GHOST events never reach the ledger, so they never reach here either.
|
|
12
12
|
|
|
13
|
-
import { mkdir
|
|
13
|
+
import { mkdir } from 'node:fs/promises';
|
|
14
14
|
import { dirname } from 'node:path';
|
|
15
15
|
import { messageEntry } from './conversation-context.mjs';
|
|
16
16
|
import { cosine, toBlob, fromBlob } from './embeddings.mjs';
|
|
@@ -196,6 +196,11 @@ export class RoomMemory {
|
|
|
196
196
|
|
|
197
197
|
#open() {
|
|
198
198
|
this.#db = new this.#Database(this.#file);
|
|
199
|
+
// Another process writes this file too — the memory server an agent's turn starts, `madre
|
|
200
|
+
// dataset` — and SQLite answered «database is locked» at once, so a note, an index line or a
|
|
201
|
+
// verdict was lost (LOG-08 of the 1.0 review). It waits its turn a few seconds instead. A
|
|
202
|
+
// pragma, because the `timeout` option of node:sqlite is newer than the Node 22.5 MADRE needs.
|
|
203
|
+
this.#db.exec('PRAGMA busy_timeout = 5000');
|
|
199
204
|
if (this.#file !== ':memory:') this.#db.exec('PRAGMA journal_mode = WAL');
|
|
200
205
|
this.#createSchema();
|
|
201
206
|
this.#meta = this.#db.prepare('SELECT value FROM meta WHERE key = ?');
|
|
@@ -323,7 +328,9 @@ export class RoomMemory {
|
|
|
323
328
|
let entries = 0;
|
|
324
329
|
for (const row of this.#db.prepare('SELECT text FROM entries').iterate()) if (this.#privacy.hits(row.text)) entries += 1;
|
|
325
330
|
let memories = 0;
|
|
326
|
-
for (const row of this.#db.prepare('SELECT text FROM memories').iterate())
|
|
331
|
+
for (const row of this.#db.prepare('SELECT text, correction FROM memories').iterate()) {
|
|
332
|
+
if (this.#privacy.hits(row.text) || (row.correction && this.#privacy.hits(row.correction))) memories += 1;
|
|
333
|
+
}
|
|
327
334
|
return { entries, memories };
|
|
328
335
|
}
|
|
329
336
|
|
|
@@ -334,13 +341,17 @@ export class RoomMemory {
|
|
|
334
341
|
if (!this.#privacy?.enabled) return { entries: 0, memories: 0 };
|
|
335
342
|
const counts = { entries: 0, memories: 0 };
|
|
336
343
|
const entryRows = this.#db.prepare('SELECT sequence, event_id, timestamp, type, role, sender, target, message_id, text, distilled FROM entries').all();
|
|
337
|
-
|
|
344
|
+
// Preserve every column when replacing a row. In particular, a refuted note must keep its
|
|
345
|
+
// standing and its refutation; a recall must keep its history. The schema is the source of
|
|
346
|
+
// this list so a later additive column cannot silently be reset to its default here.
|
|
347
|
+
const memoryColumns = this.#db.prepare('PRAGMA table_info(memories)').all().map((column) => column.name);
|
|
348
|
+
const memoryRows = this.#db.prepare('SELECT * FROM memories').all();
|
|
338
349
|
const deleteEntry = this.#db.prepare('DELETE FROM entries WHERE sequence = ?');
|
|
339
350
|
const deleteEntryVector = this.#db.prepare('DELETE FROM entry_vectors WHERE sequence = ?');
|
|
340
351
|
const insertEntry = this.#db.prepare('INSERT INTO entries (sequence, event_id, timestamp, type, role, sender, target, message_id, text, distilled) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
|
|
341
352
|
const deleteMemory = this.#db.prepare('DELETE FROM memories WHERE id = ?');
|
|
342
353
|
const deleteMemoryVector = this.#db.prepare('DELETE FROM memory_vectors WHERE id = ?');
|
|
343
|
-
const insertMemory = this.#db.prepare(
|
|
354
|
+
const insertMemory = this.#db.prepare(`INSERT INTO memories (${memoryColumns.map((name) => `"${name.replaceAll('"', '""')}"`).join(', ')}) VALUES (${memoryColumns.map(() => '?').join(', ')})`);
|
|
344
355
|
this.#db.exec('BEGIN');
|
|
345
356
|
try {
|
|
346
357
|
for (const row of entryRows) {
|
|
@@ -353,12 +364,14 @@ export class RoomMemory {
|
|
|
353
364
|
}
|
|
354
365
|
for (const row of memoryRows) {
|
|
355
366
|
const { text, hits } = this.#privacy.redact(row.text);
|
|
356
|
-
|
|
367
|
+
const correction = row.correction ? this.#privacy.redact(row.correction) : { text: null, hits: 0 };
|
|
368
|
+
if (!hits && !correction.hits) continue;
|
|
357
369
|
deleteMemoryVector.run(row.id);
|
|
358
370
|
deleteMemory.run(row.id);
|
|
359
371
|
// Two notes may collapse into the same redacted sentence: the later one is dropped.
|
|
360
|
-
const
|
|
361
|
-
|
|
372
|
+
const norm = memoryKey(row.kind, text);
|
|
373
|
+
const clash = this.#db.prepare('SELECT id FROM memories WHERE norm = ?').get(norm);
|
|
374
|
+
if (!clash) insertMemory.run(...memoryColumns.map((name) => name === 'text' ? text : name === 'norm' ? norm : name === 'correction' ? correction.text : row[name]));
|
|
362
375
|
counts.memories += 1;
|
|
363
376
|
}
|
|
364
377
|
this.#db.exec('COMMIT');
|
|
@@ -610,9 +623,13 @@ export class RoomMemory {
|
|
|
610
623
|
if (!row) return null;
|
|
611
624
|
this.#db.exec('BEGIN');
|
|
612
625
|
try {
|
|
613
|
-
|
|
626
|
+
// What it took out stands again by the rule, the human's verdict included (LOG-10 of the
|
|
627
|
+
// 1.0 review): written straight to HOLD, a note the human had marked bad travelled again.
|
|
628
|
+
const freed = this.#db.prepare('SELECT id FROM memories WHERE refuted_by = ?').all(row.id).map((one) => one.id);
|
|
629
|
+
this.#db.prepare('UPDATE memories SET refuted_by = NULL WHERE refuted_by = ?').run(row.id);
|
|
614
630
|
this.#db.prepare('DELETE FROM memories WHERE id = ?').run(row.id);
|
|
615
631
|
this.#db.prepare('DELETE FROM memory_vectors WHERE id = ?').run(row.id);
|
|
632
|
+
this.#restand(freed);
|
|
616
633
|
this.#db.exec('COMMIT');
|
|
617
634
|
return { id: row.id, text: row.text, restored: row.contradicts ?? null };
|
|
618
635
|
} catch (error) {
|
|
@@ -666,18 +683,27 @@ export class RoomMemory {
|
|
|
666
683
|
this.#db.prepare('DELETE FROM recalls WHERE memory_id = ?').run(id);
|
|
667
684
|
// A note quarantined by this one comes back: with the aberration gone there is nothing
|
|
668
685
|
// holding it out of circulation, and a memory must never be lost to a pointer at nothing.
|
|
669
|
-
|
|
686
|
+
// Where it stands is decided by the rule, the human's verdict included (LOG-10).
|
|
687
|
+
const freed = this.#db.prepare('SELECT id FROM memories WHERE refuted_by = ?').all(id).map((one) => one.id);
|
|
688
|
+
this.#db.prepare('UPDATE memories SET refuted_by = NULL WHERE refuted_by = ?').run(id);
|
|
670
689
|
this.#db.prepare('DELETE FROM memories WHERE id = ?').run(id);
|
|
690
|
+
this.#restand(freed);
|
|
671
691
|
this.#db.exec('COMMIT');
|
|
672
692
|
} catch (error) { this.#db.exec('ROLLBACK'); throw error; }
|
|
673
693
|
return row;
|
|
674
694
|
}
|
|
675
695
|
|
|
676
696
|
// Which notes are about the same thing: pairs whose vectors agree, strongest
|
|
677
|
-
// first, a few per note.
|
|
697
|
+
// first, a few per note. Comparing notes with each other needs only the vectors
|
|
698
|
+
// already stored, not a live embedder: when the one in use has none (Ollama off,
|
|
699
|
+
// the room fell back to another), the model that embedded the most notes draws
|
|
700
|
+
// the links. Empty only when no note was ever embedded.
|
|
678
701
|
memoryLinks({ floor = 0.6, maxPerNode = 4 } = {}) {
|
|
679
|
-
|
|
680
|
-
const
|
|
702
|
+
const stored = this.#db.prepare('SELECT model, COUNT(*) AS n FROM memory_vectors GROUP BY model ORDER BY n DESC').all();
|
|
703
|
+
const current = this.#embedder ? stored.find((row) => row.model === this.#embedder.model) : null;
|
|
704
|
+
const model = current?.model ?? stored[0]?.model;
|
|
705
|
+
if (!model) return [];
|
|
706
|
+
const rows = this.#db.prepare('SELECT id, vec FROM memory_vectors WHERE model = ?').all(model).map((row) => ({ id: row.id, vec: fromBlob(row.vec) }));
|
|
681
707
|
const links = [];
|
|
682
708
|
for (let i = 0; i < rows.length; i += 1) {
|
|
683
709
|
for (let j = i + 1; j < rows.length; j += 1) {
|
package/src/models.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
+
import { OPENCODE_PROVIDERS } from './credentials.mjs';
|
|
4
5
|
|
|
5
6
|
// Which model a CLI should use for one turn. Every CLI accepts --model; the
|
|
6
7
|
// names come from what is installed locally (Codex's model cache, OpenCode's
|
|
@@ -32,15 +33,45 @@ export const KNOWN_MODELS = {
|
|
|
32
33
|
// for each provider), so they get no chooser at all rather than one that guesses.
|
|
33
34
|
//
|
|
34
35
|
// Ordered lightest first: the composer draws them as a ladder, and the order is the meaning.
|
|
36
|
+
//
|
|
37
|
+
// Codex's levels belong to each model, not to the CLI. Its own cache (~/.codex/models_cache.json)
|
|
38
|
+
// lists the ones each model takes, and the API refuses any other with a 400 — «'minimal' is not
|
|
39
|
+
// supported with the 'gpt-5.5' model» — that used to reach the room as a lone «{». The list here is
|
|
40
|
+
// every name Codex has used, in order; a turn gets only the ones its model takes.
|
|
35
41
|
export const EFFORTS = {
|
|
36
42
|
claude: ['low', 'medium', 'high', 'xhigh', 'max'],
|
|
37
|
-
codex: ['minimal', 'low', 'medium', 'high', 'xhigh'],
|
|
43
|
+
codex: ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'],
|
|
38
44
|
};
|
|
45
|
+
// What a Codex with no cache to read is offered: the levels its models have always taken.
|
|
46
|
+
const CODEX_SAFE = ['low', 'medium', 'high'];
|
|
39
47
|
|
|
40
48
|
export function isValidEffort(agentId, effort) {
|
|
41
49
|
return typeof effort === 'string' && (EFFORTS[agentId] ?? []).includes(effort);
|
|
42
50
|
}
|
|
43
51
|
|
|
52
|
+
// Whose model a name is, when the name says so. Codex, Claude and Gemini each answer only to their
|
|
53
|
+
// own vendor's models, and a name from another one reaches the CLI as an API error that reads
|
|
54
|
+
// like anything but "wrong model" (Gemini: `models/gpt-5.6-sol is not found for API version
|
|
55
|
+
// v1beta`). OpenCode is left out on purpose: it speaks to many providers, so any name may be its.
|
|
56
|
+
const VENDOR = [
|
|
57
|
+
['codex', /^(gpt-|o\d|codex-|chatgpt-)/i],
|
|
58
|
+
['claude', /^(claude|fable|opus|sonnet|haiku)\b/i],
|
|
59
|
+
['gemini', /^(gemini|gemma|auto$)/i],
|
|
60
|
+
];
|
|
61
|
+
export function modelOwner(model) {
|
|
62
|
+
return VENDOR.find(([, pattern]) => pattern.test(String(model ?? '')))?.[0] ?? null;
|
|
63
|
+
}
|
|
64
|
+
// True when the name clearly belongs to another of the three: the turn is better off on the
|
|
65
|
+
// agent's own default than sent to fail.
|
|
66
|
+
export function belongsElsewhere(agentId, model) {
|
|
67
|
+
if (!VENDOR.some(([id]) => id === agentId)) return false;
|
|
68
|
+
const owner = modelOwner(model);
|
|
69
|
+
return Boolean(owner) && owner !== agentId;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// A CLI saying it does not know the model it was given, in any of the ways the four say it.
|
|
73
|
+
export const MODEL_NOT_FOUND = /models?\/\S+ is not found|not supported for generateContent|model[_ ]not[_ ]found|unknown model|invalid model|model (?:\S+ )?(?:does not exist|is not available|not found)|issue with the selected model/i;
|
|
74
|
+
|
|
44
75
|
export function parseCodexDefaultEffort(toml) {
|
|
45
76
|
return String(toml ?? '').match(/^\s*model_reasoning_effort\s*=\s*"([^"]+)"/m)?.[1] ?? null;
|
|
46
77
|
}
|
|
@@ -61,6 +92,49 @@ export function parseCodexModelCache(json) {
|
|
|
61
92
|
}
|
|
62
93
|
}
|
|
63
94
|
|
|
95
|
+
// Per model, the efforts it takes (lightest first) and the one it uses when told nothing.
|
|
96
|
+
export function parseCodexEfforts(json) {
|
|
97
|
+
try {
|
|
98
|
+
const data = JSON.parse(json);
|
|
99
|
+
const byModel = {};
|
|
100
|
+
const walk = (node) => {
|
|
101
|
+
if (!node || typeof node !== 'object') return;
|
|
102
|
+
if (typeof node.slug === 'string' && Array.isArray(node.supported_reasoning_levels)) {
|
|
103
|
+
const levels = EFFORTS.codex.filter((level) => node.supported_reasoning_levels.some((one) => one?.effort === level));
|
|
104
|
+
if (levels.length) byModel[node.slug] = { levels, default: levels.includes(node.default_reasoning_level) ? node.default_reasoning_level : null };
|
|
105
|
+
}
|
|
106
|
+
for (const value of Object.values(node)) walk(value);
|
|
107
|
+
};
|
|
108
|
+
walk(data);
|
|
109
|
+
return byModel;
|
|
110
|
+
} catch {
|
|
111
|
+
return {};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// The levels one Codex model takes: its own, from the cache; for a name the cache does not list,
|
|
116
|
+
// the ones every listed model shares, so a model MADRE cannot read never meets a refusal.
|
|
117
|
+
export function codexLevels(byModel, model) {
|
|
118
|
+
if (model && byModel[model]) return byModel[model].levels;
|
|
119
|
+
const lists = Object.values(byModel).map((one) => one.levels);
|
|
120
|
+
return lists.length ? lists.reduce((shared, levels) => shared.filter((level) => levels.includes(level))) : CODEX_SAFE;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function readCodexEfforts({ home = homedir() } = {}) {
|
|
124
|
+
const [cache, toml] = await Promise.all([
|
|
125
|
+
readFile(join(home, '.codex', 'models_cache.json'), 'utf8').catch(() => ''),
|
|
126
|
+
readFile(join(home, '.codex', 'config.toml'), 'utf8').catch(() => ''),
|
|
127
|
+
]);
|
|
128
|
+
return { byModel: parseCodexEfforts(cache), model: parseCodexDefaultModel(toml), effort: parseCodexDefaultEffort(toml) };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Whether a turn's effort reaches Codex: the model it runs on — the one chosen, or the CLI's
|
|
132
|
+
// default — has to take it.
|
|
133
|
+
export async function codexTakes(model, effort, { home = homedir() } = {}) {
|
|
134
|
+
const read = await readCodexEfforts({ home });
|
|
135
|
+
return codexLevels(read.byModel, model || read.model).includes(effort);
|
|
136
|
+
}
|
|
137
|
+
|
|
64
138
|
export function parseCodexDefaultModel(toml) {
|
|
65
139
|
return String(toml ?? '').match(/^\s*model\s*=\s*"([^"]+)"/m)?.[1] ?? null;
|
|
66
140
|
}
|
|
@@ -78,25 +152,73 @@ export async function discoverModels({ agents = [], config = {}, home = homedir(
|
|
|
78
152
|
let discovered = [];
|
|
79
153
|
let cliDefault = null;
|
|
80
154
|
let effortDefault = null;
|
|
155
|
+
let efforts = EFFORTS[agent.id] ?? [];
|
|
156
|
+
let effortsByModel;
|
|
157
|
+
let effortsShared;
|
|
81
158
|
if (agent.id === 'codex') {
|
|
82
159
|
discovered = parseCodexModelCache(await readFile(join(home, '.codex', 'models_cache.json'), 'utf8').catch(() => ''));
|
|
83
|
-
const
|
|
84
|
-
cliDefault =
|
|
85
|
-
effortDefault =
|
|
160
|
+
const read = await readCodexEfforts({ home });
|
|
161
|
+
cliDefault = read.model;
|
|
162
|
+
effortDefault = read.effort;
|
|
163
|
+
// The ladder is the model's: the CLI default's here, each model's in the composer once it is
|
|
164
|
+
// chosen. What the CLI does when told nothing is the config's level if that model takes it,
|
|
165
|
+
// or the model's own.
|
|
166
|
+
efforts = codexLevels(read.byModel, cliDefault);
|
|
167
|
+
effortsShared = codexLevels(read.byModel, null);
|
|
168
|
+
effortsByModel = Object.fromEntries(Object.entries(read.byModel).map(([slug, one]) => [slug, { levels: one.levels, default: one.levels.includes(read.effort) ? read.effort : one.default }]));
|
|
169
|
+
if (!efforts.includes(effortDefault)) effortDefault = (cliDefault && read.byModel[cliDefault]?.default) || null;
|
|
86
170
|
}
|
|
87
171
|
if (agent.id === 'opencode' && agent.detected) {
|
|
88
172
|
discovered = await listOpenCode(agent).catch(() => []);
|
|
89
173
|
cliDefault = process.env.PULSE_OPENCODE_MODEL ?? null;
|
|
90
174
|
}
|
|
91
175
|
const custom = Array.isArray(config.models?.[agent.id]) ? config.models[agent.id] : [];
|
|
176
|
+
// OpenCode speaks to many providers: what `opencode models` lists is already only the ones
|
|
177
|
+
// signed in plus OpenCode's own. The person may hide a provider from the chooser (AJUSTES ›
|
|
178
|
+
// Project › OpenCode); its models are then left out, never the room default.
|
|
179
|
+
let groups;
|
|
180
|
+
if (agent.id === 'opencode') {
|
|
181
|
+
const hidden = new Set(openCodeHidden(config));
|
|
182
|
+
discovered = discovered.filter((name) => !hidden.has(providerOf(name)));
|
|
183
|
+
groups = groupOpenCode(unique([...custom, ...discovered]));
|
|
184
|
+
}
|
|
92
185
|
result[agent.id] = {
|
|
186
|
+
...(groups ? { groups } : {}),
|
|
93
187
|
models: unique([...custom, ...discovered, ...known.defaults]),
|
|
94
188
|
default: cliDefault,
|
|
95
189
|
note: known.note,
|
|
96
|
-
efforts
|
|
190
|
+
efforts,
|
|
97
191
|
effortDefault: isValidEffort(agent.id, effortDefault) ? effortDefault : null,
|
|
192
|
+
...(effortsByModel ? { effortsByModel, effortsShared } : {}),
|
|
98
193
|
source: discovered.length ? 'discovered' : 'known',
|
|
99
194
|
};
|
|
100
195
|
}
|
|
101
196
|
return result;
|
|
102
197
|
}
|
|
198
|
+
|
|
199
|
+
// ---- OpenCode's models by provider: `provider/model`, named as the person knows the provider.
|
|
200
|
+
export const OPENCODE_OWN = 'opencode';
|
|
201
|
+
export const providerOf = (name) => String(name ?? '').split('/')[0];
|
|
202
|
+
export function openCodeHidden(config = {}) {
|
|
203
|
+
const hidden = config.opencode?.hidden;
|
|
204
|
+
return Array.isArray(hidden) ? hidden.filter((id) => typeof id === 'string' && /^[\w.-]{1,60}$/.test(id)) : [];
|
|
205
|
+
}
|
|
206
|
+
export function providerLabel(id) {
|
|
207
|
+
if (id === OPENCODE_OWN) return 'OpenCode';
|
|
208
|
+
const known = OPENCODE_PROVIDERS.find((one) => one.id === id);
|
|
209
|
+
if (known) return known.label;
|
|
210
|
+
return id.split(/[-_.]/).map((part) => (part ? part[0].toUpperCase() + part.slice(1) : part)).join(' ');
|
|
211
|
+
}
|
|
212
|
+
// Groups in a steady order: the providers with a key first, by name; OpenCode's own models last.
|
|
213
|
+
export function groupOpenCode(names = []) {
|
|
214
|
+
const byProvider = new Map();
|
|
215
|
+
for (const name of names) {
|
|
216
|
+
if (!/^[\w.-]+\/[\w.:/-]+$/.test(name)) continue;
|
|
217
|
+
const id = providerOf(name);
|
|
218
|
+
if (!byProvider.has(id)) byProvider.set(id, []);
|
|
219
|
+
byProvider.get(id).push(name);
|
|
220
|
+
}
|
|
221
|
+
return [...byProvider.entries()]
|
|
222
|
+
.sort(([a], [b]) => Number(a === OPENCODE_OWN) - Number(b === OPENCODE_OWN) || providerLabel(a).localeCompare(providerLabel(b)))
|
|
223
|
+
.map(([id, models]) => ({ id, label: providerLabel(id), own: id === OPENCODE_OWN, models }));
|
|
224
|
+
}
|
package/src/modules/ahp.mjs
CHANGED
|
@@ -30,12 +30,12 @@ async function detect(projectRoot) {
|
|
|
30
30
|
async function preflight(projectRoot, { toplevel = gitToplevel, npx = findOnPath } = {}) {
|
|
31
31
|
const root = await realpath(projectRoot).catch(() => resolve(projectRoot));
|
|
32
32
|
const problems = [];
|
|
33
|
-
if (!(await npx('npx'))) problems.push('npx is not on PATH for the MADRE server; start MADRE from a terminal where `npx --version` works.');
|
|
33
|
+
if (!(await npx('npx'))) problems.push(t('npx is not on PATH for the MADRE server; start MADRE from a terminal where `npx --version` works.'));
|
|
34
34
|
const top = await toplevel(root);
|
|
35
35
|
if (!top) {
|
|
36
|
-
problems.push('The project is not a git repository. AHP+ pins itself to the git root: run `git init` in the project first.');
|
|
36
|
+
problems.push(t('The project is not a git repository. AHP+ pins itself to the git root: run `git init` in the project first.'));
|
|
37
37
|
} else if (top !== root) {
|
|
38
|
-
problems.push(
|
|
38
|
+
problems.push(t('The project\'s git root is {top}, not the project itself. AHP+ would install there. Run `git init` inside the project, or remove the stray repository at {top}.', { top }));
|
|
39
39
|
}
|
|
40
40
|
return { ok: problems.length === 0, problems, gitRoot: top };
|
|
41
41
|
}
|
|
@@ -50,7 +50,7 @@ function installCommand({ agents = [] }) {
|
|
|
50
50
|
export default defineModule({
|
|
51
51
|
id: 'ahp',
|
|
52
52
|
kind: 'installer',
|
|
53
|
-
name: '
|
|
53
|
+
name: 'Continuity',
|
|
54
54
|
vendor: 'Agent Handoff Protocol Plus',
|
|
55
55
|
package: PACKAGE,
|
|
56
56
|
tracks: { name: PACKAGE, npm: PACKAGE },
|
package/src/modules/ash.mjs
CHANGED
|
@@ -20,7 +20,7 @@ import { t } from '../i18n.mjs';
|
|
|
20
20
|
export default defineModule({
|
|
21
21
|
id: 'ash',
|
|
22
22
|
configKey: 'ash',
|
|
23
|
-
name: '
|
|
23
|
+
name: 'Token saver',
|
|
24
24
|
vendor: 'MADRE',
|
|
25
25
|
version: '1.0.0',
|
|
26
26
|
summary: 'Asks every agent for compact prose without altering what you write. MADRE shows each turn\'s input, output and cache use; savings depend on the agent, task and cache, so no fixed percentage is promised.',
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// BISHOP's plans, without the room around them (docs/MACROVERSO.md, fases 4b y 4c): the block a
|
|
2
|
+
// reply ends with, what each of its lines means on this Mac, and the record the buttons act on.
|
|
3
|
+
// Nothing here sends, signs or starts anything; src/modules/bishop.mjs does, after a human press
|
|
4
|
+
// or within the mode the human chose.
|
|
5
|
+
import { randomBytes } from 'node:crypto';
|
|
6
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { LINK_KINDS, MAX_ORDER_MODE, receivesOrders } from '../enlaces.mjs';
|
|
9
|
+
import { salasRoomId } from '../salas.mjs';
|
|
10
|
+
|
|
11
|
+
export const MAX_ORDERS = 4;
|
|
12
|
+
export const MAX_LINKS = 2;
|
|
13
|
+
export const RESULT_CHARS = 1200;
|
|
14
|
+
export const ORDER_CHARS = 2000;
|
|
15
|
+
export const PLAN_FENCE = 'pulse-salas';
|
|
16
|
+
export const BISHOP_MODES = ['manual', 'hybrid', 'auto'];
|
|
17
|
+
const KEEP_PLANS = 40;
|
|
18
|
+
export const clean = (value, max = 60) => String(value ?? '').replace(/[\r\n\t<>]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
|
|
19
|
+
|
|
20
|
+
/* ---------- the headers a relayed message carries ---------- */
|
|
21
|
+
|
|
22
|
+
// What a turn reads first when BISHOP's channel brings it a message. English, like everything a
|
|
23
|
+
// turn reads, and with no word of @madre's list of actions (src/adapters/madre.mjs, ACTION_EN):
|
|
24
|
+
// «order» or «delegate» made @madre answer every order with its fixed refusal (S-031).
|
|
25
|
+
// Who sent it is said as it happened: pressed by the human, or on its own within the mode the human
|
|
26
|
+
// chose. Never «sent by the human» for an order nobody pressed.
|
|
27
|
+
export const orderHeader = ({ step, total, plan, auto = false }) => `BISHOP › ${step}/${total} · plan ${plan} · from Salas, ${auto ? 'sent on its own within the mode the human chose' : 'sent by the human'}. Answer in this room only; this turn cannot pass anything on to other rooms.`;
|
|
28
|
+
export const consultHeader = ({ room }) => `BISHOP › question from room ${clean(room, 60)}, asked by the human there. Answer from what you know of the linked rooms; this turn ends with the answer.`;
|
|
29
|
+
export const ORDER_HEADER = /^BISHOP › (\d+)\/(\d+) · plan (p-[0-9a-f]{6})\b/;
|
|
30
|
+
export const CONSULT_HEADER = /^BISHOP › question from room (.+?), asked by the human there\./;
|
|
31
|
+
|
|
32
|
+
/* ---------- the block ---------- */
|
|
33
|
+
|
|
34
|
+
// One order: `@<room>/<agent> #<0-4>: what to do`. The room is its name (it may have spaces) or
|
|
35
|
+
// its id; the agent is a crew id. The mode is a single digit and #3/#4 parse, to be refused later.
|
|
36
|
+
const ORDER_LINE = /^\s*[-*]?\s*@(.+?)\/([a-z][a-z0-9_-]*)\s*#(\d)\s*[::]\s*(.+)$/i;
|
|
37
|
+
// One link: `enlace <room> → <room>: decision, fact; 30 días; órdenes #1`. The arrow says which way
|
|
38
|
+
// notes travel (the first room hands over, the second reads); ↔ is both ways.
|
|
39
|
+
const LINK_LINE = /^\s*[-*]?\s*(?:enlace|link)\s*:?\s+@?(.+?)\s*(↔|<->|→|->)\s*@?(.+?)\s*[::]\s*(.+)$/i;
|
|
40
|
+
const KIND_WORDS = new Map([
|
|
41
|
+
['decision', 'decision'], ['decisions', 'decision'], ['decisión', 'decision'], ['decision', 'decision'], ['decisiones', 'decision'],
|
|
42
|
+
['fact', 'fact'], ['facts', 'fact'], ['hecho', 'fact'], ['hechos', 'fact'],
|
|
43
|
+
['preference', 'preference'], ['preferences', 'preference'], ['preferencia', 'preference'], ['preferencias', 'preference'],
|
|
44
|
+
['question', 'question'], ['questions', 'question'], ['pregunta', 'question'], ['preguntas', 'question'],
|
|
45
|
+
]);
|
|
46
|
+
const isLine = (line) => ORDER_LINE.test(line) || LINK_LINE.test(line);
|
|
47
|
+
|
|
48
|
+
function linkTerms(rest) {
|
|
49
|
+
const [first, ...others] = String(rest).split(/[;·|]/).map((part) => part.trim()).filter(Boolean);
|
|
50
|
+
const words = String(first ?? '').toLowerCase().split(/\s*,\s*|\s+y\s+|\s+and\s+/).map((word) => word.trim()).filter(Boolean);
|
|
51
|
+
const kinds = [...new Set(words.map((word) => KIND_WORDS.get(word)).filter(Boolean))];
|
|
52
|
+
const unknown = words.filter((word) => !KIND_WORDS.has(word));
|
|
53
|
+
let days = null;
|
|
54
|
+
let orders = null;
|
|
55
|
+
for (const part of others) {
|
|
56
|
+
const span = part.match(/(\d{1,3})\s*(?:d|días|dias|day|days)\b/i);
|
|
57
|
+
if (span) days = Number(span[1]);
|
|
58
|
+
const ceiling = part.match(/(?:órdenes|ordenes|orders?)\s*(?:hasta|up to)?\s*#?\s*([0-4])/i);
|
|
59
|
+
if (ceiling) orders = Number(ceiling[1]);
|
|
60
|
+
}
|
|
61
|
+
return { kinds, unknown, days, orders };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The last ```pulse-salas block of a reply, read the way the room reads ```pulse: the reason on the
|
|
65
|
+
// first line, then up to 4 orders and up to 2 links, and nothing after the block. `null` when the
|
|
66
|
+
// reply has no such block; otherwise `{ reason, orders, links, ignored }`, where `ignored` says why
|
|
67
|
+
// each line (or the whole block) was not taken.
|
|
68
|
+
export function parsePlan(text) {
|
|
69
|
+
const lines = String(text ?? '').replace(/\r\n/g, '\n').split('\n');
|
|
70
|
+
// Fences as the page reads them (renderMarkdown): a fence opens with an optional language and
|
|
71
|
+
// only a bare ``` closes it.
|
|
72
|
+
const blocks = [];
|
|
73
|
+
let open = null;
|
|
74
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
75
|
+
if (open === null) {
|
|
76
|
+
const fence = lines[index].match(/^\s*```\s*([\w+-]*)\s*$/);
|
|
77
|
+
if (fence) open = { start: index, lang: fence[1].toLowerCase() };
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (/^\s*```\s*$/.test(lines[index])) { blocks.push({ ...open, end: index }); open = null; }
|
|
81
|
+
}
|
|
82
|
+
const none = (why) => ({ reason: '', orders: [], links: [], ignored: [{ line: '', why }] });
|
|
83
|
+
if (open?.lang === PLAN_FENCE) return none('the block is not closed');
|
|
84
|
+
const found = blocks.filter((block) => block.lang === PLAN_FENCE).at(-1);
|
|
85
|
+
if (!found) return null;
|
|
86
|
+
if (lines.slice(found.end + 1).join('\n').trim()) return none('text after the block');
|
|
87
|
+
const body = lines.slice(found.start + 1, found.end).map((line) => line.trim()).filter(Boolean);
|
|
88
|
+
const reason = body[0] && !isLine(body[0]) ? clean(body.shift(), 300) : '';
|
|
89
|
+
if (!reason) return none('the first line says why');
|
|
90
|
+
const orders = [];
|
|
91
|
+
const links = [];
|
|
92
|
+
const ignored = [];
|
|
93
|
+
for (const line of body) {
|
|
94
|
+
const link = line.match(LINK_LINE);
|
|
95
|
+
if (link) {
|
|
96
|
+
if (links.length >= MAX_LINKS) { ignored.push({ line: clean(line, 120), why: 'more than 2 connections' }); continue; }
|
|
97
|
+
const terms = linkTerms(link[4]);
|
|
98
|
+
links.push({ from: clean(link[1], 80), to: clean(link[3], 80), both: link[2] === '↔' || link[2] === '<->', ...terms });
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const match = line.match(ORDER_LINE);
|
|
102
|
+
if (!match) { ignored.push({ line: clean(line, 120), why: 'not an order line' }); continue; }
|
|
103
|
+
const mode = Number(match[3]);
|
|
104
|
+
if (mode > 4) { ignored.push({ line: clean(line, 120), why: 'no such mode' }); continue; }
|
|
105
|
+
if (orders.length >= MAX_ORDERS) { ignored.push({ line: clean(line, 120), why: 'more than 4 orders' }); continue; }
|
|
106
|
+
orders.push({ room: clean(match[1], 80), agent: match[2].toLowerCase(), mode, text: String(match[4]).replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, ORDER_CHARS) });
|
|
107
|
+
}
|
|
108
|
+
return { reason, orders, links, ignored };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* ---------- what each line means on this Mac ---------- */
|
|
112
|
+
|
|
113
|
+
// A room by its id, or by a name only one room on this Mac has.
|
|
114
|
+
function findRoom(rooms, wanted) {
|
|
115
|
+
const byId = rooms.filter((room) => room.id === wanted);
|
|
116
|
+
if (byId.length) return byId;
|
|
117
|
+
const lower = String(wanted).toLowerCase();
|
|
118
|
+
return rooms.filter((room) => String(room.name).toLowerCase() === lower);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// One parsed order, checked: the room, an agent of this crew, a contract in force that lets BISHOP
|
|
122
|
+
// give orders to it and up to which mode. An order that fails a check stays on the card with its
|
|
123
|
+
// reason and no button; #3 and #4 become a proposal for that room.
|
|
124
|
+
export function resolveOrder(order, step, { rooms, contracts, salas, agents }) {
|
|
125
|
+
const crew = new Set((agents ?? []).filter((agent) => agent.detected).map((agent) => agent.id));
|
|
126
|
+
const named = findRoom(rooms, order.room);
|
|
127
|
+
const base = { step, room: named[0]?.id ?? null, name: named[0]?.name ?? order.room, agent: order.agent, mode: order.mode, text: order.text, contract: null, maxMode: null, asked: order.room };
|
|
128
|
+
if (!named.length) return { ...base, status: 'blocked', why: 'MADRE does not know that project on this Mac.' };
|
|
129
|
+
if (named.length > 1) return { ...base, room: null, status: 'blocked', why: 'Two projects on this Mac have that name; use the project id.' };
|
|
130
|
+
if (named[0].id === salas) return { ...base, status: 'blocked', why: 'The Coordinator lives in this project; it gives orders to the others.' };
|
|
131
|
+
if (!crew.has(order.agent)) return { ...base, status: 'blocked', why: 'No such agent on this Mac.' };
|
|
132
|
+
const contract = contracts.find((one) => receivesOrders(one, salas) === named[0].id) ?? null;
|
|
133
|
+
if (!contract) return { ...base, status: 'blocked', why: 'No signed connection lets the Coordinator give orders to that project.' };
|
|
134
|
+
const maxMode = Math.min(contract.orders.maxMode, MAX_ORDER_MODE);
|
|
135
|
+
if (order.mode >= 3) return { ...base, contract: contract.id, maxMode, status: 'proposal', why: '#3 and #4 never travel: unlock it yourself in that project.' };
|
|
136
|
+
if (order.mode > maxMode) return { ...base, contract: contract.id, maxMode, status: 'blocked', why: 'That connection allows orders up to #{n}.', whyArgs: { n: maxMode } };
|
|
137
|
+
return { ...base, contract: contract.id, maxMode, status: 'ready', why: null };
|
|
138
|
+
}
|
|
139
|
+
export const resolveOrders = ({ orders, ...where }) => orders.map((order, index) => resolveOrder(order, index + 1, where));
|
|
140
|
+
|
|
141
|
+
// One proposed link, checked the way the sheet of Salas and `normalizeContract` would: two rooms
|
|
142
|
+
// this Mac knows, the room of Salas never handing over, kinds that may cross, orders only with
|
|
143
|
+
// Salas at one end. A pair already connected, either way round, says so. Signing it is the human's press.
|
|
144
|
+
export function resolveLinks({ links, rooms, contracts, salas }) {
|
|
145
|
+
return links.map((link, index) => {
|
|
146
|
+
const from = findRoom(rooms, link.from);
|
|
147
|
+
const to = findRoom(rooms, link.to);
|
|
148
|
+
const base = { index, from: from[0]?.id ?? null, fromName: from[0]?.name ?? link.from, to: to[0]?.id ?? null, toName: to[0]?.name ?? link.to, direction: link.both ? 'both' : 'a→b', kinds: link.kinds, days: link.days, orders: null, receives: null };
|
|
149
|
+
if (from.length !== 1 || to.length !== 1) return { ...base, status: 'blocked', why: from.length > 1 || to.length > 1 ? 'Two projects on this Mac have that name; use the project id.' : 'MADRE does not know that project on this Mac.' };
|
|
150
|
+
if (base.from === base.to) return { ...base, status: 'blocked', why: 'A project cannot be connected to itself.' };
|
|
151
|
+
if (base.from === salas || (link.both && base.to === salas)) return { ...base, status: 'blocked', why: 'The Coordinator\'s project never hands its notes over.' };
|
|
152
|
+
if (!link.kinds.length || link.unknown.length) return { ...base, status: 'blocked', why: 'Name what crosses: decision, fact, preference or question.' };
|
|
153
|
+
if (!link.kinds.every((kind) => LINK_KINDS.includes(kind))) return { ...base, status: 'blocked', why: 'Name what crosses: decision, fact, preference or question.' };
|
|
154
|
+
let orders = null;
|
|
155
|
+
if (link.orders !== null) {
|
|
156
|
+
if (base.from !== salas && base.to !== salas) return { ...base, status: 'blocked', why: 'Orders go only on a connection with the Coordinator\'s project.' };
|
|
157
|
+
orders = { allowed: true, maxMode: Math.min(link.orders, MAX_ORDER_MODE), perHour: 6 };
|
|
158
|
+
}
|
|
159
|
+
const receives = orders ? (base.from === salas ? base.to : base.from) : null;
|
|
160
|
+
// Two projects are connected once (2026-10-11): any connection in force between them, whichever
|
|
161
|
+
// way it goes, is the one there is; signing another is refused (src/server.mjs).
|
|
162
|
+
const same = contracts.find((one) => [one.rooms.a, one.rooms.b].sort().join() === [base.from, base.to].sort().join());
|
|
163
|
+
if (same) return { ...base, orders, receives, status: 'exists', why: 'That connection is already in force.', contract: same.id };
|
|
164
|
+
return { ...base, orders, receives, status: 'ready', why: null };
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/* ---------- the plans, kept beside the room of Salas ---------- */
|
|
169
|
+
|
|
170
|
+
// What the buttons act on is read from here, never from the page: the page names a plan and a step.
|
|
171
|
+
const plansFile = (stateRoot) => join(stateRoot, 'rooms', salasRoomId(stateRoot), 'bishop-plans.json');
|
|
172
|
+
const writes = new Map();
|
|
173
|
+
async function readPlans(stateRoot) {
|
|
174
|
+
try { const data = JSON.parse(await readFile(plansFile(stateRoot), 'utf8')); return data && typeof data.plans === 'object' ? data : { plans: {} }; } catch { return { plans: {} }; }
|
|
175
|
+
}
|
|
176
|
+
// One change at a time per file, written whole and renamed into place.
|
|
177
|
+
export function changePlans(stateRoot, change) {
|
|
178
|
+
const file = plansFile(stateRoot);
|
|
179
|
+
const run = (writes.get(file) ?? Promise.resolve()).then(async () => {
|
|
180
|
+
const data = await readPlans(stateRoot);
|
|
181
|
+
const result = await change(data);
|
|
182
|
+
const kept = Object.values(data.plans).sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt))).slice(0, KEEP_PLANS);
|
|
183
|
+
data.plans = Object.fromEntries(kept.map((plan) => [plan.id, plan]));
|
|
184
|
+
await mkdir(join(file, '..'), { recursive: true });
|
|
185
|
+
const temp = `${file}.${process.pid}.${randomBytes(3).toString('hex')}.tmp`;
|
|
186
|
+
await writeFile(temp, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
|
|
187
|
+
await rename(temp, file);
|
|
188
|
+
return result;
|
|
189
|
+
});
|
|
190
|
+
writes.set(file, run.catch(() => null));
|
|
191
|
+
return run;
|
|
192
|
+
}
|
|
193
|
+
export const planById = async (stateRoot, id) => (await readPlans(stateRoot)).plans[id] ?? null;
|
|
194
|
+
export const newPlanId = () => `p-${randomBytes(3).toString('hex')}`;
|
|
195
|
+
|
|
196
|
+
// A plan closes once, when no order is still waiting for a press or an answer and at least one
|
|
197
|
+
// was answered, failed or skipped. The close is a line; it never opens another plan.
|
|
198
|
+
const OPEN_STATES = new Set(['ready', 'sending', 'sent']);
|
|
199
|
+
export function closing(plan) {
|
|
200
|
+
if (plan.closed || plan.orders.some((order) => OPEN_STATES.has(order.status))) return null;
|
|
201
|
+
if (!plan.orders.some((order) => ['done', 'failed', 'skipped'].includes(order.status))) return null;
|
|
202
|
+
plan.closed = true;
|
|
203
|
+
const count = (...statuses) => plan.orders.filter((order) => statuses.includes(order.status)).length;
|
|
204
|
+
return { plan: plan.id, done: count('done'), failed: count('failed'), skipped: count('skipped'), blocked: count('blocked', 'proposal', 'delivered') };
|
|
205
|
+
}
|
|
206
|
+
export const publicOrder = ({ step, room, name, agent, mode, text, contract, maxMode, status, why, whyArgs }) => ({ step, room, name, agent, mode, text, contract, maxMode, status, why, ...(whyArgs ? { whyArgs } : {}) });
|
|
207
|
+
export const publicLink = ({ index, from, fromName, to, toName, direction, kinds, days, orders, receives, status, why, contract }) => ({ index, from, fromName, to, toName, direction, kinds, days, orders, receives, status, why, ...(contract ? { contract } : {}) });
|