@mindot/will 0.2.0 → 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.
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@mindot/will",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "author": "Fabrice <fabrice8@github.com>",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/mindot-ai/will.git"
8
8
  },
9
9
  "bin": {
10
- "will": "dist/mcp/cli.js"
10
+ "will": "dist/cli.js"
11
11
  },
12
12
  "main": "./dist/index.js",
13
13
  "devDependencies": {
package/src/cli.ts ADDED
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env node
2
+ // ─────────────────────────────────────────────────────────────
3
+ // src/cli.ts — the `will` command: host a persistent mind
4
+ // ─────────────────────────────────────────────────────────────
5
+ //
6
+ // will mcp host over MCP stdio (Claude Desktop / Claude Code / IDEs)
7
+ // will serve host over HTTP (any language; the sidecar) — WILL_PORT/WILL_HOST
8
+ //
9
+ // Both hosts raise the same mind the same way (see host/boot.ts): env-configured,
10
+ // woken from its PMA artifact when one exists, hibernated back on the way out —
11
+ // the mind PERSISTS across sessions. Shared env: WILL_NAME, WILL_IDENTITY,
12
+ // WILL_TIER, WILL_LLM, WILL_TICK_MS, WILL_SEED, WILL_PMA_PATH, WILL_MCP_SERVERS.
13
+ //
14
+ // MCP client config:
15
+ // { "command": "npx", "args": ["-y", "@mindot/will", "mcp"],
16
+ // "env": { "WILL_NAME": "Aria", "WILL_IDENTITY": "I am Aria." } }
17
+ //
18
+ // Sidecar:
19
+ // WILL_NAME=Aria will serve # http://127.0.0.1:7777
20
+ // curl -X POST localhost:7777/perceive -d '{"text":"Hello"}'
21
+ // ─────────────────────────────────────────────────────────────
22
+
23
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
24
+ import { routeLogsToStderr, bootWillFromEnv } from '#root/host/boot'
25
+ import { buildWillMcpServer } from '#root/mcp/server'
26
+ import { buildWillHttpServer } from '#root/serve/server'
27
+
28
+ // stdout is the MCP protocol channel under `will mcp` — route logs FIRST.
29
+ routeLogsToStderr()
30
+
31
+ const USAGE = `usage: will <mcp | serve>
32
+
33
+ mcp host a persistent mind over MCP stdio (Claude Desktop / Claude Code)
34
+ serve host a persistent mind over HTTP (any language; WILL_PORT, default 7777)
35
+
36
+ Shared env: WILL_NAME, WILL_IDENTITY, WILL_TIER, WILL_LLM, WILL_TICK_MS,
37
+ WILL_SEED, WILL_PMA_PATH, WILL_MCP_SERVERS. The mind persists across runs via
38
+ its PMA artifact.`
39
+
40
+ async function main(): Promise<void> {
41
+ const sub = process.argv[2]
42
+
43
+ if( sub !== 'mcp' && sub !== 'serve' ){
44
+ console.error( sub ? `unknown subcommand: ${ sub }\n\n${ USAGE }` : USAGE )
45
+ process.exit( sub ? 2 : 0 )
46
+ }
47
+
48
+ const { will, name, pmaPath, tickMs, engineTier, onCleanup, shutdown } = await bootWillFromEnv()
49
+
50
+ if( sub === 'mcp' ){
51
+ // The MCP client owns our stdin — its disconnect is the shutdown signal.
52
+ process.stdin.on( 'end', () => void shutdown( 'client disconnected' ) )
53
+ const server = buildWillMcpServer( will, { pmaPath } )
54
+ await server.connect( new StdioServerTransport() )
55
+ console.error( `[will] ${ name } is listening on MCP stdio (tick ${ tickMs }ms, tier ${ engineTier })` )
56
+ return
57
+ }
58
+
59
+ // serve — the HTTP sidecar.
60
+ const port = parseInt( process.env.WILL_PORT ?? '7777' )
61
+ const host = process.env.WILL_HOST ?? '127.0.0.1'
62
+ const server = buildWillHttpServer( will, { pmaPath } )
63
+ onCleanup( () => new Promise<void>( r => server.close( () => r() ) ) )
64
+ await new Promise<void>( ( resolve, reject ) => {
65
+ server.once( 'error', reject )
66
+ server.listen( port, host, () => resolve() )
67
+ } )
68
+ console.error( `[will] ${ name } is listening on http://${ host }:${ port } (tick ${ tickMs }ms, tier ${ engineTier })` )
69
+ console.error( `[will] try: curl -X POST http://${ host }:${ port }/perceive -H 'content-type: application/json' -d '{"text":"Hello"}'` )
70
+ }
71
+
72
+ main().catch( e => {
73
+ console.error( `[will] fatal: ${ e instanceof Error ? e.stack ?? e.message : String( e ) }` )
74
+ process.exit( 1 )
75
+ } )
@@ -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/mcp/server.ts CHANGED
@@ -24,10 +24,8 @@ import { readFileSync, mkdirSync, writeFileSync } from 'node:fs'
24
24
  import { dirname } from 'node:path'
25
25
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
26
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
27
+ import type { Will } from '#sdk/will'
28
+ import { UtteranceTap } from '#root/host/utterances'
31
29
 
32
30
  export interface WillMcpOptions {
33
31
  /** Where `save` (and the CLI's shutdown hibernate) writes the PMA artifact. */
@@ -49,21 +47,10 @@ function serverVersion(): string {
49
47
  export function buildWillMcpServer( will: Will, opts: WillMcpOptions = {} ): McpServer {
50
48
  const server = new McpServer( { name: 'mindot-will', version: serverVersion() } )
51
49
 
52
- // ── Projection buffer ─────────────────────────────────────
53
50
  // 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
- }
51
+ // call and the next_utterance call that follows. The tap buffers projections
52
+ // so a fast reply is not lost in the gap (see host/utterances.ts).
53
+ const tap = new UtteranceTap( will )
67
54
 
68
55
  // ── Tools ─────────────────────────────────────────────────
69
56
 
@@ -101,20 +88,10 @@ export function buildWillMcpServer( will: Will, opts: WillMcpOptions = {} ): Mcp
101
88
  from: z.string().optional().describe( 'Only accept an utterance addressed to this entity id.' ),
102
89
  },
103
90
  }, 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
91
  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 )
