@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.
- package/CHANGELOG.md +412 -3
- package/CONTRIBUTING.md +3 -1
- package/README.md +67 -185
- package/SECURITY.md +2 -1
- package/bin/madre.mjs +56 -13
- package/docs/INTERNALS.md +16 -0
- package/docs/REFERENCE.md +249 -0
- package/docs/SDK.md +121 -0
- package/docs/room.png +0 -0
- package/docs/sdk/hello-module.mjs +51 -0
- package/package.json +9 -1
- package/public/app.js +3825 -851
- package/public/es.js +2050 -0
- package/public/i18n.js +66 -0
- package/public/index.html +94 -13
- package/public/inquiry.js +220 -0
- package/public/resay.js +77 -0
- package/public/styles.css +602 -62
- package/public/troubleshooting.js +168 -46
- package/src/adapters/claude.mjs +2 -1
- package/src/adapters/codex.mjs +2 -1
- package/src/adapters/gemini.mjs +6 -5
- package/src/adapters/opencode.mjs +2 -1
- package/src/adapters/process.mjs +17 -5
- package/src/asking.mjs +128 -0
- package/src/auth-probe.mjs +58 -1
- package/src/chats.mjs +193 -0
- package/src/checkpoint.mjs +1 -1
- package/src/cold.mjs +56 -0
- package/src/commands.mjs +6 -0
- package/src/conversation-context.mjs +35 -3
- package/src/credentials.mjs +145 -0
- package/src/dataset.mjs +56 -4
- package/src/distiller.mjs +12 -5
- package/src/event-store.mjs +14 -8
- package/src/exam.mjs +240 -0
- package/src/extensions.mjs +3 -2
- package/src/eyecat-watch.mjs +100 -0
- package/src/eyecat.mjs +169 -0
- package/src/i18n.mjs +47 -0
- package/src/image-studio.mjs +2 -0
- package/src/launch.mjs +61 -0
- package/src/maturity.mjs +94 -0
- package/src/mcp/image-server.mjs +12 -1
- package/src/mcp/memory-server.mjs +1 -1
- package/src/memory.mjs +325 -17
- package/src/modules/ahp.mjs +9 -7
- package/src/modules/ash.mjs +36 -0
- package/src/modules/git-pulse.mjs +5 -3
- package/src/modules/helpers.mjs +31 -0
- package/src/modules/image-studio.mjs +9 -4
- package/src/modules/index.mjs +141 -9
- package/src/modules/ollama.mjs +66 -10
- package/src/modules/playwright.mjs +36 -19
- package/src/modules/ripley.mjs +5 -3
- package/src/modules/sdk.mjs +93 -2
- package/src/modules/updates.mjs +81 -0
- package/src/ollama.mjs +5 -2
- package/src/outbound.mjs +292 -0
- package/src/privacy.mjs +54 -7
- package/src/room/context.mjs +4 -4
- package/src/room/economy.mjs +161 -0
- package/src/room/prompt.mjs +118 -46
- package/src/room.mjs +443 -44
- package/src/runtime-detection.mjs +27 -8
- package/src/server.mjs +699 -69
- package/src/setup.mjs +1 -1
- package/src/updates.mjs +4 -2
- package/src/usage-sentinel.mjs +13 -8
- package/src/verdict.mjs +74 -0
- package/src/ashcode.mjs +0 -64
- package/src/modules/ashcode.mjs +0 -28
package/src/asking.mjs
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// What the room should ask next.
|
|
2
|
+
//
|
|
3
|
+
// The maturity reading says where the archive is thin; it does not say what to do about it on a
|
|
4
|
+
// Tuesday afternoon. This does. It reads the archive the room already has and writes the handful
|
|
5
|
+
// of questions whose answers are missing — and it writes them from the archive itself, word for
|
|
6
|
+
// word, never inventing a subject the room has not raised.
|
|
7
|
+
//
|
|
8
|
+
// Three wells, because there are three different kinds of hole:
|
|
9
|
+
//
|
|
10
|
+
// · questions the archivist already recorded as open, which nobody ever went back to. This is
|
|
11
|
+
// the archive saying out loud what it does not know.
|
|
12
|
+
// · cold memories: nothing has ever reached for them. Asking is the only way to find out
|
|
13
|
+
// whether they are noise or simply never came up, and it is the honest alternative to
|
|
14
|
+
// forgetting something that was never given a chance.
|
|
15
|
+
// · a kind of note the archive is short of. A room with forty facts and two decisions is
|
|
16
|
+
// recording what is true and not what was chosen, and no amount of use fixes that on its own.
|
|
17
|
+
//
|
|
18
|
+
// Nothing here spends a turn or writes anything. It hands the human a question; sending it is
|
|
19
|
+
// theirs, as is ignoring it.
|
|
20
|
+
|
|
21
|
+
export const ASK_LIMIT = 6;
|
|
22
|
+
export const THIN_SHARE = 0.12; // a kind under this much of the archive is thin
|
|
23
|
+
export const THIN_FLOOR = 20; // and only once there is enough archive for shares to mean anything
|
|
24
|
+
|
|
25
|
+
// Open questions are deliberately not here. An archive short of them is not a problem that
|
|
26
|
+
// asking for more questions solves; what is wanted is answers, and those come from the first
|
|
27
|
+
// well. Only the three kinds a thin archive is genuinely poorer for.
|
|
28
|
+
const KIND_ASK = {
|
|
29
|
+
decision: 'What have we decided lately that is not written down anywhere?',
|
|
30
|
+
preference: 'What do I keep asking for that nobody has written down as a preference?',
|
|
31
|
+
fact: 'What is true about this project that a new agent would have to be told?',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const KIND_WHY = {
|
|
35
|
+
decision: 'decisions',
|
|
36
|
+
preference: 'preferences',
|
|
37
|
+
fact: 'facts',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const quote = (text) => `"${String(text ?? '').replace(/\s+/g, ' ').trim()}"`;
|
|
41
|
+
|
|
42
|
+
// Two ways of asking the same thing are one question. The archivist writes on several passes and
|
|
43
|
+
// sometimes records a question twice in slightly different words; offering both of them back is
|
|
44
|
+
// how a list of six turns into a list of four.
|
|
45
|
+
const words = (text) => new Set(String(text ?? '').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').match(/[a-z0-9]{3,}/g) ?? []);
|
|
46
|
+
export function sameQuestion(a, b, floor = 0.72) {
|
|
47
|
+
const one = words(a);
|
|
48
|
+
const two = words(b);
|
|
49
|
+
if (!one.size || !two.size) return false;
|
|
50
|
+
let shared = 0;
|
|
51
|
+
for (const word of one) if (two.has(word)) shared += 1;
|
|
52
|
+
// Against the union, not the shorter of the two: "who owns the billing" and "who owns the
|
|
53
|
+
// migration" are three words apiece in common and are not the same question at all. But one
|
|
54
|
+
// question wholly inside another is the same question said at more length, and the union
|
|
55
|
+
// punishes exactly that, so containment is asked separately and asked strictly.
|
|
56
|
+
const inside = shared / Math.min(one.size, two.size);
|
|
57
|
+
return shared / (one.size + two.size - shared) >= floor || inside >= 0.9;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The questions, most worth asking first, each one traceable to what raised it.
|
|
61
|
+
export function questionsFor({ notes = [], cold = new Map(), dismissed = [], limit = ASK_LIMIT } = {}) {
|
|
62
|
+
const skip = new Set(dismissed ?? []);
|
|
63
|
+
const standing = notes.filter((note) => note && note.kind !== 'aberration' && !note.refutedBy);
|
|
64
|
+
const asks = { open: [], cold: [], thin: [] };
|
|
65
|
+
|
|
66
|
+
// What the archive itself recorded as open. One the room keeps reaching for and still has no
|
|
67
|
+
// answer to is the most worth asking of anything here.
|
|
68
|
+
for (const note of standing.filter((note) => note.kind === 'question')) {
|
|
69
|
+
asks.open.push({
|
|
70
|
+
id: `open:${note.id}`,
|
|
71
|
+
source: 'open',
|
|
72
|
+
memoryId: note.id,
|
|
73
|
+
text: note.text,
|
|
74
|
+
why: Number(note.recalled ?? 0) > 0
|
|
75
|
+
? `the room has carried this open question into ${note.recalled} turn${Number(note.recalled) === 1 ? '' : 's'} and still has no answer`
|
|
76
|
+
: 'the archivist recorded this as open and nothing has answered it',
|
|
77
|
+
weight: 100 + Number(note.recalled ?? 0),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// What nothing has ever reached for. Asking settles it either way.
|
|
82
|
+
for (const note of standing) {
|
|
83
|
+
const chill = cold.get?.(note.id) ?? null;
|
|
84
|
+
if (!chill) continue;
|
|
85
|
+
asks.cold.push({
|
|
86
|
+
id: `cold:${note.id}`,
|
|
87
|
+
source: 'cold',
|
|
88
|
+
memoryId: note.id,
|
|
89
|
+
text: `Is this still true, and does it still matter here? ${quote(note.text)}`,
|
|
90
|
+
why: `the archive has been opened ${chill.chances} times since this was written and never once carried it`,
|
|
91
|
+
weight: 50 + chill.chances,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// What the archive is short of. Not a memory: a shape.
|
|
96
|
+
if (standing.length >= THIN_FLOOR) {
|
|
97
|
+
const counts = new Map();
|
|
98
|
+
for (const note of standing) counts.set(note.kind, (counts.get(note.kind) ?? 0) + 1);
|
|
99
|
+
for (const kind of Object.keys(KIND_ASK)) {
|
|
100
|
+
const count = counts.get(kind) ?? 0;
|
|
101
|
+
if (count / standing.length >= THIN_SHARE) continue;
|
|
102
|
+
asks.thin.push({
|
|
103
|
+
id: `thin:${kind}`,
|
|
104
|
+
source: 'thin',
|
|
105
|
+
memoryId: null,
|
|
106
|
+
text: KIND_ASK[kind],
|
|
107
|
+
why: `${count} of ${standing.length} notes are ${KIND_WHY[kind]}`,
|
|
108
|
+
weight: 40 - count,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (const list of Object.values(asks)) list.sort((a, b) => b.weight - a.weight);
|
|
114
|
+
// Taken in turn rather than by weight alone, so one full well cannot be the whole list: six
|
|
115
|
+
// variations on the same cold note is not a plan, it is a loop.
|
|
116
|
+
const order = ['open', 'cold', 'thin'];
|
|
117
|
+
const chosen = [];
|
|
118
|
+
while (chosen.length < limit && order.some((well) => asks[well].length)) {
|
|
119
|
+
for (const well of order) {
|
|
120
|
+
if (chosen.length >= limit) break;
|
|
121
|
+
const next = asks[well].shift();
|
|
122
|
+
if (!next || skip.has(next.id)) continue;
|
|
123
|
+
if (chosen.some((already) => sameQuestion(already.text, next.text))) continue;
|
|
124
|
+
chosen.push(next);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return chosen;
|
|
128
|
+
}
|
package/src/auth-probe.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
|
+
import { t } from './i18n.mjs';
|
|
2
3
|
import { access, readFile } from 'node:fs/promises';
|
|
3
4
|
import { homedir } from 'node:os';
|
|
4
5
|
import { join } from 'node:path';
|
|
@@ -78,7 +79,9 @@ export async function probeGemini({ env = process.env } = {}) {
|
|
|
78
79
|
settings = null;
|
|
79
80
|
}
|
|
80
81
|
const hasOauth = await exists(join(dir, 'oauth_creds.json'));
|
|
81
|
-
|
|
82
|
+
// The CLI reads ~/.gemini/.env as well as the environment and the system keychain.
|
|
83
|
+
const dotEnv = await readFile(join(dir, '.env'), 'utf8').catch(() => '');
|
|
84
|
+
const hasApiKey = Boolean(env.GEMINI_API_KEY || env.GOOGLE_API_KEY) || /^\s*(GEMINI_API_KEY|GOOGLE_API_KEY)\s*=\s*\S/m.test(dotEnv) || await geminiHasKeychainKey();
|
|
82
85
|
return geminiAuthState({ settings, hasOauth, hasApiKey });
|
|
83
86
|
}
|
|
84
87
|
|
|
@@ -90,26 +93,80 @@ export const AGENT_SETUP = {
|
|
|
90
93
|
login: ['login'],
|
|
91
94
|
loginNote: 'Opens your browser to sign in with ChatGPT.',
|
|
92
95
|
browser: true,
|
|
96
|
+
// What a newcomer needs to know before choosing this door: which account, and whether
|
|
97
|
+
// there is a way in without paying. `paid: null` means it depends on the provider you pick.
|
|
98
|
+
vendor: 'OpenAI',
|
|
99
|
+
account: 'Signs in with a ChatGPT account. An OpenAI API key works too.',
|
|
100
|
+
paid: true,
|
|
93
101
|
},
|
|
94
102
|
claude: {
|
|
95
103
|
install: ['npm install -g @anthropic-ai/claude-code', 'or: brew install --cask claude-code'],
|
|
96
104
|
login: ['auth', 'login'],
|
|
97
105
|
loginNote: 'Opens your browser to sign in with your Claude account.',
|
|
98
106
|
browser: true,
|
|
107
|
+
vendor: 'Anthropic',
|
|
108
|
+
account: 'Signs in with a Claude account. An Anthropic API key works too.',
|
|
109
|
+
paid: true,
|
|
99
110
|
},
|
|
100
111
|
gemini: {
|
|
101
112
|
install: ['npm install -g @google/gemini-cli'],
|
|
102
113
|
login: [],
|
|
103
114
|
loginNote: 'Gemini signs in from its own prompt: run `gemini`, type /auth, pick "Use Gemini API key" (get one at aistudio.google.com/app/apikey) or Google login.',
|
|
104
115
|
interactive: true,
|
|
116
|
+
vendor: 'Google',
|
|
117
|
+
account: 'Signs in with a Google account and has a free tier. A Gemini API key from AI Studio works too.',
|
|
118
|
+
paid: false,
|
|
105
119
|
},
|
|
106
120
|
opencode: {
|
|
107
121
|
install: ['brew install opencode', 'or: npm install -g opencode-ai'],
|
|
108
122
|
login: ['auth', 'login'],
|
|
109
123
|
loginNote: 'Pick a provider and paste its key or complete its OAuth flow.',
|
|
124
|
+
vendor: 'OpenCode',
|
|
125
|
+
account: 'Brings no model of its own: you point it at a provider you already use, in the cloud or on this computer.',
|
|
126
|
+
paid: null,
|
|
110
127
|
},
|
|
111
128
|
};
|
|
112
129
|
|
|
130
|
+
// Installing a CLI from the room itself. npm is the common denominator: every one of these
|
|
131
|
+
// ships an npm package, and whoever reached MADRE through npx already has npm on the machine.
|
|
132
|
+
// The prose in AGENT_SETUP.install stays as the alternatives a human may prefer.
|
|
133
|
+
export const AGENT_PACKAGE = {
|
|
134
|
+
codex: '@openai/codex',
|
|
135
|
+
claude: '@anthropic-ai/claude-code',
|
|
136
|
+
gemini: '@google/gemini-cli',
|
|
137
|
+
opencode: 'opencode-ai',
|
|
138
|
+
};
|
|
139
|
+
// One honest line per agent about the account it needs, for the bridge and for CONNECTIONS.
|
|
140
|
+
// Which agents take a pasted key instead of a browser flow.
|
|
141
|
+
export const TAKES_KEY = new Set(['gemini', 'opencode']);
|
|
142
|
+
|
|
143
|
+
export function accountNoteFor(id) {
|
|
144
|
+
// Said in the room's language when it is asked for, not when this file is imported.
|
|
145
|
+
if (id === 'madre') return { account: t("Free and local through Ollama: no account, no tokens. It answers from the room's memory."), paid: false, vendor: 'MADRE' };
|
|
146
|
+
const setup = AGENT_SETUP[id];
|
|
147
|
+
return setup ? { account: t(setup.account), paid: setup.paid, vendor: setup.vendor } : null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// npm cannot always write to the system folders: with Node installed from its own installer,
|
|
151
|
+
// a global install asks for an administrator. Rather than send the human to a terminal with
|
|
152
|
+
// sudo, MADRE installs into a folder of its own and finds the CLI there.
|
|
153
|
+
export const NEEDS_ADMIN = /EACCES|EPERM|permission denied|Missing write access|operation not permitted|npm ERR! code E401.*sudo|need (?:root|sudo)/i;
|
|
154
|
+
export const looksLikeAdminProblem = (output) => NEEDS_ADMIN.test(String(output ?? ''));
|
|
155
|
+
|
|
156
|
+
export function installPlanFor(agent, { prefix = null } = {}) {
|
|
157
|
+
const name = AGENT_PACKAGE[agent?.id];
|
|
158
|
+
if (!name) return null;
|
|
159
|
+
return {
|
|
160
|
+
package: name,
|
|
161
|
+
command: 'npm',
|
|
162
|
+
args: ['install', '-g', name, '--no-fund', '--no-audit'],
|
|
163
|
+
display: `npm install -g ${name}`,
|
|
164
|
+
// The same install, into MADRE's own prefix: no administrator, nothing outside ~/.pulse.
|
|
165
|
+
fallback: prefix ? { command: 'npm', args: ['install', '-g', name, '--prefix', prefix, '--no-fund', '--no-audit'], display: `npm install -g ${name} --prefix ${prefix}` } : null,
|
|
166
|
+
alternatives: (AGENT_SETUP[agent.id]?.install ?? []).slice(1),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
113
170
|
// How the room can (re)connect an agent. Codex and Claude sign in with a
|
|
114
171
|
// browser flow their own CLI drives, so the server can run them and stream the
|
|
115
172
|
// URL; Gemini and OpenCode need their interactive prompt, so the user gets the
|
package/src/chats.mjs
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// A project has one memory and many conversations.
|
|
2
|
+
//
|
|
3
|
+
// The archive, the crew, the modules and the privacy list belong to the project: they are what
|
|
4
|
+
// MADRE knows about this codebase, and they do not start over because somebody opened a new
|
|
5
|
+
// thread. A conversation is only the record of one line of work — its own ledger, its own
|
|
6
|
+
// transcript, its own window — and everything it says feeds the same archive.
|
|
7
|
+
//
|
|
8
|
+
// The first conversation is the ledger that was always there, kept exactly where it was: no file
|
|
9
|
+
// is moved to add this, so a room that existed before opens as it always did and simply gains a
|
|
10
|
+
// name. New conversations get a folder of their own next to it.
|
|
11
|
+
//
|
|
12
|
+
// <room>/events.jsonl the first conversation
|
|
13
|
+
// <room>/chats/<id>/events.jsonl every one after it
|
|
14
|
+
// <room>/chats.json their names, and which one is open
|
|
15
|
+
//
|
|
16
|
+
// Nothing here starts a room or reads a ledger. It keeps the list.
|
|
17
|
+
|
|
18
|
+
import { readFile, writeFile, mkdir, rm, readdir, open, stat } from 'node:fs/promises';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import { randomUUID } from 'node:crypto';
|
|
21
|
+
|
|
22
|
+
export const MAIN_CHAT = 'main';
|
|
23
|
+
export const CHAT_TITLE_MAX = 80;
|
|
24
|
+
const FILE = 'chats.json';
|
|
25
|
+
|
|
26
|
+
export const isChatId = (id) => typeof id === 'string' && (id === MAIN_CHAT || /^[a-z0-9]{8,32}$/.test(id));
|
|
27
|
+
|
|
28
|
+
// Where one conversation's ledger lives. The first one never moved.
|
|
29
|
+
export function chatLedger(roomDir, id) {
|
|
30
|
+
return id === MAIN_CHAT ? join(roomDir, 'events.jsonl') : join(roomDir, 'chats', id, 'events.jsonl');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function readIndex(roomDir) {
|
|
34
|
+
try {
|
|
35
|
+
const raw = JSON.parse(await readFile(join(roomDir, FILE), 'utf8'));
|
|
36
|
+
if (raw && typeof raw === 'object' && raw.chats && typeof raw.chats === 'object') return raw;
|
|
37
|
+
} catch { /* no index yet, or an unreadable one: the room still opens */ }
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function writeIndex(roomDir, index) {
|
|
42
|
+
await mkdir(roomDir, { recursive: true }).catch(() => {});
|
|
43
|
+
await writeFile(join(roomDir, FILE), JSON.stringify(index, null, 2));
|
|
44
|
+
return index;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The list, with the first conversation always in it. A room that predates conversations reads
|
|
48
|
+
// as one conversation, which is what it was.
|
|
49
|
+
export async function chatIndex(roomDir) {
|
|
50
|
+
const index = await readIndex(roomDir);
|
|
51
|
+
const now = new Date().toISOString();
|
|
52
|
+
if (!index) return { active: MAIN_CHAT, chats: { [MAIN_CHAT]: { title: null, createdAt: now, updatedAt: now, messages: 0, preview: null } } };
|
|
53
|
+
if (!index.chats[MAIN_CHAT]) index.chats[MAIN_CHAT] = { title: null, createdAt: now, updatedAt: now, messages: 0, preview: null };
|
|
54
|
+
if (!index.chats[index.active]) index.active = MAIN_CHAT;
|
|
55
|
+
return index;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const shape = (id, entry, active) => ({
|
|
59
|
+
id,
|
|
60
|
+
title: entry.title || (id === MAIN_CHAT ? 'First conversation' : 'Untitled'),
|
|
61
|
+
named: Boolean(entry.title),
|
|
62
|
+
createdAt: entry.createdAt ?? null,
|
|
63
|
+
updatedAt: entry.updatedAt ?? entry.createdAt ?? null,
|
|
64
|
+
messages: Number(entry.messages ?? 0),
|
|
65
|
+
preview: entry.preview ?? null,
|
|
66
|
+
active: id === active,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Newest first, because that is the order a person looks for a conversation in.
|
|
70
|
+
export async function listChats(roomDir) {
|
|
71
|
+
const index = await chatIndex(roomDir);
|
|
72
|
+
const chats = Object.entries(index.chats)
|
|
73
|
+
.map(([id, entry]) => shape(id, entry, index.active))
|
|
74
|
+
.sort((a, b) => String(b.updatedAt ?? '').localeCompare(String(a.updatedAt ?? '')));
|
|
75
|
+
return { active: index.active, chats };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function createChat(roomDir, { title = null, now = new Date().toISOString() } = {}) {
|
|
79
|
+
const index = await chatIndex(roomDir);
|
|
80
|
+
const id = randomUUID().replace(/-/g, '').slice(0, 16);
|
|
81
|
+
index.chats[id] = { title: title ? String(title).slice(0, CHAT_TITLE_MAX) : null, createdAt: now, updatedAt: now, messages: 0, preview: null };
|
|
82
|
+
index.active = id;
|
|
83
|
+
await mkdir(join(roomDir, 'chats', id), { recursive: true });
|
|
84
|
+
await writeIndex(roomDir, index);
|
|
85
|
+
return shape(id, index.chats[id], index.active);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function openChat(roomDir, id) {
|
|
89
|
+
const index = await chatIndex(roomDir);
|
|
90
|
+
if (!index.chats[id]) return null;
|
|
91
|
+
index.active = id;
|
|
92
|
+
await writeIndex(roomDir, index);
|
|
93
|
+
return shape(id, index.chats[id], index.active);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function renameChat(roomDir, id, title) {
|
|
97
|
+
const index = await chatIndex(roomDir);
|
|
98
|
+
if (!index.chats[id]) return null;
|
|
99
|
+
const named = String(title ?? '').trim().slice(0, CHAT_TITLE_MAX);
|
|
100
|
+
index.chats[id].title = named || null;
|
|
101
|
+
await writeIndex(roomDir, index);
|
|
102
|
+
return shape(id, index.chats[id], index.active);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Deleting a conversation takes its transcript and nothing else: whatever the archivist distilled
|
|
106
|
+
// from it is the project's memory, not the conversation's, and it stays. The last conversation
|
|
107
|
+
// cannot be deleted — a room always has somewhere to talk.
|
|
108
|
+
export async function deleteChat(roomDir, id) {
|
|
109
|
+
const index = await chatIndex(roomDir);
|
|
110
|
+
if (!index.chats[id]) return null;
|
|
111
|
+
if (Object.keys(index.chats).length < 2) return { error: 'A room always has one conversation. Start another before deleting this one.' };
|
|
112
|
+
delete index.chats[id];
|
|
113
|
+
if (index.active === id) index.active = Object.keys(index.chats)[0];
|
|
114
|
+
if (id === MAIN_CHAT) await rm(join(roomDir, 'events.jsonl'), { force: true });
|
|
115
|
+
else await rm(join(roomDir, 'chats', id), { recursive: true, force: true, maxRetries: 6, retryDelay: 60 });
|
|
116
|
+
await writeIndex(roomDir, index);
|
|
117
|
+
return { deleted: id, active: index.active };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// What the list shows about a conversation, kept as it happens rather than by reading every
|
|
121
|
+
// ledger to draw a sidebar. A conversation with no name of its own takes the first thing the
|
|
122
|
+
// human said in it, which is what they will look for.
|
|
123
|
+
export async function touchChat(roomDir, id, { text = null, now = new Date().toISOString(), counts = true } = {}) {
|
|
124
|
+
const index = await chatIndex(roomDir);
|
|
125
|
+
const entry = index.chats[id];
|
|
126
|
+
if (!entry) return null;
|
|
127
|
+
entry.updatedAt = now;
|
|
128
|
+
if (counts) entry.messages = Number(entry.messages ?? 0) + 1;
|
|
129
|
+
if (text) {
|
|
130
|
+
const line = String(text).replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
131
|
+
if (line) {
|
|
132
|
+
entry.preview = line;
|
|
133
|
+
if (!entry.title) entry.title = line.slice(0, CHAT_TITLE_MAX);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
await writeIndex(roomDir, index);
|
|
137
|
+
return shape(id, entry, index.active);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Conversations whose folder is on disk but which no index knows about: a room copied by hand, or
|
|
141
|
+
// an index lost. They are listed rather than ignored, because a transcript is not disposable.
|
|
142
|
+
export async function adoptStrays(roomDir) {
|
|
143
|
+
let folders = [];
|
|
144
|
+
try { folders = await readdir(join(roomDir, 'chats')); } catch { return { adopted: [] }; }
|
|
145
|
+
const index = await chatIndex(roomDir);
|
|
146
|
+
const adopted = [];
|
|
147
|
+
for (const id of folders) {
|
|
148
|
+
if (!isChatId(id) || index.chats[id]) continue;
|
|
149
|
+
index.chats[id] = { title: null, createdAt: null, updatedAt: null, messages: 0, preview: null };
|
|
150
|
+
adopted.push(id);
|
|
151
|
+
}
|
|
152
|
+
if (adopted.length) await writeIndex(roomDir, index);
|
|
153
|
+
return { adopted };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// The last sequence a ledger reached, read from its tail rather than by loading it. A project
|
|
157
|
+
// numbers its exchanges once across every conversation, so opening one has to know where the
|
|
158
|
+
// others got to; doing that by reading whole ledgers would make opening a conversation cost more
|
|
159
|
+
// the longer the project has been worked in.
|
|
160
|
+
export async function lastSequenceOf(file, { window = 65536 } = {}) {
|
|
161
|
+
let handle;
|
|
162
|
+
try {
|
|
163
|
+
const size = (await stat(file)).size;
|
|
164
|
+
if (!size) return 0;
|
|
165
|
+
handle = await open(file, 'r');
|
|
166
|
+
const length = Math.min(window, size);
|
|
167
|
+
const buffer = Buffer.alloc(length);
|
|
168
|
+
await handle.read(buffer, 0, length, size - length);
|
|
169
|
+
const lines = buffer.toString('utf8').split('\n').filter(Boolean);
|
|
170
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
171
|
+
try {
|
|
172
|
+
const event = JSON.parse(lines[i]);
|
|
173
|
+
if (Number.isInteger(event?.sequence)) return event.sequence;
|
|
174
|
+
} catch { /* a half line at the window's edge, or a torn write: keep looking back */ }
|
|
175
|
+
}
|
|
176
|
+
return 0;
|
|
177
|
+
} catch {
|
|
178
|
+
return 0;
|
|
179
|
+
} finally {
|
|
180
|
+
await handle?.close().catch(() => {});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Where the project's numbering stands, across every conversation but the one being opened.
|
|
185
|
+
export async function projectFloor(roomDir, { except = null } = {}) {
|
|
186
|
+
const { chats } = await listChats(roomDir);
|
|
187
|
+
let floor = 0;
|
|
188
|
+
for (const chat of chats) {
|
|
189
|
+
if (chat.id === except) continue;
|
|
190
|
+
floor = Math.max(floor, await lastSequenceOf(chatLedger(roomDir, chat.id)));
|
|
191
|
+
}
|
|
192
|
+
return floor;
|
|
193
|
+
}
|
package/src/checkpoint.mjs
CHANGED
|
@@ -86,7 +86,7 @@ export async function worktreeTree(root, { exclude = [] } = {}) {
|
|
|
86
86
|
}
|
|
87
87
|
return (await git(root, ['write-tree'], { env })).trim();
|
|
88
88
|
} finally {
|
|
89
|
-
await rm(dir, { recursive: true, force: true });
|
|
89
|
+
await rm(dir, { recursive: true, force: true, maxRetries: 6, retryDelay: 60 });
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
92
|
|
package/src/cold.mjs
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Cold zones: the part of the archive nothing has ever reached for.
|
|
2
|
+
//
|
|
3
|
+
// A memory is cold when three things are true at once, and the three together are what make the
|
|
4
|
+
// claim strong enough to act on:
|
|
5
|
+
//
|
|
6
|
+
// · no turn has ever carried it
|
|
7
|
+
// · it shares a subject with no other memory, so nothing can reach it sideways either
|
|
8
|
+
// · the room has reached into the archive often enough since it was written that it has plainly
|
|
9
|
+
// had its chances
|
|
10
|
+
//
|
|
11
|
+
// That third one is the whole point. Never-recalled is not cold; never-recalled is what every
|
|
12
|
+
// memory is on the day it is written. What makes a memory cold is opportunity that went by: the
|
|
13
|
+
// archive was opened forty times and it was never the answer. Chances are counted in turns that
|
|
14
|
+
// actually reached into the archive, because those are the only chances that existed.
|
|
15
|
+
//
|
|
16
|
+
// Pure functions. The room hands in what it already has.
|
|
17
|
+
|
|
18
|
+
export const COLD_CHANCES = 12; // turns that went into the archive without ever taking it
|
|
19
|
+
|
|
20
|
+
// How many of `times` (ascending) are at or after `from`.
|
|
21
|
+
function after(times, from) {
|
|
22
|
+
let low = 0;
|
|
23
|
+
let high = times.length;
|
|
24
|
+
while (low < high) {
|
|
25
|
+
const middle = (low + high) >> 1;
|
|
26
|
+
if (times[middle] < from) low = middle + 1; else high = middle;
|
|
27
|
+
}
|
|
28
|
+
return times.length - low;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Which memories are adrift, and how many chances each one has had. `since` is the day this room
|
|
32
|
+
// started keeping the trail: a memory older than that is only counted from there, because what
|
|
33
|
+
// happened before it was never written down and must not be held against the note.
|
|
34
|
+
export function coldNotes({ notes = [], links = [], batches = [], since = null, chances = COLD_CHANCES } = {}) {
|
|
35
|
+
const linked = new Set(links.flatMap((link) => [link.a, link.b]));
|
|
36
|
+
const times = batches.map((at) => Date.parse(at)).filter((at) => Number.isFinite(at)).sort((a, b) => a - b);
|
|
37
|
+
const floor = since ? Date.parse(since) : 0;
|
|
38
|
+
const cold = new Map();
|
|
39
|
+
for (const note of notes) {
|
|
40
|
+
if (!note || note.kind === 'aberration' || note.refutedBy) continue; // a refutation is not a cold memory
|
|
41
|
+
if (Number(note.recalled ?? 0) > 0) continue;
|
|
42
|
+
if (linked.has(note.id)) continue;
|
|
43
|
+
const written = Date.parse(note.created);
|
|
44
|
+
const from = Math.max(Number.isFinite(written) ? written : 0, Number.isFinite(floor) ? floor : 0);
|
|
45
|
+
const had = after(times, from);
|
|
46
|
+
if (had >= chances) cold.set(note.id, { chances: had });
|
|
47
|
+
}
|
|
48
|
+
return cold;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// What the room should say about it. Never a number on its own: a count of cold memories means
|
|
52
|
+
// nothing without what it is a count of.
|
|
53
|
+
export function coldReading(cold, notes = []) {
|
|
54
|
+
const standing = notes.filter((note) => note && note.kind !== 'aberration' && !note.refutedBy).length;
|
|
55
|
+
return { count: cold.size, standing, share: standing > 0 ? Number((cold.size / standing).toFixed(3)) : 0 };
|
|
56
|
+
}
|
package/src/commands.mjs
CHANGED
|
@@ -52,6 +52,12 @@ export const COMMANDS = [
|
|
|
52
52
|
// The human commits what the room produced. Everything in the tree, one message, local: reversible with git.
|
|
53
53
|
const message = args.slice(1).join(' ').replace(/^["'“]+|["'”]+$/g, '').trim();
|
|
54
54
|
if (!message) return { ok: false, title: 'Git Pulse · commit', text: 'Give the commit a message: /git commit "what and why".' };
|
|
55
|
+
// Without an identity git refuses the commit with a wall of advice; say the one thing to do.
|
|
56
|
+
const email = await run('git', ['config', '--get', 'user.email'], projectRoot);
|
|
57
|
+
const who = await run('git', ['config', '--get', 'user.name'], projectRoot);
|
|
58
|
+
if (!email.text.trim() || !who.text.trim()) {
|
|
59
|
+
return { ok: false, title: 'Git Pulse · commit', text: 'This computer has no git identity, so the commit would have no author. Set it once in your terminal:\n\n git config --global user.name "Your Name"\n git config --global user.email you@example.com' };
|
|
60
|
+
}
|
|
55
61
|
const staged = await run('git', ['add', '-A', '--', '.'], projectRoot);
|
|
56
62
|
if (!staged.ok) return { ok: false, title: 'Git Pulse · commit', text: staged.text };
|
|
57
63
|
const committed = await run('git', ['-c', 'color.ui=never', 'commit', '-m', message], projectRoot);
|
|
@@ -19,14 +19,44 @@ export function messageEntry(event) {
|
|
|
19
19
|
|
|
20
20
|
// `omitSynthetic` drops MADRE's own canned replies (identity, refusals, round-table plans):
|
|
21
21
|
// @madre must never read them back, or a small model starts echoing them.
|
|
22
|
-
|
|
22
|
+
// How much of the budget a window is cut back to when it finally has to let go of its oldest
|
|
23
|
+
// messages. Dropping one message per turn would move the start of the transcript on every turn;
|
|
24
|
+
// dropping a chunk at once and then holding still is what lets a CLI read most of it back from
|
|
25
|
+
// its own cache. Lower means longer stretches of stability and a leaner average window.
|
|
26
|
+
export const CONTEXT_KEEP = 0.62;
|
|
27
|
+
|
|
28
|
+
export function buildConversationContext(events, { excludeMessageId, maxChars = 16000, omitSynthetic = false, anchor = null } = {}) {
|
|
23
29
|
const messages = events
|
|
24
30
|
.map((event) => (omitSynthetic && event?.payload?.synthetic ? null : messageEntry(event)))
|
|
25
31
|
.filter((message) => message && message.messageId !== excludeMessageId);
|
|
32
|
+
const budget = Math.max(0, Number(maxChars) || 0);
|
|
33
|
+
|
|
34
|
+
// The window is anchored, not sliding. A sliding window starts one message later on every
|
|
35
|
+
// turn, so the transcript an agent reads begins with different words every time and none of
|
|
36
|
+
// it can be matched against what it read last turn. Held still, the whole of it but the tail
|
|
37
|
+
// is the same bytes as before, which is the difference between paying for it once and paying
|
|
38
|
+
// for it every turn. It only ever moves when what has been said since no longer fits.
|
|
39
|
+
const weigh = (from) => {
|
|
40
|
+
let used = 0;
|
|
41
|
+
for (let i = from; i < messages.length; i += 1) used += messages[i].sender.length + messages[i].role.length + messages[i].text.length + 4;
|
|
42
|
+
return used;
|
|
43
|
+
};
|
|
44
|
+
let start = 0;
|
|
45
|
+
if (Number.isInteger(anchor)) {
|
|
46
|
+
const held = messages.findIndex((message) => message.sequence >= anchor);
|
|
47
|
+
if (held >= 0 && weigh(held) <= budget) start = held;
|
|
48
|
+
}
|
|
49
|
+
if (!start || weigh(start) > budget) {
|
|
50
|
+
// Let go of a chunk and then hold: back off until the window sits well under its budget, so
|
|
51
|
+
// there is room for many turns of new words before it has to move again.
|
|
52
|
+
start = messages.length;
|
|
53
|
+
while (start > 0 && weigh(start - 1) <= budget * CONTEXT_KEEP) start -= 1;
|
|
54
|
+
}
|
|
55
|
+
|
|
26
56
|
const selected = [];
|
|
27
|
-
let remaining =
|
|
57
|
+
let remaining = budget;
|
|
28
58
|
|
|
29
|
-
for (let index = messages.length - 1; index >=
|
|
59
|
+
for (let index = messages.length - 1; index >= start && remaining > 0; index -= 1) {
|
|
30
60
|
const message = messages[index];
|
|
31
61
|
const label = `${message.sender} (${message.role})`;
|
|
32
62
|
const allowance = Math.max(0, remaining - label.length - 2);
|
|
@@ -42,6 +72,8 @@ export function buildConversationContext(events, { excludeMessageId, maxChars =
|
|
|
42
72
|
|
|
43
73
|
return {
|
|
44
74
|
messages: selected,
|
|
75
|
+
// Where this window begins, for the next turn to hold on to.
|
|
76
|
+
anchor: selected[0]?.sequence ?? null,
|
|
45
77
|
omittedMessages: messages.length - selected.length,
|
|
46
78
|
firstSequence: selected[0]?.sequence ?? null,
|
|
47
79
|
throughSequence: selected.at(-1)?.sequence ?? null,
|