abelien-harness 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/.env.example +1 -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 +4 -4
  11. package/skills/abelien-hardware/domain.json +1 -1
  12. package/skills/abelien-hardware/references/search-index-format.md +4 -2
  13. package/skills/abelien-hardware/references/web-evidence-contract.md +3 -3
  14. package/skills/abelien-hardware/scripts/dimensions.mjs +23 -8
  15. package/skills/abelien-hardware/scripts/electrical.mjs +62 -19
  16. package/skills/abelien-hardware/scripts/finalize-selection.mjs +3 -5
  17. package/skills/abelien-hardware/scripts/provider-chain-contract.mjs +17 -0
  18. package/skills/abelien-hardware/scripts/public-page.mjs +11 -121
  19. package/skills/abelien-hardware/scripts/response-redaction.mjs +1 -130
  20. package/skills/abelien-hardware/scripts/selection-request.mjs +6 -5
  21. package/skills/abelien-hardware/scripts/tos-wiki-search.mjs +9 -6
  22. package/skills/abelien-hardware/scripts/volc-web-search.mjs +10 -32
  23. package/skills/abelien-hardware/scripts/web-common.mjs +4 -10
  24. package/skills/abelien-hardware/scripts/web-integrity.mjs +1 -1
  25. package/skills/abelien-hardware/scripts/web-provider-chain.mjs +2 -1
  26. package/skills/abelien-hardware/scripts/web-receipt.mjs +4 -2
  27. package/skills/abelien-hardware/scripts/web-search-plan.mjs +5 -1
  28. package/skills/abelien-hardware/scripts/web-search.mjs +4 -3
  29. package/skills/abelien-hardware-selection/domain.json +2 -2
  30. package/skills/abelien-hardware-selection/references/selection-service-contract.md +18 -10
  31. package/skills/abelien-hardware-selection/scripts/select.mjs +13 -5
  32. package/skills/abelien-harness/scripts/ab.mjs +24 -2
  33. package/skills/abelien-harness/scripts/web/common.mjs +20 -0
  34. package/skills/abelien-harness/scripts/web/public-read.mjs +168 -0
  35. package/skills/abelien-harness/scripts/web/redact.mjs +136 -0
  36. package/skills/abelien-harness/scripts/web/volc.mjs +49 -0
  37. package/skills/abelien-reference/SKILL.md +59 -0
  38. package/skills/abelien-reference/domain.json +92 -0
  39. package/skills/abelien-reference/fixtures/create.final.json +104 -0
  40. package/skills/abelien-reference/fixtures/restore.collected.json +191 -0
  41. package/skills/abelien-reference/fixtures/restore.final.json +269 -0
  42. package/skills/abelien-reference/references/research-contract.md +78 -0
  43. package/skills/abelien-reference/schemas/reference-evidence.v1.json +153 -0
  44. package/skills/abelien-reference/scripts/evidence.mjs +68 -0
  45. package/skills/abelien-reference/scripts/finalize.mjs +116 -0
  46. package/skills/abelien-reference/scripts/measure.mjs +62 -0
  47. package/skills/abelien-reference/scripts/png.mjs +86 -0
  48. package/skills/abelien-reference/scripts/research.mjs +191 -0
  49. package/skills/abelien-structure/SKILL.md +2 -2
  50. package/skills/abelien-structure/domain.json +64 -0
  51. package/skills/abelien-structure/references/build123d-execution.md +1 -1
  52. package/skills/abelien-structure/references/handoff.md +1 -1
  53. package/skills/abelien-structure/worker/abelien_cad.py +585 -0
  54. package/skills/abelien-structure/worker/assembly-validation.mjs +106 -0
  55. package/skills/abelien-structure/worker/cad.py +1623 -0
  56. package/skills/abelien-structure/worker/cli-adapter.mjs +207 -0
  57. package/skills/abelien-structure/worker/component-fit-glb.mjs +173 -0
  58. package/skills/abelien-structure/worker/component-fit.d.mts +82 -0
  59. package/skills/abelien-structure/worker/component-fit.mjs +167 -0
  60. package/skills/abelien-structure/worker/component-target-image.d.mts +23 -0
  61. package/skills/abelien-structure/worker/component-target-image.mjs +107 -0
  62. package/skills/abelien-structure/worker/component-target-render.py +58 -0
  63. package/skills/abelien-structure/worker/config.mjs +6 -0
  64. package/skills/abelien-structure/worker/engineering-fit.d.mts +48 -0
  65. package/skills/abelien-structure/worker/engineering-fit.mjs +118 -0
  66. package/skills/abelien-structure/worker/engineering-policy.json +16 -0
  67. package/skills/abelien-structure/worker/engineering-repair.py +147 -0
  68. package/skills/abelien-structure/worker/engineering.py +324 -0
  69. package/skills/abelien-structure/worker/identity.mjs +98 -0
  70. package/skills/abelien-structure/worker/inspection-geometry.mjs +71 -0
  71. package/skills/abelien-structure/worker/jobs.mjs +451 -0
  72. package/skills/abelien-structure/worker/library-runtime.mjs +15 -0
  73. package/skills/abelien-structure/worker/main.mjs +102 -0
  74. package/skills/abelien-structure/worker/manifest-digest.mjs +11 -0
  75. package/skills/abelien-structure/worker/materials.mjs +52 -0
  76. package/skills/abelien-structure/worker/native-launcher.py +13 -0
  77. package/skills/abelien-structure/worker/native.mjs +157 -0
  78. package/skills/abelien-structure/worker/representation.mjs +47 -0
  79. package/skills/abelien-structure/worker/sandbox.mjs +41 -0
  80. package/skills/abelien-structure/worker/source-contract.mjs +143 -0
  81. package/skills/abelien-structure/worker/source_call_stats.py +59 -0
  82. package/skills/abelien-structure/worker/supervisor.mjs +38 -0
  83. package/skills/abelien-workspace/SKILL.md +11 -0
  84. package/skills/abelien-workspace/assets/journey-stage-plan.json +155 -0
  85. package/skills/abelien-workspace/domain.json +25 -2
  86. package/skills/abelien-workspace/references/hardware-journey-orchestration.md +1 -1
  87. package/skills/abelien-workspace/references/harness-binding.md +2 -2
  88. package/skills/abelien-workspace/references/user-journey.md +10 -0
  89. package/skills/abelien-workspace/scripts/journey-next.mjs +38 -0
  90. package/skills/abelien-workspace/workflows/definitions/399ef372790b7330d0ef88ce5096e7f69943d4793de97b2ed6c25165a332612f.json +230 -0
  91. package/skills/abelien-workspace/workflows/definitions/591a7f21e0b0ad586e340163f7533ac910356091ba8d2a26e250c0acbc80cd8b.json +176 -0
  92. package/skills/abelien-workspace/workflows/definitions/d9f8c6cdf0b2c41cdeb1c72ac5d1e37d95a7caac3272d434a3c7e8483fc565de.json +150 -0
  93. package/skills/abelien-workspace/workflows/user-journey-hardware-algorithm.json +3 -2
  94. package/skills/abelien-workspace/workflows/user-journey-hardware-candidates.json +3 -2
  95. package/skills/abelien-workspace/workflows/user-journey-hardware.json +3 -2
