a2acalling 0.1.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,470 @@
1
+ /**
2
+ * Conversation storage and summarization for A2A federation
3
+ *
4
+ * Uses SQLite for local storage with auto-summarization on call conclusion.
5
+ */
6
+
7
+ const path = require('path');
8
+ const fs = require('fs');
9
+ const crypto = require('crypto');
10
+
11
+ // Default config path
12
+ const DEFAULT_CONFIG_DIR = process.env.A2A_CONFIG_DIR ||
13
+ process.env.OPENCLAW_CONFIG_DIR ||
14
+ path.join(process.env.HOME || '/tmp', '.config', 'openclaw');
15
+
16
+ const DB_FILENAME = 'a2a-conversations.db';
17
+
18
+ class ConversationStore {
19
+ constructor(configDir = DEFAULT_CONFIG_DIR) {
20
+ this.configDir = configDir;
21
+ this.dbPath = path.join(configDir, DB_FILENAME);
22
+ this.db = null;
23
+ this._ensureDir();
24
+ }
25
+
26
+ _ensureDir() {
27
+ if (!fs.existsSync(this.configDir)) {
28
+ fs.mkdirSync(this.configDir, { recursive: true });
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Initialize SQLite database (lazy load better-sqlite3)
34
+ */
35
+ _initDb() {
36
+ if (this.db) return this.db;
37
+ if (this._dbError) return null;
38
+
39
+ try {
40
+ const Database = require('better-sqlite3');
41
+ this.db = new Database(this.dbPath);
42
+ this._migrate();
43
+ return this.db;
44
+ } catch (err) {
45
+ if (err.code === 'MODULE_NOT_FOUND') {
46
+ this._dbError = 'better-sqlite3 not installed. Run: npm install better-sqlite3';
47
+ } else {
48
+ this._dbError = err.message;
49
+ }
50
+ return null;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Check if storage is available
56
+ */
57
+ isAvailable() {
58
+ return this._initDb() !== null;
59
+ }
60
+
61
+ /**
62
+ * Get error message if storage unavailable
63
+ */
64
+ getError() {
65
+ this._initDb();
66
+ return this._dbError || null;
67
+ }
68
+
69
+ /**
70
+ * Run database migrations
71
+ */
72
+ _migrate() {
73
+ this.db.exec(`
74
+ -- Conversations with remote agents
75
+ CREATE TABLE IF NOT EXISTS conversations (
76
+ id TEXT PRIMARY KEY,
77
+ contact_id TEXT,
78
+ contact_name TEXT,
79
+ token_id TEXT,
80
+ direction TEXT NOT NULL, -- 'inbound' or 'outbound'
81
+ started_at TEXT NOT NULL,
82
+ ended_at TEXT,
83
+ last_message_at TEXT,
84
+ message_count INTEGER DEFAULT 0,
85
+ status TEXT DEFAULT 'active', -- 'active', 'concluded', 'timeout'
86
+
87
+ -- Raw summary (neutral, could be shared)
88
+ summary TEXT,
89
+ summary_at TEXT,
90
+
91
+ -- Owner-context summary (private, never shared)
92
+ owner_summary TEXT,
93
+ owner_relevance TEXT,
94
+ owner_goals_touched TEXT, -- JSON array
95
+ owner_action_items TEXT, -- JSON array (owner's action items)
96
+ caller_action_items TEXT, -- JSON array (what caller should do)
97
+ joint_action_items TEXT, -- JSON array (things to do together)
98
+ collaboration_opportunity TEXT, -- JSON object
99
+ owner_follow_up TEXT,
100
+ owner_notes TEXT
101
+ );
102
+
103
+ -- Individual messages
104
+ CREATE TABLE IF NOT EXISTS messages (
105
+ id TEXT PRIMARY KEY,
106
+ conversation_id TEXT NOT NULL,
107
+ direction TEXT NOT NULL, -- 'inbound' or 'outbound'
108
+ role TEXT NOT NULL, -- 'user' or 'assistant'
109
+ content TEXT NOT NULL,
110
+ timestamp TEXT NOT NULL,
111
+ compressed INTEGER DEFAULT 0,
112
+ metadata TEXT, -- JSON for extra data
113
+ FOREIGN KEY (conversation_id) REFERENCES conversations(id)
114
+ );
115
+
116
+ -- Indexes
117
+ CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id);
118
+ CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp);
119
+ CREATE INDEX IF NOT EXISTS idx_conversations_contact ON conversations(contact_id);
120
+ CREATE INDEX IF NOT EXISTS idx_conversations_status ON conversations(status);
121
+ `);
122
+ }
123
+
124
+ /**
125
+ * Generate a conversation ID (shared between both agents)
126
+ */
127
+ static generateConversationId() {
128
+ return 'conv_' + crypto.randomBytes(12).toString('base64url');
129
+ }
130
+
131
+ /**
132
+ * Start or resume a conversation
133
+ */
134
+ startConversation(options = {}) {
135
+ const db = this._initDb();
136
+ if (!db) return { success: false, error: this._dbError };
137
+ const {
138
+ id = ConversationStore.generateConversationId(),
139
+ contactId = null,
140
+ contactName = null,
141
+ tokenId = null,
142
+ direction = 'inbound'
143
+ } = options;
144
+
145
+ const existing = db.prepare('SELECT * FROM conversations WHERE id = ?').get(id);
146
+ if (existing) {
147
+ // Resume existing conversation
148
+ db.prepare(`
149
+ UPDATE conversations
150
+ SET status = 'active', last_message_at = ?
151
+ WHERE id = ?
152
+ `).run(new Date().toISOString(), id);
153
+ return { id, resumed: true, conversation: existing };
154
+ }
155
+
156
+ // Create new conversation
157
+ const now = new Date().toISOString();
158
+ db.prepare(`
159
+ INSERT INTO conversations (id, contact_id, contact_name, token_id, direction, started_at, last_message_at, status)
160
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'active')
161
+ `).run(id, contactId, contactName, tokenId, direction, now, now);
162
+
163
+ return { id, resumed: false };
164
+ }
165
+
166
+ /**
167
+ * Add a message to a conversation
168
+ */
169
+ addMessage(conversationId, message) {
170
+ const db = this._initDb();
171
+ if (!db) return { success: false, error: this._dbError };
172
+ const {
173
+ direction,
174
+ role,
175
+ content,
176
+ metadata = null
177
+ } = message;
178
+
179
+ const id = 'msg_' + crypto.randomBytes(8).toString('hex');
180
+ const now = new Date().toISOString();
181
+
182
+ db.prepare(`
183
+ INSERT INTO messages (id, conversation_id, direction, role, content, timestamp, metadata)
184
+ VALUES (?, ?, ?, ?, ?, ?, ?)
185
+ `).run(id, conversationId, direction, role, content, now, metadata ? JSON.stringify(metadata) : null);
186
+
187
+ // Update conversation
188
+ db.prepare(`
189
+ UPDATE conversations
190
+ SET last_message_at = ?, message_count = message_count + 1
191
+ WHERE id = ?
192
+ `).run(now, conversationId);
193
+
194
+ return { id, timestamp: now };
195
+ }
196
+
197
+ /**
198
+ * Get conversation with messages
199
+ */
200
+ getConversation(conversationId, options = {}) {
201
+ const db = this._initDb();
202
+ if (!db) return null;
203
+ const { includeMessages = true, messageLimit = 50 } = options;
204
+
205
+ const conversation = db.prepare('SELECT * FROM conversations WHERE id = ?').get(conversationId);
206
+ if (!conversation) return null;
207
+
208
+ if (includeMessages) {
209
+ conversation.messages = db.prepare(`
210
+ SELECT * FROM messages
211
+ WHERE conversation_id = ?
212
+ ORDER BY timestamp DESC
213
+ LIMIT ?
214
+ `).all(conversationId, messageLimit).reverse();
215
+ }
216
+
217
+ // Parse JSON fields
218
+ if (conversation.owner_goals_touched) {
219
+ conversation.owner_goals_touched = JSON.parse(conversation.owner_goals_touched);
220
+ }
221
+ if (conversation.owner_action_items) {
222
+ conversation.owner_action_items = JSON.parse(conversation.owner_action_items);
223
+ }
224
+
225
+ return conversation;
226
+ }
227
+
228
+ /**
229
+ * List conversations with optional filters
230
+ */
231
+ listConversations(options = {}) {
232
+ const db = this._initDb();
233
+ if (!db) return [];
234
+ const {
235
+ contactId = null,
236
+ status = null,
237
+ limit = 20,
238
+ includeMessages = false,
239
+ messageLimit = 5
240
+ } = options;
241
+
242
+ let query = 'SELECT * FROM conversations WHERE 1=1';
243
+ const params = [];
244
+
245
+ if (contactId) {
246
+ query += ' AND contact_id = ?';
247
+ params.push(contactId);
248
+ }
249
+ if (status) {
250
+ query += ' AND status = ?';
251
+ params.push(status);
252
+ }
253
+
254
+ query += ' ORDER BY last_message_at DESC LIMIT ?';
255
+ params.push(limit);
256
+
257
+ const conversations = db.prepare(query).all(...params);
258
+
259
+ if (includeMessages) {
260
+ for (const conv of conversations) {
261
+ conv.messages = db.prepare(`
262
+ SELECT * FROM messages
263
+ WHERE conversation_id = ?
264
+ ORDER BY timestamp DESC
265
+ LIMIT ?
266
+ `).all(conv.id, messageLimit).reverse();
267
+ }
268
+ }
269
+
270
+ return conversations;
271
+ }
272
+
273
+ /**
274
+ * Conclude a conversation and generate owner-context summary
275
+ *
276
+ * @param {string} conversationId
277
+ * @param {object} options
278
+ * @param {function} options.summarizer - async function(messages, ownerContext) => summary
279
+ * @param {object} options.ownerContext - owner's goals, preferences, etc.
280
+ */
281
+ async concludeConversation(conversationId, options = {}) {
282
+ const db = this._initDb();
283
+ if (!db) return { success: false, error: this._dbError };
284
+ const { summarizer = null, ownerContext = {} } = options;
285
+
286
+ const conversation = this.getConversation(conversationId, { includeMessages: true });
287
+ if (!conversation) {
288
+ return { success: false, error: 'conversation_not_found' };
289
+ }
290
+
291
+ const now = new Date().toISOString();
292
+ let summary = null;
293
+ let ownerSummary = null;
294
+
295
+ // Generate summaries if summarizer provided
296
+ if (summarizer && conversation.messages.length > 0) {
297
+ try {
298
+ const result = await summarizer(conversation.messages, ownerContext);
299
+ summary = result.summary || null;
300
+ ownerSummary = result.ownerSummary || null;
301
+
302
+ // Store owner-context fields (collaboration-focused)
303
+ db.prepare(`
304
+ UPDATE conversations SET
305
+ ended_at = ?,
306
+ status = 'concluded',
307
+ summary = ?,
308
+ summary_at = ?,
309
+ owner_summary = ?,
310
+ owner_relevance = ?,
311
+ owner_goals_touched = ?,
312
+ owner_action_items = ?,
313
+ caller_action_items = ?,
314
+ joint_action_items = ?,
315
+ collaboration_opportunity = ?,
316
+ owner_follow_up = ?,
317
+ owner_notes = ?
318
+ WHERE id = ?
319
+ `).run(
320
+ now,
321
+ summary,
322
+ now,
323
+ result.ownerSummary || null,
324
+ result.relevance || null,
325
+ result.goalsTouched ? JSON.stringify(result.goalsTouched) : null,
326
+ result.ownerActionItems ? JSON.stringify(result.ownerActionItems) : null,
327
+ result.callerActionItems ? JSON.stringify(result.callerActionItems) : null,
328
+ result.jointActionItems ? JSON.stringify(result.jointActionItems) : null,
329
+ result.collaborationOpportunity ? JSON.stringify(result.collaborationOpportunity) : null,
330
+ result.followUp || null,
331
+ result.notes || null,
332
+ conversationId
333
+ );
334
+ } catch (err) {
335
+ console.error('[a2a] Summary generation failed:', err.message);
336
+ // Still conclude, just without summary
337
+ db.prepare(`
338
+ UPDATE conversations SET ended_at = ?, status = 'concluded'
339
+ WHERE id = ?
340
+ `).run(now, conversationId);
341
+ }
342
+ } else {
343
+ // No summarizer, just mark concluded
344
+ db.prepare(`
345
+ UPDATE conversations SET ended_at = ?, status = 'concluded'
346
+ WHERE id = ?
347
+ `).run(now, conversationId);
348
+ }
349
+
350
+ return {
351
+ success: true,
352
+ conversationId,
353
+ summary,
354
+ ownerSummary,
355
+ endedAt: now
356
+ };
357
+ }
358
+
359
+ /**
360
+ * Mark conversation as timed out
361
+ */
362
+ timeoutConversation(conversationId) {
363
+ const db = this._initDb();
364
+ if (!db) return { success: false, error: this._dbError };
365
+ const now = new Date().toISOString();
366
+
367
+ db.prepare(`
368
+ UPDATE conversations SET ended_at = ?, status = 'timeout'
369
+ WHERE id = ?
370
+ `).run(now, conversationId);
371
+
372
+ return { success: true };
373
+ }
374
+
375
+ /**
376
+ * Get active conversations (for timeout checking)
377
+ */
378
+ getActiveConversations(idleThresholdMs = 60000) {
379
+ const db = this._initDb();
380
+ if (!db) return [];
381
+ const threshold = new Date(Date.now() - idleThresholdMs).toISOString();
382
+
383
+ return db.prepare(`
384
+ SELECT * FROM conversations
385
+ WHERE status = 'active' AND last_message_at < ?
386
+ `).all(threshold);
387
+ }
388
+
389
+ /**
390
+ * Compress old messages to save space
391
+ */
392
+ compressOldMessages(olderThanDays = 7) {
393
+ const db = this._initDb();
394
+ if (!db) return { compressed: 0, total: 0, error: this._dbError };
395
+ const zlib = require('zlib');
396
+ const threshold = new Date(Date.now() - olderThanDays * 24 * 60 * 60 * 1000).toISOString();
397
+
398
+ const messages = db.prepare(`
399
+ SELECT id, content FROM messages
400
+ WHERE timestamp < ? AND compressed = 0
401
+ `).all(threshold);
402
+
403
+ let compressed = 0;
404
+ const update = db.prepare('UPDATE messages SET content = ?, compressed = 1 WHERE id = ?');
405
+
406
+ for (const msg of messages) {
407
+ try {
408
+ const compressedContent = zlib.gzipSync(msg.content).toString('base64');
409
+ update.run(compressedContent, msg.id);
410
+ compressed++;
411
+ } catch (err) {
412
+ // Skip if compression fails
413
+ }
414
+ }
415
+
416
+ return { compressed, total: messages.length };
417
+ }
418
+
419
+ /**
420
+ * Get conversation context for retrieval (summary + recent messages)
421
+ */
422
+ getConversationContext(conversationId, recentMessageCount = 5) {
423
+ const conversation = this.getConversation(conversationId, {
424
+ includeMessages: true,
425
+ messageLimit: recentMessageCount
426
+ });
427
+
428
+ if (!conversation) return null;
429
+
430
+ // Parse JSON fields
431
+ const parseJson = (str) => {
432
+ if (!str) return null;
433
+ try { return JSON.parse(str); } catch (e) { return null; }
434
+ };
435
+
436
+ return {
437
+ id: conversation.id,
438
+ contact: conversation.contact_name,
439
+ summary: conversation.summary,
440
+ ownerContext: conversation.owner_summary ? {
441
+ summary: conversation.owner_summary,
442
+ relevance: conversation.owner_relevance,
443
+ goalsTouched: parseJson(conversation.owner_goals_touched),
444
+ ownerActionItems: parseJson(conversation.owner_action_items),
445
+ callerActionItems: parseJson(conversation.caller_action_items),
446
+ jointActionItems: parseJson(conversation.joint_action_items),
447
+ collaborationOpportunity: parseJson(conversation.collaboration_opportunity),
448
+ followUp: conversation.owner_follow_up,
449
+ notes: conversation.owner_notes
450
+ } : null,
451
+ recentMessages: conversation.messages,
452
+ messageCount: conversation.message_count,
453
+ startedAt: conversation.started_at,
454
+ endedAt: conversation.ended_at,
455
+ status: conversation.status
456
+ };
457
+ }
458
+
459
+ /**
460
+ * Close database connection
461
+ */
462
+ close() {
463
+ if (this.db) {
464
+ this.db.close();
465
+ this.db = null;
466
+ }
467
+ }
468
+ }
469
+
470
+ module.exports = { ConversationStore };