abelien-harness 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/.env.example +4 -0
  2. package/README.md +11 -18
  3. package/package.json +2 -3
  4. package/skills/abelien-appearance/SKILL.md +1 -0
  5. package/skills/abelien-clarify/SKILL.md +12 -2
  6. package/skills/abelien-clarify/references/examples.md +22 -0
  7. package/skills/abelien-clarify/scripts/check_result.py +38 -3
  8. package/skills/abelien-clarify/scripts/test_check_result.py +32 -1
  9. package/skills/abelien-guide/SKILL.md +1 -1
  10. package/skills/abelien-hardware/SKILL.md +10 -5
  11. package/skills/abelien-hardware/domain.json +3 -3
  12. package/skills/abelien-hardware/references/search-index-format.md +13 -1
  13. package/skills/abelien-hardware/references/search-index-release.md +4 -2
  14. package/skills/abelien-hardware/references/web-evidence-contract.md +3 -3
  15. package/skills/abelien-hardware/scripts/dimensions.mjs +23 -8
  16. package/skills/abelien-hardware/scripts/electrical.mjs +168 -0
  17. package/skills/abelien-hardware/scripts/finalize-selection.mjs +4 -2
  18. package/skills/abelien-hardware/scripts/provider-chain-contract.mjs +27 -6
  19. package/skills/abelien-hardware/scripts/public-page.mjs +11 -121
  20. package/skills/abelien-hardware/scripts/response-redaction.mjs +1 -130
  21. package/skills/abelien-hardware/scripts/search-index-release.mjs +30 -17
  22. package/skills/abelien-hardware/scripts/selection-request.mjs +6 -5
  23. package/skills/abelien-hardware/scripts/tos-wiki-search.mjs +13 -8
  24. package/skills/abelien-hardware/scripts/volc-web-search.mjs +10 -27
  25. package/skills/abelien-hardware/scripts/web-common.mjs +8 -11
  26. package/skills/abelien-hardware/scripts/web-integrity.mjs +1 -1
  27. package/skills/abelien-hardware/scripts/web-provider-chain.mjs +10 -3
  28. package/skills/abelien-hardware/scripts/web-receipt.mjs +4 -2
  29. package/skills/abelien-hardware/scripts/web-search-plan.mjs +21 -2
  30. package/skills/abelien-hardware/scripts/web-search.mjs +5 -2
  31. package/skills/abelien-hardware-selection/SKILL.md +1 -1
  32. package/skills/abelien-hardware-selection/domain.json +2 -2
  33. package/skills/abelien-hardware-selection/references/selection-service-contract.md +30 -3
  34. package/skills/abelien-hardware-selection/scripts/select.mjs +35 -8
  35. package/skills/abelien-harness/scripts/ab.mjs +24 -2
  36. package/skills/abelien-harness/scripts/web/common.mjs +20 -0
  37. package/skills/abelien-harness/scripts/web/public-read.mjs +168 -0
  38. package/skills/abelien-harness/scripts/web/redact.mjs +136 -0
  39. package/skills/abelien-harness/scripts/web/volc.mjs +49 -0
  40. package/skills/abelien-reference/SKILL.md +51 -0
  41. package/skills/abelien-reference/domain.json +92 -0
  42. package/skills/abelien-reference/fixtures/create.final.json +102 -0
  43. package/skills/abelien-reference/fixtures/restore.collected.json +189 -0
  44. package/skills/abelien-reference/fixtures/restore.final.json +267 -0
  45. package/skills/abelien-reference/references/research-contract.md +74 -0
  46. package/skills/abelien-reference/schemas/reference-evidence.v1.json +140 -0
  47. package/skills/abelien-reference/scripts/evidence.mjs +60 -0
  48. package/skills/abelien-reference/scripts/finalize.mjs +116 -0
  49. package/skills/abelien-reference/scripts/measure.mjs +62 -0
  50. package/skills/abelien-reference/scripts/png.mjs +86 -0
  51. package/skills/abelien-reference/scripts/research.mjs +179 -0
  52. package/skills/abelien-structure/SKILL.md +2 -2
  53. package/skills/abelien-structure/domain.json +64 -0
  54. package/skills/abelien-structure/references/build123d-execution.md +1 -1
  55. package/skills/abelien-structure/references/handoff.md +1 -1
  56. package/skills/abelien-structure/worker/abelien_cad.py +585 -0
  57. package/skills/abelien-structure/worker/assembly-validation.mjs +106 -0
  58. package/skills/abelien-structure/worker/cad.py +1623 -0
  59. package/skills/abelien-structure/worker/cli-adapter.mjs +207 -0
  60. package/skills/abelien-structure/worker/component-fit-glb.mjs +173 -0
  61. package/skills/abelien-structure/worker/component-fit.d.mts +82 -0
  62. package/skills/abelien-structure/worker/component-fit.mjs +167 -0
  63. package/skills/abelien-structure/worker/component-target-image.d.mts +23 -0
  64. package/skills/abelien-structure/worker/component-target-image.mjs +107 -0
  65. package/skills/abelien-structure/worker/component-target-render.py +58 -0
  66. package/skills/abelien-structure/worker/config.mjs +6 -0
  67. package/skills/abelien-structure/worker/engineering-fit.d.mts +48 -0
  68. package/skills/abelien-structure/worker/engineering-fit.mjs +118 -0
  69. package/skills/abelien-structure/worker/engineering-policy.json +16 -0
  70. package/skills/abelien-structure/worker/engineering-repair.py +147 -0
  71. package/skills/abelien-structure/worker/engineering.py +324 -0
  72. package/skills/abelien-structure/worker/identity.mjs +98 -0
  73. package/skills/abelien-structure/worker/inspection-geometry.mjs +71 -0
  74. package/skills/abelien-structure/worker/jobs.mjs +451 -0
  75. package/skills/abelien-structure/worker/library-runtime.mjs +15 -0
  76. package/skills/abelien-structure/worker/main.mjs +102 -0
  77. package/skills/abelien-structure/worker/manifest-digest.mjs +11 -0
  78. package/skills/abelien-structure/worker/materials.mjs +52 -0
  79. package/skills/abelien-structure/worker/native-launcher.py +13 -0
  80. package/skills/abelien-structure/worker/native.mjs +157 -0
  81. package/skills/abelien-structure/worker/representation.mjs +47 -0
  82. package/skills/abelien-structure/worker/sandbox.mjs +41 -0
  83. package/skills/abelien-structure/worker/source-contract.mjs +143 -0
  84. package/skills/abelien-structure/worker/source_call_stats.py +59 -0
  85. package/skills/abelien-structure/worker/supervisor.mjs +38 -0
  86. package/skills/abelien-workspace/SKILL.md +11 -0
  87. package/skills/abelien-workspace/assets/journey-stage-plan.json +155 -0
  88. package/skills/abelien-workspace/domain.json +25 -2
  89. package/skills/abelien-workspace/references/hardware-journey-orchestration.md +1 -1
  90. package/skills/abelien-workspace/references/harness-binding.md +2 -2
  91. package/skills/abelien-workspace/references/user-journey.md +10 -0
  92. package/skills/abelien-workspace/scripts/journey-next.mjs +38 -0
  93. package/skills/abelien-workspace/workflows/definitions/0852e4949f5a6246880c1a26bf5ccaa42f8cda06b960e7c7ce234a0a176878f9.json +229 -0
  94. package/skills/abelien-workspace/workflows/definitions/399ef372790b7330d0ef88ce5096e7f69943d4793de97b2ed6c25165a332612f.json +230 -0
  95. package/skills/abelien-workspace/workflows/definitions/591a7f21e0b0ad586e340163f7533ac910356091ba8d2a26e250c0acbc80cd8b.json +176 -0
  96. package/skills/abelien-workspace/workflows/definitions/7c69ea0d51099b1ae947f08b056ea67f5cad6b40ae8bcb00efb496fa5b9d5ed3.json +149 -0
  97. package/skills/abelien-workspace/workflows/definitions/9b5c65ad03fdecc0148931a7677cfa684a3fb04ab2738d6f7a1086a2c79ac86c.json +149 -0
  98. package/skills/abelien-workspace/workflows/definitions/c1b5c6c653f0c4761d51efee923aafbbeea05512e7f35c60f7f7d36ec49fc1d1.json +229 -0
  99. package/skills/abelien-workspace/workflows/definitions/d9f8c6cdf0b2c41cdeb1c72ac5d1e37d95a7caac3272d434a3c7e8483fc565de.json +150 -0
  100. package/skills/abelien-workspace/workflows/definitions/e391e14c14cb39408a0a9e226ed54ffab7a36badea305ae4a3eafbac8124fc02.json +175 -0
  101. package/skills/abelien-workspace/workflows/definitions/fd8ce7074e2044985778446e93153864940bb9465471c1c71c41328cefff70bf.json +175 -0
  102. package/skills/abelien-workspace/workflows/user-journey-hardware-algorithm.json +5 -4
  103. package/skills/abelien-workspace/workflows/user-journey-hardware-candidates.json +5 -4
  104. package/skills/abelien-workspace/workflows/user-journey-hardware.json +5 -4
