@mindot/will 0.7.0 → 0.9.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 (88) hide show
  1. package/README.md +87 -22
  2. package/dist/channels/discord.d.ts +67 -6
  3. package/dist/channels/discord.js +112 -6
  4. package/dist/channels/discord.js.map +1 -1
  5. package/dist/channels/whatsapp.d.ts +1 -1
  6. package/dist/channels/whatsapp.js +4 -1
  7. package/dist/channels/whatsapp.js.map +1 -1
  8. package/dist/cli.js +13733 -11057
  9. package/dist/cli.js.map +1 -1
  10. package/dist/index.d.ts +2 -2
  11. package/dist/index.js +3729 -1199
  12. package/dist/index.js.map +1 -1
  13. package/dist/mcp/effectors.d.ts +1 -1
  14. package/dist/{will-DAW0l-lY.d.ts → will-DbDj_TEH.d.ts} +1214 -93
  15. package/package.json +1 -1
  16. package/src/channels/discord.ts +189 -11
  17. package/src/channels/types.ts +90 -0
  18. package/src/channels/whatsapp.ts +13 -4
  19. package/src/cli.ts +9 -4
  20. package/src/cognition/agency/consequence.ts +122 -1
  21. package/src/cognition/agency/conversation.aim.ts +260 -0
  22. package/src/cognition/agency/engines/action.selector.ts +85 -3
  23. package/src/cognition/agency/engines/affordance.synthesizer.ts +90 -1
  24. package/src/cognition/agency/engines/motor.schema.executor.ts +152 -10
  25. package/src/cognition/agency/engines/reafference.engine.ts +129 -2
  26. package/src/cognition/agency/proactive.communicator.ts +19 -3
  27. package/src/cognition/agency/reconcile.learning.ts +16 -2
  28. package/src/cognition/agency/restart.ts +66 -0
  29. package/src/cognition/agency/schemas/repertoire.ts +12 -5
  30. package/src/cognition/agency/selection.scoring.ts +33 -0
  31. package/src/cognition/agency/types.ts +35 -0
  32. package/src/cognition/cache/composition.ts +232 -0
  33. package/src/cognition/cache/deliberation.cache.ts +219 -0
  34. package/src/cognition/cache/fingerprint.ts +120 -0
  35. package/src/cognition/cache/types.ts +105 -0
  36. package/src/cognition/config.mirror.entities.ts +109 -1
  37. package/src/cognition/event.schemas.ts +22 -0
  38. package/src/cognition/faculties/autobiographical.narrator.ts +5 -10
  39. package/src/cognition/faculties/episodic.consolidator.ts +59 -3
  40. package/src/cognition/faculties/executive.engine/commands.ts +189 -14
  41. package/src/cognition/faculties/executive.engine/context.ts +67 -13
  42. package/src/cognition/faculties/executive.engine/deliberate.reasoning.ts +1 -1
  43. package/src/cognition/faculties/executive.engine/engine.ts +686 -187
  44. package/src/cognition/faculties/executive.engine/escalation.buffer.ts +162 -44
  45. package/src/cognition/faculties/executive.engine/facet.supervisor.ts +310 -65
  46. package/src/cognition/faculties/executive.engine/facet.ts +89 -26
  47. package/src/cognition/faculties/executive.engine/gating.ts +14 -14
  48. package/src/cognition/faculties/executive.engine/parser.ts +21 -1
  49. package/src/cognition/faculties/executive.engine/prompt.factory.ts +169 -20
  50. package/src/cognition/faculties/executive.engine/types.ts +69 -0
  51. package/src/cognition/faculties/goal.manager.ts +94 -14
  52. package/src/cognition/faculties/known.entity.tracker.ts +267 -28
  53. package/src/cognition/faculties/moral.evaluator.ts +8 -3
  54. package/src/cognition/faculties/persona.consolidator.ts +141 -0
  55. package/src/cognition/faculties/reputation.tracker.ts +66 -2
  56. package/src/cognition/faculties/self.model.updater.ts +19 -12
  57. package/src/cognition/faculties/social.perception.ts +47 -3
  58. package/src/cognition/faculties/threat.evaluator.ts +7 -0
  59. package/src/cognition/faculties/working.memory.ts +10 -20
  60. package/src/cognition/identity.entity.ts +205 -0
  61. package/src/cognition/index.ts +11 -0
  62. package/src/cognition/memory/vector.adapter.ts +12 -3
  63. package/src/cognition/memory/vector.embedder.ts +54 -7
  64. package/src/cognition/persona.prior.ts +6 -0
  65. package/src/cognition/senses/audition.engine/engine.ts +404 -46
  66. package/src/cognition/senses/base.sense.engine.ts +1 -1
  67. package/src/cognition/senses/index.ts +12 -0
  68. package/src/cognition/social.identity.ts +273 -0
  69. package/src/cognition/utilities/token.tracker.ts +246 -98
  70. package/src/core/orchestrator.ts +38 -0
  71. package/src/host/boot.ts +78 -22
  72. package/src/index.ts +35 -0
  73. package/src/llm/index.ts +415 -97
  74. package/src/llm/routing.ts +204 -0
  75. package/src/llm/summarizer.ts +5 -1
  76. package/src/llm/wire.contracts.ts +19 -0
  77. package/src/pma/index.ts +67 -53
  78. package/src/runners/thin-shim.runner.ts +18 -6
  79. package/src/sdk/will.ts +121 -22
  80. package/src/stem/assembly.audit.ts +1 -0
  81. package/src/stem/guards/identity.coherence.ts +17 -6
  82. package/src/stem/index.ts +82 -5
  83. package/src/stem/mind.ts +327 -79
  84. package/src/stem/policy/arbiter.ts +49 -14
  85. package/src/stem/policy/rule.table.ts +2 -2
  86. package/src/stem/tracts/effector.controller.ts +56 -9
  87. package/src/stem/tracts/outbox.writer.ts +40 -2
  88. package/src/cognition/faculties/executive.engine/messages.ts +0 -102
