@tjamescouch/agentchat 0.22.0 → 0.22.1

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,612 @@
1
+ /**
2
+ * AgentChat Proposals Module
3
+ * Handles structured negotiation between agents
4
+ *
5
+ * Proposals enable agents to make verifiable, signed commitments
6
+ * for work, services, or payments.
7
+ */
8
+
9
+ import { generateProposalId, ProposalStatus } from './protocol.js';
10
+ import type { ProposalStatus as ProposalStatusType } from './types.js';
11
+
12
+ // ============ Types ============
13
+
14
+ export interface ProposalInput {
15
+ id?: string;
16
+ from: string;
17
+ to: string;
18
+ task: string;
19
+ amount?: number | null;
20
+ currency?: string | null;
21
+ payment_code?: string | null;
22
+ terms?: string | null;
23
+ expires?: number | null;
24
+ sig: string;
25
+ elo_stake?: number | null;
26
+ }
27
+
28
+ export interface StoredProposal {
29
+ id: string;
30
+ from: string;
31
+ to: string;
32
+ task: string;
33
+ amount: number | null;
34
+ currency: string | null;
35
+ payment_code: string | null;
36
+ terms: string | null;
37
+ expires: number | null;
38
+ status: string;
39
+ created_at: number;
40
+ updated_at: number;
41
+ sig: string;
42
+ proposer_stake: number | null;
43
+ acceptor_stake: number | null;
44
+ stakes_escrowed: boolean;
45
+ response_sig: string | null;
46
+ response_payment_code: string | null;
47
+ completed_at: number | null;
48
+ completion_proof: string | null;
49
+ dispute_reason: string | null;
50
+ reject_reason?: string | null;
51
+ completion_sig?: string;
52
+ completed_by?: string;
53
+ dispute_sig?: string;
54
+ disputed_by?: string;
55
+ disputed_at?: number;
56
+ }
57
+
58
+ export interface ProposalResult {
59
+ proposal?: StoredProposal;
60
+ error?: string;
61
+ status?: string;
62
+ }
63
+
64
+ export interface ListOptions {
65
+ status?: string;
66
+ role?: 'from' | 'to';
67
+ limit?: number;
68
+ }
69
+
70
+ export interface ProposalStats {
71
+ total: number;
72
+ byStatus: Record<string, number>;
73
+ agents: number;
74
+ }
75
+
76
+ export interface FormattedProposal {
77
+ id: string;
78
+ from: string;
79
+ to: string;
80
+ task: string;
81
+ amount: number | null;
82
+ currency: string | null;
83
+ payment_code: string | null;
84
+ terms: string | null;
85
+ expires: number | null;
86
+ status: string;
87
+ created_at: number;
88
+ sig: string;
89
+ elo_stake: number | null;
90
+ }
91
+
92
+ export interface ProposalResponseBase {
93
+ proposal_id: string;
94
+ status: string;
95
+ updated_at: number;
96
+ }
97
+
98
+ export interface AcceptResponse extends ProposalResponseBase {
99
+ from: string;
100
+ to: string;
101
+ payment_code: string | null;
102
+ sig: string | null;
103
+ proposer_stake: number | null;
104
+ acceptor_stake: number | null;
105
+ }
106
+
107
+ export interface RejectResponse extends ProposalResponseBase {
108
+ from: string;
109
+ to: string;
110
+ reason: string | null | undefined;
111
+ sig: string | null;
112
+ }
113
+
114
+ export interface CompleteResponse extends ProposalResponseBase {
115
+ from: string;
116
+ to: string;
117
+ completed_by: string | undefined;
118
+ completed_at: number | null;
119
+ proof: string | null;
120
+ sig: string | undefined;
121
+ elo_stakes: {
122
+ proposer: number;
123
+ acceptor: number;
124
+ };
125
+ }
126
+
127
+ export interface DisputeResponse extends ProposalResponseBase {
128
+ from: string;
129
+ to: string;
130
+ disputed_by: string | undefined;
131
+ disputed_at: number | undefined;
132
+ reason: string | null;
133
+ sig: string | undefined;
134
+ elo_stakes: {
135
+ proposer: number;
136
+ acceptor: number;
137
+ };
138
+ }
139
+
140
+ export type ProposalResponse = AcceptResponse | RejectResponse | CompleteResponse | DisputeResponse | ProposalResponseBase;
141
+
142
+ // ============ ProposalStore Class ============
143
+
144
+ /**
145
+ * In-memory proposal store
146
+ * In production, this could be backed by persistence
147
+ */
148
+ export class ProposalStore {
149
+ private proposals: Map<string, StoredProposal>;
150
+ private byAgent: Map<string, Set<string>>;
151
+ private cleanupInterval: ReturnType<typeof setInterval> | null;
152
+
153
+ constructor() {
154
+ // Map of proposal_id -> proposal object
155
+ this.proposals = new Map();
156
+
157
+ // Index by agent for quick lookups
158
+ this.byAgent = new Map(); // agent_id -> Set of proposal_ids
159
+
160
+ // Cleanup expired proposals periodically
161
+ this.cleanupInterval = setInterval(() => this.cleanupExpired(), 60000);
162
+ }
163
+
164
+ /**
165
+ * Create a new proposal
166
+ */
167
+ create(proposal: ProposalInput): StoredProposal {
168
+ const id = proposal.id || generateProposalId();
169
+ const now = Date.now();
170
+
171
+ const stored: StoredProposal = {
172
+ id,
173
+ from: proposal.from,
174
+ to: proposal.to,
175
+ task: proposal.task,
176
+ amount: proposal.amount || null,
177
+ currency: proposal.currency || null,
178
+ payment_code: proposal.payment_code || null,
179
+ terms: proposal.terms || null,
180
+ expires: proposal.expires ? now + (proposal.expires * 1000) : null,
181
+ status: ProposalStatus.PENDING,
182
+ created_at: now,
183
+ updated_at: now,
184
+ sig: proposal.sig,
185
+ // ELO staking
186
+ proposer_stake: proposal.elo_stake || null,
187
+ acceptor_stake: null,
188
+ stakes_escrowed: false,
189
+ // Response tracking
190
+ response_sig: null,
191
+ response_payment_code: null,
192
+ completed_at: null,
193
+ completion_proof: null,
194
+ dispute_reason: null
195
+ };
196
+
197
+ this.proposals.set(id, stored);
198
+
199
+ // Index by both agents
200
+ this._indexAgent(proposal.from, id);
201
+ this._indexAgent(proposal.to, id);
202
+
203
+ return stored;
204
+ }
205
+
206
+ /**
207
+ * Get a proposal by ID
208
+ */
209
+ get(id: string): StoredProposal | null {
210
+ const proposal = this.proposals.get(id);
211
+ if (!proposal) return null;
212
+
213
+ // Check expiration
214
+ if (proposal.expires && Date.now() > proposal.expires) {
215
+ if (proposal.status === ProposalStatus.PENDING) {
216
+ proposal.status = ProposalStatus.EXPIRED;
217
+ proposal.updated_at = Date.now();
218
+ }
219
+ }
220
+
221
+ return proposal;
222
+ }
223
+
224
+ /**
225
+ * Accept a proposal
226
+ * @param id - Proposal ID
227
+ * @param acceptorId - Agent accepting the proposal
228
+ * @param sig - Signature of acceptance
229
+ * @param payment_code - Optional payment code
230
+ * @param acceptor_stake - Optional ELO stake from acceptor
231
+ */
232
+ accept(
233
+ id: string,
234
+ acceptorId: string,
235
+ sig: string,
236
+ payment_code: string | null = null,
237
+ acceptor_stake: number | null = null
238
+ ): ProposalResult {
239
+ const proposal = this.get(id);
240
+ if (!proposal) {
241
+ return { error: 'PROPOSAL_NOT_FOUND' };
242
+ }
243
+
244
+ if (proposal.status !== ProposalStatus.PENDING) {
245
+ return { error: 'PROPOSAL_NOT_PENDING', status: proposal.status };
246
+ }
247
+
248
+ if (proposal.to !== acceptorId) {
249
+ return { error: 'NOT_PROPOSAL_RECIPIENT' };
250
+ }
251
+
252
+ if (proposal.expires && Date.now() > proposal.expires) {
253
+ proposal.status = ProposalStatus.EXPIRED;
254
+ proposal.updated_at = Date.now();
255
+ return { error: 'PROPOSAL_EXPIRED' };
256
+ }
257
+
258
+ proposal.status = ProposalStatus.ACCEPTED;
259
+ proposal.response_sig = sig;
260
+ proposal.response_payment_code = payment_code;
261
+ proposal.acceptor_stake = acceptor_stake;
262
+ proposal.updated_at = Date.now();
263
+
264
+ return { proposal };
265
+ }
266
+
267
+ /**
268
+ * Reject a proposal
269
+ */
270
+ reject(
271
+ id: string,
272
+ rejectorId: string,
273
+ sig: string,
274
+ reason: string | null = null
275
+ ): ProposalResult {
276
+ const proposal = this.get(id);
277
+ if (!proposal) {
278
+ return { error: 'PROPOSAL_NOT_FOUND' };
279
+ }
280
+
281
+ if (proposal.status !== ProposalStatus.PENDING) {
282
+ return { error: 'PROPOSAL_NOT_PENDING', status: proposal.status };
283
+ }
284
+
285
+ if (proposal.to !== rejectorId) {
286
+ return { error: 'NOT_PROPOSAL_RECIPIENT' };
287
+ }
288
+
289
+ proposal.status = ProposalStatus.REJECTED;
290
+ proposal.response_sig = sig;
291
+ proposal.reject_reason = reason;
292
+ proposal.updated_at = Date.now();
293
+
294
+ return { proposal };
295
+ }
296
+
297
+ /**
298
+ * Mark a proposal as complete
299
+ */
300
+ complete(
301
+ id: string,
302
+ completerId: string,
303
+ sig: string,
304
+ proof: string | null = null
305
+ ): ProposalResult {
306
+ const proposal = this.get(id);
307
+ if (!proposal) {
308
+ return { error: 'PROPOSAL_NOT_FOUND' };
309
+ }
310
+
311
+ if (proposal.status !== ProposalStatus.ACCEPTED) {
312
+ return { error: 'PROPOSAL_NOT_ACCEPTED', status: proposal.status };
313
+ }
314
+
315
+ // Either party can mark as complete
316
+ if (proposal.from !== completerId && proposal.to !== completerId) {
317
+ return { error: 'NOT_PROPOSAL_PARTY' };
318
+ }
319
+
320
+ proposal.status = ProposalStatus.COMPLETED;
321
+ proposal.completed_at = Date.now();
322
+ proposal.completion_proof = proof;
323
+ proposal.completion_sig = sig;
324
+ proposal.completed_by = completerId;
325
+ proposal.updated_at = Date.now();
326
+
327
+ return { proposal };
328
+ }
329
+
330
+ /**
331
+ * Dispute a proposal
332
+ */
333
+ dispute(
334
+ id: string,
335
+ disputerId: string,
336
+ sig: string,
337
+ reason: string
338
+ ): ProposalResult {
339
+ const proposal = this.get(id);
340
+ if (!proposal) {
341
+ return { error: 'PROPOSAL_NOT_FOUND' };
342
+ }
343
+
344
+ // Can only dispute accepted proposals
345
+ if (proposal.status !== ProposalStatus.ACCEPTED) {
346
+ return { error: 'PROPOSAL_NOT_ACCEPTED', status: proposal.status };
347
+ }
348
+
349
+ // Either party can dispute
350
+ if (proposal.from !== disputerId && proposal.to !== disputerId) {
351
+ return { error: 'NOT_PROPOSAL_PARTY' };
352
+ }
353
+
354
+ proposal.status = ProposalStatus.DISPUTED;
355
+ proposal.dispute_reason = reason;
356
+ proposal.dispute_sig = sig;
357
+ proposal.disputed_by = disputerId;
358
+ proposal.disputed_at = Date.now();
359
+ proposal.updated_at = Date.now();
360
+
361
+ return { proposal };
362
+ }
363
+
364
+ /**
365
+ * List proposals for an agent
366
+ */
367
+ listByAgent(agentId: string, options: ListOptions = {}): StoredProposal[] {
368
+ const ids = this.byAgent.get(agentId) || new Set<string>();
369
+ let proposals = Array.from(ids)
370
+ .map(id => this.get(id))
371
+ .filter((p): p is StoredProposal => p !== null);
372
+
373
+ // Filter by status
374
+ if (options.status) {
375
+ proposals = proposals.filter(p => p.status === options.status);
376
+ }
377
+
378
+ // Filter by role (from/to)
379
+ if (options.role === 'from') {
380
+ proposals = proposals.filter(p => p.from === agentId);
381
+ } else if (options.role === 'to') {
382
+ proposals = proposals.filter(p => p.to === agentId);
383
+ }
384
+
385
+ // Sort by created_at descending
386
+ proposals.sort((a, b) => b.created_at - a.created_at);
387
+
388
+ // Limit
389
+ if (options.limit) {
390
+ proposals = proposals.slice(0, options.limit);
391
+ }
392
+
393
+ return proposals;
394
+ }
395
+
396
+ /**
397
+ * Index a proposal by agent
398
+ */
399
+ private _indexAgent(agentId: string, proposalId: string): void {
400
+ if (!this.byAgent.has(agentId)) {
401
+ this.byAgent.set(agentId, new Set());
402
+ }
403
+ this.byAgent.get(agentId)!.add(proposalId);
404
+ }
405
+
406
+ /**
407
+ * Clean up expired proposals (older than 24 hours after expiration)
408
+ */
409
+ cleanupExpired(): void {
410
+ const cutoff = Date.now() - (24 * 60 * 60 * 1000);
411
+
412
+ for (const [id, proposal] of this.proposals) {
413
+ if (proposal.expires && proposal.expires < cutoff) {
414
+ this.proposals.delete(id);
415
+
416
+ // Remove from agent indices
417
+ const fromSet = this.byAgent.get(proposal.from);
418
+ if (fromSet) fromSet.delete(id);
419
+
420
+ const toSet = this.byAgent.get(proposal.to);
421
+ if (toSet) toSet.delete(id);
422
+ }
423
+ }
424
+ }
425
+
426
+ /**
427
+ * Stop the cleanup interval
428
+ */
429
+ close(): void {
430
+ if (this.cleanupInterval) {
431
+ clearInterval(this.cleanupInterval);
432
+ this.cleanupInterval = null;
433
+ }
434
+ }
435
+
436
+ /**
437
+ * Get stats about the proposal store
438
+ */
439
+ stats(): ProposalStats {
440
+ const byStatus: Record<string, number> = {};
441
+ for (const proposal of this.proposals.values()) {
442
+ byStatus[proposal.status] = (byStatus[proposal.status] || 0) + 1;
443
+ }
444
+
445
+ return {
446
+ total: this.proposals.size,
447
+ byStatus,
448
+ agents: this.byAgent.size
449
+ };
450
+ }
451
+ }
452
+
453
+ // ============ Helper Functions ============
454
+
455
+ /**
456
+ * Format a proposal for display/transmission
457
+ */
458
+ export function formatProposal(proposal: StoredProposal): FormattedProposal {
459
+ return {
460
+ id: proposal.id,
461
+ from: proposal.from,
462
+ to: proposal.to,
463
+ task: proposal.task,
464
+ amount: proposal.amount,
465
+ currency: proposal.currency,
466
+ payment_code: proposal.payment_code,
467
+ terms: proposal.terms,
468
+ expires: proposal.expires,
469
+ status: proposal.status,
470
+ created_at: proposal.created_at,
471
+ sig: proposal.sig,
472
+ elo_stake: proposal.proposer_stake
473
+ };
474
+ }
475
+
476
+ /**
477
+ * Format a proposal response (accept/reject/complete/dispute)
478
+ */
479
+ export function formatProposalResponse(
480
+ proposal: StoredProposal,
481
+ responseType: 'accept' | 'reject' | 'complete' | 'dispute' | string
482
+ ): ProposalResponse {
483
+ const base: ProposalResponseBase = {
484
+ proposal_id: proposal.id,
485
+ status: proposal.status,
486
+ updated_at: proposal.updated_at
487
+ };
488
+
489
+ switch (responseType) {
490
+ case 'accept':
491
+ return {
492
+ ...base,
493
+ from: proposal.from,
494
+ to: proposal.to,
495
+ payment_code: proposal.response_payment_code,
496
+ sig: proposal.response_sig,
497
+ proposer_stake: proposal.proposer_stake,
498
+ acceptor_stake: proposal.acceptor_stake
499
+ } as AcceptResponse;
500
+
501
+ case 'reject':
502
+ return {
503
+ ...base,
504
+ from: proposal.from,
505
+ to: proposal.to,
506
+ reason: proposal.reject_reason,
507
+ sig: proposal.response_sig
508
+ } as RejectResponse;
509
+
510
+ case 'complete':
511
+ return {
512
+ ...base,
513
+ from: proposal.from,
514
+ to: proposal.to,
515
+ completed_by: proposal.completed_by,
516
+ completed_at: proposal.completed_at,
517
+ proof: proposal.completion_proof,
518
+ sig: proposal.completion_sig,
519
+ elo_stakes: {
520
+ proposer: proposal.proposer_stake || 0,
521
+ acceptor: proposal.acceptor_stake || 0
522
+ }
523
+ } as CompleteResponse;
524
+
525
+ case 'dispute':
526
+ return {
527
+ ...base,
528
+ from: proposal.from,
529
+ to: proposal.to,
530
+ disputed_by: proposal.disputed_by,
531
+ disputed_at: proposal.disputed_at,
532
+ reason: proposal.dispute_reason,
533
+ sig: proposal.dispute_sig,
534
+ elo_stakes: {
535
+ proposer: proposal.proposer_stake || 0,
536
+ acceptor: proposal.acceptor_stake || 0
537
+ }
538
+ } as DisputeResponse;
539
+
540
+ default:
541
+ return base;
542
+ }
543
+ }
544
+
545
+ /**
546
+ * Create proposal content string for signing
547
+ * This ensures both parties sign the same canonical data
548
+ */
549
+ export function getProposalSigningContent(proposal: {
550
+ to: string;
551
+ task: string;
552
+ amount?: number | null;
553
+ currency?: string | null;
554
+ payment_code?: string | null;
555
+ expires?: number | null;
556
+ elo_stake?: number | null;
557
+ }): string {
558
+ const fields = [
559
+ proposal.to,
560
+ proposal.task,
561
+ proposal.amount || '',
562
+ proposal.currency || '',
563
+ proposal.payment_code || '',
564
+ proposal.expires || '',
565
+ proposal.elo_stake || ''
566
+ ];
567
+ return fields.join('|');
568
+ }
569
+
570
+ /**
571
+ * Create accept content string for signing
572
+ * @param proposalId - The proposal being accepted
573
+ * @param payment_code - Optional payment code
574
+ * @param elo_stake - Optional ELO stake from acceptor
575
+ */
576
+ export function getAcceptSigningContent(
577
+ proposalId: string,
578
+ payment_code: string = '',
579
+ elo_stake: string | number = ''
580
+ ): string {
581
+ return `ACCEPT|${proposalId}|${payment_code}|${elo_stake}`;
582
+ }
583
+
584
+ /**
585
+ * Create reject content string for signing
586
+ */
587
+ export function getRejectSigningContent(
588
+ proposalId: string,
589
+ reason: string = ''
590
+ ): string {
591
+ return `REJECT|${proposalId}|${reason}`;
592
+ }
593
+
594
+ /**
595
+ * Create complete content string for signing
596
+ */
597
+ export function getCompleteSigningContent(
598
+ proposalId: string,
599
+ proof: string = ''
600
+ ): string {
601
+ return `COMPLETE|${proposalId}|${proof}`;
602
+ }
603
+
604
+ /**
605
+ * Create dispute content string for signing
606
+ */
607
+ export function getDisputeSigningContent(
608
+ proposalId: string,
609
+ reason: string
610
+ ): string {
611
+ return `DISPUTE|${proposalId}|${reason}`;
612
+ }
package/lib/protocol.js CHANGED
@@ -29,7 +29,11 @@ export const ClientMessageType = {
29
29
  SET_PRESENCE: 'SET_PRESENCE',
30
30
  // Identity verification message types
31
31
  VERIFY_REQUEST: 'VERIFY_REQUEST',
32
- VERIFY_RESPONSE: 'VERIFY_RESPONSE'
32
+ VERIFY_RESPONSE: 'VERIFY_RESPONSE',
33
+ // Admin message types
34
+ ADMIN_APPROVE: 'ADMIN_APPROVE',
35
+ ADMIN_REVOKE: 'ADMIN_REVOKE',
36
+ ADMIN_LIST: 'ADMIN_LIST',
33
37
  };
