@jossuealcala/madre 0.2.3 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +60 -0
- package/CONTRIBUTING.md +31 -2
- package/README.md +145 -187
- package/SECURITY.md +1 -1
- package/bin/madre.mjs +35 -2
- package/docs/INTERNALS.md +75 -0
- package/docs/training/Modelfile +5 -0
- package/docs/training/README.md +47 -0
- package/docs/training/train.sh +17 -0
- package/package.json +5 -3
- package/public/app.js +353 -5
- package/public/brands.js +18 -0
- package/public/index.html +2 -0
- package/public/styles.css +49 -11
- package/public/troubleshooting.js +31 -0
- package/src/adapters/madre.mjs +195 -0
- package/src/auth-probe.mjs +1 -0
- package/src/capabilities.mjs +1 -0
- package/src/conversation-context.mjs +4 -2
- package/src/dataset.mjs +127 -0
- package/src/distiller.mjs +16 -6
- package/src/embeddings.mjs +7 -1
- package/src/event-store.mjs +29 -1
- package/src/extensions.mjs +24 -224
- package/src/memory.mjs +64 -4
- 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/privacy.mjs +109 -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 +66 -0
- package/src/room/vectors.mjs +47 -0
- package/src/room.mjs +190 -392
- package/src/server.mjs +229 -41
- package/src/setup.mjs +25 -7
- package/src/updates.mjs +79 -0
package/src/event-store.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir, open, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
@@ -120,6 +120,34 @@ export class EventStore {
|
|
|
120
120
|
throw new Error(`Timed out waiting for event store lock: ${this.#file}`);
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
// Rewrites every event through `transform` (which returns the event to keep, changed or
|
|
124
|
+
// not), atomically, under the lock, in the same order and with the same sequences. The
|
|
125
|
+
// ledger is append-only for the room; this is the human's hand on it, used by the privacy
|
|
126
|
+
// purge. Appends queued behind it see the new file.
|
|
127
|
+
rewrite(transform) {
|
|
128
|
+
const operation = async () => {
|
|
129
|
+
const release = await this.#acquireLock();
|
|
130
|
+
try {
|
|
131
|
+
const existing = await this.#readAllUnlocked();
|
|
132
|
+
let changed = 0;
|
|
133
|
+
const next = existing.map((event) => {
|
|
134
|
+
const out = transform(event) ?? event;
|
|
135
|
+
if (out !== event) changed += 1;
|
|
136
|
+
return out;
|
|
137
|
+
});
|
|
138
|
+
const temp = `${this.#file}.rewrite-${process.pid}`;
|
|
139
|
+
await writeFile(temp, next.map((event) => JSON.stringify(event)).join('\n') + (next.length ? '\n' : ''));
|
|
140
|
+
await rename(temp, this.#file);
|
|
141
|
+
this.#nextSequence = (next.at(-1)?.sequence ?? 0) + 1;
|
|
142
|
+
return { total: next.length, changed };
|
|
143
|
+
} finally {
|
|
144
|
+
await release();
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
this.#writeQueue = this.#writeQueue.then(operation, operation);
|
|
148
|
+
return this.#writeQueue;
|
|
149
|
+
}
|
|
150
|
+
|
|
123
151
|
append(type, payload) {
|
|
124
152
|
const operation = async () => {
|
|
125
153
|
const release = await this.#acquireLock();
|
package/src/extensions.mjs
CHANGED
|
@@ -1,227 +1,27 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
@@ -74,6 +74,7 @@ export class RoomMemory {
|
|
|
74
74
|
#meta;
|
|
75
75
|
#setMeta;
|
|
76
76
|
#embedder = null;
|
|
77
|
+
#privacy = null;
|
|
77
78
|
|
|
78
79
|
constructor(file) {
|
|
79
80
|
this.#file = file;
|
|
@@ -172,6 +173,62 @@ export class RoomMemory {
|
|
|
172
173
|
/* ---------- embeddings ---------- */
|
|
173
174
|
|
|
174
175
|
attachEmbedder(embedder) { this.#embedder = embedder ?? null; return this; }
|
|
176
|
+
|
|
177
|
+
// Private terms never enter the index or the notes, whoever wrote them.
|
|
178
|
+
attachPrivacy(privacy) { this.#privacy = privacy ?? null; return this; }
|
|
179
|
+
#guard(text) { return this.#privacy ? this.#privacy.redact(text).text : text; }
|
|
180
|
+
|
|
181
|
+
// What the archive still carries: entries and notes with a private term, for MU/TH/UR.
|
|
182
|
+
exposure() {
|
|
183
|
+
if (!this.#privacy?.enabled) return { entries: 0, memories: 0 };
|
|
184
|
+
let entries = 0;
|
|
185
|
+
for (const row of this.#db.prepare('SELECT text FROM entries').iterate()) if (this.#privacy.hits(row.text)) entries += 1;
|
|
186
|
+
let memories = 0;
|
|
187
|
+
for (const row of this.#db.prepare('SELECT text FROM memories').iterate()) if (this.#privacy.hits(row.text)) memories += 1;
|
|
188
|
+
return { entries, memories };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Replaces private terms in every entry and note already kept. FTS tables are external
|
|
192
|
+
// content with insert/delete triggers only, so a changed row is deleted and re-inserted
|
|
193
|
+
// with its own rowid; its vector goes with it and is recomputed later. Distilled flags stay.
|
|
194
|
+
purge() {
|
|
195
|
+
if (!this.#privacy?.enabled) return { entries: 0, memories: 0 };
|
|
196
|
+
const counts = { entries: 0, memories: 0 };
|
|
197
|
+
const entryRows = this.#db.prepare('SELECT sequence, event_id, timestamp, type, role, sender, target, message_id, text, distilled FROM entries').all();
|
|
198
|
+
const memoryRows = this.#db.prepare('SELECT id, created, kind, text, norm, from_sequence, through_sequence, sources, agent, origin, message_id FROM memories').all();
|
|
199
|
+
const deleteEntry = this.#db.prepare('DELETE FROM entries WHERE sequence = ?');
|
|
200
|
+
const deleteEntryVector = this.#db.prepare('DELETE FROM entry_vectors WHERE sequence = ?');
|
|
201
|
+
const insertEntry = this.#db.prepare('INSERT INTO entries (sequence, event_id, timestamp, type, role, sender, target, message_id, text, distilled) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
|
|
202
|
+
const deleteMemory = this.#db.prepare('DELETE FROM memories WHERE id = ?');
|
|
203
|
+
const deleteMemoryVector = this.#db.prepare('DELETE FROM memory_vectors WHERE id = ?');
|
|
204
|
+
const insertMemory = this.#db.prepare('INSERT INTO memories (id, created, kind, text, norm, from_sequence, through_sequence, sources, agent, origin, message_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
|
|
205
|
+
this.#db.exec('BEGIN');
|
|
206
|
+
try {
|
|
207
|
+
for (const row of entryRows) {
|
|
208
|
+
const { text, hits } = this.#privacy.redact(row.text);
|
|
209
|
+
if (!hits) continue;
|
|
210
|
+
deleteEntryVector.run(row.sequence);
|
|
211
|
+
deleteEntry.run(row.sequence);
|
|
212
|
+
insertEntry.run(row.sequence, row.event_id, row.timestamp, row.type, row.role, row.sender, row.target, row.message_id, text, row.distilled);
|
|
213
|
+
counts.entries += 1;
|
|
214
|
+
}
|
|
215
|
+
for (const row of memoryRows) {
|
|
216
|
+
const { text, hits } = this.#privacy.redact(row.text);
|
|
217
|
+
if (!hits) continue;
|
|
218
|
+
deleteMemoryVector.run(row.id);
|
|
219
|
+
deleteMemory.run(row.id);
|
|
220
|
+
// Two notes may collapse into the same redacted sentence: the later one is dropped.
|
|
221
|
+
const clash = this.#db.prepare('SELECT id FROM memories WHERE norm = ?').get(normalizeMemory(text));
|
|
222
|
+
if (!clash) insertMemory.run(row.id, row.created, row.kind, text, normalizeMemory(text), row.from_sequence, row.through_sequence, row.sources, row.agent, row.origin, row.message_id);
|
|
223
|
+
counts.memories += 1;
|
|
224
|
+
}
|
|
225
|
+
this.#db.exec('COMMIT');
|
|
226
|
+
} catch (error) {
|
|
227
|
+
this.#db.exec('ROLLBACK');
|
|
228
|
+
throw error;
|
|
229
|
+
}
|
|
230
|
+
return counts;
|
|
231
|
+
}
|
|
175
232
|
get embedder() { return this.#embedder; }
|
|
176
233
|
|
|
177
234
|
// Entries and notes without a vector for the current model, oldest first.
|
|
@@ -296,7 +353,7 @@ export class RoomMemory {
|
|
|
296
353
|
this.#db.exec('BEGIN');
|
|
297
354
|
try {
|
|
298
355
|
for (const memory of list) {
|
|
299
|
-
const text = String(memory.text ?? '').trim();
|
|
356
|
+
const text = this.#guard(String(memory.text ?? '').trim());
|
|
300
357
|
if (!text) continue;
|
|
301
358
|
const kind = MEMORY_KINDS.includes(memory.kind) ? memory.kind : 'fact';
|
|
302
359
|
const sources = (Array.isArray(memory.sources) ? memory.sources : []).filter((n) => Number.isInteger(n));
|
|
@@ -390,7 +447,8 @@ export class RoomMemory {
|
|
|
390
447
|
// weighted, like entries) and, when few match, the most recent decisions and
|
|
391
448
|
// preferences, all from before `beforeSequence` so they add to the window
|
|
392
449
|
// rather than repeat it, within a character budget.
|
|
393
|
-
|
|
450
|
+
// `fallback` fills a thin match with the latest decisions and preferences; @madre turns it off to stay honest.
|
|
451
|
+
recallMemories(text, { beforeSequence = Number.MAX_SAFE_INTEGER, limit = 6, maxChars = 1200, queryVector = null, semanticFloor = 0.45, fallback = true } = {}) {
|
|
394
452
|
if (!this.#db) return [];
|
|
395
453
|
const total = this.memoryCount();
|
|
396
454
|
if (!total) return [];
|
|
@@ -406,7 +464,7 @@ export class RoomMemory {
|
|
|
406
464
|
for (const { id } of rows) scores.set(id, (scores.get(id) ?? 0) + weight);
|
|
407
465
|
}
|
|
408
466
|
const ids = RoomMemory.fuse(scores, semantic).map(([id]) => id);
|
|
409
|
-
if (ids.length < 2) {
|
|
467
|
+
if (fallback && ids.length < 2) {
|
|
410
468
|
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
469
|
for (const { id } of recent) if (!ids.includes(id)) ids.push(id);
|
|
412
470
|
}
|
|
@@ -442,9 +500,11 @@ export class RoomMemory {
|
|
|
442
500
|
for (const event of events) {
|
|
443
501
|
if (!event || event.ghost || !Number.isInteger(event.sequence)) continue;
|
|
444
502
|
if (event.sequence > last) last = event.sequence;
|
|
503
|
+
// MADRE's canned replies are not the room's knowledge; they never enter the archive.
|
|
504
|
+
if (event.payload?.synthetic) continue;
|
|
445
505
|
const entry = messageEntry(event);
|
|
446
506
|
if (!entry) continue;
|
|
447
|
-
const result = this.#insert.run(event.sequence, event.id ?? `seq-${event.sequence}`, event.timestamp ?? null, event.type, entry.role, entry.sender, entry.target ?? null, entry.messageId ?? null, entry.text);
|
|
507
|
+
const result = this.#insert.run(event.sequence, event.id ?? `seq-${event.sequence}`, event.timestamp ?? null, event.type, entry.role, entry.sender, entry.target ?? null, entry.messageId ?? null, this.#guard(entry.text));
|
|
448
508
|
added += Number(result.changes ?? 0);
|
|
449
509
|
}
|
|
450
510
|
this.#setMeta.run('last_sequence', String(last));
|
|
@@ -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';
|