@tjamescouch/agentchat 0.4.0 → 0.7.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/README.md +216 -15
- package/bin/agentchat.js +309 -19
- package/lib/daemon.js +150 -52
- package/lib/receipts.js +294 -0
- package/lib/reputation.js +464 -0
- package/lib/server.js +6 -2
- package/package.json +1 -1
package/lib/daemon.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* AgentChat Daemon
|
|
3
3
|
* Persistent connection with file-based inbox/outbox
|
|
4
|
+
* Supports multiple instances with different identities
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
import fs from 'fs';
|
|
@@ -9,24 +10,43 @@ import path from 'path';
|
|
|
9
10
|
import os from 'os';
|
|
10
11
|
import { AgentChatClient } from './client.js';
|
|
11
12
|
import { Identity, DEFAULT_IDENTITY_PATH } from './identity.js';
|
|
13
|
+
import { appendReceipt, shouldStoreReceipt, DEFAULT_RECEIPTS_PATH } from './receipts.js';
|
|
12
14
|
|
|
13
|
-
//
|
|
15
|
+
// Base directory
|
|
14
16
|
const AGENTCHAT_DIR = path.join(os.homedir(), '.agentchat');
|
|
15
|
-
const
|
|
16
|
-
const OUTBOX_PATH = path.join(AGENTCHAT_DIR, 'outbox.jsonl');
|
|
17
|
-
const LOG_PATH = path.join(AGENTCHAT_DIR, 'daemon.log');
|
|
18
|
-
const PID_PATH = path.join(AGENTCHAT_DIR, 'daemon.pid');
|
|
17
|
+
const DAEMONS_DIR = path.join(AGENTCHAT_DIR, 'daemons');
|
|
19
18
|
|
|
20
|
-
|
|
19
|
+
// Default instance name
|
|
20
|
+
const DEFAULT_INSTANCE = 'default';
|
|
21
|
+
|
|
22
|
+
const DEFAULT_CHANNELS = ['#general', '#agents'];
|
|
21
23
|
const MAX_INBOX_LINES = 1000;
|
|
22
24
|
const RECONNECT_DELAY = 5000; // 5 seconds
|
|
23
25
|
const OUTBOX_POLL_INTERVAL = 500; // 500ms
|
|
24
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Get paths for a daemon instance
|
|
29
|
+
*/
|
|
30
|
+
export function getDaemonPaths(instanceName = DEFAULT_INSTANCE) {
|
|
31
|
+
const instanceDir = path.join(DAEMONS_DIR, instanceName);
|
|
32
|
+
return {
|
|
33
|
+
dir: instanceDir,
|
|
34
|
+
inbox: path.join(instanceDir, 'inbox.jsonl'),
|
|
35
|
+
outbox: path.join(instanceDir, 'outbox.jsonl'),
|
|
36
|
+
log: path.join(instanceDir, 'daemon.log'),
|
|
37
|
+
pid: path.join(instanceDir, 'daemon.pid')
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
25
41
|
export class AgentChatDaemon {
|
|
26
42
|
constructor(options = {}) {
|
|
27
43
|
this.server = options.server;
|
|
28
44
|
this.identityPath = options.identity || DEFAULT_IDENTITY_PATH;
|
|
29
45
|
this.channels = options.channels || DEFAULT_CHANNELS;
|
|
46
|
+
this.instanceName = options.name || DEFAULT_INSTANCE;
|
|
47
|
+
|
|
48
|
+
// Get instance-specific paths
|
|
49
|
+
this.paths = getDaemonPaths(this.instanceName);
|
|
30
50
|
|
|
31
51
|
this.client = null;
|
|
32
52
|
this.running = false;
|
|
@@ -34,13 +54,10 @@ export class AgentChatDaemon {
|
|
|
34
54
|
this.outboxWatcher = null;
|
|
35
55
|
this.outboxPollInterval = null;
|
|
36
56
|
this.lastOutboxSize = 0;
|
|
37
|
-
|
|
38
|
-
// Ensure directory exists
|
|
39
|
-
this._ensureDir();
|
|
40
57
|
}
|
|
41
58
|
|
|
42
59
|
async _ensureDir() {
|
|
43
|
-
await fsp.mkdir(
|
|
60
|
+
await fsp.mkdir(this.paths.dir, { recursive: true });
|
|
44
61
|
}
|
|
45
62
|
|
|
46
63
|
_log(level, message) {
|
|
@@ -48,7 +65,11 @@ export class AgentChatDaemon {
|
|
|
48
65
|
const line = `[${timestamp}] [${level.toUpperCase()}] ${message}\n`;
|
|
49
66
|
|
|
50
67
|
// Append to log file
|
|
51
|
-
|
|
68
|
+
try {
|
|
69
|
+
fs.appendFileSync(this.paths.log, line);
|
|
70
|
+
} catch {
|
|
71
|
+
// Directory might not exist yet
|
|
72
|
+
}
|
|
52
73
|
|
|
53
74
|
// Also output to console if not background
|
|
54
75
|
if (level === 'error') {
|
|
@@ -62,7 +83,7 @@ export class AgentChatDaemon {
|
|
|
62
83
|
const line = JSON.stringify(msg) + '\n';
|
|
63
84
|
|
|
64
85
|
// Append to inbox
|
|
65
|
-
await fsp.appendFile(
|
|
86
|
+
await fsp.appendFile(this.paths.inbox, line);
|
|
66
87
|
|
|
67
88
|
// Check if we need to truncate (ring buffer)
|
|
68
89
|
await this._truncateInbox();
|
|
@@ -70,13 +91,13 @@ export class AgentChatDaemon {
|
|
|
70
91
|
|
|
71
92
|
async _truncateInbox() {
|
|
72
93
|
try {
|
|
73
|
-
const content = await fsp.readFile(
|
|
94
|
+
const content = await fsp.readFile(this.paths.inbox, 'utf-8');
|
|
74
95
|
const lines = content.trim().split('\n');
|
|
75
96
|
|
|
76
97
|
if (lines.length > MAX_INBOX_LINES) {
|
|
77
98
|
// Keep only the last MAX_INBOX_LINES
|
|
78
99
|
const newLines = lines.slice(-MAX_INBOX_LINES);
|
|
79
|
-
await fsp.writeFile(
|
|
100
|
+
await fsp.writeFile(this.paths.inbox, newLines.join('\n') + '\n');
|
|
80
101
|
this._log('info', `Truncated inbox to ${MAX_INBOX_LINES} lines`);
|
|
81
102
|
}
|
|
82
103
|
} catch (err) {
|
|
@@ -86,16 +107,34 @@ export class AgentChatDaemon {
|
|
|
86
107
|
}
|
|
87
108
|
}
|
|
88
109
|
|
|
110
|
+
async _saveReceiptIfParty(completeMsg) {
|
|
111
|
+
try {
|
|
112
|
+
// Get our agent ID
|
|
113
|
+
const ourAgentId = this.client?.agentId;
|
|
114
|
+
if (!ourAgentId) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Check if we should store this receipt
|
|
119
|
+
if (shouldStoreReceipt(completeMsg, ourAgentId)) {
|
|
120
|
+
await appendReceipt(completeMsg, DEFAULT_RECEIPTS_PATH);
|
|
121
|
+
this._log('info', `Saved receipt for proposal ${completeMsg.proposal_id}`);
|
|
122
|
+
}
|
|
123
|
+
} catch (err) {
|
|
124
|
+
this._log('error', `Failed to save receipt: ${err.message}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
89
128
|
async _processOutbox() {
|
|
90
129
|
try {
|
|
91
130
|
// Check if outbox exists
|
|
92
131
|
try {
|
|
93
|
-
await fsp.access(
|
|
132
|
+
await fsp.access(this.paths.outbox);
|
|
94
133
|
} catch {
|
|
95
134
|
return; // No outbox file
|
|
96
135
|
}
|
|
97
136
|
|
|
98
|
-
const content = await fsp.readFile(
|
|
137
|
+
const content = await fsp.readFile(this.paths.outbox, 'utf-8');
|
|
99
138
|
if (!content.trim()) return;
|
|
100
139
|
|
|
101
140
|
const lines = content.trim().split('\n');
|
|
@@ -124,7 +163,7 @@ export class AgentChatDaemon {
|
|
|
124
163
|
}
|
|
125
164
|
|
|
126
165
|
// Truncate outbox after processing
|
|
127
|
-
await fsp.writeFile(
|
|
166
|
+
await fsp.writeFile(this.paths.outbox, '');
|
|
128
167
|
|
|
129
168
|
} catch (err) {
|
|
130
169
|
if (err.code !== 'ENOENT') {
|
|
@@ -144,11 +183,11 @@ export class AgentChatDaemon {
|
|
|
144
183
|
// Also try fs.watch for immediate response (may not work on all platforms)
|
|
145
184
|
try {
|
|
146
185
|
// Ensure outbox file exists
|
|
147
|
-
if (!fs.existsSync(
|
|
148
|
-
fs.writeFileSync(
|
|
186
|
+
if (!fs.existsSync(this.paths.outbox)) {
|
|
187
|
+
fs.writeFileSync(this.paths.outbox, '');
|
|
149
188
|
}
|
|
150
189
|
|
|
151
|
-
this.outboxWatcher = fs.watch(
|
|
190
|
+
this.outboxWatcher = fs.watch(this.paths.outbox, (eventType) => {
|
|
152
191
|
if (eventType === 'change' && this.client && this.client.connected) {
|
|
153
192
|
this._processOutbox();
|
|
154
193
|
}
|
|
@@ -204,6 +243,8 @@ export class AgentChatDaemon {
|
|
|
204
243
|
|
|
205
244
|
this.client.on('complete', async (msg) => {
|
|
206
245
|
await this._appendToInbox(msg);
|
|
246
|
+
// Save receipt if we're a party to this completion
|
|
247
|
+
await this._saveReceiptIfParty(msg);
|
|
207
248
|
});
|
|
208
249
|
|
|
209
250
|
this.client.on('dispute', async (msg) => {
|
|
@@ -262,15 +303,18 @@ export class AgentChatDaemon {
|
|
|
262
303
|
async start() {
|
|
263
304
|
this.running = true;
|
|
264
305
|
|
|
306
|
+
// Ensure instance directory exists
|
|
307
|
+
await this._ensureDir();
|
|
308
|
+
|
|
265
309
|
// Write PID file
|
|
266
|
-
await fsp.writeFile(
|
|
267
|
-
this._log('info', `Daemon starting (PID: ${process.pid})`);
|
|
310
|
+
await fsp.writeFile(this.paths.pid, process.pid.toString());
|
|
311
|
+
this._log('info', `Daemon starting (PID: ${process.pid}, instance: ${this.instanceName})`);
|
|
268
312
|
|
|
269
313
|
// Initialize inbox if it doesn't exist
|
|
270
314
|
try {
|
|
271
|
-
await fsp.access(
|
|
315
|
+
await fsp.access(this.paths.inbox);
|
|
272
316
|
} catch {
|
|
273
|
-
await fsp.writeFile(
|
|
317
|
+
await fsp.writeFile(this.paths.inbox, '');
|
|
274
318
|
}
|
|
275
319
|
|
|
276
320
|
// Connect to server
|
|
@@ -287,9 +331,9 @@ export class AgentChatDaemon {
|
|
|
287
331
|
process.on('SIGTERM', () => this.stop());
|
|
288
332
|
|
|
289
333
|
this._log('info', 'Daemon started');
|
|
290
|
-
this._log('info', `Inbox: ${
|
|
291
|
-
this._log('info', `Outbox: ${
|
|
292
|
-
this._log('info', `Log: ${
|
|
334
|
+
this._log('info', `Inbox: ${this.paths.inbox}`);
|
|
335
|
+
this._log('info', `Outbox: ${this.paths.outbox}`);
|
|
336
|
+
this._log('info', `Log: ${this.paths.log}`);
|
|
293
337
|
}
|
|
294
338
|
|
|
295
339
|
async stop() {
|
|
@@ -304,7 +348,7 @@ export class AgentChatDaemon {
|
|
|
304
348
|
|
|
305
349
|
// Remove PID file
|
|
306
350
|
try {
|
|
307
|
-
await fsp.unlink(
|
|
351
|
+
await fsp.unlink(this.paths.pid);
|
|
308
352
|
} catch {
|
|
309
353
|
// Ignore if already gone
|
|
310
354
|
}
|
|
@@ -315,36 +359,40 @@ export class AgentChatDaemon {
|
|
|
315
359
|
}
|
|
316
360
|
|
|
317
361
|
/**
|
|
318
|
-
* Check if daemon is running
|
|
362
|
+
* Check if daemon instance is running
|
|
319
363
|
*/
|
|
320
|
-
export async function isDaemonRunning() {
|
|
364
|
+
export async function isDaemonRunning(instanceName = DEFAULT_INSTANCE) {
|
|
365
|
+
const paths = getDaemonPaths(instanceName);
|
|
366
|
+
|
|
321
367
|
try {
|
|
322
|
-
const pid = await fsp.readFile(
|
|
368
|
+
const pid = await fsp.readFile(paths.pid, 'utf-8');
|
|
323
369
|
const pidNum = parseInt(pid.trim());
|
|
324
370
|
|
|
325
371
|
// Check if process is running
|
|
326
372
|
try {
|
|
327
373
|
process.kill(pidNum, 0);
|
|
328
|
-
return { running: true, pid: pidNum };
|
|
374
|
+
return { running: true, pid: pidNum, instance: instanceName };
|
|
329
375
|
} catch {
|
|
330
376
|
// Process not running, clean up stale PID file
|
|
331
|
-
await fsp.unlink(
|
|
332
|
-
return { running: false };
|
|
377
|
+
await fsp.unlink(paths.pid);
|
|
378
|
+
return { running: false, instance: instanceName };
|
|
333
379
|
}
|
|
334
380
|
} catch {
|
|
335
|
-
return { running: false };
|
|
381
|
+
return { running: false, instance: instanceName };
|
|
336
382
|
}
|
|
337
383
|
}
|
|
338
384
|
|
|
339
385
|
/**
|
|
340
|
-
* Stop
|
|
386
|
+
* Stop a daemon instance
|
|
341
387
|
*/
|
|
342
|
-
export async function stopDaemon() {
|
|
343
|
-
const status = await isDaemonRunning();
|
|
388
|
+
export async function stopDaemon(instanceName = DEFAULT_INSTANCE) {
|
|
389
|
+
const status = await isDaemonRunning(instanceName);
|
|
344
390
|
if (!status.running) {
|
|
345
|
-
return { stopped: false, reason: 'Daemon not running' };
|
|
391
|
+
return { stopped: false, reason: 'Daemon not running', instance: instanceName };
|
|
346
392
|
}
|
|
347
393
|
|
|
394
|
+
const paths = getDaemonPaths(instanceName);
|
|
395
|
+
|
|
348
396
|
try {
|
|
349
397
|
process.kill(status.pid, 'SIGTERM');
|
|
350
398
|
|
|
@@ -362,26 +410,28 @@ export async function stopDaemon() {
|
|
|
362
410
|
|
|
363
411
|
// Clean up PID file
|
|
364
412
|
try {
|
|
365
|
-
await fsp.unlink(
|
|
413
|
+
await fsp.unlink(paths.pid);
|
|
366
414
|
} catch {
|
|
367
415
|
// Ignore
|
|
368
416
|
}
|
|
369
417
|
|
|
370
|
-
return { stopped: true, pid: status.pid };
|
|
418
|
+
return { stopped: true, pid: status.pid, instance: instanceName };
|
|
371
419
|
} catch (err) {
|
|
372
|
-
return { stopped: false, reason: err.message };
|
|
420
|
+
return { stopped: false, reason: err.message, instance: instanceName };
|
|
373
421
|
}
|
|
374
422
|
}
|
|
375
423
|
|
|
376
424
|
/**
|
|
377
|
-
* Get daemon status
|
|
425
|
+
* Get daemon instance status
|
|
378
426
|
*/
|
|
379
|
-
export async function getDaemonStatus() {
|
|
380
|
-
const status = await isDaemonRunning();
|
|
427
|
+
export async function getDaemonStatus(instanceName = DEFAULT_INSTANCE) {
|
|
428
|
+
const status = await isDaemonRunning(instanceName);
|
|
429
|
+
const paths = getDaemonPaths(instanceName);
|
|
381
430
|
|
|
382
431
|
if (!status.running) {
|
|
383
432
|
return {
|
|
384
|
-
running: false
|
|
433
|
+
running: false,
|
|
434
|
+
instance: instanceName
|
|
385
435
|
};
|
|
386
436
|
}
|
|
387
437
|
|
|
@@ -390,7 +440,7 @@ export async function getDaemonStatus() {
|
|
|
390
440
|
let lastMessage = null;
|
|
391
441
|
|
|
392
442
|
try {
|
|
393
|
-
const content = await fsp.readFile(
|
|
443
|
+
const content = await fsp.readFile(paths.inbox, 'utf-8');
|
|
394
444
|
const lines = content.trim().split('\n').filter(l => l);
|
|
395
445
|
inboxLines = lines.length;
|
|
396
446
|
|
|
@@ -407,14 +457,62 @@ export async function getDaemonStatus() {
|
|
|
407
457
|
|
|
408
458
|
return {
|
|
409
459
|
running: true,
|
|
460
|
+
instance: instanceName,
|
|
410
461
|
pid: status.pid,
|
|
411
|
-
inboxPath:
|
|
412
|
-
outboxPath:
|
|
413
|
-
logPath:
|
|
462
|
+
inboxPath: paths.inbox,
|
|
463
|
+
outboxPath: paths.outbox,
|
|
464
|
+
logPath: paths.log,
|
|
414
465
|
inboxLines,
|
|
415
466
|
lastMessage
|
|
416
467
|
};
|
|
417
468
|
}
|
|
418
469
|
|
|
419
|
-
|
|
420
|
-
|
|
470
|
+
/**
|
|
471
|
+
* List all daemon instances
|
|
472
|
+
*/
|
|
473
|
+
export async function listDaemons() {
|
|
474
|
+
const instances = [];
|
|
475
|
+
|
|
476
|
+
try {
|
|
477
|
+
const entries = await fsp.readdir(DAEMONS_DIR, { withFileTypes: true });
|
|
478
|
+
|
|
479
|
+
for (const entry of entries) {
|
|
480
|
+
if (entry.isDirectory()) {
|
|
481
|
+
const status = await isDaemonRunning(entry.name);
|
|
482
|
+
instances.push({
|
|
483
|
+
name: entry.name,
|
|
484
|
+
running: status.running,
|
|
485
|
+
pid: status.pid || null
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
} catch {
|
|
490
|
+
// No daemons directory
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return instances;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Stop all running daemons
|
|
498
|
+
*/
|
|
499
|
+
export async function stopAllDaemons() {
|
|
500
|
+
const instances = await listDaemons();
|
|
501
|
+
const results = [];
|
|
502
|
+
|
|
503
|
+
for (const instance of instances) {
|
|
504
|
+
if (instance.running) {
|
|
505
|
+
const result = await stopDaemon(instance.name);
|
|
506
|
+
results.push(result);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return results;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Export for CLI (backwards compatibility with default paths)
|
|
514
|
+
export const INBOX_PATH = getDaemonPaths(DEFAULT_INSTANCE).inbox;
|
|
515
|
+
export const OUTBOX_PATH = getDaemonPaths(DEFAULT_INSTANCE).outbox;
|
|
516
|
+
export const LOG_PATH = getDaemonPaths(DEFAULT_INSTANCE).log;
|
|
517
|
+
export const PID_PATH = getDaemonPaths(DEFAULT_INSTANCE).pid;
|
|
518
|
+
export { DEFAULT_CHANNELS, DEFAULT_INSTANCE };
|
package/lib/receipts.js
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentChat Receipts Module
|
|
3
|
+
* Stores and manages COMPLETE receipts for portable reputation
|
|
4
|
+
*
|
|
5
|
+
* Receipts are proof of completed work between agents.
|
|
6
|
+
* They can be exported for reputation aggregation.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import fsp from 'fs/promises';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
import os from 'os';
|
|
13
|
+
import { getDefaultStore } from './reputation.js';
|
|
14
|
+
|
|
15
|
+
// Default receipts file location
|
|
16
|
+
const AGENTCHAT_DIR = path.join(os.homedir(), '.agentchat');
|
|
17
|
+
export const DEFAULT_RECEIPTS_PATH = path.join(AGENTCHAT_DIR, 'receipts.jsonl');
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Append a receipt to the receipts file
|
|
21
|
+
* @param {object} receipt - The COMPLETE message/receipt to store
|
|
22
|
+
* @param {string} receiptsPath - Path to receipts file
|
|
23
|
+
* @param {object} options - Options
|
|
24
|
+
* @param {boolean} options.updateRatings - Whether to update ELO ratings (default: true)
|
|
25
|
+
*/
|
|
26
|
+
export async function appendReceipt(receipt, receiptsPath = DEFAULT_RECEIPTS_PATH, options = {}) {
|
|
27
|
+
const { updateRatings = true } = options;
|
|
28
|
+
|
|
29
|
+
// Ensure directory exists
|
|
30
|
+
await fsp.mkdir(path.dirname(receiptsPath), { recursive: true });
|
|
31
|
+
|
|
32
|
+
// Add storage timestamp
|
|
33
|
+
const storedReceipt = {
|
|
34
|
+
...receipt,
|
|
35
|
+
stored_at: Date.now()
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const line = JSON.stringify(storedReceipt) + '\n';
|
|
39
|
+
await fsp.appendFile(receiptsPath, line);
|
|
40
|
+
|
|
41
|
+
// Update ELO ratings if enabled
|
|
42
|
+
if (updateRatings) {
|
|
43
|
+
try {
|
|
44
|
+
const store = getDefaultStore();
|
|
45
|
+
const ratingChanges = await store.updateRatings(storedReceipt);
|
|
46
|
+
if (ratingChanges) {
|
|
47
|
+
storedReceipt._ratingChanges = ratingChanges;
|
|
48
|
+
}
|
|
49
|
+
} catch (err) {
|
|
50
|
+
// Log but don't fail receipt storage if rating update fails
|
|
51
|
+
console.error(`Warning: Failed to update ratings: ${err.message}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return storedReceipt;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Read all receipts from the receipts file
|
|
60
|
+
* @param {string} receiptsPath - Path to receipts file
|
|
61
|
+
* @returns {Array} Array of receipt objects
|
|
62
|
+
*/
|
|
63
|
+
export async function readReceipts(receiptsPath = DEFAULT_RECEIPTS_PATH) {
|
|
64
|
+
try {
|
|
65
|
+
const content = await fsp.readFile(receiptsPath, 'utf-8');
|
|
66
|
+
const lines = content.trim().split('\n').filter(l => l.trim());
|
|
67
|
+
|
|
68
|
+
return lines.map(line => {
|
|
69
|
+
try {
|
|
70
|
+
return JSON.parse(line);
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}).filter(Boolean);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
if (err.code === 'ENOENT') {
|
|
77
|
+
return []; // No receipts file yet
|
|
78
|
+
}
|
|
79
|
+
throw err;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Filter receipts by agent ID (where agent is a party)
|
|
85
|
+
* @param {Array} receipts - Array of receipts
|
|
86
|
+
* @param {string} agentId - Agent ID to filter by
|
|
87
|
+
* @returns {Array} Filtered receipts
|
|
88
|
+
*/
|
|
89
|
+
export function filterByAgent(receipts, agentId) {
|
|
90
|
+
const normalizedId = agentId.startsWith('@') ? agentId : `@${agentId}`;
|
|
91
|
+
return receipts.filter(r =>
|
|
92
|
+
r.from === normalizedId ||
|
|
93
|
+
r.to === normalizedId ||
|
|
94
|
+
r.completed_by === normalizedId ||
|
|
95
|
+
// Also check proposal parties if available
|
|
96
|
+
r.proposal?.from === normalizedId ||
|
|
97
|
+
r.proposal?.to === normalizedId
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Get unique counterparties from receipts
|
|
103
|
+
* @param {Array} receipts - Array of receipts
|
|
104
|
+
* @param {string} agentId - Our agent ID
|
|
105
|
+
* @returns {Array} Array of unique counterparty IDs
|
|
106
|
+
*/
|
|
107
|
+
export function getCounterparties(receipts, agentId) {
|
|
108
|
+
const normalizedId = agentId.startsWith('@') ? agentId : `@${agentId}`;
|
|
109
|
+
const counterparties = new Set();
|
|
110
|
+
|
|
111
|
+
for (const r of receipts) {
|
|
112
|
+
// Check from/to fields
|
|
113
|
+
if (r.from && r.from !== normalizedId) counterparties.add(r.from);
|
|
114
|
+
if (r.to && r.to !== normalizedId) counterparties.add(r.to);
|
|
115
|
+
|
|
116
|
+
// Check proposal parties
|
|
117
|
+
if (r.proposal?.from && r.proposal.from !== normalizedId) {
|
|
118
|
+
counterparties.add(r.proposal.from);
|
|
119
|
+
}
|
|
120
|
+
if (r.proposal?.to && r.proposal.to !== normalizedId) {
|
|
121
|
+
counterparties.add(r.proposal.to);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return Array.from(counterparties);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Get receipt statistics
|
|
130
|
+
* @param {Array} receipts - Array of receipts
|
|
131
|
+
* @param {string} agentId - Optional agent ID for filtering
|
|
132
|
+
* @returns {object} Statistics object
|
|
133
|
+
*/
|
|
134
|
+
export function getStats(receipts, agentId = null) {
|
|
135
|
+
let filtered = receipts;
|
|
136
|
+
if (agentId) {
|
|
137
|
+
filtered = filterByAgent(receipts, agentId);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (filtered.length === 0) {
|
|
141
|
+
return {
|
|
142
|
+
count: 0,
|
|
143
|
+
counterparties: [],
|
|
144
|
+
dateRange: null,
|
|
145
|
+
currencies: {}
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Get date range
|
|
150
|
+
const timestamps = filtered
|
|
151
|
+
.map(r => r.completed_at || r.ts || r.stored_at)
|
|
152
|
+
.filter(Boolean)
|
|
153
|
+
.sort((a, b) => a - b);
|
|
154
|
+
|
|
155
|
+
// Count currencies/amounts
|
|
156
|
+
const currencies = {};
|
|
157
|
+
for (const r of filtered) {
|
|
158
|
+
const currency = r.proposal?.currency || r.currency || 'unknown';
|
|
159
|
+
const amount = r.proposal?.amount || r.amount || 0;
|
|
160
|
+
|
|
161
|
+
if (!currencies[currency]) {
|
|
162
|
+
currencies[currency] = { count: 0, totalAmount: 0 };
|
|
163
|
+
}
|
|
164
|
+
currencies[currency].count++;
|
|
165
|
+
currencies[currency].totalAmount += amount;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
count: filtered.length,
|
|
170
|
+
counterparties: agentId ? getCounterparties(filtered, agentId) : [],
|
|
171
|
+
dateRange: timestamps.length > 0 ? {
|
|
172
|
+
oldest: new Date(timestamps[0]).toISOString(),
|
|
173
|
+
newest: new Date(timestamps[timestamps.length - 1]).toISOString()
|
|
174
|
+
} : null,
|
|
175
|
+
currencies
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Export receipts in specified format
|
|
181
|
+
* @param {Array} receipts - Array of receipts
|
|
182
|
+
* @param {string} format - 'json' or 'yaml'
|
|
183
|
+
* @returns {string} Formatted output
|
|
184
|
+
*/
|
|
185
|
+
export function exportReceipts(receipts, format = 'json') {
|
|
186
|
+
if (format === 'yaml') {
|
|
187
|
+
// Simple YAML-like output
|
|
188
|
+
let output = 'receipts:\n';
|
|
189
|
+
for (const r of receipts) {
|
|
190
|
+
output += ` - proposal_id: ${r.proposal_id || 'unknown'}\n`;
|
|
191
|
+
output += ` completed_at: ${r.completed_at ? new Date(r.completed_at).toISOString() : 'unknown'}\n`;
|
|
192
|
+
output += ` completed_by: ${r.completed_by || 'unknown'}\n`;
|
|
193
|
+
if (r.proof) output += ` proof: ${r.proof}\n`;
|
|
194
|
+
if (r.proposal) {
|
|
195
|
+
output += ` proposal:\n`;
|
|
196
|
+
output += ` from: ${r.proposal.from}\n`;
|
|
197
|
+
output += ` to: ${r.proposal.to}\n`;
|
|
198
|
+
output += ` task: ${r.proposal.task}\n`;
|
|
199
|
+
if (r.proposal.amount) output += ` amount: ${r.proposal.amount}\n`;
|
|
200
|
+
if (r.proposal.currency) output += ` currency: ${r.proposal.currency}\n`;
|
|
201
|
+
}
|
|
202
|
+
output += '\n';
|
|
203
|
+
}
|
|
204
|
+
return output;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Default: JSON
|
|
208
|
+
return JSON.stringify(receipts, null, 2);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Check if a receipt should be stored (we are a party to it)
|
|
213
|
+
* @param {object} completeMsg - The COMPLETE message
|
|
214
|
+
* @param {string} ourAgentId - Our agent ID
|
|
215
|
+
* @returns {boolean}
|
|
216
|
+
*/
|
|
217
|
+
export function shouldStoreReceipt(completeMsg, ourAgentId) {
|
|
218
|
+
const normalizedId = ourAgentId.startsWith('@') ? ourAgentId : `@${ourAgentId}`;
|
|
219
|
+
|
|
220
|
+
// Check if we're a party to this completion
|
|
221
|
+
return (
|
|
222
|
+
completeMsg.from === normalizedId ||
|
|
223
|
+
completeMsg.to === normalizedId ||
|
|
224
|
+
completeMsg.completed_by === normalizedId ||
|
|
225
|
+
completeMsg.proposal?.from === normalizedId ||
|
|
226
|
+
completeMsg.proposal?.to === normalizedId
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* ReceiptStore class for managing receipts
|
|
232
|
+
*/
|
|
233
|
+
export class ReceiptStore {
|
|
234
|
+
constructor(receiptsPath = DEFAULT_RECEIPTS_PATH) {
|
|
235
|
+
this.receiptsPath = receiptsPath;
|
|
236
|
+
this._receipts = null; // Lazy load
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Load receipts from file
|
|
241
|
+
*/
|
|
242
|
+
async load() {
|
|
243
|
+
this._receipts = await readReceipts(this.receiptsPath);
|
|
244
|
+
return this._receipts;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Get all receipts (loads if needed)
|
|
249
|
+
*/
|
|
250
|
+
async getAll() {
|
|
251
|
+
if (this._receipts === null) {
|
|
252
|
+
await this.load();
|
|
253
|
+
}
|
|
254
|
+
return this._receipts;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Add a receipt
|
|
259
|
+
*/
|
|
260
|
+
async add(receipt) {
|
|
261
|
+
const stored = await appendReceipt(receipt, this.receiptsPath);
|
|
262
|
+
if (this._receipts !== null) {
|
|
263
|
+
this._receipts.push(stored);
|
|
264
|
+
}
|
|
265
|
+
return stored;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Get receipts for an agent
|
|
270
|
+
*/
|
|
271
|
+
async getForAgent(agentId) {
|
|
272
|
+
const all = await this.getAll();
|
|
273
|
+
return filterByAgent(all, agentId);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Get statistics
|
|
278
|
+
*/
|
|
279
|
+
async getStats(agentId = null) {
|
|
280
|
+
const all = await this.getAll();
|
|
281
|
+
return getStats(all, agentId);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Export receipts
|
|
286
|
+
*/
|
|
287
|
+
async export(format = 'json', agentId = null) {
|
|
288
|
+
let receipts = await this.getAll();
|
|
289
|
+
if (agentId) {
|
|
290
|
+
receipts = filterByAgent(receipts, agentId);
|
|
291
|
+
}
|
|
292
|
+
return exportReceipts(receipts, format);
|
|
293
|
+
}
|
|
294
|
+
}
|