@@ -1,5 +1,6 @@
1
1
  // Deterministic electrical compatibility for a selected set of parts (star topology, v1).
2
- // Only measured/datasheet records can prove pass, fail or a supply gap; anything else stays unknown. This is not a circuit simulation.
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.
3
4
  import { digest } from './selection-request.mjs';
4
5
  const object = v => v !== null && typeof v === 'object' && !Array.isArray(v);
5
6
  const positive = v => typeof v === 'number' && Number.isFinite(v) && v > 0;
@@ -62,23 +63,54 @@ const trusted = part => TRUSTED.includes(part.electrical?.basis);
62
63
  const ref = part => ({ slot_id: part.slot_id, component_id: part.component_id });
63
64
 
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.
65
- function supply(consumer, sources, rule, dimension) {
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) {
66
69
  const parts = [consumer, ...sources].map(ref), inputs = consumer.electrical?.power_inputs ?? [];
67
- if (!trusted(consumer) || !inputs.length) return { rule, dimension, status: 'unknown', parts, reason: trusted(consumer) ? 'NO_DECLARED_INPUT' : 'UNTRUSTED_OR_MISSING_DATA' };
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' };
68
73
  const rails = sources.filter(trusted).flatMap(source => (source.electrical.power_outputs ?? []).map(output => ({ source, output })));
69
- const fit = rails.find(({ output }) => inputs.some(input => within(output.voltage_v, input.voltage_v)));
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);
70
77
  if (fit) {
71
78
  const input = inputs.find(row => within(fit.output.voltage_v, row.voltage_v));
72
79
  const connectorRisk = Boolean(input.connector && fit.output.connector && input.connector !== fit.output.connector);
73
- return { rule, dimension, status: connectorRisk ? 'risk' : 'pass', parts, reason: connectorRisk ? 'CONNECTOR_MISMATCH' : 'RAIL_WITHIN_INPUT',
80
+ const check = { rule, dimension, status: connectorRisk ? 'risk' : 'pass', parts, reason: connectorRisk ? 'CONNECTOR_MISMATCH' : 'RAIL_WITHIN_INPUT',
74
81
  rail: { component_id: fit.source.component_id, id: fit.output.id, voltage_v: fit.output.voltage_v, connector: fit.output.connector ?? null },
75
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;
76
86
  }
77
87
  if (sources.some(source => !trusted(source))) return { rule, dimension, status: 'unknown', parts, reason: 'UNTRUSTED_SOURCE_MAY_SUPPLY' };
78
88
  const partial = rails.some(({ output }) => inputs.some(input => overlaps(output.voltage_v, input.voltage_v)));
79
89
  return { rule, dimension, status: partial ? 'risk' : 'gap', parts, reason: partial ? 'RAIL_PARTIALLY_OUTSIDE_INPUT' : 'NO_SUPPLY_RAIL_FOR_INPUT' };
80
90
  }
81
- // fail (incompatible part) > gap (missing part) > pass (all checks pass on trusted data) > risk > unknown.
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.
82
114
  const aggregate = (checks, dimension, members) => {
83
115
  const rows = checks.filter(check => check.dimension === dimension);
84
116
  for (const status of ['fail', 'gap']) if (rows.some(check => check.status === status)) return status;
@@ -90,33 +122,44 @@ export function evaluateElectrical(parts) {
90
122
  const method = 'electrical-star-v1', controllers = parts.filter(isController);
91
123
  if (controllers.length !== 1) return { method, topology: 'not_star', dimensions: { power: 'unknown', interfaces: 'unknown' }, checks: [],
92
124
  reasons: [{ code: controllers.length ? 'MULTIPLE_CONTROLLERS' : 'NO_CONTROLLER', controllers: controllers.map(ref) }] };
93
- const [controller] = controllers, sources = parts.filter(isPower), shifter = parts.some(isLevelShifter);
125
+ const [controller] = controllers, sources = parts.filter(isPower), shifters = parts.filter(isLevelShifter);
94
126
  const peripherals = parts.filter(part => part !== controller && !isPower(part) && !isLevelShifter(part));
95
- const checks = [];
96
- // Rule 1: a selected supply must power the controller. Without one the controller is powered from outside the selection.
97
- if (sources.length) checks.push(supply(controller, sources, 'controller_supply', 'power'));
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' });
98
131
  // Rule 2: each peripheral needs a controller rail or a selected supply rail inside one of its input ranges.
99
- for (const part of peripherals) if (part.electrical?.power_inputs?.length || !trusted(part)) checks.push(supply(part, [controller, ...sources], 'peripheral_supply', 'power'));
100
- // Rule 3: bus types the peripherals need must exist on the controller; point-to-point counts must also fit.
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.
101
136
  const need = new Map();
102
137
  for (const part of peripherals) for (const bus of part.electrical?.buses ?? []) if (bus.role === 'peripheral')
103
138
  need.set(bus.type, [...(need.get(bus.type) ?? []), { part, bus, count: bus.count * (part.quantity ?? 1) }]);
104
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);
105
141
  for (const [type, rows] of need) {
106
142
  const parts2 = [ref(controller), ...rows.map(row => ref(row.part))];
107
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; }
108
- const offered = (controller.electrical.buses ?? []).filter(bus => bus.type === type && bus.role === 'controller');
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');
109
146
  const available = offered.reduce((sum, bus) => sum + bus.count, 0);
110
- const required = SHARED_BUSES.includes(type) ? 1 : rows.reduce((sum, row) => sum + row.count, 0);
147
+ const devices = rows.reduce((sum, row) => sum + row.count, 0);
148
+ const required = SHARED_BUSES.includes(type) ? 1 : devices;
111
149
  checks.push({ rule: 'bus_availability', dimension: 'interfaces', bus: type, parts: parts2, required, available,
112
- status: available >= required ? 'pass' : 'fail', reason: !available ? 'BUS_TYPE_MISSING' : available < required ? 'BUS_COUNT_SHORT' : 'BUS_AVAILABLE' });
113
- // Rule 4: a logic-level mismatch is only a risk in v1; cables and level shifters can bridge it.
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.
114
155
  for (const row of rows) {
115
156
  const level = offered.find(bus => bus.logic_level_v !== undefined)?.logic_level_v;
116
- if (level === undefined || row.bus.logic_level_v === undefined) continue;
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; }
117
160
  const mismatch = Math.abs(level - row.bus.logic_level_v) > 1e-9;
118
- checks.push({ rule: 'logic_level', dimension: 'interfaces', bus: type, parts: [ref(controller), ref(row.part)], controller_v: level, peripheral_v: row.bus.logic_level_v,
119
- status: mismatch && !shifter ? 'risk' : 'pass', reason: !mismatch ? 'LOGIC_LEVEL_MATCH' : shifter ? 'LEVEL_SHIFTER_SELECTED' : 'LOGIC_LEVEL_MISMATCH' });
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' });
120
163
  }
121
164
  }
