@canonmsg/agent-sdk 0.6.0 → 0.7.1

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 CHANGED
@@ -77,6 +77,7 @@ The `message` event handler receives a context object with:
77
77
  | `replyFinal` | `(text: string, options?) => Promise<{ messageId: string }>` | Send the durable final reply for a turn |
78
78
  | `replyProgress` | `(text: string, options?) => Promise<{ turnId: string; durable: boolean; messageId: string \| null }>` | Update the live turn progress; add `durable: true` to also persist it |
79
79
  | `agent` | `AgentContext` | Trusted Canon agent identity and access context |
80
+ | `media` | `{ materialize, uploadFile, replyWithFile }` | Canon-managed access to real media bytes via `~/.canon/media-cache` plus local-file uploads back into Canon |
80
81
  | `session` | `SessionInfo \| undefined` | Per-conversation queue/session state when sessions are enabled |
81
82
  | `turn` | `TurnController \| undefined` | Live turn-state helpers for thinking/streaming/tool/waiting-input |
82
83
 
@@ -114,6 +115,29 @@ When `sessions.enabled` is on, the SDK serializes work per conversation and expo
114
115
 
115
116
  This is the easiest way to build agents that need per-conversation memory or queue awareness.
116
117
 
118
+ ## Media
119
+
120
+ Normalized Canon messages always expose `attachments[]` as the canonical media contract. The SDK also keeps `imageUrl` and `audioUrl` on messages for legacy compatibility, but new integrations should prefer `attachments`.
121
+
122
+ Use the handler `media` helpers when you need the actual file bytes:
123
+
124
+ ```typescript
125
+ agent.on('message', async ({ messages, media }) => {
126
+ const files = await media.materialize(messages[messages.length - 1]);
127
+ console.log(files[0]?.path); // ~/.canon/media-cache/<agent>/<conversation>/<message>/...
128
+ });
129
+ ```
130
+
131
+ - `media.materialize(message?)` downloads the message's attachments on demand into `~/.canon/media-cache`.
132
+ - `media.uploadFile(path, options?)` uploads a local file into the current Canon conversation and returns the canonical attachment metadata.
133
+ - `media.replyWithFile(path, text?, options?)` uploads a local file and sends it as the durable final Canon reply for the current turn.
134
+
135
+ The public helpers are also available from the Node-only subpath export:
136
+
137
+ ```typescript
138
+ import { materializeMessageMedia, uploadMediaFile } from '@canonmsg/agent-sdk/media';
139
+ ```
140
+
117
141
  ## Agent Registration
118
142
 
119
143
  Register a new agent using the static helpers (no API key needed):
