@siduri-x/api 2.0.1 → 2.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
@@ -2,49 +2,25 @@ import express, { Express } from 'express';
2
2
  import cors from 'cors';
3
3
  import { createCorsOptions } from './cors';
4
4
  import { SiduriRuntime, dispatchCompanionChat } from './runtime';
5
- import { OpenAICompatibleBrain, OpenRouterBrain } from '@siduri-x/brain';
6
- import { SqliteMemoryStore } from '@siduri-x/memory';
7
- import { VoiceAdapter, VoiceConfig } from '@siduri-x/voice';
8
- import { EKnowledgeAdapter, EKnowledgeConfig } from '@siduri-x/eknowledge';
9
- import { OpenRouterVisionAdapter, OpenRouterVisionConfig } from '@siduri-x/vision';
10
- import { ActiveSelfCompiler, SelfPackageParser, SqliteSelfRepository, scanDirective } from '@siduri-x/self';
11
- import { Live2DAdapter, Live2DAdapterConfig } from '@siduri-x/body';
5
+ import { SelfPackageParser, SqliteSelfRepository, scanDirective } from '@siduri-x/self';
12
6
  import { FixtureObservationOrgan } from '@siduri-x/observation';
13
- import { DefaultHandsOrgan, DefaultHandsOrganConfig } from '@siduri-x/hands';
14
- import { DefaultEarOrgan, EarOrganConfig } from '@siduri-x/ear';
15
- import { DefaultMouthOrgan, DefaultMouthOrganConfig } from '@siduri-x/mouth';
16
- import { attachIdentity, requireAuth, Identity } from './auth';
7
+ import { attachIdentity, requireAuth, Identity, isLocalRequest } from './auth';
17
8
  import { mapRequestContext } from './context-mapper';
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
- }
9
+ import {
10
+ bootCompanion,
11
+ AppBrainConfig,
12
+ AppBehaviorConfig,
13
+ AppBootCompanionConfig,
14
+ BootCompanionOptions,
15
+ } from './boot';
16
+
17
+ export {
18
+ AppBrainConfig,
19
+ AppBehaviorConfig,
20
+ AppBootCompanionConfig,
21
+ BootCompanionOptions,
22
+ bootCompanion,
23
+ };
48
24
 