@@ -0,0 +1,168 @@
1
+ // Deterministic electrical compatibility for a selected set of parts (star topology, v1).
2
+ // Only measured/datasheet records can prove pass or a supply gap; missing data is unrecorded, never "not needed", and stays unknown.
3
+ // Star-topology shortfalls are risks to confirm, so v1 never proves fail. This is not a circuit simulation.
4
+ import { digest } from './selection-request.mjs';
5
+ const object = v => v !== null && typeof v === 'object' && !Array.isArray(v);
6
+ const positive = v => typeof v === 'number' && Number.isFinite(v) && v > 0;
7
+ const text = v => typeof v === 'string' && v.trim().length > 0;
8
+ const hash = v => typeof v === 'string' && /^[a-f0-9]{64}$/.test(v);
9
+ const slug = v => typeof v === 'string' && /^[a-z0-9][a-z0-9-]{0,47}$/.test(v);
10
+ const invalid = message => { throw Object.assign(new Error(message), { code: 'VALIDATION' }); };
11
+ function keys(value, required, optional, label) {
12
+ if (!object(value) || required.some(key => !Object.hasOwn(value, key)) || Object.keys(value).some(key => ![...required, ...optional].includes(key))) invalid(`${label} has missing or unsupported fields`);
13
+ }
14
+ export const ELECTRICAL_SCHEMA = 'abelien.component-electrical.v1';
15
+ export const BUS_TYPES = ['i2c', 'spi', 'uart', 'usb', 'i2s', 'can', 'pwm', 'gpio', 'adc'];
16
+ // Multi-drop buses only need to exist on the controller; the others are point-to-point and their counts add up.
17
+ const SHARED_BUSES = ['i2c', 'spi', 'can'];
18
+ const TRUSTED = ['measured', 'datasheet'];
19
+ const BASES = ['measured', 'datasheet', 'estimate', 'unknown'];
20
+ const range = v => Array.isArray(v) && v.length === 2 && v.every(positive) && v[0] <= v[1];
21
+
22
+ function validatePower(rows, label) {
23
+ if (!Array.isArray(rows)) invalid(`${label} must be an array`);
24
+ const ids = new Set();
25
+ for (const row of rows) {
26
+ keys(row, ['id', 'voltage_v'], ['connector', 'max_current_a'], label);
27
+ if (!slug(row.id) || ids.has(row.id) || !range(row.voltage_v) || (row.connector !== undefined && !slug(row.connector))
28
+ || (row.max_current_a !== undefined && !positive(row.max_current_a))) invalid(`${label} entries need unique ids, ordered positive voltage ranges and slug connectors`);
29
+ ids.add(row.id);
30
+ }
31
+ }
32
+ // Strict consumer-side contract; the producer is the component database export (P2-04.03).
33
+ export function validateElectricalRecord(value) {
34
+ keys(value, ['schema', 'basis', 'power_inputs', 'power_outputs', 'buses'], [], 'electrical record');
35
+ if (value.schema !== ELECTRICAL_SCHEMA || !BASES.includes(value.basis)) invalid('unsupported electrical record schema or basis');
36
+ validatePower(value.power_inputs, 'electrical power_inputs');
37
+ validatePower(value.power_outputs, 'electrical power_outputs');
38
+ if (!Array.isArray(value.buses)) invalid('electrical buses must be an array');
39
+ for (const bus of value.buses) {
40
+ keys(bus, ['type', 'role', 'count'], ['logic_level_v'], 'electrical bus');
41
+ if (!BUS_TYPES.includes(bus.type) || !['controller', 'peripheral'].includes(bus.role) || !Number.isSafeInteger(bus.count) || bus.count < 1
42
+ || (bus.logic_level_v !== undefined && !positive(bus.logic_level_v))) invalid('electrical bus needs a known type, role, positive count and positive logic level');
43
+ }
44
+ return value;
45
+ }
46
+ // Source-bound records only; an unbound or legacy record is reported as unknown, never trusted.
47
+ export function electricalFields(value) {
48
+ const record = value?.electrical, source = value?.electrical_source, id = value?.component_id ?? value?.record_id;
49
+ if (record === undefined && source === undefined) return {};
50
+ if (!object(source) || source.component_id !== id || !hash(source.revision) || !hash(source.snapshot_id)
51
+ || Object.keys(source).some(key => !['component_id', 'revision', 'snapshot_id', 'evidence_ref'].includes(key))
52
+ || (source.evidence_ref !== undefined && !text(source.evidence_ref))) return { electrical: null, electrical_source: null };
53
+ validateElectricalRecord(record);
54
+ if (source.revision !== digest({ component_id: id, ...record })) invalid('electrical source revision does not match candidate fields');
55
+ return { electrical: record, electrical_source: { ...source } };
56
+ }
57
+ const overlaps = (a, b) => a[0] <= b[1] && b[0] <= a[1];
58
+ const within = (inner, outer) => inner[0] >= outer[0] && inner[1] <= outer[1];
59
+ const isController = part => part.category_path?.[0] === 'compute-control' && ['controller-board', 'controller-module'].includes(part.category_path?.[1]);
60
+ const isPower = part => part.category_path?.[0] === 'power-energy';
61
+ const isLevelShifter = part => part.category_path?.[0] === 'electrical-interconnect' && part.category_path?.[1] === 'level-shifting';
62
+ const trusted = part => TRUSTED.includes(part.electrical?.basis);
63
+ const ref = part => ({ slot_id: part.slot_id, component_id: part.component_id });
64
+
65
+ // Supply match against trusted rails only. A missing rail is a supply gap (add a power part), never a reason to reject the consumer.
66
+ // Among rails whose voltage fits, the one with the largest recorded max_current_a is chosen (rails without current last, then input order);
67
+ // current is settled afterwards in settleCurrent, because one rail may feed several consumers.
68
+ function supply(consumer, sources, rule, dimension, loads) {
69
+ const parts = [consumer, ...sources].map(ref), inputs = consumer.electrical?.power_inputs ?? [];
70
+ if (!trusted(consumer)) return { rule, dimension, status: 'unknown', parts, reason: 'UNTRUSTED_OR_MISSING_DATA' };
71
+ // The v1 schema has no "needs no power" value: an empty list is unrecorded, not unpowered.
72
+ if (!inputs.length) return { rule, dimension, status: 'unknown', parts, reason: 'NO_DECLARED_INPUT' };
73
+ const rails = sources.filter(trusted).flatMap(source => (source.electrical.power_outputs ?? []).map(output => ({ source, output })));
74
+ const capacity = row => row.output.max_current_a ?? -1;
75
+ const fit = rails.filter(({ output }) => inputs.some(input => within(output.voltage_v, input.voltage_v)))
76
+ .reduce((best, row) => (!best || capacity(row) > capacity(best) ? row : best), null);
77
+ if (fit) {
78
+ const input = inputs.find(row => within(fit.output.voltage_v, row.voltage_v));
79
+ const connectorRisk = Boolean(input.connector && fit.output.connector && input.connector !== fit.output.connector);
80
+ const check = { rule, dimension, status: connectorRisk ? 'risk' : 'pass', parts, reason: connectorRisk ? 'CONNECTOR_MISMATCH' : 'RAIL_WITHIN_INPUT',
81
+ rail: { component_id: fit.source.component_id, id: fit.output.id, voltage_v: fit.output.voltage_v, connector: fit.output.connector ?? null },
82
+ input: { id: input.id, voltage_v: input.voltage_v, connector: input.connector ?? null } };
83
+ loads.set(check, { key: JSON.stringify([fit.source.slot_id, fit.source.component_id, fit.output.id]), capacity: fit.output.max_current_a,
84
+ draw: input.max_current_a === undefined ? undefined : input.max_current_a * (consumer.quantity ?? 1) });
85
+ return check;
86
+ }
87
+ if (sources.some(source => !trusted(source))) return { rule, dimension, status: 'unknown', parts, reason: 'UNTRUSTED_SOURCE_MAY_SUPPLY' };
88
+ const partial = rails.some(({ output }) => inputs.some(input => overlaps(output.voltage_v, input.voltage_v)));
89
+ return { rule, dimension, status: partial ? 'risk' : 'gap', parts, reason: partial ? 'RAIL_PARTIALLY_OUTSIDE_INPUT' : 'NO_SUPPLY_RAIL_FOR_INPUT' };
90
+ }
91
+ // Input max_current_a is the input's maximum draw (including loads it passes on); output max_current_a is the rail's maximum supply.
92
+ // A rail's load is the sum of draw x quantity over every consumer assigned to it; one rail of a multi-quantity source counts once.
93
+ // Recorded loads above capacity are a risk; any missing current on that rail is unknown. A pass needs every current recorded and within capacity.
94
+ function settleCurrent(checks, loads) {
95
+ const rails = new Map();
96
+ for (const check of checks) if (loads.has(check)) {
97
+ const row = loads.get(check);
98
+ rails.set(row.key, [...(rails.get(row.key) ?? []), check]);
99
+ }
100
+ for (const members of rails.values()) {
101
+ const rows = members.map(check => loads.get(check)), capacity = rows[0].capacity;
102
+ const known = rows.filter(row => row.draw !== undefined), total = known.reduce((sum, row) => sum + row.draw, 0);
103
+ const complete = capacity !== undefined && known.length === rows.length;
104
+ const status = capacity !== undefined && total > capacity + 1e-9 ? 'exceeded' : complete ? 'within' : 'unrecorded';
105
+ for (const check of members) {
106
+ const draw = loads.get(check).draw;
107
+ check.current = { status, draw_a: draw ?? null, rail_load_a: known.length ? total : null, capacity_a: capacity ?? null };
108
+ if (status === 'exceeded') Object.assign(check, { status: 'risk', reason: 'RAIL_CURRENT_EXCEEDED' });
109
+ else if (status === 'unrecorded' && check.status === 'pass') Object.assign(check, { status: 'unknown', reason: 'CURRENT_UNRECORDED' });
110
+ }
111
+ }
112
+ }
113
+ // fail (incompatible part) > gap (missing part) > pass (all checks pass on trusted data) > risk > unknown. v1 rules never emit fail.
114
+ const aggregate = (checks, dimension, members) => {
115
+ const rows = checks.filter(check => check.dimension === dimension);
116
+ for (const status of ['fail', 'gap']) if (rows.some(check => check.status === status)) return status;
117
+ if (rows.length && rows.every(check => check.status === 'pass') && members.every(trusted)) return 'pass';
118
+ return rows.some(check => check.status === 'risk') ? 'risk' : 'unknown';
119
+ };
120
+ // parts: [{slot_id, component_id, category_path, quantity, electrical}] for the whole selection.
121
+ export function evaluateElectrical(parts) {
122
+ const method = 'electrical-star-v1', controllers = parts.filter(isController);
123
+ if (controllers.length !== 1) return { method, topology: 'not_star', dimensions: { power: 'unknown', interfaces: 'unknown' }, checks: [],
124
+ reasons: [{ code: controllers.length ? 'MULTIPLE_CONTROLLERS' : 'NO_CONTROLLER', controllers: controllers.map(ref) }] };
125
+ const [controller] = controllers, sources = parts.filter(isPower), shifters = parts.filter(isLevelShifter);
126
+ const peripherals = parts.filter(part => part !== controller && !isPower(part) && !isLevelShifter(part));
127
+ const checks = [], loads = new Map();
128
+ // Rule 1: a selected supply must power the controller. Without one the controller's own supply is outside the selection: unknown, never pass.
129
+ if (sources.length) checks.push(supply(controller, sources, 'controller_supply', 'power', loads));
130
+ else checks.push({ rule: 'controller_supply', dimension: 'power', status: 'unknown', parts: [ref(controller)], reason: 'CONTROLLER_SUPPLY_OUTSIDE_SELECTION' });
131
+ // Rule 2: each peripheral needs a controller rail or a selected supply rail inside one of its input ranges.
132
+ for (const part of peripherals) checks.push(supply(part, [controller, ...sources], 'peripheral_supply', 'power', loads));
133
+ settleCurrent(checks, loads);
134
+ // Rule 3: bus types the peripherals need should exist on the controller; point-to-point counts should also fit.
135
+ // The star assumption ignores expansion and driver boards, so a shortfall is a risk to confirm, never a proof of incompatibility.
136
+ const need = new Map();
137
+ for (const part of peripherals) for (const bus of part.electrical?.buses ?? []) if (bus.role === 'peripheral')
138
+ need.set(bus.type, [...(need.get(bus.type) ?? []), { part, bus, count: bus.count * (part.quantity ?? 1) }]);
139
+ for (const part of peripherals) if (!trusted(part)) checks.push({ rule: 'bus_availability', dimension: 'interfaces', status: 'unknown', parts: [ref(part), ref(controller)], reason: 'UNTRUSTED_OR_MISSING_DATA' });
140
+ const shifterTrusted = shifters.length > 0 && shifters.every(trusted);
141
+ for (const [type, rows] of need) {
142
+ const parts2 = [ref(controller), ...rows.map(row => ref(row.part))];
143
+ if (!trusted(controller) || rows.some(row => !trusted(row.part))) { checks.push({ rule: 'bus_availability', dimension: 'interfaces', status: 'unknown', bus: type, parts: parts2, reason: 'UNTRUSTED_OR_MISSING_DATA' }); continue; }
144
+ if (!controller.electrical.buses.some(bus => bus.role === 'controller')) { checks.push({ rule: 'bus_availability', dimension: 'interfaces', status: 'unknown', bus: type, parts: parts2, reason: 'CONTROLLER_BUSES_UNRECORDED' }); continue; }
145
+ const offered = controller.electrical.buses.filter(bus => bus.type === type && bus.role === 'controller');
146
+ const available = offered.reduce((sum, bus) => sum + bus.count, 0);
147
+ const devices = rows.reduce((sum, row) => sum + row.count, 0);
148
+ const required = SHARED_BUSES.includes(type) ? 1 : devices;
149
+ checks.push({ rule: 'bus_availability', dimension: 'interfaces', bus: type, parts: parts2, required, available,
150
+ status: available >= required ? 'pass' : 'risk', reason: !available ? 'BUS_TYPE_MISSING' : available < required ? 'BUS_COUNT_SHORT' : 'BUS_AVAILABLE' });
151
+ // Several devices on one shared SPI bus each need a chip-select line, which v1 does not model. I2C address conflicts are not checked in v1.
152
+ if (type === 'spi' && available && devices > available)
153
+ checks.push({ rule: 'spi_chip_select', dimension: 'interfaces', bus: type, parts: parts2, devices, available, status: 'risk', reason: 'SPI_CHIP_SELECT_UNVERIFIED' });
154
+ // Rule 4: a logic-level mismatch is only a risk in v1; only a trusted level shifter can clear it. Unrecorded levels stay unknown.
155
+ for (const row of rows) {
156
+ const level = offered.find(bus => bus.logic_level_v !== undefined)?.logic_level_v;
157
+ const base = { rule: 'logic_level', dimension: 'interfaces', bus: type, parts: [ref(controller), ref(row.part)], controller_v: level ?? null, peripheral_v: row.bus.logic_level_v ?? null };
158
+ if (!offered.length) continue;
159
+ if (level === undefined || row.bus.logic_level_v === undefined) { checks.push({ ...base, status: 'unknown', reason: 'LOGIC_LEVEL_UNRECORDED' }); continue; }
160
+ const mismatch = Math.abs(level - row.bus.logic_level_v) > 1e-9;
161
+ checks.push({ ...base, status: !mismatch || shifterTrusted ? 'pass' : shifters.length ? 'unknown' : 'risk',
162
+ reason: !mismatch ? 'LOGIC_LEVEL_MATCH' : shifterTrusted ? 'LEVEL_SHIFTER_SELECTED' : shifters.length ? 'LEVEL_SHIFTER_UNTRUSTED' : 'LOGIC_LEVEL_MISMATCH' });
163
+ }
164
+ }
165
+ const powerMembers = [controller, ...sources, ...peripherals.filter(part => checks.some(check => check.dimension === 'power' && check.parts.some(row => row.component_id === part.component_id)))];
166
+ return { method, topology: 'star', controller: ref(controller), checks,
167
+ dimensions: { power: aggregate(checks, 'power', powerMembers), interfaces: aggregate(checks, 'interfaces', [controller, ...peripherals]) } };
168
+ }
@@ -1,5 +1,6 @@
1
1
  import crypto from "node:crypto";
