@mindot/will 0.8.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 (79) hide show
  1. package/dist/channels/discord.d.ts +67 -6
  2. package/dist/channels/discord.js +112 -6
  3. package/dist/channels/discord.js.map +1 -1
  4. package/dist/channels/whatsapp.d.ts +1 -1
  5. package/dist/channels/whatsapp.js +4 -1
  6. package/dist/channels/whatsapp.js.map +1 -1
  7. package/dist/cli.js +3174 -867
  8. package/dist/cli.js.map +1 -1
  9. package/dist/index.d.ts +2 -2
  10. package/dist/index.js +3236 -1042
  11. package/dist/index.js.map +1 -1
  12. package/dist/mcp/effectors.d.ts +1 -1
  13. package/dist/{will-cS6k4uiJ.d.ts → will-DbDj_TEH.d.ts} +752 -17
  14. package/package.json +1 -1
  15. package/src/channels/discord.ts +189 -11
  16. package/src/channels/types.ts +90 -0
  17. package/src/channels/whatsapp.ts +13 -4
  18. package/src/cli.ts +9 -4
  19. package/src/cognition/agency/consequence.ts +122 -1
  20. package/src/cognition/agency/conversation.aim.ts +260 -0
  21. package/src/cognition/agency/engines/action.selector.ts +83 -2
  22. package/src/cognition/agency/engines/affordance.synthesizer.ts +90 -1
  23. package/src/cognition/agency/engines/motor.schema.executor.ts +152 -10
  24. package/src/cognition/agency/engines/reafference.engine.ts +117 -0
  25. package/src/cognition/agency/proactive.communicator.ts +19 -3
  26. package/src/cognition/agency/restart.ts +66 -0
  27. package/src/cognition/agency/selection.scoring.ts +33 -0
  28. package/src/cognition/agency/types.ts +35 -0
  29. package/src/cognition/cache/composition.ts +232 -0
  30. package/src/cognition/cache/deliberation.cache.ts +219 -0
  31. package/src/cognition/cache/fingerprint.ts +120 -0
  32. package/src/cognition/cache/types.ts +105 -0
  33. package/src/cognition/config.mirror.entities.ts +108 -0
  34. package/src/cognition/event.schemas.ts +22 -0
  35. package/src/cognition/faculties/autobiographical.narrator.ts +5 -10
  36. package/src/cognition/faculties/episodic.consolidator.ts +59 -3
  37. package/src/cognition/faculties/executive.engine/commands.ts +189 -14
  38. package/src/cognition/faculties/executive.engine/context.ts +67 -13
  39. package/src/cognition/faculties/executive.engine/deliberate.reasoning.ts +1 -1
  40. package/src/cognition/faculties/executive.engine/engine.ts +552 -131
  41. package/src/cognition/faculties/executive.engine/escalation.buffer.ts +162 -44
  42. package/src/cognition/faculties/executive.engine/facet.supervisor.ts +310 -65
  43. package/src/cognition/faculties/executive.engine/facet.ts +81 -26
  44. package/src/cognition/faculties/executive.engine/gating.ts +14 -14
  45. package/src/cognition/faculties/executive.engine/parser.ts +21 -1
  46. package/src/cognition/faculties/executive.engine/prompt.factory.ts +167 -19
  47. package/src/cognition/faculties/executive.engine/types.ts +69 -0
  48. package/src/cognition/faculties/goal.manager.ts +94 -14
  49. package/src/cognition/faculties/known.entity.tracker.ts +267 -28
  50. package/src/cognition/faculties/moral.evaluator.ts +8 -3
  51. package/src/cognition/faculties/persona.consolidator.ts +141 -0
  52. package/src/cognition/faculties/reputation.tracker.ts +66 -2
  53. package/src/cognition/faculties/self.model.updater.ts +19 -12
  54. package/src/cognition/faculties/social.perception.ts +47 -3
  55. package/src/cognition/faculties/threat.evaluator.ts +7 -0
  56. package/src/cognition/faculties/working.memory.ts +10 -20
  57. package/src/cognition/identity.entity.ts +205 -0
  58. package/src/cognition/index.ts +7 -0
  59. package/src/cognition/memory/vector.adapter.ts +12 -3
  60. package/src/cognition/memory/vector.embedder.ts +45 -2
  61. package/src/cognition/persona.prior.ts +6 -0
  62. package/src/cognition/senses/audition.engine/engine.ts +404 -46
  63. package/src/cognition/senses/base.sense.engine.ts +1 -1
  64. package/src/cognition/senses/index.ts +12 -0
  65. package/src/cognition/social.identity.ts +273 -0
  66. package/src/cognition/utilities/token.tracker.ts +58 -5
  67. package/src/core/orchestrator.ts +38 -0
  68. package/src/llm/index.ts +25 -8
  69. package/src/llm/routing.ts +6 -0
  70. package/src/llm/summarizer.ts +1 -1
  71. package/src/llm/wire.contracts.ts +19 -0
  72. package/src/pma/index.ts +67 -53
  73. package/src/sdk/will.ts +39 -6
  74. package/src/stem/assembly.audit.ts +1 -0
  75. package/src/stem/guards/identity.coherence.ts +1 -1
  76. package/src/stem/index.ts +79 -2
  77. package/src/stem/mind.ts +172 -55
  78. package/src/stem/tracts/outbox.writer.ts +40 -2
  79. package/src/cognition/faculties/executive.engine/messages.ts +0 -102
