@mindot/will 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/README.md +15 -11
  2. package/dist/index.d.ts +188 -141
  3. package/dist/index.js +6985 -3337
  4. package/dist/index.js.map +1 -1
  5. package/dist/surface/channels/discord.d.ts +177 -0
  6. package/dist/surface/channels/discord.js +394 -0
  7. package/dist/surface/channels/discord.js.map +1 -0
  8. package/dist/{channels → surface/channels}/whatsapp.d.ts +2 -2
  9. package/dist/{channels → surface/channels}/whatsapp.js +11 -5
  10. package/dist/surface/channels/whatsapp.js.map +1 -0
  11. package/dist/{cli.js → surface/cli.js} +5382 -1573
  12. package/dist/surface/cli.js.map +1 -0
  13. package/dist/{mcp → surface/mcp}/effectors.d.ts +1 -1
  14. package/dist/{mcp → surface/mcp}/effectors.js +10 -4
  15. package/dist/surface/mcp/effectors.js.map +1 -0
  16. package/dist/{will-cS6k4uiJ.d.ts → will-evj9_vrd.d.ts} +6658 -5078
  17. package/package.json +11 -12
  18. package/src/cognition/agency/consequence.ts +356 -1
  19. package/src/cognition/agency/conversation.aim.ts +292 -0
  20. package/src/cognition/agency/engines/action.selector.ts +142 -5
  21. package/src/cognition/agency/engines/affordance.synthesizer.ts +200 -7
  22. package/src/cognition/agency/engines/deliberation.engine.ts +76 -3
  23. package/src/cognition/agency/engines/motor.schema.executor.ts +509 -32
  24. package/src/cognition/agency/engines/reafference.engine.ts +160 -4
  25. package/src/cognition/agency/execution.primitives.ts +17 -4
  26. package/src/cognition/agency/proactive.communicator.ts +19 -3
  27. package/src/cognition/agency/reconcile.learning.ts +5 -4
  28. package/src/cognition/agency/restart.ts +66 -0
  29. package/src/cognition/agency/schemas/innate.ts +96 -1
  30. package/src/cognition/agency/schemas/repertoire.ts +19 -1
  31. package/src/cognition/agency/selection.scoring.ts +49 -0
  32. package/src/cognition/agency/settlement.ts +203 -0
  33. package/src/cognition/agency/types.ts +73 -1
  34. package/src/cognition/cache/composition.ts +232 -0
  35. package/src/cognition/cache/deliberation.cache.ts +219 -0
  36. package/src/cognition/cache/fingerprint.ts +120 -0
  37. package/src/cognition/cache/types.ts +105 -0
  38. package/src/cognition/config.mirror.entities.ts +108 -1
  39. package/src/cognition/event.schemas.ts +22 -8
  40. package/src/cognition/faculties/affective.blender.ts +21 -4
  41. package/src/cognition/faculties/autobiographical.narrator.ts +5 -10
  42. package/src/cognition/faculties/circadian.oscillator.ts +36 -7
  43. package/src/cognition/faculties/episodic.consolidator.ts +59 -3
  44. package/src/cognition/faculties/executive.engine/action.record.ts +129 -0
  45. package/src/cognition/faculties/executive.engine/commands.ts +189 -14
  46. package/src/cognition/faculties/executive.engine/context.ts +106 -46
  47. package/src/cognition/faculties/executive.engine/deliberate.reasoning.ts +1 -1
  48. package/src/cognition/faculties/executive.engine/engine.ts +714 -136
  49. package/src/cognition/faculties/executive.engine/escalation.buffer.ts +230 -45
  50. package/src/cognition/faculties/executive.engine/facet.supervisor.ts +320 -65
  51. package/src/cognition/faculties/executive.engine/facet.ts +81 -26
  52. package/src/cognition/faculties/executive.engine/gating.ts +14 -14
  53. package/src/cognition/faculties/executive.engine/parser.ts +120 -38
  54. package/src/cognition/faculties/executive.engine/prompt.factory.ts +331 -38
  55. package/src/cognition/faculties/executive.engine/types.ts +84 -12
  56. package/src/cognition/faculties/exteroception.ts +99 -98
  57. package/src/cognition/faculties/goal.manager.ts +144 -16
  58. package/src/cognition/faculties/known.entity.tracker.ts +287 -29
  59. package/src/cognition/faculties/moral.evaluator.ts +8 -3
  60. package/src/cognition/faculties/persona.consolidator.ts +141 -0
  61. package/src/cognition/faculties/planning.engine/engine.ts +4 -4
  62. package/src/cognition/faculties/planning.engine/plan.frontier.ts +1 -1
  63. package/src/cognition/faculties/reputation.tracker.ts +66 -2
  64. package/src/cognition/faculties/self.model.updater.ts +19 -12
  65. package/src/cognition/faculties/semantic.engine/integrator.ts +1 -1
  66. package/src/cognition/faculties/social.perception.ts +47 -3
  67. package/src/cognition/faculties/threat.evaluator.ts +7 -0
  68. package/src/cognition/faculties/working.memory.ts +16 -21
  69. package/src/cognition/identity.entity.ts +205 -0
  70. package/src/cognition/index.ts +7 -0
  71. package/src/cognition/instruction.handler.ts +1 -1
  72. package/src/cognition/memory/vector.adapter.ts +12 -3
  73. package/src/cognition/memory/vector.embedder.ts +45 -4
  74. package/src/cognition/percept.entity.ts +122 -0
  75. package/src/cognition/persona.prior.ts +6 -0
  76. package/src/cognition/sense.boundary.ts +176 -0
  77. package/src/cognition/senses/audition.engine/engine.ts +492 -53
  78. package/src/cognition/senses/base.sense.engine.ts +105 -7
  79. package/src/cognition/senses/index.ts +83 -13
  80. package/src/cognition/senses/provenance.ts +128 -0
  81. package/src/cognition/senses/somatosensation.engine.ts +117 -9
  82. package/src/cognition/social.identity.ts +296 -0
  83. package/src/cognition/utilities/token.tracker.ts +94 -10
  84. package/src/core/orchestrator.ts +51 -0
  85. package/src/index.ts +39 -5
  86. package/src/llm/gate.ts +48 -0
  87. package/src/llm/index.ts +127 -33
  88. package/src/llm/routing.ts +6 -0
  89. package/src/llm/summarizer.ts +1 -1
  90. package/src/llm/wire.contracts.ts +57 -0
  91. package/src/pma/index.ts +68 -54
  92. package/src/stem/assembly.audit.ts +1 -0
  93. package/src/stem/guards/identity.coherence.ts +1 -1
  94. package/src/stem/index.ts +171 -23
  95. package/src/stem/mind.ts +198 -57
  96. package/src/stem/policy/arbiter.ts +10 -0
  97. package/src/stem/profiles/built-in.ts +7 -0
  98. package/src/{profiles → stem/profiles}/companion.ts +1 -1
  99. package/src/{profiles → stem/profiles}/company-brain.ts +1 -1
  100. package/src/{profiles → stem/profiles}/customer-service.ts +1 -1
  101. package/src/{profiles → stem/profiles}/game-npc.ts +1 -1
  102. package/src/{profiles → stem/profiles}/index.ts +1 -1
  103. package/src/{profiles → stem/profiles}/smart-home.ts +1 -1
  104. package/src/stem/tracts/effector/escalation.lifecycle.ts +239 -0
  105. package/src/stem/tracts/effector/policy.enforcement.ts +261 -0
  106. package/src/stem/tracts/effector/types.ts +110 -0
  107. package/src/stem/tracts/effector.controller.ts +112 -401
  108. package/src/stem/tracts/outbox.controller.ts +30 -17
  109. package/src/stem/tracts/outbox.writer.ts +40 -2
  110. package/src/stem/tracts/sensory.controller.ts +6 -6
  111. package/src/stem/tracts/session.logger.ts +6 -1
  112. package/src/stem/tracts/transport/types.ts +20 -1
  113. package/src/stem/tracts/transport.controller.ts +33 -9
  114. package/src/surface/channels/discord.ts +609 -0
  115. package/src/{channels → surface/channels}/roster.ts +1 -1
  116. package/src/surface/channels/types.ts +136 -0
  117. package/src/{channels → surface/channels}/whatsapp.ts +22 -10
  118. package/src/{cli.ts → surface/cli.ts} +14 -9
  119. package/src/{host → surface/host}/boot.ts +3 -3
  120. package/src/{host → surface/host}/utterances.ts +2 -2
  121. package/src/{mcp → surface/mcp}/effectors.ts +19 -6
  122. package/src/{mcp → surface/mcp}/server.ts +19 -5
  123. package/src/{sdk → surface/sdk}/will.ts +194 -33
  124. package/src/{serve → surface/serve}/server.ts +11 -4
  125. package/src/types.ts +24 -3
  126. package/dist/channels/discord.d.ts +0 -69
  127. package/dist/channels/discord.js +0 -193
  128. package/dist/channels/discord.js.map +0 -1
  129. package/dist/channels/whatsapp.js.map +0 -1
  130. package/dist/cli.js.map +0 -1
  131. package/dist/mcp/effectors.js.map +0 -1
  132. package/src/channels/discord.ts +0 -214
  133. package/src/channels/types.ts +0 -46
  134. package/src/cognition/faculties/executive.engine/messages.ts +0 -102
  135. package/src/extensions/livestream.ext.ts +0 -570
  136. package/src/extensions/time.ext.ts +0 -339
  137. package/src/profiles/built-in.ts +0 -7
  138. package/src/runners/coherence.runner.ts +0 -49
  139. package/src/runners/outreach.runner.ts +0 -155
  140. package/src/runners/social.runner.ts +0 -179
  141. package/src/runners/thin-shim.runner.ts +0 -205
  142. /package/dist/{cli.d.ts → surface/cli.d.ts} +0 -0
