@mindot/will 0.3.0 → 0.4.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 +33 -11
- package/dist/channels/discord.d.ts +78 -0
- package/dist/channels/discord.js +193 -0
- package/dist/channels/discord.js.map +1 -0
- package/dist/cli.js +648 -330
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +141 -141
- package/dist/index.js +390 -316
- package/dist/index.js.map +1 -1
- package/dist/mcp/effectors.d.ts +1 -1
- package/dist/{will-B5eKs3Wv.d.ts → will-D-slky1N.d.ts} +4011 -3916
- package/package.json +6 -1
- package/src/channels/discord.ts +214 -0
- package/src/channels/roster.ts +87 -0
- package/src/channels/types.ts +46 -0
- package/src/cli.ts +34 -9
- package/src/cognition/agency/engines/deliberation.engine.ts +7 -7
- package/src/cognition/agency/execution.primitives.ts +11 -11
- package/src/cognition/agency/proactive.communicator.ts +8 -8
- package/src/cognition/config.mirror.entities.ts +2 -2
- package/src/cognition/conversation.memory.ts +1 -1
- package/src/cognition/faculties/executive.engine/commands.ts +7 -15
- package/src/cognition/faculties/executive.engine/engine.ts +64 -34
- package/src/cognition/faculties/executive.engine/escalation.buffer.ts +1 -1
- package/src/cognition/faculties/executive.engine/facet.ts +1 -1
- package/src/cognition/faculties/executive.engine/prompt.factory.ts +76 -61
- package/src/cognition/faculties/executive.engine/types.ts +1 -1
- package/src/cognition/faculties/introspection.engine.ts +1 -2
- package/src/cognition/faculties/planning.engine/engine.ts +38 -1
- package/src/cognition/faculties/planning.engine/plan.store.ts +42 -0
- package/src/cognition/faculties/planning.engine/plan.supervision.ts +7 -7
- package/src/cognition/faculties/theory.of.mind.ts +2 -2
- package/src/cognition/senses/audition.engine/engine.ts +21 -21
- package/src/host/boot.ts +70 -4
- package/src/llm/summarizer.ts +2 -2
- package/src/profiles/companion.ts +14 -14
- package/src/profiles/company-brain.ts +19 -19
- package/src/profiles/customer-service.ts +17 -17
- package/src/profiles/game-npc.ts +10 -10
- package/src/profiles/index.ts +2 -2
- package/src/profiles/smart-home.ts +16 -16
- package/src/runners/outreach.runner.ts +6 -9
- package/src/runners/social.runner.ts +1 -4
- package/src/runners/thin-shim.runner.ts +4 -6
- package/src/sdk/will.ts +27 -10
- package/src/stem/guards/identity.coherence.ts +5 -3
- package/src/stem/guards/identity.guard.ts +20 -9
- package/src/stem/index.ts +7 -7
- package/src/stem/mind.ts +182 -98
- package/src/stem/tracts/outbox.controller.ts +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mindot/will",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"author": "Fabrice <fabrice8@github.com>",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"zod": "^4.0.0"
|
|
22
22
|
},
|
|
23
23
|
"optionalDependencies": {
|
|
24
|
+
"discord.js": "^14.16.3",
|
|
24
25
|
"socket.io-client": "^4.8.1"
|
|
25
26
|
},
|
|
26
27
|
"exports": {
|
|
@@ -31,6 +32,10 @@
|
|
|
31
32
|
"./mcp": {
|
|
32
33
|
"import": "./dist/mcp/effectors.js",
|
|
33
34
|
"types": "./dist/mcp/effectors.d.ts"
|
|
35
|
+
},
|
|
36
|
+
"./discord": {
|
|
37
|
+
"import": "./dist/channels/discord.js",
|
|
38
|
+
"types": "./dist/channels/discord.d.ts"
|
|
34
39
|
}
|
|
35
40
|
},
|
|
36
41
|
"bugs": {
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// src/channels/discord.ts — a Will present in a Discord server
|
|
3
|
+
// ─────────────────────────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// The bridge relays both directions of the paradigm and nothing else:
|
|
6
|
+
//
|
|
7
|
+
// inbound guild/DM message → will.perceive({ from, speaker, text, thread })
|
|
8
|
+
// — every author is `discord:<userId>` (stable across guilds), the
|
|
9
|
+
// display name is *learned* by the mind, and each Discord channel
|
|
10
|
+
// is its own conversation thread.
|
|
11
|
+
// outbound will.on('message') → the addressee's last shared channel, else
|
|
12
|
+
// their DM, else the home channel. Proactive utterances (the mind
|
|
13
|
+
// speaking first) route the same way — that is the point.
|
|
14
|
+
//
|
|
15
|
+
// The Will decides when to speak. There is no command prefix and no forced
|
|
16
|
+
// reply: unaddressed chatter is perceived (salience-scored by audition) and
|
|
17
|
+
// silence is a valid outcome. `mentionOnly` narrows perception for busy
|
|
18
|
+
// servers; it does not turn the bridge into an ask() surface.
|
|
19
|
+
//
|
|
20
|
+
// discord.js is imported lazily inside `createDiscordClient` — tests (and any
|
|
21
|
+
// host that brings its own client) inject `client`, and the structural
|
|
22
|
+
// `DiscordLikeClient` type keeps the dependency out of the type graph.
|
|
23
|
+
// ─────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
import type { Will, WillMessage } from '#sdk/will'
|
|
26
|
+
import { ChannelRoster } from '#channels/roster'
|
|
27
|
+
import { chunkText, type ChannelBridge } from '#channels/types'
|
|
28
|
+
|
|
29
|
+
const DISCORD_MESSAGE_LIMIT = 2000
|
|
30
|
+
|
|
31
|
+
// ── The slice of discord.js the bridge actually uses (structural) ───────────
|
|
32
|
+
|
|
33
|
+
export interface DiscordLikeChannel {
|
|
34
|
+
send( content: string ): Promise<unknown>
|
|
35
|
+
sendTyping?(): Promise<unknown>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface DiscordLikeMessage {
|
|
39
|
+
content: string
|
|
40
|
+
cleanContent?: string
|
|
41
|
+
channelId: string
|
|
42
|
+
guildId?: string | null
|
|
43
|
+
author: { id: string; bot?: boolean; username?: string; displayName?: string }
|
|
44
|
+
member?: { displayName?: string } | null
|
|
45
|
+
mentions?: { has( userId: string ): boolean }
|
|
46
|
+
channel: DiscordLikeChannel
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface DiscordLikeClient {
|
|
50
|
+
user: { id: string; setPresence?( p: unknown ): void } | null
|
|
51
|
+
/** discord.js ≥14.22; polled so we needn't subscribe to the deprecated `ready`. */
|
|
52
|
+
isReady?(): boolean
|
|
53
|
+
on( event: 'messageCreate', fn: ( m: DiscordLikeMessage ) => void ): unknown
|
|
54
|
+
once( event: string, fn: () => void ): unknown
|
|
55
|
+
login( token: string ): Promise<unknown>
|
|
56
|
+
destroy(): Promise<unknown> | void
|
|
57
|
+
channels: { fetch( id: string ): Promise<unknown> }
|
|
58
|
+
users: { fetch( id: string ): Promise<{ send( content: string ): Promise<unknown> }> }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Options ──────────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
export interface DiscordBridgeOptions {
|
|
64
|
+
/** Bot token (Discord developer portal). Unused when `client` is injected pre-logged-in. */
|
|
65
|
+
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
|
|
70
|
+
/** Fallback channel for utterances with no reachable addressee. */
|
|
71
|
+
homeChannelId?: string
|
|
72
|
+
/** Roster path (default: ./.will/<willId>.discord.json). */
|
|
73
|
+
rosterPath?: string
|
|
74
|
+
/** Test / power-user seam: bring your own client; discord.js is never imported. */
|
|
75
|
+
client?: DiscordLikeClient
|
|
76
|
+
log?: ( msg: string ) => void
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── The bridge ───────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Connect a Will to Discord. Resolves once the bridge is live (logged in and
|
|
83
|
+
* relaying). Close it via the returned `ChannelBridge.close()` — the Will
|
|
84
|
+
* itself is not stopped; it simply loses this surface.
|
|
85
|
+
*/
|
|
86
|
+
export async function connectDiscord( will: Will, opts: DiscordBridgeOptions ): Promise<ChannelBridge> {
|
|
87
|
+
const log = opts.log ?? ( ( m: string ) => console.error( `[will:discord] ${ m }` ) )
|
|
88
|
+
const roster = new ChannelRoster( opts.rosterPath ?? `.will/${ will.id }.discord.json` )
|
|
89
|
+
const allowed = opts.channels?.length ? new Set( opts.channels ) : null
|
|
90
|
+
|
|
91
|
+
const client = opts.client ?? await createDiscordClient()
|
|
92
|
+
|
|
93
|
+
/** The most recently active allowed channel — last-resort proactive target. */
|
|
94
|
+
let lastActiveChannelId: string | null = opts.homeChannelId ?? null
|
|
95
|
+
|
|
96
|
+
// ── inbound: platform message → stimulus ──────────────────────────────────
|
|
97
|
+
client.on( 'messageCreate', message => { void onMessage( message ) } )
|
|
98
|
+
|
|
99
|
+
async function onMessage( message: DiscordLikeMessage ): Promise<void> {
|
|
100
|
+
const self = client.user
|
|
101
|
+
if( !self || message.author.id === self.id || message.author.bot ) return
|
|
102
|
+
|
|
103
|
+
const isDM = !message.guildId
|
|
104
|
+
if( !isDM && allowed && !allowed.has( message.channelId ) ) return
|
|
105
|
+
|
|
106
|
+
const addressed = isDM || ( message.mentions?.has( self.id ) ?? false )
|
|
107
|
+
if( opts.mentionOnly && !addressed ) return
|
|
108
|
+
|
|
109
|
+
const entityId = `discord:${ message.author.id }`
|
|
110
|
+
const speaker = message.member?.displayName ?? message.author.displayName ?? message.author.username
|
|
111
|
+
|
|
112
|
+
roster.record( {
|
|
113
|
+
entityId,
|
|
114
|
+
userId: message.author.id,
|
|
115
|
+
...( speaker ? { displayName: speaker } : {} ),
|
|
116
|
+
...( isDM ? { dmChannelId: message.channelId } : { lastChannelId: message.channelId } ),
|
|
117
|
+
} )
|
|
118
|
+
if( !isDM ) lastActiveChannelId = message.channelId
|
|
119
|
+
|
|
120
|
+
// Being addressed is the one moment a presence cue is honest — the mind
|
|
121
|
+
// may still choose silence, and typing expires on its own.
|
|
122
|
+
if( addressed ) await message.channel.sendTyping?.().catch( () => {} )
|
|
123
|
+
|
|
124
|
+
const text = message.cleanContent || message.content
|
|
125
|
+
if( !text.trim() ) return
|
|
126
|
+
|
|
127
|
+
await will.perceive( {
|
|
128
|
+
text,
|
|
129
|
+
from: entityId,
|
|
130
|
+
thread: `discord:${ message.channelId }`,
|
|
131
|
+
...( speaker ? { speaker } : {} ),
|
|
132
|
+
} )
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── outbound: projected utterance → the addressee ─────────────────────────
|
|
136
|
+
// The facade has no off(); the bridge gates its handler on `closed` instead.
|
|
137
|
+
let closed = false
|
|
138
|
+
will.on( 'message', ( m: WillMessage ) => { if( !closed ) void deliver( m ) } )
|
|
139
|
+
|
|
140
|
+
async function deliver( m: WillMessage ): Promise<void> {
|
|
141
|
+
const peer = m.to ? roster.resolve( m.to ) : undefined
|
|
142
|
+
const chunks = chunkText( m.content, DISCORD_MESSAGE_LIMIT )
|
|
143
|
+
|
|
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 ]
|
|
146
|
+
for( const id of channelIds ){
|
|
147
|
+
if( !id ) continue
|
|
148
|
+
try {
|
|
149
|
+
const channel = await client.channels.fetch( id ) as DiscordLikeChannel | null
|
|
150
|
+
if( !channel?.send ) continue
|
|
151
|
+
for( const chunk of chunks ) await channel.send( chunk )
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
catch { /* try the next route */ }
|
|
155
|
+
}
|
|
156
|
+
if( peer ){
|
|
157
|
+
try {
|
|
158
|
+
const user = await client.users.fetch( peer.userId )
|
|
159
|
+
for( const chunk of chunks ) await user.send( chunk )
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
catch { /* fall through */ }
|
|
163
|
+
}
|
|
164
|
+
log( `no route for utterance to '${ m.to }' — dropped (${ m.content.length } chars)` )
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── lifecycle ──────────────────────────────────────────────────────────────
|
|
168
|
+
const bridge: ChannelBridge = {
|
|
169
|
+
kind: 'discord',
|
|
170
|
+
async start(): Promise<void> {
|
|
171
|
+
if( !client.user ){
|
|
172
|
+
// discord.js ≥14.22 renamed `ready` → `clientReady`. Subscribing to the
|
|
173
|
+
// old name is what triggers its DeprecationWarning, so we take the new
|
|
174
|
+
// name and poll `isReady()` for older builds rather than listening.
|
|
175
|
+
const ready = new Promise<void>( resolve => {
|
|
176
|
+
let poll: ReturnType<typeof setInterval> | null = null
|
|
177
|
+
const done = (): void => { if( poll ) clearInterval( poll ); resolve() }
|
|
178
|
+
client.once( 'clientReady', done )
|
|
179
|
+
poll = setInterval( () => { if( client.isReady?.() ) done() }, 100 )
|
|
180
|
+
poll.unref?.()
|
|
181
|
+
} )
|
|
182
|
+
await client.login( opts.token ?? '' )
|
|
183
|
+
await ready
|
|
184
|
+
}
|
|
185
|
+
log( `${ will.name } is present on Discord as user ${ client.user?.id }` )
|
|
186
|
+
},
|
|
187
|
+
async close(): Promise<void> {
|
|
188
|
+
if( closed ) return
|
|
189
|
+
closed = true
|
|
190
|
+
roster.flush()
|
|
191
|
+
await Promise.resolve( client.destroy() ).catch( () => {} )
|
|
192
|
+
},
|
|
193
|
+
}
|
|
194
|
+
return bridge
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Build a real discord.js client (lazy import keeps it out of non-Discord hosts). */
|
|
198
|
+
async function createDiscordClient(): Promise<DiscordLikeClient> {
|
|
199
|
+
let mod: typeof import( 'discord.js' )
|
|
200
|
+
try { mod = await import( 'discord.js' ) }
|
|
201
|
+
catch {
|
|
202
|
+
throw new Error( 'discord.js is not installed (it is an optionalDependency) — run `bun add discord.js` / `npm i discord.js` and retry.' )
|
|
203
|
+
}
|
|
204
|
+
const { Client, GatewayIntentBits, Partials } = mod
|
|
205
|
+
return new Client( {
|
|
206
|
+
intents: [
|
|
207
|
+
GatewayIntentBits.Guilds,
|
|
208
|
+
GatewayIntentBits.GuildMessages,
|
|
209
|
+
GatewayIntentBits.MessageContent,
|
|
210
|
+
GatewayIntentBits.DirectMessages,
|
|
211
|
+
],
|
|
212
|
+
partials: [ Partials.Channel ], // DMs arrive on uncached channels
|
|
213
|
+
} ) as unknown as DiscordLikeClient
|
|
214
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// src/channels/roster.ts — who the Will knows on a platform, and where
|
|
3
|
+
// ─────────────────────────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// The mind knows *entities*; a platform knows user ids and channels. The roster
|
|
6
|
+
// is the durable seam between them: for each entity the Will has met on a
|
|
7
|
+
// channel it records how to reach them again — so a *proactive* utterance
|
|
8
|
+
// (`message.to` from the mind's own initiative) can find its person after a
|
|
9
|
+
// restart, not just within one session.
|
|
10
|
+
//
|
|
11
|
+
// It persists as a small JSON file next to the PMA artifact. Writes are
|
|
12
|
+
// throttled (the file is advisory routing state, not cognition — losing the
|
|
13
|
+
// last few seconds costs a fallback delivery, never memory).
|
|
14
|
+
// ─────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
17
|
+
import { dirname } from 'node:path'
|
|
18
|
+
|
|
19
|
+
/** How to reach one entity on the platform. */
|
|
20
|
+
export interface RosterEntry {
|
|
21
|
+
/** The mind-side entity id, e.g. 'discord:80351110224678912'. */
|
|
22
|
+
entityId: string
|
|
23
|
+
/** The platform-side user id. */
|
|
24
|
+
userId: string
|
|
25
|
+
/** Last display name seen (advisory — the *learned* name lives in the mind). */
|
|
26
|
+
displayName?: string
|
|
27
|
+
/** DM channel id, once one is known. */
|
|
28
|
+
dmChannelId?: string
|
|
29
|
+
/** Last shared (guild) channel this entity spoke in. */
|
|
30
|
+
lastChannelId?: string
|
|
31
|
+
/** Epoch ms of the last message seen from them. */
|
|
32
|
+
lastSeenAt: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const FLUSH_MS = 2_000
|
|
36
|
+
|
|
37
|
+
export class ChannelRoster {
|
|
38
|
+
private entries = new Map<string, RosterEntry>()
|
|
39
|
+
private dirty = false
|
|
40
|
+
private timer: ReturnType<typeof setTimeout> | null = null
|
|
41
|
+
|
|
42
|
+
constructor( private readonly path: string ) {
|
|
43
|
+
if( existsSync( path ) ){
|
|
44
|
+
try {
|
|
45
|
+
const raw = JSON.parse( readFileSync( path, 'utf8' ) ) as RosterEntry[]
|
|
46
|
+
for( const e of Array.isArray( raw ) ? raw : [] ) this.entries.set( e.entityId, e )
|
|
47
|
+
}
|
|
48
|
+
catch { /* a corrupt roster is not worth failing a boot over — start fresh */ }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Upsert what we just learned about an entity; schedules a throttled flush. */
|
|
53
|
+
record( update: { entityId: string; userId: string } & Partial<Omit<RosterEntry, 'entityId' | 'userId'>> ): RosterEntry {
|
|
54
|
+
const prev = this.entries.get( update.entityId )
|
|
55
|
+
const next: RosterEntry = {
|
|
56
|
+
lastSeenAt: Date.now(),
|
|
57
|
+
...prev,
|
|
58
|
+
...Object.fromEntries( Object.entries( update ).filter( ( [ , v ] ) => v !== undefined ) ) as typeof update,
|
|
59
|
+
}
|
|
60
|
+
this.entries.set( next.entityId, next )
|
|
61
|
+
this.dirty = true
|
|
62
|
+
if( !this.timer ){
|
|
63
|
+
this.timer = setTimeout( () => { this.timer = null; this.flush() }, FLUSH_MS )
|
|
64
|
+
this.timer.unref?.()
|
|
65
|
+
}
|
|
66
|
+
return next
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
resolve( entityId: string ): RosterEntry | undefined {
|
|
70
|
+
return this.entries.get( entityId )
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
all(): RosterEntry[] {
|
|
74
|
+
return [ ...this.entries.values() ]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Write to disk now (no-op when clean). Called by bridges on close. */
|
|
78
|
+
flush(): void {
|
|
79
|
+
if( !this.dirty ) return
|
|
80
|
+
try {
|
|
81
|
+
mkdirSync( dirname( this.path ), { recursive: true } )
|
|
82
|
+
writeFileSync( this.path, JSON.stringify( this.all(), null, 2 ) )
|
|
83
|
+
this.dirty = false
|
|
84
|
+
}
|
|
85
|
+
catch { /* advisory state — never take the mind down over it */ }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// src/channels/types.ts — the channel-bridge contract
|
|
3
|
+
// ─────────────────────────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// A channel bridge puts a Will *in a place where people already are* (Discord,
|
|
6
|
+
// Telegram, Slack, …). It is a host surface, not a cognition surface: it turns
|
|
7
|
+
// platform messages into `perceive` stimuli and delivers the Will's projected
|
|
8
|
+
// utterances back — nothing more. The paradigm survives the crossing:
|
|
9
|
+
//
|
|
10
|
+
// • every platform user is an entity the Will comes to know (`from`),
|
|
11
|
+
// with a *learned* name (`speaker`) — never a placeholder;
|
|
12
|
+
// • every platform channel/DM is a conversation thread (`thread`);
|
|
13
|
+
// • the Will decides when to speak. Silence is a valid outcome, so a
|
|
14
|
+
// bridge never fabricates a reply and never times a message out into
|
|
15
|
+
// an error.
|
|
16
|
+
//
|
|
17
|
+
// Bridges live at the same altitude as the MCP/HTTP hosts (src/mcp, src/serve):
|
|
18
|
+
// they wrap the SDK facade, not the stem.
|
|
19
|
+
// ─────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
/** A running connection between one Will and one platform. */
|
|
22
|
+
export interface ChannelBridge {
|
|
23
|
+
/** Platform kind, e.g. 'discord'. */
|
|
24
|
+
readonly kind: string
|
|
25
|
+
/** Connect and start relaying. Resolves once the bridge is live. */
|
|
26
|
+
start(): Promise<void>
|
|
27
|
+
/** Disconnect and release resources. Idempotent. */
|
|
28
|
+
close(): Promise<void>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Split a message into platform-sized chunks on natural boundaries. */
|
|
32
|
+
export function chunkText( text: string, max: number ): string[] {
|
|
33
|
+
if( text.length <= max ) return [ text ]
|
|
34
|
+
const chunks: string[] = []
|
|
35
|
+
let rest = text
|
|
36
|
+
while( rest.length > max ){
|
|
37
|
+
// Prefer a paragraph break, then a line break, then a space — else hard-cut.
|
|
38
|
+
const window = rest.slice( 0, max )
|
|
39
|
+
const cut = Math.max( window.lastIndexOf( '\n\n' ), window.lastIndexOf( '\n' ), window.lastIndexOf( ' ' ) )
|
|
40
|
+
const at = cut > max * 0.5 ? cut : max
|
|
41
|
+
chunks.push( rest.slice( 0, at ).trimEnd() )
|
|
42
|
+
rest = rest.slice( at ).trimStart()
|
|
43
|
+
}
|
|
44
|
+
if( rest ) chunks.push( rest )
|
|
45
|
+
return chunks
|
|
46
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
// src/cli.ts — the `will` command: host a persistent mind
|
|
4
4
|
// ─────────────────────────────────────────────────────────────
|
|
5
5
|
//
|
|
6
|
-
// will mcp
|
|
7
|
-
// will serve
|
|
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
|
|
8
9
|
//
|
|
9
10
|
// Both hosts raise the same mind the same way (see host/boot.ts): env-configured,
|
|
10
11
|
// woken from its PMA artifact when one exists, hibernated back on the way out —
|
|
@@ -24,14 +25,17 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
24
25
|
import { routeLogsToStderr, bootWillFromEnv } from '#root/host/boot'
|
|
25
26
|
import { buildWillMcpServer } from '#root/mcp/server'
|
|
26
27
|
import { buildWillHttpServer } from '#root/serve/server'
|
|
28
|
+
import { connectDiscord } from '#channels/discord'
|
|
27
29
|
|
|
28
30
|
// stdout is the MCP protocol channel under `will mcp` — route logs FIRST.
|
|
29
31
|
routeLogsToStderr()
|
|
30
32
|
|
|
31
|
-
const USAGE = `usage: will <mcp | serve>
|
|
33
|
+
const USAGE = `usage: will <mcp | serve | discord>
|
|
32
34
|
|
|
33
|
-
mcp
|
|
34
|
-
serve
|
|
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)
|
|
35
39
|
|
|
36
40
|
Shared env: WILL_NAME, WILL_IDENTITY, WILL_TIER, WILL_LLM, WILL_TICK_MS,
|
|
37
41
|
WILL_SEED, WILL_PMA_PATH, WILL_MCP_SERVERS. The mind persists across runs via
|
|
@@ -40,19 +44,40 @@ its PMA artifact.`
|
|
|
40
44
|
async function main(): Promise<void> {
|
|
41
45
|
const sub = process.argv[2]
|
|
42
46
|
|
|
43
|
-
if( sub !== 'mcp' && sub !== 'serve' ){
|
|
47
|
+
if( sub !== 'mcp' && sub !== 'serve' && sub !== 'discord' ){
|
|
44
48
|
console.error( sub ? `unknown subcommand: ${ sub }\n\n${ USAGE }` : USAGE )
|
|
45
49
|
process.exit( sub ? 2 : 0 )
|
|
46
50
|
}
|
|
47
51
|
|
|
48
|
-
|
|
52
|
+
// Fail on missing platform credentials BEFORE raising a mind.
|
|
53
|
+
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.' )
|
|
55
|
+
process.exit( 2 )
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const { will, name, pmaPath, tickMs, anatomy, onCleanup, shutdown } = await bootWillFromEnv()
|
|
49
59
|
|
|
50
60
|
if( sub === 'mcp' ){
|
|
51
61
|
// The MCP client owns our stdin — its disconnect is the shutdown signal.
|
|
52
62
|
process.stdin.on( 'end', () => void shutdown( 'client disconnected' ) )
|
|
53
63
|
const server = buildWillMcpServer( will, { pmaPath } )
|
|
54
64
|
await server.connect( new StdioServerTransport() )
|
|
55
|
-
console.error( `[will] ${ name } is listening on MCP stdio (tick ${ tickMs }ms,
|
|
65
|
+
console.error( `[will] ${ name } is listening on MCP stdio (tick ${ tickMs }ms, anatomy ${ anatomy })` )
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if( sub === 'discord' ){
|
|
70
|
+
const csv = ( v?: string ) => v?.split( ',' ).map( s => s.trim() ).filter( Boolean )
|
|
71
|
+
const bridge = await connectDiscord( will, {
|
|
72
|
+
token: process.env.DISCORD_BOT_TOKEN!,
|
|
73
|
+
channels: csv( process.env.WILL_DISCORD_CHANNELS ),
|
|
74
|
+
mentionOnly: /^(1|true|yes)$/i.test( process.env.WILL_DISCORD_MENTION_ONLY ?? '' ),
|
|
75
|
+
homeChannelId: process.env.WILL_DISCORD_HOME_CHANNEL,
|
|
76
|
+
rosterPath: pmaPath.replace( /(\.pma)?\.json$/, '' ) + '.discord.json',
|
|
77
|
+
} )
|
|
78
|
+
onCleanup( () => bridge.close() )
|
|
79
|
+
await bridge.start()
|
|
80
|
+
console.error( `[will] ${ name } is present on Discord (tick ${ tickMs }ms, anatomy ${ anatomy }) — it speaks when it decides to.` )
|
|
56
81
|
return
|
|
57
82
|
}
|
|
58
83
|
|
|
@@ -65,7 +90,7 @@ async function main(): Promise<void> {
|
|
|
65
90
|
server.once( 'error', reject )
|
|
66
91
|
server.listen( port, host, () => resolve() )
|
|
67
92
|
} )
|
|
68
|
-
console.error( `[will] ${ name } is listening on http://${ host }:${ port } (tick ${ tickMs }ms,
|
|
93
|
+
console.error( `[will] ${ name } is listening on http://${ host }:${ port } (tick ${ tickMs }ms, anatomy ${ anatomy })` )
|
|
69
94
|
console.error( `[will] try: curl -X POST http://${ host }:${ port }/perceive -H 'content-type: application/json' -d '{"text":"Hello"}'` )
|
|
70
95
|
}
|
|
71
96
|
|
|
@@ -42,7 +42,7 @@ interface DeliberationFacetHandle {
|
|
|
42
42
|
destroy(): void
|
|
43
43
|
}
|
|
44
44
|
export interface DeliberationFacetProvider {
|
|
45
|
-
spawnFacet(): { attention: 'available' | 'full'; handle?: DeliberationFacetHandle }
|
|
45
|
+
spawnFacet( role?: 'deliberation' ): { attention: 'available' | 'full'; handle?: DeliberationFacetHandle }
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
interface Candidate {
|
|
@@ -58,8 +58,8 @@ interface Candidate {
|
|
|
58
58
|
|
|
59
59
|
const DELIBERATION_INSTRUCTIONS =
|
|
60
60
|
'Automatic action-selection was uncertain or the stakes were high. From the candidate actions ' +
|
|
61
|
-
'listed above, choose the ONE that best fits who
|
|
62
|
-
'actions that are not listed. Put
|
|
61
|
+
'listed above, choose the ONE that best fits who I am and my situation. Do not invent ' +
|
|
62
|
+
'actions that are not listed. Put my chosen action as my single action; its "type" must be ' +
|
|
63
63
|
'exactly one of the candidate names.'
|
|
64
64
|
|
|
65
65
|
export class DeliberationEngine implements CognitiveEngine {
|
|
@@ -143,7 +143,7 @@ export class DeliberationEngine implements CognitiveEngine {
|
|
|
143
143
|
): Promise<string> {
|
|
144
144
|
try {
|
|
145
145
|
if( !this._handle ){
|
|
146
|
-
const spawned = this._provider!.spawnFacet()
|
|
146
|
+
const spawned = this._provider!.spawnFacet('deliberation')
|
|
147
147
|
if( spawned.attention === 'full' || !spawned.handle ){
|
|
148
148
|
logger.info( '[deliberation] facet budget full — confirming substrate winner' )
|
|
149
149
|
return provisional
|
|
@@ -209,9 +209,9 @@ export class DeliberationEngine implements CognitiveEngine {
|
|
|
209
209
|
// facet owns the interruption in-character rather than reasoning in a vacuum.
|
|
210
210
|
const preemptedFrom = str( meta['preemptedFrom'] )
|
|
211
211
|
if( preemptedFrom )
|
|
212
|
-
lines.push( `
|
|
212
|
+
lines.push( `I just broke off a pending action ("${ preemptedFrom }") because something more pressing pulled at me. Decide what to do now:` )
|
|
213
213
|
else
|
|
214
|
-
lines.push( '
|
|
214
|
+
lines.push( 'My automatic action-selection was uncertain. Candidate actions:' )
|
|
215
215
|
candidates.forEach( ( c, i ) => {
|
|
216
216
|
const to = c.targetEntityId ? ` toward ${ c.targetEntityId }` : ''
|
|
217
217
|
// The ability's meaning, so the facet weighs what each option IS FOR rather
|
|
@@ -219,7 +219,7 @@ export class DeliberationEngine implements CognitiveEngine {
|
|
|
219
219
|
const what = c.description ? ` — ${ c.description }` : ''
|
|
220
220
|
// Channel B: name the plan link so the facet chooses as the self pursuing it,
|
|
221
221
|
// not blindly among labels ("this one is the next step of the plan I'm on").
|
|
222
|
-
const plan = c.fromPlan ? " (
|
|
222
|
+
const plan = c.fromPlan ? " (my current plan's next step)" : ''
|
|
223
223
|
lines.push( `${ i + 1 }. ${ c.schema }${ to }${ what }${ plan }` )
|
|
224
224
|
})
|
|
225
225
|
return lines.join( '\n' )
|
|
@@ -59,7 +59,7 @@ export function enact( ctx: EnactionContext ): Enaction {
|
|
|
59
59
|
const name = str( ctx.parameters['targetEntityName'] ) ?? ctx.targetEntityId ?? 'them'
|
|
60
60
|
return {
|
|
61
61
|
mode, success: true, outcomeQuality: 0.7, valence: 0.1,
|
|
62
|
-
description: `
|
|
62
|
+
description: `I reach toward ${ name }. The words are sent; their effect is not yet known.`,
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
|
|
@@ -81,26 +81,26 @@ function syncStance( ctx: EnactionContext ): Enaction {
|
|
|
81
81
|
|
|
82
82
|
switch( schema.id ){
|
|
83
83
|
case 'rest':
|
|
84
|
-
// More restorative the more depleted
|
|
85
|
-
return sync( 0.5 + ( 1 - e01 ) * 0.4, 0.15, '
|
|
84
|
+
// More restorative the more depleted the Will was.
|
|
85
|
+
return sync( 0.5 + ( 1 - e01 ) * 0.4, 0.15, 'I let myself recover; the pressure eases a little.' )
|
|
86
86
|
case 'withdraw':
|
|
87
|
-
return sync( 0.5 + s01 * 0.3, 0.05 + s01 * 0.1, '
|
|
87
|
+
return sync( 0.5 + s01 * 0.3, 0.05 + s01 * 0.1, 'I pull back from the press of things; the world quietens.' )
|
|
88
88
|
case 'reflect':
|
|
89
|
-
return sync( 0.6, 0.05, '
|
|
89
|
+
return sync( 0.6, 0.05, 'I turn inward; patterns from recent events settle into place.' )
|
|
90
90
|
case 'attend':
|
|
91
|
-
return sync( 0.6, 0.0, '
|
|
91
|
+
return sync( 0.6, 0.0, 'I concentrate, mobilizing more of my attention.' )
|
|
92
92
|
case 'orient':
|
|
93
|
-
return sync( 0.5, 0.0, '
|
|
93
|
+
return sync( 0.5, 0.0, 'My awareness sweeps the situation, taking its measure.' )
|
|
94
94
|
case 'wait':
|
|
95
|
-
return sync( 0.5, 0.0, '
|
|
95
|
+
return sync( 0.5, 0.0, 'I let time pass; regulatory processes continue their quiet work.' )
|
|
96
96
|
case 'express':
|
|
97
|
-
return sync( 0.6, 0.1, '
|
|
97
|
+
return sync( 0.6, 0.1, 'My inner state becomes outwardly visible.' )
|
|
98
98
|
case 'inspect': {
|
|
99
99
|
const focus = str( parameters['focus'] ) ?? 'it'
|
|
100
|
-
return sync( 0.65, 0.05, `
|
|
100
|
+
return sync( 0.65, 0.05, `I examine ${ focus } closely; more of its detail resolves.` )
|
|
101
101
|
}
|
|
102
102
|
default:
|
|
103
|
-
return sync( 0.5, 0.0, `
|
|
103
|
+
return sync( 0.5, 0.0, `I enact ${ schema.id }.` )
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
106
|
|
|
@@ -77,12 +77,12 @@ export class ProactiveCommunicator {
|
|
|
77
77
|
): Promise<ActionResult> {
|
|
78
78
|
return {
|
|
79
79
|
success: true,
|
|
80
|
-
description: `
|
|
80
|
+
description: `I open myself to incoming communication. Others may now reach me through available channels.`,
|
|
81
81
|
commands,
|
|
82
82
|
feedback: {
|
|
83
83
|
outcomeQuality: 1.0,
|
|
84
84
|
surprise: 0.05,
|
|
85
|
-
lessons: [ 'Being reachable allows others to connect with
|
|
85
|
+
lessons: [ 'Being reachable allows others to connect with me.' ],
|
|
86
86
|
},
|
|
87
87
|
}
|
|
88
88
|
}
|
|
@@ -105,7 +105,7 @@ export class ProactiveCommunicator {
|
|
|
105
105
|
|
|
106
106
|
return {
|
|
107
107
|
success: true,
|
|
108
|
-
description: `
|
|
108
|
+
description: `I ${gestureType} toward ${targetEntityId}. The gesture is directed and sincere.`,
|
|
109
109
|
commands,
|
|
110
110
|
feedback: {
|
|
111
111
|
outcomeQuality: 0.8,
|
|
@@ -137,7 +137,7 @@ export class ProactiveCommunicator {
|
|
|
137
137
|
|
|
138
138
|
return {
|
|
139
139
|
success: true,
|
|
140
|
-
description: `
|
|
140
|
+
description: `I broadcast: "${finalContent.slice( 0, 80 )}${finalContent.length > 80 ? '…' : ''}"`,
|
|
141
141
|
commands,
|
|
142
142
|
feedback: {
|
|
143
143
|
outcomeQuality: 0.75,
|
|
@@ -168,7 +168,7 @@ export class ProactiveCommunicator {
|
|
|
168
168
|
if( !targetEntityId ){
|
|
169
169
|
return {
|
|
170
170
|
success: false,
|
|
171
|
-
description: `
|
|
171
|
+
description: `I want to ${effectorName} but there is no one specific to reach out to.`,
|
|
172
172
|
commands,
|
|
173
173
|
feedback: {
|
|
174
174
|
outcomeQuality: 0,
|
|
@@ -181,7 +181,7 @@ export class ProactiveCommunicator {
|
|
|
181
181
|
if( bubbles.length === 0 ){
|
|
182
182
|
return {
|
|
183
183
|
success: false,
|
|
184
|
-
description: `
|
|
184
|
+
description: `I wanted to ${effectorName} ${targetEntityName} but didn't write anything.`,
|
|
185
185
|
commands,
|
|
186
186
|
feedback: { outcomeQuality: 0, surprise: 0.1, lessons: [ 'Provide a messages array with the actual words.' ] },
|
|
187
187
|
}
|
|
@@ -261,12 +261,12 @@ export class ProactiveCommunicator {
|
|
|
261
261
|
|
|
262
262
|
return {
|
|
263
263
|
success: true,
|
|
264
|
-
description: `
|
|
264
|
+
description: `I reach out to ${targetEntityName}: "${fullReply.slice( 0, 80 )}${fullReply.length > 80 ? '…' : ''}"`,
|
|
265
265
|
commands,
|
|
266
266
|
feedback: {
|
|
267
267
|
outcomeQuality: 0.85,
|
|
268
268
|
surprise: 0.15,
|
|
269
|
-
lessons: [ `
|
|
269
|
+
lessons: [ `My message is queued for delivery to ${targetEntityName}.` ],
|
|
270
270
|
},
|
|
271
271
|
}
|
|
272
272
|
}
|
|
@@ -33,8 +33,8 @@ export function buildEngineConfigEntities( config: WillConfig, executiveInterval
|
|
|
33
33
|
id: 'engine-config-system',
|
|
34
34
|
engine: 'system',
|
|
35
35
|
params: {
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
anatomy: config.anatomy ?? 'mind',
|
|
37
|
+
model: config.model ?? '',
|
|
38
38
|
tickIntervalMs: config.tickIntervalMs ?? 1000
|
|
39
39
|
}
|
|
40
40
|
},
|
|
@@ -69,7 +69,7 @@ export function buildConversationExchange( input: ConversationExchangeInput ): C
|
|
|
69
69
|
tags: [ 'conversation', 'exchange', `entity:${ entityId }` ],
|
|
70
70
|
summary: userMessage
|
|
71
71
|
? `${ name }: "${ userMessage.slice( 0, 100 ) }" → "${ willReply.slice( 0, 100 ) }"`
|
|
72
|
-
: `
|
|
72
|
+
: `I → ${ name }: "${ willReply.slice( 0, 140 ) }"`,
|
|
73
73
|
entityId,
|
|
74
74
|
entityName: name,
|
|
75
75
|
userMessage,
|