@siduri-x/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 (49) hide show
  1. package/LICENSE +190 -0
  2. package/dist/app.d.ts +9 -0
  3. package/dist/app.js +436 -0
  4. package/dist/auth.d.ts +8 -0
  5. package/dist/auth.js +40 -0
  6. package/dist/auth.test.d.ts +1 -0
  7. package/dist/auth.test.js +48 -0
  8. package/dist/b0-b6.test.d.ts +1 -0
  9. package/dist/b0-b6.test.js +121 -0
  10. package/dist/context-mapper.d.ts +15 -0
  11. package/dist/context-mapper.js +287 -0
  12. package/dist/context-mapper.test.d.ts +1 -0
  13. package/dist/context-mapper.test.js +233 -0
  14. package/dist/index.d.ts +6 -0
  15. package/dist/index.js +167 -0
  16. package/dist/index.test.d.ts +1 -0
  17. package/dist/index.test.js +115 -0
  18. package/dist/runtime.d.ts +1 -0
  19. package/dist/runtime.js +17 -0
  20. package/dist/runtime.test.d.ts +1 -0
  21. package/dist/runtime.test.js +240 -0
  22. package/dist/smoke.test.d.ts +0 -0
  23. package/dist/smoke.test.js +6 -0
  24. package/dist/t4-gating.test.d.ts +1 -0
  25. package/dist/t4-gating.test.js +193 -0
  26. package/dist/t5-experience.test.d.ts +1 -0
  27. package/dist/t5-experience.test.js +156 -0
  28. package/dist/t6-security.test.d.ts +1 -0
  29. package/dist/t6-security.test.js +234 -0
  30. package/dist/t7-release.test.d.ts +1 -0
  31. package/dist/t7-release.test.js +119 -0
  32. package/jest.config.json +5 -0
  33. package/package.json +37 -0
  34. package/src/app.ts +459 -0
  35. package/src/auth.test.ts +57 -0
  36. package/src/auth.ts +49 -0
  37. package/src/b0-b6.test.ts +137 -0
  38. package/src/context-mapper.test.ts +258 -0
  39. package/src/context-mapper.ts +331 -0
  40. package/src/index.test.ts +129 -0
  41. package/src/index.ts +161 -0
  42. package/src/runtime.test.ts +284 -0
  43. package/src/runtime.ts +1 -0
  44. package/src/smoke.test.ts +5 -0
  45. package/src/t4-gating.test.ts +219 -0
  46. package/src/t5-experience.test.ts +175 -0
  47. package/src/t6-security.test.ts +257 -0
  48. package/src/t7-release.test.ts +131 -0
  49. package/tsconfig.json +16 -0
