@siduri-x/body 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.
@@ -0,0 +1,36 @@
1
+ import { BodyOrgan, ExperienceAdapter, ExperienceEvent, ExperienceAdapterResult } from '@siduri-x/core';
2
+ export type BodyState = 'idle' | 'speaking' | 'acting';
3
+ export interface BodySnapshot {
4
+ state: BodyState;
5
+ currentExpression: string;
6
+ lastSpeechId: string | null;
7
+ lastAction: string | null;
8
+ lastText?: string;
9
+ lastLanguage?: string;
10
+ updatedAt: number;
11
+ }
12
+ export interface NeutralBodyOrganConfig {
13
+ initialExpression?: string;
14
+ [key: string]: unknown;
15
+ }
16
+ export type Live2DAdapterConfig = NeutralBodyOrganConfig;
17
+ export declare class NeutralBodyOrgan implements BodyOrgan, ExperienceAdapter {
18
+ readonly kind: "avatar";
19
+ currentExpression: string;
20
+ lastSpeechId: string | null;
21
+ lastAction: string | null;
22
+ lastText?: string;
23
+ lastLanguage?: string;
24
+ state: BodyState;
25
+ lastEvent: ExperienceEvent | null;
26
+ updatedAt: number;
27
+ constructor(config?: NeutralBodyOrganConfig);
28
+ setExpression(expression: string): void;
29
+ speak(speechId: string, text?: string, language?: string): void;
30
+ act(action: string): void;
31
+ completeAction(): void;
32
+ getSnapshot(): BodySnapshot;
33
+ handleEvent(event: ExperienceEvent): Promise<ExperienceAdapterResult>;
34
+ cleanup(): void;
35
+ }
36
+ export declare const Live2DAdapter: typeof NeutralBodyOrgan;
package/dist/index.js ADDED
@@ -0,0 +1,112 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Live2DAdapter = exports.NeutralBodyOrgan = void 0;
4
+ const core_1 = require("@siduri-x/core");
5
+ class NeutralBodyOrgan {
6
+ kind = 'avatar';
7
+ currentExpression = 'neutral';
8
+ lastSpeechId = null;
9
+ lastAction = null;
10
+ lastText;
11
+ lastLanguage;
12
+ state = 'idle';
13
+ lastEvent = null;
14
+ updatedAt = Date.now();
15
+ constructor(config = {}) {
16
+ if (config.initialExpression) {
17
+ this.currentExpression = config.initialExpression;
18
+ }
19
+ }
20
+ setExpression(expression) {
21
+ this.currentExpression = expression;
22
+ this.updatedAt = Date.now();
23
+ }
24
+ speak(speechId, text, language) {
25
+ this.lastSpeechId = speechId;
26
+ this.lastText = text;
27
+ this.lastLanguage = language;
28
+ this.state = 'speaking';
29
+ this.updatedAt = Date.now();
30
+ }
31
+ act(action) {
32
+ this.lastAction = action;
33
+ this.state = 'acting';
34
+ this.updatedAt = Date.now();
35
+ }
36
+ completeAction() {
37
+ this.state = 'idle';
38
+ this.updatedAt = Date.now();
39
+ }
40
+ getSnapshot() {
41
+ return {
42
+ state: this.state,
43
+ currentExpression: this.currentExpression,
44
+ lastSpeechId: this.lastSpeechId,
45
+ lastAction: this.lastAction,
46
+ lastText: this.lastText,
47
+ lastLanguage: this.lastLanguage,
48
+ updatedAt: this.updatedAt,
49
+ };
50
+ }
51
+ async handleEvent(event) {
52
+ const validation = (0, core_1.validateExperienceEvent)(event);
53
+ if (!validation.valid) {
54
+ return {
55
+ accepted: false,
56
+ eventId: event?.eventId || '',
57
+ lifecycle: 'FAILED',
58
+ error: validation.error,
59
+ reason: 'INVALID_EVENT_ENVELOPE',
60
+ };
61
+ }
62
+ if (event.kind !== 'avatar') {
63
+ return {
64
+ accepted: false,
65
+ eventId: event.eventId,
66
+ lifecycle: 'FAILED',
67
+ error: `Body adapter received incompatible event kind: ${event.kind}`,
68
+ reason: 'INCOMPATIBLE_EVENT_KIND',
69
+ };
70
+ }
71
+ if (event.approval !== 'APPROVED') {
72
+ return {
73
+ accepted: false,
74
+ eventId: event.eventId,
75
+ lifecycle: 'FAILED',
76
+ error: 'Event is not APPROVED',
77
+ reason: 'APPROVAL_REQUIRED',
78
+ };
79
+ }
80
+ this.lastEvent = event;
81
+ if (event.expression) {
82
+ this.setExpression(event.expression);
83
+ }
84
+ if (event.action) {
85
+ this.act(event.action);
86
+ }
87
+ if (event.text) {
88
+ this.lastText = event.text;
89
+ this.lastLanguage = event.language;
90
+ }
91
+ return {
92
+ accepted: true,
93
+ eventId: event.eventId,
94
+ lifecycle: 'STARTED',
95
+ metadata: {
96
+ expression: this.currentExpression,
97
+ action: this.lastAction,
98
+ state: this.state,
99
+ companionId: event.companionId,
100
+ correlationId: event.correlationId,
101
+ },
102
+ };
103
+ }
104
+ cleanup() {
105
+ this.state = 'idle';
106
+ this.lastEvent = null;
107
+ this.updatedAt = Date.now();
108
+ }
109
+ }
110
+ exports.NeutralBodyOrgan = NeutralBodyOrgan;
111
+ // Backward-compatible alias
112
+ exports.Live2DAdapter = NeutralBodyOrgan;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,174 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const index_1 = require("./index");
4
+ describe('NeutralBodyOrgan', () => {
5
+ let organ;
6
+ beforeEach(() => {
7
+ organ = new index_1.NeutralBodyOrgan();
8
+ });
9
+ afterEach(() => {
10
+ organ.cleanup();
11
+ });
12
+ test('constructs with default or custom initial expression with zero external config', () => {
13
+ expect(organ.currentExpression).toBe('neutral');
14
+ expect(organ.state).toBe('idle');
15
+ expect(organ.lastSpeechId).toBeNull();
16
+ expect(organ.lastAction).toBeNull();
17
+ const customOrgan = new index_1.NeutralBodyOrgan({ initialExpression: 'happy' });
18
+ expect(customOrgan.currentExpression).toBe('happy');
19
+ customOrgan.cleanup();
20
+ });
21
+ test('Live2DAdapter is exported as a compatible alias', () => {
22
+ const adapter = new index_1.Live2DAdapter();
23
+ expect(adapter).toBeInstanceOf(index_1.NeutralBodyOrgan);
24
+ adapter.cleanup();
25
+ });
26
+ test('tracks body state machine transitions locally', () => {
27
+ organ.setExpression('smile');
28
+ expect(organ.currentExpression).toBe('smile');
29
+ organ.speak('speech_101', 'Hello world', 'en');
30
+ expect(organ.lastSpeechId).toBe('speech_101');
31
+ expect(organ.lastText).toBe('Hello world');
32
+ expect(organ.lastLanguage).toBe('en');
33
+ expect(organ.state).toBe('speaking');
34
+ organ.act('wave_hand');
35
+ expect(organ.lastAction).toBe('wave_hand');
36
+ expect(organ.state).toBe('acting');
37
+ organ.completeAction();
38
+ expect(organ.state).toBe('idle');
39
+ const snapshot = organ.getSnapshot();
40
+ expect(snapshot.state).toBe('idle');
41
+ expect(snapshot.currentExpression).toBe('smile');
42
+ expect(snapshot.lastSpeechId).toBe('speech_101');
43
+ expect(snapshot.lastAction).toBe('wave_hand');
44
+ expect(snapshot.lastText).toBe('Hello world');
45
+ expect(snapshot.lastLanguage).toBe('en');
46
+ });
47
+ test('handleEvent processes valid approved avatar event', async () => {
48
+ const res = await organ.handleEvent({
49
+ eventId: 'evt-avatar-1',
50
+ companionId: 'companion-test',
51
+ responseId: 'resp-100',
52
+ correlationId: 'corr-100',
53
+ channel: 'public',
54
+ audienceId: 'audience-public',
55
+ approval: 'APPROVED',
56
+ kind: 'avatar',
57
+ lifecycle: 'STARTED',
58
+ evidenceIds: [],
59
+ expression: 'surprised',
60
+ action: 'nod',
61
+ text: 'Affirmative.',
62
+ language: 'en',
63
+ createdAt: new Date().toISOString(),
64
+ });
65
+ expect(res.accepted).toBe(true);
66
+ expect(res.lifecycle).toBe('STARTED');
67
+ expect(res.metadata).toMatchObject({
68
+ expression: 'surprised',
69
+ action: 'nod',
70
+ state: 'acting',
71
+ companionId: 'companion-test',
72
+ correlationId: 'corr-100',
73
+ });
74
+ expect(organ.currentExpression).toBe('surprised');
75
+ expect(organ.lastAction).toBe('nod');
76
+ expect(organ.lastText).toBe('Affirmative.');
77
+ });
78
+ test('handleEvent rejects unapproved events or incompatible kinds', async () => {
79
+ const unapprovedRes = await organ.handleEvent({
80
+ eventId: 'evt-avatar-2',
81
+ companionId: 'companion-test',
82
+ responseId: 'resp-100',
83
+ correlationId: 'corr-100',
84
+ channel: 'public',
85
+ audienceId: 'audience-public',
86
+ approval: 'STAGED',
87
+ kind: 'avatar',
88
+ lifecycle: 'STARTED',
89
+ evidenceIds: [],
90
+ createdAt: new Date().toISOString(),
91
+ });
92
+ expect(unapprovedRes.accepted).toBe(false);
93
+ expect(unapprovedRes.error).toContain('APPROVED');
94
+ const incompatibleRes = await organ.handleEvent({
95
+ eventId: 'evt-avatar-3',
96
+ companionId: 'companion-test',
97
+ responseId: 'resp-100',
98
+ correlationId: 'corr-100',
99
+ channel: 'public',
100
+ audienceId: 'audience-public',
101
+ approval: 'APPROVED',
102
+ kind: 'voice',
103
+ lifecycle: 'STARTED',
104
+ evidenceIds: [],
105
+ createdAt: new Date().toISOString(),
106
+ });
107
+ expect(incompatibleRes.accepted).toBe(false);
108
+ expect(incompatibleRes.reason).toBe('INCOMPATIBLE_EVENT_KIND');
109
+ });
110
+ test('handleEvent rejects invalid envelope', async () => {
111
+ const invalidRes = await organ.handleEvent({});
112
+ expect(invalidRes.accepted).toBe(false);
113
+ expect(invalidRes.reason).toBe('INVALID_EVENT_ENVELOPE');
114
+ });
115
+ test('requires no open network sockets or transport listeners', () => {
116
+ // Verifies the object has no transport handles
117
+ expect(organ.wss).toBeUndefined();
118
+ expect(organ.vts).toBeUndefined();
119
+ expect(organ.clients).toBeUndefined();
120
+ });
121
+ test('handles sequential avatar events where latest valid event updates active state', async () => {
122
+ await organ.handleEvent({
123
+ eventId: 'evt-avatar-10',
124
+ companionId: 'companion-test',
125
+ responseId: 'resp-101',
126
+ correlationId: 'corr-101',
127
+ channel: 'public',
128
+ audienceId: 'audience-public',
129
+ approval: 'APPROVED',
130
+ kind: 'avatar',
131
+ lifecycle: 'STARTED',
132
+ evidenceIds: [],
133
+ expression: 'happy',
134
+ action: 'wave',
135
+ createdAt: new Date().toISOString(),
136
+ });
137
+ expect(organ.currentExpression).toBe('happy');
138
+ expect(organ.lastAction).toBe('wave');
139
+ // Newer event arrives
140
+ await organ.handleEvent({
141
+ eventId: 'evt-avatar-11',
142
+ companionId: 'companion-test',
143
+ responseId: 'resp-102',
144
+ correlationId: 'corr-102',
145
+ channel: 'public',
146
+ audienceId: 'audience-public',
147
+ approval: 'APPROVED',
148
+ kind: 'avatar',
149
+ lifecycle: 'STARTED',
150
+ evidenceIds: [],
151
+ expression: 'surprised',
152
+ action: 'nod',
153
+ createdAt: new Date().toISOString(),
154
+ });
155
+ expect(organ.currentExpression).toBe('surprised');
156
+ expect(organ.lastAction).toBe('nod');
157
+ });
158
+ test('speech synchronization sets speaking state and reset returns to idle', () => {
159
+ organ.speak('speech_v1', 'Konnichiwa', 'ja');
160
+ expect(organ.state).toBe('speaking');
161
+ expect(organ.lastSpeechId).toBe('speech_v1');
162
+ organ.completeAction();
163
+ expect(organ.state).toBe('idle');
164
+ });
165
+ test('handles unknown expressions and actions with graceful baseline fallbacks', () => {
166
+ organ.setExpression('unknown_custom_expression');
167
+ expect(organ.currentExpression).toBe('unknown_custom_expression');
168
+ organ.act('unknown_action');
169
+ expect(organ.lastAction).toBe('unknown_action');
170
+ expect(organ.state).toBe('acting');
171
+ organ.completeAction();
172
+ expect(organ.state).toBe('idle');
173
+ });
174
+ });
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@siduri-x/body",
3
+ "organType": "body",
4
+ "version": "1.0.0",
5
+ "displayName": "Body (Live2D & Avatar State)",
6
+ "description": "Renderer-agnostic avatar expression and embodiment event adapter",
7
+ "entrypoint": "./dist/index.js",
8
+ "factory": "NeutralBodyOrgan",
9
+ "configKey": "body",
10
+ "configSchema": {
11
+ "type": "object",
12
+ "required": [
13
+ "provider"
14
+ ],
15
+ "properties": {
16
+ "provider": {
17
+ "type": "string",
18
+ "enum": [
19
+ "live2d",
20
+ "none"
21
+ ]
22
+ },
23
+ "initialExpression": {
24
+ "type": "string",
25
+ "default": "neutral"
26
+ }
27
+ }
28
+ },
29
+ "environment": [],
30
+ "services": [],
31
+ "database": null,
32
+ "healthCheck": null
33
+ }
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@siduri-x/body",
3
+ "version": "1.0.0",
4
+ "main": "dist/index.js",
5
+ "types": "dist/index.d.ts",
6
+ "scripts": {
7
+ "build": "tsc",
8
+ "dev": "tsc -w",
9
+ "test": "jest --config jest.config.json"
10
+ },
11
+ "dependencies": {
12
+ "@siduri-x/core": "workspace:*"
13
+ },
14
+ "devDependencies": {
15
+ "@types/jest": "^29.5.14",
16
+ "@types/node": "^26.2.0",
17
+ "jest": "^29.7.0",
18
+ "ts-jest": "^29.4.12",
19
+ "typescript": "^5.3.3"
20
+ },
21
+ "description": "Body organ for embodiment, avatar expression state machine, and physical animation",
22
+ "license": "UNLICENSED",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "https://github.com/vxnuslabs/siduri-y",
26
+ "directory": "packages/organs/body"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "engines": {
32
+ "node": ">=20"
33
+ },
34
+ "files": [
35
+ "dist",
36
+ "organ-manifest.json",
37
+ "README.md",
38
+ "LICENSE"
39
+ ],
40
+ "exports": {
41
+ ".": {
42
+ "types": "./dist/index.d.ts",
43
+ "import": "./dist/index.js",
44
+ "default": "./dist/index.js"
45
+ },
46
+ "./organ-manifest.json": "./organ-manifest.json"
47
+ }
48
+ }