@mindot/will 0.4.0 → 0.6.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 (135) hide show
  1. package/README.md +32 -12
  2. package/dist/channels/discord.d.ts +2 -11
  3. package/dist/channels/discord.js.map +1 -1
  4. package/dist/channels/whatsapp.d.ts +72 -0
  5. package/dist/channels/whatsapp.js +252 -0
  6. package/dist/channels/whatsapp.js.map +1 -0
  7. package/dist/cli.js +837 -91
  8. package/dist/cli.js.map +1 -1
  9. package/dist/index.d.ts +2 -2
  10. package/dist/index.js +620 -79
  11. package/dist/index.js.map +1 -1
  12. package/dist/mcp/effectors.d.ts +1 -1
  13. package/dist/mcp/effectors.js.map +1 -1
  14. package/dist/types-E9-HV-SW.d.ts +11 -0
  15. package/dist/{will-D-slky1N.d.ts → will-Bikuk4s2.d.ts} +56 -12
  16. package/package.json +12 -3
  17. package/src/channels/discord.ts +11 -11
  18. package/src/channels/roster.ts +1 -1
  19. package/src/channels/types.ts +1 -1
  20. package/src/channels/whatsapp.ts +318 -0
  21. package/src/cli.ts +46 -23
  22. package/src/cognition/acp.ts +28 -0
  23. package/src/cognition/agency/consequence.ts +174 -0
  24. package/src/cognition/agency/engines/action.selector.ts +301 -48
  25. package/src/cognition/agency/engines/affordance.synthesizer.ts +26 -16
  26. package/src/cognition/agency/engines/deliberation.engine.ts +28 -14
  27. package/src/cognition/agency/engines/motor.schema.executor.ts +86 -14
  28. package/src/cognition/agency/engines/reafference.engine.ts +122 -18
  29. package/src/cognition/agency/execution.primitives.ts +13 -13
  30. package/src/cognition/agency/proactive.communicator.ts +1 -1
  31. package/src/cognition/agency/reconcile.learning.ts +1 -1
  32. package/src/cognition/agency/revocation.ts +79 -0
  33. package/src/cognition/agency/schemas/repertoire.ts +2 -2
  34. package/src/cognition/agency/selection.scoring.ts +25 -9
  35. package/src/cognition/bus.ts +2 -2
  36. package/src/cognition/completion.inbox.ts +1 -1
  37. package/src/cognition/config.mirror.entities.ts +5 -5
  38. package/src/cognition/event.log.ts +2 -2
  39. package/src/cognition/event.schemas.ts +67 -67
  40. package/src/cognition/faculties/aesthetic.evaluator.ts +6 -6
  41. package/src/cognition/faculties/affective.blender.ts +36 -4
  42. package/src/cognition/faculties/attachment.evaluator.ts +6 -6
  43. package/src/cognition/faculties/attention.allocator.ts +63 -8
  44. package/src/cognition/faculties/autobiographical.narrator.ts +3 -3
  45. package/src/cognition/faculties/bias.detector.ts +7 -7
  46. package/src/cognition/faculties/circadian.oscillator.ts +3 -3
  47. package/src/cognition/faculties/confidence.calibrator.ts +7 -7
  48. package/src/cognition/faculties/dream.simulator.ts +5 -5
  49. package/src/cognition/faculties/empathy.simulator.ts +7 -7
  50. package/src/cognition/faculties/energy.regulator.ts +4 -4
  51. package/src/cognition/faculties/episodic.consolidator.ts +6 -6
  52. package/src/cognition/faculties/executive.engine/commands.ts +6 -6
  53. package/src/cognition/faculties/executive.engine/context.ts +42 -42
  54. package/src/cognition/faculties/executive.engine/deferred.effects.ts +2 -2
  55. package/src/cognition/faculties/executive.engine/engine.ts +27 -26
  56. package/src/cognition/faculties/executive.engine/facet.supervisor.ts +12 -12
  57. package/src/cognition/faculties/executive.engine/facet.ts +18 -18
  58. package/src/cognition/faculties/executive.engine/gating.ts +2 -2
  59. package/src/cognition/faculties/executive.engine/messages.ts +3 -3
  60. package/src/cognition/faculties/executive.engine/parser.ts +11 -11
  61. package/src/cognition/faculties/executive.engine/prompt.factory.ts +55 -55
  62. package/src/cognition/faculties/exteroception.ts +99 -5
  63. package/src/cognition/faculties/forgetting.curve.ts +3 -3
  64. package/src/cognition/faculties/frustration.evaluator.ts +6 -6
  65. package/src/cognition/faculties/goal.manager.ts +15 -15
  66. package/src/cognition/faculties/inhibition.controller.ts +3 -3
  67. package/src/cognition/faculties/interoception.ts +3 -3
  68. package/src/cognition/faculties/introspection.engine.ts +3 -3
  69. package/src/cognition/faculties/known.entity.tracker.ts +15 -15
  70. package/src/cognition/faculties/loss.evaluator.ts +2 -2
  71. package/src/cognition/faculties/moral.evaluator.ts +4 -4
  72. package/src/cognition/faculties/novelty.detector.ts +4 -4
  73. package/src/cognition/faculties/persona.consolidator.ts +12 -12
  74. package/src/cognition/faculties/planning.engine/engine.ts +47 -47
  75. package/src/cognition/faculties/planning.engine/plan.frontier.ts +5 -5
  76. package/src/cognition/faculties/planning.engine/plan.store.ts +1 -1
  77. package/src/cognition/faculties/planning.engine/plan.supervision.ts +20 -20
  78. package/src/cognition/faculties/reputation.tracker.ts +6 -6
  79. package/src/cognition/faculties/reward.evaluator.ts +4 -4
  80. package/src/cognition/faculties/self.model.updater.ts +8 -8
  81. package/src/cognition/faculties/semantic.engine/integrator.ts +15 -15
  82. package/src/cognition/faculties/sleep.pressure.regulator.ts +4 -4
  83. package/src/cognition/faculties/social.perception.ts +3 -3
  84. package/src/cognition/faculties/spaced.repetition.ts +2 -2
  85. package/src/cognition/faculties/stress.regulator.ts +27 -4
  86. package/src/cognition/faculties/task.switcher.ts +11 -6
  87. package/src/cognition/faculties/theory.of.mind.ts +6 -6
  88. package/src/cognition/faculties/threat.evaluator.ts +3 -3
  89. package/src/cognition/faculties/working.memory.ts +8 -8
  90. package/src/cognition/generative.model.ts +2 -2
  91. package/src/cognition/memory/vector.adapter.ts +5 -5
  92. package/src/cognition/memory/vector.content.ts +4 -4
  93. package/src/cognition/memory/vector.embedder.ts +9 -9
  94. package/src/cognition/persona.prior.ts +1 -1
  95. package/src/cognition/schema.registry.ts +2 -2
  96. package/src/cognition/senses/audition.engine/engine.ts +2 -2
  97. package/src/cognition/senses/base.sense.engine.ts +1 -1
  98. package/src/cognition/utilities/token.tracker.ts +11 -5
  99. package/src/core/abstracts.ts +1 -1
  100. package/src/core/completion.recorder.ts +2 -2
  101. package/src/core/serialization.ts +3 -3
  102. package/src/core/snapshot.manager.ts +1 -1
  103. package/src/core/state.manager.ts +1 -1
  104. package/src/core/utils.ts +1 -1
  105. package/src/host/boot.ts +59 -37
  106. package/src/host/utterances.ts +1 -1
  107. package/src/llm/index.ts +84 -34
  108. package/src/llm/summarizer.ts +4 -4
  109. package/src/llm/wire.contracts.ts +1 -1
  110. package/src/mcp/effectors.ts +10 -10
  111. package/src/mcp/server.ts +11 -11
  112. package/src/pma/eval.ts +1 -1
  113. package/src/pma/index.ts +21 -21
  114. package/src/runners/outreach.runner.ts +3 -3
  115. package/src/runners/social.runner.ts +1 -1
  116. package/src/runners/thin-shim.runner.ts +1 -1
  117. package/src/sdk/will.ts +20 -11
  118. package/src/serve/server.ts +12 -12
  119. package/src/stem/assembly.audit.ts +3 -3
  120. package/src/stem/guards/identity.coherence.ts +13 -12
  121. package/src/stem/guards/identity.guard.ts +22 -22
  122. package/src/stem/index.ts +11 -11
  123. package/src/stem/mind.ts +12 -12
  124. package/src/stem/tracts/biography.writer.ts +3 -3
  125. package/src/stem/tracts/effector.controller.ts +4 -4
  126. package/src/stem/tracts/health.reporter.ts +1 -1
  127. package/src/stem/tracts/outbox.controller.ts +4 -0
  128. package/src/stem/tracts/outbox.writer.ts +4 -4
  129. package/src/stem/tracts/pma.controller.ts +1 -1
  130. package/src/stem/tracts/replay.controller.ts +10 -10
  131. package/src/stem/tracts/sensory.controller.ts +2 -2
  132. package/src/stem/tracts/session.logger.ts +4 -4
  133. package/src/stem/tracts/transport/socketio.transport.ts +7 -7
  134. package/src/stem/tracts/transport/stream.transport.ts +2 -2
  135. package/src/stem/tracts/transport.controller.ts +15 -15
