@tjamescouch/agentchat 0.9.0 → 0.11.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/bin/agentchat.js CHANGED
@@ -95,7 +95,11 @@ program
95
95
  console.log('Message sent');
96
96
  process.exit(0);
97
97
  } catch (err) {
98
- console.error('Error:', err.message);
98
+ if (err.code === 'ECONNREFUSED') {
99
+ console.error('Error: Connection refused. Is the server running?');
100
+ } else {
101
+ console.error('Error:', err.message || err.code || err);
102
+ }
99
103
  process.exit(1);
100
104
  }
101
105
  });
@@ -142,7 +146,12 @@ program
142
146
  });
143
147
 
144
148
  } catch (err) {
145
- console.error('Error:', err.message);
149
+ if (err.code === 'ECONNREFUSED') {
150
+ console.error('Error: Connection refused. Is the server running?');
151
+ console.error(` Try: agentchat serve --port 8080`);
152
+ } else {
153
+ console.error('Error:', err.message || err.code || err);
154
+ }
146
155
  process.exit(1);
147
156
  }
148
157
  });
@@ -358,6 +367,7 @@ program
358
367
  .option('-p, --payment-code <code>', 'Your payment code (BIP47, address)')
359
368
  .option('-e, --expires <seconds>', 'Expiration time in seconds', '300')
360
369
  .option('-t, --terms <terms>', 'Additional terms')
