@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.
@@ -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`). One worker holds a sim lock per tick, runs
11
- * physics, and writes the blob; every worker GET+broadcasts to local sessions.
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: "unregister" }` or `{ type: "remove", objectId?: number }` -> releases owned object(s)
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 starts when at least 1 client is connected
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
- if (!owned || owned.size === 0) {
246
- return { ok: false, reason: UNREGISTER_NACK_REASON_NOT_FOUND };
296
+ const target = resolveRemoveTarget(objectId, owned);
297
+ if (!target.ok) {
298
+ return target;
247
299
  }
248
300
 
249
- let targetId = objectId;
250
- if (targetId === undefined) {
251
- const highest = highestOwnedObjectId(sessionId);
252
- if (highest === null) {
253
- return { ok: false, reason: UNREGISTER_NACK_REASON_NOT_FOUND };
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
- const activeObjectIds = [...objectOwners.keys()];
332
- simulateWorldStep(worldState, 1 / BROADCAST_HZ, activeObjectIds);
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
- const lockAcquired = await redis.set(
338
- REDIS_SIM_LOCK_KEY,
339
- "1",
340
- "PX",
341
- SIM_LOCK_TTL_MS,
342
- "NX",
343
- );
344
- if (lockAcquired === "OK") {
345
- const world = await loadWorldFromRedis();
346
- const activeObjectIds = collectActiveObjectIds(world);
347
- simulateWorldStep(world, 1 / BROADCAST_HZ, activeObjectIds);
348
- await saveWorldToRedis(world);
349
- worldState = world;
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
- const owned = sessionObjects.get(sessionId);
431
- if (owned) {
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 = redis
449
- ? await registerObjectInRedis(ctx.sessionId)
450
- : registerObjectInMemory(ctx.sessionId);
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
+ });