/** * dsh-remind 对 DSH 宿主接口的**全部**假设,集中在这一份清单里。 * * 为什么要有这个文件:DSH 会升级,插件不能靠「大概记得」来维持兼容。这里把每条假设写成 * 机器可核对的一项,于是同一份清单有两个用途: * * 1. **运行期自检**:插件 apply 时逐项探测,结果进 `/api/dsh-remind.status` 的 `health`, * 并把缺失项写成一条 warn 日志。 * 2. **升级后体检**:`node tools/doctor.mjs` 拿这份清单去核对**新版本 DSH 自带的 API 目录** * (app.asar 里的 dsh-tool-cordis/lib/types/api-catalog.js), * 哪条没了就是哪条没了——而不是「插件莫名其妙不响了」。 * * `level` 的含义: * - `contract`:DSH 自己的 API 目录里有公开声明(事件/服务/类型),属于受支持的扩展点。 * - `internal`:目录里没有声明、属于运行时形状(例如 Agent 上挂的 inbox)。 * 这类假设一律按「拿不到就降级」写代码,并在体检里单独列出来。 */ /** 用于静态核对的 DSH API 目录(app.asar 内部路径)。 */ export const CATALOG_MEMBER = 'dsh/node_modules/@deepseek-ai/dsh-tool-cordis/lib/types/api-catalog.js' /** * 清单。每项: * - id 人类可读的假设名 * - level contract | internal * - why 这条假设坏了会怎样(写清楚降级行为) * - catalog 公开契约的静态核对串(必须能在新版本 API 目录里找到) * - source 内部假设的实现核对:`{ member, needles }`,member 是 app.asar 内部路径, * doctor 会读这个文件确认关键字符串还在(对 API 目录里没声明的实现细节) * - probe 运行期探测;返回 true=在、false=不在、undefined=本次无法判断 */ export const CONTRACT = [ { id: 'event agent/status', level: 'contract', why: '任务结束信号(running → idle);没有它插件不会触发', catalog: ["name: 'agent/status'"], probe: (ctx) => typeof ctx?.on === 'function', }, { id: 'type AgentStatus', level: 'contract', why: "状态取值只有 'idle' | 'running';取值改名则判定失效", catalog: ["name: 'AgentStatus'", "'idle' | 'running'"], probe: () => undefined, }, { id: 'service agents', level: 'contract', why: '会话注册表;缺失则插件不激活(不报错、不打扰)', catalog: ["key: 'agents'"], probe: (ctx) => ctx?.get?.('agents') !== undefined, }, { id: 'agents.roots()', level: 'contract', why: '区分顶层会话与子代理;拿不到就退回 session.header.origin 判断', catalog: ['roots(): Agent[]'], probe: (ctx) => typeof ctx?.get?.('agents')?.roots === 'function', }, { id: 'agents.get()', level: 'contract', why: '按会话 id 取回 agent(job 落地时定位会话)', catalog: ['get(id: SessionId): Agent | undefined'], probe: (ctx) => typeof ctx?.get?.('agents')?.get === 'function', }, { id: 'service jobs', level: 'contract', why: '后台任务登记;缺失则只剩「轮次结束」一种触发源', catalog: ["key: 'jobs'"], probe: (ctx) => ctx?.get?.('jobs') !== undefined, }, { id: 'jobs.list()', level: 'contract', why: '判断该会话还有没有 running/stopping 的后台任务', catalog: ['list(caller?: SessionId): JobView[]'], probe: (ctx) => typeof ctx?.get?.('jobs')?.list === 'function', }, { id: 'jobs.events.subscribe()', level: 'contract', why: '后台任务落地时补一条提醒;缺失则少了这一类触发', catalog: ['interface JobEvents'], probe: (ctx) => typeof ctx?.get?.('jobs')?.events?.subscribe === 'function', }, { id: 'service sessions + sessions.get()', level: 'contract', why: '取会话元数据(工作区路径、子代理来源)', catalog: ["key: 'sessions'", 'get(id: SessionId): Session | undefined'], probe: (ctx) => typeof ctx?.get?.('sessions')?.get === 'function', }, { id: 'SessionHeader.cwd', level: 'contract', why: '通知正文里的工作区名;拿不到则该片段自动省略', catalog: ["name: 'SessionHeader'"], probe: () => undefined, }, { id: 'sessionTitle.get()', level: 'contract', why: '通知正文里的会话标题', catalog: ["key: 'sessionTitle'"], probe: (ctx) => typeof ctx?.get?.('sessionTitle')?.get === 'function', }, { id: 'sessionProjections.stateOf()', level: 'contract', why: '标题服务的兜底来源;拿不到就只靠 sessionTitle', catalog: ['stateOf'], probe: (ctx) => typeof ctx?.get?.('sessionProjections')?.stateOf === 'function', }, { id: 'waterfall workspace/session-activity', level: 'contract', why: '宿主自己的「这个会话还有没有活」聚合口径;缺失则退回本地判据(inbox + jobs)', catalog: ["name: 'workspace/session-activity'"], probe: (ctx) => typeof ctx?.waterfall === 'function', }, { id: 'connection.fetch.register()', level: 'contract', why: '只影响自检路由 /api/dsh-remind.*;核心通知不依赖它', catalog: ['interface ConnectionFetchRoute'], probe: (ctx) => typeof ctx?.get?.('connection')?.fetch?.register === 'function', }, { id: 'connection.authenticatedUrl()', level: 'contract', why: '只影响「通知可点击跳回 DSH」(默认关闭)', catalog: ['authenticatedUrl(baseUrl: string): string'], probe: (ctx) => typeof ctx?.get?.('connection')?.authenticatedUrl === 'function', }, { id: 'webServer.port', level: 'contract', why: '同上:拼可点击地址用', catalog: ["key: 'webServer'"], probe: (ctx) => Number.isFinite(ctx?.get?.('webServer')?.port), }, { id: 'event session/event', level: 'contract', why: '审批/提问都是 session 事件;没有它这两类提醒都不触发', catalog: ["name: 'session/event'"], probe: (ctx) => typeof ctx?.on === 'function', }, { id: 'session 事件 tool/call', level: 'contract', why: 'DSH 提问表现为 ask_user_question 的 tool/call;缺失则提问提醒失效', catalog: ["SessionEventMap['tool/call']"], probe: (ctx) => typeof ctx?.on === 'function', }, { id: 'SessionHeader.parentSession', level: 'contract', why: '子代理里的审批/提问要展示到顶层会话;拿不到就显示子会话标题', catalog: ['parentSession?: SessionId'], probe: () => undefined, }, { id: 'waterfall approval/request', level: 'contract', why: '审批请求的公开扩展点(本插件只做提醒);缺失说明审批机制改了', catalog: ["name: 'approval/request'"], probe: (ctx) => ctx?.get?.('approval') !== undefined, }, { id: 'session 事件 approval/asked + approval/decided(实现核对)', level: 'internal', why: '审批提醒的触发事件;改名则审批提醒失效(任务完成提醒不受影响)', catalog: [], source: { member: 'dsh/node_modules/@deepseek-ai/dsh-user-approval/lib/index.js', needles: ['session.append("approval/asked"', 'session.append("approval/decided"'], }, probe: () => undefined, }, { id: '工具名 ask_user_question(实现核对)', level: 'internal', why: '提问提醒靠工具名识别;改名则提问提醒失效', catalog: [], source: { member: 'dsh/node_modules/@deepseek-ai/dsh-user-questions/lib/types/projection.js', needles: ["ASK_USER_QUESTION_TOOL = 'ask_user_question'"], }, probe: () => undefined, }, { id: 'agent.status(运行时形状)', level: 'internal', why: 'API 目录里 Agent 只声明了 id;拿不到 status 时静默期复查会放宽(可能多提醒一次)', catalog: [], probe: (ctx, extra) => (extra?.agentShape?.seen ? extra.agentShape.status : undefined), }, { id: 'agent.inbox.nextTurn / nextStep(运行时形状)', level: 'internal', why: '判断是否还有排队工作;拿不到就当作「没有排队」,宁可多提醒也不漏', catalog: [], probe: (ctx, extra) => (extra?.agentShape?.seen ? extra.agentShape.inbox : undefined), }, { id: 'agent.session(运行时形状)', level: 'internal', why: '通知正文取会话/工作区;拿不到则正文退化为「会话已完成」', catalog: [], probe: (ctx, extra) => (extra?.agentShape?.seen ? extra.agentShape.session : undefined), }, ] /** * 运行期逐项探测。 * @param ctx 宿主上下文(根 ctx 或 scoped ctx 都行,探测一律走 ctx.get) * @param extra.agentShape 观察到的 agent 运行时形状(由 detect.js 记录) * @returns `{ ok, checks, missing, agentShape }`;`ok` 只统计 contract 级的缺失 */ export function probeContract(ctx, extra = {}) { const checks = CONTRACT.map((item) => { let present try { present = typeof item.probe === 'function' ? item.probe(ctx, extra) : undefined } catch { present = false } return { id: item.id, level: item.level, present: present === undefined ? null : Boolean(present), why: item.why } }) const missing = checks.filter((check) => check.level === 'contract' && check.present === false) return { ok: missing.length === 0, checks, missing: missing.map((check) => check.id), agentShape: extra.agentShape ?? null, } } /** 一句话总结,便于写日志。 */ export function summarizeHealth(health) { if (health.ok) { const known = health.checks.filter((check) => check.present === true).length return `contract ok (${known}/${health.checks.length} 项已确认)` } return `contract 缺失: ${health.missing.join(', ')}` }