@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/README.md +34 -2
- package/dist/{mcp/cli.js → cli.js} +405 -232
- package/dist/cli.js.map +1 -0
- package/package.json +2 -2
- package/src/cli.ts +75 -0
- package/src/host/boot.ts +127 -0
- package/src/host/utterances.ts +53 -0
- package/src/mcp/server.ts +7 -30
- package/src/serve/server.ts +154 -0
- package/dist/mcp/cli.js.map +0 -1
- package/src/mcp/cli.ts +0 -129
- /package/dist/{mcp/cli.d.ts → cli.d.ts} +0 -0
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mindot/will",
|
|
3
|
-
"version": "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/
|
|
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
|
+
} )
|
package/src/host/boot.ts
ADDED
|
@@ -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
|
|
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.
|
|
55
|
-
// fast reply is not lost in the gap
|
|
56
|
-
const
|
|
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
|
|
111
|
-
|
|
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
|
+
}
|