@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
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Swarm binary wire format
3
+ *
4
+ * Chunks used to travel as base64 inside a JSON envelope, because the SDK
5
+ * stringified everything before it reached the data channel. `sendBinary` gives
6
+ * us a real byte lane, so a chunk now goes out as a 40-byte header followed by
7
+ * the payload untouched — 0.06% overhead on a 64 KB chunk instead of 33%.
8
+ *
9
+ * The lane carries no routing of its own: `binaryReceived` tells us which peer
10
+ * sent bytes and nothing else. So the frame has to identify the file and the
11
+ * chunk itself, and has to be self-describing enough that a future frame type
12
+ * can share the lane without ambiguity.
13
+ *
14
+ * 0 magic 'N'
15
+ * 1 magic 'J'
16
+ * 2 version
17
+ * 3 frame type
18
+ * 4..35 fileId, 32 raw bytes (the sha256 the hex id encodes)
19
+ * 36..39 chunk index, uint32 big-endian
20
+ * 40.. payload
21
+ */
22
+ export declare const SWARM_WIRE_MAGIC_0 = 78;
23
+ export declare const SWARM_WIRE_MAGIC_1 = 74;
24
+ export declare const SWARM_WIRE_VERSION = 1;
25
+ export declare const SWARM_FRAME_CHUNK = 1;
26
+ export declare const SWARM_WIRE_HEADER_BYTES = 40;
27
+ export type SwarmChunkFrame = {
28
+ fileId: string;
29
+ index: number;
30
+ data: Uint8Array;
31
+ };
32
+ /**
33
+ * Whether a content id can ride the binary lane.
34
+ *
35
+ * Ids are sha256 hex, so this is true for anything we produce. A peer that
36
+ * offers something else is not lied to or dropped — it just gets the base64
37
+ * path, which imposes no constraints on the id at all.
38
+ */
39
+ export declare function isBinaryFileId(fileId: string): boolean;
40
+ export declare function encodeChunkFrame(fileId: string, index: number, data: Uint8Array): Uint8Array;
41
+ /**
42
+ * Parse a frame, or return null if it is not one of ours.
43
+ *
44
+ * Null rather than throwing: the binary lane is shared with anything else the
45
+ * application chooses to put on it, and a frame we do not recognise is somebody
46
+ * else's business, not an error.
47
+ */
48
+ export declare function decodeChunkFrame(bytes: Uint8Array): SwarmChunkFrame | null;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Swarm binary wire format
3
+ *
4
+ * Chunks used to travel as base64 inside a JSON envelope, because the SDK
5
+ * stringified everything before it reached the data channel. `sendBinary` gives
6
+ * us a real byte lane, so a chunk now goes out as a 40-byte header followed by
7
+ * the payload untouched — 0.06% overhead on a 64 KB chunk instead of 33%.
8
+ *
9
+ * The lane carries no routing of its own: `binaryReceived` tells us which peer
10
+ * sent bytes and nothing else. So the frame has to identify the file and the
11
+ * chunk itself, and has to be self-describing enough that a future frame type
12
+ * can share the lane without ambiguity.
13
+ *
14
+ * 0 magic 'N'
15
+ * 1 magic 'J'
16
+ * 2 version
17
+ * 3 frame type
18
+ * 4..35 fileId, 32 raw bytes (the sha256 the hex id encodes)
19
+ * 36..39 chunk index, uint32 big-endian
20
+ * 40.. payload
21
+ */
22
+ export const SWARM_WIRE_MAGIC_0 = 0x4e; // 'N'
23
+ export const SWARM_WIRE_MAGIC_1 = 0x4a; // 'J'
24
+ export const SWARM_WIRE_VERSION = 1;
25
+ export const SWARM_FRAME_CHUNK = 1;
26
+ export const SWARM_WIRE_HEADER_BYTES = 40;
27
+ const FILE_ID_BYTES = 32;
28
+ const HEX_FILE_ID = /^[0-9a-f]{64}$/;
29
+ /**
30
+ * Whether a content id can ride the binary lane.
31
+ *
32
+ * Ids are sha256 hex, so this is true for anything we produce. A peer that
33
+ * offers something else is not lied to or dropped — it just gets the base64
34
+ * path, which imposes no constraints on the id at all.
35
+ */
36
+ export function isBinaryFileId(fileId) {
37
+ return HEX_FILE_ID.test(fileId);
38
+ }
39
+ export function encodeChunkFrame(fileId, index, data) {
40
+ if (!isBinaryFileId(fileId)) {
41
+ throw new Error(`fileId is not a 64-character hex id: ${fileId}`);
42
+ }
43
+ if (!Number.isInteger(index) || index < 0 || index > 0xffffffff) {
44
+ throw new Error(`chunk index out of range: ${index}`);
45
+ }
46
+ const frame = new Uint8Array(SWARM_WIRE_HEADER_BYTES + data.length);
47
+ frame[0] = SWARM_WIRE_MAGIC_0;
48
+ frame[1] = SWARM_WIRE_MAGIC_1;
49
+ frame[2] = SWARM_WIRE_VERSION;
50
+ frame[3] = SWARM_FRAME_CHUNK;
51
+ for (let i = 0; i < FILE_ID_BYTES; i += 1) {
52
+ frame[4 + i] = Number.parseInt(fileId.slice(i * 2, i * 2 + 2), 16);
53
+ }
54
+ const view = new DataView(frame.buffer, frame.byteOffset);
55
+ view.setUint32(36, index, false);
56
+ frame.set(data, SWARM_WIRE_HEADER_BYTES);
57
+ return frame;
58
+ }
59
+ /**
60
+ * Parse a frame, or return null if it is not one of ours.
61
+ *
62
+ * Null rather than throwing: the binary lane is shared with anything else the
63
+ * application chooses to put on it, and a frame we do not recognise is somebody
64
+ * else's business, not an error.
65
+ */
66
+ export function decodeChunkFrame(bytes) {
67
+ if (bytes.length < SWARM_WIRE_HEADER_BYTES)
68
+ return null;
69
+ if (bytes[0] !== SWARM_WIRE_MAGIC_0 || bytes[1] !== SWARM_WIRE_MAGIC_1)
70
+ return null;
71
+ if (bytes[2] !== SWARM_WIRE_VERSION)
72
+ return null;
73
+ if (bytes[3] !== SWARM_FRAME_CHUNK)
74
+ return null;
75
+ let fileId = "";
76
+ for (let i = 0; i < FILE_ID_BYTES; i += 1) {
77
+ fileId += bytes[4 + i].toString(16).padStart(2, "0");
78
+ }
79
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
80
+ const index = view.getUint32(36, false);
81
+ // Copy rather than subarray: the payload outlives the frame — it is hashed,
82
+ // written to disk, and may be re-served — and holding a view would pin the
83
+ // whole received buffer alive for as long as any chunk of it is referenced.
84
+ const data = bytes.slice(SWARM_WIRE_HEADER_BYTES);
85
+ return { fileId, index, data };
86
+ }
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Swarm Transfer
3
+ *
4
+ * The original file transfer was one sender pushing base64 chunks in strict
5
+ * order to one receiver. That caps throughput at a single peer's upload, breaks
6
+ * if a chunk arrives out of order, and cannot resume from anyone else.
7
+ *
8
+ * This is the torrent-shaped replacement. Files are content-addressed by their
9
+ * sha256, so every peer holding the same bytes is interchangeable. Peers publish
10
+ * a bitfield of the chunks they hold, chunks are pulled rather than pushed, and
11
+ * a peer can start serving a chunk the moment it has verified it — while still
12
+ * downloading the rest.
13
+ *
14
+ * The SDK gives one data channel per peer, so parallelism comes from two places:
15
+ * requesting from several peers at once, and keeping several requests in flight
16
+ * on each channel rather than waiting for each to land.
17
+ *
18
+ * This module is deliberately pure and I/O-light so the hard parts — bitfields,
19
+ * piece selection, verification — are testable without a network.
20
+ */
21
+ export declare const DEFAULT_SWARM_CHUNK_SIZE = 64000;
22
+ export declare const DEFAULT_MAX_IN_FLIGHT_PER_PEER = 4;
23
+ export declare const DEFAULT_MAX_IN_FLIGHT_TOTAL = 24;
24
+ /** Keep each paged manifest message comfortably below any SCTP limit. */
25
+ export declare const SWARM_MANIFEST_PAGE_HASHES = 256;
26
+ /** Small files stay one-message simple; large files request hash pages on demand. */
27
+ export declare const SWARM_INLINE_MANIFEST_HASHES = 256;
28
+ /** Bounds allocations from a peer-controlled manifest while still allowing ~64 GB at 64 KB chunks. */
29
+ export declare const MAX_SWARM_TOTAL_CHUNKS = 1000000;
30
+ /** A chunk must fit in memory and, in practice, one data-channel message. */
31
+ export declare const MAX_SWARM_CHUNK_SIZE: number;
32
+ export declare const SHA256_HEX_PATTERN: RegExp;
33
+ /** Room left for the JSON envelope wrapped around a base64 chunk. */
34
+ export declare const SWARM_ENVELOPE_HEADROOM = 4096;
35
+ /** After this long with no progress, a lock is treated as abandoned. */
36
+ export declare const SWARM_LOCK_STALE_MS: number;
37
+ /**
38
+ * Where an in-progress download is written.
39
+ *
40
+ * Keyed by content **and destination**, not content alone. Two `fetch` runs of
41
+ * the same file into different folders are a perfectly ordinary thing to do,
42
+ * and sharing one `.part` between them meant they wrote over each other and the
43
+ * first to finish renamed the file out from under the rest — which surfaced as
44
+ * an ENOENT crash, having already corrupted whatever the others had written.
45
+ *
46
+ * Keeping the destination in the key also preserves resume: the same fetch, run
47
+ * again, lands on the same part file and picks up where it left off.
48
+ */
49
+ export declare function partPathFor(workDir: string, fileId: string, savedPath: string): string;
50
+ /**
51
+ * Largest chunk that still fits one SCTP message once base64-encoded.
52
+ *
53
+ * Only the fallback path needs this. Binary chunks add a 40-byte header, so a
54
+ * 64 KB chunk fits inside even the 65536 every WebRTC implementation must
55
+ * support. Base64 inflates by 4/3, so the same chunk becomes 85 KB and would be
56
+ * refused by a peer that negotiated only that minimum — a failure that would
57
+ * only ever show up against an implementation we had not tested with.
58
+ *
59
+ * Every implementation measured here negotiates 262144, which is why this has
60
+ * never bitten. It is a guard, not a tuning knob: chunk size was measured
61
+ * against throughput at 64 KB, 128 KB and 192 KB and moved it by under 2%.
62
+ */
63
+ export declare function maxSafeChunkSize(messageLimit: number): number;
64
+ /** Immutable description of a file, shared by every peer in its swarm. */
65
+ export type SwarmManifest = {
66
+ /** sha256 of the whole file. Also the swarm identity. */
67
+ fileId: string;
68
+ name: string;
69
+ mimeType: string;
70
+ size: number;
71
+ chunkSize: number;
72
+ totalChunks: number;
73
+ /** sha256 per chunk, so a bad chunk can be blamed on the peer that sent it. */
74
+ chunkHashes: string[];
75
+ /** sha256 over the raw chunk hashes, used to authenticate paged manifests. */
76
+ chunkHashesHash: string;
77
+ };
78
+ /** Metadata that is safe to advertise before the full chunk-hash manifest arrives. */
79
+ export type SwarmManifestSummary = Omit<SwarmManifest, "chunkHashes">;
80
+ /**
81
+ * Which chunks a peer holds.
82
+ *
83
+ * A bitfield rather than a list: a 4 GB file at 64 KB chunks is ~65k chunks,
84
+ * which is 8 KB as bits and would be megabytes as JSON indices. Peers exchange
85
+ * these constantly, so the compact form matters.
86
+ */
87
+ export declare class ChunkMap {
88
+ readonly totalChunks: number;
89
+ private readonly bits;
90
+ private present;
91
+ constructor(totalChunks: number, bits?: Uint8Array);
92
+ static full(totalChunks: number): ChunkMap;
93
+ static fromBase64(value: string, totalChunks: number): ChunkMap;
94
+ toBase64(): string;
95
+ has(index: number): boolean;
96
+ set(index: number): boolean;
97
+ count(): number;
98
+ isComplete(): boolean;
99
+ missing(): number[];
100
+ clone(): ChunkMap;
101
+ private recount;
102
+ }
103
+ /**
104
+ * What we have actually observed about a peer.
105
+ *
106
+ * Every field here is measured from real traffic — no declared capacity, no
107
+ * trust tiers, nothing a peer can assert about itself. A peer that claims to be
108
+ * fast and then is not will simply score worse after a few chunks.
109
+ */
110
+ export type SwarmPeerStats = {
111
+ /** Exponentially weighted round-trip time for chunk requests, or null until measured. */
112
+ rttMs: number | null;
113
+ /** Requests that timed out or returned a chunk failing its hash. */
114
+ failures: number;
115
+ /** Chunks delivered and verified. */
116
+ delivered: number;
117
+ };
118
+ export type SwarmPeerState = {
119
+ peerId: string;
120
+ chunks: ChunkMap;
121
+ /** How many requests are already outstanding with this peer. */
122
+ inFlight: number;
123
+ stats?: SwarmPeerStats;
124
+ };
125
+ /** RTT assumed for a peer we have not measured yet, so new peers still get tried. */
126
+ export declare const UNKNOWN_PEER_RTT_MS = 250;
127
+ export declare function createPeerStats(): SwarmPeerStats;
128
+ /** Fold a new RTT sample in. Weighted toward history so one spike cannot swing it. */
129
+ export declare function recordPeerRtt(stats: SwarmPeerStats, sampleMs: number): SwarmPeerStats;
130
+ /**
131
+ * Rank a peer for serving the next chunk. Lower is better, and the units are
132
+ * milliseconds throughout so the number stays explainable: it is roughly
133
+ * "expected delay if we ask this peer".
134
+ */
135
+ export declare function scorePeer(peer: SwarmPeerState): number;
136
+ export type ChunkRequestPlan = {
137
+ peerId: string;
138
+ index: number;
139
+ };
140
+ export type PlanOptions = {
141
+ maxInFlightPerPeer?: number;
142
+ maxInFlightTotal?: number;
143
+ /**
144
+ * Tie-break among equally rare chunks. Injectable so tests stay deterministic;
145
+ * everything else about planning already is.
146
+ */
147
+ random?: () => number;
148
+ };
149
+ /**
150
+ * Decide what to ask for next.
151
+ *
152
+ * Rarest-first: chunks held by the fewest peers are fetched first, so a swarm
153
+ * does not lose the only copy of a chunk when the peer holding it leaves. Among
154
+ * peers that can serve a chunk, the best-scoring one wins — measured latency,
155
+ * observed failures, and current queue depth — which spreads load and routes
156
+ * around bad peers without any central coordination.
157
+ *
158
+ * Ties are broken randomly, and that is load-bearing rather than a detail. Ties
159
+ * are the normal case at the start of a download, when every chunk has exactly
160
+ * the same rarity. Breaking them by index meant every downloader asked for the
161
+ * same chunks in the same order, so downloaders never held anything the others
162
+ * lacked and could never serve each other — three leechers against one seeder
163
+ * measured at exactly one third the speed of one, with no peer-to-peer traffic
164
+ * at all. Random selection makes them diverge immediately.
165
+ */
166
+ export declare function planChunkRequests(have: ChunkMap, peers: SwarmPeerState[], inFlightIndices: ReadonlySet<number>, options?: PlanOptions): ChunkRequestPlan[];
167
+ export declare function chunkOffset(index: number, chunkSize: number): number;
168
+ export declare function chunkLength(index: number, manifest: Pick<SwarmManifest, "size" | "chunkSize">): number;
169
+ /**
170
+ * A partially downloaded file backed by a sparse `.part` on disk.
171
+ *
172
+ * Chunks are written at their byte offset rather than appended, which is what
173
+ * makes out-of-order and multi-source delivery possible at all.
174
+ */
175
+ export declare class ChunkFile {
176
+ readonly filePath: string;
177
+ readonly manifest: SwarmManifest;
178
+ private fd;
179
+ private lockPath;
180
+ private lockToken;
181
+ /**
182
+ * @param exclusive Refuse to open if another live process holds this file.
183
+ * Only a download needs it — a seed session opens the user's own file
184
+ * read-mostly and several of those coexisting is fine.
185
+ */
186
+ constructor(filePath: string, manifest: SwarmManifest, exclusive?: boolean);
187
+ open(): void;
188
+ /**
189
+ * Work out which chunks this file already holds, by hashing them.
190
+ *
191
+ * Keeping the bytes across a restart is only half of resuming; without this
192
+ * the bitfield starts empty and every chunk on disk is fetched again. That is
193
+ * what the code did, so "resume after an interruption" was true of the file
194
+ * and false of the transfer.
195
+ *
196
+ * Every chunk is verified rather than inferred from the file's length. A part
197
+ * file is written at byte offsets, so a gap reads back as zeros and a
198
+ * truncated write leaves a partial chunk — neither is distinguishable from
199
+ * real data by size alone, and trusting either would silently corrupt the
200
+ * result. Hashing costs one pass over the file, which is only paid when a
201
+ * part file already exists.
202
+ */
203
+ scanVerified(): ChunkMap;
204
+ close(): void;
205
+ /** Close the data handle while retaining ownership through final verification. */
206
+ closeHandle(): void;
207
+ /** Release a download lock after its part file has been finalized or abandoned. */
208
+ releaseExclusiveLock(): void;
209
+ /**
210
+ * Claim the part file, or explain who has it.
211
+ *
212
+ * Two downloads of the same file to the same folder is the one case the
213
+ * destination-keyed path cannot separate, and it is a genuine conflict rather
214
+ * than something to paper over — so fail loudly instead of interleaving
215
+ * writes. A lock left behind by a killed process goes stale rather than
216
+ * blocking that destination forever.
217
+ */
218
+ private acquireLock;
219
+ /** Refresh the lock so a long, healthy transfer is never mistaken for stale. */
220
+ touchLock(): void;
221
+ /**
222
+ * Verify and store one chunk. Returns false when the bytes do not match the
223
+ * manifest hash, which means the peer that sent them is wrong or hostile —
224
+ * the chunk is dropped and can be re-fetched from someone else.
225
+ */
226
+ writeChunk(index: number, bytes: Uint8Array): boolean;
227
+ readChunk(index: number): Uint8Array | null;
228
+ }
229
+ /** Build a manifest by hashing a file's bytes chunk by chunk. */
230
+ export declare function buildManifest(bytes: Uint8Array, name: string, mimeType: string, chunkSize?: number): SwarmManifest;
231
+ /**
232
+ * Build a manifest without loading the file into memory.
233
+ *
234
+ * The old path copied the whole file into a Buffer and then a Uint8Array before
235
+ * hashing it, and completion did the same again. That made the "large file"
236
+ * command consume roughly twice the file size in memory. This keeps the peak at
237
+ * one chunk while producing the same content and chunk digests.
238
+ */
239
+ export declare function buildManifestFromFile(filePath: string, name: string, mimeType: string, chunkSize?: number): SwarmManifest;
240
+ /** sha256 a file with bounded memory. */
241
+ export declare function sha256FileHex(filePath: string): {
242
+ sha256: string;
243
+ size: number;
244
+ };
245
+ /** Stable digest for a paged chunk-hash manifest. */
246
+ export declare function hashChunkHashes(chunkHashes: readonly string[]): string;
247
+ export declare function toManifestSummary(manifest: SwarmManifest): SwarmManifestSummary;
248
+ /** Return a useful reason rather than letting peer-controlled values reach I/O. */
249
+ export declare function validateManifestSummary(manifest: SwarmManifestSummary): string | null;
250
+ export declare function validateSwarmManifest(manifest: SwarmManifest): string | null;
251
+ export declare function isSha256Hex(value: unknown): value is string;
252
+ /**
253
+ * Confirm two peers are describing the same bytes before trading chunks.
254
+ * Without this a peer could advertise a familiar fileId while serving a
255
+ * different file's chunk hashes.
256
+ */
257
+ export declare function manifestsAgree(a: SwarmManifest, b: SwarmManifest): boolean;
258
+ export declare function sha256Hex(bytes: Uint8Array): string;