@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/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
- // Default paths
15
+ // Base directory
14
16
  const AGENTCHAT_DIR = path.join(os.homedir(), '.agentchat');
15
- const INBOX_PATH = path.join(AGENTCHAT_DIR, 'inbox.jsonl');
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
- const DEFAULT_CHANNELS = ['#general', '#agents', '#skills'];
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(AGENTCHAT_DIR, { recursive: true });
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
- fs.appendFileSync(LOG_PATH, line);
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(INBOX_PATH, line);
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(INBOX_PATH, 'utf-8');
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(INBOX_PATH, newLines.join('\n') + '\n');
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(OUTBOX_PATH);
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(OUTBOX_PATH, 'utf-8');
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(OUTBOX_PATH, '');
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(OUTBOX_PATH)) {
148
- fs.writeFileSync(OUTBOX_PATH, '');
186
+ if (!fs.existsSync(this.paths.outbox)) {
187
+ fs.writeFileSync(this.paths.outbox, '');
149
188
  }
150
189
 
151
- this.outboxWatcher = fs.watch(OUTBOX_PATH, (eventType) => {
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(PID_PATH, process.pid.toString());
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(INBOX_PATH);
315
+ await fsp.access(this.paths.inbox);
272
316
  } catch {
273
- await fsp.writeFile(INBOX_PATH, '');
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: ${INBOX_PATH}`);
291
- this._log('info', `Outbox: ${OUTBOX_PATH}`);
292
- this._log('info', `Log: ${LOG_PATH}`);
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(PID_PATH);
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(PID_PATH, 'utf-8');
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(PID_PATH);
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 the daemon
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(PID_PATH);
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(INBOX_PATH, 'utf-8');
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: INBOX_PATH,
412
- outboxPath: OUTBOX_PATH,
413
- logPath: LOG_PATH,
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
- // Export paths for CLI
420
- export { INBOX_PATH, OUTBOX_PATH, LOG_PATH, PID_PATH, DEFAULT_CHANNELS };
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 };
@@ -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
+ }