@glyphteck/veyl 0.67.0 → 0.69.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.
@@ -0,0 +1,603 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { basename, join } from 'node:path';
5
+ import {
6
+ open,
7
+ startSessionRuntime,
8
+ } from '@glyphteck/veyl';
9
+ import { createChatWhitelist } from './chat-whitelist.js';
10
+
11
+ const ATTACHMENT_TYPES = new Set(['img', 'gif', 'm4a', 'mp4', 'file']);
12
+ const MESSAGE_TYPES = new Set([
13
+ 'txt',
14
+ 'img',
15
+ 'gif',
16
+ 'm4a',
17
+ 'mp4',
18
+ 'file',
19
+ 'req',
20
+ 'rxn',
21
+ 'del',
22
+ ]);
23
+ const DEFAULT_TYPING_HEARTBEAT_MS = 2_000;
24
+ const DEFAULT_ATTACHMENT_RETRY_MS = 2_000;
25
+ const DEFAULT_ROUTE_RETRY_MS = 2_000;
26
+ const textEncoder = new TextEncoder();
27
+
28
+ function cleanText(value) {
29
+ return String(value ?? '').trim();
30
+ }
31
+
32
+ function errorSummary(error) {
33
+ return cleanText(error?.message || error || 'unknown error')
34
+ .split('\n')[0]
35
+ .slice(0, 300);
36
+ }
37
+
38
+ function wait(ms, signal) {
39
+ if (signal?.aborted) return Promise.resolve();
40
+ return new Promise((resolve) => {
41
+ const finish = () => {
42
+ clearTimeout(timer);
43
+ signal?.removeEventListener?.('abort', finish);
44
+ resolve();
45
+ };
46
+ const timer = setTimeout(finish, ms);
47
+ timer.unref?.();
48
+ signal?.addEventListener?.('abort', finish, { once: true });
49
+ });
50
+ }
51
+
52
+ function publicOwner(peer) {
53
+ const uid = cleanText(peer?.uid);
54
+ const chatPK = cleanText(peer?.chatPK).toLowerCase();
55
+ if (!uid || !/^[0-9a-f]{64}$/u.test(chatPK)) {
56
+ throw new Error('owner identity is missing a stable uid or chat key');
57
+ }
58
+ return Object.freeze({ uid, chatPK });
59
+ }
60
+
61
+ function ownerMatchesChat(chat, owner) {
62
+ if (chat?.lineage !== 'direct' || Number(chat?.memberCount) !== 2) {
63
+ return false;
64
+ }
65
+ if (
66
+ cleanText(chat?.peerUid) === owner.uid
67
+ && cleanText(chat?.peerChatPK).toLowerCase() === owner.chatPK
68
+ ) {
69
+ return true;
70
+ }
71
+ const remote = (chat?.members || []).find((member) => (
72
+ cleanText(member?.uid) === owner.uid
73
+ && cleanText(member?.chatPK).toLowerCase() === owner.chatPK
74
+ ));
75
+ return !!remote;
76
+ }
77
+
78
+ function ownerMessage(event, owner) {
79
+ return event?.type === 'message'
80
+ && event?.message?.from === 'peer'
81
+ && MESSAGE_TYPES.has(event?.message?.type)
82
+ && ownerMatchesChat(event.chat, owner)
83
+ && cleanText(event?.message?.senderChatPK).toLowerCase() === owner.chatPK;
84
+ }
85
+
86
+ function messageId(event) {
87
+ return cleanText(event?.message?.id || event?.message?.cid);
88
+ }
89
+
90
+ function attachmentFilename(event) {
91
+ const raw = basename(cleanText(event?.message?.name))
92
+ .replace(/[^a-zA-Z0-9._-]+/gu, '-');
93
+ return raw.slice(0, 120) || `${event.message.type}-${messageId(event)}`;
94
+ }
95
+
96
+ function sameBytes(left, right) {
97
+ return left instanceof Uint8Array
98
+ && right instanceof Uint8Array
99
+ && left.byteLength === right.byteLength
100
+ && left.every((value, index) => value === right[index]);
101
+ }
102
+
103
+ function plainTextFile(message) {
104
+ const mime = cleanText(message?.mime || message?.payload?.m).toLowerCase();
105
+ return message?.type === 'file'
106
+ && (mime === 'text/plain' || mime.startsWith('text/plain;'));
107
+ }
108
+
109
+ async function bootAccount(client, options) {
110
+ const log = typeof options.log === 'function' ? options.log : () => {};
111
+ log('veyl.account.reading');
112
+ let account = await client.account.me();
113
+ if (!account) {
114
+ const username = cleanText(options.username).replace(/^@+/u, '');
115
+ if (!username) throw new Error('username required to create the Veyl account');
116
+ log('veyl.account.creating', { username });
117
+ await client.account.create({
118
+ username,
119
+ network: options.network,
120
+ });
121
+ account = await client.account.me();
122
+ } else if (!account.signedIn) {
123
+ log('veyl.account.authenticating');
124
+ account = await client.account.login();
125
+ }
126
+ if (!account?.hasVault) {
127
+ log('veyl.vault.creating');
128
+ await client.vault.create();
129
+ } else if (!account.unlocked) {
130
+ log('veyl.vault.unlocking');
131
+ await client.vault.unlock();
132
+ }
133
+ log('veyl.account.ready');
134
+ return client.account.me();
135
+ }
136
+
137
+ class WritingLease {
138
+ constructor(channel, chatId, compositionId) {
139
+ this.channel = channel;
140
+ this.chatId = chatId;
141
+ this.compositionId = compositionId;
142
+ this.closed = false;
143
+ this.timer = null;
144
+ this.pending = Promise.resolve();
145
+ }
146
+
147
+ async start() {
148
+ await this.channel.ensureLive(this.chatId);
149
+ await this.channel.client.chat.markTyping(
150
+ this.chatId,
151
+ true,
152
+ this.compositionId
153
+ );
154
+ this.schedule();
155
+ return this;
156
+ }
157
+
158
+ schedule() {
159
+ if (this.closed) return;
160
+ this.timer = setTimeout(() => {
161
+ this.timer = null;
162
+ this.pending = this.pending
163
+ .catch(() => {})
164
+ .then(() => this.channel.client.chat.markTyping(
165
+ this.chatId,
166
+ true,
167
+ this.compositionId
168
+ ));
169
+ this.schedule();
170
+ }, this.channel.typingHeartbeatMs);
171
+ this.timer.unref?.();
172
+ }
173
+
174
+ pulse() {
175
+ if (!this.closed && !this.timer) this.schedule();
176
+ }
177
+
178
+ async handoff() {
179
+ if (this.closed) return;
180
+ this.closed = true;
181
+ clearTimeout(this.timer);
182
+ this.timer = null;
183
+ await this.pending.catch(() => {});
184
+ await this.channel.client.chat.markTyping(
185
+ this.chatId,
186
+ false,
187
+ this.compositionId
188
+ );
189
+ }
190
+
191
+ async clear() {
192
+ await this.handoff().catch(() => {});
193
+ await this.channel.client.chat.markTyping(this.chatId, false)
194
+ .catch(() => {});
195
+ this.channel.writing.delete(this.chatId);
196
+ }
197
+ }
198
+
199
+ export class VeylAgentChannel {
200
+ constructor(options) {
201
+ this.client = options.client;
202
+ this.state = options.state;
203
+ this.owner = options.owner;
204
+ this.log = options.log;
205
+ this.typingHeartbeatMs = options.typingHeartbeatMs;
206
+ this.attachmentRetryMs = options.attachmentRetryMs;
207
+ this.routeRetryMs = options.routeRetryMs;
208
+ this.temporaryRoot = options.temporaryRoot || '';
209
+ this.temporaryOwner = options.temporaryOwner || null;
210
+ this.liveChats = new Set();
211
+ this.writing = new Map();
212
+ this.temporaryFiles = new Set();
213
+ this.controller = new AbortController();
214
+ this.sessionController = new AbortController();
215
+ this.sessionTask = options.sessionTask || null;
216
+ this.sessionName = options.sessionName;
217
+ this.listenTask = null;
218
+ this.routeChain = Promise.resolve();
219
+ this.closed = false;
220
+ }
221
+
222
+ async assertTemporaryOwner() {
223
+ if (!this.temporaryRoot) return;
224
+ if (typeof this.temporaryOwner?.assertHeld !== 'function') {
225
+ throw new Error('shared temporary root requires the connector lock');
226
+ }
227
+ await this.temporaryOwner.assertHeld();
228
+ }
229
+
230
+ async ensureLive(chatIdValue) {
231
+ const chatId = cleanText(chatIdValue).toLowerCase();
232
+ if (!chatId || this.liveChats.has(chatId)) return !!chatId;
233
+ const result = await this.client.chat.enterLive(chatId);
234
+ if (result?.entered !== true) {
235
+ throw new Error(`could not enter Veyl live chat: ${chatId}`);
236
+ }
237
+ if (this.closed) {
238
+ await this.client.chat.leaveLive(chatId);
239
+ return false;
240
+ }
241
+ this.liveChats.add(chatId);
242
+ return true;
243
+ }
244
+
245
+ async enterExistingOwnerChats() {
246
+ const chats = await this.client.chat.list({ count: 500 });
247
+ await Promise.all(chats
248
+ .filter((chat) => ownerMatchesChat(chat, this.owner))
249
+ .map((chat) => this.ensureLive(chat.id)));
250
+ }
251
+
252
+ async prepare(event) {
253
+ const prepared = {
254
+ event,
255
+ chatId: cleanText(event?.chat?.id).toLowerCase(),
256
+ messageId: messageId(event),
257
+ attachment: null,
258
+ };
259
+ if (!ATTACHMENT_TYPES.has(event?.message?.type) || !prepared.messageId) {
260
+ return prepared;
261
+ }
262
+ while (!this.closed) {
263
+ await this.assertTemporaryOwner();
264
+ const directory = await mkdtemp(this.temporaryRoot
265
+ ? join(this.temporaryRoot, 'message-')
266
+ : join(tmpdir(), 'veyl-agent-'));
267
+ try {
268
+ const attachment = await this.client.chat.readAttachmentIn(
269
+ prepared.chatId,
270
+ prepared.messageId
271
+ );
272
+ const path = join(directory, attachmentFilename(event));
273
+ await writeFile(path, attachment.bytes, { mode: 0o600 });
274
+ this.temporaryFiles.add(directory);
275
+ prepared.attachment = {
276
+ path,
277
+ image: event.message.type === 'img'
278
+ || event.message.type === 'gif',
279
+ };
280
+ return prepared;
281
+ } catch (error) {
282
+ if (
283
+ !this.temporaryRoot
284
+ || await this.temporaryOwner?.owns?.()
285
+ ) {
286
+ await rm(directory, { recursive: true, force: true })
287
+ .catch(() => {});
288
+ }
289
+ this.log('veyl.attachment.retry', {
290
+ error: errorSummary(error),
291
+ });
292
+ await wait(this.attachmentRetryMs, this.controller.signal);
293
+ }
294
+ }
295
+ throw new Error('Veyl agent channel closed');
296
+ }
297
+
298
+ async release(prepared) {
299
+ const directory = prepared?.attachment?.path
300
+ ? [...this.temporaryFiles].find((candidate) => (
301
+ prepared.attachment.path.startsWith(`${candidate}/`)
302
+ ))
303
+ : null;
304
+ if (!directory) return;
305
+ try {
306
+ await this.assertTemporaryOwner();
307
+ } catch (error) {
308
+ this.log('veyl.temporary.cleanup.skipped', {
309
+ error: errorSummary(error),
310
+ });
311
+ return;
312
+ }
313
+ this.temporaryFiles.delete(directory);
314
+ await rm(directory, { recursive: true, force: true }).catch(() => {});
315
+ }
316
+
317
+ async beginWriting(prepared, compositionId = '') {
318
+ const existing = this.writing.get(prepared.chatId);
319
+ if (existing) return existing;
320
+ const lease = new WritingLease(
321
+ this,
322
+ prepared.chatId,
323
+ cleanText(compositionId).toLowerCase()
324
+ || randomBytes(16).toString('hex')
325
+ );
326
+ this.writing.set(prepared.chatId, lease);
327
+ try {
328
+ return await lease.start();
329
+ } catch (error) {
330
+ this.writing.delete(prepared.chatId);
331
+ throw error;
332
+ }
333
+ }
334
+
335
+ async send(prepared, text, lease, options = {}) {
336
+ const message = cleanText(text);
337
+ if (!message) throw new Error('agent response required');
338
+ try {
339
+ const cid = cleanText(options.cid);
340
+ let reconciled = false;
341
+ if (cid) {
342
+ try {
343
+ const existing = await this.client.chat.readMessageIn(
344
+ prepared.chatId,
345
+ cid
346
+ );
347
+ const found = existing?.message;
348
+ const textMatches = found?.type === 'txt'
349
+ && found?.text === message;
350
+ const attachmentMatches = plainTextFile(found)
351
+ && sameBytes(
352
+ (await this.client.chat.readAttachmentIn(
353
+ prepared.chatId,
354
+ cid,
355
+ { durableOnly: true }
356
+ )).bytes,
357
+ textEncoder.encode(message)
358
+ );
359
+ if (
360
+ found?.cid !== cid
361
+ || found?.from !== 'self'
362
+ || (!textMatches && !attachmentMatches)
363
+ ) {
364
+ throw new Error('Veyl response cid belongs to different content');
365
+ }
366
+ reconciled = true;
367
+ } catch (error) {
368
+ if (error?.message !== 'message not found') throw error;
369
+ }
370
+ }
371
+ await lease.handoff();
372
+ if (!reconciled) {
373
+ await this.client.chat.sendTo(prepared.chatId, message, {
374
+ compositionId: lease.compositionId,
375
+ ...(cid ? { cid } : {}),
376
+ });
377
+ }
378
+ this.writing.delete(prepared.chatId);
379
+ } catch (error) {
380
+ await lease.clear();
381
+ throw error;
382
+ }
383
+ }
384
+
385
+ async react(prepared, emoji, lease) {
386
+ await lease.clear();
387
+ await this.client.chat.reactIn(
388
+ prepared.chatId,
389
+ prepared.messageId,
390
+ emoji
391
+ );
392
+ }
393
+
394
+ markProcessed(events) {
395
+ const records = events.map((item) => item.event || item);
396
+ return typeof this.state.complete === 'function'
397
+ ? this.state.complete(records)
398
+ : this.state.mark(records);
399
+ }
400
+
401
+ async routeEvent(event, onMessage) {
402
+ let prepared = null;
403
+ while (!this.closed && !this.state.has(event)) {
404
+ try {
405
+ prepared ||= await this.prepare(event);
406
+ await this.ensureLive(prepared.chatId);
407
+ await this.client.chat.markReadIn(prepared.chatId, {
408
+ messageId: prepared.messageId,
409
+ });
410
+ await onMessage(prepared);
411
+ return;
412
+ } catch (error) {
413
+ this.log('veyl.route.retry', {
414
+ error: errorSummary(error),
415
+ });
416
+ await wait(this.routeRetryMs, this.controller.signal);
417
+ }
418
+ }
419
+ await this.release(prepared);
420
+ }
421
+
422
+ async listen(onMessage) {
423
+ if (this.listenTask) return this.listenTask;
424
+ this.listenTask = this.client.listen({
425
+ replay: this.state.initialized,
426
+ chats: 500,
427
+ transactions: false,
428
+ read: false,
429
+ relayReads: false,
430
+ signal: this.controller.signal,
431
+ hasReplayCheckpoint: (chat) => this.state.hasChat(chat),
432
+ isProcessedEvent: (event) => this.state.has(event),
433
+ onEvent: (event) => {
434
+ if (event?.type === 'ready') {
435
+ void this.state.setInitialized().catch((error) => (
436
+ this.log('veyl.state.failed', {
437
+ error: errorSummary(error),
438
+ })
439
+ ));
440
+ this.log('veyl.ready', {
441
+ profile: event.account?.profile || null,
442
+ });
443
+ return;
444
+ }
445
+ if (event?.type === 'error') {
446
+ this.log('veyl.event.error', {
447
+ error: cleanText(event.message),
448
+ });
449
+ return;
450
+ }
451
+ if (!ownerMessage(event, this.owner) || this.state.has(event)) {
452
+ return;
453
+ }
454
+ this.routeChain = this.routeChain
455
+ .then(() => this.routeEvent(event, onMessage))
456
+ .catch((error) => this.log('veyl.route.failed', {
457
+ error: errorSummary(error),
458
+ }));
459
+ },
460
+ }).finally(async () => {
461
+ await this.routeChain;
462
+ });
463
+ return this.listenTask;
464
+ }
465
+
466
+ async close() {
467
+ if (this.closed) return;
468
+ this.closed = true;
469
+ this.controller.abort();
470
+ await this.listenTask?.catch(() => {});
471
+ await Promise.allSettled([...this.writing.values()].map((lease) => (
472
+ lease.clear()
473
+ )));
474
+ await Promise.allSettled([...this.liveChats].map((chatId) => (
475
+ this.client.chat.leaveLive(chatId)
476
+ )));
477
+ this.liveChats.clear();
478
+ this.sessionController.abort();
479
+ await this.sessionTask?.catch(() => {});
480
+ let canCleanTemporaryRoot = true;
481
+ try {
482
+ await this.assertTemporaryOwner();
483
+ } catch (error) {
484
+ canCleanTemporaryRoot = false;
485
+ this.log('veyl.temporary.cleanup.skipped', {
486
+ error: errorSummary(error),
487
+ });
488
+ }
489
+ if (canCleanTemporaryRoot) {
490
+ await Promise.allSettled([...this.temporaryFiles].map((directory) => (
491
+ rm(directory, { recursive: true, force: true })
492
+ )));
493
+ this.temporaryFiles.clear();
494
+ if (this.temporaryRoot) {
495
+ await rm(this.temporaryRoot, { recursive: true, force: true })
496
+ .catch(() => {});
497
+ }
498
+ }
499
+ await this.client.close();
500
+ }
501
+ }
502
+
503
+ export async function openVeylAgentChannel(options = {}) {
504
+ if (!options.state) throw new Error('agent state required');
505
+ const ownerTarget = cleanText(options.owner);
506
+ if (!ownerTarget) throw new Error('Veyl owner required');
507
+ const temporaryRoot = cleanText(options.temporaryRoot);
508
+ if (temporaryRoot) {
509
+ if (typeof options.temporaryOwner?.assertHeld !== 'function') {
510
+ throw new Error('shared temporary root requires the connector lock');
511
+ }
512
+ await options.temporaryOwner.assertHeld();
513
+ }
514
+ let admission = createChatWhitelist([
515
+ options.state.owner || ownerTarget,
516
+ ]);
517
+ const log = typeof options.log === 'function' ? options.log : () => {};
518
+ const openClient = options.openClient || open;
519
+ log('veyl.channel.opening');
520
+ const client = options.client || await openClient({
521
+ ...(options.clientOptions || {}),
522
+ homeDir: options.homeDir,
523
+ profile: options.profile,
524
+ network: options.network,
525
+ chat: {
526
+ ...(options.clientOptions?.chat || {}),
527
+ incomingChatDecision: (context) => admission(context),
528
+ },
529
+ });
530
+ let channel = null;
531
+ try {
532
+ if (temporaryRoot) {
533
+ await options.temporaryOwner.assertHeld();
534
+ await rm(temporaryRoot, { recursive: true, force: true });
535
+ await mkdir(temporaryRoot, { recursive: true, mode: 0o700 });
536
+ }
537
+ await bootAccount(client, options);
538
+ log('veyl.owner.resolving', { owner: ownerTarget });
539
+ const resolved = await client.peers.show(
540
+ options.state.owner?.chatPK || ownerTarget
541
+ );
542
+ const owner = publicOwner(resolved);
543
+ await options.state.pinOwner(owner);
544
+ admission = createChatWhitelist([owner]);
545
+ log('veyl.admission.publishing');
546
+ await client.profile.setChatAdmission({
547
+ direct: 'closed',
548
+ groups: 'closed',
549
+ allow: [owner.chatPK],
550
+ });
551
+ log('veyl.admission.ready');
552
+ channel = new VeylAgentChannel({
553
+ client,
554
+ state: options.state,
555
+ owner,
556
+ log,
557
+ typingHeartbeatMs: Number(options.typingHeartbeatMs)
558
+ || DEFAULT_TYPING_HEARTBEAT_MS,
559
+ attachmentRetryMs: Number(options.attachmentRetryMs)
560
+ || DEFAULT_ATTACHMENT_RETRY_MS,
561
+ routeRetryMs: Number(options.routeRetryMs)
562
+ || DEFAULT_ROUTE_RETRY_MS,
563
+ temporaryRoot,
564
+ temporaryOwner: options.temporaryOwner,
565
+ sessionName: cleanText(options.sessionName) || 'agent',
566
+ });
567
+ if (options.serveCli !== false) {
568
+ let readyResolve;
569
+ const ready = new Promise((resolve) => {
570
+ readyResolve = resolve;
571
+ });
572
+ channel.sessionTask = startSessionRuntime(client, {
573
+ profile: options.profile,
574
+ name: channel.sessionName,
575
+ signal: channel.sessionController.signal,
576
+ onReady: readyResolve,
577
+ });
578
+ log('veyl.cli.starting', { session: channel.sessionName });
579
+ await Promise.race([
580
+ ready,
581
+ channel.sessionTask.then(() => {
582
+ throw new Error('Veyl CLI session stopped before it became ready');
583
+ }),
584
+ ]);
585
+ log('veyl.cli.ready', { session: channel.sessionName });
586
+ }
587
+ log('veyl.owner_chats.entering');
588
+ await channel.enterExistingOwnerChats();
589
+ log('veyl.channel.ready');
590
+ return channel;
591
+ } catch (error) {
592
+ if (channel) {
593
+ await channel.close().catch(() => {});
594
+ } else {
595
+ await client.close().catch(() => {});
596
+ if (temporaryRoot && await options.temporaryOwner?.owns?.()) {
597
+ await rm(temporaryRoot, { recursive: true, force: true })
598
+ .catch(() => {});
599
+ }
600
+ }
601
+ throw error;
602
+ }
603
+ }
package/package.json CHANGED
@@ -26,6 +26,17 @@
26
26
  "examples/bot-fleet/policy.js",
