@mindot/will 0.1.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 (178) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +777 -0
  3. package/dist/index.d.ts +8144 -0
  4. package/dist/index.js +26231 -0
  5. package/dist/index.js.map +1 -0
  6. package/package.json +73 -0
  7. package/src/cognition/agency/access.grants.ts +59 -0
  8. package/src/cognition/agency/competence.codec.ts +89 -0
  9. package/src/cognition/agency/engines/action.selector.ts +462 -0
  10. package/src/cognition/agency/engines/affordance.synthesizer.ts +384 -0
  11. package/src/cognition/agency/engines/deliberation.engine.ts +241 -0
  12. package/src/cognition/agency/engines/instruction.intake.ts +54 -0
  13. package/src/cognition/agency/engines/motor.schema.executor.ts +532 -0
  14. package/src/cognition/agency/engines/reafference.engine.ts +241 -0
  15. package/src/cognition/agency/execution.primitives.ts +118 -0
  16. package/src/cognition/agency/index.ts +58 -0
  17. package/src/cognition/agency/proactive.communicator.ts +274 -0
  18. package/src/cognition/agency/reconcile.learning.ts +82 -0
  19. package/src/cognition/agency/schemas/external.ts +59 -0
  20. package/src/cognition/agency/schemas/innate.ts +111 -0
  21. package/src/cognition/agency/schemas/repertoire.ts +246 -0
  22. package/src/cognition/agency/selection.scoring.ts +191 -0
  23. package/src/cognition/agency/types.ts +165 -0
  24. package/src/cognition/bus.ts +391 -0
  25. package/src/cognition/completion.inbox.ts +86 -0
  26. package/src/cognition/config.mirror.entities.ts +475 -0
  27. package/src/cognition/conversation.memory.ts +81 -0
  28. package/src/cognition/event.log.ts +116 -0
  29. package/src/cognition/event.schemas.ts +568 -0
  30. package/src/cognition/faculties/aesthetic.evaluator.ts +313 -0
  31. package/src/cognition/faculties/affective.blender.ts +562 -0
  32. package/src/cognition/faculties/attachment.evaluator.ts +432 -0
  33. package/src/cognition/faculties/attention.allocator.ts +422 -0
  34. package/src/cognition/faculties/autobiographical.narrator.ts +300 -0
  35. package/src/cognition/faculties/bias.detector.ts +363 -0
  36. package/src/cognition/faculties/circadian.oscillator.ts +294 -0
  37. package/src/cognition/faculties/confidence.calibrator.ts +324 -0
  38. package/src/cognition/faculties/dream.simulator.ts +251 -0
  39. package/src/cognition/faculties/empathy.simulator.ts +253 -0
  40. package/src/cognition/faculties/energy.regulator.ts +320 -0
  41. package/src/cognition/faculties/episodic.consolidator.ts +728 -0
  42. package/src/cognition/faculties/executive.engine/commands.ts +459 -0
  43. package/src/cognition/faculties/executive.engine/config.ts +46 -0
  44. package/src/cognition/faculties/executive.engine/context.ts +605 -0
  45. package/src/cognition/faculties/executive.engine/deferred.effects.ts +104 -0
  46. package/src/cognition/faculties/executive.engine/deliberate.reasoning.ts +60 -0
  47. package/src/cognition/faculties/executive.engine/effort.gate.ts +114 -0
  48. package/src/cognition/faculties/executive.engine/engine.ts +1028 -0
  49. package/src/cognition/faculties/executive.engine/escalation.buffer.ts +90 -0
  50. package/src/cognition/faculties/executive.engine/facet.supervisor.ts +271 -0
  51. package/src/cognition/faculties/executive.engine/facet.ts +628 -0
  52. package/src/cognition/faculties/executive.engine/gating.ts +222 -0
  53. package/src/cognition/faculties/executive.engine/index.ts +6 -0
  54. package/src/cognition/faculties/executive.engine/messages.ts +102 -0
  55. package/src/cognition/faculties/executive.engine/parser.ts +380 -0
  56. package/src/cognition/faculties/executive.engine/prompt.factory.ts +1053 -0
  57. package/src/cognition/faculties/executive.engine/types.ts +323 -0
  58. package/src/cognition/faculties/exteroception.ts +338 -0
  59. package/src/cognition/faculties/forgetting.curve.ts +202 -0
  60. package/src/cognition/faculties/frustration.evaluator.ts +280 -0
  61. package/src/cognition/faculties/goal.manager.ts +1008 -0
  62. package/src/cognition/faculties/inhibition.controller.ts +351 -0
  63. package/src/cognition/faculties/interoception.ts +417 -0
  64. package/src/cognition/faculties/introspection.engine.ts +246 -0
  65. package/src/cognition/faculties/known.entity.tracker.ts +439 -0
  66. package/src/cognition/faculties/loss.evaluator.ts +224 -0
  67. package/src/cognition/faculties/moral.evaluator.ts +437 -0
  68. package/src/cognition/faculties/novelty.detector.ts +254 -0
  69. package/src/cognition/faculties/persona.consolidator.ts +845 -0
  70. package/src/cognition/faculties/planning.engine/engine.ts +859 -0
  71. package/src/cognition/faculties/planning.engine/plan.frontier.ts +83 -0
  72. package/src/cognition/faculties/planning.engine/plan.store.ts +174 -0
  73. package/src/cognition/faculties/planning.engine/plan.supervision.ts +494 -0
  74. package/src/cognition/faculties/planning.engine/types.ts +143 -0
  75. package/src/cognition/faculties/reputation.tracker.ts +294 -0
  76. package/src/cognition/faculties/reward.evaluator.ts +304 -0
  77. package/src/cognition/faculties/self.model.updater.ts +677 -0
  78. package/src/cognition/faculties/semantic.engine/clustering.ts +673 -0
  79. package/src/cognition/faculties/semantic.engine/index.ts +6 -0
  80. package/src/cognition/faculties/semantic.engine/integrator.ts +750 -0
  81. package/src/cognition/faculties/semantic.engine/types.ts +53 -0
  82. package/src/cognition/faculties/sleep.pressure.regulator.ts +262 -0
  83. package/src/cognition/faculties/social.perception.ts +333 -0
  84. package/src/cognition/faculties/spaced.repetition.ts +605 -0
  85. package/src/cognition/faculties/stress.regulator.ts +375 -0
  86. package/src/cognition/faculties/task.switcher.ts +239 -0
  87. package/src/cognition/faculties/theory.of.mind.ts +333 -0
  88. package/src/cognition/faculties/threat.evaluator.ts +312 -0
  89. package/src/cognition/faculties/working.memory.ts +406 -0
  90. package/src/cognition/generative.model.ts +344 -0
  91. package/src/cognition/heartbeat.ts +70 -0
  92. package/src/cognition/index.ts +291 -0
  93. package/src/cognition/instruction.handler.ts +146 -0
  94. package/src/cognition/memory/index.ts +35 -0
  95. package/src/cognition/memory/vector.adapter.ts +316 -0
  96. package/src/cognition/memory/vector.content.ts +47 -0
  97. package/src/cognition/memory/vector.embedder.ts +212 -0
  98. package/src/cognition/memory/vector.index.ts +459 -0
  99. package/src/cognition/memory/vector.types.ts +65 -0
  100. package/src/cognition/orchestrator.ts +142 -0
  101. package/src/cognition/persona.prior.ts +265 -0
  102. package/src/cognition/schema.registry.ts +145 -0
  103. package/src/cognition/senses/audition.engine/engine.ts +976 -0
  104. package/src/cognition/senses/audition.engine/salience.ts +59 -0
  105. package/src/cognition/senses/base.sense.engine.ts +117 -0
  106. package/src/cognition/senses/gustation.engine.ts +24 -0
  107. package/src/cognition/senses/index.ts +172 -0
  108. package/src/cognition/senses/olfaction.engine.ts +24 -0
  109. package/src/cognition/senses/somatosensation.engine.ts +23 -0
  110. package/src/cognition/senses/vision.engine.ts +23 -0
  111. package/src/cognition/types.ts +63 -0
  112. package/src/cognition/utilities/token.tracker.ts +492 -0
  113. package/src/core/abstracts.ts +60 -0
  114. package/src/core/async.engine.ts +539 -0
  115. package/src/core/clock.ts +196 -0
  116. package/src/core/completion.recorder.ts +122 -0
  117. package/src/core/conflict.detector.ts +140 -0
  118. package/src/core/distributed.ts +626 -0
  119. package/src/core/event.bus.ts +292 -0
  120. package/src/core/inbound.recorder.ts +100 -0
  121. package/src/core/index.ts +167 -0
  122. package/src/core/logger.ts +85 -0
  123. package/src/core/metrics.ts +107 -0
  124. package/src/core/orchestrator.ts +625 -0
  125. package/src/core/replay.ts +589 -0
  126. package/src/core/scenario.ts +120 -0
  127. package/src/core/serialization.ts +448 -0
  128. package/src/core/simulation.ts +197 -0
  129. package/src/core/snapshot.manager.ts +293 -0
  130. package/src/core/state.manager.ts +283 -0
  131. package/src/core/types.ts +274 -0
  132. package/src/core/utils.ts +82 -0
  133. package/src/core/wall.clock.ts +23 -0
  134. package/src/extensions/livestream.ext.ts +570 -0
  135. package/src/extensions/time.ext.ts +339 -0
  136. package/src/index.ts +91 -0
  137. package/src/llm/gate.ts +140 -0
  138. package/src/llm/index.ts +716 -0
  139. package/src/llm/summarizer.ts +170 -0
  140. package/src/llm/wire.contracts.ts +67 -0
  141. package/src/pma/eval.ts +651 -0
  142. package/src/pma/index.ts +1082 -0
  143. package/src/profiles/built-in.ts +7 -0
  144. package/src/profiles/companion.ts +28 -0
  145. package/src/profiles/company-brain.ts +54 -0
  146. package/src/profiles/customer-service.ts +30 -0
  147. package/src/profiles/game-npc.ts +20 -0
  148. package/src/profiles/index.ts +42 -0
  149. package/src/profiles/smart-home.ts +34 -0
  150. package/src/runners/coherence.runner.ts +49 -0
  151. package/src/runners/outreach.runner.ts +158 -0
  152. package/src/runners/social.runner.ts +182 -0
  153. package/src/runners/thin-shim.runner.ts +195 -0
  154. package/src/sdk/will.ts +334 -0
  155. package/src/stem/assembly.audit.ts +88 -0
  156. package/src/stem/distribution.ts +203 -0
  157. package/src/stem/guards/identity.coherence.ts +152 -0
  158. package/src/stem/guards/identity.guard.ts +227 -0
  159. package/src/stem/index.ts +1145 -0
  160. package/src/stem/mind.ts +1090 -0
  161. package/src/stem/tracts/ack.reconciler.ts +55 -0
  162. package/src/stem/tracts/biography.writer.ts +190 -0
  163. package/src/stem/tracts/effector.controller.ts +143 -0
  164. package/src/stem/tracts/health.reporter.ts +113 -0
  165. package/src/stem/tracts/inbound.queue.ts +54 -0
  166. package/src/stem/tracts/outbox.controller.ts +167 -0
  167. package/src/stem/tracts/outbox.writer.ts +133 -0
  168. package/src/stem/tracts/pma.controller.ts +109 -0
  169. package/src/stem/tracts/replay.controller.ts +177 -0
  170. package/src/stem/tracts/sensory.controller.ts +168 -0
  171. package/src/stem/tracts/session.logger.ts +157 -0
  172. package/src/stem/tracts/transport/index.ts +38 -0
  173. package/src/stem/tracts/transport/loopback.transport.ts +86 -0
  174. package/src/stem/tracts/transport/socketio.transport.ts +200 -0
  175. package/src/stem/tracts/transport/stream.transport.ts +152 -0
  176. package/src/stem/tracts/transport/types.ts +193 -0
  177. package/src/stem/tracts/transport.controller.ts +380 -0
  178. package/src/types.ts +116 -0
