@glyphteck/veyl 0.71.3 → 0.73.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.
package/docs/api.md CHANGED
@@ -25,6 +25,8 @@ The auth and account compositions consume explicit runtime ports and have no dir
25
25
 
26
26
  Most product methods ensure login and vault unlock when saved account and vault keys are available. Long-running callers should explicitly login/unlock once and call `close()` when finished.
27
27
 
28
+ `open({ onOperation(event) })` can observe local firebase operations, default chat/presence WebSocket frame counts, and SDK wallet-claim calls. events contain operation names, lifecycle status, counts, latency, and cache/pending-write flags; they omit payloads, document paths, account/chat IDs, and capabilities. this hook observes the default adapters, not custom cloud/live ports or server internals, and its counts are not billing units. wallet-claim observations count the SDK invocation, not Spark's internal requests. see the repository's `costs/benchmarks.md` for controlled dev scenarios and provider reconciliation.
29
+
28
30
  ### Local access and connectivity
29
31
 
30
32
  The shared account owner separates `localReady`, `online`, and `connection` in its snapshot. `localReady` means the exact locally signed-in account has enough encrypted bootstrap data for vault unlock; it is not server authorization. Unlock opens encrypted cached chats and wallet history without waiting for cloud services. Cached balance is display-only and must be labeled last known. `online` becomes true after cloud reads and online proof succeed; reconnect attaches services to the same unlocked session. Text sends wait up to 30 seconds for connectivity, then remain encrypted on-device as failed attempts for manual retry; reactions expire without a retry row. Avatar changes wait only for the current session, as described below. These are domain-owned pending operations, not a general cloud mutation queue. Live wallet readiness remains mandatory for payments.
@@ -174,7 +176,7 @@ await account.close();
174
176
 
175
177
  `account.peers` is the shared peer-directory owner used by Node, web, and iOS. `openAccount()` supplies its chat, wallet, blocked-user, and encrypted-cache sources directly, including missing-profile chat cleanup. Graphical adapters only subscribe to the same snapshot and profile selectors already defined by core. `openSearch('profiles')` creates profile-only search; web may also use `openSearch('mainmenu')` for its combined local-action and remote-profile menu. Active searches track peer and blocked-user changes and release those subscriptions when cleared or closed.
176
178
 
177
- `account.chat` is the one account-scoped protocol-v3 chat owner used by Node, web, and iOS. It owns notes/direct/group creation, stable logical chat IDs, membership epochs, encrypted list/inbox convergence, epoch-spanning history, delivery, messages, receipts, signed encrypted chat settings, member changes, leaving, and deletion. It clears every route, cache, timer, and key-bearing state on lock/account switch. Graphical providers only expose this owner through React; platform media preparation and native behavior enter through explicit ports.
179
+ `account.chat` is the one account-scoped protocol-4 chat owner used by Node, web, and iOS. It owns notes/direct/group creation, stable logical chat IDs, membership epochs, encrypted list/inbox convergence, epoch-spanning history, delivery, messages, receipts, signed encrypted chat settings, member changes, leaving, and deletion. It clears every route, cache, timer, and key-bearing state on lock/account switch. Graphical providers only expose this owner through React; platform media preparation and native behavior enter through explicit ports.
178
180
 
179
181
  `account.wallet` is the stable account-scoped wallet composition used by Node, web, and iOS. It owns the one live `core/wallet/session.js` engine, its transfer store, cached pre-Spark display state, transaction aggregation, wallet-derived peer recency, late Spark attachment, and lock teardown. `getSnapshot()` preserves the shared `{ value, txValue }` contract; `transactions` exposes aggregate/search/chart data and `transfers` exposes focused list and keyed subscriptions. Graphical wallet providers only adapt those SDK subscriptions and add browser/native behavior through the `wallet` platform ports.
180
182
 
@@ -391,11 +393,15 @@ await veyl.chat.sendAttachment('@alice', {
391
393
  });
392
394
 