@@ -15,8 +15,18 @@
15
15
  // caps how many may run at once:
16
16
  // • _facets — live facet instances, keyed by id
17
17
  // • _facetCounter — monotonic id source (facet-1, facet-2, …)
18
+ // • _byKey/_keyOf — a facet's THREAD identity (`FacetSpawnDeps.key`), so one
19
+ // subject is one facet however many callers ask for it, and so eviction can
20
+ // tell an open conversation from a one-shot transient.
21
+ // • _reasoningByKey — a thread's own reasoning, outliving the instance that
22
+ // carried it, so re-opening resumes rather than starting cold.
18
23
  // • _attentionFreeCapacity — free attention (0–1), updated from
19
- // `attention.state.changed`; one facet is allowed per ~0.3 free units.
24
+ // `attention.state.changed`; it SCALES the ceiling below rather than being it.
25
+ // • the ceiling itself is `engine-config-executive.maxFacets` read through the
26
+ // persona-prior — how many things this particular mind can hold at once, a
27
+ // trait the metacognition loop develops, not a constant. Its sibling
28
+ // `facetIdleTtlTicks` — how long a QUIET thread stays open — is read the same
29
+ // way, for the same reason.
20
30
  //
21
31
  // It also keeps `_lastStateRef` / `_sessionLogger` fresh so a facet's
