@sidurijs/api 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/.turbo/turbo-test.log +78 -0
  3. package/dist/app.d.ts +11 -0
  4. package/dist/app.js +1190 -0
  5. package/dist/auth.d.ts +34 -0
  6. package/dist/auth.js +84 -0
  7. package/dist/auth.test.d.ts +1 -0
  8. package/dist/auth.test.js +74 -0
  9. package/dist/b0-b6.test.d.ts +1 -0
  10. package/dist/b0-b6.test.js +121 -0
  11. package/dist/boot.d.ts +91 -0
  12. package/dist/boot.js +154 -0
  13. package/dist/boot.test.d.ts +1 -0
  14. package/dist/boot.test.js +91 -0
  15. package/dist/context-mapper.d.ts +20 -0
  16. package/dist/context-mapper.js +233 -0
  17. package/dist/context-mapper.test.d.ts +1 -0
  18. package/dist/context-mapper.test.js +287 -0
  19. package/dist/cors.d.ts +3 -0
  20. package/dist/cors.js +42 -0
  21. package/dist/index.d.ts +7 -0
  22. package/dist/index.js +116 -0
  23. package/dist/index.test.d.ts +1 -0
  24. package/dist/index.test.js +151 -0
  25. package/dist/knowledge.test.d.ts +1 -0
  26. package/dist/knowledge.test.js +236 -0
  27. package/dist/runtime.d.ts +1 -0
  28. package/dist/runtime.js +17 -0
  29. package/dist/runtime.test.d.ts +1 -0
  30. package/dist/runtime.test.js +282 -0
  31. package/dist/smoke.test.d.ts +0 -0
  32. package/dist/smoke.test.js +6 -0
  33. package/dist/t4-gating.test.d.ts +1 -0
  34. package/dist/t4-gating.test.js +193 -0
  35. package/dist/t5-experience.test.d.ts +1 -0
  36. package/dist/t5-experience.test.js +155 -0
  37. package/dist/t6-security.test.d.ts +1 -0
  38. package/dist/t6-security.test.js +535 -0
  39. package/dist/t7-release.test.d.ts +1 -0
  40. package/dist/t7-release.test.js +119 -0
  41. package/dist/teach-mode.test.d.ts +1 -0
  42. package/dist/teach-mode.test.js +277 -0
  43. package/jest.config.json +5 -0
  44. package/package.json +44 -0
  45. package/siduri.sqlite +0 -0
  46. package/src/app.ts +1209 -0
  47. package/src/auth.test.ts +90 -0
  48. package/src/auth.ts +101 -0
  49. package/src/b0-b6.test.ts +137 -0
  50. package/src/boot.test.ts +112 -0
  51. package/src/boot.ts +197 -0
  52. package/src/context-mapper.test.ts +319 -0
  53. package/src/context-mapper.ts +279 -0
  54. package/src/cors.ts +44 -0
  55. package/src/index.test.ts +178 -0
  56. package/src/index.ts +101 -0
  57. package/src/knowledge.test.ts +267 -0
  58. package/src/runtime.test.ts +336 -0
  59. package/src/runtime.ts +1 -0
  60. package/src/smoke.test.ts +5 -0
  61. package/src/t4-gating.test.ts +219 -0
  62. package/src/t5-experience.test.ts +174 -0
  63. package/src/t6-security.test.ts +589 -0
  64. package/src/t7-release.test.ts +131 -0
  65. package/src/teach-mode.test.ts +312 -0
  66. package/tsconfig.json +16 -0