34
38
 
35
39
  // Server -> Client message types
@@ -59,7 +63,9 @@ export const ServerMessageType = {
59
63
  VERIFY_REQUEST: 'VERIFY_REQUEST',
60
64
  VERIFY_RESPONSE: 'VERIFY_RESPONSE',
61
65
  VERIFY_SUCCESS: 'VERIFY_SUCCESS',
62
- VERIFY_FAILED: 'VERIFY_FAILED'
66
+ VERIFY_FAILED: 'VERIFY_FAILED',
67
+ // Admin response
68
+ ADMIN_RESULT: 'ADMIN_RESULT',
63
69
  };
64
70
 
65
71
  // Error codes
@@ -84,7 +90,9 @@ export const ErrorCode = {
84
90
  // Verification errors
85
91
  VERIFICATION_FAILED: 'VERIFICATION_FAILED',
86
92
  VERIFICATION_EXPIRED: 'VERIFICATION_EXPIRED',
87
- NO_PUBKEY: 'NO_PUBKEY'
93
+ NO_PUBKEY: 'NO_PUBKEY',
94
+ // Allowlist errors
95
+ NOT_ALLOWED: 'NOT_ALLOWED',
88
96
  };
89
97
 
90
98
  // Presence status
@@ -282,7 +290,7 @@ export function validateClientMessage(raw) {
282
290
  if (msg.expires !== undefined && typeof msg.expires !== 'number') {
283
291
  return { valid: false, error: 'expires must be a number (seconds)' };
284
292
  }
285
- if (msg.elo_stake !== undefined) {
293
+ if (msg.elo_stake !== undefined && msg.elo_stake !== null) {
286
294
  if (typeof msg.elo_stake !== 'number' || msg.elo_stake < 0 || !Number.isInteger(msg.elo_stake)) {
287
295
  return { valid: false, error: 'elo_stake must be a non-negative integer' };
288
296
  }
@@ -298,7 +306,7 @@ export function validateClientMessage(raw) {
298
306
  return { valid: false, error: 'Accept must be signed' };
299
307
  }
300
308
  // Optional: elo_stake for acceptor's stake
301
- if (msg.elo_stake !== undefined) {
309
+ if (msg.elo_stake !== undefined && msg.elo_stake !== null) {
302
310
  if (typeof msg.elo_stake !== 'number' || msg.elo_stake < 0 || !Number.isInteger(msg.elo_stake)) {
303
311
  return { valid: false, error: 'elo_stake must be a non-negative integer' };
304
312
  }
@@ -409,6 +417,30 @@ export function validateClientMessage(raw) {
409
417
  }
410
418
  break;
411
419
 
420
+ case ClientMessageType.ADMIN_APPROVE:
421
+ if (!msg.pubkey || typeof msg.pubkey !== 'string') {
422
+ return { valid: false, error: 'Missing or invalid pubkey' };
423
+ }
424
+ if (!msg.admin_key || typeof msg.admin_key !== 'string') {
425
+ return { valid: false, error: 'Missing admin_key' };
426
+ }
427
+ break;
428
+
429
+ case ClientMessageType.ADMIN_REVOKE:
430
+ if (!msg.pubkey && !msg.agent_id) {
431
+ return { valid: false, error: 'Missing pubkey or agent_id' };
432
+ }
433
+ if (!msg.admin_key || typeof msg.admin_key !== 'string') {
434
+ return { valid: false, error: 'Missing admin_key' };
435
+ }
436
+ break;
437
+
438
+ case ClientMessageType.ADMIN_LIST:
439
+ if (!msg.admin_key || typeof msg.admin_key !== 'string') {
440
+ return { valid: false, error: 'Missing admin_key' };
441
+ }
442
+ break;
443
+
412
444
  default:
413
445
  return { valid: false, error: `Unknown message type: ${msg.type}` };
414
446
  }