reploid 0.0.0-stage → 0.1.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.
Files changed (233) hide show
  1. package/apps/poolday/config/chat-files.json +27 -0
  2. package/apps/poolday/config/partition-generation.js +21 -0
  3. package/apps/poolday/config/partition-policy.json +74 -0
  4. package/apps/poolday/config/partition-releases.json +3 -0
  5. package/apps/poolday/config/work-profile.json +122 -0
  6. package/apps/poolday/host/chat-view.js +47 -0
  7. package/apps/poolday/host/earning/client.js +199 -0
  8. package/apps/poolday/host/earning/contribution.js +168 -0
  9. package/apps/poolday/host/partition-release.js +33 -0
  10. package/apps/poolday/host/work-partition-artifact-source.js +37 -0
  11. package/apps/poolday/host/work-partition-capsule.js +39 -0
  12. package/apps/poolday/host/work-partition-plan.js +167 -0
  13. package/apps/poolday/host/work-partition-tokenizer-guard.js +21 -0
  14. package/apps/poolday/infrastructure/model-storage-lock.js +22 -0
  15. package/apps/poolday/infrastructure/pack-release-storage.js +52 -0
  16. package/apps/poolday/pool/pack-operation.js +161 -0
  17. package/apps/poolday/pool/pack-release-policy.js +44 -0
  18. package/apps/poolday/pool/peer-pack-data-channel.js +217 -0
  19. package/apps/poolday/providers/work-adapter.js +67 -0
  20. package/apps/poolday/providers/work-device.js +33 -0
  21. package/apps/poolday/providers/work-generation-completion.js +55 -0
  22. package/apps/poolday/providers/work-provider.js +209 -0
  23. package/apps/poolday/providers/work-resident-provider.js +113 -0
  24. package/deploy/artifacts/doppler-gpu-0.7.1.tgz +0 -0
  25. package/docs/fleet-service.md +214 -0
  26. package/docs/native-participant.md +542 -0
  27. package/native-delivery.json +1174 -0
  28. package/package.json +29 -4
  29. package/packages/reploid/src/artifacts/binary.js +25 -0
  30. package/packages/reploid/src/artifacts/canonical-json.js +23 -0
  31. package/packages/reploid/src/artifacts/custody/exchange.js +402 -0
  32. package/packages/reploid/src/artifacts/custody/piece-acquisition.js +70 -0
  33. package/packages/reploid/src/artifacts/custody/runtime.js +404 -0
  34. package/packages/reploid/src/artifacts/custody/supply-budget.js +19 -0
  35. package/packages/reploid/src/artifacts/custody-certificate.js +123 -0
  36. package/packages/reploid/src/artifacts/delivery-attestation.js +64 -0
  37. package/packages/reploid/src/artifacts/generation-terminal.js +36 -0
  38. package/packages/reploid/src/artifacts/identity.js +91 -0
  39. package/packages/reploid/src/artifacts/participant-proofs.js +159 -0
  40. package/packages/reploid/src/artifacts/receipt.js +131 -0
  41. package/packages/reploid/src/artifacts/signatures.js +94 -0
  42. package/packages/reploid/src/artifacts/whole-model-receipt.js +42 -0
  43. package/packages/reploid/src/cancellation.js +44 -0
  44. package/packages/reploid/src/config/defaults.json +110 -0
  45. package/packages/reploid/src/config/index.js +209 -0
  46. package/packages/reploid/src/config/request-overrides.json +9 -0
  47. package/packages/reploid/src/config/transport-defaults.json +7 -0
  48. package/packages/reploid/src/hashing.js +30 -0
  49. package/packages/reploid/src/measurement/capacity-utilization.js +321 -0
  50. package/packages/reploid/src/measurement/index.js +5 -0
  51. package/packages/reploid/src/measurement/readiness-records.js +310 -0
  52. package/packages/reploid/src/mesh/computer-identity.js +50 -0
  53. package/packages/reploid/src/mesh/computer-identity.policy.json +8 -0
  54. package/packages/reploid/src/mesh/contribution.js +141 -0
  55. package/packages/reploid/src/mesh/generation-recovery.js +58 -0
  56. package/packages/reploid/src/mesh/gpu-signature-shader.js +27 -0
  57. package/packages/reploid/src/mesh/gpu-signature.js +136 -0
  58. package/packages/reploid/src/mesh/gpu-signature.policy.json +11 -0
  59. package/packages/reploid/src/mesh/index.js +66 -0
  60. package/packages/reploid/src/mesh/partitions/automatic-ordered-partitions.js +540 -0
  61. package/packages/reploid/src/mesh/partitions/automatic-partitions.js +298 -0
  62. package/packages/reploid/src/mesh/partitions/partition-attempt-evidence.js +282 -0
  63. package/packages/reploid/src/mesh/partitions/partition-attempt-queue-policy.json +6 -0
  64. package/packages/reploid/src/mesh/partitions/partition-attempt-queue.js +112 -0
  65. package/packages/reploid/src/mesh/partitions/partition-canonical-contract.js +167 -0
  66. package/packages/reploid/src/mesh/partitions/partition-canonical-execution.js +168 -0
  67. package/packages/reploid/src/mesh/partitions/partition-chat-canonical.js +49 -0
  68. package/packages/reploid/src/mesh/partitions/partition-chat.js +125 -0
  69. package/packages/reploid/src/mesh/partitions/partition-contract.js +192 -0
  70. package/packages/reploid/src/mesh/partitions/partition-device-admission.js +96 -0
  71. package/packages/reploid/src/mesh/partitions/partition-discovery.js +96 -0
  72. package/packages/reploid/src/mesh/partitions/partition-entry-publication.js +183 -0
  73. package/packages/reploid/src/mesh/partitions/partition-entry.js +434 -0
  74. package/packages/reploid/src/mesh/partitions/partition-generation-request.js +33 -0
  75. package/packages/reploid/src/mesh/partitions/partition-grants.js +277 -0
  76. package/packages/reploid/src/mesh/partitions/partition-link.js +103 -0
  77. package/packages/reploid/src/mesh/partitions/partition-network.js +109 -0
  78. package/packages/reploid/src/mesh/partitions/partition-ordered-chat.js +290 -0
  79. package/packages/reploid/src/mesh/partitions/partition-ordered-peer.js +415 -0
  80. package/packages/reploid/src/mesh/partitions/partition-ordered-publication.js +100 -0
  81. package/packages/reploid/src/mesh/partitions/partition-peer.js +314 -0
  82. package/packages/reploid/src/mesh/partitions/partition-placement.js +143 -0
  83. package/packages/reploid/src/mesh/partitions/partition-preparation-progress.js +25 -0
  84. package/packages/reploid/src/mesh/partitions/partition-receiver-policy.json +7 -0
  85. package/packages/reploid/src/mesh/partitions/partition-reservations.js +75 -0
  86. package/packages/reploid/src/mesh/partitions/partition-route-envelope-policy.json +16 -0
  87. package/packages/reploid/src/mesh/partitions/partition-route-envelope.js +74 -0
  88. package/packages/reploid/src/mesh/partitions/partition-route-execution.js +409 -0
  89. package/packages/reploid/src/mesh/partitions/partition-route-network.js +179 -0
  90. package/packages/reploid/src/mesh/partitions/partition-route-ranking.js +268 -0
  91. package/packages/reploid/src/mesh/partitions/partition-runner.js +319 -0
  92. package/packages/reploid/src/mesh/partitions/partition-stage-assignment.js +109 -0
  93. package/packages/reploid/src/mesh/partitions/partition-stage-commit.js +103 -0
  94. package/packages/reploid/src/mesh/partitions/partition-step-receiver.js +291 -0
  95. package/packages/reploid/src/mesh/partitions/partition-summary.js +122 -0
  96. package/packages/reploid/src/mesh/partitions/preparation-owner.js +40 -0
  97. package/packages/reploid/src/mesh/partitions/resident-error-codes.json +10 -0
  98. package/packages/reploid/src/mesh/partitions/resident-partition.js +333 -0
  99. package/packages/reploid/src/mesh/partitions/resident-route.js +46 -0
  100. package/packages/reploid/src/mesh/partitions/signed-resident-factory.js +59 -0
  101. package/packages/reploid/src/mesh/peer-generation.js +896 -0
  102. package/packages/reploid/src/mesh/peer-identity.js +87 -0
  103. package/packages/reploid/src/mesh/placement-beliefs.js +107 -0
  104. package/packages/reploid/src/mesh/preparation-progress.js +46 -0
  105. package/packages/reploid/src/mesh/provider-hardware.js +11 -0
  106. package/packages/reploid/src/mesh/remote-generation-requests.js +53 -0
  107. package/packages/reploid/src/mesh/swarm-coordination.js +140 -0
  108. package/packages/reploid/src/mesh/web-access-network.js +112 -0
  109. package/packages/reploid/src/mesh/web-access-policy.json +17 -0
  110. package/packages/reploid/src/retrieval/citations.js +156 -0
  111. package/packages/reploid/src/retrieval/claim-support.js +113 -0
  112. package/packages/reploid/src/retrieval/costs.js +101 -0
  113. package/packages/reploid/src/retrieval/fixed-provider.js +34 -0
  114. package/packages/reploid/src/retrieval/index.js +20 -0
  115. package/packages/reploid/src/retrieval/lexical-support.js +27 -0
  116. package/packages/reploid/src/retrieval/model-reviewer.js +67 -0
  117. package/packages/reploid/src/retrieval/page-fetch.json +10 -0
  118. package/packages/reploid/src/retrieval/page-text.js +182 -0
  119. package/packages/reploid/src/retrieval/passage.js +37 -0
  120. package/packages/reploid/src/retrieval/policy.json +14 -0
  121. package/packages/reploid/src/retrieval/retrieval.js +23 -0
  122. package/packages/reploid/src/retrieval/review-queue.js +76 -0
  123. package/packages/reploid/src/retrieval/sources.js +263 -0
  124. package/packages/reploid/src/retrieval/support-review.js +155 -0
  125. package/packages/reploid/src/retrieval/text.js +21 -0
  126. package/packages/reploid/src/rules/contribution.rules.json +8 -0
  127. package/packages/reploid/src/rules/routing.rules.json +24 -0
  128. package/packages/reploid/src/transport/assignment.js +571 -0
  129. package/packages/reploid/src/transport/bounded-channel-writer.js +46 -0
  130. package/packages/reploid/src/transport/index.js +11 -0
  131. package/packages/reploid/src/transport/introduction-admission.js +24 -0
  132. package/packages/reploid/src/transport/introduction-bootstrap.js +112 -0
  133. package/packages/reploid/src/transport/introduction-policy.json +12 -0
  134. package/packages/reploid/src/transport/introduction-records.js +90 -0
  135. package/packages/reploid/src/transport/introductions.js +209 -0
  136. package/packages/reploid/src/transport/partition-data-channel.js +421 -0
  137. package/packages/reploid/src/transport/partition-errors.json +16 -0
  138. package/packages/reploid/src/transport/retry-policy.js +36 -0
  139. package/packages/reploid/src/transport/room.js +502 -0
  140. package/packages/reploid/src/transport/sdp-fingerprint.js +5 -0
  141. package/packages/reploid/src/transport/signaling.js +571 -0
  142. package/packages/reploid/src/transport/swarm.js +1247 -0
  143. package/packages/reploid/src/transport/tool-offer-channel.js +137 -0
  144. package/server/browser-roots.js +42 -0
  145. package/server/browser-web-worker.js +94 -0
  146. package/server/chat-completions.js +45 -0
  147. package/server/fleet/browser-bridge.js +145 -0
  148. package/server/fleet/contract.js +42 -0
  149. package/server/fleet/credit-evidence.js +103 -0
  150. package/server/fleet/credit-recovery.js +175 -0
  151. package/server/fleet/credits.js +178 -0
  152. package/server/fleet/execution-admission.js +32 -0
  153. package/server/fleet/native-host.js +817 -0
  154. package/server/fleet/placement.js +132 -0
  155. package/server/fleet/requester.js +346 -0
  156. package/server/fleet/service.js +311 -0
  157. package/server/fleet/terminal-policy.js +83 -0
  158. package/server/fleet/text-api.js +94 -0
  159. package/server/hashing.js +35 -0
  160. package/server/http-request-owner.js +49 -0
  161. package/server/messages.js +57 -0
  162. package/server/native-canonical-epochs.js +90 -0
  163. package/server/native-change-log.js +40 -0
  164. package/server/native-earning.js +203 -0
  165. package/server/native-generation-journal.js +46 -0
  166. package/server/native-model-config.js +57 -0
  167. package/server/native-model-files.js +382 -0
  168. package/server/native-model-origin.js +54 -0
  169. package/server/native-model-origins.json +12 -0
  170. package/server/native-node.js +85 -0
  171. package/server/native-owner/controls.js +162 -0
  172. package/server/native-owner/monitor.js +251 -0
  173. package/server/native-owner/policy.js +273 -0
  174. package/server/native-owner/rate.js +92 -0
  175. package/server/native-participant.js +866 -0
  176. package/server/native-readiness-log.js +30 -0
  177. package/server/native-release-storage.js +64 -0
  178. package/server/native-shader-cache.js +369 -0
  179. package/server/native-supply-budget.js +66 -0
  180. package/server/native-whole-earning.js +67 -0
  181. package/server/native-whole-model-fill.js +182 -0
  182. package/server/native-whole-model.js +130 -0
  183. package/server/public-web-worker.js +66 -0
  184. package/server/responses.js +92 -0
  185. package/server/search/errors.js +47 -0
  186. package/server/search/page-fetch.js +185 -0
  187. package/server/text-api.js +83 -0
  188. package/server/text-recovery.js +45 -0
  189. package/server/text-request-options.js +23 -0
  190. package/server/text-requester.js +231 -0
  191. package/server/token-auth.js +9 -0
  192. package/server/whole-model-requester.js +99 -0
  193. package/shared/config/chat-models.json +4685 -0
  194. package/shared/config/doppler-execution-contracts.js +32 -0
  195. package/shared/config/doppler-execution-contracts.json +43 -0
  196. package/shared/config/doppler-generation-contract.js +223 -0
  197. package/shared/config/doppler-local-models.js +79 -0
  198. package/shared/config/doppler-package.json +7 -0
  199. package/shared/config/model-manifests/a0dc69e917133a82780823d17fc771c0f6b882d7c86c571deeaef45be2ff108b.json +6434 -0
  200. package/shared/config/model-manifests/e985e3cdad60d0cb57d84b436161e66ab5b3f6589a168de6f00b4610e5f1fb59.json +16534 -0
  201. package/shared/config/model-manifests/fa6eb7bbc3ba63571f8cede780b877d9272bde948697af93dd3d33837cb46137.json +7175 -0
  202. package/shared/config/model-pieces/qwen-0-8b-pieces.json +1506 -0
  203. package/shared/config/model-pieces/qwen-2b-pieces.json +4800 -0
  204. package/shared/config/model-pieces/qwen35b-q4_0-pieces.json +28880 -0
  205. package/shared/config/reploid-library.json +49 -0
  206. package/shared/config/swarm-bootstrap.json +21 -0
  207. package/shared/infrastructure/doppler-runtime-service.js +261 -0
  208. package/shared/pool/executable-pack.js +87 -0
  209. package/shared/pool/pack-operation-adapters.js +124 -0
  210. package/shared/pool/pack-operation-policy.js +75 -0
  211. package/shared/pool/pool-config.json +2280 -0
  212. package/shared/vendor/reploid/adapters/doppler-session.js +372 -0
  213. package/shared/vendor/reploid/artifacts/binary.js +25 -0
  214. package/shared/vendor/reploid/artifacts/canonical-json.js +23 -0
  215. package/shared/vendor/reploid/artifacts/delivery-attestation.js +64 -0
  216. package/shared/vendor/reploid/artifacts/identity.js +91 -0
  217. package/shared/vendor/reploid/artifacts/manifest-identity.js +17 -0
  218. package/shared/vendor/reploid/artifacts/participant-proofs.js +159 -0
  219. package/shared/vendor/reploid/artifacts/signatures.js +94 -0
  220. package/shared/vendor/reploid/config/defaults.json +110 -0
  221. package/shared/vendor/reploid/config/index.js +209 -0
  222. package/shared/vendor/reploid/config/request-overrides.json +9 -0
  223. package/shared/vendor/reploid/hashing.js +30 -0
  224. package/shared/vendor/reploid/mesh/generation-context-cache.js +97 -0
  225. package/shared/vendor/reploid/mesh/partitions/partition-attempt-evidence.js +282 -0
  226. package/shared/vendor/reploid/mesh/partitions/partition-contract.js +192 -0
  227. package/shared/vendor/reploid/mesh/partitions/partition-receiver-policy.json +7 -0
  228. package/shared/vendor/reploid/mesh/partitions/partition-route-ranking.js +268 -0
  229. package/shared/vendor/reploid/mesh/preparation-progress.js +46 -0
  230. package/shared/vendor/reploid/mesh/provider-hardware.js +11 -0
  231. package/shared/vendor/reploid/rules/routing.rules.json +24 -0
  232. package/shared/vendor/reploid/transport/partition-errors.json +16 -0
  233. package/README.md +0 -3