@@ -0,0 +1,318 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // src/channels/whatsapp.ts — a Will present on WhatsApp
3
+ // ─────────────────────────────────────────────────────────────
4
+ //
5
+ // Same paradigm as the Discord bridge, different room:
6
+ //
7
+ // inbound DM / group message → will.perceive({ from, speaker, text, thread })
8
+ // — every author is `whatsapp:<number>` (stable across chats), the
9
+ // push name is *learned* by the mind, and each chat (DM or group)
10
+ // is its own conversation thread.
11
+ // outbound will.on('message') → the addressee's last shared group, else
12
+ // their DM (a WhatsApp DM jid is derivable from the number — no
13
+ // roster miss is fatal), else the home chat.
14
+ //
15
+ // TRANSPORT — read before deploying. This bridge speaks the *linked-device*
16
+ // protocol via Baileys: the Will pairs to a WhatsApp account by QR scan, like
17
+ // WhatsApp Web. That is not a sanctioned bot API — WhatsApp's terms don't
18
+ // allow automation on personal accounts, and accounts doing it can be banned.
19
+ // Meta's sanctioned path (the Business Cloud API) needs a business account,
20
+ // webhook hosting, and app review — no 2-minute pairing. Operator's choice;
21
+ // docs/channels/whatsapp.md is explicit about the trade. Use a spare number.
22
+ //
23
+ // Baileys is imported lazily inside `createWhatsAppSocket` — tests (and hosts
24
+ // that bring their own transport) inject `socket`, and the structural
25
+ // `WaLikeSocket` type keeps the dependency out of the type graph.
26
+ // ─────────────────────────────────────────────────────────────
27
+
28
+ import type { Will, WillMessage } from '#sdk/will'
29
+ import { ChannelRoster } from '#channels/roster'
30
+ import { chunkText, type ChannelBridge } from '#channels/types'
31
+
32
+ /** WhatsApp's hard per-message cap. */
33
+ const WHATSAPP_MESSAGE_LIMIT = 65_536
34
+
35
+ // ── The slice of a Baileys socket the bridge actually uses (structural) ──────
36
+
37
+ export interface WaLikeMessage {
38
+ key: {
39
+ remoteJid?: string | null
40
+ fromMe?: boolean | null
41
+ /** Sender jid inside a group (absent in DMs). */
42
+ participant?: string | null
43
+ }
44
+ /** Sender's display ("push") name. */
45
+ pushName?: string | null
46
+ messageStubType?: number | null
47
+ message?: {
48
+ conversation?: string | null
49
+ extendedTextMessage?: { text?: string | null; contextInfo?: { mentionedJid?: string[] | null } | null } | null
50
+ imageMessage?: { caption?: string | null } | null
51
+ videoMessage?: { caption?: string | null } | null
52
+ } | null
53
+ }
54
+
55
+ export interface WaLikeSocket {
56
+ /** The paired account, once connected. id like '4915…:12@s.whatsapp.net'. */
57
+ user?: { id: string; name?: string } | null
58
+ ev: { on( event: 'messages.upsert', fn: ( u: { messages: WaLikeMessage[]; type?: string } ) => void ): unknown }
59
+ sendMessage( jid: string, content: { text: string } ): Promise<unknown>
60
+ sendPresenceUpdate?( state: 'composing' | 'paused', jid?: string ): Promise<unknown>
61
+ /** Tear the connection down (does not unlink the device). */
62
+ end?( err?: Error ): void
63
+ }
64
+
65
+ // ── Options ──────────────────────────────────────────────────────────────────
66
+
67
+ export interface WhatsAppBridgeOptions {
68
+ /** Chat jids the Will inhabits (groups `…@g.us`, DMs `…@s.whatsapp.net`). Unset = every chat. */
69
+ chats?: string[]
70
+ /** Perceive group messages only when the Will is @mentioned (DMs always perceived). */
71
+ mentionOnly?: boolean
72
+ /** Fallback chat jid for utterances with no reachable addressee. */
73
+ homeChatId?: string
74
+ /** Linked-device credentials dir (default: ./.will/<willId>.wa-auth). QR pairs on first run. */
75
+ authPath?: string
76
+ /** Roster path (default: ./.will/<willId>.whatsapp.json). */
77
+ rosterPath?: string
78
+ /** Test / power-user seam: bring your own socket; Baileys is never imported. */
79
+ socket?: WaLikeSocket
80
+ log?: ( msg: string ) => void
81
+ }
82
+
83
+ // ── Helpers ──────────────────────────────────────────────────────────────────
84
+
85
+ const isGroupJid = ( jid: string ): boolean => jid.endsWith('@g.us')
86
+
87
+ /** '4915123:7@s.whatsapp.net' → '4915123' (device + server stripped). */
88
+ const bareId = ( jid: string ): string => jid.split('@')[0]!.split(':')[0]!
89
+
90
+ /** A DM jid is derivable from the bare number — WhatsApp's gift to proactivity. */
91
+ const dmJidFor = ( userId: string ): string => `${ userId }@s.whatsapp.net`
92
+
93
+ function textOf( m: WaLikeMessage ): string {
94
+ const msg = m.message
95
+ return msg?.conversation
96
+ ?? msg?.extendedTextMessage?.text
97
+ ?? msg?.imageMessage?.caption
98
+ ?? msg?.videoMessage?.caption
99
+ ?? ''
100
+ }
101
+
102
+ // ── The bridge ───────────────────────────────────────────────────────────────
103
+
104
+ /**
105
+ * Connect a Will to WhatsApp over the linked-device protocol. First run prints
106
+ * a QR to pair (Settings → Linked devices → Link a device); credentials persist
107
+ * in `authPath` so later runs reconnect silently. Close via the returned
108
+ * `ChannelBridge.close()` — the Will keeps ticking; it only loses this surface.
109
+ */
110
+ export async function connectWhatsApp( will: Will, opts: WhatsAppBridgeOptions = {} ): Promise<ChannelBridge> {
111
+ const log = opts.log ?? ( ( m: string ) => console.error(`[will:whatsapp] ${ m }`) )
112
+ const roster = new ChannelRoster( opts.rosterPath ?? `.will/${ will.id }.whatsapp.json`)
113
+ const allowed = opts.chats?.length ? new Set( opts.chats ) : null
114
+
115
+ let closed = false
116
+ const socket = opts.socket ?? await createWhatsAppSocket( {
117
+ authPath: opts.authPath ?? `.will/${ will.id }.wa-auth`,
118
+ log,
119
+ stillOpen: () => !closed,
120
+ } )
121
+
122
+ /** The most recently active allowed chat — last-resort proactive target. */
123
+ let lastActiveChatId: string | null = opts.homeChatId ?? null
124
+
125
+ // ── inbound: platform message → stimulus ──────────────────────────────────
126
+ socket.ev.on('messages.upsert', ( { messages, type } ) => {
127
+ if( type && type !== 'notify') return // history sync/corrections are not new percepts
128
+ for( const m of messages ) void onMessage( m )
129
+ } )
130
+
131
+ async function onMessage( m: WaLikeMessage ): Promise<void> {
132
+ const jid = m.key.remoteJid
133
+ if( !jid || m.key.fromMe || !m.message || m.messageStubType ) return
134
+ if( jid.endsWith('@broadcast') || jid.endsWith('@newsletter') ) return // stories/broadcasts aren't a room the Will is in
135
+ if( allowed && !allowed.has( jid ) ) return
136
+
137
+ const isGroup = isGroupJid( jid )
138
+ const senderJid = isGroup ? m.key.participant : jid
139
+ if( !senderJid ) return
140
+
141
+ const selfId = socket.user ? bareId( socket.user.id ) : null
142
+ const mentioned = m.message.extendedTextMessage?.contextInfo?.mentionedJid ?? []
143
+ const addressed = !isGroup || ( selfId != null && mentioned.some( j => bareId( j ) === selfId ) )
144
+ if( opts.mentionOnly && !addressed ) return
145
+
146
+ const userId = bareId( senderJid )
147
+ const entityId = `whatsapp:${ userId }`
148
+ const speaker = m.pushName ?? undefined
149
+
150
+ roster.record( {
151
+ entityId,
152
+ userId,
153
+ ...( speaker ? { displayName: speaker } : {} ),
154
+ ...( isGroup ? { lastChannelId: jid } : { dmChannelId: jid } ),
155
+ } )
156
+ if( isGroup ) lastActiveChatId = jid
157
+
158
+ // Same rule as Discord: a presence cue only when addressed — the mind may
159
+ // still choose silence, and 'composing' expires on its own.
160
+ if( addressed ) await socket.sendPresenceUpdate?.('composing', jid ).catch( () => {} )
161
+
162
+ const text = textOf( m )
163
+ if( !text.trim() ) return
164
+
165
+ await will.perceive( {
166
+ text,
167
+ from: entityId,
168
+ thread: `whatsapp:${ jid }`,
169
+ ...( speaker ? { speaker } : {} ),
170
+ } )
171
+ }
172
+
173
+ // ── outbound: projected utterance → the addressee ─────────────────────────
174
+ will.on('message', ( m: WillMessage ) => { if( !closed ) void deliver( m ) } )
175
+
176
+ async function deliver( m: WillMessage ): Promise<void> {
177
+ const peer = m.to ? roster.resolve( m.to ) : undefined
178
+ const chunks = chunkText( m.content, WHATSAPP_MESSAGE_LIMIT )
179
+
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.
183
+ 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 ]
185
+ for( const jid of targets ){
186
+ if( !jid ) continue
187
+ try {
188
+ for( const chunk of chunks ) await socket.sendMessage( jid, { text: chunk } )
189
+ return
190
+ }
191
+ catch { /* try the next route */ }
192
+ }
193
+ log(`no route for utterance to '${ m.to }' — dropped (${ m.content.length } chars)`)
194
+ }
195
+
196
+ // ── lifecycle ──────────────────────────────────────────────────────────────
197
+ return {
198
+ kind: 'whatsapp',
199
+ async start(): Promise<void> {
200
+ // createWhatsAppSocket resolves already-open; an injected socket is the
201
+ // caller's to have readied.
202
+ log(`${ will.name } is present on WhatsApp${ socket.user ? ` as ${ bareId( socket.user.id ) }` : '' }`)
203
+ },
204
+ async close(): Promise<void> {
205
+ if( closed ) return
206
+ closed = true
207
+ roster.flush()
208
+ try { socket.end?.() } catch { /* already down */ }
209
+ },
210
+ }
211
+ }
212
+
213
+ // ── The real transport (Baileys, lazily imported) ────────────────────────────
214
+
215
+ interface SocketFactoryOpts {
216
+ authPath: string
217
+ log: ( msg: string ) => void
218
+ /** Reconnect only while the bridge is open. */
219
+ stillOpen: () => boolean
220
+ }
221
+
222
+ /**
223
+ * A reconnect-stable facade over Baileys. Baileys' own pattern replaces the
224
+ * socket object on every reconnect; the facade keeps one stable identity the
225
+ * bridge can hold, re-attaching its subscribers to each inner incarnation.
226
+ * Resolves once the first connection is open (after QR pairing on first run).
227
+ */
228
+ async function createWhatsAppSocket( o: SocketFactoryOpts ): Promise<WaLikeSocket> {
229
+ let baileys: typeof import('baileys')
230
+ try { baileys = await import('baileys') }
231
+ catch {
232
+ throw new Error('baileys is not installed (it is an optionalDependency) — run `bun add baileys` / `npm i baileys` and retry.')
233
+ }
234
+ const { default: makeWASocket, useMultiFileAuthState, DisconnectReason } = baileys
235
+
236
+ // Baileys logs through pino at info by default — JSON noise all over the
237
+ // host's stderr. A minimal pino-shaped silent logger (child() returns
238
+ // itself, every level a no-op) is what it actually needs; hand-rolling it
239
+ // beats importing a transitive dep that may not resolve from dist/.
240
+ const noop = (): void => {}
241
+ const logger = {
242
+ level: 'silent',
243
+ child(){ return logger },
244
+ trace: noop, debug: noop, info: noop, warn: noop, error: noop, fatal: noop,
245
+ }
246
+
247
+ type Handler = ( u: { messages: WaLikeMessage[]; type?: string } ) => void
248
+ const subscribers: Handler[] = []
249
+ let inner: ReturnType<typeof makeWASocket> | null = null
250
+
251
+ const facade: WaLikeSocket = {
252
+ get user(){ return inner?.user ? { id: inner.user.id, name: inner.user.name ?? undefined } : null },
253
+ ev: { on: ( _e, fn ) => { subscribers.push( fn ) } },
254
+ sendMessage: ( jid, content ) => {
255
+ if( !inner ) return Promise.reject( new Error('whatsapp socket not connected') )
256
+ return inner.sendMessage( jid, content )
257
+ },
258
+ sendPresenceUpdate: ( state, jid ) => inner?.sendPresenceUpdate( state, jid ) ?? Promise.resolve(),
259
+ end: () => inner?.end( undefined ),
260
+ }
261
+
262
+ const { state, saveCreds } = await useMultiFileAuthState( o.authPath )
263
+
264
+ await new Promise<void>( ( resolveOpen, rejectOpen ) => {
265
+ let opened = false
266
+
267
+ function connect(): void {
268
+ const sock = makeWASocket( { auth: state, logger: logger as never } )
269
+ inner = sock
270
+
271
+ sock.ev.on('creds.update', saveCreds )
272
+ sock.ev.on('messages.upsert', u => { for( const fn of subscribers ) fn( u as never ) } )
273
+
274
+ sock.ev.on('connection.update', update => {
275
+ const { connection, lastDisconnect, qr } = update as {
276
+ connection?: string; lastDisconnect?: { error?: unknown }; qr?: string
277
+ }
278
+
279
+ if( qr ) void printQr( qr, o.log )
280
+
281
+ if( connection === 'open' && !opened ){
282
+ opened = true
283
+ resolveOpen()
284
+ }
285
+ if( connection === 'close'){
286
+ const code = ( lastDisconnect?.error as { output?: { statusCode?: number } } | undefined )?.output?.statusCode
287
+ if( code === DisconnectReason.loggedOut ){
288
+ const err = new Error('WhatsApp unlinked this device (logged out) — delete the auth dir and pair again.')
289
+ o.log( err.message )
290
+ if( !opened ) rejectOpen( err )
291
+ return
292
+ }
293
+ if( o.stillOpen() ){
294
+ o.log(`connection closed (status ${ code ?? '?' }) — reconnecting…`)
295
+ setTimeout( connect, 3_000 )
296
+ }
297
+ }
298
+ } )
299
+ }
300
+
301
+ connect()
302
+ } )
303
+
304
+ return facade
305
+ }
306
+
307
+ /** QR to the terminal; falls back to the raw pairing string if the tiny renderer is absent. */
308
+ async function printQr( qr: string, log: ( m: string ) => void ): Promise<void> {
309
+ log('pair this device: WhatsApp → Settings → Linked devices → Link a device')
310
+ try {
311
+ type QrModule = { generate( s: string, o: { small: boolean } ): void }
312
+ const qrt = ( await import('qrcode-terminal' as string ) ) as QrModule & { default?: QrModule }
313
+ ;( qrt.default ?? qrt ).generate( qr, { small: true } )
314
+ }
315
+ catch {
316
+ log(`qrcode-terminal not installed — raw pairing code:\n${ qr }`)
317
+ }
318
+ }
package/src/cli.ts CHANGED
@@ -3,9 +3,11 @@
3
3
  // src/cli.ts — the `will` command: host a persistent mind
