abelien-harness 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/.env.example +3 -0
  2. package/package.json +1 -1
  3. package/skills/abelien-hardware/SKILL.md +7 -2
  4. package/skills/abelien-hardware/domain.json +3 -3
  5. package/skills/abelien-hardware/references/search-index-format.md +10 -0
  6. package/skills/abelien-hardware/references/search-index-release.md +4 -2
  7. package/skills/abelien-hardware/references/web-evidence-contract.md +2 -2
  8. package/skills/abelien-hardware/scripts/electrical.mjs +125 -0
  9. package/skills/abelien-hardware/scripts/finalize-selection.mjs +5 -1
  10. package/skills/abelien-hardware/scripts/provider-chain-contract.mjs +10 -6
  11. package/skills/abelien-hardware/scripts/search-index-release.mjs +30 -17
  12. package/skills/abelien-hardware/scripts/tos-wiki-search.mjs +4 -2
  13. package/skills/abelien-hardware/scripts/volc-web-search.mjs +6 -1
  14. package/skills/abelien-hardware/scripts/web-common.mjs +4 -1
  15. package/skills/abelien-hardware/scripts/web-provider-chain.mjs +8 -2
  16. package/skills/abelien-hardware/scripts/web-search-plan.mjs +16 -1
  17. package/skills/abelien-hardware/scripts/web-search.mjs +4 -2
  18. package/skills/abelien-hardware-selection/SKILL.md +1 -1
  19. package/skills/abelien-hardware-selection/domain.json +2 -2
  20. package/skills/abelien-hardware-selection/references/selection-service-contract.md +20 -1
  21. package/skills/abelien-hardware-selection/scripts/select.mjs +26 -7
  22. package/skills/abelien-workspace/domain.json +1 -1
  23. package/skills/abelien-workspace/references/hardware-journey-orchestration.md +1 -1
  24. package/skills/abelien-workspace/workflows/definitions/0852e4949f5a6246880c1a26bf5ccaa42f8cda06b960e7c7ce234a0a176878f9.json +229 -0
  25. package/skills/abelien-workspace/workflows/definitions/7c69ea0d51099b1ae947f08b056ea67f5cad6b40ae8bcb00efb496fa5b9d5ed3.json +149 -0
  26. package/skills/abelien-workspace/workflows/definitions/9b5c65ad03fdecc0148931a7677cfa684a3fb04ab2738d6f7a1086a2c79ac86c.json +149 -0
  27. package/skills/abelien-workspace/workflows/definitions/c1b5c6c653f0c4761d51efee923aafbbeea05512e7f35c60f7f7d36ec49fc1d1.json +229 -0
  28. package/skills/abelien-workspace/workflows/definitions/e391e14c14cb39408a0a9e226ed54ffab7a36badea305ae4a3eafbac8124fc02.json +175 -0
  29. package/skills/abelien-workspace/workflows/definitions/fd8ce7074e2044985778446e93153864940bb9465471c1c71c41328cefff70bf.json +175 -0
  30. package/skills/abelien-workspace/workflows/user-journey-hardware-algorithm.json +3 -3
  31. package/skills/abelien-workspace/workflows/user-journey-hardware-candidates.json +3 -3
  32. package/skills/abelien-workspace/workflows/user-journey-hardware.json +3 -3
package/.env.example CHANGED
@@ -31,6 +31,9 @@ ABELIEN_HARDWARE_REVISE_URL=
31
31
  ABELIEN_HARDWARE_SELECT_TOKEN=
32
32
 
33
33
  # Fixed prismer_then_volc policy; both Keys are trusted host-only.
34
+ # Prismer query, URL-load and Parse are temporarily off (P2-05.02): enabled|disabled, default disabled.
35
+ # When disabled, Volc searches directly and sources are read over public HTTPS (PDF/image unavailable).
36
+ ABELIEN_HARDWARE_WEB_PRISMER=disabled
34
37
  # Volc subscription/pay-as-you-go search Keys are distinct from Ark generation Keys.
35
38
  PRISMER_API_KEY=
36
39
  WEB_SEARCH_API_KEY=
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "abelien-harness",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "npx-installable multi-agent skill harness: generic kernel + pluggable domain packs",
5
5
  "type": "module",
