From ea56ddba36559fca2a7b486f8f8d25e7e6ecd5a0 Mon Sep 17 00:00:00 2001 From: kongche-jbw Date: Sat, 15 Aug 2026 10:24:00 +0800 Subject: [PATCH] feat(tokenless): add native dsh plugin - compress JSON tool results through the native post-execute seam - preserve unsafe or unsupported outputs with fail-open behavior - ship the DSH bundle through raw, RPM, and npm packages Signed-off-by: kongche-jbw --- .../en/token-saving/tokenless/QUICKSTART.md | 14 + .../tokenless/framework-integration.md | 98 +++- .../zh/token-saving/tokenless/QUICKSTART.md | 17 +- .../tokenless/framework-integration.md | 86 +++- src/tokenless/.anolisa/component.toml.in | 14 + src/tokenless/.gitignore | 1 + src/tokenless/Makefile | 39 +- src/tokenless/README.md | 39 +- src/tokenless/README_zh.md | 30 ++ .../adapters/tokenless/dsh/cordis.patch.yml | 8 + .../adapters/tokenless/dsh/dist/index.js | 399 ++++++++++++++++ .../adapters/tokenless/dsh/package.json.in | 26 + .../adapters/tokenless/manifest.json.in | 14 + src/tokenless/npm/scripts/package-npm.js | 16 +- src/tokenless/packaging/raw/package.sh | 3 + src/tokenless/packaging/raw/verify-release.py | 1 + src/tokenless/tests/test-dsh-adapter.sh | 447 ++++++++++++++++++ .../tests/test-package-npm-prebuilt.sh | 12 + src/tokenless/tests/test-package-raw.sh | 8 + src/tokenless/tokenless.spec.in | 20 +- 20 files changed, 1276 insertions(+), 16 deletions(-) create mode 100644 src/tokenless/adapters/tokenless/dsh/cordis.patch.yml create mode 100644 src/tokenless/adapters/tokenless/dsh/dist/index.js create mode 100644 src/tokenless/adapters/tokenless/dsh/package.json.in create mode 100644 src/tokenless/tests/test-dsh-adapter.sh diff --git a/docs/user-guide/en/token-saving/tokenless/QUICKSTART.md b/docs/user-guide/en/token-saving/tokenless/QUICKSTART.md index f5dd690a50..ce0e022da0 100644 --- a/docs/user-guide/en/token-saving/tokenless/QUICKSTART.md +++ b/docs/user-guide/en/token-saving/tokenless/QUICKSTART.md @@ -120,12 +120,26 @@ anolisa adapter scan | Qoder | `anolisa adapter enable tokenless qoder` | | Claude Code | `anolisa adapter enable tokenless claude-code` | | Codex | `anolisa adapter enable tokenless codex` | +| DeepSeek Harness (dsh) | `anolisa adapter enable tokenless dsh --profile ` | | OpenCode | Lifecycle script (see below) | | Qwen Code | `anolisa adapter enable tokenless qwencode` | Restart the Agent CLI or IDE after setting it up. OpenClaw also requires `openclaw gateway restart`; if its security check rejects the plugin, follow the [OpenClaw integration instructions](framework-integration.md#2-enable-one-adapter). +For DeepSeek Harness, `` is required and must match the name used by +`dsh --profile `; restart that profile after enabling the bundle. +To enable more than one profile, repeat `--profile` in the same command: + +```bash +anolisa adapter enable tokenless dsh \ + --profile web \ + --profile headless +``` + +Every later enable or re-enable replaces the entire recorded profile set. +Include every profile that should retain Tokenless each time. + OpenCode is not registered with `anolisa adapter enable` in this release; use the bundled lifecycle script described in the [OpenCode integration instructions](framework-integration.md#opencode). diff --git a/docs/user-guide/en/token-saving/tokenless/framework-integration.md b/docs/user-guide/en/token-saving/tokenless/framework-integration.md index 36089b06eb..811e18718d 100644 --- a/docs/user-guide/en/token-saving/tokenless/framework-integration.md +++ b/docs/user-guide/en/token-saving/tokenless/framework-integration.md @@ -16,6 +16,7 @@ Python framework package that application developers install and register explic | Qoder | `qoder` | Hard-disabled | Emits rewritten shell input | Emits `additionalContext` | Attempted after response compression | — | | Claude Code | `claude-code` | Hard-disabled | Replaces Bash input | Replaces output on 2.1.121 or later; otherwise passes through | Used only when the replacement can remain text | — | | Codex | `codex` | Hard-disabled | Replaces supported shell input | Keeps the original and adds analysis or a compressed alternative | Used to build that alternative | — | +| DeepSeek Harness | `dsh` | — | — | Replaces an accepted single-text JSON result when the replacement is smaller | — | — | | OpenCode | `opencode` | Hard-disabled | Replaces Bash input | Replaces tool output | Attempted after response compression | ✅ | | Qwen Code | `qwencode` | Hard-disabled | Emits rewritten shell input | Emits `additionalContext` | Attempted after response compression | ✅ | @@ -42,6 +43,83 @@ The shared response hook, OpenClaw, and Hermes skip inputs shorter than 200 char Claude Code requires version 2.1.121 or later for `updatedToolOutput`. On older or unknown versions, response compression is disabled to avoid duplicating the original. Structured tool outputs preserve their host schema and do not switch to textual TOON; JSON carried as a string can use TOON when it is smaller. +### DeepSeek Harness native processing + +The DSH bundle requires Node.js 22 or later and a compatible DSH profile. Pass +all desired profile names in the same enable command, then start DSH with one +of those names: + +```bash +anolisa adapter enable tokenless dsh \ + --profile web \ + --profile headless +dsh --profile web +``` + +`--profile` is required and repeatable. Each enable or re-enable treats its +arguments as the complete desired profile set. It removes the bundle from any +profile recorded by the prior receipt but omitted from the new command, so +always include every profile that should retain Tokenless. ANOLISA records the +selected profiles and their resolved DSH home in the adapter receipt, so later +status, disable, and re-enable operations continue to address the same profile +tree. + +The plugin runs on DSH's `tools/post-execute` waterfall. It attempts +`tokenless compress-response` only for a successful result containing one text +block whose text is a JSON object or array. It replaces the content only when +the CLI returns valid JSON that is strictly shorter. Multiple blocks, images, +plain text, invalid JSON, errored results, Code Mode child executions, and the +default content-retrieval tools are not compressed. A missing, failing, or +timed-out CLI also preserves the original content. This native path does not +run the TOON second stage and has no pre-spawn minimum-size gate. + +Add an override for the installed row to +`$DSH_HOME/profiles//cordis.patch.yml`, then restart that DSH profile: + +```yaml +- id: anolisa-tokenless + config: + responseCompressionEnabled: true + timeoutMs: 5000 + maxBuffer: 4194304 + noStash: false +``` + +Later DSH patch layers replace the row's complete `config` value. The plugin +supplies defaults for omitted keys, so the override may contain only the keys +that need to differ. + +| Option | Default | Behavior | +|--------|---------|----------| +| `responseCompressionEnabled` | `true` | Enables response compression. Setting it to `false` does not disable environment-error attribution. | +| `tokenlessBin` | `$TOKENLESS_BIN`, then `tokenless` | Selects the Tokenless CLI executable. A non-empty plugin value takes precedence over the environment variable. | +| `skipTools` | Content-retrieval set below | Skips compression for matching tool names. A configured array replaces the default set; an empty array skips none. Attribution remains active. | +| `shellTools` | Shell/process set below | Selects shell thresholds and the tools whose structured `value` may be interpreted for failure attribution. A configured array replaces the default set. | +| `truncateStringsAt` | Shell `65536`; other `1048576` | Overrides the maximum retained string length for every tool class. Only a positive integer is accepted. | +| `truncateArraysAt` | Shell `128`; other `65536` | Overrides the maximum retained array length for every tool class. Only a positive integer is accepted. | +| `maxDepth` | Shell `8`; other `32` | Overrides maximum JSON depth for every tool class. Only a positive integer is accepted. | +| `timeoutMs` | `3000` | Bounds one Tokenless child process in milliseconds. Only a positive integer is accepted. | +| `maxBuffer` | `2097152` | Bounds captured child-process output in bytes. Only a positive integer is accepted. | +| `agentId` | `dsh` | Sets the `--agent-id` recorded by Tokenless statistics. | +| `noStash` | `false` | Passes `--no-stash` when `true`; dropped array items are otherwise eligible for Stash storage. | + +The default `skipTools` set is `Read`, `read`, `read_file`, `read_many_files`, +`Glob`, `glob`, `search_file`, `list_directory`, `list_dir`, `Grep`, `grep`, +`grep_code`, `grep_search`, `search_files`, `Lsp`, `lsp`, `NotebookRead`, +`notebook_read`, and `notebookread`. + +The default `shellTools` set is `Bash`, `bash`, `Shell`, `shell`, `exec`, +`terminal`, `run_shell_command`, `run_in_terminal`, `get_terminal_output`, +`execute_command`, and `process`. + +Raw DSH failures marked with `isError` may receive dependency, permission, +path, network, or package attribution for any tool. Structured output is +classified only for `shellTools`. Attribution is independent of compression, +so it remains active when compression is disabled, skipped, or produces no +smaller result. When a later waterfall listener replaces the canonical +`value`, Tokenless classifies that replacement and does not carry attribution +from the superseded result. + ## Manage adapters with anolisa (recommended) These commands require an ANOLISA component record. If Tokenless was installed @@ -83,9 +161,19 @@ anolisa adapter enable tokenless qoder anolisa adapter enable tokenless claude-code anolisa adapter enable tokenless codex anolisa adapter enable tokenless qwencode +anolisa adapter enable tokenless dsh \ + --profile web \ + --profile headless ``` -Enable only Agent products that you use. When enabling more than one, run and verify each command separately. +Enable only Agent products that you use. Run and verify each product's command +separately. For DSH, include every desired profile in its single enable +command. + +DeepSeek Harness is profile-scoped and therefore requires at least one +`--profile`. Each name must match one passed to `dsh --profile `; the +generic command without a profile is rejected. A later enable or re-enable +must repeat every profile that should remain registered. OpenCode uses its bundled install script under [Manual integration after npm installation](#manual-integration-after-npm-installation). @@ -196,6 +284,14 @@ The marketplace plugin takes effect after restarting Claude Code. The install sc The plugin loads in a new Codex session. Close the old session and start a new one before verifying statistics. Its PostToolUse hook is additive: use statistics as candidate-compression telemetry, not as proof that the original Codex tool output left the prompt. +### DeepSeek Harness + +The native bundle loads when the selected DSH profile starts. After enabling +or changing its profile patch, restart `dsh --profile `, run a tool +that returns compressible JSON, and inspect `tokenless stats list`. Disable the +adapter with `anolisa adapter disable tokenless dsh`; the receipt already +records the profile names, so disable does not accept another `--profile`. + ### OpenCode OpenCode discovers global local plugins at startup. Use the bundled Tokenless lifecycle script described above, restart OpenCode after installation or removal, then run a tool call and inspect `tokenless stats list`. The script resolves the configuration directory from `TOKENLESS_OPENCODE_CONFIG_DIR`, then `OPENCODE_CONFIG_DIR`, then `XDG_CONFIG_HOME/opencode`, and finally `~/.config/opencode`. Installation creates only `plugins/tokenless.js` as a managed symlink and refuses to replace an unrelated file at that path. diff --git a/docs/user-guide/zh/token-saving/tokenless/QUICKSTART.md b/docs/user-guide/zh/token-saving/tokenless/QUICKSTART.md index 1c1dfe1edf..4fef50be22 100644 --- a/docs/user-guide/zh/token-saving/tokenless/QUICKSTART.md +++ b/docs/user-guide/zh/token-saving/tokenless/QUICKSTART.md @@ -116,12 +116,27 @@ anolisa adapter scan | Qoder | `anolisa adapter enable tokenless qoder` | | Claude Code | `anolisa adapter enable tokenless claude-code` | | Codex | `anolisa adapter enable tokenless codex` | +| DeepSeek Harness(dsh) | `anolisa adapter enable tokenless dsh --profile ` | | OpenCode | 生命周期脚本(见下文) | | Qwen Code | `anolisa adapter enable tokenless qwencode` | 接入后重启对应的 Agent CLI 或 IDE。OpenClaw 还需要运行 `openclaw gateway restart`;如果安全检查拒绝 Plugin,请按照 -[OpenClaw 接入说明](framework-integration.md#2-启用一个-adapter)处理。本版本尚未将 +[OpenClaw 接入说明](framework-integration.md#2-启用一个-adapter)处理。 +DeepSeek Harness 必须提供 ``,并与 `dsh --profile ` 使用的名称 +保持一致。启用 Bundle 后应重启这个 profile。需要启用多个 profile 时,应在同一条 +命令中重复传入 `--profile`。 + +```bash +anolisa adapter enable tokenless dsh \ + --profile web \ + --profile headless +``` + +后续每次 enable 或 re-enable 都会替换 receipt 记录的完整 profile 集合。每次都要 +列出需要继续使用 Tokenless 的全部 profile。 + +本版本尚未将 OpenCode 注册到 `anolisa adapter enable`;请使用 [OpenCode 接入说明](framework-integration.md#opencode)中的随附生命周期脚本。 diff --git a/docs/user-guide/zh/token-saving/tokenless/framework-integration.md b/docs/user-guide/zh/token-saving/tokenless/framework-integration.md index 4f739d49fb..eeb92ffdfa 100644 --- a/docs/user-guide/zh/token-saving/tokenless/framework-integration.md +++ b/docs/user-guide/zh/token-saving/tokenless/framework-integration.md @@ -16,6 +16,7 @@ Python 框架包。 | Qoder | `qoder` | 已硬关闭 | 输出改写后的 Shell 输入 | 输出 `additionalContext` | 在响应压缩后尝试 | — | | Claude Code | `claude-code` | 已硬关闭 | 替换 Bash 输入 | 2.1.121 及以上替换输出;否则透传 | 仅在替换结果可保持文本时使用 | — | | Codex | `codex` | 已硬关闭 | 替换受支持的 Shell 输入 | 保留原文,追加分析或压缩备选内容 | 用于生成该备选内容 | — | +| DeepSeek Harness | `dsh` | 未注册 | 未注册 | 只在结果更小时替换已接受的单文本块 JSON 结果 | 未注册 | 未注册 | | OpenCode | `opencode` | 已硬关闭 | 替换 Bash 输入 | 替换工具输出 | 在响应压缩后尝试 | ✅ | | Qwen Code | `qwencode` | 已硬关闭 | 输出改写后的 Shell 输入 | 输出 `additionalContext` | 在响应压缩后尝试 | ✅ | @@ -42,6 +43,74 @@ OpenCode 当前使用下文说明的随附生命周期脚本,本版本尚未 Claude Code 需要 2.1.121 或更高版本才能使用 `updatedToolOutput`。版本更旧或无法确定时,响应压缩会关闭,以免重复注入原文。结构化工具输出会保留宿主 Schema,不会转换成文本 TOON;以字符串承载的 JSON 在 TOON 更小时可以使用 TOON。 +### DeepSeek Harness 原生处理路径 + +DSH Bundle 要求 Node.js 22 或更高版本,并需要兼容的 DSH profile。应在同一条 +enable 命令中列出全部目标 profile,随后使用其中一个名称启动 DSH。 + +```bash +anolisa adapter enable tokenless dsh \ + --profile web \ + --profile headless +dsh --profile web +``` + +`--profile` 是必填且可重复的参数。每次 enable 或 re-enable 都会把本次参数视为 +完整目标集合。旧 receipt 中已有但新命令没有列出的 profile 会卸载 Bundle,因此 +每次都要列出需要继续使用 Tokenless 的全部 profile。ANOLISA 会把选择的 profile +和解析后的 DSH home 写入 adapter receipt。后续 status、disable 和 re-enable 会 +继续操作同一棵 profile 目录树。 + +Plugin 在 DSH 的 `tools/post-execute` waterfall 上运行。只有成功结果包含一个文本块, +且文本是 JSON object 或 array 时,才会尝试执行 `tokenless compress-response`。 +CLI 返回更短的合法 JSON 后才会替换内容。多文本块、图片、普通文本、非法 JSON、 +错误结果、Code Mode 子调用和默认内容读取类工具不参与压缩。CLI 缺失、失败或 +超时也会保留原始内容。当前原生路径不执行 TOON 第二阶段,也没有启动子进程前的 +最小尺寸门控。 + +在 `$DSH_HOME/profiles//cordis.patch.yml` 中覆盖安装后的 row,然后重启 +对应的 DSH profile。 + +```yaml +- id: anolisa-tokenless + config: + responseCompressionEnabled: true + timeoutMs: 5000 + maxBuffer: 4194304 + noStash: false +``` + +后续 DSH patch layer 会替换该 row 的完整 `config` 值。Plugin 会为省略的 key 提供 +默认值,因此只需写出准备修改的 key。 + +| 配置项 | 默认值 | 行为 | +|--------|--------|------| +| `responseCompressionEnabled` | `true` | 控制响应压缩。设为 `false` 后,环境错误归因仍保持启用。 | +| `tokenlessBin` | `$TOKENLESS_BIN`,随后使用 `tokenless` | 选择 Tokenless CLI 可执行文件。非空 Plugin 配置优先于环境变量。 | +| `skipTools` | 下文列出的内容读取类集合 | 跳过匹配工具的压缩。配置数组会替换默认集合,空数组表示不跳过任何工具。错误归因仍保持启用。 | +| `shellTools` | 下文列出的 Shell 和 process 集合 | 选择 Shell 阈值,也决定哪些工具的结构化 `value` 可以用于失败归因。配置数组会替换默认集合。 | +| `truncateStringsAt` | Shell 为 `65536`,其他工具为 `1048576` | 覆盖全部工具类别的字符串保留上限。只接受正整数。 | +| `truncateArraysAt` | Shell 为 `128`,其他工具为 `65536` | 覆盖全部工具类别的数组保留上限。只接受正整数。 | +| `maxDepth` | Shell 为 `8`,其他工具为 `32` | 覆盖全部工具类别的 JSON 最大深度。只接受正整数。 | +| `timeoutMs` | `3000` | 限制一次 Tokenless 子进程的运行时间,单位为毫秒。只接受正整数。 | +| `maxBuffer` | `2097152` | 限制捕获的子进程输出,单位为 byte。只接受正整数。 | +| `agentId` | `dsh` | 设置 Tokenless 统计记录中的 `--agent-id`。 | +| `noStash` | `false` | 设为 `true` 时传入 `--no-stash`。默认允许把删除的数组项写入 Stash。 | + +默认 `skipTools` 集合包括 `Read`、`read`、`read_file`、`read_many_files`、`Glob`、 +`glob`、`search_file`、`list_directory`、`list_dir`、`Grep`、`grep`、`grep_code`、 +`grep_search`、`search_files`、`Lsp`、`lsp`、`NotebookRead`、`notebook_read` 和 +`notebookread`。 + +默认 `shellTools` 集合包括 `Bash`、`bash`、`Shell`、`shell`、`exec`、`terminal`、 +`run_shell_command`、`run_in_terminal`、`get_terminal_output`、`execute_command` 和 +`process`。 + +DSH 使用 `isError` 标记的原始失败可以为任何工具追加依赖、权限、路径、网络或包 +错误归因。结构化输出只会为 `shellTools` 分类。归因独立于压缩,关闭或跳过压缩、 +压缩没有得到更短结果时仍会生效。后续 waterfall listener 替换 canonical `value` +后,Tokenless 会按替换值重新分类,不会沿用已经被替换结果的旧归因。 + ## 通过 anolisa 管理(推荐) 这些命令需要 ANOLISA 组件记录。如果 Tokenless 是通过 YUM 直接安装的, @@ -82,9 +151,17 @@ anolisa adapter enable tokenless qoder anolisa adapter enable tokenless claude-code anolisa adapter enable tokenless codex anolisa adapter enable tokenless qwencode +anolisa adapter enable tokenless dsh \ + --profile web \ + --profile headless ``` -只需启用实际使用的 Agent 产品。启用多个产品时,应逐个执行并分别验证。 +只需启用实际使用的 Agent 产品。多个产品应分别执行并验证各自的命令。DSH 的全部 +目标 profile 应写在同一条 enable 命令中。 + +DeepSeek Harness 按 profile 管理,因此必须至少提供一个 `--profile`。每个名称应与 +`dsh --profile ` 使用的名称一致,不带 profile 的通用命令会被拒绝。 +后续 enable 或 re-enable 必须再次列出需要保留的全部 profile。 OpenCode 应使用 [npm 安装后的手动接入](#npm-安装后的手动接入)中的随附安装脚本。 @@ -193,6 +270,13 @@ Marketplace Plugin 在 Claude Code 重启后生效,也可以按照安装脚本 Plugin 在新的 Codex 会话中加载。关闭旧会话并重新启动后验证统计。它的 PostToolUse Hook 是追加型的:统计只能作为压缩候选遥测,不能证明原始 Codex 工具结果已离开 Prompt。 +### DeepSeek Harness + +原生 Bundle 会在选定的 DSH profile 启动时加载。启用 Bundle 或修改 profile patch +后,重启 `dsh --profile `,运行一个返回可压缩 JSON 的工具,再检查 +`tokenless stats list`。禁用命令是 `anolisa adapter disable tokenless dsh`。 +receipt 已经记录 profile 名称,因此 disable 不再接受 `--profile`。 + ### OpenCode OpenCode 启动时会自动加载配置目录下的 Plugin。使用上述 Tokenless 生命周期脚本 diff --git a/src/tokenless/.anolisa/component.toml.in b/src/tokenless/.anolisa/component.toml.in index 9fad61d734..53ce2ec963 100644 --- a/src/tokenless/.anolisa/component.toml.in +++ b/src/tokenless/.anolisa/component.toml.in @@ -88,6 +88,20 @@ detect = { binary = "openclaw" } [adapters.bundle] entry = "openclaw.plugin.json" +[[adapters]] +framework = "dsh" +adapter_type = "plugin" +plugin_id = "anolisa-tokenless" +source = "adapters/dsh" +dest = "{datadir}/adapters/{component}/dsh/" +detect = { binary = "dsh" } + +[adapters.bundle] +entry = "package.json" + +[adapters.compat] +framework_version = ">=0.1.0-rc.2 <0.2.0" + [[adapters]] framework = "hermes" adapter_type = "plugin" diff --git a/src/tokenless/.gitignore b/src/tokenless/.gitignore index 3d920bf4ce..824a99f214 100644 --- a/src/tokenless/.gitignore +++ b/src/tokenless/.gitignore @@ -6,6 +6,7 @@ third_party/rtk/ adapters/tokenless/manifest.json adapters/tokenless/openclaw/package.json adapters/tokenless/openclaw/openclaw.plugin.json +adapters/tokenless/dsh/package.json adapters/tokenless/hermes/plugin.yaml adapters/tokenless/qoder/.qoder-plugin/plugin.json adapters/tokenless/claude-code/.claude-plugin/plugin.json diff --git a/src/tokenless/Makefile b/src/tokenless/Makefile index 63d3be2910..c636af6cc8 100644 --- a/src/tokenless/Makefile +++ b/src/tokenless/Makefile @@ -20,10 +20,12 @@ BIN_DIR ?= $(BINDIR) LIB_DIR ?= $(LIBEXECDIR) ADAPTER_SRC_DIR := adapters/tokenless OPENCLAW_PLUGIN_SRC_DIR := $(ADAPTER_SRC_DIR)/openclaw +DSH_PLUGIN_SRC_DIR := $(ADAPTER_SRC_DIR)/dsh ADAPTER_TEMPLATES := \ $(ADAPTER_SRC_DIR)/manifest.json \ $(ADAPTER_SRC_DIR)/openclaw/package.json \ $(ADAPTER_SRC_DIR)/openclaw/openclaw.plugin.json \ + $(ADAPTER_SRC_DIR)/dsh/package.json \ $(ADAPTER_SRC_DIR)/hermes/plugin.yaml \ $(ADAPTER_SRC_DIR)/qoder/.qoder-plugin/plugin.json \ $(ADAPTER_SRC_DIR)/claude-code/.claude-plugin/plugin.json \ @@ -44,7 +46,7 @@ PYTHON_WHEEL_DIR := $(CURDIR)/target/wheels MATURIN ?= uvx --from 'maturin>=1.9,<2.0' maturin PYPROJECT_BUILD ?= uvx --from 'build>=1,<2' pyproject-build -.PHONY: build build-tokenless build-toon build-openclaw-plugin \ +.PHONY: build build-tokenless build-toon build-openclaw-plugin build-dsh-plugin \ stamp-adapter-templates stamp-python-packages generate-component-contract \ install uninstall test lint clean package-raw stage-raw \ test-raw-package test-npm-package \ @@ -67,7 +69,7 @@ PYPROJECT_BUILD ?= uvx --from 'build>=1,<2' pyproject-build all: build # Build both projects -build: build-tokenless build-toon build-openclaw-plugin +build: build-tokenless build-toon build-openclaw-plugin build-dsh-plugin build-tokenless: @echo "==> Building tokenless + rtk..." @@ -103,6 +105,10 @@ $(ADAPTER_SRC_DIR)/openclaw/openclaw.plugin.json: $(ADAPTER_SRC_DIR)/openclaw/op @echo "==> Generating openclaw/openclaw.plugin.json (version=$(VERSION))..." sed 's/@VERSION@/$(VERSION)/g' $< > $@ +$(ADAPTER_SRC_DIR)/dsh/package.json: $(ADAPTER_SRC_DIR)/dsh/package.json.in Cargo.toml + @echo "==> Generating dsh/package.json (version=$(VERSION))..." + sed 's/@VERSION@/$(VERSION)/g' $< > $@ + $(ADAPTER_SRC_DIR)/hermes/plugin.yaml: $(ADAPTER_SRC_DIR)/hermes/plugin.yaml.in Cargo.toml @echo "==> Generating hermes/plugin.yaml (version=$(VERSION))..." sed 's/@VERSION@/$(VERSION)/g' $< > $@ @@ -142,6 +148,19 @@ build-openclaw-plugin: stamp-adapter-templates @test -f $(OPENCLAW_PLUGIN_SRC_DIR)/dist/index.js \ || { echo "ERROR: $(OPENCLAW_PLUGIN_SRC_DIR)/dist/index.js was not produced"; exit 1; } +# The dsh entry is deliberately plain ESM and has no build-time dependency on +# the dsh SDK. Keep this target as an explicit bundle validation seam so raw, +# RPM, and npm packaging all fail before shipping an incomplete native plugin. +build-dsh-plugin: stamp-adapter-templates + @echo "==> Validating native dsh plugin bundle..." + @test -f $(DSH_PLUGIN_SRC_DIR)/package.json \ + || { echo "ERROR: $(DSH_PLUGIN_SRC_DIR)/package.json is missing"; exit 1; } + @test -f $(DSH_PLUGIN_SRC_DIR)/cordis.patch.yml \ + || { echo "ERROR: $(DSH_PLUGIN_SRC_DIR)/cordis.patch.yml is missing"; exit 1; } + @test -f $(DSH_PLUGIN_SRC_DIR)/dist/index.js \ + || { echo "ERROR: $(DSH_PLUGIN_SRC_DIR)/dist/index.js is missing"; exit 1; } + @cd $(DSH_PLUGIN_SRC_DIR) && node --input-type=module -e "import('./dist/index.js').then((plugin) => { if (typeof plugin.apply !== 'function' || plugin.name !== 'anolisa-tokenless') process.exit(1) })" + # Install binaries + adapter resources per FHS spec. install: build install-binaries install-helpers install-adapter-resources install-cosh-extension @@ -158,7 +177,7 @@ install-helpers: ln -sf $(LIBEXECDIR)/rtk $(DESTDIR)$(BINDIR)/rtk ln -sf $(LIBEXECDIR)/toon $(DESTDIR)$(BINDIR)/toon -install-adapter-resources: build-openclaw-plugin +install-adapter-resources: build-openclaw-plugin build-dsh-plugin @echo "==> Installing adapter resources to $(DESTDIR)$(SHARE_DIR)..." rm -rf $(DESTDIR)$(SHARE_DIR) install -d -m 0755 $(DESTDIR)$(SHARE_DIR) @@ -171,6 +190,12 @@ install-adapter-resources: build-openclaw-plugin find $(DESTDIR)$(SHARE_DIR) -type f \( -name '*.py' -o -name '*.sh' \) -exec chmod 0755 {} + @test -f $(DESTDIR)$(SHARE_DIR)/openclaw/dist/index.js \ || { echo "ERROR: $(DESTDIR)$(SHARE_DIR)/openclaw/dist/index.js missing after install-adapter-resources"; exit 1; } + @test -f $(DESTDIR)$(SHARE_DIR)/dsh/package.json \ + || { echo "ERROR: $(DESTDIR)$(SHARE_DIR)/dsh/package.json missing after install-adapter-resources"; exit 1; } + @test -f $(DESTDIR)$(SHARE_DIR)/dsh/cordis.patch.yml \ + || { echo "ERROR: $(DESTDIR)$(SHARE_DIR)/dsh/cordis.patch.yml missing after install-adapter-resources"; exit 1; } + @test -f $(DESTDIR)$(SHARE_DIR)/dsh/dist/index.js \ + || { echo "ERROR: $(DESTDIR)$(SHARE_DIR)/dsh/dist/index.js missing after install-adapter-resources"; exit 1; } install-cosh-extension: @echo "==> Installing tokenless cosh extension to $(DESTDIR)$(COSH_EXTENSION_DIR)..." @@ -215,9 +240,11 @@ test-integration: python3 tests/test_rewrite_hook.py python3 tests/test_resolve_agent_id.py -test-adapters: build-openclaw-plugin +test-adapters: build-openclaw-plugin build-dsh-plugin @echo "==> Testing openclaw RTK context propagation..." node --test tests/test-openclaw-rtk-context.mjs + @echo "==> Testing native dsh adapter bundle..." + bash tests/test-dsh-adapter.sh @echo "==> Testing qoder adapter installer..." bash tests/test-qoder-adapter-install.sh @echo "==> Testing OpenCode adapter..." @@ -253,12 +280,12 @@ test-agentscope-integration: python-wheel agentscope-wheel # into the portable raw-package contract maintained by this component. package-raw: @$(MAKE) -B stamp-adapter-templates - @$(MAKE) build-openclaw-plugin + @$(MAKE) build-openclaw-plugin build-dsh-plugin @./packaging/raw/package.sh package stage-raw: @$(MAKE) -B stamp-adapter-templates - @$(MAKE) build-openclaw-plugin + @$(MAKE) build-openclaw-plugin build-dsh-plugin @./packaging/raw/package.sh stage test-raw-package: diff --git a/src/tokenless/README.md b/src/tokenless/README.md index f24b1d48d9..49c5a38f9e 100644 --- a/src/tokenless/README.md +++ b/src/tokenless/README.md @@ -20,6 +20,7 @@ Agent adapters are available for: - **Claude Code plugin** — RTK command rewriting, response/TOON compression, and registered but hard-disabled Tool Ready via Claude Code's official plugin marketplace. - **Codex plugin** — response compression, TOON encoding, registered but hard-disabled Tool Ready, and command rewriting via Codex's native hook system. - **OpenCode plugin** — schema/response/TOON compression, registered but hard-disabled Tool Ready, and command rewriting via OpenCode's local plugin API. +- **DeepSeek Harness plugin** — native response compression and environment-error attribution through DSH's `tools/post-execute` seam. For framework developers, the separate **AgentScope Python integration** replaces successful final tool responses and provides a marker-scoped native retrieval Tool. @@ -41,6 +42,7 @@ final tool responses and provides a marker-scoped native retrieval Tool. | Claude Code plugin | — | Tool Ready ⛔ hard-disabled, Command rewriting ✅, Response compression ✅, TOON ✅ | | Codex plugin | — | Tool Ready ⛔ hard-disabled, Command rewriting ✅, Response compression ✅, TOON ✅ | | OpenCode plugin | — | Tool Ready ⛔ hard-disabled, Command rewriting ✅, Schema compression ✅, Response compression ✅, TOON ✅ | +| DeepSeek Harness plugin | — | Response compression ✅, Environment-error attribution ✅ | | AgentScope framework integration | — | Response compression ✅, Native retrieval Tool ✅ | | Zero runtime deps | — | Pure Rust, single static binary | @@ -104,7 +106,8 @@ Token-Less/ │ ├── qoder/ # Qoder CLI plugin + scripts │ ├── claude-code/ # Claude Code plugin + marketplace + hooks │ ├── codex/ # Codex plugin + scripts -│ └── opencode/ # OpenCode local plugin + scripts +│ ├── opencode/ # OpenCode local plugin + scripts +│ └── dsh/ # Native DeepSeek Harness bundle ├── third_party/rtk/ # RTK vendored source (justfile clone+patch from GitHub) ├── third_party/patches/ # Patches for vendored third_party sources ├── Makefile # Unified build system @@ -157,6 +160,15 @@ anolisa adapter enable tokenless openclaw anolisa adapter status tokenless ``` +DeepSeek Harness requires at least one explicit profile name. When enabling +multiple profiles, pass every name in the same command; see the plugin section +below for the complete-set behavior. Use an enabled name when starting DSH: + +```bash +anolisa adapter enable tokenless dsh --profile +dsh --profile +``` + Developers building from source can use: ```bash @@ -525,6 +537,31 @@ The installer creates a `tokenless.js` symbolic link in OpenCode's global `OPENCODE_CONFIG_DIR`, `XDG_CONFIG_HOME`, and the explicit `TOKENLESS_OPENCODE_CONFIG_DIR` override. +## DeepSeek Harness Plugin + +The native DSH bundle compresses successful single-block JSON tool results +through `tools/post-execute` and keeps the original result unless the Tokenless +CLI returns strictly smaller valid JSON. Content-retrieval tools remain +lossless by default. Environment-error attribution stays active when response +compression is disabled, skipped, or unable to reduce the result. + +Enable the bundle for every desired DSH profile in one command by repeating +`--profile`: + +```bash +anolisa adapter enable tokenless dsh \ + --profile web \ + --profile headless +``` + +Each enable or re-enable treats the supplied profiles as the complete desired +set. It removes the bundle from profiles recorded by the prior receipt but +omitted from the new command, so always include every profile that should keep +Tokenless. Each name must match a profile passed to `dsh --profile `. +Configuration belongs in that profile's `cordis.patch.yml`; see the +[DeepSeek Harness integration reference](../../docs/user-guide/en/token-saving/tokenless/framework-integration.md#deepseek-harness-native-processing) +for every option and default. + ## AgentScope Framework Integration AgentScope 2.0 applications install two same-version Python wheels explicitly. diff --git a/src/tokenless/README_zh.md b/src/tokenless/README_zh.md index 74c025dd88..6e9f38b697 100644 --- a/src/tokenless/README_zh.md +++ b/src/tokenless/README_zh.md @@ -66,6 +66,7 @@ tokenless 只优化**工具调用响应**进入 LLM 上下文前的冗余,不 - **Claude Code 插件** — Tool Ready(已硬关闭)+ 命令重写 + 响应压缩 + TOON - **Codex 插件** — Tool Ready(已硬关闭)+ 命令重写 + 响应压缩 + TOON - **OpenCode 插件** — Tool Ready(已硬关闭)+ 命令重写 + Schema/响应压缩 + TOON +- **DeepSeek Harness 插件**。通过 DSH 原生 `tools/post-execute` 接入响应压缩和环境错误归因 ### Agent 开发框架集成 @@ -116,6 +117,14 @@ anolisa adapter enable tokenless openclaw anolisa adapter status tokenless ``` +DeepSeek Harness 必须指定至少一个 profile。需要启用多个 profile 时,应在同一条 +命令中列出全部名称,完整集合语义见下文。启动 DSH 时请使用已经启用的名称。 + +```bash +anolisa adapter enable tokenless dsh --profile +dsh --profile +``` + 从源码构建适合开发者。 ```bash @@ -166,6 +175,26 @@ make opencode-install `XDG_CONFIG_HOME` 和显式的 `TOKENLESS_OPENCODE_CONFIG_DIR` 覆盖。 安装后重启 OpenCode 即可加载插件。 +### DeepSeek Harness 插件 + +DSH 原生 Bundle 通过 `tools/post-execute` 压缩成功的单文本块 JSON 工具结果。 +Tokenless CLI 只有返回更短的合法 JSON 时才会替换结果,内容读取类工具默认保持 +原样。关闭响应压缩、跳过压缩或压缩无收益时,环境错误归因仍会工作。 + +需要启用多个 DSH profile 时,应在同一条命令中重复传入 `--profile`。 + +```bash +anolisa adapter enable tokenless dsh \ + --profile web \ + --profile headless +``` + +每次 enable 或 re-enable 都会把本次传入的 profile 视为完整目标集合。旧 receipt +中已有但本次没有列出的 profile 会卸载 Bundle,因此每次都要列出需要继续使用 +Tokenless 的全部 profile。每个名称必须与 `dsh --profile ` 使用的名称 +一致。配置写在对应 profile 的 `cordis.patch.yml` 中。全部配置项和默认值见 +[DeepSeek Harness 集成参考](../../docs/user-guide/zh/token-saving/tokenless/framework-integration.md#deepseek-harness-原生处理路径)。 + ### AgentScope 框架集成 AgentScope 2.0 应用需要显式安装两个相同版本的 Python Wheel。框架集成直接调用 @@ -324,6 +353,7 @@ tokenless env-check --tool Shell --fix - `python/tokenless/` — 面向 CPython 3.11+ 的 PyO3 `anolisa_tokenless` 包 - `python/agentscope/` — 独立的 AgentScope 框架集成与 Wheel 元数据 - `adapters/tokenless/` — 面向具体 Agent/CLI 的 Plugin、Hook 与 Extension 适配器包 +- `adapters/tokenless/dsh/`。DeepSeek Harness 原生 Bundle - `third_party/rtk/` — RTK 命令重写引擎(vendored) - `packaging/raw/` — Tokenless 自维护的 ANOLISA Raw 打包与目标校验 diff --git a/src/tokenless/adapters/tokenless/dsh/cordis.patch.yml b/src/tokenless/adapters/tokenless/dsh/cordis.patch.yml new file mode 100644 index 0000000000..5850890578 --- /dev/null +++ b/src/tokenless/adapters/tokenless/dsh/cordis.patch.yml @@ -0,0 +1,8 @@ +# Tokenless native bundle layer for DeepSeek Harness. +# +# The dsh adapter installs this bundle through the framework's own plugin +# lifecycle. Resolve the installed package by its published name so dsh can +# load the bundle through its normal npm package lookup. +- insert: + - id: anolisa-tokenless + name: '@anolisa/dsh-tokenless' diff --git a/src/tokenless/adapters/tokenless/dsh/dist/index.js b/src/tokenless/adapters/tokenless/dsh/dist/index.js new file mode 100644 index 0000000000..cfe678aaa2 --- /dev/null +++ b/src/tokenless/adapters/tokenless/dsh/dist/index.js @@ -0,0 +1,399 @@ +/** + * Native Tokenless plugin for DeepSeek Harness (dsh). + * + * This entry intentionally has no dsh runtime imports. dsh supplies the + * Cordis event types at runtime, while the only process boundary is the + * installed Tokenless CLI. Keeping the entry dependency-free lets ANOLISA + * install one self-contained bundle without running npm in $DSH_HOME. + */ +import { execFile } from 'node:child_process' +import { randomUUID } from 'node:crypto' + +const PLUGIN_NAME = 'anolisa-tokenless' +const DEFAULT_AGENT_ID = 'dsh' +const DEFAULT_TIMEOUT_MS = 3000 +const DEFAULT_MAX_BUFFER = 2 * 1024 * 1024 + +// Keep content-retrieval tools lossless. These names mirror the shared +// Tokenless adapter taxonomy; callers may extend or replace the list through +// the dsh plugin config without changing this safety default. +const DEFAULT_SKIP_TOOLS = new Set([ + 'Read', + 'read', + 'read_file', + 'read_many_files', + 'Glob', + 'glob', + 'search_file', + 'list_directory', + 'list_dir', + 'Grep', + 'grep', + 'grep_code', + 'grep_search', + 'search_files', + 'Lsp', + 'lsp', + 'NotebookRead', + 'notebook_read', + 'notebookread', +]) + +// These values are the same thresholds used by the shared hook adapter. A +// dsh profile can override them in its plugin config; the Tokenless CLI still +// owns the actual compression algorithm and stats recording. +const DEFAULT_SHELL_TOOLS = new Set([ + 'Bash', + 'bash', + 'Shell', + 'shell', + 'exec', + 'terminal', + 'run_shell_command', + 'run_in_terminal', + 'get_terminal_output', + 'execute_command', + 'process', +]) + +const DEFAULT_THRESHOLDS = { + shell: { strings: 65536, arrays: 128, depth: 8 }, + api: { strings: 1048576, arrays: 65536, depth: 32 }, +} + +// Mirror common/hooks/hook_utils.py ENV_PATTERNS exactly. The dsh bundle is +// independently publishable, so changes to the canonical table must update +// this dependency-free runtime copy and its tests together. +const ENV_PATTERNS = [ + [ + [ + /command not found/i, + /not installed/i, + /which:\s+no/i, + /no command\s/i, + /cannot execute/i, + /is not recognized/i, + /could not find/i, + /unable to locate/i, + /package not found/i, + /\/bin\/sh:.*: not found/i, + /command not found:/i, + ], + 'ENV_DEPENDENCY_MISSING', + 'Missing dependency detected. Install it or ask the user for guidance.', + ], + [ + [ + /permission denied/i, + /operation not permitted/i, + /eacces/i, + /access denied/i, + /cannot open .* for writing/i, + ], + 'ENV_PERMISSION', + 'Permission denied. Check file/directory permissions or run with appropriate access.', + ], + [ + [ + /no such file or directory/i, + /enoent/i, + /cannot find/i, + /file not found/i, + /does not exist/i, + ], + 'ENV_FILE_MISSING', + 'Required file or directory not found. Verify the path or create it.', + ], + [ + [ + /connection refused/i, + /could not resolve host/i, + /network is unreachable/i, + /curl: \(7\)/i, + /curl: \(6\)/i, + /failed to connect/i, + /name or service not known/i, + /couldn't resolve host/i, + /temporary failure in name resolution/i, + /econnrefused/i, + /etimedout/i, + /connection timed out/i, + ], + 'ENV_NETWORK', + 'Network connectivity issue. Check DNS, proxy, and firewall settings.', + ], + [ + [ + /modulenotfounderror/i, + /importerror/i, + /no module named/i, + /cannot import name/i, + /npm err! 404/i, + ], + 'ENV_PACKAGE_MISSING', + 'Required package or module is missing. Install the needed dependency.', + ], +] + +/** Return a plain config value or the supplied fallback. */ +function valueOr(config, key, fallback) { + return config && Object.prototype.hasOwnProperty.call(config, key) + ? config[key] + : fallback +} + +/** Normalize a config tool list without allowing malformed values to widen it. */ +function toolSet(value, fallback) { + if (!Array.isArray(value)) return fallback + return new Set(value.filter((name) => typeof name === 'string' && name.length > 0)) +} + +/** Resolve the executable without ever installing or mutating a dsh profile. */ +function tokenlessBinary(config) { + const configured = valueOr(config, 'tokenlessBin', undefined) + if (typeof configured === 'string' && configured.length > 0) return configured + return process.env.TOKENLESS_BIN || 'tokenless' +} + +/** Extract text used only for error attribution; never stringify image blocks. */ +function errorText(result) { + if (!result || typeof result !== 'object') return '' + const error = result.error + if (error && typeof error.message === 'string') return error.message + return result.content + ?.filter((block) => block && block.type === 'text' && typeof block.text === 'string') + .map((block) => block.text) + .join('\n') || '' +} + +/** Return an environment category and remediation hint for known failure text. */ +function classifyEnvironmentError(text) { + if (typeof text !== 'string') return undefined + if (!text) return undefined + for (const [patterns, category, hint] of ENV_PATTERNS) { + if (patterns.some((pattern) => pattern.test(text))) return { category, hint } + } + return undefined +} + +/** Extract attribution only when structured output explicitly reports failure. */ +function classifyStructuredEnvironmentError(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined + const exitCode = value.exit_code ?? value.exitCode + const stringExitCode = typeof exitCode === 'string' ? exitCode.trim() : '' + const nonzeroExit = (typeof exitCode === 'number' && exitCode !== 0) + || (/^-?\d+$/.test(stringExitCode) && Number(stringExitCode) !== 0) + const timedOut = value.timed_out === true || value.timedOut === true + const failed = nonzeroExit + || timedOut + || value.isError === true + || value.success === false + || value.ok === false + if (!failed) return undefined + const error = value.error + let errorValue = error + if (error && typeof error === 'object') { + if (typeof error.message === 'string') { + errorValue = error.message + } else { + try { + errorValue = JSON.stringify(error) + } catch { + errorValue = String(error) + } + } + } + const streamText = (stream) => { + if (typeof stream === 'string') return stream + if (stream && typeof stream === 'object' && typeof stream.text === 'string') return stream.text + return undefined + } + const text = [streamText(value.stderr), errorValue] + .filter((part) => typeof part === 'string') + .join('\n') + return classifyEnvironmentError(text) +} + +/** Construct a valid plugin-owned user message without importing dsh modules. */ +function attributionContext(text) { + return { + id: randomUUID(), + role: 'user', + content: [{ type: 'text', text }], + source: { + kind: 'plugin', + plugin: PLUGIN_NAME, + form: 'notice', + summary: text.slice(0, 120), + }, + } +} + +/** Add an attribution context to a decision while preserving its shape. */ +function withAttribution(decision, attribution) { + if (!attribution) return decision + return { + ...decision, + additionalContexts: [ + ...(Array.isArray(decision.additionalContexts) ? decision.additionalContexts : []), + attributionContext(attribution), + ], + } +} + +/** Safely read one text-only result projection. */ +function singleTextContent(result) { + if (!result || result.isError || !Array.isArray(result.content)) return undefined + if (result.content.length !== 1) return undefined + const [block] = result.content + if (!block || block.type !== 'text' || typeof block.text !== 'string') return undefined + return block.text +} + +/** Convert one config threshold to a positive finite integer. */ +function positiveInteger(value, fallback) { + return Number.isInteger(value) && value > 0 ? value : fallback +} + +/** Build the Tokenless CLI argv for one dsh execution. */ +function compressionArgs(exec, config, shellTools) { + const selected = shellTools.has(exec.name) ? DEFAULT_THRESHOLDS.shell : DEFAULT_THRESHOLDS.api + const strings = positiveInteger(valueOr(config, 'truncateStringsAt', undefined), selected.strings) + const arrays = positiveInteger(valueOr(config, 'truncateArraysAt', undefined), selected.arrays) + const depth = positiveInteger(valueOr(config, 'maxDepth', undefined), selected.depth) + const args = [ + 'compress-response', + '--agent-id', + String(valueOr(config, 'agentId', DEFAULT_AGENT_ID)), + '--truncate-strings-at', + String(strings), + '--truncate-arrays-at', + String(arrays), + '--max-depth', + String(depth), + ] + const sessionId = exec.agent?.id + if (typeof sessionId === 'string' && sessionId.length > 0) { + args.push('--session-id', sessionId) + } + if (typeof exec.callId === 'string' && exec.callId.length > 0) { + args.push('--tool-use-id', exec.callId) + } + if (valueOr(config, 'noStash', false) === true) args.push('--no-stash') + return args +} + +/** Execute a child process with bounded output and explicit stdin. */ +function runTokenless(binary, args, options, input) { + return new Promise((resolve, reject) => { + let child + try { + child = execFile(binary, args, options, (error, stdout, stderr) => { + if (error) { + error.stderr = stderr + reject(error) + return + } + resolve({ stdout, stderr }) + }) + } catch (error) { + reject(error) + return + } + child.stdin?.on('error', () => {}) + try { + child.stdin?.end(input) + } catch (error) { + reject(error) + } + }) +} + +/** Run Tokenless and return a strictly smaller JSON candidate, or undefined. */ +async function compressText(text, exec, config, shellTools) { + if (exec.signal?.aborted) return undefined + let parsed + try { + parsed = JSON.parse(text) + } catch { + return undefined + } + if (parsed === null || (typeof parsed !== 'object' && !Array.isArray(parsed))) return undefined + const binary = tokenlessBinary(config) + try { + const { stdout } = await runTokenless(binary, compressionArgs(exec, config, shellTools), { + timeout: positiveInteger(valueOr(config, 'timeoutMs', undefined), DEFAULT_TIMEOUT_MS), + maxBuffer: positiveInteger(valueOr(config, 'maxBuffer', undefined), DEFAULT_MAX_BUFFER), + encoding: 'utf8', + windowsHide: true, + signal: exec.signal, + }, text) + const candidate = typeof stdout === 'string' ? stdout.trim() : '' + // The host's original content is authoritative unless compression proves + // a real reduction. This avoids duplicate payloads and preserves fail-open + // behavior when the CLI is unavailable, malformed, or no-op. + if (!candidate || candidate.length >= text.length) return undefined + JSON.parse(candidate) + return candidate + } catch { + return undefined + } +} + +/** Register native response compression on dsh's typed post-execute seam. */ +export function apply(ctx, config = {}) { + const skipTools = toolSet(valueOr(config, 'skipTools', undefined), DEFAULT_SKIP_TOOLS) + const shellTools = toolSet(valueOr(config, 'shellTools', undefined), DEFAULT_SHELL_TOOLS) + const enabled = valueOr(config, 'responseCompressionEnabled', true) !== false + ctx.on('tools/post-execute', async (exec, result, next) => { + const envError = result?.isError === true + ? classifyEnvironmentError(errorText(result)) + : undefined + const structuredError = result?.isError === false && shellTools.has(exec.name) + ? classifyStructuredEnvironmentError(result.value) + : undefined + // Attribution is independent of compression so disabled, skipped, and + // parented failures still tell the agent why blind retries are unsafe. + const originalAttribution = structuredError || envError + + // DSH treats this seam as a waterfall. Let downstream policies settle + // their decision before replacing only its accepted display content. + const decision = await next() + const replacesValue = Object.prototype.hasOwnProperty.call(decision, 'value') + // A downstream canonical value replaces the original result entirely, so + // any attribution must describe that value rather than the stale result. + const attribution = replacesValue + ? (shellTools.has(exec.name) + ? classifyStructuredEnvironmentError(decision.value) + : undefined) + : originalAttribution + const responseContext = attribution + ? `[tokenless:env] ${exec.name} failed: ${attribution.category} (${attribution.hint}). Skip retry.` + : undefined + const canCompress = enabled + && !skipTools.has(exec.name) + && exec.parent === undefined + && decision.kind === 'accept' + && !replacesValue + if (!canCompress) return withAttribution(decision, responseContext) + + // Only a single text block is safe to replace. Images, tool-call blocks, + // nested tool results, and mixed content remain untouched by design. + const contentResult = decision.content === undefined + ? result + : { ...result, content: decision.content } + const original = singleTextContent(contentResult) + if (original === undefined) return withAttribution(decision, responseContext) + + const candidate = await compressText(original, exec, config, shellTools) + if (!candidate) return withAttribution(decision, responseContext) + + return { + ...withAttribution(decision, responseContext), + content: [{ type: 'text', text: candidate }], + } + }) +} + +export const name = PLUGIN_NAME +export const inject = ['tools'] diff --git a/src/tokenless/adapters/tokenless/dsh/package.json.in b/src/tokenless/adapters/tokenless/dsh/package.json.in new file mode 100644 index 0000000000..ac3e25a30a --- /dev/null +++ b/src/tokenless/adapters/tokenless/dsh/package.json.in @@ -0,0 +1,26 @@ +{ + "name": "@anolisa/dsh-tokenless", + "version": "@VERSION@", + "description": "Native DeepSeek Harness response compression for Tokenless", + "type": "module", + "main": "./dist/index.js", + "exports": { + ".": "./dist/index.js", + "./package.json": "./package.json" + }, + "files": ["dist/", "cordis.patch.yml", "package.json"], + "license": "Apache-2.0", + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + } + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-llm": ">=0.1.0-rc.2 <0.2.0", + "@deepseek-ai/dsh-tools": ">=0.1.0-rc.2 <0.2.0" + }, + "engines": { + "node": ">=22.0.0" + } +} diff --git a/src/tokenless/adapters/tokenless/manifest.json.in b/src/tokenless/adapters/tokenless/manifest.json.in index 4cc4231abf..e95a7617dd 100644 --- a/src/tokenless/adapters/tokenless/manifest.json.in +++ b/src/tokenless/adapters/tokenless/manifest.json.in @@ -29,6 +29,20 @@ "uninstall": "openclaw/scripts/uninstall.sh" } }, + "dsh": { + "compatibleVersions": ">=0.1.0-rc.2 <0.2.0", + "method": "plugin", + "pluginId": "@anolisa/dsh-tokenless", + "capabilities": { + "seams": ["tools/post-execute"], + "features": ["compress-response", "error-attribution", "stats"] + }, + "bundle": { + "entry": "package.json", + "patch": "cordis.patch.yml", + "pluginEntry": "dist/index.js" + } + }, "hermes": { "compatibleVersions": ">=1.0.0", "capabilities": { diff --git a/src/tokenless/npm/scripts/package-npm.js b/src/tokenless/npm/scripts/package-npm.js index bd1c5d1152..90eebb87c9 100644 --- a/src/tokenless/npm/scripts/package-npm.js +++ b/src/tokenless/npm/scripts/package-npm.js @@ -357,7 +357,8 @@ function walkFiles(dir, cb) { * Build adapter payloads that are not plain source files. The OpenClaw plugin * is TypeScript and must be compiled to dist/index.js before it can be * installed by openclaw plugins install; a clean Git checkout only contains - * index.ts. This mirrors the Makefile's build-openclaw-plugin target. + * index.ts. The dsh entry is plain ESM, but its package manifest is generated + * from the component version and is validated by the same Makefile seam. */ function buildAdapters() { const openclawDir = join(tokenlessRoot, 'adapters', 'tokenless', 'openclaw'); @@ -367,14 +368,14 @@ function buildAdapters() { // never leak into a published tarball. console.log(' Building OpenClaw plugin (TypeScript -> dist/index.js)...'); try { - execSync('make build-openclaw-plugin', { + execSync('make build-openclaw-plugin build-dsh-plugin', { stdio: 'inherit', cwd: tokenlessRoot, }); } catch (err) { throw new Error( - `OpenClaw plugin build failed. Ensure npm and TypeScript are available. ` + - `Build manually with: make -C src/tokenless build-openclaw-plugin`, + `Adapter bundle preparation failed. Ensure npm and TypeScript are available. ` + + `Build manually with: make -C src/tokenless build-openclaw-plugin build-dsh-plugin`, ); } @@ -383,6 +384,13 @@ function buildAdapters() { `OpenClaw plugin build did not produce adapters/tokenless/openclaw/dist/index.js`, ); } + + const dshDir = join(tokenlessRoot, 'adapters', 'tokenless', 'dsh'); + for (const relative of ['package.json', 'cordis.patch.yml', 'dist/index.js']) { + if (!existsSync(join(dshDir, relative))) { + throw new Error(`Native dsh adapter bundle is missing adapters/tokenless/dsh/${relative}`); + } + } } /** diff --git a/src/tokenless/packaging/raw/package.sh b/src/tokenless/packaging/raw/package.sh index 06f6b6670b..bdd83fd904 100755 --- a/src/tokenless/packaging/raw/package.sh +++ b/src/tokenless/packaging/raw/package.sh @@ -91,6 +91,9 @@ stage_payload() { openclaw/package.json \ openclaw/openclaw.plugin.json \ openclaw/dist/index.js \ + dsh/package.json \ + dsh/cordis.patch.yml \ + dsh/dist/index.js \ hermes/plugin.yaml \ qoder/.qoder-plugin/plugin.json \ claude-code/.claude-plugin/marketplace.json \ diff --git a/src/tokenless/packaging/raw/verify-release.py b/src/tokenless/packaging/raw/verify-release.py index 0683334684..1ee1e6c846 100755 --- a/src/tokenless/packaging/raw/verify-release.py +++ b/src/tokenless/packaging/raw/verify-release.py @@ -80,6 +80,7 @@ def verify_versions(root: Path, contract: Path) -> str: adapters / "manifest.json", adapters / "openclaw" / "package.json", adapters / "openclaw" / "openclaw.plugin.json", + adapters / "dsh" / "package.json", adapters / "qoder" / ".qoder-plugin" / "plugin.json", adapters / "claude-code" / ".claude-plugin" / "plugin.json", adapters / "codex" / ".codex-plugin" / "plugin.json", diff --git a/src/tokenless/tests/test-dsh-adapter.sh b/src/tokenless/tests/test-dsh-adapter.sh new file mode 100644 index 0000000000..4366e7f4e1 --- /dev/null +++ b/src/tokenless/tests/test-dsh-adapter.sh @@ -0,0 +1,447 @@ +#!/usr/bin/env bash +# Exercise the native dsh bundle without requiring a dsh installation. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TMP="$(mktemp -d /tmp/tokenless-dsh-adapter-test.XXXXXX)" +trap 'rm -rf "$TMP"' EXIT + +make -C "$ROOT" stamp-adapter-templates >/dev/null +node --input-type=module - "$ROOT" "$TMP" <<'NODE' +import assert from 'node:assert/strict' +import { + chmodSync, + existsSync, + readFileSync, + unlinkSync, + writeFileSync, +} from 'node:fs' +import { join } from 'node:path' + +const [root, tmp] = process.argv.slice(2) +const binary = join(tmp, 'tokenless') +const argsFile = join(tmp, 'argv.json') +const missingBinary = join(tmp, 'missing-tokenless') +writeFileSync( + binary, + '#!/usr/bin/env node\n' + + 'const { writeFileSync } = require("node:fs");\n' + + 'process.stdin.setEncoding("utf8"); let input = "";\n' + + 'process.stdin.on("data", chunk => input += chunk);\n' + + 'process.stdin.on("end", () => {\n' + + ' if (process.env.TOKENLESS_TEST_ARGS) writeFileSync(process.env.TOKENLESS_TEST_ARGS, JSON.stringify(process.argv.slice(2)));\n' + + ' if (process.env.TOKENLESS_TEST_MODE === "fail") process.exit(7);\n' + + ' if (process.env.TOKENLESS_TEST_MODE === "timeout") { setTimeout(() => {}, 10000); return; }\n' + + ' if (process.env.TOKENLESS_TEST_MODE === "same") process.stdout.write(input);\n' + + ' else if (process.env.TOKENLESS_TEST_MODE === "invalid") process.stdout.write("{");\n' + + ' else process.stdout.write("{\\"ok\\":true}");\n' + + '});\n', +) +chmodSync(binary, 0o755) +process.env.TOKENLESS_TEST_ARGS = argsFile + +const pluginPath = join(root, 'adapters/tokenless/dsh/dist/index.js') +const plugin = await import(`file://${pluginPath}`) +const cordisPatch = readFileSync( + join(root, 'adapters/tokenless/dsh/cordis.patch.yml'), + 'utf8', +) +assert.match(cordisPatch, /name:\s+['"]@anolisa\/dsh-tokenless['"]/) +assert.doesNotMatch(cordisPatch, /name:\s+\.\/dist\/index\.js/) +function register(config) { + let callback + const ctx = { + on(event, listener) { + assert.equal(event, 'tools/post-execute') + callback = listener + }, + } + plugin.apply(ctx, config) + assert.equal(typeof callback, 'function') + return callback +} + +const listener = register({ tokenlessBin: binary }) +assert.equal(plugin.name, 'anolisa-tokenless') +assert.deepEqual(plugin.inject, ['tools']) + +const exec = { + name: 'api_call', + callId: 'call-1', + signal: new AbortController().signal, + agent: { id: 'session-1' }, +} +const longText = '{"long":"this payload is intentionally long"}' +const result = (text = longText, value) => ({ + isError: false, + ...(value === undefined ? {} : { value }), + content: [{ type: 'text', text }], +}) +const clearArgs = () => { + if (existsSync(argsFile)) unlinkSync(argsFile) +} +const args = () => JSON.parse(readFileSync(argsFile, 'utf8')) +const downstreamContext = { + id: 'downstream-context', + role: 'user', + content: [{ type: 'text', text: 'downstream policy context' }], +} + +// A compression win must still run and compose the downstream waterfall. +process.env.TOKENLESS_TEST_MODE = 'compress' +clearArgs() +let nextCalled = false +const compressed = await listener(exec, result(), async () => { + nextCalled = true + return { kind: 'accept', additionalContexts: [downstreamContext] } +}) +assert.equal(nextCalled, true) +assert.equal(compressed.kind, 'accept') +assert.deepEqual(compressed.content, [{ type: 'text', text: '{"ok":true}' }]) +assert.deepEqual(compressed.additionalContexts, [downstreamContext]) +assert.deepEqual(args(), [ + 'compress-response', + '--agent-id', 'dsh', + '--truncate-strings-at', '1048576', + '--truncate-arrays-at', '65536', + '--max-depth', '32', + '--session-id', 'session-1', + '--tool-use-id', 'call-1', +]) + +// Downstream blocks and canonical-value replacements must pass through intact. +clearArgs() +const block = await listener(exec, result(), async () => ({ + kind: 'block', + feedback: [{ type: 'text', text: 'policy blocked this result' }], + additionalContexts: [downstreamContext], +})) +assert.deepEqual(block, { + kind: 'block', + feedback: [{ type: 'text', text: 'policy blocked this result' }], + additionalContexts: [downstreamContext], +}) +assert.equal(existsSync(argsFile), false) + +const valueDecision = { kind: 'accept', value: { canonical: true }, additionalContexts: [downstreamContext] } +const valueResult = await listener(exec, result(), async () => valueDecision) +assert.strictEqual(valueResult, valueDecision) +assert.equal(existsSync(argsFile), false) + +let mixedNextCalled = false +const mixed = await listener(exec, { + isError: false, + content: [ + { type: 'text', text: longText }, + { type: 'image', attachment: { id: 'image-1' } }, + ], +}, async () => { + mixedNextCalled = true + return { kind: 'accept' } +}) +assert.equal(mixedNextCalled, true) +assert.deepEqual(mixed, { kind: 'accept' }) + +let abortedNextCalled = false +const aborted = await listener({ + ...exec, + signal: AbortSignal.abort(), +}, result(), async () => { + abortedNextCalled = true + return { kind: 'accept' } +}) +assert.equal(abortedNextCalled, true) +assert.deepEqual(aborted, { kind: 'accept' }) + +// DSH bash exposes failures in canonical result.value, not display content. +const bashExec = { ...exec, name: 'Bash' } +const canonicalFailureResult = { + isError: false, + value: { + kind: 'foreground', + exitCode: 1, + timedOut: false, + stdout: { text: '', truncated: false }, + stderr: { text: 'permission denied while opening protected file', truncated: false }, + }, + content: [{ type: 'text', text: '```console\ncat protected\n[exit code: 1]\n```' }], +} +const canonicalFailure = await listener(bashExec, canonicalFailureResult, async () => ({ kind: 'accept' })) +assert.equal(canonicalFailure.additionalContexts?.length, 1) +assert.match(canonicalFailure.additionalContexts[0].content[0].text, /ENV_PERMISSION/) + +// A downstream canonical replacement is the final result. Attribution must +// describe that replacement, never the stale result seen before next(). +clearArgs() +const recoveredDecision = { + kind: 'accept', + value: { + kind: 'foreground', + exitCode: 0, + timedOut: false, + stdout: { text: 'recovered', truncated: false }, + stderr: { text: '', truncated: false }, + }, + additionalContexts: [downstreamContext], +} +const recovered = await listener(bashExec, canonicalFailureResult, async () => recoveredDecision) +assert.strictEqual(recovered, recoveredDecision) +assert.equal(existsSync(argsFile), false) + +const replacementFailureDecision = { + kind: 'accept', + value: { + kind: 'foreground', + exitCode: 1, + timedOut: false, + stdout: { text: '', truncated: false }, + stderr: { text: 'permission denied after replacement', truncated: false }, + }, + additionalContexts: [downstreamContext], +} +const replacementFailure = await listener(bashExec, result(), async () => replacementFailureDecision) +assert.strictEqual(replacementFailure.value, replacementFailureDecision.value) +assert.equal(replacementFailure.additionalContexts.length, 2) +assert.strictEqual(replacementFailure.additionalContexts[0], downstreamContext) +assert.match(replacementFailure.additionalContexts[1].content[0].text, /ENV_PERMISSION/) +assert.equal(existsSync(argsFile), false) + +const zeroExit = await listener(bashExec, { + isError: false, + value: { + kind: 'foreground', + exitCode: 0, + timedOut: false, + stdout: { text: 'permission denied in searched documentation', truncated: false }, + stderr: { text: '', truncated: false }, + }, + content: [{ type: 'text', text: 'permission denied in searched documentation' }], +}, async () => ({ kind: 'accept' })) +assert.equal(zeroExit.additionalContexts, undefined) + +const timedOut = await listener(bashExec, { + isError: false, + value: { + kind: 'foreground', + exitCode: 124, + timedOut: true, + stdout: { text: '', truncated: false }, + stderr: { text: 'connection timed out', truncated: false }, + }, + content: [{ type: 'text', text: '```console\nnetwork call\n```' }], +}, async () => ({ kind: 'accept' })) +assert.equal(timedOut.additionalContexts?.length, 1) +assert.match(timedOut.additionalContexts[0].content[0].text, /ENV_NETWORK/) + +const unclassifiedTimeout = await listener(bashExec, { + isError: false, + value: { + kind: 'foreground', + exitCode: 124, + timedOut: true, + stdout: { text: '', truncated: false }, + stderr: { text: 'command stopped after timeout', truncated: false }, + }, + content: [{ type: 'text', text: 'command stopped after timeout' }], +}, async () => ({ kind: 'accept' })) +assert.equal(unclassifiedTimeout.additionalContexts, undefined) + +const nonnumericExit = await listener(bashExec, { + isError: false, + value: { + kind: 'foreground', + exitCode: 'N/A', + timedOut: false, + stdout: { text: '', truncated: false }, + stderr: { text: 'permission denied appears in ordinary data', truncated: false }, + }, + content: [{ type: 'text', text: 'ordinary successful result' }], +}, async () => ({ kind: 'accept' })) +assert.equal(nonnumericExit.additionalContexts, undefined) + +const errorObject = await listener(bashExec, { + isError: false, + value: { + kind: 'foreground', + exitCode: 1, + timedOut: false, + stderr: { text: '', truncated: false }, + error: { code: 'EACCES', errno: 13 }, + }, + content: [{ type: 'text', text: 'command failed' }], +}, async () => ({ kind: 'accept' })) +assert.match(errorObject.additionalContexts[0].content[0].text, /ENV_PERMISSION/) + +const stdoutOnly = await listener(bashExec, { + isError: false, + value: { + kind: 'foreground', + exitCode: 1, + timedOut: false, + stdout: { text: 'permission denied appears in command output', truncated: false }, + stderr: { text: '', truncated: false }, + }, + content: [{ type: 'text', text: 'command failed' }], +}, async () => ({ kind: 'accept' })) +assert.equal(stdoutOnly.additionalContexts, undefined) + +// Successful non-shell tools own their value schema. Shell-shaped business +// data must never be reinterpreted as a host-level execution failure. +for (const value of [ + { + kind: 'audit-record', + exitCode: 1, + stderr: { text: 'permission denied was captured in the historical record' }, + }, + { + kind: 'latency-record', + timedOut: true, + stderr: { text: 'connection timed out was the recorded outcome' }, + }, + { + kind: 'archived-result', + success: false, + error: { message: 'ENOENT: archived log no longer present' }, + }, +]) { + const businessResult = await listener(exec, result(longText, value), async () => ({ kind: 'accept' })) + assert.equal(businessResult.additionalContexts, undefined) +} + +const customShellListener = register({ + tokenlessBin: binary, + shellTools: ['custom_process'], +}) +const customShellFailure = await customShellListener({ ...exec, name: 'custom_process' }, { + isError: false, + value: { + exitCode: 1, + stderr: { text: 'permission denied', truncated: false }, + }, + content: [{ type: 'text', text: 'custom process failed' }], +}, async () => ({ kind: 'accept' })) +assert.match(customShellFailure.additionalContexts[0].content[0].text, /ENV_PERMISSION/) + +const successfulMatch = await listener(exec, { + isError: false, + content: [{ type: 'text', text: 'search result: permission denied' }], +}, async () => ({ kind: 'accept' })) +assert.equal(successfulMatch.additionalContexts, undefined) + +const env = await listener(exec, { + isError: true, + error: { message: 'command not found: jq' }, + content: [{ type: 'text', text: 'command not found: jq' }], +}, async () => ({ kind: 'accept' })) +assert.equal(env.additionalContexts?.length, 1) +assert.equal(env.additionalContexts[0].role, 'user') +assert.equal(env.additionalContexts[0].source.kind, 'plugin') +assert.match(env.additionalContexts[0].content[0].text, /ENV_DEPENDENCY_MISSING/) +assert.equal(typeof env.additionalContexts[0].id, 'string') + +const dashMissing = await listener(bashExec, { + isError: false, + value: { + kind: 'foreground', + exitCode: 127, + timedOut: false, + stderr: { text: '/bin/sh: 1: jq: not found', truncated: false }, + stdout: { text: '', truncated: false }, + }, + content: [{ type: 'text', text: '/bin/sh: 1: jq: not found' }], +}, async () => ({ kind: 'accept' })) +assert.match(dashMissing.additionalContexts[0].content[0].text, /ENV_DEPENDENCY_MISSING/) + +// Compression controls never suppress failure attribution. +clearArgs() +const disabledListener = register({ + tokenlessBin: binary, + responseCompressionEnabled: false, +}) +const disabledFailure = await disabledListener({ ...exec, name: 'Grep' }, { + isError: true, + error: { message: 'cannot open output for writing' }, + content: [{ type: 'text', text: 'cannot open output for writing' }], +}, async () => ({ kind: 'accept' })) +assert.match(disabledFailure.additionalContexts[0].content[0].text, /ENV_PERMISSION/) +assert.equal(existsSync(argsFile), false) + +// Code Mode sub-calls keep attribution but skip compression and stash writes. +clearArgs() +const parentFailure = await listener({ ...bashExec, parent: {} }, { + isError: false, + value: { + kind: 'foreground', + exitCode: 1, + timedOut: false, + stderr: { text: 'permission denied', truncated: false }, + stdout: { text: '', truncated: false }, + }, + content: [{ type: 'text', text: 'permission denied' }], +}, async () => ({ kind: 'accept' })) +assert.equal(parentFailure.additionalContexts?.length, 1) +assert.equal(existsSync(argsFile), false) + +// Missing binaries, non-zero exits, timeouts, and invalid/no-op output fail open. +async function failOpen(mode, callback = listener) { + process.env.TOKENLESS_TEST_MODE = mode + clearArgs() + let called = false + const original = result() + const decision = await callback(exec, original, async () => { + called = true + return { kind: 'accept', content: original.content } + }) + assert.equal(called, true) + assert.deepEqual(decision.content, original.content) + if (mode !== 'timeout') assert.equal(existsSync(argsFile), mode !== 'missing') +} +process.env.TOKENLESS_TEST_MODE = 'fail' +await failOpen('fail') +process.env.TOKENLESS_TEST_MODE = 'same' +await failOpen('same') +process.env.TOKENLESS_TEST_MODE = 'invalid' +await failOpen('invalid') +const timeoutListener = register({ tokenlessBin: binary, timeoutMs: 20 }) +process.env.TOKENLESS_TEST_MODE = 'timeout' +await failOpen('timeout', timeoutListener) +const missingListener = register({ tokenlessBin: missingBinary }) +await failOpen('missing', missingListener) + +// Keep the dsh taxonomy and thresholds in parity with the shared source. +process.env.TOKENLESS_TEST_MODE = 'compress' +const categories = JSON.parse(readFileSync( + join(root, 'adapters/tokenless/common/hooks/tool_categories.json'), + 'utf8', +)) +for (const name of categories.layer_1_skip.tools) { + clearArgs() + await listener({ ...exec, name }, result(), async () => ({ kind: 'accept', content: result().content })) + assert.equal(existsSync(argsFile), false, `skip tool ${name} must not invoke tokenless`) +} +const shellArgs = [ + 'compress-response', + '--agent-id', 'dsh', + '--truncate-strings-at', String(categories.layer_2_shell.thresholds.truncate_strings_at), + '--truncate-arrays-at', String(categories.layer_2_shell.thresholds.truncate_arrays_at), + '--max-depth', String(categories.layer_2_shell.thresholds.max_depth), + '--session-id', 'session-1', + '--tool-use-id', 'call-1', +] +for (const name of categories.layer_2_shell.tools) { + clearArgs() + await listener({ ...exec, name }, result(), async () => ({ kind: 'accept', content: result().content })) + assert.deepEqual(args(), shellArgs, `shell tool ${name} must use shared thresholds`) +} +clearArgs() +await listener(exec, result(), async () => ({ kind: 'accept', content: result().content })) +assert.deepEqual(args().slice(0, 9), [ + 'compress-response', + '--agent-id', 'dsh', + '--truncate-strings-at', String(categories.layer_3_api.thresholds.truncate_strings_at), + '--truncate-arrays-at', String(categories.layer_3_api.thresholds.truncate_arrays_at), + '--max-depth', String(categories.layer_3_api.thresholds.max_depth), +]) + +console.log('native dsh adapter tests passed') +NODE diff --git a/src/tokenless/tests/test-package-npm-prebuilt.sh b/src/tokenless/tests/test-package-npm-prebuilt.sh index 14d32084aa..6753a022c6 100755 --- a/src/tokenless/tests/test-package-npm-prebuilt.sh +++ b/src/tokenless/tests/test-package-npm-prebuilt.sh @@ -148,6 +148,18 @@ for (const name of expected) { } JS +test -f "$ROOT/npm/dist/tokenless/adapters/tokenless/dsh/package.json" +test -f "$ROOT/npm/dist/tokenless/adapters/tokenless/dsh/cordis.patch.yml" +test -f "$ROOT/npm/dist/tokenless/adapters/tokenless/dsh/dist/index.js" +node - "$ROOT/npm/dist/tokenless/adapters/tokenless/dsh/package.json" <<'JS' +const fs = require('node:fs'); +const manifest = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); +if (manifest.name !== '@anolisa/dsh-tokenless') throw new Error('wrong dsh package name'); +if (manifest.dsh?.bundle?.patch !== './cordis.patch.yml') { + throw new Error('dsh bundle patch contract missing'); +} +JS + if grep -Eq \ 'cargo-zigbuild|cargo zigbuild|cross build|rustup target|SDKROOT|detectBuilder' \ "$ROOT/npm/scripts/package-npm.js"; then diff --git a/src/tokenless/tests/test-package-raw.sh b/src/tokenless/tests/test-package-raw.sh index 189c72d24a..7d06ec5ff6 100755 --- a/src/tokenless/tests/test-package-raw.sh +++ b/src/tokenless/tests/test-package-raw.sh @@ -16,6 +16,7 @@ mkdir -p \ "$ADAPTERS/common/hooks" \ "$ADAPTERS/common/commands" \ "$ADAPTERS/openclaw/dist" \ + "$ADAPTERS/dsh/dist" \ "$ADAPTERS/hermes" \ "$ADAPTERS/qoder/.qoder-plugin" \ "$ADAPTERS/claude-code/.claude-plugin" \ @@ -44,6 +45,7 @@ write_json_version() { write_json_version "$ADAPTERS/manifest.json" write_json_version "$ADAPTERS/openclaw/package.json" write_json_version "$ADAPTERS/openclaw/openclaw.plugin.json" +write_json_version "$ADAPTERS/dsh/package.json" write_json_version "$ADAPTERS/qoder/.qoder-plugin/plugin.json" write_json_version "$ADAPTERS/claude-code/.claude-plugin/plugin.json" write_json_version "$ADAPTERS/codex/.codex-plugin/plugin.json" @@ -52,6 +54,9 @@ printf '{"name":"anolisa-tokenless"}\n' \ > "$ADAPTERS/claude-code/.claude-plugin/marketplace.json" printf 'version: "%s"\n' "$VERSION" > "$ADAPTERS/hermes/plugin.yaml" printf 'export default {};\n' > "$ADAPTERS/openclaw/dist/index.js" +printf '%s\n' '- insert:' ' - id: anolisa-tokenless' " name: '@anolisa/dsh-tokenless'" \ + > "$ADAPTERS/dsh/cordis.patch.yml" +printf 'export function apply() {}\n' > "$ADAPTERS/dsh/dist/index.js" printf '{"name":"tokenless","version":"%s"}\n' "$VERSION" \ > "$ADAPTERS/common/cosh-extension.json" printf '{}\n' > "$ADAPTERS/common/tool-ready-spec.json" @@ -161,6 +166,9 @@ for relative in \ test ! -L "$EXTRACTED/$relative" cmp "$ADAPTERS/common/hooks/run-hook.sh" "$EXTRACTED/$relative" done +test -f "$EXTRACTED/adapters/dsh/package.json" +test -f "$EXTRACTED/adapters/dsh/cordis.patch.yml" +test -f "$EXTRACTED/adapters/dsh/dist/index.js" test -f "$EXTRACTED/extensions/tokenless/cosh-extension.json" test -f "$EXTRACTED/extensions/tokenless/hooks/run-hook.sh" test ! -e "$EXTRACTED/adapters/agentscope" diff --git a/src/tokenless/tokenless.spec.in b/src/tokenless/tokenless.spec.in index e11fd81f99..e3289c69bc 100644 --- a/src/tokenless/tokenless.spec.in +++ b/src/tokenless/tokenless.spec.in @@ -21,7 +21,8 @@ Source0: %{name}-%{version}.tar.gz BuildRequires: cargo BuildRequires: rust >= 1.89 # nodejs/npm are required to compile the OpenClaw TS plugin -> dist/index.js -# via `make build-openclaw-plugin`. Provided by CI; declared here for chroot +# via `make build-openclaw-plugin`. The native dsh bundle is plain ESM and is +# validated by `make build-dsh-plugin`. Provided by CI; declared here for chroot # builds where they are not pre-installed. BuildRequires: nodejs BuildRequires: npm @@ -50,6 +51,9 @@ The package includes: - toon: JSON to TOON format encoder/decoder (crates.io toon-format v0.5.0) Note: OpenClaw plugin is available under /usr/share/anolisa/adapters/tokenless/openclaw/. +Native DeepSeek Harness (dsh) plugin bundle is available under +/usr/share/anolisa/adapters/tokenless/dsh/. Register it with +`anolisa adapter enable tokenless dsh --profile `. Copilot-shell extension is auto-discovered from /usr/share/anolisa/extensions/tokenless/. Hermes Agent plugin (response compression, TOON encoding, command rewriting via RTK, and Tool Ready) is available under /usr/share/anolisa/adapters/tokenless/hermes/. @@ -93,7 +97,7 @@ fi # Produces adapters/tokenless/openclaw/dist/index.js, which package.json # declares as both "main" and openclaw.extensions. Mirroring sec-core's # build-openclaw-plugin pattern — never hand-roll tsc/esbuild here. -make build-openclaw-plugin +make build-openclaw-plugin build-dsh-plugin %install rm -rf %{buildroot} @@ -103,6 +107,7 @@ mkdir -p %{buildroot}%{_datadir}/anolisa/adapters/tokenless/common/hooks mkdir -p %{buildroot}%{_datadir}/anolisa/adapters/tokenless/common/commands mkdir -p %{buildroot}%{_datadir}/anolisa/adapters/tokenless/openclaw/scripts mkdir -p %{buildroot}%{_datadir}/anolisa/adapters/tokenless/openclaw/dist +mkdir -p %{buildroot}%{_datadir}/anolisa/adapters/tokenless/dsh/dist mkdir -p %{buildroot}%{_datadir}/anolisa/adapters/tokenless/hermes/scripts mkdir -p %{buildroot}%{_datadir}/anolisa/adapters/tokenless/qoder/.qoder-plugin mkdir -p %{buildroot}%{_datadir}/anolisa/adapters/tokenless/qoder/scripts @@ -166,6 +171,12 @@ install -m 0644 adapters/tokenless/openclaw/dist/index.js %{buildroot}%{_datadir install -m 0644 adapters/tokenless/openclaw/openclaw.plugin.json %{buildroot}%{_datadir}/anolisa/adapters/tokenless/openclaw/ install -m 0644 adapters/tokenless/openclaw/package.json %{buildroot}%{_datadir}/anolisa/adapters/tokenless/openclaw/ +# Install the native dsh bundle. dsh owns profile mutation; this package only +# ships the immutable package manifest, Cordis patch, and plugin entry. +install -m 0644 adapters/tokenless/dsh/package.json %{buildroot}%{_datadir}/anolisa/adapters/tokenless/dsh/ +install -m 0644 adapters/tokenless/dsh/cordis.patch.yml %{buildroot}%{_datadir}/anolisa/adapters/tokenless/dsh/ +install -m 0644 adapters/tokenless/dsh/dist/index.js %{buildroot}%{_datadir}/anolisa/adapters/tokenless/dsh/dist/ + # Install Hermes Agent plugin (Python hooks + install scripts) install -m 0755 adapters/tokenless/hermes/__init__.py %{buildroot}%{_datadir}/anolisa/adapters/tokenless/hermes/ install -m 0644 adapters/tokenless/hermes/plugin.yaml %{buildroot}%{_datadir}/anolisa/adapters/tokenless/hermes/ @@ -253,6 +264,8 @@ install -m 0644 adapters/tokenless/common/commands/tokenless-stats.toml %{buildr %dir %{_datadir}/anolisa/adapters/tokenless/openclaw %dir %{_datadir}/anolisa/adapters/tokenless/openclaw/scripts %dir %{_datadir}/anolisa/adapters/tokenless/openclaw/dist +%dir %{_datadir}/anolisa/adapters/tokenless/dsh +%dir %{_datadir}/anolisa/adapters/tokenless/dsh/dist %dir %{_datadir}/anolisa/adapters/tokenless/hermes %dir %{_datadir}/anolisa/adapters/tokenless/hermes/scripts %dir %{_datadir}/anolisa/adapters/tokenless/qoder @@ -288,6 +301,9 @@ install -m 0644 adapters/tokenless/common/commands/tokenless-stats.toml %{buildr %attr(0644,root,root) %{_datadir}/anolisa/adapters/tokenless/openclaw/dist/index.js %attr(0644,root,root) %{_datadir}/anolisa/adapters/tokenless/openclaw/openclaw.plugin.json %attr(0644,root,root) %{_datadir}/anolisa/adapters/tokenless/openclaw/package.json +%attr(0644,root,root) %{_datadir}/anolisa/adapters/tokenless/dsh/package.json +%attr(0644,root,root) %{_datadir}/anolisa/adapters/tokenless/dsh/cordis.patch.yml +%attr(0644,root,root) %{_datadir}/anolisa/adapters/tokenless/dsh/dist/index.js %attr(0755,root,root) %{_datadir}/anolisa/adapters/tokenless/hermes/__init__.py %attr(0644,root,root) %{_datadir}/anolisa/adapters/tokenless/hermes/plugin.yaml %attr(0755,root,root) %{_datadir}/anolisa/adapters/tokenless/hermes/scripts/detect.sh