@siduri-x/api 1.0.1 → 1.0.2

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,22 +1,23 @@
1
1
  {
2
2
  "name": "@siduri-x/api",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "main": "dist/index.js",
5
5
  "dependencies": {
6
- "cors": "^2.8.5",
7
- "dotenv": "^16.3.1",
8
- "express": "^4.18.2",
9
6
  "@siduri-x/behavior": "1.0.5",
10
7
  "@siduri-x/body": "1.0.4",
11
- "@siduri-x/brain": "1.0.3",
12
- "@siduri-x/core": "1.0.4",
13
- "@siduri-x/ear": "1.0.2",
14
- "@siduri-x/hands": "1.0.2",
8
+ "@siduri-x/brain": "1.0.4",
9
+ "@siduri-x/core": "1.0.5",
10
+ "@siduri-x/ear": "1.0.3",
11
+ "@siduri-x/hands": "1.0.3",
15
12
  "@siduri-x/knowledge": "1.0.2",
16
- "@siduri-x/memory": "1.0.3",
13
+ "@siduri-x/memory": "1.0.4",
17
14
  "@siduri-x/observation": "1.0.2",
18
15
  "@siduri-x/vision": "1.0.2",
19
- "@siduri-x/voice": "1.0.5"
16
+ "@siduri-x/voice": "1.0.6",
17
+ "@siduri-x/mouth": "1.0.0",
18
+ "cors": "^2.8.5",
19
+ "dotenv": "^16.3.1",
20
+ "express": "^4.18.2"
20
21
  },
21
22
  "devDependencies": {
22
23
  "@types/cors": "^2.8.17",
package/src/app.ts CHANGED
@@ -4,17 +4,48 @@ import { createCorsOptions } from './cors';
4
4
  import { SiduriRuntime, dispatchCompanionChat } from './runtime';
5
5
  import { OpenAICompatibleBrain, OpenRouterBrain } from '@siduri-x/brain';
6
6
  import { PostgresMemoryOrgan } from '@siduri-x/memory';
7
- import { VoiceAdapter } from '@siduri-x/voice';
8
- import { EKnowledgeAdapter } from '@siduri-x/knowledge';
9
- import { OpenRouterVisionAdapter } from '@siduri-x/vision';
7
+ import { VoiceAdapter, VoiceConfig } from '@siduri-x/voice';
8
+ import { EKnowledgeAdapter, EKnowledgeConfig } from '@siduri-x/knowledge';
9
+ import { OpenRouterVisionAdapter, OpenRouterVisionConfig } from '@siduri-x/vision';
10
10
  import { ActiveSelfCompiler } from '@siduri-x/behavior';
11
- import { Live2DAdapter } from '@siduri-x/body';
11
+ import { Live2DAdapter, Live2DAdapterConfig } from '@siduri-x/body';
12
12
  import { FixtureObservationOrgan } from '@siduri-x/observation';
13
- import { DefaultHandsOrgan } from '@siduri-x/hands';
14
- import { DefaultEarOrgan } from '@siduri-x/ear';
13
+ import { DefaultHandsOrgan, DefaultHandsOrganConfig } from '@siduri-x/hands';
14
+ import { DefaultEarOrgan, EarOrganConfig } from '@siduri-x/ear';
15
+ import { DefaultMouthOrgan, DefaultMouthOrganConfig } from '@siduri-x/mouth';
15
16
  import { attachIdentity, requireRole, Identity } from './auth';
16
17
  import { mapRequestContext } from './context-mapper';
17
18
 
19
+ export interface AppBrainConfig {
20
+ provider?: 'openrouter' | 'openai-compatible' | string;
21
+ model?: string;
22
+ apiKey?: string;
23
+ apiKeyEnv?: string;
24
+ baseUrl?: string;
25
+ timeoutMs?: number;
26
+ [key: string]: unknown;
27
+ }
28
+
29
+ export interface AppBehaviorConfig {
30
+ provider?: 'active_self' | 'none' | string;
31
+ preset?: string;
32
+ [key: string]: unknown;
33
+ }
34
+
35
+ export interface AppBootCompanionConfig {
36
+ name: string;
37
+ brain?: AppBrainConfig;
38
+ voice?: VoiceConfig;
39
+ knowledge?: EKnowledgeConfig;
40
+ vision?: OpenRouterVisionConfig;
41
+ behavior?: AppBehaviorConfig;
42
+ body?: Live2DAdapterConfig;
43
+ hands?: DefaultHandsOrganConfig;
44
+ ear?: EarOrganConfig;
45
+ mouth?: DefaultMouthOrganConfig;
46
+ [key: string]: unknown;
47
+ }
48
+
18
49
  export interface AppInstance {
19
50
  app: Express;
20
51
  runtimes: Map<string, SiduriRuntime>;
@@ -28,62 +59,77 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
28
59
 
29
60
  let observationOrgan: FixtureObservationOrgan | undefined;
30
61
 
31
- function createBrain(config: any) {
32
- const provider = config.provider || 'openrouter';
62
+ function createBrain(config?: AppBrainConfig) {
63
+ const provider = config?.provider || 'openrouter';
33
64
  const defaultKeyEnv = provider === 'openai-compatible' ? 'OPENAI_COMPATIBLE_API_KEY' : 'OPENROUTER_API_KEY';
34
- const apiKey = config.apiKey || process.env[config.apiKeyEnv || defaultKeyEnv] || '';
65
+ const apiKey = config?.apiKey || process.env[config?.apiKeyEnv || defaultKeyEnv] || '';
35
66
  if (provider === 'openai-compatible') {
36
67
  return new OpenAICompatibleBrain({
37
68
  apiKey,
38
- model: config.model || 'local-model',
39
- baseUrl: config.baseUrl || 'http://127.0.0.1:1234/v1',
69
+ model: config?.model || 'local-model',
70
+ baseUrl: config?.baseUrl || 'http://127.0.0.1:1234/v1',
40
71
  });
41
72
  }
42
- return new OpenRouterBrain({ apiKey, model: config.model || 'gpt-4o-mini' });
73
+ return new OpenRouterBrain({ apiKey, model: config?.model || 'gpt-4o-mini' });
43
74
  }
44
75
 
45
- function isDisabled(config: any): boolean {
76
+ function isDisabled(config?: { provider?: string }): boolean {
46
77
  return !config || config.provider === 'none';
47
78
  }
48
79
 
49
- function createVoice(config: any) {
80
+ function createVoice(config?: VoiceConfig) {
50
81
  return isDisabled(config)
51
82
  ? undefined
52
- : new VoiceAdapter({ provider: config.provider || 'voicevox', baseUrl: process.env.VOICEVOX_URL || 'http://localhost:50021', speakerId: config.speakerId || 1 });
83
+ : new VoiceAdapter({
84
+ provider: (config?.provider as any) || 'voicevox',
85
+ baseUrl: config?.baseUrl || process.env.VOICEVOX_URL || 'http://localhost:50021',
86
+ speakerId: config?.speakerId || 1,
87
+ ...config,
88
+ });
53
89
  }
54
90
 
55
- function createKnowledge(config: any) {
56
- return isDisabled(config) ? undefined : new EKnowledgeAdapter(config);
91
+ function createKnowledge(config?: EKnowledgeConfig) {
92
+ return isDisabled(config) ? undefined : new EKnowledgeAdapter(config || {});
57
93
  }
58
94
 
59
- function createVision(config: any) {
95
+ function createVision(config?: OpenRouterVisionConfig & { provider?: string }) {
60
96
  return isDisabled(config)
61
97
  ? undefined
62
- : new OpenRouterVisionAdapter({ apiKey: process.env.OPENROUTER_API_KEY || '', model: config.model || 'gpt-4-vision' });
98
+ : new OpenRouterVisionAdapter({
99
+ apiKey: config?.apiKey || process.env.OPENROUTER_API_KEY || '',
100
+ model: config?.model || 'gpt-4-vision',
101
+ ...config,
102
+ });
63
103
  }
64
104
 
65
- function createBehavior(config: any) {
105
+ function createBehavior(config?: AppBehaviorConfig) {
66
106
  return isDisabled(config) ? undefined : new ActiveSelfCompiler();
67
107
  }
68
108
 
69
- function createBody(config: any) {
109
+ function createBody(config?: Live2DAdapterConfig & { provider?: string }) {
70
110
  return isDisabled(config)
71
111
  ? undefined
72
112
  : new Live2DAdapter(config);
73
113
  }
74
114
 
75
- function createHands(config: any) {
115
+ function createHands(config?: DefaultHandsOrganConfig & { provider?: string }) {
76
116
  return isDisabled(config)
77
117
  ? new DefaultHandsOrgan()
78
118
  : new DefaultHandsOrgan(config);
79
119
  }
80
120
 
81
- function createEar(config: any) {
121
+ function createEar(config?: EarOrganConfig & { provider?: string }) {
82
122
  return isDisabled(config)
83
123
  ? new DefaultEarOrgan()
84
124
  : new DefaultEarOrgan(config);
85
125
  }
86
126
 
127
+ function createMouth(config?: DefaultMouthOrganConfig & { provider?: string }, voice?: any) {
128
+ return isDisabled(config)
129
+ ? new DefaultMouthOrgan({ voice })
130
+ : new DefaultMouthOrgan({ ...config, voice });
131
+ }
132
+
87
133
  app.post('/boot', requireRole(['OWNER']), async (req, res) => {
88
134
  try {
89
135
  const { id, config } = req.body;
@@ -100,8 +146,21 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
100
146
  const body = createBody(config.body);
101
147
  const hands = createHands(config.hands);
102
148
  const ear = createEar(config.ear);
103
-
104
- const runtime = new SiduriRuntime(id, config, { brain, memory, voice, knowledge, vision, behavior, body, hands, ear });
149
+ const mouth = createMouth(config.mouth, voice);
150
+
151
+ const runtime = new SiduriRuntime(id, config, {
152
+ brain,
153
+ memory,
154
+ voice,
155
+ knowledge,
156
+ vision,
157
+ behavior,
158
+ body,
159
+ hands,
160
+ ear,
161
+ mouth,
162
+ observation: observationOrgan,
163
+ });
105
164
  await runtime.initialize();
106
165
 
107
166
  runtimes.set(id, runtime);
@@ -126,7 +185,22 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
126
185
  const hasVoice = Array.from(runtimes.values()).some((r) => Boolean(r.voice));
127
186
  res.json({ provider: "siduri-voice", configured: hasVoice });
128
187
  });
129
- app.get('/obs/health', (req, res) => res.json({ connected: Boolean(observationOrgan) }));
188
+ app.get('/obs/health', (req, res) => {
189
+ const connected = Boolean(observationOrgan) || Array.from(runtimes.values()).some((r) => Boolean(r.observation));
190
+ res.json({ connected });
191
+ });
192
+ app.get('/mouth/health', (req, res) => {
193
+ const hasMouth = Array.from(runtimes.values()).some((r) => Boolean(r.mouth));
194
+ res.json({ provider: "siduri-mouth", configured: hasMouth });
195
+ });
196
+ app.get('/mouth/channels', (req, res) => {
197
+ const id = (req.query.id as string) || Array.from(runtimes.keys())[0];
198
+ const runtime = runtimes.get(id);
199
+ if (!runtime || !runtime.mouth || typeof runtime.mouth.getRegisteredChannels !== 'function') {
200
+ return res.json({ channels: [] });
201
+ }
202
+ res.json({ channels: runtime.mouth.getRegisteredChannels() });
203
+ });
130
204
  app.get('/me', attachIdentity, (req, res) => {
131
205
  const identity = (req as any).identity as Identity;
132
206
  res.json({
@@ -182,6 +256,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
182
256
  message,
183
257
  context: mappingResult.context,
184
258
  history,
259
+ ...(req.body?.medium ? { medium: req.body.medium } : {}),
185
260
  });
186
261
  res.json(response);
187
262
  } catch (e: any) {
@@ -189,6 +264,147 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
189
264
  }
190
265
  });
191
266
 
267
+ // REAL-TIME SSE STREAMING (Mouth transport)
268
+ app.post('/chat/stream', attachIdentity, async (req, res) => {
269
+ const { id, message, history } = req.body;
270
+ const identity = (req as any).identity as Identity;
271
+
272
+ const effectiveRole = req.body?.role === 'VIEWER' || req.body?.context?.actor?.authorizationRole === 'viewer'
273
+ ? 'VIEWER'
274
+ : (identity?.role === 'OPERATOR' ? 'OPERATOR' : 'OWNER');
275
+ const isOwner = effectiveRole === 'OWNER';
276
+
277
+ const mappingResult = mapRequestContext(
278
+ {
279
+ ...req.body,
280
+ id: id || req.body.companionId,
281
+ role: effectiveRole,
282
+ authenticated: isOwner,
283
+ generateCorrelationId: true,
284
+ },
285
+ {
286
+ endpointPolicy: 'public',
287
+ defaultPublicAudience: 'audience-public',
288
+ }
289
+ );
290
+
291
+ if (!mappingResult.accepted) {
292
+ return res.status(400).json({
293
+ accepted: false,
294
+ error: mappingResult.error,
295
+ });
296
+ }
297
+
298
+ const companionId = mappingResult.context!.companionId;
299
+ const runtime = runtimes.get(companionId);
300
+ if (!runtime) return res.status(404).json({ error: "Companion not found" });
301
+
302
+ res.setHeader('Content-Type', 'text/event-stream');
303
+ res.setHeader('Cache-Control', 'no-cache, no-transform');
304
+ res.setHeader('Connection', 'keep-alive');
305
+ res.flushHeaders?.();
306
+
307
+ const abortController = new AbortController();
308
+ const onClose = () => {
309
+ abortController.abort('client_disconnect');
310
+ if (typeof runtime.interruptMouth === 'function') {
311
+ runtime.interruptMouth('client_disconnect');
312
+ }
313
+ };
314
+ req.on('close', onClose);
315
+
316
+ try {
317
+ const response = await dispatchCompanionChat(runtime, {
318
+ id: companionId,
319
+ companionId,
320
+ message,
321
+ context: mappingResult.context,
322
+ history,
323
+ medium: 'web',
324
+ signal: abortController.signal,
325
+ });
326
+
327
+ res.write(`event: staged\ndata: ${JSON.stringify({ response_id: response.response_id, correlation_id: response.correlation_id, status: response.status })}\n\n`);
328
+
329
+ const avatarEvent = response.metadata?.events?.find(
330
+ (e: any) => (e.kind === 'avatar' || e.kind === 'body') && (e.approval === 'APPROVED' || !e.approval)
331
+ );
332
+ if (avatarEvent) {
333
+ res.write(`event: avatar\ndata: ${JSON.stringify(avatarEvent)}\n\n`);
334
+ }
335
+
336
+ const speechText = response.delivery?.text || response.response?.subtitle_en || response.response?.spoken_ja || '';
337
+ const utterance = {
338
+ utteranceId: response.response_id || 'utt-stream',
339
+ companionId,
340
+ responseId: response.response_id,
341
+ correlationId: response.correlation_id,
342
+ text: speechText,
343
+ medium: 'web' as const,
344
+ expression: avatarEvent?.expression,
345
+ action: avatarEvent?.action,
346
+ signal: abortController.signal,
347
+ };
348
+
349
+ if (runtime.mouth && typeof runtime.mouth.stream === 'function') {
350
+ for await (const chunk of runtime.mouth.stream(utterance)) {
351
+ if (abortController.signal.aborted) {
352
+ res.write(`event: chunk\ndata: ${JSON.stringify({ ...chunk, interrupted: true })}\n\n`);
353
+ break;
354
+ }
355
+ res.write(`event: chunk\ndata: ${JSON.stringify(chunk)}\n\n`);
356
+ }
357
+ } else {
358
+ res.write(`event: chunk\ndata: ${JSON.stringify({ utteranceId: utterance.utteranceId, index: 1, deltaText: speechText, isComplete: true, medium: 'web' })}\n\n`);
359
+ }
360
+
361
+ res.write(`event: done\ndata: ${JSON.stringify(response)}\n\n`);
362
+ res.end();
363
+ } catch (e: any) {
364
+ if (abortController.signal.aborted) {
365
+ res.write(`event: interrupted\ndata: ${JSON.stringify({ reason: abortController.signal.reason })}\n\n`);
366
+ } else {
367
+ res.write(`event: error\ndata: ${JSON.stringify({ error: e.message })}\n\n`);
368
+ }
369
+ res.end();
370
+ } finally {
371
+ req.removeListener('close', onClose);
372
+ }
373
+ });
374
+
375
+ // INTERRUPTION / BARGE-IN
376
+ app.post('/chat/interrupt', attachIdentity, (req, res) => {
377
+ const companionId = req.body?.companionId || req.body?.id || Array.from(runtimes.keys())[0];
378
+ const runtime = companionId ? runtimes.get(companionId) : undefined;
379
+ const reason = req.body?.reason || 'user_barge_in';
380
+
381
+ if (runtime) {
382
+ runtime.interruptMouth(reason);
383
+ return res.json({ success: true, interrupted: true, companionId, reason });
384
+ }
385
+
386
+ for (const r of runtimes.values()) {
387
+ r.interruptMouth(reason);
388
+ }
389
+ return res.json({ success: true, interrupted: true, reason });
390
+ });
391
+
392
+ app.post('/mouth/interrupt', attachIdentity, (req, res) => {
393
+ const companionId = req.body?.companionId || req.body?.id || Array.from(runtimes.keys())[0];
394
+ const runtime = companionId ? runtimes.get(companionId) : undefined;
395
+ const reason = req.body?.reason || 'user_barge_in';
396
+
397
+ if (runtime) {
398
+ runtime.interruptMouth(reason);
399
+ return res.json({ success: true, interrupted: true, companionId, reason });
400
+ }
401
+
402
+ for (const r of runtimes.values()) {
403
+ r.interruptMouth(reason);
404
+ }
405
+ return res.json({ success: true, interrupted: true, reason });
406
+ });
407
+
192
408
  // MEMORY GETTERS
193
409
  app.get('/memory/proposals', requireRole(['OWNER', 'OPERATOR']), async (req, res) => {
194
410
  const id = req.query.id as string || Array.from(runtimes.keys())[0];
@@ -196,7 +412,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
196
412
  if (!runtime) return res.status(404).json({ error: "Companion not found" });
197
413
  if (!runtime.memory) return res.json({ proposals: [] });
198
414
  try {
199
- const proposals = await runtime.memory.getPendingClaims();
415
+ const proposals = await runtime.getPendingClaims();
200
416
  res.json({ proposals });
201
417
  } catch (e: any) {
202
418
  res.status(500).json({ error: e.message });
@@ -209,7 +425,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
209
425
  if (!runtime) return res.status(404).json({ error: "Companion not found" });
210
426
  if (!runtime.memory) return res.json({ items: [] });
211
427
  try {
212
- const items = await runtime.memory.getClaims();
428
+ const items = await runtime.getClaims();
213
429
  res.json({ items });
214
430
  } catch (e: any) {
215
431
  res.status(500).json({ error: e.message });
@@ -222,7 +438,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
222
438
  if (!runtime) return res.status(404).json({ error: "Companion not found" });
223
439
  if (!runtime.memory) return res.json({ claims: [] });
224
440
  try {
225
- const claims = await runtime.memory.getClaims();
441
+ const claims = await runtime.getClaims();
226
442
  res.json({ claims });
227
443
  } catch (e: any) {
228
444
  res.status(500).json({ error: e.message });
@@ -235,7 +451,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
235
451
  if (!runtime) return res.status(404).json({ error: "Companion not found" });
236
452
  if (!runtime.memory) return res.json({ directives: [] });
237
453
  try {
238
- const directives = await runtime.memory.getDirectives();
454
+ const directives = await runtime.getDirectives();
239
455
  res.json({ directives });
240
456
  } catch (e: any) {
241
457
  res.status(500).json({ error: e.message });
@@ -255,7 +471,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
255
471
  if (!claimId) {
256
472
  return res.status(400).json({ error: "Missing required claim id" });
257
473
  }
258
- const updated = await runtime.memory.updateClaim(claimId, req.body.updates || req.body);
474
+ const updated = await runtime.updateClaim(claimId, req.body.updates || req.body);
259
475
  res.json({ success: true, claim: updated });
260
476
  } catch (e: any) {
261
477
  res.status(500).json({ error: e.message });
@@ -268,7 +484,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
268
484
  if (!runtime) return res.status(404).json({ error: "Companion not found" });
269
485
  if (!runtime.memory) return res.status(400).json({ error: "Memory organ not configured" });
270
486
  try {
271
- await runtime.memory.approveClaim(req.body.id);
487
+ await runtime.approveClaim(req.body.id);
272
488
  res.json({ approved: true });
273
489
  } catch (e: any) {
274
490
  res.status(500).json({ error: e.message });
@@ -281,7 +497,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
281
497
  if (!runtime) return res.status(404).json({ error: "Companion not found" });
282
498
  if (!runtime.memory) return res.status(400).json({ error: "Memory organ not configured" });
283
499
  try {
284
- await runtime.memory.rejectClaim(req.body.id);
500
+ await runtime.rejectClaim(req.body.id);
285
501
  res.json({ rejected: true });
286
502
  } catch (e: any) {
287
503
  res.status(500).json({ error: e.message });
@@ -295,7 +511,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
295
511
  if (!runtime) return res.status(404).json({ error: "Companion not found" });
296
512
  if (!runtime.memory) return res.status(400).json({ error: "Memory organ not configured" });
297
513
  try {
298
- await runtime.memory.approveDirective(req.body.id);
514
+ await runtime.approveDirective(req.body.id);
299
515
  res.json({ approved: true });
300
516
  } catch (e: any) {
301
517
  res.status(500).json({ error: e.message });
@@ -308,7 +524,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
308
524
  if (!runtime) return res.status(404).json({ error: "Companion not found" });
309
525
  if (!runtime.memory) return res.status(400).json({ error: "Memory organ not configured" });
310
526
  try {
311
- await runtime.memory.rejectDirective(req.body.id);
527
+ await runtime.rejectDirective(req.body.id);
312
528
  res.json({ rejected: true });
313
529
  } catch (e: any) {
314
530
  res.status(500).json({ error: e.message });
@@ -321,7 +537,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
321
537
  if (!runtime) return res.status(404).json({ error: "Companion not found" });
322
538
  if (!runtime.memory) return res.status(400).json({ error: "Memory organ not configured" });
323
539
  try {
324
- await runtime.memory.revokeDirective(req.body.id);
540
+ await runtime.revokeDirective(req.body.id);
325
541
  res.json({ revoked: true });
326
542
  } catch (e: any) {
327
543
  res.status(500).json({ error: e.message });
@@ -334,7 +550,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
334
550
  if (!runtime) return res.status(404).json({ error: "Companion not found" });
335
551
  if (!runtime.memory) return res.status(400).json({ error: "Memory organ not configured" });
336
552
  try {
337
- await runtime.memory.disableDirective(req.body.id);
553
+ await runtime.disableDirective(req.body.id);
338
554
  res.json({ disabled: true });
339
555
  } catch (e: any) {
340
556
  res.status(500).json({ error: e.message });
@@ -351,7 +567,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
351
567
  return res.status(400).json({ error: "Memory organ does not support reset" });
352
568
  }
353
569
  try {
354
- await runtime.memory.resetMemory();
570
+ await runtime.resetMemory();
355
571
  res.json({ reset: true });
356
572
  } catch (e: any) {
357
573
  res.status(500).json({ error: e.message });
@@ -362,7 +578,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
362
578
  const companionId = req.body?.companionId || Array.from(runtimes.keys())[0] || 'default';
363
579
  const runtime = runtimes.get(companionId);
364
580
  if (!runtime) return res.status(404).json({ accepted: false, error: 'Companion not found' });
365
- const staged = runtime.gating.stageResponse({
581
+ const staged = runtime.stageResponse({
366
582
  requestContext: {
367
583
  companionId,
368
584
  actor: {
@@ -401,12 +617,12 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
401
617
  let correlationId = req.body?.correlation_id;
402
618
 
403
619
  if (!responseId && correlationId) {
404
- const found = runtime.gating.findStagedPlanByCorrelation(companionId, correlationId);
620
+ const found = runtime.findStagedPlanByCorrelation(companionId, correlationId);
405
621
  if (found) {
406
622
  responseId = found.responseId;
407
623
  }
408
624
  } else if (responseId && !correlationId) {
409
- const found = runtime.gating.getStagedPlan(responseId);
625
+ const found = runtime.getStagedPlan(responseId);
410
626
  if (found) {
411
627
  correlationId = found.correlationId;
412
628
  }
@@ -416,7 +632,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
416
632
  return res.status(400).json({ approved: false, error: 'UNKNOWN_APPROVAL_ID' });
417
633
  }
418
634
 
419
- const result = runtime.gating.approveResponse({
635
+ const result = runtime.approveResponse({
420
636
  responseId,
421
637
  companionId,
422
638
  correlationId: correlationId || '',
@@ -428,7 +644,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
428
644
  }
429
645
 
430
646
  // Now evaluated as approved
431
- const evaluation = runtime.gating.evaluateGate(result.plan!);
647
+ const evaluation = runtime.evaluateGate(result.plan!);
432
648
  res.json({
433
649
  approved: true,
434
650
  status: evaluation.disposition,
@@ -447,12 +663,12 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
447
663
  let correlationId = req.body?.correlation_id;
448
664
 
449
665
  if (!responseId && correlationId) {
450
- const found = runtime.gating.findStagedPlanByCorrelation(companionId, correlationId);
666
+ const found = runtime.findStagedPlanByCorrelation(companionId, correlationId);
451
667
  if (found) {
452
668
  responseId = found.responseId;
453
669
  }
454
670
  } else if (responseId && !correlationId) {
455
- const found = runtime.gating.getStagedPlan(responseId);
671
+ const found = runtime.getStagedPlan(responseId);
456
672
  if (found) {
457
673
  correlationId = found.correlationId;
458
674
  }
@@ -462,7 +678,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
462
678
  return res.status(400).json({ rejected: false, error: 'UNKNOWN_APPROVAL_ID' });
463
679
  }
464
680
 
465
- const result = runtime.gating.rejectResponse({
681
+ const result = runtime.rejectResponse({
466
682
  responseId,
467
683
  companionId,
468
684
  correlationId: correlationId || '',
@@ -481,8 +697,10 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
481
697
  });
482
698
 
483
699
  app.post('/dev/mock-observation', async (req, res) => {
484
- if (!observationOrgan) return res.status(503).json({ accepted: false, reason: 'observation_unavailable' });
485
- const result = await observationOrgan.ingest(
700
+ const activeRuntime = Array.from(runtimes.values())[0];
701
+ const targetObs = activeRuntime?.observation || observationOrgan;
702
+ if (!targetObs) return res.status(503).json({ accepted: false, reason: 'observation_unavailable' });
703
+ const result = await targetObs.ingest(
486
704
  new Uint8Array([115, 121, 110, 116, 104, 101, 116, 105, 99]),
487
705
  'fixture-observation',
488
706
  'configured-vision',
@@ -492,5 +710,14 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
492
710
  });
493
711
  }
494
712
 
495
- return { app, runtimes, setObservationOrgan: (org: FixtureObservationOrgan) => { observationOrgan = org; } };
713
+ return {
714
+ app,
715
+ runtimes,
716
+ setObservationOrgan: (org: FixtureObservationOrgan) => {
717
+ observationOrgan = org;
718
+ for (const r of runtimes.values()) {
719
+ r.observation = org;
720
+ }
721
+ },
722
+ };
496
723
  }
package/src/index.test.ts CHANGED
@@ -126,4 +126,57 @@ describe('API Boundary Context Validation (P2 Route Integration)', () => {
126
126
  expect(res.body.error.code).toBe('FORBIDDEN_CONTEXT');
127
127
  expect(fakeRuntime.handleUserMessage).not.toHaveBeenCalled();
128
128
  });
129
+
130
+ test('streams response chunks via POST /chat/stream', async () => {
131
+ fakeRuntime.mouth = {
132
+ stream: async function* () {
133
+ yield { utteranceId: 'utt-1', index: 1, deltaText: 'Hello', isComplete: false, medium: 'web' };
134
+ yield { utteranceId: 'utt-1', index: 2, deltaText: ' world', isComplete: false, medium: 'web' };
135
+ yield { utteranceId: 'utt-1', index: 3, deltaText: '', isComplete: true, medium: 'web' };
136
+ },
137
+ };
138
+
139
+ const res = await request(app)
140
+ .post('/chat/stream')
141
+ .send({
142
+ id: 'companion-a',
143
+ message: 'Stream me',
144
+ });
145
+
146
+ expect(res.status).toBe(200);
147
+ expect(res.headers['content-type']).toContain('text/event-stream');
148
+ expect(res.text).toContain('event: staged');
149
+ expect(res.text).toContain('event: chunk');
150
+ expect(res.text).toContain('event: done');
151
+ });
152
+
153
+ test('handles barge-in interruption via POST /chat/interrupt', async () => {
154
+ fakeRuntime.interruptMouth = jest.fn();
155
+
156
+ const res = await request(app)
157
+ .post('/chat/interrupt')
158
+ .send({
159
+ companionId: 'companion-a',
160
+ reason: 'user_stop',
161
+ });
162
+
163
+ expect(res.status).toBe(200);
164
+ expect(res.body.interrupted).toBe(true);
165
+ expect(fakeRuntime.interruptMouth).toHaveBeenCalledWith('user_stop');
166
+ });
167
+
168
+ test('handles mouth interruption via POST /mouth/interrupt', async () => {
169
+ fakeRuntime.interruptMouth = jest.fn();
170
+
171
+ const res = await request(app)
172
+ .post('/mouth/interrupt')
173
+ .send({
174
+ companionId: 'companion-a',
175
+ reason: 'user_barge_in',
176
+ });
177
+
178
+ expect(res.status).toBe(200);
179
+ expect(res.body.interrupted).toBe(true);
180
+ expect(fakeRuntime.interruptMouth).toHaveBeenCalledWith('user_barge_in');
181
+ });
129
182
  });