diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..e4ddde2 --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,36 @@ +# Zero-content privacy contract + +Plugin Lab v2 的隐私目标不是“先收集再脱敏”,而是让私密内容在能力层面无法进入反馈数据流。 + +## 不变量 + +1. **内容非干涉**:在插件、健康状态、用户选择和单次事件 ID 相同时,替换任意 Prompt、回复、日志、异常、文件、路径或 Secret,序列化反馈字节必须保持不变。 +2. **有限字母表**:网络包只允许协议中列出的标量和公开插件坐标;未知字段直接拒绝。 +3. **宿主真源**:探活只读取 DSH Host 的 Loader/Fiber 状态,不调用目标插件,也不进行文件、网络或数据库 I/O。 +4. **Agent 零内容**:Agent 工具输入为空,输出只含 `health`、固定 `experience=unknown` 和确认布尔值。 +5. **主观状态不臆测**:只有用户点击产生 `good/mixed/bad`;健康状态不能自动转换为体验评价。 +6. **本地与上传解耦**:探活和确认体验都不产生网络请求;`/omdsh-join` 是独立同意动作。 +7. **无稳定身份**:没有用户、账号、设备、安装、Session 或跨报告匿名 ID。 +8. **无日志派生**:不接受日志摘要、错误类型、错误码、堆栈、指纹或哈希。 +9. **固定渲染**:Agent 状态和反馈字段使用有限枚举;服务端错误不回显原始输入。 +10. **聚合后通知**:GitHub 只接收达到阈值后的聚合枚举,不接收单条报告。 + +## 必须通过的攻击测试 + +- 把邮箱、Token、绝对路径、聊天文本分别放入会话和异常;反馈包不得变化或出现 canary。 +- 目标插件处于同一 Fiber 状态时,替换所有私密内容,反馈序列化结果必须一致。 +- 向 Agent 工具加入 `reason/log/prompt` 参数必须被拒绝。 +- 向上传包加入 `note/log/stack/signals/environment/participantId/occurredAt` 必须拒绝整个请求。 +- `verbose/debug/include=logs` 不能改变探活结果。 +- 未配置 Loader、状态缺失或序列化失败只能得到 `unknown`,不能输出异常详情。 +- 用户只点击探活或体验选择时,抓包必须没有反馈请求。 +- 请求体超过 1 KiB 必须在 JSON 解析前拒绝。 +- 服务端 4xx/5xx 响应不得包含 canary、异常、路径或请求正文。 +- 未达到聚合阈值时不能创建 GitHub Issue。 + +## 明确限制 + +- `health=ok` 只表示宿主认为插件可运行,不代表用户满意,也不证明业务结果正确。 +- 没有稳定身份后,聚合只能统计报告数,不能准确统计独立用户。 +- IP、连接时间等传输元数据仍会被网络基础设施瞬时观察;部署者必须通过日志配置和保留策略降低风险,不能据此宣称绝对匿名。 +- 旧版本可能已经创建本地崩溃或安装身份文件。v2 不读取、不补传,也不自动删除这些历史文件。 diff --git a/README.md b/README.md index d3f83ed..1c69f95 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,32 @@ # Oh My DSH Plugin Lab -Plugin Lab 是 DeepSeek Harness 的插件试用与体验反馈闭环。它不要求用户“上传评价”,而是在一次真实试用之后帮助用户: +Plugin Lab 0.3 是 DeepSeek Harness rc.6 的零内容插件探活与体验反馈闭环。 -- 记录这次插件是否把事情做成; -- 查找相似问题并获得问题回执; -- 在修复版本发布时收到复测邀请; -- 用原任务复测,让市场证据随版本更新。 +它把两件事严格分开: -默认只保存在本机。只有用户点击“查找相似问题 / 加入并等待修复”,或明确运行 `/omdsh-join`,才会发送不含会话内容的结构化事件。 +- DSH Host 可以判断插件当前是否正常运行; +- “好用 / 一般 / 不好用”只能由用户确认,Agent 不得读取会话或日志代替用户推断。 -## 用户体验 +## 体验流程 -1. 用户用 `/omdsh-start # [task-id]` 开始一次单插件 Trial。 -2. Agent 完成后,最新回复旁出现“体验结果”。 -3. 用户选择“做成了 / 做了一部分 / 没做成”。这一步只写入本机。 -4. 结果卡提供与用户目标对应的动作: - - 做成了:`贡献匿名实测` - - 做了一部分:`查找相似问题` - - 没做成:`加入并等待修复` -5. 分享后立即返回问题回执、相似报告数和当前状态。 -6. Composer 左侧的“反馈进展”或 `/omdsh-inbox` 显示确认、Issue、修复版本与复测邀请。 -7. `/omdsh-retest` 把复测结果关联回原回执;成功复测会把原问题标记为 `verified`。 +1. `/omdsh-start #` 选择本次试用的公开插件。 +2. 用户随时点击 Composer 左侧的“插件探活”,本地查看 `OK / 暂不可用 / 异常 / 未知`。 +3. Agent 可以调用零参数工具 `omdsh_analyze_plugin_experience`,但只会得到运行状态和 `experience=unknown`。 +4. 最新回复旁出现体验卡,由用户选择“好用 / 一般 / 不好用”。这一步只保存在本机。 +5. 用户再次点击加入跟进或运行 `/omdsh-join latest`,才会发送屏幕上已经说明的有限字段。 +6. 后端按插件、版本和状态聚合;达到阈值后创建 GitHub Issue,并通过回执邀请用户复测。 ```mermaid flowchart LR - A["按任务选择插件"] --> B["隔离 Trial"] - B --> C["Agent 产生结果"] - C --> D["结果先存本机"] - D -->|"用户明确加入"| E["匿名结构化事件"] - E --> F["后端聚类与问题回执"] - F -->|"达到阈值"| G["聚合 GitHub Issue"] - G --> H["修复版本发布"] - H --> I["通知原用户复测"] - I --> J["市场实测证据更新"] - J --> A + A["DSH Host Loader/Fiber"] --> B["本地状态枚举"] + B --> C["一键探活"] + B --> D["Agent 零参数工具"] + D --> E["experience = unknown"] + E --> F["用户确认好用 / 一般 / 不好用"] + F --> G["单独同意发送"] + G --> H["后端有限枚举聚合"] + H -->|"达到阈值"| I["GitHub 聚合 Issue"] + I --> J["修复与复测回执"] ``` ## 安装开发版本 @@ -41,151 +34,125 @@ flowchart LR ```sh pnpm install pnpm pack:release -dsh plugin --profile web add ./oh-my-dsh-plugin-lab-0.2.2.tgz +dsh plugin --profile web add ./oh-my-dsh-plugin-lab-0.3.0.tgz dsh --profile web ``` -Plugin Lab 是一个标准 DSH Bundle:`package.json` 通过 `dsh.bundle.patch` 声明 `cordis.patch.yml`,同一个包还通过 `dsh.client` 提供 Web 结果卡和收件箱入口。 +Plugin Lab 是标准 DSH Bundle:`package.json` 通过 `dsh.bundle.patch` 声明 Host 插件,通过 `dsh.client` 提供 Web 探活、结果卡和收件箱。 -`0.2.2` 的 DSH Peer 契约从 `0.1.0-rc.6` 起,已完成全新 Profile 安装、Host 启动、Web Client Loader 注册、Slot 生命周期、组件交互和真实浏览器加载验证。 +版本 `0.3.0` 的 Peer 契约从 DSH `0.1.0-rc.6` 起。完整测试会执行真实的 rc.6 打包、安装、Host/Web 启动、Client Loader 注册和卸载。 -## 主要命令 +## 命令与 Agent 工具 -| 命令 | 作用 | +| 接口 | 作用 | |---|---| -| `/omdsh-start [#version] [task-id]` | 开始一次单目标插件 Trial | -| `/omdsh-result [note]` | 只在本机记录结果 | -| `/omdsh-join [--share-note]` | 明确加入匿名问题跟进 | -| `/omdsh-inbox [--peek]` | 拉取并查看新的处理进展 | -| `/omdsh-retest [#version] [task-id]` | 从回执启动修复复测 | -| `/omdsh-status` | 查看当前 Trial、待发送记录和未读进展 | -| `/omdsh-privacy` | 查看精确数据边界与本地路径 | -| `/omdsh-feedback ...` | 兼容旧版的一步式入口 | +| `/omdsh-start [#version]` | 开始单插件试用;不接受任务标签或备注 | +| `/omdsh-probe` | 本地读取当前目标的 Host 生命周期状态 | +| `/omdsh-result ` | 用户确认体验,只保存本机 | +| `/omdsh-join ` | 单独同意发送有限字段 | +| `/omdsh-inbox [--peek]` | 查看聚合问题、修复版本与复测邀请 | +| `/omdsh-retest [#version]` | 从单条回执开始复测 | +| `/omdsh-status` | 查看本地试用、待发送和未读状态 | +| `/omdsh-privacy` | 显示完整隐私边界 | +| `omdsh_analyze_plugin_experience({})` | Agent 查询运行状态;输入必须为空 | + +## 精确数据边界 + +唯一允许上传的数据包为: + +```json +{ + "schemaVersion": 2, + "type": "feedback.signal", + "eventId": "随机单次 UUID", + "plugin": { + "moduleName": "marketplace-public-id", + "version": "1.2.3" + }, + "health": "ok", + "experience": "good", + "source": "user_confirmed" +} +``` + +复测时可以额外出现一个随机、单报告范围的 `retestOfReceiptId`。客户端和服务端都拒绝任何其他字段,而不是接收后脱敏。 + +不会创建、读取或发送: -## 数据边界 +- stdout、stderr、访问日志、应用日志; +- exception、错误码、stack、frame、崩溃指纹; +- Prompt、Assistant 回复、Agent memory、Tool 参数和结果; +- 文件、代码、路径、URL、环境变量、配置; +- 用户、账号、设备、安装、Session 等稳定标识; +- 客户端时间、locale、OS、架构、DSH/Node 版本、任务标签、计数和时延; +- 备注、理由或任何自由文本反馈。 -本地真源位于: +本地 v2 文件只有: ```text $DSH_HOME/omdsh-plugin-lab/ - .install-id - crashes.ndjson - events.ndjson - share-requests.ndjson - receipts.ndjson - receipt-seen.ndjson + feedback-v2.ndjson + share-requests-v2.ndjson + receipts-v2.ndjson + receipt-seen-v2.ndjson ``` -目录权限为 `0700`,文件权限为 `0600`。插件不会收集或发送: +目录权限为 `0700`,文件权限为 `0600`。升级不会读取或补传旧版 `.install-id`、`events.ndjson` 或 `crashes.ndjson`;历史文件不会被自动删除。 -- Prompt 或 Assistant 回复正文; -- Tool 参数与结果; -- 工作目录或 DSH Session ID; -- 文件内容。 +网络传输天然会让服务器或中间层观察 IP、时间等元数据,因此项目只承诺“载荷零内容、无身份字段”,不宣称绝对匿名。生产部署必须关闭代理、网关、WAF、应用和数据库的请求体日志,并不得把 IP/User-Agent 写入业务数据。 -匿名事件只包含插件与版本、DSH/Node/系统版本、任务标签、Loader 状态、计数与时序、用户选择的结果。文字备注默认只留本地,只有 `--share-note` 才发送;服务端共享备注最长保留 30 天,并在后续写入时清理过期值。 - -进行中的 Trial 遇到未捕获异常时,会先把脱敏崩溃信号同步写入 `crashes.ndjson`,以便 DSH 重启后恢复。它只含错误类型、受限错误码、归一化首帧与稳定指纹,不含原始错误信息、完整堆栈、函数名或绝对路径。监听使用 Node 的 `uncaughtExceptionMonitor`,不会捕获异常、阻止退出或改变程序原有崩溃行为;这些信号仍只有在用户明确加入跟进后才会上传。 +更完整的可验证不变量和攻击测试见 [PRIVACY.md](./PRIVACY.md)。 ## 启用中央反馈 -Bundle 默认关闭上传。部署者必须配置接收地址: +Bundle 默认关闭网络发送: ```yaml - id: omdsh-plugin-lab config: - allowAnonymousShare: true + allowShare: true ingestUrl: https://feedback.example.com/v1/experience-events authorizationEnv: OMDSH_PLUGIN_LAB_TOKEN - profileLabel: plugin-lab requestTimeoutMs: 5000 retryIntervalMs: 30000 ``` -即使部署允许分享,每一次 Trial 仍要由用户点击加入或运行 `/omdsh-join`。后来开启配置不会补传历史本地反馈。 +部署开启发送能力不等于用户同意。每条记录仍要由用户单独运行 `/omdsh-join`;拒绝发送不影响插件功能。 -## 后端 +## 后端与 GitHub 飞轮 -`server/` 是可自托管的 Node.js + PostgreSQL 接收器,提供: +`server/` 提供 Node.js + PostgreSQL 接收器: -- 事件幂等写入; -- 匿名安装 ID 的 HMAC 哈希; -- 按插件、版本、DSH 版本、任务和症状聚类; -- 独立安装数统计; -- 带 Follow Token 的问题回执; -- 修复发布和复测状态更新; -- 最近 30 天市场实测证据; -- 达到阈值后创建去内容化的 GitHub 聚合 Issue。 +- 只接受 schema v2,未知字段 fail closed; +- 请求体上限 1 KiB,错误响应不回显输入或异常; +- 不存 IP、User-Agent、原始请求体或客户端时间; +- 不使用用户、安装或稳定匿名 ID,因此统计口径是“报告数”,不是“独立用户数”; +- 按公开插件、版本、health 和 experience 聚合; +- 默认同类报告达到 5 条后才创建 GitHub 聚合 Issue; +- GitHub 不接收单条反馈或回执 ID; +- Follow Token 只关联一条报告,用于返回修复与复测状态。 -### 本机启动 - -```sh -docker compose up -d postgres -cp server/.env.example .env -set -a && source .env && set +a -pnpm server:build -pnpm server:migrate -pnpm server:start -``` - -生产环境至少需要: +生产环境需要: ```text DATABASE_URL PUBLIC_BASE_URL -PRIVACY_HASH_SECRET FOLLOW_SECRET ADMIN_TOKEN ``` -可选的 GitHub 聚合: +可选 GitHub 配置: ```text GITHUB_TOKEN=<具有目标仓库 Issues 写权限的 token> GITHUB_REPOSITORY=omdsh-dev/omdsh-plugin-lab -GITHUB_REPORT_THRESHOLD=3 -``` - -默认同一聚类达到 3 个独立匿名安装后才创建 Issue。GitHub 中不会出现“一条反馈一个 Issue”。Issue 只含聚合字段,不含匿名 ID、备注或会话内容。 - -### API - -| Method | Path | 用途 | -|---|---|---| -| `POST` | `/v1/experience-events` | 接收一次显式分享 | -| `GET` | `/v1/receipts/:id` | 使用 `X-OMDSH-Follow-Token` 查询进展 | -| `POST` | `/v1/admin/clusters/:id/release` | 发布修复版本并邀请复测 | -| `GET` | `/v1/plugins/:module/evidence` | 返回最近 30 天市场证据 | -| `GET` | `/healthz` | 健康检查 | - -管理员发布修复: - -```sh -curl -X POST https://feedback.example.com/v1/admin/clusters//release \ - -H "Authorization: Bearer $ADMIN_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"recommendedVersion":"0.3.2","message":"修复已发布,请用原任务复测。","trackingUrl":"https://github.com/org/repo/issues/42"}' +GITHUB_REPORT_THRESHOLD=5 ``` -## 状态机 - -```text -received → clustered → reported → retest-requested → verified → closed - ↘ confirmed ← 复测仍失败 -``` - -插件会在后台重试待发送记录和刷新已有回执。Follow Token 只保存在本机私有文件中,服务端不需要账号、邮箱或原始匿名安装 ID 即可把修复状态送回最初参与者。 - ## 验证 ```sh pnpm test:all ``` -验证包含 Host 命令与 Session 集成、崩溃同步日志及重启恢复、两阶段分享、隐私投影、本地 Outbox、回执刷新、Client 结果卡真实点击、rc.6 Slot 生命周期、聚类/发布/复测领域逻辑、服务端类型检查,以及真实的 `dsh plugin add`、rc.6 Client Loader 产物执行、Profile 启动和移除生命周期。 - -## 当前边界 - -- Plugin Lab 负责试用与证据闭环,不自动判断任意 Tool 属于哪个第三方插件;一次 Trial 的目标插件是归因真源。 -- 市场页面尚未包含在本仓库;`/v1/plugins/:module/evidence` 已提供其数据接口。 -- “反馈进展”目前是 DSH 内拉取式收件箱,不要求邮箱或账号;邮件、Slack 等外部通知应由部署方在后端事件上另接通道。 +验证覆盖:闭合输入/输出 Schema、Loader 状态映射、Agent 零参数工具、内容非干涉金丝雀、无崩溃监听器、两阶段同意、Client 真实点击、后端未知字段拒绝、PostgreSQL v2 列审计、聚合阈值、回执/复测,以及真实 DSH rc.6 安装与启动生命周期。 diff --git a/cordis.patch.yml b/cordis.patch.yml index 6072dac..fbb5941 100644 --- a/cordis.patch.yml +++ b/cordis.patch.yml @@ -1,11 +1,10 @@ # Oh My DSH Plugin Lab is local-only until a deployment explicitly enables -# anonymous sharing and configures an HTTPS ingest endpoint. +# closed-packet sharing and configures an HTTPS ingest endpoint. - insert: - id: omdsh-plugin-lab name: '@oh-my-dsh/plugin-lab' config: - allowAnonymousShare: false + allowShare: false authorizationEnv: OMDSH_PLUGIN_LAB_TOKEN - profileLabel: default requestTimeoutMs: 5000 retryIntervalMs: 30000 diff --git a/dist/agent-tool.d.ts b/dist/agent-tool.d.ts new file mode 100644 index 0000000..b1e7c8f --- /dev/null +++ b/dist/agent-tool.d.ts @@ -0,0 +1,6 @@ +import type { Agent } from '@deepseek-ai/dsh-agent'; +import type { ToolDefinition } from '@deepseek-ai/dsh-tools'; +import type { SafeExperienceAssessment } from './protocol.js'; +export declare const AGENT_ASSESSMENT_TOOL = "omdsh_analyze_plugin_experience"; +/** A raw ToolDefinition is used so the rc.6 input schema is closed as well as the output. */ +export declare function createAgentAssessmentTool(assess: (agent: Agent | undefined) => SafeExperienceAssessment): ToolDefinition; diff --git a/dist/agent-tool.js b/dist/agent-tool.js new file mode 100644 index 0000000..1c904e2 --- /dev/null +++ b/dist/agent-tool.js @@ -0,0 +1,44 @@ +export const AGENT_ASSESSMENT_TOOL = 'omdsh_analyze_plugin_experience'; +const PARAMETERS = { + type: 'object', + properties: {}, + required: [], + additionalProperties: false, +}; +const OUTPUT = { + type: 'object', + properties: { + health: { type: 'string', enum: ['ok', 'unavailable', 'error', 'unknown'] }, + experience: { type: 'string', const: 'unknown' }, + userConfirmationRequired: { type: 'boolean', const: true }, + }, + required: ['health', 'experience', 'userConfirmationRequired'], + additionalProperties: false, +}; +function noArguments(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value) + && Object.keys(value).length === 0; +} +/** A raw ToolDefinition is used so the rc.6 input schema is closed as well as the output. */ +export function createAgentAssessmentTool(assess) { + return { + name: AGENT_ASSESSMENT_TOOL, + description: [ + 'Read only the current Plugin Lab trial lifecycle status from the DSH Host.', + 'Call with an empty object. Never infer subjective quality from conversation content.', + 'When experience is unknown, ask the user to confirm good, mixed, or bad.', + ].join(' '), + parameters: PARAMETERS, + output: { + schema: OUTPUT, + render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }], + }, + async execute(args, exec) { + if (!noArguments(args)) + throw new TypeError('experience assessment accepts no arguments'); + return assess(exec.agent); + }, + isConcurrencySafe: () => true, + }; +} +//# sourceMappingURL=agent-tool.js.map \ No newline at end of file diff --git a/dist/agent-tool.js.map b/dist/agent-tool.js.map new file mode 100644 index 0000000..4f82d1e --- /dev/null +++ b/dist/agent-tool.js.map @@ -0,0 +1 @@ +{"version":3,"file":"agent-tool.js","sourceRoot":"","sources":["../src/agent-tool.ts"],"names":[],"mappings":"AAIA,MAAM,CAAC,MAAM,qBAAqB,GAAG,iCAAiC,CAAA;AAEtE,MAAM,UAAU,GAAG;IACjB,IAAI,EAAE,QAAQ;IACd,UAAU,EAAE,EAAE;IACd,QAAQ,EAAE,EAAE;IACZ,oBAAoB,EAAE,KAAK;CAC5B,CAAA;AAED,MAAM,MAAM,GAAmB;IAC7B,IAAI,EAAE,QAAQ;IACd,UAAU,EAAE;QACV,MAAM,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,IAAI,EAAE,aAAa,EAAE,OAAO,EAAE,SAAS,CAAC,EAAE;QAC3E,UAAU,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,KAAK,EAAE,SAAS,EAAE;QAChD,wBAAwB,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,IAAI,EAAE;KAC3D;IACD,QAAQ,EAAE,CAAC,QAAQ,EAAE,YAAY,EAAE,0BAA0B,CAAC;IAC9D,oBAAoB,EAAE,KAAK;CAC5B,CAAA;AAED,SAAS,WAAW,CAAC,KAAc;IACjC,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;WACtE,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,MAAM,KAAK,CAAC,CAAA;AACtC,CAAC;AAED,6FAA6F;AAC7F,MAAM,UAAU,yBAAyB,CACvC,MAA8D;IAE9D,OAAO;QACL,IAAI,EAAE,qBAAqB;QAC3B,WAAW,EAAE;YACX,4EAA4E;YAC5E,sFAAsF;YACtF,0EAA0E;SAC3E,CAAC,IAAI,CAAC,GAAG,CAAC;QACX,UAAU,EAAE,UAAU;QACtB,MAAM,EAAE;YACN,MAAM,EAAE,MAAM;YACd,MAAM,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,EAAE,CAAC;SAC1E;QACD,KAAK,CAAC,OAAO,CAAC,IAAI,EAAE,IAAI;YACtB,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC;gBAAE,MAAM,IAAI,SAAS,CAAC,4CAA4C,CAAC,CAAA;YACzF,OAAO,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;QAC3B,CAAC;QACD,iBAAiB,EAAE,GAAG,EAAE,CAAC,IAAI;KAC9B,CAAA;AACH,CAAC"} \ No newline at end of file diff --git a/dist/client.js b/dist/client.js index 6cc9aed..36f0138 100644 --- a/dist/client.js +++ b/dist/client.js @@ -54,22 +54,22 @@ var LabController = class { active }); } - async record(messageId, outcome) { + async record(messageId, verdict) { this.publish({ ...this.view, pending: { messageId, - outcome, + verdict, phase: "saving" } }); - const settled = await this.execute(`/omdsh-result ${outcome}`); + const settled = await this.execute(`/omdsh-result ${verdict}`); if (settled.ok) this.publish({ ...this.view, active: false, pending: { messageId, - outcome, + verdict, phase: "local", text: settled.text } @@ -78,7 +78,7 @@ var LabController = class { ...this.view, pending: { messageId, - outcome, + verdict, phase: "error", text: settled.text } @@ -107,6 +107,9 @@ var LabController = class { async inbox() { return (await this.execute("/omdsh-inbox")).text; } + async probe() { + return (await this.execute("/omdsh-probe")).text; + } dismiss() { const { pending: _pending,...view } = this.view; this.publish(view); @@ -170,9 +173,9 @@ const choiceStyle = { color: "inherit", cursor: "pointer" }; -function shareLabel(outcome) { - if (outcome === "worked") return "贡献匿名实测"; - if (outcome === "partial") return "查找相似问题"; +function shareLabel(verdict) { + if (verdict === "good") return "贡献聚合实测"; + if (verdict === "mixed") return "查找相似反馈"; return "加入并等待修复"; } /** Latest durable assistant identity from the rc.6 conversation projection. */ @@ -214,7 +217,15 @@ function ExperienceResultCard({ messageId, useSession, usePluginLab, record, joi display: "block", marginBottom: 9 }, - children: "这次插件把事情做成了吗?" + children: "你觉得这个插件好用吗?" + }), + /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { + style: { + display: "block", + marginBottom: 9, + color: "var(--dsw-alias-label-secondary)" + }, + children: "Agent 只知道运行状态,不会读取会话或日志替你判断。" }), pending === void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { style: { @@ -227,25 +238,25 @@ function ExperienceResultCard({ messageId, useSession, usePluginLab, record, joi type: "button", style: choiceStyle, onClick: () => { - record(messageId, "worked"); + record(messageId, "good"); }, - children: "做成了" + children: "好用" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", { type: "button", style: choiceStyle, onClick: () => { - record(messageId, "partial"); + record(messageId, "mixed"); }, - children: "做了一部分" + children: "一般" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", { type: "button", style: choiceStyle, onClick: () => { - record(messageId, "failed"); + record(messageId, "bad"); }, - children: "没做成" + children: "不好用" }) ] }), @@ -271,7 +282,7 @@ function ExperienceResultCard({ messageId, useSession, usePluginLab, record, joi onClick: () => { join(); }, - children: shareLabel(pending.outcome) + children: shareLabel(pending.verdict) }), (pending.phase === "joined" || pending.phase === "error") && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", { type: "button", @@ -290,7 +301,7 @@ function ExperienceResultCard({ messageId, useSession, usePluginLab, record, joi marginTop: 10, color: "var(--dsw-alias-label-tertiary)" }, - children: "第一步只保存在本机;加入跟进仅发送插件、版本、结果与无内容运行指标,不发送对话正文。" + children: "第一步只保存在本机;加入跟进只发送插件、版本、状态枚举和你的选择。网络传输并非绝对匿名。" }) ] })] @@ -299,7 +310,7 @@ function ExperienceResultCard({ messageId, useSession, usePluginLab, record, joi //#endregion //#region src/client/InboxButton.tsx -const button = { +const button$1 = { height: 28, padding: "0 9px", border: "none", @@ -319,7 +330,7 @@ function InboxButton({ checkInbox }) { }, children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", { type: "button", - style: button, + style: button$1, disabled: busy, onClick: () => { if (text !== null) return setText(null); @@ -351,6 +362,60 @@ function InboxButton({ checkInbox }) { }); } +//#endregion +//#region src/client/ProbeButton.tsx +const button = { + height: 28, + padding: "0 9px", + border: "none", + borderRadius: 14, + cursor: "pointer", + background: "transparent", + color: "var(--dsw-alias-label-tertiary)", + fontSize: 12 +}; +function ProbeButton({ checkHealth }) { + const [text, setText] = (0, react.useState)(null); + const [busy, setBusy] = (0, react.useState)(false); + return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { + style: { + position: "relative", + display: "inline-flex" + }, + children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", { + type: "button", + style: button, + disabled: busy, + onClick: () => { + if (text !== null) return setText(null); + setBusy(true); + checkHealth().then(setText).finally(() => { + setBusy(false); + }); + }, + children: busy ? "探活中…" : "插件探活" + }), text !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { + role: "status", + style: { + position: "absolute", + zIndex: 30, + left: 0, + bottom: 34, + width: 330, + padding: 12, + border: "1px solid var(--dsw-alias-border-secondary)", + borderRadius: 10, + background: "var(--dsw-alias-bg-primary)", + color: "var(--dsw-alias-label-secondary)", + boxShadow: "0 12px 36px rgba(0,0,0,.18)", + whiteSpace: "pre-wrap", + fontSize: 13 + }, + children: text + })] + }); +} + //#endregion //#region src/client/index.ts const inject = [ @@ -396,12 +461,19 @@ function apply(ctx) { order: 40, inject: (sessionId) => ({ checkInbox: () => controllerFor(sessionId).inbox() }) }, InboxButton)); + ctx.slots.inject("conversation.input.left", () => ctx.slots.register({ + name: "conversation.input.left", + id: "omdsh-plugin-lab-probe", + order: 39, + inject: (sessionId) => ({ checkHealth: () => controllerFor(sessionId).probe() }) + }, ProbeButton)); } //#endregion exports.ExperienceResultCard = ExperienceResultCard; exports.InboxButton = InboxButton; exports.LabController = LabController; +exports.ProbeButton = ProbeButton; exports.apply = apply; exports.inject = inject; return module.exports; } }); diff --git a/dist/client.js.map b/dist/client.js.map index c05008f..0f34a04 100644 --- a/dist/client.js.map +++ b/dist/client.js.map @@ -1 +1 @@ -{"version":3,"file":"client.js","names":["INITIAL_VIEW: LabView","remote: CommandsRemote","sessionId: SessionId","error: unknown","triggerStyle: CSSProperties","panelStyle: CSSProperties","choiceStyle: CSSProperties","button: CSSProperties"],"sources":["../src/client/controller.ts","../src/client/ExperienceResultCard.tsx","../src/client/InboxButton.tsx","../src/client/index.ts"],"sourcesContent":["import type { CommandResult } from '@deepseek-ai/dsh-commands/types'\nimport type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client'\nimport type { MessageId } from '@deepseek-ai/dsh-client-connection/client'\nimport type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'\nimport type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'\nimport type { TrialOutcome } from '../protocol.js'\n\n/** Exact rc.6 generated command Remote surface, kept narrow for testability. */\nexport type CommandsRemote = Pick\n\nexport interface PendingResult {\n readonly messageId: MessageId\n readonly outcome: TrialOutcome\n readonly phase: 'saving' | 'local' | 'joining' | 'joined' | 'error'\n readonly text?: string\n}\n\nexport interface LabView {\n readonly active: boolean\n readonly pending?: PendingResult\n}\n\nconst INITIAL_VIEW: LabView = Object.freeze({ active: false })\n\nfunction commandText(result: CommandResult | undefined): string {\n if (result === undefined) return '命令未被 DSH 接收。'\n return result.text ?? (result.kind === 'success' ? '完成。' : '操作失败。')\n}\n\nexport class LabController implements HostObservable {\n private view = INITIAL_VIEW\n private readonly listeners = new Set<() => void>()\n\n constructor(\n private readonly remote: CommandsRemote,\n private readonly sessionId: SessionId,\n ) {}\n\n getSnapshot = (): LabView => this.view\n\n subscribe = (listener: () => void): (() => void) => {\n this.listeners.add(listener)\n return () => { this.listeners.delete(listener) }\n }\n\n setTrialActive(active: boolean): void {\n this.publish({ ...this.view, active })\n }\n\n async record(messageId: MessageId, outcome: TrialOutcome): Promise {\n this.publish({ ...this.view, pending: { messageId, outcome, phase: 'saving' } })\n const settled = await this.execute(`/omdsh-result ${outcome}`)\n if (settled.ok) {\n this.publish({\n ...this.view,\n active: false,\n pending: { messageId, outcome, phase: 'local', text: settled.text },\n })\n } else {\n this.publish({ ...this.view, pending: { messageId, outcome, phase: 'error', text: settled.text } })\n }\n }\n\n async join(): Promise {\n const pending = this.view.pending\n if (pending === undefined || pending.phase !== 'local') return\n this.publish({ ...this.view, pending: { ...pending, phase: 'joining' } })\n const settled = await this.execute('/omdsh-join latest')\n this.publish({\n ...this.view,\n pending: {\n ...pending,\n phase: settled.ok ? 'joined' : 'error',\n text: settled.text,\n },\n })\n }\n\n async inbox(): Promise {\n return (await this.execute('/omdsh-inbox')).text\n }\n\n dismiss(): void {\n const { pending: _pending, ...view } = this.view\n this.publish(view)\n }\n\n private async execute(line: string): Promise<{ ok: boolean; text: string }> {\n try {\n const result = await this.remote.execute(this.sessionId, line)\n if (!result.ok) return { ok: false, text: `${result.error.message} (${result.error.code})` }\n const command = result.value?.result\n return { ok: command?.kind === 'success', text: commandText(command) }\n } catch (error: unknown) {\n return { ok: false, text: error instanceof Error ? error.message : String(error) }\n }\n }\n\n private publish(view: LabView): void {\n this.view = Object.freeze(view)\n for (const listener of [...this.listeners]) listener()\n }\n}\n","import { useState, type CSSProperties } from 'react'\nimport type { MessageId } from '@deepseek-ai/dsh-client-connection/client'\nimport type { ConversationNode } from '@deepseek-ai/dsh-client-runtime/client'\nimport type { InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'\nimport type { TrialOutcome } from '../protocol.js'\nimport type { LabController, LabView } from './controller.js'\n\nexport interface LabInjected {\n hooks: { pluginLab: LabController }\n record: (messageId: MessageId, outcome: TrialOutcome) => Promise\n join: () => Promise\n dismiss: () => void\n}\n\nexport type ExperienceResultCardProps =\n PropsRuntime<'conversation.chat.assistant-actions'>\n & InjectFace\n\nconst triggerStyle: CSSProperties = {\n border: 'none', borderRadius: 14, padding: '4px 9px', cursor: 'pointer',\n background: 'var(--dsw-alias-interactive-bg-hover)',\n color: 'var(--dsw-alias-label-secondary)', fontSize: 12,\n}\n\nconst panelStyle: CSSProperties = {\n position: 'absolute', zIndex: 30, right: 0, bottom: 34, width: 330,\n padding: 14, border: '1px solid var(--dsw-alias-border-secondary)', borderRadius: 12,\n background: 'var(--dsw-alias-bg-primary)', color: 'var(--dsw-alias-label-primary)',\n boxShadow: '0 12px 36px rgba(0,0,0,.18)', fontSize: 13, lineHeight: 1.5,\n}\n\nconst choiceStyle: CSSProperties = {\n border: '1px solid var(--dsw-alias-border-secondary)', borderRadius: 9,\n padding: '7px 9px', background: 'transparent', color: 'inherit', cursor: 'pointer',\n}\n\nfunction shareLabel(outcome: TrialOutcome): string {\n if (outcome === 'worked') return '贡献匿名实测'\n if (outcome === 'partial') return '查找相似问题'\n return '加入并等待修复'\n}\n\n/** Latest durable assistant identity from the rc.6 conversation projection. */\nexport function latestAssistantMessageId(nodes: readonly ConversationNode[]): MessageId | undefined {\n for (let index = nodes.length - 1; index >= 0; index -= 1) {\n const node = nodes[index]\n if (node?.kind === 'assistant' && node.messageId !== undefined) return node.messageId\n }\n return undefined\n}\n\nfunction visible(view: LabView, messageId: MessageId, latestMessageId: MessageId | undefined): boolean {\n return view.pending?.messageId === messageId\n || (view.active && latestMessageId === messageId)\n}\n\nexport function ExperienceResultCard({\n messageId, useSession, usePluginLab, record, join, dismiss,\n}: ExperienceResultCardProps) {\n const view = usePluginLab(value => value)\n const latestMessageId = useSession(snapshot => latestAssistantMessageId(snapshot.nodes))\n const [open, setOpen] = useState(false)\n if (!visible(view, messageId, latestMessageId)) return null\n const pending = view.pending?.messageId === messageId ? view.pending : undefined\n const busy = pending?.phase === 'saving' || pending?.phase === 'joining'\n return (\n \n \n {open && (\n \n 这次插件把事情做成了吗?\n {pending === undefined && (\n \n \n \n \n \n )}\n {pending !== undefined && (\n \n \n {pending.phase === 'saving' ? '正在只存到本机…' : pending.text}\n \n {pending.phase === 'local' && (\n \n )}\n {(pending.phase === 'joined' || pending.phase === 'error') && (\n \n )}\n \n )}\n \n 第一步只保存在本机;加入跟进仅发送插件、版本、结果与无内容运行指标,不发送对话正文。\n \n \n )}\n \n )\n}\n","import { useState, type CSSProperties } from 'react'\nimport type { InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'\n\nexport interface InboxInjected {\n checkInbox: () => Promise\n}\n\nexport type InboxButtonProps = PropsRuntime<'conversation.input.left'> & InjectFace\n\nconst button: CSSProperties = {\n height: 28, padding: '0 9px', border: 'none', borderRadius: 14, cursor: 'pointer',\n background: 'transparent', color: 'var(--dsw-alias-label-tertiary)', fontSize: 12,\n}\n\nexport function InboxButton({ checkInbox }: InboxButtonProps) {\n const [text, setText] = useState(null)\n const [busy, setBusy] = useState(false)\n return (\n \n {\n if (text !== null) return setText(null)\n setBusy(true)\n void checkInbox().then(setText).finally(() => { setBusy(false) })\n }}\n >\n {busy ? '检查中…' : '反馈进展'}\n \n {text !== null && (\n \n {text}\n \n )}\n \n )\n}\n","import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'\nimport type {} from '@deepseek-ai/dsh-api-remotes/client'\nimport type {} from '@deepseek-ai/dsh-client-ui-commands/client'\nimport type {} from '@deepseek-ai/dsh-client-ui-conversation/client'\nimport { LabController, type CommandsRemote } from './controller.js'\nimport { ExperienceResultCard, type LabInjected } from './ExperienceResultCard.js'\nimport { InboxButton, type InboxInjected } from './InboxButton.js'\n\nexport { ExperienceResultCard } from './ExperienceResultCard.js'\nexport { InboxButton } from './InboxButton.js'\nexport { LabController } from './controller.js'\n\nexport const inject = ['slots', 'remote', 'remote.commands']\n\nexport function apply(ctx: ClientContext): void {\n const controllers = new Map()\n ctx.effect(() => () => { controllers.clear() }, 'plugin-lab: client controller lifecycle')\n const controllerFor = (sessionId: SessionId): LabController => {\n let controller = controllers.get(sessionId)\n if (controller === undefined) {\n controller = new LabController(ctx.remote.commands as CommandsRemote, sessionId)\n controllers.set(sessionId, controller)\n }\n return controller\n }\n\n ctx.on('command/executed', (sessionId, name, result) => {\n if (result.kind !== 'success') return\n if (name === 'omdsh-start' || name === 'omdsh-retest') controllerFor(sessionId).setTrialActive(true)\n if (name === 'omdsh-result' || name === 'omdsh-feedback') controllerFor(sessionId).setTrialActive(false)\n })\n\n ctx.slots.inject('conversation.chat.assistant-actions', () => ctx.slots.register({\n name: 'conversation.chat.assistant-actions',\n id: 'omdsh-plugin-lab',\n order: 20,\n inject: (sessionId): LabInjected => {\n const controller = controllerFor(sessionId)\n return {\n hooks: { pluginLab: controller },\n record: (messageId, outcome) => controller.record(messageId, outcome),\n join: () => controller.join(),\n dismiss: () => controller.dismiss(),\n }\n },\n }, ExperienceResultCard))\n\n ctx.slots.inject('conversation.input.left', () => ctx.slots.register({\n name: 'conversation.input.left',\n id: 'omdsh-plugin-lab-inbox',\n order: 40,\n inject: (sessionId): InboxInjected => ({\n checkInbox: () => controllerFor(sessionId).inbox(),\n }),\n }, InboxButton))\n}\n"],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"client.js","names":["INITIAL_VIEW: LabView","remote: CommandsRemote","sessionId: SessionId","error: unknown","triggerStyle: CSSProperties","panelStyle: CSSProperties","choiceStyle: CSSProperties","button: CSSProperties","button","button: CSSProperties"],"sources":["../src/client/controller.ts","../src/client/ExperienceResultCard.tsx","../src/client/InboxButton.tsx","../src/client/ProbeButton.tsx","../src/client/index.ts"],"sourcesContent":["import type { CommandResult } from '@deepseek-ai/dsh-commands/types'\nimport type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client'\nimport type { MessageId } from '@deepseek-ai/dsh-client-connection/client'\nimport type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'\nimport type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'\nimport type { ExperienceVerdict } from '../protocol.js'\n\n/** Exact rc.6 generated command Remote surface, kept narrow for testability. */\nexport type CommandsRemote = Pick\n\nexport interface PendingResult {\n readonly messageId: MessageId\n readonly verdict: ExperienceVerdict\n readonly phase: 'saving' | 'local' | 'joining' | 'joined' | 'error'\n readonly text?: string\n}\n\nexport interface LabView {\n readonly active: boolean\n readonly pending?: PendingResult\n}\n\nconst INITIAL_VIEW: LabView = Object.freeze({ active: false })\n\nfunction commandText(result: CommandResult | undefined): string {\n if (result === undefined) return '命令未被 DSH 接收。'\n return result.text ?? (result.kind === 'success' ? '完成。' : '操作失败。')\n}\n\nexport class LabController implements HostObservable {\n private view = INITIAL_VIEW\n private readonly listeners = new Set<() => void>()\n\n constructor(\n private readonly remote: CommandsRemote,\n private readonly sessionId: SessionId,\n ) {}\n\n getSnapshot = (): LabView => this.view\n\n subscribe = (listener: () => void): (() => void) => {\n this.listeners.add(listener)\n return () => { this.listeners.delete(listener) }\n }\n\n setTrialActive(active: boolean): void {\n this.publish({ ...this.view, active })\n }\n\n async record(messageId: MessageId, verdict: ExperienceVerdict): Promise {\n this.publish({ ...this.view, pending: { messageId, verdict, phase: 'saving' } })\n const settled = await this.execute(`/omdsh-result ${verdict}`)\n if (settled.ok) {\n this.publish({\n ...this.view,\n active: false,\n pending: { messageId, verdict, phase: 'local', text: settled.text },\n })\n } else {\n this.publish({ ...this.view, pending: { messageId, verdict, phase: 'error', text: settled.text } })\n }\n }\n\n async join(): Promise {\n const pending = this.view.pending\n if (pending === undefined || pending.phase !== 'local') return\n this.publish({ ...this.view, pending: { ...pending, phase: 'joining' } })\n const settled = await this.execute('/omdsh-join latest')\n this.publish({\n ...this.view,\n pending: {\n ...pending,\n phase: settled.ok ? 'joined' : 'error',\n text: settled.text,\n },\n })\n }\n\n async inbox(): Promise {\n return (await this.execute('/omdsh-inbox')).text\n }\n\n async probe(): Promise {\n return (await this.execute('/omdsh-probe')).text\n }\n\n dismiss(): void {\n const { pending: _pending, ...view } = this.view\n this.publish(view)\n }\n\n private async execute(line: string): Promise<{ ok: boolean; text: string }> {\n try {\n const result = await this.remote.execute(this.sessionId, line)\n if (!result.ok) return { ok: false, text: `${result.error.message} (${result.error.code})` }\n const command = result.value?.result\n return { ok: command?.kind === 'success', text: commandText(command) }\n } catch (error: unknown) {\n return { ok: false, text: error instanceof Error ? error.message : String(error) }\n }\n }\n\n private publish(view: LabView): void {\n this.view = Object.freeze(view)\n for (const listener of [...this.listeners]) listener()\n }\n}\n","import { useState, type CSSProperties } from 'react'\nimport type { MessageId } from '@deepseek-ai/dsh-client-connection/client'\nimport type { ConversationNode } from '@deepseek-ai/dsh-client-runtime/client'\nimport type { InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'\nimport type { ExperienceVerdict } from '../protocol.js'\nimport type { LabController, LabView } from './controller.js'\n\nexport interface LabInjected {\n hooks: { pluginLab: LabController }\n record: (messageId: MessageId, verdict: ExperienceVerdict) => Promise\n join: () => Promise\n dismiss: () => void\n}\n\nexport type ExperienceResultCardProps =\n PropsRuntime<'conversation.chat.assistant-actions'>\n & InjectFace\n\nconst triggerStyle: CSSProperties = {\n border: 'none', borderRadius: 14, padding: '4px 9px', cursor: 'pointer',\n background: 'var(--dsw-alias-interactive-bg-hover)',\n color: 'var(--dsw-alias-label-secondary)', fontSize: 12,\n}\n\nconst panelStyle: CSSProperties = {\n position: 'absolute', zIndex: 30, right: 0, bottom: 34, width: 330,\n padding: 14, border: '1px solid var(--dsw-alias-border-secondary)', borderRadius: 12,\n background: 'var(--dsw-alias-bg-primary)', color: 'var(--dsw-alias-label-primary)',\n boxShadow: '0 12px 36px rgba(0,0,0,.18)', fontSize: 13, lineHeight: 1.5,\n}\n\nconst choiceStyle: CSSProperties = {\n border: '1px solid var(--dsw-alias-border-secondary)', borderRadius: 9,\n padding: '7px 9px', background: 'transparent', color: 'inherit', cursor: 'pointer',\n}\n\nfunction shareLabel(verdict: ExperienceVerdict): string {\n if (verdict === 'good') return '贡献聚合实测'\n if (verdict === 'mixed') return '查找相似反馈'\n return '加入并等待修复'\n}\n\n/** Latest durable assistant identity from the rc.6 conversation projection. */\nexport function latestAssistantMessageId(nodes: readonly ConversationNode[]): MessageId | undefined {\n for (let index = nodes.length - 1; index >= 0; index -= 1) {\n const node = nodes[index]\n if (node?.kind === 'assistant' && node.messageId !== undefined) return node.messageId\n }\n return undefined\n}\n\nfunction visible(view: LabView, messageId: MessageId, latestMessageId: MessageId | undefined): boolean {\n return view.pending?.messageId === messageId\n || (view.active && latestMessageId === messageId)\n}\n\nexport function ExperienceResultCard({\n messageId, useSession, usePluginLab, record, join, dismiss,\n}: ExperienceResultCardProps) {\n const view = usePluginLab(value => value)\n const latestMessageId = useSession(snapshot => latestAssistantMessageId(snapshot.nodes))\n const [open, setOpen] = useState(false)\n if (!visible(view, messageId, latestMessageId)) return null\n const pending = view.pending?.messageId === messageId ? view.pending : undefined\n const busy = pending?.phase === 'saving' || pending?.phase === 'joining'\n return (\n \n \n {open && (\n \n 你觉得这个插件好用吗?\n \n Agent 只知道运行状态,不会读取会话或日志替你判断。\n \n {pending === undefined && (\n \n \n \n \n \n )}\n {pending !== undefined && (\n \n \n {pending.phase === 'saving' ? '正在只存到本机…' : pending.text}\n \n {pending.phase === 'local' && (\n \n )}\n {(pending.phase === 'joined' || pending.phase === 'error') && (\n \n )}\n \n )}\n \n 第一步只保存在本机;加入跟进只发送插件、版本、状态枚举和你的选择。网络传输并非绝对匿名。\n \n \n )}\n \n )\n}\n","import { useState, type CSSProperties } from 'react'\nimport type { InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'\n\nexport interface InboxInjected {\n checkInbox: () => Promise\n}\n\nexport type InboxButtonProps = PropsRuntime<'conversation.input.left'> & InjectFace\n\nconst button: CSSProperties = {\n height: 28, padding: '0 9px', border: 'none', borderRadius: 14, cursor: 'pointer',\n background: 'transparent', color: 'var(--dsw-alias-label-tertiary)', fontSize: 12,\n}\n\nexport function InboxButton({ checkInbox }: InboxButtonProps) {\n const [text, setText] = useState(null)\n const [busy, setBusy] = useState(false)\n return (\n \n {\n if (text !== null) return setText(null)\n setBusy(true)\n void checkInbox().then(setText).finally(() => { setBusy(false) })\n }}\n >\n {busy ? '检查中…' : '反馈进展'}\n \n {text !== null && (\n \n {text}\n \n )}\n \n )\n}\n","import { useState, type CSSProperties } from 'react'\nimport type { InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'\n\nexport interface ProbeInjected {\n checkHealth: () => Promise\n}\n\nexport type ProbeButtonProps = PropsRuntime<'conversation.input.left'> & InjectFace\n\nconst button: CSSProperties = {\n height: 28, padding: '0 9px', border: 'none', borderRadius: 14, cursor: 'pointer',\n background: 'transparent', color: 'var(--dsw-alias-label-tertiary)', fontSize: 12,\n}\n\nexport function ProbeButton({ checkHealth }: ProbeButtonProps) {\n const [text, setText] = useState(null)\n const [busy, setBusy] = useState(false)\n return (\n \n {\n if (text !== null) return setText(null)\n setBusy(true)\n void checkHealth().then(setText).finally(() => { setBusy(false) })\n }}\n >\n {busy ? '探活中…' : '插件探活'}\n \n {text !== null && (\n \n {text}\n \n )}\n \n )\n}\n","import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'\nimport type {} from '@deepseek-ai/dsh-api-remotes/client'\nimport type {} from '@deepseek-ai/dsh-client-ui-commands/client'\nimport type {} from '@deepseek-ai/dsh-client-ui-conversation/client'\nimport { LabController, type CommandsRemote } from './controller.js'\nimport { ExperienceResultCard, type LabInjected } from './ExperienceResultCard.js'\nimport { InboxButton, type InboxInjected } from './InboxButton.js'\nimport { ProbeButton, type ProbeInjected } from './ProbeButton.js'\n\nexport { ExperienceResultCard } from './ExperienceResultCard.js'\nexport { InboxButton } from './InboxButton.js'\nexport { ProbeButton } from './ProbeButton.js'\nexport { LabController } from './controller.js'\n\nexport const inject = ['slots', 'remote', 'remote.commands']\n\nexport function apply(ctx: ClientContext): void {\n const controllers = new Map()\n ctx.effect(() => () => { controllers.clear() }, 'plugin-lab: client controller lifecycle')\n const controllerFor = (sessionId: SessionId): LabController => {\n let controller = controllers.get(sessionId)\n if (controller === undefined) {\n controller = new LabController(ctx.remote.commands as CommandsRemote, sessionId)\n controllers.set(sessionId, controller)\n }\n return controller\n }\n\n ctx.on('command/executed', (sessionId, name, result) => {\n if (result.kind !== 'success') return\n if (name === 'omdsh-start' || name === 'omdsh-retest') controllerFor(sessionId).setTrialActive(true)\n if (name === 'omdsh-result' || name === 'omdsh-feedback') controllerFor(sessionId).setTrialActive(false)\n })\n\n ctx.slots.inject('conversation.chat.assistant-actions', () => ctx.slots.register({\n name: 'conversation.chat.assistant-actions',\n id: 'omdsh-plugin-lab',\n order: 20,\n inject: (sessionId): LabInjected => {\n const controller = controllerFor(sessionId)\n return {\n hooks: { pluginLab: controller },\n record: (messageId, outcome) => controller.record(messageId, outcome),\n join: () => controller.join(),\n dismiss: () => controller.dismiss(),\n }\n },\n }, ExperienceResultCard))\n\n ctx.slots.inject('conversation.input.left', () => ctx.slots.register({\n name: 'conversation.input.left',\n id: 'omdsh-plugin-lab-inbox',\n order: 40,\n inject: (sessionId): InboxInjected => ({\n checkInbox: () => controllerFor(sessionId).inbox(),\n }),\n }, InboxButton))\n\n ctx.slots.inject('conversation.input.left', () => ctx.slots.register({\n name: 'conversation.input.left',\n id: 'omdsh-plugin-lab-probe',\n order: 39,\n inject: (sessionId): ProbeInjected => ({\n checkHealth: () => controllerFor(sessionId).probe(),\n }),\n }, ProbeButton))\n}\n"],"mappings":"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"} \ No newline at end of file diff --git a/dist/crash.d.ts b/dist/crash.d.ts deleted file mode 100644 index 4c22d3b..0000000 --- a/dist/crash.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -import type { RuntimeCrashSignal } from './protocol.js'; -/** Keep only a package-relative or built-in first frame, never a machine path or function name. */ -export declare function sanitizeCrashFrame(stack: unknown): string | undefined; -export declare function runtimeCrashSignal(error: unknown, origin: RuntimeCrashSignal['origin']): RuntimeCrashSignal; diff --git a/dist/crash.js b/dist/crash.js deleted file mode 100644 index f94c40f..0000000 --- a/dist/crash.js +++ /dev/null @@ -1,49 +0,0 @@ -import { createHash } from 'node:crypto'; -const LOCATION = /(?:file:\/\/)?(?:[A-Za-z]:)?[^()\s]+:\d+:\d+/u; -const ERROR_NAME = /^[A-Za-z][A-Za-z0-9_.-]{0,79}$/u; -const ERROR_CODE = /^[A-Z][A-Z0-9_]{1,63}$/u; -function errorName(value) { - return typeof value === 'string' && ERROR_NAME.test(value) ? value : 'Error'; -} -function errorCode(error) { - if (typeof error !== 'object' || error === null || !('code' in error)) - return undefined; - return typeof error.code === 'string' && ERROR_CODE.test(error.code) ? error.code : undefined; -} -/** Keep only a package-relative or built-in first frame, never a machine path or function name. */ -export function sanitizeCrashFrame(stack) { - if (typeof stack !== 'string') - return undefined; - for (const line of stack.split('\n').slice(1)) { - const matched = line.match(LOCATION)?.[0]; - if (matched === undefined) - continue; - const normalized = matched.replace(/^file:\/\//u, '').replaceAll('\\', '/'); - if (normalized.startsWith('node:')) - return normalized.slice(0, 200); - const dependency = normalized.lastIndexOf('/node_modules/'); - if (dependency >= 0) - return `node_modules/${normalized.slice(dependency + '/node_modules/'.length)}`.slice(0, 200); - const file = normalized.split('/').at(-1); - return file === undefined ? undefined : `/${file}`.slice(0, 200); - } - return undefined; -} -export function runtimeCrashSignal(error, origin) { - const row = typeof error === 'object' && error !== null ? error : {}; - const name = errorName(row.name); - const code = errorCode(error); - const frame = sanitizeCrashFrame(row.stack); - const fingerprint = createHash('sha256') - .update([name, code ?? '', origin, frame ?? ''].join('\u001f')) - .digest('hex') - .slice(0, 20); - return { - fingerprint, - name, - origin, - ...code === undefined ? {} : { code }, - ...frame === undefined ? {} : { frame }, - }; -} -//# sourceMappingURL=crash.js.map \ No newline at end of file diff --git a/dist/crash.js.map b/dist/crash.js.map deleted file mode 100644 index efdad1c..0000000 --- a/dist/crash.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"crash.js","sourceRoot":"","sources":["../src/crash.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/diagnosis.d.ts b/dist/diagnosis.d.ts deleted file mode 100644 index c39bf79..0000000 --- a/dist/diagnosis.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { ExperienceEventV1, RetentionIntent, TrialOutcome } from './protocol.js'; -export interface Diagnosis { - readonly headline: string; - readonly actions: readonly string[]; -} -export declare function diagnoseExperience(event: ExperienceEventV1, outcome: TrialOutcome, retention: RetentionIntent): Diagnosis; diff --git a/dist/diagnosis.js b/dist/diagnosis.js deleted file mode 100644 index 6f2f800..0000000 --- a/dist/diagnosis.js +++ /dev/null @@ -1,50 +0,0 @@ -export function diagnoseExperience(event, outcome, retention) { - const actions = []; - if (event.signals.loaderHealth === 'missing') { - return { - headline: '目标插件没有出现在当前 DSH Loader 中。', - actions: ['检查它是否已安装进当前 Profile,以及 Bundle patch 是否插入了插件行。'], - }; - } - if (event.signals.loaderHealth === 'failed') { - return { - headline: '目标插件已被发现,但加载失败。', - actions: ['优先检查构建产物、依赖版本和插件启动日志,再判断插件能力。'], - }; - } - if (event.signals.loaderHealth !== 'active' && event.signals.loaderHealth !== 'unknown') { - actions.push(`插件当前 Loader 状态为 ${event.signals.loaderHealth},建议状态稳定后复测。`); - } - if (event.signals.assistantMessages === 0) { - actions.push('本次 Trial 没有产生完整 Assistant 回复,反馈更可能指向启动或运行链路。'); - } - if (event.signals.toolErrors > 0) { - actions.push(`记录到 ${event.signals.toolErrors} 次 Tool 错误;后续报告应优先附错误码,不必附会话正文。`); - } - if (event.signals.agentErrors > 0) { - actions.push(`记录到 ${event.signals.agentErrors} 次 Agent 运行错误,建议先排除框架或模型侧异常。`); - } - if (event.signals.processCrashes > 0) { - const signature = event.signals.crashes?.[0]; - actions.push(signature === undefined - ? `记录到 ${event.signals.processCrashes} 次进程崩溃。` - : `记录到 ${event.signals.processCrashes} 次进程崩溃;脱敏指纹 ${signature.fingerprint},首帧 ${signature.frame ?? 'unknown'}。`); - } - if (outcome === 'worked' && retention === 'keep') { - return { - headline: '这次试用形成了明确的正向保留信号。', - actions: actions.length > 0 ? actions : ['可以用一个相似任务复测,确认体验不是偶然。'], - }; - } - if (outcome === 'failed') { - return { - headline: '这次试用没有完成目标。', - actions: actions.length > 0 ? actions : ['建议用相同任务在干净 Profile 中复测,再决定卸载或报告问题。'], - }; - } - return { - headline: '这次试用提供了部分价值,但证据还不足以直接保留或淘汰。', - actions: actions.length > 0 ? actions : ['建议固定同一任务再试一次,或与不安装插件的基线结果比较。'], - }; -} -//# sourceMappingURL=diagnosis.js.map \ No newline at end of file diff --git a/dist/diagnosis.js.map b/dist/diagnosis.js.map deleted file mode 100644 index 60ce669..0000000 --- a/dist/diagnosis.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"diagnosis.js","sourceRoot":"","sources":["../src/diagnosis.ts"],"names":[],"mappings":"AAOA,MAAM,UAAU,kBAAkB,CAChC,KAAwB,EACxB,OAAqB,EACrB,SAA0B;IAE1B,MAAM,OAAO,GAAa,EAAE,CAAA;IAC5B,IAAI,KAAK,CAAC,OAAO,CAAC,YAAY,KAAK,SAAS,EAAE,CAAC;QAC7C,OAAO;YACL,QAAQ,EAAE,2BAA2B;YACrC,OAAO,EAAE,CAAC,+CAA+C,CAAC;SAC3D,CAAA;IACH,CAAC;IACD,IAAI,KAAK,CAAC,OAAO,CAAC,YAAY,KAAK,QAAQ,EAAE,CAAC;QAC5C,OAAO;YACL,QAAQ,EAAE,iBAAiB;YAC3B,OAAO,EAAE,CAAC,+BAA+B,CAAC;SAC3C,CAAA;IACH,CAAC;IACD,IAAI,KAAK,CAAC,OAAO,CAAC,YAAY,KAAK,QAAQ,IAAI,KAAK,CAAC,OAAO,CAAC,YAAY,KAAK,SAAS,EAAE,CAAC;QACxF,OAAO,CAAC,IAAI,CAAC,mBAAmB,KAAK,CAAC,OAAO,CAAC,YAAY,aAAa,CAAC,CAAA;IAC1E,CAAC;IACD,IAAI,KAAK,CAAC,OAAO,CAAC,iBAAiB,KAAK,CAAC,EAAE,CAAC;QAC1C,OAAO,CAAC,IAAI,CAAC,8CAA8C,CAAC,CAAA;IAC9D,CAAC;IACD,IAAI,KAAK,CAAC,OAAO,CAAC,UAAU,GAAG,CAAC,EAAE,CAAC;QACjC,OAAO,CAAC,IAAI,CAAC,OAAO,KAAK,CAAC,OAAO,CAAC,UAAU,iCAAiC,CAAC,CAAA;IAChF,CAAC;IACD,IAAI,KAAK,CAAC,OAAO,CAAC,WAAW,GAAG,CAAC,EAAE,CAAC;QAClC,OAAO,CAAC,IAAI,CAAC,OAAO,KAAK,CAAC,OAAO,CAAC,WAAW,8BAA8B,CAAC,CAAA;IAC9E,CAAC;IACD,IAAI,KAAK,CAAC,OAAO,CAAC,cAAc,GAAG,CAAC,EAAE,CAAC;QACrC,MAAM,SAAS,GAAG,KAAK,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,CAAA;QAC5C,OAAO,CAAC,IAAI,CAAC,SAAS,KAAK,SAAS;YAClC,CAAC,CAAC,OAAO,KAAK,CAAC,OAAO,CAAC,cAAc,SAAS;YAC9C,CAAC,CAAC,OAAO,KAAK,CAAC,OAAO,CAAC,cAAc,eAAe,SAAS,CAAC,WAAW,OAAO,SAAS,CAAC,KAAK,IAAI,SAAS,GAAG,CAAC,CAAA;IACpH,CAAC;IACD,IAAI,OAAO,KAAK,QAAQ,IAAI,SAAS,KAAK,MAAM,EAAE,CAAC;QACjD,OAAO;YACL,QAAQ,EAAE,mBAAmB;YAC7B,OAAO,EAAE,OAAO,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,uBAAuB,CAAC;SAClE,CAAA;IACH,CAAC;IACD,IAAI,OAAO,KAAK,QAAQ,EAAE,CAAC;QACzB,OAAO;YACL,QAAQ,EAAE,aAAa;YACvB,OAAO,EAAE,OAAO,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,oCAAoC,CAAC;SAC/E,CAAA;IACH,CAAC;IACD,OAAO;QACL,QAAQ,EAAE,6BAA6B;QACvC,OAAO,EAAE,OAAO,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,8BAA8B,CAAC;KACzE,CAAA;AACH,CAAC"} \ No newline at end of file diff --git a/dist/health.d.ts b/dist/health.d.ts new file mode 100644 index 0000000..5fb63a0 --- /dev/null +++ b/dist/health.d.ts @@ -0,0 +1,28 @@ +import type { HealthStatus } from './protocol.js'; +export declare const FIBER_PHASE: { + readonly PENDING: 0; + readonly LOADING: 1; + readonly ACTIVE: 2; + readonly FAILED: 3; + readonly DISPOSED: 4; + readonly UNLOADING: 5; +}; +export interface LoaderEntryLike { + readonly disabled?: boolean; + readonly options?: { + readonly name?: string; + readonly group?: boolean; + }; + readonly fiber?: { + readonly state?: number; + }; +} +export interface LoaderLike { + entries(): Iterable; +} +/** + * Read only Host-owned lifecycle cells. No plugin method, logger, Session, + * exception, filesystem, environment or network capability is reachable here. + */ +export declare function probeLoaderHealth(loader: LoaderLike | undefined, moduleName: string): HealthStatus; +export declare function healthText(status: HealthStatus): string; diff --git a/dist/health.js b/dist/health.js new file mode 100644 index 0000000..fb6176b --- /dev/null +++ b/dist/health.js @@ -0,0 +1,51 @@ +export const FIBER_PHASE = { + PENDING: 0, + LOADING: 1, + ACTIVE: 2, + FAILED: 3, + DISPOSED: 4, + UNLOADING: 5, +}; +/** + * Read only Host-owned lifecycle cells. No plugin method, logger, Session, + * exception, filesystem, environment or network capability is reachable here. + */ +export function probeLoaderHealth(loader, moduleName) { + if (loader === undefined || typeof loader.entries !== 'function') + return 'unknown'; + try { + const matches = [...loader.entries()].filter(entry => { + const name = entry.options?.name; + return entry.options?.group !== true + && (name === moduleName || name?.startsWith(`${moduleName}/`) === true); + }); + if (matches.length === 0 || matches.every(entry => entry.disabled === true)) + return 'unavailable'; + const states = matches + .filter(entry => entry.disabled !== true) + .map(entry => entry.fiber?.state); + if (states.includes(FIBER_PHASE.ACTIVE)) + return 'ok'; + if (states.includes(FIBER_PHASE.FAILED)) + return 'error'; + if (states.some(state => state === FIBER_PHASE.PENDING + || state === FIBER_PHASE.LOADING + || state === FIBER_PHASE.DISPOSED + || state === FIBER_PHASE.UNLOADING)) + return 'unavailable'; + return 'unknown'; + } + catch { + return 'unknown'; + } +} +export function healthText(status) { + if (status === 'ok') + return '当前运行 OK'; + if (status === 'unavailable') + return '当前暂不可用'; + if (status === 'error') + return '当前运行异常'; + return '暂时无法判断'; +} +//# sourceMappingURL=health.js.map \ No newline at end of file diff --git a/dist/health.js.map b/dist/health.js.map new file mode 100644 index 0000000..9aa9b0e --- /dev/null +++ b/dist/health.js.map @@ -0,0 +1 @@ +{"version":3,"file":"health.js","sourceRoot":"","sources":["../src/health.ts"],"names":[],"mappings":"AAEA,MAAM,CAAC,MAAM,WAAW,GAAG;IACzB,OAAO,EAAE,CAAC;IACV,OAAO,EAAE,CAAC;IACV,MAAM,EAAE,CAAC;IACT,MAAM,EAAE,CAAC;IACT,QAAQ,EAAE,CAAC;IACX,SAAS,EAAE,CAAC;CACJ,CAAA;AAYV;;;GAGG;AACH,MAAM,UAAU,iBAAiB,CAAC,MAA8B,EAAE,UAAkB;IAClF,IAAI,MAAM,KAAK,SAAS,IAAI,OAAO,MAAM,CAAC,OAAO,KAAK,UAAU;QAAE,OAAO,SAAS,CAAA;IAClF,IAAI,CAAC;QACH,MAAM,OAAO,GAAG,CAAC,GAAG,MAAM,CAAC,OAAO,EAAE,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE;YACnD,MAAM,IAAI,GAAG,KAAK,CAAC,OAAO,EAAE,IAAI,CAAA;YAChC,OAAO,KAAK,CAAC,OAAO,EAAE,KAAK,KAAK,IAAI;mBAC/B,CAAC,IAAI,KAAK,UAAU,IAAI,IAAI,EAAE,UAAU,CAAC,GAAG,UAAU,GAAG,CAAC,KAAK,IAAI,CAAC,CAAA;QAC3E,CAAC,CAAC,CAAA;QACF,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC,IAAI,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,CAAC,QAAQ,KAAK,IAAI,CAAC;YAAE,OAAO,aAAa,CAAA;QACjG,MAAM,MAAM,GAAG,OAAO;aACnB,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,CAAC,QAAQ,KAAK,IAAI,CAAC;aACxC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,CAAC,KAAK,EAAE,KAAK,CAAC,CAAA;QACnC,IAAI,MAAM,CAAC,QAAQ,CAAC,WAAW,CAAC,MAAM,CAAC;YAAE,OAAO,IAAI,CAAA;QACpD,IAAI,MAAM,CAAC,QAAQ,CAAC,WAAW,CAAC,MAAM,CAAC;YAAE,OAAO,OAAO,CAAA;QACvD,IAAI,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,KAAK,WAAW,CAAC,OAAO;eACjD,KAAK,KAAK,WAAW,CAAC,OAAO;eAC7B,KAAK,KAAK,WAAW,CAAC,QAAQ;eAC9B,KAAK,KAAK,WAAW,CAAC,SAAS,CAAC;YAAE,OAAO,aAAa,CAAA;QAC3D,OAAO,SAAS,CAAA;IAClB,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAA;IAClB,CAAC;AACH,CAAC;AAED,MAAM,UAAU,UAAU,CAAC,MAAoB;IAC7C,IAAI,MAAM,KAAK,IAAI;QAAE,OAAO,SAAS,CAAA;IACrC,IAAI,MAAM,KAAK,aAAa;QAAE,OAAO,QAAQ,CAAA;IAC7C,IAAI,MAAM,KAAK,OAAO;QAAE,OAAO,QAAQ,CAAA;IACvC,OAAO,QAAQ,CAAA;AACjB,CAAC"} \ No newline at end of file diff --git a/dist/index.d.ts b/dist/index.d.ts index d38de88..231de5e 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -1,45 +1,19 @@ -/** Consent-first plugin trial feedback loop for DeepSeek Harness. */ +/** Zero-content plugin health and user-confirmed feedback loop for DeepSeek Harness. */ import type { Context } from '@deepseek-ai/cordis'; import z from '@deepseek-ai/schemastery'; -import { type LoaderHealth, type RuntimeCrashSignal, type TrialPluginRef } from './protocol.js'; export declare const name = "omdsh-plugin-lab"; export declare const inject: string[]; export interface Config { readonly dataDir?: string; readonly ingestUrl?: string; + readonly allowShare?: boolean; + /** Deprecated deployment alias retained only so existing rc.6 configs keep working. */ readonly allowAnonymousShare?: boolean; readonly authorizationEnv?: string; - readonly profileLabel?: string; readonly requestTimeoutMs?: number; readonly retryIntervalMs?: number; } export declare const Config: z; -declare module '@deepseek-ai/dsh-session/types' { - interface SessionEventMap { - 'omdsh/trial-started': { - trialId: string; - plugin: TrialPluginRef; - taskId?: string; - retestOfReceiptId?: string; - startedAt: number; - loaderHealth: LoaderHealth; - }; - 'omdsh/feedback-recorded': { - eventId: string; - trialId: string; - outcome: 'worked' | 'partial' | 'failed'; - retention: 'keep' | 'unsure' | 'remove'; - requestedShare: boolean; - noteShared: boolean; - }; - 'omdsh/runtime-crashed': { - crashId: string; - trialId: string; - occurredAt: number; - crash: RuntimeCrashSignal; - }; - } -} export declare function apply(ctx: Context, rawConfig: Config): void; declare const _default: { name: string; diff --git a/dist/index.js b/dist/index.js index ea9446c..8e0b032 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1,146 +1,53 @@ -/** Consent-first plugin trial feedback loop for DeepSeek Harness. */ -import { createRequire } from 'node:module'; +/** Zero-content plugin health and user-confirmed feedback loop for DeepSeek Harness. */ import z from '@deepseek-ai/schemastery'; -import { runtimeCrashSignal } from './crash.js'; -import { diagnoseExperience } from './diagnosis.js'; -import { FEEDBACK_USAGE, JOIN_USAGE, parseFeedbackInput, parseResultInput, parseStartInput, RESULT_USAGE, RETEST_USAGE, retentionForOutcome, START_USAGE, } from './input.js'; -import { EXPERIENCE_SCHEMA_VERSION, } from './protocol.js'; -import { ExperienceStore } from './storage.js'; +import { createAgentAssessmentTool } from './agent-tool.js'; +import { healthText, probeLoaderHealth } from './health.js'; +import { JOIN_USAGE, parseJoinTarget, parseReceiptId, parseStartInput, parseVerdict, RESULT_USAGE, RETEST_USAGE, START_USAGE, } from './input.js'; +import { FEEDBACK_SCHEMA_VERSION, } from './protocol.js'; +import { FeedbackStore } from './storage.js'; import { ExperienceUploader } from './uploader.js'; export const name = 'omdsh-plugin-lab'; -export const inject = ['commands', 'sessions']; -const require = createRequire(import.meta.url); -function dshVersion() { - try { - return require('@deepseek-ai/dsh-session/package.json').version; - } - catch { - return 'unknown'; - } -} +export const inject = ['commands']; export const Config = z.object({ dataDir: z.string(), ingestUrl: z.string(), - allowAnonymousShare: z.boolean().default(false), + allowShare: z.boolean(), + allowAnonymousShare: z.boolean(), authorizationEnv: z.string().default('OMDSH_PLUGIN_LAB_TOKEN'), - profileLabel: z.string().default('default'), requestTimeoutMs: z.number().default(5_000), retryIntervalMs: z.number().default(30_000), }); -function emptyMetrics() { - return { - assistantMessages: 0, - turnsStarted: 0, - turnsCompleted: 0, - toolCalls: 0, - toolErrors: 0, - agentErrors: 0, - processCrashes: 0, - crashes: [], - crashIds: new Set(), - }; +function sessionKey(invocation) { + return String(invocation.agent.session.id); } -function snapshotMetrics(metrics) { - return { - assistantMessages: metrics.assistantMessages, - turnsStarted: metrics.turnsStarted, - turnsCompleted: metrics.turnsCompleted, - toolCalls: metrics.toolCalls, - toolErrors: metrics.toolErrors, - agentErrors: metrics.agentErrors, - processCrashes: metrics.processCrashes, - ...metrics.crashes.length === 0 ? {} : { crashes: [...metrics.crashes] }, - ...metrics.firstReplyMs === undefined ? {} : { firstReplyMs: metrics.firstReplyMs }, - ...metrics.lastTurnReason === undefined ? {} : { lastTurnReason: metrics.lastTurnReason }, - }; +function agentSessionKey(agent) { + return agent === undefined ? undefined : String(agent.session.id); } -function reasonKind(value) { - if (typeof value !== 'object' || value === null || !('kind' in value)) - return undefined; - return typeof value.kind === 'string' ? value.kind : undefined; +function health(ctx, plugin) { + return probeLoaderHealth(ctx.get('loader'), plugin.moduleName); } -function recordCrash(state, record) { - if (record.trialId !== state.trialId || state.metrics.crashIds.has(record.crashId)) - return; - state.metrics.crashIds.add(record.crashId); - state.metrics.processCrashes += 1; - if (state.metrics.crashes.length < 8 - && !state.metrics.crashes.some(crash => crash.fingerprint === record.crash.fingerprint)) { - state.metrics.crashes.push(record.crash); - } -} -function observe(state, event) { - if (event.seq <= state.startSeq) - return; - switch (event.type) { - case 'turn/start': - state.metrics.turnsStarted += 1; - return; - case 'turn/end': - state.metrics.turnsCompleted += 1; - state.metrics.lastTurnReason = reasonKind(event.data.reason) ?? 'unknown'; - return; - case 'assistant/message': - state.metrics.assistantMessages += 1; - state.metrics.firstReplyMs ??= Math.max(0, event.time - state.startedAt); - return; - case 'tool/call': - state.metrics.toolCalls += 1; - return; - case 'tool/result': - if (event.data.error !== undefined) - state.metrics.toolErrors += 1; - return; - case 'omdsh/runtime-crashed': - recordCrash(state, event.data); - return; - default: - return; - } +function assessment(status) { + return { health: status, experience: 'unknown', userConfirmationRequired: true }; } -const FIBER_PHASE = { - 0: 'pending', - 1: 'loading', - 2: 'active', - 3: 'failed', - 4: 'unknown', - 5: 'unloading', -}; -function loaderHealth(ctx, moduleName) { - const loader = ctx.get('loader'); - if (loader === undefined || typeof loader.entries !== 'function') - return 'unknown'; - const matches = [...loader.entries()].filter(entry => { - const name = entry.options?.name; - return entry.options?.group !== true - && (name === moduleName || name?.startsWith(`${moduleName}/`) === true); - }); - if (matches.length === 0) - return 'missing'; - if (matches.every(entry => entry.disabled === true)) - return 'disabled'; - const phases = matches.filter(entry => entry.disabled !== true) - .map(entry => entry.fiber?.state === undefined ? 'unknown' : FIBER_PHASE[entry.fiber.state] ?? 'unknown'); - if (phases.includes('active')) - return 'active'; - if (phases.includes('failed')) - return 'failed'; - return phases[0] ?? 'unknown'; +function verdictText(verdict) { + if (verdict === 'good') + return '好用'; + if (verdict === 'mixed') + return '一般'; + return '不好用'; } function renderReceipt(receipt) { - const lines = ['已匿名加入跟进。']; + const lines = ['已发送严格最小化反馈。']; if (receipt.caseId !== undefined) lines.push(`问题回执:${receipt.caseId}`); if (receipt.similarReports !== undefined) - lines.push(`相似反馈:${receipt.similarReports} 条。`); + lines.push(`同类反馈:${receipt.similarReports} 条。`); if (receipt.status !== undefined) lines.push(`状态:${receipt.status}。`); if (receipt.recommendedVersion !== undefined) lines.push(`建议版本:${receipt.recommendedVersion}。`); - if (receipt.message !== undefined) - lines.push(receipt.message); if (receipt.trackingUrl !== undefined) - lines.push(`跟踪地址:${receipt.trackingUrl}`); + lines.push(`聚合跟踪:${receipt.trackingUrl}`); return lines; } function validateConfig(config) { @@ -165,17 +72,16 @@ function validateConfig(config) { return { ...config.dataDir === undefined ? {} : { dataDir: config.dataDir }, ...config.ingestUrl === undefined || config.ingestUrl.length === 0 ? {} : { ingestUrl: config.ingestUrl }, - allowAnonymousShare: config.allowAnonymousShare ?? false, + allowShare: config.allowShare ?? config.allowAnonymousShare ?? false, authorizationEnv, - profileLabel: config.profileLabel ?? 'default', requestTimeoutMs, retryIntervalMs, }; } export function apply(ctx, rawConfig) { const config = validateConfig(rawConfig); - const store = new ExperienceStore(config.dataDir); - const uploader = config.allowAnonymousShare && config.ingestUrl !== undefined + const store = new FeedbackStore(config.dataDir); + const uploader = config.allowShare && config.ingestUrl !== undefined ? new ExperienceUploader(store, { ingestUrl: config.ingestUrl, authorizationEnv: config.authorizationEnv, @@ -183,288 +89,130 @@ export function apply(ctx, rawConfig) { }) : undefined; const trials = new Map(); - const sessionKey = (session) => String(session.id); - const adopt = (session) => { - let state; - for (const event of session.events) { - if (event.type === 'omdsh/trial-started') { - state = { - trialId: event.data.trialId, - plugin: event.data.plugin, - ...event.data.taskId === undefined ? {} : { taskId: event.data.taskId }, - ...event.data.retestOfReceiptId === undefined ? {} : { retestOfReceiptId: event.data.retestOfReceiptId }, - startedAt: event.data.startedAt, - startSeq: event.seq, - loaderHealthAtStart: event.data.loaderHealth, - metrics: emptyMetrics(), - }; - } - else if (event.type === 'omdsh/feedback-recorded' && state?.trialId === event.data.trialId) { - state = undefined; - } - else if (state !== undefined) { - observe(state, event); - } - } - if (state !== undefined) { - for (const record of store.crashRecords(state.trialId)) - recordCrash(state, record); - trials.set(sessionKey(session), state); - } + const assessTrial = (key) => { + const state = key === undefined ? undefined : trials.get(key); + return assessment(state === undefined ? 'unknown' : health(ctx, state.plugin)); }; - ctx.on('session/created', adopt); - ctx.on('session/disposed', session => { trials.delete(sessionKey(session)); }); - ctx.on('session/event', (session, event) => { - const state = trials.get(sessionKey(session)); - if (state !== undefined) - observe(state, event); - }); - ctx.on('agent/error', ({ agent }) => { - const state = trials.get(sessionKey(agent.session)); - if (state !== undefined) - state.metrics.agentErrors += 1; + // Optional capability: headless command-only tests still work, while a normal + // rc.6 Agent runtime receives the closed, zero-argument assessment tool. + ctx.inject(['tools'], toolCtx => { + toolCtx.tools.register(createAgentAssessmentTool(agent => assessTrial(agentSessionKey(agent)))); }); - for (const session of ctx.sessions.list()) - adopt(session); - const monitorCrash = (error, origin) => { - const crash = runtimeCrashSignal(error, origin); - const occurredAt = Date.now(); - for (const session of ctx.sessions.list()) { - const state = trials.get(sessionKey(session)); - if (state === undefined) - continue; - const record = { - crashId: crypto.randomUUID(), - trialId: state.trialId, - occurredAt, - crash, - }; - try { - store.appendCrash(record); - } - catch { - // Keep monitoring best-effort: never replace the original process failure. - } - recordCrash(state, record); - try { - session.append('omdsh/runtime-crashed', record); - } - catch { - // The synchronous journal above remains recoverable if Session persistence cannot run. - } - } - }; - process.on('uncaughtExceptionMonitor', monitorCrash); - ctx.effect(() => () => { process.off('uncaughtExceptionMonitor', monitorCrash); }, 'plugin-lab crash monitor'); const beginTrial = (invocation, input, retestOfReceiptId) => { - const key = sessionKey(invocation.agent.session); + const key = sessionKey(invocation); if (trials.has(key)) { - return { kind: 'error', text: '当前 Session 已有进行中的 Trial;请先提交体验结果,或换一个 Session。' }; + return { kind: 'error', text: '当前任务已有进行中的插件试用;请先确认体验结果。' }; } - const startedAt = Date.now(); - const health = loaderHealth(ctx, input.plugin.moduleName); - const event = invocation.agent.session.append('omdsh/trial-started', { - trialId: crypto.randomUUID(), + const state = { plugin: input.plugin, - ...input.taskId === undefined ? {} : { taskId: input.taskId }, ...retestOfReceiptId === undefined ? {} : { retestOfReceiptId }, - startedAt, - loaderHealth: health, - }); - trials.set(key, { - trialId: event.data.trialId, - plugin: input.plugin, - ...input.taskId === undefined ? {} : { taskId: input.taskId }, - ...retestOfReceiptId === undefined ? {} : { retestOfReceiptId }, - startedAt, - startSeq: event.seq, - loaderHealthAtStart: health, - metrics: emptyMetrics(), - }); + }; + trials.set(key, state); return { kind: 'success', text: [ - `Trial 已开始:${input.plugin.moduleName}${input.plugin.version === undefined ? '' : `#${input.plugin.version}`}`, - `Loader 状态:${health}`, - retestOfReceiptId === undefined - ? '现在正常使用 Agent。完成后在最新回复旁选择结果;默认只保存在本机。' - : `这是回执 ${retestOfReceiptId} 的修复复测;完成后请选择结果。`, + `试用已开始:${input.plugin.moduleName}${input.plugin.version === undefined ? '' : `#${input.plugin.version}`}`, + `运行状态:${healthText(health(ctx, input.plugin))}`, + '完成后由你选择“好用 / 一般 / 不好用”;Agent 不会读取会话或日志替你判断。', ].join('\n'), - sourceEventSeq: event.seq, }; }; const startTrial = (invocation) => { try { return beginTrial(invocation, parseStartInput(invocation.rawInput)); } - catch (error) { - return { kind: 'error', text: error instanceof Error ? error.message : START_USAGE }; + catch { + return { kind: 'error', text: START_USAGE }; } }; const startRetest = (invocation) => { - const [receiptId, ...trialParts] = invocation.rawInput.trim().split(/\s+/u).filter(Boolean); - if (receiptId === undefined || trialParts.length === 0) + const [receiptId, ...pluginParts] = invocation.rawInput.trim().split(/\s+/u).filter(Boolean); + if (receiptId === undefined || pluginParts.length !== 1) return { kind: 'error', text: RETEST_USAGE }; try { - return beginTrial(invocation, parseStartInput(trialParts.join(' ')), receiptId); + return beginTrial(invocation, parseStartInput(pluginParts[0] ?? ''), parseReceiptId(receiptId)); } - catch (error) { - return { kind: 'error', text: error instanceof Error ? error.message : RETEST_USAGE }; + catch { + return { kind: 'error', text: RETEST_USAGE }; } }; - const submitFeedback = async (invocation) => { - const state = trials.get(sessionKey(invocation.agent.session)); - if (state === undefined) { - return { kind: 'error', text: `没有进行中的 Trial。先运行 ${START_USAGE}` }; - } - let input; + const probe = (invocation) => { + const state = trials.get(sessionKey(invocation)); + const result = assessTrial(sessionKey(invocation)); + return { + kind: 'success', + text: state === undefined + ? `${healthText(result.health)}:当前没有选中的插件试用。` + : [ + `插件:${state.plugin.moduleName}`, + `运行状态:${healthText(result.health)}`, + '主观体验:未确认。探活没有读取日志、会话、异常或文件。', + ].join('\n'), + }; + }; + const submitResult = (invocation) => { + const key = sessionKey(invocation); + const state = trials.get(key); + if (state === undefined) + return { kind: 'error', text: `没有进行中的插件试用。先运行 ${START_USAGE}` }; + let verdict; try { - input = parseFeedbackInput(invocation.rawInput); + verdict = parseVerdict(invocation.rawInput); } - catch (error) { - return { kind: 'error', text: error instanceof Error ? error.message : FEEDBACK_USAGE }; + catch { + return { kind: 'error', text: RESULT_USAGE }; } - if (input.share && uploader === undefined) { - return { - kind: 'error', - text: '匿名分享未启用。当前不会上传任何数据;请移除 --share,或由部署者同时配置 allowAnonymousShare 和 ingestUrl。', - }; - } - const currentHealth = loaderHealth(ctx, state.plugin.moduleName); const event = { - schemaVersion: EXPERIENCE_SCHEMA_VERSION, - type: 'feedback.submitted', + schemaVersion: FEEDBACK_SCHEMA_VERSION, + type: 'feedback.signal', eventId: crypto.randomUUID(), - occurredAt: Date.now(), - participantId: store.participantId(), - trial: { - id: state.trialId, - plugin: state.plugin, - ...state.taskId === undefined ? {} : { taskId: state.taskId }, - startedAt: state.startedAt, - durationMs: Math.max(0, Date.now() - state.startedAt), - ...state.retestOfReceiptId === undefined ? {} : { retestOfReceiptId: state.retestOfReceiptId }, - }, - environment: { - dshVersion: dshVersion(), - nodeVersion: process.version, - platform: process.platform, - arch: process.arch, - locale: Intl.DateTimeFormat().resolvedOptions().locale, - profileLabel: config.profileLabel, - }, - signals: { - ...snapshotMetrics(state.metrics), - loaderHealth: currentHealth === 'unknown' ? state.loaderHealthAtStart : currentHealth, - }, - feedback: { - outcome: input.outcome, - retention: input.retention, - ...input.note === undefined ? {} : { note: input.note }, - }, - sharing: { - transcript: 'none', - noteIncluded: input.shareNote, - }, + plugin: state.plugin, + health: health(ctx, state.plugin), + experience: verdict, + source: 'user_confirmed', + ...state.retestOfReceiptId === undefined ? {} : { retestOfReceiptId: state.retestOfReceiptId }, }; - const record = { - event, - requestedShare: input.share, - shareNote: input.shareNote, + store.append({ event, requestedShare: false }); + trials.delete(key); + return { + kind: 'success', + text: [ + `运行状态:${healthText(event.health)}`, + `主观体验:${verdictText(verdict)}(由你确认)`, + `已只保存到本机:${store.eventsPath}`, + '记录只有公开插件 ID/版本、状态枚举和你的选择;不含日志、会话、时间、环境或稳定身份。', + '运行 /omdsh-join latest 才会发送屏幕上这组有限字段。', + ].join('\n'), }; - if (input.dryRun) { - const preview = input.shareNote || event.feedback.note === undefined - ? event - : { ...event, feedback: { outcome: event.feedback.outcome, retention: event.feedback.retention } }; - return { - kind: 'success', - text: `预览:不会保存或上传。\n${JSON.stringify(preview, null, 2)}`, - }; - } - store.append(record); - const feedbackEvent = invocation.agent.session.append('omdsh/feedback-recorded', { - eventId: event.eventId, - trialId: state.trialId, - outcome: input.outcome, - retention: input.retention, - requestedShare: input.share, - noteShared: input.shareNote, - }); - trials.delete(sessionKey(invocation.agent.session)); - const diagnosis = diagnoseExperience(event, input.outcome, input.retention); - const lines = [diagnosis.headline, ...diagnosis.actions.map(action => `- ${action}`)]; - lines.push(`已保存到本机:${store.eventsPath}`); - lines.push('记录不包含 Prompt、会话正文、Tool 参数/结果或工作目录。'); - if (input.note !== undefined && !input.shareNote) - lines.push('文字备注仅保存在本机。'); - if (input.share && uploader !== undefined) { - try { - const receipts = await uploader.flushPending(event.eventId); - const receipt = receipts.get(event.eventId); - if (receipt !== undefined) { - lines.push(...renderReceipt(receipt)); - store.markSeen(receipt); - } - } - catch (error) { - lines.push(`匿名上传暂时失败,已留在本地队列并会重试:${error instanceof Error ? error.message : String(error)}`); - } - } - else { - lines.push('本次只保存在本机。选择“加入跟进”或运行 /omdsh-join latest 才会匿名发送结构化体感。'); - } - return { kind: 'success', text: lines.join('\n'), sourceEventSeq: feedbackEvent.seq }; - }; - const submitResult = async (invocation) => { - let input; - try { - input = parseResultInput(invocation.rawInput); - } - catch (error) { - return { kind: 'error', text: error instanceof Error ? error.message : RESULT_USAGE }; - } - const note = input.note === undefined ? '' : ` ${input.note}`; - return await submitFeedback({ - ...invocation, - rawInput: `${input.outcome} ${retentionForOutcome(input.outcome)}${note}`, - }); }; const joinFollowUp = async (invocation) => { if (uploader === undefined) { - return { - kind: 'error', - text: '匿名跟进未启用。部署者需要同时配置 allowAnonymousShare 和 ingestUrl。', - }; + return { kind: 'error', text: '结构化分享未启用;当前不会产生反馈网络请求。' }; + } + let target; + try { + target = parseJoinTarget(invocation.rawInput); } - const parts = invocation.rawInput.trim().split(/\s+/u).filter(Boolean); - const target = parts.shift(); - if (target === undefined || parts.some(part => part !== '--share-note')) { + catch { return { kind: 'error', text: JOIN_USAGE }; } - const latestSessionEvent = invocation.agent.session.events.findLast(event => event.type === 'omdsh/feedback-recorded'); - const eventId = target === 'latest' - ? latestSessionEvent?.data.eventId - : target; + const eventId = target === 'latest' ? store.latestLocalRecord()?.event.eventId : target; if (eventId === undefined || store.record(eventId) === undefined) { - return { kind: 'error', text: '找不到这条本地体验记录。请先提交一次结果。' }; + return { kind: 'error', text: '找不到这条本地体验记录。请先确认一次结果。' }; } const existing = store.latestReceipts().find(receipt => receipt.eventId === eventId); if (existing !== undefined) return { kind: 'success', text: renderReceipt(existing).join('\n') }; - const shareNote = parts.includes('--share-note'); - store.requestShare(eventId, shareNote); + store.requestShare(eventId); try { const receipt = (await uploader.flushPending(eventId)).get(eventId); if (receipt === undefined) - return { kind: 'error', text: '没有生成远端回执,请稍后运行 /omdsh-inbox。' }; + return { kind: 'error', text: '反馈服务没有返回回执。' }; store.markSeen(receipt); return { kind: 'success', text: renderReceipt(receipt).join('\n') }; } - catch (error) { - return { - kind: 'success', - text: `已加入本地发送队列;网络恢复后会自动重试。\n${error instanceof Error ? error.message : String(error)}`, - }; + catch { + return { kind: 'success', text: '已加入本地发送队列;网络恢复后只会重试同一份有限字段。' }; } }; const inbox = async (invocation) => { @@ -472,8 +220,8 @@ export function apply(ctx, rawConfig) { try { await uploader.refreshReceipts(); } - catch (error) { - ctx.logger.warn(`plugin-lab: receipt refresh failed: ${String(error)}`); + catch { + // No logging: network failures remain an unavailable state, not diagnostic data. } } const unread = store.unreadReceipts(); @@ -488,130 +236,100 @@ export function apply(ctx, rawConfig) { return { kind: 'success', text: lines.join('\n') }; }; const status = (invocation) => { - const state = trials.get(sessionKey(invocation.agent.session)); - if (state === undefined) { - return { - kind: 'success', - text: [ - '当前没有进行中的 Trial。', - `待发送匿名记录:${store.pending().length} 条。`, - `未读处理进展:${store.unreadReceipts().length} 条。`, - ].join('\n'), - }; - } - const metrics = snapshotMetrics(state.metrics); - return { - kind: 'success', - text: [ - `Trial:${state.plugin.moduleName}${state.plugin.version === undefined ? '' : `#${state.plugin.version}`}`, - `Loader:${loaderHealth(ctx, state.plugin.moduleName)}`, - `Assistant 回复:${metrics.assistantMessages}`, - `Tool:${metrics.toolCalls} 次,错误 ${metrics.toolErrors} 次`, - `Turn:${metrics.turnsCompleted}/${metrics.turnsStarted}`, - `进程崩溃:${metrics.processCrashes} 次${metrics.crashes?.[0] === undefined ? '' : `(${metrics.crashes[0].name} / ${metrics.crashes[0].fingerprint})`}`, - `首回复:${metrics.firstReplyMs === undefined ? '尚未产生' : `${metrics.firstReplyMs} ms`}`, - ].join('\n'), - }; + const state = trials.get(sessionKey(invocation)); + const lines = state === undefined + ? ['当前没有进行中的插件试用。'] + : [ + `插件:${state.plugin.moduleName}${state.plugin.version === undefined ? '' : `#${state.plugin.version}`}`, + `运行状态:${healthText(health(ctx, state.plugin))}`, + '主观体验:未确认', + ]; + lines.push(`待发送记录:${store.pending().length} 条。`, `未读处理进展:${store.unreadReceipts().length} 条。`); + return { kind: 'success', text: lines.join('\n') }; }; const privacy = () => ({ kind: 'success', text: [ - `匿名分享:${uploader === undefined ? '未启用' : '可由用户逐次通过 --share 触发'}`, - '默认:仅本地保存。', - '匿名结构化字段:插件名/版本、DSH/Node/OS、任务 ID、加载状态、时延和错误计数、结果与保留意愿。', - '永不自动发送:Prompt、回复正文、Tool 参数/结果、cwd、Session ID。', - '备注默认不发送;只有 --share-note 会发送备注。', - '进行中 Trial 若遇到进程崩溃,只记录错误类型、错误码、归一化首帧和指纹;不记录原始 message、完整 stack 或绝对路径。', - `本地数据:${store.dataDir}`, - '结果先用 /omdsh-result 存在本机;/omdsh-join latest 才加入匿名跟进。', - '发送前也可在 /omdsh-feedback 参数末尾加 --dry-run 查看完整 JSON。', + `结构化分享:${uploader === undefined ? '未启用' : '只能由用户逐次运行 /omdsh-join 触发'}`, + '探活:仅本地读取 DSH Host 的 Loader/Fiber 生命周期枚举,不访问插件对象或网络。', + 'Agent:零参数工具只返回 health、experience=unknown、userConfirmationRequired=true。', + '可发送字段:schemaVersion、type、随机单次 eventId、公开插件 ID/版本、health、experience、source。', + '绝不读取或发送:日志、异常、堆栈、崩溃指纹、Prompt、回复、Tool 数据、文件、路径、环境、时间、用户/设备/安装/Session ID。', + '网络仍会自然暴露传输元数据,因此本插件不宣称匿名;服务端必须禁止持久化 IP、User-Agent 和请求体日志。', + `本地最小化数据:${store.dataDir}`, ].join('\n'), }); - const resetId = (invocation) => { - if (invocation.rawInput.trim() !== 'confirm') { - return { kind: 'error', text: 'Usage: /omdsh-reset-id confirm' }; - } - store.resetParticipantId(); - return { - kind: 'success', - text: '已生成新的 Plugin Lab 匿名 ID。此前已经上传的数据不会因此被远端删除。', - }; - }; ctx.commands.register({ name: 'omdsh-start', - description: '开始一次有明确目标插件的体验 Trial', - input: { hint: '[#version] [task-id]' }, + description: '开始一次单插件试用;不采集会话内容', + input: { hint: '[#version]' }, recordInput: false, handler: startTrial, }); ctx.commands.register({ - name: 'omdsh-feedback', - description: '兼容入口:提交结构化插件体感;默认仅本地保存', - input: { hint: ' [flags] [note]' }, + name: 'omdsh-probe', + description: '一键查看当前插件的无日志运行状态', recordInput: false, - handler: submitFeedback, + handler: probe, }); ctx.commands.register({ name: 'omdsh-result', - description: '记录这次插件是否做成;只保存在本机', - input: { hint: ' [note]' }, + description: '由用户确认主观体验;只保存在本机', + input: { hint: '' }, + recordInput: false, + handler: submitResult, + }); + ctx.commands.register({ + name: 'omdsh-feedback', + description: '兼容入口:由用户确认主观体验;只保存在本机', + input: { hint: '' }, recordInput: false, handler: submitResult, }); ctx.commands.register({ name: 'omdsh-join', - description: '匿名加入相似问题、获取处理回执和后续通知', - input: { hint: ' [--share-note]' }, + description: '明确发送已经显示给用户的有限状态字段', + input: { hint: '' }, recordInput: false, handler: joinFollowUp, }); ctx.commands.register({ name: 'omdsh-inbox', - description: '查看问题确认、修复发布与复测邀请', + description: '查看聚合问题、修复版本与复测邀请', input: { hint: '[--peek]' }, recordInput: false, handler: inbox, }); ctx.commands.register({ name: 'omdsh-retest', - description: '从问题回执启动一次修复复测', - input: { hint: ' [#version] [task-id]' }, + description: '从问题回执开始一次零内容复测', + input: { hint: ' [#version]' }, recordInput: false, handler: startRetest, }); ctx.commands.register({ name: 'omdsh-status', - description: '查看当前 Trial 的无内容运行指标', + description: '查看当前试用与本地反馈状态', recordInput: false, handler: status, }); ctx.commands.register({ name: 'omdsh-privacy', - description: '查看 Plugin Lab 的本地存储与分享边界', + description: '查看零日志数据边界与完整可发送字段', recordInput: false, handler: privacy, }); - ctx.commands.register({ - name: 'omdsh-reset-id', - description: '重置 Plugin Lab 自己的匿名安装 ID', - input: { hint: 'confirm' }, - recordInput: false, - handler: resetId, - }); if (uploader !== undefined) { const flush = () => { - void Promise.all([ - uploader.flushPending(), - uploader.refreshReceipts(), - ]).catch(error => { - ctx.logger.warn(`plugin-lab: background exchange failed: ${String(error)}`); + void Promise.all([uploader.flushPending(), uploader.refreshReceipts()]).catch(() => { + // Explicitly queued packets remain local; no exception text is logged. }); }; flush(); ctx.effect(() => { const timer = setInterval(flush, config.retryIntervalMs); return () => clearInterval(timer); - }, 'plugin-lab upload retry'); + }, 'plugin-lab closed-packet retry'); } } export default { name, inject, Config, apply }; diff --git a/dist/index.js.map b/dist/index.js.map index 23d2990..3d2ffc9 100644 --- a/dist/index.js.map +++ b/dist/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/input.d.ts b/dist/input.d.ts index 143b5f8..aee3ae4 100644 --- a/dist/input.d.ts +++ b/dist/input.d.ts @@ -1,26 +1,12 @@ -import type { RetentionIntent, TrialOutcome, TrialPluginRef } from './protocol.js'; +import type { ExperienceVerdict, TrialPluginRef } from './protocol.js'; export interface StartInput { readonly plugin: TrialPluginRef; - readonly taskId?: string; } -export interface FeedbackInput { - readonly outcome: TrialOutcome; - readonly retention: RetentionIntent; - readonly note?: string; - readonly share: boolean; - readonly shareNote: boolean; - readonly dryRun: boolean; -} -export interface ResultInput { - readonly outcome: TrialOutcome; - readonly note?: string; -} -export declare const START_USAGE = "Usage: /omdsh-start [#version] [task-id]"; -export declare const FEEDBACK_USAGE = "Usage: /omdsh-feedback [--share] [--share-note] [--dry-run] [note]"; -export declare const RESULT_USAGE = "Usage: /omdsh-result [note]"; -export declare const JOIN_USAGE = "Usage: /omdsh-join [--share-note]"; -export declare const RETEST_USAGE = "Usage: /omdsh-retest [#version] [task-id]"; +export declare const START_USAGE = "Usage: /omdsh-start [#version]"; +export declare const RESULT_USAGE = "Usage: /omdsh-result "; +export declare const JOIN_USAGE = "Usage: /omdsh-join "; +export declare const RETEST_USAGE = "Usage: /omdsh-retest [#version]"; export declare function parseStartInput(rawInput: string): StartInput; -export declare function parseFeedbackInput(rawInput: string): FeedbackInput; -export declare function parseResultInput(rawInput: string): ResultInput; -export declare function retentionForOutcome(outcome: TrialOutcome): RetentionIntent; +export declare function parseVerdict(rawInput: string): ExperienceVerdict; +export declare function parseJoinTarget(rawInput: string): string; +export declare function parseReceiptId(value: string): string; diff --git a/dist/input.js b/dist/input.js index 4cd23a2..f956f73 100644 --- a/dist/input.js +++ b/dist/input.js @@ -1,80 +1,40 @@ -const OUTCOMES = new Set(['worked', 'partial', 'failed']); -const RETENTION = new Set(['keep', 'unsure', 'remove']); -export const START_USAGE = 'Usage: /omdsh-start [#version] [task-id]'; -export const FEEDBACK_USAGE = 'Usage: /omdsh-feedback [--share] [--share-note] [--dry-run] [note]'; -export const RESULT_USAGE = 'Usage: /omdsh-result [note]'; -export const JOIN_USAGE = 'Usage: /omdsh-join [--share-note]'; -export const RETEST_USAGE = 'Usage: /omdsh-retest [#version] [task-id]'; +const MODULE = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/u; +const VERSION = /^[0-9A-Za-z][0-9A-Za-z.+_-]{0,63}$/u; +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu; +const VERDICTS = new Set(['good', 'mixed', 'bad']); +export const START_USAGE = 'Usage: /omdsh-start [#version]'; +export const RESULT_USAGE = 'Usage: /omdsh-result '; +export const JOIN_USAGE = 'Usage: /omdsh-join '; +export const RETEST_USAGE = 'Usage: /omdsh-retest [#version]'; export function parseStartInput(rawInput) { const parts = rawInput.trim().split(/\s+/u).filter(Boolean); const pluginSpec = parts[0]; - if (pluginSpec === undefined) + if (pluginSpec === undefined || parts.length !== 1) throw new TypeError(START_USAGE); const hash = pluginSpec.lastIndexOf('#'); const moduleName = hash > 0 ? pluginSpec.slice(0, hash) : pluginSpec; const version = hash > 0 ? pluginSpec.slice(hash + 1) : undefined; - if (moduleName.length === 0 || version === '') + if (!MODULE.test(moduleName) || (version !== undefined && !VERSION.test(version))) { throw new TypeError(START_USAGE); - const taskId = parts[1]; - if (parts.length > 2) - throw new TypeError(`${START_USAGE}. task-id must not contain spaces.`); - return { - plugin: { moduleName, ...version === undefined ? {} : { version } }, - ...taskId === undefined ? {} : { taskId }, - }; -} -export function parseFeedbackInput(rawInput) { - const parts = rawInput.trim().split(/\s+/u).filter(Boolean); - const outcome = parts.shift(); - const retention = parts.shift(); - if (!OUTCOMES.has(outcome) || !RETENTION.has(retention)) { - throw new TypeError(FEEDBACK_USAGE); - } - let share = false; - let shareNote = false; - let dryRun = false; - const noteParts = []; - for (const part of parts) { - if (part === '--share') - share = true; - else if (part === '--share-note') { - share = true; - shareNote = true; - } - else if (part === '--dry-run') - dryRun = true; - else - noteParts.push(part); - } - const note = noteParts.join(' ').trim(); - if (shareNote && note.length === 0) { - throw new TypeError('--share-note requires a non-empty note'); } - return { - outcome: outcome, - retention: retention, - ...note.length === 0 ? {} : { note }, - share, - shareNote, - dryRun, - }; + return { plugin: { moduleName, ...version === undefined ? {} : { version } } }; } -export function parseResultInput(rawInput) { - const parts = rawInput.trim().split(/\s+/u).filter(Boolean); - const outcome = parts.shift(); - if (!OUTCOMES.has(outcome)) +export function parseVerdict(rawInput) { + const value = rawInput.trim(); + if (!VERDICTS.has(value)) throw new TypeError(RESULT_USAGE); - const note = parts.join(' ').trim(); - return { - outcome: outcome, - ...note.length === 0 ? {} : { note }, - }; + return value; +} +export function parseJoinTarget(rawInput) { + const parts = rawInput.trim().split(/\s+/u).filter(Boolean); + if (parts.length !== 1 || parts[0] === undefined + || (parts[0] !== 'latest' && !UUID.test(parts[0]))) + throw new TypeError(JOIN_USAGE); + return parts[0]; } -export function retentionForOutcome(outcome) { - if (outcome === 'worked') - return 'keep'; - if (outcome === 'partial') - return 'unsure'; - return 'remove'; +export function parseReceiptId(value) { + if (!UUID.test(value)) + throw new TypeError(RETEST_USAGE); + return value; } //# sourceMappingURL=input.js.map \ No newline at end of file diff --git a/dist/input.js.map b/dist/input.js.map index 96f3c9a..9ccaa6c 100644 --- a/dist/input.js.map +++ b/dist/input.js.map @@ -1 +1 @@ -{"version":3,"file":"input.js","sourceRoot":"","sources":["../src/input.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"input.js","sourceRoot":"","sources":["../src/input.ts"],"names":[],"mappings":"AAMA,MAAM,MAAM,GAAG,qDAAqD,CAAA;AACpE,MAAM,OAAO,GAAG,qCAAqC,CAAA;AACrD,MAAM,IAAI,GAAG,yEAAyE,CAAA;AACtF,MAAM,QAAQ,GAAG,IAAI,GAAG,CAAoB,CAAC,MAAM,EAAE,OAAO,EAAE,KAAK,CAAC,CAAC,CAAA;AAErE,MAAM,CAAC,MAAM,WAAW,GAAG,oDAAoD,CAAA;AAC/E,MAAM,CAAC,MAAM,YAAY,GAAG,uCAAuC,CAAA;AACnE,MAAM,CAAC,MAAM,UAAU,GAAG,sCAAsC,CAAA;AAChE,MAAM,CAAC,MAAM,YAAY,GAAG,kEAAkE,CAAA;AAE9F,MAAM,UAAU,eAAe,CAAC,QAAgB;IAC9C,MAAM,KAAK,GAAG,QAAQ,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAA;IAC3D,MAAM,UAAU,GAAG,KAAK,CAAC,CAAC,CAAC,CAAA;IAC3B,IAAI,UAAU,KAAK,SAAS,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;QAAE,MAAM,IAAI,SAAS,CAAC,WAAW,CAAC,CAAA;IACpF,MAAM,IAAI,GAAG,UAAU,CAAC,WAAW,CAAC,GAAG,CAAC,CAAA;IACxC,MAAM,UAAU,GAAG,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,UAAU,CAAA;IACpE,MAAM,OAAO,GAAG,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,KAAK,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAA;IACjE,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,OAAO,KAAK,SAAS,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,EAAE,CAAC;QAClF,MAAM,IAAI,SAAS,CAAC,WAAW,CAAC,CAAA;IAClC,CAAC;IACD,OAAO,EAAE,MAAM,EAAE,EAAE,UAAU,EAAE,GAAG,OAAO,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,EAAE,EAAE,CAAA;AAChF,CAAC;AAED,MAAM,UAAU,YAAY,CAAC,QAAgB;IAC3C,MAAM,KAAK,GAAG,QAAQ,CAAC,IAAI,EAAE,CAAA;IAC7B,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,KAA0B,CAAC;QAAE,MAAM,IAAI,SAAS,CAAC,YAAY,CAAC,CAAA;IAChF,OAAO,KAA0B,CAAA;AACnC,CAAC;AAED,MAAM,UAAU,eAAe,CAAC,QAAgB;IAC9C,MAAM,KAAK,GAAG,QAAQ,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAA;IAC3D,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,IAAI,KAAK,CAAC,CAAC,CAAC,KAAK,SAAS;WAC3C,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,QAAQ,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;QAAE,MAAM,IAAI,SAAS,CAAC,UAAU,CAAC,CAAA;IACrF,OAAO,KAAK,CAAC,CAAC,CAAC,CAAA;AACjB,CAAC;AAED,MAAM,UAAU,cAAc,CAAC,KAAa;IAC1C,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC;QAAE,MAAM,IAAI,SAAS,CAAC,YAAY,CAAC,CAAA;IACxD,OAAO,KAAK,CAAA;AACd,CAAC"} \ No newline at end of file diff --git a/dist/protocol.d.ts b/dist/protocol.d.ts index 549030d..39174cc 100644 --- a/dist/protocol.d.ts +++ b/dist/protocol.d.ts @@ -1,107 +1,59 @@ -/** Public, versioned protocol shared by the DSH plugin and an ingest backend. */ -export declare const EXPERIENCE_SCHEMA_VERSION: 1; -export type TrialOutcome = 'worked' | 'partial' | 'failed'; -export type RetentionIntent = 'keep' | 'unsure' | 'remove'; -export type LoaderHealth = 'active' | 'disabled' | 'failed' | 'loading' | 'missing' | 'pending' | 'unloading' | 'unknown'; +/** Closed, zero-content protocol shared by the DSH plugin and ingest backend. */ +export declare const FEEDBACK_SCHEMA_VERSION: 2; +export type HealthStatus = 'ok' | 'unavailable' | 'error' | 'unknown'; +export type ExperienceVerdict = 'good' | 'mixed' | 'bad'; export interface TrialPluginRef { + /** Public DSH marketplace/package identifier; never a local path or user label. */ readonly moduleName: string; readonly version?: string; } -/** Privacy-preserving process crash evidence; no raw message, stack, or absolute path. */ -export interface RuntimeCrashSignal { - readonly fingerprint: string; - readonly name: string; - readonly origin: 'uncaughtException' | 'unhandledRejection'; - readonly code?: string; - readonly frame?: string; -} -/** Synchronously persisted local crash journal entry. It is merged into a Trial after restart. */ -export interface LocalCrashRecord { - readonly crashId: string; - readonly trialId: string; - readonly occurredAt: number; - readonly crash: RuntimeCrashSignal; -} -export interface TrialMetrics { - readonly assistantMessages: number; - readonly turnsStarted: number; - readonly turnsCompleted: number; - readonly toolCalls: number; - readonly toolErrors: number; - readonly agentErrors: number; - readonly processCrashes: number; - readonly crashes?: readonly RuntimeCrashSignal[]; - readonly firstReplyMs?: number; - readonly lastTurnReason?: string; -} -export interface ExperienceEventV1 { - readonly schemaVersion: typeof EXPERIENCE_SCHEMA_VERSION; - readonly type: 'feedback.submitted'; +/** The only value exposed to the Agent-facing analysis tool. */ +export interface SafeExperienceAssessment { + readonly health: HealthStatus; + readonly experience: 'unknown'; + readonly userConfirmationRequired: true; +} +/** + * Exact upload envelope. It intentionally contains no timestamp, stable user or + * install identifier, task/session identifier, metrics, notes, error or log data. + */ +export interface FeedbackEventV2 { + readonly schemaVersion: typeof FEEDBACK_SCHEMA_VERSION; + readonly type: 'feedback.signal'; + /** Random per-report id used only for idempotency. */ readonly eventId: string; - readonly occurredAt: number; - /** Random identity scoped only to this plugin's data directory. */ - readonly participantId: string; - readonly trial: { - readonly id: string; - readonly plugin: TrialPluginRef; - readonly taskId?: string; - readonly startedAt: number; - readonly durationMs: number; - /** Receipt that invited this run, when the user is verifying a fix. */ - readonly retestOfReceiptId?: string; - }; - readonly environment: { - readonly dshVersion: string; - readonly nodeVersion: string; - readonly platform: string; - readonly arch: string; - readonly locale: string; - readonly profileLabel: string; - }; - readonly signals: TrialMetrics & { - readonly loaderHealth: LoaderHealth; - }; - readonly feedback: { - readonly outcome: TrialOutcome; - readonly retention: RetentionIntent; - readonly note?: string; - }; - readonly sharing: { - readonly transcript: 'none'; - readonly noteIncluded: boolean; - }; -} -export interface LocalExperienceRecord { - readonly event: ExperienceEventV1; + readonly plugin: TrialPluginRef; + readonly health: HealthStatus; + readonly experience: ExperienceVerdict; + readonly source: 'user_confirmed'; + /** Optional, report-scoped link used only when the user explicitly starts a retest. */ + readonly retestOfReceiptId?: string; +} +export interface LocalFeedbackRecord { + readonly event: FeedbackEventV2; readonly requestedShare: boolean; - readonly shareNote: boolean; } +export type ReceiptStatus = 'received' | 'clustered' | 'reported' | 'fix-released' | 'retest-requested' | 'verified' | 'confirmed' | 'closed'; export interface IngestReceipt { readonly eventId: string; - readonly receivedAt: number; readonly receiptId?: string; + readonly caseId?: string; readonly status?: ReceiptStatus; readonly similarReports?: number; - readonly message?: string; readonly recommendedVersion?: string; readonly trackingUrl?: string; - readonly caseId?: string; - /** Random bearer secret. It stays in the local private receipt store. */ + /** Random per-report bearer secret; it stays in the local receipt store. */ readonly followToken?: string; readonly updatesUrl?: string; readonly updatedAt?: number; } -export type ReceiptStatus = 'received' | 'clustered' | 'confirmed' | 'reported' | 'fix-released' | 'retest-requested' | 'verified' | 'closed'; export interface ShareRequest { readonly eventId: string; - readonly requestedAt: number; - readonly shareNote: boolean; } export interface ReceiptSeen { readonly receiptId: string; readonly status?: ReceiptStatus; readonly updatedAt?: number; - readonly seenAt: number; } -/** Remove the local-only note unless the user explicitly chose --share-note. */ -export declare function uploadPayload(record: LocalExperienceRecord): ExperienceEventV1; +/** The local record is already the exact network packet; no projection can add fields. */ +export declare function uploadPayload(record: LocalFeedbackRecord): FeedbackEventV2; diff --git a/dist/protocol.js b/dist/protocol.js index cdec3a9..65797c8 100644 --- a/dist/protocol.js +++ b/dist/protocol.js @@ -1,14 +1,7 @@ -/** Public, versioned protocol shared by the DSH plugin and an ingest backend. */ -export const EXPERIENCE_SCHEMA_VERSION = 1; -/** Remove the local-only note unless the user explicitly chose --share-note. */ +/** Closed, zero-content protocol shared by the DSH plugin and ingest backend. */ +export const FEEDBACK_SCHEMA_VERSION = 2; +/** The local record is already the exact network packet; no projection can add fields. */ export function uploadPayload(record) { - if (record.shareNote || record.event.feedback.note === undefined) - return record.event; - const { note: _localOnlyNote, ...feedback } = record.event.feedback; - return { - ...record.event, - feedback, - sharing: { transcript: 'none', noteIncluded: false }, - }; + return record.event; } //# sourceMappingURL=protocol.js.map \ No newline at end of file diff --git a/dist/protocol.js.map b/dist/protocol.js.map index 67efed7..4459289 100644 --- a/dist/protocol.js.map +++ b/dist/protocol.js.map @@ -1 +1 @@ -{"version":3,"file":"protocol.js","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"AAAA,iFAAiF;AAEjF,MAAM,CAAC,MAAM,yBAAyB,GAAG,CAAU,CAAA;AAoInD,gFAAgF;AAChF,MAAM,UAAU,aAAa,CAAC,MAA6B;IACzD,IAAI,MAAM,CAAC,SAAS,IAAI,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,IAAI,KAAK,SAAS;QAAE,OAAO,MAAM,CAAC,KAAK,CAAA;IACrF,MAAM,EAAE,IAAI,EAAE,cAAc,EAAE,GAAG,QAAQ,EAAE,GAAG,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAA;IACnE,OAAO;QACL,GAAG,MAAM,CAAC,KAAK;QACf,QAAQ;QACR,OAAO,EAAE,EAAE,UAAU,EAAE,MAAM,EAAE,YAAY,EAAE,KAAK,EAAE;KACrD,CAAA;AACH,CAAC"} \ No newline at end of file +{"version":3,"file":"protocol.js","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"AAAA,iFAAiF;AAEjF,MAAM,CAAC,MAAM,uBAAuB,GAAG,CAAU,CAAA;AA0EjD,0FAA0F;AAC1F,MAAM,UAAU,aAAa,CAAC,MAA2B;IACvD,OAAO,MAAM,CAAC,KAAK,CAAA;AACrB,CAAC"} \ No newline at end of file diff --git a/dist/storage.d.ts b/dist/storage.d.ts index 93048b6..42b96a5 100644 --- a/dist/storage.d.ts +++ b/dist/storage.d.ts @@ -1,28 +1,22 @@ -import type { IngestReceipt, LocalCrashRecord, LocalExperienceRecord } from './protocol.js'; +import type { IngestReceipt, LocalFeedbackRecord } from './protocol.js'; export declare function defaultDataDir(): string; -export declare class ExperienceStore { +/** Local outbox for the same closed packet sent over the wire. It stores no logs or identifiers. */ +export declare class FeedbackStore { readonly dataDir: string; readonly eventsPath: string; - readonly crashesPath: string; readonly receiptsPath: string; readonly shareRequestsPath: string; readonly receiptSeenPath: string; - private readonly identityPath; constructor(dataDir?: string); - participantId(): string; - resetParticipantId(): string; - append(record: LocalExperienceRecord): void; - /** This deliberately uses synchronous append: the process may exit immediately afterward. */ - appendCrash(record: LocalCrashRecord): void; - crashRecords(trialId?: string): LocalCrashRecord[]; + append(record: LocalFeedbackRecord): void; appendReceipt(receipt: IngestReceipt): void; - requestShare(eventId: string, shareNote?: boolean): void; + requestShare(eventId: string): void; markSeen(receipt: IngestReceipt): void; - records(): LocalExperienceRecord[]; + records(): LocalFeedbackRecord[]; receipts(): IngestReceipt[]; - record(eventId: string): LocalExperienceRecord | undefined; - latestLocalRecord(): LocalExperienceRecord | undefined; + record(eventId: string): LocalFeedbackRecord | undefined; + latestLocalRecord(): LocalFeedbackRecord | undefined; latestReceipts(): IngestReceipt[]; unreadReceipts(): IngestReceipt[]; - pending(): LocalExperienceRecord[]; + pending(): LocalFeedbackRecord[]; } diff --git a/dist/storage.js b/dist/storage.js index 4836da5..07e8475 100644 --- a/dist/storage.js +++ b/dist/storage.js @@ -1,7 +1,6 @@ -import { appendFileSync, chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { appendFileSync, chmodSync, mkdirSync, readFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { isAbsolute, join } from 'node:path'; -const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu; function readLines(path) { let text; try { @@ -33,63 +32,34 @@ export function defaultDataDir() { const dshHome = process.env.DSH_HOME ?? join(homedir(), '.dsh'); return join(dshHome, 'omdsh-plugin-lab'); } -export class ExperienceStore { +/** Local outbox for the same closed packet sent over the wire. It stores no logs or identifiers. */ +export class FeedbackStore { dataDir; eventsPath; - crashesPath; receiptsPath; shareRequestsPath; receiptSeenPath; - identityPath; constructor(dataDir = defaultDataDir()) { if (!isAbsolute(dataDir)) throw new TypeError('plugin-lab: dataDir must be an absolute path'); this.dataDir = dataDir; - this.eventsPath = join(dataDir, 'events.ndjson'); - this.crashesPath = join(dataDir, 'crashes.ndjson'); - this.receiptsPath = join(dataDir, 'receipts.ndjson'); - this.shareRequestsPath = join(dataDir, 'share-requests.ndjson'); - this.receiptSeenPath = join(dataDir, 'receipt-seen.ndjson'); - this.identityPath = join(dataDir, '.install-id'); + this.eventsPath = join(dataDir, 'feedback-v2.ndjson'); + this.receiptsPath = join(dataDir, 'receipts-v2.ndjson'); + this.shareRequestsPath = join(dataDir, 'share-requests-v2.ndjson'); + this.receiptSeenPath = join(dataDir, 'receipt-seen-v2.ndjson'); mkdirSync(dataDir, { recursive: true, mode: 0o700 }); chmodSync(dataDir, 0o700); } - participantId() { - try { - const existing = readFileSync(this.identityPath, 'utf8').trim(); - if (UUID.test(existing)) - return existing; - } - catch (error) { - if (error.code !== 'ENOENT') - throw error; - } - return this.resetParticipantId(); - } - resetParticipantId() { - const id = crypto.randomUUID(); - writeFileSync(this.identityPath, `${id}\n`, { encoding: 'utf8', mode: 0o600 }); - chmodSync(this.identityPath, 0o600); - return id; - } append(record) { appendJson(this.eventsPath, record); } - /** This deliberately uses synchronous append: the process may exit immediately afterward. */ - appendCrash(record) { - appendJson(this.crashesPath, record); - } - crashRecords(trialId) { - const records = readLines(this.crashesPath); - return trialId === undefined ? records : records.filter(record => record.trialId === trialId); - } appendReceipt(receipt) { appendJson(this.receiptsPath, receipt); } - requestShare(eventId, shareNote = false) { + requestShare(eventId) { if (this.record(eventId) === undefined) - throw new Error(`unknown local event: ${eventId}`); - appendJson(this.shareRequestsPath, { eventId, requestedAt: Date.now(), shareNote }); + throw new Error('unknown local feedback event'); + appendJson(this.shareRequestsPath, { eventId }); } markSeen(receipt) { if (receipt.receiptId === undefined) @@ -98,7 +68,6 @@ export class ExperienceStore { receiptId: receipt.receiptId, ...receipt.status === undefined ? {} : { status: receipt.status }, ...receipt.updatedAt === undefined ? {} : { updatedAt: receipt.updatedAt }, - seenAt: Date.now(), }); } records() { @@ -134,20 +103,13 @@ export class ExperienceStore { } pending() { const delivered = new Set(this.receipts().map(receipt => receipt.eventId)); - const requests = new Map(); - for (const request of readLines(this.shareRequestsPath)) - requests.set(request.eventId, request); + const requests = new Set(readLines(this.shareRequestsPath).map(row => row.eventId)); return this.records().flatMap(record => { if (delivered.has(record.event.eventId)) return []; - const request = requests.get(record.event.eventId); - if (!record.requestedShare && request === undefined) + if (!record.requestedShare && !requests.has(record.event.eventId)) return []; - return [{ - ...record, - requestedShare: true, - shareNote: record.shareNote || request?.shareNote === true, - }]; + return [{ ...record, requestedShare: true }]; }); } } diff --git a/dist/storage.js.map b/dist/storage.js.map index 5f1021f..2cb28c8 100644 --- a/dist/storage.js.map +++ b/dist/storage.js.map @@ -1 +1 @@ -{"version":3,"file":"storage.js","sourceRoot":"","sources":["../src/storage.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"storage.js","sourceRoot":"","sources":["../src/storage.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,SAAS,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,SAAS,CAAA;AAC5E,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAA;AACjC,OAAO,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,WAAW,CAAA;AAK5C,SAAS,SAAS,CAAI,IAAY;IAChC,IAAI,IAAY,CAAA;IAChB,IAAI,CAAC;QACH,IAAI,GAAG,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAA;IACnC,CAAC;IAAC,OAAO,KAAc,EAAE,CAAC;QACxB,IAAK,KAA+B,CAAC,IAAI,KAAK,QAAQ;YAAE,OAAO,EAAE,CAAA;QACjE,MAAM,KAAK,CAAA;IACb,CAAC;IACD,MAAM,IAAI,GAAQ,EAAE,CAAA;IACpB,KAAK,MAAM,IAAI,IAAI,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;QACpC,IAAI,IAAI,CAAC,IAAI,EAAE,CAAC,MAAM,KAAK,CAAC;YAAE,SAAQ;QACtC,IAAI,CAAC;YACH,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAM,CAAC,CAAA;QAClC,CAAC;QAAC,MAAM,CAAC;YACP,yEAAyE;QAC3E,CAAC;IACH,CAAC;IACD,OAAO,IAAI,CAAA;AACb,CAAC;AAED,SAAS,UAAU,CAAC,IAAY,EAAE,KAAc;IAC9C,cAAc,CAAC,IAAI,EAAE,GAAG,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAA;IACrF,SAAS,CAAC,IAAI,EAAE,KAAK,CAAC,CAAA;AACxB,CAAC;AAED,MAAM,UAAU,cAAc;IAC5B,MAAM,OAAO,GAAG,OAAO,CAAC,GAAG,CAAC,QAAQ,IAAI,IAAI,CAAC,OAAO,EAAE,EAAE,MAAM,CAAC,CAAA;IAC/D,OAAO,IAAI,CAAC,OAAO,EAAE,kBAAkB,CAAC,CAAA;AAC1C,CAAC;AAED,oGAAoG;AACpG,MAAM,OAAO,aAAa;IACf,OAAO,CAAQ;IACf,UAAU,CAAQ;IAClB,YAAY,CAAQ;IACpB,iBAAiB,CAAQ;IACzB,eAAe,CAAQ;IAEhC,YAAY,OAAO,GAAG,cAAc,EAAE;QACpC,IAAI,CAAC,UAAU,CAAC,OAAO,CAAC;YAAE,MAAM,IAAI,SAAS,CAAC,8CAA8C,CAAC,CAAA;QAC7F,IAAI,CAAC,OAAO,GAAG,OAAO,CAAA;QACtB,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC,OAAO,EAAE,oBAAoB,CAAC,CAAA;QACrD,IAAI,CAAC,YAAY,GAAG,IAAI,CAAC,OAAO,EAAE,oBAAoB,CAAC,CAAA;QACvD,IAAI,CAAC,iBAAiB,GAAG,IAAI,CAAC,OAAO,EAAE,0BAA0B,CAAC,CAAA;QAClE,IAAI,CAAC,eAAe,GAAG,IAAI,CAAC,OAAO,EAAE,wBAAwB,CAAC,CAAA;QAC9D,SAAS,CAAC,OAAO,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAA;QACpD,SAAS,CAAC,OAAO,EAAE,KAAK,CAAC,CAAA;IAC3B,CAAC;IAED,MAAM,CAAC,MAA2B;QAChC,UAAU,CAAC,IAAI,CAAC,UAAU,EAAE,MAAM,CAAC,CAAA;IACrC,CAAC;IAED,aAAa,CAAC,OAAsB;QAClC,UAAU,CAAC,IAAI,CAAC,YAAY,EAAE,OAAO,CAAC,CAAA;IACxC,CAAC;IAED,YAAY,CAAC,OAAe;QAC1B,IAAI,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,SAAS;YAAE,MAAM,IAAI,KAAK,CAAC,8BAA8B,CAAC,CAAA;QACvF,UAAU,CAAC,IAAI,CAAC,iBAAiB,EAAE,EAAE,OAAO,EAAyB,CAAC,CAAA;IACxE,CAAC;IAED,QAAQ,CAAC,OAAsB;QAC7B,IAAI,OAAO,CAAC,SAAS,KAAK,SAAS;YAAE,OAAM;QAC3C,UAAU,CAAC,IAAI,CAAC,eAAe,EAAE;YAC/B,SAAS,EAAE,OAAO,CAAC,SAAS;YAC5B,GAAG,OAAO,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE;YACjE,GAAG,OAAO,CAAC,SAAS,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,OAAO,CAAC,SAAS,EAAE;SACrD,CAAC,CAAA;IAC1B,CAAC;IAED,OAAO;QACL,OAAO,SAAS,CAAsB,IAAI,CAAC,UAAU,CAAC,CAAA;IACxD,CAAC;IAED,QAAQ;QACN,OAAO,SAAS,CAAgB,IAAI,CAAC,YAAY,CAAC,CAAA;IACpD,CAAC;IAED,MAAM,CAAC,OAAe;QACpB,OAAO,IAAI,CAAC,OAAO,EAAE,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,CAAC,KAAK,CAAC,OAAO,KAAK,OAAO,CAAC,CAAA;IAC5E,CAAC;IAED,iBAAiB;QACf,OAAO,IAAI,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAA;IAC9B,CAAC;IAED,cAAc;QACZ,MAAM,MAAM,GAAG,IAAI,GAAG,EAAyB,CAAA;QAC/C,KAAK,MAAM,OAAO,IAAI,IAAI,CAAC,QAAQ,EAAE;YAAE,MAAM,CAAC,GAAG,CAAC,OAAO,CAAC,OAAO,EAAE,OAAO,CAAC,CAAA;QAC3E,OAAO,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC,CAAA;IAC7B,CAAC;IAED,cAAc;QACZ,MAAM,IAAI,GAAG,IAAI,GAAG,EAAuB,CAAA;QAC3C,KAAK,MAAM,GAAG,IAAI,SAAS,CAAc,IAAI,CAAC,eAAe,CAAC;YAAE,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,SAAS,EAAE,GAAG,CAAC,CAAA;QAC5F,OAAO,IAAI,CAAC,cAAc,EAAE,CAAC,MAAM,CAAC,OAAO,CAAC,EAAE;YAC5C,IAAI,OAAO,CAAC,SAAS,KAAK,SAAS;gBAAE,OAAO,KAAK,CAAA;YACjD,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,SAAS,CAAC,CAAA;YAC1C,OAAO,MAAM,KAAK,SAAS;mBACtB,MAAM,CAAC,MAAM,KAAK,OAAO,CAAC,MAAM;mBAChC,CAAC,MAAM,CAAC,SAAS,IAAI,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,SAAS,IAAI,CAAC,CAAC,CAAA;QACzD,CAAC,CAAC,CAAA;IACJ,CAAC;IAED,OAAO;QACL,MAAM,SAAS,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,CAAA;QAC1E,MAAM,QAAQ,GAAG,IAAI,GAAG,CAAC,SAAS,CAAe,IAAI,CAAC,iBAAiB,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAA;QACjG,OAAO,IAAI,CAAC,OAAO,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE;YACrC,IAAI,SAAS,CAAC,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC;gBAAE,OAAO,EAAE,CAAA;YAClD,IAAI,CAAC,MAAM,CAAC,cAAc,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC;gBAAE,OAAO,EAAE,CAAA;YAC5E,OAAO,CAAC,EAAE,GAAG,MAAM,EAAE,cAAc,EAAE,IAAI,EAAE,CAAC,CAAA;QAC9C,CAAC,CAAC,CAAA;IACJ,CAAC;CACF"} \ No newline at end of file diff --git a/dist/types/client/ExperienceResultCard.d.ts b/dist/types/client/ExperienceResultCard.d.ts index bb96b29..34e596a 100644 --- a/dist/types/client/ExperienceResultCard.d.ts +++ b/dist/types/client/ExperienceResultCard.d.ts @@ -1,13 +1,13 @@ import type { MessageId } from '@deepseek-ai/dsh-client-connection/client'; import type { ConversationNode } from '@deepseek-ai/dsh-client-runtime/client'; import type { InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'; -import type { TrialOutcome } from '../protocol.js'; +import type { ExperienceVerdict } from '../protocol.js'; import type { LabController } from './controller.js'; export interface LabInjected { hooks: { pluginLab: LabController; }; - record: (messageId: MessageId, outcome: TrialOutcome) => Promise; + record: (messageId: MessageId, verdict: ExperienceVerdict) => Promise; join: () => Promise; dismiss: () => void; } diff --git a/dist/types/client/ProbeButton.d.ts b/dist/types/client/ProbeButton.d.ts new file mode 100644 index 0000000..4156de4 --- /dev/null +++ b/dist/types/client/ProbeButton.d.ts @@ -0,0 +1,6 @@ +import type { InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'; +export interface ProbeInjected { + checkHealth: () => Promise; +} +export type ProbeButtonProps = PropsRuntime<'conversation.input.left'> & InjectFace; +export declare function ProbeButton({ checkHealth }: ProbeButtonProps): import("react").JSX.Element; diff --git a/dist/types/client/controller.d.ts b/dist/types/client/controller.d.ts index 9bec185..490b0ae 100644 --- a/dist/types/client/controller.d.ts +++ b/dist/types/client/controller.d.ts @@ -2,12 +2,12 @@ import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client'; import type { MessageId } from '@deepseek-ai/dsh-client-connection/client'; import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'; import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'; -import type { TrialOutcome } from '../protocol.js'; +import type { ExperienceVerdict } from '../protocol.js'; /** Exact rc.6 generated command Remote surface, kept narrow for testability. */ export type CommandsRemote = Pick; export interface PendingResult { readonly messageId: MessageId; - readonly outcome: TrialOutcome; + readonly verdict: ExperienceVerdict; readonly phase: 'saving' | 'local' | 'joining' | 'joined' | 'error'; readonly text?: string; } @@ -24,9 +24,10 @@ export declare class LabController implements HostObservable { getSnapshot: () => LabView; subscribe: (listener: () => void) => (() => void); setTrialActive(active: boolean): void; - record(messageId: MessageId, outcome: TrialOutcome): Promise; + record(messageId: MessageId, verdict: ExperienceVerdict): Promise; join(): Promise; inbox(): Promise; + probe(): Promise; dismiss(): void; private execute; private publish; diff --git a/dist/types/client/index.d.ts b/dist/types/client/index.d.ts index 151fd0c..d267da4 100644 --- a/dist/types/client/index.d.ts +++ b/dist/types/client/index.d.ts @@ -1,6 +1,7 @@ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'; export { ExperienceResultCard } from './ExperienceResultCard.js'; export { InboxButton } from './InboxButton.js'; +export { ProbeButton } from './ProbeButton.js'; export { LabController } from './controller.js'; export declare const inject: string[]; export declare function apply(ctx: ClientContext): void; diff --git a/dist/types/protocol.d.ts b/dist/types/protocol.d.ts index 549030d..39174cc 100644 --- a/dist/types/protocol.d.ts +++ b/dist/types/protocol.d.ts @@ -1,107 +1,59 @@ -/** Public, versioned protocol shared by the DSH plugin and an ingest backend. */ -export declare const EXPERIENCE_SCHEMA_VERSION: 1; -export type TrialOutcome = 'worked' | 'partial' | 'failed'; -export type RetentionIntent = 'keep' | 'unsure' | 'remove'; -export type LoaderHealth = 'active' | 'disabled' | 'failed' | 'loading' | 'missing' | 'pending' | 'unloading' | 'unknown'; +/** Closed, zero-content protocol shared by the DSH plugin and ingest backend. */ +export declare const FEEDBACK_SCHEMA_VERSION: 2; +export type HealthStatus = 'ok' | 'unavailable' | 'error' | 'unknown'; +export type ExperienceVerdict = 'good' | 'mixed' | 'bad'; export interface TrialPluginRef { + /** Public DSH marketplace/package identifier; never a local path or user label. */ readonly moduleName: string; readonly version?: string; } -/** Privacy-preserving process crash evidence; no raw message, stack, or absolute path. */ -export interface RuntimeCrashSignal { - readonly fingerprint: string; - readonly name: string; - readonly origin: 'uncaughtException' | 'unhandledRejection'; - readonly code?: string; - readonly frame?: string; -} -/** Synchronously persisted local crash journal entry. It is merged into a Trial after restart. */ -export interface LocalCrashRecord { - readonly crashId: string; - readonly trialId: string; - readonly occurredAt: number; - readonly crash: RuntimeCrashSignal; -} -export interface TrialMetrics { - readonly assistantMessages: number; - readonly turnsStarted: number; - readonly turnsCompleted: number; - readonly toolCalls: number; - readonly toolErrors: number; - readonly agentErrors: number; - readonly processCrashes: number; - readonly crashes?: readonly RuntimeCrashSignal[]; - readonly firstReplyMs?: number; - readonly lastTurnReason?: string; -} -export interface ExperienceEventV1 { - readonly schemaVersion: typeof EXPERIENCE_SCHEMA_VERSION; - readonly type: 'feedback.submitted'; +/** The only value exposed to the Agent-facing analysis tool. */ +export interface SafeExperienceAssessment { + readonly health: HealthStatus; + readonly experience: 'unknown'; + readonly userConfirmationRequired: true; +} +/** + * Exact upload envelope. It intentionally contains no timestamp, stable user or + * install identifier, task/session identifier, metrics, notes, error or log data. + */ +export interface FeedbackEventV2 { + readonly schemaVersion: typeof FEEDBACK_SCHEMA_VERSION; + readonly type: 'feedback.signal'; + /** Random per-report id used only for idempotency. */ readonly eventId: string; - readonly occurredAt: number; - /** Random identity scoped only to this plugin's data directory. */ - readonly participantId: string; - readonly trial: { - readonly id: string; - readonly plugin: TrialPluginRef; - readonly taskId?: string; - readonly startedAt: number; - readonly durationMs: number; - /** Receipt that invited this run, when the user is verifying a fix. */ - readonly retestOfReceiptId?: string; - }; - readonly environment: { - readonly dshVersion: string; - readonly nodeVersion: string; - readonly platform: string; - readonly arch: string; - readonly locale: string; - readonly profileLabel: string; - }; - readonly signals: TrialMetrics & { - readonly loaderHealth: LoaderHealth; - }; - readonly feedback: { - readonly outcome: TrialOutcome; - readonly retention: RetentionIntent; - readonly note?: string; - }; - readonly sharing: { - readonly transcript: 'none'; - readonly noteIncluded: boolean; - }; -} -export interface LocalExperienceRecord { - readonly event: ExperienceEventV1; + readonly plugin: TrialPluginRef; + readonly health: HealthStatus; + readonly experience: ExperienceVerdict; + readonly source: 'user_confirmed'; + /** Optional, report-scoped link used only when the user explicitly starts a retest. */ + readonly retestOfReceiptId?: string; +} +export interface LocalFeedbackRecord { + readonly event: FeedbackEventV2; readonly requestedShare: boolean; - readonly shareNote: boolean; } +export type ReceiptStatus = 'received' | 'clustered' | 'reported' | 'fix-released' | 'retest-requested' | 'verified' | 'confirmed' | 'closed'; export interface IngestReceipt { readonly eventId: string; - readonly receivedAt: number; readonly receiptId?: string; + readonly caseId?: string; readonly status?: ReceiptStatus; readonly similarReports?: number; - readonly message?: string; readonly recommendedVersion?: string; readonly trackingUrl?: string; - readonly caseId?: string; - /** Random bearer secret. It stays in the local private receipt store. */ + /** Random per-report bearer secret; it stays in the local receipt store. */ readonly followToken?: string; readonly updatesUrl?: string; readonly updatedAt?: number; } -export type ReceiptStatus = 'received' | 'clustered' | 'confirmed' | 'reported' | 'fix-released' | 'retest-requested' | 'verified' | 'closed'; export interface ShareRequest { readonly eventId: string; - readonly requestedAt: number; - readonly shareNote: boolean; } export interface ReceiptSeen { readonly receiptId: string; readonly status?: ReceiptStatus; readonly updatedAt?: number; - readonly seenAt: number; } -/** Remove the local-only note unless the user explicitly chose --share-note. */ -export declare function uploadPayload(record: LocalExperienceRecord): ExperienceEventV1; +/** The local record is already the exact network packet; no projection can add fields. */ +export declare function uploadPayload(record: LocalFeedbackRecord): FeedbackEventV2; diff --git a/dist/uploader.d.ts b/dist/uploader.d.ts index 4be9f0c..3d3af1c 100644 --- a/dist/uploader.d.ts +++ b/dist/uploader.d.ts @@ -1,5 +1,5 @@ import type { IngestReceipt } from './protocol.js'; -import type { ExperienceStore } from './storage.js'; +import type { FeedbackStore } from './storage.js'; export interface UploaderConfig { readonly ingestUrl: string; readonly authorizationEnv: string; @@ -8,7 +8,7 @@ export interface UploaderConfig { export declare class ExperienceUploader { private readonly store; private readonly config; - constructor(store: ExperienceStore, config: UploaderConfig); + constructor(store: FeedbackStore, config: UploaderConfig); flushPending(targetEventId?: string): Promise>; refreshReceipts(): Promise; private send; diff --git a/dist/uploader.js b/dist/uploader.js index b6246ea..2d6c0fb 100644 --- a/dist/uploader.js +++ b/dist/uploader.js @@ -5,7 +5,6 @@ function optionalString(value) { function parseReceipt(eventId, value) { const row = typeof value === 'object' && value !== null ? value : {}; const receiptId = optionalString(row.receiptId); - const message = optionalString(row.message); const recommendedVersion = optionalString(row.recommendedVersion); const trackingUrl = optionalString(row.trackingUrl); const caseId = optionalString(row.caseId); @@ -25,11 +24,9 @@ function parseReceipt(eventId, value) { : undefined; return { eventId, - receivedAt: Date.now(), ...receiptId === undefined ? {} : { receiptId }, ...status === undefined ? {} : { status }, ...similarReports === undefined ? {} : { similarReports }, - ...message === undefined ? {} : { message }, ...recommendedVersion === undefined ? {} : { recommendedVersion }, ...trackingUrl === undefined ? {} : { trackingUrl }, ...caseId === undefined ? {} : { caseId }, @@ -79,8 +76,7 @@ export class ExperienceUploader { }; if (merged.status !== previous.status || merged.updatedAt !== previous.updatedAt - || merged.recommendedVersion !== previous.recommendedVersion - || merged.message !== previous.message) { + || merged.recommendedVersion !== previous.recommendedVersion) { this.store.appendReceipt(merged); changed.push(merged); } @@ -103,7 +99,7 @@ export class ExperienceUploader { signal: AbortSignal.timeout(this.config.requestTimeoutMs), }); if (!response.ok) { - throw new Error(`ingest returned HTTP ${response.status}`); + throw new Error('feedback service did not accept the closed packet'); } const contentType = response.headers.get('content-type') ?? ''; const body = contentType.includes('application/json') ? await response.json() : undefined; diff --git a/dist/uploader.js.map b/dist/uploader.js.map index 3e44e1a..57cecee 100644 --- a/dist/uploader.js.map +++ b/dist/uploader.js.map @@ -1 +1 @@ -{"version":3,"file":"uploader.js","sourceRoot":"","sources":["../src/uploader.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"uploader.js","sourceRoot":"","sources":["../src/uploader.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/examples/mock-ingest.mjs b/examples/mock-ingest.mjs index 091d91b..72aad18 100644 --- a/examples/mock-ingest.mjs +++ b/examples/mock-ingest.mjs @@ -2,55 +2,53 @@ import { createServer } from 'node:http' const port = Number(process.env.PORT ?? 8787) const reports = new Map() +const allowed = new Set([ + 'schemaVersion', 'type', 'eventId', 'plugin', 'health', 'experience', 'source', 'retestOfReceiptId', +]) const server = createServer((request, response) => { if (request.method !== 'POST' || request.url !== '/v1/experience-events') { - response.writeHead(404, { 'content-type': 'application/json' }) - response.end(JSON.stringify({ error: 'not found' })) - return + return json(response, 404, { error: 'not found' }) } let raw = '' request.setEncoding('utf8') - request.on('data', chunk => { raw += chunk }) + request.on('data', chunk => { + raw += chunk + if (Buffer.byteLength(raw) > 1_024) request.destroy() + }) request.on('end', () => { try { const event = JSON.parse(raw) - if (event?.schemaVersion !== 1 || event?.type !== 'feedback.submitted') { - throw new TypeError('unsupported event') - } - const plugin = event.trial?.plugin?.moduleName - if (typeof plugin !== 'string' || plugin.length === 0) throw new TypeError('missing plugin') - const version = typeof event.trial.plugin.version === 'string' ? event.trial.plugin.version : 'unknown' - const key = `${plugin}#${version}` + if (event?.schemaVersion !== 2 || event?.type !== 'feedback.signal') throw new TypeError() + if (Object.keys(event).some(key => !allowed.has(key))) throw new TypeError() + if (event?.source !== 'user_confirmed') throw new TypeError() + const plugin = event?.plugin?.moduleName + if (typeof plugin !== 'string' || plugin.length === 0) throw new TypeError() + const version = typeof event.plugin.version === 'string' ? event.plugin.version : 'unknown' + const key = `${plugin}#${version}:${event.health}:${event.experience}` const similarReports = (reports.get(key) ?? 0) + 1 reports.set(key, similarReports) - const failed = event.feedback?.outcome === 'failed' - || Number(event.signals?.toolErrors ?? 0) > 0 - || event.signals?.loaderHealth === 'failed' - || event.signals?.loaderHealth === 'missing' - const receipt = failed - ? { - receiptId: `demo-${event.eventId}`, - status: 'clustered', - similarReports, - message: '演示诊断:这条反馈包含失败信号,建议固定同一任务和版本复测。', - } - : { - receiptId: `demo-${event.eventId}`, - status: 'received', - similarReports, - message: '演示诊断:已形成一条正向独立体验记录;建议用相似任务再验证一次。', - } - response.writeHead(200, { 'content-type': 'application/json' }) - response.end(JSON.stringify(receipt)) - process.stdout.write(`${JSON.stringify({ plugin, version, eventId: event.eventId, similarReports })}\n`) - } catch (error) { - response.writeHead(400, { 'content-type': 'application/json' }) - response.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) })) + return json(response, 200, { + receiptId: crypto.randomUUID(), + status: similarReports > 1 ? 'clustered' : 'received', + similarReports, + }) + } catch { + return json(response, 400, { error: 'invalid closed packet' }) } }) }) +function json(response, status, value) { + const body = JSON.stringify(value) + response.writeHead(status, { + 'content-type': 'application/json', + 'cache-control': 'no-store', + 'content-length': Buffer.byteLength(body), + }) + response.end(body) +} + server.listen(port, '127.0.0.1', () => { console.log(`Plugin Lab demo ingest listening on http://127.0.0.1:${port}/v1/experience-events`) }) diff --git a/package.json b/package.json index 6edc266..aafc23e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@oh-my-dsh/plugin-lab", - "version": "0.2.2", - "description": "DSH plugin trial result cards, anonymous issue receipts, fix notifications and retest flywheel", + "version": "0.3.0", + "description": "DSH zero-content plugin health, user-confirmed feedback and aggregate fix flywheel", "license": "MIT", "type": "module", "files": [ @@ -65,6 +65,7 @@ "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6", "@deepseek-ai/dsh-commands": "^0.1.0-rc.6", "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6", "react": "^18.2.0" }, "peerDependenciesMeta": { @@ -77,6 +78,7 @@ "@deepseek-ai/dsh-client-ui-slots": { "optional": true }, "@deepseek-ai/dsh-commands": { "optional": true }, "@deepseek-ai/dsh-session": { "optional": true }, + "@deepseek-ai/dsh-tools": { "optional": true }, "react": { "optional": true } }, "dependencies": { @@ -100,6 +102,7 @@ "@deepseek-ai/dsh-client-ui-slots": "0.1.0-rc.6", "@deepseek-ai/dsh-commands": "0.1.0-rc.6", "@deepseek-ai/dsh-session": "0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "0.1.0-rc.6", "@testing-library/react": "^16.3.2", "@types/node": "^24.0.0", "@types/pg": "^8.15.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee966c5..3bc3533 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,9 @@ importers: '@deepseek-ai/dsh-session': specifier: 0.1.0-rc.6 version: 0.1.0-rc.6(6fd26f59436a18b115f326d6060415e6) + '@deepseek-ai/dsh-tools': + specifier: 0.1.0-rc.6 + version: 0.1.0-rc.6(f8724372086ccc1457fc84e7becee2e0) '@testing-library/react': specifier: ^16.3.2 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) diff --git a/scripts/plugin-e2e.mjs b/scripts/plugin-e2e.mjs index d23f256..6afda1c 100644 --- a/scripts/plugin-e2e.mjs +++ b/scripts/plugin-e2e.mjs @@ -44,6 +44,10 @@ if (installedManifest.dsh?.client?.platform !== 'web') { throw new Error('installed package does not declare a Web dsh.client') } assertClientRegistration(readFileSync(join(installedRoot, 'dist', 'client.js'), 'utf8')) +assertStrictHostArtifact( + readFileSync(join(installedRoot, 'dist', 'index.js'), 'utf8'), + readFileSync(join(installedRoot, 'dist', 'agent-tool.js'), 'utf8'), +) const dumped = run(dsh, ['--profile', profile, '--dump-config'], project, env) if (!dumped.includes('omdsh-plugin-lab') || !dumped.includes('@oh-my-dsh/plugin-lab')) { throw new Error(`dumped config does not contain Plugin Lab\n${dumped}`) @@ -81,6 +85,18 @@ function assertClientRegistration(source) { } } +function assertStrictHostArtifact(indexSource, toolSource) { + const source = `${indexSource}\n${toolSource}` + if (!toolSource.includes('omdsh_analyze_plugin_experience')) { + throw new Error('installed Host artifact omitted the Agent safe assessment tool') + } + for (const forbidden of ['uncaughtExceptionMonitor', 'participantId', 'crashes.ndjson', 'shareNote']) { + if (source.includes(forbidden)) { + throw new Error(`installed Host artifact retained forbidden v1 path: ${forbidden}`) + } + } +} + function run(command, args, cwd, commandEnv = process.env) { const result = spawnSync(command, args, { cwd, diff --git a/server/.env.example b/server/.env.example index cdfb508..8764e42 100644 --- a/server/.env.example +++ b/server/.env.example @@ -1,10 +1,9 @@ DATABASE_URL=postgres://plugin_lab:plugin_lab@127.0.0.1:5432/plugin_lab PUBLIC_BASE_URL=http://127.0.0.1:8787 -PRIVACY_HASH_SECRET=replace-with-at-least-16-random-characters FOLLOW_SECRET=replace-with-a-different-random-secret ADMIN_TOKEN=replace-with-an-admin-bearer-token # INGEST_TOKEN=optional-token-configured-in-the-DSH-profile # GITHUB_TOKEN=token-with-issues-write-permission # GITHUB_REPOSITORY=omdsh-dev/omdsh-plugin-lab -GITHUB_REPORT_THRESHOLD=3 +GITHUB_REPORT_THRESHOLD=5 PORT=8787 diff --git a/server/migrations/001_initial.sql b/server/migrations/001_initial.sql index dc743ab..35620e2 100644 --- a/server/migrations/001_initial.sql +++ b/server/migrations/001_initial.sql @@ -1,53 +1,4 @@ -CREATE TABLE IF NOT EXISTS issue_clusters ( - id uuid PRIMARY KEY, - cluster_key text UNIQUE NOT NULL, - plugin_module text NOT NULL, - plugin_version text, - task_id text, - symptom text NOT NULL, - status text NOT NULL DEFAULT 'received', - similar_reports integer NOT NULL DEFAULT 0, - github_issue_url text, - recommended_version text, - message text, - updated_at bigint NOT NULL -); - -CREATE TABLE IF NOT EXISTS experience_events ( - event_id uuid PRIMARY KEY, - participant_hash text NOT NULL, - trial_id text NOT NULL, - plugin_module text NOT NULL, - plugin_version text, - task_id text, - retest_of_receipt_id text, - dsh_version text NOT NULL, - outcome text NOT NULL, - retention text NOT NULL, - loader_health text NOT NULL, - assistant_messages integer NOT NULL, - tool_errors integer NOT NULL, - agent_errors integer NOT NULL, - process_crashes integer NOT NULL DEFAULT 0, - crash_signatures jsonb NOT NULL DEFAULT '[]'::jsonb, - duration_ms bigint NOT NULL, - first_reply_ms bigint, - note text, - note_expires_at bigint, - cluster_key text NOT NULL REFERENCES issue_clusters(cluster_key), - occurred_at bigint NOT NULL, - created_at bigint NOT NULL -); - -CREATE INDEX IF NOT EXISTS experience_events_plugin_idx - ON experience_events(plugin_module, occurred_at DESC); -CREATE INDEX IF NOT EXISTS experience_events_cluster_idx - ON experience_events(cluster_key); -CREATE INDEX IF NOT EXISTS experience_events_note_expiry_idx - ON experience_events(note_expires_at) WHERE note_expires_at IS NOT NULL; - -CREATE TABLE IF NOT EXISTS follow_receipts ( - receipt_id uuid PRIMARY KEY, - event_id uuid UNIQUE NOT NULL REFERENCES experience_events(event_id), - created_at bigint NOT NULL -); +-- Historical v1 bootstrap intentionally retired. Existing installations keep +-- their old tables untouched; new installations create only strict v2 tables +-- in 003_zero_content_feedback.sql. +SELECT 1; diff --git a/server/migrations/002_crash_signals.sql b/server/migrations/002_crash_signals.sql index ac13689..a5d3aca 100644 --- a/server/migrations/002_crash_signals.sql +++ b/server/migrations/002_crash_signals.sql @@ -1,5 +1,3 @@ -ALTER TABLE experience_events - ADD COLUMN IF NOT EXISTS process_crashes integer NOT NULL DEFAULT 0; - -ALTER TABLE experience_events - ADD COLUMN IF NOT EXISTS crash_signatures jsonb NOT NULL DEFAULT '[]'::jsonb; +-- Historical crash-signal migration intentionally retired. Strict v2 never +-- creates, reads or writes crash details or log-derived data. +SELECT 1; diff --git a/server/migrations/003_zero_content_feedback.sql b/server/migrations/003_zero_content_feedback.sql new file mode 100644 index 0000000..eb1b03f --- /dev/null +++ b/server/migrations/003_zero_content_feedback.sql @@ -0,0 +1,40 @@ +-- Strict v2 lives beside historical v1 tables so upgrades never reinterpret or +-- silently upload old, richer records. The v2 schema has no content, identity, +-- client timestamp, environment, task, metrics, crash or free-text columns. +CREATE TABLE IF NOT EXISTS feedback_clusters_v2 ( + id uuid PRIMARY KEY, + cluster_key text UNIQUE NOT NULL, + plugin_module text NOT NULL, + plugin_version text, + health text NOT NULL CHECK (health IN ('ok', 'unavailable', 'error', 'unknown')), + experience text NOT NULL CHECK (experience IN ('good', 'mixed', 'bad')), + symptom text NOT NULL, + status text NOT NULL DEFAULT 'received', + report_count integer NOT NULL DEFAULT 0, + github_issue_url text, + recommended_version text, + updated_at bigint NOT NULL +); + +CREATE TABLE IF NOT EXISTS feedback_events_v2 ( + event_id uuid PRIMARY KEY, + plugin_module text NOT NULL, + plugin_version text, + health text NOT NULL CHECK (health IN ('ok', 'unavailable', 'error', 'unknown')), + experience text NOT NULL CHECK (experience IN ('good', 'mixed', 'bad')), + source text NOT NULL CHECK (source = 'user_confirmed'), + retest_of_receipt_id uuid, + cluster_key text NOT NULL REFERENCES feedback_clusters_v2(cluster_key), + created_at bigint NOT NULL +); + +CREATE INDEX IF NOT EXISTS feedback_events_v2_plugin_idx + ON feedback_events_v2(plugin_module, created_at DESC); +CREATE INDEX IF NOT EXISTS feedback_events_v2_cluster_idx + ON feedback_events_v2(cluster_key); + +CREATE TABLE IF NOT EXISTS follow_receipts_v2 ( + receipt_id uuid PRIMARY KEY, + event_id uuid UNIQUE NOT NULL REFERENCES feedback_events_v2(event_id), + created_at bigint NOT NULL +); diff --git a/server/src/config.ts b/server/src/config.ts index f4459b0..d4140b4 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -1,6 +1,5 @@ export interface RuntimeConfig { readonly databaseUrl: string - readonly privacyHashSecret: string readonly followSecret: string readonly publicBaseUrl: string readonly adminToken: string @@ -23,12 +22,11 @@ export function loadConfig(): RuntimeConfig { if (databaseUrl === undefined || databaseUrl.length === 0) throw new Error('DATABASE_URL is required') if (publicBaseUrl === undefined || !/^https?:\/\//u.test(publicBaseUrl)) throw new Error('PUBLIC_BASE_URL is required') const port = Number(process.env.PORT ?? 8787) - const githubThreshold = Number(process.env.GITHUB_REPORT_THRESHOLD ?? 3) + const githubThreshold = Number(process.env.GITHUB_REPORT_THRESHOLD ?? 5) if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) throw new Error('PORT is invalid') if (!Number.isSafeInteger(githubThreshold) || githubThreshold < 1) throw new Error('GITHUB_REPORT_THRESHOLD is invalid') return { databaseUrl, - privacyHashSecret: required('PRIVACY_HASH_SECRET'), followSecret: required('FOLLOW_SECRET'), publicBaseUrl, adminToken: required('ADMIN_TOKEN'), diff --git a/server/src/fingerprint.ts b/server/src/fingerprint.ts index 68352a2..181263b 100644 --- a/server/src/fingerprint.ts +++ b/server/src/fingerprint.ts @@ -2,31 +2,17 @@ import { createHash, createHmac } from 'node:crypto' import type { AcceptedEvent } from './types.js' export function symptomFor(event: AcceptedEvent): string { - if (event.loaderHealth === 'failed' || event.loaderHealth === 'missing') return `loader-${event.loaderHealth}` - if (event.processCrashes > 0) { - const crash = event.crashes[0] - return crash === undefined - ? 'runtime-crash' - : ['runtime-crash', crash.name, crash.code ?? '-', crash.frame ?? '-', crash.fingerprint].join(':') - } - if (event.agentErrors > 0) return 'agent-error' - if (event.toolErrors > 0) return 'tool-error' - return `outcome-${event.outcome}` + if (event.health !== 'ok') return `health-${event.health}` + return `experience-${event.experience}` } export function clusterKey(event: AcceptedEvent): string { - const parts = [ + return createHash('sha256').update([ event.pluginModule, event.pluginVersion ?? '*', - event.dshVersion, - event.taskId ?? '*', - symptomFor(event), - ] - return createHash('sha256').update(parts.join('\u001f')).digest('hex') -} - -export function hashParticipant(secret: string, participantId: string): string { - return createHmac('sha256', secret).update(participantId).digest('hex') + event.health, + event.experience, + ].join('\u001f')).digest('hex') } export function followToken(secret: string, eventId: string): string { diff --git a/server/src/github.ts b/server/src/github.ts index b6fc2db..4b61100 100644 --- a/server/src/github.ts +++ b/server/src/github.ts @@ -19,12 +19,11 @@ export class GitHubIssuePublisher implements IssuePublisher { '', `- 插件:\`${cluster.pluginModule}\``, `- 版本:\`${cluster.pluginVersion ?? 'unknown'}\``, - `- 任务:\`${cluster.taskId ?? 'unclassified'}\``, - `- 症状:\`${cluster.symptom}\``, - `- 独立安装报告:${cluster.similarReports}`, - `- 聚类 ID:\`${cluster.id}\``, + `- 运行状态:\`${cluster.health}\``, + `- 用户确认体验:\`${cluster.experience}\``, + `- 聚合报告数:${cluster.similarReports}`, '', - '此 Issue 只包含聚合后的结构化信号,不包含 Prompt、回复正文、Tool 参数或用户路径。', + '此 Issue 只包含达到阈值后的有限枚举聚合,不包含单条报告、日志、会话、环境、时间或身份字段。', ].join('\n') const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/issues`, { method: 'POST', diff --git a/server/src/http.ts b/server/src/http.ts index b808ce1..1a8ff39 100644 --- a/server/src/http.ts +++ b/server/src/http.ts @@ -10,7 +10,7 @@ export function createHandler(service: FeedbackService, config: HttpConfig) { return async (request: IncomingMessage, response: ServerResponse): Promise => { try { const url = new URL(request.url ?? '/', 'http://localhost') - if (request.method === 'GET' && url.pathname === '/healthz') return json(response, 200, { ok: true }) + if (request.method === 'GET' && url.pathname === '/healthz') return json(response, 200, { status: 'ok' }) if (request.method === 'POST' && url.pathname === '/v1/experience-events') { if (config.ingestToken !== undefined && bearer(request) !== config.ingestToken) return json(response, 401, { error: 'unauthorized' }) return json(response, 200, await service.ingest(await body(request))) @@ -28,7 +28,6 @@ export function createHandler(service: FeedbackService, config: HttpConfig) { const input = await body(request) as Record const value = await service.release(release[1] ?? '', { recommendedVersion: typeof input.recommendedVersion === 'string' ? input.recommendedVersion : '', - message: typeof input.message === 'string' ? input.message : '', ...typeof input.trackingUrl === 'string' ? { trackingUrl: input.trackingUrl } : {}, }) return value === undefined ? json(response, 404, { error: 'cluster not found' }) : json(response, 200, value) @@ -40,7 +39,7 @@ export function createHandler(service: FeedbackService, config: HttpConfig) { return json(response, 404, { error: 'not found' }) } catch (error: unknown) { const status = error instanceof TypeError ? 400 : 500 - return json(response, status, { error: error instanceof Error ? error.message : String(error) }) + return json(response, status, { error: status === 400 ? 'invalid closed packet' : 'internal error' }) } } } @@ -51,7 +50,7 @@ async function body(request: IncomingMessage): Promise { for await (const chunk of request) { const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) size += buffer.length - if (size > 32 * 1_024) throw new TypeError('request body exceeds 32 KiB') + if (size > 1_024) throw new TypeError('request body exceeds 1 KiB') chunks.push(buffer) } try { diff --git a/server/src/index.ts b/server/src/index.ts index 8f10845..5b351eb 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -13,7 +13,6 @@ const publisher = config.githubToken !== undefined && config.githubRepository != ? new GitHubIssuePublisher(config.githubToken, config.githubRepository) : undefined const service = new FeedbackService(repository, { - privacyHashSecret: config.privacyHashSecret, followSecret: config.followSecret, publicBaseUrl: config.publicBaseUrl, githubThreshold: config.githubThreshold, diff --git a/server/src/memory.ts b/server/src/memory.ts index 26591a6..6a68e6e 100644 --- a/server/src/memory.ts +++ b/server/src/memory.ts @@ -4,10 +4,15 @@ import type { export class MemoryRepository implements ExperienceRepository { private readonly clusters = new Map() - private readonly events = new Map() + private readonly events = new Map() private readonly receipts = new Map() - async ingest(event: AcceptedEvent, participantHash: string, key: string, symptom: string): Promise { + async ingest(event: AcceptedEvent, key: string, symptom: string): Promise { const existing = this.events.get(event.eventId) if (existing !== undefined) { const receipt = await this.receipt(existing.receiptId) @@ -18,22 +23,26 @@ export class MemoryRepository implements ExperienceRepository { let cluster = this.clusters.get(key) if (cluster === undefined) { cluster = { - id: crypto.randomUUID(), clusterKey: key, pluginModule: event.pluginModule, + id: crypto.randomUUID(), + clusterKey: key, + pluginModule: event.pluginModule, ...event.pluginVersion === undefined ? {} : { pluginVersion: event.pluginVersion }, - ...event.taskId === undefined ? {} : { taskId: event.taskId }, - symptom, status: 'received', similarReports: 0, updatedAt: now, + health: event.health, + experience: event.experience, + symptom, + status: 'received', + similarReports: 0, + updatedAt: now, } } const receiptId = crypto.randomUUID() - this.events.set(event.eventId, { event, participantHash, receiptId, clusterKey: key }) + this.events.set(event.eventId, { event, receiptId, clusterKey: key, createdAt: now }) this.receipts.set(receiptId, event.eventId) - const reporters = new Set([...this.events.values()] - .filter(row => row.clusterKey === key) - .map(row => row.participantHash)) + const reports = [...this.events.values()].filter(row => row.clusterKey === key).length cluster = { ...cluster, - similarReports: reporters.size, - status: cluster.status === 'received' && reporters.size > 1 ? 'clustered' : cluster.status, + similarReports: reports, + status: cluster.status === 'received' && reports > 1 ? 'clustered' : cluster.status, updatedAt: now, } this.clusters.set(key, cluster) @@ -61,8 +70,10 @@ export class MemoryRepository implements ExperienceRepository { const cluster = [...this.clusters.values()].find(row => row.id === clusterId) if (cluster === undefined) return undefined const next: ClusterRecord = { - ...cluster, status: 'retest-requested', recommendedVersion: update.recommendedVersion, - message: update.message, ...update.trackingUrl === undefined ? {} : { githubIssueUrl: update.trackingUrl }, + ...cluster, + status: 'retest-requested', + recommendedVersion: update.recommendedVersion, + ...update.trackingUrl === undefined ? {} : { githubIssueUrl: update.trackingUrl }, updatedAt: Date.now(), } this.clusters.set(next.clusterKey, next) @@ -70,24 +81,26 @@ export class MemoryRepository implements ExperienceRepository { } async evidence(pluginModule: string, since: number): Promise { - const events = [...this.events.values()].map(row => row.event) - .filter(event => event.pluginModule === pluginModule && event.occurredAt >= since) + const rows = [...this.events.values()] + .filter(row => row.event.pluginModule === pluginModule && row.createdAt >= since) + const events = rows.map(row => row.event) return { - pluginModule, windowDays: 30, total: events.length, - worked: events.filter(event => event.outcome === 'worked').length, - partial: events.filter(event => event.outcome === 'partial').length, - failed: events.filter(event => event.outcome === 'failed').length, - ...events.length === 0 ? {} : { updatedAt: Math.max(...events.map(event => event.occurredAt)) }, + pluginModule, + windowDays: 30, + total: events.length, + good: events.filter(event => event.experience === 'good').length, + mixed: events.filter(event => event.experience === 'mixed').length, + bad: events.filter(event => event.experience === 'bad').length, + ...events.length === 0 ? {} : { updatedAt: Math.max(...rows.map(row => row.createdAt)) }, } } - async verifyRetest(receiptId: string, worked: boolean): Promise { + async verifyRetest(receiptId: string, successful: boolean): Promise { const original = await this.receipt(receiptId) if (original === undefined) return undefined const next: ClusterRecord = { ...original.cluster, - status: worked ? 'verified' : 'confirmed', - message: worked ? '已有用户使用原任务确认修复。' : '复测仍未通过,问题已重新确认。', + status: successful ? 'verified' : 'confirmed', updatedAt: Date.now(), } this.clusters.set(next.clusterKey, next) diff --git a/server/src/postgres.ts b/server/src/postgres.ts index 4f39322..98bfa2a 100644 --- a/server/src/postgres.ts +++ b/server/src/postgres.ts @@ -1,7 +1,7 @@ import type { Pool, PoolClient, QueryResultRow } from 'pg' import type { - AcceptedEvent, ClusterRecord, ClusterStatus, ExperienceRepository, Ingested, PluginEvidence, - ReleaseUpdate, StoredReceipt, + AcceptedEvent, ClusterRecord, ClusterStatus, ExperienceRepository, ExperienceVerdict, HealthStatus, + Ingested, PluginEvidence, ReleaseUpdate, StoredReceipt, } from './types.js' interface ClusterRow extends QueryResultRow { @@ -9,13 +9,13 @@ interface ClusterRow extends QueryResultRow { cluster_key: string plugin_module: string plugin_version: string | null - task_id: string | null + health: HealthStatus + experience: ExperienceVerdict symptom: string status: ClusterStatus - similar_reports: number + report_count: number github_issue_url: string | null recommended_version: string | null - message: string | null updated_at: string } @@ -25,13 +25,13 @@ function cluster(row: ClusterRow): ClusterRecord { clusterKey: row.cluster_key, pluginModule: row.plugin_module, ...row.plugin_version === null ? {} : { pluginVersion: row.plugin_version }, - ...row.task_id === null ? {} : { taskId: row.task_id }, + health: row.health, + experience: row.experience, symptom: row.symptom, status: row.status, - similarReports: row.similar_reports, + similarReports: row.report_count, ...row.github_issue_url === null ? {} : { githubIssueUrl: row.github_issue_url }, ...row.recommended_version === null ? {} : { recommendedVersion: row.recommended_version }, - ...row.message === null ? {} : { message: row.message }, updatedAt: Number(row.updated_at), } } @@ -39,7 +39,7 @@ function cluster(row: ClusterRow): ClusterRecord { export class PostgresRepository implements ExperienceRepository { constructor(private readonly pool: Pool) {} - async ingest(event: AcceptedEvent, participantHash: string, key: string, symptom: string): Promise { + async ingest(event: AcceptedEvent, key: string, symptom: string): Promise { const client = await this.pool.connect() try { await client.query('BEGIN') @@ -49,36 +49,24 @@ export class PostgresRepository implements ExperienceRepository { return { receipt: existing, created: false } } const now = Date.now() - await client.query( - 'UPDATE experience_events SET note = NULL, note_expires_at = NULL WHERE note_expires_at < $1', - [now], - ) const clusterId = crypto.randomUUID() await client.query( - `INSERT INTO issue_clusters - (id, cluster_key, plugin_module, plugin_version, task_id, symptom, status, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, 'received', $7) + `INSERT INTO feedback_clusters_v2 + (id, cluster_key, plugin_module, plugin_version, health, experience, symptom, status, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'received', $8) ON CONFLICT (cluster_key) DO NOTHING`, - [clusterId, key, event.pluginModule, event.pluginVersion ?? null, event.taskId ?? null, symptom, now], + [clusterId, key, event.pluginModule, event.pluginVersion ?? null, event.health, event.experience, symptom, now], ) const inserted = await client.query( - `INSERT INTO experience_events - (event_id, participant_hash, trial_id, plugin_module, plugin_version, task_id, - retest_of_receipt_id, dsh_version, outcome, retention, loader_health, - assistant_messages, tool_errors, agent_errors, process_crashes, crash_signatures, - duration_ms, first_reply_ms, - note, note_expires_at, cluster_key, occurred_at, created_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23) + `INSERT INTO feedback_events_v2 + (event_id, plugin_module, plugin_version, health, experience, source, + retest_of_receipt_id, cluster_key, created_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT (event_id) DO NOTHING RETURNING event_id`, [ - event.eventId, participantHash, event.trialId, event.pluginModule, event.pluginVersion ?? null, - event.taskId ?? null, event.retestOfReceiptId ?? null, event.dshVersion, event.outcome, - event.retention, event.loaderHealth, event.assistantMessages, event.toolErrors, - event.agentErrors, event.processCrashes, JSON.stringify(event.crashes), - event.durationMs, event.firstReplyMs ?? null, event.note ?? null, - event.note === undefined ? null : now + 30 * 24 * 60 * 60 * 1_000, - key, event.occurredAt, now, + event.eventId, event.pluginModule, event.pluginVersion ?? null, event.health, + event.experience, event.source, event.retestOfReceiptId ?? null, key, now, ], ) if (inserted.rowCount === 0) { @@ -89,21 +77,21 @@ export class PostgresRepository implements ExperienceRepository { } const receiptId = crypto.randomUUID() await client.query( - 'INSERT INTO follow_receipts (receipt_id, event_id, created_at) VALUES ($1, $2, $3)', + 'INSERT INTO follow_receipts_v2 (receipt_id, event_id, created_at) VALUES ($1, $2, $3)', [receiptId, event.eventId, now], ) const count = await client.query<{ count: string }>( - 'SELECT COUNT(DISTINCT participant_hash)::text AS count FROM experience_events WHERE cluster_key = $1', + 'SELECT COUNT(*)::text AS count FROM feedback_events_v2 WHERE cluster_key = $1', [key], ) - const reporters = Number(count.rows[0]?.count ?? 1) + const reports = Number(count.rows[0]?.count ?? 1) const updated = await client.query( - `UPDATE issue_clusters SET - similar_reports = $2, + `UPDATE feedback_clusters_v2 SET + report_count = $2, status = CASE WHEN status = 'received' AND $2 > 1 THEN 'clustered' ELSE status END, updated_at = $3 WHERE cluster_key = $1 RETURNING *`, - [key, reporters, now], + [key, reports, now], ) const row = updated.rows[0] if (row === undefined) throw new Error('cluster disappeared during ingest') @@ -120,9 +108,9 @@ export class PostgresRepository implements ExperienceRepository { async receipt(receiptId: string): Promise { const result = await this.pool.query( `SELECT c.*, r.event_id, r.receipt_id - FROM follow_receipts r - JOIN experience_events e ON e.event_id = r.event_id - JOIN issue_clusters c ON c.cluster_key = e.cluster_key + FROM follow_receipts_v2 r + JOIN feedback_events_v2 e ON e.event_id = r.event_id + JOIN feedback_clusters_v2 c ON c.cluster_key = e.cluster_key WHERE r.receipt_id = $1`, [receiptId], ) @@ -132,7 +120,7 @@ export class PostgresRepository implements ExperienceRepository { async markReported(clusterId: string, issueUrl: string): Promise { const result = await this.pool.query( - `UPDATE issue_clusters SET status = 'reported', github_issue_url = $2, updated_at = $3 + `UPDATE feedback_clusters_v2 SET status = 'reported', github_issue_url = $2, updated_at = $3 WHERE id = $1 RETURNING *`, [clusterId, issueUrl, Date.now()], ) @@ -143,10 +131,10 @@ export class PostgresRepository implements ExperienceRepository { async release(clusterId: string, update: ReleaseUpdate): Promise { const result = await this.pool.query( - `UPDATE issue_clusters SET status = 'retest-requested', recommended_version = $2, - message = $3, github_issue_url = COALESCE($4, github_issue_url), updated_at = $5 + `UPDATE feedback_clusters_v2 SET status = 'retest-requested', recommended_version = $2, + github_issue_url = COALESCE($3, github_issue_url), updated_at = $4 WHERE id = $1 RETURNING *`, - [clusterId, update.recommendedVersion, update.message, update.trackingUrl ?? null, Date.now()], + [clusterId, update.recommendedVersion, update.trackingUrl ?? null, Date.now()], ) const row = result.rows[0] return row === undefined ? undefined : cluster(row) @@ -154,41 +142,45 @@ export class PostgresRepository implements ExperienceRepository { async evidence(pluginModule: string, since: number): Promise { const result = await this.pool.query<{ - total: string; worked: string; partial: string; failed: string; updated_at: string | null; version: string | null + total: string + good: string + mixed: string + bad: string + updated_at: string | null + version: string | null }>( `SELECT COUNT(*)::text AS total, - COUNT(*) FILTER (WHERE outcome = 'worked')::text AS worked, - COUNT(*) FILTER (WHERE outcome = 'partial')::text AS partial, - COUNT(*) FILTER (WHERE outcome = 'failed')::text AS failed, - MAX(occurred_at)::text AS updated_at, - MAX(plugin_version) FILTER (WHERE outcome = 'worked') AS version - FROM experience_events WHERE plugin_module = $1 AND occurred_at >= $2`, + SUM(CASE WHEN experience = 'good' THEN 1 ELSE 0 END)::text AS good, + SUM(CASE WHEN experience = 'mixed' THEN 1 ELSE 0 END)::text AS mixed, + SUM(CASE WHEN experience = 'bad' THEN 1 ELSE 0 END)::text AS bad, + MAX(created_at)::text AS updated_at, + MAX(plugin_version) FILTER (WHERE experience = 'good') AS version + FROM feedback_events_v2 WHERE plugin_module = $1 AND created_at >= $2`, [pluginModule, since], ) const row = result.rows[0] return { - pluginModule, windowDays: 30, total: Number(row?.total ?? 0), worked: Number(row?.worked ?? 0), - partial: Number(row?.partial ?? 0), failed: Number(row?.failed ?? 0), + pluginModule, + windowDays: 30, + total: Number(row?.total ?? 0), + good: Number(row?.good ?? 0), + mixed: Number(row?.mixed ?? 0), + bad: Number(row?.bad ?? 0), ...row?.version == null ? {} : { latestVerifiedVersion: row.version }, ...row?.updated_at == null ? {} : { updatedAt: Number(row.updated_at) }, } } - async verifyRetest(receiptId: string, worked: boolean): Promise { + async verifyRetest(receiptId: string, successful: boolean): Promise { const result = await this.pool.query( - `UPDATE issue_clusters SET status = $2, message = $3, updated_at = $4 + `UPDATE feedback_clusters_v2 SET status = $2, updated_at = $3 WHERE cluster_key = ( - SELECT e.cluster_key FROM follow_receipts r - JOIN experience_events e ON e.event_id = r.event_id + SELECT e.cluster_key FROM follow_receipts_v2 r + JOIN feedback_events_v2 e ON e.event_id = r.event_id WHERE r.receipt_id = $1 ) RETURNING *`, - [ - receiptId, - worked ? 'verified' : 'confirmed', - worked ? '已有用户使用原任务确认修复。' : '复测仍未通过,问题已重新确认。', - Date.now(), - ], + [receiptId, successful ? 'verified' : 'confirmed', Date.now()], ) const row = result.rows[0] return row === undefined ? undefined : cluster(row) @@ -197,9 +189,9 @@ export class PostgresRepository implements ExperienceRepository { private async receiptForEvent(client: PoolClient, eventId: string): Promise { const result = await client.query( `SELECT c.*, r.event_id, r.receipt_id - FROM follow_receipts r - JOIN experience_events e ON e.event_id = r.event_id - JOIN issue_clusters c ON c.cluster_key = e.cluster_key + FROM follow_receipts_v2 r + JOIN feedback_events_v2 e ON e.event_id = r.event_id + JOIN feedback_clusters_v2 c ON c.cluster_key = e.cluster_key WHERE r.event_id = $1`, [eventId], ) diff --git a/server/src/service.ts b/server/src/service.ts index 814d962..68f4251 100644 --- a/server/src/service.ts +++ b/server/src/service.ts @@ -1,13 +1,12 @@ import { timingSafeEqual } from 'node:crypto' import { acceptEvent } from './validation.js' -import { clusterKey, followToken, hashParticipant, symptomFor } from './fingerprint.js' +import { clusterKey, followToken, symptomFor } from './fingerprint.js' import type { IssuePublisher } from './github.js' import type { ClusterRecord, ExperienceRepository, PluginEvidence, ReleaseUpdate, StoredReceipt, } from './types.js' export interface ServiceConfig { - readonly privacyHashSecret: string readonly followSecret: string readonly publicBaseUrl: string readonly githubThreshold: number @@ -19,7 +18,6 @@ export interface ReceiptResponse { readonly eventId: string readonly status: ClusterRecord['status'] readonly similarReports: number - readonly message: string readonly recommendedVersion?: string readonly trackingUrl?: string readonly followToken?: string @@ -38,16 +36,15 @@ export class FeedbackService { const event = acceptEvent(value) const accepted = await this.repository.ingest( event, - hashParticipant(this.config.privacyHashSecret, event.participantId), clusterKey(event), symptomFor(event), ) let receipt = accepted.receipt if (event.retestOfReceiptId !== undefined) { - await this.repository.verifyRetest(event.retestOfReceiptId, event.outcome === 'worked') + await this.repository.verifyRetest(event.retestOfReceiptId, event.experience === 'good') } const cluster = receipt.cluster - const actionable = cluster.symptom !== 'outcome-worked' + const actionable = cluster.symptom !== 'experience-good' && cluster.similarReports >= this.config.githubThreshold && cluster.githubIssueUrl === undefined if (actionable && this.publisher !== undefined) { @@ -74,9 +71,6 @@ export class FeedbackService { if (update.recommendedVersion.trim().length === 0 || update.recommendedVersion.length > 64) { throw new TypeError('recommendedVersion is required') } - if (update.message.trim().length === 0 || update.message.length > 500) { - throw new TypeError('message is required and must be at most 500 characters') - } return await this.repository.release(clusterId, update) } @@ -93,7 +87,6 @@ export class FeedbackService { eventId: receipt.eventId, status: cluster.status, similarReports: cluster.similarReports, - message: cluster.message ?? statusMessage(cluster), ...cluster.recommendedVersion === undefined ? {} : { recommendedVersion: cluster.recommendedVersion }, ...cluster.githubIssueUrl === undefined ? {} : { trackingUrl: cluster.githubIssueUrl }, ...includeToken ? { followToken: followToken(this.config.followSecret, receipt.eventId) } : {}, @@ -103,15 +96,6 @@ export class FeedbackService { } } -function statusMessage(cluster: ClusterRecord): string { - if (cluster.status === 'received') return '已收到这次实测;有相似报告时会更新。' - if (cluster.status === 'clustered') return '已发现多个独立安装上的相似体验。' - if (cluster.status === 'reported') return '聚合问题已发送给维护者。' - if (cluster.status === 'retest-requested') return '修复版本已发布,邀请你用原任务复测。' - if (cluster.status === 'verified') return '已有用户确认修复有效。' - return `当前处理状态:${cluster.status}。` -} - function safeEqual(left: string, right: string): boolean { const a = Buffer.from(left) const b = Buffer.from(right) diff --git a/server/src/types.ts b/server/src/types.ts index 9ba4625..d7873fd 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -1,43 +1,24 @@ -export type Outcome = 'worked' | 'partial' | 'failed' +export type HealthStatus = 'ok' | 'unavailable' | 'error' | 'unknown' +export type ExperienceVerdict = 'good' | 'mixed' | 'bad' export type ClusterStatus = | 'received' | 'clustered' - | 'confirmed' | 'reported' | 'fix-released' | 'retest-requested' | 'verified' + | 'confirmed' | 'closed' -export interface RuntimeCrashSignal { - readonly fingerprint: string - readonly name: string - readonly origin: 'uncaughtException' | 'unhandledRejection' - readonly code?: string - readonly frame?: string -} - +/** Exact accepted v2 packet after fail-closed validation. */ export interface AcceptedEvent { readonly eventId: string - readonly participantId: string - readonly occurredAt: number - readonly trialId: string readonly pluginModule: string readonly pluginVersion?: string - readonly taskId?: string + readonly health: HealthStatus + readonly experience: ExperienceVerdict + readonly source: 'user_confirmed' readonly retestOfReceiptId?: string - readonly dshVersion: string - readonly outcome: Outcome - readonly retention: 'keep' | 'unsure' | 'remove' - readonly loaderHealth: string - readonly assistantMessages: number - readonly toolErrors: number - readonly agentErrors: number - readonly processCrashes: number - readonly crashes: readonly RuntimeCrashSignal[] - readonly durationMs: number - readonly firstReplyMs?: number - readonly note?: string } export interface ClusterRecord { @@ -45,13 +26,14 @@ export interface ClusterRecord { readonly clusterKey: string readonly pluginModule: string readonly pluginVersion?: string - readonly taskId?: string + readonly health: HealthStatus + readonly experience: ExperienceVerdict readonly symptom: string readonly status: ClusterStatus + /** Number of reports, not unique users: strict v2 has no stable participant id. */ readonly similarReports: number readonly githubIssueUrl?: string readonly recommendedVersion?: string - readonly message?: string readonly updatedAt: number } @@ -68,7 +50,6 @@ export interface Ingested { export interface ReleaseUpdate { readonly recommendedVersion: string - readonly message: string readonly trackingUrl?: string } @@ -76,18 +57,18 @@ export interface PluginEvidence { readonly pluginModule: string readonly windowDays: number readonly total: number - readonly worked: number - readonly partial: number - readonly failed: number + readonly good: number + readonly mixed: number + readonly bad: number readonly latestVerifiedVersion?: string readonly updatedAt?: number } export interface ExperienceRepository { - ingest(event: AcceptedEvent, participantHash: string, clusterKey: string, symptom: string): Promise + ingest(event: AcceptedEvent, clusterKey: string, symptom: string): Promise receipt(receiptId: string): Promise markReported(clusterId: string, issueUrl: string): Promise release(clusterId: string, update: ReleaseUpdate): Promise - verifyRetest(receiptId: string, worked: boolean): Promise + verifyRetest(receiptId: string, successful: boolean): Promise evidence(pluginModule: string, since: number): Promise } diff --git a/server/src/validation.ts b/server/src/validation.ts index fad4cec..4646782 100644 --- a/server/src/validation.ts +++ b/server/src/validation.ts @@ -1,107 +1,64 @@ -import type { AcceptedEvent, RuntimeCrashSignal } from './types.js' +import type { AcceptedEvent, ExperienceVerdict, HealthStatus } from './types.js' -const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu +const MODULE = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/u +const VERSION = /^[0-9A-Za-z][0-9A-Za-z.+_-]{0,63}$/u +const HEALTH = new Set(['ok', 'unavailable', 'error', 'unknown']) +const EXPERIENCE = new Set(['good', 'mixed', 'bad']) function row(value: unknown, name: string): Record { - if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new TypeError(`${name} must be an object`) + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new TypeError(`${name} must be an object`) + } return value as Record } -function text(value: unknown, name: string, max = 256): string { - if (typeof value !== 'string' || value.length === 0 || value.length > max) throw new TypeError(`${name} must be a non-empty string up to ${max} characters`) - return value -} - -function optionalText(value: unknown, name: string, max = 256): string | undefined { - return value === undefined ? undefined : text(value, name, max) +function exactKeys(value: Record, allowed: readonly string[], name: string): void { + const extras = Object.keys(value).filter(key => !allowed.includes(key)) + if (extras.length > 0) throw new TypeError(`${name} contains unsupported fields`) } -function count(value: unknown, name: string): number { - if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) throw new TypeError(`${name} must be a non-negative integer`) +function string(value: unknown, name: string): string { + if (typeof value !== 'string' || value.length === 0) throw new TypeError(`${name} is invalid`) return value } -function crashSignals(value: unknown): RuntimeCrashSignal[] { - if (value === undefined) return [] - if (!Array.isArray(value) || value.length > 8) throw new TypeError('signals.crashes must be an array of at most 8 items') - return value.map((item, index) => { - const crash = row(item, `signals.crashes[${index}]`) - const origin = crash.origin - if (origin !== 'uncaughtException' && origin !== 'unhandledRejection') { - throw new TypeError(`signals.crashes[${index}].origin is invalid`) - } - const fingerprint = text(crash.fingerprint, `signals.crashes[${index}].fingerprint`, 20) - if (!/^[0-9a-f]{20}$/u.test(fingerprint)) throw new TypeError(`signals.crashes[${index}].fingerprint is invalid`) - const code = optionalText(crash.code, `signals.crashes[${index}].code`, 64) - const name = text(crash.name, `signals.crashes[${index}].name`, 80) - if (!/^[A-Za-z][A-Za-z0-9_.-]{0,79}$/u.test(name)) { - throw new TypeError(`signals.crashes[${index}].name is invalid`) - } - if (code !== undefined && !/^[A-Z][A-Z0-9_]{1,63}$/u.test(code)) { - throw new TypeError(`signals.crashes[${index}].code is invalid`) - } - const frame = optionalText(crash.frame, `signals.crashes[${index}].frame`, 200) - if (frame !== undefined - && (frame.startsWith('/') || frame.startsWith('file:') || /^[A-Za-z]:[\\/]/u.test(frame) - || frame.includes('\\') || frame.split('/').includes('..'))) { - throw new TypeError(`signals.crashes[${index}].frame must not contain an unsafe path`) - } - return { - fingerprint, - name, - origin, - ...code === undefined ? {} : { code }, - ...frame === undefined ? {} : { frame }, - } - }) -} - +/** Reject, rather than strip, every field outside the strict v2 alphabet. */ export function acceptEvent(value: unknown): AcceptedEvent { const root = row(value, 'event') - if (root.schemaVersion !== 1 || root.type !== 'feedback.submitted') throw new TypeError('unsupported event schema') - const eventId = text(root.eventId, 'eventId', 64) - const participantId = text(root.participantId, 'participantId', 64) - if (!UUID.test(eventId) || !UUID.test(participantId)) throw new TypeError('eventId and participantId must be UUIDs') - const trial = row(root.trial, 'trial') - const plugin = row(trial.plugin, 'trial.plugin') - const environment = row(root.environment, 'environment') - const signals = row(root.signals, 'signals') - const feedback = row(root.feedback, 'feedback') - const sharing = row(root.sharing, 'sharing') - if (sharing.transcript !== 'none') throw new TypeError('transcript sharing is not accepted') - const outcome = feedback.outcome - const retention = feedback.retention - if (outcome !== 'worked' && outcome !== 'partial' && outcome !== 'failed') throw new TypeError('invalid outcome') - if (retention !== 'keep' && retention !== 'unsure' && retention !== 'remove') throw new TypeError('invalid retention') - const note = sharing.noteIncluded === true ? optionalText(feedback.note, 'feedback.note', 2_000) : undefined - const pluginVersion = optionalText(plugin.version, 'trial.plugin.version', 64) - const taskId = optionalText(trial.taskId, 'trial.taskId', 128) - const retestOfReceiptId = optionalText(trial.retestOfReceiptId, 'trial.retestOfReceiptId', 128) - const crashes = crashSignals(signals.crashes) - const processCrashes = signals.processCrashes === undefined - ? crashes.length - : count(signals.processCrashes, 'signals.processCrashes') - if (processCrashes < crashes.length) throw new TypeError('signals.processCrashes cannot be smaller than signals.crashes') + exactKeys(root, [ + 'schemaVersion', 'type', 'eventId', 'plugin', 'health', 'experience', 'source', 'retestOfReceiptId', + ], 'event') + if (root.schemaVersion !== 2 || root.type !== 'feedback.signal') { + throw new TypeError('unsupported event schema') + } + const eventId = string(root.eventId, 'eventId') + if (!UUID.test(eventId)) throw new TypeError('eventId is invalid') + + const plugin = row(root.plugin, 'plugin') + exactKeys(plugin, ['moduleName', 'version'], 'plugin') + const moduleName = string(plugin.moduleName, 'plugin.moduleName') + if (moduleName.length > 214 || !MODULE.test(moduleName)) throw new TypeError('plugin.moduleName is invalid') + const version = plugin.version === undefined ? undefined : string(plugin.version, 'plugin.version') + if (version !== undefined && !VERSION.test(version)) throw new TypeError('plugin.version is invalid') + + if (!HEALTH.has(root.health as HealthStatus)) throw new TypeError('health is invalid') + if (!EXPERIENCE.has(root.experience as ExperienceVerdict)) throw new TypeError('experience is invalid') + if (root.source !== 'user_confirmed') throw new TypeError('source is invalid') + const retestOfReceiptId = root.retestOfReceiptId === undefined + ? undefined + : string(root.retestOfReceiptId, 'retestOfReceiptId') + if (retestOfReceiptId !== undefined && !UUID.test(retestOfReceiptId)) { + throw new TypeError('retestOfReceiptId is invalid') + } + return { eventId, - participantId, - occurredAt: count(root.occurredAt, 'occurredAt'), - trialId: text(trial.id, 'trial.id', 128), - pluginModule: text(plugin.moduleName, 'trial.plugin.moduleName', 214), - ...pluginVersion === undefined ? {} : { pluginVersion }, - ...taskId === undefined ? {} : { taskId }, + pluginModule: moduleName, + ...version === undefined ? {} : { pluginVersion: version }, + health: root.health as HealthStatus, + experience: root.experience as ExperienceVerdict, + source: 'user_confirmed', ...retestOfReceiptId === undefined ? {} : { retestOfReceiptId }, - dshVersion: text(environment.dshVersion, 'environment.dshVersion', 64), - outcome, - retention, - loaderHealth: text(signals.loaderHealth, 'signals.loaderHealth', 32), - assistantMessages: count(signals.assistantMessages, 'signals.assistantMessages'), - toolErrors: count(signals.toolErrors, 'signals.toolErrors'), - agentErrors: count(signals.agentErrors, 'signals.agentErrors'), - processCrashes, - crashes, - durationMs: count(trial.durationMs, 'trial.durationMs'), - ...signals.firstReplyMs === undefined ? {} : { firstReplyMs: count(signals.firstReplyMs, 'signals.firstReplyMs') }, - ...note === undefined ? {} : { note }, } } diff --git a/server/tests/http.spec.ts b/server/tests/http.spec.ts new file mode 100644 index 0000000..6359536 --- /dev/null +++ b/server/tests/http.spec.ts @@ -0,0 +1,74 @@ +import { createServer } from 'node:http' +import { afterEach, describe, expect, it } from 'vitest' +import { createHandler } from '../src/http.js' +import { MemoryRepository } from '../src/memory.js' +import { FeedbackService } from '../src/service.js' + +const servers: Array> = [] + +afterEach(async () => { + await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(() => resolve())))) +}) + +async function endpoint(): Promise { + const service = new FeedbackService(new MemoryRepository(), { + followSecret: 'follow-secret-long-enough', + publicBaseUrl: 'https://feedback.example.test', + githubThreshold: 5, + }) + const server = createServer(createHandler(service, { adminToken: 'admin-secret-long-enough' })) + servers.push(server) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('missing address') + return `http://127.0.0.1:${address.port}` +} + +function packet() { + return { + schemaVersion: 2, + type: 'feedback.signal', + eventId: crypto.randomUUID(), + plugin: { moduleName: '@example/plugin', version: '1.0.0' }, + health: 'ok', + experience: 'good', + source: 'user_confirmed', + } +} + +describe('HTTP privacy boundary', () => { + it('returns a fixed error without echoing rejected content', async () => { + const base = await endpoint() + const secret = 'CANARY_PRIVATE_LOG_AND_TOKEN' + const response = await fetch(`${base}/v1/experience-events`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ ...packet(), log: secret }), + }) + expect(response.status).toBe(400) + const text = await response.text() + expect(text).toBe('{"error":"invalid closed packet"}') + expect(text).not.toContain(secret) + }) + + it('rejects bodies over 1 KiB before accepting their fields', async () => { + const base = await endpoint() + const response = await fetch(`${base}/v1/experience-events`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ ...packet(), log: 'S'.repeat(2_000) }), + }) + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ error: 'invalid closed packet' }) + }) + + it('uses a no-body-detail health response and no-store cache policy', async () => { + const base = await endpoint() + const response = await fetch(`${base}/healthz`) + expect(response.status).toBe(200) + expect(response.headers.get('cache-control')).toBe('no-store') + expect(await response.json()).toEqual({ status: 'ok' }) + const verbose = await fetch(`${base}/healthz?verbose=true&include=logs`) + expect(await verbose.json()).toEqual({ status: 'ok' }) + }) +}) diff --git a/server/tests/postgres.spec.ts b/server/tests/postgres.spec.ts index aeb06df..0d36d0a 100644 --- a/server/tests/postgres.spec.ts +++ b/server/tests/postgres.spec.ts @@ -6,22 +6,19 @@ import { describe, expect, it } from 'vitest' import { PostgresRepository } from '../src/postgres.js' import type { AcceptedEvent } from '../src/types.js' -function accepted(eventId = crypto.randomUUID(), participant = 'participant'): AcceptedEvent { +function accepted(eventId = crypto.randomUUID()): AcceptedEvent { return { - eventId, participantId: crypto.randomUUID(), occurredAt: Date.now(), trialId: crypto.randomUUID(), - pluginModule: '@example/search', pluginVersion: '1.0.0', taskId: 'repo-search', - dshVersion: '0.1.0-rc.6', outcome: 'failed', retention: 'remove', loaderHealth: 'active', - assistantMessages: 1, toolErrors: 1, agentErrors: 0, processCrashes: 1, - crashes: [{ - fingerprint: '0123456789abcdef0123', name: 'TypeError', origin: 'uncaughtException', - frame: 'node_modules/@example/search/dist/index.js:10:2', - }], - durationMs: 100, note: participant, + eventId, + pluginModule: '@example/search', + pluginVersion: '1.0.0', + health: 'error', + experience: 'bad', + source: 'user_confirmed', } } -describe('PostgreSQL repository', () => { - it('runs the migration and persists the idempotent receipt lifecycle', async () => { +describe('PostgreSQL strict v2 repository', () => { + it('persists only finite v2 signals and an idempotent receipt lifecycle', async () => { const database = newDb() for (const file of readdirSync(resolve('server/migrations')).filter(file => file.endsWith('.sql')).sort()) { database.public.none(readFileSync(resolve('server/migrations', file), 'utf8')) @@ -30,22 +27,30 @@ describe('PostgreSQL repository', () => { const pool = new adapter.Pool() as unknown as PgPool const repository = new PostgresRepository(pool) const event = accepted() - const first = await repository.ingest(event, 'hash-a', 'cluster-key', 'tool-error') - const duplicate = await repository.ingest(event, 'hash-a', 'cluster-key', 'tool-error') + const first = await repository.ingest(event, 'cluster-key', 'health-error') + const duplicate = await repository.ingest(event, 'cluster-key', 'health-error') expect(duplicate.receipt.receiptId).toBe(first.receipt.receiptId) - await repository.ingest(accepted(), 'hash-b', 'cluster-key', 'tool-error') + await repository.ingest(accepted(), 'cluster-key', 'health-error') await expect(repository.receipt(first.receipt.receiptId)).resolves.toMatchObject({ - cluster: { status: 'clustered', similarReports: 2 }, - }) - const released = await repository.release(first.receipt.cluster.id, { - recommendedVersion: '1.0.1', message: '请复测。', + cluster: { status: 'clustered', similarReports: 2, health: 'error', experience: 'bad' }, }) + const released = await repository.release(first.receipt.cluster.id, { recommendedVersion: '1.0.1' }) expect(released).toMatchObject({ status: 'retest-requested', recommendedVersion: '1.0.1' }) await repository.verifyRetest(first.receipt.receiptId, true) await expect(repository.receipt(first.receipt.receiptId)).resolves.toMatchObject({ cluster: { status: 'verified' }, }) - await expect(repository.evidence('@example/search', 0)).resolves.toMatchObject({ total: 2, failed: 2 }) + await expect(repository.evidence('@example/search', 0)).resolves.toMatchObject({ + total: 2, good: 0, mixed: 0, bad: 2, + }) + + const columns = database.public.many(` + SELECT column_name FROM information_schema.columns + WHERE table_name = 'feedback_events_v2' + `).map(row => row.column_name) + expect(columns).not.toEqual(expect.arrayContaining([ + 'participant_id', 'occurred_at', 'task_id', 'note', 'crash_signatures', 'environment', + ])) await pool.end() }) }) diff --git a/server/tests/service.spec.ts b/server/tests/service.spec.ts index 11c6f5c..769ca1a 100644 --- a/server/tests/service.spec.ts +++ b/server/tests/service.spec.ts @@ -1,42 +1,31 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { clusterKey } from '../src/fingerprint.js' import { MemoryRepository } from '../src/memory.js' import { FeedbackService } from '../src/service.js' +import { acceptEvent } from '../src/validation.js' -function event(participantId = crypto.randomUUID(), eventId = crypto.randomUUID(), outcome = 'failed') { +function event(experience: 'good' | 'mixed' | 'bad' = 'bad', eventId = crypto.randomUUID()) { return { - schemaVersion: 1, - type: 'feedback.submitted', + schemaVersion: 2, + type: 'feedback.signal', eventId, - occurredAt: Date.now(), - participantId, - trial: { - id: crypto.randomUUID(), plugin: { moduleName: '@example/search', version: '1.0.0' }, - taskId: 'repo-search', startedAt: Date.now() - 1_000, durationMs: 1_000, - }, - environment: { - dshVersion: '0.1.0-rc.6', nodeVersion: 'v24', platform: 'darwin', arch: 'arm64', locale: 'zh', profileLabel: 'test', - }, - signals: { - loaderHealth: 'active', assistantMessages: 1, turnsStarted: 1, turnsCompleted: 1, - toolCalls: 1, toolErrors: 1, agentErrors: 0, processCrashes: 0, crashes: [], - }, - feedback: { outcome, retention: outcome === 'worked' ? 'keep' : 'remove' }, - sharing: { transcript: 'none', noteIncluded: false }, - } + plugin: { moduleName: '@example/search', version: '1.0.0' }, + health: 'ok', + experience, + source: 'user_confirmed', + } as const } -function service(repository = new MemoryRepository()) { +function service(repository = new MemoryRepository(), publisher?: { publish: (cluster: never) => Promise }) { return new FeedbackService(repository, { - privacyHashSecret: 'privacy-secret-long-enough', followSecret: 'follow-secret-long-enough', publicBaseUrl: 'https://feedback.example.test', githubThreshold: 3, - }) + }, publisher) } -describe('feedback flywheel service', () => { - it('is idempotent and returns a followable receipt without storing transcript content', async () => { +describe('zero-content feedback flywheel service', () => { + it('is idempotent and returns a report-scoped follow receipt', async () => { const api = service() const input = event() const first = await api.ingest(input) @@ -47,62 +36,52 @@ describe('feedback flywheel service', () => { await expect(api.follow(first.receiptId, 'wrong')).resolves.toBeUndefined() }) - it('counts independent installations, releases a fix, and closes the loop through a retest', async () => { + it('counts reports without a stable user id and closes the loop through explicit retest', async () => { const repository = new MemoryRepository() const api = service(repository) - const first = await api.ingest(event()) - const second = await api.ingest(event()) + const first = await api.ingest(event('bad')) + const second = await api.ingest(event('bad')) expect(second.similarReports).toBe(2) expect(second.status).toBe('clustered') const original = await repository.receipt(first.receiptId) if (original === undefined) throw new Error('missing receipt') - await api.release(original.cluster.id, { recommendedVersion: '1.0.1', message: '修复已发布,请复测。' }) - const invitation = await api.follow(first.receiptId, first.followToken ?? '') - expect(invitation).toMatchObject({ status: 'retest-requested', recommendedVersion: '1.0.1' }) - const retest = event(crypto.randomUUID(), crypto.randomUUID(), 'worked') - retest.trial.retestOfReceiptId = first.receiptId - retest.signals.toolErrors = 0 - await api.ingest(retest) + await api.release(original.cluster.id, { recommendedVersion: '1.0.1' }) + await expect(api.follow(first.receiptId, first.followToken ?? '')).resolves.toMatchObject({ + status: 'retest-requested', recommendedVersion: '1.0.1', + }) + await api.ingest({ ...event('good'), retestOfReceiptId: first.receiptId }) await expect(api.follow(first.receiptId, first.followToken ?? '')).resolves.toMatchObject({ status: 'verified' }) }) - it('uses task, version, DSH version and symptom in the cluster fingerprint', () => { - const accepted = { - eventId: crypto.randomUUID(), participantId: crypto.randomUUID(), occurredAt: 1, trialId: 'trial', - pluginModule: 'plugin', pluginVersion: '1', taskId: 'search', dshVersion: 'dsh', - outcome: 'failed' as const, retention: 'remove' as const, loaderHealth: 'active', - assistantMessages: 1, toolErrors: 1, agentErrors: 0, processCrashes: 0, crashes: [], durationMs: 1, - } - expect(clusterKey({ ...accepted, taskId: 'other' })).not.toBe(clusterKey(accepted)) + it('clusters only public plugin coordinates and finite signals', () => { + const accepted = acceptEvent(event('bad')) + expect(clusterKey({ ...accepted, experience: 'good' })).not.toBe(clusterKey(accepted)) + expect(clusterKey({ ...accepted, health: 'error' })).not.toBe(clusterKey(accepted)) }) - it('clusters a shared crash by its sanitized signature and rejects absolute paths', async () => { - const repository = new MemoryRepository() - const api = service(repository) - const crashed = event() - Object.assign(crashed.signals, { - processCrashes: 1, - crashes: [{ - fingerprint: '0123456789abcdef0123', name: 'TypeError', code: 'ERR_STATE', - origin: 'uncaughtException', frame: 'node_modules/@example/search/dist/index.js:10:2', - }], - }) - const receipt = await api.ingest(crashed) - await expect(repository.receipt(receipt.receiptId)).resolves.toMatchObject({ - cluster: { symptom: 'runtime-crash:TypeError:ERR_STATE:node_modules/@example/search/dist/index.js:10:2:0123456789abcdef0123' }, - }) - - const unsafe = event() - Object.assign(unsafe.signals, { - processCrashes: 1, - crashes: [{ - fingerprint: '0123456789abcdef0123', name: 'Error', origin: 'uncaughtException', - frame: '/Users/private/project/index.js:1:2', - }], - }) - await expect(api.ingest(unsafe)).rejects.toThrow('must not contain an unsafe path') + it('rejects old telemetry, log-derived fields, free text and unknown fields fail closed', () => { + const attempts = [ + { ...event(), note: 'CANARY_SECRET' }, + { ...event(), log: 'CANARY_SECRET' }, + { ...event(), stack: '/Users/alice/private.ts' }, + { ...event(), participantId: crypto.randomUUID() }, + { ...event(), occurredAt: Date.now() }, + { ...event(), signals: { toolErrors: 1 } }, + { ...event(), environment: { platform: 'darwin' } }, + ] + for (const attempt of attempts) expect(() => acceptEvent(attempt)).toThrow('unsupported fields') + expect(() => acceptEvent({ ...event(), schemaVersion: 1 })).toThrow('unsupported event schema') + expect(() => acceptEvent({ ...event(), source: 'agent_inferred' })).toThrow('source is invalid') + }) - unsafe.signals.crashes[0].frame = 'node_modules/../../Users/private/index.js:1:2' - await expect(api.ingest(unsafe)).rejects.toThrow('must not contain an unsafe path') + it('publishes only an aggregate after the configured threshold', async () => { + const publish = vi.fn(async () => 'https://github.example/issues/1') + const api = service(new MemoryRepository(), { publish: publish as never }) + await api.ingest(event('bad')) + await api.ingest(event('bad')) + expect(publish).not.toHaveBeenCalled() + const third = await api.ingest(event('bad')) + expect(publish).toHaveBeenCalledOnce() + expect(third).toMatchObject({ status: 'reported', similarReports: 3 }) }) }) diff --git a/src/agent-tool.ts b/src/agent-tool.ts new file mode 100644 index 0000000..0c25a5e --- /dev/null +++ b/src/agent-tool.ts @@ -0,0 +1,52 @@ +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { JsonSchemaNode, ToolDefinition } from '@deepseek-ai/dsh-tools' +import type { SafeExperienceAssessment } from './protocol.js' + +export const AGENT_ASSESSMENT_TOOL = 'omdsh_analyze_plugin_experience' + +const PARAMETERS = { + type: 'object', + properties: {}, + required: [], + additionalProperties: false, +} + +const OUTPUT: JsonSchemaNode = { + type: 'object', + properties: { + health: { type: 'string', enum: ['ok', 'unavailable', 'error', 'unknown'] }, + experience: { type: 'string', const: 'unknown' }, + userConfirmationRequired: { type: 'boolean', const: true }, + }, + required: ['health', 'experience', 'userConfirmationRequired'], + additionalProperties: false, +} + +function noArguments(value: unknown): boolean { + return typeof value === 'object' && value !== null && !Array.isArray(value) + && Object.keys(value).length === 0 +} + +/** A raw ToolDefinition is used so the rc.6 input schema is closed as well as the output. */ +export function createAgentAssessmentTool( + assess: (agent: Agent | undefined) => SafeExperienceAssessment, +): ToolDefinition { + return { + name: AGENT_ASSESSMENT_TOOL, + description: [ + 'Read only the current Plugin Lab trial lifecycle status from the DSH Host.', + 'Call with an empty object. Never infer subjective quality from conversation content.', + 'When experience is unknown, ask the user to confirm good, mixed, or bad.', + ].join(' '), + parameters: PARAMETERS, + output: { + schema: OUTPUT, + render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }], + }, + async execute(args, exec) { + if (!noArguments(args)) throw new TypeError('experience assessment accepts no arguments') + return assess(exec.agent) + }, + isConcurrencySafe: () => true, + } +} diff --git a/src/client/ExperienceResultCard.tsx b/src/client/ExperienceResultCard.tsx index 047cb33..3b00d30 100644 --- a/src/client/ExperienceResultCard.tsx +++ b/src/client/ExperienceResultCard.tsx @@ -2,12 +2,12 @@ import { useState, type CSSProperties } from 'react' import type { MessageId } from '@deepseek-ai/dsh-client-connection/client' import type { ConversationNode } from '@deepseek-ai/dsh-client-runtime/client' import type { InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import type { TrialOutcome } from '../protocol.js' +import type { ExperienceVerdict } from '../protocol.js' import type { LabController, LabView } from './controller.js' export interface LabInjected { hooks: { pluginLab: LabController } - record: (messageId: MessageId, outcome: TrialOutcome) => Promise + record: (messageId: MessageId, verdict: ExperienceVerdict) => Promise join: () => Promise dismiss: () => void } @@ -34,9 +34,9 @@ const choiceStyle: CSSProperties = { padding: '7px 9px', background: 'transparent', color: 'inherit', cursor: 'pointer', } -function shareLabel(outcome: TrialOutcome): string { - if (outcome === 'worked') return '贡献匿名实测' - if (outcome === 'partial') return '查找相似问题' +function shareLabel(verdict: ExperienceVerdict): string { + if (verdict === 'good') return '贡献聚合实测' + if (verdict === 'mixed') return '查找相似反馈' return '加入并等待修复' } @@ -70,12 +70,15 @@ export function ExperienceResultCard({ {open && ( - 这次插件把事情做成了吗? + 你觉得这个插件好用吗? + + Agent 只知道运行状态,不会读取会话或日志替你判断。 + {pending === undefined && ( - - - + + + )} {pending !== undefined && ( @@ -85,7 +88,7 @@ export function ExperienceResultCard({ {pending.phase === 'local' && ( )} {(pending.phase === 'joined' || pending.phase === 'error') && ( @@ -94,7 +97,7 @@ export function ExperienceResultCard({ )} - 第一步只保存在本机;加入跟进仅发送插件、版本、结果与无内容运行指标,不发送对话正文。 + 第一步只保存在本机;加入跟进只发送插件、版本、状态枚举和你的选择。网络传输并非绝对匿名。 )} diff --git a/src/client/ProbeButton.tsx b/src/client/ProbeButton.tsx new file mode 100644 index 0000000..c0bbee0 --- /dev/null +++ b/src/client/ProbeButton.tsx @@ -0,0 +1,47 @@ +import { useState, type CSSProperties } from 'react' +import type { InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' + +export interface ProbeInjected { + checkHealth: () => Promise +} + +export type ProbeButtonProps = PropsRuntime<'conversation.input.left'> & InjectFace + +const button: CSSProperties = { + height: 28, padding: '0 9px', border: 'none', borderRadius: 14, cursor: 'pointer', + background: 'transparent', color: 'var(--dsw-alias-label-tertiary)', fontSize: 12, +} + +export function ProbeButton({ checkHealth }: ProbeButtonProps) { + const [text, setText] = useState(null) + const [busy, setBusy] = useState(false) + return ( + + + {text !== null && ( + + {text} + + )} + + ) +} diff --git a/src/client/controller.ts b/src/client/controller.ts index 12a0264..a1b7bb9 100644 --- a/src/client/controller.ts +++ b/src/client/controller.ts @@ -3,14 +3,14 @@ import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' import type { MessageId } from '@deepseek-ai/dsh-client-connection/client' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' -import type { TrialOutcome } from '../protocol.js' +import type { ExperienceVerdict } from '../protocol.js' /** Exact rc.6 generated command Remote surface, kept narrow for testability. */ export type CommandsRemote = Pick export interface PendingResult { readonly messageId: MessageId - readonly outcome: TrialOutcome + readonly verdict: ExperienceVerdict readonly phase: 'saving' | 'local' | 'joining' | 'joined' | 'error' readonly text?: string } @@ -47,17 +47,17 @@ export class LabController implements HostObservable { this.publish({ ...this.view, active }) } - async record(messageId: MessageId, outcome: TrialOutcome): Promise { - this.publish({ ...this.view, pending: { messageId, outcome, phase: 'saving' } }) - const settled = await this.execute(`/omdsh-result ${outcome}`) + async record(messageId: MessageId, verdict: ExperienceVerdict): Promise { + this.publish({ ...this.view, pending: { messageId, verdict, phase: 'saving' } }) + const settled = await this.execute(`/omdsh-result ${verdict}`) if (settled.ok) { this.publish({ ...this.view, active: false, - pending: { messageId, outcome, phase: 'local', text: settled.text }, + pending: { messageId, verdict, phase: 'local', text: settled.text }, }) } else { - this.publish({ ...this.view, pending: { messageId, outcome, phase: 'error', text: settled.text } }) + this.publish({ ...this.view, pending: { messageId, verdict, phase: 'error', text: settled.text } }) } } @@ -80,6 +80,10 @@ export class LabController implements HostObservable { return (await this.execute('/omdsh-inbox')).text } + async probe(): Promise { + return (await this.execute('/omdsh-probe')).text + } + dismiss(): void { const { pending: _pending, ...view } = this.view this.publish(view) diff --git a/src/client/index.ts b/src/client/index.ts index 6df9bb6..e109246 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -5,9 +5,11 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import { LabController, type CommandsRemote } from './controller.js' import { ExperienceResultCard, type LabInjected } from './ExperienceResultCard.js' import { InboxButton, type InboxInjected } from './InboxButton.js' +import { ProbeButton, type ProbeInjected } from './ProbeButton.js' export { ExperienceResultCard } from './ExperienceResultCard.js' export { InboxButton } from './InboxButton.js' +export { ProbeButton } from './ProbeButton.js' export { LabController } from './controller.js' export const inject = ['slots', 'remote', 'remote.commands'] @@ -53,4 +55,13 @@ export function apply(ctx: ClientContext): void { checkInbox: () => controllerFor(sessionId).inbox(), }), }, InboxButton)) + + ctx.slots.inject('conversation.input.left', () => ctx.slots.register({ + name: 'conversation.input.left', + id: 'omdsh-plugin-lab-probe', + order: 39, + inject: (sessionId): ProbeInjected => ({ + checkHealth: () => controllerFor(sessionId).probe(), + }), + }, ProbeButton)) } diff --git a/src/crash.ts b/src/crash.ts deleted file mode 100644 index e397aa4..0000000 --- a/src/crash.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { createHash } from 'node:crypto' -import type { RuntimeCrashSignal } from './protocol.js' - -const LOCATION = /(?:file:\/\/)?(?:[A-Za-z]:)?[^()\s]+:\d+:\d+/u -const ERROR_NAME = /^[A-Za-z][A-Za-z0-9_.-]{0,79}$/u -const ERROR_CODE = /^[A-Z][A-Z0-9_]{1,63}$/u - -function errorName(value: unknown): string { - return typeof value === 'string' && ERROR_NAME.test(value) ? value : 'Error' -} - -function errorCode(error: unknown): string | undefined { - if (typeof error !== 'object' || error === null || !('code' in error)) return undefined - return typeof error.code === 'string' && ERROR_CODE.test(error.code) ? error.code : undefined -} - -/** Keep only a package-relative or built-in first frame, never a machine path or function name. */ -export function sanitizeCrashFrame(stack: unknown): string | undefined { - if (typeof stack !== 'string') return undefined - for (const line of stack.split('\n').slice(1)) { - const matched = line.match(LOCATION)?.[0] - if (matched === undefined) continue - const normalized = matched.replace(/^file:\/\//u, '').replaceAll('\\', '/') - if (normalized.startsWith('node:')) return normalized.slice(0, 200) - const dependency = normalized.lastIndexOf('/node_modules/') - if (dependency >= 0) return `node_modules/${normalized.slice(dependency + '/node_modules/'.length)}`.slice(0, 200) - const file = normalized.split('/').at(-1) - return file === undefined ? undefined : `/${file}`.slice(0, 200) - } - return undefined -} - -export function runtimeCrashSignal( - error: unknown, - origin: RuntimeCrashSignal['origin'], -): RuntimeCrashSignal { - const row = typeof error === 'object' && error !== null ? error as { name?: unknown; stack?: unknown } : {} - const name = errorName(row.name) - const code = errorCode(error) - const frame = sanitizeCrashFrame(row.stack) - const fingerprint = createHash('sha256') - .update([name, code ?? '', origin, frame ?? ''].join('\u001f')) - .digest('hex') - .slice(0, 20) - return { - fingerprint, - name, - origin, - ...code === undefined ? {} : { code }, - ...frame === undefined ? {} : { frame }, - } -} diff --git a/src/diagnosis.ts b/src/diagnosis.ts deleted file mode 100644 index 0f4ed1d..0000000 --- a/src/diagnosis.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { ExperienceEventV1, RetentionIntent, TrialOutcome } from './protocol.js' - -export interface Diagnosis { - readonly headline: string - readonly actions: readonly string[] -} - -export function diagnoseExperience( - event: ExperienceEventV1, - outcome: TrialOutcome, - retention: RetentionIntent, -): Diagnosis { - const actions: string[] = [] - if (event.signals.loaderHealth === 'missing') { - return { - headline: '目标插件没有出现在当前 DSH Loader 中。', - actions: ['检查它是否已安装进当前 Profile,以及 Bundle patch 是否插入了插件行。'], - } - } - if (event.signals.loaderHealth === 'failed') { - return { - headline: '目标插件已被发现,但加载失败。', - actions: ['优先检查构建产物、依赖版本和插件启动日志,再判断插件能力。'], - } - } - if (event.signals.loaderHealth !== 'active' && event.signals.loaderHealth !== 'unknown') { - actions.push(`插件当前 Loader 状态为 ${event.signals.loaderHealth},建议状态稳定后复测。`) - } - if (event.signals.assistantMessages === 0) { - actions.push('本次 Trial 没有产生完整 Assistant 回复,反馈更可能指向启动或运行链路。') - } - if (event.signals.toolErrors > 0) { - actions.push(`记录到 ${event.signals.toolErrors} 次 Tool 错误;后续报告应优先附错误码,不必附会话正文。`) - } - if (event.signals.agentErrors > 0) { - actions.push(`记录到 ${event.signals.agentErrors} 次 Agent 运行错误,建议先排除框架或模型侧异常。`) - } - if (event.signals.processCrashes > 0) { - const signature = event.signals.crashes?.[0] - actions.push(signature === undefined - ? `记录到 ${event.signals.processCrashes} 次进程崩溃。` - : `记录到 ${event.signals.processCrashes} 次进程崩溃;脱敏指纹 ${signature.fingerprint},首帧 ${signature.frame ?? 'unknown'}。`) - } - if (outcome === 'worked' && retention === 'keep') { - return { - headline: '这次试用形成了明确的正向保留信号。', - actions: actions.length > 0 ? actions : ['可以用一个相似任务复测,确认体验不是偶然。'], - } - } - if (outcome === 'failed') { - return { - headline: '这次试用没有完成目标。', - actions: actions.length > 0 ? actions : ['建议用相同任务在干净 Profile 中复测,再决定卸载或报告问题。'], - } - } - return { - headline: '这次试用提供了部分价值,但证据还不足以直接保留或淘汰。', - actions: actions.length > 0 ? actions : ['建议固定同一任务再试一次,或与不安装插件的基线结果比较。'], - } -} diff --git a/src/health.ts b/src/health.ts new file mode 100644 index 0000000..0d975aa --- /dev/null +++ b/src/health.ts @@ -0,0 +1,55 @@ +import type { HealthStatus } from './protocol.js' + +export const FIBER_PHASE = { + PENDING: 0, + LOADING: 1, + ACTIVE: 2, + FAILED: 3, + DISPOSED: 4, + UNLOADING: 5, +} as const + +export interface LoaderEntryLike { + readonly disabled?: boolean + readonly options?: { readonly name?: string; readonly group?: boolean } + readonly fiber?: { readonly state?: number } +} + +export interface LoaderLike { + entries(): Iterable +} + +/** + * Read only Host-owned lifecycle cells. No plugin method, logger, Session, + * exception, filesystem, environment or network capability is reachable here. + */ +export function probeLoaderHealth(loader: LoaderLike | undefined, moduleName: string): HealthStatus { + if (loader === undefined || typeof loader.entries !== 'function') return 'unknown' + try { + const matches = [...loader.entries()].filter(entry => { + const name = entry.options?.name + return entry.options?.group !== true + && (name === moduleName || name?.startsWith(`${moduleName}/`) === true) + }) + if (matches.length === 0 || matches.every(entry => entry.disabled === true)) return 'unavailable' + const states = matches + .filter(entry => entry.disabled !== true) + .map(entry => entry.fiber?.state) + if (states.includes(FIBER_PHASE.ACTIVE)) return 'ok' + if (states.includes(FIBER_PHASE.FAILED)) return 'error' + if (states.some(state => state === FIBER_PHASE.PENDING + || state === FIBER_PHASE.LOADING + || state === FIBER_PHASE.DISPOSED + || state === FIBER_PHASE.UNLOADING)) return 'unavailable' + return 'unknown' + } catch { + return 'unknown' + } +} + +export function healthText(status: HealthStatus): string { + if (status === 'ok') return '当前运行 OK' + if (status === 'unavailable') return '当前暂不可用' + if (status === 'error') return '当前运行异常' + return '暂时无法判断' +} diff --git a/src/index.ts b/src/index.ts index 2f16d00..0cf60c6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,49 +1,37 @@ -/** Consent-first plugin trial feedback loop for DeepSeek Harness. */ +/** Zero-content plugin health and user-confirmed feedback loop for DeepSeek Harness. */ -import { createRequire } from 'node:module' import type { Context } from '@deepseek-ai/cordis' import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands' -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import z from '@deepseek-ai/schemastery' -import { runtimeCrashSignal } from './crash.js' -import { diagnoseExperience } from './diagnosis.js' +import { createAgentAssessmentTool } from './agent-tool.js' +import { healthText, probeLoaderHealth, type LoaderLike } from './health.js' import { - FEEDBACK_USAGE, JOIN_USAGE, parseFeedbackInput, parseResultInput, parseStartInput, - RESULT_USAGE, RETEST_USAGE, retentionForOutcome, START_USAGE, + JOIN_USAGE, parseJoinTarget, parseReceiptId, parseStartInput, parseVerdict, + RESULT_USAGE, RETEST_USAGE, START_USAGE, } from './input.js' import { - EXPERIENCE_SCHEMA_VERSION, - type ExperienceEventV1, + FEEDBACK_SCHEMA_VERSION, + type ExperienceVerdict, + type FeedbackEventV2, + type HealthStatus, type IngestReceipt, - type LoaderHealth, - type LocalCrashRecord, - type LocalExperienceRecord, - type RuntimeCrashSignal, - type TrialMetrics, + type LocalFeedbackRecord, + type SafeExperienceAssessment, type TrialPluginRef, } from './protocol.js' -import { ExperienceStore } from './storage.js' +import { FeedbackStore } from './storage.js' import { ExperienceUploader } from './uploader.js' export const name = 'omdsh-plugin-lab' -export const inject = ['commands', 'sessions'] - -const require = createRequire(import.meta.url) - -function dshVersion(): string { - try { - return (require('@deepseek-ai/dsh-session/package.json') as { version: string }).version - } catch { - return 'unknown' - } -} +export const inject = ['commands'] export interface Config { readonly dataDir?: string readonly ingestUrl?: string + readonly allowShare?: boolean + /** Deprecated deployment alias retained only so existing rc.6 configs keep working. */ readonly allowAnonymousShare?: boolean readonly authorizationEnv?: string - readonly profileLabel?: string readonly requestTimeoutMs?: number readonly retryIntervalMs?: number } @@ -51,185 +39,60 @@ export interface Config { export const Config: z = z.object({ dataDir: z.string(), ingestUrl: z.string(), - allowAnonymousShare: z.boolean().default(false), + allowShare: z.boolean(), + allowAnonymousShare: z.boolean(), authorizationEnv: z.string().default('OMDSH_PLUGIN_LAB_TOKEN'), - profileLabel: z.string().default('default'), requestTimeoutMs: z.number().default(5_000), retryIntervalMs: z.number().default(30_000), }) -interface MutableMetrics { - assistantMessages: number - turnsStarted: number - turnsCompleted: number - toolCalls: number - toolErrors: number - agentErrors: number - processCrashes: number - crashes: RuntimeCrashSignal[] - crashIds: Set - firstReplyMs?: number - lastTurnReason?: string +interface ResolvedConfig { + readonly dataDir?: string + readonly ingestUrl?: string + readonly allowShare: boolean + readonly authorizationEnv: string + readonly requestTimeoutMs: number + readonly retryIntervalMs: number } interface TrialState { - readonly trialId: string readonly plugin: TrialPluginRef - readonly taskId?: string readonly retestOfReceiptId?: string - readonly startedAt: number - readonly startSeq: number - readonly loaderHealthAtStart: LoaderHealth - readonly metrics: MutableMetrics } -declare module '@deepseek-ai/dsh-session/types' { - interface SessionEventMap { - 'omdsh/trial-started': { - trialId: string - plugin: TrialPluginRef - taskId?: string - retestOfReceiptId?: string - startedAt: number - loaderHealth: LoaderHealth - } - 'omdsh/feedback-recorded': { - eventId: string - trialId: string - outcome: 'worked' | 'partial' | 'failed' - retention: 'keep' | 'unsure' | 'remove' - requestedShare: boolean - noteShared: boolean - } - 'omdsh/runtime-crashed': { - crashId: string - trialId: string - occurredAt: number - crash: RuntimeCrashSignal - } - } +function sessionKey(invocation: Pick): string { + return String(invocation.agent.session.id) } -function emptyMetrics(): MutableMetrics { - return { - assistantMessages: 0, - turnsStarted: 0, - turnsCompleted: 0, - toolCalls: 0, - toolErrors: 0, - agentErrors: 0, - processCrashes: 0, - crashes: [], - crashIds: new Set(), - } +function agentSessionKey(agent: { readonly session: { readonly id: unknown } } | undefined): string | undefined { + return agent === undefined ? undefined : String(agent.session.id) } -function snapshotMetrics(metrics: MutableMetrics): TrialMetrics { - return { - assistantMessages: metrics.assistantMessages, - turnsStarted: metrics.turnsStarted, - turnsCompleted: metrics.turnsCompleted, - toolCalls: metrics.toolCalls, - toolErrors: metrics.toolErrors, - agentErrors: metrics.agentErrors, - processCrashes: metrics.processCrashes, - ...metrics.crashes.length === 0 ? {} : { crashes: [...metrics.crashes] }, - ...metrics.firstReplyMs === undefined ? {} : { firstReplyMs: metrics.firstReplyMs }, - ...metrics.lastTurnReason === undefined ? {} : { lastTurnReason: metrics.lastTurnReason }, - } -} - -function reasonKind(value: unknown): string | undefined { - if (typeof value !== 'object' || value === null || !('kind' in value)) return undefined - return typeof value.kind === 'string' ? value.kind : undefined -} - -function recordCrash(state: TrialState, record: LocalCrashRecord): void { - if (record.trialId !== state.trialId || state.metrics.crashIds.has(record.crashId)) return - state.metrics.crashIds.add(record.crashId) - state.metrics.processCrashes += 1 - if (state.metrics.crashes.length < 8 - && !state.metrics.crashes.some(crash => crash.fingerprint === record.crash.fingerprint)) { - state.metrics.crashes.push(record.crash) - } +function health(ctx: Context, plugin: TrialPluginRef): HealthStatus { + return probeLoaderHealth(ctx.get('loader') as LoaderLike | undefined, plugin.moduleName) } -function observe(state: TrialState, event: SessionEvent): void { - if (event.seq <= state.startSeq) return - switch (event.type) { - case 'turn/start': - state.metrics.turnsStarted += 1 - return - case 'turn/end': - state.metrics.turnsCompleted += 1 - state.metrics.lastTurnReason = reasonKind(event.data.reason) ?? 'unknown' - return - case 'assistant/message': - state.metrics.assistantMessages += 1 - state.metrics.firstReplyMs ??= Math.max(0, event.time - state.startedAt) - return - case 'tool/call': - state.metrics.toolCalls += 1 - return - case 'tool/result': - if (event.data.error !== undefined) state.metrics.toolErrors += 1 - return - case 'omdsh/runtime-crashed': - recordCrash(state, event.data) - return - default: - return - } -} - -const FIBER_PHASE: Record = { - 0: 'pending', - 1: 'loading', - 2: 'active', - 3: 'failed', - 4: 'unknown', - 5: 'unloading', +function assessment(status: HealthStatus): SafeExperienceAssessment { + return { health: status, experience: 'unknown', userConfirmationRequired: true } } -interface LoaderEntryLike { - readonly disabled?: boolean - readonly options?: { readonly name?: string; readonly group?: boolean } - readonly fiber?: { readonly state?: number } -} - -interface LoaderLike { - entries(): Iterable -} - -function loaderHealth(ctx: Context, moduleName: string): LoaderHealth { - const loader = ctx.get('loader') as LoaderLike | undefined - if (loader === undefined || typeof loader.entries !== 'function') return 'unknown' - const matches = [...loader.entries()].filter(entry => { - const name = entry.options?.name - return entry.options?.group !== true - && (name === moduleName || name?.startsWith(`${moduleName}/`) === true) - }) - if (matches.length === 0) return 'missing' - if (matches.every(entry => entry.disabled === true)) return 'disabled' - const phases = matches.filter(entry => entry.disabled !== true) - .map(entry => entry.fiber?.state === undefined ? 'unknown' : FIBER_PHASE[entry.fiber.state] ?? 'unknown') - if (phases.includes('active')) return 'active' - if (phases.includes('failed')) return 'failed' - return phases[0] ?? 'unknown' +function verdictText(verdict: ExperienceVerdict): string { + if (verdict === 'good') return '好用' + if (verdict === 'mixed') return '一般' + return '不好用' } function renderReceipt(receipt: IngestReceipt): string[] { - const lines = ['已匿名加入跟进。'] + const lines = ['已发送严格最小化反馈。'] if (receipt.caseId !== undefined) lines.push(`问题回执:${receipt.caseId}`) - if (receipt.similarReports !== undefined) lines.push(`相似反馈:${receipt.similarReports} 条。`) + if (receipt.similarReports !== undefined) lines.push(`同类反馈:${receipt.similarReports} 条。`) if (receipt.status !== undefined) lines.push(`状态:${receipt.status}。`) if (receipt.recommendedVersion !== undefined) lines.push(`建议版本:${receipt.recommendedVersion}。`) - if (receipt.message !== undefined) lines.push(receipt.message) - if (receipt.trackingUrl !== undefined) lines.push(`跟踪地址:${receipt.trackingUrl}`) + if (receipt.trackingUrl !== undefined) lines.push(`聚合跟踪:${receipt.trackingUrl}`) return lines } -function validateConfig(config: Config): Required> & Pick { +function validateConfig(config: Config): ResolvedConfig { const requestTimeoutMs = config.requestTimeoutMs ?? 5_000 const retryIntervalMs = config.retryIntervalMs ?? 30_000 if (!Number.isFinite(requestTimeoutMs) || requestTimeoutMs <= 0) { @@ -251,9 +114,8 @@ function validateConfig(config: Config): Required() - const sessionKey = (session: Session): string => String(session.id) - - const adopt = (session: Session): void => { - let state: TrialState | undefined - for (const event of session.events) { - if (event.type === 'omdsh/trial-started') { - state = { - trialId: event.data.trialId, - plugin: event.data.plugin, - ...event.data.taskId === undefined ? {} : { taskId: event.data.taskId }, - ...event.data.retestOfReceiptId === undefined ? {} : { retestOfReceiptId: event.data.retestOfReceiptId }, - startedAt: event.data.startedAt, - startSeq: event.seq, - loaderHealthAtStart: event.data.loaderHealth, - metrics: emptyMetrics(), - } - } else if (event.type === 'omdsh/feedback-recorded' && state?.trialId === event.data.trialId) { - state = undefined - } else if (state !== undefined) { - observe(state, event) - } - } - if (state !== undefined) { - for (const record of store.crashRecords(state.trialId)) recordCrash(state, record) - trials.set(sessionKey(session), state) - } + const assessTrial = (key: string | undefined): SafeExperienceAssessment => { + const state = key === undefined ? undefined : trials.get(key) + return assessment(state === undefined ? 'unknown' : health(ctx, state.plugin)) } - ctx.on('session/created', adopt) - ctx.on('session/disposed', session => { trials.delete(sessionKey(session)) }) - ctx.on('session/event', (session, event) => { - const state = trials.get(sessionKey(session)) - if (state !== undefined) observe(state, event) + // Optional capability: headless command-only tests still work, while a normal + // rc.6 Agent runtime receives the closed, zero-argument assessment tool. + ctx.inject(['tools'], toolCtx => { + toolCtx.tools.register(createAgentAssessmentTool(agent => assessTrial(agentSessionKey(agent)))) }) - ctx.on('agent/error', ({ agent }) => { - const state = trials.get(sessionKey(agent.session)) - if (state !== undefined) state.metrics.agentErrors += 1 - }) - for (const session of ctx.sessions.list()) adopt(session) - - const monitorCrash = (error: Error, origin: NodeJS.UncaughtExceptionOrigin): void => { - const crash = runtimeCrashSignal(error, origin) - const occurredAt = Date.now() - for (const session of ctx.sessions.list()) { - const state = trials.get(sessionKey(session)) - if (state === undefined) continue - const record: LocalCrashRecord = { - crashId: crypto.randomUUID(), - trialId: state.trialId, - occurredAt, - crash, - } - try { - store.appendCrash(record) - } catch { - // Keep monitoring best-effort: never replace the original process failure. - } - recordCrash(state, record) - try { - session.append('omdsh/runtime-crashed', record) - } catch { - // The synchronous journal above remains recoverable if Session persistence cannot run. - } - } - } - process.on('uncaughtExceptionMonitor', monitorCrash) - ctx.effect(() => () => { process.off('uncaughtExceptionMonitor', monitorCrash) }, 'plugin-lab crash monitor') const beginTrial = ( invocation: CommandInvocation, input: ReturnType, retestOfReceiptId?: string, ): CommandResult => { - const key = sessionKey(invocation.agent.session) + const key = sessionKey(invocation) if (trials.has(key)) { - return { kind: 'error', text: '当前 Session 已有进行中的 Trial;请先提交体验结果,或换一个 Session。' } + return { kind: 'error', text: '当前任务已有进行中的插件试用;请先确认体验结果。' } } - const startedAt = Date.now() - const health = loaderHealth(ctx, input.plugin.moduleName) - const event = invocation.agent.session.append('omdsh/trial-started', { - trialId: crypto.randomUUID(), - plugin: input.plugin, - ...input.taskId === undefined ? {} : { taskId: input.taskId }, - ...retestOfReceiptId === undefined ? {} : { retestOfReceiptId }, - startedAt, - loaderHealth: health, - }) - trials.set(key, { - trialId: event.data.trialId, + const state: TrialState = { plugin: input.plugin, - ...input.taskId === undefined ? {} : { taskId: input.taskId }, ...retestOfReceiptId === undefined ? {} : { retestOfReceiptId }, - startedAt, - startSeq: event.seq, - loaderHealthAtStart: health, - metrics: emptyMetrics(), - }) + } + trials.set(key, state) return { kind: 'success', text: [ - `Trial 已开始:${input.plugin.moduleName}${input.plugin.version === undefined ? '' : `#${input.plugin.version}`}`, - `Loader 状态:${health}`, - retestOfReceiptId === undefined - ? '现在正常使用 Agent。完成后在最新回复旁选择结果;默认只保存在本机。' - : `这是回执 ${retestOfReceiptId} 的修复复测;完成后请选择结果。`, + `试用已开始:${input.plugin.moduleName}${input.plugin.version === undefined ? '' : `#${input.plugin.version}`}`, + `运行状态:${healthText(health(ctx, input.plugin))}`, + '完成后由你选择“好用 / 一般 / 不好用”;Agent 不会读取会话或日志替你判断。', ].join('\n'), - sourceEventSeq: event.seq, } } const startTrial = (invocation: CommandInvocation): CommandResult => { try { return beginTrial(invocation, parseStartInput(invocation.rawInput)) - } catch (error: unknown) { - return { kind: 'error', text: error instanceof Error ? error.message : START_USAGE } + } catch { + return { kind: 'error', text: START_USAGE } } } const startRetest = (invocation: CommandInvocation): CommandResult => { - const [receiptId, ...trialParts] = invocation.rawInput.trim().split(/\s+/u).filter(Boolean) - if (receiptId === undefined || trialParts.length === 0) return { kind: 'error', text: RETEST_USAGE } + const [receiptId, ...pluginParts] = invocation.rawInput.trim().split(/\s+/u).filter(Boolean) + if (receiptId === undefined || pluginParts.length !== 1) return { kind: 'error', text: RETEST_USAGE } try { - return beginTrial(invocation, parseStartInput(trialParts.join(' ')), receiptId) - } catch (error: unknown) { - return { kind: 'error', text: error instanceof Error ? error.message : RETEST_USAGE } + return beginTrial(invocation, parseStartInput(pluginParts[0] ?? ''), parseReceiptId(receiptId)) + } catch { + return { kind: 'error', text: RETEST_USAGE } } } - const submitFeedback = async (invocation: CommandInvocation): Promise => { - const state = trials.get(sessionKey(invocation.agent.session)) - if (state === undefined) { - return { kind: 'error', text: `没有进行中的 Trial。先运行 ${START_USAGE}` } + const probe = (invocation: CommandInvocation): CommandResult => { + const state = trials.get(sessionKey(invocation)) + const result = assessTrial(sessionKey(invocation)) + return { + kind: 'success', + text: state === undefined + ? `${healthText(result.health)}:当前没有选中的插件试用。` + : [ + `插件:${state.plugin.moduleName}`, + `运行状态:${healthText(result.health)}`, + '主观体验:未确认。探活没有读取日志、会话、异常或文件。', + ].join('\n'), } - let input: ReturnType + } + + const submitResult = (invocation: CommandInvocation): CommandResult => { + const key = sessionKey(invocation) + const state = trials.get(key) + if (state === undefined) return { kind: 'error', text: `没有进行中的插件试用。先运行 ${START_USAGE}` } + let verdict: ExperienceVerdict try { - input = parseFeedbackInput(invocation.rawInput) - } catch (error: unknown) { - return { kind: 'error', text: error instanceof Error ? error.message : FEEDBACK_USAGE } + verdict = parseVerdict(invocation.rawInput) + } catch { + return { kind: 'error', text: RESULT_USAGE } } - if (input.share && uploader === undefined) { - return { - kind: 'error', - text: '匿名分享未启用。当前不会上传任何数据;请移除 --share,或由部署者同时配置 allowAnonymousShare 和 ingestUrl。', - } - } - const currentHealth = loaderHealth(ctx, state.plugin.moduleName) - const event: ExperienceEventV1 = { - schemaVersion: EXPERIENCE_SCHEMA_VERSION, - type: 'feedback.submitted', + const event: FeedbackEventV2 = { + schemaVersion: FEEDBACK_SCHEMA_VERSION, + type: 'feedback.signal', eventId: crypto.randomUUID(), - occurredAt: Date.now(), - participantId: store.participantId(), - trial: { - id: state.trialId, - plugin: state.plugin, - ...state.taskId === undefined ? {} : { taskId: state.taskId }, - startedAt: state.startedAt, - durationMs: Math.max(0, Date.now() - state.startedAt), - ...state.retestOfReceiptId === undefined ? {} : { retestOfReceiptId: state.retestOfReceiptId }, - }, - environment: { - dshVersion: dshVersion(), - nodeVersion: process.version, - platform: process.platform, - arch: process.arch, - locale: Intl.DateTimeFormat().resolvedOptions().locale, - profileLabel: config.profileLabel, - }, - signals: { - ...snapshotMetrics(state.metrics), - loaderHealth: currentHealth === 'unknown' ? state.loaderHealthAtStart : currentHealth, - }, - feedback: { - outcome: input.outcome, - retention: input.retention, - ...input.note === undefined ? {} : { note: input.note }, - }, - sharing: { - transcript: 'none', - noteIncluded: input.shareNote, - }, - } - const record: LocalExperienceRecord = { - event, - requestedShare: input.share, - shareNote: input.shareNote, - } - if (input.dryRun) { - const preview = input.shareNote || event.feedback.note === undefined - ? event - : { ...event, feedback: { outcome: event.feedback.outcome, retention: event.feedback.retention } } - return { - kind: 'success', - text: `预览:不会保存或上传。\n${JSON.stringify(preview, null, 2)}`, - } - } - store.append(record) - const feedbackEvent = invocation.agent.session.append('omdsh/feedback-recorded', { - eventId: event.eventId, - trialId: state.trialId, - outcome: input.outcome, - retention: input.retention, - requestedShare: input.share, - noteShared: input.shareNote, - }) - trials.delete(sessionKey(invocation.agent.session)) - - const diagnosis = diagnoseExperience(event, input.outcome, input.retention) - const lines = [diagnosis.headline, ...diagnosis.actions.map(action => `- ${action}`)] - lines.push(`已保存到本机:${store.eventsPath}`) - lines.push('记录不包含 Prompt、会话正文、Tool 参数/结果或工作目录。') - if (input.note !== undefined && !input.shareNote) lines.push('文字备注仅保存在本机。') - if (input.share && uploader !== undefined) { - try { - const receipts = await uploader.flushPending(event.eventId) - const receipt = receipts.get(event.eventId) - if (receipt !== undefined) { - lines.push(...renderReceipt(receipt)) - store.markSeen(receipt) - } - } catch (error: unknown) { - lines.push(`匿名上传暂时失败,已留在本地队列并会重试:${error instanceof Error ? error.message : String(error)}`) - } - } else { - lines.push('本次只保存在本机。选择“加入跟进”或运行 /omdsh-join latest 才会匿名发送结构化体感。') + plugin: state.plugin, + health: health(ctx, state.plugin), + experience: verdict, + source: 'user_confirmed', + ...state.retestOfReceiptId === undefined ? {} : { retestOfReceiptId: state.retestOfReceiptId }, } - return { kind: 'success', text: lines.join('\n'), sourceEventSeq: feedbackEvent.seq } - } - - const submitResult = async (invocation: CommandInvocation): Promise => { - let input: ReturnType - try { - input = parseResultInput(invocation.rawInput) - } catch (error: unknown) { - return { kind: 'error', text: error instanceof Error ? error.message : RESULT_USAGE } + store.append({ event, requestedShare: false }) + trials.delete(key) + return { + kind: 'success', + text: [ + `运行状态:${healthText(event.health)}`, + `主观体验:${verdictText(verdict)}(由你确认)`, + `已只保存到本机:${store.eventsPath}`, + '记录只有公开插件 ID/版本、状态枚举和你的选择;不含日志、会话、时间、环境或稳定身份。', + '运行 /omdsh-join latest 才会发送屏幕上这组有限字段。', + ].join('\n'), } - const note = input.note === undefined ? '' : ` ${input.note}` - return await submitFeedback({ - ...invocation, - rawInput: `${input.outcome} ${retentionForOutcome(input.outcome)}${note}`, - }) } const joinFollowUp = async (invocation: CommandInvocation): Promise => { if (uploader === undefined) { - return { - kind: 'error', - text: '匿名跟进未启用。部署者需要同时配置 allowAnonymousShare 和 ingestUrl。', - } + return { kind: 'error', text: '结构化分享未启用;当前不会产生反馈网络请求。' } } - const parts = invocation.rawInput.trim().split(/\s+/u).filter(Boolean) - const target = parts.shift() - if (target === undefined || parts.some(part => part !== '--share-note')) { + let target: string + try { + target = parseJoinTarget(invocation.rawInput) + } catch { return { kind: 'error', text: JOIN_USAGE } } - const latestSessionEvent = invocation.agent.session.events.findLast(event => event.type === 'omdsh/feedback-recorded') - const eventId = target === 'latest' - ? latestSessionEvent?.data.eventId - : target + const eventId = target === 'latest' ? store.latestLocalRecord()?.event.eventId : target if (eventId === undefined || store.record(eventId) === undefined) { - return { kind: 'error', text: '找不到这条本地体验记录。请先提交一次结果。' } + return { kind: 'error', text: '找不到这条本地体验记录。请先确认一次结果。' } } const existing = store.latestReceipts().find(receipt => receipt.eventId === eventId) if (existing !== undefined) return { kind: 'success', text: renderReceipt(existing).join('\n') } - const shareNote = parts.includes('--share-note') - store.requestShare(eventId, shareNote) + store.requestShare(eventId) try { const receipt = (await uploader.flushPending(eventId)).get(eventId) - if (receipt === undefined) return { kind: 'error', text: '没有生成远端回执,请稍后运行 /omdsh-inbox。' } + if (receipt === undefined) return { kind: 'error', text: '反馈服务没有返回回执。' } store.markSeen(receipt) return { kind: 'success', text: renderReceipt(receipt).join('\n') } - } catch (error: unknown) { - return { - kind: 'success', - text: `已加入本地发送队列;网络恢复后会自动重试。\n${error instanceof Error ? error.message : String(error)}`, - } + } catch { + return { kind: 'success', text: '已加入本地发送队列;网络恢复后只会重试同一份有限字段。' } } } @@ -554,8 +266,8 @@ export function apply(ctx: Context, rawConfig: Config): void { if (uploader !== undefined) { try { await uploader.refreshReceipts() - } catch (error: unknown) { - ctx.logger.warn(`plugin-lab: receipt refresh failed: ${String(error)}`) + } catch { + // No logging: network failures remain an unavailable state, not diagnostic data. } } const unread = store.unreadReceipts() @@ -569,134 +281,103 @@ export function apply(ctx: Context, rawConfig: Config): void { } const status = (invocation: CommandInvocation): CommandResult => { - const state = trials.get(sessionKey(invocation.agent.session)) - if (state === undefined) { - return { - kind: 'success', - text: [ - '当前没有进行中的 Trial。', - `待发送匿名记录:${store.pending().length} 条。`, - `未读处理进展:${store.unreadReceipts().length} 条。`, - ].join('\n'), - } - } - const metrics = snapshotMetrics(state.metrics) - return { - kind: 'success', - text: [ - `Trial:${state.plugin.moduleName}${state.plugin.version === undefined ? '' : `#${state.plugin.version}`}`, - `Loader:${loaderHealth(ctx, state.plugin.moduleName)}`, - `Assistant 回复:${metrics.assistantMessages}`, - `Tool:${metrics.toolCalls} 次,错误 ${metrics.toolErrors} 次`, - `Turn:${metrics.turnsCompleted}/${metrics.turnsStarted}`, - `进程崩溃:${metrics.processCrashes} 次${metrics.crashes?.[0] === undefined ? '' : `(${metrics.crashes[0].name} / ${metrics.crashes[0].fingerprint})`}`, - `首回复:${metrics.firstReplyMs === undefined ? '尚未产生' : `${metrics.firstReplyMs} ms`}`, - ].join('\n'), - } + const state = trials.get(sessionKey(invocation)) + const lines = state === undefined + ? ['当前没有进行中的插件试用。'] + : [ + `插件:${state.plugin.moduleName}${state.plugin.version === undefined ? '' : `#${state.plugin.version}`}`, + `运行状态:${healthText(health(ctx, state.plugin))}`, + '主观体验:未确认', + ] + lines.push(`待发送记录:${store.pending().length} 条。`, `未读处理进展:${store.unreadReceipts().length} 条。`) + return { kind: 'success', text: lines.join('\n') } } const privacy = (): CommandResult => ({ kind: 'success', text: [ - `匿名分享:${uploader === undefined ? '未启用' : '可由用户逐次通过 --share 触发'}`, - '默认:仅本地保存。', - '匿名结构化字段:插件名/版本、DSH/Node/OS、任务 ID、加载状态、时延和错误计数、结果与保留意愿。', - '永不自动发送:Prompt、回复正文、Tool 参数/结果、cwd、Session ID。', - '备注默认不发送;只有 --share-note 会发送备注。', - '进行中 Trial 若遇到进程崩溃,只记录错误类型、错误码、归一化首帧和指纹;不记录原始 message、完整 stack 或绝对路径。', - `本地数据:${store.dataDir}`, - '结果先用 /omdsh-result 存在本机;/omdsh-join latest 才加入匿名跟进。', - '发送前也可在 /omdsh-feedback 参数末尾加 --dry-run 查看完整 JSON。', + `结构化分享:${uploader === undefined ? '未启用' : '只能由用户逐次运行 /omdsh-join 触发'}`, + '探活:仅本地读取 DSH Host 的 Loader/Fiber 生命周期枚举,不访问插件对象或网络。', + 'Agent:零参数工具只返回 health、experience=unknown、userConfirmationRequired=true。', + '可发送字段:schemaVersion、type、随机单次 eventId、公开插件 ID/版本、health、experience、source。', + '绝不读取或发送:日志、异常、堆栈、崩溃指纹、Prompt、回复、Tool 数据、文件、路径、环境、时间、用户/设备/安装/Session ID。', + '网络仍会自然暴露传输元数据,因此本插件不宣称匿名;服务端必须禁止持久化 IP、User-Agent 和请求体日志。', + `本地最小化数据:${store.dataDir}`, ].join('\n'), }) - const resetId = (invocation: CommandInvocation): CommandResult => { - if (invocation.rawInput.trim() !== 'confirm') { - return { kind: 'error', text: 'Usage: /omdsh-reset-id confirm' } - } - store.resetParticipantId() - return { - kind: 'success', - text: '已生成新的 Plugin Lab 匿名 ID。此前已经上传的数据不会因此被远端删除。', - } - } - ctx.commands.register({ name: 'omdsh-start', - description: '开始一次有明确目标插件的体验 Trial', - input: { hint: '[#version] [task-id]' }, + description: '开始一次单插件试用;不采集会话内容', + input: { hint: '[#version]' }, recordInput: false, handler: startTrial, }) ctx.commands.register({ - name: 'omdsh-feedback', - description: '兼容入口:提交结构化插件体感;默认仅本地保存', - input: { hint: ' [flags] [note]' }, + name: 'omdsh-probe', + description: '一键查看当前插件的无日志运行状态', recordInput: false, - handler: submitFeedback, + handler: probe, }) ctx.commands.register({ name: 'omdsh-result', - description: '记录这次插件是否做成;只保存在本机', - input: { hint: ' [note]' }, + description: '由用户确认主观体验;只保存在本机', + input: { hint: '' }, + recordInput: false, + handler: submitResult, + }) + ctx.commands.register({ + name: 'omdsh-feedback', + description: '兼容入口:由用户确认主观体验;只保存在本机', + input: { hint: '' }, recordInput: false, handler: submitResult, }) ctx.commands.register({ name: 'omdsh-join', - description: '匿名加入相似问题、获取处理回执和后续通知', - input: { hint: ' [--share-note]' }, + description: '明确发送已经显示给用户的有限状态字段', + input: { hint: '' }, recordInput: false, handler: joinFollowUp, }) ctx.commands.register({ name: 'omdsh-inbox', - description: '查看问题确认、修复发布与复测邀请', + description: '查看聚合问题、修复版本与复测邀请', input: { hint: '[--peek]' }, recordInput: false, handler: inbox, }) ctx.commands.register({ name: 'omdsh-retest', - description: '从问题回执启动一次修复复测', - input: { hint: ' [#version] [task-id]' }, + description: '从问题回执开始一次零内容复测', + input: { hint: ' [#version]' }, recordInput: false, handler: startRetest, }) ctx.commands.register({ name: 'omdsh-status', - description: '查看当前 Trial 的无内容运行指标', + description: '查看当前试用与本地反馈状态', recordInput: false, handler: status, }) ctx.commands.register({ name: 'omdsh-privacy', - description: '查看 Plugin Lab 的本地存储与分享边界', + description: '查看零日志数据边界与完整可发送字段', recordInput: false, handler: privacy, }) - ctx.commands.register({ - name: 'omdsh-reset-id', - description: '重置 Plugin Lab 自己的匿名安装 ID', - input: { hint: 'confirm' }, - recordInput: false, - handler: resetId, - }) if (uploader !== undefined) { const flush = (): void => { - void Promise.all([ - uploader.flushPending(), - uploader.refreshReceipts(), - ]).catch(error => { - ctx.logger.warn(`plugin-lab: background exchange failed: ${String(error)}`) + void Promise.all([uploader.flushPending(), uploader.refreshReceipts()]).catch(() => { + // Explicitly queued packets remain local; no exception text is logged. }) } flush() ctx.effect(() => { const timer = setInterval(flush, config.retryIntervalMs) return () => clearInterval(timer) - }, 'plugin-lab upload retry') + }, 'plugin-lab closed-packet retry') } } diff --git a/src/input.ts b/src/input.ts index b451df0..3a4e0bc 100644 --- a/src/input.ts +++ b/src/input.ts @@ -1,95 +1,46 @@ -import type { RetentionIntent, TrialOutcome, TrialPluginRef } from './protocol.js' +import type { ExperienceVerdict, TrialPluginRef } from './protocol.js' export interface StartInput { readonly plugin: TrialPluginRef - readonly taskId?: string } -export interface FeedbackInput { - readonly outcome: TrialOutcome - readonly retention: RetentionIntent - readonly note?: string - readonly share: boolean - readonly shareNote: boolean - readonly dryRun: boolean -} - -export interface ResultInput { - readonly outcome: TrialOutcome - readonly note?: string -} +const MODULE = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/u +const VERSION = /^[0-9A-Za-z][0-9A-Za-z.+_-]{0,63}$/u +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu +const VERDICTS = new Set(['good', 'mixed', 'bad']) -const OUTCOMES = new Set(['worked', 'partial', 'failed']) -const RETENTION = new Set(['keep', 'unsure', 'remove']) - -export const START_USAGE = 'Usage: /omdsh-start [#version] [task-id]' -export const FEEDBACK_USAGE = 'Usage: /omdsh-feedback [--share] [--share-note] [--dry-run] [note]' -export const RESULT_USAGE = 'Usage: /omdsh-result [note]' -export const JOIN_USAGE = 'Usage: /omdsh-join [--share-note]' -export const RETEST_USAGE = 'Usage: /omdsh-retest [#version] [task-id]' +export const START_USAGE = 'Usage: /omdsh-start [#version]' +export const RESULT_USAGE = 'Usage: /omdsh-result ' +export const JOIN_USAGE = 'Usage: /omdsh-join ' +export const RETEST_USAGE = 'Usage: /omdsh-retest [#version]' export function parseStartInput(rawInput: string): StartInput { const parts = rawInput.trim().split(/\s+/u).filter(Boolean) const pluginSpec = parts[0] - if (pluginSpec === undefined) throw new TypeError(START_USAGE) + if (pluginSpec === undefined || parts.length !== 1) throw new TypeError(START_USAGE) const hash = pluginSpec.lastIndexOf('#') const moduleName = hash > 0 ? pluginSpec.slice(0, hash) : pluginSpec const version = hash > 0 ? pluginSpec.slice(hash + 1) : undefined - if (moduleName.length === 0 || version === '') throw new TypeError(START_USAGE) - const taskId = parts[1] - if (parts.length > 2) throw new TypeError(`${START_USAGE}. task-id must not contain spaces.`) - return { - plugin: { moduleName, ...version === undefined ? {} : { version } }, - ...taskId === undefined ? {} : { taskId }, + if (!MODULE.test(moduleName) || (version !== undefined && !VERSION.test(version))) { + throw new TypeError(START_USAGE) } + return { plugin: { moduleName, ...version === undefined ? {} : { version } } } } -export function parseFeedbackInput(rawInput: string): FeedbackInput { - const parts = rawInput.trim().split(/\s+/u).filter(Boolean) - const outcome = parts.shift() - const retention = parts.shift() - if (!OUTCOMES.has(outcome as TrialOutcome) || !RETENTION.has(retention as RetentionIntent)) { - throw new TypeError(FEEDBACK_USAGE) - } - let share = false - let shareNote = false - let dryRun = false - const noteParts: string[] = [] - for (const part of parts) { - if (part === '--share') share = true - else if (part === '--share-note') { - share = true - shareNote = true - } else if (part === '--dry-run') dryRun = true - else noteParts.push(part) - } - const note = noteParts.join(' ').trim() - if (shareNote && note.length === 0) { - throw new TypeError('--share-note requires a non-empty note') - } - return { - outcome: outcome as TrialOutcome, - retention: retention as RetentionIntent, - ...note.length === 0 ? {} : { note }, - share, - shareNote, - dryRun, - } +export function parseVerdict(rawInput: string): ExperienceVerdict { + const value = rawInput.trim() + if (!VERDICTS.has(value as ExperienceVerdict)) throw new TypeError(RESULT_USAGE) + return value as ExperienceVerdict } -export function parseResultInput(rawInput: string): ResultInput { +export function parseJoinTarget(rawInput: string): string { const parts = rawInput.trim().split(/\s+/u).filter(Boolean) - const outcome = parts.shift() - if (!OUTCOMES.has(outcome as TrialOutcome)) throw new TypeError(RESULT_USAGE) - const note = parts.join(' ').trim() - return { - outcome: outcome as TrialOutcome, - ...note.length === 0 ? {} : { note }, - } + if (parts.length !== 1 || parts[0] === undefined + || (parts[0] !== 'latest' && !UUID.test(parts[0]))) throw new TypeError(JOIN_USAGE) + return parts[0] } -export function retentionForOutcome(outcome: TrialOutcome): RetentionIntent { - if (outcome === 'worked') return 'keep' - if (outcome === 'partial') return 'unsure' - return 'remove' +export function parseReceiptId(value: string): string { + if (!UUID.test(value)) throw new TypeError(RETEST_USAGE) + return value } diff --git a/src/protocol.ts b/src/protocol.ts index 2674565..31e66b2 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -1,144 +1,80 @@ -/** Public, versioned protocol shared by the DSH plugin and an ingest backend. */ +/** Closed, zero-content protocol shared by the DSH plugin and ingest backend. */ -export const EXPERIENCE_SCHEMA_VERSION = 1 as const +export const FEEDBACK_SCHEMA_VERSION = 2 as const -export type TrialOutcome = 'worked' | 'partial' | 'failed' -export type RetentionIntent = 'keep' | 'unsure' | 'remove' -export type LoaderHealth = - | 'active' - | 'disabled' - | 'failed' - | 'loading' - | 'missing' - | 'pending' - | 'unloading' - | 'unknown' +export type HealthStatus = 'ok' | 'unavailable' | 'error' | 'unknown' +export type ExperienceVerdict = 'good' | 'mixed' | 'bad' export interface TrialPluginRef { + /** Public DSH marketplace/package identifier; never a local path or user label. */ readonly moduleName: string readonly version?: string } -/** Privacy-preserving process crash evidence; no raw message, stack, or absolute path. */ -export interface RuntimeCrashSignal { - readonly fingerprint: string - readonly name: string - readonly origin: 'uncaughtException' | 'unhandledRejection' - readonly code?: string - readonly frame?: string +/** The only value exposed to the Agent-facing analysis tool. */ +export interface SafeExperienceAssessment { + readonly health: HealthStatus + readonly experience: 'unknown' + readonly userConfirmationRequired: true } -/** Synchronously persisted local crash journal entry. It is merged into a Trial after restart. */ -export interface LocalCrashRecord { - readonly crashId: string - readonly trialId: string - readonly occurredAt: number - readonly crash: RuntimeCrashSignal -} - -export interface TrialMetrics { - readonly assistantMessages: number - readonly turnsStarted: number - readonly turnsCompleted: number - readonly toolCalls: number - readonly toolErrors: number - readonly agentErrors: number - readonly processCrashes: number - readonly crashes?: readonly RuntimeCrashSignal[] - readonly firstReplyMs?: number - readonly lastTurnReason?: string -} - -export interface ExperienceEventV1 { - readonly schemaVersion: typeof EXPERIENCE_SCHEMA_VERSION - readonly type: 'feedback.submitted' +/** + * Exact upload envelope. It intentionally contains no timestamp, stable user or + * install identifier, task/session identifier, metrics, notes, error or log data. + */ +export interface FeedbackEventV2 { + readonly schemaVersion: typeof FEEDBACK_SCHEMA_VERSION + readonly type: 'feedback.signal' + /** Random per-report id used only for idempotency. */ readonly eventId: string - readonly occurredAt: number - /** Random identity scoped only to this plugin's data directory. */ - readonly participantId: string - readonly trial: { - readonly id: string - readonly plugin: TrialPluginRef - readonly taskId?: string - readonly startedAt: number - readonly durationMs: number - /** Receipt that invited this run, when the user is verifying a fix. */ - readonly retestOfReceiptId?: string - } - readonly environment: { - readonly dshVersion: string - readonly nodeVersion: string - readonly platform: string - readonly arch: string - readonly locale: string - readonly profileLabel: string - } - readonly signals: TrialMetrics & { - readonly loaderHealth: LoaderHealth - } - readonly feedback: { - readonly outcome: TrialOutcome - readonly retention: RetentionIntent - readonly note?: string - } - readonly sharing: { - readonly transcript: 'none' - readonly noteIncluded: boolean - } + readonly plugin: TrialPluginRef + readonly health: HealthStatus + readonly experience: ExperienceVerdict + readonly source: 'user_confirmed' + /** Optional, report-scoped link used only when the user explicitly starts a retest. */ + readonly retestOfReceiptId?: string } -export interface LocalExperienceRecord { - readonly event: ExperienceEventV1 +export interface LocalFeedbackRecord { + readonly event: FeedbackEventV2 readonly requestedShare: boolean - readonly shareNote: boolean } +export type ReceiptStatus = + | 'received' + | 'clustered' + | 'reported' + | 'fix-released' + | 'retest-requested' + | 'verified' + | 'confirmed' + | 'closed' + export interface IngestReceipt { readonly eventId: string - readonly receivedAt: number readonly receiptId?: string + readonly caseId?: string readonly status?: ReceiptStatus readonly similarReports?: number - readonly message?: string readonly recommendedVersion?: string readonly trackingUrl?: string - readonly caseId?: string - /** Random bearer secret. It stays in the local private receipt store. */ + /** Random per-report bearer secret; it stays in the local receipt store. */ readonly followToken?: string readonly updatesUrl?: string readonly updatedAt?: number } -export type ReceiptStatus = - | 'received' - | 'clustered' - | 'confirmed' - | 'reported' - | 'fix-released' - | 'retest-requested' - | 'verified' - | 'closed' - export interface ShareRequest { readonly eventId: string - readonly requestedAt: number - readonly shareNote: boolean } export interface ReceiptSeen { readonly receiptId: string readonly status?: ReceiptStatus readonly updatedAt?: number - readonly seenAt: number } -/** Remove the local-only note unless the user explicitly chose --share-note. */ -export function uploadPayload(record: LocalExperienceRecord): ExperienceEventV1 { - if (record.shareNote || record.event.feedback.note === undefined) return record.event - const { note: _localOnlyNote, ...feedback } = record.event.feedback - return { - ...record.event, - feedback, - sharing: { transcript: 'none', noteIncluded: false }, - } +/** The local record is already the exact network packet; no projection can add fields. */ +export function uploadPayload(record: LocalFeedbackRecord): FeedbackEventV2 { + return record.event } diff --git a/src/storage.ts b/src/storage.ts index 1484a68..8986c9a 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -1,12 +1,10 @@ -import { appendFileSync, chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { appendFileSync, chmodSync, mkdirSync, readFileSync } from 'node:fs' import { homedir } from 'node:os' import { isAbsolute, join } from 'node:path' import type { - IngestReceipt, LocalCrashRecord, LocalExperienceRecord, ReceiptSeen, ShareRequest, + IngestReceipt, LocalFeedbackRecord, ReceiptSeen, ShareRequest, } from './protocol.js' -const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu - function readLines(path: string): T[] { let text: string try { @@ -37,66 +35,36 @@ export function defaultDataDir(): string { return join(dshHome, 'omdsh-plugin-lab') } -export class ExperienceStore { +/** Local outbox for the same closed packet sent over the wire. It stores no logs or identifiers. */ +export class FeedbackStore { readonly dataDir: string readonly eventsPath: string - readonly crashesPath: string readonly receiptsPath: string readonly shareRequestsPath: string readonly receiptSeenPath: string - private readonly identityPath: string constructor(dataDir = defaultDataDir()) { if (!isAbsolute(dataDir)) throw new TypeError('plugin-lab: dataDir must be an absolute path') this.dataDir = dataDir - this.eventsPath = join(dataDir, 'events.ndjson') - this.crashesPath = join(dataDir, 'crashes.ndjson') - this.receiptsPath = join(dataDir, 'receipts.ndjson') - this.shareRequestsPath = join(dataDir, 'share-requests.ndjson') - this.receiptSeenPath = join(dataDir, 'receipt-seen.ndjson') - this.identityPath = join(dataDir, '.install-id') + this.eventsPath = join(dataDir, 'feedback-v2.ndjson') + this.receiptsPath = join(dataDir, 'receipts-v2.ndjson') + this.shareRequestsPath = join(dataDir, 'share-requests-v2.ndjson') + this.receiptSeenPath = join(dataDir, 'receipt-seen-v2.ndjson') mkdirSync(dataDir, { recursive: true, mode: 0o700 }) chmodSync(dataDir, 0o700) } - participantId(): string { - try { - const existing = readFileSync(this.identityPath, 'utf8').trim() - if (UUID.test(existing)) return existing - } catch (error: unknown) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error - } - return this.resetParticipantId() - } - - resetParticipantId(): string { - const id = crypto.randomUUID() - writeFileSync(this.identityPath, `${id}\n`, { encoding: 'utf8', mode: 0o600 }) - chmodSync(this.identityPath, 0o600) - return id - } - - append(record: LocalExperienceRecord): void { + append(record: LocalFeedbackRecord): void { appendJson(this.eventsPath, record) } - /** This deliberately uses synchronous append: the process may exit immediately afterward. */ - appendCrash(record: LocalCrashRecord): void { - appendJson(this.crashesPath, record) - } - - crashRecords(trialId?: string): LocalCrashRecord[] { - const records = readLines(this.crashesPath) - return trialId === undefined ? records : records.filter(record => record.trialId === trialId) - } - appendReceipt(receipt: IngestReceipt): void { appendJson(this.receiptsPath, receipt) } - requestShare(eventId: string, shareNote = false): void { - if (this.record(eventId) === undefined) throw new Error(`unknown local event: ${eventId}`) - appendJson(this.shareRequestsPath, { eventId, requestedAt: Date.now(), shareNote } satisfies ShareRequest) + requestShare(eventId: string): void { + if (this.record(eventId) === undefined) throw new Error('unknown local feedback event') + appendJson(this.shareRequestsPath, { eventId } satisfies ShareRequest) } markSeen(receipt: IngestReceipt): void { @@ -105,23 +73,22 @@ export class ExperienceStore { receiptId: receipt.receiptId, ...receipt.status === undefined ? {} : { status: receipt.status }, ...receipt.updatedAt === undefined ? {} : { updatedAt: receipt.updatedAt }, - seenAt: Date.now(), } satisfies ReceiptSeen) } - records(): LocalExperienceRecord[] { - return readLines(this.eventsPath) + records(): LocalFeedbackRecord[] { + return readLines(this.eventsPath) } receipts(): IngestReceipt[] { return readLines(this.receiptsPath) } - record(eventId: string): LocalExperienceRecord | undefined { + record(eventId: string): LocalFeedbackRecord | undefined { return this.records().findLast(record => record.event.eventId === eventId) } - latestLocalRecord(): LocalExperienceRecord | undefined { + latestLocalRecord(): LocalFeedbackRecord | undefined { return this.records().at(-1) } @@ -143,19 +110,13 @@ export class ExperienceStore { }) } - pending(): LocalExperienceRecord[] { + pending(): LocalFeedbackRecord[] { const delivered = new Set(this.receipts().map(receipt => receipt.eventId)) - const requests = new Map() - for (const request of readLines(this.shareRequestsPath)) requests.set(request.eventId, request) + const requests = new Set(readLines(this.shareRequestsPath).map(row => row.eventId)) return this.records().flatMap(record => { if (delivered.has(record.event.eventId)) return [] - const request = requests.get(record.event.eventId) - if (!record.requestedShare && request === undefined) return [] - return [{ - ...record, - requestedShare: true, - shareNote: record.shareNote || request?.shareNote === true, - }] + if (!record.requestedShare && !requests.has(record.event.eventId)) return [] + return [{ ...record, requestedShare: true }] }) } } diff --git a/src/uploader.ts b/src/uploader.ts index 087d7de..ff48861 100644 --- a/src/uploader.ts +++ b/src/uploader.ts @@ -1,6 +1,6 @@ -import type { IngestReceipt, LocalExperienceRecord, ReceiptStatus } from './protocol.js' +import type { IngestReceipt, LocalFeedbackRecord, ReceiptStatus } from './protocol.js' import { uploadPayload } from './protocol.js' -import type { ExperienceStore } from './storage.js' +import type { FeedbackStore } from './storage.js' export interface UploaderConfig { readonly ingestUrl: string @@ -15,7 +15,6 @@ function optionalString(value: unknown): string | undefined { function parseReceipt(eventId: string, value: unknown): IngestReceipt { const row = typeof value === 'object' && value !== null ? value as Record : {} const receiptId = optionalString(row.receiptId) - const message = optionalString(row.message) const recommendedVersion = optionalString(row.recommendedVersion) const trackingUrl = optionalString(row.trackingUrl) const caseId = optionalString(row.caseId) @@ -35,11 +34,9 @@ function parseReceipt(eventId: string, value: unknown): IngestReceipt { : undefined return { eventId, - receivedAt: Date.now(), ...receiptId === undefined ? {} : { receiptId }, ...status === undefined ? {} : { status }, ...similarReports === undefined ? {} : { similarReports }, - ...message === undefined ? {} : { message }, ...recommendedVersion === undefined ? {} : { recommendedVersion }, ...trackingUrl === undefined ? {} : { trackingUrl }, ...caseId === undefined ? {} : { caseId }, @@ -51,7 +48,7 @@ function parseReceipt(eventId: string, value: unknown): IngestReceipt { export class ExperienceUploader { constructor( - private readonly store: ExperienceStore, + private readonly store: FeedbackStore, private readonly config: UploaderConfig, ) {} @@ -87,8 +84,7 @@ export class ExperienceUploader { } if (merged.status !== previous.status || merged.updatedAt !== previous.updatedAt - || merged.recommendedVersion !== previous.recommendedVersion - || merged.message !== previous.message) { + || merged.recommendedVersion !== previous.recommendedVersion) { this.store.appendReceipt(merged) changed.push(merged) } @@ -96,7 +92,7 @@ export class ExperienceUploader { return changed } - private async send(record: LocalExperienceRecord): Promise { + private async send(record: LocalFeedbackRecord): Promise { const headers: Record = { 'content-type': 'application/json', 'idempotency-key': record.event.eventId, @@ -111,7 +107,7 @@ export class ExperienceUploader { signal: AbortSignal.timeout(this.config.requestTimeoutMs), }) if (!response.ok) { - throw new Error(`ingest returned HTTP ${response.status}`) + throw new Error('feedback service did not accept the closed packet') } const contentType = response.headers.get('content-type') ?? '' const body: unknown = contentType.includes('application/json') ? await response.json() : undefined diff --git a/tests/client-apply.spec.ts b/tests/client-apply.spec.ts index 02dc665..f3dd5e8 100644 --- a/tests/client-apply.spec.ts +++ b/tests/client-apply.spec.ts @@ -5,6 +5,7 @@ import { SlotCore, type StoredEntry } from '@deepseek-ai/dsh-client-ui-slots' import { describe, expect, it, vi } from 'vitest' import type { LabInjected } from '../src/client/ExperienceResultCard.js' import type { InboxInjected } from '../src/client/InboxButton.js' +import type { ProbeInjected } from '../src/client/ProbeButton.js' import { apply, inject } from '../src/client/index.js' import type { CommandsRemote } from '../src/client/controller.js' @@ -82,9 +83,13 @@ async function bench() { const declaration = declare(slots) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() - const resultEntry = () => slots.entries('conversation.chat.assistant-actions')[0] - const inboxEntry = () => slots.entries('conversation.input.left')[0] - return { ctx, slots, calls, declaration, fiber, resultEntry, inboxEntry } + const resultEntry = () => slots.entries('conversation.chat.assistant-actions') + .find(entry => entry.options.id === 'omdsh-plugin-lab') + const inboxEntry = () => slots.entries('conversation.input.left') + .find(entry => entry.options.id === 'omdsh-plugin-lab-inbox') + const probeEntry = () => slots.entries('conversation.input.left') + .find(entry => entry.options.id === 'omdsh-plugin-lab-probe') + return { ctx, slots, calls, declaration, fiber, resultEntry, inboxEntry, probeEntry } } function resultFace(entry: ReturnType>['resultEntry']>, sessionId: SessionId): LabInjected { @@ -95,12 +100,17 @@ function inboxFace(entry: ReturnType>['inboxEnt return (entry?.inject as unknown as (id: SessionId) => InboxInjected)(sessionId) } +function probeFace(entry: ReturnType>['probeEntry']>, sessionId: SessionId): ProbeInjected { + return (entry?.inject as unknown as (id: SessionId) => ProbeInjected)(sessionId) +} + describe('rc.6 client plugin contract', () => { - it('registers both documented Slot entries over a real rc.6 SlotRegistry', async () => { + it('registers all documented Slot entries over a real rc.6 SlotRegistry', async () => { const b = await bench() expect(inject).toEqual(['slots', 'remote', 'remote.commands']) expect(b.resultEntry()?.options).toMatchObject({ id: 'omdsh-plugin-lab', order: 20 }) expect(b.inboxEntry()?.options).toMatchObject({ id: 'omdsh-plugin-lab-inbox', order: 40 }) + expect(b.probeEntry()?.options).toMatchObject({ id: 'omdsh-plugin-lab-probe', order: 39 }) await b.fiber.dispose() }) @@ -122,14 +132,17 @@ describe('rc.6 client plugin contract', () => { await b.fiber.dispose() }) - it('shares one controller across the two Slot faces in a Session', async () => { + it('shares one controller across all Slot faces in a Session', async () => { const b = await bench() const result = resultFace(b.resultEntry(), sid('same')) const inbox = inboxFace(b.inboxEntry(), sid('same')) + const probe = probeFace(b.probeEntry(), sid('same')) b.ctx.emit('command/executed', sid('same'), 'omdsh-start', { kind: 'success' }) expect(result.hooks.pluginLab.getSnapshot().active).toBe(true) await inbox.checkInbox() expect(b.calls.at(-1)).toEqual({ sessionId: 'same', line: '/omdsh-inbox' }) + await probe.checkHealth() + expect(b.calls.at(-1)).toEqual({ sessionId: 'same', line: '/omdsh-probe' }) await b.fiber.dispose() }) @@ -138,19 +151,23 @@ describe('rc.6 client plugin contract', () => { b.declaration() expect(b.resultEntry()).toBeUndefined() expect(b.inboxEntry()).toBeUndefined() + expect(b.probeEntry()).toBeUndefined() const redeclare = declare(b.slots) await Promise.resolve() expect(b.resultEntry()).toBeDefined() expect(b.inboxEntry()).toBeDefined() + expect(b.probeEntry()).toBeDefined() await b.fiber.dispose() expect(b.resultEntry()).toBeUndefined() expect(b.inboxEntry()).toBeUndefined() + expect(b.probeEntry()).toBeUndefined() const reloaded = b.ctx.plugin({ inject: [...inject], apply }) await reloaded.await() expect(b.resultEntry()).toBeDefined() expect(b.inboxEntry()).toBeDefined() + expect(b.probeEntry()).toBeDefined() await reloaded.dispose() redeclare() }) diff --git a/tests/client-components.spec.tsx b/tests/client-components.spec.tsx index 8adb018..df98d39 100644 --- a/tests/client-components.spec.tsx +++ b/tests/client-components.spec.tsx @@ -9,6 +9,7 @@ import { type ExperienceResultCardProps, } from '../src/client/ExperienceResultCard.js' import { InboxButton, type InboxButtonProps } from '../src/client/InboxButton.js' +import { ProbeButton, type ProbeButtonProps } from '../src/client/ProbeButton.js' import { LabController, type CommandsRemote } from '../src/client/controller.js' afterEach(cleanup) @@ -66,9 +67,9 @@ describe('rc.6 client components', () => { rendered.rerender() fireEvent.click(screen.getByRole('button', { name: '体验结果' })) - fireEvent.click(screen.getByRole('button', { name: '没做成' })) + fireEvent.click(screen.getByRole('button', { name: '不好用' })) await screen.findByText('已只保存在本机') - expect(execute).toHaveBeenLastCalledWith(SESSION, '/omdsh-result failed') + expect(execute).toHaveBeenLastCalledWith(SESSION, '/omdsh-result bad') fireEvent.click(screen.getByRole('button', { name: '加入并等待修复' })) await screen.findByText('问题回执:PL-RC6') @@ -84,4 +85,12 @@ describe('rc.6 client components', () => { fireEvent.click(screen.getByRole('button', { name: '反馈进展' })) expect(screen.queryByRole('status')).toBeNull() }) + + it('runs one-click local health without sharing', async () => { + const checkHealth = vi.fn(async () => '运行状态:当前运行 OK') + render() + fireEvent.click(screen.getByRole('button', { name: '插件探活' })) + await waitFor(() => { expect(screen.getByRole('status').textContent).toContain('当前运行 OK') }) + expect(checkHealth).toHaveBeenCalledOnce() + }) }) diff --git a/tests/client-controller.spec.ts b/tests/client-controller.spec.ts index 2f423e9..db5e120 100644 --- a/tests/client-controller.spec.ts +++ b/tests/client-controller.spec.ts @@ -19,16 +19,18 @@ describe('result-card controller', () => { controller.setTrialActive(true) expect(controller.getSnapshot()).toMatchObject({ active: true }) - await controller.record('latest' as MessageId, 'failed') - expect(execute).toHaveBeenLastCalledWith('session', '/omdsh-result failed') + await controller.record('latest' as MessageId, 'bad') + expect(execute).toHaveBeenLastCalledWith('session', '/omdsh-result bad') expect(controller.getSnapshot()).toMatchObject({ active: false, - pending: { outcome: 'failed', phase: 'local' }, + pending: { verdict: 'bad', phase: 'local' }, }) await controller.join() expect(execute).toHaveBeenLastCalledWith('session', '/omdsh-join latest') expect(controller.getSnapshot().pending).toMatchObject({ phase: 'joined', text: '问题回执:PL-1234' }) + await expect(controller.probe()).resolves.toBe('问题回执:PL-1234') + expect(execute).toHaveBeenLastCalledWith('session', '/omdsh-probe') }) it('surfaces rc.6 transport failures and undefined command admissions', async () => { @@ -38,7 +40,7 @@ describe('result-card controller', () => { })) const controller = new LabController({ execute: failed }, 'session' as SessionId) controller.setTrialActive(true) - await controller.record('message' as MessageId, 'partial') + await controller.record('message' as MessageId, 'mixed') expect(controller.getSnapshot().pending).toMatchObject({ phase: 'error', text: '连接已断开 (disconnected)', }) @@ -46,7 +48,7 @@ describe('result-card controller', () => { const absent: CommandsRemote['execute'] = vi.fn(async () => ({ ok: true as const, value: undefined })) const second = new LabController({ execute: absent }, 'session' as SessionId) second.setTrialActive(true) - await second.record('message' as MessageId, 'failed') + await second.record('message' as MessageId, 'bad') expect(second.getSnapshot().pending).toMatchObject({ phase: 'error', text: '命令未被 DSH 接收。', }) diff --git a/tests/crash.spec.ts b/tests/crash.spec.ts deleted file mode 100644 index 4dac634..0000000 --- a/tests/crash.spec.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { runtimeCrashSignal, sanitizeCrashFrame } from '../src/crash.js' - -describe('privacy-preserving crash signatures', () => { - it('keeps a package-relative top frame and removes message, function and machine path', () => { - const error = Object.assign(new TypeError('secret prompt and user content'), { code: 'ERR_PLUGIN_STATE' }) - error.stack = [ - 'TypeError: secret prompt and user content', - ' at privateCustomerFunction (file:///Users/alice/project/node_modules/@vendor/plugin/dist/index.js:42:7)', - ' at /Users/alice/project/private-file.ts:9:1', - ].join('\n') - const signal = runtimeCrashSignal(error, 'uncaughtException') - expect(signal).toMatchObject({ - name: 'TypeError', code: 'ERR_PLUGIN_STATE', origin: 'uncaughtException', - frame: 'node_modules/@vendor/plugin/dist/index.js:42:7', - }) - expect(signal.fingerprint).toMatch(/^[0-9a-f]{20}$/u) - const encoded = JSON.stringify(signal) - expect(encoded).not.toContain('secret prompt') - expect(encoded).not.toContain('privateCustomerFunction') - expect(encoded).not.toContain('/Users/alice') - }) - - it('normalizes application and Node built-in frames without exposing directories', () => { - expect(sanitizeCrashFrame('Error\n at run (file:///private/work/src/plugin.ts:8:3)')) - .toBe('/plugin.ts:8:3') - expect(sanitizeCrashFrame('Error\n at node:internal/process/task_queues:95:5')) - .toBe('node:internal/process/task_queues:95:5') - }) - - it('produces a stable fingerprint for the same structural crash', () => { - const first = new Error('one message') - first.stack = 'Error: one message\n at run (/tmp/a/node_modules/plugin/dist/index.js:1:2)' - const second = new Error('different private message') - second.stack = 'Error: different private message\n at other (/different/root/node_modules/plugin/dist/index.js:1:2)' - expect(runtimeCrashSignal(first, 'unhandledRejection').fingerprint) - .toBe(runtimeCrashSignal(second, 'unhandledRejection').fingerprint) - }) - - it('drops free-form error names and codes instead of treating them as diagnostics', () => { - const error = Object.assign(new Error('private'), { - name: 'Customer 123 secret', - code: 'raw customer value', - }) - expect(runtimeCrashSignal(error, 'uncaughtException')).toMatchObject({ name: 'Error' }) - expect(runtimeCrashSignal(error, 'uncaughtException')).not.toHaveProperty('code') - }) -}) diff --git a/tests/diagnosis.spec.ts b/tests/diagnosis.spec.ts deleted file mode 100644 index c9f1a7d..0000000 --- a/tests/diagnosis.spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { diagnoseExperience } from '../src/diagnosis.js' -import type { ExperienceEventV1 } from '../src/protocol.js' - -function event(loaderHealth: ExperienceEventV1['signals']['loaderHealth']): ExperienceEventV1 { - return { - schemaVersion: 1, - type: 'feedback.submitted', - eventId: 'e', - occurredAt: 2, - participantId: 'p', - trial: { id: 't', plugin: { moduleName: 'plugin' }, startedAt: 1, durationMs: 1 }, - environment: { - dshVersion: 'v', nodeVersion: 'n', platform: 'darwin', arch: 'arm64', locale: 'zh', profileLabel: 'p', - }, - signals: { - loaderHealth, - assistantMessages: 0, - turnsStarted: 0, - turnsCompleted: 0, - toolCalls: 0, - toolErrors: 0, - agentErrors: 0, - processCrashes: 0, - }, - feedback: { outcome: 'failed', retention: 'remove' }, - sharing: { transcript: 'none', noteIncluded: false }, - } -} - -describe('diagnoseExperience()', () => { - it('turns missing Loader attribution into an actionable result', () => { - expect(diagnoseExperience(event('missing'), 'failed', 'remove')).toEqual({ - headline: '目标插件没有出现在当前 DSH Loader 中。', - actions: ['检查它是否已安装进当前 Profile,以及 Bundle patch 是否插入了插件行。'], - }) - }) - - it('does not overclaim causality when a trial is only partial', () => { - const diagnosis = diagnoseExperience(event('active'), 'partial', 'unsure') - expect(diagnosis.headline).toContain('证据还不足') - expect(diagnosis.actions.join(' ')).toContain('没有产生完整 Assistant 回复') - }) -}) diff --git a/tests/input.spec.ts b/tests/input.spec.ts index 60027a2..b95743c 100644 --- a/tests/input.spec.ts +++ b/tests/input.spec.ts @@ -1,44 +1,35 @@ import { describe, expect, it } from 'vitest' -import { parseFeedbackInput, parseStartInput } from '../src/input.js' +import { parseJoinTarget, parseReceiptId, parseStartInput, parseVerdict } from '../src/input.js' -describe('trial command input', () => { - it('parses a scoped module, version and task id', () => { - expect(parseStartInput(' @example/dsh-plugin#0.3.1 repo-search-v1 ')).toEqual({ - plugin: { moduleName: '@example/dsh-plugin', version: '0.3.1' }, - taskId: 'repo-search-v1', +describe('closed command inputs', () => { + it('accepts only a public module and optional bounded version', () => { + expect(parseStartInput(' @example/dsh-plugin#0.3.0 ')).toEqual({ + plugin: { moduleName: '@example/dsh-plugin', version: '0.3.0' }, }) + expect(parseStartInput('plugin')).toEqual({ plugin: { moduleName: 'plugin' } }) }) - it('rejects an absent target', () => { + it('rejects task labels, paths, urls and whitespace-bearing metadata', () => { + expect(() => parseStartInput('plugin private-task')).toThrow('/omdsh-start') + expect(() => parseStartInput('/Users/alice/plugin')).toThrow('/omdsh-start') + expect(() => parseStartInput('https://example.test/plugin')).toThrow('/omdsh-start') expect(() => parseStartInput(' ')).toThrow('/omdsh-start') }) -}) - -describe('feedback command input', () => { - it('keeps notes local unless explicitly shared', () => { - expect(parseFeedbackInput('worked keep --share 很省时间')).toEqual({ - outcome: 'worked', - retention: 'keep', - note: '很省时间', - share: true, - shareNote: false, - dryRun: false, - }) - }) - - it('makes --share-note imply sharing', () => { - expect(parseFeedbackInput('partial unsure --share-note 排序不清楚')).toMatchObject({ - share: true, - shareNote: true, - note: '排序不清楚', - }) - }) - it('requires a note when --share-note is chosen', () => { - expect(() => parseFeedbackInput('failed remove --share-note')).toThrow('non-empty note') + it('accepts only finite user-confirmed verdicts with no notes or flags', () => { + expect(parseVerdict('good')).toBe('good') + expect(parseVerdict('mixed')).toBe('mixed') + expect(parseVerdict('bad')).toBe('bad') + expect(() => parseVerdict('bad secret-note')).toThrow('/omdsh-result') + expect(() => parseVerdict('worked')).toThrow('/omdsh-result') }) - it('rejects unknown structured choices', () => { - expect(() => parseFeedbackInput('great maybe')).toThrow('/omdsh-feedback') + it('accepts one join target and rejects extra arguments', () => { + expect(parseJoinTarget('latest')).toBe('latest') + expect(parseJoinTarget('00000000-0000-4000-8000-000000000001')).toContain('4000') + expect(() => parseJoinTarget('latest --share-note')).toThrow('/omdsh-join') + expect(() => parseJoinTarget('private-receipt-label')).toThrow('/omdsh-join') + expect(parseReceiptId('00000000-0000-4000-8000-000000000001')).toContain('4000') + expect(() => parseReceiptId('private-receipt-label')).toThrow('/omdsh-retest') }) }) diff --git a/tests/plugin.spec.ts b/tests/plugin.spec.ts index 0df22bc..1df09e4 100644 --- a/tests/plugin.spec.ts +++ b/tests/plugin.spec.ts @@ -9,170 +9,142 @@ import SessionStore from '@deepseek-ai/dsh-session' import { describe, expect, it } from 'vitest' import plugin from '../src/index.js' -describe('DSH plugin integration', () => { - it('registers the consent-first command flow and records no transcript content', async () => { +async function runtime(config: Parameters[1]) { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(CommandRuntime) + const fiber = await ctx.plugin(plugin, config) + const session = ctx.sessions.create() + const agent = { id: session.id, session } as Agent + return { ctx, fiber, session, agent, signal: new AbortController().signal } +} + +describe('DSH strict feedback integration', () => { + it('registers one-click health and records only the closed v2 packet', async () => { const root = mkdtempSync(join(tmpdir(), 'omdsh-plugin-lab-')) - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(CommandRuntime) - const pluginFiber = await ctx.plugin(plugin, { dataDir: join(root, 'data'), profileLabel: 'test' }) - const session = ctx.sessions.create() - const agent = { id: session.id, session } as Agent - const signal = new AbortController().signal + const dataDir = join(root, 'data') + const { ctx, fiber, agent, signal } = await runtime({ dataDir }) - const start = await ctx.commands.execute( - agent, - '/omdsh-start @example/plugin#1.0.0 task-v1', - signal, - ) - expect(start?.result.text).toContain('Trial 已开始') - session.append('turn/start', { turn: 1 }) - session.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } }) + const commandNames = (await ctx.commands.list(agent)).map(command => command.name) + expect(commandNames).toEqual(expect.arrayContaining([ + 'omdsh-start', 'omdsh-probe', 'omdsh-result', 'omdsh-join', + 'omdsh-inbox', 'omdsh-retest', 'omdsh-status', 'omdsh-privacy', + ])) + expect(commandNames).not.toContain('omdsh-reset-id') - const feedback = await ctx.commands.execute( - agent, - '/omdsh-feedback partial unsure 本地备注', - signal, - ) - expect(feedback?.result.text).toContain('只保存在本机') - expect(feedback?.result.text).toContain('不包含 Prompt') - const custom = session.events.filter(event => event.type.startsWith('omdsh/')) - expect(custom.map(event => event.type)).toEqual(['omdsh/trial-started', 'omdsh/feedback-recorded']) - expect(JSON.stringify(custom)).not.toContain('本地备注') + await ctx.commands.execute(agent, '/omdsh-start @example/plugin#1.0.0', signal) + const health = await ctx.commands.execute(agent, '/omdsh-probe', signal) + expect(health?.result.text).toContain('暂时无法判断') + expect(health?.result.text).toContain('没有读取日志') - const status = await ctx.commands.execute(agent, '/omdsh-status', signal) - expect(status?.result.text).toContain('当前没有进行中的 Trial') - await pluginFiber.dispose() + const feedback = await ctx.commands.execute(agent, '/omdsh-result bad', signal) + expect(feedback?.result.text).toContain('不好用(由你确认)') + expect(feedback?.result.text).toContain('不含日志') + const stored = JSON.parse(readFileSync(join(dataDir, 'feedback-v2.ndjson'), 'utf8').trim()) + expect(stored).toEqual({ + event: { + schemaVersion: 2, + type: 'feedback.signal', + eventId: expect.any(String), + plugin: { moduleName: '@example/plugin', version: '1.0.0' }, + health: 'unknown', + experience: 'bad', + source: 'user_confirmed', + }, + requestedShare: false, + }) + const forbidden = [ + 'participantId', 'occurredAt', 'taskId', 'environment', 'signals', 'note', + 'message', 'stack', 'crash', 'session', 'locale', 'platform', 'path', + ] + for (const key of forbidden) expect(stored.event).not.toHaveProperty(key) + await fiber.dispose() }) it('does not treat deployment permission as per-event consent', async () => { const root = mkdtempSync(join(tmpdir(), 'omdsh-plugin-lab-')) - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(CommandRuntime) - const pluginFiber = await ctx.plugin(plugin, { + const { ctx, fiber, agent, signal } = await runtime({ dataDir: join(root, 'data'), - allowAnonymousShare: true, + allowShare: true, ingestUrl: 'http://127.0.0.1:9/events', retryIntervalMs: 60_000, }) - const session = ctx.sessions.create() - const agent = { id: session.id, session } as Agent - const signal = new AbortController().signal await ctx.commands.execute(agent, '/omdsh-start plugin', signal) - const feedback = await ctx.commands.execute(agent, '/omdsh-feedback worked keep', signal) - expect(feedback?.result.text).toContain('只保存在本机') - await pluginFiber.dispose() + const feedback = await ctx.commands.execute(agent, '/omdsh-result good', signal) + expect(feedback?.result.text).toContain('已只保存到本机') + await fiber.dispose() }) - it('records locally before the user explicitly joins a follow-up case', async () => { + it('uploads only after a separate explicit action and sends exact finite fields', async () => { let posts = 0 + let body = '' const server = createServer((request, response) => { - request.resume() + request.setEncoding('utf8') + request.on('data', chunk => { body += chunk }) request.on('end', () => { posts += 1 response.writeHead(200, { 'content-type': 'application/json' }) - response.end(JSON.stringify({ - receiptId: 'receipt-1', caseId: 'PL-ABCD1234', status: 'received', similarReports: 1, - })) + response.end(JSON.stringify({ receiptId: crypto.randomUUID(), caseId: 'PL-STRICT', status: 'received' })) }) }) await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) const address = server.address() if (address === null || typeof address === 'string') throw new Error('missing address') const root = mkdtempSync(join(tmpdir(), 'omdsh-plugin-lab-')) - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(CommandRuntime) - const pluginFiber = await ctx.plugin(plugin, { - dataDir: join(root, 'data'), allowAnonymousShare: true, - ingestUrl: `http://127.0.0.1:${address.port}/v1/experience-events`, retryIntervalMs: 60_000, + const { ctx, fiber, agent, signal } = await runtime({ + dataDir: join(root, 'data'), + allowShare: true, + ingestUrl: `http://127.0.0.1:${address.port}/v1/experience-events`, + retryIntervalMs: 60_000, }) - const session = ctx.sessions.create() - const agent = { id: session.id, session } as Agent - const signal = new AbortController().signal - await ctx.commands.execute(agent, '/omdsh-start plugin', signal) - const local = await ctx.commands.execute(agent, '/omdsh-result failed', signal) - expect(local?.result.text).toContain('只保存在本机') + await ctx.commands.execute(agent, '/omdsh-start plugin#1.0.0', signal) + await ctx.commands.execute(agent, '/omdsh-result mixed', signal) expect(posts).toBe(0) const joined = await ctx.commands.execute(agent, '/omdsh-join latest', signal) - expect(joined?.result.text).toContain('PL-ABCD1234') + expect(joined?.result.text).toContain('PL-STRICT') expect(posts).toBe(1) - await pluginFiber.dispose() + const packet = JSON.parse(body) as Record + expect(Object.keys(packet).sort()).toEqual([ + 'eventId', 'experience', 'health', 'plugin', 'schemaVersion', 'source', 'type', + ]) + expect(packet).toMatchObject({ + schemaVersion: 2, + type: 'feedback.signal', + plugin: { moduleName: 'plugin', version: '1.0.0' }, + health: 'unknown', + experience: 'mixed', + source: 'user_confirmed', + }) + await fiber.dispose() await new Promise(resolve => server.close(() => resolve())) }) - it('rehydrates an active Trial and all commands across a Host plugin reload', async () => { + it('rejects free-text trial and verdict inputs instead of storing them', async () => { const root = mkdtempSync(join(tmpdir(), 'omdsh-plugin-lab-')) - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(CommandRuntime) - const config = { dataDir: join(root, 'data'), profileLabel: 'reload-test' } - const first = await ctx.plugin(plugin, config) - const session = ctx.sessions.create() - const agent = { id: session.id, session } as Agent - const signal = new AbortController().signal - const commandNames = (await ctx.commands.list(agent)).map(command => command.name) - expect(commandNames).toEqual(expect.arrayContaining([ - 'omdsh-start', 'omdsh-feedback', 'omdsh-result', 'omdsh-join', 'omdsh-inbox', - 'omdsh-retest', 'omdsh-status', 'omdsh-privacy', 'omdsh-reset-id', - ])) - - await ctx.commands.execute(agent, '/omdsh-start @example/reload#1.0.0 task-reload', signal) - await first.dispose() - const second = await ctx.plugin(plugin, config) - - const restored = await ctx.commands.execute(agent, '/omdsh-status', signal) - expect(restored?.result.text).toContain('Trial:@example/reload#1.0.0') - const settled = await ctx.commands.execute(agent, '/omdsh-result worked', signal) - expect(settled?.result.text).toContain('只保存在本机') - await second.dispose() + const { ctx, fiber, agent, signal } = await runtime({ dataDir: join(root, 'data') }) + const start = await ctx.commands.execute(agent, '/omdsh-start plugin private-task-label', signal) + expect(start?.result.kind).toBe('error') + await ctx.commands.execute(agent, '/omdsh-start plugin', signal) + const result = await ctx.commands.execute(agent, '/omdsh-result bad secret-note', signal) + expect(result?.result.kind).toBe('error') + await fiber.dispose() }) - it('records a sanitized process crash without intercepting it and restores it after reload', async () => { + it('does not register a crash monitor or inspect private Session events', async () => { + const before = [...process.listeners('uncaughtExceptionMonitor')] const root = mkdtempSync(join(tmpdir(), 'omdsh-plugin-lab-')) - const dataDir = join(root, 'data') - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(CommandRuntime) - const before = new Set(process.listeners('uncaughtExceptionMonitor')) - const first = await ctx.plugin(plugin, { dataDir, profileLabel: 'crash-test' }) - const monitor = process.listeners('uncaughtExceptionMonitor').find(listener => !before.has(listener)) - expect(monitor).toBeDefined() - const session = ctx.sessions.create() - const agent = { id: session.id, session } as Agent - const signal = new AbortController().signal - await ctx.commands.execute(agent, '/omdsh-start @example/crasher#1.0.0 crash-task', signal) - - const crash = Object.assign(new TypeError('secret customer payload'), { code: 'ERR_PLUGIN_CRASH' }) - crash.stack = 'TypeError: secret customer payload\n at privateFn (file:///Users/private/app/node_modules/@example/crasher/dist/index.js:12:4)' - ;(monitor as NodeJS.UncaughtExceptionListener)(crash, 'uncaughtException') - const status = await ctx.commands.execute(agent, '/omdsh-status', signal) - expect(status?.result.text).toContain('进程崩溃:1 次(TypeError /') - const journal = readFileSync(join(dataDir, 'crashes.ndjson'), 'utf8') - expect(journal).toContain('ERR_PLUGIN_CRASH') - expect(journal).not.toContain('secret customer payload') - expect(journal).not.toContain('/Users/private') - expect(journal).not.toContain('privateFn') - - await first.dispose() - expect(process.listeners('uncaughtExceptionMonitor')).toEqual([...before]) - const second = await ctx.plugin(plugin, { dataDir, profileLabel: 'crash-test' }) - const settled = await ctx.commands.execute(agent, '/omdsh-result failed', signal) - expect(settled?.result.text).toContain('进程崩溃') - await second.dispose() - - const stored = readFileSync(join(dataDir, 'events.ndjson'), 'utf8') - const record = JSON.parse(stored.trim()) as { event: { signals: Record } } - expect(record.event.signals).toMatchObject({ - processCrashes: 1, - crashes: [{ - name: 'TypeError', code: 'ERR_PLUGIN_CRASH', - frame: 'node_modules/@example/crasher/dist/index.js:12:4', - }], - }) - expect(stored).not.toContain('secret customer payload') - expect(stored).not.toContain('/Users/private') - expect(stored).not.toContain('privateFn') + const { ctx, fiber, session, agent, signal } = await runtime({ dataDir: join(root, 'data') }) + expect(process.listeners('uncaughtExceptionMonitor')).toEqual(before) + await ctx.commands.execute(agent, '/omdsh-start plugin', signal) + session.append('assistant/message', { + turn: 1, + step: 1, + message: { role: 'assistant', content: [{ type: 'text', text: 'CANARY_SECRET_TOKEN' }] } as never, + }, { surfaceOp: 'append' }) + await ctx.commands.execute(agent, '/omdsh-result good', signal) + const stored = readFileSync(join(root, 'data', 'feedback-v2.ndjson'), 'utf8') + expect(stored).not.toContain('CANARY_SECRET_TOKEN') + await fiber.dispose() + expect(process.listeners('uncaughtExceptionMonitor')).toEqual(before) }) }) diff --git a/tests/privacy-invariants.spec.ts b/tests/privacy-invariants.spec.ts new file mode 100644 index 0000000..ec588a4 --- /dev/null +++ b/tests/privacy-invariants.spec.ts @@ -0,0 +1,78 @@ +import type { Agent } from '@deepseek-ai/dsh-agent' +import { describe, expect, it } from 'vitest' +import { createAgentAssessmentTool } from '../src/agent-tool.js' +import { probeLoaderHealth } from '../src/health.js' +import { uploadPayload, type FeedbackEventV2, type LocalFeedbackRecord } from '../src/protocol.js' + +function event(eventId = crypto.randomUUID()): FeedbackEventV2 { + return { + schemaVersion: 2, + type: 'feedback.signal', + eventId, + plugin: { moduleName: '@example/plugin', version: '1.0.0' }, + health: 'ok', + experience: 'good', + source: 'user_confirmed', + } +} + +describe('zero-content privacy invariants', () => { + it('uses the local packet byte-for-byte as the upload packet', () => { + const packet = event() + const record: LocalFeedbackRecord = { event: packet, requestedShare: true } + expect(uploadPayload(record)).toBe(packet) + expect(Object.keys(uploadPayload(record)).sort()).toEqual([ + 'eventId', 'experience', 'health', 'plugin', 'schemaVersion', 'source', 'type', + ]) + }) + + it('maps Host lifecycle states without reading plugin content', () => { + const loader = (state: number) => ({ + entries: () => [{ options: { name: '@example/plugin' }, fiber: { state } }], + }) + expect(probeLoaderHealth(loader(2), '@example/plugin')).toBe('ok') + expect(probeLoaderHealth(loader(3), '@example/plugin')).toBe('error') + expect(probeLoaderHealth(loader(1), '@example/plugin')).toBe('unavailable') + expect(probeLoaderHealth(undefined, '@example/plugin')).toBe('unknown') + expect(probeLoaderHealth({ entries: () => [] }, '@example/plugin')).toBe('unavailable') + expect(probeLoaderHealth({ entries: () => { throw new Error('CANARY_SECRET') } }, '@example/plugin')) + .toBe('unknown') + }) + + it('gives the Agent a closed zero-argument and closed enum-only contract', async () => { + const tool = createAgentAssessmentTool(() => ({ + health: 'ok', experience: 'unknown', userConfirmationRequired: true, + })) + expect(tool.parameters).toEqual({ + type: 'object', properties: {}, required: [], additionalProperties: false, + }) + expect(tool.output.schema).toMatchObject({ type: 'object', additionalProperties: false }) + const result = await tool.execute({}, { + agent: undefined, + arguments: {}, + } as never) + expect(result).toEqual({ health: 'ok', experience: 'unknown', userConfirmationRequired: true }) + await expect(tool.execute({ reason: 'CANARY_SECRET' }, { + agent: undefined, + arguments: { reason: 'CANARY_SECRET' }, + } as never)).rejects.toThrow('accepts no arguments') + }) + + it('cannot derive subjective experience when no user verdict exists', () => { + const tool = createAgentAssessmentTool((_agent: Agent | undefined) => ({ + health: 'error', experience: 'unknown', userConfirmationRequired: true, + })) + expect(JSON.stringify(tool.output.schema)).not.toContain('good') + expect(JSON.stringify(tool.output.schema)).not.toContain('bad') + }) + + it('satisfies content non-interference for arbitrary private canaries', () => { + const firstPrivateWorld = 'prompt=ALICE_SECRET; stack=/Users/alice/private.ts' + const secondPrivateWorld = 'prompt=BOB_SECRET; token=sk-private' + const first = JSON.stringify(event('00000000-0000-4000-8000-000000000001')) + const second = JSON.stringify(event('00000000-0000-4000-8000-000000000001')) + expect(first).toBe(second) + expect(first).not.toContain(firstPrivateWorld) + expect(second).not.toContain(secondPrivateWorld) + }) +}) diff --git a/tests/protocol.spec.ts b/tests/protocol.spec.ts deleted file mode 100644 index 039d158..0000000 --- a/tests/protocol.spec.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { uploadPayload, type ExperienceEventV1, type LocalExperienceRecord } from '../src/protocol.js' - -function event(): ExperienceEventV1 { - return { - schemaVersion: 1, - type: 'feedback.submitted', - eventId: 'event-1', - occurredAt: 2, - participantId: 'participant', - trial: { - id: 'trial-1', - plugin: { moduleName: '@example/plugin', version: '1.0.0' }, - startedAt: 1, - durationMs: 1, - }, - environment: { - dshVersion: '0.1.0', - nodeVersion: 'v24', - platform: 'darwin', - arch: 'arm64', - locale: 'zh-CN', - profileLabel: 'test', - }, - signals: { - loaderHealth: 'active', - assistantMessages: 1, - turnsStarted: 1, - turnsCompleted: 1, - toolCalls: 2, - toolErrors: 0, - agentErrors: 0, - processCrashes: 0, - firstReplyMs: 100, - }, - feedback: { outcome: 'worked', retention: 'keep', note: 'local note' }, - sharing: { transcript: 'none', noteIncluded: false }, - } -} - -describe('uploadPayload()', () => { - it('removes the local note from ordinary anonymous sharing', () => { - const record: LocalExperienceRecord = { event: event(), requestedShare: true, shareNote: false } - const payload = uploadPayload(record) - expect(payload.feedback).toEqual({ outcome: 'worked', retention: 'keep' }) - expect(payload.sharing).toEqual({ transcript: 'none', noteIncluded: false }) - }) - - it('keeps the note only when explicitly requested', () => { - const record: LocalExperienceRecord = { event: event(), requestedShare: true, shareNote: true } - expect(uploadPayload(record).feedback.note).toBe('local note') - }) -}) diff --git a/tests/storage-uploader.spec.ts b/tests/storage-uploader.spec.ts index 1da63a4..33d088d 100644 --- a/tests/storage-uploader.spec.ts +++ b/tests/storage-uploader.spec.ts @@ -3,8 +3,8 @@ import { mkdtempSync, readFileSync, statSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import type { ExperienceEventV1, LocalExperienceRecord } from '../src/protocol.js' -import { ExperienceStore } from '../src/storage.js' +import type { FeedbackEventV2, LocalFeedbackRecord } from '../src/protocol.js' +import { FeedbackStore } from '../src/storage.js' import { ExperienceUploader } from '../src/uploader.js' const servers: Array> = [] @@ -13,48 +13,43 @@ afterEach(async () => { await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(() => resolve())))) }) -function record(shareNote = false): LocalExperienceRecord { - const event: ExperienceEventV1 = { - schemaVersion: 1, - type: 'feedback.submitted', +function record(): LocalFeedbackRecord { + const event: FeedbackEventV2 = { + schemaVersion: 2, + type: 'feedback.signal', eventId: crypto.randomUUID(), - occurredAt: Date.now(), - participantId: crypto.randomUUID(), - trial: { id: 'trial', plugin: { moduleName: 'plugin' }, startedAt: 1, durationMs: 1 }, - environment: { - dshVersion: 'v', nodeVersion: 'n', platform: 'darwin', arch: 'arm64', locale: 'zh', profileLabel: 'test', - }, - signals: { - loaderHealth: 'active', assistantMessages: 1, turnsStarted: 1, turnsCompleted: 1, - toolCalls: 0, toolErrors: 0, agentErrors: 0, processCrashes: 0, - }, - feedback: { outcome: 'worked', retention: 'keep', note: 'private' }, - sharing: { transcript: 'none', noteIncluded: shareNote }, + plugin: { moduleName: 'plugin', version: '1.0.0' }, + health: 'error', + experience: 'bad', + source: 'user_confirmed', } - return { event, requestedShare: true, shareNote } + return { event, requestedShare: false } } -describe('local outbox and uploader', () => { - it('uses private filesystem permissions and stable plugin-local identity', () => { +describe('strict local outbox and uploader', () => { + it('uses private filesystem permissions and creates no stable identity or crash journal', () => { const root = mkdtempSync(join(tmpdir(), 'omdsh-plugin-lab-')) - const store = new ExperienceStore(join(root, 'data')) - const first = store.participantId() - expect(store.participantId()).toBe(first) - expect(store.resetParticipantId()).not.toBe(first) - const crash = { - crashId: crypto.randomUUID(), trialId: 'trial', occurredAt: Date.now(), - crash: { fingerprint: '1234567890abcdef1234', name: 'TypeError', origin: 'uncaughtException' as const }, - } - store.appendCrash(crash) - expect(store.crashRecords('trial')).toEqual([crash]) - expect(statSync(store.crashesPath).mode & 0o777).toBe(0o600) + const store = new FeedbackStore(join(root, 'data')) + const value = record() + store.append(value) + expect(statSync(store.eventsPath).mode & 0o777).toBe(0o600) + expect(Object.keys(store).sort()).toEqual([ + 'dataDir', 'eventsPath', 'receiptSeenPath', 'receiptsPath', 'shareRequestsPath', + ]) + const stored = readFileSync(store.eventsPath, 'utf8') + expect(stored).not.toContain('participant') + expect(stored).not.toContain('crash') + expect(store.pending()).toHaveLength(0) + store.requestShare(value.event.eventId) + expect(store.pending()).toHaveLength(1) }) - it('posts a narrow payload, persists the receipt, and clears pending state', async () => { + it('posts the exact closed packet and persists a report-scoped receipt', async () => { const root = mkdtempSync(join(tmpdir(), 'omdsh-plugin-lab-')) - const store = new ExperienceStore(join(root, 'data')) - const queued = record(false) + const store = new FeedbackStore(join(root, 'data')) + const queued = record() store.append(queued) + store.requestShare(queued.event.eventId) let received = '' const server = createServer((request, response) => { if (request.method === 'GET') { @@ -62,7 +57,7 @@ describe('local outbox and uploader', () => { response.writeHead(200, { 'content-type': 'application/json' }) response.end(JSON.stringify({ receiptId: 'receipt-1', status: 'retest-requested', updatedAt: 2, - recommendedVersion: '0.3.3', message: '请用原任务复测。', + recommendedVersion: '0.3.1', })) return } @@ -76,7 +71,7 @@ describe('local outbox and uploader', () => { response.end(JSON.stringify({ receiptId: 'receipt-1', status: 'clustered', updatedAt: 1, similarReports: 7, - recommendedVersion: '0.3.2', + recommendedVersion: '0.3.0', followToken: 'follow-secret', updatesUrl: `http://127.0.0.1:${address.port}/v1/receipts/receipt-1`, })) @@ -85,7 +80,7 @@ describe('local outbox and uploader', () => { servers.push(server) await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) const address = server.address() - if (address === null || typeof address === 'string') throw new Error('missing test address') + if (address === null || typeof address === 'string') throw new Error('missing address') const uploader = new ExperienceUploader(store, { ingestUrl: `http://127.0.0.1:${address.port}/v1/experience-events`, authorizationEnv: 'OMDSH_TEST_TOKEN', @@ -93,14 +88,13 @@ describe('local outbox and uploader', () => { }) const receipts = await uploader.flushPending(queued.event.eventId) expect(receipts.get(queued.event.eventId)).toMatchObject({ - status: 'clustered', similarReports: 7, recommendedVersion: '0.3.2', + status: 'clustered', similarReports: 7, recommendedVersion: '0.3.0', }) - expect(JSON.parse(received).feedback).toEqual({ outcome: 'worked', retention: 'keep' }) + expect(JSON.parse(received)).toEqual(queued.event) + expect(Buffer.byteLength(received)).toBeLessThan(512) expect(store.pending()).toHaveLength(0) - expect(readFileSync(store.receiptsPath, 'utf8')).toContain(queued.event.eventId) await expect(uploader.refreshReceipts()).resolves.toMatchObject([ - { status: 'retest-requested', recommendedVersion: '0.3.3' }, + { status: 'retest-requested', recommendedVersion: '0.3.1' }, ]) - expect(store.unreadReceipts()).toHaveLength(1) }) })