@mindot/will 0.9.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 (110) hide show
  1. package/README.md +15 -11
  2. package/dist/index.d.ts +188 -141
  3. package/dist/index.js +16660 -15206
  4. package/dist/index.js.map +1 -1
  5. package/dist/{channels → surface/channels}/discord.d.ts +50 -3
  6. package/dist/{channels → surface/channels}/discord.js +102 -7
  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 +7 -4
  10. package/dist/surface/channels/whatsapp.js.map +1 -0
  11. package/dist/{cli.js → surface/cli.js} +4406 -2904
  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-DbDj_TEH.d.ts → will-evj9_vrd.d.ts} +5699 -4854
  17. package/package.json +11 -12
  18. package/src/cognition/agency/consequence.ts +237 -3
  19. package/src/cognition/agency/conversation.aim.ts +32 -0
  20. package/src/cognition/agency/engines/action.selector.ts +59 -3
  21. package/src/cognition/agency/engines/affordance.synthesizer.ts +114 -10
  22. package/src/cognition/agency/engines/deliberation.engine.ts +76 -3
  23. package/src/cognition/agency/engines/motor.schema.executor.ts +362 -27
  24. package/src/cognition/agency/engines/reafference.engine.ts +43 -4
  25. package/src/cognition/agency/execution.primitives.ts +17 -4
  26. package/src/cognition/agency/reconcile.learning.ts +5 -4
  27. package/src/cognition/agency/schemas/innate.ts +96 -1
  28. package/src/cognition/agency/schemas/repertoire.ts +19 -1
  29. package/src/cognition/agency/selection.scoring.ts +16 -0
  30. package/src/cognition/agency/settlement.ts +203 -0
  31. package/src/cognition/agency/types.ts +38 -1
  32. package/src/cognition/config.mirror.entities.ts +0 -1
  33. package/src/cognition/event.schemas.ts +0 -8
  34. package/src/cognition/faculties/affective.blender.ts +21 -4
  35. package/src/cognition/faculties/circadian.oscillator.ts +36 -7
  36. package/src/cognition/faculties/executive.engine/action.record.ts +129 -0
  37. package/src/cognition/faculties/executive.engine/context.ts +41 -35
  38. package/src/cognition/faculties/executive.engine/engine.ts +270 -113
  39. package/src/cognition/faculties/executive.engine/escalation.buffer.ts +126 -59
  40. package/src/cognition/faculties/executive.engine/facet.supervisor.ts +10 -0
  41. package/src/cognition/faculties/executive.engine/parser.ts +100 -38
  42. package/src/cognition/faculties/executive.engine/prompt.factory.ts +168 -23
  43. package/src/cognition/faculties/executive.engine/types.ts +18 -15
  44. package/src/cognition/faculties/exteroception.ts +99 -98
  45. package/src/cognition/faculties/goal.manager.ts +50 -2
  46. package/src/cognition/faculties/known.entity.tracker.ts +22 -3
  47. package/src/cognition/faculties/planning.engine/engine.ts +4 -4
  48. package/src/cognition/faculties/planning.engine/plan.frontier.ts +1 -1
  49. package/src/cognition/faculties/semantic.engine/integrator.ts +1 -1
  50. package/src/cognition/faculties/working.memory.ts +6 -1
  51. package/src/cognition/instruction.handler.ts +1 -1
  52. package/src/cognition/memory/vector.embedder.ts +1 -3
  53. package/src/cognition/percept.entity.ts +122 -0
  54. package/src/cognition/sense.boundary.ts +176 -0
  55. package/src/cognition/senses/audition.engine/engine.ts +97 -16
  56. package/src/cognition/senses/base.sense.engine.ts +104 -6
  57. package/src/cognition/senses/index.ts +71 -13
  58. package/src/cognition/senses/provenance.ts +128 -0
  59. package/src/cognition/senses/somatosensation.engine.ts +117 -9
  60. package/src/cognition/social.identity.ts +23 -0
  61. package/src/cognition/utilities/token.tracker.ts +36 -5
  62. package/src/core/orchestrator.ts +13 -0
  63. package/src/index.ts +39 -5
  64. package/src/llm/gate.ts +48 -0
  65. package/src/llm/index.ts +102 -25
  66. package/src/llm/wire.contracts.ts +38 -0
  67. package/src/pma/index.ts +1 -1
  68. package/src/stem/index.ts +92 -21
  69. package/src/stem/mind.ts +26 -2
  70. package/src/stem/policy/arbiter.ts +10 -0
  71. package/src/stem/profiles/built-in.ts +7 -0
  72. package/src/{profiles → stem/profiles}/companion.ts +1 -1
  73. package/src/{profiles → stem/profiles}/company-brain.ts +1 -1
  74. package/src/{profiles → stem/profiles}/customer-service.ts +1 -1
  75. package/src/{profiles → stem/profiles}/game-npc.ts +1 -1
  76. package/src/{profiles → stem/profiles}/index.ts +1 -1
  77. package/src/{profiles → stem/profiles}/smart-home.ts +1 -1
  78. package/src/stem/tracts/effector/escalation.lifecycle.ts +239 -0
  79. package/src/stem/tracts/effector/policy.enforcement.ts +261 -0
  80. package/src/stem/tracts/effector/types.ts +110 -0
  81. package/src/stem/tracts/effector.controller.ts +112 -401
  82. package/src/stem/tracts/outbox.controller.ts +30 -17
  83. package/src/stem/tracts/sensory.controller.ts +6 -6
  84. package/src/stem/tracts/session.logger.ts +6 -1
  85. package/src/stem/tracts/transport/types.ts +20 -1
  86. package/src/stem/tracts/transport.controller.ts +33 -9
  87. package/src/{channels → surface/channels}/discord.ts +224 -7
  88. package/src/{channels → surface/channels}/roster.ts +1 -1
  89. package/src/{channels → surface/channels}/types.ts +1 -1
  90. package/src/{channels → surface/channels}/whatsapp.ts +9 -6
  91. package/src/{cli.ts → surface/cli.ts} +6 -6
  92. package/src/{host → surface/host}/boot.ts +3 -3
  93. package/src/{host → surface/host}/utterances.ts +2 -2
  94. package/src/{mcp → surface/mcp}/effectors.ts +19 -6
  95. package/src/{mcp → surface/mcp}/server.ts +19 -5
  96. package/src/{sdk → surface/sdk}/will.ts +156 -28
  97. package/src/{serve → surface/serve}/server.ts +11 -4
  98. package/src/types.ts +24 -3
  99. package/dist/channels/discord.js.map +0 -1
  100. package/dist/channels/whatsapp.js.map +0 -1
  101. package/dist/cli.js.map +0 -1
  102. package/dist/mcp/effectors.js.map +0 -1
  103. package/src/extensions/livestream.ext.ts +0 -570
  104. package/src/extensions/time.ext.ts +0 -339
  105. package/src/profiles/built-in.ts +0 -7
  106. package/src/runners/coherence.runner.ts +0 -49
  107. package/src/runners/outreach.runner.ts +0 -155
  108. package/src/runners/social.runner.ts +0 -179
  109. package/src/runners/thin-shim.runner.ts +0 -205
  110. /package/dist/{cli.d.ts → surface/cli.d.ts} +0 -0
