bloby-bot 0.48.1 → 0.48.3
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/package.json +1 -1
- package/shared/config.ts +0 -9
- package/supervisor/channels/alexa.ts +116 -14
- package/supervisor/channels/manager.ts +32 -8
- package/supervisor/channels/types.ts +4 -2
- package/supervisor/index.ts +596 -133
- package/workspace/skills/alexa/SKILL.md +281 -0
- package/workspace/skills/alexa/skill.json +15 -0
package/package.json
CHANGED
package/shared/config.ts
CHANGED
|
@@ -18,15 +18,6 @@ export interface AlexaChannelConfig {
|
|
|
18
18
|
/** Per-user shared secret minted by the relay when the user first pairs an Alexa device.
|
|
19
19
|
* Used to verify that inbound /api/channels/alexa/handle calls actually came from the relay. */
|
|
20
20
|
sharedSecret?: string;
|
|
21
|
-
/** Optional Home Assistant fallback for replies that exceed Alexa's ~25s budget. */
|
|
22
|
-
overflow?: {
|
|
23
|
-
mode: 'ha-announce' | 'chat-only';
|
|
24
|
-
ha?: {
|
|
25
|
-
url: string;
|
|
26
|
-
token: string;
|
|
27
|
-
device: string;
|
|
28
|
-
};
|
|
29
|
-
};
|
|
30
21
|
}
|
|
31
22
|
|
|
32
23
|
export interface BotConfig {
|
|
@@ -18,20 +18,61 @@ import { loadConfig } from '../../shared/config.js';
|
|
|
18
18
|
import { log } from '../../shared/logger.js';
|
|
19
19
|
import type { ChannelProvider, ChannelStatus, ChannelType } from './types.js';
|
|
20
20
|
|
|
21
|
+
/** Credentials + state needed to fire Progressive Response for a single Alexa turn. */
|
|
22
|
+
export interface AlexaTurnState {
|
|
23
|
+
apiEndpoint: string;
|
|
24
|
+
apiAccessToken: string;
|
|
25
|
+
requestId: string;
|
|
26
|
+
/** Wall-clock start of the turn — used for log timing. */
|
|
27
|
+
startedAt: number;
|
|
28
|
+
/** Static-fallback timer that fires "Working on it" if no preamble text arrives in time. */
|
|
29
|
+
fallbackTimer: ReturnType<typeof setTimeout> | null;
|
|
30
|
+
/** True once we've sent at least one Progressive Response for this turn. */
|
|
31
|
+
sentAny: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface PendingSlot {
|
|
35
|
+
resolve: (text: string) => void;
|
|
36
|
+
reject: (err: Error) => void;
|
|
37
|
+
createdAt: number;
|
|
38
|
+
turn: AlexaTurnState | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const STATIC_FALLBACK_MS = 1_500; // Fire early enough to extend Alexa's budget on cold start
|
|
42
|
+
const MAX_PROGRESSIVE_SPEECH = 600;
|
|
43
|
+
|
|
21
44
|
export class AlexaChannel implements ChannelProvider {
|
|
22
45
|
readonly type: ChannelType = 'alexa';
|
|
23
46
|
|
|
24
|
-
/** Per-conversation FIFO of pending HTTP-response resolvers
|
|
25
|
-
* Alexa utterance enqueues one; each bot:response (or
|
|
26
|
-
* net) dequeues one. */
|
|
27
|
-
private pending = new Map<string,
|
|
47
|
+
/** Per-conversation FIFO of pending HTTP-response resolvers + turn state.
|
|
48
|
+
* Each inbound Alexa utterance enqueues one slot; each bot:response (or
|
|
49
|
+
* turn-complete safety net) dequeues one. */
|
|
50
|
+
private pending = new Map<string, PendingSlot[]>();
|
|
28
51
|
|
|
29
52
|
/** Reserve a resolver slot. The caller pushes the user utterance into the
|
|
30
53
|
* live conversation IMMEDIATELY after this returns, so the FIFO order on
|
|
31
|
-
* this map matches the FIFO order the routing queue uses.
|
|
32
|
-
|
|
54
|
+
* this map matches the FIFO order the routing queue uses.
|
|
55
|
+
*
|
|
56
|
+
* If `creds` are provided (apiEndpoint + apiAccessToken + requestId), this
|
|
57
|
+
* also schedules a static "Working on it" Progressive Response to fire if
|
|
58
|
+
* the agent emits no preamble text within STATIC_FALLBACK_MS. The fallback
|
|
59
|
+
* is cancelled the moment any Progressive Response is sent for this turn. */
|
|
60
|
+
reservePending(
|
|
61
|
+
convId: string,
|
|
62
|
+
creds: { apiEndpoint: string; apiAccessToken: string; requestId: string } | null,
|
|
63
|
+
timeoutMs = 25_000,
|
|
64
|
+
): Promise<string> {
|
|
33
65
|
return new Promise<string>((resolve, reject) => {
|
|
34
|
-
const
|
|
66
|
+
const turn: AlexaTurnState | null = creds ? {
|
|
67
|
+
apiEndpoint: creds.apiEndpoint,
|
|
68
|
+
apiAccessToken: creds.apiAccessToken,
|
|
69
|
+
requestId: creds.requestId,
|
|
70
|
+
startedAt: Date.now(),
|
|
71
|
+
fallbackTimer: null,
|
|
72
|
+
sentAny: false,
|
|
73
|
+
} : null;
|
|
74
|
+
|
|
75
|
+
const slot: PendingSlot = { resolve, reject, createdAt: Date.now(), turn };
|
|
35
76
|
let q = this.pending.get(convId);
|
|
36
77
|
if (!q) {
|
|
37
78
|
q = [];
|
|
@@ -39,15 +80,22 @@ export class AlexaChannel implements ChannelProvider {
|
|
|
39
80
|
}
|
|
40
81
|
q.push(slot);
|
|
41
82
|
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
83
|
+
// NOTE: the relay fires an immediate "On it." Progressive Response on
|
|
84
|
+
// every AgentIntent turn (see relay's sendImmediateProgressive), so the
|
|
85
|
+
// budget is already extended by the time we get here. We DON'T schedule
|
|
86
|
+
// a static fallback on the Pi side — it would double up and the user
|
|
87
|
+
// would hear "On it. Working on it." If the agent emits a preamble, we
|
|
88
|
+
// still flush it as a Progressive in `tryFlushProgressive`.
|
|
89
|
+
|
|
90
|
+
// Hard safety timeout — if the agent never responds at all, the HTTP
|
|
91
|
+
// handler unblocks so the relay can return a friendly fallback. The
|
|
92
|
+
// slot is removed so a late bot:response doesn't resolve a dead promise.
|
|
46
93
|
setTimeout(() => {
|
|
47
94
|
const list = this.pending.get(convId);
|
|
48
95
|
if (!list) return;
|
|
49
96
|
const idx = list.indexOf(slot);
|
|
50
97
|
if (idx >= 0) {
|
|
98
|
+
if (slot.turn?.fallbackTimer) clearTimeout(slot.turn.fallbackTimer);
|
|
51
99
|
list.splice(idx, 1);
|
|
52
100
|
if (list.length === 0) this.pending.delete(convId);
|
|
53
101
|
reject(new Error('alexa-timeout'));
|
|
@@ -64,6 +112,7 @@ export class AlexaChannel implements ChannelProvider {
|
|
|
64
112
|
if (!q || q.length === 0) return false;
|
|
65
113
|
const slot = q.shift()!;
|
|
66
114
|
if (q.length === 0) this.pending.delete(convId);
|
|
115
|
+
if (slot.turn?.fallbackTimer) clearTimeout(slot.turn.fallbackTimer);
|
|
67
116
|
slot.resolve(text);
|
|
68
117
|
return true;
|
|
69
118
|
}
|
|
@@ -74,10 +123,60 @@ export class AlexaChannel implements ChannelProvider {
|
|
|
74
123
|
if (!q || q.length === 0) return false;
|
|
75
124
|
const slot = q.shift()!;
|
|
76
125
|
if (q.length === 0) this.pending.delete(convId);
|
|
126
|
+
if (slot.turn?.fallbackTimer) clearTimeout(slot.turn.fallbackTimer);
|
|
77
127
|
slot.reject(new Error(reason));
|
|
78
128
|
return true;
|
|
79
129
|
}
|
|
80
130
|
|
|
131
|
+
/** Try to flush a buffered preamble chunk as Progressive Response on the
|
|
132
|
+
* head turn. Called by the channel manager on bot:tool events when the
|
|
133
|
+
* routing target's surface is 'alexa' and there's buffered text. */
|
|
134
|
+
tryFlushProgressive(convId: string, text: string): boolean {
|
|
135
|
+
const q = this.pending.get(convId);
|
|
136
|
+
if (!q || q.length === 0) return false;
|
|
137
|
+
const turn = q[0].turn;
|
|
138
|
+
if (!turn) return false;
|
|
139
|
+
this.sendProgressive(turn, text).catch(() => {});
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Fire a single Progressive Response directive to Amazon's Directive Service.
|
|
144
|
+
* Best-effort: failures are logged but don't break the agent's stream. */
|
|
145
|
+
private async sendProgressive(turn: AlexaTurnState, speech: string): Promise<void> {
|
|
146
|
+
const trimmed = String(speech || '').trim();
|
|
147
|
+
if (!trimmed) return;
|
|
148
|
+
const fireOffset = Date.now() - turn.startedAt;
|
|
149
|
+
try {
|
|
150
|
+
const r = await fetch(`${turn.apiEndpoint}/v1/directives`, {
|
|
151
|
+
method: 'POST',
|
|
152
|
+
headers: {
|
|
153
|
+
Authorization: `Bearer ${turn.apiAccessToken}`,
|
|
154
|
+
'Content-Type': 'application/json',
|
|
155
|
+
},
|
|
156
|
+
body: JSON.stringify({
|
|
157
|
+
header: { requestId: turn.requestId },
|
|
158
|
+
directive: {
|
|
159
|
+
type: 'VoicePlayer.Speak',
|
|
160
|
+
speech: trimmed.slice(0, MAX_PROGRESSIVE_SPEECH),
|
|
161
|
+
},
|
|
162
|
+
}),
|
|
163
|
+
});
|
|
164
|
+
if (r.ok) {
|
|
165
|
+
turn.sentAny = true;
|
|
166
|
+
if (turn.fallbackTimer) {
|
|
167
|
+
clearTimeout(turn.fallbackTimer);
|
|
168
|
+
turn.fallbackTimer = null;
|
|
169
|
+
}
|
|
170
|
+
log.info(`[alexa/progressive] sent at +${fireOffset}ms (status ${r.status}) — "${trimmed.slice(0, 60)}"`);
|
|
171
|
+
} else {
|
|
172
|
+
const body = await r.text().catch(() => '');
|
|
173
|
+
log.warn(`[alexa/progressive] REJECTED at +${fireOffset}ms — status ${r.status} body=${body.slice(0, 200)}`);
|
|
174
|
+
}
|
|
175
|
+
} catch (err: any) {
|
|
176
|
+
log.warn(`[alexa/progressive] FAILED at +${fireOffset}ms — ${err.message}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
81
180
|
// ── ChannelProvider implementation ──
|
|
82
181
|
|
|
83
182
|
async connect(): Promise<void> {
|
|
@@ -86,9 +185,13 @@ export class AlexaChannel implements ChannelProvider {
|
|
|
86
185
|
}
|
|
87
186
|
|
|
88
187
|
async disconnect(): Promise<void> {
|
|
89
|
-
// Reject any in-flight resolvers so HTTP handlers don't hang forever
|
|
188
|
+
// Reject any in-flight resolvers so HTTP handlers don't hang forever,
|
|
189
|
+
// and cancel any pending fallback timers to avoid late progressive calls.
|
|
90
190
|
for (const [, q] of this.pending) {
|
|
91
|
-
for (const slot of q)
|
|
191
|
+
for (const slot of q) {
|
|
192
|
+
if (slot.turn?.fallbackTimer) clearTimeout(slot.turn.fallbackTimer);
|
|
193
|
+
slot.reject(new Error('alexa-disconnected'));
|
|
194
|
+
}
|
|
92
195
|
}
|
|
93
196
|
this.pending.clear();
|
|
94
197
|
}
|
|
@@ -107,7 +210,6 @@ export class AlexaChannel implements ChannelProvider {
|
|
|
107
210
|
connected: !!cfg?.enabled,
|
|
108
211
|
info: {
|
|
109
212
|
linked: !!cfg?.sharedSecret,
|
|
110
|
-
overflow: cfg?.overflow?.mode || 'chat-only',
|
|
111
213
|
},
|
|
112
214
|
};
|
|
113
215
|
}
|
|
@@ -454,10 +454,16 @@ export class ChannelManager {
|
|
|
454
454
|
// Agent paused for a tool call — flush streamed text so the user sees progress
|
|
455
455
|
// before the tool result lands. Peek (don't consume) — the final bot:response
|
|
456
456
|
// is what closes out the turn.
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
457
|
+
const head = this.peekRoute(convId);
|
|
458
|
+
if (head?.surface === 'alexa') {
|
|
459
|
+
// For Alexa, send the preamble as a Progressive Response so the user
|
|
460
|
+
// hears the agent's actual "I'll do X..." line. Final bot:response is
|
|
461
|
+
// still what closes the turn with the agent's last words.
|
|
462
|
+
const alexa = this.providers.get('alexa') as AlexaChannel | undefined;
|
|
463
|
+
alexa?.tryFlushProgressive(convId, state.chunkBuf.trim());
|
|
464
|
+
state.chunkBuf = '';
|
|
465
|
+
} else {
|
|
466
|
+
this.sendStreamChunk(head, state.chunkBuf.trim(), botName);
|
|
461
467
|
state.chunkBuf = '';
|
|
462
468
|
}
|
|
463
469
|
return;
|
|
@@ -908,9 +914,17 @@ export class ChannelManager {
|
|
|
908
914
|
text: string;
|
|
909
915
|
alexaUserId: string;
|
|
910
916
|
alexaSessionId?: string;
|
|
917
|
+
deviceId?: string;
|
|
918
|
+
locale?: string;
|
|
919
|
+
/** Alexa Directive Service base URL — passed through from the relay. */
|
|
920
|
+
apiEndpoint?: string;
|
|
921
|
+
/** Alexa apiAccessToken for the current request — required to fire Progressive Response. */
|
|
922
|
+
apiAccessToken?: string;
|
|
923
|
+
/** Original Alexa requestId — required to fire Progressive Response. */
|
|
924
|
+
requestId?: string;
|
|
911
925
|
timeoutMs?: number;
|
|
912
926
|
}): Promise<string> {
|
|
913
|
-
const { text, alexaUserId, alexaSessionId, timeoutMs = 25_000 } = opts;
|
|
927
|
+
const { text, alexaUserId, alexaSessionId, deviceId, locale, apiEndpoint, apiAccessToken, requestId, timeoutMs = 25_000 } = opts;
|
|
914
928
|
const { workerApi, broadcastBloby, getModel } = this.opts;
|
|
915
929
|
const model = getModel();
|
|
916
930
|
|
|
@@ -998,9 +1012,19 @@ export class ChannelManager {
|
|
|
998
1012
|
|
|
999
1013
|
// Reserve the resolver slot FIRST — the agent may respond very quickly and
|
|
1000
1014
|
// we don't want the bot:response to arrive before the resolver is in the FIFO.
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
const
|
|
1015
|
+
// Also pass the Alexa Directive Service credentials so the channel can fire
|
|
1016
|
+
// Progressive Response on every preamble chunk the agent emits before tool calls.
|
|
1017
|
+
const creds = (apiEndpoint && apiAccessToken && requestId)
|
|
1018
|
+
? { apiEndpoint, apiAccessToken, requestId }
|
|
1019
|
+
: null;
|
|
1020
|
+
const pending = alexa.reservePending(convId, creds, timeoutMs);
|
|
1021
|
+
|
|
1022
|
+
// Compact device id so the tag stays readable — agent can correlate the
|
|
1023
|
+
// last 8 chars with its memory of "device XYZ = kitchen / office / ..."
|
|
1024
|
+
const deviceTag = deviceId ? ` | device=${deviceId.slice(-8)}` : '';
|
|
1025
|
+
const sessionTag = alexaSessionId ? ` | session=${alexaSessionId.slice(-6)}` : '';
|
|
1026
|
+
const localeTag = locale ? ` | ${locale}` : '';
|
|
1027
|
+
const channelContext = `[Alexa | user=${alexaUserId.slice(-8)}${deviceTag}${sessionTag}${localeTag}]\n`;
|
|
1004
1028
|
const target: RoutingTarget = {
|
|
1005
1029
|
surface: 'alexa',
|
|
1006
1030
|
isSelfChat: false,
|
|
@@ -70,8 +70,10 @@ export interface ChannelStatus {
|
|
|
70
70
|
* the turn ends without a response (error / empty turn).
|
|
71
71
|
*/
|
|
72
72
|
export interface RoutingTarget {
|
|
73
|
-
/** Which surface triggered this turn. Drives whether the WA reply carries a "🤖 Bot:" prefix.
|
|
74
|
-
surface
|
|
73
|
+
/** Which surface triggered this turn. Drives whether the WA reply carries a "🤖 Bot:" prefix.
|
|
74
|
+
* 'workspace' is a dashboard surface like 'chat' (broadcast-driven, optional WA self-chat mirror)
|
|
75
|
+
* but isolated for telemetry / future per-surface routing. */
|
|
76
|
+
surface: 'chat' | 'whatsapp' | 'alexa' | 'workspace';
|
|
75
77
|
/** WhatsApp JID to deliver the reply to.
|
|
76
78
|
* - 'whatsapp' surface → the originating chat JID (group or peer).
|
|
77
79
|
* - 'chat' surface → optionally the user's own number (self-chat mirror), or undefined.
|