@jossuealcala/madre 0.2.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 +38 -0
- package/LICENSE +202 -0
- package/NOTICE +5 -0
- package/README.md +281 -0
- package/bin/madre.mjs +134 -0
- package/docs/madre-banner.svg +25 -0
- package/package.json +63 -0
- package/public/app.js +4031 -0
- package/public/brands.js +59 -0
- package/public/index.html +231 -0
- package/public/styles.css +976 -0
- package/public/troubleshooting.js +460 -0
- package/src/adapters/claude.mjs +105 -0
- package/src/adapters/codex.mjs +84 -0
- package/src/adapters/gemini.mjs +335 -0
- package/src/adapters/opencode.mjs +148 -0
- package/src/adapters/process.mjs +132 -0
- package/src/ashcode.mjs +64 -0
- package/src/auth-probe.mjs +145 -0
- package/src/capabilities.mjs +126 -0
- package/src/checkpoint.mjs +106 -0
- package/src/cli-args.mjs +38 -0
- package/src/commands.mjs +98 -0
- package/src/config.mjs +59 -0
- package/src/conversation-context.mjs +59 -0
- package/src/directives.mjs +70 -0
- package/src/distiller.mjs +78 -0
- package/src/embeddings.mjs +80 -0
- package/src/event-store.mjs +152 -0
- package/src/extensions.mjs +263 -0
- package/src/files.mjs +177 -0
- package/src/image-studio.mjs +25 -0
- package/src/lease.mjs +83 -0
- package/src/mcp/image-server.mjs +169 -0
- package/src/mcp/memory-server.mjs +221 -0
- package/src/memory-tools.mjs +34 -0
- package/src/memory.mjs +543 -0
- package/src/models.mjs +78 -0
- package/src/mother.mjs +163 -0
- package/src/quota-monitor.mjs +119 -0
- package/src/quota-sources.mjs +161 -0
- package/src/room.mjs +1159 -0
- package/src/router.mjs +13 -0
- package/src/runtime-detection.mjs +85 -0
- package/src/server.mjs +693 -0
- package/src/setup.mjs +192 -0
- package/src/usage-sentinel.mjs +65 -0
package/src/commands.mjs
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { access } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
const exists = (path) => access(path).then(() => true, () => false);
|
|
8
|
+
|
|
9
|
+
// Slash commands the human types in the composer. Some are client-side
|
|
10
|
+
// (/create, /stopall, /image arm things in the UI); these run on the server,
|
|
11
|
+
// read-only, inside the project, and land in the room as a command card so
|
|
12
|
+
// agents and humans share the same facts without spending an agent turn.
|
|
13
|
+
|
|
14
|
+
async function run(command, args, cwd, timeout = 15000) {
|
|
15
|
+
try {
|
|
16
|
+
const { stdout, stderr } = await execFileAsync(command, args, { cwd, timeout, env: { ...process.env, NO_COLOR: '1', GIT_PAGER: 'cat', PAGER: 'cat' }, maxBuffer: 2 * 1024 * 1024 });
|
|
17
|
+
return { ok: true, text: `${stdout}${stderr ? `\n${stderr}` : ''}`.trim() };
|
|
18
|
+
} catch (error) {
|
|
19
|
+
return { ok: false, text: (error.stderr || error.stdout || error.message || '').toString().trim() };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const COMMANDS = [
|
|
24
|
+
{
|
|
25
|
+
name: 'git',
|
|
26
|
+
module: 'git-pulse',
|
|
27
|
+
title: 'Git Pulse',
|
|
28
|
+
usage: '/git [status|log|diff|branches]',
|
|
29
|
+
summary: 'Repository status, recent commits, uncommitted changes and branches, read-only, from the project itself.',
|
|
30
|
+
async available({ projectRoot }) {
|
|
31
|
+
return exists(join(projectRoot, '.git'));
|
|
32
|
+
},
|
|
33
|
+
async execute({ projectRoot, args }) {
|
|
34
|
+
const what = (args[0] ?? 'status').toLowerCase();
|
|
35
|
+
const sections = [];
|
|
36
|
+
const add = async (title, gitArgs) => {
|
|
37
|
+
const result = await run('git', gitArgs, projectRoot);
|
|
38
|
+
sections.push(`## ${title}\n${result.text || '(nothing)'}`);
|
|
39
|
+
};
|
|
40
|
+
if (what === 'status' || what === 'all') {
|
|
41
|
+
await add('branch', ['-c', 'color.ui=never', 'status', '-sb']);
|
|
42
|
+
await add('changes', ['-c', 'color.ui=never', 'status', '--short']);
|
|
43
|
+
await add('last commits', ['-c', 'color.ui=never', 'log', '--oneline', '--decorate', '-n', '8']);
|
|
44
|
+
} else if (what === 'log') {
|
|
45
|
+
await add('last commits', ['-c', 'color.ui=never', 'log', '--oneline', '--decorate', '--stat', '-n', String(Math.min(Number(args[1]) || 10, 50))]);
|
|
46
|
+
} else if (what === 'diff') {
|
|
47
|
+
await add('uncommitted diff (stat)', ['-c', 'color.ui=never', 'diff', '--stat']);
|
|
48
|
+
await add('staged (stat)', ['-c', 'color.ui=never', 'diff', '--cached', '--stat']);
|
|
49
|
+
} else if (what === 'branches') {
|
|
50
|
+
await add('branches', ['-c', 'color.ui=never', 'branch', '-avv']);
|
|
51
|
+
} else {
|
|
52
|
+
return { ok: false, title: 'Git Pulse', text: `Unknown subcommand "${what}". Use /git status, /git log [n], /git diff or /git branches.` };
|
|
53
|
+
}
|
|
54
|
+
return { ok: true, title: `Git Pulse · ${what}`, text: sections.join('\n\n') };
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: 'ahp',
|
|
59
|
+
module: 'ahp',
|
|
60
|
+
title: 'AHP+',
|
|
61
|
+
usage: '/ahp [status|check|context]',
|
|
62
|
+
summary: 'Ask the AHP+ installed in this project for its verified status, checks or session context.',
|
|
63
|
+
async available({ projectRoot }) {
|
|
64
|
+
return exists(join(projectRoot, '.ahp', 'manifest.json')) && exists(join(projectRoot, 'node_modules', '.bin', 'ahp'));
|
|
65
|
+
},
|
|
66
|
+
async execute({ projectRoot, args }) {
|
|
67
|
+
const what = (args[0] ?? 'status').toLowerCase();
|
|
68
|
+
const map = {
|
|
69
|
+
status: ['project', 'status', '.'],
|
|
70
|
+
check: ['project', 'check', '.'],
|
|
71
|
+
context: ['session', 'context', '.', '--format', 'markdown', '--budget', '4000'],
|
|
72
|
+
};
|
|
73
|
+
if (!map[what]) return { ok: false, title: 'AHP+', text: `Unknown subcommand "${what}". Use /ahp status, /ahp check or /ahp context.` };
|
|
74
|
+
const result = await run(join(projectRoot, 'node_modules', '.bin', 'ahp'), map[what], projectRoot, 60000);
|
|
75
|
+
return { ok: result.ok, title: `AHP+ · ${what}`, text: result.text || '(no output)' };
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
export const commandByName = (name) => COMMANDS.find((command) => command.name === String(name ?? '').toLowerCase()) ?? null;
|
|
81
|
+
|
|
82
|
+
export async function listCommands({ projectRoot }) {
|
|
83
|
+
return Promise.all(COMMANDS.map(async (command) => ({
|
|
84
|
+
name: command.name,
|
|
85
|
+
module: command.module,
|
|
86
|
+
title: command.title,
|
|
87
|
+
usage: command.usage,
|
|
88
|
+
summary: command.summary,
|
|
89
|
+
available: await command.available({ projectRoot }),
|
|
90
|
+
})));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// "/git log 5" → { name: 'git', args: ['log', '5'] }; null when not a command.
|
|
94
|
+
export function parseCommand(text) {
|
|
95
|
+
const match = String(text ?? '').trim().match(/^\/([a-z][\w-]*)(?:\s+([\s\S]*))?$/i);
|
|
96
|
+
if (!match) return null;
|
|
97
|
+
return { name: match[1].toLowerCase(), args: (match[2] ?? '').trim().split(/\s+/).filter(Boolean), raw: match[0] };
|
|
98
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
// User preferences that should survive terminals: `~/.pulse/config.json`.
|
|
6
|
+
// Environment variables always win over the file, so a one-off override
|
|
7
|
+
// never has to touch it.
|
|
8
|
+
|
|
9
|
+
export const configPath = (root = process.env.PULSE_HOME ?? join(homedir(), '.pulse')) => join(root, 'config.json');
|
|
10
|
+
|
|
11
|
+
export async function loadConfig(root) {
|
|
12
|
+
try {
|
|
13
|
+
const parsed = JSON.parse(await readFile(configPath(root), 'utf8'));
|
|
14
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
15
|
+
} catch (error) {
|
|
16
|
+
if (error.code === 'ENOENT' || error instanceof SyntaxError) return {};
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function saveConfig(root, config) {
|
|
22
|
+
const file = configPath(root);
|
|
23
|
+
await mkdir(dirname(file), { recursive: true });
|
|
24
|
+
await writeFile(file, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
25
|
+
return file;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function updateConfig(root, patch) {
|
|
29
|
+
const current = await loadConfig(root);
|
|
30
|
+
const next = { ...current, ...patch };
|
|
31
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
32
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) next[key] = { ...(current[key] ?? {}), ...value };
|
|
33
|
+
}
|
|
34
|
+
await saveConfig(root, next);
|
|
35
|
+
return next;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Applies file-backed preferences to the process environment without
|
|
39
|
+
// overriding anything the user already exported.
|
|
40
|
+
export function applyConfigToEnv(config, env = process.env) {
|
|
41
|
+
const applied = {};
|
|
42
|
+
const set = (name, value) => {
|
|
43
|
+
if (value === undefined || value === null || value === '' || env[name] !== undefined) return;
|
|
44
|
+
env[name] = String(value);
|
|
45
|
+
applied[name] = String(value);
|
|
46
|
+
};
|
|
47
|
+
set('PULSE_OPENCODE_MODEL', config.opencode?.model);
|
|
48
|
+
set('PULSE_SOFT_TOKEN_BUDGET', config.room?.softTokenBudget);
|
|
49
|
+
set('PULSE_CONTEXT_MAX_CHARS', config.room?.contextMaxChars);
|
|
50
|
+
set('PULSE_AGENT_TIMEOUT_MS', config.timeouts?.default);
|
|
51
|
+
if (config.room?.delegation === false) set('PULSE_DELEGATION', '0');
|
|
52
|
+
set('PULSE_GEMINI_IDLE_MS', config.gemini?.idleMs);
|
|
53
|
+
set('PULSE_GEMINI_RETRIES', config.gemini?.retries);
|
|
54
|
+
set('PULSE_MAX_PLAN_STEPS', config.room?.maxPlanSteps);
|
|
55
|
+
for (const [agent, value] of Object.entries(config.timeouts ?? {})) {
|
|
56
|
+
if (agent !== 'default') set(`PULSE_${agent.toUpperCase()}_TIMEOUT_MS`, value);
|
|
57
|
+
}
|
|
58
|
+
return applied;
|
|
59
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export function messageEntry(event) {
|
|
2
|
+
if (event.type === 'message.failed') {
|
|
3
|
+
// Failures are part of the conversation: an orchestrator closing a plan
|
|
4
|
+
// must know that a step never answered, and why.
|
|
5
|
+
const { messageId, target, error } = event.payload;
|
|
6
|
+
if (typeof error !== 'string' || !error.trim()) return null;
|
|
7
|
+
return { sequence: event.sequence, messageId: `${messageId}:failed`, role: 'failed', sender: target ?? 'room', target: 'you', text: error.trim() };
|
|
8
|
+
}
|
|
9
|
+
if (event.type === 'command.output') {
|
|
10
|
+
const { name, title, text } = event.payload;
|
|
11
|
+
if (typeof text !== 'string' || !text.trim()) return null;
|
|
12
|
+
return { sequence: event.sequence, messageId: `${event.id}:command`, role: 'command', sender: `/${name}`, target: 'room', text: `${title}\n${text.trim()}`.slice(0, 6000) };
|
|
13
|
+
}
|
|
14
|
+
if (event.type !== 'message.created') return null;
|
|
15
|
+
const { messageId, role, sender, target, text } = event.payload;
|
|
16
|
+
if (typeof text !== 'string' || !text.trim()) return null;
|
|
17
|
+
return { sequence: event.sequence, messageId, role, sender, target, text: text.trim() };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function buildConversationContext(events, { excludeMessageId, maxChars = 16000 } = {}) {
|
|
21
|
+
const messages = events
|
|
22
|
+
.map(messageEntry)
|
|
23
|
+
.filter((message) => message && message.messageId !== excludeMessageId);
|
|
24
|
+
const selected = [];
|
|
25
|
+
let remaining = Math.max(0, Number(maxChars) || 0);
|
|
26
|
+
|
|
27
|
+
for (let index = messages.length - 1; index >= 0 && remaining > 0; index -= 1) {
|
|
28
|
+
const message = messages[index];
|
|
29
|
+
const label = `${message.sender} (${message.role})`;
|
|
30
|
+
const allowance = Math.max(0, remaining - label.length - 2);
|
|
31
|
+
if (!allowance) break;
|
|
32
|
+
const text = message.text.length > allowance
|
|
33
|
+
? allowance <= 3
|
|
34
|
+
? '.'.repeat(allowance)
|
|
35
|
+
: `...${message.text.slice(message.text.length - allowance + 3)}`
|
|
36
|
+
: message.text;
|
|
37
|
+
selected.unshift({ ...message, text });
|
|
38
|
+
remaining -= label.length + text.length + 2;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
messages: selected,
|
|
43
|
+
omittedMessages: messages.length - selected.length,
|
|
44
|
+
firstSequence: selected[0]?.sequence ?? null,
|
|
45
|
+
throughSequence: selected.at(-1)?.sequence ?? null,
|
|
46
|
+
previousAgent: [...messages].reverse().find((message) => message.role === 'assistant')?.sender ?? null,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function formatConversationContext(context) {
|
|
51
|
+
if (!context.messages.length) return '';
|
|
52
|
+
const omitted = context.omittedMessages
|
|
53
|
+
? `[${context.omittedMessages} earlier message(s) omitted]\n`
|
|
54
|
+
: '';
|
|
55
|
+
const transcript = context.messages
|
|
56
|
+
.map((message) => `${message.sender} (${message.role}): ${message.text}`)
|
|
57
|
+
.join('\n\n');
|
|
58
|
+
return `${omitted}${transcript}`;
|
|
59
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Agent-to-agent delegation. An agent answering in the room may end its reply
|
|
2
|
+
// with a fenced block that puts other agents to work, in order:
|
|
3
|
+
//
|
|
4
|
+
// ```pulse
|
|
5
|
+
// @gemini: Synthesize who Jossue is in one paragraph.
|
|
6
|
+
// @codex: Same question; name the least supported claim.
|
|
7
|
+
// @claude: Compare both syntheses and mark where they diverge.
|
|
8
|
+
// ```
|
|
9
|
+
//
|
|
10
|
+
// MADRE runs the steps sequentially as regular room turns, each recorded,
|
|
11
|
+
// each subject to handoff, budget and timeout, and each stoppable by the
|
|
12
|
+
// human. A step addressed to the orchestrator itself becomes its closing
|
|
13
|
+
// turn once the others have answered. Delegated agents cannot delegate
|
|
14
|
+
// further (depth is capped at one), so a plan always terminates.
|
|
15
|
+
|
|
16
|
+
export const DELEGATION_HELP = (self, others, maxSteps) => [
|
|
17
|
+
`You may put other agents to work: ${others.map((id) => `@${id}`).join(', ')}.`,
|
|
18
|
+
'Only do so when the human asked you to coordinate, or when it clearly serves the request.',
|
|
19
|
+
'To delegate, end your answer with one fenced block, one step per line, in the order they should run:',
|
|
20
|
+
'```pulse',
|
|
21
|
+
`@${others[0] ?? 'codex'}: <question for that agent>`,
|
|
22
|
+
`@${self}: <what you will do with their answers, optional closing turn for you>`,
|
|
23
|
+
'```',
|
|
24
|
+
`MADRE runs the steps in order (at most ${maxSteps}), shows every answer in the room, then hands you the closing turn if you asked for one.`,
|
|
25
|
+
'The plan block must be the very last thing in your reply: nothing after it, not even a closing sentence. Say everything else before it.',
|
|
26
|
+
'Address each agent once. Do not delegate what you can answer yourself.',
|
|
27
|
+
].join('\n');
|
|
28
|
+
|
|
29
|
+
// Only a plan block that closes the reply counts. Blocks quoted earlier in
|
|
30
|
+
// the text (examples, explanations) are never executed.
|
|
31
|
+
export function parseDirectives(text, { self, available = [], maxSteps = 4 } = {}) {
|
|
32
|
+
const source = String(text ?? '');
|
|
33
|
+
const blocks = [...source.matchAll(/```pulse\s*\n([\s\S]*?)```/gi)];
|
|
34
|
+
if (!blocks.length) return { steps: [], closing: null, ignored: [] };
|
|
35
|
+
const last = blocks.at(-1);
|
|
36
|
+
const trailing = source.slice(last.index + last[0].length);
|
|
37
|
+
if (trailing.trim()) {
|
|
38
|
+
return { steps: [], closing: null, ignored: [{ line: '```pulse … ```', reason: 'a plan block must be the last thing in the reply' }] };
|
|
39
|
+
}
|
|
40
|
+
const steps = [];
|
|
41
|
+
const ignored = [];
|
|
42
|
+
let closing = null;
|
|
43
|
+
const seen = new Set();
|
|
44
|
+
for (const block of [last]) {
|
|
45
|
+
for (const raw of block[1].split('\n')) {
|
|
46
|
+
const line = raw.trim();
|
|
47
|
+
if (!line || line.startsWith('#')) continue;
|
|
48
|
+
const match = line.match(/^[-*]?\s*@([a-z0-9_-]+)\s*[::]\s*(.+)$/i);
|
|
49
|
+
if (!match) { ignored.push({ line, reason: 'not a step (expected "@agent: text")' }); continue; }
|
|
50
|
+
const agent = match[1].toLowerCase();
|
|
51
|
+
const instruction = match[2].trim();
|
|
52
|
+
if (agent === self) {
|
|
53
|
+
if (closing) { ignored.push({ line, reason: 'only one closing step for the orchestrator' }); continue; }
|
|
54
|
+
closing = instruction;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (!available.includes(agent)) { ignored.push({ line, reason: `@${agent} is not available in this room` }); continue; }
|
|
58
|
+
if (seen.has(agent)) { ignored.push({ line, reason: `@${agent} already has a step` }); continue; }
|
|
59
|
+
if (steps.length >= maxSteps) { ignored.push({ line, reason: `plan is capped at ${maxSteps} steps` }); continue; }
|
|
60
|
+
seen.add(agent);
|
|
61
|
+
steps.push({ agent, text: instruction });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return { steps, closing, ignored };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The reply text without the plan block, for readers who only want the prose.
|
|
68
|
+
export function stripDirectives(text) {
|
|
69
|
+
return String(text ?? '').replace(/```pulse\s*\n[\s\S]*?```/gi, '').trim();
|
|
70
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// The archivist's turn: every so often the cheapest agent in the room reads
|
|
2
|
+
// the exchanges nobody has distilled yet and writes the few notes worth
|
|
3
|
+
// keeping for every later turn. Pure functions here; the room schedules and
|
|
4
|
+
// invokes. Nothing in this file talks to a model.
|
|
5
|
+
|
|
6
|
+
import { MEMORY_KINDS } from './memory.mjs';
|
|
7
|
+
|
|
8
|
+
// Cheapest first, by the models these CLIs run by default.
|
|
9
|
+
export const DISTILLER_ORDER = ['gemini', 'opencode', 'codex', 'claude'];
|
|
10
|
+
export const MAX_MEMORIES_PER_RUN = 5;
|
|
11
|
+
export const MAX_MEMORY_CHARS = 240;
|
|
12
|
+
|
|
13
|
+
// The agent that pays for distillation: the human's pick if it is usable, else
|
|
14
|
+
// the cheapest ready one that has an adapter and no turn in flight.
|
|
15
|
+
export function pickDistiller(agents, { preferred = null, busy = new Set(), invokers = null } = {}) {
|
|
16
|
+
const usable = (agent) => agent?.detected && agent.ready && !busy.has(agent.id) && (!invokers || Boolean(invokers[agent.adapter]));
|
|
17
|
+
if (preferred) {
|
|
18
|
+
const chosen = agents.find((agent) => agent.id === preferred);
|
|
19
|
+
if (usable(chosen)) return chosen;
|
|
20
|
+
}
|
|
21
|
+
for (const id of DISTILLER_ORDER) {
|
|
22
|
+
const chosen = agents.find((agent) => agent.id === id);
|
|
23
|
+
if (usable(chosen)) return chosen;
|
|
24
|
+
}
|
|
25
|
+
return agents.find(usable) ?? null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function distillPrompt({ entries, projectName = 'the project', existing = [] }) {
|
|
29
|
+
const transcript = entries.map((entry) => {
|
|
30
|
+
const who = entry.role === 'command' ? entry.sender : `@${entry.sender}`;
|
|
31
|
+
const to = entry.target && entry.target !== 'room' ? ` → ${entry.target === 'you' ? 'human' : `@${entry.target}`}` : '';
|
|
32
|
+
return `[#${entry.sequence} · ${who} (${entry.role})${to}] ${entry.text}`;
|
|
33
|
+
}).join('\n\n');
|
|
34
|
+
const known = existing.length ? `\nAlready remembered (do not repeat these):\n${existing.map((memory) => `- ${memory.text}`).join('\n')}\n` : '';
|
|
35
|
+
return [
|
|
36
|
+
`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.`,
|
|
37
|
+
'Read these room exchanges (each stamped with its ledger sequence) and write only the memories worth keeping for future turns of any agent:',
|
|
38
|
+
`- decision: something the human or the room settled on (what and, briefly, why)`,
|
|
39
|
+
`- fact: a verified statement about the project or its environment that is not obvious from the code`,
|
|
40
|
+
`- preference: how the human wants things done or said`,
|
|
41
|
+
`- question: something left open that a later turn should not forget`,
|
|
42
|
+
'Skip greetings, restatements, transient status, anything a reader of the code would see anyway, and anything the exchanges do not actually establish.',
|
|
43
|
+
`Output only JSON lines, one object per memory, at most ${MAX_MEMORIES_PER_RUN}, nothing else:`,
|
|
44
|
+
`{"kind":"decision|fact|preference|question","text":"one self-contained sentence in the language the room uses, at most ${MAX_MEMORY_CHARS} characters, naming files, agents and numbers exactly","sources":[sequence numbers it comes from]}`,
|
|
45
|
+
'If nothing durable was said, output exactly: NONE',
|
|
46
|
+
known,
|
|
47
|
+
'<exchanges>',
|
|
48
|
+
transcript,
|
|
49
|
+
'</exchanges>',
|
|
50
|
+
].filter((line) => line !== null && line !== undefined).join('\n');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Whatever came back, only the well-formed memories survive: known kinds,
|
|
54
|
+
// non-empty text within the limit, sources inside the batch.
|
|
55
|
+
export function parseDistillation(text, { fromSequence, throughSequence } = {}) {
|
|
56
|
+
const memories = [];
|
|
57
|
+
const seen = new Set();
|
|
58
|
+
for (const raw of String(text ?? '').split(/\r?\n/)) {
|
|
59
|
+
const line = raw.trim().replace(/^```(?:json)?$|^```$/g, '').replace(/^[-*]\s+/, '');
|
|
60
|
+
const start = line.indexOf('{');
|
|
61
|
+
const end = line.lastIndexOf('}');
|
|
62
|
+
if (start < 0 || end <= start) continue;
|
|
63
|
+
let parsed;
|
|
64
|
+
try { parsed = JSON.parse(line.slice(start, end + 1)); } catch { continue; }
|
|
65
|
+
if (!parsed || typeof parsed !== 'object') continue;
|
|
66
|
+
const memoryText = String(parsed.text ?? '').replace(/\s+/g, ' ').trim();
|
|
67
|
+
if (!memoryText) continue;
|
|
68
|
+
const kind = MEMORY_KINDS.includes(parsed.kind) ? parsed.kind : 'fact';
|
|
69
|
+
const inRange = (n) => Number.isInteger(n) && (fromSequence == null || n >= fromSequence) && (throughSequence == null || n <= throughSequence);
|
|
70
|
+
const sources = (Array.isArray(parsed.sources) ? parsed.sources : []).map(Number).filter(inRange);
|
|
71
|
+
const key = memoryText.toLowerCase();
|
|
72
|
+
if (seen.has(key)) continue;
|
|
73
|
+
seen.add(key);
|
|
74
|
+
memories.push({ kind, text: memoryText.length > MAX_MEMORY_CHARS ? `${memoryText.slice(0, MAX_MEMORY_CHARS - 1)}…` : memoryText, sources });
|
|
75
|
+
if (memories.length >= MAX_MEMORIES_PER_RUN) break;
|
|
76
|
+
}
|
|
77
|
+
return memories;
|
|
78
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Embeddings for the room memory: meaning-aware recall on top of the lexical
|
|
2
|
+
// index. Gemini's embedding model through the user's own key (the same key
|
|
3
|
+
// Image Studio and the Gemini CLI use); no key, no embeddings, and recall stays
|
|
4
|
+
// lexical. PULSE_EMBED_FAKE=1 swaps in a deterministic local embedder so tests
|
|
5
|
+
// never call Google.
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_EMBED_MODEL = 'gemini-embedding-001';
|
|
8
|
+
export const DEFAULT_EMBED_DIMS = 768;
|
|
9
|
+
const MAX_TEXT_CHARS = 2000; // one entry is embedded from its first 2000 characters
|
|
10
|
+
const BATCH = 100; // batchEmbedContents accepts up to 100 requests
|
|
11
|
+
|
|
12
|
+
export function cosine(a, b) {
|
|
13
|
+
if (!a || !b || a.length !== b.length) return 0;
|
|
14
|
+
let dot = 0;
|
|
15
|
+
let na = 0;
|
|
16
|
+
let nb = 0;
|
|
17
|
+
for (let i = 0; i < a.length; i += 1) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }
|
|
18
|
+
return na && nb ? dot / Math.sqrt(na * nb) : 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function toBlob(vector) { return Buffer.from(Float32Array.from(vector).buffer); }
|
|
22
|
+
export function fromBlob(blob) {
|
|
23
|
+
const bytes = blob instanceof Uint8Array ? blob : new Uint8Array(blob);
|
|
24
|
+
return new Float32Array(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Hashed word and trigram bag, unit length: stable, local, good enough to test the plumbing.
|
|
28
|
+
export function fakeEmbedding(text, dims = 64) {
|
|
29
|
+
const vector = new Float32Array(dims);
|
|
30
|
+
const clean = String(text ?? '').toLowerCase();
|
|
31
|
+
const hash = (s) => { let h = 2166136261; for (let i = 0; i < s.length; i += 1) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0; };
|
|
32
|
+
for (const word of clean.split(/[^\p{L}\p{N}]+/u).filter((w) => w.length > 2)) {
|
|
33
|
+
vector[hash(word) % dims] += 1;
|
|
34
|
+
for (let i = 0; i + 3 <= word.length; i += 1) vector[hash(word.slice(i, i + 3)) % dims] += 0.35;
|
|
35
|
+
}
|
|
36
|
+
let norm = 0;
|
|
37
|
+
for (const value of vector) norm += value * value;
|
|
38
|
+
norm = Math.sqrt(norm) || 1;
|
|
39
|
+
return vector.map((value) => value / norm);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function geminiEmbed(texts, { key, model, dims, query, fetchImpl, timeoutMs }) {
|
|
43
|
+
const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:batchEmbedContents?key=${encodeURIComponent(key)}`;
|
|
44
|
+
const out = [];
|
|
45
|
+
for (let start = 0; start < texts.length; start += BATCH) {
|
|
46
|
+
const slice = texts.slice(start, start + BATCH);
|
|
47
|
+
const body = {
|
|
48
|
+
requests: slice.map((text) => ({
|
|
49
|
+
model: `models/${model}`,
|
|
50
|
+
content: { parts: [{ text: String(text ?? '').slice(0, MAX_TEXT_CHARS) || ' ' }] },
|
|
51
|
+
taskType: query ? 'RETRIEVAL_QUERY' : 'RETRIEVAL_DOCUMENT',
|
|
52
|
+
outputDimensionality: dims,
|
|
53
|
+
})),
|
|
54
|
+
};
|
|
55
|
+
const response = await fetchImpl(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs) });
|
|
56
|
+
if (!response.ok) {
|
|
57
|
+
const detail = await response.text().catch(() => '');
|
|
58
|
+
const error = new Error(`Gemini embeddings HTTP ${response.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`);
|
|
59
|
+
error.status = response.status;
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
const payload = await response.json();
|
|
63
|
+
const vectors = payload?.embeddings ?? [];
|
|
64
|
+
if (vectors.length !== slice.length) throw new Error(`Gemini embeddings returned ${vectors.length} vectors for ${slice.length} texts.`);
|
|
65
|
+
for (const item of vectors) out.push(Float32Array.from(item.values ?? []));
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The embedder the memory uses: { model, dims, embed(texts, { query }) } or null when off.
|
|
71
|
+
export function createEmbedder({ key = null, env = process.env, fetchImpl = globalThis.fetch, timeoutMs = Number(env.PULSE_EMBED_TIMEOUT_MS ?? 8000) } = {}) {
|
|
72
|
+
if (env.PULSE_EMBED === '0') return null;
|
|
73
|
+
if (env.PULSE_EMBED_FAKE === '1') {
|
|
74
|
+
return { model: 'fake-64', dims: 64, embed: async (texts) => texts.map((text) => fakeEmbedding(text)) };
|
|
75
|
+
}
|
|
76
|
+
if (!key) return null;
|
|
77
|
+
const model = env.PULSE_EMBED_MODEL ?? DEFAULT_EMBED_MODEL;
|
|
78
|
+
const dims = Number(env.PULSE_EMBED_DIMS ?? DEFAULT_EMBED_DIMS);
|
|
79
|
+
return { model: `${model}@${dims}`, dims, embed: (texts, { query = false } = {}) => geminiEmbed(texts, { key, model, dims, query, fetchImpl, timeoutMs }) };
|
|
80
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { mkdir, open, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
|
+
|
|
6
|
+
export class EventStore {
|
|
7
|
+
#file;
|
|
8
|
+
#nextSequence = 1;
|
|
9
|
+
#writeQueue = Promise.resolve();
|
|
10
|
+
#lockDirectory;
|
|
11
|
+
|
|
12
|
+
constructor(file) {
|
|
13
|
+
this.#file = file;
|
|
14
|
+
this.#lockDirectory = `${file}.lock`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async initialize() {
|
|
18
|
+
await mkdir(dirname(this.#file), { recursive: true });
|
|
19
|
+
const existing = await this.readAll();
|
|
20
|
+
this.#nextSequence = (existing.at(-1)?.sequence ?? 0) + 1;
|
|
21
|
+
return this;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async readAll() {
|
|
25
|
+
const release = await this.#acquireLock();
|
|
26
|
+
try {
|
|
27
|
+
return await this.#readAllUnlocked();
|
|
28
|
+
} finally {
|
|
29
|
+
await release();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Reads only the bytes appended after `offset`. Appends are whole lines
|
|
34
|
+
// written under the lock, so a locked read never sees a partial line; the
|
|
35
|
+
// trailing-newline guard is defensive. A shrunken file restarts from zero.
|
|
36
|
+
async tail(offset = 0) {
|
|
37
|
+
const release = await this.#acquireLock();
|
|
38
|
+
try {
|
|
39
|
+
let handle;
|
|
40
|
+
try {
|
|
41
|
+
handle = await open(this.#file, 'r');
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error.code === 'ENOENT') return { events: [], offset: 0 };
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const { size } = await handle.stat();
|
|
48
|
+
const start = size < offset ? 0 : offset;
|
|
49
|
+
if (size === start) return { events: [], offset: start };
|
|
50
|
+
const buffer = Buffer.alloc(size - start);
|
|
51
|
+
await handle.read(buffer, 0, buffer.length, start);
|
|
52
|
+
let text = buffer.toString('utf8');
|
|
53
|
+
const lastNewline = text.lastIndexOf('\n');
|
|
54
|
+
if (lastNewline < 0) return { events: [], offset: start };
|
|
55
|
+
text = text.slice(0, lastNewline + 1);
|
|
56
|
+
const events = text.split('\n').filter(Boolean).map((line) => JSON.parse(line));
|
|
57
|
+
return { events, offset: start + Buffer.byteLength(text, 'utf8') };
|
|
58
|
+
} finally {
|
|
59
|
+
await handle.close();
|
|
60
|
+
}
|
|
61
|
+
} finally {
|
|
62
|
+
await release();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async #readAllUnlocked() {
|
|
67
|
+
try {
|
|
68
|
+
const text = await readFile(this.#file, 'utf8');
|
|
69
|
+
return text.split('\n').filter(Boolean).map((line) => JSON.parse(line));
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (error.code === 'ENOENT') return [];
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async #acquireLock() {
|
|
77
|
+
const token = randomUUID();
|
|
78
|
+
const ownerFile = join(this.#lockDirectory, 'owner.json');
|
|
79
|
+
for (let attempt = 0; attempt < 500; attempt += 1) {
|
|
80
|
+
let created = false;
|
|
81
|
+
try {
|
|
82
|
+
await mkdir(this.#lockDirectory);
|
|
83
|
+
created = true;
|
|
84
|
+
await writeFile(ownerFile, JSON.stringify({ pid: process.pid, token }));
|
|
85
|
+
return async () => {
|
|
86
|
+
try {
|
|
87
|
+
const owner = JSON.parse(await readFile(ownerFile, 'utf8'));
|
|
88
|
+
if (owner.token === token) await rm(this.#lockDirectory, { recursive: true, force: true });
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (error.code !== 'ENOENT') throw error;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (created) {
|
|
95
|
+
await rm(this.#lockDirectory, { recursive: true, force: true });
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
if (error.code !== 'EEXIST') throw error;
|
|
99
|
+
try {
|
|
100
|
+
const owner = JSON.parse(await readFile(ownerFile, 'utf8'));
|
|
101
|
+
try {
|
|
102
|
+
process.kill(owner.pid, 0);
|
|
103
|
+
} catch (ownerError) {
|
|
104
|
+
if (ownerError.code === 'ESRCH') {
|
|
105
|
+
await rm(this.#lockDirectory, { recursive: true, force: true });
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
} catch (ownerError) {
|
|
110
|
+
if (ownerError.code !== 'ENOENT' && !(ownerError instanceof SyntaxError)) throw ownerError;
|
|
111
|
+
const lock = await stat(this.#lockDirectory).catch(() => null);
|
|
112
|
+
if (lock && Date.now() - lock.mtimeMs > 10000) {
|
|
113
|
+
await rm(this.#lockDirectory, { recursive: true, force: true });
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
await delay(20);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
throw new Error(`Timed out waiting for event store lock: ${this.#file}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
append(type, payload) {
|
|
124
|
+
const operation = async () => {
|
|
125
|
+
const release = await this.#acquireLock();
|
|
126
|
+
try {
|
|
127
|
+
const existing = await this.#readAllUnlocked();
|
|
128
|
+
const sequence = (existing.at(-1)?.sequence ?? 0) + 1;
|
|
129
|
+
const event = {
|
|
130
|
+
id: randomUUID(),
|
|
131
|
+
sequence,
|
|
132
|
+
type,
|
|
133
|
+
timestamp: new Date().toISOString(),
|
|
134
|
+
payload,
|
|
135
|
+
};
|
|
136
|
+
const handle = await open(this.#file, 'a');
|
|
137
|
+
try {
|
|
138
|
+
await handle.writeFile(`${JSON.stringify(event)}\n`);
|
|
139
|
+
await handle.sync();
|
|
140
|
+
} finally {
|
|
141
|
+
await handle.close();
|
|
142
|
+
}
|
|
143
|
+
this.#nextSequence = sequence + 1;
|
|
144
|
+
return event;
|
|
145
|
+
} finally {
|
|
146
|
+
await release();
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
this.#writeQueue = this.#writeQueue.then(operation, operation);
|
|
150
|
+
return this.#writeQueue;
|
|
151
|
+
}
|
|
152
|
+
}
|