122
165
  const powerMembers = [controller, ...sources, ...peripherals.filter(part => checks.some(check => check.dimension === 'power' && check.parts.some(row => row.component_id === part.component_id)))];
@@ -1,6 +1,6 @@
1
1
  import crypto from "node:crypto";
2
2
  import { assemblyOccupancy, dimensionFields, evaluateDimensions } from "./dimensions.mjs";
3
- import { electricalFields, evaluateElectrical } from "./electrical.mjs";
3
+ import { electricalFields } from "./electrical.mjs";
4
4
  import { selectionRequestDigest } from "./selection-request.mjs";
5
5
 
6
6
  function error(message, details) {
@@ -75,7 +75,7 @@ export async function finalizeHardwareSelection({ input }) {
75
75
  slot_id: selected.slot_id, component_id: selected.component_id,
76
76
  });
77
77
  }
78
- const dimensionCheck = evaluateDimensions(candidate, input.requirements);
78
+ const dimensionCheck = evaluateDimensions(candidate, input.requirements, (input.slots ?? []).find(slot => slot?.slot_id === selected.slot_id));
79
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 });
80
80
  return {
81
81
  name: String(candidate.title ?? candidate.name ?? candidate.model ?? candidate.component_id),
@@ -95,9 +95,7 @@ export async function finalizeHardwareSelection({ input }) {
95
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 } : {}),
96
96
  };
