@voicethere/agent 0.5.4 → 0.5.6
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/dist/agent.js +6 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +13 -1
- package/dist/runtime.js.map +1 -1
- package/dist/templates/echo/agent.js +3 -0
- package/dist/templates/echo-dc/agent.js +3 -0
- package/dist/templates/game-sync/agent.js +119 -49
- package/dist/templates/recording-consent/agent.js +1003 -0
- package/dist/templates/registry.d.ts.map +1 -1
- package/dist/templates/registry.js +11 -0
- package/dist/templates/registry.js.map +1 -1
- package/dist/templates/voice-showcase/agent.js +168 -10
- package/dist/templates/voice-starter/agent.js +3 -0
- package/dist/templates/webhooks/agent.js +3 -0
- package/dist/templates/webhooks-redis/agent.js +3 -0
- package/package.json +1 -1
- package/templates/README.md +10 -4
- package/templates/game-sync-protocol.ts +13 -0
- package/templates/game-sync-world-layout.ts +27 -0
- package/templates/game-sync.ts +114 -60
- package/templates/recording-consent/agent.ts +162 -0
- package/templates/recording-consent/conversation.ts +300 -0
- package/templates/voice-showcase/conversation.ts +17 -6
- package/templates/voice-showcase/weather.ts +184 -5
package/templates/game-sync.ts
CHANGED
|
@@ -7,24 +7,27 @@
|
|
|
7
7
|
* [objectId, posX, posY, posZ, posW, dirX, dirY, dirZ, dirW]
|
|
8
8
|
*
|
|
9
9
|
* With project Redis (`AGENT_REDIS_URL`), the world blob is shared across runner
|
|
10
|
-
* workers (key `game-sync:world`).
|
|
11
|
-
*
|
|
10
|
+
* workers (key `game-sync:world`). World writes (allocate/release Lua, sim SET)
|
|
11
|
+
* serialize on `game-sync:sim-lock`; one holder runs physics per tick.
|
|
12
12
|
*
|
|
13
13
|
* Control messages:
|
|
14
14
|
* - `{ type: "register" }` -> allocates (or reuses) one 9-float slot
|
|
15
15
|
* - server replies `{ type: "register_ack", objectId }`
|
|
16
16
|
* - `{ type: "register_nack", reason: "world_full", maxObjects: 25 }` when cap reached
|
|
17
|
-
* - `{ type: "
|
|
17
|
+
* - `{ type: "remove", objectId }` or `{ type: "unregister", objectId?: number }` ->
|
|
18
|
+
* zeros that live slot (objectId required for click-to-remove; omit objectId on
|
|
19
|
+
* unregister to drop the highest object owned by this session)
|
|
18
20
|
* - `{ type: "unregister_ack", objectId }` or `{ type: "unregister_nack", reason }`
|
|
19
21
|
*
|
|
20
22
|
* Simulation:
|
|
21
23
|
* - server-authoritative movement at 60Hz
|
|
22
24
|
* - wall bounce + object-object elastic collisions on server
|
|
23
25
|
* - clients render server snapshots; client binary writes are ignored
|
|
26
|
+
* - with project Redis, the sim loop keeps running with zero connected clients so
|
|
27
|
+
* objects persist and keep moving after everyone disconnects
|
|
24
28
|
*
|
|
25
29
|
* Broadcast:
|
|
26
|
-
* - 60Hz world-state broadcast
|
|
27
|
-
* - stops when connected client count drops below 1
|
|
30
|
+
* - 60Hz world-state broadcast while the sim loop runs (no-op send when 0 sessions)
|
|
28
31
|
*
|
|
29
32
|
* Build:
|
|
30
33
|
* npx @voicethere/agent build --entry templates/game-sync.ts
|
|
@@ -42,9 +45,9 @@ import {
|
|
|
42
45
|
parseChatCommand,
|
|
43
46
|
parseRegisterCommand,
|
|
44
47
|
parseUnregisterCommand,
|
|
48
|
+
resolveRemoveTarget,
|
|
45
49
|
REGISTER_NACK_REASON_WORLD_FULL,
|
|
46
50
|
UNREGISTER_NACK_REASON_NOT_FOUND,
|
|
47
|
-
UNREGISTER_NACK_REASON_NOT_OWNER,
|
|
48
51
|
} from "./game-sync-protocol.js";
|
|
49
52
|
import {
|
|
50
53
|
LUA_ALLOCATE_OBJECT,
|
|
@@ -59,6 +62,7 @@ import {
|
|
|
59
62
|
} from "./game-sync-sim.js";
|
|
60
63
|
import {
|
|
61
64
|
collectActiveObjectIds,
|
|
65
|
+
commitSimulatedWorld,
|
|
62
66
|
countLiveObjects,
|
|
63
67
|
createEmptyWorldBuffer,
|
|
64
68
|
findFirstEmptySlot,
|
|
@@ -75,6 +79,7 @@ import {
|
|
|
75
79
|
const BROADCAST_HZ = 60;
|
|
76
80
|
const BROADCAST_INTERVAL_MS = Math.floor(1000 / BROADCAST_HZ);
|
|
77
81
|
const SIM_LOCK_TTL_MS = BROADCAST_INTERVAL_MS * 2;
|
|
82
|
+
const SIM_LOCK_RETRY_MS = 5;
|
|
78
83
|
const MIN_SPEED = 90;
|
|
79
84
|
const MAX_SPEED = 180;
|
|
80
85
|
|
|
@@ -86,6 +91,54 @@ const freeSlots: number[] = [];
|
|
|
86
91
|
let worldState = createEmptyWorldBuffer();
|
|
87
92
|
let redis: Redis | null = null;
|
|
88
93
|
let broadcastTimer: NodeJS.Timeout | null = null;
|
|
94
|
+
let worldMutationChain: Promise<void> = Promise.resolve();
|
|
95
|
+
|
|
96
|
+
function withWorldMutation<T>(fn: () => Promise<T>): Promise<T> {
|
|
97
|
+
const run = worldMutationChain.then(fn);
|
|
98
|
+
worldMutationChain = run.then(
|
|
99
|
+
() => undefined,
|
|
100
|
+
() => undefined,
|
|
101
|
+
);
|
|
102
|
+
return run;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function sleep(ms: number): Promise<void> {
|
|
106
|
+
return new Promise((resolve) => {
|
|
107
|
+
setTimeout(resolve, ms);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function withRedisSimLock<T>(
|
|
112
|
+
fn: () => Promise<T>,
|
|
113
|
+
options?: { retryUntilAcquired?: boolean },
|
|
114
|
+
): Promise<T | null> {
|
|
115
|
+
if (!redis) {
|
|
116
|
+
return fn();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const retryUntilAcquired = options?.retryUntilAcquired ?? true;
|
|
120
|
+
|
|
121
|
+
while (true) {
|
|
122
|
+
const lockAcquired = await redis.set(
|
|
123
|
+
REDIS_SIM_LOCK_KEY,
|
|
124
|
+
"1",
|
|
125
|
+
"PX",
|
|
126
|
+
SIM_LOCK_TTL_MS,
|
|
127
|
+
"NX",
|
|
128
|
+
);
|
|
129
|
+
if (lockAcquired === "OK") {
|
|
130
|
+
try {
|
|
131
|
+
return await fn();
|
|
132
|
+
} finally {
|
|
133
|
+
await redis.del(REDIS_SIM_LOCK_KEY);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (!retryUntilAcquired) {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
await sleep(SIM_LOCK_RETRY_MS);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
89
142
|
|
|
90
143
|
interface TrackedObjectInfo {
|
|
91
144
|
objectId: number;
|
|
@@ -208,6 +261,7 @@ async function registerObjectInRedis(
|
|
|
208
261
|
}
|
|
209
262
|
|
|
210
263
|
attachObjectToSession(sessionId, objectId);
|
|
264
|
+
worldState = await loadWorldFromRedis();
|
|
211
265
|
return objectId;
|
|
212
266
|
}
|
|
213
267
|
|
|
@@ -228,43 +282,37 @@ async function releaseObjectInRedis(objectId: number): Promise<boolean> {
|
|
|
228
282
|
String(objectId),
|
|
229
283
|
REDIS_EVAL_KEYS.headers,
|
|
230
284
|
);
|
|
285
|
+
if (Number(released) === 1) {
|
|
286
|
+
worldState = await loadWorldFromRedis();
|
|
287
|
+
}
|
|
231
288
|
return Number(released) === 1;
|
|
232
289
|
}
|
|
233
290
|
|
|
234
|
-
function highestOwnedObjectId(sessionId: string): number | null {
|
|
235
|
-
const owned = sessionObjects.get(sessionId);
|
|
236
|
-
if (!owned || owned.size === 0) return null;
|
|
237
|
-
return Math.max(...owned);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
291
|
async function unregisterObject(
|
|
241
292
|
sessionId: string,
|
|
242
293
|
objectId?: number,
|
|
243
294
|
): Promise<{ ok: true; objectId: number } | { ok: false; reason: string }> {
|
|
244
295
|
const owned = sessionObjects.get(sessionId);
|
|
245
|
-
|
|
246
|
-
|
|
296
|
+
const target = resolveRemoveTarget(objectId, owned);
|
|
297
|
+
if (!target.ok) {
|
|
298
|
+
return target;
|
|
247
299
|
}
|
|
248
300
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
if (
|
|
253
|
-
return
|
|
254
|
-
}
|
|
255
|
-
targetId = highest;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
if (!owned.has(targetId)) {
|
|
259
|
-
if (objectId !== undefined) {
|
|
260
|
-
return { ok: false, reason: UNREGISTER_NACK_REASON_NOT_OWNER };
|
|
301
|
+
const previousOwner = objectOwners.get(target.objectId) ?? sessionId;
|
|
302
|
+
notifyObjectReleased(target.objectId, previousOwner);
|
|
303
|
+
const released = await withWorldMutation(async () => {
|
|
304
|
+
if (!redis) {
|
|
305
|
+
return releaseObjectInRedis(target.objectId);
|
|
261
306
|
}
|
|
307
|
+
const result = await withRedisSimLock(() =>
|
|
308
|
+
releaseObjectInRedis(target.objectId),
|
|
309
|
+
);
|
|
310
|
+
return result === true;
|
|
311
|
+
});
|
|
312
|
+
if (!released) {
|
|
262
313
|
return { ok: false, reason: UNREGISTER_NACK_REASON_NOT_FOUND };
|
|
263
314
|
}
|
|
264
|
-
|
|
265
|
-
notifyObjectReleased(targetId, sessionId);
|
|
266
|
-
await releaseObjectInRedis(targetId);
|
|
267
|
-
return { ok: true, objectId: targetId };
|
|
315
|
+
return { ok: true, objectId: target.objectId };
|
|
268
316
|
}
|
|
269
317
|
|
|
270
318
|
function trackedObjectsSnapshot(): TrackedObjectInfo[] {
|
|
@@ -328,26 +376,28 @@ async function saveWorldToRedis(world: Float32Array): Promise<void> {
|
|
|
328
376
|
|
|
329
377
|
async function runSimulationTick(): Promise<void> {
|
|
330
378
|
if (!redis) {
|
|
331
|
-
|
|
332
|
-
|
|
379
|
+
await withWorldMutation(async () => {
|
|
380
|
+
const activeObjectIds = [...objectOwners.keys()];
|
|
381
|
+
simulateWorldStep(worldState, 1 / BROADCAST_HZ, activeObjectIds);
|
|
382
|
+
});
|
|
333
383
|
broadcastWorldBuffer(worldState);
|
|
334
384
|
return;
|
|
335
385
|
}
|
|
336
386
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
}
|
|
387
|
+
await withWorldMutation(async () => {
|
|
388
|
+
await withRedisSimLock(
|
|
389
|
+
async () => {
|
|
390
|
+
const world = await loadWorldFromRedis();
|
|
391
|
+
const activeObjectIds = collectActiveObjectIds(world);
|
|
392
|
+
simulateWorldStep(world, 1 / BROADCAST_HZ, activeObjectIds);
|
|
393
|
+
const latestRedis = await loadWorldFromRedis();
|
|
394
|
+
commitSimulatedWorld(world, latestRedis);
|
|
395
|
+
await saveWorldToRedis(world);
|
|
396
|
+
worldState = world;
|
|
397
|
+
},
|
|
398
|
+
{ retryUntilAcquired: false },
|
|
399
|
+
);
|
|
400
|
+
});
|
|
351
401
|
|
|
352
402
|
const world = await loadWorldFromRedis();
|
|
353
403
|
worldState = world;
|
|
@@ -356,10 +406,10 @@ async function runSimulationTick(): Promise<void> {
|
|
|
356
406
|
|
|
357
407
|
function startBroadcastLoopIfNeeded(): void {
|
|
358
408
|
if (broadcastTimer) return;
|
|
359
|
-
if (connectedSessions.size < 1) return;
|
|
409
|
+
if (!redis && connectedSessions.size < 1) return;
|
|
360
410
|
|
|
361
411
|
broadcastTimer = setInterval(() => {
|
|
362
|
-
if (connectedSessions.size < 1) {
|
|
412
|
+
if (!redis && connectedSessions.size < 1) {
|
|
363
413
|
if (broadcastTimer) {
|
|
364
414
|
clearInterval(broadcastTimer);
|
|
365
415
|
broadcastTimer = null;
|
|
@@ -376,6 +426,7 @@ function startBroadcastLoopIfNeeded(): void {
|
|
|
376
426
|
}
|
|
377
427
|
|
|
378
428
|
function stopBroadcastLoopIfNeeded(): void {
|
|
429
|
+
if (redis) return;
|
|
379
430
|
if (connectedSessions.size >= 1) return;
|
|
380
431
|
if (!broadcastTimer) return;
|
|
381
432
|
clearInterval(broadcastTimer);
|
|
@@ -409,8 +460,9 @@ defineAgent({
|
|
|
409
460
|
lazyConnect: true,
|
|
410
461
|
});
|
|
411
462
|
await redis.connect();
|
|
412
|
-
worldState = createEmptyWorldBuffer();
|
|
413
463
|
await ensureRedisWorldInitialized();
|
|
464
|
+
worldState = await loadWorldFromRedis();
|
|
465
|
+
startBroadcastLoopIfNeeded();
|
|
414
466
|
agentLog("info", "game-sync agent connected to project Redis world buffer");
|
|
415
467
|
},
|
|
416
468
|
|
|
@@ -426,16 +478,12 @@ defineAgent({
|
|
|
426
478
|
|
|
427
479
|
async onClientLeave({ sessionId }) {
|
|
428
480
|
connectedSessions.delete(sessionId);
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
for (const objectId of [...owned]) {
|
|
433
|
-
notifyObjectReleased(objectId, sessionId);
|
|
434
|
-
await releaseObjectInRedis(objectId);
|
|
481
|
+
for (const [objectId, ownerSessionId] of objectOwners) {
|
|
482
|
+
if (ownerSessionId === sessionId) {
|
|
483
|
+
objectOwners.delete(objectId);
|
|
435
484
|
}
|
|
436
|
-
sessionObjects.delete(sessionId);
|
|
437
485
|
}
|
|
438
|
-
|
|
486
|
+
sessionObjects.delete(sessionId);
|
|
439
487
|
stopBroadcastLoopIfNeeded();
|
|
440
488
|
agentLog(
|
|
441
489
|
"info",
|
|
@@ -445,9 +493,15 @@ defineAgent({
|
|
|
445
493
|
|
|
446
494
|
async onDataChannelMessage(ctx) {
|
|
447
495
|
if (parseRegisterCommand(ctx.message)) {
|
|
448
|
-
const objectId =
|
|
449
|
-
|
|
450
|
-
|
|
496
|
+
const objectId = await withWorldMutation(async () => {
|
|
497
|
+
if (!redis) {
|
|
498
|
+
return registerObjectInMemory(ctx.sessionId);
|
|
499
|
+
}
|
|
500
|
+
const result = await withRedisSimLock(() =>
|
|
501
|
+
registerObjectInRedis(ctx.sessionId),
|
|
502
|
+
);
|
|
503
|
+
return result;
|
|
504
|
+
});
|
|
451
505
|
if (objectId === null) {
|
|
452
506
|
sendToClient(ctx.sessionId, {
|
|
453
507
|
type: "register_nack",
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recording consent template — ask consent, pause for PII, resume when allowed.
|
|
3
|
+
*
|
|
4
|
+
* Build:
|
|
5
|
+
* npx @voicethere/agent build --entry templates/recording-consent/agent.ts
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
agentLog,
|
|
9
|
+
defineAgent,
|
|
10
|
+
parseChatText,
|
|
11
|
+
pauseRecording,
|
|
12
|
+
resumeRecording,
|
|
13
|
+
sendToClient,
|
|
14
|
+
speak,
|
|
15
|
+
stopRecording,
|
|
16
|
+
type SpeechEvent,
|
|
17
|
+
} from "@voicethere/agent";
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
beginSession,
|
|
21
|
+
handleUtterance,
|
|
22
|
+
type ConversationState,
|
|
23
|
+
type ConversationTurnResult,
|
|
24
|
+
type OutboundMessage,
|
|
25
|
+
type RecordingAction,
|
|
26
|
+
} from "./conversation.js";
|
|
27
|
+
|
|
28
|
+
let sessions = new Map<string, ConversationState>();
|
|
29
|
+
/** Per-session consent flag (shared-child safe — not one process-wide boolean). */
|
|
30
|
+
let consentBySessionId = new Map<string, boolean>();
|
|
31
|
+
|
|
32
|
+
function getState(sessionId: string): ConversationState {
|
|
33
|
+
const state = sessions.get(sessionId);
|
|
34
|
+
if (!state) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`recording-consent: missing session state for ${sessionId}`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
return state;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function relaySpeechEvent(sessionId: string, event: SpeechEvent): void {
|
|
43
|
+
sendToClient(sessionId, {
|
|
44
|
+
type: "agent_event",
|
|
45
|
+
event: event.type,
|
|
46
|
+
text: event.text,
|
|
47
|
+
raw: event,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function deliverMessages(sessionId: string, messages: OutboundMessage[]): void {
|
|
52
|
+
for (const message of messages) {
|
|
53
|
+
sendToClient(sessionId, message);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function speakLines(sessionId: string, lines: string[]): void {
|
|
58
|
+
for (const line of lines) {
|
|
59
|
+
speak(sessionId, line);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function applyRecordingAction(
|
|
64
|
+
sessionId: string,
|
|
65
|
+
action: RecordingAction,
|
|
66
|
+
): Promise<void> {
|
|
67
|
+
if (!action) return;
|
|
68
|
+
switch (action) {
|
|
69
|
+
case "pause":
|
|
70
|
+
await pauseRecording(sessionId);
|
|
71
|
+
break;
|
|
72
|
+
case "stop":
|
|
73
|
+
await stopRecording(sessionId);
|
|
74
|
+
break;
|
|
75
|
+
case "start":
|
|
76
|
+
case "resume":
|
|
77
|
+
await resumeRecording(sessionId);
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function applyTurn(
|
|
83
|
+
sessionId: string,
|
|
84
|
+
result: ConversationTurnResult,
|
|
85
|
+
): Promise<void> {
|
|
86
|
+
sessions.set(sessionId, result.state);
|
|
87
|
+
if (result.state.consent !== undefined) {
|
|
88
|
+
consentBySessionId.set(sessionId, result.state.consent);
|
|
89
|
+
}
|
|
90
|
+
if (result.warnRecordingDisabled) {
|
|
91
|
+
agentLog(
|
|
92
|
+
"warn",
|
|
93
|
+
"Project conversation recording is disabled; skipping consent and will not call startRecording",
|
|
94
|
+
sessionId,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
speakLines(sessionId, result.speakLines);
|
|
98
|
+
deliverMessages(sessionId, result.messages);
|
|
99
|
+
await applyRecordingAction(sessionId, result.recordingAction);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function onUserText(sessionId: string, text: string): Promise<void> {
|
|
103
|
+
const state = getState(sessionId);
|
|
104
|
+
const result = handleUtterance(state, text);
|
|
105
|
+
await applyTurn(sessionId, result);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
defineAgent({
|
|
109
|
+
onAgentStart() {
|
|
110
|
+
sessions = new Map();
|
|
111
|
+
consentBySessionId = new Map();
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
onSessionStart({ sessionId, recordingAvailable }) {
|
|
115
|
+
const result = beginSession(recordingAvailable);
|
|
116
|
+
sessions.set(sessionId, result.state);
|
|
117
|
+
if (result.warnRecordingDisabled) {
|
|
118
|
+
agentLog(
|
|
119
|
+
"warn",
|
|
120
|
+
"Project conversation recording is disabled; skipping consent and will not call startRecording",
|
|
121
|
+
sessionId,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
speakLines(sessionId, result.speakLines);
|
|
125
|
+
deliverMessages(sessionId, result.messages);
|
|
126
|
+
sendToClient(sessionId, {
|
|
127
|
+
type: "agent_event",
|
|
128
|
+
event: "session_start",
|
|
129
|
+
sessionId,
|
|
130
|
+
});
|
|
131
|
+
agentLog(
|
|
132
|
+
"info",
|
|
133
|
+
`recording-consent session_start ${sessionId} recordingAvailable=${recordingAvailable}`,
|
|
134
|
+
sessionId,
|
|
135
|
+
);
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
onSpeechEvent({ sessionId }, event: SpeechEvent) {
|
|
139
|
+
relaySpeechEvent(sessionId, event);
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
onUserSpeechFinal({ sessionId, text }) {
|
|
143
|
+
void onUserText(sessionId, text);
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
onDataChannelMessage(ctx) {
|
|
147
|
+
const text = parseChatText(ctx.message);
|
|
148
|
+
if (!text) return;
|
|
149
|
+
void onUserText(ctx.sessionId, text);
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
onSessionEnd({ sessionId }) {
|
|
153
|
+
sessions.delete(sessionId);
|
|
154
|
+
consentBySessionId.delete(sessionId);
|
|
155
|
+
sendToClient(sessionId, {
|
|
156
|
+
type: "agent_event",
|
|
157
|
+
event: "session_end",
|
|
158
|
+
sessionId,
|
|
159
|
+
});
|
|
160
|
+
agentLog("info", `recording-consent session_end ${sessionId}`, sessionId);
|
|
161
|
+
},
|
|
162
|
+
});
|