@glyphteck/veyl 0.60.0 → 0.61.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/agents.md CHANGED
@@ -52,7 +52,7 @@ await veyl.listen({
52
52
  });
53
53
  ```
54
54
 
55
- This is a live subscription to shared chat-list and transfer state, not a polling loop. By default it emits compact peer-authored events and opens a changed chat only long enough to process it, so hundreds of peers do not become hundreds of retained message listeners. Set `compact: false` or `incomingOnly: false` when complete payloads or self-authored messages are required. A long-lived fleet can opt into `persistentChats: true` with `persistentChatIdleMs` to keep active conversations mounted, mark peer messages read before policy effects, and release only idle conversations; the account-level chat-list subscription reopens them when new activity arrives. Each listener has independent replay/filter state. Slow callbacks should hand work to an application queue if they must preserve event throughput.
55
+ This is a live subscription to shared chat-list and transfer state, not a polling loop. By default it emits compact peer-authored events and opens a changed chat only long enough to process it, so hundreds of peers do not become hundreds of retained message listeners. Set `compact: false` or `incomingOnly: false` when complete payloads or self-authored messages are required. A long-lived fleet can opt into `persistentChats: true` with `persistentChatIdleMs` to keep active conversations mounted and release only idle conversations; the account-level chat-list subscription reopens them when new activity arrives. Set `relayReads: true` with `read: true` when the listener itself represents viewing: it briefly joins the ordinary encrypted live room, advances from the already-decrypted message before emitting the event, and coalesces durable receipt writes without a second message lookup. Each listener has independent replay/filter state. Slow callbacks should hand work to an application queue if they must preserve event throughput.
56
56
 
57
57
  Compact message events include stable `chatId`, verified sender identity, member summaries, and reusable `replyTo` and `reactTo` targets. Transaction events carry the same simplified transfer shape returned by wallet history.
58
58
 
package/docs/api.md CHANGED
@@ -435,7 +435,7 @@ await veyl.listen({
435
435
  });
436
436
  ```
437
437
 
438
- `listen` subscribes to the shared live chat-list and transfer-store owners. It emits `ready`, `message`, `message-delete`, `transaction`, and `error` events. Message events carry a stable `chatId`, verified actor/member summaries, and a qualified message target. Compact, incoming-only events and transient chat subscriptions are the defaults: the encrypted chat list acts as a wake index, so a changed chat opens briefly, processes its current and history pages, and releases instead of retaining one listener per chat. Set `compact: false`, `incomingOnly: false`, or `persistentChats: true` only when the caller needs those broader behaviors.
438
+ `listen` subscribes to the shared live chat-list and transfer-store owners. It emits `ready`, `message`, `message-delete`, `transaction`, and `error` events. Message events carry a stable `chatId`, verified actor/member summaries, and a qualified message target. Compact, incoming-only events and transient chat subscriptions are the defaults: the encrypted chat list acts as a wake index, so a changed chat opens briefly, processes its current and history pages, and releases instead of retaining one listener per chat. Set `compact: false`, `incomingOnly: false`, or `persistentChats: true` only when the caller needs those broader behaviors. A headless viewer may set `read: true, relayReads: true`; the listener then advances each peer read from its existing decrypted batch through a short encrypted live-room lease before emitting the event, while the durable write remains coalesced.
439
439
 
440
440
  Use `listen` for account-wide agent wakeups. Use `chat.enterById` when an agent is actively inside any notes/direct/group chat; use `chat.enter(peer)` only as a canonical-direct convenience.
441
441
 
@@ -1,11 +1,19 @@
1
1
  import { createHash } from 'node:crypto';
2
2
 
3
- export const BOT_FLEET_ROLES = new Set(['read', 'echo', 'faucet', 'traffic']);
3
+ export const BOT_FLEET_ROLES = new Set([
4
+ 'read',
5
+ 'echo',
6
+ 'faucet',
7
+ 'traffic',
8
+ 'live',
9
+ 'typing',
10
+ ]);
4
11
  const BOT_UNDERFUNDED_TEXT = 'insufficient funds';
