@jossuealcala/madre 0.2.3 → 0.3.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 +60 -0
- package/CONTRIBUTING.md +31 -2
- package/README.md +145 -187
- package/SECURITY.md +1 -1
- package/bin/madre.mjs +35 -2
- package/docs/INTERNALS.md +75 -0
- package/docs/training/Modelfile +5 -0
- package/docs/training/README.md +47 -0
- package/docs/training/train.sh +17 -0
- package/package.json +5 -3
- package/public/app.js +353 -5
- package/public/brands.js +18 -0
- package/public/index.html +2 -0
- package/public/styles.css +49 -11
- package/public/troubleshooting.js +31 -0
- package/src/adapters/madre.mjs +195 -0
- package/src/auth-probe.mjs +1 -0
- package/src/capabilities.mjs +1 -0
- package/src/conversation-context.mjs +4 -2
- package/src/dataset.mjs +127 -0
- package/src/distiller.mjs +16 -6
- package/src/embeddings.mjs +7 -1
- package/src/event-store.mjs +29 -1
- package/src/extensions.mjs +24 -224
- package/src/memory.mjs +64 -4
- package/src/modules/ahp.mjs +64 -0
- package/src/modules/ashcode.mjs +28 -0
- package/src/modules/git-pulse.mjs +26 -0
- package/src/modules/helpers.mjs +30 -0
- package/src/modules/image-studio.mjs +39 -0
- package/src/modules/index.mjs +21 -0
- package/src/modules/ollama.mjs +56 -0
- package/src/modules/ripley.mjs +20 -0
- package/src/modules/sdk.mjs +78 -0
- package/src/ollama.mjs +118 -0
- package/src/privacy.mjs +109 -0
- package/src/room/archivist.mjs +141 -0
- package/src/room/attachments.mjs +15 -0
- package/src/room/budget.mjs +83 -0
- package/src/room/context.mjs +31 -0
- package/src/room/control.mjs +66 -0
- package/src/room/escalation.mjs +41 -0
- package/src/room/ghost.mjs +32 -0
- package/src/room/guard.mjs +52 -0
- package/src/room/prompt.mjs +66 -0
- package/src/room/vectors.mjs +47 -0
- package/src/room.mjs +190 -392
- package/src/server.mjs +229 -41
- package/src/setup.mjs +25 -7
- package/src/updates.mjs +79 -0
package/src/server.mjs
CHANGED
|
@@ -12,6 +12,10 @@ import { createEmbedder } from './embeddings.mjs';
|
|
|
12
12
|
import { memoryServerFor } from './memory-tools.mjs';
|
|
13
13
|
import { MotherChannel, CODE000_STRIKES } from './mother.mjs';
|
|
14
14
|
import { ErrorSentinel } from './sentinel-errors.mjs';
|
|
15
|
+
import { probeOllama, ollamaEmbedder, ollamaInvoker, pullModel } from './ollama.mjs';
|
|
16
|
+
import { moduleById, describeModules, findModuleRoute } from './modules/index.mjs';
|
|
17
|
+
import { madreAgent, madreInvoker, MADRE_AGENT_ID, MADRE_ADAPTER } from './adapters/madre.mjs';
|
|
18
|
+
import { exportDataset, readiness as datasetReadiness } from './dataset.mjs';
|
|
15
19
|
|
|
16
20
|
const PACKAGE = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8').catch(() => '{}'));
|
|
17
21
|
let crashHandlersInstalled = false;
|
|
@@ -35,9 +39,12 @@ import { QuotaMonitor } from './quota-monitor.mjs';
|
|
|
35
39
|
import { defaultQuotaSources } from './quota-sources.mjs';
|
|
36
40
|
import { Room } from './room.mjs';
|
|
37
41
|
import { applyConfigToEnv, loadConfig } from './config.mjs';
|
|
38
|
-
import { extensionById,
|
|
42
|
+
import { extensionById, runInstaller } from './extensions.mjs';
|
|
39
43
|
import { loginPlanFor, probeAll } from './auth-probe.mjs';
|
|
40
44
|
import { loadConfig as readConfig, updateConfig } from './config.mjs';
|
|
45
|
+
import { Privacy, normalizeTerms, privacySettings } from './privacy.mjs';
|
|
46
|
+
import { checkForUpdate, detectInstall, updateCommand, releaseUrl } from './updates.mjs';
|
|
47
|
+
import { totalmem } from 'node:os';
|
|
41
48
|
import { discoverModels } from './models.mjs';
|
|
42
49
|
import { setImageModule } from './capabilities.mjs';
|
|
43
50
|
import { resolveGeminiKey } from './image-studio.mjs';
|
|
@@ -111,12 +118,14 @@ export async function createPulseServer({
|
|
|
111
118
|
imageKey = resolveGeminiKey,
|
|
112
119
|
// The sentinel's outbound channel; tests hand in a fake.
|
|
113
120
|
reportFetch = globalThis.fetch,
|
|
121
|
+
// Ollama detection; tests hand in a fake probe. PULSE_OLLAMA=0 leaves Ollama alone entirely.
|
|
122
|
+
ollamaProbe = process.env.PULSE_OLLAMA === '0' ? async () => ({ running: false, host: null, models: [], embedModel: null, chatModel: null, disabled: true }) : probeOllama,
|
|
114
123
|
}) {
|
|
115
124
|
const root = stateRoot ?? process.env.PULSE_HOME ?? join(homedir(), '.pulse');
|
|
116
125
|
// ~/.pulse/config.json fills in whatever the environment did not set.
|
|
117
126
|
applyConfigToEnv(await loadConfig(root));
|
|
118
|
-
// RIPLEY renders HTML in the viewer only while the human has it switched on.
|
|
119
|
-
|
|
127
|
+
// RIPLEY renders HTML in the viewer only while the human has it switched on; read live, the switch is the module's.
|
|
128
|
+
const ripleyOn = async () => Boolean((await readConfig(root)).modules?.ripley?.enabled);
|
|
120
129
|
agentTimeouts ??= agentTimeoutsFromEnv();
|
|
121
130
|
const agents = providedAgents ?? await detectAgents();
|
|
122
131
|
const canonicalProjectRoot = await realpath(projectRoot).catch(() => resolve(projectRoot));
|
|
@@ -125,25 +134,93 @@ export async function createPulseServer({
|
|
|
125
134
|
const historicalEvents = await store.readAll();
|
|
126
135
|
// The room's memory: derived from the ledger, rebuilt if missing or stale,
|
|
127
136
|
// and never a reason for the room not to open.
|
|
128
|
-
|
|
137
|
+
// Release channel: one registry read a day, cached for every room on this machine. On by
|
|
138
|
+
// default; PULSE_UPDATE_CHECK=0 or the switch in MU/TH/UR turns it off. Never self-updates.
|
|
139
|
+
const updatesEnabled = () => (process.env.PULSE_UPDATE_CHECK !== undefined ? process.env.PULSE_UPDATE_CHECK !== '0' : updatesConfig.check !== false);
|
|
140
|
+
let updatesConfig = { ...((await readConfig(root)).updates ?? {}) };
|
|
141
|
+
const install = detectInstall({ projectRoot: canonicalProjectRoot });
|
|
142
|
+
async function versionView({ force = false } = {}) {
|
|
143
|
+
const check = await checkForUpdate({ name: PACKAGE.name, current: PACKAGE.version, cacheFile: join(root, 'updates.json'), fetchImpl: reportFetch, enabled: updatesEnabled(), force });
|
|
144
|
+
return { ...check, name: PACKAGE.name, install, command: updateCommand(install, PACKAGE.name, check.latest ?? 'latest'), release: check.latest ? releaseUrl(PACKAGE.repository, check.latest) : null, envWins: process.env.PULSE_UPDATE_CHECK !== undefined };
|
|
145
|
+
}
|
|
146
|
+
// Privacy: the terms that never travel through this room, from config.json and the environment.
|
|
147
|
+
// config.json is shared by every room on this machine, so the list is re-read before each
|
|
148
|
+
// human message and whenever MU/TH/UR looks: a term named in one room guards them all.
|
|
149
|
+
const privacy = new Privacy(privacySettings(await readConfig(root)));
|
|
150
|
+
const refreshPrivacy = async () => { const { terms, marker } = privacySettings(await readConfig(root)); privacy.set(terms, marker); return privacy; };
|
|
151
|
+
const memory = await new RoomMemory(join(roomDir, 'memory.sqlite')).attachPrivacy(privacy).initialize(store)
|
|
129
152
|
.catch((error) => { console.error(`MADRE memory unavailable, turns get the recent window only: ${error.message}`); return null; });
|
|
130
153
|
// Meaning-aware recall through the user's own Gemini key, when there is one;
|
|
131
154
|
// and the memory as MCP tools for every agent's turn.
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
155
|
+
// Ollama, when it is there: local embeddings and a local archivist. Probed at
|
|
156
|
+
// start and again from MODULES; wired live, no restart.
|
|
157
|
+
let ollama = { running: false, host: null, models: [], embedModel: null, chatModel: null };
|
|
158
|
+
const ollamaSettings = async () => ({ enabled: true, embeddings: true, archivist: true, agent: true, ...((await readConfig(root)).modules?.ollama ?? {}) });
|
|
159
|
+
// What the TRAIN card needs: where the recipe ships, the room, the model name @madre will pick up, a base that fits this machine.
|
|
160
|
+
function trainingInfo() {
|
|
161
|
+
const slug = basename(canonicalProjectRoot).toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
|
162
|
+
const gb = Math.round(totalmem() / 1024 ** 3);
|
|
163
|
+
return { roomDir, recipeDir: fileURLToPath(new URL('../docs/training/', import.meta.url)), modelName: `madre-${slug}`, baseModel: gb >= 32 ? 'Qwen/Qwen2.5-3B-Instruct' : 'Qwen/Qwen2.5-1.5B-Instruct', memoryGb: gb };
|
|
164
|
+
}
|
|
165
|
+
async function wireOllama({ probe: doProbe = true } = {}) {
|
|
166
|
+
if (doProbe) ollama = await ollamaProbe();
|
|
167
|
+
// A model trained on this room (docs/training) is named madre-<project>: @madre uses it when it exists.
|
|
168
|
+
const slug = basename(canonicalProjectRoot).toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
|
169
|
+
const trained = (ollama.models ?? []).map((model) => model.name).find((name) => name.startsWith(`madre-${slug}`)) ?? null;
|
|
170
|
+
ollama = { ...ollama, madreModel: trained };
|
|
171
|
+
const settings = await ollamaSettings();
|
|
172
|
+
const useEmbeddings = ollama.running && settings.enabled && settings.embeddings && ollama.embedModel;
|
|
173
|
+
const useArchivist = ollama.running && settings.enabled && settings.archivist && ollama.chatModel;
|
|
174
|
+
if (memory) {
|
|
175
|
+
const key = process.env.PULSE_EMBED === '0' ? null : await imageKey().catch(() => null);
|
|
176
|
+
const embedder = createEmbedder({ key, env: embedEnvFor(await memoryConfig()), ollama: useEmbeddings ? ollama : null, ollamaEmbedder });
|
|
177
|
+
if (room) room.setEmbedder(embedder); else memory.attachEmbedder(embedder);
|
|
178
|
+
}
|
|
179
|
+
if (room) room.setInvoker('ollama', useArchivist ? ollamaInvoker({ host: ollama.host, model: ollama.chatModel }) : null);
|
|
180
|
+
// @madre: the fifth agent, present whenever Ollama has a chat model and the human left it on.
|
|
181
|
+
const fifth = madreAgent({ ...ollama, chatModel: ollama.madreModel ?? ollama.chatModel }, { enabled: settings.enabled && settings.agent !== false });
|
|
182
|
+
const index = agents.findIndex((agent) => agent.id === MADRE_AGENT_ID);
|
|
183
|
+
let changed = false;
|
|
184
|
+
if (fifth.ready && index < 0) { agents.push(fifth); changed = true; }
|
|
185
|
+
else if (fifth.ready && (agents[index].version !== fifth.version)) { Object.assign(agents[index], fifth); changed = true; }
|
|
186
|
+
else if (!fifth.ready && index >= 0) { agents.splice(index, 1); changed = true; }
|
|
187
|
+
if (room) {
|
|
188
|
+
room.setInvoker(MADRE_ADAPTER, fifth.ready ? madreInvoker({ memory, ollama: () => ({ ...ollama, chatModel: ollama.madreModel ?? ollama.chatModel }), fetchImpl: reportFetch }) : null);
|
|
189
|
+
if (changed) await room.record('agents.updated', { agents: agents.map((agent) => ({ id: agent.id, label: agent.label, detected: agent.detected, ready: agent.ready, version: agent.version, local: Boolean(agent.local) })), removed: fifth.ready ? [] : [MADRE_AGENT_ID], reason: fifth.ready ? `@madre is in the room · ${fifth.version}` : '@madre left the room: Ollama has no chat model running' });
|
|
190
|
+
}
|
|
191
|
+
return { ...ollama, settings, embeddings: Boolean(useEmbeddings), archivist: Boolean(useArchivist), agent: fifth.ready };
|
|
135
192
|
}
|
|
193
|
+
// Memory settings the human keeps in config.json (MU/TH/UR → MEMORY); environment still wins at launch.
|
|
194
|
+
const memoryConfig = async () => ({ archivist: 'auto', archivists: null, every: 10, idleMinutes: 10, embedProvider: 'auto', recallShare: 0.3, ...((await readConfig(root)).memory ?? {}) });
|
|
195
|
+
const embedEnvFor = (cfg) => {
|
|
196
|
+
const view = { ...process.env };
|
|
197
|
+
if (!process.env.PULSE_EMBED_PROVIDER && !process.env.PULSE_EMBED) {
|
|
198
|
+
if (cfg.embedProvider === 'off') view.PULSE_EMBED = '0'; else if (cfg.embedProvider && cfg.embedProvider !== 'auto') view.PULSE_EMBED_PROVIDER = cfg.embedProvider;
|
|
199
|
+
}
|
|
200
|
+
return view;
|
|
201
|
+
};
|
|
202
|
+
let room = null;
|
|
203
|
+
await wireOllama();
|
|
136
204
|
const memoryServer = memory ? memoryServerFor({ dbFile: memory.file, projectRoot: canonicalProjectRoot }) : null;
|
|
137
205
|
// MOTHER's channel to the crew: her seal's fingerprint lives in the memory
|
|
138
206
|
// file, so a deleted or edited .pulse/mother.env is noticed.
|
|
139
207
|
const mother = new MotherChannel(join(canonicalProjectRoot, '.pulse', 'mother.env'), { meta: memory ? { get: (key) => memory.metaGet(key), set: (key, value) => memory.metaSet(key, value) } : null });
|
|
140
208
|
const motherOutcome = await mother.load().catch((error) => { console.error(`MADRE could not open MOTHER's channel: ${error.message}`); return null; });
|
|
141
|
-
const
|
|
209
|
+
const startupMemory = await memoryConfig();
|
|
210
|
+
room = new Room({
|
|
142
211
|
store,
|
|
143
212
|
agents,
|
|
144
213
|
projectRoot,
|
|
214
|
+
privacy,
|
|
145
215
|
softTokenBudget,
|
|
146
216
|
contextMaxChars,
|
|
217
|
+
recallShare: Number(process.env.PULSE_RECALL_SHARE ?? startupMemory.recallShare),
|
|
218
|
+
distill: {
|
|
219
|
+
every: Number(process.env.PULSE_DISTILL_EVERY ?? startupMemory.every),
|
|
220
|
+
idleMs: Number(process.env.PULSE_DISTILL_IDLE_MS ?? startupMemory.idleMinutes * 60000),
|
|
221
|
+
agent: process.env.PULSE_DISTILL_AGENT ?? (startupMemory.archivist && startupMemory.archivist !== 'auto' ? startupMemory.archivist : null),
|
|
222
|
+
allowed: Array.isArray(startupMemory.archivists) && startupMemory.archivists.length ? startupMemory.archivists : null,
|
|
223
|
+
},
|
|
147
224
|
memory,
|
|
148
225
|
memoryServer,
|
|
149
226
|
mother: motherOutcome ? mother : null,
|
|
@@ -154,6 +231,7 @@ export async function createPulseServer({
|
|
|
154
231
|
delegation,
|
|
155
232
|
maxPlanSteps,
|
|
156
233
|
});
|
|
234
|
+
await wireOllama({ probe: false });
|
|
157
235
|
const embedKick = memory?.embedder ? setTimeout(() => void room.embedNow(), 2000) : null;
|
|
158
236
|
embedKick?.unref?.();
|
|
159
237
|
if (motherOutcome === 'deleted' || motherOutcome === 'altered') setTimeout(() => void room.motherTampered(motherOutcome).catch(() => {}), 500).unref?.();
|
|
@@ -181,6 +259,21 @@ export async function createPulseServer({
|
|
|
181
259
|
geminiIdleMs: Number(process.env.PULSE_GEMINI_IDLE_MS ?? 90000),
|
|
182
260
|
geminiRetries: Number(process.env.PULSE_GEMINI_RETRIES ?? 1),
|
|
183
261
|
capabilities: room.capabilities(),
|
|
262
|
+
memory: memorySettingsView(),
|
|
263
|
+
updates: { check: updatesEnabled(), envWins: process.env.PULSE_UPDATE_CHECK !== undefined },
|
|
264
|
+
privacy: { terms: privacy.terms, marker: privacy.marker, envWins: privacySettings({}, process.env).envWins },
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
// What MU/TH/UR shows under MEMORY: the live values, who could distil, and what embeds today.
|
|
268
|
+
function memorySettingsView() {
|
|
269
|
+
const distill = room.distillSettings();
|
|
270
|
+
const candidates = [...(distill.ollama ? [{ id: 'ollama', label: `Ollama · ${ollama.chatModel ?? 'local'}`, local: true }] : []), ...agents.filter((agent) => agent.detected && !agent.local).map((agent) => ({ id: agent.id, label: agent.label, local: false }))];
|
|
271
|
+
return {
|
|
272
|
+
enabled: distill.enabled, every: distill.every, idleMinutes: Math.round(distill.idleMs / 60000), archivist: distill.agent ?? 'auto', archivists: distill.allowed, recallShare: distill.recallShare,
|
|
273
|
+
candidates, embedder: memory?.embedder?.model ?? null, embedProvider: process.env.PULSE_EMBED === '0' ? 'off' : (process.env.PULSE_EMBED_PROVIDER ?? null),
|
|
274
|
+
ollama: { running: ollama.running, embedModel: ollama.embedModel, chatModel: ollama.chatModel },
|
|
275
|
+
stats: room.memoryStats() ? { entries: room.memoryStats().entries, memories: room.memoryStats().memories, pending: room.memoryStats().pending } : null,
|
|
276
|
+
envWins: Boolean(process.env.PULSE_DISTILL_EVERY || process.env.PULSE_DISTILL_AGENT || process.env.PULSE_RECALL_SHARE || process.env.PULSE_EMBED_PROVIDER || process.env.PULSE_EMBED),
|
|
184
277
|
};
|
|
185
278
|
}
|
|
186
279
|
async function applySettings(patch) {
|
|
@@ -208,6 +301,21 @@ export async function createPulseServer({
|
|
|
208
301
|
if (Number(patch.room.maxPlanSteps) > 0) { config.room.maxPlanSteps = Number(patch.room.maxPlanSteps); live.maxPlanSteps = Number(patch.room.maxPlanSteps); }
|
|
209
302
|
if (Number(patch.room.softTokenBudget) > 0) { config.room.softTokenBudget = Number(patch.room.softTokenBudget); live.softTokenBudget = Number(patch.room.softTokenBudget); }
|
|
210
303
|
}
|
|
304
|
+
if (patch.memory && typeof patch.memory === 'object') {
|
|
305
|
+
const current = await memoryConfig();
|
|
306
|
+
const next = { ...current };
|
|
307
|
+
const m = patch.memory;
|
|
308
|
+
if (Number(m.every) >= 1) next.every = Math.trunc(Number(m.every));
|
|
309
|
+
if (Number(m.idleMinutes) >= 1) next.idleMinutes = Math.trunc(Number(m.idleMinutes));
|
|
310
|
+
if (typeof m.archivist === 'string') next.archivist = m.archivist === 'auto' ? 'auto' : m.archivist;
|
|
311
|
+
if (Array.isArray(m.archivists)) next.archivists = m.archivists.length ? m.archivists.map(String) : null;
|
|
312
|
+
if (['auto', 'ollama', 'gemini', 'off'].includes(m.embedProvider)) next.embedProvider = m.embedProvider;
|
|
313
|
+
if (Number.isFinite(Number(m.recallShare))) next.recallShare = Math.min(0.6, Math.max(0, Number(m.recallShare)));
|
|
314
|
+
if (typeof m.enabled === 'boolean') next.enabled = m.enabled;
|
|
315
|
+
config.memory = next;
|
|
316
|
+
room.configureDistill({ every: next.every, idleMs: next.idleMinutes * 60000, agent: next.archivist === 'auto' ? null : next.archivist, allowed: next.archivists, recallShare: next.recallShare, ...(typeof next.enabled === 'boolean' ? { enabled: next.enabled } : {}) });
|
|
317
|
+
live.memory = true;
|
|
318
|
+
}
|
|
211
319
|
if (patch.scopes && typeof patch.scopes === 'object') {
|
|
212
320
|
const current = (await readConfig(root)).scopes ?? {};
|
|
213
321
|
config.scopes = { ...current };
|
|
@@ -226,6 +334,7 @@ export async function createPulseServer({
|
|
|
226
334
|
}
|
|
227
335
|
room.configure(live);
|
|
228
336
|
await updateConfig(root, config);
|
|
337
|
+
if (live.memory) await wireOllama({ probe: false });
|
|
229
338
|
await room.record('room.settings', { changed: Object.keys(config), settings: effectiveSettings() });
|
|
230
339
|
return effectiveSettings();
|
|
231
340
|
}
|
|
@@ -369,38 +478,52 @@ export async function createPulseServer({
|
|
|
369
478
|
poller.unref();
|
|
370
479
|
|
|
371
480
|
let installing = null;
|
|
372
|
-
|
|
481
|
+
// The context a module works in: the project, its settings, the room, and what this server offers.
|
|
482
|
+
async function moduleContext() {
|
|
483
|
+
const config = await readConfig(root);
|
|
484
|
+
return {
|
|
485
|
+
projectRoot: canonicalProjectRoot, stateRoot: root, config, env: process.env, agents, room,
|
|
486
|
+
readConfig: () => readConfig(root),
|
|
487
|
+
updateConfig: (patch) => updateConfig(root, patch),
|
|
488
|
+
record: (type, payload) => room.record(type, payload),
|
|
489
|
+
services: {
|
|
490
|
+
imageKey,
|
|
491
|
+
setImageModule,
|
|
492
|
+
ollama: {
|
|
493
|
+
state: () => ollama,
|
|
494
|
+
wire: (options) => wireOllama(options),
|
|
495
|
+
// Pulls stream into the room like a module install; one at a time.
|
|
496
|
+
pull: async (model) => {
|
|
497
|
+
if (installing) return { ok: false, error: `Another install is running (${installing}).` };
|
|
498
|
+
installing = 'ollama';
|
|
499
|
+
await room.record('extension.install.started', { id: 'ollama', name: 'OLLAMA', command: `ollama pull ${model}`, platforms: [], alreadyInstalled: false });
|
|
500
|
+
void (async () => {
|
|
501
|
+
let pending = [];
|
|
502
|
+
let flushTimer = null;
|
|
503
|
+
const flush = async () => { flushTimer = null; if (!pending.length) return; const batch = pending; pending = []; await room.record('extension.install.output', { id: 'ollama', lines: batch }); };
|
|
504
|
+
try {
|
|
505
|
+
await pullModel({ host: ollama.host, model, fetchImpl: reportFetch, onLine: (line) => { pending.push(line); if (!flushTimer) flushTimer = setTimeout(() => void flush(), 400); } });
|
|
506
|
+
await flush();
|
|
507
|
+
const status = await wireOllama();
|
|
508
|
+
await room.record('extension.install.finished', { id: 'ollama', name: 'OLLAMA', ok: true, installed: true, detail: `pulled ${model}`, ollama: status });
|
|
509
|
+
} catch (error) {
|
|
510
|
+
await flush();
|
|
511
|
+
await room.record('extension.install.finished', { id: 'ollama', name: 'OLLAMA', ok: false, installed: false, detail: error.message });
|
|
512
|
+
} finally { installing = null; }
|
|
513
|
+
})();
|
|
514
|
+
return { ok: true };
|
|
515
|
+
},
|
|
516
|
+
},
|
|
517
|
+
},
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async function installExtension(id, payload = {}) {
|
|
522
|
+
const { confirm } = payload;
|
|
373
523
|
const extension = extensionById(id);
|
|
374
524
|
if (!extension) return { status: 404, body: { error: `Unknown module: ${id}.` } };
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
const enabled = !Boolean(current.modules?.ripley?.enabled);
|
|
378
|
-
await updateConfig(root, { modules: { ...current.modules, ripley: { enabled } } });
|
|
379
|
-
ripleyEnabled = enabled;
|
|
380
|
-
await room.record('extension.toggled', { id, name: extension.name, enabled });
|
|
381
|
-
return { status: 200, body: { enabled } };
|
|
382
|
-
}
|
|
383
|
-
if (extension.id === 'ashcode') {
|
|
384
|
-
const current = await readConfig(root);
|
|
385
|
-
const enabled = !Boolean(current.modules?.ashCode?.enabled);
|
|
386
|
-
if (enabled && confirm !== true) return { status: 400, body: { error: 'AshCode is beta and can change meaning. Send { "confirm": true } to enable it.' } };
|
|
387
|
-
await updateConfig(root, { modules: { ...current.modules, ashCode: { enabled } } });
|
|
388
|
-
room.setAshCode(enabled);
|
|
389
|
-
await room.record('extension.toggled', { id, name: extension.name, enabled, beta: true });
|
|
390
|
-
return { status: 200, body: { enabled, beta: true, warning: 'AshCode beta may alter meaning; review the original. Character reduction is not verified token savings.' } };
|
|
391
|
-
}
|
|
392
|
-
if (extension.kind === 'builtin') {
|
|
393
|
-
// Image Studio: a switch in config.json, nothing written to the project.
|
|
394
|
-
const current = await readConfig(root);
|
|
395
|
-
const enabled = !(current.modules?.imageStudio?.enabled);
|
|
396
|
-
const key = await imageKey();
|
|
397
|
-
if (enabled && !key) return { status: 412, body: { error: 'No Gemini API key found. Sign in with the Gemini CLI (/auth → API key) or set GEMINI_API_KEY, then enable Image Studio.' } };
|
|
398
|
-
const model = typeof arguments[1]?.model === 'string' && arguments[1].model ? arguments[1].model : (current.modules?.imageStudio?.model ?? extension.models[0]);
|
|
399
|
-
await updateConfig(root, { modules: { ...current.modules, imageStudio: { enabled, model } } });
|
|
400
|
-
setImageModule({ enabled, model });
|
|
401
|
-
await room.record('extension.toggled', { id, name: extension.name, enabled, model });
|
|
402
|
-
return { status: 200, body: { enabled, model, capabilities: room.capabilities() } };
|
|
403
|
-
}
|
|
525
|
+
// Built-ins are switches the module itself defines.
|
|
526
|
+
if (extension.toggle) return extension.toggle(await moduleContext(), payload);
|
|
404
527
|
if (confirm !== true) return { status: 400, body: { error: 'Installing a module writes into the project; send { "confirm": true } to proceed.' } };
|
|
405
528
|
if (installing) return { status: 409, body: { error: `Another install is running (${installing}).` } };
|
|
406
529
|
const preflight = extension.preflight ? await extension.preflight(canonicalProjectRoot) : { ok: true, problems: [] };
|
|
@@ -474,7 +597,7 @@ export async function createPulseServer({
|
|
|
474
597
|
projectRoot,
|
|
475
598
|
agents,
|
|
476
599
|
ashCode: { enabled: room.ashCodeEnabled() },
|
|
477
|
-
ripley: { enabled:
|
|
600
|
+
ripley: { enabled: await ripleyOn() },
|
|
478
601
|
softTokenBudget,
|
|
479
602
|
timeouts: Object.fromEntries(agents.map((agent) => [agent.id, room.timeoutFor(agent.id)])),
|
|
480
603
|
delegation: { enabled: room.settings().delegation, maxPlanSteps: room.settings().maxPlanSteps },
|
|
@@ -544,7 +667,7 @@ export async function createPulseServer({
|
|
|
544
667
|
// resolve to the same route.
|
|
545
668
|
const previewMatch = request.method === 'GET' && url.pathname.match(/^\/preview\/(project|attachments)\/(.*)$/);
|
|
546
669
|
if (previewMatch) {
|
|
547
|
-
if (!
|
|
670
|
+
if (!(await ripleyOn())) return sendJson(response, 412, { error: 'RIPLEY is off. Enable it in MODULES to render files.' });
|
|
548
671
|
const which = previewMatch[1] === 'attachments' ? attachmentsRoot : canonicalProjectRoot;
|
|
549
672
|
const relative = decodeURIComponent(previewMatch[2]);
|
|
550
673
|
const file = await readServable(which, relative);
|
|
@@ -578,6 +701,7 @@ export async function createPulseServer({
|
|
|
578
701
|
return sendJson(response, 201, { attachment: { id: record.id, name: record.name, fileName: record.fileName, size: record.size, contentType: record.contentType, url: `/api/files?root=attachments&path=${encodeURIComponent(record.fileName)}` } });
|
|
579
702
|
}
|
|
580
703
|
if (request.method === 'GET' && url.pathname === '/api/settings') {
|
|
704
|
+
await refreshPrivacy();
|
|
581
705
|
return sendJson(response, 200, { settings: effectiveSettings(), config: await readConfig(root), sessions, sessionsAt, loggingIn, agents: agents.map((agent) => ({ ...agent, login: loginPlanFor(agent) })) });
|
|
582
706
|
}
|
|
583
707
|
if (request.method === 'POST' && url.pathname === '/api/settings') {
|
|
@@ -602,6 +726,69 @@ export async function createPulseServer({
|
|
|
602
726
|
const outcome = await sentinel.send(sentinelMatch[1]);
|
|
603
727
|
return sendJson(response, outcome.ok ? 200 : (outcome.status === 404 || outcome.status === 412 ? outcome.status : 502), outcome);
|
|
604
728
|
}
|
|
729
|
+
// Routes a module serves, before MADRE's own: the module answers with { status, body }.
|
|
730
|
+
const moduleRoute = findModuleRoute(request.method, url.pathname);
|
|
731
|
+
if (moduleRoute) {
|
|
732
|
+
const payload = request.method === 'POST' ? await body(request).catch(() => ({})) : {};
|
|
733
|
+
const result = await moduleRoute.route.handler(await moduleContext(), { request, url, params: moduleRoute.params, payload });
|
|
734
|
+
return sendJson(response, result.status ?? 200, result.body ?? {});
|
|
735
|
+
}
|
|
736
|
+
// The dataset behind MADRE AI: exported on demand next to the ledger.
|
|
737
|
+
if (url.pathname === '/api/dataset' && (request.method === 'GET' || request.method === 'POST')) {
|
|
738
|
+
const dir = join(roomDir, 'dataset');
|
|
739
|
+
const notes = memory ? memory.memories({ limit: 5000 }) : [];
|
|
740
|
+
if (request.method === 'GET') {
|
|
741
|
+
const manifest = await readFile(join(dir, 'manifest.json'), 'utf8').then(JSON.parse).catch(() => null);
|
|
742
|
+
return sendJson(response, 200, { dataset: manifest, dir, trained: ollama.madreModel ?? null, readiness: datasetReadiness(await store.readAll(), notes), training: trainingInfo() });
|
|
743
|
+
}
|
|
744
|
+
const events = await store.readAll();
|
|
745
|
+
const result = await exportDataset({ events, notes, dir, project: basename(canonicalProjectRoot), home: homedir(), privacy });
|
|
746
|
+
await room.record('dataset.exported', { pairs: result.pairs, turns: result.turns, notes: result.notes, train: result.train, valid: result.valid, dir });
|
|
747
|
+
return sendJson(response, 200, { dataset: result, dir, trained: ollama.madreModel ?? null, readiness: datasetReadiness(events, notes), training: trainingInfo() });
|
|
748
|
+
}
|
|
749
|
+
// PRIVACY: the terms live in config.json only; the ledger records counts, never words.
|
|
750
|
+
if (request.method === 'GET' && url.pathname === '/api/privacy') {
|
|
751
|
+
await refreshPrivacy();
|
|
752
|
+
return sendJson(response, 200, { terms: privacy.terms, marker: privacy.marker, exposure: room.privacyExposure(await store.readAll()) });
|
|
753
|
+
}
|
|
754
|
+
if (request.method === 'POST' && url.pathname === '/api/privacy') {
|
|
755
|
+
const patch = await body(request).catch(() => ({}));
|
|
756
|
+
const current = (await readConfig(root)).privacy ?? {};
|
|
757
|
+
const next = { ...current };
|
|
758
|
+
if ('terms' in patch) next.terms = normalizeTerms(patch.terms);
|
|
759
|
+
if (typeof patch.marker === 'string' && patch.marker.trim()) next.marker = patch.marker.trim().slice(0, 40);
|
|
760
|
+
await updateConfig(root, { privacy: next });
|
|
761
|
+
privacy.set(next.terms ?? [], next.marker);
|
|
762
|
+
await room.record('privacy.updated', { terms: privacy.terms.length, marker: privacy.marker });
|
|
763
|
+
return sendJson(response, 200, { terms: privacy.terms, marker: privacy.marker, exposure: room.privacyExposure(await store.readAll()) });
|
|
764
|
+
}
|
|
765
|
+
if (request.method === 'POST' && url.pathname === '/api/privacy/purge') {
|
|
766
|
+
const payload = await body(request).catch(() => ({}));
|
|
767
|
+
if (!designationOk(payload.designation)) return sendJson(response, 403, { error: 'UNABLE TO COMPUTE. UNABLE TO CLARIFY.' });
|
|
768
|
+
await refreshPrivacy();
|
|
769
|
+
if (!privacy.enabled) return sendJson(response, 412, { error: 'No private terms are set. Write them first.' });
|
|
770
|
+
const result = await room.purgePrivate();
|
|
771
|
+
// The log was rewritten in place: the broadcaster's byte offset is stale, the sequences are not.
|
|
772
|
+
tailOffset = (await store.tail(0)).offset;
|
|
773
|
+
return sendJson(response, 200, { purged: result, exposure: room.privacyExposure(await store.readAll()) });
|
|
774
|
+
}
|
|
775
|
+
// The human's verdict on a reply, for the dataset: good, bad, none.
|
|
776
|
+
if (request.method === 'POST' && url.pathname === '/api/messages/rate') {
|
|
777
|
+
const payload = await body(request).catch(() => ({}));
|
|
778
|
+
try {
|
|
779
|
+
const event = await room.rateMessage(String(payload.messageId ?? ''), String(payload.rating ?? ''));
|
|
780
|
+
return sendJson(response, 200, { rated: event.payload });
|
|
781
|
+
} catch (error) { return sendJson(response, 400, { error: error.message }); }
|
|
782
|
+
}
|
|
783
|
+
// Release channel: current, latest on npm, and the command for how this copy runs.
|
|
784
|
+
if (request.method === 'GET' && url.pathname === '/api/version') {
|
|
785
|
+
return sendJson(response, 200, await versionView({ force: url.searchParams.get('force') === '1' }));
|
|
786
|
+
}
|
|
787
|
+
if (request.method === 'POST' && url.pathname === '/api/updates/settings') {
|
|
788
|
+
const patch = await body(request).catch(() => ({}));
|
|
789
|
+
if (typeof patch.check === 'boolean') { updatesConfig = { ...updatesConfig, check: patch.check }; await updateConfig(root, { updates: updatesConfig }); }
|
|
790
|
+
return sendJson(response, 200, { updates: { check: updatesEnabled(), envWins: process.env.PULSE_UPDATE_CHECK !== undefined } });
|
|
791
|
+
}
|
|
605
792
|
if (request.method === 'GET' && url.pathname === '/api/mother') {
|
|
606
793
|
return sendJson(response, 200, { mother: room.motherStatus(), strikes: CODE000_STRIKES });
|
|
607
794
|
}
|
|
@@ -673,7 +860,7 @@ export async function createPulseServer({
|
|
|
673
860
|
return sendJson(response, result.ok ? 200 : 422, { command: parsed.name, title: result.title, ok: result.ok, sequence: event.sequence });
|
|
674
861
|
}
|
|
675
862
|
if (request.method === 'GET' && url.pathname === '/api/extensions') {
|
|
676
|
-
return sendJson(response, 200, { installing, extensions: await
|
|
863
|
+
return sendJson(response, 200, { installing, extensions: await describeModules(await moduleContext()) });
|
|
677
864
|
}
|
|
678
865
|
const installMatch = request.method === 'POST' && url.pathname.match(/^\/api\/extensions\/([a-z0-9-]+)\/install$/);
|
|
679
866
|
if (installMatch) {
|
|
@@ -696,6 +883,7 @@ export async function createPulseServer({
|
|
|
696
883
|
// hears "no" with a reason instead of a silent log line.
|
|
697
884
|
const gate = await room.modeCheck(payload);
|
|
698
885
|
if (!gate.ok) return sendJson(response, gate.status ?? 403, { error: gate.error, mode: gate.mode, maxMode: gate.maxMode });
|
|
886
|
+
await refreshPrivacy();
|
|
699
887
|
void room.send(payload).catch((error) => {
|
|
700
888
|
console.error(`MADRE room error: ${error.message}`);
|
|
701
889
|
});
|
package/src/setup.mjs
CHANGED
|
@@ -48,13 +48,21 @@ const glyphFor = (agent, probe) => {
|
|
|
48
48
|
return '◌';
|
|
49
49
|
};
|
|
50
50
|
|
|
51
|
+
// Ollama, when it runs with a chat model, is an agent of its own: @madre. Tests hand in a probe.
|
|
52
|
+
export async function localIntelligence(env = process.env) {
|
|
53
|
+
if (env.PULSE_OLLAMA === '0') return { running: false, chatModel: null, embedModel: null, disabled: true };
|
|
54
|
+
const { probeOllama } = await import('./ollama.mjs');
|
|
55
|
+
return probeOllama({ env });
|
|
56
|
+
}
|
|
57
|
+
export const madreOnline = (ollama) => Boolean(ollama?.running && ollama.chatModel);
|
|
58
|
+
|
|
51
59
|
export function isOnline(agent, probe) {
|
|
52
60
|
return Boolean(agent.ready && probe?.state === 'signed-in');
|
|
53
61
|
}
|
|
54
62
|
|
|
55
63
|
/* ---------- report (pure, testable) ---------- */
|
|
56
64
|
|
|
57
|
-
export function renderReport({ agents, probes, projectRoot, config = {}, palette = makePalette({ colors: false }), width = 72 }) {
|
|
65
|
+
export function renderReport({ agents, probes, projectRoot, config = {}, palette = makePalette({ colors: false }), width = 72, ollama = null }) {
|
|
58
66
|
const p = palette;
|
|
59
67
|
const bar = p.phosphor('▌');
|
|
60
68
|
const lines = [];
|
|
@@ -83,11 +91,19 @@ export function renderReport({ agents, probes, projectRoot, config = {}, palette
|
|
|
83
91
|
lines.push(` ${p.dim('│')} ${p.dim(model ? `model ${model}` : 'model: provider default (set one if the default provider has no session)')}`);
|
|
84
92
|
}
|
|
85
93
|
}
|
|
94
|
+
if (ollama && !ollama.disabled) {
|
|
95
|
+
const local = madreOnline(ollama);
|
|
96
|
+
const status = local ? p.phosphor('RUNNING ') : ollama.running ? p.warn('NO MODEL ') : p.dim('NOT FOUND ');
|
|
97
|
+
const detail = local ? `@madre in the room · ${ollama.chatModel}${ollama.embedModel ? ` · embeddings ${ollama.embedModel}` : ''}` : ollama.running ? 'pull a chat model: ollama pull qwen2.5:3b' : 'optional: ollama serve · local memory, embeddings and @madre';
|
|
98
|
+
lines.push(` ${p.dim('│')} ${p.phosphor('◉')} ${p.phosphor('OLLAMA'.padEnd(9))} ${p.dim('local'.padEnd(10))} ${status} ${p.dim(detail)}`);
|
|
99
|
+
}
|
|
86
100
|
lines.push(` ${p.dim('└' + '─'.repeat(Math.max(4, width - 4)))}`);
|
|
87
101
|
const online = agents.filter((agent) => isOnline(agent, probes[agent.id]));
|
|
102
|
+
const local = madreOnline(ollama);
|
|
88
103
|
const verdict = online.length
|
|
89
|
-
? `${online.length} OF ${agents.length} AGENTS ONLINE. ROOM CAN OPEN.`
|
|
90
|
-
:
|
|
104
|
+
? `${online.length} OF ${agents.length} AGENTS ONLINE${local ? ' · @MADRE LOCAL' : ''}. ROOM CAN OPEN.`
|
|
105
|
+
: local ? 'NO CLI ONLINE · @MADRE (LOCAL) IS. THE ROOM CAN OPEN WITH MEMORY ALONE; CONNECT A CLI TO WORK ON FILES.'
|
|
106
|
+
: `NO AGENT ONLINE. CONFIGURE ONE TO OPEN THE ROOM.`;
|
|
91
107
|
lines.push('');
|
|
92
108
|
lines.push(` ${p.phosphor('MOTHER')}${p.dim(' ›')} ${p.bold(verdict)}`);
|
|
93
109
|
return lines.join('\n');
|
|
@@ -119,12 +135,14 @@ export async function runSetup({ projectRoot, stateRoot, interactive = stdin.isT
|
|
|
119
135
|
let agents = await detectAgents();
|
|
120
136
|
let probes = await probeAll(agents);
|
|
121
137
|
let config = await loadConfig(stateRoot);
|
|
122
|
-
|
|
138
|
+
const ollama = await localIntelligence();
|
|
139
|
+
log(renderReport({ agents, probes, projectRoot, config, palette: p, ollama }));
|
|
140
|
+
const anyoneOnline = () => agents.some((agent) => isOnline(agent, probes[agent.id])) || madreOnline(ollama);
|
|
123
141
|
|
|
124
142
|
if (!interactive) {
|
|
125
143
|
// Without a terminal there is nobody to walk through; report and hand
|
|
126
144
|
// back. `online` lets the caller decide (exit codes, CI checks).
|
|
127
|
-
return { action: 'report', online:
|
|
145
|
+
return { action: 'report', online: anyoneOnline(), agents, probes, ollama };
|
|
128
146
|
}
|
|
129
147
|
|
|
130
148
|
const rl = createInterface({ input: stdin, output: stdout });
|
|
@@ -136,7 +154,7 @@ export async function runSetup({ projectRoot, stateRoot, interactive = stdin.isT
|
|
|
136
154
|
const answer = (await ask('select')).trim().toLowerCase();
|
|
137
155
|
if (answer === 'q') return { action: 'quit', agents, probes };
|
|
138
156
|
if (answer === 's') {
|
|
139
|
-
if (!
|
|
157
|
+
if (!anyoneOnline()) {
|
|
140
158
|
log(` ${p.warn('MOTHER ›')} no agent online; the room would open empty. Configure one first or press s again to open anyway.`);
|
|
141
159
|
const again = (await ask('start anyway? [y/N]')).trim().toLowerCase();
|
|
142
160
|
if (again !== 'y') continue;
|
|
@@ -147,7 +165,7 @@ export async function runSetup({ projectRoot, stateRoot, interactive = stdin.isT
|
|
|
147
165
|
agents = await detectAgents();
|
|
148
166
|
probes = await probeAll(agents);
|
|
149
167
|
config = await loadConfig(stateRoot);
|
|
150
|
-
log(renderReport({ agents, probes, projectRoot, config, palette: p }));
|
|
168
|
+
log(renderReport({ agents, probes, projectRoot, config, palette: p, ollama }));
|
|
151
169
|
continue;
|
|
152
170
|
}
|
|
153
171
|
if (answer === 'm') {
|
package/src/updates.mjs
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Release channel: is there a newer MADRE on npm? One read of the public registry, at most
|
|
2
|
+
// once a day, cached in ~/.pulse/updates.json. It sends the package name and nothing else,
|
|
3
|
+
// the same request `npx` makes; PULSE_UPDATE_CHECK=0 or the switch in MU/TH/UR turns it off.
|
|
4
|
+
// MADRE never updates itself: the room is in use and the process belongs to the human. It
|
|
5
|
+
// shows the exact command for how this copy was launched.
|
|
6
|
+
|
|
7
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
8
|
+
import { dirname } from 'node:path';
|
|
9
|
+
|
|
10
|
+
export const REGISTRY = 'https://registry.npmjs.org';
|
|
11
|
+
export const UPDATE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
12
|
+
|
|
13
|
+
const parse = (version) => String(version ?? '').trim().replace(/^v/, '').split(/[.+-]/).slice(0, 3).map((part) => Number.parseInt(part, 10) || 0);
|
|
14
|
+
|
|
15
|
+
// -1 when a < b, 0 when equal, 1 when a > b. Pre-release tags are ignored: a room compares releases.
|
|
16
|
+
export function compareVersions(a, b) {
|
|
17
|
+
const [x, y] = [parse(a), parse(b)];
|
|
18
|
+
for (let i = 0; i < 3; i += 1) if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1;
|
|
19
|
+
return 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// How this copy runs, from the path of the script Node started: npx cache, the project's own
|
|
23
|
+
// node_modules, a global install, or the source tree.
|
|
24
|
+
export function detectInstall({ script = process.argv[1] ?? '', projectRoot = process.cwd() } = {}) {
|
|
25
|
+
const path = String(script).replace(/\\/g, '/');
|
|
26
|
+
if (/\/_npx\//.test(path)) return 'npx';
|
|
27
|
+
if (projectRoot && path.startsWith(`${String(projectRoot).replace(/\\/g, '/').replace(/\/$/, '')}/node_modules/`)) return 'project';
|
|
28
|
+
if (/\/node_modules\//.test(path)) return 'global';
|
|
29
|
+
return 'source';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function updateCommand(install, name, version = 'latest') {
|
|
33
|
+
switch (install) {
|
|
34
|
+
case 'npx': return `npx ${name}@${version} start`;
|
|
35
|
+
case 'project': return `npm install ${name}@${version}`;
|
|
36
|
+
case 'global': return `npm install -g ${name}@${version}`;
|
|
37
|
+
default: return 'git pull';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function releaseUrl(repository, version) {
|
|
42
|
+
const url = String(repository?.url ?? repository ?? '').replace(/^git\+/, '').replace(/\.git$/, '');
|
|
43
|
+
return url ? `${url}/releases/tag/v${version}` : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function readCache(file) {
|
|
47
|
+
try { return JSON.parse(await readFile(file, 'utf8')); } catch { return null; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The check. `enabled: false` answers from the cache only and never touches the network.
|
|
51
|
+
export async function checkForUpdate({ name, current, cacheFile, fetchImpl = globalThis.fetch, now = Date.now(), ttlMs = UPDATE_TTL_MS, enabled = true, force = false, timeoutMs = 4000 } = {}) {
|
|
52
|
+
const cached = cacheFile ? await readCache(cacheFile) : null;
|
|
53
|
+
const fresh = cached && cached.name === name && Number.isFinite(cached.checkedAt) && now - cached.checkedAt < ttlMs;
|
|
54
|
+
let latest = cached?.name === name ? cached.latest ?? null : null;
|
|
55
|
+
let checkedAt = cached?.name === name ? cached.checkedAt ?? null : null;
|
|
56
|
+
let source = latest ? 'cache' : 'none';
|
|
57
|
+
let error = null;
|
|
58
|
+
if (enabled && (force || !fresh)) {
|
|
59
|
+
try {
|
|
60
|
+
const response = await fetchImpl(`${REGISTRY}/${encodeURIComponent(name).replace('%40', '@')}/latest`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs) });
|
|
61
|
+
if (!response.ok) throw new Error(`registry HTTP ${response.status}`);
|
|
62
|
+
const doc = await response.json();
|
|
63
|
+
if (typeof doc?.version !== 'string') throw new Error('registry answered without a version');
|
|
64
|
+
latest = doc.version;
|
|
65
|
+
checkedAt = now;
|
|
66
|
+
source = 'registry';
|
|
67
|
+
if (cacheFile) {
|
|
68
|
+
await mkdir(dirname(cacheFile), { recursive: true }).catch(() => {});
|
|
69
|
+
await writeFile(cacheFile, JSON.stringify({ name, latest, checkedAt })).catch(() => {});
|
|
70
|
+
}
|
|
71
|
+
} catch (cause) {
|
|
72
|
+
error = cause.message;
|
|
73
|
+
if (!latest) source = 'error';
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (!enabled) source = latest ? 'cache' : 'off';
|
|
77
|
+
const available = Boolean(latest && current && compareVersions(current, latest) < 0);
|
|
78
|
+
return { enabled, current: current ?? null, latest, available, checkedAt, source, error };
|
|
79
|
+
}
|