49
25
  export interface AppInstance {
50
26
  app: Express;
@@ -59,77 +35,6 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
59
35
 
60
36
  let observationOrgan: FixtureObservationOrgan | undefined;
61
37
 
62
- function createBrain(config?: AppBrainConfig) {
63
- const provider = config?.provider || 'openrouter';
64
- const defaultKeyEnv = provider === 'openai-compatible' ? 'OPENAI_COMPATIBLE_API_KEY' : 'OPENROUTER_API_KEY';
65
- const apiKey = config?.apiKey || process.env[config?.apiKeyEnv || defaultKeyEnv] || '';
66
- if (provider === 'openai-compatible') {
67
- return new OpenAICompatibleBrain({
68
- apiKey,
69
- model: config?.model || 'local-model',
70
- baseUrl: config?.baseUrl || 'http://127.0.0.1:1234/v1',
71
- });
72
- }
73
- return new OpenRouterBrain({ apiKey, model: config?.model || 'gpt-4o-mini' });
74
- }
75
-
76
- function isDisabled(config?: { provider?: string }): boolean {
77
- return !config || config.provider === 'none';
78
- }
79
-
80
- function createVoice(config?: VoiceConfig) {
81
- return isDisabled(config)
82
- ? undefined
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
- });
89
- }
90
-
91
- function createKnowledge(config?: EKnowledgeConfig) {
92
- return isDisabled(config) ? undefined : new EKnowledgeAdapter(config || {});
93
- }
94
-
95
- function createVision(config?: OpenRouterVisionConfig & { provider?: string }) {
96
- return isDisabled(config)
97
- ? undefined
98
- : new OpenRouterVisionAdapter({
99
- apiKey: config?.apiKey || process.env.OPENROUTER_API_KEY || '',
100
- model: config?.model || 'gpt-4-vision',
101
- ...config,
102
- });
103
- }
104
-
105
- function createBehavior(config?: AppBehaviorConfig) {
106
- return isDisabled(config) ? undefined : new ActiveSelfCompiler();
107
- }
108
-
109
- function createBody(config?: Live2DAdapterConfig & { provider?: string }) {
110
- return isDisabled(config)
111
- ? undefined
112
- : new Live2DAdapter(config);
113
- }
114
-
115
- function createHands(config?: DefaultHandsOrganConfig & { provider?: string }) {
116
- return isDisabled(config)
117
- ? new DefaultHandsOrgan()
118
- : new DefaultHandsOrgan(config);
119
- }
120
-
121
- function createEar(config?: EarOrganConfig & { provider?: string }) {
122
- return isDisabled(config)
123
- ? new DefaultEarOrgan()
124
- : new DefaultEarOrgan(config);
125
- }
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
-
133
38
  app.post('/boot', requireAuth, async (req, res) => {
134
39
  try {
135
40
  const { id, config } = req.body;
@@ -137,36 +42,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
137
42
  return res.status(400).json({ error: "Already booted" });
138
43
  }
139
44
 
140
- const organs = config?.organs || {};
141
- const brain = createBrain(organs.brain || config?.brain);
142
- const memory = new SqliteMemoryStore({ dbPath: process.env.STORAGE_PATH || process.env.SQLITE_DB_PATH || 'siduri.sqlite' });
143
- const selfRepo = new SqliteSelfRepository({ dbPath: process.env.STORAGE_PATH || process.env.SQLITE_DB_PATH || 'siduri.sqlite' });
144
- const voice = createVoice(organs.voice || config?.voice);
145
- const knowledge = createKnowledge(organs.knowledge || config?.knowledge);
146
- const vision = createVision(organs.vision || config?.vision);
147
- const behavior = createBehavior(organs.behavior || config?.behavior);
148
- const body = createBody(organs.body || config?.body);
149
- const hands = createHands(organs.hands || config?.hands);
150
- const ear = createEar(organs.ear || config?.ear);
151
- const mouth = createMouth(organs.mouth || config?.mouth, voice);
152
-
153
- const runtime = new SiduriRuntime(id, config, {
154
- brain,
155
- memory,
156
- voice,
157
- knowledge,
158
- vision,
159
- behavior,
160
- body,
161
- hands,
162
- ear,
163
- mouth,
164
- observation: observationOrgan,
165
- self: selfRepo,
166
- externalKnowledge: knowledge,
167
- });
168
- await runtime.initialize();
169
-
45
+ const runtime = await bootCompanion(id, config, { observationOrgan });
170
46
  runtimes.set(id, runtime);
171
47
 
172
48
  res.json({ success: true, id });
@@ -285,13 +161,20 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
285
161
  app.post('/chat', attachIdentity, async (req, res) => {
286
162
  const { id, message, history } = req.body;
287
163
  const identity = (req as any).identity as Identity;
164
+ const local = isLocalRequest(req);
165
+ const hasConfiguredToken = Boolean(process.env.AUTH_TOKEN || process.env.OPERATOR_TOKEN || process.env.OWNER_TOKEN);
166
+ const isLocalDirectOwner = local && !hasConfiguredToken;
288
167
 
289
- // Single-owner companion model: map request context directly
168
+ const authenticated = identity?.authenticated || isLocalDirectOwner;
169
+ const serverRole = identity?.authenticated ? identity.role : (isLocalDirectOwner ? 'OWNER' : 'VIEWER');
170
+
171
+ // Single-owner companion model: map request context directly with server identity enforcement
290
172
  const mappingResult = mapRequestContext({
291
173
  ...req.body,
292
174
  id: id || req.body.companionId,
293
- authenticated: identity?.authenticated ?? true,
294
- source: identity?.source ?? 'local',
175
+ authenticated,
176
+ serverRole,
177
+ source: identity?.source ?? (local ? 'local' : 'external'),
295
178
  generateCorrelationId: true,
296
179
  });
297
180
 
@@ -325,12 +208,19 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
325
208
  app.post('/chat/stream', attachIdentity, async (req, res) => {
326
209
  const { id, message, history } = req.body;
327
210
  const identity = (req as any).identity as Identity;
211
+ const local = isLocalRequest(req);
212
+ const hasConfiguredToken = Boolean(process.env.AUTH_TOKEN || process.env.OPERATOR_TOKEN || process.env.OWNER_TOKEN);
213
+ const isLocalDirectOwner = local && !hasConfiguredToken;
214
+
215
+ const authenticated = identity?.authenticated || isLocalDirectOwner;
216
+ const serverRole = identity?.authenticated ? identity.role : (isLocalDirectOwner ? 'OWNER' : 'VIEWER');
328
217
 
329
218
  const mappingResult = mapRequestContext({
330
219
  ...req.body,
331
220
  id: id || req.body.companionId,
332
- authenticated: identity?.authenticated ?? true,
333
- source: identity?.source ?? 'local',
221
+ authenticated,
222
+ serverRole,
223
+ source: identity?.source ?? (local ? 'local' : 'external'),
334
224
  generateCorrelationId: true,
335
225
  });
336
226
 
@@ -353,8 +243,8 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
353
243
  const abortController = new AbortController();
354
244
  const onClose = () => {
355
245
  abortController.abort('client_disconnect');
356
- if (typeof runtime.interruptMouth === 'function') {
357
- runtime.interruptMouth('client_disconnect');
246
+ if (runtime.mouth && typeof runtime.mouth.interrupt === 'function') {
247
+ runtime.mouth.interrupt('client_disconnect');
358
248
  }
359
249
  };
360
250
  req.on('close', onClose);
@@ -427,12 +317,16 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
427
317
  const reason = req.body?.reason || 'user_barge_in';
428
318
 
429
319
  if (runtime) {
430
- runtime.interruptMouth(reason);
320
+ if (runtime.mouth && typeof runtime.mouth.interrupt === 'function') {
321
+ runtime.mouth.interrupt(reason);
322
+ }
431
323
  return res.json({ success: true, interrupted: true, companionId, reason });
432
324
  }
433
325
 
434
326
  for (const r of runtimes.values()) {
435
- r.interruptMouth(reason);
327
+ if (r.mouth && typeof r.mouth.interrupt === 'function') {
328
+ r.mouth.interrupt(reason);
329
+ }
436
330
  }
437
331
  return res.json({ success: true, interrupted: true, reason });
438
332
  });
@@ -443,12 +337,16 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
443
337
  const reason = req.body?.reason || 'user_barge_in';
444
338
 
445
339
  if (runtime) {
446
- runtime.interruptMouth(reason);
340
+ if (runtime.mouth && typeof runtime.mouth.interrupt === 'function') {
341
+ runtime.mouth.interrupt(reason);
342
+ }
447
343
  return res.json({ success: true, interrupted: true, companionId, reason });
448
344
  }
449
345
 
450
346
  for (const r of runtimes.values()) {
451
- r.interruptMouth(reason);
347
+ if (r.mouth && typeof r.mouth.interrupt === 'function') {
348
+ r.mouth.interrupt(reason);
349
+ }
452
350
  }
453
351
  return res.json({ success: true, interrupted: true, reason });
454
352
  });
@@ -460,7 +358,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
460
358
  if (!runtime) return res.status(404).json({ error: "Companion not found" });
461
359
  if (!runtime.memory) return res.json({ proposals: [] });
462
360
  try {
463
- const proposals = await runtime.getPendingClaims();
361
+ const proposals = await runtime.memory.getPendingClaims();
464
362
  res.json({ proposals });
465
363
  } catch (e: any) {
466
364
  res.status(500).json({ error: e.message });
@@ -473,7 +371,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
473
371
  if (!runtime) return res.status(404).json({ error: "Companion not found" });
474
372
  if (!runtime.memory) return res.json({ items: [] });
475
373
  try {
476
- const items = await runtime.getClaims();
374
+ const items = await runtime.memory.getClaims();
477
375
  res.json({ items });
478
376
  } catch (e: any) {
479
377
  res.status(500).json({ error: e.message });
@@ -486,7 +384,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
486
384
  if (!runtime) return res.status(404).json({ error: "Companion not found" });
487
385
  if (!runtime.memory) return res.json({ claims: [] });
488
386
  try {
489
- const claims = await runtime.getClaims();
387
+ const claims = await runtime.memory.getClaims();
490
388
  res.json({ claims });
491
389
  } catch (e: any) {
492
390
  res.status(500).json({ error: e.message });
@@ -499,13 +397,96 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
499
397
  if (!runtime) return res.status(404).json({ error: "Companion not found" });
500
398
  if (!runtime.memory) return res.json({ directives: [] });
501
399
  try {
502
- const directives = await runtime.getDirectives();
400
+ const directives = await runtime.memory.getDirectives();
503
401
  res.json({ directives });
504
402
  } catch (e: any) {
505
403
  res.status(500).json({ error: e.message });
506
404
  }
507
405
  });
508
406
 
407
+ // KNOWLEDGE / LIFE DB GETTERS
408
+ app.get('/knowledge/life', requireAuth, async (req, res) => {
409
+ const id = req.query.id as string || Array.from(runtimes.keys())[0];
410
+ const runtime = runtimes.get(id);
411
+ if (!runtime) return res.status(404).json({ error: "Companion not found" });
412
+ const query = (req.query.q as string) || '';
413
+ if (!runtime.knowledge || typeof (runtime.knowledge as any).queryLifeContext !== 'function') {
414
+ return res.json({ matchedInventory: [], recentFinances: [], upcomingSchedule: [], preferences: [], formattedContext: '' });
415
+ }
416
+ try {
417
+ const result = await (runtime.knowledge as any).queryLifeContext(id, query);
418
+ res.json(result);
419
+ } catch (e: any) {
420
+ res.status(500).json({ error: e.message });
421
+ }
422
+ });
423
+
424
+ app.get('/knowledge/inventory', requireAuth, async (req, res) => {
425
+ const id = req.query.id as string || Array.from(runtimes.keys())[0];
426
+ const runtime = runtimes.get(id);
427
+ if (!runtime) return res.status(404).json({ error: "Companion not found" });
428
+ const domain = req.query.domain as string | undefined;
429
+ if (!runtime.knowledge || !(runtime.knowledge as any).inventory) {
430
+ return res.json({ items: [] });
431
+ }
432
+ try {
433
+ const items = await (runtime.knowledge as any).inventory.getItems(id, domain);
434
+ res.json({ items });
435
+ } catch (e: any) {
436
+ res.status(500).json({ error: e.message });
437
+ }
438
+ });
439
+
440
+ app.get('/knowledge/finance', requireAuth, async (req, res) => {
441
+ const id = req.query.id as string || Array.from(runtimes.keys())[0];
442
+ const runtime = runtimes.get(id);
443
+ if (!runtime) return res.status(404).json({ error: "Companion not found" });
444
+ if (!runtime.knowledge || !(runtime.knowledge as any).finance) {
445
+ return res.json({ summary: null, entries: [] });
446
+ }
447
+ try {
448
+ const limit = Number(req.query.limit || 20);
449
+ const [summary, entries] = await Promise.all([
450
+ (runtime.knowledge as any).finance.getSummary(id),
451
+ (runtime.knowledge as any).finance.getEntries(id, limit),
452
+ ]);
453
+ res.json({ summary, entries });
454
+ } catch (e: any) {
455
+ res.status(500).json({ error: e.message });
456
+ }
457
+ });
458
+
459
+ app.get('/knowledge/schedule', requireAuth, async (req, res) => {
460
+ const id = req.query.id as string || Array.from(runtimes.keys())[0];
461
+ const runtime = runtimes.get(id);
462
+ if (!runtime) return res.status(404).json({ error: "Companion not found" });
463
+ if (!runtime.knowledge || !(runtime.knowledge as any).schedule) {
464
+ return res.json({ items: [] });
465
+ }
466
+ try {
467
+ const items = await (runtime.knowledge as any).schedule.getUpcoming(id);
468
+ res.json({ items });
469
+ } catch (e: any) {
470
+ res.status(500).json({ error: e.message });
471
+ }
472
+ });
473
+
474
+ app.get('/knowledge/preferences', requireAuth, async (req, res) => {
475
+ const id = req.query.id as string || Array.from(runtimes.keys())[0];
476
+ const runtime = runtimes.get(id);
477
+ if (!runtime) return res.status(404).json({ error: "Companion not found" });
478
+ const category = req.query.category as string | undefined;
479
+ if (!runtime.knowledge || !(runtime.knowledge as any).preferences) {
480
+ return res.json({ preferences: [] });
481
+ }
482
+ try {
483
+ const preferences = await (runtime.knowledge as any).preferences.getPreferences(id, category);
484
+ res.json({ preferences });
485
+ } catch (e: any) {
486
+ res.status(500).json({ error: e.message });
487
+ }
488
+ });
489
+
509
490
  // MEMORY MUTATIONS - PROPOSALS
510
491
  app.post('/memory/proposals/update', requireAuth, async (req, res) => {
511
492
  const id = req.body.companionId as string || Array.from(runtimes.keys())[0];
@@ -519,7 +500,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
519
500
  if (!claimId) {
520
501
  return res.status(400).json({ error: "Missing required claim id" });
521
502
  }
522
- const updated = await runtime.updateClaim(claimId, req.body.updates || req.body);
503
+ const updated = await runtime.memory.updateClaim(claimId, req.body.updates || req.body);
523
504
  res.json({ success: true, claim: updated });
524
505
  } catch (e: any) {
525
506
  res.status(500).json({ error: e.message });
@@ -532,7 +513,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
532
513
  if (!runtime) return res.status(404).json({ error: "Companion not found" });
533
514
  if (!runtime.memory) return res.status(400).json({ error: "Memory organ not configured" });
534
515
  try {
535
- await runtime.approveClaim(req.body.id);
516
+ await runtime.memory.approveClaim(req.body.id);
536
517
  res.json({ approved: true });
537
518
  } catch (e: any) {
538
519
  res.status(500).json({ error: e.message });
@@ -545,7 +526,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
545
526
  if (!runtime) return res.status(404).json({ error: "Companion not found" });
546
527
  if (!runtime.memory) return res.status(400).json({ error: "Memory organ not configured" });
547
528
  try {
548
- await runtime.rejectClaim(req.body.id);
529
+ await runtime.memory.rejectClaim(req.body.id);
549
530
  res.json({ rejected: true });
550
531
  } catch (e: any) {
551
532
  res.status(500).json({ error: e.message });
@@ -559,7 +540,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
559
540
  if (!runtime) return res.status(404).json({ error: "Companion not found" });
560
541
  if (!runtime.memory) return res.status(400).json({ error: "Memory organ not configured" });
561
542
  try {
562
- await runtime.approveDirective(req.body.id);
543
+ await runtime.memory.approveDirective(req.body.id);
563
544
  res.json({ approved: true });
564
545
  } catch (e: any) {
565
546
  res.status(500).json({ error: e.message });
@@ -572,7 +553,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
572
553
  if (!runtime) return res.status(404).json({ error: "Companion not found" });
573
554
  if (!runtime.memory) return res.status(400).json({ error: "Memory organ not configured" });
574
555
  try {
575
- await runtime.rejectDirective(req.body.id);
556
+ await runtime.memory.rejectDirective(req.body.id);
576
557
  res.json({ rejected: true });
577
558
  } catch (e: any) {
578
559
  res.status(500).json({ error: e.message });
@@ -585,7 +566,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
585
566
  if (!runtime) return res.status(404).json({ error: "Companion not found" });
586
567
  if (!runtime.memory) return res.status(400).json({ error: "Memory organ not configured" });
587
568
  try {
588
- await runtime.revokeDirective(req.body.id);
569
+ await runtime.memory.revokeDirective(req.body.id);
589
570
  res.json({ revoked: true });
590
571
  } catch (e: any) {
591
572
  res.status(500).json({ error: e.message });
@@ -598,7 +579,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
598
579
  if (!runtime) return res.status(404).json({ error: "Companion not found" });
599
580
  if (!runtime.memory) return res.status(400).json({ error: "Memory organ not configured" });
600
581
  try {
601
- await runtime.disableDirective(req.body.id);
582
+ await runtime.memory.disableDirective(req.body.id);
602
583
  res.json({ disabled: true });
603
584
  } catch (e: any) {
604
585
  res.status(500).json({ error: e.message });
@@ -615,7 +596,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
615
596
  return res.status(400).json({ error: "Memory organ does not support reset" });
616
597
  }
617
598
  try {
618
- await runtime.resetMemory();
599
+ await runtime.memory.resetMemory();
619
600
  res.json({ reset: true });
620
601
  } catch (e: any) {
621
602
  res.status(500).json({ error: e.message });
@@ -626,7 +607,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
626
607
  const companionId = req.body?.companionId || Array.from(runtimes.keys())[0] || 'default';
627
608
  const runtime = runtimes.get(companionId);
628
609
  if (!runtime) return res.status(404).json({ accepted: false, error: 'Companion not found' });
629
- const staged = runtime.stageResponse({
610
+ const staged = runtime.gating.stageResponse({
630
611
  requestContext: {
631
612
  companionId,
632
613
  actor: {
@@ -662,12 +643,12 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
662
643
  let correlationId = req.body?.correlation_id;
663
644
 
664
645
  if (!responseId && correlationId) {
665
- const found = runtime.findStagedPlanByCorrelation(companionId, correlationId);
646
+ const found = runtime.gating.findStagedPlanByCorrelation(companionId, correlationId);
666
647
  if (found) {
667
648
  responseId = found.responseId;
668
649
  }
669
650
  } else if (responseId && !correlationId) {
670
- const found = runtime.getStagedPlan(responseId);
651
+ const found = runtime.gating.getStagedPlan(responseId);
671
652
  if (found) {
672
653
  correlationId = found.correlationId;
673
654
  }
@@ -677,7 +658,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
677
658
  return res.status(400).json({ approved: false, error: 'UNKNOWN_APPROVAL_ID' });
678
659
  }
679
660
 
680
- const result = runtime.approveResponse({
661
+ const result = runtime.gating.approveResponse({
681
662
  responseId,
682
663
  companionId,
683
664
  correlationId: correlationId || '',
@@ -688,7 +669,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
688
669
  }
689
670
 
690
671
  // Now evaluated as approved
691
- const evaluation = runtime.evaluateGate(result.plan!);
672
+ const evaluation = runtime.gating.evaluateGate(result.plan!);
692
673
  res.json({
693
674
  approved: true,
694
675
  status: evaluation.disposition,
@@ -707,12 +688,12 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
707
688
  let correlationId = req.body?.correlation_id;
708
689
 
709
690
  if (!responseId && correlationId) {
710
- const found = runtime.findStagedPlanByCorrelation(companionId, correlationId);
691
+ const found = runtime.gating.findStagedPlanByCorrelation(companionId, correlationId);
711
692
  if (found) {
712
693
  responseId = found.responseId;
713
694
  }
714
695
  } else if (responseId && !correlationId) {
715
- const found = runtime.getStagedPlan(responseId);
696
+ const found = runtime.gating.getStagedPlan(responseId);
716
697
  if (found) {
717
698
  correlationId = found.correlationId;
718
699
  }
@@ -722,7 +703,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
722
703
  return res.status(400).json({ rejected: false, error: 'UNKNOWN_APPROVAL_ID' });
723
704
  }
724
705
 
725
- const result = runtime.rejectResponse({
706
+ const result = runtime.gating.rejectResponse({
726
707
  responseId,
727
708
  companionId,
728
709
  correlationId: correlationId || '',
@@ -0,0 +1,103 @@
1
+ import { bootCompanion, isDisabled, createBrain, createMemory, createVoice, createKnowledge, createVision, createBehavior, createBody, createHands, createEar, createMouth, createSelf, createObservation } from './boot';
2
+ import { FixtureObservationOrgan } from '@siduri-x/observation';
3
+
4
+ describe('Canonical bootCompanion & Organ Factory Suite', () => {
5
+ const originalEnv = process.env;
6
+
7
+ beforeEach(() => {
8
+ process.env = { ...originalEnv };
9
+ });
10
+
11
+ afterAll(() => {
12
+ process.env = originalEnv;
13
+ });
14
+
15
+ test('isDisabled correctly identifies undefined or none provider', () => {
16
+ expect(isDisabled(undefined)).toBe(true);
17
+ expect(isDisabled({ provider: 'none' })).toBe(true);
18
+ expect(isDisabled({ provider: 'openrouter' })).toBe(false);
19
+ expect(isDisabled({ provider: 'sqlite' })).toBe(false);
20
+ });
21
+
22
+ test('creates all standard organs with fallback configurations', () => {
23
+ const brain = createBrain({ provider: 'openai-compatible', baseUrl: 'http://localhost:11434/v1', apiKey: 'test' });
24
+ expect(brain).toBeDefined();
25
+
26
+ const voice = createVoice({ provider: 'none' });
27
+ expect(voice).toBeUndefined();
28
+
29
+ const memory = createMemory({ provider: 'sqlite', dbPath: ':memory:' });
30
+ expect(memory).toBeDefined();
31
+
32
+ const selfRepo = createSelf({ dbPath: ':memory:' });
33
+ expect(selfRepo).toBeDefined();
34
+
35
+ const vision = createVision({ provider: 'none' });
36
+ expect(vision).toBeUndefined();
37
+
38
+ const behavior = createBehavior({ provider: 'active_self' });
39
+ expect(behavior).toBeDefined();
40
+
41
+ const body = createBody({ provider: 'none' });
42
+ expect(body).toBeUndefined();
43
+
44
+ const hands = createHands();
45
+ expect(hands).toBeDefined();
46
+
47
+ const ear = createEar();
48
+ expect(ear).toBeDefined();
49
+
50
+ const mouth = createMouth(undefined, voice);
51
+ expect(mouth).toBeDefined();
52
+
53
+ const observation = createObservation();
54
+ expect(observation).toBeInstanceOf(FixtureObservationOrgan);
55
+ });
56
+
57
+ test('bootCompanion wires all organs and runs migrations', async () => {
58
+ let migrationsRun = false;
59
+ const testConfig = {
60
+ name: 'Test Companion',
61
+ brain: { provider: 'openrouter', apiKey: 'mock-key', model: 'mock-model' },
62
+ memory: { provider: 'none' }, // will use custom mock
63
+ voice: { provider: 'none' },
64
+ vision: { provider: 'none' },
65
+ behavior: { provider: 'none' },
66
+ body: { provider: 'none' },
67
+ hands: { provider: 'none' },
68
+ ear: { provider: 'none' },
69
+ mouth: { provider: 'none' },
70
+ knowledge: { provider: 'none' },
71
+ };
72
+
73
+ const mockObservation = new FixtureObservationOrgan({ analyze: async () => JSON.stringify({ readings: [] }) });
74
+
75
+ const runtime = await bootCompanion('test-comp-1', testConfig as any, {
76
+ observationOrgan: mockObservation,
77
+ });
78
+
79
+ expect(runtime).toBeDefined();
80
+ expect(runtime.id).toBe('test-comp-1');
81
+ expect(runtime.observation).toBe(mockObservation);
82
+ expect(runtime.hands).toBeDefined();
83
+ expect(runtime.ear).toBeDefined();
84
+ expect(runtime.mouth).toBeDefined();
85
+ });
86
+
87
+ test('bootCompanion handles nested organs configuration structure', async () => {
88
+ const nestedConfig = {
89
+ name: 'Nested Companion',
90
+ organs: {
91
+ brain: { provider: 'openrouter', apiKey: 'mock' },
92
+ memory: { provider: 'none' },
93
+ knowledge: { provider: 'none' },
94
+ voice: { provider: 'none' },
95
+ },
96
+ };
97
+
98
+ const runtime = await bootCompanion('nested-comp', nestedConfig as any);
99
+ expect(runtime).toBeDefined();
100
+ expect(runtime.id).toBe('nested-comp');
101
+ expect(runtime.brain).toBeDefined();
102
+ });
103
+ });