@mindot/will 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +87 -22
  2. package/dist/channels/discord.d.ts +67 -6
  3. package/dist/channels/discord.js +112 -6
  4. package/dist/channels/discord.js.map +1 -1
  5. package/dist/channels/whatsapp.d.ts +1 -1
  6. package/dist/channels/whatsapp.js +4 -1
  7. package/dist/channels/whatsapp.js.map +1 -1
  8. package/dist/cli.js +13733 -11057
  9. package/dist/cli.js.map +1 -1
  10. package/dist/index.d.ts +2 -2
  11. package/dist/index.js +3729 -1199
  12. package/dist/index.js.map +1 -1
  13. package/dist/mcp/effectors.d.ts +1 -1
  14. package/dist/{will-DAW0l-lY.d.ts → will-DbDj_TEH.d.ts} +1214 -93
  15. package/package.json +1 -1
  16. package/src/channels/discord.ts +189 -11
  17. package/src/channels/types.ts +90 -0
  18. package/src/channels/whatsapp.ts +13 -4
  19. package/src/cli.ts +9 -4
  20. package/src/cognition/agency/consequence.ts +122 -1
  21. package/src/cognition/agency/conversation.aim.ts +260 -0
  22. package/src/cognition/agency/engines/action.selector.ts +85 -3
  23. package/src/cognition/agency/engines/affordance.synthesizer.ts +90 -1
  24. package/src/cognition/agency/engines/motor.schema.executor.ts +152 -10
  25. package/src/cognition/agency/engines/reafference.engine.ts +129 -2
  26. package/src/cognition/agency/proactive.communicator.ts +19 -3
  27. package/src/cognition/agency/reconcile.learning.ts +16 -2
  28. package/src/cognition/agency/restart.ts +66 -0
  29. package/src/cognition/agency/schemas/repertoire.ts +12 -5
  30. package/src/cognition/agency/selection.scoring.ts +33 -0
  31. package/src/cognition/agency/types.ts +35 -0
  32. package/src/cognition/cache/composition.ts +232 -0
  33. package/src/cognition/cache/deliberation.cache.ts +219 -0
  34. package/src/cognition/cache/fingerprint.ts +120 -0
  35. package/src/cognition/cache/types.ts +105 -0
  36. package/src/cognition/config.mirror.entities.ts +109 -1
  37. package/src/cognition/event.schemas.ts +22 -0
  38. package/src/cognition/faculties/autobiographical.narrator.ts +5 -10
  39. package/src/cognition/faculties/episodic.consolidator.ts +59 -3
  40. package/src/cognition/faculties/executive.engine/commands.ts +189 -14
  41. package/src/cognition/faculties/executive.engine/context.ts +67 -13
  42. package/src/cognition/faculties/executive.engine/deliberate.reasoning.ts +1 -1
  43. package/src/cognition/faculties/executive.engine/engine.ts +686 -187
  44. package/src/cognition/faculties/executive.engine/escalation.buffer.ts +162 -44
  45. package/src/cognition/faculties/executive.engine/facet.supervisor.ts +310 -65
  46. package/src/cognition/faculties/executive.engine/facet.ts +89 -26
  47. package/src/cognition/faculties/executive.engine/gating.ts +14 -14
  48. package/src/cognition/faculties/executive.engine/parser.ts +21 -1
  49. package/src/cognition/faculties/executive.engine/prompt.factory.ts +169 -20
  50. package/src/cognition/faculties/executive.engine/types.ts +69 -0
  51. package/src/cognition/faculties/goal.manager.ts +94 -14
  52. package/src/cognition/faculties/known.entity.tracker.ts +267 -28
  53. package/src/cognition/faculties/moral.evaluator.ts +8 -3
  54. package/src/cognition/faculties/persona.consolidator.ts +141 -0
  55. package/src/cognition/faculties/reputation.tracker.ts +66 -2
  56. package/src/cognition/faculties/self.model.updater.ts +19 -12
  57. package/src/cognition/faculties/social.perception.ts +47 -3
  58. package/src/cognition/faculties/threat.evaluator.ts +7 -0
  59. package/src/cognition/faculties/working.memory.ts +10 -20
  60. package/src/cognition/identity.entity.ts +205 -0
  61. package/src/cognition/index.ts +11 -0
  62. package/src/cognition/memory/vector.adapter.ts +12 -3
  63. package/src/cognition/memory/vector.embedder.ts +54 -7
  64. package/src/cognition/persona.prior.ts +6 -0
  65. package/src/cognition/senses/audition.engine/engine.ts +404 -46
  66. package/src/cognition/senses/base.sense.engine.ts +1 -1
  67. package/src/cognition/senses/index.ts +12 -0
  68. package/src/cognition/social.identity.ts +273 -0
  69. package/src/cognition/utilities/token.tracker.ts +246 -98
  70. package/src/core/orchestrator.ts +38 -0
  71. package/src/host/boot.ts +78 -22
  72. package/src/index.ts +35 -0
  73. package/src/llm/index.ts +415 -97
  74. package/src/llm/routing.ts +204 -0
  75. package/src/llm/summarizer.ts +5 -1
  76. package/src/llm/wire.contracts.ts +19 -0
  77. package/src/pma/index.ts +67 -53
  78. package/src/runners/thin-shim.runner.ts +18 -6
  79. package/src/sdk/will.ts +121 -22
  80. package/src/stem/assembly.audit.ts +1 -0
  81. package/src/stem/guards/identity.coherence.ts +17 -6
  82. package/src/stem/index.ts +82 -5
  83. package/src/stem/mind.ts +327 -79
  84. package/src/stem/policy/arbiter.ts +49 -14
  85. package/src/stem/policy/rule.table.ts +2 -2
  86. package/src/stem/tracts/effector.controller.ts +56 -9
  87. package/src/stem/tracts/outbox.writer.ts +40 -2
  88. package/src/cognition/faculties/executive.engine/messages.ts +0 -102
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mindot/will",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "author": "Fabrice <fabrice8@github.com>",
5
5
  "repository": {
6
6
  "type": "git",
@@ -24,10 +24,23 @@
24
24
 
25
25
  import type { Will, WillMessage } from '#sdk/will'
26
26
  import { ChannelRoster } from '#channels/roster'
27
- import { chunkText, type ChannelBridge } from '#channels/types'
27
+ import { chunkText, renderAttachments, isTextual, type ChannelBridge, type ChannelAttachment } from '#channels/types'
28
28
 
29
29
  const DISCORD_MESSAGE_LIMIT = 2000
30
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
+
31
44
  // ── The slice of discord.js the bridge actually uses (structural) ───────────
32
45
 
33
46
  export interface DiscordLikeChannel {
@@ -35,6 +48,13 @@ export interface DiscordLikeChannel {
35
48
  sendTyping?(): Promise<unknown>
36
49
  }
37
50
 
51
+ export interface DiscordLikeAttachment {
52
+ name?: string | null
53
+ contentType?: string | null
54
+ size?: number
55
+ url?: string
56
+ }
57
+
38
58
  export interface DiscordLikeMessage {
39
59
  content: string
40
60
  cleanContent?: string
@@ -44,6 +64,16 @@ export interface DiscordLikeMessage {
44
64
  member?: { displayName?: string } | null
45
65
  mentions?: { has( userId: string ): boolean }
46
66
  channel: DiscordLikeChannel
67
+ /**
68
+ * Files riding with the message.
69
+ *
70
+ * discord.js hands us a `Collection`, which extends `Map` — so iterating it
71
+ * directly yields `[id, attachment]` PAIRS, not attachments. Typing this as a
72
+ * bare `Iterable` was wrong and silently produced `name: undefined` against
73
+ * the real client while passing every test, because the test fake injects an
74
+ * array. Both shapes are accepted now and normalised in `collectAttachments`.
75
+ */
76
+ attachments?: ReadonlyMap<string, DiscordLikeAttachment> | Iterable<DiscordLikeAttachment>
47
77
  }
48
78
 
49
79
  export interface DiscordLikeClient {
@@ -63,14 +93,38 @@ export interface DiscordLikeClient {
63
93
  export interface DiscordBridgeOptions {
64
94
  /** Bot token (Discord developer portal). Unused when `client` is injected pre-logged-in. */
65
95
  token?: string
66
- /** Channel ids the Will inhabits. Unset = every channel it can see. */
67
- channels?: string[]
68
- /** Perceive guild messages only when the Will is @mentioned (DMs always perceived). */
69
- mentionOnly?: boolean
96
+ /**
97
+ * Channel ids the Will inhabits. Unset — or the single wildcard `'*'` — means
98
+ * every channel it can see, so adding it to a new channel in Discord is enough.
99
+ * A list restrains it to exactly those, and a message anywhere else is dropped
100
+ * at the bridge: the Will never perceives it and its silence there is not a choice.
101
+ */
102
+ channels?: readonly string[]
103
+ /**
104
+ * Where the Will only perceives guild messages that @mention it. DMs are always
105
+ * perceived either way.
106
+ *
107
+ * `true` — everywhere
108
+ * `[ 'id', … ]` — only in those channels; it listens openly elsewhere
109
+ * omitted / `false` — nowhere
110
+ *
111
+ * The list form is what makes a wide-open roster usable: present in every channel,
112
+ * but a quiet participant in the busy ones.
113
+ */
114
+ mentionOnly?: boolean | readonly string[]
70
115
  /** Fallback channel for utterances with no reachable addressee. */
71
116
  homeChannelId?: string
72
117
  /** Roster path (default: ./.will/<willId>.discord.json). */
73
118
  rosterPath?: string
119
+ /**
120
+ * Read the contents of text-like attachments (.md, .txt, .json, …) into the
121
+ * percept, rather than only naming them. Default true.
122
+ *
123
+ * Only Discord's own CDN is ever fetched, and only up to a size cap. Set false
124
+ * for a bridge that should never pull remote bytes — the Will still perceives
125
+ * that a file arrived and can ask about it.
126
+ */
127
+ readAttachments?: boolean
74
128
  /** Test / power-user seam: bring your own client; discord.js is never imported. */
75
129
  client?: DiscordLikeClient
76
130
  log?: ( msg: string ) => void
@@ -86,7 +140,17 @@ export interface DiscordBridgeOptions {
86
140
  export async function connectDiscord( will: Will, opts: DiscordBridgeOptions ): Promise<ChannelBridge> {
87
141
  const log = opts.log ?? ( ( m: string ) => console.error(`[will:discord] ${ m }`) )
88
142
  const roster = new ChannelRoster( opts.rosterPath ?? `.will/${ will.id }.discord.json`)
89
- const allowed = opts.channels?.length ? new Set( opts.channels ) : null
143
+ // `null` = everywhere. An explicit '*' reads the same as omitting the list, so a
144
+ // host can say "all channels" out loud instead of by leaving a variable blank.
145
+ const allowed = opts.channels?.length && !opts.channels.includes('*')
146
+ ? new Set( opts.channels )
147
+ : null
148
+
149
+ // Mention-gating is either global (true) or scoped to named channels.
150
+ const mentionEverywhere = opts.mentionOnly === true
151
+ const mentionIn = Array.isArray( opts.mentionOnly ) && opts.mentionOnly.length
152
+ ? new Set( opts.mentionOnly )
153
+ : null
90
154
 
91
155
  const client = opts.client ?? await createDiscordClient()
92
156
 
@@ -104,7 +168,8 @@ export async function connectDiscord( will: Will, opts: DiscordBridgeOptions ):
104
168
  if( !isDM && allowed && !allowed.has( message.channelId ) ) return
105
169
 
106
170
  const addressed = isDM || ( message.mentions?.has( self.id ) ?? false )
107
- if( opts.mentionOnly && !addressed ) return
171
+ // DMs are addressed by definition, so this never gates them.
172
+ if( !addressed && ( mentionEverywhere || mentionIn?.has( message.channelId ) ) ) return
108
173
 
109
174
  const entityId = `discord:${ message.author.id }`
110
175
  const speaker = message.member?.displayName ?? message.author.displayName ?? message.author.username
@@ -121,17 +186,82 @@ export async function connectDiscord( will: Will, opts: DiscordBridgeOptions ):
121
186
  // may still choose silence, and typing expires on its own.
122
187
  if( addressed ) await message.channel.sendTyping?.().catch( () => {} )
123
188
 
124
- const text = message.cleanContent || message.content
125
- if( !text.trim() ) return
189
+ const said = ( message.cleanContent || message.content ).trim()
190
+ const files = collectAttachments( message )
191
+
192
+ // An attachment-only message used to die here on the empty body: no percept,
193
+ // no log line, nothing. From the mind's side the person had simply gone
194
+ // quiet — and Discord *makes* these, turning a long pasted markdown block
195
+ // into a .md upload. Only a message with neither words nor files is nothing.
196
+ if( !said && files.length === 0 ) return
197
+
198
+ const shared = await renderAttachments(
199
+ files, speaker,
200
+ opts.readAttachments === false ? undefined : fetchAttachmentText,
201
+ )
202
+ const text = [ said, shared ].filter( Boolean ).join('\n')
126
203
 
127
204
  await will.perceive( {
128
205
  text,
129
206
  from: entityId,
130
207
  thread: `discord:${ message.channelId }`,
208
+ // `isDM` has been computed on every inbound since this bridge shipped and
209
+ // used only to pick a roster field. It is the one fact that makes a room
210
+ // the right or wrong place to say something, and the mind never saw it —
211
+ // which is how a follow-up promised in a DM went out to #general.
212
+ direct: isDM,
131
213
  ...( speaker ? { speaker } : {} ),
132
214
  } )
133
215
  }
134
216
 
217
+ /**
218
+ * Normalise whatever the client gave us into attachments.
219
+ *
220
+ * `.values()` first: a discord.js Collection is a Map, so `for..of` over it
221
+ * yields `[id, attachment]` pairs and every field reads `undefined`. Arrays
222
+ * expose `.values()` too and yield their elements, so one branch covers the
223
+ * real client, a plain array, and a Map alike.
224
+ */
225
+ function collectAttachments( message: DiscordLikeMessage ): ChannelAttachment[] {
226
+ if( !message.attachments ) return []
227
+ const source = message.attachments as { values?: () => Iterable<DiscordLikeAttachment> }
228
+ const items: Iterable<DiscordLikeAttachment> = typeof source.values === 'function'
229
+ ? source.values()
230
+ : message.attachments as Iterable<DiscordLikeAttachment>
231
+
232
+ const out: ChannelAttachment[] = []
233
+ for( const a of items )
234
+ out.push( {
235
+ name: a.name ?? 'unnamed',
236
+ ...( a.contentType ? { contentType: a.contentType } : {} ),
237
+ ...( a.size != null ? { size: a.size } : {} ),
238
+ ...( a.url ? { url: a.url } : {} ),
239
+ } )
240
+ return out
241
+ }
242
+
243
+ /** Fetch one text attachment — Discord CDN only, size-capped. */
244
+ async function fetchAttachmentText( a: ChannelAttachment ): Promise<string | null> {
245
+ if( !a.url || !isTextual( a ) ) return null
246
+ let host: string
247
+ try { host = new URL( a.url ).hostname }
248
+ catch { return null }
249
+ if( !DISCORD_CDN_HOSTS.has( host ) ){
250
+ log(`refusing to fetch attachment '${ a.name }' from non-CDN host ${ host }`)
251
+ return null
252
+ }
253
+ if( a.size != null && a.size > MAX_FETCH_BYTES ){
254
+ log(`attachment '${ a.name }' is ${ a.size } bytes — naming it without reading`)
255
+ return null
256
+ }
257
+ const res = await fetch( a.url, { signal: AbortSignal.timeout( 10_000 ) } )
258
+ if( !res.ok ){
259
+ log(`attachment '${ a.name }' fetch failed: ${ res.status }`)
260
+ return null
261
+ }
262
+ return ( await res.text() ).slice( 0, MAX_FETCH_BYTES )
263
+ }
264
+
135
265
  // ── outbound: projected utterance → the addressee ─────────────────────────
136
266
  // The facade has no off(); the bridge gates its handler on `closed` instead.
137
267
  let closed = false
@@ -141,8 +271,22 @@ export async function connectDiscord( will: Will, opts: DiscordBridgeOptions ):
141
271
  const peer = m.to ? roster.resolve( m.to ) : undefined
142
272
  const chunks = chunkText( m.content, DISCORD_MESSAGE_LIMIT )
143
273
 
144
- // Preference order: where we last shared a room their DM home channel.
145
- const channelIds = [ peer?.lastChannelId, peer?.dmChannelId, opts.homeChannelId ?? undefined, lastActiveChannelId ?? undefined ]
274
+ // A reply goes back to the room it was said in. `m.thread` is the thread from
275
+ // the `perceive()` that prompted this `discord:<channelId>` so it is not a
276
+ // guess about where this person usually is, it is where they just spoke.
277
+ //
278
+ // Everything below it IS a guess, and the guesses were wrong in the way that
279
+ // matters most: a DM arrived, she answered it in seconds, and the answer went
280
+ // to the shared server channel because `lastChannelId` still held the last
281
+ // room they had been in together. She looked like she was ignoring him.
282
+ //
283
+ // Unprompted utterances carry no thread — nothing was said to them — so those
284
+ // still fall through to the roster, which is the right behaviour there.
285
+ const replyTo = m.thread?.startsWith('discord:') ? m.thread.slice('discord:'.length ) : undefined
286
+
287
+ // Preference order: the room they spoke in → where we last shared a room →
288
+ // their DM → home channel.
289
+ const channelIds = [ replyTo, peer?.lastChannelId, peer?.dmChannelId, opts.homeChannelId ?? undefined, lastActiveChannelId ?? undefined ]
146
290
  for( const id of channelIds ){
147
291
  if( !id ) continue
148
292
  try {
@@ -212,3 +356,37 @@ async function createDiscordClient(): Promise<DiscordLikeClient> {
212
356
  partials: [ Partials.Channel ], // DMs arrive on uncached channels
213
357
  } ) as unknown as DiscordLikeClient
214
358
  }
359
+
360
+ /**
361
+ * Parse `WILL_DISCORD_MENTION_ONLY` into the `mentionOnly` option.
362
+ *
363
+ * Accepts a boolean OR a channel list, because "only speak when spoken to" is
364
+ * rarely a whole-server property — it is how you stay present in a busy channel
365
+ * without narrating in it.
366
+ *
367
+ * `1` / `true` / `yes` → true (everywhere)
368
+ * `0` / `false` / unset → false (nowhere)
369
+ * `123,456` → only those channels
370
+ *
371
+ * Exported so every host parses it identically; the CLI and any SDK host share
372
+ * this rather than each re-deriving the syntax.
373
+ */
374
+ export function parseMentionOnly( raw?: string ): boolean | string[] {
375
+ const v = raw?.trim()
376
+ if( !v ) return false
377
+ if( /^(1|true|yes)$/i.test( v ) ) return true
378
+ if( /^(0|false|no)$/i.test( v ) ) return false
379
+
380
+ const ids = v.split(',').map( s => s.trim() ).filter( Boolean )
381
+ return ids.length ? ids : false
382
+ }
383
+
384
+ /**
385
+ * Parse `WILL_DISCORD_CHANNELS`. `*` (or unset/empty) means every channel the Will
386
+ * can see — being added to a channel in Discord is then all it takes. Anything else
387
+ * restrains it to exactly the ids listed.
388
+ */
389
+ export function parseChannels( raw?: string ): string[] | undefined {
390
+ const ids = raw?.split(',').map( s => s.trim() ).filter( Boolean )
391
+ return ids?.length ? ids : undefined
392
+ }
@@ -28,6 +28,96 @@ export interface ChannelBridge {
28
28
  close(): Promise<void>
29
29
  }
30
30
 
31
+ // ── Attachments ──────────────────────────────────────────────────────────────
32
+ //
33
+ // People hand over documents as well as speech, and some platforms *manufacture*
34
+ // them: Discord silently turns a long pasted markdown block into a `.md` upload.
35
+ // A bridge that reads only the text body sees such a message as empty and — worse
36
+ // — as nothing at all, so the person appears to have gone silent.
37
+ //
38
+ // What a bridge does with these is deliberately modest. A named-but-unread file
39
+ // is already a percept the Will can act on ("what's in it?"), which is the
40
+ // paradigm-correct outcome and strictly better than silence. Inlining text is an
41
+ // upgrade on top, never a precondition.
42
+
43
+ /** One file riding along with a platform message. */
44
+ export interface ChannelAttachment {
45
+ name: string
46
+ contentType?: string
47
+ size?: number
48
+ url?: string
49
+ }
50
+
51
+ /** Per-attachment inline budget. A 2 MB doc must not enter working memory whole. */
52
+ const INLINE_CHAR_CAP = 24_000
53
+ /** How many text attachments to inline from one message. */
54
+ const INLINE_COUNT_CAP = 4
55
+
56
+ const TEXTUAL_EXT = /\.(md|markdown|txt|text|json|jsonl|csv|tsv|ya?ml|log|ini|toml)$/i
57
+
58
+ /** Is this something we can meaningfully read as text? */
59
+ export function isTextual( a: ChannelAttachment ): boolean {
60
+ const ct = a.contentType?.split(';')[0]?.trim().toLowerCase() ?? ''
61
+ if( ct.startsWith('text/') ) return true
62
+ if( ct === 'application/json' || ct === 'application/x-yaml' ) return true
63
+ // Discord's own markdown uploads arrive as text/plain, but trust the extension
64
+ // too — content types from platforms are advisory at best.
65
+ return TEXTUAL_EXT.test( a.name )
66
+ }
67
+
68
+ function humanSize( bytes?: number ): string {
69
+ if( bytes == null ) return ''
70
+ return bytes < 1024 ? `${ bytes } B`
71
+ : bytes < 1024 * 1024 ? `${ ( bytes / 1024 ).toFixed( 1 ) } KB`
72
+ : `${ ( bytes / 1024 / 1024 ).toFixed( 1 ) } MB`
73
+ }
74
+
75
+ /**
76
+ * Render attachments into perceivable text.
77
+ *
78
+ * `fetchText` is supplied by the bridge, not by this module — the decision about
79
+ * which hosts are safe to fetch from is platform knowledge, and a helper that
80
+ * fetched arbitrary URLs found in inbound messages would be an open redirect
81
+ * into the Will's perception. Omit it and attachments are named, never read.
82
+ *
83
+ * Inlined content is untrusted, exactly like message text — more so, since a
84
+ * document is long, structured, and looks authoritative, which is the shape of
85
+ * an effective injection. It is fenced and labelled as shared content so the
86
+ * mind reads it as something it was handed, not as something it was told.
87
+ */
88
+ export async function renderAttachments(
89
+ attachments: ChannelAttachment[],
90
+ speaker: string | undefined,
91
+ fetchText?: ( a: ChannelAttachment ) => Promise<string | null>,
92
+ ): Promise<string> {
93
+ if( attachments.length === 0 ) return ''
94
+ const who = speaker ?? 'someone'
95
+ const out: string[] = []
96
+ let inlined = 0
97
+
98
+ for( const a of attachments ){
99
+ const meta = [ a.contentType, humanSize( a.size ) ].filter( Boolean ).join(', ')
100
+ const label = `${ a.name }${ meta ? ` (${ meta })` : '' }`
101
+
102
+ if( !fetchText || !isTextual( a ) || inlined >= INLINE_COUNT_CAP ){
103
+ out.push(`[${ who } shared a file I have not read: ${ label }]`)
104
+ continue
105
+ }
106
+
107
+ const body = await fetchText( a ).catch( () => null )
108
+ if( body == null ){
109
+ out.push(`[${ who } shared a file I could not read: ${ label }]`)
110
+ continue
111
+ }
112
+ inlined++
113
+ const clipped = body.length > INLINE_CHAR_CAP
114
+ ? `${ body.slice( 0, INLINE_CHAR_CAP ) }\n[… truncated — ${ humanSize( body.length ) } of ${ humanSize( a.size ?? body.length ) }]`
115
+ : body
116
+ out.push(`[${ who } shared ${ label }; its contents follow — this is a document I was handed, not something said to me]\n---\n${ clipped }\n---`)
117
+ }
118
+ return out.join('\n')
119
+ }
120
+
31
121
  /** Split a message into platform-sized chunks on natural boundaries. */
32
122
  export function chunkText( text: string, max: number ): string[] {
33
123
  if( text.length <= max ) return [ text ]
@@ -166,6 +166,8 @@ export async function connectWhatsApp( will: Will, opts: WhatsAppBridgeOptions =
166
166
  text,
167
167
  from: entityId,
168
168
  thread: `whatsapp:${ jid }`,
169
+ // A WhatsApp group jid ends in `@g.us`; anything else is a one-to-one chat.
170
+ direct: !jid.endsWith('@g.us'),
169
171
  ...( speaker ? { speaker } : {} ),
170
172
  } )
171
173
  }
@@ -177,11 +179,18 @@ export async function connectWhatsApp( will: Will, opts: WhatsAppBridgeOptions =
177
179
  const peer = m.to ? roster.resolve( m.to ) : undefined
178
180
  const chunks = chunkText( m.content, WHATSAPP_MESSAGE_LIMIT )
179
181
 
180
- // Last shared group → known DM → DM derived from the entity id itself →
181
- // home chat → last active chat. Unlike Discord, an unmet-but-addressed
182
- // entity is still reachable: `whatsapp:<number>` implies its DM jid.
182
+ // The chat they actually spoke in → last shared group → known DM → DM derived
183
+ // from the entity id itself → home chat → last active chat. Unlike Discord, an
184
+ // unmet-but-addressed entity is still reachable: `whatsapp:<number>` implies
185
+ // its DM jid.
186
+ //
187
+ // `m.thread` leads because everything after it is a guess about where this
188
+ // person usually is, and a reply belongs in the room the question was asked
189
+ // in. The Discord bridge had the identical ordering and answered a DM in a
190
+ // shared server channel — from the operator's side, silence.
191
+ const replyTo = m.thread?.startsWith('whatsapp:') ? m.thread.slice('whatsapp:'.length ) : undefined
183
192
  const derivedDm = m.to?.startsWith('whatsapp:') ? dmJidFor( m.to.slice('whatsapp:'.length ) ) : undefined
184
- const targets = [ peer?.lastChannelId, peer?.dmChannelId, derivedDm, opts.homeChatId ?? undefined, lastActiveChatId ?? undefined ]
193
+ const targets = [ replyTo, peer?.lastChannelId, peer?.dmChannelId, derivedDm, opts.homeChatId ?? undefined, lastActiveChatId ?? undefined ]
185
194
  for( const jid of targets ){
186
195
  if( !jid ) continue
187
196
  try {
package/src/cli.ts CHANGED
@@ -27,7 +27,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
27
27
  import { routeLogsToStderr, bootWillFromEnv } from '#root/host/boot'
28
28
  import { buildWillMcpServer } from '#root/mcp/server'
29
29
  import { buildWillHttpServer } from '#root/serve/server'
30
- import { connectDiscord } from '#channels/discord'
30
+ import { connectDiscord, parseChannels, parseMentionOnly } from '#channels/discord'
31
31
  import { connectWhatsApp } from '#channels/whatsapp'
32
32
 
33
33
  // stdout is the MCP protocol channel under `will mcp` — route logs FIRST.
@@ -38,7 +38,12 @@ const USAGE = `usage: will <mcp | serve | discord | whatsapp>
38
38
  mcp host a persistent mind over MCP stdio (Claude Desktop / Claude Code)
39
39
  serve host a persistent mind over HTTP (any language; WILL_PORT, default 7777)
40
40
  discord put a persistent mind in a Discord server (DISCORD_BOT_TOKEN; optional
41
- WILL_DISCORD_CHANNELS, WILL_DISCORD_MENTION_ONLY, WILL_DISCORD_HOME_CHANNEL)
41
+ WILL_DISCORD_CHANNELS "*" or unset = every channel it can see,
42
+ else a comma-separated allowlist
43
+ WILL_DISCORD_MENTION_ONLY 1/true = only when @mentioned, anywhere;
44
+ or a comma-separated channel list to gate
45
+ just those; DMs are always perceived
46
+ WILL_DISCORD_HOME_CHANNEL where it speaks when it has no other route)
42
47
  whatsapp put a persistent mind on WhatsApp — QR-pairs as a linked device; no token.
43
48
  UNOFFICIAL protocol (ban risk; use a spare number — docs/channels/whatsapp.md).
44
49
  Optional WILL_WHATSAPP_CHATS, WILL_WHATSAPP_MENTION_ONLY, WILL_WHATSAPP_HOME_CHAT
@@ -76,8 +81,8 @@ async function main(): Promise<void> {
76
81
  const csv = ( v?: string ) => v?.split(',').map( s => s.trim() ).filter( Boolean )
77
82
  const bridge = await connectDiscord( will, {
78
83
  token: process.env.DISCORD_BOT_TOKEN!,
79
- channels: csv( process.env.WILL_DISCORD_CHANNELS ),
80
- mentionOnly: /^(1|true|yes)$/i.test( process.env.WILL_DISCORD_MENTION_ONLY ?? ''),
84
+ channels: parseChannels( process.env.WILL_DISCORD_CHANNELS ),
85
+ mentionOnly: parseMentionOnly( process.env.WILL_DISCORD_MENTION_ONLY ),
81
86
  homeChannelId: process.env.WILL_DISCORD_HOME_CHANNEL,
82
87
  rosterPath: pmaPath.replace( /(\.pma)?\.json$/, '') + '.discord.json',
83
88
  } )
@@ -122,7 +122,17 @@ export function liveConsequences(
122
122
  for( const [ , e ] of entities ){
123
123
  if( e.type !== CONSEQUENCE_TYPE ) continue
124
124
  const d = readConsequence( e.metadata )
125
- if( d && tick < d.expiresAt ) out.push( d )
125
+ if( !d ) continue
126
+ // A descriptor stamped LATER than now came from a previous session: descriptors
127
+ // snapshot with the state, and the tick counter restarts at 1 on wake. Without
128
+ // this the expiry test (`tick < expiresAt`) reads every restored descriptor as
129
+ // freshly live — measured: a Will woke at tick 1 holding three reach-out
130
+ // descriptors stamped 575–596 with expiry 605–626, so for the WHOLE session it
131
+ // believed it had just messaged that person moments ago. It damped every
132
+ // reach-out to them and delivered nothing, and the P2 matcher would equally
133
+ // have attenuated their genuine replies as its own echo.
134
+ if( d.tick > tick ) continue
135
+ if( tick < d.expiresAt ) out.push( d )
126
136
  }
127
137
  return out.sort( ( a, b ) => ( a.intentId < b.intentId ? -1 : a.intentId > b.intentId ? 1 : 0 ) )
128
138
  }
@@ -148,6 +158,117 @@ export function matchConsequenceText(
148
158
  return null
149
159
  }
150
160
 
161
+ // ── P5: the act's own footprint, felt as satiation ───────────────────────────
162
+
163
+ /**
164
+ * How much of an act's own footprint is still live for this (schema, target),
165
+ * 1 → 0 as the descriptor ages out. 0 when nothing matching is in flight.
166
+ *
167
+ * This is the consumer #112 always described and the descriptors never had. A
168
+ * delivered message wrote its footprint here and nothing ever read it back, so
169
+ * the standing `ideomotor.intent` that produced it kept re-winning the
170
+ * competition and the same words went out again — observed three times, ~21
171
+ * ticks apart, to the same person, while the mind believed each was the first.
172
+ *
173
+ * Deliberately a decaying quantity, not a lock. Right after acting it is ~1 and
174
+ * the pull to repeat is strongly damped; as the window in which the world could
175
+ * still answer runs out it returns to 0 and the appetite comes back on its own.
176
+ * That is a refractory period, not a rule: the mind may still repeat itself if
177
+ * something has become pressing enough to out-compete the damping, which is
178
+ * exactly what a person does when the silence starts to matter.
179
+ *
180
+ * Matches on (schema, target) rather than on the words, because at the moment
181
+ * the competition runs the words do not exist yet — a facet authors them later.
182
+ * "I have just reached out to this person and do not yet know how it landed" is
183
+ * the honest predicate, and it is the one that governs whether to speak again.
184
+ */
185
+ export function enactionFootprint(
186
+ descriptors: readonly ConsequenceDescriptor[],
187
+ schema: string,
188
+ targetEntityId: string | undefined,
189
+ tick: Tick,
190
+ windowTicks: number = CONSEQUENCE_TTL_TICKS,
191
+ /**
192
+ * Tick of the last thing SAID to each entity, from `conversation.sent`.
193
+ *
194
+ * Descriptors cannot carry satiation on their own: the executor deletes them at
195
+ * `tick >= expiresAt` (motor.schema.executor's TTL sweep), so nothing survives
196
+ * past the echo window and a satiation window longer than it was silently a
197
+ * no-op. Observed live: the same relay delivered twice with `justEnacted` never
198
+ * once reaching the competition.
199
+ *
200
+ * `conversation.sent` is the durable record of having spoken — written by
201
+ * ProactiveCommunicator at push time, snapshotted with the state, and already
202
+ * what discharges an undertaking. Keyed by person rather than by schema on
203
+ * purpose: having just spoken to someone damps speaking to them again, however
204
+ * that speaking happens to be schematised.
205
+ */
206
+ spokenAt?: ReadonlyMap<string, number>,
207
+ ): number {
208
+ if( !targetEntityId ) return 0
209
+ if( windowTicks <= 0 ) return 0
210
+
211
+ let strongest = 0
212
+
213
+ const spoken = spokenAt?.get( targetEntityId )
214
+ if( spoken !== undefined ){
215
+ const remaining = ( windowTicks - ( tick - spoken ) ) / windowTicks
216
+ if( remaining > strongest ) strongest = remaining
217
+ }
218
+
219
+ for( const d of descriptors ){
220
+ if( d.schema !== schema || d.targetEntityId !== targetEntityId ) continue
221
+ // Measured from when the act happened, against the SATIATION window — not from
222
+ // the descriptor's own expiry, which is the echo window and a different
223
+ // question. Conflating them meant "how long before I say this again" was
224
+ // pinned to "how long until the world's reply could still arrive", and the
225
+ // second is necessarily short. Observed: re-deliveries landing ~25 ticks
226
+ // apart against a 30-tick echo TTL, so the damping had decayed to almost
227
+ // nothing exactly when the next impulse arrived.
228
+ const elapsed = tick - d.tick
229
+ const remaining = ( windowTicks - elapsed ) / windowTicks
230
+ if( remaining > strongest ) strongest = remaining
231
+ }
232
+
233
+ return strongest < 0 ? 0 : strongest > 1 ? 1 : strongest
234
+ }
235
+
236
+ /**
237
+ * When the mind last SAID something to each person, from the durable
238
+ * `conversation.sent` records. Feeds `enactionFootprint`'s satiation.
239
+ *
240
+ * Unlike consequence descriptors these are never swept, so satiation can run on
241
+ * whatever window the persona wants rather than being capped by the echo TTL.
242
+ */
243
+ export function spokenAtByEntity(
244
+ entities: ReadonlyMap<string, { type: string; updatedAtTick?: number; tick?: number; metadata?: ReadonlyMap<string, unknown> | Record<string, unknown> }>,
245
+ ): Map<string, number> {
246
+ const out = new Map<string, number>()
247
+
248
+ for( const [ , e ] of entities ){
249
+ if( e.type !== 'conversation.sent') continue
250
+ const m = ( e.metadata ?? {} ) as Record<string, unknown>
251
+ const target = typeof m['targetEntityId'] === 'string' ? m['targetEntityId'] as string : undefined
252
+ if( !target ) continue
253
+ // `metadata.tick` where the writer set one, else the entity's own tick, which
254
+ // StateManager.setEntity stamps from the sim clock on every write. The fallback
255
+ // matters: not every path that records having spoken fills the metadata field,
256
+ // and defaulting to 0 made every record look infinitely old.
257
+ // `updatedAtTick` is the field `StateManager.setEntity` actually stamps.
258
+ // This read `e.tick`, which does not exist on SimulationEntity — so the
259
+ // fallback the comment above describes has never once fired, and any writer
260
+ // that omitted `metadata.tick` was treated as having spoken at tick 0, i.e.
261
+ // infinitely long ago, i.e. not satiating at all.
262
+ const at = typeof m['tick'] === 'number' ? m['tick'] as number
263
+ : typeof e.updatedAtTick === 'number' ? e.updatedAtTick
264
+ : typeof e.tick === 'number' ? e.tick
265
+ : 0
266
+ if( at > ( out.get( target ) ?? -Infinity ) ) out.set( target, at )
267
+ }
268
+
269
+ return out
270
+ }
271
+
151
272
  // ── ACP-P1: entity correspondence (ACTION_CONDITIONED_PREDICTION §2) ─────────
152
273
 
153
274
  /** Salience multiplier for an entity-correspondence match — gentler than a