@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/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 createLease({ projectRoot: this.#projectRoot, leaseId: randomUUID() });
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 createLease({ projectRoot: this.#projectRoot, leaseId: randomUUID() });
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 standing write lease authorizes files only.
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
- const { context, recall, memories } = await this.#contextFor(priorEvents, { messageId, text });
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
- if (mode === 3 && requester === 'you' && depth === 0) {
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
- const seat = await this.#controlDesk.begin({ agent, messageId, enabledScopes: enabled });
705
- controlRun = seat.run;
706
- lease = seat.lease;
707
- await this.#emit('control.started', seat.announcement);
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 createLease({ projectRoot: this.#projectRoot, leaseId: randomUUID() });
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
- const artifacts = lease && !lease.control ? diffSnapshots(before, await snapshot(lease.outDir), { relativeDir: lease.relativeDir }) : [];
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 others = this.delegatesFor(agent.id);
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 runs at the human's mode: #2 only while a lease exists, and a
833
- // step's agent gets #2 only if its own scopes allow writing.
834
- const stepMode = (agentId) => (lease && this.scopesFor(agentId).write.enabled ? 2 : 1);
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.agent) })),
948
+ steps: directives.steps.map((step) => ({ ...step, mode: stepMode(step) })),
842
949
  closing: directives.closing,
843
950
  ignored: directives.ignored,
844
- mode: lease ? 2 : 1,
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
- if (!lease && !stepScopes.write.always && looksLikeCreation(step.text) && stepScopes.maxMode >= 2 && stepScopes.write.enabled) {
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
- const memory = await new RoomMemory(join(roomDir, 'memory.sqlite')).initialize(store)
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
- if (Number.isInteger(Number(scopes.maxMode)) && Number(scopes.maxMode) >= 0 && Number(scopes.maxMode) <= 3) config.scopes[agentId].maxMode = Number(scopes.maxMode);
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 result = await exportDataset({ events: await store.readAll(), notes: memory ? memory.memories({ limit: 5000 }) : [], dir, project: basename(canonicalProjectRoot), home: homedir() });
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
  });
@@ -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
+ }