4
4
  // ─────────────────────────────────────────────────────────────
5
5
  //
6
- // will mcp host over MCP stdio (Claude Desktop / Claude Code / IDEs)
7
- // will serve host over HTTP (any language; the sidecar) — WILL_PORT/WILL_HOST
8
- // will discord a presence in a Discord server — DISCORD_BOT_TOKEN
6
+ // will mcp host over MCP stdio (Claude Desktop / Claude Code / IDEs)
7
+ // will serve host over HTTP (any language; the sidecar) — WILL_PORT/WILL_HOST
8
+ // will discord a presence in a Discord server — DISCORD_BOT_TOKEN
9
+ // will whatsapp a presence on WhatsApp — QR-pairs a linked device (unofficial
10
+ // protocol; see docs/channels/whatsapp.md before deploying)
9
11
  //
10
12
  // Both hosts raise the same mind the same way (see host/boot.ts): env-configured,
11
13
  // woken from its PMA artifact when one exists, hibernated back on the way out —
@@ -26,16 +28,20 @@ import { routeLogsToStderr, bootWillFromEnv } from '#root/host/boot'
26
28
  import { buildWillMcpServer } from '#root/mcp/server'
27
29
  import { buildWillHttpServer } from '#root/serve/server'
28
30
  import { connectDiscord } from '#channels/discord'
