@siduri-x/api 1.0.0 → 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/src/app.ts CHANGED
@@ -1,19 +1,51 @@
1
1
  import express, { Express } from 'express';
2
2
  import cors from 'cors';
3
- import { SiduriRuntime } from './runtime';
3
+ import { createCorsOptions } from './cors';
4
+ import { SiduriRuntime, dispatchCompanionChat } from './runtime';
4
5
  import { OpenAICompatibleBrain, OpenRouterBrain } from '@siduri-x/brain';
5
6
  import { PostgresMemoryOrgan } from '@siduri-x/memory';
6
- import { VoicevoxAdapter } from '@siduri-x/voice';
7
- import { EKnowledgeAdapter } from '@siduri-x/knowledge';
8
- 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';
9
10
  import { ActiveSelfCompiler } from '@siduri-x/behavior';
10
- import { Live2DAdapter } from '@siduri-x/body';
11
+ import { Live2DAdapter, Live2DAdapterConfig } from '@siduri-x/body';
11
12
  import { FixtureObservationOrgan } from '@siduri-x/observation';
12
- import { DefaultHandsOrgan } from '@siduri-x/hands';
13
- 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';
14
16
  import { attachIdentity, requireRole, Identity } from './auth';
15
17
  import { mapRequestContext } from './context-mapper';