393
395
  await veyl.chat.sendAttachmentMany(['@alice', '@bob'], attachment);
394
- await veyl.chat.share('@alice', messageId, ['@bob']);
396
+ await veyl.chat.forward('@alice', messageId, ['@bob']);
395
397
  const downloaded = await veyl.chat.readAttachment('@alice', messageId);
396
398
  ```
397
399
 
398
- The caller supplies/consumes bytes; shared owners still derive paths, encrypt/decrypt, upload/download, create message actions, and project media cache state.
400
+ the caller supplies/consumes bytes; shared owners derive paths, encrypt/decrypt, upload/download, create message actions, and project media cache state. `chat.forward` forwards an existing attachment. forwarded attachments cannot be saved forever, and forwarding reuses the original encrypted object without extending its expiry or transmitting management authority. participants in the original conversation keep the existing save/unsave authority.
401
+
402
+ audio attachment objects may include `duration` in fractional seconds and optional `waveform`: canonical padded base64 encoding of 32 unsigned bytes, representing peak-normalized rms amplitude in equal-duration bins. compact audio events expose the same validated `message.waveform`, and sharing/echoing should preserve it. generic sdk/cli uploads do not decode audio to generate it; the command schema does not accept it, and graphical clients display a stable generic waveform when it is absent. that placeholder is presentation-only and is never attached to a message as measured audio. real waveform data stays inside the encrypted message.
403
+
404
+ media metadata is removed automatically before encryption, including when recognizable media is sent as a generic file. supply prepared jpeg, png, webp, gif, or supported mp4/mov/m4a bytes; video must use h.264 or hevc. normalize other image/video formats first. raw heic/avif/webm, unsupported movie tracks/codecs, and malformed media reject with `media-sanitization-failed` instead of uploading the original. orientation, color transforms, animation timing, audio/video synchronization, and display matrices are retained. this does not scrub ordinary documents, visible/audible content, or authored filenames/captions. public `account.profile.setAvatar` also sanitizes prepared webp bytes before publishing.
399
405
 
400
406
  ## Invites
401
407
 
package/docs/cli.md CHANGED
@@ -117,7 +117,7 @@ Attachments use filesystem paths in the CLI while the shared owners still perfor
117
117
  ```bash
118
118
  veyl chat send-file @alice ./photo.webp --mime image/webp --caption "caption"
119
119
  veyl chat send-file-many @alice,@bob ./document.pdf --name document.pdf
120
- veyl chat share @alice MESSAGE_ID @bob,@carol
120
+ veyl chat forward @alice MESSAGE_ID @bob,@carol
121
121
  veyl chat download @alice MESSAGE_ID ./downloaded-file
