@canonmsg/agent-sdk 0.2.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 +129 -0
- package/dist/api-client.d.ts +64 -0
- package/dist/api-client.js +257 -0
- package/dist/auth.d.ts +22 -0
- package/dist/auth.js +73 -0
- package/dist/canon-agent.d.ts +48 -0
- package/dist/canon-agent.js +274 -0
- package/dist/debouncer.d.ts +13 -0
- package/dist/debouncer.js +64 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +3 -0
- package/dist/polling.d.ts +16 -0
- package/dist/polling.js +78 -0
- package/dist/realtime.d.ts +23 -0
- package/dist/realtime.js +124 -0
- package/dist/session-manager.d.ts +61 -0
- package/dist/session-manager.js +180 -0
- package/dist/types.d.ts +71 -0
- package/dist/types.js +1 -0
- package/package.json +36 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { CanonClient, initRTDBAuth, writeSessionState, clearSessionState, } from '@canonmsg/core';
|
|
2
|
+
import { AuthManager } from './auth.js';
|
|
3
|
+
import { Debouncer } from './debouncer.js';
|
|
4
|
+
import { PollingManager } from './polling.js';
|
|
5
|
+
import { SessionManager } from './session-manager.js';
|
|
6
|
+
const AUTO_MODE_THRESHOLD = 500;
|
|
7
|
+
export class CanonAgent {
|
|
8
|
+
options;
|
|
9
|
+
apiClient;
|
|
10
|
+
authManager;
|
|
11
|
+
debouncer;
|
|
12
|
+
pollingManager = null;
|
|
13
|
+
realtimeManager = null;
|
|
14
|
+
sessionManager = null;
|
|
15
|
+
handler = null;
|
|
16
|
+
agentId = null;
|
|
17
|
+
agentContext = null;
|
|
18
|
+
cachedConversationIds = [];
|
|
19
|
+
running = false;
|
|
20
|
+
constructor(options) {
|
|
21
|
+
this.options = {
|
|
22
|
+
baseUrl: 'https://api-6m6mlelskq-uc.a.run.app',
|
|
23
|
+
deliveryMode: 'auto',
|
|
24
|
+
pollingIntervalMs: 3000,
|
|
25
|
+
debounceMs: 2000,
|
|
26
|
+
historyLimit: 50,
|
|
27
|
+
autoMarkRead: true,
|
|
28
|
+
...options,
|
|
29
|
+
};
|
|
30
|
+
this.apiClient = new CanonClient(this.options.apiKey, this.options.baseUrl);
|
|
31
|
+
this.authManager = new AuthManager(this.apiClient);
|
|
32
|
+
this.debouncer = new Debouncer(this.options.debounceMs);
|
|
33
|
+
if (options.sessions?.enabled) {
|
|
34
|
+
this.sessionManager = new SessionManager({
|
|
35
|
+
contextLimit: options.sessions.contextLimit,
|
|
36
|
+
concurrency: options.sessions.concurrency,
|
|
37
|
+
idleTimeoutMs: options.sessions.idleTimeoutMs,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
on(event, handler) {
|
|
42
|
+
if (event === 'message') {
|
|
43
|
+
this.handler = handler;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async start() {
|
|
47
|
+
if (this.running)
|
|
48
|
+
return;
|
|
49
|
+
this.running = true;
|
|
50
|
+
// 1. Authenticate
|
|
51
|
+
const { agentId } = await this.authManager.authenticate();
|
|
52
|
+
this.agentId = agentId;
|
|
53
|
+
console.log(`[canon-sdk] Authenticated as ${agentId}`);
|
|
54
|
+
// 2. Wire debouncer to handler
|
|
55
|
+
this.debouncer.setCallback(async (conversationId, messages) => {
|
|
56
|
+
await this.handleMessages(conversationId, messages);
|
|
57
|
+
});
|
|
58
|
+
// 3. Fetch conversations (used for delivery mode + session state)
|
|
59
|
+
let conversations = [];
|
|
60
|
+
try {
|
|
61
|
+
conversations = await this.apiClient.getConversations();
|
|
62
|
+
this.cachedConversationIds = conversations.map((c) => c.id);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Non-fatal — delivery mode will fall back to default
|
|
66
|
+
}
|
|
67
|
+
// 3a. Determine delivery mode
|
|
68
|
+
let mode = this.options.deliveryMode;
|
|
69
|
+
if (mode === 'auto') {
|
|
70
|
+
mode = conversations.length < AUTO_MODE_THRESHOLD ? 'sse' : 'polling';
|
|
71
|
+
console.log(`[canon-sdk] Auto-selected ${mode} mode (${conversations.length} conversations)`);
|
|
72
|
+
}
|
|
73
|
+
// 3b. Fetch agent context (identity, owner, access level)
|
|
74
|
+
try {
|
|
75
|
+
this.agentContext = await this.apiClient.getAgentMe();
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
console.warn('[canon-sdk] Failed to fetch agent context — owner/access info unavailable');
|
|
79
|
+
}
|
|
80
|
+
// 3c. Initialize RTDB session state reporting (opt-in)
|
|
81
|
+
if (this.options.sessionState) {
|
|
82
|
+
initRTDBAuth(this.apiClient);
|
|
83
|
+
for (const id of this.cachedConversationIds) {
|
|
84
|
+
writeSessionState(id, agentId, {
|
|
85
|
+
cwd: process.cwd(),
|
|
86
|
+
isActive: true,
|
|
87
|
+
...(this.options.clientType ? { clientType: this.options.clientType } : {}),
|
|
88
|
+
}).catch(() => { });
|
|
89
|
+
}
|
|
90
|
+
if (this.cachedConversationIds.length > 0) {
|
|
91
|
+
console.log(`[canon-sdk] Session state reported for ${this.cachedConversationIds.length} conversations`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// 4. Start delivery
|
|
95
|
+
if (mode === 'sse' || mode === 'realtime') {
|
|
96
|
+
const { RealtimeManager } = await import('./realtime.js');
|
|
97
|
+
const rtm = new RealtimeManager(this.options.apiKey, this.debouncer, agentId, this.options.streamUrl, this.apiClient);
|
|
98
|
+
rtm.setOnAgentContext((ctx) => {
|
|
99
|
+
this.agentContext = ctx;
|
|
100
|
+
});
|
|
101
|
+
this.realtimeManager = rtm;
|
|
102
|
+
await rtm.start();
|
|
103
|
+
console.log('[canon-sdk] SSE stream started');
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
this.pollingManager = new PollingManager(this.apiClient, this.debouncer, agentId, this.options.pollingIntervalMs);
|
|
107
|
+
await this.pollingManager.start();
|
|
108
|
+
console.log(`[canon-sdk] Polling started (interval: ${this.options.pollingIntervalMs}ms)`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async createConversation(options) {
|
|
112
|
+
return this.apiClient.createConversation(options);
|
|
113
|
+
}
|
|
114
|
+
async updateTopic(conversationId, topic) {
|
|
115
|
+
return this.apiClient.updateTopic(conversationId, topic);
|
|
116
|
+
}
|
|
117
|
+
async leaveConversation(conversationId) {
|
|
118
|
+
return this.apiClient.leaveConversation(conversationId);
|
|
119
|
+
}
|
|
120
|
+
async updateConversationName(conversationId, name) {
|
|
121
|
+
return this.apiClient.updateConversationName(conversationId, name);
|
|
122
|
+
}
|
|
123
|
+
async addMember(conversationId, userId) {
|
|
124
|
+
return this.apiClient.addMember(conversationId, userId);
|
|
125
|
+
}
|
|
126
|
+
async removeMember(conversationId, userId) {
|
|
127
|
+
return this.apiClient.removeMember(conversationId, userId);
|
|
128
|
+
}
|
|
129
|
+
async uploadMedia(conversationId, data, mimeType) {
|
|
130
|
+
return this.apiClient.uploadMedia(conversationId, data, mimeType);
|
|
131
|
+
}
|
|
132
|
+
async stop() {
|
|
133
|
+
if (!this.running)
|
|
134
|
+
return;
|
|
135
|
+
this.running = false;
|
|
136
|
+
// Clear session state if enabled (uses cached IDs — no network call during shutdown)
|
|
137
|
+
if (this.options.sessionState && this.agentId) {
|
|
138
|
+
for (const id of this.cachedConversationIds) {
|
|
139
|
+
clearSessionState(id, this.agentId).catch(() => { });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
this.pollingManager?.stop();
|
|
143
|
+
this.realtimeManager?.stop();
|
|
144
|
+
this.sessionManager?.destroy();
|
|
145
|
+
this.authManager.destroy();
|
|
146
|
+
this.debouncer.destroy();
|
|
147
|
+
console.log('[canon-sdk] Stopped');
|
|
148
|
+
}
|
|
149
|
+
async handleMessages(conversationId, messages) {
|
|
150
|
+
if (!this.handler) {
|
|
151
|
+
console.warn(`[canon-sdk] No message handler registered — messages for ${conversationId} dropped. Call agent.on('message', handler) before starting.`);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (this.sessionManager) {
|
|
155
|
+
await this.sessionManager.enqueue(conversationId, messages, async (session, newMessages) => {
|
|
156
|
+
await this.executeHandler(conversationId, newMessages, session);
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
await this.executeHandler(conversationId, messages);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
async executeHandler(conversationId, messages, session) {
|
|
164
|
+
if (!this.handler)
|
|
165
|
+
return;
|
|
166
|
+
// Show thinking indicator and keep it alive (5s client-side expiry)
|
|
167
|
+
try {
|
|
168
|
+
await this.apiClient.setTyping(conversationId, true, 'thinking');
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
// Non-critical
|
|
172
|
+
}
|
|
173
|
+
const thinkingKeepalive = setInterval(() => {
|
|
174
|
+
this.apiClient.setTyping(conversationId, true, 'thinking').catch(() => { });
|
|
175
|
+
}, 3500);
|
|
176
|
+
try {
|
|
177
|
+
// Fetch history from API
|
|
178
|
+
const history = await this.apiClient.getMessages(conversationId, this.options.historyLimit);
|
|
179
|
+
// If sessions enabled, seed the session with fetched history
|
|
180
|
+
if (this.sessionManager && session) {
|
|
181
|
+
this.sessionManager.seedHistory(conversationId, history);
|
|
182
|
+
}
|
|
183
|
+
// Get conversation info
|
|
184
|
+
const conversations = await this.apiClient.getConversations();
|
|
185
|
+
const conversation = conversations.find((c) => c.id === conversationId);
|
|
186
|
+
if (!conversation)
|
|
187
|
+
return;
|
|
188
|
+
// Build reply function — switch to typing, send, then clear
|
|
189
|
+
const reply = async (text) => {
|
|
190
|
+
try {
|
|
191
|
+
await this.apiClient.setTyping(conversationId, true, 'typing');
|
|
192
|
+
}
|
|
193
|
+
catch { }
|
|
194
|
+
const result = await this.apiClient.sendMessage(conversationId, text);
|
|
195
|
+
try {
|
|
196
|
+
await this.apiClient.setTyping(conversationId, false);
|
|
197
|
+
}
|
|
198
|
+
catch { }
|
|
199
|
+
return result;
|
|
200
|
+
};
|
|
201
|
+
// Enrich history messages with isOwner
|
|
202
|
+
if (this.agentContext?.ownerId) {
|
|
203
|
+
const ownerId = this.agentContext.ownerId;
|
|
204
|
+
for (const m of history) {
|
|
205
|
+
m.isOwner = m.senderId === ownerId;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// Build agent context (fallback to minimal if not yet received)
|
|
209
|
+
const agent = this.agentContext ?? {
|
|
210
|
+
agentId: this.agentId,
|
|
211
|
+
ownerId: '',
|
|
212
|
+
ownerName: '',
|
|
213
|
+
accessLevel: 'open',
|
|
214
|
+
};
|
|
215
|
+
// Build context methods bound to this conversation
|
|
216
|
+
const deleteMessage = (messageId) => this.apiClient.deleteMessage(conversationId, messageId);
|
|
217
|
+
const markAsRead = () => this.apiClient.markAsRead(conversationId);
|
|
218
|
+
const leave = () => this.apiClient.leaveConversation(conversationId);
|
|
219
|
+
const react = (messageId, emoji) => this.apiClient.react(conversationId, messageId, emoji);
|
|
220
|
+
const addMember = (userId) => this.apiClient.addMember(conversationId, userId);
|
|
221
|
+
const removeMember = (userId) => this.apiClient.removeMember(conversationId, userId);
|
|
222
|
+
// Invoke handler
|
|
223
|
+
await this.handler({
|
|
224
|
+
messages,
|
|
225
|
+
history,
|
|
226
|
+
conversationId,
|
|
227
|
+
conversation,
|
|
228
|
+
reply,
|
|
229
|
+
deleteMessage,
|
|
230
|
+
markAsRead,
|
|
231
|
+
leave,
|
|
232
|
+
react,
|
|
233
|
+
addMember,
|
|
234
|
+
removeMember,
|
|
235
|
+
agent,
|
|
236
|
+
session: session
|
|
237
|
+
? {
|
|
238
|
+
id: session.id,
|
|
239
|
+
messages: session.messages,
|
|
240
|
+
metadata: session.metadata,
|
|
241
|
+
}
|
|
242
|
+
: undefined,
|
|
243
|
+
});
|
|
244
|
+
// Auto-mark conversation as read after successful processing
|
|
245
|
+
if (this.options.autoMarkRead) {
|
|
246
|
+
try {
|
|
247
|
+
await this.apiClient.markAsRead(conversationId);
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
// Non-critical
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
console.error(`[canon-sdk] Handler error for ${conversationId}:`, err);
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
clearInterval(thinkingKeepalive);
|
|
259
|
+
// Always clear typing when done
|
|
260
|
+
try {
|
|
261
|
+
await this.apiClient.setTyping(conversationId, false);
|
|
262
|
+
}
|
|
263
|
+
catch { }
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
// Static registration helpers (unauthenticated)
|
|
267
|
+
static async register(options) {
|
|
268
|
+
const { baseUrl, ...body } = options;
|
|
269
|
+
return CanonClient.register(baseUrl, body);
|
|
270
|
+
}
|
|
271
|
+
static async checkStatus(requestId, baseUrl) {
|
|
272
|
+
return CanonClient.checkStatus(baseUrl, requestId);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { CanonMessage } from '@canonmsg/core';
|
|
2
|
+
export declare class Debouncer {
|
|
3
|
+
private debounceMs;
|
|
4
|
+
private pending;
|
|
5
|
+
private timers;
|
|
6
|
+
private orderedFlags;
|
|
7
|
+
private callback;
|
|
8
|
+
constructor(debounceMs: number);
|
|
9
|
+
setCallback(cb: (conversationId: string, messages: CanonMessage[]) => void): void;
|
|
10
|
+
add(conversationId: string, message: CanonMessage): void;
|
|
11
|
+
private flush;
|
|
12
|
+
destroy(): void;
|
|
13
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export class Debouncer {
|
|
2
|
+
debounceMs;
|
|
3
|
+
pending = new Map();
|
|
4
|
+
timers = new Map();
|
|
5
|
+
// Track whether each conversation's pending messages are already in sorted
|
|
6
|
+
// order so we can skip the sort on flush when possible (#10)
|
|
7
|
+
orderedFlags = new Map();
|
|
8
|
+
callback = null;
|
|
9
|
+
constructor(debounceMs) {
|
|
10
|
+
this.debounceMs = debounceMs;
|
|
11
|
+
}
|
|
12
|
+
setCallback(cb) {
|
|
13
|
+
this.callback = cb;
|
|
14
|
+
}
|
|
15
|
+
add(conversationId, message) {
|
|
16
|
+
const existing = this.pending.get(conversationId) || [];
|
|
17
|
+
// Deduplicate by message ID
|
|
18
|
+
if (!existing.some((m) => m.id === message.id)) {
|
|
19
|
+
if (existing.length === 0) {
|
|
20
|
+
// First message for this conversation — trivially ordered
|
|
21
|
+
this.orderedFlags.set(conversationId, true);
|
|
22
|
+
}
|
|
23
|
+
else if (this.orderedFlags.get(conversationId) !== false) {
|
|
24
|
+
// Still potentially ordered — check if new message maintains sort order
|
|
25
|
+
const lastTime = new Date(existing[existing.length - 1].createdAt).getTime();
|
|
26
|
+
const newTime = new Date(message.createdAt).getTime();
|
|
27
|
+
if (newTime < lastTime) {
|
|
28
|
+
this.orderedFlags.set(conversationId, false);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
existing.push(message);
|
|
32
|
+
this.pending.set(conversationId, existing);
|
|
33
|
+
}
|
|
34
|
+
// Reset timer for this conversation
|
|
35
|
+
const existingTimer = this.timers.get(conversationId);
|
|
36
|
+
if (existingTimer)
|
|
37
|
+
clearTimeout(existingTimer);
|
|
38
|
+
this.timers.set(conversationId, setTimeout(() => {
|
|
39
|
+
this.flush(conversationId);
|
|
40
|
+
}, this.debounceMs));
|
|
41
|
+
}
|
|
42
|
+
flush(conversationId) {
|
|
43
|
+
const messages = this.pending.get(conversationId);
|
|
44
|
+
const isOrdered = this.orderedFlags.get(conversationId) ?? false;
|
|
45
|
+
this.pending.delete(conversationId);
|
|
46
|
+
this.timers.delete(conversationId);
|
|
47
|
+
this.orderedFlags.delete(conversationId);
|
|
48
|
+
if (messages && messages.length > 0 && this.callback) {
|
|
49
|
+
// Only sort if insertions arrived out of order (#10)
|
|
50
|
+
if (!isOrdered) {
|
|
51
|
+
messages.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
|
52
|
+
}
|
|
53
|
+
this.callback(conversationId, messages);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
destroy() {
|
|
57
|
+
for (const timer of this.timers.values()) {
|
|
58
|
+
clearTimeout(timer);
|
|
59
|
+
}
|
|
60
|
+
this.timers.clear();
|
|
61
|
+
this.pending.clear();
|
|
62
|
+
this.orderedFlags.clear();
|
|
63
|
+
}
|
|
64
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { CanonAgent } from './canon-agent.js';
|
|
2
|
+
export { CanonApiError } from '@canonmsg/core';
|
|
3
|
+
export { SessionManager } from './session-manager.js';
|
|
4
|
+
export type { SessionConfig, Session } from './session-manager.js';
|
|
5
|
+
export type { AgentContext, CanonMessage, CanonConversation, SendMessageOptions, CreateConversationOptions, } from '@canonmsg/core';
|
|
6
|
+
export type { SDKMessage, SDKConversation, CanonAgentOptions, MessageHandler, MessageHandlerContext, SessionInfo, SessionOptions, DeliveryMode, } from './types.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { CanonClient } from '@canonmsg/core';
|
|
2
|
+
import { Debouncer } from './debouncer.js';
|
|
3
|
+
export declare class PollingManager {
|
|
4
|
+
private apiClient;
|
|
5
|
+
private debouncer;
|
|
6
|
+
private agentId;
|
|
7
|
+
private pollingIntervalMs;
|
|
8
|
+
private lastSeenTimestamps;
|
|
9
|
+
private pollTimer;
|
|
10
|
+
private running;
|
|
11
|
+
constructor(apiClient: CanonClient, debouncer: Debouncer, agentId: string, pollingIntervalMs: number);
|
|
12
|
+
start(): Promise<void>;
|
|
13
|
+
private poll;
|
|
14
|
+
private findActiveConversations;
|
|
15
|
+
stop(): void;
|
|
16
|
+
}
|
package/dist/polling.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export class PollingManager {
|
|
2
|
+
apiClient;
|
|
3
|
+
debouncer;
|
|
4
|
+
agentId;
|
|
5
|
+
pollingIntervalMs;
|
|
6
|
+
lastSeenTimestamps = new Map();
|
|
7
|
+
pollTimer = null;
|
|
8
|
+
running = false;
|
|
9
|
+
constructor(apiClient, debouncer, agentId, pollingIntervalMs) {
|
|
10
|
+
this.apiClient = apiClient;
|
|
11
|
+
this.debouncer = debouncer;
|
|
12
|
+
this.agentId = agentId;
|
|
13
|
+
this.pollingIntervalMs = pollingIntervalMs;
|
|
14
|
+
}
|
|
15
|
+
async start() {
|
|
16
|
+
this.running = true;
|
|
17
|
+
// Initialize: mark current time as baseline (only respond to messages after start)
|
|
18
|
+
const now = Date.now();
|
|
19
|
+
const conversations = await this.apiClient.getConversations();
|
|
20
|
+
for (const convo of conversations) {
|
|
21
|
+
this.lastSeenTimestamps.set(convo.id, now);
|
|
22
|
+
}
|
|
23
|
+
// Start polling
|
|
24
|
+
this.pollTimer = setInterval(() => this.poll(), this.pollingIntervalMs);
|
|
25
|
+
}
|
|
26
|
+
async poll() {
|
|
27
|
+
if (!this.running)
|
|
28
|
+
return;
|
|
29
|
+
try {
|
|
30
|
+
const conversations = await this.apiClient.getConversations();
|
|
31
|
+
const activeConvos = this.findActiveConversations(conversations);
|
|
32
|
+
await Promise.all(activeConvos.map(async (convo) => {
|
|
33
|
+
try {
|
|
34
|
+
const messages = await this.apiClient.getMessages(convo.id, 50);
|
|
35
|
+
// Filter to only new messages (after lastSeen, not from self)
|
|
36
|
+
const lastSeen = this.lastSeenTimestamps.get(convo.id) || 0;
|
|
37
|
+
const newMessages = messages.filter((m) => {
|
|
38
|
+
const msgTime = new Date(m.createdAt).getTime();
|
|
39
|
+
return msgTime > lastSeen && m.senderId !== this.agentId;
|
|
40
|
+
});
|
|
41
|
+
for (const msg of newMessages) {
|
|
42
|
+
this.debouncer.add(convo.id, msg);
|
|
43
|
+
}
|
|
44
|
+
// Update lastSeen to latest message timestamp
|
|
45
|
+
if (messages.length > 0) {
|
|
46
|
+
const latestTime = Math.max(...messages.map((m) => new Date(m.createdAt).getTime()));
|
|
47
|
+
this.lastSeenTimestamps.set(convo.id, latestTime);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
console.error(`[canon-sdk] Failed to fetch messages for ${convo.id}:`, err);
|
|
52
|
+
}
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
console.error('[canon-sdk] Polling error:', err);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
findActiveConversations(conversations) {
|
|
60
|
+
return conversations.filter((convo) => {
|
|
61
|
+
if (!convo.lastMessage)
|
|
62
|
+
return false;
|
|
63
|
+
// Skip if agent was last sender
|
|
64
|
+
if (convo.lastMessage.senderId === this.agentId)
|
|
65
|
+
return false;
|
|
66
|
+
const lastMsgTime = new Date(convo.lastMessage.timestamp).getTime();
|
|
67
|
+
const lastSeen = this.lastSeenTimestamps.get(convo.id) || 0;
|
|
68
|
+
return lastMsgTime > lastSeen;
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
stop() {
|
|
72
|
+
this.running = false;
|
|
73
|
+
if (this.pollTimer) {
|
|
74
|
+
clearInterval(this.pollTimer);
|
|
75
|
+
this.pollTimer = null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { CanonClient, type AgentContext } from '@canonmsg/core';
|
|
2
|
+
import { Debouncer } from './debouncer.js';
|
|
3
|
+
/**
|
|
4
|
+
* Wraps @canonmsg/core's CanonStream with SDK-specific features:
|
|
5
|
+
* - Debouncer integration (message batching)
|
|
6
|
+
* - Conversation discovery polling (detect new conversations)
|
|
7
|
+
* - Agent context callback
|
|
8
|
+
*/
|
|
9
|
+
export declare class RealtimeManager {
|
|
10
|
+
private apiClient;
|
|
11
|
+
private debouncer;
|
|
12
|
+
private agentId;
|
|
13
|
+
private stream;
|
|
14
|
+
private running;
|
|
15
|
+
private knownConversationIds;
|
|
16
|
+
private discoveryTimer;
|
|
17
|
+
private onAgentContext;
|
|
18
|
+
constructor(apiKey: string, debouncer: Debouncer, agentId: string, streamUrl?: string, apiClient?: CanonClient);
|
|
19
|
+
setOnAgentContext(cb: (ctx: AgentContext) => void): void;
|
|
20
|
+
start(): Promise<void>;
|
|
21
|
+
stop(): void;
|
|
22
|
+
private discoverNewConversations;
|
|
23
|
+
}
|
package/dist/realtime.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { CanonClient, CanonStream } from '@canonmsg/core';
|
|
2
|
+
const DISCOVERY_INTERVAL_MS = 5_000;
|
|
3
|
+
/**
|
|
4
|
+
* Wraps @canonmsg/core's CanonStream with SDK-specific features:
|
|
5
|
+
* - Debouncer integration (message batching)
|
|
6
|
+
* - Conversation discovery polling (detect new conversations)
|
|
7
|
+
* - Agent context callback
|
|
8
|
+
*/
|
|
9
|
+
export class RealtimeManager {
|
|
10
|
+
apiClient;
|
|
11
|
+
debouncer;
|
|
12
|
+
agentId;
|
|
13
|
+
stream;
|
|
14
|
+
running = false;
|
|
15
|
+
knownConversationIds = new Set();
|
|
16
|
+
discoveryTimer = null;
|
|
17
|
+
onAgentContext = null;
|
|
18
|
+
constructor(apiKey, debouncer, agentId, streamUrl, apiClient) {
|
|
19
|
+
this.debouncer = debouncer;
|
|
20
|
+
this.agentId = agentId;
|
|
21
|
+
this.apiClient = apiClient || new CanonClient(apiKey);
|
|
22
|
+
this.stream = new CanonStream({
|
|
23
|
+
apiKey,
|
|
24
|
+
agentId,
|
|
25
|
+
streamUrl,
|
|
26
|
+
handler: {
|
|
27
|
+
onMessage: (payload) => {
|
|
28
|
+
const m = payload.message;
|
|
29
|
+
const message = {
|
|
30
|
+
id: m.id,
|
|
31
|
+
senderId: m.senderId,
|
|
32
|
+
senderType: m.senderType ?? 'human',
|
|
33
|
+
isOwner: m.isOwner ?? false,
|
|
34
|
+
contentType: m.contentType ?? 'text',
|
|
35
|
+
text: m.text ?? null,
|
|
36
|
+
imageUrl: m.imageUrl ?? null,
|
|
37
|
+
audioUrl: m.audioUrl ?? null,
|
|
38
|
+
audioDurationMs: m.audioDurationMs ?? null,
|
|
39
|
+
mentions: m.mentions ?? [],
|
|
40
|
+
replyTo: m.replyTo ?? null,
|
|
41
|
+
replyToPosition: m.replyToPosition ?? null,
|
|
42
|
+
status: 'sent',
|
|
43
|
+
deleted: false,
|
|
44
|
+
createdAt: m.createdAt ?? new Date().toISOString(),
|
|
45
|
+
...(m.metadata ? { metadata: m.metadata } : {}),
|
|
46
|
+
};
|
|
47
|
+
this.debouncer.add(payload.conversationId, message);
|
|
48
|
+
},
|
|
49
|
+
onAgentContext: (ctx) => {
|
|
50
|
+
this.onAgentContext?.(ctx);
|
|
51
|
+
},
|
|
52
|
+
onConnected: () => {
|
|
53
|
+
// Reset backoff is handled internally by CanonStream
|
|
54
|
+
},
|
|
55
|
+
onError: (err) => {
|
|
56
|
+
console.error('[canon-sdk] SSE error:', err.message);
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
setOnAgentContext(cb) {
|
|
62
|
+
this.onAgentContext = cb;
|
|
63
|
+
}
|
|
64
|
+
async start() {
|
|
65
|
+
this.running = true;
|
|
66
|
+
// Snapshot current conversations
|
|
67
|
+
try {
|
|
68
|
+
const convos = await this.apiClient.getConversations();
|
|
69
|
+
for (const c of convos)
|
|
70
|
+
this.knownConversationIds.add(c.id);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// Non-fatal
|
|
74
|
+
}
|
|
75
|
+
// Start SSE stream
|
|
76
|
+
await this.stream.start();
|
|
77
|
+
// Start conversation discovery poll
|
|
78
|
+
this.discoveryTimer = setInterval(() => this.discoverNewConversations(), DISCOVERY_INTERVAL_MS);
|
|
79
|
+
if (this.discoveryTimer.unref)
|
|
80
|
+
this.discoveryTimer.unref();
|
|
81
|
+
}
|
|
82
|
+
stop() {
|
|
83
|
+
this.running = false;
|
|
84
|
+
if (this.discoveryTimer) {
|
|
85
|
+
clearInterval(this.discoveryTimer);
|
|
86
|
+
this.discoveryTimer = null;
|
|
87
|
+
}
|
|
88
|
+
this.stream.stop();
|
|
89
|
+
}
|
|
90
|
+
// ── Conversation discovery ─────────────────────────────────────────
|
|
91
|
+
async discoverNewConversations() {
|
|
92
|
+
if (!this.running)
|
|
93
|
+
return;
|
|
94
|
+
try {
|
|
95
|
+
const convos = await this.apiClient.getConversations();
|
|
96
|
+
const newConvoIds = [];
|
|
97
|
+
for (const c of convos) {
|
|
98
|
+
if (!this.knownConversationIds.has(c.id)) {
|
|
99
|
+
this.knownConversationIds.add(c.id);
|
|
100
|
+
newConvoIds.push(c.id);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (newConvoIds.length === 0)
|
|
104
|
+
return;
|
|
105
|
+
console.log(`[canon-sdk] Discovered ${newConvoIds.length} new conversation(s) — fetching messages`);
|
|
106
|
+
// Fetch and deliver pending messages from new conversations
|
|
107
|
+
for (const convoId of newConvoIds) {
|
|
108
|
+
try {
|
|
109
|
+
const messages = await this.apiClient.getMessages(convoId, 50);
|
|
110
|
+
const newMessages = messages.filter((m) => m.senderId !== this.agentId);
|
|
111
|
+
for (const msg of newMessages) {
|
|
112
|
+
this.debouncer.add(convoId, msg);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// Will be picked up after reconnect
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// Non-fatal — will retry next interval
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { CanonMessage } from '@canonmsg/core';
|
|
2
|
+
export interface SessionConfig {
|
|
3
|
+
/** Max messages to retain per session (default: 50) */
|
|
4
|
+
contextLimit?: number;
|
|
5
|
+
/** Max sessions processing concurrently (default: 10) */
|
|
6
|
+
concurrency?: number;
|
|
7
|
+
/** Evict idle sessions after this many ms (default: 1h) */
|
|
8
|
+
idleTimeoutMs?: number;
|
|
9
|
+
}
|
|
10
|
+
export interface Session {
|
|
11
|
+
/** Session ID (= conversationId) */
|
|
12
|
+
id: string;
|
|
13
|
+
/** Accumulated messages for this conversation, oldest first */
|
|
14
|
+
messages: CanonMessage[];
|
|
15
|
+
/** Arbitrary per-session state managed by the agent */
|
|
16
|
+
metadata: Record<string, unknown>;
|
|
17
|
+
/** When this session last processed a message */
|
|
18
|
+
lastActiveAt: number;
|
|
19
|
+
}
|
|
20
|
+
type SessionHandler = (session: Session, newMessages: CanonMessage[]) => Promise<void>;
|
|
21
|
+
/**
|
|
22
|
+
* Manages per-conversation sessions with:
|
|
23
|
+
* - In-memory message buffer per session
|
|
24
|
+
* - Serialized processing within a session (queue)
|
|
25
|
+
* - Parallel processing across sessions (bounded concurrency)
|
|
26
|
+
* - Idle session eviction
|
|
27
|
+
*/
|
|
28
|
+
export declare class SessionManager {
|
|
29
|
+
private sessions;
|
|
30
|
+
private queues;
|
|
31
|
+
private activeCount;
|
|
32
|
+
private pending;
|
|
33
|
+
private processing;
|
|
34
|
+
private seenMessages;
|
|
35
|
+
private seededSessions;
|
|
36
|
+
private sweepTimer;
|
|
37
|
+
private contextLimit;
|
|
38
|
+
private concurrency;
|
|
39
|
+
private idleTimeoutMs;
|
|
40
|
+
constructor(config?: SessionConfig);
|
|
41
|
+
/** Get or create a session for a conversation */
|
|
42
|
+
getSession(conversationId: string): Session;
|
|
43
|
+
/**
|
|
44
|
+
* Enqueue messages for processing in a specific session.
|
|
45
|
+
* Returns a promise that resolves when the handler completes for these messages.
|
|
46
|
+
*
|
|
47
|
+
* - Messages within the same session are serialized (queued).
|
|
48
|
+
* - Messages across different sessions run concurrently up to the concurrency limit.
|
|
49
|
+
*/
|
|
50
|
+
enqueue(conversationId: string, messages: CanonMessage[], handler: SessionHandler): Promise<void>;
|
|
51
|
+
private drainSession;
|
|
52
|
+
/** Seed a session with historical messages (e.g. fetched from API) */
|
|
53
|
+
seedHistory(conversationId: string, history: CanonMessage[]): void;
|
|
54
|
+
/** Remove idle sessions */
|
|
55
|
+
private sweep;
|
|
56
|
+
/** Number of active sessions */
|
|
57
|
+
get sessionCount(): number;
|
|
58
|
+
/** Clean up all state */
|
|
59
|
+
destroy(): void;
|
|
60
|
+
}
|
|
61
|
+
export {};
|