@mindot/will 0.2.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 +65 -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/{mcp/cli.js → cli.js} +1037 -546
- package/dist/cli.js.map +1 -0
- 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 +7 -2
- 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 +100 -0
- 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 +193 -0
- package/src/host/utterances.ts +53 -0
- package/src/llm/summarizer.ts +2 -2
- package/src/mcp/server.ts +7 -30
- 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/serve/server.ts +154 -0
- 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/dist/mcp/cli.js.map +0 -1
- package/src/mcp/cli.ts +0 -129
- /package/dist/{mcp/cli.d.ts → cli.d.ts} +0 -0
package/README.md
CHANGED
|
@@ -19,6 +19,8 @@ Regulatory → Perceptual → Affective → Memory → Executive → Meta-cognit
|
|
|
19
19
|
|
|
20
20
|
The mind is not re-derived from a prompt each run. It **accretes**: traits develop from experience, beliefs consolidate, skills proceduralise, and a coherent self carries across restarts as a portable, eval-verified artifact.
|
|
21
21
|
|
|
22
|
+
Embed it five ways — [**SDK facade**](#the-will-sdk-facade--recommended) (Node/TS) · [**Discord**](#a-will-in-your-discord-server--will-discord) (a mind in your server) · [**Claude Desktop via MCP**](#the-mcp-server--a-persistent-mind-in-claude-desktop--claude-code) · [**MCP tools as its abilities**](#employing-mcp-tools--the-mind-gets-abilities) · [**HTTP sidecar / Docker**](#the-http-sidecar--will-serve-any-language-or-docker) (any language). [Pick a surface →](#use-it-in-your-project)
|
|
23
|
+
|
|
22
24
|
---
|
|
23
25
|
|
|
24
26
|
## What makes it different
|
|
@@ -82,7 +84,17 @@ ticks — or earlier when physiology demands it.
|
|
|
82
84
|
|
|
83
85
|
## Use it in your project
|
|
84
86
|
|
|
85
|
-
Runs anywhere **Node 18+ or Bun** runs (the engine is Node-compatible; Bun is the primary target).
|
|
87
|
+
Runs anywhere **Node 18+ or Bun** runs (the engine is Node-compatible; Bun is the primary target). **Five surfaces**, one paradigm — you *perceive* things to a mind and *observe what it projects*; it may act, speak, or stay silent, and it persists across restarts via its [PMA artifact](#pma--the-persistent-mind-artifact):
|
|
88
|
+
|
|
89
|
+
| Surface | You are… | Start with |
|
|
90
|
+
|---|---|---|
|
|
91
|
+
| [**SDK facade**](#the-will-sdk-facade--recommended) | a Node/TypeScript app embedding a mind | `import { Will } from '@mindot/will'` |
|
|
92
|
+
| [**Discord**](#a-will-in-your-discord-server--will-discord) | a server where a mind should *live* | `npx -y @mindot/will discord` |
|
|
93
|
+
| [**MCP host**](#the-mcp-server--a-persistent-mind-in-claude-desktop--claude-code) | Claude Desktop / Claude Code / an IDE | `npx -y @mindot/will mcp` |
|
|
94
|
+
| [**MCP tools as abilities**](#employing-mcp-tools--the-mind-gets-abilities) | giving the mind tools it *chooses* to use | `import { connectMcpEffectors } from '@mindot/will/mcp'` |
|
|
95
|
+
| [**HTTP sidecar**](#the-http-sidecar--will-serve-any-language-or-docker) | Python, Go, a game server — any language, or Docker | `npx -y @mindot/will serve` |
|
|
96
|
+
|
|
97
|
+
(Power users can drop below all four to the [`WillStem` contract](#the-willstem-contract--full-control).)
|
|
86
98
|
|
|
87
99
|
### The `Will` SDK facade — recommended
|
|
88
100
|
|
|
@@ -114,6 +126,20 @@ const revived = await Will.wake(pma, { name: 'Aria' })
|
|
|
114
126
|
|
|
115
127
|
`will.state()` returns a compact read of the mind (energy, mood, goals, beliefs, self-narrative). Drop to `will.stem` for the full `WillStem` contract at any time. Runnable: [`examples/effectors.ts`](examples/effectors.ts).
|
|
116
128
|
|
|
129
|
+
### A Will in your Discord server — `will discord`
|
|
130
|
+
|
|
131
|
+
Two minutes from a bot token to a persistent mind *living* in your server — not a command bot:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
DISCORD_BOT_TOKEN=… WILL_NAME=Aria \
|
|
135
|
+
WILL_IDENTITY="I am Aria — curious, dry-witted, fond of this server's people." \
|
|
136
|
+
ANTHROPIC_API_KEY=sk-ant-… npx -y @mindot/will discord
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
No command prefix, no forced replies. It perceives the rooms it can see (salience-scored — perceiving costs no LLM call), **decides for itself when to speak** (silence is a valid outcome), learns people's names as *learned* knowledge (`discord:<userId>` is one entity across servers), keeps each channel as its own conversation thread, and **can message first** — proactive utterances route to the addressee's last shared channel, then their DM, then `WILL_DISCORD_HOME_CHANNEL`. On shutdown it hibernates to its PMA and returns as the same self, still knowing everyone.
|
|
140
|
+
|
|
141
|
+
Scope it with `WILL_DISCORD_CHANNELS` (id allowlist) and `WILL_DISCORD_MENTION_ONLY` for busy servers. The bridge grants no tools — it is a mouth and ears, not hands; abilities stay explicit (effectors / MCP). Setup + SDK embedding (`import { connectDiscord } from '@mindot/will/discord'`): [docs/channels/discord.md](docs/channels/discord.md) · runnable: [`examples/discord.ts`](examples/discord.ts).
|
|
142
|
+
|
|
117
143
|
### The MCP server — a persistent mind in Claude Desktop / Claude Code
|
|
118
144
|
|
|
119
145
|
Host a Will over the [Model Context Protocol](https://modelcontextprotocol.io) — any MCP client can then live alongside a persistent mind that **remembers across sessions** (it hibernates to a Persistent Mind Artifact on shutdown and wakes as the same self on the next boot):
|
|
@@ -133,9 +159,11 @@ Host a Will over the [Model Context Protocol](https://modelcontextprotocol.io)
|
|
|
133
159
|
}
|
|
134
160
|
```
|
|
135
161
|
|
|
136
|
-
The surface keeps the paradigm: `perceive` delivers a stimulus (it returns when *delivered*, not answered), `next_utterance` awaits the mind's next words (**silence is a valid outcome**, reported — never an error), `state` reads its inner life, and `save` checkpoints it without stopping it. There is deliberately no `ask()`-shaped tool. Config via env: `
|
|
162
|
+
The surface keeps the paradigm: `perceive` delivers a stimulus (it returns when *delivered*, not answered), `next_utterance` awaits the mind's next words (**silence is a valid outcome**, reported — never an error), `state` reads its inner life, and `save` checkpoints it without stopping it. There is deliberately no `ask()`-shaped tool. Config via env: `WILL_ANATOMY` (mind|reflex — reflex is the no-LLM shell), `WILL_LLM_MODEL` (concrete model id), `WILL_LLM` (mock|anthropic — defaults to the zero-key mock unless `ANTHROPIC_API_KEY` is set), `WILL_TICK_MS`, `WILL_PMA_PATH`.
|
|
163
|
+
|
|
164
|
+
### Employing MCP tools — the mind gets abilities
|
|
137
165
|
|
|
138
|
-
|
|
166
|
+
The other direction from hosting: any MCP server's tools can become the Will's own *abilities*. Each tool registers as a learnable affordance (its description is the ability's meaning, surfaced to the mind's deliberation), the **Will decides when to enact one** — nothing dispatches tools at it — and outcomes feed its reafference loop, so it gets *skilled* at the tools it uses. Arguments come from conscious intent: the executive supplies them in an action's `args`.
|
|
139
167
|
|
|
140
168
|
```typescript
|
|
141
169
|
import { connectMcpEffectors } from '@mindot/will/mcp'
|
|
@@ -148,6 +176,25 @@ const { names } = await connectMcpEffectors(will, {
|
|
|
148
176
|
|
|
149
177
|
The hosted server composes with this: set `WILL_MCP_SERVERS` (a JSON array of `{command,args}` or `{url}` entries) and the mind you host in Claude Desktop itself employs those servers' tools.
|
|
150
178
|
|
|
179
|
+
### The HTTP sidecar — `will serve` (any language, or Docker)
|
|
180
|
+
|
|
181
|
+
Not on Node? Host the mind as a sidecar and speak to it over HTTP from Python, Go, a game server — anything:
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
npx -y @mindot/will serve # http://127.0.0.1:7777, or: docker build -t will . && docker run -p 7777:7777 -v will-data:/data will
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
curl -X POST localhost:7777/perceive -H 'content-type: application/json' \
|
|
189
|
+
-d '{"text":"Hello there.","from":"sam","speaker":"Sam"}' # 202 — delivered, not answered
|
|
190
|
+
curl 'localhost:7777/next-utterance?within_ms=8000&from=sam' # its next words, or {"silence":true}
|
|
191
|
+
curl localhost:7777/state # its inner life
|
|
192
|
+
curl -N localhost:7777/utterances # SSE: utterance / emotion / action projections
|
|
193
|
+
curl -X POST localhost:7777/save # checkpoint without stopping
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Same paradigm, same env config, same persistence as the MCP host: the mind hibernates to its PMA artifact on shutdown and wakes as the same self on the next start (in Docker, mount `/data` to keep it across container restarts). There is deliberately no `/ask` route.
|
|
197
|
+
|
|
151
198
|
### The `WillStem` contract — full control
|
|
152
199
|
|
|
153
200
|
The lower-level engine surface the facade wraps (explicit tick listeners, the outbox drain, the effector ack loop, PMA distill/load) — for hosts that manage many Wills, custom transports, or replay. The rest of this section walks it end to end.
|
|
@@ -172,8 +219,7 @@ const config: WillConfig = {
|
|
|
172
219
|
traits: { conscientiousness: 0.9, neuroticism: 0.4 },
|
|
173
220
|
style: 'measured and warm',
|
|
174
221
|
},
|
|
175
|
-
|
|
176
|
-
modelTier: 'sonnet', // which model the executive recruits
|
|
222
|
+
model: 'claude-sonnet-4-5-20250929', // concrete model id (optional — env/provider default otherwise)
|
|
177
223
|
allowedGenericEffectors: ['listen', 'talk', 'text'], // opt in to communication
|
|
178
224
|
persistentMemory: true,
|
|
179
225
|
snapshotInterval: 10,
|
|
@@ -207,6 +253,10 @@ There is **no synchronous reply** — `ingestText` returns immediately; the Will
|
|
|
207
253
|
|
|
208
254
|
## Architecture
|
|
209
255
|
|
|
256
|
+
> **Visual map:** [`docs/graphs/`](docs/graphs/) holds twenty-one architecture graphs — the cognitive stories (memory, executive & facets, agency, audition, body & affect, meta-cognition, the executive ⇄ agency seam, planning & goals, social cognition, proactive communication, competence, the two persona channels), the machinery (the deterministic tick, the simulation core, the cognitive bus & wiring, one LLM call end-to-end, transports, the stem's tracts), the edges (host surfaces, the PMA lifecycle), and the [whole composition](docs/graphs/composition.svg). One palette across all of them: violet is always memory, amber executive, green agency. Regenerate with `bun docs/graphs/generate.ts`.
|
|
257
|
+
|
|
258
|
+

|
|
259
|
+
|
|
210
260
|
### The cognitive engines
|
|
211
261
|
|
|
212
262
|
**40+ engines** run on the tick clock: **38 faculties** across seven systems, the **six-engine agency pipeline**, and **five sense engines**. The vast majority resolve every tick with no LLM call.
|
|
@@ -298,7 +348,7 @@ Beyond the five communication effectors, your world can expose **domain actions*
|
|
|
298
348
|
registerProfile({
|
|
299
349
|
id: 'rover', name: 'Rover', description: 'A field robot.',
|
|
300
350
|
effectors: ['listen', 'talk', 'move', 'scan', 'grab'],
|
|
301
|
-
context: '
|
|
351
|
+
context: 'I am a rover exploring terrain. I can move, scan, and grab samples.',
|
|
302
352
|
})
|
|
303
353
|
|
|
304
354
|
// 2. When the Will chooses one, it appears in pendingEffectorInvocations.
|
|
@@ -426,7 +476,7 @@ registerProfile({
|
|
|
426
476
|
name: 'Research Lab',
|
|
427
477
|
description: 'An observable mind for studying emergent cognition.',
|
|
428
478
|
effectors: ['listen', 'talk', 'remember', 'reflect'],
|
|
429
|
-
context: '
|
|
479
|
+
context: 'I am a research subject. I report my reasoning transparently…',
|
|
430
480
|
})
|
|
431
481
|
|
|
432
482
|
// Then in WillConfig: { ..., profile: 'research-lab' }
|
|
@@ -474,10 +524,10 @@ const config: WillConfig = {
|
|
|
474
524
|
|
|
475
525
|
// REQUIRED. How many cognitive layers run (see WillConfig reference below).
|
|
476
526
|
// 'full' runs the complete mind — the normal choice.
|
|
477
|
-
|
|
527
|
+
|
|
478
528
|
|
|
479
529
|
// REQUIRED. Which model the executive recruits when it fires.
|
|
480
|
-
|
|
530
|
+
model: 'claude-sonnet-4-5-20250929',
|
|
481
531
|
|
|
482
532
|
// Communication effectors this Will is permitted to use.
|
|
483
533
|
// Omit or set null for a Will with no communication surface.
|
|
@@ -503,8 +553,8 @@ Traits seed the PersonaPrior as a *starting disposition*, not a fixed personalit
|
|
|
503
553
|
| `id` | ✅ | — | Unique identifier — thread key and filesystem path segment |
|
|
504
554
|
| `name` | ✅ | — | Human-readable display name |
|
|
505
555
|
| `identity` | ✅ | — | Persona: `{ prompt, values[], traits{}, style }` (Layer 2) |
|
|
506
|
-
| `
|
|
507
|
-
| `
|
|
556
|
+
| `anatomy` | — | `'mind'` | `'mind' \| 'reflex'` — the only structural variant. `mind` runs the whole architecture; `reflex` is a no-LLM shell (regulatory + senses + agency heuristics) for embedded use. Faculties are not a pricing axis — budgets are. |
|
|
557
|
+
| `model` | — | env / provider default | Concrete LLM model id (e.g. `'claude-sonnet-4-5-20250929'`). `WILL_LLM_MODEL` env pins over this. Product tier labels resolve to a concrete id host-side, before the engine. |
|
|
508
558
|
| `persistentMemory` | ✅ | — | Persist snapshots so beliefs/goals/narrative survive restarts |
|
|
509
559
|
| `snapshotInterval` | ✅ | — | Ticks between in-memory snapshots |
|
|
510
560
|
| `profile` | — | `null` | World profile preset (effectors + environment context). Merged with `allowedGenericEffectors` |
|
|
@@ -750,6 +800,10 @@ cd will && bun run build
|
|
|
750
800
|
| `WILL_EMBEDDING_MODEL` | `text-embedding-3-small` *(when keyed)* | Embedding model for episodic recall; `none` disables |
|
|
751
801
|
| `WILL_EMBEDDING_URL` | *(provider default)* | Base URL for an OpenAI-compatible embedding endpoint |
|
|
752
802
|
| `WILL_TRANSPORT` | `off` | Delivery mode used by the host: `off` (outbox polling) · `stream` (in-process) · `socketio` (peer) |
|
|
803
|
+
| `DISCORD_BOT_TOKEN` | — | Bot token for `will discord` (see [docs/channels/discord.md](docs/channels/discord.md)) |
|
|
804
|
+
| `WILL_DISCORD_CHANNELS` | *(all visible)* | Comma-separated channel ids the Will inhabits |
|
|
805
|
+
| `WILL_DISCORD_MENTION_ONLY` | `false` | Perceive guild messages only when @mentioned (DMs always perceived) |
|
|
806
|
+
| `WILL_DISCORD_HOME_CHANNEL` | — | Fallback channel for utterances with no reachable addressee |
|
|
753
807
|
| `OPENAI_BASE_URL` | — | Base URL override for local / OpenAI-compatible models |
|
|
754
808
|
|
|
755
809
|
---
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { W as Will } from '../will-D-slky1N.js';
|
|
2
|
+
|
|
3
|
+
/** A running connection between one Will and one platform. */
|
|
4
|
+
interface ChannelBridge {
|
|
5
|
+
/** Platform kind, e.g. 'discord'. */
|
|
6
|
+
readonly kind: string;
|
|
7
|
+
/** Connect and start relaying. Resolves once the bridge is live. */
|
|
8
|
+
start(): Promise<void>;
|
|
9
|
+
/** Disconnect and release resources. Idempotent. */
|
|
10
|
+
close(): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface DiscordLikeChannel {
|
|
14
|
+
send(content: string): Promise<unknown>;
|
|
15
|
+
sendTyping?(): Promise<unknown>;
|
|
16
|
+
}
|
|
17
|
+
interface DiscordLikeMessage {
|
|
18
|
+
content: string;
|
|
19
|
+
cleanContent?: string;
|
|
20
|
+
channelId: string;
|
|
21
|
+
guildId?: string | null;
|
|
22
|
+
author: {
|
|
23
|
+
id: string;
|
|
24
|
+
bot?: boolean;
|
|
25
|
+
username?: string;
|
|
26
|
+
displayName?: string;
|
|
27
|
+
};
|
|
28
|
+
member?: {
|
|
29
|
+
displayName?: string;
|
|
30
|
+
} | null;
|
|
31
|
+
mentions?: {
|
|
32
|
+
has(userId: string): boolean;
|
|
33
|
+
};
|
|
34
|
+
channel: DiscordLikeChannel;
|
|
35
|
+
}
|
|
36
|
+
interface DiscordLikeClient {
|
|
37
|
+
user: {
|
|
38
|
+
id: string;
|
|
39
|
+
setPresence?(p: unknown): void;
|
|
40
|
+
} | null;
|
|
41
|
+
/** discord.js ≥14.22; polled so we needn't subscribe to the deprecated `ready`. */
|
|
42
|
+
isReady?(): boolean;
|
|
43
|
+
on(event: 'messageCreate', fn: (m: DiscordLikeMessage) => void): unknown;
|
|
44
|
+
once(event: string, fn: () => void): unknown;
|
|
45
|
+
login(token: string): Promise<unknown>;
|
|
46
|
+
destroy(): Promise<unknown> | void;
|
|
47
|
+
channels: {
|
|
48
|
+
fetch(id: string): Promise<unknown>;
|
|
49
|
+
};
|
|
50
|
+
users: {
|
|
51
|
+
fetch(id: string): Promise<{
|
|
52
|
+
send(content: string): Promise<unknown>;
|
|
53
|
+
}>;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
interface DiscordBridgeOptions {
|
|
57
|
+
/** Bot token (Discord developer portal). Unused when `client` is injected pre-logged-in. */
|
|
58
|
+
token?: string;
|
|
59
|
+
/** Channel ids the Will inhabits. Unset = every channel it can see. */
|
|
60
|
+
channels?: string[];
|
|
61
|
+
/** Perceive guild messages only when the Will is @mentioned (DMs always perceived). */
|
|
62
|
+
mentionOnly?: boolean;
|
|
63
|
+
/** Fallback channel for utterances with no reachable addressee. */
|
|
64
|
+
homeChannelId?: string;
|
|
65
|
+
/** Roster path (default: ./.will/<willId>.discord.json). */
|
|
66
|
+
rosterPath?: string;
|
|
67
|
+
/** Test / power-user seam: bring your own client; discord.js is never imported. */
|
|
68
|
+
client?: DiscordLikeClient;
|
|
69
|
+
log?: (msg: string) => void;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Connect a Will to Discord. Resolves once the bridge is live (logged in and
|
|
73
|
+
* relaying). Close it via the returned `ChannelBridge.close()` — the Will
|
|
74
|
+
* itself is not stopped; it simply loses this surface.
|
|
75
|
+
*/
|
|
76
|
+
declare function connectDiscord(will: Will, opts: DiscordBridgeOptions): Promise<ChannelBridge>;
|
|
77
|
+
|
|
78
|
+
export { type DiscordBridgeOptions, type DiscordLikeChannel, type DiscordLikeClient, type DiscordLikeMessage, connectDiscord };
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'fs';
|
|
2
|
+
import { dirname } from 'path';
|
|
3
|
+
|
|
4
|
+
// src/channels/roster.ts
|
|
5
|
+
var FLUSH_MS = 2e3;
|
|
6
|
+
var ChannelRoster = class {
|
|
7
|
+
constructor(path) {
|
|
8
|
+
this.path = path;
|
|
9
|
+
if (existsSync(path)) {
|
|
10
|
+
try {
|
|
11
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
12
|
+
for (const e of Array.isArray(raw) ? raw : []) this.entries.set(e.entityId, e);
|
|
13
|
+
} catch {
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
path;
|
|
18
|
+
entries = /* @__PURE__ */ new Map();
|
|
19
|
+
dirty = false;
|
|
20
|
+
timer = null;
|
|
21
|
+
/** Upsert what we just learned about an entity; schedules a throttled flush. */
|
|
22
|
+
record(update) {
|
|
23
|
+
const prev = this.entries.get(update.entityId);
|
|
24
|
+
const next = {
|
|
25
|
+
lastSeenAt: Date.now(),
|
|
26
|
+
...prev,
|
|
27
|
+
...Object.fromEntries(Object.entries(update).filter(([, v]) => v !== void 0))
|
|
28
|
+
};
|
|
29
|
+
this.entries.set(next.entityId, next);
|
|
30
|
+
this.dirty = true;
|
|
31
|
+
if (!this.timer) {
|
|
32
|
+
this.timer = setTimeout(() => {
|
|
33
|
+
this.timer = null;
|
|
34
|
+
this.flush();
|
|
35
|
+
}, FLUSH_MS);
|
|
36
|
+
this.timer.unref?.();
|
|
37
|
+
}
|
|
38
|
+
return next;
|
|
39
|
+
}
|
|
40
|
+
resolve(entityId) {
|
|
41
|
+
return this.entries.get(entityId);
|
|
42
|
+
}
|
|
43
|
+
all() {
|
|
44
|
+
return [...this.entries.values()];
|
|
45
|
+
}
|
|
46
|
+
/** Write to disk now (no-op when clean). Called by bridges on close. */
|
|
47
|
+
flush() {
|
|
48
|
+
if (!this.dirty) return;
|
|
49
|
+
try {
|
|
50
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
51
|
+
writeFileSync(this.path, JSON.stringify(this.all(), null, 2));
|
|
52
|
+
this.dirty = false;
|
|
53
|
+
} catch {
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// src/channels/types.ts
|
|
59
|
+
function chunkText(text, max) {
|
|
60
|
+
if (text.length <= max) return [text];
|
|
61
|
+
const chunks = [];
|
|
62
|
+
let rest = text;
|
|
63
|
+
while (rest.length > max) {
|
|
64
|
+
const window = rest.slice(0, max);
|
|
65
|
+
const cut = Math.max(window.lastIndexOf("\n\n"), window.lastIndexOf("\n"), window.lastIndexOf(" "));
|
|
66
|
+
const at = cut > max * 0.5 ? cut : max;
|
|
67
|
+
chunks.push(rest.slice(0, at).trimEnd());
|
|
68
|
+
rest = rest.slice(at).trimStart();
|
|
69
|
+
}
|
|
70
|
+
if (rest) chunks.push(rest);
|
|
71
|
+
return chunks;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// src/channels/discord.ts
|
|
75
|
+
var DISCORD_MESSAGE_LIMIT = 2e3;
|
|
76
|
+
async function connectDiscord(will, opts) {
|
|
77
|
+
const log = opts.log ?? ((m) => console.error(`[will:discord] ${m}`));
|
|
78
|
+
const roster = new ChannelRoster(opts.rosterPath ?? `.will/${will.id}.discord.json`);
|
|
79
|
+
const allowed = opts.channels?.length ? new Set(opts.channels) : null;
|
|
80
|
+
const client = opts.client ?? await createDiscordClient();
|
|
81
|
+
let lastActiveChannelId = opts.homeChannelId ?? null;
|
|
82
|
+
client.on("messageCreate", (message) => {
|
|
83
|
+
void onMessage(message);
|
|
84
|
+
});
|
|
85
|
+
async function onMessage(message) {
|
|
86
|
+
const self = client.user;
|
|
87
|
+
if (!self || message.author.id === self.id || message.author.bot) return;
|
|
88
|
+
const isDM = !message.guildId;
|
|
89
|
+
if (!isDM && allowed && !allowed.has(message.channelId)) return;
|
|
90
|
+
const addressed = isDM || (message.mentions?.has(self.id) ?? false);
|
|
91
|
+
if (opts.mentionOnly && !addressed) return;
|
|
92
|
+
const entityId = `discord:${message.author.id}`;
|
|
93
|
+
const speaker = message.member?.displayName ?? message.author.displayName ?? message.author.username;
|
|
94
|
+
roster.record({
|
|
95
|
+
entityId,
|
|
96
|
+
userId: message.author.id,
|
|
97
|
+
...speaker ? { displayName: speaker } : {},
|
|
98
|
+
...isDM ? { dmChannelId: message.channelId } : { lastChannelId: message.channelId }
|
|
99
|
+
});
|
|
100
|
+
if (!isDM) lastActiveChannelId = message.channelId;
|
|
101
|
+
if (addressed) await message.channel.sendTyping?.().catch(() => {
|
|
102
|
+
});
|
|
103
|
+
const text = message.cleanContent || message.content;
|
|
104
|
+
if (!text.trim()) return;
|
|
105
|
+
await will.perceive({
|
|
106
|
+
text,
|
|
107
|
+
from: entityId,
|
|
108
|
+
thread: `discord:${message.channelId}`,
|
|
109
|
+
...speaker ? { speaker } : {}
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
let closed = false;
|
|
113
|
+
will.on("message", (m) => {
|
|
114
|
+
if (!closed) void deliver(m);
|
|
115
|
+
});
|
|
116
|
+
async function deliver(m) {
|
|
117
|
+
const peer = m.to ? roster.resolve(m.to) : void 0;
|
|
118
|
+
const chunks = chunkText(m.content, DISCORD_MESSAGE_LIMIT);
|
|
119
|
+
const channelIds = [peer?.lastChannelId, peer?.dmChannelId, opts.homeChannelId ?? void 0, lastActiveChannelId ?? void 0];
|
|
120
|
+
for (const id of channelIds) {
|
|
121
|
+
if (!id) continue;
|
|
122
|
+
try {
|
|
123
|
+
const channel = await client.channels.fetch(id);
|
|
124
|
+
if (!channel?.send) continue;
|
|
125
|
+
for (const chunk of chunks) await channel.send(chunk);
|
|
126
|
+
return;
|
|
127
|
+
} catch {
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (peer) {
|
|
131
|
+
try {
|
|
132
|
+
const user = await client.users.fetch(peer.userId);
|
|
133
|
+
for (const chunk of chunks) await user.send(chunk);
|
|
134
|
+
return;
|
|
135
|
+
} catch {
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
log(`no route for utterance to '${m.to}' \u2014 dropped (${m.content.length} chars)`);
|
|
139
|
+
}
|
|
140
|
+
const bridge = {
|
|
141
|
+
kind: "discord",
|
|
142
|
+
async start() {
|
|
143
|
+
if (!client.user) {
|
|
144
|
+
const ready = new Promise((resolve) => {
|
|
145
|
+
let poll = null;
|
|
146
|
+
const done = () => {
|
|
147
|
+
if (poll) clearInterval(poll);
|
|
148
|
+
resolve();
|
|
149
|
+
};
|
|
150
|
+
client.once("clientReady", done);
|
|
151
|
+
poll = setInterval(() => {
|
|
152
|
+
if (client.isReady?.()) done();
|
|
153
|
+
}, 100);
|
|
154
|
+
poll.unref?.();
|
|
155
|
+
});
|
|
156
|
+
await client.login(opts.token ?? "");
|
|
157
|
+
await ready;
|
|
158
|
+
}
|
|
159
|
+
log(`${will.name} is present on Discord as user ${client.user?.id}`);
|
|
160
|
+
},
|
|
161
|
+
async close() {
|
|
162
|
+
if (closed) return;
|
|
163
|
+
closed = true;
|
|
164
|
+
roster.flush();
|
|
165
|
+
await Promise.resolve(client.destroy()).catch(() => {
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
return bridge;
|
|
170
|
+
}
|
|
171
|
+
async function createDiscordClient() {
|
|
172
|
+
let mod;
|
|
173
|
+
try {
|
|
174
|
+
mod = await import('discord.js');
|
|
175
|
+
} catch {
|
|
176
|
+
throw new Error("discord.js is not installed (it is an optionalDependency) \u2014 run `bun add discord.js` / `npm i discord.js` and retry.");
|
|
177
|
+
}
|
|
178
|
+
const { Client, GatewayIntentBits, Partials } = mod;
|
|
179
|
+
return new Client({
|
|
180
|
+
intents: [
|
|
181
|
+
GatewayIntentBits.Guilds,
|
|
182
|
+
GatewayIntentBits.GuildMessages,
|
|
183
|
+
GatewayIntentBits.MessageContent,
|
|
184
|
+
GatewayIntentBits.DirectMessages
|
|
185
|
+
],
|
|
186
|
+
partials: [Partials.Channel]
|
|
187
|
+
// DMs arrive on uncached channels
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export { connectDiscord };
|
|
192
|
+
//# sourceMappingURL=discord.js.map
|
|
193
|
+
//# sourceMappingURL=discord.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/channels/roster.ts","../../src/channels/types.ts","../../src/channels/discord.ts"],"names":[],"mappings":"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","file":"discord.js","sourcesContent":["// ─────────────────────────────────────────────────────────────\n// src/channels/roster.ts — who the Will knows on a platform, and where\n// ─────────────────────────────────────────────────────────────\n//\n// The mind knows *entities*; a platform knows user ids and channels. The roster\n// is the durable seam between them: for each entity the Will has met on a\n// channel it records how to reach them again — so a *proactive* utterance\n// (`message.to` from the mind's own initiative) can find its person after a\n// restart, not just within one session.\n//\n// It persists as a small JSON file next to the PMA artifact. Writes are\n// throttled (the file is advisory routing state, not cognition — losing the\n// last few seconds costs a fallback delivery, never memory).\n// ─────────────────────────────────────────────────────────────\n\nimport { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'\nimport { dirname } from 'node:path'\n\n/** How to reach one entity on the platform. */\nexport interface RosterEntry {\n /** The mind-side entity id, e.g. 'discord:80351110224678912'. */\n entityId: string\n /** The platform-side user id. */\n userId: string\n /** Last display name seen (advisory — the *learned* name lives in the mind). */\n displayName?: string\n /** DM channel id, once one is known. */\n dmChannelId?: string\n /** Last shared (guild) channel this entity spoke in. */\n lastChannelId?: string\n /** Epoch ms of the last message seen from them. */\n lastSeenAt: number\n}\n\nconst FLUSH_MS = 2_000\n\nexport class ChannelRoster {\n private entries = new Map<string, RosterEntry>()\n private dirty = false\n private timer: ReturnType<typeof setTimeout> | null = null\n\n constructor( private readonly path: string ) {\n if( existsSync( path ) ){\n try {\n const raw = JSON.parse( readFileSync( path, 'utf8' ) ) as RosterEntry[]\n for( const e of Array.isArray( raw ) ? raw : [] ) this.entries.set( e.entityId, e )\n }\n catch { /* a corrupt roster is not worth failing a boot over — start fresh */ }\n }\n }\n\n /** Upsert what we just learned about an entity; schedules a throttled flush. */\n record( update: { entityId: string; userId: string } & Partial<Omit<RosterEntry, 'entityId' | 'userId'>> ): RosterEntry {\n const prev = this.entries.get( update.entityId )\n const next: RosterEntry = {\n lastSeenAt: Date.now(),\n ...prev,\n ...Object.fromEntries( Object.entries( update ).filter( ( [ , v ] ) => v !== undefined ) ) as typeof update,\n }\n this.entries.set( next.entityId, next )\n this.dirty = true\n if( !this.timer ){\n this.timer = setTimeout( () => { this.timer = null; this.flush() }, FLUSH_MS )\n this.timer.unref?.()\n }\n return next\n }\n\n resolve( entityId: string ): RosterEntry | undefined {\n return this.entries.get( entityId )\n }\n\n all(): RosterEntry[] {\n return [ ...this.entries.values() ]\n }\n\n /** Write to disk now (no-op when clean). Called by bridges on close. */\n flush(): void {\n if( !this.dirty ) return\n try {\n mkdirSync( dirname( this.path ), { recursive: true } )\n writeFileSync( this.path, JSON.stringify( this.all(), null, 2 ) )\n this.dirty = false\n }\n catch { /* advisory state — never take the mind down over it */ }\n }\n}\n","// ─────────────────────────────────────────────────────────────\n// src/channels/types.ts — the channel-bridge contract\n// ─────────────────────────────────────────────────────────────\n//\n// A channel bridge puts a Will *in a place where people already are* (Discord,\n// Telegram, Slack, …). It is a host surface, not a cognition surface: it turns\n// platform messages into `perceive` stimuli and delivers the Will's projected\n// utterances back — nothing more. The paradigm survives the crossing:\n//\n// • every platform user is an entity the Will comes to know (`from`),\n// with a *learned* name (`speaker`) — never a placeholder;\n// • every platform channel/DM is a conversation thread (`thread`);\n// • the Will decides when to speak. Silence is a valid outcome, so a\n// bridge never fabricates a reply and never times a message out into\n// an error.\n//\n// Bridges live at the same altitude as the MCP/HTTP hosts (src/mcp, src/serve):\n// they wrap the SDK facade, not the stem.\n// ─────────────────────────────────────────────────────────────\n\n/** A running connection between one Will and one platform. */\nexport interface ChannelBridge {\n /** Platform kind, e.g. 'discord'. */\n readonly kind: string\n /** Connect and start relaying. Resolves once the bridge is live. */\n start(): Promise<void>\n /** Disconnect and release resources. Idempotent. */\n close(): Promise<void>\n}\n\n/** Split a message into platform-sized chunks on natural boundaries. */\nexport function chunkText( text: string, max: number ): string[] {\n if( text.length <= max ) return [ text ]\n const chunks: string[] = []\n let rest = text\n while( rest.length > max ){\n // Prefer a paragraph break, then a line break, then a space — else hard-cut.\n const window = rest.slice( 0, max )\n const cut = Math.max( window.lastIndexOf( '\\n\\n' ), window.lastIndexOf( '\\n' ), window.lastIndexOf( ' ' ) )\n const at = cut > max * 0.5 ? cut : max\n chunks.push( rest.slice( 0, at ).trimEnd() )\n rest = rest.slice( at ).trimStart()\n }\n if( rest ) chunks.push( rest )\n return chunks\n}\n","// ─────────────────────────────────────────────────────────────\n// src/channels/discord.ts — a Will present in a Discord server\n// ─────────────────────────────────────────────────────────────\n//\n// The bridge relays both directions of the paradigm and nothing else:\n//\n// inbound guild/DM message → will.perceive({ from, speaker, text, thread })\n// — every author is `discord:<userId>` (stable across guilds), the\n// display name is *learned* by the mind, and each Discord channel\n// is its own conversation thread.\n// outbound will.on('message') → the addressee's last shared channel, else\n// their DM, else the home channel. Proactive utterances (the mind\n// speaking first) route the same way — that is the point.\n//\n// The Will decides when to speak. There is no command prefix and no forced\n// reply: unaddressed chatter is perceived (salience-scored by audition) and\n// silence is a valid outcome. `mentionOnly` narrows perception for busy\n// servers; it does not turn the bridge into an ask() surface.\n//\n// discord.js is imported lazily inside `createDiscordClient` — tests (and any\n// host that brings its own client) inject `client`, and the structural\n// `DiscordLikeClient` type keeps the dependency out of the type graph.\n// ─────────────────────────────────────────────────────────────\n\nimport type { Will, WillMessage } from '#sdk/will'\nimport { ChannelRoster } from '#channels/roster'\nimport { chunkText, type ChannelBridge } from '#channels/types'\n\nconst DISCORD_MESSAGE_LIMIT = 2000\n\n// ── The slice of discord.js the bridge actually uses (structural) ───────────\n\nexport interface DiscordLikeChannel {\n send( content: string ): Promise<unknown>\n sendTyping?(): Promise<unknown>\n}\n\nexport interface DiscordLikeMessage {\n content: string\n cleanContent?: string\n channelId: string\n guildId?: string | null\n author: { id: string; bot?: boolean; username?: string; displayName?: string }\n member?: { displayName?: string } | null\n mentions?: { has( userId: string ): boolean }\n channel: DiscordLikeChannel\n}\n\nexport interface DiscordLikeClient {\n user: { id: string; setPresence?( p: unknown ): void } | null\n /** discord.js ≥14.22; polled so we needn't subscribe to the deprecated `ready`. */\n isReady?(): boolean\n on( event: 'messageCreate', fn: ( m: DiscordLikeMessage ) => void ): unknown\n once( event: string, fn: () => void ): unknown\n login( token: string ): Promise<unknown>\n destroy(): Promise<unknown> | void\n channels: { fetch( id: string ): Promise<unknown> }\n users: { fetch( id: string ): Promise<{ send( content: string ): Promise<unknown> }> }\n}\n\n// ── Options ──────────────────────────────────────────────────────────────────\n\nexport interface DiscordBridgeOptions {\n /** Bot token (Discord developer portal). Unused when `client` is injected pre-logged-in. */\n token?: string\n /** Channel ids the Will inhabits. Unset = every channel it can see. */\n channels?: string[]\n /** Perceive guild messages only when the Will is @mentioned (DMs always perceived). */\n mentionOnly?: boolean\n /** Fallback channel for utterances with no reachable addressee. */\n homeChannelId?: string\n /** Roster path (default: ./.will/<willId>.discord.json). */\n rosterPath?: string\n /** Test / power-user seam: bring your own client; discord.js is never imported. */\n client?: DiscordLikeClient\n log?: ( msg: string ) => void\n}\n\n// ── The bridge ───────────────────────────────────────────────────────────────\n\n/**\n * Connect a Will to Discord. Resolves once the bridge is live (logged in and\n * relaying). Close it via the returned `ChannelBridge.close()` — the Will\n * itself is not stopped; it simply loses this surface.\n */\nexport async function connectDiscord( will: Will, opts: DiscordBridgeOptions ): Promise<ChannelBridge> {\n const log = opts.log ?? ( ( m: string ) => console.error( `[will:discord] ${ m }` ) )\n const roster = new ChannelRoster( opts.rosterPath ?? `.will/${ will.id }.discord.json` )\n const allowed = opts.channels?.length ? new Set( opts.channels ) : null\n\n const client = opts.client ?? await createDiscordClient()\n\n /** The most recently active allowed channel — last-resort proactive target. */\n let lastActiveChannelId: string | null = opts.homeChannelId ?? null\n\n // ── inbound: platform message → stimulus ──────────────────────────────────\n client.on( 'messageCreate', message => { void onMessage( message ) } )\n\n async function onMessage( message: DiscordLikeMessage ): Promise<void> {\n const self = client.user\n if( !self || message.author.id === self.id || message.author.bot ) return\n\n const isDM = !message.guildId\n if( !isDM && allowed && !allowed.has( message.channelId ) ) return\n\n const addressed = isDM || ( message.mentions?.has( self.id ) ?? false )\n if( opts.mentionOnly && !addressed ) return\n\n const entityId = `discord:${ message.author.id }`\n const speaker = message.member?.displayName ?? message.author.displayName ?? message.author.username\n\n roster.record( {\n entityId,\n userId: message.author.id,\n ...( speaker ? { displayName: speaker } : {} ),\n ...( isDM ? { dmChannelId: message.channelId } : { lastChannelId: message.channelId } ),\n } )\n if( !isDM ) lastActiveChannelId = message.channelId\n\n // Being addressed is the one moment a presence cue is honest — the mind\n // may still choose silence, and typing expires on its own.\n if( addressed ) await message.channel.sendTyping?.().catch( () => {} )\n\n const text = message.cleanContent || message.content\n if( !text.trim() ) return\n\n await will.perceive( {\n text,\n from: entityId,\n thread: `discord:${ message.channelId }`,\n ...( speaker ? { speaker } : {} ),\n } )\n }\n\n // ── outbound: projected utterance → the addressee ─────────────────────────\n // The facade has no off(); the bridge gates its handler on `closed` instead.\n let closed = false\n will.on( 'message', ( m: WillMessage ) => { if( !closed ) void deliver( m ) } )\n\n async function deliver( m: WillMessage ): Promise<void> {\n const peer = m.to ? roster.resolve( m.to ) : undefined\n const chunks = chunkText( m.content, DISCORD_MESSAGE_LIMIT )\n\n // Preference order: where we last shared a room → their DM → home channel.\n const channelIds = [ peer?.lastChannelId, peer?.dmChannelId, opts.homeChannelId ?? undefined, lastActiveChannelId ?? undefined ]\n for( const id of channelIds ){\n if( !id ) continue\n try {\n const channel = await client.channels.fetch( id ) as DiscordLikeChannel | null\n if( !channel?.send ) continue\n for( const chunk of chunks ) await channel.send( chunk )\n return\n }\n catch { /* try the next route */ }\n }\n if( peer ){\n try {\n const user = await client.users.fetch( peer.userId )\n for( const chunk of chunks ) await user.send( chunk )\n return\n }\n catch { /* fall through */ }\n }\n log( `no route for utterance to '${ m.to }' — dropped (${ m.content.length } chars)` )\n }\n\n // ── lifecycle ──────────────────────────────────────────────────────────────\n const bridge: ChannelBridge = {\n kind: 'discord',\n async start(): Promise<void> {\n if( !client.user ){\n // discord.js ≥14.22 renamed `ready` → `clientReady`. Subscribing to the\n // old name is what triggers its DeprecationWarning, so we take the new\n // name and poll `isReady()` for older builds rather than listening.\n const ready = new Promise<void>( resolve => {\n let poll: ReturnType<typeof setInterval> | null = null\n const done = (): void => { if( poll ) clearInterval( poll ); resolve() }\n client.once( 'clientReady', done )\n poll = setInterval( () => { if( client.isReady?.() ) done() }, 100 )\n poll.unref?.()\n } )\n await client.login( opts.token ?? '' )\n await ready\n }\n log( `${ will.name } is present on Discord as user ${ client.user?.id }` )\n },\n async close(): Promise<void> {\n if( closed ) return\n closed = true\n roster.flush()\n await Promise.resolve( client.destroy() ).catch( () => {} )\n },\n }\n return bridge\n}\n\n/** Build a real discord.js client (lazy import keeps it out of non-Discord hosts). */\nasync function createDiscordClient(): Promise<DiscordLikeClient> {\n let mod: typeof import( 'discord.js' )\n try { mod = await import( 'discord.js' ) }\n catch {\n throw new Error( 'discord.js is not installed (it is an optionalDependency) — run `bun add discord.js` / `npm i discord.js` and retry.' )\n }\n const { Client, GatewayIntentBits, Partials } = mod\n return new Client( {\n intents: [\n GatewayIntentBits.Guilds,\n GatewayIntentBits.GuildMessages,\n GatewayIntentBits.MessageContent,\n GatewayIntentBits.DirectMessages,\n ],\n partials: [ Partials.Channel ], // DMs arrive on uncached channels\n } ) as unknown as DiscordLikeClient\n}\n"]}
|