@glyphteck/veyl 0.72.0 → 0.74.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.
@@ -1,4 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { createVoiceRole } from './voice.js';
2
3
 
3
4
  export const BOT_FLEET_ROLES = new Set([
4
5
  'read',
@@ -7,6 +8,7 @@ export const BOT_FLEET_ROLES = new Set([
7
8
  'traffic',
8
9
  'live',
9
10
  'typing',
11
+ 'voice',
10
12
  ]);
11
13
  const BOT_UNDERFUNDED_TEXT = 'insufficient funds';
12
14
  const ATTACHMENT_TYPES = new Set(['img', 'gif', 'm4a', 'mp4', 'file']);
@@ -520,6 +522,7 @@ export function createBotFleetPolicy(options = {}) {
520
522
  const echoChatKeys = new Set();
521
523
  const claimStates = new Map();
522
524
  const liveStates = new Map();
525
+ const voiceStates = new Map();
523
526
 
524
527
  function shouldMirror(account, event) {
525
528
  if (isFleetGeneratedMessage(event)) return false;
@@ -591,9 +594,22 @@ export function createBotFleetPolicy(options = {}) {
591
594
  if (roles.has('live')) {
592
595
  await startLiveRole(account, client, liveStates);
593
596
  }
597
+ if (roles.has('voice')) {
598
+ const voice = createVoiceRole(client, current.uid);
599
+ voiceStates.set(account.profile, voice);
600
+ try { await voice.start(); }
601
+ catch (error) {
602
+ voiceStates.delete(account.profile);
603
+ await voice.close();
604
+ throw error;
605
+ }
606
+ }
594
607
  }
595
608
  },
596
609
  async stop({ account }) {
610
+ const voice = voiceStates.get(account.profile);
611
+ voiceStates.delete(account.profile);
612
+ await voice?.close();
597
613
  await stopLiveRole(account, liveStates);
598
614
  await stopClaimLoop(account.profile, claimStates);
599
615
  const username = usernameByProfile.get(account.profile);
@@ -605,6 +621,8 @@ export function createBotFleetPolicy(options = {}) {
605
621
  if (username) managedUsernames.delete(username);
606
622
  },
607
623
  async stopFleet() {
624
+ await Promise.all([...voiceStates.values()].map(voice => voice.close()));
625
+ voiceStates.clear();
608
626
  await Promise.all([...liveStates.keys()].map((profile) =>
609
627
  stopLiveRole({ profile, roles: [] }, liveStates)
610
628
  ));
@@ -12,6 +12,7 @@ The secret-free version-2 manifest stores account indices, usernames, networks,
12
12
  - `traffic` is an eligibility marker for an external local operator policy; it performs no work by itself.
13
13
  - `live` holds the account's normal encrypted live-room connection in each chat without advancing read state or sending messages. The concrete host supplies the same realm-matched portable live transport used by graphical clients; startup rejects this role when that client port is absent.
14
14
  - `typing` requires `live` and continually renews one stable composition through the same encrypted live state until the policy stops.
15
+ - `voice` starts muted and joins the newest active, foreign-occupied call among the account's watched chats. It uses `client.chat.watch(onChats, { count: 500 })`, `client.calls.observe(chatId, onAvailable)`, and strict `joinExisting(chatId, callId)`; it never creates a call or inherits read/echo behavior. The host must supply real `calls.media` and ephemeral `calls.mls` ports. One shared owner admits the new destination before leaving an old call, supports cancellation, and leaves when no other account remains. A failed unchanged room is not retried on every heartbeat.
15
16
 
16
17
  Only response-capable fleet peers are loop-suppressed. Incoming transaction events and staggered fallback loops call the public wallet claim method. Startup replays a bounded visible window; an event checkpoint suppresses completed source messages, while the action journal suppresses completed effects and stops ambiguous payments for explicit reconciliation.
17
18
 
@@ -24,6 +24,13 @@ export async function resolveBotFleetClientOptions(source, profile) {
24
24
  ) {
25
25
  throw new Error(`live bot transport required: ${profile.profile}`);
26
26
  }
27
+ if (profile?.roles?.includes('voice') && (
28
+ typeof options?.calls?.media?.open !== 'function'
29
+ || typeof options?.calls?.media?.prepare !== 'function'
30
+ || typeof options?.calls?.mls?.createMember !== 'function'
31
+ )) {
32
+ throw new Error(`voice bot media and encryption ports required: ${profile.profile}`);
33
+ }
27
34
  return options;
28
35
  }
29
36
 
@@ -41,7 +48,7 @@ export async function createExampleBotFleetRuntime(options = {}) {
41
48
  policies: [],
42
49
  eventOptions: async (profile) => ({
43
50
  replay: true,
44
- chats: profile.roles.includes('read') || profile.roles.includes('live'),
51
+ chats: profile.roles.some(role => ['read', 'live', 'voice'].includes(role)),
45
52
  persistentChats: true,
46
53
  persistentChatIdleMs: options.persistentChatIdleMs || 5 * 60_000,
47
54
  transactions: true,
@@ -0,0 +1,160 @@
1
+ function sameRoom(left, right) {
2
+ return !!left && !!right
3
+ && left.chatId === right.chatId && left.callId === right.callId;
4
+ }
5
+
6
+ function availableRoom(value, chatId, uid) {
7
+ if (value?.chatId !== chatId || !value.callId || !Number.isFinite(value.startedAt)) return null;
8
+ const others = (value.roster || []).filter(peer => peer.uid && peer.uid !== uid);
9
+ if (!others.length) return null;
10
+ // Own admission and audio preferences do not constitute a new opportunity
11
+ // to retry a failed room. Only a new call or changed foreign membership does.
12
+ const members = others.map(peer => [peer.uid, peer.chatPK, peer.holder]).sort();
13
+ return {
14
+ chatId, callId: value.callId, startedAt: value.startedAt,
15
+ fingerprint: JSON.stringify([value.callId, value.startedAt, members]),
16
+ };
17
+ }
18
+
19
+ export function createVoiceRole(client, uid) {
20
+ if (!uid) throw new Error('voice bot account identity required');
21
+ const calls = client.calls;
22
+ const chats = new Map();
23
+ let closed = false;
24
+ let dirty = false;
25
+ let running = null;
26
+ let startup = null;
27
+ let stopping = null;
28
+ let pending = null;
29
+ let releaseChats = null;
30
+ let releaseCalls = null;
31
+ let lastError = null;
32
+
33
+ function report(error) {
34
+ const code = /^[a-z0-9/-]{1,80}$/u.test(error?.code || '') ? error.code : 'calls/failed';
35
+ client.diag?.('bot.voice.error', { code });
36
+ }
37
+
38
+ function newest() {
39
+ let selected = null;
40
+ for (const entry of chats.values()) {
41
+ const room = entry.room;
42
+ if (!room || entry.failed === room.fingerprint) continue;
43
+ if (!selected || room.startedAt > selected.startedAt
44
+ || (room.startedAt === selected.startedAt && room.chatId > selected.chatId)) selected = room;
45
+ }
46
+ return selected;
47
+ }
48
+
49
+ function fail(room, error) {
50
+ const entry = chats.get(room?.chatId);
51
+ if (sameRoom(entry?.room, room)) entry.failed = entry.room.fingerprint;
52
+ if (error?.code !== 'calls/cancelled') report(error);
53
+ }
54
+
55
+ async function reconcile() {
56
+ const room = newest();
57
+ const current = calls.getSnapshot();
58
+ // An admitted logical call owns its continuation. Discovery can move
59
+ // to a new epoch before that room's replacement roster is available.
60
+ if (current.callId && current.joiningChatId === current.chatId && chats.has(current.chatId)) return;
61
+ if (!room) {
62
+ if (current.joiningChatId || !['idle', 'error'].includes(current.phase)) await calls.leave();
63
+ return;
64
+ }
65
+ // callId identifies the admitted instance even while ICE is joining or
66
+ // waiting. Only joiningChatId represents an unadmitted candidate.
67
+ if (sameRoom(current, room) && !['idle', 'error'].includes(current.phase)) return;
68
+ const attempt = { ...room, cancelled: false };
69
+ pending = attempt;
70
+ try {
71
+ // The shared owner admits the destination before retiring an
72
+ // existing call. A stale discovery can never create a fresh call.
73
+ await calls.joinExisting(room.chatId, room.callId);
74
+ } catch (error) {
75
+ if (!closed && !attempt.cancelled) fail(room, error);
76
+ } finally {
77
+ if (pending === attempt) pending = null;
78
+ dirty = true;
79
+ }
80
+ }
81
+
82
+ function changed() {
83
+ if (closed) return;
84
+ dirty = true;
85
+ if (pending && !pending.cancelled && !sameRoom(pending, newest())) {
86
+ pending.cancelled = true;
87
+ void calls.cancelJoin().catch(report);
88
+ }
89
+ if (running) return;
90
+ running = Promise.resolve().then(async () => {
91
+ while (dirty && !closed) {
92
+ dirty = false;
93
+ await reconcile();
94
+ }
95
+ }).catch(report).finally(() => {
96
+ running = null;
97
+ if (dirty && !closed) changed();
98
+ });
99
+ }
100
+
101
+ function syncChats(values) {
102
+ if (closed) return;
103
+ const ids = new Set(values.filter(chat => !chat.messageRequest && !chat.membershipRemoved).map(chat => chat.id));
104
+ for (const [chatId, entry] of chats) {
105
+ if (ids.has(chatId)) continue;
106
+ chats.delete(chatId);
107
+ entry.release();
108
+ }
109
+ for (const chatId of ids) {
110
+ if (chats.has(chatId)) continue;
111
+ const entry = { room: null, failed: null, release: () => {} };
112
+ chats.set(chatId, entry);
113
+ entry.release = calls.observe(chatId, value => {
114
+ if (closed || chats.get(chatId) !== entry) return;
115
+ const room = availableRoom(value, chatId, uid);
116
+ if (entry.room?.fingerprint === room?.fingerprint) return;
117
+ entry.room = room;
118
+ entry.failed = null;
119
+ changed();
120
+ });
121
+ }
122
+ changed();
123
+ }
124
+
125
+ return Object.freeze({
126
+ start() {
127
+ startup ||= (async () => {
128
+ await calls.setMuted(false);
129
+ if (closed) return;
130
+ releaseCalls = calls.subscribe(() => {
131
+ const { error } = calls.getSnapshot();
132
+ if (error && error !== lastError && error.code !== 'calls/cancelled') {
133
+ lastError = error;
134
+ const entry = chats.get(error.chatId);
135
+ if (entry?.room) entry.failed = entry.room.fingerprint;
136
+ report(error);
137
+ }
138
+ changed();
139
+ });
140
+ const release = await client.chat.watch(syncChats, { count: 500 });
141
+ if (closed) release();
142
+ else releaseChats = release;
143
+ })();
144
+ return startup;
145
+ },
146
+ close() {
147
+ if (stopping) return stopping;
148
+ closed = true;
149
+ releaseCalls?.();
150
+ releaseChats?.();
151
+ for (const entry of chats.values()) entry.release();
152
+ chats.clear();
153
+ // leave synchronously revokes local playback/join intent. A late
154
+ // watch setup or old join completion cannot retain this role.
155
+ const leaving = calls.leave();
156
+ stopping = Promise.allSettled([startup, running, leaving]).then(() => undefined);
157
+ return stopping;
158
+ },
159
+ });
160
+ }
package/package.json CHANGED
@@ -25,6 +25,7 @@
25
25
  "examples/bot-fleet/index.js",
26
26
  "examples/bot-fleet/policy.js",
27
27
  "examples/bot-fleet/runtime.js",
28
+ "examples/bot-fleet/voice.js",
28
29
  "examples/bot-fleet/readme.md",
29
30
  "examples/codex-agent/agent-instructions.js",
30
31
  "examples/codex-agent/agent-state.js",
@@ -53,5 +54,5 @@
53
54
  "start": "node src/cli.js",
54
55
  "lint": "eslint src --quiet"
55
56
  },
56
- "version": "0.72.0"
57
+ "version": "0.74.0"
57
58
  }