@mindot/will 0.1.0 → 0.2.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 (36) hide show
  1. package/README.md +38 -3
  2. package/dist/index.d.ts +52 -7760
  3. package/dist/index.js +374 -86
  4. package/dist/index.js.map +1 -1
  5. package/dist/mcp/cli.d.ts +1 -0
  6. package/dist/mcp/cli.js +26048 -0
  7. package/dist/mcp/cli.js.map +1 -0
  8. package/dist/mcp/effectors.d.ts +55 -0
  9. package/dist/mcp/effectors.js +76 -0
  10. package/dist/mcp/effectors.js.map +1 -0
  11. package/dist/will-B5eKs3Wv.d.ts +7903 -0
  12. package/package.json +38 -30
  13. package/src/cognition/agency/engines/action.selector.ts +3 -0
  14. package/src/cognition/agency/engines/affordance.synthesizer.ts +26 -12
  15. package/src/cognition/agency/engines/deliberation.engine.ts +7 -2
  16. package/src/cognition/agency/engines/motor.schema.executor.ts +2 -0
  17. package/src/cognition/agency/schemas/external.ts +27 -10
  18. package/src/cognition/agency/schemas/repertoire.ts +12 -0
  19. package/src/cognition/agency/types.ts +51 -2
  20. package/src/cognition/faculties/executive.engine/commands.ts +47 -11
  21. package/src/cognition/faculties/executive.engine/context.ts +28 -0
  22. package/src/cognition/faculties/executive.engine/facet.supervisor.ts +15 -1
  23. package/src/cognition/faculties/executive.engine/facet.ts +32 -6
  24. package/src/cognition/faculties/executive.engine/prompt.factory.ts +11 -1
  25. package/src/cognition/faculties/executive.engine/types.ts +24 -2
  26. package/src/cognition/index.ts +1 -1
  27. package/src/core/abstracts.ts +39 -12
  28. package/src/index.ts +6 -0
  29. package/src/mcp/cli.ts +129 -0
  30. package/src/mcp/effectors.ts +159 -0
  31. package/src/mcp/server.ts +167 -0
  32. package/src/sdk/will.ts +269 -44
  33. package/src/stem/index.ts +16 -0
  34. package/src/stem/mind.ts +11 -4
  35. package/src/stem/tracts/effector.controller.ts +1 -0
  36. package/src/types.ts +2 -0
@@ -8,7 +8,16 @@ import type { PlanStep } from '#cognition/faculties/planning.engine/engine'
8
8
  // ── Full executive output ────────────────────────────────────
9
9
 
