@jossuealcala/madre 0.7.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/CHANGELOG.md +833 -1
  2. package/README.md +92 -28
  3. package/SECURITY.md +1 -0
  4. package/bin/madre.mjs +30 -10
  5. package/docs/REFERENCE.md +158 -79
  6. package/docs/SDK.md +23 -19
  7. package/package.json +1 -1
  8. package/public/app.js +11599 -4186
  9. package/public/assets/brand/Geist-latin-ext.woff2 +0 -0
  10. package/public/assets/brand/Geist-latin.woff2 +0 -0
  11. package/public/assets/brand/GeistMono-latin-ext.woff2 +0 -0
  12. package/public/assets/brand/GeistMono-latin.woff2 +0 -0
  13. package/public/assets/brand/JetBrainsMono-latin-ext.woff2 +0 -0
  14. package/public/assets/brand/JetBrainsMono-latin.woff2 +0 -0
  15. package/public/assets/brand/OFL-Geist.txt +93 -0
  16. package/public/assets/brand/OFL-GeistMono.txt +93 -0
  17. package/public/assets/brand/OFL-JetBrainsMono.txt +93 -0
  18. package/public/assets/brand/OFL-Sora.txt +93 -0
  19. package/public/assets/brand/Sora-latin-ext.woff2 +0 -0
  20. package/public/assets/brand/Sora-latin.woff2 +0 -0
  21. package/public/assets/brand/madre-app-180.png +0 -0
  22. package/public/assets/brand/madre-app-192.png +0 -0
  23. package/public/assets/brand/madre-app-512.png +0 -0
  24. package/public/assets/brand/madre-app.svg +1 -0
  25. package/public/assets/brand/madre-favicon.svg +1 -0
  26. package/public/assets/news/1.0/alerts.webp +0 -0
  27. package/public/assets/news/1.0/bar.webp +0 -0
  28. package/public/assets/news/1.0/finder.webp +0 -0
  29. package/public/assets/news/1.0/github.webp +0 -0
  30. package/public/assets/news/1.0/intents.webp +0 -0
  31. package/public/assets/news/1.0/looks.webp +0 -0
  32. package/public/assets/news/1.0/modules.webp +0 -0
  33. package/public/assets/news/1.0/mother.webp +0 -0
  34. package/public/assets/news/1.0/nostromo.webp +0 -0
  35. package/public/assets/news/1.0/permissions.webp +0 -0
  36. package/public/assets/news/1.0/room.webp +0 -0
  37. package/public/assets/news/1.0/settings.webp +0 -0
  38. package/public/assets/wallpapers/biznaga.webp +0 -0
  39. package/public/assets/wallpapers/canela.webp +0 -0
  40. package/public/assets/wallpapers/jarrito.webp +0 -0
  41. package/public/assets/wallpapers/magna.webp +0 -0
  42. package/public/assets/wallpapers/oceano.webp +0 -0
  43. package/public/assets/wallpapers/paraiso.webp +0 -0
  44. package/public/assets/wallpapers/quila.webp +0 -0
  45. package/public/assets/wallpapers/tradicion.webp +0 -0
  46. package/public/assets/wallpapers/tropical.webp +0 -0
  47. package/public/assets/wallpapers/urbania.webp +0 -0
  48. package/public/assets/wallpapers/ventura.webp +0 -0
  49. package/public/assets/wallpapers/xico.webp +0 -0
  50. package/public/brands.js +28 -2
  51. package/public/editor.js +744 -0
  52. package/public/es.js +2863 -1008
  53. package/public/i18n.js +1 -1
  54. package/public/index.html +201 -175
  55. package/public/inquiry.js +11 -11
  56. package/public/loader.js +79 -0
  57. package/public/macroverso.js +1497 -0
  58. package/public/manifest.webmanifest +14 -0
  59. package/public/nostromo-engine.js +2222 -0
  60. package/public/resay.js +24 -0
  61. package/public/review-ask.js +85 -0
  62. package/public/salas-mac.css +787 -0
  63. package/public/salas-mac.html +154 -0
  64. package/public/salas-mac.js +3264 -0
  65. package/public/salas.css +85 -0
  66. package/public/salas.html +47 -0
  67. package/public/salas.js +200 -0
  68. package/public/styles.css +6303 -414
  69. package/public/touch.js +83 -0
  70. package/public/tour-chapters.js +342 -0
  71. package/public/tour.js +510 -0
  72. package/public/troubleshooting.js +159 -140
  73. package/src/adapters/claude.mjs +4 -3
  74. package/src/adapters/codex.mjs +49 -8
  75. package/src/adapters/gemini.mjs +30 -10
  76. package/src/adapters/madre.mjs +110 -18
  77. package/src/adapters/process.mjs +42 -2
  78. package/src/auth-probe.mjs +2 -2
  79. package/src/bridge/devices.mjs +202 -0
  80. package/src/bridge/handoff.mjs +45 -0
  81. package/src/bridge/host.mjs +82 -0
  82. package/src/bridge/network.mjs +133 -0
  83. package/src/bridge/policy.mjs +89 -0
  84. package/src/bridge/qr.mjs +287 -0
  85. package/src/bridge/server.mjs +269 -0
  86. package/src/capabilities.mjs +5 -9
  87. package/src/chats.mjs +59 -22
  88. package/src/checkpoint.mjs +137 -35
  89. package/src/choices.mjs +45 -6
  90. package/src/cli-args.mjs +12 -1
  91. package/src/commands.mjs +15 -15
  92. package/src/config.mjs +73 -9
  93. package/src/conversation-context.mjs +5 -2
  94. package/src/dataset.mjs +3 -1
  95. package/src/directives.mjs +0 -5
  96. package/src/enlaces.mjs +451 -0
  97. package/src/event-store.mjs +55 -10
  98. package/src/exam.mjs +5 -5
  99. package/src/extensions.mjs +0 -2
  100. package/src/file-ops.mjs +42 -0
  101. package/src/files.mjs +52 -2
  102. package/src/github.mjs +407 -0
  103. package/src/json-file.mjs +63 -0
  104. package/src/lease.mjs +2 -2
  105. package/src/lobby-work.mjs +103 -0
  106. package/src/looks.mjs +55 -0
  107. package/src/maturity.mjs +3 -3
  108. package/src/mcp/github-server.mjs +164 -0
  109. package/src/mcp/image-server.mjs +1 -1
  110. package/src/mcp/memory-server.mjs +11 -3
  111. package/src/memory.mjs +38 -12
  112. package/src/models.mjs +109 -1
  113. package/src/modules/ahp.mjs +4 -4
  114. package/src/modules/ash.mjs +1 -1
  115. package/src/modules/bishop-plan.mjs +207 -0
  116. package/src/modules/bishop.mjs +645 -0
  117. package/src/modules/choices.mjs +2 -2
  118. package/src/modules/correo.mjs +1 -1
  119. package/src/modules/enlaces.mjs +255 -0
  120. package/src/modules/git-pulse.mjs +2 -2
  121. package/src/modules/github.mjs +322 -0
  122. package/src/modules/image-studio.mjs +3 -3
  123. package/src/modules/index.mjs +19 -4
  124. package/src/modules/intents-calibration.mjs +338 -0
  125. package/src/modules/intents-desk.mjs +481 -0
  126. package/src/modules/intents-observe.mjs +266 -0
  127. package/src/modules/intents-policy.mjs +414 -0
  128. package/src/modules/intents-try.mjs +91 -0
  129. package/src/modules/intents.mjs +145 -0
  130. package/src/modules/ollama.mjs +9 -5
  131. package/src/modules/playwright.mjs +121 -11
  132. package/src/modules/ripley.mjs +1 -1
  133. package/src/modules/runs.mjs +34 -0
  134. package/src/modules/sdk.mjs +10 -4
  135. package/src/ollama.mjs +25 -1
  136. package/src/outbound.mjs +19 -17
  137. package/src/override.mjs +100 -0
  138. package/src/ports.mjs +23 -1
  139. package/src/prices.mjs +72 -0
  140. package/src/privacy.mjs +30 -11
  141. package/src/quota-sources.mjs +0 -8
  142. package/src/request-guard.mjs +1 -1
  143. package/src/review-ask.mjs +111 -0
  144. package/src/room/archivist.mjs +55 -37
  145. package/src/room/budget.mjs +17 -5
  146. package/src/room/control.mjs +88 -8
  147. package/src/room/economy.mjs +35 -7
  148. package/src/room/escalation.mjs +2 -0
  149. package/src/room/guard.mjs +26 -2
  150. package/src/room/prompt.mjs +32 -13
  151. package/src/room/runs.mjs +151 -0
  152. package/src/room/vectors.mjs +19 -4
  153. package/src/room.mjs +402 -54
  154. package/src/router.mjs +3 -1
  155. package/src/runs.mjs +137 -0
  156. package/src/salas.mjs +313 -0
  157. package/src/sentinel-errors.mjs +1 -1
  158. package/src/server-sentinel.mjs +124 -0
  159. package/src/server.mjs +1578 -167
  160. package/src/setup.mjs +1 -0
  161. package/src/tour.mjs +159 -0
  162. package/src/updates.mjs +48 -8
  163. package/src/usage-sentinel.mjs +1 -1
  164. package/src/verdict.mjs +7 -7
  165. package/src/whats-new.mjs +160 -0
