@canonmsg/core 0.2.2

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/dist/stream.js ADDED
@@ -0,0 +1,244 @@
1
+ import { DEFAULT_STREAM_URL } from './constants.js';
2
+ const MAX_BACKOFF_MS = 30_000;
3
+ /**
4
+ * Manages a persistent SSE connection to Canon's stream service.
5
+ *
6
+ * Uses native fetch + ReadableStream (Node 18+) — no EventSource polyfill.
7
+ * Supports Last-Event-ID for gapless replay on reconnect.
8
+ */
9
+ export class CanonStream {
10
+ apiKey;
11
+ agentId;
12
+ streamUrl;
13
+ handler;
14
+ running = false;
15
+ abortController = null;
16
+ lastEventId = null;
17
+ reconnectAttempt = 0;
18
+ reconnectTimer = null;
19
+ constructor(opts) {
20
+ this.apiKey = opts.apiKey;
21
+ this.agentId = opts.agentId;
22
+ this.streamUrl = opts.streamUrl || DEFAULT_STREAM_URL;
23
+ this.handler = opts.handler;
24
+ }
25
+ async start() {
26
+ this.running = true;
27
+ this.reconnectAttempt = 0;
28
+ await this.connect();
29
+ }
30
+ stop() {
31
+ this.running = false;
32
+ if (this.reconnectTimer) {
33
+ clearTimeout(this.reconnectTimer);
34
+ this.reconnectTimer = null;
35
+ }
36
+ if (this.abortController) {
37
+ this.abortController.abort();
38
+ this.abortController = null;
39
+ }
40
+ this.handler.onDisconnected?.();
41
+ }
42
+ isRunning() {
43
+ return this.running;
44
+ }
45
+ // ── SSE connection ────────────────────────────────────────────────────
46
+ async connect() {
47
+ if (!this.running)
48
+ return;
49
+ this.abortController = new AbortController();
50
+ const headers = {
51
+ Authorization: `Bearer ${this.apiKey}`,
52
+ Accept: 'text/event-stream',
53
+ };
54
+ if (this.lastEventId) {
55
+ headers['Last-Event-ID'] = this.lastEventId;
56
+ }
57
+ try {
58
+ const res = await fetch(`${this.streamUrl}/agents/stream`, {
59
+ headers,
60
+ signal: this.abortController.signal,
61
+ });
62
+ if (!res.ok) {
63
+ throw new Error(`SSE connect failed: ${res.status} ${res.statusText}`);
64
+ }
65
+ this.handler.onConnected?.();
66
+ await this.readStream(res);
67
+ }
68
+ catch (err) {
69
+ if (!this.running)
70
+ return;
71
+ const msg = err instanceof Error ? err.message : String(err);
72
+ if (msg.includes('abort'))
73
+ return;
74
+ this.handler.onError?.(err instanceof Error ? err : new Error(msg));
75
+ this.scheduleReconnect();
76
+ }
77
+ }
78
+ async readStream(res) {
79
+ const reader = res.body.getReader();
80
+ const decoder = new TextDecoder();
81
+ let buffer = '';
82
+ try {
83
+ while (this.running) {
84
+ const { value, done } = await reader.read();
85
+ if (done)
86
+ break;
87
+ buffer += decoder.decode(value, { stream: true });
88
+ let boundary;
89
+ while ((boundary = buffer.indexOf('\n\n')) !== -1) {
90
+ const frame = buffer.slice(0, boundary);
91
+ buffer = buffer.slice(boundary + 2);
92
+ this.processFrame(frame);
93
+ }
94
+ }
95
+ }
96
+ catch (err) {
97
+ if (!this.running)
98
+ return;
99
+ const msg = err instanceof Error ? err.message : String(err);
100
+ if (msg.includes('abort'))
101
+ return;
102
+ this.handler.onError?.(err instanceof Error ? err : new Error(msg));
103
+ }
104
+ finally {
105
+ reader.releaseLock();
106
+ }
107
+ if (this.running) {
108
+ this.handler.onDisconnected?.();
109
+ this.scheduleReconnect();
110
+ }
111
+ }
112
+ // ── SSE frame parsing ─────────────────────────────────────────────────
113
+ processFrame(frame) {
114
+ let id = null;
115
+ let event = null;
116
+ let data = '';
117
+ for (const line of frame.split('\n')) {
118
+ if (line.startsWith('id:')) {
119
+ id = line.slice(3).trim();
120
+ }
121
+ else if (line.startsWith('event:')) {
122
+ event = line.slice(6).trim();
123
+ }
124
+ else if (line.startsWith('data:')) {
125
+ data += (data ? '\n' : '') + line.slice(5).trim();
126
+ }
127
+ // Lines starting with ':' are comments — ignore
128
+ }
129
+ if (id) {
130
+ this.lastEventId = id;
131
+ }
132
+ if (!event || !data)
133
+ return;
134
+ switch (event) {
135
+ case 'agent.context':
136
+ this.reconnectAttempt = 0;
137
+ this.handleAgentContext(data);
138
+ break;
139
+ case 'message.created':
140
+ this.reconnectAttempt = 0;
141
+ this.handleMessageCreated(data);
142
+ break;
143
+ case 'typing':
144
+ this.reconnectAttempt = 0;
145
+ this.handleTyping(data);
146
+ break;
147
+ case 'presence':
148
+ this.reconnectAttempt = 0;
149
+ this.handlePresence(data);
150
+ break;
151
+ case 'message.deleted':
152
+ this.reconnectAttempt = 0;
153
+ this.handleMessageDeleted(data);
154
+ break;
155
+ case 'conversation.updated':
156
+ this.reconnectAttempt = 0;
157
+ this.handleConversationUpdated(data);
158
+ break;
159
+ case 'heartbeat':
160
+ this.reconnectAttempt = 0;
161
+ break;
162
+ case 'replay.expired':
163
+ this.reconnectAttempt = 0;
164
+ this.handler.onError?.(new Error('Replay expired — some messages may have been missed'));
165
+ break;
166
+ case 'error':
167
+ // Don't reset backoff — error events mean something is wrong
168
+ this.handler.onError?.(new Error(`Stream error: ${data}`));
169
+ break;
170
+ default:
171
+ break;
172
+ }
173
+ }
174
+ handleAgentContext(raw) {
175
+ try {
176
+ const ctx = JSON.parse(raw);
177
+ this.handler.onAgentContext?.(ctx);
178
+ }
179
+ catch (err) {
180
+ this.handler.onError?.(new Error(`Failed to parse agent.context: ${err}`));
181
+ }
182
+ }
183
+ handleMessageCreated(raw) {
184
+ try {
185
+ const payload = JSON.parse(raw);
186
+ // Skip own messages
187
+ if (payload.message.senderId === this.agentId)
188
+ return;
189
+ this.handler.onMessage(payload);
190
+ }
191
+ catch (err) {
192
+ this.handler.onError?.(new Error(`Failed to parse message.created: ${err}`));
193
+ }
194
+ }
195
+ handleTyping(raw) {
196
+ try {
197
+ const data = JSON.parse(raw);
198
+ this.handler.onTyping?.(data);
199
+ }
200
+ catch {
201
+ // Ignore parse errors for non-critical events
202
+ }
203
+ }
204
+ handlePresence(raw) {
205
+ try {
206
+ const data = JSON.parse(raw);
207
+ this.handler.onPresence?.(data);
208
+ }
209
+ catch {
210
+ // Ignore parse errors for non-critical events
211
+ }
212
+ }
213
+ handleMessageDeleted(raw) {
214
+ try {
215
+ const data = JSON.parse(raw);
216
+ this.handler.onMessageDeleted?.(data);
217
+ }
218
+ catch {
219
+ // Ignore parse errors for non-critical events
220
+ }
221
+ }
222
+ handleConversationUpdated(raw) {
223
+ try {
224
+ const data = JSON.parse(raw);
225
+ this.handler.onConversationUpdated?.(data);
226
+ }
227
+ catch {
228
+ // Ignore parse errors for non-critical events
229
+ }
230
+ }
231
+ // ── Reconnect with exponential backoff + jitter ───────────────────────
232
+ scheduleReconnect() {
233
+ if (!this.running)
234
+ return;
235
+ const base = Math.min(1000 * Math.pow(2, this.reconnectAttempt), MAX_BACKOFF_MS);
236
+ const jitter = Math.random() * 0.25 * base;
237
+ const delay = base + jitter;
238
+ this.reconnectAttempt++;
239
+ this.reconnectTimer = setTimeout(() => {
240
+ this.reconnectTimer = null;
241
+ this.connect();
242
+ }, delay);
243
+ }
244
+ }
@@ -0,0 +1,52 @@
1
+ import type { StreamingStatus } from './types.js';
2
+ /** Minimal subset of firebase-admin Database to avoid a hard dependency. */
3
+ export interface RTDBHandle {
4
+ ref(path: string): RTDBRef;
5
+ }
6
+ export interface RTDBRef {
7
+ set(value: unknown): Promise<void>;
8
+ update(value: Record<string, unknown>): Promise<void>;
9
+ remove(): Promise<void>;
10
+ }
11
+ /** Server-timestamp sentinel — the consumer passes the appropriate value
12
+ * (e.g. `admin.database.ServerValue.TIMESTAMP` for firebase-admin). */
13
+ export type ServerTimestamp = object;
14
+ export interface StreamingHelperOptions {
15
+ /** RTDB instance (e.g. `admin.database()`) */
16
+ db: RTDBHandle;
17
+ /** Server-timestamp sentinel (e.g. `admin.database.ServerValue.TIMESTAMP`) */
18
+ serverTimestamp: ServerTimestamp;
19
+ }
20
+ export interface StreamingNode {
21
+ text: string;
22
+ status: StreamingStatus;
23
+ startedAt: unknown;
24
+ updatedAt: unknown;
25
+ messageId: string;
26
+ }
27
+ /**
28
+ * Creates helpers that read/write the RTDB streaming node for a single
29
+ * agent in a single conversation.
30
+ *
31
+ * Usage (in the Canon plugin):
32
+ * ```ts
33
+ * const s = createStreamingHelper({ db, serverTimestamp: ServerValue.TIMESTAMP });
34
+ * const h = s.forConversation(convoId, agentId);
35
+ * await h.start(messageId); // status → thinking
36
+ * await h.update('Hello ', 'streaming');
37
+ * await h.update('Hello world', 'streaming');
38
+ * await h.clear(); // remove the node
39
+ * ```
40
+ */
41
+ export declare function createStreamingHelper(opts: StreamingHelperOptions): {
42
+ forConversation: (conversationId: string, agentId: string) => {
43
+ /** Write the initial streaming node (status: "thinking", empty text). */
44
+ start(messageId: string): Promise<void>;
45
+ /** Update the accumulated text and optionally change status. */
46
+ update(text: string, status?: StreamingStatus): Promise<void>;
47
+ /** Set status without changing text (e.g. switching to "tool"). */
48
+ setStatus(status: StreamingStatus): Promise<void>;
49
+ /** Remove the streaming node (call after final Firestore message is written). */
50
+ clear(): Promise<void>;
51
+ };
52
+ };
@@ -0,0 +1,55 @@
1
+ // ── Helper ─────────────────────────────────────────────────────────────
2
+ /**
3
+ * Creates helpers that read/write the RTDB streaming node for a single
4
+ * agent in a single conversation.
5
+ *
6
+ * Usage (in the Canon plugin):
7
+ * ```ts
8
+ * const s = createStreamingHelper({ db, serverTimestamp: ServerValue.TIMESTAMP });
9
+ * const h = s.forConversation(convoId, agentId);
10
+ * await h.start(messageId); // status → thinking
11
+ * await h.update('Hello ', 'streaming');
12
+ * await h.update('Hello world', 'streaming');
13
+ * await h.clear(); // remove the node
14
+ * ```
15
+ */
16
+ export function createStreamingHelper(opts) {
17
+ const { db, serverTimestamp } = opts;
18
+ function forConversation(conversationId, agentId) {
19
+ const nodePath = `/streaming/${conversationId}/${agentId}`;
20
+ const nodeRef = db.ref(nodePath);
21
+ return {
22
+ /** Write the initial streaming node (status: "thinking", empty text). */
23
+ async start(messageId) {
24
+ const node = {
25
+ text: '',
26
+ status: 'thinking',
27
+ startedAt: serverTimestamp,
28
+ updatedAt: serverTimestamp,
29
+ messageId,
30
+ };
31
+ await nodeRef.set(node);
32
+ },
33
+ /** Update the accumulated text and optionally change status. */
34
+ async update(text, status = 'streaming') {
35
+ await nodeRef.update({
36
+ text,
37
+ status,
38
+ updatedAt: serverTimestamp,
39
+ });
40
+ },
41
+ /** Set status without changing text (e.g. switching to "tool"). */
42
+ async setStatus(status) {
43
+ await nodeRef.update({
44
+ status,
45
+ updatedAt: serverTimestamp,
46
+ });
47
+ },
48
+ /** Remove the streaming node (call after final Firestore message is written). */
49
+ async clear() {
50
+ await nodeRef.remove();
51
+ },
52
+ };
53
+ }
54
+ return { forConversation };
55
+ }
@@ -0,0 +1,194 @@
1
+ export interface CanonMessage {
2
+ id: string;
3
+ senderId: string;
4
+ senderType: 'human' | 'ai_agent';
5
+ /** Whether the sender is this agent's owner (server-computed, trusted) */
6
+ isOwner: boolean;
7
+ contentType: 'text' | 'image' | 'audio' | 'contact_card';
8
+ text: string | null;
9
+ imageUrl: string | null;
10
+ audioUrl: string | null;
11
+ audioDurationMs: number | null;
12
+ mentions: string[];
13
+ replyTo: string | null;
14
+ replyToPosition: number | null;
15
+ metadata?: Record<string, unknown>;
16
+ status: 'sent' | 'read';
17
+ deleted: boolean;
18
+ createdAt: string;
19
+ }
20
+ export interface CanonConversation {
21
+ id: string;
22
+ type: 'direct' | 'group';
23
+ name: string | null;
24
+ topic: string | null;
25
+ memberIds: string[];
26
+ isAgentChat: boolean;
27
+ hasUnread?: boolean;
28
+ lastMessage: {
29
+ text: string;
30
+ senderId: string;
31
+ senderType?: 'human' | 'ai_agent';
32
+ timestamp: string;
33
+ } | null;
34
+ createdAt: string;
35
+ }
36
+ export type AgentClientType = 'claude-code' | 'openclaw' | 'codex' | 'generic';
37
+ /** Declares what session controls an agent type supports. */
38
+ export interface AgentCapabilities {
39
+ supportsModelSwitch: boolean;
40
+ supportsPermissionMode: boolean;
41
+ supportsEffort: boolean;
42
+ supportsSessionState: boolean;
43
+ supportsInterrupt: boolean;
44
+ }
45
+ export interface ModelOption {
46
+ value: string;
47
+ label: string;
48
+ }
49
+ export interface WorkspaceOption {
50
+ id: string;
51
+ label: string;
52
+ }
53
+ /**
54
+ * Capability map keyed by clientType. Add new agent types here.
55
+ * ALSO update the duplicate in app/src/types/index.ts (RN app can't
56
+ * import from core directly due to different bundler/runtime).
57
+ */
58
+ export declare const AGENT_CAPABILITIES: Record<AgentClientType, AgentCapabilities>;
59
+ /** Trusted agent identity & access context, provided by the server */
60
+ export interface AgentContext {
61
+ agentId: string;
62
+ displayName?: string;
63
+ avatarUrl?: string | null;
64
+ description?: string | null;
65
+ ownerId: string;
66
+ ownerName: string;
67
+ accessLevel: 'open' | 'owner-only';
68
+ /** Identifies the agent's client platform for UI feature detection */
69
+ clientType?: AgentClientType;
70
+ }
71
+ export interface MessageCreatedPayload {
72
+ conversationId: string;
73
+ message: {
74
+ id: string;
75
+ senderId: string;
76
+ senderName?: string;
77
+ senderType?: 'human' | 'ai_agent';
78
+ /** Whether the sender is this agent's owner (server-computed, trusted) */
79
+ isOwner?: boolean;
80
+ text?: string;
81
+ contentType?: 'text' | 'image' | 'audio' | 'contact_card';
82
+ imageUrl?: string;
83
+ audioUrl?: string;
84
+ audioDurationMs?: number;
85
+ replyTo?: string;
86
+ replyToPosition?: number;
87
+ mentions?: string[];
88
+ createdAt?: string;
89
+ /** Structured metadata for rich UI (approval cards, etc.) */
90
+ metadata?: Record<string, unknown>;
91
+ };
92
+ }
93
+ export interface TypingPayload {
94
+ conversationId: string;
95
+ userId: string;
96
+ isTyping: boolean;
97
+ status?: 'thinking' | 'typing';
98
+ }
99
+ export interface PresencePayload {
100
+ userId: string;
101
+ online: boolean;
102
+ }
103
+ export interface SendMessageOptions {
104
+ contentType?: 'text' | 'audio' | 'image' | 'contact_card';
105
+ replyTo?: string;
106
+ replyToPosition?: number;
107
+ audioUrl?: string;
108
+ audioDurationMs?: number;
109
+ imageUrl?: string;
110
+ contactCardUserId?: string;
111
+ mentions?: string[];
112
+ /** Structured metadata for rich UI (approval cards, etc.) */
113
+ metadata?: Record<string, unknown>;
114
+ }
115
+ export interface CreateConversationOptions {
116
+ type: 'direct' | 'group';
117
+ targetUserId?: string;
118
+ memberIds?: string[];
119
+ name?: string;
120
+ }
121
+ export type StreamingStatus = 'thinking' | 'streaming' | 'tool';
122
+ export interface SetStreamingOptions {
123
+ conversationId: string;
124
+ text: string;
125
+ status: StreamingStatus;
126
+ messageId: string;
127
+ }
128
+ /** Written by Canon app to /control/{convoId}/{agentId}/session in RTDB */
129
+ export interface SessionControl {
130
+ model?: string;
131
+ permissionMode?: 'default' | 'acceptEdits' | 'plan' | 'bypassPermissions' | 'auto';
132
+ effort?: 'low' | 'medium' | 'high' | 'max';
133
+ updatedAt: number;
134
+ updatedBy: string;
135
+ }
136
+ /** Written by agent to /session-state/{convoId}/{agentId} in RTDB */
137
+ export interface SessionState {
138
+ model?: string;
139
+ permissionMode?: string;
140
+ effort?: string;
141
+ cwd?: string;
142
+ /** True when the agent is running under the host wrapper (host.ts) which can apply control signals */
143
+ hostMode?: boolean;
144
+ isActive: boolean;
145
+ state?: 'idle' | 'running' | 'requires_action';
146
+ contextUsage?: {
147
+ percentage: number;
148
+ totalTokens: number;
149
+ maxTokens: number;
150
+ };
151
+ availableModels?: ModelOption[];
152
+ updatedAt: number;
153
+ }
154
+ export interface SessionConfig {
155
+ clientType?: AgentClientType;
156
+ hostMode?: boolean;
157
+ model?: string;
158
+ permissionMode?: string;
159
+ workspaceId?: string;
160
+ availableModels?: ModelOption[];
161
+ workspaceOptions?: WorkspaceOption[];
162
+ updatedAt?: number;
163
+ }
164
+ export interface AgentRuntime {
165
+ clientType?: AgentClientType;
166
+ hostMode?: boolean;
167
+ defaultModel?: string;
168
+ defaultPermissionMode?: string;
169
+ defaultWorkspaceId?: string;
170
+ availableModels?: ModelOption[];
171
+ availableWorkspaces?: WorkspaceOption[];
172
+ updatedAt?: number;
173
+ }
174
+ export interface RegistrationInput {
175
+ name: string;
176
+ description: string;
177
+ ownerPhone: string;
178
+ developerInfo?: string;
179
+ avatarUrl?: string;
180
+ baseUrl?: string;
181
+ clientType?: AgentClientType;
182
+ }
183
+ export interface RegistrationResult {
184
+ status: 'approved' | 'rejected' | 'timeout';
185
+ apiKey?: string;
186
+ agentId?: string;
187
+ agentName?: string;
188
+ }
189
+ export interface RegistrationStatus {
190
+ status: 'pending' | 'approved' | 'rejected';
191
+ agentName: string;
192
+ agentId?: string;
193
+ apiKey?: string;
194
+ }
package/dist/types.js ADDED
@@ -0,0 +1,31 @@
1
+ // ── Message ────────────────────────────────────────────────────────────
2
+ const DEFAULT_CAPABILITIES = {
3
+ supportsModelSwitch: false,
4
+ supportsPermissionMode: false,
5
+ supportsEffort: false,
6
+ supportsSessionState: false,
7
+ supportsInterrupt: false,
8
+ };
9
+ /**
10
+ * Capability map keyed by clientType. Add new agent types here.
11
+ * ALSO update the duplicate in app/src/types/index.ts (RN app can't
12
+ * import from core directly due to different bundler/runtime).
13
+ */
14
+ export const AGENT_CAPABILITIES = {
15
+ 'claude-code': {
16
+ supportsModelSwitch: true,
17
+ supportsPermissionMode: true,
18
+ supportsEffort: true,
19
+ supportsSessionState: true,
20
+ supportsInterrupt: true,
21
+ },
22
+ 'codex': {
23
+ supportsModelSwitch: false,
24
+ supportsPermissionMode: false,
25
+ supportsEffort: false,
26
+ supportsSessionState: true,
27
+ supportsInterrupt: true,
28
+ },
29
+ 'openclaw': { ...DEFAULT_CAPABILITIES },
30
+ 'generic': { ...DEFAULT_CAPABILITIES },
31
+ };
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@canonmsg/core",
3
+ "version": "0.2.2",
4
+ "description": "Canon core — shared types, REST client, SSE stream, and registration for Canon messaging",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "scripts": {
18
+ "build": "tsc",
19
+ "dev": "tsc --watch",
20
+ "prepublishOnly": "npm run build"
21
+ },
22
+ "engines": {
23
+ "node": ">=18.0.0"
24
+ },
25
+ "keywords": [
26
+ "canon",
27
+ "messaging",
28
+ "ai-agents",
29
+ "sse",
30
+ "rest-client"
31
+ ],
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "https://github.com/HeyBobChan/canon",
35
+ "directory": "packages/core"
36
+ },
37
+ "homepage": "https://github.com/HeyBobChan/canon/tree/main/packages/core",
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "devDependencies": {
42
+ "@types/node": "^22.0.0",
43
+ "typescript": "~5.7.0"
44
+ },
45
+ "license": "MIT"
46
+ }