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.
- package/.env.example +1 -0
- package/README.md +11 -18
- package/package.json +2 -3
- package/skills/abelien-appearance/SKILL.md +1 -0
- package/skills/abelien-clarify/SKILL.md +12 -2
- package/skills/abelien-clarify/references/examples.md +22 -0
- package/skills/abelien-clarify/scripts/check_result.py +38 -3
- package/skills/abelien-clarify/scripts/test_check_result.py +32 -1
- package/skills/abelien-guide/SKILL.md +1 -1
- package/skills/abelien-hardware/SKILL.md +4 -4
- package/skills/abelien-hardware/domain.json +1 -1
- package/skills/abelien-hardware/references/search-index-format.md +4 -2
- package/skills/abelien-hardware/references/web-evidence-contract.md +3 -3
- package/skills/abelien-hardware/scripts/dimensions.mjs +23 -8
- package/skills/abelien-hardware/scripts/electrical.mjs +62 -19
- package/skills/abelien-hardware/scripts/finalize-selection.mjs +3 -5
- package/skills/abelien-hardware/scripts/provider-chain-contract.mjs +17 -0
- package/skills/abelien-hardware/scripts/public-page.mjs +11 -121
- package/skills/abelien-hardware/scripts/response-redaction.mjs +1 -130
- package/skills/abelien-hardware/scripts/selection-request.mjs +6 -5
- package/skills/abelien-hardware/scripts/tos-wiki-search.mjs +9 -6
- package/skills/abelien-hardware/scripts/volc-web-search.mjs +10 -32
- package/skills/abelien-hardware/scripts/web-common.mjs +4 -10
- package/skills/abelien-hardware/scripts/web-integrity.mjs +1 -1
- package/skills/abelien-hardware/scripts/web-provider-chain.mjs +2 -1
- package/skills/abelien-hardware/scripts/web-receipt.mjs +4 -2
- package/skills/abelien-hardware/scripts/web-search-plan.mjs +5 -1
- package/skills/abelien-hardware/scripts/web-search.mjs +4 -3
- package/skills/abelien-hardware-selection/domain.json +2 -2
- package/skills/abelien-hardware-selection/references/selection-service-contract.md +18 -10
- package/skills/abelien-hardware-selection/scripts/select.mjs +13 -5
- package/skills/abelien-harness/scripts/ab.mjs +24 -2
- package/skills/abelien-harness/scripts/web/common.mjs +20 -0
- package/skills/abelien-harness/scripts/web/public-read.mjs +168 -0
- package/skills/abelien-harness/scripts/web/redact.mjs +136 -0
- package/skills/abelien-harness/scripts/web/volc.mjs +49 -0
- package/skills/abelien-reference/SKILL.md +59 -0
- package/skills/abelien-reference/domain.json +92 -0
- package/skills/abelien-reference/fixtures/create.final.json +104 -0
- package/skills/abelien-reference/fixtures/restore.collected.json +191 -0
- package/skills/abelien-reference/fixtures/restore.final.json +269 -0
- package/skills/abelien-reference/references/research-contract.md +78 -0
- package/skills/abelien-reference/schemas/reference-evidence.v1.json +153 -0
- package/skills/abelien-reference/scripts/evidence.mjs +68 -0
- package/skills/abelien-reference/scripts/finalize.mjs +116 -0
- package/skills/abelien-reference/scripts/measure.mjs +62 -0
- package/skills/abelien-reference/scripts/png.mjs +86 -0
- package/skills/abelien-reference/scripts/research.mjs +191 -0
- package/skills/abelien-structure/SKILL.md +2 -2
- package/skills/abelien-structure/domain.json +64 -0
- package/skills/abelien-structure/references/build123d-execution.md +1 -1
- package/skills/abelien-structure/references/handoff.md +1 -1
- package/skills/abelien-structure/worker/abelien_cad.py +585 -0
- package/skills/abelien-structure/worker/assembly-validation.mjs +106 -0
- package/skills/abelien-structure/worker/cad.py +1623 -0
- package/skills/abelien-structure/worker/cli-adapter.mjs +207 -0
- package/skills/abelien-structure/worker/component-fit-glb.mjs +173 -0
- package/skills/abelien-structure/worker/component-fit.d.mts +82 -0
- package/skills/abelien-structure/worker/component-fit.mjs +167 -0
- package/skills/abelien-structure/worker/component-target-image.d.mts +23 -0
- package/skills/abelien-structure/worker/component-target-image.mjs +107 -0
- package/skills/abelien-structure/worker/component-target-render.py +58 -0
- package/skills/abelien-structure/worker/config.mjs +6 -0
- package/skills/abelien-structure/worker/engineering-fit.d.mts +48 -0
- package/skills/abelien-structure/worker/engineering-fit.mjs +118 -0
- package/skills/abelien-structure/worker/engineering-policy.json +16 -0
- package/skills/abelien-structure/worker/engineering-repair.py +147 -0
- package/skills/abelien-structure/worker/engineering.py +324 -0
- package/skills/abelien-structure/worker/identity.mjs +98 -0
- package/skills/abelien-structure/worker/inspection-geometry.mjs +71 -0
- package/skills/abelien-structure/worker/jobs.mjs +451 -0
- package/skills/abelien-structure/worker/library-runtime.mjs +15 -0
- package/skills/abelien-structure/worker/main.mjs +102 -0
- package/skills/abelien-structure/worker/manifest-digest.mjs +11 -0
- package/skills/abelien-structure/worker/materials.mjs +52 -0
- package/skills/abelien-structure/worker/native-launcher.py +13 -0
- package/skills/abelien-structure/worker/native.mjs +157 -0
- package/skills/abelien-structure/worker/representation.mjs +47 -0
- package/skills/abelien-structure/worker/sandbox.mjs +41 -0
- package/skills/abelien-structure/worker/source-contract.mjs +143 -0
- package/skills/abelien-structure/worker/source_call_stats.py +59 -0
- package/skills/abelien-structure/worker/supervisor.mjs +38 -0
- package/skills/abelien-workspace/SKILL.md +11 -0
- package/skills/abelien-workspace/assets/journey-stage-plan.json +155 -0
- package/skills/abelien-workspace/domain.json +25 -2
- package/skills/abelien-workspace/references/hardware-journey-orchestration.md +1 -1
- package/skills/abelien-workspace/references/harness-binding.md +2 -2
- package/skills/abelien-workspace/references/user-journey.md +10 -0
- package/skills/abelien-workspace/scripts/journey-next.mjs +38 -0
- package/skills/abelien-workspace/workflows/definitions/399ef372790b7330d0ef88ce5096e7f69943d4793de97b2ed6c25165a332612f.json +230 -0
- package/skills/abelien-workspace/workflows/definitions/591a7f21e0b0ad586e340163f7533ac910356091ba8d2a26e250c0acbc80cd8b.json +176 -0
- package/skills/abelien-workspace/workflows/definitions/d9f8c6cdf0b2c41cdeb1c72ac5d1e37d95a7caac3272d434a3c7e8483fc565de.json +150 -0
- package/skills/abelien-workspace/workflows/user-journey-hardware-algorithm.json +3 -2
- package/skills/abelien-workspace/workflows/user-journey-hardware-candidates.json +3 -2
- 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
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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),
|
|
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
|
|
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)
|
|
100
|
-
|
|
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
|
-
|
|
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
|
|
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' : '
|
|
113
|
-
//
|
|
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
|
-
|
|
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({
|
|
119
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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 {
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
2
|
-
import {
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
export
|
|
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;
|