@jossuealcala/madre 0.5.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +265 -1
- package/README.md +33 -1
- package/docs/SDK.md +173 -6
- package/docs/sdk/connector-module.mjs +186 -0
- package/package.json +6 -2
- package/public/abduction.js +384 -0
- package/public/app.js +3450 -540
- package/public/assets/abduction-cat-blink.png +0 -0
- package/public/assets/abduction-cat-control-left.png +0 -0
- package/public/assets/abduction-cat-control-right.png +0 -0
- package/public/assets/abduction-cat.png +0 -0
- package/public/es.js +354 -54
- package/public/index.html +65 -19
- package/public/routing.js +74 -0
- package/public/styles.css +788 -77
- package/src/adapters/claude.mjs +6 -2
- package/src/adapters/gemini.mjs +26 -5
- package/src/adapters/madre.mjs +5 -0
- package/src/choices.mjs +64 -0
- package/src/dataset.mjs +11 -6
- package/src/distiller.mjs +12 -1
- package/src/file-ops.mjs +45 -0
- package/src/lease.mjs +37 -2
- package/src/mcp/smtp-server.mjs +189 -0
- package/src/memory-dashboard.mjs +69 -0
- package/src/memory.mjs +222 -18
- package/src/modules/ahp.mjs +1 -1
- package/src/modules/ash-policy.mjs +67 -0
- package/src/modules/ash.mjs +1 -1
- package/src/modules/choices.mjs +34 -0
- package/src/modules/correo.mjs +107 -0
- package/src/modules/index.mjs +48 -4
- package/src/modules/playwright.mjs +5 -1
- package/src/modules/ripley.mjs +13 -1
- package/src/modules/sdk.mjs +44 -9
- package/src/outbound.mjs +37 -3
- package/src/ports.mjs +91 -0
- package/src/request-guard.mjs +26 -0
- package/src/room/archivist.mjs +2 -1
- package/src/room/economy.mjs +83 -1
- package/src/room/prompt.mjs +14 -2
- package/src/room.mjs +150 -18
- package/src/sentinel-errors.mjs +5 -1
- package/src/server.mjs +188 -8
- package/src/smtp.mjs +122 -0
- package/src/vault.mjs +100 -0
- package/src/whats-new.mjs +140 -0
package/src/adapters/claude.mjs
CHANGED
|
@@ -21,6 +21,10 @@ export function mcpServersFor({ imageStudio = null, memoryServer = null, mcpServ
|
|
|
21
21
|
// Claude's permission names for a module's server: each tool, or the whole server when the list is open.
|
|
22
22
|
export const claudeMcpTools = (server) => (server.tools?.length ? server.tools.map((tool) => `mcp__${server.name}__${tool}`) : [`mcp__${server.name}`]);
|
|
23
23
|
|
|
24
|
+
// Every spelling of the lease directory. One unless the path given to MADRE goes through a
|
|
25
|
+
// symlink, in which case the resolved one travels too — see the note in createLease.
|
|
26
|
+
export const leaseRoots = (lease) => [...new Set([lease?.outDir, lease?.realOutDir].filter(Boolean))];
|
|
27
|
+
|
|
24
28
|
export function buildClaudeArgs({ prompt, model = null, attachmentsDir = null, lease = null, scopes = null, imageStudio = null, memoryServer = null, mcpServers = [] }) {
|
|
25
29
|
const tools = claudeTools({ lease, scopes });
|
|
26
30
|
const mcpTools = [
|
|
@@ -34,7 +38,7 @@ export function buildClaudeArgs({ prompt, model = null, attachmentsDir = null, l
|
|
|
34
38
|
// Claude Code grants Write only when Edit is allowed on the same paths (verified with the real
|
|
35
39
|
// CLI: a lone Write rule is denied under dontAsk), so both come together; in CREATE the room
|
|
36
40
|
// restores existing files after the turn.
|
|
37
|
-
...(lease ? [`Write(//${
|
|
41
|
+
...(lease ? leaseRoots(lease).flatMap((root) => [`Write(//${root}/**)`, `Edit(//${root}/**)`]) : []),
|
|
38
42
|
...(lease?.airlock ? ['Bash'] : []),
|
|
39
43
|
...(scopes?.web ? ['WebFetch', 'WebSearch'] : []),
|
|
40
44
|
...mcpTools,
|
|
@@ -53,7 +57,7 @@ export function buildClaudeArgs({ prompt, model = null, attachmentsDir = null, l
|
|
|
53
57
|
'--tools', tools.join(','),
|
|
54
58
|
...(lease || scopes?.web || anyMcp ? ['--allowedTools', allowed.join(',')] : []),
|
|
55
59
|
// CONTROL: the whole project is writable except MADRE's forbidden zones.
|
|
56
|
-
...(lease?.control || lease?.create ? ['--disallowedTools', ['.git/**', '.pulse/**', '.madre/**', '.env', '.env.*', '**/.env', '**/.env.*', '.claude/settings.local.json'].flatMap((glob) => [`Write(//${
|
|
60
|
+
...(lease?.control || lease?.create ? ['--disallowedTools', ['.git/**', '.pulse/**', '.madre/**', '.env', '.env.*', '**/.env', '**/.env.*', '.claude/settings.local.json'].flatMap((glob) => leaseRoots(lease).flatMap((root) => [`Write(//${root}/${glob})`, `Edit(//${root}/${glob})`])).join(',')] : []),
|
|
57
61
|
// --safe-mode disables every MCP server, ours included. With a MADRE server
|
|
58
62
|
// attached we drop it and instead load no setting sources at all: no user
|
|
59
63
|
// hooks, plugins or MCP servers, only the project's CLAUDE.md and ours.
|
package/src/adapters/gemini.mjs
CHANGED
|
@@ -28,13 +28,21 @@ export const geminiCredentialFiles = ['oauth_creds.json', 'google_accounts.json'
|
|
|
28
28
|
// whose file_path argument starts with the lease directory. Plan mode would
|
|
29
29
|
// block every write regardless of policy, so a lease uses approval "default":
|
|
30
30
|
// headless Gemini cannot prompt, so anything the policy does not allow fails.
|
|
31
|
-
export function geminiLeasePolicy(outDir, { control = false, create = false, airlock = false } = {}) {
|
|
32
|
-
const
|
|
31
|
+
export function geminiLeasePolicy(outDir, { control = false, create = false, airlock = false, alsoAt = null } = {}) {
|
|
32
|
+
const quote = (path) => path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
33
|
+
// Every spelling of the same directory. When the path MADRE was given goes through a symlink,
|
|
34
|
+
// an agent that resolves before writing asks for a path that does not start with the one these
|
|
35
|
+
// rules were built from: the allow rule misses and the write dies in silence. Both go in the
|
|
36
|
+
// allow rule AND in the deny rule — allowing a second spelling while forbidding the zones under
|
|
37
|
+
// only the first would leave .git and .pulse reachable by the other name.
|
|
38
|
+
const roots = [...new Set([outDir, alsoAt].filter(Boolean))];
|
|
39
|
+
const escaped = roots.map(quote).join('|');
|
|
40
|
+
const anyRoot = roots.length > 1 ? `(?:${escaped})` : escaped;
|
|
33
41
|
// The whole project is the lease in #2 and #3: MADRE's zones stay out of reach.
|
|
34
42
|
const forbidden = control || create ? `
|
|
35
43
|
[[rule]]
|
|
36
44
|
toolName = ["write_file", "replace", "edit", "run_shell_command"]
|
|
37
|
-
argsPattern = '${
|
|
45
|
+
argsPattern = '${anyRoot}/(\\.git|\\.pulse|\\.madre|\\.env|\\.claude/settings\\.local\\.json)'
|
|
38
46
|
decision = "deny"
|
|
39
47
|
priority = 1100
|
|
40
48
|
interactive = false
|
|
@@ -52,7 +60,7 @@ interactive = false
|
|
|
52
60
|
return `${geminiReadonlyPolicy}${commands}
|
|
53
61
|
[[rule]]
|
|
54
62
|
toolName = ${tools}
|
|
55
|
-
argsPattern = '"file_path"\\s*:\\s*"${
|
|
63
|
+
argsPattern = '"file_path"\\s*:\\s*"${anyRoot}/'
|
|
56
64
|
decision = "allow"
|
|
57
65
|
priority = 1000
|
|
58
66
|
interactive = false
|
|
@@ -68,7 +76,7 @@ interactive = false
|
|
|
68
76
|
`;
|
|
69
77
|
|
|
70
78
|
export function geminiPolicy({ lease = null, scopes = null, imageStudio = null, memoryServer = null, mcpServers = [] } = {}) {
|
|
71
|
-
return `${lease ? geminiLeasePolicy(lease.outDir, { control: Boolean(lease.control), create: Boolean(lease.create), airlock: Boolean(lease.airlock) }) : geminiReadonlyPolicy}${scopes?.web ? geminiWebPolicy : ''}${imageStudio && lease ? geminiImagePolicy(imageStudio) : ''}${memoryServer ? geminiMemoryPolicy(memoryServer) : ''}${mcpServers.map(geminiMemoryPolicy).join('')}`;
|
|
79
|
+
return `${lease ? geminiLeasePolicy(lease.outDir, { control: Boolean(lease.control), create: Boolean(lease.create), airlock: Boolean(lease.airlock), alsoAt: lease.realOutDir ?? null }) : geminiReadonlyPolicy}${scopes?.web ? geminiWebPolicy : ''}${imageStudio && lease ? geminiImagePolicy(imageStudio) : ''}${memoryServer ? geminiMemoryPolicy(memoryServer) : ''}${mcpServers.map(geminiMemoryPolicy).join('')}`;
|
|
72
80
|
}
|
|
73
81
|
|
|
74
82
|
// Allow rules for a server's tools, by bare name and by server-prefixed name; an open tool list allows the server's prefix.
|
|
@@ -251,6 +259,19 @@ export function diagnoseGeminiStderr(stderr) {
|
|
|
251
259
|
if (/status:\s*40[13]|PERMISSION_DENIED|API key not valid|IneligibleTierError/i.test(text)) {
|
|
252
260
|
return { code: 'AUTH', message: t('Google rejected the Gemini credentials.'), hint: t('Run `gemini` and use /auth, or check GEMINI_API_KEY.') };
|
|
253
261
|
}
|
|
262
|
+
// A subagent, refused. The policy is right to refuse it — a subagent answers outside the lease
|
|
263
|
+
// and outside everything the room counts — but the CLI does not treat the refusal as an answer:
|
|
264
|
+
// it waits, and the turn dies on MADRE's clock three minutes later with nothing to show.
|
|
265
|
+
//
|
|
266
|
+
// Only this tool. A blocked `write_file` leaves the CLI working and it goes on to answer, so
|
|
267
|
+
// treating every refusal as fatal would cut turns that were going to finish.
|
|
268
|
+
if (/Unauthorized tool call:\s*'?invoke_agent'?|Blocked call:[^\n]*invoke_agent/i.test(text)) {
|
|
269
|
+
return {
|
|
270
|
+
code: 'SUBAGENT_BLOCKED',
|
|
271
|
+
message: t('Gemini tried to hand this turn to one of its own subagents. MADRE does not allow that: a subagent answers outside the lease, so nothing it wrote would be checked, shown or undoable.'),
|
|
272
|
+
hint: t('Ask again saying it should do the work itself, without subagents.'),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
254
275
|
return null;
|
|
255
276
|
}
|
|
256
277
|
|
package/src/adapters/madre.mjs
CHANGED
|
@@ -105,6 +105,11 @@ export function roundTable(text, { crew = [], maxSteps = 4 } = {}) {
|
|
|
105
105
|
if (!step || !step[0]) break;
|
|
106
106
|
rest = rest.slice(step[0].length);
|
|
107
107
|
}
|
|
108
|
+
// Whatever punctuation the hinge left behind. The loop above eats the linking words, but the
|
|
109
|
+
// colon in «pregúntale al crew: …» only goes with them when another connective follows it —
|
|
110
|
+
// and what is left starts the question the room announces, sends to four agents and quotes
|
|
111
|
+
// again when it closes.
|
|
112
|
+
rest = rest.trim().replace(/^[\s,:;.\-–—]+/, '');
|
|
108
113
|
rest = rest.trim().replace(/^[«"“'`]+|[»"”'`.!]+$/g, '').trim();
|
|
109
114
|
const question = rest.length >= 6 ? rest : (es ? DEFAULT_ES : DEFAULT_EN);
|
|
110
115
|
const agents = crew.slice(0, maxSteps);
|
package/src/choices.mjs
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// An agent asking the human to choose.
|
|
2
|
+
//
|
|
3
|
+
// The room already has one fenced channel, ```pulse, and it puts other agents to work. This is
|
|
4
|
+
// its inert sibling: ```pulse-ask carries a question and the options an agent sees, and running
|
|
5
|
+
// it does nothing at all. MADRE renders the options under the answer; pressing one writes that
|
|
6
|
+
// option into the composer as the human's next message. Nothing is sent, nothing is decided and
|
|
7
|
+
// no turn is spent until the human presses.
|
|
8
|
+
//
|
|
9
|
+
// Separate from the delegation parser on purpose. That one is load-bearing — it starts real
|
|
10
|
+
// turns with real permissions — and a question is not a step. Teaching it a second grammar would
|
|
11
|
+
// put a parser that can spend money in the path of a parser that cannot.
|
|
12
|
+
//
|
|
13
|
+
// ```pulse-ask
|
|
14
|
+
// Where should the exported dataset live?
|
|
15
|
+
// - Beside the room log, with the rest of the archive
|
|
16
|
+
// - In the project, so it travels with the repository
|
|
17
|
+
// - Somewhere the human names each time
|
|
18
|
+
// ```
|
|
19
|
+
//
|
|
20
|
+
// Two options are a yes/no, which a sentence already handles; the block earns its place from
|
|
21
|
+
// three. Above six it is a list, not a choice, and the room says so by refusing it.
|
|
22
|
+
|
|
23
|
+
export const MIN_OPTIONS = 3;
|
|
24
|
+
export const MAX_OPTIONS = 6;
|
|
25
|
+
const MAX_QUESTION = 200;
|
|
26
|
+
const MAX_OPTION = 120;
|
|
27
|
+
|
|
28
|
+
// The last ```pulse-ask block of a reply, if it closes the reply. Returns null for anything
|
|
29
|
+
// else: a block quoted mid-answer is an example, and an example is not a question.
|
|
30
|
+
export function parseChoice(text) {
|
|
31
|
+
const source = String(text ?? '');
|
|
32
|
+
const blocks = [...source.matchAll(/```pulse-ask\s*\n([\s\S]*?)```/gi)];
|
|
33
|
+
if (!blocks.length) return null;
|
|
34
|
+
const last = blocks.at(-1);
|
|
35
|
+
if (source.slice(last.index + last[0].length).trim()) return null;
|
|
36
|
+
|
|
37
|
+
const lines = last[1].split('\n').map((line) => line.trim()).filter(Boolean);
|
|
38
|
+
if (!lines.length) return null;
|
|
39
|
+
const question = lines[0].startsWith('-') ? null : lines[0].slice(0, MAX_QUESTION);
|
|
40
|
+
const options = [];
|
|
41
|
+
const seen = new Set();
|
|
42
|
+
for (const line of lines.slice(question ? 1 : 0)) {
|
|
43
|
+
const match = line.match(/^[-*•]\s+(.+)$/);
|
|
44
|
+
if (!match) continue;
|
|
45
|
+
const label = match[1].trim().slice(0, MAX_OPTION);
|
|
46
|
+
// The same option twice is a mistake, and a choice that offers it twice reads as rigged.
|
|
47
|
+
const key = label.toLowerCase();
|
|
48
|
+
if (!label || seen.has(key)) continue;
|
|
49
|
+
seen.add(key);
|
|
50
|
+
options.push(label);
|
|
51
|
+
}
|
|
52
|
+
if (!question) return null;
|
|
53
|
+
if (options.length < MIN_OPTIONS || options.length > MAX_OPTIONS) return null;
|
|
54
|
+
return { question, options };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// What the reply reads as once the block has been lifted out of it: the room shows the question
|
|
58
|
+
// as bubbles, so leaving the raw fence in the text would say everything twice.
|
|
59
|
+
export function withoutChoice(text) {
|
|
60
|
+
const source = String(text ?? '');
|
|
61
|
+
if (!parseChoice(source)) return source;
|
|
62
|
+
const last = [...source.matchAll(/```pulse-ask\s*\n[\s\S]*?```/gi)].at(-1);
|
|
63
|
+
return source.slice(0, last.index).trimEnd();
|
|
64
|
+
}
|
package/src/dataset.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
9
9
|
import { join } from 'node:path';
|
|
10
10
|
import { redact } from './sentinel-errors.mjs';
|
|
11
|
-
import { ABERRATION } from './memory.mjs';
|
|
11
|
+
import { ABERRATION, VERDICTS, NEGATIVE_VERDICTS, POSITIVE_VERDICTS } from './memory.mjs';
|
|
12
12
|
|
|
13
13
|
const MIN_ANSWER_CHARS = 40;
|
|
14
14
|
|
|
@@ -19,16 +19,21 @@ function system(project, agent) {
|
|
|
19
19
|
// Pairs a question with the reply that answered it (parentMessageId). The question is the
|
|
20
20
|
// human's message, or an orchestrator's delegated step: both are real instructions with a
|
|
21
21
|
// real answer. What never trains MADRE: @madre's own answers (the student is not the teacher),
|
|
22
|
-
// MADRE's canned replies, replies the human
|
|
22
|
+
// MADRE's canned replies, replies the human judged bad or never, ghosts, one-liners.
|
|
23
23
|
const guard = (privacy) => (text) => (privacy ? privacy.redact(text).text : text);
|
|
24
24
|
export const STUDENT = 'madre';
|
|
25
25
|
export const DATASET_TARGET = 300;
|
|
26
26
|
|
|
27
|
+
// The ledger folded to the last word said about each reply. Read against the vocabulary and not
|
|
28
|
+
// against a pair of literals: for a while this knew two verdicts and treated everything else as a
|
|
29
|
+
// clearing, so a verdict the room had just learned to say would be deleted here on the way out,
|
|
30
|
+
// in silence, with the export looking perfectly healthy.
|
|
27
31
|
export function ratingsFrom(events) {
|
|
28
32
|
const ratings = new Map();
|
|
29
33
|
for (const event of events) {
|
|
30
34
|
if (event.type !== 'message.rated' || !event.payload?.messageId) continue;
|
|
31
|
-
|
|
35
|
+
const verdict = event.payload.rating;
|
|
36
|
+
if (VERDICTS.includes(verdict) && verdict !== 'none') ratings.set(event.payload.messageId, verdict);
|
|
32
37
|
else ratings.delete(event.payload.messageId);
|
|
33
38
|
}
|
|
34
39
|
return ratings;
|
|
@@ -51,7 +56,7 @@ export function pairsFromEvents(events, { project = 'project', home, user, priva
|
|
|
51
56
|
continue;
|
|
52
57
|
}
|
|
53
58
|
if (p.sender === STUDENT || p.synthetic) continue;
|
|
54
|
-
if (ratings.get(p.messageId)
|
|
59
|
+
if (NEGATIVE_VERDICTS.includes(ratings.get(p.messageId))) continue;
|
|
55
60
|
const ask = asks.get(p.parentMessageId);
|
|
56
61
|
if (!ask) continue;
|
|
57
62
|
const answer = (p.originalText ?? p.text).trim();
|
|
@@ -82,7 +87,7 @@ export function readiness(events, notes = [], { target = DATASET_TARGET } = {})
|
|
|
82
87
|
for (const rating of ratings.values()) if (rating === 'bad') bad += 1;
|
|
83
88
|
const turns = pairs.filter((pair) => pair.kind === 'turn').length;
|
|
84
89
|
const delegated = pairs.length - turns;
|
|
85
|
-
const good = pairs.filter((pair) => pair.rating
|
|
90
|
+
const good = pairs.filter((pair) => POSITIVE_VERDICTS.includes(pair.rating)).length;
|
|
86
91
|
// Aberrations are counted apart: they never become one of the pairs the model learns to
|
|
87
92
|
// answer with, so counting them toward readiness would say the room is further along than it is.
|
|
88
93
|
const standing = notes.filter((note) => note.kind !== ABERRATION && !note.refutedBy);
|
|
@@ -173,7 +178,7 @@ export async function exportDataset({ events, notes = [], dir, project = 'projec
|
|
|
173
178
|
await writeFile(join(dir, 'preferences.jsonl'), preferences.map((pair) => JSON.stringify({ prompt: pair.prompt, chosen: pair.chosen, rejected: pair.rejected })).join('\n') + (preferences.length ? '\n' : ''));
|
|
174
179
|
const byAgent = {};
|
|
175
180
|
for (const pair of pairs) byAgent[pair.agent] = (byAgent[pair.agent] ?? 0) + 1;
|
|
176
|
-
const manifest = { project, exportedAt: new Date().toISOString(), pairs: pairs.length, turns: pairs.filter((p) => p.kind === 'turn').length, delegated: pairs.filter((p) => p.kind === 'delegated').length, good: pairs.filter((p) => p.rating
|
|
181
|
+
const manifest = { project, exportedAt: new Date().toISOString(), pairs: pairs.length, turns: pairs.filter((p) => p.kind === 'turn').length, delegated: pairs.filter((p) => p.kind === 'delegated').length, good: pairs.filter((p) => POSITIVE_VERDICTS.includes(p.rating)).length, notes: pairs.filter((p) => p.kind === 'note').length, aberrations: preferences.length, train: train.length, valid: valid.length, byAgent, files: ['train.jsonl', 'valid.jsonl', 'preferences.jsonl'], format: 'chat · {"messages":[{role,content}]} · mlx-lm / llama-factory / axolotl', preferenceFormat: 'preference · {"prompt","chosen","rejected"} · DPO / ORPO' };
|
|
177
182
|
await writeFile(join(dir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
178
183
|
return { dir, ...manifest };
|
|
179
184
|
}
|
package/src/distiller.mjs
CHANGED
|
@@ -11,6 +11,9 @@ export const DISTILLER_ORDER = ['ollama', 'gemini', 'opencode', 'codex', 'claude
|
|
|
11
11
|
export const OLLAMA_ARCHIVIST = { id: 'ollama', label: 'Ollama', detected: true, ready: true, adapter: 'ollama', path: null, version: null, local: true };
|
|
12
12
|
export const MAX_MEMORIES_PER_RUN = 5;
|
|
13
13
|
export const MAX_MEMORY_CHARS = 240;
|
|
14
|
+
// A discard is matched against, not learned from, so it travels clipped: the room pays about
|
|
15
|
+
// half for the same effect.
|
|
16
|
+
export const DISCARDED_CHARS = 110;
|
|
14
17
|
|
|
15
18
|
// The agent that pays for distillation: the human's pick if it is usable, else
|
|
16
19
|
// the cheapest ready one that has an adapter and no turn in flight.
|
|
@@ -28,13 +31,20 @@ export function pickDistiller(agents, { preferred = null, busy = new Set(), invo
|
|
|
28
31
|
return agents.find(usable) ?? null;
|
|
29
32
|
}
|
|
30
33
|
|
|
31
|
-
|
|
34
|
+
const shorten = (value, max) => (value.length > max ? `${value.slice(0, max - 1)}…` : value);
|
|
35
|
+
|
|
36
|
+
export function distillPrompt({ entries, projectName = 'the project', existing = [], discarded = [], json = false }) {
|
|
32
37
|
const transcript = entries.map((entry) => {
|
|
33
38
|
const who = entry.role === 'command' ? entry.sender : `@${entry.sender}`;
|
|
34
39
|
const to = entry.target && entry.target !== 'room' ? ` → ${entry.target === 'you' ? 'human' : `@${entry.target}`}` : '';
|
|
35
40
|
return `[#${entry.sequence} · ${who} (${entry.role})${to}] ${entry.text}`;
|
|
36
41
|
}).join('\n\n');
|
|
42
|
+
// Two lists, because they say opposite things. What the room holds is not to be written twice;
|
|
43
|
+
// what the room threw out is not to be written at all — and before this the archivist was handed
|
|
44
|
+
// the newest notes whatever their standing, false ones included, under one heading that only
|
|
45
|
+
// said «do not repeat». A list with nothing in it is left out rather than printed empty.
|
|
37
46
|
const known = existing.length ? `\nAlready remembered (do not repeat these):\n${existing.map((memory) => `- ${memory.text}`).join('\n')}\n` : '';
|
|
47
|
+
const thrownOut = discarded.length ? `\nAlready discarded — the room took these out of circulation. Do not write them again, in these words or others:\n${discarded.map((memory) => `- ${shorten(memory.text, DISCARDED_CHARS)}`).join('\n')}\n` : '';
|
|
38
48
|
return [
|
|
39
49
|
`You are the archivist of a MADRE project room for "${projectName}", shared by a human and several AI agents. Do not read or modify any file: everything you need is below.`,
|
|
40
50
|
'Read these room exchanges (each stamped with its ledger sequence) and write only the memories worth keeping for future turns of any agent:',
|
|
@@ -50,6 +60,7 @@ export function distillPrompt({ entries, projectName = 'the project', existing =
|
|
|
50
60
|
json ? null : `{"kind":"${MEMORY_KINDS.join('|')}","text":"one self-contained sentence in the language the room uses, at most ${MAX_MEMORY_CHARS} characters, naming files, agents and numbers exactly","correction":"only on an aberration: what is true instead","sources":[sequence numbers it comes from]}`,
|
|
51
61
|
json ? null : 'If nothing durable was said, output exactly: NONE',
|
|
52
62
|
known,
|
|
63
|
+
thrownOut,
|
|
53
64
|
'<exchanges>',
|
|
54
65
|
transcript,
|
|
55
66
|
'</exchanges>',
|
package/src/file-ops.mjs
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Copying and moving a file, from the human's own hands.
|
|
2
|
+
//
|
|
3
|
+
// Everything else in MADRE that writes into a project is an agent under a permission mode. This
|
|
4
|
+
// is not that: it is the human, in their own project, doing what a file manager does. So there
|
|
5
|
+
// is no mode and no lease — but there are still two fences, because a slip of the hand is not
|
|
6
|
+
// an argument for losing a .env.
|
|
7
|
+
//
|
|
8
|
+
// · the project is the world. Both ends resolve inside it or the operation does not happen,
|
|
9
|
+
// and that is checked after following symlinks, not before.
|
|
10
|
+
// · the zones MADRE protects from agents are protected from accidents too. Touching .git,
|
|
11
|
+
// .pulse, .madre, a .env or the local settings asks for the project's designation first —
|
|
12
|
+
// the same word the room asks for before CONTROL. Typing a folder name is a small ceremony;
|
|
13
|
+
// restoring a deleted key is not.
|
|
14
|
+
|
|
15
|
+
import { isForbidden } from './checkpoint.mjs';
|
|
16
|
+
|
|
17
|
+
export const OPERATIONS = ['copy', 'move', 'new-file', 'new-folder'];
|
|
18
|
+
// The two that make something out of nothing: they have a destination and no source.
|
|
19
|
+
export const CREATIONS = ['new-file', 'new-folder'];
|
|
20
|
+
|
|
21
|
+
// Whether a path is one the room guards. Both ends count: moving something harmless ONTO a
|
|
22
|
+
// protected path is how a protected path gets overwritten.
|
|
23
|
+
export function needsDesignation(...paths) {
|
|
24
|
+
return paths.filter(Boolean).some((path) => isForbidden(String(path).replace(/^\.\//, '')));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// What the room will say about an operation before doing any of it.
|
|
28
|
+
export function planFileOp({ operation, from, to }) {
|
|
29
|
+
if (!OPERATIONS.includes(operation)) return { ok: false, error: `Unknown operation: ${operation}.` };
|
|
30
|
+
const source = String(from ?? '').replace(/^\/+/, '').trim();
|
|
31
|
+
const target = String(to ?? '').replace(/^\/+/, '').trim();
|
|
32
|
+
if (CREATIONS.includes(operation)) {
|
|
33
|
+
if (!target) return { ok: false, error: 'A name is needed.' };
|
|
34
|
+
// A name, not a path: making something is done where the human is looking, and a slash in
|
|
35
|
+
// the box would quietly write somewhere else.
|
|
36
|
+
const name = target.slice(target.lastIndexOf('/') + 1);
|
|
37
|
+
if (!name || name === '.' || name === '..') return { ok: false, error: 'That is not a name.' };
|
|
38
|
+
return { ok: true, operation, from: null, to: target, guarded: needsDesignation(target) };
|
|
39
|
+
}
|
|
40
|
+
if (!source || !target) return { ok: false, error: 'Both a source and a destination are needed.' };
|
|
41
|
+
if (source === target) return { ok: false, error: 'The source and the destination are the same path.' };
|
|
42
|
+
// Moving a folder into itself leaves nothing behind and no way back.
|
|
43
|
+
if (operation === 'move' && `${target}/`.startsWith(`${source}/`)) return { ok: false, error: 'A folder cannot be moved inside itself.' };
|
|
44
|
+
return { ok: true, operation, from: source, to: target, guarded: needsDesignation(source, target) };
|
|
45
|
+
}
|
package/src/lease.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir, readdir, stat } from 'node:fs/promises';
|
|
1
|
+
import { mkdir, readdir, realpath, stat } from 'node:fs/promises';
|
|
2
2
|
import { join, relative, sep } from 'node:path';
|
|
3
3
|
import { contentTypeFor } from './files.mjs';
|
|
4
4
|
|
|
@@ -24,7 +24,41 @@ export async function createLease({ projectRoot, leaseId }) {
|
|
|
24
24
|
const relativeDir = join(LEASE_ROOT, name);
|
|
25
25
|
const outDir = join(projectRoot, relativeDir);
|
|
26
26
|
await mkdir(outDir, { recursive: true, mode: 0o755 });
|
|
27
|
-
|
|
27
|
+
// The same directory said the other way, when the path given to MADRE goes through a symlink.
|
|
28
|
+
// On macOS `/tmp` is a link to `/private/tmp`, so an agent that resolves a path before writing
|
|
29
|
+
// asks to write somewhere that does not begin with the string the permission rule was built
|
|
30
|
+
// from. The rule misses, a headless CLI cannot stop to ask, the write fails without a word, and
|
|
31
|
+
// the model reports the file as created. Both spellings travel so both can be allowed — and,
|
|
32
|
+
// just as importantly, so the forbidden zones can be denied under either one.
|
|
33
|
+
const realOutDir = await realpath(outDir).catch(() => outDir);
|
|
34
|
+
return { leaseId, outDir, relativeDir, ...(realOutDir === outDir ? {} : { realOutDir }) };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The files a reply names, as paths and not as intentions.
|
|
38
|
+
//
|
|
39
|
+
// A CLI whose write was refused in silence answers as if it had written: the room shows no
|
|
40
|
+
// artifacts, and the text says «Archivos creados: …». Rather than read the prose for a claim —
|
|
41
|
+
// which means guessing in two languages and warning wrongly when an agent merely SUGGESTS a
|
|
42
|
+
// path — this collects the paths it names under the project and lets the disk settle it. What
|
|
43
|
+
// is on disk is never mentioned; only what is named and absent.
|
|
44
|
+
export function namedPaths(text, { roots = [] } = {}) {
|
|
45
|
+
const said = String(text ?? '');
|
|
46
|
+
const bases = roots.filter(Boolean);
|
|
47
|
+
const found = new Set();
|
|
48
|
+
// A path, absolute or project-relative, ending in a short extension. Trailing punctuation and
|
|
49
|
+
// the closing of a quote, a bracket or a sentence are not part of a filename.
|
|
50
|
+
for (const match of said.matchAll(/(?:^|[\s(`"'«\[>])((?:\/|\.{0,2}\/)?[\w.@+-]+(?:\/[\w.@+-]+)+\.[A-Za-z][\w]{0,8})/g)) {
|
|
51
|
+
let path = match[1].replace(/[.,;:)\]»"'`]+$/, '');
|
|
52
|
+
if (!path.includes('/')) continue;
|
|
53
|
+
if (path.startsWith('/')) {
|
|
54
|
+
const under = bases.find((base) => path === base || path.startsWith(`${base}/`));
|
|
55
|
+
if (!under) continue; // somewhere else entirely: not ours to check
|
|
56
|
+
found.add(path);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
found.add(path.replace(/^\.\//, ''));
|
|
60
|
+
}
|
|
61
|
+
return [...found];
|
|
28
62
|
}
|
|
29
63
|
|
|
30
64
|
// name → { size, mtimeMs } for every file under `dir`, recursively.
|
|
@@ -85,6 +119,7 @@ export function leaseInstructions({ outDir, agentId, scopes = { write: true, ima
|
|
|
85
119
|
: canImage ? `You can generate images; save them${create ? ' where images live in this project, or in the scratch folder,' : ' into the lease directory'} with a descriptive file name.`
|
|
86
120
|
: couldImage ? 'Image generation is switched off for this request; if asked for an image, say so and do not attempt it.'
|
|
87
121
|
: 'You cannot generate images from this CLI; if asked for one, say so plainly instead of attempting it.',
|
|
122
|
+
'Do this work yourself. Do not hand it to a subagent or spawn another agent: a subagent writes outside this lease, so nothing it did would be checked, shown to the human or undoable — and the tools for it are blocked, which can leave you waiting instead of answering.',
|
|
88
123
|
'List the files you created (or "none") before any plan block; nothing may follow a plan block.',
|
|
89
124
|
].filter(Boolean).join('\n');
|
|
90
125
|
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// MADRE CORREO: a tiny MCP server (stdio, JSON-RPC 2.0) exposing one tool, send_email, that
|
|
3
|
+
// hands one message to the user's own mail provider over SMTP+TLS with the user's own app
|
|
4
|
+
// password. There is no MADRE in the middle: this process talks to smtp.gmail.com (or whatever
|
|
5
|
+
// host the module was pointed at) and to nothing else.
|
|
6
|
+
//
|
|
7
|
+
// This is the first MADRE tool whose effect does not come back. A file can be undone; a sent
|
|
8
|
+
// message cannot. So three things hold, and each is enforced somewhere a module cannot reach:
|
|
9
|
+
//
|
|
10
|
+
// · The room only hands this server's tool to a turn at #4 AIRLOCK (toolsForTurn, `sends`),
|
|
11
|
+
// where the agent must already say in one line what leaves and where before it leaves.
|
|
12
|
+
// · The password arrives in this process's environment from the vault, and is never written
|
|
13
|
+
// anywhere: not in the tool result, not in the outbound log, not in the ledger.
|
|
14
|
+
// · If the module set a list of allowed recipients, a message to anybody else is refused here
|
|
15
|
+
// rather than in the prompt, because a prompt is a suggestion.
|
|
16
|
+
//
|
|
17
|
+
// Environment:
|
|
18
|
+
// PULSE_SMTP_FROM required, the address messages are sent from
|
|
19
|
+
// PULSE_SMTP_PASSWORD required, the app password (from the vault)
|
|
20
|
+
// PULSE_SMTP_USER the account to authenticate as; defaults to PULSE_SMTP_FROM
|
|
21
|
+
// PULSE_SMTP_FROM_NAME optional display name
|
|
22
|
+
// PULSE_SMTP_HOST default smtp.gmail.com
|
|
23
|
+
// PULSE_SMTP_PORT default 465 (implicit TLS)
|
|
24
|
+
// PULSE_SMTP_ALLOW optional comma list of addresses or @domains; empty means anyone
|
|
25
|
+
// PULSE_OUTBOUND_LOG where to append the one line this send leaves behind
|
|
26
|
+
// PULSE_SMTP_FAKE=1 write the message to PULSE_SMTP_FAKE_FILE instead of sending (tests)
|
|
27
|
+
|
|
28
|
+
import { appendFile, realpath } from 'node:fs/promises';
|
|
29
|
+
import { OutboundLog, declareDestinations } from '../outbound.mjs';
|
|
30
|
+
import { SMTP_PORT, buildMessage, sendMail, validAddress } from '../smtp.mjs';
|
|
31
|
+
|
|
32
|
+
const SERVER_NAME = 'pulse-correo';
|
|
33
|
+
const SERVER_VERSION = '0.1.0';
|
|
34
|
+
const DEFAULT_HOST = 'smtp.gmail.com';
|
|
35
|
+
|
|
36
|
+
// The four answers `src/outbound.mjs` demands of any destination. They live here rather than in
|
|
37
|
+
// the module because this server is a different process — `declareDestinations` in the room says
|
|
38
|
+
// nothing to `declareDestinations` here — and both sides must give the same answers.
|
|
39
|
+
//
|
|
40
|
+
// The id stays `smtp` whatever the host is, so re-declaring a changed host REPLACES the entry
|
|
41
|
+
// rather than leaving the room vouching for a server it no longer talks to.
|
|
42
|
+
export const reachesFor = (host = DEFAULT_HOST) => [{
|
|
43
|
+
id: 'smtp',
|
|
44
|
+
host,
|
|
45
|
+
to: host === DEFAULT_HOST ? 'Google · Gmail, with your own account' : `${host}, with your own account`,
|
|
46
|
+
what: 'The message you asked to send: its recipients, subject and body, and your app password to sign in.',
|
|
47
|
+
when: 'Only when an agent in #4 AIRLOCK calls send_email, and only for that one message.',
|
|
48
|
+
where: 'Switch off the CORREO module, or forget its app password, and nothing can leave.',
|
|
49
|
+
}];
|
|
50
|
+
export const REACHES = reachesFor();
|
|
51
|
+
|
|
52
|
+
// Who may receive. An empty list is "anyone" — deliberate, because narrowing it is a setting the
|
|
53
|
+
// human turns on, not a default that would quietly swallow the first thing they try to send.
|
|
54
|
+
export function parseAllow(raw) {
|
|
55
|
+
return String(raw ?? '').split(',').map((one) => one.trim().toLowerCase()).filter(Boolean);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function allowed(address, allow = []) {
|
|
59
|
+
if (!allow.length) return true;
|
|
60
|
+
const one = String(address ?? '').toLowerCase();
|
|
61
|
+
const domain = one.slice(one.indexOf('@'));
|
|
62
|
+
return allow.some((rule) => (rule.startsWith('@') ? rule === domain : rule === one));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// A TLS socket is not `fetch`, so the wrapper that watches every other outbound call cannot see
|
|
66
|
+
// this one. The server writes its own line instead: the host, whether it went, how long it took
|
|
67
|
+
// and how many bytes. Never a recipient, never a subject, never the password — the log says that
|
|
68
|
+
// something left and where to, the same as it does for every other destination.
|
|
69
|
+
function noteSend({ env, host, ok, ms, bytes, error = null }) {
|
|
70
|
+
if (!env.PULSE_OUTBOUND_LOG) return;
|
|
71
|
+
declareDestinations('correo', reachesFor(host));
|
|
72
|
+
try {
|
|
73
|
+
new OutboundLog({ file: env.PULSE_OUTBOUND_LOG })
|
|
74
|
+
.record({ url: `smtps://${host}:${env.PULSE_SMTP_PORT || SMTP_PORT}/`, method: 'SMTP', ok, ms, bytes, ...(error ? { error } : {}) });
|
|
75
|
+
} catch { /* the log must never be the reason a message fails */ }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function send({ to, subject, body, env = process.env, send: sendImpl = sendMail }) {
|
|
79
|
+
const from = env.PULSE_SMTP_FROM ?? '';
|
|
80
|
+
if (!validAddress(from)) throw Object.assign(new Error('CORREO has no sending address configured.'), { code: 'NO_FROM' });
|
|
81
|
+
const recipients = (Array.isArray(to) ? to : String(to ?? '').split(',')).map((one) => String(one).trim()).filter(Boolean);
|
|
82
|
+
if (!recipients.length) throw Object.assign(new Error('Say who it goes to.'), { code: 'NO_RECIPIENT' });
|
|
83
|
+
const allow = parseAllow(env.PULSE_SMTP_ALLOW);
|
|
84
|
+
const refused = recipients.filter((one) => !allowed(one, allow));
|
|
85
|
+
if (refused.length) throw Object.assign(new Error(`CORREO is limited to ${allow.join(', ')}; it will not write to ${refused.join(', ')}.`), { code: 'NOT_ALLOWED' });
|
|
86
|
+
if (!String(subject ?? '').trim()) throw Object.assign(new Error('A message with no subject reads as spam. Give it one.'), { code: 'NO_SUBJECT' });
|
|
87
|
+
if (!String(body ?? '').trim()) throw Object.assign(new Error('There is nothing in the message.'), { code: 'NO_BODY' });
|
|
88
|
+
|
|
89
|
+
const host = env.PULSE_SMTP_HOST || DEFAULT_HOST;
|
|
90
|
+
const started = Date.now();
|
|
91
|
+
|
|
92
|
+
if (env.PULSE_SMTP_FAKE === '1') {
|
|
93
|
+
const message = buildMessage({ from, fromName: env.PULSE_SMTP_FROM_NAME ?? '', to: recipients, subject, body });
|
|
94
|
+
if (env.PULSE_SMTP_FAKE_FILE) await appendFile(env.PULSE_SMTP_FAKE_FILE, `${JSON.stringify({ host, from, to: recipients, message })}\n`);
|
|
95
|
+
noteSend({ env, host, ok: true, ms: Date.now() - started, bytes: Buffer.byteLength(message, 'utf8') });
|
|
96
|
+
return { to: recipients, bytes: Buffer.byteLength(message, 'utf8'), host, reply: 'fake' };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const result = await sendImpl({
|
|
101
|
+
host,
|
|
102
|
+
port: Number(env.PULSE_SMTP_PORT) || SMTP_PORT,
|
|
103
|
+
user: env.PULSE_SMTP_USER || from,
|
|
104
|
+
password: env.PULSE_SMTP_PASSWORD ?? '',
|
|
105
|
+
from,
|
|
106
|
+
fromName: env.PULSE_SMTP_FROM_NAME ?? '',
|
|
107
|
+
to: recipients,
|
|
108
|
+
subject,
|
|
109
|
+
body,
|
|
110
|
+
});
|
|
111
|
+
noteSend({ env, host, ok: true, ms: Date.now() - started, bytes: result.bytes });
|
|
112
|
+
return { ...result, host };
|
|
113
|
+
} catch (error) {
|
|
114
|
+
noteSend({ env, host, ok: false, ms: Date.now() - started, bytes: null, error: error.code ?? 'ERROR' });
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const TOOL = {
|
|
120
|
+
name: 'send_email',
|
|
121
|
+
description: 'Send one plain-text email from the human\'s own mail account. This cannot be undone and the message carries their name: before calling it, state in one line exactly who it goes to and what it says. Attachments, HTML and CC/BCC are not supported.',
|
|
122
|
+
inputSchema: {
|
|
123
|
+
type: 'object',
|
|
124
|
+
properties: {
|
|
125
|
+
to: { type: 'string', description: 'Recipient address. Several are allowed, separated by commas.' },
|
|
126
|
+
subject: { type: 'string', description: 'The subject line.' },
|
|
127
|
+
body: { type: 'string', description: 'The message, as plain text. Line breaks are kept.' },
|
|
128
|
+
},
|
|
129
|
+
required: ['to', 'subject', 'body'],
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export function handleRequest(message, { env = process.env, sendImpl = send } = {}) {
|
|
134
|
+
const reply = (result) => ({ jsonrpc: '2.0', id: message.id, result });
|
|
135
|
+
const fail = (code, text) => ({ jsonrpc: '2.0', id: message.id, error: { code, message: text } });
|
|
136
|
+
switch (message.method) {
|
|
137
|
+
case 'initialize':
|
|
138
|
+
return Promise.resolve(reply({ protocolVersion: message.params?.protocolVersion ?? '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: SERVER_NAME, version: SERVER_VERSION } }));
|
|
139
|
+
case 'notifications/initialized':
|
|
140
|
+
case 'initialized':
|
|
141
|
+
return Promise.resolve(null);
|
|
142
|
+
case 'ping':
|
|
143
|
+
return Promise.resolve(reply({}));
|
|
144
|
+
case 'tools/list':
|
|
145
|
+
return Promise.resolve(reply({ tools: [TOOL] }));
|
|
146
|
+
case 'tools/call': {
|
|
147
|
+
const { name, arguments: args = {} } = message.params ?? {};
|
|
148
|
+
if (name !== TOOL.name) return Promise.resolve(fail(-32602, `Unknown tool: ${name}`));
|
|
149
|
+
return sendImpl({ to: args.to, subject: args.subject, body: args.body, env })
|
|
150
|
+
.then((result) => reply({ content: [{ type: 'text', text: `Sent to ${result.to.join(', ')} via ${result.host} (${result.bytes} bytes).` }], isError: false }))
|
|
151
|
+
.catch((error) => reply({ content: [{ type: 'text', text: `Nothing was sent (${error.code ?? 'ERROR'}): ${error.message}` }], isError: true }));
|
|
152
|
+
}
|
|
153
|
+
default:
|
|
154
|
+
return Promise.resolve(message.id === undefined ? null : fail(-32601, `Method not found: ${message.method}`));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function serve({ input = process.stdin, output = process.stdout, env = process.env } = {}) {
|
|
159
|
+
let buffer = '';
|
|
160
|
+
let pending = 0;
|
|
161
|
+
let ended = false;
|
|
162
|
+
const finish = () => { if (ended && pending === 0) process.exit(0); };
|
|
163
|
+
input.setEncoding('utf8');
|
|
164
|
+
input.on('data', (chunk) => {
|
|
165
|
+
buffer += chunk;
|
|
166
|
+
let index;
|
|
167
|
+
while ((index = buffer.indexOf('\n')) >= 0) {
|
|
168
|
+
const line = buffer.slice(0, index).trim();
|
|
169
|
+
buffer = buffer.slice(index + 1);
|
|
170
|
+
if (!line) continue;
|
|
171
|
+
let message;
|
|
172
|
+
try { message = JSON.parse(line); } catch { output.write(`${JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } })}\n`); continue; }
|
|
173
|
+
pending += 1;
|
|
174
|
+
void handleRequest(message, { env })
|
|
175
|
+
.then((response) => { if (response) output.write(`${JSON.stringify(response)}\n`); })
|
|
176
|
+
.finally(() => { pending -= 1; finish(); });
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
input.on('end', () => { ended = true; finish(); });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const invokedDirectly = process.argv[1] && (await realpath(process.argv[1]).catch(() => process.argv[1])) === (await realpath(new URL(import.meta.url).pathname).catch(() => null));
|
|
183
|
+
if (invokedDirectly) {
|
|
184
|
+
if (!process.env.PULSE_SMTP_FROM || !process.env.PULSE_SMTP_PASSWORD) {
|
|
185
|
+
process.stderr.write('PULSE_SMTP_FROM and PULSE_SMTP_PASSWORD are required.\n');
|
|
186
|
+
process.exit(2);
|
|
187
|
+
}
|
|
188
|
+
serve();
|
|
189
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// What the MEMORY dashboard draws, shaped from counts the archive already keeps.
|
|
2
|
+
//
|
|
3
|
+
// Pure functions. The room hands in `RoomMemory#activity()` and the stats it already reports;
|
|
4
|
+
// nothing here reads a file, and nothing here ever sees the text of a memory. A day with nothing
|
|
5
|
+
// in it is still a day: the series are filled to the window, so a quiet week reads as quiet
|
|
6
|
+
// instead of collapsing out of the chart.
|
|
7
|
+
|
|
8
|
+
import { PASS } from './exam.mjs';
|
|
9
|
+
|
|
10
|
+
export const DASHBOARD_DAYS = 30;
|
|
11
|
+
|
|
12
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
13
|
+
// The calendar day of this machine, the same day SQLite's `localtime` names.
|
|
14
|
+
export const localDay = (date) => `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
15
|
+
|
|
16
|
+
export function windowDays(days = DASHBOARD_DAYS, now = new Date()) {
|
|
17
|
+
const out = [];
|
|
18
|
+
for (let back = days - 1; back >= 0; back -= 1) {
|
|
19
|
+
const day = new Date(now.getFullYear(), now.getMonth(), now.getDate() - back);
|
|
20
|
+
out.push(localDay(day));
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const fill = (pairs, days) => {
|
|
26
|
+
const seen = new Map(pairs ?? []);
|
|
27
|
+
return days.map((day) => Number(seen.get(day) ?? 0));
|
|
28
|
+
};
|
|
29
|
+
const sum = (list) => list.reduce((total, n) => total + n, 0);
|
|
30
|
+
|
|
31
|
+
// This week against the one before it, from the same filled series. `null` when there is no week
|
|
32
|
+
// before to compare with, so a new room never shows a rise from nothing as +100%.
|
|
33
|
+
function week(series) {
|
|
34
|
+
const last = sum(series.slice(-7));
|
|
35
|
+
const before = sum(series.slice(-14, -7));
|
|
36
|
+
return { last, before, delta: last - before };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function memoryDashboard({ activity = null, stats = null, days = DASHBOARD_DAYS, now = new Date() } = {}) {
|
|
40
|
+
if (!activity) return null;
|
|
41
|
+
const axis = windowDays(days, now);
|
|
42
|
+
const series = {
|
|
43
|
+
entries: fill(activity.entries, axis),
|
|
44
|
+
memories: fill(activity.memories, axis),
|
|
45
|
+
recalls: fill(activity.recalls, axis),
|
|
46
|
+
};
|
|
47
|
+
const search = Number(activity.via?.search ?? 0);
|
|
48
|
+
const cascade = Number(activity.via?.cascade ?? 0);
|
|
49
|
+
const total = Number(stats?.memories ?? sum(Object.values(activity.kinds ?? {})));
|
|
50
|
+
return {
|
|
51
|
+
days: axis,
|
|
52
|
+
series,
|
|
53
|
+
weeks: { entries: week(series.entries), memories: week(series.memories), recalls: week(series.recalls) },
|
|
54
|
+
totals: {
|
|
55
|
+
entries: Number(stats?.entries ?? 0),
|
|
56
|
+
memories: total,
|
|
57
|
+
pending: Number(stats?.pending ?? 0),
|
|
58
|
+
turns: Number(activity.turns ?? 0),
|
|
59
|
+
reached: Number(activity.reached ?? 0),
|
|
60
|
+
},
|
|
61
|
+
kinds: activity.kinds ?? {},
|
|
62
|
+
agents: activity.agents ?? {},
|
|
63
|
+
zones: activity.zones ?? {},
|
|
64
|
+
via: { search, cascade },
|
|
65
|
+
// The rate each test has to reach to pass, so a chart can draw the line it is measured against.
|
|
66
|
+
pass: PASS,
|
|
67
|
+
vectors: stats?.embeddings ? { model: stats.embeddings.model, entries: Number(stats.embeddings.entries ?? 0), memories: Number(stats.embeddings.memories ?? 0) } : null,
|
|
68
|
+
};
|
|
69
|
+
}
|