@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/dist/account.js +319 -176
- package/dist/cli.js +485 -200
- package/dist/index.js +485 -200
- package/docs/agents.md +1 -1
- package/docs/api.md +1 -1
- package/examples/bot-fleet/policy.js +136 -17
- package/examples/bot-fleet/readme.md +5 -3
- package/examples/bot-fleet/runtime.js +6 -2
- package/package.json +1 -1
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
|
|
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([
|
|
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
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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