dsh-acp-enhanced 0.3.5 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README-zh.md CHANGED
@@ -20,7 +20,10 @@ ACP 线上。
20
20
  ### 模型与权限
21
21
 
22
22
  - **模型切换**:实时 `provider/model` 目录下拉(按 ACP 规范分组线格式)
23
- - **推理强度**:`reasoning_effort` 下拉——仅当当前路由暴露可选 efforts 时出现
23
+ - **推理强度**:`reasoning_effort` 下拉——仅当当前路由暴露可选 efforts 时出现;
24
+ 每个模型都会记住它上次使用的强度(按 profile 持久化),切回时自动恢复,
25
+ 首次切换的模型则回退到它自己的默认值——没有默认值时取第一个可选值,
26
+ 绝不出现空的 "unknown" 选择
24
27
  - **权限预设**:read-only / workspace-write / full-access 三种会话模式
25
28
  - **审批**:工具调用弹出原生 allow-once / reject-once 审批
26
29
 
@@ -196,7 +199,7 @@ dsh plugin --profile acp-enhanced add dsh-web-search-openrouter
196
199
  |---|---|
197
200
  | `exec: dsh: not found`(status 127) | 用随附 `dsh-acp-zed.sh` 启动器(自定位 node/dsh) |
198
201
  | `no API key for provider route "xxx"` | 写入 `~/.dsh/.credentials.yaml`,或在 agent_servers 里设 `env.DEEPSEEK_API_KEY` |
199
- | 无法切换模型 | 保存的 `reasoning_effort` 默认值(或会话当前 effort)被带到新模型上;0.3.3 起新模型不支持的 effort 会自动回退为该模型默认值,不再让整个切换失败。另检查:是否选到了不可路由的"幽灵 provider"——本桥默认过滤(只广播 `config.provider` 的模型),确认 profile 的 provider 指向真实路由 |
202
+ | 无法切换模型 | 保存的 `reasoning_effort` 默认值(或会话当前 effort)被带到新模型上。0.3.6 起本桥按模型记住上次使用的强度(随 profile 持久化):不被新模型支持的 effort 会被该模型记忆值替换——没有记忆则回退其默认值,再无默认则取第一个可选值,既不会切换失败也不会出现 "unknown"。另检查:是否选到了不可路由的"幽灵 provider"——本桥默认过滤(只广播 `config.provider` 的模型),确认 profile 的 provider 指向真实路由 |
200
203
  | 上下文用量不显示 | 选到了不可路由的"幽灵 provider";本桥默认过滤(只广播 `config.provider` 的模型),确认 profile 的 provider 指向真实路由 |
201
204
  | 需要详细诊断 | `ACP_DEBUG=1 dsh --profile acp-enhanced`(stderr 生命周期 trace) |
202
205
 
package/README.md CHANGED
@@ -23,7 +23,9 @@ over the ACP wire.
23
23
 
24
24
  - **Model switching**: live `provider/model` catalog dropdown (ACP grouped-select wire shape)
25
25
  - **Reasoning effort**: `reasoning_effort` dropdown — only when the routed model exposes
26
- selectable efforts
26
+ selectable efforts; each model remembers the effort it last used (persisted per profile),
27
+ so switching back restores it, and a first-time model falls back to its own default — or
28
+ its first offered effort — instead of an empty "unknown" selection
27
29
  - **Permission presets**: read-only / workspace-write / full-access session modes
28
30
  - **Approval**: native allow-once / reject-once prompts per tool call
29
31
 
@@ -215,7 +217,7 @@ dsh plugin --profile acp-enhanced add dsh-web-search-openrouter
215
217
  |---|---|
216
218
  | `exec: dsh: not found` (status 127) | Use the shipped `dsh-acp-zed.sh` launcher (locates node/dsh itself) |
217
219
  | `no API key for provider route "xxx"` | Write `~/.dsh/.credentials.yaml`, or set `env.DEEPSEEK_API_KEY` on the agent_servers entry |