97
97
  });
98
- // Trusted data proving a part cannot connect means one side must be replaced; a missing supply rail is a gap for select to report.
99
- const failed = selectedModels.length > 1 ? evaluateElectrical(selectedModels).checks.filter(check => check.status === "fail") : [];
100
- if (failed.length) throw error("selected components are electrically incompatible; replace one side of each failed check", { electrical_checks: failed });
98
+ // Electrical checks never reject here: star-topology bus shortfalls and supply gaps are reported by hardware.select for confirmation.
101
99
  const stable = {
102
100
  requirements: input.requirements ?? {}, slots: input.slots ?? [], source_snapshot: recall.source_snapshot,
103
101
  proposal: { ...proposal, selected_models: selectedModels },
@@ -16,6 +16,22 @@ export const missingKey = attempt => attempt?.status === "unavailable" && attemp
16
16
  // A Prismer attempt skipped by the trusted host switch is recorded unsent with no receipt;
17
17
  // it authorizes the same group's Volc query or direct source read.
18
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
+ }
19
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);
20
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);
21
37
  export function expectedSelection(group, query) {
@@ -93,6 +109,7 @@ export function validateChainIntegrity({ original, source, saved, allowPartial =
93
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)}`;
94
110
  try {
95
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");
96
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");
97
114
  for (const entry of body.searches) {
98
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);
@@ -1,8 +1,7 @@
1
- import { createHash } from "node:crypto";
2
- export const canonical = value => value === null || typeof value !== "object" ? JSON.stringify(value)
3
- : Array.isArray(value) ? `[${value.map(canonical).join(",")}]`
4
- : `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`;
5
- export const digest = value => createHash("sha256").update(canonical(value)).digest("hex");
1
+ // One canonical/digest for every evidence hash: the kernel writes Volc receipts with it, so verification must use the same function.
2
+ import { canonical, digest } from "../../abelien-harness/scripts/web/common.mjs";
3
+ import { validateFitEnvelope } from "./dimensions.mjs";
4
+ export { canonical, digest };
6
5
  // Bind recall to complete requirements and slots, never an Agent summary.
7
6
  export const selectionRequestDigest = input => digest({ requirements: input.requirements ?? {}, slots: input.slots ?? [] });
8
7
 
@@ -19,6 +18,8 @@ export function validateSelectionSlots(slots) {
19
18
  || !validCategoryPath(slot.category_path)
20
19
  || !slot.hard_constraints || typeof slot.hard_constraints !== "object" || Array.isArray(slot.hard_constraints))
21
20
  invalid(`slots[${index}] needs slot_id, function, explicit integer quantity, three nonempty category levels (no null), and hard_constraints object; read level1/level2 guides before correcting the reply`);
21
+ // Optional structure zone budget (P2-06.03); its absence keeps the original behavior.
22
+ if (slot.fit_envelope !== undefined) validateFitEnvelope(slot.fit_envelope);
22
23
  }
23
24
  return slots;
24
25
  }
@@ -1,5 +1,5 @@
1
1
  import zlib from "node:zlib";
2
- import { dimensionFields, evaluateDimensions, orderByDimensionTier, sameDimensionMetadata, screenDimensions, shortlistByDimensionTier, validateRequirementEnvelopes } from "./dimensions.mjs";
2
+ import { dimensionFields, evaluateDimensions, orderByDimensionTier, sameDimensionMetadata, screenDimensions, shortlistByDimensionTier, validateFitEnvelope, validateRequirementEnvelopes } from "./dimensions.mjs";
3
3
  import { electricalFields } from "./electrical.mjs";
4
4
  import { selectionRequestDigest, validCategoryPath } from "./selection-request.mjs";
5
5
  import { createTosConfig, getTosObject, sha256 } from "./tos-object-store.mjs";
@@ -87,8 +87,10 @@ export function parseHardwareWiki(content) {
87
87
  ];
88
88
  const meta = {};
89
89
  for (const key of wanted) {
90
- const line = frontMatter.match(new RegExp(`^${key}:\\s*(.*)$`, "m"));
91
- if (line) {
90
+ // Same-line value only: \s* would cross the newline and read the next line as this key's value.
91
+ // An empty value (including YAML block style) is treated as absent, as before these keys were read.
92
+ const line = frontMatter.match(new RegExp(`^${key}:[ \\t]*(.*)$`, "m"));
93
+ if (line && line[1].trim()) {
92
94
  if (["dimension_source", "dimensions_mm", "electrical", "electrical_source"].includes(key)) {
93
95
  try { meta[key] = JSON.parse(line[1]); }
94
96
  catch { throw error("WIKI_INVALID", `${key} must be generated inline JSON`); }
@@ -235,6 +237,7 @@ function validateInput(input) {
235
237
  if (!slot?.slot_id || !validCategoryPath(slot.category_path)) {
236
238
  throw error("VALIDATION", "each hardware search slot needs slot_id and three nonempty category_path levels (no null)");
237
239
  }
240
+ if (slot.fit_envelope !== undefined) validateFitEnvelope(slot.fit_envelope);
238
241
  }
239
242
  }
240
243
 
@@ -349,7 +352,7 @@ async function searchWithCategoryIndexes({ input, env, config, catalogObject, fe
349
352
  }
350
353
  const index = await loadIndex(descriptor);
351
354
  const semanticQuery = semanticQueryFor(input, slot);
352
- const screen = screenDimensions(index.records, input.requirements);
355
+ const screen = screenDimensions(index.records, input.requirements, slot);
353
356
  const rankedIndex = screen.survivors.map(({ record: entry, check }) => ({
354
357
  entry, check,
355
358
  ranking: scoreIndexEntry(entry, semanticQuery, input.policy?.prefer_system_candidates !== false),
@@ -361,7 +364,7 @@ async function searchWithCategoryIndexes({ input, env, config, catalogObject, fe
361
364
  const candidates = orderByDimensionTier(loaded.map(({ document, indexRanking }) => {
362
365
  const ranking = scoreDocument(document, semanticQuery, input.policy?.prefer_system_candidates !== false);
363
366
  const candidate = candidateFromDocument(document, config, ranking, includeContent, indexRanking.score);
364
- return { ...candidate, dimension_check: evaluateDimensions(candidate, input.requirements) };
367
+ return { ...candidate, dimension_check: evaluateDimensions(candidate, input.requirements, slot) };
365
368
  }).sort((a, b) => b.semantic_score - a.semantic_score || b.index_score - a.index_score || String(a.model).localeCompare(String(b.model))), candidate => candidate.dimension_check.status);
366
369
  if (candidates.length === 0) unmatchedSlots.push(slot.slot_id);
367
370
  results.push({
@@ -407,7 +410,7 @@ async function searchWithLegacyManifest({ input, env, config, fetchImpl, topK, m
407
410
  const candidates = shortlistByDimensionTier(loaded.map(document => {
408
411
  const ranking = scoreDocument(document, semanticQuery, input.policy?.prefer_system_candidates !== false);
409
412
  const candidate = candidateFromDocument(document, config, ranking, includeContent, ranking.score);
410
- return { ...candidate, dimension_check: evaluateDimensions(candidate, input.requirements) };
413
+ return { ...candidate, dimension_check: evaluateDimensions(candidate, input.requirements, slot) };
411
414
  }).filter(candidate => candidate.dimension_check.status !== "incompatible").sort((a, b) => b.semantic_score - a.semantic_score || String(a.model).localeCompare(String(b.model))), topK, maxFetch, candidate => candidate.dimension_check.status);
412
415
  if (candidates.length === 0) unmatchedSlots.push(slot.slot_id);
413
416
  results.push({
@@ -1,33 +1,11 @@
1
- import { failure, text, timed } from "./web-common.mjs";
2
- import { sha256 } from "./public-page.mjs";
3
- import { digest } from "./selection-request.mjs";
1
+ // Hardware binding of the kernel Volc client (P2-13.03). Codes, messages and receipts are unchanged.
2
+ import { createVolcSearch } from "../../abelien-harness/scripts/web/volc.mjs";
4
3
  import { redactHardwareResponse } from "./response-redaction.mjs";
5
- import { receiptRequestId, validVolcResult } from "./web-receipt.mjs";
6
- // Plans cap count at 5 (P2-05.02). Evidence saved by <=0.4.0 used the old default of 10;
7
- // provider-chain-contract still accepts that frozen shape when validating saved receipts.
8
- export const volcMaxCount = 5;
9
- const volcBody = (item, count) => ({ Query: item.query, SearchType: "web", Count: count, Filter: { NeedUrl: true, NeedContent: false }, ContentFormats: "markdown", QueryControl: { QueryRewrite: false } });
10
- export const volcQueryRequest = item => volcBody(item, item.count ?? volcMaxCount);
11
- export const legacyVolcQueryRequest = item => volcBody(item, item.count ?? 10);
12
- export function validateVolcCapability(body) {
13
- if (!text(body.Query) || !Number.isSafeInteger(body.Count) || body.Count < 1 || body.Count > 50) throw failure("HARDWARE_WEB_CAPABILITY", "Volc Query must be nonempty and Count 1–50; original input is retained without truncation");
14
- }
15
- export async function volcWebSearch(body, { env, fetchImpl = fetch, timeoutMs }) {
16
- validateVolcCapability(body);
17
- const key = env.WEB_SEARCH_API_KEY;
18
- if (!text(key) || /[\r\n\0]/.test(key)) throw failure("HARDWARE_WEB_CONFIG", "WEB_SEARCH_API_KEY is required from the trusted host");
19
- try {
20
- return await timed(async signal => {
21
- const response = await fetchImpl("https://open.feedcoopapi.com/search_api/web_search", { method: "POST", redirect: "error", signal, headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" }, body: JSON.stringify(body) });
22
- const raw = redactHardwareResponse(await response.text(), env), saved = { raw, raw_sha256: sha256(raw), response: null, response_sha256: null, request_id: null, http_status: response.status, received_at: new Date().toISOString() };
23
- try { saved.response = JSON.parse(raw); saved.response_sha256 = digest(saved.response); saved.request_id = receiptRequestId({ ...saved, provider: "volcengine" }); } catch { /* Keep full received text. */ }
24
- const reject = (code, message, unknown = false) => { const error = failure(code, message, { http_status: response.status }); Object.defineProperties(error, { providerEvidence: { value: saved }, ambiguous: { value: unknown } }); throw error; };
25
- if (response.status >= 500) reject("HARDWARE_WEB_PROVIDER", "Volc server outcome is unknown", true);
26
- if (response.status < 200 || response.status >= 300) reject([401, 403].includes(response.status) ? "HARDWARE_WEB_AUTH" : response.status === 429 ? "HARDWARE_WEB_RATE_LIMIT" : "HARDWARE_WEB_PROVIDER", "Volc HTTP request rejected");
27
- const error = saved.response?.ResponseMetadata?.Error;
28
- if (error) reject("HARDWARE_WEB_PROVIDER", "Volc reported an explicit error", !/^(104\d\d|700429)$/.test(String(error.Code ?? error.CodeN)));
29
- if (!validVolcResult(saved.response)) reject("HARDWARE_WEB_RESPONSE", "Volc result is malformed or ambiguous", true);
30
- return saved;
31
- }, timeoutMs);
32
- } catch (error) { if (error?.code?.startsWith("HARDWARE_WEB_")) throw error; throw failure("HARDWARE_WEB_TRANSPORT", "Volc transport outcome is unknown"); }
33
- }
4
+ const volc = createVolcSearch({
5
+ codes: { capability: "HARDWARE_WEB_CAPABILITY", config: "HARDWARE_WEB_CONFIG", provider: "HARDWARE_WEB_PROVIDER", auth: "HARDWARE_WEB_AUTH",
6
+ rateLimit: "HARDWARE_WEB_RATE_LIMIT", response: "HARDWARE_WEB_RESPONSE", transport: "HARDWARE_WEB_TRANSPORT", timeout: "HARDWARE_WEB_TIMEOUT" },
7
+ ownsError: error => Boolean(error?.code?.startsWith("HARDWARE_WEB_")),
8
+ redact: redactHardwareResponse,
9
+ timeoutMessage: "hardware network request timed out",
10
+ });
11
+ export const { volcMaxCount, volcQueryRequest, legacyVolcQueryRequest, validateVolcCapability, volcWebSearch } = volc;