6
6
  "engines": {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: abelien-hardware
3
- description: 处理 Abelien 硬件分类、TOS Wiki 实时检索、固定 Prismer 优先/Volc 降级的来源绑定补选、整机复用、型号语义选型和已有方案局部修订。用于需求已澄清后的硬件阶段,不用于需求澄清或外观3D生成。
3
+ description: 处理 Abelien 硬件分类、TOS Wiki 实时检索、英文 Volc 搜索加来源直读的来源绑定补选(Prismer 默认关闭)、整机复用、型号语义选型和已有方案局部修订。用于需求已澄清后的硬件阶段,不用于需求澄清或外观3D生成。
4
4
  license: MIT
5
5
  compatibility:
6
6
  - claude-code
@@ -48,7 +48,7 @@ TOS 读取所需变量是 `TOS_REGION`、`TOS_ENDPOINT`、`TOS_BUCKET`、`TOS_AC
48
48
 
49
49
  ## Online supplemental candidates
50
50
 
51
- Read [web evidence contract](references/web-evidence-contract.md). Library recall is followed by suitability review. Every zero-candidate or all-unsuitable slot uses `hardware.web-search` to persist fixed Prismer-first/Volc fallback results and independently read source documents, then `hardware.web-merge` consumes that evidence. Keep all user requirements and quantities; catalog absence does not justify dropping rechargeable batteries or wireless charging. Suitable library slots do zero network I/O and are omitted from unresolved_slots; never use empty query/count0 placeholders. All suitable returns []. The trusted host runs the shared [pure search-plan preflight](references/web-evidence-contract.md#trusted-host-search-plan-preflight) before committing the review, so invalid plans remain correctable at the same pause. Extract each physical part separately; missing dimensions, authority and engineering remain unknown, `envelope_only` never means true component CAD. The same formal `hardware.select` consumes `merge-hardware-web.resource.id`; external choices cannot become verified/adopted/delivered. System reuse is optional when it is a good match; concept visuals can be shown first.
51
+ Read [web evidence contract](references/web-evidence-contract.md). Library recall is followed by suitability review. Every zero-candidate or all-unsuitable slot uses `hardware.web-search` to persist English Volc search results (Count ≤ 5) and independently read source documents (Prismer query and reads are off by default, so sources are read directly), then `hardware.web-merge` consumes that evidence. Keep all user requirements and quantities; catalog absence does not justify dropping rechargeable batteries or wireless charging. Suitable library slots do zero network I/O and are omitted from unresolved_slots; never use empty query/count0 placeholders. All suitable returns []. The trusted host runs the shared [pure search-plan preflight](references/web-evidence-contract.md#trusted-host-search-plan-preflight) before committing the review, so invalid plans remain correctable at the same pause. Extract each physical part separately; missing dimensions, authority and engineering remain unknown, `envelope_only` never means true component CAD. The same formal `hardware.select` consumes `merge-hardware-web.resource.id`; external choices cannot become verified/adopted/delivered. System reuse is optional when it is a good match; concept visuals can be shown first.
52
52
 
53
53
  The trusted host alone supplies PRISMER_API_KEY/WEB_SEARCH_API_KEY. Only a stored definitive primary denial, successful empty query or unsent missing Key permits the fixed fallback; sent unknown/ambiguous or storage failures block it. Safe direct HTML/plaintext follows each original source denial; PDF/image without Parse remains unavailable. Do not choose providers/endpoints or change an unknown invocation. Full schema and original runId-only recovery are in the web evidence contract.
54
54
 
@@ -58,3 +58,8 @@ An ordinary failure of an authorized public read is recorded as an unavailable s
58
58
  ## 结构化尺寸粗筛
59
59
 
60
60
  数据库尺寸快照是尺寸编辑源;生成与迁移见 [search-index-format](references/search-index-format.md)。搜索先对全部分类索引做保守筛选再取 Top-K,unknown/estimate 保留。使用候选的 dimension_check 和来源,不从正文猜 dimensions,不把 clear、占用比例、用户确认或 CAD 可用性当作工程验证;all_indexed_candidates_rejected 只指已评估分类。risk 候选排在可装/未知候选之后且不占 Top-K 名额:有满足功能与硬约束的非 risk 候选时选它;只有都不满足才选 risk 件,并在 selection_reason 写明可能放不下的原因。正式选择会重算尺寸,incompatible 必须换候选;只有 decision_required(用户硬尺寸上限+实测/datasheet 尺寸,且没有轴对齐装法)返回待确认,交用户在换件与放宽尺寸间取舍;unknown 与其他 risk 只随 dimension_check 提示,不阻塞。
61
+
62
+ ## 电气兼容
63
+
64
+ 多型号选型的电源与接口由确定性检查先判(星形拓扑,主控为中心),规则见 [selection-service-contract](../abelien-hardware-selection/references/selection-service-contract.md#电气兼容与兼容报告)。可信数据证明总线缺失或数量不足时必须替换一侧;`ELECTRICAL_SUPPLY_GAP` 表示要补电源或稳压件;结构、散热、软件只作提示。不要从正文猜电压或接口,电气数据只来自候选的 `electrical` 字段。
65
+
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "abelien-hardware",
3
- "version": "1.2.0",
4
- "description": "Hardware library recall, fixed Prismer-first/Volc fallback evidence and envelope candidates; formal selection remains hardware.select",
3
+ "version": "1.3.0",
4
+ "description": "Hardware library recall, English Volc query and source evidence (Prismer off by default) with envelope candidates; formal selection remains hardware.select",
5
5
  "resources": [
6
6
  "hardware-recall",
7
7
  "hardware-proposal",
@@ -155,7 +155,7 @@
155
155
  "produces": "hardware-selection"
156
156
  },
157
157
  "hardware.web-search": {
158
- "description": "Fixed Prismer-first/Volc fallback, safe source evidence and original-task recovery",
158
+ "description": "English Volc query and direct source reads (Prismer switchable, off by default), safe source evidence and original-task recovery",
159
159
  "adapter": {
160
160
  "type": "module",
161
161
  "path": "scripts/web-search.mjs",
@@ -68,3 +68,13 @@ source 与 output 须是互不嵌套的目录。每个 Wiki record_id 必须命
68
68
 
69
69
 
70
70
  新域包 hardware 1.2.0 / selection 0.5.0 / workspace 1.3.0 使用此加法契约。已有持久结果只读原样;旧 workflow JSON 摘要不固定模块代码,因此不可用旧定义配新包宣称旧行为仍相同。宿主必须固定完整发布包,为尺寸筛选开新运行,不能原地改写旧 CAD 输入或已失败/未知副作用。
71
+
72
+ ## 电气字段(消费方契约)
73
+
74
+ 生成 Wiki 头部可带两行严格内联 JSON:
75
+
76
+ - `electrical`:`{schema:"abelien.component-electrical.v1", basis, power_inputs, power_outputs, buses}`。`basis` 为 measured / datasheet / estimate / unknown;供电项为 `{id, voltage_v:[min,max], connector?, max_current_a?}`,id 与连接器为小写短横线标识,电压为有序正数;总线项为 `{type, role, count, logic_level_v?}`,type 取 i2c / spi / uart / usb / i2s / can / pwm / gpio / adc,role 为 controller 或 peripheral。不接受多余字段。
77
+ - `electrical_source`:`{component_id, revision, snapshot_id, evidence_ref?}`,`revision` 是 `{component_id, ...electrical}` 规范 JSON 的 SHA-256。
78
+
79
+ 只有两行都在、源绑定一致的记录才进入候选;只有 `electrical` 没有来源时按未知处理,摘要不符则拒绝。组件数据库是电气数据唯一编辑源,生产方(P2-04.03)沿尺寸快照同一条只读导出与构建管道写入,禁止在 Wiki 上手改。占位、默认或无出处的数据只能标 estimate 或 unknown。
80
+
@@ -62,7 +62,9 @@ npm run smoke:hardware-search -- \
62
62
 
63
63
  ## 激活与失败语义
64
64
 
65
- 构建器以源 Manifest SHA-256 作为 `release_id`,分片写入 `indexes/releases/<release_id>/...`。发布器先验证远端 Manifest,再以不可变语义上传并回读全部分片;只有全部 SHA、Schema、分类和记录数通过后,才用条件 PUT 替换 `_catalog.json`。并发发布导致 Catalog ETag 变化时显式失败,不覆盖别人的新版本。
65
+ 构建器以源 Manifest SHA-256 作为 `release_id`,分片写入 `indexes/releases/<release_id>/...`。发布器先验证远端 Manifest,再以不可变语义上传并回读全部分片;只有全部 SHA、Schema、分类和记录数通过后,才替换 `_catalog.json`。
66
+
67
+ 存储是阿里云 OSS(S3 兼容签名),这条路径**没有条件 PUT**:`If-None-Match`/`If-Match` 返回 400 NotImplemented,`x-oss-forbid-overwrite` 被忽略、照常覆盖。所以不可变对象靠先读后写:不存在才上传,已存在则比对 SHA,字节不同以 `IMMUTABLE_OBJECT_CONFLICT` 失败,上传后一律回读校验。激活前重读 `_catalog.json`,按内容 SHA-256 与发布开始时读到的版本比对(OSS 对 JSON 对象不回 ETag),变了就以 `CATALOG_ACTIVATION_RACE` 失败,不覆盖别人的新版本。重读到写入之间仍有很短的窗口,写后校验会比对 `release_id`,输掉竞争的一方因此失败,不会误报成功;同一时刻只应有一人发布。结果的 `previous_catalog` 记录被替换的 catalog(sha256、release_id),作为回滚点。
66
68
 
67
69
  Catalog 激活失败只会留下未引用的不可变分片,不影响旧版本;搜索端始终从唯一 `_catalog.json` 读取一个完整 release。发布命令默认最后执行真实 `hardware.search`,确保 Catalog、分类索引和至少一篇 Wiki 正文能够按生产读取路径访问。`--skip-smoke` 只用于已有独立冒烟门禁的受控 CI,不能把单元测试当成生产连通性证明。
68
70
 
@@ -71,6 +73,6 @@ Catalog 激活失败只会留下未引用的不可变分片,不影响旧版本
71
73
 
72
74
  数据库导出由 Web 仓库显式只读命令产生;Harness 不接收数据库凭据。尺寸发布使用同一命令的 `--component-snapshot /absolute/path/to/component-dimensions.json`,先运行 `--dry-run --output /absolute/path/to/review` 检查生成 Wiki、Manifest、导出和索引。确定发布环境后去掉 dry-run,才会执行远端写入。
73
75
 
74
- 这条路径按 content-addressed key 上传生成 Wiki,再上传不可变导出和 Manifest、索引分片,最后条件激活 `_catalog.json`;每个对象冲突即失败,不覆盖旧 Wiki、旧清单或旧快照。生成路径从 built.generated_source 读取,不能把原始 source 目录的 Wiki 当成新的正文。无需 bootstrap-manifest,catalog 使用该 release 自己的版本化 Manifest。故障只留下未引用的不可变文件;本地测试的内存 TOS 只证明执行顺序和摘要,不证明线上发布成功。
76
+ 这条路径按 content-addressed key 上传生成 Wiki,再上传不可变导出和 Manifest、索引分片,最后按上节语义激活 `_catalog.json`;每个对象冲突即失败,不覆盖旧 Wiki、旧清单或旧快照。生成路径从 built.generated_source 读取,不能把原始 source 目录的 Wiki 当成新的正文。无需 bootstrap-manifest,catalog 使用该 release 自己的版本化 Manifest。故障只留下未引用的不可变文件;本地测试的内存 TOS 只证明执行顺序和摘要,不证明线上发布成功。
75
77
 
76
78
  v1 迁移:所有索引记录必须具备 Wiki 内容 SHA-256。旧无 hash 索引重建再发布;旧无尺寸/geometry 记录保持 unknown。dimensions 更新会改变源 revision、snapshot_id、生成 Wiki 内容 hash、Manifest 身份与分片摘要;旧固定产物保持原字节,不原地“补齐”过去的候选。
@@ -2,9 +2,9 @@
2
2
 
3
3
  Read the [architecture](../../../docs/architecture/hardware-online-fallback.md) and [official API inventory](../../../docs/architecture/prismer-api-inventory.md). The trusted current-workdir reader/checkpoint owns all source bodies. A model cannot submit URL/key/provider/command/source content or default envelopes.
4
4
 
5
- hardware.web-search input is exactly {requirements,slots,recall_id,unresolved_slots}. Each unresolved item is {slot_id,reason,query,count?}; every zero-candidate slot must occur and all-unsuitable candidates need a reason. Query is nonempty; Count is a positive integer/default10 without undocumented fixed upper limits. Full selectionRequestDigest preserves original requirements, quantities and constraints. unresolved_slots lists only slots needing search: omit suitable slots entirely, never submit empty query/count0 placeholders; all suitable means []. Empty unresolved list with suitable candidates uses zero network/credentials. The shared validateHardwareWebPlan({input,readResource}) in scripts/web-search-plan.mjs returns {source} after the same original-recall and query/count checks used before search configuration, checkpoints or provider calls. It does not filter or default invalid entries.
5
+ hardware.web-search input is exactly {requirements,slots,recall_id,unresolved_slots}. Each unresolved item is {slot_id,reason,query,count?}; every zero-candidate slot must occur and all-unsuitable candidates need a reason. Query is nonempty; Count is a positive integer. New plans (P2-05.02) must write the query in English (no Chinese/Japanese/Korean characters; keep model numbers and manufacturer names) and Count is 1–5, default 5; validateNewHardwareWebPlan rejects violations with VALIDATION. A plan already frozen in saved evidence keeps the rules it was accepted under (validateFrozenHardwareWebPlan). Full selectionRequestDigest preserves original requirements, quantities and constraints. unresolved_slots lists only slots needing search: omit suitable slots entirely, never submit empty query/count0 placeholders; all suitable means []. Empty unresolved list with suitable candidates uses zero network/credentials. The shared validateHardwareWebPlan({input,readResource}) in scripts/web-search-plan.mjs returns {source} after the original-recall, query/count and new-plan checks used by the preflight helper; search applies the new-plan checks only when the plan is not the one frozen in existing evidence, before configuration, checkpoints or provider calls. It does not filter or default invalid entries.
6
6
 
7
- The trusted fixed policy is prismer_then_volc. Fixed Prismer query POST /api/context/load sends inputType=query, search.topK and explicit return.topK/format=both. URL-load sends inputType=url/format=both; only result.raw supplies source text. cached/cachedAt/meta and complete raw/response/summary/cost/time are retained. HQCC-only is unavailable. PDF/image suffixes from original query URLs use actual Parse {url,mode:fast,output:markdown,wait:true}. Synchronous requestId is nullable; async taskId is persisted before fixed status/result GET. Callback/endpoints are ignored. Source text is API raw/parsed markdown, not original HTTP bytes or a verified realtime/final URL.
7
+ The trusted fixed policy is prismer_then_volc. Prismer is temporarily off (P2-05.02): unless the trusted host sets ABELIEN_HARDWARE_WEB_PRISMER=enabled (default disabled; any other value is HARDWARE_WEB_CONFIG), every prismer attempt (query, context_url, parse) is saved unsent as status=unavailable, reason=prismer_disabled with no receipt, and that marker authorizes the same group's volcengine/query or direct_https/direct_page attempt; PDF/image sources then stay unavailable (parse_unavailable). Volc requests use Count = plan count or 5; Volc receipts saved by 0.4.0 keep their frozen default Count 10 request. When enabled, the fixed Prismer query POST /api/context/load sends inputType=query, search.topK and explicit return.topK/format=both. URL-load sends inputType=url/format=both; only result.raw supplies source text. cached/cachedAt/meta and complete raw/response/summary/cost/time are retained. HQCC-only is unavailable. PDF/image suffixes from original query URLs use actual Parse {url,mode:fast,output:markdown,wait:true}. Synchronous requestId is nullable; async taskId is persisted before fixed status/result GET. Callback/endpoints are ignored. Source text is API raw/parsed markdown, not original HTTP bytes or a verified realtime/final URL.
8
8
 
9
9
  New hardware-web-evidence has provider=prismer, protocol=prismer-volc-source-v2, schema_version=2, provider_policy=prismer_then_volc; original recall_id/recall_digest/request_digest/requirements/slots; plan_digest/unresolved_slots/query_plans; all searches; all pages; unknowns. Its stable ID depends only on original recall_id/request_digest. Every paid attempt has stable id/status/request/raw/raw_sha256/canonical response_sha256/received_at/http_status/request_id/response. Ordered searches[].attempts use prismer/query then the optional volcengine/query; pages[].attempts use prismer/context_url|parse then optional direct_https/direct_page. Each group retains selected_attempt_id/selected_provider and the exact selected receipt alias. Page primary attempt.kind is context_url or parse; pending Parse includes task_id and observations of each fixed GET. Page has source_url/returned_url (nullable), text/text_sha256/content_sha256/retrieval/cached/cachedAt/meta/loaded_at. Prismer source_bytes_verified/remote_redirects_verified remain false. Direct receipts retain actual wire and safe persisted raw/text digests, final_url/fetched_at, DNS/redirect/IP/TLS trace; redaction modifies only the safe copy and raw_redacted=true makes source_bytes_verified=false. PDF/image denial remains unavailable without a direct Parse substitute.
10
10
 
@@ -0,0 +1,125 @@
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.
3
+ import { digest } from './selection-request.mjs';
4
+ const object = v => v !== null && typeof v === 'object' && !Array.isArray(v);
5
+ const positive = v => typeof v === 'number' && Number.isFinite(v) && v > 0;
6
+ const text = v => typeof v === 'string' && v.trim().length > 0;
7
+ const hash = v => typeof v === 'string' && /^[a-f0-9]{64}$/.test(v);
8
+ const slug = v => typeof v === 'string' && /^[a-z0-9][a-z0-9-]{0,47}$/.test(v);
9
+ const invalid = message => { throw Object.assign(new Error(message), { code: 'VALIDATION' }); };
10
+ function keys(value, required, optional, label) {
11
+ if (!object(value) || required.some(key => !Object.hasOwn(value, key)) || Object.keys(value).some(key => ![...required, ...optional].includes(key))) invalid(`${label} has missing or unsupported fields`);
12
+ }
13
+ export const ELECTRICAL_SCHEMA = 'abelien.component-electrical.v1';
14
+ export const BUS_TYPES = ['i2c', 'spi', 'uart', 'usb', 'i2s', 'can', 'pwm', 'gpio', 'adc'];
15
+ // Multi-drop buses only need to exist on the controller; the others are point-to-point and their counts add up.
16
+ const SHARED_BUSES = ['i2c', 'spi', 'can'];
17
+ const TRUSTED = ['measured', 'datasheet'];
18
+ const BASES = ['measured', 'datasheet', 'estimate', 'unknown'];
19
+ const range = v => Array.isArray(v) && v.length === 2 && v.every(positive) && v[0] <= v[1];
20
+
21
+ function validatePower(rows, label) {
22
+ if (!Array.isArray(rows)) invalid(`${label} must be an array`);
23
+ const ids = new Set();
24
+ for (const row of rows) {
25
+ keys(row, ['id', 'voltage_v'], ['connector', 'max_current_a'], label);
26
+ if (!slug(row.id) || ids.has(row.id) || !range(row.voltage_v) || (row.connector !== undefined && !slug(row.connector))
27
+ || (row.max_current_a !== undefined && !positive(row.max_current_a))) invalid(`${label} entries need unique ids, ordered positive voltage ranges and slug connectors`);
28
+ ids.add(row.id);
29
+ }
30
+ }
31
+ // Strict consumer-side contract; the producer is the component database export (P2-04.03).
32
+ export function validateElectricalRecord(value) {
33
+ keys(value, ['schema', 'basis', 'power_inputs', 'power_outputs', 'buses'], [], 'electrical record');
34
+ if (value.schema !== ELECTRICAL_SCHEMA || !BASES.includes(value.basis)) invalid('unsupported electrical record schema or basis');
35
+ validatePower(value.power_inputs, 'electrical power_inputs');
36
+ validatePower(value.power_outputs, 'electrical power_outputs');
37
+ if (!Array.isArray(value.buses)) invalid('electrical buses must be an array');
38
+ for (const bus of value.buses) {
39
+ keys(bus, ['type', 'role', 'count'], ['logic_level_v'], 'electrical bus');
40
+ if (!BUS_TYPES.includes(bus.type) || !['controller', 'peripheral'].includes(bus.role) || !Number.isSafeInteger(bus.count) || bus.count < 1
41
+ || (bus.logic_level_v !== undefined && !positive(bus.logic_level_v))) invalid('electrical bus needs a known type, role, positive count and positive logic level');
42
+ }
43
+ return value;
44
+ }
45
+ // Source-bound records only; an unbound or legacy record is reported as unknown, never trusted.
46
+ export function electricalFields(value) {
47
+ const record = value?.electrical, source = value?.electrical_source, id = value?.component_id ?? value?.record_id;
48
+ if (record === undefined && source === undefined) return {};
49
+ if (!object(source) || source.component_id !== id || !hash(source.revision) || !hash(source.snapshot_id)
50
+ || Object.keys(source).some(key => !['component_id', 'revision', 'snapshot_id', 'evidence_ref'].includes(key))
51
+ || (source.evidence_ref !== undefined && !text(source.evidence_ref))) return { electrical: null, electrical_source: null };
52
+ validateElectricalRecord(record);
53
+ if (source.revision !== digest({ component_id: id, ...record })) invalid('electrical source revision does not match candidate fields');
54
+ return { electrical: record, electrical_source: { ...source } };
55
+ }
56
+ const overlaps = (a, b) => a[0] <= b[1] && b[0] <= a[1];
57
+ const within = (inner, outer) => inner[0] >= outer[0] && inner[1] <= outer[1];
58
+ const isController = part => part.category_path?.[0] === 'compute-control' && ['controller-board', 'controller-module'].includes(part.category_path?.[1]);
59
+ const isPower = part => part.category_path?.[0] === 'power-energy';
60
+ const isLevelShifter = part => part.category_path?.[0] === 'electrical-interconnect' && part.category_path?.[1] === 'level-shifting';
61
+ const trusted = part => TRUSTED.includes(part.electrical?.basis);
62
+ const ref = part => ({ slot_id: part.slot_id, component_id: part.component_id });
63
+
64
+ // 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
+ 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' };
68
+ 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)));
70
+ if (fit) {
71
+ const input = inputs.find(row => within(fit.output.voltage_v, row.voltage_v));
72
+ 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',
74
+ rail: { component_id: fit.source.component_id, id: fit.output.id, voltage_v: fit.output.voltage_v, connector: fit.output.connector ?? null },
75
+ input: { id: input.id, voltage_v: input.voltage_v, connector: input.connector ?? null } };
76
+ }
77
+ if (sources.some(source => !trusted(source))) return { rule, dimension, status: 'unknown', parts, reason: 'UNTRUSTED_SOURCE_MAY_SUPPLY' };
78
+ const partial = rails.some(({ output }) => inputs.some(input => overlaps(output.voltage_v, input.voltage_v)));
79
+ return { rule, dimension, status: partial ? 'risk' : 'gap', parts, reason: partial ? 'RAIL_PARTIALLY_OUTSIDE_INPUT' : 'NO_SUPPLY_RAIL_FOR_INPUT' };
80
+ }
81
+ // fail (incompatible part) > gap (missing part) > pass (all checks pass on trusted data) > risk > unknown.
82
+ const aggregate = (checks, dimension, members) => {
83
+ const rows = checks.filter(check => check.dimension === dimension);
84
+ for (const status of ['fail', 'gap']) if (rows.some(check => check.status === status)) return status;
85
+ if (rows.length && rows.every(check => check.status === 'pass') && members.every(trusted)) return 'pass';
86
+ return rows.some(check => check.status === 'risk') ? 'risk' : 'unknown';
87
+ };
88
+ // parts: [{slot_id, component_id, category_path, quantity, electrical}] for the whole selection.
89
+ export function evaluateElectrical(parts) {
90
+ const method = 'electrical-star-v1', controllers = parts.filter(isController);
91
+ if (controllers.length !== 1) return { method, topology: 'not_star', dimensions: { power: 'unknown', interfaces: 'unknown' }, checks: [],
92
+ reasons: [{ code: controllers.length ? 'MULTIPLE_CONTROLLERS' : 'NO_CONTROLLER', controllers: controllers.map(ref) }] };
93
+ const [controller] = controllers, sources = parts.filter(isPower), shifter = parts.some(isLevelShifter);
94
+ 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'));
98
+ // 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.
101
+ const need = new Map();
102
+ for (const part of peripherals) for (const bus of part.electrical?.buses ?? []) if (bus.role === 'peripheral')
103
+ need.set(bus.type, [...(need.get(bus.type) ?? []), { part, bus, count: bus.count * (part.quantity ?? 1) }]);
104
+ 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' });
105
+ for (const [type, rows] of need) {
106
+ const parts2 = [ref(controller), ...rows.map(row => ref(row.part))];
107
+ 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');
109
+ 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);
111
+ 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.
114
+ for (const row of rows) {
115
+ const level = offered.find(bus => bus.logic_level_v !== undefined)?.logic_level_v;
116
+ if (level === undefined || row.bus.logic_level_v === undefined) continue;
117
+ 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' });
120
+ }
121
+ }
122
+ const powerMembers = [controller, ...sources, ...peripherals.filter(part => checks.some(check => check.dimension === 'power' && check.parts.some(row => row.component_id === part.component_id)))];
123
+ return { method, topology: 'star', controller: ref(controller), checks,
124
+ dimensions: { power: aggregate(checks, 'power', powerMembers), interfaces: aggregate(checks, 'interfaces', [controller, ...peripherals]) } };
125
+ }
@@ -1,5 +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
4
  import { selectionRequestDigest } from "./selection-request.mjs";
4
5
 
5
6
  function error(message, details) {
@@ -80,7 +81,7 @@ export async function finalizeHardwareSelection({ input }) {
80
81
  name: String(candidate.title ?? candidate.name ?? candidate.model ?? candidate.component_id),
81
82
  // This is a deterministic selection-line identity, NOT an upstream catalog BOM identity.
82
83
  bom_item_id: `selection-line-${digest({ slot_id: selected.slot_id, component_id: candidate.component_id }).slice(0, 24)}`,
83
- ...dimensionFields(candidate), dimension_check: dimensionCheck,
84
+ ...dimensionFields(candidate), dimension_check: dimensionCheck, ...electricalFields(candidate),
84
85
  ...(candidate.interfaces !== undefined ? { interfaces: candidate.interfaces } : {}),
85
86
  slot_id: selected.slot_id, component_id: selected.component_id, quantity: selected.quantity,
86
87
  selection_reason: selected.selection_reason, wiki_evidence: selected.wiki_evidence,
@@ -94,6 +95,9 @@ export async function finalizeHardwareSelection({ input }) {
94
95
  ...(candidate.source_type === "external_web" ? { source_type: candidate.source_type, source: candidate.source, geometry: candidate.geometry, CAD_available: false, cad_available: false, engineering_verified: false } : {}),
95
96
  };
96
97
  });
98
+ // 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 });
97
101
  const stable = {
98
102
  requirements: input.requirements ?? {}, slots: input.slots ?? [], source_snapshot: recall.source_snapshot,
99
103
  proposal: { ...proposal, selected_models: selectedModels },
@@ -2,7 +2,7 @@ import { digest, selectionRequestDigest } from "./selection-request.mjs";
2
2
  import { failure, text } from "./web-common.mjs";
3
3
  import { sha256, normalizePublicSourceUrl, pageText, publicAddress, publicReadFailureCodes } from "./public-page.mjs";
4
4
  import { intactReceipt, successfulReceipt } from "./web-receipt.mjs";
5
- import { volcQueryRequest } from "./volc-web-search.mjs";
5
+ import { volcQueryRequest, legacyVolcQueryRequest } from "./volc-web-search.mjs";
6
6
  import { isIP } from "node:net";
7
7
  export const chainProtocol = "prismer-volc-source-v2", chainPolicy = "prismer_then_volc";
8
8
  export const receiptFields = ["raw", "raw_sha256", "response", "response_sha256", "request_id", "received_at", "http_status"];
@@ -13,8 +13,11 @@ export const same = (left, right) => digest({ value: left }) === digest({ value:
13
13
  export const selectedReceipt = group => group?.attempts?.find(attempt => attempt.id === group.selected_attempt_id) ?? null;
14
14
  export const queryResults = attempt => attempt?.provider === "volcengine" ? attempt.response?.Result?.WebResults?.map(result => ({ ...result, url: result?.Url ?? null })) : attempt?.response?.results;
15
15
  export const missingKey = attempt => attempt?.status === "unavailable" && attempt.dispatch_state === "not_sent" && attempt.reason === "missing_key" && attempt.error_code === "HARDWARE_WEB_CONFIG" && attempt.raw == null && attempt.response == null && attempt.http_status == null;
16
+ // A Prismer attempt skipped by the trusted host switch is recorded unsent with no receipt;
17
+ // it authorizes the same group's Volc query or direct source read.
18
+ export const prismerDisabled = attempt => attempt?.provider === "prismer" && attempt.status === "unavailable" && attempt.dispatch_state === "not_sent" && attempt.reason === "prismer_disabled" && attempt.error_code == null && attempt.raw == null && attempt.response == null && attempt.http_status == null;
16
19
  export const knownDenial = attempt => attempt?.provider === "prismer" && attempt.status === "denied" && attempt.dispatch_state === "sent" && [401, 403, 402, 429].includes(attempt.http_status) && intactReceipt(attempt);
17
- export const permitsFallback = (attempt, query) => knownDenial(attempt) || missingKey(attempt) || (query && attempt?.status === "completed" && successfulReceipt(attempt) && Array.isArray(queryResults(attempt)) && queryResults(attempt).length === 0);
20
+ export const permitsFallback = (attempt, query) => knownDenial(attempt) || missingKey(attempt) || prismerDisabled(attempt) || (query && attempt?.status === "completed" && successfulReceipt(attempt) && Array.isArray(queryResults(attempt)) && queryResults(attempt).length === 0);
18
21
  export function expectedSelection(group, query) {
19
22
  const primary = group.attempts?.[0], fallback = group.attempts?.[1];
20
23
  if (!primary) return null;
@@ -67,13 +70,14 @@ function validateGroup(group, item, page, partial) {
67
70
  const operation = page ? sourceOperation(page.source_url) : null;
68
71
  for (const [index, attempt] of attempts.entries()) {
69
72
  const provider = index === 0 ? "prismer" : query ? "volcengine" : "direct_https", kind = query ? "query" : index === 0 ? operation.kind : "direct_page";
70
- const request = query ? index === 0 ? primaryQuery(item) : volcQueryRequest(item) : index === 0 ? operation.request : { url: page.source_url, method: "GET" };
71
- if (attempt.provider !== provider || attempt.kind !== kind || attempt.role !== (index === 0 ? "primary" : "fallback") || attempt.group_id !== group.id || attempt.id !== operationId(group.id, provider, kind, request) || !same(attempt.request, request)) return false;
73
+ // Volc receipts saved before the Count-5 default keep their frozen Count-10 request shape.
74
+ const requests = query ? index === 0 ? [primaryQuery(item)] : [volcQueryRequest(item), legacyVolcQueryRequest(item)] : [index === 0 ? operation.request : { url: page.source_url, method: "GET" }];
75
+ if (attempt.provider !== provider || attempt.kind !== kind || attempt.role !== (index === 0 ? "primary" : "fallback") || attempt.group_id !== group.id || !requests.some(request => attempt.id === operationId(group.id, provider, kind, request) && same(attempt.request, request))) return false;
72
76
  if (index === 1 && !permitsFallback(attempts[0], query)) return false;
73
77
  if (["in_progress", "unknown", "failed"].includes(attempt.status)) return false;
74
78
  if (provider === "direct_https") { if (!validDirect(attempt) || (attempt.dispatch_state === "not_sent" && operation.kind !== "parse") || (attempt.public_read_failure && operation.kind === "parse")) return false; continue; }
75
79
  if (Object.hasOwn(attempt, "public_read_failure")) return false;
76
- if (missingKey(attempt) || knownDenial(attempt)) continue;
80
+ if (missingKey(attempt) || knownDenial(attempt) || prismerDisabled(attempt)) continue;
77
81
  if (attempt.dispatch_state !== "sent" || !["completed", "pending"].includes(attempt.status) || !successfulReceipt(attempt)) return false;
78
82
  if (query && !Array.isArray(queryResults(attempt))) return false;
79
83
  if (attempt.status === "pending" && !(kind === "parse" && text(attempt.task_id) && attempt.task_id === attempt.response.taskId)) return false;
@@ -81,7 +85,7 @@ function validateGroup(group, item, page, partial) {
81
85
  if (page && (!Array.isArray(attempt.observations) || !attempt.observations.every(value => ["status", "result"].includes(value.kind) && (value.error_code ? ((value.raw === undefined && value.response === undefined) || intactReceipt(value)) : successfulReceipt(value) && value.response.taskId === attempt.task_id)))) return false;
82
86
  }
83
87
  const expected = expectedSelection(group, query), selected = selectedReceipt(group);
84
- if (group.selected_attempt_id == null) return partial && group.status === "in_progress" && (knownDenial(attempts[0]) || missingKey(attempts[0]) || successfulReceipt(attempts[0])) && (!page || page.attempt == null);
88
+ if (group.selected_attempt_id == null) return partial && group.status === "in_progress" && (knownDenial(attempts[0]) || missingKey(attempts[0]) || prismerDisabled(attempts[0]) || successfulReceipt(attempts[0])) && (!page || page.attempt == null);
85
89
  if (!selected || !expected || selected.id !== expected.id) return false;
86
90
  return group.selected_provider === selected.provider && (query ? group.status === "completed" && receiptFields.every(key => same(group[key], selected[key])) : same(page.attempt, selected));
87
91
  }
@@ -21,6 +21,12 @@ function parseJson(bytes, code, key) {
21
21
  catch { throw releaseError(code, `invalid JSON object: ${key}`, { key }); }
22
22
  }
23
23
 
24
+ // 回滚点只作记录:旧 catalog 坏了也不阻断本次激活,release_id 记 null。
25
+ function previousReleaseId(object) {
26
+ try { return JSON.parse(Buffer.from(object.bytes).toString("utf8")).release_id ?? null; }
27
+ catch { return null; }
28
+ }
29
+
24
30
  function safeFile(root, relative) {
25
31
  const base = path.resolve(root);
26
32
  const file = path.resolve(base, relative);
@@ -133,10 +139,7 @@ async function bootstrapRemoteManifest({ config, catalog, sourceDir, fetchImpl,
133
139
  if (actual !== entry.sha256) throw releaseError("REMOTE_WIKI_MISMATCH", "remote Wiki SHA-256 does not match Manifest", { key, expected_sha256: entry.sha256, actual_sha256: actual });
134
140
  });
135
141
  const key = `${config.prefix}${catalog.source_manifest.object_key || "_manifest.json"}`;
136
- await putTosObject(config, key, manifestBytes, {
137
- fetchImpl,
138
- headers: { "content-type": "application/json", "if-none-match": "*", "x-amz-meta-sha256": sha256(manifestBytes) },
139
- });
142
+ await putImmutable(config, key, manifestBytes, fetchImpl);
140
143
  const uploaded = await getTosObject(config, key, { fetchImpl });
141
144
  if (sha256(uploaded.bytes) !== catalog.source_manifest.sha256) {
142
145
  throw releaseError("SOURCE_MANIFEST_MISMATCH", "uploaded Wiki Manifest SHA-256 mismatch", { key });
@@ -144,6 +147,15 @@ async function bootstrapRemoteManifest({ config, catalog, sourceDir, fetchImpl,
144
147
  return { key, etag: uploaded.etag, sha256: catalog.source_manifest.sha256, bootstrapped: true, verified_wiki_objects: manifest.length };
145
148
  }
146
149
 
150
+ function objectContentType(key) {
151
+ if (key.endsWith(".gz")) return "application/gzip";
152
+ if (key.endsWith(".md")) return "text/markdown; charset=utf-8";
153
+ return "application/json";
154
+ }
155
+
156
+ // 阿里云 OSS 的 S3 兼容写入没有条件 PUT:If-None-Match / If-Match 一律 400 NotImplemented,
157
+ // x-oss-forbid-overwrite 被忽略照常覆盖。不可变对象因此靠"先读后写 + 回读 SHA":键按内容寻址,
158
+ // 并发写同一键只会写入同一份字节;已存在但字节不同即冲突失败,不覆盖。
147
159
  async function putImmutable(config, key, bytes, fetchImpl) {
148
160
  const expected = sha256(bytes);
149
161
  const existing = await getTosObject(config, key, { fetchImpl, optional: true });
@@ -152,14 +164,10 @@ async function putImmutable(config, key, bytes, fetchImpl) {
152
164
  if (actual !== expected) throw releaseError("IMMUTABLE_OBJECT_CONFLICT", "release object already exists with different bytes", { key, expected_sha256: expected, actual_sha256: actual });
153
165
  return { key, sha256: expected, reused: true };
154
166
  }
155
- try {
156
- await putTosObject(config, key, bytes, {
157
- fetchImpl,
158
- headers: { "content-type": key.endsWith(".gz") ? "application/gzip" : "application/json", "if-none-match": "*" },
159
- });
160
- } catch (cause) {
161
- if (cause.code !== "TOS_HTTP_STATUS" || cause.details?.status !== 412) throw cause;
162
- }
167
+ await putTosObject(config, key, bytes, {
168
+ fetchImpl,
169
+ headers: { "content-type": objectContentType(key), "x-amz-meta-sha256": expected },
170
+ });
163
171
  const uploaded = await getTosObject(config, key, { fetchImpl });
164
172
  const actual = sha256(uploaded.bytes);
165
173
  if (actual !== expected) throw releaseError("INDEX_UPLOAD_VERIFY_FAILED", "uploaded release object SHA-256 mismatch", { key, expected_sha256: expected, actual_sha256: actual });
@@ -290,11 +298,15 @@ export async function publishHardwareSearchIndex({ sourceDir, outputDir, release
290
298
  const uploaded = await mapConcurrent(local.files, Number.isFinite(limit) ? limit : 6, item =>
291
299
  putImmutable(config, `${config.prefix}${item.key}`, item.bytes, fetchImpl));
292
300
 
293
- const catalogHeaders = {
294
- "content-type": "application/json",
295
- ...(previousCatalog ? { "if-match": `"${previousCatalog.etag}"` } : { "if-none-match": "*" }),
296
- };
297
- await putTosObject(config, catalogKey, local.catalogBytes, { fetchImpl, headers: catalogHeaders });
301
+ // OSS 没有条件 PUT(见 putImmutable),激活前按内容 SHA 重读比对;OSS 对 JSON 对象不回 ETag,
302
+ // 故不能比 ETag。重读到写入之间仍有窗口,写后 verify 的 release_id 比对兜住输掉的竞争。
303
+ const previousSha256 = previousCatalog ? sha256(previousCatalog.bytes) : null;
304
+ const currentCatalog = await getTosObject(config, catalogKey, { fetchImpl, optional: true });
305
+ const currentSha256 = currentCatalog ? sha256(currentCatalog.bytes) : null;
306
+ if (currentSha256 !== previousSha256) {
307
+ throw releaseError("CATALOG_ACTIVATION_RACE", "active catalog changed while this release was uploading", { expected_sha256: previousSha256, actual_sha256: currentSha256 });
308
+ }
309
+ await putTosObject(config, catalogKey, local.catalogBytes, { fetchImpl, headers: { "content-type": "application/json" } });
298
310
  const verified = await verifyPublishedIndex({ env, fetchImpl, concurrency: limit });
299
311
  if (verified.release_id !== local.catalog.release_id) {
300
312
  throw releaseError("CATALOG_ACTIVATION_RACE", "active catalog is not the release just published", { expected_release_id: local.catalog.release_id, actual_release_id: verified.release_id });
@@ -308,6 +320,7 @@ export async function publishHardwareSearchIndex({ sourceDir, outputDir, release
308
320
  uploaded_shards: uploaded.filter(item => !item.reused).length,
309
321
  reused_shards: uploaded.filter(item => item.reused).length,
310
322
  catalog_key: catalogKey,
323
+ previous_catalog: previousCatalog ? { sha256: previousSha256, release_id: previousReleaseId(previousCatalog) } : null,
311
324
  verified,
312
325
  smoke: smokeResult,
313
326
  };
@@ -1,5 +1,6 @@
1
1
  import zlib from "node:zlib";
2
2
  import { dimensionFields, evaluateDimensions, orderByDimensionTier, sameDimensionMetadata, screenDimensions, shortlistByDimensionTier, validateRequirementEnvelopes } from "./dimensions.mjs";
3
+ import { electricalFields } from "./electrical.mjs";
3
4
  import { selectionRequestDigest, validCategoryPath } from "./selection-request.mjs";
4
5
  import { createTosConfig, getTosObject, sha256 } from "./tos-object-store.mjs";
5
6
 
@@ -82,13 +83,13 @@ export function parseHardwareWiki(content) {
82
83
  "schema", "type", "title", "description", "manufacturer", "model", "aliases", "resource_urn", "record_id",
83
84
  "record_version", "status", "category_path", "category_leaf", "primary_function", "integration_level",
84
85
  "hardware_form", "component_kind", "functional_capabilities", "capability_coverage", "source_url", "source_updated_at",
85
- "dimensions_mm", "dimension_basis", "dimension_geometry", "dimension_source", "interfaces",
86
+ "dimensions_mm", "dimension_basis", "dimension_geometry", "dimension_source", "interfaces", "electrical", "electrical_source",
86
87
  ];
87
88
  const meta = {};
88
89
  for (const key of wanted) {
89
90
  const line = frontMatter.match(new RegExp(`^${key}:\\s*(.*)$`, "m"));
90
91
  if (line) {
91
- if (key === "dimension_source" || key === "dimensions_mm") {
92
+ if (["dimension_source", "dimensions_mm", "electrical", "electrical_source"].includes(key)) {
92
93
  try { meta[key] = JSON.parse(line[1]); }
93
94
  catch { throw error("WIKI_INVALID", `${key} must be generated inline JSON`); }
94
95
  } else meta[key] = scalar(line[1]);
@@ -270,6 +271,7 @@ function candidateFromDocument(document, config, ranking, includeContent, indexS
270
271
  title: meta.title,
271
272
  name: meta.title,
272
273
  ...dimensionFields(meta),
274
+ ...electricalFields(meta),
273
275
  ...(meta.interfaces !== undefined ? { interfaces: meta.interfaces } : {}),
274
276
  category_path: meta.category_path,
275
277
  integration_level: meta.integration_level,
@@ -3,7 +3,12 @@ import { sha256 } from "./public-page.mjs";
3
3
  import { digest } from "./selection-request.mjs";
4
4
  import { redactHardwareResponse } from "./response-redaction.mjs";
5
5
  import { receiptRequestId, validVolcResult } from "./web-receipt.mjs";
6
- export const volcQueryRequest = item => ({ Query: item.query, SearchType: "web", Count: item.count ?? 10, Filter: { NeedUrl: true, NeedContent: false }, ContentFormats: "markdown", QueryControl: { QueryRewrite: false } });
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);
7
12
  export function validateVolcCapability(body) {
8
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");
9
14
  }
@@ -22,7 +22,10 @@ export function positiveEnv(env, name, fallback) {
22
22
  export function webConfig(env = {}) {
23
23
  const dnsTransport = env.ABELIEN_HARDWARE_WEB_DNS_TRANSPORT || "system";
24
24
  if (!["system", "doh"].includes(dnsTransport)) throw failure("HARDWARE_WEB_CONFIG", "web DNS transport must be system or doh");
25
- return { dnsTransport, timeoutMs: positiveEnv(env, "ABELIEN_HARDWARE_WEB_SEARCH_TIMEOUT_MS", 30000),
25
+ // Prismer (query, URL-load and Parse) is temporarily off by default (P2-05.02).
26
+ const prismer = env.ABELIEN_HARDWARE_WEB_PRISMER || "disabled";
27
+ if (!["enabled", "disabled"].includes(prismer)) throw failure("HARDWARE_WEB_CONFIG", "Prismer switch must be enabled or disabled");
28
+ return { dnsTransport, prismer, timeoutMs: positiveEnv(env, "ABELIEN_HARDWARE_WEB_SEARCH_TIMEOUT_MS", 30000),
26
29
  pageTimeoutMs: positiveEnv(env, "ABELIEN_HARDWARE_WEB_PAGE_TIMEOUT_MS", 30000),
27
30
  parseTimeoutMs: positiveEnv(env, "ABELIEN_HARDWARE_WEB_PARSE_TIMEOUT_MS", 120000) };
28
31
  }
@@ -76,8 +76,13 @@ export async function searchProviderChain({ input, source, existing, env, readRe
76
76
  if (attempt.kind === "parse" && text(receipt.response?.taskId)) { attempt.task_id = receipt.response.taskId; attempt.status = "pending"; }
77
77
  await save();
78
78
  };
79
+ const skipPrismer = async attempt => { Object.assign(attempt, { status: "unavailable", reason: "prismer_disabled" }); await save(); };
79
80
  const chooseQuery = async (entry, item) => {
80
- if (!entry.attempts.length) { const attempt = makeAttempt(entry, "prismer", "query", entry.request); await dispatch(attempt, () => request("/api/context/load", attempt.request, config.timeoutMs)); }
81
+ if (!entry.attempts.length) {
82
+ const attempt = makeAttempt(entry, "prismer", "query", entry.request);
83
+ if (config.prismer === "disabled") await skipPrismer(attempt);
84
+ else await dispatch(attempt, () => request("/api/context/load", attempt.request, config.timeoutMs));
85
+ }
81
86
  if (permitsFallback(entry.attempts[0], true) && entry.attempts.length === 1) { const attempt = makeAttempt(entry, "volcengine", "query", volcQueryRequest(item)); await dispatch(attempt, () => volcWebSearch(attempt.request, { env, fetchImpl, timeoutMs: config.timeoutMs })); }
82
87
  const selected = expectedSelection(entry, true);
83
88
  if (!selected) throw failure("HARDWARE_WEB_RECONCILIATION_REQUIRED", "original query effect is not known selectable");
@@ -131,7 +136,8 @@ export async function searchProviderChain({ input, source, existing, env, readRe
131
136
  const operation = sourceOperation(page.source_url);
132
137
  if (!page.attempts.length) {
133
138
  const attempt = makeAttempt(page, "prismer", operation.kind, operation.request);
134
- await dispatch(attempt, () => request(operation.path, attempt.request, operation.kind === "parse" ? config.parseTimeoutMs : config.pageTimeoutMs));
139
+ if (config.prismer === "disabled") await skipPrismer(attempt);
140
+ else await dispatch(attempt, () => request(operation.path, attempt.request, operation.kind === "parse" ? config.parseTimeoutMs : config.pageTimeoutMs));
135
141
  }
136
142
  if (permitsFallback(page.attempts[0], false) && page.attempts.length === 1) {
137
143
  const attempt = makeAttempt(page, "direct_https", "direct_page", { url: page.source_url, method: "GET" });
@@ -1,8 +1,12 @@
1
1
  import { onlyKeys, text, invalid, boundRecall } from "./web-common.mjs";
2
+ import { volcMaxCount } from "./volc-web-search.mjs";
3
+
4
+ const cjk = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
2
5
 
3
6
  // Shared read-only plan validation. Do not normalize model intent or access
4
7
  // credentials/configuration/checkpoints before this original-source binding.
5
- export async function validateHardwareWebPlan({ input, readResource }) {
8
+ // The shape check alone is used for a plan already frozen in saved evidence.
9
+ export async function validateFrozenHardwareWebPlan({ input, readResource }) {
6
10
  onlyKeys(input, ["requirements", "slots", "recall_id", "unresolved_slots"], "web search input");
7
11
  const source = await boundRecall(input, readResource), recall = source.body;
8
12
  if (!Array.isArray(input.unresolved_slots)) invalid("unresolved_slots must be an array");
@@ -16,3 +20,14 @@ export async function validateHardwareWebPlan({ input, readResource }) {
16
20
  for (const slot of input.slots) if (!(recall.results.find(result => result.slot_id === slot.slot_id)?.candidates?.length) && !unresolved.has(slot.slot_id)) invalid("every zero-candidate slot needs an unresolved query; preserve the requirement");
17
21
  return { source };
18
22
  }
23
+ // New plans (P2-05.02): English queries for Volc and at most 5 results per query.
24
+ export function validateNewHardwareWebPlan(input) {
25
+ for (const item of input.unresolved_slots) {
26
+ if (item.count !== undefined && item.count > volcMaxCount) invalid(`search Count must be an integer from 1 to ${volcMaxCount}`);
27
+ if (cjk.test(item.query)) invalid("search query must be written in English; keep model numbers and manufacturer names");
28
+ }
29
+ }
30
+ export async function validateHardwareWebPlan({ input, readResource }) {
31
+ const result = await validateFrozenHardwareWebPlan({ input, readResource });
32
+ validateNewHardwareWebPlan(input); return result;
33
+ }
@@ -6,7 +6,7 @@ import { prismerRequest } from "./prismer.mjs";
6
6
  import { queryRequest, validateHardwareWebIntegrity, verifyCheckpointExtension, trustedParseResult } from "./web-integrity.mjs";
7
7
  import { chainProtocol } from "./provider-chain-contract.mjs";
8
8
  import { searchProviderChain } from "./web-provider-chain.mjs";
9
- import { validateHardwareWebPlan } from "./web-search-plan.mjs";
9
+ import { validateFrozenHardwareWebPlan, validateNewHardwareWebPlan } from "./web-search-plan.mjs";
10
10
  const provider = "prismer", protocol = "prismer-context-parse-v1";
11
11
  const unresolvedAttempt = attempt => ["in_progress", "unknown", "failed"].includes(attempt?.status);
12
12
  export async function verifyHardwareWebRecovery({ input, readResource, readBlob }) {
@@ -16,13 +16,15 @@ export async function verifyHardwareWebRecovery({ input, readResource, readBlob
16
16
  await unchanged(source, readResource); await unchanged(saved, readResource); return { ok: true };
17
17
  }
18
18
  export async function searchHardwareWeb({ input, env = {}, readResource, fetchImpl = fetch, ports = {}, checkpoint, readBlob, writeBlob }) {
19
- const { source } = await validateHardwareWebPlan({ input, readResource });
19
+ const { source } = await validateFrozenHardwareWebPlan({ input, readResource });
20
20
  const binding = { recall_id: source.id, recall_digest: digest(source), request_digest: selectionRequestDigest(input), requirements: input.requirements, slots: input.slots };
21
21
  const planDigest = digest(input.unresolved_slots), id = `web-evidence-${digest({ recall_id: binding.recall_id, request_digest: binding.request_digest }).slice(0, 24)}`;
22
22
  const storedExisting = await readResource(`hardware-web-evidence:${id}`);
23
23
  const existing = resolveWebEvidence(storedExisting, readBlob);
24
24
  const pack = body => packWebEvidence(body, writeBlob, !existing && env.AB_HARNESS_AUDIT_V1 === "true");
25
25
  if (existing && (existing.kind !== "hardware-web-evidence" || existing.body?.recall_id !== binding.recall_id || existing.body.request_digest !== binding.request_digest)) invalid("existing evidence has a different original identity");
26
+ // A plan frozen in saved evidence keeps the rules it was accepted under.
27
+ if (existing?.body.plan_digest !== planDigest) validateNewHardwareWebPlan(input);
26
28
  if (!existing || existing.body.protocol === chainProtocol) return searchProviderChain({ input, source, existing: storedExisting, env, readResource, fetchImpl, ports, checkpoint, readBlob, writeBlob });
27
29
  if (existing && (existing.body.provider !== provider || existing.body.protocol !== protocol)) throw failure("HARDWARE_WEB_RECONCILIATION_REQUIRED", "retain the original provider evidence; provider migration cannot reissue its invocation");
28
30
  validateHardwareWebIntegrity({ original: input, source, saved: storedExisting, readBlob, allowPartial: true, code: "HARDWARE_WEB_RECONCILIATION_REQUIRED" });
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: abelien-hardware-selection
3
- description: 为已澄清的需求分层选择硬件分类,经真实 TOS 召回、固定 Prismer 优先/Volc 降级的来源绑定补选、Agent 语义比较与本地 hardware.select 校验身份、数量和引用。用于硬件选型,不用于需求澄清、独立数据库兼容求解或实物验证。
3
+ description: 为已澄清的需求分层选择硬件分类,经真实 TOS 召回、英文 Volc 搜索加来源直读的来源绑定补选(Prismer 默认关闭)、Agent 语义比较与本地 hardware.select 校验身份、数量和引用。用于硬件选型,不用于需求澄清、独立数据库兼容求解或实物验证。
4
4
  license: MIT
5
5
  compatibility:
6
6
  - claude-code