@mindot/will 0.8.0 → 0.10.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 +15 -11
- package/dist/index.d.ts +188 -141
- package/dist/index.js +6985 -3337
- package/dist/index.js.map +1 -1
- package/dist/surface/channels/discord.d.ts +177 -0
- package/dist/surface/channels/discord.js +394 -0
- package/dist/surface/channels/discord.js.map +1 -0
- package/dist/{channels → surface/channels}/whatsapp.d.ts +2 -2
- package/dist/{channels → surface/channels}/whatsapp.js +11 -5
- package/dist/surface/channels/whatsapp.js.map +1 -0
- package/dist/{cli.js → surface/cli.js} +5382 -1573
- package/dist/surface/cli.js.map +1 -0
- package/dist/{mcp → surface/mcp}/effectors.d.ts +1 -1
- package/dist/{mcp → surface/mcp}/effectors.js +10 -4
- package/dist/surface/mcp/effectors.js.map +1 -0
- package/dist/{will-cS6k4uiJ.d.ts → will-evj9_vrd.d.ts} +6658 -5078
- package/package.json +11 -12
- package/src/cognition/agency/consequence.ts +356 -1
- package/src/cognition/agency/conversation.aim.ts +292 -0
- package/src/cognition/agency/engines/action.selector.ts +142 -5
- package/src/cognition/agency/engines/affordance.synthesizer.ts +200 -7
- package/src/cognition/agency/engines/deliberation.engine.ts +76 -3
- package/src/cognition/agency/engines/motor.schema.executor.ts +509 -32
- package/src/cognition/agency/engines/reafference.engine.ts +160 -4
- package/src/cognition/agency/execution.primitives.ts +17 -4
- package/src/cognition/agency/proactive.communicator.ts +19 -3
- package/src/cognition/agency/reconcile.learning.ts +5 -4
- package/src/cognition/agency/restart.ts +66 -0
- package/src/cognition/agency/schemas/innate.ts +96 -1
- package/src/cognition/agency/schemas/repertoire.ts +19 -1
- package/src/cognition/agency/selection.scoring.ts +49 -0
- package/src/cognition/agency/settlement.ts +203 -0
- package/src/cognition/agency/types.ts +73 -1
- 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 +108 -1
- package/src/cognition/event.schemas.ts +22 -8
- package/src/cognition/faculties/affective.blender.ts +21 -4
- package/src/cognition/faculties/autobiographical.narrator.ts +5 -10
- package/src/cognition/faculties/circadian.oscillator.ts +36 -7
- package/src/cognition/faculties/episodic.consolidator.ts +59 -3
- package/src/cognition/faculties/executive.engine/action.record.ts +129 -0
- package/src/cognition/faculties/executive.engine/commands.ts +189 -14
- package/src/cognition/faculties/executive.engine/context.ts +106 -46
- package/src/cognition/faculties/executive.engine/deliberate.reasoning.ts +1 -1
- package/src/cognition/faculties/executive.engine/engine.ts +714 -136
- package/src/cognition/faculties/executive.engine/escalation.buffer.ts +230 -45
- package/src/cognition/faculties/executive.engine/facet.supervisor.ts +320 -65
- package/src/cognition/faculties/executive.engine/facet.ts +81 -26
- package/src/cognition/faculties/executive.engine/gating.ts +14 -14
- package/src/cognition/faculties/executive.engine/parser.ts +120 -38
- package/src/cognition/faculties/executive.engine/prompt.factory.ts +331 -38
- package/src/cognition/faculties/executive.engine/types.ts +84 -12
- package/src/cognition/faculties/exteroception.ts +99 -98
- package/src/cognition/faculties/goal.manager.ts +144 -16
- package/src/cognition/faculties/known.entity.tracker.ts +287 -29
- package/src/cognition/faculties/moral.evaluator.ts +8 -3
- package/src/cognition/faculties/persona.consolidator.ts +141 -0
- package/src/cognition/faculties/planning.engine/engine.ts +4 -4
- package/src/cognition/faculties/planning.engine/plan.frontier.ts +1 -1
- package/src/cognition/faculties/reputation.tracker.ts +66 -2
- package/src/cognition/faculties/self.model.updater.ts +19 -12
- package/src/cognition/faculties/semantic.engine/integrator.ts +1 -1
- 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 +16 -21
- package/src/cognition/identity.entity.ts +205 -0
- package/src/cognition/index.ts +7 -0
- package/src/cognition/instruction.handler.ts +1 -1
- package/src/cognition/memory/vector.adapter.ts +12 -3
- package/src/cognition/memory/vector.embedder.ts +45 -4
- package/src/cognition/percept.entity.ts +122 -0
- package/src/cognition/persona.prior.ts +6 -0
- package/src/cognition/sense.boundary.ts +176 -0
- package/src/cognition/senses/audition.engine/engine.ts +492 -53
- package/src/cognition/senses/base.sense.engine.ts +105 -7
- package/src/cognition/senses/index.ts +83 -13
- package/src/cognition/senses/provenance.ts +128 -0
- package/src/cognition/senses/somatosensation.engine.ts +117 -9
- package/src/cognition/social.identity.ts +296 -0
- package/src/cognition/utilities/token.tracker.ts +94 -10
- package/src/core/orchestrator.ts +51 -0
- package/src/index.ts +39 -5
- package/src/llm/gate.ts +48 -0
- package/src/llm/index.ts +127 -33
- package/src/llm/routing.ts +6 -0
- package/src/llm/summarizer.ts +1 -1
- package/src/llm/wire.contracts.ts +57 -0
- package/src/pma/index.ts +68 -54
- package/src/stem/assembly.audit.ts +1 -0
- package/src/stem/guards/identity.coherence.ts +1 -1
- package/src/stem/index.ts +171 -23
- package/src/stem/mind.ts +198 -57
- package/src/stem/policy/arbiter.ts +10 -0
- package/src/stem/profiles/built-in.ts +7 -0
- package/src/{profiles → stem/profiles}/companion.ts +1 -1
- package/src/{profiles → stem/profiles}/company-brain.ts +1 -1
- package/src/{profiles → stem/profiles}/customer-service.ts +1 -1
- package/src/{profiles → stem/profiles}/game-npc.ts +1 -1
- package/src/{profiles → stem/profiles}/index.ts +1 -1
- package/src/{profiles → stem/profiles}/smart-home.ts +1 -1
- package/src/stem/tracts/effector/escalation.lifecycle.ts +239 -0
- package/src/stem/tracts/effector/policy.enforcement.ts +261 -0
- package/src/stem/tracts/effector/types.ts +110 -0
- package/src/stem/tracts/effector.controller.ts +112 -401
- package/src/stem/tracts/outbox.controller.ts +30 -17
- package/src/stem/tracts/outbox.writer.ts +40 -2
- package/src/stem/tracts/sensory.controller.ts +6 -6
- package/src/stem/tracts/session.logger.ts +6 -1
- package/src/stem/tracts/transport/types.ts +20 -1
- package/src/stem/tracts/transport.controller.ts +33 -9
- package/src/surface/channels/discord.ts +609 -0
- package/src/{channels → surface/channels}/roster.ts +1 -1
- package/src/surface/channels/types.ts +136 -0
- package/src/{channels → surface/channels}/whatsapp.ts +22 -10
- package/src/{cli.ts → surface/cli.ts} +14 -9
- package/src/{host → surface/host}/boot.ts +3 -3
- package/src/{host → surface/host}/utterances.ts +2 -2
- package/src/{mcp → surface/mcp}/effectors.ts +19 -6
- package/src/{mcp → surface/mcp}/server.ts +19 -5
- package/src/{sdk → surface/sdk}/will.ts +194 -33
- package/src/{serve → surface/serve}/server.ts +11 -4
- package/src/types.ts +24 -3
- package/dist/channels/discord.d.ts +0 -69
- package/dist/channels/discord.js +0 -193
- package/dist/channels/discord.js.map +0 -1
- package/dist/channels/whatsapp.js.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/mcp/effectors.js.map +0 -1
- package/src/channels/discord.ts +0 -214
- package/src/channels/types.ts +0 -46
- package/src/cognition/faculties/executive.engine/messages.ts +0 -102
- package/src/extensions/livestream.ext.ts +0 -570
- package/src/extensions/time.ext.ts +0 -339
- package/src/profiles/built-in.ts +0 -7
- package/src/runners/coherence.runner.ts +0 -49
- package/src/runners/outreach.runner.ts +0 -155
- package/src/runners/social.runner.ts +0 -179
- package/src/runners/thin-shim.runner.ts +0 -205
- /package/dist/{cli.d.ts → surface/cli.d.ts} +0 -0
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// src/surface/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.sense({ 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 '#surface/sdk/will'
|
|
26
|
+
import { ChannelRoster } from '#surface/channels/roster'
|
|
27
|
+
import { chunkText, renderAttachments, isTextual, type ChannelBridge, type ChannelAttachment } from '#surface/channels/types'
|
|
28
|
+
|
|
29
|
+
const DISCORD_MESSAGE_LIMIT = 2000
|
|
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
|
+
|
|
44
|
+
/**
|
|
45
|
+
* How much of her own message to quote back when someone reacts to it.
|
|
46
|
+
*
|
|
47
|
+
* Enough to identify WHICH thing was agreed with — a bare "someone reacted 👍"
|
|
48
|
+
* closes the answered loop but tells the mind nothing about what was affirmed,
|
|
49
|
+
* and 0.9.0 established that an answer without its content is worse than none:
|
|
50
|
+
* it invites acting on an answer never seen.
|
|
51
|
+
*/
|
|
52
|
+
const REACTION_QUOTE_CHARS = 140
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* What to call the room this was said in — `#general`, `#general › release-cut`,
|
|
56
|
+
* or `#general in Mindot` when the server is known.
|
|
57
|
+
*
|
|
58
|
+
* A LABEL, not an address. `discord:1531…` is how a message gets to the room;
|
|
59
|
+
* this is what a person calls it, and 0.9.0 established those are different
|
|
60
|
+
* facts. Without it a room had a dossier and no name, so it reached the mind as
|
|
61
|
+
* "something" — which is what the prompt says for a `thing` it cannot name — and
|
|
62
|
+
* a mind choosing where to speak was picking between two opaque numbers.
|
|
63
|
+
*
|
|
64
|
+
* Returns undefined for a DM: a private thread is not a place, it is the person,
|
|
65
|
+
* and the tracker deliberately gives it no dossier to name.
|
|
66
|
+
*/
|
|
67
|
+
function roomLabel( message: DiscordLikeMessage ): string | undefined {
|
|
68
|
+
if( !message.guildId ) return undefined
|
|
69
|
+
const own = message.channel?.name
|
|
70
|
+
if( !own ) return undefined
|
|
71
|
+
const parent = message.channel?.parent?.name
|
|
72
|
+
const room = parent ? `#${ parent } › ${ own }` : `#${ own }`
|
|
73
|
+
const guild = message.guild?.name
|
|
74
|
+
return guild ? `${ room } in ${ guild }` : room
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── The slice of discord.js the bridge actually uses (structural) ───────────
|
|
78
|
+
|
|
79
|
+
export interface DiscordLikeChannel {
|
|
80
|
+
send( content: string ): Promise<unknown>
|
|
81
|
+
sendTyping?(): Promise<unknown>
|
|
82
|
+
/** `general` for a text channel; absent on a DM, which has no name and is a person. */
|
|
83
|
+
name?: string | null
|
|
84
|
+
/** The thread's parent channel, so a thread reads as "#general › release-cut". */
|
|
85
|
+
parent?: { name?: string | null } | null
|
|
86
|
+
/** What the room is FOR, in the server's own words. Only read when asked. */
|
|
87
|
+
topic?: string | null
|
|
88
|
+
/** How many are in it — a count, never a roster. */
|
|
89
|
+
memberCount?: number
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface DiscordLikeAttachment {
|
|
93
|
+
name?: string | null
|
|
94
|
+
contentType?: string | null
|
|
95
|
+
size?: number
|
|
96
|
+
url?: string
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface DiscordLikeMessage {
|
|
100
|
+
content: string
|
|
101
|
+
cleanContent?: string
|
|
102
|
+
channelId: string
|
|
103
|
+
guildId?: string | null
|
|
104
|
+
/** The server this was said in. Its NAME is what a person calls the place. */
|
|
105
|
+
guild?: { name?: string | null } | null
|
|
106
|
+
author: { id: string; bot?: boolean; username?: string; displayName?: string }
|
|
107
|
+
member?: { displayName?: string } | null
|
|
108
|
+
mentions?: { has( userId: string ): boolean }
|
|
109
|
+
channel: DiscordLikeChannel
|
|
110
|
+
/**
|
|
111
|
+
* Files riding with the message.
|
|
112
|
+
*
|
|
113
|
+
* discord.js hands us a `Collection`, which extends `Map` — so iterating it
|
|
114
|
+
* directly yields `[id, attachment]` PAIRS, not attachments. Typing this as a
|
|
115
|
+
* bare `Iterable` was wrong and silently produced `name: undefined` against
|
|
116
|
+
* the real client while passing every test, because the test fake injects an
|
|
117
|
+
* array. Both shapes are accepted now and normalised in `collectAttachments`.
|
|
118
|
+
*/
|
|
119
|
+
attachments?: ReadonlyMap<string, DiscordLikeAttachment> | Iterable<DiscordLikeAttachment>
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* A reaction, and the message it lands on.
|
|
124
|
+
*
|
|
125
|
+
* Both halves may be PARTIAL: Discord delivers a reaction on an uncached message
|
|
126
|
+
* with almost every field empty, which is the normal case for a message the bot
|
|
127
|
+
* sent before its current process started. `fetch()` fills it in, and the handler
|
|
128
|
+
* must call it rather than reading through the hole.
|
|
129
|
+
*/
|
|
130
|
+
export interface DiscordLikeReaction {
|
|
131
|
+
emoji: { name?: string | null; id?: string | null }
|
|
132
|
+
message: DiscordLikeMessage & {
|
|
133
|
+
partial?: boolean
|
|
134
|
+
author?: { id: string; bot?: boolean; username?: string; displayName?: string }
|
|
135
|
+
fetch?(): Promise<DiscordLikeReaction['message']>
|
|
136
|
+
}
|
|
137
|
+
partial?: boolean
|
|
138
|
+
fetch?(): Promise<DiscordLikeReaction>
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface DiscordLikeReactor {
|
|
142
|
+
id: string
|
|
143
|
+
bot?: boolean
|
|
144
|
+
username?: string
|
|
145
|
+
displayName?: string
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface DiscordLikeClient {
|
|
149
|
+
user: { id: string; setPresence?( p: unknown ): void } | null
|
|
150
|
+
/** discord.js ≥14.22; polled so we needn't subscribe to the deprecated `ready`. */
|
|
151
|
+
isReady?(): boolean
|
|
152
|
+
on( event: 'messageCreate', fn: ( m: DiscordLikeMessage ) => void ): unknown
|
|
153
|
+
on( event: 'messageReactionAdd', fn: ( r: DiscordLikeReaction, u: DiscordLikeReactor ) => void ): unknown
|
|
154
|
+
once( event: string, fn: () => void ): unknown
|
|
155
|
+
login( token: string ): Promise<unknown>
|
|
156
|
+
destroy(): Promise<unknown> | void
|
|
157
|
+
channels: { fetch( id: string ): Promise<unknown> }
|
|
158
|
+
users: { fetch( id: string ): Promise<{ send( content: string ): Promise<unknown> }> }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Options ──────────────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
export interface DiscordBridgeOptions {
|
|
164
|
+
/** Bot token (Discord developer portal). Unused when `client` is injected pre-logged-in. */
|
|
165
|
+
token?: string
|
|
166
|
+
/**
|
|
167
|
+
* Channel ids the Will inhabits. Unset — or the single wildcard `'*'` — means
|
|
168
|
+
* every channel it can see, so adding it to a new channel in Discord is enough.
|
|
169
|
+
* A list restrains it to exactly those, and a message anywhere else is dropped
|
|
170
|
+
* at the bridge: the Will never perceives it and its silence there is not a choice.
|
|
171
|
+
*/
|
|
172
|
+
channels?: readonly string[]
|
|
173
|
+
/**
|
|
174
|
+
* Where the Will only perceives guild messages that @mention it. DMs are always
|
|
175
|
+
* perceived either way.
|
|
176
|
+
*
|
|
177
|
+
* `true` — everywhere
|
|
178
|
+
* `[ 'id', … ]` — only in those channels; it listens openly elsewhere
|
|
179
|
+
* omitted / `false` — nowhere
|
|
180
|
+
*
|
|
181
|
+
* The list form is what makes a wide-open roster usable: present in every channel,
|
|
182
|
+
* but a quiet participant in the busy ones.
|
|
183
|
+
*/
|
|
184
|
+
mentionOnly?: boolean | readonly string[]
|
|
185
|
+
/** Fallback channel for utterances with no reachable addressee. */
|
|
186
|
+
homeChannelId?: string
|
|
187
|
+
/** Roster path (default: ./.will/<willId>.discord.json). */
|
|
188
|
+
rosterPath?: string
|
|
189
|
+
/**
|
|
190
|
+
* Read the contents of text-like attachments (.md, .txt, .json, …) into the
|
|
191
|
+
* percept, rather than only naming them. Default true.
|
|
192
|
+
*
|
|
193
|
+
* Only Discord's own CDN is ever fetched, and only up to a size cap. Set false
|
|
194
|
+
* for a bridge that should never pull remote bytes — the Will still perceives
|
|
195
|
+
* that a file arrived and can ask about it.
|
|
196
|
+
*/
|
|
197
|
+
readAttachments?: boolean
|
|
198
|
+
/** Test / power-user seam: bring your own client; discord.js is never imported. */
|
|
199
|
+
client?: DiscordLikeClient
|
|
200
|
+
log?: ( msg: string ) => void
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── The bridge ───────────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Connect a Will to Discord. Resolves once the bridge is live (logged in and
|
|
207
|
+
* relaying). Close it via the returned `ChannelBridge.close()` — the Will
|
|
208
|
+
* itself is not stopped; it simply loses this surface.
|
|
209
|
+
*/
|
|
210
|
+
export async function connectDiscord( will: Will, opts: DiscordBridgeOptions ): Promise<ChannelBridge> {
|
|
211
|
+
const log = opts.log ?? ( ( m: string ) => console.error(`[will:discord] ${ m }`) )
|
|
212
|
+
const roster = new ChannelRoster( opts.rosterPath ?? `.will/${ will.id }.discord.json`)
|
|
213
|
+
// `null` = everywhere. An explicit '*' reads the same as omitting the list, so a
|
|
214
|
+
// host can say "all channels" out loud instead of by leaving a variable blank.
|
|
215
|
+
const allowed = opts.channels?.length && !opts.channels.includes('*')
|
|
216
|
+
? new Set( opts.channels )
|
|
217
|
+
: null
|
|
218
|
+
|
|
219
|
+
// Mention-gating is either global (true) or scoped to named channels.
|
|
220
|
+
const mentionEverywhere = opts.mentionOnly === true
|
|
221
|
+
const mentionIn = Array.isArray( opts.mentionOnly ) && opts.mentionOnly.length
|
|
222
|
+
? new Set( opts.mentionOnly )
|
|
223
|
+
: null
|
|
224
|
+
|
|
225
|
+
const client = opts.client ?? await createDiscordClient()
|
|
226
|
+
|
|
227
|
+
/** The most recently active allowed channel — last-resort proactive target. */
|
|
228
|
+
let lastActiveChannelId: string | null = opts.homeChannelId ?? null
|
|
229
|
+
|
|
230
|
+
// ── inbound: platform message → stimulus ──────────────────────────────────
|
|
231
|
+
client.on('messageCreate', message => { void onMessage( message ) } )
|
|
232
|
+
client.on('messageReactionAdd', ( reaction, user ) => { void onReaction( reaction, user ) } )
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Someone reacted to something the Will said.
|
|
236
|
+
*
|
|
237
|
+
* A reaction IS an answer, and until this existed the mind read it as silence.
|
|
238
|
+
* `conversation.received` is written only from a text percept, so a 👍 — the
|
|
239
|
+
* commonest acknowledgement on Discord — produced no record at all: the turn it
|
|
240
|
+
* answered stayed open, the reply window elapsed, and the mind concluded it had
|
|
241
|
+
* been ignored. That conclusion is not inert. It reaches reputation as
|
|
242
|
+
* reliability (−0.06 per unanswered turn) and goals as absence of progress, so
|
|
243
|
+
* answering her with an emoji actively taught her that person does not respond.
|
|
244
|
+
*
|
|
245
|
+
* Only reactions on the Will's OWN messages are perceived. A reaction between
|
|
246
|
+
* two other people in a busy channel is real social information, but it is not
|
|
247
|
+
* an answer to anything the mind said, and routing it here would swamp the
|
|
248
|
+
* conversation record it is meant to correct.
|
|
249
|
+
*/
|
|
250
|
+
async function onReaction( reaction: DiscordLikeReaction, user: DiscordLikeReactor ): Promise<void> {
|
|
251
|
+
const self = client.user
|
|
252
|
+
if( !self || user.id === self.id || user.bot ) return
|
|
253
|
+
|
|
254
|
+
// Both may arrive partial — a reaction on a message this process never cached
|
|
255
|
+
// is the NORMAL case for anything said before the last restart, which is
|
|
256
|
+
// exactly the long-lived conversation this is here to keep honest.
|
|
257
|
+
const full = reaction.partial && reaction.fetch ? await reaction.fetch().catch( () => null ) : reaction
|
|
258
|
+
if( !full ) return
|
|
259
|
+
const msg = full.message.partial && full.message.fetch
|
|
260
|
+
? await full.message.fetch().catch( () => null )
|
|
261
|
+
: full.message
|
|
262
|
+
if( !msg?.author ) return
|
|
263
|
+
|
|
264
|
+
if( msg.author.id !== self.id ) return // not ours — not an answer to us
|
|
265
|
+
const isDM = !msg.guildId
|
|
266
|
+
if( !isDM && allowed && !allowed.has( msg.channelId ) ) return
|
|
267
|
+
|
|
268
|
+
const emoji = full.emoji?.name ?? ( full.emoji?.id ? ':custom:' : '' )
|
|
269
|
+
if( !emoji ) return
|
|
270
|
+
|
|
271
|
+
const who = user.displayName ?? user.username
|
|
272
|
+
const said = ( msg.cleanContent || msg.content || '').trim().slice( 0, REACTION_QUOTE_CHARS )
|
|
273
|
+
|
|
274
|
+
// Described, bracketed, first person — the same shape `renderAttachments` uses
|
|
275
|
+
// for a file, and for the same reason: this reached the mind through the
|
|
276
|
+
// conversation but nobody SAID it, and a percept that reads like speech invites
|
|
277
|
+
// the mind to answer words that were never spoken.
|
|
278
|
+
const text = said
|
|
279
|
+
? `[${ who ?? 'someone' } reacted ${ emoji } to what I said: "${ said }"]`
|
|
280
|
+
: `[${ who ?? 'someone' } reacted ${ emoji } to something I said]`
|
|
281
|
+
|
|
282
|
+
await will.sense( {
|
|
283
|
+
text,
|
|
284
|
+
from: `discord:${ user.id }`,
|
|
285
|
+
thread: `discord:${ msg.channelId }`,
|
|
286
|
+
direct: isDM,
|
|
287
|
+
// Exafferent, and the near-miss is worth naming: this is ABOUT something
|
|
288
|
+
// she did, but it is not her doing it. Somebody else reacted. Reafference
|
|
289
|
+
// is the mind sensing its OWN act's consequence, not the world's response
|
|
290
|
+
// to that act — a reply would fail the same test for the same reason.
|
|
291
|
+
provenance: 'exafferent',
|
|
292
|
+
...( roomLabel( msg ) ? { threadName: roomLabel( msg ) } : {} ),
|
|
293
|
+
...( who ? { speaker: who } : {} ),
|
|
294
|
+
} )
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function onMessage( message: DiscordLikeMessage ): Promise<void> {
|
|
298
|
+
const self = client.user
|
|
299
|
+
if( !self || message.author.id === self.id || message.author.bot ) return
|
|
300
|
+
|
|
301
|
+
const isDM = !message.guildId
|
|
302
|
+
if( !isDM && allowed && !allowed.has( message.channelId ) ) return
|
|
303
|
+
|
|
304
|
+
const addressed = isDM || ( message.mentions?.has( self.id ) ?? false )
|
|
305
|
+
// DMs are addressed by definition, so this never gates them.
|
|
306
|
+
if( !addressed && ( mentionEverywhere || mentionIn?.has( message.channelId ) ) ) return
|
|
307
|
+
|
|
308
|
+
const entityId = `discord:${ message.author.id }`
|
|
309
|
+
const speaker = message.member?.displayName ?? message.author.displayName ?? message.author.username
|
|
310
|
+
|
|
311
|
+
roster.record( {
|
|
312
|
+
entityId,
|
|
313
|
+
userId: message.author.id,
|
|
314
|
+
...( speaker ? { displayName: speaker } : {} ),
|
|
315
|
+
...( isDM ? { dmChannelId: message.channelId } : { lastChannelId: message.channelId } ),
|
|
316
|
+
} )
|
|
317
|
+
if( !isDM ) lastActiveChannelId = message.channelId
|
|
318
|
+
|
|
319
|
+
// Being addressed is the one moment a presence cue is honest — the mind
|
|
320
|
+
// may still choose silence, and typing expires on its own.
|
|
321
|
+
if( addressed ) await message.channel.sendTyping?.().catch( () => {} )
|
|
322
|
+
|
|
323
|
+
const said = ( message.cleanContent || message.content ).trim()
|
|
324
|
+
const files = collectAttachments( message )
|
|
325
|
+
|
|
326
|
+
// An attachment-only message used to die here on the empty body: no percept,
|
|
327
|
+
// no log line, nothing. From the mind's side the person had simply gone
|
|
328
|
+
// quiet — and Discord *makes* these, turning a long pasted markdown block
|
|
329
|
+
// into a .md upload. Only a message with neither words nor files is nothing.
|
|
330
|
+
if( !said && files.length === 0 ) return
|
|
331
|
+
|
|
332
|
+
const shared = await renderAttachments(
|
|
333
|
+
files, speaker,
|
|
334
|
+
opts.readAttachments === false ? undefined : fetchAttachmentText,
|
|
335
|
+
)
|
|
336
|
+
const text = [ said, shared ].filter( Boolean ).join('\n')
|
|
337
|
+
|
|
338
|
+
await will.sense( {
|
|
339
|
+
text,
|
|
340
|
+
from: entityId,
|
|
341
|
+
thread: `discord:${ message.channelId }`,
|
|
342
|
+
// `isDM` has been computed on every inbound since this bridge shipped and
|
|
343
|
+
// used only to pick a roster field. It is the one fact that makes a room
|
|
344
|
+
// the right or wrong place to say something, and the mind never saw it —
|
|
345
|
+
// which is how a follow-up promised in a DM went out to #general.
|
|
346
|
+
direct: isDM,
|
|
347
|
+
// Somebody spoke. The bridge already drops her own messages (`onMessage`
|
|
348
|
+
// returns early on `author.id === self.id`), so nothing reafferent can
|
|
349
|
+
// reach this line today — but that filter is a bridge-level deletion of
|
|
350
|
+
// a signal she is entitled to sense, not a reason for the field to lie.
|
|
351
|
+
provenance: 'exafferent',
|
|
352
|
+
...( roomLabel( message ) ? { threadName: roomLabel( message ) } : {} ),
|
|
353
|
+
...( speaker ? { speaker } : {} ),
|
|
354
|
+
} )
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Normalise whatever the client gave us into attachments.
|
|
359
|
+
*
|
|
360
|
+
* `.values()` first: a discord.js Collection is a Map, so `for..of` over it
|
|
361
|
+
* yields `[id, attachment]` pairs and every field reads `undefined`. Arrays
|
|
362
|
+
* expose `.values()` too and yield their elements, so one branch covers the
|
|
363
|
+
* real client, a plain array, and a Map alike.
|
|
364
|
+
*/
|
|
365
|
+
function collectAttachments( message: DiscordLikeMessage ): ChannelAttachment[] {
|
|
366
|
+
if( !message.attachments ) return []
|
|
367
|
+
const source = message.attachments as { values?: () => Iterable<DiscordLikeAttachment> }
|
|
368
|
+
const items: Iterable<DiscordLikeAttachment> = typeof source.values === 'function'
|
|
369
|
+
? source.values()
|
|
370
|
+
: message.attachments as Iterable<DiscordLikeAttachment>
|
|
371
|
+
|
|
372
|
+
const out: ChannelAttachment[] = []
|
|
373
|
+
for( const a of items )
|
|
374
|
+
out.push( {
|
|
375
|
+
name: a.name ?? 'unnamed',
|
|
376
|
+
...( a.contentType ? { contentType: a.contentType } : {} ),
|
|
377
|
+
...( a.size != null ? { size: a.size } : {} ),
|
|
378
|
+
...( a.url ? { url: a.url } : {} ),
|
|
379
|
+
} )
|
|
380
|
+
return out
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Fetch one text attachment — Discord CDN only, size-capped. */
|
|
384
|
+
async function fetchAttachmentText( a: ChannelAttachment ): Promise<string | null> {
|
|
385
|
+
if( !a.url || !isTextual( a ) ) return null
|
|
386
|
+
let host: string
|
|
387
|
+
try { host = new URL( a.url ).hostname }
|
|
388
|
+
catch { return null }
|
|
389
|
+
if( !DISCORD_CDN_HOSTS.has( host ) ){
|
|
390
|
+
log(`refusing to fetch attachment '${ a.name }' from non-CDN host ${ host }`)
|
|
391
|
+
return null
|
|
392
|
+
}
|
|
393
|
+
if( a.size != null && a.size > MAX_FETCH_BYTES ){
|
|
394
|
+
log(`attachment '${ a.name }' is ${ a.size } bytes — naming it without reading`)
|
|
395
|
+
return null
|
|
396
|
+
}
|
|
397
|
+
const res = await fetch( a.url, { signal: AbortSignal.timeout( 10_000 ) } )
|
|
398
|
+
if( !res.ok ){
|
|
399
|
+
log(`attachment '${ a.name }' fetch failed: ${ res.status }`)
|
|
400
|
+
return null
|
|
401
|
+
}
|
|
402
|
+
return ( await res.text() ).slice( 0, MAX_FETCH_BYTES )
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ── the Will looks at something here, and Discord answers ─────────────────
|
|
406
|
+
//
|
|
407
|
+
// `inspect` is innate: every mind can look. What it finds depends on there
|
|
408
|
+
// being a world that can be asked, and this is Discord's answer.
|
|
409
|
+
//
|
|
410
|
+
// The answer is RETURNED AS AN OBSERVATION, and the engine turns it into a
|
|
411
|
+
// percept (SIGNAL_BOUNDARY P2). `description` says how the looking went;
|
|
412
|
+
// `observation` says what was found, and arrives at the mind as a reafferent
|
|
413
|
+
// percept tied to this act by `sourceIntentId` — so it can weigh the facts,
|
|
414
|
+
// disbelieve them, or decide they do not matter. An earlier cut wrote the
|
|
415
|
+
// answer straight into her dossier and beliefs, which is a mind being told
|
|
416
|
+
// what it knows rather than a mind finding out.
|
|
417
|
+
//
|
|
418
|
+
// This used to take TWO calls: return the ack, then separately `perceive()`
|
|
419
|
+
// the answer wrapped in a bracketed sentence — because an ack could not carry
|
|
420
|
+
// facts, and the sense door only accepted things somebody had said. The prose
|
|
421
|
+
// bracket was load-bearing: it was the only place the reafference lived where
|
|
422
|
+
// anything could read it. Both are gone.
|
|
423
|
+
will.effector('inspect', async ( _args, ctx ) => {
|
|
424
|
+
// The mind names a referent (`ke:1sqlkux`); we hold channel ids. The Will
|
|
425
|
+
// resolves the anchor to the addresses it was met at, and we take ours.
|
|
426
|
+
const address = ( ctx.targetAddresses ?? [] ).find( a => a.startsWith('discord:') )
|
|
427
|
+
if( !address ) return { success: false, description: 'Not something I can see on Discord.' }
|
|
428
|
+
|
|
429
|
+
const id = address.slice('discord:'.length )
|
|
430
|
+
const channel = await client.channels.fetch( id ).catch( () => null ) as DiscordLikeChannel | null
|
|
431
|
+
// A user id lands here too — a person is not a place, and Discord has nothing
|
|
432
|
+
// to say about them that watching them talk does not say better.
|
|
433
|
+
if( !channel?.name ) return { success: false, description: 'There is nothing here I can look up.' }
|
|
434
|
+
|
|
435
|
+
const facts: string[] = []
|
|
436
|
+
if( channel.topic ) facts.push(`it is for: ${ channel.topic }`)
|
|
437
|
+
if( channel.parent?.name ) facts.push(`it sits under #${ channel.parent.name }`)
|
|
438
|
+
if( typeof channel.memberCount === 'number')
|
|
439
|
+
// A COUNT, not a roster. Walking into a room you see that it is crowded long
|
|
440
|
+
// before you learn who anyone is; people become known by being met.
|
|
441
|
+
facts.push(`${ channel.memberCount } people are in it`)
|
|
442
|
+
|
|
443
|
+
if( facts.length === 0 )
|
|
444
|
+
return { success: false, description: `#${ channel.name } has nothing recorded about it.` }
|
|
445
|
+
|
|
446
|
+
const label = roomLabel( { channelId: id, guildId: 'g', channel } as DiscordLikeMessage ) ?? `#${ channel.name }`
|
|
447
|
+
|
|
448
|
+
return {
|
|
449
|
+
success: true,
|
|
450
|
+
description: `Looked into ${ label }.`,
|
|
451
|
+
// The room as Discord has it, in the shape Discord has it. Not flattened
|
|
452
|
+
// into a sentence for the mind's benefit — a host that reshapes its own
|
|
453
|
+
// data is deciding what the mind may notice about it, and `observation`
|
|
454
|
+
// takes whatever shape the answer already had.
|
|
455
|
+
//
|
|
456
|
+
// `summary` is the one concession: it is what the executive prompt renders,
|
|
457
|
+
// so the host says it in words rather than leaving the mind to read JSON.
|
|
458
|
+
// Everything beside it stays available.
|
|
459
|
+
observation: {
|
|
460
|
+
summary: `I looked into ${ label }: ${ facts.join('; ') }.`,
|
|
461
|
+
room: label,
|
|
462
|
+
address,
|
|
463
|
+
...( channel.topic ? { topic: channel.topic } : {} ),
|
|
464
|
+
...( channel.parent?.name ? { parent: `#${ channel.parent.name }` } : {} ),
|
|
465
|
+
...( typeof channel.memberCount === 'number' ? { memberCount: channel.memberCount } : {} ),
|
|
466
|
+
},
|
|
467
|
+
}
|
|
468
|
+
} )
|
|
469
|
+
|
|
470
|
+
// ── outbound: projected utterance → the addressee ─────────────────────────
|
|
471
|
+
// The facade has no off(); the bridge gates its handler on `closed` instead.
|
|
472
|
+
let closed = false
|
|
473
|
+
will.on('message', ( m: WillMessage ) => { if( !closed ) void deliver( m ) } )
|
|
474
|
+
|
|
475
|
+
async function deliver( m: WillMessage ): Promise<void> {
|
|
476
|
+
const peer = m.to ? roster.resolve( m.to ) : undefined
|
|
477
|
+
const chunks = chunkText( m.content, DISCORD_MESSAGE_LIMIT )
|
|
478
|
+
|
|
479
|
+
// A reply goes back to the room it was said in. `m.thread` is the thread from
|
|
480
|
+
// the `perceive()` that prompted this — `discord:<channelId>` — so it is not a
|
|
481
|
+
// guess about where this person usually is, it is where they just spoke.
|
|
482
|
+
//
|
|
483
|
+
// Everything below it IS a guess, and the guesses were wrong in the way that
|
|
484
|
+
// matters most: a DM arrived, she answered it in seconds, and the answer went
|
|
485
|
+
// to the shared server channel because `lastChannelId` still held the last
|
|
486
|
+
// room they had been in together. She looked like she was ignoring him.
|
|
487
|
+
//
|
|
488
|
+
// Unprompted utterances carry no thread — nothing was said to them — so those
|
|
489
|
+
// still fall through to the roster, which is the right behaviour there.
|
|
490
|
+
const replyTo = m.thread?.startsWith('discord:') ? m.thread.slice('discord:'.length ) : undefined
|
|
491
|
+
|
|
492
|
+
// Preference order: the room they spoke in → where we last shared a room →
|
|
493
|
+
// their DM → home channel.
|
|
494
|
+
const channelIds = [ replyTo, peer?.lastChannelId, peer?.dmChannelId, opts.homeChannelId ?? undefined, lastActiveChannelId ?? undefined ]
|
|
495
|
+
for( const id of channelIds ){
|
|
496
|
+
if( !id ) continue
|
|
497
|
+
try {
|
|
498
|
+
const channel = await client.channels.fetch( id ) as DiscordLikeChannel | null
|
|
499
|
+
if( !channel?.send ) continue
|
|
500
|
+
for( const chunk of chunks ) await channel.send( chunk )
|
|
501
|
+
return
|
|
502
|
+
}
|
|
503
|
+
catch { /* try the next route */ }
|
|
504
|
+
}
|
|
505
|
+
if( peer ){
|
|
506
|
+
try {
|
|
507
|
+
const user = await client.users.fetch( peer.userId )
|
|
508
|
+
for( const chunk of chunks ) await user.send( chunk )
|
|
509
|
+
return
|
|
510
|
+
}
|
|
511
|
+
catch { /* fall through */ }
|
|
512
|
+
}
|
|
513
|
+
log(`no route for utterance to '${ m.to }' — dropped (${ m.content.length } chars)`)
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// ── lifecycle ──────────────────────────────────────────────────────────────
|
|
517
|
+
const bridge: ChannelBridge = {
|
|
518
|
+
kind: 'discord',
|
|
519
|
+
async start(): Promise<void> {
|
|
520
|
+
if( !client.user ){
|
|
521
|
+
// discord.js ≥14.22 renamed `ready` → `clientReady`. Subscribing to the
|
|
522
|
+
// old name is what triggers its DeprecationWarning, so we take the new
|
|
523
|
+
// name and poll `isReady()` for older builds rather than listening.
|
|
524
|
+
const ready = new Promise<void>( resolve => {
|
|
525
|
+
let poll: ReturnType<typeof setInterval> | null = null
|
|
526
|
+
const done = (): void => { if( poll ) clearInterval( poll ); resolve() }
|
|
527
|
+
client.once('clientReady', done )
|
|
528
|
+
poll = setInterval( () => { if( client.isReady?.() ) done() }, 100 )
|
|
529
|
+
poll.unref?.()
|
|
530
|
+
} )
|
|
531
|
+
await client.login( opts.token ?? '')
|
|
532
|
+
await ready
|
|
533
|
+
}
|
|
534
|
+
log(`${ will.name } is present on Discord as user ${ client.user?.id }`)
|
|
535
|
+
},
|
|
536
|
+
async close(): Promise<void> {
|
|
537
|
+
if( closed ) return
|
|
538
|
+
closed = true
|
|
539
|
+
roster.flush()
|
|
540
|
+
await Promise.resolve( client.destroy() ).catch( () => {} )
|
|
541
|
+
},
|
|
542
|
+
}
|
|
543
|
+
return bridge
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** Build a real discord.js client (lazy import keeps it out of non-Discord hosts). */
|
|
547
|
+
async function createDiscordClient(): Promise<DiscordLikeClient> {
|
|
548
|
+
let mod: typeof import('discord.js')
|
|
549
|
+
try { mod = await import('discord.js') }
|
|
550
|
+
catch {
|
|
551
|
+
throw new Error('discord.js is not installed (it is an optionalDependency) — run `bun add discord.js` / `npm i discord.js` and retry.')
|
|
552
|
+
}
|
|
553
|
+
const { Client, GatewayIntentBits, Partials } = mod
|
|
554
|
+
return new Client( {
|
|
555
|
+
intents: [
|
|
556
|
+
GatewayIntentBits.Guilds,
|
|
557
|
+
GatewayIntentBits.GuildMessages,
|
|
558
|
+
GatewayIntentBits.MessageContent,
|
|
559
|
+
GatewayIntentBits.DirectMessages,
|
|
560
|
+
// Neither is privileged, so this costs nothing to ask for — and without
|
|
561
|
+
// them an emoji answer never arrives and reads as being ignored.
|
|
562
|
+
GatewayIntentBits.GuildMessageReactions,
|
|
563
|
+
GatewayIntentBits.DirectMessageReactions,
|
|
564
|
+
],
|
|
565
|
+
partials: [
|
|
566
|
+
Partials.Channel, // DMs arrive on uncached channels
|
|
567
|
+
// A reaction on a message this process did not cache — i.e. anything said
|
|
568
|
+
// before the last restart — is delivered partial, and without these the
|
|
569
|
+
// event is dropped before the handler sees it. That is precisely the
|
|
570
|
+
// long-running conversation the answered loop exists for.
|
|
571
|
+
Partials.Message,
|
|
572
|
+
Partials.Reaction,
|
|
573
|
+
],
|
|
574
|
+
} ) as unknown as DiscordLikeClient
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Parse `WILL_DISCORD_MENTION_ONLY` into the `mentionOnly` option.
|
|
579
|
+
*
|
|
580
|
+
* Accepts a boolean OR a channel list, because "only speak when spoken to" is
|
|
581
|
+
* rarely a whole-server property — it is how you stay present in a busy channel
|
|
582
|
+
* without narrating in it.
|
|
583
|
+
*
|
|
584
|
+
* `1` / `true` / `yes` → true (everywhere)
|
|
585
|
+
* `0` / `false` / unset → false (nowhere)
|
|
586
|
+
* `123,456` → only those channels
|
|
587
|
+
*
|
|
588
|
+
* Exported so every host parses it identically; the CLI and any SDK host share
|
|
589
|
+
* this rather than each re-deriving the syntax.
|
|
590
|
+
*/
|
|
591
|
+
export function parseMentionOnly( raw?: string ): boolean | string[] {
|
|
592
|
+
const v = raw?.trim()
|
|
593
|
+
if( !v ) return false
|
|
594
|
+
if( /^(1|true|yes)$/i.test( v ) ) return true
|
|
595
|
+
if( /^(0|false|no)$/i.test( v ) ) return false
|
|
596
|
+
|
|
597
|
+
const ids = v.split(',').map( s => s.trim() ).filter( Boolean )
|
|
598
|
+
return ids.length ? ids : false
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Parse `WILL_DISCORD_CHANNELS`. `*` (or unset/empty) means every channel the Will
|
|
603
|
+
* can see — being added to a channel in Discord is then all it takes. Anything else
|
|
604
|
+
* restrains it to exactly the ids listed.
|
|
605
|
+
*/
|
|
606
|
+
export function parseChannels( raw?: string ): string[] | undefined {
|
|
607
|
+
const ids = raw?.split(',').map( s => s.trim() ).filter( Boolean )
|
|
608
|
+
return ids?.length ? ids : undefined
|
|
609
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// ─────────────────────────────────────────────────────────────
|
|
2
|
-
// src/channels/roster.ts — who the Will knows on a platform, and where
|
|
2
|
+
// src/surface/channels/roster.ts — who the Will knows on a platform, and where
|
|
3
3
|
// ─────────────────────────────────────────────────────────────
|
|
4
4
|
//
|
|
5
5
|
// The mind knows *entities*; a platform knows user ids and channels. The roster
|