@@ -0,0 +1,90 @@
1
+ import { resolveIdentity, requireAuth } from './auth';
2
+
3
+ describe('Auth Identity Resolution (Single-Owner External Boundary)', () => {
4
+ const originalEnv = process.env;
5
+
6
+ beforeEach(() => {
7
+ jest.resetModules();
8
+ process.env = { ...originalEnv };
9
+ });
10
+
11
+ afterAll(() => {
12
+ process.env = originalEnv;
13
+ });
14
+
15
+ const mockReq = (token?: string, ip: string = '192.168.1.50') => ({
16
+ headers: {
17
+ authorization: token ? `Bearer ${token}` : undefined,
18
+ },
19
+ ip,
20
+ socket: { remoteAddress: ip },
21
+ } as any);
22
+
23
+ test('authenticates external request when token matches configured AUTH_TOKEN', () => {
24
+ process.env.AUTH_TOKEN = 'secret-auth-key';
25
+ process.env.NODE_ENV = 'production';
26
+
27
+ const identity = resolveIdentity(mockReq('secret-auth-key'));
28
+ expect(identity.authenticated).toBe(true);
29
+ expect(identity.source).toBe('external');
30
+ });
31
+
32
+ test('authenticates external request when token matches legacy OWNER_TOKEN', () => {
33
+ process.env.OWNER_TOKEN = 'owner-secret';
34
+ process.env.NODE_ENV = 'production';
35
+
36
+ const identity = resolveIdentity(mockReq('owner-secret'));
37
+ expect(identity.authenticated).toBe(true);
38
+ expect(identity.source).toBe('external');
39
+ });
40
+
41
+ test('rejects external request when token is missing in production with configured token', () => {
42
+ process.env.AUTH_TOKEN = 'secret-auth-key';
43
+ process.env.NODE_ENV = 'production';
44
+
45
+ const identity = resolveIdentity(mockReq());
46
+ expect(identity.authenticated).toBe(false);
47
+ });
48
+
49
+ test('rejects external request for invalid token in production', () => {
50
+ process.env.AUTH_TOKEN = 'secret-auth-key';
51
+ process.env.NODE_ENV = 'production';
52
+
53
+ const identity = resolveIdentity(mockReq('invalid-token'));
54
+ expect(identity.authenticated).toBe(false);
55
+ });
56
+
57
+ test('authenticates local loopback request in development mode', () => {
58
+ process.env.NODE_ENV = 'development';
59
+ process.env.DEV_LOCAL_AUTH_ROLE = 'OWNER';
60
+ process.env.AUTH_TOKEN = 'secret-key';
61
+
62
+ const devIdentity = resolveIdentity(mockReq(undefined, '127.0.0.1'));
63
+ expect(devIdentity.authenticated).toBe(true);
64
+ expect(devIdentity.source).toBe('local');
65
+ });
66
+
67
+ test('requireAuth middleware accepts authenticated request and rejects unauthenticated with 401', () => {
68
+ process.env.AUTH_TOKEN = 'secret-key';
69
+ process.env.NODE_ENV = 'production';
70
+
71
+ const nextFn = jest.fn();
72
+ const resUnauthorized = {
73
+ status: jest.fn().mockReturnThis(),
74
+ json: jest.fn().mockReturnThis(),
75
+ } as any;
76
+
77
+ // Unauthorized call
78
+ requireAuth(mockReq('wrong-token'), resUnauthorized, nextFn);
79
+ expect(resUnauthorized.status).toHaveBeenCalledWith(401);
80
+ expect(nextFn).not.toHaveBeenCalled();
81
+
82
+ // Authorized call
83
+ const resAuthorized = {
84
+ status: jest.fn().mockReturnThis(),
85
+ json: jest.fn().mockReturnThis(),
86
+ } as any;
87
+ requireAuth(mockReq('secret-key'), resAuthorized, nextFn);
88
+ expect(nextFn).toHaveBeenCalled();
89
+ });
90
+ });
package/src/auth.ts ADDED
@@ -0,0 +1,101 @@
1
+ import { Request, Response, NextFunction } from 'express';
2
+
3
+ export type Role = 'OWNER' | 'OPERATOR' | 'VIEWER' | 'user' | string;
4
+
5
+ export interface Identity {
6
+ authenticated: boolean;
7
+ source: 'local' | 'external';
8
+ actorId?: string;
9
+ role?: Role;
10
+ [key: string]: unknown;
11
+ }
12
+
13
+ /**
14
+ * Checks whether an incoming request originates from the local machine loopback interface.
15
+ */
16
+ export function isLocalRequest(req: Request): boolean {
17
+ const ip = req.ip || req.socket?.remoteAddress || '';
18
+ return (
19
+ ip === '127.0.0.1' ||
20
+ ip === '::1' ||
21
+ ip === '::ffff:127.0.0.1' ||
22
+ ip.endsWith('127.0.0.1') ||
23
+ ip === 'localhost'
24
+ );
25
+ }
26
+
27
+ /**
28
+ * Resolves request identity according to the single-owner external machine boundary security model.
29
+ *
30
+ * - Security is enforced at the external machine boundary, NOT internally.
31
+ * - External network requests require a matching token (AUTH_TOKEN / OWNER_TOKEN).
32
+ * - There are no internal viewer/operator/owner role privileges inside the machine.
33
+ */
34
+ export function resolveIdentity(req: Request): Identity {
35
+ const authHeader = req.headers?.authorization;
36
+ const token = authHeader?.startsWith('Bearer ') ? authHeader.split(' ')[1] : undefined;
37
+ const ownerToken = process.env.AUTH_TOKEN || process.env.API_TOKEN || process.env.OWNER_TOKEN;
38
+ const operatorToken = process.env.OPERATOR_TOKEN;
39
+ const configuredToken = ownerToken || operatorToken;
40
+
41
+ const local = isLocalRequest(req);
42
+
43
+ // 1. Explicit token matches
44
+ if (ownerToken && token === ownerToken) {
45
+ return { authenticated: true, source: local ? 'local' : 'external', role: 'OWNER' };
46
+ }
47
+ if (operatorToken && token === operatorToken) {
48
+ return { authenticated: true, source: local ? 'local' : 'external', role: 'OPERATOR' };
49
+ }
50
+
51
+ // 2. Dev local auth role fallback
52
+ const isDev = process.env.NODE_ENV !== 'production';
53
+ if (isDev && process.env.DEV_LOCAL_AUTH_ROLE) {
54
+ const fallbackRole = process.env.DEV_LOCAL_AUTH_ROLE.toUpperCase() as Role;
55
+ return {
56
+ authenticated: fallbackRole === 'OWNER' || fallbackRole === 'OPERATOR',
57
+ source: 'local',
58
+ role: fallbackRole,
59
+ };
60
+ }
61
+
62
+ // 3. Default unauthenticated / visitor
63
+ return { authenticated: false, source: local ? 'local' : 'external', role: 'VIEWER' };
64
+ }
65
+
66
+ /**
67
+ * External boundary authentication middleware.
68
+ * Verifies that requests crossing the external boundary are authorized.
69
+ */
70
+ export function requireAuth(req: Request, res: Response, next: NextFunction) {
71
+ const identity = resolveIdentity(req);
72
+ (req as any).identity = identity;
73
+
74
+ if (!identity.authenticated) {
75
+ return res.status(401).json({ error: 'Unauthorized: missing or invalid authentication token' });
76
+ }
77
+ next();
78
+ }
79
+
80
+ /**
81
+ * Attaches the resolved machine identity to the request.
82
+ */
83
+ export function attachIdentity(req: Request, res: Response, next: NextFunction) {
84
+ (req as any).identity = resolveIdentity(req);
85
+ next();
86
+ }
87
+
88
+ /**
89
+ * Compatibility alias: in single-owner model, all authenticated callers have full access.
90
+ */
91
+ export function requireRole(allowedRoles?: Role[]) {
92
+ return (req: Request, res: Response, next: NextFunction) => {
93
+ const identity = resolveIdentity(req);
94
+ (req as any).identity = identity;
95
+
96
+ if (!identity.authenticated) {
97
+ return res.status(403).json({ error: `Forbidden: requires one of ${(allowedRoles || []).join(', ')}` });
98
+ }
99
+ next();
100
+ };
101
+ }
@@ -0,0 +1,137 @@
1
+ import request from 'supertest';
2
+ import { createApp } from './app';
3
+ import { SiduriRuntime } from './runtime';
4
+ import { Message, BrainContext, ResponsePlan } from '@sidurijs/core';
5
+
6
+ describe('T0 B0 & B6 Runtime Proof Suite', () => {
7
+ let mockBrain: any;
8
+ let mockMemory: any;
9
+ let mockKnowledge: any;
10
+ let mockBehavior: any;
11
+ let runtime: SiduriRuntime;
12
+ let app: any;
13
+
14
+ beforeEach(async () => {
15
+ mockBrain = {
16
+ generatePlan: jest.fn().mockImplementation(async (ctx: BrainContext): Promise<ResponsePlan> => {
17
+ return {
18
+ speech: 'Hello. I am a neutral companion.',
19
+ language: 'en',
20
+ };
21
+ }),
22
+ };
23
+
24
+ mockMemory = {
25
+ initialize: jest.fn().mockResolvedValue(undefined),
26
+ searchClaims: jest.fn().mockResolvedValue([]),
27
+ getClaims: jest.fn().mockResolvedValue([]),
28
+ getDirectives: jest.fn().mockResolvedValue([]),
29
+ getPendingClaims: jest.fn().mockResolvedValue([]),
30
+ proposeClaim: jest.fn().mockResolvedValue({}),
31
+ approveClaim: jest.fn().mockResolvedValue(undefined),
32
+ rejectClaim: jest.fn().mockResolvedValue(undefined),
33
+ };
34
+
35
+ mockKnowledge = {
36
+ search: jest.fn().mockResolvedValue([]),
37
+ };
38
+
39
+ mockBehavior = {
40
+ compile: jest.fn().mockResolvedValue(''),
41
+ };
42
+
43
+ const config = {
44
+ name: 'NeutralCompanion',
45
+ brain: { provider: 'openrouter' },
46
+ memory: { provider: 'sqlite' },
47
+ knowledge: { provider: 'e-knowledge' },
48
+ behavior: { provider: 'active-self' },
49
+ voice: { provider: 'none' },
50
+ vision: { provider: 'none' },
51
+ body: { provider: 'none' },
52
+ };
53
+
54
+ runtime = new SiduriRuntime('companion-a', config as any, {
55
+ brain: mockBrain,
56
+ memory: mockMemory,
57
+ knowledge: mockKnowledge,
58
+ behavior: mockBehavior,
59
+ });
60
+ await runtime.initialize();
61
+
62
+ const runtimes = new Map([['companion-a', runtime]]);
63
+ const created = createApp(runtimes);
64
+ app = created.app;
65
+ });
66
+
67
+ // B0: Fresh companion is empty (no prior claims, no user relationship, no knowledge search on greeting)
68
+ describe('B0 — Fresh companion is empty', () => {
69
+ test('initial state has empty memory and empty directives', async () => {
70
+ const claims = await runtime.memory?.getClaims();
71
+ const directives = await runtime.memory?.getDirectives();
72
+ expect(claims).toEqual([]);
73
+ expect(directives).toEqual([]);
74
+ });
75
+
76
+ test('greeting does not query knowledge or inject prior personal knowledge', async () => {
77
+ const res = await request(app)
78
+ .post('/chat')
79
+ .send({
80
+ companionId: 'companion-a',
81
+ message: 'Hello.',
82
+ history: [],
83
+ });
84
+
85
+ expect(res.status).toBe(200);
86
+ expect(mockKnowledge.search).not.toHaveBeenCalled();
87
+ expect(mockBrain.generatePlan).toHaveBeenCalledWith(
88
+ expect.objectContaining({
89
+ contextPrompt: '',
90
+ recipient: 'OWNER',
91
+ })
92
+ );
93
+ });
94
+ });
95
+
96
+ // B6: Identity and relationship are learned, not inferred (self identity questions do not query external knowledge)
97
+ describe('B6 — Identity and relationship are learned, not inferred', () => {
98
+ test('asking "Who are you?" suppresses knowledge query and asserts self identity without external search', async () => {
99
+ mockBrain.generatePlan.mockResolvedValueOnce({
100
+ speech: 'I am NeutralCompanion.',
101
+ language: 'en',
102
+ });
103
+
104
+ const res = await request(app)
105
+ .post('/chat')
106
+ .send({
107
+ companionId: 'companion-a',
108
+ message: 'Who are you?',
109
+ history: [],
110
+ });
111
+
112
+ expect(res.status).toBe(200);
113
+ // B6 oracle: self identity chat does not query external knowledge
114
+ expect(mockKnowledge.search).not.toHaveBeenCalled();
115
+ expect(mockMemory.searchClaims).toHaveBeenCalled();
116
+ expect(mockBrain.generatePlan).toHaveBeenCalledWith(
117
+ expect.objectContaining({
118
+ contextPrompt: '',
119
+ })
120
+ );
121
+ expect(res.body.response.subtitle_en).toBe('I am NeutralCompanion.');
122
+ });
123
+
124
+ test('asking "Tell me about yourself" suppresses knowledge query', async () => {
125
+ const res = await request(app)
126
+ .post('/chat')
127
+ .send({
128
+ companionId: 'companion-a',
129
+ message: 'Tell me about yourself',
130
+ history: [],
131
+ });
132
+
133
+ expect(res.status).toBe(200);
134
+ expect(mockKnowledge.search).not.toHaveBeenCalled();
135
+ });
136
+ });
137
+ });
@@ -0,0 +1,112 @@
1
+ import { bootCompanion, isDisabled, createBrain, createMemory, createVoice, createKnowledge, createVision, createBehavior, createBody, createHands, createEar, createMouth, createSelf, createObservation } from './boot';
2
+ import { FixtureObservationOrgan } from '@sidurijs/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 bodyNone = createBody({ provider: 'none' });
42
+ expect(bodyNone).toBeUndefined();
43
+
44
+ const bodyLive2d = createBody({ provider: 'live2d', modelPath: './assets/avatar.model3.json' });
45
+ expect(bodyLive2d).toBeDefined();
46
+ expect((bodyLive2d as any).format).toBe('live2d');
47
+
48
+ const bodyVrm = createBody({ provider: 'vrm', modelPath: './assets/avatar.vrm', vrm: { lookAtMode: 'camera' } });
49
+ expect(bodyVrm).toBeDefined();
50
+ expect((bodyVrm as any).format).toBe('vrm');
51
+ expect((bodyVrm as any).vrmOptions?.lookAtMode).toBe('camera');
52
+
53
+ const hands = createHands();
54
+ expect(hands).toBeDefined();
55
+
56
+ const ear = createEar();
57
+ expect(ear).toBeDefined();
58
+
59
+ const mouth = createMouth(undefined, voice);
60
+ expect(mouth).toBeDefined();
61
+
62
+ const observation = createObservation();
63
+ expect(observation).toBeInstanceOf(FixtureObservationOrgan);
64
+ });
65
+
66
+ test('bootCompanion wires all organs and runs migrations', async () => {
67
+ let migrationsRun = false;
68
+ const testConfig = {
69
+ name: 'Test Companion',
70
+ brain: { provider: 'openrouter', apiKey: 'mock-key', model: 'mock-model' },
71
+ memory: { provider: 'none' }, // will use custom mock
72
+ voice: { provider: 'none' },
73
+ vision: { provider: 'none' },
74
+ behavior: { provider: 'none' },
75
+ body: { provider: 'none' },
76
+ hands: { provider: 'none' },
77
+ ear: { provider: 'none' },
78
+ mouth: { provider: 'none' },
79
+ knowledge: { provider: 'none' },
80
+ };
81
+
82
+ const mockObservation = new FixtureObservationOrgan({ analyze: async () => JSON.stringify({ readings: [] }) });
83
+
84
+ const runtime = await bootCompanion('test-comp-1', testConfig as any, {
85
+ observationOrgan: mockObservation,
86
+ });
87
+
88
+ expect(runtime).toBeDefined();
89
+ expect(runtime.id).toBe('test-comp-1');
90
+ expect(runtime.observation).toBe(mockObservation);
91
+ expect(runtime.hands).toBeDefined();
92
+ expect(runtime.ear).toBeDefined();
93
+ expect(runtime.mouth).toBeDefined();
94
+ });
95
+
96
+ test('bootCompanion handles nested organs configuration structure', async () => {
97
+ const nestedConfig = {
98
+ name: 'Nested Companion',
99
+ organs: {
100
+ brain: { provider: 'openrouter', apiKey: 'mock' },
101
+ memory: { provider: 'none' },
102
+ knowledge: { provider: 'none' },
103
+ voice: { provider: 'none' },
104
+ },
105
+ };
106
+
107
+ const runtime = await bootCompanion('nested-comp', nestedConfig as any);
108
+ expect(runtime).toBeDefined();
109
+ expect(runtime.id).toBe('nested-comp');
110
+ expect(runtime.brain).toBeDefined();
111
+ });
112
+ });
package/src/boot.ts ADDED
@@ -0,0 +1,197 @@
1
+ import { OpenAICompatibleBrain, OpenRouterBrain } from '@sidurijs/brain';
2
+ import { SqliteMemoryStore } from '@sidurijs/memory';
3
+ import { VoiceAdapter, VoiceConfig } from '@sidurijs/voice';
4
+ import { UnifiedKnowledgeOrgan, UnifiedKnowledgeConfig } from '@sidurijs/knowledge';
5
+ import { OpenRouterVisionAdapter, OpenRouterVisionConfig } from '@sidurijs/vision';
6
+ import { ActiveSelfCompiler, SqliteSelfRepository } from '@sidurijs/self';
7
+ import { Live2DAdapter, VRMAdapter, NeutralBodyOrganConfig } from '@sidurijs/body';
8
+ import { FixtureObservationOrgan } from '@sidurijs/observation';
9
+ import { DefaultHandsOrgan, DefaultHandsOrganConfig } from '@sidurijs/hands';
10
+ import { DefaultEarOrgan, EarOrganConfig } from '@sidurijs/ear';
11
+ import { DefaultMouthOrgan, DefaultMouthOrganConfig } from '@sidurijs/mouth';
12
+ import { SiduriRuntime } from './runtime';
13
+
14
+ export interface AppBrainConfig {
15
+ provider?: 'openrouter' | 'openai-compatible' | string;
16
+ model?: string;
17
+ apiKey?: string;
18
+ apiKeyEnv?: string;
19
+ baseUrl?: string;
20
+ timeoutMs?: number;
21
+ [key: string]: unknown;
22
+ }
23
+
24
+ export interface AppBehaviorConfig {
25
+ provider?: 'active_self' | 'none' | string;
26
+ preset?: string;
27
+ [key: string]: unknown;
28
+ }
29
+
30
+ export interface AppBootCompanionConfig {
31
+ name: string;
32
+ id?: string;
33
+ brain?: AppBrainConfig;
34
+ voice?: VoiceConfig;
35
+ memory?: { provider?: string; connectionString?: string; maxConnections?: number; dbPath?: string; [key: string]: unknown };
36
+ knowledge?: UnifiedKnowledgeConfig;
37
+ vision?: OpenRouterVisionConfig;
38
+ behavior?: AppBehaviorConfig;
39
+ body?: NeutralBodyOrganConfig;
40
+ hands?: DefaultHandsOrganConfig;
41
+ ear?: EarOrganConfig;
42
+ mouth?: DefaultMouthOrganConfig;
43
+ self?: { dbPath?: string; [key: string]: unknown };
44
+ organs?: Record<string, any>;
45
+ [key: string]: unknown;
46
+ }
47
+
48
+ export interface BootCompanionOptions {
49
+ observationOrgan?: FixtureObservationOrgan;
50
+ }
51
+
52
+ export function isDisabled(config?: { provider?: string }): boolean {
53
+ return !config || config.provider === 'none';
54
+ }
55
+
56
+ export function createBrain(config?: AppBrainConfig) {
57
+ const provider = config?.provider || 'openrouter';
58
+ const defaultKeyEnv = provider === 'openai-compatible' ? 'OPENAI_COMPATIBLE_API_KEY' : 'OPENROUTER_API_KEY';
59
+ const apiKey = config?.apiKey || process.env[config?.apiKeyEnv || defaultKeyEnv] || '';
60
+ if (provider === 'openai-compatible') {
61
+ return new OpenAICompatibleBrain({
62
+ apiKey,
63
+ model: config?.model || 'local-model',
64
+ baseUrl: config?.baseUrl || 'http://127.0.0.1:1234/v1',
65
+ });
66
+ }
67
+ return new OpenRouterBrain({ apiKey, model: config?.model || 'gpt-4o-mini' });
68
+ }
69
+
70
+ export function createVoice(config?: VoiceConfig) {
71
+ return isDisabled(config)
72
+ ? undefined
73
+ : new VoiceAdapter({
74
+ provider: (config?.provider as any) || 'voicevox',
75
+ baseUrl: config?.baseUrl || process.env.VOICEVOX_URL || 'http://localhost:50021',
76
+ speakerId: config?.speakerId || 1,
77
+ ...config,
78
+ });
79
+ }
80
+
81
+ export function createKnowledge(config?: UnifiedKnowledgeConfig) {
82
+ if (isDisabled(config)) return undefined;
83
+ const dbPath = (config?.dbPath as string) || process.env.STORAGE_PATH || process.env.SQLITE_DB_PATH || 'siduri.sqlite';
84
+ return new UnifiedKnowledgeOrgan({
85
+ ...config,
86
+ dbPath,
87
+ lifeDatabase: config?.lifeDatabase ?? true,
88
+ });
89
+ }
90
+
91
+ export function createVision(config?: OpenRouterVisionConfig & { provider?: string }) {
92
+ return isDisabled(config)
93
+ ? undefined
94
+ : new OpenRouterVisionAdapter({
95
+ apiKey: config?.apiKey || process.env.OPENROUTER_API_KEY || '',
96
+ model: config?.model || 'gpt-4-vision',
97
+ ...config,
98
+ });
99
+ }
100
+
101
+ export function createBehavior(config?: AppBehaviorConfig) {
102
+ return isDisabled(config) ? undefined : new ActiveSelfCompiler();
103
+ }
104
+
105
+ export function createBody(config?: NeutralBodyOrganConfig) {
106
+ if (isDisabled(config)) {
107
+ return undefined;
108
+ }
109
+ if (config?.provider === 'vrm' || config?.format === 'vrm') {
110
+ return new VRMAdapter(config);
111
+ }
112
+ return new Live2DAdapter(config);
113
+ }
114
+
115
+ export function createHands(config?: DefaultHandsOrganConfig & { provider?: string }) {
116
+ return isDisabled(config)
117
+ ? new DefaultHandsOrgan()
118
+ : new DefaultHandsOrgan(config);
119
+ }
120
+
121
+ export function createEar(config?: EarOrganConfig & { provider?: string }) {
122
+ return isDisabled(config)
123
+ ? new DefaultEarOrgan()
124
+ : new DefaultEarOrgan(config);
125
+ }
126
+
127
+ export function createMouth(config?: DefaultMouthOrganConfig & { provider?: string }, voice?: any) {
128
+ return isDisabled(config)
129
+ ? new DefaultMouthOrgan({ voice })
130
+ : new DefaultMouthOrgan({ ...config, voice });
131
+ }
132
+
133
+ export function createMemory(config?: { provider?: string; connectionString?: string; maxConnections?: number; dbPath?: string }) {
134
+ if (isDisabled(config)) return undefined;
135
+ const defaultPath = process.env.NODE_ENV === 'test' ? ':memory:' : 'siduri.sqlite';
136
+ return new SqliteMemoryStore({ dbPath: config?.dbPath || process.env.STORAGE_PATH || process.env.SQLITE_DB_PATH || defaultPath });
137
+ }
138
+
139
+ export function createSelf(config?: { dbPath?: string; provider?: string }) {
140
+ if (isDisabled(config)) return undefined;
141
+ const defaultPath = process.env.NODE_ENV === 'test' ? ':memory:' : 'siduri.sqlite';
142
+ return new SqliteSelfRepository({ dbPath: config?.dbPath || process.env.STORAGE_PATH || process.env.SQLITE_DB_PATH || defaultPath });
143
+ }
144
+
145
+ export function createObservation(vision?: any): FixtureObservationOrgan {
146
+ return new FixtureObservationOrgan(
147
+ vision ?? { analyze: async () => JSON.stringify({ readings: [] }) }
148
+ );
149
+ }
150
+
151
+ /**
152
+ * Canonical companion bootstrapper.
153
+ * Wires all 10 organs, runs memory migrations, and initializes the SiduriRuntime.
154
+ */
155
+ export async function bootCompanion(
156
+ id: string,
157
+ config: AppBootCompanionConfig,
158
+ options?: BootCompanionOptions
159
+ ): Promise<SiduriRuntime> {
160
+ const organs = (config as any)?.organs || {};
161
+
162
+ const brain = createBrain(organs.brain || config?.brain);
163
+ const memory = createMemory(organs.memory || (config as any)?.memory);
164
+ const selfRepo = createSelf(organs.self || (config as any)?.self);
165
+ const voice = createVoice(organs.voice || config?.voice);
166
+ const knowledge = createKnowledge(organs.knowledge || config?.knowledge);
167
+ const vision = createVision(organs.vision || config?.vision);
168
+ const behavior = createBehavior(organs.behavior || config?.behavior);
169
+ const body = createBody(organs.body || config?.body);
170
+ const hands = createHands({ ...((organs.hands || config?.hands) as any), knowledge });
171
+ const ear = createEar(organs.ear || config?.ear);
172
+ const mouth = createMouth(organs.mouth || config?.mouth, voice);
173
+ const observation = options?.observationOrgan;
174
+
175
+ if (memory && typeof (memory as any).runMigrations === 'function') {
176
+ await (memory as any).runMigrations().catch((e: any) => console.warn("Migrations warning:", e.message));
177
+ }
178
+
179
+ const runtime = new SiduriRuntime(id, config as any, {
180
+ brain,
181
+ memory,
182
+ voice,
183
+ knowledge,
184
+ vision,
185
+ behavior,
186
+ body,
187
+ hands,
188
+ ear,
189
+ mouth,
190
+ observation,
191
+ self: selfRepo,
192
+ externalKnowledge: (knowledge as any)?.eAdapter ?? knowledge,
193
+ });
194
+
195
+ await runtime.initialize();
196
+ return runtime;
197
+ }