22
32
  // deferred destroy() logs against the same live values the engine would —
@@ -24,9 +34,11 @@
24
34
  // engine's own state ref changes), so the two never diverge.
25
35
  //
26
36
  // Deliberately left in the engine (not here):
27
- // • the `executive.facet.sync` / `audition.task.signal` bus subscriptions
28
- // and the `_facetSyncSubscribed` guard they push into the engine's
29
- // gating salience buffer and are shared with the escalation path;
37
+ // • `executive.facet.sync` / `executive.facet.handoff` handling — dispatched from
38
+ // ExecutiveEngine.onCognitiveEvent (the bus keeps ONE subscription per
39
+ // engineId, so a dedicated `subscribe(this.name, …)` is overwritten by the
40
+ // orchestrator's registration); they push into the engine's gating salience
41
+ // buffer and are shared with the escalation path;
30
42
  // • the `executive.master.sync` publish — it reads the master's reasoning
31
43
  // output; the engine publishes it gated on `size`.
32
44
  //
@@ -43,6 +55,34 @@ import { ExecutiveFacet, type ExecutiveFacetHandle } from '#faculties/executive.
43
55
  import type { CompletionInbox } from '#cognition/completion.inbox'
44
56
  import type { ContextDependencies } from '#faculties/executive.engine/context'
45
57
  import type { PromptDependencies } from '#faculties/executive.engine/prompt.factory'
58
+ import { readEffectiveParams } from '#cognition/persona.prior'
59
+
60
+ /**
61
+ * Facets a Will can hold at once when nothing is seeded — the same value
62
+ * `buildEngineConfigEntities` seeds, so a supervisor running against a state with
63
+ * no engine-config mirror (unit tests, bare harnesses) behaves like a real mind
64
+ * rather than collapsing to the old cap of 3.
65
+ */
66
+ const DEFAULT_MAX_FACETS = 10
67
+
68
+ /**
69
+ * Ticks of quiet before a facet is reaped, when nothing is seeded.
70
+ *
71
+ * This was a hardcoded 50 and it was catastrophic. Measured on a live Will at
72
+ * 1.64 ticks/second, 50 ticks is THIRTY SECONDS — so a conversation facet was
73
+ * destroyed half a minute after the human stopped typing. Her operator's replies
74
+ * were one to three minutes apart, which means every message he sent arrived at a
75
+ * brand-new facet with an empty reasoning history: 290 spawns and 235 destroys in
76
+ * a 29-minute session, ten a minute. She was not losing the thread; the thread was
77
+ * being destroyed between his turns, and re-asking the question he had already
78
+ * answered is exactly what a cold facet does.
79
+ *
80
+ * The right scale is human, not machine: a conversation stays open across the pause
81
+ * where someone goes to make coffee. 3000 ticks is ~30 minutes at that rate. Nothing
82
+ * is leaked by being generous here — `maxFacets` plus LRU eviction is what actually
83
+ * bounds the population, and this only decides when a QUIET thread is considered over.
84
+ */
85
+ const DEFAULT_FACET_IDLE_TTL_TICKS = 3000
46
86
 