2
2
  import { assemblyOccupancy, dimensionFields, evaluateDimensions } from "./dimensions.mjs";
3
+ import { electricalFields } from "./electrical.mjs";
3
4
  import { selectionRequestDigest } from "./selection-request.mjs";
4
5
 
5
6
  function error(message, details) {
@@ -74,13 +75,13 @@ export async function finalizeHardwareSelection({ input }) {
74
75
  slot_id: selected.slot_id, component_id: selected.component_id,
75
76
  });
76
77
  }
77
- const dimensionCheck = evaluateDimensions(candidate, input.requirements);
78
+ const dimensionCheck = evaluateDimensions(candidate, input.requirements, (input.slots ?? []).find(slot => slot?.slot_id === selected.slot_id));
78
79
  if (dimensionCheck.status === "incompatible") throw error("selected component is dimensionally incompatible; choose a surviving candidate", { slot_id: selected.slot_id, component_id: selected.component_id, dimension_check: dimensionCheck });
79
80
  return {
80
81
  name: String(candidate.title ?? candidate.name ?? candidate.model ?? candidate.component_id),
81
82
  // This is a deterministic selection-line identity, NOT an upstream catalog BOM identity.
82
83
  bom_item_id: `selection-line-${digest({ slot_id: selected.slot_id, component_id: candidate.component_id }).slice(0, 24)}`,
83
- ...dimensionFields(candidate), dimension_check: dimensionCheck,
84
+ ...dimensionFields(candidate), dimension_check: dimensionCheck, ...electricalFields(candidate),
84
85
  ...(candidate.interfaces !== undefined ? { interfaces: candidate.interfaces } : {}),
85
86
  slot_id: selected.slot_id, component_id: selected.component_id, quantity: selected.quantity,
86
87
  selection_reason: selected.selection_reason, wiki_evidence: selected.wiki_evidence,
@@ -94,6 +95,7 @@ export async function finalizeHardwareSelection({ input }) {
94
95
  ...(candidate.source_type === "external_web" ? { source_type: candidate.source_type, source: candidate.source, geometry: candidate.geometry, CAD_available: false, cad_available: false, engineering_verified: false } : {}),
95
96
  };
96
97
  });