92
+ const msg = await tap.next( within, from )
93
+ if( msg )
116
94
  return { content: [ { type: 'text', text: `${ will.name } says (to ${ msg.to }): ${ msg.content }` } ] }
117
- }
118
95
  return {
119
96
  content: [ {
120
97
  type: 'text',
@@ -0,0 +1,154 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // src/serve/server.ts — a Will, exposed over plain HTTP (the sidecar)
3
+ // ─────────────────────────────────────────────────────────────
4
+ //
5
+ // `will serve` for hosts that aren't Node and aren't MCP clients — a Python
6
+ // app, a game server, a cron job, anything that can speak HTTP. Same paradigm
7
+ // as the SDK facade and the MCP surface: a Will is a SUBJECT you speak to and
8
+ // observe, never a request/response function —
9
+ //
10
+ // POST /perceive deliver a stimulus → 202 (delivered, not answered)
11
+ // GET /next-utterance long-poll its next words; 200 {silence:true} is a
12
+ // real outcome, never an error
13
+ // GET /utterances SSE stream of projections (utterance/emotion/action)
14
+ // GET /state snapshot of its inner life
15
+ // POST /save checkpoint the living mind (non-destructive)
16
+ // GET /health liveness: name, tick, uptime
17
+ //
18
+ // There is deliberately no ask()-shaped route. Zero dependencies (node:http).
19
+ // Boot/persistence/shutdown wiring lives in the CLI; this module only maps a
20
+ // facade instance onto a server (testable on an ephemeral port).
21
+ // ─────────────────────────────────────────────────────────────
22
+
23
+ import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'node:http'
24
+ import { mkdirSync, writeFileSync } from 'node:fs'
25
+ import { dirname } from 'node:path'
26
+ import type { Will } from '#sdk/will'
27
+ import { UtteranceTap } from '#root/host/utterances'
28
+
29
+ export interface WillHttpOptions {
30
+ /** Where POST /save writes the PMA artifact. */
31
+ pmaPath?: string
32
+ }
33
+
34
+ const SSE_HEARTBEAT_MS = 15_000
35
+
36
+ function json( res: ServerResponse, status: number, body: unknown ): void {
37
+ const text = JSON.stringify( body )
38
+ res.writeHead( status, { 'content-type': 'application/json', 'access-control-allow-origin': '*' } )
39
+ res.end( text )
40
+ }
41
+
42
+ async function readJsonBody( req: IncomingMessage ): Promise<Record<string, unknown>> {
43
+ const chunks: Buffer[] = []
44
+ for await ( const c of req ) chunks.push( c as Buffer )
45
+ const raw = Buffer.concat( chunks ).toString( 'utf8' ).trim()
46
+ if( !raw ) return {}
47
+ return JSON.parse( raw ) as Record<string, unknown>
48
+ }
49
+
50
+ /**
51
+ * Map a running Will onto an HTTP server. The caller owns the Will's lifecycle
52
+ * and calls `listen()`; everything here is the protocol surface.
53
+ */
54
+ export function buildWillHttpServer( will: Will, opts: WillHttpOptions = {} ): Server {
55
+ const tap = new UtteranceTap( will )
56
+ const born = Date.now()
57
+
58
+ // SSE subscribers — every projection fans out to all open streams.
59
+ const streams = new Set<ServerResponse>()
60
+ const fanout = ( event: string, data: unknown ): void => {
61
+ const frame = `event: ${ event }\ndata: ${ JSON.stringify( data ) }\n\n`
62
+ for( const res of streams ) res.write( frame )
63
+ }
64
+ will.on( 'message', m => fanout( 'utterance', m ) )
65
+ will.on( 'emotion', a => fanout( 'emotion', a ) )
66
+ will.on( 'effector', a => fanout( 'action', a ) )
67
+
68
+ const server = createServer( ( req, res ) => {
69
+ void handle( req, res ).catch( err => {
70
+ if( !res.headersSent )
71
+ json( res, 500, { error: err instanceof Error ? err.message : String( err ) } )
72
+ else res.end()
73
+ } )
74
+ } )
75
+
76
+ async function handle( req: IncomingMessage, res: ServerResponse ): Promise<void> {
77
+ const url = new URL( req.url ?? '/', 'http://sidecar' )
78
+ const route = `${ req.method } ${ url.pathname }`
79
+
80
+ if( req.method === 'OPTIONS' ){
81
+ res.writeHead( 204, {
82
+ 'access-control-allow-origin': '*',
83
+ 'access-control-allow-methods': 'GET, POST, OPTIONS',
84
+ 'access-control-allow-headers': 'content-type',
85
+ } )
86
+ res.end()
87
+ return
88
+ }
89
+
90
+ switch( route ){
91
+ case 'GET /health':
92
+ return json( res, 200, { ok: true, name: will.name, tick: will.state().tick, uptimeMs: Date.now() - born } )
93
+
94
+ case 'GET /state':
95
+ return json( res, 200, will.state() )
96
+
97
+ case 'POST /perceive': {
98
+ const body = await readJsonBody( req )
99
+ const text = typeof body.text === 'string' ? body.text : ''
100
+ if( !text ) return json( res, 400, { error: 'text is required' } )
101
+ await will.perceive( {
102
+ text,
103
+ ...( typeof body.from === 'string' ? { from: body.from } : {} ),
104
+ ...( typeof body.speaker === 'string' ? { speaker: body.speaker } : {} ),
105
+ } )
106
+ // 202: delivered into the sensory field — NOT answered. A response, if
107
+ // any, arrives on /utterances or /next-utterance; silence is valid.
108
+ return json( res, 202, { delivered: true, tick: will.state().tick } )
109
+ }
110
+
111
+ case 'GET /next-utterance': {
112
+ const within = Math.min( Math.max( parseInt( url.searchParams.get( 'within_ms' ) ?? '15000' ) || 15_000, 100 ), 120_000 )
113
+ const from = url.searchParams.get( 'from' ) ?? undefined
114
+ const msg = await tap.next( within, from )
115
+ return msg
116
+ ? json( res, 200, { utterance: msg } )
117
+ : json( res, 200, { silence: true, waitedMs: within } ) // a choice, not an error
118
+ }
119
+
120
+ case 'GET /utterances': {
121
+ res.writeHead( 200, {
122
+ 'content-type': 'text/event-stream',
123
+ 'cache-control': 'no-cache',
124
+ 'connection': 'keep-alive',
125
+ 'access-control-allow-origin': '*',
126
+ } )
127
+ res.write( `event: hello\ndata: ${ JSON.stringify( { name: will.name, tick: will.state().tick } ) }\n\n` )
128
+ streams.add( res )
129
+ const heartbeat = setInterval( () => res.write( `: tick ${ will.state().tick }\n\n` ), SSE_HEARTBEAT_MS )
130
+ req.on( 'close', () => { clearInterval( heartbeat ); streams.delete( res ) } )
131
+ return
132
+ }
133
+
134
+ case 'POST /save': {
135
+ if( !opts.pmaPath ) return json( res, 409, { error: 'no PMA path configured — set WILL_PMA_PATH' } )
136
+ const pma = await will.save()
137
+ mkdirSync( dirname( opts.pmaPath ), { recursive: true } )
138
+ writeFileSync( opts.pmaPath, JSON.stringify( pma ) )
139
+ return json( res, 200, { saved: true, path: opts.pmaPath } )
140
+ }
141
+
142
+ default:
143
+ return json( res, 404, {
144
+ error: `no such route: ${ route }`,
145
+ routes: [ 'GET /health', 'GET /state', 'POST /perceive', 'GET /next-utterance', 'GET /utterances (SSE)', 'POST /save' ],
146
+ } )
147
+ }
148
+ }
149
+
150
+ // Close open SSE streams when the server closes (so close() can complete).
151
+ server.on( 'close', () => { for( const res of streams ) res.end(); streams.clear() } )
152
+
153
+ return server
154
+ }