31
+ import { connectWhatsApp } from '#channels/whatsapp'
29
32
 
30
33
  // stdout is the MCP protocol channel under `will mcp` — route logs FIRST.
31
34
  routeLogsToStderr()
32
35
 
33
- const USAGE = `usage: will <mcp | serve | discord>
36
+ const USAGE = `usage: will <mcp | serve | discord | whatsapp>
34
37
 
35
- mcp host a persistent mind over MCP stdio (Claude Desktop / Claude Code)
36
- serve host a persistent mind over HTTP (any language; WILL_PORT, default 7777)
37
- discord put a persistent mind in a Discord server (DISCORD_BOT_TOKEN; optional
38
- WILL_DISCORD_CHANNELS, WILL_DISCORD_MENTION_ONLY, WILL_DISCORD_HOME_CHANNEL)
38
+ mcp host a persistent mind over MCP stdio (Claude Desktop / Claude Code)
39
+ serve host a persistent mind over HTTP (any language; WILL_PORT, default 7777)
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)
42
+ whatsapp put a persistent mind on WhatsApp — QR-pairs as a linked device; no token.
43
+ UNOFFICIAL protocol (ban risk; use a spare number — docs/channels/whatsapp.md).
44
+ Optional WILL_WHATSAPP_CHATS, WILL_WHATSAPP_MENTION_ONLY, WILL_WHATSAPP_HOME_CHAT
39
45
 
