abelien-harness 0.8.0 → 0.9.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 (33) hide show
  1. package/.env.example +19 -20
  2. package/README.md +12 -12
  3. package/package.json +1 -1
  4. package/skills/abelien-hardware/SKILL.md +4 -4
  5. package/skills/abelien-hardware/domain.json +4 -4
  6. package/skills/abelien-hardware/references/knowledge-recall-service-contract.md +4 -4
  7. package/skills/abelien-hardware/references/search-index-format.md +5 -5
  8. package/skills/abelien-hardware/references/search-index-release.md +7 -7
  9. package/skills/abelien-hardware/references/selection-service-contract.md +3 -3
  10. package/skills/abelien-hardware/scripts/build-search-index.mjs +1 -1
  11. package/skills/abelien-hardware/scripts/oss-object-store.mjs +247 -0
  12. package/skills/abelien-hardware/scripts/{tos-wiki-search.mjs → oss-wiki-search.mjs} +19 -19
  13. package/skills/abelien-hardware/scripts/search-index-release.mjs +25 -25
  14. package/skills/abelien-hardware-selection/SKILL.md +2 -2
  15. package/skills/abelien-hardware-selection/domain.json +2 -2
  16. package/skills/abelien-hardware-selection/scripts/load-guide.mjs +1 -1
  17. package/skills/abelien-harness/references/adapter-contract.md +1 -1
  18. package/skills/abelien-harness/references/m1-integration.md +2 -2
  19. package/skills/abelien-harness/scripts/ab.mjs +8 -1
  20. package/skills/abelien-harness/scripts/studio.mjs +15 -14
  21. package/skills/abelien-reference/domain.json +1 -1
  22. package/skills/abelien-reference/references/research-contract.md +1 -0
  23. package/skills/abelien-reference/schemas/reference-evidence.v1.json +1 -1
  24. package/skills/abelien-reference/scripts/finalize.mjs +3 -1
  25. package/skills/abelien-workspace/SKILL.md +1 -1
  26. package/skills/abelien-workspace/assets/journey-stage-plan.json +46 -13
  27. package/skills/abelien-workspace/domain.json +3 -2
  28. package/skills/abelien-workspace/references/hardware-journey-orchestration.md +2 -2
  29. package/skills/abelien-workspace/references/harness-binding.md +3 -3
  30. package/skills/abelien-workspace/references/user-journey.md +5 -5
  31. package/skills/abelien-workspace/scripts/journey-next.mjs +10 -2
  32. package/skills/abelien-workspace/workflows/user-journey.json +1 -1
  33. package/skills/abelien-hardware/scripts/tos-object-store.mjs +0 -202
package/.env.example CHANGED
@@ -1,27 +1,26 @@
1
1
  # Harness automatically loads .env and then .env.local from its launch directory.
2
2
  # Existing process variables take precedence. Never commit real credentials.
3
3
 
4
- TOS_REGION=cn-beijing
5
- TOS_ENDPOINT=https://tos-s3-cn-beijing.volces.com
6
- TOS_BUCKET=your-bucket
7
- TOS_ACCESS_KEY_ID=
8
- TOS_SECRET_ACCESS_KEY=
9
- TOS_ADDRESSING_STYLE=virtual
10
- TOS_WIKI_PREFIX=hardware_profile_wiki/
4
+ # Aliyun OSS connection, same names as the Web host (S3-compatible SigV4).
5
+ CAD_STORAGE_REGION=cn-hangzhou
6
+ CAD_STORAGE_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com
7
+ CAD_STORAGE_BUCKET=your-bucket
8
+ CAD_STORAGE_ACCESS_KEY=
9
+ CAD_STORAGE_SECRET_KEY=
10
+ CAD_STORAGE_ADDRESSING_STYLE=virtual
11
+ # Optional STS token for temporary credentials.
12
+ CAD_STORAGE_SESSION_TOKEN=
11
13
 
12
- # Optional runtime controls
13
- TOS_SEARCH_CONCURRENCY=8
14
- TOS_MAX_FULL_WIKI_FETCH_PER_SLOT=24
15
- TOS_PUBLISH_CONCURRENCY=6
16
- TOS_ALLOW_LEGACY_MANIFEST_FALLBACK=false
17
-
18
- # Web deployment aliases are also accepted when the corresponding TOS_* value is absent:
19
- # CAD_STORAGE_REGION=cn-beijing
20
- # CAD_STORAGE_ENDPOINT=https://tos-s3-cn-beijing.volces.com
21
- # CAD_STORAGE_BUCKET=your-bucket
22
- # CAD_STORAGE_ACCESS_KEY=
23
- # CAD_STORAGE_SECRET_KEY=
24
- # CAD_STORAGE_ADDRESSING_STYLE=virtual
14
+ # Optional hardware Wiki search and publish controls
15
+ OSS_WIKI_PREFIX=hardware_profile_wiki/
16
+ OSS_SEARCH_CONCURRENCY=8
17
+ # Total attempts (incl. the first) for idempotent reads on transient failures (dropped connection, timeout, 429/5xx); 1–5.
18
+ OSS_REQUEST_ATTEMPTS=3
19
+ OSS_MAX_FULL_WIKI_FETCH_PER_SLOT=24
20
+ OSS_PUBLISH_CONCURRENCY=6
21
+ OSS_REQUEST_TIMEOUT_MS=30000
22
+ OSS_MAX_OBJECT_BYTES=16777216
23
+ OSS_ALLOW_LEGACY_MANIFEST_FALLBACK=false
25
24
 
26
25
  # Optional external hardware revision service
27
26
  ABELIEN_HARDWARE_REVISE_URL=
package/README.md CHANGED
@@ -10,7 +10,7 @@ npx 可安装的多 agent skill harness:**通用内核 + 可插拔域包**。
10
10
 
11
11
  内核对领域不感知,随包分发 10 个 bundle。产品层:[abelien-workspace](skills/abelien-workspace/SKILL.md)(旅程、版本与确认)·
12
12
  [abelien-guide](skills/abelien-guide/SKILL.md)(对话调度与沟通)· [abelien-clarify](skills/abelien-clarify/SKILL.md)(需求澄清只读快照)。
13
- 领域层:[abelien-hardware](skills/abelien-hardware/SKILL.md)(TOS 候选检索与修订)·
13
+ 领域层:[abelien-hardware](skills/abelien-hardware/SKILL.md)(OSS 候选检索与修订)·
14
14
  [abelien-hardware-selection](skills/abelien-hardware-selection/SKILL.md)(本地三级分类选型)·
15
15
  [abelien-algorithm](skills/abelien-algorithm/SKILL.md)(基于已验证硬件、官方资料优先的联网算法选型)·
16
16
  [abelien-appearance](skills/abelien-appearance/SKILL.md) 与 [abelien-structure](skills/abelien-structure/SKILL.md)(外观与机械结构)·
@@ -153,18 +153,18 @@ node skills/abelien-harness/scripts/ab.mjs --workdir <作品工作目录> workfl
153
153
 
154
154
  PRD 以用户指定的飞书模板 revision 191 为结构依据,保留七块总览、逐条需求清单与图片引用;从 A1 起每轮登记/修改/撤回需求,保留 ID、来源与历史,选型和检查回来后再更新关联行。定义与写入语义集中在 [PRD 契约](skills/abelien-workspace/references/prd-contract.md),模板随 workspace 安装。产品写入/回读接口尚未接入,不能把规则声明说成自动写入已实现。
155
155
 