10
10
  export interface ExecutiveOutputFull {
11
- actions: Array<{ type: string; reasoning: string; expectedOutcome: string; target?: string }>
11
+ actions: Array<{
12
+ type: string; reasoning: string; expectedOutcome: string; target?: string
13
+ /**
14
+ * Arguments the executive consciously supplies when enacting an ability
15
+ * that needs them (e.g. a search ability's query). Ride the ideomotor
16
+ * intent into the affordance competition and, if the action wins, reach
17
+ * the host handler as the invocation's parameters.
18
+ */
19
+ args?: Record<string, unknown>
20
+ }>
12
21
  reasoning: string
13
22
  confidence: number
14
23
  /** Plans — the executive controls lifecycle via status + action fields */
@@ -140,7 +149,7 @@ export interface ExecutivePlanOutput {
140
149
  // ── Minimal output from LLM (before tagged-block parsing) ────
141
150
 
142
151
  export interface ExecutiveOutputMinimal {
143
- actions: Array<{ type: string; reasoning: string; expectedOutcome: string; target?: string }>
152
+ actions: Array<{ type: string; reasoning: string; expectedOutcome: string; target?: string; args?: Record<string, unknown> }>
144
153
  reasoning: string
145
154
  confidence: number
146
155
  }
@@ -229,6 +238,19 @@ export interface ExecutiveContext {
229
238
  summary: string
230
239
  salience: number
231
240
  }>
241
+ /**
242
+ * Host-declared abilities afforded to the Will *right now* — what it can do in
243
+ * this situation and what each is for. Surfaced so System 2 reasons with
244
+ * knowledge of its options; the Will still expresses intent (it does not fill a
245
+ * tool form) and the agency field competes + binds. Only *available* external
246
+ * affordances appear; absent when there are none.
247
+ */
248
+ abilities?: Array<{
249
+ name: string
250
+ description?: string
251
+ /** Bound target's display name, when the ability is directed at someone. */
252
+ target?: string
253
+ }>
232
254
  workingMemory: Array<{
233
255
  type: string
234
256
  summary: string
@@ -39,7 +39,7 @@ import { DreamSimulator, type DreamSimulatorConfig } from '#faculties/dream.simu
39
39
 
40
40
  import { GoalManager, type GoalManagerConfig } from '#faculties/goal.manager'
41
41
  import { ExecutiveEngine, type ExecutiveEngineConfig } from '#faculties/executive.engine'
42
- import { PlanningEngine, type PlanningEngineConfig, type ActivityEvent, type ActivityEventHandler } from '#cognition/faculties/planning.engine/engine'
42
+ import { PlanningEngine, type PlanningEngineConfig, type ActivityEvent, type ActivityEventHandler } from '#faculties/planning.engine/engine'
43
43
  import { InhibitionController, type InhibitionControllerConfig } from '#faculties/inhibition.controller'
44
44
  import { TaskSwitcher, type TaskSwitcherConfig } from '#faculties/task.switcher'
45
45
 
@@ -20,39 +20,66 @@ export interface StorageAdapter {
20
20
  }
21
21
 
22
22
  /**
23
- * Bun-native storage adapter.
23
+ * Bun-native storage adapter, with a node:fs fallback when the Bun global is
24
+ * absent — the engine is Node-compatible (Bun remains the primary target).
24
25
  * Default for all framework components that perform file I/O.
25
26
  */
26
27
  export class BunStorageAdapter implements StorageAdapter {
28
+ private get _isBun(): boolean { return typeof Bun !== 'undefined' }
29
+
27
30
  async write( path: string, content: string | Uint8Array ): Promise<void> {
28
- await Bun.write( path, content )
31
+ if( this._isBun ){
32
+ await Bun.write( path, content )
33
+ return
34
+ }
35
+ // Bun.write creates parent directories; node's writeFile does not.
36
+ const { mkdir, writeFile } = await import('node:fs/promises')
37
+ const { dirname } = await import('node:path')
38
+ await mkdir( dirname( path ), { recursive: true } )
39
+ await writeFile( path, content )
29
40
  }
30
41
 
31
42
  async read( path: string ): Promise<string> {
32
- const file = Bun.file( path )
33
- if( !( await file.exists() ) )
43
+ if( !( await this.exists( path ) ) )
34
44
  throw new Error(`File not found: ${path}`)
35
45
 
36
- return file.text()
46
+ if( this._isBun )
47
+ return Bun.file( path ).text()
48
+
49
+ const { readFile } = await import('node:fs/promises')
50
+ return readFile( path, 'utf8' )
37
51
  }
38
52
 
39
53
  async readBytes( path: string ): Promise<Uint8Array> {
40
- const file = Bun.file( path )
41
- if( !( await file.exists() ) )
54
+ if( !( await this.exists( path ) ) )
42
55
  throw new Error(`File not found: ${path}`)
43
56
 
44
- return new Uint8Array( await file.arrayBuffer() )
57
+ if( this._isBun )
58
+ return new Uint8Array( await Bun.file( path ).arrayBuffer() )
59
+
60
+ const { readFile } = await import('node:fs/promises')
61
+ return new Uint8Array( await readFile( path ) )
45
62
  }
46
63
 
47
64
  async exists( path: string ): Promise<boolean> {
48
- return Bun.file( path ).exists()
65
+ if( this._isBun )
66
+ return Bun.file( path ).exists()
67
+
68
+ const { access } = await import('node:fs/promises')
69
+ try { await access( path ); return true }
70
+ catch { return false }
49
71
  }
50
72
 
51
73
  async delete( path: string ): Promise<void> {
52
- const file = Bun.file( path )
53
- await file.exists() && await file.delete()
74
+ if( this._isBun ){
75
+ const file = Bun.file( path )
76
+ await file.exists() && await file.delete()
77
+ return
78
+ }
79
+ const { rm } = await import('node:fs/promises')
80
+ await rm( path, { force: true } )
54
81
  }
55
-
82
+
56
83
  async ensureDir( path: string ): Promise<void> {
57
84
  const { mkdirSync } = await import('node:fs')
58
85
  mkdirSync( path, { recursive: true } )
package/src/index.ts CHANGED
@@ -84,8 +84,14 @@ export type { TextMessage, VoiceChunk, SensoryInput } from '#senses/index'
84
84
  export { Will } from '#sdk/will'
85
85
  export type {
86
86
  CreateWillOptions,
87
+ Stimulus,
87
88
  WillMessage,
89
+ WillEffectorAct,
90
+ WillAffect,
88
91
  WillStateSummary,
89
92
  EffectorHandler,
90
93
  EffectorResult,
94
+ EffectorSpec,
95
+ EffectorEntry,
91
96
  } from '#sdk/will'
97
+ export type { SchemaPrecondition, EffectorDeclaration } from '#agency/types'
package/src/mcp/cli.ts ADDED
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env node
2
+ // ─────────────────────────────────────────────────────────────
3
+ // src/mcp/cli.ts — `will mcp`: host a persistent mind over MCP stdio
4
+ // ─────────────────────────────────────────────────────────────
5
+ //
6
+ // Add to an MCP client (Claude Desktop / Claude Code / an IDE):
7
+ //
8
+ // { "command": "npx", "args": ["-y", "@mindot/will", "mcp"],
9
+ // "env": { "WILL_NAME": "Aria", "WILL_IDENTITY": "I am Aria, a curious mind." } }
10
+ //
11
+ // The mind PERSISTS across sessions: on boot, if the PMA file exists the same
12
+ // self wakes from it (identity, memories, relationships, learned skills); on
13
+ // shutdown it hibernates back to the file. Configuration (env):
14
+ //
15
+ // WILL_NAME display name (default "Will")
16
+ // WILL_IDENTITY persona prompt (default a minimal self)
17
+ // WILL_TIER basic | standard | full (default standard)
18
+ // WILL_LLM mock | anthropic (default: auto — anthropic when
19
+ // ANTHROPIC_API_KEY is set, else mock)
20
+ // WILL_TICK_MS ms per tick (default 1000)
21
+ // WILL_SEED deterministic seed (testing) (default unseeded/wall-time)
22
+ // WILL_PMA_PATH PMA artifact path (default ./.will/<name>.pma.json)
23
+ // WILL_MCP_SERVERS JSON array of MCP servers whose tools become the Will's
24
+ // OWN abilities (it employs them; results feed its learning):
25
+ // '[{"command":"npx","args":["-y","@modelcontextprotocol/server-filesystem","/tmp"]}]'
26
+ // Entries: {command,args?,env?} (stdio) or {url} (streamable HTTP).
27
+ //
28
+ // stdio discipline: stdout carries ONLY the MCP protocol — all engine logging
29
+ // is routed to stderr before anything else runs.
30
+ // ─────────────────────────────────────────────────────────────
31
+
32
+ import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'
33
+ import { dirname, resolve } from 'node:path'
34
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
35
+ import { setLogger } from '#core/logger'
36
+ import { Will, type CreateWillOptions } from '#sdk/will'
37
+ import type { PMASnapshot } from '#pma/index'
38
+ import { buildWillMcpServer } from '#root/mcp/server'
39
+ import { connectMcpEffectors, type McpToolsSource } from '#root/mcp/effectors'
40
+
41
+ // stdout is the protocol channel — route every engine log line to stderr FIRST.
42
+ const err = ( level: string ) => ( msg: string, ...rest: unknown[] ) =>
43
+ console.error( `[will-mcp:${ level }] ${ msg }`, ...rest )
44
+ setLogger( { debug: () => {}, info: err( 'info' ), warn: err( 'warn' ), error: err( 'error' ) } )
45
+
46
+ function slug( s: string ): string {
47
+ return s.toLowerCase().replace( /[^a-z0-9]+/g, '-' ).replace( /^-+|-+$/g, '' ) || 'will'
48
+ }
49
+
50
+ async function main(): Promise<void> {
51
+ const sub = process.argv[2]
52
+ if( sub !== undefined && sub !== 'mcp' ){
53
+ console.error( `usage: will mcp (host a persistent mind over MCP stdio)\nunknown subcommand: ${ sub }` )
54
+ process.exit( 2 )
55
+ }
56
+
57
+ const name = process.env.WILL_NAME ?? 'Will'
58
+ const pmaPath = resolve( process.env.WILL_PMA_PATH ?? `.will/${ slug( name ) }.pma.json` )
59
+
60
+ const opts: Omit<CreateWillOptions, 'identity'> = {
61
+ name,
62
+ engineTier: ( process.env.WILL_TIER as CreateWillOptions['engineTier'] ) ?? 'standard',
63
+ tickMs: parseInt( process.env.WILL_TICK_MS ?? '1000' ),
64
+ ...( process.env.WILL_LLM ? { llm: process.env.WILL_LLM as 'mock' | 'anthropic' } : {} ),
65
+ ...( process.env.WILL_SEED ? { seed: parseInt( process.env.WILL_SEED ) } : {} ),
66
+ }
67
+
68
+ // Wake the same self from its artifact when one exists; otherwise a birth.
69
+ let will: Will
70
+ if( existsSync( pmaPath ) ){
71
+ const pma = JSON.parse( readFileSync( pmaPath, 'utf8' ) ) as PMASnapshot
72
+ will = await Will.wake( pma, opts )
73
+ console.error( `[will-mcp] ${ name } woke from ${ pmaPath }` )
74
+ }
75
+ else {
76
+ will = await Will.create( {
77
+ ...opts,
78
+ identity: { prompt: process.env.WILL_IDENTITY ?? `I am ${ name }, a persistent mind hosted over MCP.` },
79
+ } )
80
+ console.error( `[will-mcp] ${ name } born (no artifact at ${ pmaPath } yet)` )
81
+ }
82
+ will.on( 'error', e => console.error( `[will-mcp] error: ${ e.message }` ) )
83
+
84
+ // Onward bridges: MCP servers whose tools become the Will's OWN abilities.
85
+ // Best-effort — a bad entry warns and is skipped; the mind still boots.
86
+ const bridgeCloses: Array<() => Promise<void>> = []
87
+ if( process.env.WILL_MCP_SERVERS ){
88
+ try {
89
+ const sources = JSON.parse( process.env.WILL_MCP_SERVERS ) as McpToolsSource[]
90
+ for( const source of Array.isArray( sources ) ? sources : [] ){
91
+ try {
92
+ const { names, close } = await connectMcpEffectors( will, source )
93
+ bridgeCloses.push( close )
94
+ console.error( `[will-mcp] ${ name } gained abilities: ${ names.join( ', ' ) }` )
95
+ }
96
+ catch( e ){ console.error( `[will-mcp] MCP bridge failed (skipped): ${ ( e as Error ).message }` ) }
97
+ }
98
+ }
99
+ catch( e ){ console.error( `[will-mcp] WILL_MCP_SERVERS is not valid JSON — ignoring: ${ ( e as Error ).message }` ) }
100
+ }
101
+
102
+ // Hibernate exactly once on the way out — distill + stop, then persist.
103
+ let leaving = false
104
+ const shutdown = async ( why: string ): Promise<void> => {
105
+ if( leaving ) return
106
+ leaving = true
107
+ for( const close of bridgeCloses ) await close().catch( () => {} )
108
+ try {
109
+ const pma = await will.hibernate()
110
+ mkdirSync( dirname( pmaPath ), { recursive: true } )
111
+ writeFileSync( pmaPath, JSON.stringify( pma ) )
112
+ console.error( `[will-mcp] ${ name } hibernated to ${ pmaPath } (${ why })` )
113
+ }
114
+ catch( e ){ console.error( `[will-mcp] hibernate failed: ${ ( e as Error ).message }` ) }
115
+ process.exit( 0 )
116
+ }
117
+ process.on( 'SIGINT', () => void shutdown( 'SIGINT' ) )
118
+ process.on( 'SIGTERM', () => void shutdown( 'SIGTERM' ) )
119
+ process.stdin.on( 'end', () => void shutdown( 'client disconnected' ) )
120
+
121
+ const server = buildWillMcpServer( will, { pmaPath } )
122
+ await server.connect( new StdioServerTransport() )
123
+ console.error( `[will-mcp] ${ name } is listening on stdio (tick ${ opts.tickMs }ms, tier ${ opts.engineTier })` )
124
+ }
125
+
126
+ main().catch( e => {
127
+ console.error( `[will-mcp] fatal: ${ e instanceof Error ? e.stack ?? e.message : String( e ) }` )
128
+ process.exit( 1 )
129
+ } )
@@ -0,0 +1,159 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // src/mcp/effectors.ts — a Will EMPLOYING MCP tools (Seam 1)
3
+ // ─────────────────────────────────────────────────────────────
4
+ //
5
+ // The other direction from server.ts: connect a Will to an external MCP server
6
+ // and register that server's tools as the Will's own ABILITIES. Each tool
7
+ // becomes a learnable affordance — its description (plus a compact hint of the
8
+ // arguments it takes) is the ability's meaning, surfaced to the executive and
9
+ // the deliberator; the WILL decides when to enact one (nothing here dispatches);
10
+ // the tool's result feeds back through reafference, so the Will gets *skilled*
11
+ // at the tools it uses.
12
+ //
13
+ // Arguments come from conscious intent: the executive supplies them via an
14
+ // action's `args`, which ride the ideomotor leg into the invocation (see
15
+ // executive commands.ts). A tool with required arguments enacted habitually
16
+ // (without args) fails informatively — reafference then teaches the Will that
17
+ // this ability wants deliberate articulation.
18
+ //
19
+ // const { names, close } = await connectMcpEffectors( will, {
20
+ // command: 'npx', args: [ '-y', '@modelcontextprotocol/server-filesystem', '/tmp' ],
21
+ // } )
22
+ //
23
+ // Import from '@mindot/will/mcp' — kept off the main entry so non-MCP
24
+ // consumers never load the MCP SDK.
25
+ // ─────────────────────────────────────────────────────────────
26
+
27
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
28
+ import { StdioClientTransport, getDefaultEnvironment } from '@modelcontextprotocol/sdk/client/stdio.js'
29
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
30
+ import type { Will, EffectorHandler, EffectorResult } from '#sdk/will'
31
+
32
+ /** Where the tools live: spawn a local server, reach a remote one, or bring a connected client. */
33
+ export type McpToolsSource =
34
+ | { command: string; args?: string[]; env?: Record<string, string> }
35
+ | { url: string }
36
+ | { client: Client }
37
+
38
+ export interface McpEffectorsOptions {
39
+ /** Intrinsic effort prior 0..1 seeded on every bridged ability (default 0.2). */
40
+ cost?: number
41
+ /** Prefix for the ability names (e.g. 'fs_') — avoids collisions across servers. */
42
+ prefix?: string
43
+ }
44
+
45
+ /** Minimal structural view of an MCP tool (the SDK's zod-inferred type, loosened). */
46
+ export interface McpToolInfo {
47
+ name: string
48
+ description?: string
49
+ inputSchema?: {
50
+ type?: string
51
+ properties?: Record<string, { type?: string; description?: string }>
52
+ required?: string[]
53
+ }
54
+ }
55
+
56
+ /** Keep tool outcomes bounded — the description feeds reafference + episodic memory. */
57
+ const RESULT_DESCRIPTION_CAP = 700
58
+ /** Keep ability meanings bounded — they render into the executive prompt. */
59
+ const MEANING_CAP = 300
60
+
61
+ /**
62
+ * The ability's *meaning*: the tool's description plus a compact hint of the
63
+ * arguments it takes — so the executive knows what to supply in an action's
64
+ * `args` when it enacts this ability.
65
+ */
66
+ export function describeMcpTool( tool: McpToolInfo ): string {
67
+ const props = tool.inputSchema?.properties ?? {}
68
+ const required = new Set( tool.inputSchema?.required ?? [] )
69
+ const argHints = Object.entries( props ).map( ( [ key, p ] ) =>
70
+ `${ key }${ required.has( key ) ? '' : '?' }${ p.description ? `: ${ p.description }` : '' }` )
71
+
72
+ const base = ( tool.description ?? `The ${ tool.name } tool.` ).trim().replace( /\s+/g, ' ' )
73
+ const hint = argHints.length > 0 ? ` (args — ${ argHints.join( '; ' ) })` : ''
74
+ const full = `${ base }${ hint }`
75
+ return full.length > MEANING_CAP ? `${ full.slice( 0, MEANING_CAP - 1 ) }…` : full
76
+ }
77
+
78
+ /**
79
+ * The effector handler for one bridged tool: checks required args (an ability
80
+ * enacted without its needed articulation fails informatively — reafference
81
+ * learns from it), calls the tool, and maps the result onto EffectorResult.
82
+ */
83
+ export function buildMcpHandler( client: Client, tool: McpToolInfo ): EffectorHandler {
84
+ return async ( args ): Promise<EffectorResult> => {
85
+ const props = tool.inputSchema?.properties
86
+ // Only pass keys the tool declares — invocation params can carry situation
87
+ // extras (targetEntityName, learned priors) the tool never asked for.
88
+ const filtered: Record<string, unknown> = {}
89
+ for( const [ k, v ] of Object.entries( args ?? {} ) )
90
+ if( !props || k in props ) filtered[ k ] = v
91
+
92
+ const missing = ( tool.inputSchema?.required ?? [] ).filter(
93
+ k => filtered[ k ] === undefined || filtered[ k ] === '' )
94
+ if( missing.length > 0 )
95
+ return {
96
+ success: false,
97
+ description: `${ tool.name } needs ${ missing.join( ', ' ) } — enact it deliberately, supplying them in the action's args.`,
98
+ }
99
+
100
+ try {
101
+ const res = await client.callTool( { name: tool.name, arguments: filtered } ) as
102
+ { content?: Array<{ type: string; text?: string }>; isError?: boolean }
103
+ const text = ( res.content ?? [] )
104
+ .filter( c => c.type === 'text' && typeof c.text === 'string' )
105
+ .map( c => c.text as string )
106
+ .join( '\n' )
107
+ .trim() || ( res.isError ? 'The tool reported an error.' : 'Done (no output).' )
108
+ const bounded = text.length > RESULT_DESCRIPTION_CAP ? `${ text.slice( 0, RESULT_DESCRIPTION_CAP - 1 ) }…` : text
109
+ return { success: !res.isError, description: bounded }
110
+ }
111
+ catch( err ){
112
+ return { success: false, description: `${ tool.name } failed: ${ err instanceof Error ? err.message : String( err ) }` }
113
+ }
114
+ }
115
+ }
116
+
117
+ async function connect( source: McpToolsSource ): Promise<{ client: Client; owned: boolean }> {
118
+ if( 'client' in source ) return { client: source.client, owned: false }
119
+
120
+ const client = new Client( { name: 'mindot-will', version: '0' } )
121
+ if( 'url' in source )
122
+ await client.connect( new StreamableHTTPClientTransport( new URL( source.url ) ) )
123
+ else
124
+ await client.connect( new StdioClientTransport( {
125
+ command: source.command,
126
+ ...( source.args ? { args: source.args } : {} ),
127
+ // Merge over the SDK's safe default env so PATH etc. survive a custom env.
128
+ env: { ...getDefaultEnvironment(), ...( source.env ?? {} ) },
129
+ } ) )
130
+ return { client, owned: true }
131
+ }
132
+
133
+ /**
134
+ * Register an MCP server's tools as the Will's abilities. Returns the ability
135
+ * names registered and a `close()` for the connection (call it when the Will
136
+ * stops; a client passed in via `source.client` is left open).
137
+ */
138
+ export async function connectMcpEffectors(
139
+ will: Will,
140
+ source: McpToolsSource,
141
+ opts: McpEffectorsOptions = {},
142
+ ): Promise<{ names: string[]; close: () => Promise<void> }> {
143
+ const { client, owned } = await connect( source )
144
+ const { tools } = await client.listTools() as unknown as { tools: McpToolInfo[] }
145
+
146
+ const names: string[] = []
147
+ for( const tool of tools ){
148
+ const name = `${ opts.prefix ?? '' }${ tool.name }`
149
+ will.effector( name, {
150
+ description: describeMcpTool( tool ),
151
+ cost: opts.cost ?? 0.2,
152
+ tags: [ 'mcp' ],
153
+ handler: buildMcpHandler( client, tool ),
154
+ } )
155
+ names.push( name )
156
+ }
157
+
158
+ return { names, close: async () => { if( owned ) await client.close() } }
159
+ }
@@ -0,0 +1,167 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // src/mcp/server.ts — a Will, exposed over the Model Context Protocol
3
+ // ─────────────────────────────────────────────────────────────
4
+ //
5
+ // Lets any MCP client (Claude Desktop, Claude Code, an IDE) host a persistent
6
+ // mind. The surface follows the same paradigm as the SDK facade: a Will is a
7
+ // SUBJECT that emits projections, not a chatbot function —
8
+ //
9
+ // • `perceive` — deliver a stimulus into its sensory field
10
+ // • `next_utterance` — await its next words (silence is a real outcome)
11
+ // • `state` — read a snapshot of its inner life
12
+ // • `save` — checkpoint the living mind to a PMA file
13
+ //
14
+ // plus read-only resources (`will://state`, `will://narrative`) for clients
15
+ // that surface them. There is deliberately no `ask()`-shaped tool: you speak
16
+ // TO the Will and observe what it projects; it may choose not to answer.
17
+ //
18
+ // One Will per server process. Boot/persistence/stdio wiring lives in cli.ts;
19
+ // this module only maps an existing facade instance onto MCP (testable with
20
+ // InMemoryTransport).
21
+ // ─────────────────────────────────────────────────────────────
22
+
23
+ import { readFileSync, mkdirSync, writeFileSync } from 'node:fs'
24
+ import { dirname } from 'node:path'
25
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
26
+ import { z } from 'zod'
27
+ import type { Will, WillMessage } from '#sdk/will'
28
+
29
+ /** Utterances projected but not yet consumed by a next_utterance call. */
30
+ const UTTERANCE_BUFFER_CAP = 50
31
+
32
+ export interface WillMcpOptions {
33
+ /** Where `save` (and the CLI's shutdown hibernate) writes the PMA artifact. */
34
+ pmaPath?: string
35
+ }
36
+
37
+ function serverVersion(): string {
38
+ // Same relative depth from src/mcp/ and dist/mcp/ — resolves in both.
39
+ try {
40
+ return JSON.parse( readFileSync( new URL( '../../package.json', import.meta.url ), 'utf8' ) ).version ?? '0.0.0'
41
+ }
42
+ catch { return '0.0.0' }
43
+ }
44
+
45
+ /**
46
+ * Map a running Will onto an MCP server. The caller owns the Will's lifecycle
47
+ * (boot, shutdown) and the transport; this only registers the surface.
48
+ */
49
+ export function buildWillMcpServer( will: Will, opts: WillMcpOptions = {} ): McpServer {
50
+ const server = new McpServer( { name: 'mindot-will', version: serverVersion() } )
51
+
52
+ // ── Projection buffer ─────────────────────────────────────
53
+ // MCP calls are separate round trips: the Will may speak BETWEEN a perceive
54
+ // call and the next_utterance call that follows. Buffer projections so a
55
+ // fast reply is not lost in the gap; next_utterance drains the buffer first.
56
+ const pending: WillMessage[] = []
57
+ will.on( 'message', m => {
58
+ pending.push( m )
59
+ if( pending.length > UTTERANCE_BUFFER_CAP ) pending.shift()
60
+ } )
61
+
62
+ const takeBuffered = ( to?: string ): WillMessage | undefined => {
63
+ const i = to === undefined ? 0 : pending.findIndex( m => m.to === to )
64
+ if( i < 0 || pending.length === 0 ) return undefined
65
+ return pending.splice( i, 1 )[0]
66
+ }
67
+
68
+ // ── Tools ─────────────────────────────────────────────────
69
+
70
+ server.registerTool( 'perceive', {
71
+ title: 'Perceive',
72
+ description:
73
+ `Deliver a stimulus (something said or observed) into ${ will.name }'s sensory field. ` +
74
+ `${ will.name } is a living mind, not a chatbot: this returns once the stimulus is delivered, ` +
75
+ `NOT when it has responded. Its response — if it chooses to give one — arrives as its next ` +
76
+ `utterance (use the next_utterance tool). Staying silent is a valid choice, not an error.`,
77
+ inputSchema: {
78
+ text: z.string().describe( 'What is said or observed.' ),
79
+ from: z.string().optional().describe( "Who it's from (entity id, default 'user'). Use a stable id per person." ),
80
+ speaker: z.string().optional().describe( 'Display name of the speaker.' ),
81
+ },
82
+ }, async ( { text, from, speaker } ) => {
83
+ await will.perceive( { text, ...( from ? { from } : {} ), ...( speaker ? { speaker } : {} ) } )
84
+ return {
85
+ content: [ {
86
+ type: 'text',
87
+ text: `Delivered into ${ will.name }'s sensory field at tick ${ will.state().tick }. ` +
88
+ `It may respond in its next utterance — or stay silent.`,
89
+ } ],
90
+ }
91
+ } )
92
+
93
+ server.registerTool( 'next_utterance', {
94
+ title: 'Next utterance',
95
+ description:
96
+ `Await ${ will.name }'s next spontaneous utterance. Returns what it says next, or reports ` +
97
+ `that it chose silence within the wait window — silence is a real outcome, not a failure. ` +
98
+ `Call after perceive to hear the response, or on its own to listen for unprompted speech.`,
99
+ inputSchema: {
100
+ within_ms: z.number().optional().describe( 'How long to wait before accepting silence (default 15000, max 120000).' ),
101
+ from: z.string().optional().describe( 'Only accept an utterance addressed to this entity id.' ),
102
+ },
103
+ }, async ( { within_ms, from } ) => {
104
+ // A projection may have landed between calls — drain the buffer first.
105
+ const buffered = takeBuffered( from )
106
+ if( buffered )
107
+ return { content: [ { type: 'text', text: `${ will.name } says (to ${ buffered.to }): ${ buffered.content }` } ] }
108
+
109
+ const within = Math.min( Math.max( within_ms ?? 15_000, 100 ), 120_000 )
110
+ const msg = await will.nextUtterance( { within, ...( from ? { to: from } : {} ) } )
111
+ // The waiter and the buffer listener both see a new message; consume the
112
+ // buffered copy so the same utterance is not replayed on the next call.
113
+ if( msg ){
114
+ const i = pending.findIndex( p => p.id === msg.id )
115
+ if( i >= 0 ) pending.splice( i, 1 )
116
+ return { content: [ { type: 'text', text: `${ will.name } says (to ${ msg.to }): ${ msg.content }` } ] }
117
+ }
118
+ return {
119
+ content: [ {
120
+ type: 'text',
121
+ text: `${ will.name } stayed silent (waited ${ within }ms). That is a choice, not an error — ` +
122
+ `it may be occupied with its own thoughts, or simply have nothing to say.`,
123
+ } ],
124
+ }
125
+ } )
126
+
127
+ server.registerTool( 'state', {
128
+ title: 'Inner state',
129
+ description:
130
+ `Read a snapshot of ${ will.name }'s current inner life: tick, body/affect metrics ` +
131
+ `(energy, stress, valence, arousal), active goals, beliefs, and self-narrative. ` +
132
+ `Read-only; observing does not disturb it.`,
133
+ inputSchema: {},
134
+ }, async () => (
135
+ { content: [ { type: 'text', text: JSON.stringify( will.state(), null, 2 ) } ] }
136
+ ) )
137
+
138
+ server.registerTool( 'save', {
139
+ title: 'Save (checkpoint)',
140
+ description:
141
+ `Checkpoint ${ will.name } into a portable PMA artifact on disk — non-destructive, it keeps ` +
142
+ `living. The artifact restores the same self (identity, memories, relationships, learned ` +
143
+ `skills) when the server next starts.`,
144
+ inputSchema: {},
145
+ }, async () => {
146
+ if( !opts.pmaPath )
147
+ return { content: [ { type: 'text', text: 'No PMA path configured — set WILL_PMA_PATH.' } ], isError: true }
148
+ const pma = await will.save()
149
+ mkdirSync( dirname( opts.pmaPath ), { recursive: true } )
150
+ writeFileSync( opts.pmaPath, JSON.stringify( pma ) )
151
+ return { content: [ { type: 'text', text: `${ will.name } checkpointed to ${ opts.pmaPath } (still living).` } ] }
152
+ } )
153
+
154
+ // ── Resources (read-only projections) ─────────────────────
155
+
156
+ server.registerResource( 'state', 'will://state',
157
+ { title: `${ will.name } — inner state`, description: 'Live snapshot of the mind: metrics, goals, beliefs, narrative.', mimeType: 'application/json' },
158
+ async uri => ( { contents: [ { uri: uri.href, mimeType: 'application/json', text: JSON.stringify( will.state(), null, 2 ) } ] } ),
159
+ )
160
+
161
+ server.registerResource( 'narrative', 'will://narrative',
162
+ { title: `${ will.name } — self-narrative`, description: 'The story the mind currently tells about itself.', mimeType: 'text/plain' },
163
+ async uri => ( { contents: [ { uri: uri.href, mimeType: 'text/plain', text: will.state().narrative || '(no narrative formed yet)' } ] } ),
164
+ )
165
+
166
+ return server
167
+ }