40
46
  Shared env: WILL_NAME, WILL_IDENTITY, WILL_TIER, WILL_LLM, WILL_TICK_MS,
41
47
  WILL_SEED, WILL_PMA_PATH, WILL_MCP_SERVERS. The mind persists across runs via
@@ -44,57 +50,74 @@ its PMA artifact.`
44
50
  async function main(): Promise<void> {
45
51
  const sub = process.argv[2]
46
52
 
47
- if( sub !== 'mcp' && sub !== 'serve' && sub !== 'discord' ){
53
+ if( sub !== 'mcp' && sub !== 'serve' && sub !== 'discord' && sub !== 'whatsapp'){
48
54
  console.error( sub ? `unknown subcommand: ${ sub }\n\n${ USAGE }` : USAGE )
49
55
  process.exit( sub ? 2 : 0 )
50
56
  }
51
57
 
52
58
  // Fail on missing platform credentials BEFORE raising a mind.
53
59
  if( sub === 'discord' && !process.env.DISCORD_BOT_TOKEN ){
54
- console.error( '[will] DISCORD_BOT_TOKEN is required for `will discord` — create a bot at https://discord.com/developers/applications (enable the Message Content intent) and set the token.' )
60
+ console.error('[will] DISCORD_BOT_TOKEN is required for `will discord` — create a bot at https://discord.com/developers/applications (enable the Message Content intent) and set the token.')
55
61
  process.exit( 2 )
56
62
  }
57
63
 
58
64
  const { will, name, pmaPath, tickMs, anatomy, onCleanup, shutdown } = await bootWillFromEnv()
59
65
 
60
- if( sub === 'mcp' ){
66
+ if( sub === 'mcp'){
61
67
  // The MCP client owns our stdin — its disconnect is the shutdown signal.
62
- process.stdin.on( 'end', () => void shutdown( 'client disconnected' ) )
68
+ process.stdin.on('end', () => void shutdown('client disconnected') )
63
69
  const server = buildWillMcpServer( will, { pmaPath } )
64
70
  await server.connect( new StdioServerTransport() )
65
- console.error( `[will] ${ name } is listening on MCP stdio (tick ${ tickMs }ms, anatomy ${ anatomy })` )
71
+ console.error(`[will] ${ name } is listening on MCP stdio (tick ${ tickMs }ms, anatomy ${ anatomy })`)
66
72
  return
67
73
  }
68
74
 
69
- if( sub === 'discord' ){
70
- const csv = ( v?: string ) => v?.split( ',' ).map( s => s.trim() ).filter( Boolean )
75
+ if( sub === 'discord'){
76
+ const csv = ( v?: string ) => v?.split(',').map( s => s.trim() ).filter( Boolean )
71
77
  const bridge = await connectDiscord( will, {
72
78
  token: process.env.DISCORD_BOT_TOKEN!,
73
79
  channels: csv( process.env.WILL_DISCORD_CHANNELS ),
74
- mentionOnly: /^(1|true|yes)$/i.test( process.env.WILL_DISCORD_MENTION_ONLY ?? '' ),
80
+ mentionOnly: /^(1|true|yes)$/i.test( process.env.WILL_DISCORD_MENTION_ONLY ?? ''),
75
81
  homeChannelId: process.env.WILL_DISCORD_HOME_CHANNEL,
76
- rosterPath: pmaPath.replace( /(\.pma)?\.json$/, '' ) + '.discord.json',
82
+ rosterPath: pmaPath.replace( /(\.pma)?\.json$/, '') + '.discord.json',
77
83
  } )
78
84
  onCleanup( () => bridge.close() )
79
85
  await bridge.start()
80
- console.error( `[will] ${ name } is present on Discord (tick ${ tickMs }ms, anatomy ${ anatomy }) — it speaks when it decides to.` )
86
+ console.error(`[will] ${ name } is present on Discord (tick ${ tickMs }ms, anatomy ${ anatomy }) — it speaks when it decides to.`)
87
+ return
88
+ }
89
+
90
+ if( sub === 'whatsapp'){
91
+ const csv = ( v?: string ) => v?.split(',').map( s => s.trim() ).filter( Boolean )
92
+ const stem = pmaPath.replace( /(\.pma)?\.json$/, '')
93
+ // connectWhatsApp blocks here on first run until the printed QR is scanned.
94
+ const bridge = await connectWhatsApp( will, {
95
+ chats: csv( process.env.WILL_WHATSAPP_CHATS ),
96
+ mentionOnly: /^(1|true|yes)$/i.test( process.env.WILL_WHATSAPP_MENTION_ONLY ?? ''),
97
+ homeChatId: process.env.WILL_WHATSAPP_HOME_CHAT,
98
+ authPath: stem + '.wa-auth',
99
+ rosterPath: stem + '.whatsapp.json',
100
+ } )
101
+ onCleanup( () => bridge.close() )
102
+ await bridge.start()
103
+ console.error(`[will] ${ name } is present on WhatsApp (tick ${ tickMs }ms, anatomy ${ anatomy }) — it speaks when it decides to.`)
81
104
  return
82
105
  }
83
106
 
84
107
  // serve — the HTTP sidecar.
85
- const port = parseInt( process.env.WILL_PORT ?? '7777' )
108
+ const port = parseInt( process.env.WILL_PORT ?? '7777')
86
109
  const host = process.env.WILL_HOST ?? '127.0.0.1'
87
110
  const server = buildWillHttpServer( will, { pmaPath } )
88
111
  onCleanup( () => new Promise<void>( r => server.close( () => r() ) ) )
89
112
  await new Promise<void>( ( resolve, reject ) => {
90
- server.once( 'error', reject )
113
+ server.once('error', reject )
91
114
  server.listen( port, host, () => resolve() )
92
115
  } )
93
- console.error( `[will] ${ name } is listening on http://${ host }:${ port } (tick ${ tickMs }ms, anatomy ${ anatomy })` )
94
- console.error( `[will] try: curl -X POST http://${ host }:${ port }/perceive -H 'content-type: application/json' -d '{"text":"Hello"}'` )
116
+ console.error(`[will] ${ name } is listening on http://${ host }:${ port } (tick ${ tickMs }ms, anatomy ${ anatomy })`)
117
+ console.error(`[will] try: curl -X POST http://${ host }:${ port }/perceive -H 'content-type: application/json' -d '{"text":"Hello"}'`)
95
118
  }