@@ -2,6 +2,7 @@ import { CanonClient, FINAL_MESSAGE_HANDOFF_MS, initRTDBAuth, mergeWorkSessionCo
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { AuthManager } from './auth.js';
4
4
  import { Debouncer } from './debouncer.js';
5
+ import { materializeMessageMedia, uploadMediaFile, } from './media.js';
5
6
  import { PollingManager } from './polling.js';
6
7
  import { SessionManager } from './session-manager.js';
7
8
  const AUTO_MODE_THRESHOLD = 500;
@@ -153,12 +154,12 @@ export class CanonAgent {
153
154
  // Clear session state if enabled (uses cached IDs — no network call during shutdown)
154
155
  if (this.options.sessionState && this.agentId) {
155
156
  for (const id of this.cachedConversationIds) {
156
- clearSessionState(id, this.agentId).catch(() => { });
157
+ Promise.resolve(clearSessionState(id, this.agentId)).catch(() => { });
157
158
  }
158
159
  }
159
160
  if (this.agentId) {
160
161
  for (const id of this.cachedConversationIds) {
161
- clearTurnState(id, this.agentId).catch(() => { });
162
+ Promise.resolve(clearTurnState(id, this.agentId)).catch(() => { });
162
163
  }
163
164
  }
164
165
  await this.clearAgentRuntime();
@@ -197,7 +198,7 @@ export class CanonAgent {
197
198
  async clearAgentRuntime() {
198
199
  if (!this.agentId)
199
200
  return;
200
- await rtdbWrite(`/agent-runtime/${this.agentId}`, null).catch(() => { });
201
+ await Promise.resolve(rtdbWrite(`/agent-runtime/${this.agentId}`, null)).catch(() => { });
201
202
  }
202
203
  async handleMessages(conversationId, messages) {
203
204
  if (!this.handler) {
@@ -226,7 +227,7 @@ export class CanonAgent {
226
227
  if (!agentId)
227
228
  return;
228
229
  turnState = state;
229
- await writeTurnState(conversationId, agentId, {
230
+ await Promise.resolve(writeTurnState(conversationId, agentId, {
230
231
  turnId,
231
232
  state,
232
233
  queueDepth: queueDepth(),
@@ -236,7 +237,7 @@ export class CanonAgent {
236
237
  ...(state === 'completed' || state === 'interrupted' || state === 'idle'
237
238
  ? { completedAt: { '.sv': 'timestamp' } }
238
239
  : {}),
239
- }).catch(() => { });
240
+ })).catch(() => { });
240
241
  };
241
242
  const setLiveState = async (state, text, streamingStatus) => {
242
243
  await writeTurn(state);
@@ -277,6 +278,16 @@ export class CanonAgent {
277
278
  // Fetch hydrated history/context from API
278
279
  const page = await this.apiClient.getMessagesPage(conversationId, this.options.historyLimit);
279
280
  const history = page.messages;
281
+ const historyById = new Map(history.map((message) => [message.id, message]));
282
+ const hydratedMessages = messages.map((message) => {
283
+ const hydrated = historyById.get(message.id);
284
+ if (hydrated)
285
+ return hydrated;
286
+ return {
287
+ ...message,
288
+ attachments: message.attachments ?? [],
289
+ };
290
+ });
280
291
  // If sessions enabled, seed the session with fetched history
281
292
  if (this.sessionManager && session) {
282
293
  this.sessionManager.seedHistory(conversationId, history);
@@ -368,9 +379,43 @@ export class CanonAgent {
368
379
  ...options,
369
380
  });
370
381
  };
382
+ const uploadFile = (filePath, options) => uploadMediaFile(this.apiClient, conversationId, filePath, options);
383
+ const replyWithFile = async (filePath, text = '', options) => {
384
+ try {
385
+ await this.apiClient.setTyping(conversationId, true, 'typing');
386
+ }
387
+ catch { }
388
+ try {
389
+ const uploaded = await uploadFile(filePath, options);
390
+ const result = await this.apiClient.sendMessage(conversationId, text, {
391
+ ...(options?.replyTo ? { replyTo: options.replyTo } : {}),
392
+ ...(options?.replyToPosition != null
393
+ ? { replyToPosition: options.replyToPosition }
394
+ : {}),
395
+ ...(options?.mentions ? { mentions: options.mentions } : {}),
396
+ metadata: {
397
+ ...(options?.metadata ?? {}),
398
+ turnId,
399
+ turnSemantics: 'turn_complete',
400
+ turnComplete: true,
401
+ },
402
+ ...(options?.workSessionId ? { workSessionId: options.workSessionId } : {}),
403
+ contentType: uploaded.attachment.kind,
404
+ attachments: [uploaded.attachment],
405
+ });
406
+ await sleep(FINAL_MESSAGE_HANDOFF_MS);
407
+ return result;
408
+ }
409
+ finally {
410
+ try {
411
+ await this.apiClient.setTyping(conversationId, false);
412
+ }
413
+ catch { }
414
+ }
415
+ };
371
416
  // Invoke handler
372
417
  await this.handler({
373
- messages,
418
+ messages: hydratedMessages,
374
419
  history,
375
420
  conversationId,
376
421
  conversation,
@@ -390,6 +435,19 @@ export class CanonAgent {
390
435
  agent,
391
436
  workSession,
392
437
  activeWorkSessions,
438
+ media: {
439
+ materialize: (message = hydratedMessages[hydratedMessages.length - 1], options) => {
440
+ if (!message)
441
+ return Promise.resolve([]);
442
+ return materializeMessageMedia(message, {
443
+ agentId: agent.agentId,
444
+ conversationId,
445
+ ...(options ?? {}),
446
+ });
447
+ },
448
+ uploadFile,
449
+ replyWithFile,
450
+ },
393
451
  session: session
394
452
  ? {
395
453
  id: session.id,
@@ -461,7 +519,7 @@ export class CanonAgent {
461
519
  }
462
520
  catch { }
463
521
  if (agentId && !shouldPersistTurnState) {
464
- await clearTurnState(conversationId, agentId).catch(() => { });
522
+ await Promise.resolve(clearTurnState(conversationId, agentId)).catch(() => { });
465
523
  }
466
524
  }
467
525
  }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  export { CanonAgent } from './canon-agent.js';
2
2
  export { CanonApiError } from '@canonmsg/core';
3
3
  export { SessionManager } from './session-manager.js';
4
+ export { getMessageAttachments, inferUploadMimeType, materializeAttachment, materializeMessageMedia, uploadMediaFile, } from './media.js';
5
+ export type { MaterializeMediaOptions, MaterializedCanonAttachment, ReplyWithFileOptions, UploadMediaFileOptions, } from './media.js';
4
6
  export type { SessionConfig, Session } from './session-manager.js';
5
7
  export type { AgentContext, CanonMessage, CanonConversation, CanonResolvedWorkSession, CanonWorkSession, CanonWorkSessionContext, CanonWorkSessionConversationRole, CanonWorkSessionDisclosureMode, CanonWorkSessionParticipant, CanonWorkSessionStatus, CreateWorkSessionOptions, SendLinkedMessageOptions, SendLinkedMessageResult, SendMessageOptions, CreateConversationOptions, UpdateWorkSessionConversationOptions, } from '@canonmsg/core';
6
8
  export type { SDKMessage, SDKConversation, CanonAgentOptions, MessageHandler, MessageHandlerContext, ProgressMessageOptions, ProgressMessageResult, SessionInfo, SessionOptions, DeliveryMode, } from './types.js';
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export { CanonAgent } from './canon-agent.js';
2
2
  export { CanonApiError } from '@canonmsg/core';
3
3
  export { SessionManager } from './session-manager.js';
4
+ export { getMessageAttachments, inferUploadMimeType, materializeAttachment, materializeMessageMedia, uploadMediaFile, } from './media.js';
@@ -0,0 +1,32 @@
1
+ import { CanonClient, type CanonMessage, type MediaAttachment, type SendMessageOptions } from '@canonmsg/core';
2
+ export interface MaterializeMediaOptions {
3
+ agentId: string;
4
+ conversationId: string;
5
+ messageId: string;
6
+ rootDir?: string;
7
+ fetchImpl?: typeof fetch;
8
+ signal?: AbortSignal;
9
+ }
10
+ export interface UploadMediaFileOptions {
11
+ fileName?: string;
12
+ mimeType?: string;
13
+ }
14
+ export interface ReplyWithFileOptions extends Omit<SendMessageOptions, 'attachments' | 'contentType'>, UploadMediaFileOptions {
15
+ }
16
+ export interface MaterializedCanonAttachment extends MediaAttachment {
17
+ index: number;
18
+ path: string;
19
+ sourceUrl: string;
20
+ conversationId: string;
21
+ messageId: string;
22
+ }
23
+ export declare function getMessageAttachments(message: Pick<CanonMessage, 'attachments' | 'imageUrl' | 'audioUrl' | 'audioDurationMs'>): MediaAttachment[];
24
+ export declare function materializeAttachment(attachment: MediaAttachment, options: MaterializeMediaOptions & {
25
+ index?: number;
26
+ }): Promise<MaterializedCanonAttachment>;
27
+ export declare function materializeMessageMedia(message: Pick<CanonMessage, 'id' | 'attachments' | 'imageUrl' | 'audioUrl' | 'audioDurationMs'>, options: Omit<MaterializeMediaOptions, 'messageId'>): Promise<MaterializedCanonAttachment[]>;
28
+ export declare function inferUploadMimeType(filePath: string, overrideMimeType?: string): string;
29
+ export declare function uploadMediaFile(client: CanonClient, conversationId: string, filePath: string, options?: UploadMediaFileOptions): Promise<{
30
+ url: string;
31
+ attachment: MediaAttachment;
32
+ }>;
package/dist/media.js ADDED
@@ -0,0 +1,166 @@
1
+ import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
2
+ import { basename, dirname, extname, join } from 'node:path';
3
+ import { CANON_DIR, } from '@canonmsg/core';
4
+ const DEFAULT_MEDIA_CACHE_DIR = join(CANON_DIR, 'media-cache');
5
+ const EXTENSION_BY_MIME = {
6
+ 'application/json': 'json',
7
+ 'application/pdf': 'pdf',
8
+ 'application/zip': 'zip',
9
+ 'audio/mp4': 'm4a',
10
+ 'audio/mpeg': 'mp3',
11
+ 'audio/ogg': 'ogg',
12
+ 'audio/webm': 'webm',
13
+ 'audio/wav': 'wav',
14
+ 'image/gif': 'gif',
15
+ 'image/jpeg': 'jpg',
16
+ 'image/png': 'png',
17
+ 'image/webp': 'webp',
18
+ 'text/plain': 'txt',
19
+ };
20
+ const MIME_BY_EXTENSION = {
21
+ '.gif': 'image/gif',
22
+ '.jpeg': 'image/jpeg',
23
+ '.jpg': 'image/jpeg',
24
+ '.json': 'application/json',
25
+ '.m4a': 'audio/mp4',
26
+ '.mp3': 'audio/mpeg',
27
+ '.ogg': 'audio/ogg',
28
+ '.pdf': 'application/pdf',
29
+ '.png': 'image/png',
30
+ '.txt': 'text/plain',
31
+ '.wav': 'audio/wav',
32
+ '.webm': 'audio/webm',
33
+ '.webp': 'image/webp',
34
+ '.zip': 'application/zip',
35
+ };
36
+ function sanitizeSegment(value, fallback) {
37
+ const sanitized = value.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
38
+ return sanitized || fallback;
39
+ }
40
+ function sanitizeFileName(fileName, fallback) {
41
+ const sanitized = basename(fileName).replace(/[^a-zA-Z0-9._-]+/g, '_');
42
+ return sanitized || fallback;
43
+ }
44
+ function inferExtension(input) {
45
+ const explicitExtension = extname(input.attachment.fileName ?? '').toLowerCase();
46
+ if (explicitExtension) {
47
+ return explicitExtension.replace(/[^a-z0-9.]/g, '') || '.bin';
48
+ }
49
+ const mimeType = input.responseMimeType ?? input.attachment.mimeType ?? '';
50
+ const byMime = EXTENSION_BY_MIME[mimeType.toLowerCase()];
51
+ if (byMime) {
52
+ return `.${byMime}`;
53
+ }
54
+ const urlPath = input.attachment.url.split('?')[0] ?? '';
55
+ const urlExtension = extname(urlPath).toLowerCase();
56
+ if (urlExtension) {
57
+ return urlExtension.replace(/[^a-z0-9.]/g, '') || '.bin';
58
+ }
59
+ return '.bin';
60
+ }
61
+ function buildCachePath(input) {
62
+ const rootDir = input.rootDir ?? DEFAULT_MEDIA_CACHE_DIR;
63
+ const extension = inferExtension({
64
+ attachment: input.attachment,
65
+ responseMimeType: input.responseMimeType,
66
+ });
67
+ const fallbackName = `${input.attachment.kind}-${input.index}${extension}`;
68
+ const preferredName = input.attachment.fileName
69
+ ? sanitizeFileName(input.attachment.fileName, fallbackName)
70
+ : fallbackName;
71
+ const fileName = extname(preferredName) ? preferredName : `${preferredName}${extension}`;
72
+ return join(rootDir, sanitizeSegment(input.agentId, 'agent'), sanitizeSegment(input.conversationId, 'conversation'), sanitizeSegment(input.messageId, 'message'), `${String(input.index).padStart(2, '0')}-${fileName}`);
73
+ }
74
+ function ensureFetch(fetchImpl) {
75
+ if (fetchImpl)
76
+ return fetchImpl;
77
+ if (typeof fetch === 'function')
78
+ return fetch;
79
+ throw new Error('Global fetch is unavailable; provide fetchImpl explicitly.');
80
+ }
81
+ async function fileExists(path) {
82
+ try {
83
+ const info = await stat(path);
84
+ return info.isFile();
85
+ }
86
+ catch {
87
+ return false;
88
+ }
89
+ }
90
+ export function getMessageAttachments(message) {
91
+ if (Array.isArray(message.attachments) && message.attachments.length > 0) {
92
+ return message.attachments;
93
+ }
94
+ if (message.audioUrl) {
95
+ return [{
96
+ kind: 'audio',
97
+ url: message.audioUrl,
98
+ ...(typeof message.audioDurationMs === 'number'
99
+ ? { durationMs: message.audioDurationMs }
100
+ : {}),
101
+ }];
102
+ }
103
+ if (message.imageUrl) {
104
+ return [{
105
+ kind: 'image',
106
+ url: message.imageUrl,
107
+ }];
108
+ }
109
+ return [];
110
+ }
111
+ export async function materializeAttachment(attachment, options) {
112
+ const path = buildCachePath({
113
+ agentId: options.agentId,
114
+ conversationId: options.conversationId,
115
+ messageId: options.messageId,
116
+ attachment,
117
+ index: options.index ?? 0,
118
+ rootDir: options.rootDir,
119
+ });
120
+ await mkdir(dirname(path), { recursive: true });
121
+ let responseMimeType = null;
122
+ if (!(await fileExists(path))) {
123
+ const fetchImpl = ensureFetch(options.fetchImpl);
124
+ const response = await fetchImpl(attachment.url, {
125
+ signal: options.signal,
126
+ });
127
+ if (!response.ok) {
128
+ throw new Error(`Failed to download Canon media (${response.status} ${response.statusText})`);
129
+ }
130
+ responseMimeType = response.headers.get('content-type');
131
+ const body = Buffer.from(await response.arrayBuffer());
132
+ await writeFile(path, body);
133
+ }
134
+ return {
135
+ ...attachment,
136
+ index: options.index ?? 0,
137
+ path,
138
+ sourceUrl: attachment.url,
139
+ conversationId: options.conversationId,
140
+ messageId: options.messageId,
141
+ ...(attachment.mimeType
142
+ ? {}
143
+ : responseMimeType
144
+ ? { mimeType: responseMimeType }
145
+ : {}),
146
+ };
147
+ }
148
+ export async function materializeMessageMedia(message, options) {
149
+ const attachments = getMessageAttachments(message);
150
+ return Promise.all(attachments.map((attachment, index) => materializeAttachment(attachment, {
151
+ ...options,
152
+ messageId: message.id,
153
+ index,
154
+ })));
155
+ }
156
+ export function inferUploadMimeType(filePath, overrideMimeType) {
157
+ if (overrideMimeType)
158
+ return overrideMimeType;
159
+ return MIME_BY_EXTENSION[extname(filePath).toLowerCase()] ?? 'application/octet-stream';
160
+ }
161
+ export async function uploadMediaFile(client, conversationId, filePath, options) {
162
+ const buffer = await readFile(filePath);
163
+ const mimeType = inferUploadMimeType(filePath, options?.mimeType);
164
+ const fileName = options?.fileName ?? basename(filePath);
165
+ return client.uploadMedia(conversationId, buffer.toString('base64'), mimeType, fileName);
166
+ }
package/dist/types.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export type { AgentClientType, CanonMessage, CanonConversation, AgentContext, CanonResolvedWorkSession, CanonWorkSession, CanonWorkSessionContext, CanonWorkSessionConversationRole, CanonWorkSessionDisclosureMode, CanonWorkSessionParticipant, CanonWorkSessionStatus, CreateWorkSessionOptions, SendLinkedMessageOptions, SendLinkedMessageResult, SendMessageOptions, CreateConversationOptions, TurnLifecycleState, UpdateWorkSessionConversationOptions, } from '@canonmsg/core';
2
2
  import type { CanonMessage, CanonConversation, CreateWorkSessionOptions, SendMessageOptions, UpdateWorkSessionConversationOptions } from '@canonmsg/core';
3
+ import type { MaterializeMediaOptions, MaterializedCanonAttachment, ReplyWithFileOptions, UploadMediaFileOptions } from './media.js';
3
4
  export type SDKMessage = CanonMessage;
4
5
  export type SDKConversation = CanonConversation;
5
6
  export interface ProgressMessageOptions extends SendMessageOptions {
@@ -73,6 +74,17 @@ export interface MessageHandlerContext {
73
74
  workSession?: import('@canonmsg/core').CanonWorkSessionContext | null;
74
75
  /** All active Canon work sessions currently linked to this conversation. */
75
76
  activeWorkSessions?: import('@canonmsg/core').CanonWorkSessionContext[];
77
+ /** Canon-managed local media access for the current conversation. */
78
+ media: {
79
+ materialize: (message?: SDKMessage, options?: Omit<MaterializeMediaOptions, 'agentId' | 'conversationId' | 'messageId'>) => Promise<MaterializedCanonAttachment[]>;
80
+ uploadFile: (filePath: string, options?: UploadMediaFileOptions) => Promise<{
81
+ url: string;
82
+ attachment: import('@canonmsg/core').MediaAttachment;
83
+ }>;
84
+ replyWithFile: (filePath: string, text?: string, options?: ReplyWithFileOptions) => Promise<{
85
+ messageId: string;
86
+ }>;
87
+ };
76
88
  /** Per-conversation session state. Present when sessions are enabled. */
77
89
  session?: SessionInfo;
78
90
  /** Turn lifecycle helpers for live-work rendering and progress reporting. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/agent-sdk",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "description": "Canon Agent SDK — build AI agents that participate in Canon conversations",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -9,6 +9,10 @@
9
9
  ".": {
10
10
  "import": "./dist/index.js",
11
11
  "types": "./dist/index.d.ts"
12
+ },
13
+ "./media": {
14
+ "import": "./dist/media.js",
15
+ "types": "./dist/media.d.ts"
12
16
  }
13
17
  },
14
18
  "files": [
@@ -17,20 +21,22 @@
17
21
  "scripts": {
18
22
  "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc",
19
23
  "dev": "tsc --watch",
24
+ "test": "vitest run",
20
25
  "prepack": "npm run build"
21
26
  },
22
27
  "engines": {
23
28
  "node": ">=18.0.0"
24
29
  },
25
30
  "dependencies": {
26
- "@canonmsg/core": "^0.6.0"
31
+ "@canonmsg/core": "^0.7.0"
27
32
  },
28
33
  "publishConfig": {
29
34
  "access": "public"
30
35
  },
31
36
  "devDependencies": {
32
37
  "@types/node": "^22.0.0",
33
- "typescript": "~5.7.0"
38
+ "typescript": "~5.7.0",
39
+ "vitest": "^3.0.0"
34
40
  },
35
41
  "license": "MIT"
36
42
  }