@canonmsg/agent-sdk 8.4.0 → 8.6.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 +2 -2
- package/dist/bounded-file-read.d.ts +14 -0
- package/dist/bounded-file-read.js +34 -0
- package/dist/canon-agent.d.ts +3 -3
- package/dist/canon-agent.js +10 -4
- package/dist/index.d.ts +4 -4
- package/dist/index.js +1 -1
- package/dist/media.d.ts +41 -6
- package/dist/media.js +322 -19
- package/dist/types.d.ts +3 -6
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -356,8 +356,8 @@ agent.on('message', async ({ messages, media }) => {
|
|
|
356
356
|
});
|
|
357
357
|
```
|
|
358
358
|
|
|
359
|
-
- `media.materialize(message?)`
|
|
360
|
-
- `media.uploadFile(path, options?)`
|
|
359
|
+
- `media.materialize(message?, options?)` streams attachments on demand into `~/.canon/media-cache`. Automatic materialization is capped at 10 MiB per attachment before and during the download. A runtime that deliberately needs a larger input can pass `maxBytes`, up to 100 MiB. Failed or oversized attachments are reported through optional `onError`; valid siblings are still returned. Cancellation still aborts the complete operation. Cache files are published through an atomic rename. The cache is persistent and currently has no aggregate quota or automatic eviction, so hosted-agent operators should monitor or periodically clear it.
|
|
360
|
+
- `media.uploadFile(path, options?)` streams a local file (up to 100 MiB) through Canon's private resumable-capable upload lane and returns `{ uploadId, url, attachment }`. The attachment carries the same server-issued `uploadId`, allowing a subsequent durable message/card write to retain the temporary upload. File bytes are never base64-buffered in the SDK. The current helper uses one streamed PUT; a failed application-level attempt starts a new Canon session rather than resuming an acknowledged byte range in the old session.
|
|
361
361
|
- `media.replyWithFile(path, text?, options?)` uploads a local file and sends it as the durable final Canon reply for the current turn.
|
|
362
362
|
|
|
363
363
|
GIFs are regular image attachments. Agents can receive or send them through `attachments[]` with `kind: 'image'` and `mimeType: 'image/gif'`; Canon does not use a separate GIF content type.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { FileHandle } from 'node:fs/promises';
|
|
2
|
+
export type BoundedFileReadFailure = 'not-file' | 'too-large';
|
|
3
|
+
export declare class BoundedFileReadError extends Error {
|
|
4
|
+
readonly failure: BoundedFileReadFailure;
|
|
5
|
+
constructor(failure: BoundedFileReadFailure);
|
|
6
|
+
}
|
|
7
|
+
type ReadableFileHandle = Pick<FileHandle, 'stat' | 'read'>;
|
|
8
|
+
/**
|
|
9
|
+
* Read a regular file through an already-open handle without ever requesting
|
|
10
|
+
* more than maxBytes + 1. The extra byte detects a file that grew after stat
|
|
11
|
+
* without allowing an unbounded path-based read.
|
|
12
|
+
*/
|
|
13
|
+
export declare function readBoundedRegularFileHandle(handle: ReadableFileHandle, maxBytes: number): Promise<Buffer>;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export class BoundedFileReadError extends Error {
|
|
2
|
+
failure;
|
|
3
|
+
constructor(failure) {
|
|
4
|
+
super(failure);
|
|
5
|
+
this.failure = failure;
|
|
6
|
+
this.name = 'BoundedFileReadError';
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Read a regular file through an already-open handle without ever requesting
|
|
11
|
+
* more than maxBytes + 1. The extra byte detects a file that grew after stat
|
|
12
|
+
* without allowing an unbounded path-based read.
|
|
13
|
+
*/
|
|
14
|
+
export async function readBoundedRegularFileHandle(handle, maxBytes) {
|
|
15
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
|
|
16
|
+
throw new Error('maxBytes must be a positive safe integer');
|
|
17
|
+
}
|
|
18
|
+
const fileStat = await handle.stat();
|
|
19
|
+
if (!fileStat.isFile())
|
|
20
|
+
throw new BoundedFileReadError('not-file');
|
|
21
|
+
if (fileStat.size > maxBytes)
|
|
22
|
+
throw new BoundedFileReadError('too-large');
|
|
23
|
+
const buffer = Buffer.allocUnsafe(maxBytes + 1);
|
|
24
|
+
let totalBytes = 0;
|
|
25
|
+
while (totalBytes < buffer.byteLength) {
|
|
26
|
+
const { bytesRead } = await handle.read(buffer, totalBytes, buffer.byteLength - totalBytes, totalBytes);
|
|
27
|
+
if (bytesRead === 0)
|
|
28
|
+
break;
|
|
29
|
+
totalBytes += bytesRead;
|
|
30
|
+
}
|
|
31
|
+
if (totalBytes > maxBytes)
|
|
32
|
+
throw new BoundedFileReadError('too-large');
|
|
33
|
+
return buffer.subarray(0, totalBytes);
|
|
34
|
+
}
|
package/dist/canon-agent.d.ts
CHANGED
|
@@ -156,9 +156,9 @@ export declare class CanonAgent {
|
|
|
156
156
|
* Outcome depends on the target's `groupJoinPolicy` and the relationship
|
|
157
157
|
* graph:
|
|
158
158
|
* - `{ status: 'added' }` — the member was added immediately.
|
|
159
|
-
* - `{ status: 'pending', requestId }` — the target
|
|
160
|
-
*
|
|
161
|
-
*
|
|
159
|
+
* - `{ status: 'pending', requestId, requirements }` — the target needs
|
|
160
|
+
* policy approval, owner session setup, or both. The server created one
|
|
161
|
+
* `group_invite`; membership activates after every requirement is met
|
|
162
162
|
* (you can listen for `contact.approved` SSE events to know when).
|
|
163
163
|
*
|
|
164
164
|
* Throws `CanonApiError` for hard failures (block, inactive, owner-only,
|
package/dist/canon-agent.js
CHANGED
|
@@ -791,9 +791,9 @@ export class CanonAgent {
|
|
|
791
791
|
* Outcome depends on the target's `groupJoinPolicy` and the relationship
|
|
792
792
|
* graph:
|
|
793
793
|
* - `{ status: 'added' }` — the member was added immediately.
|
|
794
|
-
* - `{ status: 'pending', requestId }` — the target
|
|
795
|
-
*
|
|
796
|
-
*
|
|
794
|
+
* - `{ status: 'pending', requestId, requirements }` — the target needs
|
|
795
|
+
* policy approval, owner session setup, or both. The server created one
|
|
796
|
+
* `group_invite`; membership activates after every requirement is met
|
|
797
797
|
* (you can listen for `contact.approved` SSE events to know when).
|
|
798
798
|
*
|
|
799
799
|
* Throws `CanonApiError` for hard failures (block, inactive, owner-only,
|
|
@@ -2028,7 +2028,10 @@ export class CanonAgent {
|
|
|
2028
2028
|
return result;
|
|
2029
2029
|
}
|
|
2030
2030
|
};
|
|
2031
|
-
const uploadFile = (filePath, options) => uploadMediaFile(this.apiClient, conversationId, filePath,
|
|
2031
|
+
const uploadFile = (filePath, options) => uploadMediaFile(this.apiClient, conversationId, filePath, {
|
|
2032
|
+
...(options ?? {}),
|
|
2033
|
+
signal: options?.signal ?? abortController.signal,
|
|
2034
|
+
});
|
|
2032
2035
|
const replyWithFile = async (filePath, text = '', options) => {
|
|
2033
2036
|
throwIfAborted();
|
|
2034
2037
|
try {
|
|
@@ -2057,6 +2060,8 @@ export class CanonAgent {
|
|
|
2057
2060
|
...(options?.fileName ? { fileName: options.fileName } : {}),
|
|
2058
2061
|
...(options?.mimeType ? { mimeType: options.mimeType } : {}),
|
|
2059
2062
|
...(options?.durationMs != null ? { durationMs: options.durationMs } : {}),
|
|
2063
|
+
...(options?.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
|
|
2064
|
+
signal: options?.signal ?? abortController.signal,
|
|
2060
2065
|
});
|
|
2061
2066
|
durableMessageSequence += 1;
|
|
2062
2067
|
await sleep(FINAL_MESSAGE_HANDOFF_MS);
|
|
@@ -2108,6 +2113,7 @@ export class CanonAgent {
|
|
|
2108
2113
|
agentId: agent.agentId,
|
|
2109
2114
|
conversationId,
|
|
2110
2115
|
...(options ?? {}),
|
|
2116
|
+
signal: options?.signal ?? abortController.signal,
|
|
2111
2117
|
});
|
|
2112
2118
|
},
|
|
2113
2119
|
uploadFile,
|
package/dist/index.d.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
export { CanonAgent } from './canon-agent.js';
|
|
2
2
|
export type { AgentContactsAPI, AgentConversationsAPI, AgentUsersAPI } from './canon-agent.js';
|
|
3
3
|
export { ApprovalManager, buildApprovalOutcome, buildApprovalReply, buildApprovalRequest, CanonApiError, DEFAULT_APPROVAL_CONFIG, generateApprovalId, HOST_ADMISSION_ACTION_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, parseApprovalReplyMetadata, parseApprovalRequestMetadata, parseSessionRule, redactSecrets, } from '@canonmsg/core';
|
|
4
|
-
export type { ApprovalConfig, ApprovalNativeRequestMetadata, ApprovalOutcomeMetadata, ApprovalReplyMetadata, ApprovalRequestCategory, ApprovalRequestDetail, ApprovalRequestMetadata, ApprovalResult, ApprovalRisk, CanonContact, CanonRuntimeActivityItem, CanonRuntimeActivityKind, CanonRuntimeActivityStatus, CanonRuntimeTurnModeActivation, CanonRuntimeTurnModeDescriptor, CanonRuntimeTurnModeScope, CanonRuntimeFact, CanonRuntimeFactGroup, CanonResolveAdmissionResult, ContactAddedPayload, ContactCardPayload, ContactRemovedPayload, ContactSource, HostAdmissionActionCapabilities, ParticipationSuppressedPayload, ResolveAdmissionTargetInput, ResolvedAdmissionState, ResolvedAdmissionTargetSummary, ResolvedTargetAdmissionPayload, RuntimeInputAnswers, RuntimeInputChoice, RuntimeInputKind, RuntimeInputNativeMetadata, RuntimeInputQuestion, SessionRule, } from '@canonmsg/core';
|
|
4
|
+
export type { ApprovalConfig, ApprovalNativeRequestMetadata, ApprovalOutcomeMetadata, ApprovalReplyMetadata, ApprovalRequestCategory, ApprovalRequestDetail, ApprovalRequestMetadata, ApprovalResult, ApprovalRisk, CanonContact, CanonRuntimeActivityItem, CanonRuntimeActivityKind, CanonRuntimeActivityStatus, CanonRuntimeTurnModeActivation, CanonRuntimeTurnModeDescriptor, CanonRuntimeTurnModeScope, CanonRuntimeFact, CanonRuntimeFactGroup, CanonResolveAdmissionResult, ContactAddedPayload, ContactCardPayload, ContactRemovedPayload, ContactSource, HostAdmissionActionCapabilities, ParticipationSuppressedPayload, ResolveAdmissionTargetInput, ResolvedAdmissionState, ResolvedAdmissionTargetSummary, ResolvedTargetAdmissionPayload, ResumableMediaUploadResult, RuntimeInputAnswers, RuntimeInputChoice, RuntimeInputKind, RuntimeInputNativeMetadata, RuntimeInputQuestion, SessionRule, } from '@canonmsg/core';
|
|
5
5
|
export { SessionManager } from './session-manager.js';
|
|
6
|
-
export { DEFAULT_MEDIA_CACHE_DIR, getCodexImagePath, getMessageAttachments, inferUploadMimeType, isAnthropicImageAttachment, materializeAttachment, materializeMessageMedia, materializeReplyContextMedia, resolveAttachmentMimeType, sendMediaFileMessage, toAnthropicImageBlock, uploadMediaFile, } from './media.js';
|
|
7
|
-
export type { AnthropicImageBlock, AnthropicImageMimeType, MaterializeMediaOptions, MaterializedCanonAttachment, MaterializedCanonReplyContext, ReplyWithFileOptions, UploadMediaFileOptions, } from './media.js';
|
|
6
|
+
export { DEFAULT_ANTHROPIC_REQUEST_HEADROOM_BYTES, DEFAULT_MEDIA_CACHE_DIR, DEFAULT_MEDIA_MATERIALIZATION_BYTES, MAX_ANTHROPIC_IMAGE_RAW_BYTES, MAX_ANTHROPIC_REQUEST_BYTES, MAX_CANON_MEDIA_BYTES, getCodexImagePath, getMessageAttachments, inferUploadMimeType, isAnthropicImageAttachment, materializeAttachment, materializeMessageMedia, materializeReplyContextMedia, resolveAttachmentMimeType, sendMediaFileMessage, toAnthropicImageBlock, toAnthropicImageBlocksWithinBudget, uploadMediaFile, } from './media.js';
|
|
7
|
+
export type { AnthropicImageBlock, AnthropicImageBudgetOptions, AnthropicImageMimeType, MaterializeMediaOptions, MaterializedCanonAttachment, MaterializedCanonReplyContext, ReplyWithFileOptions, UploadMediaFileOptions, } from './media.js';
|
|
8
8
|
export type { SessionConfig, Session } from './session-manager.js';
|
|
9
9
|
export type { CanonAgentTurnVerbosityOption } from './turn-verbosity-option.js';
|
|
10
|
-
export type { AgentContext, CanonGroupContext, CanonKnownRecentParticipant, CanonMembershipChange, CanonContactRequest, CanonMessage, CanonConversation, CanonConversationsPage, CanonConversationsPageOptions, CanonReplyContext, CanonSelfContext, CanonTurnContextV2, CanonRuntimeDescriptor, MessageUpdatedPayload, SendContextualMessageOptions, SendContextualMessageResult, SendContextualSelfContextInput, SendMessageOptions, CreateConversationOptions, CreateConversationResult, DirectSessionSelection, TurnVerbosity, TurnVerbosityConfig, } from '@canonmsg/core';
|
|
10
|
+
export type { AgentContext, CanonGroupContext, CanonKnownRecentParticipant, CanonMembershipChange, CanonContactRequest, GroupInviteRequirements, CanonMessage, CanonConversation, CanonConversationsPage, CanonConversationsPageOptions, CanonReplyContext, CanonSelfContext, CanonTurnContextV2, CanonRuntimeDescriptor, MessageUpdatedPayload, SendContextualMessageOptions, SendContextualMessageResult, SendContextualSelfContextInput, SendMessageOptions, CreateConversationOptions, CreateConversationResult, DirectSessionSelection, TurnVerbosity, TurnVerbosityConfig, } from '@canonmsg/core';
|
|
11
11
|
export type { CanonAgentConnectionOptions, CanonAgentOptions, ContactAddedHandler, ContactRemovedHandler, ContactRequestHandler, FinalMessageResult, MessageHandler, MessageHandlerContext, MessageUpdatedHandler, ParticipationSuppressedHandler, ProgressMessageOptions, ProgressMessageResult, ReachOutOptions, ReachOutResult, RuntimeApprovalRequest, RuntimeInputRequest, RuntimeInputResult, RuntimeControlSurface, RuntimePrimitiveContext, RuntimePrimitiveHandler, RuntimePrimitiveHandlers, SessionInfo, SessionOptions, DeliveryMode, } from './types.js';
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { CanonAgent } from './canon-agent.js';
|
|
2
2
|
export { ApprovalManager, buildApprovalOutcome, buildApprovalReply, buildApprovalRequest, CanonApiError, DEFAULT_APPROVAL_CONFIG, generateApprovalId, HOST_ADMISSION_ACTION_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, parseApprovalReplyMetadata, parseApprovalRequestMetadata, parseSessionRule, redactSecrets, } from '@canonmsg/core';
|
|
3
3
|
export { SessionManager } from './session-manager.js';
|
|
4
|
-
export { DEFAULT_MEDIA_CACHE_DIR, getCodexImagePath, getMessageAttachments, inferUploadMimeType, isAnthropicImageAttachment, materializeAttachment, materializeMessageMedia, materializeReplyContextMedia, resolveAttachmentMimeType, sendMediaFileMessage, toAnthropicImageBlock, uploadMediaFile, } from './media.js';
|
|
4
|
+
export { DEFAULT_ANTHROPIC_REQUEST_HEADROOM_BYTES, DEFAULT_MEDIA_CACHE_DIR, DEFAULT_MEDIA_MATERIALIZATION_BYTES, MAX_ANTHROPIC_IMAGE_RAW_BYTES, MAX_ANTHROPIC_REQUEST_BYTES, MAX_CANON_MEDIA_BYTES, getCodexImagePath, getMessageAttachments, inferUploadMimeType, isAnthropicImageAttachment, materializeAttachment, materializeMessageMedia, materializeReplyContextMedia, resolveAttachmentMimeType, sendMediaFileMessage, toAnthropicImageBlock, toAnthropicImageBlocksWithinBudget, uploadMediaFile, } from './media.js';
|
package/dist/media.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CanonClient, type CanonReplyContext, type CanonMessage, type MediaAttachment, type SendMessageOptions } from '@canonmsg/core';
|
|
1
|
+
import { CanonClient, type CanonReplyContext, type CanonMessage, type MediaAttachment, type ResumableMediaUploadResult, type SendMessageOptions } from '@canonmsg/core';
|
|
2
2
|
export interface MaterializeMediaOptions {
|
|
3
3
|
agentId: string;
|
|
4
4
|
conversationId: string;
|
|
@@ -6,11 +6,20 @@ export interface MaterializeMediaOptions {
|
|
|
6
6
|
rootDir?: string;
|
|
7
7
|
fetchImpl?: typeof fetch;
|
|
8
8
|
signal?: AbortSignal;
|
|
9
|
+
/**
|
|
10
|
+
* Maximum bytes written to disk per attachment. Automatic materialization
|
|
11
|
+
* defaults to 10 MiB; runtimes may opt in up to Canon's 100 MiB upload cap.
|
|
12
|
+
*/
|
|
13
|
+
maxBytes?: number;
|
|
14
|
+
/** Called for each attachment that could not be materialized. */
|
|
15
|
+
onError?: (error: unknown, attachment: MediaAttachment, index: number) => void;
|
|
9
16
|
}
|
|
10
17
|
export interface UploadMediaFileOptions {
|
|
11
18
|
fileName?: string;
|
|
12
19
|
mimeType?: string;
|
|
13
20
|
durationMs?: number;
|
|
21
|
+
fetchImpl?: typeof fetch;
|
|
22
|
+
signal?: AbortSignal;
|
|
14
23
|
}
|
|
15
24
|
export interface ReplyWithFileOptions extends Omit<SendMessageOptions, 'attachments' | 'contentType'>, UploadMediaFileOptions {
|
|
16
25
|
}
|
|
@@ -44,7 +53,27 @@ export interface AnthropicImageBlock {
|
|
|
44
53
|
data: string;
|
|
45
54
|
};
|
|
46
55
|
}
|
|
56
|
+
export interface AnthropicImageBudgetOptions {
|
|
57
|
+
/** Text content that shares the Anthropic request with the image blocks. */
|
|
58
|
+
promptText: string;
|
|
59
|
+
/** Optional lower per-image limit for providers with smaller image caps. */
|
|
60
|
+
maxImageRawBytes?: number;
|
|
61
|
+
/** Optional lower total request ceiling. Defaults to Anthropic's 32 MB limit. */
|
|
62
|
+
maxRequestBytes?: number;
|
|
63
|
+
/** Bytes reserved for SDK/request framing outside these content blocks. */
|
|
64
|
+
requestHeadroomBytes?: number;
|
|
65
|
+
}
|
|
47
66
|
export declare const DEFAULT_MEDIA_CACHE_DIR: string;
|
|
67
|
+
export declare const DEFAULT_MEDIA_MATERIALIZATION_BYTES: number;
|
|
68
|
+
export declare const MAX_CANON_MEDIA_BYTES: number;
|
|
69
|
+
/**
|
|
70
|
+
* Seven MiB expands to just under 10 MB when base64-encoded. This is suitable
|
|
71
|
+
* for the direct Anthropic transport used by the Claude Code host. Providers
|
|
72
|
+
* with smaller encoded-image limits must pass a lower maxImageRawBytes value.
|
|
73
|
+
*/
|
|
74
|
+
export declare const MAX_ANTHROPIC_IMAGE_RAW_BYTES: number;
|
|
75
|
+
export declare const MAX_ANTHROPIC_REQUEST_BYTES = 32000000;
|
|
76
|
+
export declare const DEFAULT_ANTHROPIC_REQUEST_HEADROOM_BYTES: number;
|
|
48
77
|
export declare function getMessageAttachments(message: Pick<CanonMessage, 'attachments'>): MediaAttachment[];
|
|
49
78
|
export declare function materializeAttachment(attachment: MediaAttachment, options: MaterializeMediaOptions & {
|
|
50
79
|
index?: number;
|
|
@@ -52,10 +81,7 @@ export declare function materializeAttachment(attachment: MediaAttachment, optio
|
|
|
52
81
|
export declare function materializeMessageMedia(message: Pick<CanonMessage, 'id' | 'attachments'>, options: Omit<MaterializeMediaOptions, 'messageId'>): Promise<MaterializedCanonAttachment[]>;
|
|
53
82
|
export declare function materializeReplyContextMedia(replyContext: CanonReplyContext | null, options: Omit<MaterializeMediaOptions, 'messageId'>): Promise<MaterializedCanonReplyContext>;
|
|
54
83
|
export declare function inferUploadMimeType(filePath: string, overrideMimeType?: string): string;
|
|
55
|
-
export declare function uploadMediaFile(client: CanonClient, conversationId: string, filePath: string, options?: UploadMediaFileOptions): Promise<
|
|
56
|
-
url: string;
|
|
57
|
-
attachment: MediaAttachment;
|
|
58
|
-
}>;
|
|
84
|
+
export declare function uploadMediaFile(client: CanonClient, conversationId: string, filePath: string, options?: UploadMediaFileOptions): Promise<ResumableMediaUploadResult>;
|
|
59
85
|
export declare function sendMediaFileMessage(client: CanonClient, conversationId: string, filePath: string, text?: string, options?: ReplyWithFileOptions): Promise<{
|
|
60
86
|
messageId: string;
|
|
61
87
|
}>;
|
|
@@ -75,7 +101,16 @@ export declare function isAnthropicImageAttachment(attachment: MaterializedCanon
|
|
|
75
101
|
* Callers should first check `isAnthropicImageAttachment`; this function
|
|
76
102
|
* throws if the MIME type is not supported.
|
|
77
103
|
*/
|
|
78
|
-
export declare function toAnthropicImageBlock(attachment: MaterializedCanonAttachment
|
|
104
|
+
export declare function toAnthropicImageBlock(attachment: MaterializedCanonAttachment, options?: {
|
|
105
|
+
maxRawBytes?: number;
|
|
106
|
+
}): Promise<AnthropicImageBlock>;
|
|
107
|
+
/**
|
|
108
|
+
* Convert only the supported images that fit both the direct-image limit and
|
|
109
|
+
* the aggregate request budget. Skipped images remain represented by the
|
|
110
|
+
* paths already rendered into promptText; one unavailable image never drops
|
|
111
|
+
* valid siblings.
|
|
112
|
+
*/
|
|
113
|
+
export declare function toAnthropicImageBlocksWithinBudget(attachments: ReadonlyArray<MaterializedCanonAttachment>, options: AnthropicImageBudgetOptions): Promise<AnthropicImageBlock[]>;
|
|
79
114
|
/**
|
|
80
115
|
* Return the local path to pass to Codex via its `-i/--image` flag when the
|
|
81
116
|
* attachment is an image Codex can consume. Codex accepts the same MIME types
|
package/dist/media.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createReadStream, createWriteStream } from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { mkdir, open, rename, stat, unlink } from 'node:fs/promises';
|
|
2
4
|
import { basename, dirname, extname, join } from 'node:path';
|
|
5
|
+
import { Readable, Transform } from 'node:stream';
|
|
6
|
+
import { pipeline } from 'node:stream/promises';
|
|
3
7
|
import { CANON_DIR, renderCanonHostInboundContent, } from '@canonmsg/core';
|
|
8
|
+
import { BoundedFileReadError, readBoundedRegularFileHandle, } from './bounded-file-read.js';
|
|
4
9
|
const ANTHROPIC_IMAGE_MIME_TYPES = new Set([
|
|
5
10
|
'image/jpeg',
|
|
6
11
|
'image/png',
|
|
@@ -8,6 +13,16 @@ const ANTHROPIC_IMAGE_MIME_TYPES = new Set([
|
|
|
8
13
|
'image/webp',
|
|
9
14
|
]);
|
|
10
15
|
export const DEFAULT_MEDIA_CACHE_DIR = join(CANON_DIR, 'media-cache');
|
|
16
|
+
export const DEFAULT_MEDIA_MATERIALIZATION_BYTES = 10 * 1024 * 1024;
|
|
17
|
+
export const MAX_CANON_MEDIA_BYTES = 100 * 1024 * 1024;
|
|
18
|
+
/**
|
|
19
|
+
* Seven MiB expands to just under 10 MB when base64-encoded. This is suitable
|
|
20
|
+
* for the direct Anthropic transport used by the Claude Code host. Providers
|
|
21
|
+
* with smaller encoded-image limits must pass a lower maxImageRawBytes value.
|
|
22
|
+
*/
|
|
23
|
+
export const MAX_ANTHROPIC_IMAGE_RAW_BYTES = 7 * 1024 * 1024;
|
|
24
|
+
export const MAX_ANTHROPIC_REQUEST_BYTES = 32_000_000;
|
|
25
|
+
export const DEFAULT_ANTHROPIC_REQUEST_HEADROOM_BYTES = 4 * 1024 * 1024;
|
|
11
26
|
const EXTENSION_BY_MIME = {
|
|
12
27
|
'application/json': 'json',
|
|
13
28
|
'application/pdf': 'pdf',
|
|
@@ -100,6 +115,64 @@ async function fileExists(path) {
|
|
|
100
115
|
return false;
|
|
101
116
|
}
|
|
102
117
|
}
|
|
118
|
+
function resolveMaxBytes(value) {
|
|
119
|
+
if (value === undefined)
|
|
120
|
+
return DEFAULT_MEDIA_MATERIALIZATION_BYTES;
|
|
121
|
+
if (!Number.isSafeInteger(value) || value <= 0 || value > MAX_CANON_MEDIA_BYTES) {
|
|
122
|
+
throw new Error(`maxBytes must be an integer between 1 and ${MAX_CANON_MEDIA_BYTES}`);
|
|
123
|
+
}
|
|
124
|
+
return value;
|
|
125
|
+
}
|
|
126
|
+
function reportMaterializationError(options, error, attachment, index) {
|
|
127
|
+
try {
|
|
128
|
+
options.onError?.(error, attachment, index);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// Diagnostics must never turn a recoverable sibling failure into a failed
|
|
132
|
+
// message materialization.
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
async function materializeAvailableAttachments(inputs, options) {
|
|
136
|
+
const settled = await Promise.allSettled(inputs.map((input) => input.run()));
|
|
137
|
+
options.signal?.throwIfAborted();
|
|
138
|
+
const materialized = [];
|
|
139
|
+
let firstError;
|
|
140
|
+
let hasFailure = false;
|
|
141
|
+
settled.forEach((result, resultIndex) => {
|
|
142
|
+
if (result.status === 'fulfilled') {
|
|
143
|
+
materialized.push(result.value);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const input = inputs[resultIndex];
|
|
147
|
+
if (!hasFailure) {
|
|
148
|
+
firstError = result.reason;
|
|
149
|
+
hasFailure = true;
|
|
150
|
+
}
|
|
151
|
+
reportMaterializationError(options, result.reason, input.attachment, input.index);
|
|
152
|
+
});
|
|
153
|
+
// Preserve the strict all-failed behavior so callers still learn when no
|
|
154
|
+
// bytes were available, while allowing valid siblings through unchanged.
|
|
155
|
+
if (materialized.length === 0 && hasFailure) {
|
|
156
|
+
throw firstError;
|
|
157
|
+
}
|
|
158
|
+
return materialized;
|
|
159
|
+
}
|
|
160
|
+
function byteLimitTransform(maxBytes) {
|
|
161
|
+
let total = 0;
|
|
162
|
+
return {
|
|
163
|
+
stream: new Transform({
|
|
164
|
+
transform(chunk, _encoding, callback) {
|
|
165
|
+
total += chunk.length;
|
|
166
|
+
if (total > maxBytes) {
|
|
167
|
+
callback(new Error(`Canon media exceeds the ${maxBytes}-byte materialization limit`));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
callback(null, chunk);
|
|
171
|
+
},
|
|
172
|
+
}),
|
|
173
|
+
bytesRead: () => total,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
103
176
|
export function getMessageAttachments(message) {
|
|
104
177
|
return Array.isArray(message.attachments) ? message.attachments : [];
|
|
105
178
|
}
|
|
@@ -113,7 +186,25 @@ export async function materializeAttachment(attachment, options) {
|
|
|
113
186
|
rootDir: options.rootDir,
|
|
114
187
|
});
|
|
115
188
|
await mkdir(dirname(path), { recursive: true });
|
|
189
|
+
const maxBytes = resolveMaxBytes(options.maxBytes);
|
|
190
|
+
const expectedBytes = typeof attachment.sizeBytes === 'number'
|
|
191
|
+
&& Number.isSafeInteger(attachment.sizeBytes)
|
|
192
|
+
&& attachment.sizeBytes >= 0
|
|
193
|
+
? attachment.sizeBytes
|
|
194
|
+
: null;
|
|
195
|
+
if (expectedBytes !== null && expectedBytes > maxBytes) {
|
|
196
|
+
throw new Error(`Canon media exceeds the ${maxBytes}-byte materialization limit`);
|
|
197
|
+
}
|
|
116
198
|
let responseMimeType = null;
|
|
199
|
+
if (await fileExists(path)) {
|
|
200
|
+
const info = await stat(path);
|
|
201
|
+
if (info.size > maxBytes) {
|
|
202
|
+
throw new Error(`Cached Canon media exceeds the ${maxBytes}-byte materialization limit`);
|
|
203
|
+
}
|
|
204
|
+
if (expectedBytes !== null && info.size !== expectedBytes) {
|
|
205
|
+
await unlink(path).catch(() => { });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
117
208
|
if (!(await fileExists(path))) {
|
|
118
209
|
const fetchImpl = ensureFetch(options.fetchImpl);
|
|
119
210
|
const response = await fetchImpl(attachment.url, {
|
|
@@ -123,8 +214,33 @@ export async function materializeAttachment(attachment, options) {
|
|
|
123
214
|
throw new Error(`Failed to download Canon media (${response.status} ${response.statusText})`);
|
|
124
215
|
}
|
|
125
216
|
responseMimeType = response.headers.get('content-type');
|
|
126
|
-
const
|
|
127
|
-
|
|
217
|
+
const contentLength = response.headers.get('content-length');
|
|
218
|
+
const declaredLength = contentLength === null ? null : Number(contentLength);
|
|
219
|
+
if (declaredLength !== null && Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
|
220
|
+
throw new Error(`Canon media exceeds the ${maxBytes}-byte materialization limit`);
|
|
221
|
+
}
|
|
222
|
+
if (expectedBytes !== null
|
|
223
|
+
&& declaredLength !== null
|
|
224
|
+
&& Number.isFinite(declaredLength)
|
|
225
|
+
&& declaredLength !== expectedBytes) {
|
|
226
|
+
throw new Error('Canon media response length does not match attachment metadata');
|
|
227
|
+
}
|
|
228
|
+
if (!response.body) {
|
|
229
|
+
throw new Error('Canon media response had no body');
|
|
230
|
+
}
|
|
231
|
+
const tempPath = `${path}.${process.pid}.${randomUUID()}.part`;
|
|
232
|
+
const limiter = byteLimitTransform(maxBytes);
|
|
233
|
+
try {
|
|
234
|
+
await pipeline(Readable.fromWeb(response.body), limiter.stream, createWriteStream(tempPath, { mode: 0o600, flags: 'wx' }));
|
|
235
|
+
if (expectedBytes !== null && limiter.bytesRead() !== expectedBytes) {
|
|
236
|
+
throw new Error('Canon media response length does not match attachment metadata');
|
|
237
|
+
}
|
|
238
|
+
await rename(tempPath, path);
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
await unlink(tempPath).catch(() => { });
|
|
242
|
+
throw error;
|
|
243
|
+
}
|
|
128
244
|
}
|
|
129
245
|
return {
|
|
130
246
|
...attachment,
|
|
@@ -142,23 +258,32 @@ export async function materializeAttachment(attachment, options) {
|
|
|
142
258
|
}
|
|
143
259
|
export async function materializeMessageMedia(message, options) {
|
|
144
260
|
const attachments = getMessageAttachments(message);
|
|
145
|
-
return
|
|
146
|
-
|
|
147
|
-
messageId: message.id,
|
|
261
|
+
return materializeAvailableAttachments(attachments.map((attachment, index) => ({
|
|
262
|
+
attachment,
|
|
148
263
|
index,
|
|
149
|
-
|
|
264
|
+
run: () => materializeAttachment(attachment, {
|
|
265
|
+
...options,
|
|
266
|
+
messageId: message.id,
|
|
267
|
+
index,
|
|
268
|
+
}),
|
|
269
|
+
})), options);
|
|
150
270
|
}
|
|
151
271
|
export async function materializeReplyContextMedia(replyContext, options) {
|
|
152
272
|
if (!replyContext?.found || !replyContext.attachments?.length) {
|
|
153
273
|
return { replyContext, materialized: [] };
|
|
154
274
|
}
|
|
155
|
-
const
|
|
156
|
-
?
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
275
|
+
const materializable = replyContext.attachments.flatMap((attachment, index) => attachment.url
|
|
276
|
+
? [{
|
|
277
|
+
attachment,
|
|
278
|
+
index,
|
|
279
|
+
run: () => materializeAttachment(attachment, {
|
|
280
|
+
...options,
|
|
281
|
+
messageId: replyContext.messageId,
|
|
282
|
+
index,
|
|
283
|
+
}),
|
|
284
|
+
}]
|
|
285
|
+
: []);
|
|
286
|
+
const materialized = await materializeAvailableAttachments(materializable, options);
|
|
162
287
|
return {
|
|
163
288
|
replyContext: {
|
|
164
289
|
...replyContext,
|
|
@@ -179,10 +304,77 @@ export function inferUploadMimeType(filePath, overrideMimeType) {
|
|
|
179
304
|
return MIME_BY_EXTENSION[extname(filePath).toLowerCase()] ?? 'application/octet-stream';
|
|
180
305
|
}
|
|
181
306
|
export async function uploadMediaFile(client, conversationId, filePath, options) {
|
|
182
|
-
|
|
307
|
+
options?.signal?.throwIfAborted();
|
|
308
|
+
const fileStat = await stat(filePath);
|
|
309
|
+
if (!fileStat.isFile())
|
|
310
|
+
throw new Error(`Canon media path is not a file: ${filePath}`);
|
|
311
|
+
if (fileStat.size <= 0)
|
|
312
|
+
throw new Error('Canon media file is empty');
|
|
313
|
+
if (fileStat.size > MAX_CANON_MEDIA_BYTES) {
|
|
314
|
+
throw new Error(`Canon media exceeds the ${MAX_CANON_MEDIA_BYTES}-byte upload limit`);
|
|
315
|
+
}
|
|
183
316
|
const mimeType = inferUploadMimeType(filePath, options?.mimeType);
|
|
184
317
|
const fileName = options?.fileName ?? basename(filePath);
|
|
185
|
-
const
|
|
318
|
+
const session = await client.createResumableMediaUpload(conversationId, fileStat.size, mimeType, fileName);
|
|
319
|
+
options?.signal?.throwIfAborted();
|
|
320
|
+
if (typeof session.uploadId !== 'string' || session.uploadId.length === 0) {
|
|
321
|
+
throw new Error('Canon returned an invalid resumable upload ID');
|
|
322
|
+
}
|
|
323
|
+
if (session.sizeBytes !== fileStat.size) {
|
|
324
|
+
throw new Error('Canon resumable upload size does not match the local file');
|
|
325
|
+
}
|
|
326
|
+
if (typeof session.mimeType !== 'string' || session.mimeType.length === 0) {
|
|
327
|
+
throw new Error('Canon returned an invalid resumable upload MIME type');
|
|
328
|
+
}
|
|
329
|
+
let uploadUrl;
|
|
330
|
+
try {
|
|
331
|
+
uploadUrl = new URL(session.uploadUrl);
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
throw new Error('Canon returned an invalid resumable upload URL');
|
|
335
|
+
}
|
|
336
|
+
if (uploadUrl.protocol !== 'https:') {
|
|
337
|
+
throw new Error('Canon returned a non-HTTPS resumable upload URL');
|
|
338
|
+
}
|
|
339
|
+
if (uploadUrl.hostname !== 'storage.googleapis.com'
|
|
340
|
+
|| uploadUrl.username.length > 0
|
|
341
|
+
|| uploadUrl.password.length > 0) {
|
|
342
|
+
throw new Error('Canon returned an untrusted resumable upload URL');
|
|
343
|
+
}
|
|
344
|
+
const fetchImpl = ensureFetch(options?.fetchImpl);
|
|
345
|
+
const uploadResponse = await fetchImpl(uploadUrl, {
|
|
346
|
+
method: 'PUT',
|
|
347
|
+
headers: {
|
|
348
|
+
'Content-Type': session.mimeType,
|
|
349
|
+
'Content-Length': String(fileStat.size),
|
|
350
|
+
'Content-Range': `bytes 0-${fileStat.size - 1}/${fileStat.size}`,
|
|
351
|
+
},
|
|
352
|
+
body: createReadStream(filePath),
|
|
353
|
+
signal: options?.signal,
|
|
354
|
+
// Node requires this for streaming request bodies. It is intentionally
|
|
355
|
+
// outside the DOM RequestInit type but supported by the built-in fetch.
|
|
356
|
+
duplex: 'half',
|
|
357
|
+
});
|
|
358
|
+
if (!uploadResponse.ok) {
|
|
359
|
+
throw new Error(`Failed to upload Canon media (${uploadResponse.status} ${uploadResponse.statusText})`);
|
|
360
|
+
}
|
|
361
|
+
options?.signal?.throwIfAborted();
|
|
362
|
+
const finalized = await client.finalizeResumableMediaUpload(session.uploadId);
|
|
363
|
+
options?.signal?.throwIfAborted();
|
|
364
|
+
if (finalized.uploadId !== session.uploadId) {
|
|
365
|
+
throw new Error('Canon resumable upload result does not match its session');
|
|
366
|
+
}
|
|
367
|
+
if (finalized.attachment.uploadId !== undefined
|
|
368
|
+
&& finalized.attachment.uploadId !== session.uploadId) {
|
|
369
|
+
throw new Error('Canon resumable upload attachment does not match its session');
|
|
370
|
+
}
|
|
371
|
+
const uploaded = {
|
|
372
|
+
...finalized,
|
|
373
|
+
attachment: {
|
|
374
|
+
...finalized.attachment,
|
|
375
|
+
uploadId: session.uploadId,
|
|
376
|
+
},
|
|
377
|
+
};
|
|
186
378
|
if (uploaded.attachment.kind === 'audio'
|
|
187
379
|
&& typeof options?.durationMs === 'number'
|
|
188
380
|
&& Number.isFinite(options.durationMs)
|
|
@@ -198,12 +390,15 @@ export async function uploadMediaFile(client, conversationId, filePath, options)
|
|
|
198
390
|
return uploaded;
|
|
199
391
|
}
|
|
200
392
|
export async function sendMediaFileMessage(client, conversationId, filePath, text = '', options) {
|
|
201
|
-
const { fileName, mimeType, durationMs, ...sendOptions } = options ?? {};
|
|
393
|
+
const { fileName, mimeType, durationMs, fetchImpl, signal, ...sendOptions } = options ?? {};
|
|
202
394
|
const uploaded = await uploadMediaFile(client, conversationId, filePath, {
|
|
203
395
|
...(fileName ? { fileName } : {}),
|
|
204
396
|
...(mimeType ? { mimeType } : {}),
|
|
205
397
|
...(durationMs != null ? { durationMs } : {}),
|
|
398
|
+
...(fetchImpl ? { fetchImpl } : {}),
|
|
399
|
+
...(signal ? { signal } : {}),
|
|
206
400
|
});
|
|
401
|
+
signal?.throwIfAborted();
|
|
207
402
|
return client.sendMessage(conversationId, text, {
|
|
208
403
|
...sendOptions,
|
|
209
404
|
contentType: uploaded.attachment.kind,
|
|
@@ -247,12 +442,39 @@ export function isAnthropicImageAttachment(attachment) {
|
|
|
247
442
|
* Callers should first check `isAnthropicImageAttachment`; this function
|
|
248
443
|
* throws if the MIME type is not supported.
|
|
249
444
|
*/
|
|
250
|
-
export async function toAnthropicImageBlock(attachment) {
|
|
445
|
+
export async function toAnthropicImageBlock(attachment, options) {
|
|
251
446
|
if (!isAnthropicImageAttachment(attachment)) {
|
|
252
447
|
throw new Error(`Canon attachment ${attachment.index} is not a supported Anthropic image (kind=${attachment.kind}, mime=${attachment.mimeType ?? 'unknown'})`);
|
|
253
448
|
}
|
|
449
|
+
const maxRawBytes = options?.maxRawBytes ?? MAX_ANTHROPIC_IMAGE_RAW_BYTES;
|
|
450
|
+
if (!Number.isSafeInteger(maxRawBytes)
|
|
451
|
+
|| maxRawBytes <= 0
|
|
452
|
+
|| maxRawBytes > MAX_ANTHROPIC_IMAGE_RAW_BYTES) {
|
|
453
|
+
throw new Error(`maxRawBytes must be an integer between 1 and ${MAX_ANTHROPIC_IMAGE_RAW_BYTES}`);
|
|
454
|
+
}
|
|
254
455
|
const mediaType = resolveAttachmentMimeType(attachment);
|
|
255
|
-
const buffer = await
|
|
456
|
+
const buffer = await readAnthropicImageBytes(attachment, maxRawBytes);
|
|
457
|
+
return buildAnthropicImageBlock(buffer, mediaType);
|
|
458
|
+
}
|
|
459
|
+
async function readAnthropicImageBytes(attachment, maxRawBytes) {
|
|
460
|
+
const handle = await open(attachment.path, 'r');
|
|
461
|
+
try {
|
|
462
|
+
return await readBoundedRegularFileHandle(handle, maxRawBytes);
|
|
463
|
+
}
|
|
464
|
+
catch (error) {
|
|
465
|
+
if (error instanceof BoundedFileReadError) {
|
|
466
|
+
if (error.failure === 'not-file') {
|
|
467
|
+
throw new Error(`Canon attachment ${attachment.index} is not a file`);
|
|
468
|
+
}
|
|
469
|
+
throw new Error(`Canon attachment ${attachment.index} exceeds the ${maxRawBytes}-byte Anthropic image limit`);
|
|
470
|
+
}
|
|
471
|
+
throw error;
|
|
472
|
+
}
|
|
473
|
+
finally {
|
|
474
|
+
await handle.close();
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
function buildAnthropicImageBlock(buffer, mediaType) {
|
|
256
478
|
return {
|
|
257
479
|
type: 'image',
|
|
258
480
|
source: {
|
|
@@ -262,6 +484,87 @@ export async function toAnthropicImageBlock(attachment) {
|
|
|
262
484
|
},
|
|
263
485
|
};
|
|
264
486
|
}
|
|
487
|
+
function resolveBoundedInteger(input) {
|
|
488
|
+
const value = input.value ?? input.fallback;
|
|
489
|
+
const minimum = input.allowZero ? 0 : 1;
|
|
490
|
+
if (!Number.isSafeInteger(value) || value < minimum || value > input.maximum) {
|
|
491
|
+
throw new Error(`${input.name} must be an integer between ${minimum} and ${input.maximum}`);
|
|
492
|
+
}
|
|
493
|
+
return value;
|
|
494
|
+
}
|
|
495
|
+
function encodedBase64Length(rawBytes) {
|
|
496
|
+
return Math.ceil(rawBytes / 3) * 4;
|
|
497
|
+
}
|
|
498
|
+
function serializedTextBlockBytes(promptText) {
|
|
499
|
+
return Buffer.byteLength(JSON.stringify({ type: 'text', text: promptText }), 'utf8');
|
|
500
|
+
}
|
|
501
|
+
function serializedImageBlockBytes(rawBytes, mediaType) {
|
|
502
|
+
const framingBytes = Buffer.byteLength(JSON.stringify({
|
|
503
|
+
type: 'image',
|
|
504
|
+
source: {
|
|
505
|
+
type: 'base64',
|
|
506
|
+
media_type: mediaType,
|
|
507
|
+
data: '',
|
|
508
|
+
},
|
|
509
|
+
}), 'utf8');
|
|
510
|
+
return framingBytes + encodedBase64Length(rawBytes);
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Convert only the supported images that fit both the direct-image limit and
|
|
514
|
+
* the aggregate request budget. Skipped images remain represented by the
|
|
515
|
+
* paths already rendered into promptText; one unavailable image never drops
|
|
516
|
+
* valid siblings.
|
|
517
|
+
*/
|
|
518
|
+
export async function toAnthropicImageBlocksWithinBudget(attachments, options) {
|
|
519
|
+
const maxImageRawBytes = resolveBoundedInteger({
|
|
520
|
+
value: options.maxImageRawBytes,
|
|
521
|
+
fallback: MAX_ANTHROPIC_IMAGE_RAW_BYTES,
|
|
522
|
+
maximum: MAX_ANTHROPIC_IMAGE_RAW_BYTES,
|
|
523
|
+
name: 'maxImageRawBytes',
|
|
524
|
+
});
|
|
525
|
+
const maxRequestBytes = resolveBoundedInteger({
|
|
526
|
+
value: options.maxRequestBytes,
|
|
527
|
+
fallback: MAX_ANTHROPIC_REQUEST_BYTES,
|
|
528
|
+
maximum: MAX_ANTHROPIC_REQUEST_BYTES,
|
|
529
|
+
name: 'maxRequestBytes',
|
|
530
|
+
});
|
|
531
|
+
const requestHeadroomBytes = resolveBoundedInteger({
|
|
532
|
+
value: options.requestHeadroomBytes,
|
|
533
|
+
fallback: DEFAULT_ANTHROPIC_REQUEST_HEADROOM_BYTES,
|
|
534
|
+
maximum: maxRequestBytes,
|
|
535
|
+
name: 'requestHeadroomBytes',
|
|
536
|
+
allowZero: true,
|
|
537
|
+
});
|
|
538
|
+
const contentBudgetBytes = maxRequestBytes - requestHeadroomBytes;
|
|
539
|
+
// Exact JSON content-array framing: opening/closing brackets around the
|
|
540
|
+
// text block, then one comma before every native image block.
|
|
541
|
+
let usedBytes = 2 + serializedTextBlockBytes(options.promptText);
|
|
542
|
+
if (usedBytes > contentBudgetBytes)
|
|
543
|
+
return [];
|
|
544
|
+
const blocks = [];
|
|
545
|
+
for (const attachment of attachments) {
|
|
546
|
+
if (!isAnthropicImageAttachment(attachment))
|
|
547
|
+
continue;
|
|
548
|
+
try {
|
|
549
|
+
const mediaType = resolveAttachmentMimeType(attachment);
|
|
550
|
+
const buffer = await readAnthropicImageBytes(attachment, maxImageRawBytes);
|
|
551
|
+
const nextBlockBytes = 1 + serializedImageBlockBytes(buffer.byteLength, mediaType);
|
|
552
|
+
if (usedBytes + nextBlockBytes > contentBudgetBytes)
|
|
553
|
+
continue;
|
|
554
|
+
const block = buildAnthropicImageBlock(buffer, mediaType);
|
|
555
|
+
const actualBlockBytes = 1 + Buffer.byteLength(JSON.stringify(block), 'utf8');
|
|
556
|
+
if (usedBytes + actualBlockBytes > contentBudgetBytes)
|
|
557
|
+
continue;
|
|
558
|
+
blocks.push(block);
|
|
559
|
+
usedBytes += actualBlockBytes;
|
|
560
|
+
}
|
|
561
|
+
catch {
|
|
562
|
+
// A missing, changed, or unreadable file stays as its prompt path. Keep
|
|
563
|
+
// inspecting siblings instead of failing the whole model request.
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return blocks;
|
|
567
|
+
}
|
|
265
568
|
/**
|
|
266
569
|
* Return the local path to pass to Codex via its `-i/--image` flag when the
|
|
267
570
|
* attachment is an image Codex can consume. Codex accepts the same MIME types
|
package/dist/types.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export type { AddMemberResult, AgentClientType, CanonGroupContext, CanonRuntimeActivityItem, CanonRuntimeActivityKind, CanonRuntimeActivityStatus, CanonRuntimeDescriptor, CanonRuntimeFact, CanonRuntimeFactGroup, CanonRuntimePrimitiveId, CanonRuntimeProvenance, CanonTurnContextV2, CanonMessage, CanonConversation, CanonReplyContext, CanonContact, CanonContactRequest, CanonResolveAdmissionResult, ContactAddedPayload, ContactRemovedPayload, ContactSource, AgentContext, ResolveAdmissionTargetInput, ResolvedAdmissionState, ResolvedAdmissionTargetSummary, ResolvedTargetAdmissionPayload, CanonSelfContext, CreateConversationResult, DirectSessionSelection, SendContextualMessageOptions, SendContextualMessageResult, SendContextualSelfContextInput, SendMessageOptions, SessionConfig, VerbSessionConfig, CreateConversationOptions, TurnLifecycleState, TurnOutputBlock, TurnOutputBlockInput, ApprovalNativeRequestMetadata, ApprovalRequestCategory, ApprovalRequestDetail, ApprovalRequestMetadata, ApprovalRisk, ApprovalReplyMetadata, ApprovalOutcomeMetadata, RuntimeInputChoice, RuntimeInputAnswers, RuntimeInputKind, RuntimeInputNativeMetadata, RuntimeInputQuestion, RuntimeCardNativeMetadata, RuntimeCardV1, SessionRule, ApprovalResult, } from '@canonmsg/core';
|
|
2
|
-
import type { AddMemberResult, CanonGroupContext, CanonMessage, CanonConversation, CanonReplyContext, ContactCardPayload, CanonRuntimeActionDispatch, CanonRuntimePrimitiveId, CanonRuntimeProvenance, CanonTurnContextV2, ApprovalNativeRequestMetadata, ApprovalRequestCategory, ApprovalRequestDetail, ApprovalResult, ApprovalRisk, RuntimeInputChoice, RuntimeInputAnswers, RuntimeInputKind, RuntimeInputNativeMetadata, RuntimeInputQuestion, RuntimeCardNativeMetadata, RuntimeCardV1, SendMessageOptions, SendContextualSelfContextInput, VerbSessionConfig, DirectSessionSelection, TurnOutputBlock, TurnOutputBlockInput } from '@canonmsg/core';
|
|
1
|
+
export type { AddMemberResult, GroupInviteRequirements, AgentClientType, CanonGroupContext, CanonRuntimeActivityItem, CanonRuntimeActivityKind, CanonRuntimeActivityStatus, CanonRuntimeDescriptor, CanonRuntimeFact, CanonRuntimeFactGroup, CanonRuntimePrimitiveId, CanonRuntimeProvenance, CanonTurnContextV2, CanonMessage, CanonConversation, CanonReplyContext, CanonContact, CanonContactRequest, CanonResolveAdmissionResult, ContactAddedPayload, ContactRemovedPayload, ContactSource, AgentContext, ResolveAdmissionTargetInput, ResolvedAdmissionState, ResolvedAdmissionTargetSummary, ResolvedTargetAdmissionPayload, CanonSelfContext, CreateConversationResult, DirectSessionSelection, SendContextualMessageOptions, SendContextualMessageResult, SendContextualSelfContextInput, SendMessageOptions, SessionConfig, VerbSessionConfig, CreateConversationOptions, TurnLifecycleState, TurnOutputBlock, TurnOutputBlockInput, ApprovalNativeRequestMetadata, ApprovalRequestCategory, ApprovalRequestDetail, ApprovalRequestMetadata, ApprovalRisk, ApprovalReplyMetadata, ApprovalOutcomeMetadata, RuntimeInputChoice, RuntimeInputAnswers, RuntimeInputKind, RuntimeInputNativeMetadata, RuntimeInputQuestion, RuntimeCardNativeMetadata, RuntimeCardV1, ResumableMediaUploadResult, SessionRule, ApprovalResult, } from '@canonmsg/core';
|
|
2
|
+
import type { AddMemberResult, CanonGroupContext, CanonMessage, CanonConversation, CanonReplyContext, ContactCardPayload, CanonRuntimeActionDispatch, CanonRuntimePrimitiveId, CanonRuntimeProvenance, CanonTurnContextV2, ApprovalNativeRequestMetadata, ApprovalRequestCategory, ApprovalRequestDetail, ApprovalResult, ApprovalRisk, RuntimeInputChoice, RuntimeInputAnswers, RuntimeInputKind, RuntimeInputNativeMetadata, RuntimeInputQuestion, RuntimeCardNativeMetadata, RuntimeCardV1, ResumableMediaUploadResult, SendMessageOptions, SendContextualSelfContextInput, VerbSessionConfig, DirectSessionSelection, TurnOutputBlock, TurnOutputBlockInput } from '@canonmsg/core';
|
|
3
3
|
import type { MaterializeMediaOptions, MaterializedCanonAttachment, ReplyWithFileOptions, UploadMediaFileOptions } from './media.js';
|
|
4
4
|
export interface ProgressMessageOptions extends SendMessageOptions {
|
|
5
5
|
/**
|
|
@@ -230,10 +230,7 @@ export interface MessageHandlerContext {
|
|
|
230
230
|
/** Canon-managed local media access for the current conversation. */
|
|
231
231
|
media: {
|
|
232
232
|
materialize: (message?: CanonMessage, options?: Omit<MaterializeMediaOptions, 'agentId' | 'conversationId' | 'messageId'>) => Promise<MaterializedCanonAttachment[]>;
|
|
233
|
-
uploadFile: (filePath: string, options?: UploadMediaFileOptions) => Promise<
|
|
234
|
-
url: string;
|
|
235
|
-
attachment: import('@canonmsg/core').MediaAttachment;
|
|
236
|
-
}>;
|
|
233
|
+
uploadFile: (filePath: string, options?: UploadMediaFileOptions) => Promise<ResumableMediaUploadResult>;
|
|
237
234
|
replyWithFile: (filePath: string, text?: string, options?: ReplyWithFileOptions) => Promise<{
|
|
238
235
|
messageId: string;
|
|
239
236
|
}>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@canonmsg/agent-sdk",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.6.0",
|
|
4
4
|
"description": "Canon Agent SDK — build AI agents that participate in Canon conversations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"node": ">=18.0.0"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@canonmsg/core": "^10.
|
|
31
|
+
"@canonmsg/core": "^10.4.0"
|
|
32
32
|
},
|
|
33
33
|
"publishConfig": {
|
|
34
34
|
"access": "public"
|