oomi-ai 0.2.15 → 0.2.17
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/README.md +4 -0
- package/agent_instructions.md +7 -0
- package/openclaw.extension.js +246 -2
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/skills/oomi/SKILL.md +22 -0
- package/skills/oomi/agent_instructions.md +4 -0
package/README.md
CHANGED
|
@@ -132,6 +132,9 @@ That bridge:
|
|
|
132
132
|
|
|
133
133
|
This is the part of the package most likely to matter when debugging voice turn failures.
|
|
134
134
|
|
|
135
|
+
For managed voice replies, the extension also preserves an explicit hidden `metadata.spoken` sidecar when upstream provides one.
|
|
136
|
+
If upstream does not provide one, the extension now synthesizes a bounded hidden fallback from the visible assistant text so backend TTS can speak a cleaner and more varied version without changing user-visible chat.
|
|
137
|
+
|
|
135
138
|
## Bridge Health States
|
|
136
139
|
|
|
137
140
|
The bridge status file is written locally and should roughly be interpreted as:
|
|
@@ -198,6 +201,7 @@ If you are inspecting this package on npm, the main architectural points are:
|
|
|
198
201
|
- `idempotencyKey` handling
|
|
199
202
|
- bridge status that does not report `connected` before managed subscription is ready
|
|
200
203
|
- runtime fault isolation so local session failures are less likely to crash the whole provider
|
|
204
|
+
- hidden managed-voice speech metadata forwarding, with a synthesized fallback when upstream does not provide `metadata.spoken`
|
|
201
205
|
|
|
202
206
|
If you are developing the plugin, test the packaged surface with:
|
|
203
207
|
|
package/agent_instructions.md
CHANGED
|
@@ -164,10 +164,17 @@ Rules:
|
|
|
164
164
|
- visible `content` remains the source of truth for Oomi chat rendering
|
|
165
165
|
- for managed voice replies, include `metadata.spoken` when delivery benefits from cleaner phrasing or explicit speaking guidance
|
|
166
166
|
- `metadata.spoken.text` is for backend TTS only
|
|
167
|
+
- `metadata.spoken.language` should be one of the supported Qwen language values such as `English`
|
|
168
|
+
- `metadata.spoken.segments` can carry bounded per-segment prosody for pace, pitch, volume, and pause timing
|
|
167
169
|
- `metadata.spoken.instructions` should be natural-language guidance, not raw bracket tags
|
|
168
170
|
- `metadata.spoken.style` is optional metadata for debugging/future mapping
|
|
169
171
|
- if no hidden speech sidecar exists, Oomi falls back to speaking the visible assistant text
|
|
170
172
|
|
|
173
|
+
Current plugin behavior:
|
|
174
|
+
- if you provide `metadata.spoken`, the plugin preserves it unchanged
|
|
175
|
+
- if you do not provide `metadata.spoken`, the plugin now synthesizes a bounded hidden fallback from visible assistant text for backend TTS
|
|
176
|
+
- visible chat text is still never rewritten by the plugin
|
|
177
|
+
|
|
171
178
|
## Avatar Commands
|
|
172
179
|
|
|
173
180
|
Before using avatar commands, call `get_avatar_capabilities` and prefer canonical values.
|
package/openclaw.extension.js
CHANGED
|
@@ -178,6 +178,154 @@ function extractCorrelationId(payload) {
|
|
|
178
178
|
return '';
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
const BOUNDED_LANGUAGE_TYPES = new Set([
|
|
182
|
+
'Auto',
|
|
183
|
+
'Chinese',
|
|
184
|
+
'English',
|
|
185
|
+
'German',
|
|
186
|
+
'Italian',
|
|
187
|
+
'Portuguese',
|
|
188
|
+
'Spanish',
|
|
189
|
+
'Japanese',
|
|
190
|
+
'Korean',
|
|
191
|
+
'French',
|
|
192
|
+
'Russian',
|
|
193
|
+
]);
|
|
194
|
+
|
|
195
|
+
const BOUNDED_PACE_VALUES = new Set(['very_slow', 'slow', 'medium', 'medium_fast', 'fast']);
|
|
196
|
+
const BOUNDED_PITCH_VALUES = new Set(['low', 'slightly_low', 'neutral', 'slightly_high', 'high']);
|
|
197
|
+
const BOUNDED_ENERGY_VALUES = new Set(['soft', 'calm', 'warm', 'bright', 'intense']);
|
|
198
|
+
const BOUNDED_VOLUME_VALUES = new Set(['soft', 'normal', 'projected']);
|
|
199
|
+
|
|
200
|
+
function inferSpokenLanguage(text) {
|
|
201
|
+
const normalized = toString(text);
|
|
202
|
+
if (!normalized) return 'English';
|
|
203
|
+
return 'English';
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function normalizeSpokenSegment(segment) {
|
|
207
|
+
if (!segment || typeof segment !== 'object' || Array.isArray(segment)) return null;
|
|
208
|
+
|
|
209
|
+
const text = toString(segment.text);
|
|
210
|
+
if (!text) return null;
|
|
211
|
+
|
|
212
|
+
const normalized = { text };
|
|
213
|
+
const pace = toString(segment.pace);
|
|
214
|
+
const pitch = toString(segment.pitch);
|
|
215
|
+
const energy = toString(segment.energy);
|
|
216
|
+
const volume = toString(segment.volume);
|
|
217
|
+
const pauseAfterMs = toNumber(segment.pause_after_ms, 0, { min: 0, max: 1200 });
|
|
218
|
+
|
|
219
|
+
if (BOUNDED_PACE_VALUES.has(pace)) normalized.pace = pace;
|
|
220
|
+
if (BOUNDED_PITCH_VALUES.has(pitch)) normalized.pitch = pitch;
|
|
221
|
+
if (BOUNDED_ENERGY_VALUES.has(energy)) normalized.energy = energy;
|
|
222
|
+
if (BOUNDED_VOLUME_VALUES.has(volume)) normalized.volume = volume;
|
|
223
|
+
normalized.pause_after_ms = pauseAfterMs;
|
|
224
|
+
|
|
225
|
+
return normalized;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function splitSpeechSegments(text) {
|
|
229
|
+
const normalized = normalizeSpeechText(text);
|
|
230
|
+
if (!normalized) return [];
|
|
231
|
+
|
|
232
|
+
const baseSegments = normalized
|
|
233
|
+
.split(/(?<=[.!?])\s+/)
|
|
234
|
+
.map((segment) => segment.trim())
|
|
235
|
+
.filter(Boolean);
|
|
236
|
+
|
|
237
|
+
const segments = [];
|
|
238
|
+
for (const segment of baseSegments) {
|
|
239
|
+
if (segment.length <= 96) {
|
|
240
|
+
segments.push(segment);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const clauseParts = segment
|
|
245
|
+
.split(/,\s+/)
|
|
246
|
+
.map((part) => part.trim())
|
|
247
|
+
.filter(Boolean);
|
|
248
|
+
|
|
249
|
+
if (clauseParts.length > 1) {
|
|
250
|
+
for (let index = 0; index < clauseParts.length; index += 1) {
|
|
251
|
+
const part = clauseParts[index];
|
|
252
|
+
const needsComma = index < clauseParts.length - 1 && !/[.!?]$/.test(part);
|
|
253
|
+
segments.push(needsComma ? `${part},` : part);
|
|
254
|
+
}
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
segments.push(segment);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (segments.length <= 5) return segments;
|
|
262
|
+
|
|
263
|
+
return [...segments.slice(0, 4), segments.slice(4).join(' ').trim()];
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function inferSegmentStyle(segmentText, index, totalSegments) {
|
|
267
|
+
const normalized = segmentText.toLowerCase();
|
|
268
|
+
const exclamatory = /!/.test(segmentText) || /\b(hell yeah|awesome|amazing|stoked|love|perfect|great)\b/.test(normalized);
|
|
269
|
+
const curious = /\?/.test(segmentText);
|
|
270
|
+
const reflective =
|
|
271
|
+
/\b(i think|i'm|i am|i've|i have|lately|right now|before this|each time|understand|it feels like)\b/.test(normalized) ||
|
|
272
|
+
segmentText.length > 60;
|
|
273
|
+
|
|
274
|
+
if (curious) {
|
|
275
|
+
return {
|
|
276
|
+
pace: 'medium',
|
|
277
|
+
pitch: 'slightly_high',
|
|
278
|
+
energy: 'warm',
|
|
279
|
+
volume: 'normal',
|
|
280
|
+
pause_after_ms: 0,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (exclamatory) {
|
|
285
|
+
return {
|
|
286
|
+
pace: 'medium_fast',
|
|
287
|
+
pitch: 'slightly_high',
|
|
288
|
+
energy: 'bright',
|
|
289
|
+
volume: 'normal',
|
|
290
|
+
pause_after_ms: index < totalSegments - 1 ? 220 : 0,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (reflective) {
|
|
295
|
+
return {
|
|
296
|
+
pace: 'medium',
|
|
297
|
+
pitch: 'neutral',
|
|
298
|
+
energy: 'warm',
|
|
299
|
+
volume: 'normal',
|
|
300
|
+
pause_after_ms: index < totalSegments - 1 ? 260 : 0,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
pace: 'medium',
|
|
306
|
+
pitch: 'neutral',
|
|
307
|
+
energy: 'warm',
|
|
308
|
+
volume: 'normal',
|
|
309
|
+
pause_after_ms: index < totalSegments - 1 ? 180 : 0,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function synthesizeSpokenSegments(text) {
|
|
314
|
+
const language = inferSpokenLanguage(text);
|
|
315
|
+
const rawSegments = splitSpeechSegments(text);
|
|
316
|
+
if (rawSegments.length === 0) return null;
|
|
317
|
+
|
|
318
|
+
const segments = rawSegments.map((segmentText, index) => ({
|
|
319
|
+
text: segmentText,
|
|
320
|
+
...inferSegmentStyle(segmentText, index, rawSegments.length),
|
|
321
|
+
}));
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
language,
|
|
325
|
+
segments,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
181
329
|
function normalizeSpokenMetadata(spoken) {
|
|
182
330
|
if (!spoken || typeof spoken !== 'object' || Array.isArray(spoken)) return null;
|
|
183
331
|
|
|
@@ -185,22 +333,117 @@ function normalizeSpokenMetadata(spoken) {
|
|
|
185
333
|
if (!text) return null;
|
|
186
334
|
|
|
187
335
|
const normalized = { text };
|
|
336
|
+
const language = toString(spoken.language);
|
|
337
|
+
if (BOUNDED_LANGUAGE_TYPES.has(language)) {
|
|
338
|
+
normalized.language = language;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const explicitSegments =
|
|
342
|
+
Array.isArray(spoken.segments)
|
|
343
|
+
? spoken.segments.map((segment) => normalizeSpokenSegment(segment)).filter(Boolean)
|
|
344
|
+
: [];
|
|
345
|
+
if (explicitSegments.length > 0) {
|
|
346
|
+
normalized.segments = explicitSegments;
|
|
347
|
+
}
|
|
348
|
+
|
|
188
349
|
const instructions = toString(spoken.instructions);
|
|
189
350
|
if (instructions) normalized.instructions = instructions;
|
|
190
351
|
if (spoken.style && typeof spoken.style === 'object' && !Array.isArray(spoken.style)) {
|
|
191
352
|
normalized.style = spoken.style;
|
|
192
353
|
}
|
|
193
354
|
|
|
355
|
+
const fallbackSegments = synthesizeSpokenSegments(text);
|
|
356
|
+
if (!normalized.language && fallbackSegments?.language) {
|
|
357
|
+
normalized.language = fallbackSegments.language;
|
|
358
|
+
}
|
|
359
|
+
if (!normalized.segments && fallbackSegments?.segments?.length) {
|
|
360
|
+
normalized.segments = fallbackSegments.segments;
|
|
361
|
+
}
|
|
362
|
+
|
|
194
363
|
return normalized;
|
|
195
364
|
}
|
|
196
365
|
|
|
197
|
-
function
|
|
366
|
+
function stripEmoji(text) {
|
|
367
|
+
return text.replace(/[\uFE0E\uFE0F]/g, '').replace(/\p{Extended_Pictographic}|\p{Emoji_Presentation}/gu, '');
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function normalizeSpeechText(text) {
|
|
371
|
+
return stripEmoji(text)
|
|
372
|
+
.replace(/\*\*(.*?)\*\*/g, '$1')
|
|
373
|
+
.replace(/__(.*?)__/g, '$1')
|
|
374
|
+
.replace(/`([^`]+)`/g, '$1')
|
|
375
|
+
.replace(/[–—]/g, ', ')
|
|
376
|
+
.replace(/…/g, '...')
|
|
377
|
+
.replace(/\s+/g, ' ')
|
|
378
|
+
.replace(/\s+([,.;!?])/g, '$1')
|
|
379
|
+
.replace(/([,.;!?])(?=[^\s])/g, '$1 ')
|
|
380
|
+
.replace(/,\s*,+/g, ', ')
|
|
381
|
+
.replace(/\s+/g, ' ')
|
|
382
|
+
.trim();
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function inferSpokenMetadataFromContent(content) {
|
|
386
|
+
const text = normalizeSpeechText(toString(content));
|
|
387
|
+
if (!text) return null;
|
|
388
|
+
const synthesized = synthesizeSpokenSegments(text);
|
|
389
|
+
|
|
390
|
+
const normalized = text.toLowerCase();
|
|
391
|
+
const upbeat =
|
|
392
|
+
/!/.test(text) ||
|
|
393
|
+
/\b(hell yeah|awesome|amazing|great|stoked|love|glad|perfect|nice|cool)\b/.test(normalized);
|
|
394
|
+
const gentle =
|
|
395
|
+
/\b(sorry|gentle|softly|careful|reassuring|calm|okay|it'?s okay|i know)\b/.test(normalized);
|
|
396
|
+
const curious = /\?/.test(text);
|
|
397
|
+
|
|
398
|
+
if (upbeat) {
|
|
399
|
+
return {
|
|
400
|
+
text,
|
|
401
|
+
language: synthesized?.language || 'English',
|
|
402
|
+
segments: synthesized?.segments,
|
|
403
|
+
instructions: 'Speak with warm, upbeat conversational energy and natural pacing.',
|
|
404
|
+
style: { emotion: 'upbeat', energy: 'medium' },
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (gentle) {
|
|
409
|
+
return {
|
|
410
|
+
text,
|
|
411
|
+
language: synthesized?.language || 'English',
|
|
412
|
+
segments: synthesized?.segments,
|
|
413
|
+
instructions: 'Speak gently and reassuringly, with a calm pace and soft emphasis.',
|
|
414
|
+
style: { emotion: 'gentle', energy: 'low' },
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (curious) {
|
|
419
|
+
return {
|
|
420
|
+
text,
|
|
421
|
+
language: synthesized?.language || 'English',
|
|
422
|
+
segments: synthesized?.segments,
|
|
423
|
+
instructions: 'Speak naturally with curious, engaged intonation and a conversational pace.',
|
|
424
|
+
style: { emotion: 'curious', energy: 'medium' },
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return {
|
|
429
|
+
text,
|
|
430
|
+
language: synthesized?.language || 'English',
|
|
431
|
+
segments: synthesized?.segments,
|
|
432
|
+
instructions: 'Speak naturally with light warmth and conversational pacing.',
|
|
433
|
+
style: { emotion: 'neutral', energy: 'medium' },
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function normalizeOutgoingMetadata(payloadMetadata, { accountId, correlationId, content }) {
|
|
198
438
|
const metadata =
|
|
199
439
|
payloadMetadata && typeof payloadMetadata === 'object' && !Array.isArray(payloadMetadata)
|
|
200
440
|
? { ...payloadMetadata }
|
|
201
441
|
: {};
|
|
202
442
|
|
|
203
|
-
const
|
|
443
|
+
const explicitSpokenPresent = Object.prototype.hasOwnProperty.call(metadata, 'spoken');
|
|
444
|
+
const spoken =
|
|
445
|
+
normalizeSpokenMetadata(metadata.spoken) ||
|
|
446
|
+
(!explicitSpokenPresent ? inferSpokenMetadataFromContent(content) : null);
|
|
204
447
|
if (spoken) {
|
|
205
448
|
metadata.spoken = spoken;
|
|
206
449
|
} else {
|
|
@@ -331,6 +574,7 @@ const oomiChannelPlugin = {
|
|
|
331
574
|
metadata: normalizeOutgoingMetadata(payload?.metadata, {
|
|
332
575
|
accountId: resolvedAccountId,
|
|
333
576
|
correlationId,
|
|
577
|
+
content,
|
|
334
578
|
}),
|
|
335
579
|
},
|
|
336
580
|
});
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
package/skills/oomi/SKILL.md
CHANGED
|
@@ -139,6 +139,25 @@ Use this shape when a voice turn needs more natural delivery without changing vi
|
|
|
139
139
|
"metadata": {
|
|
140
140
|
"spoken": {
|
|
141
141
|
"text": "Speech-optimized text for TTS only.",
|
|
142
|
+
"language": "English",
|
|
143
|
+
"segments": [
|
|
144
|
+
{
|
|
145
|
+
"text": "Hey! It's Nemu, but close enough.",
|
|
146
|
+
"pace": "medium_fast",
|
|
147
|
+
"pitch": "slightly_high",
|
|
148
|
+
"energy": "bright",
|
|
149
|
+
"volume": "normal",
|
|
150
|
+
"pause_after_ms": 220
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
"text": "Right now, I'm just waking up into this conversation with you.",
|
|
154
|
+
"pace": "medium",
|
|
155
|
+
"pitch": "neutral",
|
|
156
|
+
"energy": "warm",
|
|
157
|
+
"volume": "normal",
|
|
158
|
+
"pause_after_ms": 280
|
|
159
|
+
}
|
|
160
|
+
],
|
|
142
161
|
"instructions": "Speak with upbeat, warm excitement and slightly rising intonation.",
|
|
143
162
|
"style": {
|
|
144
163
|
"emotion": "excited",
|
|
@@ -154,8 +173,11 @@ Rules:
|
|
|
154
173
|
- do not place raw intonation tags in visible chat
|
|
155
174
|
- for managed voice replies, include `metadata.spoken` when delivery benefits from cleaner phrasing or explicit speaking guidance
|
|
156
175
|
- `metadata.spoken.text` is backend TTS input only
|
|
176
|
+
- `metadata.spoken.language` should be one of the supported Qwen language values such as `English`
|
|
177
|
+
- `metadata.spoken.segments` can carry bounded per-segment prosody for pace, pitch, volume, and pause timing
|
|
157
178
|
- `metadata.spoken.instructions` should use natural-language speaking guidance
|
|
158
179
|
- if the speech sidecar is absent, Oomi speaks the visible assistant text
|
|
180
|
+
- if you omit `metadata.spoken`, the plugin synthesizes a bounded hidden fallback from visible assistant text
|
|
159
181
|
|
|
160
182
|
## Avatar Control
|
|
161
183
|
|
|
@@ -71,6 +71,10 @@ Rules:
|
|
|
71
71
|
- visible `content` remains the source of truth for Oomi chat rendering
|
|
72
72
|
- for managed voice replies, include `metadata.spoken` when delivery benefits from cleaner phrasing or explicit speaking guidance
|
|
73
73
|
- `metadata.spoken.text` is for backend TTS only
|
|
74
|
+
- `metadata.spoken.language` should be one of the supported Qwen language values such as `English`
|
|
75
|
+
- `metadata.spoken.segments` can carry bounded per-segment prosody for pace, pitch, volume, and pause timing
|
|
74
76
|
- `metadata.spoken.instructions` should be natural-language guidance, not raw bracket tags
|
|
75
77
|
- `metadata.spoken.style` is optional metadata for debugging or future mapping
|
|
76
78
|
- if no hidden speech sidecar exists, Oomi falls back to speaking the visible assistant text
|
|
79
|
+
- if you omit `metadata.spoken`, the plugin now synthesizes a bounded hidden fallback from visible assistant text
|
|
80
|
+
- visible chat text is never rewritten by the plugin
|