@kraki/tentacle 0.15.4 → 0.16.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.
@@ -0,0 +1,98 @@
1
+ import { existsSync, readFileSync, statSync } from 'node:fs';
2
+ import { extname } from 'node:path';
3
+ /** Hard cap on a single image's raw bytes. Stays well under the relay 10 MB
4
+ * frame cap once base64 + JSON wrapping + RSA-OAEP per-recipient keys are
5
+ * added by the broadcast path. */
6
+ export const SHOW_IMAGE_MAX_BYTES = 8 * 1024 * 1024;
7
+ /** Allowed image MIME types, keyed by lowercase file extension. */
8
+ export const SHOW_IMAGE_MIME_BY_EXT = {
9
+ '.png': 'image/png',
10
+ '.jpg': 'image/jpeg',
11
+ '.jpeg': 'image/jpeg',
12
+ '.webp': 'image/webp',
13
+ '.gif': 'image/gif',
14
+ };
15
+ export const SHOW_IMAGE_TOOL_NAME = 'show_image';
16
+ const DESCRIPTION = 'Display an image to the user in the Kraki chat. Use this when you want to ' +
17
+ 'visually present something to the user — a screenshot, diagram, chart, or ' +
18
+ 'generated graphic. For images you only need to inspect for your own ' +
19
+ 'reasoning, use the standard view/read tools instead; those appear as ' +
20
+ 'collapsible attachments rather than inline displays.';
21
+ export const showImageHandler = async (args, _ctx) => {
22
+ const path = args.path;
23
+ if (typeof path !== 'string' || path.length === 0) {
24
+ return errorResult('Argument "path" is required and must be a non-empty string.');
25
+ }
26
+ if (!path.startsWith('/')) {
27
+ return errorResult('Argument "path" must be an absolute path (start with "/").');
28
+ }
29
+ if (!existsSync(path)) {
30
+ return errorResult(`File not found: ${path}`);
31
+ }
32
+ let stat;
33
+ try {
34
+ stat = statSync(path);
35
+ }
36
+ catch (err) {
37
+ return errorResult(`Could not stat file: ${err.message}`);
38
+ }
39
+ if (!stat.isFile()) {
40
+ return errorResult(`Not a regular file: ${path}`);
41
+ }
42
+ if (stat.size > SHOW_IMAGE_MAX_BYTES) {
43
+ return errorResult(`Image too large: ${stat.size} bytes (max ${SHOW_IMAGE_MAX_BYTES}). ` +
44
+ `Resize or compress before calling show_image.`);
45
+ }
46
+ const ext = extname(path).toLowerCase();
47
+ const mimeType = SHOW_IMAGE_MIME_BY_EXT[ext];
48
+ if (!mimeType) {
49
+ return errorResult(`Unsupported image type "${ext || '(no extension)'}". ` +
50
+ `Supported: ${Object.keys(SHOW_IMAGE_MIME_BY_EXT).join(', ')}`);
51
+ }
52
+ let bytes;
53
+ try {
54
+ bytes = readFileSync(path);
55
+ }
56
+ catch (err) {
57
+ return errorResult(`Failed to read file: ${err.message}`);
58
+ }
59
+ const caption = typeof args.caption === 'string' ? args.caption.trim() : '';
60
+ const text = caption
61
+ ? `Image displayed to user. Caption: ${caption}`
62
+ : 'Image displayed to user.';
63
+ return {
64
+ content: [
65
+ { type: 'image', mimeType, data: bytes.toString('base64') },
66
+ { type: 'text', text },
67
+ ],
68
+ };
69
+ };
70
+ function errorResult(message) {
71
+ return {
72
+ content: [{ type: 'text', text: message }],
73
+ isError: true,
74
+ };
75
+ }
76
+ export const showImageTool = {
77
+ definition: {
78
+ name: SHOW_IMAGE_TOOL_NAME,
79
+ description: DESCRIPTION,
80
+ inputSchema: {
81
+ type: 'object',
82
+ properties: {
83
+ path: {
84
+ type: 'string',
85
+ description: 'Absolute path to the image file on disk. Supported formats: PNG, JPEG, WebP, GIF (non-animated).',
86
+ },
87
+ caption: {
88
+ type: 'string',
89
+ description: 'Optional caption shown alongside the image.',
90
+ },
91
+ },
92
+ required: ['path'],
93
+ additionalProperties: false,
94
+ },
95
+ },
96
+ handler: showImageHandler,
97
+ };
98
+ //# sourceMappingURL=show-image.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"show-image.js","sourceRoot":"","sources":["../../../src/mcp/tools/show-image.ts"],"names":[],"mappings":"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"}
@@ -62,13 +62,26 @@ export declare class RelayClient {
62
62
  private static readonly STALE_THRESHOLD;
63
63
  /** How often to check for stale connection (ms) */
64
64
  private static readonly STALE_CHECK_INTERVAL;
65
+ /** Highest relaySeq received from head on this connection. Echoed back as
66
+ * `ack` in outbound messages so head can prune its in-flight buffer. */
67
+ private lastReceivedRelaySeq;
68
+ /** Recent relaySeqs already processed — used to silently drop duplicate
69
+ * retries from head. Bounded; cleared on reconnect. */
70
+ private seenRelaySeqs;
71
+ private static readonly RELAY_SEQ_DEDUP_WINDOW;
65
72
  /** Called when relay state changes */
66
73
  onStateChange: ((state: RelayClientState) => void) | null;
67
74
  /** Called on auth success */
68
75
  onAuthenticated: ((info: AuthOkMessage) => void) | null;
69
76
  /** Called on fatal error (won't reconnect) */
70
77
  onFatalError: ((message: string) => void) | null;
71
- constructor(adapter: AgentAdapter, sessionManager: SessionManager, options: RelayClientOptions, keyManager?: KeyManager | null);
78
+ constructor(adapter: AgentAdapter, sessionManager: SessionManager, options: RelayClientOptions, keyManager?: KeyManager | null, attachmentStore?: import('./attachment-store.js').AttachmentStore);
79
+ private readonly attachmentStore?;
80
+ /** Track attachment ids already broadcast per session — prevents double-push
81
+ * if the agent calls show_image twice with the same image bytes. The
82
+ * receiving devices still see the ref each time (so the chat history is
83
+ * correct); they just don't get a redundant byte broadcast. */
84
+ private broadcastedAttachmentIds;
72
85
  /**
73
86
  * Connect to the relay. Auto-reconnects on disconnect.
74
87
  */
@@ -112,11 +125,33 @@ export declare class RelayClient {
112
125
  * Handle a per-session replay request from a reconnecting app.
113
126
  */
114
127
  private handleSessionReplay;
128
+ /** Plaintext chunk budget for `attachment_data` envelopes.
129
+ * Stays under the relay's 10 MB frame cap after base64 + envelope. */
130
+ private static readonly ATTACHMENT_CHUNK_BYTES;
131
+ /**
132
+ * Stream an attachment's bytes to all connected consumers as
133
+ * `attachment_data` chunks. Called immediately after the producer fires
134
+ * a `tool_complete` carrying the matching `AttachmentRef`. Skipped if
135
+ * we've already broadcast this id within the session.
136
+ */
137
+ private broadcastAttachmentBytes;
138
+ /**
139
+ * Serve a `request_attachment` from a consumer device. Reads from the
140
+ * AttachmentStore, encrypts chunked unicasts to the requester only.
141
+ */
142
+ private handleRequestAttachment;
143
+ private unicastAttachmentError;
115
144
  /**
116
145
  * Write web app debug logs to a local file.
117
146
  */
118
147
  private handleClientLog;
119
148
  private send;
149
+ /** Inject cumulative ack into an outbound envelope. Piggybacks delivery
150
+ * acknowledgments on existing traffic — no dedicated ack message. */
151
+ private withAck;
152
+ /** Update inbound relaySeq tracking. Returns true if this is a duplicate
153
+ * retry from head and should be silently dropped. */
154
+ private trackInboundRelaySeq;
120
155
  /**
121
156
  * Encrypt and send a message to the relay as a BroadcastEnvelope.
122
157
  */
@@ -41,6 +41,9 @@ export class RelayClient {
41
41
  'session_model_set',
42
42
  'session_pinned',
43
43
  'session_read',
44
+ // attachment_data carries raw bytes and is regeneratable from the
45
+ // AttachmentStore — never persisted into messages.jsonl.
46
+ 'attachment_data',
44
47
  ]);
45
48
  /** Global seq counter for envelope ordering (not used for replay — per-session seq handles that). */
46
49
  seqCounter = 0;
@@ -61,19 +64,34 @@ export class RelayClient {
61
64
  static STALE_THRESHOLD = 60_000;
62
65
  /** How often to check for stale connection (ms) */
63
66
  static STALE_CHECK_INTERVAL = 10_000;
67
+ // ── Delivery assurance state (per-connection) ──────────────
68
+ /** Highest relaySeq received from head on this connection. Echoed back as
69
+ * `ack` in outbound messages so head can prune its in-flight buffer. */
70
+ lastReceivedRelaySeq = 0;
71
+ /** Recent relaySeqs already processed — used to silently drop duplicate
72
+ * retries from head. Bounded; cleared on reconnect. */
73
+ seenRelaySeqs = new Set();
74
+ static RELAY_SEQ_DEDUP_WINDOW = 200;
64
75
  /** Called when relay state changes */
65
76
  onStateChange = null;
66
77
  /** Called on auth success */
67
78
  onAuthenticated = null;
68
79
  /** Called on fatal error (won't reconnect) */
69
80
  onFatalError = null;
70
- constructor(adapter, sessionManager, options, keyManager) {
81
+ constructor(adapter, sessionManager, options, keyManager, attachmentStore) {
71
82
  this.adapter = adapter;
72
83
  this.sessionManager = sessionManager;
73
84
  this.options = options;
74
85
  this.keyManager = keyManager ?? null;
86
+ this.attachmentStore = attachmentStore;
75
87
  this.wireAdapterEvents();
76
88
  }
89
+ attachmentStore;
90
+ /** Track attachment ids already broadcast per session — prevents double-push
91
+ * if the agent calls show_image twice with the same image bytes. The
92
+ * receiving devices still see the ref each time (so the chat history is
93
+ * correct); they just don't get a redundant byte broadcast. */
94
+ broadcastedAttachmentIds = new Map();
77
95
  /**
78
96
  * Connect to the relay. Auto-reconnects on disconnect.
79
97
  */
@@ -82,6 +100,9 @@ export class RelayClient {
82
100
  return;
83
101
  this.intentionalDisconnect = false;
84
102
  this.setState('connecting');
103
+ // Reset delivery-assurance state — relaySeq is per-connection.
104
+ this.lastReceivedRelaySeq = 0;
105
+ this.seenRelaySeqs.clear();
85
106
  const ws = new WebSocket(this.options.relayUrl);
86
107
  this.ws = ws;
87
108
  ws.on('open', () => {
@@ -100,6 +121,9 @@ export class RelayClient {
100
121
  this.lastActivityAt = Date.now();
101
122
  try {
102
123
  const msg = JSON.parse(data.toString());
124
+ // Dedup duplicate retries from head silently.
125
+ if (this.trackInboundRelaySeq(msg))
126
+ return;
103
127
  this.handleMessage(msg);
104
128
  }
105
129
  catch {
@@ -208,7 +232,10 @@ export class RelayClient {
208
232
  }
209
233
  if (msg.type === 'ping') {
210
234
  if (this.ws?.readyState === WebSocket.OPEN) {
211
- this.ws.send(JSON.stringify({ type: 'pong' }));
235
+ const pong = { type: 'pong' };
236
+ if (this.lastReceivedRelaySeq > 0)
237
+ pong.ack = this.lastReceivedRelaySeq;
238
+ this.ws.send(JSON.stringify(pong));
212
239
  }
213
240
  return;
214
241
  }
@@ -328,6 +355,12 @@ export class RelayClient {
328
355
  this.handleImportSession(msg);
329
356
  return;
330
357
  }
358
+ if (msg.type === 'request_attachment') {
359
+ this.handleRequestAttachment(msg).catch((err) => {
360
+ logger.warn({ err, attachmentId: msg.payload?.id }, 'request_attachment failed');
361
+ });
362
+ return;
363
+ }
331
364
  const sessionId = msg.sessionId;
332
365
  if (!sessionId)
333
366
  return;
@@ -863,6 +896,17 @@ export class RelayClient {
863
896
  },
864
897
  });
865
898
  };
899
+ this.adapter.onAttachmentBytes = (sessionId, event) => {
900
+ // Fire-and-forget — chunks are streamed from disk and pushed via the
901
+ // normal broadcast queue. We deliberately don't block onToolComplete
902
+ // on chunk delivery; both events ride the same WS queue so ordering
903
+ // (ref-first, chunks-after) is preserved naturally.
904
+ for (const ref of event.refs) {
905
+ this.broadcastAttachmentBytes(sessionId, ref).catch((err) => {
906
+ logger.warn({ err, sessionId, attachmentId: ref.id }, 'failed to broadcast attachment bytes');
907
+ });
908
+ }
909
+ };
866
910
  this.adapter.onIdle = (sessionId) => {
867
911
  this.sessionManager.markIdle(sessionId);
868
912
  const usage = this.adapter.getSessionUsage(sessionId) ?? undefined;
@@ -1011,6 +1055,111 @@ export class RelayClient {
1011
1055
  };
1012
1056
  this.sendUnicastTo(requesterDeviceId, requesterKey, batchMsg);
1013
1057
  }
1058
+ // ── Attachment bytes ────────────────────────────────
1059
+ /** Plaintext chunk budget for `attachment_data` envelopes.
1060
+ * Stays under the relay's 10 MB frame cap after base64 + envelope. */
1061
+ static ATTACHMENT_CHUNK_BYTES = 2 * 1024 * 1024;
1062
+ /**
1063
+ * Stream an attachment's bytes to all connected consumers as
1064
+ * `attachment_data` chunks. Called immediately after the producer fires
1065
+ * a `tool_complete` carrying the matching `AttachmentRef`. Skipped if
1066
+ * we've already broadcast this id within the session.
1067
+ */
1068
+ async broadcastAttachmentBytes(sessionId, ref) {
1069
+ if (!this.attachmentStore) {
1070
+ logger.warn({ sessionId, attachmentId: ref.id }, 'no attachment store — skipping broadcast');
1071
+ return;
1072
+ }
1073
+ let seen = this.broadcastedAttachmentIds.get(sessionId);
1074
+ if (!seen) {
1075
+ seen = new Set();
1076
+ this.broadcastedAttachmentIds.set(sessionId, seen);
1077
+ }
1078
+ if (seen.has(ref.id))
1079
+ return;
1080
+ seen.add(ref.id);
1081
+ const total = Math.max(1, Math.ceil(ref.size / RelayClient.ATTACHMENT_CHUNK_BYTES));
1082
+ let index = 0;
1083
+ for (const chunk of this.attachmentStore.stream(sessionId, ref.id, RelayClient.ATTACHMENT_CHUNK_BYTES)) {
1084
+ this.send({
1085
+ type: 'attachment_data',
1086
+ sessionId,
1087
+ payload: {
1088
+ id: ref.id,
1089
+ index,
1090
+ total,
1091
+ mimeType: ref.mimeType,
1092
+ data: chunk.toString('base64'),
1093
+ },
1094
+ });
1095
+ index += 1;
1096
+ }
1097
+ if (index === 0) {
1098
+ // The ref was emitted but bytes are no longer on disk — odd state, but
1099
+ // notify consumers with a structured error so they don't sit on a
1100
+ // pending placeholder forever.
1101
+ logger.warn({ sessionId, attachmentId: ref.id }, 'no bytes on disk for ref, sending error chunk');
1102
+ this.send({
1103
+ type: 'attachment_data',
1104
+ sessionId,
1105
+ payload: { id: ref.id, index: 0, total: 0, mimeType: ref.mimeType, data: '', error: 'not_found' },
1106
+ });
1107
+ }
1108
+ }
1109
+ /**
1110
+ * Serve a `request_attachment` from a consumer device. Reads from the
1111
+ * AttachmentStore, encrypts chunked unicasts to the requester only.
1112
+ */
1113
+ async handleRequestAttachment(msg) {
1114
+ if (msg.type !== 'request_attachment')
1115
+ return;
1116
+ const { id, sessionId } = msg.payload;
1117
+ const requesterDeviceId = msg.deviceId;
1118
+ const requesterKey = this.consumerKeys.get(requesterDeviceId);
1119
+ if (!requesterKey) {
1120
+ logger.warn({ requesterDeviceId }, 'request_attachment: no key for requester');
1121
+ return;
1122
+ }
1123
+ if (!this.attachmentStore) {
1124
+ this.unicastAttachmentError(requesterDeviceId, requesterKey, sessionId, id, 'not_found');
1125
+ return;
1126
+ }
1127
+ const got = this.attachmentStore.read(sessionId, id);
1128
+ if (!got) {
1129
+ this.unicastAttachmentError(requesterDeviceId, requesterKey, sessionId, id, 'not_found');
1130
+ return;
1131
+ }
1132
+ const total = Math.max(1, Math.ceil(got.bytes.length / RelayClient.ATTACHMENT_CHUNK_BYTES));
1133
+ for (let i = 0; i < total; i++) {
1134
+ const slice = got.bytes.subarray(i * RelayClient.ATTACHMENT_CHUNK_BYTES, Math.min((i + 1) * RelayClient.ATTACHMENT_CHUNK_BYTES, got.bytes.length));
1135
+ const chunkMsg = {
1136
+ type: 'attachment_data',
1137
+ deviceId: this.authInfo?.deviceId ?? '',
1138
+ sessionId,
1139
+ seq: ++this.seqCounter,
1140
+ timestamp: new Date().toISOString(),
1141
+ payload: {
1142
+ id,
1143
+ index: i,
1144
+ total,
1145
+ mimeType: got.meta.mimeType,
1146
+ data: slice.toString('base64'),
1147
+ },
1148
+ };
1149
+ this.sendUnicastTo(requesterDeviceId, requesterKey, chunkMsg);
1150
+ }
1151
+ }
1152
+ unicastAttachmentError(requesterDeviceId, requesterKey, sessionId, id, error) {
1153
+ const errorMsg = {
1154
+ type: 'attachment_data',
1155
+ deviceId: this.authInfo?.deviceId ?? '',
1156
+ sessionId,
1157
+ seq: ++this.seqCounter,
1158
+ timestamp: new Date().toISOString(),
1159
+ payload: { id, index: 0, total: 0, mimeType: '', data: '', error },
1160
+ };
1161
+ this.sendUnicastTo(requesterDeviceId, requesterKey, errorMsg);
1162
+ }
1014
1163
  // ── Client log shipping ─────────────────────────────
1015
1164
  /**
1016
1165
  * Write web app debug logs to a local file.
@@ -1074,12 +1223,41 @@ export class RelayClient {
1074
1223
  return;
1075
1224
  }
1076
1225
  try {
1077
- this.ws.send(JSON.stringify(msg));
1226
+ this.ws.send(JSON.stringify(this.withAck(msg)));
1078
1227
  }
1079
1228
  catch (err) {
1080
1229
  logger.error({ err }, 'ws.send failed');
1081
1230
  }
1082
1231
  }
1232
+ /** Inject cumulative ack into an outbound envelope. Piggybacks delivery
1233
+ * acknowledgments on existing traffic — no dedicated ack message. */
1234
+ withAck(msg) {
1235
+ if (this.lastReceivedRelaySeq <= 0)
1236
+ return msg;
1237
+ return { ...msg, ack: this.lastReceivedRelaySeq };
1238
+ }
1239
+ /** Update inbound relaySeq tracking. Returns true if this is a duplicate
1240
+ * retry from head and should be silently dropped. */
1241
+ trackInboundRelaySeq(msg) {
1242
+ if (!('relaySeq' in msg))
1243
+ return false;
1244
+ const seq = msg.relaySeq;
1245
+ if (typeof seq !== 'number' || !Number.isFinite(seq) || seq <= 0)
1246
+ return false;
1247
+ if (this.seenRelaySeqs.has(seq)) {
1248
+ return true;
1249
+ }
1250
+ if (this.seenRelaySeqs.size >= RelayClient.RELAY_SEQ_DEDUP_WINDOW) {
1251
+ const iter = this.seenRelaySeqs.values().next();
1252
+ if (!iter.done)
1253
+ this.seenRelaySeqs.delete(iter.value);
1254
+ }
1255
+ this.seenRelaySeqs.add(seq);
1256
+ if (seq > this.lastReceivedRelaySeq) {
1257
+ this.lastReceivedRelaySeq = seq;
1258
+ }
1259
+ return false;
1260
+ }
1083
1261
  /**
1084
1262
  * Encrypt and send a message to the relay as a BroadcastEnvelope.
1085
1263
  */
@@ -1127,7 +1305,7 @@ export class RelayClient {
1127
1305
  const previewBlob = encryptToBlob(preview, recipients);
1128
1306
  envelope.pushPreview = { blob: previewBlob.blob, keys: previewBlob.keys };
1129
1307
  }
1130
- this.ws.send(JSON.stringify(envelope));
1308
+ this.ws.send(JSON.stringify(this.withAck(envelope)));
1131
1309
  }
1132
1310
  catch (err) {
1133
1311
  logger.error({ err }, 'Encrypted broadcast failed');
@@ -1151,7 +1329,7 @@ export class RelayClient {
1151
1329
  blob,
1152
1330
  keys,
1153
1331
  };
1154
- this.ws.send(JSON.stringify(envelope));
1332
+ this.ws.send(JSON.stringify(this.withAck(envelope)));
1155
1333
  }
1156
1334
  catch (err) {
1157
1335
  logger.error({ err, targetDeviceId }, 'Encrypted unicast failed');