package/src/maturity.mjs CHANGED
@@ -36,13 +36,13 @@ export function maturity({ readiness = null, notes = [], links = [], stats = nul
36
36
  id: 'volume', label: t('HOW MUCH THERE IS'),
37
37
  value: round(share(pairs, VOLUME_TARGET)),
38
38
  detail: t('{pairs} of about {target} exchanges worth training on', { pairs, target: VOLUME_TARGET }),
39
- next: t('Use the room. Nothing else fills this.'),
39
+ next: t('Use the project. Nothing else fills this.'),
40
40
  },
41
41
  {
42
42
  id: 'coverage', label: t('HOW MUCH OF IT GETS USED'),
43
43
  value: round(share(recalled, standing.length)),
44
44
  detail: t('{recalled} of {total} memories have been reached for at least once', { recalled, total: standing.length }),
45
- next: t('Memories nobody has needed may be noise, or may simply not have come up yet. Ask the room about older decisions and see which ones answer.'),
45
+ next: t('Memories nobody has needed may be noise, or may simply not have come up yet. Ask the project about older decisions and see which ones answer.'),
46
46
  },
47
47
  {
48
48
  id: 'weave', label: t('HOW WOVEN IT IS'),
@@ -64,7 +64,7 @@ export function maturity({ readiness = null, notes = [], links = [], stats = nul
64
64
  // progress where there is none.
65
65
  value: pairs > 0 ? round(1 - Math.min(1, Math.max(0, (noteShare - 0.5) / 0.5))) : 0,
66
66
  detail: t('{share}% of the corpus is recall questions, {turns} exchanges are real work', { share: Math.round(noteShare * 100), turns }),
67
- next: t('Recall pairs are made from notes and cost nothing, so they pile up. Work in the room to balance them.'),
67
+ next: t('Recall pairs are made from notes and cost nothing, so they pile up. Work in the project to balance them.'),
68
68
  },
69
69
  {
70
70
  id: 'upkeep', label: t('HOW CURRENT IT IS'),
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env node
2
+ // MADRE GITHUB: a small MCP server (stdio, JSON-RPC 2.0) that lets an agent read the GitHub side
3
+ // of this project — pull requests, issues, checks — and, in #4 AIRLOCK only, open a pull request,
4
+ // open an issue or leave a comment. There is no MADRE in the middle: this process asks gh, or
5
+ // GitHub's API with the human's token, and nothing else.
6
+ //
7
+ // Reading comes back; writing does not. A comment carries the human's name the moment it lands,
8
+ // so three things hold, each somewhere a prompt cannot reach:
9
+ //
10
+ // · The room hands the write tools to a turn only at #4 AIRLOCK (`sends` in the module, enforced
11
+ // in src/modules/index.mjs). This server checks again: unless PULSE_GITHUB_WRITES=1 it does
12
+ // not list them and refuses to run them.
13
+ // · The credential is gh's own session, which this process never sees, or a token in its
14
+ // environment. Neither is written anywhere: not in a result, not in the outbound log.
15
+ // · The repository is the one the module found in the project's remotes. A tool cannot be
16
+ // pointed at another one.
17
+ //
18
+ // Environment:
19
+ // PULSE_GITHUB_REPO required, owner/repo
20
+ // PULSE_GITHUB_ROOT the project, to read the current branch from git
21
+ // PULSE_GITHUB_AUTH 'gh' or 'token'
22
+ // PULSE_GITHUB_GH the gh executable, with PULSE_GITHUB_AUTH=gh
23
+ // PULSE_GITHUB_TOKEN the token, with PULSE_GITHUB_AUTH=token (from MADRE's vault)
24
+ // PULSE_GITHUB_WRITES '1' only in a #4 AIRLOCK turn
25
+ // PULSE_OUTBOUND_LOG where each request leaves its line
26
+
27
+ import { realpath } from 'node:fs/promises';
28
+ import { fileURLToPath } from 'node:url';
29
+ import { OutboundLog, declareDestinations } from '../outbound.mjs';
30
+ import * as github from '../github.mjs';
31
+
32
+ const SERVER_NAME = 'pulse-github';
33
+ const SERVER_VERSION = '1.0.0';
34
+ const SELF = fileURLToPath(import.meta.url);
35
+
36
+ export const READ_TOOLS = ['github_list_prs', 'github_get_pr', 'github_ci_status', 'github_list_issues', 'github_get_issue'];
37
+ export const WRITE_TOOLS = ['github_create_pr', 'github_comment', 'github_create_issue'];
38
+
39
+ const n = { type: 'integer', minimum: 1 };
40
+ export const TOOLS = [
41
+ { name: 'github_list_prs', description: 'List the open pull requests of this project\'s GitHub repository: number, title, author, branch, base, draft.', inputSchema: { type: 'object', properties: { limit: { type: 'integer', minimum: 1, maximum: 100, description: 'How many, newest first. Default 20.' } } } },
42
+ { name: 'github_get_pr', description: 'One pull request: title, state, branch and base, whether it can merge, additions/deletions, changed files, and the review state.', inputSchema: { type: 'object', properties: { number: { ...n, description: 'The pull request number.' } }, required: ['number'] } },
43
+ { name: 'github_ci_status', description: 'The checks and CI of a branch: one overall state (success, failure, pending, none), each check, and the latest workflow runs. Defaults to the branch the project is on.', inputSchema: { type: 'object', properties: { ref: { type: 'string', description: 'A branch, tag or commit. Default: the current branch.' } } } },
44
+ { name: 'github_list_issues', description: 'List the open issues (not pull requests): number, title, author, labels, comment count.', inputSchema: { type: 'object', properties: { limit: { type: 'integer', minimum: 1, maximum: 100, description: 'How many, newest first. Default 20.' } } } },
45
+ { name: 'github_get_issue', description: 'One issue: title, state, author, labels and its text.', inputSchema: { type: 'object', properties: { number: { ...n, description: 'The issue number.' } }, required: ['number'] } },
46
+ { name: 'github_create_pr', description: 'Open a pull request on GitHub under the human\'s name. It cannot be undone from here: before calling it, state in one line its title, from which branch and into which. The branch must already be pushed.', inputSchema: { type: 'object', properties: { title: { type: 'string' }, body: { type: 'string', description: 'The description, Markdown.' }, head: { type: 'string', description: 'The branch to merge from. Default: the current branch.' }, base: { type: 'string', description: 'The branch to merge into. Default: the repository\'s default branch.' } }, required: ['title'] } },
47
+ { name: 'github_comment', description: 'Post a comment on a pull request or issue under the human\'s name. It cannot be undone from here: before calling it, state in one line where it goes and what it says.', inputSchema: { type: 'object', properties: { number: { ...n, description: 'The pull request or issue number.' }, body: { type: 'string', description: 'The comment, Markdown.' } }, required: ['number', 'body'] } },
48
+ { name: 'github_create_issue', description: 'Open an issue on GitHub under the human\'s name. It cannot be undone from here: before calling it, state in one line its title and what it says.', inputSchema: { type: 'object', properties: { title: { type: 'string' }, body: { type: 'string', description: 'The text, Markdown.' } }, required: ['title'] } },
49
+ ];
50
+
51
+ // What this process was handed, as the client wants it. The token is read here and only here.
52
+ export function contextFrom(env = process.env) {
53
+ const [owner, repo] = String(env.PULSE_GITHUB_REPO ?? '').split('/');
54
+ const auth = env.PULSE_GITHUB_AUTH === 'gh' && env.PULSE_GITHUB_GH ? { kind: 'gh', gh: env.PULSE_GITHUB_GH }
55
+ : env.PULSE_GITHUB_AUTH === 'token' && env.PULSE_GITHUB_TOKEN ? { kind: 'token', token: env.PULSE_GITHUB_TOKEN }
56
+ : null;
57
+ // gh is handed an environment without the token variable it never needed.
58
+ const { PULSE_GITHUB_TOKEN: _drop, ...clean } = env;
59
+ return { repo: owner && repo ? { owner, repo, slug: `${owner}/${repo}` } : null, root: env.PULSE_GITHUB_ROOT || null, auth, writes: env.PULSE_GITHUB_WRITES === '1', env: clean };
60
+ }
61
+
62
+ let outbound = null;
63
+ function logFor(env) {
64
+ if (!env.PULSE_OUTBOUND_LOG) return null;
65
+ // This process has its own copy of the declarations; without this, its lines would read as an
66
+ // address nothing here declares.
67
+ declareDestinations('github', github.REACHES);
68
+ outbound ??= new OutboundLog({ file: env.PULSE_OUTBOUND_LOG });
69
+ return outbound;
70
+ }
71
+
72
+ export async function callTool(name, args = {}, { env = process.env, api = github } = {}) {
73
+ const here = contextFrom(env);
74
+ if (!here.repo) throw new github.GithubError('NO_REPO', 'No repository was handed to this server.');
75
+ if (!here.auth) throw new github.GithubError('NO_CREDENTIAL', github.ERRORS.NO_SESSION);
76
+ if (WRITE_TOOLS.includes(name) && !here.writes) throw new github.GithubError('NOT_AIRLOCK', 'Writing to GitHub needs a turn in #4 AIRLOCK. Nothing was posted.');
77
+ const common = { repo: here.repo, auth: here.auth, log: logFor(env), env: here.env };
78
+ const branch = async () => (here.root ? api.currentBranch(here.root, { env: here.env }) : null);
79
+ switch (name) {
80
+ case 'github_list_prs': return { repo: here.repo.slug, pulls: await api.listPulls({ ...common, limit: args.limit }) };
81
+ case 'github_get_pr': return await api.getPull({ ...common, number: args.number });
82
+ case 'github_ci_status': {
83
+ const ref = String(args.ref ?? '').trim() || await branch();
84
+ const [ci, runs] = await Promise.all([api.ciStatus({ ...common, ref }), api.workflowRuns({ ...common, branch: ref, limit: 5 }).catch(() => [])]);
85
+ return { ...ci, runs };
86
+ }
87
+ case 'github_list_issues': return { repo: here.repo.slug, issues: await api.listIssues({ ...common, limit: args.limit }) };
88
+ case 'github_get_issue': return await api.getIssue({ ...common, number: args.number });
89
+ case 'github_create_pr': {
90
+ const head = String(args.head ?? '').trim() || await branch();
91
+ return await api.createPull({ ...common, title: args.title, body: args.body ?? '', head, base: String(args.base ?? '').trim() || null });
92
+ }
93
+ case 'github_comment': return await api.comment({ ...common, number: args.number, body: args.body });
94
+ case 'github_create_issue': return await api.createIssue({ ...common, title: args.title, body: args.body ?? '' });
95
+ default: throw new github.GithubError('UNKNOWN_TOOL', `Unknown tool: ${name}`);
96
+ }
97
+ }
98
+
99
+ export function handleRequest(message, { env = process.env, api = github } = {}) {
100
+ const reply = (result) => ({ jsonrpc: '2.0', id: message.id, result });
101
+ const fail = (code, text) => ({ jsonrpc: '2.0', id: message.id, error: { code, message: text } });
102
+ const writes = env.PULSE_GITHUB_WRITES === '1';
103
+ switch (message.method) {
104
+ case 'initialize':
105
+ return Promise.resolve(reply({ protocolVersion: message.params?.protocolVersion ?? '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: SERVER_NAME, version: SERVER_VERSION } }));
106
+ case 'notifications/initialized':
107
+ case 'initialized':
108
+ return Promise.resolve(null);
109
+ case 'ping':
110
+ return Promise.resolve(reply({}));
111
+ case 'tools/list':
112
+ // Below #4 the write tools are not merely refused: they are not here.
113
+ return Promise.resolve(reply({ tools: TOOLS.filter((tool) => writes || !WRITE_TOOLS.includes(tool.name)) }));
114
+ case 'tools/call': {
115
+ const { name, arguments: args = {} } = message.params ?? {};
116
+ if (!TOOLS.some((tool) => tool.name === name)) return Promise.resolve(fail(-32602, `Unknown tool: ${name}`));
117
+ const writing = WRITE_TOOLS.includes(name);
118
+ return callTool(name, args, { env, api })
119
+ .then((result) => reply({ content: [{ type: 'text', text: writing ? `Posted to GitHub: ${JSON.stringify(result)}` : JSON.stringify(result) }], isError: false }))
120
+ .catch((error) => {
121
+ const detail = error.detail ? ` GitHub said: ${error.detail}` : '';
122
+ const lead = writing ? 'Nothing was posted' : 'GitHub did not answer';
123
+ return reply({ content: [{ type: 'text', text: `${lead} (${error.code ?? 'ERROR'}): ${error.message}${detail}` }], isError: true });
124
+ });
125
+ }
126
+ default:
127
+ return Promise.resolve(message.id === undefined ? null : fail(-32601, `Method not found: ${message.method}`));
128
+ }
129
+ }
130
+
131
+ export function serve({ input = process.stdin, output = process.stdout, env = process.env } = {}) {
132
+ let buffer = '';
133
+ let pending = 0;
134
+ let ended = false;
135
+ const finish = () => { if (ended && pending === 0) void (outbound?.drain() ?? Promise.resolve()).finally(() => process.exit(0)); };
136
+ input.setEncoding('utf8');
137
+ input.on('data', (chunk) => {
138
+ buffer += chunk;
139
+ let index;
140
+ while ((index = buffer.indexOf('\n')) >= 0) {
141
+ const line = buffer.slice(0, index).trim();
142
+ buffer = buffer.slice(index + 1);
143
+ if (!line) continue;
144
+ let message;
145
+ try { message = JSON.parse(line); } catch { output.write(`${JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } })}\n`); continue; }
146
+ pending += 1;
147
+ void handleRequest(message, { env })
148
+ .then((response) => { if (response) output.write(`${JSON.stringify(response)}\n`); })
149
+ .finally(() => { pending -= 1; finish(); });
150
+ }
151
+ });
152
+ input.on('end', () => { ended = true; finish(); });
153
+ }
154
+
155
+ // Both paths resolved before they are compared: `new URL(import.meta.url).pathname` would be
156
+ // /C:/… on Windows (WIN-004), and a path through a symlink would never match its target.
157
+ const real = async (path) => (path ? realpath(path).catch(() => path) : null);
158
+ if (process.argv[1] && (await real(process.argv[1])) === (await real(SELF))) {
159
+ if (!process.env.PULSE_GITHUB_REPO) {
160
+ process.stderr.write('PULSE_GITHUB_REPO is required.\n');
161
+ process.exit(2);
162
+ }
163
+ serve();
164
+ }
@@ -28,7 +28,7 @@ const FAKE_PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADU
28
28
 
29
29
  // Where a Gemini key can be, in the order the room should trust them: what this process was
30
30
  // given, then the file the CLI reads on every run — which is also the file MADRE itself writes
31
- // when a key is pasted into CONNECTIONS — and last the macOS keychain, which only exists when
31
+ // when a key is pasted into Settings › Agents — and last the macOS keychain, which only exists when
32
32
  // the CLI's own sign-in put it there. Leaving the file out meant MADRE saved a key in a place it
33
33
  // then refused to look, and on Linux, where there is no keychain, that was the only place it
34
34
  // could have been: Image Studio could never find a key there at all.
@@ -11,9 +11,12 @@
11
11
  // Gemini CLI. Without a key search is lexical only.
12
12
 
13
13
  import { readFile, readdir, stat, realpath } from 'node:fs/promises';
14
+ import { homedir } from 'node:os';
14
15
  import { join } from 'node:path';
15
16
  import { RoomMemory, formatRecall, formatMemories, MEMORY_KINDS } from '../memory.mjs';
16
17
  import { createEmbedder } from '../embeddings.mjs';
18
+ import { loadConfig } from '../config.mjs';
19
+ import { Privacy, privacySettings } from '../privacy.mjs';
17
20
  import { resolveGeminiKey } from './image-server.mjs';
18
21
 
19
22
  const SERVER_NAME = 'pulse-memory';
@@ -71,9 +74,13 @@ export const TOOLS = [
71
74
 
72
75
  function clamp(n, min, max, fallback) { const value = Number(n); return Number.isFinite(value) ? Math.min(max, Math.max(min, Math.trunc(value))) : fallback; }
73
76
 
74
- export async function openMemory({ dbFile = process.env.PULSE_MEMORY_DB, env = process.env, keyResolver = resolveGeminiKey } = {}) {
77
+ export async function openMemory({ dbFile = process.env.PULSE_MEMORY_DB, env = process.env, keyResolver = resolveGeminiKey, stateRoot = env.PULSE_HOME ?? join(homedir(), '.pulse'), privacy = null } = {}) {
75
78
  if (!dbFile) throw new Error('PULSE_MEMORY_DB is required.');
76
- const memory = await new RoomMemory(dbFile).initialize();
79
+ // A private term never enters a note, whoever writes it (docs/MACROVERSO.md, S-008): the guard
80
+ // the room attaches to its own index, built from the same config.json every room on this Mac
81
+ // shares, so a note an agent saves through memory_note is kept the way the archivist's are.
82
+ const guard = privacy ?? new Privacy({ ...privacySettings(await loadConfig(stateRoot).catch(() => ({})), env), home: homedir() });
83
+ const memory = await new RoomMemory(dbFile).attachPrivacy(guard).initialize();
77
84
  let embedder = null;
78
85
  if (env.PULSE_EMBED !== '0') {
79
86
  const key = env.PULSE_EMBED_FAKE === '1' ? null : await keyResolver(env).catch(() => null);
@@ -115,7 +122,8 @@ export async function callTool(name, args = {}, { memory, projectRoot = process.
115
122
  const added = memory.addMemories([{ kind, text, sources }], { agent, fromSequence: sources.length ? Math.min(...sources) : last, throughSequence: sources.length ? Math.max(...sources) : last, origin: 'noted', messageId });
116
123
  if (!added) return `Already remembered: "${text}"`;
117
124
  const [saved] = memory.notesSince(before, { agent });
118
- return `Saved memory #${saved?.id ?? '?'} (${kind}) for every future turn of this room: "${text}"`;
125
+ // Echoed as it was kept: with a private term already replaced by the marker.
126
+ return `Saved memory #${saved?.id ?? '?'} (${kind}) for every future turn of this room: "${saved?.text ?? text}"`;
119
127
  }
120
128
  case 'memory_search': {
121
129
  const query = String(args.query ?? '').trim();
package/src/memory.mjs CHANGED
@@ -10,7 +10,7 @@
10
10
  // match meaning and not only words. Filled in the background.
11
11
  // GHOST events never reach the ledger, so they never reach here either.
12
12
 
13
- import { mkdir, rm } from 'node:fs/promises';
13
+ import { mkdir } from 'node:fs/promises';
14
14
  import { dirname } from 'node:path';
15
15
  import { messageEntry } from './conversation-context.mjs';
16
16
  import { cosine, toBlob, fromBlob } from './embeddings.mjs';
@@ -196,6 +196,11 @@ export class RoomMemory {
196
196
 
197
197
  #open() {
198
198
  this.#db = new this.#Database(this.#file);
199
+ // Another process writes this file too — the memory server an agent's turn starts, `madre
200
+ // dataset` — and SQLite answered «database is locked» at once, so a note, an index line or a
201
+ // verdict was lost (LOG-08 of the 1.0 review). It waits its turn a few seconds instead. A
202
+ // pragma, because the `timeout` option of node:sqlite is newer than the Node 22.5 MADRE needs.
203
+ this.#db.exec('PRAGMA busy_timeout = 5000');
199
204
  if (this.#file !== ':memory:') this.#db.exec('PRAGMA journal_mode = WAL');
200
205
  this.#createSchema();
201
206
  this.#meta = this.#db.prepare('SELECT value FROM meta WHERE key = ?');
@@ -323,7 +328,9 @@ export class RoomMemory {
323
328
  let entries = 0;
324
329
  for (const row of this.#db.prepare('SELECT text FROM entries').iterate()) if (this.#privacy.hits(row.text)) entries += 1;
325
330
  let memories = 0;
326
- for (const row of this.#db.prepare('SELECT text FROM memories').iterate()) if (this.#privacy.hits(row.text)) memories += 1;
331
+ for (const row of this.#db.prepare('SELECT text, correction FROM memories').iterate()) {
332
+ if (this.#privacy.hits(row.text) || (row.correction && this.#privacy.hits(row.correction))) memories += 1;
333
+ }
327
334
  return { entries, memories };
328
335
  }
329
336
 
@@ -334,13 +341,17 @@ export class RoomMemory {
334
341
  if (!this.#privacy?.enabled) return { entries: 0, memories: 0 };
335
342
  const counts = { entries: 0, memories: 0 };
336
343
  const entryRows = this.#db.prepare('SELECT sequence, event_id, timestamp, type, role, sender, target, message_id, text, distilled FROM entries').all();
337
- const memoryRows = this.#db.prepare('SELECT id, created, kind, text, norm, from_sequence, through_sequence, sources, agent, origin, message_id FROM memories').all();
344
+ // Preserve every column when replacing a row. In particular, a refuted note must keep its
345
+ // standing and its refutation; a recall must keep its history. The schema is the source of
346
+ // this list so a later additive column cannot silently be reset to its default here.
347
+ const memoryColumns = this.#db.prepare('PRAGMA table_info(memories)').all().map((column) => column.name);
348
+ const memoryRows = this.#db.prepare('SELECT * FROM memories').all();
338
349
  const deleteEntry = this.#db.prepare('DELETE FROM entries WHERE sequence = ?');
339
350
  const deleteEntryVector = this.#db.prepare('DELETE FROM entry_vectors WHERE sequence = ?');
340
351
  const insertEntry = this.#db.prepare('INSERT INTO entries (sequence, event_id, timestamp, type, role, sender, target, message_id, text, distilled) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
341
352
  const deleteMemory = this.#db.prepare('DELETE FROM memories WHERE id = ?');
342
353
  const deleteMemoryVector = this.#db.prepare('DELETE FROM memory_vectors WHERE id = ?');
343
- const insertMemory = this.#db.prepare('INSERT INTO memories (id, created, kind, text, norm, from_sequence, through_sequence, sources, agent, origin, message_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
354
+ const insertMemory = this.#db.prepare(`INSERT INTO memories (${memoryColumns.map((name) => `"${name.replaceAll('"', '""')}"`).join(', ')}) VALUES (${memoryColumns.map(() => '?').join(', ')})`);
344
355
  this.#db.exec('BEGIN');
345
356
  try {
346
357
  for (const row of entryRows) {
@@ -353,12 +364,14 @@ export class RoomMemory {
353
364
  }
354
365
  for (const row of memoryRows) {
355
366
  const { text, hits } = this.#privacy.redact(row.text);
356
- if (!hits) continue;
367
+ const correction = row.correction ? this.#privacy.redact(row.correction) : { text: null, hits: 0 };
368
+ if (!hits && !correction.hits) continue;
357
369
  deleteMemoryVector.run(row.id);
358
370
  deleteMemory.run(row.id);
359
371
  // Two notes may collapse into the same redacted sentence: the later one is dropped.
360
- const clash = this.#db.prepare('SELECT id FROM memories WHERE norm = ?').get(normalizeMemory(text));
361
- 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);
372
+ const norm = memoryKey(row.kind, text);
373
+ const clash = this.#db.prepare('SELECT id FROM memories WHERE norm = ?').get(norm);
374
+ if (!clash) insertMemory.run(...memoryColumns.map((name) => name === 'text' ? text : name === 'norm' ? norm : name === 'correction' ? correction.text : row[name]));
362
375
  counts.memories += 1;
363
376
  }
364
377
  this.#db.exec('COMMIT');
@@ -610,9 +623,13 @@ export class RoomMemory {
610
623
  if (!row) return null;
611
624
  this.#db.exec('BEGIN');
612
625
  try {
613
- this.#db.prepare(`UPDATE memories SET refuted_by = NULL, zone = '${HOLD}' WHERE refuted_by = ?`).run(row.id);
626
+ // What it took out stands again by the rule, the human's verdict included (LOG-10 of the
627
+ // 1.0 review): written straight to HOLD, a note the human had marked bad travelled again.
628
+ const freed = this.#db.prepare('SELECT id FROM memories WHERE refuted_by = ?').all(row.id).map((one) => one.id);
629
+ this.#db.prepare('UPDATE memories SET refuted_by = NULL WHERE refuted_by = ?').run(row.id);
614
630
  this.#db.prepare('DELETE FROM memories WHERE id = ?').run(row.id);
615
631
  this.#db.prepare('DELETE FROM memory_vectors WHERE id = ?').run(row.id);
632
+ this.#restand(freed);
616
633
  this.#db.exec('COMMIT');
617
634
  return { id: row.id, text: row.text, restored: row.contradicts ?? null };
618
635
  } catch (error) {
@@ -666,18 +683,27 @@ export class RoomMemory {
666
683
  this.#db.prepare('DELETE FROM recalls WHERE memory_id = ?').run(id);
667
684
  // A note quarantined by this one comes back: with the aberration gone there is nothing
668
685
  // holding it out of circulation, and a memory must never be lost to a pointer at nothing.
669
- this.#db.prepare(`UPDATE memories SET refuted_by = NULL, zone = '${HOLD}' WHERE refuted_by = ?`).run(id);
686
+ // Where it stands is decided by the rule, the human's verdict included (LOG-10).
687
+ const freed = this.#db.prepare('SELECT id FROM memories WHERE refuted_by = ?').all(id).map((one) => one.id);
688
+ this.#db.prepare('UPDATE memories SET refuted_by = NULL WHERE refuted_by = ?').run(id);
670
689
  this.#db.prepare('DELETE FROM memories WHERE id = ?').run(id);
690
+ this.#restand(freed);
671
691
  this.#db.exec('COMMIT');
672
692
  } catch (error) { this.#db.exec('ROLLBACK'); throw error; }
673
693
  return row;
674
694
  }
675
695
 
676
696
  // Which notes are about the same thing: pairs whose vectors agree, strongest
677
- // first, a few per note. Empty without an embedder or vectors.
697
+ // first, a few per note. Comparing notes with each other needs only the vectors
698
+ // already stored, not a live embedder: when the one in use has none (Ollama off,
699
+ // the room fell back to another), the model that embedded the most notes draws
700
+ // the links. Empty only when no note was ever embedded.
678
701
  memoryLinks({ floor = 0.6, maxPerNode = 4 } = {}) {
679
- if (!this.#embedder) return [];
680
- const rows = this.#db.prepare('SELECT id, vec FROM memory_vectors WHERE model = ?').all(this.#embedder.model).map((row) => ({ id: row.id, vec: fromBlob(row.vec) }));
702
+ const stored = this.#db.prepare('SELECT model, COUNT(*) AS n FROM memory_vectors GROUP BY model ORDER BY n DESC').all();
703
+ const current = this.#embedder ? stored.find((row) => row.model === this.#embedder.model) : null;
704
+ const model = current?.model ?? stored[0]?.model;
705
+ if (!model) return [];
706
+ const rows = this.#db.prepare('SELECT id, vec FROM memory_vectors WHERE model = ?').all(model).map((row) => ({ id: row.id, vec: fromBlob(row.vec) }));
681
707
  const links = [];
682
708
  for (let i = 0; i < rows.length; i += 1) {
683
709
  for (let j = i + 1; j < rows.length; j += 1) {
package/src/models.mjs CHANGED
@@ -26,6 +26,55 @@ export const KNOWN_MODELS = {
26
26
  },
27
27
  };
28
28
 
29
+ // How hard the model thinks before it answers, for the CLIs that take it per run. Claude Code
30
+ // reads --effort; Codex reads model_reasoning_effort as a config override. Gemini and OpenCode
31
+ // have no per-run knob MADRE can name truthfully (OpenCode's --variant means something different
32
+ // for each provider), so they get no chooser at all rather than one that guesses.
33
+ //
34
+ // Ordered lightest first: the composer draws them as a ladder, and the order is the meaning.
35
+ //
36
+ // Codex's levels belong to each model, not to the CLI. Its own cache (~/.codex/models_cache.json)
37
+ // lists the ones each model takes, and the API refuses any other with a 400 — «'minimal' is not
38
+ // supported with the 'gpt-5.5' model» — that used to reach the room as a lone «{». The list here is
39
+ // every name Codex has used, in order; a turn gets only the ones its model takes.
40
+ export const EFFORTS = {
41
+ claude: ['low', 'medium', 'high', 'xhigh', 'max'],
42
+ codex: ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'],
43
+ };
44
+ // What a Codex with no cache to read is offered: the levels its models have always taken.
45
+ const CODEX_SAFE = ['low', 'medium', 'high'];
46
+
47
+ export function isValidEffort(agentId, effort) {
48
+ return typeof effort === 'string' && (EFFORTS[agentId] ?? []).includes(effort);
49
+ }
50
+
51
+ // Whose model a name is, when the name says so. Codex, Claude and Gemini each answer only to their
52
+ // own vendor's models, and a name from another one reaches the CLI as an API error that reads
53
+ // like anything but "wrong model" (Gemini: `models/gpt-5.6-sol is not found for API version
54
+ // v1beta`). OpenCode is left out on purpose: it speaks to many providers, so any name may be its.
55
+ const VENDOR = [
56
+ ['codex', /^(gpt-|o\d|codex-|chatgpt-)/i],
57
+ ['claude', /^(claude|fable|opus|sonnet|haiku)\b/i],
58
+ ['gemini', /^(gemini|gemma|auto$)/i],
59
+ ];
60
+ export function modelOwner(model) {
61
+ return VENDOR.find(([, pattern]) => pattern.test(String(model ?? '')))?.[0] ?? null;
62
+ }
63
+ // True when the name clearly belongs to another of the three: the turn is better off on the
64
+ // agent's own default than sent to fail.
65
+ export function belongsElsewhere(agentId, model) {
66
+ if (!VENDOR.some(([id]) => id === agentId)) return false;
67
+ const owner = modelOwner(model);
68
+ return Boolean(owner) && owner !== agentId;
69
+ }
70
+
71
+ // A CLI saying it does not know the model it was given, in any of the ways the four say it.
72
+ export const MODEL_NOT_FOUND = /models?\/\S+ is not found|not supported for generateContent|model[_ ]not[_ ]found|unknown model|invalid model|model (?:\S+ )?(?:does not exist|is not available|not found)|issue with the selected model/i;
73
+
74
+ export function parseCodexDefaultEffort(toml) {
75
+ return String(toml ?? '').match(/^\s*model_reasoning_effort\s*=\s*"([^"]+)"/m)?.[1] ?? null;
76
+ }
77
+
29
78
  export function parseCodexModelCache(json) {
30
79
  try {
31
80
  const data = JSON.parse(json);
@@ -42,6 +91,49 @@ export function parseCodexModelCache(json) {
42
91
  }
43
92
  }
44
93
 
94
+ // Per model, the efforts it takes (lightest first) and the one it uses when told nothing.
95
+ export function parseCodexEfforts(json) {
96
+ try {
97
+ const data = JSON.parse(json);
98
+ const byModel = {};
99
+ const walk = (node) => {
100
+ if (!node || typeof node !== 'object') return;
101
+ if (typeof node.slug === 'string' && Array.isArray(node.supported_reasoning_levels)) {
102
+ const levels = EFFORTS.codex.filter((level) => node.supported_reasoning_levels.some((one) => one?.effort === level));
103
+ if (levels.length) byModel[node.slug] = { levels, default: levels.includes(node.default_reasoning_level) ? node.default_reasoning_level : null };
104
+ }
105
+ for (const value of Object.values(node)) walk(value);
106
+ };
107
+ walk(data);
108
+ return byModel;
109
+ } catch {
110
+ return {};
111
+ }
112
+ }
113
+
114
+ // The levels one Codex model takes: its own, from the cache; for a name the cache does not list,
115
+ // the ones every listed model shares, so a model MADRE cannot read never meets a refusal.
116
+ export function codexLevels(byModel, model) {
117
+ if (model && byModel[model]) return byModel[model].levels;
118
+ const lists = Object.values(byModel).map((one) => one.levels);
119
+ return lists.length ? lists.reduce((shared, levels) => shared.filter((level) => levels.includes(level))) : CODEX_SAFE;
120
+ }
121
+
122
+ export async function readCodexEfforts({ home = homedir() } = {}) {
123
+ const [cache, toml] = await Promise.all([
124
+ readFile(join(home, '.codex', 'models_cache.json'), 'utf8').catch(() => ''),
125
+ readFile(join(home, '.codex', 'config.toml'), 'utf8').catch(() => ''),
126
+ ]);
127
+ return { byModel: parseCodexEfforts(cache), model: parseCodexDefaultModel(toml), effort: parseCodexDefaultEffort(toml) };
128
+ }
129
+
130
+ // Whether a turn's effort reaches Codex: the model it runs on — the one chosen, or the CLI's
131
+ // default — has to take it.
132
+ export async function codexTakes(model, effort, { home = homedir() } = {}) {
133
+ const read = await readCodexEfforts({ home });
134
+ return codexLevels(read.byModel, model || read.model).includes(effort);
135
+ }
136
+
45
137
  export function parseCodexDefaultModel(toml) {
46
138
  return String(toml ?? '').match(/^\s*model\s*=\s*"([^"]+)"/m)?.[1] ?? null;
47
139
  }
@@ -58,9 +150,22 @@ export async function discoverModels({ agents = [], config = {}, home = homedir(
58
150
  const known = KNOWN_MODELS[agent.id] ?? { defaults: [], note: '' };
59
151
  let discovered = [];
60
152
  let cliDefault = null;
153
+ let effortDefault = null;
154
+ let efforts = EFFORTS[agent.id] ?? [];
155
+ let effortsByModel;
156
+ let effortsShared;
61
157
  if (agent.id === 'codex') {
62
158
  discovered = parseCodexModelCache(await readFile(join(home, '.codex', 'models_cache.json'), 'utf8').catch(() => ''));
63
- cliDefault = parseCodexDefaultModel(await readFile(join(home, '.codex', 'config.toml'), 'utf8').catch(() => ''));
159
+ const read = await readCodexEfforts({ home });
160
+ cliDefault = read.model;
161
+ effortDefault = read.effort;
162
+ // The ladder is the model's: the CLI default's here, each model's in the composer once it is
163
+ // chosen. What the CLI does when told nothing is the config's level if that model takes it,
164
+ // or the model's own.
165
+ efforts = codexLevels(read.byModel, cliDefault);
166
+ effortsShared = codexLevels(read.byModel, null);
167
+ effortsByModel = Object.fromEntries(Object.entries(read.byModel).map(([slug, one]) => [slug, { levels: one.levels, default: one.levels.includes(read.effort) ? read.effort : one.default }]));
168
+ if (!efforts.includes(effortDefault)) effortDefault = (cliDefault && read.byModel[cliDefault]?.default) || null;
64
169
  }
65
170
  if (agent.id === 'opencode' && agent.detected) {
66
171
  discovered = await listOpenCode(agent).catch(() => []);
@@ -71,6 +176,9 @@ export async function discoverModels({ agents = [], config = {}, home = homedir(
71
176
  models: unique([...custom, ...discovered, ...known.defaults]),
72
177
  default: cliDefault,
73
178
  note: known.note,
179
+ efforts,
180
+ effortDefault: isValidEffort(agent.id, effortDefault) ? effortDefault : null,
181
+ ...(effortsByModel ? { effortsByModel, effortsShared } : {}),
74
182
  source: discovered.length ? 'discovered' : 'known',
75
183
  };
76
184
  }
@@ -30,12 +30,12 @@ async function detect(projectRoot) {
30
30
  async function preflight(projectRoot, { toplevel = gitToplevel, npx = findOnPath } = {}) {
31
31
  const root = await realpath(projectRoot).catch(() => resolve(projectRoot));
32
32
  const problems = [];
33
- if (!(await npx('npx'))) problems.push('npx is not on PATH for the MADRE server; start MADRE from a terminal where `npx --version` works.');
33
+ if (!(await npx('npx'))) problems.push(t('npx is not on PATH for the MADRE server; start MADRE from a terminal where `npx --version` works.'));
34
34
  const top = await toplevel(root);
35
35
  if (!top) {
36
- problems.push('The project is not a git repository. AHP+ pins itself to the git root: run `git init` in the project first.');
36
+ problems.push(t('The project is not a git repository. AHP+ pins itself to the git root: run `git init` in the project first.'));
37
37
  } else if (top !== root) {
38
- problems.push(`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
+ problems.push(t('The project\'s git root is {top}, not the project itself. AHP+ would install there. Run `git init` inside the project, or remove the stray repository at {top}.', { top }));
39
39
  }
40
40
  return { ok: problems.length === 0, problems, gitRoot: top };
41
41
  }
@@ -50,7 +50,7 @@ function installCommand({ agents = [] }) {
50
50
  export default defineModule({
51
51
  id: 'ahp',
52
52
  kind: 'installer',
53
- name: 'AHP+',
53
+ name: 'Continuity',
54
54
  vendor: 'Agent Handoff Protocol Plus',
55
55
  package: PACKAGE,
56
56
  tracks: { name: PACKAGE, npm: PACKAGE },
@@ -20,7 +20,7 @@ import { t } from '../i18n.mjs';
20
20
  export default defineModule({
21
21
  id: 'ash',
22
22
  configKey: 'ash',
23
- name: 'Ash',
23
+ name: 'Token saver',
24
24
  vendor: 'MADRE',
25
25
  version: '1.0.0',
26
26
  summary: 'Asks every agent for compact prose without altering what you write. MADRE shows each turn\'s input, output and cache use; savings depend on the agent, task and cache, so no fixed percentage is promised.',