@@ -91,24 +91,37 @@ export class OutboxController {
91
91
  break
92
92
  }
93
93
 
94
- // 2. Write a percept so Exteroception perceives delivery ("ear hears the word")
95
- instance.simulation.stateManager.setEntity({
96
- id: `msg-delivered-${messageId}`,
97
- type: 'percept',
98
- createdAt: Date.now(),
99
- updatedAt: Date.now(),
100
- metadata: {
101
- category: 'message-delivery',
102
- summary: delivered
103
- ? `My message was delivered successfully.`
104
- : `My message failed to reach the recipient.`,
105
- salience: delivered ? 0.35 : 0.6,
106
- changeType: delivered ? 'delivered' : 'failed',
107
- // Reafference by construction this percept describes our own action's
108
- // outcome ("ear hears the word"). Tagged, not attenuated: it is the ack
109
- // surface, not a content echo (EXAFFERENCE P2).
110
- provenance: 'reafferent',
94
+ // 2. The world's answer about my own words in through the sense door.
95
+ //
96
+ // SIGNAL_BOUNDARY P4. This tract used to build the percept entity itself and
97
+ // `setEntity` it, which made it one of three places outside a sense that
98
+ // wrote a percept. It is reafference by construction ("the ear hears the
99
+ // word you spoke"): the message went out, and the world said whether it
100
+ // landed. That is precisely what the somatosensation door carries since P2,
101
+ // and going through it means the percept is stamped, traced and swept by the
102
+ // same machinery as every other one instead of by hand here.
103
+ //
104
+ // The facts go as `data`, not as prose. `delivered` and the id are what the
105
+ // mind has to reason from; the sentence it reads is the engine's business.
106
+ // Two deliveries in one tick stay distinct because `messageId` is in the
107
+ // data and the trace id hashes the label the data composes — identical
108
+ // payloads in one tick are what that hash is meant to collapse.
109
+ //
110
+ // `summary` here is a host-style hint, and the engine may bound it; the data
111
+ // beside it is never bounded. Salience keeps the old asymmetry — a failure
112
+ // is worth more attention than a success, because only one of them needs
113
+ // anything done about it.
114
+ void instance.cognition.somatosensationEngine.sense({
115
+ kind: 'system',
116
+ signal: 'message_delivery',
117
+ provenance: 'reafferent',
118
+ data: {
111
119
  messageId,
120
+ delivered,
121
+ salience: delivered ? 0.35 : 0.6,
122
+ summary: delivered
123
+ ? 'My message was delivered successfully.'
124
+ : 'My message failed to reach the recipient.',
112
125
  },
113
126
  })
114
127
 
@@ -4,7 +4,7 @@
4
4
  //
5
5
  // SensoryController owns the senses input + LLM chunk-streaming output
6
6
  // boundary extracted from WillStem (R5-e):
7
- // - input ingestion: ingestText / ingestSensory / injectEvent
7
+ // - input ingestion: senseText / senseSignal / injectEvent
8
8
  // - real-time chunk streaming: addChunkListener / addSensoryChunkListener
9
9
  // and their sync helpers (syncChunkBroadcaster + the addChunkCallback fan-out)
10
10
  //
@@ -34,17 +34,17 @@ export class SensoryController {
34
34
  * LanguagePercept publication, conversation-facet spawn/reuse, and LLM
35
35
  * reply generation. Reply delivery is async via the outbox / SSE channel.
36
36
  */
37
- async ingestText( instance: WillInstance, input: TextMessage ): Promise<void> {
38
- await instance.cognition.auditionEngine.ingest( input )
37
+ async senseText( instance: WillInstance, input: TextMessage ): Promise<void> {
38
+ await instance.cognition.auditionEngine.sense( input )
39
39
  }
40
40
 
41
41
  /**
42
42
  * Route a raw SensoryInput to the appropriate sense engine by domain.
43
43
  * Used by the debug `POST /senses/:domain/ingest` route.
44
44
  */
45
- async ingestSensory( instance: WillInstance, domain: string, input: SensoryInput ): Promise<void> {
45
+ async senseSignal( instance: WillInstance, domain: string, input: SensoryInput ): Promise<void> {
46
46
  const cog = instance.cognition
47
- const engineMap: Record<string, { ingest: (i: SensoryInput) => Promise<void> }> = {
47
+ const engineMap: Record<string, { sense: (i: SensoryInput) => Promise<void> }> = {
48
48
  audition: cog.auditionEngine,
49
49
  vision: cog.visionEngine,
50
50
  somatosensation: cog.somatosensationEngine,
@@ -53,7 +53,7 @@ export class SensoryController {
53
53
  }
54
54
  const engine = engineMap[ domain ]
55
55
  if( !engine ) throw Object.assign( new Error(`Unknown sense domain: ${domain}`), { code: 400 } )
56
- await engine.ingest( input )
56
+ await engine.sense( input )
57
57
  }
58
58
 
59
59
  /**
@@ -50,7 +50,12 @@ export type LogEntryType =
50
50
  | 'executive.facet.destroy'
51
51
  | 'action.execute'
52
52
  | 'action.error'
53
- | 'action.outcome'
53
+ // A HOST ACKED AN EFFECTOR — not the `action.outcome` BUS EVENT, which is a
54
+ // different shape with six subscribers (SIGNAL_BOUNDARY P3). The two shared
55
+ // this string, and the prompt section named after it renders a third thing
56
+ // again. One word, three shapes; this is the one that gives it up, because
57
+ // "a host acked" is what it actually records and nothing else logs it.
58
+ | 'effector.acked'
54
59
  | 'belief.integrate'
55
60
  | 'conversation.in'
56
61
  | 'conversation.out'
@@ -29,7 +29,7 @@ interface BaseEnvelope {
29
29
  /**
30
30
  * Stable id used to match an outbound envelope to its ack(s).
31
31
  * For messages this is the OutboxMessage id; for effector invocations the
32
- * decisionRecordId; for replies/chunks a generated id.
32
+ * intentId; for replies/chunks a generated id.
33
33
  */
34
34
  correlationId: string
35
35
  /** Monotonic per-Will sequence number — ordering + dedup at the peer. */
@@ -123,6 +123,25 @@ export interface InboundMessageEnvelope extends BaseEnvelope {
123
123
  threadId: string
124
124
  content: string
125
125
  speakerName?: string
126
+ /**
127
+ * Whose doing this was, as the SENDING host asserts it. Optional on the wire
128
+ * and only there: `SensoryInput.provenance` is required inside the package,
129
+ * but a wire type cannot make an older peer send a field, so absence has to
130
+ * be survivable. It is read through `asProvenance()`, which owns the
131
+ * direction for untyped ingress.
132
+ *
133
+ * This field is why the transport no longer says `'unknown'`. It used to,
134
+ * and correctly — the absence was STRUCTURAL, there was no field to fill —
135
+ * but `unknown` percepts are skipped by the rupture gate in `action.selector`,
136
+ * so a mind reached over a transport could not be interrupted by anyone
137
+ * speaking to it, while the same words in-process could interrupt it. Two
138
+ * transports, two different minds.
139
+ */
140
+ provenance?: import('#senses/index').SignalProvenance
141
+ /** True when this thread is private — just this someone and the Will. */
142
+ direct?: boolean
143
+ /** What the room is called, e.g. `#general`. A label, not an address. */
144
+ threadName?: string
126
145
  }
127
146
 
128
147
  /** A non-conversational external percept (webhook, system signal, etc.). */
@@ -23,6 +23,7 @@
23
23
  import { logger } from '#core/logger'
24
24
  import { wallClock } from '#core/wall.clock'
25
25
  import { getInboundRecorder, getInboundSource } from '#core/inbound.recorder'
26
+ import { asProvenance } from '#senses/index'
26
27
  import type { SensoryInput } from '#senses/index'
27
28
  import type { effectorInvocation, OutboxMessage } from '#types'
28
29
  import type { WillInstance } from '#stem/index'
@@ -242,7 +243,7 @@ export class TransportController {
242
243
  /**
243
244
  * Emit drained external effector invocations over the transport (2.4). Called
244
245
  * by the tick loop after `pendingEffectorInvocations` is spliced. The peer
245
- * executes each and returns a result-ack (correlationId = decisionRecordId),
246
+ * executes each and returns a result-ack (correlationId = intentId),
246
247
  * which inbound dispatch routes to `confirmExecution`.
247
248
  */
248
249
  emitInvocations( instance: WillInstance, invocations: effectorInvocation[] ): void {
@@ -254,7 +255,7 @@ export class TransportController {
254
255
  this._emit( instance, {
255
256
  channel: 'effector_invocation',
256
257
  willId: instance.config.id,
257
- correlationId: invocation.decisionRecordId,
258
+ correlationId: invocation.intentId,
258
259
  seq: this._nextSeq( instance.config.id ),
259
260
  wallTime: wallClock(),
260
261
  invocation,
@@ -339,18 +340,41 @@ export class TransportController {
339
340
  private _dispatch( instance: WillInstance, env: InboundEnvelope, deps: InboundApplyDeps ): void {
340
341
  switch( env.channel ){
341
342
  case 'inbound_message': {
342
- // Fire-and-forget: AuditionEngine.ingest() serializes per-entity
343
+ // Fire-and-forget: AuditionEngine.sense() serializes per-entity
343
344
  // internally; the tick must not block on the LLM reply.
345
+ // THE FIX THIS COMMENT USED TO PROMISE. It said `'unknown'` was right
346
+ // because the absence was STRUCTURAL — the envelope had no provenance
347
+ // field, so a remote host could not declare one however much it knew —
348
+ // and that all three dropped fields were one deliberate wire change
349
+ // rather than three patches. This is that change: the envelope now
350
+ // carries `provenance`, `direct` and `threadName`.
351
+ //
352
+ // With the field there, an omission stops being structural and becomes
353
+ // a caller's omission, which is exactly what `asProvenance()` is for at
354
+ // an untyped boundary (its own comment sends this case here).
355
+ //
356
+ // It is not cosmetic. `'unknown'` percepts are skipped by the rupture
357
+ // gate in `action.selector`, so a mind reached over a transport COULD
358
+ // NOT BE INTERRUPTED by anyone speaking to it, while the same words
359
+ // in-process could interrupt it — and the transport path is the default
360
+ // whenever one is bound. Two transports, two different minds.
361
+ const provenance = asProvenance( env.provenance )
344
362
  const input: SensoryInput = env.kind === 'voice'
345
- ? { kind: 'voice', entityId: env.entityId, threadId: env.threadId, transcription: env.content }
363
+ ? { kind: 'voice', entityId: env.entityId, threadId: env.threadId, transcription: env.content, provenance }
346
364
  : {
347
- kind: 'text',
348
- entityId: env.entityId,
349
- threadId: env.threadId,
350
- content: env.content,
365
+ kind: 'text',
366
+ entityId: env.entityId,
367
+ threadId: env.threadId,
368
+ content: env.content,
369
+ provenance,
351
370
  ...( env.speakerName ? { speakerName: env.speakerName } : {} ),
371
+ // Omitted rather than defaulted, the same way the SDK door does
372
+ // it: an unknown room is not known to be public, and a room with
373
+ // no name stays unnamed rather than being labelled with its id.
374
+ ...( env.direct !== undefined ? { direct: env.direct } : {} ),
375
+ ...( env.threadName ? { threadName: env.threadName } : {} ),
352
376
  }
353
- void instance.cognition.auditionEngine.ingest( input )
377
+ void instance.cognition.auditionEngine.sense( input )
354
378
  break
355
379
  }
356
380
 
@@ -1,10 +1,10 @@
1
1
  // ─────────────────────────────────────────────────────────────
2
- // src/channels/discord.ts — a Will present in a Discord server
2
+ // src/surface/channels/discord.ts — a Will present in a Discord server
3
3
  // ─────────────────────────────────────────────────────────────
4
4
  //
5
5
  // The bridge relays both directions of the paradigm and nothing else:
6
6
  //
7
- // inbound guild/DM message → will.perceive({ from, speaker, text, thread })
7
+ // inbound guild/DM message → will.sense({ from, speaker, text, thread })
8
8
  // — every author is `discord:<userId>` (stable across guilds), the
9
9
  // display name is *learned* by the mind, and each Discord channel
10
10
  // is its own conversation thread.
@@ -22,9 +22,9 @@
22
22
  // `DiscordLikeClient` type keeps the dependency out of the type graph.
23
23
  // ─────────────────────────────────────────────────────────────
24
24
 
25
- import type { Will, WillMessage } from '#sdk/will'
26
- import { ChannelRoster } from '#channels/roster'
27
- import { chunkText, renderAttachments, isTextual, type ChannelBridge, type ChannelAttachment } from '#channels/types'
25
+ import type { Will, WillMessage } from '#surface/sdk/will'
26
+ import { ChannelRoster } from '#surface/channels/roster'
27
+ import { chunkText, renderAttachments, isTextual, type ChannelBridge, type ChannelAttachment } from '#surface/channels/types'
28
28
 
29
29
  const DISCORD_MESSAGE_LIMIT = 2000
30
30
 
@@ -41,11 +41,52 @@ const DISCORD_CDN_HOSTS = new Set( [ 'cdn.discordapp.com', 'media.discordapp.net
41
41
  * bounds what is *kept*, this bounds what is fetched at all. */
42
42
  const MAX_FETCH_BYTES = 256 * 1024
43
43
 
44
+ /**
45
+ * How much of her own message to quote back when someone reacts to it.
46
+ *
47
+ * Enough to identify WHICH thing was agreed with — a bare "someone reacted 👍"
48
+ * closes the answered loop but tells the mind nothing about what was affirmed,
49
+ * and 0.9.0 established that an answer without its content is worse than none:
50
+ * it invites acting on an answer never seen.
51
+ */
52
+ const REACTION_QUOTE_CHARS = 140
53
+
54
+ /**
55
+ * What to call the room this was said in — `#general`, `#general › release-cut`,
56
+ * or `#general in Mindot` when the server is known.
57
+ *
58
+ * A LABEL, not an address. `discord:1531…` is how a message gets to the room;
59
+ * this is what a person calls it, and 0.9.0 established those are different
60
+ * facts. Without it a room had a dossier and no name, so it reached the mind as
61
+ * "something" — which is what the prompt says for a `thing` it cannot name — and
62
+ * a mind choosing where to speak was picking between two opaque numbers.
63
+ *
64
+ * Returns undefined for a DM: a private thread is not a place, it is the person,
65
+ * and the tracker deliberately gives it no dossier to name.
66
+ */
67
+ function roomLabel( message: DiscordLikeMessage ): string | undefined {
68
+ if( !message.guildId ) return undefined
69
+ const own = message.channel?.name
70
+ if( !own ) return undefined
71
+ const parent = message.channel?.parent?.name
72
+ const room = parent ? `#${ parent } › ${ own }` : `#${ own }`
73
+ const guild = message.guild?.name
74
+ return guild ? `${ room } in ${ guild }` : room
75
+ }
76
+
44
77
  // ── The slice of discord.js the bridge actually uses (structural) ───────────
45
78
 
46
79
  export interface DiscordLikeChannel {
47
80
  send( content: string ): Promise<unknown>
48
81
  sendTyping?(): Promise<unknown>
82
+ /** `general` for a text channel; absent on a DM, which has no name and is a person. */
83
+ name?: string | null
84
+ /** The thread's parent channel, so a thread reads as "#general › release-cut". */
85
+ parent?: { name?: string | null } | null
86
+ /** What the room is FOR, in the server's own words. Only read when asked. */
87
+ topic?: string | null
88
+ /** How many are in it — a count, never a roster. */
89
+ memberCount?: number
49
90
  }
50
91
 
51
92
  export interface DiscordLikeAttachment {
@@ -60,6 +101,8 @@ export interface DiscordLikeMessage {
60
101
  cleanContent?: string
61
102
  channelId: string
62
103
  guildId?: string | null
104
+ /** The server this was said in. Its NAME is what a person calls the place. */
105
+ guild?: { name?: string | null } | null
63
106
  author: { id: string; bot?: boolean; username?: string; displayName?: string }
64
107
  member?: { displayName?: string } | null
65
108
  mentions?: { has( userId: string ): boolean }
@@ -76,11 +119,38 @@ export interface DiscordLikeMessage {
76
119
  attachments?: ReadonlyMap<string, DiscordLikeAttachment> | Iterable<DiscordLikeAttachment>
77
120
  }
78
121
 
122
+ /**
123
+ * A reaction, and the message it lands on.
124
+ *
125
+ * Both halves may be PARTIAL: Discord delivers a reaction on an uncached message
126
+ * with almost every field empty, which is the normal case for a message the bot
127
+ * sent before its current process started. `fetch()` fills it in, and the handler
128
+ * must call it rather than reading through the hole.
129
+ */
130
+ export interface DiscordLikeReaction {
131
+ emoji: { name?: string | null; id?: string | null }
132
+ message: DiscordLikeMessage & {
133
+ partial?: boolean
134
+ author?: { id: string; bot?: boolean; username?: string; displayName?: string }
135
+ fetch?(): Promise<DiscordLikeReaction['message']>
136
+ }
137
+ partial?: boolean
138
+ fetch?(): Promise<DiscordLikeReaction>
139
+ }
140
+
141
+ export interface DiscordLikeReactor {
142
+ id: string
143
+ bot?: boolean
144
+ username?: string
145
+ displayName?: string
146
+ }
147
+
79
148
  export interface DiscordLikeClient {
80
149
  user: { id: string; setPresence?( p: unknown ): void } | null
81
150
  /** discord.js ≥14.22; polled so we needn't subscribe to the deprecated `ready`. */
82
151
  isReady?(): boolean
83
152
  on( event: 'messageCreate', fn: ( m: DiscordLikeMessage ) => void ): unknown
153
+ on( event: 'messageReactionAdd', fn: ( r: DiscordLikeReaction, u: DiscordLikeReactor ) => void ): unknown
84
154
  once( event: string, fn: () => void ): unknown
85
155
  login( token: string ): Promise<unknown>
86
156
  destroy(): Promise<unknown> | void
@@ -159,6 +229,70 @@ export async function connectDiscord( will: Will, opts: DiscordBridgeOptions ):
159
229
 
160
230
  // ── inbound: platform message → stimulus ──────────────────────────────────
161
231
  client.on('messageCreate', message => { void onMessage( message ) } )
232
+ client.on('messageReactionAdd', ( reaction, user ) => { void onReaction( reaction, user ) } )
233
+
234
+ /**
235
+ * Someone reacted to something the Will said.
236
+ *
237
+ * A reaction IS an answer, and until this existed the mind read it as silence.
238
+ * `conversation.received` is written only from a text percept, so a 👍 — the
239
+ * commonest acknowledgement on Discord — produced no record at all: the turn it
240
+ * answered stayed open, the reply window elapsed, and the mind concluded it had
241
+ * been ignored. That conclusion is not inert. It reaches reputation as
242
+ * reliability (−0.06 per unanswered turn) and goals as absence of progress, so
243
+ * answering her with an emoji actively taught her that person does not respond.
244
+ *
245
+ * Only reactions on the Will's OWN messages are perceived. A reaction between
246
+ * two other people in a busy channel is real social information, but it is not
247
+ * an answer to anything the mind said, and routing it here would swamp the
248
+ * conversation record it is meant to correct.
249
+ */
250
+ async function onReaction( reaction: DiscordLikeReaction, user: DiscordLikeReactor ): Promise<void> {
251
+ const self = client.user
252
+ if( !self || user.id === self.id || user.bot ) return
253
+
254
+ // Both may arrive partial — a reaction on a message this process never cached
255
+ // is the NORMAL case for anything said before the last restart, which is
256
+ // exactly the long-lived conversation this is here to keep honest.
257
+ const full = reaction.partial && reaction.fetch ? await reaction.fetch().catch( () => null ) : reaction
258
+ if( !full ) return
259
+ const msg = full.message.partial && full.message.fetch
260
+ ? await full.message.fetch().catch( () => null )
261
+ : full.message
262
+ if( !msg?.author ) return
263
+
264
+ if( msg.author.id !== self.id ) return // not ours — not an answer to us
265
+ const isDM = !msg.guildId
266
+ if( !isDM && allowed && !allowed.has( msg.channelId ) ) return
267
+
268
+ const emoji = full.emoji?.name ?? ( full.emoji?.id ? ':custom:' : '' )
269
+ if( !emoji ) return
270
+
271
+ const who = user.displayName ?? user.username
272
+ const said = ( msg.cleanContent || msg.content || '').trim().slice( 0, REACTION_QUOTE_CHARS )
273
+
274
+ // Described, bracketed, first person — the same shape `renderAttachments` uses
275
+ // for a file, and for the same reason: this reached the mind through the
276
+ // conversation but nobody SAID it, and a percept that reads like speech invites
277
+ // the mind to answer words that were never spoken.
278
+ const text = said
279
+ ? `[${ who ?? 'someone' } reacted ${ emoji } to what I said: "${ said }"]`
280
+ : `[${ who ?? 'someone' } reacted ${ emoji } to something I said]`
281
+
282
+ await will.sense( {
283
+ text,
284
+ from: `discord:${ user.id }`,
285
+ thread: `discord:${ msg.channelId }`,
286
+ direct: isDM,
287
+ // Exafferent, and the near-miss is worth naming: this is ABOUT something
288
+ // she did, but it is not her doing it. Somebody else reacted. Reafference
289
+ // is the mind sensing its OWN act's consequence, not the world's response
290
+ // to that act — a reply would fail the same test for the same reason.
291
+ provenance: 'exafferent',
292
+ ...( roomLabel( msg ) ? { threadName: roomLabel( msg ) } : {} ),
293
+ ...( who ? { speaker: who } : {} ),
294
+ } )
295
+ }
162
296
 
163
297
  async function onMessage( message: DiscordLikeMessage ): Promise<void> {
164
298
  const self = client.user
@@ -201,7 +335,7 @@ export async function connectDiscord( will: Will, opts: DiscordBridgeOptions ):
201
335
  )
202
336
  const text = [ said, shared ].filter( Boolean ).join('\n')
203
337
 
204
- await will.perceive( {
338
+ await will.sense( {
205
339
  text,
206
340
  from: entityId,
207
341
  thread: `discord:${ message.channelId }`,
@@ -210,6 +344,12 @@ export async function connectDiscord( will: Will, opts: DiscordBridgeOptions ):
210
344
  // the right or wrong place to say something, and the mind never saw it —
211
345
  // which is how a follow-up promised in a DM went out to #general.
212
346
  direct: isDM,
347
+ // Somebody spoke. The bridge already drops her own messages (`onMessage`
348
+ // returns early on `author.id === self.id`), so nothing reafferent can
349
+ // reach this line today — but that filter is a bridge-level deletion of
350
+ // a signal she is entitled to sense, not a reason for the field to lie.
351
+ provenance: 'exafferent',
352
+ ...( roomLabel( message ) ? { threadName: roomLabel( message ) } : {} ),
213
353
  ...( speaker ? { speaker } : {} ),
214
354
  } )
215
355
  }
@@ -262,6 +402,71 @@ export async function connectDiscord( will: Will, opts: DiscordBridgeOptions ):
262
402
  return ( await res.text() ).slice( 0, MAX_FETCH_BYTES )
263
403
  }
264
404
 
405
+ // ── the Will looks at something here, and Discord answers ─────────────────
406
+ //
407
+ // `inspect` is innate: every mind can look. What it finds depends on there
408
+ // being a world that can be asked, and this is Discord's answer.
409
+ //
410
+ // The answer is RETURNED AS AN OBSERVATION, and the engine turns it into a
411
+ // percept (SIGNAL_BOUNDARY P2). `description` says how the looking went;
412
+ // `observation` says what was found, and arrives at the mind as a reafferent
413
+ // percept tied to this act by `sourceIntentId` — so it can weigh the facts,
414
+ // disbelieve them, or decide they do not matter. An earlier cut wrote the
415
+ // answer straight into her dossier and beliefs, which is a mind being told
416
+ // what it knows rather than a mind finding out.
417
+ //
418
+ // This used to take TWO calls: return the ack, then separately `perceive()`
419
+ // the answer wrapped in a bracketed sentence — because an ack could not carry
420
+ // facts, and the sense door only accepted things somebody had said. The prose
421
+ // bracket was load-bearing: it was the only place the reafference lived where
422
+ // anything could read it. Both are gone.
423
+ will.effector('inspect', async ( _args, ctx ) => {
424
+ // The mind names a referent (`ke:1sqlkux`); we hold channel ids. The Will
425
+ // resolves the anchor to the addresses it was met at, and we take ours.
426
+ const address = ( ctx.targetAddresses ?? [] ).find( a => a.startsWith('discord:') )
427
+ if( !address ) return { success: false, description: 'Not something I can see on Discord.' }
428
+
429
+ const id = address.slice('discord:'.length )
430
+ const channel = await client.channels.fetch( id ).catch( () => null ) as DiscordLikeChannel | null
431
+ // A user id lands here too — a person is not a place, and Discord has nothing
432
+ // to say about them that watching them talk does not say better.
433
+ if( !channel?.name ) return { success: false, description: 'There is nothing here I can look up.' }
434
+
435
+ const facts: string[] = []
436
+ if( channel.topic ) facts.push(`it is for: ${ channel.topic }`)
437
+ if( channel.parent?.name ) facts.push(`it sits under #${ channel.parent.name }`)
438
+ if( typeof channel.memberCount === 'number')
439
+ // A COUNT, not a roster. Walking into a room you see that it is crowded long
440
+ // before you learn who anyone is; people become known by being met.
441
+ facts.push(`${ channel.memberCount } people are in it`)
442
+
443
+ if( facts.length === 0 )
444
+ return { success: false, description: `#${ channel.name } has nothing recorded about it.` }
445
+
446
+ const label = roomLabel( { channelId: id, guildId: 'g', channel } as DiscordLikeMessage ) ?? `#${ channel.name }`
447
+
448
+ return {
449
+ success: true,
450
+ description: `Looked into ${ label }.`,
451
+ // The room as Discord has it, in the shape Discord has it. Not flattened
452
+ // into a sentence for the mind's benefit — a host that reshapes its own
453
+ // data is deciding what the mind may notice about it, and `observation`
454
+ // takes whatever shape the answer already had.
455
+ //
456
+ // `summary` is the one concession: it is what the executive prompt renders,
457
+ // so the host says it in words rather than leaving the mind to read JSON.
458
+ // Everything beside it stays available.
459
+ observation: {
460
+ summary: `I looked into ${ label }: ${ facts.join('; ') }.`,
461
+ room: label,
462
+ address,
463
+ ...( channel.topic ? { topic: channel.topic } : {} ),
464
+ ...( channel.parent?.name ? { parent: `#${ channel.parent.name }` } : {} ),
465
+ ...( typeof channel.memberCount === 'number' ? { memberCount: channel.memberCount } : {} ),
466
+ },
467
+ }
468
+ } )
469
+
265
470
  // ── outbound: projected utterance → the addressee ─────────────────────────
266
471
  // The facade has no off(); the bridge gates its handler on `closed` instead.
267
472
  let closed = false
@@ -352,8 +557,20 @@ async function createDiscordClient(): Promise<DiscordLikeClient> {
352
557
  GatewayIntentBits.GuildMessages,
353
558
  GatewayIntentBits.MessageContent,
354
559
  GatewayIntentBits.DirectMessages,
560
+ // Neither is privileged, so this costs nothing to ask for — and without
561
+ // them an emoji answer never arrives and reads as being ignored.
562
+ GatewayIntentBits.GuildMessageReactions,
563
+ GatewayIntentBits.DirectMessageReactions,
564
+ ],
565
+ partials: [
566
+ Partials.Channel, // DMs arrive on uncached channels
567
+ // A reaction on a message this process did not cache — i.e. anything said
568
+ // before the last restart — is delivered partial, and without these the
569
+ // event is dropped before the handler sees it. That is precisely the
570
+ // long-running conversation the answered loop exists for.
571
+ Partials.Message,
572
+ Partials.Reaction,
355
573
  ],
356
- partials: [ Partials.Channel ], // DMs arrive on uncached channels
357
574
  } ) as unknown as DiscordLikeClient
358
575
  }
359
576
 
@@ -1,5 +1,5 @@
1
1
  // ─────────────────────────────────────────────────────────────
2
- // src/channels/roster.ts — who the Will knows on a platform, and where
2
+ // src/surface/channels/roster.ts — who the Will knows on a platform, and where
3
3
  // ─────────────────────────────────────────────────────────────
4
4
  //
5
5
  // The mind knows *entities*; a platform knows user ids and channels. The roster
@@ -1,5 +1,5 @@
1
1
  // ─────────────────────────────────────────────────────────────
2
- // src/channels/types.ts — the channel-bridge contract
2
+ // src/surface/channels/types.ts — the channel-bridge contract
3
3
  // ─────────────────────────────────────────────────────────────
4
4
  //
5
5
  // A channel bridge puts a Will *in a place where people already are* (Discord,
@@ -1,10 +1,10 @@
1
1
  // ─────────────────────────────────────────────────────────────
2
- // src/channels/whatsapp.ts — a Will present on WhatsApp
2
+ // src/surface/channels/whatsapp.ts — a Will present on WhatsApp
3
3
  // ─────────────────────────────────────────────────────────────
4
4
  //
5
5
  // Same paradigm as the Discord bridge, different room:
6
6
  //
7
- // inbound DM / group message → will.perceive({ from, speaker, text, thread })
7
+ // inbound DM / group message → will.sense({ from, speaker, text, thread })
8
8
  // — every author is `whatsapp:<number>` (stable across chats), the
9
9
  // push name is *learned* by the mind, and each chat (DM or group)
10
10
  // is its own conversation thread.
@@ -25,9 +25,9 @@
25
25
  // `WaLikeSocket` type keeps the dependency out of the type graph.
26
26
  // ─────────────────────────────────────────────────────────────
27
27
 
28
- import type { Will, WillMessage } from '#sdk/will'
29
- import { ChannelRoster } from '#channels/roster'
30
- import { chunkText, type ChannelBridge } from '#channels/types'
28
+ import type { Will, WillMessage } from '#surface/sdk/will'
29
+ import { ChannelRoster } from '#surface/channels/roster'
30
+ import { chunkText, type ChannelBridge } from '#surface/channels/types'
31
31
 
32
32
  /** WhatsApp's hard per-message cap. */
33
33
  const WHATSAPP_MESSAGE_LIMIT = 65_536
@@ -162,7 +162,10 @@ export async function connectWhatsApp( will: Will, opts: WhatsAppBridgeOptions =
162
162
  const text = textOf( m )
163
163
  if( !text.trim() ) return
164
164
 
165
- await will.perceive( {
165
+ await will.sense( {
166
+ // Somebody messaged her. Baileys filters `fromMe` upstream, so as with
167
+ // Discord nothing reafferent reaches this bridge today.
168
+ provenance: 'exafferent',
166
169
  text,
167
170
  from: entityId,
168
171
  thread: `whatsapp:${ jid }`,
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // ─────────────────────────────────────────────────────────────
3
- // src/cli.ts — the `will` command: host a persistent mind
3
+ // src/surface/cli.ts — the `will` command: host a persistent mind
4
4
  // ─────────────────────────────────────────────────────────────
5
5
  //
6
6
  // will mcp host over MCP stdio (Claude Desktop / Claude Code / IDEs)
@@ -24,11 +24,11 @@
24
24
  // ─────────────────────────────────────────────────────────────
25
25
 
26
26
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
27
- import { routeLogsToStderr, bootWillFromEnv } from '#root/host/boot'
28
- import { buildWillMcpServer } from '#root/mcp/server'
29
- import { buildWillHttpServer } from '#root/serve/server'
30
- import { connectDiscord, parseChannels, parseMentionOnly } from '#channels/discord'
31
- import { connectWhatsApp } from '#channels/whatsapp'
27
+ import { routeLogsToStderr, bootWillFromEnv } from '#surface/host/boot'
28
+ import { buildWillMcpServer } from '#surface/mcp/server'
29
+ import { buildWillHttpServer } from '#surface/serve/server'
30
+ import { connectDiscord, parseChannels, parseMentionOnly } from '#surface/channels/discord'
31
+ import { connectWhatsApp } from '#surface/channels/whatsapp'
32
32
 
33
33
  // stdout is the MCP protocol channel under `will mcp` — route logs FIRST.
34
34
  routeLogsToStderr()
@@ -1,5 +1,5 @@
1
1
  // ─────────────────────────────────────────────────────────────
2
- // src/host/boot.ts — shared boot/shutdown for the `will` CLI hosts
2
+ // src/surface/host/boot.ts — shared boot/shutdown for the `will` CLI hosts
3
3
  // ─────────────────────────────────────────────────────────────
4
4
  //
5
5
  // Both hosts (`will mcp`, `will serve`) raise the same mind the same way:
@@ -27,9 +27,9 @@
27
27
  import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'
28
28
  import { dirname, resolve } from 'node:path'
29
29
  import { setLogger } from '#core/logger'
30
- import { Will, detectProvider, type CreateWillOptions } from '#sdk/will'
30
+ import { Will, detectProvider, type CreateWillOptions } from '#surface/sdk/will'
31
31
  import type { PMASnapshot } from '#pma/index'
32
- import { connectMcpEffectors, type McpToolsSource } from '#root/mcp/effectors'
32
+ import { connectMcpEffectors, type McpToolsSource } from '#surface/mcp/effectors'
33
33
  import { anthropicWireHeaders, defaultBaseFor, knownWireFor, providerKeyFromEnv, PROVIDER_KEY_ENV, type LLMProvider, type LLMWire } from '#llm/index'
34
34
 
35
35
  /**
@@ -1,5 +1,5 @@
1
1
  // ─────────────────────────────────────────────────────────────
2
- // src/host/utterances.ts — a host-side tap on a Will's speech
2
+ // src/surface/host/utterances.ts — a host-side tap on a Will's speech
3
3
  // ─────────────────────────────────────────────────────────────
4
4
  //
5
5
  // Hosts that expose a Will over a request/response protocol (MCP tools, HTTP
@@ -10,7 +10,7 @@
10
10
  // first, else wait, else report silence (null — a choice, never an error).
11
11
  // ─────────────────────────────────────────────────────────────
12
12
 
13
- import type { Will, WillMessage } from '#sdk/will'
13
+ import type { Will, WillMessage } from '#surface/sdk/will'
14
14
 
15
15
  const BUFFER_CAP = 50
16
16