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.
- package/AGENTS.md +66 -0
- package/CLAUDE.md +52 -0
- package/README.md +307 -0
- package/SKILL.md +122 -0
- package/bin/cli.js +908 -0
- package/docs/protocol.md +241 -0
- package/package.json +44 -0
- package/scripts/install-openclaw.js +291 -0
- package/src/index.js +61 -0
- package/src/lib/call-monitor.js +143 -0
- package/src/lib/client.js +208 -0
- package/src/lib/config.js +173 -0
- package/src/lib/conversations.js +470 -0
- package/src/lib/openclaw-integration.js +329 -0
- package/src/lib/summarizer.js +137 -0
- package/src/lib/tokens.js +448 -0
- package/src/routes/federation.js +463 -0
- package/src/server.js +56 -0
|
@@ -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 };
|