122
122
  ```
123
123
 
@@ -169,15 +169,7 @@ Cold one-off commands are inherently inefficient for agent loops because Spark b
169
169
 
170
170
  ## Server-operation cost shape
171
171
 
172
- The executable source is `costs/model.mjs`.
173
-
174
- Protocol v3 separates constant content cost from recipient availability cost. One visible action commits one ciphertext, one logical-chat budget update, and at most one coalesced sender owner row: 6 planning reads and 3 writes independent of group size. Each accepted recipient wake adds two route/slot reads, one minimum push-route query when APNs is due, one overwritten slot write, and one Function invocation. The default one-edge/direct upper bound is therefore 9 reads, 4 writes, and 1 invocation. Read receipts add one shared 6-read/2-write action with no wake.
175
-
176
- The current one-edge planning bundle is about 542 Firestore read-equivalent operations, 163 writes, 28 Function invocations, and one Storage Class A operation per active user/day. Headline projections are about `$2,519/month` at 100k DAU and `$25,217/month` at 1M DAU. At a sustained one visible send per second, the model is `$33.70` gross per month, or `$52.36` with one receipt per send. Set `MESSAGE_RECIPIENT_EDGES` to model larger groups; sender, wake-slot, and APNs coalescing reduce the isolated-send upper bound.
177
-
178
- Earlier live Cloud Run measurements covered the retired direct-chat delivery contract and must not be treated as v3 evidence. Current source uses anonymous opaque capability delivery, a recipient-owned overwritten slot, prompt-plus-trailing sender burst coalescing, a 250 ms slot debounce, and a 2 s APNs debounce. Dev traces must establish the real accepted-wake ratio before production projections are tightened.
179
-
180
- These totals intentionally exclude Function CPU/memory duration, outbound network, APNS delivery, Spark/vendor/network fees, media download bytes, and moderation labor until real rates are measured.
172
+ the repository's `costs/README.md` owns cost documentation, `costs/model.mjs` owns the partial workload calculation, and `costs/dau-scale.md` contains generated projections. packaged SDK validation deliberately does not duplicate their prices or operation coefficients. source-level behavior and dated runtime samples do not establish current billing.
181
173
 
182
174
  The live L1 path confirmed that withdrawal, confirmed-UTXO discovery, claim, balance, and transfer status do not call a Veyl wallet Function or write wallet state to Firestore. They are client-local Spark SDK operations against Spark/network services. Active unlocked wallets currently run one identity-scoped deposit check every 20 seconds on REGTEST or every minute on MAINNET; a pending cooperative exit adds one exact-transfer Spark check at most every 15 seconds for ten minutes. Neither cadence is a Firebase cost. The sample consumed 2,190 sats for cooperative exit and 99 sats for the static claim, while all 3,711 recoverable sats were returned to the faucet.
183
175
 
@@ -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,12 +8,13 @@ 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']);
13
15
  const NORMAL_CLAIM_INTERVAL_MS = 5 * 60_000;
14
16
  const FAUCET_CLAIM_INTERVAL_MS = 30_000;
15
- const MAX_CLAIM_BACKOFF_MS = 2 * 60_000;
17
+ const MAX_CLAIM_BACKOFF_MS = 30 * 60_000;
16
18
  const TYPING_HEARTBEAT_MS = 2_000;
17
19
 
18
20
  function rolesFor(account) {
@@ -236,6 +238,9 @@ async function mirrorAttachment(account, client, event) {
236
238
  height: event.message.height || payload.h,
237
239
  duration: event.message.duration || payload.d,
238
240
  audio: typeof event.message.audio === 'boolean' ? event.message.audio : payload.audio,
241
+ ...(event.message.type === 'm4a' && event.message.waveform
242
+ ? { waveform: event.message.waveform }
243
+ : {}),
239
244
  }, {
240
245
  cid: botActionCid(event, 'echo', account),
241
246
  });
@@ -517,6 +522,7 @@ export function createBotFleetPolicy(options = {}) {
517
522
  const echoChatKeys = new Set();
518
523
  const claimStates = new Map();
519
524
  const liveStates = new Map();
525
+ const voiceStates = new Map();
520
526
 
521
527
  function shouldMirror(account, event) {
522
528
  if (isFleetGeneratedMessage(event)) return false;
@@ -588,9 +594,22 @@ export function createBotFleetPolicy(options = {}) {
588
594
  if (roles.has('live')) {
589
595
  await startLiveRole(account, client, liveStates);
590
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
+ }
591
607
  }
592
608
  },
593
609
  async stop({ account }) {
610
+ const voice = voiceStates.get(account.profile);
611
+ voiceStates.delete(account.profile);
612
+ await voice?.close();
594
613
  await stopLiveRole(account, liveStates);
595
614
  await stopClaimLoop(account.profile, claimStates);
596
615
  const username = usernameByProfile.get(account.profile);
@@ -602,6 +621,8 @@ export function createBotFleetPolicy(options = {}) {
602
621
  if (username) managedUsernames.delete(username);
603
622
  },
604
623
  async stopFleet() {
624
+ await Promise.all([...voiceStates.values()].map(voice => voice.close()));
625
+ voiceStates.clear();
605
626
  await Promise.all([...liveStates.keys()].map((profile) =>
606
627
  stopLiveRole({ profile, roles: [] }, liveStates)
607
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,157 @@
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
+ if (!room) {
59
+ if (current.joiningChatId || !['idle', 'error'].includes(current.phase)) await calls.leave();
60
+ return;
61
+ }
62
+ // callId identifies the admitted instance even while ICE is joining or
63
+ // waiting. Only joiningChatId represents an unadmitted candidate.
64
+ if (sameRoom(current, room) && !['idle', 'error'].includes(current.phase)) return;
65
+ const attempt = { ...room, cancelled: false };
66
+ pending = attempt;
67
+ try {
68
+ // The shared owner admits the destination before retiring an
69
+ // existing call. A stale discovery can never create a fresh call.
70
+ await calls.joinExisting(room.chatId, room.callId);
71
+ } catch (error) {
72
+ if (!closed && !attempt.cancelled) fail(room, error);
73
+ } finally {
74
+ if (pending === attempt) pending = null;
75
+ dirty = true;
76
+ }
77
+ }
78
+
79
+ function changed() {
80
+ if (closed) return;
81
+ dirty = true;
82
+ if (pending && !pending.cancelled && !sameRoom(pending, newest())) {
83
+ pending.cancelled = true;
84
+ void calls.cancelJoin().catch(report);
85
+ }
86
+ if (running) return;
87
+ running = Promise.resolve().then(async () => {
88
+ while (dirty && !closed) {
89
+ dirty = false;
90
+ await reconcile();
91
+ }
92
+ }).catch(report).finally(() => {
93
+ running = null;
94
+ if (dirty && !closed) changed();
95
+ });
96
+ }
97
+
98
+ function syncChats(values) {
99
+ if (closed) return;
100
+ const ids = new Set(values.filter(chat => !chat.messageRequest && !chat.membershipRemoved).map(chat => chat.id));
101
+ for (const [chatId, entry] of chats) {
102
+ if (ids.has(chatId)) continue;
103
+ chats.delete(chatId);
104
+ entry.release();
105
+ }
106
+ for (const chatId of ids) {
107
+ if (chats.has(chatId)) continue;
108
+ const entry = { room: null, failed: null, release: () => {} };
109
+ chats.set(chatId, entry);
110
+ entry.release = calls.observe(chatId, value => {
111
+ if (closed || chats.get(chatId) !== entry) return;
112
+ const room = availableRoom(value, chatId, uid);
113
+ if (entry.room?.fingerprint === room?.fingerprint) return;
114
+ entry.room = room;
115
+ entry.failed = null;
116
+ changed();
117
+ });
118
+ }
119
+ changed();
120
+ }
121
+
122
+ return Object.freeze({
123
+ start() {
124
+ startup ||= (async () => {
125
+ await calls.setMuted(true);
126
+ if (closed) return;
127
+ releaseCalls = calls.subscribe(() => {
128
+ const { error } = calls.getSnapshot();
129
+ if (error && error !== lastError && error.code !== 'calls/cancelled') {
130
+ lastError = error;
131
+ const entry = chats.get(error.chatId);
132
+ if (entry?.room) entry.failed = entry.room.fingerprint;
133
+ report(error);
134
+ }
135
+ changed();
136
+ });
137
+ const release = await client.chat.watch(syncChats, { count: 500 });
138
+ if (closed) release();
139
+ else releaseChats = release;
140
+ })();
141
+ return startup;
142
+ },
143
+ close() {
144
+ if (stopping) return stopping;
145
+ closed = true;
146
+ releaseCalls?.();
147
+ releaseChats?.();
148
+ for (const entry of chats.values()) entry.release();
149
+ chats.clear();
150
+ // leave synchronously revokes local playback/join intent. A late
151
+ // watch setup or old join completion cannot retain this role.
152
+ const leaving = calls.leave();
153
+ stopping = Promise.allSettled([startup, running, leaving]).then(() => undefined);
154
+ return stopping;
155
+ },
156
+ });
157
+ }
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.71.3"
57
+ "version": "0.73.0"
57
58
  }