@mindot/will 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/README.md +15 -11
  2. package/dist/index.d.ts +188 -141
  3. package/dist/index.js +6985 -3337
  4. package/dist/index.js.map +1 -1
  5. package/dist/surface/channels/discord.d.ts +177 -0
  6. package/dist/surface/channels/discord.js +394 -0
  7. package/dist/surface/channels/discord.js.map +1 -0
  8. package/dist/{channels → surface/channels}/whatsapp.d.ts +2 -2
  9. package/dist/{channels → surface/channels}/whatsapp.js +11 -5
  10. package/dist/surface/channels/whatsapp.js.map +1 -0
  11. package/dist/{cli.js → surface/cli.js} +5382 -1573
  12. package/dist/surface/cli.js.map +1 -0
  13. package/dist/{mcp → surface/mcp}/effectors.d.ts +1 -1
  14. package/dist/{mcp → surface/mcp}/effectors.js +10 -4
  15. package/dist/surface/mcp/effectors.js.map +1 -0
  16. package/dist/{will-cS6k4uiJ.d.ts → will-evj9_vrd.d.ts} +6658 -5078
  17. package/package.json +11 -12
  18. package/src/cognition/agency/consequence.ts +356 -1
  19. package/src/cognition/agency/conversation.aim.ts +292 -0
  20. package/src/cognition/agency/engines/action.selector.ts +142 -5
  21. package/src/cognition/agency/engines/affordance.synthesizer.ts +200 -7
  22. package/src/cognition/agency/engines/deliberation.engine.ts +76 -3
  23. package/src/cognition/agency/engines/motor.schema.executor.ts +509 -32
  24. package/src/cognition/agency/engines/reafference.engine.ts +160 -4
  25. package/src/cognition/agency/execution.primitives.ts +17 -4
  26. package/src/cognition/agency/proactive.communicator.ts +19 -3
  27. package/src/cognition/agency/reconcile.learning.ts +5 -4
  28. package/src/cognition/agency/restart.ts +66 -0
  29. package/src/cognition/agency/schemas/innate.ts +96 -1
  30. package/src/cognition/agency/schemas/repertoire.ts +19 -1
  31. package/src/cognition/agency/selection.scoring.ts +49 -0
  32. package/src/cognition/agency/settlement.ts +203 -0
  33. package/src/cognition/agency/types.ts +73 -1
  34. package/src/cognition/cache/composition.ts +232 -0
  35. package/src/cognition/cache/deliberation.cache.ts +219 -0
  36. package/src/cognition/cache/fingerprint.ts +120 -0
  37. package/src/cognition/cache/types.ts +105 -0
  38. package/src/cognition/config.mirror.entities.ts +108 -1
  39. package/src/cognition/event.schemas.ts +22 -8
  40. package/src/cognition/faculties/affective.blender.ts +21 -4
  41. package/src/cognition/faculties/autobiographical.narrator.ts +5 -10
  42. package/src/cognition/faculties/circadian.oscillator.ts +36 -7
  43. package/src/cognition/faculties/episodic.consolidator.ts +59 -3
  44. package/src/cognition/faculties/executive.engine/action.record.ts +129 -0
  45. package/src/cognition/faculties/executive.engine/commands.ts +189 -14
  46. package/src/cognition/faculties/executive.engine/context.ts +106 -46
  47. package/src/cognition/faculties/executive.engine/deliberate.reasoning.ts +1 -1
  48. package/src/cognition/faculties/executive.engine/engine.ts +714 -136
  49. package/src/cognition/faculties/executive.engine/escalation.buffer.ts +230 -45
  50. package/src/cognition/faculties/executive.engine/facet.supervisor.ts +320 -65
  51. package/src/cognition/faculties/executive.engine/facet.ts +81 -26
  52. package/src/cognition/faculties/executive.engine/gating.ts +14 -14
  53. package/src/cognition/faculties/executive.engine/parser.ts +120 -38
  54. package/src/cognition/faculties/executive.engine/prompt.factory.ts +331 -38
  55. package/src/cognition/faculties/executive.engine/types.ts +84 -12
  56. package/src/cognition/faculties/exteroception.ts +99 -98
  57. package/src/cognition/faculties/goal.manager.ts +144 -16
  58. package/src/cognition/faculties/known.entity.tracker.ts +287 -29
  59. package/src/cognition/faculties/moral.evaluator.ts +8 -3
  60. package/src/cognition/faculties/persona.consolidator.ts +141 -0
  61. package/src/cognition/faculties/planning.engine/engine.ts +4 -4
  62. package/src/cognition/faculties/planning.engine/plan.frontier.ts +1 -1
  63. package/src/cognition/faculties/reputation.tracker.ts +66 -2
  64. package/src/cognition/faculties/self.model.updater.ts +19 -12
  65. package/src/cognition/faculties/semantic.engine/integrator.ts +1 -1
  66. package/src/cognition/faculties/social.perception.ts +47 -3
  67. package/src/cognition/faculties/threat.evaluator.ts +7 -0
  68. package/src/cognition/faculties/working.memory.ts +16 -21
  69. package/src/cognition/identity.entity.ts +205 -0
  70. package/src/cognition/index.ts +7 -0
  71. package/src/cognition/instruction.handler.ts +1 -1
  72. package/src/cognition/memory/vector.adapter.ts +12 -3
  73. package/src/cognition/memory/vector.embedder.ts +45 -4
  74. package/src/cognition/percept.entity.ts +122 -0
  75. package/src/cognition/persona.prior.ts +6 -0
  76. package/src/cognition/sense.boundary.ts +176 -0
  77. package/src/cognition/senses/audition.engine/engine.ts +492 -53
  78. package/src/cognition/senses/base.sense.engine.ts +105 -7
  79. package/src/cognition/senses/index.ts +83 -13
  80. package/src/cognition/senses/provenance.ts +128 -0
  81. package/src/cognition/senses/somatosensation.engine.ts +117 -9
  82. package/src/cognition/social.identity.ts +296 -0
  83. package/src/cognition/utilities/token.tracker.ts +94 -10
  84. package/src/core/orchestrator.ts +51 -0
  85. package/src/index.ts +39 -5
  86. package/src/llm/gate.ts +48 -0
  87. package/src/llm/index.ts +127 -33
  88. package/src/llm/routing.ts +6 -0
  89. package/src/llm/summarizer.ts +1 -1
  90. package/src/llm/wire.contracts.ts +57 -0
  91. package/src/pma/index.ts +68 -54
  92. package/src/stem/assembly.audit.ts +1 -0
  93. package/src/stem/guards/identity.coherence.ts +1 -1
  94. package/src/stem/index.ts +171 -23
  95. package/src/stem/mind.ts +198 -57
  96. package/src/stem/policy/arbiter.ts +10 -0
  97. package/src/stem/profiles/built-in.ts +7 -0
  98. package/src/{profiles → stem/profiles}/companion.ts +1 -1
  99. package/src/{profiles → stem/profiles}/company-brain.ts +1 -1
  100. package/src/{profiles → stem/profiles}/customer-service.ts +1 -1
  101. package/src/{profiles → stem/profiles}/game-npc.ts +1 -1
  102. package/src/{profiles → stem/profiles}/index.ts +1 -1
  103. package/src/{profiles → stem/profiles}/smart-home.ts +1 -1
  104. package/src/stem/tracts/effector/escalation.lifecycle.ts +239 -0
  105. package/src/stem/tracts/effector/policy.enforcement.ts +261 -0
  106. package/src/stem/tracts/effector/types.ts +110 -0
  107. package/src/stem/tracts/effector.controller.ts +112 -401
  108. package/src/stem/tracts/outbox.controller.ts +30 -17
  109. package/src/stem/tracts/outbox.writer.ts +40 -2
  110. package/src/stem/tracts/sensory.controller.ts +6 -6
  111. package/src/stem/tracts/session.logger.ts +6 -1
  112. package/src/stem/tracts/transport/types.ts +20 -1
  113. package/src/stem/tracts/transport.controller.ts +33 -9
  114. package/src/surface/channels/discord.ts +609 -0
  115. package/src/{channels → surface/channels}/roster.ts +1 -1
  116. package/src/surface/channels/types.ts +136 -0
  117. package/src/{channels → surface/channels}/whatsapp.ts +22 -10
  118. package/src/{cli.ts → surface/cli.ts} +14 -9
  119. package/src/{host → surface/host}/boot.ts +3 -3
  120. package/src/{host → surface/host}/utterances.ts +2 -2
  121. package/src/{mcp → surface/mcp}/effectors.ts +19 -6
  122. package/src/{mcp → surface/mcp}/server.ts +19 -5
  123. package/src/{sdk → surface/sdk}/will.ts +194 -33
  124. package/src/{serve → surface/serve}/server.ts +11 -4
  125. package/src/types.ts +24 -3
  126. package/dist/channels/discord.d.ts +0 -69
  127. package/dist/channels/discord.js +0 -193
  128. package/dist/channels/discord.js.map +0 -1
  129. package/dist/channels/whatsapp.js.map +0 -1
  130. package/dist/cli.js.map +0 -1
  131. package/dist/mcp/effectors.js.map +0 -1
  132. package/src/channels/discord.ts +0 -214
  133. package/src/channels/types.ts +0 -46
  134. package/src/cognition/faculties/executive.engine/messages.ts +0 -102
  135. package/src/extensions/livestream.ext.ts +0 -570
  136. package/src/extensions/time.ext.ts +0 -339
  137. package/src/profiles/built-in.ts +0 -7
  138. package/src/runners/coherence.runner.ts +0 -49
  139. package/src/runners/outreach.runner.ts +0 -155
  140. package/src/runners/social.runner.ts +0 -179
  141. package/src/runners/thin-shim.runner.ts +0 -205
  142. /package/dist/{cli.d.ts → surface/cli.d.ts} +0 -0
