@jossuealcala/madre 0.3.0 → 0.3.2
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 +44 -0
- package/CONTRIBUTING.md +6 -1
- package/README.md +146 -219
- package/bin/madre.mjs +8 -0
- package/docs/INTERNALS.md +81 -0
- package/docs/training/Modelfile +5 -0
- package/docs/training/README.md +47 -0
- package/docs/training/train.sh +17 -0
- package/package.json +4 -2
- package/public/app.js +270 -44
- package/public/index.html +2 -0
- package/public/styles.css +40 -11
- package/public/troubleshooting.js +13 -4
- package/src/adapters/claude.mjs +4 -1
- package/src/adapters/gemini.mjs +8 -5
- package/src/adapters/madre.mjs +122 -12
- package/src/adapters/opencode.mjs +23 -6
- package/src/capabilities.mjs +10 -8
- package/src/checkpoint.mjs +118 -21
- package/src/conversation-context.mjs +4 -2
- package/src/dataset.mjs +57 -15
- package/src/directives.mjs +7 -4
- package/src/event-store.mjs +29 -1
- package/src/lease.mjs +12 -7
- package/src/memory.mjs +61 -2
- package/src/privacy.mjs +109 -0
- package/src/room/context.mjs +3 -3
- package/src/room/control.mjs +23 -11
- package/src/room/prompt.mjs +8 -4
- package/src/room.mjs +148 -32
- package/src/server.mjs +81 -6
- package/src/updates.mjs +79 -0
package/src/room.mjs
CHANGED
|
@@ -21,6 +21,9 @@ import { parseDirectives } from './directives.mjs';
|
|
|
21
21
|
import { isValidModelName } from './models.mjs';
|
|
22
22
|
import { MODES, SCOPES, SCOPE_LABELS, capabilitySummary, normalizeMode, resolveScopes } from './capabilities.mjs';
|
|
23
23
|
import { createLease, diffSnapshots, snapshot } from './lease.mjs';
|
|
24
|
+
import { stat as statFile } from 'node:fs/promises';
|
|
25
|
+
import { basename as baseName, join as joinPath } from 'node:path';
|
|
26
|
+
import { contentTypeFor } from './files.mjs';
|
|
24
27
|
import { imageStudioFor } from './image-studio.mjs';
|
|
25
28
|
import { CAPABILITIES, imageModuleState } from './capabilities.mjs';
|
|
26
29
|
import { resolveReferences } from './files.mjs';
|
|
@@ -80,6 +83,7 @@ export class Room {
|
|
|
80
83
|
#attachments = new Attachments();
|
|
81
84
|
#scopeConfig = {};
|
|
82
85
|
#delegation;
|
|
86
|
+
#privacy = null;
|
|
83
87
|
#maxPlanSteps;
|
|
84
88
|
#maxConcurrentTurns;
|
|
85
89
|
#planMaxAgeMs;
|
|
@@ -96,6 +100,7 @@ export class Room {
|
|
|
96
100
|
distill = {},
|
|
97
101
|
memoryServer = null,
|
|
98
102
|
mother = null,
|
|
103
|
+
privacy = null,
|
|
99
104
|
historicalEvents = [],
|
|
100
105
|
invokers = defaultInvokers,
|
|
101
106
|
agentTimeouts = {},
|
|
@@ -114,6 +119,7 @@ export class Room {
|
|
|
114
119
|
this.#recallShare = Math.min(0.6, Math.max(0, Number.isFinite(recallShare) ? recallShare : 0.3));
|
|
115
120
|
this.#memoryServer = memoryServer;
|
|
116
121
|
this.#mother = mother;
|
|
122
|
+
this.#privacy = privacy;
|
|
117
123
|
this.#invokers = invokers;
|
|
118
124
|
this.#agentTimeouts = agentTimeouts;
|
|
119
125
|
this.#maxMessageChars = maxMessageChars;
|
|
@@ -190,8 +196,7 @@ export class Room {
|
|
|
190
196
|
}
|
|
191
197
|
const scopes = this.scopesFor(step.agent);
|
|
192
198
|
const enabled = Object.fromEntries(SCOPES.map((scope) => [scope, scopes[scope].enabled && scopes[scope].wired]));
|
|
193
|
-
const lease = await
|
|
194
|
-
lease.messageId = parentMessageId;
|
|
199
|
+
const lease = await this.#projectLease({ leaseId: randomUUID(), messageId: parentMessageId });
|
|
195
200
|
lease.scopes = decision.decision === 'plan' ? Object.fromEntries(SCOPES.map((scope) => [scope, true])) : enabled;
|
|
196
201
|
await this.#emit('lease.granted', {
|
|
197
202
|
escalated: decision.decision,
|
|
@@ -199,6 +204,7 @@ export class Room {
|
|
|
199
204
|
messageId: parentMessageId,
|
|
200
205
|
agent: step.agent,
|
|
201
206
|
outDir: lease.relativeDir,
|
|
207
|
+
scratchDir: lease.scratchDir,
|
|
202
208
|
scopes: SCOPES.filter((scope) => enabled[scope]),
|
|
203
209
|
unavailable: SCOPES.filter((scope) => !scopes[scope].capable).map((scope) => SCOPE_LABELS[scope]),
|
|
204
210
|
planId,
|
|
@@ -226,8 +232,8 @@ export class Room {
|
|
|
226
232
|
}
|
|
227
233
|
embedNow() { return this.#vectors.runNow(); }
|
|
228
234
|
|
|
229
|
-
#contextFor(priorEvents, { messageId, text }) {
|
|
230
|
-
return contextFor({ memory: this.#memory, priorEvents, messageId, text, contextMaxChars: this.#contextMaxChars, recallShare: this.#recallShare, remember: (events) => this.#remember(events) });
|
|
235
|
+
#contextFor(priorEvents, { messageId, text, omitSynthetic = false }) {
|
|
236
|
+
return contextFor({ memory: this.#memory, priorEvents, messageId, text, contextMaxChars: this.#contextMaxChars, recallShare: this.#recallShare, remember: (events) => this.#remember(events), omitSynthetic });
|
|
231
237
|
}
|
|
232
238
|
|
|
233
239
|
// The human tunes the archive from MU/TH/UR; changes apply to the next run.
|
|
@@ -293,6 +299,44 @@ export class Room {
|
|
|
293
299
|
return { stats: this.memoryStats(), memories: this.#memory.memories({ limit: 500 }), links: this.#memory.memoryLinks() };
|
|
294
300
|
}
|
|
295
301
|
|
|
302
|
+
/* ---------- privacy: terms that never travel through the room ---------- */
|
|
303
|
+
|
|
304
|
+
privacy() { return this.#privacy; }
|
|
305
|
+
setPrivacy(terms, marker) {
|
|
306
|
+
if (!this.#privacy) return null;
|
|
307
|
+
this.#privacy.set(terms, marker);
|
|
308
|
+
return this.#privacy;
|
|
309
|
+
}
|
|
310
|
+
privacyExposure(events = []) {
|
|
311
|
+
if (!this.#privacy?.enabled) return { events: 0, entries: 0, memories: 0 };
|
|
312
|
+
let hits = 0;
|
|
313
|
+
for (const event of events) if (this.#privacy.redactDeep(event.payload).hits) hits += 1;
|
|
314
|
+
return { events: hits, ...(this.#memory ? this.#memory.exposure() : { entries: 0, memories: 0 }) };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// The human's hand on the ledger: every private term already recorded becomes the marker,
|
|
318
|
+
// in the log, the index and the notes. Recorded like anything else the human does.
|
|
319
|
+
async purgePrivate() {
|
|
320
|
+
if (!this.#privacy?.enabled) return { events: 0, entries: 0, memories: 0 };
|
|
321
|
+
const privacy = this.#privacy;
|
|
322
|
+
const rewritten = await this.#store.rewrite((event) => {
|
|
323
|
+
const { value, hits } = privacy.redactDeep(event.payload);
|
|
324
|
+
return hits ? { ...event, payload: value } : event;
|
|
325
|
+
});
|
|
326
|
+
const memory = this.#memory ? this.#memory.purge() : { entries: 0, memories: 0 };
|
|
327
|
+
if (this.#memory) this.#vectors.schedule();
|
|
328
|
+
const result = { events: rewritten.changed, ...memory };
|
|
329
|
+
await this.#emit('privacy.purged', { ...result, terms: privacy.terms.length });
|
|
330
|
+
return result;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// The human's verdict on a reply: good, bad, or cleared. The dataset listens.
|
|
334
|
+
async rateMessage(messageId, rating) {
|
|
335
|
+
if (typeof messageId !== 'string' || !messageId.trim()) throw new Error('Which reply? Give its messageId.');
|
|
336
|
+
if (!['good', 'bad', 'none'].includes(rating)) throw new Error('A rating is good, bad or none.');
|
|
337
|
+
return this.#emit('message.rated', { messageId, rating, by: 'you' });
|
|
338
|
+
}
|
|
339
|
+
|
|
296
340
|
// Forgetting is recorded in the ledger like anything else the human does to the room.
|
|
297
341
|
async forgetMemory(id) {
|
|
298
342
|
if (!this.#memory) return null;
|
|
@@ -508,6 +552,9 @@ export class Room {
|
|
|
508
552
|
references: references.length ? references.map(({ excerpt, ...reference }) => reference) : undefined,
|
|
509
553
|
create: create === true ? true : undefined,
|
|
510
554
|
});
|
|
555
|
+
// The human may say what they like; the room only says so, because every agent will read it.
|
|
556
|
+
const exposed = this.#privacy?.hits(text2) ?? 0;
|
|
557
|
+
if (exposed && !ghost) await this.#emit('privacy.warning', { messageId, hits: exposed });
|
|
511
558
|
// The human's creation lease: one directory for this message and any plan
|
|
512
559
|
// it starts. Granted before the agent runs, recorded, revocable by STOPALL.
|
|
513
560
|
// The lease carries the scopes enabled for the target; if the target cannot
|
|
@@ -530,10 +577,9 @@ export class Room {
|
|
|
530
577
|
message: `@${parsed.target} will answer read-only: ${scopes.write.capable ? 'file creation is switched off for it (enable it in CONNECTIONS)' : 'its CLI cannot create files'}${unavailable.length ? `; it cannot ${unavailable.join(' or ')}` : ''}.${alternatives.length ? ` For creation ask ${alternatives.join(' or ')}.` : ''}`,
|
|
531
578
|
});
|
|
532
579
|
} else {
|
|
533
|
-
lease = await
|
|
534
|
-
lease.messageId = messageId;
|
|
580
|
+
lease = await this.#projectLease({ leaseId: randomUUID(), messageId });
|
|
535
581
|
// CREATE authorizes the plan to use each delegate's enabled creation
|
|
536
|
-
// scopes; a
|
|
582
|
+
// scopes; a default-#2 lease authorizes files only.
|
|
537
583
|
lease.scopeCeiling = { write: true, imageGen: !standing, web: true };
|
|
538
584
|
lease.scopes = Object.fromEntries(SCOPES.map((scope) => [scope, enabled.includes(scope) && (!standing || scope === 'write')]));
|
|
539
585
|
await this.#emit('lease.granted', {
|
|
@@ -542,6 +588,7 @@ export class Room {
|
|
|
542
588
|
messageId,
|
|
543
589
|
agent: parsed.target,
|
|
544
590
|
outDir: lease.relativeDir,
|
|
591
|
+
scratchDir: lease.scratchDir,
|
|
545
592
|
grantedBy: 'you',
|
|
546
593
|
scopes: standing ? ['write'] : enabled,
|
|
547
594
|
unavailable,
|
|
@@ -565,6 +612,25 @@ export class Room {
|
|
|
565
612
|
await this.#track(this.#dispatch({ messageId, targetId: parsed.target, text: text2, requester: 'you', depth: 0, allowDelegation: allowDelegation && !ghost, model: chosenModel, attachments: files, references, lease, ashCode: ashActive, mode: ghost ? 0 : control ? 3 : (lease ? 2 : 1) }));
|
|
566
613
|
}
|
|
567
614
|
|
|
615
|
+
// A #2 lease: the project itself is where new files go, and MADRE keeps a scratch folder
|
|
616
|
+
// under .pulse/out/ for what has no natural place. Each turn under it takes a checkpoint
|
|
617
|
+
// and puts back whatever existed before, so CREATE only ever adds.
|
|
618
|
+
async #projectLease({ leaseId, messageId }) {
|
|
619
|
+
const scratch = await createLease({ projectRoot: this.#projectRoot, leaseId });
|
|
620
|
+
return { leaseId, messageId, outDir: this.#projectRoot, relativeDir: '.', scratchDir: scratch.relativeDir, create: true };
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Artifacts from a #2 turn: the files the checkpoint saw appear, as the room shows them.
|
|
624
|
+
async #artifactsFrom(files) {
|
|
625
|
+
const out = [];
|
|
626
|
+
for (const file of files) {
|
|
627
|
+
if (file.status !== 'A') continue;
|
|
628
|
+
const info = await statFile(joinPath(this.#projectRoot, file.path)).catch(() => null);
|
|
629
|
+
out.push({ name: baseName(file.path), path: file.path, size: info?.size ?? 0, contentType: contentTypeFor(file.path), status: 'created' });
|
|
630
|
+
}
|
|
631
|
+
return out.sort((a, b) => a.path.localeCompare(b.path));
|
|
632
|
+
}
|
|
633
|
+
|
|
568
634
|
// Agents that can receive a delegated step from `self`.
|
|
569
635
|
delegatesFor(self) {
|
|
570
636
|
return this.#agents.filter((agent) => agent.ready && agent.id !== self).map((agent) => agent.id);
|
|
@@ -626,7 +692,8 @@ export class Room {
|
|
|
626
692
|
}
|
|
627
693
|
|
|
628
694
|
const priorEvents = await this.#store.readAll();
|
|
629
|
-
|
|
695
|
+
// @madre never reads its own canned replies back: a small model would echo them.
|
|
696
|
+
const { context, recall, memories } = await this.#contextFor(priorEvents, { messageId, text, omitSynthetic: Boolean(agent.local) });
|
|
630
697
|
let handoffId = null;
|
|
631
698
|
if (context.previousAgent && context.previousAgent !== targetId) {
|
|
632
699
|
handoffId = randomUUID();
|
|
@@ -669,7 +736,7 @@ export class Room {
|
|
|
669
736
|
// The orchestrator's turn is over before its plan starts, so it is never
|
|
670
737
|
// counted as in flight while the others work.
|
|
671
738
|
if (outcome?.directives?.steps.length) {
|
|
672
|
-
await this.#runPlan({ orchestrator: agent.id, parentMessageId: outcome.responseMessageId, directives: outcome.directives, lease, ashCode });
|
|
739
|
+
await this.#runPlan({ orchestrator: agent.id, parentMessageId: outcome.responseMessageId, directives: outcome.directives, lease, ashCode, mode });
|
|
673
740
|
}
|
|
674
741
|
return outcome?.responseMessageId ?? null;
|
|
675
742
|
}
|
|
@@ -684,6 +751,8 @@ export class Room {
|
|
|
684
751
|
motherLines: this.#mother ? this.#mother.recent() : [],
|
|
685
752
|
memoryServer: this.#memoryServer,
|
|
686
753
|
controlHolder: this.#controlDesk.holder?.agent ?? null,
|
|
754
|
+
privacyMarker: this.#privacy?.marker ?? '[ENTIDAD-ORG]',
|
|
755
|
+
madreModel: this.#agents.find((agent) => agent.id === 'madre' && agent.ready)?.version ?? null,
|
|
687
756
|
});
|
|
688
757
|
}
|
|
689
758
|
|
|
@@ -698,13 +767,19 @@ export class Room {
|
|
|
698
767
|
const turnScopes = { web: enabled.web, imageGen: enabled.imageGen };
|
|
699
768
|
let lease = null;
|
|
700
769
|
let controlRun = null;
|
|
701
|
-
|
|
770
|
+
let createRun = null;
|
|
771
|
+
if (mode === 3 && (requester === 'you' || planId) && depth <= 1 && agentScopes.maxMode >= 3 && enabled.write) {
|
|
702
772
|
// CONTROL: the project itself is the writable root, and a checkpoint
|
|
703
|
-
// taken now makes every change of this turn reversible.
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
773
|
+
// taken now makes every change of this turn reversible. A plan step gets
|
|
774
|
+
// it when the orchestrator, itself in #3, named #3 for that step.
|
|
775
|
+
if (this.#controlDesk.holder) {
|
|
776
|
+
await this.#alert('control-busy', `@${this.#controlDesk.holder.agent} still holds CONTROL; @${agent.id} answers read-only this turn.`, `control-busy:${agent.id}`);
|
|
777
|
+
} else {
|
|
778
|
+
const seat = await this.#controlDesk.begin({ agent, messageId, enabledScopes: enabled });
|
|
779
|
+
controlRun = seat.run;
|
|
780
|
+
lease = seat.lease;
|
|
781
|
+
await this.#emit('control.started', seat.announcement);
|
|
782
|
+
}
|
|
708
783
|
} else if (sharedLease) {
|
|
709
784
|
lease = enabled.write ? {
|
|
710
785
|
...sharedLease,
|
|
@@ -713,8 +788,7 @@ export class Room {
|
|
|
713
788
|
} else if (agentScopes.write.always && enabled.write && requester !== 'you' && requester !== 'mother') {
|
|
714
789
|
// Standing lease for a delegate: the human opted this agent into
|
|
715
790
|
// creating files on every turn, so a plan step gets its own directory.
|
|
716
|
-
lease = await
|
|
717
|
-
lease.messageId = messageId;
|
|
791
|
+
lease = await this.#projectLease({ leaseId: randomUUID(), messageId });
|
|
718
792
|
lease.scopeCeiling = { write: true, imageGen: false, web: true };
|
|
719
793
|
lease.scopes = { ...enabled, imageGen: false };
|
|
720
794
|
await this.#emit('lease.granted', {
|
|
@@ -723,11 +797,18 @@ export class Room {
|
|
|
723
797
|
messageId,
|
|
724
798
|
agent: agent.id,
|
|
725
799
|
outDir: lease.relativeDir,
|
|
800
|
+
scratchDir: lease.scratchDir,
|
|
726
801
|
scopes: ['write'],
|
|
727
802
|
unavailable: SCOPES.filter((scope) => !agentScopes[scope].capable).map((scope) => SCOPE_LABELS[scope]),
|
|
728
803
|
planId,
|
|
729
804
|
});
|
|
730
805
|
}
|
|
806
|
+
if (lease?.create && !controlRun) {
|
|
807
|
+
// CREATE: photograph the project now; after the turn only what appeared stays.
|
|
808
|
+
const seat = await this.#controlDesk.begin({ agent, messageId, enabledScopes: enabled, mode: 2 });
|
|
809
|
+
createRun = seat.run;
|
|
810
|
+
lease = { ...lease, checkpoint: seat.run.checkpoint };
|
|
811
|
+
}
|
|
731
812
|
turnScopes.imageGen = Boolean(lease?.scopes?.imageGen);
|
|
732
813
|
// The step asks for files but nobody granted a lease: say so now, with a
|
|
733
814
|
// way out, instead of letting the agent's refusal be the only signal.
|
|
@@ -746,16 +827,18 @@ export class Room {
|
|
|
746
827
|
const native = Boolean(CAPABILITIES[agent.id]?.imageGen);
|
|
747
828
|
const studio = imageModuleState();
|
|
748
829
|
const imageStudio = lease?.scopes?.imageGen && enabled.imageGen && !native && studio.enabled
|
|
749
|
-
? imageStudioFor({ enabled: true, model: studio.model, outDir: lease.outDir })
|
|
830
|
+
? imageStudioFor({ enabled: true, model: studio.model, outDir: lease.scratchDir ? joinPath(this.#projectRoot, lease.scratchDir) : lease.outDir })
|
|
750
831
|
: null;
|
|
751
832
|
// The turn's effective mode: #0 for ghosts, #3 in control, #2 only while it holds a lease.
|
|
752
833
|
const turnMode = mode === 0 ? 0 : controlRun ? 3 : lease ? 2 : 1;
|
|
753
834
|
try {
|
|
754
835
|
const invoke = this.#invokers[agent.adapter];
|
|
755
836
|
if (!invoke) throw new Error(`${agent.label} does not have a supported MADRE adapter.`);
|
|
756
|
-
const before = lease && !lease.control ? await snapshot(lease.outDir) : null;
|
|
837
|
+
const before = lease && !lease.control && !lease.create ? await snapshot(lease.outDir) : null;
|
|
757
838
|
const responseMessageId = randomUUID();
|
|
758
839
|
const notesBefore = this.#memory ? this.#memory.maxMemoryId() : 0;
|
|
840
|
+
const others = this.delegatesFor(agent.id);
|
|
841
|
+
const mayDelegate = allowDelegation && this.#delegation && depth === 0;
|
|
759
842
|
const result = await invoke({
|
|
760
843
|
executable: agent.path,
|
|
761
844
|
projectRoot: this.#projectRoot,
|
|
@@ -769,12 +852,22 @@ export class Room {
|
|
|
769
852
|
scopes: turnScopes,
|
|
770
853
|
imageStudio,
|
|
771
854
|
memoryServer: memoryServerForTurn(this.#memoryServer, { agent: agent.id, messageId: responseMessageId, mode: turnMode }),
|
|
855
|
+
// For @madre: who asks, who it may convene, and whether a plan would run at all.
|
|
856
|
+
requester,
|
|
857
|
+
crew: others,
|
|
858
|
+
delegation: mayDelegate,
|
|
859
|
+
maxSteps: this.#maxPlanSteps,
|
|
772
860
|
});
|
|
773
|
-
|
|
861
|
+
// Privacy: a private term in a reply is replaced before the ledger, the plan parser,
|
|
862
|
+
// the archivist or any other agent can see it. The reply says how many, not which.
|
|
863
|
+
const guarded = this.#privacy?.redact(result.text ?? '') ?? { text: result.text, hits: 0 };
|
|
864
|
+
if (guarded.hits) result.text = guarded.text;
|
|
865
|
+
// CREATE: what appeared stays and is shown; what existed before is put back and said.
|
|
866
|
+
const createChanges = createRun ? await this.#controlDesk.settle(createRun) : null;
|
|
867
|
+
const artifacts = createChanges ? await this.#artifactsFrom(createChanges.files) : lease && !lease.control ? diffSnapshots(before, await snapshot(lease.outDir), { relativeDir: lease.relativeDir }) : [];
|
|
774
868
|
// CONTROL: what really changed in the project, forbidden zones reverted on the spot.
|
|
775
869
|
const controlChanges = controlRun ? await this.#controlDesk.settle(controlRun) : null;
|
|
776
|
-
const
|
|
777
|
-
const directives = allowDelegation && this.#delegation && depth === 0
|
|
870
|
+
const directives = mayDelegate
|
|
778
871
|
? parseDirectives(result.text, { self: agent.id, available: others, maxSteps: this.#maxPlanSteps })
|
|
779
872
|
: { steps: [], closing: null, ignored: [] };
|
|
780
873
|
if (directives.steps.length === 0 && directives.ignored.length && depth === 0) {
|
|
@@ -798,14 +891,20 @@ export class Room {
|
|
|
798
891
|
status: 'completed',
|
|
799
892
|
planId,
|
|
800
893
|
model,
|
|
894
|
+
synthetic: result.synthetic || undefined,
|
|
895
|
+
redacted: guarded.hits || undefined,
|
|
801
896
|
mode: turnMode,
|
|
802
897
|
leaseId: lease?.leaseId,
|
|
803
898
|
artifacts: artifacts.length ? artifacts : undefined,
|
|
804
899
|
delegates: directives.steps.length ? directives.steps.map((step) => step.agent) : undefined,
|
|
805
900
|
});
|
|
901
|
+
if (guarded.hits) await this.#emit('privacy.redacted', { agent: agent.id, messageId, responseMessageId, hits: guarded.hits, marker: this.#privacy.marker });
|
|
806
902
|
if (artifacts.length) {
|
|
807
903
|
await this.#emit('artifacts.created', { leaseId: lease.leaseId, messageId, responseMessageId, agent: agent.id, outDir: lease.relativeDir, files: artifacts });
|
|
808
904
|
}
|
|
905
|
+
if (createChanges && (createChanges.existingReverted.length || createChanges.forbiddenReverted.length)) {
|
|
906
|
+
await this.#emit('create.reverted', { checkpointId: createChanges.checkpointId, agent: agent.id, messageId, responseMessageId, existing: createChanges.existingReverted, forbidden: createChanges.forbiddenReverted, message: createChanges.message });
|
|
907
|
+
}
|
|
809
908
|
// What the agent saved through memory_note during this turn, for the bubble's hint.
|
|
810
909
|
if (this.#memory && turnMode !== 0) {
|
|
811
910
|
const noted = this.#memory.notesSince(notesBefore, { agent: agent.id });
|
|
@@ -819,29 +918,37 @@ export class Room {
|
|
|
819
918
|
await this.#emit('agent.completed', { messageId, agent: agent.id, handoffId, planId });
|
|
820
919
|
return { responseMessageId, directives };
|
|
821
920
|
} catch (error) {
|
|
822
|
-
await this.#emit('message.failed', { messageId, target: agent.id, planId, error: failureMessage(error) });
|
|
921
|
+
await this.#emit('message.failed', { messageId, target: agent.id, planId, error: this.#privacy ? this.#privacy.redact(failureMessage(error)).text : failureMessage(error) });
|
|
823
922
|
return null;
|
|
824
923
|
} finally {
|
|
825
924
|
// Release CONTROL whichever way the turn ended; the checkpoint stays for UNDO.
|
|
826
925
|
await this.#controlDesk.release(controlRun);
|
|
926
|
+
await this.#controlDesk.release(createRun);
|
|
827
927
|
}
|
|
828
928
|
}
|
|
829
929
|
|
|
830
|
-
async #runPlan({ orchestrator, parentMessageId, directives, lease = null, ashCode = false }) {
|
|
930
|
+
async #runPlan({ orchestrator, parentMessageId, directives, lease = null, ashCode = false, mode = 1 }) {
|
|
831
931
|
const planId = randomUUID();
|
|
832
|
-
// The plan
|
|
833
|
-
// step
|
|
834
|
-
|
|
932
|
+
// The plan's ceiling is the human's mode: #3 when the orchestrator held CONTROL, #2 while a
|
|
933
|
+
// lease exists, #1 otherwise. A step may ask for a mode ("@codex #2: …"); it gets the lowest
|
|
934
|
+
// of what it asked, the ceiling and its own MAX MODE. Without a number it inherits the plan.
|
|
935
|
+
const ceiling = mode >= 3 ? 3 : lease ? 2 : 1;
|
|
936
|
+
const stepMode = (step) => {
|
|
937
|
+
const scopes = this.scopesFor(step.agent);
|
|
938
|
+
const asked = normalizeMode(step.mode, lease ? 2 : 1);
|
|
939
|
+
const granted = Math.min(asked, ceiling, scopes.maxMode);
|
|
940
|
+
return granted >= 2 && !scopes.write.enabled ? 1 : granted;
|
|
941
|
+
};
|
|
835
942
|
const plan = { planId, orchestrator, steps: directives.steps, closing: directives.closing, step: 0, stopped: null, startedAt: Date.now() };
|
|
836
943
|
this.#plans.set(planId, plan);
|
|
837
944
|
await this.#emit('plan.created', {
|
|
838
945
|
planId,
|
|
839
946
|
orchestrator,
|
|
840
947
|
parentMessageId,
|
|
841
|
-
steps: directives.steps.map((step) => ({ ...step, mode: stepMode(step
|
|
948
|
+
steps: directives.steps.map((step) => ({ ...step, mode: stepMode(step) })),
|
|
842
949
|
closing: directives.closing,
|
|
843
950
|
ignored: directives.ignored,
|
|
844
|
-
mode:
|
|
951
|
+
mode: ceiling,
|
|
845
952
|
leaseId: lease?.leaseId ?? null,
|
|
846
953
|
});
|
|
847
954
|
try {
|
|
@@ -857,7 +964,16 @@ export class Room {
|
|
|
857
964
|
let stepLease = lease;
|
|
858
965
|
let escalation = null;
|
|
859
966
|
const stepScopes = this.scopesFor(step.agent);
|
|
860
|
-
|
|
967
|
+
const wanted = stepMode(step);
|
|
968
|
+
// Under a #3 ceiling the orchestrator's word is enough: a #2 step gets its project lease,
|
|
969
|
+
// a #3 step gets CONTROL for its turn. Under #1 a creation step still asks the human.
|
|
970
|
+
if (ceiling === 3 && !stepLease && wanted === 2) {
|
|
971
|
+
stepLease = await this.#projectLease({ leaseId: randomUUID(), messageId });
|
|
972
|
+
stepLease.scopeCeiling = { write: true, imageGen: true, web: true };
|
|
973
|
+
stepLease.scopes = Object.fromEntries(SCOPES.map((scope) => [scope, stepScopes[scope].enabled && stepScopes[scope].wired]));
|
|
974
|
+
await this.#emit('lease.granted', { delegated: true, leaseId: stepLease.leaseId, messageId, agent: step.agent, outDir: '.', scratchDir: stepLease.scratchDir, grantedBy: orchestrator, scopes: SCOPES.filter((scope) => stepLease.scopes[scope]), unavailable: [], planId });
|
|
975
|
+
}
|
|
976
|
+
if (ceiling < 3 && !lease && !stepScopes.write.always && (wanted >= 2 || looksLikeCreation(step.text)) && stepScopes.maxMode >= 2 && stepScopes.write.enabled) {
|
|
861
977
|
const outcome = await this.#askForMode({ planId, plan, step, index, totalSteps: directives.steps.length + (directives.closing ? 1 : 0), orchestrator, parentMessageId, mode: 2 });
|
|
862
978
|
if (outcome.scope === 'plan') lease = outcome.lease;
|
|
863
979
|
if (outcome.scope) stepLease = outcome.lease; else escalation = outcome.reason;
|
|
@@ -873,12 +989,12 @@ export class Room {
|
|
|
873
989
|
ashCode: ashCode ? { active: true, applied: abbreviated.applied, reason: abbreviated.reason, language: abbreviated.language, originalChars: abbreviated.originalChars, encodedChars: abbreviated.encodedChars } : undefined,
|
|
874
990
|
status: 'delegated',
|
|
875
991
|
planId,
|
|
876
|
-
mode: stepLease && stepScopes.write.enabled ? 2 : 1,
|
|
992
|
+
mode: wanted === 3 ? 3 : stepLease && stepScopes.write.enabled ? 2 : 1,
|
|
877
993
|
escalation: escalation ?? undefined,
|
|
878
994
|
step: index + 1,
|
|
879
995
|
totalSteps: directives.steps.length + (directives.closing ? 1 : 0),
|
|
880
996
|
});
|
|
881
|
-
await this.#dispatch({ messageId, targetId: step.agent, text: stepText, requester: orchestrator, depth: 1, planId, allowDelegation: false, lease: stepLease, ashCode, mode: stepLease && stepScopes.write.enabled ? 2 : 1, escalation });
|
|
997
|
+
await this.#dispatch({ messageId, targetId: step.agent, text: stepText, requester: orchestrator, depth: 1, planId, allowDelegation: false, lease: wanted === 3 ? null : stepLease, ashCode, mode: wanted === 3 ? 3 : stepLease && stepScopes.write.enabled ? 2 : 1, escalation });
|
|
882
998
|
}
|
|
883
999
|
if (!plan.stopped && directives.closing) {
|
|
884
1000
|
plan.step = directives.steps.length + 1;
|
package/src/server.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import { ErrorSentinel } from './sentinel-errors.mjs';
|
|
|
15
15
|
import { probeOllama, ollamaEmbedder, ollamaInvoker, pullModel } from './ollama.mjs';
|
|
16
16
|
import { moduleById, describeModules, findModuleRoute } from './modules/index.mjs';
|
|
17
17
|
import { madreAgent, madreInvoker, MADRE_AGENT_ID, MADRE_ADAPTER } from './adapters/madre.mjs';
|
|
18
|
-
import { exportDataset } from './dataset.mjs';
|
|
18
|
+
import { exportDataset, readiness as datasetReadiness } from './dataset.mjs';
|
|
19
19
|
|
|
20
20
|
const PACKAGE = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8').catch(() => '{}'));
|
|
21
21
|
let crashHandlersInstalled = false;
|
|
@@ -42,6 +42,9 @@ import { applyConfigToEnv, loadConfig } from './config.mjs';
|
|
|
42
42
|
import { extensionById, runInstaller } from './extensions.mjs';
|
|
43
43
|
import { loginPlanFor, probeAll } from './auth-probe.mjs';
|
|
44
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';
|
|
45
48
|
import { discoverModels } from './models.mjs';
|
|
46
49
|
import { setImageModule } from './capabilities.mjs';
|
|
47
50
|
import { resolveGeminiKey } from './image-studio.mjs';
|
|
@@ -131,7 +134,21 @@ export async function createPulseServer({
|
|
|
131
134
|
const historicalEvents = await store.readAll();
|
|
132
135
|
// The room's memory: derived from the ledger, rebuilt if missing or stale,
|
|
133
136
|
// and never a reason for the room not to open.
|
|
134
|
-
|
|
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)
|
|
135
152
|
.catch((error) => { console.error(`MADRE memory unavailable, turns get the recent window only: ${error.message}`); return null; });
|
|
136
153
|
// Meaning-aware recall through the user's own Gemini key, when there is one;
|
|
137
154
|
// and the memory as MCP tools for every agent's turn.
|
|
@@ -139,6 +156,12 @@ export async function createPulseServer({
|
|
|
139
156
|
// start and again from MODULES; wired live, no restart.
|
|
140
157
|
let ollama = { running: false, host: null, models: [], embedModel: null, chatModel: null };
|
|
141
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
|
+
}
|
|
142
165
|
async function wireOllama({ probe: doProbe = true } = {}) {
|
|
143
166
|
if (doProbe) ollama = await ollamaProbe();
|
|
144
167
|
// A model trained on this room (docs/training) is named madre-<project>: @madre uses it when it exists.
|
|
@@ -188,6 +211,7 @@ export async function createPulseServer({
|
|
|
188
211
|
store,
|
|
189
212
|
agents,
|
|
190
213
|
projectRoot,
|
|
214
|
+
privacy,
|
|
191
215
|
softTokenBudget,
|
|
192
216
|
contextMaxChars,
|
|
193
217
|
recallShare: Number(process.env.PULSE_RECALL_SHARE ?? startupMemory.recallShare),
|
|
@@ -236,6 +260,8 @@ export async function createPulseServer({
|
|
|
236
260
|
geminiRetries: Number(process.env.PULSE_GEMINI_RETRIES ?? 1),
|
|
237
261
|
capabilities: room.capabilities(),
|
|
238
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 },
|
|
239
265
|
};
|
|
240
266
|
}
|
|
241
267
|
// What MU/TH/UR shows under MEMORY: the live values, who could distil, and what embeds today.
|
|
@@ -297,7 +323,9 @@ export async function createPulseServer({
|
|
|
297
323
|
if (!agents.some((agent) => agent.id === agentId) || !scopes || typeof scopes !== 'object') continue;
|
|
298
324
|
config.scopes[agentId] = { ...(current[agentId] ?? {}) };
|
|
299
325
|
for (const scope of ['write', 'imageGen', 'web', 'alwaysCreate']) if (typeof scopes[scope] === 'boolean') config.scopes[agentId][scope] = scopes[scope];
|
|
300
|
-
|
|
326
|
+
// MAX MODE and DEFAULT MODE are the two controls; setting them retires the older flags they replace.
|
|
327
|
+
if (Number.isInteger(Number(scopes.maxMode)) && Number(scopes.maxMode) >= 0 && Number(scopes.maxMode) <= 3) { config.scopes[agentId].maxMode = Number(scopes.maxMode); delete config.scopes[agentId].write; }
|
|
328
|
+
if (Number.isInteger(Number(scopes.defaultMode)) && Number(scopes.defaultMode) >= 1 && Number(scopes.defaultMode) <= 2) { config.scopes[agentId].defaultMode = Number(scopes.defaultMode); delete config.scopes[agentId].alwaysCreate; }
|
|
301
329
|
}
|
|
302
330
|
room.setScopes(config.scopes);
|
|
303
331
|
}
|
|
@@ -675,6 +703,7 @@ export async function createPulseServer({
|
|
|
675
703
|
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)}` } });
|
|
676
704
|
}
|
|
677
705
|
if (request.method === 'GET' && url.pathname === '/api/settings') {
|
|
706
|
+
await refreshPrivacy();
|
|
678
707
|
return sendJson(response, 200, { settings: effectiveSettings(), config: await readConfig(root), sessions, sessionsAt, loggingIn, agents: agents.map((agent) => ({ ...agent, login: loginPlanFor(agent) })) });
|
|
679
708
|
}
|
|
680
709
|
if (request.method === 'POST' && url.pathname === '/api/settings') {
|
|
@@ -709,13 +738,58 @@ export async function createPulseServer({
|
|
|
709
738
|
// The dataset behind MADRE AI: exported on demand next to the ledger.
|
|
710
739
|
if (url.pathname === '/api/dataset' && (request.method === 'GET' || request.method === 'POST')) {
|
|
711
740
|
const dir = join(roomDir, 'dataset');
|
|
741
|
+
const notes = memory ? memory.memories({ limit: 5000 }) : [];
|
|
712
742
|
if (request.method === 'GET') {
|
|
713
743
|
const manifest = await readFile(join(dir, 'manifest.json'), 'utf8').then(JSON.parse).catch(() => null);
|
|
714
|
-
return sendJson(response, 200, { dataset: manifest, dir, trained: ollama.madreModel ?? null });
|
|
744
|
+
return sendJson(response, 200, { dataset: manifest, dir, trained: ollama.madreModel ?? null, readiness: datasetReadiness(await store.readAll(), notes), training: trainingInfo() });
|
|
715
745
|
}
|
|
716
|
-
const
|
|
746
|
+
const events = await store.readAll();
|
|
747
|
+
const result = await exportDataset({ events, notes, dir, project: basename(canonicalProjectRoot), home: homedir(), privacy });
|
|
717
748
|
await room.record('dataset.exported', { pairs: result.pairs, turns: result.turns, notes: result.notes, train: result.train, valid: result.valid, dir });
|
|
718
|
-
return sendJson(response, 200, { dataset: result, dir, trained: ollama.madreModel ?? null });
|
|
749
|
+
return sendJson(response, 200, { dataset: result, dir, trained: ollama.madreModel ?? null, readiness: datasetReadiness(events, notes), training: trainingInfo() });
|
|
750
|
+
}
|
|
751
|
+
// PRIVACY: the terms live in config.json only; the ledger records counts, never words.
|
|
752
|
+
if (request.method === 'GET' && url.pathname === '/api/privacy') {
|
|
753
|
+
await refreshPrivacy();
|
|
754
|
+
return sendJson(response, 200, { terms: privacy.terms, marker: privacy.marker, exposure: room.privacyExposure(await store.readAll()) });
|
|
755
|
+
}
|
|
756
|
+
if (request.method === 'POST' && url.pathname === '/api/privacy') {
|
|
757
|
+
const patch = await body(request).catch(() => ({}));
|
|
758
|
+
const current = (await readConfig(root)).privacy ?? {};
|
|
759
|
+
const next = { ...current };
|
|
760
|
+
if ('terms' in patch) next.terms = normalizeTerms(patch.terms);
|
|
761
|
+
if (typeof patch.marker === 'string' && patch.marker.trim()) next.marker = patch.marker.trim().slice(0, 40);
|
|
762
|
+
await updateConfig(root, { privacy: next });
|
|
763
|
+
privacy.set(next.terms ?? [], next.marker);
|
|
764
|
+
await room.record('privacy.updated', { terms: privacy.terms.length, marker: privacy.marker });
|
|
765
|
+
return sendJson(response, 200, { terms: privacy.terms, marker: privacy.marker, exposure: room.privacyExposure(await store.readAll()) });
|
|
766
|
+
}
|
|
767
|
+
if (request.method === 'POST' && url.pathname === '/api/privacy/purge') {
|
|
768
|
+
const payload = await body(request).catch(() => ({}));
|
|
769
|
+
if (!designationOk(payload.designation)) return sendJson(response, 403, { error: 'UNABLE TO COMPUTE. UNABLE TO CLARIFY.' });
|
|
770
|
+
await refreshPrivacy();
|
|
771
|
+
if (!privacy.enabled) return sendJson(response, 412, { error: 'No private terms are set. Write them first.' });
|
|
772
|
+
const result = await room.purgePrivate();
|
|
773
|
+
// The log was rewritten in place: the broadcaster's byte offset is stale, the sequences are not.
|
|
774
|
+
tailOffset = (await store.tail(0)).offset;
|
|
775
|
+
return sendJson(response, 200, { purged: result, exposure: room.privacyExposure(await store.readAll()) });
|
|
776
|
+
}
|
|
777
|
+
// The human's verdict on a reply, for the dataset: good, bad, none.
|
|
778
|
+
if (request.method === 'POST' && url.pathname === '/api/messages/rate') {
|
|
779
|
+
const payload = await body(request).catch(() => ({}));
|
|
780
|
+
try {
|
|
781
|
+
const event = await room.rateMessage(String(payload.messageId ?? ''), String(payload.rating ?? ''));
|
|
782
|
+
return sendJson(response, 200, { rated: event.payload });
|
|
783
|
+
} catch (error) { return sendJson(response, 400, { error: error.message }); }
|
|
784
|
+
}
|
|
785
|
+
// Release channel: current, latest on npm, and the command for how this copy runs.
|
|
786
|
+
if (request.method === 'GET' && url.pathname === '/api/version') {
|
|
787
|
+
return sendJson(response, 200, await versionView({ force: url.searchParams.get('force') === '1' }));
|
|
788
|
+
}
|
|
789
|
+
if (request.method === 'POST' && url.pathname === '/api/updates/settings') {
|
|
790
|
+
const patch = await body(request).catch(() => ({}));
|
|
791
|
+
if (typeof patch.check === 'boolean') { updatesConfig = { ...updatesConfig, check: patch.check }; await updateConfig(root, { updates: updatesConfig }); }
|
|
792
|
+
return sendJson(response, 200, { updates: { check: updatesEnabled(), envWins: process.env.PULSE_UPDATE_CHECK !== undefined } });
|
|
719
793
|
}
|
|
720
794
|
if (request.method === 'GET' && url.pathname === '/api/mother') {
|
|
721
795
|
return sendJson(response, 200, { mother: room.motherStatus(), strikes: CODE000_STRIKES });
|
|
@@ -811,6 +885,7 @@ export async function createPulseServer({
|
|
|
811
885
|
// hears "no" with a reason instead of a silent log line.
|
|
812
886
|
const gate = await room.modeCheck(payload);
|
|
813
887
|
if (!gate.ok) return sendJson(response, gate.status ?? 403, { error: gate.error, mode: gate.mode, maxMode: gate.maxMode });
|
|
888
|
+
await refreshPrivacy();
|
|
814
889
|
void room.send(payload).catch((error) => {
|
|
815
890
|
console.error(`MADRE room error: ${error.message}`);
|
|
816
891
|
});
|
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
|
+
}
|