# 接口契约 本插件所依赖的接口,以及它所暴露的接口。平台 API 是用 `cordis_inspect_list` / `cordis_inspect_query` 现场核验的,而不是凭记忆写下的——如果 DSH 自身的源码与本文件不一致,以源码为准,本文件即为过期。 --- ## 1. 平台 API 消费(宿主端) `inject` 保持为 `[]`——没有任何东西是硬依赖,缺少某个服务的配置档仍会加载其余全部内容。但 `systemPrompt`、`tools`、`webServer` 三者都是**可选的硬依赖**,各自由 `ctx.inject([...], (scoped) => ...)` 获取:在 `apply` 运行*之后*才到达的服务会被等待,而不是被一次性的 `ctx.get(...)` 永久错过。某个服务始终不出现时,只有它自己那一半不接线,其余照常工作。 三者必须是 `inject` 而不是 `ctx.get`,因为它们有同一种沉默失败模式:各行使并发挂载,挂载时读一次拿到 `undefined`,注册就被静默跳过——请求里于是少掉 4 个工具或整段系统提示词,而日志里没有任何错误。依赖某个名字却读不到时,`ctx.get` 本身也会抛错,这正是它必须被 `inject` 取代的原因。 ### `tools.register(definition)` ```ts interface ToolDefinition extends ToolSchema { output: ToolOutputDefinition; execute(args, exec): Promise; } interface ToolSchema { deferLoading?: boolean; name: string; description: string; parameters: Record; // JSON Schema } interface ToolOutputDefinition { schema: JsonSchemaNode; render(args, value): ContentBlock[]; presentationMeta?(args, value): JsonValue; } ``` 返回一个销毁器(disposer)。`lib/index.js:719` 注册了 `renpy_stage`。 ### `systemPrompt.section(section)` ```ts interface PromptSection { name: string; order: number; text: string | ((ctx: AssembleContext) => string); interpolate?: boolean; complete?: boolean; } interface AssembleContext { scope?: ScopeKey; signal?: AbortSignal } ``` `lib/index.js:682` 注册了 `renpy-skin:stage-director`。它的 `order` 是 `systemPrompt.getSectionOrder("PLAN_POLICY") + 100`,当 `getSectionOrder` 缺失时硬回退为 `500 + 100`。必须设置 `interpolate: false`——该简介文本在 Ren'Py 示例中包含 `{` 和 `}`。 供参考,DSH 定义的 section-order 取值: `HARNESS_IDENTITY: -1000`、`DEPLOYMENT_PERSONA_PREFIX: 0`、`PLAN_POLICY: 500`、 `TEAM_POLICY: 600`、`PTC_ONLY: 800`、`FILE_REFERENCE: 900`、`TOOL_BASH: 1000`、 `TOOL_PWSH: 1010`、`TOOL_READ: 1100`、`TOOL_WRITE: 1200`、`TOOL_EDIT: 1300`、 `TOOL_GLOB: 1400`。 ### `webServer.register(route)` ```ts { kind: "exact" | "prefix", path: string, handler: (request: IncomingMessage, res: ServerResponse) => void } ``` Node 风格,**不是** Web 的 `Request`/`Response` 对:处理器写入 `res`(`res.statusCode`、`res.setHeader`、`res.end`)并返回 `undefined`。返回一个用于注销该路由的销毁器。 **必须通过 `ctx.inject(["webServer"], (webCtx) => ...)` 获取该服务,绝不要在挂载时通过裸的 `ctx.get("webServer")` 获取。** 各行使并发挂载,所以插件的 `apply` 运行时 web 服务器可能仍然缺席;此时一次性读取返回 `undefined`,所有路由注册被静默跳过,于是 SPA 回退会为整个插件前缀应答 **404**(页面仍能渲染,但每次写入都失败)。`inject` 则会等待该服务,这就是为什么 `export const inject = []` 可以是空数组,而路由仍能可靠注册。 ### 客户端 - `theme.overrideTokens(source: string, tokens: ThemeTokenOverrides): () => void` 其中 `ThemeTokenOverrides = Record`, `ThemeTokenModes = { light: string; dark: string }`。 **两种模式都是必需的**——仅提供 light 的映射会被拒绝。 - `slots.register(options, component)` / `slots.inject(key, callback)`。 `inject` 等待插槽声明;回调返回一个销毁器。 - `slots.entries(key)` → 原始注册条目,包含 `store` 句柄; `slots.resolveStore(handle, scopeBinding)` → 实时的 store 实例。 两者都是**检视接口**,而非有文档的契约,并且 `sidebar.settings` 可能尚未挂载——因此它们带有守卫,未命中时只是一个普通的 `undefined`。 头部的 Ren'Py 图标用这对接口深链到 DSH 设置对话框,后者没有文档化的客户端 API:`entries("sidebar.settings")[0].store` → `resolveStore(...)` → `actions.openSection("renpy-skin")`。该 id 必须等于注册的 `settings.section` id,因为面板用 `rows.find((r) => r.id === activeId) ?? rows[0]` 解析当前分区。如果任一跳不可用,弹出层就退回到给出手动路径。**不要**改为注册到 `settings.launcher`:它是 `kind: "single"` 且已被占用,那样做会遮蔽已发布的 UI。 - 设置页的「用 Agent 加载」按钮用到的会话服务通过 `ctx.inject(["uiWorkspace", "sessions"], ...)` 获取,`workspaces` 另用一次 `ctx.inject` 补上——服务可能在 `apply` 之后才挂载,一次性 `ctx.get` 会错过它们(Cordis 上下文对未注入的名字是**抛错**,所以 `serviceOf` 仍保留作为兜底读取)。 - `POST /renpy-skin/agent/prepare` 先由宿主把游戏准备好并写出两棵树,按钮再开会话投递提示词。**顺序不能反**:提示词里写着真实路径,宿主没准备好时提示词会把 Agent 指向一个不存在的清单。任一步失败都 abort 并把原因显示给用户——**不再自动复制到剪贴板**,剪贴板只是失败后面板上的一个可选按钮(用户明确点击才写)。 - `uiWorkspace` → `startSession(workspaceId?)`:打开「新建会话」流程并 导航过去(无参数则继承当前工作区)。这是平台文档里列出的那一项。 - `workspaces` → `workspaces.create({ path })`,新会话的工作区 id 取 `view.id ?? view.workspaceId`(宿主用哪种拼写都认)。这一步失败**不**牵连会话: 只记一条 `console.warn` 就继续,因为它只决定 Agent 的临时文件落在哪(现在那个目录 由 `/agent/prepare` 建好)。 - `sessions` → `sessions.binding(sessionId)`,会话对象是 `binding.session`,投递文本用 `session.prompt([{ type: "text", text }], "queue")`, 与 composer 自己提交时走的是同一个方法。`sessions.get(id)` **不存在**。 - 新会话的 id 从 `[data-conversation-session]` 读。宿主在已经有一个空白会话时会 复用它而不新建,所以 id 没变**不**等于失败:判别要看 composer 的阶段钩子 (`:is([data-phase="active"], [data-content-phase="active"]) [data-composer-seat]`), 空白会话停在 hero 阶段、不带 `active`。id 没变**且**会话已经开始,就意味着什么都没 打开、眼前还是用户自己的对话,此时**拒绝投递**并报错——否则提示词会悄悄埋进 用户正在聊的那条对话里,而界面还会报「已发送到新会话」。 - 提示词里显式写明用户当前语言(`activeLocaleId()`),因为新会话不继承任何 persona 或 locale。它同时带上一段事实块(游戏名、游戏目录、配置目录、两棵树的路径、 各类文件计数),这样 Agent 不必再问路径。 - **不切换预设。** 平台没有运行中改预设的 API:`agentPresets` 服务只有 `resolve(id)` 与 `mount(agentCtx, id)`,且只在**创建** Agent 时通过 `CreateAgentOptions.meta.agentPreset` 生效;经 `agents.create(...)` 建出来的会话 不进入 Shell 的会话列表,用户看不见。`renpy_*` 工具本来就是全局注册的,所以按钮 只负责「准备→建工作区→开新会话→投递」,persona 由用户在 composer 里自己选。 --- ## 2. 暴露的 HTTP 路由(前缀 `/renpy-skin`) | 方法 | 路径 | 用途 | | --- | --- | --- | | `GET` | `/renpy-skin/manifest?session=&gameDir=` | 资源清单(manifest)。`gameDir` 参数会**持久化**一个新的设置项(它不是只读覆盖)。 | | `GET` | `/renpy-skin/media?session=&under=&match=` | `{ ok, images: string[], videos: string[] }`——当前游戏自带的、**游戏相对**的图片与视频路径,用于开场媒体选择器,**也是 Agent 发现归档内可显示对象的唯一手段**(清单摘要只有目录直方图 `stats.assetDirs`,没有路径列表;工具描述 `renpy_read_manifest` 里点名了这个路由)。它刻意不复用 `manifest.backgrounds`:splash 视频不是可显示对象,永远不会进入那份索引。`?under=<目录>`(前缀匹配)与 `?match=<子串>` 做**服务端**过滤,两者都不区分大小写、都可省略;无参数时行为完全不变。带参数时响应多一个 `filter: { under, match, matched, total }`:`matched` 是**过滤后**的命中数,`total` 是过滤**之前**的总数(全量清单仍可用不带参数的调用取到)。两个数字都要给——只报一个时,「这段目录里一共 3 个」和「3 个命中」读起来一模一样,用错就会得出「`images/` 下什么都没有」的结论。过滤是给归档里的资源用的:实测 CSE 的 `/media` 一次返回 748 个图片路径,而 Agent 原本没有任何列举手段,只能整份收下再自己筛。列表按解析出的 `gameDir` 缓存,`forgetSessionState()` 与「重读磁盘」都会清空它。 | | `GET` | `/renpy-skin/asset?p=&v=&session=` | 提供一个可显示对象(displayable),**或清单索引到的任意音频/视频**。`cache-control: private, max-age=3600`。回退到从 `.rpa` 归档读取。遵循 `Range: bytes=a-b` → `206`,并带 `accept-ranges: bytes` 和 `content-range`;语法合法但无法满足的范围是 `416`,并带 `content-range: bytes */`。`