@@ -0,0 +1,609 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // src/surface/channels/discord.ts — a Will present in a Discord server
3
+ // ─────────────────────────────────────────────────────────────
4
+ //
5
+ // The bridge relays both directions of the paradigm and nothing else:
6
+ //
7
+ // inbound guild/DM message → will.sense({ from, speaker, text, thread })
8
+ // — every author is `discord:<userId>` (stable across guilds), the
9
+ // display name is *learned* by the mind, and each Discord channel
10
+ // is its own conversation thread.
11
+ // outbound will.on('message') → the addressee's last shared channel, else
12
+ // their DM, else the home channel. Proactive utterances (the mind
13
+ // speaking first) route the same way — that is the point.
14
+ //
15
+ // The Will decides when to speak. There is no command prefix and no forced
16
+ // reply: unaddressed chatter is perceived (salience-scored by audition) and
17
+ // silence is a valid outcome. `mentionOnly` narrows perception for busy
18
+ // servers; it does not turn the bridge into an ask() surface.
19
+ //
20
+ // discord.js is imported lazily inside `createDiscordClient` — tests (and any
21
+ // host that brings its own client) inject `client`, and the structural
22
+ // `DiscordLikeClient` type keeps the dependency out of the type graph.
23
+ // ─────────────────────────────────────────────────────────────
24
+
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
+
29
+ const DISCORD_MESSAGE_LIMIT = 2000
30
+
31
+ /**
32
+ * The only hosts the bridge will fetch attachment bodies from.
33
+ *
34
+ * Deliberately an allowlist of Discord's own CDN. An inbound message is
35
+ * untrusted input; following URLs it names would turn perception into an open
36
+ * redirect, and a `url` field is no more trustworthy than the message text.
37
+ */
38
+ const DISCORD_CDN_HOSTS = new Set( [ 'cdn.discordapp.com', 'media.discordapp.net' ] )
39
+
40
+ /** Refuse to pull a large file into a percept — the cap in renderAttachments
41
+ * bounds what is *kept*, this bounds what is fetched at all. */
42
+ const MAX_FETCH_BYTES = 256 * 1024
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
+
77
+ // ── The slice of discord.js the bridge actually uses (structural) ───────────
78
+
79
+ export interface DiscordLikeChannel {
80
+ send( content: string ): Promise<unknown>
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
90
+ }
91
+
92
+ export interface DiscordLikeAttachment {
93
+ name?: string | null
94
+ contentType?: string | null
95
+ size?: number
96
+ url?: string
97
+ }
98
+
99
+ export interface DiscordLikeMessage {
100
+ content: string
101
+ cleanContent?: string
102
+ channelId: string
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
106
+ author: { id: string; bot?: boolean; username?: string; displayName?: string }
107
+ member?: { displayName?: string } | null
108
+ mentions?: { has( userId: string ): boolean }
109
+ channel: DiscordLikeChannel
110
+ /**
111
+ * Files riding with the message.
112
+ *
113
+ * discord.js hands us a `Collection`, which extends `Map` — so iterating it
114
+ * directly yields `[id, attachment]` PAIRS, not attachments. Typing this as a
115
+ * bare `Iterable` was wrong and silently produced `name: undefined` against
116
+ * the real client while passing every test, because the test fake injects an
117
+ * array. Both shapes are accepted now and normalised in `collectAttachments`.
118
+ */
119
+ attachments?: ReadonlyMap<string, DiscordLikeAttachment> | Iterable<DiscordLikeAttachment>
120
+ }
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
+
148
+ export interface DiscordLikeClient {
149
+ user: { id: string; setPresence?( p: unknown ): void } | null
150
+ /** discord.js ≥14.22; polled so we needn't subscribe to the deprecated `ready`. */
151
+ isReady?(): boolean
152
+ on( event: 'messageCreate', fn: ( m: DiscordLikeMessage ) => void ): unknown
153
+ on( event: 'messageReactionAdd', fn: ( r: DiscordLikeReaction, u: DiscordLikeReactor ) => void ): unknown
154
+ once( event: string, fn: () => void ): unknown
155
+ login( token: string ): Promise<unknown>
156
+ destroy(): Promise<unknown> | void
157
+ channels: { fetch( id: string ): Promise<unknown> }
158
+ users: { fetch( id: string ): Promise<{ send( content: string ): Promise<unknown> }> }
159
+ }
160
+
161
+ // ── Options ──────────────────────────────────────────────────────────────────
162
+
163
+ export interface DiscordBridgeOptions {
164
+ /** Bot token (Discord developer portal). Unused when `client` is injected pre-logged-in. */
165
+ token?: string
166
+ /**
167
+ * Channel ids the Will inhabits. Unset — or the single wildcard `'*'` — means
168
+ * every channel it can see, so adding it to a new channel in Discord is enough.
169
+ * A list restrains it to exactly those, and a message anywhere else is dropped
170
+ * at the bridge: the Will never perceives it and its silence there is not a choice.
171
+ */
172
+ channels?: readonly string[]
173
+ /**
174
+ * Where the Will only perceives guild messages that @mention it. DMs are always
175
+ * perceived either way.
176
+ *
177
+ * `true` — everywhere
178
+ * `[ 'id', … ]` — only in those channels; it listens openly elsewhere
179
+ * omitted / `false` — nowhere
180
+ *
181
+ * The list form is what makes a wide-open roster usable: present in every channel,
182
+ * but a quiet participant in the busy ones.
183
+ */
184
+ mentionOnly?: boolean | readonly string[]
185
+ /** Fallback channel for utterances with no reachable addressee. */
186
+ homeChannelId?: string
187
+ /** Roster path (default: ./.will/<willId>.discord.json). */
188
+ rosterPath?: string
189
+ /**
190
+ * Read the contents of text-like attachments (.md, .txt, .json, …) into the
191
+ * percept, rather than only naming them. Default true.
192
+ *
193
+ * Only Discord's own CDN is ever fetched, and only up to a size cap. Set false
194
+ * for a bridge that should never pull remote bytes — the Will still perceives
195
+ * that a file arrived and can ask about it.
196
+ */
197
+ readAttachments?: boolean
198
+ /** Test / power-user seam: bring your own client; discord.js is never imported. */
199
+ client?: DiscordLikeClient
200
+ log?: ( msg: string ) => void
201
+ }
202
+
203
+ // ── The bridge ───────────────────────────────────────────────────────────────
204
+
205
+ /**
206
+ * Connect a Will to Discord. Resolves once the bridge is live (logged in and
207
+ * relaying). Close it via the returned `ChannelBridge.close()` — the Will
208
+ * itself is not stopped; it simply loses this surface.
209
+ */
210
+ export async function connectDiscord( will: Will, opts: DiscordBridgeOptions ): Promise<ChannelBridge> {
211
+ const log = opts.log ?? ( ( m: string ) => console.error(`[will:discord] ${ m }`) )
212
+ const roster = new ChannelRoster( opts.rosterPath ?? `.will/${ will.id }.discord.json`)
213
+ // `null` = everywhere. An explicit '*' reads the same as omitting the list, so a
214
+ // host can say "all channels" out loud instead of by leaving a variable blank.
215
+ const allowed = opts.channels?.length && !opts.channels.includes('*')
216
+ ? new Set( opts.channels )
217
+ : null
218
+
219
+ // Mention-gating is either global (true) or scoped to named channels.
220
+ const mentionEverywhere = opts.mentionOnly === true
221
+ const mentionIn = Array.isArray( opts.mentionOnly ) && opts.mentionOnly.length
222
+ ? new Set( opts.mentionOnly )
223
+ : null
224
+
225
+ const client = opts.client ?? await createDiscordClient()
226
+
227
+ /** The most recently active allowed channel — last-resort proactive target. */
228
+ let lastActiveChannelId: string | null = opts.homeChannelId ?? null
229
+
230
+ // ── inbound: platform message → stimulus ──────────────────────────────────
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
+ }
296
+
297
+ async function onMessage( message: DiscordLikeMessage ): Promise<void> {
298
+ const self = client.user
299
+ if( !self || message.author.id === self.id || message.author.bot ) return
300
+
301
+ const isDM = !message.guildId
302
+ if( !isDM && allowed && !allowed.has( message.channelId ) ) return
303
+
304
+ const addressed = isDM || ( message.mentions?.has( self.id ) ?? false )
305
+ // DMs are addressed by definition, so this never gates them.
306
+ if( !addressed && ( mentionEverywhere || mentionIn?.has( message.channelId ) ) ) return
307
+
308
+ const entityId = `discord:${ message.author.id }`
309
+ const speaker = message.member?.displayName ?? message.author.displayName ?? message.author.username
310
+
311
+ roster.record( {
312
+ entityId,
313
+ userId: message.author.id,
314
+ ...( speaker ? { displayName: speaker } : {} ),
315
+ ...( isDM ? { dmChannelId: message.channelId } : { lastChannelId: message.channelId } ),
316
+ } )
317
+ if( !isDM ) lastActiveChannelId = message.channelId
318
+
319
+ // Being addressed is the one moment a presence cue is honest — the mind
320
+ // may still choose silence, and typing expires on its own.
321
+ if( addressed ) await message.channel.sendTyping?.().catch( () => {} )
322
+
323
+ const said = ( message.cleanContent || message.content ).trim()
324
+ const files = collectAttachments( message )
325
+
326
+ // An attachment-only message used to die here on the empty body: no percept,
327
+ // no log line, nothing. From the mind's side the person had simply gone
328
+ // quiet — and Discord *makes* these, turning a long pasted markdown block
329
+ // into a .md upload. Only a message with neither words nor files is nothing.
330
+ if( !said && files.length === 0 ) return
331
+
332
+ const shared = await renderAttachments(
333
+ files, speaker,
334
+ opts.readAttachments === false ? undefined : fetchAttachmentText,
335
+ )
336
+ const text = [ said, shared ].filter( Boolean ).join('\n')
337
+
338
+ await will.sense( {
339
+ text,
340
+ from: entityId,
341
+ thread: `discord:${ message.channelId }`,
342
+ // `isDM` has been computed on every inbound since this bridge shipped and
343
+ // used only to pick a roster field. It is the one fact that makes a room
344
+ // the right or wrong place to say something, and the mind never saw it —
345
+ // which is how a follow-up promised in a DM went out to #general.
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 ) } : {} ),
353
+ ...( speaker ? { speaker } : {} ),
354
+ } )
355
+ }
356
+
357
+ /**
358
+ * Normalise whatever the client gave us into attachments.
359
+ *
360
+ * `.values()` first: a discord.js Collection is a Map, so `for..of` over it
361
+ * yields `[id, attachment]` pairs and every field reads `undefined`. Arrays
362
+ * expose `.values()` too and yield their elements, so one branch covers the
363
+ * real client, a plain array, and a Map alike.
364
+ */
365
+ function collectAttachments( message: DiscordLikeMessage ): ChannelAttachment[] {
366
+ if( !message.attachments ) return []
367
+ const source = message.attachments as { values?: () => Iterable<DiscordLikeAttachment> }
368
+ const items: Iterable<DiscordLikeAttachment> = typeof source.values === 'function'
369
+ ? source.values()
370
+ : message.attachments as Iterable<DiscordLikeAttachment>
371
+
372
+ const out: ChannelAttachment[] = []
373
+ for( const a of items )
374
+ out.push( {
375
+ name: a.name ?? 'unnamed',
376
+ ...( a.contentType ? { contentType: a.contentType } : {} ),
377
+ ...( a.size != null ? { size: a.size } : {} ),
378
+ ...( a.url ? { url: a.url } : {} ),
379
+ } )
380
+ return out
381
+ }
382
+
383
+ /** Fetch one text attachment — Discord CDN only, size-capped. */
384
+ async function fetchAttachmentText( a: ChannelAttachment ): Promise<string | null> {
385
+ if( !a.url || !isTextual( a ) ) return null
386
+ let host: string
387
+ try { host = new URL( a.url ).hostname }
388
+ catch { return null }
389
+ if( !DISCORD_CDN_HOSTS.has( host ) ){
390
+ log(`refusing to fetch attachment '${ a.name }' from non-CDN host ${ host }`)
391
+ return null
392
+ }
393
+ if( a.size != null && a.size > MAX_FETCH_BYTES ){
394
+ log(`attachment '${ a.name }' is ${ a.size } bytes — naming it without reading`)
395
+ return null
396
+ }
397
+ const res = await fetch( a.url, { signal: AbortSignal.timeout( 10_000 ) } )
398
+ if( !res.ok ){
399
+ log(`attachment '${ a.name }' fetch failed: ${ res.status }`)
400
+ return null
401
+ }
402
+ return ( await res.text() ).slice( 0, MAX_FETCH_BYTES )
403
+ }
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
+
470
+ // ── outbound: projected utterance → the addressee ─────────────────────────
471
+ // The facade has no off(); the bridge gates its handler on `closed` instead.
472
+ let closed = false
473
+ will.on('message', ( m: WillMessage ) => { if( !closed ) void deliver( m ) } )
474
+
475
+ async function deliver( m: WillMessage ): Promise<void> {
476
+ const peer = m.to ? roster.resolve( m.to ) : undefined
477
+ const chunks = chunkText( m.content, DISCORD_MESSAGE_LIMIT )
478
+
479
+ // A reply goes back to the room it was said in. `m.thread` is the thread from
480
+ // the `perceive()` that prompted this — `discord:<channelId>` — so it is not a
481
+ // guess about where this person usually is, it is where they just spoke.
482
+ //
483
+ // Everything below it IS a guess, and the guesses were wrong in the way that
484
+ // matters most: a DM arrived, she answered it in seconds, and the answer went
485
+ // to the shared server channel because `lastChannelId` still held the last
486
+ // room they had been in together. She looked like she was ignoring him.
487
+ //
488
+ // Unprompted utterances carry no thread — nothing was said to them — so those
489
+ // still fall through to the roster, which is the right behaviour there.
490
+ const replyTo = m.thread?.startsWith('discord:') ? m.thread.slice('discord:'.length ) : undefined
491
+
492
+ // Preference order: the room they spoke in → where we last shared a room →
493
+ // their DM → home channel.
494
+ const channelIds = [ replyTo, peer?.lastChannelId, peer?.dmChannelId, opts.homeChannelId ?? undefined, lastActiveChannelId ?? undefined ]
495
+ for( const id of channelIds ){
496
+ if( !id ) continue
497
+ try {
498
+ const channel = await client.channels.fetch( id ) as DiscordLikeChannel | null
499
+ if( !channel?.send ) continue
500
+ for( const chunk of chunks ) await channel.send( chunk )
501
+ return
502
+ }
503
+ catch { /* try the next route */ }
504
+ }
505
+ if( peer ){
506
+ try {
507
+ const user = await client.users.fetch( peer.userId )
508
+ for( const chunk of chunks ) await user.send( chunk )
509
+ return
510
+ }
511
+ catch { /* fall through */ }
512
+ }
513
+ log(`no route for utterance to '${ m.to }' — dropped (${ m.content.length } chars)`)
514
+ }
515
+
516
+ // ── lifecycle ──────────────────────────────────────────────────────────────
517
+ const bridge: ChannelBridge = {
518
+ kind: 'discord',
519
+ async start(): Promise<void> {
520
+ if( !client.user ){
521
+ // discord.js ≥14.22 renamed `ready` → `clientReady`. Subscribing to the
522
+ // old name is what triggers its DeprecationWarning, so we take the new
523
+ // name and poll `isReady()` for older builds rather than listening.
524
+ const ready = new Promise<void>( resolve => {
525
+ let poll: ReturnType<typeof setInterval> | null = null
526
+ const done = (): void => { if( poll ) clearInterval( poll ); resolve() }
527
+ client.once('clientReady', done )
528
+ poll = setInterval( () => { if( client.isReady?.() ) done() }, 100 )
529
+ poll.unref?.()
530
+ } )
531
+ await client.login( opts.token ?? '')
532
+ await ready
533
+ }
534
+ log(`${ will.name } is present on Discord as user ${ client.user?.id }`)
535
+ },
536
+ async close(): Promise<void> {
537
+ if( closed ) return
538
+ closed = true
539
+ roster.flush()
540
+ await Promise.resolve( client.destroy() ).catch( () => {} )
541
+ },
542
+ }
543
+ return bridge
544
+ }
545
+
546
+ /** Build a real discord.js client (lazy import keeps it out of non-Discord hosts). */
547
+ async function createDiscordClient(): Promise<DiscordLikeClient> {
548
+ let mod: typeof import('discord.js')
549
+ try { mod = await import('discord.js') }
550
+ catch {
551
+ throw new Error('discord.js is not installed (it is an optionalDependency) — run `bun add discord.js` / `npm i discord.js` and retry.')
552
+ }
553
+ const { Client, GatewayIntentBits, Partials } = mod
554
+ return new Client( {
555
+ intents: [
556
+ GatewayIntentBits.Guilds,
557
+ GatewayIntentBits.GuildMessages,
558
+ GatewayIntentBits.MessageContent,
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,
573
+ ],
574
+ } ) as unknown as DiscordLikeClient
575
+ }
576
+
577
+ /**
578
+ * Parse `WILL_DISCORD_MENTION_ONLY` into the `mentionOnly` option.
579
+ *
580
+ * Accepts a boolean OR a channel list, because "only speak when spoken to" is
581
+ * rarely a whole-server property — it is how you stay present in a busy channel
582
+ * without narrating in it.
583
+ *
584
+ * `1` / `true` / `yes` → true (everywhere)
585
+ * `0` / `false` / unset → false (nowhere)
586
+ * `123,456` → only those channels
587
+ *
588
+ * Exported so every host parses it identically; the CLI and any SDK host share
589
+ * this rather than each re-deriving the syntax.
590
+ */
591
+ export function parseMentionOnly( raw?: string ): boolean | string[] {
592
+ const v = raw?.trim()
593
+ if( !v ) return false
594
+ if( /^(1|true|yes)$/i.test( v ) ) return true
595
+ if( /^(0|false|no)$/i.test( v ) ) return false
596
+
597
+ const ids = v.split(',').map( s => s.trim() ).filter( Boolean )
598
+ return ids.length ? ids : false
599
+ }
600
+
601
+ /**
602
+ * Parse `WILL_DISCORD_CHANNELS`. `*` (or unset/empty) means every channel the Will
603
+ * can see — being added to a channel in Discord is then all it takes. Anything else
604
+ * restrains it to exactly the ids listed.
605
+ */
606
+ export function parseChannels( raw?: string ): string[] | undefined {
607
+ const ids = raw?.split(',').map( s => s.trim() ).filter( Boolean )
608
+ return ids?.length ? ids : undefined
609
+ }
@@ -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