@tjamescouch/agentchat 0.1.0 → 0.2.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,393 @@
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
+
11
+ /**
12
+ * In-memory proposal store
13
+ * In production, this could be backed by persistence
14
+ */
15
+ export class ProposalStore {
16
+ constructor() {
17
+ // Map of proposal_id -> proposal object
18
+ this.proposals = new Map();
19
+
20
+ // Index by agent for quick lookups
21
+ this.byAgent = new Map(); // agent_id -> Set of proposal_ids
22
+
23
+ // Cleanup expired proposals periodically
24
+ this.cleanupInterval = setInterval(() => this.cleanupExpired(), 60000);
25
+ }
26
+
27
+ /**
28
+ * Create a new proposal
29
+ */
30
+ create(proposal) {
31
+ const id = proposal.id || generateProposalId();
32
+ const now = Date.now();
33
+
34
+ const stored = {
35
+ id,
36
+ from: proposal.from,
37
+ to: proposal.to,
38
+ task: proposal.task,
39
+ amount: proposal.amount || null,
40
+ currency: proposal.currency || null,
41
+ payment_code: proposal.payment_code || null,
42
+ terms: proposal.terms || null,
43
+ expires: proposal.expires ? now + (proposal.expires * 1000) : null,
44
+ status: ProposalStatus.PENDING,
45
+ created_at: now,
46
+ updated_at: now,
47
+ sig: proposal.sig,
48
+ // Response tracking
49
+ response_sig: null,
50
+ response_payment_code: null,
51
+ completed_at: null,
52
+ completion_proof: null,
53
+ dispute_reason: null
54
+ };
55
+
56
+ this.proposals.set(id, stored);
57
+
58
+ // Index by both agents
59
+ this._indexAgent(proposal.from, id);
60
+ this._indexAgent(proposal.to, id);
61
+
62
+ return stored;
63
+ }
64
+
65
+ /**
66
+ * Get a proposal by ID
67
+ */
68
+ get(id) {
69
+ const proposal = this.proposals.get(id);
70
+ if (!proposal) return null;
71
+
72
+ // Check expiration
73
+ if (proposal.expires && Date.now() > proposal.expires) {
74
+ if (proposal.status === ProposalStatus.PENDING) {
75
+ proposal.status = ProposalStatus.EXPIRED;
76
+ proposal.updated_at = Date.now();
77
+ }
78
+ }
79
+
80
+ return proposal;
81
+ }
82
+
83
+ /**
84
+ * Accept a proposal
85
+ */
86
+ accept(id, acceptorId, sig, payment_code = null) {
87
+ const proposal = this.get(id);
88
+ if (!proposal) {
89
+ return { error: 'PROPOSAL_NOT_FOUND' };
90
+ }
91
+
92
+ if (proposal.status !== ProposalStatus.PENDING) {
93
+ return { error: 'PROPOSAL_NOT_PENDING', status: proposal.status };
94
+ }
95
+
96
+ if (proposal.to !== acceptorId) {
97
+ return { error: 'NOT_PROPOSAL_RECIPIENT' };
98
+ }
99
+
100
+ if (proposal.expires && Date.now() > proposal.expires) {
101
+ proposal.status = ProposalStatus.EXPIRED;
102
+ proposal.updated_at = Date.now();
103
+ return { error: 'PROPOSAL_EXPIRED' };
104
+ }
105
+
106
+ proposal.status = ProposalStatus.ACCEPTED;
107
+ proposal.response_sig = sig;
108
+ proposal.response_payment_code = payment_code;
109
+ proposal.updated_at = Date.now();
110
+
111
+ return { proposal };
112
+ }
113
+
114
+ /**
115
+ * Reject a proposal
116
+ */
117
+ reject(id, rejectorId, sig, reason = null) {
118
+ const proposal = this.get(id);
119
+ if (!proposal) {
120
+ return { error: 'PROPOSAL_NOT_FOUND' };
121
+ }
122
+
123
+ if (proposal.status !== ProposalStatus.PENDING) {
124
+ return { error: 'PROPOSAL_NOT_PENDING', status: proposal.status };
125
+ }
126
+
127
+ if (proposal.to !== rejectorId) {
128
+ return { error: 'NOT_PROPOSAL_RECIPIENT' };
129
+ }
130
+
131
+ proposal.status = ProposalStatus.REJECTED;
132
+ proposal.response_sig = sig;
133
+ proposal.reject_reason = reason;
134
+ proposal.updated_at = Date.now();
135
+
136
+ return { proposal };
137
+ }
138
+
139
+ /**
140
+ * Mark a proposal as complete
141
+ */
142
+ complete(id, completerId, sig, proof = null) {
143
+ const proposal = this.get(id);
144
+ if (!proposal) {
145
+ return { error: 'PROPOSAL_NOT_FOUND' };
146
+ }
147
+
148
+ if (proposal.status !== ProposalStatus.ACCEPTED) {
149
+ return { error: 'PROPOSAL_NOT_ACCEPTED', status: proposal.status };
150
+ }
151
+
152
+ // Either party can mark as complete
153
+ if (proposal.from !== completerId && proposal.to !== completerId) {
154
+ return { error: 'NOT_PROPOSAL_PARTY' };
155
+ }
156
+
157
+ proposal.status = ProposalStatus.COMPLETED;
158
+ proposal.completed_at = Date.now();
159
+ proposal.completion_proof = proof;
160
+ proposal.completion_sig = sig;
161
+ proposal.completed_by = completerId;
162
+ proposal.updated_at = Date.now();
163
+
164
+ return { proposal };
165
+ }
166
+
167
+ /**
168
+ * Dispute a proposal
169
+ */
170
+ dispute(id, disputerId, sig, reason) {
171
+ const proposal = this.get(id);
172
+ if (!proposal) {
173
+ return { error: 'PROPOSAL_NOT_FOUND' };
174
+ }
175
+
176
+ // Can only dispute accepted proposals
177
+ if (proposal.status !== ProposalStatus.ACCEPTED) {
178
+ return { error: 'PROPOSAL_NOT_ACCEPTED', status: proposal.status };
179
+ }
180
+
181
+ // Either party can dispute
182
+ if (proposal.from !== disputerId && proposal.to !== disputerId) {
183
+ return { error: 'NOT_PROPOSAL_PARTY' };
184
+ }
185
+
186
+ proposal.status = ProposalStatus.DISPUTED;
187
+ proposal.dispute_reason = reason;
188
+ proposal.dispute_sig = sig;
189
+ proposal.disputed_by = disputerId;
190
+ proposal.disputed_at = Date.now();
191
+ proposal.updated_at = Date.now();
192
+
193
+ return { proposal };
194
+ }
195
+
196
+ /**
197
+ * List proposals for an agent
198
+ */
199
+ listByAgent(agentId, options = {}) {
200
+ const ids = this.byAgent.get(agentId) || new Set();
201
+ let proposals = Array.from(ids).map(id => this.get(id)).filter(Boolean);
202
+
203
+ // Filter by status
204
+ if (options.status) {
205
+ proposals = proposals.filter(p => p.status === options.status);
206
+ }
207
+
208
+ // Filter by role (from/to)
209
+ if (options.role === 'from') {
210
+ proposals = proposals.filter(p => p.from === agentId);
211
+ } else if (options.role === 'to') {
212
+ proposals = proposals.filter(p => p.to === agentId);
213
+ }
214
+
215
+ // Sort by created_at descending
216
+ proposals.sort((a, b) => b.created_at - a.created_at);
217
+
218
+ // Limit
219
+ if (options.limit) {
220
+ proposals = proposals.slice(0, options.limit);
221
+ }
222
+
223
+ return proposals;
224
+ }
225
+
226
+ /**
227
+ * Index a proposal by agent
228
+ */
229
+ _indexAgent(agentId, proposalId) {
230
+ if (!this.byAgent.has(agentId)) {
231
+ this.byAgent.set(agentId, new Set());
232
+ }
233
+ this.byAgent.get(agentId).add(proposalId);
234
+ }
235
+
236
+ /**
237
+ * Clean up expired proposals (older than 24 hours after expiration)
238
+ */
239
+ cleanupExpired() {
240
+ const cutoff = Date.now() - (24 * 60 * 60 * 1000);
241
+
242
+ for (const [id, proposal] of this.proposals) {
243
+ if (proposal.expires && proposal.expires < cutoff) {
244
+ this.proposals.delete(id);
245
+
246
+ // Remove from agent indices
247
+ const fromSet = this.byAgent.get(proposal.from);
248
+ if (fromSet) fromSet.delete(id);
249
+
250
+ const toSet = this.byAgent.get(proposal.to);
251
+ if (toSet) toSet.delete(id);
252
+ }
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Stop the cleanup interval
258
+ */
259
+ close() {
260
+ if (this.cleanupInterval) {
261
+ clearInterval(this.cleanupInterval);
262
+ this.cleanupInterval = null;
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Get stats about the proposal store
268
+ */
269
+ stats() {
270
+ const byStatus = {};
271
+ for (const proposal of this.proposals.values()) {
272
+ byStatus[proposal.status] = (byStatus[proposal.status] || 0) + 1;
273
+ }
274
+
275
+ return {
276
+ total: this.proposals.size,
277
+ byStatus,
278
+ agents: this.byAgent.size
279
+ };
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Format a proposal for display/transmission
285
+ */
286
+ export function formatProposal(proposal) {
287
+ return {
288
+ id: proposal.id,
289
+ from: proposal.from,
290
+ to: proposal.to,
291
+ task: proposal.task,
292
+ amount: proposal.amount,
293
+ currency: proposal.currency,
294
+ payment_code: proposal.payment_code,
295
+ terms: proposal.terms,
296
+ expires: proposal.expires,
297
+ status: proposal.status,
298
+ created_at: proposal.created_at,
299
+ sig: proposal.sig
300
+ };
301
+ }
302
+
303
+ /**
304
+ * Format a proposal response (accept/reject/complete/dispute)
305
+ */
306
+ export function formatProposalResponse(proposal, responseType) {
307
+ const base = {
308
+ proposal_id: proposal.id,
309
+ status: proposal.status,
310
+ updated_at: proposal.updated_at
311
+ };
312
+
313
+ switch (responseType) {
314
+ case 'accept':
315
+ return {
316
+ ...base,
317
+ payment_code: proposal.response_payment_code,
318
+ sig: proposal.response_sig
319
+ };
320
+
321
+ case 'reject':
322
+ return {
323
+ ...base,
324
+ reason: proposal.reject_reason,
325
+ sig: proposal.response_sig
326
+ };
327
+
328
+ case 'complete':
329
+ return {
330
+ ...base,
331
+ completed_by: proposal.completed_by,
332
+ completed_at: proposal.completed_at,
333
+ proof: proposal.completion_proof,
334
+ sig: proposal.completion_sig
335
+ };
336
+
337
+ case 'dispute':
338
+ return {
339
+ ...base,
340
+ disputed_by: proposal.disputed_by,
341
+ disputed_at: proposal.disputed_at,
342
+ reason: proposal.dispute_reason,
343
+ sig: proposal.dispute_sig
344
+ };
345
+
346
+ default:
347
+ return base;
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Create proposal content string for signing
353
+ * This ensures both parties sign the same canonical data
354
+ */
355
+ export function getProposalSigningContent(proposal) {
356
+ const fields = [
357
+ proposal.to,
358
+ proposal.task,
359
+ proposal.amount || '',
360
+ proposal.currency || '',
361
+ proposal.payment_code || '',
362
+ proposal.expires || ''
363
+ ];
364
+ return fields.join('|');
365
+ }
366
+
367
+ /**
368
+ * Create accept content string for signing
369
+ */
370
+ export function getAcceptSigningContent(proposalId, payment_code = '') {
371
+ return `ACCEPT|${proposalId}|${payment_code}`;
372
+ }
373
+
374
+ /**
375
+ * Create reject content string for signing
376
+ */
377
+ export function getRejectSigningContent(proposalId, reason = '') {
378
+ return `REJECT|${proposalId}|${reason}`;
379
+ }
380
+
381
+ /**
382
+ * Create complete content string for signing
383
+ */
384
+ export function getCompleteSigningContent(proposalId, proof = '') {
385
+ return `COMPLETE|${proposalId}|${proof}`;
386
+ }
387
+
388
+ /**
389
+ * Create dispute content string for signing
390
+ */
391
+ export function getDisputeSigningContent(proposalId, reason) {
392
+ return `DISPUTE|${proposalId}|${reason}`;
393
+ }
package/lib/protocol.js CHANGED
@@ -15,7 +15,13 @@ export const ClientMessageType = {
15
15
  LIST_AGENTS: 'LIST_AGENTS',
16
16
  CREATE_CHANNEL: 'CREATE_CHANNEL',
17
17
  INVITE: 'INVITE',
18
- PING: 'PING'
18
+ PING: 'PING',
19
+ // Proposal/negotiation message types
20
+ PROPOSAL: 'PROPOSAL',
21
+ ACCEPT: 'ACCEPT',
22
+ REJECT: 'REJECT',
23
+ COMPLETE: 'COMPLETE',
24
+ DISPUTE: 'DISPUTE'
19
25
  };
20
26
 
21
27
  // Server -> Client message types
@@ -29,7 +35,13 @@ export const ServerMessageType = {
29
35
  CHANNELS: 'CHANNELS',
30
36
  AGENTS: 'AGENTS',
31
37
  ERROR: 'ERROR',
32
- PONG: 'PONG'
38
+ PONG: 'PONG',
39
+ // Proposal/negotiation message types (relayed from clients)
40
+ PROPOSAL: 'PROPOSAL',
41
+ ACCEPT: 'ACCEPT',
42
+ REJECT: 'REJECT',
43
+ COMPLETE: 'COMPLETE',
44
+ DISPUTE: 'DISPUTE'
33
45
  };
34
46
 
35
47
  // Error codes
@@ -41,7 +53,23 @@ export const ErrorCode = {
41
53
  RATE_LIMITED: 'RATE_LIMITED',
42
54
  AGENT_NOT_FOUND: 'AGENT_NOT_FOUND',
43
55
  CHANNEL_EXISTS: 'CHANNEL_EXISTS',
44
- INVALID_NAME: 'INVALID_NAME'
56
+ INVALID_NAME: 'INVALID_NAME',
57
+ // Proposal errors
58
+ PROPOSAL_NOT_FOUND: 'PROPOSAL_NOT_FOUND',
59
+ PROPOSAL_EXPIRED: 'PROPOSAL_EXPIRED',
60
+ INVALID_PROPOSAL: 'INVALID_PROPOSAL',
61
+ SIGNATURE_REQUIRED: 'SIGNATURE_REQUIRED',
62
+ NOT_PROPOSAL_PARTY: 'NOT_PROPOSAL_PARTY'
63
+ };
64
+
65
+ // Proposal status
66
+ export const ProposalStatus = {
67
+ PENDING: 'pending',
68
+ ACCEPTED: 'accepted',
69
+ REJECTED: 'rejected',
70
+ COMPLETED: 'completed',
71
+ DISPUTED: 'disputed',
72
+ EXPIRED: 'expired'
45
73
  };
46
74
 
47
75
  /**
@@ -201,7 +229,61 @@ export function validateClientMessage(raw) {
201
229
  case ClientMessageType.PING:
202
230
  // No additional validation needed
203
231
  break;
204
-
232
+
233
+ case ClientMessageType.PROPOSAL:
234
+ // Proposals require: to, task, and signature
235
+ if (!msg.to) {
236
+ return { valid: false, error: 'Missing target (to)' };
237
+ }
238
+ if (!isAgent(msg.to)) {
239
+ return { valid: false, error: 'Proposals must be sent to an agent (@id)' };
240
+ }
241
+ if (!msg.task || typeof msg.task !== 'string') {
242
+ return { valid: false, error: 'Missing or invalid task description' };
243
+ }
244
+ if (!msg.sig) {
245
+ return { valid: false, error: 'Proposals must be signed' };
246
+ }
247
+ // Optional fields: amount, currency, payment_code, expires, terms
248
+ if (msg.expires !== undefined && typeof msg.expires !== 'number') {
249
+ return { valid: false, error: 'expires must be a number (seconds)' };
250
+ }
251
+ break;
252
+
253
+ case ClientMessageType.ACCEPT:
254
+ case ClientMessageType.REJECT:
255
+ // Accept/Reject require: proposal_id and signature
256
+ if (!msg.proposal_id) {
257
+ return { valid: false, error: 'Missing proposal_id' };
258
+ }
259
+ if (!msg.sig) {
260
+ return { valid: false, error: 'Accept/Reject must be signed' };
261
+ }
262
+ break;
263
+
264
+ case ClientMessageType.COMPLETE:
265
+ // Complete requires: proposal_id, signature, and optionally proof
266
+ if (!msg.proposal_id) {
267
+ return { valid: false, error: 'Missing proposal_id' };
268
+ }
269
+ if (!msg.sig) {
270
+ return { valid: false, error: 'Complete must be signed' };
271
+ }
272
+ break;
273
+
274
+ case ClientMessageType.DISPUTE:
275
+ // Dispute requires: proposal_id, reason, and signature
276
+ if (!msg.proposal_id) {
277
+ return { valid: false, error: 'Missing proposal_id' };
278
+ }
279
+ if (!msg.reason || typeof msg.reason !== 'string') {
280
+ return { valid: false, error: 'Missing or invalid dispute reason' };
281
+ }
282
+ if (!msg.sig) {
283
+ return { valid: false, error: 'Dispute must be signed' };
284
+ }
285
+ break;
286
+
205
287
  default:
206
288
  return { valid: false, error: `Unknown message type: ${msg.type}` };
207
289
  }
@@ -234,3 +316,26 @@ export function serialize(msg) {
234
316
  export function parse(data) {
235
317
  return JSON.parse(data);
236
318
  }
319
+
320
+ /**
321
+ * Generate a unique proposal ID
322
+ * Format: prop_<timestamp>_<random>
323
+ */
324
+ export function generateProposalId() {
325
+ const timestamp = Date.now().toString(36);
326
+ const random = crypto.randomBytes(4).toString('hex');
327
+ return `prop_${timestamp}_${random}`;
328
+ }
329
+
330
+ /**
331
+ * Check if a message type is a proposal-related type
332
+ */
333
+ export function isProposalMessage(type) {
334
+ return [
335
+ ClientMessageType.PROPOSAL,
336
+ ClientMessageType.ACCEPT,
337
+ ClientMessageType.REJECT,
338
+ ClientMessageType.COMPLETE,
339
+ ClientMessageType.DISPUTE
340
+ ].includes(type);
341
+ }