@vdoninja/ninja-p2p 0.1.2 → 0.2.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.
Files changed (47) hide show
  1. package/.agents/skills/ninja-p2p/SKILL.md +102 -2
  2. package/.claude/skills/ninja-p2p/SKILL.md +209 -130
  3. package/.codex/skills/ninja-p2p/SKILL.md +102 -2
  4. package/CHANGELOG.md +113 -0
  5. package/README.md +1077 -825
  6. package/dashboard.html +370 -50
  7. package/dist/agent-state.js +17 -1
  8. package/dist/cli-lib.d.ts +40 -0
  9. package/dist/cli-lib.js +165 -2
  10. package/dist/cli.d.ts +2 -0
  11. package/dist/cli.js +501 -10
  12. package/dist/demo.d.ts +37 -0
  13. package/dist/demo.js +186 -0
  14. package/dist/doctor.d.ts +52 -0
  15. package/dist/doctor.js +219 -0
  16. package/dist/file-transfer.d.ts +15 -2
  17. package/dist/file-transfer.js +249 -15
  18. package/dist/index.d.ts +1 -1
  19. package/dist/index.js +1 -1
  20. package/dist/message-bus.d.ts +17 -1
  21. package/dist/message-bus.js +55 -16
  22. package/dist/peer-registry.js +7 -0
  23. package/dist/protocol.d.ts +78 -1
  24. package/dist/protocol.js +42 -6
  25. package/dist/shared-folders.js +20 -1
  26. package/dist/social-stream.d.ts +100 -0
  27. package/dist/social-stream.js +254 -0
  28. package/dist/swarm-manager.d.ts +164 -0
  29. package/dist/swarm-manager.js +957 -0
  30. package/dist/swarm-session.d.ts +197 -0
  31. package/dist/swarm-session.js +465 -0
  32. package/dist/swarm-wire.d.ts +48 -0
  33. package/dist/swarm-wire.js +86 -0
  34. package/dist/swarm.d.ts +258 -0
  35. package/dist/swarm.js +694 -0
  36. package/dist/vdo-bridge.d.ts +62 -1
  37. package/dist/vdo-bridge.js +280 -30
  38. package/dist/vdoninja-sdk-types.d.ts +75 -0
  39. package/dist/vdoninja-sdk-types.js +10 -0
  40. package/dist/wake.d.ts +83 -0
  41. package/dist/wake.js +206 -0
  42. package/docs/images/agent-room-dashboard.png +0 -0
  43. package/docs/protocol-and-reliability.md +231 -0
  44. package/docs/sdk-wishlist.md +236 -0
  45. package/docs/security.md +197 -0
  46. package/docs/social-stream-bridge.md +390 -0
  47. package/package.json +125 -113
@@ -1,11 +1,31 @@
1
- import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync, } from "node:fs";
1
+ import { appendFileSync, constants, closeSync, copyFileSync, existsSync, linkSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, rmSync, statSync, writeFileSync, } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { createHash } from "node:crypto";
4
4
  import { createMessageId, } from "./protocol.js";
5
5
  export const DEFAULT_TRANSFER_CHUNK_SIZE = 12_000;