@@ -0,0 +1,182 @@
1
+ /** Fills a whole-model directory from verified pieces so one machine can hold the complete
2
+ * model. Every piece is checked against the pinned piece index, every assembled file against
3
+ * its pinned whole-file hash (the manifest's shard hash or the catalog's file hash), and a
4
+ * file appears under its final name only after that check (temporary `.partial` + rename).
5
+ * An interrupted fill resumes: pieces already written and still matching their identity are
6
+ * reused. The manifest is written last, so a directory with a matching manifest was either
7
+ * placed by its owner or completed here. Where the bytes come from is the caller's port. */
8
+ import fs from 'node:fs/promises';
9
+ import { constants, createReadStream } from 'node:fs';
10
+ import path from 'node:path';
11
+ import { createHash } from 'node:crypto';
12
+
13
+ const assert = (ok, message, code) => { if (!ok) throw Object.assign(new Error(message), code ? { code } : {}); };
14
+ const sha256 = bytes => createHash('sha256').update(bytes).digest('hex');
15
+ const plainName = name => typeof name === 'string' && /^[A-Za-z0-9_.-]+$/.test(name) && !/^\.\.?$/.test(name);
16
+ const isMetadataSource = file => plainName(file.path) && file.role !== 'model-piece-index';
17
+
18
+ async function regularSize(filename) {
19
+ try { const info = await fs.lstat(filename); return info.isFile() ? info.size : -1; }
20
+ catch (error) { if (error.code === 'ENOENT') return null; throw error; }
21
+ }
22
+
23
+ async function hashFile(filename, signal) {
24
+ const digest = createHash('sha256');
25
+ for await (const chunk of createReadStream(filename, { signal })) digest.update(chunk);
26
+ return digest.digest('hex');
27
+ }
28
+
29
+ /** True when the directory already holds the catalog manifest and every file it needs at its
30
+ * pinned size. Reads only the local manifest, so an owner-provided directory never needs peers. */
31
+ export async function wholeModelDirectoryComplete(root, model) {
32
+ let manifestBytes;
33
+ try { manifestBytes = await fs.readFile(path.join(root, 'manifest.json')); }
34
+ catch (error) { if (error.code === 'ENOENT') return false; throw error; }
35
+ if ('sha256:' + sha256(manifestBytes) !== model.identity) return false;
36
+ const manifest = JSON.parse(manifestBytes.toString('utf8'));
37
+ const required = [...(manifest.shards || []).map(shard => ({ path: shard.filename, size: shard.size })),
38
+ ...(model.source?.files || []).filter(file => isMetadataSource(file) && file.path !== 'manifest.json')
39
+ .map(file => ({ path: file.path, size: file.sizeBytes }))];
40
+ for (const file of required) if (await regularSize(path.join(root, file.path)) !== file.size) return false;
41
+ return true;
42
+ }
43
+
44
+ /** The files a complete directory holds: every file of the piece index with its pinned whole
45
+ * hash, then catalog metadata outside the index, the manifest last. */
46
+ export function wholeModelFillPlan({ model, manifest, index }) {
47
+ assert(manifest.hashAlgorithm === 'sha256', 'Whole-model fill requires SHA-256 shard hashes');
48
+ const pinned = new Map((model.source?.files || []).map(file => [file.path, file]));
49
+ const assembled = index.files.map(file => {
50
+ assert(plainName(file.path), 'Piece index names a file outside the model directory: ' + file.path);
51
+ const shard = manifest.shards.find(row => row.filename === file.path), source = pinned.get(file.path);
52
+ const hash = shard ? shard.hash : source?.hashAlgorithm === 'sha256' ? source.hash : null;
53
+ assert(/^[a-f0-9]{64}$/.test(hash || ''), 'Piece index names an unpinned file: ' + file.path);
54
+ assert(file.size === (shard ? shard.size : source.sizeBytes), 'Piece index size differs from the pinned file: ' + file.path);
55
+ let offset = 0;
56
+ for (const piece of file.pieces) { assert(piece.offset === offset && piece.size > 0, 'Pieces do not cover ' + file.path); offset += piece.size; }
57
+ assert(offset === file.size, 'Pieces do not cover ' + file.path);
58
+ return { path: file.path, size: file.size, hash, pieces: file.pieces };
59
+ });
60
+ for (const shard of manifest.shards) assert(assembled.some(file => file.path === shard.filename), 'Piece index omits shard ' + shard.filename);
61
+ const metadata = (model.source?.files || []).filter(file => isMetadataSource(file) && !assembled.some(row => row.path === file.path))
62
+ .sort((a, b) => (a.path === 'manifest.json') - (b.path === 'manifest.json'));
63
+ assert(metadata.at(-1)?.path === 'manifest.json', 'Catalog source files must pin manifest.json');
64
+ return { assembled, metadata };
65
+ }
66
+
67
+ async function writeAtomically(root, name, bytes, signal) {
68
+ const temporary = path.join(root, name + '.' + crypto.randomUUID() + '.tmp');
69
+ let handle;
70
+ try {
71
+ handle = await fs.open(temporary, 'wx', 0o600); await handle.writeFile(bytes); await handle.sync(); await handle.close(); handle = null;
72
+ signal?.throwIfAborted(); await fs.rename(temporary, path.join(root, name));
73
+ } finally { await handle?.close(); await fs.unlink(temporary).catch(error => { if (error.code !== 'ENOENT') throw error; }); }
74
+ }
75
+
76
+ async function readRange(handle, offset, size) {
77
+ const bytes = new Uint8Array(size); let received = 0;
78
+ while (received < size) {
79
+ const part = await handle.read(bytes, received, size - received, offset + received);
80
+ if (part.bytesRead === 0) return null;
81
+ received += part.bytesRead;
82
+ }
83
+ return bytes;
84
+ }
85
+
86
+ async function syncDirectory(root) {
87
+ let handle;
88
+ try { handle = await fs.open(root, constants.O_RDONLY); await handle.sync(); }
89
+ catch (error) { if (!['EISDIR', 'EINVAL', 'EPERM', 'EBADF'].includes(error.code)) throw error; }
90
+ finally { await handle?.close(); }
91
+ }
92
+
93
+ /**
94
+ * `acquire(piece, {signal})` returns a piece's bytes, or `{bytes, source}` naming the source,
95
+ * from an authorized source; they are verified here regardless. `readMetadata(file, signal)` returns a pinned catalog file.
96
+ * `release(file)` lets the caller drop duplicate piece copies once `file` is in place.
97
+ * `freeBytes(root)` reports writable space. `onProgress` receives `start`, `piece`, `file`
98
+ * and `complete` events with cumulative counts.
99
+ */
100
+ export async function fillWholeModelDirectory({ root, model, manifest, index, acquire, readMetadata,
101
+ release = async () => {}, freeBytes, concurrency = 1, signal = AbortSignal.timeout(2 ** 31 - 1), onProgress = () => {} }) {
102
+ assert(Number.isSafeInteger(concurrency) && concurrency > 0, 'Explicit fill concurrency required');
103
+ const started = Date.now(), plan = wholeModelFillPlan({ model, manifest, index });
104
+ // Never overwrite a directory that holds another model.
105
+ try {
106
+ const present = await fs.readFile(path.join(root, 'manifest.json'));
107
+ assert('sha256:' + sha256(present) === model.identity, 'Model directory holds a different manifest', 'WHOLE_MODEL_DIRECTORY_CONFLICT');
108
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
109
+ // A file already under its final name is used only after its whole hash matches; one that
110
+ // does not becomes the partial file, so its matching pieces are still reused.
111
+ const missing = [];
112
+ for (const file of plan.assembled) {
113
+ const filename = path.join(root, file.path);
114
+ if (await regularSize(filename) === file.size && await hashFile(filename, signal) === file.hash) continue;
115
+ if (await regularSize(filename) !== null) await fs.rename(filename, filename + '.partial');
116
+ missing.push(file);
117
+ }
118
+ const metadata = [];
119
+ for (const file of plan.metadata) {
120
+ const filename = path.join(root, file.path);
121
+ if (file.path !== 'manifest.json' && await regularSize(filename) === file.sizeBytes && await hashFile(filename, signal) === file.hash) continue;
122
+ metadata.push(file);
123
+ }
124
+ const totals = { files: missing.length + metadata.length, pieces: missing.reduce((sum, file) => sum + file.pieces.length, 0),
125
+ bytes: missing.reduce((sum, file) => sum + file.size, 0) + metadata.reduce((sum, file) => sum + file.sizeBytes, 0) };
126
+ const progress = { fetchedPieces: 0, fetchedBytes: 0, reusedPieces: 0, reusedBytes: 0, completedFiles: 0, sources: {} };
127
+ const count = (source, bytes) => { const row = progress.sources[source] ||= { pieces: 0, bytes: 0 }; row.pieces++; row.bytes += bytes; };
128
+ const emit = (phase, extra = {}) => onProgress(structuredClone({ phase, ...totals, ...progress, elapsedMs: Date.now() - started, ...extra }));
129
+ const available = await freeBytes(root);
130
+ assert(available >= totals.bytes, `Model directory needs ${totals.bytes} bytes; ${available} are free`, 'WHOLE_MODEL_STORAGE');
131
+ emit('start');
132
+ for (const file of missing) {
133
+ signal.throwIfAborted();
134
+ const partial = path.join(root, file.path + '.partial');
135
+ const handle = await fs.open(partial, constants.O_RDWR | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
136
+ try {
137
+ const info = await handle.stat(); assert(info.isFile(), 'Partial model file must be a regular file');
138
+ if (info.size > file.size) await handle.truncate(file.size);
139
+ const queue = file.pieces.slice();
140
+ const worker = async () => {
141
+ for (let piece = queue.shift(); piece; piece = queue.shift()) {
142
+ signal.throwIfAborted();
143
+ const present = await readRange(handle, piece.offset, piece.size);
144
+ if (present && 'sha256:' + sha256(present) === piece.identity) {
145
+ progress.reusedPieces++; progress.reusedBytes += piece.size; count('partial-file', piece.size);
146
+ emit('piece', { path: file.path, reused: true, source: 'partial-file' }); continue;
147
+ }
148
+ const acquired = await acquire({ ...piece, path: file.path }, { signal });
149
+ const bytes = acquired instanceof Uint8Array ? acquired : acquired?.bytes;
150
+ const source = acquired instanceof Uint8Array ? 'acquired' : String(acquired?.source || 'acquired');
151
+ signal.throwIfAborted();
152
+ assert(bytes instanceof Uint8Array && bytes.byteLength === piece.size && 'sha256:' + sha256(bytes) === piece.identity,
153
+ `Rejected unverified bytes for piece ${piece.identity} of ${file.path}`, 'WHOLE_MODEL_PIECE_REJECTED');
154
+ let written = 0;
155
+ while (written < bytes.byteLength) written += (await handle.write(bytes, written, bytes.byteLength - written, piece.offset + written)).bytesWritten;
156
+ progress.fetchedPieces++; progress.fetchedBytes += piece.size; count(source, piece.size);
157
+ emit('piece', { path: file.path, reused: false, source });
158
+ }
159
+ };
160
+ await Promise.all(Array.from({ length: Math.min(concurrency, file.pieces.length) }, worker));
161
+ await handle.sync();
162
+ } finally { await handle.close(); }
163
+ const actual = await hashFile(partial, signal);
164
+ if (actual !== file.hash) {
165
+ await fs.unlink(partial);
166
+ assert(false, `Assembled ${file.path} differs from its pinned hash`, 'WHOLE_MODEL_FILE_REJECTED');
167
+ }
168
+ await fs.rename(partial, path.join(root, file.path)); await syncDirectory(root);
169
+ progress.completedFiles++; emit('file', { path: file.path });
170
+ await release(file);
171
+ }
172
+ for (const file of metadata) {
173
+ const read = await readMetadata(file, signal);
174
+ const bytes = read instanceof Uint8Array ? read : read?.bytes;
175
+ assert(bytes instanceof Uint8Array && bytes.byteLength === file.sizeBytes && sha256(bytes) === file.hash,
176
+ 'Rejected unverified bytes for ' + file.path, 'WHOLE_MODEL_FILE_REJECTED');
177
+ await writeAtomically(root, file.path, bytes, signal); await syncDirectory(root);
178
+ progress.completedFiles++; emit('file', { path: file.path, ...(read instanceof Uint8Array ? {} : { source: read?.source }) });
179
+ }
180
+ emit('complete');
181
+ return { ...totals, ...progress, elapsedMs: Date.now() - started };
182
+ }
@@ -0,0 +1,130 @@
1
+ /** Native whole-model contribution uses the same scoped provider as browser chat. */
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { sha256Digest } from './hashing.js';
6
+ import { createReploidDopplerRuntimeService } from '../shared/infrastructure/doppler-runtime-service.js';
7
+ import { createWorkResidentProvider } from '../apps/poolday/providers/work-resident-provider.js';
8
+ import { createWorkAdapterResolver, selectWorkAdapters } from '../apps/poolday/providers/work-adapter.js';
9
+ import profile from '../apps/poolday/config/work-profile.json' with { type: 'json' };
10
+ import { resolvePartitionGeneration } from '../apps/poolday/config/partition-generation.js';
11
+
12
+ /** `fill({signal})`, when given, completes the model directory from verified peer pieces before
13
+ * the manifest is read (server/native-model-files.js fillWholeModel); stop aborts it. */
14
+ export function createNativeWholeModel({ model, modelDirectory, nativeGpu, owner = null, onChange = () => {}, fill = null,
15
+ configureMemory = async maxBytes => (await import('doppler-gpu/partitions')).configureDeviceMemoryBudget({ maxBytes }),
16
+ resolveAdapter = createWorkAdapterResolver({ models: [model] }),
17
+ service = createReploidDopplerRuntimeService({ loadModule: () => import('doppler-gpu/compat') }),
18
+ createResident = createWorkResidentProvider }) {
19
+ model = structuredClone(model);
20
+ model.adapters = selectWorkAdapters(model);
21
+ let resident = null, preparing = null, stopping = null, closed = false, enabled = false, filling = null;
22
+ const ownerAllows = () => {
23
+ const decision = owner?.decide();
24
+ return !decision || decision.action === 'admit'
25
+ || decision.action === 'reduce' && decision.limits.maxConcurrentAttempts > 0;
26
+ };
27
+ const getState = () => {
28
+ const state = resident?.getState() || { phase: filling ? 'acquiring-model' : 'idle', ready: false };
29
+ return { ...state, mode: 'whole-model', offering: enabled,
30
+ ready: enabled && !closed && ownerAllows() && state.ready,
31
+ modelId: model.id, modelIdentity: state.modelIdentity ?? null };
32
+ };
33
+ const notify = () => onChange(getState());
34
+ const stop = () => stopping ||= (async () => {
35
+ enabled = false; filling?.abort(new Error('Whole-model contribution stopped')); notify();
36
+ await resident?.close();
37
+ await preparing?.catch(() => {});
38
+ resident = null; notify();
39
+ })().finally(() => { stopping = null; });
40
+ return Object.freeze({
41
+ getState,
42
+ getExecutionState() {
43
+ const state = getState();
44
+ return { ...state,
45
+ // Prepared capacity remains discoverable while its current slot is
46
+ // occupied. The mesh separately advertises the available slot count.
47
+ phase: state.ready ? 'ready' : ['ready', 'executing'].includes(state.phase) ? 'idle' : state.phase,
48
+ activity: { phase: state.phase, completed: state.completed || 0, error: state.error || null }
49
+ };
50
+ },
51
+ async contribute(approved) {
52
+ if (approved !== true) throw new Error('Approve contribution before loading a whole model');
53
+ if (closed || stopping) throw new Error('Whole-model contributor is stopping');
54
+ if (preparing) return preparing;
55
+ if (resident) return getState();
56
+ enabled = true;
57
+ preparing = (async () => {
58
+ if (fill) {
59
+ const controller = new AbortController(); filling = controller; notify();
60
+ try { await fill({ signal: controller.signal }); }
61
+ finally { if (filling === controller) filling = null; }
62
+ if (!enabled || closed) throw new Error('Whole-model contribution stopped');
63
+ }
64
+ const root = await fs.realpath(modelDirectory);
65
+ const manifestPath = await fs.realpath(path.join(root, 'manifest.json'));
66
+ if (!manifestPath.startsWith(root + path.sep)) throw new Error('Native whole-model manifest escapes approved directory');
67
+ const manifest = await fs.readFile(manifestPath);
68
+ if (sha256Digest(manifest) !== model.identity) {
69
+ throw new Error('Native whole-model manifest differs from the catalog');
70
+ }
71
+ if (!enabled || closed) throw new Error('Whole-model contribution stopped');
72
+ await nativeGpu();
73
+ if (!enabled || closed) throw new Error('Whole-model contribution stopped');
74
+ const loadOptions = structuredClone(model.loadOptions || {});
75
+ const budget = loadOptions.runtimeConfig?.shared?.bufferPool?.budget;
76
+ const maximum = owner?.gpuBufferBytes(budget?.maxTotalBytes ?? Infinity);
77
+ if (Number.isFinite(maximum)) {
78
+ // The pool limit alone excludes some persistent weight/state allocations.
79
+ await configureMemory(maximum);
80
+ loadOptions.runtimeConfig ??= {};
81
+ loadOptions.runtimeConfig.shared ??= {};
82
+ loadOptions.runtimeConfig.shared.bufferPool ??= {};
83
+ loadOptions.runtimeConfig.shared.bufferPool.budget ??= {};
84
+ loadOptions.runtimeConfig.shared.bufferPool.budget.maxTotalBytes = maximum;
85
+ loadOptions.runtimeConfig.inference ??= {};
86
+ loadOptions.runtimeConfig.inference.session ??= {};
87
+ loadOptions.runtimeConfig.inference.session.prefillChunkSubmitMode ??= 'sync';
88
+ }
89
+ // A catalog model without explicit generation runs whole with its partition route's
90
+ // policy: the same output budget and a KV cache sized to that policy's context. The work
91
+ // profile alone left the runtime's default context no larger than its output budget.
92
+ const generation = model.generation || resolvePartitionGeneration(model);
93
+ if (!model.generation) {
94
+ loadOptions.runtimeConfig ??= {};
95
+ loadOptions.runtimeConfig.inference ??= {};
96
+ loadOptions.runtimeConfig.inference.session ??= {};
97
+ loadOptions.runtimeConfig.inference.session.kvcache ??= {};
98
+ loadOptions.runtimeConfig.inference.session.kvcache.maxSeqLen ??= generation.maxSeqLen;
99
+ }
100
+ resident = createResident({ service, model, resolveAdapter, generation,
101
+ maxOutcomeCharacters: profile.maxOutcomeCharacters, loadOptions,
102
+ source: { manifest: JSON.parse(manifest.toString('utf8')), manifestText: manifest.toString('utf8'),
103
+ manifestHash: model.identity.slice(7), baseUrl: pathToFileURL(root + path.sep).href }, onChange: notify });
104
+ await resident.prepare();
105
+ return getState();
106
+ })().catch(async error => {
107
+ enabled = false;
108
+ await resident?.close(); resident = null; notify(); throw error;
109
+ }).finally(() => { preparing = null; });
110
+ return preparing;
111
+ },
112
+ generate(messages, onUpdate, controls) {
113
+ if (!getState().ready) throw Object.assign(new Error('Whole-model capacity unavailable'), { code: 'CAPACITY_UNAVAILABLE' });
114
+ const identities = controls.adapterIdentities || [];
115
+ if (!Array.isArray(identities) || identities.length > 1) throw new Error('Select at most one prepared adapter');
116
+ const adapters = identities.map(identity => {
117
+ const selected = model.adapters?.find(adapter => adapter.identity === identity);
118
+ if (!selected || !getState().adapterIdentities?.includes(identity)) {
119
+ throw new Error('Requested adapter is not prepared on this contributor');
120
+ }
121
+ return structuredClone(selected);
122
+ });
123
+ const ownerPolicyDigest = owner?.digest ?? null;
124
+ return Promise.resolve(resident.generate(messages, onUpdate, { ...controls, adapters }))
125
+ .then(result => ({ ...result, ownerPolicyDigest }));
126
+ },
127
+ stop,
128
+ async close() { closed = true; await stop(); }
129
+ });
130
+ }
@@ -0,0 +1,66 @@
1
+ /** Native contributor result grants and cookie-free public page extraction.
2
+ * Only URLs returned by this worker's search can be extracted, by the same requester and query.
3
+ * The guarded reader checks DNS/redirects and bounds compressed and decoded bytes. */
4
+ import { createPageFetcher } from './search/page-fetch.js';
5
+ import { extractDocumentText, selectPassage, sha256Text } from '../packages/reploid/src/retrieval/index.js';
6
+ import policy from '../packages/reploid/src/mesh/web-access-policy.json' with { type: 'json' };
7
+
8
+ const requireValue = (ok, message) => { if (!ok) throw new Error(message); };
9
+ const text = value => extractDocumentText({ body: new TextEncoder().encode(value), contentType: 'text/html' }).text;
10
+ const field = (item, key) => text(item.match(new RegExp(`<${key}>([\\s\\S]*?)</${key}>`, 'i'))?.[1] || '');
11
+
12
+ export function createPublicWebWorker({
13
+ searchFetch = createPageFetcher({ allowedTypes: ['text/xml', 'application/rss+xml'], maxBytes: 256000 }).fetchPage,
14
+ pageFetch = createPageFetcher().fetchPage, now = Date.now, service = 'bing', searchResults = null
15
+ } = {}) {
16
+ const grants = new Map();
17
+ const prune = () => { for (const [token, grant] of grants) if (grant.expiresAt <= now()) grants.delete(token); };
18
+ const queryValid = query => typeof query === 'string' && query.trim() && query.length <= policy.maxQueryCharacters;
19
+ return Object.freeze({ service, version: '1',
20
+ async search({ query, maxResults }, { signal, requester }) {
21
+ requireValue(queryValid(query) && Number.isInteger(maxResults) && maxResults > 0 && maxResults <= policy.maxResults, 'Invalid search limits');
22
+ prune(); requireValue(grants.size + maxResults <= policy.maxSeen, 'Search result capacity reached');
23
+ let rows;
24
+ if (searchResults) rows = await searchResults({ query, maxResults, signal });
25
+ else {
26
+ const url = new URL('https://www.bing.com/search'); url.searchParams.set('format', 'rss'); url.searchParams.set('q', query);
27
+ const page = await searchFetch(url.href, { signal }); signal?.throwIfAborted();
28
+ const xml = new TextDecoder().decode(page.body);
29
+ requireValue(page.status === 200 && /<rss[\s>]/i.test(xml) && !/<!DOCTYPE|<!ENTITY/i.test(xml), 'Search did not return results');
30
+ rows = [...xml.matchAll(/<item>([\s\S]*?)<\/item>/gi)].map(item => ({
31
+ url: field(item[1], 'link'), title: field(item[1], 'title'), excerpt: field(item[1], 'description')
32
+ }));
33
+ }
34
+ signal?.throwIfAborted(); prune();
35
+ requireValue(grants.size + maxResults <= policy.maxSeen, 'Search result capacity reached');
36
+ const results = [];
37
+ for (const row of rows) {
38
+ if (results.length >= maxResults) break;
39
+ let target;
40
+ try { target = new URL(row.url); } catch { continue; }
41
+ if (!['https:', 'http:'].includes(target.protocol) || target.username || target.password) continue;
42
+ const token = crypto.randomUUID(), sourceId = `web-${results.length + 1}`;
43
+ grants.set(token, { requester, query, url: target.href, sourceId, expiresAt: now() + policy.resultTtlMs });
44
+ const excerpt = String(row.excerpt || row.title || '').slice(0, 1500);
45
+ results.push({ sourceId, url: target.href, title: String(row.title || '').slice(0, 1000),
46
+ excerpt, contentHash: await sha256Text(excerpt), retrievedAt: now(), fetchToken: token });
47
+ }
48
+ return { results, usage: { provider: `contributor-${service}`, requests: 1, costMicroUsd: null } };
49
+ },
50
+ async extract({ source, query, maxCharacters }, { signal, requester }) {
51
+ prune();
52
+ const grant = grants.get(source?.fetchToken);
53
+ requireValue(grant && grant.requester === requester && grant.query === query && grant.url === source.url
54
+ && grant.sourceId === source.sourceId, 'Page is not an authorized search result');
55
+ requireValue(Number.isInteger(maxCharacters) && maxCharacters > 0 && maxCharacters <= policy.maxSourceCharacters, 'Invalid passage limit');
56
+ const page = await pageFetch(grant.url, { signal }); signal?.throwIfAborted();
57
+ const document = extractDocumentText({ body: page.body, contentType: page.contentType });
58
+ const passage = selectPassage({ text: document.text, query, maxCharacters });
59
+ requireValue(passage.text.trim(), 'Page has no extractable text');
60
+ const sha256 = await sha256Text(document.text);
61
+ return { sourceId: grant.sourceId, url: page.url, title: document.title.slice(0, 1000), text: passage.text,
62
+ retrievedAt: now(), contentHash: sha256, document: { url: page.url, status: page.status, contentType: page.contentType,
63
+ characters: document.text.length, sha256, start: passage.start, end: passage.end, bytes: page.body.byteLength }, costMicroUsd: null };
64
+ }
65
+ });
66
+ }
@@ -0,0 +1,92 @@
1
+ /** Stateless text Responses protocol. No response store, continuation lookup or tools. */
2
+ import { randomUUID } from 'node:crypto';
3
+ import { parseTextChatCompletion } from './chat-completions.js';
4
+ import { createTextRequester, fail, object, freeze } from './text-requester.js';
5
+ import policy from '../apps/poolday/config/partition-policy.json' with { type: 'json' };
6
+ import { parseRequesterBinding } from './text-request-options.js';
7
+
8
+ export function parseTextResponse(body) {
9
+ if (!object(body)) throw fail(400, 'A JSON object is required');
10
+ for (const key of Object.keys(body)) if (!['model', 'input', 'instructions', 'store', 'stream', 'max_output_tokens', 'text', 'reploid'].includes(key)) throw fail(400, `Unsupported parameter: ${key}`, key);
11
+ if (body.store !== false) throw fail(400, 'Stateless Responses requires explicit store:false', 'store');
12
+ if (body.instructions !== undefined && body.instructions !== null && typeof body.instructions !== 'string') throw fail(400, 'instructions must be text', 'instructions');
13
+ if (body.max_output_tokens !== undefined && (!Number.isSafeInteger(body.max_output_tokens)
14
+ || body.max_output_tokens <= 0 || body.max_output_tokens > policy.limits.maxTokens)) {
15
+ throw fail(400, 'max_output_tokens must fit the prepared route budget', 'max_output_tokens');
16
+ }
17
+ const jsonObject = body.text !== undefined;
18
+ if (jsonObject && (!object(body.text) || Object.keys(body.text).join() !== 'format'
19
+ || !object(body.text.format) || Object.keys(body.text.format).join() !== 'type' || body.text.format.type !== 'json_object')) {
20
+ throw fail(400, 'Only text.format.type json_object is supported', 'text');
21
+ }
22
+ let messages;
23
+ if (typeof body.input === 'string') messages = [{ role: 'user', content: body.input }];
24
+ else if (Array.isArray(body.input) && body.input.length > 0 && body.input.length <= 128) messages = body.input.map((value, index) => {
25
+ if (!object(value) || Object.keys(value).some(key => !['type', 'role', 'content'].includes(key))
26
+ || value.type !== undefined && value.type !== 'message') throw fail(400, 'Only text message inputs are supported', `input[${index}]`);
27
+ return { role: value.role, content: value.content };
28
+ });
29
+ else throw fail(400, 'Text input or text messages required', 'input');
30
+ if (typeof body.instructions === 'string') messages.unshift({ role: 'system', content: body.instructions });
31
+ if (jsonObject) messages.unshift({ role: 'system', content: 'Return exactly one valid JSON object. No markdown fences or commentary.' });
32
+ const parsed = parseTextChatCompletion({ model: body.model, messages, stream: body.stream });
33
+ return freeze({ ...parsed, instructions: body.instructions ?? null, jsonObject,
34
+ ...(body.max_output_tokens === undefined ? {} : { generationRequest: {
35
+ schema: 'reploid.partition-generation-request/v1', maxOutputTokens: body.max_output_tokens, usage: 'raw-model-tokens/v1',
36
+ } }), ...(body.reploid === undefined ? {} : { reploid: parseRequesterBinding(body.reploid) }) });
37
+ }
38
+
39
+ export function createResponses(options) {
40
+ return createTextRequester(options, { path: '/v1/responses', idPrefix: 'resp_', parse: parseTextResponse,
41
+ output({ id, created, model, input, write, end, json }) {
42
+ const itemId = 'msg_' + randomUUID().replaceAll('-', '');
43
+ let started = false, sequence = 0, observedUsage;
44
+ const part = text => ({ type: 'output_text', text, annotations: [], logprobs: [] });
45
+ const item = (text, status) => ({ id: itemId, type: 'message', role: 'assistant', status, content: [part(text)] });
46
+ const response = (status, output = []) => ({ id, object: 'response', created_at: created, model, status,
47
+ store: false, background: false, error: null, incomplete_details: status === 'incomplete' ? { reason: 'max_output_tokens' } : null,
48
+ instructions: input.instructions, output, tools: [], tool_choice: 'none', parallel_tool_calls: false,
49
+ temperature: null, top_p: null, metadata: null, access_programs: null, previous_response_id: null,
50
+ text: { format: { type: input.jsonObject ? 'json_object' : 'text' } },
51
+ ...(observedUsage ? { usage: { input_tokens: observedUsage.inputTokens, output_tokens: observedUsage.outputTokens,
52
+ total_tokens: observedUsage.inputTokens + observedUsage.outputTokens } } : {}) });
53
+ const frame = (type, value) => `event: ${type}\ndata: ${JSON.stringify({ ...value, type, sequence_number: sequence++ })}\n\n`;
54
+ const event = (type, value) => write(frame(type, value));
55
+ const position = { item_id: itemId, output_index: 0, content_index: 0 };
56
+ const start = () => {
57
+ if (started) return; started = true;
58
+ event('response.created', { response: response('in_progress') });
59
+ event('response.in_progress', { response: response('in_progress') });
60
+ event('response.output_item.added', { output_index: 0, item: { ...item('', 'in_progress'), content: [] } });
61
+ event('response.content_part.added', { ...position, part: part('') });
62
+ };
63
+ return {
64
+ usage(value) { observedUsage = value; },
65
+ delta(text) { start(); event('response.output_text.delta', { ...position, delta: text, logprobs: [] }); },
66
+ complete(content, finishReason, reason, usage, evidence) {
67
+ // The Responses incomplete enum has no context-limit member. Do not
68
+ // rename a context limit to an output-token budget or successful stop.
69
+ if (reason === 'context-limit') throw fail(502, 'Requester stopped at its context limit');
70
+ if (input.jsonObject) {
71
+ let parsed;
72
+ try { parsed = JSON.parse(content); } catch { throw fail(502, 'Requester did not return a JSON object'); }
73
+ if (!object(parsed)) throw fail(502, 'Requester did not return a JSON object');
74
+ }
75
+ observedUsage = usage;
76
+ const status = finishReason === 'length' ? 'incomplete' : 'completed';
77
+ const finalItem = item(content, status), final = response(status, [finalItem]);
78
+ if (evidence) final.reploid = evidence;
79
+ final.completed_at = status === 'completed' ? Math.floor(Date.now() / 1000) : null;
80
+ if (!input.stream) { json(final); return; }
81
+ start();
82
+ event('response.output_text.done', { ...position, text: content, logprobs: [] });
83
+ event('response.content_part.done', { ...position, part: part(content) });
84
+ event('response.output_item.done', { output_index: 0, item: finalItem });
85
+ event(status === 'completed' ? 'response.completed' : 'response.incomplete', { response: final });
86
+ end();
87
+ },
88
+ error(error) { end(frame('error', { code: 'server_error', message: String(error.message).slice(0, 1024), param: error.param })); }
89
+ };
90
+ }
91
+ });
92
+ }
@@ -0,0 +1,47 @@
1
+ /** Typed failures for search providers and page fetches. Codes are the contract; messages are for people. */
2
+ class SearchFailure extends Error {
3
+ constructor(code, { status = null, retryAfterSeconds = null, detail = null, usage = null } = {}, message = code) {
4
+ super(detail ? `${message}: ${detail}` : message);
5
+ this.code = code; this.status = status; this.retryAfterSeconds = retryAfterSeconds; this.detail = detail;
6
+ if (usage) this.usage = usage;
7
+ }
8
+ }
9
+
10
+ export class SearchProviderError extends SearchFailure {
11
+ constructor(code, details = {}) { super(code, details, `Search provider failed (${code})`); this.name = 'SearchProviderError'; }
12
+ }
13
+
14
+ export class PageFetchError extends SearchFailure {
15
+ constructor(code, details = {}) { super(code, details, `Page fetch failed (${code})`); this.name = 'PageFetchError'; }
16
+ }
17
+
18
+ /** Waits `ms` unless `signal` aborts first. */
19
+ export const sleep = (ms, signal = null) => new Promise((resolve, reject) => {
20
+ if (signal?.aborted) { reject(signal.reason); return; }
21
+ const timer = setTimeout(() => { signal?.removeEventListener('abort', stop); resolve(); }, ms);
22
+ const stop = () => { clearTimeout(timer); reject(signal.reason); };
23
+ signal?.addEventListener('abort', stop, { once: true });
24
+ });
25
+
26
+ /** Maps a caller abort to timeout or cancellation. */
27
+ export function abortCode(signal) {
28
+ const reason = signal?.reason;
29
+ return reason?.name === 'TimeoutError' || reason?.code === 'timeout' ? 'timeout' : 'cancelled';
30
+ }
31
+
32
+ /** Reads at most `limit` bytes of a fetch Response body. */
33
+ export async function readBounded(response, limit, fail) {
34
+ const declared = Number(response.headers.get('content-length'));
35
+ if (Number.isFinite(declared) && declared > limit) { await response.body?.cancel().catch(() => {}); throw fail(); }
36
+ if (!response.body) return '';
37
+ const reader = response.body.getReader(), chunks = [];
38
+ let total = 0;
39
+ for (;;) {
40
+ const { done, value } = await reader.read();
41
+ if (done) break;
42
+ total += value.byteLength;
43
+ if (total > limit) { await reader.cancel().catch(() => {}); throw fail(); }
44
+ chunks.push(value);
45
+ }
46
+ return Buffer.concat(chunks.map(chunk => Buffer.from(chunk))).toString('utf8');
47
+ }