47
87
  /**
48
88
  * The engine attachments a spawn needs, captured at call time. bus / director
@@ -58,10 +98,41 @@ export interface FacetSpawnDeps {
58
98
  willId: string | null
59
99
  /** Tick-boundary landing for decision effects (see cognition/completion.inbox). */
60
100
  inbox?: CompletionInbox | null
101
+ /**
102
+ * What this facet is FOR — a caller-chosen identity for the thread, e.g.
103
+ * `conversation:discord:1019…`. Two spawns with the same key are the same
104
+ * thread of attention, so the second returns the first's handle instead of
105
+ * opening a rival facet on the same subject.
106
+ *
107
+ * Keying is opt-in because not every facet has a subject (a one-shot
108
+ * deliberation does not). But a keyless facet is also the one the mind can
109
+ * most afford to lose, so eviction takes those first — see _evictionVictim.
110
+ *
111
+ * Without this the registry was keyed only by `facet-N` and every caller
112
+ * deduplicated (or didn't) on its own: AuditionEngine kept one facet per
113
+ * speaker, `authorOutreach` kept none, and a proactive message to someone
114
+ * could evict the live conversation with that same person.
115
+ */
116
+ key?: string
61
117
  }
62
118
 
63
- export type SpawnResult =
64
- { attention: 'available' | 'full', handle?: ExecutiveFacetHandle }
119
+ export type SpawnResult = { attention: 'available' | 'full', handle?: ExecutiveFacetHandle }
120
+
121
+ /**
122
+ * How a facet gets built. Exists so a caller can stand something else in place of
123
+ * a real reasoning loop — chiefly a test that wants to observe the REGISTRY
124
+ * (keying, eviction, continuity) without paying for an LLM director.
125
+ *
126
+ * A seam rather than a module mock, deliberately. `mock.module` / `vi.mock` is
127
+ * process-global and permanent in Bun's runner: one file mocking this module
128
+ * replaced ExecutiveFacet for every file that loaded AFTER it, in the same
129
+ * process. That turned a green branch red — the audition reply tests sat waiting
130
+ * on a facet whose `pump()` was a no-op and timed out at 30s — and because the
131
+ * damage follows file order, the failing set differed between CI and a local run,
132
+ * which reads exactly like flake. Injection is scoped to the supervisor that asked
133
+ * for it and cannot reach anybody else.
134
+ */
135
+ export type FacetFactory = ( facetId: string, deps: FacetSpawnDeps ) => ExecutiveFacet
65
136
 
