@jossuealcala/madre 0.3.3 → 0.4.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 (72) hide show
  1. package/CHANGELOG.md +412 -3
  2. package/CONTRIBUTING.md +3 -1
  3. package/README.md +67 -185
  4. package/SECURITY.md +2 -1
  5. package/bin/madre.mjs +56 -13
  6. package/docs/INTERNALS.md +16 -0
  7. package/docs/REFERENCE.md +249 -0
  8. package/docs/SDK.md +121 -0
  9. package/docs/room.png +0 -0
  10. package/docs/sdk/hello-module.mjs +51 -0
  11. package/package.json +9 -1
  12. package/public/app.js +3825 -851
  13. package/public/es.js +2050 -0
  14. package/public/i18n.js +66 -0
  15. package/public/index.html +94 -13
  16. package/public/inquiry.js +220 -0
  17. package/public/resay.js +77 -0
  18. package/public/styles.css +602 -62
  19. package/public/troubleshooting.js +168 -46
  20. package/src/adapters/claude.mjs +2 -1
  21. package/src/adapters/codex.mjs +2 -1
  22. package/src/adapters/gemini.mjs +6 -5
  23. package/src/adapters/opencode.mjs +2 -1
  24. package/src/adapters/process.mjs +17 -5
  25. package/src/asking.mjs +128 -0
  26. package/src/auth-probe.mjs +58 -1
  27. package/src/chats.mjs +193 -0
  28. package/src/checkpoint.mjs +1 -1
  29. package/src/cold.mjs +56 -0
  30. package/src/commands.mjs +6 -0
  31. package/src/conversation-context.mjs +35 -3
  32. package/src/credentials.mjs +145 -0
  33. package/src/dataset.mjs +56 -4
  34. package/src/distiller.mjs +12 -5
  35. package/src/event-store.mjs +14 -8
  36. package/src/exam.mjs +240 -0
  37. package/src/extensions.mjs +3 -2
  38. package/src/eyecat-watch.mjs +100 -0
  39. package/src/eyecat.mjs +169 -0
  40. package/src/i18n.mjs +47 -0
  41. package/src/image-studio.mjs +2 -0
  42. package/src/launch.mjs +61 -0
  43. package/src/maturity.mjs +94 -0
  44. package/src/mcp/image-server.mjs +12 -1
  45. package/src/mcp/memory-server.mjs +1 -1
  46. package/src/memory.mjs +325 -17
  47. package/src/modules/ahp.mjs +9 -7
  48. package/src/modules/ash.mjs +36 -0
  49. package/src/modules/git-pulse.mjs +5 -3
  50. package/src/modules/helpers.mjs +31 -0
  51. package/src/modules/image-studio.mjs +9 -4
  52. package/src/modules/index.mjs +141 -9
  53. package/src/modules/ollama.mjs +66 -10
  54. package/src/modules/playwright.mjs +36 -19
  55. package/src/modules/ripley.mjs +5 -3
  56. package/src/modules/sdk.mjs +93 -2
  57. package/src/modules/updates.mjs +81 -0
  58. package/src/ollama.mjs +5 -2
  59. package/src/outbound.mjs +292 -0
  60. package/src/privacy.mjs +54 -7
  61. package/src/room/context.mjs +4 -4
  62. package/src/room/economy.mjs +161 -0
  63. package/src/room/prompt.mjs +118 -46
  64. package/src/room.mjs +443 -44
  65. package/src/runtime-detection.mjs +27 -8
  66. package/src/server.mjs +699 -69
  67. package/src/setup.mjs +1 -1
  68. package/src/updates.mjs +4 -2
  69. package/src/usage-sentinel.mjs +13 -8
  70. package/src/verdict.mjs +74 -0
  71. package/src/ashcode.mjs +0 -64
  72. package/src/modules/ashcode.mjs +0 -28