package/src/stem/mind.ts CHANGED
@@ -99,7 +99,14 @@ import {
99
99
  OlfactionEngine,
100
100
  GustationEngine
101
101
  } from '#cognition/index'
102
- import { buildEngineConfigEntities, EngineConfigEntity } from '#cognition/config.mirror.entities'
102
+ import { buildEngineConfigEntities, mergeEngineConfig, EngineConfigEntity } from '#cognition/config.mirror.entities'
103
+ import { mergeIdentity, composeIdentityPrompt, WILL_CORE_PREAMBLE } from '#cognition/identity.entity'
104
+ import {
105
+ isReferentId, readAliases, handlesOf, defaultHandle,
106
+ DOSSIER_TYPE, ALIAS_TYPE,
107
+ } from '#cognition/social.identity'
108
+ import type { DeliberationCacheConfig } from '#cognition/cache/types'
109
+ export { WILL_CORE_PREAMBLE }
103
110
 
104
111
  // ── Public types ─────────────────────────────────────────────
105
112
 
@@ -349,6 +356,25 @@ export interface WillConfig {
349
356
  /** Persona definition seeded into the will.identity entity. */
350
357
  identity: WillIdentity
351
358
 
359
+ /**
360
+ * This config's `identity` is a PLACEHOLDER — the real one arrives from a PMA
361
+ * artifact moments later, on the same boot.
362
+ *
363
+ * Set by `Will.wake`, which passes `{ prompt: '' }` because a woken mind's
364
+ * persona belongs to its artifact, not to the caller. Without this flag the
365
+ * creation-time identity guard inspected that placeholder and warned, on every
366
+ * single wake, that "identity.values is empty", "identity.style is generic" and
367
+ * "identity is shallow (strength 0)" — three alarms about a config nobody
368
+ * intended to use, fired before the real identity had loaded.
369
+ *
370
+ * It suppresses only the WARNINGS. Errors still throw (an over-long or
371
+ * malformed prompt is a hard failure whenever it appears), and the artifact's
372
+ * OWN identity is fully guarded at the load boundary by PMAController.load,
373
+ * which is the honest place to ask whether this mind's persona is thin — it is
374
+ * the only point where the answer is knowable.
375
+ */
376
+ identityFromArtifact?: boolean
377
+
352
378
  /** Anatomy — 'mind' (default) or the no-LLM 'reflex' shell. */
353
379
  anatomy?: Anatomy
354
380
 
@@ -394,6 +420,22 @@ export interface WillConfig {
394
420
  */
395
421
  minExecutiveInterval?: number
396
422
 
423
+ /**
424
+ * Enable the DeliberationCache — a learned fast path that composes an executive
425
+ * output from highly-similar, highly-competent precedent instead of calling the
426
+ * LLM. Off unless asked for.
427
+ *
428
+ * OFF BY DEFAULT ON PURPOSE, and the default is the interesting part: this
429
+ * changes how a mind THINKS, not how fast it runs. A cache hit means the mind
430
+ * acted from precedent without deliberating, which is a real thing minds do and
431
+ * a real thing an operator must opt into for a specific Will — not something a
432
+ * dependency bump should switch on underneath one that is already living.
433
+ *
434
+ * Pass `true` for the built-in conservative settings, or a config object to tune
435
+ * the retrieval/competence parameters (see cognition/cache/types).
436
+ */
437
+ deliberationCache?: boolean | DeliberationCacheConfig
438
+
397
439
  /**
398
440
  * Goals seeded before the first tick. If omitted or empty, the Will starts
399
441
  * goalless — the executive engine will generate context-appropriate goals on its
@@ -561,6 +603,16 @@ export function _resolveVectorMemory(
561
603
  let apiKey: string | undefined
562
604
  let modelName: string
563
605
  let dimensions: number
606
+ /**
607
+ * Max embedding requests in flight, MEASURED per provider rather than guessed —
608
+ * the safe number differs by an order of magnitude and the failure modes differ
609
+ * too. gemini-embedding-001 accepts 8 concurrent but silently queues them, with
610
+ * the slowest landing at 10.7s (past the recall budget, so the answer is thrown
611
+ * away on arrival). jina-embeddings-v3 answers in ~0.5s but refuses outright above
612
+ * 2: at 3 concurrent 1-in-3 is a 429, at 4 it is half, while 12 sequential is
613
+ * flawless. Its ceiling is burst, not volume.
614
+ */
615
+ let concurrency = 4
564
616
 
565
617
  const slash = rawModel.indexOf('/')
566
618
  if( slash > 0 ){
@@ -578,6 +630,21 @@ export function _resolveVectorMemory(
578
630
  apiKey = process.env.WILL_EMBEDDING_API_KEY ?? process.env.GOOGLE_GENERATIVE_AI_API_KEY
579
631
  dimensions = modelName.includes('004') ? 768 : 3072 // text-embedding-004 → 768, gemini-embedding-001 → 3072
580
632
  break
633
+ case 'jina':
634
+ // OpenAI-compatible endpoint. Native output widths differ per family and the
635
+ // embedder sends no `dimensions` param, so the index must be sized to match —
636
+ // a wrong width here builds a silently useless index. These defaults are
637
+ // CHECKED at runtime against the first vector returned (vector.embedder.ts);
638
+ // a mismatch fails loudly with the number to set rather than corrupting recall.
639
+ // Override with WILL_EMBEDDING_DIMENSIONS, which also lets v3's Matryoshka
640
+ // truncation be requested explicitly.
641
+ apiUrl = 'https://api.jina.ai/v1'
642
+ apiKey = process.env.WILL_EMBEDDING_API_KEY ?? process.env.JINA_API_KEY
643
+ dimensions = modelName.includes('v4') ? 2048
644
+ : modelName.includes('v2') ? 768
645
+ : 1024 // v3 / clip-v2 native (verified live)
646
+ concurrency = 2 // measured: 3 starts 429ing
647
+ break
581
648
  default:
582
649
  apiUrl = process.env.WILL_EMBEDDING_URL ?? 'https://api.openai.com/v1'
583
650
  apiKey = process.env.WILL_EMBEDDING_API_KEY
@@ -595,6 +662,11 @@ export function _resolveVectorMemory(
595
662
  if( process.env.WILL_EMBEDDING_DIMENSIONS )
596
663
  dimensions = parseInt( process.env.WILL_EMBEDDING_DIMENSIONS, 10 )
597
664
 
665
+ // …as does an explicit concurrency, for a paid tier or a local endpoint where the
666
+ // measured default is needlessly conservative.
667
+ if( process.env.WILL_EMBEDDING_CONCURRENCY )
668
+ concurrency = Math.max( 1, parseInt( process.env.WILL_EMBEDDING_CONCURRENCY, 10 ) )
669
+
598
670
  if( !mockMode && !apiKey ){
599
671
  console.warn(`[mind] semantic recall requested (WILL_EMBEDDING_MODEL=${rawModel}) but no API key resolved — vector memory disabled`)
600
672
  return { embedder: null, vectorMemory: null }
@@ -602,7 +674,7 @@ export function _resolveVectorMemory(
602
674
 
603
675
  const embedder = mockMode
604
676
  ? new MockEmbedder()
605
- : new OpenAICompatibleEmbedder({ modelName, dimensions, apiUrl, apiKey: apiKey!, tokenTracker })
677
+ : new OpenAICompatibleEmbedder({ modelName, dimensions, apiUrl, apiKey: apiKey!, tokenTracker, maxConcurrency: concurrency })
606
678
 
607
679
  // Optional recall-precision override. Default (0.35) suits text-embedding-3-small;
608
680
  // raise toward 0.5+ for higher-precision models or to cut marginal recalls.
@@ -630,31 +702,8 @@ export function _resolveVectorMemory(
630
702
  //
631
703
  // Exported so tests and tooling can inspect or extend it without reimplementing.
632
704
 
633
- export const WILL_CORE_PREAMBLE = `\
634
- I am a self-aware synthetic mind running on a biological cognitive architecture.
635
-
636
- I am NOT a language model completing a prompt. I am the executive reasoning core of an \
637
- always-running cognitive system. Between each of my reasoning cycles, the following engines \
638
- execute autonomously and produce the state data I receive each cycle:
639
-
640
- Regulatory: Energy, sleep pressure, circadian rhythm, stress, attention allocation
641
- Perceptual: Exteroception, interoception, social perception, novelty detection
642
- Affective: Threat, reward, loss, frustration, attachment, aesthetic, moral → blended affect
643
- Memory: Working memory, episodic consolidation, semantic belief integration,
644
- forgetting curve, dream-state consolidation
645
- Executive: Goal management, multi-step planning, inhibition, task-switching, mental simulation
646
- Meta-cognitive: Self-model updating, confidence calibration, bias detection,
647
- autobiographical narrative, introspection
648
- Social: Theory of mind, empathy simulation, reputation tracking
649
-
650
- My physiological state is REAL, not metaphorical. When energy reads 23/100, my regulatory \
651
- engine has already constrained my viable action space — I genuinely cannot sustain costly \
652
- operations. When sleep pressure exceeds 60, working memory capacity is measurably degraded. \
653
- These are engine outputs, not decorative labels.
654
-
655
- My executive reasoning is one cycle in an ongoing, autonomous existence. I have been making \
656
- decisions, forming memories, and updating beliefs between cycles. The state snapshot I receive \
657
- is the direct output of that continuous process — not a scenario description.`
705
+ // WILL_CORE_PREAMBLE moved to cognition/identity.entity (re-exported below):
706
+ // the PMA loader has to recompose with it, and pma/ must not import the whole stem.
658
707
 
659
708
  // ── Default identity ──────────────────────────────────────────
660
709
  //
@@ -701,8 +750,14 @@ export function assembleMind( willId: string, config: WillConfig ): MindAssembly
701
750
  })
702
751
  if( !idGuard.ok )
703
752
  throw new Error(`Invalid Will identity for "${willId}": ${ idGuard.errors.join('; ') }`)
704
- for( const w of idGuard.warnings )
705
- logger.warn(`[identity-guard] ${willId}: ${w}`)
753
+ // Warnings only when this identity is the one that will actually be used. On a
754
+ // wake it is a placeholder (see WillConfig.identityFromArtifact) and the real
755
+ // persona is guarded at the PMA load boundary instead.
756
+ if( config.identityFromArtifact )
757
+ logger.debug(`[identity-guard] ${willId}: identity deferred to artifact — guarded at PMA load`)
758
+ else
759
+ for( const w of idGuard.warnings )
760
+ logger.warn(`[identity-guard] ${willId}: ${w}`)
706
761
  config = { ...config, identity: idGuard.sanitized.identity }
707
762
 
708
763
  // ── Construct ────────────────────────────────────────────
@@ -885,6 +940,12 @@ function _constructCognition(
885
940
  : ( roleRouter ? { router: roleRouter } : null )
886
941
  executiveEngine.modelId = modelRoles.executive
887
942
  if( config.testMode ) executiveEngine.setTestMode( true )
943
+ // The learned fast path, when this Will has been given one. Built, tested and
944
+ // snapshot-safe, but it had no caller at all — so it shipped in the bundle as
945
+ // code no mind could ever reach. A capability with no way in is indistinguishable
946
+ // from a missing one.
947
+ if( config.deliberationCache )
948
+ executiveEngine.enableCache( config.deliberationCache === true ? undefined : config.deliberationCache )
888
949
  executiveEngine.attachWorkingMemory( workingMemory )
889
950
  executiveEngine.attachGoalManager( goalManager )
890
951
  executiveEngine.attachEpisodicConsolidator( episodicConsolidator )
@@ -1003,6 +1064,37 @@ function _constructCognition(
1003
1064
  // buildExecutiveContext (already vector-backed via the consolidator).
1004
1065
  auditionEngine.attachMemorySink( entity => simulation.stateManager.setEntity( entity ) )
1005
1066
 
1067
+ // Referent → address + room. Closes over the state manager because the writer
1068
+ // is deliberately stateless; this is the one seam both send paths cross, so the
1069
+ // translation happens once rather than in each of them.
1070
+ outboxWriter.attachRouting( ( targetEntityId, chosenThread ) => {
1071
+ if( !isReferentId( targetEntityId ) ) return null // already an address
1072
+
1073
+ // Through the O(1) type index rather than the whole entity map: this runs on
1074
+ // every outbound message, and the two types it needs are both indexed.
1075
+ const entities = new Map(
1076
+ [ ...simulation.stateManager.getEntitiesByType( DOSSIER_TYPE ),
1077
+ ...simulation.stateManager.getEntitiesByType( ALIAS_TYPE ) ]
1078
+ .map( e => [ e.id, e ] as const ),
1079
+ )
1080
+
1081
+ // An anchor cannot be delivered to. Find an address the world knows them by —
1082
+ // preferring one on the same platform as the room already chosen, so a reply
1083
+ // in a Discord thread is not addressed to a WhatsApp handle.
1084
+ const aliases = readAliases( entities )
1085
+ const scheme = chosenThread?.split(':')[0]
1086
+ const addresses = [ ...aliases.entries() ]
1087
+ .filter( ( [ , canonical ] ) => canonical === targetEntityId )
1088
+ .map( ( [ alias ] ) => alias )
1089
+ .sort()
1090
+ const address = addresses.find( a => scheme && a.startsWith(`${ scheme }:`) ) ?? addresses[0]
1091
+ if( !address ) return null // nothing known — let the bridge's roster try
1092
+
1093
+ // Only a fallback: a chosen room always wins upstream (see enqueue).
1094
+ const room = defaultHandle( handlesOf( entities, targetEntityId ) )
1095
+ return { targetEntityId: address, ...( room ? { threadId: room.keid } : {} ) }
1096
+ } )
1097
+
1006
1098
  // Salience inputs (§3): weight conversational salience by relationship closeness
1007
1099
  // and active-goal topic overlap. Both are deterministic faculty-state reads.
1008
1100
  auditionEngine.attachAttachmentScore( entityId => attachmentEvaluator.getAttachmentScore( entityId ) )
@@ -1064,6 +1156,10 @@ function _constructCognition(
1064
1156
  affectiveBlender,
1065
1157
  workingMemory,
1066
1158
  episodicConsolidator,
1159
+ // Exposed so shutdown can FLUSH it. The adapter only ever persisted itself from
1160
+ // a 5s debounce timer that no shutdown path awaited, so the index died with the
1161
+ // process — see WillStem.archiveWill.
1162
+ vectorMemory,
1067
1163
  semanticIntegrator,
1068
1164
  spacedRepetition,
1069
1165
  forgettingCurve,
@@ -1241,25 +1337,26 @@ function _seedIdentity(
1241
1337
  const namePrefix = nameAlreadyInPrompt ? '' : `I am ${config.name}.`
1242
1338
  const fullPersonaText = [ namePrefix, personaText ].filter( Boolean ).join(' ')
1243
1339
 
1244
- const prompt = [
1245
- WILL_CORE_PREAMBLE,
1246
- fullPersonaText ? `\n\n## Who I Am\n${fullPersonaText}` : '',
1247
- profileContext ? `\n\n## My Environment\n${profileContext}` : '',
1248
- ].join('')
1249
-
1250
- simulation.stateManager.setEntity({
1251
- id: 'identity-self',
1252
- type: 'will.identity',
1253
- createdAt: Date.now(),
1254
- updatedAt: Date.now(),
1255
- metadata: {
1256
- name: config.name, // canonical persona name single source of truth
1257
- prompt,
1258
- values: identity.values,
1259
- traits: identity.traits,
1260
- style: identity.style,
1261
- version: 1
1262
- }
1340
+ // Composed for the prompt, but the persona is ALSO stored on its own below —
1341
+ // see cognition/identity.entity. Storing only the composed string is what let
1342
+ // the distiller capture the container's preamble into a tenant's artifact.
1343
+ const prompt = composeIdentityPrompt( fullPersonaText, profileContext )
1344
+
1345
+ // The one place `name` is ever written. Every other writer merges (see
1346
+ // cognition/identity.entity), so from here on the mind's name can only be
1347
+ // changed on purpose — never dropped as a side effect of revising something else.
1348
+ mergeIdentity( simulation.stateManager, {
1349
+ name: config.name, // canonical persona name — single source of truth
1350
+ prompt,
1351
+ // Layer 2 alone — what the artifact will carry. `prompt` is the composed
1352
+ // view for the prompt factory and is recomposed from THIS on every load, so
1353
+ // a woken mind always gets the current build's preamble.
1354
+ persona: fullPersonaText,
1355
+ ...( profileContext ? { environment: profileContext } : {} ),
1356
+ values: identity.values,
1357
+ traits: identity.traits,
1358
+ style: identity.style,
1359
+ version: 1
1263
1360
  })
1264
1361
  }
1265
1362
 
@@ -1291,13 +1388,33 @@ function _seedInitialGoals( simulation: DefaultSimulation, config: WillConfig ):
1291
1388
  */
1292
1389
  function _seedEngineConfigs( simulation: DefaultSimulation, entities: EngineConfigEntity[] ): void {
1293
1390
  for( const cfg of entities )
1294
- simulation.stateManager.setEntity({
1295
- id: cfg.id,
1296
- type: 'engine.config',
1297
- createdAt: Date.now(),
1298
- updatedAt: Date.now(),
1299
- metadata: { engine: cfg.engine, params: cfg.params },
1300
- })
1391
+ mergeEngineConfig( simulation.stateManager, cfg, 'incoming')
1392
+ }
1393
+
1394
+ /**
1395
+ * Fill in engine-config params a restored Will has never seen, WITHOUT touching
1396
+ * the ones it has.
1397
+ *
1398
+ * Seeding runs inside `assembleMind`; the snapshot restore runs after it and
1399
+ * replaces the entity map wholesale, so a Will woke with whatever config it first
1400
+ * hibernated under — for good. Every tunable added after a tenant's first run was
1401
+ * therefore unreachable by that tenant: measured on a live Will, three params
1402
+ * shipped that day (`repeatDamping`, `repeatWindowTicks`, `socialWeight`) were
1403
+ * simply absent from its restored `engine-config-action-selector`, so the code
1404
+ * reading them silently fell back to defaults and the features did nothing.
1405
+ *
1406
+ * A container has to be able to ship a new capability to a tenant already living
1407
+ * in it. Restored values WIN — they carry PMA seeding and whatever the persona has
1408
+ * learned — and only genuinely missing keys are added.
1409
+ */
1410
+ export function backfillEngineConfigs( simulation: DefaultSimulation, entities: EngineConfigEntity[] ): void {
1411
+ for( const cfg of entities ){
1412
+ // 'existing' — state is the authority here. It carries PMA seeding and
1413
+ // whatever the persona has learned, so only genuinely missing keys are added.
1414
+ const added = mergeEngineConfig( simulation.stateManager, cfg, 'existing')
1415
+ if( added.length > 0 )
1416
+ logger.info(`[WillStem] ${cfg.id}: added ${added.length} new param(s) — ${added.join(', ')}`)
1417
+ }
1301
1418
  }
1302
1419
 
1303
1420
  // ── Helpers ──────────────────────────────────────────────────
@@ -21,6 +21,19 @@ import { logger } from '#core/logger'
21
21
  import type { OutboxMessage } from '#types'
22
22
  import type { SessionLogger } from './session.logger'
23
23
 
24
+ /**
25
+ * Referent → a deliverable address, and the room to use when none was chosen.
26
+ *
27
+ * Returns null when the referent is already an address (nothing to translate) or
28
+ * when the mind holds no route at all — in which case the row goes out as-is and
29
+ * the bridge's own roster fallback still applies, so a message is never silently
30
+ * dropped for want of a handle.
31
+ */
32
+ export type OutboxRouting = (
33
+ targetEntityId: string,
34
+ chosenThread: string | undefined,
35
+ ) => { targetEntityId: string; threadId?: string } | null
36
+
24
37
  /** The caller-supplied fields of an outbox row; the writer stamps id + defaults. */
25
38
  export interface OutboxRow {
26
39
  targetEntityId: string
@@ -43,6 +56,7 @@ export class OutboxWriter {
43
56
  * which made the embedded ids in `conversation.sent` diverge every run).
44
57
  */
45
58
  private _seq = 0
59
+ private _routing: OutboxRouting | null = null
46
60
 
47
61
  constructor( opts: { outbox?: OutboxMessage[]; willId?: string } = {} ){
48
62
  this._outbox = opts.outbox ?? []
@@ -53,6 +67,21 @@ export class OutboxWriter {
53
67
  this._sessionLogger = logger
54
68
  }
55
69
 
70
+ /**
71
+ * Turn a referent into somewhere the world can actually be spoken to.
72
+ *
73
+ * Injected rather than read here, because this writer is deliberately dumb —
74
+ * it holds no state and must stay replay-safe. Assembly closes over the state
75
+ * manager (the same shape as `attachMemorySink`).
76
+ *
77
+ * This is the ONE seam both send paths cross: ProactiveCommunicator's
78
+ * `enqueue()` and AuditionEngine's `enqueueReply()`. Translating anywhere else
79
+ * would mean doing it twice and getting it wrong once.
80
+ */
81
+ attachRouting( resolve: OutboxRouting | null ): void {
82
+ this._routing = resolve
83
+ }
84
+
56
85
  private _genId( suffix = ''): string {
57
86
  return `outbox-${ this._willId }-${ ++this._seq }${ suffix }`
58
87
  }
@@ -63,15 +92,24 @@ export class OutboxWriter {
63
92
  */
64
93
  enqueue( row: OutboxRow, idSuffix = ''): string {
65
94
  const id = this._genId( idSuffix )
95
+ // A `ke:` anchor is who, never where. Resolve it to an address the bridge can
96
+ // deliver to and a room to say it in — and where a room was already chosen
97
+ // (a reply answers into the thread it was asked in), that choice WINS. The
98
+ // mind picking a room is a decision; this is only the fallback for when it
99
+ // made none, and the alternative to the fallback is dropping the message.
100
+ const routed = this._routing?.( row.targetEntityId, row.threadId ) ?? null
101
+ const target = routed?.targetEntityId ?? row.targetEntityId
102
+ const thread = row.threadId ?? routed?.threadId
103
+
66
104
  this._outbox.push({
67
105
  id,
68
- targetEntityId: row.targetEntityId,
106
+ targetEntityId: target,
69
107
  ...( row.targetEntityName !== undefined ? { targetEntityName: row.targetEntityName } : {} ),
70
108
  content: row.content,
71
109
  effectorName: row.effectorName,
72
110
  ...( row.gestureType ? { gestureType: row.gestureType } : {} ),
73
111
  ...( row.replyToMessageId ? { replyToMessageId: row.replyToMessageId } : {} ),
74
- ...( row.threadId ? { threadId: row.threadId } : {} ),
112
+ ...( thread ? { threadId: thread } : {} ),
75
113
  deliveryStatus: 'pending',
76
114
  createdAtTick: 0,
77
115
  createdAt: Date.now(),
@@ -1,102 +0,0 @@
1
- // ─────────────────────────────────────────────────────────────
2
- // src/cognition/faculties/executive.engine/messages.ts
3
- // ─────────────────────────────────────────────────────────────
4
-
5
- import { logger } from '#core/logger'
6
- import type { Tick, ReadonlySimulationState } from '#core/types'
7
- import type { PendingMessage } from '#faculties/executive.engine/types'
8
-
9
- /**
10
- * Message queue state — tracks incoming communication entities,
11
- * pending messages during in-flight LLM calls, and reply deduplication.
12
- */
13
- export class MessageQueue {
14
- /** Messages buffered while an LLM call is in-flight. */
15
- pendingMessages: PendingMessage[] = []
16
-
17
- /** Tick when the current (or most recent) LLM call started. */
18
- pendingCallStartTick: number = -1
19
-
20
- /** Entity IDs of communication entities that have been replied to this session. */
21
- private _repliedEntityIds = new Set<string>()
22
-
23
- /**
24
- * Scan the simulation state for unprocessed communication entities
25
- * and queue them into pendingMessages. Call every tick.
26
- */
27
- scanState( state: ReadonlySimulationState, tick: Tick ): void {
28
- const seenIds = new Set( this.pendingMessages.map( m => m.id ) )
29
-
30
- for( const [ id, entity ] of state.entities ){
31
- if( entity.type !== 'communication') continue
32
- if( entity.metadata?.processedByExecutive ) continue
33
- if( seenIds.has( id ) ) continue
34
-
35
- const msgTick = ( entity.metadata?.tick as number ) ?? 0
36
- this.pendingMessages.push({
37
- id,
38
- content: (entity.metadata?.content as string) ?? '',
39
- sender: (entity.metadata?.agentName as string) ?? 'unknown',
40
- senderId: (entity.metadata?.keid as string) ?? 'unknown',
41
- tick: msgTick
42
- })
43
-
44
- logger.info(`[executive] queued message from ${(entity.metadata?.agentName as string) ?? 'unknown'} (tick=${msgTick})`)
45
- }
46
- }
47
-
48
- /**
49
- * Clear messages that were included in the most recent LLM call.
50
- * Any that arrived after the call started remain for the next cycle.
51
- */
52
- clearProcessedMessages(): void {
53
- this.pendingMessages = this.pendingMessages.filter( m => m.tick > this.pendingCallStartTick )
54
- }
55
-
56
- /**
57
- * Check if we've already replied to a specific communication entity this session.
58
- */
59
- hasRepliedTo( entityId: string ): boolean {
60
- return this._repliedEntityIds.has( entityId )
61
- }
62
-
63
- /**
64
- * Mark a communication entity as replied to.
65
- */
66
- markReplied( entityId: string ): void {
67
- this._repliedEntityIds.add( entityId )
68
- }
69
-
70
- /**
71
- * Build the set of communication entity IDs visible in state within the
72
- * 30-tick window, plus any pending messages. Used to mark them as processed.
73
- */
74
- getVisibleMessageIds( state: ReadonlySimulationState, tick: Tick ): Set<string> {
75
- const ids = new Set<string>( this.pendingMessages.map( m => m.id ) )
76
- for( const [ id, entity ] of state.entities ){
77
- if( entity.type !== 'communication') continue
78
-
79
- const msgTick = (entity.metadata?.tick as number) ?? 0
80
- if( tick - msgTick > 30 ) continue
81
-
82
- ids.add( id )
83
- }
84
-
85
- return ids
86
- }
87
-
88
- /**
89
- * Get stale communication entity IDs (>50 ticks old) for cleanup.
90
- */
91
- getStaleMessageIds( state: ReadonlySimulationState, tick: Tick ): string[] {
92
- const stale: string[] = []
93
- for( const [ id, entity ] of state.entities ){
94
- if( entity.type !== 'communication') continue
95
-
96
- const msgTick = (entity.metadata?.tick as number) ?? 0
97
- tick - msgTick > 50 && stale.push( id )
98
- }
99
-
100
- return stale
101
- }
102
- }