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.
- package/apps/poolday/config/chat-files.json +27 -0
- package/apps/poolday/config/partition-generation.js +21 -0
- package/apps/poolday/config/partition-policy.json +74 -0
- package/apps/poolday/config/partition-releases.json +3 -0
- package/apps/poolday/config/work-profile.json +122 -0
- package/apps/poolday/host/chat-view.js +47 -0
- package/apps/poolday/host/earning/client.js +199 -0
- package/apps/poolday/host/earning/contribution.js +168 -0
- package/apps/poolday/host/partition-release.js +33 -0
- package/apps/poolday/host/work-partition-artifact-source.js +37 -0
- package/apps/poolday/host/work-partition-capsule.js +39 -0
- package/apps/poolday/host/work-partition-plan.js +167 -0
- package/apps/poolday/host/work-partition-tokenizer-guard.js +21 -0
- package/apps/poolday/infrastructure/model-storage-lock.js +22 -0
- package/apps/poolday/infrastructure/pack-release-storage.js +52 -0
- package/apps/poolday/pool/pack-operation.js +161 -0
- package/apps/poolday/pool/pack-release-policy.js +44 -0
- package/apps/poolday/pool/peer-pack-data-channel.js +217 -0
- package/apps/poolday/providers/work-adapter.js +67 -0
- package/apps/poolday/providers/work-device.js +33 -0
- package/apps/poolday/providers/work-generation-completion.js +55 -0
- package/apps/poolday/providers/work-provider.js +209 -0
- package/apps/poolday/providers/work-resident-provider.js +113 -0
- package/deploy/artifacts/doppler-gpu-0.7.1.tgz +0 -0
- package/docs/fleet-service.md +214 -0
- package/docs/native-participant.md +542 -0
- package/native-delivery.json +1174 -0
- package/package.json +29 -4
- package/packages/reploid/src/artifacts/binary.js +25 -0
- package/packages/reploid/src/artifacts/canonical-json.js +23 -0
- package/packages/reploid/src/artifacts/custody/exchange.js +402 -0
- package/packages/reploid/src/artifacts/custody/piece-acquisition.js +70 -0
- package/packages/reploid/src/artifacts/custody/runtime.js +404 -0
- package/packages/reploid/src/artifacts/custody/supply-budget.js +19 -0
- package/packages/reploid/src/artifacts/custody-certificate.js +123 -0
- package/packages/reploid/src/artifacts/delivery-attestation.js +64 -0
- package/packages/reploid/src/artifacts/generation-terminal.js +36 -0
- package/packages/reploid/src/artifacts/identity.js +91 -0
- package/packages/reploid/src/artifacts/participant-proofs.js +159 -0
- package/packages/reploid/src/artifacts/receipt.js +131 -0
- package/packages/reploid/src/artifacts/signatures.js +94 -0
- package/packages/reploid/src/artifacts/whole-model-receipt.js +42 -0
- package/packages/reploid/src/cancellation.js +44 -0
- package/packages/reploid/src/config/defaults.json +110 -0
- package/packages/reploid/src/config/index.js +209 -0
- package/packages/reploid/src/config/request-overrides.json +9 -0
- package/packages/reploid/src/config/transport-defaults.json +7 -0
- package/packages/reploid/src/hashing.js +30 -0
- package/packages/reploid/src/measurement/capacity-utilization.js +321 -0
- package/packages/reploid/src/measurement/index.js +5 -0
- package/packages/reploid/src/measurement/readiness-records.js +310 -0
- package/packages/reploid/src/mesh/computer-identity.js +50 -0
- package/packages/reploid/src/mesh/computer-identity.policy.json +8 -0
- package/packages/reploid/src/mesh/contribution.js +141 -0
- package/packages/reploid/src/mesh/generation-recovery.js +58 -0
- package/packages/reploid/src/mesh/gpu-signature-shader.js +27 -0
- package/packages/reploid/src/mesh/gpu-signature.js +136 -0
- package/packages/reploid/src/mesh/gpu-signature.policy.json +11 -0
- package/packages/reploid/src/mesh/index.js +66 -0
- package/packages/reploid/src/mesh/partitions/automatic-ordered-partitions.js +540 -0
- package/packages/reploid/src/mesh/partitions/automatic-partitions.js +298 -0
- package/packages/reploid/src/mesh/partitions/partition-attempt-evidence.js +282 -0
- package/packages/reploid/src/mesh/partitions/partition-attempt-queue-policy.json +6 -0
- package/packages/reploid/src/mesh/partitions/partition-attempt-queue.js +112 -0
- package/packages/reploid/src/mesh/partitions/partition-canonical-contract.js +167 -0
- package/packages/reploid/src/mesh/partitions/partition-canonical-execution.js +168 -0
- package/packages/reploid/src/mesh/partitions/partition-chat-canonical.js +49 -0
- package/packages/reploid/src/mesh/partitions/partition-chat.js +125 -0
- package/packages/reploid/src/mesh/partitions/partition-contract.js +192 -0
- package/packages/reploid/src/mesh/partitions/partition-device-admission.js +96 -0
- package/packages/reploid/src/mesh/partitions/partition-discovery.js +96 -0
- package/packages/reploid/src/mesh/partitions/partition-entry-publication.js +183 -0
- package/packages/reploid/src/mesh/partitions/partition-entry.js +434 -0
- package/packages/reploid/src/mesh/partitions/partition-generation-request.js +33 -0
- package/packages/reploid/src/mesh/partitions/partition-grants.js +277 -0
- package/packages/reploid/src/mesh/partitions/partition-link.js +103 -0
- package/packages/reploid/src/mesh/partitions/partition-network.js +109 -0
- package/packages/reploid/src/mesh/partitions/partition-ordered-chat.js +290 -0
- package/packages/reploid/src/mesh/partitions/partition-ordered-peer.js +415 -0
- package/packages/reploid/src/mesh/partitions/partition-ordered-publication.js +100 -0
- package/packages/reploid/src/mesh/partitions/partition-peer.js +314 -0
- package/packages/reploid/src/mesh/partitions/partition-placement.js +143 -0
- package/packages/reploid/src/mesh/partitions/partition-preparation-progress.js +25 -0
- package/packages/reploid/src/mesh/partitions/partition-receiver-policy.json +7 -0
- package/packages/reploid/src/mesh/partitions/partition-reservations.js +75 -0
- package/packages/reploid/src/mesh/partitions/partition-route-envelope-policy.json +16 -0
- package/packages/reploid/src/mesh/partitions/partition-route-envelope.js +74 -0
- package/packages/reploid/src/mesh/partitions/partition-route-execution.js +409 -0
- package/packages/reploid/src/mesh/partitions/partition-route-network.js +179 -0
- package/packages/reploid/src/mesh/partitions/partition-route-ranking.js +268 -0
- package/packages/reploid/src/mesh/partitions/partition-runner.js +319 -0
- package/packages/reploid/src/mesh/partitions/partition-stage-assignment.js +109 -0
- package/packages/reploid/src/mesh/partitions/partition-stage-commit.js +103 -0
- package/packages/reploid/src/mesh/partitions/partition-step-receiver.js +291 -0
- package/packages/reploid/src/mesh/partitions/partition-summary.js +122 -0
- package/packages/reploid/src/mesh/partitions/preparation-owner.js +40 -0
- package/packages/reploid/src/mesh/partitions/resident-error-codes.json +10 -0
- package/packages/reploid/src/mesh/partitions/resident-partition.js +333 -0
- package/packages/reploid/src/mesh/partitions/resident-route.js +46 -0
- package/packages/reploid/src/mesh/partitions/signed-resident-factory.js +59 -0
- package/packages/reploid/src/mesh/peer-generation.js +896 -0
- package/packages/reploid/src/mesh/peer-identity.js +87 -0
- package/packages/reploid/src/mesh/placement-beliefs.js +107 -0
- package/packages/reploid/src/mesh/preparation-progress.js +46 -0
- package/packages/reploid/src/mesh/provider-hardware.js +11 -0
- package/packages/reploid/src/mesh/remote-generation-requests.js +53 -0
- package/packages/reploid/src/mesh/swarm-coordination.js +140 -0
- package/packages/reploid/src/mesh/web-access-network.js +112 -0
- package/packages/reploid/src/mesh/web-access-policy.json +17 -0
- package/packages/reploid/src/retrieval/citations.js +156 -0
- package/packages/reploid/src/retrieval/claim-support.js +113 -0
- package/packages/reploid/src/retrieval/costs.js +101 -0
- package/packages/reploid/src/retrieval/fixed-provider.js +34 -0
- package/packages/reploid/src/retrieval/index.js +20 -0
- package/packages/reploid/src/retrieval/lexical-support.js +27 -0
- package/packages/reploid/src/retrieval/model-reviewer.js +67 -0
- package/packages/reploid/src/retrieval/page-fetch.json +10 -0
- package/packages/reploid/src/retrieval/page-text.js +182 -0
- package/packages/reploid/src/retrieval/passage.js +37 -0
- package/packages/reploid/src/retrieval/policy.json +14 -0
- package/packages/reploid/src/retrieval/retrieval.js +23 -0
- package/packages/reploid/src/retrieval/review-queue.js +76 -0
- package/packages/reploid/src/retrieval/sources.js +263 -0
- package/packages/reploid/src/retrieval/support-review.js +155 -0
- package/packages/reploid/src/retrieval/text.js +21 -0
- package/packages/reploid/src/rules/contribution.rules.json +8 -0
- package/packages/reploid/src/rules/routing.rules.json +24 -0
- package/packages/reploid/src/transport/assignment.js +571 -0
- package/packages/reploid/src/transport/bounded-channel-writer.js +46 -0
- package/packages/reploid/src/transport/index.js +11 -0
- package/packages/reploid/src/transport/introduction-admission.js +24 -0
- package/packages/reploid/src/transport/introduction-bootstrap.js +112 -0
- package/packages/reploid/src/transport/introduction-policy.json +12 -0
- package/packages/reploid/src/transport/introduction-records.js +90 -0
- package/packages/reploid/src/transport/introductions.js +209 -0
- package/packages/reploid/src/transport/partition-data-channel.js +421 -0
- package/packages/reploid/src/transport/partition-errors.json +16 -0
- package/packages/reploid/src/transport/retry-policy.js +36 -0
- package/packages/reploid/src/transport/room.js +502 -0
- package/packages/reploid/src/transport/sdp-fingerprint.js +5 -0
- package/packages/reploid/src/transport/signaling.js +571 -0
- package/packages/reploid/src/transport/swarm.js +1247 -0
- package/packages/reploid/src/transport/tool-offer-channel.js +137 -0
- package/server/browser-roots.js +42 -0
- package/server/browser-web-worker.js +94 -0
- package/server/chat-completions.js +45 -0
- package/server/fleet/browser-bridge.js +145 -0
- package/server/fleet/contract.js +42 -0
- package/server/fleet/credit-evidence.js +103 -0
- package/server/fleet/credit-recovery.js +175 -0
- package/server/fleet/credits.js +178 -0
- package/server/fleet/execution-admission.js +32 -0
- package/server/fleet/native-host.js +817 -0
- package/server/fleet/placement.js +132 -0
- package/server/fleet/requester.js +346 -0
- package/server/fleet/service.js +311 -0
- package/server/fleet/terminal-policy.js +83 -0
- package/server/fleet/text-api.js +94 -0
- package/server/hashing.js +35 -0
- package/server/http-request-owner.js +49 -0
- package/server/messages.js +57 -0
- package/server/native-canonical-epochs.js +90 -0
- package/server/native-change-log.js +40 -0
- package/server/native-earning.js +203 -0
- package/server/native-generation-journal.js +46 -0
- package/server/native-model-config.js +57 -0
- package/server/native-model-files.js +382 -0
- package/server/native-model-origin.js +54 -0
- package/server/native-model-origins.json +12 -0
- package/server/native-node.js +85 -0
- package/server/native-owner/controls.js +162 -0
- package/server/native-owner/monitor.js +251 -0
- package/server/native-owner/policy.js +273 -0
- package/server/native-owner/rate.js +92 -0
- package/server/native-participant.js +866 -0
- package/server/native-readiness-log.js +30 -0
- package/server/native-release-storage.js +64 -0
- package/server/native-shader-cache.js +369 -0
- package/server/native-supply-budget.js +66 -0
- package/server/native-whole-earning.js +67 -0
- package/server/native-whole-model-fill.js +182 -0
- package/server/native-whole-model.js +130 -0
- package/server/public-web-worker.js +66 -0
- package/server/responses.js +92 -0
- package/server/search/errors.js +47 -0
- package/server/search/page-fetch.js +185 -0
- package/server/text-api.js +83 -0
- package/server/text-recovery.js +45 -0
- package/server/text-request-options.js +23 -0
- package/server/text-requester.js +231 -0
- package/server/token-auth.js +9 -0
- package/server/whole-model-requester.js +99 -0
- package/shared/config/chat-models.json +4685 -0
- package/shared/config/doppler-execution-contracts.js +32 -0
- package/shared/config/doppler-execution-contracts.json +43 -0
- package/shared/config/doppler-generation-contract.js +223 -0
- package/shared/config/doppler-local-models.js +79 -0
- package/shared/config/doppler-package.json +7 -0
- package/shared/config/model-manifests/a0dc69e917133a82780823d17fc771c0f6b882d7c86c571deeaef45be2ff108b.json +6434 -0
- package/shared/config/model-manifests/e985e3cdad60d0cb57d84b436161e66ab5b3f6589a168de6f00b4610e5f1fb59.json +16534 -0
- package/shared/config/model-manifests/fa6eb7bbc3ba63571f8cede780b877d9272bde948697af93dd3d33837cb46137.json +7175 -0
- package/shared/config/model-pieces/qwen-0-8b-pieces.json +1506 -0
- package/shared/config/model-pieces/qwen-2b-pieces.json +4800 -0
- package/shared/config/model-pieces/qwen35b-q4_0-pieces.json +28880 -0
- package/shared/config/reploid-library.json +49 -0
- package/shared/config/swarm-bootstrap.json +21 -0
- package/shared/infrastructure/doppler-runtime-service.js +261 -0
- package/shared/pool/executable-pack.js +87 -0
- package/shared/pool/pack-operation-adapters.js +124 -0
- package/shared/pool/pack-operation-policy.js +75 -0
- package/shared/pool/pool-config.json +2280 -0
- package/shared/vendor/reploid/adapters/doppler-session.js +372 -0
- package/shared/vendor/reploid/artifacts/binary.js +25 -0
- package/shared/vendor/reploid/artifacts/canonical-json.js +23 -0
- package/shared/vendor/reploid/artifacts/delivery-attestation.js +64 -0
- package/shared/vendor/reploid/artifacts/identity.js +91 -0
- package/shared/vendor/reploid/artifacts/manifest-identity.js +17 -0
- package/shared/vendor/reploid/artifacts/participant-proofs.js +159 -0
- package/shared/vendor/reploid/artifacts/signatures.js +94 -0
- package/shared/vendor/reploid/config/defaults.json +110 -0
- package/shared/vendor/reploid/config/index.js +209 -0
- package/shared/vendor/reploid/config/request-overrides.json +9 -0
- package/shared/vendor/reploid/hashing.js +30 -0
- package/shared/vendor/reploid/mesh/generation-context-cache.js +97 -0
- package/shared/vendor/reploid/mesh/partitions/partition-attempt-evidence.js +282 -0
- package/shared/vendor/reploid/mesh/partitions/partition-contract.js +192 -0
- package/shared/vendor/reploid/mesh/partitions/partition-receiver-policy.json +7 -0
- package/shared/vendor/reploid/mesh/partitions/partition-route-ranking.js +268 -0
- package/shared/vendor/reploid/mesh/preparation-progress.js +46 -0
- package/shared/vendor/reploid/mesh/provider-hardware.js +11 -0
- package/shared/vendor/reploid/rules/routing.rules.json +24 -0
- package/shared/vendor/reploid/transport/partition-errors.json +16 -0
- package/README.md +0 -3
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Text extraction from fetched pages, shared by the server and device-side extractors so the same
|
|
3
|
+
* bytes yield the same document text and hash on every route. HTML is reduced to readable paragraphs without a DOM:
|
|
4
|
+
* scripts, styles and page chrome are dropped, the main content region is preferred, and block elements
|
|
5
|
+
* become paragraph breaks. Plain text is decoded as-is. PDF and other types are refused before this point.
|
|
6
|
+
* Environment-neutral: no Node or DOM APIs.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// A tag whose quoted attributes may contain '>'.
|
|
10
|
+
const ATTRS = String.raw`(?:[^>"']|"[^"]*"|'[^']*')*`;
|
|
11
|
+
const TAG = new RegExp(String.raw`<(\/?)([a-zA-Z][a-zA-Z0-9:-]*)(${ATTRS})>`, 'g');
|
|
12
|
+
const RAW_TEXT = ['script', 'style', 'noscript', 'template', 'svg', 'iframe', 'object', 'canvas', 'math'];
|
|
13
|
+
const CHROME = ['nav', 'header', 'footer', 'aside', 'select', 'button', 'textarea', 'dialog'];
|
|
14
|
+
const BLOCK = new Set(['p', 'div', 'section', 'article', 'main', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'dl', 'table',
|
|
15
|
+
'thead', 'tbody', 'tfoot', 'blockquote', 'pre', 'figure', 'figcaption', 'address', 'hr', 'details', 'summary', 'fieldset',
|
|
16
|
+
'legend', 'caption', 'center', 'body', 'html', 'form']);
|
|
17
|
+
const LINE = new Set(['li', 'dt', 'dd', 'br', 'tr', 'option']);
|
|
18
|
+
const CELL = new Set(['td', 'th']);
|
|
19
|
+
|
|
20
|
+
const NAMED = {
|
|
21
|
+
amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', ensp: ' ', emsp: ' ', thinsp: ' ', zwnj: '', zwj: '', shy: '',
|
|
22
|
+
ndash: '–', mdash: '—', lsquo: '‘', rsquo: '’', sbquo: '‚', ldquo: '“', rdquo: '”', bdquo: '„', hellip: '…', middot: '·',
|
|
23
|
+
bull: '•', laquo: '«', raquo: '»', lsaquo: '‹', rsaquo: '›', prime: '′', Prime: '″', dagger: '†', Dagger: '‡', permil: '‰',
|
|
24
|
+
copy: '©', reg: '®', trade: '™', deg: '°', plusmn: '±', times: '×', divide: '÷', minus: '−', frac12: '½', frac14: '¼',
|
|
25
|
+
frac34: '¾', sup1: '¹', sup2: '²', sup3: '³', micro: 'µ', para: '¶', sect: '§', cent: '¢', pound: '£', euro: '€', yen: '¥',
|
|
26
|
+
curren: '¤', iexcl: '¡', iquest: '¿', ordf: 'ª', ordm: 'º', not: '¬', macr: '¯', acute: '´', cedil: '¸', uml: '¨', brvbar: '¦',
|
|
27
|
+
le: '≤', ge: '≥', ne: '≠', asymp: '≈', infin: '∞', larr: '←', rarr: '→', uarr: '↑', darr: '↓', harr: '\u2194', szlig: 'ß',
|
|
28
|
+
alpha: 'α', beta: 'β', gamma: 'γ', delta: 'δ', epsilon: 'ε', lambda: 'λ', mu: 'μ', pi: 'π', sigma: 'σ', tau: 'τ', phi: 'φ',
|
|
29
|
+
omega: 'ω', Delta: 'Δ', Sigma: 'Σ', Omega: 'Ω'
|
|
30
|
+
};
|
|
31
|
+
// Latin-1 letters by name: agrave, Agrave, eacute, ...
|
|
32
|
+
const LETTERS = { a: 'àáâãäå', e: 'èéêë', i: 'ìíîï', o: 'òóôõöø', u: 'ùúûü', y: 'ýÿ', n: 'ñ', c: 'ç' };
|
|
33
|
+
const MARKS = { a: ['grave', 'acute', 'circ', 'tilde', 'uml', 'ring'], e: ['grave', 'acute', 'circ', 'uml'],
|
|
34
|
+
i: ['grave', 'acute', 'circ', 'uml'], o: ['grave', 'acute', 'circ', 'tilde', 'uml', 'slash'], u: ['grave', 'acute', 'circ', 'uml'],
|
|
35
|
+
y: ['acute', 'uml'], n: ['tilde'], c: ['cedil'] };
|
|
36
|
+
for (const [base, letters] of Object.entries(LETTERS)) {
|
|
37
|
+
[...letters].forEach((letter, index) => {
|
|
38
|
+
NAMED[base + MARKS[base][index]] = letter;
|
|
39
|
+
const upper = letter.toUpperCase();
|
|
40
|
+
if (upper !== letter && !(base === 'y' && index === 1)) NAMED[base.toUpperCase() + MARKS[base][index]] = upper;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
Object.assign(NAMED, { aelig: 'æ', AElig: 'Æ', eth: 'ð', ETH: 'Ð', thorn: 'þ', THORN: 'Þ', oelig: 'œ', OElig: 'Œ', Yuml: 'Ÿ' });
|
|
44
|
+
// Legacy names browsers accept without a semicolon.
|
|
45
|
+
const BARE = new Set(['amp', 'lt', 'gt', 'quot', 'nbsp', 'copy', 'reg']);
|
|
46
|
+
|
|
47
|
+
const codePoint = value => (Number.isSafeInteger(value) && value > 0 && value <= 0x10ffff && !(value >= 0xd800 && value <= 0xdfff))
|
|
48
|
+
? String.fromCodePoint(value) : '�';
|
|
49
|
+
|
|
50
|
+
/** Decodes numeric and common named character references; unknown names stay as written. */
|
|
51
|
+
export function decodeEntities(text) {
|
|
52
|
+
return String(text).replace(/&(?:#(\d{1,8})|#[xX]([0-9a-fA-F]{1,7})|([a-zA-Z][a-zA-Z0-9]{1,31}))(;?)/g, (match, decimal, hex, name, semi) => {
|
|
53
|
+
if (decimal) return codePoint(Number(decimal));
|
|
54
|
+
if (hex) return codePoint(parseInt(hex, 16));
|
|
55
|
+
if (!Object.hasOwn(NAMED, name) || (!semi && !BARE.has(name))) return match;
|
|
56
|
+
return NAMED[name];
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Removes tags and decodes entities from a short HTML fragment, such as a search result title or snippet. */
|
|
61
|
+
export function htmlFragmentText(fragment) {
|
|
62
|
+
return decodeEntities(String(fragment ?? '').replace(/<!--[\s\S]*?(?:-->|$)/g, ' ')
|
|
63
|
+
.replace(TAG, (match, closing, name) => BLOCK.has(name.toLowerCase()) || LINE.has(name.toLowerCase()) ? ' ' : ''))
|
|
64
|
+
.replace(/[\s ]+/g, ' ').trim();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const anyTag = name => new RegExp(String.raw`<(\/?)${name}(?=[\s/>])${ATTRS}>`, 'gi');
|
|
68
|
+
|
|
69
|
+
/** Balanced elements named `name`: [{ start, innerStart, innerEnd, end }]. Unclosed elements run to the end. */
|
|
70
|
+
function elements(html, name) {
|
|
71
|
+
const found = [], pattern = anyTag(name);
|
|
72
|
+
let depth = 0, current = null, match;
|
|
73
|
+
while ((match = pattern.exec(html))) {
|
|
74
|
+
if (!match[1]) {
|
|
75
|
+
if (match[0].endsWith('/>')) continue;
|
|
76
|
+
if (depth === 0) current = { start: match.index, innerStart: match.index + match[0].length };
|
|
77
|
+
depth++;
|
|
78
|
+
} else if (depth > 0 && --depth === 0) {
|
|
79
|
+
found.push({ ...current, innerEnd: match.index, end: match.index + match[0].length });
|
|
80
|
+
current = null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (current) found.push({ ...current, innerEnd: html.length, end: html.length, unclosed: true });
|
|
84
|
+
return found;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Removes every `name` element with its content; an unclosed one loses only its opening tag. */
|
|
88
|
+
function removeElements(html, name) {
|
|
89
|
+
let output = '', cursor = 0;
|
|
90
|
+
for (const element of elements(html, name)) {
|
|
91
|
+
if (element.start < cursor) continue;
|
|
92
|
+
output += html.slice(cursor, element.start) + ' ';
|
|
93
|
+
cursor = element.unclosed ? element.innerStart : element.end;
|
|
94
|
+
}
|
|
95
|
+
return output + html.slice(cursor);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function removeRawText(html) {
|
|
99
|
+
// Raw-text elements cannot nest; an unclosed one hides everything after it, as browsers do.
|
|
100
|
+
const pattern = new RegExp(String.raw`<(${RAW_TEXT.join('|')})(?=[\s/>])${ATTRS}>[\s\S]*?(?:<\/\1\s*>|$)`, 'gi');
|
|
101
|
+
return html.replace(pattern, ' ');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function region(html) {
|
|
105
|
+
const main = elements(html, 'main')[0];
|
|
106
|
+
if (main) return html.slice(main.innerStart, main.innerEnd);
|
|
107
|
+
const articles = elements(html, 'article');
|
|
108
|
+
if (articles.length) {
|
|
109
|
+
const largest = articles.reduce((best, item) => item.innerEnd - item.innerStart > best.innerEnd - best.innerStart ? item : best);
|
|
110
|
+
return html.slice(largest.innerStart, largest.innerEnd);
|
|
111
|
+
}
|
|
112
|
+
const body = elements(html, 'body')[0];
|
|
113
|
+
return body ? html.slice(body.innerStart, body.innerEnd) : html;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function render(html) {
|
|
117
|
+
const marked = html.replace(/[\s ]+/g, ' ').replace(TAG, (match, closing, name) => {
|
|
118
|
+
const tag = name.toLowerCase();
|
|
119
|
+
if (BLOCK.has(tag)) return '\n\n';
|
|
120
|
+
if (LINE.has(tag)) return closing ? '' : '\n';
|
|
121
|
+
if (CELL.has(tag)) return ' ';
|
|
122
|
+
return '';
|
|
123
|
+
});
|
|
124
|
+
return decodeEntities(marked).replace(/ /g, ' ').split('\n').map(line => line.replace(/[ \t\f\v]+/g, ' ').trim())
|
|
125
|
+
.join('\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function htmlText(source) {
|
|
129
|
+
let html = source.replace(/<!--[\s\S]*?(?:-->|$)/g, ' ').replace(/<!\[CDATA\[[\s\S]*?(?:\]\]>|$)/g, ' ')
|
|
130
|
+
.replace(/<![^>]*>|<\?[^>]*>/g, ' ');
|
|
131
|
+
html = removeRawText(html);
|
|
132
|
+
const titleMatch = html.match(new RegExp(String.raw`<title(?=[\s>])${ATTRS}>([\s\S]*?)<\/title\s*>`, 'i'));
|
|
133
|
+
const title = titleMatch ? htmlFragmentText(titleMatch[1]) : '';
|
|
134
|
+
for (const head of elements(html, 'head').filter(item => !item.unclosed).reverse()) html = html.slice(0, head.start) + ' ' + html.slice(head.end);
|
|
135
|
+
const chosen = region(html);
|
|
136
|
+
// Page chrome is dropped inside the chosen region; if that leaves nothing, the region is kept whole.
|
|
137
|
+
const cleaned = render(CHROME.reduce(removeElements, chosen));
|
|
138
|
+
return { text: cleaned || render(chosen), title };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const CHARSET = /charset\s*=\s*["']?([\w.:-]+)/i;
|
|
142
|
+
/** The charset named by the header, else a <meta> declaration in the first 2 KB, else a byte-order mark, else UTF-8. */
|
|
143
|
+
export function detectCharset(body, contentType = '', charset = null) {
|
|
144
|
+
if (charset) return charset;
|
|
145
|
+
const declared = String(contentType).match(CHARSET)?.[1];
|
|
146
|
+
if (declared) return declared;
|
|
147
|
+
if (body[0] === 0xef && body[1] === 0xbb && body[2] === 0xbf) return 'utf-8';
|
|
148
|
+
if (body[0] === 0xff && body[1] === 0xfe) return 'utf-16le';
|
|
149
|
+
if (body[0] === 0xfe && body[1] === 0xff) return 'utf-16be';
|
|
150
|
+
const head = Array.from(body.subarray(0, 2048), byte => String.fromCharCode(byte)).join('');
|
|
151
|
+
for (const meta of head.matchAll(new RegExp(String.raw`<meta(?=[\s/])${ATTRS}>`, 'gi'))) {
|
|
152
|
+
const direct = meta[0].match(/\bcharset\s*=\s*["']?([\w.:-]+)/i)?.[1];
|
|
153
|
+
if (direct) return direct;
|
|
154
|
+
}
|
|
155
|
+
return 'utf-8';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// windows-1252 bytes 0x80-0x9F. Some Node releases decode this label as ISO-8859-1, so it is mapped here.
|
|
159
|
+
const CP1252 = '\u20ac\u0081\u201a\u0192\u201e\u2026\u2020\u2021\u02c6\u2030\u0160\u2039\u0152\u008d\u017d\u008f'
|
|
160
|
+
+ '\u0090\u2018\u2019\u201c\u201d\u2022\u2013\u2014\u02dc\u2122\u0161\u203a\u0153\u009d\u017e\u0178';
|
|
161
|
+
|
|
162
|
+
function decode(body, label) {
|
|
163
|
+
let decoder;
|
|
164
|
+
try { decoder = new TextDecoder(label); } catch { decoder = new TextDecoder('utf-8'); }
|
|
165
|
+
if (decoder.encoding !== 'windows-1252') return decoder.decode(body);
|
|
166
|
+
let text = '';
|
|
167
|
+
for (const byte of body) text += byte >= 0x80 && byte <= 0x9f ? CP1252[byte - 0x80] : String.fromCharCode(byte);
|
|
168
|
+
return text;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** { text, title, characters } for an HTML or plain-text body. */
|
|
172
|
+
export function extractDocumentText({ body, contentType = '', charset = null }) {
|
|
173
|
+
const bytes = body instanceof Uint8Array ? body : new TextEncoder().encode(String(body ?? ''));
|
|
174
|
+
const source = decode(bytes, detectCharset(bytes, contentType, charset)).replace(/^/, '');
|
|
175
|
+
const mime = String(contentType).split(';')[0].trim().toLowerCase();
|
|
176
|
+
if (mime === 'text/plain') {
|
|
177
|
+
const text = source.replace(/\r\n?/g, '\n').replace(/[ \t]+$/gm, '').replace(/\n{3,}/g, '\n\n').trim();
|
|
178
|
+
return { text, title: '', characters: text.length };
|
|
179
|
+
}
|
|
180
|
+
const { text, title } = htmlText(source);
|
|
181
|
+
return { text, title, characters: text.length };
|
|
182
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Chooses the passage of an extracted document that is sent as a numbered source, for the server and
|
|
3
|
+
* device-side extractors alike. The result is always an exact slice of the document, text === document.slice(start, end),
|
|
4
|
+
* so its offsets and hashes can be checked against the document later. Relevance is lexical: query content words per paragraph.
|
|
5
|
+
*/
|
|
6
|
+
import { contentWords, wordTokens } from './text.js';
|
|
7
|
+
|
|
8
|
+
const PARAGRAPH = /\S[\s\S]*?(?=\n[ \t]*\n|$)/g;
|
|
9
|
+
|
|
10
|
+
function trimmedEnd(text, start, end) {
|
|
11
|
+
while (end > start && /\s/.test(text[end - 1])) end--;
|
|
12
|
+
// Never end between the halves of a surrogate pair.
|
|
13
|
+
if (end > start && /[\ud800-\udbff]/.test(text[end - 1])) end--;
|
|
14
|
+
return end;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** { text, start, end }: the best-matching paragraph (earliest on ties), extended with following paragraphs within maxCharacters. */
|
|
18
|
+
export function selectPassage({ text, query = '', maxCharacters }) {
|
|
19
|
+
if (!Number.isSafeInteger(maxCharacters) || maxCharacters < 1) throw new Error('maxCharacters must be a positive integer');
|
|
20
|
+
const document = String(text ?? '');
|
|
21
|
+
const paragraphs = [...document.matchAll(PARAGRAPH)].map(match => ({ start: match.index,
|
|
22
|
+
end: trimmedEnd(document, match.index, match.index + match[0].length) })).filter(item => item.end > item.start);
|
|
23
|
+
if (!paragraphs.length) return { text: '', start: 0, end: 0 };
|
|
24
|
+
const terms = contentWords(query);
|
|
25
|
+
const score = item => { const words = new Set(wordTokens(document.slice(item.start, item.end))); return terms.filter(term => words.has(term)).length; };
|
|
26
|
+
let best = 0, bestScore = -1;
|
|
27
|
+
paragraphs.forEach((item, index) => { const value = score(item); if (value > bestScore) { best = index; bestScore = value; } });
|
|
28
|
+
const start = paragraphs[best].start;
|
|
29
|
+
let end = paragraphs[best].end;
|
|
30
|
+
if (end - start > maxCharacters) {
|
|
31
|
+
const cut = start + maxCharacters, window = document.slice(start, cut), space = window.search(/\s\S*$/);
|
|
32
|
+
end = trimmedEnd(document, start, space > maxCharacters * 0.8 ? start + space : cut);
|
|
33
|
+
} else {
|
|
34
|
+
for (const next of paragraphs.slice(best + 1)) { if (next.end - start > maxCharacters) break; end = next.end; }
|
|
35
|
+
}
|
|
36
|
+
return { text: document.slice(start, end), start, end };
|
|
37
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "reploid.retrieval-policy",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"defaults": { "maxResults": 5, "maxSourceCharacters": 1200, "maxContextCharacters": 6000, "timeoutMs": 10000 },
|
|
5
|
+
"maxima": { "maxResults": 10, "maxSourceCharacters": 4000, "maxContextCharacters": 12000, "timeoutMs": 30000 },
|
|
6
|
+
"maxQueryCharacters": 512,
|
|
7
|
+
"maxSupportChecks": 256,
|
|
8
|
+
"context": {
|
|
9
|
+
"schema": "reploid.retrieval-context/v1",
|
|
10
|
+
"instructions": "Answer using only the numbered sources below. Cite each factual sentence with the number of every source that states it, for example [1] or [1][2]. If the sources do not answer the question, say that the retrieved sources do not answer it. Do not name or cite any other source.",
|
|
11
|
+
"sourcesHeading": "Sources:",
|
|
12
|
+
"questionLabel": "Question:"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** One retrieval owner per workspace: host-supplied search, extraction and support ports under library policy. */
|
|
2
|
+
import defaults from './policy.json' with { type: 'json' };
|
|
3
|
+
import { verifyCitations } from './citations.js';
|
|
4
|
+
import { mapClaimSpans } from './claim-support.js';
|
|
5
|
+
import { normalizeRetrievalRequest, pendingRetrievalRecord, retrieveSources, validateRetrievalPolicy,
|
|
6
|
+
validateRetrievalPorts, withSourceContext } from './sources.js';
|
|
7
|
+
|
|
8
|
+
export function createRetrieval({ provider, extractor = null, supportChecker = null, policy = defaults, now = Date.now }) {
|
|
9
|
+
validateRetrievalPorts({ provider, extractor, supportChecker });
|
|
10
|
+
const rules = validateRetrievalPolicy(policy);
|
|
11
|
+
return Object.freeze({
|
|
12
|
+
/** Validates a request's opt-in before anything is recorded. */
|
|
13
|
+
request: (input, content) => normalizeRetrievalRequest(input, content, rules),
|
|
14
|
+
pending: (request, startedAt) => pendingRetrievalRecord({ request, provider, extractor, policy: rules, startedAt }),
|
|
15
|
+
/** `answer`: an opaque key for the answer, so per-answer costs can be summed in provider usage logs. */
|
|
16
|
+
retrieve: (record, signal, { answer = null, onProgress } = {}) => retrieveSources({ record, provider, extractor, signal, now, policy: rules, answer, onProgress }),
|
|
17
|
+
dispatch: (request, record) => withSourceContext(request, record, rules),
|
|
18
|
+
// Citation flags plus the claim-to-span mapping and deterministic validity; entailment awaits an independent reviewer.
|
|
19
|
+
// withVerification (costs.js) attaches the result to its record together with the verification cost entry.
|
|
20
|
+
verify: async (answer, record, signal) => ({ ...await verifyCitations({ answer, record, checker: supportChecker, signal,
|
|
21
|
+
maxSupportChecks: rules.maxSupportChecks }), claimSupport: await mapClaimSpans({ answer, record, policy: rules }) })
|
|
22
|
+
});
|
|
23
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/** A blinded human review queue for citation support. Each entry keeps the packet reviewers see apart from the
|
|
2
|
+
* sealed generator identity, which only checks independence and is never shown. Two human reviewers judge every
|
|
3
|
+
* claim; claims they disagree on are listed for a third reviewer, whose review decides them by majority. */
|
|
4
|
+
import { SUPPORT_JUDGMENTS_SCHEMA, applySupportJudgments, assertIndependentReviewer, supportPacketHash,
|
|
5
|
+
validateGenerator, validateSupportJudgments } from './support-review.js';
|
|
6
|
+
|
|
7
|
+
export const REVIEW_QUEUE_SCHEMA = 'reploid.claim-support-review-queue/v1';
|
|
8
|
+
const assert = (ok, message) => { if (!ok) throw new Error(message); };
|
|
9
|
+
const copy = value => structuredClone(value);
|
|
10
|
+
const memory = () => { let saved = null; return { load: () => saved, save: value => { saved = copy(value); } }; };
|
|
11
|
+
|
|
12
|
+
/** `store` persists the queue: `{ load(): state | null, save(state) }`. Required reviews default to two. */
|
|
13
|
+
export function createHumanReviewQueue({ store = memory(), now = Date.now, requiredReviews = 2 } = {}) {
|
|
14
|
+
assert(typeof store?.load === 'function' && typeof store?.save === 'function', 'Review queue store required');
|
|
15
|
+
assert(Number.isSafeInteger(requiredReviews) && requiredReviews > 0, 'Invalid required review count');
|
|
16
|
+
const loaded = store.load();
|
|
17
|
+
assert(!loaded || loaded.schema === REVIEW_QUEUE_SCHEMA && Array.isArray(loaded.entries), 'Invalid stored review queue');
|
|
18
|
+
const entries = copy(loaded?.entries || []);
|
|
19
|
+
const save = () => store.save({ schema: REVIEW_QUEUE_SCHEMA, entries });
|
|
20
|
+
const entry = id => { const found = entries.find(item => item.packet.id === id); assert(found, 'Review packet not found'); return found; };
|
|
21
|
+
const disagreements = async item => {
|
|
22
|
+
const files = [];
|
|
23
|
+
for (const file of item.judgments) files.push(await validateSupportJudgments({ packet: item.packet, judgments: file, generator: item.generator }));
|
|
24
|
+
return item.packet.claims.filter(claim => new Set(files.flatMap(decided => decided.has(claim.id) ? [decided.get(claim.id).verdict] : [])).size > 1)
|
|
25
|
+
.map(claim => claim.id);
|
|
26
|
+
};
|
|
27
|
+
const queue = Object.freeze({
|
|
28
|
+
id: 'reploid.human-support-review', version: '1', kind: 'human-queue',
|
|
29
|
+
/** Reviewer interface: queues the packet and returns null, because human judgments arrive later through submit(). */
|
|
30
|
+
async review(packet, { generator, claimSupport }) {
|
|
31
|
+
validateGenerator(generator);
|
|
32
|
+
assert(!entries.some(item => item.packet.id === packet.id), 'Review packet already queued');
|
|
33
|
+
entries.push({ packet: copy(packet), sha256: await supportPacketHash(packet), generator: validateGenerator(generator),
|
|
34
|
+
claimSupport: copy(claimSupport), queuedAt: now(), judgments: [] });
|
|
35
|
+
save();
|
|
36
|
+
return null;
|
|
37
|
+
},
|
|
38
|
+
/** Packets still needing a review, without their sealed generator identity. */
|
|
39
|
+
async pending() {
|
|
40
|
+
const waiting = [];
|
|
41
|
+
for (const item of entries) {
|
|
42
|
+
const third = (await disagreements(item)).length > 0 && item.judgments.length === requiredReviews;
|
|
43
|
+
if (item.judgments.length < requiredReviews || third) waiting.push({ packet: copy(item.packet), sha256: item.sha256,
|
|
44
|
+
reviews: item.judgments.length, ...(third ? { thirdReviewClaims: await disagreements(item) } : {}) });
|
|
45
|
+
}
|
|
46
|
+
return waiting;
|
|
47
|
+
},
|
|
48
|
+
packet: id => copy(entry(id).packet),
|
|
49
|
+
/** Accepts one human reviewer's judgment file; the generator never reviews, and a reviewer submits once per packet. */
|
|
50
|
+
async submit(judgments) {
|
|
51
|
+
assert(judgments?.schema === SUPPORT_JUDGMENTS_SCHEMA && judgments.reviewer?.kind === 'human', 'The queue accepts human judgment files');
|
|
52
|
+
const item = entry(judgments.packet?.id);
|
|
53
|
+
assertIndependentReviewer(judgments.reviewer, item.generator);
|
|
54
|
+
assert(!item.judgments.some(file => file.reviewer.id === judgments.reviewer.id), 'This reviewer already judged the packet');
|
|
55
|
+
const limit = requiredReviews + ((await disagreements(item)).length ? 1 : 0);
|
|
56
|
+
assert(item.judgments.length < limit, 'This packet needs no further review');
|
|
57
|
+
await validateSupportJudgments({ packet: item.packet, judgments, generator: item.generator });
|
|
58
|
+
item.judgments.push({ ...copy(judgments), receivedAt: now() });
|
|
59
|
+
save();
|
|
60
|
+
return queue.status(item.packet.id);
|
|
61
|
+
},
|
|
62
|
+
async status(id) {
|
|
63
|
+
const item = entry(id), disputed = await disagreements(item);
|
|
64
|
+
return { packetId: id, reviews: item.judgments.length, requiredReviews, disagreements: disputed,
|
|
65
|
+
needsThirdReview: disputed.length > 0 && item.judgments.length === requiredReviews,
|
|
66
|
+
complete: item.judgments.length >= requiredReviews && (!disputed.length || item.judgments.length > requiredReviews) };
|
|
67
|
+
},
|
|
68
|
+
/** The per-claim result from every judgment received so far. */
|
|
69
|
+
result(id) {
|
|
70
|
+
const item = entry(id);
|
|
71
|
+
return applySupportJudgments({ packet: item.packet, claimSupport: item.claimSupport,
|
|
72
|
+
judgments: item.judgments.map(({ receivedAt, ...file }) => file), generator: item.generator });
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
return queue;
|
|
76
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/** Retrieval for one request: provider search, optional passage extraction, bounded numbered context
|
|
2
|
+
* and the provenance record kept with the conversation. Providers and extractors are host ports. */
|
|
3
|
+
import defaults from './policy.json' with { type: 'json' };
|
|
4
|
+
import { SHA256, sha256Text } from './text.js';
|
|
5
|
+
import { COST_BEARERS, retrievalCostEntry, retrievalCostRecord, unknownRetrievalCosts } from './costs.js';
|
|
6
|
+
// Provider and extractor calls are awaited only until the deadline passes or the attempt stops;
|
|
7
|
+
// providers that ignore their signal cannot extend it.
|
|
8
|
+
import { abortable } from '../cancellation.js';
|
|
9
|
+
|
|
10
|
+
export const RETRIEVAL_RECORD_SCHEMA = 'reploid.retrieval-record/v1';
|
|
11
|
+
export const RETRIEVAL_OUTCOMES = Object.freeze(['pending', 'retrieved', 'empty', 'failed', 'cancelled', 'interrupted']);
|
|
12
|
+
const LIMIT_NAMES = ['maxResults', 'maxSourceCharacters', 'maxContextCharacters', 'timeoutMs'];
|
|
13
|
+
const FAILURES = Object.freeze({
|
|
14
|
+
'no-results': 'the search returned no results',
|
|
15
|
+
timeout: 'retrieval exceeded its time limit',
|
|
16
|
+
'provider-error': 'the search provider failed',
|
|
17
|
+
'invalid-provider-result': 'the search provider returned an invalid result',
|
|
18
|
+
'extraction-failed': 'no source text could be extracted',
|
|
19
|
+
'context-limit': 'no source fits the context limit',
|
|
20
|
+
cancelled: 'the request was stopped during retrieval',
|
|
21
|
+
interrupted: 'the connection was interrupted during retrieval'
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
class RetrievalError extends Error {
|
|
25
|
+
constructor(code, detail = null) { super(FAILURES[code]); this.code = code; this.detail = detail; }
|
|
26
|
+
}
|
|
27
|
+
const assert = (ok, message) => { if (!ok) throw new Error(message); };
|
|
28
|
+
/** Where a provider's retrieval runs: an operator's server route, or this device. Recorded with the provider. */
|
|
29
|
+
export const RETRIEVAL_SUPPLIES = Object.freeze(['server', 'device', 'contributor']);
|
|
30
|
+
const copy = value => structuredClone(value);
|
|
31
|
+
const port = value => value && typeof value.id === 'string' && value.id && typeof value.version === 'string' && value.version;
|
|
32
|
+
const line = value => String(value).replace(/\s+/g, ' ').trim();
|
|
33
|
+
|
|
34
|
+
export function validateRetrievalPolicy(policy) {
|
|
35
|
+
assert(policy && typeof policy.id === 'string' && typeof policy.version === 'string'
|
|
36
|
+
&& LIMIT_NAMES.every(name => Number.isSafeInteger(policy.maxima?.[name]) && policy.maxima[name] > 0
|
|
37
|
+
&& Number.isSafeInteger(policy.defaults?.[name]) && policy.defaults[name] > 0 && policy.defaults[name] <= policy.maxima[name])
|
|
38
|
+
&& policy.maxima.timeoutMs <= 2147483647 && Number.isSafeInteger(policy.maxQueryCharacters) && policy.maxQueryCharacters > 0
|
|
39
|
+
&& Number.isSafeInteger(policy.maxSupportChecks) && policy.maxSupportChecks >= 0
|
|
40
|
+
&& ['schema', 'instructions', 'sourcesHeading', 'questionLabel'].every(name => typeof policy.context?.[name] === 'string'),
|
|
41
|
+
'Invalid retrieval policy');
|
|
42
|
+
return copy(policy);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Requested limits may only narrow the policy maxima; omitted limits use the policy defaults. */
|
|
46
|
+
export function normalizeRetrievalLimits(requested = {}, policy = defaults) {
|
|
47
|
+
assert(requested && typeof requested === 'object' && !Array.isArray(requested)
|
|
48
|
+
&& Object.keys(requested).every(name => LIMIT_NAMES.includes(name)), 'Unknown retrieval limit');
|
|
49
|
+
return Object.fromEntries(LIMIT_NAMES.map(name => {
|
|
50
|
+
const value = requested[name] ?? policy.defaults[name];
|
|
51
|
+
assert(Number.isSafeInteger(value) && value > 0 && value <= policy.maxima[name], `Retrieval ${name} is outside the policy limit`);
|
|
52
|
+
return [name, value];
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The request's opt-in: the query sent to the provider and its limits. Defaults to the message text. */
|
|
57
|
+
export function normalizeRetrievalRequest(input, content, policy = defaults) {
|
|
58
|
+
assert(input && typeof input === 'object' && !Array.isArray(input)
|
|
59
|
+
&& Object.keys(input).every(name => ['query', 'limits'].includes(name)), 'Invalid retrieval request');
|
|
60
|
+
assert(input.query === undefined || typeof input.query === 'string', 'Invalid search query');
|
|
61
|
+
const query = (input.query ?? content).trim();
|
|
62
|
+
assert(query, 'Search query is empty');
|
|
63
|
+
assert(query.length <= policy.maxQueryCharacters, 'Search query exceeds the retrieval limit');
|
|
64
|
+
return { query, limits: normalizeRetrievalLimits(input.limits, policy) };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function pendingRetrievalRecord({ request, provider, extractor = null, policy = defaults, startedAt }) {
|
|
68
|
+
return { schema: RETRIEVAL_RECORD_SCHEMA, policy: { id: policy.id, version: policy.version },
|
|
69
|
+
provider: { id: provider.id, version: provider.version, ...(provider.supply === undefined ? {} : { supply: provider.supply }) },
|
|
70
|
+
extractor: extractor ? { id: extractor.id, version: extractor.version } : null,
|
|
71
|
+
// Every source below came from the provider this request explicitly opted into.
|
|
72
|
+
sourceUse: 'requester-opt-in', query: request.query, limits: copy(request.limits),
|
|
73
|
+
outcome: 'pending', error: null, startedAt, finishedAt: null, providerResultCount: null,
|
|
74
|
+
sources: [], omitted: [], context: null, usage: null, verification: null, costs: null };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function retrievalFailureMessage(record) {
|
|
78
|
+
return `Could not retrieve sources: ${FAILURES[record?.error?.code] || 'retrieval did not complete'}.`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Marks a record left pending by a reload. Nothing it would have retrieved was sent; what it spent was not observed. */
|
|
82
|
+
export function interruptRetrievalRecord(record, finishedAt) {
|
|
83
|
+
if (record?.outcome !== 'pending') return record;
|
|
84
|
+
return { ...record, outcome: 'interrupted', error: { code: 'interrupted', message: FAILURES.interrupted }, finishedAt,
|
|
85
|
+
costs: retrievalCostRecord({ retrieval: unknownRetrievalCosts() }) };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const webUrl = value => {
|
|
89
|
+
try { return typeof value === 'string' && value.length <= 2048 && ['http:', 'https:'].includes(new URL(value).protocol); }
|
|
90
|
+
catch { return false; }
|
|
91
|
+
};
|
|
92
|
+
function validResult(value) {
|
|
93
|
+
return value && typeof value === 'object' && typeof value.sourceId === 'string' && /^[\x21-\x7e]{1,256}$/.test(value.sourceId)
|
|
94
|
+
&& webUrl(value.url) && typeof value.title === 'string' && line(value.title) && value.title.length <= 1000
|
|
95
|
+
&& typeof value.excerpt === 'string' && value.excerpt.trim()
|
|
96
|
+
&& Number.isFinite(value.retrievedAt) && value.retrievedAt > 0 && typeof value.contentHash === 'string' && SHA256.test(value.contentHash);
|
|
97
|
+
}
|
|
98
|
+
const count = value => Number.isSafeInteger(value) && value >= 0;
|
|
99
|
+
/** Where a fetched passage sits in the extracted document text the extractor hashed. */
|
|
100
|
+
const validDocument = (value, text) => value === undefined || value && typeof value === 'object' && webUrl(value.url)
|
|
101
|
+
&& typeof value.contentType === 'string' && value.contentType.length <= 200 && SHA256.test(value.sha256)
|
|
102
|
+
&& count(value.start) && count(value.end) && value.end - value.start === text.length && count(value.characters)
|
|
103
|
+
&& value.end <= value.characters && (value.bytes === undefined || count(value.bytes))
|
|
104
|
+
&& (value.status === undefined || Number.isSafeInteger(value.status) && value.status >= 200 && value.status < 300);
|
|
105
|
+
const validPassage = (value, source) => value && typeof value === 'object' && value.sourceId === source.sourceId
|
|
106
|
+
&& (value.title === undefined || typeof value.title === 'string' && value.title.length <= 1000)
|
|
107
|
+
&& typeof value.text === 'string' && value.text.trim() && Number.isFinite(value.retrievedAt) && value.retrievedAt > 0
|
|
108
|
+
&& typeof value.contentHash === 'string' && SHA256.test(value.contentHash) && validDocument(value.document, value.text)
|
|
109
|
+
&& (value.costMicroUsd === undefined || value.costMicroUsd === null || count(value.costMicroUsd));
|
|
110
|
+
/** Provider-reported usage for one query: billable requests and, when priced, the cost in micro-dollars. */
|
|
111
|
+
const validUsage = value => value === undefined || value && typeof value === 'object' && typeof value.provider === 'string'
|
|
112
|
+
&& /^[\x21-\x7e]{1,64}$/.test(value.provider) && count(value.requests) && (value.costMicroUsd === null || count(value.costMicroUsd));
|
|
113
|
+
/** Providers return results, or `{ results, usage }` when they report per-query usage. */
|
|
114
|
+
const providerResults = value => Array.isArray(value) ? { results: value, usage: undefined }
|
|
115
|
+
: value && typeof value === 'object' && Array.isArray(value.results) ? { results: value.results, usage: value.usage } : null;
|
|
116
|
+
|
|
117
|
+
/** The retained passage's offsets in the extracted document: the extractor's span, less trimmed and truncated text. */
|
|
118
|
+
function retainedDocument(document, text, retained) {
|
|
119
|
+
if (!document) return null;
|
|
120
|
+
const start = document.start + (text.length - text.trimStart().length);
|
|
121
|
+
return { url: document.url, contentType: document.contentType, characters: document.characters, sha256: document.sha256,
|
|
122
|
+
start, end: start + retained.length, ...(document.bytes === undefined ? {} : { bytes: document.bytes }),
|
|
123
|
+
...(document.status === undefined ? {} : { status: document.status }) };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Passages longer than the per-source limit are cut at a word boundary when one is near. */
|
|
127
|
+
function bounded(text, limit) {
|
|
128
|
+
const clean = text.trim();
|
|
129
|
+
if (clean.length <= limit) return { text: clean, truncated: false };
|
|
130
|
+
const cut = clean.slice(0, limit), space = cut.search(/\s\S*$/);
|
|
131
|
+
return { text: (space > limit * 0.8 ? cut.slice(0, space) : cut).trimEnd(), truncated: true };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const sourceBlock = source => `[${source.number}] ${source.title}\n${source.url}\n${source.passage}`;
|
|
135
|
+
/** The exact numbered context sent with the question. Deterministic for a record's sources and policy. */
|
|
136
|
+
export function renderSourceContext(sources, policy = defaults) {
|
|
137
|
+
return `${policy.context.instructions}\n\n${policy.context.sourcesHeading}\n\n${sources.map(sourceBlock).join('\n\n')}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
/** Runs one retrieval and returns the finished record. Failures are recorded, never thrown and never filled with sources.
|
|
142
|
+
* `answer` is an opaque key for the answer this retrieval serves; providers may pass it to their own usage log so
|
|
143
|
+
* service economics can sum costs per answer. It is never part of the query. */
|
|
144
|
+
export async function retrieveSources({ record, provider, extractor = null, signal, now = Date.now, policy = defaults, answer = null, onProgress }) {
|
|
145
|
+
const limits = record.limits, deadline = new AbortController();
|
|
146
|
+
const timer = setTimeout(() => deadline.abort(new RetrievalError('timeout')), limits.timeoutMs);
|
|
147
|
+
const forward = () => deadline.abort(new RetrievalError('cancelled'));
|
|
148
|
+
if (signal?.aborted) forward(); else signal?.addEventListener('abort', forward, { once: true });
|
|
149
|
+
let usage = null, stage = 'search';
|
|
150
|
+
let completed = 0, available = 0;
|
|
151
|
+
const progress = (total = null) => {
|
|
152
|
+
if (signal?.aborted) return;
|
|
153
|
+
try { onProgress?.({ stage, completed, total, available }); } catch { /* Observation cannot cancel retrieval. */ }
|
|
154
|
+
};
|
|
155
|
+
// What this retrieval spent, for its cost record: observed even when it fails.
|
|
156
|
+
const meter = { searched: false, usage: null, attempted: 0, documents: 0, failed: 0, bytes: 0, costs: [] };
|
|
157
|
+
const context = { providerId: record.provider.id, ...(answer === null ? {} : { answer }) };
|
|
158
|
+
const finish = (fields) => ({ ...record, ...fields, usage, finishedAt: now(), costs: retrievalCostRecord({
|
|
159
|
+
retrieval: retrievalCostEntry({ bearer: provider.costBearer ?? null, service: provider.searchProvider ?? null, meter }) }) });
|
|
160
|
+
try {
|
|
161
|
+
progress();
|
|
162
|
+
let returned;
|
|
163
|
+
try {
|
|
164
|
+
returned = providerResults(await abortable(() => { meter.searched = true; return provider.search(record.query, copy(limits), deadline.signal, copy(context)); },
|
|
165
|
+
deadline.signal));
|
|
166
|
+
} catch (error) { throw error instanceof RetrievalError ? error : new RetrievalError('provider-error', error); }
|
|
167
|
+
if (!returned || !returned.results.every(validResult) || !validUsage(returned.usage)) throw new RetrievalError('invalid-provider-result');
|
|
168
|
+
const results = returned.results;
|
|
169
|
+
// Recorded before anything else can fail: a failed or empty search may still have been billed.
|
|
170
|
+
const searchUsage = returned.usage ? { provider: returned.usage.provider, requests: returned.usage.requests,
|
|
171
|
+
costMicroUsd: returned.usage.costMicroUsd } : null;
|
|
172
|
+
meter.usage = searchUsage;
|
|
173
|
+
usage = { search: searchUsage, extraction: { documents: 0, bytes: 0 } };
|
|
174
|
+
if (!results.length) return finish({ outcome: 'empty', error: { code: 'no-results', message: FAILURES['no-results'] }, providerResultCount: 0 });
|
|
175
|
+
stage = 'extraction';
|
|
176
|
+
const omitted = [], seen = new Set(), kept = [];
|
|
177
|
+
for (const result of results) {
|
|
178
|
+
if (seen.has(result.sourceId)) continue;
|
|
179
|
+
seen.add(result.sourceId);
|
|
180
|
+
if (kept.length < limits.maxResults) kept.push(result); else omitted.push({ sourceId: result.sourceId, reason: 'result-limit' });
|
|
181
|
+
}
|
|
182
|
+
if (extractor) meter.attempted = kept.length;
|
|
183
|
+
progress(kept.length);
|
|
184
|
+
const passages = await Promise.allSettled(kept.map(source => (extractor
|
|
185
|
+
? abortable(() => extractor.extract({ source: copy(source), query: record.query, limits: copy(limits), signal: deadline.signal, ...copy(context) }),
|
|
186
|
+
deadline.signal)
|
|
187
|
+
.then(value => { if (!validPassage(value, source)) throw new Error('Invalid extracted passage'); return { ...value, origin: 'extract' }; })
|
|
188
|
+
: Promise.resolve({ sourceId: source.sourceId, text: source.excerpt, retrievedAt: source.retrievedAt,
|
|
189
|
+
contentHash: source.contentHash, origin: 'search-excerpt' }))
|
|
190
|
+
.then(value => { available++; return value; })
|
|
191
|
+
.finally(() => { completed++; progress(kept.length); })));
|
|
192
|
+
if (extractor) for (const passage of passages) {
|
|
193
|
+
if (passage.status === 'rejected') { meter.failed++; continue; }
|
|
194
|
+
const bytes = passage.value.document?.bytes;
|
|
195
|
+
meter.documents++;
|
|
196
|
+
meter.bytes = meter.bytes === null || bytes === undefined ? null : meter.bytes + bytes;
|
|
197
|
+
meter.costs.push(passage.value.costMicroUsd ?? null);
|
|
198
|
+
}
|
|
199
|
+
const timedOut = deadline.signal.aborted && deadline.signal.reason?.code === 'timeout';
|
|
200
|
+
if (deadline.signal.aborted && !timedOut) throw deadline.signal.reason;
|
|
201
|
+
const candidates = [];
|
|
202
|
+
kept.forEach((source, index) => {
|
|
203
|
+
const passage = passages[index];
|
|
204
|
+
if (passage.status === 'rejected') { omitted.push({ sourceId: source.sourceId,
|
|
205
|
+
reason: passage.reason?.code === 'timeout' ? 'extraction-timeout' : 'extraction-failed' }); return; }
|
|
206
|
+
const { text, truncated } = bounded(passage.value.text, limits.maxSourceCharacters);
|
|
207
|
+
const document = retainedDocument(passage.value.document, passage.value.text, text);
|
|
208
|
+
if (document) { usage.extraction.documents++; usage.extraction.bytes += document.bytes ?? 0; }
|
|
209
|
+
// A fetched page supplies its own address and title, so retained and forwarded sources keep nothing from the
|
|
210
|
+
// search response beyond transient use: search terms (such as Brave's) forbid storing or redistributing results.
|
|
211
|
+
const page = passage.value.document;
|
|
212
|
+
candidates.push({ sourceId: source.sourceId, url: page ? page.url : source.url,
|
|
213
|
+
title: page ? line(passage.value.title ?? '') || page.url : line(source.title), retrievedAt: passage.value.retrievedAt,
|
|
214
|
+
contentHash: passage.value.contentHash, passage: text, passageOrigin: passage.value.origin, truncated,
|
|
215
|
+
...(document ? { document } : {}) });
|
|
216
|
+
});
|
|
217
|
+
if (!candidates.length) throw new RetrievalError(timedOut ? 'timeout' : 'extraction-failed');
|
|
218
|
+
// Sources are numbered in provider order; any that would push the context past its limit are omitted.
|
|
219
|
+
const sources = [];
|
|
220
|
+
for (const candidate of candidates) {
|
|
221
|
+
const next = [...sources, { number: sources.length + 1, ...candidate }];
|
|
222
|
+
if (renderSourceContext(next, policy).length <= limits.maxContextCharacters) sources.push(next.at(-1));
|
|
223
|
+
else omitted.push({ sourceId: candidate.sourceId, reason: 'context-limit' });
|
|
224
|
+
}
|
|
225
|
+
if (!sources.length) throw new RetrievalError('context-limit');
|
|
226
|
+
stage = 'context';
|
|
227
|
+
progress(kept.length);
|
|
228
|
+
for (const source of sources) source.passageHash = await sha256Text(source.passage);
|
|
229
|
+
const content = renderSourceContext(sources, policy);
|
|
230
|
+
const digest = await sha256Text(content);
|
|
231
|
+
if (signal?.aborted) throw new RetrievalError('cancelled');
|
|
232
|
+
return finish({ outcome: 'retrieved', providerResultCount: results.length, sources, omitted,
|
|
233
|
+
partial: passages.some(passage => passage.status === 'rejected'),
|
|
234
|
+
context: { schema: policy.context.schema, characters: content.length, digest } });
|
|
235
|
+
} catch (cause) {
|
|
236
|
+
const error = cause instanceof RetrievalError ? cause : new RetrievalError('provider-error', cause);
|
|
237
|
+
return finish({ outcome: error.code === 'cancelled' ? 'cancelled' : 'failed', sources: [], context: null,
|
|
238
|
+
error: { code: error.code, message: FAILURES[error.code], stage } });
|
|
239
|
+
} finally {
|
|
240
|
+
clearTimeout(timer); signal?.removeEventListener('abort', forward);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The request executors receive: the latest user turn carries the numbered sources, checked against the record's digest. */
|
|
245
|
+
export async function withSourceContext(request, record, policy = defaults) {
|
|
246
|
+
assert(record?.outcome === 'retrieved' && record.sources.length, retrievalFailureMessage(record));
|
|
247
|
+
const content = renderSourceContext(record.sources, policy);
|
|
248
|
+
assert(record.context?.schema === policy.context.schema && await sha256Text(content) === record.context.digest,
|
|
249
|
+
'Retrieved context does not match its recorded digest');
|
|
250
|
+
const { retrieval, ...executed } = request, messages = request.messages.map(message => ({ ...message }));
|
|
251
|
+
const last = messages.at(-1);
|
|
252
|
+
assert(last?.role === 'user', 'Retrieval requires a user question');
|
|
253
|
+
last.content = `${content}\n\n${record.query !== last.content ? `Search query: ${record.query}\n` : ''}${policy.context.questionLabel} ${last.content}`;
|
|
254
|
+
return { ...executed, messages };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function validateRetrievalPorts({ provider, extractor = null, supportChecker = null }) {
|
|
258
|
+
assert(port(provider) && typeof provider.search === 'function', 'Retrieval requires a search provider');
|
|
259
|
+
assert(provider.costBearer === undefined || COST_BEARERS.includes(provider.costBearer), 'Invalid search provider cost bearer');
|
|
260
|
+
assert(provider.supply === undefined || RETRIEVAL_SUPPLIES.includes(provider.supply), 'Invalid search provider supply');
|
|
261
|
+
assert(extractor === null || port(extractor) && typeof extractor.extract === 'function', 'Invalid passage extractor');
|
|
262
|
+
assert(supportChecker === null || port(supportChecker) && typeof supportChecker.check === 'function', 'Invalid citation support checker');
|
|
263
|
+
}
|