@@ -0,0 +1,81 @@
1
+ // Is there a newer version of what a module is? Two kinds of answer, because there are two kinds
2
+ // of module.
3
+ //
4
+ // A module that ships with MADRE has its own version, written in its file and starting at 1.0.0.
5
+ // It cannot update on its own: it arrives in a release, so its update is MADRE's update, and the
6
+ // card says which release it travels in.
7
+ //
8
+ // A module that is a wrapper around something else — @playwright/mcp, Ollama, the AHP+ CLI — has
9
+ // no version of its own worth showing. Its version is that thing's version, found on this
10
+ // computer, and its update is that thing's next release. A module declares this once, in the SDK:
11
+ //
12
+ // tracks: { name: '@playwright/mcp', npm: '@playwright/mcp' }
13
+ // tracks: { name: 'ollama', github: 'ollama/ollama' }
14
+ //
15
+ // Every check is cached a day in ~/.pulse/updates/, goes out only while the release channel is on,
16
+ // and sends nothing but the name of the thing being asked about. A screen never waits on it:
17
+ // MODULES reads the cache, and the button on a card is what forces a fresh look.
18
+
19
+ import { join, dirname } from 'node:path';
20
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
21
+ import { checkForUpdate, compareVersions, UPDATE_TTL_MS } from '../updates.mjs';
22
+
23
+ export const GITHUB_RELEASES = 'https://api.github.com/repos';
24
+
25
+ async function readCache(file) {
26
+ try { return JSON.parse(await readFile(file, 'utf8')); } catch { return null; }
27
+ }
28
+
29
+ // The newest release of a repository, by its tag. Same shape and same cache as the registry read,
30
+ // because a card does not care where a version comes from.
31
+ export async function checkGithubRelease({ repo, current, cacheFile, fetchImpl = globalThis.fetch, now = Date.now(), ttlMs = UPDATE_TTL_MS, enabled = true, force = false, cacheOnly = false, timeoutMs = 4000 } = {}) {
32
+ const cached = cacheFile ? await readCache(cacheFile) : null;
33
+ const known = cached?.name === repo ? cached : null;
34
+ const fresh = known && Number.isFinite(known.checkedAt) && now - known.checkedAt < ttlMs;
35
+ let latest = known?.latest ?? null;
36
+ let checkedAt = known?.checkedAt ?? null;
37
+ let source = latest ? 'cache' : 'none';
38
+ let error = null;
39
+ if (enabled && !cacheOnly && (force || !fresh)) {
40
+ try {
41
+ const response = await fetchImpl(`${GITHUB_RELEASES}/${repo}/releases/latest`, { headers: { accept: 'application/vnd.github+json' }, signal: AbortSignal.timeout(timeoutMs) });
42
+ if (!response.ok) throw new Error(`github HTTP ${response.status}`);
43
+ const doc = await response.json();
44
+ const tag = String(doc?.tag_name ?? doc?.name ?? '').trim().replace(/^v/, '');
45
+ if (!tag) throw new Error('github answered without a release');
46
+ latest = tag;
47
+ checkedAt = now;
48
+ source = 'github';
49
+ if (cacheFile) {
50
+ await mkdir(dirname(cacheFile), { recursive: true }).catch(() => {});
51
+ await writeFile(cacheFile, JSON.stringify({ name: repo, latest, checkedAt })).catch(() => {});
52
+ }
53
+ } catch (cause) {
54
+ error = cause.message;
55
+ if (!latest) source = 'error';
56
+ }
57
+ }
58
+ if (!enabled) source = latest ? 'cache' : 'off';
59
+ return { enabled, current: current ?? null, latest, available: Boolean(latest && current && compareVersions(current, latest) < 0), checkedAt, source, error };
60
+ }
61
+
62
+ // What one card should say about being up to date. `item` is a described module.
63
+ export async function moduleUpdate(item, { stateRoot, madre, fetchImpl = globalThis.fetch, enabled = true, force = false, cacheOnly = !force, now = Date.now() } = {}) {
64
+ const tracks = item?.tracks ?? null;
65
+ const common = { fetchImpl, enabled, force, cacheOnly, now };
66
+ if (!tracks) {
67
+ // It ships with MADRE, so what can be newer is MADRE.
68
+ const check = await checkForUpdate({ name: madre.name, current: madre.version, cacheFile: join(stateRoot, 'updates.json'), ...common });
69
+ return { via: 'madre', name: madre.name, current: item?.version ?? null, ships: madre.version, latest: check.latest, available: check.available, checkedAt: check.checkedAt, source: check.source, error: check.error };
70
+ }
71
+ const cacheFile = join(stateRoot, 'updates', `${item.id}.json`);
72
+ if (tracks.npm) {
73
+ const check = await checkForUpdate({ name: tracks.npm, current: item.version, cacheFile, ...common });
74
+ return { via: 'npm', name: tracks.npm, current: item.version ?? null, latest: check.latest, available: check.available, checkedAt: check.checkedAt, source: check.source, error: check.error };
75
+ }
76
+ if (tracks.github) {
77
+ const check = await checkGithubRelease({ repo: tracks.github, current: item.version, cacheFile, ...common });
78
+ return { via: 'github', name: tracks.name ?? tracks.github, latest: check.latest, current: item.version ?? null, available: check.available, checkedAt: check.checkedAt, source: check.source, error: check.error };
79
+ }
80
+ return { via: 'none', name: tracks.name ?? item.name, current: item.version ?? null, latest: null, available: false, checkedAt: null, source: 'none', error: null };
81
+ }
package/src/ollama.mjs CHANGED
@@ -34,16 +34,19 @@ export async function probeOllama({ host = ollamaHost(), fetchImpl = globalThis.
34
34
  const response = await fetchImpl(`${host}/api/tags`, { signal: AbortSignal.timeout(timeoutMs) });
35
35
  if (!response.ok) return { running: false, host, models: [], embedModel: null, chatModel: null, error: `HTTP ${response.status}` };
36
36
  const payload = await response.json();
37
+ // What Ollama itself is running. It answers or it does not; either way the probe goes on.
38
+ const version = await fetchImpl(`${host}/api/version`, { signal: AbortSignal.timeout(timeoutMs) })
39
+ .then((answer) => (answer.ok ? answer.json() : null)).then((body) => body?.version ?? null).catch(() => null);
37
40
  const models = (payload.models ?? []).map((model) => ({ name: model.name, size: model.size ?? 0, family: model.details?.family ?? '', details: model.details ?? {} }));
38
41
  const embeds = models.filter(isEmbedModel);
39
42
  const chats = models.filter((model) => !isEmbedModel(model));
40
43
  return {
41
- running: true, host, models,
44
+ running: true, host, version, models,
42
45
  embedModel: pick(embeds, EMBED_MODELS, env.PULSE_OLLAMA_EMBED_MODEL),
43
46
  chatModel: pick(chats, CHAT_MODELS, env.PULSE_OLLAMA_MODEL),
44
47
  };
45
48
  } catch (error) {
46
- return { running: false, host, models: [], embedModel: null, chatModel: null, error: error.message };
49
+ return { running: false, host, version: null, models: [], embedModel: null, chatModel: null, error: error.message };
47
50
  }
48
51
  }