98
+ // Electrical checks never reject here: star-topology bus shortfalls and supply gaps are reported by hardware.select for confirmation.
97
99
  const stable = {
98
100
  requirements: input.requirements ?? {}, slots: input.slots ?? [], source_snapshot: recall.source_snapshot,
99
101
  proposal: { ...proposal, selected_models: selectedModels },
@@ -2,7 +2,7 @@ import { digest, selectionRequestDigest } from "./selection-request.mjs";
2
2
  import { failure, text } from "./web-common.mjs";
3
3
  import { sha256, normalizePublicSourceUrl, pageText, publicAddress, publicReadFailureCodes } from "./public-page.mjs";
4
4
  import { intactReceipt, successfulReceipt } from "./web-receipt.mjs";
5
- import { volcQueryRequest } from "./volc-web-search.mjs";
5
+ import { volcQueryRequest, legacyVolcQueryRequest } from "./volc-web-search.mjs";
6
6
  import { isIP } from "node:net";
7
7
  export const chainProtocol = "prismer-volc-source-v2", chainPolicy = "prismer_then_volc";
8
8
  export const receiptFields = ["raw", "raw_sha256", "response", "response_sha256", "request_id", "received_at", "http_status"];
@@ -13,8 +13,27 @@ export const same = (left, right) => digest({ value: left }) === digest({ value:
13
13
  export const selectedReceipt = group => group?.attempts?.find(attempt => attempt.id === group.selected_attempt_id) ?? null;
14
14
  export const queryResults = attempt => attempt?.provider === "volcengine" ? attempt.response?.Result?.WebResults?.map(result => ({ ...result, url: result?.Url ?? null })) : attempt?.response?.results;
15
15
  export const missingKey = attempt => attempt?.status === "unavailable" && attempt.dispatch_state === "not_sent" && attempt.reason === "missing_key" && attempt.error_code === "HARDWARE_WEB_CONFIG" && attempt.raw == null && attempt.response == null && attempt.http_status == null;
16
+ // A Prismer attempt skipped by the trusted host switch is recorded unsent with no receipt;
17
+ // it authorizes the same group's Volc query or direct source read.
18
+ export const prismerDisabled = attempt => attempt?.provider === "prismer" && attempt.status === "unavailable" && attempt.dispatch_state === "not_sent" && attempt.reason === "prismer_disabled" && attempt.error_code == null && attempt.raw == null && attempt.response == null && attempt.http_status == null;
19
+ // The Prismer switch belongs to one web evidence: it is pinned when the evidence first needs
20
+ // provider IO, and every resume uses the pinned value, never the host environment of that time.
21
+ // Legacy evidence without the pin is inferred from its own attempts; a mix fails closed.
22
+ export const prismerSwitches = ["enabled", "disabled"];
23
+ const prismerAttempts = body => [...(body.searches ?? []), ...(body.pages ?? [])].flatMap(group => Array.isArray(group?.attempts) ? group.attempts : []).filter(attempt => attempt?.provider === "prismer");
24
+ function switchConsistent(body) {
25
+ const attempts = prismerAttempts(body);
26
+ return body.prismer_switch === "disabled" ? attempts.every(prismerDisabled) : !attempts.some(prismerDisabled);
27
+ }
28
+ export function pinPrismerSwitch(body, current) {
29
+ if (Object.hasOwn(body, "prismer_switch")) return body.prismer_switch;
30
+ const attempts = prismerAttempts(body), disabled = attempts.some(prismerDisabled), live = attempts.some(attempt => !prismerDisabled(attempt));
31
+ if (disabled && live) throw failure("HARDWARE_WEB_RECONCILIATION_REQUIRED", "legacy evidence mixes skipped and dispatched Prismer attempts; reconcile it before resuming");
32
+ body.prismer_switch = disabled ? "disabled" : live ? "enabled" : current;
33
+ return body.prismer_switch;
34
+ }
16
35
  export const knownDenial = attempt => attempt?.provider === "prismer" && attempt.status === "denied" && attempt.dispatch_state === "sent" && [401, 403, 402, 429].includes(attempt.http_status) && intactReceipt(attempt);
17
- export const permitsFallback = (attempt, query) => knownDenial(attempt) || missingKey(attempt) || (query && attempt?.status === "completed" && successfulReceipt(attempt) && Array.isArray(queryResults(attempt)) && queryResults(attempt).length === 0);
36
+ export const permitsFallback = (attempt, query) => knownDenial(attempt) || missingKey(attempt) || prismerDisabled(attempt) || (query && attempt?.status === "completed" && successfulReceipt(attempt) && Array.isArray(queryResults(attempt)) && queryResults(attempt).length === 0);
18
37
  export function expectedSelection(group, query) {
19
38
  const primary = group.attempts?.[0], fallback = group.attempts?.[1];
20
39
  if (!primary) return null;
@@ -67,13 +86,14 @@ function validateGroup(group, item, page, partial) {
67
86
  const operation = page ? sourceOperation(page.source_url) : null;
68
87
  for (const [index, attempt] of attempts.entries()) {
69
88
  const provider = index === 0 ? "prismer" : query ? "volcengine" : "direct_https", kind = query ? "query" : index === 0 ? operation.kind : "direct_page";
70
- const request = query ? index === 0 ? primaryQuery(item) : volcQueryRequest(item) : index === 0 ? operation.request : { url: page.source_url, method: "GET" };
71
- if (attempt.provider !== provider || attempt.kind !== kind || attempt.role !== (index === 0 ? "primary" : "fallback") || attempt.group_id !== group.id || attempt.id !== operationId(group.id, provider, kind, request) || !same(attempt.request, request)) return false;
89
+ // Volc receipts saved before the Count-5 default keep their frozen Count-10 request shape.
90
+ const requests = query ? index === 0 ? [primaryQuery(item)] : [volcQueryRequest(item), legacyVolcQueryRequest(item)] : [index === 0 ? operation.request : { url: page.source_url, method: "GET" }];
91
+ if (attempt.provider !== provider || attempt.kind !== kind || attempt.role !== (index === 0 ? "primary" : "fallback") || attempt.group_id !== group.id || !requests.some(request => attempt.id === operationId(group.id, provider, kind, request) && same(attempt.request, request))) return false;
72
92
  if (index === 1 && !permitsFallback(attempts[0], query)) return false;
73
93
  if (["in_progress", "unknown", "failed"].includes(attempt.status)) return false;
74
94
  if (provider === "direct_https") { if (!validDirect(attempt) || (attempt.dispatch_state === "not_sent" && operation.kind !== "parse") || (attempt.public_read_failure && operation.kind === "parse")) return false; continue; }
75
95
  if (Object.hasOwn(attempt, "public_read_failure")) return false;
76
- if (missingKey(attempt) || knownDenial(attempt)) continue;
96
+ if (missingKey(attempt) || knownDenial(attempt) || prismerDisabled(attempt)) continue;
77
97
  if (attempt.dispatch_state !== "sent" || !["completed", "pending"].includes(attempt.status) || !successfulReceipt(attempt)) return false;
78
98
  if (query && !Array.isArray(queryResults(attempt))) return false;
79
99
  if (attempt.status === "pending" && !(kind === "parse" && text(attempt.task_id) && attempt.task_id === attempt.response.taskId)) return false;
@@ -81,7 +101,7 @@ function validateGroup(group, item, page, partial) {
81
101
  if (page && (!Array.isArray(attempt.observations) || !attempt.observations.every(value => ["status", "result"].includes(value.kind) && (value.error_code ? ((value.raw === undefined && value.response === undefined) || intactReceipt(value)) : successfulReceipt(value) && value.response.taskId === attempt.task_id)))) return false;
82
102
  }
83
103
  const expected = expectedSelection(group, query), selected = selectedReceipt(group);
84
- if (group.selected_attempt_id == null) return partial && group.status === "in_progress" && (knownDenial(attempts[0]) || missingKey(attempts[0]) || successfulReceipt(attempts[0])) && (!page || page.attempt == null);
104
+ if (group.selected_attempt_id == null) return partial && group.status === "in_progress" && (knownDenial(attempts[0]) || missingKey(attempts[0]) || prismerDisabled(attempts[0]) || successfulReceipt(attempts[0])) && (!page || page.attempt == null);
85
105
  if (!selected || !expected || selected.id !== expected.id) return false;
86
106
  return group.selected_provider === selected.provider && (query ? group.status === "completed" && receiptFields.every(key => same(group[key], selected[key])) : same(page.attempt, selected));
87
107
  }
@@ -89,6 +109,7 @@ export function validateChainIntegrity({ original, source, saved, allowPartial =
89
109
  const reject = message => { throw failure(code, message); }, body = saved?.body, expectedId = `hardware-web-evidence:web-evidence-${digest({ recall_id: source.id, request_digest: selectionRequestDigest(original) }).slice(0, 24)}`;
90
110
  try {
91
111
  if (saved?.id !== expectedId || saved.kind !== "hardware-web-evidence" || body.id !== expectedId.split(":")[1] || body.protocol !== chainProtocol || body.provider !== "prismer" || body.schema_version !== 2 || body.provider_policy !== chainPolicy || body.recall_id !== source.id || body.recall_digest !== digest(source) || body.request_digest !== selectionRequestDigest(original) || selectionRequestDigest(body) !== selectionRequestDigest(original) || body.plan_digest !== digest(original.unresolved_slots) || body.plan_digest !== digest(body.unresolved_slots) || !["completed", ...(allowPartial ? ["pending", "in_progress"] : [])].includes(body.status)) reject("chain identity or original frozen plan drifted");
112
+ if (Object.hasOwn(body, "prismer_switch") && (!prismerSwitches.includes(body.prismer_switch) || !switchConsistent(body))) reject("pinned Prismer switch is invalid or contradicts its attempts");
92
113
  if (![body.searches, body.pages, body.query_plans, body.unknowns].every(Array.isArray) || !body.query_plans.length || !body.query_plans.every(plan => Array.isArray(plan.unresolved_slots) && plan.plan_digest === digest(plan.unresolved_slots)) || body.query_plans.at(-1).plan_digest !== body.plan_digest || new Set(body.searches.map(entry => entry.id)).size !== body.searches.length || new Set(body.pages.map(page => page.id)).size !== body.pages.length) reject("chain history is malformed");
93
114
  for (const entry of body.searches) {
94
115
  const item = body.query_plans.flatMap(plan => plan.unresolved_slots).find(item => item.slot_id === entry.slot_id && item.query === entry.query && item.reason === entry.reason && same(entry.request, primaryQuery(item)));
@@ -1,121 +1,11 @@
1
- import https from "node:https";
2
- import dns from "node:dns/promises";
3
- import { isIP } from "node:net";
4
- import { createHash } from "node:crypto";
5
- import { failure, timed } from "./web-common.mjs";
6
- export const sha256 = bytes => createHash("sha256").update(bytes).digest("hex");
7
- export const publicReadFailureCodes = Object.freeze({ transport: "HARDWARE_WEB_TRANSPORT", dns: "HARDWARE_WEB_DNS", access: "HARDWARE_WEB_URL", redirect: "HARDWARE_WEB_PAGE", timeout: "HARDWARE_WEB_TIMEOUT" });
8
- const readFailures = new WeakMap();
9
- export const publicReadFailure = error => readFailures.get(error) ?? null;
10
- function readFailure(stage) {
11
- const code = publicReadFailureCodes[stage], error = failure(code, `public source ${stage} failed`);
12
- readFailures.set(error, Object.freeze({ stage, code, failed_at: new Date().toISOString() })); return error;
13
- }
14
- const networkError = error => ["ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "EPIPE", "ENETUNREACH", "EHOSTUNREACH", "EAI_AGAIN", "ENOTFOUND", "ENODATA", "ESERVFAIL", "EREFUSED"].includes(error?.code);
15
- async function readTimed(operation, timeoutMs) {
16
- try { return await timed(operation, timeoutMs, "HARDWARE_PUBLIC_READ_TIMEOUT"); }
17
- catch (error) { if (error?.code === "HARDWARE_PUBLIC_READ_TIMEOUT") throw readFailure("timeout"); throw error; }
18
- }
19
- export function publicAddress(address) {
20
- if (typeof address !== "string") return false;
21
- if (isIP(address) === 4) {
22
- const value = address.split(".").reduce((out, part) => (out * 256 + Number(part)) >>> 0, 0);
23
- const ranges = [["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8], ["169.254.0.0", 16], ["172.16.0.0", 12], ["192.0.0.0", 24], ["192.0.2.0", 24], ["192.168.0.0", 16], ["198.18.0.0", 15], ["198.51.100.0", 24], ["203.0.113.0", 24], ["224.0.0.0", 4], ["240.0.0.0", 4]];
24
- return !ranges.some(([network, bits]) => {
25
- const base = network.split(".").reduce((out, part) => (out * 256 + Number(part)) >>> 0, 0), mask = (0xffffffff << (32 - bits)) >>> 0;
26
- return (value & mask) === (base & mask);
27
- });
28
- }
29
- if (isIP(address) === 6) {
30
- const normalized = new URL(`https://[${address}]/`).hostname.slice(1, -1);
31
- return /^[23][0-9a-f]{3}:/.test(normalized) && !/^(2001:(:|[01][0-9a-f]?:|2[0-9a-f]?:|db8:)|2002:|3fff:)/.test(normalized);
32
- }
33
- return false;
34
- }
35
- export function normalizePublicSourceUrl(value) {
36
- let url; try { url = new URL(value); } catch { throw failure("HARDWARE_WEB_URL", "public source URL is invalid"); }
37
- if (url.protocol !== "https:" || url.username || url.password || (url.port && url.port !== "443")
38
- || !url.hostname.includes(".") || isIP(url.hostname.replace(/^\[|\]$/g, ""))
39
- || /(^|\.)(localhost|local|internal|test|invalid)$/.test(url.hostname)) throw failure("HARDWARE_WEB_URL", "only public HTTPS source URLs without credentials are allowed");
40
- url.hash = ""; return url;
41
- }
42
- function pageRequest(url, { address, family, signal, headers = {} }) {
43
- return new Promise((resolve, reject) => {
44
- const req = https.get(url, { signal, agent: false, servername: new URL(url).hostname, rejectUnauthorized: true, headers: { "user-agent": "Abelien-Hardware-Evidence/1", accept: "text/html,text/plain,application/xhtml+xml,application/pdf", ...headers },
45
- lookup: (_hostname, opts, callback) => callback(null, ...(opts.all ? [[{ address, family }]] : [address, family])) }, response => {
46
- const chunks = []; response.on("data", chunk => chunks.push(chunk)); response.on("error", () => reject(readFailure("transport")));
47
- response.on("end", () => resolve({ status: response.statusCode, headers: response.headers, bytes: Buffer.concat(chunks) }));
48
- });
49
- req.on("error", () => reject(readFailure("transport")));
50
- });
51
- }
52
- async function dohLookup(hostname, signal, request) {
53
- const answers = await Promise.all(["A", "AAAA"].map(async type => {
54
- // Fixed trusted resolver and public bootstrap address; no unchecked system resolution.
55
- const url = `https://cloudflare-dns.com/dns-query?type=${type}&name=${encodeURIComponent(hostname)}`;
56
- let response;
57
- try {
58
- response = await request(url, { address: "1.1.1.1", family: 4, signal, headers: { accept: "application/dns-json" } });
59
- } catch (error) { if (publicReadFailure(error) || networkError(error)) throw readFailure("dns"); throw error; }
60
- let value;
61
- try { value = JSON.parse(Buffer.from(response.bytes ?? []).toString("utf8")); }
62
- catch (error) { if (error instanceof SyntaxError) throw failure("HARDWARE_WEB_DNS", "trusted public DNS returned malformed JSON"); throw error; }
63
- if (response.status !== 200 || value?.Status !== 0 || (value.Answer !== undefined && !Array.isArray(value.Answer))) throw readFailure("dns");
64
- return (value.Answer ?? []).filter(item => [1, 28].includes(item?.type)).map(item => ({ address: item.data, family: item.type === 1 ? 4 : 6 }));
65
- }));
66
- return answers.flat();
67
- }
68
- export function decodeHtmlEntities(raw) {
69
- return raw.replace(/&#(x[0-9a-f]+|[0-9]+);/gi, (_, n) => { const code = n[0].toLowerCase() === "x" ? parseInt(n.slice(1), 16) : Number(n); return code <= 0x10ffff ? String.fromCodePoint(code) : "�"; })
70
- .replace(/&(amp|lt|gt|quot|apos|nbsp);/gi, (_, entity) => ({ amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " })[entity.toLowerCase()]);
71
- }
72
- export function pageText(raw, contentType) {
73
- if (!contentType.includes("html")) return raw;
74
- return decodeHtmlEntities(raw.replace(/<(script|style|noscript)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, " ").replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
75
- }
76
- // Validate the submitted URL only. Prismer's remote fetch/redirect behavior is outside
77
- // this local DNS check; this does not assert that its source connection is pinned.
78
- export async function validatePublicSource(target, { lookup, dnsRequest = pageRequest, dnsTransport = "system", timeoutMs = 30000, checkUrl = () => {} } = {}) {
79
- checkUrl(target);
80
- const url = normalizePublicSourceUrl(target);
81
- await readTimed(async signal => {
82
- let addresses;
83
- try { addresses = await (lookup ? lookup(url.hostname) : dnsTransport === "doh" ? dohLookup(url.hostname, signal, dnsRequest) : dns.lookup(url.hostname, { all: true, verbatim: true })); }
84
- catch (error) { if (publicReadFailure(error) || networkError(error)) throw readFailure("dns"); throw error; }
85
- if (!Array.isArray(addresses) || !addresses.length || addresses.some(item => !publicAddress(item?.address) || isIP(item.address) !== item.family)) throw failure("HARDWARE_WEB_URL", "submitted source DNS includes non-public addresses");
86
- }, timeoutMs);
87
- return url.href;
88
- }
89
- export async function fetchPublicPage(target, { lookup, pageRequest: request = pageRequest, dnsRequest = pageRequest, dnsTransport = "system", timeoutMs = 30000, maxRedirects = 5, checkUrl = () => {} } = {}) {
90
- checkUrl(target);
91
- let url = normalizePublicSourceUrl(target); const redirects = [], connections = [], fetchedAt = new Date().toISOString();
92
- if (!["system", "doh"].includes(dnsTransport)) throw failure("HARDWARE_WEB_CONFIG", "web DNS transport must be system or doh");
93
- return readTimed(async signal => {
94
- for (let n = 0; n <= maxRedirects; n++) {
95
- let addresses;
96
- try { addresses = await (lookup ? lookup(url.hostname) : dnsTransport === "doh" ? dohLookup(url.hostname, signal, dnsRequest) : dns.lookup(url.hostname, { all: true, verbatim: true })); }
97
- catch (err) { if (publicReadFailure(err) || networkError(err)) throw readFailure("dns"); throw err; }
98
- if (!Array.isArray(addresses) || !addresses.length || addresses.some(item => !publicAddress(item?.address) || isIP(item.address) !== item.family)) throw readFailure("access");
99
- connections.push({ url: url.href, tls_hostname: url.hostname, addresses, address: addresses[0].address, family: addresses[0].family });
100
- let response;
101
- try { response = await request(url.href, { address: addresses[0].address, family: addresses[0].family, signal }); }
102
- catch (error) { if (publicReadFailure(error)) throw error; if (networkError(error)) throw readFailure("transport"); throw error; }
103
- const bytes = Buffer.from(response.bytes ?? []), headers = response.headers ?? {};
104
- if ([301, 302, 303, 307, 308].includes(response.status)) {
105
- if (n === maxRedirects || !headers.location) throw readFailure("redirect");
106
- let next;
107
- try { next = new URL(headers.location, url); } catch (error) { if (error?.code === "ERR_INVALID_URL") throw readFailure("redirect"); throw error; }
108
- try { next = normalizePublicSourceUrl(next.href); checkUrl(next.href); } catch (error) { if (error?.code === "HARDWARE_WEB_URL") throw readFailure("access"); throw error; }
109
- redirects.push({ from: url.href, to: next.href }); url = next; continue;
110
- }
111
- const common = { url: normalizePublicSourceUrl(target).href, final_url: url.href, fetched_at: fetchedAt, redirects, connections, dns_transport: dnsTransport, http_status: response.status,
112
- raw_sha256: sha256(bytes), raw_base64: bytes.toString("base64"), content_type: String(headers["content-type"] ?? "").toLowerCase() };
113
- const mime = common.content_type;
114
- if (response.status < 200 || response.status >= 300) return { ...common, status: "unavailable", reason: "http_status", text: null, text_sha256: null };
115
- if (mime.includes("pdf") || bytes.subarray(0, 5).toString() === "%PDF-") return { ...common, status: "unavailable", reason: "pdf_parser_unavailable", text: null, text_sha256: null };
116
- if (!(mime.startsWith("text/") || mime.includes("xhtml"))) return { ...common, status: "unavailable", reason: "unsupported_content_type", text: null, text_sha256: null };
117
- const text = pageText(bytes.toString("utf8"), mime);
118
- return { ...common, status: text.trim() ? "read" : "unavailable", reason: text.trim() ? null : "empty_page", text, text_sha256: sha256(text) };
119
- }
120
- }, timeoutMs);
121
- }
1
+ // Hardware binding of the kernel public HTTPS reader (P2-13.03). Codes, User-Agent and Accept are unchanged.
2
+ import { createPublicReader } from "../../abelien-harness/scripts/web/public-read.mjs";
3
+ export { sha256 } from "../../abelien-harness/scripts/web/common.mjs";
4
+ export { publicAddress, decodeHtmlEntities, pageText } from "../../abelien-harness/scripts/web/public-read.mjs";
5
+ const reader = createPublicReader({
6
+ codes: { transport: "HARDWARE_WEB_TRANSPORT", dns: "HARDWARE_WEB_DNS", access: "HARDWARE_WEB_URL", redirect: "HARDWARE_WEB_PAGE", timeout: "HARDWARE_WEB_TIMEOUT",
7
+ url: "HARDWARE_WEB_URL", config: "HARDWARE_WEB_CONFIG", readTimeout: "HARDWARE_PUBLIC_READ_TIMEOUT" },
8
+ userAgent: "Abelien-Hardware-Evidence/1",
9
+ accept: "text/html,text/plain,application/xhtml+xml,application/pdf",
10
+ });
11
+ export const { publicReadFailureCodes, publicReadFailure, normalizePublicSourceUrl, validatePublicSource, fetchPublicPage } = reader;
@@ -1,135 +1,6 @@
1
- import { createHash } from "node:crypto";
1
+ import { redact } from "../../abelien-harness/scripts/web/redact.mjs";
2
2
  import { text } from "./web-common.mjs";
3
3
 
4
- // Four numbers describe a whole unchanged run or one decoded unit:
5
- // output start/end, original start/end. No object is allocated per character.
6
- function appendSpan(spans, start, end, originalStart, originalEnd) {
7
- if (start === end) return;
8
- const last = spans.length - 4;
9
- if (last >= 0 && spans[last + 1] === start && spans[last + 3] === originalStart &&
10
- spans[last + 1] - spans[last] === spans[last + 3] - spans[last + 2] &&
11
- end - start === originalEnd - originalStart) {
12
- spans[last + 1] = end; spans[last + 3] = originalEnd; return;
13
- }
14
- spans.push(start, end, originalStart, originalEnd);
15
- }
16
- function spanAt(spans, index) {
17
- let low = 0, high = spans.length / 4 - 1;
18
- while (low <= high) {
19
- const middle = (low + high) >>> 1, offset = middle * 4;
20
- if (index < spans[offset]) high = middle - 1;
21
- else if (index >= spans[offset + 1]) low = middle + 1;
22
- else return offset;
23
- }
24
- throw new Error("Redaction span missing");
25
- }
26
- function originalRange(projection, start, end) {
27
- const spans = projection.spans, first = spanAt(spans, start), last = spanAt(spans, end - 1);
28
- const from = spans[first + 1] - spans[first] === spans[first + 3] - spans[first + 2]
29
- ? spans[first + 2] + start - spans[first] : spans[first + 2];
30
- const to = spans[last + 1] - spans[last] === spans[last + 3] - spans[last + 2]
31
- ? spans[last + 2] + end - spans[last] : spans[last + 3];
32
- return [from, to];
33
- }
34
- function copyRange(projection, start, end, spans, output) {
35
- if (start === end) return output;
36
- const source = projection.spans;
37
- for (let offset = spanAt(source, start); start < end; offset += 4) {
38
- const stop = Math.min(end, source[offset + 1]);
39
- const linear = source[offset + 1] - source[offset] === source[offset + 3] - source[offset + 2];
40
- const from = linear ? source[offset + 2] + start - source[offset] : source[offset + 2];
41
- const to = linear ? source[offset + 2] + stop - source[offset] : source[offset + 3];
42
- appendSpan(spans, output, output + stop - start, from, to);
43
- output += stop - start; start = stop;
44
- }
45
- return output;
46
- }
47
- function percentLayer(projection) {
48
- const value = projection.value, chunks = [], spans = [];
49
- let cursor = 0, output = 0, changed = false;
50
- for (let index = value.indexOf("%"); index !== -1; index = value.indexOf("%", index + 1)) {
51
- let encoded = "", decoded = null, size = 0;
52
- // UTF-8 has at most four bytes per scalar; malformed units stay intact.
53
- for (let bytes = 1; bytes <= 4; bytes++) {
54
- const part = value.slice(index + (bytes - 1) * 3, index + bytes * 3);
55
- if (!/^%[0-9a-f]{2}$/i.test(part)) break;
56
- encoded += part;
57
- try { decoded = decodeURIComponent(encoded); size = bytes * 3; break; } catch { /* Try this scalar's remaining bytes. */ }
58
- }
59
- if (decoded === null) continue;
60
- chunks.push(value.slice(cursor, index), decoded);
61
- output = copyRange(projection, cursor, index, spans, output);
62
- const [from, to] = originalRange(projection, index, index + size);
63
- appendSpan(spans, output, output + decoded.length, from, to); output += decoded.length;
64
- cursor = index + size; index = cursor - 1; changed = true;
65
- }
66
- if (!changed) return null;
67
- chunks.push(value.slice(cursor)); copyRange(projection, cursor, value.length, spans, output);
68
- return { value: chunks.join(""), spans };
69
- }
70
- function* jsonChildren(projection) {
71
- const value = projection.value, tokenPattern = /"(?:\\.|[^"\\])*"/y;
72
- let coveredUntil = -1;
73
- // Every possible start remains eligible, including a preceding token's closing
74
- // quote. Interior escaped starts in a valid decoded child are already covered
75
- // by that child's traversal; avoid materializing all of its overlapping suffixes.
76
- for (let start = value.indexOf('"'); start !== -1; start = value.indexOf('"', start + 1)) {
77
- if (start < coveredUntil) continue;
78
- tokenPattern.lastIndex = start;
79
- const match = tokenPattern.exec(value); if (!match) continue;
80
- const token = match[0], end = start + token.length - 1;
81
- // Removing quotes without decoding changes neither matches nor percent units.
82
- // The parent already covers that identity projection.
83
- if (!token.includes("\\")) continue;
84
- let decoded; try { decoded = JSON.parse(token); } catch { continue; }
85
- coveredUntil = end;
86
- const spans = []; let cursor = start + 1, output = 0;
87
- for (let escape = value.indexOf("\\", cursor); escape !== -1 && escape < end; escape = value.indexOf("\\", cursor)) {
88
- output = copyRange(projection, cursor, escape, spans, output);
89
- const size = value[escape + 1] === "u" ? 6 : 2;
90
- const unit = JSON.parse('"' + value.slice(escape, escape + size) + '"');
91
- const [from, to] = originalRange(projection, escape, escape + size);
92
- appendSpan(spans, output, output + unit.length, from, to); output += unit.length;
93
- cursor = escape + size;
94
- }
95
- copyRange(projection, cursor, end, spans, output);
96
- if (decoded.length) yield { value: decoded, spans };
97
- }
98
- }
99
- function identity(projection) {
100
- // Fixed-size memo entries, with UTF-16 preserving even lone-surrogate spelling.
101
- const spans = Float64Array.from(projection.spans);
102
- return createHash("sha256").update(String(projection.value.length) + ":").update(Buffer.from(projection.value, "utf16le"))
103
- .update(Buffer.from(spans.buffer)).digest("hex");
104
- }
105
- function redact(raw, keys) {
106
- if (!keys.length) return raw;
107
- if (!raw.includes("%") && !raw.includes("\\")) return keys.reduce((value, key) => value.split(key).join("[redacted]"), raw);
108
- const ranges = [], seen = new Set(), stack = [{ projection: { value: raw, spans: raw.length ? [0, raw.length, 0, raw.length] : [] } }];
109
- while (stack.length) {
110
- const frame = stack[stack.length - 1], projection = frame.projection;
111
- if (!frame.children) {
112
- const memo = identity(projection); if (seen.has(memo)) { stack.pop(); continue; } seen.add(memo);
113
- for (const key of keys) for (let index = projection.value.indexOf(key); index !== -1; index = projection.value.indexOf(key, index + 1)) {
114
- const [start, end] = originalRange(projection, index, index + key.length); ranges.push({ start, end });
115
- }
116
- frame.children = jsonChildren(projection);
117
- }
118
- const next = frame.children.next();
119
- if (!next.done) { stack.push({ projection: next.value }); continue; }
120
- // A percent layer is a tail transition, releasing its finished parent instead
121
- // of retaining an eager queue of full bodies. JSON children are visited lazily.
122
- const percent = percentLayer(projection); stack.pop(); if (percent) stack.push({ projection: percent });
123
- }
124
- let result = "", start = 0, end = 0;
125
- for (const range of ranges.sort((a, b) => a.start - b.start || b.end - a.end)) {
126
- if (range.start < end) { end = Math.max(end, range.end); continue; }
127
- if (end > start) { result += "[redacted]"; start = end; }
128
- result += raw.slice(start, range.start); start = range.start; end = range.end;
129
- }
130
- if (end > start) { result += "[redacted]"; start = end; }
131
- return result + raw.slice(start);
132
- }
133
4
  export function redactHardwareResponse(raw, env = {}) {
134
5
  const keys = [...new Set([env.PRISMER_API_KEY, env.WEB_SEARCH_API_KEY].filter(text))].sort((a, b) => b.length - a.length);
135
6
  return redact(raw, keys);