27
27
  "examples/bot-fleet/runtime.js",
28
28
  "examples/bot-fleet/readme.md",
29
+ "examples/codex-agent/agent-instructions.js",
30
+ "examples/codex-agent/agent-state.js",
31
+ "examples/codex-agent/chat-whitelist.js",
32
+ "examples/codex-agent/codex-app-server.js",
33
+ "examples/codex-agent/codex-config.js",
34
+ "examples/codex-agent/codex-input.js",
35
+ "examples/codex-agent/connector.js",
36
+ "examples/codex-agent/index.js",
37
+ "examples/codex-agent/instance-lock.js",
38
+ "examples/codex-agent/readme.md",
39
+ "examples/codex-agent/veyl-channel.js",
29
40
  "LICENSE",
30
41
  "readme.md",
31
42
  "package.json"
@@ -42,5 +53,5 @@
42
53
  "start": "node src/cli.js",
43
54
  "lint": "eslint src --quiet"
44
55
  },
45
- "version": "0.67.0"
56
+ "version": "0.69.0"
46
57
  }
package/readme.md CHANGED
@@ -54,6 +54,7 @@ Run `bunx veyl help` for the full command list. Long-running agents should keep
54
54
  - [JavaScript API](docs/api.md)
55
55
  - [CLI reference](docs/cli.md)
56
56
  - [agent operation](docs/agents.md)
57
+ - [single-account Codex agent](examples/codex-agent/readme.md)
57
58
  - [MCP documentation discovery](docs/discovery.md)
58
59
 
59
60
  ## license