@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
@@ -11,16 +11,16 @@
11
11
  * ExecutiveFacet — one facet per entityId, kept alive for the session duration.
12
12
  *
13
13
  * Architecture:
14
- * - External call: WillManager.ingestText() → audition.ingest(TextMessage)
14
+ * - External call: WillManager.senseText() → audition.sense(TextMessage)
15
15
  * - Percept published on bus: senses.audition.percept
16
- * - Facet reply signals: audition.task.signal (when master attention needed)
16
+ * - Facet handoffs to the seat: executive.facet.handoff (escalation | undertaking)
17
17
  * - GoalManager integration: automatic via executive.facet.progress (bus)
18
18
  * - Chunk streaming: via multi-subscriber chunk callbacks (transport + SSE)
19
19
  *
20
20
  * The master executive is NOT involved in replies — it learns about conversations
21
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.
22
+ * It takes initiative when a facet raises an `executive.facet.handoff` — the one
23
+ * channel every facet type uses to hand the singular seat something it owns.
24
24
  *
25
25
  * FocusSection.outputFormat provides a custom format that re-enables [REPLY]
26
26
  * (normally gated out in facet mode) while removing PLANS (conversation
@@ -60,6 +60,7 @@ import type { ExecutiveEngine } from '#faculties/executive.engine'
60
60
  import type { ExecutiveFacetHandle, FacetDecision } from '#faculties/executive.engine/facet'
61
61
  import { DEFAULT_FACET_AWARENESS, type FocusSection } from '#faculties/executive.engine/prompt.factory'
62
62
  import type { ExecutiveOutputFull } from '#faculties/executive.engine/types'
63
+ import { COMMUNICATE_ACTION_TYPES } from '#faculties/executive.engine/commands'
63
64
  import type { EpisodicConsolidator } from '#faculties/episodic.consolidator'
64
65
  import type { OutboxWriter } from '#stem/tracts/outbox.writer'
65
66
  import { GenerativeModel } from '#cognition/generative.model'
@@ -72,8 +73,13 @@ import type {
72
73
  SensoryInput,
73
74
  LanguagePercept,
74
75
  TextMessage,
76
+ Transduced,
75
77
  VoiceChunk
76
78
  } from '#senses/index'
79
+ import { PERCEPT_SUMMARY_CAP } from '#cognition/percept.entity'
80
+ import { validateFacetHandoff, type HandoffBody } from '#faculties/executive.engine/escalation.buffer'
81
+ import { fnv1a } from '#agency/consequence'
82
+ import type { OutreachResult } from '#agency/engines/motor.schema.executor'
77
83
 
78
84
  // ── Internal types ─────────────────────────────────────────────
79
85
 
@@ -82,7 +88,32 @@ interface ConversationDecision {
82
88
  reply: string
83
89
  /** Individual reply bubbles for display (separate SSE chunks). */
84
90
  replyBubbles: string[]
91
+ /**
92
+ * The mind DECLARED silence — it considered speaking and chose not to.
93
+ *
94
+ * Distinct from empty bubbles, which also happens when authoring timed out, the
95
+ * facet budget was full, or a second pass deferred to one already in flight.
96
+ * Only this one is an answer; the others are the absence of one, and the
97
+ * executor must keep holding those.
98
+ */
99
+ withheld?: boolean
85
100
  targetEntityId: string
101
+ /**
102
+ * Actions this facet aimed at someone OTHER than the person it is talking to.
103
+ *
104
+ * A conversation facet is the mind talking to ONE person. When it decides mid-
105
+ * conversation to contact a third party ("I'll reach out to FKEM now"), it must
106
+ * not open that channel itself — a facet bound to Fabrice messaging FKEM is the
107
+ * parallel-conversation failure, and it would collide with any facet already
108
+ * talking to FKEM. So the intention is surfaced to the master, which is singular
109
+ * and owns whom the mind contacts.
110
+ *
111
+ * Before this existed, `extractDecision` read `output.actions` only to test for
112
+ * 'escalate' and dropped the rest: a third-party action was delivered nowhere,
113
+ * became no intent, competed in nothing and left no reafference — the mind said
114
+ * it would make contact, believed it had, and nothing ever went out.
115
+ */
116
+ outwardIntents?: { target: string; gist?: string; reasoning?: string }[]
86
117
  newGoals?: ExecutiveOutputFull['newGoals']
87
118
  goalsToAbandon?: ExecutiveOutputFull['goalsToAbandon']
88
119
  newBeliefs?: ExecutiveOutputFull['newBeliefs']
@@ -114,12 +145,52 @@ interface CoalesceWindow {
114
145
  }
115
146
 
116
147
 
148
+ /**
149
+ * Split a facet's actions into the ones aimed at the person it is talking to
150
+ * (delivered as the reply, which the [REPLY_TEXT] block already carries) and the
151
+ * ones aimed at someone else (returned here, for the master to own).
152
+ *
153
+ * "Aimed at this person" is matched against both the bound keid and the name the
154
+ * facet was given for them, because a mind writes whichever it is looking at —
155
+ * the trace shows it using `discord:1019…` and `Fabrice` interchangeably within a
156
+ * single decision. Anything else — including a name the mind has heard but never
157
+ * bound to anyone — is outward, and gets carried rather than dropped: whether it
158
+ * can be reached at all is the master's problem to notice, not this partition's.
159
+ */
160
+ export function partitionOutwardIntents(
161
+ actions: ExecutiveOutputFull['actions'] | undefined,
162
+ boundKeid: string,
163
+ boundName: string,
164
+ ): { target: string; gist?: string; reasoning?: string }[] {
165
+ const mine = new Set( [ boundKeid, boundName ].map( s => s.trim().toLowerCase() ).filter( Boolean ) )
166
+ const out: { target: string; gist?: string; reasoning?: string }[] = []
167
+
168
+ for( const action of actions ?? [] ){
169
+ if( !COMMUNICATE_ACTION_TYPES.has( action.type.toLowerCase() ) ) continue
170
+
171
+ const args = ( action.args && typeof action.args === 'object' ? action.args : {} ) as Record<string, unknown>
172
+ const target = [ action.target, args['to'], args['recipient'], args['target'] ]
173
+ .find( v => typeof v === 'string' && v.trim().length > 0 ) as string | undefined
174
+
175
+ // No addressee named at all ⇒ it meant the person in front of it; the
176
+ // [REPLY_TEXT] block is already that reply.
177
+ if( !target || mine.has( target.trim().toLowerCase() ) ) continue
178
+
179
+ const gist = [ args['content'], args['message'], args['text'], args['body'] ]
180
+ .find( v => typeof v === 'string' && v.trim().length > 0 ) as string | undefined
181
+
182
+ out.push({ target: target.trim(), ...( gist ? { gist } : {} ), ...( action.reasoning ? { reasoning: action.reasoning } : {} ) })
183
+ }
184
+
185
+ return out
186
+ }
187
+
117
188
  // ── Conversation output format ─────────────────────────────────
118
189
  // Two-step format: JSON first (private reasoning), then [REPLY_TEXT] (streamed to client).
119
190
  // [REPLY_TEXT] is plain prose — no JSON wrapper, no targetEntityId needed.
120
191
  // The facet is entity-scoped so the recipient is always the speakerEntityId.
121
192
 
122
- const CONVERSATION_OUTPUT_FORMAT = `\
193
+ export const CONVERSATION_OUTPUT_FORMAT = `\
123
194
  ## Response Format (REQUIRED)
124
195
 
125
196
  Step 1 — JSON object (my private reasoning, optionally in a \`\`\`json code block):
@@ -145,25 +216,56 @@ Start a new paragraph (blank line) to send a separate chat bubble.
145
216
  Write [REPLY_TEXT] AFTER the closing \`\`\`. This is the only part the speaker sees — keep it grounded, present.
146
217
  Separate multiple messages with a blank line for natural conversational pauses (like separate texts).
147
218
 
219
+ ## Saying nothing
220
+ Silence is a real choice and it is available to me: if I have nothing to say right now — I am
221
+ waiting on them, or speaking again would only repeat myself — I write a [NO_MESSAGE] block
222
+ instead of a [REPLY_TEXT] one, and I put my reason inside it:
223
+
224
+ [NO_MESSAGE]
225
+ Nothing new to add — I am waiting on their answer to what I already asked.
226
+ [/NO_MESSAGE]
227
+
228
+ That is recorded and NEVER sent. Anything between the [REPLY_TEXT] markers IS SENT, so a line
229
+ like "[no message this cycle — waiting for their reply]" does not describe my silence to
230
+ myself, it delivers that sentence to them. If I write both blocks, the silence wins.
231
+
232
+ ## Reaching someone who is not in this conversation
233
+ [REPLY_TEXT] is delivered TO THE PERSON I AM TALKING TO, and to nobody else. It has an
234
+ audience, not just a reader. So words meant for a third party do not become a message to
235
+ that third party by being about them — they are handed to the speaker, who reads something
236
+ addressed to someone else, while the person it was actually for never hears it.
237
+
238
+ To reach someone else I name them in an action:
239
+
240
+ {"type": "reach-out", "target": "<their name or id as it appears under '## People I Know'>",
241
+ "args": {"content": "what I want to say to them"}}
242
+
243
+ I am one conversation of a mind that is having several. Opening a channel is not mine to
244
+ do — that action hands the intention to the part of me that owns whom I contact. It is a
245
+ decision, not a delivery: at the moment I write it nothing has reached anyone, and it may
246
+ yet come to nothing. So I never tell the person in front of me that I have contacted
247
+ someone, or that a message is on its way. The most I can truthfully say is that I mean to.
248
+ I keep [REPLY_TEXT] for the person in front of me.
249
+
148
250
  ## When to use GOALS_NEW (almost always)
149
251
  If the speaker requests, mentions, or implies something I should follow through on — embed [GOALS_NEW] in my reasoning.
150
- This tracks intent across future cycles without requiring master attention.
252
+ This tracks intent across future cycles on its own.
151
253
 
152
254
  ## 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 my master consciousness to create a plan:
255
+ Use \`{"type": "escalate", "reasoning": "...", "expectedOutcome": "..."}\` in actions ONLY when the request genuinely needs a plan I carry out over time rather than an answer I can give now:
154
256
  - The task involves multiple steps across future cycles ("build me X", "monitor Y", "set up Z")
155
257
  - The request changes my active goal priorities in a significant way
156
258
  - I need to coordinate something beyond a single reply
157
259
 
158
- **The "reasoning" field on the escalate action becomes the task description the master sees.**
260
+ **The "reasoning" field on the escalate action becomes the description of the work I pick up.**
159
261
  Make it concrete — describe WHAT needs to happen, not just that I am escalating.
160
262
  Good: type=escalate, reasoning="User wants weekly mood summaries by email every Monday. Needs: data aggregation, schedule, email delivery.", expectedOutcome="Weekly email delivered."
161
263
  Bad: type=escalate, reasoning="Escalating because this is complex."
162
264
 
163
265
  When I escalate:
164
266
  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 a [PLANS] block — plan creation is the master's domain only
267
+ 2. I form and carry out the plan away from this conversation, over the cycles that follow
268
+ 3. Do NOT include a [PLANS] block — the planning happens there, not here
167
269
 
168
270
  For simple, single-exchange requests (questions, opinions, short tasks) — do NOT escalate. Just reply.`
169
271
 
@@ -270,6 +372,33 @@ export class AuditionEngine extends BaseSenseEngine {
270
372
  * other percept uses. Wired to `stateManager.setEntity` in assembleMind().
271
373
  */
272
374
  private _memorySink: (( entity: MemoryEntity ) => void) | null = null
375
+ /**
376
+ * Sim tick of the most recent facet decision — the only deterministic clock this
377
+ * off-tick engine has. Stamped from `FacetDecision.tick`, and used to key the
378
+ * conversation records it writes into state.
379
+ */
380
+ /**
381
+ * Audition does NOT lay down a percept trace (SIGNAL_BOUNDARY P0, step 3).
382
+ *
383
+ * Every other sense does, and a host implementing a new one gets it by
384
+ * default. This is the grandfathered exception, and the reason is measurement
385
+ * rather than principle: audition is the only live sense, and switching it on
386
+ * routes every inbound message to five consumers it has never reached —
387
+ * `action.selector`'s rupture gate (where a fresh high-salience exafferent
388
+ * percept can preempt an awaiting intent), working memory, the executive
389
+ * prompt, novelty, and reafference credit. On a deployed Will that is not a
390
+ * tweak; it is a different mind, and it deserves a measured rollout rather
391
+ * than a line in a refactor.
392
+ *
393
+ * Audition is not trace-less meanwhile: it writes `conversation.received`
394
+ * through its own sink, which is what SocialPerception reads. That record is
395
+ * social — sourceKeid, directedAtSelf, action:'communication' — and is NOT
396
+ * the generic percept the other senses now write. Two different traces for
397
+ * two different readers; this flag turns off only the second.
398
+ */
399
+ protected readonly tracesPercepts = false
400
+
401
+ private _lastDecisionTick = 0
273
402
  /** Speaker attachment strength accessor (0–1) — weights salience by relationship. */
274
403
  private _getAttachmentScore: (( entityId: string ) => number) | null = null
275
404
  /** Active-goal topic text accessor — for salience topic-overlap. */
@@ -278,6 +407,8 @@ export class AuditionEngine extends BaseSenseEngine {
278
407
  private _inflightInbound = new Map<string, string>()
279
408
  /** In-flight thread per entity — stamps chunk envelopes with the current threadId. */
280
409
  private _inflightThread = new Map<string, string>()
410
+ /** Targets with an outreach being composed right now — see authorOutreach. */
411
+ private _outreachInFlight = new Set<string>()
281
412
 
282
413
  // ── Assembly wiring ─────────────────────────────────────────
283
414
 
@@ -331,11 +462,11 @@ export class AuditionEngine extends BaseSenseEngine {
331
462
 
332
463
  // ── CognitiveEngine ─────────────────────────────────────────
333
464
 
334
- /** Override: audition adds the master-escalation signal to the base percept schema. */
465
+ /** Override: audition adds the facet→master handoff to the base percept schema. */
335
466
  publishes(): CognitiveEventSchema[] {
336
467
  return [
337
- { type: 'senses.audition.percept', version: 1, validate: () => null },
338
- { type: 'audition.task.signal', version: 1, validate: () => null }
468
+ { type: 'senses.audition.percept', version: 1, validate: () => null },
469
+ { type: 'executive.facet.handoff', version: 1, validate: validateFacetHandoff },
339
470
  ]
340
471
  }
341
472
  // subscribes() and onCognitiveEvent() inherit the base no-ops (ingest-driven).
@@ -477,7 +608,7 @@ export class AuditionEngine extends BaseSenseEngine {
477
608
  const salience = this._model.observe(`audition.${entityId}`, langEnergy ).salience
478
609
 
479
610
  // ── Percept ────────────────────────────────────────────────
480
- const percept: LanguagePercept = {
611
+ const transduced: Transduced<LanguagePercept> = {
481
612
  domain: 'audition',
482
613
  channel: msg.kind,
483
614
  content,
@@ -485,6 +616,10 @@ export class AuditionEngine extends BaseSenseEngine {
485
616
  speakerEntityId: entityId,
486
617
  threadId,
487
618
  digest: this._digests.getDigest( threadId ),
619
+ // What a heard turn amounts to, for readers that do not know this is
620
+ // audition. Bounded, because `summary` renders into the executive prompt
621
+ // and a pasted essay would take the whole percept budget.
622
+ summary: `${ speakerName } said: ${ content }`.slice( 0, PERCEPT_SUMMARY_CAP ),
488
623
  salience,
489
624
  // Arrival metadata for an EXTERNAL inbound message (network/RPC boundary):
490
625
  // no sim clock in scope here and the value is not replayed — wallClock() is
@@ -495,7 +630,7 @@ export class AuditionEngine extends BaseSenseEngine {
495
630
 
496
631
  // Publish to CognitiveBus — AttentionAllocator et al. can react.
497
632
  // publishPercept() (base) is the single emit chokepoint on senses.<domain>.percept.
498
- this.publishPercept( percept )
633
+ const percept = this.publishPercept<LanguagePercept>( transduced, msg )
499
634
 
500
635
  // ── Update digest with inbound turn ───────────────────────
501
636
  this._digests.append( threadId, 'user', content )
@@ -506,6 +641,16 @@ export class AuditionEngine extends BaseSenseEngine {
506
641
  this._inflightInbound.set( entityId, content )
507
642
  this._inflightThread.set( entityId, threadId )
508
643
 
644
+ // Someone spoke to us — record it in state so SOCIAL COGNITION can see it.
645
+ // Until this, an inbound message existed only on the bus and inside a facet:
646
+ // it created no entity, so SocialPerception (whose whole job is to notice
647
+ // people acting toward us) had nothing to scan, never published
648
+ // `interaction.occurred`, and every consumer of that event — reputation,
649
+ // affect, theory-of-mind, attachment, frustration — learned nothing from any
650
+ // conversation the Will ever had. A Will could hold 27 exchanges with someone
651
+ // and still carry familiarity 0, valence 0 for them.
652
+ this._writeReceived( entityId, speakerName, content, threadId )
653
+
509
654
  // ── Route to facet, then block the entity queue until the turn resolves ──
510
655
  // The turn deferred must be armed BEFORE routing because a synchronous facet
511
656
  // (e.g. a test mock) can fire its decision during report().
@@ -550,6 +695,16 @@ export class AuditionEngine extends BaseSenseEngine {
550
695
  this._turnDone.get( entityId )?.()
551
696
  }
552
697
 
698
+ /**
699
+ * True while a turn with this person is still resolving — they spoke and the
700
+ * reply has not landed yet. The agency asks before delivering a self-initiated
701
+ * message (see OutreachAuthor.isSpeakingTo), so one mind does not reach one
702
+ * person down two paths in the same tick.
703
+ */
704
+ isSpeakingTo( entityId: string ): boolean {
705
+ return this._turnDone.has( entityId )
706
+ }
707
+
553
708
  // ── Facet lifecycle ─────────────────────────────────────────
554
709
 
555
710
  private async _routeToFacet( percept: LanguagePercept, speakerName: string ): Promise<boolean> {
@@ -561,7 +716,10 @@ export class AuditionEngine extends BaseSenseEngine {
561
716
  let handle = this._facets.get( percept.speakerEntityId )
562
717
  if( !handle ){
563
718
  // New conversation session — try to spawn a facet.
564
- const result = this._executiveEngine.spawnFacet('conversation')
719
+ // Keyed by speaker: one thread of attention per person. The supervisor now
720
+ // owns that guarantee (and carries the thread's reasoning across a reap),
721
+ // so a re-spawn after an idle gap resumes rather than starting cold.
722
+ const result = this._executiveEngine.spawnFacet('conversation', `conversation:${percept.speakerEntityId}`)
565
723
  if( result.attention === 'full' || !result.handle ){
566
724
  logger.warn(
567
725
  `[audition-engine] Executive attention full — ` +
@@ -691,6 +849,11 @@ export class AuditionEngine extends BaseSenseEngine {
691
849
  awareness: [ ...DEFAULT_FACET_AWARENESS, 'plans' ],
692
850
  awarenessEntityId: percept.speakerEntityId,
693
851
 
852
+ // Who this facet is with — reported to the master on every facet sync so the
853
+ // singular seat knows whose conversations these are, not just how many.
854
+ subjectEntityId: percept.speakerEntityId,
855
+ subjectName: speakerName,
856
+
694
857
  instructions: [
695
858
  'I am in a live conversation with this person. I respond as myself.',
696
859
  'I stay grounded in my real memories and feelings — I do not invent experiences I have no record of.',
@@ -704,15 +867,28 @@ export class AuditionEngine extends BaseSenseEngine {
704
867
  // paragraphs (double-newline separated) become separate reply bubbles.
705
868
  extractDecision: ( raw: unknown ): ConversationDecision => {
706
869
  const output = raw as ExecutiveOutputFull
707
- const rawReply = output.replyText?.trim() ?? ''
870
+ // A declared silence suppresses the words and NOTHING else — the goals,
871
+ // beliefs and entity updates below are things the mind worked out from
872
+ // what it heard, and they are true whether or not it answers. Discarding
873
+ // them with the reply would make choosing silence cost the mind its
874
+ // learning, which is a reason not to choose it.
875
+ const silent = output.noMessage !== undefined
876
+ if( silent )
877
+ logger.info(`[audition-engine] chose silence toward ${ speakerName ?? percept.speakerEntityId }: ${ output.noMessage!.slice( 0, 120 ) }`)
878
+ const rawReply = silent ? '' : ( output.replyText?.trim() ?? '')
708
879
  const bubbles = rawReply.split( /\n{2,}/ )
709
880
  .map( b => b.trim() )
710
881
  .filter( Boolean )
711
882
 
883
+ const outwardIntents = partitionOutwardIntents(
884
+ output.actions, percept.speakerEntityId, speakerName,
885
+ )
886
+
712
887
  return {
713
888
  reply: bubbles.join('\n'),
714
889
  replyBubbles: bubbles,
715
890
  targetEntityId: percept.speakerEntityId,
891
+ ...( outwardIntents.length > 0 ? { outwardIntents } : {} ),
716
892
  newGoals: output.newGoals,
717
893
  goalsToAbandon: output.goalsToAbandon,
718
894
  newBeliefs: output.newBeliefs,
@@ -734,33 +910,97 @@ export class AuditionEngine extends BaseSenseEngine {
734
910
  * (MotorSchemaExecutor) to deliver through the proactive communicate path; empty
735
911
  * when no executive is attached or the facet budget is full (caller then awaits).
736
912
  */
737
- async authorOutreach( entityId: string, entityName: string, gist?: string ): Promise<string[]> {
738
- if( !this._executiveEngine ) return []
739
- const spawned = this._executiveEngine.spawnFacet('outreach')
740
- if( spawned.attention === 'full' || !spawned.handle ){
741
- logger.warn(`[audition-engine] facet budget full — cannot author outreach to ${ entityId }`)
742
- return []
913
+ async authorOutreach( entityId: string, entityName: string, gist?: string ): Promise<OutreachResult> {
914
+ if( !this._executiveEngine ) return { bubbles: [] }
915
+
916
+ // One authoring pass per person at a time.
917
+ //
918
+ // This was unguarded, and the agency can hold more than one intent toward the
919
+ // same target at once (two undertakings, or an undertaking plus a self-initiated
920
+ // reach). Each one spawned its own transient facet, each facet independently
921
+ // composed a message, and both were delivered — the same question asked twice,
922
+ // reworded, seconds apart. The executor's idempotence was keyed by INTENT id,
923
+ // which cannot see that two intents mean one conversation.
924
+ //
925
+ // A concurrent second call returns empty rather than waiting: its intent stays
926
+ // 'awaiting' and comes back round once this pass has landed and satiation has
927
+ // had a chance to read it, which is the outcome we want anyway.
928
+ if( this._outreachInFlight.has( entityId ) ){
929
+ logger.info(`[audition-engine] already composing an outreach to ${ entityId } — not opening a second`)
930
+ return { bubbles: [] }
743
931
  }
744
- const handle = spawned.handle
745
932
 
746
- handle.setFocus({
933
+ // Already talking to them? Then this is not a second thread — it is a thing to
934
+ // say in the one that is open, and it must be said BY that thread.
935
+ //
936
+ // A transient facet composing in parallel cannot see the live conversation: not
937
+ // the thread digest, not what was said two minutes ago, not the thinking the
938
+ // open facet has been doing about this person. So the mind asked the same
939
+ // question it had already asked, in different words, while the answer was
940
+ // sitting in a thread it was not reading. Routing through the open facet costs
941
+ // nothing extra — the focus rides on the REPORT (see FacetReport.focus), so the
942
+ // conversation's own standing focus is never touched and the next inbound turn
943
+ // resumes exactly where it was.
944
+ const openThread = this._executiveEngine.facetFor(`conversation:${ entityId }`)
945
+ const handle = openThread ?? ( () => {
946
+ // Nobody home — a transient authoring facet, deliberately NOT supervisor-keyed:
947
+ // it is exactly what the mind can most afford to evict under pressure, and a
948
+ // key would move it into the protected tier alongside live conversations.
949
+ const spawned = this._executiveEngine!.spawnFacet('outreach')
950
+ if( spawned.attention === 'full' || !spawned.handle ){
951
+ logger.warn(`[audition-engine] facet budget full — cannot author outreach to ${ entityId }`)
952
+ return undefined
953
+ }
954
+ return spawned.handle
955
+ } )()
956
+
957
+ if( !handle ) return { bubbles: [] }
958
+
959
+ if( openThread )
960
+ logger.info(`[audition-engine] composing outreach to ${ entityId } inside the open conversation (${ openThread.facetId })`)
961
+
962
+ // What we have already said to each other, when there IS an open thread. Without
963
+ // it the mind opens with a question it asked four minutes ago.
964
+ const digest = openThread ? this._digests.getDigest( this._inflightThread.get( entityId ) ?? entityId ) : ''
965
+
966
+ const outreachFocus: FocusSection = ({
747
967
  title: 'Reaching out',
748
968
  function: 'outreach',
749
969
  content: [
750
- `I have decided, on my own initiative, to reach out to ${ entityName } (id: ${ entityId }).`,
751
- 'No one prompted this I am choosing to make contact now.',
970
+ openThread
971
+ ? `I am already in conversation with ${ entityName } (id: ${ entityId }), and there is something I have decided to say to them now — unprompted, not an answer to anything they asked.`
972
+ : `I have decided, on my own initiative, to reach out to ${ entityName } (id: ${ entityId }).`,
973
+ openThread
974
+ ? 'This continues that conversation. I do not re-introduce myself and I do not ask again for something already answered above.'
975
+ : 'No one prompted this — I am choosing to make contact now.',
976
+ digest,
752
977
  gist ? `What is on my mind: ${ gist }` : '',
978
+ // The gist is what the MASTER framed, and the master was not talking to
979
+ // them — so it refers to people in the third person, including sometimes
980
+ // the very person about to read it. Observed live: "Fabrice says the
981
+ // server issue is fixed now and he should look into the logs", addressed
982
+ // TO him. The words are mine to choose; the gist is only what I mean.
983
+ gist ? `That is my sense of it, not my words to them — I am speaking to ${ entityName } directly, so I say it the way I would say it to their face.` : '',
753
984
  ].filter( Boolean ).join('\n'),
754
985
  recallQuery: gist ?? entityName,
755
986
  awareness: [ ...DEFAULT_FACET_AWARENESS, 'plans' ],
756
987
  awarenessEntityId: entityId,
988
+ subjectEntityId: entityId,
989
+ subjectName: entityName,
757
990
  instructions:
758
991
  'Considering who I am, my goals, and how I feel, I say what I genuinely want to say to ' +
759
992
  'them now. I speak as myself; I stay grounded in my real memories — I do not invent experiences ' +
760
993
  'I have no record of.',
761
994
  outputFormat: CONVERSATION_OUTPUT_FORMAT,
762
995
  extractDecision: ( raw: unknown ): ConversationDecision => {
763
- const output = raw as ExecutiveOutputFull
996
+ const output = raw as ExecutiveOutputFull
997
+ // A declared silence beats anything else in the response. Unprompted
998
+ // speech is the one case where saying nothing must be cheaper than
999
+ // saying something — nobody is waiting on this.
1000
+ if( output.noMessage !== undefined ){
1001
+ logger.info(`[audition-engine] chose not to reach out to ${ entityName }: ${ output.noMessage.slice( 0, 120 ) }`)
1002
+ return { reply: '', replyBubbles: [], withheld: true, targetEntityId: entityId, requiresMasterAttention: false }
1003
+ }
764
1004
  const rawReply = output.replyText?.trim() ?? ''
765
1005
  const bubbles = rawReply.split( /\n{2,}/ ).map( b => b.trim() ).filter( Boolean )
766
1006
  return { reply: bubbles.join('\n'), replyBubbles: bubbles, targetEntityId: entityId, requiresMasterAttention: false }
@@ -770,24 +1010,59 @@ export class AuditionEngine extends BaseSenseEngine {
770
1010
  // report() only QUEUES the facet's reasoning; the authored bubbles arrive LATER
771
1011
  // via the subscription. So wait for the DECISION (not report's resolution), with
772
1012
  // 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( [] )
1013
+ this._outreachInFlight.add( entityId )
1014
+ try {
1015
+ const authored = await new Promise<OutreachResult>( resolve => {
1016
+ let settled = false
1017
+ let unsub: () => void = () => {}
1018
+ let timer: ReturnType<typeof setTimeout>
1019
+ const done = ( r: OutreachResult ): void => { if( settled ) return; settled = true; clearTimeout( timer ); unsub(); resolve( r ) }
1020
+ timer = setTimeout(
1021
+ () => { logger.warn(`[audition-engine] outreach authoring timed out for ${ entityId }`); done( { bubbles: [] } ) },
1022
+ 60_000, // generous: the facet LLM authors in ~8–18s
1023
+ )
1024
+ // ONLY this report's decision. Sharing a live conversation facet means its
1025
+ // ordinary reply decisions arrive on the same subscription, and resolving
1026
+ // on one of those would hand the human's reply back as if the mind had
1027
+ // composed it unprompted — and deliver it twice.
1028
+ unsub = handle.subscribe( d => {
1029
+ if( d.respondingToType !== 'outreach') return
1030
+ const decision = d.decision as ConversationDecision
1031
+ // A facet reasoned and its decision arrived — `answered`, whatever it
1032
+ // holds. That is what separates an empty ANSWER from the empty every
1033
+ // other exit on this method returns, and it is the only place on the
1034
+ // method that can honestly say so.
1035
+ done( {
1036
+ bubbles: decision.replyBubbles ?? [],
1037
+ withheld: decision.withheld === true,
1038
+ answered: true,
1039
+ } )
1040
+ } )
1041
+ // The focus rides the REPORT, so a shared conversation facet keeps its own
1042
+ // standing focus and its next inbound turn resumes untouched.
1043
+ Promise.resolve( handle.report({ type: 'outreach', payload: { entityId, gist }, focus: outreachFocus }) ).catch( err => {
1044
+ logger.warn(`[audition-engine] outreach report failed for ${ entityId }: ${ ( err as Error ).message }`)
1045
+ done( { bubbles: [] } )
1046
+ } )
786
1047
  } )
787
- } )
788
1048
 
789
- handle.destroy()
790
- return bubbles
1049
+ // Only tear down what we opened. Destroying a borrowed conversation facet
1050
+ // would end the conversation as a side effect of speaking in it.
1051
+ if( !openThread ) handle.destroy()
1052
+ return authored
1053
+ }
1054
+ catch( err ){
1055
+ // A facet that throws is a pass that produced no words — the same outcome as
1056
+ // the timeout, and the caller's contract is already "empty means I could not
1057
+ // author". Letting it escape would reject inside MotorSchemaExecutor's
1058
+ // fire-and-forget authoring chain instead.
1059
+ logger.warn(`[audition-engine] outreach authoring failed for ${ entityId }: ${ ( err as Error ).message }`)
1060
+ return { bubbles: [] }
1061
+ }
1062
+ // finally{} on every path — the timeout resolves empty rather than throwing, but
1063
+ // a destroy() or report() that throws must not leave this person permanently
1064
+ // un-reachable by leaving the guard set.
1065
+ finally { this._outreachInFlight.delete( entityId ) }
791
1066
  }
792
1067
 
793
1068
  // ── Conversation memory (Section 5) ─────────────────────────
@@ -801,6 +1076,116 @@ export class AuditionEngine extends BaseSenseEngine {
801
1076
  * (Section 1.2) routes ingest through the tick loop. The entity carries no
802
1077
  * wall-clock timestamp — `setEntity` stamps createdAt/tick from the sim clock.
803
1078
  */
1079
+ /**
1080
+ * The inbound as a social signal in state — mirror of `conversation.sent`.
1081
+ *
1082
+ * Shaped for `SocialPerception._scanSocialSignals`, which reads `sourceKeid` for
1083
+ * who acted and `directedAtSelf` for whether it was aimed at us. Valence is left
1084
+ * UNSET on purpose: the words have not been appraised yet, and guessing a number
1085
+ * here would feed reputation and affect a sentiment nobody measured. Absent, the
1086
+ * scanner falls back to its neutral default, so the Will learns *that* someone
1087
+ * engaged (familiarity, recency, reliability) without inventing how it felt.
1088
+ */
1089
+ /**
1090
+ * A durable, deterministic id for a conversation record.
1091
+ *
1092
+ * `<prefix>-<entity>-<tick>-<hash of the words>`. Every part earns its place:
1093
+ * • entity — whose conversation this is;
1094
+ * • tick — WHEN, from the sim clock, which resumes from the snapshot and so
1095
+ * keeps rising across restarts;
1096
+ * • hash — which utterance, so two things said to one person on one tick stay
1097
+ * two records.
1098
+ *
1099
+ * What it replaces was `<prefix>-<entity>-<N>` with N a process-local counter.
1100
+ * It restarted at 1 on every boot, so each session OVERWROTE the previous
1101
+ * session's records of the same person — a mind that had spoken with someone
1102
+ * across four restarts held one session's worth of evidence that it ever had.
1103
+ * Found by diffing a live snapshot against the Discord transcript it came from:
1104
+ * `conv-sent-reply-discord:1019…-1` held that morning's greeting, and every
1105
+ * earlier conversation keyed to the same id was simply gone.
1106
+ *
1107
+ * No wallClock: these ids live in state, and a wall-clock id makes the recorded
1108
+ * and replayed runs diverge (R2).
1109
+ */
1110
+ private _sentKey( prefix: string, entityId: string, words: string ): string {
1111
+ return `${ prefix }-${ entityId }-${ this._lastDecisionTick }-${ fnv1a( words ) }`
1112
+ }
1113
+
1114
+ private _writeReceived( entityId: string, speakerName: string | undefined, content: string, threadId: string ): void {
1115
+ if( !this._memorySink ) return
1116
+ // Never wallClock(): this entity LIVES IN STATE, so a wall-clock id makes the
1117
+ // recorded and replayed runs diverge (R2). Observed as a replay consuming 17 of
1118
+ // 18 recorded completions — different ids meant different percepts meant a
1119
+ // different executive firing schedule.
1120
+ //
1121
+ // But nor a process-local counter, which is what this was. `conv-received-<id>-N`
1122
+ // restarted at N=1 on every boot, so each session silently OVERWROTE the last
1123
+ // session's records of the same person. A mind that had spoken with someone
1124
+ // across four restarts held one session's worth of evidence that it ever had.
1125
+ // See _sentKey.
1126
+ this._memorySink({
1127
+ id: this._sentKey('conv-received', entityId, content ),
1128
+ type: 'conversation.received',
1129
+ metadata: {
1130
+ sourceKeid: entityId,
1131
+ sourceName: speakerName,
1132
+ directedAtSelf: true, // an inbound turn is addressed to us by definition
1133
+ action: 'communication',
1134
+ preview: content.slice( 0, 140 ),
1135
+ chars: content.length,
1136
+ ...( threadId ? { threadId } : {} ),
1137
+ },
1138
+ })
1139
+ }
1140
+
1141
+ /**
1142
+ * Record that the mind SPOKE to someone, mirroring `_writeReceived`.
1143
+ *
1144
+ * Only ProactiveCommunicator wrote `conversation.sent`, so a reply — which is
1145
+ * most of what a Will says — left no durable trace of having spoken. Everything
1146
+ * that asks "have I already said something to them?" was therefore blind to
1147
+ * conversation: satiation could not damp repeating a relay delivered as a reply,
1148
+ * and an undertaking discharged inside a conversation stayed forever unkept,
1149
+ * which is exactly how the same message went out again and again.
1150
+ *
1151
+ * Speaking is speaking, whichever path carried it.
1152
+ */
1153
+ private _writeSent(
1154
+ entityId: string,
1155
+ entityName: string | undefined,
1156
+ bubbles: string[],
1157
+ /**
1158
+ * Outbox ids for these bubbles — the ONLY thing that lets a later delivery
1159
+ * ack find this record (`OutboxController.confirmDelivery` correlates on
1160
+ * `outboxMessageIds`, there is no other key).
1161
+ *
1162
+ * Omitted, this record could never be marked delivered. Every reply the mind
1163
+ * ever made carried `delivered` unset, forever — so a mind asking itself "did
1164
+ * that land?" found no answer for anything it had SAID, while the answer was
1165
+ * recorded faithfully for everything it had initiated. Silence read exactly
1166
+ * like failure, and it re-sent. The proactive path stored these from the
1167
+ * start; the reply path was simply never given them.
1168
+ */
1169
+ outboxMessageIds?: string[],
1170
+ ): void {
1171
+ if( !this._memorySink || bubbles.length === 0 ) return
1172
+ this._memorySink({
1173
+ id: this._sentKey('conv-sent-reply', entityId, bubbles.join('\n') ),
1174
+ type: 'conversation.sent',
1175
+ metadata: {
1176
+ targetEntityId: entityId,
1177
+ targetEntityName: entityName,
1178
+ messageCount: bubbles.length,
1179
+ preview: bubbles[0]?.slice( 0, 100 ) ?? '',
1180
+ effectorName: 'text',
1181
+ source: 'audition-facet',
1182
+ tick: this._lastDecisionTick,
1183
+ delivered: false,
1184
+ ...( outboxMessageIds?.length ? { outboxMessageIds } : {} ),
1185
+ },
1186
+ })
1187
+ }
1188
+
804
1189
  private _persistExchangeMemory( entityId: string, threadId: string, reply: string, confidence: number, entityName?: string ): void {
805
1190
  const inbound = this._inflightInbound.get( entityId ) ?? ''
806
1191
  this._inflightInbound.delete( entityId )
@@ -832,6 +1217,19 @@ export class AuditionEngine extends BaseSenseEngine {
832
1217
  // facet's spawn-time thread — correct for an entity that spans threads (§2).
833
1218
  const threadId = this._inflightThread.get( entityId ) ?? ''
834
1219
 
1220
+ // The sim tick this was reasoned at — the only deterministic clock an off-tick
1221
+ // engine has, and what the conversation-record ids are keyed on.
1222
+ this._lastDecisionTick = decision.tick ?? this._lastDecisionTick
1223
+
1224
+ // An outreach composed INSIDE this conversation (authorOutreach borrowing the
1225
+ // open facet) lands here too, because the session subscription is facet-wide.
1226
+ // Its words belong to `authorOutreach`, which returns them to the agency for
1227
+ // delivery through the proactive path; delivering them here as well would send
1228
+ // the same message twice and answer a turn nobody took. `_endTurn` is not
1229
+ // called either — an outreach is not a turn, and releasing the queue here would
1230
+ // let the next inbound start while a real turn was still in flight.
1231
+ if( decision.respondingToType === 'outreach') return
1232
+
835
1233
  // finally{} releases the entity's serial turn queue on EVERY exit path —
836
1234
  // reply delivered, reply suppressed, or escalation only. Without this a
837
1235
  // suppressed-reply early-return would stall the queue until the safety timeout.
@@ -875,6 +1273,8 @@ export class AuditionEngine extends BaseSenseEngine {
875
1273
  pushToOutbox: !viaTransport,
876
1274
  })
877
1275
 
1276
+ this._writeSent( entityId, d.targetEntityId, d.replyBubbles, ids )
1277
+
878
1278
  if( viaTransport )
879
1279
  logger.info(
880
1280
  `[audition-engine] Reply emitted via transport for ${entityId} ` +
@@ -892,22 +1292,61 @@ export class AuditionEngine extends BaseSenseEngine {
892
1292
  )
893
1293
  }
894
1294
 
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',
1295
+ // ── Outward intentions ────────────────────────────────────
1296
+ // The facet decided, mid-conversation, to say something to someone ELSE.
1297
+ // It does not do that itself (see ConversationDecision.outwardIntents) it
1298
+ // hands the intention to the master, which is singular and owns whom the
1299
+ // mind contacts. The master perceives it as an undertaking it made and
1300
+ // decides whether it still means it; nothing here forces the contact.
1301
+ // `executive.facet.handoff` is the ONE channel every facet type uses to hand
1302
+ // the master something. It replaced `audition.task.signal`, which was named
1303
+ // and typed for this engine alone — see EscalationBuffer.
1304
+ const handoff = ( body: HandoffBody ): void => {
1305
+ this._bus?.publish({
1306
+ type: 'executive.facet.handoff',
901
1307
  version: 1,
902
1308
  sourceEngine: this.name,
903
1309
  salience: 0.9,
904
1310
  payload: {
905
- entityId,
1311
+ facetId: decision.facetId,
1312
+ subjectEntityId: entityId,
1313
+ ...( d.targetEntityId ? { subjectName: d.targetEntityId } : {} ),
906
1314
  threadId,
907
- reasoning: decision.reasoning,
908
- confidence: decision.confidence
1315
+ confidence: decision.confidence,
1316
+ // No `tick` — this engine runs off-tick and has no honest sim clock of
1317
+ // its own. The master stamps it from the tick the handoff ARRIVES on,
1318
+ // which is within one tick of when it was formed and, crucially, is a
1319
+ // real clock reading rather than the last time the master happened to run.
1320
+ body,
909
1321
  }
910
1322
  })
1323
+ }
1324
+
1325
+ if( d.outwardIntents?.length )
1326
+ for( const intent of d.outwardIntents ){
1327
+ logger.info(
1328
+ `[audition-engine] Outward intent from ${entityId}'s facet → ${intent.target} ` +
1329
+ `(handing to master; the facet does not open that channel itself)`
1330
+ )
1331
+ handoff({
1332
+ kind: 'undertaking',
1333
+ // The commitment in the mind's own words. A contact is one KIND of
1334
+ // promise, not the only kind the tract can carry — see
1335
+ // UndertakingHandoff. This producer only makes contact-shaped ones,
1336
+ // because a facet declares an outward intent by naming a
1337
+ // communicate action; anything else it means to follow through on
1338
+ // goes through [GOALS_NEW], which the format already asks for.
1339
+ what: `reach ${ intent.target }`,
1340
+ target: intent.target,
1341
+ reasoning: intent.reasoning ?? '',
1342
+ ...( intent.gist ? { gist: intent.gist } : {} ),
1343
+ })
1344
+ }
1345
+
1346
+ // ── Master escalation signal ──────────────────────────────
1347
+ // Published when the facet emits an 'escalate' action type.
1348
+ if( d.requiresMasterAttention )
1349
+ handoff({ kind: 'escalation', reasoning: decision.reasoning })
911
1350
  }
912
1351
  finally {
913
1352
  // Release the entity's turn queue so the next message can be processed.