156
- `user-journey-mock`、专属 workspace mock 能力和 draft/quote 样例状态机已移除,旧记录与 [MVP 实录](docs/mvp/journey.md) 仅用于历史核对,不再提供运行入口。当前 eval 共五条场景:`user-journey-entry` 只验证入口暂停,不表示产品 E2E 通过;`user-journey-cancelled` 验证取消是**终态**(落 `cancelled` 后 `advance` 与再次 `cancel` 都被拒);`user-journey-hardware-verified` 与 `user-journey-hardware-pending-confirmation` 经**场景 http 桩**端到端驱动硬件阶段的接线(Agent 槽位真的进了请求体、选型资源落库、draft 记下 id 与 status)与门禁(`verified` 过门落 `selected`;`needs_confirmation` 不再被拒——不执行算法,以 `selected_pending_confirmation` 正常完成,仍不宣称 `verified`;`failed`/`unknown` 依旧 `GUARD_FAILED`);`user-journey-hardware-candidates` 驱动**候选提案**路径(本地 `hardware.search` 按受控三级路径读 TOS,`hardware.finalize` 归一化为提案),三条实况断言锁 Catalog/索引/Wiki 各被真实读一次,且 draft 不带 `hardwareSelectionId`(候选不产生正式选型)。桩是评估进程内的假服务,**不**证明选型质量,也**不**证明服务端已就位。
156
+ `user-journey-mock`、专属 workspace mock 能力和 draft/quote 样例状态机已移除,旧记录与 [MVP 实录](docs/mvp/journey.md) 仅用于历史核对,不再提供运行入口。当前 eval 共五条场景:`user-journey-entry` 只验证入口暂停,不表示产品 E2E 通过;`user-journey-cancelled` 验证取消是**终态**(落 `cancelled` 后 `advance` 与再次 `cancel` 都被拒);`user-journey-hardware-verified` 与 `user-journey-hardware-pending-confirmation` 经**场景 http 桩**端到端驱动硬件阶段的接线(Agent 槽位真的进了请求体、选型资源落库、draft 记下 id 与 status)与门禁(`verified` 过门落 `selected`;`needs_confirmation` 不再被拒——不执行算法,以 `selected_pending_confirmation` 正常完成,仍不宣称 `verified`;`failed`/`unknown` 依旧 `GUARD_FAILED`);`user-journey-hardware-candidates` 驱动**候选提案**路径(本地 `hardware.search` 按受控三级路径读 OSS,`hardware.finalize` 归一化为提案),三条实况断言锁 Catalog/索引/Wiki 各被真实读一次,且 draft 不带 `hardwareSelectionId`(候选不产生正式选型)。桩是评估进程内的假服务,**不**证明选型质量,也**不**证明服务端已就位。
157
157
  基础需求足够后推进首版;guide 在宿主实际展示条件满足时通过可选选择题卡片邀请交互细化并告知可“随时收尾”。PRD 从已保存需求起可读,不强制展开或增加文档确认关卡。收尾切入同一作品的[成果体验](skills/abelien-workspace/references/user-journey.md#result-experience):收起全部编辑面板,有可运行仿真就进入仿真,否则按真实播放器能力提供 Three.js 状态动作演示;保留 PRD 查看/草稿或确认版导出及返回编辑入口。展示模式与方案确认独立,不能用收尾、演示状态或草稿导出触发 workflow 完成。宿主 UI/播放器仍须实际接入,历史入口测试不证明体验已实现。
158
158
 
159
159
  ### 真实硬件阶段
160
160
 
161
- 另有独立的 `user-journey-hardware` 阶段流程:输入 clarify 交回的 Requirements(context / reaction[] / limits[],每条须带 `source ∈ {user,system}`、非空 `text` 与全局唯一 `id`,limits 另有 `strength ∈ {hard,preference}`;格式不合规在读取任何可信召回之前即失败),在 agent 暂停点按需加载同级 `abelien-hardware-selection` 的三级分类规则,再将槽位交给 `hardware.select`——它是该域包 `domain.json` 声明的 `{type:"module"}` 本地 adapter(`scripts/select.mjs`),不连网络、无服务端 URL。主流程先 TOS 搜索、再 Agent 证据判断、最后本地 `hardware.select`;未知不通过门禁。契约见选型域 selection-service-contract;verified 不是工程实测。算法与外观真实阶段尚未接到该流程。分类规则快照的来源见下文「快照来源」。
161
+ 另有独立的 `user-journey-hardware` 阶段流程:输入 clarify 交回的 Requirements(context / reaction[] / limits[],每条须带 `source ∈ {user,system}`、非空 `text` 与全局唯一 `id`,limits 另有 `strength ∈ {hard,preference}`;格式不合规在读取任何可信召回之前即失败),在 agent 暂停点按需加载同级 `abelien-hardware-selection` 的三级分类规则,再将槽位交给 `hardware.select`——它是该域包 `domain.json` 声明的 `{type:"module"}` 本地 adapter(`scripts/select.mjs`),不连网络、无服务端 URL。主流程先 OSS 搜索、再 Agent 证据判断、最后本地 `hardware.select`;未知不通过门禁。契约见选型域 selection-service-contract;verified 不是工程实测。算法与外观真实阶段尚未接到该流程。分类规则快照的来源见下文「快照来源」。
162
162
 
163
- ### TOS 候选检索工具
163
+ ### OSS 候选检索工具
164
164
 
165
- `hardware.search` 独立读取候选 Wiki,正式选型仍通过 `hardware.select`;TOS 结果不自动通过硬件选型门禁。
165
+ `hardware.search` 独立读取候选 Wiki,正式选型仍通过 `hardware.select`;OSS 结果不自动通过硬件选型门禁。
166
166
 
167
- 发布搜索索引前,从同一份 `_manifest.json` 和 Markdown 构建分类索引;完整 Wiki 无需复制。先做不连接 TOS 的校验:
167
+ 发布搜索索引前,从同一份 `_manifest.json` 和 Markdown 构建分类索引;完整 Wiki 无需复制。先做不连接 OSS 的校验:
168
168
 
169
169
  ```bash
170
170
  npm run publish:hardware-search-index -- \
@@ -184,16 +184,16 @@ npm run publish:hardware-search-index -- \
184
184
 
185
185
  如果旧上传器只上传了 Wiki Markdown、云端尚无 `_manifest.json`,首次加 `--bootstrap-manifest`;发布器会先逐个验证全部远端 Wiki 后才创建 Manifest。后续正常发布不再需要该参数。
186
186
 
187
- 也可分别运行 `npm run verify:hardware-search-index` 和 `npm run smoke:hardware-search`。搜索默认要求索引存在;缺少 `_catalog.json` 会返回 `WIKI_SEARCH_INDEX_MISSING`,不会静默下载整个分类。迁移期可显式设置 `TOS_ALLOW_LEGACY_MANIFEST_FALLBACK=true`,但响应会标记 `legacy_manifest_full_scan`,不应作为长期生产配置。格式见 [search-index-format.md](skills/abelien-hardware/references/search-index-format.md),发布操作见 [search-index-release.md](skills/abelien-hardware/references/search-index-release.md)。
187
+ 也可分别运行 `npm run verify:hardware-search-index` 和 `npm run smoke:hardware-search`。搜索默认要求索引存在;缺少 `_catalog.json` 会返回 `WIKI_SEARCH_INDEX_MISSING`,不会静默下载整个分类。迁移期可显式设置 `OSS_ALLOW_LEGACY_MANIFEST_FALLBACK=true`,但响应会标记 `legacy_manifest_full_scan`,不应作为长期生产配置。格式见 [search-index-format.md](skills/abelien-hardware/references/search-index-format.md),发布操作见 [search-index-release.md](skills/abelien-hardware/references/search-index-release.md)。
188
188
 
189
189
  在本仓库内启动硬件阶段:
190
190
 
191
191
  ```bash
192
- export TOS_REGION='cn-beijing'
193
- export TOS_ENDPOINT='https://tos-s3-cn-beijing.volces.com'
194
- export TOS_BUCKET='your-bucket'
195
- export TOS_ACCESS_KEY_ID='...'
196
- export TOS_SECRET_ACCESS_KEY='...'
192
+ export CAD_STORAGE_REGION='cn-hangzhou'
193
+ export CAD_STORAGE_ENDPOINT='https://oss-cn-hangzhou.aliyuncs.com'
194
+ export CAD_STORAGE_BUCKET='your-bucket'
195
+ export CAD_STORAGE_ACCESS_KEY='...'
196
+ export CAD_STORAGE_SECRET_KEY='...'
197
197
  export ABELIEN_HARDWARE_REVISE_URL='https://your-selector.example/revise'
198
198
  node skills/abelien-harness/scripts/ab.mjs workflow run user-journey-hardware --input '{"requirements":{"context":{"text":"放在桌面上","source":"user"},"reaction":[{"id":"r-turn","text":"有人靠近时转头看向对方","source":"user"}],"limits":[{"id":"l-net","text":"不能联网","source":"user","strength":"hard"}]}}'
199
199
  # 返回 awaiting_agent 后,按请求读取 skills/abelien-hardware/SKILL.md,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "abelien-harness",
3
- "version": "0.8.0",
3
+ "version": "0.9.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 实时检索、英文 Volc 搜索加来源直读的来源绑定补选(Prismer 默认关闭)、整机复用、型号语义选型和已有方案局部修订。用于需求已澄清后的硬件阶段,不用于需求澄清或外观3D生成。
3
+ description: 处理 Abelien 硬件分类、OSS Wiki 实时检索、英文 Volc 搜索加来源直读的来源绑定补选(Prismer 默认关闭)、整机复用、型号语义选型和已有方案局部修订。用于需求已澄清后的硬件阶段,不用于需求澄清或外观3D生成。
4
4
  license: MIT
5
5
  compatibility:
6
6
  - claude-code
@@ -35,16 +35,16 @@ allowed-tools:
35
35
 
36
36
  不要一次加载整个分类目录或大量 Wiki 全文。分类阶段只产出功能槽位、数量、受控三级路径和本次硬约束,不指定 SKU。
37
37
 
38
- - 新建方案可先调用 `hardware.search`,按受控三级分类读取 TOS `_catalog.json` 和对应的压缩分类索引,只下载索引 Top-K 的完整 Wiki;详细协议见[Wiki 检索契约](references/knowledge-recall-service-contract.md)。也可直接执行 `ab search_hardware <query> --category-path <l1/l2/l3>`。它只产生候选召回,不能替代正式选型服务。
38
+ - 新建方案可先调用 `hardware.search`,按受控三级分类读取 OSS `_catalog.json` 和对应的压缩分类索引,只下载索引 Top-K 的完整 Wiki;详细协议见[Wiki 检索契约](references/knowledge-recall-service-contract.md)。也可直接执行 `ab search_hardware <query> --category-path <l1/l2/l3>`。它只产生候选召回,不能替代正式选型服务。
39
39
  - Agent 必须读取检索结果中的最新完整 Wiki,按功能槽位逐槽位选择零部件;同类整机配置只作参考,不先比较或套用整机(还原、基于现成产品修改或用户明确要求沿用整机时除外);只能提交候选中真实的 `component_id`。
40
40
  - 候选比较如需保留,调用本地 `hardware.finalize` 归一化为 `hardware-proposal`;`proposed`/`needs_confirmation` 只代表候选提案,不能通过 `hardware-selection` 门,也不能直接开始算法阶段。
41
41
  - 正式新建选型走同级 `abelien-hardware-selection` 的本地 `hardware.select`:回读真实召回资源校验 Agent 逐项证据判断,返回版本化 `hardware-selection`。`hardware.finalize` 本身仍不授予 verified。具体契约及未实现工程能力见其 selection-service-contract;主流程不需要 HTTP 服务地址。
42
42
  - 已有方案只改指定部件时,先读[规划模式与修订边界](references/planning-modes.md),再调用 `hardware.revise`;未点名槽位保持不变。
43
43
  - 接入或调试服务时读[选型服务契约](references/selection-service-contract.md)。
44
44
 
45
- TOS 读取所需变量是 `TOS_REGION`、`TOS_ENDPOINT`、`TOS_BUCKET`、`TOS_ACCESS_KEY_ID`、`TOS_SECRET_ACCESS_KEY`;可选 `TOS_ADDRESSING_STYLE`、`TOS_WIKI_PREFIX`、`TOS_SEARCH_CONCURRENCY`、`TOS_MAX_FULL_WIKI_FETCH_PER_SLOT`。没有对应 `TOS_*` 时兼容服务端 `CAD_STORAGE_*` 名称。CLI 会读取启动目录的 `.env`、`.env.local`,但进程变量优先;凭据不得写入 Skill、workflow、日志或结果。
45
+ OSS 连接与 Web 共用变量 `CAD_STORAGE_REGION`、`CAD_STORAGE_ENDPOINT`、`CAD_STORAGE_BUCKET`、`CAD_STORAGE_ACCESS_KEY`、`CAD_STORAGE_SECRET_KEY`;可选 `CAD_STORAGE_ADDRESSING_STYLE`、`CAD_STORAGE_SESSION_TOKEN`,以及检索调参 `OSS_WIKI_PREFIX`、`OSS_SEARCH_CONCURRENCY`、`OSS_MAX_FULL_WIKI_FETCH_PER_SLOT`。每项只有这一个名字,旧 `TOS_*` 不再读取。CLI 会读取启动目录的 `.env`、`.env.local`,但进程变量优先;凭据不得写入 Skill、workflow、日志或结果。
46
46
 
47
- 发布索引时按[分类索引格式](references/search-index-format.md)构建,并按[搜索索引发布](references/search-index-release.md)执行版本化上传、Catalog 最后激活、远端校验和真实冒烟。迁移期只有显式设置 `TOS_ALLOW_LEGACY_MANIFEST_FALLBACK=true` 才允许旧 Manifest 全文扫描,结果会标记降级。局部修订服务地址仍来自 `ABELIEN_HARDWARE_REVISE_URL`。未配置连接、分类路径不受控、版本冲突或证据不足时必须停止;不得按 `draft`、维护门禁或 CAD 状态排除候选。
47
+ 发布索引时按[分类索引格式](references/search-index-format.md)构建,并按[搜索索引发布](references/search-index-release.md)执行版本化上传、Catalog 最后激活、远端校验和真实冒烟。迁移期只有显式设置 `OSS_ALLOW_LEGACY_MANIFEST_FALLBACK=true` 才允许旧 Manifest 全文扫描,结果会标记降级。局部修订服务地址仍来自 `ABELIEN_HARDWARE_REVISE_URL`。未配置连接、分类路径不受控、版本冲突或证据不足时必须停止;不得按 `draft`、维护门禁或 CAD 状态排除候选。
48
48
 
49
49
  ## Online supplemental candidates
50
50
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "abelien-hardware",
3
- "version": "1.4.1",
3
+ "version": "2.0.0",
4
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",
@@ -13,10 +13,10 @@
13
13
  ],
