@mindot/will 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -11
- package/dist/channels/discord.d.ts +78 -0
- package/dist/channels/discord.js +193 -0
- package/dist/channels/discord.js.map +1 -0
- package/dist/cli.js +648 -330
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +141 -141
- package/dist/index.js +390 -316
- package/dist/index.js.map +1 -1
- package/dist/mcp/effectors.d.ts +1 -1
- package/dist/{will-B5eKs3Wv.d.ts → will-D-slky1N.d.ts} +4011 -3916
- package/package.json +6 -1
- package/src/channels/discord.ts +214 -0
- package/src/channels/roster.ts +87 -0
- package/src/channels/types.ts +46 -0
- package/src/cli.ts +34 -9
- package/src/cognition/agency/engines/deliberation.engine.ts +7 -7
- package/src/cognition/agency/execution.primitives.ts +11 -11
- package/src/cognition/agency/proactive.communicator.ts +8 -8
- package/src/cognition/config.mirror.entities.ts +2 -2
- package/src/cognition/conversation.memory.ts +1 -1
- package/src/cognition/faculties/executive.engine/commands.ts +7 -15
- package/src/cognition/faculties/executive.engine/engine.ts +64 -34
- package/src/cognition/faculties/executive.engine/escalation.buffer.ts +1 -1
- package/src/cognition/faculties/executive.engine/facet.ts +1 -1
- package/src/cognition/faculties/executive.engine/prompt.factory.ts +76 -61
- package/src/cognition/faculties/executive.engine/types.ts +1 -1
- package/src/cognition/faculties/introspection.engine.ts +1 -2
- package/src/cognition/faculties/planning.engine/engine.ts +38 -1
- package/src/cognition/faculties/planning.engine/plan.store.ts +42 -0
- package/src/cognition/faculties/planning.engine/plan.supervision.ts +7 -7
- package/src/cognition/faculties/theory.of.mind.ts +2 -2
- package/src/cognition/senses/audition.engine/engine.ts +21 -21
- package/src/host/boot.ts +70 -4
- package/src/llm/summarizer.ts +2 -2
- package/src/profiles/companion.ts +14 -14
- package/src/profiles/company-brain.ts +19 -19
- package/src/profiles/customer-service.ts +17 -17
- package/src/profiles/game-npc.ts +10 -10
- package/src/profiles/index.ts +2 -2
- package/src/profiles/smart-home.ts +16 -16
- package/src/runners/outreach.runner.ts +6 -9
- package/src/runners/social.runner.ts +1 -4
- package/src/runners/thin-shim.runner.ts +4 -6
- package/src/sdk/will.ts +27 -10
- package/src/stem/guards/identity.coherence.ts +5 -3
- package/src/stem/guards/identity.guard.ts +20 -9
- package/src/stem/index.ts +7 -7
- package/src/stem/mind.ts +182 -98
- package/src/stem/tracts/outbox.controller.ts +2 -2
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ 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
|
|
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
23
|
|
|
24
24
|
---
|
|
25
25
|
|
|
@@ -84,11 +84,12 @@ ticks — or earlier when physiology demands it.
|
|
|
84
84
|
|
|
85
85
|
## Use it in your project
|
|
86
86
|
|
|
87
|
-
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
88
|
|
|
89
89
|
| Surface | You are… | Start with |
|
|
90
90
|
|---|---|---|
|
|
91
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` |
|
|
92
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` |
|
|
93
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'` |
|
|
94
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` |
|
|
@@ -125,6 +126,20 @@ const revived = await Will.wake(pma, { name: 'Aria' })
|
|
|
125
126
|
|
|
126
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).
|
|
127
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
|
+
|
|
128
143
|
### The MCP server — a persistent mind in Claude Desktop / Claude Code
|
|
129
144
|
|
|
130
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):
|
|
@@ -144,7 +159,7 @@ Host a Will over the [Model Context Protocol](https://modelcontextprotocol.io)
|
|
|
144
159
|
}
|
|
145
160
|
```
|
|
146
161
|
|
|
147
|
-
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`.
|
|
148
163
|
|
|
149
164
|
### Employing MCP tools — the mind gets abilities
|
|
150
165
|
|
|
@@ -204,8 +219,7 @@ const config: WillConfig = {
|
|
|
204
219
|
traits: { conscientiousness: 0.9, neuroticism: 0.4 },
|
|
205
220
|
style: 'measured and warm',
|
|
206
221
|
},
|
|
207
|
-
|
|
208
|
-
modelTier: 'sonnet', // which model the executive recruits
|
|
222
|
+
model: 'claude-sonnet-4-5-20250929', // concrete model id (optional — env/provider default otherwise)
|
|
209
223
|
allowedGenericEffectors: ['listen', 'talk', 'text'], // opt in to communication
|
|
210
224
|
persistentMemory: true,
|
|
211
225
|
snapshotInterval: 10,
|
|
@@ -239,6 +253,10 @@ There is **no synchronous reply** — `ingestText` returns immediately; the Will
|
|
|
239
253
|
|
|
240
254
|
## Architecture
|
|
241
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
|
+
|
|
242
260
|
### The cognitive engines
|
|
243
261
|
|
|
244
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.
|
|
@@ -330,7 +348,7 @@ Beyond the five communication effectors, your world can expose **domain actions*
|
|
|
330
348
|
registerProfile({
|
|
331
349
|
id: 'rover', name: 'Rover', description: 'A field robot.',
|
|
332
350
|
effectors: ['listen', 'talk', 'move', 'scan', 'grab'],
|
|
333
|
-
context: '
|
|
351
|
+
context: 'I am a rover exploring terrain. I can move, scan, and grab samples.',
|
|
334
352
|
})
|
|
335
353
|
|
|
336
354
|
// 2. When the Will chooses one, it appears in pendingEffectorInvocations.
|
|
@@ -458,7 +476,7 @@ registerProfile({
|
|
|
458
476
|
name: 'Research Lab',
|
|
459
477
|
description: 'An observable mind for studying emergent cognition.',
|
|
460
478
|
effectors: ['listen', 'talk', 'remember', 'reflect'],
|
|
461
|
-
context: '
|
|
479
|
+
context: 'I am a research subject. I report my reasoning transparently…',
|
|
462
480
|
})
|
|
463
481
|
|
|
464
482
|
// Then in WillConfig: { ..., profile: 'research-lab' }
|
|
@@ -506,10 +524,10 @@ const config: WillConfig = {
|
|
|
506
524
|
|
|
507
525
|
// REQUIRED. How many cognitive layers run (see WillConfig reference below).
|
|
508
526
|
// 'full' runs the complete mind — the normal choice.
|
|
509
|
-
|
|
527
|
+
|
|
510
528
|
|
|
511
529
|
// REQUIRED. Which model the executive recruits when it fires.
|
|
512
|
-
|
|
530
|
+
model: 'claude-sonnet-4-5-20250929',
|
|
513
531
|
|
|
514
532
|
// Communication effectors this Will is permitted to use.
|
|
515
533
|
// Omit or set null for a Will with no communication surface.
|
|
@@ -535,8 +553,8 @@ Traits seed the PersonaPrior as a *starting disposition*, not a fixed personalit
|
|
|
535
553
|
| `id` | ✅ | — | Unique identifier — thread key and filesystem path segment |
|
|
536
554
|
| `name` | ✅ | — | Human-readable display name |
|
|
537
555
|
| `identity` | ✅ | — | Persona: `{ prompt, values[], traits{}, style }` (Layer 2) |
|
|
538
|
-
| `
|
|
539
|
-
| `
|
|
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. |
|
|
540
558
|
| `persistentMemory` | ✅ | — | Persist snapshots so beliefs/goals/narrative survive restarts |
|
|
541
559
|
| `snapshotInterval` | ✅ | — | Ticks between in-memory snapshots |
|
|
542
560
|
| `profile` | — | `null` | World profile preset (effectors + environment context). Merged with `allowedGenericEffectors` |
|
|
@@ -782,6 +800,10 @@ cd will && bun run build
|
|
|
782
800
|
| `WILL_EMBEDDING_MODEL` | `text-embedding-3-small` *(when keyed)* | Embedding model for episodic recall; `none` disables |
|
|
783
801
|
| `WILL_EMBEDDING_URL` | *(provider default)* | Base URL for an OpenAI-compatible embedding endpoint |
|
|
784
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 |
|
|
785
807
|
| `OPENAI_BASE_URL` | — | Base URL override for local / OpenAI-compatible models |
|
|
786
808
|
|
|
787
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"]}
|