@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.
- package/README.md +2 -0
- package/dist/adapters/base.d.ts +9 -0
- package/dist/adapters/base.js +3 -0
- package/dist/adapters/base.js.map +1 -1
- package/dist/adapters/copilot.d.ts +36 -0
- package/dist/adapters/copilot.js +206 -60
- package/dist/adapters/copilot.js.map +1 -1
- package/dist/attachment-store.d.ts +73 -0
- package/dist/attachment-store.js +317 -0
- package/dist/attachment-store.js.map +1 -0
- package/dist/daemon-worker.js +34 -2
- package/dist/daemon-worker.js.map +1 -1
- package/dist/mcp/index.d.ts +7 -0
- package/dist/mcp/index.js +5 -0
- package/dist/mcp/index.js.map +1 -0
- package/dist/mcp/protocol.d.ts +82 -0
- package/dist/mcp/protocol.js +19 -0
- package/dist/mcp/protocol.js.map +1 -0
- package/dist/mcp/server.d.ts +58 -0
- package/dist/mcp/server.js +253 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/tools/index.d.ts +24 -0
- package/dist/mcp/tools/index.js +19 -0
- package/dist/mcp/tools/index.js.map +1 -0
- package/dist/mcp/tools/show-image.d.ts +10 -0
- package/dist/mcp/tools/show-image.js +98 -0
- package/dist/mcp/tools/show-image.js.map +1 -0
- package/dist/relay-client.d.ts +36 -1
- package/dist/relay-client.js +183 -5
- package/dist/relay-client.js.map +1 -1
- package/dist/session-manager.d.ts +31 -0
- package/dist/session-manager.js +121 -0
- package/dist/session-manager.js.map +1 -1
- package/dist/update.js +59 -7
- package/dist/update.js.map +1 -1
- package/package.json +2 -2
|
@@ -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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"}
|
package/dist/relay-client.d.ts
CHANGED
|
@@ -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
|
*/
|
package/dist/relay-client.js
CHANGED
|
@@ -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
|
-
|
|
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');
|