@mindot/will 0.1.1 → 0.3.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 (39) hide show
  1. package/README.md +71 -4
  2. package/dist/cli.d.ts +1 -0
  3. package/dist/cli.js +26221 -0
  4. package/dist/cli.js.map +1 -0
  5. package/dist/index.d.ts +52 -7760
  6. package/dist/index.js +374 -86
  7. package/dist/index.js.map +1 -1
  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/cli.ts +75 -0
  14. package/src/cognition/agency/engines/action.selector.ts +3 -0
  15. package/src/cognition/agency/engines/affordance.synthesizer.ts +26 -12
  16. package/src/cognition/agency/engines/deliberation.engine.ts +7 -2
  17. package/src/cognition/agency/engines/motor.schema.executor.ts +2 -0
  18. package/src/cognition/agency/schemas/external.ts +27 -10
  19. package/src/cognition/agency/schemas/repertoire.ts +12 -0
  20. package/src/cognition/agency/types.ts +51 -2
  21. package/src/cognition/faculties/executive.engine/commands.ts +47 -11
  22. package/src/cognition/faculties/executive.engine/context.ts +28 -0
  23. package/src/cognition/faculties/executive.engine/facet.supervisor.ts +15 -1
  24. package/src/cognition/faculties/executive.engine/facet.ts +32 -6
  25. package/src/cognition/faculties/executive.engine/prompt.factory.ts +11 -1
  26. package/src/cognition/faculties/executive.engine/types.ts +24 -2
  27. package/src/cognition/index.ts +1 -1
  28. package/src/core/abstracts.ts +39 -12
  29. package/src/host/boot.ts +127 -0
  30. package/src/host/utterances.ts +53 -0
  31. package/src/index.ts +6 -0
  32. package/src/mcp/effectors.ts +159 -0
  33. package/src/mcp/server.ts +144 -0
  34. package/src/sdk/will.ts +269 -44
  35. package/src/serve/server.ts +154 -0
  36. package/src/stem/index.ts +16 -0
  37. package/src/stem/mind.ts +11 -4
  38. package/src/stem/tracts/effector.controller.ts +1 -0
  39. package/src/types.ts +2 -0
