@mindot/will 0.4.0 → 0.5.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.
- package/README.md +32 -12
- package/dist/channels/discord.d.ts +2 -11
- package/dist/channels/whatsapp.d.ts +72 -0
- package/dist/channels/whatsapp.js +252 -0
- package/dist/channels/whatsapp.js.map +1 -0
- package/dist/cli.js +270 -40
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +53 -28
- package/dist/index.js.map +1 -1
- package/dist/mcp/effectors.d.ts +1 -1
- package/dist/types-E9-HV-SW.d.ts +11 -0
- package/dist/{will-D-slky1N.d.ts → will-BDq-TMQr.d.ts} +8 -6
- package/package.json +12 -3
- package/src/channels/whatsapp.ts +318 -0
- package/src/cli.ts +32 -9
- package/src/cognition/faculties/executive.engine/engine.ts +3 -2
- package/src/cognition/utilities/token.tracker.ts +6 -0
- package/src/host/boot.ts +32 -10
- package/src/llm/index.ts +75 -25
- package/src/sdk/will.ts +16 -7
- package/src/stem/guards/identity.coherence.ts +4 -3
package/dist/mcp/effectors.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
2
|
-
import { E as EffectorHandler, W as Will } from '../will-
|
|
2
|
+
import { E as EffectorHandler, W as Will } from '../will-BDq-TMQr.js';
|
|
3
3
|
|
|
4
4
|
/** Where the tools live: spawn a local server, reach a remote one, or bring a connected client. */
|
|
5
5
|
type McpToolsSource = {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** A running connection between one Will and one platform. */
|
|
2
|
+
interface ChannelBridge {
|
|
3
|
+
/** Platform kind, e.g. 'discord'. */
|
|
4
|
+
readonly kind: string;
|
|
5
|
+
/** Connect and start relaying. Resolves once the bridge is live. */
|
|
6
|
+
start(): Promise<void>;
|
|
7
|
+
/** Disconnect and release resources. Idempotent. */
|
|
8
|
+
close(): Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type { ChannelBridge as C };
|
|
@@ -2567,7 +2567,7 @@ declare class CompletionInbox {
|
|
|
2567
2567
|
clear(): number;
|
|
2568
2568
|
}
|
|
2569
2569
|
|
|
2570
|
-
type LLMProvider = 'anthropic' | 'deepseek' | 'openai' | 'google';
|
|
2570
|
+
type LLMProvider = 'anthropic' | 'glm' | 'deepseek' | 'openai' | 'google';
|
|
2571
2571
|
interface LLMDirectorConfig {
|
|
2572
2572
|
willId: string;
|
|
2573
2573
|
model: string;
|
|
@@ -7868,11 +7868,13 @@ interface CreateWillOptions {
|
|
|
7868
7868
|
* (Named llmConfig because `llm` is the mock/anthropic MODE switch.) */
|
|
7869
7869
|
llmConfig?: WillLLMConfig;
|
|
7870
7870
|
/**
|
|
7871
|
-
|
|
7872
|
-
|
|
7873
|
-
|
|
7874
|
-
|
|
7875
|
-
|
|
7871
|
+
* LLM mode. 'mock' (default when no key is present) runs a deterministic
|
|
7872
|
+
* canned executive — zero keys, zero cost. 'anthropic' calls Claude (needs
|
|
7873
|
+
* ANTHROPIC_API_KEY / WILL_LLM_* env); 'glm' calls Z.ai's GLM over its
|
|
7874
|
+
* Anthropic-compatible endpoint (needs ZAI_API_KEY / WILL_LLM_*). Omit to
|
|
7875
|
+
* auto-detect from whichever key is set.
|
|
7876
|
+
*/
|
|
7877
|
+
llm?: 'mock' | 'anthropic' | 'glm';
|
|
7876
7878
|
/**
|
|
7877
7879
|
* Abilities the Will can choose to enact. `name → handler`, or
|
|
7878
7880
|
* `name → { handler, description?, cost?, valence?, preconditions? }` to seed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mindot/will",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"author": "Fabrice <fabrice8@github.com>",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -21,7 +21,9 @@
|
|
|
21
21
|
"zod": "^4.0.0"
|
|
22
22
|
},
|
|
23
23
|
"optionalDependencies": {
|
|
24
|
+
"baileys": "^7.0.0-rc13",
|
|
24
25
|
"discord.js": "^14.16.3",
|
|
26
|
+
"qrcode-terminal": "^0.12.0",
|
|
25
27
|
"socket.io-client": "^4.8.1"
|
|
26
28
|
},
|
|
27
29
|
"exports": {
|
|
@@ -36,16 +38,23 @@
|
|
|
36
38
|
"./discord": {
|
|
37
39
|
"import": "./dist/channels/discord.js",
|
|
38
40
|
"types": "./dist/channels/discord.d.ts"
|
|
41
|
+
},
|
|
42
|
+
"./whatsapp": {
|
|
43
|
+
"import": "./dist/channels/whatsapp.js",
|
|
44
|
+
"types": "./dist/channels/whatsapp.d.ts"
|
|
39
45
|
}
|
|
40
46
|
},
|
|
41
47
|
"bugs": {
|
|
42
48
|
"url": "https://github.com/mindot-ai/will/issues"
|
|
43
49
|
},
|
|
44
|
-
"description": "Will
|
|
50
|
+
"description": "Will \u2014 an engine for persistent machine minds. A Will is a continuously-ticking synthetic mind built from 38 cognitive faculties, a learning agency pipeline, and a dual-process executive \u2014 deterministic, persistent, and portable (PMA).",
|
|
45
51
|
"engines": {
|
|
46
52
|
"bun": ">=1.0.0"
|
|
47
53
|
},
|
|
48
|
-
"files": [
|
|
54
|
+
"files": [
|
|
55
|
+
"dist",
|
|
56
|
+
"src"
|
|
57
|
+
],
|
|
49
58
|
"homepage": "https://github.com/mindot-ai/will#readme",
|
|
50
59
|
"keywords": [
|
|
51
60
|
"ai",
|
|
@@ -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
|
|
7
|
-
// will serve
|
|
8
|
-
// will discord
|
|
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
|
|
36
|
-
serve
|
|
37
|
-
discord
|
|
38
|
-
|
|
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,7 +50,7 @@ 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
|
}
|
|
@@ -81,6 +87,23 @@ async function main(): Promise<void> {
|
|
|
81
87
|
return
|
|
82
88
|
}
|
|
83
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.` )
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
84
107
|
// serve — the HTTP sidecar.
|
|
85
108
|
const port = parseInt( process.env.WILL_PORT ?? '7777' )
|
|
86
109
|
const host = process.env.WILL_HOST ?? '127.0.0.1'
|
|
@@ -60,7 +60,7 @@ import {
|
|
|
60
60
|
type GatingState
|
|
61
61
|
} from '#faculties/executive.engine/gating'
|
|
62
62
|
import { LLMDirector } from '#llm/index'
|
|
63
|
-
import type
|
|
63
|
+
import { defaultModelFor, type LLMProvider } from '#llm/index'
|
|
64
64
|
import { buildFallbackOutput, parseResponse } from '#faculties/executive.engine/parser'
|
|
65
65
|
import { selectProcess, ideationTemperature, DELIBERATE_THRESHOLD } from '#faculties/executive.engine/effort.gate'
|
|
66
66
|
import { proposeCandidates } from '#faculties/executive.engine/deliberate.reasoning'
|
|
@@ -436,7 +436,8 @@ export class ExecutiveEngine extends AsyncEngine implements CognitiveEngine {
|
|
|
436
436
|
// Initialize LLM directors if not yet done (requires willId). One director
|
|
437
437
|
// per distinct role model; roles that share a model share the instance.
|
|
438
438
|
if( !this._llmDirector && this._willId ){
|
|
439
|
-
const execModel = this._models.executive ?? process.env.WILL_LLM_MODEL
|
|
439
|
+
const execModel = this._models.executive ?? process.env.WILL_LLM_MODEL
|
|
440
|
+
?? defaultModelFor( ( this._llm?.provider ?? process.env.WILL_LLM_PROVIDER ?? 'anthropic' ) as LLMProvider )
|
|
440
441
|
this._llmDirector = this._directorFor( execModel )
|
|
441
442
|
// The summarizer runs its role's model (falls back to executive) with the
|
|
442
443
|
// same provider, session logging and token tracking.
|
|
@@ -53,6 +53,12 @@ const MODEL_PRICING: Record<string, { input: number; output: number }> = {
|
|
|
53
53
|
'anthropic/claude-haiku-4': { input: 1.00, output: 5.00 },
|
|
54
54
|
'anthropic/claude-opus-4': { input: 5.00, output: 25.00 },
|
|
55
55
|
|
|
56
|
+
// Z.ai (GLM-5 family). `glm-5.2[1m]` is the same model asking for its 1M
|
|
57
|
+
// context window — same rate, so it gets its own row rather than relying on
|
|
58
|
+
// the normalizer (a future long-context tier would price differently).
|
|
59
|
+
'glm/glm-5.2': { input: 1.40, output: 4.40 },
|
|
60
|
+
'glm/glm-5.2[1m]': { input: 1.40, output: 4.40 },
|
|
61
|
+
|
|
56
62
|
// Google
|
|
57
63
|
'google/gemini-2.0-flash': { input: 0.10, output: 0.40 },
|
|
58
64
|
'google/gemini-2.0-pro': { input: 1.25, output: 5.00 },
|
package/src/host/boot.ts
CHANGED
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
// WILL_NAME display name (default "Will")
|
|
13
13
|
// WILL_IDENTITY persona prompt (default a minimal self)
|
|
14
14
|
// WILL_TIER basic | standard | full (default standard)
|
|
15
|
-
// WILL_LLM mock | anthropic
|
|
16
|
-
// ANTHROPIC_API_KEY is set,
|
|
15
|
+
// WILL_LLM mock | anthropic | glm (default: auto — anthropic when
|
|
16
|
+
// ANTHROPIC_API_KEY is set, glm when
|
|
17
|
+
// ZAI_API_KEY is, else mock)
|
|
17
18
|
// WILL_TICK_MS ms per tick (default 1000)
|
|
18
19
|
// WILL_SEED deterministic seed (testing) (default unseeded/wall-time)
|
|
19
20
|
// WILL_PMA_PATH PMA artifact path (default ./.will/<name>.pma.json)
|
|
@@ -27,6 +28,7 @@ import { setLogger } from '#core/logger'
|
|
|
27
28
|
import { Will, type CreateWillOptions } from '#sdk/will'
|
|
28
29
|
import type { PMASnapshot } from '#pma/index'
|
|
29
30
|
import { connectMcpEffectors, type McpToolsSource } from '#root/mcp/effectors'
|
|
31
|
+
import { anthropicWireHeaders, defaultBaseFor, defaultModelFor } from '#llm/index'
|
|
30
32
|
|
|
31
33
|
/**
|
|
32
34
|
* Route every engine log line to stderr. For `will mcp`, stdout is the MCP
|
|
@@ -43,6 +45,23 @@ function slug( s: string ): string {
|
|
|
43
45
|
return s.toLowerCase().replace( /[^a-z0-9]+/g, '-' ).replace( /^-+|-+$/g, '' ) || 'will'
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
/** The LLM mode the hosts will boot with: an explicit WILL_LLM, else whichever
|
|
49
|
+
* provider's key is present, else the zero-key mock. */
|
|
50
|
+
export function resolveLlmMode(): 'mock' | 'anthropic' | 'glm' {
|
|
51
|
+
const explicit = process.env.WILL_LLM as 'mock' | 'anthropic' | 'glm' | undefined
|
|
52
|
+
if( explicit ) return explicit
|
|
53
|
+
if( process.env.ANTHROPIC_API_KEY ) return 'anthropic'
|
|
54
|
+
if( process.env.ZAI_API_KEY ) return 'glm'
|
|
55
|
+
return 'mock'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The key for a live mode — the provider-agnostic override first, then the
|
|
59
|
+
* provider's own env. */
|
|
60
|
+
function resolveLlmKey( mode: 'anthropic' | 'glm' ): string | undefined {
|
|
61
|
+
return process.env.WILL_LLM_API_KEY
|
|
62
|
+
?? ( mode === 'glm' ? process.env.ZAI_API_KEY : process.env.ANTHROPIC_API_KEY )
|
|
63
|
+
}
|
|
64
|
+
|
|
46
65
|
/**
|
|
47
66
|
* Ask the executive's provider one trivial question BEFORE raising the mind.
|
|
48
67
|
*
|
|
@@ -59,25 +78,28 @@ function slug( s: string ): string {
|
|
|
59
78
|
* Skipped for the mock executive and the no-LLM `reflex` anatomy.
|
|
60
79
|
*/
|
|
61
80
|
async function preflightLLM( anatomy: string ): Promise<void> {
|
|
62
|
-
const mode =
|
|
81
|
+
const mode = resolveLlmMode()
|
|
63
82
|
if( mode === 'mock' || anatomy === 'reflex' ) return
|
|
64
83
|
|
|
65
|
-
const key =
|
|
84
|
+
const key = resolveLlmKey( mode )
|
|
66
85
|
if( !key ){
|
|
67
|
-
|
|
86
|
+
const expected = mode === 'glm' ? 'ZAI_API_KEY' : 'ANTHROPIC_API_KEY'
|
|
87
|
+
console.error( `[will] WILL_LLM=${ mode } but no ${ expected } / WILL_LLM_API_KEY is set.` )
|
|
68
88
|
console.error( '[will] The Will would boot, perceive, and never speak. Set a key, or run keyless with WILL_LLM=mock.' )
|
|
69
89
|
process.exit( 2 )
|
|
70
90
|
}
|
|
71
91
|
|
|
72
92
|
// The ping validates key + balance + reachability, which is the failure class
|
|
73
93
|
// that strands an operator. It uses the pinned model when there is one, so a
|
|
74
|
-
// bad model id is caught too; otherwise the cheapest model stands in.
|
|
75
|
-
|
|
76
|
-
const
|
|
94
|
+
// bad model id is caught too; otherwise the cheapest model stands in. GLM
|
|
95
|
+
// speaks the same wire at Z.ai's compat endpoint, so one ping serves both.
|
|
96
|
+
const base = process.env.WILL_LLM_BASE_URL ?? defaultBaseFor( mode )
|
|
97
|
+
const model = process.env.WILL_LLM_MODEL
|
|
98
|
+
?? ( mode === 'glm' ? defaultModelFor( 'glm' ) : 'claude-haiku-4-5-20251001' )
|
|
77
99
|
try {
|
|
78
100
|
const res = await fetch( `${ base }/messages`, {
|
|
79
101
|
method: 'POST',
|
|
80
|
-
headers:
|
|
102
|
+
headers: anthropicWireHeaders( mode, key ),
|
|
81
103
|
body: JSON.stringify( { model, max_tokens: 1, messages: [ { role: 'user', content: 'ping' } ] } ),
|
|
82
104
|
signal: AbortSignal.timeout( 20_000 ),
|
|
83
105
|
} )
|
|
@@ -125,7 +147,7 @@ export async function bootWillFromEnv(): Promise<BootedWill> {
|
|
|
125
147
|
const opts: Omit<CreateWillOptions, 'identity'> = {
|
|
126
148
|
name, anatomy, tickMs,
|
|
127
149
|
...( process.env.WILL_LLM_MODEL ? { model: process.env.WILL_LLM_MODEL } : {} ),
|
|
128
|
-
...( process.env.WILL_LLM
|
|
150
|
+
...( process.env.WILL_LLM ? { llm: process.env.WILL_LLM as CreateWillOptions['llm'] } : {} ),
|
|
129
151
|
...( process.env.WILL_SEED ? { seed: parseInt( process.env.WILL_SEED ) } : {} ),
|
|
130
152
|
}
|
|
131
153
|
|