218
- | Cannot switch models | The saved `reasoning_effort` default (or the session's current effort) is carried onto the new model; since 0.3.3 an unsupported effort resets to the model's default instead of failing the switch. Also check: a "phantom provider" route was picked — this bridge filters them by default (only `config.provider`'s models are advertised), so point the profile's provider at a real route |
220
+ | Cannot switch models | The saved `reasoning_effort` default (or the session's current effort) is carried onto the new model. Since 0.3.6 the bridge remembers the last effort per model (per-profile JSON): an unsupported carried effort is replaced by that model's remembered effort, else its own default, else its first offered effort — never an "unknown" dropdown, never a failed switch. Also check: a "phantom provider" route was picked — this bridge filters them by default (only `config.provider`'s models are advertised), so point the profile's provider at a real route |
219
221
  | Context usage missing | A "phantom provider" route was picked; this bridge filters them by default (only `config.provider`'s models are advertised) — point the profile's provider at a real route |
220
222
  | Need detailed diagnostics | `ACP_DEBUG=1 dsh --profile acp-enhanced` (stderr lifecycle trace) |
221
223
 
package/lib/index.js CHANGED
@@ -36,9 +36,10 @@
36
36
  */
37
37
 
38
38
  import { randomUUID } from 'node:crypto'
39
- import { rm } from 'node:fs/promises'
39
+ import { readFileSync } from 'node:fs'
40
+ import { rm, writeFile } from 'node:fs/promises'
40
41
  import { createRequire } from 'node:module'
41
- import { isAbsolute, dirname, resolve } from 'node:path'
42
+ import { isAbsolute, dirname, join, resolve } from 'node:path'
42
43
  import { Readable, Writable } from 'node:stream'
43
44
  import Schema from '@deepseek-ai/schemastery'
44
45
  import { AgentSideConnection, ndJsonStream, PROTOCOL_VERSION, RequestError } from '@agentclientprotocol/sdk'
@@ -52,6 +53,55 @@ import { acpPromptToText, promptHasUnsupportedContent, sanitizeWireTitle, turnEn
52
53
  * handshake can never drift from the released package version. */
53
54
  const AGENT_VERSION = createRequire(import.meta.url)('../package.json').version
54
55
 
56
+ /** State-kept per-model reasoning-effort memory (`provider/model` → effort
57
+ * id). A model switch carries the session's current effort onto the new
58
+ * model; when the target does not offer it, the effort this bridge last
59
+ * successfully applied to that route is restored instead — and switching
60
+ * back restores the effort that model had before, so the editor's dropdown
61
+ * never falls back to an empty ("unknown") selection. Persisted as a small
62
+ * JSON file next to the profile (the shipped Zed launcher exports
63
+ * DSH_ACP_PROFILE_DIR); without that variable the memory simply lives for
64
+ * the process. */
65
+ const effortMemoryFile = process.env.DSH_ACP_PROFILE_DIR !== undefined && process.env.DSH_ACP_PROFILE_DIR.length > 0
66
+ ? join(process.env.DSH_ACP_PROFILE_DIR, 'dsh-acp-enhanced-effort-memory.json')
67
+ : undefined
68
+
69
+ function loadEffortMemory() {
70
+ if (effortMemoryFile === undefined) return {}
71
+ try {
72
+ const parsed = JSON.parse(readFileSync(effortMemoryFile, 'utf8'))
73
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed
74
+ } catch (error) {
75
+ if (error?.code !== 'ENOENT') {
76
+ console.warn(`acp-enhanced: effort memory unreadable (${effortMemoryFile}): ${String(error)}`)
77
+ }
78
+ }
79
+ return {}
80
+ }
81
+
82
+ const effortMemory = loadEffortMemory()
83
+
84
+ function writeEffortMemory() {
85
+ if (effortMemoryFile === undefined) return
86
+ writeFile(effortMemoryFile, JSON.stringify(effortMemory, null, 2)).catch((error) => {
87
+ console.warn(`acp-enhanced: effort memory write failed (${effortMemoryFile}): ${String(error)}`)
88
+ })
89
+ }
90
+
91
+ /** The effort last successfully applied to a route, when one was recorded. */
92
+ function rememberedEffort(provider, model) {
93
+ const effort = effortMemory[`${provider}/${model}`]
94
+ return typeof effort === 'string' && effort.length > 0 ? effort : undefined
95
+ }
96
+
97
+ /** Record the effort applied to a route, persisting when possible. */
98
+ function rememberEffort(provider, model, effort) {
99
+ const key = `${provider}/${model}`
100
+ if (effortMemory[key] === effort) return
101
+ effortMemory[key] = effort
102
+ writeEffortMemory()
103
+ }
104
+
55
105
  export const name = 'acp-enhanced'
56
106
  /** The bridge creates and owns agents; every other concern is carried by the composition. */
57
107
  export const inject = ['agents', 'llm', 'approval', 'tools', 'commands', 'systemPrompt']
@@ -858,7 +908,11 @@ export function apply(ctx, config) {
858
908
  name: 'Reasoning effort',
859
909
  description: 'Reasoning level applied to model requests for this session.',
860
910
  category: 'thought_level',
861
- currentValue: selected.reasoningEffort ?? modelInfo?.reasoning?.defaultEffort ?? '',
911
+ // The chain mirrors applySelection: selected effort, the model's own
912
+ // default, then the first offered effort — a non-empty selection
913
+ // unless the model declares no reasoning at all. This backstop keeps
914
+ // the editor's dropdown from ever showing an "unknown" effort.
915
+ currentValue: selected.reasoningEffort ?? modelInfo?.reasoning?.defaultEffort ?? efforts[0]?.id ?? '',
862
916
  options: efforts.map((effort) => ({
863
917
  value: effort.id,
864
918
  name: effort.name,
@@ -901,28 +955,49 @@ export function apply(ctx, config) {
901
955
 
902
956
  /** Resolve and apply a new model/effort selection to the session. */
903
957
  async function applySelection(record, next) {
904
- let resolved
958
+ // Per-model effort restoration. An explicit effort (Zed re-applies its
959
+ // saved default_config_options on every session, and a model switch
960
+ // carries the session's current effort onto the newly picked model) wins
961
+ // when the target offers it. Unsupported efforts are not just dropped:
962
+ // the effort last successfully applied to this route is restored, and a
963
+ // first-time route falls back to its own default — or, when it declares
964
+ // none, to the FIRST effort it offers — so the selection never carries an
965
+ // empty effort that renders as "unknown" in the editor's dropdown. A
966
+ // model without reasoning metadata resolves without an effort.
967
+ let info
905
968
  try {
906
- resolved = await llm.resolveCallConfig(next)
907
- } catch (error) {
908
- // A reasoning effort can outlive the model that supported it: Zed
909
- // re-applies its saved `default_config_options` on every session, and
910
- // switching models carries the session's current effort onto the newly
911
- // picked model, which may not offer it (glm-* and gpt-5.6-luna reject
912
- // "max"). Rejecting the whole switch snapped Zed's dropdown back and
913
- // made model switching itself appear broken, so drop the stale effort
914
- // and resolve against the target model's own default instead. Any other
915
- // failure (unknown route, missing credential) still surfaces.
916
- if (error?.code !== 'UNSUPPORTED_REASONING_EFFORT' || next.reasoningEffort === undefined) throw error
917
- logger.warn(`acp-enhanced: dropped reasoning effort "${next.reasoningEffort}" for ${next.provider}/${next.model} (unsupported); using the model default`)
918
- resolved = await llm.resolveCallConfig({ ...next, reasoningEffort: undefined })
969
+ info = await llm.resolveModelInfo(next.provider, next.model)
970
+ } catch {
971
+ info = undefined
919
972
  }
973
+ const efforts = info?.reasoning?.efforts ?? []
974
+ let candidate = next.reasoningEffort
975
+ if (candidate !== undefined && !efforts.some((effort) => effort.id === candidate)) {
976
+ logger.warn(`acp-enhanced: dropped reasoning effort "${candidate}" for ${next.provider}/${next.model} (unsupported)`)
977
+ candidate = undefined
978
+ }
979
+ if (candidate === undefined && efforts.length > 0) {
980
+ const remembered = rememberedEffort(next.provider, next.model)
981
+ candidate = remembered !== undefined && efforts.some((effort) => effort.id === remembered)
982
+ ? remembered
983
+ : info?.reasoning?.defaultEffort ?? efforts[0].id
984
+ }
985
+ const resolved = await llm.resolveCallConfig(
986
+ candidate === undefined
987
+ ? { provider: next.provider, model: next.model }
988
+ : { ...next, reasoningEffort: candidate },
989
+ )
920
990
  const selected = {
921
991
  provider: resolved.provider,
922
992
  model: resolved.model,
923
993
  ...resolved.reasoningEffort === undefined ? {} : { reasoningEffort: resolved.reasoningEffort },
924
994
  }
925
995
  record.selection.current = selected
996
+ // Remember the effective effort for this route so a later switch back
997
+ // restores it (a model without reasoning metadata records nothing).
998
+ if (selected.reasoningEffort !== undefined) {
999
+ rememberEffort(selected.provider, selected.model, selected.reasoningEffort)
1000
+ }
926
1001
  // Persist as the default only when the chosen route is one this bridge is
927
1002
  // wired to serve. On a single-provider machine the default route is the
928
1003
  // routable one, so switching within it always persists; a stray cross-provider
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-acp-enhanced",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
4
4
  "description": "Enhanced ACP server for DeepSeek Harness: block-level streaming, usage/stat telemetry (cache hit rate, token speed, input/output tokens, context length, turns, tool timing), model & reasoning-effort switching, and permission-preset control over the ACP wire (Zed-friendly)",
5
5
  "keywords": [
6
6
  "dsh",
@@ -61,4 +61,15 @@ if [ -z "${DEEPSEEK_API_KEY:-}" ]; then
61
61
  fi
62
62
  fi
63
63
 
64
+ # Derive the profile root from this launcher's own location:
65
+ # …/profiles/<name>/node_modules/<pkg>/scripts/dsh-acp-zed.sh
66
+ # — three levels above this script is the profile dir. pwd -L keeps the
67
+ # logical path (pnpm lays out node_modules as symlinks), so the climb lands
68
+ # on the profile root. The bridge persists small per-model state (the last
69
+ # reasoning effort per model) next to the profile's own files.
70
+ if [ -z "${DSH_ACP_PROFILE_DIR:-}" ]; then
71
+ ACP_LAUNCHER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -L)"
72
+ export DSH_ACP_PROFILE_DIR="$(cd "${ACP_LAUNCHER_DIR}/../../.." && pwd -L)"
73
+ fi
74
+
64
75
  exec "${DASH_BIN}" --profile acp-enhanced "$@"