@jossuealcala/madre 0.3.3 → 0.4.1
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 +497 -3
- package/CONTRIBUTING.md +3 -1
- package/README.md +68 -186
- 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 +3979 -867
- package/public/es.js +2258 -0
- package/public/i18n.js +66 -0
- package/public/index.html +96 -15
- package/public/inquiry.js +220 -0
- package/public/resay.js +77 -0
- package/public/styles.css +622 -65
- package/public/troubleshooting.js +255 -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 +79 -20
- 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 +36 -3
- 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 +10 -4
- package/src/modules/index.mjs +141 -9
- package/src/modules/ollama.mjs +66 -10
- package/src/modules/playwright.mjs +44 -23
- 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 +297 -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/sentinel-errors.mjs +19 -1
- package/src/server.mjs +709 -71
- 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/adapters/claude.mjs
CHANGED
|
@@ -101,8 +101,9 @@ export function parseClaudeOutput(output) {
|
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
export function invokeClaude({ executable, projectRoot, prompt, timeoutMs = 120000, signal, model = null, attachments = [], lease = null, scopes = null, imageStudio = null, memoryServer = null, mcpServers = [] }) {
|
|
104
|
+
export function invokeClaude({ executable, projectRoot, prompt, timeoutMs = 120000, signal, model = null, attachments = [], lease = null, scopes = null, imageStudio = null, memoryServer = null, mcpServers = [], onProgress = null }) {
|
|
105
105
|
return runReadonlyProcess({
|
|
106
|
+
onProgress: onProgress ? (out) => onProgress({ chars: (() => { try { return String(JSON.parse(out)?.result ?? '').length; } catch { return 0; } })() }) : null,
|
|
106
107
|
executable,
|
|
107
108
|
args: buildClaudeArgs({ prompt, model, attachmentsDir: attachments[0]?.dir ?? null, lease, scopes, imageStudio, memoryServer, mcpServers }),
|
|
108
109
|
cwd: projectRoot,
|
package/src/adapters/codex.mjs
CHANGED
|
@@ -73,8 +73,9 @@ export function parseCodexOutput(output) {
|
|
|
73
73
|
return { text: text.trim(), usage };
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
export function invokeCodex({ executable, projectRoot, prompt, timeoutMs = 120000, signal, model = null, attachments = [], lease = null, scopes = null, memoryServer = null, mcpServers = [] }) {
|
|
76
|
+
export function invokeCodex({ executable, projectRoot, prompt, timeoutMs = 120000, signal, model = null, attachments = [], lease = null, scopes = null, memoryServer = null, mcpServers = [], onProgress = null }) {
|
|
77
77
|
return runReadonlyProcess({
|
|
78
|
+
onProgress: onProgress ? (out) => onProgress({ chars: parseCodexOutput(out).text.length }) : null,
|
|
78
79
|
executable,
|
|
79
80
|
args: buildCodexArgs({ projectRoot, prompt, model, attachments, lease, scopes, memoryServer, mcpServers }),
|
|
80
81
|
cwd: lease ? lease.outDir : projectRoot,
|
package/src/adapters/gemini.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { t } from '../i18n.mjs';
|
|
2
3
|
import { homedir, tmpdir } from 'node:os';
|
|
3
4
|
import { join } from 'node:path';
|
|
4
5
|
import { runReadonlyProcess } from './process.mjs';
|
|
@@ -215,7 +216,7 @@ export function parseGeminiOutput(output) {
|
|
|
215
216
|
export async function cleanupRuntimeRoot(runtimeRoot, { attempts = 6, delayMs = 250 } = {}) {
|
|
216
217
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
217
218
|
try {
|
|
218
|
-
await rm(runtimeRoot, { recursive: true, force: true });
|
|
219
|
+
await rm(runtimeRoot, { recursive: true, force: true, maxRetries: 6, retryDelay: 60 });
|
|
219
220
|
return true;
|
|
220
221
|
} catch (error) {
|
|
221
222
|
if (attempt === attempts) {
|
|
@@ -234,21 +235,21 @@ export async function cleanupRuntimeRoot(runtimeRoot, { attempts = 6, delayMs =
|
|
|
234
235
|
export function diagnoseGeminiStderr(stderr) {
|
|
235
236
|
const text = String(stderr ?? '');
|
|
236
237
|
if (/prepayment credits are depleted/i.test(text)) {
|
|
237
|
-
return { code: 'CREDITS_DEPLETED', message: 'Google says the AI Studio project behind this Gemini key has no prepaid credits left; every request is refused (HTTP 429) until it is topped up.', hint: 'Add credits at https://ai.studio/projects, or switch the Gemini CLI to another key.' };
|
|
238
|
+
return { code: 'CREDITS_DEPLETED', message: t('Google says the AI Studio project behind this Gemini key has no prepaid credits left; every request is refused (HTTP 429) until it is topped up.'), hint: t('Add credits at https://ai.studio/projects, or switch the Gemini CLI to another key.') };
|
|
238
239
|
}
|
|
239
240
|
if (/status:\s*429|\b429\b|RESOURCE_EXHAUSTED|rate ?limit|quota exceeded/i.test(text)) {
|
|
240
241
|
const router = /ClassifierStrategy|\.route\b/.test(text);
|
|
241
242
|
return {
|
|
242
243
|
code: 'RATE_LIMITED',
|
|
243
244
|
message: `Google is rate-limiting this Gemini key (HTTP 429)${router ? ' while its "auto" router picked a model' : ''}; the CLI kept retrying with backoff.`,
|
|
244
|
-
hint: 'Wait a minute, or pick an explicit model such as gemini-3-flash-preview to skip the router; check the key\'s quota at aistudio.google.com.',
|
|
245
|
+
hint: t('Wait a minute, or pick an explicit model such as gemini-3-flash-preview to skip the router; check the key\'s quota at aistudio.google.com.'),
|
|
245
246
|
};
|
|
246
247
|
}
|
|
247
248
|
if (/status:?\s*503|UNAVAILABLE|high demand/i.test(text)) {
|
|
248
|
-
return { code: 'UNAVAILABLE', message: 'Google reported the model as unavailable (HTTP 503) and the CLI kept retrying.', hint: 'Try again shortly or choose another model.' };
|
|
249
|
+
return { code: 'UNAVAILABLE', message: t('Google reported the model as unavailable (HTTP 503) and the CLI kept retrying.'), hint: t('Try again shortly or choose another model.') };
|
|
249
250
|
}
|
|
250
251
|
if (/status:\s*40[13]|PERMISSION_DENIED|API key not valid|IneligibleTierError/i.test(text)) {
|
|
251
|
-
return { code: 'AUTH', message: 'Google rejected the Gemini credentials.', hint: 'Run `gemini` and use /auth, or check GEMINI_API_KEY.' };
|
|
252
|
+
return { code: 'AUTH', message: t('Google rejected the Gemini credentials.'), hint: t('Run `gemini` and use /auth, or check GEMINI_API_KEY.') };
|
|
252
253
|
}
|
|
253
254
|
return null;
|
|
254
255
|
}
|
|
@@ -154,8 +154,9 @@ export function parseOpenCodeOutput(output) {
|
|
|
154
154
|
return { text: text.join('').trim(), usage, ...(error ? { error } : {}) };
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
export function invokeOpenCode({ executable, projectRoot, prompt, timeoutMs = 120000, signal, model = null, attachments = [], lease = null, scopes = null, imageStudio = null, memoryServer = null, mcpServers = [] }) {
|
|
157
|
+
export function invokeOpenCode({ executable, projectRoot, prompt, timeoutMs = 120000, signal, model = null, attachments = [], lease = null, scopes = null, imageStudio = null, memoryServer = null, mcpServers = [], onProgress = null }) {
|
|
158
158
|
return runReadonlyProcess({
|
|
159
|
+
onProgress: onProgress ? (out) => onProgress({ chars: out.length }) : null,
|
|
159
160
|
executable,
|
|
160
161
|
args: buildOpenCodeArgs({ projectRoot, prompt, attachments, ...(model ? { model } : {}) }),
|
|
161
162
|
cwd: projectRoot,
|
package/src/adapters/process.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
+
import { t } from '../i18n.mjs';
|
|
2
3
|
|
|
3
4
|
function signalProcessGroup(child, signal) {
|
|
4
5
|
if (!child.pid) return false;
|
|
@@ -24,6 +25,36 @@ export function terminateProcessTree(child, { graceMs = 2000 } = {}) {
|
|
|
24
25
|
child.once('close', () => clearTimeout(escalation));
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
// A number is not a reason. A process that was signalled reports it two ways — node hands the
|
|
29
|
+
// signal itself when it killed the child, and a CLI that handles SIGTERM exits 128+n on its own —
|
|
30
|
+
// and both used to surface as a bare `exited with code 143`, which reads like a crash and is not
|
|
31
|
+
// one: something STOPPED it. Naming the signal is the difference between looking for a bug in the
|
|
32
|
+
// agent and looking for whoever pulled the plug.
|
|
33
|
+
const SIGNAL_MEANING = {
|
|
34
|
+
SIGTERM: 'was stopped (SIGTERM) — something asked it to quit: STOP ALL, a timeout, or the room shutting down',
|
|
35
|
+
SIGKILL: 'was killed outright (SIGKILL) — usually this machine running out of memory',
|
|
36
|
+
SIGINT: 'was interrupted (SIGINT) — a Ctrl+C reached it',
|
|
37
|
+
SIGHUP: 'lost its terminal (SIGHUP)',
|
|
38
|
+
};
|
|
39
|
+
function endedBy(code, closedBy) {
|
|
40
|
+
const name = closedBy ?? (Number.isInteger(code) && code > 128 ? Object.keys(SIGNAL_MEANING).find((key) => code === 128 + { SIGHUP: 1, SIGINT: 2, SIGKILL: 9, SIGTERM: 15 }[key]) : null);
|
|
41
|
+
if (name && SIGNAL_MEANING[name]) return `${SIGNAL_MEANING[name]}.`;
|
|
42
|
+
if (name) return `ended on ${name}.`;
|
|
43
|
+
return `exited with code ${code}.`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// One explanation for a process that never ran, whichever way spawn refused. ENOENT is the
|
|
47
|
+
// ambiguous one: node says it for a missing binary and for a missing working directory alike,
|
|
48
|
+
// so the sentence names both instead of guessing which.
|
|
49
|
+
function couldNotStart(error, { label, executable, cwd }) {
|
|
50
|
+
if (error?.code === 'ENOENT') {
|
|
51
|
+
error.message = `${label} could not start: ${error.message}. Check that the project folder ${cwd} exists and that ${executable} is still installed — a CLI that updates itself can move out from under a room that is already open, and reopening it finds the new one.`;
|
|
52
|
+
} else if (error && !String(error.message ?? '').startsWith(label)) {
|
|
53
|
+
error.message = `${label} could not start: ${error.message}`;
|
|
54
|
+
}
|
|
55
|
+
return error;
|
|
56
|
+
}
|
|
57
|
+
|
|
27
58
|
export function runReadonlyProcess({
|
|
28
59
|
executable,
|
|
29
60
|
args,
|
|
@@ -41,18 +72,33 @@ export function runReadonlyProcess({
|
|
|
41
72
|
label,
|
|
42
73
|
parse,
|
|
43
74
|
signal,
|
|
75
|
+
// Called as output arrives, with everything received so far. The adapter decides what that
|
|
76
|
+
// means: a CLI that streams its answer can be read as it writes, one that hands over a single
|
|
77
|
+
// blob at the end will simply say nothing until then, and saying nothing is the honest answer.
|
|
78
|
+
onProgress = null,
|
|
44
79
|
}) {
|
|
45
80
|
return new Promise((resolve, reject) => {
|
|
46
81
|
if (signal?.aborted) {
|
|
47
|
-
reject(new Error(
|
|
82
|
+
reject(new Error(t('{label} was interrupted before it started: {why}.', { label, why: typeof signal.reason === 'string' ? signal.reason : t('MADRE is shutting down') })));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
// spawn fails two ways, and only one of them used to be explained. The asynchronous one
|
|
86
|
+
// raises 'error' on the child; the synchronous one throws right here, before there is a
|
|
87
|
+
// child to listen to — a binary replaced underneath a running room by an app that updates
|
|
88
|
+
// itself, an option the platform refuses. That path handed the human a bare `spawn … ENOENT`
|
|
89
|
+
// with nothing to act on, which is how a self-updating CLI looked like a broken product.
|
|
90
|
+
let child;
|
|
91
|
+
try {
|
|
92
|
+
child = spawn(executable, args, {
|
|
93
|
+
cwd,
|
|
94
|
+
env,
|
|
95
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
96
|
+
detached: process.platform !== 'win32',
|
|
97
|
+
});
|
|
98
|
+
} catch (error) {
|
|
99
|
+
reject(couldNotStart(error, { label, executable, cwd }));
|
|
48
100
|
return;
|
|
49
101
|
}
|
|
50
|
-
const child = spawn(executable, args, {
|
|
51
|
-
cwd,
|
|
52
|
-
env,
|
|
53
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
54
|
-
detached: process.platform !== 'win32',
|
|
55
|
-
});
|
|
56
102
|
let stdout = '';
|
|
57
103
|
let stderr = '';
|
|
58
104
|
let settled = false;
|
|
@@ -77,8 +123,8 @@ export function runReadonlyProcess({
|
|
|
77
123
|
|
|
78
124
|
const onAbort = () => {
|
|
79
125
|
terminateProcessTree(child, { graceMs: killGraceMs });
|
|
80
|
-
const reason = typeof signal?.reason === 'string' ? signal.reason : 'MADRE is shutting down';
|
|
81
|
-
finish(() => reject(new Error(
|
|
126
|
+
const reason = typeof signal?.reason === 'string' ? signal.reason : t('MADRE is shutting down');
|
|
127
|
+
finish(() => reject(new Error(t('{label} was interrupted: {why}.', { label, why: reason }))));
|
|
82
128
|
};
|
|
83
129
|
const finish = (operation) => {
|
|
84
130
|
if (settled) return;
|
|
@@ -92,7 +138,7 @@ export function runReadonlyProcess({
|
|
|
92
138
|
terminateProcessTree(child, { graceMs: killGraceMs });
|
|
93
139
|
// The last thing the agent said is usually the reason it was slow.
|
|
94
140
|
const lastLine = `${stderr}\n${stdout}`.split('\n').map((line) => line.trim()).filter(Boolean).at(-1);
|
|
95
|
-
const error = new Error(
|
|
141
|
+
const error = new Error(t('{label} did not respond before the timeout ({seconds}s).', { label, seconds: Math.round(timeoutMs / 1000) }) + (lastLine ? t(' Last output: {output}', { output: lastLine.slice(0, 200) }) : ''));
|
|
96
142
|
error.code = 'TIMEOUT';
|
|
97
143
|
error.partialOutput = stdout;
|
|
98
144
|
error.partialStderr = stderr.slice(-2000);
|
|
@@ -103,7 +149,14 @@ export function runReadonlyProcess({
|
|
|
103
149
|
// Only complete stdout lines count as activity: a CLI's stderr spinner or
|
|
104
150
|
// progress noise must not keep a silent model alive past the idle limit.
|
|
105
151
|
// Blank keep-alive lines are not activity either.
|
|
106
|
-
|
|
152
|
+
let toldAt = 0;
|
|
153
|
+
child.stdout.on('data', (chunk) => {
|
|
154
|
+
stdout += chunk;
|
|
155
|
+
if (/\S/.test(String(chunk)) && String(chunk).includes('\n')) lastActivity = Date.now();
|
|
156
|
+
// Throttled: a chatty CLI can produce hundreds of chunks a second and nobody needs to see
|
|
157
|
+
// a number move that fast.
|
|
158
|
+
if (onProgress && Date.now() - toldAt > 400) { toldAt = Date.now(); try { onProgress(stdout); } catch { /* a meter must never break a turn */ } }
|
|
159
|
+
});
|
|
107
160
|
child.stderr.on('data', (chunk) => {
|
|
108
161
|
stderr += chunk;
|
|
109
162
|
if (!watchStderr || settled) return;
|
|
@@ -116,17 +169,23 @@ export function runReadonlyProcess({
|
|
|
116
169
|
finish(() => reject(verdict));
|
|
117
170
|
});
|
|
118
171
|
armIdle();
|
|
119
|
-
child.on('error', (error) => finish(() => {
|
|
120
|
-
|
|
121
|
-
if (error.code === 'ENOENT') {
|
|
122
|
-
error.message = `${label} could not start: ${error.message}. Check that the project folder ${cwd} exists and that ${executable} is still installed.`;
|
|
123
|
-
}
|
|
124
|
-
reject(error);
|
|
125
|
-
}));
|
|
126
|
-
child.on('close', (code) => finish(() => {
|
|
172
|
+
child.on('error', (error) => finish(() => reject(couldNotStart(error, { label, executable, cwd }))));
|
|
173
|
+
child.on('close', (code, closedBy) => finish(() => {
|
|
127
174
|
const response = parse(stdout);
|
|
128
175
|
if (code === 0 && response.text) return resolve(response);
|
|
129
|
-
|
|
176
|
+
// What happened first, what the CLI printed second, and never one dressed as the other.
|
|
177
|
+
// This used to be `stderr.trim() || …`, so anything a CLI wrote on the way out became the
|
|
178
|
+
// reason: a notice about an unrelated setting, a line saying it was reading stdin. The
|
|
179
|
+
// facts that actually diagnose the turn — the exit code, or a clean exit with nothing to
|
|
180
|
+
// show — were discarded by the `||` and never reached the human. A warning is not a cause.
|
|
181
|
+
const printed = stderr.trim();
|
|
182
|
+
const why = response.error ?? (code === 0
|
|
183
|
+
? `${label} exited cleanly without an answer.`
|
|
184
|
+
: `${label} ${endedBy(code, closedBy)}`);
|
|
185
|
+
const error = new Error(printed ? `${why}\n\n${label} printed:\n${printed.slice(-600)}` : why);
|
|
186
|
+
error.partialStderr = printed.slice(-2000);
|
|
187
|
+
error.exitCode = code;
|
|
188
|
+
reject(error);
|
|
130
189
|
}));
|
|
131
190
|
});
|
|
132
191
|
}
|
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
|
|