@jossuealcala/madre 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/CONTRIBUTING.md +32 -1
- package/README.md +39 -6
- package/SECURITY.md +1 -1
- package/bin/madre.mjs +37 -3
- package/package.json +4 -3
- package/public/app.js +247 -7
- package/public/brands.js +18 -0
- package/public/index.html +5 -0
- package/public/styles.css +20 -0
- package/public/troubleshooting.js +24 -2
- package/src/adapters/madre.mjs +85 -0
- package/src/auth-probe.mjs +1 -0
- package/src/capabilities.mjs +1 -0
- package/src/dataset.mjs +85 -0
- package/src/distiller.mjs +16 -6
- package/src/embeddings.mjs +7 -1
- package/src/extensions.mjs +24 -224
- package/src/memory.mjs +3 -2
- package/src/modules/ahp.mjs +64 -0
- package/src/modules/ashcode.mjs +28 -0
- package/src/modules/git-pulse.mjs +26 -0
- package/src/modules/helpers.mjs +30 -0
- package/src/modules/image-studio.mjs +39 -0
- package/src/modules/index.mjs +21 -0
- package/src/modules/ollama.mjs +56 -0
- package/src/modules/ripley.mjs +20 -0
- package/src/modules/sdk.mjs +78 -0
- package/src/ollama.mjs +118 -0
- package/src/room/archivist.mjs +141 -0
- package/src/room/attachments.mjs +15 -0
- package/src/room/budget.mjs +83 -0
- package/src/room/context.mjs +31 -0
- package/src/room/control.mjs +66 -0
- package/src/room/escalation.mjs +41 -0
- package/src/room/ghost.mjs +32 -0
- package/src/room/guard.mjs +52 -0
- package/src/room/prompt.mjs +62 -0
- package/src/room/vectors.mjs +47 -0
- package/src/room.mjs +126 -388
- package/src/server.mjs +190 -53
- package/src/setup.mjs +25 -7
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// OLLAMA: local intelligence. Embeddings and distillation on this machine when
|
|
2
|
+
// Ollama runs. The server offers the wiring through ctx.services.ollama.
|
|
3
|
+
|
|
4
|
+
import { defineModule } from './sdk.mjs';
|
|
5
|
+
import { RECOMMENDED } from '../ollama.mjs';
|
|
6
|
+
|
|
7
|
+
export default defineModule({
|
|
8
|
+
id: 'ollama',
|
|
9
|
+
name: 'OLLAMA',
|
|
10
|
+
vendor: 'MADRE · LOCAL INTELLIGENCE',
|
|
11
|
+
summary: 'Recall by meaning and memory distillation on this machine through Ollama: no provider tokens, nothing leaves. Needs Ollama running with an embedding model and a chat model; MADRE can pull the recommended ones.',
|
|
12
|
+
creates: ['nothing in the project', 'an ollama block in ~/.pulse/config.json', 'models in Ollama\'s own store when you press PULL'],
|
|
13
|
+
requires: ['Ollama installed and running (ollama serve, or the Ollama app)'],
|
|
14
|
+
settings: { enabled: true, embeddings: true, archivist: true, agent: true },
|
|
15
|
+
card: 'ollama',
|
|
16
|
+
async status(ctx) {
|
|
17
|
+
const probe = ctx.services.ollama?.state() ?? { running: false, models: [], embedModel: null, chatModel: null };
|
|
18
|
+
const settings = ctx.settings;
|
|
19
|
+
const roles = [settings.embeddings && probe.embedModel ? `embeddings · ${probe.embedModel}` : null, settings.archivist && probe.chatModel ? `archivist · ${probe.chatModel}` : null, settings.agent !== false && probe.chatModel ? '@madre in the room' : null].filter(Boolean);
|
|
20
|
+
const detail = !probe.running ? 'not running · start Ollama and RECHECK'
|
|
21
|
+
: !settings.enabled ? `off · ${probe.models.length} model${probe.models.length === 1 ? '' : 's'} available`
|
|
22
|
+
: roles.length ? `on · ${roles.join(' · ')}` : 'on · no usable model yet · PULL one';
|
|
23
|
+
return {
|
|
24
|
+
models: probe.models.map((model) => model.name),
|
|
25
|
+
status: { installed: settings.enabled && probe.running && roles.length > 0, detail },
|
|
26
|
+
ollama: { ...probe, settings },
|
|
27
|
+
recommended: RECOMMENDED,
|
|
28
|
+
preflight: probe.running ? { ok: true, problems: [] } : { ok: false, problems: ['Ollama is not running: open the Ollama app or run `ollama serve`, then RECHECK.'] },
|
|
29
|
+
install: { display: settings.enabled ? 'disable Ollama' : 'enable Ollama (config.json)', platforms: [] },
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
async toggle(ctx) {
|
|
33
|
+
const enabled = !(ctx.settings.enabled ?? true);
|
|
34
|
+
await ctx.updateConfig({ modules: { ...(ctx.config.modules ?? {}), ollama: { ...(ctx.config.modules?.ollama ?? {}), enabled } } });
|
|
35
|
+
const status = await ctx.services.ollama.wire();
|
|
36
|
+
await ctx.record('extension.toggled', { id: 'ollama', name: 'OLLAMA', enabled });
|
|
37
|
+
return { status: 200, body: { enabled, ollama: status } };
|
|
38
|
+
},
|
|
39
|
+
routes: [
|
|
40
|
+
{ method: 'GET', path: '/api/ollama', handler: async (ctx) => ({ status: 200, body: { ollama: await ctx.services.ollama.wire({ probe: false }), recommended: RECOMMENDED } }) },
|
|
41
|
+
{ method: 'POST', path: '/api/ollama/probe', handler: async (ctx) => ({ status: 200, body: { ollama: await ctx.services.ollama.wire(), recommended: RECOMMENDED } }) },
|
|
42
|
+
{ method: 'POST', path: '/api/ollama/settings', handler: async (ctx, { payload }) => {
|
|
43
|
+
const next = { ...(ctx.config.modules?.ollama ?? {}) };
|
|
44
|
+
for (const key of ['embeddings', 'archivist', 'agent', 'enabled']) if (typeof payload[key] === 'boolean') next[key] = payload[key];
|
|
45
|
+
await ctx.updateConfig({ modules: { ...(ctx.config.modules ?? {}), ollama: next } });
|
|
46
|
+
return { status: 200, body: { ollama: await ctx.services.ollama.wire({ probe: false }) } };
|
|
47
|
+
} },
|
|
48
|
+
{ method: 'POST', path: '/api/ollama/pull', handler: async (ctx, { payload }) => {
|
|
49
|
+
const model = String(payload.model ?? '').trim();
|
|
50
|
+
if (!/^[a-z0-9][a-z0-9._:/-]{1,80}$/i.test(model)) return { status: 400, body: { error: 'Give a model name like nomic-embed-text or qwen2.5:3b.' } };
|
|
51
|
+
if (!ctx.services.ollama.state().running) return { status: 412, body: { error: 'Ollama is not running.' } };
|
|
52
|
+
const started = await ctx.services.ollama.pull(model);
|
|
53
|
+
return started.ok ? { status: 202, body: { pulling: model } } : { status: 409, body: { error: started.error } };
|
|
54
|
+
} },
|
|
55
|
+
],
|
|
56
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// RIPLEY: the file viewer renders HTML, SVG and Markdown inside a sealed frame.
|
|
2
|
+
// A switch in config.json, read live by the preview route.
|
|
3
|
+
|
|
4
|
+
import { defineModule } from './sdk.mjs';
|
|
5
|
+
|
|
6
|
+
export default defineModule({
|
|
7
|
+
id: 'ripley',
|
|
8
|
+
name: 'RIPLEY',
|
|
9
|
+
vendor: 'MADRE · PREVIEW',
|
|
10
|
+
summary: 'Renders HTML, SVG and Markdown from the project and from .pulse/out in the file viewer, inside a sealed frame: scripts run but nothing leaves, nothing is stored, nothing reaches MADRE. Nothing leaves the room.',
|
|
11
|
+
creates: ['nothing in the project', 'a ripley switch in ~/.pulse/config.json'],
|
|
12
|
+
card: 'ripley',
|
|
13
|
+
async status(ctx) {
|
|
14
|
+
return {
|
|
15
|
+
status: { installed: Boolean(ctx.settings.enabled), detail: ctx.settings.enabled ? 'on · PREVIEW in the file viewer' : 'off · files show as source' },
|
|
16
|
+
install: { display: ctx.settings.enabled ? 'disable RIPLEY' : 'enable RIPLEY (config.json)', platforms: [] },
|
|
17
|
+
fixed: false,
|
|
18
|
+
};
|
|
19
|
+
},
|
|
20
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// MADRE's module SDK. A module is one file: what it is, the settings it keeps
|
|
2
|
+
// in ~/.pulse/config.json, how it describes itself to MODULES, what its switch
|
|
3
|
+
// does, the routes it serves and the events it listens to. The server builds a
|
|
4
|
+
// context (ctx) per call and hands it in; the module never reaches for globals.
|
|
5
|
+
//
|
|
6
|
+
// ctx = { projectRoot, stateRoot, config, settings, env, agents, room, readConfig(), updateConfig(patch),
|
|
7
|
+
// record(type, payload), services: { ... what the server offers } }
|
|
8
|
+
//
|
|
9
|
+
// Kinds: 'builtin' switches MADRE's own behaviour (config.json only);
|
|
10
|
+
// 'installer' writes into the project through a confirmed command.
|
|
11
|
+
|
|
12
|
+
const camel = (id) => id.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
13
|
+
|
|
14
|
+
export function defineModule(spec) {
|
|
15
|
+
if (!spec?.id || !/^[a-z][a-z0-9-]*$/.test(spec.id)) throw new Error(`Module id must be kebab-case: ${spec?.id}`);
|
|
16
|
+
if (!spec.name) throw new Error(`Module ${spec.id} needs a name.`);
|
|
17
|
+
const kind = spec.kind ?? 'builtin';
|
|
18
|
+
const configKey = spec.configKey ?? camel(spec.id);
|
|
19
|
+
const defaults = { ...(kind === 'builtin' ? { enabled: false } : {}), ...(spec.settings ?? {}) };
|
|
20
|
+
const base = {
|
|
21
|
+
id: spec.id, kind, name: spec.name, vendor: spec.vendor ?? 'MADRE', package: spec.package ?? null, version: spec.version ?? '0.1.0',
|
|
22
|
+
summary: spec.summary ?? '', creates: spec.creates ?? [], requires: spec.requires ?? [], models: spec.models ?? [], commands: spec.commands,
|
|
23
|
+
card: spec.card ?? (kind === 'builtin' ? 'switch' : 'installer'),
|
|
24
|
+
};
|
|
25
|
+
const settingsFrom = (config) => ({ ...defaults, ...(config?.modules?.[configKey] ?? {}) });
|
|
26
|
+
const module = {
|
|
27
|
+
...base,
|
|
28
|
+
configKey,
|
|
29
|
+
defaults,
|
|
30
|
+
settingsFrom,
|
|
31
|
+
routes: (spec.routes ?? []).map((route) => ({ ...route, method: route.method.toUpperCase() })),
|
|
32
|
+
onEvent: spec.onEvent ?? null,
|
|
33
|
+
conditions: spec.conditions ?? [],
|
|
34
|
+
// Legacy installer hooks, kept on the object so the confirm-and-run path can use them.
|
|
35
|
+
detect: spec.detect ?? null,
|
|
36
|
+
preflight: spec.preflight ?? null,
|
|
37
|
+
installCommand: spec.installCommand ?? null,
|
|
38
|
+
|
|
39
|
+
// What MODULES shows: base fields plus status, preflight, install line and whatever the module adds.
|
|
40
|
+
async describe(ctx) {
|
|
41
|
+
const settings = settingsFrom(ctx.config);
|
|
42
|
+
const own = spec.status ? await spec.status({ ...ctx, settings }) : {};
|
|
43
|
+
const installed = own.installed ?? (kind === 'builtin' ? Boolean(settings.enabled) : false);
|
|
44
|
+
return {
|
|
45
|
+
...base,
|
|
46
|
+
...own,
|
|
47
|
+
status: own.status ?? { installed, detail: own.detail ?? (installed ? 'on' : 'off') },
|
|
48
|
+
preflight: own.preflight ?? { ok: true, problems: [] },
|
|
49
|
+
install: own.install ?? (kind === 'builtin' ? { display: installed ? `disable ${base.name}` : `enable ${base.name} (config.json)`, platforms: [] } : { display: '', platforms: [] }),
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
// The switch. Default for builtins: flip `enabled`, persist, tell the room.
|
|
54
|
+
// A module may guard it (`confirm`) or replace it (`toggle`).
|
|
55
|
+
toggle: kind === 'builtin' || spec.toggle ? async (ctx, payload = {}) => {
|
|
56
|
+
const settings = settingsFrom(ctx.config);
|
|
57
|
+
if (spec.toggle) return spec.toggle({ ...ctx, settings }, payload);
|
|
58
|
+
const enabled = !settings.enabled;
|
|
59
|
+
if (enabled && spec.confirm && payload.confirm !== true) return { status: 400, body: { error: spec.confirm } };
|
|
60
|
+
await ctx.updateConfig({ modules: { ...(ctx.config.modules ?? {}), [configKey]: { ...(ctx.config.modules?.[configKey] ?? {}), enabled } } });
|
|
61
|
+
const after = spec.onToggle ? await spec.onToggle({ ...ctx, settings: { ...settings, enabled } }, enabled) : null;
|
|
62
|
+
await ctx.record('extension.toggled', { id: spec.id, name: base.name, enabled, ...(spec.toggledEvent ?? {}) });
|
|
63
|
+
return { status: 200, body: { enabled, ...(after ?? {}), ...(spec.toggledBody?.(enabled) ?? {}) } };
|
|
64
|
+
} : null,
|
|
65
|
+
};
|
|
66
|
+
return Object.freeze(module);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Matches a request against a module's routes; params come from a RegExp path.
|
|
70
|
+
export function matchRoute(routes, method, pathname) {
|
|
71
|
+
for (const route of routes) {
|
|
72
|
+
if (route.method !== method) continue;
|
|
73
|
+
if (typeof route.path === 'string') { if (route.path === pathname) return { route, params: [] }; continue; }
|
|
74
|
+
const match = pathname.match(route.path);
|
|
75
|
+
if (match) return { route, params: match.slice(1) };
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
package/src/ollama.mjs
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Ollama: the local intelligence MADRE leans on when it is there. Embeddings
|
|
2
|
+
// for recall by meaning and a model to distil memories, both on this machine,
|
|
3
|
+
// so remembering costs no tokens elsewhere and nothing leaves. Nothing here is
|
|
4
|
+
// required: without Ollama every path falls back to what it did before.
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_OLLAMA_HOST = 'http://127.0.0.1:11434';
|
|
7
|
+
// Embedding models we know how to talk to, best first.
|
|
8
|
+
export const EMBED_MODELS = ['nomic-embed-text', 'mxbai-embed-large', 'snowflake-arctic-embed', 'all-minilm', 'bge-m3'];
|
|
9
|
+
// Chat models that follow instructions well enough to distil, best first for a 16 GB machine.
|
|
10
|
+
// General chat models first: @madre speaks for the room in the human's language, and coder
|
|
11
|
+
// models drift into other voices. A coder is taken only when nothing else is there.
|
|
12
|
+
export const CHAT_MODELS = ['qwen2.5:7b', 'llama3.1:8b', 'gemma3:4b', 'qwen2.5:3b', 'llama3.2:3b', 'qwen2.5:1.5b', 'llama3.2:1b', 'qwen2.5-coder:7b', 'qwen2.5-coder:1.5b'];
|
|
13
|
+
// Ollama's default window is 4k tokens and it drops the OLDEST text when a prompt overflows:
|
|
14
|
+
// the system prompt goes first. Every MADRE call asks for a wider window.
|
|
15
|
+
export const DEFAULT_NUM_CTX = 8192;
|
|
16
|
+
export const RECOMMENDED = { embed: 'nomic-embed-text', chat: 'qwen2.5:3b' };
|
|
17
|
+
|
|
18
|
+
export function ollamaHost(env = process.env) {
|
|
19
|
+
return (env.PULSE_OLLAMA_HOST ?? env.OLLAMA_HOST ?? DEFAULT_OLLAMA_HOST).replace(/\/$/, '').replace(/^(?!https?:\/\/)/, 'http://');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const isEmbedModel = (model) => EMBED_MODELS.some((name) => model.name.startsWith(name)) || /embed|minilm|bge/i.test(model.name) || /bert|nomic/i.test(model.details?.family ?? '');
|
|
23
|
+
function pick(models, preferred, env) {
|
|
24
|
+
const names = models.map((model) => model.name);
|
|
25
|
+
const bare = (name) => name.replace(/:latest$/, '');
|
|
26
|
+
if (env && names.some((name) => bare(name) === bare(env))) return names.find((name) => bare(name) === bare(env));
|
|
27
|
+
for (const want of preferred) { const hit = names.find((name) => bare(name) === want || bare(name).startsWith(`${want}`)); if (hit) return hit; }
|
|
28
|
+
return names[0] ?? null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Is Ollama there, and what can it do? Quick, never throws.
|
|
32
|
+
export async function probeOllama({ host = ollamaHost(), fetchImpl = globalThis.fetch, timeoutMs = 1500, env = process.env } = {}) {
|
|
33
|
+
try {
|
|
34
|
+
const response = await fetchImpl(`${host}/api/tags`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
35
|
+
if (!response.ok) return { running: false, host, models: [], embedModel: null, chatModel: null, error: `HTTP ${response.status}` };
|
|
36
|
+
const payload = await response.json();
|
|
37
|
+
const models = (payload.models ?? []).map((model) => ({ name: model.name, size: model.size ?? 0, family: model.details?.family ?? '', details: model.details ?? {} }));
|
|
38
|
+
const embeds = models.filter(isEmbedModel);
|
|
39
|
+
const chats = models.filter((model) => !isEmbedModel(model));
|
|
40
|
+
return {
|
|
41
|
+
running: true, host, models,
|
|
42
|
+
embedModel: pick(embeds, EMBED_MODELS, env.PULSE_OLLAMA_EMBED_MODEL),
|
|
43
|
+
chatModel: pick(chats, CHAT_MODELS, env.PULSE_OLLAMA_MODEL),
|
|
44
|
+
};
|
|
45
|
+
} catch (error) {
|
|
46
|
+
return { running: false, host, models: [], embedModel: null, chatModel: null, error: error.message };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// An embedder the memory understands: { model, dims, embed(texts, { query }) }.
|
|
51
|
+
// nomic wants a task prefix; the others take the text as it is.
|
|
52
|
+
export function ollamaEmbedder({ host = ollamaHost(), model, fetchImpl = globalThis.fetch, timeoutMs = 60000 } = {}) {
|
|
53
|
+
if (!model) return null;
|
|
54
|
+
const nomic = model.startsWith('nomic');
|
|
55
|
+
return {
|
|
56
|
+
model: `ollama:${model}`,
|
|
57
|
+
dims: null,
|
|
58
|
+
local: true,
|
|
59
|
+
async embed(texts, { query = false } = {}) {
|
|
60
|
+
const input = texts.map((text) => `${nomic ? (query ? 'search_query: ' : 'search_document: ') : ''}${String(text ?? '').slice(0, 2000) || ' '}`);
|
|
61
|
+
const response = await fetchImpl(`${host}/api/embed`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ model, input }), signal: AbortSignal.timeout(timeoutMs) });
|
|
62
|
+
if (!response.ok) throw new Error(`Ollama embeddings HTTP ${response.status}: ${(await response.text().catch(() => '')).slice(0, 200)}`);
|
|
63
|
+
const payload = await response.json();
|
|
64
|
+
const vectors = payload.embeddings ?? [];
|
|
65
|
+
if (vectors.length !== texts.length) throw new Error(`Ollama returned ${vectors.length} vectors for ${texts.length} texts.`);
|
|
66
|
+
return vectors.map((values) => Float32Array.from(values));
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// One answer from a local model. `json: true` asks Ollama for a JSON object.
|
|
72
|
+
export async function ollamaGenerate({ host = ollamaHost(), model, prompt, system = null, json = false, fetchImpl = globalThis.fetch, timeoutMs = 180000, temperature = 0.2, numCtx = DEFAULT_NUM_CTX } = {}) {
|
|
73
|
+
const started = Date.now();
|
|
74
|
+
const messages = [...(system ? [{ role: 'system', content: system }] : []), { role: 'user', content: prompt }];
|
|
75
|
+
const response = await fetchImpl(`${host}/api/chat`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ model, messages, stream: false, options: { temperature, num_ctx: numCtx }, ...(json ? { format: 'json' } : {}) }), signal: AbortSignal.timeout(timeoutMs) });
|
|
76
|
+
if (!response.ok) throw new Error(`Ollama HTTP ${response.status}: ${(await response.text().catch(() => '')).slice(0, 200)}`);
|
|
77
|
+
const payload = await response.json();
|
|
78
|
+
const inputTokens = payload.prompt_eval_count ?? 0;
|
|
79
|
+
const outputTokens = payload.eval_count ?? 0;
|
|
80
|
+
return {
|
|
81
|
+
text: String(payload.message?.content ?? '').trim(),
|
|
82
|
+
usage: { inputTokens, outputTokens, cachedInputTokens: 0, reasoningTokens: 0, totalTokens: inputTokens + outputTokens, costUsd: 0, source: 'ollama', local: true, model },
|
|
83
|
+
elapsedMs: Date.now() - started,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The shape the room's invokers have: ({ prompt, timeoutMs }) → { text, usage }.
|
|
88
|
+
export function ollamaInvoker({ host = ollamaHost(), model, fetchImpl = globalThis.fetch } = {}) {
|
|
89
|
+
if (!model) return null;
|
|
90
|
+
return async ({ prompt, timeoutMs = 180000, json = false }) => ollamaGenerate({ host, model, prompt, json, fetchImpl, timeoutMs });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Pulls a model, reporting progress lines as Ollama streams them.
|
|
94
|
+
export async function pullModel({ host = ollamaHost(), model, fetchImpl = globalThis.fetch, onLine = () => {}, timeoutMs = 30 * 60 * 1000 } = {}) {
|
|
95
|
+
const response = await fetchImpl(`${host}/api/pull`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ model, stream: true }), signal: AbortSignal.timeout(timeoutMs) });
|
|
96
|
+
if (!response.ok) throw new Error(`Ollama pull HTTP ${response.status}: ${(await response.text().catch(() => '')).slice(0, 200)}`);
|
|
97
|
+
const reader = response.body.getReader();
|
|
98
|
+
const decoder = new TextDecoder();
|
|
99
|
+
let buffer = '';
|
|
100
|
+
let lastStatus = '';
|
|
101
|
+
for (;;) {
|
|
102
|
+
const { value, done } = await reader.read();
|
|
103
|
+
if (done) break;
|
|
104
|
+
buffer += decoder.decode(value, { stream: true });
|
|
105
|
+
let index;
|
|
106
|
+
while ((index = buffer.indexOf('\n')) >= 0) {
|
|
107
|
+
const line = buffer.slice(0, index).trim();
|
|
108
|
+
buffer = buffer.slice(index + 1);
|
|
109
|
+
if (!line) continue;
|
|
110
|
+
let event;
|
|
111
|
+
try { event = JSON.parse(line); } catch { continue; }
|
|
112
|
+
if (event.error) throw new Error(event.error);
|
|
113
|
+
const status = event.total ? `${event.status} · ${Math.round((event.completed ?? 0) / event.total * 100)}%` : event.status;
|
|
114
|
+
if (status && status !== lastStatus) { lastStatus = status; onLine(status); }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { model };
|
|
118
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// The archivist: every so often the cheapest allowed agent reads what nobody
|
|
2
|
+
// has distilled and keeps the few notes worth remembering. Scheduling by count
|
|
3
|
+
// or by quiet, one batch per run, a bench for whoever fails, a report to the
|
|
4
|
+
// room either way. The room hands in what it knows through `deps`.
|
|
5
|
+
|
|
6
|
+
import { pickDistiller, distillPrompt, parseDistillation, OLLAMA_ARCHIVIST } from '../distiller.mjs';
|
|
7
|
+
|
|
8
|
+
export const BENCH_MS = 30 * 60 * 1000;
|
|
9
|
+
|
|
10
|
+
export function distillDefaults(distill = {}, env = process.env) {
|
|
11
|
+
return {
|
|
12
|
+
enabled: distill.enabled ?? env.PULSE_DISTILL !== '0',
|
|
13
|
+
every: Math.max(1, Number(distill.every ?? env.PULSE_DISTILL_EVERY ?? 10)),
|
|
14
|
+
idleMs: Math.max(1000, Number(distill.idleMs ?? env.PULSE_DISTILL_IDLE_MS ?? 10 * 60 * 1000)),
|
|
15
|
+
maxChars: Math.max(500, Number(distill.maxChars ?? env.PULSE_DISTILL_MAX_CHARS ?? 6000)),
|
|
16
|
+
agent: distill.agent ?? env.PULSE_DISTILL_AGENT ?? null,
|
|
17
|
+
model: distill.model ?? env.PULSE_DISTILL_MODEL ?? null,
|
|
18
|
+
allowed: Array.isArray(distill.allowed) && distill.allowed.length ? [...distill.allowed] : null, // who may distil; null = anyone
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class Archivist {
|
|
23
|
+
#memory;
|
|
24
|
+
#settings;
|
|
25
|
+
#deps;
|
|
26
|
+
#timer = null;
|
|
27
|
+
#running = null;
|
|
28
|
+
#failures = new Map(); // fromSequence -> failed attempts on that batch
|
|
29
|
+
#bench = new Map(); // agent -> until (ms)
|
|
30
|
+
|
|
31
|
+
// deps: agents(), invokers(), busyAgents(), turnsInFlight(), timeoutFor(id), localTimeoutMs(), projectName(), emit(type, payload), recordUsage(agent, usage), stopped(), failureMessage(error)
|
|
32
|
+
constructor({ memory, settings, deps }) {
|
|
33
|
+
this.#memory = memory;
|
|
34
|
+
this.#settings = settings;
|
|
35
|
+
this.#deps = deps;
|
|
36
|
+
}
|
|
37
|
+
get inflight() { return this.#running; }
|
|
38
|
+
settings() { return { ...this.#settings, allowed: this.#settings.allowed ? [...this.#settings.allowed] : null }; }
|
|
39
|
+
|
|
40
|
+
configure(patch = {}) {
|
|
41
|
+
const s = this.#settings;
|
|
42
|
+
if (typeof patch.enabled === 'boolean') s.enabled = patch.enabled;
|
|
43
|
+
if (Number(patch.every) >= 1) s.every = Math.trunc(Number(patch.every));
|
|
44
|
+
if (Number(patch.idleMs) >= 1000) s.idleMs = Math.trunc(Number(patch.idleMs));
|
|
45
|
+
if (Number(patch.maxChars) >= 500) s.maxChars = Math.trunc(Number(patch.maxChars));
|
|
46
|
+
if ('agent' in patch) s.agent = patch.agent ? String(patch.agent) : null;
|
|
47
|
+
if ('allowed' in patch) s.allowed = Array.isArray(patch.allowed) && patch.allowed.length ? [...patch.allowed] : null;
|
|
48
|
+
this.#bench.clear();
|
|
49
|
+
return this.settings();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// After a turn: run now if enough piled up and the room is quiet, else wait for quiet.
|
|
53
|
+
schedule() {
|
|
54
|
+
if (this.#deps.stopped() || !this.#memory || !this.#settings.enabled) return;
|
|
55
|
+
clearTimeout(this.#timer);
|
|
56
|
+
this.#timer = null;
|
|
57
|
+
let pending = 0;
|
|
58
|
+
try { pending = this.#memory.undistilledCount(); } catch { return; }
|
|
59
|
+
if (pending < 2) return;
|
|
60
|
+
const wait = pending >= this.#settings.every ? (this.#deps.turnsInFlight() ? 3000 : 0) : this.#settings.idleMs;
|
|
61
|
+
this.#timer = setTimeout(() => {
|
|
62
|
+
this.#timer = null;
|
|
63
|
+
if (this.#deps.turnsInFlight()) { this.schedule(); return; }
|
|
64
|
+
void this.runNow();
|
|
65
|
+
}, wait);
|
|
66
|
+
this.#timer.unref?.();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
#candidates() {
|
|
70
|
+
const invokers = this.#deps.invokers();
|
|
71
|
+
// @madre answers questions; the archive is written by the Ollama archivist itself.
|
|
72
|
+
const agents = this.#deps.agents().filter((agent) => agent.adapter !== 'madre-local');
|
|
73
|
+
const everyone = invokers.ollama ? [OLLAMA_ARCHIVIST, ...agents] : agents;
|
|
74
|
+
return this.#settings.allowed ? everyone.filter((agent) => this.#settings.allowed.includes(agent.id)) : everyone;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// One batch: the cheapest usable agent reads it, well-formed notes are kept,
|
|
78
|
+
// the batch is marked, tokens are counted. A batch that fails three times is
|
|
79
|
+
// skipped so a poisoned range cannot stall the archive; whoever failed sits out.
|
|
80
|
+
async runNow() {
|
|
81
|
+
if (!this.#memory) return null;
|
|
82
|
+
if (this.#running) return this.#running;
|
|
83
|
+
this.#running = (async () => {
|
|
84
|
+
const batch = this.#memory.undistilled({ maxChars: this.#settings.maxChars });
|
|
85
|
+
if (!batch.entries.length) return null;
|
|
86
|
+
const invokers = this.#deps.invokers();
|
|
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();
|
|
96
|
+
try {
|
|
97
|
+
const local = agent.adapter === 'ollama';
|
|
98
|
+
const prompt = distillPrompt({ entries: batch.entries, projectName: this.#deps.projectName(), existing: this.#memory.memories({ limit: 12 }), json: local });
|
|
99
|
+
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 });
|
|
100
|
+
const memories = parseDistillation(result?.text, { fromSequence: batch.fromSequence, throughSequence: batch.throughSequence });
|
|
101
|
+
const added = this.#memory.addMemories(memories, { agent: agent.id, fromSequence: batch.fromSequence, throughSequence: batch.throughSequence });
|
|
102
|
+
this.#memory.markDistilled(batch.sequences);
|
|
103
|
+
this.#failures.delete(batch.fromSequence);
|
|
104
|
+
// Local tokens cost nothing and count against no provider budget; they are reported, not charged.
|
|
105
|
+
if (!local) await this.#deps.recordUsage(agent.id, result?.usage ?? null);
|
|
106
|
+
const kinds = {};
|
|
107
|
+
for (const memory of memories) kinds[memory.kind] = (kinds[memory.kind] ?? 0) + 1;
|
|
108
|
+
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() };
|
|
109
|
+
await this.#deps.emit('memory.distilled', report);
|
|
110
|
+
return report;
|
|
111
|
+
} catch (error) {
|
|
112
|
+
const attempts = (this.#failures.get(batch.fromSequence) ?? 0) + 1;
|
|
113
|
+
this.#failures.set(batch.fromSequence, attempts);
|
|
114
|
+
const skipped = attempts >= 3;
|
|
115
|
+
if (skipped) { this.#memory.markDistilled(batch.sequences); this.#failures.delete(batch.fromSequence); }
|
|
116
|
+
this.#bench.set(agent.id, Date.now() + BENCH_MS);
|
|
117
|
+
const next = pickDistiller(candidates, { preferred: this.#settings.agent, busy: new Set(), invokers, benched: new Set(this.#bench.keys()) });
|
|
118
|
+
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 };
|
|
119
|
+
await this.#deps.emit('memory.distilled', report);
|
|
120
|
+
return report;
|
|
121
|
+
}
|
|
122
|
+
})().finally(() => { this.#running = null; });
|
|
123
|
+
return this.#running;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Tests: run a distillation that is due by count without waiting for its
|
|
127
|
+
// timer, leave an idle wait alone, and let a run in flight finish.
|
|
128
|
+
async settle() {
|
|
129
|
+
if (this.#timer && !this.#deps.turnsInFlight() && this.#memory && this.#memory.undistilledCount() >= this.#settings.every) {
|
|
130
|
+
clearTimeout(this.#timer);
|
|
131
|
+
this.#timer = null;
|
|
132
|
+
await this.runNow();
|
|
133
|
+
}
|
|
134
|
+
await this.#running;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
stop() {
|
|
138
|
+
clearTimeout(this.#timer);
|
|
139
|
+
this.#timer = null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Human uploads, stored under the room (never in the project). Registered
|
|
2
|
+
// here so a message can reference them by id; the ledger lets a restarted
|
|
3
|
+
// room rebuild the registry.
|
|
4
|
+
|
|
5
|
+
import { dirname } from 'node:path';
|
|
6
|
+
|
|
7
|
+
export class Attachments {
|
|
8
|
+
#records = new Map();
|
|
9
|
+
register(record) {
|
|
10
|
+
this.#records.set(record.id, { ...record, dir: dirname(record.path) });
|
|
11
|
+
return this.#records.get(record.id);
|
|
12
|
+
}
|
|
13
|
+
restore(records) { for (const record of records) this.#records.set(record.id, record); }
|
|
14
|
+
get(id) { return this.#records.get(id) ?? null; }
|
|
15
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// The local token budget: a rolling window per agent (five hours, like the
|
|
2
|
+
// providers' short windows), raw totals for the record, and the seeding of the
|
|
3
|
+
// usage sentinel from the ledger on start.
|
|
4
|
+
|
|
5
|
+
// What a turn costs against MADRE's local budget. Cache reads are close to
|
|
6
|
+
// free at every provider, so they weigh a tenth; Codex counts cached tokens
|
|
7
|
+
// inside its input, so they are taken out before weighing.
|
|
8
|
+
export function budgetTokens(usage = {}) {
|
|
9
|
+
if (usage.local) return 0;
|
|
10
|
+
const n = (value) => (Number.isFinite(Number(value)) ? Number(value) : 0);
|
|
11
|
+
const cached = n(usage.cachedInputTokens);
|
|
12
|
+
const input = usage.source === 'codex-json' ? Math.max(0, n(usage.inputTokens) - cached) : n(usage.inputTokens);
|
|
13
|
+
const fresh = input + n(usage.cacheCreationInputTokens) + n(usage.outputTokens) + n(usage.reasoningTokens);
|
|
14
|
+
if (!fresh && !cached) return n(usage.totalTokens);
|
|
15
|
+
return Math.round(fresh + cached * 0.1);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class Budget {
|
|
19
|
+
#window = new Map(); // agent -> [{ at, spent }]
|
|
20
|
+
#raw = new Map(); // agent -> raw total tokens ever
|
|
21
|
+
#windowMs;
|
|
22
|
+
softBudget;
|
|
23
|
+
|
|
24
|
+
constructor({ softBudget = 500000, windowMs = Number(process.env.PULSE_BUDGET_WINDOW_MS ?? 5 * 3600 * 1000) } = {}) {
|
|
25
|
+
this.softBudget = softBudget;
|
|
26
|
+
this.#windowMs = windowMs;
|
|
27
|
+
}
|
|
28
|
+
get windowMs() { return this.#windowMs; }
|
|
29
|
+
|
|
30
|
+
// Replays usage from the ledger and tells the sentinel where each ring stood.
|
|
31
|
+
seed(events, sentinel) {
|
|
32
|
+
for (const event of events) {
|
|
33
|
+
if (event.type === 'usage.recorded') {
|
|
34
|
+
this.#raw.set(event.payload.agent, event.payload.roomTotalTokens);
|
|
35
|
+
const at = new Date(event.timestamp ?? 0).getTime() || 0;
|
|
36
|
+
const spent = event.payload.budgetTokens ?? budgetTokens(event.payload.usage ?? {});
|
|
37
|
+
const list = this.#window.get(event.payload.agent) ?? [];
|
|
38
|
+
list.push({ at, spent });
|
|
39
|
+
this.#window.set(event.payload.agent, list);
|
|
40
|
+
}
|
|
41
|
+
if (sentinel && (event.type === 'quota.updated' || event.type === 'limit.warning')) {
|
|
42
|
+
// An old report whose window has since reset must not seed a full ring.
|
|
43
|
+
const passed = event.payload.resetAt && new Date(event.payload.resetAt).getTime() <= Date.now();
|
|
44
|
+
sentinel.seed(passed ? { ...event.payload, usedPercent: 0 } : event.payload);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (sentinel && Number.isFinite(this.softBudget) && this.softBudget > 0) {
|
|
48
|
+
for (const agent of this.#window.keys()) {
|
|
49
|
+
sentinel.seed({ agent, usedPercent: (this.windowTotal(agent) / this.softBudget) * 100, source: 'room-soft-budget' });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Budget tokens spent by an agent inside the rolling window, dropping what fell out.
|
|
55
|
+
windowTotal(agent, now = Date.now()) {
|
|
56
|
+
const list = (this.#window.get(agent) ?? []).filter((entry) => now - entry.at < this.#windowMs);
|
|
57
|
+
this.#window.set(agent, list);
|
|
58
|
+
return list.reduce((sum, entry) => sum + entry.spent, 0);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The local window as the UI should see it now: totals recomputed so a ring
|
|
62
|
+
// empties when the window rolls over, even without a new turn.
|
|
63
|
+
view(agents) {
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
return Object.fromEntries(agents.map((agent) => {
|
|
66
|
+
const total = this.windowTotal(agent.id, now);
|
|
67
|
+
const oldest = (this.#window.get(agent.id) ?? [])[0]?.at ?? null;
|
|
68
|
+
return [agent.id, { tokens: total, rawTokens: this.#raw.get(agent.id) ?? 0, windowMs: this.#windowMs, rollsOverAt: oldest ? new Date(oldest + this.#windowMs).toISOString() : null }];
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Charges a turn and returns what the ledger should record and what the ring shows.
|
|
73
|
+
record(agent, usage) {
|
|
74
|
+
const spent = budgetTokens(usage);
|
|
75
|
+
const list = this.#window.get(agent) ?? [];
|
|
76
|
+
list.push({ at: Date.now(), spent });
|
|
77
|
+
this.#window.set(agent, list);
|
|
78
|
+
const total = this.windowTotal(agent);
|
|
79
|
+
const rawTotal = (this.#raw.get(agent) ?? 0) + (usage.totalTokens ?? 0);
|
|
80
|
+
this.#raw.set(agent, rawTotal);
|
|
81
|
+
return { spent, total, rawTotal, windowMs: this.#windowMs, usedPercent: this.softBudget > 0 ? (total / this.softBudget) * 100 : null, projectedPercent: this.softBudget > 0 ? ((total + spent) / this.softBudget) * 100 : null };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// The context an agent gets: the recent transcript verbatim and, when the
|
|
2
|
+
// room is longer than that window, the older exchanges that match this
|
|
3
|
+
// request, recalled from memory inside the same character budget.
|
|
4
|
+
|
|
5
|
+
import { buildConversationContext } from '../conversation-context.mjs';
|
|
6
|
+
|
|
7
|
+
export async function contextFor({ memory, priorEvents, messageId, text, contextMaxChars, recallShare, remember = () => {} }) {
|
|
8
|
+
const full = buildConversationContext(priorEvents, { excludeMessageId: messageId, maxChars: contextMaxChars });
|
|
9
|
+
const none = { context: full, recall: null, memories: null };
|
|
10
|
+
if (!memory || !full.omittedMessages || recallShare <= 0) return none;
|
|
11
|
+
// Another server may have written this room: index what we have not seen.
|
|
12
|
+
const last = memory.lastSequence();
|
|
13
|
+
remember(priorEvents.filter((event) => event.sequence > last));
|
|
14
|
+
const recallBudget = Math.floor(contextMaxChars * recallShare);
|
|
15
|
+
const recent = buildConversationContext(priorEvents, { excludeMessageId: messageId, maxChars: contextMaxChars - recallBudget });
|
|
16
|
+
const before = recent.firstSequence ?? Number.MAX_SAFE_INTEGER;
|
|
17
|
+
let memories = null;
|
|
18
|
+
let recall = null;
|
|
19
|
+
try {
|
|
20
|
+
// One embedding of the request lets both lookups match meaning; without it they match words.
|
|
21
|
+
const queryVector = await memory.embedQuery(text);
|
|
22
|
+
// Distilled notes first (dense, cheap), exact quotes with what is left.
|
|
23
|
+
memories = memory.recallMemories(text, { beforeSequence: before, maxChars: Math.floor(recallBudget * 0.4), queryVector });
|
|
24
|
+
const spent = memories.reduce((sum, item) => sum + item.text.length + 24, 0);
|
|
25
|
+
recall = memory.recall(text, { beforeSequence: before, excludeMessageId: messageId, maxChars: recallBudget - spent, queryVector });
|
|
26
|
+
} catch (error) {
|
|
27
|
+
console.error(`MADRE memory recall failed: ${error.message}`);
|
|
28
|
+
}
|
|
29
|
+
if (!recall?.entries?.length && !memories?.length) return none;
|
|
30
|
+
return { context: recent, recall: recall?.entries?.length ? recall : null, memories: memories?.length ? memories : null };
|
|
31
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// CONTROL: one agent holds the project itself for one turn. A git checkpoint
|
|
2
|
+
// is taken before, the diff is read after, writes into forbidden zones are
|
|
3
|
+
// restored on the spot, and the checkpoint stays for UNDO. One holder at a time.
|
|
4
|
+
|
|
5
|
+
import { createCheckpoint, diffCheckpoint, restoreCheckpoint } from '../checkpoint.mjs';
|
|
6
|
+
import { guardForbidden } from './guard.mjs';
|
|
7
|
+
|
|
8
|
+
export class ControlDesk {
|
|
9
|
+
#projectRoot;
|
|
10
|
+
#holder = null; // { agent, messageId, checkpoint, since }
|
|
11
|
+
#checkpoints = new Map(); // id -> checkpoint (for UNDO after the turn)
|
|
12
|
+
|
|
13
|
+
constructor({ projectRoot }) {
|
|
14
|
+
this.#projectRoot = projectRoot;
|
|
15
|
+
}
|
|
16
|
+
get holder() { return this.#holder; }
|
|
17
|
+
status() {
|
|
18
|
+
return this.#holder ? { agent: this.#holder.agent, messageId: this.#holder.messageId, checkpointId: this.#holder.checkpoint.id, since: this.#holder.since } : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Takes the checkpoint and seats the agent. Returns the run, the lease that
|
|
22
|
+
// makes the whole project writable, and what the room should announce.
|
|
23
|
+
async begin({ agent, messageId, enabledScopes }) {
|
|
24
|
+
const checkpoint = await createCheckpoint(this.#projectRoot, { id: `${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}-${messageId.slice(0, 8)}`, label: `MADRE control @${agent.id}` });
|
|
25
|
+
checkpoint.agent = agent.id;
|
|
26
|
+
this.#checkpoints.set(checkpoint.id, checkpoint);
|
|
27
|
+
// Prevention first: .env files and MADRE's folders are read-only for the length of the turn.
|
|
28
|
+
const guard = await guardForbidden(this.#projectRoot);
|
|
29
|
+
const run = { agent: agent.id, messageId, checkpoint, since: new Date().toISOString(), guard };
|
|
30
|
+
this.#holder = run;
|
|
31
|
+
const lease = { leaseId: checkpoint.id, outDir: this.#projectRoot, relativeDir: '.', scopes: { ...enabledScopes, write: true }, control: true, checkpoint };
|
|
32
|
+
const guarded = guard.locked.length ? ` ${guard.locked.length} forbidden path${guard.locked.length === 1 ? '' : 's'} locked read-only for the turn (${guard.locked.slice(0, 4).join(', ')}${guard.locked.length > 4 ? ', …' : ''}).` : '';
|
|
33
|
+
const announcement = { checkpointId: checkpoint.id, commit: checkpoint.commit, head: checkpoint.head, agent: agent.id, messageId, guarded: guard.locked, message: `@${agent.id} holds CONTROL of the project. Checkpoint ${checkpoint.commit.slice(0, 7)} taken; UNDO will be one click.${guarded}` };
|
|
34
|
+
return { run, lease, announcement };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// What really changed, with forbidden zones already restored.
|
|
38
|
+
async settle(run) {
|
|
39
|
+
const diff = await diffCheckpoint(this.#projectRoot, run.checkpoint);
|
|
40
|
+
let reverted = [];
|
|
41
|
+
if (diff.forbidden.length) {
|
|
42
|
+
const restored = await restoreCheckpoint(this.#projectRoot, run.checkpoint, { paths: diff.forbidden });
|
|
43
|
+
reverted = [...restored.restored, ...restored.removed];
|
|
44
|
+
}
|
|
45
|
+
const changes = { checkpointId: run.checkpoint.id, agent: run.agent, messageId: run.messageId, files: diff.files.filter((file) => !diff.forbidden.includes(file.path)), stat: diff.stat, forbiddenReverted: reverted };
|
|
46
|
+
const count = changes.files.length;
|
|
47
|
+
const note = reverted.length ? ` ${reverted.length} write(s) into forbidden zones were reverted.` : '';
|
|
48
|
+
changes.message = `${count ? `@${run.agent} changed ${count} file(s) in the project.` : `@${run.agent} changed nothing in the project.`}${note}`;
|
|
49
|
+
return changes;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Whichever way the turn ended, the seat is free again and the locks come off; the checkpoint stays.
|
|
53
|
+
async release(run) {
|
|
54
|
+
if (!run) return;
|
|
55
|
+
await run.guard?.release?.();
|
|
56
|
+
if (this.#holder === run) this.#holder = null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async undo(checkpointId) {
|
|
60
|
+
const checkpoint = this.#checkpoints.get(checkpointId);
|
|
61
|
+
if (!checkpoint) return { ok: false, status: 404, error: 'That checkpoint is not known to this room.' };
|
|
62
|
+
if (this.#holder?.checkpoint.id === checkpointId) return { ok: false, status: 409, error: 'That CONTROL turn is still running; STOPALL first.' };
|
|
63
|
+
const result = await restoreCheckpoint(this.#projectRoot, checkpoint);
|
|
64
|
+
return { ok: true, checkpoint, ...result, message: `Project restored to the checkpoint taken before @${checkpoint.agent}'s CONTROL turn: ${result.restored.length} file(s) restored, ${result.removed.length} removed.` };
|
|
65
|
+
}
|
|
66
|
+
}
|