66
137
  export class FacetSupervisor {
67
138
  private _facets = new Map<string, ExecutiveFacet>()
@@ -75,19 +146,110 @@ export class FacetSupervisor {
75
146
 
76
147
  /** Reap callbacks per facet — fired when the supervisor reaps (idle/LRU), not on explicit destroy(). */
77
148
  private _onReaped = new Map<string, () => void>()
78
- /** Ticks of inactivity before a facet is reaped (reclaims its attention budget). */
79
- private readonly _idleTtlTicks: number
149
+
150
+ /** Live handles by facetId — returned again when a keyed spawn matches an open thread. */
151
+ private _handles = new Map<string, ExecutiveFacetHandle>()
152
+ /** `FacetSpawnDeps.key` → facetId, for the keyed spawns. */
153
+ private _byKey = new Map<string, string>()
154
+ /** facetId → its key, so reaping can clear both directions. */
155
+ private _keyOf = new Map<string, string>()
156
+
157
+ /**
158
+ * A keyed thread's own prior reasoning, surviving the facet that produced it.
159
+ *
160
+ * A facet reaped mid-conversation used to take its train of thought with it: the
161
+ * transcript survived (AuditionEngine holds the digest by thread) but everything
162
+ * the mind had privately worked out about that person was gone, so the replacement
163
+ * facet re-derived it — or, more often, re-asked. Continuity belongs to the thread,
164
+ * not to the instance that happened to be carrying it.
165
+ */
166
+ private _reasoningByKey = new Map<string, string[]>()
167
+
168
+ /** Constructor override for the idle TTL — null means read it from the persona. */
169
+ private readonly _idleTtlOverride: number | null
80
170
  /** When the budget is full, evict the least-recently-active facet instead of refusing a spawn. */
81
171
  private readonly _evictLruOnPressure: boolean
172
+ /** What a spawn constructs — see FacetFactory. */
173
+ private readonly _createFacet: FacetFactory
82
174
 
83
- constructor( opts: { idleTtlTicks?: number; evictLruOnPressure?: boolean } = {} ){
84
- this._idleTtlTicks = opts.idleTtlTicks ?? 50
175
+ constructor( opts: { idleTtlTicks?: number; evictLruOnPressure?: boolean; createFacet?: FacetFactory } = {} ){
176
+ this._idleTtlOverride = opts.idleTtlTicks ?? null
85
177
  this._evictLruOnPressure = opts.evictLruOnPressure ?? true
178
+ // `spawn` throws on a missing bus / director / stateRef before it ever gets
179
+ // here, so the factory is only ever called with those present — the same
180
+ // reason `willId` was already asserted at this call.
181
+ this._createFacet = opts.createFacet ?? ( ( facetId, deps ) => new ExecutiveFacet(
182
+ facetId,
183
+ deps.bus!,
184
+ deps.llmDirector!,
185
+ deps.contextDeps,
186
+ deps.promptDeps,
187
+ deps.willId!,
188
+ deps.inbox ?? null
189
+ ) )
190
+ }
191
+
192
+ /**
193
+ * How long a quiet facet lives, in ticks.
194
+ *
195
+ * Read through the persona-prior like `maxFacets`, because it is the same kind of
196
+ * fact about a person: how long a conversation stays open for them before it feels
197
+ * finished. It was the one number in this economy that no personality could move —
198
+ * the ceiling was developable while the thing doing the killing was a constant.
199
+ */
200
+ private _idleTtl( state: ReadonlySimulationState | null ): number {
201
+ if( this._idleTtlOverride != null ) return this._idleTtlOverride
202
+ if( !state ) return DEFAULT_FACET_IDLE_TTL_TICKS
203
+ return Math.max(
204
+ 1,
205
+ Math.round(
206
+ readEffectiveParams( state, 'engine-config-executive').facetIdleTtlTicks
207
+ ?? DEFAULT_FACET_IDLE_TTL_TICKS
208
+ )
209
+ )
86
210
  }
87
211
 
88
212
  /** Number of live facets — the engine gates `master.sync` on this. */
89
213
  get size(): number { return this._facets.size }
90
214
 
215
+ /**
216
+ * The facet already carrying `key`, if any — WITHOUT opening one.
217
+ *
218
+ * Lets a caller ask "am I already attending to this?" and act differently when
219
+ * the answer is yes. The case it exists for: the mind decides, on its own
220
+ * initiative, to say something to someone it is ALREADY in conversation with.
221
+ * That is not a second thread; it is a thing to say in the thread that is open.
222
+ */
223
+ handleFor( key: string ): ExecutiveFacetHandle | undefined {
224
+ const id = this._byKey.get( key )
225
+ return id ? this._handles.get( id ) : undefined
226
+ }
227
+
228
+ /** Ids of the facets currently alive — the engine prunes its subject map against these. */
229
+ liveFacetIds(): Set<string> { return new Set( this._facets.keys() ) }
230
+
231
+ /**
232
+ * Ids of the facets currently REASONING — queued reports or an in-flight
233
+ * `_reason()`. These are what the mind is actually attending to, and the engine
234
+ * turns them into `attention.demand` entities so they cost the AttentionAllocator
235
+ * real capacity.
236
+ *
237
+ * The distinction is the whole two-level model: an open facet is a thread the
238
+ * mind is IN (bounded by the persona's `maxFacets`), a busy one is a thread it is
239
+ * ATTENDING TO (bounded by the allocator's `maxFoci`, and paid for out of the
240
+ * same 100-unit budget as every other focus). You can be in ten conversations and
241
+ * attending to two. Without this, holding conversations cost the allocator
242
+ * nothing, so `freeFraction` — the very signal the facet budget scales on —
243
+ * reported the same spare attention whether the mind was idle or mid-thread with
244
+ * three people.
245
+ */
246
+ busyFacetIds(): string[] {
247
+ const out: string[] = []
248
+ for( const [ id, facet ] of this._facets )
249
+ if( facet.busy ) out.push( id )
250
+ return out
251
+ }
252
+
91
253
  attachSessionLogger( logger: SessionLogger | null ): void {
92
254
  this._sessionLogger = logger
93
255
  }
@@ -96,10 +258,10 @@ export class FacetSupervisor {
96
258
  * Update the attention budget from an `attention.state.changed` event.
97
259
  *
98
260
  * `freeFraction` is the allocator's normalized 0–1 spare-attention signal
99
- * (free capacity ÷ baseline capacity). It is consumed directly — one facet per
100
- * ~0.3 free units so the budget binds on the same scale the `0.3` constant
101
- * and the default (`1` 3 facets) assume. (Pre-fix this received the raw 0–100
102
- * capacity, inflating the budget ~100× so facets were bounded only by TTL/LRU.)
261
+ * (free capacity ÷ baseline capacity). It scales the persona's facet ceiling:
262
+ * fully free the whole ceiling, half free about half of it, never below 1.
263
+ * (It must stay normalized an earlier version received the raw 0–100 capacity,
264
+ * inflating the budget ~100× so facets were bounded only by TTL/LRU.)
103
265
  */
104
266
  setAttentionState( freeFraction: number ): void {
105
267
  this._attentionFreeCapacity = Math.max( 0, freeFraction )
@@ -133,13 +295,14 @@ export class FacetSupervisor {
133
295
  }
134
296
 
135
297
  private _reapIdle( tick: number ): void {
298
+ const ttl = this._idleTtl( this._lastStateRef )
136
299
  for( const [ id, facet ] of [ ...this._facets ] ){
137
300
  // Never reap a busy facet: queued reports / an in-flight LLM call span
138
301
  // many ticks (a real call is 10–30s), and destroying the facet clears the
139
302
  // listeners its pending decision lands on — a conversation reply would
140
303
  // vanish silently. The TTL measures *quiet* facets only.
141
304
  if( facet.busy ) continue
142
- if( tick - facet.lastActiveTick > this._idleTtlTicks )
305
+ if( tick - facet.lastActiveTick > ttl )
143
306
  this._reap( id, 'idle')
144
307
  }
145
308
  }
@@ -149,8 +312,19 @@ export class FacetSupervisor {
149
312
  const facet = this._facets.get( facetId )
150
313
  if( !facet ) return
151
314
 
315
+ // Keep the thread's reasoning before the instance carrying it is destroyed,
316
+ // so a later spawn on the same key resumes rather than starting cold.
317
+ const key = this._keyOf.get( facetId )
318
+ if( key ){
319
+ const carried = facet.reasoningHistory
320
+ if( carried.length ) this._reasoningByKey.set( key, carried )
321
+ this._byKey.delete( key )
322
+ this._keyOf.delete( facetId )
323
+ }
324
+
152
325
  facet.destroy()
153
326
  this._facets.delete( facetId )
327
+ this._handles.delete( facetId )
154
328
  const onReaped = this._onReaped.get( facetId )
155
329
  this._onReaped.delete( facetId )
156
330
 
@@ -159,6 +333,11 @@ export class FacetSupervisor {
159
333
  type: 'executive.facet.destroy',
160
334
  tick: this._lastStateRef?.tick as unknown as number ?? 0,
161
335
  facetId,
336
+ // The key a reader needs to tell the two populations apart. A KEYED facet
337
+ // dying is a conversation ending; a keyless one dying is a deliberation
338
+ // finishing, which is what the executive does every time it thinks. Without
339
+ // it a churn metric counts both and reads engagement as damage.
340
+ ...( key ? { key } : {} ),
162
341
  reason,
163
342
  totalFacets: this._facets.size,
164
343
  } as any)
@@ -168,20 +347,33 @@ export class FacetSupervisor {
168
347
  catch( err ){ logger.error(`[executive] facet ${facetId} onReaped error:`, err ) }
169
348
  }
170
349
 
171
- private _leastRecentlyActive(): string | null {
172
- // Prefer a quiet victim: evicting a busy facet drops its in-flight decision
173
- // (same silent-loss mode the idle reaper guards against). Only when every
174
- // facet is busy does pressure eviction fall back to the absolute LRU — a
175
- // new conversation still preempts rather than being refused.
176
- let id: string | null = null
177
- let min = Infinity
178
- for( const [ fid, facet ] of this._facets )
179
- if( !facet.busy && facet.lastActiveTick < min ){ min = facet.lastActiveTick; id = fid }
180
- if( id ) return id
181
-
182
- for( const [ fid, facet ] of this._facets )
183
- if( facet.lastActiveTick < min ){ min = facet.lastActiveTick; id = fid }
184
- return id
350
+ /**
351
+ * Who gets evicted when the budget is full, in order of what the mind can
352
+ * afford to lose:
353
+ *
354
+ * 1. quiet AND keyless a transient facet (a one-shot authoring pass, a
355
+ * deliberation) with nobody on the other end of it;
356
+ * 2. quiet but keyed — an open thread with a real subject;
357
+ * 3. busy last resort, and it drops an in-flight decision.
358
+ *
359
+ * Tier 1 exists because of an observed inversion: a proactive outreach spawn
360
+ * would evict the LIVE CONVERSATION FACET WITH THAT SAME PERSON. A conversation
361
+ * facet waiting on the human's next message is, correctly, not `busy` so it was
362
+ * the most attractive LRU victim in the registry, and deciding to message someone
363
+ * destroyed the conversation already open with them.
364
+ */
365
+ private _evictionVictim(): string | null {
366
+ const oldestIn = ( pick: ( id: string, f: ExecutiveFacet ) => boolean ): string | null => {
367
+ let id: string | null = null
368
+ let min = Infinity
369
+ for( const [ fid, facet ] of this._facets )
370
+ if( pick( fid, facet ) && facet.lastActiveTick < min ){ min = facet.lastActiveTick; id = fid }
371
+ return id
372
+ }
373
+
374
+ return oldestIn( ( fid, f ) => !f.busy && !this._keyOf.has( fid ) )
375
+ ?? oldestIn( ( _fid, f ) => !f.busy )
376
+ ?? oldestIn( () => true )
185
377
  }
186
378
 
187
379
  /**
@@ -210,33 +402,67 @@ export class FacetSupervisor {
210
402
  // destroy() logs the same tick the engine would.
211
403
  this._lastStateRef = deps.stateRef
212
404
 
213
- // One facet per ~0.3 free capacity units, floor at 1
214
- const maxFacets = Math.max( 1, Math.floor( this._attentionFreeCapacity / 0.3 ) )
405
+ // Same key same thread of attention. Hand back the facet already carrying
406
+ // it rather than opening a rival on the same subject: two facets on one person
407
+ // is two minds answering as one, and both of them were reported to `_facetSubjects`,
408
+ // so the master read itself as being in two conversations with the same person.
409
+ if( deps.key ){
410
+ const openId = this._byKey.get( deps.key )
411
+ const open = openId ? this._handles.get( openId ) : undefined
412
+ if( open ){
413
+ this._facets.get( openId! )?.markActive( deps.stateRef.tick as unknown as number )
414
+ logger.info(`[executive] facet ${openId} reused for "${deps.key}"`)
415
+ return { attention: 'available', handle: open }
416
+ }
417
+ }
418
+
419
+ // How many focused facets this mind can hold at once.
420
+ //
421
+ // This is the OPEN-THREAD level. Its sibling is the allocator's
422
+ // `engine-config-attention.maxFoci` — how many things the mind ATTENDS TO at
423
+ // once — and the two are one economy, not two budgets: a facet that is actually
424
+ // reasoning is published as an `attention` (ExecutiveEngine.
425
+ // _facetAttentionDemands) and competes for those foci slots against every
426
+ // percept, paying `costPerFocus` out of the same 100-unit capacity. So a Will
427
+ // can be IN ten conversations while ATTENDING TO two, and the cost of the ones
428
+ // it is attending to flows back into the free fraction below. The same
429
+ // openness/conscientiousness pair develops both levels (consolidator 27c/27d),
430
+ // so they move together rather than drifting apart.
431
+ //
432
+ // Two layers, deliberately separate:
433
+ // • the CEILING is who this person is — `engine-config-executive.maxFacets`
434
+ // read through the persona-prior, so openness widens it and
435
+ // conscientiousness narrows it as the Will demonstrates those traits. It is
436
+ // a property of the mind, not a constant in the code.
437
+ // • the live ALLOWANCE is how loaded it is right now — spare attention scales
438
+ // the ceiling, so a tired or saturated mind takes on fewer new threads and
439
+ // recovers the room as attention frees up.
440
+ //
441
+ // Previously the second layer WAS the ceiling (one facet per 0.3 free units,
442
+ // max 3), which put a hard architectural cap on the mind that no personality
443
+ // could move: at 52% night capacity it resolved to exactly 1, so a Will could
444
+ // hold one conversation and every second person to speak evicted the first.
445
+ const ceiling = Math.max( 1, Math.round( readEffectiveParams( deps.stateRef, 'engine-config-executive').maxFacets ?? DEFAULT_MAX_FACETS ) )
446
+ const maxFacets = Math.max( 1, Math.min( ceiling, Math.round( ceiling * this._attentionFreeCapacity ) ) )
447
+
215
448
  if( this._facets.size >= maxFacets ){
216
449
  if( !this._evictLruOnPressure ){
217
450
  logger.info(`[executive] attention full (${this._facets.size}/${maxFacets} facets) `)
218
451
  return { attention: 'full' }
219
452
  }
220
- // Budget full but a new conversation arrived — evict the least-recently-active
221
- // facet so a fresh, live conversation preempts a stale one (no silent drop).
222
- const lru = this._leastRecentlyActive()
453
+ // Budget full but a new thread arrived — evict the cheapest loss (transient
454
+ // and quiet first, an open conversation only if there is nothing else) so a
455
+ // live thread preempts a stale one without a silent drop.
456
+ const lru = this._evictionVictim()
223
457
  if( !lru ) return { attention: 'full' } // nothing evictable (shouldn't happen)
224
- logger.info(`[executive] attention full (${this._facets.size}/${maxFacets}) — evicting LRU facet ${lru}`)
458
+ logger.info(`[executive] attention full (${this._facets.size}/${maxFacets}) — evicting facet ${lru}`)
225
459
  this._reap( lru, 'lru')
226
460
  }
227
461
 
228
462
  this._facetCounter++
229
463
  const facetId = `facet-${this._facetCounter}`
230
464
 
231
- const facet = new ExecutiveFacet(
232
- facetId,
233
- deps.bus,
234
- deps.llmDirector,
235
- deps.contextDeps,
236
- deps.promptDeps,
237
- deps.willId!,
238
- deps.inbox ?? null
239
- )
465
+ const facet = this._createFacet( facetId, deps )
240
466
 
241
467
  // Attach session logger if available
242
468
  if( this._sessionLogger )
@@ -248,38 +474,67 @@ export class FacetSupervisor {
248
474
 
249
475
  this._facets.set( facetId, facet )
250
476
 
477
+ if( deps.key ){
478
+ this._byKey.set( deps.key, facetId )
479
+ this._keyOf.set( facetId, deps.key )
480
+
481
+ // Resume the thread's own thinking. The words were never lost (the digest
482
+ // survives with the thread); what was lost was everything the mind had
483
+ // privately worked out about this person, which is why a replacement facet
484
+ // re-asked what it had already been told.
485
+ const carried = this._reasoningByKey.get( deps.key )
486
+ if( carried?.length ){
487
+ facet.restoreReasoningHistory( carried )
488
+ logger.info(`[executive] facet ${facetId} resumed "${deps.key}" (${carried.length} prior turns)`)
489
+ }
490
+ }
491
+
251
492
  logger.info(`[executive] spawned facet → ${facetId} (total facets: ${this._facets.size})`)
252
493
 
253
494
  this._sessionLogger?.write({
254
495
  type: 'executive.facet.spawn',
255
496
  tick: deps.stateRef.tick as unknown as number,
256
497
  facetId,
498
+ ...( deps.key ? { key: deps.key } : {} ),
257
499
  totalFacets: this._facets.size,
258
500
  } as any)
259
501
 
260
- return {
261
- attention: 'available',
262
- handle: {
263
- facetId,
264
- setFocus: ( focus ) => facet.setFocus( focus ),
265
- setStateRef: ( state ) => facet.setStateRef( state ),
266
- report: ( report ) => facet.report( report ),
267
- subscribe: ( listener ) => facet.subscribe( listener ),
268
- onChunk: ( handler ) => facet.setChunkHandler( handler ),
269
- onReaped: ( handler ) => { this._onReaped.set( facetId, handler ) },
270
- destroy: () => {
271
- facet.destroy()
272
- this._facets.delete( facetId )
273
- this._onReaped.delete( facetId ) // explicit close — owner already knows; don't fire onReaped
274
- logger.info(`[executive] facet ${facetId} destroyed (remaining: ${this._facets.size})`)
275
- this._sessionLogger?.write({
276
- type: 'executive.facet.destroy',
277
- tick: this._lastStateRef?.tick as unknown as number ?? 0,
278
- facetId,
279
- totalFacets: this._facets.size,
280
- } as any)
502
+ const handle: ExecutiveFacetHandle = {
503
+ facetId,
504
+ setFocus: ( focus ) => facet.setFocus( focus ),
505
+ setStateRef: ( state ) => facet.setStateRef( state ),
506
+ report: ( report ) => facet.report( report ),
507
+ subscribe: ( listener ) => facet.subscribe( listener ),
508
+ onChunk: ( handler ) => facet.setChunkHandler( handler ),
509
+ onReaped: ( handler ) => { this._onReaped.set( facetId, handler ) },
510
+ destroy: () => {
511
+ const key = this._keyOf.get( facetId )
512
+ if( key ){
513
+ const carried = facet.reasoningHistory
514
+ if( carried.length ) this._reasoningByKey.set( key, carried )
515
+ this._byKey.delete( key )
516
+ this._keyOf.delete( facetId )
281
517
  }
518
+ facet.destroy()
519
+ this._facets.delete( facetId )
520
+ this._handles.delete( facetId )
521
+ this._onReaped.delete( facetId ) // explicit close — owner already knows; don't fire onReaped
522
+ logger.info(`[executive] facet ${facetId} destroyed (remaining: ${this._facets.size})`)
523
+ this._sessionLogger?.write({
524
+ type: 'executive.facet.destroy',
525
+ tick: this._lastStateRef?.tick as unknown as number ?? 0,
526
+ facetId,
527
+ ...( key ? { key } : {} ),
528
+ // Named so the three ways a facet can end are distinguishable in a
529
+ // trace: its owner closed it, the TTL took a quiet one, or pressure
530
+ // evicted it. Only the last two are ever a symptom.
531
+ reason: 'explicit',
532
+ totalFacets: this._facets.size,
533
+ } as any)
282
534
  }
283
535
  }
536
+
537
+ this._handles.set( facetId, handle )
538
+ return { attention: 'available', handle }
284
539
  }
285
540
  }