5
12
  const ATTACHMENT_TYPES = new Set(['img', 'gif', 'm4a', 'mp4', 'file']);
6
13
  const NORMAL_CLAIM_INTERVAL_MS = 5 * 60_000;
7
14
  const FAUCET_CLAIM_INTERVAL_MS = 30_000;
8
15
  const MAX_CLAIM_BACKOFF_MS = 2 * 60_000;
16
+ const TYPING_HEARTBEAT_MS = 2_000;
9
17
 
10
18
  function rolesFor(account) {
11
19
  return new Set(account?.roles || []);
@@ -64,6 +72,9 @@ function assertRoles(account) {
64
72
  if ((roles.has('echo') || roles.has('faucet')) && !roles.has('read')) {
65
73
  throw new Error('echo and faucet bot roles require read');
66
74
  }
75
+ if (roles.has('typing') && !roles.has('live')) {
76
+ throw new Error('typing bot role requires live');
77
+ }
67
78
  return roles;
68
79
  }
69
80
 
@@ -108,12 +119,19 @@ function sendAttachmentToEventChat(client, event, attachment, options) {
108
119
  : client.chat.sendAttachment(event.peer, attachment, options);
109
120
  }
110
121
 
111
- function markEventChatRead(client, event) {
112
- const options = { messageId: event.message.id };
113
- const chatId = eventChatId(event);
114
- return chatId
115
- ? client.chat.markReadIn(chatId, options)
116
- : client.chat.markRead(event.peer, options);
122
+ function groupEchoWinner(event, echoChatKeys) {
123
+ const eligible = [...new Set(
124
+ (event?.chat?.members || [])
125
+ .map((member) => chatKey(member?.chatPK))
126
+ .filter((key) => key && echoChatKeys.has(key))
127
+ )].sort();
128
+ const source = eventId(event);
129
+ if (!source || !eligible.length) return '';
130
+ const bucket = createHash('sha256')
131
+ .update(source)
132
+ .digest()
133
+ .readUInt32BE(0);
134
+ return eligible[bucket % eligible.length];
117
135
  }
118
136
 
119
137
  async function runAction(
@@ -355,6 +373,96 @@ async function stopClaimLoop(profile, states) {
355
373
  await state.pending;
356
374
  }
357
375
 
376
+ function typingCompositionId(account, chatId) {
377
+ return createHash('sha256')
378
+ .update(`${account.profile}\n${chatId}\ntyping`)
379
+ .digest('hex')
380
+ .slice(0, 32);
381
+ }
382
+
383
+ async function ensureLiveChat(account, state, chatId) {
384
+ const id = String(chatId || '').trim().toLowerCase();
385
+ if (!id || state.chatIds.has(id)) return false;
386
+ const pending = state.entering.get(id);
387
+ if (pending) return pending;
388
+ const entering = state.client.chat.enterLive(id)
389
+ .then(async (result) => {
390
+ if (!result?.entered) return false;
391
+ if (state.closed) {
392
+ await state.client.chat.leaveLive(id);
393
+ return false;
394
+ }
395
+ state.chatIds.add(id);
396
+ if (state.typing) {
397
+ await state.client.chat.markTyping(
398
+ id,
399
+ true,
400
+ typingCompositionId(account, id)
401
+ );
402
+ }
403
+ return true;
404
+ })
405
+ .finally(() => state.entering.delete(id));
406
+ state.entering.set(id, entering);
407
+ return entering;
408
+ }
409
+
410
+ function scheduleTypingHeartbeat(account, state) {
411
+ if (!state.typing || state.closed) return;
412
+ state.timer = setTimeout(() => {
413
+ state.pending = Promise.allSettled([...state.chatIds].map((chatId) =>
414
+ state.client.chat.markTyping(
415
+ chatId,
416
+ true,
417
+ typingCompositionId(account, chatId)
418
+ )
419
+ )).finally(() => scheduleTypingHeartbeat(account, state));
420
+ }, TYPING_HEARTBEAT_MS);
421
+ state.timer.unref?.();
422
+ }
423
+
424
+ async function startLiveRole(account, client, states) {
425
+ const roles = rolesFor(account);
426
+ const state = {
427
+ chatIds: new Set(),
428
+ client,
429
+ closed: false,
430
+ entering: new Map(),
431
+ pending: Promise.resolve(),
432
+ timer: null,
433
+ typing: roles.has('typing'),
434
+ };
435
+ states.set(account.profile, state);
436
+ try {
437
+ const chats = await client.chat.list({ count: 500 });
438
+ await Promise.all(chats.map((chat) => ensureLiveChat(account, state, chat.id)));
439
+ scheduleTypingHeartbeat(account, state);
440
+ } catch (error) {
441
+ await stopLiveRole(account, states);
442
+ throw error;
443
+ }
444
+ }
445
+
446
+ async function stopLiveRole(account, states) {
447
+ const state = states.get(account.profile);
448
+ states.delete(account.profile);
449
+ if (!state) return;
450
+ state.closed = true;
451
+ clearTimeout(state.timer);
452
+ await Promise.allSettled(state.entering.values());
453
+ await state.pending.catch(() => {});
454
+ await Promise.allSettled([...state.chatIds].map(async (chatId) => {
455
+ if (state.typing) {
456
+ await state.client.chat.markTyping(
457
+ chatId,
458
+ false,
459
+ typingCompositionId(account, chatId)
460
+ );
461
+ }
462
+ await state.client.chat.leaveLive(chatId);
463
+ }));
464
+ }
465
+
358
466
  export function createBotFleetPolicy(options = {}) {
359
467
  const journal = options.journal || null;
360
468
  const checkpoint = options.checkpoint || null;
@@ -367,9 +475,11 @@ export function createBotFleetPolicy(options = {}) {
367
475
  const usernameByProfile = new Map();
368
476
  const responderProfiles = new Set();
369
477
  const chatKeyByProfile = new Map();
478
+ const echoChatKeys = new Set();
370
479
  const claimStates = new Map();
480
+ const liveStates = new Map();
371
481
 
372
- function shouldMirror(event) {
482
+ function shouldMirror(account, event) {
373
483
  if (isFleetGeneratedMessage(event)) return false;
374
484
  if (!isGroupMessage(event)) {
375
485
  return !managedUsernames.has(usernameTarget(event.peer));
@@ -382,7 +492,10 @@ export function createBotFleetPolicy(options = {}) {
382
492
  }
383
493
  const senderChatPK = chatKey(event.message?.senderChatPK);
384
494
  if (!senderChatPK) return false;
385
- return !managedChatKeys.has(senderChatPK);
495
+ if (managedChatKeys.has(senderChatPK)) return false;
496
+ const accountChatPK = chatKeyByProfile.get(account.profile);
497
+ return !!accountChatPK
498
+ && groupEchoWinner(event, echoChatKeys) === accountChatPK;
386
499
  }
387
500
 
388
501
  return Object.freeze({
@@ -391,6 +504,7 @@ export function createBotFleetPolicy(options = {}) {
391
504
  managedChatKeys.clear();
392
505
  responderProfiles.clear();
393
506
  chatKeyByProfile.clear();
507
+ echoChatKeys.clear();
394
508
  for (const profile of profiles) {
395
509
  assertRoles(profile);
396
510
  if (!canRespond(profile)) continue;
@@ -402,7 +516,7 @@ export function createBotFleetPolicy(options = {}) {
402
516
  }
403
517
  },
404
518
  async start({ account, client }) {
405
- assertRoles(account);
519
+ const roles = assertRoles(account);
406
520
  const current = await client.account.me();
407
521
  const username = usernameTarget(current?.username);
408
522
  if (!username) {
@@ -427,29 +541,40 @@ export function createBotFleetPolicy(options = {}) {
427
541
  responderProfiles.add(account.profile);
428
542
  chatKeyByProfile.set(account.profile, accountChatPK);
429
543
  managedChatKeys.add(accountChatPK);
544
+ if (roles.has('echo')) echoChatKeys.add(accountChatPK);
430
545
  managedUsernames.add(username);
431
546
  }
432
547
  if (!baseline) {
433
548
  startClaimLoop(account, client, claimStates);
549
+ if (roles.has('live')) {
550
+ await startLiveRole(account, client, liveStates);
551
+ }
434
552
  }
435
553
  },
436
554
  async stop({ account }) {
555
+ await stopLiveRole(account, liveStates);
437
556
  await stopClaimLoop(account.profile, claimStates);
438
557
  const username = usernameByProfile.get(account.profile);
439
558
  const accountChatPK = chatKeyByProfile.get(account.profile);
440
559
  usernameByProfile.delete(account.profile);
441
560
  chatKeyByProfile.delete(account.profile);
442
561
  if (accountChatPK) managedChatKeys.delete(accountChatPK);
562
+ if (accountChatPK) echoChatKeys.delete(accountChatPK);
443
563
  if (username) managedUsernames.delete(username);
444
564
  },
445
565
  async stopFleet() {
566
+ await Promise.all([...liveStates.keys()].map((profile) =>
567
+ stopLiveRole({ profile, roles: [] }, liveStates)
568
+ ));
446
569
  managedUsernames.clear();
447
570
  managedChatKeys.clear();
448
571
  responderProfiles.clear();
449
572
  chatKeyByProfile.clear();
573
+ echoChatKeys.clear();
450
574
  await checkpoint?.flush();
451
575
  },
452
576
  async onEvent({ account, client, event }) {
577
+ const roles = rolesFor(account);
453
578
  if (checkpoint?.has(account.profile, event)) {
454
579
  return null;
455
580
  }
@@ -459,9 +584,7 @@ export function createBotFleetPolicy(options = {}) {
459
584
  });
460
585
  return null;
461
586
  }
462
- const roles = rolesFor(account);
463
587
  let result = null;
464
- let markRead = false;
465
588
  if (event?.type === 'transaction') {
466
589
  result = await client.wallet.claim({ count: 100 });
467
590
  } else if (
@@ -469,7 +592,6 @@ export function createBotFleetPolicy(options = {}) {
469
592
  event.message?.from === 'peer' &&
470
593
  roles.has('read')
471
594
  ) {
472
- markRead = true;
473
595
  if (
474
596
  roles.has('faucet') &&
475
597
  event.message.type === 'req'
@@ -482,7 +604,7 @@ export function createBotFleetPolicy(options = {}) {
482
604
  );
483
605
  } else if (
484
606
  roles.has('echo') &&
485
- shouldMirror(event)
607
+ shouldMirror(account, event)
486
608
  ) {
487
609
  result = await mirrorEcho(
488
610
  journal,
@@ -492,9 +614,6 @@ export function createBotFleetPolicy(options = {}) {
492
614
  );
493
615
  }
494
616
  }
495
- if (markRead) {
496
- await markEventChatRead(client, event);
497
- }
498
617
  await checkpoint?.mark(account.profile, event);
499
618
  return result;
500
619
  },
@@ -6,10 +6,12 @@ The secret-free version-2 manifest stores account indices, usernames, networks,
6
6
 
7
7
  ## Roles
8
8
 
9
- - `read` subscribes through the public live-event API, marks peer messages read before policy effects run, and keeps active conversations mounted. A conversation with no activity is released after the configured idle window so inactive peers do not keep a message listener open; the account-level chat-list subscription reopens it when new activity arrives.
10
- - `echo` mirrors text, encrypted attachments, and payment requests with deterministic action ids.
9
+ - `read` subscribes through the public event API and advances directly from the listener's newest decrypted activity before enabled effects run, including a bot's own confirmed reply. The canonical runtime keeps that chat's ordinary encrypted live room for five idle minutes, flushes an already-connected relay frontier before emitting the event, lets a cold room publish its frontier in the initial socket snapshot without waiting on the handshake, coalesces the durable write, and never acquires a lease for checkpointed startup history. The event checkpoint remains post-effect for safe replay.
10
+ - `echo` mirrors text, encrypted attachments, and payment requests with deterministic action ids. Group members derive exactly one source-specific eligible fleet echo from the encrypted roster for each source message; every other echo remains silent and only reads. Selection distributes independently across messages, so the same bot may legitimately win consecutive messages.
11
11
  - `faucet` pays valid requests through the public wallet API and journals caller operation ids.
12
12
  - `traffic` is an eligibility marker for an external local operator policy; it performs no work by itself.
13
+ - `live` holds the account's normal encrypted live-room connection in each chat without advancing read state or sending messages.
14
+ - `typing` requires `live` and continually renews one stable composition through the same encrypted live state until the policy stops.
13
15
 
14
16
  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.
15
17
 
@@ -29,7 +31,7 @@ The bounded live harness is opt-in because it creates and deletes real REGTEST a
29
31
  bun check:bot-fleet-live
30
32
  ```
31
33
 
32
- It uses a separate random local home, proves creation/recovery, text/request/attachment echo, retention, post-policy reads, faucet behavior, transfer claim, restart, final balance reconciliation, and normal SDK account deletion. An interrupted run prints the disposable manifest path and can resume with `VEYL_LIVE_BOT_FLEET_MANIFEST=/absolute/path/to/manifest.json bun check:bot-fleet-live`.
34
+ It uses a separate random local home, proves creation/recovery, text/request/attachment echo, retention, immediate reads with post-policy checkpoints, faucet behavior, transfer claim, restart, final balance reconciliation, and normal SDK account deletion. An interrupted run prints the disposable manifest path and can resume with `VEYL_LIVE_BOT_FLEET_MANIFEST=/absolute/path/to/manifest.json bun check:bot-fleet-live`.
33
35
 
34
36
  Reserved usernames require an owner-only namespace key. Provisioning signs a short-lived claim bound to the exact derived machine credential; the key is never accepted through argv, environment variables, or the manifest.
35
37
 
@@ -4,6 +4,7 @@ import {
4
4
  openFleetOwner,
5
5
  } from '@glyphteck/veyl';
6
6
  let policyRevision = 0;
7
+ const DEFAULT_READ_LIVE_IDLE_MS = 5 * 60_000;
7
8
 
8
9
  async function loadBotFleetPolicy(options) {
9
10
  policyRevision += 1;
@@ -22,11 +23,14 @@ export async function createExampleBotFleetRuntime(options = {}) {
22
23
  policies: [],
23
24
  eventOptions: async (profile) => ({
24
25
  replay: true,
25
- chats: profile.roles.includes('read'),
26
+ chats: profile.roles.includes('read') || profile.roles.includes('live'),
26
27
  persistentChats: true,
27
28
  persistentChatIdleMs: options.persistentChatIdleMs || 5 * 60_000,
28
29
  transactions: true,
29
- read: true,
30
+ read: profile.roles.includes('read'),
31
+ relayReads: profile.roles.includes('read'),
32
+ readLiveIdleMs:
33
+ options.readLiveIdleMs ?? DEFAULT_READ_LIVE_IDLE_MS,
30
34
  chatCount: options.chatCount || 500,
31
35
  messageCount: options.messageCount || 20,
32
36
  replayMessageLimit: options.replayMessageLimit || 500,
package/package.json CHANGED
@@ -41,5 +41,5 @@
41
41
  "start": "node src/cli.js",
42
42
  "lint": "eslint src --quiet"
43
43
  },
44
- "version": "0.60.0"
44
+ "version": "0.61.0"
45
45
  }