@jossuealcala/madre 0.7.2 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +773 -1
- package/README.md +92 -28
- package/bin/madre.mjs +30 -10
- package/docs/REFERENCE.md +157 -81
- package/docs/SDK.md +23 -19
- package/package.json +1 -1
- package/public/app.js +11207 -4196
- 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 +2784 -1026
- 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 +1497 -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 +787 -0
- package/public/salas-mac.html +154 -0
- package/public/salas-mac.js +3264 -0
- package/public/salas.css +85 -0
- package/public/salas.html +47 -0
- package/public/salas.js +200 -0
- package/public/styles.css +6207 -425
- package/public/touch.js +83 -0
- package/public/tour-chapters.js +342 -0
- package/public/tour.js +510 -0
- package/public/troubleshooting.js +159 -140
- 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 +2 -2
- 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/dataset.mjs +3 -1
- package/src/directives.mjs +0 -5
- package/src/enlaces.mjs +451 -0
- package/src/event-store.mjs +55 -10
- package/src/exam.mjs +5 -5
- package/src/extensions.mjs +0 -2
- package/src/file-ops.mjs +23 -0
- package/src/files.mjs +52 -2
- package/src/github.mjs +407 -0
- package/src/json-file.mjs +63 -0
- package/src/lease.mjs +2 -2
- package/src/lobby-work.mjs +103 -0
- package/src/looks.mjs +55 -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 +89 -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 +4 -4
- 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 +19 -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 +2 -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 +351 -65
- 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 +1526 -163
- package/src/setup.mjs +1 -0
- package/src/tour.mjs +159 -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/src/prices.mjs
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// What a turn would cost at the provider's API list price, for the CLIs that do not say it
|
|
2
|
+
// themselves. Claude Code reports its own figure (`total_cost_usd`); Codex and Gemini report only
|
|
3
|
+
// tokens, and OpenCode reports 0 when the model runs on a subscription. So the room prices those
|
|
4
|
+
// turns from the table below — an estimate, said as one, never a bill.
|
|
5
|
+
//
|
|
6
|
+
// THE TABLE IS ONLY WHAT WAS READ AT THE SOURCE. Every row was read twice on the provider's own
|
|
7
|
+
// pricing page on PRICES_CHECKED; a model that is not here gets no price at all, never a guess
|
|
8
|
+
// from a model that looks like it. Prices are US dollars per million tokens, standard tier, short
|
|
9
|
+
// context (OpenAI ≤ 272K, Google ≤ 200K tokens per request). A turn can make several requests and
|
|
10
|
+
// the room only sees their sum, so the long-context rates are not applied: a turn with one very
|
|
11
|
+
// long request costs more than this says.
|
|
12
|
+
export const PRICES_CHECKED = '2026-10-09';
|
|
13
|
+
// Where they were read. Written down, never reached: MADRE does not fetch prices (no scheme here,
|
|
14
|
+
// so the outbound guard in test/outbound.test.mjs does not take them for destinations).
|
|
15
|
+
export const PRICE_SOURCES = Object.freeze({
|
|
16
|
+
openai: 'developers.openai.com/api/docs/pricing',
|
|
17
|
+
google: 'ai.google.dev/gemini-api/docs/pricing',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const LIST = Object.freeze({
|
|
21
|
+
// OpenAI · input, cached input, output
|
|
22
|
+
'gpt-6-sol': { input: 2.0, cached: 0.2, output: 10.0 },
|
|
23
|
+
'gpt-6-luna': { input: 0.1, cached: 0.01, output: 0.5 },
|
|
24
|
+
'gpt-5.6-sol': { input: 4.0, cached: 0.4, output: 20.0 },
|
|
25
|
+
'gpt-5.6-terra': { input: 2.0, cached: 0.2, output: 12.0 },
|
|
26
|
+
'gpt-5.6-luna': { input: 0.2, cached: 0.02, output: 1.2 },
|
|
27
|
+
'gpt-5.5': { input: 5.0, cached: 0.5, output: 30.0 },
|
|
28
|
+
// Google · input, context caching, output (thinking included)
|
|
29
|
+
'gemini-3.1-pro-preview': { input: 2.0, cached: 0.2, output: 12.0 },
|
|
30
|
+
'gemini-3-flash-preview': { input: 0.5, cached: 0.05, output: 3.0 },
|
|
31
|
+
'gemini-2.5-pro': { input: 1.25, cached: 0.125, output: 10.0 },
|
|
32
|
+
'gemini-2.5-flash': { input: 0.3, cached: 0.03, output: 2.5 },
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// "openai/gpt-5.6-sol" (OpenCode's provider/model) and "models/gemini-2.5-pro" name the same rows.
|
|
36
|
+
export function listPrice(model) {
|
|
37
|
+
if (typeof model !== 'string' || !model.trim()) return null;
|
|
38
|
+
const id = model.trim().toLowerCase().split('/').at(-1);
|
|
39
|
+
return Object.hasOwn(LIST, id) ? LIST[id] : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// How each CLI counts. `cachedIn`: the cached tokens are part of `inputTokens` (OpenAI and Google
|
|
43
|
+
// count them inside the prompt); OpenCode counts them apart. `reasoningIn`: the reasoning tokens
|
|
44
|
+
// are part of `outputTokens` (OpenAI's usage does); Gemini and OpenCode count them apart, and both
|
|
45
|
+
// providers bill them at the output rate.
|
|
46
|
+
const COUNTING = Object.freeze({
|
|
47
|
+
'codex-json': { cachedIn: true, reasoningIn: true },
|
|
48
|
+
'gemini-json': { cachedIn: true, reasoningIn: false },
|
|
49
|
+
'opencode-json': { cachedIn: false, reasoningIn: false },
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Dollars for one model's share of a turn, or null when the model or the counting is unknown.
|
|
53
|
+
export function priceTokens(model, usage, source) {
|
|
54
|
+
const price = listPrice(model);
|
|
55
|
+
const counting = COUNTING[source];
|
|
56
|
+
if (!price || !counting || !usage) return null;
|
|
57
|
+
const n = (value) => (Number.isFinite(value) && value > 0 ? value : 0);
|
|
58
|
+
const cached = n(usage.cachedInputTokens);
|
|
59
|
+
const fresh = counting.cachedIn ? Math.max(0, n(usage.inputTokens) - cached) : n(usage.inputTokens);
|
|
60
|
+
const output = n(usage.outputTokens) + (counting.reasoningIn ? 0 : n(usage.reasoningTokens));
|
|
61
|
+
return (fresh * price.input + cached * price.cached + output * price.output) / 1e6;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// A turn's usage with its estimate added, when the CLI did not give a figure of its own. A figure
|
|
65
|
+
// the CLI gave (above zero) is never replaced. The estimate carries which model and which date it
|
|
66
|
+
// was priced at, so the page can say so.
|
|
67
|
+
export function withListPrice(usage, model) {
|
|
68
|
+
if (!usage || (Number.isFinite(usage.costUsd) && usage.costUsd > 0) || usage.local) return usage;
|
|
69
|
+
const cost = priceTokens(model, usage, usage.source);
|
|
70
|
+
if (cost === null) return usage;
|
|
71
|
+
return { ...usage, costUsd: Math.round(cost * 1e6) / 1e6, costEstimated: true, pricedModel: String(model).split('/').at(-1), pricedAt: PRICES_CHECKED };
|
|
72
|
+
}
|
package/src/privacy.mjs
CHANGED
|
@@ -14,7 +14,19 @@
|
|
|
14
14
|
// What a secret looks like, whatever project it turns up in. The same list the crash reporter
|
|
15
15
|
// has used since the beginning, brought where the room can use it: a credential does not become
|
|
16
16
|
// safe by being in a reply rather than in a stack trace.
|
|
17
|
+
// Widened 2026-10-12 (INI-05 of the 1.0 review): what crosses between projects was cleaned of a few
|
|
18
|
+
// shapes only, and Stripe's live keys, AWS's, private keys, Google's access tokens, a password
|
|
19
|
+
// written as a setting, a bearer token and the credentials inside an address went across as they
|
|
20
|
+
// were. A replacement may be a function of the match, to keep the name and hide only the value.
|
|
17
21
|
const SECRETS = [
|
|
22
|
+
[/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----/g, '[private key]'],
|
|
23
|
+
[/\b(sk|rk|pk)_(live|test)_[A-Za-z0-9]{16,}\b/g, '[key]'],
|
|
24
|
+
[/\b(AKIA|ASIA)[0-9A-Z]{16}\b/g, '[key]'],
|
|
25
|
+
[/\bya29\.[0-9A-Za-z_-]{20,}/g, '[token]'],
|
|
26
|
+
[/\b(Bearer)\s+[A-Za-z0-9._~+/-]{20,}=*/g, (match, word) => `${word} [token]`],
|
|
27
|
+
[/\b([a-z][a-z0-9+.-]*:\/\/)[^\s:@/]+:[^\s@/]+@/gi, (match, scheme) => `${scheme}[credentials]@`],
|
|
28
|
+
[/\b(aws_secret_access_key|password|passwd|pwd|secret|api[_-]?key|access[_-]?token|auth[_-]?token)(\s*[=:]\s*)(["']?)[^\s"']{6,}\3/gi, (match, name, between, quote) => `${name}${between}${quote}[secret]${quote}`],
|
|
29
|
+
[/\+\d{1,3}[\s-]?\(?\d{2,4}\)?[\s-]?\d{3,4}[\s-]?\d{3,4}\b/g, '[phone]'],
|
|
18
30
|
[/\b(sk|rk|pk)-[A-Za-z0-9_-]{16,}\b/g, '[key]'],
|
|
19
31
|
[/\bAIza[0-9A-Za-z_-]{20,}\b/g, '[key]'],
|
|
20
32
|
[/\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}\b/g, '[token]'],
|
|
@@ -120,20 +132,27 @@ export class Privacy {
|
|
|
120
132
|
|
|
121
133
|
// The text with every private term replaced by the marker, and the count. The shape guards run
|
|
122
134
|
// too: a key or a home path is caught whether or not anyone thought to name it.
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
135
|
+
// `kinds` says what was replaced: a term the human named, a key, or a home path. The room's
|
|
136
|
+
// notice used to call all of them «private terms», so a home path turned into ~ read as a term
|
|
137
|
+
// that was never written.
|
|
138
|
+
// `always`: secrets and paths are hidden even where this project switched those guards off —
|
|
139
|
+
// what one project hands to another (a connection) is not this project's to leave open (INI-05).
|
|
140
|
+
redact(text, { always = false } = {}) {
|
|
141
|
+
if (typeof text !== 'string' || !text) return { text, hits: 0, kinds: { terms: 0, secrets: 0, paths: 0 } };
|
|
142
|
+
const kinds = { terms: 0, secrets: 0, paths: 0 };
|
|
126
143
|
let out = text;
|
|
127
|
-
if (this.#pattern) out = out.replace(this.#pattern, () => {
|
|
128
|
-
if (this.#secrets) {
|
|
129
|
-
for (const [pattern, replacement] of SECRETS) out = out.replace(pattern, () => {
|
|
144
|
+
if (this.#pattern) out = out.replace(this.#pattern, () => { kinds.terms += 1; return this.#marker; });
|
|
145
|
+
if (this.#secrets || always) {
|
|
146
|
+
for (const [pattern, replacement] of SECRETS) out = out.replace(pattern, (...match) => { kinds.secrets += 1; return typeof replacement === 'function' ? replacement(...match) : replacement; });
|
|
130
147
|
}
|
|
131
|
-
if (this.#paths) {
|
|
132
|
-
if (this.#home) { const parts = out.split(this.#home);
|
|
133
|
-
|
|
134
|
-
|
|
148
|
+
if (this.#paths || always) {
|
|
149
|
+
if (this.#home) { const parts = out.split(this.#home); kinds.paths += parts.length - 1; out = parts.join('~'); }
|
|
150
|
+
// The folder named as it was (`RegExp.$1` read whatever the last match anywhere left there,
|
|
151
|
+
// so a /home/ path came back as /Users/).
|
|
152
|
+
out = out.replace(/\/(Users|home)\/[^/\s"']+/g, (match, top) => { kinds.paths += 1; return `/${top}/…`; });
|
|
153
|
+
out = out.replace(/[A-Za-z]:\\Users\\[^\\\s"']+/g, () => { kinds.paths += 1; return 'C:\\Users\\…'; });
|
|
135
154
|
}
|
|
136
|
-
return { text: out, hits };
|
|
155
|
+
return { text: out, hits: kinds.terms + kinds.secrets + kinds.paths, kinds };
|
|
137
156
|
}
|
|
138
157
|
|
|
139
158
|
// Every string inside a value (an event payload, a plan), replaced in place of
|
package/src/quota-sources.mjs
CHANGED
|
@@ -151,11 +151,3 @@ export function defaultQuotaSources({ env = process.env } = {}) {
|
|
|
151
151
|
return sources;
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
// Human-readable window label for the room: "5h" / "7d" / "300m".
|
|
155
|
-
export function windowLabel(minutes) {
|
|
156
|
-
if (!Number.isFinite(Number(minutes))) return 'window';
|
|
157
|
-
const m = Number(minutes);
|
|
158
|
-
if (m % 1440 === 0) return `${m / 1440}d`;
|
|
159
|
-
if (m % 60 === 0) return `${m / 60}h`;
|
|
160
|
-
return `${m}m`;
|
|
161
|
-
}
|
package/src/request-guard.mjs
CHANGED
|
@@ -22,5 +22,5 @@ export function refuseForeign({ method = 'GET', headers = {} } = {}) {
|
|
|
22
22
|
const origin = headers.origin;
|
|
23
23
|
if (SAFE.has(method) || origin === undefined) return null;
|
|
24
24
|
if (origin === `http://${headers.host}` || origin === `https://${headers.host}`) return null;
|
|
25
|
-
return 'A page from somewhere else cannot act in this
|
|
25
|
+
return 'A page from somewhere else cannot act in this project.';
|
|
26
26
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Asking for a star or a review (2026-10-10): three times at most, and never again once answered.
|
|
2
|
+
//
|
|
3
|
+
// After twenty minutes of MADRE in use, and on the second and the seventh day after it was first
|
|
4
|
+
// used, the room asks once whether MADRE is being liked: a star on GitHub, or a review on
|
|
5
|
+
// madre.run. Whoever answers — a star, a review, or «don't ask again» — is never asked again; «not
|
|
6
|
+
// now» only lets that moment pass. A later moment that comes first takes the earlier ones with it,
|
|
7
|
+
// so nobody is asked twice in a row, and never twice within a day.
|
|
8
|
+
//
|
|
9
|
+
// Use is counted by the pages themselves: while one is in view it says so once a minute, and the
|
|
10
|
+
// minutes are added here, one per minute however many pages are open. What is kept lives in
|
|
11
|
+
// ~/.pulse/review-ask.json, not in a browser: each room is its own origin, and the same person on
|
|
12
|
+
// another port has been asked already. Nothing here touches the network; the two links are opened
|
|
13
|
+
// by the person, in their browser.
|
|
14
|
+
|
|
15
|
+
import { readFile } from 'node:fs/promises';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { oneAtATime, writeWhole } from './json-file.mjs';
|
|
18
|
+
|
|
19
|
+
const FILE = 'review-ask.json';
|
|
20
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
21
|
+
export const ASK_MOMENTS = [
|
|
22
|
+
{ id: 'm20', reached: (state) => state.minutes >= 20 },
|
|
23
|
+
{ id: 'd2', reached: (state, now) => now - Date.parse(state.first) >= 2 * DAY },
|
|
24
|
+
{ id: 'd7', reached: (state, now) => now - Date.parse(state.first) >= 7 * DAY },
|
|
25
|
+
];
|
|
26
|
+
export const ASK_ANSWERS = ['later', 'star', 'review', 'never'];
|
|
27
|
+
// One minute of use is counted at most once a minute, whichever page says it.
|
|
28
|
+
const TICK_MS = 55 * 1000;
|
|
29
|
+
// Never asked twice within this long, whatever moments have come.
|
|
30
|
+
const QUIET_MS = 20 * 60 * 60 * 1000;
|
|
31
|
+
|
|
32
|
+
const fresh = () => ({ first: null, minutes: 0, lastTick: null, shown: {}, answer: null, offered: null });
|
|
33
|
+
// A moment handed to one page is not handed to another for this long (WEB-12 of the 1.0 review):
|
|
34
|
+
// every page in view ticks — Home, each project, each half of a split view — and each one asked.
|
|
35
|
+
const OFFER_HOLD_MS = 10 * 60 * 1000;
|
|
36
|
+
|
|
37
|
+
export async function readAsk(root) {
|
|
38
|
+
try {
|
|
39
|
+
const raw = JSON.parse(await readFile(join(root, FILE), 'utf8'));
|
|
40
|
+
const state = fresh();
|
|
41
|
+
if (typeof raw?.first === 'string' && Number.isFinite(Date.parse(raw.first))) state.first = raw.first;
|
|
42
|
+
if (Number.isFinite(raw?.minutes) && raw.minutes >= 0) state.minutes = Math.floor(raw.minutes);
|
|
43
|
+
if (typeof raw?.lastTick === 'string' && Number.isFinite(Date.parse(raw.lastTick))) state.lastTick = raw.lastTick;
|
|
44
|
+
if (raw?.shown && typeof raw.shown === 'object') for (const { id } of ASK_MOMENTS) if (typeof raw.shown[id] === 'string') state.shown[id] = raw.shown[id];
|
|
45
|
+
if (ASK_ANSWERS.includes(raw?.answer) && raw.answer !== 'later') state.answer = raw.answer;
|
|
46
|
+
if (typeof raw?.offered?.moment === 'string' && Number.isFinite(Date.parse(raw.offered.at))) state.offered = { moment: raw.offered.moment, at: raw.offered.at };
|
|
47
|
+
return state;
|
|
48
|
+
} catch {
|
|
49
|
+
return fresh();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function writeAsk(root, state) {
|
|
54
|
+
await writeWhole(join(root, FILE), `${JSON.stringify(state, null, 2)}\n`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The moment that is due now, or null: the latest one reached and not shown yet, unless the person
|
|
58
|
+
// already answered or was asked within the last QUIET_MS.
|
|
59
|
+
export function dueMoment(state, now = Date.now()) {
|
|
60
|
+
if (state.answer || !state.first) return null;
|
|
61
|
+
const last = Math.max(0, ...Object.values(state.shown).map((at) => Date.parse(at)).filter(Number.isFinite));
|
|
62
|
+
if (last && now - last < QUIET_MS) return null;
|
|
63
|
+
const reached = ASK_MOMENTS.filter((moment) => !state.shown[moment.id] && moment.reached(state, now));
|
|
64
|
+
return reached.at(-1)?.id ?? null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// A page in view says so: the first time starts the count, then a minute is added at most once a
|
|
68
|
+
// minute. Answers which moment is due, if any.
|
|
69
|
+
// `quietSince`: a moment that asks for quiet — the day of the bonjour (src/tour.mjs) — keeps every
|
|
70
|
+
// ask away for QUIET_MS after it, as an ask itself would. The minutes still count.
|
|
71
|
+
// One change at a time (src/json-file.mjs): every page in view ticks, from every project.
|
|
72
|
+
export function tickAsk(root, now = Date.now(), options = {}) {
|
|
73
|
+
return oneAtATime(join(root, FILE), () => tick(root, now, options));
|
|
74
|
+
}
|
|
75
|
+
async function tick(root, now, { quietSince = null } = {}) {
|
|
76
|
+
const state = await readAsk(root);
|
|
77
|
+
const at = new Date(now).toISOString();
|
|
78
|
+
if (!state.first) state.first = at;
|
|
79
|
+
const last = state.lastTick ? Date.parse(state.lastTick) : 0;
|
|
80
|
+
if (!last || now - last >= TICK_MS) {
|
|
81
|
+
if (last) state.minutes += 1;
|
|
82
|
+
state.lastTick = at;
|
|
83
|
+
await writeAsk(root, state);
|
|
84
|
+
}
|
|
85
|
+
const hushed = quietSince && now - Date.parse(quietSince) < QUIET_MS;
|
|
86
|
+
const due = hushed ? null : dueMoment(state, now);
|
|
87
|
+
if (!due) return { due: null, minutes: state.minutes };
|
|
88
|
+
if (state.offered?.moment === due && now - Date.parse(state.offered.at) < OFFER_HOLD_MS) return { due: null, minutes: state.minutes };
|
|
89
|
+
state.offered = { moment: due, at: new Date(now).toISOString() };
|
|
90
|
+
await writeAsk(root, state);
|
|
91
|
+
return { due, minutes: state.minutes };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// The person answered the moment they were asked at. Every moment reached by then counts as shown,
|
|
95
|
+
// so an earlier one never comes after a later one; any answer but «not now» ends the asking.
|
|
96
|
+
export function answerAsk(root, said, now = Date.now()) {
|
|
97
|
+
return oneAtATime(join(root, FILE), () => recordAnswer(root, said, now));
|
|
98
|
+
}
|
|
99
|
+
async function recordAnswer(root, { moment, answer }, now) {
|
|
100
|
+
if (!ASK_ANSWERS.includes(answer)) return { error: 'answer' };
|
|
101
|
+
if (!ASK_MOMENTS.some(({ id }) => id === moment)) return { error: 'moment' };
|
|
102
|
+
const state = await readAsk(root);
|
|
103
|
+
const at = new Date(now).toISOString();
|
|
104
|
+
if (!state.first) state.first = at;
|
|
105
|
+
for (const one of ASK_MOMENTS) {
|
|
106
|
+
if (!state.shown[one.id] && (one.id === moment || one.reached(state, now))) state.shown[one.id] = at;
|
|
107
|
+
}
|
|
108
|
+
if (answer !== 'later') state.answer = answer;
|
|
109
|
+
await writeAsk(root, state);
|
|
110
|
+
return { answer: state.answer, shown: state.shown };
|
|
111
|
+
}
|
package/src/room/archivist.mjs
CHANGED
|
@@ -19,6 +19,12 @@ export function distillDefaults(distill = {}, env = process.env) {
|
|
|
19
19
|
};
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
// The project's memory is one, and every conversation alive at once has its archivist (2026-10-12,
|
|
23
|
+
// LOG-06 of the 1.0 review): each asked for the same undistilled batch and paid a provider for it,
|
|
24
|
+
// and the notes came out twice. One batch at a time per memory: whoever finds one being distilled
|
|
25
|
+
// waits for it and then reads what is left.
|
|
26
|
+
const distilling = new WeakMap(); // memory -> the batch being distilled
|
|
27
|
+
|
|
22
28
|
export class Archivist {
|
|
23
29
|
#memory;
|
|
24
30
|
#settings;
|
|
@@ -81,49 +87,61 @@ export class Archivist {
|
|
|
81
87
|
if (!this.#memory) return null;
|
|
82
88
|
if (this.#running) return this.#running;
|
|
83
89
|
this.#running = (async () => {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const busy = this.#deps.busyAgents();
|
|
88
|
-
const now = Date.now();
|
|
89
|
-
for (const [id, until] of this.#bench) if (until <= now) this.#bench.delete(id);
|
|
90
|
-
const benched = new Set(this.#bench.keys());
|
|
91
|
-
const candidates = this.#candidates();
|
|
92
|
-
let agent = pickDistiller(candidates, { preferred: this.#settings.agent, busy, invokers, benched });
|
|
93
|
-
if (!agent && benched.size) { this.#bench.clear(); agent = pickDistiller(candidates, { preferred: this.#settings.agent, busy, invokers }); }
|
|
94
|
-
if (!agent) return null;
|
|
95
|
-
const started = Date.now();
|
|
90
|
+
while (distilling.has(this.#memory)) await distilling.get(this.#memory).catch(() => null);
|
|
91
|
+
let release;
|
|
92
|
+
distilling.set(this.#memory, new Promise((resolve) => { release = resolve; }));
|
|
96
93
|
try {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const memories = parseDistillation(result?.text, { fromSequence: batch.fromSequence, throughSequence: batch.throughSequence });
|
|
102
|
-
const added = this.#memory.addMemories(memories, { agent: agent.id, fromSequence: batch.fromSequence, throughSequence: batch.throughSequence });
|
|
103
|
-
this.#memory.markDistilled(batch.sequences);
|
|
104
|
-
this.#failures.delete(batch.fromSequence);
|
|
105
|
-
// Local tokens cost nothing and count against no provider budget; they are reported, not charged.
|
|
106
|
-
if (!local) await this.#deps.recordUsage(agent.id, result?.usage ?? null);
|
|
107
|
-
const kinds = {};
|
|
108
|
-
for (const memory of memories) kinds[memory.kind] = (kinds[memory.kind] ?? 0) + 1;
|
|
109
|
-
const report = { agent: agent.id, local, model: local ? result?.usage?.model ?? null : this.#settings.model, tokens: result?.usage?.totalTokens ?? null, added, parsed: memories.length, considered: batch.entries.length, fromSequence: batch.fromSequence, throughSequence: batch.throughSequence, remaining: batch.remaining, kinds, elapsedMs: Date.now() - started, total: this.#memory.memoryCount() };
|
|
110
|
-
await this.#deps.emit('memory.distilled', report);
|
|
111
|
-
return report;
|
|
112
|
-
} catch (error) {
|
|
113
|
-
const attempts = (this.#failures.get(batch.fromSequence) ?? 0) + 1;
|
|
114
|
-
this.#failures.set(batch.fromSequence, attempts);
|
|
115
|
-
const skipped = attempts >= 3;
|
|
116
|
-
if (skipped) { this.#memory.markDistilled(batch.sequences); this.#failures.delete(batch.fromSequence); }
|
|
117
|
-
this.#bench.set(agent.id, Date.now() + BENCH_MS);
|
|
118
|
-
const next = pickDistiller(candidates, { preferred: this.#settings.agent, busy: new Set(), invokers, benched: new Set(this.#bench.keys()) });
|
|
119
|
-
const report = { agent: agent.id, error: this.#deps.failureMessage(error), attempts, skipped, next: next?.id ?? null, fromSequence: batch.fromSequence, throughSequence: batch.throughSequence, considered: batch.entries.length, remaining: batch.remaining };
|
|
120
|
-
await this.#deps.emit('memory.distilled', report);
|
|
121
|
-
return report;
|
|
94
|
+
return await this.#distil();
|
|
95
|
+
} finally {
|
|
96
|
+
distilling.delete(this.#memory);
|
|
97
|
+
release();
|
|
122
98
|
}
|
|
123
99
|
})().finally(() => { this.#running = null; });
|
|
124
100
|
return this.#running;
|
|
125
101
|
}
|
|
126
102
|
|
|
103
|
+
async #distil() {
|
|
104
|
+
const batch = this.#memory.undistilled({ maxChars: this.#settings.maxChars });
|
|
105
|
+
if (!batch.entries.length) return null;
|
|
106
|
+
const invokers = this.#deps.invokers();
|
|
107
|
+
const busy = this.#deps.busyAgents();
|
|
108
|
+
const now = Date.now();
|
|
109
|
+
for (const [id, until] of this.#bench) if (until <= now) this.#bench.delete(id);
|
|
110
|
+
const benched = new Set(this.#bench.keys());
|
|
111
|
+
const candidates = this.#candidates();
|
|
112
|
+
let agent = pickDistiller(candidates, { preferred: this.#settings.agent, busy, invokers, benched });
|
|
113
|
+
if (!agent && benched.size) { this.#bench.clear(); agent = pickDistiller(candidates, { preferred: this.#settings.agent, busy, invokers }); }
|
|
114
|
+
if (!agent) return null;
|
|
115
|
+
const started = Date.now();
|
|
116
|
+
try {
|
|
117
|
+
const local = agent.adapter === 'ollama';
|
|
118
|
+
const brief = this.#memory.briefing();
|
|
119
|
+
const prompt = distillPrompt({ entries: batch.entries, projectName: this.#deps.projectName(), existing: brief.standing, discarded: brief.discarded, json: local });
|
|
120
|
+
const result = await invokers[agent.adapter]({ executable: agent.path, projectRoot: this.#deps.projectRoot(), prompt, timeoutMs: local ? this.#deps.localTimeoutMs() : this.#deps.timeoutFor(agent.id), model: local ? null : this.#settings.model, json: local, attachments: [], lease: null, scopes: { web: false, imageGen: false }, imageStudio: null });
|
|
121
|
+
const memories = parseDistillation(result?.text, { fromSequence: batch.fromSequence, throughSequence: batch.throughSequence });
|
|
122
|
+
const added = this.#memory.addMemories(memories, { agent: agent.id, fromSequence: batch.fromSequence, throughSequence: batch.throughSequence });
|
|
123
|
+
this.#memory.markDistilled(batch.sequences);
|
|
124
|
+
this.#failures.delete(batch.fromSequence);
|
|
125
|
+
// Local tokens cost nothing and count against no provider budget; they are reported, not charged.
|
|
126
|
+
if (!local) await this.#deps.recordUsage(agent.id, result?.usage ?? null);
|
|
127
|
+
const kinds = {};
|
|
128
|
+
for (const memory of memories) kinds[memory.kind] = (kinds[memory.kind] ?? 0) + 1;
|
|
129
|
+
const report = { agent: agent.id, local, model: local ? result?.usage?.model ?? null : this.#settings.model, tokens: result?.usage?.totalTokens ?? null, added, parsed: memories.length, considered: batch.entries.length, fromSequence: batch.fromSequence, throughSequence: batch.throughSequence, remaining: batch.remaining, kinds, elapsedMs: Date.now() - started, total: this.#memory.memoryCount() };
|
|
130
|
+
await this.#deps.emit('memory.distilled', report);
|
|
131
|
+
return report;
|
|
132
|
+
} catch (error) {
|
|
133
|
+
const attempts = (this.#failures.get(batch.fromSequence) ?? 0) + 1;
|
|
134
|
+
this.#failures.set(batch.fromSequence, attempts);
|
|
135
|
+
const skipped = attempts >= 3;
|
|
136
|
+
if (skipped) { this.#memory.markDistilled(batch.sequences); this.#failures.delete(batch.fromSequence); }
|
|
137
|
+
this.#bench.set(agent.id, Date.now() + BENCH_MS);
|
|
138
|
+
const next = pickDistiller(candidates, { preferred: this.#settings.agent, busy: new Set(), invokers, benched: new Set(this.#bench.keys()) });
|
|
139
|
+
const report = { agent: agent.id, error: this.#deps.failureMessage(error), attempts, skipped, next: next?.id ?? null, fromSequence: batch.fromSequence, throughSequence: batch.throughSequence, considered: batch.entries.length, remaining: batch.remaining };
|
|
140
|
+
await this.#deps.emit('memory.distilled', report);
|
|
141
|
+
return report;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
127
145
|
// Tests: run a distillation that is due by count without waiting for its
|
|
128
146
|
// timer, leave an idle wait alone, and let a run in flight finish.
|
|
129
147
|
async settle() {
|
package/src/room/budget.mjs
CHANGED
|
@@ -2,14 +2,26 @@
|
|
|
2
2
|
// providers' short windows), raw totals for the record, and the seeding of the
|
|
3
3
|
// usage sentinel from the ledger on start.
|
|
4
4
|
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// Which CLIs count what they read back from a cache INSIDE the input they report: Codex
|
|
6
|
+
// (input_tokens includes cached_input_tokens) and Gemini (its prompt count includes the cached
|
|
7
|
+
// part; stream-json even prints both). Claude and OpenCode report the cache beside the input.
|
|
8
|
+
// Summed as if they were all the same, the economy called 2.1 M tokens «charged as fresh» in a
|
|
9
|
+
// room where the fresh reading was 0.36 M, and counted the same cache twice.
|
|
10
|
+
export const INPUT_HOLDS_CACHE = new Set(['codex-json', 'gemini-json']);
|
|
11
|
+
const num = (value) => (Number.isFinite(Number(value)) ? Number(value) : 0);
|
|
12
|
+
export function freshInput(usage = {}) {
|
|
13
|
+
const input = num(usage.inputTokens);
|
|
14
|
+
return INPUT_HOLDS_CACHE.has(usage.source) ? Math.max(0, input - num(usage.cachedInputTokens)) : input;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// What a turn costs against MADRE's local budget. Cache reads are close to free at every
|
|
18
|
+
// provider, so they weigh a tenth, and they are taken out of the input first where a CLI counts
|
|
19
|
+
// them inside it.
|
|
8
20
|
export function budgetTokens(usage = {}) {
|
|
9
21
|
if (usage.local) return 0;
|
|
10
|
-
const n =
|
|
22
|
+
const n = num;
|
|
11
23
|
const cached = n(usage.cachedInputTokens);
|
|
12
|
-
const input =
|
|
24
|
+
const input = freshInput(usage);
|
|
13
25
|
const fresh = input + n(usage.cacheCreationInputTokens) + n(usage.outputTokens) + n(usage.reasoningTokens);
|
|
14
26
|
if (!fresh && !cached) return n(usage.totalTokens);
|
|
15
27
|
return Math.round(fresh + cached * 0.1);
|
package/src/room/control.mjs
CHANGED
|
@@ -2,18 +2,53 @@
|
|
|
2
2
|
// is taken before, the diff is read after, writes into forbidden zones are
|
|
3
3
|
// restored on the spot, and the checkpoint stays for UNDO. One holder at a time.
|
|
4
4
|
|
|
5
|
-
import { createCheckpoint, diffCheckpoint, restoreCheckpoint } from '../checkpoint.mjs';
|
|
5
|
+
import { createCheckpoint, diffCheckpoint, restoreCheckpoint, settleControls } from '../checkpoint.mjs';
|
|
6
6
|
import { guardForbidden } from './guard.mjs';
|
|
7
7
|
|
|
8
8
|
export class ControlDesk {
|
|
9
9
|
#projectRoot;
|
|
10
10
|
#holder = null; // { agent, messageId, checkpoint, since }
|
|
11
11
|
#checkpoints = new Map(); // id -> checkpoint (for UNDO after the turn)
|
|
12
|
+
// Every turn writing in the project right now, CREATE or CONTROL, in any conversation. Two at
|
|
13
|
+
// once cannot be told apart afterwards: each settles against its own photograph and claims, or
|
|
14
|
+
// puts back, what the other wrote. A README one conversation's OpenCode created was listed as
|
|
15
|
+
// the work of another conversation's Codex. So the project has one writer at a time.
|
|
16
|
+
#writers = new Set();
|
|
17
|
+
#waiting = [];
|
|
12
18
|
|
|
13
|
-
|
|
19
|
+
#guardRecord;
|
|
20
|
+
|
|
21
|
+
// `guardRecord`: where the locks of a turn are written down, outside the project (room/guard.mjs).
|
|
22
|
+
constructor({ projectRoot, guardRecord = null }) {
|
|
14
23
|
this.#projectRoot = projectRoot;
|
|
24
|
+
this.#guardRecord = guardRecord;
|
|
15
25
|
}
|
|
16
26
|
get holder() { return this.#holder; }
|
|
27
|
+
// Who is writing now, for the line that says why a turn is waiting.
|
|
28
|
+
writer() { return [...this.#writers][0] ?? null; }
|
|
29
|
+
|
|
30
|
+
// The project's one pen: taken the moment it is free, in the same tick it is seen free, so two
|
|
31
|
+
// turns can never both find it free. Waits otherwise; a stop while waiting rejects, and the
|
|
32
|
+
// turn ends having written nothing.
|
|
33
|
+
async #takePen(signal = null, agent = null) {
|
|
34
|
+
while (this.#writers.size) {
|
|
35
|
+
await new Promise((resolve, reject) => {
|
|
36
|
+
const entry = { resolve, reject };
|
|
37
|
+
this.#waiting.push(entry);
|
|
38
|
+
signal?.addEventListener?.('abort', () => {
|
|
39
|
+
this.#waiting = this.#waiting.filter((one) => one !== entry);
|
|
40
|
+
reject(Object.assign(new Error(String(signal.reason ?? 'stopped')), { code: 'ABORT_ERR' }));
|
|
41
|
+
}, { once: true });
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
const pen = { pen: true, agent };
|
|
45
|
+
this.#writers.add(pen);
|
|
46
|
+
return pen;
|
|
47
|
+
}
|
|
48
|
+
#wakeOne() {
|
|
49
|
+
if (this.#writers.size) return;
|
|
50
|
+
this.#waiting.shift()?.resolve();
|
|
51
|
+
}
|
|
17
52
|
status() {
|
|
18
53
|
return this.#holder ? { agent: this.#holder.agent, messageId: this.#holder.messageId, checkpointId: this.#holder.checkpoint.id, since: this.#holder.since } : null;
|
|
19
54
|
}
|
|
@@ -22,14 +57,25 @@ export class ControlDesk {
|
|
|
22
57
|
// makes the whole project writable, and what the room should announce.
|
|
23
58
|
// mode 3 (CONTROL) seats one holder for the whole project; mode 2 (CREATE) takes the same
|
|
24
59
|
// photograph without a seat: the project is writable for new files, and settle() puts back
|
|
25
|
-
// whatever existed before.
|
|
26
|
-
|
|
27
|
-
|
|
60
|
+
// whatever existed before. One writing turn at a time in the project, across conversations: a
|
|
61
|
+
// second one waits for the pen (see #writers).
|
|
62
|
+
async begin({ agent, messageId, enabledScopes, mode = 3, signal = null }) {
|
|
63
|
+
const pen = await this.#takePen(signal, agent.id);
|
|
64
|
+
let checkpoint;
|
|
65
|
+
try {
|
|
66
|
+
checkpoint = await createCheckpoint(this.#projectRoot, { id: `${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}-${messageId.slice(0, 8)}`, label: `MADRE ${mode === 3 ? 'control' : 'create'} @${agent.id}` });
|
|
67
|
+
} catch (error) {
|
|
68
|
+
this.#writers.delete(pen);
|
|
69
|
+
this.#wakeOne();
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
28
72
|
checkpoint.agent = agent.id;
|
|
29
73
|
this.#checkpoints.set(checkpoint.id, checkpoint);
|
|
30
74
|
// Prevention first: .env files and MADRE's folders are read-only for the length of the turn.
|
|
31
|
-
const guard = await guardForbidden(this.#projectRoot);
|
|
75
|
+
const guard = await guardForbidden(this.#projectRoot, { record: this.#guardRecord });
|
|
32
76
|
const run = { agent: agent.id, messageId, checkpoint, since: new Date().toISOString(), guard, mode, scratchDir: null };
|
|
77
|
+
this.#writers.delete(pen);
|
|
78
|
+
this.#writers.add(run);
|
|
33
79
|
if (mode >= 3) this.#holder = run;
|
|
34
80
|
const lease = { leaseId: checkpoint.id, outDir: this.#projectRoot, relativeDir: '.', scopes: { ...enabledScopes, write: true }, control: mode >= 3, airlock: mode === 4, create: mode === 2, checkpoint };
|
|
35
81
|
if (mode < 3) return { run, lease, announcement: null };
|
|
@@ -41,6 +87,10 @@ export class ControlDesk {
|
|
|
41
87
|
// What really changed, with forbidden zones already restored. In a #2 turn every change to
|
|
42
88
|
// a file that existed before (modified, deleted, renamed) is restored too: CREATE adds, only.
|
|
43
89
|
async settle(run) {
|
|
90
|
+
// What the turn wrote into a repository's own settings, hooks or attributes goes back first,
|
|
91
|
+
// before git runs again (src/checkpoint.mjs). Not after #4: there the agent runs commands
|
|
92
|
+
// already, and configuring a remote it was asked to configure is its work.
|
|
93
|
+
const controls = run.mode === 4 ? [] : await settleControls(run.checkpoint);
|
|
44
94
|
const diff = await diffCheckpoint(this.#projectRoot, run.checkpoint);
|
|
45
95
|
const additive = run.mode === 2;
|
|
46
96
|
// The draft folder MADRE itself hands the agent lives under `.pulse/out/`, and `.pulse/` is a
|
|
@@ -58,7 +108,7 @@ export class ControlDesk {
|
|
|
58
108
|
reverted = [...restored.restored, ...restored.removed];
|
|
59
109
|
}
|
|
60
110
|
const kept = diff.files.filter((file) => !toRevert.has(file.path));
|
|
61
|
-
const changes = { checkpointId: run.checkpoint.id, agent: run.agent, messageId: run.messageId, mode: run.mode ?? 3, files: kept, stat: diff.stat, forbiddenReverted: reverted.filter((path) => diff.forbidden.includes(path) && !mine(path)), existingReverted: additive ? reverted.filter((path) => !diff.forbidden.includes(path)) : [] };
|
|
111
|
+
const changes = { checkpointId: run.checkpoint.id, agent: run.agent, messageId: run.messageId, mode: run.mode ?? 3, files: kept, stat: diff.stat, forbiddenReverted: [...controls, ...reverted.filter((path) => diff.forbidden.includes(path) && !mine(path))], existingReverted: additive ? reverted.filter((path) => !diff.forbidden.includes(path)) : [] };
|
|
62
112
|
const count = changes.files.length;
|
|
63
113
|
const notes = [
|
|
64
114
|
changes.forbiddenReverted.length ? `${changes.forbiddenReverted.length} write(s) into forbidden zones were reverted.` : null,
|
|
@@ -68,11 +118,24 @@ export class ControlDesk {
|
|
|
68
118
|
return changes;
|
|
69
119
|
}
|
|
70
120
|
|
|
121
|
+
// What changed, or — when git cannot say — that it could not, with the checkpoint still there
|
|
122
|
+
// for UNDO. A turn that finished is never turned into a failed one by the reading of it: its
|
|
123
|
+
// answer was lost along with the list when this threw.
|
|
124
|
+
async settleOrSay(run) {
|
|
125
|
+
try {
|
|
126
|
+
return await this.settle(run);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
const reason = String(error?.stderr || error?.message || error).split('\n').map((line) => line.trim()).filter(Boolean).at(-1) ?? 'unknown error';
|
|
129
|
+
return { checkpointId: run.checkpoint.id, agent: run.agent, messageId: run.messageId, mode: run.mode ?? 3, files: [], stat: '', forbiddenReverted: [], existingReverted: [], unreadable: reason, message: `MADRE could not read what @${run.agent} changed (${reason.slice(0, 160)}). The checkpoint taken before the turn is still there: UNDO goes back to it.` };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
71
133
|
// Whichever way the turn ended, the seat is free again and the locks come off; the checkpoint stays.
|
|
72
134
|
async release(run) {
|
|
73
135
|
if (!run) return;
|
|
74
136
|
await run.guard?.release?.();
|
|
75
137
|
if (this.#holder === run) this.#holder = null;
|
|
138
|
+
if (this.#writers.delete(run)) this.#wakeOne();
|
|
76
139
|
}
|
|
77
140
|
|
|
78
141
|
// A photograph with no seat and no locks, for a command the human runs by hand: the human may
|
package/src/room/economy.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { freshInput } from './budget.mjs';
|
|
2
|
+
|
|
1
3
|
// What a turn actually costs, and where it went.
|
|
2
4
|
//
|
|
3
5
|
// Pure functions here. The room hands in the blocks a prompt was built from and what the CLI
|
|
@@ -60,7 +62,9 @@ export function turnCost(parts = [], usage = null) {
|
|
|
60
62
|
chars += size;
|
|
61
63
|
}
|
|
62
64
|
const fixed = Object.entries(blocks).reduce((sum, [id, size]) => sum + (ALWAYS.has(id) ? size : 0), 0);
|
|
63
|
-
|
|
65
|
+
// Fresh reading only: a CLI that counts its cache inside the input has it taken out here, so
|
|
66
|
+
// every turn's `input` means the same thing whoever answered it.
|
|
67
|
+
const input = usage ? freshInput(usage) : 0;
|
|
64
68
|
const cached = Number(usage?.cachedInputTokens ?? 0) || 0;
|
|
65
69
|
const created = Number(usage?.cacheCreationInputTokens ?? 0) || 0;
|
|
66
70
|
const output = Number(usage?.outputTokens ?? 0) || 0;
|
|
@@ -70,7 +74,7 @@ export function turnCost(parts = [], usage = null) {
|
|
|
70
74
|
const perToken = input > 0 ? Number((chars / input).toFixed(2)) : null;
|
|
71
75
|
return {
|
|
72
76
|
chars, fixed, carried: chars - fixed, blocks, prefix: stablePrefix(parts),
|
|
73
|
-
input, cached, created, output,
|
|
77
|
+
input, cached, created, output, basis: 'fresh',
|
|
74
78
|
cacheShare: input + cached > 0 ? Number((cached / (input + cached)).toFixed(3)) : null,
|
|
75
79
|
charsPerInputToken: perToken,
|
|
76
80
|
};
|
|
@@ -164,14 +168,22 @@ export function economy(events = []) {
|
|
|
164
168
|
for (const event of events) {
|
|
165
169
|
if (event?.type !== 'turn.cost') continue;
|
|
166
170
|
const cost = event.payload;
|
|
167
|
-
if (cost
|
|
171
|
+
if (!cost || typeof cost !== 'object') continue;
|
|
172
|
+
// Weighed before `input` meant fresh reading for everyone: Codex and Gemini had their cache
|
|
173
|
+
// inside it. Read the old record the way it was written.
|
|
174
|
+
const legacy = cost.basis !== 'fresh' && ['codex', 'gemini'].includes(cost.agent);
|
|
175
|
+
turns.push(legacy ? { ...cost, input: Math.max(0, (Number(cost.input) || 0) - (Number(cost.cached) || 0)), basis: 'fresh' } : cost);
|
|
168
176
|
}
|
|
169
177
|
if (!turns.length) return { turns: 0, blocks: [], agents: [], totals: null, free: { turns: 0, share: 0, paidOutputMedian: null }, ash: ashObservation([]) };
|
|
170
178
|
|
|
179
|
+
// What was charged is read from the turns that had a bill. @madre answers on this computer: it
|
|
180
|
+
// reports the tokens its model read, and those were never charged, so its turns are counted
|
|
181
|
+
// below as turns with no bill and never added to what was.
|
|
182
|
+
const billed = turns.filter((turn) => turn.agent !== 'madre');
|
|
171
183
|
const blocks = new Map();
|
|
172
184
|
const agents = new Map();
|
|
173
185
|
const totals = { chars: 0, fixed: 0, carried: 0, prefix: 0, input: 0, cached: 0, created: 0, output: 0 };
|
|
174
|
-
for (const turn of
|
|
186
|
+
for (const turn of billed) {
|
|
175
187
|
for (const key of Object.keys(totals)) totals[key] += Number(turn[key] ?? 0) || 0;
|
|
176
188
|
for (const [id, size] of Object.entries(turn.blocks ?? {})) {
|
|
177
189
|
const seen = blocks.get(id) ?? { id, chars: 0, turns: 0, always: ALWAYS.has(id) };
|
|
@@ -180,9 +192,9 @@ export function economy(events = []) {
|
|
|
180
192
|
blocks.set(id, seen);
|
|
181
193
|
}
|
|
182
194
|
const who = turn.agent ?? 'unknown';
|
|
183
|
-
const mine = agents.get(who) ?? { agent: who, turns: 0, input: 0, cached: 0, output: 0, chars: 0 };
|
|
195
|
+
const mine = agents.get(who) ?? { agent: who, turns: 0, input: 0, cached: 0, created: 0, output: 0, chars: 0 };
|
|
184
196
|
mine.turns += 1;
|
|
185
|
-
for (const key of ['input', 'cached', 'output', 'chars']) mine[key] += Number(turn[key] ?? 0) || 0;
|
|
197
|
+
for (const key of ['input', 'cached', 'created', 'output', 'chars']) mine[key] += Number(turn[key] ?? 0) || 0;
|
|
186
198
|
agents.set(who, mine);
|
|
187
199
|
}
|
|
188
200
|
|
|
@@ -211,7 +223,7 @@ export function economy(events = []) {
|
|
|
211
223
|
tokens: totals.cached + tokensFor(spared),
|
|
212
224
|
};
|
|
213
225
|
|
|
214
|
-
// Turns the room answered itself. @madre runs on this computer and
|
|
226
|
+
// Turns the room answered itself. @madre runs on this computer and nothing it reads is billed, so
|
|
215
227
|
// these are not a saving measured against something — they are turns with no bill. The median
|
|
216
228
|
// of what a paid turn cost in THIS room is given beside them so the count has a scale, and it
|
|
217
229
|
// is named as what it is: what other turns cost, not what these would have.
|
|
@@ -223,8 +235,24 @@ export function economy(events = []) {
|
|
|
223
235
|
paidOutputMedian: paidOutputs.length ? median(paidOutputs) : null,
|
|
224
236
|
};
|
|
225
237
|
|
|
238
|
+
// What an MCP server carried costs: its tool list rides in every call a CLI makes inside a turn.
|
|
239
|
+
// Set against the same agent's turns without it, and only once each side has two turns, because
|
|
240
|
+
// one agent's turns already differ by an order of magnitude from one request to the next.
|
|
241
|
+
const servers = [];
|
|
242
|
+
const weighed = billed.filter((turn) => Array.isArray(turn.servers) && !turn.failed);
|
|
243
|
+
for (const agent of new Set(weighed.map((turn) => turn.agent))) {
|
|
244
|
+
const own = weighed.filter((turn) => turn.agent === agent);
|
|
245
|
+
for (const server of new Set(own.flatMap((turn) => turn.servers))) {
|
|
246
|
+
const carried = own.filter((turn) => turn.servers.includes(server)).map((turn) => Number(turn.input) || 0);
|
|
247
|
+
const without = own.filter((turn) => !turn.servers.includes(server)).map((turn) => Number(turn.input) || 0);
|
|
248
|
+
if (carried.length >= 2 && without.length >= 2) servers.push({ agent, server, with: carried.length, without: without.length, medianWith: median(carried), medianWithout: median(without) });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
226
252
|
return {
|
|
227
253
|
turns: turns.length,
|
|
254
|
+
billed: billed.length,
|
|
255
|
+
servers,
|
|
228
256
|
saved,
|
|
229
257
|
free: freeTurns,
|
|
230
258
|
ash: ashObservation(turns),
|