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