@@ -0,0 +1,976 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // src/cognition/senses/audition.engine/engine.ts
3
+ // ─────────────────────────────────────────────────────────────
4
+
5
+ /**
6
+ * AuditionEngine — hearing / language perception.
7
+ *
8
+ * The first fully implemented sense engine. Handles all text and voice
9
+ * input from external entities. Translates raw language into typed
10
+ * LanguagePercepts and routes each conversation session through a dedicated
11
+ * ExecutiveFacet — one facet per entityId, kept alive for the session duration.
12
+ *
13
+ * Architecture:
14
+ * - External call: WillManager.ingestText() → audition.ingest(TextMessage)
15
+ * - Percept published on bus: senses.audition.percept
16
+ * - Facet reply signals: audition.task.signal (when master attention needed)
17
+ * - GoalManager integration: automatic via executive.facet.progress (bus)
18
+ * - Chunk streaming: via multi-subscriber chunk callbacks (transport + SSE)
19
+ *
20
+ * The master executive is NOT involved in replies — it learns about conversations
21
+ * only via executive.facet.sync events published by the conversation facets.
22
+ * Master takes initiative if it receives an audition.task.signal marked
23
+ * requiresMasterAttention: true.
24
+ *
25
+ * FocusSection.outputFormat provides a custom format that re-enables [REPLY]
26
+ * (normally gated out in facet mode) while removing PLANS (conversation
27
+ * facets must not spawn plans — that is the master's domain).
28
+ *
29
+ * ── Entity keying & namespace contract (§7) ───────────────────────────────────
30
+ * AuditionEngine keys EVERYTHING by `entityId`: the conversation facet
31
+ * (`_facets`), the per-entity serial turn queue (`_entityTail`), the chunk-stream
32
+ * state (`_streamState`), and the in-flight inbound/thread maps. Two inbound
33
+ * messages with the same `entityId` are therefore the SAME speaker and share one
34
+ * facet/session — even if they arrive from different sources (web, Slack, SMS).
35
+ * That is intentional (one person = one continuous conversation), but it makes
36
+ * globally-unique entityIds the INTEGRATOR's responsibility: when bridging
37
+ * multiple channels, namespace the id by source (e.g. `slack:U123`, `web:42`) so
38
+ * distinct people never collide into one facet, and the same person across
39
+ * channels merges only when you deliberately map them to one id. `threadId`
40
+ * further scopes one speaker's parallel threads (§2) but does NOT split facets.
41
+ *
42
+ * ── Determinism: on-tick vs off-tick boundary (R2, §7) ────────────────────────
43
+ * The only replay-recorded, ON-tick step is inbound application: an inbound
44
+ * envelope crosses the tick-stamped `InboundQueue` and is applied at a fixed
45
+ * point in the tick (the transport's determinism leg — the analog of the LLM
46
+ * recorder). Everything AuditionEngine does in response runs OFF-tick and is
47
+ * reconstructed from those recorded inputs, never replayed directly: facet
48
+ * reasoning (LLM calls), chunk streaming, the transport reply emit
49
+ * (`_replyCallback`), and the memory-sink `setEntity` writes (`conversation.
50
+ * exchange`). Off-tick writes stay R2-safe because they carry no wall-clock into
51
+ * state — `setEntity` stamps createdAt/tick from the sim clock, and any
52
+ * `wallClock()` here is telemetry-only (ids, session logs). Boundary in one line:
53
+ * percept/ack application = on-tick & recorded; reasoning + side effects =
54
+ * off-tick & reconstructed.
55
+ */
56
+
57
+ import { logger } from '#core/logger'
58
+ import type { CognitiveEventSchema } from '#cognition/schema.registry'
59
+ import type { ExecutiveEngine } from '#faculties/executive.engine'
60
+ import type { ExecutiveFacetHandle, FacetDecision } from '#faculties/executive.engine/facet'
61
+ import { DEFAULT_FACET_AWARENESS, type FocusSection } from '#faculties/executive.engine/prompt.factory'
62
+ import type { ExecutiveOutputFull } from '#faculties/executive.engine/types'
63
+ import type { EpisodicConsolidator } from '#faculties/episodic.consolidator'
64
+ import type { OutboxWriter } from '#stem/tracts/outbox.writer'
65
+ import { GenerativeModel } from '#cognition/generative.model'
66
+ import { buildConversationExchange } from '#cognition/conversation.memory'
67
+ import { wallClock } from '#core/wall.clock'
68
+ import { computeLanguageSalience } from '#senses/audition.engine/salience'
69
+ import { BaseSenseEngine } from '#senses/base.sense.engine'
70
+ import { REPLY_TEXT_OPEN, REPLY_TEXT_CLOSE, renderSpeakerLine, renderCurrentMessageLine } from '#llm/wire.contracts'
71
+ import type {
72
+ SensoryInput,
73
+ LanguagePercept,
74
+ TextMessage,
75
+ VoiceChunk
76
+ } from '#senses/index'
77
+
78
+ // ── Internal types ─────────────────────────────────────────────
79
+
80
+ interface ConversationDecision {
81
+ /** Full assembled reply text (all bubbles joined with \n). */
82
+ reply: string
83
+ /** Individual reply bubbles for display (separate SSE chunks). */
84
+ replyBubbles: string[]
85
+ targetEntityId: string
86
+ newGoals?: ExecutiveOutputFull['newGoals']
87
+ goalsToAbandon?: ExecutiveOutputFull['goalsToAbandon']
88
+ newBeliefs?: ExecutiveOutputFull['newBeliefs']
89
+ knownEntityUpdates?: ExecutiveOutputFull['knownEntityUpdates']
90
+ /** True when an explicit 'escalate' action appears in the output. */
91
+ requiresMasterAttention: boolean
92
+ }
93
+
94
+ /** Minimal write-side entity shape accepted by `stateManager.setEntity`. */
95
+ interface MemoryEntity {
96
+ id: string
97
+ type: string
98
+ metadata: Record<string, unknown>
99
+ }
100
+
101
+ /**
102
+ * One coalescing window (§6) — a turn-in-waiting that accumulates rapid-fire
103
+ * messages from a single entity until its turn starts. `base` is the latest
104
+ * message (its thread/speaker metadata wins); `parts` are the message contents in
105
+ * arrival order, joined into one turn. `done` resolves for every `ingest()` that
106
+ * folded into this window when its coalesced turn completes.
107
+ */
108
+ interface CoalesceWindow {
109
+ base: TextMessage | VoiceChunk
110
+ parts: string[]
111
+ started: boolean
112
+ done: Promise<void>
113
+ resolve: () => void
114
+ }
115
+
116
+
117
+ // ── Conversation output format ─────────────────────────────────
118
+ // Two-step format: JSON first (private reasoning), then [REPLY_TEXT] (streamed to client).
119
+ // [REPLY_TEXT] is plain prose — no JSON wrapper, no targetEntityId needed.
120
+ // The facet is entity-scoped so the recipient is always the speakerEntityId.
121
+
122
+ const CONVERSATION_OUTPUT_FORMAT = `\
123
+ ## Response Format (REQUIRED)
124
+
125
+ Step 1 — JSON object (your private reasoning, optionally in a \`\`\`json code block):
126
+
127
+ \`\`\`json
128
+ {
129
+ "actions": [{"type": "reflect", "reasoning": "...", "expectedOutcome": "..."}],
130
+ "reasoning": "Your private inner reasoning. Embed optional tagged blocks here:\\n[BELIEFS]\\n{\\"newBeliefs\\": [...]}\\n[/BELIEFS]\\n[GOALS_NEW]\\n{\\"goals\\": [{...}]}\\n[/GOALS_NEW]",
131
+ "confidence": 0.8
132
+ }
133
+ \`\`\`
134
+
135
+ Available reasoning tags: BELIEFS, GOALS_NEW, GOALS_ABANDON, SELF_OBS. Include only those with meaningful content.
136
+
137
+ Step 2 — Your reply to the speaker (plain text, streamed live to them):
138
+
139
+ [REPLY_TEXT]
140
+ Your response here, written in your own voice.
141
+
142
+ Start a new paragraph (blank line) to send a separate chat bubble.
143
+ [/REPLY_TEXT]
144
+
145
+ Write [REPLY_TEXT] AFTER the closing \`\`\`. This is the only part the speaker sees — keep it grounded, present.
146
+ Separate multiple messages with a blank line for natural conversational pauses (like separate texts).
147
+
148
+ ## When to use GOALS_NEW (almost always)
149
+ If the speaker requests, mentions, or implies something you should follow through on — embed [GOALS_NEW] in your reasoning.
150
+ This tracks intent across future cycles without requiring master attention.
151
+
152
+ ## When to use the escalate action (rare — only for multi-step tasks)
153
+ Use \`{"type": "escalate", "reasoning": "...", "expectedOutcome": "..."}\` in actions ONLY when the request genuinely requires your master consciousness to create a plan:
154
+ - The task involves multiple steps across future cycles ("build me X", "monitor Y", "set up Z")
155
+ - The request changes your active goal priorities in a significant way
156
+ - You need to coordinate something beyond a single reply
157
+
158
+ **The "reasoning" field on the escalate action becomes the task description the master sees.**
159
+ Make it concrete — describe WHAT needs to happen, not just that you are escalating.
160
+ Good: type=escalate, reasoning="User wants weekly mood summaries by email every Monday. Needs: data aggregation, schedule, email delivery.", expectedOutcome="Weekly email delivered."
161
+ Bad: type=escalate, reasoning="Escalating because this is complex."
162
+
163
+ When you escalate:
164
+ 1. STILL include a [REPLY_TEXT] that acknowledges the request (e.g. "Got it — I'm on it.")
165
+ 2. The master will create and execute the plan in the background
166
+ 3. Do NOT include [PLANS] yourself — plan creation is the master's domain only
167
+
168
+ For simple, single-exchange requests (questions, opinions, short tasks) — do NOT escalate. Just reply.`
169
+
170
+ // ── Thread digest ──────────────────────────────────────────────
171
+
172
+ /** Rolling last-N message summaries per thread. */
173
+ export class ThreadDigestManager {
174
+ static readonly MAX_TURNS = 5
175
+ private _threads = new Map<string, string[]>()
176
+
177
+ append( threadId: string, role: 'user' | 'will', content: string ): void {
178
+ const lines = this._threads.get( threadId ) ?? []
179
+ lines.push(`${role}: ${content.slice( 0, 200 )}`)
180
+ if( lines.length > ThreadDigestManager.MAX_TURNS )
181
+ lines.splice( 0, lines.length - ThreadDigestManager.MAX_TURNS )
182
+
183
+ this._threads.set( threadId, lines )
184
+ }
185
+
186
+ /**
187
+ * Seed an EMPTY thread's digest from recalled prior exchanges (§5.4) — used on a
188
+ * cold facet spawn so the first reply already carries recent-conversation context.
189
+ * No-op when the thread already has live turns: never clobber a running digest.
190
+ */
191
+ hydrate( threadId: string, summaries: string[] ): void {
192
+ if( summaries.length === 0 ) return
193
+ const existing = this._threads.get( threadId )
194
+ if( existing && existing.length > 0 ) return
195
+ this._threads.set( threadId, summaries.slice( -ThreadDigestManager.MAX_TURNS ) )
196
+ }
197
+
198
+ getDigest( threadId: string ): string {
199
+ const lines = this._threads.get( threadId )
200
+ if( !lines || lines.length === 0 ) return ''
201
+
202
+ return `[Thread — last ${lines.length} turn${lines.length === 1 ? '' : 's'}]\n${lines.join('\n')}`
203
+ }
204
+
205
+ clear( threadId: string ): void {
206
+ this._threads.delete( threadId )
207
+ }
208
+ }
209
+
210
+ // ── AuditionEngine ─────────────────────────────────────────────
211
+
212
+ export class AuditionEngine extends BaseSenseEngine {
213
+ readonly name = 'audition-engine'
214
+ readonly domain = 'audition' as const
215
+
216
+ /** Audition consumes language input; the base filters every other kind out. */
217
+ protected readonly acceptedKinds = new Set<SensoryInput['kind']>( [ 'text', 'voice' ] )
218
+ /** Inbound is gated on the 'listen' effector — the base enforces it before _perceive(). */
219
+ protected readonly gateEffector = 'listen'
220
+
221
+ private _executiveEngine: ExecutiveEngine | null = null
222
+ private _episodicConsolidator: EpisodicConsolidator | null = null
223
+ private _outboxWriter: OutboxWriter | null = null
224
+
225
+ /**
226
+ * Chunk subscribers — multi-subscriber so several consumers (transport emit,
227
+ * SSE fan-out) receive the filtered [REPLY_TEXT] stream simultaneously.
228
+ * Handlers get (entityId, threadId, chunk).
229
+ */
230
+ private _chunkCallbacks = new Set<( entityId: string, threadId: string, chunk: string ) => void>()
231
+ private _replyCallback: (( entityId: string, threadId: string, bubbles: string[] ) => void) | null = null
232
+
233
+ /** One facet per active conversation (keyed by entityId). */
234
+ private _facets = new Map<string, ExecutiveFacetHandle>()
235
+ /** Rolling thread digests keyed by threadId. */
236
+ private _digests = new ThreadDigestManager()
237
+ /** Salience computer — tracks baseline message energy per entity. */
238
+ private _model = new GenerativeModel()
239
+ /**
240
+ * Per-entity chunk stream state.
241
+ * Tracks position in the raw LLM token stream so only [REPLY_TEXT] content
242
+ * is forwarded to chunk subscribers — internal reasoning and JSON never leak.
243
+ */
244
+ private _streamState = new Map<string, { inReplyText: boolean; holdback: string }>()
245
+
246
+ // ── Per-entity turn serialization (Tier 2) ──────────────────
247
+ /**
248
+ * Per-entity promise chain. Each entity processes its messages strictly one
249
+ * turn at a time so two rapid messages never interleave the shared per-entity
250
+ * stream state, the facet's reasoning history, or the chunk stream.
251
+ */
252
+ private _entityTail = new Map<string, Promise<void>>()
253
+ /** Resolver for the in-flight turn per entity — fired by _onFacetDecision. */
254
+ private _turnDone = new Map<string, () => void>()
255
+ /** Safety valve: release the entity queue if a reasoning cycle emits no decision. */
256
+ private _turnTimeoutMs = 60_000
257
+ /**
258
+ * Open coalescing window per entity (§6). Rapid-fire messages that arrive while a
259
+ * turn is queued-but-not-yet-started fold into the same window, so a burst becomes
260
+ * one turn instead of N. The window closes the instant its turn starts (parts are
261
+ * snapshotted), and the next message opens a fresh window behind it on the chain.
262
+ */
263
+ private _coalesce = new Map<string, CoalesceWindow>()
264
+
265
+ // ── Conversation memory (Section 5) ─────────────────────────
266
+ /**
267
+ * Persists conversation turns as `working_memory.item` state entities
268
+ * (wmType: 'conversation.exchange'), so they flow through the canonical
269
+ * WorkingMemory → EpisodicConsolidator → vector pipeline — the same path every
270
+ * other percept uses. Wired to `stateManager.setEntity` in assembleMind().
271
+ */
272
+ private _memorySink: (( entity: MemoryEntity ) => void) | null = null
273
+ /** Speaker attachment strength accessor (0–1) — weights salience by relationship. */
274
+ private _getAttachmentScore: (( entityId: string ) => number) | null = null
275
+ /** Active-goal topic text accessor — for salience topic-overlap. */
276
+ private _getActiveGoalText: (() => string[]) | null = null
277
+ /** In-flight inbound text per entity — paired with the reply into an exchange memory. */
278
+ private _inflightInbound = new Map<string, string>()
279
+ /** In-flight thread per entity — stamps chunk envelopes with the current threadId. */
280
+ private _inflightThread = new Map<string, string>()
281
+
282
+ // ── Assembly wiring ─────────────────────────────────────────
283
+
284
+ // attachBus() and attachGrants() are inherited from BaseSenseEngine.
285
+ attachExecutiveEngine( exec: ExecutiveEngine ): void { this._executiveEngine = exec }
286
+ /**
287
+ * Inject the OutboxWriter so reply bubbles are delivered through the canonical
288
+ * outbox path (or the transport fast-path when one is attached). A sense engine
289
+ * needs only to write to the outbox — not the effector executor.
290
+ * Called by `assembleMind()` after the engines are constructed.
291
+ */
292
+ attachOutboxWriter( writer: OutboxWriter ): void { this._outboxWriter = writer }
293
+ /**
294
+ * Inject a chunk delivery callback — called per token chunk as the
295
+ * conversation facet streams its response.
296
+ * In production: WillManager provides this to push chunks into the SSE outbox.
297
+ * Full wiring deferred to Section 5.4 (requires onChunk() on ExecutiveFacetHandle).
298
+ */
299
+ /**
300
+ * Inject an episodic recall accessor (`semanticQuery` → exchange summaries) used
301
+ * to seed an empty thread digest on cold facet spawn (§5.4). Wired only when a
302
+ * vector adapter is present; absent → digests simply start empty on cold spawn.
303
+ */
304
+ attachEpisodicConsolidator( consolidator: EpisodicConsolidator ): void { this._episodicConsolidator = consolidator }
305
+ /**
306
+ * Subscribe to the filtered reply-token stream (content between [REPLY_TEXT]
307
+ * and [/REPLY_TEXT] only). Multi-subscriber — the transport emit and the SSE
308
+ * fan-out can both listen. Returns an unsubscribe.
309
+ */
310
+ addChunkCallback( cb: ( entityId: string, threadId: string, chunk: string ) => void ): () => void {
311
+ this._chunkCallbacks.add( cb )
312
+ return () => { this._chunkCallbacks.delete( cb ) }
313
+ }
314
+ /**
315
+ * Inject a reply callback fired the instant a facet decision is ready, with the
316
+ * assembled bubbles. The stem bridges this to `transport.emit({channel:'reply'})`
317
+ * for off-tick delivery — decoupled from the outbox tick-drain. The outbox copy
318
+ * (via OutboxWriter.enqueueReply) remains as the disconnect buffer / legacy SSE path.
319
+ */
320
+ attachReplyCallback( cb: ( entityId: string, threadId: string, bubbles: string[] ) => void ): void { this._replyCallback = cb }
321
+ /**
322
+ * Inject a sink that persists conversation turns as `working_memory.item`
323
+ * state entities. Wired to `simulation.stateManager.setEntity` in assembleMind()
324
+ * so the EpisodicConsolidator consolidates exchanges on its next tick.
325
+ */
326
+ attachMemorySink( sink: ( entity: MemoryEntity ) => void ): void { this._memorySink = sink }
327
+ /** Inject a per-entity attachment-strength accessor (reads AttachmentEvaluator). */
328
+ attachAttachmentScore( fn: ( entityId: string ) => number ): void { this._getAttachmentScore = fn }
329
+ /** Inject an active-goal topic-text accessor (reads GoalManager) for salience overlap. */
330
+ attachActiveGoalText( fn: () => string[] ): void { this._getActiveGoalText = fn }
331
+
332
+ // ── CognitiveEngine ─────────────────────────────────────────
333
+
334
+ /** Override: audition adds the master-escalation signal to the base percept schema. */
335
+ publishes(): CognitiveEventSchema[] {
336
+ return [
337
+ { type: 'senses.audition.percept', version: 1, validate: () => null },
338
+ { type: 'audition.task.signal', version: 1, validate: () => null }
339
+ ]
340
+ }
341
+ // subscribes() and onCognitiveEvent() inherit the base no-ops (ingest-driven).
342
+
343
+ snapshot(): Record<string, unknown> {
344
+ return {
345
+ domain: 'audition',
346
+ activeSessions: this._facets.size,
347
+ sessions: [ ...this._facets.keys() ]
348
+ }
349
+ }
350
+
351
+ // ── SenseEngine: domain perception ──────────────────────────
352
+
353
+ /**
354
+ * Domain perception. The base `ingest()` calls this for accepted kinds
355
+ * (text/voice) only once the 'listen' gate passes — so the gate + kind filter
356
+ * are no longer repeated here.
357
+ *
358
+ * Two layers of per-entity ordering:
359
+ * - Serialization (Tier 2): an entity processes one turn at a time so rapid
360
+ * messages never interleave stream state, reasoning history, or the chunk
361
+ * stream.
362
+ * - Coalescing (§6): messages that pile up before a turn STARTS fold into that
363
+ * turn, so a burst becomes one reply instead of N. Once the turn starts, the
364
+ * next message opens a fresh window behind it on the chain.
365
+ *
366
+ * The returned promise resolves when the turn this message folded into completes,
367
+ * so callers awaiting ingest() still see their turn through.
368
+ */
369
+ protected async _perceive( input: SensoryInput ): Promise<void> {
370
+ const msg = input as TextMessage | VoiceChunk
371
+ const entityId = msg.entityId
372
+
373
+ // Fold into the open window if its turn hasn't started yet (§6).
374
+ const open = this._coalesce.get( entityId )
375
+ if( open && !open.started ){
376
+ open.base = msg // freshest thread/speaker metadata wins
377
+ open.parts.push( this._contentOf( msg ) )
378
+ return open.done
379
+ }
380
+
381
+ // Otherwise open a new window and enqueue its turn behind any in-flight one.
382
+ let resolve!: () => void
383
+ const done = new Promise<void>( r => { resolve = r } )
384
+ const entry: CoalesceWindow = { base: msg, parts: [ this._contentOf( msg ) ], started: false, done, resolve }
385
+ this._coalesce.set( entityId, entry )
386
+ void this._enqueue( entityId, () => this._runCoalesced( entityId, entry ) )
387
+ return done
388
+ }
389
+
390
+ /** Extract the textual content of a message (voice → transcription). */
391
+ private _contentOf( msg: TextMessage | VoiceChunk ): string {
392
+ return msg.kind === 'text'
393
+ ? msg.content
394
+ : ( ( msg as VoiceChunk ).transcription ?? '[voice — transcription pending]')
395
+ }
396
+
397
+ /**
398
+ * Run one coalescing window as a single turn. Closing the window (started=true +
399
+ * delete) BEFORE processing snapshots its parts: any message arriving from here on
400
+ * opens a fresh window that the chain runs after this turn. `done` resolves for
401
+ * every folded `ingest()` on every exit path.
402
+ */
403
+ private async _runCoalesced( entityId: string, entry: CoalesceWindow ): Promise<void> {
404
+ entry.started = true
405
+ if( this._coalesce.get( entityId ) === entry ) this._coalesce.delete( entityId )
406
+
407
+ const merged = entry.parts.length === 1
408
+ ? entry.base
409
+ : entry.base.kind === 'text'
410
+ ? { ...( entry.base as TextMessage ), content: entry.parts.join('\n') }
411
+ : { ...( entry.base as VoiceChunk ), transcription: entry.parts.join('\n') }
412
+
413
+ try { await this._processMessage( merged ) }
414
+ finally { entry.resolve() }
415
+ }
416
+
417
+ private async _getEpisodicRecall( query: string, limit: number ): Promise<string[]> {
418
+ if( !this._episodicConsolidator ) return Promise.resolve( [] )
419
+
420
+ const episodes = await this._episodicConsolidator.semanticQuery( query, { limit } )
421
+ return episodes
422
+ .map( ep => {
423
+ const c = ep.content as { summary?: unknown; content?: { summary?: unknown } } | null
424
+ return ( typeof c?.summary === 'string' ? c.summary : undefined )
425
+ ?? ( typeof c?.content?.summary === 'string' ? c.content.summary : undefined )
426
+ ?? ''
427
+ } )
428
+ .filter( ( s ): s is string => s.length > 0 )
429
+ }
430
+
431
+ /**
432
+ * Append a unit of work to an entity's serial turn chain. A rejected unit is
433
+ * isolated so it never breaks the chain for subsequent messages.
434
+ */
435
+ private _enqueue( entityId: string, task: () => Promise<void> ): Promise<void> {
436
+ const prev = this._entityTail.get( entityId ) ?? Promise.resolve()
437
+ const next = prev.then( task, task ) // run regardless of prior outcome
438
+ this._entityTail.set( entityId, next.catch( () => {} ) ) // tail never rejects
439
+ return next
440
+ }
441
+
442
+ /** Process one conversational turn end-to-end (runs serialized per entity). */
443
+ private async _processMessage( msg: TextMessage | VoiceChunk ): Promise<void> {
444
+ const content = msg.kind === 'text'
445
+ ? msg.content
446
+ : ( ( msg as VoiceChunk ).transcription ?? '[voice — transcription pending]')
447
+
448
+ const entityId = msg.entityId
449
+ const threadId = msg.threadId
450
+ const speakerName = msg.kind === 'text' ? ( ( msg as TextMessage ).speakerName ?? entityId ) : entityId
451
+
452
+ // ── Cold-spawn digest hydration (§5.4) ─────────────────────
453
+ // On the first turn for an entity (no facet yet) seed an EMPTY thread digest
454
+ // from episodic recall, so the very first reply already carries prior-
455
+ // conversation context instead of starting blank after a restart. Best-effort
456
+ // and off-tick: recall failure is non-fatal and never blocks the turn. Must run
457
+ // before the percept's `digest` snapshot below so the seed reaches the focus.
458
+ if( !this._facets.has( entityId ) && !this._digests.getDigest( threadId ) )
459
+ try {
460
+ const recalled = await this._getEpisodicRecall( content, ThreadDigestManager.MAX_TURNS )
461
+ if( recalled.length > 0 ) this._digests.hydrate( threadId, recalled )
462
+ }
463
+ catch( err ){
464
+ logger.warn(`[audition-engine] digest hydration recall failed for ${entityId}: ${( err as Error ).message}`)
465
+ }
466
+
467
+ // ── Salience ───────────────────────────────────────────────
468
+ // Two-stage: (1) a deterministic "language energy" from content + speaker
469
+ // attachment + active-goal overlap; (2) per-entity novelty modulation via the
470
+ // GenerativeModel (a normally-quiet entity speaking up spikes; a chatty one's
471
+ // routine traffic settles). The per-entity key is what makes baselines independent.
472
+ const langEnergy = computeLanguageSalience({
473
+ content,
474
+ attachmentScore: this._getAttachmentScore?.( entityId ) ?? 0,
475
+ activeGoalText: this._getActiveGoalText?.() ?? [],
476
+ })
477
+ const salience = this._model.observe( `audition.${entityId}`, langEnergy ).salience
478
+
479
+ // ── Percept ────────────────────────────────────────────────
480
+ const percept: LanguagePercept = {
481
+ domain: 'audition',
482
+ channel: msg.kind,
483
+ content,
484
+ sourceEntityId: entityId, // base Percept field — same as speaker
485
+ speakerEntityId: entityId,
486
+ threadId,
487
+ digest: this._digests.getDigest( threadId ),
488
+ salience,
489
+ // Arrival metadata for an EXTERNAL inbound message (network/RPC boundary):
490
+ // no sim clock in scope here and the value is not replayed — wallClock() is
491
+ // the sanctioned source for non-deterministic telemetry. (R2)
492
+ timestamp: wallClock(),
493
+ raw: msg
494
+ }
495
+
496
+ // Publish to CognitiveBus — AttentionAllocator et al. can react.
497
+ // publishPercept() (base) is the single emit chokepoint on senses.<domain>.percept.
498
+ this.publishPercept( percept )
499
+
500
+ // ── Update digest with inbound turn ───────────────────────
501
+ this._digests.append( threadId, 'user', content )
502
+
503
+ // Track inbound text so _onFacetDecision can pair it with the reply into a
504
+ // single exchange memory (Section 5), and the current thread so chunk
505
+ // envelopes carry the right threadId.
506
+ this._inflightInbound.set( entityId, content )
507
+ this._inflightThread.set( entityId, threadId )
508
+
509
+ // ── Route to facet, then block the entity queue until the turn resolves ──
510
+ // The turn deferred must be armed BEFORE routing because a synchronous facet
511
+ // (e.g. a test mock) can fire its decision during report().
512
+ // Recall is unified into the facet's "## Relevant Memories" section, driven by
513
+ // focus.recallQuery (the message) — no separate recall step here (§5).
514
+ const turn = this._beginTurn( entityId )
515
+ const reported = await this._routeToFacet( percept, speakerName )
516
+ if( !reported ){
517
+ this._endTurn( entityId ) // nothing dispatched — release immediately
518
+ return
519
+ }
520
+ // Resolved by _onFacetDecision (always fires — even on LLM fallback) or by
521
+ // the safety timeout. This is what serializes the next message's reasoning.
522
+ await turn
523
+ }
524
+
525
+ /**
526
+ * Arm the in-flight turn deferred for an entity. Returns a promise that
527
+ * resolves when the turn's decision arrives (_endTurn) or the safety timeout
528
+ * fires — guaranteeing the per-entity queue can never deadlock.
529
+ */
530
+ private _beginTurn( entityId: string ): Promise<void> {
531
+ return new Promise<void>( resolve => {
532
+ const timer = setTimeout( () => {
533
+ logger.warn(
534
+ `[audition-engine] turn timeout (${this._turnTimeoutMs}ms) for ${entityId} — releasing queue`
535
+ )
536
+ this._turnDone.delete( entityId )
537
+ resolve()
538
+ }, this._turnTimeoutMs )
539
+
540
+ this._turnDone.set( entityId, () => {
541
+ clearTimeout( timer )
542
+ this._turnDone.delete( entityId )
543
+ resolve()
544
+ } )
545
+ } )
546
+ }
547
+
548
+ /** Release the in-flight turn for an entity (idempotent). */
549
+ private _endTurn( entityId: string ): void {
550
+ this._turnDone.get( entityId )?.()
551
+ }
552
+
553
+ // ── Facet lifecycle ─────────────────────────────────────────
554
+
555
+ private async _routeToFacet( percept: LanguagePercept, speakerName: string ): Promise<boolean> {
556
+ if( !this._executiveEngine ){
557
+ logger.warn('[audition-engine] No executive engine attached — cannot spawn facet.')
558
+ return false
559
+ }
560
+
561
+ let handle = this._facets.get( percept.speakerEntityId )
562
+ if( !handle ){
563
+ // New conversation session — try to spawn a facet.
564
+ const result = this._executiveEngine.spawnFacet()
565
+ if( result.attention === 'full' || !result.handle ){
566
+ logger.warn(
567
+ `[audition-engine] Executive attention full — ` +
568
+ `cannot open conversation facet for ${percept.speakerEntityId}.`
569
+ )
570
+
571
+ // Graceful degradation: publish the percept but skip the facet.
572
+ // The message is not lost — it remains in the outbox for the user.
573
+ return false
574
+ }
575
+
576
+ handle = result.handle
577
+ this._facets.set( percept.speakerEntityId, handle )
578
+
579
+ // Subscribe to decisions from this facet. The thread is resolved at decision
580
+ // time from _inflightThread (the CURRENT turn's thread) — not captured here —
581
+ // so an entity that spans multiple threads always replies on the right one.
582
+ handle.subscribe( decision => this._onFacetDecision( percept.speakerEntityId, decision ) )
583
+
584
+ // Reclaim our session state if the supervisor reaps this facet (idle TTL /
585
+ // LRU eviction) out from under us — otherwise we'd hold a dead handle.
586
+ handle.onReaped( () => {
587
+ logger.info(`[audition-engine] Facet reaped for ${percept.speakerEntityId} — clearing session`)
588
+ this._teardownEntity( percept.speakerEntityId )
589
+ } )
590
+
591
+ // Register per-entity chunk filter for token-level streaming.
592
+ // Only content between [REPLY_TEXT] and [/REPLY_TEXT] is forwarded —
593
+ // internal reasoning, JSON structure, and cognitive tags never reach the client.
594
+ if( this._chunkCallbacks.size > 0 ){
595
+ const entityId = percept.speakerEntityId
596
+ // Fresh stream state for this session
597
+ this._streamState.set( entityId, { inReplyText: false, holdback: '' })
598
+ handle.onChunk( this._pipeChunk( entityId ) )
599
+ }
600
+
601
+ logger.info(`[audition-engine] Opened conversation facet for ${percept.speakerEntityId}`)
602
+ }
603
+
604
+ // (Re)build focus with current percept content. Recall is unified into the
605
+ // facet's single "## Relevant Memories" section, driven by focus.recallQuery.
606
+ const focus = this._buildFocus( percept, speakerName )
607
+ handle.setFocus( focus )
608
+
609
+ // Report triggers the facet's next reasoning cycle
610
+ await handle.report({ type: 'language_percept', payload: percept })
611
+ return true
612
+ }
613
+
614
+ private _pipeChunk( entityId: string ){
615
+ const
616
+ OPEN = REPLY_TEXT_OPEN,
617
+ CLOSE = REPLY_TEXT_CLOSE
618
+
619
+ return ( rawChunk: string ) => {
620
+ const st = this._streamState.get( entityId )
621
+ if( !st ) return
622
+
623
+ st.holdback += rawChunk
624
+
625
+ if( !st.inReplyText ){
626
+ const openIdx = st.holdback.indexOf( OPEN )
627
+ if( openIdx !== -1 ){
628
+ st.inReplyText = true
629
+ st.holdback = st.holdback.slice( openIdx + OPEN.length )
630
+ // Fall through to emit logic below
631
+ }
632
+ else {
633
+ // Discard everything except a trailing window that could be a partial marker
634
+ if( st.holdback.length > OPEN.length - 1 )
635
+ st.holdback = st.holdback.slice( -( OPEN.length - 1 ) )
636
+
637
+ return
638
+ }
639
+ }
640
+
641
+ // Inside reply block — forward safe content, hold back potential partial marker
642
+ const closeIdx = st.holdback.indexOf( CLOSE )
643
+ if( closeIdx !== -1 ){
644
+ const toEmit = st.holdback.slice( 0, closeIdx )
645
+ if( toEmit ) this._emitChunk( entityId, toEmit )
646
+
647
+ st.inReplyText = false
648
+ st.holdback = ''
649
+ }
650
+ else {
651
+ const safeLen = Math.max( 0, st.holdback.length - ( CLOSE.length - 1 ) )
652
+ if( safeLen > 0 ){
653
+ this._emitChunk( entityId, st.holdback.slice( 0, safeLen ) )
654
+ st.holdback = st.holdback.slice( safeLen )
655
+ }
656
+ }
657
+ }
658
+ }
659
+
660
+ /** Fan a filtered reply-token out to all chunk subscribers, stamping the current thread. */
661
+ private _emitChunk( entityId: string, chunk: string ): void {
662
+ if( this._chunkCallbacks.size === 0 ) return
663
+ const threadId = this._inflightThread.get( entityId ) ?? ''
664
+ for( const cb of this._chunkCallbacks ){
665
+ try { cb( entityId, threadId, chunk ) }
666
+ catch( err ){ logger.error(`[audition-engine] chunk subscriber error: ${( err as Error ).message}`) }
667
+ }
668
+ }
669
+
670
+ private _buildFocus( percept: LanguagePercept, speakerName: string ): FocusSection {
671
+ const digestBlock = percept.digest ? `${percept.digest}\n\n` : ''
672
+
673
+ return {
674
+ title: 'Active Conversation',
675
+ function: 'conversation',
676
+ content: [
677
+ renderSpeakerLine( speakerName, percept.speakerEntityId ),
678
+ digestBlock,
679
+ renderCurrentMessageLine( percept.content ),
680
+ ]
681
+ .filter( Boolean )
682
+ .join('\n'),
683
+
684
+ // Drive the single "## Relevant Memories" recall with the live message (§5) —
685
+ // one recall surface, message-relevant, instead of a separate per-focus block.
686
+ recallQuery: percept.content,
687
+
688
+ // Be precise about plans when asked: add the 'plans' awareness scope so the
689
+ // conversation facet sees live plan/step state, scoped to THIS speaker so the
690
+ // prompt carries only their plans, not the whole mind's.
691
+ awareness: [ ...DEFAULT_FACET_AWARENESS, 'plans' ],
692
+ awarenessEntityId: percept.speakerEntityId,
693
+
694
+ instructions: [
695
+ 'You are in a live conversation with this person. Respond as yourself.',
696
+ 'Stay grounded in your real memories and feelings — do not invent experiences you have no record of.',
697
+ ].join(' '),
698
+
699
+ // Custom output format — uses [REPLY_TEXT] block for streamed reply.
700
+ outputFormat: CONVERSATION_OUTPUT_FORMAT,
701
+
702
+ // Extract conversation-specific payload from the parsed output.
703
+ // replyText is populated by parseResponse() from the [REPLY_TEXT] plain-text block.
704
+ // paragraphs (double-newline separated) become separate reply bubbles.
705
+ extractDecision: ( raw: unknown ): ConversationDecision => {
706
+ const output = raw as ExecutiveOutputFull
707
+ const rawReply = output.replyText?.trim() ?? ''
708
+ const bubbles = rawReply.split( /\n{2,}/ )
709
+ .map( b => b.trim() )
710
+ .filter( Boolean )
711
+
712
+ return {
713
+ reply: bubbles.join('\n'),
714
+ replyBubbles: bubbles,
715
+ targetEntityId: percept.speakerEntityId,
716
+ newGoals: output.newGoals,
717
+ goalsToAbandon: output.goalsToAbandon,
718
+ newBeliefs: output.newBeliefs,
719
+ knownEntityUpdates: output.knownEntityUpdates,
720
+ requiresMasterAttention: ( output.actions ?? [] ).some( a => a.type === 'escalate' ),
721
+ }
722
+ }
723
+ }
724
+ }
725
+
726
+ // ── Proactive outreach authoring (agency-initiated) ─────────
727
+
728
+ /**
729
+ * Author the words for a PROACTIVE outreach the agency selected — a self-initiated
730
+ * contact with no inbound to trigger a reply. Reuses the unified conversation voice
731
+ * ([REPLY_TEXT] → bubbles, same persona/identity/memory grounding) via a TRANSIENT
732
+ * facet with its OWN subscription, so it never collides with the entity's live
733
+ * reactive facet or double-delivers. Returns the bubbles for the caller
734
+ * (MotorSchemaExecutor) to deliver through the proactive communicate path; empty
735
+ * when no executive is attached or the facet budget is full (caller then awaits).
736
+ */
737
+ async authorOutreach( entityId: string, entityName: string, gist?: string ): Promise<string[]> {
738
+ if( !this._executiveEngine ) return []
739
+ const spawned = this._executiveEngine.spawnFacet()
740
+ if( spawned.attention === 'full' || !spawned.handle ){
741
+ logger.warn(`[audition-engine] facet budget full — cannot author outreach to ${ entityId }`)
742
+ return []
743
+ }
744
+ const handle = spawned.handle
745
+
746
+ handle.setFocus({
747
+ title: 'Reaching out',
748
+ function: 'outreach',
749
+ content: [
750
+ `You have decided, on your own initiative, to reach out to ${ entityName } (id: ${ entityId }).`,
751
+ 'No one prompted this — it is you choosing to make contact now.',
752
+ gist ? `What is on your mind: ${ gist }` : '',
753
+ ].filter( Boolean ).join('\n'),
754
+ recallQuery: gist ?? entityName,
755
+ awareness: [ ...DEFAULT_FACET_AWARENESS, 'plans' ],
756
+ awarenessEntityId: entityId,
757
+ instructions:
758
+ 'Considering who you are, your goals, and how you feel, say what you genuinely want to say to ' +
759
+ 'them now. Speak as yourself; stay grounded in your real memories — do not invent experiences ' +
760
+ 'you have no record of.',
761
+ outputFormat: CONVERSATION_OUTPUT_FORMAT,
762
+ extractDecision: ( raw: unknown ): ConversationDecision => {
763
+ const output = raw as ExecutiveOutputFull
764
+ const rawReply = output.replyText?.trim() ?? ''
765
+ const bubbles = rawReply.split( /\n{2,}/ ).map( b => b.trim() ).filter( Boolean )
766
+ return { reply: bubbles.join('\n'), replyBubbles: bubbles, targetEntityId: entityId, requiresMasterAttention: false }
767
+ },
768
+ })
769
+
770
+ // report() only QUEUES the facet's reasoning; the authored bubbles arrive LATER
771
+ // via the subscription. So wait for the DECISION (not report's resolution), with
772
+ // a safety timeout, then tear the transient facet down.
773
+ const bubbles = await new Promise<string[]>( resolve => {
774
+ let settled = false
775
+ let unsub: () => void = () => {}
776
+ let timer: ReturnType<typeof setTimeout>
777
+ const done = ( b: string[] ): void => { if( settled ) return; settled = true; clearTimeout( timer ); unsub(); resolve( b ) }
778
+ timer = setTimeout(
779
+ () => { logger.warn(`[audition-engine] outreach authoring timed out for ${ entityId }`); done( [] ) },
780
+ 60_000, // generous: the facet LLM authors in ~8–18s
781
+ )
782
+ unsub = handle.subscribe( d => done( ( d.decision as ConversationDecision ).replyBubbles ?? [] ) )
783
+ Promise.resolve( handle.report({ type: 'outreach', payload: { entityId, gist } }) ).catch( err => {
784
+ logger.warn(`[audition-engine] outreach report failed for ${ entityId }: ${ ( err as Error ).message }`)
785
+ done( [] )
786
+ } )
787
+ } )
788
+
789
+ handle.destroy()
790
+ return bubbles
791
+ }
792
+
793
+ // ── Conversation memory (Section 5) ─────────────────────────
794
+
795
+ /**
796
+ * Persist one completed exchange (inbound + reply) as a `working_memory.item`
797
+ * state entity so the EpisodicConsolidator consolidates it on its next tick.
798
+ *
799
+ * Off-tick `setEntity` matches the established external-injection pattern
800
+ * (`injectEvent`); it becomes on-tick automatically once inbound marshaling
801
+ * (Section 1.2) routes ingest through the tick loop. The entity carries no
802
+ * wall-clock timestamp — `setEntity` stamps createdAt/tick from the sim clock.
803
+ */
804
+ private _persistExchangeMemory( entityId: string, threadId: string, reply: string, confidence: number, entityName?: string ): void {
805
+ const inbound = this._inflightInbound.get( entityId ) ?? ''
806
+ this._inflightInbound.delete( entityId )
807
+ if( !this._memorySink || ( !inbound && !reply ) ) return
808
+
809
+ const conf = Number.isFinite( confidence ) ? confidence : 0.6
810
+
811
+ // Shared shape with the proactive (ProactiveCommunicator) path; setEntity
812
+ // stamps createdAt, so none is supplied here.
813
+ this._memorySink( buildConversationExchange({
814
+ entityId,
815
+ entityName,
816
+ userMessage: inbound,
817
+ willReply: reply,
818
+ threadId,
819
+ activation: Math.min( 1, Math.max( 0.6, conf ) ),
820
+ attendedCount: 1,
821
+ idSeed: wallClock(), // wallClock id — telemetry only (R2)
822
+ }) )
823
+ }
824
+
825
+ // ── Facet decision handling ─────────────────────────────────
826
+
827
+ private _onFacetDecision(
828
+ entityId: string,
829
+ decision: FacetDecision,
830
+ ): void {
831
+ // Resolve the CURRENT turn's thread (set by _processMessage) rather than the
832
+ // facet's spawn-time thread — correct for an entity that spans threads (§2).
833
+ const threadId = this._inflightThread.get( entityId ) ?? ''
834
+
835
+ // finally{} releases the entity's serial turn queue on EVERY exit path —
836
+ // reply delivered, reply suppressed, or escalation only. Without this a
837
+ // suppressed-reply early-return would stall the queue until the safety timeout.
838
+ try {
839
+ const d = decision.decision as ConversationDecision
840
+
841
+ // Update thread digest with Will's reply
842
+ d.reply && this._digests.append( threadId, 'will', d.reply )
843
+
844
+ // Persist the exchange to memory (inbound + reply) so it consolidates into
845
+ // episodic + vector memory. Runs even if the reply is later suppressed or
846
+ // undelivered — the Will still formulated it and should remember it.
847
+ this._persistExchangeMemory( entityId, threadId, d.reply, decision.confidence, d.targetEntityId )
848
+
849
+ // ── Reply delivery ─────────────────────────────────────────
850
+ // Gated by the `talk` agency grant (checked just below), not a channel.
851
+ // Route bubbles through ProactiveCommunicator so the outbox is populated via
852
+ // the canonical path. Falls back to a log if the executor isn't wired yet
853
+ // (basic tier / unit tests).
854
+ if( d.replyBubbles.length > 0 ){
855
+ if( !this._grants?.isAllowed('talk') ){
856
+ logger.warn(`[audition-engine] Reply suppressed — 'talk' effector not granted (entity=${entityId})`)
857
+ return
858
+ }
859
+
860
+ // Fast-path: emit the reply over the external transport the instant it is
861
+ // ready (off-tick), decoupled from the outbox tick-drain.
862
+ const viaTransport = !!this._replyCallback
863
+ this._replyCallback?.( entityId, threadId, d.replyBubbles )
864
+
865
+ if( this._outboxWriter ){
866
+ // When the transport delivered, skip the outbox push (avoid double
867
+ // delivery). Exchange memory is persisted separately via the memory
868
+ // sink (_persistExchangeMemory); enqueueReply only handles delivery.
869
+ const ids = this._outboxWriter.enqueueReply({
870
+ entityId,
871
+ entityName: d.targetEntityId, // actual display name resolved by facet
872
+ bubbles: d.replyBubbles,
873
+ threadId,
874
+ tick: wallClock(), // wall-clock tick for session log (telemetry, R2)
875
+ pushToOutbox: !viaTransport,
876
+ })
877
+
878
+ if( viaTransport )
879
+ logger.info(
880
+ `[audition-engine] Reply emitted via transport for ${entityId} ` +
881
+ `(${d.replyBubbles.length} bubble(s))`
882
+ )
883
+ else logger.info(
884
+ `[audition-engine] Delivered ${ids.length} bubble(s) to ${entityId} ` +
885
+ `via outbox (messageIds=${ids.join(',')})`
886
+ )
887
+ }
888
+ // OutboxWriter not yet attached — log only (dev / test contexts)
889
+ else logger.info(
890
+ `[audition-engine] Facet reply for ${entityId} (no outbox writer — not delivered): ` +
891
+ `"${d.reply.slice( 0, 80 )}${d.reply.length > 80 ? '…' : ''}"`
892
+ )
893
+ }
894
+
895
+ // ── Master escalation signal ──────────────────────────────
896
+ // Published when the facet emits an 'escalate' action type.
897
+ // Master executive subscribes to this and queues it as a PendingMessage.
898
+ if( d.requiresMasterAttention && this._bus )
899
+ this._bus.publish({
900
+ type: 'audition.task.signal',
901
+ version: 1,
902
+ sourceEngine: this.name,
903
+ salience: 0.9,
904
+ payload: {
905
+ entityId,
906
+ threadId,
907
+ reasoning: decision.reasoning,
908
+ confidence: decision.confidence
909
+ }
910
+ })
911
+ }
912
+ finally {
913
+ // Release the entity's turn queue so the next message can be processed.
914
+ this._endTurn( entityId )
915
+ }
916
+ }
917
+
918
+ // ── Session management ─────────────────────────────────────
919
+
920
+ /**
921
+ * Terminate the conversation session for an entity.
922
+ * Called when the SSE/WS client disconnects or the session expires.
923
+ */
924
+ endSession( entityId: string ): void {
925
+ const handle = this._facets.get( entityId )
926
+ if( !handle ) return
927
+
928
+ handle.destroy()
929
+ this._teardownEntity( entityId )
930
+
931
+ logger.info(`[audition-engine] Conversation session closed for ${entityId}`)
932
+ }
933
+
934
+ /**
935
+ * Drop all per-entity session state. Does NOT destroy the facet handle — used
936
+ * both after an explicit `endSession()` (handle already destroyed) and from the
937
+ * `onReaped` callback when the supervisor reclaims the facet (already destroyed).
938
+ * Idempotent: deleting absent keys is safe.
939
+ */
940
+ private _teardownEntity( entityId: string ): void {
941
+ this._endTurn( entityId ) // resolve any in-flight turn (clears its timer)
942
+ this._facets.delete( entityId )
943
+ this._streamState.delete( entityId ) // release chunk filter state
944
+ this._entityTail.delete( entityId ) // drop the serial chain
945
+ this._inflightInbound.delete( entityId )
946
+ this._inflightThread.delete( entityId )
947
+ // Release any open coalescing window so folded ingest()s don't hang.
948
+ const open = this._coalesce.get( entityId )
949
+ if( open ){ this._coalesce.delete( entityId ); if( !open.started ) open.resolve() }
950
+ }
951
+
952
+ /** Active entityId sessions. */
953
+ activeSessions(): string[] {
954
+ return [ ...this._facets.keys() ]
955
+ }
956
+
957
+ destroy(): void {
958
+ for( const handle of this._facets.values() )
959
+ handle.destroy()
960
+
961
+ // Resolve any in-flight turns so their safety timers are cleared.
962
+ for( const entityId of [ ...this._turnDone.keys() ] ) this._endTurn( entityId )
963
+
964
+ // Release any open coalescing windows so folded ingest()s don't hang.
965
+ for( const open of this._coalesce.values() ) if( !open.started ) open.resolve()
966
+
967
+ this._facets.clear()
968
+ this._entityTail.clear()
969
+ this._turnDone.clear()
970
+ this._coalesce.clear()
971
+ this._streamState.clear()
972
+ this._inflightInbound.clear()
973
+ this._inflightThread.clear()
974
+ this._chunkCallbacks.clear()
975
+ }
976
+ }