370
+ .option('-s, --elo-stake <n>', 'ELO points to stake on this proposal')
361
371
  .action(async (server, agent, task, options) => {
362
372
  try {
363
373
  const client = new AgentChatClient({ server, identity: options.identity });
@@ -369,7 +379,8 @@ program
369
379
  currency: options.currency,
370
380
  payment_code: options.paymentCode,
371
381
  terms: options.terms,
372
- expires: parseInt(options.expires)
382
+ expires: parseInt(options.expires),
383
+ elo_stake: options.eloStake ? parseInt(options.eloStake) : undefined
373
384
  });
374
385
 
375
386
  console.log('Proposal sent:');
@@ -377,6 +388,7 @@ program
377
388
  console.log(` To: ${proposal.to}`);
378
389
  console.log(` Task: ${proposal.task}`);
379
390
  if (proposal.amount) console.log(` Amount: ${proposal.amount} ${proposal.currency || ''}`);
391
+ if (proposal.elo_stake) console.log(` ELO Stake: ${proposal.elo_stake}`);
380
392
  if (proposal.expires) console.log(` Expires: ${new Date(proposal.expires).toISOString()}`);
381
393
  console.log(`\nUse this ID to track responses.`);
382
394
 
@@ -394,16 +406,20 @@ program
394
406
  .description('Accept a proposal')
395
407
  .option('-i, --identity <file>', 'Path to identity file (required)', DEFAULT_IDENTITY_PATH)
396
408
  .option('-p, --payment-code <code>', 'Your payment code for receiving payment')
409
+ .option('-s, --elo-stake <n>', 'ELO points to stake (as acceptor)')
397
410
  .action(async (server, proposalId, options) => {
398
411
  try {
399
412
  const client = new AgentChatClient({ server, identity: options.identity });
400
413
  await client.connect();
401
414
 
402
- const response = await client.accept(proposalId, options.paymentCode);
415
+ const eloStake = options.eloStake ? parseInt(options.eloStake) : undefined;
416
+ const response = await client.accept(proposalId, options.paymentCode, eloStake);
403
417
 
404
418
  console.log('Proposal accepted:');
405
419
  console.log(` Proposal ID: ${response.proposal_id}`);
406
420
  console.log(` Status: ${response.status}`);
421
+ if (response.proposer_stake) console.log(` Proposer Stake: ${response.proposer_stake} ELO`);
422
+ if (response.acceptor_stake) console.log(` Your Stake: ${response.acceptor_stake} ELO`);
407
423
 
408
424
  client.disconnect();
409
425
  process.exit(0);
package/lib/client.js CHANGED
@@ -297,7 +297,8 @@ export class AgentChatClient extends EventEmitter {
297
297
  currency: proposal.currency,
298
298
  payment_code: proposal.payment_code,
299
299
  terms: proposal.terms,
300
- expires: proposal.expires
300
+ expires: proposal.expires,
301
+ elo_stake: proposal.elo_stake
301
302
  };
302
303
 
303
304
  // Sign the proposal
@@ -337,19 +338,21 @@ export class AgentChatClient extends EventEmitter {
337
338
  * Accept a proposal
338
339
  * @param {string} proposalId - The proposal ID to accept
339
340
  * @param {string} [payment_code] - Your payment code for receiving payment
341
+ * @param {number} [elo_stake] - ELO points to stake as acceptor
340
342
  */
341
- async accept(proposalId, payment_code = null) {
343
+ async accept(proposalId, payment_code = null, elo_stake = null) {
342
344
  if (!this._identity || !this._identity.privkey) {
343
345
  throw new Error('Accepting proposals requires persistent identity.');
344
346
  }
345
347
 
346
- const sigContent = getAcceptSigningContent(proposalId, payment_code || '');
348
+ const sigContent = getAcceptSigningContent(proposalId, payment_code || '', elo_stake || '');
347
349
  const sig = this._identity.sign(sigContent);
348
350
 
349
351
  const msg = {
350
352
  type: ClientMessageType.ACCEPT,
351
353
  proposal_id: proposalId,
352
354
  payment_code,
355
+ elo_stake,
353
356
  sig
354
357
  };
355
358
 
package/lib/proposals.js CHANGED
@@ -45,6 +45,10 @@ export class ProposalStore {
45
45
  created_at: now,
46
46
  updated_at: now,
47
47
  sig: proposal.sig,
48
+ // ELO staking
49
+ proposer_stake: proposal.elo_stake || null,
50
+ acceptor_stake: null,
51
+ stakes_escrowed: false,
48
52
  // Response tracking
49
53
  response_sig: null,
50
54
  response_payment_code: null,
@@ -82,8 +86,13 @@ export class ProposalStore {
82
86
 
83
87
  /**
84
88
  * Accept a proposal
89
+ * @param {string} id - Proposal ID
90
+ * @param {string} acceptorId - Agent accepting the proposal
91
+ * @param {string} sig - Signature of acceptance
92
+ * @param {string} payment_code - Optional payment code
93
+ * @param {number} acceptor_stake - Optional ELO stake from acceptor
85
94
  */
86
- accept(id, acceptorId, sig, payment_code = null) {
95
+ accept(id, acceptorId, sig, payment_code = null, acceptor_stake = null) {
87
96
  const proposal = this.get(id);
88
97
  if (!proposal) {
89
98
  return { error: 'PROPOSAL_NOT_FOUND' };
@@ -106,6 +115,7 @@ export class ProposalStore {
106
115
  proposal.status = ProposalStatus.ACCEPTED;
107
116
  proposal.response_sig = sig;
108
117
  proposal.response_payment_code = payment_code;
118
+ proposal.acceptor_stake = acceptor_stake;
109
119
  proposal.updated_at = Date.now();
110
120
 
111
121
  return { proposal };
@@ -296,7 +306,8 @@ export function formatProposal(proposal) {
296
306
  expires: proposal.expires,
297
307
  status: proposal.status,
298
308
  created_at: proposal.created_at,
299
- sig: proposal.sig
309
+ sig: proposal.sig,
310
+ elo_stake: proposal.proposer_stake
300
311
  };
301
312
  }
302
313
 
@@ -317,7 +328,9 @@ export function formatProposalResponse(proposal, responseType) {
317
328
  from: proposal.from,
318
329
  to: proposal.to,
319
330
  payment_code: proposal.response_payment_code,
320
- sig: proposal.response_sig
331
+ sig: proposal.response_sig,
332
+ proposer_stake: proposal.proposer_stake,
333
+ acceptor_stake: proposal.acceptor_stake
321
334
  };
322
335
 
323
336
  case 'reject':
@@ -337,7 +350,11 @@ export function formatProposalResponse(proposal, responseType) {
337
350
  completed_by: proposal.completed_by,
338
351
  completed_at: proposal.completed_at,
339
352
  proof: proposal.completion_proof,
340
- sig: proposal.completion_sig
353
+ sig: proposal.completion_sig,
354
+ elo_stakes: {
355
+ proposer: proposal.proposer_stake || 0,
356
+ acceptor: proposal.acceptor_stake || 0
357
+ }
341
358
  };
342
359
 
343
360
  case 'dispute':
@@ -348,7 +365,11 @@ export function formatProposalResponse(proposal, responseType) {
348
365
  disputed_by: proposal.disputed_by,
349
366
  disputed_at: proposal.disputed_at,
350
367
  reason: proposal.dispute_reason,
351
- sig: proposal.dispute_sig
368
+ sig: proposal.dispute_sig,
369
+ elo_stakes: {
370
+ proposer: proposal.proposer_stake || 0,
371
+ acceptor: proposal.acceptor_stake || 0
372
+ }
352
373
  };
353
374
 
354
375
  default:
@@ -367,16 +388,20 @@ export function getProposalSigningContent(proposal) {
367
388
  proposal.amount || '',
368
389
  proposal.currency || '',
369
390
  proposal.payment_code || '',
370
- proposal.expires || ''
391
+ proposal.expires || '',
392
+ proposal.elo_stake || ''
371
393
  ];
372
394
  return fields.join('|');
373
395
  }
374
396
 
375
397
  /**
376
398
  * Create accept content string for signing
399
+ * @param {string} proposalId - The proposal being accepted
400
+ * @param {string} payment_code - Optional payment code
401
+ * @param {number} elo_stake - Optional ELO stake from acceptor
377
402
  */
378
- export function getAcceptSigningContent(proposalId, payment_code = '') {
379
- return `ACCEPT|${proposalId}|${payment_code}`;
403
+ export function getAcceptSigningContent(proposalId, payment_code = '', elo_stake = '') {
404
+ return `ACCEPT|${proposalId}|${payment_code}|${elo_stake}`;
380
405
  }
381
406
 
382
407
  /**
package/lib/protocol.js CHANGED
@@ -65,7 +65,10 @@ export const ErrorCode = {
65
65
  PROPOSAL_EXPIRED: 'PROPOSAL_EXPIRED',
66
66
  INVALID_PROPOSAL: 'INVALID_PROPOSAL',
67
67
  SIGNATURE_REQUIRED: 'SIGNATURE_REQUIRED',
68
- NOT_PROPOSAL_PARTY: 'NOT_PROPOSAL_PARTY'
68
+ NOT_PROPOSAL_PARTY: 'NOT_PROPOSAL_PARTY',
69
+ // Staking errors
70
+ INSUFFICIENT_REPUTATION: 'INSUFFICIENT_REPUTATION',
71
+ INVALID_STAKE: 'INVALID_STAKE'
69
72
  };
70
73
 
71
74
  // Proposal status
@@ -250,20 +253,40 @@ export function validateClientMessage(raw) {
250
253
  if (!msg.sig) {
251
254
  return { valid: false, error: 'Proposals must be signed' };
252
255
  }
253
- // Optional fields: amount, currency, payment_code, expires, terms
256
+ // Optional fields: amount, currency, payment_code, expires, terms, elo_stake
254
257
  if (msg.expires !== undefined && typeof msg.expires !== 'number') {
255
258
  return { valid: false, error: 'expires must be a number (seconds)' };
256
259
  }
260
+ if (msg.elo_stake !== undefined) {
261
+ if (typeof msg.elo_stake !== 'number' || msg.elo_stake < 0 || !Number.isInteger(msg.elo_stake)) {
262
+ return { valid: false, error: 'elo_stake must be a non-negative integer' };
263
+ }
264
+ }
257
265
  break;
258
266
 
259
267
  case ClientMessageType.ACCEPT:
268
+ // Accept requires: proposal_id and signature
269
+ if (!msg.proposal_id) {
270
+ return { valid: false, error: 'Missing proposal_id' };
271
+ }
272
+ if (!msg.sig) {
273
+ return { valid: false, error: 'Accept must be signed' };
274
+ }
275
+ // Optional: elo_stake for acceptor's stake
276
+ if (msg.elo_stake !== undefined) {
277
+ if (typeof msg.elo_stake !== 'number' || msg.elo_stake < 0 || !Number.isInteger(msg.elo_stake)) {
278
+ return { valid: false, error: 'elo_stake must be a non-negative integer' };
279
+ }
280
+ }
281
+ break;
282
+
260
283
  case ClientMessageType.REJECT:
261
- // Accept/Reject require: proposal_id and signature
284
+ // Reject requires: proposal_id and signature
262
285
  if (!msg.proposal_id) {
263
286
  return { valid: false, error: 'Missing proposal_id' };
264
287
  }
265
288
  if (!msg.sig) {
266
- return { valid: false, error: 'Accept/Reject must be signed' };
289
+ return { valid: false, error: 'Reject must be signed' };
267
290
  }
268
291
  break;
269
292
 
package/lib/reputation.js CHANGED
@@ -21,6 +21,7 @@ export const DEFAULT_RATINGS_PATH = path.join(AGENTCHAT_DIR, 'ratings.json');
21
21
  // ELO constants
22
22
  export const DEFAULT_RATING = 1200;
23
23
  export const ELO_DIVISOR = 400; // Standard ELO divisor
24
+ export const MINIMUM_RATING = 100; // Floor - can't drop below this
24
25
 
25
26
  // K-factor thresholds
26
27
  const K_FACTOR_NEW = 32; // < 30 transactions
@@ -127,6 +128,7 @@ export class ReputationStore {
127
128
  constructor(ratingsPath = DEFAULT_RATINGS_PATH) {
128
129
  this.ratingsPath = ratingsPath;
129
130
  this._ratings = null; // Lazy load
131
+ this._escrows = new Map(); // proposalId → escrow record
130
132
  }
131
133
 
132
134
  /**
@@ -210,6 +212,125 @@ export class ReputationStore {
210
212
  return getKFactor(record.transactions);
211
213
  }
212
214
 
215
+ /**
216
+ * Get total escrowed ELO for an agent
217
+ */
218
+ getEscrowedAmount(agentId) {
219
+ const id = this._normalizeId(agentId);
220
+ let total = 0;
221
+ for (const escrow of this._escrows.values()) {
222
+ if (escrow.status === 'active') {
223
+ if (escrow.from.agent_id === id) {
224
+ total += escrow.from.stake;
225
+ }
226
+ if (escrow.to.agent_id === id) {
227
+ total += escrow.to.stake;
228
+ }
229
+ }
230
+ }
231
+ return total;
232
+ }
233
+
234
+ /**
235
+ * Get available rating for staking (rating - escrowed - minimum floor)
236
+ */
237
+ async getAvailableRating(agentId) {
238
+ const record = await this.getRating(agentId);
239
+ const escrowed = this.getEscrowedAmount(agentId);
240
+ const available = record.rating - escrowed - MINIMUM_RATING;
241
+ return Math.max(0, available);
242
+ }
243
+
244
+ /**
245
+ * Check if agent can stake the requested amount
246
+ */
247
+ async canStake(agentId, amount) {
248
+ if (!amount || amount <= 0) {
249
+ return { canStake: true, available: await this.getAvailableRating(agentId) };
250
+ }
251
+
252
+ const available = await this.getAvailableRating(agentId);
253
+
254
+ if (amount > available) {
255
+ return {
256
+ canStake: false,
257
+ available,
258
+ reason: `Insufficient ELO. Available: ${available}, Requested: ${amount}`
259
+ };
260
+ }
261
+
262
+ return { canStake: true, available };
263
+ }
264
+
265
+ /**
266
+ * Create escrow for a proposal
267
+ * Called when proposal is accepted with stakes
268
+ */
269
+ async createEscrow(proposalId, fromStake, toStake, expiresAt = null) {
270
+ // Validate both parties can stake
271
+ if (fromStake.stake > 0) {
272
+ const canFrom = await this.canStake(fromStake.agent_id, fromStake.stake);
273
+ if (!canFrom.canStake) {
274
+ return { success: false, error: `Proposer: ${canFrom.reason}` };
275
+ }
276
+ }
277
+
278
+ if (toStake.stake > 0) {
279
+ const canTo = await this.canStake(toStake.agent_id, toStake.stake);
280
+ if (!canTo.canStake) {
281
+ return { success: false, error: `Acceptor: ${canTo.reason}` };
282
+ }
283
+ }
284
+
285
+ const escrow = {
286
+ proposal_id: proposalId,
287
+ created_at: Date.now(),
288
+ from: {
289
+ agent_id: this._normalizeId(fromStake.agent_id),
290
+ stake: fromStake.stake || 0
291
+ },
292
+ to: {
293
+ agent_id: this._normalizeId(toStake.agent_id),
294
+ stake: toStake.stake || 0
295
+ },
296
+ status: 'active',
297
+ expires_at: expiresAt,
298
+ settled_at: null,
299
+ settlement_reason: null
300
+ };
301
+
302
+ this._escrows.set(proposalId, escrow);
303
+ return { success: true, escrow };
304
+ }
305
+
306
+ /**
307
+ * Get escrow for a proposal
308
+ */
309
+ getEscrow(proposalId) {
310
+ return this._escrows.get(proposalId) || null;
311
+ }
312
+
313
+ /**
314
+ * Release escrow (return stakes to both parties, no rating change)
315
+ * Used for proposal expiration
316
+ */
317
+ releaseEscrow(proposalId) {
318
+ const escrow = this._escrows.get(proposalId);
319
+ if (!escrow) {
320
+ return { released: false, error: 'Escrow not found' };
321
+ }
322
+
323
+ if (escrow.status !== 'active') {
324
+ return { released: false, error: `Escrow already ${escrow.status}` };
325
+ }
326
+
327
+ escrow.status = 'released';
328
+ escrow.settled_at = Date.now();
329
+ escrow.settlement_reason = 'expired';
330
+
331
+ return { released: true, escrow };
332
+ }
333
+
213
334
  /**
214
335
  * Update rating for an agent
215
336
  */
@@ -233,7 +354,7 @@ export class ReputationStore {
233
354
  }
234
355
 
235
356
  /**
236
- * Process a COMPLETE receipt - both parties gain
357
+ * Process a COMPLETE receipt - both parties gain (halved gains with staking)
237
358
  *
238
359
  * @param {object} receipt - The COMPLETE receipt
239
360
  * @returns {object} Rating changes for both parties
@@ -243,6 +364,7 @@ export class ReputationStore {
243
364
  const party1 = receipt.proposal?.from || receipt.from;
244
365
  const party2 = receipt.proposal?.to || receipt.to;
245
366
  const amount = receipt.proposal?.amount || receipt.amount || 0;
367
+ const proposalId = receipt.proposal_id;
246
368
 
247
369
  if (!party1 || !party2) {
248
370
  throw new Error('Receipt missing party information');
@@ -252,12 +374,32 @@ export class ReputationStore {
252
374
  const rating1 = await this.getRating(party1);
253
375
  const rating2 = await this.getRating(party2);
254
376
 
255
- // Calculate gains
377
+ // Calculate gains (halved for staking model)
256
378
  const k1 = getKFactor(rating1.transactions);
257
379
  const k2 = getKFactor(rating2.transactions);
258
380
 
259
- const gain1 = calculateCompletionGain(rating1.rating, rating2.rating, k1, amount);
260
- const gain2 = calculateCompletionGain(rating2.rating, rating1.rating, k2, amount);
381
+ const fullGain1 = calculateCompletionGain(rating1.rating, rating2.rating, k1, amount);
382
+ const fullGain2 = calculateCompletionGain(rating2.rating, rating1.rating, k2, amount);
383
+
384
+ // Half the gains (staking model: split gains to balance inflation)
385
+ const gain1 = Math.max(1, Math.round(fullGain1 / 2));
386
+ const gain2 = Math.max(1, Math.round(fullGain2 / 2));
387
+
388
+ // Settle escrow if exists (return stakes)
389
+ let escrowSettlement = null;
390
+ if (proposalId) {
391
+ const escrow = this._escrows.get(proposalId);
392
+ if (escrow && escrow.status === 'active') {
393
+ escrow.status = 'settled';
394
+ escrow.settled_at = Date.now();
395
+ escrow.settlement_reason = 'completed';
396
+ escrowSettlement = {
397
+ proposer_stake: escrow.from.stake,
398
+ acceptor_stake: escrow.to.stake,
399
+ settlement: 'returned'
400
+ };
401
+ }
402
+ }
261
403
 
262
404
  // Apply updates
263
405
  const updated1 = await this._updateAgent(party1, gain1);
@@ -266,7 +408,7 @@ export class ReputationStore {
266
408
  // Save
267
409
  await this.save();
268
410
 
269
- return {
411
+ const result = {
270
412
  [party1]: {
271
413
  oldRating: rating1.rating,
272
414
  newRating: updated1.rating,
@@ -280,12 +422,19 @@ export class ReputationStore {
280
422
  transactions: updated2.transactions
281
423
  }
282
424
  };
425
+
426
+ if (escrowSettlement) {
427
+ result._escrow = escrowSettlement;
428
+ }
429
+
430
+ return result;
283
431
  }
284
432
 
285
433
  /**
286
434
  * Process a DISPUTE receipt
287
435
  * If disputed_by is set, they are the "winner" (counterparty is at fault)
288
436
  * Otherwise, both parties lose (mutual fault)
437
+ * Stakes are transferred to winner or burned on mutual fault
289
438
  *
290
439
  * @param {object} receipt - The DISPUTE receipt
291
440
  * @returns {object} Rating changes for both parties
@@ -295,6 +444,7 @@ export class ReputationStore {
295
444
  const party2 = receipt.proposal?.to || receipt.to;
296
445
  const disputedBy = receipt.disputed_by;
297
446
  const amount = receipt.proposal?.amount || receipt.amount || 0;
447
+ const proposalId = receipt.proposal_id;
298
448
 
299
449
  if (!party1 || !party2) {
300
450
  throw new Error('Receipt missing party information');
@@ -306,24 +456,68 @@ export class ReputationStore {
306
456
  const k1 = getKFactor(rating1.transactions);
307
457
  const k2 = getKFactor(rating2.transactions);
308
458
 
459
+ // Get escrow info for stake calculations
460
+ let escrow = null;
461
+ let stake1 = 0, stake2 = 0;
462
+ if (proposalId) {
463
+ escrow = this._escrows.get(proposalId);
464
+ if (escrow && escrow.status === 'active') {
465
+ stake1 = escrow.from.agent_id === this._normalizeId(party1)
466
+ ? escrow.from.stake
467
+ : escrow.to.stake;
468
+ stake2 = escrow.from.agent_id === this._normalizeId(party2)
469
+ ? escrow.from.stake
470
+ : escrow.to.stake;
471
+ }
472
+ }
473
+
309
474
  let change1, change2;
475
+ let escrowSettlement = null;
310
476
 
311
477
  if (disputedBy) {
312
478
  // The disputer is the "winner", counterparty at fault
313
479
  const atFault = disputedBy === party1 ? party2 : party1;
314
- const winner = disputedBy === party1 ? party1 : party2;
315
480
 
316
481
  if (atFault === party1) {
317
- change1 = calculateDisputeLoss(rating1.rating, rating2.rating, k1, amount);
318
- change2 = Math.round(Math.abs(change1) * 0.5); // Winner gains half of loser's loss
482
+ // Party1 at fault: loses ELO + loses stake to party2
483
+ change1 = calculateDisputeLoss(rating1.rating, rating2.rating, k1, amount) - stake1;
484
+ change2 = Math.round(Math.abs(calculateDisputeLoss(rating1.rating, rating2.rating, k1, amount)) * 0.5) + stake1;
485
+ escrowSettlement = {
486
+ proposer_stake: escrow?.from.stake || 0,
487
+ acceptor_stake: escrow?.to.stake || 0,
488
+ settlement: 'transferred',
489
+ transferred_to: party2,
490
+ transferred_amount: stake1
491
+ };
319
492
  } else {
320
- change2 = calculateDisputeLoss(rating2.rating, rating1.rating, k2, amount);
321
- change1 = Math.round(Math.abs(change2) * 0.5);
493
+ // Party2 at fault: loses ELO + loses stake to party1
494
+ change2 = calculateDisputeLoss(rating2.rating, rating1.rating, k2, amount) - stake2;
495
+ change1 = Math.round(Math.abs(calculateDisputeLoss(rating2.rating, rating1.rating, k2, amount)) * 0.5) + stake2;
496
+ escrowSettlement = {
497
+ proposer_stake: escrow?.from.stake || 0,
498
+ acceptor_stake: escrow?.to.stake || 0,
499
+ settlement: 'transferred',
500
+ transferred_to: party1,
501
+ transferred_amount: stake2
502
+ };
322
503
  }
323
504
  } else {
324
- // Mutual fault - both lose
325
- change1 = calculateDisputeLoss(rating1.rating, rating2.rating, k1, amount);
326
- change2 = calculateDisputeLoss(rating2.rating, rating1.rating, k2, amount);
505
+ // Mutual fault - both lose ELO + both stakes burned
506
+ change1 = calculateDisputeLoss(rating1.rating, rating2.rating, k1, amount) - stake1;
507
+ change2 = calculateDisputeLoss(rating2.rating, rating1.rating, k2, amount) - stake2;
508
+ escrowSettlement = {
509
+ proposer_stake: escrow?.from.stake || 0,
510
+ acceptor_stake: escrow?.to.stake || 0,
511
+ settlement: 'burned',
512
+ burned_amount: stake1 + stake2
513
+ };
514
+ }
515
+
516
+ // Settle escrow
517
+ if (escrow && escrow.status === 'active') {
518
+ escrow.status = 'settled';
519
+ escrow.settled_at = Date.now();
520
+ escrow.settlement_reason = 'disputed';
327
521
  }
328
522
 
329
523
  const updated1 = await this._updateAgent(party1, change1);
@@ -331,7 +525,7 @@ export class ReputationStore {
331
525
 
332
526
  await this.save();
333
527
 
334
- return {
528
+ const result = {
335
529
  [party1]: {
336
530
  oldRating: rating1.rating,
337
531
  newRating: updated1.rating,
@@ -345,6 +539,12 @@ export class ReputationStore {
345
539
  transactions: updated2.transactions
346
540
  }
347
541
  };
542
+
543
+ if (escrowSettlement) {
544
+ result._escrow = escrowSettlement;
545
+ }
546
+
547
+ return result;
348
548
  }
349
549
 
350
550
  /**
package/lib/server.js CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  isProposalMessage
24
24
  } from './protocol.js';
25
25
  import { ProposalStore, formatProposal, formatProposalResponse } from './proposals.js';
26
+ import { ReputationStore } from './reputation.js';
26
27
 
27
28
  export class AgentChatServer {
28
29
  constructor(options = {}) {
@@ -75,6 +76,9 @@ export class AgentChatServer {
75
76
  // Skills registry: agentId -> { skills: [], registered_at, sig }
76
77
  this.skillsRegistry = new Map();
77
78
 
79
+ // Reputation store for ELO ratings
80
+ this.reputationStore = new ReputationStore();
81
+
78
82
  this.wss = null;
79
83
  this.httpServer = null; // For TLS mode
80
84
  }
@@ -681,7 +685,8 @@ export class AgentChatServer {
681
685
  payment_code: msg.payment_code,
682
686
  terms: msg.terms,
683
687
  expires: msg.expires,
684
- sig: msg.sig
688
+ sig: msg.sig,
689
+ elo_stake: msg.elo_stake || null
685
690
  });
686
691
 
687
692
  this._log('proposal', { id: proposal.id, from: agent.id, to: targetId });
@@ -696,7 +701,7 @@ export class AgentChatServer {
696
701
  this._send(ws, outMsg);
697
702
  }
698
703
 
699
- _handleAccept(ws, msg) {
704
+ async _handleAccept(ws, msg) {
700
705
  const agent = this.agents.get(ws);
701
706
  if (!agent) {
702
707
  this._send(ws, createError(ErrorCode.AUTH_REQUIRED, 'Must IDENTIFY first'));
@@ -708,11 +713,40 @@ export class AgentChatServer {
708
713
  return;
709
714
  }
710
715
 
716
+ // Get proposal first to check stakes
717
+ const existingProposal = this.proposals.get(msg.proposal_id);
718
+ if (!existingProposal) {
719
+ this._send(ws, createError(ErrorCode.PROPOSAL_NOT_FOUND, 'Proposal not found'));
720
+ return;
721
+ }
722
+
723
+ const proposerStake = existingProposal.proposer_stake || 0;
724
+ const acceptorStake = msg.elo_stake || 0;
725
+
726
+ // Validate proposer can stake (if they declared a stake)
727
+ if (proposerStake > 0) {
728
+ const canProposerStake = await this.reputationStore.canStake(existingProposal.from, proposerStake);
729
+ if (!canProposerStake.canStake) {
730
+ this._send(ws, createError(ErrorCode.INSUFFICIENT_REPUTATION, `Proposer: ${canProposerStake.reason}`));
731
+ return;
732
+ }
733
+ }
734
+
735
+ // Validate acceptor can stake (if they declared a stake)
736
+ if (acceptorStake > 0) {
737
+ const canAcceptorStake = await this.reputationStore.canStake(`@${agent.id}`, acceptorStake);
738
+ if (!canAcceptorStake.canStake) {
739
+ this._send(ws, createError(ErrorCode.INSUFFICIENT_REPUTATION, canAcceptorStake.reason));
740
+ return;
741
+ }
742
+ }
743
+
711
744
  const result = this.proposals.accept(
712
745
  msg.proposal_id,
713
746
  `@${agent.id}`,
714
747
  msg.sig,
715
- msg.payment_code
748
+ msg.payment_code,
749
+ acceptorStake
716
750
  );
717
751
 
718
752
  if (result.error) {
@@ -721,7 +755,29 @@ export class AgentChatServer {
721
755
  }
722
756
 
723
757
  const proposal = result.proposal;
724
- this._log('accept', { id: proposal.id, by: agent.id });
758
+
759
+ // Create escrow if either party has a stake
760
+ if (proposerStake > 0 || acceptorStake > 0) {
761
+ const escrowResult = await this.reputationStore.createEscrow(
762
+ proposal.id,
763
+ { agent_id: proposal.from, stake: proposerStake },
764
+ { agent_id: proposal.to, stake: acceptorStake },
765
+ proposal.expires
766
+ );
767
+
768
+ if (escrowResult.success) {
769
+ proposal.stakes_escrowed = true;
770
+ this._log('escrow_created', {
771
+ proposal_id: proposal.id,
772
+ proposer_stake: proposerStake,
773
+ acceptor_stake: acceptorStake
774
+ });
775
+ } else {
776
+ this._log('escrow_error', { proposal_id: proposal.id, error: escrowResult.error });
777
+ }
778
+ }
779
+
780
+ this._log('accept', { id: proposal.id, by: agent.id, proposer_stake: proposerStake, acceptor_stake: acceptorStake });
725
781
 
726
782
  // Notify the proposal creator
727
783
  const creatorId = proposal.from.slice(1);
@@ -780,7 +836,7 @@ export class AgentChatServer {
780
836
  this._send(ws, outMsg);
781
837
  }
782
838
 
783
- _handleComplete(ws, msg) {
839
+ async _handleComplete(ws, msg) {
784
840
  const agent = this.agents.get(ws);
785
841
  if (!agent) {
786
842
  this._send(ws, createError(ErrorCode.AUTH_REQUIRED, 'Must IDENTIFY first'));
@@ -807,9 +863,29 @@ export class AgentChatServer {
807
863
  const proposal = result.proposal;
808
864
  this._log('complete', { id: proposal.id, by: agent.id });
809
865
 
866
+ // Update reputation ratings (includes escrow settlement)
867
+ let ratingChanges = null;
868
+ try {
869
+ ratingChanges = await this.reputationStore.processCompletion({
870
+ type: 'COMPLETE',
871
+ proposal_id: proposal.id,
872
+ from: proposal.from,
873
+ to: proposal.to,
874
+ amount: proposal.amount
875
+ });
876
+ this._log('reputation_updated', {
877
+ proposal_id: proposal.id,
878
+ changes: ratingChanges,
879
+ escrow: ratingChanges?._escrow
880
+ });
881
+ } catch (err) {
882
+ this._log('reputation_error', { error: err.message });
883
+ }
884
+
810
885
  // Notify both parties
811
886
  const outMsg = createMessage(ServerMessageType.COMPLETE, {
812
- ...formatProposalResponse(proposal, 'complete')
887
+ ...formatProposalResponse(proposal, 'complete'),
888
+ rating_changes: ratingChanges
813
889
  });
814
890
 
815
891
  // Notify the other party
@@ -823,7 +899,7 @@ export class AgentChatServer {
823
899
  this._send(ws, outMsg);
824
900
  }
825
901
 
826
- _handleDispute(ws, msg) {
902
+ async _handleDispute(ws, msg) {
827
903
  const agent = this.agents.get(ws);
828
904
  if (!agent) {
829
905
  this._send(ws, createError(ErrorCode.AUTH_REQUIRED, 'Must IDENTIFY first'));
@@ -850,9 +926,30 @@ export class AgentChatServer {
850
926
  const proposal = result.proposal;
851
927
  this._log('dispute', { id: proposal.id, by: agent.id, reason: msg.reason });
852
928
 
929
+ // Update reputation ratings (includes escrow settlement)
930
+ let ratingChanges = null;
931
+ try {
932
+ ratingChanges = await this.reputationStore.processDispute({
933
+ type: 'DISPUTE',
934
+ proposal_id: proposal.id,
935
+ from: proposal.from,
936
+ to: proposal.to,
937
+ amount: proposal.amount,
938
+ disputed_by: `@${agent.id}`
939
+ });
940
+ this._log('reputation_updated', {
941
+ proposal_id: proposal.id,
942
+ changes: ratingChanges,
943
+ escrow: ratingChanges?._escrow
944
+ });
945
+ } catch (err) {
946
+ this._log('reputation_error', { error: err.message });
947
+ }
948
+
853
949
  // Notify both parties
854
950
  const outMsg = createMessage(ServerMessageType.DISPUTE, {
855
- ...formatProposalResponse(proposal, 'dispute')
951
+ ...formatProposalResponse(proposal, 'dispute'),
952
+ rating_changes: ratingChanges
856
953
  });
857
954
 
858
955
  // Notify the other party
@@ -907,7 +1004,7 @@ export class AgentChatServer {
907
1004
  }
908
1005
  }
909
1006
 
910
- _handleSearchSkills(ws, msg) {
1007
+ async _handleSearchSkills(ws, msg) {
911
1008
  const agent = this.agents.get(ws);
912
1009
  if (!agent) {
913
1010
  this._send(ws, createError(ErrorCode.AUTH_REQUIRED, 'Must IDENTIFY first'));
@@ -955,8 +1052,26 @@ export class AgentChatServer {
955
1052
  }
956
1053
  }
957
1054
 
958
- // Sort by registration time (newest first)
959
- results.sort((a, b) => b.registered_at - a.registered_at);
1055
+ // Enrich results with reputation data
1056
+ const uniqueAgentIds = [...new Set(results.map(r => r.agent_id))];
1057
+ const ratingCache = new Map();
1058
+ for (const agentId of uniqueAgentIds) {
1059
+ const ratingInfo = await this.reputationStore.getRating(agentId);
1060
+ ratingCache.set(agentId, ratingInfo);
1061
+ }
1062
+
1063
+ // Add rating info to each result
1064
+ for (const result of results) {
1065
+ const ratingInfo = ratingCache.get(result.agent_id);
1066
+ result.rating = ratingInfo.rating;
1067
+ result.transactions = ratingInfo.transactions;
1068
+ }
1069
+
1070
+ // Sort by rating (highest first), then by registration time
1071
+ results.sort((a, b) => {
1072
+ if (b.rating !== a.rating) return b.rating - a.rating;
1073
+ return b.registered_at - a.registered_at;
1074
+ });
960
1075
 
961
1076
  // Limit results
962
1077
  const limit = query.limit || 50;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tjamescouch/agentchat",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Real-time IRC-like communication protocol for AI agents",
5
5
  "main": "lib/client.js",
6
6
  "files": [