@@ -0,0 +1,331 @@
1
+ import {
2
+ RequestContext,
3
+ AuthorizationRole,
4
+ Channel,
5
+ DiagnosticCode,
6
+ ContextError,
7
+ validateRequestContext,
8
+ } from '@siduri-x/core';
9
+
10
+ export interface ContextMapperOptions {
11
+ endpointPolicy?: 'public' | 'private' | 'operator' | 'direct';
12
+ defaultPublicAudience?: string;
13
+ defaultPrivateAudience?: string;
14
+ defaultOperatorAudience?: string;
15
+ allowAnonymousPublicChat?: boolean;
16
+ }
17
+
18
+ export interface MapRequestContextResult {
19
+ accepted: boolean;
20
+ context?: RequestContext;
21
+ diagnostics?: DiagnosticCode[];
22
+ error?: ContextError;
23
+ }
24
+
25
+ export function mapRequestContext(
26
+ input: any,
27
+ options: ContextMapperOptions = {}
28
+ ): MapRequestContextResult {
29
+ const diagnostics: DiagnosticCode[] = [];
30
+ const endpointPolicy = options.endpointPolicy || 'public';
31
+ const defaultPublicAudience = options.defaultPublicAudience || 'audience-public';
32
+
33
+ if (!input || typeof input !== 'object') {
34
+ return {
35
+ accepted: false,
36
+ error: {
37
+ code: 'MISSING_CONTEXT',
38
+ fields: ['request'],
39
+ },
40
+ };
41
+ }
42
+
43
+ // Check for legacy MASTER_PRIVATE in any audience field or request
44
+ const rawAudience =
45
+ input?.context?.conversation?.audienceId ??
46
+ input?.conversation?.audienceId ??
47
+ input?.audienceId ??
48
+ input?.audience;
49
+
50
+ if (rawAudience === 'MASTER_PRIVATE' || input?.scope === 'MASTER_PRIVATE') {
51
+ if (endpointPolicy === 'public' || input?.channel === 'public' || input?.context?.conversation?.channel === 'public') {
52
+ return {
53
+ accepted: false,
54
+ error: {
55
+ code: 'LEGACY_PERSONAL_AUDIENCE',
56
+ field: 'audienceId',
57
+ correlationId: input?.context?.conversation?.correlationId || input?.correlationId,
58
+ },
59
+ };
60
+ }
61
+ }
62
+
63
+ // 1. If incoming input already has a full neutral context structure
64
+ if (input.context && typeof input.context === 'object') {
65
+ const rawCtx = input.context;
66
+ const companionId = input.companionId || rawCtx.companionId || input.id;
67
+ const correlationId = rawCtx.conversation?.correlationId || input.correlationId;
68
+
69
+ if (!companionId) {
70
+ return {
71
+ accepted: false,
72
+ error: {
73
+ code: 'MISSING_CONTEXT',
74
+ fields: ['companionId'],
75
+ correlationId,
76
+ },
77
+ };
78
+ }
79
+
80
+ // Role cannot select audience or subject
81
+ const rawRole = input.role || rawCtx.actor?.authorizationRole;
82
+ if (input.role && !rawCtx.conversation?.channel && !rawCtx.conversation?.audienceId) {
83
+ return {
84
+ accepted: false,
85
+ error: {
86
+ code: 'AMBIGUOUS_CONTEXT',
87
+ conflicts: ['role_does_not_select_audience', 'role_does_not_select_subject'],
88
+ correlationId,
89
+ },
90
+ };
91
+ }
92
+
93
+ const channel: Channel = rawCtx.conversation?.channel || (endpointPolicy as Channel);
94
+ let audienceId: string | undefined = rawCtx.conversation?.audienceId;
95
+
96
+ if (!audienceId) {
97
+ if (channel === 'public' || endpointPolicy === 'public') {
98
+ audienceId = defaultPublicAudience;
99
+ diagnostics.push('audience_defaulted_by_public_policy');
100
+ } else {
101
+ return {
102
+ accepted: false,
103
+ error: {
104
+ code: 'MISSING_CONTEXT',
105
+ fields: ['conversation.audienceId'],
106
+ correlationId,
107
+ },
108
+ };
109
+ }
110
+ }
111
+
112
+ if (!correlationId) {
113
+ return {
114
+ accepted: false,
115
+ error: {
116
+ code: 'MISSING_CONTEXT',
117
+ fields: ['conversation.correlationId'],
118
+ },
119
+ };
120
+ }
121
+
122
+ const actor = rawCtx.actor;
123
+ if (!actor || typeof actor !== 'object') {
124
+ return {
125
+ accepted: false,
126
+ error: {
127
+ code: 'MISSING_CONTEXT',
128
+ fields: ['actor'],
129
+ correlationId,
130
+ },
131
+ };
132
+ }
133
+
134
+ // Check required capabilities for private/operator/direct channels
135
+ const capabilities: string[] = Array.isArray(actor.capabilities) ? actor.capabilities : [];
136
+ if (channel === 'private' && !capabilities.includes('chat:private')) {
137
+ return {
138
+ accepted: false,
139
+ error: {
140
+ code: 'UNAUTHORIZED_CHANNEL_OR_CAPABILITY',
141
+ message: 'Private channel requires explicit chat:private capability',
142
+ fields: ['actor.capabilities'],
143
+ correlationId,
144
+ },
145
+ };
146
+ }
147
+ if (channel === 'operator' && !capabilities.includes('memory:inspect') && !capabilities.includes('operator:access')) {
148
+ return {
149
+ accepted: false,
150
+ error: {
151
+ code: 'UNAUTHORIZED_CHANNEL_OR_CAPABILITY',
152
+ message: 'Operator channel requires explicit operator capability',
153
+ fields: ['actor.capabilities'],
154
+ correlationId,
155
+ },
156
+ };
157
+ }
158
+
159
+ // Check subject policy
160
+ let subject = rawCtx.subject;
161
+ if (subject) {
162
+ if (subject.subjectId === 'primary_user' || subject === 'primary_user') {
163
+ // Global primary_user is rejected or quarantined
164
+ return {
165
+ accepted: false,
166
+ error: {
167
+ code: 'FORBIDDEN_CONTEXT',
168
+ message: 'Global primary_user subject is forbidden',
169
+ field: 'subject.subjectId',
170
+ correlationId,
171
+ },
172
+ };
173
+ }
174
+ }
175
+
176
+ const constructed: RequestContext = {
177
+ companionId,
178
+ actor: {
179
+ actorId: actor.actorId,
180
+ sessionId: actor.sessionId,
181
+ authorizationRole: actor.authorizationRole,
182
+ capabilities,
183
+ authenticated: Boolean(actor.authenticated),
184
+ },
185
+ conversation: {
186
+ channel,
187
+ audienceId,
188
+ isLive: rawCtx.conversation?.isLive,
189
+ correlationId,
190
+ },
191
+ subject,
192
+ };
193
+
194
+ const validated = validateRequestContext(constructed);
195
+ if (!validated.accepted) {
196
+ return validated;
197
+ }
198
+
199
+ return {
200
+ accepted: true,
201
+ context: validated.context,
202
+ diagnostics: diagnostics.length > 0 ? diagnostics : undefined,
203
+ };
204
+ }
205
+
206
+ // 2. Legacy compatibility envelope mapping
207
+ const companionId = input.companionId || input.id;
208
+ const correlationId = input.correlationId || input.conversation?.correlationId;
209
+
210
+ if (!companionId) {
211
+ return {
212
+ accepted: false,
213
+ error: {
214
+ code: 'MISSING_CONTEXT',
215
+ fields: ['companionId'],
216
+ correlationId,
217
+ },
218
+ };
219
+ }
220
+
221
+ if (companionId === 'default') {
222
+ diagnostics.push('companion_default_mapped_for_bootstrap');
223
+ }
224
+
225
+ // Map legacy role to authorizationRole
226
+ const legacyRole = input.role?.toString().toUpperCase();
227
+ let authRole: AuthorizationRole = 'viewer';
228
+ if (legacyRole === 'OWNER') {
229
+ authRole = 'administrator';
230
+ diagnostics.push('legacy_role_mapped_to_authorization');
231
+ } else if (legacyRole === 'OPERATOR') {
232
+ authRole = 'operator';
233
+ diagnostics.push('legacy_role_mapped_to_authorization');
234
+ } else if (legacyRole === 'VIEWER') {
235
+ authRole = 'viewer';
236
+ diagnostics.push('legacy_role_mapped_to_authorization');
237
+ } else if (input.role) {
238
+ return {
239
+ accepted: false,
240
+ error: {
241
+ code: 'INVALID_CONTEXT',
242
+ field: 'role',
243
+ correlationId,
244
+ },
245
+ };
246
+ }
247
+
248
+ // Check endpoint policy vs legacy request
249
+ if (endpointPolicy === 'private' || endpointPolicy === 'operator' || endpointPolicy === 'direct') {
250
+ // Missing explicit channel, audience, or capability on private/operator endpoint is an error
251
+ const fields: string[] = [];
252
+ if (!input.channel) fields.push('conversation.channel');
253
+ if (!input.audienceId) fields.push('conversation.audienceId');
254
+ if (!input.capabilities && !input.actor?.capabilities) fields.push('actor.capabilities');
255
+ if (!correlationId) fields.push('conversation.correlationId');
256
+
257
+ return {
258
+ accepted: false,
259
+ error: {
260
+ code: 'MISSING_CONTEXT',
261
+ fields: fields.length > 0 ? fields : ['conversation.audienceId', 'actor.capabilities'],
262
+ correlationId,
263
+ },
264
+ };
265
+ }
266
+
267
+ // Ambiguity check: if legacy input specifies role without correlationId or endpoint context for stateful op
268
+ if (input.subject === 'primary_user' || input.subjectId === 'primary_user') {
269
+ return {
270
+ accepted: false,
271
+ error: {
272
+ code: 'FORBIDDEN_CONTEXT',
273
+ message: 'Global primary_user subject is forbidden',
274
+ field: 'subject',
275
+ correlationId,
276
+ },
277
+ };
278
+ }
279
+
280
+ // Missing correlationId for stateful requests
281
+ const finalCorrelationId = correlationId || (input.generateCorrelationId ? `corr-${Date.now()}` : undefined);
282
+ if (!finalCorrelationId) {
283
+ return {
284
+ accepted: false,
285
+ error: {
286
+ code: 'MISSING_CONTEXT',
287
+ fields: ['conversation.correlationId'],
288
+ },
289
+ };
290
+ }
291
+
292
+ // Build anonymous public context
293
+ const actorId = input.actorId || input.actor?.actorId || 'anonymous-session-a';
294
+ const sessionId = input.sessionId || input.actor?.sessionId || 'session-a';
295
+ if (!input.actorId && !input.actor?.actorId) {
296
+ diagnostics.push('anonymous_session_generated');
297
+ }
298
+
299
+ const channel: Channel = 'public';
300
+ const audienceId = defaultPublicAudience;
301
+ diagnostics.push('audience_defaulted_by_public_policy');
302
+
303
+ const capabilities = authRole === 'administrator'
304
+ ? ['chat:public', 'admin:access']
305
+ : authRole === 'operator'
306
+ ? ['chat:public', 'operator:access']
307
+ : ['chat:public'];
308
+
309
+ const mappedContext: RequestContext = {
310
+ companionId,
311
+ actor: {
312
+ actorId,
313
+ sessionId,
314
+ authorizationRole: authRole,
315
+ capabilities,
316
+ authenticated: Boolean(input.authenticated),
317
+ },
318
+ conversation: {
319
+ channel,
320
+ audienceId,
321
+ correlationId: finalCorrelationId,
322
+ },
323
+ subject: undefined, // Anonymous public chat has no subject
324
+ };
325
+
326
+ return {
327
+ accepted: true,
328
+ context: mappedContext,
329
+ diagnostics: diagnostics.length > 0 ? diagnostics : undefined,
330
+ };
331
+ }
@@ -0,0 +1,129 @@
1
+ import request from 'supertest';
2
+ import { createApp } from './app';
3
+
4
+ describe('API Boundary Context Validation (P2 Route Integration)', () => {
5
+ const fakeRuntime: any = {
6
+ handleUserMessage: jest.fn().mockResolvedValue({
7
+ response: { subtitle_en: 'Hello there' },
8
+ metadata: {},
9
+ }),
10
+ };
11
+
12
+ let app: any;
13
+ let runtimes: Map<string, any>;
14
+
15
+ beforeEach(() => {
16
+ runtimes = new Map([['companion-a', fakeRuntime]]);
17
+ const created = createApp(runtimes);
18
+ app = created.app;
19
+ fakeRuntime.handleUserMessage.mockClear();
20
+ });
21
+
22
+ test('accepts valid anonymous chat request and maps through API boundary', async () => {
23
+ const res = await request(app)
24
+ .post('/chat')
25
+ .send({
26
+ id: 'companion-a',
27
+ message: 'Hello neutral world',
28
+ history: [],
29
+ });
30
+
31
+ expect(res.status).toBe(200);
32
+ expect(fakeRuntime.handleUserMessage).toHaveBeenCalledWith(
33
+ 'Hello neutral world',
34
+ 'VIEWER',
35
+ []
36
+ );
37
+ });
38
+
39
+ test('accepts neutral context chat envelope at /chat route', async () => {
40
+ const res = await request(app)
41
+ .post('/chat')
42
+ .send({
43
+ companionId: 'companion-a',
44
+ context: {
45
+ actor: {
46
+ actorId: 'actor-a',
47
+ sessionId: 'session-a',
48
+ authorizationRole: 'viewer',
49
+ capabilities: ['chat:public'],
50
+ authenticated: false,
51
+ },
52
+ conversation: {
53
+ channel: 'public',
54
+ audienceId: 'audience-public',
55
+ correlationId: 'corr-route-1',
56
+ },
57
+ },
58
+ message: 'Hello structured context',
59
+ history: [],
60
+ });
61
+
62
+ expect(res.status).toBe(200);
63
+ expect(fakeRuntime.handleUserMessage).toHaveBeenCalledWith(
64
+ 'Hello structured context',
65
+ 'VIEWER',
66
+ []
67
+ );
68
+ });
69
+
70
+ test('rejects MASTER_PRIVATE in public request with 400 and structured error', async () => {
71
+ const res = await request(app)
72
+ .post('/chat')
73
+ .send({
74
+ companionId: 'companion-a',
75
+ context: {
76
+ actor: {
77
+ actorId: 'actor-a',
78
+ sessionId: 'session-a',
79
+ authorizationRole: 'viewer',
80
+ capabilities: ['chat:public'],
81
+ authenticated: false,
82
+ },
83
+ conversation: {
84
+ channel: 'public',
85
+ audienceId: 'MASTER_PRIVATE',
86
+ correlationId: 'corr-err-1',
87
+ },
88
+ },
89
+ message: 'Forbidden audience test',
90
+ });
91
+
92
+ expect(res.status).toBe(400);
93
+ expect(res.body.accepted).toBe(false);
94
+ expect(res.body.error.code).toBe('LEGACY_PERSONAL_AUDIENCE');
95
+ expect(fakeRuntime.handleUserMessage).not.toHaveBeenCalled();
96
+ });
97
+
98
+ test('rejects global primary_user subject with 400 and structured error', async () => {
99
+ const res = await request(app)
100
+ .post('/chat')
101
+ .send({
102
+ companionId: 'companion-a',
103
+ context: {
104
+ actor: {
105
+ actorId: 'actor-a',
106
+ sessionId: 'session-a',
107
+ authorizationRole: 'viewer',
108
+ capabilities: ['chat:public'],
109
+ authenticated: true,
110
+ },
111
+ conversation: {
112
+ channel: 'public',
113
+ audienceId: 'audience-public',
114
+ correlationId: 'corr-err-2',
115
+ },
116
+ subject: {
117
+ subjectId: 'primary_user',
118
+ kind: 'actor',
119
+ },
120
+ },
121
+ message: 'Forbidden primary user test',
122
+ });
123
+
124
+ expect(res.status).toBe(400);
125
+ expect(res.body.accepted).toBe(false);
126
+ expect(res.body.error.code).toBe('FORBIDDEN_CONTEXT');
127
+ expect(fakeRuntime.handleUserMessage).not.toHaveBeenCalled();
128
+ });
129
+ });
package/src/index.ts ADDED
@@ -0,0 +1,161 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { Express } from 'express';
4
+ import { createApp, AppInstance } from './app';
5
+ import { SiduriRuntime } from './runtime';
6
+ import { OpenAICompatibleBrain, OpenRouterBrain } from '@siduri-x/brain';
7
+ import { PostgresMemoryOrgan } from '@siduri-x/memory';
8
+ import { VoicevoxAdapter } from '@siduri-x/voice';
9
+ import { EKnowledgeAdapter } from '@siduri-x/knowledge';
10
+ import { OpenRouterVisionAdapter } from '@siduri-x/vision';
11
+ import { ActiveSelfCompiler } from '@siduri-x/behavior';
12
+ import { Live2DAdapter } from '@siduri-x/body';
13
+ import { FixtureObservationOrgan } from '@siduri-x/observation';
14
+
15
+ export { createApp, AppInstance };
16
+ export * from './context-mapper';
17
+
18
+ const runtimes = new Map<string, SiduriRuntime>();
19
+ const instance: AppInstance = createApp(runtimes);
20
+ export const app: Express = instance.app;
21
+ export default app;
22
+
23
+ function createBrain(config: any) {
24
+ const provider = config.provider || 'openrouter';
25
+ const defaultKeyEnv = provider === 'openai-compatible' ? 'OPENAI_COMPATIBLE_API_KEY' : 'OPENROUTER_API_KEY';
26
+ const apiKey = config.apiKey || process.env[config.apiKeyEnv || defaultKeyEnv] || '';
27
+ if (provider === 'openai-compatible') {
28
+ return new OpenAICompatibleBrain({
29
+ apiKey,
30
+ model: config.model || 'local-model',
31
+ baseUrl: config.baseUrl || 'http://127.0.0.1:1234/v1',
32
+ });
33
+ }
34
+ return new OpenRouterBrain({ apiKey, model: config.model || 'gpt-4o-mini' });
35
+ }
36
+
37
+ function isDisabled(config: any): boolean {
38
+ return !config || config.provider === 'none';
39
+ }
40
+
41
+ function createVoice(config: any) {
42
+ return isDisabled(config)
43
+ ? undefined
44
+ : new VoicevoxAdapter({ baseUrl: process.env.VOICEVOX_URL || 'http://localhost:50021', speakerId: config.speakerId || 1 });
45
+ }
46
+
47
+ function createKnowledge(config: any) {
48
+ if (isDisabled(config)) return undefined;
49
+ if (!config?.packPath && !config?.registryUrl && !config?.baseUrl && !config?.hubUrl) {
50
+ return undefined;
51
+ }
52
+ return new EKnowledgeAdapter(config);
53
+ }
54
+
55
+ function createVision(config: any) {
56
+ return isDisabled(config)
57
+ ? undefined
58
+ : new OpenRouterVisionAdapter({ apiKey: process.env.OPENROUTER_API_KEY || '', model: config.model || 'gpt-4-vision' });
59
+ }
60
+
61
+ function createBehavior(config: any) {
62
+ return isDisabled(config) ? undefined : new ActiveSelfCompiler();
63
+ }
64
+
65
+ function createBody(config: any) {
66
+ return isDisabled(config)
67
+ ? undefined
68
+ : new Live2DAdapter(config);
69
+ }
70
+
71
+ const PORT = process.env.PORT || 3001;
72
+
73
+ const defaultCompanionConfig = {
74
+ id: 'default',
75
+ name: 'Siduri',
76
+ brain: { provider: 'openrouter', model: 'gpt-4o-mini' },
77
+ voice: { provider: 'voicevox', speakerId: 1 },
78
+ memory: { provider: 'postgres' },
79
+ knowledge: {
80
+ provider: (process.env.SIDURI_KNOWLEDGE_PROVIDER as 'e-knowledge' | 'e-remote' | 'e-hub') || 'e-knowledge',
81
+ packPath: process.env.SIDURI_KNOWLEDGE_PACK || '',
82
+ registryUrl: process.env.SIDURI_KNOWLEDGE_REGISTRY_URL || '',
83
+ packId: process.env.SIDURI_KNOWLEDGE_PACK_ID || '',
84
+ timeoutMs: Number(process.env.SIDURI_KNOWLEDGE_TIMEOUT_MS || 5000),
85
+ preferredMode: (process.env.SIDURI_KNOWLEDGE_MODE as 'lexical' | 'semantic' | 'hybrid') || 'lexical',
86
+ },
87
+ behavior: { provider: 'active_self' },
88
+ body: {
89
+ provider: 'live2d',
90
+ },
91
+ vision: { provider: 'openrouter', model: 'gpt-4-vision' }
92
+ };
93
+
94
+ async function loadCompanionConfig() {
95
+ const configPath = process.env.SIDURI_CONFIG || path.resolve(process.cwd(), 'siduri.config.json');
96
+ let fileConfig: Record<string, any> = {};
97
+ try {
98
+ fileConfig = JSON.parse(await readFile(configPath, 'utf8')) as Record<string, any>;
99
+ console.log(`Loaded companion configuration from ${configPath}`);
100
+ } catch (error: any) {
101
+ if (error?.code !== 'ENOENT') throw new Error(`Unable to read ${configPath}: ${error.message}`);
102
+ console.log(`No ${configPath} found; using environment/default configuration.`);
103
+ }
104
+
105
+ const config: any = {
106
+ ...defaultCompanionConfig,
107
+ ...fileConfig,
108
+ id: fileConfig.id || defaultCompanionConfig.id,
109
+ brain: { ...defaultCompanionConfig.brain, ...fileConfig.brain },
110
+ voice: { ...defaultCompanionConfig.voice, ...fileConfig.voice },
111
+ memory: { ...defaultCompanionConfig.memory, ...fileConfig.memory },
112
+ knowledge: { ...defaultCompanionConfig.knowledge, ...fileConfig.knowledge },
113
+ behavior: { ...defaultCompanionConfig.behavior, ...fileConfig.behavior },
114
+ body: { ...defaultCompanionConfig.body, ...fileConfig.body },
115
+ vision: { ...defaultCompanionConfig.vision, ...fileConfig.vision },
116
+ };
117
+
118
+ if (process.env.SIDURI_KNOWLEDGE_PROVIDER) config.knowledge.provider = process.env.SIDURI_KNOWLEDGE_PROVIDER;
119
+ if (process.env.SIDURI_KNOWLEDGE_PACK) config.knowledge.packPath = process.env.SIDURI_KNOWLEDGE_PACK;
120
+ if (process.env.SIDURI_KNOWLEDGE_REGISTRY_URL) config.knowledge.registryUrl = process.env.SIDURI_KNOWLEDGE_REGISTRY_URL;
121
+ if (process.env.SIDURI_KNOWLEDGE_PACK_ID) config.knowledge.packId = process.env.SIDURI_KNOWLEDGE_PACK_ID;
122
+ if (process.env.SIDURI_KNOWLEDGE_MODE) config.knowledge.preferredMode = process.env.SIDURI_KNOWLEDGE_MODE;
123
+ return config;
124
+ }
125
+
126
+ async function bootDefaultCompanion() {
127
+ if (runtimes.has('default')) return;
128
+ console.log("Booting default companion...");
129
+ const config: any = await loadCompanionConfig();
130
+
131
+ const brain = createBrain(config.brain);
132
+ const memory = new PostgresMemoryOrgan({ connectionString: process.env.DATABASE_URL || 'postgresql://postgres:postgres@localhost:5432/siduri' });
133
+ const voice = createVoice(config.voice);
134
+ const knowledge = createKnowledge(config.knowledge);
135
+ const vision = createVision(config.vision);
136
+ const observation = new FixtureObservationOrgan(
137
+ vision ?? { analyze: async () => JSON.stringify({ readings: [] }) },
138
+ );
139
+ instance.setObservationOrgan(observation);
140
+ const behavior = createBehavior(config.behavior);
141
+ const body = createBody(config.body);
142
+
143
+ await memory.runMigrations().catch(e => console.warn("Migrations warning:", e.message));
144
+
145
+ const runtime = new SiduriRuntime('default', config as any, { brain, memory, voice, knowledge, vision, behavior, body });
146
+ await runtime.initialize();
147
+
148
+ runtimes.set('default', runtime);
149
+ console.log("Default companion booted successfully.");
150
+ }
151
+
152
+ if (process.env.NODE_ENV !== 'test') {
153
+ bootDefaultCompanion().then(() => {
154
+ app.listen(PORT, () => {
155
+ console.log(`Siduri-Y API running on port ${PORT}`);
156
+ });
157
+ }).catch(e => {
158
+ console.error("Failed to boot default companion:", e);
159
+ process.exit(1);
160
+ });
161
+ }