6
+ /** The simple transfer path buffers at the sender; use swarm transfer above this. */
7
+ export const MAX_BASIC_TRANSFER_SIZE = 256 * 1024 * 1024;
8
+ export const MAX_BASIC_TRANSFER_CHUNK_SIZE = 1024 * 1024;
9
+ export const MAX_BASIC_TRANSFER_CHUNKS = Math.ceil(MAX_BASIC_TRANSFER_SIZE / 1_024);
10
+ export const MAX_INCOMPLETE_TRANSFER_BYTES = 512 * 1024 * 1024;
11
+ export const MAX_INCOMPLETE_TRANSFERS = 16;
12
+ export const INCOMPLETE_TRANSFER_STALE_MS = 24 * 60 * 60_000;
13
+ export const TRANSFER_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/;
14
+ const SHA256_PATTERN = /^[0-9a-f]{64}$/;
15
+ const BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
6
16
  export function prepareFileTransferFromPath(filePath, kind = "file") {
7
17
  const resolved = path.resolve(filePath);
18
+ const file = statSync(resolved);
19
+ if (!file.isFile())
20
+ throw new Error(`not a file: ${resolved}`);
21
+ if (file.size > MAX_BASIC_TRANSFER_SIZE) {
22
+ throw new Error(`file is ${file.size} bytes; simple transfers are limited to ${MAX_BASIC_TRANSFER_SIZE} bytes ` +
23
+ "(use `ninja-p2p seed` / `fetch` for larger files)");
24
+ }
8
25
  const bytes = new Uint8Array(readFileSync(resolved));
26
+ if (bytes.byteLength > MAX_BASIC_TRANSFER_SIZE) {
27
+ throw new Error(`file grew beyond the ${MAX_BASIC_TRANSFER_SIZE}-byte simple-transfer limit while reading`);
28
+ }
9
29
  const name = path.basename(resolved);
10
30
  return {
11
31
  name,
@@ -24,7 +44,16 @@ export function sendPreparedFileTransfer(bridge, targetStreamId, prepared, chunk
24
44
  if (!bridge.peers.isConnected(targetStreamId)) {
25
45
  throw new Error(`peer is not connected: ${targetStreamId}`);
26
46
  }
27
- const normalizedChunkSize = Math.max(1_024, chunkSize);
47
+ if (!Number.isFinite(chunkSize) || chunkSize <= 0) {
48
+ throw new Error(`invalid chunk size: ${chunkSize}`);
49
+ }
50
+ if (prepared.size > MAX_BASIC_TRANSFER_SIZE) {
51
+ throw new Error(`simple transfers are limited to ${MAX_BASIC_TRANSFER_SIZE} bytes`);
52
+ }
53
+ const normalizedChunkSize = Math.max(1_024, Math.floor(chunkSize));
54
+ if (normalizedChunkSize > MAX_BASIC_TRANSFER_CHUNK_SIZE) {
55
+ throw new Error(`chunk size exceeds ${MAX_BASIC_TRANSFER_CHUNK_SIZE} bytes`);
56
+ }
28
57
  const totalChunks = prepared.size === 0 ? 0 : Math.ceil(prepared.size / normalizedChunkSize);
29
58
  const transferId = createMessageId();
30
59
  const offer = {
@@ -37,7 +66,9 @@ export function sendPreparedFileTransfer(bridge, targetStreamId, prepared, chunk
37
66
  chunkSize: normalizedChunkSize,
38
67
  totalChunks,
39
68
  };
40
- bridge.bus.send(targetStreamId, "file_offer", offer);
69
+ if (!bridge.bus.trySend(targetStreamId, "file_offer", offer)) {
70
+ throw new Error(`peer did not accept file offer: ${targetStreamId}`);
71
+ }
41
72
  for (let index = 0; index < totalChunks; index += 1) {
42
73
  const start = index * normalizedChunkSize;
43
74
  const end = Math.min(start + normalizedChunkSize, prepared.size);
@@ -48,27 +79,44 @@ export function sendPreparedFileTransfer(bridge, targetStreamId, prepared, chunk
48
79
  totalChunks,
49
80
  data: bytesToBase64(chunk),
50
81
  };
51
- bridge.bus.send(targetStreamId, "file_chunk", payload);
82
+ if (!bridge.bus.trySend(targetStreamId, "file_chunk", payload)) {
83
+ throw new Error(`peer stopped accepting ${prepared.name} at chunk ${index}/${totalChunks}`);
84
+ }
52
85
  }
53
- bridge.bus.send(targetStreamId, "file_complete", {
86
+ if (!bridge.bus.trySend(targetStreamId, "file_complete", {
54
87
  transferId,
55
88
  totalChunks,
56
89
  size: prepared.size,
57
90
  sha256: prepared.sha256,
58
- });
91
+ })) {
92
+ throw new Error(`peer did not accept completion for ${prepared.name}`);
93
+ }
59
94
  return offer;
60
95
  }
61
96
  export function sendFileFromPath(bridge, targetStreamId, filePath, kind = "file", chunkSize = DEFAULT_TRANSFER_CHUNK_SIZE) {
62
97
  return sendPreparedFileTransfer(bridge, targetStreamId, prepareFileTransferFromPath(filePath, kind), chunkSize);
63
98
  }
64
99
  export function beginIncomingTransfer(stateDir, from, offer) {
100
+ validateFileOffer(offer);
101
+ if (!from || typeof from.streamId !== "string" || !from.streamId) {
102
+ throw new Error("file offer has no valid sender");
103
+ }
65
104
  const paths = getTransferPaths(stateDir, offer.transferId);
66
105
  mkdirSync(paths.transfersDir, { recursive: true });
67
106
  mkdirSync(paths.downloadsDir, { recursive: true });
68
107
  const existing = readTransferManifest(stateDir, offer.transferId);
69
108
  if (existing) {
109
+ if (existing.from.streamId !== from.streamId ||
110
+ existing.name !== offer.name ||
111
+ existing.size !== offer.size ||
112
+ existing.sha256 !== offer.sha256 ||
113
+ existing.chunkSize !== offer.chunkSize ||
114
+ existing.totalChunks !== offer.totalChunks) {
115
+ throw new Error(`transfer id is already in use: ${offer.transferId}`);
116
+ }
70
117
  return existing;
71
118
  }
119
+ enforceIncomingTransferQuota(stateDir, offer.size);
72
120
  writeFileSync(paths.tempPath, new Uint8Array(0));
73
121
  const safeName = sanitizeFileName(offer.name);
74
122
  const manifest = {
@@ -92,18 +140,35 @@ export function beginIncomingTransfer(stateDir, from, offer) {
92
140
  writeManifest(paths.manifestPath, manifest);
93
141
  return manifest;
94
142
  }
95
- export function appendIncomingTransferChunk(stateDir, payload) {
143
+ export function appendIncomingTransferChunk(stateDir, payload, fromStreamId) {
144
+ validateTransferId(payload?.transferId);
96
145
  const manifest = mustReadTransferManifest(stateDir, payload.transferId);
146
+ if (fromStreamId && manifest.from.streamId !== fromStreamId) {
147
+ throw new Error(`chunk sender does not own transfer ${payload.transferId}`);
148
+ }
97
149
  if (manifest.completedAt) {
98
150
  return manifest;
99
151
  }
152
+ if (!Number.isInteger(payload.index) || payload.index < 0 || payload.index >= manifest.totalChunks) {
153
+ throw new Error(`invalid chunk index ${payload.index}`);
154
+ }
100
155
  if (payload.index !== manifest.receivedChunks) {
101
156
  throw new Error(`unexpected chunk index ${payload.index}; expected ${manifest.receivedChunks}`);
102
157
  }
103
158
  if (payload.totalChunks !== manifest.totalChunks) {
104
159
  throw new Error(`unexpected totalChunks ${payload.totalChunks}; expected ${manifest.totalChunks}`);
105
160
  }
161
+ if (typeof payload.data !== "string" || !BASE64_PATTERN.test(payload.data)) {
162
+ throw new Error("chunk data is not valid base64");
163
+ }
106
164
  const bytes = base64ToBytes(payload.data);
165
+ const expectedLength = Math.min(manifest.chunkSize, manifest.size - payload.index * manifest.chunkSize);
166
+ if (bytes.byteLength !== expectedLength) {
167
+ throw new Error(`unexpected chunk length ${bytes.byteLength}; expected ${expectedLength}`);
168
+ }
169
+ if (manifest.receivedBytes + bytes.byteLength > manifest.size) {
170
+ throw new Error("chunk would exceed the offered file size");
171
+ }
107
172
  appendFileSync(manifest.tempPath, bytes);
108
173
  manifest.receivedChunks += 1;
109
174
  manifest.receivedBytes += bytes.byteLength;
@@ -111,9 +176,19 @@ export function appendIncomingTransferChunk(stateDir, payload) {
111
176
  writeManifest(getTransferPaths(stateDir, payload.transferId).manifestPath, manifest);
112
177
  return manifest;
113
178
  }
114
- export function completeIncomingTransfer(stateDir, payload) {
179
+ export function completeIncomingTransfer(stateDir, payload, fromStreamId) {
180
+ validateTransferId(payload?.transferId);
115
181
  const manifest = mustReadTransferManifest(stateDir, payload.transferId);
182
+ if (fromStreamId && manifest.from.streamId !== fromStreamId) {
183
+ throw new Error(`completion sender does not own transfer ${payload.transferId}`);
184
+ }
116
185
  if (!manifest.completedAt) {
186
+ if (!Number.isInteger(payload.totalChunks) || !Number.isSafeInteger(payload.size)) {
187
+ throw new Error("invalid completion metadata");
188
+ }
189
+ if (typeof payload.sha256 !== "string" || !SHA256_PATTERN.test(payload.sha256)) {
190
+ throw new Error("invalid completion sha256");
191
+ }
117
192
  if (payload.totalChunks !== manifest.totalChunks) {
118
193
  throw new Error(`unexpected totalChunks ${payload.totalChunks}; expected ${manifest.totalChunks}`);
119
194
  }
@@ -123,12 +198,14 @@ export function completeIncomingTransfer(stateDir, payload) {
123
198
  if (manifest.receivedBytes !== manifest.size || payload.size !== manifest.size) {
124
199
  throw new Error(`transfer size mismatch: received ${manifest.receivedBytes}, expected ${manifest.size}`);
125
200
  }
126
- const bytes = new Uint8Array(readFileSync(manifest.tempPath));
127
- const sha256 = sha256Hex(bytes);
128
- if (sha256 !== manifest.sha256 || payload.sha256 !== manifest.sha256) {
201
+ const file = sha256File(manifest.tempPath);
202
+ if (file.size !== manifest.size) {
203
+ throw new Error(`transfer size mismatch on disk: ${file.size}, expected ${manifest.size}`);
204
+ }
205
+ if (file.sha256 !== manifest.sha256 || payload.sha256 !== manifest.sha256) {
129
206
  throw new Error("transfer sha256 mismatch");
130
207
  }
131
- renameSync(manifest.tempPath, manifest.savedPath);
208
+ moveFileExclusive(manifest.tempPath, manifest.savedPath);
132
209
  manifest.completedAt = Date.now();
133
210
  manifest.updatedAt = manifest.completedAt;
134
211
  writeManifest(getTransferPaths(stateDir, payload.transferId).manifestPath, manifest);
@@ -168,6 +245,25 @@ export function readTransferManifest(stateDir, transferId) {
168
245
  return null;
169
246
  return JSON.parse(readFileSync(manifestPath, "utf8"));
170
247
  }
248
+ /**
249
+ * Remove a transfer that cannot be completed. Sender matching prevents a peer
250
+ * from aborting somebody else's transfer by guessing its id.
251
+ */
252
+ export function discardIncomingTransfer(stateDir, transferId, expectedSender) {
253
+ try {
254
+ validateTransferId(transferId);
255
+ const manifest = readTransferManifest(stateDir, transferId);
256
+ if (expectedSender && manifest?.from.streamId !== expectedSender)
257
+ return false;
258
+ const paths = getTransferPaths(stateDir, transferId);
259
+ rmSync(paths.tempPath, { force: true });
260
+ rmSync(paths.manifestPath, { force: true });
261
+ return true;
262
+ }
263
+ catch {
264
+ return false;
265
+ }
266
+ }
171
267
  function mustReadTransferManifest(stateDir, transferId) {
172
268
  const manifest = readTransferManifest(stateDir, transferId);
173
269
  if (!manifest) {
@@ -176,6 +272,7 @@ function mustReadTransferManifest(stateDir, transferId) {
176
272
  return manifest;
177
273
  }
178
274
  function getTransferPaths(stateDir, transferId) {
275
+ validateTransferId(transferId);
179
276
  const transfersDir = path.join(path.resolve(stateDir), "transfers");
180
277
  const downloadsDir = path.join(path.resolve(stateDir), "downloads");
181
278
  return {
@@ -196,11 +293,24 @@ function chooseSavedPath(downloadsDir, safeName, transferId) {
196
293
  if (!existsSync(direct)) {
197
294
  return direct;
198
295
  }
199
- return path.join(downloadsDir, `${base}_${transferId}${ext}`);
296
+ const tagged = path.join(downloadsDir, `${base}_${transferId}${ext}`);
297
+ if (!existsSync(tagged))
298
+ return tagged;
299
+ for (let suffix = 2; suffix < 100_000; suffix += 1) {
300
+ const candidate = path.join(downloadsDir, `${base}_${transferId}_${suffix}${ext}`);
301
+ if (!existsSync(candidate))
302
+ return candidate;
303
+ }
304
+ throw new Error(`could not choose a free destination for ${safeName}`);
200
305
  }
201
306
  function sanitizeFileName(name) {
202
- const base = path.basename(name || "download");
203
- return base.replace(/[<>:"/\\|?*\x00-\x1F]/g, "_") || "download";
307
+ let safe = path.basename(name || "download")
308
+ .replace(/[<>:"/\\|?*\x00-\x1F]/g, "_")
309
+ .replace(/[. ]+$/g, "")
310
+ .slice(0, 180) || "download";
311
+ if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(safe))
312
+ safe = `_${safe}`;
313
+ return safe;
204
314
  }
205
315
  function guessMimeType(name, kind) {
206
316
  const ext = path.extname(name).toLowerCase();
@@ -233,6 +343,130 @@ export function bytesToBase64(bytes) {
233
343
  export function base64ToBytes(value) {
234
344
  return new Uint8Array(Buffer.from(value, "base64"));
235
345
  }
346
+ function validateTransferId(transferId) {
347
+ if (typeof transferId !== "string" || !TRANSFER_ID_PATTERN.test(transferId)) {
348
+ throw new Error("invalid transfer id");
349
+ }
350
+ }
351
+ function validateFileOffer(offer) {
352
+ if (!offer || typeof offer !== "object")
353
+ throw new Error("file offer is not an object");
354
+ validateTransferId(offer.transferId);
355
+ if (typeof offer.name !== "string" || !offer.name.trim() || offer.name.length > 1_024 || offer.name.includes("\0")) {
356
+ throw new Error("invalid file name");
357
+ }
358
+ if (typeof offer.mimeType !== "string" || offer.mimeType.length > 256) {
359
+ throw new Error("invalid mime type");
360
+ }
361
+ if (offer.kind !== "file" && offer.kind !== "image")
362
+ throw new Error("invalid transfer kind");
363
+ if (!Number.isSafeInteger(offer.size) || offer.size < 0 || offer.size > MAX_BASIC_TRANSFER_SIZE) {
364
+ throw new Error(`invalid file size; maximum is ${MAX_BASIC_TRANSFER_SIZE} bytes`);
365
+ }
366
+ if (typeof offer.sha256 !== "string" || !SHA256_PATTERN.test(offer.sha256)) {
367
+ throw new Error("invalid file sha256");
368
+ }
369
+ if (!Number.isInteger(offer.chunkSize) ||
370
+ offer.chunkSize < 1_024 ||
371
+ offer.chunkSize > MAX_BASIC_TRANSFER_CHUNK_SIZE) {
372
+ throw new Error("invalid file chunk size");
373
+ }
374
+ if (!Number.isInteger(offer.totalChunks) ||
375
+ offer.totalChunks < 0 ||
376
+ offer.totalChunks > MAX_BASIC_TRANSFER_CHUNKS) {
377
+ throw new Error("invalid file chunk count");
378
+ }
379
+ const expected = offer.size === 0 ? 0 : Math.ceil(offer.size / offer.chunkSize);
380
+ if (offer.totalChunks !== expected) {
381
+ throw new Error(`file chunk count ${offer.totalChunks} does not match size/chunkSize (${expected})`);
382
+ }
383
+ }
384
+ function moveFileExclusive(source, destination) {
385
+ try {
386
+ linkSync(source, destination);
387
+ try {
388
+ rmSync(source);
389
+ }
390
+ catch {
391
+ // The destination is already a complete independent directory entry.
392
+ // A leftover temp file is cleanup debt, not a failed delivery.
393
+ }
394
+ return;
395
+ }
396
+ catch (error) {
397
+ const code = error.code;
398
+ if (code === "EEXIST")
399
+ throw new Error(`destination already exists: ${destination}`);
400
+ if (existsSync(destination))
401
+ throw error;
402
+ if (code !== "EXDEV" && code !== "EPERM" && code !== "EACCES")
403
+ throw error;
404
+ }
405
+ copyFileSync(source, destination, constants.COPYFILE_EXCL);
406
+ try {
407
+ rmSync(source);
408
+ }
409
+ catch {
410
+ // See the hard-link path above.
411
+ }
412
+ }
413
+ function enforceIncomingTransferQuota(stateDir, incomingSize) {
414
+ const transfersDir = path.join(path.resolve(stateDir), "transfers");
415
+ if (!existsSync(transfersDir))
416
+ return;
417
+ let activeCount = 0;
418
+ let promisedBytes = 0;
419
+ const now = Date.now();
420
+ for (const name of readdirSync(transfersDir)) {
421
+ if (!name.endsWith(".json"))
422
+ continue;
423
+ const transferId = name.slice(0, -5);
424
+ if (!TRANSFER_ID_PATTERN.test(transferId))
425
+ continue;
426
+ let manifest;
427
+ try {
428
+ manifest = JSON.parse(readFileSync(path.join(transfersDir, name), "utf8"));
429
+ }
430
+ catch {
431
+ continue;
432
+ }
433
+ if (manifest.completedAt || !existsSync(path.join(transfersDir, `${transferId}.part`)))
434
+ continue;
435
+ if (Number.isFinite(manifest.updatedAt) && now - manifest.updatedAt >= INCOMPLETE_TRANSFER_STALE_MS) {
436
+ discardIncomingTransfer(stateDir, transferId);
437
+ continue;
438
+ }
439
+ activeCount += 1;
440
+ promisedBytes += Number.isSafeInteger(manifest.size) && manifest.size >= 0
441
+ ? manifest.size
442
+ : MAX_BASIC_TRANSFER_SIZE;
443
+ }
444
+ if (activeCount >= MAX_INCOMPLETE_TRANSFERS) {
445
+ throw new Error(`too many incomplete file transfers (${activeCount}/${MAX_INCOMPLETE_TRANSFERS})`);
446
+ }
447
+ if (promisedBytes + incomingSize > MAX_INCOMPLETE_TRANSFER_BYTES) {
448
+ throw new Error(`incomplete file transfers would exceed the ${MAX_INCOMPLETE_TRANSFER_BYTES}-byte quota`);
449
+ }
450
+ }
451
+ function sha256File(filePath) {
452
+ const fd = openSync(filePath, "r");
453
+ try {
454
+ const hash = createHash("sha256");
455
+ const buffer = new Uint8Array(1024 * 1024);
456
+ let position = 0;
457
+ for (;;) {
458
+ const read = readSync(fd, buffer, 0, buffer.length, position);
459
+ if (read === 0)
460
+ break;
461
+ hash.update(buffer.subarray(0, read));
462
+ position += read;
463
+ }
464
+ return { sha256: hash.digest("hex"), size: position };
465
+ }
466
+ finally {
467
+ closeSync(fd);
468
+ }
469
+ }
236
470
  export function readSavedTransferBytes(savedPath) {
237
471
  return new Uint8Array(readFileSync(savedPath));
238
472
  }
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { MessageBus, type KeywordTrigger, type MessageBusOptions, type SendDataFn } from "./message-bus.js";
2
2
  export { PeerRegistry, type PeerRecord, type PeerRegistryEvents } from "./peer-registry.js";
3
3
  export { ensureAgentState, getInboxSummary, isInboxWorthy, listInboxMessages, listQueuedAgentActions, queueAgentAction, readAgentSession, readPeersSnapshot, removeQueuedAgentAction, storeInboxMessage, takeInboxMessages, writeAgentSession, writePeersSnapshot, type AgentSessionState, type InboxMessage, type InboxSummary, type QueuedAgentAction, type QueuedAgentActionInput, } from "./agent-state.js";
4
- export { DEFAULT_TRANSFER_CHUNK_SIZE, appendIncomingTransferChunk, beginIncomingTransfer, bytesToBase64, completeIncomingTransfer, createFailedFileAckPayload, createFileAckPayload, prepareFileTransferFromPath, readSavedTransferBytes, readTransferManifest, sendFileFromPath, sendPreparedFileTransfer, sha256Hex, type CompletedTransferResult, type IncomingTransferManifest, type PreparedFileTransfer, } from "./file-transfer.js";
4
+ export { DEFAULT_TRANSFER_CHUNK_SIZE, appendIncomingTransferChunk, beginIncomingTransfer, bytesToBase64, completeIncomingTransfer, createFailedFileAckPayload, createFileAckPayload, discardIncomingTransfer, INCOMPLETE_TRANSFER_STALE_MS, MAX_BASIC_TRANSFER_CHUNK_SIZE, MAX_BASIC_TRANSFER_CHUNKS, MAX_BASIC_TRANSFER_SIZE, MAX_INCOMPLETE_TRANSFER_BYTES, MAX_INCOMPLETE_TRANSFERS, prepareFileTransferFromPath, readSavedTransferBytes, readTransferManifest, sendFileFromPath, sendPreparedFileTransfer, sha256Hex, type CompletedTransferResult, type IncomingTransferManifest, type PreparedFileTransfer, } from "./file-transfer.js";
5
5
  export { createEnvelope, createInstanceId, createMessageId, envelopeToWire, type FileAckPayload, type FileChunkPayload, type FileCompletePayload, type FileOfferPayload, type FileTransferKind, generateRoomName, isValidEnvelope, parseEnvelope, type AgentAsk, type AgentProfile, type AnnouncePayload, type MessageEnvelope, type MessageType, type PeerIdentity, type SharedFolderSummary, type SkillUpdatePayload, } from "./protocol.js";
6
6
  export { inferTransferKind, listSharedFolderEntries, parseSharedFolderSpecs, resolveSharedFile, toSharedFolderSummaries, type ResolvedSharedFile, type SharedFolderConfig, type SharedFolderEntry, type SharedFolderListing, } from "./shared-folders.js";
7
7
  export { VDOBridge, type VDOBridgeOptions } from "./vdo-bridge.js";
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  export { MessageBus } from "./message-bus.js";
2
2
  export { PeerRegistry } from "./peer-registry.js";
3
3
  export { ensureAgentState, getInboxSummary, isInboxWorthy, listInboxMessages, listQueuedAgentActions, queueAgentAction, readAgentSession, readPeersSnapshot, removeQueuedAgentAction, storeInboxMessage, takeInboxMessages, writeAgentSession, writePeersSnapshot, } from "./agent-state.js";
4
- export { DEFAULT_TRANSFER_CHUNK_SIZE, appendIncomingTransferChunk, beginIncomingTransfer, bytesToBase64, completeIncomingTransfer, createFailedFileAckPayload, createFileAckPayload, prepareFileTransferFromPath, readSavedTransferBytes, readTransferManifest, sendFileFromPath, sendPreparedFileTransfer, sha256Hex, } from "./file-transfer.js";
4
+ export { DEFAULT_TRANSFER_CHUNK_SIZE, appendIncomingTransferChunk, beginIncomingTransfer, bytesToBase64, completeIncomingTransfer, createFailedFileAckPayload, createFileAckPayload, discardIncomingTransfer, INCOMPLETE_TRANSFER_STALE_MS, MAX_BASIC_TRANSFER_CHUNK_SIZE, MAX_BASIC_TRANSFER_CHUNKS, MAX_BASIC_TRANSFER_SIZE, MAX_INCOMPLETE_TRANSFER_BYTES, MAX_INCOMPLETE_TRANSFERS, prepareFileTransferFromPath, readSavedTransferBytes, readTransferManifest, sendFileFromPath, sendPreparedFileTransfer, sha256Hex, } from "./file-transfer.js";
5
5
  export { createEnvelope, createInstanceId, createMessageId, envelopeToWire, generateRoomName, isValidEnvelope, parseEnvelope, } from "./protocol.js";
6
6
  export { inferTransferKind, listSharedFolderEntries, parseSharedFolderSpecs, resolveSharedFile, toSharedFolderSummaries, } from "./shared-folders.js";
7
7
  export { VDOBridge } from "./vdo-bridge.js";
@@ -16,7 +16,8 @@ export type KeywordTrigger = {
16
16
  pattern: RegExp;
17
17
  handler: (msg: MessageEnvelope) => void;
18
18
  };
19
- export type SendDataFn = (data: object, target?: unknown) => void;
19
+ /** Return false when the transport could not accept the message yet. */
20
+ export type SendDataFn = (data: object, target?: unknown) => boolean | void;
20
21
  export type MessageBusOptions = {
21
22
  /** Max messages kept in history ring buffer. Default: 200. */
22
23
  historySize?: number;
@@ -47,6 +48,21 @@ export declare class MessageBus extends EventEmitter {
47
48
  getSubscriptions(): string[];
48
49
  /** Broadcast a message to all connected peers. */
49
50
  broadcast(type: MessageType, payload: unknown, topic?: string): MessageEnvelope;
51
+ /**
52
+ * Send something that is only worth delivering right now, reporting whether
53
+ * the transport took it.
54
+ *
55
+ * Unlike `send`, a failure is not queued for replay and nothing enters the
56
+ * history ring. Both matter for swarm traffic: a chunk request replayed from
57
+ * a queue asks for something the requester already has, and a few seconds of
58
+ * transfer is enough to evict every real message from history.
59
+ *
60
+ * The returned boolean is the point. A caller that assumes a send succeeded
61
+ * will sit waiting out a request timeout for a message that never left.
62
+ */
63
+ trySend(targetStreamId: string, type: MessageType, payload: unknown): boolean;
64
+ /** Broadcast counterpart of `trySend`: not retained, not replayed. */
65
+ tryBroadcast(type: MessageType, payload: unknown): boolean;
50
66
  /** Send a message to a specific peer (by streamId). */
51
67
  send(targetStreamId: string, type: MessageType, payload: unknown): MessageEnvelope;
52
68
  /** Publish a message to a topic (only peers subscribed to that topic receive it).
@@ -10,7 +10,7 @@
10
10
  * Zero dependencies on stevesbot internals.
11
11
  */
12
12
  import { EventEmitter } from "node:events";
13
- import { createEnvelope, envelopeToWire, } from "./protocol.js";
13
+ import { createEnvelope, envelopeToWire, isTransientType, } from "./protocol.js";
14
14
  // ── Class ────────────────────────────────────────────────────────────────────
15
15
  export class MessageBus extends EventEmitter {
16
16
  identity;
@@ -65,15 +65,39 @@ export class MessageBus extends EventEmitter {
65
65
  this.rawSend(envelope, null);
66
66
  return envelope;
67
67
  }
68
+ /**
69
+ * Send something that is only worth delivering right now, reporting whether
70
+ * the transport took it.
71
+ *
72
+ * Unlike `send`, a failure is not queued for replay and nothing enters the
73
+ * history ring. Both matter for swarm traffic: a chunk request replayed from
74
+ * a queue asks for something the requester already has, and a few seconds of
75
+ * transfer is enough to evict every real message from history.
76
+ *
77
+ * The returned boolean is the point. A caller that assumes a send succeeded
78
+ * will sit waiting out a request timeout for a message that never left.
79
+ */
80
+ trySend(targetStreamId, type, payload) {
81
+ if (!this.peers.isConnected(targetStreamId))
82
+ return false;
83
+ const envelope = createEnvelope(this.identity, type, payload, { to: targetStreamId });
84
+ return this.rawSend(envelope, targetStreamId);
85
+ }
86
+ /** Broadcast counterpart of `trySend`: not retained, not replayed. */
87
+ tryBroadcast(type, payload) {
88
+ const envelope = createEnvelope(this.identity, type, payload);
89
+ return this.rawSend(envelope, null);
90
+ }
68
91
  /** Send a message to a specific peer (by streamId). */
69
92
  send(targetStreamId, type, payload) {
70
93
  const envelope = createEnvelope(this.identity, type, payload, { to: targetStreamId });
71
- this.addToHistory(envelope);
72
- if (this.peers.isConnected(targetStreamId)) {
73
- this.rawSend(envelope, targetStreamId);
74
- }
75
- else {
76
- this.enqueueOffline(targetStreamId, envelope);
94
+ // Transient types are still deliverable this way, but never retained or
95
+ // queued, so a caller reaching for the wrong method cannot flood history.
96
+ if (!isTransientType(type))
97
+ this.addToHistory(envelope);
98
+ if (!this.peers.isConnected(targetStreamId) || !this.rawSend(envelope, targetStreamId)) {
99
+ if (!isTransientType(type))
100
+ this.enqueueOffline(targetStreamId, envelope);
77
101
  }
78
102
  return envelope;
79
103
  }
@@ -142,13 +166,21 @@ export class MessageBus extends EventEmitter {
142
166
  const queue = this.offlineQueues.get(streamId);
143
167
  if (!queue || queue.length === 0)
144
168
  return [];
145
- this.offlineQueues.delete(streamId);
146
- // Send each queued message wrapped as history_replay
169
+ const flushed = [];
147
170
  for (const msg of queue) {
148
- const replay = createEnvelope(this.identity, "history_replay", msg, { to: streamId });
149
- this.rawSend(replay, streamId);
171
+ // Preserve the original type and ID. Wrapping queued commands as
172
+ // history_replay prevented normal command/file handlers from seeing them.
173
+ if (!this.rawSend(msg, streamId))
174
+ break;
175
+ flushed.push(msg);
176
+ }
177
+ if (flushed.length === queue.length) {
178
+ this.offlineQueues.delete(streamId);
179
+ }
180
+ else if (flushed.length > 0) {
181
+ this.offlineQueues.set(streamId, queue.slice(flushed.length));
150
182
  }
151
- return queue;
183
+ return flushed;
152
184
  }
153
185
  /** Get the number of queued messages for a peer. */
154
186
  getOfflineQueueSize(streamId) {
@@ -174,25 +206,31 @@ export class MessageBus extends EventEmitter {
174
206
  // ── Internals ────────────────────────────────────────────────────────────
175
207
  rawSend(envelope, target) {
176
208
  if (!this.sendDataFn)
177
- return;
209
+ return false;
178
210
  const wire = envelopeToWire(envelope);
211
+ let accepted;
179
212
  if (target) {
180
213
  // Prefer uuid targeting once we know it; streamID targeting can be ambiguous
181
214
  // before announce/rekey completes on some SDK connection paths.
182
215
  const peer = this.peers.getPeer(target);
183
216
  if (peer?.uuid) {
184
- this.sendDataFn(wire, { uuid: peer.uuid });
217
+ accepted = this.sendDataFn(wire, { uuid: peer.uuid });
185
218
  }
186
219
  else {
187
- this.sendDataFn(wire, { streamID: target });
220
+ accepted = this.sendDataFn(wire, { streamID: target });
188
221
  }
189
222
  }
190
223
  else {
191
224
  // Broadcast to all
192
- this.sendDataFn(wire);
225
+ accepted = this.sendDataFn(wire);
193
226
  }
227
+ // Void preserves compatibility with existing transports written before
228
+ // acceptance results were supported. Only an explicit false is a failure.
229
+ return accepted !== false;
194
230
  }
195
231
  addToHistory(envelope) {
232
+ if (isTransientType(envelope.type))
233
+ return;
196
234
  // Don't store heartbeat or file chunk traffic in history.
197
235
  if (envelope.type === "ping" || envelope.type === "pong" || envelope.type === "file_chunk")
198
236
  return;
@@ -221,6 +259,7 @@ export class MessageBus extends EventEmitter {
221
259
  if (!text)
222
260
  return;
223
261
  for (const trigger of this.triggers) {
262
+ trigger.pattern.lastIndex = 0;
224
263
  if (trigger.pattern.test(text)) {
225
264
  try {
226
265
  trigger.handler(envelope);
@@ -18,6 +18,13 @@ export class PeerRegistry extends EventEmitter {
18
18
  addPeer(streamId, uuid) {
19
19
  const existing = this.peers.get(streamId);
20
20
  if (existing) {
21
+ const duplicateForUuid = this.resolve(uuid);
22
+ if (duplicateForUuid && duplicateForUuid !== existing) {
23
+ this.peers.delete(duplicateForUuid.streamId);
24
+ this.uuidToStream.delete(duplicateForUuid.uuid);
25
+ }
26
+ if (existing.uuid !== uuid)
27
+ this.uuidToStream.delete(existing.uuid);
21
28
  existing.uuid = uuid;
22
29
  existing.connected = true;
23
30
  existing.connectedAt = Date.now();
@@ -5,7 +5,19 @@
5
5
  * WebRTC data channels. This module has zero dependencies on stevesbot
6
6
  * internals — it can be extracted and reused by any bot.
7
7
  */
8
- export type MessageType = "chat" | "announce" | "skill_update" | "command" | "command_response" | "file_offer" | "file_chunk" | "file_complete" | "file_ack" | "event" | "ping" | "pong" | "ack" | "history_replay" | "history_request";
8
+ export type MessageType = "chat" | "announce" | "skill_update" | "command" | "command_response" | "file_offer" | "file_chunk" | "file_complete" | "file_ack" | "event" | "ping" | "pong" | "ack" | "history_replay" | "history_request" | "swarm_offer" | "swarm_manifest_request" | "swarm_manifest_page" | "swarm_announce" | "swarm_request" | "swarm_chunk" | "swarm_have";
9
+ /**
10
+ * Messages that are only worth delivering right now.
11
+ *
12
+ * Transfer traffic is high volume and stateful: a chunk request replayed from
13
+ * an offline queue minutes later asks for something the requester already has,
14
+ * while replaying half of a simple file transfer cannot restore its missing
15
+ * half. A history ring full of chunks also evicts every real message a peer
16
+ * asking for history actually wanted. Swarm state is re-announced; a rejected
17
+ * simple transfer fails explicitly and can be retried from the start.
18
+ */
19
+ export declare const TRANSIENT_MESSAGE_TYPES: ReadonlySet<MessageType>;
20
+ export declare function isTransientType(type: MessageType): boolean;
9
21
  export type PeerIdentity = {
10
22
  streamId: string;
11
23
  role: string;
@@ -32,6 +44,71 @@ export type AgentProfile = {
32
44
  asks?: AgentAsk[];
33
45
  shares?: SharedFolderSummary[];
34
46
  };
47
+ /** Announces that a file exists in the swarm and describes how it is chunked. */
48
+ export type SwarmOfferPayload = {
49
+ fileId: string;
50
+ name: string;
51
+ mimeType: string;
52
+ size: number;
53
+ chunkSize: number;
54
+ totalChunks: number;
55
+ /** sha256 over the raw 32-byte chunk hashes, in chunk order. */
56
+ chunkHashesHash?: string;
57
+ /**
58
+ * Small manifests ride inline. Large manifests are requested in bounded
59
+ * pages, otherwise one offer eventually exceeds the negotiated SCTP message
60
+ * limit and silently becomes impossible to send.
61
+ */
62
+ chunkHashes?: string[];
63
+ manifestPageSize?: number;
64
+ manifestPages?: number;
65
+ };
66
+ /** Ask a source for selected pages of a large chunk-hash manifest. */
67
+ export type SwarmManifestRequestPayload = {
68
+ fileId: string;
69
+ pageSize: number;
70
+ pages: number[];
71
+ };
72
+ /** One bounded page of chunk hashes. */
73
+ export type SwarmManifestPagePayload = {
74
+ fileId: string;
75
+ page: number;
76
+ totalPages: number;
77
+ pageSize: number;
78
+ chunkHashesHash: string;
79
+ hashes: string[];
80
+ };
81
+ /** A peer's full chunk bitfield, base64 encoded. */
82
+ export type SwarmAnnouncePayload = {
83
+ fileId: string;
84
+ totalChunks: number;
85
+ chunks: string;
86
+ };
87
+ export type SwarmRequestPayload = {
88
+ fileId: string;
89
+ index: number;
90
+ /**
91
+ * Set when the requester can receive the chunk as raw bytes on the binary
92
+ * lane. Carrying it on the request rather than negotiating up front means
93
+ * there is no capability table to keep in sync and no window where a peer
94
+ * that just joined is assumed to be one thing or the other — each request
95
+ * states how its own reply should come back.
96
+ */
97
+ bin?: 1;
98
+ };
99
+ export type SwarmChunkPayload = {
100
+ fileId: string;
101
+ index: number;
102
+ data: string;
103
+ };
104
+ /** Incremental "I now hold these chunks", so peers learn without a full re-announce. */
105
+ export type SwarmHavePayload = {
106
+ fileId: string;
107
+ /** Batched indexes. Preferred; one message can carry a whole burst. */
108
+ indexes?: number[];
109
+ /** Single index, kept so a peer running an older build is still understood. */
110
+ index?: number;
111
+ };
35
112
  export type FileTransferKind = "file" | "image";
36
113
  export type FileOfferPayload = {
37
114
  transferId: string;