dsh-plan-completion 0.1.5 → 0.1.7

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/CHANGELOG.md CHANGED
@@ -3,6 +3,33 @@
3
3
  All notable changes to this project are documented here.
4
4
  Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versioning: SemVer.
5
5
 
6
+ ## [0.1.7] - 2026-11-02
7
+
8
+ ### Fixed
9
+ - Settings page model lists now load per provider (`GET /settings?provider=…`), so third-party
10
+ providers' models appear after switching Provider; fixed an internal error caused by a
11
+ provider-scope bug in the settings snapshot.
12
+
13
+ ### Changed
14
+ - Settings page redesign matched to DSH General Settings: flat 66px rows (title + description left,
15
+ control right), filled controls without borders, DSH-sized toggle, right-aligned save button,
16
+ unified typography/theme tokens for light and dark themes.
17
+
18
+ ## [0.1.6] - 2026-11-02
19
+
20
+ ### Added
21
+ - Model settings page registered at `settings.plugins.tab` (Settings → Plugins → 计划补全):
22
+ radio group for `default` / `fixed` / `auto` model policy, Provider/Model ID inputs for fixed
23
+ mode, a `modelCalls` toggle, current host-default route hint, and save/status feedback.
24
+ - Host settings route `GET/POST /plan-completion/settings` (authenticated) persists the policy to
25
+ `<storageDir>/settings.json` and loads it on apply; `pickModel` honours `default`/`fixed`/`auto`.
26
+ - Client transport test for the settings page (GET/POST, failure handling).
27
+ - Dev-only suggestion log lives outside the release: `research/suggestion-log-local/` keeps a local
28
+ copy for tuning; the published plugin does not contain or enable suggestion-content logging.
29
+
30
+ ### Changed
31
+ - `npm test` now reports **138/138** tests.
32
+
6
33
  ## [0.1.5] - 2026-11-02
7
34
 
8
35
  ### Fixed
@@ -136,3 +163,5 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versioning: Se
136
163
  - Debug endpoints (`theme`, `experiment`, `autojob`, `steps`, `reset`) are only registered when
137
164
  `enableDebugRoutes: true`; production endpoints are `state`, `debug`, `diag`, `replan`, `warm`.
138
165
  - No credentials are stored in the plugin; model calls use the host `llm` service.
