@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.
@@ -0,0 +1,85 @@
1
+ // The dataset behind MADRE AI: every exchange the room kept (never GHOST),
2
+ // redacted, as chat pairs a small model can learn from, plus the distilled
3
+ // notes. Written next to the room's ledger in the format mlx-lm and most
4
+ // fine-tuners read: one {"messages": [...]} per line, split train / valid.
5
+ // Export is the first step of roadmap 2c; training runs outside MADRE, on the
6
+ // human's machine, from docs/training/.
7
+
8
+ import { mkdir, writeFile } from 'node:fs/promises';
9
+ import { join } from 'node:path';
10
+ import { redact } from './sentinel-errors.mjs';
11
+
12
+ const MIN_ANSWER_CHARS = 40;
13
+
14
+ function system(project, agent) {
15
+ return `You are @${agent} in the MADRE room of the project "${project}". Answer the human directly and concisely; distinguish facts from inference; name files, agents and numbers exactly.`;
16
+ }
17
+
18
+ // Pairs a user message with the assistant reply that answered it (parentMessageId).
19
+ export function pairsFromEvents(events, { project = 'project', home, user } = {}) {
20
+ const users = new Map();
21
+ const pairs = [];
22
+ for (const event of events) {
23
+ if (event.type !== 'message.created' || event.ghost || !Number.isInteger(event.sequence)) continue;
24
+ const p = event.payload ?? {};
25
+ if (typeof p.text !== 'string' || !p.text.trim()) continue;
26
+ if (p.role === 'user') { users.set(p.messageId, { text: p.text, mode: p.mode ?? 1, sequence: event.sequence }); continue; }
27
+ if (p.role !== 'assistant' || p.status === 'delegated') continue;
28
+ const ask = users.get(p.parentMessageId);
29
+ if (!ask) continue;
30
+ const answer = (p.originalText ?? p.text).trim();
31
+ if (answer.length < MIN_ANSWER_CHARS) continue;
32
+ pairs.push({
33
+ kind: 'turn',
34
+ agent: p.sender,
35
+ mode: p.mode ?? ask.mode,
36
+ sequence: event.sequence,
37
+ at: event.timestamp,
38
+ messages: [
39
+ { role: 'system', content: system(project, p.sender) },
40
+ { role: 'user', content: redact(ask.text, { home, user }) },
41
+ { role: 'assistant', content: redact(answer, { home, user }) },
42
+ ],
43
+ });
44
+ }
45
+ return pairs;
46
+ }
47
+
48
+ // Distilled notes become recall pairs: "what does the room remember about …" → the note.
49
+ export function pairsFromNotes(notes, { project = 'project', home, user } = {}) {
50
+ return notes.map((note) => ({
51
+ kind: 'note',
52
+ agent: 'madre',
53
+ mode: 1,
54
+ sequence: note.throughSequence,
55
+ at: note.created,
56
+ messages: [
57
+ { role: 'system', content: `You are @madre, the memory of the MADRE room of the project "${project}". Answer only from what the room decided and recorded.` },
58
+ { role: 'user', content: `What does the room remember about this? Kind: ${note.kind}. Topic: ${redact(note.text, { home, user }).split(/[.;:]/)[0].slice(0, 80)}` },
59
+ { role: 'assistant', content: `${redact(note.text, { home, user })} [#${note.fromSequence}${note.throughSequence !== note.fromSequence ? `–#${note.throughSequence}` : ''}]` },
60
+ ],
61
+ }));
62
+ }
63
+
64
+ // Deterministic split: the sequence decides, so re-exports keep lines on the same side.
65
+ export function split(pairs, { validEvery = 10 } = {}) {
66
+ const train = [];
67
+ const valid = [];
68
+ for (const pair of pairs) (pair.sequence % validEvery === 0 ? valid : train).push(pair);
69
+ if (!valid.length && train.length > 1) valid.push(train.pop());
70
+ return { train, valid };
71
+ }
72
+
73
+ export async function exportDataset({ events, notes = [], dir, project = 'project', home, user }) {
74
+ const pairs = [...pairsFromEvents(events, { project, home, user }), ...pairsFromNotes(notes, { project, home, user })].sort((a, b) => a.sequence - b.sequence);
75
+ const { train, valid } = split(pairs);
76
+ await mkdir(dir, { recursive: true });
77
+ const line = (pair) => JSON.stringify({ messages: pair.messages });
78
+ await writeFile(join(dir, 'train.jsonl'), train.map(line).join('\n') + (train.length ? '\n' : ''));
79
+ await writeFile(join(dir, 'valid.jsonl'), valid.map(line).join('\n') + (valid.length ? '\n' : ''));
80
+ const byAgent = {};
81
+ for (const pair of pairs) byAgent[pair.agent] = (byAgent[pair.agent] ?? 0) + 1;
82
+ const manifest = { project, exportedAt: new Date().toISOString(), pairs: pairs.length, turns: pairs.filter((p) => p.kind === 'turn').length, notes: pairs.filter((p) => p.kind === 'note').length, train: train.length, valid: valid.length, byAgent, files: ['train.jsonl', 'valid.jsonl'], format: 'chat · {"messages":[{role,content}]} · mlx-lm / llama-factory / axolotl' };
83
+ await writeFile(join(dir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
84
+ return { dir, ...manifest };
85
+ }
package/src/distiller.mjs CHANGED
@@ -6,7 +6,9 @@
6
6
  import { MEMORY_KINDS } from './memory.mjs';
7
7
 
8
8
  // Cheapest first, by the models these CLIs run by default.
9
- export const DISTILLER_ORDER = ['gemini', 'opencode', 'codex', 'claude'];
9
+ // Ollama, when the room has it, is the cheapest of all: local and free.
10
+ export const DISTILLER_ORDER = ['ollama', 'gemini', 'opencode', 'codex', 'claude'];
11
+ export const OLLAMA_ARCHIVIST = { id: 'ollama', label: 'Ollama', detected: true, ready: true, adapter: 'ollama', path: null, version: null, local: true };
10
12
  export const MAX_MEMORIES_PER_RUN = 5;
11
13
  export const MAX_MEMORY_CHARS = 240;
12
14
 
@@ -26,7 +28,7 @@ export function pickDistiller(agents, { preferred = null, busy = new Set(), invo
26
28
  return agents.find(usable) ?? null;
27
29
  }
28
30
 
29
- export function distillPrompt({ entries, projectName = 'the project', existing = [] }) {
31
+ export function distillPrompt({ entries, projectName = 'the project', existing = [], json = false }) {
30
32
  const transcript = entries.map((entry) => {
31
33
  const who = entry.role === 'command' ? entry.sender : `@${entry.sender}`;
32
34
  const to = entry.target && entry.target !== 'room' ? ` → ${entry.target === 'you' ? 'human' : `@${entry.target}`}` : '';
@@ -41,9 +43,11 @@ export function distillPrompt({ entries, projectName = 'the project', existing =
41
43
  `- preference: how the human wants things done or said`,
42
44
  `- question: something left open that a later turn should not forget`,
43
45
  'Skip greetings, restatements, transient status, anything a reader of the code would see anyway, and anything the exchanges do not actually establish.',
44
- `Output only JSON lines, one object per memory, at most ${MAX_MEMORIES_PER_RUN}, nothing else:`,
45
- `{"kind":"decision|fact|preference|question","text":"one self-contained sentence in the language the room uses, at most ${MAX_MEMORY_CHARS} characters, naming files, agents and numbers exactly","sources":[sequence numbers it comes from]}`,
46
- 'If nothing durable was said, output exactly: NONE',
46
+ json
47
+ ? `Output one JSON object and nothing else: {"memories":[...]} with at most ${MAX_MEMORIES_PER_RUN} items, each {"kind":"decision|fact|preference|question","text":"one self-contained sentence in the language the room uses, at most ${MAX_MEMORY_CHARS} characters, naming files, agents and numbers exactly","sources":[sequence numbers it comes from]}. If nothing durable was said: {"memories":[]}`
48
+ : `Output only JSON lines, one object per memory, at most ${MAX_MEMORIES_PER_RUN}, nothing else:`,
49
+ json ? null : `{"kind":"decision|fact|preference|question","text":"one self-contained sentence in the language the room uses, at most ${MAX_MEMORY_CHARS} characters, naming files, agents and numbers exactly","sources":[sequence numbers it comes from]}`,
50
+ json ? null : 'If nothing durable was said, output exactly: NONE',
47
51
  known,
48
52
  '<exchanges>',
49
53
  transcript,
@@ -56,7 +60,13 @@ export function distillPrompt({ entries, projectName = 'the project', existing =
56
60
  export function parseDistillation(text, { fromSequence, throughSequence } = {}) {
57
61
  const memories = [];
58
62
  const seen = new Set();
59
- for (const raw of String(text ?? '').split(/\r?\n/)) {
63
+ // A whole JSON object with a memories array (what local models return under format=json) is unrolled into lines.
64
+ let source = String(text ?? '');
65
+ const object = source.trim().match(/^\{[\s\S]*\}$/);
66
+ if (object) {
67
+ try { const parsed = JSON.parse(object[0]); if (Array.isArray(parsed?.memories)) source = parsed.memories.map((item) => JSON.stringify(item)).join('\n'); } catch { /* fall through: line by line */ }
68
+ }
69
+ for (const raw of source.split(/\r?\n/)) {
60
70
  const line = raw.trim().replace(/^```(?:json)?$|^```$/g, '').replace(/^[-*]\s+/, '');
61
71
  const start = line.indexOf('{');
62
72
  const end = line.lastIndexOf('}');
@@ -68,11 +68,17 @@ async function geminiEmbed(texts, { key, model, dims, query, fetchImpl, timeoutM
68
68
  }
69
69
 
70
70
  // The embedder the memory uses: { model, dims, embed(texts, { query }) } or null when off.
71
- export function createEmbedder({ key = null, env = process.env, fetchImpl = globalThis.fetch, timeoutMs = Number(env.PULSE_EMBED_TIMEOUT_MS ?? 8000) } = {}) {
71
+ // Which embedder: PULSE_EMBED_PROVIDER=ollama|gemini|auto. Auto prefers Ollama
72
+ // when it is running with an embedding model (local, free, private), then Gemini
73
+ // when there is a key, then none.
74
+ export function createEmbedder({ key = null, env = process.env, fetchImpl = globalThis.fetch, timeoutMs = Number(env.PULSE_EMBED_TIMEOUT_MS ?? 8000), ollama = null, ollamaEmbedder: makeOllama = null } = {}) {
72
75
  if (env.PULSE_EMBED === '0') return null;
73
76
  if (env.PULSE_EMBED_FAKE === '1') {
74
77
  return { model: 'fake-64', dims: 64, embed: async (texts) => texts.map((text) => fakeEmbedding(text)) };
75
78
  }
79
+ const provider = env.PULSE_EMBED_PROVIDER ?? 'auto';
80
+ if (provider !== 'gemini' && ollama?.running && ollama.embedModel && makeOllama) return makeOllama({ host: ollama.host, model: ollama.embedModel, fetchImpl });
81
+ if (provider === 'ollama') return null;
76
82
  if (!key) return null;
77
83
  const model = env.PULSE_EMBED_MODEL ?? DEFAULT_EMBED_MODEL;
78
84
  const dims = Number(env.PULSE_EMBED_DIMS ?? DEFAULT_EMBED_DIMS);
@@ -1,227 +1,27 @@
1
- import { execFile, spawn } from 'node:child_process';
2
- import { access, readFile, realpath } from 'node:fs/promises';
3
- import { delimiter, join, resolve } from 'node:path';
4
- import { promisify } from 'node:util';
5
-
6
- const execFileAsync = promisify(execFile);
7
-
8
- // Optional room modules. Built-ins only change MADRE's local configuration;
9
- // the external AHP+ installer can write into the selected project, so it
10
- // requires explicit confirmation and records that action in the durable log.
11
-
12
- async function readJson(path) {
13
- try {
14
- return JSON.parse(await readFile(path, 'utf8'));
15
- } catch {
16
- return null;
17
- }
18
- }
19
-
20
- // AHP+ platform names for the agents MADRE knows about. Gemini has no AHP+
21
- // adapter yet, so it is simply not requested.
22
- const AHP_PLATFORMS = { codex: 'codex', claude: 'claude', opencode: 'opencode' };
23
-
24
- // Where does git think this project lives? AHP+ pins itself to the git root,
25
- // so a project inside a bigger repository (or a home directory that was
26
- // accidentally `git init`ed) would receive .ahp/ somewhere else entirely.
27
- export async function gitToplevel(projectRoot) {
28
- try {
29
- const { stdout } = await execFileAsync('git', ['-C', projectRoot, 'rev-parse', '--show-toplevel'], { timeout: 5000 });
30
- return await realpath(stdout.trim()).catch(() => stdout.trim());
31
- } catch {
32
- return null;
33
- }
34
- }
35
-
36
- export async function findOnPath(name, envPath = process.env.PATH ?? '') {
37
- for (const directory of envPath.split(delimiter).filter(Boolean)) {
38
- const candidate = join(directory, name);
39
- if (await access(candidate).then(() => true, () => false)) return candidate;
40
- }
41
- return null;
42
- }
43
-
44
- export const EXTENSIONS = [
45
- {
46
- id: 'ahp',
47
- name: 'AHP+',
48
- vendor: 'Agent Handoff Protocol Plus',
49
- package: '@jossuealcala/ahp-plus',
50
- version: '1.4.1',
51
- summary: 'Verified project state, checkpoints and handoffs between AI sessions, stored in .ahp/ next to your code.',
52
- creates: ['.ahp/ with manifest, sessions, handoffs and evidence', 'a project-local pin of @jossuealcala/ahp-plus', 'IDE adapter files for the detected agents'],
53
- async detect(projectRoot) {
54
- const manifest = await readJson(join(projectRoot, '.ahp', 'manifest.json'));
55
- if (!manifest) return { installed: false };
56
- const pinned = await readJson(join(projectRoot, 'node_modules', '@jossuealcala', 'ahp-plus', 'package.json'));
57
- return {
58
- installed: true,
59
- version: pinned?.version ?? null,
60
- protocolVersion: manifest.protocol_version ?? null,
61
- projectId: manifest.project_id ?? null,
62
- detail: [pinned?.version ? `cli ${pinned.version}` : null, manifest.protocol_version ? `protocol ${manifest.protocol_version}` : null].filter(Boolean).join(' · '),
63
- };
64
- },
65
- // Refuses before anything is written when the outcome would be wrong.
66
- async preflight(projectRoot, { toplevel = gitToplevel, npx = findOnPath } = {}) {
67
- const root = await realpath(projectRoot).catch(() => resolve(projectRoot));
68
- const problems = [];
69
- if (!(await npx('npx'))) problems.push('npx is not on PATH for the MADRE server; start MADRE from a terminal where `npx --version` works.');
70
- const top = await toplevel(root);
71
- if (!top) {
72
- problems.push('The project is not a git repository. AHP+ pins itself to the git root: run `git init` in the project first.');
73
- } else if (top !== root) {
74
- problems.push(`The project's git root is ${top}, not the project itself. AHP+ would install there. Run \`git init\` inside the project, or remove the stray repository at ${top}.`);
75
- }
76
- return { ok: problems.length === 0, problems, gitRoot: top };
77
- },
78
- installCommand({ agents = [] }) {
79
- const platforms = agents
80
- .filter((agent) => agent.detected && AHP_PLATFORMS[agent.id])
81
- .map((agent) => AHP_PLATFORMS[agent.id]);
82
- const args = ['--yes', `${this.package}@${this.version}`, 'setup', '.'];
83
- if (platforms.length) args.push('--platforms', platforms.join(','));
84
- return { command: 'npx', args, platforms, display: ['npx', ...args].join(' ') };
85
- },
86
- },
87
- ];
88
-
89
- // Built-in module: no project write, no npm. "Installing" it enables MADRE's own
90
- // MCP image server for the CLIs that cannot generate images natively.
91
- export const IMAGE_STUDIO = {
92
- id: 'image-studio',
93
- kind: 'builtin',
94
- name: 'Image Studio',
95
- vendor: 'MADRE · Gemini API',
96
- package: null,
97
- version: '0.1.0',
98
- summary: 'Gives Gemini CLI, Claude Code and OpenCode an image-generation tool through a MADRE-owned MCP server on the Gemini API image models, using your own Gemini key and credits. Attached only inside a creation lease with the image scope on.',
99
- creates: ['nothing in the project: images land in the lease directory like any artifact', 'an "image-studio" entry in ~/.pulse/config.json', 'an MCP server process per turn, started and stopped by the CLI'],
100
- requires: ['a Gemini API key with credits (the key the Gemini CLI stores, or GEMINI_API_KEY)'],
101
- models: ['gemini-2.5-flash-image', 'gemini-3.1-flash-image', 'gemini-3-pro-image'],
102
- };
103
- EXTENSIONS.push(IMAGE_STUDIO);
104
-
105
- // Git Pulse: read-only repository facts in the room via /git. Nothing to
106
- // install; it is on wherever the project is a git repository.
107
- export const GIT_PULSE = {
108
- id: 'git-pulse',
109
- kind: 'builtin',
110
- name: 'Git Pulse',
111
- vendor: 'MADRE',
112
- package: null,
113
- version: '0.1.0',
114
- summary: 'Type /git in the composer to bring the repository\'s branch, uncommitted changes, recent commits or diff stats into the room as a shared fact card, without spending an agent turn.',
115
- creates: ['nothing: read-only git commands run inside the project'],
116
- requires: ['the project is a git repository'],
117
- models: [],
118
- commands: ['/git status', '/git log [n]', '/git diff', '/git branches'],
119
- };
120
- EXTENSIONS.push(GIT_PULSE);
121
-
122
- export const ASHCODE = {
123
- id: 'ashcode',
124
- kind: 'builtin',
125
- name: 'AshCode',
126
- vendor: 'MADRE · ORDER 937',
127
- package: null,
128
- version: '0.1.0-beta',
129
- summary: 'Opt-in Spanish/English prompt and reply abbreviation. Keeps the original visible; may change meaning. Shorter characters do not guarantee fewer provider tokens.',
130
- creates: ['nothing in the project', 'an ashCode switch in ~/.pulse/config.json', 'original and abbreviated text in the room event log when applied'],
131
- requires: [],
132
- models: [],
133
- };
134
- EXTENSIONS.push(ASHCODE);
135
-
136
- // RIPLEY: the file viewer renders HTML, SVG and Markdown instead of showing
137
- // their source, inside a sealed frame. A switch in config.json, nothing else.
138
- export const RIPLEY = {
139
- id: 'ripley',
140
- kind: 'builtin',
141
- name: 'RIPLEY',
142
- vendor: 'MADRE · PREVIEW',
143
- package: null,
144
- version: '0.1.0',
145
- summary: 'Renders HTML, SVG and Markdown from the project and from .pulse/out in the file viewer, inside a sealed frame: no scripts, no network, no storage. Nothing leaves the room.',
146
- creates: ['nothing in the project', 'a ripley switch in ~/.pulse/config.json'],
147
- requires: [],
148
- models: [],
149
- };
150
- EXTENSIONS.push(RIPLEY);
151
-
152
- export const extensionById = (id) => EXTENSIONS.find((extension) => extension.id === id) ?? null;
153
-
154
- export async function listExtensions({ projectRoot, agents = [], config = {}, imageKey = async () => null }) {
155
- return Promise.all(EXTENSIONS.map(async (extension) => {
156
- if (extension.id === 'git-pulse') {
157
- const isRepo = await gitToplevel(projectRoot);
158
- return {
159
- id: extension.id, kind: 'builtin', name: extension.name, vendor: extension.vendor, package: null, version: extension.version,
160
- summary: extension.summary, creates: extension.creates, requires: extension.requires, models: [], commands: extension.commands,
161
- status: { installed: Boolean(isRepo), detail: isRepo ? 'on · project is a git repository' : 'not a git repository' },
162
- preflight: isRepo ? { ok: true, problems: [] } : { ok: false, problems: ['Run `git init` in the project to use /git.'] },
163
- install: { display: '/git in the composer', platforms: [] },
164
- fixed: true,
165
- };
166
- }
167
- if (extension.id === 'ashcode') {
168
- const enabled = Boolean(config.modules?.ashCode?.enabled);
169
- return {
170
- id: extension.id, kind: 'builtin', name: extension.name, vendor: extension.vendor, package: null, version: extension.version,
171
- summary: extension.summary, creates: extension.creates, requires: [], models: [],
172
- status: { installed: enabled, detail: enabled ? 'on · beta · ORDER 937 available' : 'off · beta' },
173
- preflight: { ok: true, problems: [] },
174
- install: { display: enabled ? 'disable AshCode' : 'enable AshCode (config.json)', platforms: [] },
175
- warning: 'Beta: abbreviation may alter meaning or introduce errors. Review the original. Character reduction is not verified token savings.',
176
- };
177
- }
178
- if (extension.id === 'ripley') {
179
- const enabled = Boolean(config.modules?.ripley?.enabled);
180
- return {
181
- id: extension.id, kind: 'builtin', name: extension.name, vendor: extension.vendor, package: null, version: extension.version,
182
- summary: extension.summary, creates: extension.creates, requires: [], models: [],
183
- status: { installed: enabled, detail: enabled ? 'on · PREVIEW in the file viewer' : 'off · files show as source' },
184
- preflight: { ok: true, problems: [] },
185
- install: { display: enabled ? 'disable RIPLEY' : 'enable RIPLEY (config.json)', platforms: [] },
186
- fixed: false,
187
- };
188
- }
189
- if (extension.kind === 'builtin') {
190
- const enabled = Boolean(config.modules?.imageStudio?.enabled);
191
- const key = await imageKey();
192
- return {
193
- id: extension.id,
194
- kind: 'builtin',
195
- name: extension.name,
196
- vendor: extension.vendor,
197
- package: null,
198
- version: extension.version,
199
- summary: extension.summary,
200
- creates: extension.creates,
201
- requires: extension.requires,
202
- models: extension.models,
203
- model: config.modules?.imageStudio?.model ?? extension.models[0],
204
- status: { installed: enabled, detail: enabled ? `on · ${config.modules?.imageStudio?.model ?? extension.models[0]}${key ? '' : ' · no Gemini key found'}` : key ? 'key found' : 'no Gemini key found' },
205
- preflight: key ? { ok: true, problems: [] } : { ok: false, problems: ['No Gemini API key: sign in with the Gemini CLI (/auth → API key) or set GEMINI_API_KEY. Image models bill against that key\'s AI Studio credits.'] },
206
- install: { display: enabled ? 'disable Image Studio' : 'enable Image Studio (config.json)', platforms: ['gemini', 'claude', 'opencode'] },
207
- };
208
- }
209
- const status = await extension.detect(projectRoot);
210
- const plan = extension.installCommand({ agents });
211
- const preflight = extension.preflight ? await extension.preflight(projectRoot) : { ok: true, problems: [] };
212
- return {
213
- id: extension.id,
214
- name: extension.name,
215
- vendor: extension.vendor,
216
- package: extension.package,
217
- version: extension.version,
218
- summary: extension.summary,
219
- creates: extension.creates,
220
- status,
221
- preflight,
222
- install: { display: plan.display, platforms: plan.platforms },
223
- };
224
- }));
1
+ import { spawn } from 'node:child_process';
2
+ import { MODULES, moduleById, describeModules } from './modules/index.mjs';
3
+ export { gitToplevel, findOnPath } from './modules/helpers.mjs';
4
+
5
+ // Compatibility over the module registry (src/modules/): the same names the
6
+ // server and the tests have always used, now answered by one file per module.
7
+ export const EXTENSIONS = MODULES;
8
+ export const AHP = moduleById('ahp');
9
+ export const IMAGE_STUDIO = moduleById('image-studio');
10
+ export const GIT_PULSE = moduleById('git-pulse');
11
+ export const ASHCODE = moduleById('ashcode');
12
+ export const RIPLEY = moduleById('ripley');
13
+ export const OLLAMA = moduleById('ollama');
14
+ export const extensionById = moduleById;
15
+
16
+ // Lists every module as MODULES shows it. `ollama` is the server's probe state;
17
+ // `services` may add what a server offers (imageKey, ollama wiring).
18
+ export async function listExtensions({ projectRoot, agents = [], config = {}, imageKey = async () => null, ollama = null, services = {}, env = process.env } = {}) {
19
+ const ctx = {
20
+ projectRoot, config, agents, env, room: null,
21
+ readConfig: async () => config, updateConfig: async () => {}, record: async () => {},
22
+ services: { imageKey, ollama: ollama ? { state: () => ollama, wire: async () => ollama, pull: async () => ({ ok: false, error: 'not available here' }) } : null, ...services },
23
+ };
24
+ return describeModules(ctx);
225
25
  }
226
26
 
227
27
  // Runs one installer inside the project, streaming output lines. `runner`
package/src/memory.mjs CHANGED
@@ -390,7 +390,8 @@ export class RoomMemory {
390
390
  // weighted, like entries) and, when few match, the most recent decisions and
391
391
  // preferences, all from before `beforeSequence` so they add to the window
392
392
  // rather than repeat it, within a character budget.
393
- recallMemories(text, { beforeSequence = Number.MAX_SAFE_INTEGER, limit = 6, maxChars = 1200, queryVector = null, semanticFloor = 0.45 } = {}) {
393
+ // `fallback` fills a thin match with the latest decisions and preferences; @madre turns it off to stay honest.
394
+ recallMemories(text, { beforeSequence = Number.MAX_SAFE_INTEGER, limit = 6, maxChars = 1200, queryVector = null, semanticFloor = 0.45, fallback = true } = {}) {
394
395
  if (!this.#db) return [];
395
396
  const total = this.memoryCount();
396
397
  if (!total) return [];
@@ -406,7 +407,7 @@ export class RoomMemory {
406
407
  for (const { id } of rows) scores.set(id, (scores.get(id) ?? 0) + weight);
407
408
  }
408
409
  const ids = RoomMemory.fuse(scores, semantic).map(([id]) => id);
409
- if (ids.length < 2) {
410
+ if (fallback && ids.length < 2) {
410
411
  const recent = this.#db.prepare("SELECT id FROM memories WHERE through_sequence < ? AND kind IN ('decision', 'preference') ORDER BY id DESC LIMIT ?").all(beforeSequence, limit);
411
412
  for (const { id } of recent) if (!ids.includes(id)) ids.push(id);
412
413
  }
@@ -0,0 +1,64 @@
1
+ // AHP+: the one module that writes into the project, through a confirmed npx
2
+ // command. Verified project state, checkpoints and handoffs in .ahp/.
3
+
4
+ import { join, resolve } from 'node:path';
5
+ import { realpath } from 'node:fs/promises';
6
+ import { defineModule } from './sdk.mjs';
7
+ import { readJson, gitToplevel, findOnPath } from './helpers.mjs';
8
+
9
+ // AHP+ platform names for the agents MADRE knows about. Gemini has no AHP+
10
+ // adapter yet, so it is simply not requested.
11
+ const AHP_PLATFORMS = { codex: 'codex', claude: 'claude', opencode: 'opencode' };
12
+ const PACKAGE = '@jossuealcala/ahp-plus';
13
+ const VERSION = '1.4.1';
14
+
15
+ async function detect(projectRoot) {
16
+ const manifest = await readJson(join(projectRoot, '.ahp', 'manifest.json'));
17
+ if (!manifest) return { installed: false };
18
+ const pinned = await readJson(join(projectRoot, 'node_modules', '@jossuealcala', 'ahp-plus', 'package.json'));
19
+ return {
20
+ installed: true,
21
+ version: pinned?.version ?? null,
22
+ protocolVersion: manifest.protocol_version ?? null,
23
+ projectId: manifest.project_id ?? null,
24
+ detail: [pinned?.version ? `cli ${pinned.version}` : null, manifest.protocol_version ? `protocol ${manifest.protocol_version}` : null].filter(Boolean).join(' · '),
25
+ };
26
+ }
27
+
28
+ // Refuses before anything is written when the outcome would be wrong.
29
+ async function preflight(projectRoot, { toplevel = gitToplevel, npx = findOnPath } = {}) {
30
+ const root = await realpath(projectRoot).catch(() => resolve(projectRoot));
31
+ const problems = [];
32
+ if (!(await npx('npx'))) problems.push('npx is not on PATH for the MADRE server; start MADRE from a terminal where `npx --version` works.');
33
+ const top = await toplevel(root);
34
+ if (!top) {
35
+ problems.push('The project is not a git repository. AHP+ pins itself to the git root: run `git init` in the project first.');
36
+ } else if (top !== root) {
37
+ problems.push(`The project's git root is ${top}, not the project itself. AHP+ would install there. Run \`git init\` inside the project, or remove the stray repository at ${top}.`);
38
+ }
39
+ return { ok: problems.length === 0, problems, gitRoot: top };
40
+ }
41
+
42
+ function installCommand({ agents = [] }) {
43
+ const platforms = agents.filter((agent) => agent.detected && AHP_PLATFORMS[agent.id]).map((agent) => AHP_PLATFORMS[agent.id]);
44
+ const args = ['--yes', `${PACKAGE}@${VERSION}`, 'setup', '.'];
45
+ if (platforms.length) args.push('--platforms', platforms.join(','));
46
+ return { command: 'npx', args, platforms, display: ['npx', ...args].join(' ') };
47
+ }
48
+
49
+ export default defineModule({
50
+ id: 'ahp',
51
+ kind: 'installer',
52
+ name: 'AHP+',
53
+ vendor: 'Agent Handoff Protocol Plus',
54
+ package: PACKAGE,
55
+ version: VERSION,
56
+ summary: 'Verified project state, checkpoints and handoffs between AI sessions, stored in .ahp/ next to your code.',
57
+ creates: ['.ahp/ with manifest, sessions, handoffs and evidence', 'a project-local pin of @jossuealcala/ahp-plus', 'IDE adapter files for the detected agents'],
58
+ detect, preflight, installCommand,
59
+ async status(ctx) {
60
+ const status = await detect(ctx.projectRoot);
61
+ const plan = installCommand({ agents: ctx.agents });
62
+ return { status, preflight: await preflight(ctx.projectRoot), install: { display: plan.display, platforms: plan.platforms } };
63
+ },
64
+ });
@@ -0,0 +1,28 @@
1
+ // AshCode: opt-in abbreviation of prompts and replies. Beta, so the switch asks
2
+ // for confirmation and the room says so every time it turns on.
3
+
4
+ import { defineModule } from './sdk.mjs';
5
+
6
+ const WARNING = 'Beta: abbreviation may alter meaning or introduce errors. Review the original. Character reduction is not verified token savings.';
7
+
8
+ export default defineModule({
9
+ id: 'ashcode',
10
+ configKey: 'ashCode',
11
+ name: 'AshCode',
12
+ vendor: 'MADRE · ORDER 937',
13
+ version: '0.1.0-beta',
14
+ summary: 'Opt-in Spanish/English prompt and reply abbreviation. Keeps the original visible; may change meaning. Shorter characters do not guarantee fewer provider tokens.',
15
+ creates: ['nothing in the project', 'an ashCode switch in ~/.pulse/config.json', 'original and abbreviated text in the room event log when applied'],
16
+ card: 'ashcode',
17
+ confirm: 'AshCode is beta and can change meaning. Send { "confirm": true } to enable it.',
18
+ toggledEvent: { beta: true },
19
+ toggledBody: () => ({ beta: true, warning: 'AshCode beta may alter meaning; review the original. Character reduction is not verified token savings.' }),
20
+ async status(ctx) {
21
+ return {
22
+ status: { installed: Boolean(ctx.settings.enabled), detail: ctx.settings.enabled ? 'on · beta · ORDER 937 available' : 'off · beta' },
23
+ install: { display: ctx.settings.enabled ? 'disable AshCode' : 'enable AshCode (config.json)', platforms: [] },
24
+ warning: WARNING,
25
+ };
26
+ },
27
+ async onToggle(ctx, enabled) { ctx.room.setAshCode(enabled); return null; },
28
+ });
@@ -0,0 +1,26 @@
1
+ // Git Pulse: /git in the composer brings the repository's facts into the room.
2
+ // Nothing to switch: it is on wherever the project is a git repository.
3
+
4
+ import { defineModule } from './sdk.mjs';
5
+ import { gitToplevel } from './helpers.mjs';
6
+
7
+ export default defineModule({
8
+ id: 'git-pulse',
9
+ name: 'Git Pulse',
10
+ vendor: 'MADRE',
11
+ summary: 'Type /git in the composer to bring the repository\'s branch, uncommitted changes, recent commits or diff stats into the room as a shared fact card, read-only, without spending an agent turn.',
12
+ creates: ['nothing: read-only git commands run inside the project'],
13
+ requires: ['the project is a git repository'],
14
+ commands: ['/git status', '/git log [n]', '/git diff', '/git branches'],
15
+ card: 'fixed',
16
+ async status(ctx) {
17
+ const isRepo = await gitToplevel(ctx.projectRoot);
18
+ return {
19
+ status: { installed: Boolean(isRepo), detail: isRepo ? 'on · project is a git repository' : 'not a git repository' },
20
+ preflight: isRepo ? { ok: true, problems: [] } : { ok: false, problems: ['Run `git init` in the project to use /git.'] },
21
+ install: { display: '/git in the composer', platforms: [] },
22
+ fixed: true,
23
+ };
24
+ },
25
+ async toggle() { return { status: 405, body: { error: 'Git Pulse has no switch: it is on wherever the project is a git repository.' } }; },
26
+ });
@@ -0,0 +1,30 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { access, readFile, realpath } from 'node:fs/promises';
3
+ import { delimiter, join } from 'node:path';
4
+ import { promisify } from 'node:util';
5
+
6
+ const execFileAsync = promisify(execFile);
7
+
8
+ export async function readJson(path) {
9
+ try { return JSON.parse(await readFile(path, 'utf8')); } catch { return null; }
10
+ }
11
+
12
+ // Where does git think this project lives? AHP+ pins itself to the git root,
13
+ // so a project inside a bigger repository (or a home directory that was
14
+ // accidentally `git init`ed) would receive .ahp/ somewhere else entirely.
15
+ export async function gitToplevel(projectRoot) {
16
+ try {
17
+ const { stdout } = await execFileAsync('git', ['-C', projectRoot, 'rev-parse', '--show-toplevel'], { timeout: 5000 });
18
+ return await realpath(stdout.trim()).catch(() => stdout.trim());
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ export async function findOnPath(name, envPath = process.env.PATH ?? '') {
25
+ for (const directory of envPath.split(delimiter).filter(Boolean)) {
26
+ const candidate = join(directory, name);
27
+ if (await access(candidate).then(() => true, () => false)) return candidate;
28
+ }
29
+ return null;
30
+ }
@@ -0,0 +1,39 @@
1
+ // Image Studio: MADRE's own MCP image server on the Gemini image models, for
2
+ // the CLIs that cannot draw natively. A switch and a model in config.json.
3
+
4
+ import { defineModule } from './sdk.mjs';
5
+
6
+ const MODELS = ['gemini-2.5-flash-image', 'gemini-3.1-flash-image', 'gemini-3-pro-image'];
7
+
8
+ export default defineModule({
9
+ id: 'image-studio',
10
+ configKey: 'imageStudio',
11
+ name: 'Image Studio',
12
+ vendor: 'MADRE · Gemini API',
13
+ summary: 'Gives Gemini CLI, Claude Code and OpenCode an image-generation tool through a MADRE-owned MCP server on the Gemini API image models, using your own Gemini key and credits. Attached only inside a creation lease with the image scope on.',
14
+ creates: ['nothing in the project: images land in the lease directory like any artifact', 'an "image-studio" entry in ~/.pulse/config.json', 'an MCP server process per turn, started and stopped by the room'],
15
+ requires: ['a Gemini API key with credits (the key the Gemini CLI stores, or GEMINI_API_KEY)'],
16
+ models: MODELS,
17
+ settings: { enabled: false, model: MODELS[0] },
18
+ card: 'image-studio',
19
+ async status(ctx) {
20
+ const key = await ctx.services.imageKey();
21
+ const model = ctx.settings.model ?? MODELS[0];
22
+ return {
23
+ model,
24
+ status: { installed: Boolean(ctx.settings.enabled), detail: ctx.settings.enabled ? `on · ${model}${key ? '' : ' · no Gemini key found'}` : key ? 'key found' : 'no Gemini key found' },
25
+ preflight: key ? { ok: true, problems: [] } : { ok: false, problems: ['No Gemini API key: sign in with the Gemini CLI (/auth → API key) or set GEMINI_API_KEY. Image models bill against that key.'] },
26
+ install: { display: ctx.settings.enabled ? 'disable Image Studio' : 'enable Image Studio (config.json)', platforms: ['gemini', 'claude', 'opencode'] },
27
+ };
28
+ },
29
+ async toggle(ctx, payload) {
30
+ const enabled = !ctx.settings.enabled;
31
+ const key = await ctx.services.imageKey();
32
+ if (enabled && !key) return { status: 412, body: { error: 'No Gemini API key found. Sign in with the Gemini CLI (/auth → API key) or set GEMINI_API_KEY, then enable Image Studio.' } };
33
+ const model = typeof payload?.model === 'string' && payload.model ? payload.model : (ctx.settings.model ?? MODELS[0]);
34
+ await ctx.updateConfig({ modules: { ...(ctx.config.modules ?? {}), imageStudio: { enabled, model } } });
35
+ ctx.services.setImageModule({ enabled, model });
36
+ await ctx.record('extension.toggled', { id: 'image-studio', name: 'Image Studio', enabled, model });
37
+ return { status: 200, body: { enabled, model, capabilities: ctx.room.capabilities() } };
38
+ },
39
+ });
@@ -0,0 +1,21 @@
1
+ // The registry. Order is the order MODULES shows.
2
+ import ahp from './ahp.mjs';
3
+ import imageStudio from './image-studio.mjs';
4
+ import gitPulse from './git-pulse.mjs';
5
+ import ashcode from './ashcode.mjs';
6
+ import ripley from './ripley.mjs';
7
+ import ollama from './ollama.mjs';
8
+ import { matchRoute } from './sdk.mjs';
9
+
10
+ export const MODULES = [ahp, imageStudio, gitPulse, ashcode, ripley, ollama];
11
+ export const moduleById = (id) => MODULES.find((module) => module.id === id) ?? null;
12
+ export function describeModules(ctx) { return Promise.all(MODULES.map((module) => module.describe(ctx))); }
13
+ // One flat list of every route a module serves, with the module attached.
14
+ export function findModuleRoute(method, pathname) {
15
+ for (const module of MODULES) {
16
+ const hit = matchRoute(module.routes, method, pathname);
17
+ if (hit) return { module, ...hit };
18
+ }
19
+ return null;
20
+ }
21
+ export { defineModule } from './sdk.mjs';