From dbbf1697037c6025541ab4bae38f906982628423 Mon Sep 17 00:00:00 2001 From: jerboy Date: Mon, 10 Aug 2026 11:05:06 +0800 Subject: [PATCH] chore(repo): remove unused protocol packages Signed-off-by: jerboy --- .github/ISSUE_TEMPLATE/bug_report.md | 4 +- .github/ISSUE_TEMPLATE/protocol_proposal.md | 3 +- .github/PULL_REQUEST_TEMPLATE.md | 1 + CONTRIBUTING.en.md | 51 +- CONTRIBUTING.md | 27 +- README.en.md | 44 +- README.md | 32 +- docs/commit-convention.md | 4 +- docs/model-access-protocol.md | 4 +- docs/skill-protocol.md | 122 ---- docs/voice-protocol.md | 89 --- packages/skill-protocol/package.json | 23 - .../src/__tests__/delivery.test.ts | 183 ------ .../src/__tests__/manifest.test.ts | 118 ---- packages/skill-protocol/src/delivery.ts | 294 ---------- packages/skill-protocol/src/index.ts | 2 - packages/skill-protocol/src/manifest.ts | 176 ------ packages/skill-protocol/tsconfig.json | 11 - packages/voice-protocol/package.json | 22 - .../src/__tests__/protocol.test.ts | 486 ---------------- packages/voice-protocol/src/index.ts | 21 - packages/voice-protocol/src/parse.ts | 545 ------------------ packages/voice-protocol/src/routes.ts | 6 - packages/voice-protocol/src/types.ts | 222 ------- packages/voice-protocol/tsconfig.json | 16 - pnpm-lock.yaml | 18 - 26 files changed, 104 insertions(+), 2420 deletions(-) delete mode 100644 docs/skill-protocol.md delete mode 100644 docs/voice-protocol.md delete mode 100644 packages/skill-protocol/package.json delete mode 100644 packages/skill-protocol/src/__tests__/delivery.test.ts delete mode 100644 packages/skill-protocol/src/__tests__/manifest.test.ts delete mode 100644 packages/skill-protocol/src/delivery.ts delete mode 100644 packages/skill-protocol/src/index.ts delete mode 100644 packages/skill-protocol/src/manifest.ts delete mode 100644 packages/skill-protocol/tsconfig.json delete mode 100644 packages/voice-protocol/package.json delete mode 100644 packages/voice-protocol/src/__tests__/protocol.test.ts delete mode 100644 packages/voice-protocol/src/index.ts delete mode 100644 packages/voice-protocol/src/parse.ts delete mode 100644 packages/voice-protocol/src/routes.ts delete mode 100644 packages/voice-protocol/src/types.ts delete mode 100644 packages/voice-protocol/tsconfig.json diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 9ef4b78..d465442 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -7,7 +7,7 @@ labels: [bug] ## 涉及的包 / Affected package - + ## 复现 / Reproduction @@ -24,4 +24,4 @@ labels: [bug] ## 版本 / Version - + diff --git a/.github/ISSUE_TEMPLATE/protocol_proposal.md b/.github/ISSUE_TEMPLATE/protocol_proposal.md index 63e9dcc..f33d30e 100644 --- a/.github/ISSUE_TEMPLATE/protocol_proposal.md +++ b/.github/ISSUE_TEMPLATE/protocol_proposal.md @@ -7,7 +7,8 @@ labels: [proposal] ## 涉及的包 / Affected package - + ## 动机 / Motivation diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 9d3d6bb..8fa4990 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -25,6 +25,7 @@ ## 检查项 / Checklist - [ ] 遵循 [CONTRIBUTING](../CONTRIBUTING.md) 的协议演进纪律 / Follows CONTRIBUTING's protocol-evolution discipline +- [ ] 未新增 package、未增加 package 数量、未扩大存量 package 的业务边界 / Adds no package, does not increase package count, and does not expand an existing package into an unrelated domain - [ ] commit message 符合[规范](../docs/commit-convention.md) / Commit messages follow the convention - [ ] 一个 PR 只做一个逻辑变更 / One logical change per PR - [ ] 测试 fixture 使用中性占位值(无真实邮箱/组织名/内部标识) / Test fixtures use neutral placeholders diff --git a/CONTRIBUTING.en.md b/CONTRIBUTING.en.md index 2cf92ae..6b5845e 100644 --- a/CONTRIBUTING.en.md +++ b/CONTRIBUTING.en.md @@ -31,6 +31,21 @@ By contribution type: implements it. - **Security issues**: see [SECURITY.md](SECURITY.md). +### The package set is closed (hard rule) + +- This repository no longer accepts new packages. The set under `packages/` + may stay the same or shrink, but it must not grow. +- Compatible fixes, necessary evolution for real consumers, tests, docs, + removals, and consolidation of existing packages remain allowed. +- Do not create a package through a split or rename, keep a compatibility copy, + or put an unrelated domain into an existing package to bypass this rule. +- New domains, services, or cross-end protocols belong in their owning business + repository or require a separate architecture and distribution decision + outside this repository. +- Do not prebuild protocols for hypothetical future needs. Before deleting a + package, confirm that every known consumer has stopped referencing it; old + checkouts retain the content through their historical git pin. + ## Development environment - Node.js >= 22, pnpm 10.x @@ -45,6 +60,9 @@ By contribution type: `git clone --recurse-submodules`. - **Changing the protocol itself** (message types, fields, envelope, routing semantics, constants): open a PR here and follow the two-stage flow below. +- **Adding a protocol package**: this repository does not accept it. Return to + the owning business repository or complete an architecture decision outside + this repository first. ## Two-stage protocol-change flow @@ -70,23 +88,23 @@ Because a consuming repo's submodule pointer can only reference a commit that is `fix/device-link-version-check`. - **PR description**: state the motivation and compatibility impact, and complete the repo's PR-template checklist (especially the "trio" for protocol changes). -- **Where new message types/fields go and how to extend**: see the +- **Where new message types/fields go and how to extend an existing package**: see the "extension guide / versioning" section of the relevant protocol doc — [slack-hook §9](docs/slack-hook-protocol.md), - [device-link §9](docs/device-link-protocol.md), - [voice](docs/voice-protocol.md). + [device-link §9](docs/device-link-protocol.md). ## Protocol-evolution discipline (hard rules) -1. **Zero runtime dependencies**: no package here may introduce a runtime +1. **The package set may only shrink**: do not add packages; evolve an existing + package only for a real consumer requirement. +2. **Zero runtime dependencies**: no package here may introduce a runtime dependency. -2. **`device-link-protocol` and `voice-protocol` must be React Native-safe**: no - `node:*` and no Node-only imports (mobile compiles these packages' source - directly). -3. **Append-only first**: prefer adding optional fields / new message types over +3. **`device-link-protocol` must be React Native-safe**: no `node:*` and no + Node-only imports (mobile compiles this package's source directly). +4. **Append-only first**: prefer adding optional fields / new message types over changing the semantics of existing fields. Every new optional field must document each side's degradation behavior "when the peer is an older version". -4. **Each protocol's compatibility strategy differs — don't mix them up**: +5. **Each protocol's compatibility strategy differs — don't mix them up**: - `slack-hook-protocol`: `type` is an open set; an older peer that receives an unknown type drops the frame without disconnecting — new message types are inherently backward-compatible, but you must define the degraded experience. @@ -98,16 +116,9 @@ Because a consuming repo's submodule pointer can only reference a commit that is independently. Unknown optional fields may be ignored; an unsupported manifest or envelope version must be rejected outright — the client keeps its existing installs and never applies a partial update. - - `skill-protocol`: the publish manifest and client HTTP envelope are versioned - independently. Unknown optional fields may be ignored; unsupported versions - must be rejected, and a failed client update retains the existing Skill. - - `voice-protocol`: session request/response allow unknown fields and roll out - via optional fields; the refiner business payload strictly rejects unknown - fields to prevent project-key abuse. A missing `protocolVersion` is - interpreted as v1; an explicitly unsupported version is rejected. -5. **Incompatible changes must bump the protocol version**, and the PR must state +6. **Incompatible changes must bump the protocol version**, and the PR must state the upgrade-window plan for the consuming repos. -6. **Changing a protocol means changing the trio**: type definition + parse-time +7. **Changing a protocol means changing the trio**: type definition + parse-time runtime validation (error messages carry the field path) + tests (at minimum round-trip and bad-frame rejection). Behavioral/semantic changes also update the corresponding doc under `docs/`. @@ -126,12 +137,12 @@ Commit messages follow [Conventional Commits](https://www.conventionalcommits.or `(): `. - **type**: `feat` / `fix` / `docs` / `refactor` / `test` / `chore` / `ci` / `revert` -- **scope**: `slack-hook` / `device-link` / `plugin` / `voice` / `docs` / `repo` +- **scope**: `slack-hook` / `device-link` / `plugin` / `model-access` / `docs` / `repo` - **subject**: imperative mood, no trailing period; `type`/`scope` in English, the subject may be English or Chinese. - **Breaking changes**: append `!` after the type (e.g. `feat(device-link)!: ...`), write a `BREAKING CHANGE:` note in the body, and **bump the corresponding - protocol version in the same commit** (see rule 5 above). + protocol version in the same commit** (see rule 6 above). The repo ships a commit template; after cloning, run it once so `git commit` prints the format hint: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9389085..b7c42a6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,6 +14,15 @@ - **协议语义变更**(新消息类型 / 字段 / 信封 / 路由语义 / 常量):需要服务端 + 客户端协同实现,而**服务端闭源**——这类由内部维护者主导,走下面的两段式流程落地。外部若有想法,请先开一个[协议提案 issue](../../issues/new/choose)讨论,由内部实现。 - **安全问题**:见 [SECURITY.md](SECURITY.md)。 +### Package 集合已封闭(硬性) + +- 本仓库不再接收任何新 package;`packages/` 的集合只能保持或减少,不能增加。 +- 允许现有 package 的兼容修复、真实消费方所需的必要演进、测试、文档,以及删除或合并。 +- 禁止拆分或重命名出新 package、保留兼容副本,或把无关业务域塞进存量 package 绕过限制。 +- 新业务域、新服务或新跨端协议必须在所属业务仓库处理,或在仓库外另行完成架构与分发决策。 +- 不为推测性的未来需求预建协议。删除 package 前必须确认所有已知消费仓均已停止引用;旧 + checkout 仍由历史 git pin 保留原内容。 + ## 开发环境 - Node.js >= 22,pnpm 10.x @@ -24,6 +33,7 @@ - **只改客户端 / 服务端代码**:去对应消费方仓库,不需要动本仓库。消费方仓库 clone 时记得 `git clone --recurse-submodules`。 - **要改协议本身**(消息类型、字段、信封、路由语义、常量):在本仓库提 PR,走下面的两段式流程。 +- **要新增协议 package**:本仓库不接收;请回到所属业务仓库或先完成仓库外的架构决策。 ## 两段式协议变更流程 @@ -39,21 +49,20 @@ - **一个 PR 只做一个逻辑变更**:便于评审与回滚;协议改动与无关重构不要混在一起。 - **分支命名**:`/<简短描述>`,`type` 与 commit 一致(`feat`/`fix`/`docs`/…),如 `feat/slack-hook-multi-team`、`fix/device-link-version-check`。 - **PR 描述**:写清动机与兼容性影响,并按仓库 PR 模板逐项勾选(协议变更尤其要过「三件套」)。 -- **新增消息类型 / 字段落在哪、怎么扩展**:见对应协议文档的「扩展指南 / 版本纪律」小节——[slack-hook §9](docs/slack-hook-protocol.md)、[device-link §9](docs/device-link-protocol.md)、[voice](docs/voice-protocol.md)。 +- **新增消息类型 / 字段落在哪、怎么扩展**:见对应存量协议文档的「扩展指南 / 版本纪律」小节——[slack-hook §9](docs/slack-hook-protocol.md)、[device-link §9](docs/device-link-protocol.md)。 ## 协议演进纪律(硬性) -1. **零 runtime 依赖**:本仓库任何包不得引入 runtime dependency。 -2. **`device-link-protocol` 与 `voice-protocol` 必须 React Native 安全**:禁止 `node:*` 及任何 Node-only import(mobile 端直接编译这些包源码)。 -3. **append-only 优先**:优先加可选字段 / 新消息类型,不改已有字段语义;每个新增可选字段必须写清"对端是旧版"时两侧各自的降级行为。 -4. **各协议的兼容策略不同,不要套错**: +1. **package 集合只减不增**:不得新增 package;存量 package 只有真实消费需求时才演进。 +2. **零 runtime 依赖**:本仓库任何包不得引入 runtime dependency。 +3. **`device-link-protocol` 必须 React Native 安全**:禁止 `node:*` 及任何 Node-only import(mobile 端直接编译该包源码)。 +4. **append-only 优先**:优先加可选字段 / 新消息类型,不改已有字段语义;每个新增可选字段必须写清"对端是旧版"时两侧各自的降级行为。 +5. **各协议的兼容策略不同,不要套错**: - `slack-hook-protocol`:`type` 是开放集合,老端收到未知类型丢帧不断连——新消息类型天然向后兼容,但必须定义降级体验; - `device-link-protocol`:relay 对未知 kind 静默丢弃(发送方表现为超时黑洞)——新增需要转发的 kind 属于**两侧同步升级**的变更,`EnvelopeKind` 集合与 `PROTOCOL_VERSION` 必须同步调整。 - `plugin-protocol`:manifest 与客户端 HTTP envelope 分别版本化。未知可选字段可忽略;不支持的 manifest 或 envelope 版本必须拒绝应用,客户端保留已有安装,不做部分更新。 - - `skill-protocol`:发布 manifest 与客户端 HTTP envelope 分别版本化。未知可选字段可忽略;不支持的版本必须拒绝发布/安装,客户端更新失败时保留已有 Skill。 - - `voice-protocol`:会话请求/响应允许未知字段并以可选字段做滚动升级;refiner 业务 payload 为防止项目 Key 被滥用而严格拒绝未知字段。缺省 `protocolVersion` 按 v1 解释,显式不支持的版本直接拒绝。 -5. **不兼容改动必须升协议版本号**,并在 PR 里写明两个消费方仓库的升级时间窗安排。 -6. **改协议必改三件套**:类型定义 + parse 运行时校验(错误信息带字段路径)+ 测试(至少覆盖 roundTrip 与坏帧拒收);涉及行为语义的同步更新 `docs/` 对应文档。 +6. **不兼容改动必须升协议版本号**,并在 PR 里写明两个消费方仓库的升级时间窗安排。 +7. **改协议必改三件套**:类型定义 + parse 运行时校验(错误信息带字段路径)+ 测试(至少覆盖 roundTrip 与坏帧拒收);涉及行为语义的同步更新 `docs/` 对应文档。 ## 测试要求 diff --git a/README.en.md b/README.en.md index 45ac826..b94fc5b 100644 --- a/README.en.md +++ b/README.en.md @@ -7,30 +7,40 @@ client and server repositories. Each side mounts this repository as a git submodule and includes `packages/*` in its own pnpm workspace (source-shipped, no build artifacts). -## Admission rule - -Only protocols that the **server actually needs to parse/validate** belong here. -Purely client-to-client, end-to-end types that are opaque to the server stay in -the client repository. +## Repository direction: closed set, continuous reduction + +This is a **maintenance and contraction repository for existing protocols**. +The set under `packages/` is closed: **new packages are no longer accepted**. +The package count may stay the same or decrease, but it must not increase. + +- Maintain only the packages below that have real client and server consumers; + compatible fixes, necessary evolution, tests, and docs remain welcome. +- Continue removing packages, exports, validators, and docs after their + consumers migrate away. +- Do not bypass the rule through splits, renames, compatibility copies, or by + putting an unrelated domain into an existing package. +- New domains, services, or cross-end protocols belong in their owning business + repository or require a separate architecture and distribution decision + outside this repository. Do not prebuild packages for hypothetical future use. + +Within an existing package, only protocols that the **server actually needs to +parse/validate** belong here. Purely client-to-client, end-to-end types that are +opaque to the server stay in the client repository. ## Packages -| Package | Contents | Consumers | -| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | -| `@cindy/slack-hook-protocol` | hook server ↔ desktop duplex task protocol: envelope, message types, runtime validation, builders | desktop (hook-control), slack-hook-server | -| `@cindy/device-link-protocol` | device-link relay-layer protocol: envelope, routing semantics (ROUTED/CONTROL kinds), connection-layer payloads. Tunnel-layer payloads are opaque to the relay and stay in the client device-link package | desktop/mobile (device-link package), device-link-server | -| `@cindy/plugin-protocol` | Ghost package `ghost.json` types and validator, plus the Plugin list/detail/download DTOs and response parsers the Desktop client needs | plugin-server; Desktop consumes it in a later remote-Plugin integration | -| `@cindy/skill-protocol` | Skill package manifests plus the Skill list/detail/download DTOs and response parsers the Desktop client needs | plugin-server Skill Domain and the Desktop Skill marketplace | -| `@cindy/voice-protocol` | desktop/mobile ↔ voice-server voice control plane: sessions, one-shot tickets, ASR target descriptors, refine payloads, and runtime validation | desktop, mobile, voice-server | -| `@cindy/model-access-protocol` | model-access-server ↔ desktop/mobile model catalog responses, price currency, and runtime validation | model-access-server, desktop/mobile model pickers | +| Package | Contents | Consumers | +| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | +| `@cindy/slack-hook-protocol` | hook server ↔ desktop duplex task protocol: envelope, message types, runtime validation, builders | desktop (hook-control), slack/telegram/x hook servers | +| `@cindy/device-link-protocol` | device-link relay-layer protocol: envelope, routing semantics (ROUTED/CONTROL kinds), connection-layer payloads. Tunnel-layer payloads are opaque to the relay and stay in the client device-link package | desktop/mobile (device-link package), device-link-server | +| `@cindy/plugin-protocol` | Ghost package `ghost.json` types and validator, plus the Plugin list/detail/download DTOs and response parsers the Desktop client needs | plugin-server, desktop, cindy-tools | +| `@cindy/model-access-protocol` | model-access-server ↔ desktop/mobile model catalog responses, price currency, and runtime validation | model-access-server, desktop/mobile model pickers | ## Protocol docs - [slack-hook-protocol](docs/slack-hook-protocol.md) — hook server ↔ desktop duplex task protocol (envelope, 24 message types, reliability & compatibility strategy) - [device-link-protocol](docs/device-link-protocol.md) — device-interconnect relay-layer protocol (dumb-relay model, routing semantics, security semantics) - [plugin-protocol](docs/plugin-protocol.md) — Ghost manifest and Plugin HTTP delivery contract (version boundaries, compatibility strategy) -- [skill-protocol](docs/skill-protocol.md) — Skill package manifest and marketplace HTTP delivery contract (scopes and compatibility strategy) -- [voice-protocol](docs/voice-protocol.md) — voice control-plane protocol (sessions, one-shot tickets, refine payloads, compatibility strategy) - [model-access-protocol](docs/model-access-protocol.md) — model catalog, price currency, and compatibility > The protocol docs are currently Chinese-only. English translations are @@ -84,8 +94,8 @@ same terms, per Section 5 of Apache-2.0. changes must bump the protocol version (e.g. device-link's `PROTOCOL_VERSION`) and bump the submodule pointer in every consuming repo within the same window. - Zero runtime dependencies is a hard constraint: no package here may introduce - any runtime dependency. `device-link-protocol` and `voice-protocol` must - additionally remain React Native-compilable (no Node-only imports; mobile + any runtime dependency. `device-link-protocol` must additionally remain + React Native-compilable (no Node-only imports; mobile consumes the source directly); `slack-hook-protocol` targets the Node environment (desktop main process / hook server) and may use the `node:*` standard library. diff --git a/README.md b/README.md index 29404cd..720ff66 100644 --- a/README.md +++ b/README.md @@ -4,28 +4,34 @@ 客户端仓库与服务端仓库**共享的线上协议**(wire protocol)单一权威来源。两侧以 git submodule 挂载本仓库,并把 `packages/*` 纳入各自的 pnpm workspace(源码直发,无构建产物)。 -## 准入规则 +## 仓库方向:封闭集合、持续精简 -只有**服务端真正需要解析/校验**的协议才进本仓库;纯客户端之间端到端、对服务端不透明的类型留在客户端仓库。 +本仓库是**存量协议维护与收缩仓库**。`packages/` 的集合已经封闭,**不再接收任何新 +package**;package 数量只能保持或减少,不能增加。 + +- 只维护下表中已有且被客户端、服务端真实消费的协议包;允许兼容修复、必要演进、测试和文档。 +- 当消费方完成迁移后,继续删除不再使用的 package、导出、校验器和文档。 +- 禁止通过拆分、重命名、兼容副本,或把无关业务域塞进存量 package 来绕过新增限制。 +- 新业务域、新服务或新跨端协议必须在所属业务仓库中处理,或另行完成仓库外的架构与分发决策; + 不在本仓库预建“以后可能会用”的协议包。 + +现有 package 内仍只接收**服务端真正需要解析/校验**的协议。纯客户端之间端到端、对服务端 +不透明的类型留在客户端仓库。 ## 包清单 -| 包 | 内容 | 消费方 | -| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | -| `@cindy/slack-hook-protocol` | hook server ↔ desktop 双工任务协议:信封、消息类型、运行时校验、构造器 | desktop(hook-control)、slack-hook-server | -| `@cindy/device-link-protocol` | device-link 中继层协议:信封、路由语义(ROUTED/CONTROL kinds)、连接层 payload。隧道层 payload 对 relay 不透明,留在客户端 device-link 包 | desktop/mobile(device-link 包)、device-link-server | -| `@cindy/plugin-protocol` | Ghost 包 `ghost.json` 类型与校验器、Desktop 所需 Plugin 列表/详情/下载 DTO 与响应解析器 | plugin-server;Desktop 在后续远程 Plugin 接入时消费 | -| `@cindy/skill-protocol` | Skill 发布包 manifest、Desktop 所需 Skill 列表/详情/下载 DTO 与响应解析器 | plugin-server Skill Domain;Desktop Skill 市场 | -| `@cindy/voice-protocol` | desktop/mobile ↔ voice-server 语音控制面:会话、一次性 ticket、ASR 目标描述、refine payload 与运行时校验 | desktop、mobile、voice-server | -| `@cindy/model-access-protocol` | model-access-server ↔ desktop/mobile 模型目录响应、价格币种与运行时校验 | model-access-server、desktop/mobile 模型选择器 | +| 包 | 内容 | 消费方 | +| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | +| `@cindy/slack-hook-protocol` | hook server ↔ desktop 双工任务协议:信封、消息类型、运行时校验、构造器 | desktop(hook-control)、slack/telegram/x hook servers | +| `@cindy/device-link-protocol` | device-link 中继层协议:信封、路由语义(ROUTED/CONTROL kinds)、连接层 payload。隧道层 payload 对 relay 不透明,留在客户端 device-link 包 | desktop/mobile(device-link 包)、device-link-server | +| `@cindy/plugin-protocol` | Ghost 包 `ghost.json` 类型与校验器、Desktop 所需 Plugin 列表/详情/下载 DTO 与响应解析器 | plugin-server、desktop、cindy-tools | +| `@cindy/model-access-protocol` | model-access-server ↔ desktop/mobile 模型目录响应、价格币种与运行时校验 | model-access-server、desktop/mobile 模型选择器 | ## 协议文档 - [slack-hook-protocol](docs/slack-hook-protocol.md) — hook server ↔ desktop 双工任务协议(信封、24 种消息、可靠性与兼容策略) - [device-link-protocol](docs/device-link-protocol.md) — 设备互联中继层协议(哑中继模型、路由语义、安全语义) - [plugin-protocol](docs/plugin-protocol.md) — Ghost manifest 与 Plugin HTTP 交付契约、版本边界和兼容策略 -- [skill-protocol](docs/skill-protocol.md) — Skill 发布 manifest 与市场 HTTP 交付契约、Scope 和兼容策略 -- [voice-protocol](docs/voice-protocol.md) — 语音控制面协议(会话、一次性 ticket、refine payload 与兼容策略) - [model-access-protocol](docs/model-access-protocol.md) — 模型目录、价格币种与兼容策略 ## 消费方式 @@ -56,4 +62,4 @@ packages: ## 变更纪律 - 协议是跨仓契约:**append-only 优先**,不兼容改动必须升协议版本号(如 device-link 的 `PROTOCOL_VERSION`),并在同一时间窗内 bump 各消费方仓库的 submodule 指针。 -- 零运行时依赖是硬约束:本仓库的包不得引入任何 runtime dependency。其中 `device-link-protocol`、`voice-protocol` 还必须保持 React Native 可编译(禁 Node-only import,mobile 端直接消费);`slack-hook-protocol` 面向 Node 环境(desktop main / hook server),允许 `node:*` 标准库。 +- 零运行时依赖是硬约束:本仓库的包不得引入任何 runtime dependency。其中 `device-link-protocol` 必须保持 React Native 可编译(禁 Node-only import,mobile 端直接消费);`slack-hook-protocol` 面向 Node 环境(desktop main / hook server),允许 `node:*` 标准库。 diff --git a/docs/commit-convention.md b/docs/commit-convention.md index 46871f5..1b8b688 100644 --- a/docs/commit-convention.md +++ b/docs/commit-convention.md @@ -3,9 +3,9 @@ 提交信息遵循 [Conventional Commits](https://www.conventionalcommits.org/):`(): `。 - **type**:`feat` / `fix` / `docs` / `refactor` / `test` / `chore` / `ci` / `revert` -- **scope**:`slack-hook` / `device-link` / `plugin` / `voice` / `docs` / `repo` +- **scope**:`slack-hook` / `device-link` / `plugin` / `model-access` / `docs` / `repo` - **subject**:祈使句、结尾不加句号;type/scope 用英文,subject 中英文皆可 -- **破坏性变更**:type 后加 `!`(如 `feat(device-link)!: ...`),正文写 `BREAKING CHANGE:` 说明,且**同一提交内必须 bump 对应协议的版本号**(见 [CONTRIBUTING.md](../CONTRIBUTING.md) 「协议演进纪律」第 5 条) +- **破坏性变更**:type 后加 `!`(如 `feat(device-link)!: ...`),正文写 `BREAKING CHANGE:` 说明,且**同一提交内必须 bump 对应协议的版本号**(见 [CONTRIBUTING.md](../CONTRIBUTING.md) 「协议演进纪律」第 6 条) 仓库根提供了提交模板,clone 后执行一次即可在 `git commit` 时自动带出格式提示: diff --git a/docs/model-access-protocol.md b/docs/model-access-protocol.md index 6901683..22b8784 100644 --- a/docs/model-access-protocol.md +++ b/docs/model-access-protocol.md @@ -128,8 +128,8 @@ Protocol 变更,再发布能双读 v1/v2 的客户端,确认 reader 覆盖 有效 registry,并继续使用 bundled fallback。 协议包与本仓其余协议包一致,以 TypeScript 源码直发:公开入口直指 `src/*.ts`, -仓库内不产出也不提交构建产物。源码内部沿用显式 `.js` 扩展名(与 plugin-protocol、 -skill-protocol 一致),以满足消费方 `moduleResolution: node16` 的类型检查。 +仓库内不产出也不提交构建产物。源码内部沿用显式 `.js` 扩展名(与 plugin-protocol +一致),以满足消费方 `moduleResolution: node16` 的类型检查。 纯 Node 生产进程不能直接加载 `node_modules` 下的 TypeScript,因此服务端消费方必须 在构建期把本包内联进自己的 bundle(`tsup` 的 `noExternal`,与 device-link-server、 diff --git a/docs/skill-protocol.md b/docs/skill-protocol.md deleted file mode 100644 index b5a7e5b..0000000 --- a/docs/skill-protocol.md +++ /dev/null @@ -1,122 +0,0 @@ -# Skill Protocol - -`@cindy/skill-protocol` 是 plugin-server Skill Domain 与 Desktop Skill 市场共享的零运行时依赖 TypeScript contract。它定义发布包 manifest,以及普通客户端使用的市场列表、详情和下载响应;不包含服务端数据库、管理 API 或本地安装实现。 - -## 使用方式 - -消费方通过 `cindy-protocol` submodule 和 pnpm workspace 引用: - -```json -{ - "dependencies": { - "@cindy/skill-protocol": "workspace:*" - } -} -``` - -```ts -import { - SkillProtocolError, - parseGetSkillResponse, - parseListSkillsResponse, - parseSkillDownloadResponse, - parseSkillPackageManifest, -} from '@cindy/skill-protocol'; -``` - -所有外部输入必须先按 `unknown` 处理,再交给解析器。解析失败会抛出带字段路径的 `SkillProtocolError`;调用方不得继续发布、安装或切换 Version。 - -## Skill 发布 manifest - -发布请求携带 `SkillPackageManifest`,它不是包内文件,也不进入自己的 `files` 清单: - -```json -{ - "schemaVersion": 1, - "slug": "release-helper", - "name": "Release Helper", - "description": "Prepare and validate a release.", - "version": "1.0.0", - "files": [ - { - "path": "SKILL.md", - "sizeBytes": 128, - "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - } - ] -} -``` - -约束: - -- `slug` 为 1–64 位小写字母、数字或连字符,并以字母或数字开头和结尾,且不能使用 Windows 设备保留名; -- `SKILL.md` 必须位于包根且非空; -- `files` 只列普通文件,必须按 `path` 逐字升序排列; -- 路径使用 NFC 规范化的相对 POSIX 格式,并同时满足 macOS、Windows 和 Linux 的安全文件名约束; -- 大小与 SHA-256 针对每个文件的原始字节; -- 大小上限、文件数量、压缩率、symlink、可执行文件和危险类型属于服务端发布策略,不固化进 wire protocol; -- 服务端必须从上传制品重新计算清单,并校验 `SKILL.md` frontmatter 的 `name`、`description` 与 manifest 一致,不能信任发布方声明。 - -未知字段会被忽略且不会出现在规范化返回值中。不支持的 `schemaVersion` 必须拒绝。 - -## 市场交付契约 - -Skill 市场使用: - -- `parseListSkillsResponse`:分页摘要; -- `parseGetSkillResponse`:单项详情和完整发布 manifest; -- `parseSkillDownloadResponse`:当前 Version 的短期 HTTPS 下载凭证。 - -```text -GET /api/skills -GET /api/skills/:skillId -GET /api/skills/:skillId/versions/:versionId/download -``` - -字段语义: - -| 字段 | 语义 | -| ---------------- | --------------------------------------------------------------------------------------------- | -| `Skill.id` | plugin-server 生成的永久资源 ID,是本地市场安装记录的远程身份。 | -| `slug` | 同一 Scope owner 内唯一的逻辑名称;不同 owner 间允许重名。 | -| `scope` | `public` 对所有登录身份可见;`organization` 只对对应组织可见;`personal` 只对当前自然人可见。 | -| `organizationId` | Organization 必须为 1–128 字符的组织 ID;Public 和 Personal 恒为 `null`。 | -| `currentVersion` | 普通客户端可安装的当前唯一 Version;列表只含摘要,详情额外包含 manifest。 | -| `nextCursor` | 下一页使用的 `Skill.id`,没有下一页时为 `null`。 | - -普通客户端 DTO 刻意不暴露 `ownerPassportId`。Personal 资源已经由服务端按已验签的 `passportId` 过滤,客户端不需要也不能据此自行授权。 - -详情解析器会额外校验: - -- 外层 `slug/name/description` 与 manifest 一致; -- `currentVersion.version` 与 manifest `version` 一致; -- Scope 与 `organizationId` 一致。 - -下载响应只接受 HTTPS、64 位小写十六进制 SHA-256、正整数字节数和带毫秒的 UTC ISO 8601 过期时间。下载后仍须重新计算完整制品 SHA-256,再进入 staging 解包和逐文件清单校验。 - -## 版本与兼容行为 - -- `SKILL_PACKAGE_SCHEMA_VERSION=1`; -- `SKILL_API_SCHEMA_VERSION=1`; -- 两个版本独立演进。 - -新增可选字段可以 append-only 扩展;解析器忽略未知字段。删除字段、改变字段语义或新增必填字段必须提升对应 schema version。 - -当客户端遇到不支持的 envelope 或 manifest 版本时: - -- 首次安装失败并提示当前 Cindy 版本不兼容; -- 更新失败时丢弃 staging,保留现有本地 Skill 和安装记录; -- 不执行原子替换,不更新 Version ID 或 SHA-256。 - -## 边界 - -本包不定义: - -- Public/Organization/Personal 管理 API; -- Platform admin、Organization owner/admin 或 Personal owner 的写权限; -- GitHub OIDC、人工上传会话和发布溯源; -- 分类、搜索排序、下载量和审核状态; -- global/project 本地安装位置; -- XD SkillHub 的旧可见性和部门模型。 - -这些能力由 plugin-server 和 Desktop 各自实现;出现真实跨仓 wire consumer 后再以版本化字段扩展,不能提前塞入无消费者的万能 DTO。 diff --git a/docs/voice-protocol.md b/docs/voice-protocol.md deleted file mode 100644 index e9a5021..0000000 --- a/docs/voice-protocol.md +++ /dev/null @@ -1,89 +0,0 @@ -# voice-protocol - -`@cindy/voice-protocol` 是 desktop、mobile 与 voice-server 之间语音控制面的单一协议来源。包以 TypeScript 源码直发、零运行依赖,并保持 React Native 可编译。 - -## 协议边界 - -协议包负责 Cindy 自有且服务端需要解析的线上契约: - -- `POST /api/voice/sessions` 的会话申请与响应; -- 一次性 ASR ticket、过期时间和 WebSocket 目标描述; -- `protocolProfile`、客户端类型、refiner schema 与 prompt 归属等枚举; -- `POST /api/voice/sessions/:sessionId/refine` 的请求信封; -- `POST /api/voice/dictionary-learning` 的会话无关入口; -- 听写优化、词典学习两种 user payload; -- `{ error: { code, message } }` 错误信封和稳定路由构造器。 - -以下内容不属于本包: - -- 豆包、Qwen、OpenAI 等上游的原生 WebSocket 帧;voice-server 对这些帧保持透明代理; -- Gateway API Key、JWT/JWKS 实现、Redis ticket 存储结构; -- 数据库、限流、计费和 provider/model allowlist。 - -## 版本与兼容性 - -当前 `VOICE_PROTOCOL_VERSION` 为 `1`。首次滚动接入期间,请求与响应里的 `protocolVersion` 为可选字段:缺省按 v1 解释,显式值只接受 `1`。这样新 server 可以先增加字段而不影响旧客户端,新客户端也能读取尚未升级的旧 server 响应。 - -协议演进遵循 append-only:新增可选字段时旧端忽略未知字段;修改已有字段语义、删除字段或收紧必填约束属于不兼容变更,必须升级版本并同步安排两个消费仓的升级窗口。 - -## Prompt 归属(`promptOwner`) - -会话响应的 `refiner.promptOwner` 声明这次托管润色由谁提供 system prompt: - -| 取值 | 客户端行为 | 服务端行为 | -| --------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------- | -| 缺省 / `client` | 发 `[system, user]` 两条消息,自带 prompt 与 `promptVersion`,并自行派生 `prompt_cache_key` | 直接使用客户端给的 prompt | -| `server` | 只发 `[user]` 一条消息,省略 `promptVersion` 与 `prompt_cache_key` | 按 `schemaName` 注入自己的 prompt,并自行生成 cache key | - -降级行为(append-only 要求逐项写明): - -- **新客户端 + 旧服务端**:响应里没有 `promptOwner`,客户端按 `client` 走历史行为,功能不受影响; -- **旧客户端 + 新服务端**:客户端仍发两条消息,服务端沿用其 prompt,不强制切换; -- 该字段只在 `refiner.enabled` 为 `true` 时允许出现,且只影响托管润色 —— BYOK 客户端直连上游,必须始终自带 prompt。 - -因此 `parseVoiceRefineRequest` 接受两种信封形态:`[system, user]` 与仅 `[user]`。user 消息恒为最后一条(它承载服务端据以分发的 `schemaName`),只给 system 而没有 user 属于非法请求。`VoiceDictationRefinementInput.promptVersion` 与 `VoiceDictionaryLearningInput.promptVersion` 的类型相应放宽为可选,但**具体是否允许出现由归属决定**——详见下文「组合解析」一节的字段约束表。 - -## 词典学习入口 - -`VOICE_DICTIONARY_LEARNING_PATH`(`POST /api/voice/dictionary-learning`)与 refine 共用 `VoiceRefineRequest` 信封和 `dictation_dictionary_learning` payload,但**不绑定会话**,仅凭账号令牌鉴权。 - -原因是触发时机:词典学习发生在用户改动已插入文本之后,此时 ASR 会话与其一次性 ticket 已经回收,`/api/voice/sessions/:sessionId/refine` 不再可用。该端点始终由服务端提供 prompt,不需要 `promptOwner` 协商;旧服务端没有这个路由,客户端按 404 静默跳过即可。 - -## 组合解析(服务端首选入口) - -信封与 payload 的契约是**耦合**的:server-owned 信封才允许省略 `promptVersion`;词典学习路由必须拒绝调用方自带的 system prompt 与非词典学习 payload。分别调用两个 parser 无法表达这层耦合,会迫使业务层写零散的补充检查。 - -因此服务端应优先使用 `parseVoiceRefineRequestWithPayload(body, { route })`:它一次完成信封解析、prompt 归属推导(有 system 即 `client`)、以及按归属和路由收紧的 payload 校验,返回 `{ request, payload, promptOwner }`。 - -| route | 允许的归属 | 允许的 schema | -| --------------------- | ------------------- | ---------------------------------- | -| `refine`(缺省) | `client` / `server` | 两种都可 | -| `dictionary_learning` | 仅 `server` | 仅 `dictation_dictionary_learning` | - -`promptVersion` 与 `prompt_cache_key` 都严格跟随归属,两者的理由相同——它们标识的是「哪份 prompt」,而 server 归属下客户端从未见过那份 prompt: - -| 字段 | `client` 归属 | `server` 归属 | -| -------------------------- | --------------------------------------------- | -------------------------- | -| `input.promptVersion` | 必填(标识客户端那份 prompt,参与 cache key) | **必须缺省** | -| `request.prompt_cache_key` | 客户端自行派生 | **必须缺省**,由服务端生成 | - -放行调用方在 server 归属下给出的这两个字段,等于让它为自己没见过的 prompt 指定版本或缓存分片——服务端若信任校验后的结果继续转发,就成了攻击者可控的元数据。因此组合解析对两者一律拒收。 - -单独调用 `parseVoiceRefinerUserPayload` 时可传 `{ promptOwner, route }` 得到同样的收紧;两者都不传才保持宽松,供确实无法判定归属的调用方使用。其中 `route: 'dictionary_learning'` 本身就确立了归属:只传它也会按 server 归属校验,无需再传 `promptOwner`;显式传 `promptOwner: 'client'` 与该路由自相矛盾,会被拒收。 - -## 运行时校验 - -两端必须通过包内 parser 处理不可信输入: - -- `parseCreateVoiceSessionRequest` -- `parseCreateVoiceSessionResponse` -- `parseVoiceRefineRequestWithPayload`(refine / 词典学习端点的首选入口,见上节) -- `parseVoiceRefineRequest` -- `parseVoiceRefinerUserPayloadJson` -- `parseVoiceErrorResponse` - -解析失败返回 `{ ok: false, error }`,错误文本包含字段路径,不抛异常。业务层再把解析错误映射为本服务的 HTTP 错误码。 - -`parseVoiceRefinerUserPayloadJson` 在 `JSON.parse` 前先按 `VOICE_MAX_REFINER_PAYLOAD_CHARS`(64k 字符,与 refine 请求信封中 user message content 的上限一致)拒收超长原始输入——协议层的粗防 OOM 兜底;传输/部署层的 body 限额仍应独立设置。 - -Refiner 的业务 payload 使用严格字段集合,避免项目 Key 被未登记任务滥用;会话请求与响应允许额外字段,以支持 append-only 的滚动升级。 diff --git a/packages/skill-protocol/package.json b/packages/skill-protocol/package.json deleted file mode 100644 index c4391c9..0000000 --- a/packages/skill-protocol/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "@cindy/skill-protocol", - "version": "0.0.0", - "private": true, - "description": "Cindy Skill package manifest and marketplace delivery contracts. Zero runtime dependencies", - "type": "module", - "main": "./src/index.ts", - "types": "./src/index.ts", - "exports": { - ".": "./src/index.ts", - "./manifest": "./src/manifest.ts", - "./delivery": "./src/delivery.ts" - }, - "scripts": { - "build": "tsc --noEmit", - "test": "vitest run" - }, - "license": "Apache-2.0", - "devDependencies": { - "typescript": "^5.7.0", - "vitest": "^3.2.4" - } -} diff --git a/packages/skill-protocol/src/__tests__/delivery.test.ts b/packages/skill-protocol/src/__tests__/delivery.test.ts deleted file mode 100644 index 45f245e..0000000 --- a/packages/skill-protocol/src/__tests__/delivery.test.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - SKILL_API_SCHEMA_VERSION, - SkillProtocolError, - parseGetSkillResponse, - parseListSkillsResponse, - parseSkillDownloadResponse, -} from '../index.js'; -import { SKILL_PACKAGE_SCHEMA_VERSION } from '../manifest.js'; - -const skillId = `c${'s'.repeat(24)}`; -const manifest = { - schemaVersion: SKILL_PACKAGE_SCHEMA_VERSION, - slug: 'release-helper', - name: 'Release Helper', - description: 'Prepare and validate a release.', - version: '1.0.0', - files: [ - { - path: 'SKILL.md', - sizeBytes: 128, - sha256: 'a'.repeat(64), - }, - ], -} as const; - -const versionSummary = { - id: 'version-1', - version: manifest.version, - sha256: 'b'.repeat(64), - sizeBytes: 512, - publishedAt: '2026-07-23T00:00:00.000Z', -} as const; - -const visibleSkill = { - id: skillId, - slug: manifest.slug, - name: manifest.name, - description: manifest.description, - scope: 'public', - organizationId: null, - currentVersion: versionSummary, -} as const; - -describe('skill delivery contract', () => { - it('round-trips a paginated visible Skill summary', () => { - expect( - parseListSkillsResponse({ - schemaVersion: SKILL_API_SCHEMA_VERSION, - skills: [visibleSkill], - nextCursor: skillId, - }), - ).toEqual({ - schemaVersion: SKILL_API_SCHEMA_VERSION, - skills: [visibleSkill], - nextCursor: skillId, - }); - }); - - it('parses detail and verifies outer metadata against the manifest', () => { - const response = parseGetSkillResponse({ - schemaVersion: SKILL_API_SCHEMA_VERSION, - skill: { - ...visibleSkill, - currentVersion: { ...versionSummary, manifest }, - }, - }); - expect(response.skill.currentVersion.manifest.slug).toBe(manifest.slug); - - expect(() => - parseGetSkillResponse({ - schemaVersion: SKILL_API_SCHEMA_VERSION, - skill: { - ...visibleSkill, - name: 'Stale name', - currentVersion: { ...versionSummary, manifest }, - }, - }), - ).toThrow(/manifest.name/); - }); - - it('keeps organization ownership consistent with scope', () => { - const organization = parseListSkillsResponse({ - schemaVersion: SKILL_API_SCHEMA_VERSION, - skills: [ - { - ...visibleSkill, - scope: 'organization', - organizationId: 'org-example', - }, - ], - nextCursor: null, - }); - expect(organization.skills[0]?.organizationId).toBe('org-example'); - expect( - parseListSkillsResponse({ - schemaVersion: SKILL_API_SCHEMA_VERSION, - skills: [ - { - ...visibleSkill, - scope: 'organization', - organizationId: 'o'.repeat(128), - }, - ], - nextCursor: null, - }).skills[0]?.organizationId, - ).toBe('o'.repeat(128)); - - for (const skill of [ - { ...visibleSkill, scope: 'public', organizationId: 'org-example' }, - { ...visibleSkill, scope: 'personal', organizationId: 'org-example' }, - { ...visibleSkill, scope: 'organization', organizationId: null }, - { ...visibleSkill, scope: 'organization', organizationId: 'o'.repeat(129) }, - ]) { - expect(() => - parseListSkillsResponse({ - schemaVersion: SKILL_API_SCHEMA_VERSION, - skills: [skill], - nextCursor: null, - }), - ).toThrow(/organizationId/); - } - }); - - it('parses Personal without exposing its owner identity', () => { - const response = parseListSkillsResponse({ - schemaVersion: SKILL_API_SCHEMA_VERSION, - skills: [ - { - ...visibleSkill, - scope: 'personal', - ownerPassportId: 'passport-example', - }, - ], - nextCursor: null, - }); - expect(response.skills[0]?.scope).toBe('personal'); - expect(response.skills[0]).not.toHaveProperty('ownerPassportId'); - }); - - it('rejects unsupported envelopes, invalid cursors, and version mismatches', () => { - expect(() => - parseListSkillsResponse({ schemaVersion: 2, skills: [], nextCursor: null }), - ).toThrow(/schemaVersion/); - expect(() => - parseListSkillsResponse({ - schemaVersion: SKILL_API_SCHEMA_VERSION, - skills: [], - nextCursor: 'invalid', - }), - ).toThrow(/nextCursor/); - expect(() => - parseGetSkillResponse({ - schemaVersion: SKILL_API_SCHEMA_VERSION, - skill: { - ...visibleSkill, - currentVersion: { - ...versionSummary, - version: '2.0.0', - manifest, - }, - }, - }), - ).toThrow(/manifest.version/); - }); - - it('parses an HTTPS download credential and rejects unsafe responses', () => { - const value = { - url: 'https://example.com/release-helper.zip?signature=example', - expiresAt: '2026-07-23T00:05:00.000Z', - sha256: 'c'.repeat(64), - sizeBytes: 512, - }; - expect(parseSkillDownloadResponse(value)).toEqual(value); - expect(() => - parseSkillDownloadResponse({ ...value, url: 'http://example.com/release-helper.zip' }), - ).toThrow(SkillProtocolError); - expect(() => parseSkillDownloadResponse({ ...value, sha256: 'invalid' })).toThrow(/sha256/); - expect(() => parseSkillDownloadResponse({ ...value, expiresAt: 'tomorrow' })).toThrow( - /expiresAt/, - ); - }); -}); diff --git a/packages/skill-protocol/src/__tests__/manifest.test.ts b/packages/skill-protocol/src/__tests__/manifest.test.ts deleted file mode 100644 index 2a78cec..0000000 --- a/packages/skill-protocol/src/__tests__/manifest.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - SKILL_PACKAGE_SCHEMA_VERSION, - SkillProtocolError, - isValidSkillPackagePath, - isValidSkillSlug, - parseSkillPackageManifest, -} from '../manifest.js'; - -const sha = (character: string): string => character.repeat(64); - -const validManifest = { - schemaVersion: SKILL_PACKAGE_SCHEMA_VERSION, - slug: 'release-helper', - name: 'Release Helper', - description: 'Prepare and validate a release.', - version: '1.0.0', - files: [ - { path: 'SKILL.md', sizeBytes: 128, sha256: sha('a') }, - { path: 'references/checklist.md', sizeBytes: 64, sha256: sha('b') }, - { path: 'scripts/verify.sh', sizeBytes: 32, sha256: sha('c') }, - ], -} as const; - -describe('skill package manifest', () => { - it('parses a complete manifest and strips unknown fields', () => { - expect( - parseSkillPackageManifest({ - ...validManifest, - internalOwnerId: 'passport-example', - files: validManifest.files.map((file) => ({ ...file, language: 'text' })), - }), - ).toEqual(validManifest); - }); - - it('accepts portable slugs and relative package paths', () => { - expect(isValidSkillSlug('a')).toBe(true); - expect(isValidSkillSlug('release-helper')).toBe(true); - expect(isValidSkillSlug('a'.repeat(64))).toBe(true); - expect(isValidSkillSlug('release-')).toBe(false); - expect(isValidSkillSlug('a'.repeat(65))).toBe(false); - expect(isValidSkillSlug('Release_Helper')).toBe(false); - expect(isValidSkillSlug('con')).toBe(false); - expect(isValidSkillPackagePath('参考/checklist.md')).toBe(true); - expect(isValidSkillPackagePath('references/checklist.md')).toBe(true); - }); - - it.each([ - '../secret', - '/absolute', - './relative', - 'scripts\\run.cmd', - 'scripts//run.sh', - 'scripts/CON', - 'scripts/trailing. ', - 'scripts/a:b', - `references/cafe\u0301.md`, - ])('rejects unsafe cross-platform path %s', (path) => { - expect(isValidSkillPackagePath(path)).toBe(false); - }); - - it('requires a non-empty root SKILL.md', () => { - expect(() => - parseSkillPackageManifest({ - ...validManifest, - files: [{ path: 'README.md', sizeBytes: 1, sha256: sha('a') }], - }), - ).toThrow(/SKILL\.md/); - expect(() => - parseSkillPackageManifest({ - ...validManifest, - files: [{ path: 'SKILL.md', sizeBytes: 0, sha256: sha('a') }], - }), - ).toThrow(/SKILL\.md/); - }); - - it('requires sorted files and rejects case-insensitive collisions', () => { - expect(() => - parseSkillPackageManifest({ - ...validManifest, - files: [ - { path: 'scripts/run.sh', sizeBytes: 1, sha256: sha('a') }, - { path: 'SKILL.md', sizeBytes: 1, sha256: sha('b') }, - ], - }), - ).toThrow(/升序/); - expect(() => - parseSkillPackageManifest({ - ...validManifest, - files: [ - { path: 'SKILL.md', sizeBytes: 1, sha256: sha('a') }, - { path: 'skill.md', sizeBytes: 1, sha256: sha('b') }, - ], - }), - ).toThrow(/跨平台同名/); - }); - - it('rejects malformed fingerprints, sizes, metadata, and schema versions', () => { - expect(() => - parseSkillPackageManifest({ - ...validManifest, - files: [{ path: 'SKILL.md', sizeBytes: -1, sha256: sha('a') }], - }), - ).toThrow(SkillProtocolError); - expect(() => - parseSkillPackageManifest({ - ...validManifest, - files: [{ path: 'SKILL.md', sizeBytes: 1, sha256: sha('A') }], - }), - ).toThrow(/sha256/); - expect(() => parseSkillPackageManifest({ ...validManifest, name: ' padded ' })).toThrow( - /首尾空白/, - ); - expect(() => parseSkillPackageManifest({ ...validManifest, schemaVersion: 2 })).toThrow( - /schemaVersion/, - ); - }); -}); diff --git a/packages/skill-protocol/src/delivery.ts b/packages/skill-protocol/src/delivery.ts deleted file mode 100644 index b98ae81..0000000 --- a/packages/skill-protocol/src/delivery.ts +++ /dev/null @@ -1,294 +0,0 @@ -import { - isValidSkillSlug, - parseSkillPackageManifest, - SkillProtocolError, - type SkillPackageManifest, -} from './manifest.js'; - -/** Skill 客户端 HTTP list/detail envelope 版本。 */ -export const SKILL_API_SCHEMA_VERSION = 1 as const; - -/** 普通客户端可见的云端 Skill 来源范围。 */ -export const SKILL_SCOPES = ['public', 'organization', 'personal'] as const; - -/** Public 对所有登录身份可见,其余范围只对对应组织或自然人可见。 */ -export type SkillScope = (typeof SKILL_SCOPES)[number]; - -/** 列表和详情共用的当前 Version 摘要。 */ -export interface SkillVersionSummary { - /** plugin-server 生成的 Version 资源 ID;调用方应视为不透明字符串。 */ - id: string; - /** 与当前发布 manifest.version 一致。 */ - version: string; - /** 完整上传制品原始字节的 SHA-256。 */ - sha256: string; - /** 完整上传制品原始字节数。 */ - sizeBytes: number; - /** 发布时间,格式为带毫秒的 UTC ISO 8601。 */ - publishedAt: string; -} - -/** 详情响应中的当前 Version;摘要之外包含完整发布 manifest。 */ -export interface SkillVersionDetail extends SkillVersionSummary { - manifest: SkillPackageManifest; -} - -/** Skill 市场列表项。Personal owner 的 passportId 不进入客户端 DTO。 */ -export interface VisibleSkillSummary { - /** plugin-server 生成的永久 Skill 资源 ID。 */ - id: string; - /** 同一 Scope owner 内唯一的市场逻辑名称。 */ - slug: string; - /** 当前 Version manifest 的展示名。 */ - name: string; - /** 当前 Version manifest 的简介。 */ - description: string; - /** Skill 的云端可见范围。 */ - scope: SkillScope; - /** Organization 必须是非空组织 ID;Public 和 Personal 恒为 null。 */ - organizationId: string | null; - /** 服务端当前发布的唯一 Version。 */ - currentVersion: SkillVersionSummary; -} - -/** Skill 市场详情。 */ -export interface VisibleSkillDetail extends Omit { - currentVersion: SkillVersionDetail; -} - -/** Skill 分页列表响应。 */ -export interface ListSkillsResponse { - schemaVersion: typeof SKILL_API_SCHEMA_VERSION; - skills: VisibleSkillSummary[]; - nextCursor: string | null; -} - -/** 单个 Skill 详情响应。 */ -export interface GetSkillResponse { - schemaVersion: typeof SKILL_API_SCHEMA_VERSION; - skill: VisibleSkillDetail; -} - -/** 当前 Version 的短期私有下载凭证。 */ -export interface SkillDownloadResponse { - url: string; - expiresAt: string; - sha256: string; - sizeBytes: number; -} - -function object(value: unknown, path: string): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new SkillProtocolError(`${path} 必须是对象`); - } - return value as Record; -} - -function string(value: unknown, path: string, max = 256): string { - if (typeof value !== 'string' || value.length === 0 || value.length > max) { - throw new SkillProtocolError(`${path} 必须是 1–${max} 字符的字符串`); - } - return value; -} - -function sha256(value: unknown, path: string): string { - const text = string(value, path, 64); - if (!/^[a-f0-9]{64}$/.test(text)) { - throw new SkillProtocolError(`${path} 必须是 64 位小写十六进制`); - } - return text; -} - -function positiveSize(value: unknown, path: string): number { - if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) { - throw new SkillProtocolError(`${path} 必须是正整数`); - } - return value; -} - -function isoDate(value: unknown, path: string): string { - const text = string(value, path, 64); - if ( - !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(text) || - Number.isNaN(Date.parse(text)) || - new Date(text).toISOString() !== text - ) { - throw new SkillProtocolError(`${path} 必须是 ISO 8601 UTC 时间`); - } - return text; -} - -/** 判断值是否符合 plugin-server Prisma Skill 资源 ID 的当前形状。 */ -export function isValidSkillResourceId(value: unknown): value is string { - return typeof value === 'string' && /^c[a-z0-9]{24}$/.test(value); -} - -function parseVersionSummary(value: unknown, path: string): SkillVersionSummary { - const raw = object(value, path); - return { - id: string(raw.id, `${path}.id`, 128), - version: string(raw.version, `${path}.version`, 64), - sha256: sha256(raw.sha256, `${path}.sha256`), - sizeBytes: positiveSize(raw.sizeBytes, `${path}.sizeBytes`), - publishedAt: isoDate(raw.publishedAt, `${path}.publishedAt`), - }; -} - -function parseVersionDetail(value: unknown, slug: string, path: string): SkillVersionDetail { - const raw = object(value, path); - const summary = parseVersionSummary(raw, path); - const manifest = parseSkillPackageManifest(raw.manifest); - if (manifest.slug !== slug) { - throw new SkillProtocolError(`${path}.manifest.slug 与 skill.slug 不一致`); - } - if (manifest.version !== summary.version) { - throw new SkillProtocolError(`${path}.manifest.version 与 version 不一致`); - } - return { ...summary, manifest }; -} - -function parseVisibleSkillBase( - value: unknown, - path: string, -): { - raw: Record; - id: string; - slug: string; - name: string; - description: string; - scope: SkillScope; - organizationId: string | null; -} { - const raw = object(value, path); - if (!isValidSkillResourceId(raw.id)) { - throw new SkillProtocolError(`${path}.id 不合法`); - } - if (!isValidSkillSlug(raw.slug)) { - throw new SkillProtocolError(`${path}.slug 不合法`); - } - const name = string(raw.name, `${path}.name`, 100); - const description = string(raw.description, `${path}.description`, 500); - if (name.trim() !== name || description.trim() !== description) { - throw new SkillProtocolError(`${path}.name/description 不得有首尾空白`); - } - if (!(SKILL_SCOPES as readonly unknown[]).includes(raw.scope)) { - throw new SkillProtocolError(`${path}.scope 不合法`); - } - const scope = raw.scope as SkillScope; - const organizationId = raw.organizationId; - if ( - ((scope === 'public' || scope === 'personal') && organizationId !== null) || - (scope === 'organization' && - (typeof organizationId !== 'string' || - organizationId.length === 0 || - organizationId.length > 128)) - ) { - throw new SkillProtocolError(`${path}.organizationId 与 scope 不一致`); - } - return { - raw, - id: raw.id, - slug: raw.slug, - name, - description, - scope, - organizationId: organizationId as string | null, - }; -} - -function parseVisibleSkillSummary(value: unknown, index: number): VisibleSkillSummary { - const path = `skills[${index}]`; - const parsed = parseVisibleSkillBase(value, path); - return { - id: parsed.id, - slug: parsed.slug, - name: parsed.name, - description: parsed.description, - scope: parsed.scope, - organizationId: parsed.organizationId, - currentVersion: parseVersionSummary(parsed.raw.currentVersion, `${path}.currentVersion`), - }; -} - -function parseVisibleSkillDetail(value: unknown, path: string): VisibleSkillDetail { - const parsed = parseVisibleSkillBase(value, path); - const currentVersion = parseVersionDetail( - parsed.raw.currentVersion, - parsed.slug, - `${path}.currentVersion`, - ); - if (parsed.name !== currentVersion.manifest.name) { - throw new SkillProtocolError(`${path}.name 与 currentVersion.manifest.name 不一致`); - } - if (parsed.description !== currentVersion.manifest.description) { - throw new SkillProtocolError( - `${path}.description 与 currentVersion.manifest.description 不一致`, - ); - } - return { - id: parsed.id, - slug: parsed.slug, - name: parsed.name, - description: parsed.description, - scope: parsed.scope, - organizationId: parsed.organizationId, - currentVersion, - }; -} - -function nextCursor(value: unknown): string | null { - if (value === null) return null; - if (!isValidSkillResourceId(value)) { - throw new SkillProtocolError('response.nextCursor 必须是合法 Skill ID 或 null'); - } - return value; -} - -/** 解析并规范化 Skill 分页列表响应。 */ -export function parseListSkillsResponse(value: unknown): ListSkillsResponse { - const raw = object(value, 'response'); - if (raw.schemaVersion !== SKILL_API_SCHEMA_VERSION) { - throw new SkillProtocolError(`response.schemaVersion 必须为 ${SKILL_API_SCHEMA_VERSION}`); - } - if (!Array.isArray(raw.skills)) { - throw new SkillProtocolError('response.skills 必须是数组'); - } - return { - schemaVersion: SKILL_API_SCHEMA_VERSION, - skills: raw.skills.map(parseVisibleSkillSummary), - nextCursor: nextCursor(raw.nextCursor), - }; -} - -/** 解析并规范化单个 Skill 详情响应,并校验外层展示字段与 manifest 一致。 */ -export function parseGetSkillResponse(value: unknown): GetSkillResponse { - const raw = object(value, 'response'); - if (raw.schemaVersion !== SKILL_API_SCHEMA_VERSION) { - throw new SkillProtocolError(`response.schemaVersion 必须为 ${SKILL_API_SCHEMA_VERSION}`); - } - return { - schemaVersion: SKILL_API_SCHEMA_VERSION, - skill: parseVisibleSkillDetail(raw.skill, 'response.skill'), - }; -} - -/** 解析当前 Skill Version 的短期 HTTPS 下载凭证。 */ -export function parseSkillDownloadResponse(value: unknown): SkillDownloadResponse { - const raw = object(value, 'response'); - const url = string(raw.url, 'response.url', 4096); - let parsedUrl: URL; - try { - parsedUrl = new URL(url); - } catch { - throw new SkillProtocolError('response.url 必须是合法 URL'); - } - if (parsedUrl.protocol !== 'https:') { - throw new SkillProtocolError('response.url 必须使用 HTTPS'); - } - return { - url, - expiresAt: isoDate(raw.expiresAt, 'response.expiresAt'), - sha256: sha256(raw.sha256, 'response.sha256'), - sizeBytes: positiveSize(raw.sizeBytes, 'response.sizeBytes'), - }; -} diff --git a/packages/skill-protocol/src/index.ts b/packages/skill-protocol/src/index.ts deleted file mode 100644 index e4f2c90..0000000 --- a/packages/skill-protocol/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './manifest.js'; -export * from './delivery.js'; diff --git a/packages/skill-protocol/src/manifest.ts b/packages/skill-protocol/src/manifest.ts deleted file mode 100644 index 6bb711e..0000000 --- a/packages/skill-protocol/src/manifest.ts +++ /dev/null @@ -1,176 +0,0 @@ -/** Skill 包内必须存在的根入口文件。 */ -export const SKILL_ENTRY_FILE = 'SKILL.md' as const; - -/** Skill 发布 manifest 的格式版本;与市场 HTTP envelope 独立演进。 */ -export const SKILL_PACKAGE_SCHEMA_VERSION = 1 as const; - -/** 市场 Skill slug:小写字母或数字开头和结尾,中间允许小写字母、数字和连字符。 */ -const SKILL_SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/; - -/** Windows 设备保留名;跨平台安装时任何路径段都不得使用。 */ -const WINDOWS_RESERVED_NAME_RE = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i; - -/** Skill 协议值不合法时由运行时解析器抛出的错误。 */ -export class SkillProtocolError extends Error { - constructor(message: string) { - super(message); - this.name = 'SkillProtocolError'; - } -} - -/** 发布包中单个普通文件的内容指纹。目录不进入清单。 */ -export interface SkillPackageFileEntry { - /** 相对于 Skill 根目录的规范 POSIX 路径。 */ - path: string; - /** 文件原始字节数;空文件允许为 0。 */ - sizeBytes: number; - /** 文件原始字节的 SHA-256,固定为 64 位小写十六进制。 */ - sha256: string; -} - -/** - * Skill 发布包的确定性 manifest。 - * - * manifest 作为发布请求元数据传输,不放入自身的 `files` 清单,避免自指纹。 - * 服务端必须重新从上传制品计算文件清单,不能信任发布方声明。 - */ -export interface SkillPackageManifest { - schemaVersion: typeof SKILL_PACKAGE_SCHEMA_VERSION; - /** 市场逻辑名称;同一 Scope owner 内永久唯一。 */ - slug: string; - /** 展示名,必须与 `SKILL.md` frontmatter.name 一致。 */ - name: string; - /** 简介,必须与 `SKILL.md` frontmatter.description 一致。 */ - description: string; - /** 不透明版本字符串;同一 Skill 内不可重复。 */ - version: string; - /** 按 `path` 逐字升序排列的完整普通文件清单。 */ - files: SkillPackageFileEntry[]; -} - -function object(value: unknown, path: string): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new SkillProtocolError(`${path} 必须是对象`); - } - return value as Record; -} - -function string(value: unknown, path: string, max: number): string { - if (typeof value !== 'string' || value.length === 0 || value.length > max) { - throw new SkillProtocolError(`${path} 必须是 1–${max} 字符的字符串`); - } - return value; -} - -/** 判断值是否符合市场 Skill slug 规则。 */ -export function isValidSkillSlug(value: unknown): value is string { - return ( - typeof value === 'string' && SKILL_SLUG_RE.test(value) && !WINDOWS_RESERVED_NAME_RE.test(value) - ); -} - -/** 判断值是否为可安全安装到 macOS、Windows 和 Linux 的相对 POSIX 文件路径。 */ -export function isValidSkillPackagePath(value: unknown): value is string { - if ( - typeof value !== 'string' || - value.length === 0 || - value.length > 240 || - value.startsWith('/') || - value.startsWith('./') || - value.endsWith('/') || - value.includes('\\') || - value.normalize('NFC') !== value - ) { - return false; - } - const segments = value.split('/'); - return segments.every( - (segment) => - segment.length > 0 && - segment !== '.' && - segment !== '..' && - !/[<>:"|?*]/.test(segment) && - ![...segment].some((character) => character.charCodeAt(0) <= 31) && - !/[. ]$/.test(segment) && - !WINDOWS_RESERVED_NAME_RE.test(segment), - ); -} - -function parseFileEntry(value: unknown, index: number): SkillPackageFileEntry { - const path = `manifest.files[${index}]`; - const raw = object(value, path); - if (!isValidSkillPackagePath(raw.path)) { - throw new SkillProtocolError(`${path}.path 不是安全的相对 POSIX 路径`); - } - if ( - typeof raw.sizeBytes !== 'number' || - !Number.isSafeInteger(raw.sizeBytes) || - raw.sizeBytes < 0 - ) { - throw new SkillProtocolError(`${path}.sizeBytes 必须是非负安全整数`); - } - const sha256 = string(raw.sha256, `${path}.sha256`, 64); - if (!/^[a-f0-9]{64}$/.test(sha256)) { - throw new SkillProtocolError(`${path}.sha256 必须是 64 位小写十六进制`); - } - return { path: raw.path, sizeBytes: raw.sizeBytes, sha256 }; -} - -/** - * 解析并规范化 Skill 发布 manifest。 - * - * 未知字段会被忽略;已知字段缺失、文件路径不安全、大小/指纹不合法、 - * 清单未排序或存在跨平台同名冲突时抛出 `SkillProtocolError`。 - */ -export function parseSkillPackageManifest(value: unknown): SkillPackageManifest { - const raw = object(value, 'manifest'); - if (raw.schemaVersion !== SKILL_PACKAGE_SCHEMA_VERSION) { - throw new SkillProtocolError(`manifest.schemaVersion 必须为 ${SKILL_PACKAGE_SCHEMA_VERSION}`); - } - if (!isValidSkillSlug(raw.slug)) { - throw new SkillProtocolError('manifest.slug 不合法'); - } - const name = string(raw.name, 'manifest.name', 100); - const description = string(raw.description, 'manifest.description', 500); - const version = string(raw.version, 'manifest.version', 64); - if (name.trim() !== name || description.trim() !== description || version.trim() !== version) { - throw new SkillProtocolError('manifest.name/description/version 不得有首尾空白'); - } - if (!Array.isArray(raw.files) || raw.files.length === 0) { - throw new SkillProtocolError('manifest.files 必须是非空数组'); - } - - const files = raw.files.map(parseFileEntry); - const caseFoldedPaths = new Set(); - let previousPath: string | null = null; - let totalSize = 0; - for (const file of files) { - if (previousPath !== null && file.path <= previousPath) { - throw new SkillProtocolError('manifest.files 必须按 path 逐字升序排列且不得重复'); - } - const caseFoldedPath = file.path.toLocaleLowerCase('en-US'); - if (caseFoldedPaths.has(caseFoldedPath)) { - throw new SkillProtocolError(`manifest.files 存在跨平台同名路径: ${file.path}`); - } - caseFoldedPaths.add(caseFoldedPath); - previousPath = file.path; - totalSize += file.sizeBytes; - if (!Number.isSafeInteger(totalSize)) { - throw new SkillProtocolError('manifest.files 总大小超出安全整数范围'); - } - } - - const entry = files.find((file) => file.path === SKILL_ENTRY_FILE); - if (!entry || entry.sizeBytes === 0) { - throw new SkillProtocolError(`manifest.files 必须包含非空的 ${SKILL_ENTRY_FILE}`); - } - - return { - schemaVersion: SKILL_PACKAGE_SCHEMA_VERSION, - slug: raw.slug, - name, - description, - version, - files, - }; -} diff --git a/packages/skill-protocol/tsconfig.json b/packages/skill-protocol/tsconfig.json deleted file mode 100644 index 3f9aa88..0000000 --- a/packages/skill-protocol/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "Node16", - "moduleResolution": "Node16", - "strict": true, - "skipLibCheck": true, - "noEmit": true - }, - "include": ["src/**/*.ts"] -} diff --git a/packages/voice-protocol/package.json b/packages/voice-protocol/package.json deleted file mode 100644 index 61c1b15..0000000 --- a/packages/voice-protocol/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "@cindy/voice-protocol", - "version": "0.0.0", - "private": true, - "description": "Cindy voice client/server wire contract: sessions, one-shot tickets and refinement payloads. Zero runtime deps", - "type": "module", - "main": "./src/index.ts", - "types": "./src/index.ts", - "exports": { - ".": "./src/index.ts", - "./types": "./src/types.ts" - }, - "scripts": { - "build": "tsc --noEmit", - "test": "vitest run" - }, - "license": "Apache-2.0", - "devDependencies": { - "typescript": "^5.7.0", - "vitest": "^3.2.4" - } -} diff --git a/packages/voice-protocol/src/__tests__/protocol.test.ts b/packages/voice-protocol/src/__tests__/protocol.test.ts deleted file mode 100644 index 3608062..0000000 --- a/packages/voice-protocol/src/__tests__/protocol.test.ts +++ /dev/null @@ -1,486 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { - VOICE_MAX_REFINER_PAYLOAD_CHARS, - VOICE_PROTOCOL_VERSION, - VOICE_ASR_PATH, - VOICE_DICTIONARY_LEARNING_PATH, - VOICE_SESSION_PATH, - makeVoiceRefinePath, - parseCreateVoiceSessionRequest, - parseCreateVoiceSessionResponse, - parseVoiceErrorResponse, - parseVoiceRefineRequest, - parseVoiceRefineRequestWithPayload, - parseVoiceRefinerUserPayload, - parseVoiceRefinerUserPayloadJson, - type CreateVoiceSessionRequest, - type CreateVoiceSessionResponse, - type VoiceRefinerUserPayload, -} from '../index'; - -function roundTrip(value: T, parse: (input: unknown) => { ok: boolean; value?: T }): T { - const parsed = parse(JSON.parse(JSON.stringify(value))); - expect(parsed.ok).toBe(true); - if (!parsed.ok || parsed.value === undefined) throw new Error('unreachable'); - expect(parsed.value).toEqual(value); - return parsed.value; -} - -function expectReject( - value: T, - parse: (input: T) => { ok: boolean; error?: string }, - keyword: string, -): void { - const parsed = parse(value); - expect(parsed.ok).toBe(false); - if (parsed.ok) throw new Error('unreachable'); - expect(parsed.error).toContain(keyword); -} - -const VALID_SESSION_REQUEST: CreateVoiceSessionRequest = { - protocolVersion: VOICE_PROTOCOL_VERSION, - mode: 'dictation', - language: 'zh-CN', - client: 'desktop', - clientVersion: '1.2.3', - asrProvider: 'example-asr-provider', - refinerProvider: 'example-refiner-provider', -}; - -const VALID_SESSION_RESPONSE: CreateVoiceSessionResponse = { - protocolVersion: VOICE_PROTOCOL_VERSION, - sessionId: '11111111-1111-4111-8111-111111111111', - ticket: 'one-shot-ticket', - expiresAt: '2026-07-21T12:00:00.000Z', - asr: { - provider: 'example-asr-provider', - websocketUrl: 'wss://voice.example.com/api/voice/asr', - protocolProfile: 'qwen-asr-server-vad', - sampleRate: 16_000, - }, - refiner: { enabled: true, provider: 'example-refiner-provider' }, -}; - -const VALID_REFINEMENT_PAYLOAD: VoiceRefinerUserPayload = { - schemaName: 'dictation_refinement', - input: { - promptVersion: 'dictation-refinement.zh.v1', - context: { - uiLanguage: 'zh-CN', - sourceLanguage: 'zh-CN', - userDictionary: 'Cindy', - }, - dictationText: '测试语音输入', - }, -}; - -/** Same refinement input, minus the prompt version the server now owns. */ -const SERVER_OWNED_REFINEMENT_PAYLOAD: VoiceRefinerUserPayload = { - schemaName: 'dictation_refinement', - input: { - context: { uiLanguage: 'zh-CN', sourceLanguage: 'zh-CN' }, - dictationText: '测试语音输入', - }, -}; - -describe('voice session contract', () => { - it('round-trips the create request and response', () => { - roundTrip(VALID_SESSION_REQUEST, parseCreateVoiceSessionRequest); - roundTrip(VALID_SESSION_RESPONSE, parseCreateVoiceSessionResponse); - }); - - it('accepts rollout-era v1 messages without protocolVersion', () => { - const request = { ...VALID_SESSION_REQUEST }; - const response = { ...VALID_SESSION_RESPONSE }; - delete request.protocolVersion; - delete response.protocolVersion; - expect(parseCreateVoiceSessionRequest(request).ok).toBe(true); - expect(parseCreateVoiceSessionResponse(response).ok).toBe(true); - }); - - it('rejects unsupported versions and malformed request fields', () => { - expectReject( - { ...VALID_SESSION_REQUEST, protocolVersion: 2 }, - parseCreateVoiceSessionRequest, - 'protocolVersion', - ); - expectReject( - { ...VALID_SESSION_REQUEST, client: 'browser' }, - parseCreateVoiceSessionRequest, - 'client', - ); - expectReject( - { ...VALID_SESSION_REQUEST, asrProvider: '' }, - parseCreateVoiceSessionRequest, - 'asrProvider', - ); - }); - - it('rejects invalid ASR targets and refiner field combinations', () => { - expectReject( - { - ...VALID_SESSION_RESPONSE, - asr: { ...VALID_SESSION_RESPONSE.asr, websocketUrl: 'https://voice.example.com/asr' }, - }, - parseCreateVoiceSessionResponse, - 'websocketUrl', - ); - expectReject( - { - ...VALID_SESSION_RESPONSE, - asr: { ...VALID_SESSION_RESPONSE.asr, protocolProfile: 'unknown-profile' }, - }, - parseCreateVoiceSessionResponse, - 'protocolProfile', - ); - expectReject( - { ...VALID_SESSION_RESPONSE, refiner: { enabled: true } }, - parseCreateVoiceSessionResponse, - 'refiner.provider', - ); - expectReject( - { ...VALID_SESSION_RESPONSE, refiner: { enabled: false, provider: 'unexpected' } }, - parseCreateVoiceSessionResponse, - 'must be absent', - ); - }); - - it('round-trips the server-owned prompt marker and rejects bad owners', () => { - roundTrip( - { - ...VALID_SESSION_RESPONSE, - refiner: { enabled: true, provider: 'example-refiner-provider', promptOwner: 'server' }, - } satisfies CreateVoiceSessionResponse, - parseCreateVoiceSessionResponse, - ); - // Absent marker is the rollout default: a server that predates the field - // keeps the historical client-owned behaviour. - expect(parseCreateVoiceSessionResponse(VALID_SESSION_RESPONSE).ok).toBe(true); - expectReject( - { - ...VALID_SESSION_RESPONSE, - refiner: { enabled: true, provider: 'example-refiner-provider', promptOwner: 'nobody' }, - }, - parseCreateVoiceSessionResponse, - 'promptOwner', - ); - expectReject( - { ...VALID_SESSION_RESPONSE, refiner: { enabled: false, promptOwner: 'server' } }, - parseCreateVoiceSessionResponse, - 'must be absent', - ); - }); - - it('builds encoded Cindy-owned routes', () => { - expect(VOICE_SESSION_PATH).toBe('/api/voice/sessions'); - expect(VOICE_ASR_PATH).toBe('/api/voice/asr'); - expect(VOICE_DICTIONARY_LEARNING_PATH).toBe('/api/voice/dictionary-learning'); - expect(makeVoiceRefinePath('session/a', 'provider+b')).toBe( - '/api/voice/sessions/session%2Fa/refine?provider=provider%2Bb', - ); - }); -}); - -describe('voice refinement contract', () => { - it('round-trips both supported user payload schemas', () => { - roundTrip(VALID_REFINEMENT_PAYLOAD, parseVoiceRefinerUserPayload); - roundTrip( - { - schemaName: 'dictation_dictionary_learning', - input: { - promptVersion: 'dictation-dictionary-learning.zh.v1', - source: 'in_app', - beforeText: '旧文本', - afterText: '新文本', - context: { activeApp: 'Cindy' }, - existingEntries: [ - { - term: 'Cindy', - aliases: [{ text: '辛迪', count: 2 }], - source: 'manual', - frequency: 4, - }, - ], - existingCandidates: [{ term: 'Codex', evidenceCount: 1 }], - }, - } satisfies VoiceRefinerUserPayload, - parseVoiceRefinerUserPayload, - ); - }); - - it('parses the request envelope and encoded user payload', () => { - const request = { - prompt_cache_key: 'voice-session', - messages: [ - { role: 'system', content: 'System prompt' }, - { role: 'user', content: JSON.stringify(VALID_REFINEMENT_PAYLOAD) }, - ], - }; - expect(parseVoiceRefineRequest(request).ok).toBe(true); - expect(parseVoiceRefinerUserPayloadJson(request.messages[1].content)).toEqual({ - ok: true, - value: VALID_REFINEMENT_PAYLOAD, - }); - }); - - it('accepts a server-owned envelope carrying only the user message', () => { - const request = { - messages: [{ role: 'user', content: JSON.stringify(SERVER_OWNED_REFINEMENT_PAYLOAD) }], - }; - expect(parseVoiceRefineRequest(request).ok).toBe(true); - expect(parseVoiceRefinerUserPayloadJson(request.messages[0].content)).toEqual({ - ok: true, - value: SERVER_OWNED_REFINEMENT_PAYLOAD, - }); - }); - - it('round-trips both payload schemas without a client prompt version', () => { - roundTrip(SERVER_OWNED_REFINEMENT_PAYLOAD, parseVoiceRefinerUserPayload); - roundTrip( - { - schemaName: 'dictation_dictionary_learning', - input: { beforeText: '旧文本', afterText: '新文本' }, - } satisfies VoiceRefinerUserPayload, - parseVoiceRefinerUserPayload, - ); - }); - - it('rejects envelopes that are empty, over-long or missing the user message', () => { - expectReject({ messages: [] }, parseVoiceRefineRequest, 'request.messages'); - expectReject( - { - messages: [ - { role: 'system', content: 'a' }, - { role: 'user', content: 'b' }, - { role: 'user', content: 'c' }, - ], - }, - parseVoiceRefineRequest, - 'request.messages', - ); - // A lone system message is not a valid server-owned request: the user - // payload is what carries the schemaName the server dispatches on. - expectReject( - { messages: [{ role: 'system', content: 'only a prompt' }] }, - parseVoiceRefineRequest, - 'messages[0].role must be user', - ); - }); - - it('rejects bad message order, malformed JSON and unsupported schemas', () => { - expectReject( - { - messages: [ - { role: 'user', content: 'wrong order' }, - { role: 'system', content: 'wrong order' }, - ], - }, - parseVoiceRefineRequest, - 'messages[0].role', - ); - expectReject('{', parseVoiceRefinerUserPayloadJson, 'valid JSON'); - expectReject( - '"' + 'x'.repeat(VOICE_MAX_REFINER_PAYLOAD_CHARS + 1) + '"', - parseVoiceRefinerUserPayloadJson, - 'payload too large', - ); - expectReject({ schemaName: 'unknown', input: {} }, parseVoiceRefinerUserPayload, 'schemaName'); - expectReject( - { input: VALID_REFINEMENT_PAYLOAD.input }, - parseVoiceRefinerUserPayload, - 'schemaName is required', - ); - }); - - it('rejects unknown strict fields and invalid dictionary counts', () => { - expectReject( - { - ...VALID_REFINEMENT_PAYLOAD, - input: { ...VALID_REFINEMENT_PAYLOAD.input, promptVersion: '' }, - }, - parseVoiceRefinerUserPayload, - 'promptVersion', - ); - expectReject( - { - schemaName: 'dictation_dictionary_learning', - input: { promptVersion: '', beforeText: 'before', afterText: 'after' }, - }, - parseVoiceRefinerUserPayload, - 'promptVersion', - ); - expectReject( - { - ...VALID_REFINEMENT_PAYLOAD, - input: { ...VALID_REFINEMENT_PAYLOAD.input, unexpected: true }, - }, - parseVoiceRefinerUserPayload, - 'unknown field', - ); - expectReject( - { - schemaName: 'dictation_dictionary_learning', - input: { - promptVersion: 'v1', - beforeText: '', - afterText: '', - existingCandidates: [{ term: 'Cindy', evidenceCount: -1 }], - }, - }, - parseVoiceRefinerUserPayload, - 'evidenceCount', - ); - }); -}); - -describe('route-aware combined parsing', () => { - const learningPayload = { - schemaName: 'dictation_dictionary_learning', - input: { beforeText: '旧文本', afterText: '新文本' }, - } satisfies VoiceRefinerUserPayload; - - function envelope(payload: unknown, system?: string) { - const user = { role: 'user', content: JSON.stringify(payload) }; - return { messages: system ? [{ role: 'system', content: system }, user] : [user] }; - } - - it('derives the prompt owner from the envelope', () => { - const serverOwned = parseVoiceRefineRequestWithPayload(envelope(learningPayload)); - expect(serverOwned.ok && serverOwned.value.promptOwner).toBe('server'); - - const clientOwned = parseVoiceRefineRequestWithPayload( - envelope(VALID_REFINEMENT_PAYLOAD, 'a prompt'), - ); - expect(clientOwned.ok && clientOwned.value.promptOwner).toBe('client'); - expect(clientOwned.ok && clientOwned.value.payload).toEqual(VALID_REFINEMENT_PAYLOAD); - }); - - it('keeps promptVersion mandatory for client-owned payloads', () => { - // The relaxation is scoped to server-owned prompts: a client-owned request - // that drops the version would silently lose the cache-key input that - // identifies the prompt it was built for. - expectReject( - envelope(SERVER_OWNED_REFINEMENT_PAYLOAD, 'a prompt'), - parseVoiceRefineRequestWithPayload, - 'promptVersion', - ); - expect(parseVoiceRefineRequestWithPayload(envelope(SERVER_OWNED_REFINEMENT_PAYLOAD)).ok).toBe( - true, - ); - }); - - it('locks the dictionary-learning route to server-owned learning payloads', () => { - expect( - parseVoiceRefineRequestWithPayload(envelope(learningPayload), { - route: 'dictionary_learning', - }).ok, - ).toBe(true); - // A caller-supplied prompt on the session-less route would bypass the - // server's own prompt injection. - expectReject( - { value: envelope(learningPayload, 'attacker prompt') }, - (input: { value: unknown }) => - parseVoiceRefineRequestWithPayload(input.value, { route: 'dictionary_learning' }), - 'must omit the system message', - ); - // So would smuggling a refinement payload onto it. - expectReject( - { value: envelope(SERVER_OWNED_REFINEMENT_PAYLOAD) }, - (input: { value: unknown }) => - parseVoiceRefineRequestWithPayload(input.value, { route: 'dictionary_learning' }), - 'must be dictation_dictionary_learning', - ); - }); - - it('leaves the standalone payload parser permissive for owner-agnostic callers', () => { - expect(parseVoiceRefinerUserPayload(SERVER_OWNED_REFINEMENT_PAYLOAD).ok).toBe(true); - expect( - parseVoiceRefinerUserPayload(SERVER_OWNED_REFINEMENT_PAYLOAD, { promptOwner: 'client' }).ok, - ).toBe(false); - }); - - it('infers server ownership from the dictionary-learning route alone', () => { - // Passing only `route` must still apply the server-owned field rules — - // otherwise a caller-supplied promptVersion slips through route-aware - // validation on a route that is server-owned by definition. - expectReject( - { value: { ...learningPayload, input: { ...learningPayload.input, promptVersion: 'v1' } } }, - (input: { value: unknown }) => - parseVoiceRefinerUserPayload(input.value, { route: 'dictionary_learning' }), - 'promptVersion must be absent', - ); - expect(parseVoiceRefinerUserPayload(learningPayload, { route: 'dictionary_learning' }).ok).toBe( - true, - ); - }); - - it('rejects client ownership on the dictionary-learning route in the standalone parser', () => { - // The route is server-owned by definition, so this option combination is - // self-contradictory. Consumers that parse envelope and payload separately - // must not be able to validate it. - expectReject( - { value: learningPayload }, - (input: { value: unknown }) => - parseVoiceRefinerUserPayload(input.value, { - route: 'dictionary_learning', - promptOwner: 'client', - }), - 'cannot be client-owned on this route', - ); - expect( - parseVoiceRefinerUserPayload(learningPayload, { - route: 'dictionary_learning', - promptOwner: 'server', - }).ok, - ).toBe(true); - }); - - it('rejects a caller-supplied promptVersion when the server owns the prompt', () => { - // Mirror of the cache-key rule: under server ownership the version is the - // server's, so a caller-supplied one is attacker-controlled metadata a - // handler might select or cache against. - expectReject( - envelope(VALID_REFINEMENT_PAYLOAD), - parseVoiceRefineRequestWithPayload, - 'promptVersion must be absent', - ); - expect(parseVoiceRefineRequestWithPayload(envelope(SERVER_OWNED_REFINEMENT_PAYLOAD)).ok).toBe( - true, - ); - // Owner-agnostic standalone callers stay permissive. - expect(parseVoiceRefinerUserPayload(VALID_REFINEMENT_PAYLOAD).ok).toBe(true); - }); - - it('rejects a caller-supplied cache key when the server owns the prompt', () => { - // Forwarding it would let a caller pick the cache shard for a prompt it - // never saw; under server ownership the key is the server's to generate. - expectReject( - { ...envelope(learningPayload), prompt_cache_key: 'attacker-chosen-shard' }, - parseVoiceRefineRequestWithPayload, - 'prompt_cache_key must be absent', - ); - // Client-owned requests still carry their own key, as before. - expect( - parseVoiceRefineRequestWithPayload({ - ...envelope(VALID_REFINEMENT_PAYLOAD, 'a prompt'), - prompt_cache_key: 'xdt:dictation_refinement:abc', - }).ok, - ).toBe(true); - }); -}); - -describe('voice error envelope', () => { - it('accepts the shared structured error shape', () => { - expect( - parseVoiceErrorResponse({ error: { code: 'RATE_LIMITED', message: 'Try later' } }), - ).toEqual({ - ok: true, - value: { error: { code: 'RATE_LIMITED', message: 'Try later' } }, - }); - }); - - it('rejects missing codes', () => { - expectReject({ error: { code: '', message: 'bad' } }, parseVoiceErrorResponse, 'error.code'); - }); -}); diff --git a/packages/voice-protocol/src/index.ts b/packages/voice-protocol/src/index.ts deleted file mode 100644 index 419d30a..0000000 --- a/packages/voice-protocol/src/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * @cindy/voice-protocol - * --------------------------------------------------------------------------- - * Cindy voice client/server wire contract: types, runtime parsing and routes. - * The package is source-shipped, zero-dependency and React Native safe. - */ - -export * from './types'; -export { makeVoiceRefinePath } from './routes'; -export { - isVoiceClientKind, - isVoiceProtocolProfile, - parseCreateVoiceSessionRequest, - parseCreateVoiceSessionResponse, - parseVoiceErrorResponse, - parseVoiceRefineRequest, - parseVoiceRefineRequestWithPayload, - parseVoiceRefinerUserPayload, - parseVoiceRefinerUserPayloadJson, - type VoiceRefinerUserPayloadOptions, -} from './parse'; diff --git a/packages/voice-protocol/src/parse.ts b/packages/voice-protocol/src/parse.ts deleted file mode 100644 index 516524a..0000000 --- a/packages/voice-protocol/src/parse.ts +++ /dev/null @@ -1,545 +0,0 @@ -/** Runtime validation for every Cindy-owned voice HTTP payload. */ - -import { - VOICE_CLIENT_KINDS, - VOICE_MAX_REFINER_PAYLOAD_CHARS, - VOICE_PROMPT_OWNERS, - VOICE_PROTOCOL_PROFILES, - VOICE_PROTOCOL_VERSION, - type CreateVoiceSessionRequest, - type CreateVoiceSessionResponse, - type VoiceClientKind, - type VoiceErrorResponse, - type VoiceParseResult, - type VoicePromptOwner, - type VoiceProtocolProfile, - type VoiceRefineRequest, - type VoiceRefineRequestWithPayload, - type VoiceRefineRoute, - type VoiceRefinerUserPayload, -} from './types'; - -type PlainObject = Record; - -function ok(value: T): VoiceParseResult { - return { ok: true, value }; -} - -function fail(error: string): VoiceParseResult { - return { ok: false, error }; -} - -function isPlainObject(value: unknown): value is PlainObject { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function hasOnlyKeys(value: PlainObject, allowed: readonly string[]): boolean { - return Object.keys(value).every((key) => allowed.includes(key)); -} - -function stringError( - value: unknown, - path: string, - options: { min?: number; max: number }, -): string | null { - if (typeof value !== 'string') return `${path} must be a string`; - if (options.min !== undefined && value.length < options.min) { - return `${path} must contain at least ${options.min} character(s)`; - } - if (value.length > options.max) return `${path} must contain at most ${options.max} characters`; - return null; -} - -function optionalStringError( - value: unknown, - path: string, - options: { min?: number; max: number }, -): string | null { - return value === undefined ? null : stringError(value, path, options); -} - -function optionalCountError(value: unknown, path: string): string | null { - if (value === undefined) return null; - if (!Number.isInteger(value) || (value as number) < 0) { - return `${path} must be a non-negative integer when present`; - } - return null; -} - -export function isVoiceClientKind(value: unknown): value is VoiceClientKind { - return typeof value === 'string' && VOICE_CLIENT_KINDS.includes(value as VoiceClientKind); -} - -export function isVoiceProtocolProfile(value: unknown): value is VoiceProtocolProfile { - return ( - typeof value === 'string' && VOICE_PROTOCOL_PROFILES.includes(value as VoiceProtocolProfile) - ); -} - -function protocolVersionError(value: unknown, path: string): string | null { - if (value === undefined || value === VOICE_PROTOCOL_VERSION) return null; - return `${path} must be ${VOICE_PROTOCOL_VERSION} when present`; -} - -export function parseCreateVoiceSessionRequest( - value: unknown, -): VoiceParseResult { - if (!isPlainObject(value)) return fail('request must be an object'); - - let error = protocolVersionError(value.protocolVersion, 'request.protocolVersion'); - if (error) return fail(error); - if (value.mode !== undefined && value.mode !== 'dictation') { - return fail('request.mode must be dictation when present'); - } - error = optionalStringError(value.language, 'request.language', { min: 1, max: 32 }); - if (error) return fail(error); - if (!isVoiceClientKind(value.client)) { - return fail('request.client must be desktop or mobile'); - } - error = optionalStringError(value.clientVersion, 'request.clientVersion', { max: 64 }); - if (error) return fail(error); - error = stringError(value.asrProvider, 'request.asrProvider', { min: 1, max: 80 }); - if (error) return fail(error); - error = optionalStringError(value.refinerProvider, 'request.refinerProvider', { - min: 1, - max: 80, - }); - if (error) return fail(error); - - return ok(value as unknown as CreateVoiceSessionRequest); -} - -export function parseCreateVoiceSessionResponse( - value: unknown, -): VoiceParseResult { - if (!isPlainObject(value)) return fail('response must be an object'); - - let error = protocolVersionError(value.protocolVersion, 'response.protocolVersion'); - if (error) return fail(error); - error = stringError(value.sessionId, 'response.sessionId', { min: 1, max: 200 }); - if (error) return fail(error); - error = stringError(value.ticket, 'response.ticket', { min: 1, max: 4096 }); - if (error) return fail(error); - error = stringError(value.expiresAt, 'response.expiresAt', { min: 1, max: 64 }); - if (error) return fail(error); - if (Number.isNaN(Date.parse(value.expiresAt as string))) { - return fail('response.expiresAt must be an ISO-compatible timestamp'); - } - - if (!isPlainObject(value.asr)) return fail('response.asr must be an object'); - error = stringError(value.asr.provider, 'response.asr.provider', { min: 1, max: 80 }); - if (error) return fail(error); - error = stringError(value.asr.websocketUrl, 'response.asr.websocketUrl', { - min: 1, - max: 2048, - }); - if (error) return fail(error); - if (!/^wss?:\/\//i.test(value.asr.websocketUrl as string)) { - return fail('response.asr.websocketUrl must use ws or wss'); - } - if (!isVoiceProtocolProfile(value.asr.protocolProfile)) { - return fail('response.asr.protocolProfile is unsupported'); - } - if ( - !Number.isInteger(value.asr.sampleRate) || - (value.asr.sampleRate as number) <= 0 || - (value.asr.sampleRate as number) > 48_000 - ) { - return fail('response.asr.sampleRate must be a positive integer no greater than 48000'); - } - error = optionalStringError(value.asr.model, 'response.asr.model', { min: 1, max: 160 }); - if (error) return fail(error); - error = optionalStringError(value.asr.resourceId, 'response.asr.resourceId', { - min: 1, - max: 160, - }); - if (error) return fail(error); - - if (!isPlainObject(value.refiner)) return fail('response.refiner must be an object'); - if (typeof value.refiner.enabled !== 'boolean') { - return fail('response.refiner.enabled must be a boolean'); - } - if (value.refiner.enabled) { - error = stringError(value.refiner.provider, 'response.refiner.provider', { - min: 1, - max: 80, - }); - if (error) return fail(error); - if ( - value.refiner.promptOwner !== undefined && - !VOICE_PROMPT_OWNERS.includes(value.refiner.promptOwner as VoicePromptOwner) - ) { - return fail('response.refiner.promptOwner must be client or server when present'); - } - } else if (value.refiner.provider !== undefined) { - return fail('response.refiner.provider must be absent when refinement is disabled'); - } else if (value.refiner.promptOwner !== undefined) { - return fail('response.refiner.promptOwner must be absent when refinement is disabled'); - } - - return ok(value as unknown as CreateVoiceSessionResponse); -} - -export function parseVoiceRefineRequest(value: unknown): VoiceParseResult { - if (!isPlainObject(value)) return fail('request must be an object'); - let error = optionalStringError(value.prompt_cache_key, 'request.prompt_cache_key', { max: 200 }); - if (error) return fail(error); - // Two shapes are legal: `[system, user]` when the client owns the prompt, and - // `[user]` alone when the server does (see VoicePromptOwner). The user - // message is always last, so its index depends on whether system is present. - if (!Array.isArray(value.messages) || value.messages.length < 1 || value.messages.length > 2) { - return fail( - 'request.messages must contain an optional system message followed by a user message', - ); - } - - const userIndex = value.messages.length - 1; - if (value.messages.length === 2) { - const system = value.messages[0]; - if (!isPlainObject(system) || system.role !== 'system') { - return fail('request.messages[0].role must be system'); - } - error = stringError(system.content, 'request.messages[0].content', { min: 1, max: 32_000 }); - if (error) return fail(error); - } - - const user = value.messages[userIndex]; - if (!isPlainObject(user) || user.role !== 'user') { - return fail(`request.messages[${userIndex}].role must be user`); - } - error = stringError(user.content, `request.messages[${userIndex}].content`, { - min: 1, - max: 64_000, - }); - if (error) return fail(error); - - return ok(value as unknown as VoiceRefineRequest); -} - -/** - * `promptVersion` identifies the prompt a payload was built for and feeds its - * cache key, so it tracks prompt ownership exactly: - * - * - `client`: mandatory — dropping it would silently lose the cache-key input. - * - `server`: must be absent — the server owns both the prompt and its version, - * so a caller-supplied one is attacker-controlled metadata that a handler - * might select or cache against (same reasoning as `prompt_cache_key`). - * - unknown (the historical single-arg form): permissive. - */ -function promptVersionError( - value: unknown, - path: string, - promptOwner: VoicePromptOwner | undefined, -): string | null { - const options = { min: 1, max: 80 } as const; - if (promptOwner === 'client') return stringError(value, `${path}.promptVersion`, options); - if (promptOwner === 'server' && value !== undefined) { - return `${path}.promptVersion must be absent when the server owns the prompt`; - } - return optionalStringError(value, `${path}.promptVersion`, options); -} - -function validateRefinementContext(value: unknown, path: string): string | null { - if (!isPlainObject(value)) return `${path} must be an object`; - const limits: Readonly> = { - uiLanguage: 32, - sourceLanguage: 32, - userRefinementInstructions: 1_000, - userDictionary: 4_000, - voiceInputHistory: 20_000, - selectionBefore: 1_200, - selectedText: 1_200, - selectionAfter: 1_200, - }; - if (!hasOnlyKeys(value, Object.keys(limits))) return `${path} contains an unknown field`; - for (const [key, max] of Object.entries(limits)) { - const error = optionalStringError(value[key], `${path}.${key}`, { max }); - if (error) return error; - } - return null; -} - -function validateDictationRefinementInput( - value: unknown, - path: string, - promptOwner: VoicePromptOwner | undefined, -): string | null { - if (!isPlainObject(value)) return `${path} must be an object`; - const keys = [ - 'promptVersion', - 'context', - 'dictationText', - 'replyToMessage', - 'userDictionaryMatches', - ]; - if (!hasOnlyKeys(value, keys)) return `${path} contains an unknown field`; - let error = promptVersionError(value.promptVersion, path, promptOwner); - if (error) return error; - error = validateRefinementContext(value.context, `${path}.context`); - if (error) return error; - error = stringError(value.dictationText, `${path}.dictationText`, { min: 1, max: 20_000 }); - if (error) return error; - error = optionalStringError(value.replyToMessage, `${path}.replyToMessage`, { max: 500 }); - if (error) return error; - return optionalStringError(value.userDictionaryMatches, `${path}.userDictionaryMatches`, { - max: 1_800, - }); -} - -function validateAlias(value: unknown, path: string): string | null { - if (!isPlainObject(value)) return `${path} must be an object`; - if (!hasOnlyKeys(value, ['text', 'count'])) return `${path} contains an unknown field`; - return ( - stringError(value.text, `${path}.text`, { max: 160 }) ?? - optionalCountError(value.count, `${path}.count`) - ); -} - -function validateTermBase(value: unknown, path: string): string | null { - if (!isPlainObject(value)) return `${path} must be an object`; - let error = stringError(value.term, `${path}.term`, { max: 160 }); - if (error) return error; - if (value.aliases === undefined) return null; - if (!Array.isArray(value.aliases) || value.aliases.length > 5) { - return `${path}.aliases must be an array with at most 5 items`; - } - for (let index = 0; index < value.aliases.length; index += 1) { - error = validateAlias(value.aliases[index], `${path}.aliases[${index}]`); - if (error) return error; - } - return null; -} - -function validateDictionaryEntry(value: unknown, path: string): string | null { - if (!isPlainObject(value)) return `${path} must be an object`; - if (!hasOnlyKeys(value, ['term', 'aliases', 'source', 'frequency'])) { - return `${path} contains an unknown field`; - } - const error = validateTermBase(value, path); - if (error) return error; - if (value.source !== undefined && value.source !== 'manual' && value.source !== 'automatic') { - return `${path}.source must be manual or automatic when present`; - } - return optionalCountError(value.frequency, `${path}.frequency`); -} - -function validateDictionaryCandidate(value: unknown, path: string): string | null { - if (!isPlainObject(value)) return `${path} must be an object`; - if (!hasOnlyKeys(value, ['term', 'aliases', 'evidenceCount'])) { - return `${path} contains an unknown field`; - } - return ( - validateTermBase(value, path) ?? - optionalCountError(value.evidenceCount, `${path}.evidenceCount`) - ); -} - -function validateDictionaryCollection( - value: unknown, - path: string, - validateItem: (item: unknown, itemPath: string) => string | null, -): string | null { - if (value === undefined) return null; - if (!Array.isArray(value) || value.length > 80) { - return `${path} must be an array with at most 80 items`; - } - for (let index = 0; index < value.length; index += 1) { - const error = validateItem(value[index], `${path}[${index}]`); - if (error) return error; - } - return null; -} - -function validateDictionaryLearningContext(value: unknown, path: string): string | null { - if (value === undefined) return null; - if (!isPlainObject(value)) return `${path} must be an object when present`; - const limits: Readonly> = { - uiLanguage: 32, - sourceLanguage: 32, - activeApp: 200, - selectionBefore: 600, - selectedText: 600, - selectionAfter: 600, - }; - if (!hasOnlyKeys(value, Object.keys(limits))) return `${path} contains an unknown field`; - for (const [key, max] of Object.entries(limits)) { - const error = optionalStringError(value[key], `${path}.${key}`, { max }); - if (error) return error; - } - return null; -} - -function validateDictionaryLearningInput( - value: unknown, - path: string, - promptOwner: VoicePromptOwner | undefined, -): string | null { - if (!isPlainObject(value)) return `${path} must be an object`; - const keys = [ - 'promptVersion', - 'debug', - 'source', - 'rawTranscriptText', - 'beforeText', - 'afterText', - 'context', - 'existingEntries', - 'existingCandidates', - ]; - if (!hasOnlyKeys(value, keys)) return `${path} contains an unknown field`; - let error = promptVersionError(value.promptVersion, path, promptOwner); - if (error) return error; - if (value.debug !== undefined && typeof value.debug !== 'boolean') { - return `${path}.debug must be a boolean when present`; - } - if ( - value.source !== undefined && - value.source !== 'in_app' && - value.source !== 'external_overlay' - ) { - return `${path}.source must be in_app or external_overlay when present`; - } - error = optionalStringError(value.rawTranscriptText, `${path}.rawTranscriptText`, { max: 2_000 }); - if (error) return error; - error = stringError(value.beforeText, `${path}.beforeText`, { max: 2_000 }); - if (error) return error; - error = stringError(value.afterText, `${path}.afterText`, { max: 2_000 }); - if (error) return error; - error = validateDictionaryLearningContext(value.context, `${path}.context`); - if (error) return error; - error = validateDictionaryCollection( - value.existingEntries, - `${path}.existingEntries`, - validateDictionaryEntry, - ); - if (error) return error; - return validateDictionaryCollection( - value.existingCandidates, - `${path}.existingCandidates`, - validateDictionaryCandidate, - ); -} - -export interface VoiceRefinerUserPayloadOptions { - /** - * When known, tightens validation to that owner's contract: `client` keeps - * `promptVersion` mandatory. Omit it only when the owner genuinely cannot be - * determined — prefer {@link parseVoiceRefineRequestWithPayload}, which - * derives the owner from the envelope. - */ - promptOwner?: VoicePromptOwner; - /** Restricts the accepted schema; see {@link VoiceRefineRoute}. */ - route?: VoiceRefineRoute; -} - -export function parseVoiceRefinerUserPayload( - value: unknown, - options?: VoiceRefinerUserPayloadOptions, -): VoiceParseResult { - if (!isPlainObject(value)) return fail('payload must be an object'); - if (!hasOnlyKeys(value, ['schemaName', 'input'])) { - return fail('payload contains an unknown field'); - } - - if (value.schemaName === undefined) { - return fail('payload.schemaName is required'); - } - - let promptOwner = options?.promptOwner; - if (options?.route === 'dictionary_learning') { - // Server-owned only, so a caller asserting client ownership on this route - // is self-contradictory — reject it rather than silently validating a - // forbidden combination for consumers that parse envelope and payload - // separately. - if (promptOwner === 'client') { - return fail('payload cannot be client-owned on this route'); - } - if (value.schemaName !== 'dictation_dictionary_learning') { - return fail('payload.schemaName must be dictation_dictionary_learning on this route'); - } - // The route itself establishes ownership, so a caller that passes only - // `route` still gets the server-owned field rules (no caller-supplied - // promptVersion) rather than the permissive owner-unknown ones. - promptOwner = 'server'; - } - - let error: string | null; - if (value.schemaName === 'dictation_refinement') { - error = validateDictationRefinementInput(value.input, 'payload.input', promptOwner); - } else if (value.schemaName === 'dictation_dictionary_learning') { - error = validateDictionaryLearningInput(value.input, 'payload.input', promptOwner); - } else { - return fail('payload.schemaName is unsupported'); - } - return error ? fail(error) : ok(value as unknown as VoiceRefinerUserPayload); -} - -export function parseVoiceRefinerUserPayloadJson( - raw: string, - options?: VoiceRefinerUserPayloadOptions, -): VoiceParseResult { - if (raw.length > VOICE_MAX_REFINER_PAYLOAD_CHARS) { - return fail(`payload too large: ${raw.length} > ${VOICE_MAX_REFINER_PAYLOAD_CHARS} chars`); - } - let value: unknown; - try { - value = JSON.parse(raw); - } catch { - return fail('payload must be valid JSON'); - } - return parseVoiceRefinerUserPayload(value, options); -} - -/** - * Single authoritative entry point for a refine-shaped HTTP body: parses the - * envelope, derives the prompt owner from it, then parses the user payload - * under that owner's contract and the route's schema restriction. - * - * Servers should prefer this over calling the envelope and payload parsers - * separately — the two contracts are coupled (a server-owned envelope may omit - * `promptVersion`; the session-less dictionary-learning route must not accept a - * caller-supplied prompt), and only a combined pass can enforce that coupling - * without ad hoc checks at the call site. - */ -export function parseVoiceRefineRequestWithPayload( - value: unknown, - options?: { route?: VoiceRefineRoute }, -): VoiceParseResult { - const request = parseVoiceRefineRequest(value); - if (!request.ok) return fail(request.error); - - const messages = request.value.messages; - const promptOwner: VoicePromptOwner = messages.length === 2 ? 'client' : 'server'; - const route = options?.route ?? 'refine'; - if (route === 'dictionary_learning' && promptOwner === 'client') { - return fail('request.messages must omit the system message on this route'); - } - // A client that does not hold the prompt cannot derive a key matching what - // the server will run, so under server ownership the key is the server's to - // generate. Accepting one here would let a caller pick the cache shard for a - // prompt it never saw. - if (promptOwner === 'server' && request.value.prompt_cache_key !== undefined) { - return fail('request.prompt_cache_key must be absent when the server owns the prompt'); - } - - const payload = parseVoiceRefinerUserPayloadJson(messages[messages.length - 1].content, { - promptOwner, - route, - }); - if (!payload.ok) return fail(payload.error); - - return ok({ request: request.value, payload: payload.value, promptOwner }); -} - -export function parseVoiceErrorResponse(value: unknown): VoiceParseResult { - if (!isPlainObject(value) || !isPlainObject(value.error)) { - return fail('response.error must be an object'); - } - const codeError = stringError(value.error.code, 'response.error.code', { min: 1, max: 100 }); - if (codeError) return fail(codeError); - const messageError = stringError(value.error.message, 'response.error.message', { - min: 1, - max: 2_000, - }); - return messageError ? fail(messageError) : ok(value as unknown as VoiceErrorResponse); -} diff --git a/packages/voice-protocol/src/routes.ts b/packages/voice-protocol/src/routes.ts deleted file mode 100644 index 3f4892c..0000000 --- a/packages/voice-protocol/src/routes.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { VOICE_SESSION_PATH } from './types'; - -/** Builds the authenticated refinement endpoint for one allocated voice session. */ -export function makeVoiceRefinePath(sessionId: string, provider: string): string { - return `${VOICE_SESSION_PATH}/${encodeURIComponent(sessionId)}/refine?provider=${encodeURIComponent(provider)}`; -} diff --git a/packages/voice-protocol/src/types.ts b/packages/voice-protocol/src/types.ts deleted file mode 100644 index b2631e0..0000000 --- a/packages/voice-protocol/src/types.ts +++ /dev/null @@ -1,222 +0,0 @@ -/** - * Cindy voice client <-> voice-server wire types. - * - * Provider-native WebSocket frames are deliberately excluded: voice-server - * transports those frames opaquely and never needs to understand them. - */ - -/** Initial voice protocol generation. Missing `protocolVersion` means v1 during rollout. */ -export const VOICE_PROTOCOL_VERSION = 1 as const; - -/** Stable Cindy-owned API paths. */ -export const VOICE_SESSION_PATH = '/api/voice/sessions' as const; -export const VOICE_ASR_PATH = '/api/voice/asr' as const; -/** - * Session-less dictionary-learning endpoint, authenticated by the account - * token alone. Dictionary learning is triggered by the user editing inserted - * text *after* dictation finished, when the ASR session (and its one-shot - * ticket) is already gone — so it cannot reuse the session-scoped refine path. - */ -export const VOICE_DICTIONARY_LEARNING_PATH = '/api/voice/dictionary-learning' as const; - -/** - * Max raw length (chars) accepted by `parseVoiceRefinerUserPayloadJson` before - * `JSON.parse` — a coarse OOM guard, mirroring the 64k cap that - * `parseVoiceRefineRequest` applies to `messages[1].content` (the carrier of - * this JSON). Field-level limits inside the payload stay the fine-grained - * bounds. - */ -export const VOICE_MAX_REFINER_PAYLOAD_CHARS = 64_000; - -export const VOICE_CLIENT_KINDS = ['desktop', 'mobile'] as const; -export type VoiceClientKind = (typeof VOICE_CLIENT_KINDS)[number]; - -export const VOICE_PROTOCOL_PROFILES = [ - 'volcengine-sauc-duration', - 'qwen-asr-server-vad', - 'openai-transcription-manual', -] as const; -export type VoiceProtocolProfile = (typeof VOICE_PROTOCOL_PROFILES)[number]; - -export const VOICE_REFINER_SCHEMA_NAMES = [ - 'dictation_refinement', - 'dictation_dictionary_learning', -] as const; -export type VoiceRefinerSchemaName = (typeof VOICE_REFINER_SCHEMA_NAMES)[number]; - -/** - * Who owns the refiner system prompt for this session. - * - * - `client`: the client sends its own bundled prompt as `messages[0]`. This is - * the historical behaviour and stays the default when the field is absent, so - * a client talking to a server that predates this field keeps working. - * - `server`: the client omits the system message entirely and voice-server - * injects its own prompt (selected by `schemaName`). Lets the managed prompt - * be iterated without shipping a client release. - * - * Only meaningful for managed refinement: a BYOK client dials the upstream - * directly and must always carry its own prompt. - */ -export const VOICE_PROMPT_OWNERS = ['client', 'server'] as const; -export type VoicePromptOwner = (typeof VOICE_PROMPT_OWNERS)[number]; - -/** Authenticated control-plane request that allocates one ASR session. */ -export interface CreateVoiceSessionRequest { - protocolVersion?: typeof VOICE_PROTOCOL_VERSION; - mode?: 'dictation'; - language?: string; - client: VoiceClientKind; - clientVersion?: string; - asrProvider: string; - refinerProvider?: string; -} - -/** ASR data-plane target authorized by a short-lived, single-use ticket. */ -export interface VoiceAsrTarget { - provider: string; - websocketUrl: string; - protocolProfile: VoiceProtocolProfile; - sampleRate: number; - model?: string; - resourceId?: string; -} - -export type VoiceRefinerTarget = - | { enabled: false; provider?: never; promptOwner?: never } - | { enabled: true; provider: string; promptOwner?: VoicePromptOwner }; - -/** Successful `POST /api/voice/sessions` response. */ -export interface CreateVoiceSessionResponse { - protocolVersion?: typeof VOICE_PROTOCOL_VERSION; - sessionId: string; - ticket: string; - expiresAt: string; - asr: VoiceAsrTarget; - refiner: VoiceRefinerTarget; -} - -export interface VoiceRefinementContext { - uiLanguage?: string; - sourceLanguage?: string; - userRefinementInstructions?: string; - userDictionary?: string; - voiceInputHistory?: string; - selectionBefore?: string; - selectedText?: string; - selectionAfter?: string; -} - -export interface VoiceDictationRefinementInput { - /** - * Version tag of the *client-owned* prompt this payload was built for; part - * of the prompt-cache key. Absent under `promptOwner: 'server'`, where the - * server owns both the prompt and its version. - */ - promptVersion?: string; - context: VoiceRefinementContext; - dictationText: string; - replyToMessage?: string; - userDictionaryMatches?: string; -} - -export interface VoiceDictionaryAlias { - text: string; - count?: number; -} - -export interface VoiceDictionaryTermState { - term: string; - aliases?: VoiceDictionaryAlias[]; -} - -export interface VoiceDictionaryEntryState extends VoiceDictionaryTermState { - source?: 'manual' | 'automatic'; - frequency?: number; -} - -export interface VoiceDictionaryCandidateState extends VoiceDictionaryTermState { - evidenceCount?: number; -} - -export interface VoiceDictionaryLearningContext { - uiLanguage?: string; - sourceLanguage?: string; - activeApp?: string; - selectionBefore?: string; - selectedText?: string; - selectionAfter?: string; -} - -export interface VoiceDictionaryLearningInput { - /** See {@link VoiceDictationRefinementInput.promptVersion}. */ - promptVersion?: string; - debug?: boolean; - source?: 'in_app' | 'external_overlay'; - rawTranscriptText?: string; - beforeText: string; - afterText: string; - context?: VoiceDictionaryLearningContext; - existingEntries?: VoiceDictionaryEntryState[]; - existingCandidates?: VoiceDictionaryCandidateState[]; -} - -/** JSON encoded in the user message of a refinement request. */ -export type VoiceRefinerUserPayload = - | { schemaName: 'dictation_refinement'; input: VoiceDictationRefinementInput } - | { schemaName: 'dictation_dictionary_learning'; input: VoiceDictionaryLearningInput }; - -export interface VoiceRefineSystemMessage { - role: 'system'; - content: string; -} - -export interface VoiceRefineUserMessage { - role: 'user'; - content: string; -} - -export interface VoiceRefineRequest { - /** - * Routes warmup and the real request to the same upstream cache shard. Under - * `promptOwner: 'server'` the client cannot derive it (it never sees the - * prompt), so it omits the field and the server generates its own. - */ - prompt_cache_key?: string; - /** - * `[system, user]` under client-owned prompts; `[user]` alone under - * server-owned prompts, where voice-server supplies the system message. - */ - messages: [VoiceRefineSystemMessage, VoiceRefineUserMessage] | [VoiceRefineUserMessage]; -} - -/** - * Which endpoint a refine-shaped request arrived on. The envelope alone cannot - * express this, but the two routes have different contracts, so the parser - * needs it to reject cross-route payloads: - * - * - `refine` (`/api/voice/sessions/:id/refine`): both prompt owners are legal - * and both schemas are accepted. - * - `dictionary_learning` (`/api/voice/dictionary-learning`): server-owned only, - * and the payload must be `dictation_dictionary_learning`. Without this the - * session-less route would accept a caller-supplied system prompt. - */ -export const VOICE_REFINE_ROUTES = ['refine', 'dictionary_learning'] as const; -export type VoiceRefineRoute = (typeof VOICE_REFINE_ROUTES)[number]; - -/** Cross-validated envelope + payload, with the prompt owner it implies. */ -export interface VoiceRefineRequestWithPayload { - request: VoiceRefineRequest; - payload: VoiceRefinerUserPayload; - /** Derived from the envelope: a system message means the client owns it. */ - promptOwner: VoicePromptOwner; -} - -/** Shared HTTP error envelope used by voice-server. */ -export interface VoiceErrorResponse { - error: { - code: string; - message: string; - }; -} - -export type VoiceParseResult = { ok: true; value: T } | { ok: false; error: string }; diff --git a/packages/voice-protocol/tsconfig.json b/packages/voice-protocol/tsconfig.json deleted file mode 100644 index 7556c8a..0000000 --- a/packages/voice-protocol/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "target": "ESNext", - "module": "ESNext", - "moduleResolution": "bundler", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "noEmit": true, - "sourceMap": true, - "baseUrl": "." - }, - "include": ["src/**/*"] -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f16bfd..9f8d007 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,15 +45,6 @@ importers: specifier: ^3.2.4 version: 3.2.7(@types/node@26.1.1) - packages/skill-protocol: - devDependencies: - typescript: - specifier: ^5.7.0 - version: 5.9.3 - vitest: - specifier: ^3.2.4 - version: 3.2.7(@types/node@26.1.1) - packages/slack-hook-protocol: devDependencies: '@types/node': @@ -66,15 +57,6 @@ importers: specifier: ^3.2.4 version: 3.2.7(@types/node@26.1.1) - packages/voice-protocol: - devDependencies: - typescript: - specifier: ^5.7.0 - version: 5.9.3 - vitest: - specifier: ^3.2.4 - version: 3.2.7(@types/node@26.1.1) - packages: '@esbuild/aix-ppc64@0.28.1':