@canonmsg/claude-code-plugin 0.8.0 → 0.9.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/dist/host-runtime.d.ts +21 -4
- package/dist/host-runtime.js +37 -29
- package/dist/host.js +93 -35
- package/dist/server.js +16 -25
- package/dist/streaming-buffer.d.ts +16 -0
- package/dist/streaming-buffer.js +52 -0
- package/package.json +3 -2
package/dist/host-runtime.d.ts
CHANGED
|
@@ -28,9 +28,6 @@ export interface HostInboundParticipantContext {
|
|
|
28
28
|
type HostInboundMessage = {
|
|
29
29
|
text?: string | null;
|
|
30
30
|
contentType?: CanonMessage['contentType'] | null;
|
|
31
|
-
audioUrl?: string | null;
|
|
32
|
-
audioDurationMs?: number | null;
|
|
33
|
-
imageUrl?: string | null;
|
|
34
31
|
attachments?: CanonMessage['attachments'];
|
|
35
32
|
senderType?: CanonMessage['senderType'];
|
|
36
33
|
mentions?: string[] | null;
|
|
@@ -49,7 +46,27 @@ export declare function buildCanonHostPrompt(input: {
|
|
|
49
46
|
workSessions?: MessageCreatedPayload['workSessions'];
|
|
50
47
|
buildInboundContextLines: (context: HostInboundParticipantContext) => string[];
|
|
51
48
|
}): string;
|
|
52
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Render the **text portion** of an inbound Canon message. Images and other
|
|
51
|
+
* binary attachments are referenced by short placeholders — their actual
|
|
52
|
+
* bytes are delivered to the host as native vision/media inputs (Anthropic
|
|
53
|
+
* image blocks, Codex `-i` file args). URLs are intentionally *not* inlined,
|
|
54
|
+
* since the harness never needs to refetch and earlier `[Image: <url>]`
|
|
55
|
+
* inlining caused vision models to see a string about an image instead of
|
|
56
|
+
* the image itself.
|
|
57
|
+
*
|
|
58
|
+
* `materialized` may be passed so non-image attachments can reference a
|
|
59
|
+
* local path the agent can Read. Without it we fall back to an unadorned
|
|
60
|
+
* placeholder; the vision path still works because image blocks carry the
|
|
61
|
+
* bytes directly.
|
|
62
|
+
*/
|
|
63
|
+
export declare function renderCanonHostInboundContent(message: HostInboundMessage, materialized?: ReadonlyArray<{
|
|
64
|
+
kind: 'image' | 'audio' | 'file';
|
|
65
|
+
path: string;
|
|
66
|
+
fileName?: string;
|
|
67
|
+
durationMs?: number;
|
|
68
|
+
index: number;
|
|
69
|
+
}>): string;
|
|
53
70
|
export declare function buildHydratedInboundContext(input: {
|
|
54
71
|
agentId: string;
|
|
55
72
|
conversation: CanonConversation | null;
|
package/dist/host-runtime.js
CHANGED
|
@@ -32,38 +32,46 @@ export function buildCanonHostPrompt(input) {
|
|
|
32
32
|
input.content,
|
|
33
33
|
].join('\n');
|
|
34
34
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Render the **text portion** of an inbound Canon message. Images and other
|
|
37
|
+
* binary attachments are referenced by short placeholders — their actual
|
|
38
|
+
* bytes are delivered to the host as native vision/media inputs (Anthropic
|
|
39
|
+
* image blocks, Codex `-i` file args). URLs are intentionally *not* inlined,
|
|
40
|
+
* since the harness never needs to refetch and earlier `[Image: <url>]`
|
|
41
|
+
* inlining caused vision models to see a string about an image instead of
|
|
42
|
+
* the image itself.
|
|
43
|
+
*
|
|
44
|
+
* `materialized` may be passed so non-image attachments can reference a
|
|
45
|
+
* local path the agent can Read. Without it we fall back to an unadorned
|
|
46
|
+
* placeholder; the vision path still works because image blocks carry the
|
|
47
|
+
* bytes directly.
|
|
48
|
+
*/
|
|
49
|
+
export function renderCanonHostInboundContent(message, materialized) {
|
|
50
|
+
const body = message.text || '';
|
|
51
|
+
const placeholders = [];
|
|
52
|
+
const attachments = message.attachments ?? [];
|
|
53
|
+
for (let i = 0; i < attachments.length; i += 1) {
|
|
54
|
+
const att = attachments[i];
|
|
55
|
+
const mat = materialized?.find((m) => m.index === i) ?? null;
|
|
56
|
+
placeholders.push(describeAttachment(att, mat));
|
|
54
57
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
const rendered = [...placeholders, body].filter(Boolean).join('\n');
|
|
59
|
+
return rendered || '[Empty message]';
|
|
60
|
+
}
|
|
61
|
+
function describeAttachment(attachment, materialized) {
|
|
62
|
+
if (attachment.kind === 'image') {
|
|
63
|
+
return '[Image attached]';
|
|
60
64
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
+
if (attachment.kind === 'audio') {
|
|
66
|
+
const durationMs = materialized?.durationMs ?? attachment.durationMs;
|
|
67
|
+
const duration = durationMs ? ` (${Math.round(durationMs / 1000)}s)` : '';
|
|
68
|
+
const ref = materialized?.path ? ` ${materialized.path}` : '';
|
|
69
|
+
return `[Voice message${duration}${ref}]`;
|
|
65
70
|
}
|
|
66
|
-
|
|
71
|
+
// file
|
|
72
|
+
const label = materialized?.fileName ?? attachment.fileName ?? 'File';
|
|
73
|
+
const ref = materialized?.path ? ` ${materialized.path}` : '';
|
|
74
|
+
return `[File: ${label}${ref}]`;
|
|
67
75
|
}
|
|
68
76
|
export function buildHydratedInboundContext(input) {
|
|
69
77
|
const history = buildParticipationHistorySnapshot(input.page?.messages ?? [], input.agentId);
|
package/dist/host.js
CHANGED
|
@@ -24,9 +24,11 @@ setDefaultResultOrder('ipv4first');
|
|
|
24
24
|
import { parseArgs } from 'node:util';
|
|
25
25
|
import { randomUUID } from 'node:crypto';
|
|
26
26
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
27
|
-
import {
|
|
27
|
+
import { isAnthropicImageAttachment, materializeMessageMedia, toAnthropicImageBlock, } from '@canonmsg/agent-sdk';
|
|
28
|
+
import { buildConfiguredWorkspaceOptions, buildConversationEnvironmentKey, buildPublicWorkspaceOptions, EXECUTION_ENVIRONMENT_MODES, isEnabledFlag, CanonClient, CanonStream, clearTurnState, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, ExecutionEnvironmentError, FINAL_MESSAGE_HANDOFF_MS, normalizeTurnMetadata, prepareConversationEnvironment, resolveCanonAgent, getActiveProfile, releaseLock, initRTDBAuth, rtdbRead, rtdbWrite, writeSessionState, clearSessionState, writeTurnState, releaseConversationEnvironment, } from '@canonmsg/core';
|
|
28
29
|
import { buildCanonHostPrompt, buildHydratedInboundContext, createConversationMetadataLoader, loadHostSessionConfig, publishHostAgentRuntime, renderCanonHostInboundContent, resolveHostWorkspaceCwd, } from './host-runtime.js';
|
|
29
30
|
import { buildInboundContextLines, decideAutoReply, } from './inbound-policy.js';
|
|
31
|
+
import { createStreamingBuffer } from './streaming-buffer.js';
|
|
30
32
|
let workingDir = process.cwd();
|
|
31
33
|
let workspaceOptions = [];
|
|
32
34
|
let runtimeModels = [];
|
|
@@ -50,6 +52,14 @@ async function loadSessionConfig(conversationId, agentId) {
|
|
|
50
52
|
permissionMode: config?.permissionMode,
|
|
51
53
|
};
|
|
52
54
|
}
|
|
55
|
+
const SESSION_EXECUTION_MODE_REQUIRED = 'Session execution mode required; please select a mode before starting the session.';
|
|
56
|
+
function requireSessionExecutionMode(config) {
|
|
57
|
+
const mode = config?.executionMode;
|
|
58
|
+
if (!mode) {
|
|
59
|
+
throw new ExecutionEnvironmentError(SESSION_EXECUTION_MODE_REQUIRED, SESSION_EXECUTION_MODE_REQUIRED);
|
|
60
|
+
}
|
|
61
|
+
return mode;
|
|
62
|
+
}
|
|
53
63
|
function resolveWorkspaceCwd(config) {
|
|
54
64
|
return resolveHostWorkspaceCwd({
|
|
55
65
|
workspaceOptions,
|
|
@@ -105,8 +115,28 @@ function buildCanonPrompt(input) {
|
|
|
105
115
|
...input,
|
|
106
116
|
});
|
|
107
117
|
}
|
|
108
|
-
function renderInboundContent(message) {
|
|
109
|
-
return renderCanonHostInboundContent(message);
|
|
118
|
+
function renderInboundContent(message, materialized) {
|
|
119
|
+
return renderCanonHostInboundContent(message, materialized);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Build the multimodal `content` payload handed to the Claude Code SDK.
|
|
123
|
+
*
|
|
124
|
+
* Images are delivered as native Anthropic `image` blocks (base64) so the
|
|
125
|
+
* vision model sees pixels, not a text reference to a URL. Non-image
|
|
126
|
+
* attachments (audio, files) are referenced by their local path inside the
|
|
127
|
+
* text prompt — see `renderCanonHostInboundContent` — so the agent can
|
|
128
|
+
* Read them on demand.
|
|
129
|
+
*/
|
|
130
|
+
async function buildCanonUserContent(input) {
|
|
131
|
+
const imageAttachments = input.materialized.filter(isAnthropicImageAttachment);
|
|
132
|
+
if (imageAttachments.length === 0) {
|
|
133
|
+
return input.promptText;
|
|
134
|
+
}
|
|
135
|
+
const imageBlocks = await Promise.all(imageAttachments.map((attachment) => toAnthropicImageBlock(attachment)));
|
|
136
|
+
return [
|
|
137
|
+
{ type: 'text', text: input.promptText },
|
|
138
|
+
...imageBlocks,
|
|
139
|
+
];
|
|
110
140
|
}
|
|
111
141
|
// ── Session factory ─────────────────────────────────────────────────
|
|
112
142
|
function createSession(conversationId, environment, agentId, client, onSessionEnd, onRuntimeDescriptorUpdate, config, resumeSessionId) {
|
|
@@ -270,31 +300,33 @@ function createSession(conversationId, environment, agentId, client, onSessionEn
|
|
|
270
300
|
sdkSessionId: resumeSessionId || null,
|
|
271
301
|
pendingReply: null,
|
|
272
302
|
};
|
|
303
|
+
const streamingBuffer = createStreamingBuffer({
|
|
304
|
+
throttleMs: STREAMING_THROTTLE_MS,
|
|
305
|
+
getText: () => session.streamingText,
|
|
306
|
+
setText: (text) => {
|
|
307
|
+
session.streamingText = text;
|
|
308
|
+
},
|
|
309
|
+
getTimer: () => session.streamingTimer,
|
|
310
|
+
setTimer: (timer) => {
|
|
311
|
+
session.streamingTimer = timer;
|
|
312
|
+
},
|
|
313
|
+
onWrite: (text) => {
|
|
314
|
+
rtdbWrite(`/streaming/${conversationId}/${agentId}`, {
|
|
315
|
+
text,
|
|
316
|
+
status: 'streaming',
|
|
317
|
+
updatedAt: { '.sv': 'timestamp' },
|
|
318
|
+
}).catch(() => { });
|
|
319
|
+
},
|
|
320
|
+
});
|
|
273
321
|
// ── Per-session streaming helpers ──
|
|
274
322
|
function flushStreaming() {
|
|
275
|
-
|
|
276
|
-
return;
|
|
277
|
-
rtdbWrite(`/streaming/${conversationId}/${agentId}`, {
|
|
278
|
-
text: session.streamingText,
|
|
279
|
-
status: 'streaming',
|
|
280
|
-
updatedAt: { '.sv': 'timestamp' },
|
|
281
|
-
}).catch(() => { });
|
|
323
|
+
streamingBuffer.flush();
|
|
282
324
|
}
|
|
283
325
|
function onStreamDelta(text) {
|
|
284
|
-
|
|
285
|
-
if (!session.streamingTimer) {
|
|
286
|
-
session.streamingTimer = setTimeout(() => {
|
|
287
|
-
session.streamingTimer = null;
|
|
288
|
-
flushStreaming();
|
|
289
|
-
}, STREAMING_THROTTLE_MS);
|
|
290
|
-
}
|
|
326
|
+
streamingBuffer.append(text);
|
|
291
327
|
}
|
|
292
328
|
function clearStreaming() {
|
|
293
|
-
|
|
294
|
-
clearTimeout(session.streamingTimer);
|
|
295
|
-
session.streamingTimer = null;
|
|
296
|
-
}
|
|
297
|
-
session.streamingText = '';
|
|
329
|
+
streamingBuffer.clear();
|
|
298
330
|
rtdbWrite(`/streaming/${conversationId}/${agentId}`, null).catch(() => { });
|
|
299
331
|
}
|
|
300
332
|
function clearIdleResetTimer() {
|
|
@@ -338,8 +370,7 @@ function createSession(conversationId, environment, agentId, client, onSessionEn
|
|
|
338
370
|
session.pendingFinalDeliveryText = finalText;
|
|
339
371
|
session.state.state = 'running';
|
|
340
372
|
session.turnState = 'streaming';
|
|
341
|
-
|
|
342
|
-
flushStreaming();
|
|
373
|
+
streamingBuffer.replace(finalText);
|
|
343
374
|
writeState();
|
|
344
375
|
writeTurn();
|
|
345
376
|
client.setTyping(conversationId, true, 'typing').catch(() => { });
|
|
@@ -612,6 +643,7 @@ function createSession(conversationId, environment, agentId, client, onSessionEn
|
|
|
612
643
|
defaultPermissionMode: 'default',
|
|
613
644
|
availableModels: modelList,
|
|
614
645
|
availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
646
|
+
availableExecutionModes: config.availableExecutionModes,
|
|
615
647
|
});
|
|
616
648
|
writeSessionState(conversationId, agentId, {
|
|
617
649
|
...session.state,
|
|
@@ -674,10 +706,14 @@ async function main() {
|
|
|
674
706
|
console.error(`[canon-host] Authenticated as ${agentId}`);
|
|
675
707
|
}
|
|
676
708
|
let streamConnected = false;
|
|
709
|
+
const hostAvailableExecutionModes = allowWorktrees
|
|
710
|
+
? [...EXECUTION_ENVIRONMENT_MODES]
|
|
711
|
+
: ['locked'];
|
|
677
712
|
let runtimeDescriptor = {
|
|
678
713
|
defaultWorkspaceId: workspaceOptions[0]?.id,
|
|
679
714
|
defaultPermissionMode: 'default',
|
|
680
715
|
availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
716
|
+
availableExecutionModes: hostAvailableExecutionModes,
|
|
681
717
|
};
|
|
682
718
|
const publishRuntimeHeartbeat = async () => {
|
|
683
719
|
if (!streamConnected)
|
|
@@ -695,6 +731,7 @@ async function main() {
|
|
|
695
731
|
defaultPermissionMode: 'default',
|
|
696
732
|
availableModels: runtimeModels,
|
|
697
733
|
availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
734
|
+
availableExecutionModes: hostAvailableExecutionModes,
|
|
698
735
|
};
|
|
699
736
|
}
|
|
700
737
|
catch {
|
|
@@ -702,6 +739,7 @@ async function main() {
|
|
|
702
739
|
defaultWorkspaceId: workspaceOptions[0]?.id,
|
|
703
740
|
defaultPermissionMode: 'default',
|
|
704
741
|
availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
742
|
+
availableExecutionModes: hostAvailableExecutionModes,
|
|
705
743
|
};
|
|
706
744
|
}
|
|
707
745
|
try {
|
|
@@ -747,6 +785,8 @@ async function main() {
|
|
|
747
785
|
}
|
|
748
786
|
if (session.streamingTimer)
|
|
749
787
|
clearTimeout(session.streamingTimer);
|
|
788
|
+
session.streamingText = '';
|
|
789
|
+
rtdbWrite(`/streaming/${conversationId}/${agentId}`, null).catch(() => { });
|
|
750
790
|
if (session.idleResetTimer) {
|
|
751
791
|
clearTimeout(session.idleResetTimer);
|
|
752
792
|
session.idleResetTimer = null;
|
|
@@ -755,7 +795,6 @@ async function main() {
|
|
|
755
795
|
clearTimeout(session.finalDeliveryTimer);
|
|
756
796
|
session.finalDeliveryTimer = null;
|
|
757
797
|
}
|
|
758
|
-
rtdbWrite(`/streaming/${conversationId}/${agentId}`, null).catch(() => { });
|
|
759
798
|
clearSessionState(conversationId, agentId).catch(() => { });
|
|
760
799
|
clearTurnState(conversationId, agentId).catch(() => { });
|
|
761
800
|
client.setTyping(conversationId, false).catch(() => { });
|
|
@@ -794,12 +833,16 @@ async function main() {
|
|
|
794
833
|
}
|
|
795
834
|
const creation = (async () => {
|
|
796
835
|
const config = await loadSessionConfig(conversationId, agentId);
|
|
836
|
+
const sessionExecutionMode = requireSessionExecutionMode(config);
|
|
837
|
+
if (sessionExecutionMode === 'worktree' && !allowWorktrees) {
|
|
838
|
+
throw new ExecutionEnvironmentError('This host does not allow worktree sessions (launched without --enable-worktrees).', 'This Canon host was started without worktree isolation enabled. Choose "Lock the workspace" or restart the host with --enable-worktrees.');
|
|
839
|
+
}
|
|
797
840
|
const workspaceCwd = resolveWorkspaceCwd(config);
|
|
798
841
|
const environment = prepareConversationEnvironment({
|
|
799
842
|
agentId,
|
|
800
843
|
conversationId,
|
|
801
844
|
workspaceCwd,
|
|
802
|
-
allowWorktrees,
|
|
845
|
+
allowWorktrees: sessionExecutionMode === 'worktree',
|
|
803
846
|
});
|
|
804
847
|
try {
|
|
805
848
|
const resumeKey = buildConversationEnvironmentKey(conversationId, environment.baseCwd);
|
|
@@ -823,6 +866,7 @@ async function main() {
|
|
|
823
866
|
model: config?.model ?? runtimeModels[0]?.value,
|
|
824
867
|
permissionMode: config?.permissionMode ?? 'default',
|
|
825
868
|
availableModels: runtimeModels,
|
|
869
|
+
availableExecutionModes: hostAvailableExecutionModes,
|
|
826
870
|
}, resumeId);
|
|
827
871
|
sessions.set(conversationId, session);
|
|
828
872
|
return session;
|
|
@@ -863,9 +907,21 @@ async function main() {
|
|
|
863
907
|
}
|
|
864
908
|
}
|
|
865
909
|
const sender = m.senderName || m.senderId;
|
|
866
|
-
const content = renderInboundContent(m);
|
|
867
910
|
const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
|
|
868
911
|
void (async () => {
|
|
912
|
+
let materialized = [];
|
|
913
|
+
if (m.id) {
|
|
914
|
+
try {
|
|
915
|
+
materialized = await materializeMessageMedia({
|
|
916
|
+
id: m.id,
|
|
917
|
+
attachments: m.attachments ?? [],
|
|
918
|
+
}, { agentId, conversationId: convoId });
|
|
919
|
+
}
|
|
920
|
+
catch (error) {
|
|
921
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to materialize media:`, error instanceof Error ? error.message : error);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
const content = renderInboundContent(m, materialized);
|
|
869
925
|
const hydrated = await loadHydratedInboundContext({
|
|
870
926
|
conversationId: convoId,
|
|
871
927
|
message: m,
|
|
@@ -899,18 +955,20 @@ async function main() {
|
|
|
899
955
|
const turnMetadata = normalizeTurnMetadata(m.metadata);
|
|
900
956
|
const deliveryIntent = turnMetadata?.deliveryIntent ?? 'queue';
|
|
901
957
|
const shouldMarkAccepted = turnMetadata?.inboundDisposition === 'queued';
|
|
958
|
+
const promptText = buildCanonPrompt({
|
|
959
|
+
content,
|
|
960
|
+
conversationId: convoId,
|
|
961
|
+
participantContext,
|
|
962
|
+
behavior,
|
|
963
|
+
workSession: m.workSession,
|
|
964
|
+
workSessions,
|
|
965
|
+
});
|
|
966
|
+
const messageContent = await buildCanonUserContent({ promptText, materialized });
|
|
902
967
|
session.enqueueInbound({
|
|
903
968
|
type: 'user',
|
|
904
969
|
message: {
|
|
905
970
|
role: 'user',
|
|
906
|
-
content:
|
|
907
|
-
content,
|
|
908
|
-
conversationId: convoId,
|
|
909
|
-
participantContext,
|
|
910
|
-
behavior,
|
|
911
|
-
workSession: m.workSession,
|
|
912
|
-
workSessions,
|
|
913
|
-
}),
|
|
971
|
+
content: messageContent,
|
|
914
972
|
},
|
|
915
973
|
parent_tool_use_id: null,
|
|
916
974
|
}, deliveryIntent, m.id ?? null, shouldMarkAccepted);
|
package/dist/server.js
CHANGED
|
@@ -32,36 +32,31 @@ function getPrimaryAttachment(message) {
|
|
|
32
32
|
const attachment = message.attachments?.[0];
|
|
33
33
|
if (attachment?.url)
|
|
34
34
|
return attachment;
|
|
35
|
-
if (message.audioUrl) {
|
|
36
|
-
return {
|
|
37
|
-
kind: 'audio',
|
|
38
|
-
url: message.audioUrl,
|
|
39
|
-
durationMs: message.audioDurationMs,
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
|
-
if (message.imageUrl) {
|
|
43
|
-
return {
|
|
44
|
-
kind: 'image',
|
|
45
|
-
url: message.imageUrl,
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
35
|
return null;
|
|
49
36
|
}
|
|
50
37
|
function renderInboundContent(message) {
|
|
51
|
-
|
|
38
|
+
const content = message.text || '';
|
|
52
39
|
const attachment = getPrimaryAttachment(message);
|
|
40
|
+
// MCP channel mode does not yet materialize media into local files. Surface
|
|
41
|
+
// a placeholder that tells the model media *was* attached without inlining a
|
|
42
|
+
// Firebase Storage URL: the model cannot fetch it, and reasoning about a
|
|
43
|
+
// URL-as-string leads to hallucinated image descriptions. The `attachment_*`
|
|
44
|
+
// fields on the structured `meta` sidechannel still carry the URL for UI /
|
|
45
|
+
// telemetry consumers.
|
|
46
|
+
let placeholder = null;
|
|
53
47
|
if (attachment?.kind === 'audio') {
|
|
54
48
|
const duration = attachment.durationMs ? ` (${Math.round(attachment.durationMs / 1000)}s)` : '';
|
|
55
|
-
|
|
56
|
-
? `[Voice message${duration}: ${attachment.url}]\n${content}`
|
|
57
|
-
: `[Voice message${duration}: ${attachment.url}]`;
|
|
49
|
+
placeholder = `[Voice message${duration} — ask sender to resend if you need the audio]`;
|
|
58
50
|
}
|
|
59
|
-
if (attachment?.kind === 'image') {
|
|
60
|
-
|
|
51
|
+
else if (attachment?.kind === 'image') {
|
|
52
|
+
placeholder = '[Image attached — not yet materialized into this session; ask sender to describe if needed]';
|
|
61
53
|
}
|
|
62
|
-
if (attachment?.kind === 'file') {
|
|
54
|
+
else if (attachment?.kind === 'file') {
|
|
63
55
|
const label = attachment.fileName || 'File';
|
|
64
|
-
|
|
56
|
+
placeholder = `[File: ${label} — not yet materialized into this session]`;
|
|
57
|
+
}
|
|
58
|
+
if (placeholder) {
|
|
59
|
+
return content ? `${placeholder}\n${content}` : placeholder;
|
|
65
60
|
}
|
|
66
61
|
return content;
|
|
67
62
|
}
|
|
@@ -486,10 +481,6 @@ async function handleInboundMessage(payload) {
|
|
|
486
481
|
}
|
|
487
482
|
}
|
|
488
483
|
const primaryAttachment = getPrimaryAttachment(m);
|
|
489
|
-
if (m.imageUrl)
|
|
490
|
-
meta.image_url = m.imageUrl;
|
|
491
|
-
if (m.audioUrl)
|
|
492
|
-
meta.audio_url = m.audioUrl;
|
|
493
484
|
if (primaryAttachment?.url) {
|
|
494
485
|
meta.attachment_kind = primaryAttachment.kind;
|
|
495
486
|
meta.attachment_url = primaryAttachment.url;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface StreamingBufferOptions {
|
|
2
|
+
throttleMs: number;
|
|
3
|
+
getText: () => string;
|
|
4
|
+
setText: (text: string) => void;
|
|
5
|
+
getTimer: () => ReturnType<typeof setTimeout> | null;
|
|
6
|
+
setTimer: (timer: ReturnType<typeof setTimeout> | null) => void;
|
|
7
|
+
onWrite: (text: string) => void;
|
|
8
|
+
schedule?: typeof setTimeout;
|
|
9
|
+
cancel?: typeof clearTimeout;
|
|
10
|
+
}
|
|
11
|
+
export declare function createStreamingBuffer(options: StreamingBufferOptions): {
|
|
12
|
+
append(delta: string): void;
|
|
13
|
+
flush: () => void;
|
|
14
|
+
clear(): void;
|
|
15
|
+
replace(text: string): void;
|
|
16
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export function createStreamingBuffer(options) {
|
|
2
|
+
const schedule = options.schedule ?? setTimeout;
|
|
3
|
+
const cancel = options.cancel ?? clearTimeout;
|
|
4
|
+
const flush = () => {
|
|
5
|
+
const timer = options.getTimer();
|
|
6
|
+
if (timer) {
|
|
7
|
+
cancel(timer);
|
|
8
|
+
options.setTimer(null);
|
|
9
|
+
}
|
|
10
|
+
const text = options.getText();
|
|
11
|
+
if (text) {
|
|
12
|
+
options.onWrite(text);
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
return {
|
|
16
|
+
append(delta) {
|
|
17
|
+
if (!delta)
|
|
18
|
+
return;
|
|
19
|
+
options.setText(`${options.getText()}${delta}`);
|
|
20
|
+
if (options.getTimer())
|
|
21
|
+
return;
|
|
22
|
+
const timer = schedule(() => {
|
|
23
|
+
options.setTimer(null);
|
|
24
|
+
const text = options.getText();
|
|
25
|
+
if (text) {
|
|
26
|
+
options.onWrite(text);
|
|
27
|
+
}
|
|
28
|
+
}, options.throttleMs);
|
|
29
|
+
options.setTimer(timer);
|
|
30
|
+
},
|
|
31
|
+
flush,
|
|
32
|
+
clear() {
|
|
33
|
+
const timer = options.getTimer();
|
|
34
|
+
if (timer) {
|
|
35
|
+
cancel(timer);
|
|
36
|
+
options.setTimer(null);
|
|
37
|
+
}
|
|
38
|
+
options.setText('');
|
|
39
|
+
},
|
|
40
|
+
replace(text) {
|
|
41
|
+
const timer = options.getTimer();
|
|
42
|
+
if (timer) {
|
|
43
|
+
cancel(timer);
|
|
44
|
+
options.setTimer(null);
|
|
45
|
+
}
|
|
46
|
+
options.setText(text);
|
|
47
|
+
if (text) {
|
|
48
|
+
options.onWrite(text);
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@canonmsg/claude-code-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "Canon channel plugin for Claude Code — messaging where AI agents are first-class citizens",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/server.js",
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"@anthropic-ai/claude-agent-sdk": "^0.2.101",
|
|
26
|
-
"@canonmsg/
|
|
26
|
+
"@canonmsg/agent-sdk": "^0.8.1",
|
|
27
|
+
"@canonmsg/core": "^0.7.1",
|
|
27
28
|
"@modelcontextprotocol/sdk": "^1.12.0"
|
|
28
29
|
},
|
|
29
30
|
"engines": {
|