@@ -149,6 +149,16 @@ export class ExecutiveFacet {
149
149
  /** Stamp activity at `tick` (called at spawn and on each report). */
150
150
  markActive( tick: number ): void { this._lastActiveTick = tick }
151
151
 
152
+ /** _reason() calls currently in flight — a real LLM call spans many ticks. */
153
+ private _inflight = 0
154
+ /**
155
+ * True while the facet has work the reaper must not discard: queued reports
156
+ * awaiting the pump, or an in-flight _reason() whose decision hasn't landed.
157
+ * The idle TTL only measures *quiet* facets — reaping a busy one destroys the
158
+ * listeners its pending decision needs, silently dropping a conversation reply.
159
+ */
160
+ get busy(): boolean { return this._inflight > 0 || this._pendingReports.length > 0 }
161
+
152
162
  /**
153
163
  * Per-facet chunk handler — fires for every LLM token during _reason().
154
164
  * Set by the creating engine (e.g. AuditionEngine) for entity-scoped streaming.
@@ -236,9 +246,27 @@ export class ExecutiveFacet {
236
246
  }
237
247
 
238
248
  // Legacy (bare facets in unit tests): trigger reasoning immediately.
239
- this._reason( report ).catch( err =>
240
- logger.error( `[executive.facet] ${this.facetId} reasoning error:`, err )
241
- )
249
+ this._launchReason( report )
250
+ }
251
+
252
+ /**
253
+ * Launch _reason() with in-flight accounting. `busy` must hold for the whole
254
+ * span (a real LLM call is 10–30s ≈ many ticks) so the supervisor's idle
255
+ * reaper never destroys a facet whose decision is still coming — that would
256
+ * clear its listeners and silently drop the reply. Completion re-stamps
257
+ * activity so the idle TTL measures quiet time *after* the decision, not
258
+ * after the report that started it.
259
+ */
260
+ private _launchReason( report: FacetReport ): void {
261
+ this._inflight++
262
+ this._reason( report )
263
+ .catch( err =>
264
+ logger.error( `[executive.facet] ${this.facetId} reasoning error:`, err )
265
+ )
266
+ .finally( () => {
267
+ this._inflight--
268
+ this.markActive( ( this._currentStateRef?.tick as number ) ?? this._lastActiveTick )
269
+ })
242
270
  }
243
271
 
244
272
  /**
@@ -257,9 +285,7 @@ export class ExecutiveFacet {
257
285
  this._pendingReports = []
258
286
 
259
287
  for( const report of batch )
260
- this._reason( report ).catch( err =>
261
- logger.error( `[executive.facet] ${this.facetId} reasoning error:`, err )
262
- )
288
+ this._launchReason( report )
263
289
  }
264
290
 
265
291
  subscribe( listener: FacetEventListener ): () => void {
@@ -344,7 +344,7 @@ ${roleDescription}
344
344
  ${consciousnessArchitecture}
345
345
 
346
346
  ## Output Guidelines
347
- - **actions**: Choose from effectors you know about. If uncertain, describe what you want to achieve in natural language and your body will try to match it.
347
+ - **actions**: Choose from effectors you know about. If uncertain, describe what you want to achieve in natural language and your body will try to match it. When enacting one of your available abilities that needs specifics (a query, a message, a value), supply them in the action's "args" object — e.g. {"type": "search_docs", "args": {"query": "tick loop design"}, ...}. Your body enacts the ability with exactly those args.
348
348
  - **plans**: Include for goals without existing plans or where plans need revision. You may keep multiple plans per goal — set **planId** to act on a specific existing plan (validate/execute/revise/cancel); omit it to draft a new one. Your current plans are listed under "## Active Plans".
349
349
  - **newBeliefs**: Extract patterns from experiences visible in your current state. Only record a belief if you can point to a specific observation that supports it — do not infer experiences you have no record of. Set 'evidence' honestly: 'single_observation' (first time noticing), 'recurring_pattern' (seen multiple times), 'strong_pattern' (deeply established).
350
350
  - **introspection**: Include when significant events occurred or you notice patterns. When you spot a cognitive bias in your own reasoning, name it in 'identifiedBiases' using its common term where one fits (e.g. overgeneralization, confirmation bias, recency bias) — this lets your self-assessment line up with the patterns your faculties detect on their own.
@@ -643,6 +643,15 @@ Dominance: ${context.affect.dominance.toFixed( 2 )}${context.affect.blends.lengt
643
643
  ? `## Percepts (What You Notice)\n${context.percepts.slice( 0, 10 ).map( p => `- [${p.category}] ${p.summary} (salience: ${p.salience.toFixed( 2 )})` ).join( '\n' ) || 'Nothing notable'}`
644
644
  : ''
645
645
 
646
+ // Host abilities afforded right now + what each is for. Framed as
647
+ // self-knowledge (things you *can* do), NOT a tool-call menu: the Will still
648
+ // expresses intent in natural language and the agency field enacts the fit.
649
+ const abilitiesBlock = ( context.abilities && context.abilities.length > 0 )
650
+ ? `## Abilities Available Now\nThings you can do in this situation — name one as an action's "type" (with "args" for any specifics it needs) and your body enacts it:\n${context.abilities.map( a =>
651
+ `- **${a.name}**${a.target ? ` (toward ${a.target})` : ''}${a.description ? ` — ${a.description}` : ''}`
652
+ ).join( '\n' )}`
653
+ : ''
654
+
646
655
  const ruminationsBlock = has( 'ruminations' )
647
656
  ? `## Active Ruminations (retrieved memories & thoughts)\n${context.workingMemory.map( w => `- [${w.type}] ${w.summary} (activation: ${w.activation.toFixed( 2 )})` ).join( '\n' ) || 'Nothing actively held in mind'}`
648
657
  : ''
@@ -694,6 +703,7 @@ Dominance: ${context.affect.dominance.toFixed( 2 )}${context.affect.blends.lengt
694
703
  actionDiversity.trim(),
695
704
  recentOutcomesBlock,
696
705
  perceptsBlock,
706
+ abilitiesBlock,
697
707
  ruminationsBlock,
698
708
  recentIntrospection.trim(),
699
709
  memoriesBlock,
@@ -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 } )
@@ -0,0 +1,127 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // src/host/boot.ts — shared boot/shutdown for the `will` CLI hosts
3
+ // ─────────────────────────────────────────────────────────────
4
+ //
5
+ // Both hosts (`will mcp`, `will serve`) raise the same mind the same way:
6
+ // env-configured, woken from its PMA artifact when one exists (else born),
7
+ // optionally bridged onto external MCP servers whose tools become its own
8
+ // abilities, and hibernated back to the artifact exactly once on the way out.
9
+ // Only the protocol surface differs — that stays in each host.
10
+ //
11
+ // Env (shared):
12
+ // WILL_NAME display name (default "Will")
13
+ // WILL_IDENTITY persona prompt (default a minimal self)
14
+ // WILL_TIER basic | standard | full (default standard)
15
+ // WILL_LLM mock | anthropic (default: auto — anthropic when
16
+ // ANTHROPIC_API_KEY is set, else mock)
17
+ // WILL_TICK_MS ms per tick (default 1000)
18
+ // WILL_SEED deterministic seed (testing) (default unseeded/wall-time)
19
+ // WILL_PMA_PATH PMA artifact path (default ./.will/<name>.pma.json)
20
+ // WILL_MCP_SERVERS JSON array of MCP servers whose tools become the Will's
21
+ // OWN abilities: entries {command,args?,env?} or {url}.
22
+ // ─────────────────────────────────────────────────────────────
23
+
24
+ import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'
25
+ import { dirname, resolve } from 'node:path'
26
+ import { setLogger } from '#core/logger'
27
+ import { Will, type CreateWillOptions } from '#sdk/will'
28
+ import type { PMASnapshot } from '#pma/index'
29
+ import { connectMcpEffectors, type McpToolsSource } from '#root/mcp/effectors'
30
+
31
+ /**
32
+ * Route every engine log line to stderr. For `will mcp`, stdout is the MCP
33
+ * protocol channel and must stay pure; `will serve` keeps the same discipline
34
+ * so both hosts log identically (and Docker captures one stream).
35
+ */
36
+ export function routeLogsToStderr(): void {
37
+ const err = ( level: string ) => ( msg: string, ...rest: unknown[] ) =>
38
+ console.error( `[will:${ level }] ${ msg }`, ...rest )
39
+ setLogger( { debug: () => {}, info: err( 'info' ), warn: err( 'warn' ), error: err( 'error' ) } )
40
+ }
41
+
42
+ function slug( s: string ): string {
43
+ return s.toLowerCase().replace( /[^a-z0-9]+/g, '-' ).replace( /^-+|-+$/g, '' ) || 'will'
44
+ }
45
+
46
+ export interface BootedWill {
47
+ will: Will
48
+ name: string
49
+ pmaPath: string
50
+ tickMs: number
51
+ engineTier: NonNullable<CreateWillOptions['engineTier']>
52
+ /** Run before hibernate on shutdown (close servers/transports). LIFO. */
53
+ onCleanup: ( fn: () => Promise<void> | void ) => void
54
+ /** Hibernate → persist → exit(0). Idempotent; SIGINT/SIGTERM already wired. */
55
+ shutdown: ( why: string ) => Promise<void>
56
+ }
57
+
58
+ /** Raise the mind from env config — wake from the artifact if one exists. */
59
+ export async function bootWillFromEnv(): Promise<BootedWill> {
60
+ const name = process.env.WILL_NAME ?? 'Will'
61
+ const pmaPath = resolve( process.env.WILL_PMA_PATH ?? `.will/${ slug( name ) }.pma.json` )
62
+ const tickMs = parseInt( process.env.WILL_TICK_MS ?? '1000' )
63
+ const engineTier = ( process.env.WILL_TIER as CreateWillOptions['engineTier'] ) ?? 'standard'
64
+
65
+ const opts: Omit<CreateWillOptions, 'identity'> = {
66
+ name, engineTier, tickMs,
67
+ ...( process.env.WILL_LLM ? { llm: process.env.WILL_LLM as 'mock' | 'anthropic' } : {} ),
68
+ ...( process.env.WILL_SEED ? { seed: parseInt( process.env.WILL_SEED ) } : {} ),
69
+ }
70
+
71
+ let will: Will
72
+ if( existsSync( pmaPath ) ){
73
+ const pma = JSON.parse( readFileSync( pmaPath, 'utf8' ) ) as PMASnapshot
74
+ will = await Will.wake( pma, opts )
75
+ console.error( `[will] ${ name } woke from ${ pmaPath }` )
76
+ }
77
+ else {
78
+ will = await Will.create( {
79
+ ...opts,
80
+ identity: { prompt: process.env.WILL_IDENTITY ?? `I am ${ name }, a persistent mind.` },
81
+ } )
82
+ console.error( `[will] ${ name } born (no artifact at ${ pmaPath } yet)` )
83
+ }
84
+ will.on( 'error', e => console.error( `[will] error: ${ e.message }` ) )
85
+
86
+ // Onward bridges: MCP servers whose tools become the Will's OWN abilities.
87
+ // Best-effort — a bad entry warns and is skipped; the mind still boots.
88
+ const cleanups: Array<() => Promise<void> | void> = []
89
+ if( process.env.WILL_MCP_SERVERS ){
90
+ try {
91
+ const sources = JSON.parse( process.env.WILL_MCP_SERVERS ) as McpToolsSource[]
92
+ for( const source of Array.isArray( sources ) ? sources : [] ){
93
+ try {
94
+ const { names, close } = await connectMcpEffectors( will, source )
95
+ cleanups.push( close )
96
+ console.error( `[will] ${ name } gained abilities: ${ names.join( ', ' ) }` )
97
+ }
98
+ catch( e ){ console.error( `[will] MCP bridge failed (skipped): ${ ( e as Error ).message }` ) }
99
+ }
100
+ }
101
+ catch( e ){ console.error( `[will] WILL_MCP_SERVERS is not valid JSON — ignoring: ${ ( e as Error ).message }` ) }
102
+ }
103
+
104
+ // Hibernate exactly once on the way out — cleanups (LIFO), distill + stop, persist.
105
+ let leaving = false
106
+ const shutdown = async ( why: string ): Promise<void> => {
107
+ if( leaving ) return
108
+ leaving = true
109
+ for( const fn of cleanups.reverse() ) await Promise.resolve( fn() ).catch( () => {} )
110
+ try {
111
+ const pma = await will.hibernate()
112
+ mkdirSync( dirname( pmaPath ), { recursive: true } )
113
+ writeFileSync( pmaPath, JSON.stringify( pma ) )
114
+ console.error( `[will] ${ name } hibernated to ${ pmaPath } (${ why })` )
115
+ }
116
+ catch( e ){ console.error( `[will] hibernate failed: ${ ( e as Error ).message }` ) }
117
+ process.exit( 0 )
118
+ }
119
+ process.on( 'SIGINT', () => void shutdown( 'SIGINT' ) )
120
+ process.on( 'SIGTERM', () => void shutdown( 'SIGTERM' ) )
121
+
122
+ return {
123
+ will, name, pmaPath, tickMs, engineTier: engineTier ?? 'standard',
124
+ onCleanup: fn => cleanups.push( fn ),
125
+ shutdown,
126
+ }
127
+ }
@@ -0,0 +1,53 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // src/host/utterances.ts — a host-side tap on a Will's speech
3
+ // ─────────────────────────────────────────────────────────────
4
+ //
5
+ // Hosts that expose a Will over a request/response protocol (MCP tools, HTTP
6
+ // long-polls) share a timing problem: the Will may speak BETWEEN two calls —
7
+ // after a perceive round trip returns and before the caller asks for the next
8
+ // utterance. The tap buffers projections so nothing is lost in the gap, and
9
+ // `next()` gives the MCP/HTTP hosts one shared, honest await: drain the buffer
10
+ // first, else wait, else report silence (null — a choice, never an error).
11
+ // ─────────────────────────────────────────────────────────────
12
+
13
+ import type { Will, WillMessage } from '#sdk/will'
14
+
15
+ const BUFFER_CAP = 50
16
+
17
+ export class UtteranceTap {
18
+ private readonly _will: Will
19
+ private readonly _pending: WillMessage[] = []
20
+
21
+ constructor( will: Will ){
22
+ this._will = will
23
+ will.on( 'message', m => {
24
+ this._pending.push( m )
25
+ if( this._pending.length > BUFFER_CAP ) this._pending.shift()
26
+ } )
27
+ }
28
+
29
+ /** Consume the oldest buffered utterance (optionally only one addressed to `to`). */
30
+ takeBuffered( to?: string ): WillMessage | undefined {
31
+ if( this._pending.length === 0 ) return undefined
32
+ const i = to === undefined ? 0 : this._pending.findIndex( m => m.to === to )
33
+ if( i < 0 ) return undefined
34
+ return this._pending.splice( i, 1 )[0]
35
+ }
36
+
37
+ /**
38
+ * The next utterance: a buffered one if a projection already landed, else
39
+ * await up to `within` ms. `null` = the Will chose silence. An awaited
40
+ * message is also consumed from the buffer so it never replays.
41
+ */
42
+ async next( within: number, to?: string ): Promise<WillMessage | null> {
43
+ const buffered = this.takeBuffered( to )
44
+ if( buffered ) return buffered
45
+
46
+ const msg = await this._will.nextUtterance( { within, ...( to ? { to } : {} ) } )
47
+ if( msg ){
48
+ const i = this._pending.findIndex( p => p.id === msg.id )
49
+ if( i >= 0 ) this._pending.splice( i, 1 )
50
+ }
51
+ return msg
52
+ }
53
+ }
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'
@@ -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
+ }