@@ -13,14 +13,14 @@
13
13
  * Architecture:
14
14
  * - External call: WillManager.ingestText() → audition.ingest(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'
@@ -74,6 +75,8 @@ import type {
74
75
  TextMessage,
75
76
  VoiceChunk
76
77
  } from '#senses/index'
78
+ import { validateFacetHandoff, type HandoffBody } from '#faculties/executive.engine/escalation.buffer'
79
+ import { fnv1a } from '#agency/consequence'
77
80
 
78
81
  // ── Internal types ─────────────────────────────────────────────
79
82
 
@@ -83,6 +86,22 @@ interface ConversationDecision {
83
86
  /** Individual reply bubbles for display (separate SSE chunks). */
84
87
  replyBubbles: string[]
85
88
  targetEntityId: string
89
+ /**
90
+ * Actions this facet aimed at someone OTHER than the person it is talking to.
91
+ *
92
+ * A conversation facet is the mind talking to ONE person. When it decides mid-
93
+ * conversation to contact a third party ("I'll reach out to FKEM now"), it must
94
+ * not open that channel itself — a facet bound to Fabrice messaging FKEM is the
95
+ * parallel-conversation failure, and it would collide with any facet already
96
+ * talking to FKEM. So the intention is surfaced to the master, which is singular
97
+ * and owns whom the mind contacts.
98
+ *
99
+ * Before this existed, `extractDecision` read `output.actions` only to test for
100
+ * 'escalate' and dropped the rest: a third-party action was delivered nowhere,
101
+ * became no intent, competed in nothing and left no reafference — the mind said
102
+ * it would make contact, believed it had, and nothing ever went out.
103
+ */
104
+ outwardIntents?: { target: string; gist?: string; reasoning?: string }[]
86
105
  newGoals?: ExecutiveOutputFull['newGoals']
87
106
  goalsToAbandon?: ExecutiveOutputFull['goalsToAbandon']
88
107
  newBeliefs?: ExecutiveOutputFull['newBeliefs']
@@ -114,6 +133,46 @@ interface CoalesceWindow {
114
133
  }
115
134
 
116
135
 
136
+ /**
137
+ * Split a facet's actions into the ones aimed at the person it is talking to
138
+ * (delivered as the reply, which the [REPLY_TEXT] block already carries) and the
139
+ * ones aimed at someone else (returned here, for the master to own).
140
+ *
141
+ * "Aimed at this person" is matched against both the bound keid and the name the
142
+ * facet was given for them, because a mind writes whichever it is looking at —
143
+ * the trace shows it using `discord:1019…` and `Fabrice` interchangeably within a
144
+ * single decision. Anything else — including a name the mind has heard but never
145
+ * bound to anyone — is outward, and gets carried rather than dropped: whether it
146
+ * can be reached at all is the master's problem to notice, not this partition's.
147
+ */
148
+ export function partitionOutwardIntents(
149
+ actions: ExecutiveOutputFull['actions'] | undefined,
150
+ boundKeid: string,
151
+ boundName: string,
152
+ ): { target: string; gist?: string; reasoning?: string }[] {
153
+ const mine = new Set( [ boundKeid, boundName ].map( s => s.trim().toLowerCase() ).filter( Boolean ) )
154
+ const out: { target: string; gist?: string; reasoning?: string }[] = []
155
+
156
+ for( const action of actions ?? [] ){
157
+ if( !COMMUNICATE_ACTION_TYPES.has( action.type.toLowerCase() ) ) continue
158
+
159
+ const args = ( action.args && typeof action.args === 'object' ? action.args : {} ) as Record<string, unknown>
160
+ const target = [ action.target, args['to'], args['recipient'], args['target'] ]
161
+ .find( v => typeof v === 'string' && v.trim().length > 0 ) as string | undefined
162
+
163
+ // No addressee named at all ⇒ it meant the person in front of it; the
164
+ // [REPLY_TEXT] block is already that reply.
165
+ if( !target || mine.has( target.trim().toLowerCase() ) ) continue
166
+
167
+ const gist = [ args['content'], args['message'], args['text'], args['body'] ]
168
+ .find( v => typeof v === 'string' && v.trim().length > 0 ) as string | undefined
169
+
170
+ out.push({ target: target.trim(), ...( gist ? { gist } : {} ), ...( action.reasoning ? { reasoning: action.reasoning } : {} ) })
171
+ }
172
+
173
+ return out
174
+ }
175
+
117
176
  // ── Conversation output format ─────────────────────────────────
118
177
  // Two-step format: JSON first (private reasoning), then [REPLY_TEXT] (streamed to client).
119
178
  // [REPLY_TEXT] is plain prose — no JSON wrapper, no targetEntityId needed.
@@ -145,25 +204,38 @@ Start a new paragraph (blank line) to send a separate chat bubble.
145
204
  Write [REPLY_TEXT] AFTER the closing \`\`\`. This is the only part the speaker sees — keep it grounded, present.
146
205
  Separate multiple messages with a blank line for natural conversational pauses (like separate texts).
147
206
 
207
+ ## Saying nothing
208
+ Silence is a real choice and it is available to me: if I have nothing to say right now — I am
209
+ waiting on them, or speaking again would only repeat myself — I write a [NO_MESSAGE] block
210
+ instead of a [REPLY_TEXT] one, and I put my reason inside it:
211
+
212
+ [NO_MESSAGE]
213
+ Nothing new to add — I am waiting on their answer to what I already asked.
214
+ [/NO_MESSAGE]
215
+
216
+ That is recorded and NEVER sent. Anything between the [REPLY_TEXT] markers IS SENT, so a line
217
+ like "[no message this cycle — waiting for their reply]" does not describe my silence to
218
+ myself, it delivers that sentence to them. If I write both blocks, the silence wins.
219
+
148
220
  ## When to use GOALS_NEW (almost always)
149
221
  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.
222
+ This tracks intent across future cycles on its own.
151
223
 
152
224
  ## 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:
225
+ 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
226
  - The task involves multiple steps across future cycles ("build me X", "monitor Y", "set up Z")
155
227
  - The request changes my active goal priorities in a significant way
156
228
  - I need to coordinate something beyond a single reply
157
229
 
158
- **The "reasoning" field on the escalate action becomes the task description the master sees.**
230
+ **The "reasoning" field on the escalate action becomes the description of the work I pick up.**
159
231
  Make it concrete — describe WHAT needs to happen, not just that I am escalating.
160
232
  Good: type=escalate, reasoning="User wants weekly mood summaries by email every Monday. Needs: data aggregation, schedule, email delivery.", expectedOutcome="Weekly email delivered."
161
233
  Bad: type=escalate, reasoning="Escalating because this is complex."
162
234
 
163
235
  When I escalate:
164
236
  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
237
+ 2. I form and carry out the plan away from this conversation, over the cycles that follow
238
+ 3. Do NOT include a [PLANS] block — the planning happens there, not here
167
239
 
168
240
  For simple, single-exchange requests (questions, opinions, short tasks) — do NOT escalate. Just reply.`
169
241
 
@@ -270,6 +342,12 @@ export class AuditionEngine extends BaseSenseEngine {
270
342
  * other percept uses. Wired to `stateManager.setEntity` in assembleMind().
271
343
  */
272
344
  private _memorySink: (( entity: MemoryEntity ) => void) | null = null
345
+ /**
346
+ * Sim tick of the most recent facet decision — the only deterministic clock this
347
+ * off-tick engine has. Stamped from `FacetDecision.tick`, and used to key the
348
+ * conversation records it writes into state.
349
+ */
350
+ private _lastDecisionTick = 0
273
351
  /** Speaker attachment strength accessor (0–1) — weights salience by relationship. */
274
352
  private _getAttachmentScore: (( entityId: string ) => number) | null = null
275
353
  /** Active-goal topic text accessor — for salience topic-overlap. */
@@ -278,6 +356,8 @@ export class AuditionEngine extends BaseSenseEngine {
278
356
  private _inflightInbound = new Map<string, string>()
279
357
  /** In-flight thread per entity — stamps chunk envelopes with the current threadId. */
280
358
  private _inflightThread = new Map<string, string>()
359
+ /** Targets with an outreach being composed right now — see authorOutreach. */
360
+ private _outreachInFlight = new Set<string>()
281
361
 
282
362
  // ── Assembly wiring ─────────────────────────────────────────
283
363
 
@@ -331,11 +411,11 @@ export class AuditionEngine extends BaseSenseEngine {
331
411
 
332
412
  // ── CognitiveEngine ─────────────────────────────────────────
333
413
 
334
- /** Override: audition adds the master-escalation signal to the base percept schema. */
414
+ /** Override: audition adds the facet→master handoff to the base percept schema. */
335
415
  publishes(): CognitiveEventSchema[] {
336
416
  return [
337
- { type: 'senses.audition.percept', version: 1, validate: () => null },
338
- { type: 'audition.task.signal', version: 1, validate: () => null }
417
+ { type: 'senses.audition.percept', version: 1, validate: () => null },
418
+ { type: 'executive.facet.handoff', version: 1, validate: validateFacetHandoff },
339
419
  ]
340
420
  }
341
421
  // subscribes() and onCognitiveEvent() inherit the base no-ops (ingest-driven).
@@ -506,6 +586,16 @@ export class AuditionEngine extends BaseSenseEngine {
506
586
  this._inflightInbound.set( entityId, content )
507
587
  this._inflightThread.set( entityId, threadId )
508
588
 
589
+ // Someone spoke to us — record it in state so SOCIAL COGNITION can see it.
590
+ // Until this, an inbound message existed only on the bus and inside a facet:
591
+ // it created no entity, so SocialPerception (whose whole job is to notice
592
+ // people acting toward us) had nothing to scan, never published
593
+ // `interaction.occurred`, and every consumer of that event — reputation,
594
+ // affect, theory-of-mind, attachment, frustration — learned nothing from any
595
+ // conversation the Will ever had. A Will could hold 27 exchanges with someone
596
+ // and still carry familiarity 0, valence 0 for them.
597
+ this._writeReceived( entityId, speakerName, content, threadId )
598
+
509
599
  // ── Route to facet, then block the entity queue until the turn resolves ──
510
600
  // The turn deferred must be armed BEFORE routing because a synchronous facet
511
601
  // (e.g. a test mock) can fire its decision during report().
@@ -561,7 +651,10 @@ export class AuditionEngine extends BaseSenseEngine {
561
651
  let handle = this._facets.get( percept.speakerEntityId )
562
652
  if( !handle ){
563
653
  // New conversation session — try to spawn a facet.
564
- const result = this._executiveEngine.spawnFacet('conversation')
654
+ // Keyed by speaker: one thread of attention per person. The supervisor now
655
+ // owns that guarantee (and carries the thread's reasoning across a reap),
656
+ // so a re-spawn after an idle gap resumes rather than starting cold.
657
+ const result = this._executiveEngine.spawnFacet('conversation', `conversation:${percept.speakerEntityId}`)
565
658
  if( result.attention === 'full' || !result.handle ){
566
659
  logger.warn(
567
660
  `[audition-engine] Executive attention full — ` +
@@ -691,6 +784,11 @@ export class AuditionEngine extends BaseSenseEngine {
691
784
  awareness: [ ...DEFAULT_FACET_AWARENESS, 'plans' ],
692
785
  awarenessEntityId: percept.speakerEntityId,
693
786
 
787
+ // Who this facet is with — reported to the master on every facet sync so the
788
+ // singular seat knows whose conversations these are, not just how many.
789
+ subjectEntityId: percept.speakerEntityId,
790
+ subjectName: speakerName,
791
+
694
792
  instructions: [
695
793
  'I am in a live conversation with this person. I respond as myself.',
696
794
  'I stay grounded in my real memories and feelings — I do not invent experiences I have no record of.',
@@ -704,15 +802,28 @@ export class AuditionEngine extends BaseSenseEngine {
704
802
  // paragraphs (double-newline separated) become separate reply bubbles.
705
803
  extractDecision: ( raw: unknown ): ConversationDecision => {
706
804
  const output = raw as ExecutiveOutputFull
707
- const rawReply = output.replyText?.trim() ?? ''
805
+ // A declared silence suppresses the words and NOTHING else — the goals,
806
+ // beliefs and entity updates below are things the mind worked out from
807
+ // what it heard, and they are true whether or not it answers. Discarding
808
+ // them with the reply would make choosing silence cost the mind its
809
+ // learning, which is a reason not to choose it.
810
+ const silent = output.noMessage !== undefined
811
+ if( silent )
812
+ logger.info(`[audition-engine] chose silence toward ${ speakerName ?? percept.speakerEntityId }: ${ output.noMessage!.slice( 0, 120 ) }`)
813
+ const rawReply = silent ? '' : ( output.replyText?.trim() ?? '')
708
814
  const bubbles = rawReply.split( /\n{2,}/ )
709
815
  .map( b => b.trim() )
710
816
  .filter( Boolean )
711
817
 
818
+ const outwardIntents = partitionOutwardIntents(
819
+ output.actions, percept.speakerEntityId, speakerName,
820
+ )
821
+
712
822
  return {
713
823
  reply: bubbles.join('\n'),
714
824
  replyBubbles: bubbles,
715
825
  targetEntityId: percept.speakerEntityId,
826
+ ...( outwardIntents.length > 0 ? { outwardIntents } : {} ),
716
827
  newGoals: output.newGoals,
717
828
  goalsToAbandon: output.goalsToAbandon,
718
829
  newBeliefs: output.newBeliefs,
@@ -736,31 +847,95 @@ export class AuditionEngine extends BaseSenseEngine {
736
847
  */
737
848
  async authorOutreach( entityId: string, entityName: string, gist?: string ): Promise<string[]> {
738
849
  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 }`)
850
+
851
+ // One authoring pass per person at a time.
852
+ //
853
+ // This was unguarded, and the agency can hold more than one intent toward the
854
+ // same target at once (two undertakings, or an undertaking plus a self-initiated
855
+ // reach). Each one spawned its own transient facet, each facet independently
856
+ // composed a message, and both were delivered — the same question asked twice,
857
+ // reworded, seconds apart. The executor's idempotence was keyed by INTENT id,
858
+ // which cannot see that two intents mean one conversation.
859
+ //
860
+ // A concurrent second call returns empty rather than waiting: its intent stays
861
+ // 'awaiting' and comes back round once this pass has landed and satiation has
862
+ // had a chance to read it, which is the outcome we want anyway.
863
+ if( this._outreachInFlight.has( entityId ) ){
864
+ logger.info(`[audition-engine] already composing an outreach to ${ entityId } — not opening a second`)
742
865
  return []
743
866
  }
744
- const handle = spawned.handle
745
867
 
746
- handle.setFocus({
868
+ // Already talking to them? Then this is not a second thread — it is a thing to
869
+ // say in the one that is open, and it must be said BY that thread.
870
+ //
871
+ // A transient facet composing in parallel cannot see the live conversation: not
872
+ // the thread digest, not what was said two minutes ago, not the thinking the
873
+ // open facet has been doing about this person. So the mind asked the same
874
+ // question it had already asked, in different words, while the answer was
875
+ // sitting in a thread it was not reading. Routing through the open facet costs
876
+ // nothing extra — the focus rides on the REPORT (see FacetReport.focus), so the
877
+ // conversation's own standing focus is never touched and the next inbound turn
878
+ // resumes exactly where it was.
879
+ const openThread = this._executiveEngine.facetFor(`conversation:${ entityId }`)
880
+ const handle = openThread ?? ( () => {
881
+ // Nobody home — a transient authoring facet, deliberately NOT supervisor-keyed:
882
+ // it is exactly what the mind can most afford to evict under pressure, and a
883
+ // key would move it into the protected tier alongside live conversations.
884
+ const spawned = this._executiveEngine!.spawnFacet('outreach')
885
+ if( spawned.attention === 'full' || !spawned.handle ){
886
+ logger.warn(`[audition-engine] facet budget full — cannot author outreach to ${ entityId }`)
887
+ return undefined
888
+ }
889
+ return spawned.handle
890
+ } )()
891
+
892
+ if( !handle ) return []
893
+
894
+ if( openThread )
895
+ logger.info(`[audition-engine] composing outreach to ${ entityId } inside the open conversation (${ openThread.facetId })`)
896
+
897
+ // What we have already said to each other, when there IS an open thread. Without
898
+ // it the mind opens with a question it asked four minutes ago.
899
+ const digest = openThread ? this._digests.getDigest( this._inflightThread.get( entityId ) ?? entityId ) : ''
900
+
901
+ const outreachFocus: FocusSection = ({
747
902
  title: 'Reaching out',
748
903
  function: 'outreach',
749
904
  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.',
905
+ openThread
906
+ ? `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.`
907
+ : `I have decided, on my own initiative, to reach out to ${ entityName } (id: ${ entityId }).`,
908
+ openThread
909
+ ? 'This continues that conversation. I do not re-introduce myself and I do not ask again for something already answered above.'
910
+ : 'No one prompted this — I am choosing to make contact now.',
911
+ digest,
752
912
  gist ? `What is on my mind: ${ gist }` : '',
913
+ // The gist is what the MASTER framed, and the master was not talking to
914
+ // them — so it refers to people in the third person, including sometimes
915
+ // the very person about to read it. Observed live: "Fabrice says the
916
+ // server issue is fixed now and he should look into the logs", addressed
917
+ // TO him. The words are mine to choose; the gist is only what I mean.
918
+ 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
919
  ].filter( Boolean ).join('\n'),
754
920
  recallQuery: gist ?? entityName,
755
921
  awareness: [ ...DEFAULT_FACET_AWARENESS, 'plans' ],
756
922
  awarenessEntityId: entityId,
923
+ subjectEntityId: entityId,
924
+ subjectName: entityName,
757
925
  instructions:
758
926
  'Considering who I am, my goals, and how I feel, I say what I genuinely want to say to ' +
759
927
  'them now. I speak as myself; I stay grounded in my real memories — I do not invent experiences ' +
760
928
  'I have no record of.',
761
929
  outputFormat: CONVERSATION_OUTPUT_FORMAT,
762
930
  extractDecision: ( raw: unknown ): ConversationDecision => {
763
- const output = raw as ExecutiveOutputFull
931
+ const output = raw as ExecutiveOutputFull
932
+ // A declared silence beats anything else in the response. Unprompted
933
+ // speech is the one case where saying nothing must be cheaper than
934
+ // saying something — nobody is waiting on this.
935
+ if( output.noMessage !== undefined ){
936
+ logger.info(`[audition-engine] chose not to reach out to ${ entityName }: ${ output.noMessage.slice( 0, 120 ) }`)
937
+ return { reply: '', replyBubbles: [], targetEntityId: entityId, requiresMasterAttention: false }
938
+ }
764
939
  const rawReply = output.replyText?.trim() ?? ''
765
940
  const bubbles = rawReply.split( /\n{2,}/ ).map( b => b.trim() ).filter( Boolean )
766
941
  return { reply: bubbles.join('\n'), replyBubbles: bubbles, targetEntityId: entityId, requiresMasterAttention: false }
@@ -770,24 +945,50 @@ export class AuditionEngine extends BaseSenseEngine {
770
945
  // report() only QUEUES the facet's reasoning; the authored bubbles arrive LATER
771
946
  // via the subscription. So wait for the DECISION (not report's resolution), with
772
947
  // 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( [] )
948
+ this._outreachInFlight.add( entityId )
949
+ try {
950
+ const bubbles = await new Promise<string[]>( resolve => {
951
+ let settled = false
952
+ let unsub: () => void = () => {}
953
+ let timer: ReturnType<typeof setTimeout>
954
+ const done = ( b: string[] ): void => { if( settled ) return; settled = true; clearTimeout( timer ); unsub(); resolve( b ) }
955
+ timer = setTimeout(
956
+ () => { logger.warn(`[audition-engine] outreach authoring timed out for ${ entityId }`); done( [] ) },
957
+ 60_000, // generous: the facet LLM authors in ~8–18s
958
+ )
959
+ // ONLY this report's decision. Sharing a live conversation facet means its
960
+ // ordinary reply decisions arrive on the same subscription, and resolving
961
+ // on one of those would hand the human's reply back as if the mind had
962
+ // composed it unprompted — and deliver it twice.
963
+ unsub = handle.subscribe( d => {
964
+ if( d.respondingToType !== 'outreach') return
965
+ done( ( d.decision as ConversationDecision ).replyBubbles ?? [] )
966
+ } )
967
+ // The focus rides the REPORT, so a shared conversation facet keeps its own
968
+ // standing focus and its next inbound turn resumes untouched.
969
+ Promise.resolve( handle.report({ type: 'outreach', payload: { entityId, gist }, focus: outreachFocus }) ).catch( err => {
970
+ logger.warn(`[audition-engine] outreach report failed for ${ entityId }: ${ ( err as Error ).message }`)
971
+ done( [] )
972
+ } )
786
973
  } )
787
- } )
788
974
 
789
- handle.destroy()
790
- return bubbles
975
+ // Only tear down what we opened. Destroying a borrowed conversation facet
976
+ // would end the conversation as a side effect of speaking in it.
977
+ if( !openThread ) handle.destroy()
978
+ return bubbles
979
+ }
980
+ catch( err ){
981
+ // A facet that throws is a pass that produced no words — the same outcome as
982
+ // the timeout, and the caller's contract is already "empty means I could not
983
+ // author". Letting it escape would reject inside MotorSchemaExecutor's
984
+ // fire-and-forget authoring chain instead.
985
+ logger.warn(`[audition-engine] outreach authoring failed for ${ entityId }: ${ ( err as Error ).message }`)
986
+ return []
987
+ }
988
+ // finally{} on every path — the timeout resolves empty rather than throwing, but
989
+ // a destroy() or report() that throws must not leave this person permanently
990
+ // un-reachable by leaving the guard set.
991
+ finally { this._outreachInFlight.delete( entityId ) }
791
992
  }
792
993
 
793
994
  // ── Conversation memory (Section 5) ─────────────────────────
@@ -801,6 +1002,116 @@ export class AuditionEngine extends BaseSenseEngine {
801
1002
  * (Section 1.2) routes ingest through the tick loop. The entity carries no
802
1003
  * wall-clock timestamp — `setEntity` stamps createdAt/tick from the sim clock.
803
1004
  */
1005
+ /**
1006
+ * The inbound as a social signal in state — mirror of `conversation.sent`.
1007
+ *
1008
+ * Shaped for `SocialPerception._scanSocialSignals`, which reads `sourceKeid` for
1009
+ * who acted and `directedAtSelf` for whether it was aimed at us. Valence is left
1010
+ * UNSET on purpose: the words have not been appraised yet, and guessing a number
1011
+ * here would feed reputation and affect a sentiment nobody measured. Absent, the
1012
+ * scanner falls back to its neutral default, so the Will learns *that* someone
1013
+ * engaged (familiarity, recency, reliability) without inventing how it felt.
1014
+ */
1015
+ /**
1016
+ * A durable, deterministic id for a conversation record.
1017
+ *
1018
+ * `<prefix>-<entity>-<tick>-<hash of the words>`. Every part earns its place:
1019
+ * • entity — whose conversation this is;
1020
+ * • tick — WHEN, from the sim clock, which resumes from the snapshot and so
1021
+ * keeps rising across restarts;
1022
+ * • hash — which utterance, so two things said to one person on one tick stay
1023
+ * two records.
1024
+ *
1025
+ * What it replaces was `<prefix>-<entity>-<N>` with N a process-local counter.
1026
+ * It restarted at 1 on every boot, so each session OVERWROTE the previous
1027
+ * session's records of the same person — a mind that had spoken with someone
1028
+ * across four restarts held one session's worth of evidence that it ever had.
1029
+ * Found by diffing a live snapshot against the Discord transcript it came from:
1030
+ * `conv-sent-reply-discord:1019…-1` held that morning's greeting, and every
1031
+ * earlier conversation keyed to the same id was simply gone.
1032
+ *
1033
+ * No wallClock: these ids live in state, and a wall-clock id makes the recorded
1034
+ * and replayed runs diverge (R2).
1035
+ */
1036
+ private _sentKey( prefix: string, entityId: string, words: string ): string {
1037
+ return `${ prefix }-${ entityId }-${ this._lastDecisionTick }-${ fnv1a( words ) }`
1038
+ }
1039
+
1040
+ private _writeReceived( entityId: string, speakerName: string | undefined, content: string, threadId: string ): void {
1041
+ if( !this._memorySink ) return
1042
+ // Never wallClock(): this entity LIVES IN STATE, so a wall-clock id makes the
1043
+ // recorded and replayed runs diverge (R2). Observed as a replay consuming 17 of
1044
+ // 18 recorded completions — different ids meant different percepts meant a
1045
+ // different executive firing schedule.
1046
+ //
1047
+ // But nor a process-local counter, which is what this was. `conv-received-<id>-N`
1048
+ // restarted at N=1 on every boot, so each session silently OVERWROTE the last
1049
+ // session's records of the same person. A mind that had spoken with someone
1050
+ // across four restarts held one session's worth of evidence that it ever had.
1051
+ // See _sentKey.
1052
+ this._memorySink({
1053
+ id: this._sentKey('conv-received', entityId, content ),
1054
+ type: 'conversation.received',
1055
+ metadata: {
1056
+ sourceKeid: entityId,
1057
+ sourceName: speakerName,
1058
+ directedAtSelf: true, // an inbound turn is addressed to us by definition
1059
+ action: 'communication',
1060
+ preview: content.slice( 0, 140 ),
1061
+ chars: content.length,
1062
+ ...( threadId ? { threadId } : {} ),
1063
+ },
1064
+ })
1065
+ }
1066
+
1067
+ /**
1068
+ * Record that the mind SPOKE to someone, mirroring `_writeReceived`.
1069
+ *
1070
+ * Only ProactiveCommunicator wrote `conversation.sent`, so a reply — which is
1071
+ * most of what a Will says — left no durable trace of having spoken. Everything
1072
+ * that asks "have I already said something to them?" was therefore blind to
1073
+ * conversation: satiation could not damp repeating a relay delivered as a reply,
1074
+ * and an undertaking discharged inside a conversation stayed forever unkept,
1075
+ * which is exactly how the same message went out again and again.
1076
+ *
1077
+ * Speaking is speaking, whichever path carried it.
1078
+ */
1079
+ private _writeSent(
1080
+ entityId: string,
1081
+ entityName: string | undefined,
1082
+ bubbles: string[],
1083
+ /**
1084
+ * Outbox ids for these bubbles — the ONLY thing that lets a later delivery
1085
+ * ack find this record (`OutboxController.confirmDelivery` correlates on
1086
+ * `outboxMessageIds`, there is no other key).
1087
+ *
1088
+ * Omitted, this record could never be marked delivered. Every reply the mind
1089
+ * ever made carried `delivered` unset, forever — so a mind asking itself "did
1090
+ * that land?" found no answer for anything it had SAID, while the answer was
1091
+ * recorded faithfully for everything it had initiated. Silence read exactly
1092
+ * like failure, and it re-sent. The proactive path stored these from the
1093
+ * start; the reply path was simply never given them.
1094
+ */
1095
+ outboxMessageIds?: string[],
1096
+ ): void {
1097
+ if( !this._memorySink || bubbles.length === 0 ) return
1098
+ this._memorySink({
1099
+ id: this._sentKey('conv-sent-reply', entityId, bubbles.join('\n') ),
1100
+ type: 'conversation.sent',
1101
+ metadata: {
1102
+ targetEntityId: entityId,
1103
+ targetEntityName: entityName,
1104
+ messageCount: bubbles.length,
1105
+ preview: bubbles[0]?.slice( 0, 100 ) ?? '',
1106
+ effectorName: 'text',
1107
+ source: 'audition-facet',
1108
+ tick: this._lastDecisionTick,
1109
+ delivered: false,
1110
+ ...( outboxMessageIds?.length ? { outboxMessageIds } : {} ),
1111
+ },
1112
+ })
1113
+ }
1114
+
804
1115
  private _persistExchangeMemory( entityId: string, threadId: string, reply: string, confidence: number, entityName?: string ): void {
805
1116
  const inbound = this._inflightInbound.get( entityId ) ?? ''
806
1117
  this._inflightInbound.delete( entityId )
@@ -832,6 +1143,19 @@ export class AuditionEngine extends BaseSenseEngine {
832
1143
  // facet's spawn-time thread — correct for an entity that spans threads (§2).
833
1144
  const threadId = this._inflightThread.get( entityId ) ?? ''
834
1145
 
1146
+ // The sim tick this was reasoned at — the only deterministic clock an off-tick
1147
+ // engine has, and what the conversation-record ids are keyed on.
1148
+ this._lastDecisionTick = decision.tick ?? this._lastDecisionTick
1149
+
1150
+ // An outreach composed INSIDE this conversation (authorOutreach borrowing the
1151
+ // open facet) lands here too, because the session subscription is facet-wide.
1152
+ // Its words belong to `authorOutreach`, which returns them to the agency for
1153
+ // delivery through the proactive path; delivering them here as well would send
1154
+ // the same message twice and answer a turn nobody took. `_endTurn` is not
1155
+ // called either — an outreach is not a turn, and releasing the queue here would
1156
+ // let the next inbound start while a real turn was still in flight.
1157
+ if( decision.respondingToType === 'outreach') return
1158
+
835
1159
  // finally{} releases the entity's serial turn queue on EVERY exit path —
836
1160
  // reply delivered, reply suppressed, or escalation only. Without this a
837
1161
  // suppressed-reply early-return would stall the queue until the safety timeout.
@@ -875,6 +1199,8 @@ export class AuditionEngine extends BaseSenseEngine {
875
1199
  pushToOutbox: !viaTransport,
876
1200
  })
877
1201
 
1202
+ this._writeSent( entityId, d.targetEntityId, d.replyBubbles, ids )
1203
+
878
1204
  if( viaTransport )
879
1205
  logger.info(
880
1206
  `[audition-engine] Reply emitted via transport for ${entityId} ` +
@@ -892,22 +1218,54 @@ export class AuditionEngine extends BaseSenseEngine {
892
1218
  )
893
1219
  }
894
1220
 
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',
1221
+ // ── Outward intentions ────────────────────────────────────
1222
+ // The facet decided, mid-conversation, to say something to someone ELSE.
1223
+ // It does not do that itself (see ConversationDecision.outwardIntents) it
1224
+ // hands the intention to the master, which is singular and owns whom the
1225
+ // mind contacts. The master perceives it as an undertaking it made and
1226
+ // decides whether it still means it; nothing here forces the contact.
1227
+ // `executive.facet.handoff` is the ONE channel every facet type uses to hand
1228
+ // the master something. It replaced `audition.task.signal`, which was named
1229
+ // and typed for this engine alone — see EscalationBuffer.
1230
+ const handoff = ( body: HandoffBody ): void => {
1231
+ this._bus?.publish({
1232
+ type: 'executive.facet.handoff',
901
1233
  version: 1,
902
1234
  sourceEngine: this.name,
903
1235
  salience: 0.9,
904
1236
  payload: {
905
- entityId,
1237
+ facetId: decision.facetId,
1238
+ subjectEntityId: entityId,
1239
+ ...( d.targetEntityId ? { subjectName: d.targetEntityId } : {} ),
906
1240
  threadId,
907
- reasoning: decision.reasoning,
908
- confidence: decision.confidence
1241
+ confidence: decision.confidence,
1242
+ // No `tick` — this engine runs off-tick and has no honest sim clock of
1243
+ // its own. The master stamps it from the tick the handoff ARRIVES on,
1244
+ // which is within one tick of when it was formed and, crucially, is a
1245
+ // real clock reading rather than the last time the master happened to run.
1246
+ body,
909
1247
  }
910
1248
  })
1249
+ }
1250
+
1251
+ if( d.outwardIntents?.length )
1252
+ for( const intent of d.outwardIntents ){
1253
+ logger.info(
1254
+ `[audition-engine] Outward intent from ${entityId}'s facet → ${intent.target} ` +
1255
+ `(handing to master; the facet does not open that channel itself)`
1256
+ )
1257
+ handoff({
1258
+ kind: 'undertaking',
1259
+ target: intent.target,
1260
+ reasoning: intent.reasoning ?? '',
1261
+ ...( intent.gist ? { gist: intent.gist } : {} ),
1262
+ })
1263
+ }
1264
+
1265
+ // ── Master escalation signal ──────────────────────────────
1266
+ // Published when the facet emits an 'escalate' action type.
1267
+ if( d.requiresMasterAttention )
1268
+ handoff({ kind: 'escalation', reasoning: decision.reasoning })
911
1269
  }
912
1270
  finally {
913
1271
  // Release the entity's turn queue so the next message can be processed.
@@ -20,7 +20,7 @@
20
20
  * domains (see §6 cross-modal note).
21
21
  *
22
22
  * - `AuditionEngine` extends this and overrides `publishes()`/`snapshot()` for its
23
- * extra `audition.task.signal` schema and session snapshot.
23
+ * extra `executive.facet.handoff` schema and session snapshot.
24
24
  * - The four shell engines extend `ShellSenseEngine` (below) and are ~6 lines each.
25
25
  */
26
26