166
+
167
+
package/client.js CHANGED
@@ -105,7 +105,25 @@ window.__ModuleLoader__.load({
105
105
  'verify.clean': '未发现明显差异。',
106
106
  'verify.next': '建议:{value}',
107
107
  'verify.failed': '核对失败:{value}',
108
- 'verify.no-session': '当前还没有可用会话'
108
+ 'verify.no-session': '当前还没有可用会话',
109
+ 'settings.title': '计划补全',
110
+ 'settings.model': '模型选择',
111
+ 'settings.policy.default': '跟随宿主默认',
112
+ 'settings.policy.fixed': '固定模型',
113
+ 'settings.policy.auto': '自动(优先非推理)',
114
+ 'settings.provider': 'Provider',
115
+ 'settings.modelId': 'Model ID',
116
+ 'settings.modelCalls': '允许本插件调用模型',
117
+ 'settings.modelDesc': '选择插件生成计划与建议时使用的模型来源',
118
+ 'settings.providerDesc': '固定使用的 provider',
119
+ 'settings.callsDesc': '关闭后插件只使用启发式,不向模型发送内容',
120
+ 'settings.defaultRoute': '宿主默认:{value}',
121
+ 'settings.save': '保存',
122
+ 'settings.saving': '保存中…',
123
+ 'settings.saved': '已保存',
124
+ 'settings.loading': '加载中…',
125
+ 'settings.error': '设置加载失败',
126
+ 'settings.saveError': '保存失败'
109
127
  },
110
128
  en: {
111
129
  'entry.aria': 'Open the session plan panel',
@@ -148,7 +166,25 @@ window.__ModuleLoader__.load({
148
166
  'verify.clean': 'No obvious mismatches found.',
149
167
  'verify.next': 'Next: {value}',
150
168
  'verify.failed': 'Verification failed: {value}',
151
- 'verify.no-session': 'No session available yet'
169
+ 'verify.no-session': 'No session available yet',
170
+ 'settings.title': 'Plan Completion',
171
+ 'settings.model': 'Model selection',
172
+ 'settings.policy.default': 'Follow host default',
173
+ 'settings.policy.fixed': 'Fixed model',
174
+ 'settings.policy.auto': 'Auto (prefer non-reasoning)',
175
+ 'settings.provider': 'Provider',
176
+ 'settings.modelId': 'Model ID',
177
+ 'settings.modelCalls': 'Allow this plugin to call models',
178
+ 'settings.modelDesc': 'Model source used for plan and suggestion generation',
179
+ 'settings.providerDesc': 'Provider used by the fixed policy',
180
+ 'settings.callsDesc': 'When off, the plugin only uses heuristics and sends nothing',
181
+ 'settings.defaultRoute': 'Host default: {value}',
182
+ 'settings.save': 'Save',
183
+ 'settings.saving': 'Saving…',
184
+ 'settings.saved': 'Saved',
185
+ 'settings.loading': 'Loading…',
186
+ 'settings.error': 'Failed to load settings',
187
+ 'settings.saveError': 'Save failed'
152
188
  }
153
189
  };
154
190
 
@@ -787,6 +823,53 @@ window.__ModuleLoader__.load({
787
823
  });
788
824
  }
789
825
 
826
+ /**
827
+ * Host settings used by the settings page (model policy + modelCalls).
828
+ */
829
+ function fetchSettings(provider) {
830
+ try {
831
+ const query = typeof provider === 'string' && provider ? '?provider=' + encodeURIComponent(provider) : '';
832
+ return Promise.resolve(fetch('/plan-completion/settings' + query, {
833
+ method: 'GET',
834
+ credentials: 'same-origin',
835
+ headers: { accept: 'application/json' }
836
+ }))
837
+ .then(function readResponse(response) {
838
+ return response && response.ok === true ? Promise.resolve(response.json()) : null;
839
+ })
840
+ .then(function parsed(json) {
841
+ return json && typeof json === 'object' ? json : null;
842
+ })
843
+ .catch(function networkError() {
844
+ return null;
845
+ });
846
+ } catch (error) {
847
+ return Promise.resolve(null);
848
+ }
849
+ }
850
+
851
+ function saveSettings(payload) {
852
+ try {
853
+ return Promise.resolve(fetch('/plan-completion/settings', {
854
+ method: 'POST',
855
+ credentials: 'same-origin',
856
+ headers: { 'content-type': 'application/json' },
857
+ body: JSON.stringify(payload || {})
858
+ }))
859
+ .then(function readResponse(response) {
860
+ return response && response.ok === true ? Promise.resolve(response.json()) : null;
861
+ })
862
+ .then(function parsed(json) {
863
+ return json && typeof json === 'object' ? json : null;
864
+ })
865
+ .catch(function networkError() {
866
+ return null;
867
+ });
868
+ } catch (error) {
869
+ return Promise.resolve(null);
870
+ }
871
+ }
872
+
790
873
  /**
791
874
  * P0 boundary: read the current plan state. Keep this signature stable —
792
875
  * swapping the transport (HTTP route / projection / wire view) only changes
@@ -2055,6 +2138,146 @@ window.__ModuleLoader__.load({
2055
2138
  );
2056
2139
  }
2057
2140
 
2141
+ // =====================================================================
2142
+ // Settings page (settings.plugins.tab)
2143
+ // =====================================================================
2144
+ const SETTINGS_SLOT = 'settings.plugins.tab';
2145
+ const SETTINGS_ID = 'plan-completion-settings';
2146
+ const SETTINGS_ORDER = 60;
2147
+
2148
+ function settingsCss() {
2149
+ return [
2150
+ '.dpc-settings-page{display:flex;flex-direction:column;padding:8px 24px 20px;max-width:720px;box-sizing:border-box;font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary)}',
2151
+ '.dpc-settings-head{display:flex;flex-direction:column;gap:4px;padding:6px 0 10px}',
2152
+ '.dpc-settings-title{font-size:16px;font-weight:600;line-height:26px}',
2153
+ '.dpc-settings-sub{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}',
2154
+ '.dpc-settings-row{display:flex;align-items:center;justify-content:space-between;gap:24px;min-height:66px;padding:12px 0;box-sizing:border-box;border-bottom:1px solid var(--dsw-alias-border-l1)}',
2155
+ '.dpc-settings-row:last-of-type{border-bottom:0}',
2156
+ '.dpc-settings-row-main{display:flex;flex-direction:column;gap:3px;min-width:0}',
2157
+ '.dpc-settings-row-label{font-size:14px;line-height:22px;color:var(--dsw-alias-label-primary)}',
2158
+ '.dpc-settings-row-desc{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}',
2159
+ '.dpc-settings-control{flex:none;display:flex;justify-content:flex-end;align-items:center;min-width:132px;max-width:240px}',
2160
+ '.dpc-settings-select{width:100%;border:1px solid transparent;border-radius:8px;padding:7px 12px;font-size:13px;line-height:20px;background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-layer-1));color:var(--dsw-alias-label-primary);cursor:pointer;appearance:auto}',
2161
+ '.dpc-settings-select:hover{background:var(--dsw-alias-bg-layer-1)}',
2162
+ '.dpc-settings-select:focus{outline:none;border-color:var(--dsw-alias-brand-primary,var(--dsw-alias-state-business-primary))}',
2163
+ '.dpc-settings-toggle{width:40px;height:24px;border-radius:999px;border:0;background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-layer-1));position:relative;cursor:pointer;padding:0;flex:none}',
2164
+ '.dpc-settings-toggle[data-on="true"]{background:var(--dsw-alias-brand-primary,var(--dsw-alias-state-business-primary))}',
2165
+ '.dpc-settings-toggle span{position:absolute;top:2px;left:2px;width:20px;height:20px;border-radius:999px;background:#fff;transition:left .12s ease;box-shadow:0 1px 2px rgba(0,0,0,.18)}',
2166
+ '.dpc-settings-toggle[data-on="true"] span{left:18px}',
2167
+ '.dpc-settings-footer{display:flex;align-items:center;justify-content:flex-end;gap:12px;padding-top:18px}',
2168
+ '.dpc-settings-status{font-size:12px;line-height:18px;color:var(--dsw-alias-state-success-primary,var(--dsw-alias-label-secondary));margin-right:auto}',
2169
+ '.dpc-settings-error{font-size:12px;line-height:18px;color:var(--dsw-alias-state-error-primary,var(--dsw-alias-label-secondary));margin-right:auto}',
2170
+ '.dpc-settings-save{border:0;border-radius:8px;padding:7px 16px;font-size:13px;line-height:20px;cursor:pointer;background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-layer-1));color:var(--dsw-alias-label-primary);flex:none}',
2171
+ '.dpc-settings-save:hover{background:var(--dsw-alias-bg-layer-1)}',
2172
+ '.dpc-settings-save:disabled{opacity:.5;cursor:default}',
2173
+ '.dpc-settings-loading{padding:18px 24px;font-size:13px;color:var(--dsw-alias-label-secondary)}'
2174
+ ].join('\n');
2175
+ }
2176
+
2177
+ function PlanSettingsPage(props) {
2178
+ const t = makeT(props);
2179
+ const [settings, setSettings] = React.useState(null);
2180
+ const [status, setStatus] = React.useState('loading');
2181
+ const [saving, setSaving] = React.useState(false);
2182
+ const [error, setError] = React.useState('');
2183
+ const [saved, setSaved] = React.useState(false);
2184
+
2185
+ React.useEffect(function load() {
2186
+ let alive = true;
2187
+ fetchSettings().then(function applied(result) {
2188
+ if (!alive) return;
2189
+ if (!result || result.ok !== true || !result.settings) { setStatus('error'); return; }
2190
+ setSettings(result.settings);
2191
+ setStatus('ready');
2192
+ });
2193
+ return function cleanup() { alive = false; };
2194
+ }, []);
2195
+
2196
+ function patch(next) {
2197
+ setSaved(false);
2198
+ setSettings(function keep(previous) { return previous ? { ...previous, ...next } : previous; });
2199
+ }
2200
+ function chooseProvider(provider) {
2201
+ patch({ fixedProvider: provider, fixedModel: '', models: [] });
2202
+ fetchSettings(provider).then(function applied(result) {
2203
+ if (result && result.ok === true && result.settings) patch({ models: result.settings.models || [] });
2204
+ });
2205
+ }
2206
+ function save() {
2207
+ if (!settings || saving) return;
2208
+ setSaving(true); setError('');
2209
+ saveSettings({
2210
+ modelPolicy: settings.modelPolicy,
2211
+ fixedProvider: settings.fixedProvider || '',
2212
+ fixedModel: settings.fixedModel || '',
2213
+ modelCalls: settings.modelCalls === true
2214
+ }).then(function applied(result) {
2215
+ setSaving(false);
2216
+ if (!result || result.ok !== true) {
2217
+ const detail = result && typeof result.error === 'string' ? result.error : '';
2218
+ setError(detail ? t('settings.saveError') + ': ' + detail : t('settings.saveError'));
2219
+ return;
2220
+ }
2221
+ setSettings(result.settings || settings);
2222
+ setSaved(true); setError('');
2223
+ });
2224
+ }
2225
+
2226
+ const styleTag = h('style', { key: 'style', 'data-plan-completion-style': 'settings' }, settingsCss());
2227
+ if (status === 'loading') return h(React.Fragment, null, styleTag, h('div', { key: 'loading', className: 'dpc-settings-loading' }, t('settings.loading')));
2228
+ if (status === 'error' || !settings) return h(React.Fragment, null, styleTag, h('div', { key: 'error', className: 'dpc-settings-loading dpc-settings-error' }, t('settings.error')));
2229
+
2230
+ const defaultRoute = settings.defaultSelection ? settings.defaultSelection.provider + '/' + settings.defaultSelection.model : '-';
2231
+ const providers = settings.providers || [];
2232
+ const models = settings.models || [];
2233
+ const row = function row(key, label, desc, control) {
2234
+ return h('div', { key: key, className: 'dpc-settings-row' },
2235
+ h('div', { key: 'main', className: 'dpc-settings-row-main' },
2236
+ h('div', { key: 'label', className: 'dpc-settings-row-label' }, label),
2237
+ desc ? h('div', { key: 'desc', className: 'dpc-settings-row-desc' }, desc) : null),
2238
+ h('div', { key: 'control', className: 'dpc-settings-control' }, control));
2239
+ };
2240
+
2241
+ return h(React.Fragment, null, styleTag,
2242
+ h('div', { key: 'page', className: 'dpc-settings-page' },
2243
+ h('div', { key: 'head', className: 'dpc-settings-head' },
2244
+ h('div', { key: 'title', className: 'dpc-settings-title' }, t('settings.title')),
2245
+ h('div', { key: 'sub', className: 'dpc-settings-sub' }, t('settings.defaultRoute', { value: defaultRoute }))),
2246
+ row('policy', t('settings.model'), t('settings.modelDesc'),
2247
+ h('select', { className: 'dpc-settings-select', value: settings.modelPolicy,
2248
+ onChange: function onPolicy(event) { patch({ modelPolicy: event.target.value }); } },
2249
+ [h('option', { key: 'default', value: 'default' }, t('settings.policy.default')),
2250
+ h('option', { key: 'fixed', value: 'fixed' }, t('settings.policy.fixed')),
2251
+ h('option', { key: 'auto', value: 'auto' }, t('settings.policy.auto'))])),
2252
+ settings.modelPolicy === 'fixed'
2253
+ ? row('provider', t('settings.provider'), t('settings.providerDesc'),
2254
+ h('select', { className: 'dpc-settings-select', value: settings.fixedProvider || '',
2255
+ onChange: function onProvider(event) { chooseProvider(event.target.value); } },
2256
+ [h('option', { key: 'empty', value: '' }, '—')].concat(providers.map(function option(p) {
2257
+ return h('option', { key: p.id, value: p.id }, p.name || p.id);
2258
+ }))))
2259
+ : null,
2260
+ settings.modelPolicy === 'fixed'
2261
+ ? row('model', t('settings.modelId'), t('settings.modelDesc'),
2262
+ h('select', { className: 'dpc-settings-select', value: settings.fixedModel || '',
2263
+ onChange: function onModel(event) { patch({ fixedModel: event.target.value }); } },
2264
+ [h('option', { key: 'empty', value: '' }, '—')].concat(models.map(function option(m) {
2265
+ return h('option', { key: m.id, value: m.id }, m.name || m.id);
2266
+ }))))
2267
+ : null,
2268
+ row('calls', t('settings.modelCalls'), t('settings.callsDesc'),
2269
+ h('button', { type: 'button', className: 'dpc-settings-toggle', 'data-on': settings.modelCalls === true ? 'true' : 'false',
2270
+ 'aria-pressed': settings.modelCalls === true ? 'true' : 'false',
2271
+ onClick: function onCalls() { patch({ modelCalls: settings.modelCalls !== true }); } },
2272
+ h('span', { key: 'knob' }))),
2273
+ h('div', { key: 'footer', className: 'dpc-settings-footer' },
2274
+ error ? h('div', { key: 'e', className: 'dpc-settings-error' }, error)
2275
+ : saved ? h('div', { key: 's', className: 'dpc-settings-status' }, t('settings.saved')) : null,
2276
+ h('button', { key: 'save', type: 'button', className: 'dpc-settings-save', disabled: saving, onClick: save },
2277
+ saving ? t('settings.saving') : t('settings.save')))));
2278
+ }
2279
+
2280
+
2058
2281
  // =====================================================================
2059
2282
  // Plugin entry
2060
2283
  // =====================================================================
@@ -2090,6 +2313,13 @@ window.__ModuleLoader__.load({
2090
2313
  return ctx.slots.register({ name: OVERLAY_SLOT, id: OVERLAY_ID, order: OVERLAY_ORDER }, PlanPopover);
2091
2314
  });
2092
2315
 
2316
+ ctx.slots.inject(SETTINGS_SLOT, function registerSettingsPage() {
2317
+ return ctx.slots.register(
2318
+ { name: SETTINGS_SLOT, id: SETTINGS_ID, order: SETTINGS_ORDER, label: '计划补全' },
2319
+ PlanSettingsPage
2320
+ );
2321
+ });
2322
+
2093
2323
  ctx.effect(function installKeyboard() {
2094
2324
  if (typeof window === 'undefined') return undefined;
2095
2325
  const onKeyDown = function onKeyDown(event) {
@@ -2181,3 +2411,8 @@ window.__ModuleLoader__.load({
2181
2411
  // hot-reload marker 2026-11-01T13:57Z
2182
2412
 
2183
2413
 
2414
+
2415
+
2416
+
2417
+
2418
+
@@ -1,7 +1,11 @@
1
1
  # GitHub 发布评估 · 会话计划补全插件(2026-11-02)
2
2
 
3
- > 历史文档:以下保留 v0.1.0 的交付/评估/计划记录。**当前最新为 v0.1.4**(建议文本保真 + 核对进度 + 本地计划持久化 + 鉴权/状态一致性/隐私边界修复),详见 [security.md](security.md) 与 [CHANGELOG](../CHANGELOG.md);旧记录中的“无鉴权”、原始诊断字段与独立评审不代表当前实现。
3
+ > 历史文档:以下保留 v0.1.0 的交付/评估/计划记录。**当前最新为 v0.1.6**(计划停更检测 + 建议文本保真 + 核对进度 + 本地计划持久化 + 鉴权/状态一致性/隐私边界修复),详见 [security.md](security.md) 与 [CHANGELOG](../CHANGELOG.md);旧记录中的“无鉴权”、原始诊断字段与独立评审不代表当前实现。
4
4
  >
5
+ > v0.1.6 发布记录(2026-11-02):模型设置页(settings.plugins.tab)+ settings 持久化;Tab 建议日志仅本地不发布。tag `v0.1.6`;npm `dsh-plan-completion@0.1.6`;138/138 tests;CI run 41393937067。
6
+ >
7
+ > v0.1.5 发布记录(2026-11-02):计划停更检测——连续 2 次 op=none 后建议变为 `/replan …` 并标记 stale。tag `v0.1.5` + [Release](https://github.com/Xumi0920/dsh-plan-completion/releases/tag/v0.1.5);Actions [run 41391402601](https://github.com/Xumi0920/dsh-plan-completion/actions/runs/41391402601)(四 job 全绿,133/133 tests);npm `dsh-plan-completion@0.1.5`(shasum `c53c6d7ef79d8634913054db79fa9e5b1e86b182`)。
8
+ >
5
9
  > v0.1.4 发布记录(2026-11-02):建议文本保真修复(标识符/路径/命令保留 + 语义化截断)。tag `v0.1.4` + [Release](https://github.com/Xumi0920/dsh-plan-completion/releases/tag/v0.1.4);GitHub Actions [run 41389845733](https://github.com/Xumi0920/dsh-plan-completion/actions/runs/41389845733)(四 job 全绿,132/132 tests);npm `dsh-plan-completion@0.1.4`(31 files,shasum `a6f8953445e330081243f9f9d6b429fd4402b341`)。
6
10
  >
7
11
  > v0.1.3 发布记录(2026-11-02):tag `v0.1.3` + [Release](https://github.com/Xumi0920/dsh-plan-completion/releases/tag/v0.1.3);GitHub Actions [run 41368609382](https://github.com/Xumi0920/dsh-plan-completion/actions/runs/41368609382)(Ubuntu/Windows × Node20/22 四 job 全绿,126/126 tests);npm `dsh-plan-completion@0.1.3`(31 files,shasum `11adee0fe9acff199a180be511ca8bc600afdf6f`);canary 验证 `/plan-completion/verify` 未认证 401、客户端鉴权请求正常。
@@ -108,3 +112,5 @@ git tag v0.1.0 && git push --tags
108
112
 
109
113
 
110
114
 
115
+
116
+
package/index.js CHANGED
@@ -11,7 +11,7 @@
11
11
  * The Client half only talks to this half through `/plan-completion/*`
12
12
  * (verified contract: same-origin fetch from the Electron page works and the body is readable).
13
13
  */
14
- import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs'
14
+ import { appendFileSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
15
15
  import { homedir } from 'node:os'
16
16
 
17
17
  import { DEFAULTS } from './host/config.js'
@@ -63,6 +63,38 @@ function expandStorageDir(value, envValue) {
63
63
  return raw
64
64
  }
65
65
 
66
+ const MODEL_POLICIES = new Set(['default', 'fixed', 'auto'])
67
+
68
+ function normalizeModelPolicy(value) {
69
+ const policy = typeof value === 'string' ? value.trim() : ''
70
+ return MODEL_POLICIES.has(policy) ? policy : 'default'
71
+ }
72
+
73
+ /** Settings written through the model-settings page (persisted next to the plan store). */
74
+ function readSavedSettings(dir) {
75
+ if (!dir) return {}
76
+ try {
77
+ const data = JSON.parse(readFileSync(join(dir, 'settings.json'), 'utf8'))
78
+ return data && typeof data === 'object' && !Array.isArray(data) ? data : {}
79
+ } catch {
80
+ return {}
81
+ }
82
+ }
83
+
84
+ function writeSavedSettings(dir, data) {
85
+ if (!dir) return false
86
+ try {
87
+ mkdirSync(dir, { recursive: true })
88
+ const target = join(dir, 'settings.json')
89
+ const tmp = join(dir, `.settings-${process.pid}-${Date.now()}.json.tmp`)
90
+ writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, 'utf8')
91
+ renameSync(tmp, target)
92
+ return true
93
+ } catch {
94
+ return false
95
+ }
96
+ }
97
+
66
98
  function readJsonBody(req) {
67
99
  return new Promise(resolve => {
68
100
  let size = 0
@@ -154,11 +186,56 @@ function normalizePromptMessages(prompt) {
154
186
  /**
155
187
  * Use the user's current default route only. Never send session evidence to
156
188
  * another configured provider/model just because it may respond faster.
189
+ *
190
+ * v0.1.6 model policy:
191
+ * default — follow the host's current default model (previous behaviour)
192
+ * fixed — always use `fixedProvider`/`fixedModel` (only if resolvable)
193
+ * auto — within the default provider, prefer a model without reasoning efforts
194
+ * Without a host default route the generator falls back to heuristics (null).
157
195
  */
158
- async function pickModel(services) {
196
+ async function pickModel(services, policy = null) {
159
197
  const llm = services.llm
160
198
  const selection = services.agentDefaultModel?.currentSelection?.()
161
- if (!llm || !selection?.provider || !selection?.model) return null
199
+ if (!llm) return null
200
+ const mode = policy?.modelPolicy === 'fixed' ? 'fixed' : policy?.modelPolicy === 'auto' ? 'auto' : 'default'
201
+
202
+ if (mode === 'fixed' && policy?.fixedProvider && policy?.fixedModel) {
203
+ const route = { provider: policy.fixedProvider, model: policy.fixedModel, source: 'fixed' }
204
+ try {
205
+ const info = await llm.resolveModelInfo?.(route.provider, route.model)
206
+ if (info) {
207
+ const efforts = info?.reasoning?.efforts
208
+ if (Array.isArray(efforts) && efforts.length) route.reasoningEffort = efforts[0]?.id
209
+ return route
210
+ }
211
+ } catch {
212
+ /* fixed route unavailable: fall back to the host default below */
213
+ }
214
+ }
215
+
216
+ if (!selection?.provider || !selection?.model) return null
217
+
218
+ if (mode === 'auto') {
219
+ try {
220
+ const models = typeof llm.listModels === 'function' ? await llm.listModels(selection.provider) : []
221
+ for (const model of Array.isArray(models) ? models : []) {
222
+ const id = String(model?.id ?? '')
223
+ if (!id) continue
224
+ try {
225
+ const info = await llm.resolveModelInfo?.(selection.provider, id)
226
+ const efforts = info?.reasoning?.efforts
227
+ if (!Array.isArray(efforts) || efforts.length === 0) {
228
+ return { provider: selection.provider, model: id, source: 'auto' }
229
+ }
230
+ } catch {
231
+ /* try the next catalog entry */
232
+ }
233
+ }
234
+ } catch {
235
+ /* fall back to the host default */
236
+ }
237
+ }
238
+
162
239
  const route = { provider: selection.provider, model: selection.model, source: 'session-default' }
163
240
  try {
164
241
  const info = await llm.resolveModelInfo?.(route.provider, route.model)
@@ -247,14 +324,19 @@ export function apply(ctx, config = {}) {
247
324
  return Math.min(max, Math.max(min, number))
248
325
  }
249
326
  const rawConfig = config && typeof config === 'object' ? config : {}
327
+ const storageDir = expandStorageDir(rawConfig.storageDir, process.env.DSH_PLAN_COMPLETION_STORE_DIR)
328
+ const savedSettings = readSavedSettings(storageDir)
250
329
  const settings = {
251
330
  evidenceChars: readInt(rawConfig.evidenceChars, 1500, 400, 8000),
252
331
  maxTokensPatch: readInt(rawConfig.maxTokensPatch, 400, 120, 4000),
253
332
  maxTokensRewrite: readInt(rawConfig.maxTokensRewrite, 1200, 400, 8000),
254
333
  warmBackfill: rawConfig.warmBackfill !== false,
255
- modelCalls: rawConfig.modelCalls !== false,
334
+ modelCalls: (typeof savedSettings.modelCalls === 'boolean' ? savedSettings.modelCalls : true) && rawConfig.modelCalls !== false,
335
+ modelPolicy: normalizeModelPolicy(savedSettings.modelPolicy ?? rawConfig.modelPolicy),
336
+ fixedProvider: typeof savedSettings.fixedProvider === 'string' ? savedSettings.fixedProvider.trim().slice(0, 120) : '',
337
+ fixedModel: typeof savedSettings.fixedModel === 'string' ? savedSettings.fixedModel.trim().slice(0, 120) : '',
256
338
  persistPlans: rawConfig.persistPlans !== false,
257
- storageDir: expandStorageDir(rawConfig.storageDir, process.env.DSH_PLAN_COMPLETION_STORE_DIR),
339
+ storageDir,
258
340
  maxTokensVerify: readInt(rawConfig.maxTokensVerify, DEFAULTS.maxTokensVerify, 200, 2000),
259
341
  enableDebugRoutes: rawConfig.enableDebugRoutes === true,
260
342
  debugDir: typeof rawConfig.debugDir === 'string' && rawConfig.debugDir.trim() !== ''
@@ -337,6 +419,38 @@ export function apply(ctx, config = {}) {
337
419
  }
338
420
  }
339
421
 
422
+ async function settingsSnapshot(providerHint = '') {
423
+ const selection = services.agentDefaultModel?.currentSelection?.() ?? null
424
+ let providers = []
425
+ try {
426
+ providers = services.llm?.listProviders?.() ?? []
427
+ } catch {
428
+ providers = []
429
+ }
430
+ let models = []
431
+ let modelsProvider = ''
432
+ try {
433
+ modelsProvider = providerHint || selection?.provider || (Array.isArray(providers) ? providers[0]?.id : '')
434
+ if (modelsProvider && typeof services.llm?.listModels === 'function') {
435
+ models = (await services.llm.listModels(modelsProvider))?.map(m => ({ provider: modelsProvider, id: m?.id ?? '', name: m?.name ?? m?.id ?? '' })) ?? []
436
+ }
437
+ } catch { models = [] }
438
+ return {
439
+ modelPolicy: settings.modelPolicy,
440
+ fixedProvider: settings.fixedProvider,
441
+ fixedModel: settings.fixedModel,
442
+ modelCalls: settings.modelCalls,
443
+ defaultSelection: selection && selection.provider && selection.model
444
+ ? { provider: selection.provider, model: selection.model }
445
+ : null,
446
+ models,
447
+ modelsProvider,
448
+ providers: Array.isArray(providers)
449
+ ? providers.map(provider => ({ id: provider?.id ?? '', name: provider?.name ?? provider?.id ?? '' }))
450
+ : [],
451
+ }
452
+ }
453
+
340
454
  function publishState(sessionId, patch) {
341
455
  const previous = states.get(sessionId) ?? { plan: null, suggestion: null, stale: false }
342
456
  states.set(sessionId, { ...previous, ...patch })
@@ -633,7 +747,7 @@ export function apply(ctx, config = {}) {
633
747
  ]
634
748
  }
635
749
  try {
636
- const route = await pickModel(services)
750
+ const route = await pickModel(services, settings)
637
751
  if (!isCurrent()) return
638
752
  debug.model = route
639
753
  debugLog({ phase: 'model-choice', sessionId, route })
@@ -1009,7 +1123,7 @@ export function apply(ctx, config = {}) {
1009
1123
  jsonResponse(res, 200, { ok: false, error: 'in-progress' })
1010
1124
  return
1011
1125
  }
1012
- const route = await pickModel(services)
1126
+ const route = await pickModel(services, settings)
1013
1127
  if (!route) {
1014
1128
  jsonResponse(res, 200, { ok: false, error: 'no-model-route' })
1015
1129
  return
@@ -1077,6 +1191,54 @@ export function apply(ctx, config = {}) {
1077
1191
  }
1078
1192
  return
1079
1193
  }
1194
+ if (pathname === `${ROUTE_PREFIX}settings` && method === 'GET') {
1195
+ jsonResponse(res, 200, { ok: true, settings: await settingsSnapshot(url?.searchParams?.get('provider') ?? '') })
1196
+ return
1197
+ }
1198
+ if (pathname === `${ROUTE_PREFIX}settings` && method === 'POST') {
1199
+ const body = (await readJsonBody(req)) ?? {}
1200
+ const next = { ...(await settingsSnapshot()) }
1201
+ if (body.modelPolicy !== undefined) {
1202
+ const policy = normalizeModelPolicy(body.modelPolicy)
1203
+ if (policy !== String(body.modelPolicy).trim()) {
1204
+ jsonResponse(res, 200, { ok: false, error: 'bad-model-policy' })
1205
+ return
1206
+ }
1207
+ next.modelPolicy = policy
1208
+ }
1209
+ if (body.modelCalls !== undefined) {
1210
+ if (typeof body.modelCalls !== 'boolean') {
1211
+ jsonResponse(res, 200, { ok: false, error: 'bad-model-calls' })
1212
+ return
1213
+ }
1214
+ next.modelCalls = body.modelCalls
1215
+ }
1216
+ for (const key of ['fixedProvider', 'fixedModel']) {
1217
+ if (body[key] === undefined) continue
1218
+ if (typeof body[key] !== 'string') {
1219
+ jsonResponse(res, 200, { ok: false, error: 'bad-' + key })
1220
+ return
1221
+ }
1222
+ next[key] = body[key].trim().slice(0, 120)
1223
+ }
1224
+ if (next.modelPolicy === 'fixed' && (!next.fixedProvider || !next.fixedModel)) {
1225
+ jsonResponse(res, 200, { ok: false, error: 'missing-fixed-route' })
1226
+ return
1227
+ }
1228
+ settings.modelPolicy = next.modelPolicy
1229
+ settings.fixedProvider = next.fixedProvider
1230
+ settings.fixedModel = next.fixedModel
1231
+ settings.modelCalls = next.modelCalls
1232
+ const saved = writeSavedSettings(settings.storageDir, {
1233
+ modelPolicy: settings.modelPolicy,
1234
+ fixedProvider: settings.fixedProvider,
1235
+ fixedModel: settings.fixedModel,
1236
+ modelCalls: settings.modelCalls,
1237
+ })
1238
+ debugLog({ phase: 'settings-updated', modelPolicy: settings.modelPolicy, saved })
1239
+ jsonResponse(res, 200, { ok: true, saved, settings: await settingsSnapshot() })
1240
+ return
1241
+ }
1080
1242
  if (pathname === `${ROUTE_PREFIX}state` && method === 'GET') {
1081
1243
  const sessionId = url?.searchParams?.get('sessionId') ?? ''
1082
1244
  await hydrateSession(sessionId)
@@ -1297,7 +1459,7 @@ export function apply(ctx, config = {}) {
1297
1459
  experimentState.error = null
1298
1460
  void (async () => {
1299
1461
  try {
1300
- const route = await pickModel(services)
1462
+ const route = await pickModel(services, settings)
1301
1463
  if (!route) throw new Error('no-model-route')
1302
1464
  const judgeRoute = await pickJudgeRoute(services, route)
1303
1465
  debugLog({ phase: 'experiment-start', sessionId, route, judgeRoute })
@@ -1390,6 +1552,7 @@ export function apply(ctx, config = {}) {
1390
1552
  `${ROUTE_PREFIX}replan`,
1391
1553
  `${ROUTE_PREFIX}warm`,
1392
1554
  `${ROUTE_PREFIX}verify`,
1555
+ `${ROUTE_PREFIX}settings`,
1393
1556
  ]
1394
1557
  if (settings.enableDebugRoutes) {
1395
1558
  paths.push(
@@ -1494,3 +1657,6 @@ export function apply(ctx, config = {}) {
1494
1657
  })
1495
1658
  }
1496
1659
 
1660
+
1661
+
1662
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plan-completion",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Implicit session plans with Tab-completable next-message suggestions for DeepSeek Harness",