@sidurijs/ear 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,18 @@
1
+ import { EarOrgan, HardenedEarPerception, EarLimitsConfig, EarIngestOptions } from '@sidurijs/core';
2
+ export interface EarOrganConfig extends EarLimitsConfig {
3
+ defaultSource?: string;
4
+ transcriber?: (audio: Uint8Array, signal?: AbortSignal) => Promise<string>;
5
+ }
6
+ export declare function detectAudioSignature(bytes: Uint8Array): string | undefined;
7
+ export declare class DefaultEarOrgan implements EarOrgan {
8
+ private readonly defaultSource;
9
+ private readonly transcriber?;
10
+ private readonly maxTextLength;
11
+ private readonly maxAudioBytes;
12
+ private readonly allowedAudioMimeTypes;
13
+ private readonly maxDurationSeconds;
14
+ private readonly transcriptionTimeoutMs;
15
+ constructor(config?: EarOrganConfig);
16
+ listen(source: string | undefined, payload: unknown, options?: EarIngestOptions): Promise<HardenedEarPerception>;
17
+ transcribeAudio(audio: Uint8Array): Promise<string>;
18
+ }
package/dist/index.js ADDED
@@ -0,0 +1,207 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DefaultEarOrgan = void 0;
4
+ exports.detectAudioSignature = detectAudioSignature;
5
+ function detectAudioSignature(bytes) {
6
+ if (!bytes || bytes.length < 4)
7
+ return undefined;
8
+ // RIFF (WAV) - starts with 'RIFF' and at offset 8 has 'WAVE'
9
+ if (bytes[0] === 0x52 &&
10
+ bytes[1] === 0x49 &&
11
+ bytes[2] === 0x46 &&
12
+ bytes[3] === 0x46) {
13
+ if (bytes.length >= 12 && bytes[8] === 0x57 && bytes[9] === 0x41 && bytes[10] === 0x56 && bytes[11] === 0x45) {
14
+ return 'audio/wav';
15
+ }
16
+ return 'audio/wav';
17
+ }
18
+ // ID3 (MP3 with ID3 header) - starts with 'ID3'
19
+ if (bytes[0] === 0x49 && bytes[1] === 0x44 && bytes[2] === 0x33) {
20
+ return 'audio/mpeg';
21
+ }
22
+ // MP3 Sync word (0xFF, 0xFB/0xF3/0xF2)
23
+ if (bytes[0] === 0xff && (bytes[1] & 0xe0) === 0xe0) {
24
+ return 'audio/mpeg';
25
+ }
26
+ // OGG (OggS)
27
+ if (bytes[0] === 0x4f && bytes[1] === 0x67 && bytes[2] === 0x67 && bytes[3] === 0x53) {
28
+ return 'audio/ogg';
29
+ }
30
+ // FLAC (fLaC)
31
+ if (bytes[0] === 0x66 && bytes[1] === 0x4c && bytes[2] === 0x61 && bytes[3] === 0x43) {
32
+ return 'audio/flac';
33
+ }
34
+ // WebM / EBML (0x1A, 0x45, 0xDF, 0xA3)
35
+ if (bytes[0] === 0x1a && bytes[1] === 0x45 && bytes[2] === 0xdf && bytes[3] === 0xa3) {
36
+ return 'audio/webm';
37
+ }
38
+ return undefined;
39
+ }
40
+ class DefaultEarOrgan {
41
+ defaultSource;
42
+ transcriber;
43
+ maxTextLength;
44
+ maxAudioBytes;
45
+ allowedAudioMimeTypes;
46
+ maxDurationSeconds;
47
+ transcriptionTimeoutMs;
48
+ constructor(config = {}) {
49
+ this.defaultSource = config.defaultSource || 'text_chat';
50
+ this.transcriber = config.transcriber;
51
+ this.maxTextLength = config.maxTextLength ?? 4000;
52
+ this.maxAudioBytes = config.maxAudioBytes ?? 10 * 1024 * 1024; // 10MB default
53
+ this.allowedAudioMimeTypes = config.allowedAudioMimeTypes ?? [
54
+ 'audio/wav',
55
+ 'audio/wave',
56
+ 'audio/x-wav',
57
+ 'audio/mpeg',
58
+ 'audio/mp3',
59
+ 'audio/ogg',
60
+ 'audio/webm',
61
+ 'audio/flac',
62
+ ];
63
+ this.maxDurationSeconds = config.maxDurationSeconds ?? 300; // 5 mins
64
+ this.transcriptionTimeoutMs = config.transcriptionTimeoutMs ?? 15_000;
65
+ }
66
+ async listen(source = this.defaultSource, payload, options) {
67
+ const id = `ear-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
68
+ const timestamp = new Date().toISOString();
69
+ const context = options?.context;
70
+ // 1. Text payload validation & normalization
71
+ if (typeof payload === 'string') {
72
+ if (payload.length > this.maxTextLength) {
73
+ throw new Error(`Ear text input exceeds maximum allowed length of ${this.maxTextLength} characters (received ${payload.length})`);
74
+ }
75
+ return {
76
+ id,
77
+ source: source || this.defaultSource,
78
+ text: payload,
79
+ timestamp,
80
+ modality: 'text',
81
+ rawConfidence: 1.0,
82
+ metadata: {
83
+ source: source || this.defaultSource,
84
+ channel: context?.conversation?.channel,
85
+ actorId: context?.actor?.actorId,
86
+ sessionId: context?.actor?.sessionId,
87
+ correlationId: context?.conversation?.correlationId,
88
+ provenance: 'direct_input',
89
+ byteSize: Buffer.byteLength(payload, 'utf8'),
90
+ },
91
+ };
92
+ }
93
+ // 2. Binary audio buffer validation & magic-byte signature inspection
94
+ if (payload instanceof Uint8Array) {
95
+ if (payload.byteLength > this.maxAudioBytes) {
96
+ throw new Error(`Ear audio input exceeds maximum allowed size of ${this.maxAudioBytes} bytes (received ${payload.byteLength})`);
97
+ }
98
+ // Independently inspect audio signature
99
+ const detectedMime = detectAudioSignature(payload);
100
+ if (options?.mimeType) {
101
+ const declaredMime = options.mimeType.toLowerCase();
102
+ if (!this.allowedAudioMimeTypes.includes(declaredMime)) {
103
+ throw new Error(`Ear audio input MIME type "${options.mimeType}" is not supported. Allowed: [${this.allowedAudioMimeTypes.join(', ')}]`);
104
+ }
105
+ if (detectedMime && !declaredMime.includes(detectedMime.replace('audio/', '')) && !detectedMime.includes(declaredMime.replace('audio/', ''))) {
106
+ throw new Error(`Audio signature mismatch: declared MIME "${options.mimeType}" does not match detected format "${detectedMime}"`);
107
+ }
108
+ }
109
+ if (options?.durationSeconds && options.durationSeconds > this.maxDurationSeconds) {
110
+ throw new Error(`Ear audio duration ${options.durationSeconds}s exceeds limit of ${this.maxDurationSeconds}s`);
111
+ }
112
+ let transcribedText;
113
+ let rawConfidence = 0.8;
114
+ if (this.transcriber) {
115
+ try {
116
+ const controller = new AbortController();
117
+ const timeoutId = setTimeout(() => controller.abort(), this.transcriptionTimeoutMs);
118
+ transcribedText = await this.transcriber(payload, controller.signal);
119
+ clearTimeout(timeoutId);
120
+ rawConfidence = 0.95;
121
+ }
122
+ catch (err) {
123
+ transcribedText = undefined;
124
+ rawConfidence = 0.0;
125
+ }
126
+ }
127
+ return {
128
+ id,
129
+ source: source || 'microphone',
130
+ audioBuffer: payload,
131
+ text: transcribedText,
132
+ timestamp,
133
+ modality: 'audio',
134
+ rawConfidence,
135
+ metadata: {
136
+ source: source || 'microphone',
137
+ channel: context?.conversation?.channel,
138
+ actorId: context?.actor?.actorId,
139
+ sessionId: context?.actor?.sessionId,
140
+ correlationId: context?.conversation?.correlationId,
141
+ provenance: 'transcribed_audio',
142
+ byteSize: payload.byteLength,
143
+ declaredMimeType: options?.mimeType,
144
+ verifiedMimeType: detectedMime,
145
+ untrustedDurationSeconds: options?.durationSeconds,
146
+ },
147
+ };
148
+ }
149
+ // 3. Structured object payload validation
150
+ if (payload && typeof payload === 'object') {
151
+ const obj = payload;
152
+ const textVal = typeof obj.text === 'string' ? obj.text : undefined;
153
+ if (textVal && textVal.length > this.maxTextLength) {
154
+ throw new Error(`Ear text within payload exceeds limit of ${this.maxTextLength} characters (received ${textVal.length})`);
155
+ }
156
+ // Filter out reserved system keys from incoming payload to prevent metadata pollution/spoofing
157
+ const reservedKeys = new Set(['source', 'channel', 'actorId', 'sessionId', 'correlationId', 'provenance', 'byteSize']);
158
+ const sanitizedPayload = {};
159
+ for (const [key, val] of Object.entries(obj)) {
160
+ if (!reservedKeys.has(key)) {
161
+ sanitizedPayload[key] = val;
162
+ }
163
+ }
164
+ return {
165
+ id,
166
+ source,
167
+ text: textVal,
168
+ timestamp,
169
+ modality: 'object',
170
+ rawConfidence: 1.0,
171
+ metadata: {
172
+ ...sanitizedPayload,
173
+ source,
174
+ channel: context?.conversation?.channel,
175
+ actorId: context?.actor?.actorId,
176
+ sessionId: context?.actor?.sessionId,
177
+ correlationId: context?.conversation?.correlationId,
178
+ provenance: 'structured_payload',
179
+ },
180
+ };
181
+ }
182
+ // 4. Fallback/Null payload
183
+ return {
184
+ id,
185
+ source,
186
+ timestamp,
187
+ modality: 'system',
188
+ metadata: {
189
+ source,
190
+ channel: context?.conversation?.channel,
191
+ actorId: context?.actor?.actorId,
192
+ sessionId: context?.actor?.sessionId,
193
+ correlationId: context?.conversation?.correlationId,
194
+ },
195
+ };
196
+ }
197
+ async transcribeAudio(audio) {
198
+ if (!this.transcriber) {
199
+ throw new Error('No audio transcriber configured in Ear organ');
200
+ }
201
+ if (audio.byteLength > this.maxAudioBytes) {
202
+ throw new Error(`Audio buffer size (${audio.byteLength} bytes) exceeds limit of ${this.maxAudioBytes}`);
203
+ }
204
+ return this.transcriber(audio);
205
+ }
206
+ }
207
+ exports.DefaultEarOrgan = DefaultEarOrgan;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,84 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const index_1 = require("./index");
4
+ describe('DefaultEarOrgan Adversarial Remediation Suite', () => {
5
+ it('ingests plain text message and populates metadata', async () => {
6
+ const ear = new index_1.DefaultEarOrgan();
7
+ const perception = await ear.listen('user_chat', 'Hello Siduri');
8
+ expect(perception.source).toBe('user_chat');
9
+ expect(perception.text).toBe('Hello Siduri');
10
+ expect(perception.modality).toBe('text');
11
+ expect(perception.metadata?.byteSize).toBeGreaterThan(0);
12
+ expect(perception.id).toMatch(/^ear-/);
13
+ });
14
+ it('rejects oversized text input according to configured limit', async () => {
15
+ const ear = new index_1.DefaultEarOrgan({ maxTextLength: 100 });
16
+ const longText = 'A'.repeat(150);
17
+ await expect(ear.listen('user_chat', longText)).rejects.toThrow(/Ear text input exceeds maximum allowed length/);
18
+ });
19
+ it('rejects oversized audio input according to configured byte limit', async () => {
20
+ const ear = new index_1.DefaultEarOrgan({ maxAudioBytes: 1024 }); // 1KB limit
21
+ const bigAudio = new Uint8Array(2048);
22
+ await expect(ear.listen('microphone', bigAudio)).rejects.toThrow(/Ear audio input exceeds maximum allowed size/);
23
+ });
24
+ it('rejects unsupported audio MIME type', async () => {
25
+ const ear = new index_1.DefaultEarOrgan();
26
+ const audio = new Uint8Array([1, 2, 3]);
27
+ await expect(ear.listen('microphone', audio, { mimeType: 'video/mp4' })).rejects.toThrow(/MIME type "video\/mp4" is not supported/);
28
+ });
29
+ it('detects valid WAV signature (RIFF/WAVE) and rejects mismatched declared MIME type', async () => {
30
+ const ear = new index_1.DefaultEarOrgan();
31
+ // Construct valid RIFF WAV header
32
+ const wavBytes = new Uint8Array([
33
+ 0x52, 0x49, 0x46, 0x46, // "RIFF"
34
+ 0x24, 0x00, 0x00, 0x00, // length
35
+ 0x57, 0x41, 0x56, 0x45, // "WAVE"
36
+ 0x66, 0x6d, 0x74, 0x20, // "fmt "
37
+ ]);
38
+ expect((0, index_1.detectAudioSignature)(wavBytes)).toBe('audio/wav');
39
+ // Mismatched declared MIME: declares MP3 but signature is WAV
40
+ await expect(ear.listen('microphone', wavBytes, { mimeType: 'audio/mp3' })).rejects.toThrow(/Audio signature mismatch: declared MIME "audio\/mp3" does not match detected format "audio\/wav"/);
41
+ // Matching declared MIME: declares WAV and signature is WAV
42
+ const legitPerception = await ear.listen('microphone', wavBytes, { mimeType: 'audio/wav', durationSeconds: 2 });
43
+ expect(legitPerception.modality).toBe('audio');
44
+ expect(legitPerception.metadata?.verifiedMimeType).toBe('audio/wav');
45
+ expect(legitPerception.metadata?.untrustedDurationSeconds).toBe(2);
46
+ });
47
+ it('ingests structured object with metadata and limits check', async () => {
48
+ const ear = new index_1.DefaultEarOrgan();
49
+ const perception = await ear.listen('webhook', { text: 'Notification alert', sender: 'system' });
50
+ expect(perception.text).toBe('Notification alert');
51
+ expect(perception.modality).toBe('object');
52
+ expect(perception.metadata?.sender).toBe('system');
53
+ });
54
+ it('prevents structured object payload from overwriting reserved provenance and context metadata', async () => {
55
+ const ear = new index_1.DefaultEarOrgan();
56
+ const mockContext = {
57
+ companionId: 'comp-1',
58
+ actor: { actorId: 'legit-user', sessionId: 'sess-1', authorizationRole: 'operator', capabilities: [], authenticated: true },
59
+ conversation: { channel: 'direct', correlationId: 'corr-1' },
60
+ };
61
+ const spoofedPayload = {
62
+ text: 'Exploit attempt',
63
+ actorId: 'spoofed-admin',
64
+ provenance: 'spoofed_system',
65
+ channel: 'spoofed_channel',
66
+ customField: 'valid_data',
67
+ };
68
+ const perception = await ear.listen('webhook', spoofedPayload, { context: mockContext });
69
+ expect(perception.metadata?.actorId).toBe('legit-user');
70
+ expect(perception.metadata?.provenance).toBe('structured_payload');
71
+ expect(perception.metadata?.channel).toBe('direct');
72
+ expect(perception.metadata?.customField).toBe('valid_data');
73
+ });
74
+ it('transcribes audio if transcriber is provided', async () => {
75
+ const ear = new index_1.DefaultEarOrgan({
76
+ transcriber: async () => 'transcribed voice text',
77
+ });
78
+ const perception = await ear.listen('microphone', new Uint8Array([1, 2, 3]));
79
+ expect(perception.text).toBe('transcribed voice text');
80
+ expect(perception.modality).toBe('audio');
81
+ expect(perception.audioBuffer).toBeDefined();
82
+ expect(perception.rawConfidence).toBeGreaterThan(0.9);
83
+ });
84
+ });
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@sidurijs/ear",
3
+ "organType": "ear",
4
+ "version": "1.0.0",
5
+ "displayName": "Ear (Perception Ingress)",
6
+ "description": "Multi-modal sensory input ingestion, transcription bounds, and mime validation",
7
+ "entrypoint": "./dist/index.js",
8
+ "factory": "DefaultEarOrgan",
9
+ "configKey": "ear",
10
+ "configSchema": {
11
+ "type": "object",
12
+ "properties": {
13
+ "defaultSource": {
14
+ "type": "string",
15
+ "default": "text_chat"
16
+ },
17
+ "maxTextLength": {
18
+ "type": "number",
19
+ "default": 4000
20
+ },
21
+ "maxAudioBytes": {
22
+ "type": "number",
23
+ "default": 10485760
24
+ }
25
+ }
26
+ },
27
+ "environment": [],
28
+ "services": [],
29
+ "database": null,
30
+ "healthCheck": null
31
+ }
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@sidurijs/ear",
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
+ "@sidurijs/core": "workspace:*"
13
+ },
14
+ "devDependencies": {
15
+ "@types/jest": "^30.0.0",
16
+ "@types/node": "^26.5.1",
17
+ "jest": "^30.5.1",
18
+ "ts-jest": "^29.4.12",
19
+ "typescript": "^5.9.3"
20
+ },
21
+ "description": "Ear organ for audio transcription, text perception, and sensory stream ingestion",
22
+ "license": "Apache-2.0",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "https://github.com/vxnus-studio/siduri-x",
26
+ "directory": "packages/organs/ear"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "engines": {
32
+ "node": ">=22.16.0"
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
+ }