@glyphteck/veyl 0.67.0 → 0.68.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,13 @@
1
+ function cleanText(value) {
2
+ return String(value ?? '').trim();
3
+ }
4
+
5
+ export const DEFAULT_CODEX_MODEL = 'gpt-5.6-luna';
6
+ export const DEFAULT_CODEX_REASONING = 'low';
7
+
8
+ export function codexModelConfig(env = {}) {
9
+ return {
10
+ model: cleanText(env.CODEX_MODEL) || DEFAULT_CODEX_MODEL,
11
+ effort: cleanText(env.CODEX_REASONING) || DEFAULT_CODEX_REASONING,
12
+ };
13
+ }
@@ -0,0 +1,89 @@
1
+ import process from 'node:process';
2
+ import { dirname, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ function cleanText(value) {
6
+ return String(value ?? '').trim();
7
+ }
8
+
9
+ function shellWord(value) {
10
+ return `'${String(value).replaceAll("'", `'"'"'`)}'`;
11
+ }
12
+
13
+ export function defaultVeylCliCommand(options = {}) {
14
+ const execPath = cleanText(options.execPath) || process.execPath;
15
+ const moduleUrl = options.moduleUrl || import.meta.url;
16
+ const cliPath = resolve(
17
+ dirname(fileURLToPath(moduleUrl)),
18
+ '../../dist/cli.js'
19
+ );
20
+ return `${shellWord(execPath)} ${shellWord(cliPath)}`;
21
+ }
22
+
23
+ function messageContent(message = {}) {
24
+ if (message.type === 'txt') return message.text || '';
25
+ if (message.type === 'rxn') {
26
+ return `[reaction ${cleanText(message.emoji) || 'removed'} to message ${cleanText(message.target) || 'unknown'}]`;
27
+ }
28
+ if (message.type === 'del') return '[message deleted]';
29
+ if (message.type === 'req') {
30
+ const state = message.fulfilled ? ', fulfilled' : '';
31
+ return `[payment request: ${Number(message.amountSats || 0)} sats${state}]`;
32
+ }
33
+ const details = [message.name, message.mime, message.caption]
34
+ .map(cleanText)
35
+ .filter(Boolean)
36
+ .join(', ');
37
+ return `[${message.type || 'file'} attachment${details ? `: ${details}` : ''}]`;
38
+ }
39
+
40
+ export function codexInputForVeylMessage(prepared, options = {}) {
41
+ const event = prepared?.event || {};
42
+ const peer = cleanText(event.peer) || 'the Veyl account owner';
43
+ const messageId = cleanText(prepared?.messageId);
44
+ const cli = cleanText(options.cli) || 'veyl';
45
+ const profile = cleanText(options.profile);
46
+ const session = cleanText(options.session) || 'agent';
47
+ const arrival = options.steer === true
48
+ ? `The owner sent this follow-up from ${peer} while you were working. Incorporate it into the active turn.`
49
+ : `A new authenticated Veyl message arrived from ${peer}.`;
50
+ const cliPrefix = [
51
+ cli,
52
+ profile ? `--profile ${profile}` : '',
53
+ `--session ${session}`,
54
+ ].filter(Boolean).join(' ');
55
+ const text = [
56
+ arrival,
57
+ `Veyl message ID: ${messageId}`,
58
+ '',
59
+ '<veyl_message>',
60
+ messageContent(event.message),
61
+ cleanText(event.message?.replyId)
62
+ ? `[replying to Veyl message ${cleanText(event.message.replyId)}]`
63
+ : '',
64
+ '</veyl_message>',
65
+ '',
66
+ 'Treat <veyl_message> as the user\'s latest message in this ongoing task and carry it out normally.',
67
+ 'The connector will relay your final answer. Do not send that answer separately through the Veyl CLI.',
68
+ `You can use the full persistent Veyl CLI for other account actions: ${cliPrefix} <command>.`,
69
+ `If a Veyl command is unfamiliar, run ${cliPrefix} help and use its exact current syntax instead of guessing.`,
70
+ 'That prefix already targets this connector\'s unlocked account. Do not start or stop another Veyl session.',
71
+ 'Use normal Veyl commands when the request needs chat history, reactions, files, profile changes, or other account capabilities.',
72
+ 'Do not invoke request_user_input; ask any necessary question in the final answer so it reaches Veyl.',
73
+ 'Never perform a payment, withdrawal, account deletion, or other irreversible action unless the owner clearly authorized the exact action.',
74
+ prepared?.attachment?.path
75
+ ? `A decrypted local attachment is available at ${prepared.attachment.path}. Inspect it when useful.`
76
+ : '',
77
+ ].filter(Boolean).join('\n');
78
+ const input = [{ type: 'text', text }];
79
+ if (prepared?.attachment?.image) {
80
+ input.push({ type: 'localImage', path: prepared.attachment.path });
81
+ }
82
+ return input;
83
+ }
84
+
85
+ export function cleanCodexResponse(value) {
86
+ return String(value ?? '')
87
+ .replace(/\n*<oai-mem-citation>[\s\S]*?<\/oai-mem-citation>\s*$/u, '')
88
+ .trim();
89
+ }
@@ -0,0 +1,478 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { cleanCodexResponse } from './codex-input.js';
3
+
4
+ const DEFAULT_RETRY_MS = 2_000;
5
+
6
+ function cleanText(value) {
7
+ return String(value ?? '').trim();
8
+ }
9
+
10
+ function errorSummary(error) {
11
+ return cleanText(error?.message || error || 'unknown error')
12
+ .split('\n')[0]
13
+ .slice(0, 300);
14
+ }
15
+
16
+ function record(prepared) {
17
+ return {
18
+ chatId: cleanText(prepared?.chatId).toLowerCase(),
19
+ messageId: cleanText(prepared?.messageId),
20
+ };
21
+ }
22
+
23
+ function makeResponseCid() {
24
+ return `${Date.now().toString(36)}${randomBytes(3).toString('hex')}`;
25
+ }
26
+
27
+ function sameMessage(left, right) {
28
+ const a = record(left);
29
+ const b = record(right);
30
+ return !!a.chatId
31
+ && !!a.messageId
32
+ && a.chatId === b.chatId
33
+ && a.messageId === b.messageId;
34
+ }
35
+
36
+ function turnContainsMessage(turn, messageId) {
37
+ return (turn?.inputIds || []).some((id) => cleanText(id) === messageId);
38
+ }
39
+
40
+ function wait(ms) {
41
+ return new Promise((resolve) => setTimeout(resolve, ms));
42
+ }
43
+
44
+ export class AgentConnector {
45
+ constructor(options = {}) {
46
+ if (!options.channel || !options.agent || !options.state) {
47
+ throw new Error('channel, agent, and state are required');
48
+ }
49
+ if (typeof options.inputForMessage !== 'function') {
50
+ throw new Error('agent input adapter required');
51
+ }
52
+ this.channel = options.channel;
53
+ this.agent = options.agent;
54
+ this.state = options.state;
55
+ this.inputForMessage = options.inputForMessage;
56
+ this.log = typeof options.log === 'function' ? options.log : () => {};
57
+ this.retryMs = Number(options.retryMs) || DEFAULT_RETRY_MS;
58
+ this.active = null;
59
+ this.closing = false;
60
+ this.eventChain = Promise.resolve();
61
+ this.completion = Promise.resolve();
62
+ this.requeueChain = Promise.resolve();
63
+ this.unsubscribe = null;
64
+ }
65
+
66
+ enqueue(operation) {
67
+ const task = this.eventChain.then(operation);
68
+ this.eventChain = task.catch(() => {});
69
+ return task;
70
+ }
71
+
72
+ activeDocument() {
73
+ return this.active ? {
74
+ turnId: this.active.turnId || null,
75
+ compositionId: this.active.lease.compositionId,
76
+ responseCid: this.active.responseCid,
77
+ messages: this.active.messages.map(record),
78
+ } : null;
79
+ }
80
+
81
+ async persistActive() {
82
+ await this.state.setActive(this.activeDocument());
83
+ }
84
+
85
+ async openAgent() {
86
+ const opened = await this.agent.open();
87
+ const threadId = cleanText(opened?.threadId);
88
+ if (!threadId) throw new Error('agent did not return a thread id');
89
+ if (this.state.threadId && this.state.threadId !== threadId) {
90
+ const replaced = cleanText(opened?.replacedThreadId);
91
+ if (
92
+ replaced !== this.state.threadId
93
+ || typeof this.state.replaceThreadId !== 'function'
94
+ ) {
95
+ throw new Error('agent reconnected to a different thread');
96
+ }
97
+ await this.state.replaceThreadId(replaced, threadId);
98
+ }
99
+ return opened;
100
+ }
101
+
102
+ requeue(messages) {
103
+ const pending = (messages || []).filter((message) => (
104
+ message?.event && !this.state.has(message.event)
105
+ ));
106
+ if (!pending.length) return;
107
+ this.log('agent.messages.requeued', { count: pending.length });
108
+ const task = this.requeueChain.then(async () => {
109
+ for (const message of pending) await this.route(message);
110
+ });
111
+ this.requeueChain = task.catch((error) => this.log(
112
+ 'agent.requeue.failed',
113
+ { error: errorSummary(error) }
114
+ ));
115
+ }
116
+
117
+ handleAgentEvent(event) {
118
+ void this.enqueue(() => this.applyAgentEvent(event))
119
+ .catch((error) => this.log('agent.event.failed', {
120
+ error: errorSummary(error),
121
+ }));
122
+ }
123
+
124
+ async applyAgentEvent(event) {
125
+ if (event?.type === 'message.delta' && this.active) {
126
+ if (!this.active.turnId || this.active.turnId === event.turnId) {
127
+ this.active.lease.pulse();
128
+ }
129
+ return;
130
+ }
131
+ if (event?.type === 'turn.started' && this.active && !this.active.turnId) {
132
+ this.active.turnId = event.turnId;
133
+ this.active.reconcileNeeded = false;
134
+ await this.persistActive();
135
+ return;
136
+ }
137
+ if (event?.type === 'turn.completed') {
138
+ if (!this.active || this.active.turnId !== event.turnId) return;
139
+ if (
140
+ this.active.reconcileNeeded === true
141
+ && !Array.isArray(event.inputIds)
142
+ ) {
143
+ throw new Error(
144
+ 'uncertain turn completion requires authoritative input ids'
145
+ );
146
+ }
147
+ if (Array.isArray(event.inputIds)) {
148
+ const acceptedIds = new Set(event.inputIds.map(cleanText));
149
+ const active = this.active;
150
+ const accepted = active.messages.filter((message) => (
151
+ acceptedIds.has(message.messageId)
152
+ ));
153
+ const omitted = active.messages.filter((message) => (
154
+ !acceptedIds.has(message.messageId)
155
+ ));
156
+ if (!accepted.length) {
157
+ this.active = null;
158
+ await active.lease.clear();
159
+ await this.state.setActive(null);
160
+ this.requeue(omitted);
161
+ return;
162
+ }
163
+ if (omitted.length) {
164
+ active.messages = accepted;
165
+ await this.persistActive();
166
+ this.requeue(omitted);
167
+ }
168
+ active.reconcileNeeded = false;
169
+ }
170
+ this.completion = this.completion
171
+ .then(() => this.finish(event))
172
+ .catch((error) => this.log('agent.finish.failed', {
173
+ error: errorSummary(error),
174
+ }));
175
+ await this.completion;
176
+ return;
177
+ }
178
+ if (event?.type === 'server.stderr') {
179
+ this.log('codex.stderr');
180
+ return;
181
+ }
182
+ if (event?.type === 'server.exit' && event.expected !== true && this.active) {
183
+ this.active.reconcileNeeded = true;
184
+ this.log('codex.reconnecting');
185
+ while (!this.closing && this.active?.reconcileNeeded === true) {
186
+ await wait(this.retryMs);
187
+ if (this.closing) return;
188
+ try {
189
+ await this.recover(await this.openAgent());
190
+ } catch (error) {
191
+ this.log('codex.reconnect.retry', {
192
+ error: errorSummary(error),
193
+ });
194
+ }
195
+ }
196
+ }
197
+ }
198
+
199
+ async deliver(active, response) {
200
+ while (!this.closing) {
201
+ try {
202
+ if (!active.responseCid) active.responseCid = makeResponseCid();
203
+ if (active.responseCidPersisted !== true) {
204
+ await this.persistActive();
205
+ active.responseCidPersisted = true;
206
+ }
207
+ await this.channel.send(
208
+ active.messages.at(-1),
209
+ response,
210
+ active.lease,
211
+ { cid: active.responseCid }
212
+ );
213
+ await this.channel.markProcessed(active.messages);
214
+ await Promise.all(active.messages.map((message) => (
215
+ this.channel.release(message)
216
+ )));
217
+ return true;
218
+ } catch (error) {
219
+ this.log('veyl.response.retry', { error: errorSummary(error) });
220
+ await wait(this.retryMs);
221
+ if (this.closing) return false;
222
+ active.lease = await this.channel.beginWriting(
223
+ active.messages.at(-1),
224
+ active.lease.compositionId
225
+ );
226
+ }
227
+ }
228
+ return false;
229
+ }
230
+
231
+ async finish(event) {
232
+ const active = this.active;
233
+ if (!active || active.turnId !== event.turnId) return;
234
+ const response = event.status === 'completed'
235
+ ? cleanCodexResponse(event.finalText)
236
+ || 'that turn completed without a response. please send it again.'
237
+ : event.status === 'failed'
238
+ ? `i hit an error before i could finish: ${errorSummary(event.error)}`
239
+ : 'that turn was interrupted before i could finish. please send it again.';
240
+ const delivered = await this.deliver(active, response);
241
+ if (delivered && this.active === active) this.active = null;
242
+ this.log('agent.turn.completed', {
243
+ status: event.status || null,
244
+ inputs: active.messages.length,
245
+ });
246
+ }
247
+
248
+ async routeOnce(prepared) {
249
+ if (this.state.has(prepared.event)) {
250
+ await this.channel.release(prepared);
251
+ return;
252
+ }
253
+ if (this.active) {
254
+ const active = this.active;
255
+ const input = await this.inputForMessage(prepared, { steer: true });
256
+ active.messages.push(prepared);
257
+ await this.persistActive();
258
+ try {
259
+ await this.agent.steerTurn({
260
+ input,
261
+ clientUserMessageId: prepared.messageId,
262
+ expectedTurnId: active.turnId,
263
+ });
264
+ } catch (error) {
265
+ if (error?.outcomeUnknown !== true && this.active === active) {
266
+ active.messages = active.messages.filter(
267
+ (message) => message !== prepared
268
+ );
269
+ await this.persistActive();
270
+ } else if (this.active === active) {
271
+ active.reconcileNeeded = true;
272
+ }
273
+ throw error;
274
+ }
275
+ active.reconcileNeeded = false;
276
+ active.lease.pulse();
277
+ this.log('agent.message.steered', {
278
+ turnId: active.turnId,
279
+ });
280
+ return;
281
+ }
282
+
283
+ const input = await this.inputForMessage(prepared, { steer: false });
284
+ const lease = await this.channel.beginWriting(prepared);
285
+ const active = {
286
+ turnId: null,
287
+ lease,
288
+ responseCid: null,
289
+ responseCidPersisted: false,
290
+ messages: [prepared],
291
+ reconcileNeeded: false,
292
+ };
293
+ this.active = active;
294
+ await this.persistActive();
295
+ try {
296
+ const result = await this.agent.startTurn({
297
+ input,
298
+ clientUserMessageId: prepared.messageId,
299
+ });
300
+ if (this.active !== active) return;
301
+ active.turnId = cleanText(result?.turnId) || active.turnId;
302
+ if (!active.turnId) throw new Error('agent did not return a turn id');
303
+ await this.persistActive();
304
+ active.reconcileNeeded = false;
305
+ this.log('agent.message.started', { turnId: active.turnId });
306
+ } catch (error) {
307
+ if (
308
+ error?.outcomeUnknown !== true
309
+ && this.active === active
310
+ && !active.turnId
311
+ ) {
312
+ this.active = null;
313
+ await lease.clear();
314
+ await this.state.setActive(null);
315
+ } else if (this.active === active) {
316
+ active.reconcileNeeded = true;
317
+ }
318
+ throw error;
319
+ }
320
+ }
321
+
322
+ async route(prepared) {
323
+ while (!this.closing) {
324
+ try {
325
+ const outcome = await this.enqueue(async () => {
326
+ if (this.closing) {
327
+ await this.channel.release(prepared);
328
+ return 'done';
329
+ }
330
+ if (this.state.has(prepared.event)) {
331
+ await this.channel.release(prepared);
332
+ return 'done';
333
+ }
334
+ if (this.active?.messages.some((message) => (
335
+ sameMessage(message, prepared)
336
+ ))) {
337
+ if (this.active.reconcileNeeded !== true) return 'done';
338
+ await this.recover(await this.openAgent());
339
+ if (
340
+ this.state.has(prepared.event)
341
+ || (
342
+ this.active?.messages.some((message) => (
343
+ sameMessage(message, prepared)
344
+ ))
345
+ && this.active.reconcileNeeded !== true
346
+ )
347
+ ) {
348
+ return 'done';
349
+ }
350
+ return 'retry';
351
+ }
352
+ if (
353
+ this.active
354
+ && this.active.messages[0]?.chatId !== prepared.chatId
355
+ ) {
356
+ return 'retry';
357
+ }
358
+ await this.routeOnce(prepared);
359
+ return 'done';
360
+ });
361
+ if (outcome === 'done') return;
362
+ } catch (error) {
363
+ this.log('agent.route.retry', { error: errorSummary(error) });
364
+ }
365
+ await wait(this.retryMs);
366
+ }
367
+ await this.channel.release(prepared);
368
+ }
369
+
370
+ async recover(opened) {
371
+ const stored = this.state.active;
372
+ const turns = opened?.turns || [];
373
+ if (!stored) {
374
+ const inProgress = turns.findLast((turn) => turn?.status === 'inProgress');
375
+ if (inProgress) {
376
+ throw new Error('Codex thread has an unrelated in-progress turn');
377
+ }
378
+ return;
379
+ }
380
+ if (stored.messages.every((message) => this.state.has(message))) {
381
+ const abandoned = this.active;
382
+ this.active = null;
383
+ await abandoned?.lease.clear();
384
+ await this.state.setActive(null);
385
+ return;
386
+ }
387
+ const turn = stored.turnId
388
+ ? turns.find((item) => item?.id === stored.turnId)
389
+ : turns.findLast((item) => turnContainsMessage(
390
+ item,
391
+ stored.messages[0].messageId
392
+ ));
393
+ if (!turn) {
394
+ const abandoned = this.active;
395
+ this.active = null;
396
+ await abandoned?.lease.clear();
397
+ await this.state.setActive(null);
398
+ this.requeue(abandoned?.messages);
399
+ return;
400
+ }
401
+ const acceptedIds = new Set(turn.inputIds || []);
402
+ const currentMessages = this.active?.messages || [];
403
+ const omittedMessages = currentMessages.filter((message) => (
404
+ !acceptedIds.has(message.messageId)
405
+ ));
406
+ const messages = stored.messages
407
+ .filter((message) => acceptedIds.has(message.messageId))
408
+ .map((message) => (
409
+ currentMessages.find((current) => sameMessage(current, message))
410
+ || { ...message }
411
+ ));
412
+ if (!messages.length) {
413
+ const abandoned = this.active;
414
+ this.active = null;
415
+ await abandoned?.lease.clear();
416
+ await this.state.setActive(null);
417
+ this.requeue(abandoned?.messages);
418
+ return;
419
+ }
420
+ const reusableLease = this.active?.lease?.compositionId
421
+ === stored.compositionId
422
+ ? this.active.lease
423
+ : null;
424
+ const lease = reusableLease || await this.channel.beginWriting(
425
+ messages.at(-1),
426
+ stored.compositionId
427
+ );
428
+ this.active = {
429
+ turnId: turn.id,
430
+ lease,
431
+ responseCid: stored.responseCid,
432
+ responseCidPersisted: !!stored.responseCid,
433
+ messages,
434
+ reconcileNeeded: false,
435
+ };
436
+ await this.persistActive();
437
+ this.requeue(omittedMessages);
438
+ if (turn.status === 'inProgress') return;
439
+ await this.finish({
440
+ turnId: turn.id,
441
+ status: turn.status,
442
+ error: turn.error,
443
+ finalText: turn.finalText,
444
+ });
445
+ }
446
+
447
+ async run() {
448
+ this.unsubscribe = this.agent.subscribe((event) => (
449
+ this.handleAgentEvent(event)
450
+ ));
451
+ const opened = await this.openAgent();
452
+ const threadId = opened.threadId;
453
+ await this.state.setThreadId(threadId);
454
+ await this.recover(opened);
455
+ this.log('connector.ready', { threadId });
456
+ return this.channel.listen((prepared) => this.route(prepared));
457
+ }
458
+
459
+ async close() {
460
+ if (this.closing) return;
461
+ this.closing = true;
462
+ this.unsubscribe?.();
463
+ this.unsubscribe = null;
464
+ await Promise.allSettled([
465
+ this.channel.close(),
466
+ this.agent.close(),
467
+ ]);
468
+ await Promise.allSettled([
469
+ this.eventChain,
470
+ this.completion,
471
+ this.requeueChain,
472
+ ]);
473
+ }
474
+ }
475
+
476
+ export function createAgentConnector(options = {}) {
477
+ return new AgentConnector(options);
478
+ }