16
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
+
17
49
  export interface AppInstance {
18
50
  app: Express;
19
51
  runtimes: Map<string, SiduriRuntime>;
@@ -22,67 +54,82 @@ export interface AppInstance {
22
54
 
23
55
  export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): AppInstance {
24
56
  const app: Express = express();
25
- app.use(cors());
57
+ app.use(cors(createCorsOptions()));
26
58
  app.use(express.json());
27
59
 
28
60
  let observationOrgan: FixtureObservationOrgan | undefined;
29
61
 
30
- function createBrain(config: any) {
31
- const provider = config.provider || 'openrouter';
62
+ function createBrain(config?: AppBrainConfig) {
63
+ const provider = config?.provider || 'openrouter';
32
64
  const defaultKeyEnv = provider === 'openai-compatible' ? 'OPENAI_COMPATIBLE_API_KEY' : 'OPENROUTER_API_KEY';
33
- const apiKey = config.apiKey || process.env[config.apiKeyEnv || defaultKeyEnv] || '';
65
+ const apiKey = config?.apiKey || process.env[config?.apiKeyEnv || defaultKeyEnv] || '';
34
66
  if (provider === 'openai-compatible') {
35
67
  return new OpenAICompatibleBrain({
36
68
  apiKey,
37
- model: config.model || 'local-model',
38
- 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',
39
71
  });
40
72
  }
41
- return new OpenRouterBrain({ apiKey, model: config.model || 'gpt-4o-mini' });
73
+ return new OpenRouterBrain({ apiKey, model: config?.model || 'gpt-4o-mini' });
42
74
  }
43
75
 
44
- function isDisabled(config: any): boolean {
76
+ function isDisabled(config?: { provider?: string }): boolean {
45
77
  return !config || config.provider === 'none';
46
78
  }
47
79
 
48
- function createVoice(config: any) {
80
+ function createVoice(config?: VoiceConfig) {
49
81
  return isDisabled(config)
50
82
  ? undefined
51
- : new VoicevoxAdapter({ 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
+ });
52
89
  }
53
90
 
54
- function createKnowledge(config: any) {
55
- return isDisabled(config) ? undefined : new EKnowledgeAdapter(config);
91
+ function createKnowledge(config?: EKnowledgeConfig) {
92
+ return isDisabled(config) ? undefined : new EKnowledgeAdapter(config || {});
56
93
  }
57
94
 
58
- function createVision(config: any) {
95
+ function createVision(config?: OpenRouterVisionConfig & { provider?: string }) {
59
96
  return isDisabled(config)
60
97
  ? undefined
61
- : 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
+ });
62
103
  }
63
104
 
64
- function createBehavior(config: any) {
105
+ function createBehavior(config?: AppBehaviorConfig) {
65
106
  return isDisabled(config) ? undefined : new ActiveSelfCompiler();
66
107
  }
67
108
 
68
- function createBody(config: any) {
109
+ function createBody(config?: Live2DAdapterConfig & { provider?: string }) {
69
110
  return isDisabled(config)
70
111
  ? undefined
71
112
  : new Live2DAdapter(config);
72
113
  }
73
114
 
74
- function createHands(config: any) {
115
+ function createHands(config?: DefaultHandsOrganConfig & { provider?: string }) {
75
116
  return isDisabled(config)
76
117
  ? new DefaultHandsOrgan()
77
118
  : new DefaultHandsOrgan(config);
78
119
  }
79
120
 
80
- function createEar(config: any) {
121
+ function createEar(config?: EarOrganConfig & { provider?: string }) {
81
122
  return isDisabled(config)
82
123
  ? new DefaultEarOrgan()
83
124
  : new DefaultEarOrgan(config);
84
125
  }
85
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
+
86
133
  app.post('/boot', requireRole(['OWNER']), async (req, res) => {
87
134
  try {
88
135
  const { id, config } = req.body;
@@ -99,8 +146,21 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
99
146
  const body = createBody(config.body);
100
147
  const hands = createHands(config.hands);
101
148
  const ear = createEar(config.ear);
102
-
103
- 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
+ });
104
164
  await runtime.initialize();
105
165
 
106
166
  runtimes.set(id, runtime);
@@ -113,11 +173,34 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
113
173
 
114
174
  // STATUS / HEALTH ENDPOINTS
115
175
  app.get('/health', (req, res) => res.json({ status: "ok" }));
116
- app.get('/version', (req, res) => res.json({ name: "siduri-y-api", version: "0.2.0-y" }));
117
- app.get('/ready', (req, res) => res.json({ status: "ready", dependencies: {} }));
118
- app.get('/voice/health', (req, res) => res.json({ provider: "voicevox", healthy: true }));
119
- app.get('/obs/health', (req, res) => res.json({ connected: true }));
120
- app.get('/platforms/status', (req, res) => res.json({ platforms: {} }));
176
+ app.get('/version', (req, res) => res.json({ name: "siduri-x-api", version: "0.2.0-x" }));
177
+ app.get('/ready', (req, res) => {
178
+ const isReady = runtimes.size > 0;
179
+ res.status(isReady ? 200 : 503).json({
180
+ status: isReady ? "ready" : "not_ready",
181
+ companionCount: runtimes.size,
182
+ });
183
+ });
184
+ app.get('/voice/health', (req, res) => {
185
+ const hasVoice = Array.from(runtimes.values()).some((r) => Boolean(r.voice));
186
+ res.json({ provider: "siduri-voice", configured: hasVoice });
187
+ });
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
+ });
121
204
  app.get('/me', attachIdentity, (req, res) => {
122
205
  const identity = (req as any).identity as Identity;
123
206
  res.json({
@@ -133,12 +216,20 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
133
216
  const { id, message, history } = req.body;
134
217
  const identity = (req as any).identity as Identity;
135
218
 
219
+ // Single-owner companion model: /chat defaults to OWNER identity.
220
+ // Explicit non-owner role in request body or context (e.g. adversarial test suites) is respected.
221
+ const effectiveRole = req.body?.role === 'VIEWER' || req.body?.context?.actor?.authorizationRole === 'viewer'
222
+ ? 'VIEWER'
223
+ : (identity?.role === 'OPERATOR' ? 'OPERATOR' : 'OWNER');
224
+ const isOwner = effectiveRole === 'OWNER';
225
+
136
226
  // Call context mapper at the API boundary
137
227
  const mappingResult = mapRequestContext(
138
228
  {
139
229
  ...req.body,
140
230
  id: id || req.body.companionId,
141
- role: req.body.role || identity?.role,
231
+ role: effectiveRole,
232
+ authenticated: isOwner,
142
233
  generateCorrelationId: true,
143
234
  },
144
235
  {
@@ -159,21 +250,161 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
159
250
  if (!runtime) return res.status(404).json({ error: "Companion not found" });
160
251
 
161
252
  try {
162
- // Map authorization role to legacy memory scope for backwards-compatible runtime call
163
- const legacyScope =
164
- mappingResult.context!.actor.authorizationRole === 'administrator'
165
- ? 'OWNER'
166
- : mappingResult.context!.actor.authorizationRole === 'operator'
167
- ? 'OPERATOR'
168
- : 'VIEWER';
169
-
170
- const response = await runtime.handleUserMessage(message, legacyScope, history);
253
+ const response = await dispatchCompanionChat(runtime, {
254
+ id: companionId,
255
+ companionId,
256
+ message,
257
+ context: mappingResult.context,
258
+ history,
259
+ ...(req.body?.medium ? { medium: req.body.medium } : {}),
260
+ });
171
261
  res.json(response);
172
262
  } catch (e: any) {
173
263
  res.status(500).json({ error: e.message });
174
264
  }
175
265
  });
176
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
+
177
408
  // MEMORY GETTERS
178
409
  app.get('/memory/proposals', requireRole(['OWNER', 'OPERATOR']), async (req, res) => {
179
410
  const id = req.query.id as string || Array.from(runtimes.keys())[0];
@@ -181,7 +412,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
181
412
  if (!runtime) return res.status(404).json({ error: "Companion not found" });
182
413
  if (!runtime.memory) return res.json({ proposals: [] });
183
414
  try {
184
- const proposals = await runtime.memory.getPendingClaims();
415
+ const proposals = await runtime.getPendingClaims();
185
416
  res.json({ proposals });
186
417
  } catch (e: any) {
187
418
  res.status(500).json({ error: e.message });
@@ -194,7 +425,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
194
425
  if (!runtime) return res.status(404).json({ error: "Companion not found" });
195
426
  if (!runtime.memory) return res.json({ items: [] });
196
427
  try {
197
- const items = await runtime.memory.getClaims();
428
+ const items = await runtime.getClaims();
198
429
  res.json({ items });
199
430
  } catch (e: any) {
200
431
  res.status(500).json({ error: e.message });
@@ -207,7 +438,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
207
438
  if (!runtime) return res.status(404).json({ error: "Companion not found" });
208
439
  if (!runtime.memory) return res.json({ claims: [] });
209
440
  try {
210
- const claims = await runtime.memory.getClaims();
441
+ const claims = await runtime.getClaims();
211
442
  res.json({ claims });
212
443
  } catch (e: any) {
213
444
  res.status(500).json({ error: e.message });
@@ -220,7 +451,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
220
451
  if (!runtime) return res.status(404).json({ error: "Companion not found" });
221
452
  if (!runtime.memory) return res.json({ directives: [] });
222
453
  try {
223
- const directives = await runtime.memory.getDirectives();
454
+ const directives = await runtime.getDirectives();
224
455
  res.json({ directives });
225
456
  } catch (e: any) {
226
457
  res.status(500).json({ error: e.message });
@@ -228,7 +459,24 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
228
459
  });
229
460
 
230
461
  // MEMORY MUTATIONS - PROPOSALS
231
- app.post('/memory/proposals/update', requireRole(['OWNER', 'OPERATOR']), async (req, res) => res.json({ success: true }));
462
+ app.post('/memory/proposals/update', requireRole(['OWNER', 'OPERATOR']), async (req, res) => {
463
+ const id = req.body.companionId as string || Array.from(runtimes.keys())[0];
464
+ const runtime = runtimes.get(id);
465
+ if (!runtime) return res.status(404).json({ error: "Companion not found" });
466
+ if (!runtime.memory || typeof runtime.memory.updateClaim !== 'function') {
467
+ return res.status(400).json({ error: "Memory organ does not support updating claims" });
468
+ }
469
+ try {
470
+ const claimId = req.body.id || req.body.claimId;
471
+ if (!claimId) {
472
+ return res.status(400).json({ error: "Missing required claim id" });
473
+ }
474
+ const updated = await runtime.updateClaim(claimId, req.body.updates || req.body);
475
+ res.json({ success: true, claim: updated });
476
+ } catch (e: any) {
477
+ res.status(500).json({ error: e.message });
478
+ }
479
+ });
232
480
 
233
481
  app.post('/memory/proposals/approve', requireRole(['OWNER', 'OPERATOR']), async (req, res) => {
234
482
  const id = req.body.companionId as string || Array.from(runtimes.keys())[0];
@@ -236,7 +484,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
236
484
  if (!runtime) return res.status(404).json({ error: "Companion not found" });
237
485
  if (!runtime.memory) return res.status(400).json({ error: "Memory organ not configured" });
238
486
  try {
239
- await runtime.memory.approveClaim(req.body.id);
487
+ await runtime.approveClaim(req.body.id);
240
488
  res.json({ approved: true });
241
489
  } catch (e: any) {
242
490
  res.status(500).json({ error: e.message });
@@ -249,7 +497,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
249
497
  if (!runtime) return res.status(404).json({ error: "Companion not found" });
250
498
  if (!runtime.memory) return res.status(400).json({ error: "Memory organ not configured" });
251
499
  try {
252
- await runtime.memory.rejectClaim(req.body.id);
500
+ await runtime.rejectClaim(req.body.id);
253
501
  res.json({ rejected: true });
254
502
  } catch (e: any) {
255
503
  res.status(500).json({ error: e.message });
@@ -263,7 +511,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
263
511
  if (!runtime) return res.status(404).json({ error: "Companion not found" });
264
512
  if (!runtime.memory) return res.status(400).json({ error: "Memory organ not configured" });
265
513
  try {
266
- await runtime.memory.approveDirective(req.body.id);
514
+ await runtime.approveDirective(req.body.id);
267
515
  res.json({ approved: true });
268
516
  } catch (e: any) {
269
517
  res.status(500).json({ error: e.message });
@@ -276,7 +524,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
276
524
  if (!runtime) return res.status(404).json({ error: "Companion not found" });
277
525
  if (!runtime.memory) return res.status(400).json({ error: "Memory organ not configured" });
278
526
  try {
279
- await runtime.memory.rejectDirective(req.body.id);
527
+ await runtime.rejectDirective(req.body.id);
280
528
  res.json({ rejected: true });
281
529
  } catch (e: any) {
282
530
  res.status(500).json({ error: e.message });
@@ -289,7 +537,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
289
537
  if (!runtime) return res.status(404).json({ error: "Companion not found" });
290
538
  if (!runtime.memory) return res.status(400).json({ error: "Memory organ not configured" });
291
539
  try {
292
- await runtime.memory.revokeDirective(req.body.id);
540
+ await runtime.revokeDirective(req.body.id);
293
541
  res.json({ revoked: true });
294
542
  } catch (e: any) {
295
543
  res.status(500).json({ error: e.message });
@@ -302,158 +550,174 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
302
550
  if (!runtime) return res.status(404).json({ error: "Companion not found" });
303
551
  if (!runtime.memory) return res.status(400).json({ error: "Memory organ not configured" });
304
552
  try {
305
- await runtime.memory.disableDirective(req.body.id);
553
+ await runtime.disableDirective(req.body.id);
306
554
  res.json({ disabled: true });
307
555
  } catch (e: any) {
308
556
  res.status(500).json({ error: e.message });
309
557
  }
310
558
  });
311
559
 
312
- app.post('/dev/memory/reset', requireRole(['OWNER']), async (req, res) => res.json({ reset: true }));
313
-
314
- // MOCKS / DEV / EVIDENCE / PLATFORMS
315
- app.get('/platforms/events', (req, res) => res.json({ events: [] }));
316
- app.get('/platforms/actions', (req, res) => res.json({ actions: [] }));
317
- app.get('/evidence', (req, res) => res.json({ results: [] }));
318
- app.get('/observations', (req, res) => res.json({ observations: observationOrgan?.current() ?? [] }));
560
+ const isDevMode = process.env.NODE_ENV !== 'production' || process.env.SIDURI_DEV_MODE === 'true';
561
+ if (isDevMode) {
562
+ app.post('/dev/memory/reset', requireRole(['OWNER']), async (req, res) => {
563
+ const id = req.body.companionId as string || Array.from(runtimes.keys())[0];
564
+ const runtime = runtimes.get(id);
565
+ if (!runtime) return res.status(404).json({ error: "Companion not found" });
566
+ if (!runtime.memory || typeof runtime.memory.resetMemory !== 'function') {
567
+ return res.status(400).json({ error: "Memory organ does not support reset" });
568
+ }
569
+ try {
570
+ await runtime.resetMemory();
571
+ res.json({ reset: true });
572
+ } catch (e: any) {
573
+ res.status(500).json({ error: e.message });
574
+ }
575
+ });
319
576
 
320
- app.post('/dev/mock-response', async (req, res) => {
321
- const companionId = req.body?.companionId || Array.from(runtimes.keys())[0] || 'default';
322
- const runtime = runtimes.get(companionId);
323
- if (!runtime) return res.status(404).json({ accepted: false, error: 'Companion not found' });
324
- const staged = runtime.gating.stageResponse({
325
- requestContext: {
326
- companionId,
327
- actor: {
328
- actorId: 'operator-a',
329
- sessionId: 'sess-op',
330
- authorizationRole: 'operator',
331
- capabilities: ['chat:public', 'memory:approve'],
332
- authenticated: true,
333
- },
334
- conversation: {
335
- channel: 'public',
336
- audienceId: 'audience-public',
337
- correlationId: req.body?.correlation_id || `corr-${Date.now()}`,
577
+ app.post('/dev/mock-response', async (req, res) => {
578
+ const companionId = req.body?.companionId || Array.from(runtimes.keys())[0] || 'default';
579
+ const runtime = runtimes.get(companionId);
580
+ if (!runtime) return res.status(404).json({ accepted: false, error: 'Companion not found' });
581
+ const staged = runtime.stageResponse({
582
+ requestContext: {
583
+ companionId,
584
+ actor: {
585
+ actorId: 'operator-a',
586
+ sessionId: 'sess-op',
587
+ authorizationRole: 'operator',
588
+ capabilities: ['chat:public', 'memory:approve'],
589
+ authenticated: true,
590
+ },
591
+ conversation: {
592
+ channel: 'public',
593
+ audienceId: 'audience-public',
594
+ correlationId: req.body?.correlation_id || `corr-${Date.now()}`,
595
+ },
338
596
  },
339
- },
340
- candidateSpeech: req.body?.speech || 'Mocked staged response for review',
341
- candidateLanguage: req.body?.language || 'en',
342
- requiresApproval: req.body?.requiresApproval ?? true,
343
- });
344
- res.json({
345
- accepted: true,
346
- staged: true,
347
- status: staged.status,
348
- response_id: staged.responseId,
349
- correlation_id: staged.correlationId,
350
- speech: staged.speech,
597
+ candidateSpeech: req.body?.speech || 'Mocked staged response for review',
598
+ candidateLanguage: req.body?.language || 'en',
599
+ requiresApproval: req.body?.requiresApproval ?? true,
600
+ });
601
+ res.json({
602
+ accepted: true,
603
+ staged: true,
604
+ status: staged.status,
605
+ response_id: staged.responseId,
606
+ correlation_id: staged.correlationId,
607
+ speech: staged.speech,
608
+ });
351
609
  });
352
- });
353
-
354
- app.post('/dev/approve-response', async (req, res) => {
355
- const companionId = req.body?.companionId || Array.from(runtimes.keys())[0] || 'default';
356
- const runtime = runtimes.get(companionId);
357
- if (!runtime) return res.status(404).json({ approved: false, error: 'Companion not found' });
358
610
 
359
- let responseId = req.body?.responseId;
360
- let correlationId = req.body?.correlation_id;
361
-
362
- if (!responseId && correlationId) {
363
- const found = runtime.gating.findStagedPlanByCorrelation(companionId, correlationId);
364
- if (found) {
365
- responseId = found.responseId;
366
- }
367
- } else if (responseId && !correlationId) {
368
- const found = runtime.gating.getStagedPlan(responseId);
369
- if (found) {
370
- correlationId = found.correlationId;
611
+ app.post('/dev/approve-response', async (req, res) => {
612
+ const companionId = req.body?.companionId || Array.from(runtimes.keys())[0] || 'default';
613
+ const runtime = runtimes.get(companionId);
614
+ if (!runtime) return res.status(404).json({ approved: false, error: 'Companion not found' });
615
+
616
+ let responseId = req.body?.responseId;
617
+ let correlationId = req.body?.correlation_id;
618
+
619
+ if (!responseId && correlationId) {
620
+ const found = runtime.findStagedPlanByCorrelation(companionId, correlationId);
621
+ if (found) {
622
+ responseId = found.responseId;
623
+ }
624
+ } else if (responseId && !correlationId) {
625
+ const found = runtime.getStagedPlan(responseId);
626
+ if (found) {
627
+ correlationId = found.correlationId;
628
+ }
371
629
  }
372
- }
373
630
 
374
- if (!responseId) {
375
- return res.status(400).json({ approved: false, error: 'UNKNOWN_APPROVAL_ID' });
376
- }
631
+ if (!responseId) {
632
+ return res.status(400).json({ approved: false, error: 'UNKNOWN_APPROVAL_ID' });
633
+ }
377
634
 
378
- const result = runtime.gating.approveResponse({
379
- responseId,
380
- companionId,
381
- correlationId: correlationId || '',
382
- audienceId: req.body?.audienceId,
383
- });
635
+ const result = runtime.approveResponse({
636
+ responseId,
637
+ companionId,
638
+ correlationId: correlationId || '',
639
+ audienceId: req.body?.audienceId,
640
+ });
384
641
 
385
- if (!result.success) {
386
- return res.status(400).json({ approved: false, error: result.reason });
387
- }
642
+ if (!result.success) {
643
+ return res.status(400).json({ approved: false, error: result.reason });
644
+ }
388
645
 
389
- // Now evaluated as approved
390
- const evaluation = runtime.gating.evaluateGate(result.plan!);
391
- res.json({
392
- approved: true,
393
- status: evaluation.disposition,
394
- response_id: result.plan!.responseId,
395
- speech: result.plan!.speech,
396
- language: result.plan!.language,
646
+ // Now evaluated as approved
647
+ const evaluation = runtime.evaluateGate(result.plan!);
648
+ res.json({
649
+ approved: true,
650
+ status: evaluation.disposition,
651
+ response_id: result.plan!.responseId,
652
+ speech: result.plan!.speech,
653
+ language: result.plan!.language,
654
+ });
397
655
  });
398
- });
399
656
 
400
- app.post('/dev/reject-response', async (req, res) => {
401
- const companionId = req.body?.companionId || Array.from(runtimes.keys())[0] || 'default';
402
- const runtime = runtimes.get(companionId);
403
- if (!runtime) return res.status(404).json({ rejected: false, error: 'Companion not found' });
404
-
405
- let responseId = req.body?.responseId;
406
- let correlationId = req.body?.correlation_id;
407
-
408
- if (!responseId && correlationId) {
409
- const found = runtime.gating.findStagedPlanByCorrelation(companionId, correlationId);
410
- if (found) {
411
- responseId = found.responseId;
412
- }
413
- } else if (responseId && !correlationId) {
414
- const found = runtime.gating.getStagedPlan(responseId);
415
- if (found) {
416
- correlationId = found.correlationId;
657
+ app.post('/dev/reject-response', async (req, res) => {
658
+ const companionId = req.body?.companionId || Array.from(runtimes.keys())[0] || 'default';
659
+ const runtime = runtimes.get(companionId);
660
+ if (!runtime) return res.status(404).json({ rejected: false, error: 'Companion not found' });
661
+
662
+ let responseId = req.body?.responseId;
663
+ let correlationId = req.body?.correlation_id;
664
+
665
+ if (!responseId && correlationId) {
666
+ const found = runtime.findStagedPlanByCorrelation(companionId, correlationId);
667
+ if (found) {
668
+ responseId = found.responseId;
669
+ }
670
+ } else if (responseId && !correlationId) {
671
+ const found = runtime.getStagedPlan(responseId);
672
+ if (found) {
673
+ correlationId = found.correlationId;
674
+ }
417
675
  }
418
- }
419
676
 
420
- if (!responseId) {
421
- return res.status(400).json({ rejected: false, error: 'UNKNOWN_APPROVAL_ID' });
422
- }
677
+ if (!responseId) {
678
+ return res.status(400).json({ rejected: false, error: 'UNKNOWN_APPROVAL_ID' });
679
+ }
423
680
 
424
- const result = runtime.gating.rejectResponse({
425
- responseId,
426
- companionId,
427
- correlationId: correlationId || '',
428
- reason: req.body?.reason,
429
- });
681
+ const result = runtime.rejectResponse({
682
+ responseId,
683
+ companionId,
684
+ correlationId: correlationId || '',
685
+ reason: req.body?.reason,
686
+ });
430
687
 
431
- if (!result.success) {
432
- return res.status(400).json({ rejected: false, error: result.reason });
433
- }
688
+ if (!result.success) {
689
+ return res.status(400).json({ rejected: false, error: result.reason });
690
+ }
434
691
 
435
- res.json({
436
- rejected: true,
437
- status: result.plan!.status,
438
- response_id: result.plan!.responseId,
692
+ res.json({
693
+ rejected: true,
694
+ status: result.plan!.status,
695
+ response_id: result.plan!.responseId,
696
+ });
439
697
  });
440
- });
441
-
442
- app.post('/dev/mock-observation', async (req, res) => {
443
- if (!observationOrgan) return res.status(503).json({ accepted: false, reason: 'observation_unavailable' });
444
- const result = await observationOrgan.ingest(
445
- new Uint8Array([115, 121, 110, 116, 104, 101, 116, 105, 99]),
446
- 'fixture-observation',
447
- 'configured-vision',
448
- );
449
- if (!result.observation) return res.status(result.duplicate ? 200 : 409).json({ accepted: false, ...result });
450
- res.status(202).json({ accepted: true, observation: result.observation });
451
- });
452
698
 
453
- app.post('/platforms/actions/suggest', (req, res) => res.json({ suggested: true }));
454
- app.post('/platforms/actions/approve', (req, res) => res.json({ approved: true }));
455
- app.post('/platforms/actions/reject', (req, res) => res.json({ rejected: true }));
456
- app.post('/platforms/actions/send', (req, res) => res.json({ sent: true }));
699
+ app.post('/dev/mock-observation', async (req, res) => {
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(
704
+ new Uint8Array([115, 121, 110, 116, 104, 101, 116, 105, 99]),
705
+ 'fixture-observation',
706
+ 'configured-vision',
707
+ );
708
+ if (!result.observation) return res.status(result.duplicate ? 200 : 409).json({ accepted: false, ...result });
709
+ res.status(202).json({ accepted: true, observation: result.observation });
710
+ });
711
+ }
457
712
 
458
- 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
+ };
459
723
  }