From 87a1774fefd99bf458898154e7e40ca3715a48f2 Mon Sep 17 00:00:00 2001 From: lintianle Date: Thu, 9 Jul 2026 16:07:58 +0800 Subject: [PATCH 01/14] feat: add docs website --- website/.gitignore | 3 + website/.vitepress/config/index.ts | 17 + website/.vitepress/config/zh-CN.ts | 99 +++++ website/package.json | 15 + website/zh-CN/api/cordis/context.md | 85 +++++ website/zh-CN/api/cordis/events.md | 120 ++++++ website/zh-CN/api/cordis/fiber.md | 108 ++++++ website/zh-CN/api/cordis/registry.md | 87 +++++ website/zh-CN/api/cordis/service.md | 97 +++++ website/zh-CN/api/harness/agent.md | 85 +++++ website/zh-CN/api/harness/bash.md | 81 +++++ website/zh-CN/api/harness/fs.md | 78 ++++ website/zh-CN/api/harness/llm.md | 124 +++++++ website/zh-CN/api/harness/session.md | 56 +++ website/zh-CN/api/harness/subagent.md | 85 +++++ website/zh-CN/api/harness/tools.md | 122 +++++++ website/zh-CN/api/index.md | 25 ++ website/zh-CN/design/composability.md | 72 ++++ website/zh-CN/design/context-model.md | 129 +++++++ website/zh-CN/design/effects-coeffects.md | 69 ++++ website/zh-CN/design/index.md | 39 ++ website/zh-CN/design/reactive-coeffects.md | 90 +++++ website/zh-CN/design/revertible-effects.md | 128 +++++++ website/zh-CN/develop/basic/config.md | 108 ++++++ website/zh-CN/develop/basic/index.md | 148 ++++++++ website/zh-CN/develop/basic/tool.md | 199 ++++++++++ website/zh-CN/develop/framework/events.md | 152 ++++++++ website/zh-CN/develop/framework/index.md | 139 +++++++ website/zh-CN/develop/framework/service.md | 147 ++++++++ website/zh-CN/develop/practice/index.md | 156 ++++++++ website/zh-CN/develop/practice/llm-adapter.md | 169 +++++++++ website/zh-CN/guide/config.md | 342 ++++++++++++++++++ website/zh-CN/guide/index.md | 47 +++ website/zh-CN/guide/quickstart.md | 98 +++++ website/zh-CN/index.md | 21 ++ 35 files changed, 3540 insertions(+) create mode 100644 website/.gitignore create mode 100644 website/.vitepress/config/index.ts create mode 100644 website/.vitepress/config/zh-CN.ts create mode 100644 website/package.json create mode 100644 website/zh-CN/api/cordis/context.md create mode 100644 website/zh-CN/api/cordis/events.md create mode 100644 website/zh-CN/api/cordis/fiber.md create mode 100644 website/zh-CN/api/cordis/registry.md create mode 100644 website/zh-CN/api/cordis/service.md create mode 100644 website/zh-CN/api/harness/agent.md create mode 100644 website/zh-CN/api/harness/bash.md create mode 100644 website/zh-CN/api/harness/fs.md create mode 100644 website/zh-CN/api/harness/llm.md create mode 100644 website/zh-CN/api/harness/session.md create mode 100644 website/zh-CN/api/harness/subagent.md create mode 100644 website/zh-CN/api/harness/tools.md create mode 100644 website/zh-CN/api/index.md create mode 100644 website/zh-CN/design/composability.md create mode 100644 website/zh-CN/design/context-model.md create mode 100644 website/zh-CN/design/effects-coeffects.md create mode 100644 website/zh-CN/design/index.md create mode 100644 website/zh-CN/design/reactive-coeffects.md create mode 100644 website/zh-CN/design/revertible-effects.md create mode 100644 website/zh-CN/develop/basic/config.md create mode 100644 website/zh-CN/develop/basic/index.md create mode 100644 website/zh-CN/develop/basic/tool.md create mode 100644 website/zh-CN/develop/framework/events.md create mode 100644 website/zh-CN/develop/framework/index.md create mode 100644 website/zh-CN/develop/framework/service.md create mode 100644 website/zh-CN/develop/practice/index.md create mode 100644 website/zh-CN/develop/practice/llm-adapter.md create mode 100644 website/zh-CN/guide/config.md create mode 100644 website/zh-CN/guide/index.md create mode 100644 website/zh-CN/guide/quickstart.md create mode 100644 website/zh-CN/index.md diff --git a/website/.gitignore b/website/.gitignore new file mode 100644 index 0000000000..2c1fa99cb4 --- /dev/null +++ b/website/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.vitepress/dist/ +.vitepress/cache/ diff --git a/website/.vitepress/config/index.ts b/website/.vitepress/config/index.ts new file mode 100644 index 0000000000..b4978ca4aa --- /dev/null +++ b/website/.vitepress/config/index.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vitepress' +import { zhCN } from './zh-CN' + +export default defineConfig({ + title: 'DeepSeek Harness', + description: '插件化 Agent 开发框架', + + locales: { + 'zh-CN': zhCN, + }, + + themeConfig: { + socialLinks: [ + { icon: 'github', link: 'https://github.com/deepseek-harness/deepseek-harness' }, + ], + }, +}) diff --git a/website/.vitepress/config/zh-CN.ts b/website/.vitepress/config/zh-CN.ts new file mode 100644 index 0000000000..83767b6cbc --- /dev/null +++ b/website/.vitepress/config/zh-CN.ts @@ -0,0 +1,99 @@ +import type { DefaultTheme, LocaleSpecificConfig } from 'vitepress' + +const guideSidebar: DefaultTheme.SidebarItem[] = [ + { + text: '入门', + items: [ + { text: '介绍', link: '/zh-CN/guide/' }, + { text: '快速开始', link: '/zh-CN/guide/quickstart' }, + { text: '配置文件', link: '/zh-CN/guide/config' }, + ], + }, +] + +const developSidebar: DefaultTheme.SidebarItem[] = [ + { + text: '基础', + items: [ + { text: '第一个插件', link: '/zh-CN/develop/basic/' }, + { text: '开发一个 Tool', link: '/zh-CN/develop/basic/tool' }, + { text: '插件配置', link: '/zh-CN/develop/basic/config' }, + ], + }, + { + text: '框架能力', + items: [ + { text: '插件与生命周期', link: '/zh-CN/develop/framework/' }, + { text: '服务与依赖', link: '/zh-CN/develop/framework/service' }, + { text: '事件系统', link: '/zh-CN/develop/framework/events' }, + ], + }, + { + text: '实战', + items: [ + { text: '能力的三层拆分', link: '/zh-CN/develop/practice/' }, + { text: 'LLM 适配器', link: '/zh-CN/develop/practice/llm-adapter' }, + ], + }, +] + +const apiSidebar: DefaultTheme.SidebarItem[] = [ + { + text: '框架 API', + items: [ + { text: '总览', link: '/zh-CN/api/' }, + { text: 'Context', link: '/zh-CN/api/cordis/context' }, + { text: 'Events', link: '/zh-CN/api/cordis/events' }, + { text: 'Fiber', link: '/zh-CN/api/cordis/fiber' }, + { text: 'Registry', link: '/zh-CN/api/cordis/registry' }, + { text: 'Service', link: '/zh-CN/api/cordis/service' }, + ], + }, + { + text: 'Harness API', + items: [ + { text: 'Tools (dsh-tools)', link: '/zh-CN/api/harness/tools' }, + { text: 'LLM (dsh-llm)', link: '/zh-CN/api/harness/llm' }, + { text: 'Session (dsh-session)', link: '/zh-CN/api/harness/session' }, + { text: 'Agent (dsh-agent)', link: '/zh-CN/api/harness/agent' }, + { text: 'Bash (dsh-bash)', link: '/zh-CN/api/harness/bash' }, + { text: 'Filesystem (dsh-fs)', link: '/zh-CN/api/harness/fs' }, + { text: 'Subagent (dsh-subagent)', link: '/zh-CN/api/harness/subagent' }, + ], + }, +] + +const designSidebar: DefaultTheme.SidebarItem[] = [ + { + text: '系统设计', + items: [ + { text: '概述', link: '/zh-CN/design/' }, + { text: '可组合性与插件系统', link: '/zh-CN/design/composability' }, + { text: '作用与余作用', link: '/zh-CN/design/effects-coeffects' }, + { text: '可逆作用', link: '/zh-CN/design/revertible-effects' }, + { text: '响应式余作用', link: '/zh-CN/design/reactive-coeffects' }, + { text: '上下文模型', link: '/zh-CN/design/context-model' }, + ], + }, +] + +export const zhCN: LocaleSpecificConfig = { + label: '简体中文', + lang: 'zh-CN', + themeConfig: { + nav: [ + { text: '入门', link: '/zh-CN/guide/', activeMatch: '/zh-CN/guide/' }, + { text: '开发', link: '/zh-CN/develop/basic/', activeMatch: '/zh-CN/develop/' }, + { text: 'API', link: '/zh-CN/api/', activeMatch: '/zh-CN/api/' }, + { text: '设计', link: '/zh-CN/design/', activeMatch: '/zh-CN/design/' }, + ], + sidebar: { + '/zh-CN/guide/': guideSidebar, + '/zh-CN/develop/': developSidebar, + '/zh-CN/api/': apiSidebar, + '/zh-CN/design/': designSidebar, + }, + outline: { label: '本页目录' }, + docFooter: { prev: '上一篇', next: '下一篇' }, + }, +} diff --git a/website/package.json b/website/package.json new file mode 100644 index 0000000000..33c32fae4c --- /dev/null +++ b/website/package.json @@ -0,0 +1,15 @@ +{ + "name": "@deepseek-ai/website", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vitepress dev . --port 5173 --open", + "build": "vitepress build .", + "preview": "vitepress preview ." + }, + "devDependencies": { + "vitepress": "^1.6.3", + "vue": "^3.5.13" + } +} diff --git a/website/zh-CN/api/cordis/context.md b/website/zh-CN/api/cordis/context.md new file mode 100644 index 0000000000..a18f275dad --- /dev/null +++ b/website/zh-CN/api/cordis/context.md @@ -0,0 +1,85 @@ +# Context + +上下文对象是 Cordis 的核心。所有服务、方法、属性都通过 `ctx` 访问。 + +## 服务与混入 + +Context 基于组合式 API 设计,大部分属性和方法挂载在服务上。以下是核心 API: + +- [`ctx.on`](./events#ctx-on) — 注册事件监听器 +- [`ctx.emit`](./events#ctx-emit) — 触发事件 +- [`ctx.bail`](./events#ctx-bail) — 短路事件 +- [`ctx.serial`](./events#ctx-serial) — 顺序异步事件 +- [`ctx.waterfall`](./events#ctx-waterfall) — 管道事件 +- [`ctx.effect`](./fiber#fiber-effect) — 注册可逆效果 +- [`ctx.plugin`](./registry#ctx-plugin) — 加载子插件 +- [`ctx.inject`](./registry#ctx-inject) — 获取依赖的插件 +- [`ctx.get`](#ctx-get) — 获取服务 +- [`ctx.set`](#ctx-set) — 设置服务 +- [`ctx.provide`](#ctx-provide) — 声明服务 + +## 实例属性 + +### ctx.fiber + +- **类型:** [`Fiber`](./fiber) + +当前上下文的作用域对象。 + +## 实例方法 + +### ctx.extend(meta) + +- **meta:** `object` +- **返回值:** `Context` + +构造一个以当前上下文为原型的新上下文实例。 + +### ctx.intercept(name, config) + +- **name:** `string` 服务名称 +- **config:** `object` 配置拦截 +- **返回值:** `Context` + +为指定服务添加一层配置拦截,返回新的上下文实例。 + +### ctx.isolate(name, label?) + +- **name:** `string` 服务名称 +- **label:** `symbol` 隔离域符号(可选) +- **返回值:** `Context` + +创建一个针对指定服务的隔离域,返回新的上下文实例。隔离域中的同名服务互不影响。 + +### ctx.get(name) + +- **name:** `string` 服务名称 +- **返回值:** `Service | undefined` + +获取指定名称的服务实例。 + +### ctx.set(name, value) + +- **name:** `string` 服务名称 +- **value:** `any` 服务值 + +设置指定名称的服务。 + +### ctx.provide(name, value?, options?) + +- **name:** `string` 服务名称 +- **value:** `any` 初始值(可选) +- **options:** `object` +- **返回值:** `void` + +声明一个服务。声明后其他插件可以通过 `inject` 依赖它。 + +## 静态属性 + +### Context.events + +内置事件服务的 symbol key。 + +### Context.current + +当前活跃的 Context 实例(在异步链中通过 AsyncLocalStorage 追踪)。 diff --git a/website/zh-CN/api/cordis/events.md b/website/zh-CN/api/cordis/events.md new file mode 100644 index 0000000000..dbc03a87bc --- /dev/null +++ b/website/zh-CN/api/cordis/events.md @@ -0,0 +1,120 @@ +# Events + +`ctx.events` 是内置服务,提供事件系统相关的全部 API。 + +## 实例方法 + +### ctx.on(event, listener, options?) {#ctx-on} + +- **event:** `string` 事件名称 +- **listener:** `Function` 事件监听器 +- **options:** `object` + - **prepend:** `boolean` 是否注册为前置(默认 `false`) + - **global:** `boolean` 是否注册为全局(默认 `false`) +- **返回值:** `() => void` 取消注册函数 + +注册一个事件监听器。返回的函数可用于手动取消注册,但通常不需要——插件卸载时会自动清理。 + +```typescript +ctx.on('agent/turn-end', (data) => { + console.log('turn ended:', data) +}) +``` + +### ctx.emit(thisArg?, event, ...args) {#ctx-emit} + +- **thisArg:** `any` 监听器的 `this` 参数(可选) +- **event:** `string` 事件名称 +- **args:** `any[]` 事件参数 +- **返回值:** `void` + +同步触发所有匹配的监听器(并行,不等待异步完成)。 + +### ctx.parallel(thisArg?, event, ...args) + +- 签名同 `emit` +- **返回值:** `Promise` + +异步触发所有匹配的监听器(并行等待)。 + +### ctx.bail(thisArg?, event, ...args) {#ctx-bail} + +- **返回值:** `any` + +同步依次触发监听器。第一个返回非 `undefined`/`null`/`false` 值的监听器停止链并返回该值。 + +### ctx.serial(thisArg?, event, ...args) {#ctx-serial} + +- **返回值:** `Promise` + +异步依次触发监听器。语义同 `bail` 的异步版本。 + +### ctx.waterfall(thisArg?, event, ...args) {#ctx-waterfall} + +- **返回值:** `Promise` + +管道模式:每个监听器接收前一个的输出。监听器内部必须调用 `next()` 才会传递给下一个。 + +```typescript +// 注册 +ctx.on('llm/pre-request', async (messages, next) => { + messages.push(extraMsg) + return next(messages) // 必须调用 +}) + +// 触发 +const result = await ctx.waterfall('llm/pre-request', initialMessages) +``` + +::: warning +不调用 `next()` 即为否决 (veto)——管道终止。这是设计行为,用于拦截/网关。 +::: + +## Harness 内置事件 + +### agent/pre-step + +- **触发模式:** serial +- **参数:** `{ agentId, turnIndex }` + +Agent 执行一步之前触发。 + +### agent/post-step + +- **触发模式:** emit +- **参数:** `{ agentId, turnIndex, blocks }` + +Agent 执行一步之后触发。 + +### tool/call + +- **触发模式:** emit +- **参数:** `{ name, args, callId }` + +Tool 被模型调用时触发。 + +### tool/result + +- **触发模式:** emit +- **参数:** `{ name, result, callId }` + +Tool 返回结果时触发。 + +### session/event + +- **触发模式:** emit +- **参数:** `SessionEvent` + +会话事件被记录时触发。 + +### compact/start + +- **触发模式:** emit + +上下文压缩开始。 + +### compact/end + +- **触发模式:** emit + +上下文压缩结束。 diff --git a/website/zh-CN/api/cordis/fiber.md b/website/zh-CN/api/cordis/fiber.md new file mode 100644 index 0000000000..ffb8f23bb5 --- /dev/null +++ b/website/zh-CN/api/cordis/fiber.md @@ -0,0 +1,108 @@ +# Fiber + +Fiber(作用域)是插件实例的运行时容器,管理其生命周期和效果。 + +## 状态机 + +``` +PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED + ↘ FAILED +``` + +| 状态 | 数值 | 含义 | +|------|------|------| +| PENDING | 0 | 依赖未就绪,等待中 | +| LOADING | 1 | 正在执行 `apply` | +| ACTIVE | 2 | 运行中 | +| FAILED | 3 | `apply` 抛出异常 | +| UNLOADING | 4 | 正在撤销效果 | +| DISPOSED | 5 | 已完全卸载 | + +## 实例属性 + +### fiber.uid + +- **类型:** `number` + +Fiber 的唯一标识符。 + +### fiber.status + +- **类型:** `number` + +当前状态(见状态机)。 + +### fiber.config + +- **类型:** `object` + +传递给插件的配置对象。 + +### fiber.error + +- **类型:** `Error | undefined` + +如果状态是 FAILED,包含导致失败的异常。 + +## 实例方法 + +### fiber.effect(callback) {#fiber-effect} + +- **callback:** `() => (() => void) | void` +- **返回值:** `() => void` + +注册一个效果。`callback` 在 Fiber 激活时执行;如果返回函数,该函数在 Fiber dispose 时执行。 + +```typescript +ctx.effect(() => { + const timer = setInterval(tick, 1000) + return () => clearInterval(timer) +}) +``` + +等价地可以通过 `ctx.effect()` 调用(ctx 代理到当前 fiber)。 + +### fiber.dispose() + +- **返回值:** `Promise` + +手动 dispose 该 Fiber。按注册逆序撤销所有效果,递归 dispose 所有子 Fiber。 + +```typescript +const child = ctx.plugin(somePlugin) +// 之后: +await child.dispose() +``` + +### fiber.update(config) + +- **config:** `object` 新配置 +- **返回值:** `void` + +热更新配置。如果新旧配置不同,触发 dispose + 重新 apply。 + +### fiber.restart() + +- **返回值:** `void` + +强制重启:dispose 后重新加载。 + +### fiber.then(resolve, reject?) + +- **返回值:** `Promise` + +使 Fiber 可以被 `await`:等到状态进入 ACTIVE 或 FAILED。 + +```typescript +const fiber = ctx.plugin(myPlugin) +await fiber // 等待插件加载完成 +``` + +## 访问当前 Fiber + +```typescript +export function apply(ctx: Context) { + const fiber = ctx.fiber // 当前插件的 Fiber + console.log(fiber.status) // 1 (LOADING, 因为正在 apply 中) +} +``` diff --git a/website/zh-CN/api/cordis/registry.md b/website/zh-CN/api/cordis/registry.md new file mode 100644 index 0000000000..e0f66d8ed7 --- /dev/null +++ b/website/zh-CN/api/cordis/registry.md @@ -0,0 +1,87 @@ +# Registry + +插件注册表,管理插件的加载和依赖解析。 + +## 实例方法 + +### ctx.plugin(plugin, config?) {#ctx-plugin} + +- **plugin:** `Plugin` 插件(函数、对象或类) +- **config:** `object` 传递给插件的配置(可选) +- **返回值:** `Fiber` + +加载一个子插件,返回其 Fiber。子 Fiber 的生命周期绑定到父上下文。 + +```typescript +// 函数插件 +ctx.plugin(myPlugin, { key: 'value' }) + +// 类插件 +ctx.plugin(MyService) + +// 返回的 Fiber 可以 await 或 dispose +const fiber = ctx.plugin(myPlugin) +await fiber +``` + +### ctx.inject(names, callback) {#ctx-inject} + +- **names:** `string[]` 服务名列表 +- **callback:** `(ctx: Context) => void` +- **返回值:** `() => void` + +等待指定服务全部就绪后执行 callback。如果服务消失,callback 的效果会自动撤销;服务恢复后重新执行。 + +```typescript +ctx.inject(['tools', 'llm'], (ctx) => { + // tools 和 llm 都就绪了 + ctx.tools.register(/* ... */) +}) +``` + +这是 `export const inject = [...]` 声明的底层 API。大多数情况下直接使用声明式写法即可。 + +## 插件形态 + +`ctx.plugin()` 接受三种插件形态: + +### 函数插件 + +```typescript +function myPlugin(ctx: Context, config?: Config) { + // ... +} +myPlugin.name = 'my-plugin' +myPlugin.inject = ['tools'] +``` + +### 对象插件 + +```typescript +const myPlugin = { + name: 'my-plugin', + inject: ['tools'], + apply(ctx: Context, config?: Config) { + // ... + }, +} +``` + +### 类插件(Service) + +```typescript +class MyService extends Service { + static inject = ['tools'] + constructor(ctx: Context) { + super(ctx, 'myService') + } +} +``` + +## 插件元信息 + +| 属性 | 类型 | 说明 | +|------|------|------| +| `name` | `string` | 插件名称(日志用) | +| `inject` | `string[] \| { required?: string[], optional?: string[] }` | 依赖声明 | +| `Config` | `Schema \| object` | 配置 schema 或默认值 | diff --git a/website/zh-CN/api/cordis/service.md b/website/zh-CN/api/cordis/service.md new file mode 100644 index 0000000000..a57a00c461 --- /dev/null +++ b/website/zh-CN/api/cordis/service.md @@ -0,0 +1,97 @@ +# Service + +Service 基类,用于创建对外暴露能力的插件。 + +## 基本用法 + +```typescript +import { Service, type Context } from 'cordis' + +declare module 'cordis' { + interface Context { + myService: MyService + } +} + +export default class MyService extends Service { + constructor(ctx: Context) { + super(ctx, 'myService') + } + + // 公开方法 + doSomething() { + // ... + } +} +``` + +加载后,其他插件可通过 `ctx.myService` 访问。 + +## 构造函数 + +### new Service(ctx, name) + +- **ctx:** `Context` 上下文 +- **name:** `string` 服务名(注册到 `ctx[name]`) + +## 实例属性 + +### service.ctx + +- **类型:** `Context` + +该服务绑定的上下文。 + +### service\[Service.tracker\] + +- **类型:** `object` + +服务追踪信息(名称、绑定状态等)。 + +## 生命周期 + +Service 子类可以覆写以下方法: + +### start() + +服务激活时调用。在这里初始化资源。 + +### stop() + +服务停用时调用。在这里释放资源。 + +## 静态属性 + +### Service.inject + +- **类型:** `string[] | { required?: string[], optional?: string[] }` + +声明本服务依赖的其他服务。 + +## 与 inject 的关系 + +当一个 Service 被加载: +1. 框架为该服务名创建声明 (`ctx.provide`) +2. 实例赋值到 `ctx[name]` +3. 依赖该服务的所有 Fiber 从 PENDING 转为 LOADING + +当 Service 被卸载: +1. `ctx[name]` 被置为 `undefined` +2. 依赖它的 Fiber 被 dispose +3. 当新的 provider 出现时,dependant Fiber 重新加载 + +## 示例:Harness 中的 Service + +```typescript +// dsh-tools 的 ToolRegistry 就是一个 Service +export class ToolRegistry extends Service { + constructor(ctx: Context) { + super(ctx, 'tools') + } + + register(tool: ToolDefinition): () => void { + // ...注册逻辑 + return dispose + } +} +``` diff --git a/website/zh-CN/api/harness/agent.md b/website/zh-CN/api/harness/agent.md new file mode 100644 index 0000000000..bf46c7e4e1 --- /dev/null +++ b/website/zh-CN/api/harness/agent.md @@ -0,0 +1,85 @@ +# Agent (dsh-agent) + +Agent 实例管理和生命周期。 + +**包名:** `@deepseek-ai/dsh-agent` +**服务名:** `ctx.agents` + +## Agent Service + +### ctx.agents.create(options) + +- **options:** `AgentOptions` +- **返回值:** `Agent` + +创建一个新的 Agent 实例。 + +### ctx.agents.get(id) + +- **id:** `AgentId` +- **返回值:** `Agent | undefined` + +获取指定 ID 的 Agent 实例。 + +## AgentOptions + +```typescript +interface AgentOptions { + /** Agent ID(branded) */ + id?: AgentId + /** 使用的模型名 */ + model: string + /** 系统提示词(支持 {{model}} 变量) */ + persona?: string + /** 关联的 session */ + session?: Session +} +``` + +## Agent 实例 + +### agent.id + +- **类型:** `AgentId` + +Agent 的唯一标识符(branded string)。 + +### agent.model + +- **类型:** `string` + +Agent 使用的模型名。 + +### agent.step(input) + +- **input:** `ContentBlock[]` +- **返回值:** `Promise` + +执行一步:将输入发送给模型,获取响应,执行 tool calls。这是 agent-loop 内部使用的核心方法。 + +## Agent Loop + +Agent 的执行循环由 `dsh-agent-loop` 管理。它: + +1. 组装 system prompt + 历史消息 + 当前输入 +2. 调用 LLM(通过 `ctx.llm`) +3. 解析响应中的 tool calls +4. 执行 tools +5. 将 tool results 追加到 session +6. 如果 finish reason 是 `tool-calls`,回到步骤 2 + +### 扩展点 + +- `agent/pre-step` 事件 — 在每一步 LLM 调用前触发 +- `agent/post-step` 事件 — 在每一步完成后触发 +- `llm/pre-request` waterfall — 可修改发送给模型的消息 + +## AgentId + +Opaque branded string: + +```typescript +import { AgentId } from '@deepseek-ai/dsh-agent' + +const id = AgentId('main') +``` diff --git a/website/zh-CN/api/harness/bash.md b/website/zh-CN/api/harness/bash.md new file mode 100644 index 0000000000..8e8d8d3068 --- /dev/null +++ b/website/zh-CN/api/harness/bash.md @@ -0,0 +1,81 @@ +# Bash (dsh-bash) + +Bash 命令执行接口。 + +**接口包:** `@deepseek-ai/dsh-bash` +**实现:** `@deepseek-ai/dsh-bash-local` +**消费者:** `@deepseek-ai/dsh-tool-bash`(内置于 agent-core) + +## Bash Service + +### ctx.bash.execute(request) + +- **request:** `BashRequest` +- **返回值:** `Promise` + +执行一个 bash 命令。 + +## BashRequest + +```typescript +interface BashRequest { + /** 要执行的命令 */ + command: string + /** 工作目录 */ + workdir?: string + /** 超时时间 (ms) */ + timeoutMs?: number +} +``` + +## BashResult + +```typescript +interface BashResult { + /** 退出码 */ + exitCode: number + /** stdout 输出 */ + stdout: string + /** stderr 输出 */ + stderr: string + /** 是否超时 */ + timedOut: boolean +} +``` + +## 配置 (dsh-bash-local) + +```typescript +interface Config { + /** 命令超时时间,默认 120000 (2 分钟) */ + timeoutMs: number +} +``` + +在 `cordis.yml` 中: + +```yaml +- name: '@deepseek-ai/dsh-bash-local' + config: + timeoutMs: 60000 +``` + +## 模型可用的 Tools + +`dsh-tool-bash` 向模型暴露以下 tools(由 `agent-core` 捆绑): + +| Tool | 说明 | +|------|------| +| `bash` | 执行命令(同步,等待完成) | +| `bash_output` | 获取后台命令的输出 | +| `bash_kill` | 终止后台命令 | + +## 设计模式 + +Bash 是 Harness 的"能力三件套"典型案例: + +- `dsh-bash`(接口):定义 `ctx.bash` 和 `BashRequest`/`BashResult` 类型 +- `dsh-bash-local`(实现):通过 `child_process.spawn` 在本地执行 +- `dsh-tool-bash`(消费者):将能力包装为模型可调用的 tool + +换一个沙箱执行器只需替换 `dsh-bash-local`,接口和 tool 不变。 diff --git a/website/zh-CN/api/harness/fs.md b/website/zh-CN/api/harness/fs.md new file mode 100644 index 0000000000..4e336ff962 --- /dev/null +++ b/website/zh-CN/api/harness/fs.md @@ -0,0 +1,78 @@ +# Filesystem (dsh-fs) + +文件系统操作接口。 + +**接口包:** `@deepseek-ai/dsh-fs` +**实现:** `@deepseek-ai/dsh-fs-local` + `@deepseek-ai/dsh-fs-policy` +**消费者:** `@deepseek-ai/dsh-tool-fs` + +## FS Service + +### ctx.fs.read(path, options?) + +- **path:** `string` +- **options:** `{ offset?: number; limit?: number }` +- **返回值:** `Promise` + +读取文件内容。 + +### ctx.fs.write(path, content) + +- **path:** `string` +- **content:** `string` +- **返回值:** `Promise` + +写入文件(覆盖)。 + +### ctx.fs.edit(path, edits) + +- **path:** `string` +- **edits:** `Edit[]` +- **返回值:** `Promise` + +对文件执行精确的字符串替换编辑。 + +### ctx.fs.stat(path) + +- **path:** `string` +- **返回值:** `Promise` + +获取文件/目录信息。 + +## 配置 (dsh-fs-local) + +```typescript +interface Config { + /** 工作目录(相对路径的基准) */ + cwd: string +} +``` + +## 策略门 (dsh-fs-policy) + +`dsh-fs-policy` 是一个可选的中间层插件,实现 read-before-write/edit 策略——模型必须先读取文件才能写入或编辑。这防止模型盲目覆盖文件。 + +在 `cordis.yml` 中,它位于 `fs-local` 和 `tool-fs` 之间: + +```yaml +- name: '@deepseek-ai/dsh-fs-local' + config: + cwd: !!js process.cwd() +- name: '@deepseek-ai/dsh-fs-policy' +- name: '@deepseek-ai/dsh-tool-fs' +``` + +## 模型可用的 Tools + +| Tool | 说明 | +|------|------| +| `read` | 读取文件内容(支持 offset/limit) | +| `write` | 写入文件(需要先 read) | +| `edit` | 精确字符串替换(需要先 read) | + +## 三件套结构 + +- `dsh-fs`:接口定义 +- `dsh-fs-local`:本地文件系统实现 +- `dsh-fs-policy`:策略门(read-before-write 检查) +- `dsh-tool-fs`:模型 tool 层 diff --git a/website/zh-CN/api/harness/llm.md b/website/zh-CN/api/harness/llm.md new file mode 100644 index 0000000000..82a4d8e225 --- /dev/null +++ b/website/zh-CN/api/harness/llm.md @@ -0,0 +1,124 @@ +# LLM (dsh-llm) + +LLM 服务接口和适配器注册。 + +**包名:** `@deepseek-ai/dsh-llm` +**服务名:** `ctx.llm` + +## LLM Service + +### ctx.llm.registerAdapter(models, adapter) + +- **models:** `string[]` 该适配器支持的模型名列表 +- **adapter:** `LlmAdapter` 适配器实例 +- **返回值:** `() => void` disposer + +注册一个 LLM 适配器。当请求中指定的模型名在 `models` 列表中时,路由到该适配器。 + +```typescript +ctx.llm.registerAdapter(['deepseek-v4-flash', 'deepseek-v4-pro'], adapter) +``` + +## LlmAdapter + +适配器基类。子类必须实现 `stream()` 方法。 + +### stream(options) + +- **options:** `GenerateOptions` +- **返回值:** `AsyncIterable` + +将统一请求格式转换为具体 API 的流式调用。 + +## GenerateOptions + +```typescript +interface GenerateOptions { + model: string + messages: Message[] + tools?: ToolSpec[] + system?: string + maxTokens?: number + temperature?: number +} +``` + +| 字段 | 说明 | +|------|------| +| `model` | 请求的模型名 | +| `messages` | 对话历史 | +| `tools` | 当前可用的 tool 列表(JSON Schema 格式) | +| `system` | 系统提示词 | +| `maxTokens` | 最大输出 token | +| `temperature` | 采样温度 | + +## StreamChunk + +流式响应的增量 chunk 类型: + +```typescript +type StreamChunk = + | { type: 'block-start'; index: number; blockType: 'text' | 'tool-call' } + | { type: 'text-delta'; index: number; text: string } + | { type: 'tool-call-delta'; index: number; id: CallId; name: string; argumentsDelta: string } + | { type: 'block-end'; index: number; block: ContentBlock } + | { type: 'usage'; usage: TokenUsage } + | { type: 'finish'; reason: FinishReason } +``` + +### 协议规则 + +1. 每个内容块以 `block-start` 开始,以 `block-end` 结束 +2. `index` 从 0 递增 +3. `text-delta` 只在 `blockType: 'text'` 的块中 +4. `tool-call-delta` 只在 `blockType: 'tool-call'` 的块中 +5. `usage` 在 `finish` 之前 +6. `finish` 必须是最后一个 chunk + +## CallId + +Tool call 的 opaque branded ID: + +```typescript +import { CallId } from '@deepseek-ai/dsh-llm' + +const id = CallId('call-abc123') +``` + +## TokenUsage + +```typescript +interface TokenUsage { + inputTokens: number + outputTokens: number +} +``` + +## FinishReason + +```typescript +type FinishReason = + | { kind: 'stop' } + | { kind: 'tool-calls' } + | { kind: 'max-tokens' } +``` + +## Message + +对话消息类型: + +```typescript +interface Message { + role: 'user' | 'assistant' + content: ContentBlock[] +} +``` + +## ContentBlock + +```typescript +type ContentBlock = + | { type: 'text'; text: string } + | { type: 'tool-call'; id: CallId; name: string; arguments: string } + | { type: 'tool-result'; callId: CallId; content: ContentBlock[]; isError?: boolean } +``` diff --git a/website/zh-CN/api/harness/session.md b/website/zh-CN/api/harness/session.md new file mode 100644 index 0000000000..5ff5b0d97b --- /dev/null +++ b/website/zh-CN/api/harness/session.md @@ -0,0 +1,56 @@ +# Session (dsh-session) + +会话事件流管理。 + +**包名:** `@deepseek-ai/dsh-session` +**服务名:** `ctx.session` + +## 概述 + +Session 是 Agent 的对话状态容器。所有模型可见的内容都必须经过 session 事件流记录——这是"model-visible = logged"原则的实现。 + +## SessionSurface + +会话的外部接口,用于查询当前状态。 + +### surface.messages + +- **类型:** `Message[]` + +当前会话的完整消息列表(经过 compaction 处理后的视图)。 + +### surface.events + +- **类型:** `SessionEvent[]` + +原始事件流。 + +## SessionEvent + +会话中所有变更以事件形式记录: + +```typescript +type SessionEvent = + | { type: 'user/message'; content: ContentBlock[] } + | { type: 'assistant/message'; content: ContentBlock[] } + | { type: 'tool/call'; name: string; args: unknown; callId: CallId } + | { type: 'tool/result'; callId: CallId; content: ContentBlock[]; isError?: boolean } + | { type: 'compact/start'; range: [number, number] } + | { type: 'compact/end'; summary: string } + | { type: 'todo/write'; items: TodoItem[] } + // ... 更多事件类型 +``` + +## 设计原则 + +### Model-visible = Logged + +任何到达模型请求的内容都必须能从 session log 重建。如果你要引入新的模型可见输入,必须先定义对应的 session event。 + +### 事件是 append-only + +Session 事件流是只追加的。修改历史(如 compaction)通过新事件(compact/start + compact/end)表达,而不是修改旧事件。 + +### 持久化 + +Session 事件流可以通过 `dsh-session-persistence` 持久化到磁盘(JSONL 或 SQLite),实现跨进程恢复。 diff --git a/website/zh-CN/api/harness/subagent.md b/website/zh-CN/api/harness/subagent.md new file mode 100644 index 0000000000..97ad7b5c87 --- /dev/null +++ b/website/zh-CN/api/harness/subagent.md @@ -0,0 +1,85 @@ +# Subagent (dsh-subagent) + +子代理委派接口。 + +**接口包:** `@deepseek-ai/dsh-subagent` +**实现:** `@deepseek-ai/dsh-subagent-spawn` / `@deepseek-ai/dsh-subagent-fork` +**消费者:** `@deepseek-ai/dsh-tool-subagent` + +## Subagent Service + +### ctx.subagent.run(request) + +- **request:** `SubagentRequest` +- **返回值:** `Promise` + +委派一个任务给子代理执行。 + +## SubagentRequest + +```typescript +interface SubagentRequest { + /** 使用的 provider 名称 */ + provider: string + /** 委派给子代理的提示 */ + prompt: string + /** 子代理使用的模型(可选,默认继承父) */ + model?: string +} +``` + +## SubagentResult + +```typescript +interface SubagentResult { + /** 子代理的最终回复 */ + response: string +} +``` + +## Provider 模式 + +Subagent 支持多种"后端"(provider),通过配置选择: + +### spawn + +创建一个全新的子代理实例,没有父级的对话历史: + +```yaml +- name: '@deepseek-ai/dsh-subagent-spawn' + config: + providerName: spawn +``` + +### fork + +创建一个携带父级已完成 turn 前缀的子代理,子代理"知道"父级的对话上下文: + +```yaml +- name: '@deepseek-ai/dsh-subagent-fork' + config: + providerName: fork +``` + +## 模型可用的 Tools + +通过 `dsh-tool-subagent` 暴露。可以加载多次,每次绑定不同 provider: + +```yaml +# 暴露为 "subagent" tool,使用 spawn 后端 +- name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + +# 暴露为 "subagent_fork" tool,使用 fork 后端 +- name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: fork + toolName: subagent_fork +``` + +## 使用场景 + +- **spawn** — 独立子任务(如"搜索这个问题"),子代理不需要知道父级上下文 +- **fork** — 需要上下文的子任务(如"基于我们刚才讨论的,去实现这个"),子代理继承父级的对话前缀 diff --git a/website/zh-CN/api/harness/tools.md b/website/zh-CN/api/harness/tools.md new file mode 100644 index 0000000000..d2011ad85e --- /dev/null +++ b/website/zh-CN/api/harness/tools.md @@ -0,0 +1,122 @@ +# Tools (dsh-tools) + +Tool 注册表和 `defineTool` DSL。 + +**包名:** `@deepseek-ai/dsh-tools` +**服务名:** `ctx.tools` + +## ToolRegistry + +### ctx.tools.register(tool) + +- **tool:** `ToolDefinition` +- **返回值:** `() => void` disposer + +注册一个 tool。返回的 disposer 可手动撤销注册(通常不需要,插件卸载时自动撤销)。 + +## defineTool\(options) + +类型安全的 tool 定义辅助函数。 + +```typescript +import { defineTool } from '@deepseek-ai/dsh-tools' + +const tool = defineTool({ + name: 'read_file', + description: 'Read a file from disk.', + parameters: { + path: { type: 'string', required: true, description: 'Absolute file path' }, + offset: { type: 'number' }, + limit: { type: 'number', description: 'Max lines to read' }, + }, + async execute(args) { + // args: { path: string; offset?: number; limit?: number } + }, +}) +``` + +### DefineToolOptions\ + +| 字段 | 类型 | 说明 | +|------|------|------| +| `name` | `string` | Tool 名称(全局唯一) | +| `description` | `string` | 发送给模型的描述 | +| `parameters` | `SchemaSpec` | 参数 schema(见下文) | +| `execute` | `(args: InferArgs, exec: ToolExecution) => Promise` | 执行函数 | +| `presentCall?` | `(args: InferArgs) => ToolCallView \| undefined` | UI 展示(纯函数) | +| `presentResult?` | `(args: InferArgs, result: ToolResult) => ToolResultView \| undefined` | 结果 UI 展示(纯函数) | + +## SchemaSpec + +参数 schema DSL。每个属性是一个 `SchemaProp`: + +```typescript +interface SchemaProp { + type: 'string' | 'number' | 'boolean' | 'object' | 'array' + required?: true + description?: string + enum?: string[] + properties?: SchemaSpec // type: 'object' 时 + items?: SchemaProp // type: 'array' 时 +} +``` + +### 类型推导 (InferArgs) + +`InferArgs` 自动从 `SchemaSpec` 推导 TypeScript 类型: + +- `required: true` → 必填字段 +- 无 `required` → 可选字段(`?`) +- `type: 'object'` + `properties` → 递归推导嵌套对象 +- `type: 'array'` + `items` → 推导为数组 + +## ToolDefinition + +运行时 tool 定义(`defineTool` 的返回值): + +```typescript +interface ToolDefinition { + name: string + description: string + parameters: Record // JSON Schema + execute(args: unknown, exec: ToolExecution): Promise + presentCall?(args: unknown): ToolCallView | undefined + presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined +} +``` + +## ToolExecuteReturn + +```typescript +type ToolExecuteReturn = + | ContentBlock[] // 仅内容 + | { content: ContentBlock[]; meta?: unknown } // 内容 + 元信息 +``` + +## ToolArgsError + +当模型生成的参数不匹配 schema 时抛出: + +```typescript +class ToolArgsError extends HarnessError { + code: 'INVALID_ARGS' + violations: string[] +} +``` + +框架自动捕获并转换为 `isError` 结果返回给模型。 + +## validateArgs(spec, args) + +- **spec:** `SchemaSpec` +- **args:** `unknown` +- **返回值:** `string[]` 违规信息列表(空 = 合法) + +手动校验参数。`defineTool` 内部使用,通常不需要直接调用。 + +## schemaSpecToJsonSchema(spec) + +- **spec:** `SchemaSpec` +- **返回值:** `JsonSchemaObject` + +将 SchemaSpec 转换为标准 JSON Schema。用于发送给模型的 wire format。 diff --git a/website/zh-CN/api/index.md b/website/zh-CN/api/index.md new file mode 100644 index 0000000000..371cd1e622 --- /dev/null +++ b/website/zh-CN/api/index.md @@ -0,0 +1,25 @@ +# API 参考 + +本节提供 DeepSeek Harness 的完整 API 参考文档,分为两部分: + +## 框架 API + +Cordis 微内核提供的基础能力,所有插件开发都建立在这些 API 之上: + +- [Context](./cordis/context) — 上下文对象,所有服务和方法的入口 +- [Events](./cordis/events) — 事件系统 API(emit / on / bail / serial / waterfall) +- [Fiber](./cordis/fiber) — 作用域生命周期(状态机、effect、dispose) +- [Registry](./cordis/registry) — 插件注册(plugin / inject) +- [Service](./cordis/service) — 服务基类 + +## Harness API + +DeepSeek Harness SDK 提供的扩展 API,用于构建 Agent 能力: + +- [Tools (dsh-tools)](./harness/tools) — Tool 注册、defineTool DSL、Schema 类型系统 +- [LLM (dsh-llm)](./harness/llm) — LLM 服务、适配器注册、StreamChunk 协议 +- [Session (dsh-session)](./harness/session) — 会话事件流、消息类型 +- [Agent (dsh-agent)](./harness/agent) — Agent 实例管理、生命周期 +- [Bash (dsh-bash)](./harness/bash) — Bash 执行接口 +- [Filesystem (dsh-fs)](./harness/fs) — 文件系统接口 +- [Subagent (dsh-subagent)](./harness/subagent) — 子代理委派接口 diff --git a/website/zh-CN/design/composability.md b/website/zh-CN/design/composability.md new file mode 100644 index 0000000000..8370d8e136 --- /dev/null +++ b/website/zh-CN/design/composability.md @@ -0,0 +1,72 @@ +# 可组合性与插件系统 + +## 组合 + +编程的本质就是组合。将小的构建块拼装为更大的系统,再将大系统作为块继续拼装——这是从函数到模块到微服务一脉相承的思想。 + +组合可以分为两种: + +- **静态组合**:编译期确定的组合,例如函数调用、模块导入。 +- **动态组合**:运行时确定的组合,例如热更新、插件加载/卸载。 + +静态组合是逻辑的组合;动态组合为可组合性引入了时间和空间两个新维度。 + +## 三种可组合性 + +| 维度 | 定义 | 对应问题 | +|------|------|----------| +| **逻辑可组合性** (Logical) | 功能能否被任意拆分和组装 | 接口设计是否正交 | +| **时间可组合性** (Temporal) | 能否灵活、安全地控制组合的运行时序 | 能否热加载/卸载而不泄漏 | +| **空间可组合性** (Spatial) | 能否灵活、安全地管理组合的依赖关系 | 依赖缺失时行为是否确定 | + +一门编程语言或应用框架越多地使用组合范式,就称它的可组合性越好。 + +## 传统插件系统的问题 + +插件系统是动态组合的典型形式。浏览器扩展、IDE 插件、操作系统驱动,都是其实例。然而大多数插件系统并不可靠。 + +### 不可逆的插件化 + +以 VSCode 为例: + +- 卸载或更新插件时需要重启整个系统。 +- 无法在运行时追踪和回收副作用,导致内存泄漏和非预期的资源占用。 +- 即便提供了 `deactivate` 钩子,也无法强制开发者正确实现清理逻辑。 + +**根本原因**:未做到时间可组合——系统不知道某个插件产生了哪些副作用、占用了哪些资源。 + +### 不完全的插件化 + +- 无法表达插件间的依赖关系,扩展能力受限。 +- 只有外围功能被下放给插件,核心功能依然通过修改主体代码来实现。 + +**根本原因**:未做到空间可组合——系统缺乏对依赖关系的建模和管理。 + +## Cordis 的解法 + +Cordis 同时解决了上述两个问题: + +1. **可逆作用** (Revertible Effects) 实现时间可组合性——所有注册自动追踪、自动回收。 +2. **响应式余作用** (Reactive Coeffects) 实现空间可组合性——依赖声明驱动加载顺序。 + +两者通过**上下文模型** (Context Model) 统一为单一的编程范式:开发者只需通过 `ctx` 调用框架 API,可逆性和依赖管理由框架保证。 + +## 在 Harness 中的体现 + +DeepSeek Harness 将 Cordis 的可组合性应用到 Agent 开发领域: + +```typescript +// 一个 Harness 插件天然是可逆的 +export const inject = ['tools', 'llm'] // 空间可组合:声明依赖 + +export function apply(ctx: Context) { + // 时间可组合:注册会被自动追踪和回收 + ctx.tools.register(defineTool('my-tool', { + description: '...', + parameters: { /* ... */ }, + async execute(args) { /* ... */ }, + })) +} +``` + +插件卸载时,tool 自动注销、事件监听自动移除——无需手动清理。依赖的服务(如 `llm`)消失时,插件自动挂起;恢复时自动重新加载。 diff --git a/website/zh-CN/design/context-model.md b/website/zh-CN/design/context-model.md new file mode 100644 index 0000000000..cc25df88e5 --- /dev/null +++ b/website/zh-CN/design/context-model.md @@ -0,0 +1,129 @@ +# 上下文模型 + +上下文 (Context) 是 Cordis 将作用与余作用统一的运行时模型。它提供了一种编程范式,允许开发者无心智负担地编写时间、空间可组合的程序。 + +## 作用上下文 (Effect Context) + +当副作用被记录到全局环境时,$\mathcal{C}\times\left(\mathcal{C}\to\mathcal{C}\right)$ 也就变成了一个更大的 $\mathcal{C}$。 + +递归地定义: + +$$ +\begin{matrix} +\mathcal{C}_1=\mathcal{C}_0\times\left(\mathcal{C}_0\to\mathcal{C}_0\right)\\ +\mathcal{C}_2=\mathcal{C}_1\times\left(\mathcal{C}_1\to\mathcal{C}_1\right)\\ +\cdots\\ +\mathcal{C}_{n+1}=\mathcal{C}_n\times\left(\mathcal{C}_n\to\mathcal{C}_n\right)\\ +\end{matrix} +$$ + +每一层 $\mathcal{C}$ 包含上一层的状态,同时记录了上一层的副作用。 + +利用递归类型得到真正的作用上下文: + +$$ +\mathcal{C}=\mathcal{C}\times\left(\mathcal{C}\to\mathcal{C}\right) +$$ + +这就是 Cordis Context 的理论根基:**上下文既是状态容器,又是副作用追踪器。** + +## 上下文的派生 + +当一个插件被加载时,从当前上下文派生出新的上下文实例: + +``` +Root Context +├── Plugin A Context ← 管理 A 的副作用 +│ └── Sub-plugin Context +└── Plugin B Context ← 管理 B 的副作用 +``` + +- 子级上下文管理插件内部的全部副作用 +- 插件整体作为一个副作用被父级上下文收集 +- 父级 dispose 时,子级先被 dispose(保证依赖逆序) + +## 余作用上下文 (Coeffect Context) + +余作用由作用产生: + +- **提供服务**本身是一种作用——它占用了服务命名空间资源 +- 因此服务的提供被记录在作用上下文中 +- 上下文将作用与余作用关联起来,提供了统一的时间、空间可组合性 + +```typescript +// 提供服务 = 一个 effect(占用 ctx.llm 这个 "资源") +class LlmService extends Service { + // 当此插件卸载时,ctx.llm 被回收(effect 的逆操作) + // 所有依赖 llm 的插件因 coeffect 不满足而挂起 +} +``` + +## 基于上下文的开发范式 + +上下文模型提供了两个关键优势: + +### 无感性 (Transparent) + +框架将领域中的所有方法都封装为 effect 版本。开发者只需调用 `ctx` 上的方法,就能自动获得时间/空间可组合性: + +```typescript +export function apply(ctx: Context) { + // 以下每一行都是 effect——卸载时自动逆序回收 + ctx.on('agent/step-result', validateResult) + ctx.tools.register(myTool) + ctx.llm.registerAdapter(['my-model'], adapter) + + // 开发者无需知道"可逆作用"的存在 + // 只需通过 ctx 调用,框架保证一切安全 +} +``` + +### 渐进性 (Incremental) + +可以逐步将现有框架中的 API 替换为可组合版本,无需一次性重写: + +```typescript +// 第一步:用 ctx.effect 包装遗留 API +ctx.effect(() => { + const legacy = legacySystem.register(handler) + return () => legacySystem.unregister(legacy) +}) + +// 第二步:在未来将遗留 API 原生改造为 effect +// 两种方式可以并存 +``` + +## 在 Harness 中的完整图景 + +DeepSeek Harness 的运行时是一个 Context 树: + +``` +Root Context (Cordis 应用) +├── dsh-session (提供 ctx.sessions) +├── dsh-tools (提供 ctx.tools) +├── dsh-llm (提供 ctx.llm) +│ └── deepseek-adapter (注册模型适配器) +├── dsh-agent-loop (提供 ctx.agentLoop) +├── dsh-bash (提供 ctx.bash) +│ └── bash-local (本地执行器实现) +├── dsh-fs (提供 ctx.fs) +│ └── fs-local (本地 FS 实现) +├── dsh-system-prompt (提供 ctx.systemPrompt) +└── Agent Context (由 agents.create() 派生) + ├── Agent 自己注册的 tools + ├── Agent 的 session + └── Subagent Context (进一步派生) +``` + +每个节点都是一个 Context 实例。插件加载/卸载、服务出现/消失、Agent 创建/销毁——这一切都在 Context 树上以统一的语义发生。 + +## 总结 + +| 概念 | 解决的问题 | Cordis 机制 | +|------|-----------|-------------| +| 作用上下文 | 副作用追踪与回收 | `ctx.effect()` / `fiber.dispose()` | +| 上下文派生 | 副作用的层级隔离 | `ctx.plugin()` 创建子 Context | +| 余作用上下文 | 依赖的动态管理 | `inject` 声明 + 服务生命周期 | +| 统一范式 | 开发者无需关心底层机制 | 只需通过 `ctx` 调用 API | + +这就是为什么 Harness 能在保持「一切皆插件」的同时,不给插件开发者增加心智负担——**上下文模型把复杂性封装在了框架内部**。 diff --git a/website/zh-CN/design/effects-coeffects.md b/website/zh-CN/design/effects-coeffects.md new file mode 100644 index 0000000000..01c181315f --- /dev/null +++ b/website/zh-CN/design/effects-coeffects.md @@ -0,0 +1,69 @@ +# 作用与余作用 + +## 作用 (Effects) + +Effects 是程序中对系统状态或外部环境产生影响的操作:I/O、状态修改、资源占用等。 + +学术界对作用有两种主要建模方式: + +### 单子作用 (Monadic Effects) + +- 通过单子 (monad) 将副作用封装为类型安全的计算链。 +- 提供 `return`(纯值注入)和 `bind`(链式组合)两个基本操作。 +- 以纯函数式的方式处理带有副作用的计算。(Moggi 1991, Wadler 1992) +- 代表语言:Haskell (IO Monad)、Rust (Result/Option) + +### 代数作用 (Algebraic Effects) + +- 允许在函数中"抛出"一个 effect,在调用栈的更高层次"捕获"并处理。 +- 类似异常处理,但更通用——处理后可以恢复执行。 +- 代表语言:Koka、Eff、OCaml 5+ (Kiselyov 2018, Kawahara 2020) + +## 余作用 (Coeffects) + +Coeffects 是程序执行时依赖的上下文信息:环境变量、系统资源、外部服务等。 + +- Coeffects 是 effects 的对偶 (dual) 概念,通常通过余单子 (comonad) 建模。(Petricek 2013, 2014; Brünnler 2014) +- 更前沿的理论将带有资源的上下文建模为 **graded algebra**(有序半环加最大元): + - 加法 = 并行组合;0 元 = 无资源 + - 乘法 = 串行组合;1 元 = 单位资源 + - 序 = 资源约束;最大元 = 无限资源 + - (Breuvart 2015, Gaboardi 2016, Dal Lago 2022) + +## 现有理论的不足 + +这些理论主要面向**静态分析**和**短时程序**: + +1. **缺乏运行时追踪**:类型系统能标记副作用的存在,但无法在运行时追踪和回收。对长时运行程序(服务端、Agent),这意味着资源泄漏不可避免。 + +2. **缺乏动态性**:面向编译期分析,无法处理运行时的加载/卸载需求。 + +3. **崩溃而非降级**:类型不满足时直接拒绝编译或运行时崩溃,而长时运行程序更希望安全降级——挂起不满足依赖的部分,而非停止整个系统。 + +## Cordis 的突破 + +Cordis 选择了不同的路径——在运行时层面解决可组合性问题: + +| 现有理论 | Cordis 方案 | +|----------|-------------| +| 类型标记副作用 | 运行时追踪并自动回收副作用 | +| 编译期拒绝 | 运行时挂起/恢复 | +| 面向短时程序 | 面向长时运行程序设计 | + +这由两个互补机制实现: + +- **[可逆作用](./revertible-effects)** — 将副作用形式化为可逆的群操作 +- **[响应式余作用](./reactive-coeffects)** — 将依赖建模为具有生命周期的服务 + +## 在 Agent 开发中的意义 + +对 DeepSeek Harness 而言,作用/余作用模型直接支撑了以下能力: + +| 作用 (Effect) | 余作用 (Coeffect) | +|---------------|-------------------| +| 注册一个 tool | 依赖 tool registry 服务 | +| 注册一个 LLM adapter | 依赖 LLM 服务接口 | +| 监听 session 事件 | 依赖 session 服务存在 | +| 启动子进程 | 依赖 bash executor 实现 | + +每一个 effect 都可逆(tool 可注销、adapter 可移除);每一个 coeffect 都有生命周期(服务消失则依赖者挂起)。这就是 Agent 能被安全热替换的根本原因。 diff --git a/website/zh-CN/design/index.md b/website/zh-CN/design/index.md new file mode 100644 index 0000000000..de6ebcf7aa --- /dev/null +++ b/website/zh-CN/design/index.md @@ -0,0 +1,39 @@ +# 系统设计 + +DeepSeek Harness 建立在 Cordis 微内核之上,采用「一切皆插件」的架构。本节阐述这套设计背后的理论基础和设计哲学。 + +## 核心思想 + +Harness 追求三种可组合性的统一: + +| 维度 | 含义 | Cordis 对应机制 | +|------|------|----------------| +| 逻辑可组合性 | 功能能否自由拆分和拼装 | 插件系统、事件系统 | +| 时间可组合性 | 运行时能否安全地加载/卸载功能 | 可逆作用、自动清理 | +| 空间可组合性 | 依赖关系能否被安全地声明和管理 | 服务生命周期、依赖注入 | + +这三种可组合性在上下文模型中统一为单一的编程范式。 + +## 目录 + +- [可组合性与插件系统](./composability) — 组合的本质,以及传统插件系统为什么不可靠 +- [作用与余作用](./effects-coeffects) — Cordis 效果系统的理论模型 +- [可逆作用](./revertible-effects) — 时间可组合性的形式化定义与证明 +- [响应式余作用](./reactive-coeffects) — 空间可组合性的服务语义 +- [上下文模型](./context-model) — Context 如何将作用与余作用统一 + +## 设计如何映射到 Harness + +| 理论概念 | Harness 中的体现 | +|----------|-----------------| +| 可逆作用 | `ctx.tools.register()` 返回 disposer;插件卸载时工具自动注销 | +| 响应式余作用 | `inject: ['llm']` 声明依赖;LLM 适配器不可用时插件自动挂起 | +| 上下文派生 | 子 Agent 拥有独立 Context,继承父级服务但有独立生命周期 | +| Waterfall 事件 | `agent/request` 链式拦截,任一监听器可决定最终请求参数 | +| Capability seam | bash/fs/web 三层拆分:接口 → 实现 → 模型工具 | + +## 进一步阅读 + +- [插件与生命周期](/zh-CN/develop/framework/) — 实践中的 Fiber 状态机 +- [服务与依赖](/zh-CN/develop/framework/service) — 服务声明与注入 +- [能力的三层拆分](/zh-CN/develop/practice/) — Capability seam 模式 diff --git a/website/zh-CN/design/reactive-coeffects.md b/website/zh-CN/design/reactive-coeffects.md new file mode 100644 index 0000000000..45345f934a --- /dev/null +++ b/website/zh-CN/design/reactive-coeffects.md @@ -0,0 +1,90 @@ +# 响应式余作用 + +响应式余作用 (Reactive Coeffects) 是 Cordis 实现**空间可组合性**的核心机制。 + +- 将代码中的资源依赖抽象为服务 (service) 的概念 +- 通过运行时生命周期语义,实现自动、安全、高效的资源管理 + +## 依赖的本质是生命周期 + +传统的依赖注入(如 Angular DI、Spring IoC)解决的是"怎么拿到依赖"的问题,但忽略了一个关键问题:**依赖是有生命周期的**。 + +一个数据库连接池可能重启,一个 API 服务可能下线,一个 LLM adapter 可能被热替换。当依赖消失时,依赖者应当如何表现? + +- 崩溃?——对长时运行程序不可接受。 +- 继续运行?——可能产生不一致状态。 +- **自动挂起,等待恢复?**——Cordis 的选择。 + +## 服务与生命周期 + +Cordis 将程序中的资源依赖抽象为**服务** (service): + +- 任何插件都可以声明自己依赖的服务列表 +- 服务存在明确的生命周期(提供、撤销) +- 运行时对依赖不满足的插件**等待**,而非拒绝 +- 服务生命周期结束前,依赖该服务的插件**先一步被回收** + +```typescript +// LLM 适配器插件:提供 llm 服务 +export class LlmService extends Service { + static inject = ['http'] // 自身依赖 http + // 当 http 不可用时,LlmService 自动挂起 + // 挂起导致 ctx.llm 不可用 + // 所有 inject: ['llm'] 的插件级联挂起 +} +``` + +## 与现有理论的对比 + +### 与 Comonad 余作用比较 + +基于 Comonad 的余作用(Petricek 2013)将上下文建模为静态结构,侧重于编译期分析。Cordis 的响应式余作用额外引入了**时序语义**: + +- 服务可在运行时出现/消失 +- 依赖关系随之动态建立/解除 +- 效果的生命周期由依赖关系决定 + +### 与 Grade Algebra 余作用比较 + +基于 Grade Algebra 的余作用(Gaboardi 2016)用有序半环描述资源的组合规则。Cordis 的服务依赖可以建模为**交换半群**: + +- 服务名构成依赖集合 +- 集合并(∪)对应并行依赖 +- 交换律:依赖 A + B ≡ 依赖 B + A(声明顺序无关) +- 结合律:依赖分组方式不影响语义 + +但 Cordis 还增加了代数不具备的运行时行为:当集合中的某个服务不可用时,整个依赖集不满足,触发挂起。 + +## 在 Cordis 中的实现 + +```typescript +// 声明依赖 +export const inject = ['tools', 'llm'] + +export function apply(ctx: Context) { + // 到这里时,ctx.tools 和 ctx.llm 一定可用 + // 如果任一服务消失,此插件自动卸载 + // 服务恢复后,自动重新执行 apply +} +``` + +服务生命周期变化时的行为: + +``` +llm service 可用 → 依赖 llm 的插件 PENDING → ACTIVE +llm service 消失 → 依赖 llm 的插件 ACTIVE → DISPOSED +llm service 恢复 → 依赖 llm 的插件重新 PENDING → ACTIVE +``` + +## 为什么 Agent 需要响应式余作用 + +在 Harness 场景下,响应式余作用直接支撑: + +| 场景 | 行为 | +|------|------| +| LLM adapter 热替换 | 依赖 `llm` 的插件自动挂起/恢复,中间不丢状态 | +| 按需加载 bash 执行器 | bash tool 只在 `bash` 服务就绪后注册 | +| 子 Agent 独立服务空间 | 通过 `ctx.isolate()` 隔离服务实例,互不干扰 | +| 可选能力降级 | `inject: { web: { required: false } }` 允许 web 不可用时继续运行 | + +这意味着 Harness 插件开发者无需编写防御性的 "if service exists" 检查——框架保证:当你的 `apply` 被调用时,声明的依赖一定已就绪。 diff --git a/website/zh-CN/design/revertible-effects.md b/website/zh-CN/design/revertible-effects.md new file mode 100644 index 0000000000..5133400e75 --- /dev/null +++ b/website/zh-CN/design/revertible-effects.md @@ -0,0 +1,128 @@ +# 可逆作用 + +可逆作用 (Revertible Effects) 是 Cordis 实现**时间可组合性**的核心机制。 + +- 在单子作用的基础上增加可逆性约束 +- 提供面向长时运行程序的作用系统 +- 确保程序可以在插件粒度上回到任意状态 + +## 副作用的封装 + +现实中的程序需要与各种副作用打交道。假设一个不纯函数: + +$$ +f_\text{impure}: \text{X}\to\text{Y} +$$ + +我们将所有可能的副作用用类型 $\mathcal{C}$ 封装,函数变为: + +$$ +f: \mathcal{C}\times\text{X}\to\mathcal{C}\times\text{Y} +$$ + +对于长时运行程序,忽略函数本身的入参和出参,$f$ 属于函数空间 $\mathfrak{F}=\mathcal{C}\to\mathcal{C}$。 + +## 从幺半群到群 + +任何函数 $f: \mathcal{C}\to\mathcal{C}$ 都是状态空间到自身的变换。在组合 $\circ$ 下构成**幺半群**: + +1. 封闭性:$f\circ g$ 也是 $\mathcal{C}\to\mathcal{C}$ +2. 结合律:$(f\circ g)\circ h=f\circ (g\circ h)$ +3. 单位元:$\text{id}$,使得 $f\circ\text{id}=\text{id}\circ f=f$ + +如果额外要求每个 $f$ 存在逆元 $f^{-1}$(即副作用可回收),$\mathfrak{F}$ 升级为**群**。 + +## 副作用都可逆吗? + +观察计算机中的副作用模式: + +| 操作 | 占用资源 | 逆操作 | +|------|----------|--------| +| 打开文件 | 文件描述符 | 关闭文件 | +| 创建子进程 | 进程号 | 杀死进程 | +| 监听端口 | 端口 | 取消监听 | +| 添加回调函数 | 事件槽位 | 删除回调 | +| 分配内存 | 内存区块 | 回收内存 | + +**副作用就是对资源的占用。** 计算机的资源天然设计为可重复使用,因此这些副作用一定是可逆的。 + +## 追踪和回收副作用 + +Cordis 通过 $\text{effect}$ 和 $\text{restore}$ 函子追踪和回收逆函数。 + +### effect 函子 + +$$ +\begin{array}{} +\text{effect}&:& +\left(\mathcal{C}\to\mathcal{C}\right)&\to& +\mathcal{C}\times\left(\mathcal{C}\to\mathcal{C}\right)&\to& +\mathcal{C}\times\left(\mathcal{C}\to\mathcal{C}\right)\\ +\text{effect}&=&f&\mapsto&\left(c, h\right)&\mapsto&\left(f(c), h\circ f^{-1}\right) +\end{array} +$$ + +直觉:执行 $f$ 产生的副作用记入状态 $c$,同时将逆操作 $f^{-1}$ 追加到回收链 $h$ 中。 + +### 同态性证明 + +$\text{effect}$ 是从 $\mathcal{C}\to\mathcal{C}$ 到 $\mathcal{C}\times(\mathcal{C}\to\mathcal{C})\to\mathcal{C}\times(\mathcal{C}\to\mathcal{C})$ 的同态: + +$$ +\begin{aligned} +\text{effect}\ (f\circ g) \left(c, h\right) +&=\left((f\circ g)(c), h\circ (f\circ g)^{-1}\right)\\ +&=\left(f(g(c)), h\circ g^{-1}\circ f^{-1}\right)\\ +&=\left(\text{effect}\ f\right)\left(g(c), h\circ g^{-1}\right)\\ +&=\left(\text{effect}\ f\right)\circ\left(\text{effect}\ g\right) \left(c, h\right) +\end{aligned} +$$ + +这意味着:组合两个操作后再追踪 = 分别追踪后再组合。副作用追踪与执行顺序无关。 + +### restore 函子 + +$$ +\begin{array}{} +\text{restore}&:& +\mathcal{C}\times\left(\mathcal{C}\to\mathcal{C}\right)&\to& +\mathcal{C}\times\left(\mathcal{C}\to\mathcal{C}\right)\\ +\text{restore}&=&\left(c, h\right)&\mapsto&\left(h(c),\text{id}\right) +\end{array} +$$ + +直觉:将回收链 $h$ 应用到当前状态,一次性回收所有已追踪的副作用。 + +## 在 Cordis 中的实现 + +理论映射到 API: + +| 数学概念 | Cordis API | 说明 | +|----------|-----------|------| +| $\text{effect}(f)$ | `ctx.effect(() => { ...; return dispose })` | 注册副作用并返回清理函数 | +| $\text{restore}$ | `fiber.dispose()` | 执行 Fiber 的整个回收链 | +| $f^{-1}$ | dispose 返回值 / cleanup 函数 | 逆操作 | + +```typescript +export function apply(ctx: Context) { + // effect: 创建资源,返回其逆操作 + ctx.effect(() => { + const server = startServer(8080) // f: 占用端口 + return () => server.close() // f⁻¹: 释放端口 + }) + + // 框架 API 内部已封装 effect + ctx.on('event', handler) // 内部: effect(addListener, removeListener) + ctx.tools.register(myTool) // 内部: effect(addTool, removeTool) +} +// 当此插件被卸载时,restore 自动按逆序执行所有 f⁻¹ +``` + +## 为什么 Agent 需要可逆作用 + +在 Harness 场景下,可逆作用直接支撑: + +- **热替换 LLM 适配器**:卸载旧适配器(回收注册)、加载新适配器,无需重启 +- **动态 tool 管理**:根据对话上下文动态添加/移除 tool,不泄漏 +- **子 Agent 生命周期**:子 Agent 完成后,其注册的所有临时 tool 和监听器自动清理 +- **优雅关闭**:进程退出时所有插件按依赖逆序 dispose,确保资源完全释放 diff --git a/website/zh-CN/develop/basic/config.md b/website/zh-CN/develop/basic/config.md new file mode 100644 index 0000000000..49bcc4ca77 --- /dev/null +++ b/website/zh-CN/develop/basic/config.md @@ -0,0 +1,108 @@ +# 插件配置 + +让你的插件接受用户在 `cordis.yml` 中传入的配置。 + +## 定义 Config 类型 + +在插件中导出一个 `Config` 类型和可选的默认值: + +```typescript +import type { Context } from 'cordis' + +export const name = 'my-plugin' + +export interface Config { + greeting: string + maxRetries: number + verbose?: boolean +} + +export const Config = { + greeting: 'Hello', + maxRetries: 3, + verbose: false, +} + +export function apply(ctx: Context, config: Config) { + console.log(config.greeting) // 用户配置或默认值 +} +``` + +用户在 `cordis.yml` 中这样使用: + +```yaml +- name: './src/my-plugin.ts' + config: + greeting: 'Hi there' + maxRetries: 5 +``` + +未提供的字段使用导出的 `Config` 对象中的默认值。 + +## Schema 校验 + +对于需要严格校验的场景,使用 Schemastery 定义 schema: + +```typescript +import type { Context } from 'cordis' +import Schema from 'schemastery' + +export const name = 'validated-plugin' + +export interface Config { + apiKey: string + timeout: number + mode: 'fast' | 'accurate' +} + +export const Config = Schema.object({ + apiKey: Schema.string().required(), + timeout: Schema.number().default(30000), + mode: Schema.union(['fast', 'accurate']).default('fast'), +}) + +export function apply(ctx: Context, config: Config) { + // config 已经过校验,类型安全 +} +``` + +Schema 在插件加载时执行校验。如果配置不合法,插件会加载失败并给出明确错误信息。 + +## 设计原则 + +### 无硬编码可调参数 + +Harness 的约定:**任何两个部署可能想要不同值的东西,都应该是配置字段**。 + +```typescript +// 错误 — 硬编码超时时间 +const TIMEOUT = 30000 + +// 正确 — 可配置 +export interface Config { + timeoutMs: number // 默认 30000 +} +``` + +检验标准:能否在 `cordis.yml` 中改变这个值,而不需要修改代码? + +### 配置错误要响亮 + +如果配置引用了不存在的东西(比如一个不存在的模型名),应该尽早报错,而不是静默跳过: + +```typescript +export function apply(ctx: Context, config: Config) { + if (!ctx.llm.hasAdapter(config.model)) { + throw new Error(`Model "${config.model}" is not registered by any LLM adapter`) + } +} +``` + +## 配合 HMR + +配置变更会触发插件热替换:修改 `cordis.yml` 中某个插件的 `config`,框架会卸载旧实例、加载新实例。由于注册都是效果(自动清理),这个过程是安全的。 + +## 下一步 + +- [插件与生命周期](../framework/) — 深入了解插件的完整生命周期 +- [服务与依赖](../framework/service) — 让你的插件对外提供服务 diff --git a/website/zh-CN/develop/basic/index.md b/website/zh-CN/develop/basic/index.md new file mode 100644 index 0000000000..71d6962edd --- /dev/null +++ b/website/zh-CN/develop/basic/index.md @@ -0,0 +1,148 @@ +# 第一个插件 + +本文带你编写一个最小的 Harness 插件并加载到 Agent 中。 + +## 插件是什么 + +在 Harness 中,插件是一个导出 `apply` 函数的 TypeScript 模块。框架在加载时调用 `apply`,传入一个 `ctx`(上下文对象),你通过 `ctx` 注册能力: + +```typescript +import type { Context } from 'cordis' + +export const name = 'my-plugin' + +export function apply(ctx: Context) { + // 在这里注册能力 +} +``` + +就这么简单。 + +## 创建插件文件 + +在你的项目目录下创建 `src/my-plugin.ts`: + +```typescript +import type { Context } from 'cordis' + +export const name = 'hello-plugin' + +export function apply(ctx: Context) { + // 监听 agent-loop 的 ready 事件 + ctx.on('ready', () => { + console.log('[hello-plugin] 插件已加载!') + }) +} +``` + +## 注册到 cordis.yml + +在你的 `cordis.yml` 中添加一条: + +```yaml +- id: hello + name: './src/my-plugin.ts' +``` + +启动后你会在控制台看到 `[hello-plugin] 插件已加载!`。 + +## 自动清理 + +通过 `ctx` 注册的任何东西——事件监听、tool、定时器——在插件卸载时都会被自动清理。你不需要手动 removeListener 或 clearInterval。 + +如果你有需要手动清理的资源(比如一个网络连接),用 `ctx.effect()` 告诉框架怎么清理: + +```typescript +export function apply(ctx: Context) { + ctx.effect(() => { + const timer = setInterval(() => { + console.log('heartbeat') + }, 5000) + + // 返回的函数会在插件卸载时被调用 + return () => clearInterval(timer) + }) +} +``` + +## 声明依赖 + +如果你的插件需要使用其他服务(如 `tools`、`llm`),需要声明 `inject`: + +```typescript +export const name = 'my-tool-plugin' +export const inject = ['tools'] + +export function apply(ctx: Context) { + // ctx.tools 现在可用 + ctx.tools.register(/* ... */) +} +``` + +框架会确保依赖的服务就绪后才加载你的插件。 + +## 插件的三种形态 + +除了函数形式,插件还支持对象形式和类形式: + +### 对象形式 + +```typescript +export default { + name: 'my-plugin', + inject: ['tools'], + apply(ctx: Context) { + // ... + }, +} +``` + +### 类形式 + +```typescript +import { Service } from 'cordis' + +export default class MyService extends Service { + static inject = ['tools'] + + constructor(ctx: Context) { + super(ctx, 'myService') + } + + start() { + // 服务启动逻辑 + } +} +``` + +大多数情况下,函数形式足够了。类形式用于需要对外提供服务的插件(见 [服务与依赖](../framework/service))。 + +## 完整示例 + +参考仓库中的 `examples/echo-agent/src/echo-tool.ts`,这是一个注册 tool 的插件: + +```typescript +import type { Context } from 'cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' + +export const name = 'echo-tool' +export const inject = ['tools'] + +export function apply(ctx: Context) { + ctx.tools.register(defineTool({ + name: 'echo', + description: 'Echo the given text back, uppercased.', + parameters: { + text: { type: 'string', required: true }, + }, + async execute(args) { + return [{ type: 'text', text: `ECHO: ${args.text.toUpperCase()}` }] + }, + })) +} +``` + +## 下一步 + +- [开发一个 Tool](./tool) — 详细了解 tool 定义 DSL +- [插件配置](./config) — 让插件接受用户配置 diff --git a/website/zh-CN/develop/basic/tool.md b/website/zh-CN/develop/basic/tool.md new file mode 100644 index 0000000000..96d58da78d --- /dev/null +++ b/website/zh-CN/develop/basic/tool.md @@ -0,0 +1,199 @@ +# 开发一个 Tool + +Tool 是模型可以调用的能力。本文介绍如何用 `defineTool` 编写一个 tool。 + +## 最小示例 + +```typescript +import type { Context } from 'cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' + +export const name = 'my-tool' +export const inject = ['tools'] + +export function apply(ctx: Context) { + ctx.tools.register(defineTool({ + name: 'greet', + description: 'Greet someone by name.', + parameters: { + name: { type: 'string', required: true, description: 'The name to greet' }, + }, + async execute(args) { + // args 自动推导为 { name: string } + return [{ type: 'text', text: `Hello, ${args.name}!` }] + }, + })) +} +``` + +## 参数定义 + +`parameters` 用一种简洁的格式描述参数,框架会自动转换为模型需要的 JSON Schema。 + +### 基本类型 + +```typescript +parameters: { + path: { type: 'string', required: true }, + limit: { type: 'number' }, + recursive: { type: 'boolean' }, +} +// 推导类型: { path: string; limit?: number; recursive?: boolean } +``` + +### 枚举 + +```typescript +parameters: { + mode: { type: 'string', required: true, enum: ['read', 'write', 'append'] }, +} +// 推导类型: { mode: string } (运行时校验 enum 值) +``` + +### 嵌套对象 + +```typescript +parameters: { + options: { + type: 'object', + properties: { + timeout: { type: 'number' }, + retries: { type: 'number' }, + }, + }, +} +// 推导类型: { options?: { timeout?: number; retries?: number } } +``` + +### 数组 + +```typescript +parameters: { + tags: { + type: 'array', + items: { type: 'string' }, + }, +} +// 推导类型: { tags?: string[] } +``` + +### 每个属性的字段 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `type` | `'string' \| 'number' \| 'boolean' \| 'object' \| 'array'` | 值类型 | +| `required` | `true` | 标记为必填(影响类型推导) | +| `description` | `string` | 发送给模型的描述 | +| `enum` | `string[]` | 允许的枚举值 | +| `properties` | `SchemaSpec` | 嵌套属性(type 为 object 时) | +| `items` | `SchemaProp` | 数组元素 schema(type 为 array 时) | + +## execute 函数 + +`execute` 接收经过校验的 `args`(类型自动推导)和一个 `exec` 上下文对象: + +```typescript +async execute(args, exec) { + // args: 根据 parameters 自动推导的类型 + // exec: ToolExecution 对象,提供执行上下文 + + // 返回 ContentBlock 数组 + return [{ type: 'text', text: 'result here' }] +} +``` + +### 返回值 + +`execute` 必须返回一个 `ContentBlock[]`,告诉模型 tool 的执行结果: + +```typescript +// 文本结果 +return [{ type: 'text', text: 'file content here...' }] + +// 多个 block +return [ + { type: 'text', text: 'Found 3 matches:' }, + { type: 'text', text: matchResults.join('\n') }, +] +``` + +### 参数校验 + +`defineTool` 在调用 `execute` 之前会自动校验模型生成的参数。如果参数不合法,会抛出 `ToolArgsError`,框架将其转换为 `isError` 结果返回给模型,让模型自行修正。 + +你不需要在 `execute` 里手动校验参数类型。 + +## 展示层 (Presentation) + +Tool 可以定义 UI 渲染方法,用于在终端或 ACP 客户端中展示 tool call 和 result: + +```typescript +defineTool({ + name: 'bash', + // ... + presentCall(args) { + return { + intent: 'terminal', + title: `bash(${JSON.stringify(args.command).slice(0, 60)})`, + } + }, + presentResult(args, result) { + return { + intent: 'terminal', + body: result.content.map(b => b.type === 'text' ? b.text : '').join(''), + } + }, +}) +``` + +`presentCall` 和 `presentResult` 是**纯函数**,不能有副作用——UI 可能在流式传输中和会话回放中多次调用它们。 + +## 注册与卸载 + +`ctx.tools.register()` 返回值就是 disposer。但由于你在 `ctx` 上调用,框架已经自动追踪了这个注册——插件卸载时会自动移除 tool。你不需要手动调用 disposer。 + +```typescript +// 这样就够了: +ctx.tools.register(defineTool({ /* ... */ })) + +// 不需要: +// const dispose = ctx.tools.register(...) +// ctx.on('dispose', dispose) +``` + +## 完整实战示例 + +一个文件计数 tool: + +```typescript +import type { Context } from 'cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' +import { readdir } from 'node:fs/promises' + +export const name = 'file-counter' +export const inject = ['tools'] + +export function apply(ctx: Context) { + ctx.tools.register(defineTool({ + name: 'count_files', + description: 'Count files in a directory.', + parameters: { + path: { type: 'string', required: true, description: 'Directory path' }, + extension: { type: 'string', description: 'Filter by extension (e.g. ".ts")' }, + }, + async execute(args) { + const entries = await readdir(args.path, { withFileTypes: true }) + let files = entries.filter(e => e.isFile()) + if (args.extension) { + files = files.filter(f => f.name.endsWith(args.extension!)) + } + return [{ type: 'text', text: `Found ${files.length} files.` }] + }, + })) +} +``` + +## 下一步 + +- [插件配置](./config) — 让你的 tool 可配置 +- [能力三件套](../practice/) — 了解 seam/impl/consumer 模式 diff --git a/website/zh-CN/develop/framework/events.md b/website/zh-CN/develop/framework/events.md new file mode 100644 index 0000000000..0546fd68e7 --- /dev/null +++ b/website/zh-CN/develop/framework/events.md @@ -0,0 +1,152 @@ +# 事件系统 + +事件是 Cordis 插件间通信的核心机制。Harness 大量使用事件来实现松耦合的扩展点。 + +## 基本用法 + +### 监听事件 + +```typescript +ctx.on('event-name', (payload) => { + // 处理事件 +}) +``` + +### 触发事件 + +```typescript +ctx.emit('event-name', payload) +``` + +## 事件模式 + +Cordis 提供多种事件触发模式,适用于不同场景: + +### emit — 广播 + +所有监听器并行执行,不关心返回值: + +```typescript +// 触发 +ctx.emit('agent/turn-end', { agentId, turnIndex }) + +// 监听 +ctx.on('agent/turn-end', ({ agentId, turnIndex }) => { + console.log(`Turn ${turnIndex} ended`) +}) +``` + +### bail — 短路 + +依次调用监听器,第一个返回非 `undefined` 值的结果作为最终值: + +```typescript +// 触发 +const result = ctx.bail('some-check', input) + +// 监听(返回值阻止后续监听器) +ctx.on('some-check', (input) => { + if (shouldBlock(input)) return 'blocked' + // 返回 undefined 继续传递给下一个监听器 +}) +``` + +### serial — 顺序执行 + +所有监听器按注册顺序依次执行(异步安全): + +```typescript +await ctx.serial('setup-phase', context) +``` + +### waterfall — 管道 + +每个监听器接收前一个的输出,形成数据管道。**必须调用 `next()` 传递给下游**,不调用即为否决: + +```typescript +// 触发 +const finalMessages = await ctx.waterfall('llm/pre-request', messages) + +// 监听(必须调用 next) +ctx.on('llm/pre-request', async (messages, next) => { + // 可以修改 messages + messages.push(extraMessage) + // 必须调用 next() 传递给下一个监听器 + return next(messages) +}) +``` + +::: warning +Waterfall 监听器**必须调用 `next()`**。不调用 `next` 等于否决整个管道,这是故意为之的设计——用于实现拦截/网关逻辑。 +::: + +## Typed Events + +Harness 使用 TypeScript 声明合并来为事件提供类型安全: + +```typescript +declare module 'cordis' { + interface Events { + 'my-plugin/ready': (payload: { id: string }) => void + 'my-plugin/check': (input: string) => boolean | undefined + } +} + +// 现在 ctx.on('my-plugin/ready', ...) 和 ctx.emit('my-plugin/ready', ...) +// 都有正确的类型推导 +``` + +## 命名约定 + +Harness 事件遵循 `namespace/action` 命名: + +``` +agent/pre-step — agent 执行一步之前 +agent/post-step — agent 执行一步之后 +tool/call — tool 被调用 +tool/result — tool 返回结果 +llm/pre-request — LLM 请求发送前 +session/event — 会话事件被记录 +compact/start — 压缩开始 +compact/end — 压缩结束 +``` + +## 事件也是效果 + +通过 `ctx.on()` 注册的监听器会在插件卸载时自动移除: + +```typescript +export function apply(ctx: Context) { + // 这个监听器在插件 dispose 时自动清理 + ctx.on('agent/turn-end', handler) +} +``` + +## 实战示例:日志插件 + +一个记录所有 tool 调用的简单插件: + +```typescript +import type { Context } from 'cordis' + +export const name = 'tool-logger' + +export function apply(ctx: Context) { + ctx.on('tool/call', ({ name, args }) => { + console.log(`[tool] ${name}(${JSON.stringify(args)})`) + }) + + ctx.on('tool/result', ({ name, result }) => { + const text = result.content + .filter(b => b.type === 'text') + .map(b => b.text) + .join('') + console.log(`[tool result] ${text.slice(0, 100)}`) + }) +} +``` + +## 下一步 + +- [能力三件套](../practice/) — 事件在 capability seam 中的角色 +- [LLM 适配器](../practice/llm-adapter) — 实现一个完整的 LLM 后端 diff --git a/website/zh-CN/develop/framework/index.md b/website/zh-CN/develop/framework/index.md new file mode 100644 index 0000000000..8d2f7c2b8a --- /dev/null +++ b/website/zh-CN/develop/framework/index.md @@ -0,0 +1,139 @@ +# 插件与生命周期 + +深入了解 Cordis 插件模型和生命周期状态机。 + +## Fiber 状态机 + +每个被加载的插件对应一个 **Fiber**(作用域)。Fiber 有以下状态: + +``` +PENDING → LOADING → ACTIVE + ↘ FAILED +ACTIVE → UNLOADING → DISPOSED +``` + +| 状态 | 含义 | +|------|------| +| PENDING | 已声明但依赖未就绪 | +| LOADING | 依赖就绪,正在执行 `apply` | +| ACTIVE | 插件运行中 | +| FAILED | `apply` 抛出异常 | +| UNLOADING | 正在卸载,清理中 | +| DISPOSED | 已完全卸载 | + +## 依赖驱动的加载 + +声明了 `inject` 的插件不会立即加载,而是等待依赖的服务就绪: + +```typescript +export const inject = ['tools', 'llm'] + +export function apply(ctx: Context) { + // 到这里时,ctx.tools 和 ctx.llm 一定存在 +} +``` + +如果依赖的服务消失(比如提供者被热替换),插件会被自动卸载(ACTIVE → DISPOSED),待服务恢复后重新加载。 + +## 自动清理机制 + +通过 `ctx` 做的任何注册,在插件卸载时都会自动撤销: + +```typescript +export function apply(ctx: Context) { + // 事件监听——卸载时自动移除 + ctx.on('some-event', handler) + + // 自定义资源——卸载时调用返回的函数 + ctx.effect(() => { + const connection = createConnection() + return () => connection.close() + }) +} +``` + +以下操作都会被自动追踪和清理: +- `ctx.on(event, handler)` — 事件监听 +- `ctx.tools.register(tool)` — tool 注册 +- `ctx.llm.registerAdapter(names, adapter)` — LLM 适配器注册 +- `ctx.effect(() => cleanup)` — 自定义资源 + +插件卸载时,这些注册按倒序逐个撤销。 + +## 嵌套上下文 + +`ctx.plugin()` 创建子 Fiber,它继承父上下文但有独立的生命周期: + +```typescript +export function apply(ctx: Context) { + // 注册一个子插件 + ctx.plugin(childPlugin) + + // 子插件有自己的 Fiber,父卸载时子也卸载 +} +``` + +## dispose 语义 + +当你需要提前终止一个插件实例: + +```typescript +const fiber = ctx.plugin(myPlugin) + +// 之后可以手动 dispose +fiber.dispose() +``` + +`dispose` 保证: +1. 该插件注册的所有东西被撤销 +2. 它的子插件也被递归卸载 +3. 所有异步清理完成后 Promise resolve + +## 热替换 (HMR) + +在开发环境中(`cordis.yml` 加载了 `@cordisjs/plugin-hmr`),修改插件源文件会自动触发: + +1. 卸载旧插件(清理所有注册) +2. 重新加载新代码 +3. 执行新的 `apply` + +因为所有注册都会被自动清理,所以热替换天然安全——不会留下旧状态。 + +## 实战:理解生命周期 + +```typescript +export function apply(ctx: Context) { + console.log('plugin loading') + + ctx.on('ready', () => { + console.log('context ready') + }) + + ctx.on('dispose', () => { + console.log('plugin disposing') + }) + + ctx.effect(() => { + console.log('effect registered') + return () => console.log('effect cleaned up') + }) +} +``` + +加载时输出: +``` +plugin loading +effect registered +context ready +``` + +卸载时输出(逆序): +``` +plugin disposing +effect cleaned up +``` + +## 下一步 + +- [服务与依赖](./service) — 让你的插件对外提供能力 +- [事件系统](./events) — 插件间通信的核心机制 diff --git a/website/zh-CN/develop/framework/service.md b/website/zh-CN/develop/framework/service.md new file mode 100644 index 0000000000..08d9a1b2c8 --- /dev/null +++ b/website/zh-CN/develop/framework/service.md @@ -0,0 +1,147 @@ +# 服务与依赖 + +服务 (Service) 是插件对外暴露能力的方式。依赖 (inject) 是插件声明自己需要哪些服务。 + +## 什么是服务 + +在 Harness 中,`tools`、`llm`、`agents` 都是服务。服务是挂载在 `ctx` 上的命名能力: + +```typescript +ctx.tools // ToolRegistry 服务 +ctx.llm // LLM 服务 +ctx.agents // Agent 服务 +``` + +任何插件都可以提供一个新服务,供其他插件使用。 + +## 使用服务 + +声明 `inject` 来使用已有服务: + +```typescript +export const inject = ['tools'] + +export function apply(ctx: Context) { + // ctx.tools 在这里一定存在且就绪 + ctx.tools.register(/* ... */) +} +``` + +框架保证:在 `apply` 执行时,`inject` 声明的服务已经全部就绪。如果服务还没准备好,你的插件会等着,不会执行。 + +## 提供服务 + +### 使用 Service 基类 + +```typescript +import { Service, type Context } from 'cordis' + +export default class MetricsService extends Service { + static inject = ['llm'] // 本服务也可以依赖其他服务 + + constructor(ctx: Context) { + super(ctx, 'metrics') // 'metrics' 是服务名 + } + + // 服务的公开方法 + record(event: string, value: number) { + // ... + } +} +``` + +加载这个插件后,其他插件就可以通过 `ctx.metrics` 访问它: + +```typescript +export const inject = ['metrics'] + +export function apply(ctx: Context) { + ctx.metrics.record('tool_call', 1) +} +``` + +### 类型声明 + +使用 TypeScript 声明合并让 `ctx.metrics` 有正确类型: + +```typescript +import { Service, type Context } from 'cordis' + +declare module 'cordis' { + interface Context { + metrics: MetricsService + } +} + +export default class MetricsService extends Service { + constructor(ctx: Context) { + super(ctx, 'metrics') + } + + record(event: string, value: number) { /* ... */ } +} +``` + +## 依赖的行为 + +### 必选依赖 vs 可选依赖 + +```typescript +// 必选:服务不存在时,插件不会加载 +export const inject = ['tools'] + +// 可选:服务不存在时,插件仍然加载,但 ctx.xxx 可能是 undefined +export const inject = { optional: ['metrics'] } +``` + +### 服务消失时的行为 + +如果一个必选依赖的服务在运行时消失(比如提供者被卸载): + +1. 依赖它的插件自动 dispose +2. 当服务重新出现时,插件自动重新加载 + +这保证了不会出现"调用一个已不存在的服务"的情况。 + +## 服务隔离 + +`cordis.yml` 支持服务隔离——同一个服务可以有多个实例,不同插件组看到不同实例: + +```yaml +- id: group-a + name: 'group:' + config: + - name: '@deepseek-ai/dsh-bash-local' + config: + timeoutMs: 5000 + - name: './src/plugin-a.ts' + +- id: group-b + name: 'group:' + config: + - name: '@deepseek-ai/dsh-bash-local' + config: + timeoutMs: 60000 + - name: './src/plugin-b.ts' +``` + +`plugin-a` 和 `plugin-b` 各自看到自己组内的 bash 实例,互不影响。 + +## Harness 内置服务一览 + +| 服务名 | 提供者 | 用途 | +|--------|--------|------| +| `tools` | dsh-tools | Tool 注册表 | +| `llm` | dsh-llm | LLM 调用 + 适配器注册 | +| `agents` | dsh-agent | Agent 实例管理 | +| `session` | dsh-session | 会话事件流 | +| `systemPrompt` | dsh-system-prompt | 系统提示词组装 | +| `bash` | dsh-bash-local | Bash 命令执行 | +| `fs` | dsh-fs-local | 文件系统操作 | +| `subagent` | dsh-subagent | 子代理委派 | +| `persistence` | dsh-session-persistence | 会话持久化 | + +## 下一步 + +- [事件系统](./events) — 插件间松耦合通信 +- [能力三件套](../practice/) — 服务在 seam 模式中的应用 diff --git a/website/zh-CN/develop/practice/index.md b/website/zh-CN/develop/practice/index.md new file mode 100644 index 0000000000..dd0ec1cb60 --- /dev/null +++ b/website/zh-CN/develop/practice/index.md @@ -0,0 +1,156 @@ +# 能力的三层拆分 + +当一个能力(插件)足够通用(比如"执行 bash 命令"),Harness 会把它拆成三个包:**接口**、**实现**、**消费者**。这样可以独立替换其中任何一层。 + +## 以 Bash 为例 + +考虑 "Bash 执行" 这个能力: + +- **接口** (`dsh-bash`) — 定义"bash 执行"长什么样:输入是什么、输出是什么 +- **实现** (`dsh-bash-local`) — 真正在本地跑命令的代码 +- **消费者** (`dsh-tool-bash`) — 把这个能力包装成模型能调用的 tool + +``` +┌─────────────┐ ┌──────────────────┐ ┌──────────────┐ +│ dsh-bash │────▶│ dsh-bash-local │ │ dsh-tool-bash│ +│ (接口) │ │ (实现) │ │ (消费者/tool)│ +└─────────────┘ └──────────────────┘ └──────────────┘ + ▲ │ + └────────────────────────────────────────────┘ + inject: ['bash'] +``` + +## 拆分的好处 + +### 具体实现可替换 + +同一个接口可以有多种实现。用户通过 `cordis.yml` 选择: + +```yaml +# 本地执行 +- name: '@deepseek-ai/dsh-bash-local' + +# 或:远程沙箱执行(未来) +# - name: '@deepseek-ai/dsh-bash-remote' +# config: +# endpoint: 'https://sandbox.example.com' +``` + +接口不变、tool 不变,只换实现。 + +### 独立演进 + +- 接口定义稳定后很少改动 +- 实现可以独立优化(性能、安全) +- 消费者(tool)可以调整对模型的呈现方式 + +### 依赖解耦 + +- 实现 depend on 接口 +- 消费者 depend on 接口 +- 实现和消费者**互不依赖** + +## Harness 中内置的三件套 + +| 能力 | 接口 (seam) | 实现 | 消费者 (tool) | +|------|-------------|------|---------------| +| Bash | `dsh-bash` | `dsh-bash-local` | `dsh-tool-bash` | +| 文件系统 | `dsh-fs` | `dsh-fs-local` + `dsh-fs-policy` | `dsh-tool-fs` | +| Web | `dsh-web` | `dsh-web-fetch-local` / `dsh-web-search-*` | `dsh-tool-web` | +| 子代理 | `dsh-subagent` | `dsh-subagent-spawn` / `dsh-subagent-fork` | `dsh-tool-subagent` | +| 压缩 | `dsh-compact` | `dsh-compact-basic` | (内置于 agent-loop) | + +## 开发你自己的三件套 + +### 第一步:定义接口 + +```typescript +// packages/my-cap/my-cap/src/index.ts +import { Service, type Context } from 'cordis' + +declare module 'cordis' { + interface Context { + myCap: MyCapService + } +} + +export abstract class MyCapService extends Service { + constructor(ctx: Context) { + super(ctx, 'myCap') + } + + /** 执行能力的核心方法 */ + abstract execute(request: MyCapRequest): Promise +} + +export interface MyCapRequest { + input: string +} + +export interface MyCapResult { + output: string +} +``` + +### 第二步:编写实现 + +```typescript +// packages/my-cap/my-cap-local/src/index.ts +import type { Context } from 'cordis' +import { MyCapService, type MyCapRequest, type MyCapResult } from '@deepseek-ai/dsh-my-cap' + +class MyCapLocal extends MyCapService { + async execute(request: MyCapRequest): Promise { + // 具体实现 + return { output: request.input.toUpperCase() } + } +} + +export const name = 'my-cap-local' + +export function apply(ctx: Context) { + ctx.plugin(MyCapLocal) +} +``` + +### 第三步:编写消费者 (tool) + +```typescript +// packages/my-cap/tool-my-cap/src/index.ts +import type { Context } from 'cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' + +export const name = 'tool-my-cap' +export const inject = ['tools', 'myCap'] + +export function apply(ctx: Context) { + ctx.tools.register(defineTool({ + name: 'my_cap', + description: 'Execute my capability.', + parameters: { + input: { type: 'string', required: true }, + }, + async execute(args) { + const result = await ctx.myCap.execute({ input: args.input }) + return [{ type: 'text', text: result.output }] + }, + })) +} +``` + +### 在 cordis.yml 中组合 + +```yaml +- name: '@deepseek-ai/dsh-my-cap-local' +- name: '@deepseek-ai/dsh-tool-my-cap' +``` + +## 设计要点 + +- **不要预防性拆分** — 只有当你确实需要可替换实现时才拆三件套。一个简单的 tool 插件不需要拆分。 +- **接口定义 Request/Result 类型** — 实现和消费者只依赖接口包。 +- **Explicit > Implicit** — 实现中的默认值处理应该是显式的 `resolve(request): Spec` 步骤,不是隐藏在 `run()` 中的 `?? default`。 + +## 下一步 + +- [LLM 适配器](./llm-adapter) — 实现一个 LLM 后端(最常见的 seam 扩展) diff --git a/website/zh-CN/develop/practice/llm-adapter.md b/website/zh-CN/develop/practice/llm-adapter.md new file mode 100644 index 0000000000..20b1fa2c88 --- /dev/null +++ b/website/zh-CN/develop/practice/llm-adapter.md @@ -0,0 +1,169 @@ +# LLM 适配器 + +本文介绍如何为 Harness 接入一个新的 LLM 提供方。 + +## 概述 + +LLM 适配器是一个继承 `LlmAdapter` 的类,实现 `stream()` 方法,将 Harness 的统一请求格式转换为具体 API 的调用。 + +## 最小实现 + +```typescript +import type { Context } from 'cordis' +import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm' + +class MyAdapter extends LlmAdapter { + private apiKey: string + + constructor(apiKey: string) { + super() + this.apiKey = apiKey + } + + async *stream(options: GenerateOptions): AsyncIterable { + // 1. 将 options.messages 转换为你的 API 格式 + // 2. 调用 API(流式) + // 3. 将 API 响应转换为 StreamChunk 序列 + } +} + +export interface Config { + apiKey: string + models: string[] +} + +export const name = 'my-llm-adapter' +export const inject = ['llm'] + +export function apply(ctx: Context, config: Config) { + const adapter = new MyAdapter(config.apiKey) + ctx.llm.registerAdapter(config.models, adapter) +} +``` + +## StreamChunk 协议 + +`stream()` 必须按以下协议 yield chunk: + +```typescript +// 1. 每个内容块以 block-start 开始 +yield { type: 'block-start', index: 0, blockType: 'text' } + +// 2. 文本块使用 text-delta +yield { type: 'text-delta', index: 0, text: 'Hello' } +yield { type: 'text-delta', index: 0, text: ' world' } + +// 3. 每个内容块以 block-end 结束(携带完整 block) +yield { + type: 'block-end', + index: 0, + block: { type: 'text', text: 'Hello world' }, +} + +// 4. Tool call 块 +yield { type: 'block-start', index: 1, blockType: 'tool-call' } +yield { + type: 'tool-call-delta', + index: 1, + id: CallId('call-123'), + name: 'bash', + argumentsDelta: '{"command":"ls"}', +} +yield { + type: 'block-end', + index: 1, + block: { + type: 'tool-call', + id: CallId('call-123'), + name: 'bash', + arguments: '{"command":"ls"}', + }, +} + +// 5. Token 用量 +yield { type: 'usage', usage: { inputTokens: 100, outputTokens: 50 } } + +// 6. 结束原因 +yield { type: 'finish', reason: { kind: 'stop' } } +// 或: { kind: 'tool-calls' } 表示模型想调用 tool +``` + +### 关键规则 + +- 每个 `block-start` 必须有对应的 `block-end` +- `index` 从 0 递增,标识内容块顺序 +- `tool-call-delta` 的 `argumentsDelta` 是 JSON 字符串的增量(可以一次 yield 全部,也可以分多次) +- `finish` 必须是最后一个 chunk +- `usage` 在 `finish` 之前 yield + +## GenerateOptions + +`stream()` 接收的请求包含: + +```typescript +interface GenerateOptions { + /** 模型名 */ + model: string + /** 对话历史 */ + messages: Message[] + /** 可用的 tool 列表 */ + tools?: ToolSpec[] + /** 系统提示词 */ + system?: string + /** 最大输出 token */ + maxTokens?: number + /** 温度 */ + temperature?: number +} +``` + +你的适配器需要将这些映射到具体 API 的参数。 + +## 注册适配器 + +```typescript +ctx.llm.registerAdapter(['model-name-1', 'model-name-2'], adapter) +``` + +第一个参数是该适配器支持的模型名列表。当用户在 `cordis.yml` 中配置 `model: model-name-1` 时,框架会路由到这个适配器。 + +## 在 cordis.yml 中使用 + +```yaml +- id: my-llm + name: './src/my-llm-adapter.ts' + config: + apiKey: !!js process.env.MY_API_KEY + models: + - my-model-v1 + - my-model-v2 + +- id: stdio-agent + name: '@deepseek-ai/dsh-stdio-agent' + config: + model: my-model-v1 # 引用上面注册的模型名 +``` + +## 实战参考 + +仓库中有两个完整实现可供参考: + +- `packages/llm/llm-deepseek/` — DeepSeek API 适配器(OpenAI 兼容格式) +- `packages/llm/llm-pi-ai/` — Pi AI 适配器(不同的 API 格式) +- `examples/echo-agent/src/mock-llm.ts` — 最简 mock 适配器(教学用) + +mock 适配器是学习 StreamChunk 协议的最佳起点——它用纯本地逻辑演示了完整的 chunk 序列。 + +## 错误处理 + +适配器中的异常会被 agent-loop 捕获并转化为 `LlmError`,告知上层。不需要在 `stream()` 内部做错误恢复——让异常冒泡即可。 + +```typescript +async *stream(options: GenerateOptions): AsyncIterable { + const response = await fetch(this.endpoint, { /* ... */ }) + if (!response.ok) { + throw new Error(`API error: ${response.status}`) + } + // ... 正常流式处理 +} +``` diff --git a/website/zh-CN/guide/config.md b/website/zh-CN/guide/config.md new file mode 100644 index 0000000000..d555a0a478 --- /dev/null +++ b/website/zh-CN/guide/config.md @@ -0,0 +1,342 @@ +# 配置文件 + +Harness 使用 `cordis.yml` 描述一个 Agent 加载哪些插件、以什么参数运行。 + +## 从例子开始 + +### echo-agent 的配置 + +这是一开始的第一个 Agent 的完整配置: + +```yaml +# 热替换:修改代码后自动重载,不用手动重启 +- id: hmr + name: '@cordisjs/plugin-hmr' + config: + root: ['.'] + +# Mock 模型:从本地 `.ts` 文件加载,注册一个名为 `mock-llm` 的工具 +# 本地模拟 LLM 响应,不联网 +- id: mock-llm + name: './src/mock-llm.ts' + +# Echo 工具:收到文本后转大写返回 +- id: echo-tool + name: './src/echo-tool.ts' + +# Bash 执行器:从 npm 包 `@deepseek-ai/dsh-bash-local`加载,提供 bash 命令执行能力 +- id: bash + name: '@deepseek-ai/dsh-bash-local' + +# 应用主体:把 session 管理、tool 调度、agent loop 等组装成一个可交互的终端 Agent +# 只需告诉它用哪个模型 (`model`)、什么人设 (`persona`) +- id: stdio-agent + name: '@deepseek-ai/dsh-stdio-agent' + config: + model: mock-echo + persona: 'You are echo-agent, a demo agent.' + welcome: 'echo-agent ready. Type a message ("echo " triggers the tool).' + persistenceRoot: './.sessions' +``` + +### coding-agent 的配置 + +真实场景——接入 DeepSeek API,带完整工具链: + +```yaml +# 热替换:同上,开发时自动重载 +- id: hmr + name: '@cordisjs/plugin-hmr' + config: + root: ['.'] + +# LLM 后端:从 npm 包加载,具备接入 DeepSeek API 能力 +# `!!js` 从环境变量读取密钥,不会写进配置文件 +# `models` 声明该适配器能处理哪些模型名 +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_BASE_URL + models: + - deepseek-v4-pro + - deepseek-v4-flash + +# Bash 执行器:让 Agent 能跑 shell 命令 +# timeoutMs 设置单条命令的超时时间 +- id: bash + name: '@deepseek-ai/dsh-bash-local' + config: + timeoutMs: 60000 + +# 应用主体:和 echo-agent 一样的框架,只是配置不同 +# `model` 指定默认使用哪个模型(要和上面 models 列表里的名字对应) +# `persona` 是系统提示词,{{model}} 会被替换为实际模型名 +# `resumeSessionId` 设了就恢复旧对话,没设就每次新建 +- id: stdio-agent + name: '@deepseek-ai/dsh-stdio-agent' + config: + model: deepseek-v4-flash + resumeSessionId: !!js process.env.RESUME_SESSION_ID + persistenceRoot: './.sessions' + welcome: 'agent REPL ready. Give it a coding task.' + persona: | + You are coding-agent, a coding assistant powered by the {{model}} model. + Verify your work by running the code or tests. Keep answers brief and factual. + +# 自动压缩:对话太长时自动总结旧内容,腾出上下文空间 +# contextWindow 是模型能看到的 token 上限 +# thresholdRatio 超过这个比例就触发压缩 +- id: compact-basic + name: '@deepseek-ai/dsh-compact-basic' + config: + contextWindow: 128000 + thresholdRatio: 0.8 + retainTokens: 20480 + maxTokens: 8192 + +# 子代理:把子任务分配给独立的 Agent 去做 +# subagent 是服务注册,spawn/fork 是两种委派方式: +# spawn — 全新子代理,不知道父级在聊什么 +# fork — 继承父级对话上下文的子代理 +# tool-subagent 把委派能力暴露给模型,toolName 是模型看到的工具名 +- id: subagent + name: '@deepseek-ai/dsh-subagent' + +- id: subagent-spawn + name: '@deepseek-ai/dsh-subagent-spawn' + config: + providerName: spawn + +- id: subagent-fork + name: '@deepseek-ai/dsh-subagent-fork' + config: + providerName: fork + +- id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + +- id: tool-subagent-fork + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: fork + toolName: subagent_fork + +# 任务追踪:模型可以用 todo_write 记录和更新任务清单 +- id: tool-todo + name: '@deepseek-ai/dsh-tool-todo' + +# 文件系统:让 Agent 能读写编辑文件 +# fs-local 提供本地文件操作能力,cwd 是工作目录 +# fs-policy 是安全策略——必须先读才能写,防止模型盲写 +# tool-fs 把能力暴露给模型(read / write / edit 三个工具) +- id: fs-local + name: '@deepseek-ai/dsh-fs-local' + config: + cwd: !!js process.cwd() + +- id: fs-policy + name: '@deepseek-ai/dsh-fs-policy' + +- id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' +``` + +和 echo-agent 对比:同一个 `dsh-stdio-agent` 应用主体,只是把 mock 换成了真实 API,加上了更多工具插件。 + +## 语法详解 + +### 插件声明字段 + +每个插件条目支持以下字段: + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `name` | string | 是 | 插件来源(npm 包名或相对路径) | +| `id` | string | 否 | 实例标识符,用于日志和调试 | +| `config` | object | 否 | 传递给插件的配置 | +| `disabled` | boolean | 否 | 设为 `true` 临时禁用该插件 | + +### 插件来源 (`name`) + +**npm 包** — 已安装的 `@deepseek-ai/dsh-*` 包或第三方包: + +```yaml +- name: '@deepseek-ai/dsh-llm-deepseek' +``` + +**相对路径** — 本地 TypeScript 文件(相对于 `cordis.yml` 所在目录): + +```yaml +- name: './src/my-tool.ts' +``` + +### 环境变量 (`!!js`) + +用 `!!js` 标签在配置中引用运行时表达式: + +```yaml +config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + cwd: !!js process.cwd() +``` + +::: warning +是 `!!js`(两个感叹号),不是 `!js`。写错了会静默失败。 +::: + +环境变量从仓库根目录的 `.env` 文件自动加载(已被 gitignore)。 + +### 禁用插件 + +不想删配置但暂时不加载?加一行 `disabled`: + +```yaml +- id: compact-basic + name: '@deepseek-ai/dsh-compact-basic' + disabled: true + config: + contextWindow: 128000 +``` + +## 各插件配置参考 + +### stdio-agent(标准应用主体) + +**包名:** `@deepseek-ai/dsh-stdio-agent` + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `model` | string | **必填** | 使用的模型名,需与 LLM 适配器注册的名字一致 | +| `persona` | string | `''` | 系统提示词。支持 `{{model}}` 等模板变量 | +| `toolOrder` | string[] | — | 模型看到的工具顺序。省略则按字母排序 | +| `persistenceRoot` | string | `'./.sessions'` | 会话日志存储目录 | +| `welcome` | string | `'ready.'` | 启动时显示的欢迎信息 | +| `resumeSessionId` | string | — | 恢复指定会话 ID。留空则每次新建 | + +### llm-deepseek(DeepSeek 适配器) + +**包名:** `@deepseek-ai/dsh-llm-deepseek` + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `apiKey` | string | `$DEEPSEEK_API_KEY` | API 密钥。省略则从环境变量读取 | +| `baseURL` | string | `$DEEPSEEK_BASE_URL` 或官方地址 | API 端点 | +| `models` | string[] | `['deepseek-v4-flash', 'deepseek-v4-pro']` | 注册的模型名列表 | +| `thinking` | `'enabled'` \| `'disabled'` | `'enabled'` | 是否开启思维链 | +| `reasoningEffort` | `'high'` \| `'max'` | — | 思维链深度(仅 thinking 开启时有效) | + +### bash-local(Bash 执行器) + +**包名:** `@deepseek-ai/dsh-bash-local` + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `cwd` | string | `process.cwd()` | 命令执行的工作目录 | +| `timeoutMs` | number | `120000` | 单条命令的超时时间(毫秒) | +| `maxTimeoutMs` | number | `600000` | 单条命令超时的上限(模型不能请求更久) | +| `maxOutputBytes` | number | `64000` | 单次输出的内存上限(超出后溢出到临时文件) | +| `graceMs` | number | `3000` | kill 时从 SIGTERM 到 SIGKILL 的等待时间 | + +### compact-basic(自动压缩) + +**包名:** `@deepseek-ai/dsh-compact-basic` + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `contextWindow` | number | **必填** | 模型的上下文窗口大小(token) | +| `thresholdRatio` | number | **必填** | token 占用超过此比例时触发压缩(0-1) | +| `retainTokens` | number | **必填** | 压缩后至少保留多少 token 的近期内容 | +| `maxTokens` | number | **必填** | 总结时的最大输出 token | +| `summarizationModel` | string | `''`(用当前模型) | 专门用于总结的模型名 | +| `compactionRetries` | number | **必填** | 首次压缩后仍超标时的额外重试次数 | +| `auto` | boolean | `true` | 是否自动在每步前检查并触发压缩 | +| `charsPerToken` | number | `4` | 每 token 估算字符数。中文应设 1-2 | + +### fs-local(文件系统) + +**包名:** `@deepseek-ai/dsh-fs-local` + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `cwd` | string | `process.cwd()` | 工作目录,相对路径以此为基准 | + +### fs-policy(文件系统策略) + +**包名:** `@deepseek-ai/dsh-fs-policy` + +无配置项。加载即启用"必须先读才能写"的安全策略。 + +### tool-fs(文件系统工具) + +**包名:** `@deepseek-ai/dsh-tool-fs` + +无配置项。加载后向模型暴露 `read`、`write`、`edit` 三个工具。 + +### tool-web(Web 工具) + +**包名:** `@deepseek-ai/dsh-tool-web` + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `search` | boolean | `true` | 是否注册 `web_search` 工具 | +| `fetch` | boolean | `true` | 是否注册 `web_fetch` 工具 | +| `searchMaxResults` | number | `8` | 单次搜索返回的最大结果数 | + +### subagent-spawn / subagent-fork(子代理后端) + +**包名:** `@deepseek-ai/dsh-subagent-spawn` / `@deepseek-ai/dsh-subagent-fork` + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `providerName` | string | `'spawn'` / `'fork'` | 注册到子代理服务的 provider 名称 | + +### tool-subagent(子代理工具) + +**包名:** `@deepseek-ai/dsh-tool-subagent` + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `provider` | string | **必填** | 使用哪个 provider(如 `spawn`、`fork`) | +| `toolName` | string | `'subagent'` | 暴露给模型的工具名。多次加载时必须不同 | +| `agentOptions.model` | string | — | 子代理使用的模型名(省略则继承父代理) | + +### tool-todo(任务清单) + +**包名:** `@deepseek-ai/dsh-tool-todo` + +无配置项。加载后向模型暴露 `todo_write` 工具。 + +### hmr(热替换) + +**包名:** `@cordisjs/plugin-hmr` + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `root` | string[] | **必填** | 监听文件变更的目录列表 | + +::: tip +hmr 仅用于开发环境。它需要 `node --expose-internals` 启动参数,`demo:*` 脚本已自动添加。 +::: + +--- + +## 加载顺序 + +`cordis.yml` 的顺序就是加载顺序。推荐: + +1. **hmr** — 热替换(仅开发时需要) +2. **LLM 适配器** — 模型后端 +3. **执行器** — bash、fs 等能力提供者 +4. **应用主体** — `dsh-stdio-agent` 或 `dsh-acp-agent` +5. **附加插件** — compact、subagent、todo 等 + +应用主体内部已经捆绑了核心能力(session、tools、agent-loop),不需要手动加载。 + +## 下一步 + +- [开发插件](../develop/basic/) — 编写自己的插件 +- [API 参考](../api/) — 查看各插件完整接口 diff --git a/website/zh-CN/guide/index.md b/website/zh-CN/guide/index.md new file mode 100644 index 0000000000..8b7211b308 --- /dev/null +++ b/website/zh-CN/guide/index.md @@ -0,0 +1,47 @@ +# 介绍 + +DeepSeek Harness 是一个**插件化的 Agent 开发框架**,基于 [Cordis](https://github.com/cordiverse/cordis) 微内核构建。它的核心理念是:**一切皆插件**。 + +## 它是什么 + +Harness 将一个 AI Agent(智能体) 所需要的所有能力——LLM 调用、工具执行、会话管理、子任务分配——全部构建为可组合的插件。你通过一个 `cordis.yml` 配置文件来声明加载哪些插件、使用什么参数,就能组装出一个完整的 Agent。 + +```yaml +# 选择 LLM 后端 +- name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + +# 选择应用模板 +- name: '@deepseek-ai/dsh-stdio-agent' + config: + model: deepseek-v4-flash +``` + +## 适合谁 + +### 应用使用者 + +如果你只是想用一个现成的 Agent 应用(如编程助手、对话代理),你需要的全部操作就是: + +1. 复制一个 example 模板 +2. 填写 API key +3. 运行 + +不需要写任何代码。详见 [快速开始](./quickstart)。 + +### 插件开发者 + +如果你想为 Agent 添加新能力——一个自定义 tool、一个新的 LLM 适配器、一个新的执行后端——你需要编写一个插件。Harness 提供了清晰的扩展接口和类型安全的开发体验。详见 [开发](../develop/basic/)。 + +## 核心特性 + +- **只需要配置** — `cordis.yml` 决定能力集合,换模型、加工具只需改一行 +- **随时替换 (HMR)** — 开发时修改插件代码,无需重启进程 + +## 技术栈 + +- **运行时**: Node.js >= 24 +- **语言**: TypeScript (ESM) +- **框架**: Cordis +- **包管理**: pnpm workspaces diff --git a/website/zh-CN/guide/quickstart.md b/website/zh-CN/guide/quickstart.md new file mode 100644 index 0000000000..f15ac182cf --- /dev/null +++ b/website/zh-CN/guide/quickstart.md @@ -0,0 +1,98 @@ +# 快速开始 + +本指南带你在 5 分钟内跑起一个 Agent。 + +## 环境准备 + +- [Node.js](https://nodejs.org/) >= 24 +- [pnpm](https://pnpm.io/) >= 9 + +```sh +# 确认版本 +node -v # v24.x 或更高 +pnpm -v # 9.x 或更高 +``` + +## 第一步:运行 echo-agent + +echo-agent 不需要 API key,装好依赖就能跑。 + +```sh +# 克隆仓库 +git clone https://github.com/deepseek-harness/deepseek-harness.git +cd deepseek-harness + +# 安装依赖 +pnpm install +# 如果看到 ERR_PNPM_IGNORED_BUILDS,可以忽略——安装已经成功了。 +# 想消除这个提示可以跑一次: pnpm approve-builds + +# 启动 echo-agent +pnpm run demo:echo +``` + +启动后你会看到: + +``` +echo-agent ready. Type a message ("echo " triggers the tool). +> +``` + +试着输入: + +``` +> echo hello world +``` + +你会看到模型发起了一次 tool call(工具调用),echo 工具将文本转为大写并返回: + +``` +[tool call] echo({"text":"hello world"}) +[tool result] ECHO: HELLO WORLD +``` + +恭喜!环境没问题。 + +## 第二步:使用真实模型调用 + +接下来接入真实的 DeepSeek 模型,跑一个完整的命令行 Agent。 + +### 获取 API Key + +前往 [DeepSeek Platform](https://platform.deepseek.com/) 获取你的 API key。 + +### 配置环境变量 + +在仓库根目录创建 `.env` 文件(已被 gitignore): + +```sh +DEEPSEEK_API_KEY=sk-your-key-here +``` + +### 启动 coding-agent + +```sh +pnpm run demo:repl +``` + +``` +agent REPL ready. Give it a coding task. +> +``` + +这就是一个完整的编程助手,它能读写文件、跑命令、拆分子任务。 + +试着给它一个任务: + +``` +> 在当前目录创建一个 hello.js,内容是打印 "Hello from Harness!",然后运行它 +``` + +## 回头看 + +echo-agent 和 coding-agent 用的是同一个应用框架(`@deepseek-ai/dsh-stdio-agent`),区别只在 `cordis.yml`——换了哪些插件、填了什么配置。你以后定制自己的 Agent 也是同样的方式。 + +## 下一步 + +- [配置文件](./config) — 了解 `cordis.yml` 的完整语法 +- [开发插件](../develop/basic/) — 编写你自己的 tool 或后端 diff --git a/website/zh-CN/index.md b/website/zh-CN/index.md new file mode 100644 index 0000000000..90b23e483a --- /dev/null +++ b/website/zh-CN/index.md @@ -0,0 +1,21 @@ +--- +layout: home +hero: + name: DeepSeek Harness + text: 插件化 Agent 开发框架 + tagline: 基于 Cordis 微内核,一切皆插件 + actions: + - theme: brand + text: 快速开始 + link: /zh-CN/guide/quickstart + - theme: alt + text: 开发插件 + link: /zh-CN/develop/basic/ +features: + - title: 插件化架构 + details: 基于 Cordis 效果系统,所有能力通过插件注册,加载即生效、卸载即还原。 + - title: 配置即组合 + details: 一个 cordis.yml 决定整个 Agent 的能力组合——换模型、加工具,只需改一行配置。 + - title: 开箱即用 + details: 内置 LLM 调用、文件读写、Bash 执行、子代理委派等完整工具链,复制模板即可运行。 +--- From cc546c4580721a78c0276ffa8723523a74679e6a Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Wed, 15 Jul 2026 13:22:44 +0800 Subject: [PATCH 02/14] feat(compact): move pairing helpers (PR1 round 1) --- docs/cordis-catalog/events.md | 8 +- docs/cordis-catalog/services.md | 4 +- docs/core-data-structures/compaction.md | 2 + docs/core-data-structures/session.md | 2 + docs/event-producer-consumer.md | 8 +- .../2026-06-18-compaction-capability-seam.md | 4 +- packages/compact/compact-basic/README.md | 2 +- packages/compact/compact-basic/src/index.ts | 16 +- .../tests/compact-loop-repro.spec.ts | 6 +- packages/compact/compact/README.md | 8 +- packages/compact/compact/src/index.ts | 3 + packages/compact/compact/src/tool-pairing.ts | 160 +++++++++ .../compact/tests/tool-pairing.spec.ts | 327 ++++++++++++++++++ packages/core/session/README.md | 2 +- packages/core/session/src/index.ts | 1 - packages/core/session/src/tool-pairing.ts | 56 --- .../core/session/tests/tool-pairing.spec.ts | 292 ---------------- 17 files changed, 525 insertions(+), 376 deletions(-) create mode 100644 packages/compact/compact/src/tool-pairing.ts create mode 100644 packages/compact/compact/tests/tool-pairing.spec.ts delete mode 100644 packages/core/session/src/tool-pairing.ts delete mode 100644 packages/core/session/tests/tool-pairing.spec.ts diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 4af431f8c0..d2102e5e3f 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -245,7 +245,7 @@ Creation announcement during session publication. A synchronous throw vetoes and 'session/created'(this: Scoped, session: Session): void ``` -Source: [`packages/core/session/src/index.ts:47`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:46`](../../packages/core/session/src/index.ts) ### `session/disposed` — emit @@ -255,7 +255,7 @@ Emitted once when an announced session leaves the store, including publication r 'session/disposed'(this: Scoped, session: Session): void ``` -Source: [`packages/core/session/src/index.ts:57`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:56`](../../packages/core/session/src/index.ts) ### `session/event` — emit @@ -267,7 +267,7 @@ Post-commit, fire-and-forget append feed. The listener snapshot resolves before Types: [SessionEvent](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:69`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:68`](../../packages/core/session/src/index.ts) ### `session/flush` — parallel @@ -277,7 +277,7 @@ Awaited parallel durability checkpoint: every listener runs and the caller await 'session/flush'(this: Scoped, session: Session): Promise | void ``` -Source: [`packages/core/session/src/index.ts:79`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:78`](../../packages/core/session/src/index.ts) ## `skill/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 1509afc76a..1197570d23 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -95,7 +95,7 @@ abstract compactRegion( session: Session, start: number, end: number, agent: Com Types: [Message](../core-data-structures/core.md) -Source: [`packages/compact/compact/src/index.ts:36`](../../packages/compact/compact/src/index.ts) +Source: [`packages/compact/compact/src/index.ts:37`](../../packages/compact/compact/src/index.ts) ## `ctx.fs` — `FileSystem` (abstract seam) @@ -200,7 +200,7 @@ list(): Session[] fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session ``` -Source: [`packages/core/session/src/index.ts:564`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:563`](../../packages/core/session/src/index.ts) ## `ctx.skills` — `SkillService` diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md index 82bf512eeb..05022f9937 100644 --- a/docs/core-data-structures/compaction.md +++ b/docs/core-data-structures/compaction.md @@ -53,3 +53,5 @@ interface CompactionResult { `CompactService` exposes `compactIfNeeded(...)` for pressure-triggered compaction, returning `null` when no compaction is needed, and `compactRegion(...)` for an explicit inclusive surface range. The pre-step caller supplies the agent, full prompt, session prefix, and abort signal; implementations must forward that signal to summarization. Estimation, retention, event sequencing, and summarization remain backend policy. Auto-compaction runs at serial `agent/pre-step`, before the step and request derivation, so it can replace surface nodes while keeping trace events outside the step. Region boundaries preserve tool-call/result pairing but do not preserve whole turns, allowing early closed steps of one oversized turn to compact. `dsh-compact-basic` owns the retention and failure details. + +The seam exports `toolPairingBalancedBefore(session, node)` and `toolPairingBalancedAfter(session, node)` for those edge checks. Both validate current surface membership, reject stale or missing seqs and orphan results, and ignore a caller-retained `node.next`; the [package contract](../../packages/compact/compact/README.md#tool-pairing-boundaries) owns their cache semantics. diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index d8292c49b3..174f06602e 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -196,6 +196,8 @@ export interface SurfaceNode { } ``` +`SurfaceNode` is positional state, not durable identity. A replacement can remove a caller-retained node or make a copied `next` stale; consumers that cross a surface mutation validate membership and resolve successors from `Session.surface.nodes`. `SurfaceManager.replaceGeneration` increments for each replacement so incremental consumers can distinguish pure tail growth from a rewrite. + ### `SurfaceFoldReplacement` and `SurfaceFoldResult` — a complete surface replay `foldSurface(events)` returns detached current nodes together with the actual node seqs shadowed by each declared replacement range. `SurfaceManager` uses the same transition functions for its incremental cache. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index bdf1c6e320..445078cb37 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -25,10 +25,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:68`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:51`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:39`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | - | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) | -| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:46`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | - | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:68`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:78`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `skill/provider-added` | `emit` | [`packages/skill/skill/src/index.ts:131`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - | | `skill/provider-removed` | `emit` | [`packages/skill/skill/src/index.ts:137`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:92`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) | diff --git a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md index 4a6586b159..29e3347759 100644 --- a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -52,7 +52,7 @@ The loop derives messages once after `agent/pre-step`. Running before `step/star Auto-compaction fires before **every** step, not once per turn. This is **load-bearing for runaway-turn survival**: a tool-heavy ReAct turn appends an `assistant/message` + a `tool/result` per step, so the surface grows *within* a turn. A single turn can grow past the window on its own (a "runaway turn") — and the only moment to rescue it before the next model call overflows is the next step's `pre-step` checkpoint. Gating compaction to a turn's first step (or, worse, retaining the whole in-flight turn verbatim) re-opens exactly the hole compaction exists to close: the harness would die when compaction is most needed. -`compactIfNeeded` retains the smallest tail of whole surface units whose estimated size reaches `retainTokens` and compacts older nodes. A unit is a complete closed step or one no-step message. If the token cutoff lands inside a step, retention expands until the cut is tool-pairing balanced. Balance is checked on surface order, not log sequence, because replacement summaries have new sequence numbers at old surface positions. `compactRegion` rejects boundaries that split a tool call from its result. The in-flight turn receives no special retention. +`compactIfNeeded` retains the smallest tail of whole surface units whose estimated size reaches `retainTokens` and compacts older nodes. A unit is a complete closed step or one no-step message. If the token cutoff lands inside a step, retention expands until the cut is tool-pairing balanced. Balance is checked on surface order, not log sequence, because replacement summaries have new sequence numbers at old surface positions. `dsh-compact` exports the before/after edge helpers; their per-session cache folds only appended surface-tail nodes while `replaceGeneration` is unchanged, does no event reads for log-only growth, and rebuilds current membership, positional successors, and balances after replacement. `compactRegion` rejects boundaries that split a tool call from its result. The in-flight turn receives no special retention. A runaway turn thus compacts exactly like any other history: its early *closed* steps get summarized while its recent steps stay verbatim. When the only compactable content left is an un-splittable open tail step (its tool-calls have no results yet), compaction declines (`null`) and retries once that step closes. @@ -113,7 +113,7 @@ Two failure paths, both documented: - **New packages**: `packages/compact/compact` (interface) and a sibling `compact-basic` (backend) under `packages/compact/`, wired into the root tsconfigs. The consumer tier is deferred. - **New loop seam**: `agent/pre-step` (`@mode serial`) declared in `dsh-agent` and emitted by `dsh-agent-loop` after system assembly and before `step/start`. This is a documented change to the loop — `docs/architecture.md` records it and the generated cordis catalog carries its signature. - **`SessionEventMap`** gains `compact/start` / `compact/summary` / `compact/end` by declaration merging (merge-extensible); `SurfaceEventType` is **not** touched. These are session events, not cordis `Events`, so the event-taxonomy gate needs no entry. -- **`dsh-session`** gains the tool-pairing balance predicate (`isToolPairingBalanced`, in `tool-pairing.ts`, exported from the package index) that `compactRegion`/`compactIfNeeded` use to keep a collapsed region from splitting a step's tool-call/result pair. The surface `replace` op and the surface-metadata runtime guard already existed and are reused. +- **`dsh-compact`** owns `toolPairingBalancedBefore(session, node)` and `toolPairingBalancedAfter(session, node)`, the cached surface-edge checks that `compactRegion` and `compactIfNeeded` use to avoid splitting a tool-call/result pair. The cache validates current membership by seq and resolves after-edges from its positional successor map instead of trusting a caller-retained `node.next`; stale or missing seqs and orphan results reject. `dsh-session` continues to own the surface `replace` operation, positional nodes, and rewrite generation. - **`dsh-invariants`** drops its `surface replace: start must be <= end` assertion: a head-anchored compaction lands a high-seq replacement node at an older range's *position*, so `start > end` numerically is normal and valid (the range is positional, validated by the surface's `indexOf` checks that remain). The turn-enclosure invariant is reused unchanged. - **Wiring**: `dsh-compact-basic` is loaded in `examples/coding-agent`'s `cordis.yml`, so the seam ships in the real demo (it was previously loaded nowhere). diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index d9c0f472a8..d05ce47356 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -9,7 +9,7 @@ This is the implementation tier of the compaction capability — see the [interf This backend owns the compaction policy: - **Estimation** — a configurable characters-per-token heuristic counts the current session prefix supplied to pre-step, derived history, and system prompt, matching the next request rather than stale logged prefix state. -- **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts. Turn boundaries do not protect old steps inside a runaway turn. An open indivisible tail declines until it closes; a single unit larger than the budget remains out of scope. +- **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts through the [`dsh-compact` boundary helpers](../compact/README.md#tool-pairing-boundaries). Turn boundaries do not protect old steps inside a runaway turn. An open indivisible tail declines until it closes; a single unit larger than the budget remains out of scope. - **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold. - **Summarization** — a direct `llm/stream` call uses the configured model and cap without running the loop-only `agent/request` seam. The input transcript preserves non-text blocks as tagged placeholders; only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call. - **Framing** — the replacement user message marks established checkpoint context with `` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint. diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index bf349f7f73..fffc8e9a63 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -7,12 +7,11 @@ */ import { Context } from 'cordis' -import { CompactService, renderTranscript } from '@deepseek-ai/dsh-compact' +import { CompactService, renderTranscript, toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact' import type { CompactionResult } from '@deepseek-ai/dsh-compact' import { BlockAssembler } from '@deepseek-ai/dsh-llm' import type { ContentBlock, FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' -import { isToolPairingBalanced } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import type { BasicCompactConfig, ResolvedConfig } from './types.ts' import { resolveConfig } from './types.ts' @@ -348,15 +347,14 @@ export class BasicCompactService extends CompactService { } // Both range edges must preserve assistant tool-call/result pairing. - const events = session.events - if (!isToolPairingBalanced(nodes, events, start)) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const startNode = nodes[startIdx]! + if (!toolPairingBalancedBefore(session, startNode)) { throw new Error(`compactRegion: start seq ${start} is not a balanced boundary (would split a step's tool-call/result pair)`) } - // The cut after `end` is named by `end`'s surface successor, or `null` when - // `end` is the tail. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const afterEnd: number | null = nodes[endIdx]!.next - if (!isToolPairingBalanced(nodes, events, afterEnd)) { + const endNode = nodes[endIdx]! + if (!toolPairingBalancedAfter(session, endNode)) { throw new Error(`compactRegion: end seq ${end} is not a balanced boundary (would split a step, or the step is still open)`) } @@ -511,7 +509,7 @@ export class BasicCompactService extends CompactService { // splitting an assistant↔result pair. while (keepFromIdx > 0) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - if (isToolPairingBalanced(nodes, events, nodes[keepFromIdx]!.seq)) break + if (toolPairingBalancedBefore(session, nodes[keepFromIdx]!)) break keepFromIdx -= 1 } if (keepFromIdx === 0) return null diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index dbf8a3b737..49fb99acec 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' +import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact' import LlmService from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' -import { isToolPairingBalanced } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' @@ -124,9 +124,9 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () for (const cp of checkpoints) { const node = nodes.find(n => n.seq === cp.seq) if (!node) continue // shadowed by a later checkpoint — no longer an edge. - expect(isToolPairingBalanced(nodes, events, node.seq), + expect(toolPairingBalancedBefore(agent.session, node), `checkpoint seq ${node.seq} must be a balanced region START`).toBe(true) - expect(isToolPairingBalanced(nodes, events, node.next), + expect(toolPairingBalancedAfter(agent.session, node), `checkpoint seq ${node.seq} must be a balanced region END`).toBe(true) } } finally { diff --git a/packages/compact/compact/README.md b/packages/compact/compact/README.md index 9141012281..5b59893561 100644 --- a/packages/compact/compact/README.md +++ b/packages/compact/compact/README.md @@ -6,7 +6,7 @@ This package is the interface tier of the compaction capability, split so each c | Package | Role | |---|---| -| `@deepseek-ai/dsh-compact` (this) | the interface: abstract service + `compact/*` events + `CompactionResult` + the shared transcript renderer (`renderTranscript`/`renderContentBlocks`) | +| `@deepseek-ai/dsh-compact` (this) | the interface: abstract service + `compact/*` events + `CompactionResult` + tool-pairing boundary helpers + the shared transcript renderer (`renderTranscript`/`renderContentBlocks`) | | `@deepseek-ai/dsh-compact-basic` | a backend: chars-per-token estimation (`charsPerToken`, default 4) + token-budget retention + `llm.stream()` summarization | | `@deepseek-ai/dsh-tool-compact` (deferred) | the model-facing `/compact` tool over `ctx.compact` | @@ -23,6 +23,12 @@ Both methods are **abstract** — the backend owns the entire strategy (token es `compactIfNeeded` takes a required `signal`; `compactRegion`'s is optional. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization instead of leaving an orphaned model call running past the cancellation. The session being compacted comes from the agent context; the turn that the `compact/*` events belong to is recoverable from the log (the currently-open turn), so the backend stamps it from the log rather than trusting a caller-supplied value. +## Tool-pairing boundaries + +The interface exports `toolPairingBalancedBefore(session, node)` and `toolPairingBalancedAfter(session, node)` for snapping and validating compaction edges. A safe edge has no unanswered assistant tool call crossing it. Each helper validates the node's seq against current surface membership and resolves the trailing edge from its cached positional successor, so a stale caller-held `node.next` cannot choose the cut. + +The private per-session cache is keyed by `session.surface.replaceGeneration` and the processed surface-node count. An unchanged generation extends the fold with unseen tail nodes only; a log-only append with no new surface node does no event reads, while a replacement generation rebuilds current membership, successors, and balances. Missing event seqs and a `tool/result` without a preceding open call reject as corrupt surface state. + ## Surface contract `SurfaceEventType` is a closed union — only `user/message`, `assistant/message`, `tool/result`, `context/message`, and `steering/message` may carry `surfaceOp`. A `compact/*` event therefore **cannot** appear on the surface. A successful compaction instead: diff --git a/packages/compact/compact/src/index.ts b/packages/compact/compact/src/index.ts index f59e0dc328..12eb77b362 100644 --- a/packages/compact/compact/src/index.ts +++ b/packages/compact/compact/src/index.ts @@ -14,6 +14,7 @@ import type { CompactionResult } from './types.ts' export type { CompactionResult } from './types.ts' export { renderContentBlocks, renderTranscript } from './render.ts' +export { toolPairingBalancedAfter, toolPairingBalancedBefore } from './tool-pairing.ts' /** Minimal agent context compaction needs without depending on the agent package. */ export interface CompactAgentContext { @@ -67,6 +68,8 @@ export abstract class CompactService extends Service { * balanced so assistant tool calls remain paired with their results. A model- * backed implementation forwards cancellation and rejects active, missing, * reversed, or unbalanced ranges. + * Use {@link toolPairingBalancedBefore} and {@link toolPairingBalancedAfter} + * for the edge checks. * * @param session - session to mutate. * @param start - first surface seq, inclusive. diff --git a/packages/compact/compact/src/tool-pairing.ts b/packages/compact/compact/src/tool-pairing.ts new file mode 100644 index 0000000000..a9fca01bf6 --- /dev/null +++ b/packages/compact/compact/src/tool-pairing.ts @@ -0,0 +1,160 @@ +/** + * Tool-pairing balance over a session surface. Compaction changes surface + * positions, so safe cuts are derived from tool-call/result content in current + * surface order rather than step markers or linked-list fields supplied by a + * caller. + * @module @deepseek-ai/dsh-compact/tool-pairing + */ + +import type { Session, SessionEvent, SurfaceNode } from '@deepseek-ai/dsh-session' + +/** Incremental balance state for one session surface generation. */ +interface BalanceCache { + /** Surface rewrite generation this state describes. */ + generation: number + /** Number of surface nodes already folded into the state. */ + processedNodes: number + /** Balance of the cut immediately before each current surface node. */ + beforeSeq: Map + /** Current positional successor of each surface node. */ + successorBySeq: Map + /** Unanswered tool-call count after the processed surface tail. */ + depth: number +} + +const balanceCacheBySession = new WeakMap() + +/** Return how one surface event changes the unanswered tool-call count. */ +function nodeDelta(event: SessionEvent): number { + switch (event.type) { + case 'assistant/message': + return event.data.content.filter(block => block.type === 'tool-call').length + case 'tool/result': + return -1 + default: + return 0 + } +} + +/** Read and validate the event named by a surface node. */ +function eventForNode(events: readonly SessionEvent[], node: SurfaceNode): SessionEvent { + const event = events[node.seq] + if (event === undefined || event.seq !== node.seq) { + throw new Error(`tool-pairing balance: surface seq ${node.seq} has no matching session event (corrupt surface)`) + } + return event +} + +/** Build balance state for a complete current surface. */ +function rebuildCache( + session: Session, + nodes: readonly SurfaceNode[], + generation: number, +): BalanceCache { + const beforeSeq = new Map() + const successorBySeq = new Map() + const events = session.events + let depth = 0 + let previousSeq: number | undefined + + for (const node of nodes) { + beforeSeq.set(node.seq, depth === 0) + successorBySeq.set(node.seq, null) + if (previousSeq !== undefined) successorBySeq.set(previousSeq, node.seq) + depth += nodeDelta(eventForNode(events, node)) + if (depth < 0) { + throw new Error(`tool-pairing balance: tool/result at surface seq ${node.seq} has no matching tool-call (corrupt surface)`) + } + previousSeq = node.seq + } + + return { generation, processedNodes: nodes.length, beforeSeq, successorBySeq, depth } +} + +/** Fold a pure surface tail append into existing balance state. */ +function extendCache( + session: Session, + cache: BalanceCache, + nodes: readonly SurfaceNode[], +): BalanceCache { + const tail = nodes.slice(cache.processedNodes) + // Validate the unseen tail before mutating the live cache, so a corrupt + // append cannot leave a partially advanced state behind. + const events = session.events + const pending: Array<{ seq: number; before: boolean }> = [] + let depth = cache.depth + for (const node of tail) { + pending.push({ seq: node.seq, before: depth === 0 }) + depth += nodeDelta(eventForNode(events, node)) + if (depth < 0) { + throw new Error(`tool-pairing balance: tool/result at surface seq ${node.seq} has no matching tool-call (corrupt surface)`) + } + } + + let previousSeq = nodes[cache.processedNodes - 1]?.seq + for (const entry of pending) { + if (previousSeq !== undefined) cache.successorBySeq.set(previousSeq, entry.seq) + cache.beforeSeq.set(entry.seq, entry.before) + cache.successorBySeq.set(entry.seq, null) + previousSeq = entry.seq + } + cache.processedNodes = nodes.length + cache.depth = depth + return cache +} + +/** Return balance state synchronized with the current session surface. */ +function balanceCache(session: Session): BalanceCache { + const surface = session.surface + const nodes = surface.nodes + const generation = surface.replaceGeneration + const cached = balanceCacheBySession.get(session) + + if (cached === undefined || cached.generation !== generation || cached.processedNodes > nodes.length) { + const rebuilt = rebuildCache(session, nodes, generation) + balanceCacheBySession.set(session, rebuilt) + return rebuilt + } + if (cached.processedNodes < nodes.length) return extendCache(session, cached, nodes) + return cached +} + +/** + * Whether the cut immediately before a current surface node is tool-pairing balanced. + * @param session - session whose surface is checked. + * @param node - surface node whose leading cut is checked; only its seq identifies it. + * @returns true when no unanswered tool call crosses the cut. + * @throws when the seq is absent from the current surface, a surface node has no + * matching log event, or a tool result has no preceding open call. + */ +export function toolPairingBalancedBefore(session: Session, node: SurfaceNode): boolean { + const cache = balanceCache(session) + const balanced = cache.beforeSeq.get(node.seq) + if (balanced === undefined) { + throw new Error(`tool-pairing balance: surface seq ${node.seq} not found`) + } + return balanced +} + +/** + * Whether the cut immediately after a current surface node is tool-pairing balanced. + * @param session - session whose surface is checked. + * @param node - surface node whose trailing cut is checked; only its seq identifies it. + * @returns true when no unanswered tool call crosses the cut. + * @throws when the seq is absent from the current surface, a surface node has no + * matching log event, or a tool result has no preceding open call. + */ +export function toolPairingBalancedAfter(session: Session, node: SurfaceNode): boolean { + const cache = balanceCache(session) + const successor = cache.successorBySeq.get(node.seq) + if (successor === undefined) { + throw new Error(`tool-pairing balance: surface seq ${node.seq} not found`) + } + if (successor === null) return cache.depth === 0 + // Current membership and positional successors are cache-owned. A caller may + // retain a node across surface changes, so its mutable-looking `next` field is + // never authoritative for this query. + // The successor map and balance map are committed together. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return cache.beforeSeq.get(successor)! +} diff --git a/packages/compact/compact/tests/tool-pairing.spec.ts b/packages/compact/compact/tests/tool-pairing.spec.ts new file mode 100644 index 0000000000..dfde2bd2ba --- /dev/null +++ b/packages/compact/compact/tests/tool-pairing.spec.ts @@ -0,0 +1,327 @@ +import { describe, expect, it } from 'vitest' +import { CallId } from '@deepseek-ai/dsh-llm' +import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SurfaceNode } from '@deepseek-ai/dsh-session' + +const SURFACE = { surfaceOp: 'append' as const } + +function seqOf(session: Session, type: SessionEvent['type'], nth = 0): number { + return session.events.filter(event => event.type === type)[nth]!.seq +} + +function nodeAt(session: Session, seq: number): SurfaceNode { + const node = session.surface.nodes.find(candidate => candidate.seq === seq) + if (node === undefined) throw new Error(`seq ${seq} is not a surface node`) + return node +} + +function before(session: Session, type: SessionEvent['type'], nth = 0): boolean { + return toolPairingBalancedBefore(session, nodeAt(session, seqOf(session, type, nth))) +} + +function after(session: Session, type: SessionEvent['type'], nth = 0): boolean { + return toolPairingBalancedAfter(session, nodeAt(session, seqOf(session, type, nth))) +} + +function closedToolStep(): Session { + const session = new Session(SessionId('closed-tool-step')) + session.append('user/message', { + content: [{ type: 'text', text: 'go' }], + source: { kind: 'user' }, + }, SURFACE) + session.append('assistant/message', { + turn: 1, + step: 1, + content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }], + }, SURFACE) + session.append('tool/result', { + turn: 1, + step: 1, + callId: CallId('c1'), + content: [{ type: 'text', text: 'done' }], + isError: false, + }, SURFACE) + return session +} + +describe('tool-pairing boundaries', () => { + it('classifies closed and open single-call steps', () => { + const closed = closedToolStep() + expect(before(closed, 'user/message')).toBe(true) + expect(after(closed, 'user/message')).toBe(true) + expect(before(closed, 'assistant/message')).toBe(true) + expect(after(closed, 'assistant/message')).toBe(false) + expect(before(closed, 'tool/result')).toBe(false) + expect(after(closed, 'tool/result')).toBe(true) + + const open = new Session(SessionId('open-tool-step')) + open.append('assistant/message', { + turn: 1, + step: 1, + content: [{ type: 'tool-call', id: CallId('open'), name: 'bash', arguments: '{}' }], + }, SURFACE) + expect(toolPairingBalancedAfter(open, open.surface.nodes[0]!)).toBe(false) + }) + + it('requires every result from a multiple-call assistant message', () => { + const session = new Session(SessionId('multiple-calls')) + session.append('assistant/message', { + turn: 1, + step: 1, + content: [ + { type: 'tool-call', id: CallId('c1'), name: 'one', arguments: '{}' }, + { type: 'tool-call', id: CallId('c2'), name: 'two', arguments: '{}' }, + ], + }, SURFACE) + session.append('tool/result', { + turn: 1, step: 1, callId: CallId('c1'), content: [], isError: false, + }, SURFACE) + session.append('tool/result', { + turn: 1, step: 1, callId: CallId('c2'), content: [], isError: false, + }, SURFACE) + + expect(after(session, 'tool/result', 0)).toBe(false) + expect(after(session, 'tool/result', 1)).toBe(true) + }) + + it('keeps neutral nodes inside an open pair unbalanced and free nodes balanced', () => { + const midStep = new Session(SessionId('neutral-mid-step')) + midStep.append('assistant/message', { + turn: 1, + step: 1, + content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }], + }, SURFACE) + midStep.append('context/message', { + content: [{ type: 'text', text: 'background update' }], + source: { kind: 'plugin', plugin: 'test' }, + }, SURFACE) + midStep.append('tool/result', { + turn: 1, step: 1, callId: CallId('c1'), content: [], isError: false, + }, SURFACE) + expect(before(midStep, 'context/message')).toBe(false) + expect(after(midStep, 'context/message')).toBe(false) + + const free = new Session(SessionId('neutral-free')) + free.append('context/message', { + content: [{ type: 'text', text: 'idle injection' }], + source: { kind: 'user' }, + }, SURFACE) + expect(before(free, 'context/message')).toBe(true) + expect(after(free, 'context/message')).toBe(true) + }) +}) + +describe('tool-pairing surface identity', () => { + it('rebuilds after replace and rejects nodes removed from current membership', () => { + const session = closedToolStep() + const staleTail = nodeAt(session, seqOf(session, 'tool/result')) + expect(toolPairingBalancedAfter(session, staleTail)).toBe(true) + + const nodes = session.surface.nodes + session.append('user/message', { + content: [{ type: 'text', text: 'checkpoint' }], + source: { kind: 'plugin', plugin: 'compact' }, + }, { surfaceOp: { op: 'replace', start: nodes[0]!.seq, end: nodes.at(-1)!.seq } }) + + const checkpoint = session.surface.nodes[0]! + expect(toolPairingBalancedBefore(session, checkpoint)).toBe(true) + expect(toolPairingBalancedAfter(session, checkpoint)).toBe(true) + expect(() => toolPairingBalancedBefore(session, staleTail)).toThrow(/surface seq .* not found/) + expect(() => toolPairingBalancedAfter(session, staleTail)).toThrow(/surface seq .* not found/) + }) + + it('uses the cached positional successor instead of a caller node next field', () => { + const session = closedToolStep() + const assistant = nodeAt(session, seqOf(session, 'assistant/message')) + expect(toolPairingBalancedAfter(session, { ...assistant, next: null })).toBe(false) + expect(toolPairingBalancedAfter(session, { ...assistant, next: 999 })).toBe(false) + }) + + it('rejects missing seqs before and after, including an empty surface', () => { + const session = new Session(SessionId('missing-membership')) + const missing: SurfaceNode = { seq: 999, prev: null, next: null } + expect(() => toolPairingBalancedBefore(session, missing)).toThrow(/surface seq 999 not found/) + expect(() => toolPairingBalancedAfter(session, missing)).toThrow(/surface seq 999 not found/) + + session.append('user/message', { + content: [{ type: 'text', text: 'first node after empty cache' }], + source: { kind: 'user' }, + }, SURFACE) + expect(toolPairingBalancedAfter(session, session.surface.nodes[0]!)).toBe(true) + }) +}) + +describe('tool-pairing cache refresh', () => { + it('does no event reads for unchanged or log-only growth, folds only appended nodes, and rebuilds on replace', () => { + const events: SessionEvent[] = [ + { + type: 'user/message', seq: 0, time: 0, + data: { content: [{ type: 'text', text: 'user' }], source: { kind: 'user' } }, + surfaceOp: 'append', + }, + { + type: 'assistant/message', seq: 1, time: 1, + data: { turn: 1, step: 1, content: [{ type: 'tool-call', id: CallId('c1'), name: 'one', arguments: '{}' }] }, + surfaceOp: 'append', + }, + { + type: 'tool/result', seq: 2, time: 2, + data: { turn: 1, step: 1, callId: CallId('c1'), content: [], isError: false }, + surfaceOp: 'append', + }, + ] + const nodes: SurfaceNode[] = [ + { seq: 0, prev: null, next: 1 }, + { seq: 1, prev: 0, next: 2 }, + { seq: 2, prev: 1, next: null }, + ] + let generation = 0 + let eventCollectionReads = 0 + let eventIndexReads = 0 + const trackedEvents = new Proxy(events, { + get(target, property, receiver) { + if (typeof property === 'string' && /^\d+$/.test(property)) eventIndexReads += 1 + return Reflect.get(target, property, receiver) as unknown + }, + }) + const surface = { + get nodes() { return nodes }, + get replaceGeneration() { return generation }, + } + const session = { + surface, + get events() { + eventCollectionReads += 1 + return trackedEvents + }, + } as unknown as Session + + expect(toolPairingBalancedAfter(session, nodes[2]!)).toBe(true) + expect(eventCollectionReads).toBe(1) + expect(eventIndexReads).toBe(3) + + expect(toolPairingBalancedBefore(session, nodes[0]!)).toBe(true) + expect(toolPairingBalancedAfter(session, nodes[1]!)).toBe(false) + expect(eventCollectionReads).toBe(1) + expect(eventIndexReads).toBe(3) + + events.push({ + type: 'turn/end', seq: 3, time: 3, + data: { turn: 1, reason: { kind: 'completed' } }, + }) + expect(toolPairingBalancedAfter(session, nodes[2]!)).toBe(true) + expect(eventCollectionReads).toBe(1) + expect(eventIndexReads).toBe(3) + + events.push({ + type: 'user/message', seq: 4, time: 4, + data: { content: [{ type: 'text', text: 'tail' }], source: { kind: 'user' } }, + surfaceOp: 'append', + }) + nodes.push({ seq: 4, prev: 2, next: null }) + expect(toolPairingBalancedAfter(session, nodes[3]!)).toBe(true) + expect(eventCollectionReads).toBe(2) + expect(eventIndexReads).toBe(4) + + events.push( + { + type: 'assistant/message', seq: 5, time: 5, + data: { turn: 2, step: 1, content: [{ type: 'tool-call', id: CallId('c2'), name: 'two', arguments: '{}' }] }, + surfaceOp: 'append', + }, + { + type: 'tool/result', seq: 6, time: 6, + data: { turn: 2, step: 1, callId: CallId('c2'), content: [], isError: false }, + surfaceOp: 'append', + }, + ) + nodes.push( + { seq: 5, prev: 4, next: 6 }, + { seq: 6, prev: 5, next: null }, + ) + expect(toolPairingBalancedAfter(session, nodes[5]!)).toBe(true) + expect(eventCollectionReads).toBe(3) + expect(eventIndexReads).toBe(6) + + events.push({ + type: 'user/message', seq: 7, time: 7, + data: { content: [{ type: 'text', text: 'replacement' }], source: { kind: 'user' } }, + surfaceOp: { op: 'replace', start: 0, end: 6 }, + }) + nodes.splice(0, nodes.length, { seq: 7, prev: null, next: null }) + generation += 1 + expect(toolPairingBalancedAfter(session, nodes[0]!)).toBe(true) + expect(eventCollectionReads).toBe(4) + expect(eventIndexReads).toBe(7) + }) + + it('rebuilds defensively when a same-generation surface node count regresses', () => { + const events: SessionEvent[] = [ + { + type: 'user/message', seq: 0, time: 0, + data: { content: [], source: { kind: 'user' } }, surfaceOp: 'append', + }, + { + type: 'user/message', seq: 1, time: 1, + data: { content: [], source: { kind: 'user' } }, surfaceOp: 'append', + }, + ] + const nodes: SurfaceNode[] = [ + { seq: 0, prev: null, next: 1 }, + { seq: 1, prev: 0, next: null }, + ] + const session = { + events, + surface: { nodes, replaceGeneration: 0 }, + } as unknown as Session + expect(toolPairingBalancedAfter(session, nodes[1]!)).toBe(true) + nodes.pop() + expect(toolPairingBalancedAfter(session, nodes[0]!)).toBe(true) + }) +}) + +describe('tool-pairing corrupt surfaces', () => { + it('throws for an orphan result during a rebuild', () => { + const session = new Session(SessionId('orphan-rebuild')) + session.append('tool/result', { + turn: 1, step: 1, callId: CallId('orphan'), content: [], isError: false, + }, SURFACE) + expect(() => toolPairingBalancedAfter(session, session.surface.nodes[0]!)).toThrow(/no matching tool-call/) + }) + + it('retries an orphan result in an appended tail without committing partial cache state', () => { + const session = new Session(SessionId('orphan-tail')) + session.append('user/message', { + content: [{ type: 'text', text: 'safe head' }], source: { kind: 'user' }, + }, SURFACE) + expect(toolPairingBalancedAfter(session, session.surface.nodes[0]!)).toBe(true) + session.append('tool/result', { + turn: 1, step: 1, callId: CallId('orphan'), content: [], isError: false, + }, SURFACE) + expect(() => toolPairingBalancedAfter(session, session.surface.nodes[1]!)).toThrow(/no matching tool-call/) + expect(() => toolPairingBalancedAfter(session, session.surface.nodes[1]!)).toThrow(/no matching tool-call/) + }) + + it('throws when a current surface seq has no matching event or indexes the wrong event', () => { + const missingNode: SurfaceNode = { seq: 1, prev: null, next: null } + const missing = { + events: [{ + type: 'user/message', seq: 0, time: 0, + data: { content: [], source: { kind: 'user' } }, surfaceOp: 'append', + } satisfies SessionEvent], + surface: { nodes: [missingNode], replaceGeneration: 0 }, + } as unknown as Session + expect(() => toolPairingBalancedBefore(missing, missingNode)).toThrow(/no matching session event/) + + const mismatchedNode: SurfaceNode = { seq: 0, prev: null, next: null } + const mismatched = { + events: [{ + type: 'user/message', seq: 99, time: 0, + data: { content: [], source: { kind: 'user' } }, surfaceOp: 'append', + } satisfies SessionEvent], + surface: { nodes: [mismatchedNode], replaceGeneration: 0 }, + } as unknown as Session + expect(() => toolPairingBalancedBefore(mismatched, mismatchedNode)).toThrow(/no matching session event/) + }) +}) diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 7e201ab647..2540f988c7 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -77,7 +77,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata) - Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (`SessionHeader`, `session.header`) is what such a backend stores beside the log. - Replay/fork: `create(id, { seed })` validates and freezes a contiguous log and rebuilds its surface. `fork(source, boundary?, childSessionId?)` selects a completed-turn prefix and records lineage. -- Compaction: the `dsh-compact-basic` plugin appends a `user/message` with `surfaceOp: { op: 'replace', start, end }` to shadow old surface nodes behind a summary checkpoint. +- Compaction: the `dsh-compact-basic` plugin appends a `user/message` with `surfaceOp: { op: 'replace', start, end }` to shadow old surface nodes behind a summary checkpoint. Tool-pairing boundary policy and its cache belong to the [`dsh-compact` seam](../../compact/compact/README.md), while this package owns surface membership, positional links, and `replaceGeneration`. ## Model Experience diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 89f1627445..c3c99ae885 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -24,7 +24,6 @@ export type { JsonValue } from './json.ts' export { interruptedTurnClosers } from './repair.ts' export type { SurfaceFoldReplacement, SurfaceFoldResult, SurfaceNode } from './surface.ts' export { foldSurface, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts' -export { isToolPairingBalanced } from './tool-pairing.ts' export { applyHeaderDelta, canonicalHeader, diffHeader, foldRequestHeader, headerEquals } from './request-header.ts' declare module 'cordis' { diff --git a/packages/core/session/src/tool-pairing.ts b/packages/core/session/src/tool-pairing.ts deleted file mode 100644 index ce9dc639c7..0000000000 --- a/packages/core/session/src/tool-pairing.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Tool-pairing balance over a session surface. Compaction changes surface - * positions, so safe cuts are derived from tool-call/result content on the - * surface rather than step markers in the append-only log. - * @module @deepseek-ai/dsh-session/tool-pairing - */ - -import type { SessionEvent } from './types.ts' -import type { SurfaceNode } from './surface.ts' - -/** - * The tool-pairing delta of a surface node: how it shifts the count of - * unanswered tool calls. An `assistant/message` opens one bracket per - * `tool-call` block; a `tool/result` closes one; every other surface node - * (`user/message`, `context/message`, `steering/message`, a usage-only - * `assistant/message` with no tool-call blocks) is pairing-neutral. - */ -function nodeDelta(event: SessionEvent): number { - switch (event.type) { - case 'assistant/message': - return event.data.content.filter(block => block.type === 'tool-call').length - case 'tool/result': - return -1 - // Non-pairing surface nodes and every non-surface event contribute nothing. - default: - return 0 - } -} - -/** - * Check that a surface cut does not split a tool call from its result. A region - * is safe to collapse only when the cuts before its first node and after its - * last node both return `true`. - * @param nodes - the surface linked list in head→tail order. - * @param events - the session log each node's `seq` indexes into. - * @param beforeSeq - node immediately after the cut; `null` or a seq absent from the surface means after-tail. - * @returns whether every call before the cut has its result before the cut. - * @throws if a result appears without a preceding open call. - */ -export function isToolPairingBalanced( - nodes: readonly SurfaceNode[], - events: readonly SessionEvent[], - beforeSeq: number | null, -): boolean { - let depth = 0 - for (const node of nodes) { - if (node.seq === beforeSeq) return depth === 0 - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - depth += nodeDelta(events[node.seq]!) - if (depth < 0) { - throw new Error(`tool-pairing balance: tool/result at surface seq ${node.seq} has no matching tool-call (corrupt surface)`) - } - } - // A missing cut node means the after-tail boundary. - return depth === 0 -} diff --git a/packages/core/session/tests/tool-pairing.spec.ts b/packages/core/session/tests/tool-pairing.spec.ts deleted file mode 100644 index eb7b1a6203..0000000000 --- a/packages/core/session/tests/tool-pairing.spec.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { CallId } from '@deepseek-ai/dsh-llm' -import { Session, SessionId, isToolPairingBalanced } from '../src/index.ts' -import type { SessionEvent, SurfaceNode } from '../src/index.ts' - -/** - * Unit coverage for compaction-cut safety: a cut is balanced only when it - * separates no assistant tool call from its result. Non-step nodes are neutral, - * and replace operations prove surface order—not raw log order—is authoritative. - */ - -const SURFACE = { surfaceOp: 'append' as const } - -/** Surface nodes + log for a session, the two args the balance check takes. */ -function surfaceOf(session: Session): { nodes: readonly SurfaceNode[]; events: readonly SessionEvent[] } { - return { nodes: session.surface.nodes, events: session.events } -} - -/** The cut BEFORE the surface node at `seq` is balanced (safe region start). */ -function startBalanced(session: Session, seq: number): boolean { - const { nodes, events } = surfaceOf(session) - return isToolPairingBalanced(nodes, events, seq) -} - -/** The cut AFTER the surface node at `seq` is balanced (safe region end). */ -function endBalanced(session: Session, seq: number): boolean { - const { nodes, events } = surfaceOf(session) - const node = nodes.find(n => n.seq === seq) - if (!node) throw new Error(`seq ${seq} is not a surface node`) - return isToolPairingBalanced(nodes, events, node.next) -} - -/** Surface seq of the nth (0-based) event of a given type. */ -function seqOf(s: Session, type: SessionEvent['type'], nth = 0): number { - return s.events.filter(e => e.type === type)[nth]!.seq -} - -/** A closed turn with one closed step holding an assistant + its tool result. */ -function toolStepSession(): Session { - const s = new Session(SessionId('tool-step')) - s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - s.append('user/message', { content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }, SURFACE) - s.append('step/start', { turn: 1, step: 1 }) - s.append('assistant/message', { - turn: 1, step: 1, - content: [ - { type: 'text', text: 'calling' }, - { type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }, - ], - }, SURFACE) - s.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: '{}' }) - s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }, SURFACE) - s.append('step/end', { turn: 1, step: 1 }) - s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - return s -} - -describe('isToolPairingBalanced — region START (cut before a node)', () => { - it('is true for a pre-step user/message (belongs to no step)', () => { - const s = toolStepSession() - expect(startBalanced(s, seqOf(s, 'user/message'))).toBe(true) - }) - - it('is true for the first surface node of a step (the assistant/message)', () => { - // The cut before the assistant is balanced — nothing unanswered precedes it. - const s = toolStepSession() - expect(startBalanced(s, seqOf(s, 'assistant/message'))).toBe(true) - }) - - it('is false for a tool/result whose assistant/message precedes it in the same step', () => { - // The cut before the tool/result has one unanswered tool-call (the - // assistant's) → starting the region here would orphan that call. - const s = toolStepSession() - expect(startBalanced(s, seqOf(s, 'tool/result'))).toBe(false) - }) - - it('is true at the surface head (nothing precedes)', () => { - const s = new Session(SessionId('lone')) - s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - s.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, SURFACE) - expect(startBalanced(s, seqOf(s, 'user/message'))).toBe(true) - }) -}) - -describe('isToolPairingBalanced — region END (cut after a node)', () => { - it('is true for the last surface node of a closed step (the tool/result)', () => { - // After the tool/result the assistant's single call is answered → balanced. - const s = toolStepSession() - expect(endBalanced(s, seqOf(s, 'tool/result'))).toBe(true) - }) - - it('is false for an assistant/message with a later tool/result in the same step', () => { - // After the assistant its tool-call is still unanswered → ending here strands - // the result. - const s = toolStepSession() - expect(endBalanced(s, seqOf(s, 'assistant/message'))).toBe(false) - }) - - it('is true for a pre-step user/message', () => { - const s = toolStepSession() - expect(endBalanced(s, seqOf(s, 'user/message'))).toBe(true) - }) - - it('is false at the tail when the node is inside an open (unclosed) step', () => { - // step/start then an assistant tool-call, but no tool/result yet (mid-flight). - // The after-tail cut still has one unanswered call → not balanced. - const s = new Session(SessionId('open-step')) - s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - s.append('step/start', { turn: 1, step: 1 }) - s.append('assistant/message', { - turn: 1, step: 1, - content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }], - }, SURFACE) - expect(endBalanced(s, seqOf(s, 'assistant/message'))).toBe(false) - }) - - it('is true at the tail when the node is a trailing inter-step node (step already closed)', () => { - // A steering message appended after step/end, at the tail. The prior step's - // pair is balanced and steering is neutral → the after-tail cut is balanced. - const s = new Session(SessionId('trailing-steer')) - s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - s.append('step/start', { turn: 1, step: 1 }) - s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] }, SURFACE) - s.append('step/end', { turn: 1, step: 1 }) - s.append('steering/message', { turn: 1, content: [{ type: 'text', text: 's' }], source: { kind: 'user' } }, SURFACE) - expect(endBalanced(s, seqOf(s, 'steering/message'))).toBe(true) - }) - - it('is true at the tail when no step ever opened', () => { - const s = new Session(SessionId('no-step')) - s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - s.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, SURFACE) - expect(endBalanced(s, seqOf(s, 'user/message'))).toBe(true) - }) -}) - -describe('isToolPairingBalanced — multiple tool calls in one assistant message', () => { - // An assistant message with two tool-calls needs BOTH results before the cut - // after it is balanced — depth +2, then -1, -1. - function twoCallStep(): Session { - const s = new Session(SessionId('two-call')) - s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - s.append('step/start', { turn: 1, step: 1 }) - s.append('assistant/message', { - turn: 1, step: 1, - content: [ - { type: 'tool-call', id: CallId('c1'), name: 'a', arguments: '{}' }, - { type: 'tool-call', id: CallId('c2'), name: 'b', arguments: '{}' }, - ], - }, SURFACE) - s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: '1' }], isError: false }, SURFACE) - s.append('tool/result', { turn: 1, step: 1, callId: CallId('c2'), content: [{ type: 'text', text: '2' }], isError: false }, SURFACE) - s.append('step/end', { turn: 1, step: 1 }) - s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - return s - } - - it('is unbalanced after the first of two results (one call still open)', () => { - const s = twoCallStep() - expect(endBalanced(s, seqOf(s, 'tool/result', 0))).toBe(false) - }) - - it('is balanced after the second result (both calls answered)', () => { - const s = twoCallStep() - expect(endBalanced(s, seqOf(s, 'tool/result', 1))).toBe(true) - }) -}) - -describe('isToolPairingBalanced — a mid-step injection context/message', () => { - // The injected context is pairing-neutral, but both adjacent cuts remain - // unbalanced because the tool call is still open across them. - function midStepInjection(): Session { - const s = new Session(SessionId('mid-inject')) - s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - s.append('step/start', { turn: 1, step: 1 }) - s.append('assistant/message', { - turn: 1, step: 1, - content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }], - }, SURFACE) - s.append('context/message', { content: [{ type: 'text', text: 'bg task done' }], source: { kind: 'plugin', plugin: 'tool-bash' } }, SURFACE) - s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }, SURFACE) - s.append('step/end', { turn: 1, step: 1 }) - s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - return s - } - - it('start cut before the mid-step context/message is unbalanced (call still open)', () => { - const s = midStepInjection() - expect(startBalanced(s, seqOf(s, 'context/message'))).toBe(false) - }) - - it('end cut after the mid-step context/message is unbalanced (call still open)', () => { - const s = midStepInjection() - expect(endBalanced(s, seqOf(s, 'context/message'))).toBe(false) - }) -}) - -describe('isToolPairingBalanced on an injection turn (no step)', () => { - // An idle inject() wraps a context/message in a bare turn/start → - // context/message → turn/end with NO step. The context node is a free boundary - // both ways (pairing-neutral, nothing open around it). - function injectionSession(): Session { - const s = new Session(SessionId('injection')) - s.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: { kind: 'user' } } }) - s.append('context/message', { content: [{ type: 'text', text: 'ctx' }], source: { kind: 'user' } }, SURFACE) - s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - return s - } - - it('start: balanced', () => { - const s = injectionSession() - expect(startBalanced(s, seqOf(s, 'context/message'))).toBe(true) - }) - - it('end: balanced', () => { - const s = injectionSession() - expect(endBalanced(s, seqOf(s, 'context/message'))).toBe(true) - }) -}) - -describe('isToolPairingBalanced — CBR-001: a head checkpoint left by a replace op', () => { - // A replacement checkpoint has a high log seq but sits at the surface head; - // its cuts are balanced regardless of later raw-log neighbors. - function checkpointHeadedSession(): Session { - const s = new Session(SessionId('checkpoint')) - // A closed turn with a tool step → surface [u1, asst(call), result]. - s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - s.append('step/start', { turn: 1, step: 1 }) - s.append('user/message', { content: [{ type: 'text', text: 'u1' }], source: { kind: 'user' } }, SURFACE) - s.append('assistant/message', { - turn: 1, step: 1, - content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }], - }, SURFACE) - s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }, SURFACE) - s.append('step/end', { turn: 1, step: 1 }) - s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - // An OPEN turn whose step is in progress (loop fires compaction here). - s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) - s.append('step/start', { turn: 2, step: 1 }) - // Compaction replaces the whole turn-1 surface ([u1, asst, result]) with one - // summary user/message — appended now, so it carries a high log seq. - const u1 = seqOf(s, 'user/message') - const result = s.events.find(e => e.type === 'tool/result')!.seq - s.append('user/message', { - content: [{ type: 'text', text: 'CHECKPOINT' }], - source: { kind: 'plugin', plugin: 'compact' }, - }, { surfaceOp: { op: 'replace', start: u1, end: result } }) - // The step's own assistant/message lands AFTER the checkpoint in the log, - // still inside the open step. - s.append('assistant/message', { turn: 2, step: 1, content: [{ type: 'text', text: 'a2' }] }, SURFACE) - return s - } - - it('the head checkpoint sits at the surface head while a later surface node follows it in the log', () => { - const s = checkpointHeadedSession() - const nodes = s.surface.nodes - const checkpointSeq = nodes[0]!.seq - // The checkpoint heads the surface, yet a surface node (the open step's - // assistant) follows it in LOG order — the exact split between surface - // position and log position that the log-position scan tripped on. - const laterSurfaceInLog = s.events.find( - e => e.seq > checkpointSeq && nodes.some(n => n.seq === e.seq), - ) - expect(laterSurfaceInLog).toBeDefined() - expect(nodes[0]!.seq).toBe(checkpointSeq) - }) - - it('start cut before the head checkpoint is balanced (it is the head)', () => { - const s = checkpointHeadedSession() - expect(startBalanced(s, s.surface.nodes[0]!.seq)).toBe(true) - }) - - it('end cut after the head checkpoint is balanced (it carries no tool pair)', () => { - // This is the exact assertion the log-position scan failed: the forward log scan from the - // checkpoint reached the open step's assistant/message and wrongly reported mid-step. - const s = checkpointHeadedSession() - expect(endBalanced(s, s.surface.nodes[0]!.seq)).toBe(true) - }) -}) - -describe('isToolPairingBalanced — corrupt surface guard', () => { - it('throws when a tool/result has no preceding tool-call (depth goes negative)', () => { - // A surface that opens with a tool/result (no assistant call before it) is - // structurally corrupt — surfaced loudly rather than mis-classified. - const s = new Session(SessionId('corrupt')) - s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - s.append('step/start', { turn: 1, step: 1 }) - s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'x' }], isError: false }, SURFACE) - const { nodes, events } = surfaceOf(s) - expect(() => isToolPairingBalanced(nodes, events, null)).toThrow(/no matching tool-call/) - }) -}) From 2793325df0b0bc0354068b1dbedb4751edce04da Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 16 Jul 2026 11:40:00 +0800 Subject: [PATCH 03/14] refactor(compact): store tool-pairing balance once per surface cut toolPairingBalancedAfter previously answered by resolving a cached positional successor and reading its before-balance, with a null-successor depth fallback. Both queries are the same prefix property sampled at adjacent cuts, so the cache now holds one per-cut balance sequence (N nodes -> N+1 cuts) plus a seq->position index; before/after differ only by a cut offset. The successor map, the duplicate rebuild/extend fold loops, and the non-null assertion are gone, and the running counter is named inProgressToolCalls. Docs describing the successor mechanism are updated in place. --- docs/core-data-structures/session.md | 2 +- .../2026-06-18-compaction-capability-seam.md | 4 +- ...-12-simplify-session-log-representation.md | 2 +- packages/compact/compact/README.md | 4 +- packages/compact/compact/src/tool-pairing.ts | 116 +++++++----------- .../compact/tests/tool-pairing.spec.ts | 2 +- 6 files changed, 51 insertions(+), 79 deletions(-) diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 174f06602e..616370fb84 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -196,7 +196,7 @@ export interface SurfaceNode { } ``` -`SurfaceNode` is positional state, not durable identity. A replacement can remove a caller-retained node or make a copied `next` stale; consumers that cross a surface mutation validate membership and resolve successors from `Session.surface.nodes`. `SurfaceManager.replaceGeneration` increments for each replacement so incremental consumers can distinguish pure tail growth from a rewrite. +`SurfaceNode` is positional state, not durable identity. A replacement can remove a caller-retained node or make a copied `next` stale; consumers that cross a surface mutation validate membership and answer positional queries from `Session.surface.nodes`. `SurfaceManager.replaceGeneration` increments for each replacement so incremental consumers can distinguish pure tail growth from a rewrite. ### `SurfaceFoldReplacement` and `SurfaceFoldResult` — a complete surface replay diff --git a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md index 29e3347759..75eaf3207f 100644 --- a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -52,7 +52,7 @@ The loop derives messages once after `agent/pre-step`. Running before `step/star Auto-compaction fires before **every** step, not once per turn. This is **load-bearing for runaway-turn survival**: a tool-heavy ReAct turn appends an `assistant/message` + a `tool/result` per step, so the surface grows *within* a turn. A single turn can grow past the window on its own (a "runaway turn") — and the only moment to rescue it before the next model call overflows is the next step's `pre-step` checkpoint. Gating compaction to a turn's first step (or, worse, retaining the whole in-flight turn verbatim) re-opens exactly the hole compaction exists to close: the harness would die when compaction is most needed. -`compactIfNeeded` retains the smallest tail of whole surface units whose estimated size reaches `retainTokens` and compacts older nodes. A unit is a complete closed step or one no-step message. If the token cutoff lands inside a step, retention expands until the cut is tool-pairing balanced. Balance is checked on surface order, not log sequence, because replacement summaries have new sequence numbers at old surface positions. `dsh-compact` exports the before/after edge helpers; their per-session cache folds only appended surface-tail nodes while `replaceGeneration` is unchanged, does no event reads for log-only growth, and rebuilds current membership, positional successors, and balances after replacement. `compactRegion` rejects boundaries that split a tool call from its result. The in-flight turn receives no special retention. +`compactIfNeeded` retains the smallest tail of whole surface units whose estimated size reaches `retainTokens` and compacts older nodes. A unit is a complete closed step or one no-step message. If the token cutoff lands inside a step, retention expands until the cut is tool-pairing balanced. Balance is checked on surface order, not log sequence, because replacement summaries have new sequence numbers at old surface positions. `dsh-compact` exports the before/after edge helpers; their per-session cache folds only appended surface-tail nodes while `replaceGeneration` is unchanged, does no event reads for log-only growth, and rebuilds current membership and balances after replacement. `compactRegion` rejects boundaries that split a tool call from its result. The in-flight turn receives no special retention. A runaway turn thus compacts exactly like any other history: its early *closed* steps get summarized while its recent steps stay verbatim. When the only compactable content left is an un-splittable open tail step (its tool-calls have no results yet), compaction declines (`null`) and retries once that step closes. @@ -113,7 +113,7 @@ Two failure paths, both documented: - **New packages**: `packages/compact/compact` (interface) and a sibling `compact-basic` (backend) under `packages/compact/`, wired into the root tsconfigs. The consumer tier is deferred. - **New loop seam**: `agent/pre-step` (`@mode serial`) declared in `dsh-agent` and emitted by `dsh-agent-loop` after system assembly and before `step/start`. This is a documented change to the loop — `docs/architecture.md` records it and the generated cordis catalog carries its signature. - **`SessionEventMap`** gains `compact/start` / `compact/summary` / `compact/end` by declaration merging (merge-extensible); `SurfaceEventType` is **not** touched. These are session events, not cordis `Events`, so the event-taxonomy gate needs no entry. -- **`dsh-compact`** owns `toolPairingBalancedBefore(session, node)` and `toolPairingBalancedAfter(session, node)`, the cached surface-edge checks that `compactRegion` and `compactIfNeeded` use to avoid splitting a tool-call/result pair. The cache validates current membership by seq and resolves after-edges from its positional successor map instead of trusting a caller-retained `node.next`; stale or missing seqs and orphan results reject. `dsh-session` continues to own the surface `replace` operation, positional nodes, and rewrite generation. +- **`dsh-compact`** owns `toolPairingBalancedBefore(session, node)` and `toolPairingBalancedAfter(session, node)`, the cached surface-edge checks that `compactRegion` and `compactIfNeeded` use to avoid splitting a tool-call/result pair. The cache validates current membership by seq and answers both edges from one per-cut balance sequence instead of trusting a caller-retained `node.next`; stale or missing seqs and orphan results reject. `dsh-session` continues to own the surface `replace` operation, positional nodes, and rewrite generation. - **`dsh-invariants`** drops its `surface replace: start must be <= end` assertion: a head-anchored compaction lands a high-seq replacement node at an older range's *position*, so `start > end` numerically is normal and valid (the range is positional, validated by the surface's `indexOf` checks that remain). The turn-enclosure invariant is reused unchanged. - **Wiring**: `dsh-compact-basic` is loaded in `examples/coding-agent`'s `cordis.yml`, so the seam ships in the real demo (it was previously loaded nowhere). diff --git a/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.md b/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.md index 715ce93924..1cb8d5708a 100644 --- a/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.md +++ b/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.md @@ -6,7 +6,7 @@ Status: proposed The session log maintains two representations that cost more machinery than their consumers require: a pseudo-linked surface and custom request-header deltas. -`SurfaceManager` stores the same order in an array, a seq map, and mutable `prev`/`next` links. Production never reads `prev`; compact's sole `next` read is the successor of an array position. Replacement already uses `indexOf`, so the links do not make its dominant operation constant-time. A seq array with linear replacement lookup has the same asymptotic replacement cost and one representation to validate. +`SurfaceManager` stores the same order in an array, a seq map, and mutable `prev`/`next` links. Production never reads either link: compact's tool-pairing balance answers from per-cut balances cached in surface order. Replacement already uses `indexOf`, so the links do not make its dominant operation constant-time. A seq array with linear replacement lookup has the same asymptotic replacement cost and one representation to validate. The request-header subsystem implements a custom system/tool delta codec and transmission-decision layer even though its contract says deltas are an encoding optimization, not a reconstructability requirement. Retaining the initial/resume full snapshot at each loop-instance boundary, then writing a canonical full `request/header` whenever that instance's assembled header changes, preserves replay while deleting `SystemDelta`, `ToolsDelta`, round-trip fallback, and the durable `request/header-delta` variant. Codec-only vocabulary disappears with the codec, not because its individual arms were invalid. diff --git a/packages/compact/compact/README.md b/packages/compact/compact/README.md index 5b59893561..ef8c300856 100644 --- a/packages/compact/compact/README.md +++ b/packages/compact/compact/README.md @@ -25,9 +25,9 @@ Both methods are **abstract** — the backend owns the entire strategy (token es ## Tool-pairing boundaries -The interface exports `toolPairingBalancedBefore(session, node)` and `toolPairingBalancedAfter(session, node)` for snapping and validating compaction edges. A safe edge has no unanswered assistant tool call crossing it. Each helper validates the node's seq against current surface membership and resolves the trailing edge from its cached positional successor, so a stale caller-held `node.next` cannot choose the cut. +The interface exports `toolPairingBalancedBefore(session, node)` and `toolPairingBalancedAfter(session, node)` for snapping and validating compaction edges. A safe edge has no unanswered assistant tool call crossing it. Each helper identifies the node by seq alone and answers from balances cached per cut in current surface order, so a stale caller-held `node.next` cannot choose the cut. -The private per-session cache is keyed by `session.surface.replaceGeneration` and the processed surface-node count. An unchanged generation extends the fold with unseen tail nodes only; a log-only append with no new surface node does no event reads, while a replacement generation rebuilds current membership, successors, and balances. Missing event seqs and a `tool/result` without a preceding open call reject as corrupt surface state. +The private per-session cache is keyed by `session.surface.replaceGeneration` and the processed surface-node count. An unchanged generation extends the fold with unseen tail nodes only; a log-only append with no new surface node does no event reads, while a replacement generation rebuilds current membership and balances. Missing event seqs and a `tool/result` without a preceding open call reject as corrupt surface state. ## Surface contract diff --git a/packages/compact/compact/src/tool-pairing.ts b/packages/compact/compact/src/tool-pairing.ts index a9fca01bf6..0fc0f68dc8 100644 --- a/packages/compact/compact/src/tool-pairing.ts +++ b/packages/compact/compact/src/tool-pairing.ts @@ -12,19 +12,21 @@ import type { Session, SessionEvent, SurfaceNode } from '@deepseek-ai/dsh-sessio interface BalanceCache { /** Surface rewrite generation this state describes. */ generation: number - /** Number of surface nodes already folded into the state. */ - processedNodes: number - /** Balance of the cut immediately before each current surface node. */ - beforeSeq: Map - /** Current positional successor of each surface node. */ - successorBySeq: Map - /** Unanswered tool-call count after the processed surface tail. */ - depth: number + /** + * Balance of every surface cut in current order: a surface of N nodes has + * N + 1 cuts, entry `i` being the cut before node `i` and the final entry + * the cut after the surface tail. + */ + cutBalanced: readonly boolean[] + /** Current surface position of each node seq, indexing {@link cutBalanced}. */ + indexBySeq: Map + /** In-progress tool-call count after the processed surface tail. */ + inProgressToolCalls: number } const balanceCacheBySession = new WeakMap() -/** Return how one surface event changes the unanswered tool-call count. */ +/** Return how one surface event changes the in-progress tool-call count. */ function nodeDelta(event: SessionEvent): number { switch (event.type) { case 'assistant/message': @@ -45,61 +47,30 @@ function eventForNode(events: readonly SessionEvent[], node: SurfaceNode): Sessi return event } -/** Build balance state for a complete current surface. */ -function rebuildCache( - session: Session, - nodes: readonly SurfaceNode[], - generation: number, -): BalanceCache { - const beforeSeq = new Map() - const successorBySeq = new Map() - const events = session.events - let depth = 0 - let previousSeq: number | undefined - - for (const node of nodes) { - beforeSeq.set(node.seq, depth === 0) - successorBySeq.set(node.seq, null) - if (previousSeq !== undefined) successorBySeq.set(previousSeq, node.seq) - depth += nodeDelta(eventForNode(events, node)) - if (depth < 0) { - throw new Error(`tool-pairing balance: tool/result at surface seq ${node.seq} has no matching tool-call (corrupt surface)`) - } - previousSeq = node.seq - } - - return { generation, processedNodes: nodes.length, beforeSeq, successorBySeq, depth } -} - -/** Fold a pure surface tail append into existing balance state. */ +/** Fold surface nodes not yet in the cache into its balance state. */ function extendCache( session: Session, cache: BalanceCache, nodes: readonly SurfaceNode[], ): BalanceCache { - const tail = nodes.slice(cache.processedNodes) + const processed = cache.cutBalanced.length - 1 + const tail = nodes.slice(processed) // Validate the unseen tail before mutating the live cache, so a corrupt // append cannot leave a partially advanced state behind. const events = session.events - const pending: Array<{ seq: number; before: boolean }> = [] - let depth = cache.depth + const pendingCuts: boolean[] = [] + let inProgressToolCalls = cache.inProgressToolCalls for (const node of tail) { - pending.push({ seq: node.seq, before: depth === 0 }) - depth += nodeDelta(eventForNode(events, node)) - if (depth < 0) { + inProgressToolCalls += nodeDelta(eventForNode(events, node)) + if (inProgressToolCalls < 0) { throw new Error(`tool-pairing balance: tool/result at surface seq ${node.seq} has no matching tool-call (corrupt surface)`) } + pendingCuts.push(inProgressToolCalls === 0) } - let previousSeq = nodes[cache.processedNodes - 1]?.seq - for (const entry of pending) { - if (previousSeq !== undefined) cache.successorBySeq.set(previousSeq, entry.seq) - cache.beforeSeq.set(entry.seq, entry.before) - cache.successorBySeq.set(entry.seq, null) - previousSeq = entry.seq - } - cache.processedNodes = nodes.length - cache.depth = depth + tail.forEach((node, offset) => cache.indexBySeq.set(node.seq, processed + offset)) + cache.cutBalanced = cache.cutBalanced.concat(pendingCuts) + cache.inProgressToolCalls = inProgressToolCalls return cache } @@ -110,15 +81,32 @@ function balanceCache(session: Session): BalanceCache { const generation = surface.replaceGeneration const cached = balanceCacheBySession.get(session) - if (cached === undefined || cached.generation !== generation || cached.processedNodes > nodes.length) { - const rebuilt = rebuildCache(session, nodes, generation) + if (cached === undefined || cached.generation !== generation || cached.cutBalanced.length - 1 > nodes.length) { + // A rebuild is the same fold started from the empty-surface state, whose + // single leading cut is trivially balanced. + const rebuilt = extendCache(session, { + generation, + cutBalanced: [true], + indexBySeq: new Map(), + inProgressToolCalls: 0, + }, nodes) balanceCacheBySession.set(session, rebuilt) return rebuilt } - if (cached.processedNodes < nodes.length) return extendCache(session, cached, nodes) + if (cached.cutBalanced.length - 1 < nodes.length) return extendCache(session, cached, nodes) return cached } +/** Balance of the cut at a node's position plus offset, rejecting seqs outside current membership. */ +function cutBalance(cache: BalanceCache, seq: number, offset: 0 | 1): boolean { + const index = cache.indexBySeq.get(seq) + const balanced = index === undefined ? undefined : cache.cutBalanced[index + offset] + if (balanced === undefined) { + throw new Error(`tool-pairing balance: surface seq ${seq} not found`) + } + return balanced +} + /** * Whether the cut immediately before a current surface node is tool-pairing balanced. * @param session - session whose surface is checked. @@ -128,12 +116,7 @@ function balanceCache(session: Session): BalanceCache { * matching log event, or a tool result has no preceding open call. */ export function toolPairingBalancedBefore(session: Session, node: SurfaceNode): boolean { - const cache = balanceCache(session) - const balanced = cache.beforeSeq.get(node.seq) - if (balanced === undefined) { - throw new Error(`tool-pairing balance: surface seq ${node.seq} not found`) - } - return balanced + return cutBalance(balanceCache(session), node.seq, 0) } /** @@ -145,16 +128,5 @@ export function toolPairingBalancedBefore(session: Session, node: SurfaceNode): * matching log event, or a tool result has no preceding open call. */ export function toolPairingBalancedAfter(session: Session, node: SurfaceNode): boolean { - const cache = balanceCache(session) - const successor = cache.successorBySeq.get(node.seq) - if (successor === undefined) { - throw new Error(`tool-pairing balance: surface seq ${node.seq} not found`) - } - if (successor === null) return cache.depth === 0 - // Current membership and positional successors are cache-owned. A caller may - // retain a node across surface changes, so its mutable-looking `next` field is - // never authoritative for this query. - // The successor map and balance map are committed together. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return cache.beforeSeq.get(successor)! + return cutBalance(balanceCache(session), node.seq, 1) } diff --git a/packages/compact/compact/tests/tool-pairing.spec.ts b/packages/compact/compact/tests/tool-pairing.spec.ts index dfde2bd2ba..bd74faeec5 100644 --- a/packages/compact/compact/tests/tool-pairing.spec.ts +++ b/packages/compact/compact/tests/tool-pairing.spec.ts @@ -131,7 +131,7 @@ describe('tool-pairing surface identity', () => { expect(() => toolPairingBalancedAfter(session, staleTail)).toThrow(/surface seq .* not found/) }) - it('uses the cached positional successor instead of a caller node next field', () => { + it('ignores a caller-held node next field and answers from cached balances', () => { const session = closedToolStep() const assistant = nodeAt(session, seqOf(session, 'assistant/message')) expect(toolPairingBalancedAfter(session, { ...assistant, next: null })).toBe(false) From 6ce9f16030299d5262f4a19865c7f718c11b606c Mon Sep 17 00:00:00 2001 From: lintianle Date: Thu, 16 Jul 2026 18:12:22 +0800 Subject: [PATCH 04/14] website: wire the site into the repo gates; make every tutorial example compile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - website joins the pnpm workspace; root scripts website:dev/website:build; run-gates gains a website-build gate (ci-primary + ci-static) — the VitePress build doubles as the site's dead-link check; AGENTS.md documents the commands. - doc-typecheck + verify-type-equiv now scan website/zh-CN/**/*.md; every ```typescript fence converted to ```ts and made standalone-compilable (55 compiled, 1 ignore-check). Phantom APIs the compiler caught are fixed: invented event names (agent/turn-end, tool/call, llm/pre-request, ready, dispose) replaced with real catalog events or per-plugin declare-module merges; presentCall/inject/Config claims corrected to the real shapes. - guide/config.md entry-fields table completed against loader EntryOptions; its coding-agent example brought in line with examples/coding-agent. --- AGENTS.md | 3 + knip.json | 2 +- package.json | 7 +- pnpm-lock.yaml | 1448 +++++++++++++++++ pnpm-workspace.yaml | 1 + scripts/doc-typecheck.ts | 5 +- scripts/run-gates.ts | 2 + scripts/verify-type-equiv.ts | 2 +- website/zh-CN/design/composability.md | 11 +- website/zh-CN/design/context-model.md | 29 +- website/zh-CN/design/reactive-coeffects.md | 16 +- website/zh-CN/design/revertible-effects.md | 17 +- website/zh-CN/develop/basic/config.md | 54 +- website/zh-CN/develop/basic/index.md | 45 +- website/zh-CN/develop/basic/tool.md | 115 +- website/zh-CN/develop/framework/events.md | 150 +- website/zh-CN/develop/framework/index.md | 54 +- website/zh-CN/develop/framework/service.md | 62 +- website/zh-CN/develop/practice/index.md | 6 +- website/zh-CN/develop/practice/llm-adapter.md | 129 +- website/zh-CN/guide/config.md | 18 +- 21 files changed, 1949 insertions(+), 227 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 20abf56c87..70bd5b3ddf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,7 @@ packages/ Harness packages at packages///, all named @deepseek-ai examples/ Runnable demos: thin cordis.yml leaves over the app packages (see examples/AGENTS.md) docs/ architecture, generated catalogs, RFCs, postmortems, cookbook (see docs/AGENTS.md) scripts/ repo gates and generators +website/ VitePress docs site (zh-CN) ``` Per-package map: the group READMEs, indexed from [packages/README.md](packages/README.md). @@ -48,6 +49,7 @@ pnpm run lint pnpm run build # tsc emits lib/types, tsdown bundles runtime pnpm run hygiene # knip + publint + workspace constraints + NodeNext consumer check pnpm run doc-sync # all documentation gates; see the doc-sync script in package.json +pnpm run website:build # VitePress build (doubles as the site's dead-link check) pnpm run demo:echo # mock-model REPL, no key needed pnpm run demo:repl # real REPL coding agent (needs DEEPSEEK_API_KEY) pnpm run demo:cordis # self-referential demo: the agent modifies its own runtime (needs key) @@ -65,6 +67,7 @@ pnpm run lint pnpm run test:coverage pnpm run test:snapshot pnpm run doc-sync +pnpm run website:build pnpm run verify-module-graph pnpm run build pnpm run hygiene diff --git a/knip.json b/knip.json index 1c5b0f6b6f..6ddfa29130 100644 --- a/knip.json +++ b/knip.json @@ -1,7 +1,7 @@ { "$schema": "https://unpkg.com/knip@5/schema.json", "exclude": ["duplicates"], - "ignoreWorkspaces": ["vendor/*"], + "ignoreWorkspaces": ["vendor/*", "website"], "workspaces": { ".": { "entry": [ diff --git a/package.json b/package.json index c4cc307893..588346fc0f 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ }, "workspaces": [ "vendor/*", - "packages/*/*" + "packages/*/*", + "website" ], "scripts": { "build": "tsc -b tsconfig.build.json && tsdown", @@ -60,6 +61,8 @@ "verify-persistence-catalog": "tsx scripts/gen-persistence-catalog.ts --check", "gen-module-graph": "tsx scripts/gen-module-graph.ts", "verify-module-graph": "tsx scripts/gen-module-graph.ts --check", + "website:dev": "pnpm --filter @deepseek-ai/website run dev", + "website:build": "pnpm --filter @deepseek-ai/website run build", "constraints": "tsx scripts/check-workspace-constraints.ts", "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-cordis-api && pnpm run verify-export-jsdoc && pnpm run verify-tool-catalog && pnpm run verify-config-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-mermaid && pnpm run verify-rfc-classification && pnpm run verify-rfc-format && pnpm run verify-type-equiv && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets", "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types", @@ -74,12 +77,14 @@ "@agentclientprotocol/sdk": "0.25.1", "@stylistic/eslint-plugin": "^5.10.0", "@types/jsdom": "^28.0.3", + "@types/js-yaml": "^4.0.9", "@types/mdast": "^4.0.4", "@types/node": "^22.20.0", "@vitest/coverage-v8": "^4.1.8", "eslint": "^10.4.1", "fast-check": "^4.8.0", "jsdom": "29.1.1", + "js-yaml": "^4.1.0", "knip": "^6.16.1", "lefthook": "^2.1.9", "mdast-util-from-markdown": "^2.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4425c6bd41..52575ae3de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@stylistic/eslint-plugin': specifier: ^5.10.0 version: 5.10.0(eslint@10.5.0(jiti@2.7.0)) + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 '@types/jsdom': specifier: ^28.0.3 version: 28.0.3 @@ -32,6 +35,9 @@ importers: fast-check: specifier: ^4.8.0 version: 4.8.0 + js-yaml: + specifier: ^4.1.0 + version: 4.2.0 jsdom: specifier: 29.1.1 version: 29.1.1 @@ -1480,6 +1486,15 @@ importers: specifier: ^1.8.1 version: 1.8.1 + website: + devDependencies: + vitepress: + specifier: ^1.6.3 + version: 1.6.4(@algolia/client-search@5.55.2)(@types/node@25.9.3)(lightningcss@1.32.0)(postcss@8.5.15)(search-insights@2.17.3)(typescript@6.0.3) + vue: + specifier: ^3.5.13 + version: 3.5.39(typescript@6.0.3) + packages: '@agentclientprotocol/sdk@0.25.1': @@ -1487,6 +1502,82 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 + '@algolia/abtesting@1.21.2': + resolution: {integrity: sha512-uXj0rgk30EpsKvOpuS+R+1XFDrnm56hED1Lz56e8uBkZdKCxw99LS2U8eXBqAHYU8kpkbsnV1GC8velBG070Hg==} + engines: {node: '>= 14.0.0'} + + '@algolia/autocomplete-core@1.17.7': + resolution: {integrity: sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==} + + '@algolia/autocomplete-plugin-algolia-insights@1.17.7': + resolution: {integrity: sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==} + peerDependencies: + search-insights: '>= 1 < 3' + + '@algolia/autocomplete-preset-algolia@1.17.7': + resolution: {integrity: sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==} + peerDependencies: + '@algolia/client-search': '>= 4.9.1 < 6' + algoliasearch: '>= 4.9.1 < 6' + + '@algolia/autocomplete-shared@1.17.7': + resolution: {integrity: sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==} + peerDependencies: + '@algolia/client-search': '>= 4.9.1 < 6' + algoliasearch: '>= 4.9.1 < 6' + + '@algolia/client-abtesting@5.55.2': + resolution: {integrity: sha512-y7Epol8HcjlBxKXHhyhfFPFhm78B3P6x9cCbCyGTdxjsdVCptXCy5hpkZWxjGpnaLHvWsHS4QRF0TiBOLst2xg==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-analytics@5.55.2': + resolution: {integrity: sha512-8Pxj2VVmpM2d+UZufnlTq7T1QIcYPVugLV5XC50PnHsV5uRM9CSoYkg2Y+CwqwRk2La0xK5QsfZ0obIU+9XftQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-common@5.55.2': + resolution: {integrity: sha512-9L4IpIYUqA63a7sw1trnHQGUvwiAjKz67nsgDnal98JGAc7wyposRb0Iag+eiMuyzFFaSHLe2/rGyIo+PafRBA==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-insights@5.55.2': + resolution: {integrity: sha512-ZBm2ytY5EHFcj+kjNsXxMNO/TGlOHe2fBFXGKHJOM1bk1rAy4o2YI+d9oV/w/jrqx44pvJMJlc8X6vKnCuDgUQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-personalization@5.55.2': + resolution: {integrity: sha512-3FGVW/jDk7sdYwqa2NKnF/qXWcttc4bvGrwNbvqz3VoWSRv42CNvRk+3Y9QJFIUf1vY50hAuVWUoFKdyc8vaXA==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-query-suggestions@5.55.2': + resolution: {integrity: sha512-JsG8LovDAYul5t8e533tZ3O1uZILxso5zsTtB7ONc5RJ8ACdTxAAC/jaOnsBNYb+x+STP7fzx/Iro55v5DNgoQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-search@5.55.2': + resolution: {integrity: sha512-5wDnoIfC75zJ2MSHv5SSzTlRL2z7jQMbqQ5jrzottuq2p3oBObv8pD/JpXWu8pRaimaxNr3/Bs/KZIGVXxJ7hg==} + engines: {node: '>= 14.0.0'} + + '@algolia/ingestion@1.55.2': + resolution: {integrity: sha512-da+SC6ikpza98W7C5ChsKEQDvZc8PQLQ0sxmQ5yMRsHpdD3iPKnclJA6ViB5Nr5T9qOX+IDswC6AyqY4V3rtug==} + engines: {node: '>= 14.0.0'} + + '@algolia/monitoring@1.55.2': + resolution: {integrity: sha512-Y8kEcPqCiIEeaGv83l9RRA09mfYECqAJHNnOyEtZc9UirI6XBMUyFVss/sSeYUiV/Lf30hkbWcl00V1uXsf86Q==} + engines: {node: '>= 14.0.0'} + + '@algolia/recommend@5.55.2': + resolution: {integrity: sha512-5zmobuCQqFZkx+84Nt+suL7vo6jTh2CfAs2ndDSeTS2QHvnzP8YEEGWtWftjyACI0cK/FuH8urWwCHP+d2j8TA==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-browser-xhr@5.55.2': + resolution: {integrity: sha512-qnGUUuWG66dRMnr33owLsrYIh9fHVxtU4R2rd3SpneAHuoAUcGbDOWNrj05glVU6M8yOqo9gQ22K8zpz0I8Xpg==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-fetch@5.55.2': + resolution: {integrity: sha512-lKZ5uhafMvR7dWCJEyuaeyZitid1I3ICx+k0vGf5x/ktdIQvc7bndCiOPpmIDqUmN26FE3jTehkAzSqee95G2Q==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-node-http@5.55.2': + resolution: {integrity: sha512-Zc90xvKWUvxcNicvvTO9Pr/hT2TAnkixOIzJm/KMj5Ptm2pKjk71ngTsdkbRtJQvhZ2Kr9N1YdIjLrNHB5P2xw==} + engines: {node: '>= 14.0.0'} + '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} @@ -1727,6 +1818,29 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} + '@docsearch/css@3.8.2': + resolution: {integrity: sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==} + + '@docsearch/js@3.8.2': + resolution: {integrity: sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==} + + '@docsearch/react@3.8.2': + resolution: {integrity: sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==} + peerDependencies: + '@types/react': '>= 16.8.0 < 19.0.0' + react: '>= 16.8.0 < 19.0.0' + react-dom: '>= 16.8.0 < 19.0.0' + search-insights: '>= 1 < 3' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + react-dom: + optional: true + search-insights: + optional: true + '@earendil-works/pi-ai@0.79.3': resolution: {integrity: sha512-lMSput/haP5uZAGbXhS5rAYd3GB7GYdJkoAUxg3VFummBeqGqGqllaTWrbHFN12kVGyVfWHhdySNXkiqVh65Iw==} engines: {node: '>=22.19.0'} @@ -1750,102 +1864,204 @@ packages: '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.28.1': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.28.1': resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.28.1': resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.28.1': resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.28.1': resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.28.1': resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.28.1': resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.28.1': resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.28.1': resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.28.1': resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.28.1': resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.28.1': resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.28.1': resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.28.1': resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.28.1': resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.28.1': resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.28.1': resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} @@ -1858,6 +2074,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.28.1': resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} @@ -1870,6 +2092,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.28.1': resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} @@ -1882,24 +2110,48 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.28.1': resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.28.1': resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.28.1': resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.28.1': resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} @@ -1974,6 +2226,9 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@iconify-json/simple-icons@1.2.90': + resolution: {integrity: sha512-zt2o2ZvQpHVvZJARIkZ51RnaHY2oqcPJMvHE+mVnxkSr+c33fnX4gciiXu+wyX5ei+s0qbVX1wD0DWBbaGBYMA==} + '@iconify/types@2.0.0': resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} @@ -2471,6 +2726,168 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + + '@shikijs/core@2.5.0': + resolution: {integrity: sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==} + + '@shikijs/engine-javascript@2.5.0': + resolution: {integrity: sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==} + + '@shikijs/engine-oniguruma@2.5.0': + resolution: {integrity: sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==} + + '@shikijs/langs@2.5.0': + resolution: {integrity: sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==} + + '@shikijs/themes@2.5.0': + resolution: {integrity: sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==} + + '@shikijs/transformers@2.5.0': + resolution: {integrity: sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==} + + '@shikijs/types@2.5.0': + resolution: {integrity: sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@smithy/core@3.24.7': resolution: {integrity: sha512-KoUi4M1f3BG6kzN1FnCwL7oyFptTbyBJKjR6yhSib+JHRdUmM1o+VwsFtJ66NZCkCzVfJMWRHJNo0R0jznp0Pg==} engines: {node: '>=18.0.0'} @@ -2637,6 +3054,12 @@ packages: '@types/geojson@7946.0.16': resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + '@types/jsdom@28.0.3': resolution: {integrity: sha512-/HQ2uFoetFTXuye8vzIcHw2z6Fwi7Hi/qcgC+RoS9NCyewiqxhVGqlG+ViGB6lkax481R6dmhf1I7lIGlzJStQ==} @@ -2646,9 +3069,18 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} @@ -2673,6 +3105,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + '@typescript-eslint/eslint-plugin@8.61.0': resolution: {integrity: sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2732,9 +3167,19 @@ packages: resolution: {integrity: sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ungap/structured-clone@1.3.3': + resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==} + '@upsetjs/venn.js@2.0.0': resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + '@vitejs/plugin-vue@5.2.4': + resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 + vue: ^3.2.25 + '@vitest/coverage-v8@4.1.8': resolution: {integrity: sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==} peerDependencies: @@ -2773,6 +3218,94 @@ packages: '@vitest/utils@4.1.8': resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} + '@vue/compiler-core@3.5.39': + resolution: {integrity: sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==} + + '@vue/compiler-dom@3.5.39': + resolution: {integrity: sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==} + + '@vue/compiler-sfc@3.5.39': + resolution: {integrity: sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==} + + '@vue/compiler-ssr@3.5.39': + resolution: {integrity: sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==} + + '@vue/devtools-api@7.7.10': + resolution: {integrity: sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==} + + '@vue/devtools-kit@7.7.10': + resolution: {integrity: sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==} + + '@vue/devtools-shared@7.7.10': + resolution: {integrity: sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==} + + '@vue/reactivity@3.5.39': + resolution: {integrity: sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==} + + '@vue/runtime-core@3.5.39': + resolution: {integrity: sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==} + + '@vue/runtime-dom@3.5.39': + resolution: {integrity: sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==} + + '@vue/server-renderer@3.5.39': + resolution: {integrity: sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==} + peerDependencies: + vue: 3.5.39 + + '@vue/shared@3.5.39': + resolution: {integrity: sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==} + + '@vueuse/core@12.8.2': + resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==} + + '@vueuse/integrations@12.8.2': + resolution: {integrity: sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==} + peerDependencies: + async-validator: ^4 + axios: ^1 + change-case: ^5 + drauu: ^0.4 + focus-trap: ^7 + fuse.js: ^7 + idb-keyval: ^6 + jwt-decode: ^4 + nprogress: ^0.2 + qrcode: ^1.5 + sortablejs: ^1 + universal-cookie: ^7 + peerDependenciesMeta: + async-validator: + optional: true + axios: + optional: true + change-case: + optional: true + drauu: + optional: true + focus-trap: + optional: true + fuse.js: + optional: true + idb-keyval: + optional: true + jwt-decode: + optional: true + nprogress: + optional: true + qrcode: + optional: true + sortablejs: + optional: true + universal-cookie: + optional: true + + '@vueuse/metadata@12.8.2': + resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==} + + '@vueuse/shared@12.8.2': + resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -2790,6 +3323,10 @@ packages: ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + algoliasearch@5.55.2: + resolution: {integrity: sha512-OyacJsaeuLUvGWOynNqYc6sx88XvyoG39wMT8SYqL3l9wwaorDW/LPRbUPfhzw0bWsUWzNCZTnFYOrWFBKsUaw==} + engines: {node: '>= 14.0.0'} + ansis@4.3.1: resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} engines: {node: '>=14'} @@ -2824,6 +3361,9 @@ packages: bignumber.js@9.3.1: resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + birpc@2.9.0: + resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + birpc@4.0.0: resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==} @@ -2848,6 +3388,12 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + character-entities@2.0.2: resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} @@ -2855,6 +3401,9 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@7.2.0: resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} engines: {node: '>= 10'} @@ -2866,6 +3415,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + copy-anything@4.0.5: + resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} + engines: {node: '>=18'} + cordis@4.0.0-rc.6: resolution: {integrity: sha512-GzUv7zCKh3FlgM3/Ad2S03UpYO3v4u1GcKa7ig4K2je4lCrgJ/S64ziiZI6XNyKEa1tZwdzj4oBQrhYDLgfEiA==} hasBin: true @@ -2895,6 +3448,9 @@ packages: resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + cytoscape-cose-bilkent@4.1.0: resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} peerDependencies: @@ -3116,10 +3672,17 @@ packages: ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + emoji-regex-xs@1.0.0: + resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} + empathic@2.0.1: resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} engines: {node: '>=14'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + entities@8.0.0: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} @@ -3130,6 +3693,11 @@ packages: es-toolkit@1.49.0: resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==} + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.28.1: resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} @@ -3189,6 +3757,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -3254,6 +3825,9 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + focus-trap@7.8.0: + resolution: {integrity: sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==} + formatly@0.3.0: resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==} engines: {node: '>=18.3.0'} @@ -3305,6 +3879,15 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + hookable@6.1.1: resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==} @@ -3315,6 +3898,9 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -3364,6 +3950,10 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-what@5.5.0: + resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} + engines: {node: '>=18'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -3609,6 +4199,9 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + mark.js@8.11.1: + resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==} + markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} @@ -3644,6 +4237,9 @@ packages: mdast-util-phrasing@4.1.0: resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + mdast-util-to-markdown@2.1.2: resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} @@ -3744,6 +4340,12 @@ packages: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} + minisearch@7.2.0: + resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==} + + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -3772,6 +4374,9 @@ packages: resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} engines: {node: '>=12.20.0'} + oniguruma-to-es@3.1.1: + resolution: {integrity: sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==} + openai@6.26.0: resolution: {integrity: sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==} hasBin: true @@ -3834,6 +4439,9 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -3851,10 +4459,21 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + preact@10.29.7: + resolution: {integrity: sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==} + peerDependencies: + preact-render-to-string: '>=5' + peerDependenciesMeta: + preact-render-to-string: + optional: true + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + protobufjs@7.6.4: resolution: {integrity: sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==} engines: {node: '>=12.0.0'} @@ -3878,6 +4497,15 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -3889,6 +4517,9 @@ packages: resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} engines: {node: '>= 4'} + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + robust-predicates@3.0.3: resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} @@ -3921,6 +4552,11 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + roughjs@4.6.6: resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} @@ -3944,6 +4580,9 @@ packages: schemastery@3.18.0: resolution: {integrity: sha512-Jw2uxjoyyqc/yeurmChUEc/jbi8GsrdXV/KmqRUDZXJAXAmrJiPsz8vKa17l/VckyzljHZ9oGaul443CQiXxtA==} + search-insights@2.17.3: + resolution: {integrity: sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==} + semver@7.8.4: resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} engines: {node: '>=10'} @@ -3957,6 +4596,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shiki@2.5.0: + resolution: {integrity: sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -3968,12 +4610,22 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + speakingurl@14.0.1: + resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} + engines: {node: '>=0.10.0'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-json-comments@5.0.3: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} @@ -3984,6 +4636,10 @@ packages: stylis@4.4.0: resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + superjson@2.2.6: + resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} + engines: {node: '>=16'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -3995,6 +4651,9 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tabbable@6.5.0: + resolution: {integrity: sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -4029,6 +4688,9 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + ts-algebra@2.0.0: resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} @@ -4134,6 +4796,9 @@ packages: unist-util-is@6.0.1: resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + unist-util-stringify-position@4.0.0: resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} @@ -4150,11 +4815,48 @@ packages: resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} hasBin: true + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-tsconfig-paths@6.1.1: resolution: {integrity: sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg==} peerDependencies: vite: '*' + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + vite@8.0.16: resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4198,6 +4900,18 @@ packages: yaml: optional: true + vitepress@1.6.4: + resolution: {integrity: sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==} + hasBin: true + peerDependencies: + markdown-it-mathjax3: ^4 + postcss: ^8 + peerDependenciesMeta: + markdown-it-mathjax3: + optional: true + postcss: + optional: true + vitest@4.1.8: resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -4239,6 +4953,14 @@ packages: jsdom: optional: true + vue@3.5.39: + resolution: {integrity: sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + w3c-xmlserializer@5.0.0: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} @@ -4326,6 +5048,118 @@ snapshots: dependencies: zod: 4.4.3 + '@algolia/abtesting@1.21.2': + dependencies: + '@algolia/client-common': 5.55.2 + '@algolia/requester-browser-xhr': 5.55.2 + '@algolia/requester-fetch': 5.55.2 + '@algolia/requester-node-http': 5.55.2 + + '@algolia/autocomplete-core@1.17.7(@algolia/client-search@5.55.2)(algoliasearch@5.55.2)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-plugin-algolia-insights': 1.17.7(@algolia/client-search@5.55.2)(algoliasearch@5.55.2)(search-insights@2.17.3) + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.55.2)(algoliasearch@5.55.2) + transitivePeerDependencies: + - '@algolia/client-search' + - algoliasearch + - search-insights + + '@algolia/autocomplete-plugin-algolia-insights@1.17.7(@algolia/client-search@5.55.2)(algoliasearch@5.55.2)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.55.2)(algoliasearch@5.55.2) + search-insights: 2.17.3 + transitivePeerDependencies: + - '@algolia/client-search' + - algoliasearch + + '@algolia/autocomplete-preset-algolia@1.17.7(@algolia/client-search@5.55.2)(algoliasearch@5.55.2)': + dependencies: + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.55.2)(algoliasearch@5.55.2) + '@algolia/client-search': 5.55.2 + algoliasearch: 5.55.2 + + '@algolia/autocomplete-shared@1.17.7(@algolia/client-search@5.55.2)(algoliasearch@5.55.2)': + dependencies: + '@algolia/client-search': 5.55.2 + algoliasearch: 5.55.2 + + '@algolia/client-abtesting@5.55.2': + dependencies: + '@algolia/client-common': 5.55.2 + '@algolia/requester-browser-xhr': 5.55.2 + '@algolia/requester-fetch': 5.55.2 + '@algolia/requester-node-http': 5.55.2 + + '@algolia/client-analytics@5.55.2': + dependencies: + '@algolia/client-common': 5.55.2 + '@algolia/requester-browser-xhr': 5.55.2 + '@algolia/requester-fetch': 5.55.2 + '@algolia/requester-node-http': 5.55.2 + + '@algolia/client-common@5.55.2': {} + + '@algolia/client-insights@5.55.2': + dependencies: + '@algolia/client-common': 5.55.2 + '@algolia/requester-browser-xhr': 5.55.2 + '@algolia/requester-fetch': 5.55.2 + '@algolia/requester-node-http': 5.55.2 + + '@algolia/client-personalization@5.55.2': + dependencies: + '@algolia/client-common': 5.55.2 + '@algolia/requester-browser-xhr': 5.55.2 + '@algolia/requester-fetch': 5.55.2 + '@algolia/requester-node-http': 5.55.2 + + '@algolia/client-query-suggestions@5.55.2': + dependencies: + '@algolia/client-common': 5.55.2 + '@algolia/requester-browser-xhr': 5.55.2 + '@algolia/requester-fetch': 5.55.2 + '@algolia/requester-node-http': 5.55.2 + + '@algolia/client-search@5.55.2': + dependencies: + '@algolia/client-common': 5.55.2 + '@algolia/requester-browser-xhr': 5.55.2 + '@algolia/requester-fetch': 5.55.2 + '@algolia/requester-node-http': 5.55.2 + + '@algolia/ingestion@1.55.2': + dependencies: + '@algolia/client-common': 5.55.2 + '@algolia/requester-browser-xhr': 5.55.2 + '@algolia/requester-fetch': 5.55.2 + '@algolia/requester-node-http': 5.55.2 + + '@algolia/monitoring@1.55.2': + dependencies: + '@algolia/client-common': 5.55.2 + '@algolia/requester-browser-xhr': 5.55.2 + '@algolia/requester-fetch': 5.55.2 + '@algolia/requester-node-http': 5.55.2 + + '@algolia/recommend@5.55.2': + dependencies: + '@algolia/client-common': 5.55.2 + '@algolia/requester-browser-xhr': 5.55.2 + '@algolia/requester-fetch': 5.55.2 + '@algolia/requester-node-http': 5.55.2 + + '@algolia/requester-browser-xhr@5.55.2': + dependencies: + '@algolia/client-common': 5.55.2 + + '@algolia/requester-fetch@5.55.2': + dependencies: + '@algolia/client-common': 5.55.2 + + '@algolia/requester-node-http@5.55.2': + dependencies: + '@algolia/client-common': 5.55.2 + '@antfu/install-pkg@1.1.0': dependencies: package-manager-detector: 1.6.0 @@ -4674,6 +5508,31 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} + '@docsearch/css@3.8.2': {} + + '@docsearch/js@3.8.2(@algolia/client-search@5.55.2)(search-insights@2.17.3)': + dependencies: + '@docsearch/react': 3.8.2(@algolia/client-search@5.55.2)(search-insights@2.17.3) + preact: 10.29.7 + transitivePeerDependencies: + - '@algolia/client-search' + - '@types/react' + - preact-render-to-string + - react + - react-dom + - search-insights + + '@docsearch/react@3.8.2(@algolia/client-search@5.55.2)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-core': 1.17.7(@algolia/client-search@5.55.2)(algoliasearch@5.55.2)(search-insights@2.17.3) + '@algolia/autocomplete-preset-algolia': 1.17.7(@algolia/client-search@5.55.2)(algoliasearch@5.55.2) + '@docsearch/css': 3.8.2 + algoliasearch: 5.55.2 + optionalDependencies: + search-insights: 2.17.3 + transitivePeerDependencies: + - '@algolia/client-search' + '@earendil-works/pi-ai@0.79.3(ws@8.21.0)(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.91.1(zod@4.4.3) @@ -4726,81 +5585,150 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.21.5': + optional: true + '@esbuild/aix-ppc64@0.28.1': optional: true + '@esbuild/android-arm64@0.21.5': + optional: true + '@esbuild/android-arm64@0.28.1': optional: true + '@esbuild/android-arm@0.21.5': + optional: true + '@esbuild/android-arm@0.28.1': optional: true + '@esbuild/android-x64@0.21.5': + optional: true + '@esbuild/android-x64@0.28.1': optional: true + '@esbuild/darwin-arm64@0.21.5': + optional: true + '@esbuild/darwin-arm64@0.28.1': optional: true + '@esbuild/darwin-x64@0.21.5': + optional: true + '@esbuild/darwin-x64@0.28.1': optional: true + '@esbuild/freebsd-arm64@0.21.5': + optional: true + '@esbuild/freebsd-arm64@0.28.1': optional: true + '@esbuild/freebsd-x64@0.21.5': + optional: true + '@esbuild/freebsd-x64@0.28.1': optional: true + '@esbuild/linux-arm64@0.21.5': + optional: true + '@esbuild/linux-arm64@0.28.1': optional: true + '@esbuild/linux-arm@0.21.5': + optional: true + '@esbuild/linux-arm@0.28.1': optional: true + '@esbuild/linux-ia32@0.21.5': + optional: true + '@esbuild/linux-ia32@0.28.1': optional: true + '@esbuild/linux-loong64@0.21.5': + optional: true + '@esbuild/linux-loong64@0.28.1': optional: true + '@esbuild/linux-mips64el@0.21.5': + optional: true + '@esbuild/linux-mips64el@0.28.1': optional: true + '@esbuild/linux-ppc64@0.21.5': + optional: true + '@esbuild/linux-ppc64@0.28.1': optional: true + '@esbuild/linux-riscv64@0.21.5': + optional: true + '@esbuild/linux-riscv64@0.28.1': optional: true + '@esbuild/linux-s390x@0.21.5': + optional: true + '@esbuild/linux-s390x@0.28.1': optional: true + '@esbuild/linux-x64@0.21.5': + optional: true + '@esbuild/linux-x64@0.28.1': optional: true '@esbuild/netbsd-arm64@0.28.1': optional: true + '@esbuild/netbsd-x64@0.21.5': + optional: true + '@esbuild/netbsd-x64@0.28.1': optional: true '@esbuild/openbsd-arm64@0.28.1': optional: true + '@esbuild/openbsd-x64@0.21.5': + optional: true + '@esbuild/openbsd-x64@0.28.1': optional: true '@esbuild/openharmony-arm64@0.28.1': optional: true + '@esbuild/sunos-x64@0.21.5': + optional: true + '@esbuild/sunos-x64@0.28.1': optional: true + '@esbuild/win32-arm64@0.21.5': + optional: true + '@esbuild/win32-arm64@0.28.1': optional: true + '@esbuild/win32-ia32@0.21.5': + optional: true + '@esbuild/win32-ia32@0.28.1': optional: true + '@esbuild/win32-x64@0.21.5': + optional: true + '@esbuild/win32-x64@0.28.1': optional: true @@ -4863,6 +5791,10 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@iconify-json/simple-icons@1.2.90': + dependencies: + '@iconify/types': 2.0.0 + '@iconify/types@2.0.0': {} '@iconify/utils@3.1.3': @@ -5169,6 +6101,121 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + + '@shikijs/core@2.5.0': + dependencies: + '@shikijs/engine-javascript': 2.5.0 + '@shikijs/engine-oniguruma': 2.5.0 + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 3.1.1 + + '@shikijs/engine-oniguruma@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + + '@shikijs/themes@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + + '@shikijs/transformers@2.5.0': + dependencies: + '@shikijs/core': 2.5.0 + '@shikijs/types': 2.5.0 + + '@shikijs/types@2.5.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + '@shikijs/vscode-textmate@10.0.2': {} + '@smithy/core@3.24.7': dependencies: '@aws-crypto/crc32': 5.2.0 @@ -5376,6 +6423,12 @@ snapshots: '@types/geojson@7946.0.16': {} + '@types/hast@3.0.5': + dependencies: + '@types/unist': 3.0.3 + + '@types/js-yaml@4.0.9': {} + '@types/jsdom@28.0.3': dependencies: '@types/node': 25.9.3 @@ -5387,10 +6440,19 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/linkify-it@5.0.0': {} + + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 + '@types/mdurl@2.0.0': {} + '@types/ms@2.1.0': {} '@types/node@22.20.0': @@ -5412,6 +6474,8 @@ snapshots: '@types/unist@3.0.3': {} + '@types/web-bluetooth@0.0.21': {} + '@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -5503,11 +6567,18 @@ snapshots: '@typescript-eslint/types': 8.61.0 eslint-visitor-keys: 5.0.1 + '@ungap/structured-clone@1.3.3': {} + '@upsetjs/venn.js@2.0.0': optionalDependencies: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) + '@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@25.9.3)(lightningcss@1.32.0))(vue@3.5.39(typescript@6.0.3))': + dependencies: + vite: 5.4.21(@types/node@25.9.3)(lightningcss@1.32.0) + vue: 3.5.39(typescript@6.0.3) + '@vitest/coverage-v8@4.1.8(vitest@4.1.8)': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -5571,6 +6642,105 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + '@vue/compiler-core@3.5.39': + dependencies: + '@babel/parser': 7.29.7 + '@vue/shared': 3.5.39 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.39': + dependencies: + '@vue/compiler-core': 3.5.39 + '@vue/shared': 3.5.39 + + '@vue/compiler-sfc@3.5.39': + dependencies: + '@babel/parser': 7.29.7 + '@vue/compiler-core': 3.5.39 + '@vue/compiler-dom': 3.5.39 + '@vue/compiler-ssr': 3.5.39 + '@vue/shared': 3.5.39 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.15 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.39': + dependencies: + '@vue/compiler-dom': 3.5.39 + '@vue/shared': 3.5.39 + + '@vue/devtools-api@7.7.10': + dependencies: + '@vue/devtools-kit': 7.7.10 + + '@vue/devtools-kit@7.7.10': + dependencies: + '@vue/devtools-shared': 7.7.10 + birpc: 2.9.0 + hookable: 5.5.3 + mitt: 3.0.1 + perfect-debounce: 1.0.0 + speakingurl: 14.0.1 + superjson: 2.2.6 + + '@vue/devtools-shared@7.7.10': + dependencies: + rfdc: 1.4.1 + + '@vue/reactivity@3.5.39': + dependencies: + '@vue/shared': 3.5.39 + + '@vue/runtime-core@3.5.39': + dependencies: + '@vue/reactivity': 3.5.39 + '@vue/shared': 3.5.39 + + '@vue/runtime-dom@3.5.39': + dependencies: + '@vue/reactivity': 3.5.39 + '@vue/runtime-core': 3.5.39 + '@vue/shared': 3.5.39 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.39(vue@3.5.39(typescript@6.0.3))': + dependencies: + '@vue/compiler-ssr': 3.5.39 + '@vue/shared': 3.5.39 + vue: 3.5.39(typescript@6.0.3) + + '@vue/shared@3.5.39': {} + + '@vueuse/core@12.8.2(typescript@6.0.3)': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 12.8.2 + '@vueuse/shared': 12.8.2(typescript@6.0.3) + vue: 3.5.39(typescript@6.0.3) + transitivePeerDependencies: + - typescript + + '@vueuse/integrations@12.8.2(focus-trap@7.8.0)(typescript@6.0.3)': + dependencies: + '@vueuse/core': 12.8.2(typescript@6.0.3) + '@vueuse/shared': 12.8.2(typescript@6.0.3) + vue: 3.5.39(typescript@6.0.3) + optionalDependencies: + focus-trap: 7.8.0 + transitivePeerDependencies: + - typescript + + '@vueuse/metadata@12.8.2': {} + + '@vueuse/shared@12.8.2(typescript@6.0.3)': + dependencies: + vue: 3.5.39(typescript@6.0.3) + transitivePeerDependencies: + - typescript + acorn-jsx@5.3.2(acorn@8.17.0): dependencies: acorn: 8.17.0 @@ -5586,6 +6756,23 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + algoliasearch@5.55.2: + dependencies: + '@algolia/abtesting': 1.21.2 + '@algolia/client-abtesting': 5.55.2 + '@algolia/client-analytics': 5.55.2 + '@algolia/client-common': 5.55.2 + '@algolia/client-insights': 5.55.2 + '@algolia/client-personalization': 5.55.2 + '@algolia/client-query-suggestions': 5.55.2 + '@algolia/client-search': 5.55.2 + '@algolia/ingestion': 1.55.2 + '@algolia/monitoring': 1.55.2 + '@algolia/recommend': 5.55.2 + '@algolia/requester-browser-xhr': 5.55.2 + '@algolia/requester-fetch': 5.55.2 + '@algolia/requester-node-http': 5.55.2 + ansis@4.3.1: {} anynum@1.0.0: {} @@ -5616,6 +6803,8 @@ snapshots: bignumber.js@9.3.1: {} + birpc@2.9.0: {} + birpc@4.0.0: {} bowser@2.14.1: {} @@ -5632,18 +6821,28 @@ snapshots: chai@6.2.2: {} + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + character-entities@2.0.2: {} chokidar@4.0.3: dependencies: readdirp: 4.1.2 + comma-separated-tokens@2.0.3: {} + commander@7.2.0: {} commander@8.3.0: {} convert-source-map@2.0.0: {} + copy-anything@4.0.5: + dependencies: + is-what: 5.5.0 + cordis@4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4): dependencies: '@standard-schema/spec': 1.1.0 @@ -5681,6 +6880,8 @@ snapshots: mdn-data: 2.27.1 source-map-js: 1.2.1 + csstype@3.2.3: {} + cytoscape-cose-bilkent@4.1.0(cytoscape@3.34.0): dependencies: cose-base: 1.0.3 @@ -5916,14 +7117,44 @@ snapshots: dependencies: safe-buffer: 5.2.1 + emoji-regex-xs@1.0.0: {} + empathic@2.0.1: {} + entities@7.0.1: {} + entities@8.0.0: {} es-module-lexer@2.1.0: {} es-toolkit@1.49.0: {} + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + esbuild@0.28.1: optionalDependencies: '@esbuild/aix-ppc64': 0.28.1 @@ -6029,6 +7260,8 @@ snapshots: estraverse@5.3.0: {} + estree-walker@2.0.2: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -6090,6 +7323,10 @@ snapshots: flatted@3.4.2: {} + focus-trap@7.8.0: + dependencies: + tabbable: 6.5.0 + formatly@0.3.0: dependencies: fd-package-json: 2.0.0 @@ -6148,6 +7385,26 @@ snapshots: has-flag@4.0.0: {} + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + hookable@5.5.3: {} + hookable@6.1.1: {} html-encoding-sniffer@6.0.0: @@ -6158,6 +7415,8 @@ snapshots: html-escaper@2.0.2: {} + html-void-elements@3.0.0: {} + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 @@ -6198,6 +7457,8 @@ snapshots: is-potential-custom-element-name@1.0.1: {} + is-what@5.5.0: {} + isexe@2.0.0: {} istanbul-lib-coverage@3.2.2: {} @@ -6430,6 +7691,8 @@ snapshots: dependencies: semver: 7.8.4 + mark.js@8.11.1: {} + markdown-table@3.0.4: {} marked@16.4.2: {} @@ -6520,6 +7783,18 @@ snapshots: '@types/mdast': 4.0.4 unist-util-is: 6.0.1 + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.3 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + mdast-util-to-markdown@2.1.2: dependencies: '@types/mdast': 4.0.4 @@ -6757,6 +8032,10 @@ snapshots: dependencies: brace-expansion: 5.0.6 + minisearch@7.2.0: {} + + mitt@3.0.1: {} + mri@1.2.0: {} ms@2.1.3: {} @@ -6775,6 +8054,12 @@ snapshots: obug@2.1.3: {} + oniguruma-to-es@3.1.1: + dependencies: + emoji-regex-xs: 1.0.0 + regex: 6.1.0 + regex-recursion: 6.0.2 + openai@6.26.0(ws@8.21.0)(zod@4.4.3): optionalDependencies: ws: 8.21.0 @@ -6867,6 +8152,8 @@ snapshots: pathe@2.0.3: {} + perfect-debounce@1.0.0: {} + picocolors@1.1.1: {} picomatch@4.0.4: {} @@ -6884,8 +8171,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + preact@10.29.7: {} + prelude-ls@1.2.1: {} + property-information@7.2.0: {} + protobufjs@7.6.4: dependencies: '@protobufjs/aspromise': 1.1.2 @@ -6915,12 +8206,24 @@ snapshots: readdirp@4.1.2: {} + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + require-from-string@2.0.2: {} resolve-pkg-maps@1.0.0: {} retry@0.13.1: {} + rfdc@1.4.1: {} + robust-predicates@3.0.3: {} rolldown-plugin-dts@0.25.2(oxc-resolver@11.20.0)(rolldown@1.1.1)(typescript@6.0.3): @@ -6981,6 +8284,37 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.1.1 '@rolldown/binding-win32-x64-msvc': 1.1.1 + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + roughjs@4.6.6: dependencies: hachure-fill: 0.5.2 @@ -7007,6 +8341,8 @@ snapshots: '@standard-schema/spec': 1.1.0 cosmokit: 1.8.1 + search-insights@2.17.3: {} + semver@7.8.4: {} shebang-command@2.0.0: @@ -7015,16 +8351,36 @@ snapshots: shebang-regex@3.0.0: {} + shiki@2.5.0: + dependencies: + '@shikijs/core': 2.5.0 + '@shikijs/engine-javascript': 2.5.0 + '@shikijs/engine-oniguruma': 2.5.0 + '@shikijs/langs': 2.5.0 + '@shikijs/themes': 2.5.0 + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + siginfo@2.0.0: {} smol-toml@1.6.1: {} source-map-js@1.2.1: {} + space-separated-tokens@2.0.2: {} + + speakingurl@14.0.1: {} + stackback@0.0.2: {} std-env@4.1.0: {} + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + strip-json-comments@5.0.3: {} strnum@2.4.0: @@ -7033,6 +8389,10 @@ snapshots: stylis@4.4.0: {} + superjson@2.2.6: + dependencies: + copy-anything: 4.0.5 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -7041,6 +8401,8 @@ snapshots: symbol-tree@3.2.4: {} + tabbable@6.5.0: {} + tinybench@2.9.0: {} tinyexec@1.2.4: {} @@ -7068,6 +8430,8 @@ snapshots: tree-kill@1.2.2: {} + trim-lines@3.0.1: {} + ts-algebra@2.0.0: {} ts-api-utils@2.5.0(typescript@6.0.3): @@ -7151,6 +8515,10 @@ snapshots: dependencies: '@types/unist': 3.0.3 + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position@4.0.0: dependencies: '@types/unist': 3.0.3 @@ -7172,6 +8540,16 @@ snapshots: uuid@14.0.1: {} + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + vite-tsconfig-paths@6.1.1(typescript@6.0.3)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: debug: 4.4.3 @@ -7182,6 +8560,16 @@ snapshots: - supports-color - typescript + vite@5.4.21(@types/node@25.9.3)(lightningcss@1.32.0): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.15 + rollup: 4.62.2 + optionalDependencies: + '@types/node': 25.9.3 + fsevents: 2.3.3 + lightningcss: 1.32.0 + vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 @@ -7212,6 +8600,56 @@ snapshots: tsx: 4.22.4 yaml: 2.9.0 + vitepress@1.6.4(@algolia/client-search@5.55.2)(@types/node@25.9.3)(lightningcss@1.32.0)(postcss@8.5.15)(search-insights@2.17.3)(typescript@6.0.3): + dependencies: + '@docsearch/css': 3.8.2 + '@docsearch/js': 3.8.2(@algolia/client-search@5.55.2)(search-insights@2.17.3) + '@iconify-json/simple-icons': 1.2.90 + '@shikijs/core': 2.5.0 + '@shikijs/transformers': 2.5.0 + '@shikijs/types': 2.5.0 + '@types/markdown-it': 14.1.2 + '@vitejs/plugin-vue': 5.2.4(vite@5.4.21(@types/node@25.9.3)(lightningcss@1.32.0))(vue@3.5.39(typescript@6.0.3)) + '@vue/devtools-api': 7.7.10 + '@vue/shared': 3.5.39 + '@vueuse/core': 12.8.2(typescript@6.0.3) + '@vueuse/integrations': 12.8.2(focus-trap@7.8.0)(typescript@6.0.3) + focus-trap: 7.8.0 + mark.js: 8.11.1 + minisearch: 7.2.0 + shiki: 2.5.0 + vite: 5.4.21(@types/node@25.9.3)(lightningcss@1.32.0) + vue: 3.5.39(typescript@6.0.3) + optionalDependencies: + postcss: 8.5.15 + transitivePeerDependencies: + - '@algolia/client-search' + - '@types/node' + - '@types/react' + - async-validator + - axios + - change-case + - drauu + - fuse.js + - idb-keyval + - jwt-decode + - less + - lightningcss + - nprogress + - preact-render-to-string + - qrcode + - react + - react-dom + - sass + - sass-embedded + - search-insights + - sortablejs + - stylus + - sugarss + - terser + - typescript + - universal-cookie + vitest@4.1.8(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.8 @@ -7270,6 +8708,16 @@ snapshots: transitivePeerDependencies: - msw + vue@3.5.39(typescript@6.0.3): + dependencies: + '@vue/compiler-dom': 3.5.39 + '@vue/compiler-sfc': 3.5.39 + '@vue/runtime-dom': 3.5.39 + '@vue/server-renderer': 3.5.39(vue@3.5.39(typescript@6.0.3)) + '@vue/shared': 3.5.39 + optionalDependencies: + typescript: 6.0.3 + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b2b731fc58..dee7c5674c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - vendor/* - packages/*/* + - website peerDependencyRules: allowedVersions: diff --git a/scripts/doc-typecheck.ts b/scripts/doc-typecheck.ts index e57f3710ee..f7bbaab312 100644 --- a/scripts/doc-typecheck.ts +++ b/scripts/doc-typecheck.ts @@ -2,7 +2,8 @@ * Doc-sync gate (doc-sync-enforcement RFC, part 1): typecheck the fenced `ts` code blocks in our * Markdown so documentation can't drift from the API it documents. * - * Every ```ts block in README.md, docs/** and packages/* /README.md is + * Every ```ts block in README.md, docs/**, packages/* /README.md and the + * website tutorial pages (website/zh-CN/**) is * extracted to a temp typecheck project and compiled against the workspace * sources through the same project-reference boundaries used by repo * typecheck. A block that is a deliberate sketch rather than compilable code @@ -134,7 +135,7 @@ function tempTsconfig(): string { }) } -const markdownGlobs = ['README.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md'] +const markdownGlobs = ['README.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md', 'website/zh-CN/**/*.md'] const files: string[] = [] for (const pattern of markdownGlobs) { diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 9ee5a66ed6..958ddad0ae 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -167,6 +167,7 @@ function ciPrimaryGates(): Gate[] { ...docSyncLeafGates(), pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }), pnpmScript('knip', 'knip'), + pnpmScript('website-build', 'website:build', { label: 'website build' }), pnpmScript('build', 'build', { needs: ['typecheck'] }), pnpmScript('publint', 'publint', { needs: ['build'] }), pnpmScript('node-next-types', 'verify-node-next-types', { @@ -184,6 +185,7 @@ function ciStaticGates(): Gate[] { ...docSyncLeafGates(), pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }), pnpmScript('knip', 'knip'), + pnpmScript('website-build', 'website:build', { label: 'website build' }), ] } diff --git a/scripts/verify-type-equiv.ts b/scripts/verify-type-equiv.ts index 85ccd642d9..9ad1a4a29d 100644 --- a/scripts/verify-type-equiv.ts +++ b/scripts/verify-type-equiv.ts @@ -35,7 +35,7 @@ const root = resolve(import.meta.dirname, '..') * added to a doc with NO manifest entry is still discovered here and reported as * an orphan, instead of being silently skipped. */ -const MARKDOWN_GLOBS = ['README.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md'] +const MARKDOWN_GLOBS = ['README.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md', 'website/zh-CN/**/*.md'] /** One manifest entry: a documented type-equiv block and its source symbol. */ interface ManifestEntry { diff --git a/website/zh-CN/design/composability.md b/website/zh-CN/design/composability.md index 8370d8e136..a52a7bb8c5 100644 --- a/website/zh-CN/design/composability.md +++ b/website/zh-CN/design/composability.md @@ -55,16 +55,21 @@ Cordis 同时解决了上述两个问题: DeepSeek Harness 将 Cordis 的可组合性应用到 Agent 开发领域: -```typescript +```ts +import type { Context } from 'cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' +import type {} from '@deepseek-ai/dsh-llm' + // 一个 Harness 插件天然是可逆的 export const inject = ['tools', 'llm'] // 空间可组合:声明依赖 export function apply(ctx: Context) { // 时间可组合:注册会被自动追踪和回收 - ctx.tools.register(defineTool('my-tool', { + ctx.tools.register(defineTool({ + name: 'my-tool', description: '...', parameters: { /* ... */ }, - async execute(args) { /* ... */ }, + async execute(args) { return [] }, })) } ``` diff --git a/website/zh-CN/design/context-model.md b/website/zh-CN/design/context-model.md index cc25df88e5..6323db27df 100644 --- a/website/zh-CN/design/context-model.md +++ b/website/zh-CN/design/context-model.md @@ -50,9 +50,14 @@ Root Context - 因此服务的提供被记录在作用上下文中 - 上下文将作用与余作用关联起来,提供了统一的时间、空间可组合性 -```typescript +```ts +import { Service, type Context } from 'cordis' + // 提供服务 = 一个 effect(占用 ctx.llm 这个 "资源") class LlmService extends Service { + constructor(ctx: Context) { + super(ctx, 'llm') + } // 当此插件卸载时,ctx.llm 被回收(effect 的逆操作) // 所有依赖 llm 的插件因 coeffect 不满足而挂起 } @@ -66,7 +71,16 @@ class LlmService extends Service { 框架将领域中的所有方法都封装为 effect 版本。开发者只需调用 `ctx` 上的方法,就能自动获得时间/空间可组合性: -```typescript +```ts +import type { Context } from 'cordis' +import type { ToolDefinition } from '@deepseek-ai/dsh-tools' +import type { LlmAdapter, Message } from '@deepseek-ai/dsh-llm' +import type { Agent } from '@deepseek-ai/dsh-agent' + +declare function validateResult(agent: Agent, turn: number, step: number, message: Message, next: () => Promise): Promise +declare const myTool: ToolDefinition +declare const adapter: LlmAdapter + export function apply(ctx: Context) { // 以下每一行都是 effect——卸载时自动逆序回收 ctx.on('agent/step-result', validateResult) @@ -82,7 +96,16 @@ export function apply(ctx: Context) { 可以逐步将现有框架中的 API 替换为可组合版本,无需一次性重写: -```typescript +```ts +import type { Context } from 'cordis' + +declare const ctx: Context +declare function handler(): void +declare const legacySystem: { + register(handler: () => void): object + unregister(token: object): void +} + // 第一步:用 ctx.effect 包装遗留 API ctx.effect(() => { const legacy = legacySystem.register(handler) diff --git a/website/zh-CN/design/reactive-coeffects.md b/website/zh-CN/design/reactive-coeffects.md index 45345f934a..2ff6cb4199 100644 --- a/website/zh-CN/design/reactive-coeffects.md +++ b/website/zh-CN/design/reactive-coeffects.md @@ -24,13 +24,19 @@ Cordis 将程序中的资源依赖抽象为**服务** (service): - 运行时对依赖不满足的插件**等待**,而非拒绝 - 服务生命周期结束前,依赖该服务的插件**先一步被回收** -```typescript +```ts +import { Service, type Context } from 'cordis' + // LLM 适配器插件:提供 llm 服务 export class LlmService extends Service { static inject = ['http'] // 自身依赖 http // 当 http 不可用时,LlmService 自动挂起 // 挂起导致 ctx.llm 不可用 // 所有 inject: ['llm'] 的插件级联挂起 + + constructor(ctx: Context) { + super(ctx, 'llm') + } } ``` @@ -57,7 +63,11 @@ export class LlmService extends Service { ## 在 Cordis 中的实现 -```typescript +```ts +import type { Context } from 'cordis' +import type {} from '@deepseek-ai/dsh-tools' +import type {} from '@deepseek-ai/dsh-llm' + // 声明依赖 export const inject = ['tools', 'llm'] @@ -85,6 +95,6 @@ llm service 恢复 → 依赖 llm 的插件重新 PENDING → ACTIVE | LLM adapter 热替换 | 依赖 `llm` 的插件自动挂起/恢复,中间不丢状态 | | 按需加载 bash 执行器 | bash tool 只在 `bash` 服务就绪后注册 | | 子 Agent 独立服务空间 | 通过 `ctx.isolate()` 隔离服务实例,互不干扰 | -| 可选能力降级 | `inject: { web: { required: false } }` 允许 web 不可用时继续运行 | +| 可选能力降级 | 不声明 `inject`,用 `ctx.get('web')` 读取——服务不可用时返回 `undefined`,插件照常运行 | 这意味着 Harness 插件开发者无需编写防御性的 "if service exists" 检查——框架保证:当你的 `apply` 被调用时,声明的依赖一定已就绪。 diff --git a/website/zh-CN/design/revertible-effects.md b/website/zh-CN/design/revertible-effects.md index 5133400e75..3cbcfdfc06 100644 --- a/website/zh-CN/design/revertible-effects.md +++ b/website/zh-CN/design/revertible-effects.md @@ -103,7 +103,20 @@ $$ | $\text{restore}$ | `fiber.dispose()` | 执行 Fiber 的整个回收链 | | $f^{-1}$ | dispose 返回值 / cleanup 函数 | 逆操作 | -```typescript +```ts +import type { Context } from 'cordis' +import type { ToolDefinition } from '@deepseek-ai/dsh-tools' + +declare module 'cordis' { + interface Events { + 'my-plugin/event'(): void + } +} + +declare function startServer(port: number): { close(): void } +declare function handler(): void +declare const myTool: ToolDefinition + export function apply(ctx: Context) { // effect: 创建资源,返回其逆操作 ctx.effect(() => { @@ -112,7 +125,7 @@ export function apply(ctx: Context) { }) // 框架 API 内部已封装 effect - ctx.on('event', handler) // 内部: effect(addListener, removeListener) + ctx.on('my-plugin/event', handler) // 内部: effect(addListener, removeListener) ctx.tools.register(myTool) // 内部: effect(addTool, removeTool) } // 当此插件被卸载时,restore 自动按逆序执行所有 f⁻¹ diff --git a/website/zh-CN/develop/basic/config.md b/website/zh-CN/develop/basic/config.md index 49bcc4ca77..8b1edf385f 100644 --- a/website/zh-CN/develop/basic/config.md +++ b/website/zh-CN/develop/basic/config.md @@ -4,27 +4,21 @@ ## 定义 Config 类型 -在插件中导出一个 `Config` 类型和可选的默认值: +在插件中导出一个 `Config` 类型,`apply` 的第二个参数就是用户配置: -```typescript +```ts import type { Context } from 'cordis' export const name = 'my-plugin' export interface Config { - greeting: string - maxRetries: number + greeting?: string + maxRetries?: number verbose?: boolean } -export const Config = { - greeting: 'Hello', - maxRetries: 3, - verbose: false, -} - export function apply(ctx: Context, config: Config) { - console.log(config.greeting) // 用户配置或默认值 + console.log(config.greeting ?? 'Hello') // 用户配置或默认值 } ``` @@ -37,32 +31,32 @@ export function apply(ctx: Context, config: Config) { maxRetries: 5 ``` -未提供的字段使用导出的 `Config` 对象中的默认值。 +只导出类型时,配置原样传入,默认值由代码自己兜底(如上面的 `??`)。想让框架代管默认值和校验,导出一个 schema(见下节)。 ## Schema 校验 -对于需要严格校验的场景,使用 Schemastery 定义 schema: +对于需要默认值和严格校验的场景,额外导出一个 Schemastery schema(仓库约定以 `z` 引入)。加载时框架先用它校验并填充默认值,再把结果传给 `apply`: -```typescript +```ts import type { Context } from 'cordis' -import Schema from 'schemastery' +import z from 'schemastery' export const name = 'validated-plugin' export interface Config { apiKey: string - timeout: number - mode: 'fast' | 'accurate' + timeout?: number + mode?: 'fast' | 'accurate' } -export const Config = Schema.object({ - apiKey: Schema.string().required(), - timeout: Schema.number().default(30000), - mode: Schema.union(['fast', 'accurate']).default('fast'), +export const Config: z = z.object({ + apiKey: z.string().required(), + timeout: z.number().default(30000), + mode: z.union(['fast', 'accurate'] as const).default('fast'), }) export function apply(ctx: Context, config: Config) { - // config 已经过校验,类型安全 + // config 已经过校验,类型安全,默认值已填充 } ``` @@ -74,13 +68,14 @@ Schema 在插件加载时执行校验。如果配置不合法,插件会加载 Harness 的约定:**任何两个部署可能想要不同值的东西,都应该是配置字段**。 -```typescript +```ts // 错误 — 硬编码超时时间 const TIMEOUT = 30000 // 正确 — 可配置 export interface Config { - timeoutMs: number // 默认 30000 + /** 默认 30000 */ + timeoutMs?: number } ``` @@ -90,9 +85,16 @@ export interface Config { 如果配置引用了不存在的东西(比如一个不存在的模型名),应该尽早报错,而不是静默跳过: -```typescript +```ts +import type { Context } from 'cordis' +import type {} from '@deepseek-ai/dsh-llm' + +export interface Config { + model: string +} + export function apply(ctx: Context, config: Config) { - if (!ctx.llm.hasAdapter(config.model)) { + if (!ctx.llm.models().includes(config.model)) { throw new Error(`Model "${config.model}" is not registered by any LLM adapter`) } } diff --git a/website/zh-CN/develop/basic/index.md b/website/zh-CN/develop/basic/index.md index 71d6962edd..6b482f1401 100644 --- a/website/zh-CN/develop/basic/index.md +++ b/website/zh-CN/develop/basic/index.md @@ -6,7 +6,7 @@ 在 Harness 中,插件是一个导出 `apply` 函数的 TypeScript 模块。框架在加载时调用 `apply`,传入一个 `ctx`(上下文对象),你通过 `ctx` 注册能力: -```typescript +```ts import type { Context } from 'cordis' export const name = 'my-plugin' @@ -22,16 +22,14 @@ export function apply(ctx: Context) { 在你的项目目录下创建 `src/my-plugin.ts`: -```typescript +```ts import type { Context } from 'cordis' export const name = 'hello-plugin' export function apply(ctx: Context) { - // 监听 agent-loop 的 ready 事件 - ctx.on('ready', () => { - console.log('[hello-plugin] 插件已加载!') - }) + // apply 函数体在插件加载时执行 + console.log('[hello-plugin] 插件已加载!') } ``` @@ -52,7 +50,9 @@ export function apply(ctx: Context) { 如果你有需要手动清理的资源(比如一个网络连接),用 `ctx.effect()` 告诉框架怎么清理: -```typescript +```ts +import type { Context } from 'cordis' + export function apply(ctx: Context) { ctx.effect(() => { const timer = setInterval(() => { @@ -69,13 +69,23 @@ export function apply(ctx: Context) { 如果你的插件需要使用其他服务(如 `tools`、`llm`),需要声明 `inject`: -```typescript +```ts +import type { Context } from 'cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' + export const name = 'my-tool-plugin' export const inject = ['tools'] export function apply(ctx: Context) { // ctx.tools 现在可用 - ctx.tools.register(/* ... */) + ctx.tools.register(defineTool({ + name: 'demo', + description: 'Demo tool.', + parameters: {}, + async execute() { + return [] + }, + })) } ``` @@ -87,7 +97,10 @@ export function apply(ctx: Context) { ### 对象形式 -```typescript +```ts +import type { Context } from 'cordis' +import type {} from '@deepseek-ai/dsh-tools' + export default { name: 'my-plugin', inject: ['tools'], @@ -99,8 +112,9 @@ export default { ### 类形式 -```typescript -import { Service } from 'cordis' +```ts +import { Service, type Context } from 'cordis' +import type {} from '@deepseek-ai/dsh-tools' export default class MyService extends Service { static inject = ['tools'] @@ -109,8 +123,9 @@ export default class MyService extends Service { super(ctx, 'myService') } - start() { - // 服务启动逻辑 + // 服务的公开方法 + greet(name: string) { + return `Hello, ${name}!` } } ``` @@ -121,7 +136,7 @@ export default class MyService extends Service { 参考仓库中的 `examples/echo-agent/src/echo-tool.ts`,这是一个注册 tool 的插件: -```typescript +```ts import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' diff --git a/website/zh-CN/develop/basic/tool.md b/website/zh-CN/develop/basic/tool.md index 96d58da78d..d9e6f10b80 100644 --- a/website/zh-CN/develop/basic/tool.md +++ b/website/zh-CN/develop/basic/tool.md @@ -4,7 +4,7 @@ Tool 是模型可以调用的能力。本文介绍如何用 `defineTool` 编写 ## 最小示例 -```typescript +```ts import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' @@ -32,28 +32,34 @@ export function apply(ctx: Context) { ### 基本类型 -```typescript -parameters: { +```ts +import type { SchemaSpec } from '@deepseek-ai/dsh-tools' + +const parameters = { path: { type: 'string', required: true }, limit: { type: 'number' }, recursive: { type: 'boolean' }, -} +} satisfies SchemaSpec // 推导类型: { path: string; limit?: number; recursive?: boolean } ``` ### 枚举 -```typescript -parameters: { +```ts +import type { SchemaSpec } from '@deepseek-ai/dsh-tools' + +const parameters = { mode: { type: 'string', required: true, enum: ['read', 'write', 'append'] }, -} +} satisfies SchemaSpec // 推导类型: { mode: string } (运行时校验 enum 值) ``` ### 嵌套对象 -```typescript -parameters: { +```ts +import type { SchemaSpec } from '@deepseek-ai/dsh-tools' + +const parameters = { options: { type: 'object', properties: { @@ -61,19 +67,21 @@ parameters: { retries: { type: 'number' }, }, }, -} +} satisfies SchemaSpec // 推导类型: { options?: { timeout?: number; retries?: number } } ``` ### 数组 -```typescript -parameters: { +```ts +import type { SchemaSpec } from '@deepseek-ai/dsh-tools' + +const parameters = { tags: { type: 'array', items: { type: 'string' }, }, -} +} satisfies SchemaSpec // 推导类型: { tags?: string[] } ``` @@ -92,29 +100,44 @@ parameters: { `execute` 接收经过校验的 `args`(类型自动推导)和一个 `exec` 上下文对象: -```typescript -async execute(args, exec) { - // args: 根据 parameters 自动推导的类型 - // exec: ToolExecution 对象,提供执行上下文 +```ts +import { defineTool } from '@deepseek-ai/dsh-tools' - // 返回 ContentBlock 数组 - return [{ type: 'text', text: 'result here' }] -} +defineTool({ + name: 'demo', + description: 'Demo tool.', + parameters: {}, + async execute(args, exec) { + // args: 根据 parameters 自动推导的类型 + // exec: ToolExecution 对象,提供执行上下文 + + // 返回 ContentBlock 数组 + return [{ type: 'text', text: 'result here' }] + }, +}) ``` ### 返回值 `execute` 必须返回一个 `ContentBlock[]`,告诉模型 tool 的执行结果: -```typescript +```ts +import type { ContentBlock } from '@deepseek-ai/dsh-llm' + +declare const matchResults: string[] + // 文本结果 -return [{ type: 'text', text: 'file content here...' }] +function textResult(): ContentBlock[] { + return [{ type: 'text', text: 'file content here...' }] +} // 多个 block -return [ - { type: 'text', text: 'Found 3 matches:' }, - { type: 'text', text: matchResults.join('\n') }, -] +function multiBlockResult(): ContentBlock[] { + return [ + { type: 'text', text: 'Found 3 matches:' }, + { type: 'text', text: matchResults.join('\n') }, + ] +} ``` ### 参数校验 @@ -127,20 +150,28 @@ return [ Tool 可以定义 UI 渲染方法,用于在终端或 ACP 客户端中展示 tool call 和 result: -```typescript +```ts +import { defineTool } from '@deepseek-ai/dsh-tools' + defineTool({ name: 'bash', - // ... + description: 'Run a shell command.', + parameters: { + command: { type: 'string', required: true }, + }, + async execute(args) { + return [{ type: 'text', text: `ran: ${args.command}` }] + }, presentCall(args) { return { - intent: 'terminal', - title: `bash(${JSON.stringify(args.command).slice(0, 60)})`, + card: 'terminal', + title: args.command.slice(0, 60), } }, presentResult(args, result) { return { - intent: 'terminal', - body: result.content.map(b => b.type === 'text' ? b.text : '').join(''), + card: 'terminal', + output: result.content.map(b => b.type === 'text' ? b.text : '').join(''), } }, }) @@ -152,20 +183,32 @@ defineTool({ `ctx.tools.register()` 返回值就是 disposer。但由于你在 `ctx` 上调用,框架已经自动追踪了这个注册——插件卸载时会自动移除 tool。你不需要手动调用 disposer。 -```typescript +```ts +import type { Context } from 'cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' + +declare const ctx: Context + // 这样就够了: -ctx.tools.register(defineTool({ /* ... */ })) +ctx.tools.register(defineTool({ + name: 'noop', + description: 'Do nothing.', + parameters: {}, + async execute() { + return [] + }, +})) // 不需要: // const dispose = ctx.tools.register(...) -// ctx.on('dispose', dispose) +// ctx.effect(() => dispose) ``` ## 完整实战示例 一个文件计数 tool: -```typescript +```ts import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import { readdir } from 'node:fs/promises' diff --git a/website/zh-CN/develop/framework/events.md b/website/zh-CN/develop/framework/events.md index 0546fd68e7..d2ddcf17a2 100644 --- a/website/zh-CN/develop/framework/events.md +++ b/website/zh-CN/develop/framework/events.md @@ -6,7 +6,17 @@ ### 监听事件 -```typescript +```ts +import type { Context } from 'cordis' + +declare module 'cordis' { + interface Events { + 'event-name'(payload: string): void + } +} + +declare const ctx: Context + ctx.on('event-name', (payload) => { // 处理事件 }) @@ -14,7 +24,18 @@ ctx.on('event-name', (payload) => { ### 触发事件 -```typescript +```ts +import type { Context } from 'cordis' + +declare module 'cordis' { + interface Events { + 'event-name'(payload: string): void + } +} + +declare const ctx: Context +declare const payload: string + ctx.emit('event-name', payload) ``` @@ -26,12 +47,24 @@ Cordis 提供多种事件触发模式,适用于不同场景: 所有监听器并行执行,不关心返回值: -```typescript +```ts +import type { Context } from 'cordis' + +declare module 'cordis' { + interface Events { + 'my-plugin/turn-end'(agentId: string, turnIndex: number): void + } +} + +declare const ctx: Context +declare const agentId: string +declare const turnIndex: number + // 触发 -ctx.emit('agent/turn-end', { agentId, turnIndex }) +ctx.emit('my-plugin/turn-end', agentId, turnIndex) // 监听 -ctx.on('agent/turn-end', ({ agentId, turnIndex }) => { +ctx.on('my-plugin/turn-end', (agentId, turnIndex) => { console.log(`Turn ${turnIndex} ended`) }) ``` @@ -40,7 +73,19 @@ ctx.on('agent/turn-end', ({ agentId, turnIndex }) => { 依次调用监听器,第一个返回非 `undefined` 值的结果作为最终值: -```typescript +```ts +import type { Context } from 'cordis' + +declare module 'cordis' { + interface Events { + 'some-check'(input: string): string | undefined + } +} + +declare const ctx: Context +declare const input: string +declare function shouldBlock(input: string): boolean + // 触发 const result = ctx.bail('some-check', input) @@ -48,6 +93,7 @@ const result = ctx.bail('some-check', input) ctx.on('some-check', (input) => { if (shouldBlock(input)) return 'blocked' // 返回 undefined 继续传递给下一个监听器 + return undefined }) ``` @@ -55,24 +101,47 @@ ctx.on('some-check', (input) => { 所有监听器按注册顺序依次执行(异步安全): -```typescript +```ts +import type { Context } from 'cordis' + +declare module 'cordis' { + interface Events { + 'setup-phase'(context: object): Promise | void + } +} + +declare const ctx: Context +declare const context: object + await ctx.serial('setup-phase', context) ``` ### waterfall — 管道 -每个监听器接收前一个的输出,形成数据管道。**必须调用 `next()` 传递给下游**,不调用即为否决: +监听器围绕默认实现层层包裹,形成数据管道。**必须调用 `next()` 委托给下游**,不调用即为否决: -```typescript -// 触发 -const finalMessages = await ctx.waterfall('llm/pre-request', messages) +```ts +import type { Context } from 'cordis' +import type { Message } from '@deepseek-ai/dsh-llm' + +declare module 'cordis' { + interface Events { + 'my-plugin/messages'(messages: Message[], next: () => Promise): Promise + } +} + +declare const ctx: Context +declare const messages: Message[] +declare const extraMessage: Message + +// 触发:最后一个参数是默认实现(所有监听器都调用 next 时的最终值) +const finalMessages = await ctx.waterfall('my-plugin/messages', messages, async () => messages) // 监听(必须调用 next) -ctx.on('llm/pre-request', async (messages, next) => { - // 可以修改 messages - messages.push(extraMessage) - // 必须调用 next() 传递给下一个监听器 - return next(messages) +ctx.on('my-plugin/messages', async (messages, next) => { + // next() 委托给下游监听器(最终到达默认实现),返回值可以被加工 + const result = await next() + return [...result, extraMessage] }) ``` @@ -84,11 +153,13 @@ Waterfall 监听器**必须调用 `next()`**。不调用 `next` 等于否决整 Harness 使用 TypeScript 声明合并来为事件提供类型安全: -```typescript +```ts +import type {} from 'cordis' + declare module 'cordis' { interface Events { - 'my-plugin/ready': (payload: { id: string }) => void - 'my-plugin/check': (input: string) => boolean | undefined + 'my-plugin/ready'(payload: { id: string }): void + 'my-plugin/check'(input: string): boolean | undefined } } @@ -101,24 +172,30 @@ declare module 'cordis' { Harness 事件遵循 `namespace/action` 命名: ``` -agent/pre-step — agent 执行一步之前 -agent/post-step — agent 执行一步之后 -tool/call — tool 被调用 -tool/result — tool 返回结果 -llm/pre-request — LLM 请求发送前 -session/event — 会话事件被记录 -compact/start — 压缩开始 -compact/end — 压缩结束 +agent/pre-step — 每个 step 开始前的检查点(serial) +agent/step-result — step 的 assistant 消息组装完成(waterfall) +tools/pre-execute — tool 执行前的允许/拒绝门(waterfall) +tools/post-execute — tool 执行后的检查/改写缝(waterfall) +llm/stream — 每次流式模型调用的环绕点(waterfall) +session/event — 会话事件被记录(emit) +session/flush — 会话持久化检查点(parallel) ``` +完整的事件列表(含每个事件的签名与派发模式)见仓库中的 `docs/cordis-catalog/events.md`。 + ## 事件也是效果 通过 `ctx.on()` 注册的监听器会在插件卸载时自动移除: -```typescript +```ts +import type { Context } from 'cordis' +import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' + +declare function handler(agent: Agent, status: AgentStatus): void + export function apply(ctx: Context) { // 这个监听器在插件 dispose 时自动清理 - ctx.on('agent/turn-end', handler) + ctx.on('agent/status', handler) } ``` @@ -126,22 +203,21 @@ export function apply(ctx: Context) { 一个记录所有 tool 调用的简单插件: -```typescript +```ts import type { Context } from 'cordis' +import type {} from '@deepseek-ai/dsh-tools' export const name = 'tool-logger' export function apply(ctx: Context) { - ctx.on('tool/call', ({ name, args }) => { - console.log(`[tool] ${name}(${JSON.stringify(args)})`) - }) - - ctx.on('tool/result', ({ name, result }) => { + ctx.on('tools/execute', async (exec, next) => { + console.log(`[tool] ${exec.name}(${JSON.stringify(exec.arguments)})`) + const result = await next() const text = result.content - .filter(b => b.type === 'text') - .map(b => b.text) + .map(b => b.type === 'text' ? b.text : '') .join('') console.log(`[tool result] ${text.slice(0, 100)}`) + return result }) } ``` diff --git a/website/zh-CN/develop/framework/index.md b/website/zh-CN/develop/framework/index.md index 8d2f7c2b8a..d9c6def99a 100644 --- a/website/zh-CN/develop/framework/index.md +++ b/website/zh-CN/develop/framework/index.md @@ -25,7 +25,11 @@ ACTIVE → UNLOADING → DISPOSED 声明了 `inject` 的插件不会立即加载,而是等待依赖的服务就绪: -```typescript +```ts +import type { Context } from 'cordis' +import type {} from '@deepseek-ai/dsh-tools' +import type {} from '@deepseek-ai/dsh-llm' + export const inject = ['tools', 'llm'] export function apply(ctx: Context) { @@ -39,10 +43,21 @@ export function apply(ctx: Context) { 通过 `ctx` 做的任何注册,在插件卸载时都会自动撤销: -```typescript +```ts +import type { Context } from 'cordis' + +declare module 'cordis' { + interface Events { + 'my-plugin/some-event'(): void + } +} + +declare function handler(): void +declare function createConnection(): { close(): void } + export function apply(ctx: Context) { // 事件监听——卸载时自动移除 - ctx.on('some-event', handler) + ctx.on('my-plugin/some-event', handler) // 自定义资源——卸载时调用返回的函数 ctx.effect(() => { @@ -64,7 +79,11 @@ export function apply(ctx: Context) { `ctx.plugin()` 创建子 Fiber,它继承父上下文但有独立的生命周期: -```typescript +```ts +import type { Context } from 'cordis' + +declare function childPlugin(ctx: Context): void + export function apply(ctx: Context) { // 注册一个子插件 ctx.plugin(childPlugin) @@ -77,11 +96,16 @@ export function apply(ctx: Context) { 当你需要提前终止一个插件实例: -```typescript +```ts +import type { Context } from 'cordis' + +declare const ctx: Context +declare function myPlugin(ctx: Context): void + const fiber = ctx.plugin(myPlugin) // 之后可以手动 dispose -fiber.dispose() +await fiber.dispose() ``` `dispose` 保证: @@ -101,18 +125,14 @@ fiber.dispose() ## 实战:理解生命周期 -```typescript +`apply` 函数体就是加载钩子;卸载没有专门的事件——把清理逻辑放进 `ctx.effect()` 的返回函数即可: + +```ts +import type { Context } from 'cordis' + export function apply(ctx: Context) { console.log('plugin loading') - ctx.on('ready', () => { - console.log('context ready') - }) - - ctx.on('dispose', () => { - console.log('plugin disposing') - }) - ctx.effect(() => { console.log('effect registered') return () => console.log('effect cleaned up') @@ -124,12 +144,10 @@ export function apply(ctx: Context) { ``` plugin loading effect registered -context ready ``` -卸载时输出(逆序): +卸载时输出: ``` -plugin disposing effect cleaned up ``` diff --git a/website/zh-CN/develop/framework/service.md b/website/zh-CN/develop/framework/service.md index 08d9a1b2c8..7508d02675 100644 --- a/website/zh-CN/develop/framework/service.md +++ b/website/zh-CN/develop/framework/service.md @@ -6,10 +6,17 @@ 在 Harness 中,`tools`、`llm`、`agents` 都是服务。服务是挂载在 `ctx` 上的命名能力: -```typescript +```ts +import type { Context } from 'cordis' +import type {} from '@deepseek-ai/dsh-tools' +import type {} from '@deepseek-ai/dsh-llm' +import type {} from '@deepseek-ai/dsh-agent' + +declare const ctx: Context + ctx.tools // ToolRegistry 服务 ctx.llm // LLM 服务 -ctx.agents // Agent 服务 +ctx.agents // Agent 注册表服务 ``` 任何插件都可以提供一个新服务,供其他插件使用。 @@ -18,12 +25,22 @@ ctx.agents // Agent 服务 声明 `inject` 来使用已有服务: -```typescript +```ts +import type { Context } from 'cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' + export const inject = ['tools'] export function apply(ctx: Context) { // ctx.tools 在这里一定存在且就绪 - ctx.tools.register(/* ... */) + ctx.tools.register(defineTool({ + name: 'demo', + description: 'Demo tool.', + parameters: {}, + async execute() { + return [] + }, + })) } ``` @@ -33,8 +50,9 @@ export function apply(ctx: Context) { ### 使用 Service 基类 -```typescript +```ts import { Service, type Context } from 'cordis' +import type {} from '@deepseek-ai/dsh-llm' export default class MetricsService extends Service { static inject = ['llm'] // 本服务也可以依赖其他服务 @@ -52,7 +70,9 @@ export default class MetricsService extends Service { 加载这个插件后,其他插件就可以通过 `ctx.metrics` 访问它: -```typescript +```ts +import type { Context } from 'cordis' + export const inject = ['metrics'] export function apply(ctx: Context) { @@ -64,7 +84,7 @@ export function apply(ctx: Context) { 使用 TypeScript 声明合并让 `ctx.metrics` 有正确类型: -```typescript +```ts import { Service, type Context } from 'cordis' declare module 'cordis' { @@ -84,14 +104,21 @@ export default class MetricsService extends Service { ## 依赖的行为 -### 必选依赖 vs 可选依赖 +### 必选依赖 vs 可选读取 + +`inject` 声明的依赖都是必选的:服务不存在时,插件不会加载。如果只想"有则用之",用 `ctx.get()` 读取——服务不存在时返回 `undefined`,插件照常加载: + +```ts +import type { Context } from 'cordis' -```typescript // 必选:服务不存在时,插件不会加载 export const inject = ['tools'] -// 可选:服务不存在时,插件仍然加载,但 ctx.xxx 可能是 undefined -export const inject = { optional: ['metrics'] } +export function apply(ctx: Context) { + // 可选读取:不声明 inject,服务不存在时返回 undefined + const metrics = ctx.get('metrics') + metrics?.record('plugin_loaded', 1) +} ``` ### 服务消失时的行为 @@ -133,13 +160,14 @@ export const inject = { optional: ['metrics'] } |--------|--------|------| | `tools` | dsh-tools | Tool 注册表 | | `llm` | dsh-llm | LLM 调用 + 适配器注册 | -| `agents` | dsh-agent | Agent 实例管理 | -| `session` | dsh-session | 会话事件流 | +| `agents` | dsh-agent | Agent 注册表 | +| `agentLoop` | dsh-agent-loop | Agent 创建与循环执行 | +| `sessions` | dsh-session | 会话存储与事件流 | | `systemPrompt` | dsh-system-prompt | 系统提示词组装 | -| `bash` | dsh-bash-local | Bash 命令执行 | -| `fs` | dsh-fs-local | 文件系统操作 | -| `subagent` | dsh-subagent | 子代理委派 | -| `persistence` | dsh-session-persistence | 会话持久化 | +| `bash` | dsh-bash(实现:dsh-bash-local) | Bash 命令执行 | +| `fs` | dsh-fs(实现:dsh-fs-local) | 文件系统操作 | +| `subagents` | dsh-subagent | 子代理委派 | +| `sessionPersistence` | dsh-session-persistence(实现:-jsonl / -sqlite) | 会话持久化 | ## 下一步 diff --git a/website/zh-CN/develop/practice/index.md b/website/zh-CN/develop/practice/index.md index dd0ec1cb60..9781138c50 100644 --- a/website/zh-CN/develop/practice/index.md +++ b/website/zh-CN/develop/practice/index.md @@ -64,7 +64,7 @@ ### 第一步:定义接口 -```typescript +```ts // packages/my-cap/my-cap/src/index.ts import { Service, type Context } from 'cordis' @@ -94,7 +94,7 @@ export interface MyCapResult { ### 第二步:编写实现 -```typescript +```ts ignore-check // packages/my-cap/my-cap-local/src/index.ts import type { Context } from 'cordis' import { MyCapService, type MyCapRequest, type MyCapResult } from '@deepseek-ai/dsh-my-cap' @@ -115,7 +115,7 @@ export function apply(ctx: Context) { ### 第三步:编写消费者 (tool) -```typescript +```ts // packages/my-cap/tool-my-cap/src/index.ts import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' diff --git a/website/zh-CN/develop/practice/llm-adapter.md b/website/zh-CN/develop/practice/llm-adapter.md index 20b1fa2c88..ce60b8078f 100644 --- a/website/zh-CN/develop/practice/llm-adapter.md +++ b/website/zh-CN/develop/practice/llm-adapter.md @@ -8,7 +8,7 @@ LLM 适配器是一个继承 `LlmAdapter` 的类,实现 `stream()` 方法, ## 最小实现 -```typescript +```ts import type { Context } from 'cordis' import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm' @@ -45,47 +45,51 @@ export function apply(ctx: Context, config: Config) { `stream()` 必须按以下协议 yield chunk: -```typescript -// 1. 每个内容块以 block-start 开始 -yield { type: 'block-start', index: 0, blockType: 'text' } +```ts +import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm' -// 2. 文本块使用 text-delta -yield { type: 'text-delta', index: 0, text: 'Hello' } -yield { type: 'text-delta', index: 0, text: ' world' } +async function* demo(): AsyncIterable { + // 1. 每个内容块以 block-start 开始 + yield { type: 'block-start', index: 0, blockType: 'text' } -// 3. 每个内容块以 block-end 结束(携带完整 block) -yield { - type: 'block-end', - index: 0, - block: { type: 'text', text: 'Hello world' }, -} + // 2. 文本块使用 text-delta + yield { type: 'text-delta', index: 0, text: 'Hello' } + yield { type: 'text-delta', index: 0, text: ' world' } -// 4. Tool call 块 -yield { type: 'block-start', index: 1, blockType: 'tool-call' } -yield { - type: 'tool-call-delta', - index: 1, - id: CallId('call-123'), - name: 'bash', - argumentsDelta: '{"command":"ls"}', -} -yield { - type: 'block-end', - index: 1, - block: { - type: 'tool-call', + // 3. 每个内容块以 block-end 结束(携带完整 block) + yield { + type: 'block-end', + index: 0, + block: { type: 'text', text: 'Hello world' }, + } + + // 4. Tool call 块 + yield { type: 'block-start', index: 1, blockType: 'tool-call' } + yield { + type: 'tool-call-delta', + index: 1, id: CallId('call-123'), name: 'bash', - arguments: '{"command":"ls"}', - }, + argumentsDelta: '{"command":"ls"}', + } + yield { + type: 'block-end', + index: 1, + block: { + type: 'tool-call', + id: CallId('call-123'), + name: 'bash', + arguments: '{"command":"ls"}', + }, + } + + // 5. Token 用量 + yield { type: 'usage', usage: { inputTokens: 100, outputTokens: 50 } } + + // 6. 结束原因 + yield { type: 'finish', reason: { kind: 'stop' } } + // 或: { kind: 'tool-calls' } 表示模型想调用 tool } - -// 5. Token 用量 -yield { type: 'usage', usage: { inputTokens: 100, outputTokens: 50 } } - -// 6. 结束原因 -yield { type: 'finish', reason: { kind: 'stop' } } -// 或: { kind: 'tool-calls' } 表示模型想调用 tool ``` ### 关键规则 @@ -100,28 +104,31 @@ yield { type: 'finish', reason: { kind: 'stop' } } `stream()` 接收的请求包含: -```typescript -interface GenerateOptions { - /** 模型名 */ - model: string - /** 对话历史 */ - messages: Message[] - /** 可用的 tool 列表 */ - tools?: ToolSpec[] - /** 系统提示词 */ - system?: string - /** 最大输出 token */ - maxTokens?: number - /** 温度 */ - temperature?: number -} +```ts +import type { GenerateOptions } from '@deepseek-ai/dsh-llm' + +declare const options: GenerateOptions + +options.model // 模型名 +options.messages // 对话历史 (Message[]) +options.tools // 可用的 tool schema 列表 (ToolSchema[]) +options.system // 系统提示词 +options.maxTokens // 最大输出 token +options.temperature // 温度 +options.signal // 取消信号(必须响应) ``` 你的适配器需要将这些映射到具体 API 的参数。 ## 注册适配器 -```typescript +```ts +import type { Context } from 'cordis' +import type { LlmAdapter } from '@deepseek-ai/dsh-llm' + +declare const ctx: Context +declare const adapter: LlmAdapter + ctx.llm.registerAdapter(['model-name-1', 'model-name-2'], adapter) ``` @@ -158,12 +165,18 @@ mock 适配器是学习 StreamChunk 协议的最佳起点——它用纯本地 适配器中的异常会被 agent-loop 捕获并转化为 `LlmError`,告知上层。不需要在 `stream()` 内部做错误恢复——让异常冒泡即可。 -```typescript -async *stream(options: GenerateOptions): AsyncIterable { - const response = await fetch(this.endpoint, { /* ... */ }) - if (!response.ok) { - throw new Error(`API error: ${response.status}`) +```ts +import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm' + +class HttpAdapter extends LlmAdapter { + private endpoint = 'https://api.example.com/v1/chat' + + async *stream(options: GenerateOptions): AsyncIterable { + const response = await fetch(this.endpoint, { method: 'POST' }) + if (!response.ok) { + throw new Error(`API error: ${response.status}`) + } + // ... 正常流式处理 } - // ... 正常流式处理 } ``` diff --git a/website/zh-CN/guide/config.md b/website/zh-CN/guide/config.md index d555a0a478..3f194edc79 100644 --- a/website/zh-CN/guide/config.md +++ b/website/zh-CN/guide/config.md @@ -87,6 +87,7 @@ Harness 使用 `cordis.yml` 描述一个 Agent 加载哪些插件、以什么参 # 自动压缩:对话太长时自动总结旧内容,腾出上下文空间 # contextWindow 是模型能看到的 token 上限 # thresholdRatio 超过这个比例就触发压缩 +# compactionRetries 是压缩后仍超标时的额外重试次数 - id: compact-basic name: '@deepseek-ai/dsh-compact-basic' config: @@ -94,6 +95,7 @@ Harness 使用 `cordis.yml` 描述一个 Agent 加载哪些插件、以什么参 thresholdRatio: 0.8 retainTokens: 20480 maxTokens: 8192 + compactionRetries: 1 # 子代理:把子任务分配给独立的 Agent 去做 # subagent 是服务注册,spawn/fork 是两种委派方式: @@ -125,6 +127,16 @@ Harness 使用 `cordis.yml` 描述一个 Agent 加载哪些插件、以什么参 provider: fork toolName: subagent_fork +# 动态工作流:模型编写一段编排脚本,引擎在独立 worker 线程里运行它, +# 并通过上面的 spawn 后端把 agent() 调用分发为子代理 +- id: workflow-workerthread + name: '@deepseek-ai/dsh-workflow-workerthread' + config: + provider: spawn + +- id: tool-workflow + name: '@deepseek-ai/dsh-tool-workflow' + # 任务追踪:模型可以用 todo_write 记录和更新任务清单 - id: tool-todo name: '@deepseek-ai/dsh-tool-todo' @@ -156,9 +168,13 @@ Harness 使用 `cordis.yml` 描述一个 Agent 加载哪些插件、以什么参 | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | `name` | string | 是 | 插件来源(npm 包名或相对路径) | -| `id` | string | 否 | 实例标识符,用于日志和调试 | +| `id` | string | 否 | 实例标识符,用于日志和调试。省略时由 loader 生成并写回 | | `config` | object | 否 | 传递给插件的配置 | | `disabled` | boolean | 否 | 设为 `true` 临时禁用该插件 | +| `group` | boolean | 否 | 标记该条目为嵌套分组(`config` 为子条目列表) | +| `inject` | array \| object | 否 | 声明该插件依赖的服务 | +| `intercept` | object | 否 | 按服务名拦截并覆盖下游配置 | +| `isolate` | object | 否 | 服务隔离:服务名 → `true` 或隔离标签 | ### 插件来源 (`name`) From 83cb48441ea14d03fb98b9105cbadd4fddc966ff Mon Sep 17 00:00:00 2001 From: lintianle Date: Thu, 16 Jul 2026 18:12:36 +0800 Subject: [PATCH 05/14] vendor(cordis): document the full plugin-author surface (@param/@returns everywhere) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comment-only enrichment across cordis/src/*.ts — Context, EventsService (+ the ctx merges), Fiber, RegistryService, ReflectService, Service, logger — so the website API generator can render a complete reference and hard-error on any future undocumented member (vendor sync included). Logged as local modification 6 in vendor/README.md; retire it when upstreamed to the fork. INHERITED_SERVICES/EVENTS source pointers refreshed for the shifted lines; cordis catalogs regenerated. --- docs/cordis-catalog/events.md | 16 ++-- docs/cordis-catalog/services.md | 8 +- scripts/gen-cordis-catalog.ts | 24 ++--- vendor/README.md | 1 + vendor/cordis/src/context.ts | 57 +++++++++++- vendor/cordis/src/events.ts | 156 ++++++++++++++++++++++++++++++-- vendor/cordis/src/fiber.ts | 117 ++++++++++++++++++++++-- vendor/cordis/src/logger.ts | 10 +- vendor/cordis/src/reflect.ts | 125 +++++++++++++++++++++++++ vendor/cordis/src/registry.ts | 97 +++++++++++++++++++- vendor/cordis/src/service.ts | 31 ++++++- 11 files changed, 587 insertions(+), 55 deletions(-) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index a9dfb7e05c..78fee7e8e0 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -427,14 +427,14 @@ Source: [`packages/workflow/workflow/src/index.ts:62`](../../packages/workflow/w The framework events every plugin also sees, beyond the harness vocabulary above. This is pinned vendor source ([vendoring policy](../../vendor/README.md)); it is summarized here so the page is a complete picture of the event bus, without elevating framework internals to the harness tier's prominence. -- `internal/plugin` — A plugin fiber was created. ([`vendor/cordis/src/events.ts:197`](../../vendor/cordis/src/events.ts)) -- `internal/status` — A fiber changed lifecycle state. ([`vendor/cordis/src/events.ts:198`](../../vendor/cordis/src/events.ts)) -- `internal/service` — Interception hook for a service binding (no core producer). ([`vendor/cordis/src/events.ts:199`](../../vendor/cordis/src/events.ts)) -- `internal/update` — Waterfall: a fiber config update is being applied. ([`vendor/cordis/src/events.ts:200`](../../vendor/cordis/src/events.ts)) -- `internal/get` — Waterfall: a service is being read from the store. ([`vendor/cordis/src/events.ts:201`](../../vendor/cordis/src/events.ts)) -- `internal/set` — Waterfall: a service is being written to the store. ([`vendor/cordis/src/events.ts:202`](../../vendor/cordis/src/events.ts)) -- `internal/listener` — A listener was registered. ([`vendor/cordis/src/events.ts:203`](../../vendor/cordis/src/events.ts)) -- `internal/dispatch` — An event is being dispatched to listeners. ([`vendor/cordis/src/events.ts:204`](../../vendor/cordis/src/events.ts)) +- `internal/plugin` — A plugin fiber was created. ([`vendor/cordis/src/events.ts:328`](../../vendor/cordis/src/events.ts)) +- `internal/status` — A fiber changed lifecycle state. ([`vendor/cordis/src/events.ts:330`](../../vendor/cordis/src/events.ts)) +- `internal/service` — Interception hook for a service binding (no core producer). ([`vendor/cordis/src/events.ts:332`](../../vendor/cordis/src/events.ts)) +- `internal/update` — Waterfall: a fiber config update is being applied. ([`vendor/cordis/src/events.ts:334`](../../vendor/cordis/src/events.ts)) +- `internal/get` — Waterfall: a service is being read from the store. ([`vendor/cordis/src/events.ts:336`](../../vendor/cordis/src/events.ts)) +- `internal/set` — Waterfall: a service is being written to the store. ([`vendor/cordis/src/events.ts:338`](../../vendor/cordis/src/events.ts)) +- `internal/listener` — A listener was registered. ([`vendor/cordis/src/events.ts:340`](../../vendor/cordis/src/events.ts)) +- `internal/dispatch` — An event is being dispatched to listeners. ([`vendor/cordis/src/events.ts:342`](../../vendor/cordis/src/events.ts)) - `hmr/change` — A watched source file changed on disk. ([`vendor/hmr/src/index.ts:20`](../../vendor/hmr/src/index.ts)) - `hmr/reload` — Plugins are being reloaded after a change. ([`vendor/hmr/src/index.ts:21`](../../vendor/hmr/src/index.ts)) - `exit` — The process is exiting on a signal. ([`vendor/loader/src/index.ts:23`](../../vendor/loader/src/index.ts)) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 79902ba2fc..edd5434977 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -282,12 +282,12 @@ Source: [`packages/workflow/workflow/src/index.ts:210`](../../packages/workflow/ The framework `ctx` surface every plugin also sees, beyond the harness services above. This is pinned vendor source ([vendoring policy](../../vendor/README.md)); it is summarized here so the page is a complete picture of what `ctx` offers, without elevating framework internals to the harness tier's prominence. -- `ctx.on / ctx.once` — Register an event listener (disposable). ([`vendor/cordis/src/events.ts:29`](../../vendor/cordis/src/events.ts)) -- `ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall` — Dispatch an event (sync / awaited / first-bail / veto-chain). ([`vendor/cordis/src/events.ts:29`](../../vendor/cordis/src/events.ts)) -- `ctx.plugin / ctx.inject` — Load a plugin / declare required services. ([`vendor/cordis/src/registry.ts:144`](../../vendor/cordis/src/registry.ts)) +- `ctx.on / ctx.once` — Register an event listener (disposable). ([`vendor/cordis/src/events.ts:34`](../../vendor/cordis/src/events.ts)) +- `ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall` — Dispatch an event (sync / awaited / first-bail / veto-chain). ([`vendor/cordis/src/events.ts:34`](../../vendor/cordis/src/events.ts)) +- `ctx.plugin / ctx.inject` — Load a plugin / declare required services. ([`vendor/cordis/src/registry.ts:164`](../../vendor/cordis/src/registry.ts)) - `ctx.effect` — Register a disposable side effect tied to the fiber. ([`vendor/cordis/src/fiber.ts:9`](../../vendor/cordis/src/fiber.ts)) - `ctx.get / ctx.set / ctx.provide / ctx.accessor / ctx.mixin` — Low-level service-store access and binding. ([`vendor/cordis/src/reflect.ts:7`](../../vendor/cordis/src/reflect.ts)) -- `ctx.extend / ctx.isolate / ctx.intercept` — Derive a child context (scoped services / isolation / interception). ([`vendor/cordis/src/context.ts:35`](../../vendor/cordis/src/context.ts)) +- `ctx.extend / ctx.isolate / ctx.intercept` — Derive a child context (scoped services / isolation / interception). ([`vendor/cordis/src/context.ts:42`](../../vendor/cordis/src/context.ts)) - `ctx.root / ctx.scope / ctx.fiber / ctx.registry / ctx.reflect / ctx.events / ctx.logger` — Ambient handles onto the running context graph. ([`vendor/cordis/src/context.ts:16`](../../vendor/cordis/src/context.ts)) - `ctx.timer (+ interval / timeout / throttle / debounce / setTimeout / setInterval)` — Disposable timer helpers. The `timer` key is provided at runtime; the six helpers are mixed onto ctx directly (declared via Pick). ([`vendor/timer/src/index.ts:4`](../../vendor/timer/src/index.ts)) - `ctx.loader` — The config Loader that booted the app (present under the loader). ([`vendor/loader/src/index.ts:30`](../../vendor/loader/src/index.ts)) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 9b8141807e..dc2023f4cc 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -310,14 +310,14 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] { * sibling check is N/A; keep them current on a vendor bump. */ const INHERITED_EVENTS: InheritedEntry[] = [ - { name: 'internal/plugin', summary: 'A plugin fiber was created.', source: 'vendor/cordis/src/events.ts:197' }, - { name: 'internal/status', summary: 'A fiber changed lifecycle state.', source: 'vendor/cordis/src/events.ts:198' }, - { name: 'internal/service', summary: 'Interception hook for a service binding (no core producer).', source: 'vendor/cordis/src/events.ts:199' }, - { name: 'internal/update', summary: 'Waterfall: a fiber config update is being applied.', source: 'vendor/cordis/src/events.ts:200' }, - { name: 'internal/get', summary: 'Waterfall: a service is being read from the store.', source: 'vendor/cordis/src/events.ts:201' }, - { name: 'internal/set', summary: 'Waterfall: a service is being written to the store.', source: 'vendor/cordis/src/events.ts:202' }, - { name: 'internal/listener', summary: 'A listener was registered.', source: 'vendor/cordis/src/events.ts:203' }, - { name: 'internal/dispatch', summary: 'An event is being dispatched to listeners.', source: 'vendor/cordis/src/events.ts:204' }, + { name: 'internal/plugin', summary: 'A plugin fiber was created.', source: 'vendor/cordis/src/events.ts:328' }, + { name: 'internal/status', summary: 'A fiber changed lifecycle state.', source: 'vendor/cordis/src/events.ts:330' }, + { name: 'internal/service', summary: 'Interception hook for a service binding (no core producer).', source: 'vendor/cordis/src/events.ts:332' }, + { name: 'internal/update', summary: 'Waterfall: a fiber config update is being applied.', source: 'vendor/cordis/src/events.ts:334' }, + { name: 'internal/get', summary: 'Waterfall: a service is being read from the store.', source: 'vendor/cordis/src/events.ts:336' }, + { name: 'internal/set', summary: 'Waterfall: a service is being written to the store.', source: 'vendor/cordis/src/events.ts:338' }, + { name: 'internal/listener', summary: 'A listener was registered.', source: 'vendor/cordis/src/events.ts:340' }, + { name: 'internal/dispatch', summary: 'An event is being dispatched to listeners.', source: 'vendor/cordis/src/events.ts:342' }, { name: 'hmr/change', summary: 'A watched source file changed on disk.', source: 'vendor/hmr/src/index.ts:20' }, { name: 'hmr/reload', summary: 'Plugins are being reloaded after a change.', source: 'vendor/hmr/src/index.ts:21' }, { name: 'exit', summary: 'The process is exiting on a signal.', source: 'vendor/loader/src/index.ts:23' }, @@ -328,12 +328,12 @@ const INHERITED_EVENTS: InheritedEntry[] = [ ] export const INHERITED_SERVICES: InheritedEntry[] = [ - { name: 'ctx.on / ctx.once', summary: 'Register an event listener (disposable).', source: 'vendor/cordis/src/events.ts:29' }, - { name: 'ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall', summary: 'Dispatch an event (sync / awaited / first-bail / veto-chain).', source: 'vendor/cordis/src/events.ts:29' }, - { name: 'ctx.plugin / ctx.inject', summary: 'Load a plugin / declare required services.', source: 'vendor/cordis/src/registry.ts:144' }, + { name: 'ctx.on / ctx.once', summary: 'Register an event listener (disposable).', source: 'vendor/cordis/src/events.ts:34' }, + { name: 'ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall', summary: 'Dispatch an event (sync / awaited / first-bail / veto-chain).', source: 'vendor/cordis/src/events.ts:34' }, + { name: 'ctx.plugin / ctx.inject', summary: 'Load a plugin / declare required services.', source: 'vendor/cordis/src/registry.ts:164' }, { name: 'ctx.effect', summary: 'Register a disposable side effect tied to the fiber.', source: 'vendor/cordis/src/fiber.ts:9' }, { name: 'ctx.get / ctx.set / ctx.provide / ctx.accessor / ctx.mixin', summary: 'Low-level service-store access and binding.', source: 'vendor/cordis/src/reflect.ts:7' }, - { name: 'ctx.extend / ctx.isolate / ctx.intercept', summary: 'Derive a child context (scoped services / isolation / interception).', source: 'vendor/cordis/src/context.ts:35' }, + { name: 'ctx.extend / ctx.isolate / ctx.intercept', summary: 'Derive a child context (scoped services / isolation / interception).', source: 'vendor/cordis/src/context.ts:42' }, { name: 'ctx.root / ctx.scope / ctx.fiber / ctx.registry / ctx.reflect / ctx.events / ctx.logger', summary: 'Ambient handles onto the running context graph.', source: 'vendor/cordis/src/context.ts:16' }, { name: 'ctx.timer (+ interval / timeout / throttle / debounce / setTimeout / setInterval)', summary: 'Disposable timer helpers. The `timer` key is provided at runtime; the six helpers are mixed onto ctx directly (declared via Pick).', source: 'vendor/timer/src/index.ts:4' }, { name: 'ctx.loader', summary: 'The config Loader that booted the app (present under the loader).', source: 'vendor/loader/src/index.ts:30' }, diff --git a/vendor/README.md b/vendor/README.md index bf0f0b5a8c..8487ab1086 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -35,6 +35,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 3. **All `tsconfig.json` files**: regenerated to extend the repo-root `tsconfig.base.json`, emit TypeScript intermediates to `lib/types`, and declare project references. 4. **Vendored TypeScript source internal specifiers**: changed local relative imports/exports from upstream's specifier shape to explicit `.ts` specifiers so TypeScript rewrites emitted JS to `.js` while declarations keep explicit, NodeNext-safe `.ts` specifiers. This includes `loader/src/config/isolate.ts` using `declare module './entry.ts'`. 5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface. +6. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context`, `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. ## Sync procedure diff --git a/vendor/cordis/src/context.ts b/vendor/cordis/src/context.ts index 8b21c464b2..b34b575cb9 100644 --- a/vendor/cordis/src/context.ts +++ b/vendor/cordis/src/context.ts @@ -14,14 +14,21 @@ import { Fiber } from './fiber.ts' * be read from `ctx`. */ export interface Context { + /** Isolation map: service name → scope label. Lookups for a name resolve within its label. */ [symbols.isolate]: Dict + /** Intercept map: service name → config merged into that service's per-plugin config. */ [symbols.intercept]: Dict /** @experimental */ root: this + /** Base URL used to resolve relative plugin/module specifiers, if the runtime sets one. */ baseUrl?: string + /** The event bus. Its methods are also mixed onto `ctx` (`ctx.on`, `ctx.emit`, ...). */ events: EventsService + /** The logging service. Call `ctx.logger(name)` for a named logger. */ logger: LoggerService + /** The reflection layer backing the context proxy (`ctx.get`, `ctx.provide`, ...). */ reflect: ReflectService + /** The plugin registry. Its methods are mixed onto `ctx` (`ctx.plugin`, `ctx.inject`). */ registry: RegistryService } @@ -33,12 +40,24 @@ export interface Context { * contexts without mutating their parent. */ export class Context { + /** Symbol key under which a disposer exposes its {@link EffectMeta} diagnostics tree. */ static readonly effect: unique symbol = symbols.effect + /** Symbol key for a context's listener filter, consulted on every event dispatch. */ static readonly filter: unique symbol = symbols.filter + /** Symbol key of the isolation map (see the `Context[symbols.isolate]` property). */ static readonly isolate: unique symbol = symbols.isolate + /** Symbol key of the intercept map (see the `Context[symbols.intercept]` property). */ static readonly intercept: unique symbol = symbols.intercept - /** Returns true for Cordis context proxies and context prototypes. */ + /** + * Returns true for Cordis context proxies and context prototypes. + * + * Works across realms and across multiple copies of cordis, because the + * brand is keyed by a global symbol rather than by `instanceof`. + * + * @param value — the value to test. + * @returns `true` if `value` is a Cordis context, narrowing its type. + */ static is(value: any): value is Context { return !!value?.[Context.is as any] } @@ -68,7 +87,15 @@ export class Context { return `Context <${this.fiber.name}>` } - /** Create a child context with extra metadata on top of the current scope. */ + /** + * Create a child context with extra metadata on top of the current scope. + * + * The child prototypally inherits every property of this context; own + * properties of `meta` shadow the inherited ones. The parent is not mutated. + * + * @param meta — own properties (including symbol keys) to define on the child. + * @returns a child context inheriting from this one. + */ extend(meta = {}): this { const shadow = Reflect.getOwnPropertyDescriptor(this, symbols.shadow)?.value const self = Object.create(getTraceable(this, this)) @@ -79,14 +106,36 @@ export class Context { return Object.assign(Object.create(self), { [symbols.shadow]: shadow }) } - /** Create a child context with an independent service scope for `name`. */ + /** + * Create a child context with an independent service scope for `name`. + * + * Below the returned context, reads and writes of the service `name` + * resolve against the new label instead of the parent's, so a different + * implementation can be provided without affecting the parent scope. + * Passing the same `label` to two `isolate()` calls joins their scopes. + * + * @param name — the service name to isolate. + * @param label — scope label to join; defaults to a fresh unique symbol. + * @returns a child context whose `name` service resolves in the new scope. + */ isolate(name: string, label?: symbol) { const shadow = Object.create(this[symbols.isolate]) shadow[name] = label ?? Symbol(name) return this.extend({ [symbols.isolate]: shadow }) } - /** Add service-specific intercept config for plugins started below this context. */ + /** + * Add service-specific intercept config for plugins started below this + * context. + * + * Plugins loaded under the returned context see `config` merged into the + * service's resolved config (ancestor entries first; see + * `Service[symbols.resolveConfig]`). The parent context is not affected. + * + * @param name — the service name whose config to intercept. + * @param config — the intercept config to merge for that service. + * @returns a child context carrying the additional intercept entry. + */ intercept(name: K, config: Context[K] extends { [symbols.config]: infer T } ? T : never): this intercept(name: string, config: any): this intercept(name: string, config: any) { diff --git a/vendor/cordis/src/events.ts b/vendor/cordis/src/events.ts index 4461816537..d0d9ee7353 100644 --- a/vendor/cordis/src/events.ts +++ b/vendor/cordis/src/events.ts @@ -3,7 +3,12 @@ import { Context } from './context.ts' import { Fiber, FiberState } from './fiber.ts' import { DisposableList, symbols } from './utils.ts' -/** Return whether an event result should stop a bail-style dispatch. */ +/** + * Return whether an event result should stop a bail-style dispatch. + * + * @param value — a listener's return value. + * @returns `true` unless `value` is `null`, `false`, or `undefined`. + */ export function isBailed(value: any) { return value !== null && value !== false && value !== undefined } @@ -28,17 +33,75 @@ export type DispatchMode = 'emit' | 'parallel' | 'serial' | 'bail' | 'waterfall' declare module './context.ts' { export interface Context { /* eslint-disable max-len */ + /** + * Dispatch an event, running all listeners concurrently. + * + * @param name — the event name. + * @param args — arguments passed to every listener. + * @returns a promise resolving once every listener has settled. + */ parallel(name: K, ...args: Parameters): Promise + /** Same as above, with an explicit `this` for listeners (also used for filtering). */ parallel(thisArg: NoInfer>, name: K, ...args: Parameters): Promise + /** + * Dispatch an event synchronously, ignoring listener return values. + * + * @param name — the event name. + * @param args — arguments passed to every listener. + */ emit(name: K, ...args: Parameters): void + /** Same as above, with an explicit `this` for listeners (also used for filtering). */ emit(thisArg: NoInfer>, name: K, ...args: Parameters): void + /** + * Dispatch an event, awaiting listeners in order until one bails. + * + * @param name — the event name. + * @param args — arguments passed to each listener. + * @returns the first bail value (non-null, non-false, non-undefined), if any. + */ serial(name: K, ...args: Parameters): Promisify> + /** Same as above, with an explicit `this` for listeners (also used for filtering). */ serial(thisArg: NoInfer>, name: K, ...args: Parameters): Promisify> + /** + * Dispatch an event, calling listeners in order until one bails. + * + * @param name — the event name. + * @param args — arguments passed to each listener. + * @returns the first bail value (non-null, non-false, non-undefined), if any. + */ bail(name: K, ...args: Parameters): ReturnType + /** Same as above, with an explicit `this` for listeners (also used for filtering). */ bail(thisArg: NoInfer>, name: K, ...args: Parameters): ReturnType + /** + * Dispatch an event whose last argument is a `next` continuation. + * + * Each listener wraps the rest of the chain: calling `next()` invokes the + * next listener (finally the built-in behavior); not calling it vetoes. + * + * @param name — the event name. + * @param args — listener arguments; the final one is the innermost `next`. + * @returns the outermost listener's return value. + */ waterfall(name: K, ...args: Parameters): ReturnType + /** Same as above, with an explicit `this` for listeners (also used for filtering). */ waterfall(thisArg: NoInfer>, name: K, ...args: Parameters): ReturnType + /** + * Register an event listener owned by the current fiber. + * + * @param name — the event name to listen for. + * @param listener — called with the dispatch arguments. + * @param options — listener options; a boolean is shorthand for `prepend`. + * @returns a disposer removing the listener; `true` if it was still registered. + */ on(name: K, listener: Events[K], options?: boolean | EventOptions): () => boolean + /** + * Same as `on()`, but the listener disposes itself after its first call. + * + * @param name — the event name to listen for. + * @param listener — called at most once with the dispatch arguments. + * @param options — listener options; a boolean is shorthand for `prepend`. + * @returns a disposer removing the listener; `true` if it was still registered. + */ once(name: K, listener: Events[K], options?: boolean | EventOptions): () => boolean /* eslint-enable max-len */ } @@ -91,7 +154,13 @@ export class EventsService { }, { global: true, prepend: true }) } - /** Resolve listeners for one dispatch and apply context filtering. */ + /** + * Resolve listeners for one dispatch and apply context filtering. + * + * @param type — the dispatch mode, reported on `internal/dispatch`. + * @param args — the raw dispatch arguments; consumed up to the event name. + * @returns the matching listener callbacks, bound to the dispatch `this`. + */ dispatch(type: string, args: any[]) { const thisArg = typeof args[0] === 'object' || typeof args[0] === 'function' ? args.shift() : null const name: string = args.shift() @@ -104,17 +173,31 @@ export class EventsService { .map(hook => hook.callback.bind(thisArg)) } - /** Run listeners concurrently and wait for all of them. */ + /** + * Run listeners concurrently and wait for all of them. + * + * @param args — optional `this`, the event name, then listener arguments. + * @returns a promise resolving once every listener has settled. + */ async parallel(...args: any[]) { await Promise.all(this.dispatch('emit', args).map(cb => cb(...args))) } - /** Run listeners synchronously without waiting for returned promises. */ + /** + * Run listeners synchronously without waiting for returned promises. + * + * @param args — optional `this`, the event name, then listener arguments. + */ emit(...args: any[]) { this.dispatch('emit', args).map(cb => cb(...args)) } - /** Run listeners in order until one returns a bail value. */ + /** + * Run listeners in order, awaiting each, until one returns a bail value. + * + * @param args — optional `this`, the event name, then listener arguments. + * @returns the first bail value (see {@link isBailed}), if any. + */ async serial(...args: any[]) { for (const cb of this.dispatch('serial', args)) { const result = await cb(...args) @@ -122,7 +205,12 @@ export class EventsService { } } - /** Run listeners synchronously until one returns a bail value. */ + /** + * Run listeners synchronously until one returns a bail value. + * + * @param args — optional `this`, the event name, then listener arguments. + * @returns the first bail value (see {@link isBailed}), if any. + */ bail(...args: any[]) { for (const cb of this.dispatch('bail', args)) { const result = cb(...args) @@ -130,7 +218,16 @@ export class EventsService { } } - /** Compose listeners around the final `next` callback. */ + /** + * Compose listeners around the final `next` callback. + * + * The last dispatch argument is treated as the innermost `next`. Listeners + * run outermost-first; a listener that does not call `next()` vetoes the + * rest of the chain, including the built-in behavior. + * + * @param args — optional `this`, the event name, listener arguments, then `next`. + * @returns the outermost listener's return value. + */ waterfall(...args: any[]) { const cbs = this.dispatch('waterfall', args) const inner = args.pop() @@ -142,6 +239,15 @@ export class EventsService { return next() } + /** + * Store a listener record as an effect on the current fiber. + * + * @param label — effect label shown in fiber diagnostics. + * @param hooks — the listener list for one event. + * @param callback — the listener to store. + * @param options — placement and filtering options. + * @returns a disposer that unregisters the listener. + */ register(label: string, hooks: Hook[], callback: any, options: EventOptions): () => void { const method = options.prepend ? 'unshift' : 'push' return this.ctx.fiber.effect(() => { @@ -150,6 +256,13 @@ export class EventsService { }, label) } + /** + * Remove a stored listener record. + * + * @param hooks — the listener list for one event. + * @param callback — the listener to remove. + * @returns `true` if the listener was found and removed. + */ unregister(hooks: Hook[], callback: any) { const index = hooks.findIndex(hook => hook.callback === callback) if (index >= 0) { @@ -158,7 +271,17 @@ export class EventsService { } } - /** Register an event listener owned by the current fiber. */ + /** + * Register an event listener owned by the current fiber. + * + * The listener is removed automatically when the fiber unloads. Throws + * `CordisError('INACTIVE_EFFECT')` if the fiber is already disposed. + * + * @param name — the event name to listen for. + * @param listener — called with the dispatch arguments. + * @param options — listener options; a boolean is shorthand for `prepend`. + * @returns a disposer removing the listener; `true` if it was still registered. + */ on(name: string | symbol, listener: (...args: any) => any, options?: boolean | EventOptions) { if (typeof options !== 'object') { options = { prepend: options } @@ -175,7 +298,14 @@ export class EventsService { return this.register(label, hooks, listener, options) } - /** Register an event listener that disposes itself after the first call. */ + /** + * Register an event listener that disposes itself after the first call. + * + * @param name — the event name to listen for. + * @param listener — called at most once with the dispatch arguments. + * @param options — listener options; a boolean is shorthand for `prepend`. + * @returns a disposer removing the listener; `true` if it was still registered. + */ once(name: string, listener: (...args: any) => any, options?: boolean | EventOptions) { const dispose = this.on(name, function (...args: any[]) { dispose() @@ -194,12 +324,20 @@ export class EventsService { * diagnostics before public events are delivered. */ export interface Events { + /** A plugin fiber was created or its uid was cleared on disposal. */ 'internal/plugin'(fiber: Fiber): void + /** A fiber changed lifecycle state; receives the fiber and its previous state. */ 'internal/status'(fiber: Fiber, oldValue: FiberState): void + /** Interception hook for a service binding (no core producer). */ 'internal/service'(this: Context, name: string, value: any): void + /** Waterfall: a fiber config update is being applied; skip `next()` to veto. */ 'internal/update'(this: Fiber, config: any, noSave: boolean, next: () => void): void + /** Waterfall: a service is being read through the context proxy. */ 'internal/get'(ctx: Context, name: string, error: Error, next: () => any): any + /** Waterfall: a service is being written through the context proxy. */ 'internal/set'(ctx: Context, name: string, value: any, error: Error, next: () => boolean): boolean + /** Bail: a listener is being registered; a non-null result replaces registration. */ 'internal/listener'(this: Context, name: string, listener: any, prepend: boolean): void + /** An event is being dispatched to listeners (fired for non-internal events only). */ 'internal/dispatch'(mode: DispatchMode, name: string, args: any[], thisArg: any): void } diff --git a/vendor/cordis/src/fiber.ts b/vendor/cordis/src/fiber.ts index fd472e7733..9844e3e75d 100644 --- a/vendor/cordis/src/fiber.ts +++ b/vendor/cordis/src/fiber.ts @@ -7,6 +7,7 @@ import { StandardSchemaV1 } from '@standard-schema/spec' declare module './context.ts' { export interface Context extends Pick { + /** The fiber (plugin runtime instance) that owns this context. */ fiber: Fiber } } @@ -17,6 +18,11 @@ const kValidationError = Symbol.for('ValidationError') export class ValidationError extends TypeError { name = 'ValidationError' + /** + * Build the aggregated message from schema issues. + * + * @param issues — the standard-schema issues, one message line each. + */ constructor(issues: readonly StandardSchemaV1.Issue[]) { super(`invalid config:\n` + issues.map(issue => { if (issue.path) { @@ -32,7 +38,14 @@ Object.defineProperty(ValidationError.prototype, kValidationError, { value: true, }) -/** Validate and normalize config for a plugin runtime before it starts. */ +/** + * Validate and normalize config for a plugin runtime before it starts. + * + * @param runtime — the plugin runtime whose `Config` schema to apply. + * @param config — the raw user config. + * @returns the validated config, or `config` unchanged if the runtime has no schema. + * @throws {ValidationError} when validation reports issues. + */ export function resolveConfig(runtime: Plugin.Runtime, config: any) { if (!runtime.Config) return config // TODO: async validation @@ -51,10 +64,21 @@ interface AsyncDisposable = Awaitable> extends P (): T } -/** Function returned by an effect to release resources during disposal. */ +/** + * Function returned by an effect to release resources during disposal. + * + * Disposers run in reverse registration order when the owning fiber unloads; + * they may be async, in which case unloading awaits them. + */ export type Disposable = () => T -/** Effect body result accepted by `ctx.effect()` and plugin startup. */ +/** + * Effect body result accepted by `ctx.effect()` and plugin startup. + * + * Either a single disposer, a promise of one, or a (possibly async) iterable + * yielding several — generator effects register each yielded disposer as it + * is produced. + */ export type Effect = | SyncEffect | AsyncEffect @@ -69,7 +93,9 @@ type AsyncEffect = /** Tree node used to expose nested effect labels for diagnostics. */ export interface EffectMeta { + /** Human-readable effect label, e.g. `ctx.on("event")` or `ctx.provide("name")`. */ label: string + /** Metadata of nested effects registered while this effect ran. */ children: EffectMeta[] } @@ -80,7 +106,14 @@ interface EffectRunner { getOuterStack: () => string[] } -/** Lifecycle state for one plugin fiber. */ +/** + * Lifecycle state for one plugin fiber. + * + * `PENDING` — waiting for required services; `LOADING` — the plugin callback + * is running; `ACTIVE` — loaded and providing; `FAILED` — the callback or its + * config threw; `UNLOADING` — disposers are running; `DISPOSED` — the fiber + * was removed and cannot restart. + */ export const enum FiberState { PENDING, LOADING, @@ -92,6 +125,10 @@ export const enum FiberState { /** Framework error with a stable machine-readable code. */ export class CordisError extends Error { + /** + * @param code — the stable error code; also the default message. + * @param message — optional human-readable override. + */ constructor(public code: CordisError.Code, message?: string) { super(message ?? CordisError.Code[code]) } @@ -115,12 +152,19 @@ const INACTIVE = '__INACTIVE__' * cleanup for the plugin context returned by `ctx.plugin()`. */ export class Fiber { + /** Unique id within the registry; 0 for the root fiber, `null` once disposed. */ public uid: number | null + /** The context this fiber's plugin runs in (extends the parent context). */ public readonly ctx: Context + /** The validated plugin config (updated by `update()`). */ public config: any + /** Current lifecycle state; transitions emit `internal/status`. */ public state = FiberState.PENDING + /** Dispose this fiber: unload the plugin, then settle once cleanup finished. */ public readonly dispose: () => Promise + /** Snapshot of required service implementations while loaded; `undefined` otherwise. */ public store: Dict | undefined + /** The in-flight load/unload transition, if one is currently running. */ public inertia: Promise | undefined public readonly _hooks: Dict> = Object.create(null) @@ -133,6 +177,16 @@ export class Fiber { private _runner: EffectRunner private _store: Dict = Object.create(null) + /** + * Create a fiber. Plugin authors normally obtain fibers from `ctx.plugin()` + * rather than constructing them directly. + * + * @param parent — the context the plugin was loaded from. + * @param config — raw config, validated against the runtime's schema. + * @param inject — resolved dependency map (service name → intercept config). + * @param runtime — the shared plugin runtime, or `null` for the root fiber. + * @param getOuterStack — captures the caller stack for effect diagnostics. + */ constructor( public parent: Context, config: any, @@ -226,6 +280,7 @@ export class Fiber { } } + /** The plugin's display name, inherited from the nearest named ancestor, else `'root'`. */ get name() { let fiber: Fiber = this do { @@ -235,7 +290,12 @@ export class Fiber { return 'root' } - /** Throw if the fiber has already been disposed. */ + /** + * Throw if the fiber has already been disposed. + * + * @returns nothing when the fiber is still active. + * @throws {CordisError} `INACTIVE_EFFECT` when the fiber's uid has been cleared. + */ assertActive() { if (this.uid !== null) return throw new CordisError('INACTIVE_EFFECT') @@ -287,8 +347,21 @@ export class Fiber { }, runner.getOuterStack) } - /** Register a cleanup-aware effect on this fiber. */ + /** + * Register a cleanup-aware effect on this fiber. + * + * `execute` runs immediately; the disposers it produces are collected and + * run (in reverse order) either when the returned disposer is called or + * when the fiber unloads, whichever comes first. Calling the disposer twice + * is a no-op. Throws `CordisError('INACTIVE_EFFECT')` if the fiber is + * already disposed, and `TypeError` if `execute` returns an invalid shape. + * + * @param execute — the effect body; see {@link Effect} for accepted shapes. + * @param label — effect label shown in `getEffects()` diagnostics. + * @returns a disposer that tears the effect down and settles once done. + */ effect(execute: () => SyncEffect, label?: string): Disposable> + /** Same as above for async effects; the disposer is also awaitable. */ effect(execute: () => Effect, label?: string): AsyncDisposable> effect(execute: () => Effect, label = 'anonymous'): any { this.assertActive() @@ -355,7 +428,11 @@ export class Fiber { return wrapper } - /** Return metadata for currently registered effects. */ + /** + * Return metadata for currently registered effects. + * + * @returns one {@link EffectMeta} tree per labeled live effect. + */ getEffects() { return [...this._disposables] .map(dispose => dispose[symbols.effect]) @@ -474,7 +551,12 @@ export class Fiber { }) } - /** Wait for current lifecycle work and rethrow startup errors. */ + /** + * Wait for current lifecycle work and rethrow startup errors. + * + * @returns this fiber, once it has settled into a stable state. + * @throws the config-validation or plugin-startup error, if any. + */ async await() { while (this.inertia) { await this.inertia @@ -483,7 +565,12 @@ export class Fiber { return this } - /** Dispose and immediately reload this plugin with its current config. */ + /** + * Dispose and immediately reload this plugin with its current config. + * + * @returns a promise resolving once the reload settled. + * @throws {CordisError} `INACTIVE_EFFECT` when the fiber is already disposed. + */ async restart() { this.assertActive() this._setEpoch(INACTIVE) @@ -491,7 +578,17 @@ export class Fiber { await this.await() } - /** Validate and apply new config, then restart the plugin. */ + /** + * Validate and apply new config, then restart the plugin. + * + * Runs the `internal/update` waterfall first, so update hooks (and HMR) + * can veto or replace the restart. + * + * @param config — the new raw config; validated before anything restarts. + * @param noSave — hint for persistence hooks not to write the change back. + * @returns nothing; the restart runs behind the `internal/update` waterfall. + * @throws {ValidationError} when the new config fails validation. + */ update(config: any, noSave = false) { this.assertActive() config = resolveConfig(this.runtime!, config) diff --git a/vendor/cordis/src/logger.ts b/vendor/cordis/src/logger.ts index a1e97c165a..3c5ad10525 100644 --- a/vendor/cordis/src/logger.ts +++ b/vendor/cordis/src/logger.ts @@ -62,8 +62,11 @@ export const defaultFormatters: Record = { /** Options used when creating a named logger facade. */ export interface LoggerOptions { + /** The logger name shown with each message. */ name: string + /** Message fields merged into every record from this logger. */ meta?: Partial + /** Default maximum level exported when an exporter has no own threshold. */ level?: number } @@ -220,7 +223,12 @@ export class LoggerService { return self } - /** Register an exporter and dispose it with the current fiber. */ + /** + * Register an exporter and dispose it with the current fiber. + * + * @param exporter — the sink that receives structured log messages. + * @returns a disposer that removes the exporter. + */ exporter(exporter: Exporter) { return this.ctx.effect(() => { this.exporters.set(++this._snExporter, exporter) diff --git a/vendor/cordis/src/reflect.ts b/vendor/cordis/src/reflect.ts index 212ec4e779..e983745024 100644 --- a/vendor/cordis/src/reflect.ts +++ b/vendor/cordis/src/reflect.ts @@ -5,14 +5,66 @@ import { Fiber, FiberState } from './fiber.ts' declare module './context.ts' { interface Context { + /** + * Read a service from the store without the inject requirement. + * + * @param name — the service name. + * @param strict — when `true` (default), only return implementations + * whose providing fiber is currently active. + * @returns the service value, or `undefined` when not (yet) provided. + */ get(name: K, strict?: boolean): undefined | this[K] + /** Same as above for service names outside the typed `Context` surface. */ get(name: string, strict?: boolean): any + /** + * Overwrite a provided service's value. + * + * Only the fiber that provided the service may set it; setting an + * unprovided name throws. + * + * @param name — the service name. + * @param value — the new service value. + */ set(name: K, value: undefined | this[K]): void + /** Same as above for service names outside the typed `Context` surface. */ set(name: string, value: any): void + /** + * Register a service implementation owned by the current fiber. + * + * The service becomes visible to dependents in the same isolation scope + * once the fiber is active; it is unregistered (waking dependents) when + * the returned disposer runs or the fiber unloads. Throws if the name is + * already provided in this scope or declared as an accessor. + * + * @param name — the service name. + * @param value — the service value. + * @returns a disposer that unregisters the service. + */ provide(name: K, value: undefined | this[K]): () => void + /** Same as above for service names outside the typed `Context` surface. */ provide(name: string, value?: any): () => void + /** + * Define a computed context property backed by get/set hooks. + * + * The accessor is removed when the current fiber unloads. Throws if the + * name is already declared. + * + * @param name — the context property name. + * @param options — the `get` hook and optional `set` hook. + */ accessor(name: string, options: Omit): void + /** + * Expose selected members of a service directly on `ctx`. + * + * Each mixed-in key becomes an accessor that forwards to the service + * (binding methods to it), so e.g. `ctx.on` forwards to `ctx.events.on`. + * Mixins are removed when the current fiber unloads. + * + * @param name — the context property holding the source service. + * @param mixins — keys to forward, or a source-key → ctx-key map. + */ mixin(name: K, mixins: (keyof this & keyof this[K])[] | Dict): void + /** Same as above with a source object instead of a context property name. */ mixin(source: T, mixins: (keyof this & keyof T)[] | Dict): void } } @@ -44,22 +96,30 @@ export type Property = Property.Service | Property.Accessor export namespace Property { /** Service property backed by a provided implementation. */ export interface Service { + /** Discriminator. */ type: 'service' } /** Computed context property backed by custom get/set hooks. */ export interface Accessor { + /** Discriminator. */ type: 'accessor' + /** Compute the property value; `error` carries the caller stack for diagnostics. */ get: (this: Context, receiver: any, error: Error) => any + /** Optional setter; return `false` to reject the write. */ set?: (this: Context, value: any, receiver: any, error: Error) => boolean } } /** Concrete service implementation record stored in the root reflect service. */ export interface Impl { + /** The service name. */ name: string + /** The fiber that provided the service (owns its lifetime). */ fiber: Fiber + /** The current service value. */ value?: any + /** Optional availability predicate consulted before dependents may load. */ check?: () => boolean } @@ -70,6 +130,7 @@ export interface Impl { * the mixins that expose core service methods directly on `ctx`. */ export class ReflectService { + /** Proxy traps implementing service resolution for every context object. */ static handler: ProxyHandler = { get: (target, prop, ctx: Context) => { if (isSpecialProperty(prop)) { @@ -143,7 +204,9 @@ export class ReflectService { }, } + /** Service implementations, keyed by isolation label. */ public store: Dict = Object.create(null) + /** Declared context properties (services and accessors), by name. */ public props: Dict = Object.create(null) constructor(public ctx: Context) { @@ -158,6 +221,14 @@ export class ReflectService { this.mixin('events', ['on', 'once', 'parallel', 'emit', 'serial', 'bail', 'waterfall']) } + /** + * Read a service from the store without the inject requirement. + * + * @param name — the service name. + * @param strict — when `true`, only return implementations whose providing + * fiber is currently active. + * @returns the service value, or `undefined` when not (yet) provided. + */ get(name: string, strict = true) { return getTraceable(this.ctx, this._getImpl(name, strict)?.value) } @@ -170,6 +241,15 @@ export class ReflectService { return impl } + /** + * Overwrite a provided service's value. + * + * @param name — the service name. + * @param value — the new service value. + * @param error — carrier for the caller stack in diagnostics. + * @returns `true` on success. + * @throws when `name` was never provided, or was provided by another fiber. + */ set(name: string, value: any, error?: Error) { const key = this.ctx[symbols.isolate][name] const impl = this.store[key] @@ -183,6 +263,16 @@ export class ReflectService { return true } + /** + * Register a service implementation owned by the current fiber. + * + * See the `ctx.provide()` overload above for the full contract. + * + * @param name — the service name. + * @param value — the service value. + * @param check — optional availability predicate for dependents. + * @returns a disposer that unregisters the service. + */ provide(name: string, value?: any, check?: () => boolean) { return this.ctx.fiber.effect(() => { if (!this.props[name]) { @@ -213,6 +303,13 @@ export class ReflectService { }, `ctx.provide(${JSON.stringify(name)})`) } + /** + * Re-evaluate every fiber that requires one of the given services. + * + * @param names — the service names that changed. + * @param filter — restricts notification to matching isolation scopes. + * @returns the fibers whose dependency state was refreshed. + */ notify(names: string[], filter = (ctx: Context, name: string) => ctx[symbols.isolate][name] === this.ctx[symbols.isolate][name]) { const fibers: Fiber[] = [] for (const runtime of this.ctx.registry.values()) { @@ -232,6 +329,13 @@ export class ReflectService { return fibers } + /** + * Define a computed context property backed by get/set hooks. + * + * @param name — the context property name. + * @param options — the `get` hook and optional `set` hook. + * @returns a disposer that removes the accessor. + */ accessor(name: string, options: Omit) { return this.ctx.fiber.effect(() => { if (name in this.props) { @@ -242,6 +346,15 @@ export class ReflectService { }, `ctx.accessor(${JSON.stringify(name)})`) } + /** + * Expose selected members of a service directly on `ctx`. + * + * See the `ctx.mixin()` overload above for the full contract. + * + * @param source — a context property name or a source object. + * @param mixins — keys to forward, or a source-key → ctx-key map. + * @returns a disposer that removes all created accessors. + */ mixin(source: any, mixins: string[] | Dict) { const self = this return this.ctx.fiber.effect(function* () { @@ -270,10 +383,22 @@ export class ReflectService { }, `ctx.mixin(${JSON.stringify(source)})`) } + /** + * Attach this context's tracing wrapper to a value. + * + * @param value — the value to wrap. + * @returns the traceable wrapper (or the value itself when not applicable). + */ trace(value: T) { return getTraceable(this.ctx, value) } + /** + * Wrap a callback so calls trace `this` and arguments to this context. + * + * @param callback — the function to wrap. + * @returns a proxy delegating to `callback` with traced values. + */ bind(callback: T) { return new Proxy(callback, { apply: (target, thisArg, args) => { diff --git a/vendor/cordis/src/registry.ts b/vendor/cordis/src/registry.ts index 9dfa10a06b..05fbadcfad 100644 --- a/vendor/cordis/src/registry.ts +++ b/vendor/cordis/src/registry.ts @@ -28,6 +28,11 @@ export type InjectKey = keyof { * On classes it contributes to the plugin's static `inject` map. On methods it * delays the method call until the declared services are available. */ +/** + * @param name — the required service name. + * @param config — optional intercept config applied for that service. + * @returns the class or method decorator. + */ export function Inject(name: K, config?: Context[K] extends { [symbols.config]: infer T } ? T : never) { return function (value: any, decorator: ClassDecoratorContext | ClassMethodDecoratorContext) { if (decorator.kind === 'class') { @@ -55,7 +60,13 @@ export function Inject(name: K, config?: Context[K] extends /** Utilities for normalizing plugin dependency declarations. */ export namespace Inject { - /** Convert array/object/class-inherited inject metadata into a plain map. */ + /** + * Convert array/object/class-inherited inject metadata into a plain map. + * + * @param inject — the declaration to normalize; `null`/`undefined` add nothing. + * @param result — the map to fill (service name → intercept config or `null`). + * @returns `result`. + */ export function resolve(inject: Inject | null | undefined, result: Dict = Object.create(null)) { if (!inject) return result if (Array.isArray(inject)) { @@ -86,10 +97,15 @@ export type Plugin = export namespace Plugin { /** Shared metadata understood by the plugin registry and related tooling. */ export interface Base { + /** Display name used for fiber diagnostics and logger names. */ name?: string + /** Standard-schema validator applied to config before the plugin starts. */ Config?: StandardSchemaV1 + /** Services the plugin requires; it only loads while all are available. */ inject?: Inject + /** Service name(s) the plugin provides (read by `Service` and by loaders). */ provide?: string | string[] + /** Service names whose intercept config the plugin declares it consumes. */ intercept?: Dict } @@ -117,9 +133,13 @@ export namespace Plugin { /** Mutable registry record shared by all fibers of one plugin callback. */ export interface Runtime { + /** Display name copied from the first registered plugin shape. */ name?: string + /** Every live fiber of this plugin (one per `ctx.plugin()` call). */ fibers: DisposableList + /** The executable entrypoint all fibers share (registry identity key). */ callback: globalThis.Function + /** Standard-schema validator applied to each fiber's config. */ Config?: StandardSchemaV1 } } @@ -142,7 +162,25 @@ type GetPluginConfig

= declare module './context.ts' { export interface Context { + /** + * Run a callback once the requested services are available. + * + * Shorthand for `ctx.plugin({ inject, apply: callback })`: the callback + * is unloaded and re-run whenever a required service changes. + * + * @param deps — required services, as an array or a name → config map. + * @param callback — plugin body called with `(ctx, config)`. + * @returns the fiber; awaiting it settles once loading finished. + */ inject(deps: Inject, callback: Plugin.Function): Fiber & PromiseLike + /** + * Load a plugin in the current context. + * + * @param plugin — a function, class, or `{ apply }` object plugin. + * @param args — the plugin config, validated against its `Config` schema. + * @returns the fiber; awaiting it settles once loading finished + * (rejecting on config or startup errors). + */ plugin

(plugin: P, ...args: Spread>): Fiber & PromiseLike } } @@ -164,15 +202,22 @@ export class RegistryService { }) } + /** Allocate the next fiber uid (increments on every read). */ get counter() { return ++this._counter } + /** Number of registered plugin runtimes. */ get size() { return this._internal.size } - /** Resolve a supported plugin shape to its executable callback. */ + /** + * Resolve a supported plugin shape to its executable callback. + * + * @param plugin — a function, class, or `{ apply }` object plugin. + * @returns the callback identifying the plugin, or `undefined` if invalid. + */ resolve(plugin: Plugin): Function | undefined { // plugin.apply may throw try { @@ -181,17 +226,34 @@ export class RegistryService { } catch {} } + /** + * Look up the runtime record for a plugin. + * + * @param plugin — any supported plugin shape. + * @returns the runtime, or `undefined` when the plugin is not registered. + */ get(plugin: Plugin) { const key = this.resolve(plugin) return key && this._internal.get(key) } + /** + * Check whether a plugin has a registered runtime. + * + * @param plugin — any supported plugin shape. + * @returns `true` when at least one fiber of the plugin exists. + */ has(plugin: Plugin) { const key = this.resolve(plugin) return !!key && this._internal.has(key) } - /** Dispose every running fiber for a plugin and remove its runtime record. */ + /** + * Dispose every running fiber for a plugin and remove its runtime record. + * + * @param plugin — any supported plugin shape. + * @returns the removed runtime, or `undefined` when none was registered. + */ delete(plugin: Plugin) { const key = this.resolve(plugin) const runtime = key && this._internal.get(key) @@ -203,28 +265,53 @@ export class RegistryService { return runtime } + /** Iterate the registered plugin callbacks. */ keys() { return this._internal.keys() } + /** Iterate the registered plugin runtimes. */ values() { return this._internal.values() } + /** Iterate `[callback, runtime]` pairs. */ entries() { return this._internal.entries() } + /** + * Visit every registered runtime. + * + * @param callback — receives each runtime and its identifying callback. + */ forEach(callback: (value: Plugin.Runtime, key: Function) => void) { return this._internal.forEach(callback) } - /** Start a callback once the requested dependencies are available. */ + /** + * Start a callback once the requested dependencies are available. + * + * @param inject — required services, as an array or a name → config map. + * @param callback — plugin body called with `(ctx, config)`. + * @returns the fiber; awaiting it settles once loading finished. + */ inject(inject: Inject, callback: Plugin.Function) { return this.plugin({ inject, apply: callback, name: callback.name }) } - /** Start a plugin in the current context and return its fiber. */ + /** + * Start a plugin in the current context and return its fiber. + * + * Creates (or reuses) the plugin's runtime record, then starts a new fiber + * under the current context. Throws if `plugin` is not a supported shape or + * if the current fiber is already disposed. + * + * @param plugin — a function, class, or `{ apply }` object plugin. + * @param config — the plugin config, validated against its `Config` schema. + * @param getOuterStack — captures the caller stack for effect diagnostics. + * @returns the fiber; awaiting it settles once loading finished. + */ plugin(plugin: Plugin, config?: any, getOuterStack = buildOuterStack()) { // check if it's a valid plugin const callback = this.resolve(plugin) diff --git a/vendor/cordis/src/service.ts b/vendor/cordis/src/service.ts index 30895247c1..dc6622b68f 100644 --- a/vendor/cordis/src/service.ts +++ b/vendor/cordis/src/service.ts @@ -9,19 +9,36 @@ import { createCallable, joinPrototype, symbols, Tracker } from './utils.ts' * registered immediately and is automatically removed with the owning fiber. */ export abstract class Service { + /** Symbol key of an instance method run after construction (class plugins). */ static readonly init: unique symbol = symbols.init + /** Symbol key of the availability predicate passed to `ctx.provide()`. */ static readonly check: unique symbol = symbols.check + /** Symbol key of the phantom intercept-config type parameter. */ static readonly config: unique symbol = symbols.config + /** Symbol key of the call body making a service callable (e.g. `ctx.logger()`). */ static readonly invoke: unique symbol = symbols.invoke + /** Symbol key of the helper deriving an extended service instance. */ static readonly extend: unique symbol = symbols.extend + /** Symbol key of the tracker metadata used for context tracing. */ static readonly tracker: unique symbol = symbols.tracker + /** Symbol key of the intercept-config resolution helper below. */ static readonly resolveConfig: unique symbol = symbols.resolveConfig declare [symbols.config]: T + /** The service name this instance is registered under. */ public name!: string - /** Register this instance as `name` in the current context. */ + /** + * Register this instance as `name` in the current context. + * + * Calls `ctx.reflect.provide(name, this, this[Service.check])`, so the + * service is unregistered automatically when the owning fiber unloads. + * Services with a `[Service.invoke]` body return a callable instance. + * + * @param ctx — the context to register in (stored as `this.ctx`). + * @param name — the service name; defaults to the static `provide` field. + */ constructor(protected ctx: Context, name: string) { name ??= this.constructor['provide'] as string @@ -55,7 +72,17 @@ export abstract class Service { return Object.assign(self, props) } - /** Merge intercept config from ancestors with optional base and head values. */ + /** + * Merge intercept config from ancestors with optional base and head values. + * + * Entries added closer to the root apply first; `base` is prepended and + * `head` appended. Uses `Config.merge` when the service declares one, + * otherwise a shallow `Object.assign`. + * + * @param base — lowest-precedence config merged before all intercepts. + * @param head — highest-precedence config merged after all intercepts. + * @returns the merged config. + */ [symbols.resolveConfig](base?: T, head?: T): T { let intercept = this.ctx[Context.intercept] const configs: any[] = [] From da261385920217faa06ad6b5192fe2c6e480c8b9 Mon Sep 17 00:00:00 2001 From: lintianle Date: Thu, 16 Jul 2026 18:13:04 +0800 Subject: [PATCH 06/14] website: gate every yaml config example against the real plugin surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New doc-sync gate verify-website-yaml: each ```yaml block under website/zh-CN (api/ excluded — generator-owned) must parse with the loader's real schema (JSON_SCHEMA + !!js), use only EntryOptions keys, name only real workspace packages, and pass only config keys the plugin's declared Config type / schemastery schema accepts (collectConfigCatalog drives the key sets). ```yaml ignore-check opts out a deliberate-placeholder block (the capability-trio tutorial keeps its fictional package names). --- package.json | 3 +- scripts/run-gates.ts | 1 + scripts/verify-website-yaml.ts | 284 ++++++++++++++++++++++++ website/zh-CN/develop/practice/index.md | 2 +- 4 files changed, 288 insertions(+), 2 deletions(-) create mode 100644 scripts/verify-website-yaml.ts diff --git a/package.json b/package.json index 588346fc0f..acfe588c50 100644 --- a/package.json +++ b/package.json @@ -61,10 +61,11 @@ "verify-persistence-catalog": "tsx scripts/gen-persistence-catalog.ts --check", "gen-module-graph": "tsx scripts/gen-module-graph.ts", "verify-module-graph": "tsx scripts/gen-module-graph.ts --check", + "verify-website-yaml": "tsx scripts/verify-website-yaml.ts", "website:dev": "pnpm --filter @deepseek-ai/website run dev", "website:build": "pnpm --filter @deepseek-ai/website run build", "constraints": "tsx scripts/check-workspace-constraints.ts", - "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-cordis-api && pnpm run verify-export-jsdoc && pnpm run verify-tool-catalog && pnpm run verify-config-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-mermaid && pnpm run verify-rfc-classification && pnpm run verify-rfc-format && pnpm run verify-type-equiv && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets", + "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-cordis-api && pnpm run verify-export-jsdoc && pnpm run verify-tool-catalog && pnpm run verify-config-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-mermaid && pnpm run verify-rfc-classification && pnpm run verify-rfc-format && pnpm run verify-type-equiv && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets && pnpm run verify-website-yaml", "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types", "demo:echo": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/echo-agent/cordis.yml", "demo:repl": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/coding-agent/cordis.yml", diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 958ddad0ae..6384697650 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -274,6 +274,7 @@ function docSyncLeafGates(): Gate[] { pnpmScript('type-equivalence', 'verify-type-equiv', { label: 'type equivalence' }), pnpmScript('translation-pairing', 'verify-translation-pairing', { label: 'translation pairing' }), pnpmScript('doc-budgets', 'verify-doc-budgets', { label: 'doc budgets' }), + pnpmScript('website-yaml', 'verify-website-yaml', { label: 'website yaml' }), ] } diff --git a/scripts/verify-website-yaml.ts b/scripts/verify-website-yaml.ts new file mode 100644 index 0000000000..809af247e2 --- /dev/null +++ b/scripts/verify-website-yaml.ts @@ -0,0 +1,284 @@ +/** + * Doc-sync gate: verify the fenced ```yaml examples in the website against + * the loader and the workspace truth. A `cordis.yml` example that names a + * plugin that does not exist, or passes a config key the plugin never + * declared, is worse than no example — it fails silently for the reader. + * + * Scope: `website/zh-CN/**​/*.md`, EXCLUDING `website/zh-CN/api/**` (the api + * pages are generator-owned — their yaml examples are verified at generation + * time by a later stream, not re-checked here). Blocks opt out with + * ` ```yaml ignore-check ` (same philosophy as doc-typecheck's opt-out: the + * count is reported, an unchecked block is a visible decision, not a silent + * hole — placeholder plugin names in tutorials are the legitimate case). + * + * Each checked block is parsed with the loader's REAL schema — + * `JSON_SCHEMA` extended with the `!!js` scalar type exactly as + * vendor/include/src/index.ts declares it — so `!!js process.env.X` parses + * here iff it parses at runtime. Then: + * + * - Root is an ARRAY → a cordis.yml entry list. Every item must be a mapping + * with a string `name` and only the keys `EntryOptions` declares + * (vendor/loader/src/config/entry.ts plus the isolate.ts merge: + * id, name, config, group, disabled, inject, intercept, isolate). + * - `./` / `../` names are illustrative local plugins — existence is not + * checkable, skip. `group:*` names are loader built-ins; their `config` + * is itself an entry list and is recursed into. + * - Any other name must be a real workspace package (`packages/*​/*` and + * `vendor/*` package.json names). + * - For `@deepseek-ai/dsh-*` names the config-catalog generator is the + * truth: kind `config` → the yaml `config`'s top-level keys must be + * properties of the declared config type (member names of the first + * catalog paste ∪ top-level segments of the runtime schema keys); + * config-free kinds → a non-empty `config` mapping is a violation; + * seam/library kinds → name existence only (loading one directly is + * dubious, but that is a docs-prose concern, not this gate's). + * - Root is a MAPPING or scalar → a fragment (e.g. a bare `config:` excerpt): + * syntax check only. + * + * This is a checker, not a fixer: it reports `file:line message` and exits 1. + * + * Run: `tsx scripts/verify-website-yaml.ts`. + */ + +import { globSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import * as yaml from 'js-yaml' +import ts from 'typescript' +import { collectConfigCatalog, type CatalogEntry } from './gen-config-catalog.ts' + +const root = resolve(import.meta.dirname, '..') + +/** Mirror of the loader's yaml schema (vendor/include/src/index.ts): the + * `!!js` tag parses to an expression wrapper, everything else is JSON. */ +const JsExpr = new yaml.Type('tag:yaml.org,2002:js', { + kind: 'scalar', + resolve: data => typeof data === 'string', + construct: (data: string) => ({ __jsExpr: data }), +}) +const schema = yaml.JSON_SCHEMA.extend(JsExpr) + +/** The exact key set an entry mapping may carry: `EntryOptions` in + * vendor/loader/src/config/entry.ts plus the isolate.ts interface merge. */ +const ENTRY_KEYS = ['id', 'name', 'config', 'group', 'disabled', 'inject', 'intercept', 'isolate'] as const + +/** One `file:line message` finding. */ +interface Violation { + file: string + /** 1-based line of the block's opening fence. */ + line: number + message: string +} + +/** One extracted ```yaml block. */ +interface Block { + file: string + /** 1-based line of the opening fence. */ + line: number + kind: 'check' | 'ignore' + code: string +} + +/** Extract every ```yaml / ```yaml ignore-check block from one Markdown file. */ +function extractBlocks(file: string): Block[] { + const text = readFileSync(resolve(root, file), 'utf8') + const lines = text.split('\n') + const blocks: Block[] = [] + let open: { line: number; kind: Block['kind']; body: string[] } | null = null + + lines.forEach((raw, i) => { + const fence = /^```(\s*)(\S.*)?$/.exec(raw) + if (!fence) { + if (open) open.body.push(raw) + return + } + if (open) { + // closing fence + blocks.push({ file, line: open.line, kind: open.kind, code: open.body.join('\n') }) + open = null + return + } + // opening fence — only yaml blocks participate + const info = (fence[2] ?? '').trim() + const kind: Block['kind'] | null = + info === 'yaml' ? 'check' + : info === 'yaml ignore-check' ? 'ignore' + : null + if (kind) open = { line: i + 1, kind, body: [] } + }) + return blocks +} + +/** Every workspace package name: `packages//` and `vendor/`. */ +function knownPackages(): Set { + const names = new Set() + for (const pattern of ['packages/*/*/package.json', 'vendor/*/package.json']) { + for (const match of globSync(pattern, { cwd: root })) { + const pkg: unknown = JSON.parse(readFileSync(resolve(root, match), 'utf8')) + if (typeof pkg === 'object' && pkg !== null && 'name' in pkg && typeof pkg.name === 'string') { + names.add(pkg.name) + } + } + } + return names +} + +/** The catalog, built once on first `@deepseek-ai/dsh-*` name, keyed by pkg. */ +let catalogByPkg: Map | null = null +function catalogFor(pkg: string): CatalogEntry | undefined { + catalogByPkg ??= new Map(collectConfigCatalog().map(e => [e.pkg, e])) + return catalogByPkg.get(pkg) +} + +/** Top-level property names of the first catalog paste (the verbatim config + * type declaration), parsed as source text. */ +function pasteKeys(paste: string): Set { + const sf = ts.createSourceFile('paste.ts', paste, ts.ScriptTarget.Latest, true) + const keys = new Set() + const addMembers = (members: ts.NodeArray): void => { + for (const m of members) { + if (ts.isPropertySignature(m) || ts.isMethodSignature(m)) { + const name = m.name + keys.add(ts.isIdentifier(name) || ts.isStringLiteral(name) ? name.text : name.getText(sf)) + } + } + } + for (const stmt of sf.statements) { + if (ts.isInterfaceDeclaration(stmt)) addMembers(stmt.members) + else if (ts.isTypeAliasDeclaration(stmt) && ts.isTypeLiteralNode(stmt.type)) addMembers(stmt.type.members) + } + return keys +} + +/** The allowed top-level config keys of a kind-`config` catalog entry: the + * first paste's member names ∪ the schema keys' top-level segments + * (`agents[].id` → `agents`). Cached per entry. */ +const allowedKeysCache = new Map>() +function allowedConfigKeys(entry: CatalogEntry): Set { + const cached = allowedKeysCache.get(entry.pkg) + if (cached) return cached + const keys = pasteKeys(entry.pastes?.[0]?.text ?? '') + for (const path of entry.schemaKeys ?? []) { + const top = path.split('.')[0]?.replace(/\[\]$/, '') + if (top) keys.add(top) + } + allowedKeysCache.set(entry.pkg, keys) + return keys +} + +/** A parsed yaml mapping (arrays and `!!js` wrappers excluded). */ +function asMapping(value: unknown): Record | null { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return null + if ('__jsExpr' in value) return null + return value as Record +} + +/** Check one cordis.yml entry list (recursing into `group:` sub-lists). */ +function checkEntryList( + items: unknown[], + known: Set, + block: Block, + violations: Violation[], +): void { + const flag = (message: string): void => { + violations.push({ file: block.file, line: block.line, message }) + } + items.forEach((item, index) => { + const at = `entry ${index + 1}` + const entry = asMapping(item) + if (!entry) { + flag(`${at}: not a mapping`) + return + } + const name = entry['name'] + if (typeof name !== 'string') { + flag(`${at}: missing string \`name\``) + return + } + for (const key of Object.keys(entry)) { + if (!(ENTRY_KEYS as readonly string[]).includes(key)) { + flag(`${at} (${name}): unknown entry key \`${key}\` (EntryOptions allows: ${[...ENTRY_KEYS].join(', ')})`) + } + } + // Illustrative local plugin — nothing on disk to check against. + if (name.startsWith('./') || name.startsWith('../')) return + // Loader built-in group: its config is a nested entry list. + if (name.startsWith('group:')) { + if (Array.isArray(entry['config'])) checkEntryList(entry['config'], known, block, violations) + return + } + if (!known.has(name)) { + flag(`${at}: unknown plugin \`${name}\` (not a workspace package)`) + return + } + if (!name.startsWith('@deepseek-ai/dsh-')) return + const catalog = catalogFor(name) + if (!catalog) return + const config = asMapping(entry['config']) + if (catalog.kind === 'config') { + if (!config) return + const allowed = allowedConfigKeys(catalog) + for (const key of Object.keys(config)) { + if (!allowed.has(key)) { + flag(`${at}: \`${name}\` has no config key \`${key}\` (known keys: ${[...allowed].sort().join(', ')})`) + } + } + } else if (catalog.kind === 'no-config') { + if (config && Object.keys(config).length > 0) { + flag(`${at}: \`${name}\` declares no config, but the example passes one`) + } + } + // seam / library: loading one directly is dubious, but that is a prose + // concern — this gate only vouches for name existence. + }) +} + +const files = globSync('website/zh-CN/**/*.md', { cwd: root }) + .filter(f => !f.startsWith('website/zh-CN/api/')) + .sort() + +const violations: Violation[] = [] +const known = knownPackages() +let entryLists = 0 +let fragments = 0 +let ignored = 0 +let scanned = 0 + +for (const file of files) { + for (const block of extractBlocks(file)) { + scanned++ + if (block.kind === 'ignore') { + ignored++ + continue + } + let parsed: unknown + try { + parsed = yaml.load(block.code, { schema }) + } catch (error) { + const message = error instanceof Error ? error.message.split('\n')[0] ?? 'parse error' : String(error) + violations.push({ file: block.file, line: block.line, message: `yaml parse error: ${message}` }) + continue + } + if (Array.isArray(parsed)) { + entryLists++ + checkEntryList(parsed, known, block, violations) + } else { + // Mapping or scalar root: a fragment (e.g. a bare `config:` excerpt) — + // syntax is all there is to check. + fragments++ + } + } +} + +if (violations.length === 0) { + console.log( + `verify-website-yaml: ${scanned} yaml block(s) in ${files.length} file(s): ` + + `${entryLists} entry list(s) + ${fragments} fragment(s) checked, ${ignored} ignore-check skipped.`, + ) + process.exit(0) +} + +console.error('verify-website-yaml: invalid yaml examples found:') +for (const v of violations) { + console.error(` ${v.file}:${v.line} ${v.message}`) +} +process.exit(1) diff --git a/website/zh-CN/develop/practice/index.md b/website/zh-CN/develop/practice/index.md index 9781138c50..2a077aa22e 100644 --- a/website/zh-CN/develop/practice/index.md +++ b/website/zh-CN/develop/practice/index.md @@ -140,7 +140,7 @@ export function apply(ctx: Context) { ### 在 cordis.yml 中组合 -```yaml +```yaml ignore-check - name: '@deepseek-ai/dsh-my-cap-local' - name: '@deepseek-ai/dsh-tool-my-cap' ``` From efba9fab0a43e25f0073365a7aa60144aef162af Mon Sep 17 00:00:00 2001 From: lintianle Date: Thu, 16 Jul 2026 18:13:34 +0800 Subject: [PATCH 07/14] website: generate the API reference from source (cordis + all 15 harness services) scripts/gen-website-api.ts renders website/zh-CN/api/{cordis,harness}/* and the api-sidebar.json fragment the VitePress config imports, so pages and navigation can never drift from the code: signatures, @param/@returns prose, dispatch modes, and GitHub source links are extracted, never transcribed, and the generator hard-errors on any rendered member missing docs. verify-website-api (doc-sync + run-gates) is the freshness gate. Replaces the hand-written zh api pages (7 pages covering 7 of 15 services, with phantom APIs: Context.current/Context.events, agent/post-step, tool/call, compact/*, llm/pre-request none of which exist) with generated English references: 5 cordis pages, 15 per-service pages, and a 35-event catalog grouped by scope. The hand-written hub api/index.md stays and now indexes the full surface; zh for these pages arrives with the unified translation flow. --- AGENTS.md | 2 +- package.json | 4 +- scripts/gen-website-api.ts | 675 ++++++++++++++++++ scripts/run-gates.ts | 1 + website/.vitepress/config/api-sidebar.json | 90 +++ website/.vitepress/config/zh-CN.ts | 20 +- website/zh-CN/api/cordis/context.md | 219 ++++-- website/zh-CN/api/cordis/events.md | 192 ++--- website/zh-CN/api/cordis/fiber.md | 303 ++++++-- website/zh-CN/api/cordis/registry.md | 160 +++-- website/zh-CN/api/cordis/service.md | 163 ++--- website/zh-CN/api/harness/agent-loop.md | 56 ++ website/zh-CN/api/harness/agent.md | 85 --- website/zh-CN/api/harness/agents.md | 91 +++ website/zh-CN/api/harness/bash.md | 173 +++-- website/zh-CN/api/harness/code-runtime.md | 28 + website/zh-CN/api/harness/compact.md | 55 ++ website/zh-CN/api/harness/events.md | 546 ++++++++++++++ website/zh-CN/api/harness/fs.md | 172 +++-- website/zh-CN/api/harness/llm.md | 128 +--- .../zh-CN/api/harness/session-persistence.md | 66 ++ website/zh-CN/api/harness/session.md | 56 -- website/zh-CN/api/harness/sessions.md | 110 +++ website/zh-CN/api/harness/subagent.md | 85 --- website/zh-CN/api/harness/subagents.md | 64 ++ website/zh-CN/api/harness/system-prompt.md | 66 ++ website/zh-CN/api/harness/tools.md | 131 +--- website/zh-CN/api/harness/user-interaction.md | 37 + website/zh-CN/api/harness/web.md | 74 ++ website/zh-CN/api/harness/workflows.md | 28 + website/zh-CN/api/index.md | 34 +- 31 files changed, 2983 insertions(+), 931 deletions(-) create mode 100644 scripts/gen-website-api.ts create mode 100644 website/.vitepress/config/api-sidebar.json create mode 100644 website/zh-CN/api/harness/agent-loop.md delete mode 100644 website/zh-CN/api/harness/agent.md create mode 100644 website/zh-CN/api/harness/agents.md create mode 100644 website/zh-CN/api/harness/code-runtime.md create mode 100644 website/zh-CN/api/harness/compact.md create mode 100644 website/zh-CN/api/harness/events.md create mode 100644 website/zh-CN/api/harness/session-persistence.md delete mode 100644 website/zh-CN/api/harness/session.md create mode 100644 website/zh-CN/api/harness/sessions.md delete mode 100644 website/zh-CN/api/harness/subagent.md create mode 100644 website/zh-CN/api/harness/subagents.md create mode 100644 website/zh-CN/api/harness/system-prompt.md create mode 100644 website/zh-CN/api/harness/user-interaction.md create mode 100644 website/zh-CN/api/harness/web.md create mode 100644 website/zh-CN/api/harness/workflows.md diff --git a/AGENTS.md b/AGENTS.md index 70bd5b3ddf..593b4d39b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ packages/ Harness packages at packages///, all named @deepseek-ai examples/ Runnable demos: thin cordis.yml leaves over the app packages (see examples/AGENTS.md) docs/ architecture, generated catalogs, RFCs, postmortems, cookbook (see docs/AGENTS.md) scripts/ repo gates and generators -website/ VitePress docs site (zh-CN) +website/ VitePress docs site (zh-CN); api/ pages generated from source ``` Per-package map: the group READMEs, indexed from [packages/README.md](packages/README.md). diff --git a/package.json b/package.json index acfe588c50..8e524eae18 100644 --- a/package.json +++ b/package.json @@ -61,11 +61,13 @@ "verify-persistence-catalog": "tsx scripts/gen-persistence-catalog.ts --check", "gen-module-graph": "tsx scripts/gen-module-graph.ts", "verify-module-graph": "tsx scripts/gen-module-graph.ts --check", + "gen-website-api": "tsx scripts/gen-website-api.ts", + "verify-website-api": "tsx scripts/gen-website-api.ts --check", "verify-website-yaml": "tsx scripts/verify-website-yaml.ts", "website:dev": "pnpm --filter @deepseek-ai/website run dev", "website:build": "pnpm --filter @deepseek-ai/website run build", "constraints": "tsx scripts/check-workspace-constraints.ts", - "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-cordis-api && pnpm run verify-export-jsdoc && pnpm run verify-tool-catalog && pnpm run verify-config-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-mermaid && pnpm run verify-rfc-classification && pnpm run verify-rfc-format && pnpm run verify-type-equiv && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets && pnpm run verify-website-yaml", + "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-cordis-api && pnpm run verify-export-jsdoc && pnpm run verify-tool-catalog && pnpm run verify-config-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-website-api && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-mermaid && pnpm run verify-rfc-classification && pnpm run verify-rfc-format && pnpm run verify-type-equiv && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets && pnpm run verify-website-yaml", "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types", "demo:echo": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/echo-agent/cordis.yml", "demo:repl": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/coding-agent/cordis.yml", diff --git a/scripts/gen-website-api.ts b/scripts/gen-website-api.ts new file mode 100644 index 0000000000..2d74f075e6 --- /dev/null +++ b/scripts/gen-website-api.ts @@ -0,0 +1,675 @@ +/** + * Generate (and verify) the website API reference under `website/zh-CN/api/`. + * + * The website's API section is FULLY GENERATED from source — never hand-edit + * it. The hand-written hub `api/index.md` sits OUTSIDE the generated subdirs + * (`api/cordis/`, `api/harness/`), so the orphan sweep never touches it. Two tiers: + * + * - `api/cordis/*` — the vendored cordis framework surface (Context, Events, + * Fiber, Registry, Service), driven by the CORDIS_PAGES manifest below. + * Members come from the real class declarations and the `declare module + * './context.ts'` interface merges (the typed `ctx.*` surface a plugin + * author actually sees). + * - `api/harness/*` — one page per `ctx.` harness service (walked from + * every `declare module 'cordis'` Context merge under `packages///src`), + * plus `events.md` listing every harness event grouped by scope. + * + * Prose comes from the JSDoc; the generator HARD-ERRORS (aggregated) when a + * rendered member lacks a summary, a parameter lacks `@param`, or a non-void + * annotated return lacks `@returns` — so a vendor sync or a new service method + * cannot land undocumented without CI going red. Pages are English (the + * planned zh translation flow arrives separately; see docs/i18n/README.md). + * + * Signature fences use the ` ```ts website-api ` info string: doc-typecheck + * only processes its known info strings, so these bare (non-compilable) + * signature fragments are skipped there, while VitePress still highlights the + * `ts` token. The sidebar fragment `website/.vitepress/config/api-sidebar.json` + * is generated alongside so navigation can never drift from the page set. + * + * `tsx scripts/gen-website-api.ts` → write pages + sidebar + * `tsx scripts/gen-website-api.ts --check` → exit 1 if committed copies are + * stale (doc-sync / CI gate) + */ + +import { globSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import ts from 'typescript' +import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc, reportViolations, type Mode } from './jsdoc.ts' + +const root = resolve(import.meta.dirname, '..') + +/** Output roots: generated pages and the generated sidebar fragment. */ +const PAGES_DIR = 'website/zh-CN/api' +const SIDEBAR_OUT = 'website/.vitepress/config/api-sidebar.json' + +/** GitHub blob base for source links on the public site (repo-relative paths + * do not resolve on the built site, unlike the in-repo catalogs). */ +const GITHUB = 'https://github.com/deepseek-harness/deepseek-harness/blob/master' + +/** Signature-fence info string (skipped by doc-typecheck, highlighted as ts). */ +const FENCE = 'ts website-api' + +/** One rendered member: a method/property plus its parsed JSDoc. */ +interface MemberDoc { + /** Display name, e.g. `on` or `agent/pre-step`. */ + name: string + /** Heading suffix with parameter names, e.g. `(name, listener, options?)`; + * empty for properties. */ + heading: string + /** All overload signature lines (bodies stripped). */ + signatures: string[] + /** Description prose, one paragraph per line. */ + doc: string + /** Parameter name → `@param` text, in declaration order. */ + params: { name: string; text: string }[] + /** `@returns` text, or null for void/undocumented. */ + returns: string | null + /** Repo-relative `file:line` of the (first) declaration. */ + source: string +} + +/** A cordis-page section: which declarations it renders. */ +type Section = + | { kind: 'class'; file: string; symbol: string; prefix?: string } + | { kind: 'context-merge'; file: string } + | { kind: 'decl'; file: string; symbol: string } + +/** One generated cordis page. */ +interface CordisPage { + out: string + title: string + intro: string + sections: Section[] +} + +/** + * The cordis tier manifest. Deliberately explicit (not a blind walk): the + * vendor `Context` mixes true plugin-author surface with internals, and page + * grouping is an editorial choice — but every member listed here is still + * EXTRACTED, never transcribed, so signatures and docs cannot drift. + */ +const CORDIS_PAGES: CordisPage[] = [ + { + out: 'cordis/context.md', + title: 'Context', + intro: 'The context is the core cordis object: every service, event, and lifecycle API is reached through `ctx`. Event methods (`ctx.on`, `ctx.emit`, …) are documented on [Events](./events.md); `ctx.effect` and `ctx.fiber` on [Fiber](./fiber.md); `ctx.plugin` and `ctx.inject` on [Registry](./registry.md).', + sections: [ + { kind: 'class', file: 'vendor/cordis/src/context.ts', symbol: 'Context', prefix: 'ctx.' }, + { kind: 'context-merge', file: 'vendor/cordis/src/reflect.ts' }, + ], + }, + { + out: 'cordis/events.md', + title: 'Events', + intro: 'The event system mixed into every context. Harness-defined events are cataloged on [Harness events](../harness/events.md).', + sections: [ + { kind: 'context-merge', file: 'vendor/cordis/src/events.ts' }, + { kind: 'decl', file: 'vendor/cordis/src/events.ts', symbol: 'EventOptions' }, + { kind: 'decl', file: 'vendor/cordis/src/events.ts', symbol: 'DispatchMode' }, + ], + }, + { + out: 'cordis/fiber.md', + title: 'Fiber', + intro: 'A fiber is one loaded plugin instance: its lifecycle state, validated config, and registered effects. `ctx.fiber` is the current fiber; `ctx.effect()` delegates to it.', + sections: [ + { kind: 'context-merge', file: 'vendor/cordis/src/fiber.ts' }, + { kind: 'class', file: 'vendor/cordis/src/fiber.ts', symbol: 'Fiber' }, + { kind: 'decl', file: 'vendor/cordis/src/fiber.ts', symbol: 'Effect' }, + { kind: 'decl', file: 'vendor/cordis/src/fiber.ts', symbol: 'Disposable' }, + { kind: 'decl', file: 'vendor/cordis/src/fiber.ts', symbol: 'EffectMeta' }, + { kind: 'decl', file: 'vendor/cordis/src/fiber.ts', symbol: 'CordisError' }, + { kind: 'decl', file: 'vendor/cordis/src/fiber.ts', symbol: 'ValidationError' }, + ], + }, + { + out: 'cordis/registry.md', + title: 'Registry', + intro: 'Plugin loading and dependency injection.', + sections: [ + { kind: 'context-merge', file: 'vendor/cordis/src/registry.ts' }, + { kind: 'decl', file: 'vendor/cordis/src/registry.ts', symbol: 'Plugin' }, + { kind: 'decl', file: 'vendor/cordis/src/registry.ts', symbol: 'Inject' }, + ], + }, + { + out: 'cordis/service.md', + title: 'Service', + intro: 'Base class for context services: subclass it and load the subclass as a plugin to register `ctx.`.', + sections: [ + { kind: 'class', file: 'vendor/cordis/src/service.ts', symbol: 'Service' }, + ], + }, +] +// --------------------------------------------------------------------------- +// Extraction +// --------------------------------------------------------------------------- + +const sfCache = new Map() + +/** Parse (and cache) one repo-relative source file. */ +function load(rel: string): { sf: ts.SourceFile; text: string } { + const cached = sfCache.get(rel) + if (cached) return cached + const text = readFileSync(resolve(root, rel), 'utf8') + const sf = ts.createSourceFile(rel, text, ts.ScriptTarget.Latest, true) + const entry = { sf, text } + sfCache.set(rel, entry) + return entry +} + +/** The body of a `declare module './context.ts'` / `declare module 'cordis'` + * block, or null. */ +function moduleBody(sf: ts.SourceFile): ts.ModuleBlock | null { + for (const stmt of sf.statements) { + if (!ts.isModuleDeclaration(stmt) || !ts.isStringLiteral(stmt.name)) continue + if (stmt.name.text !== './context.ts' && stmt.name.text !== 'cordis') continue + if (stmt.body && ts.isModuleBlock(stmt.body)) return stmt.body + } + return null +} + +/** Signature text of a member: full text minus body/initializer, whitespace + * collapsed, trailing semicolon stripped. */ +function signatureOf(member: ts.Node, sf: ts.SourceFile): string { + const full = member.getText(sf) + const tail = (member as { body?: ts.Node; initializer?: ts.Node }).body + ?? (member as { initializer?: ts.Node }).initializer + const sig = tail ? full.slice(0, full.length - tail.getText(sf).length).replace(/[=\s]+$/, '') : full + return sig.replace(/\s*;?\s*$/, '').replace(/\s+/g, ' ').trim() +} + +/** `(a, b?, ...rest)` heading suffix from a parameter list, `this` dropped. */ +function headingParams(parameters: readonly ts.ParameterDeclaration[], sf: ts.SourceFile): string { + const names = parameters + .filter(p => !(ts.isIdentifier(p.name) && p.name.text === 'this')) + .map((p) => { + const dots = p.dotDotDotToken ? '...' : '' + const opt = p.questionToken || p.initializer ? '?' : '' + return `${dots}${p.name.getText(sf)}${opt}` + }) + return `(${names.join(', ')})` +} + +/** Whether a class member is renderable public API (non-static half). */ +function isPublicInstance(member: ts.ClassElement): boolean { + const mods = ts.getCombinedModifierFlags(member) + if (mods & (ts.ModifierFlags.Private | ts.ModifierFlags.Protected | ts.ModifierFlags.Static)) return false + if (!member.name) return false + if (ts.isComputedPropertyName(member.name) || ts.isPrivateIdentifier(member.name)) return false + return !member.name.getText().startsWith('_') +} + +/** Whether a class member is renderable public STATIC API. */ +function isPublicStatic(member: ts.ClassElement): boolean { + const mods = ts.getCombinedModifierFlags(member) + if (mods & (ts.ModifierFlags.Private | ts.ModifierFlags.Protected)) return false + if (!(mods & ts.ModifierFlags.Static)) return false + if (!member.name || ts.isComputedPropertyName(member.name) || ts.isPrivateIdentifier(member.name)) return false + return !member.name.getText().startsWith('_') +} + +/** Build a MemberDoc from a declaration group (overloads share one entry), + * collecting completeness violations for everything rendered. */ +function memberDoc( + where: string, + name: string, + group: (ts.MethodDeclaration | ts.MethodSignature | ts.PropertyDeclaration | ts.PropertySignature | ts.GetAccessorDeclaration)[], + rel: string, + violations: string[], +): MemberDoc { + const { sf, text } = load(rel) + const first = group[0] + if (!first) throw new Error(`gen-website-api: empty member group for ${name}`) + // Doc from the first overload that carries JSDoc prose. + const rawDocs = group.map(m => rawJsDoc(text, m)) + const docIndex = rawDocs.findIndex(r => parseJsDoc(r).doc !== '') + const raw = docIndex === -1 ? '' : (rawDocs[docIndex] ?? '') + const doc = parseJsDoc(raw).doc + if (!doc) violations.push(`${where} has no JSDoc prose.`) + const { params: tags, returns } = parseTags(raw) + const params: { name: string; text: string }[] = [] + let returnsText: string | null = null + const funcLike = group.filter((m): m is ts.MethodDeclaration | ts.MethodSignature => ts.isMethodDeclaration(m) || ts.isMethodSignature(m)) + const docCarrier = funcLike[docIndex === -1 ? 0 : docIndex] + if (docCarrier) { + checkParams(where, 'website-api', docCarrier.parameters, tags, sf, + p => ts.isIdentifier(p.name) && p.name.text === 'this', violations) + if (docCarrier.type) { + checkReturns(where, docCarrier.type, returns, sf, violations) + } else if (!returns && ts.isMethodDeclaration(docCarrier)) { + // Comment-only vendor policy: we cannot add a return type annotation to + // pinned upstream source, so an unannotated rendered method must carry + // an explicit @returns describing the result instead. + violations.push(`${where} has no return type annotation; document the result with @returns.`) + } + for (const p of docCarrier.parameters) { + if (ts.isIdentifier(p.name) && p.name.text === 'this') continue + const pname = p.name.getText(sf) + const tag = tags.get(pname) + if (tag) params.push({ name: pname, text: tag }) + } + returnsText = returns + } + const headingSource = docCarrier ?? funcLike[0] + return { + name, + heading: headingSource ? headingParams(headingSource.parameters, sf) : '', + signatures: (ts.isMethodDeclaration(first) && funcLike.length > 1 + ? funcLike.filter(m => ts.isMethodDeclaration(m) && !m.body) + : group).map(m => signatureOf(m, sf)), + doc, + params, + returns: returnsText, + source: pointer(rel, sf, first), + } +} + +/** Members of the `interface Context` merge in `rel`, overloads grouped. */ +function contextMergeMembers(rel: string, violations: string[]): MemberDoc[] { + const { sf } = load(rel) + const body = moduleBody(sf) + if (!body) throw new Error(`gen-website-api: ${rel} has no context module merge`) + const groups = new Map() + for (const stmt of body.statements) { + if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Context') continue + for (const member of stmt.members) { + if (!ts.isMethodSignature(member) && !ts.isPropertySignature(member)) continue + if (ts.isComputedPropertyName(member.name)) continue + const name = member.name.getText(sf) + const group = groups.get(name) ?? [] + group.push(member) + groups.set(name, group) + } + } + return [...groups.entries()].map(([name, group]) => + memberDoc(`ctx.${name} (${rel})`, name, group, rel, violations)) +} + +/** Instance + static members of one class, as two rendered lists. */ +function classMembers(rel: string, className: string, violations: string[]): { + doc: string + instance: MemberDoc[] + statics: MemberDoc[] + source: string +} { + const { sf, text } = load(rel) + const cls = sf.statements.find( + (s): s is ts.ClassDeclaration => ts.isClassDeclaration(s) && s.name?.text === className, + ) + if (!cls) throw new Error(`gen-website-api: class ${className} not found in ${rel}`) + const clsDoc = parseJsDoc(rawJsDoc(text, cls)).doc + if (!clsDoc) violations.push(`class ${className} (${pointer(rel, sf, cls)}) has no JSDoc.`) + const instance = new Map() + const statics = new Map() + for (const member of cls.members) { + const renderable = ts.isMethodDeclaration(member) || ts.isPropertyDeclaration(member) || ts.isGetAccessorDeclaration(member) + if (!renderable) continue + const name = member.name.getText(sf) + if (isPublicInstance(member)) { + const group = instance.get(name) ?? [] + group.push(member) + instance.set(name, group) + } else if (isPublicStatic(member) && !ts.isGetAccessorDeclaration(member)) { + const group = statics.get(name) ?? [] + group.push(member) + statics.set(name, group) + } + } + type Renderable = ts.MethodDeclaration | ts.PropertyDeclaration | ts.GetAccessorDeclaration + const toDocs = (groups: Map, prefix: string): MemberDoc[] => + [...groups.entries()].map(([name, group]) => + memberDoc(`${prefix}${name} (${rel})`, name, group, rel, violations)) + return { + doc: clsDoc, + instance: toDocs(instance, `${className}#`), + statics: toDocs(statics, `${className}.`), + source: pointer(rel, sf, cls), + } +} + +/** Splice every function-like BODY out of a declaration's text, leaving the + * signature (`) {` → `)`). A reference paste shows shapes, not implementation; + * property initializers (e.g. an `as const` code table) are data and stay. */ +function stripBodies(node: ts.Node, sf: ts.SourceFile): string { + const cuts: { start: number; end: number }[] = [] + const visit = (n: ts.Node): void => { + const funcLike = ts.isMethodDeclaration(n) || ts.isConstructorDeclaration(n) + || ts.isFunctionDeclaration(n) || ts.isGetAccessorDeclaration(n) || ts.isSetAccessorDeclaration(n) + if (funcLike && n.body) { + // Cut from just after the parameter close (or return-type end) through + // the body, so `foo(a: string) { … }` renders as `foo(a: string)`. + const sigEnd = (n.type ?? n.parameters[n.parameters.length - 1] ?? n).getEnd() + // Find the `)` (and optional `: Type`) boundary: body start is exact. + cuts.push({ start: sigEnd, end: n.body.getEnd() }) + return // nothing renderable inside the body + } + n.forEachChild(visit) + } + visit(node) + const base = node.getStart(sf) + let out = node.getText(sf) + for (const cut of cuts.sort((a, b) => b.start - a.start)) { + const head = out.slice(0, cut.start - base) + // Keep everything of the signature up to the closing paren / return type, + // drop ` { … }`. The head may end mid-signature (last param), so retain + // the source between sigEnd and the body's `{` MINUS trailing space. + const between = out.slice(cut.start - base, cut.end - base) + const bodyBrace = between.indexOf('{') + out = head + between.slice(0, bodyBrace).trimEnd() + out.slice(cut.end - base) + } + return out +} + +/** Verbatim declaration paste: every top-level statement named `symbol` + * (class + merged namespace both), with leading JSDoc prose extracted and + * function bodies stripped (a reference shows shapes, not implementation). */ +function declPaste(rel: string, symbol: string): { doc: string; code: string; source: string } { + const { sf, text } = load(rel) + const matches = sf.statements.filter((s) => { + const named = ts.isInterfaceDeclaration(s) || ts.isTypeAliasDeclaration(s) + || ts.isClassDeclaration(s) || ts.isEnumDeclaration(s) || ts.isModuleDeclaration(s) + return named && s.name?.getText(sf) === symbol + }) + if (matches.length === 0) throw new Error(`gen-website-api: declaration ${symbol} not found in ${rel}`) + const first = matches[0] + if (!first) throw new Error(`gen-website-api: declaration ${symbol} not found in ${rel}`) + const doc = parseJsDoc(rawJsDoc(text, first)).doc + const code = matches.map(s => stripBodies(s, sf).replace(/^export\s+(default\s+)?/, '')).join('\n\n') + return { doc, code, source: pointer(rel, sf, first) } +} + +/** One harness service with member-level detail. */ +interface HarnessService { + key: string + type: string + abstract: boolean + doc: string + members: MemberDoc[] + source: string + /** Owning npm package name (from the package.json beside the entry). */ + pkg: string +} + +/** Walk every harness `declare module 'cordis'` Context merge → services. */ +function collectHarnessServices(violations: string[]): HarnessService[] { + const services: HarnessService[] = [] + for (const rel of globSync('packages/*/*/src/index.ts', { cwd: root }).sort()) { + const { sf, text } = load(rel) + if (!text.includes('interface Context')) continue + const body = moduleBody(sf) + if (!body) continue + const keyToType = new Map() + for (const stmt of body.statements) { + if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Context') continue + for (const member of stmt.members) { + if (!ts.isPropertySignature(member) || !member.type) continue + keyToType.set(member.name.getText(sf), member.type.getText(sf)) + } + } + const pkgJson = rel.replace(/src\/index\.ts$/, 'package.json') + const pkg = (JSON.parse(readFileSync(resolve(root, pkgJson), 'utf8')) as { name: string }).name + for (const [key, type] of keyToType) { + const cls = sf.statements.find( + (s): s is ts.ClassDeclaration => ts.isClassDeclaration(s) && s.name?.text === type, + ) + if (!cls) continue // a Pick-mixin member, not a class here + const abstract = cls.modifiers?.some(m => m.kind === ts.SyntaxKind.AbstractKeyword) ?? false + const clsDoc = parseJsDoc(rawJsDoc(text, cls)).doc + if (!clsDoc) violations.push(`service ctx.${key} (${pointer(rel, sf, cls)}): class ${type} has no JSDoc.`) + const groups = new Map() + for (const member of cls.members) { + if (!ts.isMethodDeclaration(member)) continue + if (!isPublicInstance(member)) continue + const name = member.name.getText(sf) + const group = groups.get(name) ?? [] + group.push(member) + groups.set(name, group) + } + const members = [...groups.entries()].map(([name, group]) => + memberDoc(`ctx.${key}.${name} (${rel})`, name, group, rel, violations)) + services.push({ key, type, abstract, doc: clsDoc, members, source: pointer(rel, sf, cls), pkg }) + } + } + return services.sort((a, b) => a.key.localeCompare(b.key)) +} + +/** One harness event with member-level detail. */ +interface HarnessEvent { + name: string + scope: string + mode: Mode | null + signature: string + doc: string + params: { name: string; text: string }[] + source: string +} + +/** Walk every harness `interface Events` merge → events. */ +function collectHarnessEvents(violations: string[]): HarnessEvent[] { + const events: HarnessEvent[] = [] + for (const rel of globSync('packages/*/*/src/*.ts', { cwd: root }).sort()) { + const { sf, text } = load(rel) + if (!text.includes('interface Events')) continue + const body = moduleBody(sf) + if (!body) continue + for (const stmt of body.statements) { + if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Events') continue + for (const member of stmt.members) { + if (!ts.isMethodSignature(member)) continue + const name = ts.isStringLiteral(member.name) ? member.name.text : member.name.getText(sf) + const raw = rawJsDoc(text, member) + const { doc, mode } = parseJsDoc(raw) + if (!mode) violations.push(`event '${name}' (${pointer(rel, sf, member)}) is missing @mode.`) + if (!doc) violations.push(`event '${name}' (${pointer(rel, sf, member)}) has no JSDoc prose.`) + const { params: tags } = parseTags(raw) + const last = member.parameters.at(-1) + const hasNext = !!last && last.name.getText(sf) === 'next' + checkParams(`event '${name}' (${pointer(rel, sf, member)})`, 'website-api', member.parameters, tags, sf, + p => (ts.isIdentifier(p.name) && p.name.text === 'this') || (hasNext && p === last), violations) + const params: { name: string; text: string }[] = [] + for (const p of member.parameters) { + const pname = p.name.getText(sf) + const tag = tags.get(pname) + if (tag) params.push({ name: pname, text: tag }) + } + events.push({ name, scope: name.split('/')[0] ?? name, mode, signature: signatureOf(member, sf), doc, params, source: pointer(rel, sf, member) }) + } + } + } + return events.sort((a, b) => a.name.localeCompare(b.name)) +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +const BANNER = '' + +/** GitHub source link for a `file:line` pointer. */ +function sourceLink(source: string): string { + const [file, line] = source.split(':') + return `[Source](${GITHUB}/${file}#L${line})` +} + +/** Render prose paragraphs (one per line of `doc`). */ +function prose(doc: string): string[] { + return doc.split('\n').filter(l => l.trim() !== '') +} + +/** Render one member section at heading depth 3. */ +function renderMember(prefix: string, m: MemberDoc): string[] { + const lines: string[] = [] + const call = m.heading === '' ? '' : m.heading + lines.push(`### ${prefix}${m.name}${call}`, '') + lines.push('```' + FENCE) + for (const sig of m.signatures) lines.push(sig) + lines.push('```', '') + lines.push(...prose(m.doc), '') + if (m.params.length > 0) { + for (const p of m.params) lines.push(`- \`${p.name}\` — ${p.text}`) + lines.push('') + } + if (m.returns) lines.push(`**Returns** ${m.returns}`, '') + lines.push(sourceLink(m.source), '') + return lines +} + +/** Render one cordis-tier page from its manifest entry. */ +function renderCordisPage(page: CordisPage, violations: string[]): string { + const lines: string[] = [BANNER, '', `# ${page.title}`, '', page.intro, ''] + for (const section of page.sections) { + if (section.kind === 'context-merge') { + for (const m of contextMergeMembers(section.file, violations)) { + lines.push(...renderMember('ctx.', m)) + } + } else if (section.kind === 'class') { + const cls = classMembers(section.file, section.symbol, violations) + lines.push(...prose(cls.doc), '', sourceLink(cls.source), '') + const instancePrefix = section.prefix ?? `${section.symbol.toLowerCase()}.` + for (const m of cls.instance) lines.push(...renderMember(instancePrefix, m)) + if (cls.statics.length > 0) { + lines.push('## Static members', '') + for (const m of cls.statics) lines.push(...renderMember(`${section.symbol}.`, m)) + } + } else { + const decl = declPaste(section.file, section.symbol) + lines.push(`## ${section.symbol}`, '') + if (decl.doc) lines.push(...prose(decl.doc), '') + lines.push('```' + FENCE, decl.code, '```', '', sourceLink(decl.source), '') + } + } + return `${lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n` +} + +/** kebab-case a ctx key: `agentLoop` → `agent-loop`. */ +function kebab(key: string): string { + return key.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`) +} + +/** Render one harness service page. */ +function renderServicePage(svc: HarnessService): string { + const seam = svc.abstract ? ' (abstract seam)' : '' + const lines: string[] = [ + BANNER, '', + `# ctx.${svc.key}`, '', + `\`${svc.type}\`${seam} — provided by \`${svc.pkg}\`.`, '', + ...prose(svc.doc), '', + sourceLink(svc.source), '', + ] + for (const m of svc.members) lines.push(...renderMember(`ctx.${svc.key}.`, m)) + return `${lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n` +} + +/** Render the harness events page, grouped by scope. */ +function renderEventsPage(events: HarnessEvent[]): string { + const lines: string[] = [ + BANNER, '', + '# Harness events', '', + `Every event the harness packages declare on the cordis event bus (${events.length} total), grouped by scope. The **mode** is the dispatch semantics (\`emit\` fire-and-forget, \`parallel\` awaited, \`serial\` first-bail, \`waterfall\` veto-chain — a waterfall listener MUST call \`next()\` to delegate).`, '', + ] + const scopes = [...new Set(events.map(e => e.scope))].sort() + for (const scope of scopes) { + lines.push(`## ${scope}/*`, '') + for (const e of events.filter(ev => ev.scope === scope)) { + lines.push(`### ${e.name}`, '') + lines.push(`**Mode:** \`${e.mode ?? 'unknown'}\``, '') + lines.push('```' + FENCE, e.signature, '```', '') + lines.push(...prose(e.doc), '') + if (e.params.length > 0) { + for (const p of e.params) lines.push(`- \`${p.name}\` — ${p.text}`) + lines.push('') + } + lines.push(sourceLink(e.source), '') + } + } + return `${lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n` +} + +// --------------------------------------------------------------------------- +// Assembly + CLI +// --------------------------------------------------------------------------- + +/** Build every generated file as `relPath → content`. */ +export function generate(): Map { + const violations: string[] = [] + const files = new Map() + + for (const page of CORDIS_PAGES) { + files.set(`${PAGES_DIR}/${page.out}`, renderCordisPage(page, violations)) + } + + const services = collectHarnessServices(violations) + for (const svc of services) { + files.set(`${PAGES_DIR}/harness/${kebab(svc.key)}.md`, renderServicePage(svc)) + } + + const events = collectHarnessEvents(violations) + files.set(`${PAGES_DIR}/harness/events.md`, renderEventsPage(events)) + + reportViolations('gen-website-api', violations) + + const sidebar = { + cordis: CORDIS_PAGES.map(p => ({ + text: p.title, + link: `/zh-CN/api/${p.out.replace(/\.md$/, '')}`, + })), + harness: [ + ...services.map(s => ({ text: `ctx.${s.key}`, link: `/zh-CN/api/harness/${kebab(s.key)}` })), + { text: 'Events', link: '/zh-CN/api/harness/events' }, + ], + } + files.set(SIDEBAR_OUT, `${JSON.stringify(sidebar, null, 2)}\n`) + return files +} + +/** CLI entry: default writes, `--check` fails on stale/orphan files. Guarded + * behind an entry-point check so tests can import `generate()`. */ +function main(): void { + const check = process.argv.includes('--check') + const files = generate() + + // Orphan detection: a generated-dir page that generate() no longer emits + // (e.g. a service was renamed) must be deleted, not left to rot. + const expected = new Set([...files.keys()]) + // Orphans live in the generated subdirs only; the hand-written api/index.md + // is one level up and never matches this glob. + const onDisk = globSync(`${PAGES_DIR}/{cordis,harness}/*.md`, { cwd: root }).sort() + const orphans = onDisk.filter(rel => !expected.has(rel)) + + if (check) { + const stale: string[] = [] + for (const [rel, content] of files) { + let current: string | null = null + try { + current = readFileSync(resolve(root, rel), 'utf8') + } catch { + // Missing file: reported as stale below; readFileSync is the probe. + } + if (current !== content) stale.push(rel) + } + if (stale.length > 0 || orphans.length > 0) { + console.error('gen-website-api: website API reference is stale. Run `pnpm run gen-website-api` and commit the result.') + for (const rel of stale) console.error(` stale: ${rel}`) + for (const rel of orphans) console.error(` orphan (delete): ${rel}`) + process.exit(1) + } + console.log(`gen-website-api: ${files.size} generated file(s) fresh.`) + return + } + + for (const [rel, content] of files) { + const abs = resolve(root, rel) + mkdirSync(dirname(abs), { recursive: true }) + writeFileSync(abs, content) + } + for (const rel of orphans) { + console.log(`gen-website-api: orphan page ${rel} — delete it (no longer generated).`) + } + console.log(`gen-website-api: wrote ${files.size} file(s).`) +} + +// Run only when invoked as a script, not when imported by a test. +if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) { + main() +} diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 6384697650..6891cf1066 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -264,6 +264,7 @@ function docSyncLeafGates(): Gate[] { pnpmScript('config-catalog', 'verify-config-catalog', { label: 'config catalog' }), pnpmScript('persistence-catalog', 'verify-persistence-catalog', { label: 'persistence catalog' }), pnpmScript('doc-graphs', 'verify-doc-graphs', { label: 'doc graphs' }), + pnpmScript('website-api', 'verify-website-api', { label: 'website api' }), pnpmScript('markdown-wrap', 'verify-md-wrap', { label: 'markdown wrap' }), pnpmScript('markdown-links', 'verify-md-links', { label: 'markdown links' }), pnpmScript('doc-refs', 'verify-doc-refs', { label: 'doc refs' }), diff --git a/website/.vitepress/config/api-sidebar.json b/website/.vitepress/config/api-sidebar.json new file mode 100644 index 0000000000..3b1f94d56f --- /dev/null +++ b/website/.vitepress/config/api-sidebar.json @@ -0,0 +1,90 @@ +{ + "cordis": [ + { + "text": "Context", + "link": "/zh-CN/api/cordis/context" + }, + { + "text": "Events", + "link": "/zh-CN/api/cordis/events" + }, + { + "text": "Fiber", + "link": "/zh-CN/api/cordis/fiber" + }, + { + "text": "Registry", + "link": "/zh-CN/api/cordis/registry" + }, + { + "text": "Service", + "link": "/zh-CN/api/cordis/service" + } + ], + "harness": [ + { + "text": "ctx.agentLoop", + "link": "/zh-CN/api/harness/agent-loop" + }, + { + "text": "ctx.agents", + "link": "/zh-CN/api/harness/agents" + }, + { + "text": "ctx.bash", + "link": "/zh-CN/api/harness/bash" + }, + { + "text": "ctx.codeRuntime", + "link": "/zh-CN/api/harness/code-runtime" + }, + { + "text": "ctx.compact", + "link": "/zh-CN/api/harness/compact" + }, + { + "text": "ctx.fs", + "link": "/zh-CN/api/harness/fs" + }, + { + "text": "ctx.llm", + "link": "/zh-CN/api/harness/llm" + }, + { + "text": "ctx.sessionPersistence", + "link": "/zh-CN/api/harness/session-persistence" + }, + { + "text": "ctx.sessions", + "link": "/zh-CN/api/harness/sessions" + }, + { + "text": "ctx.subagents", + "link": "/zh-CN/api/harness/subagents" + }, + { + "text": "ctx.systemPrompt", + "link": "/zh-CN/api/harness/system-prompt" + }, + { + "text": "ctx.tools", + "link": "/zh-CN/api/harness/tools" + }, + { + "text": "ctx.userInteraction", + "link": "/zh-CN/api/harness/user-interaction" + }, + { + "text": "ctx.web", + "link": "/zh-CN/api/harness/web" + }, + { + "text": "ctx.workflows", + "link": "/zh-CN/api/harness/workflows" + }, + { + "text": "Events", + "link": "/zh-CN/api/harness/events" + } + ] +} diff --git a/website/.vitepress/config/zh-CN.ts b/website/.vitepress/config/zh-CN.ts index 83767b6cbc..ba83cf52c5 100644 --- a/website/.vitepress/config/zh-CN.ts +++ b/website/.vitepress/config/zh-CN.ts @@ -1,4 +1,5 @@ import type { DefaultTheme, LocaleSpecificConfig } from 'vitepress' +import apiSidebarData from './api-sidebar.json' const guideSidebar: DefaultTheme.SidebarItem[] = [ { @@ -37,29 +38,20 @@ const developSidebar: DefaultTheme.SidebarItem[] = [ }, ] +// The API section sidebar is GENERATED (scripts/gen-website-api.ts writes +// api-sidebar.json alongside the pages), so navigation can never drift from +// the generated page set. Only the hand-written hub link lives here. const apiSidebar: DefaultTheme.SidebarItem[] = [ { text: '框架 API', items: [ { text: '总览', link: '/zh-CN/api/' }, - { text: 'Context', link: '/zh-CN/api/cordis/context' }, - { text: 'Events', link: '/zh-CN/api/cordis/events' }, - { text: 'Fiber', link: '/zh-CN/api/cordis/fiber' }, - { text: 'Registry', link: '/zh-CN/api/cordis/registry' }, - { text: 'Service', link: '/zh-CN/api/cordis/service' }, + ...apiSidebarData.cordis, ], }, { text: 'Harness API', - items: [ - { text: 'Tools (dsh-tools)', link: '/zh-CN/api/harness/tools' }, - { text: 'LLM (dsh-llm)', link: '/zh-CN/api/harness/llm' }, - { text: 'Session (dsh-session)', link: '/zh-CN/api/harness/session' }, - { text: 'Agent (dsh-agent)', link: '/zh-CN/api/harness/agent' }, - { text: 'Bash (dsh-bash)', link: '/zh-CN/api/harness/bash' }, - { text: 'Filesystem (dsh-fs)', link: '/zh-CN/api/harness/fs' }, - { text: 'Subagent (dsh-subagent)', link: '/zh-CN/api/harness/subagent' }, - ], + items: apiSidebarData.harness, }, ] diff --git a/website/zh-CN/api/cordis/context.md b/website/zh-CN/api/cordis/context.md index a18f275dad..f8ef268f03 100644 --- a/website/zh-CN/api/cordis/context.md +++ b/website/zh-CN/api/cordis/context.md @@ -1,85 +1,192 @@ + + # Context -上下文对象是 Cordis 的核心。所有服务、方法、属性都通过 `ctx` 访问。 +The context is the core cordis object: every service, event, and lifecycle API is reached through `ctx`. Event methods (`ctx.on`, `ctx.emit`, …) are documented on [Events](./events.md); `ctx.effect` and `ctx.fiber` on [Fiber](./fiber.md); `ctx.plugin` and `ctx.inject` on [Registry](./registry.md). -## 服务与混入 +Root and child dependency containers for Cordis plugins. +A context is a proxy: normal property reads go through the service resolver, while `extend()`, `isolate()`, and `intercept()` create scoped child contexts without mutating their parent. -Context 基于组合式 API 设计,大部分属性和方法挂载在服务上。以下是核心 API: +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/context.ts#L42) -- [`ctx.on`](./events#ctx-on) — 注册事件监听器 -- [`ctx.emit`](./events#ctx-emit) — 触发事件 -- [`ctx.bail`](./events#ctx-bail) — 短路事件 -- [`ctx.serial`](./events#ctx-serial) — 顺序异步事件 -- [`ctx.waterfall`](./events#ctx-waterfall) — 管道事件 -- [`ctx.effect`](./fiber#fiber-effect) — 注册可逆效果 -- [`ctx.plugin`](./registry#ctx-plugin) — 加载子插件 -- [`ctx.inject`](./registry#ctx-inject) — 获取依赖的插件 -- [`ctx.get`](#ctx-get) — 获取服务 -- [`ctx.set`](#ctx-set) — 设置服务 -- [`ctx.provide`](#ctx-provide) — 声明服务 +### ctx.extend(meta?) -## 实例属性 +```ts website-api +extend(meta = {}): this +``` -### ctx.fiber +Create a child context with extra metadata on top of the current scope. +The child prototypally inherits every property of this context; own properties of `meta` shadow the inherited ones. The parent is not mutated. -- **类型:** [`Fiber`](./fiber) +- `meta` — own properties (including symbol keys) to define on the child. -当前上下文的作用域对象。 +**Returns** a child context inheriting from this one. -## 实例方法 - -### ctx.extend(meta) - -- **meta:** `object` -- **返回值:** `Context` - -构造一个以当前上下文为原型的新上下文实例。 - -### ctx.intercept(name, config) - -- **name:** `string` 服务名称 -- **config:** `object` 配置拦截 -- **返回值:** `Context` - -为指定服务添加一层配置拦截,返回新的上下文实例。 +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/context.ts#L99) ### ctx.isolate(name, label?) -- **name:** `string` 服务名称 -- **label:** `symbol` 隔离域符号(可选) -- **返回值:** `Context` +```ts website-api +isolate(name: string, label?: symbol) +``` -创建一个针对指定服务的隔离域,返回新的上下文实例。隔离域中的同名服务互不影响。 +Create a child context with an independent service scope for `name`. +Below the returned context, reads and writes of the service `name` resolve against the new label instead of the parent's, so a different implementation can be provided without affecting the parent scope. Passing the same `label` to two `isolate()` calls joins their scopes. -### ctx.get(name) +- `name` — the service name to isolate. +- `label` — scope label to join; defaults to a fresh unique symbol. -- **name:** `string` 服务名称 -- **返回值:** `Service | undefined` +**Returns** a child context whose `name` service resolves in the new scope. -获取指定名称的服务实例。 +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/context.ts#L121) + +### ctx.intercept(name, config) + +```ts website-api +intercept(name: K, config: Context[K] extends { [symbols.config]: infer T } ? T : never): this +intercept(name: string, config: any): this +``` + +Add service-specific intercept config for plugins started below this context. +Plugins loaded under the returned context see `config` merged into the service's resolved config (ancestor entries first; see `Service[symbols.resolveConfig]`). The parent context is not affected. + +- `name` — the service name whose config to intercept. +- `config` — the intercept config to merge for that service. + +**Returns** a child context carrying the additional intercept entry. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/context.ts#L139) + +## Static members + +### Context.effect + +```ts website-api +static readonly effect: unique symbol +``` + +Symbol key under which a disposer exposes its EffectMeta diagnostics tree. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/context.ts#L44) + +### Context.filter + +```ts website-api +static readonly filter: unique symbol +``` + +Symbol key for a context's listener filter, consulted on every event dispatch. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/context.ts#L46) + +### Context.isolate + +```ts website-api +static readonly isolate: unique symbol +``` + +Symbol key of the isolation map (see the `Context[symbols.isolate]` property). + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/context.ts#L48) + +### Context.intercept + +```ts website-api +static readonly intercept: unique symbol +``` + +Symbol key of the intercept map (see the `Context[symbols.intercept]` property). + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/context.ts#L50) + +### Context.is(value) + +```ts website-api +static is(value: any): value is Context +``` + +Returns true for Cordis context proxies and context prototypes. +Works across realms and across multiple copies of cordis, because the brand is keyed by a global symbol rather than by `instanceof`. + +- `value` — the value to test. + +**Returns** `true` if `value` is a Cordis context, narrowing its type. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/context.ts#L61) + +### ctx.get(name, strict?) + +```ts website-api +get(name: K, strict?: boolean): undefined | this[K] +get(name: string, strict?: boolean): any +``` + +Read a service from the store without the inject requirement. + +- `name` — the service name. +- `strict` — when `true` (default), only return implementations whose providing fiber is currently active. + +**Returns** the service value, or `undefined` when not (yet) provided. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/reflect.ts#L16) ### ctx.set(name, value) -- **name:** `string` 服务名称 -- **value:** `any` 服务值 +```ts website-api +set(name: K, value: undefined | this[K]): void +set(name: string, value: any): void +``` -设置指定名称的服务。 +Overwrite a provided service's value. +Only the fiber that provided the service may set it; setting an unprovided name throws. -### ctx.provide(name, value?, options?) +- `name` — the service name. +- `value` — the new service value. -- **name:** `string` 服务名称 -- **value:** `any` 初始值(可选) -- **options:** `object` -- **返回值:** `void` +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/reflect.ts#L28) -声明一个服务。声明后其他插件可以通过 `inject` 依赖它。 +### ctx.provide(name, value) -## 静态属性 +```ts website-api +provide(name: K, value: undefined | this[K]): () => void +provide(name: string, value?: any): () => void +``` -### Context.events +Register a service implementation owned by the current fiber. +The service becomes visible to dependents in the same isolation scope once the fiber is active; it is unregistered (waking dependents) when the returned disposer runs or the fiber unloads. Throws if the name is already provided in this scope or declared as an accessor. -内置事件服务的 symbol key。 +- `name` — the service name. +- `value` — the service value. -### Context.current +**Returns** a disposer that unregisters the service. -当前活跃的 Context 实例(在异步链中通过 AsyncLocalStorage 追踪)。 +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/reflect.ts#L43) + +### ctx.accessor(name, options) + +```ts website-api +accessor(name: string, options: Omit): void +``` + +Define a computed context property backed by get/set hooks. +The accessor is removed when the current fiber unloads. Throws if the name is already declared. + +- `name` — the context property name. +- `options` — the `get` hook and optional `set` hook. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/reflect.ts#L55) + +### ctx.mixin(name, mixins) + +```ts website-api +mixin(name: K, mixins: (keyof this & keyof this[K])[] | Dict): void +mixin(source: T, mixins: (keyof this & keyof T)[] | Dict): void +``` + +Expose selected members of a service directly on `ctx`. +Each mixed-in key becomes an accessor that forwards to the service (binding methods to it), so e.g. `ctx.on` forwards to `ctx.events.on`. Mixins are removed when the current fiber unloads. + +- `name` — the context property holding the source service. +- `mixins` — keys to forward, or a source-key → ctx-key map. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/reflect.ts#L66) diff --git a/website/zh-CN/api/cordis/events.md b/website/zh-CN/api/cordis/events.md index dbc03a87bc..b56f8096fe 100644 --- a/website/zh-CN/api/cordis/events.md +++ b/website/zh-CN/api/cordis/events.md @@ -1,120 +1,142 @@ + + # Events -`ctx.events` 是内置服务,提供事件系统相关的全部 API。 +The event system mixed into every context. Harness-defined events are cataloged on [Harness events](../harness/events.md). -## 实例方法 +### ctx.parallel(name, ...args) -### ctx.on(event, listener, options?) {#ctx-on} - -- **event:** `string` 事件名称 -- **listener:** `Function` 事件监听器 -- **options:** `object` - - **prepend:** `boolean` 是否注册为前置(默认 `false`) - - **global:** `boolean` 是否注册为全局(默认 `false`) -- **返回值:** `() => void` 取消注册函数 - -注册一个事件监听器。返回的函数可用于手动取消注册,但通常不需要——插件卸载时会自动清理。 - -```typescript -ctx.on('agent/turn-end', (data) => { - console.log('turn ended:', data) -}) +```ts website-api +parallel(name: K, ...args: Parameters): Promise +parallel(thisArg: NoInfer>, name: K, ...args: Parameters): Promise ``` -### ctx.emit(thisArg?, event, ...args) {#ctx-emit} +Dispatch an event, running all listeners concurrently. -- **thisArg:** `any` 监听器的 `this` 参数(可选) -- **event:** `string` 事件名称 -- **args:** `any[]` 事件参数 -- **返回值:** `void` +- `name` — the event name. +- `args` — arguments passed to every listener. -同步触发所有匹配的监听器(并行,不等待异步完成)。 +**Returns** a promise resolving once every listener has settled. -### ctx.parallel(thisArg?, event, ...args) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/events.ts#L43) -- 签名同 `emit` -- **返回值:** `Promise` +### ctx.emit(name, ...args) -异步触发所有匹配的监听器(并行等待)。 - -### ctx.bail(thisArg?, event, ...args) {#ctx-bail} - -- **返回值:** `any` - -同步依次触发监听器。第一个返回非 `undefined`/`null`/`false` 值的监听器停止链并返回该值。 - -### ctx.serial(thisArg?, event, ...args) {#ctx-serial} - -- **返回值:** `Promise` - -异步依次触发监听器。语义同 `bail` 的异步版本。 - -### ctx.waterfall(thisArg?, event, ...args) {#ctx-waterfall} - -- **返回值:** `Promise` - -管道模式:每个监听器接收前一个的输出。监听器内部必须调用 `next()` 才会传递给下一个。 - -```typescript -// 注册 -ctx.on('llm/pre-request', async (messages, next) => { - messages.push(extraMsg) - return next(messages) // 必须调用 -}) - -// 触发 -const result = await ctx.waterfall('llm/pre-request', initialMessages) +```ts website-api +emit(name: K, ...args: Parameters): void +emit(thisArg: NoInfer>, name: K, ...args: Parameters): void ``` -::: warning -不调用 `next()` 即为否决 (veto)——管道终止。这是设计行为,用于拦截/网关。 -::: +Dispatch an event synchronously, ignoring listener return values. -## Harness 内置事件 +- `name` — the event name. +- `args` — arguments passed to every listener. -### agent/pre-step +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/events.ts#L52) -- **触发模式:** serial -- **参数:** `{ agentId, turnIndex }` +### ctx.serial(name, ...args) -Agent 执行一步之前触发。 +```ts website-api +serial(name: K, ...args: Parameters): Promisify> +serial(thisArg: NoInfer>, name: K, ...args: Parameters): Promisify> +``` -### agent/post-step +Dispatch an event, awaiting listeners in order until one bails. -- **触发模式:** emit -- **参数:** `{ agentId, turnIndex, blocks }` +- `name` — the event name. +- `args` — arguments passed to each listener. -Agent 执行一步之后触发。 +**Returns** the first bail value (non-null, non-false, non-undefined), if any. -### tool/call +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/events.ts#L62) -- **触发模式:** emit -- **参数:** `{ name, args, callId }` +### ctx.bail(name, ...args) -Tool 被模型调用时触发。 +```ts website-api +bail(name: K, ...args: Parameters): ReturnType +bail(thisArg: NoInfer>, name: K, ...args: Parameters): ReturnType +``` -### tool/result +Dispatch an event, calling listeners in order until one bails. -- **触发模式:** emit -- **参数:** `{ name, result, callId }` +- `name` — the event name. +- `args` — arguments passed to each listener. -Tool 返回结果时触发。 +**Returns** the first bail value (non-null, non-false, non-undefined), if any. -### session/event +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/events.ts#L72) -- **触发模式:** emit -- **参数:** `SessionEvent` +### ctx.waterfall(name, ...args) -会话事件被记录时触发。 +```ts website-api +waterfall(name: K, ...args: Parameters): ReturnType +waterfall(thisArg: NoInfer>, name: K, ...args: Parameters): ReturnType +``` -### compact/start +Dispatch an event whose last argument is a `next` continuation. +Each listener wraps the rest of the chain: calling `next()` invokes the next listener (finally the built-in behavior); not calling it vetoes. -- **触发模式:** emit +- `name` — the event name. +- `args` — listener arguments; the final one is the innermost `next`. -上下文压缩开始。 +**Returns** the outermost listener's return value. -### compact/end +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/events.ts#L85) -- **触发模式:** emit +### ctx.on(name, listener, options?) -上下文压缩结束。 +```ts website-api +on(name: K, listener: Events[K], options?: boolean | EventOptions): () => boolean +``` + +Register an event listener owned by the current fiber. + +- `name` — the event name to listen for. +- `listener` — called with the dispatch arguments. +- `options` — listener options; a boolean is shorthand for `prepend`. + +**Returns** a disposer removing the listener; `true` if it was still registered. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/events.ts#L96) + +### ctx.once(name, listener, options?) + +```ts website-api +once(name: K, listener: Events[K], options?: boolean | EventOptions): () => boolean +``` + +Same as `on()`, but the listener disposes itself after its first call. + +- `name` — the event name to listen for. +- `listener` — called at most once with the dispatch arguments. +- `options` — listener options; a boolean is shorthand for `prepend`. + +**Returns** a disposer removing the listener; `true` if it was still registered. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/events.ts#L105) + +## EventOptions + +Options accepted by `ctx.on()` and `ctx.once()`. + +```ts website-api +interface EventOptions { + /** Add the listener before existing listeners for the same event. */ + prepend?: boolean + /** Receive the event regardless of context filter checks. */ + global?: boolean +} +``` + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/events.ts#L111) + +## DispatchMode + +Event dispatch strategy used by the event service. +`emit` runs synchronous listeners without awaiting them, `parallel` awaits all listeners together, `serial` awaits them in order until one bails, `bail` stops on the first synchronous bail value, and `waterfall` composes listeners around a final `next` callback. + +```ts website-api +type DispatchMode = 'emit' | 'parallel' | 'serial' | 'bail' | 'waterfall' +``` + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/events.ts#L31) diff --git a/website/zh-CN/api/cordis/fiber.md b/website/zh-CN/api/cordis/fiber.md index ffb8f23bb5..360986350b 100644 --- a/website/zh-CN/api/cordis/fiber.md +++ b/website/zh-CN/api/cordis/fiber.md @@ -1,108 +1,263 @@ + + # Fiber -Fiber(作用域)是插件实例的运行时容器,管理其生命周期和效果。 +A fiber is one loaded plugin instance: its lifecycle state, validated config, and registered effects. `ctx.fiber` is the current fiber; `ctx.effect()` delegates to it. -## 状态机 +### ctx.fiber -``` -PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED - ↘ FAILED +```ts website-api +fiber: Fiber ``` -| 状态 | 数值 | 含义 | -|------|------|------| -| PENDING | 0 | 依赖未就绪,等待中 | -| LOADING | 1 | 正在执行 `apply` | -| ACTIVE | 2 | 运行中 | -| FAILED | 3 | `apply` 抛出异常 | -| UNLOADING | 4 | 正在撤销效果 | -| DISPOSED | 5 | 已完全卸载 | +The fiber (plugin runtime instance) that owns this context. -## 实例属性 +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts#L11) + +Runtime instance of one plugin application. +A fiber tracks dependency state, validated config, lifecycle effects, and cleanup for the plugin context returned by `ctx.plugin()`. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts#L154) ### fiber.uid -- **类型:** `number` +```ts website-api +public uid: number | null +``` -Fiber 的唯一标识符。 +Unique id within the registry; 0 for the root fiber, `null` once disposed. -### fiber.status +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts#L156) -- **类型:** `number` +### fiber.ctx -当前状态(见状态机)。 +```ts website-api +public readonly ctx: Context +``` + +The context this fiber's plugin runs in (extends the parent context). + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts#L158) ### fiber.config -- **类型:** `object` - -传递给插件的配置对象。 - -### fiber.error - -- **类型:** `Error | undefined` - -如果状态是 FAILED,包含导致失败的异常。 - -## 实例方法 - -### fiber.effect(callback) {#fiber-effect} - -- **callback:** `() => (() => void) | void` -- **返回值:** `() => void` - -注册一个效果。`callback` 在 Fiber 激活时执行;如果返回函数,该函数在 Fiber dispose 时执行。 - -```typescript -ctx.effect(() => { - const timer = setInterval(tick, 1000) - return () => clearInterval(timer) -}) +```ts website-api +public config: any ``` -等价地可以通过 `ctx.effect()` 调用(ctx 代理到当前 fiber)。 +The validated plugin config (updated by `update()`). -### fiber.dispose() +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts#L160) -- **返回值:** `Promise` +### fiber.state -手动 dispose 该 Fiber。按注册逆序撤销所有效果,递归 dispose 所有子 Fiber。 - -```typescript -const child = ctx.plugin(somePlugin) -// 之后: -await child.dispose() +```ts website-api +public state ``` -### fiber.update(config) +Current lifecycle state; transitions emit `internal/status`. -- **config:** `object` 新配置 -- **返回值:** `void` +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts#L162) -热更新配置。如果新旧配置不同,触发 dispose + 重新 apply。 +### fiber.dispose + +```ts website-api +public readonly dispose: () => Promise +``` + +Dispose this fiber: unload the plugin, then settle once cleanup finished. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts#L164) + +### fiber.store + +```ts website-api +public store: Dict | undefined +``` + +Snapshot of required service implementations while loaded; `undefined` otherwise. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts#L166) + +### fiber.inertia + +```ts website-api +public inertia: Promise | undefined +``` + +The in-flight load/unload transition, if one is currently running. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts#L168) + +### fiber.name + +```ts website-api +get name() +``` + +The plugin's display name, inherited from the nearest named ancestor, else `'root'`. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts#L284) + +### fiber.assertActive() + +```ts website-api +assertActive() +``` + +Throw if the fiber has already been disposed. + +**Returns** nothing when the fiber is still active. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts#L299) + +### fiber.effect(execute, label?) + +```ts website-api +effect(execute: () => SyncEffect, label?: string): Disposable> +effect(execute: () => Effect, label?: string): AsyncDisposable> +``` + +Register a cleanup-aware effect on this fiber. +`execute` runs immediately; the disposers it produces are collected and run (in reverse order) either when the returned disposer is called or when the fiber unloads, whichever comes first. Calling the disposer twice is a no-op. Throws `CordisError('INACTIVE_EFFECT')` if the fiber is already disposed, and `TypeError` if `execute` returns an invalid shape. + +- `execute` — the effect body; see {@link Effect} for accepted shapes. +- `label` — effect label shown in `getEffects()` diagnostics. + +**Returns** a disposer that tears the effect down and settles once done. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts#L363) + +### fiber.getEffects() + +```ts website-api +getEffects() +``` + +Return metadata for currently registered effects. + +**Returns** one {@link EffectMeta} tree per labeled live effect. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts#L436) + +### fiber.await() + +```ts website-api +async await() +``` + +Wait for current lifecycle work and rethrow startup errors. + +**Returns** this fiber, once it has settled into a stable state. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts#L560) ### fiber.restart() -- **返回值:** `void` - -强制重启:dispose 后重新加载。 - -### fiber.then(resolve, reject?) - -- **返回值:** `Promise` - -使 Fiber 可以被 `await`:等到状态进入 ACTIVE 或 FAILED。 - -```typescript -const fiber = ctx.plugin(myPlugin) -await fiber // 等待插件加载完成 +```ts website-api +async restart() ``` -## 访问当前 Fiber +Dispose and immediately reload this plugin with its current config. -```typescript -export function apply(ctx: Context) { - const fiber = ctx.fiber // 当前插件的 Fiber - console.log(fiber.status) // 1 (LOADING, 因为正在 apply 中) +**Returns** a promise resolving once the reload settled. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts#L574) + +### fiber.update(config, noSave?) + +```ts website-api +update(config: any, noSave = false) +``` + +Validate and apply new config, then restart the plugin. +Runs the `internal/update` waterfall first, so update hooks (and HMR) can veto or replace the restart. + +- `config` — the new raw config; validated before anything restarts. +- `noSave` — hint for persistence hooks not to write the change back. + +**Returns** nothing; the restart runs behind the `internal/update` waterfall. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts#L592) + +## Effect + +Effect body result accepted by `ctx.effect()` and plugin startup. +Either a single disposer, a promise of one, or a (possibly async) iterable yielding several — generator effects register each yielded disposer as it is produced. + +```ts website-api +type Effect = + | SyncEffect + | AsyncEffect +``` + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts#L82) + +## Disposable + +Function returned by an effect to release resources during disposal. +Disposers run in reverse registration order when the owning fiber unloads; they may be async, in which case unloading awaits them. + +```ts website-api +type Disposable = () => T +``` + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts#L73) + +## EffectMeta + +Tree node used to expose nested effect labels for diagnostics. + +```ts website-api +interface EffectMeta { + /** Human-readable effect label, e.g. `ctx.on("event")` or `ctx.provide("name")`. */ + label: string + /** Metadata of nested effects registered while this effect ran. */ + children: EffectMeta[] } ``` + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts#L95) + +## CordisError + +Framework error with a stable machine-readable code. + +```ts website-api +class CordisError extends Error { + /** + * @param code — the stable error code; also the default message. + * @param message — optional human-readable override. + */ + constructor(public code: CordisError.Code, message?: string) +} + +namespace CordisError { + export type Code = keyof typeof Code + + export const Code = { + INACTIVE_EFFECT: 'cannot create effect on inactive context', + } as const +} +``` + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts#L127) + +## ValidationError + +Error raised when plugin configuration fails standard-schema validation. + +```ts website-api +class ValidationError extends TypeError { + name = 'ValidationError' + + /** + * Build the aggregated message from schema issues. + * + * @param issues — the standard-schema issues, one message line each. + */ + constructor(issues: readonly StandardSchemaV1.Issue[]) +} +``` + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts#L18) diff --git a/website/zh-CN/api/cordis/registry.md b/website/zh-CN/api/cordis/registry.md index e0f66d8ed7..55f6d666e5 100644 --- a/website/zh-CN/api/cordis/registry.md +++ b/website/zh-CN/api/cordis/registry.md @@ -1,87 +1,121 @@ + + # Registry -插件注册表,管理插件的加载和依赖解析。 +Plugin loading and dependency injection. -## 实例方法 +### ctx.inject(deps, callback) -### ctx.plugin(plugin, config?) {#ctx-plugin} - -- **plugin:** `Plugin` 插件(函数、对象或类) -- **config:** `object` 传递给插件的配置(可选) -- **返回值:** `Fiber` - -加载一个子插件,返回其 Fiber。子 Fiber 的生命周期绑定到父上下文。 - -```typescript -// 函数插件 -ctx.plugin(myPlugin, { key: 'value' }) - -// 类插件 -ctx.plugin(MyService) - -// 返回的 Fiber 可以 await 或 dispose -const fiber = ctx.plugin(myPlugin) -await fiber +```ts website-api +inject(deps: Inject, callback: Plugin.Function): Fiber & PromiseLike ``` -### ctx.inject(names, callback) {#ctx-inject} +Run a callback once the requested services are available. +Shorthand for `ctx.plugin({ inject, apply: callback })`: the callback is unloaded and re-run whenever a required service changes. -- **names:** `string[]` 服务名列表 -- **callback:** `(ctx: Context) => void` -- **返回值:** `() => void` +- `deps` — required services, as an array or a name → config map. +- `callback` — plugin body called with `(ctx, config)`. -等待指定服务全部就绪后执行 callback。如果服务消失,callback 的效果会自动撤销;服务恢复后重新执行。 +**Returns** the fiber; awaiting it settles once loading finished. -```typescript -ctx.inject(['tools', 'llm'], (ctx) => { - // tools 和 llm 都就绪了 - ctx.tools.register(/* ... */) -}) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/registry.ts#L175) + +### ctx.plugin(plugin, ...args) + +```ts website-api +plugin

(plugin: P, ...args: Spread>): Fiber & PromiseLike ``` -这是 `export const inject = [...]` 声明的底层 API。大多数情况下直接使用声明式写法即可。 +Load a plugin in the current context. -## 插件形态 +- `plugin` — a function, class, or `{ apply }` object plugin. +- `args` — the plugin config, validated against its `Config` schema. -`ctx.plugin()` 接受三种插件形态: +**Returns** the fiber; awaiting it settles once loading finished (rejecting on config or startup errors). -### 函数插件 +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/registry.ts#L184) -```typescript -function myPlugin(ctx: Context, config?: Config) { - // ... -} -myPlugin.name = 'my-plugin' -myPlugin.inject = ['tools'] -``` +## Plugin -### 对象插件 +Supported plugin entrypoint shapes. -```typescript -const myPlugin = { - name: 'my-plugin', - inject: ['tools'], - apply(ctx: Context, config?: Config) { - // ... - }, -} -``` +```ts website-api +type Plugin = + | Plugin.Function + | Plugin.Constructor + | Plugin.Object -### 类插件(Service) +namespace Plugin { + /** Shared metadata understood by the plugin registry and related tooling. */ + export interface Base { + /** Display name used for fiber diagnostics and logger names. */ + name?: string + /** Standard-schema validator applied to config before the plugin starts. */ + Config?: StandardSchemaV1 + /** Services the plugin requires; it only loads while all are available. */ + inject?: Inject + /** Service name(s) the plugin provides (read by `Service` and by loaders). */ + provide?: string | string[] + /** Service names whose intercept config the plugin declares it consumes. */ + intercept?: Dict + } -```typescript -class MyService extends Service { - static inject = ['tools'] - constructor(ctx: Context) { - super(ctx, 'myService') + export interface Transform { + /** Marks the transform object as a schema/config transform. */ + schema?: true + /** Convert user-facing config to runtime config. */ + Config: (config: S) => T + } + + /** Function plugin called with `(ctx, config)`. */ + export interface Function extends Base { + (ctx: Context, config: T): any + } + + /** Class plugin constructed with `(ctx, config)`. */ + export interface Constructor extends Base { + new (ctx: Context, config: T): any + } + + /** Object plugin with an `apply(ctx, config)` method. */ + export interface Object extends Base { + apply(ctx: Context, config: T): any + } + + /** Mutable registry record shared by all fibers of one plugin callback. */ + export interface Runtime { + /** Display name copied from the first registered plugin shape. */ + name?: string + /** Every live fiber of this plugin (one per `ctx.plugin()` call). */ + fibers: DisposableList + /** The executable entrypoint all fibers share (registry identity key). */ + callback: globalThis.Function + /** Standard-schema validator applied to each fiber's config. */ + Config?: StandardSchemaV1 } } ``` -## 插件元信息 +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/registry.ts#L91) -| 属性 | 类型 | 说明 | -|------|------|------| -| `name` | `string` | 插件名称(日志用) | -| `inject` | `string[] \| { required?: string[], optional?: string[] }` | 依赖声明 | -| `Config` | `Schema \| object` | 配置 schema 或默认值 | +## Inject + +Service dependency declaration accepted by plugins and the `@Inject` decorator. +Array form requests services without intercept config. Object form maps each service name to optional intercept config for the plugin context. + +```ts website-api +type Inject = (keyof M)[] | { [K in keyof M]?: M[K] } + +namespace Inject { + /** + * Convert array/object/class-inherited inject metadata into a plain map. + * + * @param inject — the declaration to normalize; `null`/`undefined` add nothing. + * @param result — the map to fill (service name → intercept config or `null`). + * @returns `result`. + */ + export function resolve(inject: Inject | null | undefined, result: Dict = Object.create(null)) +} +``` + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/registry.ts#L18) diff --git a/website/zh-CN/api/cordis/service.md b/website/zh-CN/api/cordis/service.md index a57a00c461..acd43163d6 100644 --- a/website/zh-CN/api/cordis/service.md +++ b/website/zh-CN/api/cordis/service.md @@ -1,97 +1,92 @@ + + # Service -Service 基类,用于创建对外暴露能力的插件。 +Base class for context services: subclass it and load the subclass as a plugin to register `ctx.`. -## 基本用法 +Base class for services that expose a named API on `ctx`. +Subclasses call `super(ctx, name)` from their constructor. The service is registered immediately and is automatically removed with the owning fiber. -```typescript -import { Service, type Context } from 'cordis' +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/service.ts#L11) -declare module 'cordis' { - interface Context { - myService: MyService - } -} +### service.name -export default class MyService extends Service { - constructor(ctx: Context) { - super(ctx, 'myService') - } - - // 公开方法 - doSomething() { - // ... - } -} +```ts website-api +public name!: string ``` -加载后,其他插件可通过 `ctx.myService` 访问。 +The service name this instance is registered under. -## 构造函数 +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/service.ts#L30) -### new Service(ctx, name) +## Static members -- **ctx:** `Context` 上下文 -- **name:** `string` 服务名(注册到 `ctx[name]`) +### Service.init -## 实例属性 - -### service.ctx - -- **类型:** `Context` - -该服务绑定的上下文。 - -### service\[Service.tracker\] - -- **类型:** `object` - -服务追踪信息(名称、绑定状态等)。 - -## 生命周期 - -Service 子类可以覆写以下方法: - -### start() - -服务激活时调用。在这里初始化资源。 - -### stop() - -服务停用时调用。在这里释放资源。 - -## 静态属性 - -### Service.inject - -- **类型:** `string[] | { required?: string[], optional?: string[] }` - -声明本服务依赖的其他服务。 - -## 与 inject 的关系 - -当一个 Service 被加载: -1. 框架为该服务名创建声明 (`ctx.provide`) -2. 实例赋值到 `ctx[name]` -3. 依赖该服务的所有 Fiber 从 PENDING 转为 LOADING - -当 Service 被卸载: -1. `ctx[name]` 被置为 `undefined` -2. 依赖它的 Fiber 被 dispose -3. 当新的 provider 出现时,dependant Fiber 重新加载 - -## 示例:Harness 中的 Service - -```typescript -// dsh-tools 的 ToolRegistry 就是一个 Service -export class ToolRegistry extends Service { - constructor(ctx: Context) { - super(ctx, 'tools') - } - - register(tool: ToolDefinition): () => void { - // ...注册逻辑 - return dispose - } -} +```ts website-api +static readonly init: unique symbol ``` + +Symbol key of an instance method run after construction (class plugins). + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/service.ts#L13) + +### Service.check + +```ts website-api +static readonly check: unique symbol +``` + +Symbol key of the availability predicate passed to `ctx.provide()`. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/service.ts#L15) + +### Service.config + +```ts website-api +static readonly config: unique symbol +``` + +Symbol key of the phantom intercept-config type parameter. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/service.ts#L17) + +### Service.invoke + +```ts website-api +static readonly invoke: unique symbol +``` + +Symbol key of the call body making a service callable (e.g. `ctx.logger()`). + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/service.ts#L19) + +### Service.extend + +```ts website-api +static readonly extend: unique symbol +``` + +Symbol key of the helper deriving an extended service instance. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/service.ts#L21) + +### Service.tracker + +```ts website-api +static readonly tracker: unique symbol +``` + +Symbol key of the tracker metadata used for context tracing. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/service.ts#L23) + +### Service.resolveConfig + +```ts website-api +static readonly resolveConfig: unique symbol +``` + +Symbol key of the intercept-config resolution helper below. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/service.ts#L25) diff --git a/website/zh-CN/api/harness/agent-loop.md b/website/zh-CN/api/harness/agent-loop.md new file mode 100644 index 0000000000..d664651cd7 --- /dev/null +++ b/website/zh-CN/api/harness/agent-loop.md @@ -0,0 +1,56 @@ + + +# ctx.agentLoop + +`AgentLoop` — provided by `@deepseek-ai/dsh-agent-loop`. + +The agent-loop plugin (`ctx.agentLoop`): creates ReactLoopAgents, runs their loops, and registers them in `ctx.agents`. Also implements the AgentFactory seam, so plugins create/resume agents through `ctx.agents` (the interface) without depending on this concrete package. +The loop itself is deliberately thin — every behavior beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy declared in @deepseek-ai/dsh-agent. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L68) + +### ctx.agentLoop.create(id, options?) + +```ts website-api +create(id: AgentId, options: AgentOptions = {}): ReactLoopAgent +``` + +Config-driven create: an agent on a FRESH, non-colliding session id per run (`${id}-session-`, no cwd). Used for `cordis.yml`-configured agents and as the shared core for the programmatic factory createAgent. +Why a per-run id, not a fixed `${id}-session`: once a durable persistence backend is loaded, a fixed id collides on the second run — the backend refuses to re-create an id whose log already exists on disk (the SessionId is the identity). A fresh id means each run is a new session. +TODO(demo): each run starting a brand-new session is fine for demos but is NOT real conversation continuity. A production config-driven agent needs a deliberate resume-or-create policy (resume the prior session if one exists, else start fresh) or an explicit caller-chosen session id — revisit when the UI/ACP path owns session selection. + +- `id` — the agent id; also seeds the generated session id. +- `options` — loop options (model, limits, …); defaults applied per option. + +**Returns** the running agent, owned by the calling fiber (no handle). + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L142) + +### ctx.agentLoop.createAgent(options) + +```ts website-api +createAgent(options: CreateAgentOptions): AgentHandle +``` + +Programmatic factory create (AgentFactory): an agent on a caller-supplied `sessionId` (NOT `${id}-session`), with optional session metadata (validated `cwd`, lineage) and an optional `seed` event prefix. The ACP bridge uses this so the client-generated session id becomes the live/persisted session id; the in-process FORK subagent backend passes a `seed` (a balanced completed-turn prefix of the parent's log) so the child starts with the parent's context. Returns an AgentHandle the owner disposes to tear down exactly this agent. + +- `options` — agent id, caller-supplied session id, optional seed/meta, and agent options. + +**Returns** the handle whose dispose tears down exactly this agent. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L166) + +### ctx.agentLoop.resume(options) + +```ts website-api +async resume(options: ResumeAgentOptions): Promise +``` + +Resume an agent on a persisted session (AgentFactory). Loads the session log + metadata via `ctx.sessionPersistence`, reconstructs the live session with the loaded events (so `lastTurnNumber`/`deriveMessages` continue), and starts a fresh agent on it. The live session id is the resumed id, NOT `${agentId}-session`. +Requires `ctx.sessionPersistence`; rejects with a clear error if it is not configured. NOT hard-injected (that would make non-persistent demos pend forever) — callers that need resume (ACP) inject `sessionPersistence`, so by the time this runs the service exists. + +- `options` — the persisted session id to reload, plus agent id/options. + +**Returns** the handle for the agent resumed on the reconstructed session. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L194) diff --git a/website/zh-CN/api/harness/agent.md b/website/zh-CN/api/harness/agent.md deleted file mode 100644 index bf46c7e4e1..0000000000 --- a/website/zh-CN/api/harness/agent.md +++ /dev/null @@ -1,85 +0,0 @@ -# Agent (dsh-agent) - -Agent 实例管理和生命周期。 - -**包名:** `@deepseek-ai/dsh-agent` -**服务名:** `ctx.agents` - -## Agent Service - -### ctx.agents.create(options) - -- **options:** `AgentOptions` -- **返回值:** `Agent` - -创建一个新的 Agent 实例。 - -### ctx.agents.get(id) - -- **id:** `AgentId` -- **返回值:** `Agent | undefined` - -获取指定 ID 的 Agent 实例。 - -## AgentOptions - -```typescript -interface AgentOptions { - /** Agent ID(branded) */ - id?: AgentId - /** 使用的模型名 */ - model: string - /** 系统提示词(支持 {{model}} 变量) */ - persona?: string - /** 关联的 session */ - session?: Session -} -``` - -## Agent 实例 - -### agent.id - -- **类型:** `AgentId` - -Agent 的唯一标识符(branded string)。 - -### agent.model - -- **类型:** `string` - -Agent 使用的模型名。 - -### agent.step(input) - -- **input:** `ContentBlock[]` -- **返回值:** `Promise` - -执行一步:将输入发送给模型,获取响应,执行 tool calls。这是 agent-loop 内部使用的核心方法。 - -## Agent Loop - -Agent 的执行循环由 `dsh-agent-loop` 管理。它: - -1. 组装 system prompt + 历史消息 + 当前输入 -2. 调用 LLM(通过 `ctx.llm`) -3. 解析响应中的 tool calls -4. 执行 tools -5. 将 tool results 追加到 session -6. 如果 finish reason 是 `tool-calls`,回到步骤 2 - -### 扩展点 - -- `agent/pre-step` 事件 — 在每一步 LLM 调用前触发 -- `agent/post-step` 事件 — 在每一步完成后触发 -- `llm/pre-request` waterfall — 可修改发送给模型的消息 - -## AgentId - -Opaque branded string: - -```typescript -import { AgentId } from '@deepseek-ai/dsh-agent' - -const id = AgentId('main') -``` diff --git a/website/zh-CN/api/harness/agents.md b/website/zh-CN/api/harness/agents.md new file mode 100644 index 0000000000..9ce78bcf14 --- /dev/null +++ b/website/zh-CN/api/harness/agents.md @@ -0,0 +1,91 @@ + + +# ctx.agents + +`AgentRegistry` — provided by `@deepseek-ai/dsh-agent`. + +Agent registry (`ctx.agents`): tracks live agents so UI, hook, and orchestrator plugins can find them without depending on the concrete loop package. Agent *creation* is provided by whichever plugin implements the AgentFactory (phase 1: `@deepseek-ai/dsh-agent-loop`), registered via setFactory. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L117) + +### ctx.agents.setFactory(factory) + +```ts website-api +setFactory(factory: AgentFactory): () => void +``` + +Register the agent-creation factory (the loop calls this on construction, effect-scoped). Throws if a factory is already registered. Returns the disposer; on dispose the factory slot is cleared. + +- `factory` — the loop-owned factory {@link create}/{@link resume} delegate to. + +**Returns** the disposer that clears the factory slot. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L132) + +### ctx.agents.create(options) + +```ts website-api +create(options: CreateAgentOptions): AgentHandle +``` + +Create, start, and register a new agent through the registered factory. Distinct from register (which records an already-constructed agent): this constructs the agent and its session. Throws if no factory is registered. Returns an AgentHandle — the owner disposes it to tear down exactly this agent. + +- `options` — agent id, session id/seed/metadata, and agent options. + +**Returns** the handle whose dispose tears down exactly this agent. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L150) + +### ctx.agents.resume(options) + +```ts website-api +async resume(options: ResumeAgentOptions): Promise +``` + +Load a persisted session and resume an agent on it through the registered factory. Rejects if no factory is registered; the factory rejects if session persistence is not configured. Returns an AgentHandle. + +- `options` — the persisted session id plus agent id and options. + +**Returns** the handle for the resumed agent. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L162) + +### ctx.agents.register(agent) + +```ts website-api +register(agent: Agent): () => void +``` + +Register a live agent. Throws if an agent with the same id is already registered. Emits `agent/created` on registration and `agent/disposed` when the calling fiber is disposed. Returns the disposer. + +- `agent` — the already-constructed agent to record in the store. + +**Returns** the disposer that removes the agent and emits `agent/disposed`. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L174) + +### ctx.agents.get(id) + +```ts website-api +get(id: AgentId): Agent | undefined +``` + +Look up a live agent. + +- `id` — the agent id to look up. + +**Returns** the agent, or undefined when no live agent has that id. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L216) + +### ctx.agents.list() + +```ts website-api +list(): Agent[] +``` + +All live agents, in registration order. + +**Returns** a fresh array; mutating it does not affect the registry. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L224) diff --git a/website/zh-CN/api/harness/bash.md b/website/zh-CN/api/harness/bash.md index 8e8d8d3068..a18d5e5a0d 100644 --- a/website/zh-CN/api/harness/bash.md +++ b/website/zh-CN/api/harness/bash.md @@ -1,81 +1,138 @@ -# Bash (dsh-bash) + -Bash 命令执行接口。 +# ctx.bash -**接口包:** `@deepseek-ai/dsh-bash` -**实现:** `@deepseek-ai/dsh-bash-local` -**消费者:** `@deepseek-ai/dsh-tool-bash`(内置于 agent-core) +`BashExecutor` (abstract seam) — provided by `@deepseek-ai/dsh-bash`. -## Bash Service +Abstract bash execution service. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.bash` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior). +Semantics every implementation must honor: +- run REJECTS only for infrastructure failures (unusable workdir, missing shell, pre-aborted signal). Nonzero exits, timeout kills, and abort kills RESOLVE with a descriptive BashRunResult — reporting a failed command is the tool layer's job, not an exception. +- start returns immediately; no timeout applies to background tasks (callers stop them via kill or the spec's AbortSignal). Completion must fire the onTaskDone listeners exactly once per task, and must NOT fire after the service is disposed. +- readOutput is incremental: consecutive reads never re-deliver output. Implementations bound their buffers; reads that lost data flag `lossy` and point at full-stream spill files when available. +- Disposal kills every running task and awaits their exit (no orphan processes survive `fiber.dispose()`). -### ctx.bash.execute(request) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/bash/bash/src/index.ts#L59) -- **request:** `BashRequest` -- **返回值:** `Promise` +### ctx.bash.resolve(request) -执行一个 bash 命令。 - -## BashRequest - -```typescript -interface BashRequest { - /** 要执行的命令 */ - command: string - /** 工作目录 */ - workdir?: string - /** 超时时间 (ms) */ - timeoutMs?: number -} +```ts website-api +abstract resolve(request: BashExecRequest): BashExecSpec ``` -## BashResult +Resolve a caller's BashExecRequest into a fully-specified BashExecSpec, applying this implementation's config defaults and caps (working directory, default/max timeout). Consumers (tool layer) call this, then pass the result to run/start — keeping defaulting in the implementation that owns the config while the seam type stays explicit (no hidden `?? default` inside run/start). -```typescript -interface BashResult { - /** 退出码 */ - exitCode: number - /** stdout 输出 */ - stdout: string - /** stderr 输出 */ - stderr: string - /** 是否超时 */ - timedOut: boolean -} +- `request` — the caller's request; omitted fields get this implementation's defaults, capped fields are clamped. + +**Returns** the fully-specified spec to hand to {@link run}/{@link start}. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/bash/bash/src/index.ts#L84) + +### ctx.bash.run(spec) + +```ts website-api +abstract run(spec: BashExecSpec): Promise ``` -## 配置 (dsh-bash-local) +Run a command in the foreground; resolves when it finishes. -```typescript -interface Config { - /** 命令超时时间,默认 120000 (2 分钟) */ - timeoutMs: number -} +- `spec` — a resolved spec from {@link resolve}, never a raw request. + +**Returns** the outcome; nonzero exits, timeout kills, and abort kills resolve with a descriptive result rather than reject. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/bash/bash/src/index.ts#L92) + +### ctx.bash.start(spec) + +```ts website-api +abstract start(spec: BashExecSpec): BashTask ``` -在 `cordis.yml` 中: +Start a background task and return its handle immediately. -```yaml -- name: '@deepseek-ai/dsh-bash-local' - config: - timeoutMs: 60000 +- `spec` — a resolved spec from {@link resolve}, never a raw request. + +**Returns** the live task handle; completion fires {@link onTaskDone}. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/bash/bash/src/index.ts#L99) + +### ctx.bash.get(id) + +```ts website-api +abstract get(id: BashTaskId): BashTask | undefined ``` -## 模型可用的 Tools +Look up a background task by id. -`dsh-tool-bash` 向模型暴露以下 tools(由 `agent-core` 捆绑): +- `id` — the task id to look up. -| Tool | 说明 | -|------|------| -| `bash` | 执行命令(同步,等待完成) | -| `bash_output` | 获取后台命令的输出 | -| `bash_kill` | 终止后台命令 | +**Returns** the tracked task, or undefined for an id this executor never issued. -## 设计模式 +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/bash/bash/src/index.ts#L106) -Bash 是 Harness 的"能力三件套"典型案例: +### ctx.bash.ownerOf(id) -- `dsh-bash`(接口):定义 `ctx.bash` 和 `BashRequest`/`BashResult` 类型 -- `dsh-bash-local`(实现):通过 `child_process.spawn` 在本地执行 -- `dsh-tool-bash`(消费者):将能力包装为模型可调用的 tool +```ts website-api +abstract ownerOf(id: BashTaskId): OwnerToken | undefined +``` -换一个沙箱执行器只需替换 `dsh-bash-local`,接口和 tool 不变。 +The opaque OWNER token recorded for a background task at start (from the BashExecSpec's `owner`), or `undefined` for an unknown id OR a known-but-ownerless task. The executor stores and returns the token verbatim — it never interprets it; the access POLICY (who may read/kill a task) lives in the consumer (`@deepseek-ai/dsh-tool-bash`), which compares `ownerOf(id)` to the caller's token. Collapsing unknown-id and known-but-unowned into the same `undefined` is fine: the consumer's access gate treats `undefined` as "open", and a genuinely unknown id then fails loudly at the subsequent readOutput/kill ("unknown task"). Storing ownership in the executor (disposed with ITS fiber) — not in the tool plugin — is what makes ownership survive a `tool-bash` HMR reload. + +- `id` — the background task id to look up ownership for. + +**Returns** the token recorded at start, verbatim; undefined for an unknown id or a known-but-ownerless task. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/bash/bash/src/index.ts#L124) + +### ctx.bash.list() + +```ts website-api +abstract list(): BashTask[] +``` + +All tracked background tasks (insertion order). + +**Returns** every task this executor started, running or finished. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/bash/bash/src/index.ts#L130) + +### ctx.bash.readOutput(id) + +```ts website-api +abstract readOutput(id: BashTaskId): BashTaskRead +``` + +Read output produced since the previous read. Throws for unknown ids. + +- `id` — the task to read from. + +**Returns** the incremental read; consecutive reads never re-deliver output. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/bash/bash/src/index.ts#L137) + +### ctx.bash.kill(id) + +```ts website-api +abstract kill(id: BashTaskId): boolean +``` + +Kill a running background task. Returns false when it had already finished (no-op). Throws for unknown ids. + +- `id` — the task to kill. + +**Returns** true when this call killed it, false when it had already finished. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/bash/bash/src/index.ts#L145) + +### ctx.bash.onTaskDone(listener) + +```ts website-api +onTaskDone(listener: BashTaskListener): () => void +``` + +Register a background-task completion listener (disposed with the calling fiber). Listeners never fire after this service is disposed. + +- `listener` — called exactly once per task completion. + +**Returns** the disposer that unregisters the listener. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/bash/bash/src/index.ts#L153) diff --git a/website/zh-CN/api/harness/code-runtime.md b/website/zh-CN/api/harness/code-runtime.md new file mode 100644 index 0000000000..9ce642b1ab --- /dev/null +++ b/website/zh-CN/api/harness/code-runtime.md @@ -0,0 +1,28 @@ + + +# ctx.codeRuntime + +`CodeRuntime` (abstract seam) — provided by `@deepseek-ai/dsh-code-runtime`. + +Abstract code-execution service. Subclass, implement run and the two descriptors, and load the subclass as a plugin — it registers as `ctx.codeRuntime` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior). +Semantics every implementation must honor: +- run resolves with an error FIELD for every program outcome — parse/transform failures, thrown exceptions, budget expiry, abort, substrate death (CodeRunFailure's taxonomy). It REJECTS only for caller misuse of the seam itself (e.g. a run submitted after disposal). +- Binding calls bridge to the caller's CodeBindingFunctions verbatim; arguments and resolutions must be structured-cloneable, and the runtime treats the program as a hostile peer (arbitrary binding names are own properties, malformed traffic is rejected or ignored, never crashes the host). +- Runs are isolated from each other: no state survives from one run to the next through the runtime. +- Disposal reaches quiescence: in-flight runs are terminated AND awaited before the service's own teardown completes (no orphan substrate survives `fiber.dispose()`). + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/code-runtime/code-runtime/src/index.ts#L59) + +### ctx.codeRuntime.run(request) + +```ts website-api +abstract run(request: CodeRunRequest): Promise +``` + +Execute one program against the request's bindings and capture what it emitted. See the class doc for the resolution contract (error is a result field; rejection means seam misuse only). + +- `request` — the program, its bindings, and the abort signal; the request carries everything the runtime acts on, with no hidden defaults. + +**Returns** the run's outcome: completion value (when transferable), the ordered log capture, and the failure (if any). + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/code-runtime/code-runtime/src/index.ts#L90) diff --git a/website/zh-CN/api/harness/compact.md b/website/zh-CN/api/harness/compact.md new file mode 100644 index 0000000000..60555f3511 --- /dev/null +++ b/website/zh-CN/api/harness/compact.md @@ -0,0 +1,55 @@ + + +# ctx.compact + +`CompactService` (abstract seam) — provided by `@deepseek-ai/dsh-compact`. + +Abstract compaction service. Subclass implement the two abstract methods, and load the subclass as a plugin — it registers as `ctx.compact` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior). +Both core methods are abstract: the contract states WHAT compaction does, while the entire strategy — token estimation, retention policy, event sequencing, summarization — is a HOW decision owned by the implementation. +Implementations MUST honor: +- **Surface contract**: a successful compaction shadows the compacted surface nodes with a SINGLE replacement node carrying the summary. Because `SurfaceEventType` is a closed union, that node is a `user/message` with `surfaceOp: { op:'replace', start, end }`; the `compact/*` events are log-only (lock + provenance). +- **Blocking**: no compaction begins while another is in progress for the same session. The recommended mechanism is the log-recorded lock — append `compact/start` before the slow work and `compact/end` after (even on failure) — so the lock is visible to replay and crash recovery. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/compact/compact/src/index.ts#L65) + +### ctx.compact.compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal) + +```ts website-api +abstract compactIfNeeded( agent: CompactAgentContext, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal, ): Promise +``` + +Check token pressure and compact if the conversation is too large. +Estimates the NEXT request's size — the session prefix, the surface-derived history, and the system prompt — and if it exceeds the backend's threshold, compacts an older range via compactRegion, keeping recent context intact. Returns `null` when no compaction is needed. +Scope and guarantees a backend MUST honor: +- **Compaction acts on surface-derived history only**, but the ESTIMATE counts everything the request carries: the loop composes the session prefix before the pre-step seam fires and hands it here, so the gate sees the prefix this instance will actually send (`EpochHeader.messagePrefix` — request-only, never derived history). Non-surface context injected downstream (into the request `messages` by a later listener) is out of this accounting by construction. +- **Head-anchored, best-effort.** Auto-compaction consolidates from the surface HEAD up to a balanced tool-pairing cutoff, so a prior head checkpoint is re-summarized into one fresh checkpoint (the surface holds at most one auto-generated checkpoint, always at the head). It is best-effort over CLOSED steps: when the only compactable content left is an un-splittable open tail step, it declines (`null`) and retries once that step closes. +- **Single-unit overflow is out of scope.** If a single retained unit (one closed step, or a large free node such as a pasted `user/message`) ALONE exceeds the budget, compaction cannot help and the call may go out over-budget. Bounding an individual unit's size is a separate concern — as is a session prefix that alone approaches the window (a configuration error no compactor fixes: compaction cannot shrink the prefix). + +- `agent` — agent context owning the session surface and model options. +- `fullSystemPrompt` — assembled system prompt, counted toward the estimate. +- `sessionPrefix` — the instance's composed session prefix, counted toward the estimate. +- `signal` — cancellation signal. A backend summarizing via `ctx.llm.stream()` MUST forward this into the call's `GenerateOptions.signal` so an abort/dispose tears down the in-flight summarization rather than leaving an orphaned model call running past the cancellation. + +**Returns** the compaction result, or `null` if no compaction was needed. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/compact/compact/src/index.ts#L111) + +### ctx.compact.compactRegion(session, start, end, agent, signal?) + +```ts website-api +abstract compactRegion( session: Session, start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise +``` + +Forcibly compact a range of surface nodes into a single summary node. +`start` and `end` are inclusive seqs of surface nodes to shadow; the backend summarizes their content and appends a replacement surface node. Used by the (future) `/compact` tool and internally by compactIfNeeded. +The region MUST NOT split a step's `assistant/message` tool-calls from their `tool/result`s, leaving the rehydrated transcript with a dangling tool-call or an orphaned tool-result that every provider rejects. A region is safe iff both its edges are balanced cuts on the surface: the cut before `start` and the cut after `end` each have no unanswered tool-call before them. A node that belongs to no step (a pre-step user message, inter-step steering, or an injection context message) is a balanced (free) boundary; an `end` inside an open (unclosed) tail step is invalid — its tool-calls have no results yet. `dsh-session` exports `isToolPairingBalanced` for this check. + +- `session` — the session whose surface is mutated. +- `start` — inclusive seq of the first surface node to compact. +- `end` — inclusive seq of the last surface node to compact. +- `agent` — agent context used by router-aware summarizers. +- `signal` — optional cancellation signal. A backend that summarizes via `ctx.llm.stream()` MUST forward this into the call's `GenerateOptions.signal` so an abort/dispose tears down the in-flight summarization rather than leaving an orphaned model call running past the cancellation. + +**Returns** what the compaction did (the replaced range and its summary node). + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/compact/compact/src/index.ts#L151) diff --git a/website/zh-CN/api/harness/events.md b/website/zh-CN/api/harness/events.md new file mode 100644 index 0000000000..9aa3d3b791 --- /dev/null +++ b/website/zh-CN/api/harness/events.md @@ -0,0 +1,546 @@ + + +# Harness events + +Every event the harness packages declare on the cordis event bus (35 total), grouped by scope. The **mode** is the dispatch semantics (`emit` fire-and-forget, `parallel` awaited, `serial` first-bail, `waterfall` veto-chain — a waterfall listener MUST call `next()` to delegate). + +## agent/* + +### agent/created + +**Mode:** `emit` + +```ts website-api +'agent/created'(agent: Agent): void +``` + +An agent was registered in the AgentRegistry and is ready to receive messages. + +- `agent` — the newly registered agent, already resolvable in the registry. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L265) + +### agent/disposed + +**Mode:** `emit` + +```ts website-api +'agent/disposed'(agent: Agent): void +``` + +An agent was disposed and removed from the registry; its fiber and any in-flight turn have been torn down. + +- `agent` — the agent that was torn down; its handle is now inert. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L272) + +### agent/error + +**Mode:** `emit` + +```ts website-api +'agent/error'(agent: Agent, turn: number, step: number, error: Error): void +``` + +A step or turn errored. The loop reports a failure here (plus the logger) even when the error has no in-turn position for a session `error` event. + +- `agent` — the agent whose turn errored. +- `turn` — the turn in which the failure surfaced. +- `step` — the step at which the failure surfaced. +- `error` — the failure, verbatim. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L476) + +### agent/pre-step + +**Mode:** `serial` + +```ts website-api +'agent/pre-step'(agent: Agent, turn: number, step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal): Promise | void +``` + +Awaited pre-step surface-mutation checkpoint, fired once per step AFTER `turn/start` (and after the prior step closed) but BEFORE this step's `step/start` — so anything a listener appends lands OUTSIDE the step, between `turn/start`/`step/end` and the upcoming `step/start`. `step` is the number of the step about to start. The loop awaits `ctx.serial('agent/pre-step', …)` after assembling the system prompt, then opens the step and derives the request history ONCE from whatever the surface now holds. This is where compaction belongs: it mutates the session surface in place (shadowing an older range with a summary node) with its log-only `compact/*` records cleanly outside any step, and the single subsequent derive reflects the mutation — so there is no double-derive and no listener can see (or be expected to act on) an assembled `messages` array that does not exist yet. +Serial (awaited in registration order), not a waterfall: a listener mutates the surface as a side effect; there is nothing to transform, but the loop must wait for the mutation to complete before opening the step and deriving. Cordis `serial` bails early if a listener returns a bail value; this event is typed and documented as `void`, so listeners must not return a semantic veto value. `fullSystemPrompt` is the assembled prompt a listener needs to measure pressure (the system prompt counts toward the budget), and `sessionPrefix` is the instance's composed agent/session-prefix product for the same reason — every request carries it in front of the derived history, and it is composed BEFORE this seam fires precisely so a pressure gate counts the prefix the request will actually send (never a stale logged one). `signal` cancels any in-flight work a listener starts (e.g. a summarization model call). + +- `agent` — the agent about to open the step. +- `turn` — the already-open turn this step belongs to. +- `step` — the number of the step about to start. +- `fullSystemPrompt` — the assembled prompt, for measuring token pressure. +- `sessionPrefix` — the instance's frozen session prefix, for the same measurement. +- `signal` — aborts in-flight listener work when the turn is torn down. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L357) + +### agent/prompt-submit + +**Mode:** `waterfall` + +```ts website-api +'agent/prompt-submit'(agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise): Promise +``` + +Waterfall: decide what happens to ONE drained queued message before it becomes a `user/message` — allow (optionally rewriting the prompt bytes or attaching `additionalContext`) or block it. Fires inside the already-open turn, per drained message. Maps onto Claude Code's `UserPromptSubmit` hook. Call `next()` to delegate to the default (allow unchanged), or return a PromptDecision without calling `next()` to short-circuit. + +- `agent` — the agent draining its inbox. +- `content` — the drained message's blocks, as queued. +- `source` — the message's resolved source. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L370) + +### agent/queued + +**Mode:** `emit` + +```ts website-api +'agent/queued'(agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void +``` + +A message entered the agent's inbox (queued or steering). `source` is the resolved source (defaults applied), not the caller's raw options. + +- `agent` — the agent whose inbox received the message. +- `content` — the enqueued content blocks, verbatim. +- `info` — the resolved source plus whether it entered as steering. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L290) + +### agent/request + +**Mode:** `waterfall` + +```ts website-api +'agent/request'(agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise): Promise +``` + +Waterfall: shape the step's call configuration — model switching, sampling overrides — by returning a replacement LlmCallConfig (the frozen seed is the config the loop would otherwise use). Config is ALL a listener shapes here: every request is a pure function of the session log (the reconstructability RFC), so model-visible content flows through the log channels — `inject()`, steering, prompt-submit `additionalContext`, prompt sections via `system-prompt/assemble`, or the header-logged session prefix via agent/session-prefix — never through request mutation, and the loop records whatever config the request actually uses as a `request/header*` event before dispatch. The step's messages are already snapshotted when this fires (the `step/start` boundary): an `inject()` from a listener here lands in the log but joins the NEXT request. For surface mutation that must precede the snapshot (compaction), use agent/pre-step. Call `next()` to delegate, or return an LlmCallConfig without it to short-circuit. + +- `agent` — the agent making the model call. +- `turn` — the open turn number. +- `step` — the step whose request this is. +- `config` — the config the loop would use (frozen); return a replacement to switch. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L394) + +### agent/session-prefix + +**Mode:** `waterfall` + +```ts website-api +'agent/session-prefix'(agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise): Promise +``` + +Waterfall: compose the SESSION PREFIX — request-only messages placed in front of the ENTIRE derived history (directly after the provider's system slot) on every request this loop instance sends. Fired ONCE per loop instance, lazily before its first step's agent/pre-step seam — BEFORE the pre-step so a token-pressure gate (compaction) counts the prefix this instance will actually send, never a previous instance's logged one. The composed result is deep-frozen, recorded as `EpochHeader.messagePrefix` on the instance's anchoring `'initial'`/`'resume'` header snapshot, and reused verbatim for every subsequent request — never recomputed mid-session, so the provider prefix cache holds by construction (a process restart or `ctx.agents.resume()` is a new instance: it recomposes, and any drift lands attributably on the `'resume'` snapshot). Composition runs outside the step, before the boundary snapshot: a composing listener's session append joins the CURRENT request's derived history. A composition interrupted by a cancel/dispose landing inside the waterfall is discarded — never cached, logged, or sent — and the next turn recomposes under a live signal, so an abort-aware listener's degraded fallback cannot leak into later requests. +This is the home for session-stable openers the model must always see but that must NOT become durable history — a skills catalog, an AGENTS.md digest, a workspace baseline: `Session.deriveMessages()` never returns the prefix, and the header events are its only durable record, so the request stays reconstructable from the log. Content that CHANGES mid-session belongs in the append-only history channels instead — `agent.inject()`, a `tools/post-execute` decision's `additionalContext`, prompt-submit `additionalContext` — each a durable `context/message` paid once and prefix-cached thereafter. +The seed is a frozen empty list; a contributing listener returns a NEW array — never an in-place push. The canonical contribution is a PREPEND, `[mine, ...await next()]`: the waterfall unwinds innermost-first (the LAST-registered listener's `next()` resolves first), so prepending yields registration order on the wire, and every plugin using it composes deterministically. The append form `[...await next(), mine]` is legal but places a contribution AFTER every later-registered plugin's — reverse registration order when all contributors append. Call `next()` to delegate, or return a list without it to short-circuit. + +- `agent` — the agent whose session prefix is being composed. +- `prefix` — the frozen empty seed; return an extended replacement to contribute. +- `signal` — aborts in-flight listener work (e.g. a discovery scan) when the step is torn down. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L441) + +### agent/session-start + +**Mode:** `emit` + +```ts website-api +'agent/session-start'(agent: Agent, source: SessionStartSource): void +``` + +The agent's session lifecycle began, fired once before its first turn. `source` says why (SessionStartSource: fresh startup, a resumed persisted session, …). A pure NOTIFICATION (emit, not waterfall): it carries no veto — a session-start listener that wants to seed context does so via `agent.inject()` (a `context/message` the first request sees), not by returning a decision. Cannot block the session from starting; that gap is deliberate (a bridge logs/injects, it does not gate startup). + +- `agent` — the agent whose session lifecycle began. +- `source` — why the session started (fresh startup, resume, …). + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L305) + +### agent/status + +**Mode:** `emit` + +```ts website-api +'agent/status'(agent: Agent, status: AgentStatus): void +``` + +Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle off this transition, never off a status you just requested — `send()` does not flip status to `running` before it returns. + +- `agent` — the agent whose status flipped. +- `status` — the status just entered (the transition's destination). + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L281) + +### agent/step-result + +**Mode:** `waterfall` + +```ts website-api +'agent/step-result'(agent: Agent, turn: number, step: number, message: Message, next: () => Promise): Promise +``` + +Waterfall: post-process the assembled assistant Message before tool dispatch (validation, content rewriting, …). + +- `agent` — the agent that received the step's response. +- `turn` — the open turn number. +- `step` — the step that produced the message. +- `message` — the assistant message as assembled from the stream. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L451) + +### agent/turn-continuation + +**Mode:** `waterfall` + +```ts website-api +'agent/turn-continuation'(agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise): Promise +``` + +Waterfall: override the turn-continuation decision via a typed ContinuationDecision. The loop's `defaultDecision` is `continue` when the step had tool calls or steering was injected, else `stop`. Listeners force-continue (`/goal`, `/loop` — optionally attaching a `reason` recorded as next-step steering) or force-stop (budget guards). Call `next()` to delegate to the default, or return a decision to override. + +- `agent` — the agent deciding whether to run another step. +- `turn` — the turn being continued or stopped. +- `defaultDecision` — what the loop would do absent an override. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L464) + +## fs/* + +### fs/edit-intent + +**Mode:** `waterfall` + +```ts website-api +'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined> +``` + +Single-slot decision: produce the optional version guard for the next FileSystem.editText. The tool dispatches this as an unbound waterfall and supplies a default thunk returning `undefined` (unconditional edit of the current content — the bare provider; no `stat`). The `@deepseek-ai/dsh-fs-policy` policy listener returns `{ version: vObserved }`, or throws `FS_NOT_OBSERVED` if the actor is unset or has not observed the target. Does NOT call `next()`: one decision, first-wins (see Events.'fs/write-intent'). + +- `target` — the resolved target about to be edited. +- `actor` — the opaque tool-execution context the decider keys off. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L123) + +### fs/observed + +**Mode:** `emit` + +```ts website-api +'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void +``` + +Record that an actor observed a target at a version, after a successful read/write/edit. Fire-and-forget (plain `emit`). A listener MUST be a synchronous, side-effect-only recorder (`@deepseek-ai/dsh-fs-policy`'s is a `WeakMap.set`): the tool does not guard the emit, so a listener that throws surfaces as the tool's `isError` result, and cordis `emit` does not await listener promises — async or fallible audit/telemetry does not belong here. No listener ⇒ nothing recorded. `actor` is the opaque tool-execution context. + +- `target` — the target that was read/written/edited. +- `version` — the version the actor now holds as its observation. +- `actor` — the observing tool-execution context; undefined records nothing useful. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L138) + +### fs/write-intent + +**Mode:** `waterfall` + +```ts website-api +'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise): Promise +``` + +Single-slot decision: produce the write intent for the next FileSystem.writeText. The tool dispatches this as an unbound waterfall (no `this`) and supplies a default thunk returning `undefined` (unconditional create-or-overwrite — the bare provider). The `@deepseek-ai/dsh-fs-policy` policy listener returns `createIfAbsent` (unobserved actor) or `{ kind: 'replaceIfVersion', version: vObserved }` (observed) and does NOT call `next()` — one decision, not a composable chain. The slot is first-wins: the first non-`next()` decider (registration order, or `prepend`) occupies it; a second decider is a misconfiguration, not layering. `actor` is the opaque tool-execution context, never read here. + +- `target` — the resolved target about to be written. +- `actor` — the opaque tool-execution context the decider keys off. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L109) + +## llm/* + +### llm/stream + +**Mode:** `waterfall` + +```ts website-api +'llm/stream'(this: LlmService, options: GenerateOptions, next: () => AsyncIterable): AsyncIterable +``` + +Waterfall around every streaming model call (retry, replay, routing). Bound to the LlmService; call `next()` to reach the resolved adapter's stream, or yield your own chunks to short-circuit. + +- `options` — the full request. A LOOP-built request arrives deep-frozen (mutation throws): its content is a pure function of the session log (the reconstructability RFC), so listeners read it, never rewrite it. A hand-built one-shot (compaction summarize) is the caller's own object and stays mutable here. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L39) + +## session/* + +### session/created + +**Mode:** `emit` + +```ts website-api +'session/created'(session: Session): void +``` + +A session was created in the store. + +- `session` — the session just entered and announced. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L39) + +### session/event + +**Mode:** `emit` + +```ts website-api +'session/event'(session: Session, event: SessionEvent): void +``` + +An event was appended to a session log (sync, fire-and-forget). This is the per-append feed a UI or invariant plugin tails. + +- `session` — the session whose log grew. +- `event` — the appended event, exactly as recorded. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L47) + +### session/flush + +**Mode:** `parallel` + +```ts website-api +'session/flush'(session: Session): Promise | void +``` + +Awaited durability checkpoint. The agent loop awaits `ctx.parallel('session/flush', session)` at every turn end; persistence plugins (JSONL, SQLite) drain their write-behind buffers here and on fiber dispose. Awaited (parallel), not a waterfall: every listener runs and the loop waits for all of them, but none can veto. + +- `session` — the session whose buffered events must reach durable storage. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L57) + +## subagent/* + +### subagent/end + +**Mode:** `emit` + +```ts website-api +'subagent/end'(info: SubagentRunEndInfo): void +``` + +A subagent run settled — emitted when SubagentRun.result resolves (any stop reason). Paired with Events['subagent/start']. + +- `info` — the run identity plus stop reason and final output. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L98) + +### subagent/provider-added + +**Mode:** `emit` + +```ts website-api +'subagent/provider-added'(provider: SubagentProvider): void +``` + +A provider became resolvable in the SubagentService registry. Consumers that derive state from a named provider (e.g. the model-facing tool wording in `dsh-tool-subagent`) react HERE instead of assuming load order — the cordis Loader starts sibling plugins concurrently, so "listed earlier in cordis.yml" does not mean "registered earlier". + +- `provider` — the provider that just registered, live in the registry. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L72) + +### subagent/provider-removed + +**Mode:** `emit` + +```ts website-api +'subagent/provider-removed'(name: string): void +``` + +A provider left the registry (its plugin's fiber was disposed — an unload or an HMR reload). Consumers holding provider-derived state drop it here; a reload re-fires `subagent/provider-added` with the fresh provider. Delivered with per-listener containment: a throwing subscriber is logged, never starves later subscribers, and never disrupts the provider's teardown. + +- `name` — the registry name that no longer resolves. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L83) + +### subagent/start + +**Mode:** `emit` + +```ts website-api +'subagent/start'(info: SubagentRunInfo): void +``` + +A subagent run started — emitted after the provider is resolved and its capabilities validated, as the child run begins. Paired with Events['subagent/end']. + +- `info` — which provider started which child agent. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L91) + +## system-prompt/* + +### system-prompt/assemble + +**Mode:** `waterfall` + +```ts website-api +'system-prompt/assemble'(this: SystemPrompt, assembly: PromptAssembly, context: AssembleContext, next: () => Promise): Promise +``` + +Waterfall around prompt assembly — mutate or extend the PromptAssembly (sections + tools + variables) before it is rendered. Bound to the SystemPrompt service; call `next()` to delegate. + +- `assembly` — the assembly built from the registered sections, tool providers, and variable providers; listeners may mutate it or return a replacement. +- `context` — the per-assembly {@link AssembleContext} the caller passed to {@link SystemPrompt.assemble} (e.g. which agent the prompt is for), so a listener can filter or extend per agent. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/system-prompt/src/index.ts#L38) + +### system-prompt/change + +**Mode:** `emit` + +```ts website-api +'system-prompt/change'(): void +``` + +A section, tool provider, or variable provider was registered or unregistered (the assembly inputs changed). + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/system-prompt/src/index.ts#L44) + +## tools/* + +### tools/change + +**Mode:** `emit` + +```ts website-api +'tools/change'(): void +``` + +A tool was registered or unregistered (the available tool set changed). + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L132) + +### tools/execute + +**Mode:** `waterfall` + +```ts website-api +'tools/execute'(this: ToolRegistry, exec: ToolExecution, next: () => Promise): Promise +``` + +Around-dispatch waterfall wrapping the registry's core tool dispatch, between the `tools/pre-execute` gate and the `tools/post-execute` seam. A listener receives `(exec, next)`: call `next()` to delegate to dispatch (returning its ToolExecutionResult, optionally wrapped), or return a replacement result without calling `next()` to short-circuit dispatch. The base `next()` IS the dispatch-with-normalization thunk — a thrown tool (or unknown tool) is already normalized to an `isError` result by the time a listener's `await next()` returns, so a wrapper never sees a raw throw from the tool body. This is the seam a timeout/retry/metrics plugin wraps: it can mutate `exec` (e.g. replace `exec.signal` with a per-call deadline) BEFORE `next()` and inspect the result AFTER. (Cordis `next()` ignores any passed arguments and re-invokes downstream with the shared payload, so a wrapper mutates `exec` in place rather than passing a new object to `next()`.) Multiple listeners compose by registration order — an outer one wraps the inner ones plus dispatch. + +- `exec` — the allowed call about to dispatch (name, parsed arguments, caller agent, signal). + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L111) + +### tools/post-execute + +**Mode:** `waterfall` + +```ts website-api +'tools/post-execute'(this: ToolRegistry, exec: ToolExecution, result: ToolExecutionResult, next: () => Promise): Promise +``` + +Waterfall AFTER a tool runs — where hook plugins inspect the result and accept it (optionally REPLACING the model-facing content, and/or attaching `additionalContext` for the next request) or block it with corrective `feedback` (Claude Code's `PostToolUse`). Listeners receive `(exec, result, next)`: call `next()` to delegate to the default (accept unchanged), or return a PostToolDecision to override. Core tool dispatch runs earlier as the base `next()` of the `tools/execute` waterfall, all inside `execute`'s outer try/catch (and the tool body keeps its own inner try/catch, so a thrown tool still reaches `post-execute` as an `isError` result). + +- `exec` — the call that just ran (name, parsed arguments, caller agent). +- `result` — the dispatch outcome a listener may accept, replace, or block. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L127) + +### tools/pre-execute + +**Mode:** `waterfall` + +```ts website-api +'tools/pre-execute'(this: ToolRegistry, exec: ToolExecution, next: () => Promise): Promise +``` + +Waterfall BEFORE a tool runs — the gate where sandbox, permission, and hook plugins allow or deny a call (Claude Code's `PreToolUse`). Listeners receive `(exec, next)`: call `next()` to delegate to the default (allow), or return a PreToolDecision without calling `next()` to short-circuit. A `deny` skips dispatch and yields an `isError` result; the tool body never runs. Input rewrite is deliberately NOT offered here (see PreToolDecision); `ask` degrades to deny until the permission system lands (`FIXME(permissions)`). + +- `exec` — the pending call (name, parsed arguments, caller agent). + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L91) + +## workflow/* + +### workflow/agent-end + +**Mode:** `emit` + +```ts website-api +'workflow/agent-end'(info: WorkflowRunInfo, agent: WorkflowAgentEndInfo): void +``` + +One `agent()` call settled (clean result, child failure, or run cancellation). Paired with Events['workflow/agent-start'] by `agent.seq`, exactly once per started call on every stop path — on an engine termination path (a worker killed past its grace) the end is engine-synthesized with outcome `'cancelled'`. + +- `info` — the run's identity snapshot. +- `agent` — the call identity plus its outcome. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/workflow/workflow/src/index.ts#L96) + +### workflow/agent-start + +**Mode:** `emit` + +```ts website-api +'workflow/agent-start'(info: WorkflowRunInfo, agent: WorkflowAgentInfo): void +``` + +One `agent()` call started a child run. Paired with Events['workflow/agent-end'] by `agent.seq`. + +- `info` — the run's identity snapshot. +- `agent` — the call's sequence number, label, phase, and child id. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/workflow/workflow/src/index.ts#L85) + +### workflow/end + +**Mode:** `emit` + +```ts website-api +'workflow/end'(info: WorkflowRunInfo, result: WorkflowResultInfo): void +``` + +A workflow run settled (any stop reason). Fired when WorkflowRun.result resolves. Paired with Events['workflow/start']. + +- `info` — the run's identity snapshot. +- `result` — the outcome data (stop reason, error, agent count) — deliberately WITHOUT the result value (see {@link WorkflowResultInfo}). + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/workflow/workflow/src/index.ts#L106) + +### workflow/log + +**Mode:** `emit` + +```ts website-api +'workflow/log'(info: WorkflowRunInfo, message: string): void +``` + +The script emitted a narration line (a `log(message)` call). + +- `info` — the run's identity snapshot. +- `message` — the logged message, verbatim. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/workflow/workflow/src/index.ts#L77) + +### workflow/phase + +**Mode:** `emit` + +```ts website-api +'workflow/phase'(info: WorkflowRunInfo, title: string): void +``` + +The script entered a phase (a `phase(title)` call) — progress grouping for observers; no execution semantics. + +- `info` — the run's identity snapshot. +- `title` — the phase title, verbatim. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/workflow/workflow/src/index.ts#L70) + +### workflow/start + +**Mode:** `emit` + +```ts website-api +'workflow/start'(info: WorkflowRunInfo): void +``` + +A workflow run started — the script's meta block validated, the body about to execute. Paired with Events['workflow/end']. + +- `info` — the run's identity snapshot (id + meta). + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/workflow/workflow/src/index.ts#L62) diff --git a/website/zh-CN/api/harness/fs.md b/website/zh-CN/api/harness/fs.md index 4e336ff962..911bda4db2 100644 --- a/website/zh-CN/api/harness/fs.md +++ b/website/zh-CN/api/harness/fs.md @@ -1,78 +1,126 @@ -# Filesystem (dsh-fs) + -文件系统操作接口。 +# ctx.fs -**接口包:** `@deepseek-ai/dsh-fs` -**实现:** `@deepseek-ai/dsh-fs-local` + `@deepseek-ai/dsh-fs-policy` -**消费者:** `@deepseek-ai/dsh-tool-fs` +`FileSystem` (abstract seam) — provided by `@deepseek-ai/dsh-fs`. -## FS Service +Abstract filesystem provider service. Subclass, implement the seven storage primitives, and load the subclass as a plugin — it registers as `ctx.fs` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior). +Semantics every backend must honor: +- resolve returns a stable FsTarget; the same underlying file reached by different input paths must yield the same `targetKey` so stale guards and target lookup agree across paths (e.g. through symlinks). +- stat returns FsInfo metadata (never content) or `undefined` when the target is absent. +- readText/streamText read the whole regular text file (the stream for large files); both own regular-file checks, UTF-8 decoding, binary/NUL rejection, and `FS_NOT_TEXT`. +- listDir returns direct children of a directory in stable name order with resolved child targets and cheap metadata only. It never reads file contents. Missing targets throw `FS_NOT_FOUND`, non-directories throw `FS_NOT_DIRECTORY`, permission failures throw `FS_PERMISSION_DENIED`, and other backend I/O failures throw `FS_IO_ERROR`. +- writeText is atomic temp-file + rename. `expected` is OPTIONAL: omit it for an unconditional create-or-overwrite (the bare-provider default), or supply a FsWriteIntent to guard the write. +- editText verifies `expected.version` BEFORE literal matching (so a stale edit reports `FS_STALE_VERSION`, not `FS_EDIT_NOT_FOUND`/ `FS_AMBIGUOUS_EDIT` against newer content), then applies literal replacement and writes atomically — all inside one mutation critical section. `expected` is OPTIONAL: omit it for an unconditional edit of the current content (a missing target still reports `FS_STALE_VERSION`). -### ctx.fs.read(path, options?) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L172) -- **path:** `string` -- **options:** `{ offset?: number; limit?: number }` -- **返回值:** `Promise` +### ctx.fs.resolve(path, opts?) -读取文件内容。 - -### ctx.fs.write(path, content) - -- **path:** `string` -- **content:** `string` -- **返回值:** `Promise` - -写入文件(覆盖)。 - -### ctx.fs.edit(path, edits) - -- **path:** `string` -- **edits:** `Edit[]` -- **返回值:** `Promise` - -对文件执行精确的字符串替换编辑。 - -### ctx.fs.stat(path) - -- **path:** `string` -- **返回值:** `Promise` - -获取文件/目录信息。 - -## 配置 (dsh-fs-local) - -```typescript -interface Config { - /** 工作目录(相对路径的基准) */ - cwd: string -} +```ts website-api +abstract resolve(path: string, opts?: { cwd?: string }): Promise ``` -## 策略门 (dsh-fs-policy) +Resolve a model/plugin-supplied path into a stable FsTarget. May perform I/O (a remote/sandboxed backend may need a round-trip to map a path to a stable identity), hence async even though the local backend only normalizes + realpaths. +`opts.cwd` is the base directory a RELATIVE `path` resolves against; an absolute `path` ignores it. Omitted ⇒ the backend's own default base (the local backend uses its configured `cwd`). The CALLER supplies this — the seam does not read a session or agent — so a tool can resolve against the caller's per-session workspace (`exec.agent.session.header.cwd`) without the provider depending on `dsh-agent`/`dsh-session`. Mirrors how `dsh-tool-bash` defaults a bash `workdir` to the session cwd. -`dsh-fs-policy` 是一个可选的中间层插件,实现 read-before-write/edit 策略——模型必须先读取文件才能写入或编辑。这防止模型盲目覆盖文件。 +- `path` — the path to resolve; relative paths resolve against `opts.cwd`. +- `opts` — `cwd` overrides the backend's default base for relative paths. -在 `cordis.yml` 中,它位于 `fs-local` 和 `tool-fs` 之间: +**Returns** the stable target; the same file yields the same `targetKey`. -```yaml -- name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.cwd() -- name: '@deepseek-ai/dsh-fs-policy' -- name: '@deepseek-ai/dsh-tool-fs' +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L194) + +### ctx.fs.stat(target, signal?) + +```ts website-api +abstract stat(target: FsTarget, signal?: AbortSignal): Promise ``` -## 模型可用的 Tools +Return target metadata, or `undefined` when the target does not exist. -| Tool | 说明 | -|------|------| -| `read` | 读取文件内容(支持 offset/limit) | -| `write` | 写入文件(需要先 read) | -| `edit` | 精确字符串替换(需要先 read) | +- `target` — the resolved target to stat. +- `signal` — aborts the metadata round-trip. -## 三件套结构 +**Returns** metadata only, never content; undefined for an absent target. -- `dsh-fs`:接口定义 -- `dsh-fs-local`:本地文件系统实现 -- `dsh-fs-policy`:策略门(read-before-write 检查) -- `dsh-tool-fs`:模型 tool 层 +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L202) + +### ctx.fs.readText(target, signal?) + +```ts website-api +abstract readText(target: FsTarget, signal?: AbortSignal): Promise +``` + +Read the whole regular text file as a single decoded string. + +- `target` — the resolved target to read. +- `signal` — aborts the read. + +**Returns** the full decoded UTF-8 content. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L210) + +### ctx.fs.streamText(target, signal?) + +```ts website-api +abstract streamText(target: FsTarget, signal?: AbortSignal): Promise> +``` + +Stream the whole regular text file as decoded text chunks (same text semantics as readText, for large files). The backend owns cross-chunk UTF-8 decoding and binary rejection so the policy layer never touches raw bytes. + +- `target` — the resolved target to read. +- `signal` — aborts the stream, including between chunks. + +**Returns** the chunk iterable, decoded and validated like {@link readText}. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L221) + +### ctx.fs.listDir(target, signal?) + +```ts website-api +abstract listDir(target: FsTarget, signal?: AbortSignal): Promise +``` + +List direct children of a directory in stable name order. Returns resolved child targets plus cheap metadata only; never reads file contents. + +- `target` — the resolved directory target. +- `signal` — aborts the listing. + +**Returns** one entry per direct child, in stable name order. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L230) + +### ctx.fs.writeText(target, content, expected?, signal?) + +```ts website-api +abstract writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise +``` + +Create or fully replace a UTF-8 text file atomically. `expected` is the create-vs-replace decision and stale guard when supplied; OMITTING it is an unconditional create-or-overwrite (the bare provider — no version guard, no read-first requirement). Atomic either way. + +- `target` — the resolved target to write. +- `content` — the full new file content. +- `expected` — the write intent guarding the write; omit for unconditional. +- `signal` — aborts before the atomic rename takes effect. + +**Returns** the outcome, including the version the write produced. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L243) + +### ctx.fs.editText(target, edit, expected?, signal?) + +```ts website-api +abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise +``` + +Apply a literal edit to an existing UTF-8 text file. When `expected` is supplied, verifies `expected.version` as the stale guard BEFORE literal matching; OMITTING it edits the current content unconditionally (no version guard). Either way applies the replacement and writes atomically — one mutation critical section — and a missing target reports `FS_STALE_VERSION`. + +- `target` — the resolved target to edit. +- `edit` — the literal search/replace request. +- `expected` — the version guard; omit for an unconditional edit. +- `signal` — aborts before the atomic rename takes effect. + +**Returns** the outcome, including the version the edit produced. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L257) diff --git a/website/zh-CN/api/harness/llm.md b/website/zh-CN/api/harness/llm.md index 82a4d8e225..73b5a2484f 100644 --- a/website/zh-CN/api/harness/llm.md +++ b/website/zh-CN/api/harness/llm.md @@ -1,124 +1,50 @@ -# LLM (dsh-llm) + -LLM 服务接口和适配器注册。 +# ctx.llm -**包名:** `@deepseek-ai/dsh-llm` -**服务名:** `ctx.llm` +`LlmService` — provided by `@deepseek-ai/dsh-llm`. -## LLM Service +The abstract `llm` service: an adapter registry plus a streaming model-call surface, interceptable via the `llm/stream` waterfall. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L88) ### ctx.llm.registerAdapter(models, adapter) -- **models:** `string[]` 该适配器支持的模型名列表 -- **adapter:** `LlmAdapter` 适配器实例 -- **返回值:** `() => void` disposer - -注册一个 LLM 适配器。当请求中指定的模型名在 `models` 列表中时,路由到该适配器。 - -```typescript -ctx.llm.registerAdapter(['deepseek-v4-flash', 'deepseek-v4-pro'], adapter) +```ts website-api +registerAdapter(models: string[], adapter: LlmAdapter): () => void ``` -## LlmAdapter +Register an adapter for the given model names. Throws `LlmError` with code `DUPLICATE_ADAPTER` if any model already has an adapter (all-or-nothing). Disposed with the fiber. -适配器基类。子类必须实现 `stream()` 方法。 +- `models` — every model name this adapter should serve. +- `adapter` — the adapter that streams calls for those models. -### stream(options) +**Returns** the disposer that unregisters all of them. -- **options:** `GenerateOptions` -- **返回值:** `AsyncIterable` +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L103) -将统一请求格式转换为具体 API 的流式调用。 +### ctx.llm.models() -## GenerateOptions - -```typescript -interface GenerateOptions { - model: string - messages: Message[] - tools?: ToolSpec[] - system?: string - maxTokens?: number - temperature?: number -} +```ts website-api +models(): string[] ``` -| 字段 | 说明 | -|------|------| -| `model` | 请求的模型名 | -| `messages` | 对话历史 | -| `tools` | 当前可用的 tool 列表(JSON Schema 格式) | -| `system` | 系统提示词 | -| `maxTokens` | 最大输出 token | -| `temperature` | 采样温度 | +Model names with a registered adapter. -## StreamChunk +**Returns** the registered names, in registration order. -流式响应的增量 chunk 类型: +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L124) -```typescript -type StreamChunk = - | { type: 'block-start'; index: number; blockType: 'text' | 'tool-call' } - | { type: 'text-delta'; index: number; text: string } - | { type: 'tool-call-delta'; index: number; id: CallId; name: string; argumentsDelta: string } - | { type: 'block-end'; index: number; block: ContentBlock } - | { type: 'usage'; usage: TokenUsage } - | { type: 'finish'; reason: FinishReason } +### ctx.llm.stream(options) + +```ts website-api +stream(options: GenerateOptions): AsyncIterable ``` -### 协议规则 +Stream one model call as raw chunks (token-level deltas). Throws `LlmError` with code `NO_ADAPTER` if no adapter is registered for `options.model`. Dispatches through the `llm/stream` waterfall. -1. 每个内容块以 `block-start` 开始,以 `block-end` 结束 -2. `index` 从 0 递增 -3. `text-delta` 只在 `blockType: 'text'` 的块中 -4. `tool-call-delta` 只在 `blockType: 'tool-call'` 的块中 -5. `usage` 在 `finish` 之前 -6. `finish` 必须是最后一个 chunk +- `options` — the full request; `options.model` selects the adapter. -## CallId +**Returns** the chunk stream, possibly wrapped by `llm/stream` listeners. -Tool call 的 opaque branded ID: - -```typescript -import { CallId } from '@deepseek-ai/dsh-llm' - -const id = CallId('call-abc123') -``` - -## TokenUsage - -```typescript -interface TokenUsage { - inputTokens: number - outputTokens: number -} -``` - -## FinishReason - -```typescript -type FinishReason = - | { kind: 'stop' } - | { kind: 'tool-calls' } - | { kind: 'max-tokens' } -``` - -## Message - -对话消息类型: - -```typescript -interface Message { - role: 'user' | 'assistant' - content: ContentBlock[] -} -``` - -## ContentBlock - -```typescript -type ContentBlock = - | { type: 'text'; text: string } - | { type: 'tool-call'; id: CallId; name: string; arguments: string } - | { type: 'tool-result'; callId: CallId; content: ContentBlock[]; isError?: boolean } -``` +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L141) diff --git a/website/zh-CN/api/harness/session-persistence.md b/website/zh-CN/api/harness/session-persistence.md new file mode 100644 index 0000000000..6a111cc59f --- /dev/null +++ b/website/zh-CN/api/harness/session-persistence.md @@ -0,0 +1,66 @@ + + +# ctx.sessionPersistence + +`SessionPersistence` (abstract seam) — provided by `@deepseek-ai/dsh-session-persistence`. + +Abstract durable session-persistence service. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.sessionPersistence` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior). +Contracts every implementation MUST honor (a DB backend asserts them inside a transaction; a file backend appends at EOF): +- **Append-only; a crashed turn is closed, not truncated.** Committed events — those at or below a flushed `turn/end` — are never rewritten. A crash can leave an unclosed final turn whose events are real (and possibly large); load preserves them and closes the orphaned turn with synthetic boundary events (see load). Only a never-fully-written torn tail fragment is discarded. +- **Contiguous seq.** A persisted log is contiguous: `events[i].seq === i`. load rejects a parse error or a `seq` gap in the COMMITTED region (unloadable); append's first event `seq` MUST equal the backend's stored next-seq (after `load` has balanced any interrupted turn). +- **JSON-serializable data.** `SessionEventMap` is merge-extensible and `event.data` is typed only as `SessionEventMap[K]`, so append REJECTS non-JSON-serializable data with an error naming the offending event type. A backend snapshots (serializes/clones) each event when it buffers, since `session.events` hands out the live mutable object. +- **Durability.** append returns only once the batch is durable (the file backend fsyncs; a DB commits). create MAY defer the physical write until the first append (lazy materialization). + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/session-persistence/session-persistence/src/index.ts#L102) + +### ctx.sessionPersistence.create(meta) + +```ts website-api +abstract create(meta: SessionHeader): Promise +``` + +Register a new session's metadata. A backend MAY defer the physical write until the first append (lazy materialization), in which case a created-but-never-appended session is absent from list — abandoned sessions leave nothing behind. + +- `meta` — the immutable header (id, version, cwd, lineage) to record. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/session-persistence/session-persistence/src/index.ts#L114) + +### ctx.sessionPersistence.append(id, events) + +```ts website-api +abstract append(id: SessionId, events: readonly SessionEvent[]): Promise +``` + +Durably persist a batch of events (called from the write-behind drain at the `session/flush` checkpoint). Honors the append-only and contiguous-seq contracts: the first event's `seq` MUST equal the stored next-seq (after `load` has durably closed any interrupted turn). Rejects non-JSON- serializable `event.data` with an error naming the offending event type. + +- `id` — the session the batch belongs to. +- `events` — the contiguous batch to persist, in seq order. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/session-persistence/session-persistence/src/index.ts#L125) + +### ctx.sessionPersistence.load(id) + +```ts website-api +abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> +``` + +Reload a session: its SessionHeader plus the event log up to the last durable checkpoint. Returns `meta` AND `events` so the live session is reconstructed with its `cwd`/lineage, not just its log. +The loop only flushes at `turn/end`, so a crash can leave a durable log whose final turn never closed: real, fully-written events sit after the last `turn/end`. Those events are PRESERVED — a single turn can be huge in a long-horizon task, so truncating it would destroy real work — and `load` CLOSES the orphaned turn by durably appending the minimal synthetic boundary events: an error `tool/result` for every `tool-call` the crash left unanswered (so the rehydrated history is a valid provider transcript — a dangling assistant tool-call is otherwise rejected), then a `step/end` if a step was open, then a `turn/end` carrying the `{ kind: 'interrupted' }` reason. The returned `events` therefore end on a balanced `turn/end` and are immediately usable as a session seed. Only a never-fully-written TORN tail fragment (a half-written final record) is discarded. Returned events are contiguous (`events[i].seq === i`); a parse error or a `seq` gap in the COMMITTED region (at or before the last real `turn/end`) makes the session unloadable (reject). Rejects an unknown format `version`. See the session-persistence RFC for the crash-recovery contract. + +- `id` — the persisted session to reload. + +**Returns** the header plus the event log, ending on a balanced `turn/end` — immediately usable as a session seed. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/session-persistence/session-persistence/src/index.ts#L152) + +### ctx.sessionPersistence.list() + +```ts website-api +abstract list(): Promise +``` + +Lightweight listing from metadata, without a full-log parse. + +**Returns** one header per materialized session. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/session-persistence/session-persistence/src/index.ts#L158) diff --git a/website/zh-CN/api/harness/session.md b/website/zh-CN/api/harness/session.md deleted file mode 100644 index 5ff5b0d97b..0000000000 --- a/website/zh-CN/api/harness/session.md +++ /dev/null @@ -1,56 +0,0 @@ -# Session (dsh-session) - -会话事件流管理。 - -**包名:** `@deepseek-ai/dsh-session` -**服务名:** `ctx.session` - -## 概述 - -Session 是 Agent 的对话状态容器。所有模型可见的内容都必须经过 session 事件流记录——这是"model-visible = logged"原则的实现。 - -## SessionSurface - -会话的外部接口,用于查询当前状态。 - -### surface.messages - -- **类型:** `Message[]` - -当前会话的完整消息列表(经过 compaction 处理后的视图)。 - -### surface.events - -- **类型:** `SessionEvent[]` - -原始事件流。 - -## SessionEvent - -会话中所有变更以事件形式记录: - -```typescript -type SessionEvent = - | { type: 'user/message'; content: ContentBlock[] } - | { type: 'assistant/message'; content: ContentBlock[] } - | { type: 'tool/call'; name: string; args: unknown; callId: CallId } - | { type: 'tool/result'; callId: CallId; content: ContentBlock[]; isError?: boolean } - | { type: 'compact/start'; range: [number, number] } - | { type: 'compact/end'; summary: string } - | { type: 'todo/write'; items: TodoItem[] } - // ... 更多事件类型 -``` - -## 设计原则 - -### Model-visible = Logged - -任何到达模型请求的内容都必须能从 session log 重建。如果你要引入新的模型可见输入,必须先定义对应的 session event。 - -### 事件是 append-only - -Session 事件流是只追加的。修改历史(如 compaction)通过新事件(compact/start + compact/end)表达,而不是修改旧事件。 - -### 持久化 - -Session 事件流可以通过 `dsh-session-persistence` 持久化到磁盘(JSONL 或 SQLite),实现跨进程恢复。 diff --git a/website/zh-CN/api/harness/sessions.md b/website/zh-CN/api/harness/sessions.md new file mode 100644 index 0000000000..d0df73e846 --- /dev/null +++ b/website/zh-CN/api/harness/sessions.md @@ -0,0 +1,110 @@ + + +# ctx.sessions + +`SessionStore` — provided by `@deepseek-ai/dsh-session`. + +In-memory session store (`ctx.sessions`). +Persistence is intentionally not implemented here — persistence plugins subscribe to `session/event` and flush on `session/flush` / dispose. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L405) + +### ctx.sessions.create(id?, options?) + +```ts website-api +create(id?: SessionId, options?: CreateSessionOptions): Session +``` + +Create a session owned by the calling fiber: disposing that fiber stops event notification and removes the session from the store. `options.seed` populates the session with a copy of those events (replay/fork); `options.meta` attaches creation metadata (validated absolute `cwd`, `parentSession` lineage) as the immutable SessionHeader (the store fills `version`/`id`/`createdAt`). +For an agent whose session must be torn down IN ORDER with its loop (so the loop's final flush is captured before `onAppend` detaches), do NOT use this — fold the session lifecycle into the agent's own effect via prepare + enter + announce (see `dsh-agent-loop`'s `startOwned`). + +- `id` — the session id; omitted, the store mints `session-`. +- `options` — seed events and/or creation metadata for the header. + +**Returns** the live session, already entered and announced. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L433) + +### ctx.sessions.prepare(id?, options?) + +```ts website-api +prepare(id?: SessionId, options?: CreateSessionOptions): Session +``` + +Build a session WITHOUT entering it into the store — validate the id/cwd and construct the Session (with its immutable SessionHeader). Pairs with enter + announce: a caller that owns a composite `ctx.effect` (the agent factory) folds the session lifecycle into that ONE effect so a fiber unload tears the session + agent down as a single ORDERED chain rather than as racing sibling effects — which would detach `onAppend` before the loop's closing `session/flush`, dropping the closing events. + +- `id` — the session id; omitted, the store mints `session-`. +- `options` — seed events and/or creation metadata for the header. + +**Returns** the constructed session, NOT yet in the store. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L461) + +### ctx.sessions.enter(session) + +```ts website-api +enter(session: Session): () => void +``` + +Enter a prepared session into the store: wire `onAppend` → `session/event` and add it to the store. Returns the DETACH disposer (`onAppend = undefined` + store removal). Does NOT emit `session/created` — the caller yields this disposer inside its effect and THEN calls announce, so a throwing `session/created` listener rolls the attach back instead of leaking it. +Re-checks the id for a duplicate: `prepare` and `enter` are public cross-package primitives and a caller may interleave arbitrary work (or another create) between them, so a stale prepared session must NOT overwrite a live store entry of the same id — its detach disposer would later delete the REAL session. The create convenience and the agent factory call the two back-to-back so they never trip this, but the public seam cannot assume that. + +- `session` — a {@link prepare}d session not yet in the store. + +**Returns** the detach disposer (`onAppend = undefined` + store removal). + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L499) + +### ctx.sessions.announce(session) + +```ts website-api +announce(session: Session): void +``` + +Emit `session/created` for an entered session. Separate from enter so the caller can yield the detach disposer first (rollback safety — see enter). + +- `session` — the entered session to announce to listeners. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L513) + +### ctx.sessions.get(id) + +```ts website-api +get(id: SessionId): Session | undefined +``` + +Look up a live session. + +- `id` — the session id to look up. + +**Returns** the session, or undefined when no live session has that id. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L522) + +### ctx.sessions.list() + +```ts website-api +list(): Session[] +``` + +All live sessions, in creation order. + +**Returns** a fresh array; mutating it does not affect the store. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L530) + +### ctx.sessions.fork(source, boundary?, childSessionId?) + +```ts website-api +fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session +``` + +Create a live child session from a turn-enclosed prefix of a live source. `boundary` is an inclusive source event seq; omitted means the source's current last event. A non-empty selected slice must end at `turn/end`. + +- `source` — Live source session object or id. +- `boundary` — Inclusive source event seq to fork through; omitted means the source's current last event, and omitted on an empty source forks an empty child. +- `childSessionId` — Optional child session id; omitted delegates to `SessionStore`'s id policy. + +**Returns** The created live child session. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L547) diff --git a/website/zh-CN/api/harness/subagent.md b/website/zh-CN/api/harness/subagent.md deleted file mode 100644 index 97ad7b5c87..0000000000 --- a/website/zh-CN/api/harness/subagent.md +++ /dev/null @@ -1,85 +0,0 @@ -# Subagent (dsh-subagent) - -子代理委派接口。 - -**接口包:** `@deepseek-ai/dsh-subagent` -**实现:** `@deepseek-ai/dsh-subagent-spawn` / `@deepseek-ai/dsh-subagent-fork` -**消费者:** `@deepseek-ai/dsh-tool-subagent` - -## Subagent Service - -### ctx.subagent.run(request) - -- **request:** `SubagentRequest` -- **返回值:** `Promise` - -委派一个任务给子代理执行。 - -## SubagentRequest - -```typescript -interface SubagentRequest { - /** 使用的 provider 名称 */ - provider: string - /** 委派给子代理的提示 */ - prompt: string - /** 子代理使用的模型(可选,默认继承父) */ - model?: string -} -``` - -## SubagentResult - -```typescript -interface SubagentResult { - /** 子代理的最终回复 */ - response: string -} -``` - -## Provider 模式 - -Subagent 支持多种"后端"(provider),通过配置选择: - -### spawn - -创建一个全新的子代理实例,没有父级的对话历史: - -```yaml -- name: '@deepseek-ai/dsh-subagent-spawn' - config: - providerName: spawn -``` - -### fork - -创建一个携带父级已完成 turn 前缀的子代理,子代理"知道"父级的对话上下文: - -```yaml -- name: '@deepseek-ai/dsh-subagent-fork' - config: - providerName: fork -``` - -## 模型可用的 Tools - -通过 `dsh-tool-subagent` 暴露。可以加载多次,每次绑定不同 provider: - -```yaml -# 暴露为 "subagent" tool,使用 spawn 后端 -- name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: spawn - toolName: subagent - -# 暴露为 "subagent_fork" tool,使用 fork 后端 -- name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: fork - toolName: subagent_fork -``` - -## 使用场景 - -- **spawn** — 独立子任务(如"搜索这个问题"),子代理不需要知道父级上下文 -- **fork** — 需要上下文的子任务(如"基于我们刚才讨论的,去实现这个"),子代理继承父级的对话前缀 diff --git a/website/zh-CN/api/harness/subagents.md b/website/zh-CN/api/harness/subagents.md new file mode 100644 index 0000000000..258d80e082 --- /dev/null +++ b/website/zh-CN/api/harness/subagents.md @@ -0,0 +1,64 @@ + + +# ctx.subagents + +`SubagentService` — provided by `@deepseek-ai/dsh-subagent`. + +The `subagents` service: a registry of named SubagentProviders and a capability-checked start surface. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L144) + +### ctx.subagents.registerProvider(provider) + +```ts website-api +registerProvider(provider: SubagentProvider): () => void +``` + +Register a provider under its `provider.name`. Throws SubagentError (`DUPLICATE_PROVIDER`) if the name is already taken. Effect-scoped: disposed with the calling fiber (HMR-safe). Emits `subagent/provider-added` after the registration and `subagent/provider-removed` on unregistration, so consumers can mirror provider lifecycle instead of assuming load order. + +- `provider` — the provider; its `name` is the registry key. + +**Returns** the disposer that unregisters the provider. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L160) + +### ctx.subagents.getProvider(name) + +```ts website-api +getProvider(name: string): SubagentProvider | undefined +``` + +Look up a registered provider by name (`undefined` if absent). + +- `name` — the provider name as registered. + +**Returns** the provider, or undefined when the name is unknown. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L188) + +### ctx.subagents.list() + +```ts website-api +list(): string[] +``` + +The names of all registered providers (insertion order). + +**Returns** the registered provider names. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L196) + +### ctx.subagents.start(name, request) + +```ts website-api +start(name: string, request: SubagentStartRequest): SubagentRun +``` + +Start a subagent run on the named provider. Resolves the provider (throws `NO_PROVIDER` if absent), validates every requested START-TIME capability against SubagentProvider.capabilities (throws `UNSUPPORTED_CAPABILITY` for the first unmet one — fail loud, before any child is created), then delegates to SubagentProvider.start and emits `subagent/start` / `subagent/end` around the run. + +- `name` — the provider to run on. +- `request` — the child's prompt, capabilities, and options. + +**Returns** the live run (its `result` resolves when the child settles). + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L211) diff --git a/website/zh-CN/api/harness/system-prompt.md b/website/zh-CN/api/harness/system-prompt.md new file mode 100644 index 0000000000..2016285739 --- /dev/null +++ b/website/zh-CN/api/harness/system-prompt.md @@ -0,0 +1,66 @@ + + +# ctx.systemPrompt + +`SystemPrompt` — provided by `@deepseek-ai/dsh-system-prompt`. + +Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections, tool-schema providers, and named prompt variables; the agent loop calls `assemble(context)` once per step. Registers the harness-owned `harness:identity` and `deployment:persona` sections itself (see Config.persona). + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/system-prompt/src/index.ts#L291) + +### ctx.systemPrompt.section(section) + +```ts website-api +section(section: PromptSection): () => void +``` + +Contribute a text section to the system prompt. Order is determined by `section.order` (ascending). Throws if a section with the same name is already registered (a duplicate would silently double prompt text — e.g. a double-loaded tool plugin). The section is removed when the calling fiber is disposed. Emits `system-prompt/change` on register/unregister. + +- `section` — the section to contribute (name, order, text or provider). + +**Returns** the disposer that removes the section. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/system-prompt/src/index.ts#L340) + +### ctx.systemPrompt.tools(provider) + +```ts website-api +tools(provider: () => ToolSchema[]): () => void +``` + +Contribute a tool-schema provider that is evaluated at each assembly call (so it can reflect the live registry state). The provider is removed when the calling fiber is disposed. A provider must not return a schema named TOOL_ORDER_REST; that name is reserved for Config.toolOrder's rest entry and rejects the assembly. Emits `system-prompt/change`. + +- `provider` — evaluated at every {@link assemble} for fresh schemas. + +**Returns** the disposer that removes the provider. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/system-prompt/src/index.ts#L373) + +### ctx.systemPrompt.variable(name, provider) + +```ts website-api +variable(name: string, provider: (context: AssembleContext) => string | undefined): () => void +``` + +Contribute a named prompt variable, referenced from section text as `{{name}}`. The provider is evaluated at each assembly with that assembly's AssembleContext; returning `undefined` means "no value for this assembly" (a section referencing it then fails to render — a deployment must not claim facts it does not have). Throws on a name that does not match `[a-z][a-z0-9_]*` (it could never be referenced) or is already registered. Removed when the calling fiber is disposed; emits `system-prompt/change` on register/unregister. + +- `name` — the reference name (matches `[a-z][a-z0-9_]*`). +- `provider` — evaluated at every {@link assemble} for the value. + +**Returns** the disposer that removes the variable. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/system-prompt/src/index.ts#L403) + +### ctx.systemPrompt.assemble(context?) + +```ts website-api +async assemble(context: AssembleContext = {}): Promise +``` + +Assemble the current prompt for one caller: section texts are resolved against `context` and sorted by order, tools collected from all providers and put in the canonical model-facing order (Config.toolOrder, or lexicographic name order when unconfigured — provider registration order is a plugin-load artifact and never reaches the assembly; a configured order naming a tool no provider contributed rejects the assembly), and every registered variable resolved against `context` into `assembly.variables`. Tool schemas are deep-cloned because adapters and request waterfalls may mutate schema objects. Runs through the `system-prompt/assemble` waterfall, giving listeners the opportunity to mutate or replace the assembly before it reaches the model — like the sections' `order` sort, tool canonicalization happens on the initial assembly, and a listener owns the determinism of whatever it emits. Await the result before reading the assembly values — waterfall listeners may be async. Interpolation happens later, in renderPrompt. + +- `context` — what this assembly is for (defaults to an empty context; see {@link AssembleContext}). + +**Returns** the assembly after the waterfall has run. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/system-prompt/src/index.ts#L447) diff --git a/website/zh-CN/api/harness/tools.md b/website/zh-CN/api/harness/tools.md index d2011ad85e..187f1d5dfc 100644 --- a/website/zh-CN/api/harness/tools.md +++ b/website/zh-CN/api/harness/tools.md @@ -1,122 +1,63 @@ -# Tools (dsh-tools) + -Tool 注册表和 `defineTool` DSL。 +# ctx.tools -**包名:** `@deepseek-ai/dsh-tools` -**服务名:** `ctx.tools` +`ToolRegistry` — provided by `@deepseek-ai/dsh-tools`. -## ToolRegistry +Tool registry (`ctx.tools`): tool plugins register definitions; the agent loop executes calls through the `tools/pre-execute` → `tools/execute` → `tools/post-execute` pipeline. The registry contributes its schemas into the system-prompt assembly — WHICH schemas is governed by its `mode` config (see Config.mode); under a non-native mode it also registers the `run_code` tool and the `tools:sdk` prompt section itself. -### ctx.tools.register(tool) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L345) -- **tool:** `ToolDefinition` -- **返回值:** `() => void` disposer +### ctx.tools.register(definition) -注册一个 tool。返回的 disposer 可手动撤销注册(通常不需要,插件卸载时自动撤销)。 - -## defineTool\(options) - -类型安全的 tool 定义辅助函数。 - -```typescript -import { defineTool } from '@deepseek-ai/dsh-tools' - -const tool = defineTool({ - name: 'read_file', - description: 'Read a file from disk.', - parameters: { - path: { type: 'string', required: true, description: 'Absolute file path' }, - offset: { type: 'number' }, - limit: { type: 'number', description: 'Max lines to read' }, - }, - async execute(args) { - // args: { path: string; offset?: number; limit?: number } - }, -}) +```ts website-api +register(definition: ToolDefinition): () => void ``` -### DefineToolOptions\ +Register a tool. Throws if a tool with the same name is already registered. The tool's schema (minus the `execute` function) is automatically contributed to the system-prompt assembly. Disposed with the calling fiber. Emits `tools/change` on register/unregister. -| 字段 | 类型 | 说明 | -|------|------|------| -| `name` | `string` | Tool 名称(全局唯一) | -| `description` | `string` | 发送给模型的描述 | -| `parameters` | `SchemaSpec` | 参数 schema(见下文) | -| `execute` | `(args: InferArgs, exec: ToolExecution) => Promise` | 执行函数 | -| `presentCall?` | `(args: InferArgs) => ToolCallView \| undefined` | UI 展示(纯函数) | -| `presentResult?` | `(args: InferArgs, result: ToolResult) => ToolResultView \| undefined` | 结果 UI 展示(纯函数) | +- `definition` — the tool's schema plus its execute (and optional presentation) functions. -## SchemaSpec +**Returns** the disposer that unregisters the tool. -参数 schema DSL。每个属性是一个 `SchemaProp`: +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L420) -```typescript -interface SchemaProp { - type: 'string' | 'number' | 'boolean' | 'object' | 'array' - required?: true - description?: string - enum?: string[] - properties?: SchemaSpec // type: 'object' 时 - items?: SchemaProp // type: 'array' 时 -} +### ctx.tools.get(name) + +```ts website-api +get(name: string): ToolDefinition | undefined ``` -### 类型推导 (InferArgs) +Look up a registered tool. -`InferArgs` 自动从 `SchemaSpec` 推导 TypeScript 类型: +- `name` — the tool name as registered. -- `required: true` → 必填字段 -- 无 `required` → 可选字段(`?`) -- `type: 'object'` + `properties` → 递归推导嵌套对象 -- `type: 'array'` + `items` → 推导为数组 +**Returns** the definition, or undefined when no tool has that name. -## ToolDefinition +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L447) -运行时 tool 定义(`defineTool` 的返回值): +### ctx.tools.schemas() -```typescript -interface ToolDefinition { - name: string - description: string - parameters: Record // JSON Schema - execute(args: unknown, exec: ToolExecution): Promise - presentCall?(args: unknown): ToolCallView | undefined - presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined -} +```ts website-api +schemas(): ToolSchema[] ``` -## ToolExecuteReturn +Return all registered tool schemas — exactly the model-facing fields (`name`, `description`, `parameters`), as sent to the model via the system-prompt assembly. Constructed EXPLICITLY rather than by stripping known non-schema members: a `ToolDefinition` also carries `execute` and the optional `presentCall`/`presentResult` UI callbacks, and those (especially the functions) must never leak into a model request. An allowlist can't drift when a new non-schema member is added to the definition; a denylist (rest-destructure) would silently leak it. -```typescript -type ToolExecuteReturn = - | ContentBlock[] // 仅内容 - | { content: ContentBlock[]; meta?: unknown } // 内容 + 元信息 +**Returns** one deep-cloned schema per registered tool, in registration order. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L462) + +### ctx.tools.execute(exec) + +```ts website-api +async execute(exec: ToolExecution): Promise ``` -## ToolArgsError +Execute one tool call through the `tools/pre-execute` → `tools/execute` (around dispatch) → `tools/post-execute` pipeline. `pre-execute` is the gate (allow/deny), `tools/execute` wraps core dispatch (a timeout/retry/metrics seam), and `post-execute` is the inspect/transform seam; core dispatch sits as the base `next()` of the `tools/execute` waterfall. The whole thing is wrapped in one outer try/catch so a throwing listener (in any waterfall) becomes an `isError` result instead of failing the turn; the tool body ALSO keeps its own inner try/catch, so a thrown tool becomes an `isError` result that `tools/execute` and `post-execute` listeners can still inspect. If the tool is not registered, the result is an `isError` carrying a `UNKNOWN_TOOL` structured error. A thrown HarnessError surfaces its `{ name, code }` on the result. -当模型生成的参数不匹配 schema 时抛出: +- `exec` — the call to run (name, parsed arguments, caller agent, signal). -```typescript -class ToolArgsError extends HarnessError { - code: 'INVALID_ARGS' - violations: string[] -} -``` +**Returns** the final result after every waterfall; failures resolve as `isError` results, never rejections. -框架自动捕获并转换为 `isError` 结果返回给模型。 - -## validateArgs(spec, args) - -- **spec:** `SchemaSpec` -- **args:** `unknown` -- **返回值:** `string[]` 违规信息列表(空 = 合法) - -手动校验参数。`defineTool` 内部使用,通常不需要直接调用。 - -## schemaSpecToJsonSchema(spec) - -- **spec:** `SchemaSpec` -- **返回值:** `JsonSchemaObject` - -将 SchemaSpec 转换为标准 JSON Schema。用于发送给模型的 wire format。 +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L487) diff --git a/website/zh-CN/api/harness/user-interaction.md b/website/zh-CN/api/harness/user-interaction.md new file mode 100644 index 0000000000..09db0f6107 --- /dev/null +++ b/website/zh-CN/api/harness/user-interaction.md @@ -0,0 +1,37 @@ + + +# ctx.userInteraction + +`UserInteractionService` — provided by `@deepseek-ai/dsh-user-interaction`. + +`ctx.userInteraction`: one active UI provider plus an `ask()` surface. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/user-interaction/src/index.ts#L82) + +### ctx.userInteraction.registerProvider(provider) + +```ts website-api +registerProvider(provider: UserInteractionProvider): () => void +``` + +Register the UI provider. Only one provider may be active in a context. + +- `provider` — UI-side implementation that collects answers. + +**Returns** Disposer that unregisters this provider. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/user-interaction/src/index.ts#L95) + +### ctx.userInteraction.ask(request) + +```ts website-api +async ask(request: AskUserQuestionRequest): Promise +``` + +Ask the active UI provider and wait for the user's answer. + +- `request` — Questions, owner agent, and abort signal. + +**Returns** The answer chosen or typed by the human. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/user-interaction/src/index.ts#L114) diff --git a/website/zh-CN/api/harness/web.md b/website/zh-CN/api/harness/web.md new file mode 100644 index 0000000000..3f25a06839 --- /dev/null +++ b/website/zh-CN/api/harness/web.md @@ -0,0 +1,74 @@ + + +# ctx.web + +`WebService` — provided by `@deepseek-ai/dsh-web`. + +The web access service. Registered as `ctx.web` (one instance per context). +Selection semantics (resolved at execution time, never order-dependent): +- A configured id that is registered and `status().available` → that provider. +- A configured id not registered → `WEB_PROVIDER_CONFIGURED_MISSING`. +- A configured id registered but unavailable → `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`. +- No id configured, exactly one registered usable provider → that provider. +- No id configured, multiple usable providers → `WEB_PROVIDER_AMBIGUOUS`. +- No id configured, no usable provider → `WEB_PROVIDER_UNAVAILABLE`. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/web/web/src/index.ts#L87) + +### ctx.web.registerSearchProvider(provider) + +```ts website-api +registerSearchProvider(provider: WebSearchProvider): () => void +``` + +Register a search provider. Throws WebError `WEB_DUPLICATE_PROVIDER` if its id is already registered for search. Returns a disposer; disposed with the calling fiber. + +- `provider` — the provider; its `id` is the registry key. + +**Returns** the disposer that unregisters the provider. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/web/web/src/index.ts#L116) + +### ctx.web.registerFetchProvider(provider) + +```ts website-api +registerFetchProvider(provider: WebFetchProvider): () => void +``` + +Register a fetch provider. Throws WebError `WEB_DUPLICATE_PROVIDER` if its id is already registered for fetch. Returns a disposer; disposed with the calling fiber. + +- `provider` — the provider; its `id` is the registry key. + +**Returns** the disposer that unregisters the provider. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/web/web/src/index.ts#L127) + +### ctx.web.search(request, exec?) + +```ts website-api +async search(request: WebSearchRequest, exec?: WebExecContext): Promise +``` + +Run one search through the selected provider. Resolves the provider at call time with the selection rules above; throws WebError when the capability cannot run. The seam enforces `request.maxResults` on the result: if the provider over-returns, `sources[]` is truncated and `truncated` set. + +- `request` — the query plus result-shaping options. +- `exec` — the tool-execution context, forwarded to the provider. + +**Returns** the provider's results, capped to `request.maxResults`. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/web/web/src/index.ts#L153) + +### ctx.web.fetch(request, exec?) + +```ts website-api +async fetch(request: WebFetchRequest, exec?: WebExecContext): Promise +``` + +Retrieve one URL through the selected provider. Resolves the provider at call time with the selection rules above; throws WebError when the capability cannot run. A non-2xx response is a result, not a throw. + +- `request` — the URL plus retrieval options. +- `exec` — the tool-execution context, forwarded to the provider. + +**Returns** the retrieval outcome; non-2xx responses resolve descriptively. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/web/web/src/index.ts#L170) diff --git a/website/zh-CN/api/harness/workflows.md b/website/zh-CN/api/harness/workflows.md new file mode 100644 index 0000000000..80e9b84795 --- /dev/null +++ b/website/zh-CN/api/harness/workflows.md @@ -0,0 +1,28 @@ + + +# ctx.workflows + +`WorkflowService` (abstract seam) — provided by `@deepseek-ai/dsh-workflow`. + +Abstract workflow execution service. Subclass, implement start, and load the subclass as a plugin — it registers as `ctx.workflows` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior). +Semantics every implementation must honor: +- start throws synchronously for a request that cannot begin (an unparseable script, an invalid meta block). Once it returns a WorkflowRun, `result` NEVER rejects — every failure resolves with `stopReason: 'error'` (or `'cancelled'`) — and once the run is cancelled, `result` SETTLES within the implementation's bounded grace even if the script itself never settles (a consumer awaiting `result` must never be wedged past a cancellation). +- The `workflow/*` events fire through emitWorkflowEvent (data snapshots, per-listener containment); `workflow/end` fires exactly once per started run, after `result` is settled or as it settles. +- `dispose()` reaches quiescence within a bounded grace: it cancels, waits for the script to settle AND its started children to finish disposing, and abandons whatever is left rather than hanging its caller (the engine documents what abandonment leaves behind). +- Runs are HOLDER-OWNED: the engine hands control (`cancel`/`dispose`) to the `start()` caller and does not track its live runs — disposing the engine's own fiber mid-run deliberately leaves those runs to their holders' teardown, so an engine reload cannot yank a run out from under the consumer awaiting it. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/workflow/workflow/src/index.ts#L210) + +### ctx.workflows.start(request) + +```ts website-api +abstract start(request: WorkflowStartRequest): WorkflowRun +``` + +Parse and execute a workflow script. + +- `request` — the script, its `args`, the parent agent, and an optional cancel signal. + +**Returns** the live run; its `result` resolves when the script settles. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/workflow/workflow/src/index.ts#L221) diff --git a/website/zh-CN/api/index.md b/website/zh-CN/api/index.md index 371cd1e622..ea43808971 100644 --- a/website/zh-CN/api/index.md +++ b/website/zh-CN/api/index.md @@ -1,25 +1,37 @@ # API 参考 -本节提供 DeepSeek Harness 的完整 API 参考文档,分为两部分: +本节是 DeepSeek Harness 的 API 参考。除本页外,`cordis/` 与 `harness/` 下的所有页面**由脚本从源码生成**(`pnpm run gen-website-api`,CI 校验新鲜度),签名与说明永远与代码一致;生成页目前为英文,中文版将随统一翻译流程提供。 ## 框架 API Cordis 微内核提供的基础能力,所有插件开发都建立在这些 API 之上: - [Context](./cordis/context) — 上下文对象,所有服务和方法的入口 -- [Events](./cordis/events) — 事件系统 API(emit / on / bail / serial / waterfall) -- [Fiber](./cordis/fiber) — 作用域生命周期(状态机、effect、dispose) +- [Events](./cordis/events) — 事件系统 API(on / emit / bail / serial / waterfall) +- [Fiber](./cordis/fiber) — 插件生命周期(状态机、effect、dispose) - [Registry](./cordis/registry) — 插件注册(plugin / inject) - [Service](./cordis/service) — 服务基类 ## Harness API -DeepSeek Harness SDK 提供的扩展 API,用于构建 Agent 能力: +每个 `ctx.*` 服务一页,按服务名索引: -- [Tools (dsh-tools)](./harness/tools) — Tool 注册、defineTool DSL、Schema 类型系统 -- [LLM (dsh-llm)](./harness/llm) — LLM 服务、适配器注册、StreamChunk 协议 -- [Session (dsh-session)](./harness/session) — 会话事件流、消息类型 -- [Agent (dsh-agent)](./harness/agent) — Agent 实例管理、生命周期 -- [Bash (dsh-bash)](./harness/bash) — Bash 执行接口 -- [Filesystem (dsh-fs)](./harness/fs) — 文件系统接口 -- [Subagent (dsh-subagent)](./harness/subagent) — 子代理委派接口 +- [ctx.agentLoop](./harness/agent-loop) — ReAct 循环的创建与恢复 +- [ctx.agents](./harness/agents) — Agent 注册表与工厂 +- [ctx.bash](./harness/bash) — Bash 执行接口(抽象缝) +- [ctx.codeRuntime](./harness/code-runtime) — 代码执行接口(抽象缝) +- [ctx.compact](./harness/compact) — 上下文压缩接口(抽象缝) +- [ctx.fs](./harness/fs) — 文件系统接口(抽象缝) +- [ctx.llm](./harness/llm) — LLM 服务与适配器注册 +- [ctx.sessionPersistence](./harness/session-persistence) — 会话持久化接口(抽象缝) +- [ctx.sessions](./harness/sessions) — 会话存储 +- [ctx.subagents](./harness/subagents) — 子代理委派 +- [ctx.systemPrompt](./harness/system-prompt) — 系统提示词组装 +- [ctx.tools](./harness/tools) — Tool 注册表 +- [ctx.userInteraction](./harness/user-interaction) — 用户交互接口 +- [ctx.web](./harness/web) — Web 搜索与抓取 +- [ctx.workflows](./harness/workflows) — 动态工作流引擎(抽象缝) + +事件总表:[Harness events](./harness/events) — 全部事件按作用域分组,含触发模式与载荷签名。 + +想学"怎么写一个 tool / 插件"?教程在[开发指南](../develop/basic/);本节只做精确的接口参考。 From 20bcd66dbf147eecff5540ee1da9792e3748db79 Mon Sep 17 00:00:00 2001 From: lintianle Date: Thu, 16 Jul 2026 18:43:12 +0800 Subject: [PATCH 08/14] website: include h3 member headings in the page outline The generated API pages put each member at h3 under an h2 group; default outline depth (h2 only) hid them, leaving e.g. the Context page outline with a single 'Static members' entry. --- website/.vitepress/config/zh-CN.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/website/.vitepress/config/zh-CN.ts b/website/.vitepress/config/zh-CN.ts index ba83cf52c5..0cea119777 100644 --- a/website/.vitepress/config/zh-CN.ts +++ b/website/.vitepress/config/zh-CN.ts @@ -85,7 +85,9 @@ export const zhCN: LocaleSpecificConfig = { '/zh-CN/api/': apiSidebar, '/zh-CN/design/': designSidebar, }, - outline: { label: '本页目录' }, + // level [2,3]: the generated API pages put each member at h3 (### ctx.foo) + // under an h2 scope/statics group — both belong in the page outline. + outline: { label: '本页目录', level: [2, 3] }, docFooter: { prev: '上一篇', next: '下一篇' }, }, } From 4c496774695841f3cb61709086ec060734fd0292 Mon Sep 17 00:00:00 2001 From: lintianle Date: Thu, 16 Jul 2026 18:57:44 +0800 Subject: [PATCH 09/14] website: render the design essays' TeX (math: true, mathjax3 pinned to v4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit design/revertible-effects and design/context-model carry real TeX that was showing as literal $$ source. markdown: { math: true } enables markdown-it-mathjax3; pinned ^4.3.2 deliberately — v5 injects a