14
14
  "capabilities": {
15
15
  "hardware.search": {
16
- "description": "按三级功能槽位读取 TOS 压缩分类索引,先保守尺寸筛选再下载 Top-K Wiki,返回供 Agent 语义比较的完整候选页",
16
+ "description": "按三级功能槽位读取 OSS 压缩分类索引,先保守尺寸筛选再下载 Top-K Wiki,返回供 Agent 语义比较的完整候选页",
17
17
  "adapter": {
18
18
  "type": "module",
19
- "path": "scripts/tos-wiki-search.mjs",
19
+ "path": "scripts/oss-wiki-search.mjs",
20
20
  "export": "searchHardware"
21
21
  },
22
22
  "input": {
@@ -49,7 +49,7 @@
49
49
  "produces": "hardware-recall"
50
50
  },
51
51
  "hardware.finalize": {
52
- "description": "把 TOS Wiki 候选比较保存为硬件提案;不产生正式选型或 verified 结论",
52
+ "description": "把 OSS Wiki 候选比较保存为硬件提案;不产生正式选型或 verified 结论",
53
53
  "adapter": {
54
54
  "type": "module",
55
55
  "path": "scripts/finalize-selection.mjs",
@@ -1,6 +1,6 @@
1
1
  # 硬件 Wiki 检索契约
2
2
 
3
- 本文件只在接入或调试 `hardware.search` 时读取。该能力由 Harness 本地 module adapter 执行:从 TOS 读取 `hardware_profile_wiki/_catalog.json` 和命中的三级分类索引,在轻量 Meta 上预排序,只下载 Top-K Markdown,再把少量完整 Wiki 交给 Agent 做最终语义比较。索引发布格式见[分类索引格式](search-index-format.md),凭据只从环境变量读取。
3
+ 本文件只在接入或调试 `hardware.search` 时读取。该能力由 Harness 本地 module adapter 执行:从 OSS 读取 `hardware_profile_wiki/_catalog.json` 和命中的三级分类索引,在轻量 Meta 上预排序,只下载 Top-K Markdown,再把少量完整 Wiki 交给 Agent 做最终语义比较。索引发布格式见[分类索引格式](search-index-format.md),凭据只从环境变量读取。
4
4
 
5
5
  ## 请求
6
6
 
@@ -39,7 +39,7 @@ ab search_hardware '低功耗离线视觉机器人主控' \
39
39
  - `component_id`,等于 Wiki Meta 的真实 `record_id`;
40
40
  - 厂商、准确型号、`category_path`、`integration_level`、`hardware_form`;
41
41
  - `functional_capabilities[]`、`selection_profile`、轻量匹配分数及匹配词;
42
- - TOS object key、相对路径、ETag、Manifest SHA-256 和源更新时间;
42
+ - OSS object key、相对路径、ETag、Manifest SHA-256 和源更新时间;
43
43
  - 解析后的选型 Meta,以及在 `include_content=true` 时的完整 Markdown。
44
44
 
45
45
  每槽位只下载并把索引排序后的 `top_k_per_slot` 篇完整 Wiki 放入 Agent 上下文,同时返回 `available_documents`、`indexed_candidates` 和 `fetched_documents`。`source_snapshot` 由 bucket、prefix、Catalog ETag 和所用分类分片 ETag 摘要构成,候选各自保存对象 ETag,因此可以重放证据。不同槽位命中相同索引或 Wiki 时在单次请求内复用缓存。
@@ -52,6 +52,6 @@ Wiki 中的 `status`/`draft`、`selection_gate`、CAD 状态或创建时间不
52
52
 
53
53
  ## 配置与失败
54
54
 
55
- 必填环境变量为 `TOS_REGION`、`TOS_ENDPOINT`、`TOS_BUCKET`、`TOS_ACCESS_KEY_ID` 和 `TOS_SECRET_ACCESS_KEY`;对应项缺失时接受 Web 服务的 `CAD_STORAGE_REGION`、`CAD_STORAGE_ENDPOINT`、`CAD_STORAGE_BUCKET`、`CAD_STORAGE_ACCESS_KEY`、`CAD_STORAGE_SECRET_KEY`。可选 `TOS_ADDRESSING_STYLE=virtual|path`、`TOS_WIKI_PREFIX`、`TOS_SEARCH_CONCURRENCY`、`TOS_MAX_FULL_WIKI_FETCH_PER_SLOT`。后者默认 24,用于防止误把过大的 `top_k_per_slot` 变成批量全文下载。缺配置、Catalog/分类索引无效、索引与 Wiki 身份或 SHA-256 不一致、对象读取失败或 Wiki front matter 无效时显式失败,不允许跳过检索。
55
+ 必填环境变量与 Web 服务共用:`CAD_STORAGE_REGION`、`CAD_STORAGE_ENDPOINT`、`CAD_STORAGE_BUCKET`、`CAD_STORAGE_ACCESS_KEY` 和 `CAD_STORAGE_SECRET_KEY`,旧 `TOS_*` 不再读取。可选 `CAD_STORAGE_ADDRESSING_STYLE=virtual|path`(默认 `virtual`)、`CAD_STORAGE_SESSION_TOKEN`,以及 `OSS_WIKI_PREFIX`、`OSS_SEARCH_CONCURRENCY`、`OSS_MAX_FULL_WIKI_FETCH_PER_SLOT`、`OSS_REQUEST_TIMEOUT_MS`、`OSS_MAX_OBJECT_BYTES`、`OSS_REQUEST_ATTEMPTS`(幂等读遇瞬时故障的总尝试次数,含首次,默认 3,1–5)。后者默认 24,用于防止误把过大的 `top_k_per_slot` 变成批量全文下载。缺配置、Catalog/分类索引无效、索引与 Wiki 身份或 SHA-256 不一致、对象读取失败或 Wiki front matter 无效时显式失败,不允许跳过检索。
56
56
 
57
- 缺少 `_catalog.json` 默认返回 `WIKI_SEARCH_INDEX_MISSING`,避免退回大量全文下载。仅迁移期可显式设置 `TOS_ALLOW_LEGACY_MANIFEST_FALLBACK=true`;降级响应使用 `retrieval_mode: legacy_manifest_full_scan` 并写入 `SEARCH_INDEX_FALLBACK`。
57
+ 缺少 `_catalog.json` 默认返回 `WIKI_SEARCH_INDEX_MISSING`,避免退回大量全文下载。仅迁移期可显式设置 `OSS_ALLOW_LEGACY_MANIFEST_FALLBACK=true`;降级响应使用 `retrieval_mode: legacy_manifest_full_scan` 并写入 `SEARCH_INDEX_FALLBACK`。
@@ -1,10 +1,10 @@
1
1
  # 硬件 Wiki 分类索引格式
2
2
 
3
- 本文件只在发布、校验或迁移 TOS 硬件 Wiki 搜索索引时读取。完整 Wiki 是型号选型证据;尺寸仅以数据库导出快照为编辑源,生成 Wiki 和索引须逐字段同源。索引在 Top-K 前执行保守尺寸筛选,不替代 Wiki。
3
+ 本文件只在发布、校验或迁移 OSS 硬件 Wiki 搜索索引时读取。完整 Wiki 是型号选型证据;尺寸仅以数据库导出快照为编辑源,生成 Wiki 和索引须逐字段同源。索引在 Top-K 前执行保守尺寸筛选,不替代 Wiki。
4
4
 
5
5
  ## 对象布局
6
6
 
7
- 索引与现有 Wiki 共用 `TOS_WIKI_PREFIX`,默认布局为:
7
+ 索引与现有 Wiki 共用 `OSS_WIKI_PREFIX`,默认布局为:
8
8
 
9
9
  ```text
10
10
  hardware_profile_wiki/
@@ -25,7 +25,7 @@ hardware_profile_wiki/
25
25
  ```bash
26
26
  npm run build:hardware-search-index -- \
27
27
  --source /absolute/path/to/硬件wiki \
28
- --output /absolute/path/to/tos-search-index
28
+ --output /absolute/path/to/oss-search-index
29
29
  ```
30
30
 
31
31
  构建器逐页验证 `record_id`、三级 `category_path` 和 Wiki SHA-256与 Manifest 一致,随后输出 `_catalog.json` 和 `indexes/releases/<release_id>/**/*.json.gz`。不带尺寸快照时输出仅有搜索索引;带 `--component-snapshot` 时另生成 Wiki 和 Manifest,见下节。
@@ -36,7 +36,7 @@ npm run build:hardware-search-index -- \
36
36
 
37
37
  正常查询只执行:一个 `_catalog.json` GET、每个不同三级分类一个索引 GET,以及每槽位最多 `top_k_per_slot` 个 Wiki GET。同一请求中相同索引和 Wiki 会复用缓存。
38
38
 
39
- 默认缺少 `_catalog.json` 时返回 `WIKI_SEARCH_INDEX_MISSING`,不会悄悄下载整个分类。迁移期可显式设置 `TOS_ALLOW_LEGACY_MANIFEST_FALLBACK=true`;此时响应的 `retrieval_mode` 为 `legacy_manifest_full_scan`,并在 `unknowns[]` 写入 `SEARCH_INDEX_FALLBACK`。迁移开关不应作为长期生产配置。
39
+ 默认缺少 `_catalog.json` 时返回 `WIKI_SEARCH_INDEX_MISSING`,不会悄悄下载整个分类。迁移期可显式设置 `OSS_ALLOW_LEGACY_MANIFEST_FALLBACK=true`;此时响应的 `retrieval_mode` 为 `legacy_manifest_full_scan`,并在 `unknowns[]` 写入 `SEARCH_INDEX_FALLBACK`。迁移开关不应作为长期生产配置。
40
40
 
41
41
  正常响应的 `retrieval_mode` 为 `category_index`。每个槽位同时返回 `available_documents`、`indexed_candidates` 和 `fetched_documents`,用于核对“索引看了多少、全文实际下载多少”。
42
42
 
@@ -58,7 +58,7 @@ source 与 output 须是互不嵌套的目录。每个 Wiki record_id 必须命
58
58
 
59
59
  候选和 selected_models 携带 dimensions_mm、dimension_basis、dimension_geometry、dimension_source={component_id,revision,snapshot_id,evidence_ref?}、dimension_check={status,decision_required,reasons,method}。status 只表达粗筛装配结果:clear 为所有粗筛通过,risk 为至少一项失败但不构成证明,incompatible 为用户 hard 上限+measured/datasheet 尺寸下的证明,unknown 为无法判定;依据与上限权威仍看 dimension_basis 和 limit source,不另编码进 status。decision_required 仅在用户 hard 上限+measured/datasheet 尺寸且轴对齐装法全部失败时为 true。索引读入时每条 Wiki sha256 必须存在且合法;即使该行会被尺寸剔除,也不能缺 hash。实际抓取时验证正文 hash、身份、分类及全部尺寸元数据与索引相同。source_snapshot 包含 catalog 和实际分片内容摘要,不以 ETag 代替内容身份。
60
60
 
61
- 旧 v1 索引缺内容 hash 必须重新构建,迁移开关也不绕过 hash;缺尺寸或缺 geometry 的旧条目仍可读,但 dimension_check=unknown,不获得 hard rejection 或 clear。新索引为 legacy 无尺寸记录显式输出 null/unknown。搜索保留所有 unknown 和 estimate 候选;`dimension_screen` 报 evaluated/rejected/retained/demoted 和逐个硬拒绝原因。按相关度排序后,risk 候选保留但不占 Top-K 名额,因此每个 risk 会多带入一个可能装得下的替补,总抓取数不超过 TOS_MAX_FULL_WIKI_FETCH_PER_SLOT;返回列表先列非 risk、后列 risk,各层内保持相关度顺序,unknown 不降级。`all_indexed_candidates_rejected` 仅指该槽位已评估分类索引,不能说所有硬件都不适用。
61
+ 旧 v1 索引缺内容 hash 必须重新构建,迁移开关也不绕过 hash;缺尺寸或缺 geometry 的旧条目仍可读,但 dimension_check=unknown,不获得 hard rejection 或 clear。新索引为 legacy 无尺寸记录显式输出 null/unknown。搜索保留所有 unknown 和 estimate 候选;`dimension_screen` 报 evaluated/rejected/retained/demoted 和逐个硬拒绝原因。按相关度排序后,risk 候选保留但不占 Top-K 名额,因此每个 risk 会多带入一个可能装得下的替补,总抓取数不超过 OSS_MAX_FULL_WIKI_FETCH_PER_SLOT;返回列表先列非 risk、后列 risk,各层内保持相关度顺序,unknown 不降级。`all_indexed_candidates_rejected` 仅指该槽位已评估分类索引,不能说所有硬件都不适用。
62
62
 
63
63
  ## 尺寸判定与占用边界
64
64
 
@@ -1,10 +1,10 @@
1
1
  # 硬件 Wiki 搜索索引发布
2
2
 
3
- 本文件在构建、发布、验证或排查 TOS 搜索索引时读取。Wiki 正文与 `_manifest.json` 由知识库仓库维护;Harness 不复制数千篇源 Wiki,但提供从一个完整 Wiki 快照到可用搜索服务的闭环命令。
3
+ 本文件在构建、发布、验证或排查 OSS 搜索索引时读取。Wiki 正文与 `_manifest.json` 由知识库仓库维护;Harness 不复制数千篇源 Wiki,但提供从一个完整 Wiki 快照到可用搜索服务的闭环命令。
4
4
 
5
5
  ## 输入契约
6
6
 
7
- `--source` 指向的目录必须包含 `_manifest.json` 及 Manifest 中引用的全部 Markdown。构建器逐页校验 `record_id`、三级 `category_path` 和 SHA-256。远端 `TOS_WIKI_PREFIX/_manifest.json` 必须与本地 Manifest 字节一致,否则发布在写入任何索引对象前失败。
7
+ `--source` 指向的目录必须包含 `_manifest.json` 及 Manifest 中引用的全部 Markdown。构建器逐页校验 `record_id`、三级 `category_path` 和 SHA-256。远端 `OSS_WIKI_PREFIX/_manifest.json` 必须与本地 Manifest 字节一致,否则发布在写入任何索引对象前失败。
8
8
 
9
9
  仓库内的 `skills/abelien-hardware/references/hardware-selection/` 是自包含的分类规则快照,与 Wiki 数据源是不同工件:前者决定如何分类和比较,后者提供实际型号事实。
10
10
 
@@ -12,13 +12,13 @@
12
12
 
13
13
  CLI 自动从启动目录依次加载 `.env`、`.env.local`,进程中已存在的变量优先;也可传 `--env-file <path>`。复制仓库根 `.env.example` 后填写以下服务端变量,真实凭据不得提交:
14
14
 
15
- - `TOS_REGION`、`TOS_ENDPOINT`、`TOS_BUCKET`、`TOS_ACCESS_KEY_ID`、`TOS_SECRET_ACCESS_KEY`;
16
- - 可选 `TOS_ADDRESSING_STYLE`、`TOS_WIKI_PREFIX`、`TOS_PUBLISH_CONCURRENCY`;
17
- - 若对应 `TOS_*` 未设置,也接受 Web 部署使用的 `CAD_STORAGE_REGION`、`CAD_STORAGE_ENDPOINT`、`CAD_STORAGE_BUCKET`、`CAD_STORAGE_ACCESS_KEY`、`CAD_STORAGE_SECRET_KEY`、`CAD_STORAGE_ADDRESSING_STYLE`。
15
+ - 与 Web 部署共用的 OSS 连接:`CAD_STORAGE_REGION`、`CAD_STORAGE_ENDPOINT`、`CAD_STORAGE_BUCKET`、`CAD_STORAGE_ACCESS_KEY`、`CAD_STORAGE_SECRET_KEY`;
16
+ - 可选 `CAD_STORAGE_ADDRESSING_STYLE`、`CAD_STORAGE_SESSION_TOKEN`、`OSS_WIKI_PREFIX`、`OSS_PUBLISH_CONCURRENCY`;
17
+ - 旧 `TOS_*` 名称不再读取。
18
18
 
19
19
  ## 命令
20
20
 
21
- 先只构建和校验,不连接或修改 TOS:
21
+ 先只构建和校验,不连接或修改 OSS:
22
22
 
23
23
  ```bash
24
24
  npm run publish:hardware-search-index -- \
@@ -73,6 +73,6 @@ Catalog 激活失败只会留下未引用的不可变分片,不影响旧版本
73
73
 
74
74
  数据库导出由 Web 仓库显式只读命令产生;Harness 不接收数据库凭据。尺寸发布使用同一命令的 `--component-snapshot /absolute/path/to/component-dimensions.json`,先运行 `--dry-run --output /absolute/path/to/review` 检查生成 Wiki、Manifest、导出和索引。确定发布环境后去掉 dry-run,才会执行远端写入。
75
75
 
76
- 这条路径按 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。故障只留下未引用的不可变文件;本地测试的内存 OSS 只证明执行顺序和摘要,不证明线上发布成功。
77
77
 
78
78
  v1 迁移:所有索引记录必须具备 Wiki 内容 SHA-256。旧无 hash 索引重建再发布;旧无尺寸/geometry 记录保持 unknown。dimensions 更新会改变源 revision、snapshot_id、生成 Wiki 内容 hash、Manifest 身份与分片摘要;旧固定产物保持原字节,不原地“补齐”过去的候选。
@@ -1,12 +1,12 @@
1
1
  # 硬件选型归一化与修订边界
2
2
 
3
- 本文件在调试候选提案归一化或已有方案修订时读取。TOS 候选流程使用本地 `hardware.finalize`,只产生 `hardware-proposal`;正式新建流程必须使用同级 `abelien-hardware-selection` 的本地 `hardware.select`。局部修订仍使用 HTTP `hardware.revise`,未配置 URL 时返回 `ENV_MISSING`。
3
+ 本文件在调试候选提案归一化或已有方案修订时读取。OSS 候选流程使用本地 `hardware.finalize`,只产生 `hardware-proposal`;正式新建流程必须使用同级 `abelien-hardware-selection` 的本地 `hardware.select`。局部修订仍使用 HTTP `hardware.revise`,未配置 URL 时返回 `ENV_MISSING`。
4
4
 
5
5
  ## 候选提案 `hardware.finalize`
6
6
 
7
7
  本地 module adapter 接收 `operation: "create"`、`planning_mode: "auto"`、已澄清的 `requirements`、`slots[]`、上一步 `hardware.search` 的完整 `knowledge_recall` 响应,以及 Agent 阅读最新 Wiki 后形成的 `selection_proposal`。每槽位有稳定 `slot_id`、功能、`quantity`、受控 `category_path[3]` 及本次硬约束;提案只可引用对应槽位检索候选中的 `component_id`。保存器会再次校验这一关系,并以候选 Wiki 中的厂商、型号、分类和对象证据为准,拒绝 Agent 自行生成的型号标识。
8
8
 
9
- Agent 按[型号检索契约](hardware-selection/references/query-contract.md)判断分类路径、数量、整机覆盖扣减及关键接口、供电、机械、热和软件依赖;本地能力检查结果结构并生成稳定 ID/版本。TOS 检索结果是本次型号候选和语义证据,不另起一套语义选择。不得按 `draft`、维护选型门禁或 CAD 状态过滤。
9
+ Agent 按[型号检索契约](hardware-selection/references/query-contract.md)判断分类路径、数量、整机覆盖扣减及关键接口、供电、机械、热和软件依赖;本地能力检查结果结构并生成稳定 ID/版本。OSS 检索结果是本次型号候选和语义证据,不另起一套语义选择。不得按 `draft`、维护选型门禁或 CAD 状态过滤。
10
10
 
11
11
  响应必须具有非空稳定 `id`、`status`、`source_snapshot`、`revision`、`plan_mode`,以及 `selected_models[]`、`coverage_ledger[]`、`remaining_slots[]`、`unknowns[]`、`replacement_plan[]`。`selected_models[]` 每项必须带**显式的 1–128 整数 `quantity`**(缺失、非整数或越界一律 `VALIDATION` 拒绝):这是本仓唯一能独立核对的覆盖依据,缺了它 `coverage_ledger` 的 `verified_allocated_quantity` 就无从对照。`plan_mode` 只能是 `adapt_system` 或 `assemble_new`;前者须返回基线整机及完整继承/修改证据,后者不得假装继承整机。提案 `status` 只能是 `proposed` 或 `needs_confirmation`,即使字段完整也不授予正式 `verified` 权限。
12
12
 
@@ -18,7 +18,7 @@ URL 来自 `ABELIEN_HARDWARE_REVISE_URL`。请求包含 `operation: "revise_exis
18
18
 
19
19
  所有能力的响应结构不合格都不会保存资源或通过阶段门。本地归一化输入错误返回 `VALIDATION`,能力结果违反声明返回 `CAPABILITY_RESULT_INVALID`。
20
20
 
21
- 候选工作流将响应保存在 `hardware-proposal` 资源,不创建 `hardware-selection` 状态机实例。正式工作流才把 `hardware.select` 响应保存为 `hardware-selection` 并通过 `hardware-selection` 状态门;候选事实来自本次 TOS Wiki 检索,当前域包不建立另一套数据库候选系统。
21
+ 候选工作流将响应保存在 `hardware-proposal` 资源,不创建 `hardware-selection` 状态机实例。正式工作流才把 `hardware.select` 响应保存为 `hardware-selection` 并通过 `hardware-selection` 状态门;候选事实来自本次 OSS Wiki 检索,当前域包不建立另一套数据库候选系统。
22
22
 
23
23
  ## Supplemental source binding
24
24
 
@@ -7,7 +7,7 @@ import zlib from "node:zlib";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { validateComponentSnapshot, fieldsFromSnapshotRecord, dimensionFields } from './dimensions.mjs';
9
9
  import { digest } from './selection-request.mjs';
10
- import { parseHardwareWiki } from "./tos-wiki-search.mjs";
10
+ import { parseHardwareWiki } from "./oss-wiki-search.mjs";
11
11
 
12
12
  const CATALOG_SCHEMA = "abelien.hardware-wiki-search-catalog.v1";
13
13
  const INDEX_SCHEMA = "abelien.hardware-wiki-category-index.v1";
@@ -0,0 +1,247 @@
1
+ import crypto from "node:crypto";
2
+
3
+ const EMPTY = Buffer.alloc(0);
4
+
5
+ function ossError(code, message, details) {
6
+ const error = new Error(message);
7
+ error.code = code;
8
+ if (details !== undefined) error.details = details;
9
+ return error;
10
+ }
11
+
12
+ export function sha256(value) {
13
+ return crypto.createHash("sha256").update(value).digest("hex");
14
+ }
15
+
16
+ function hmac(key, value, encoding) {
17
+ return crypto.createHmac("sha256", key).update(value).digest(encoding);
18
+ }
19
+
20
+ function normalizePrefix(value) {
21
+ const cleaned = String(value || "hardware_profile_wiki/").replace(/^\/+|\/+$/g, "");
22
+ if (!cleaned || cleaned.split("/").some(part => part === "." || part === "..")) {
23
+ throw ossError("VALIDATION", "invalid OSS Wiki prefix");
24
+ }
25
+ return `${cleaned}/`;
26
+ }
27
+
28
+ const MAX_REQUEST_ATTEMPTS = 5;
29
+
30
+ function boundedAttempts(env) {
31
+ const value = positiveIntegerEnv(env, "OSS_REQUEST_ATTEMPTS", 3);
32
+ if (value > MAX_REQUEST_ATTEMPTS) throw ossError("VALIDATION", `OSS_REQUEST_ATTEMPTS must be at most ${MAX_REQUEST_ATTEMPTS}`, { name: "OSS_REQUEST_ATTEMPTS" });
33
+ return value;
34
+ }
35
+
36
+ function positiveIntegerEnv(env, name, fallback) {
37
+ if (env[name] === undefined || env[name] === "") return fallback;
38
+ const value = Number(env[name]);
39
+ if (!Number.isInteger(value) || value <= 0) throw ossError("VALIDATION", `${name} must be a positive integer`, { name });
40
+ return value;
41
+ }
42
+
43
+ // 连接参数与 Web 共用同一组 CAD_STORAGE_* 名字(一个事实一个名字,不设别名);
44
+ // OSS_* 只放本仓独有的检索与发布调参。
45
+ export function createOssConfig(env = {}) {
46
+ const required = ["CAD_STORAGE_REGION", "CAD_STORAGE_ENDPOINT", "CAD_STORAGE_BUCKET", "CAD_STORAGE_ACCESS_KEY", "CAD_STORAGE_SECRET_KEY"];
47
+ const missing = required.filter(name => !env[name]);
48
+ if (missing.length) throw ossError("ENV_MISSING", `env var not set: ${missing[0]}`, { name: missing[0], missing });
49
+ let endpoint;
50
+ try { endpoint = new URL(env.CAD_STORAGE_ENDPOINT); }
51
+ catch { throw ossError("VALIDATION", "CAD_STORAGE_ENDPOINT must be an absolute URL"); }
52
+ if (!['http:', 'https:'].includes(endpoint.protocol)) throw ossError("VALIDATION", "CAD_STORAGE_ENDPOINT must use http or https");
53
+ if (endpoint.username || endpoint.password || endpoint.search || endpoint.hash) {
54
+ throw ossError("VALIDATION", "CAD_STORAGE_ENDPOINT must not contain credentials, query or fragment");
55
+ }
56
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{1,62}$/.test(env.CAD_STORAGE_BUCKET)) throw ossError("VALIDATION", "invalid CAD_STORAGE_BUCKET");
57
+ const addressingStyle = env.CAD_STORAGE_ADDRESSING_STYLE || "virtual";
58
+ if (!['virtual', 'path'].includes(addressingStyle)) throw ossError("VALIDATION", "CAD_STORAGE_ADDRESSING_STYLE must be virtual or path");
59
+ return {
60
+ region: env.CAD_STORAGE_REGION,
61
+ endpoint,
62
+ bucket: env.CAD_STORAGE_BUCKET,
63
+ accessKeyId: env.CAD_STORAGE_ACCESS_KEY,
64
+ secretAccessKey: env.CAD_STORAGE_SECRET_KEY,
65
+ sessionToken: env.CAD_STORAGE_SESSION_TOKEN || "",
66
+ addressingStyle,
67
+ prefix: normalizePrefix(env.OSS_WIKI_PREFIX),
68
+ // 网络读取的两道边界。此前 module adapter 路径既无超时也无体积上限:一个不响应的
69
+ // 对象存储会把召回挂到进程结束,一个超大对象会被整段读进内存(召回本身还并发 8 路)。
70
+ // 默认值取"够宽但有限"——Wiki 与分类分片都是小对象,写读都要走这两道闸。
71
+ timeoutMs: positiveIntegerEnv(env, "OSS_REQUEST_TIMEOUT_MS", 30000),
72
+ maxObjectBytes: positiveIntegerEnv(env, "OSS_MAX_OBJECT_BYTES", 16 * 1024 * 1024),
73
+ // 幂等读(GET/HEAD)遇到瞬时故障的总尝试次数(含首次)。召回一次要并发读几十个 Wiki,
74
+ // 任一个连接被断都会让整个 hardware.search 失败、选型流程判死(2026-10-13 小时钟 602fd178)。
75
+ requestAttempts: boundedAttempts(env),
76
+ };
77
+ }
78
+
79
+ // 先按声明的 content-length 拦一道(能在读之前拒),再在读的过程中边读边数——
80
+ // arrayBuffer() 是先整段进内存再回看长度,对"超大对象"这个失败形态等于没拦。
81
+ async function readObjectBytes(response, maxBytes, key, method) {
82
+ const declared = Number(response.headers?.get?.("content-length"));
83
+ if (Number.isFinite(declared) && declared > maxBytes) {
84
+ throw ossError("OSS_OBJECT_TOO_LARGE", `OSS object exceeds OSS_MAX_OBJECT_BYTES (${maxBytes})`, { key, method, maxBytes, contentLength: declared });
85
+ }
86
+ if (response.body && typeof response.body.getReader === "function") {
87
+ const reader = response.body.getReader();
88
+ const chunks = [];
89
+ let total = 0;
90
+ for (;;) {
91
+ const { done, value } = await reader.read();
92
+ if (done) break;
93
+ total += value.byteLength;
94
+ if (total > maxBytes) {
95
+ await reader.cancel().catch(() => {});
96
+ throw ossError("OSS_OBJECT_TOO_LARGE", `OSS object exceeds OSS_MAX_OBJECT_BYTES (${maxBytes})`, { key, method, maxBytes });
97
+ }
98
+ chunks.push(Buffer.from(value));
99
+ }
100
+ return Buffer.concat(chunks);
101
+ }
102
+ const bytes = typeof response.arrayBuffer === "function"
103
+ ? Buffer.from(await response.arrayBuffer())
104
+ : Buffer.from(await response.text(), "utf8");
105
+ if (bytes.length > maxBytes) {
106
+ throw ossError("OSS_OBJECT_TOO_LARGE", `OSS object exceeds OSS_MAX_OBJECT_BYTES (${maxBytes})`, { key, method, maxBytes, contentLength: bytes.length });
107
+ }
108
+ return bytes;
109
+ }
110
+
111
+ function encodePath(value) {
112
+ return String(value).split("/").map(part => encodeURIComponent(part).replace(/[!'()*]/g, char => `%${char.charCodeAt(0).toString(16).toUpperCase()}`)).join("/");
113
+ }
114
+
115
+ function objectLocation(config, key) {
116
+ const cleanKey = String(key).replace(/^\/+/, "");
117
+ if (!cleanKey || cleanKey.split("/").some(part => part === "." || part === "..")) throw ossError("VALIDATION", "invalid OSS object key", { key });
118
+ const parts = [config.endpoint.pathname.replace(/^\/+|\/+$/g, "")].filter(Boolean);
119
+ let host = config.endpoint.host;
120
+ if (config.addressingStyle === "path") parts.push(config.bucket);
121
+ else host = `${config.bucket}.${host}`;
122
+ parts.push(cleanKey);
123
+ const uri = `/${encodePath(parts.join("/"))}`;
124
+ return { host, uri, url: `${config.endpoint.protocol}//${host}${uri}` };
125
+ }
126
+
127
+ export function signOssObjectRequest(config, key, { method = "GET", body = EMPTY, headers = {}, now = new Date() } = {}) {
128
+ const payload = Buffer.isBuffer(body) ? body : Buffer.from(body ?? "");
129
+ const location = objectLocation(config, key);
130
+ const timestamp = now.toISOString().replace(/[:-]|\.\d{3}/g, "");
131
+ const date = timestamp.slice(0, 8);
132
+ const signed = {
133
+ host: location.host,
134
+ "x-amz-content-sha256": sha256(payload),
135
+ "x-amz-date": timestamp,
136
+ };
137
+ for (const [name, value] of Object.entries(headers)) signed[name.toLowerCase()] = String(value).trim();
138
+ if (config.sessionToken) signed["x-amz-security-token"] = config.sessionToken;
139
+ const names = Object.keys(signed).sort();
140
+ const canonicalHeaders = names.map(name => `${name}:${signed[name]}\n`).join("");
141
+ const signedHeaders = names.join(";");
142
+ const canonicalRequest = [method.toUpperCase(), location.uri, "", canonicalHeaders, signedHeaders, sha256(payload)].join("\n");
143
+ const scope = `${date}/${config.region}/s3/aws4_request`;
144
+ const stringToSign = ["AWS4-HMAC-SHA256", timestamp, scope, sha256(canonicalRequest)].join("\n");
145
+ const dateKey = hmac(`AWS4${config.secretAccessKey}`, date);
146
+ const regionKey = hmac(dateKey, config.region);
147
+ const serviceKey = hmac(regionKey, "s3");
148
+ const signingKey = hmac(serviceKey, "aws4_request");
149
+ const signature = hmac(signingKey, stringToSign, "hex");
150
+ signed.authorization = `AWS4-HMAC-SHA256 Credential=${config.accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
151
+ return { url: location.url, method: method.toUpperCase(), headers: signed, body: payload };
152
+ }
153
+
154
+ // 只有这些失败值得原样再读一次:连接被断或没连上、超时、对端限流或暂时不可用。
155
+ // 4xx(鉴权、不存在、请求错误)与超限是确定性的,重试只会更慢地得到同一个结果。
156
+ const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
157
+
158
+ // fetch 规范里网络错误一律以 TypeError 拒绝(undici 的 "fetch failed"、正文中断的 "terminated");
159
+ // 其它异常(桩、编程错误)照旧报 OSS_REQUEST_FAILED,但不当作断线重试。标记不进序列化输出。
160
+ const NETWORK_FAILURE = Symbol("oss.networkFailure");
161
+
162
+ function requestFailed(cause, key, method) {
163
+ const error = ossError("OSS_REQUEST_FAILED", `OSS request failed: ${cause?.message ?? cause}`, { key, method });
164
+ if (cause instanceof TypeError) error[NETWORK_FAILURE] = true;
165
+ return error;
166
+ }
167
+
168
+ function transient(error) {
169
+ if (error?.[NETWORK_FAILURE] || error?.code === "OSS_REQUEST_TIMEOUT") return true;
170
+ return error?.code === "OSS_HTTP_STATUS" && RETRYABLE_STATUS.has(error.details?.status);
171
+ }
172
+
173
+ const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
174
+
175
+ // 写(PUT)不重试:结果未知时不能假定没写成;幂等读每次重新签名(时间戳随之更新),
176
+ // 退避 0.5 s → 1 s → 2 s 再加最多 250 ms 抖动,避免并发 8 路同时撞回去。
177
+ export async function requestOssObject(config, key, options = {}) {
178
+ const method = (options.method ?? "GET").toUpperCase();
179
+ const attempts = method === "GET" || method === "HEAD" ? config.requestAttempts ?? 1 : 1;
180
+ const sleep = options.sleep ?? pause;
181
+ for (let attempt = 1; ; attempt += 1) {
182
+ try { return await requestOssObjectOnce(config, key, options); }
183
+ catch (error) {
184
+ if (attempt >= attempts || !transient(error)) {
185
+ if (attempt > 1 && error.details && typeof error.details === "object") error.details.attempts = attempt;
186
+ throw error;
187
+ }
188
+ await sleep(500 * 2 ** (attempt - 1) + Math.floor(Math.random() * 250));
189
+ }
190
+ }
191
+ }
192
+
193
+ async function requestOssObjectOnce(config, key, { method = "GET", body = EMPTY, headers = {}, optional = false, fetchImpl = fetch } = {}) {
194
+ const request = signOssObjectRequest(config, key, { method, body, headers });
195
+ let response;
196
+ try {
197
+ response = await fetchImpl(request.url, {
198
+ method: request.method,
199
+ headers: request.headers,
200
+ // 超时覆盖到响应体读完(undici 把 signal 一路带到 body 流):否则一个连上就不发完的
201
+ // 对端同样能挂死召回。既有桩 fetch 忽略 signal,行为不变。
202
+ signal: AbortSignal.timeout(config.timeoutMs),
203
+ ...(request.method === "GET" || request.method === "HEAD" ? {} : { body: request.body }),
204
+ });
205
+ } catch (cause) {
206
+ if (cause?.name === "TimeoutError" || cause?.code === "ABORT_ERR") {
207
+ throw ossError("OSS_REQUEST_TIMEOUT", `OSS request timed out after ${config.timeoutMs}ms`, { key, method: request.method, timeout_ms: config.timeoutMs });
208
+ }
209
+ throw requestFailed(cause, key, request.method);
210
+ }
211
+ if (!response.ok) {
212
+ if (optional && response.status === 404) return null;
213
+ throw ossError("OSS_HTTP_STATUS", `OSS returned HTTP ${response.status}`, { key, method: request.method, status: response.status });
214
+ }
215
+ let bytes = EMPTY;
216
+ if (request.method !== "HEAD") {
217
+ // 响应头到了、正文读到一半连接断开或超时,与没连上是同一种瞬时故障。
218
+ try { bytes = await readObjectBytes(response, config.maxObjectBytes, key, request.method); }
219
+ catch (cause) {
220
+ if (cause?.code === "OSS_OBJECT_TOO_LARGE") throw cause;
221
+ if (cause?.name === "TimeoutError" || cause?.code === "ABORT_ERR") {
222
+ throw ossError("OSS_REQUEST_TIMEOUT", `OSS request timed out after ${config.timeoutMs}ms`, { key, method: request.method, timeout_ms: config.timeoutMs });
223
+ }
224
+ throw requestFailed(cause, key, request.method);
225
+ }
226
+ }
227
+ return {
228
+ key,
229
+ bytes,
230
+ etag: response.headers?.get?.("etag")?.replace(/^"|"$/g, "") || sha256(bytes),
231
+ lastModified: response.headers?.get?.("last-modified") || null,
232
+ contentLength: Number(response.headers?.get?.("content-length")) || null,
233
+ metadataSha256: response.headers?.get?.("x-amz-meta-sha256") || null,
234
+ };
235
+ }
236
+
237
+ export function getOssObject(config, key, options = {}) {
238
+ return requestOssObject(config, key, { ...options, method: "GET" });
239
+ }
240
+
241
+ export function putOssObject(config, key, body, options = {}) {
242
+ return requestOssObject(config, key, { ...options, method: "PUT", body });
243
+ }
244
+
245
+ export function headOssObject(config, key, options = {}) {
246
+ return requestOssObject(config, key, { ...options, method: "HEAD" });
247
+ }