49
52
 
@@ -0,0 +1,292 @@
1
+ // What left this machine.
2
+ //
3
+ // MADRE runs on your computer and keeps everything it knows in a file you own, and that sentence
4
+ // is worth exactly as much as the list that qualifies it. Something does leave: a version check,
5
+ // an embedding, a crash report, the briefing itself. A person deciding whether to point this at a
6
+ // private codebase needs that list to be complete, and needs it to come from the code rather than
7
+ // from a promise in a README.
8
+ //
9
+ // So there are two halves here and they are different things.
10
+ //
11
+ // DESTINATIONS is the declaration: every address MADRE's own process may reach, what it says
12
+ // there, what puts it on and where you turn it off. It is written by hand because it says what a
13
+ // request MEANS, and no wrapper can know that.
14
+ //
15
+ // The log is the check on the declaration. Every fetch the room's process makes goes through one
16
+ // wrapper — MADRE's own calls and any a module makes, because a module installed tomorrow runs
17
+ // inside this process and cannot opt out of it — and lands in a line saying where it went and
18
+ // when. An address nobody declared shows up as one nobody declared, which is the whole point.
19
+ //
20
+ // What is never recorded: bodies, headers, and the values of query parameters. The names stay.
21
+ // The Gemini embedding endpoint takes the key in the URL, and a log of what left this machine
22
+ // would be a poor place to leave it.
23
+
24
+ import { appendFile, mkdir, open, stat, writeFile } from 'node:fs/promises';
25
+ import { dirname } from 'node:path';
26
+
27
+ export const KEEP = 200; // lines held for the screen
28
+ export const MAX_BYTES = 512 * 1024; // the file is trimmed to the last KEEP lines past this
29
+ const LOCAL = new Set(['127.0.0.1', 'localhost', '::1', '0.0.0.0', '[::1]']);
30
+
31
+ // Where an unknown condition goes when somebody presses SEND. It lives here because this is the
32
+ // file that has to name every address MADRE can reach; the room reads it from here.
33
+ export const DEFAULT_REPORT_URL = 'https://madre-reports.jossue-alcala-o.workers.dev/v1/reports';
34
+
35
+ // The declaration. `what` is the honest sentence: what a request carries, not what it is called.
36
+ export const DESTINATIONS = [
37
+ {
38
+ id: 'crew', host: null, to: 'whoever runs the agent you send a turn to',
39
+ what: 'The briefing — the document above, your question, the memories it summoned and the transcript it carries.',
40
+ when: 'every turn you send to an agent that is not @madre',
41
+ where: 'you choose it every time you send a turn; @madre answers on this computer and sends nothing',
42
+ inside: false,
43
+ },
44
+ {
45
+ id: 'embeddings', host: 'generativelanguage.googleapis.com', match: (url) => /batchEmbedContents/.test(url.pathname), to: 'Google · Gemini embeddings',
46
+ what: 'The text of what is being embedded: the first 2000 characters of each memory, and the question a recall is made of.',
47
+ when: 'a memory is written or recalled, while embeddings are set to Gemini',
48
+ where: 'MU/TH/UR → MEMORY → EMBEDDINGS · OLLAMA keeps it on this computer, OFF drops back to words',
49
+ inside: true,
50
+ },
51
+ {
52
+ id: 'image', host: 'generativelanguage.googleapis.com', match: (url) => /generateContent/.test(url.pathname), to: 'Google · Gemini image model',
53
+ what: 'The prompt an agent wrote for an image.',
54
+ when: 'an agent calls the image tool during a lease',
55
+ where: 'MODULES → IMAGE STUDIO',
56
+ // It runs in a process of its own and writes its own line into this same log, so it is here.
57
+ inside: true,
58
+ },
59
+ {
60
+ id: 'npm', host: 'registry.npmjs.org', to: 'the npm registry',
61
+ what: 'The name of a package, to read the version of its latest release. Nothing about you or your project.',
62
+ when: 'at most once a day per package, while the release channel is on',
63
+ where: 'MU/TH/UR → RELEASE CHANNEL · or PULSE_UPDATE_CHECK=0',
64
+ inside: true,
65
+ },
66
+ {
67
+ id: 'github', host: 'api.github.com', to: 'GitHub',
68
+ what: 'The name of a repository, to read the tag of its latest release. Nothing about you or your project.',
69
+ when: 'at most once a day per repository, while the release channel is on',
70
+ where: 'MU/TH/UR → RELEASE CHANNEL · or PULSE_UPDATE_CHECK=0',
71
+ inside: true,
72
+ },
73
+ {
74
+ id: 'reports', host: new URL(DEFAULT_REPORT_URL).hostname, to: "the author's error collector",
75
+ what: 'An unknown condition: what broke, where in MADRE, and the version. Paths are cut back and your words are not in it.',
76
+ when: 'you press SEND — or on its own, only while auto-report is on',
77
+ where: 'MU/TH/UR → AUTO-REPORT UNKNOWN CONDITIONS · or PULSE_REPORT_URL',
78
+ inside: true,
79
+ },
80
+ {
81
+ id: 'anthropic', host: 'api.anthropic.com', to: 'Anthropic',
82
+ what: 'Your Claude Code token, to read how much of your plan is left. It is read from where Claude keeps it and never stored by MADRE.',
83
+ when: 'while a Claude session is being watched for its quota',
84
+ where: '⚙ CONNECTIONS → CLAUDE',
85
+ inside: true,
86
+ },
87
+ {
88
+ id: 'ollama', host: null, local: true, to: 'Ollama, on this computer',
89
+ what: 'Memories to embed, exchanges to distil, and whatever you ask @madre. It goes to a port on this machine and stops there.',
90
+ when: 'while Ollama is running',
91
+ where: 'MODULES → OLLAMA',
92
+ inside: true,
93
+ },
94
+ ];
95
+
96
+ const byId = new Map(DESTINATIONS.map((one) => [one.id, one]));
97
+ export const destination = (id) => byId.get(id) ?? null;
98
+
99
+ // Which declaration a URL belongs to. An address nobody declared is said to be exactly that.
100
+ export function classify(rawUrl, { reportHost = null } = {}) {
101
+ let url;
102
+ try { url = new URL(rawUrl); } catch { return { id: 'unknown', to: String(rawUrl).slice(0, 80), local: false }; }
103
+ const host = url.hostname.replace(/^\[|\]$/g, '');
104
+ if (LOCAL.has(host)) return { id: url.port === '11434' ? 'ollama' : 'room', to: `${host}:${url.port || '80'}`, local: true };
105
+ if (reportHost && host === reportHost) return { id: 'reports', to: byId.get('reports').to, local: false };
106
+ for (const one of DESTINATIONS) {
107
+ if (!one.host || one.host !== host) continue;
108
+ if (one.match && !one.match(url)) continue;
109
+ return { id: one.id, to: one.to, local: false };
110
+ }
111
+ return { id: 'unknown', to: host, local: false };
112
+ }
113
+
114
+ // One line of the log. No body, no headers, and no query VALUES — only which parameters were set.
115
+ export function lineFor({ url, method = 'GET', status = null, ok = false, ms = 0, bytes = null, error = null, at, reportHost = null }) {
116
+ const kind = classify(url, { reportHost });
117
+ let path = '';
118
+ let params = [];
119
+ try {
120
+ const parsed = new URL(url);
121
+ path = parsed.pathname;
122
+ params = [...parsed.searchParams.keys()];
123
+ } catch { path = ''; }
124
+ return {
125
+ at: at ?? new Date().toISOString(),
126
+ id: kind.id,
127
+ to: kind.to,
128
+ local: Boolean(kind.local),
129
+ method: String(method || 'GET').toUpperCase(),
130
+ path,
131
+ ...(params.length ? { params } : {}),
132
+ ...(Number.isFinite(status) ? { status } : {}),
133
+ ok: Boolean(ok),
134
+ ms: Math.round(ms),
135
+ ...(Number.isFinite(bytes) ? { bytes } : {}),
136
+ ...(error ? { error } : {}),
137
+ };
138
+ }
139
+
140
+ async function tail(file, window = 96 * 1024) {
141
+ let handle;
142
+ try {
143
+ const size = (await stat(file)).size;
144
+ if (!size) return [];
145
+ handle = await open(file, 'r');
146
+ const length = Math.min(window, size);
147
+ const buffer = Buffer.alloc(length);
148
+ await handle.read(buffer, 0, length, size - length);
149
+ const lines = buffer.toString('utf8').split('\n').filter(Boolean);
150
+ const out = [];
151
+ for (const line of lines) {
152
+ try { out.push(JSON.parse(line)); } catch { /* a half line at the window's edge */ }
153
+ }
154
+ return out;
155
+ } catch {
156
+ return [];
157
+ } finally {
158
+ await handle?.close().catch(() => {});
159
+ }
160
+ }
161
+
162
+ export class OutboundLog {
163
+ #file;
164
+ #keep;
165
+ #recent = [];
166
+ #counts = new Map();
167
+ #writes = Promise.resolve();
168
+ #reportHost = null;
169
+ #restore = null;
170
+
171
+ constructor({ file = null, keep = KEEP } = {}) {
172
+ this.#file = file;
173
+ this.#keep = keep;
174
+ }
175
+
176
+ // The collector's address is a setting, so which host counts as the collector is read live.
177
+ watchReportUrl(url) {
178
+ try { this.#reportHost = url ? new URL(url).hostname : null; } catch { this.#reportHost = null; }
179
+ return this;
180
+ }
181
+
182
+ async load() {
183
+ if (!this.#file) return this;
184
+ const lines = await tail(this.#file);
185
+ this.#recent = lines.slice(-this.#keep);
186
+ for (const line of this.#recent) this.#count(line);
187
+ return this;
188
+ }
189
+
190
+ #count(line) {
191
+ const at = this.#counts.get(line.id) ?? { calls: 0, failed: 0, last: null };
192
+ at.calls += 1;
193
+ if (!line.ok) at.failed += 1;
194
+ at.last = line.at;
195
+ this.#counts.set(line.id, at);
196
+ }
197
+
198
+ record(input) {
199
+ const line = lineFor({ ...input, reportHost: this.#reportHost });
200
+ this.#recent.push(line);
201
+ if (this.#recent.length > this.#keep) this.#recent.splice(0, this.#recent.length - this.#keep);
202
+ this.#count(line);
203
+ if (this.#file) {
204
+ this.#writes = this.#writes
205
+ .then(async () => {
206
+ await mkdir(dirname(this.#file), { recursive: true }).catch(() => {});
207
+ await appendFile(this.#file, `${JSON.stringify(line)}\n`);
208
+ const size = await stat(this.#file).then((one) => one.size, () => 0);
209
+ // Only a writer that is holding a full screen of lines may rewrite the file: the
210
+ // image studio appends from its own process and knows nothing of what came before it.
211
+ if (size > MAX_BYTES && this.#recent.length >= this.#keep) await writeFile(this.#file, `${this.#recent.map((one) => JSON.stringify(one)).join('\n')}\n`);
212
+ })
213
+ .catch(() => null);
214
+ }
215
+ return line;
216
+ }
217
+
218
+ recent({ limit = 40 } = {}) { return this.#recent.slice(-limit).reverse(); }
219
+
220
+ counts() { return Object.fromEntries(this.#counts); }
221
+
222
+ // The wrapper. It never changes what the caller gets back and never reads the response body:
223
+ // a log that consumed what it watched would break the thing it is watching.
224
+ watch(fetchImpl = globalThis.fetch) {
225
+ if (fetchImpl?.watched) return fetchImpl;
226
+ const log = this;
227
+ const watched = async function outbound(resource, init = {}) {
228
+ const url = typeof resource === 'string' ? resource : (resource?.url ?? String(resource));
229
+ const method = init?.method ?? resource?.method ?? 'GET';
230
+ const bytes = typeof init?.body === 'string' ? Buffer.byteLength(init.body) : null;
231
+ const started = Date.now();
232
+ try {
233
+ const response = await fetchImpl(resource, init);
234
+ log.record({ url, method, bytes, status: response?.status ?? null, ok: Boolean(response?.ok), ms: Date.now() - started });
235
+ return response;
236
+ } catch (error) {
237
+ // The name and the code, never the message: a message carries the address it failed on
238
+ // and sometimes what was in it.
239
+ log.record({ url, method, bytes, ok: false, ms: Date.now() - started, error: error?.code ?? error?.name ?? 'failed' });
240
+ throw error;
241
+ }
242
+ };
243
+ watched.watched = true;
244
+ return watched;
245
+ }
246
+
247
+ // Everything in this process, including whatever a module calls. Returns the undo.
248
+ install(target = globalThis) {
249
+ if (this.#restore) return this.#restore;
250
+ const original = target.fetch;
251
+ // Binding makes a new function, which would hide a watch somebody else already installed;
252
+ // the mark travels with it so one process logs a request once.
253
+ const bound = original.bind(target);
254
+ bound.watched = original.watched;
255
+ target.fetch = this.watch(bound);
256
+ this.#restore = () => { target.fetch = original; this.#restore = null; };
257
+ return this.#restore;
258
+ }
259
+
260
+ async drain() { await this.#writes; }
261
+ }
262
+
263
+ // The declaration with today's answer filled in: is this one on, and what has it done.
264
+ export function outboundView({ log = null, state = {}, agents = [] } = {}) {
265
+ const counts = log?.counts() ?? {};
266
+ const vendors = { codex: 'OpenAI', claude: 'Anthropic', gemini: 'Google', opencode: 'the provider OpenCode is signed in to' };
267
+ const crew = agents.filter((agent) => agent.detected && !agent.local).map((agent) => `@${agent.id} → ${vendors[agent.id] ?? 'its own provider'}`);
268
+ const destinations = DESTINATIONS.map((one) => ({
269
+ id: one.id,
270
+ to: one.id === 'crew' && crew.length ? crew.join(' · ') : one.to,
271
+ what: one.what,
272
+ when: one.when,
273
+ where: one.where,
274
+ local: Boolean(one.local),
275
+ // Whether the call lands in the log at all. Everything MADRE starts does, the image studio
276
+ // from its own process included; the crew's own conversation with its provider does not.
277
+ inside: Boolean(one.inside),
278
+ on: state[one.id] ?? null,
279
+ calls: counts[one.id]?.calls ?? 0,
280
+ failed: counts[one.id]?.failed ?? 0,
281
+ last: counts[one.id]?.last ?? null,
282
+ }));
283
+ const undeclared = counts.unknown?.calls ?? 0;
284
+ return {
285
+ destinations,
286
+ recent: log?.recent({ limit: 40 }) ?? [],
287
+ undeclared,
288
+ says: undeclared
289
+ ? `${undeclared} request${undeclared === 1 ? '' : 's'} went to an address nothing here declares. A module can do that; nothing can do it unlogged.`
290
+ : 'Every request this process made went to an address declared above.',
291
+ };
292
+ }
package/src/privacy.mjs CHANGED
@@ -11,6 +11,20 @@
11
11
  // the same to what the room already holds. The terms themselves stay in
12
12
  // ~/.pulse/config.json and are never written to the ledger or to any prompt.
13
13
 
14
+ // What a secret looks like, whatever project it turns up in. The same list the crash reporter
15
+ // has used since the beginning, brought where the room can use it: a credential does not become
16
+ // safe by being in a reply rather than in a stack trace.
17
+ const SECRETS = [
18
+ [/\b(sk|rk|pk)-[A-Za-z0-9_-]{16,}\b/g, '[key]'],
19
+ [/\bAIza[0-9A-Za-z_-]{20,}\b/g, '[key]'],
20
+ [/\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}\b/g, '[token]'],
21
+ [/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, '[token]'],
22
+ [/\bnpm_[A-Za-z0-9]{20,}\b/g, '[token]'],
23
+ [/\bxox[abprs]-[A-Za-z0-9-]{10,}\b/g, '[token]'],
24
+ [/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, '[jwt]'],
25
+ [/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, '[email]'],
26
+ ];
27
+
14
28
  export const PRIVACY_MARKER = '[ENTIDAD-ORG]';
15
29
  export const MAX_TERMS = 64;
16
30
 
@@ -52,16 +66,30 @@ export function privacySettings(config = {}, env = process.env) {
52
66
  const fromEnv = env.PULSE_PRIVATE_TERMS ? String(env.PULSE_PRIVATE_TERMS).split(/[,;\n]+/) : [];
53
67
  const fromConfig = Array.isArray(config.privacy?.terms) ? config.privacy.terms : [];
54
68
  const marker = String(config.privacy?.marker ?? env.PULSE_PRIVATE_MARKER ?? PRIVACY_MARKER).trim() || PRIVACY_MARKER;
55
- return { terms: normalizeTerms([...fromEnv, ...fromConfig]), marker, envWins: fromEnv.length > 0 };
69
+ // The two shape guards default to on. Someone who wants a verbatim ledger can turn them off,
70
+ // but nobody should have to know they exist to be protected by them.
71
+ return {
72
+ terms: normalizeTerms([...fromEnv, ...fromConfig]), marker, envWins: fromEnv.length > 0,
73
+ secrets: config.privacy?.secrets !== false,
74
+ paths: config.privacy?.paths !== false,
75
+ };
56
76
  }
57
77
 
58
78
  export class Privacy {
59
79
  #terms = [];
60
80
  #pattern = null;
61
81
  #marker = PRIVACY_MARKER;
82
+ // Two guards that need no list of words, because what they catch has a shape rather than a
83
+ // name. A key is a key in any project, and the path to a home directory carries whoever lives
84
+ // in it. Both are on by default: a credential written into a plain-text ledger is a real
85
+ // exposure, and nobody chose to put it there.
86
+ #secrets = true;
87
+ #paths = true;
88
+ #home = null;
62
89
 
63
- constructor({ terms = [], marker = PRIVACY_MARKER } = {}) {
90
+ constructor({ terms = [], marker = PRIVACY_MARKER, secrets = true, paths = true, home = null } = {}) {
64
91
  this.set(terms, marker);
92
+ this.setGuards({ secrets, paths, home });
65
93
  }
66
94
 
67
95
  set(terms, marker = this.#marker) {
@@ -71,9 +99,18 @@ export class Privacy {
71
99
  return this;
72
100
  }
73
101
 
102
+ setGuards({ secrets, paths, home } = {}) {
103
+ if (secrets !== undefined) this.#secrets = Boolean(secrets);
104
+ if (paths !== undefined) this.#paths = Boolean(paths);
105
+ if (home !== undefined) this.#home = home ? String(home) : null;
106
+ return this;
107
+ }
108
+
109
+ get guards() { return { secrets: this.#secrets, paths: this.#paths }; }
110
+
74
111
  get terms() { return [...this.#terms]; }
75
112
  get marker() { return this.#marker; }
76
- get enabled() { return this.#pattern !== null; }
113
+ get enabled() { return this.#pattern !== null || this.#secrets || this.#paths; }
77
114
 
78
115
  // How many private terms a text carries.
79
116
  hits(text) {
@@ -81,18 +118,28 @@ export class Privacy {
81
118
  return (text.match(this.#pattern) ?? []).length;
82
119
  }
83
120
 
84
- // The text with every private term replaced by the marker, and the count.
121
+ // The text with every private term replaced by the marker, and the count. The shape guards run
122
+ // too: a key or a home path is caught whether or not anyone thought to name it.
85
123
  redact(text) {
86
- if (!this.#pattern || typeof text !== 'string' || !text) return { text, hits: 0 };
124
+ if (typeof text !== 'string' || !text) return { text, hits: 0 };
87
125
  let hits = 0;
88
- const out = text.replace(this.#pattern, () => { hits += 1; return this.#marker; });
126
+ let out = text;
127
+ if (this.#pattern) out = out.replace(this.#pattern, () => { hits += 1; return this.#marker; });
128
+ if (this.#secrets) {
129
+ for (const [pattern, replacement] of SECRETS) out = out.replace(pattern, () => { hits += 1; return replacement; });
130
+ }
131
+ if (this.#paths) {
132
+ if (this.#home) { const parts = out.split(this.#home); hits += parts.length - 1; out = parts.join('~'); }
133
+ out = out.replace(/\/(Users|home)\/[^/\s"']+/g, () => { hits += 1; return '/$1/…'.replace('$1', RegExp.$1 || 'Users'); });
134
+ out = out.replace(/[A-Za-z]:\\Users\\[^\\\s"']+/g, () => { hits += 1; return 'C:\\Users\\…'; });
135
+ }
89
136
  return { text: out, hits };
90
137
  }
91
138
 
92
139
  // Every string inside a value (an event payload, a plan), replaced in place of
93
140
  // a copy. Ids and numbers are untouched; only prose can carry a name.
94
141
  redactDeep(value) {
95
- if (!this.#pattern) return { value, hits: 0 };
142
+ if (!this.enabled) return { value, hits: 0 };
96
143
  let hits = 0;
97
144
  const walk = (node) => {
98
145
  if (typeof node === 'string') { const r = this.redact(node); hits += r.hits; return r.text; }
@@ -4,15 +4,15 @@
4
4
 
5
5
  import { buildConversationContext } from '../conversation-context.mjs';
6
6
 
7
- export async function contextFor({ memory, priorEvents, messageId, text, contextMaxChars, recallShare, remember = () => {}, omitSynthetic = false }) {
8
- const full = buildConversationContext(priorEvents, { excludeMessageId: messageId, maxChars: contextMaxChars, omitSynthetic });
7
+ export async function contextFor({ memory, priorEvents, messageId, text, contextMaxChars, recallShare, remember = () => {}, omitSynthetic = false, anchor = null, by = null, cascade = true, track = true }) {
8
+ const full = buildConversationContext(priorEvents, { excludeMessageId: messageId, maxChars: contextMaxChars, omitSynthetic, anchor });
9
9
  const none = { context: full, recall: null, memories: null };
10
10
  if (!memory || !full.omittedMessages || recallShare <= 0) return none;
11
11
  // Another server may have written this room: index what we have not seen.
12
12
  const last = memory.lastSequence();
13
13
  remember(priorEvents.filter((event) => event.sequence > last));
14
14
  const recallBudget = Math.floor(contextMaxChars * recallShare);
15
- const recent = buildConversationContext(priorEvents, { excludeMessageId: messageId, maxChars: contextMaxChars - recallBudget, omitSynthetic });
15
+ const recent = buildConversationContext(priorEvents, { excludeMessageId: messageId, maxChars: contextMaxChars - recallBudget, omitSynthetic, anchor });
16
16
  const before = recent.firstSequence ?? Number.MAX_SAFE_INTEGER;
17
17
  let memories = null;
18
18
  let recall = null;
@@ -20,7 +20,7 @@ export async function contextFor({ memory, priorEvents, messageId, text, context
20
20
  // One embedding of the request lets both lookups match meaning; without it they match words.
21
21
  const queryVector = await memory.embedQuery(text);
22
22
  // Distilled notes first (dense, cheap), exact quotes with what is left.
23
- memories = memory.recallMemories(text, { beforeSequence: before, maxChars: Math.floor(recallBudget * 0.4), queryVector });
23
+ memories = memory.recallMemories(text, { beforeSequence: before, maxChars: Math.floor(recallBudget * 0.4), queryVector, by, cascade, track });
24
24
  const spent = memories.reduce((sum, item) => sum + item.text.length + 24, 0);
25
25
  recall = memory.recall(text, { beforeSequence: before, excludeMessageId: messageId, maxChars: recallBudget - spent, queryVector });
26
26
  } catch (error) {