96
119
 
97
120
  main().catch( e => {
98
- console.error( `[will] fatal: ${ e instanceof Error ? e.stack ?? e.message : String( e ) }` )
121
+ console.error(`[will] fatal: ${ e instanceof Error ? e.stack ?? e.message : String( e ) }`)
99
122
  process.exit( 1 )
100
123
  } )
@@ -0,0 +1,28 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // src/cognition/acp.ts — action-conditioned prediction constants
3
+ // ─────────────────────────────────────────────────────────────
4
+ //
5
+ // Shared by every ACP-P2 consumer (ACTION_CONDITIONED_PREDICTION §3) so the
6
+ // safety-relevant number cannot drift between engines. The pattern each
7
+ // consumer implements:
8
+ //
9
+ // 1. subscribe `agency.enacted` / `agency.communicate` / `agency.invocation`;
10
+ // 2. on any of them, setPrecision(stream, ACP_SELF_PRECISION) on the streams
11
+ // whose prediction errors are actually CONSUMED (gate or weight a
12
+ // publish) — anticipating unconsumed streams is theater;
13
+ // 3. restore precision to 1.0 explicitly after ONE observe in react() —
14
+ // GenerativeModel's own mean-reversion (0.02/observe ≈ 50 ticks) would
15
+ // keep dampening GENUINE world surprise arriving after our action.
16
+ //
17
+ // Measured on the first consumer (AttentionAllocator): after a stable
18
+ // stretch the salience denominator (EW variance) collapses, so any deviation
19
+ // saturates salience at 1.0 — precision is the only lever that stays
20
+ // measurable there; a conservative anticipate() nudge is invisible.
21
+ // ─────────────────────────────────────────────────────────────
22
+
23
+ /**
24
+ * The weight a self-caused observe carries. Below the executive's
25
+ * `WORKSPACE_THRESHOLD` (0.4) even when the base salience saturates at 1.0 —
26
+ * our own action's swing does not recruit our own executive.
27
+ */
28
+ export const ACP_SELF_PRECISION = 0.35
@@ -0,0 +1,174 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // src/agency/consequence.ts — expected-consequence descriptors
3
+ // ─────────────────────────────────────────────────────────────
4
+ //
5
+ // EXAFFERENCE P1. The efference copy predicts an action's *reward*; a
6
+ // consequence descriptor predicts its *sensory footprint* — what the world will
7
+ // look like because we acted. The MotorSchemaExecutor registers one at the
8
+ // enaction moment for world-facing actions only (delivered communicate, or the
9
+ // async communicate/external hold); sync innate enactions have internal effects
10
+ // and get none. Descriptors are ordinary entities, so they snapshot/restore and
11
+ // replay with the state (FN9), and they expire on a tick-denominated TTL that
12
+ // deliberately OUTLIVES intent resolution — a host ack does not stop the
13
+ // channel echo from arriving two ticks later.
14
+ //
15
+ // Dark in P1: written and expired, consumed by nothing. P2's corollary-
16
+ // discharge matcher reads them to tag percept provenance
17
+ // ('reafferent' | 'exafferent') and attenuate self-caused salience.
18
+ // Matching stays deterministic: FNV-1a content hashes, no clocks, no RNG.
19
+ // ─────────────────────────────────────────────────────────────
20
+
21
+ import type { EntityInput, Tick } from '#core/types'
22
+
23
+ export const CONSEQUENCE_TYPE = 'agency.consequence'
24
+
25
+ /** Salience multiplier applied to a matched (reafferent) percept — attenuated,
26
+ * not zeroed: a *surprising* consequence of our own action can still climb. */
27
+ export const ATTENUATION = 0.25
28
+
29
+ /** Minimum descriptor-text length for substring matching — below this a
30
+ * containment hit is too cheap to mean "these are my own words". */
31
+ export const MIN_TEXT_MATCH_LEN = 12
32
+
33
+ /**
34
+ * Ticks a descriptor stays live — 2 × the executor's `AWAIT_TIMEOUT`, so a
35
+ * descriptor survives both a stranded await (15 ticks) and the late sensory
36
+ * echo of an already-reconciled action.
37
+ */
38
+ export const CONSEQUENCE_TTL_TICKS = 30
39
+
40
+ export interface ConsequenceDescriptor {
41
+ /** The enacting intent — sensory reafference can reconcile to it (P5). */
42
+ intentId: string
43
+ schema: string
44
+ mode: 'communicate' | 'external'
45
+ /** Communication effector name (talk/text/gesture/broadcast), when known. */
46
+ effector?: string
47
+ targetEntityId?: string
48
+ /** FNV-1a over the authored outbound text (delivered communicate). */
49
+ textHash?: number
50
+ /** The authored outbound text itself — enables substring matching on echoes
51
+ * that arrive decorated ("Alice said: …"). Internal state, never emitted. */
52
+ text?: string
53
+ /** FNV-1a over the canonicalized parameters (async dispatch). */
54
+ paramsHash?: number
55
+ expiresAt: Tick
56
+ tick: Tick
57
+ }
58
+
59
+ /** Deterministic 32-bit FNV-1a — the shared content-match key (P2 matcher uses the same). */
60
+ export function fnv1a( text: string ): number {
61
+ let h = 0x811c9dc5
62
+ for( let i = 0; i < text.length; i++ ){
63
+ h ^= text.charCodeAt( i )
64
+ h = Math.imul( h, 0x01000193 ) >>> 0
65
+ }
66
+ return h >>> 0
67
+ }
68
+
69
+ /**
70
+ * Canonicalize parameters into a stable string before hashing: keys sorted
71
+ * recursively so `{a,b}` and `{b,a}` produce the same key. Deterministic and
72
+ * cheap; not a serialization format (never parsed back).
73
+ */
74
+ export function paramsKey( value: unknown ): string {
75
+ if( value === null || typeof value !== 'object')
76
+ return typeof value === 'string' ? JSON.stringify( value ) : String( value )
77
+ if( Array.isArray( value ) )
78
+ return `[${ value.map( paramsKey ).join(',') }]`
79
+ const obj = value as Record<string, unknown>
80
+ const keys = Object.keys( obj ).sort()
81
+ return `{${ keys.map( k => `${ k }:${ paramsKey( obj[k] ) }` ).join(',') }}`
82
+ }
83
+
84
+ /** Build the descriptor entity (id keyed by intent — one footprint per enaction). */
85
+ export function consequenceEntity( d: ConsequenceDescriptor ): EntityInput {
86
+ return {
87
+ id: `agency-consequence-${ d.intentId }`,
88
+ type: CONSEQUENCE_TYPE,
89
+ metadata: { ...d },
90
+ }
91
+ }
92
+
93
+ /** Read a descriptor back off entity metadata (P2 matcher + tests). */
94
+ export function readConsequence(
95
+ m: ReadonlyMap<string, unknown> | Record<string, unknown> | undefined,
96
+ ): ConsequenceDescriptor | null {
97
+ const meta = ( m ?? {} ) as Record<string, unknown>
98
+ const intentId = typeof meta['intentId'] === 'string' ? meta['intentId'] as string : undefined
99
+ const schema = typeof meta['schema'] === 'string' ? meta['schema'] as string : undefined
100
+ const mode = meta['mode'] === 'communicate' || meta['mode'] === 'external' ? meta['mode'] : undefined
101
+ if( !intentId || !schema || !mode ) return null
102
+ return {
103
+ intentId, schema, mode,
104
+ effector: typeof meta['effector'] === 'string' ? meta['effector'] as string : undefined,
105
+ targetEntityId: typeof meta['targetEntityId'] === 'string' ? meta['targetEntityId'] as string : undefined,
106
+ textHash: typeof meta['textHash'] === 'number' ? meta['textHash'] as number : undefined,
107
+ text: typeof meta['text'] === 'string' ? meta['text'] as string : undefined,
108
+ paramsHash: typeof meta['paramsHash'] === 'number' ? meta['paramsHash'] as number : undefined,
109
+ expiresAt: typeof meta['expiresAt'] === 'number' ? meta['expiresAt'] as number : 0,
110
+ tick: typeof meta['tick'] === 'number' ? meta['tick'] as number : 0,
111
+ }
112
+ }
113
+
114
+ // ── P2: the corollary-discharge matcher ──────────────────────────────────────
115
+
116
+ /** Collect the live (unexpired) descriptors from frozen state, in stable id order. */
117
+ export function liveConsequences(
118
+ entities: ReadonlyMap<string, { type: string; metadata?: ReadonlyMap<string, unknown> | Record<string, unknown> }>,
119
+ tick: Tick,
120
+ ): ConsequenceDescriptor[] {
121
+ const out: ConsequenceDescriptor[] = []
122
+ for( const [ , e ] of entities ){
123
+ if( e.type !== CONSEQUENCE_TYPE ) continue
124
+ const d = readConsequence( e.metadata )
125
+ if( d && tick < d.expiresAt ) out.push( d )
126
+ }
127
+ return out.sort( ( a, b ) => ( a.intentId < b.intentId ? -1 : a.intentId > b.intentId ? 1 : 0 ) )
128
+ }
129
+
130
+ /**
131
+ * High-precision text matching only (P2): a candidate text is *ours* when it
132
+ * hashes exactly to a descriptor's outbound text, or contains that text
133
+ * verbatim (echoes arrive decorated — "Alice said: …"). Deliberately NO
134
+ * entity-correspondence matching for external effectors yet: over-attribution
135
+ * (muting a genuine world event) is the dangerous direction; under-attribution
136
+ * is the status quo. Pure, deterministic, first match in stable order wins.
137
+ */
138
+ export function matchConsequenceText(
139
+ descriptors: readonly ConsequenceDescriptor[],
140
+ candidate: string,
141
+ ): ConsequenceDescriptor | null {
142
+ if( candidate.length === 0 ) return null
143
+ const candidateHash = fnv1a( candidate )
144
+ for( const d of descriptors ){
145
+ if( d.textHash !== undefined && d.textHash === candidateHash ) return d
146
+ if( d.text !== undefined && d.text.length >= MIN_TEXT_MATCH_LEN && candidate.includes( d.text ) ) return d
147
+ }
148
+ return null
149
+ }
150
+
151
+ // ── ACP-P1: entity correspondence (ACTION_CONDITIONED_PREDICTION §2) ─────────
152
+
153
+ /** Salience multiplier for an entity-correspondence match — gentler than a
154
+ * text match (0.25): the change is on our action's target while its
155
+ * descriptor lives, but we are less certain it is ours. */
156
+ export const CORRESPONDENCE_ATTENUATION = 0.5
157
+
158
+ /**
159
+ * A live **external**-mode descriptor is a standing prediction that its
160
+ * `targetEntityId` is about to change *because of us*. A `modified` percept on
161
+ * exactly that entity is claimed as reafference (an `appeared` entity is new
162
+ * information, not our footprint). Communicate descriptors are excluded — the
163
+ * text path owns them. First match in stable order; pure.
164
+ */
165
+ export function matchConsequenceEntity(
166
+ descriptors: readonly ConsequenceDescriptor[],
167
+ entityId: string,
168
+ changeType: string,
169
+ ): ConsequenceDescriptor | null {
170
+ if( changeType !== 'modified' || entityId.length === 0 ) return null
171
+ for( const d of descriptors )
172
+ if( d.mode === 'external' && d.targetEntityId === entityId ) return d
173
+ return null
174
+ }