@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.
Files changed (56) hide show
  1. package/README.md +65 -11
  2. package/dist/channels/discord.d.ts +78 -0
  3. package/dist/channels/discord.js +193 -0
  4. package/dist/channels/discord.js.map +1 -0
  5. package/dist/{mcp/cli.js → cli.js} +1037 -546
  6. package/dist/cli.js.map +1 -0
  7. package/dist/index.d.ts +141 -141
  8. package/dist/index.js +390 -316
  9. package/dist/index.js.map +1 -1
  10. package/dist/mcp/effectors.d.ts +1 -1
  11. package/dist/{will-B5eKs3Wv.d.ts → will-D-slky1N.d.ts} +4011 -3916
  12. package/package.json +7 -2
  13. package/src/channels/discord.ts +214 -0
  14. package/src/channels/roster.ts +87 -0
  15. package/src/channels/types.ts +46 -0
  16. package/src/cli.ts +100 -0
  17. package/src/cognition/agency/engines/deliberation.engine.ts +7 -7
  18. package/src/cognition/agency/execution.primitives.ts +11 -11
  19. package/src/cognition/agency/proactive.communicator.ts +8 -8
  20. package/src/cognition/config.mirror.entities.ts +2 -2
  21. package/src/cognition/conversation.memory.ts +1 -1
  22. package/src/cognition/faculties/executive.engine/commands.ts +7 -15
  23. package/src/cognition/faculties/executive.engine/engine.ts +64 -34
  24. package/src/cognition/faculties/executive.engine/escalation.buffer.ts +1 -1
  25. package/src/cognition/faculties/executive.engine/facet.ts +1 -1
  26. package/src/cognition/faculties/executive.engine/prompt.factory.ts +76 -61
  27. package/src/cognition/faculties/executive.engine/types.ts +1 -1
  28. package/src/cognition/faculties/introspection.engine.ts +1 -2
  29. package/src/cognition/faculties/planning.engine/engine.ts +38 -1
  30. package/src/cognition/faculties/planning.engine/plan.store.ts +42 -0
  31. package/src/cognition/faculties/planning.engine/plan.supervision.ts +7 -7
  32. package/src/cognition/faculties/theory.of.mind.ts +2 -2
  33. package/src/cognition/senses/audition.engine/engine.ts +21 -21
  34. package/src/host/boot.ts +193 -0
  35. package/src/host/utterances.ts +53 -0
  36. package/src/llm/summarizer.ts +2 -2
  37. package/src/mcp/server.ts +7 -30
  38. package/src/profiles/companion.ts +14 -14
  39. package/src/profiles/company-brain.ts +19 -19
  40. package/src/profiles/customer-service.ts +17 -17
  41. package/src/profiles/game-npc.ts +10 -10
  42. package/src/profiles/index.ts +2 -2
  43. package/src/profiles/smart-home.ts +16 -16
  44. package/src/runners/outreach.runner.ts +6 -9
  45. package/src/runners/social.runner.ts +1 -4
  46. package/src/runners/thin-shim.runner.ts +4 -6
  47. package/src/sdk/will.ts +27 -10
  48. package/src/serve/server.ts +154 -0
  49. package/src/stem/guards/identity.coherence.ts +5 -3
  50. package/src/stem/guards/identity.guard.ts +20 -9
  51. package/src/stem/index.ts +7 -7
  52. package/src/stem/mind.ts +182 -98
  53. package/src/stem/tracts/outbox.controller.ts +2 -2
  54. package/dist/mcp/cli.js.map +0 -1
  55. package/src/mcp/cli.ts +0 -129
  56. /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). Two entry points:
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: `WILL_TIER` (basic|standard|full), `WILL_LLM` (mock|anthropic — defaults to the zero-key mock unless `ANTHROPIC_API_KEY` is set), `WILL_TICK_MS`, `WILL_PMA_PATH`.
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
- **The other direction a Will *employing* MCP tools.** 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`.
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
- engineTier: 'full', // required see WillConfig reference (full = all layers)
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
+ ![Will — the whole composition](docs/graphs/composition.svg)
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: 'You are a rover exploring terrain. You can move, scan, and grab samples.',
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: 'You are a research subject. Report your reasoning transparently…',
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
- engineTier: 'full',
527
+
478
528
 
479
529
  // REQUIRED. Which model the executive recruits when it fires.
480
- modelTier: 'sonnet',
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
- | `engineTier` | ✅ | — | `'basic' \| 'standard' \| 'full'` — how many cognitive layers run. `full` = all engines (regulatory→perceptual→affective→memory→executive→meta-cognitive→social). Lower tiers drop upper layers and use a slower default cadence. **Use `'full'` unless you have a reason not to.** |
507
- | `modelTier` | | | `'haiku' \| 'sonnet' \| 'opus'` which model the executive recruits |
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"]}