dsh-plan-completion 0.1.5 → 0.1.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/CHANGELOG.md CHANGED
@@ -3,6 +3,21 @@
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.6] - 2026-11-02
7
+
8
+ ### Added
9
+ - Model settings page registered at `settings.plugins.tab` (Settings → Plugins → 计划补全):
10
+ radio group for `default` / `fixed` / `auto` model policy, Provider/Model ID inputs for fixed
11
+ mode, a `modelCalls` toggle, current host-default route hint, and save/status feedback.
12
+ - Host settings route `GET/POST /plan-completion/settings` (authenticated) persists the policy to
13
+ `<storageDir>/settings.json` and loads it on apply; `pickModel` honours `default`/`fixed`/`auto`.
14
+ - Client transport test for the settings page (GET/POST, failure handling).
15
+ - Dev-only suggestion log lives outside the release: `research/suggestion-log-local/` keeps a local
16
+ copy for tuning; the published plugin does not contain or enable suggestion-content logging.
17
+
18
+ ### Changed
19
+ - `npm test` now reports **138/138** tests.
20
+
6
21
  ## [0.1.5] - 2026-11-02
7
22
 
8
23
  ### Fixed
@@ -136,3 +151,4 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versioning: Se
136
151
  - Debug endpoints (`theme`, `experiment`, `autojob`, `steps`, `reset`) are only registered when
137
152
  `enableDebugRoutes: true`; production endpoints are `state`, `debug`, `diag`, `replan`, `warm`.
138
153
  - No credentials are stored in the plugin; model calls use the host `llm` service.
154
+
package/client.js CHANGED
@@ -105,7 +105,22 @@ 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.defaultRoute': '宿主默认:{value}',
118
+ 'settings.save': '保存',
119
+ 'settings.saving': '保存中…',
120
+ 'settings.saved': '已保存',
121
+ 'settings.loading': '加载中…',
122
+ 'settings.error': '设置加载失败',
123
+ 'settings.saveError': '保存失败'
109
124
  },
110
125
  en: {
111
126
  'entry.aria': 'Open the session plan panel',
@@ -148,7 +163,22 @@ window.__ModuleLoader__.load({
148
163
  'verify.clean': 'No obvious mismatches found.',
149
164
  'verify.next': 'Next: {value}',
150
165
  'verify.failed': 'Verification failed: {value}',
151
- 'verify.no-session': 'No session available yet'
166
+ 'verify.no-session': 'No session available yet',
167
+ 'settings.title': 'Plan Completion',
168
+ 'settings.model': 'Model selection',
169
+ 'settings.policy.default': 'Follow host default',
170
+ 'settings.policy.fixed': 'Fixed model',
171
+ 'settings.policy.auto': 'Auto (prefer non-reasoning)',
172
+ 'settings.provider': 'Provider',
173
+ 'settings.modelId': 'Model ID',
174
+ 'settings.modelCalls': 'Allow this plugin to call models',
175
+ 'settings.defaultRoute': 'Host default: {value}',
176
+ 'settings.save': 'Save',
177
+ 'settings.saving': 'Saving…',
178
+ 'settings.saved': 'Saved',
179
+ 'settings.loading': 'Loading…',
180
+ 'settings.error': 'Failed to load settings',
181
+ 'settings.saveError': 'Save failed'
152
182
  }
153
183
  };
154
184
 
@@ -787,6 +817,52 @@ window.__ModuleLoader__.load({
787
817
  });
788
818
  }
789
819
 
820
+ /**
821
+ * Host settings used by the settings page (model policy + modelCalls).
822
+ */
823
+ function fetchSettings() {
824
+ try {
825
+ return Promise.resolve(fetch('/plan-completion/settings', {
826
+ method: 'GET',
827
+ credentials: 'same-origin',
828
+ headers: { accept: 'application/json' }
829
+ }))
830
+ .then(function readResponse(response) {
831
+ return response && response.ok === true ? Promise.resolve(response.json()) : null;
832
+ })
833
+ .then(function parsed(json) {
834
+ return json && typeof json === 'object' ? json : null;
835
+ })
836
+ .catch(function networkError() {
837
+ return null;
838
+ });
839
+ } catch (error) {
840
+ return Promise.resolve(null);
841
+ }
842
+ }
843
+
844
+ function saveSettings(payload) {
845
+ try {
846
+ return Promise.resolve(fetch('/plan-completion/settings', {
847
+ method: 'POST',
848
+ credentials: 'same-origin',
849
+ headers: { 'content-type': 'application/json' },
850
+ body: JSON.stringify(payload || {})
851
+ }))
852
+ .then(function readResponse(response) {
853
+ return response && response.ok === true ? Promise.resolve(response.json()) : null;
854
+ })
855
+ .then(function parsed(json) {
856
+ return json && typeof json === 'object' ? json : null;
857
+ })
858
+ .catch(function networkError() {
859
+ return null;
860
+ });
861
+ } catch (error) {
862
+ return Promise.resolve(null);
863
+ }
864
+ }
865
+
790
866
  /**
791
867
  * P0 boundary: read the current plan state. Keep this signature stable —
792
868
  * swapping the transport (HTTP route / projection / wire view) only changes
@@ -2055,6 +2131,184 @@ window.__ModuleLoader__.load({
2055
2131
  );
2056
2132
  }
2057
2133
 
2134
+ // =====================================================================
2135
+ // Settings page (settings.plugins.tab)
2136
+ // =====================================================================
2137
+ const SETTINGS_SLOT = 'settings.plugins.tab';
2138
+ const SETTINGS_ID = 'plan-completion-settings';
2139
+ const SETTINGS_ORDER = 60;
2140
+
2141
+ function settingsCss() {
2142
+ return [
2143
+ '.dpc-settings-panel{display:flex;flex-direction:column;gap:14px;padding:16px 18px;font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary);max-width:560px}',
2144
+ '.dpc-settings-title{font-size:15px;font-weight:600;line-height:24px}',
2145
+ '.dpc-settings-group{display:flex;flex-direction:column;gap:8px}',
2146
+ '.dpc-settings-label{font-size:12px;color:var(--dsw-alias-label-tertiary)}',
2147
+ '.dpc-settings-radio{display:flex;align-items:center;gap:8px;cursor:pointer}',
2148
+ '.dpc-settings-fixed{display:flex;gap:8px;padding:8px 0 0 24px;flex-wrap:wrap}',
2149
+ '.dpc-settings-input{flex:1 1 180px;min-width:140px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:5px 10px;font-size:12px;background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-layer-1));color:var(--dsw-alias-label-primary)}',
2150
+ '.dpc-settings-check{display:flex;align-items:center;gap:8px;cursor:pointer}',
2151
+ '.dpc-settings-hint{font-size:11px;color:var(--dsw-alias-label-tertiary)}',
2152
+ '.dpc-settings-error{font-size:12px;color:var(--dsw-alias-state-error-primary,var(--dsw-alias-label-secondary))}',
2153
+ '.dpc-settings-save{align-self:flex-start;border:1px solid var(--dsw-alias-border-l1);border-radius:999px;padding:5px 14px;font-size:12px;line-height:18px;cursor:pointer;background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-layer-1));color:var(--dsw-alias-label-primary)}',
2154
+ '.dpc-settings-save:disabled{opacity:.5;cursor:default}',
2155
+ '.dpc-settings-status{padding:16px 18px;font-size:13px;color:var(--dsw-alias-label-secondary)}'
2156
+ ].join('\n');
2157
+ }
2158
+
2159
+ function PlanSettingsPage(props) {
2160
+ const t = makeT(props);
2161
+ const [settings, setSettings] = React.useState(null);
2162
+ const [status, setStatus] = React.useState('loading');
2163
+ const [saving, setSaving] = React.useState(false);
2164
+ const [error, setError] = React.useState('');
2165
+ const [saved, setSaved] = React.useState(false);
2166
+
2167
+ React.useEffect(function loadSettings() {
2168
+ let alive = true;
2169
+ fetchSettings().then(function applySettings(result) {
2170
+ if (!alive) return;
2171
+ if (!result || result.ok !== true || !result.settings) {
2172
+ setStatus('error');
2173
+ return;
2174
+ }
2175
+ setSettings(result.settings);
2176
+ setStatus('ready');
2177
+ });
2178
+ return function cleanup() {
2179
+ alive = false;
2180
+ };
2181
+ }, []);
2182
+
2183
+ function patch(next) {
2184
+ setSaved(false);
2185
+ setSettings(function keep(previous) {
2186
+ return previous ? { ...previous, ...next } : previous;
2187
+ });
2188
+ }
2189
+
2190
+ function save() {
2191
+ if (!settings || saving) return;
2192
+ setSaving(true);
2193
+ setError('');
2194
+ saveSettings({
2195
+ modelPolicy: settings.modelPolicy,
2196
+ fixedProvider: settings.fixedProvider || '',
2197
+ fixedModel: settings.fixedModel || '',
2198
+ modelCalls: settings.modelCalls === true
2199
+ }).then(function applied(result) {
2200
+ setSaving(false);
2201
+ if (!result || result.ok !== true) {
2202
+ const detail = result && typeof result.error === 'string' ? result.error : '';
2203
+ setError(detail ? t('settings.saveError') + ': ' + detail : t('settings.saveError'));
2204
+ return;
2205
+ }
2206
+ setSettings(result.settings || settings);
2207
+ setSaved(true);
2208
+ setError('');
2209
+ });
2210
+ }
2211
+
2212
+ const styleTag = h('style', { key: 'style', 'data-plan-completion-style': 'settings' }, settingsCss());
2213
+
2214
+ if (status === 'loading') {
2215
+ return h(React.Fragment, null, styleTag, h('div', { key: 'loading', className: 'dpc-settings-status' }, t('settings.loading')));
2216
+ }
2217
+ if (status === 'error' || !settings) {
2218
+ return h(
2219
+ React.Fragment,
2220
+ null,
2221
+ styleTag,
2222
+ h('div', { key: 'error', className: 'dpc-settings-status dpc-settings-error' }, t('settings.error'))
2223
+ );
2224
+ }
2225
+
2226
+ const policies = [
2227
+ { value: 'default', label: t('settings.policy.default') },
2228
+ { value: 'fixed', label: t('settings.policy.fixed') },
2229
+ { value: 'auto', label: t('settings.policy.auto') }
2230
+ ];
2231
+ const defaultRoute = settings.defaultSelection
2232
+ ? settings.defaultSelection.provider + '/' + settings.defaultSelection.model
2233
+ : '-';
2234
+
2235
+ return h(
2236
+ React.Fragment,
2237
+ null,
2238
+ styleTag,
2239
+ h(
2240
+ 'div',
2241
+ { key: 'panel', className: 'dpc-settings-panel' },
2242
+ h('div', { key: 'title', className: 'dpc-settings-title' }, t('settings.title')),
2243
+ h(
2244
+ 'div',
2245
+ { key: 'model', className: 'dpc-settings-group' },
2246
+ h('div', { key: 'label', className: 'dpc-settings-label' }, t('settings.model')),
2247
+ ...policies.map(function renderPolicy(option) {
2248
+ return h(
2249
+ 'label',
2250
+ { key: 'policy-' + option.value, className: 'dpc-settings-radio' },
2251
+ h('input', {
2252
+ type: 'radio',
2253
+ name: 'dpc-model-policy',
2254
+ value: option.value,
2255
+ checked: settings.modelPolicy === option.value,
2256
+ onChange: function onPolicy() {
2257
+ patch({ modelPolicy: option.value });
2258
+ }
2259
+ }),
2260
+ h('span', null, option.label)
2261
+ );
2262
+ }),
2263
+ h('div', { key: 'hint', className: 'dpc-settings-hint' }, t('settings.defaultRoute', { value: defaultRoute })),
2264
+ settings.modelPolicy === 'fixed'
2265
+ ? h(
2266
+ 'div',
2267
+ { key: 'fixed', className: 'dpc-settings-fixed' },
2268
+ h('input', {
2269
+ key: 'provider',
2270
+ className: 'dpc-settings-input',
2271
+ placeholder: t('settings.provider'),
2272
+ value: settings.fixedProvider || '',
2273
+ onChange: function onProvider(event) {
2274
+ patch({ fixedProvider: event.target.value });
2275
+ }
2276
+ }),
2277
+ h('input', {
2278
+ key: 'model',
2279
+ className: 'dpc-settings-input',
2280
+ placeholder: t('settings.modelId'),
2281
+ value: settings.fixedModel || '',
2282
+ onChange: function onModel(event) {
2283
+ patch({ fixedModel: event.target.value });
2284
+ }
2285
+ })
2286
+ )
2287
+ : null
2288
+ ),
2289
+ h(
2290
+ 'label',
2291
+ { key: 'calls', className: 'dpc-settings-check' },
2292
+ h('input', {
2293
+ type: 'checkbox',
2294
+ checked: settings.modelCalls === true,
2295
+ onChange: function onCalls(event) {
2296
+ patch({ modelCalls: event.target.checked });
2297
+ }
2298
+ }),
2299
+ h('span', null, t('settings.modelCalls'))
2300
+ ),
2301
+ error ? h('div', { key: 'error', className: 'dpc-settings-error' }, error) : null,
2302
+ saved && !error ? h('div', { key: 'saved', className: 'dpc-settings-hint' }, t('settings.saved')) : null,
2303
+ h(
2304
+ 'button',
2305
+ { key: 'save', type: 'button', className: 'dpc-settings-save', disabled: saving, onClick: save },
2306
+ saving ? t('settings.saving') : t('settings.save')
2307
+ )
2308
+ )
2309
+ );
2310
+ }
2311
+
2058
2312
  // =====================================================================
2059
2313
  // Plugin entry
2060
2314
  // =====================================================================
@@ -2090,6 +2344,13 @@ window.__ModuleLoader__.load({
2090
2344
  return ctx.slots.register({ name: OVERLAY_SLOT, id: OVERLAY_ID, order: OVERLAY_ORDER }, PlanPopover);
2091
2345
  });
2092
2346
 
2347
+ ctx.slots.inject(SETTINGS_SLOT, function registerSettingsPage() {
2348
+ return ctx.slots.register(
2349
+ { name: SETTINGS_SLOT, id: SETTINGS_ID, order: SETTINGS_ORDER, label: '计划补全' },
2350
+ PlanSettingsPage
2351
+ );
2352
+ });
2353
+
2093
2354
  ctx.effect(function installKeyboard() {
2094
2355
  if (typeof window === 'undefined') return undefined;
2095
2356
  const onKeyDown = function onKeyDown(event) {
@@ -1,7 +1,9 @@
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.5**(计划停更检测 + 建议文本保真 + 核对进度 + 本地计划持久化 + 鉴权/状态一致性/隐私边界修复),详见 [security.md](security.md) 与 [CHANGELOG](../CHANGELOG.md);旧记录中的“无鉴权”、原始诊断字段与独立评审不代表当前实现。
4
4
  >
5
+ > 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`)。
6
+ >
5
7
  > 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
8
  >
7
9
  > 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 +110,4 @@ git tag v0.1.0 && git push --tags
108
110
 
109
111
 
110
112
 
113
+
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,28 @@ export function apply(ctx, config = {}) {
337
419
  }
338
420
  }
339
421
 
422
+ function settingsSnapshot() {
423
+ const selection = services.agentDefaultModel?.currentSelection?.() ?? null
424
+ let providers = []
425
+ try {
426
+ providers = services.llm?.listProviders?.() ?? []
427
+ } catch {
428
+ providers = []
429
+ }
430
+ return {
431
+ modelPolicy: settings.modelPolicy,
432
+ fixedProvider: settings.fixedProvider,
433
+ fixedModel: settings.fixedModel,
434
+ modelCalls: settings.modelCalls,
435
+ defaultSelection: selection && selection.provider && selection.model
436
+ ? { provider: selection.provider, model: selection.model }
437
+ : null,
438
+ providers: Array.isArray(providers)
439
+ ? providers.map(provider => ({ id: provider?.id ?? '', name: provider?.name ?? provider?.id ?? '' }))
440
+ : [],
441
+ }
442
+ }
443
+
340
444
  function publishState(sessionId, patch) {
341
445
  const previous = states.get(sessionId) ?? { plan: null, suggestion: null, stale: false }
342
446
  states.set(sessionId, { ...previous, ...patch })
@@ -633,7 +737,7 @@ export function apply(ctx, config = {}) {
633
737
  ]
634
738
  }
635
739
  try {
636
- const route = await pickModel(services)
740
+ const route = await pickModel(services, settings)
637
741
  if (!isCurrent()) return
638
742
  debug.model = route
639
743
  debugLog({ phase: 'model-choice', sessionId, route })
@@ -1009,7 +1113,7 @@ export function apply(ctx, config = {}) {
1009
1113
  jsonResponse(res, 200, { ok: false, error: 'in-progress' })
1010
1114
  return
1011
1115
  }
1012
- const route = await pickModel(services)
1116
+ const route = await pickModel(services, settings)
1013
1117
  if (!route) {
1014
1118
  jsonResponse(res, 200, { ok: false, error: 'no-model-route' })
1015
1119
  return
@@ -1077,6 +1181,54 @@ export function apply(ctx, config = {}) {
1077
1181
  }
1078
1182
  return
1079
1183
  }
1184
+ if (pathname === `${ROUTE_PREFIX}settings` && method === 'GET') {
1185
+ jsonResponse(res, 200, { ok: true, settings: settingsSnapshot() })
1186
+ return
1187
+ }
1188
+ if (pathname === `${ROUTE_PREFIX}settings` && method === 'POST') {
1189
+ const body = (await readJsonBody(req)) ?? {}
1190
+ const next = { ...settingsSnapshot() }
1191
+ if (body.modelPolicy !== undefined) {
1192
+ const policy = normalizeModelPolicy(body.modelPolicy)
1193
+ if (policy !== String(body.modelPolicy).trim()) {
1194
+ jsonResponse(res, 200, { ok: false, error: 'bad-model-policy' })
1195
+ return
1196
+ }
1197
+ next.modelPolicy = policy
1198
+ }
1199
+ if (body.modelCalls !== undefined) {
1200
+ if (typeof body.modelCalls !== 'boolean') {
1201
+ jsonResponse(res, 200, { ok: false, error: 'bad-model-calls' })
1202
+ return
1203
+ }
1204
+ next.modelCalls = body.modelCalls
1205
+ }
1206
+ for (const key of ['fixedProvider', 'fixedModel']) {
1207
+ if (body[key] === undefined) continue
1208
+ if (typeof body[key] !== 'string') {
1209
+ jsonResponse(res, 200, { ok: false, error: 'bad-' + key })
1210
+ return
1211
+ }
1212
+ next[key] = body[key].trim().slice(0, 120)
1213
+ }
1214
+ if (next.modelPolicy === 'fixed' && (!next.fixedProvider || !next.fixedModel)) {
1215
+ jsonResponse(res, 200, { ok: false, error: 'missing-fixed-route' })
1216
+ return
1217
+ }
1218
+ settings.modelPolicy = next.modelPolicy
1219
+ settings.fixedProvider = next.fixedProvider
1220
+ settings.fixedModel = next.fixedModel
1221
+ settings.modelCalls = next.modelCalls
1222
+ const saved = writeSavedSettings(settings.storageDir, {
1223
+ modelPolicy: settings.modelPolicy,
1224
+ fixedProvider: settings.fixedProvider,
1225
+ fixedModel: settings.fixedModel,
1226
+ modelCalls: settings.modelCalls,
1227
+ })
1228
+ debugLog({ phase: 'settings-updated', modelPolicy: settings.modelPolicy, saved })
1229
+ jsonResponse(res, 200, { ok: true, saved, settings: settingsSnapshot() })
1230
+ return
1231
+ }
1080
1232
  if (pathname === `${ROUTE_PREFIX}state` && method === 'GET') {
1081
1233
  const sessionId = url?.searchParams?.get('sessionId') ?? ''
1082
1234
  await hydrateSession(sessionId)
@@ -1297,7 +1449,7 @@ export function apply(ctx, config = {}) {
1297
1449
  experimentState.error = null
1298
1450
  void (async () => {
1299
1451
  try {
1300
- const route = await pickModel(services)
1452
+ const route = await pickModel(services, settings)
1301
1453
  if (!route) throw new Error('no-model-route')
1302
1454
  const judgeRoute = await pickJudgeRoute(services, route)
1303
1455
  debugLog({ phase: 'experiment-start', sessionId, route, judgeRoute })
@@ -1390,6 +1542,7 @@ export function apply(ctx, config = {}) {
1390
1542
  `${ROUTE_PREFIX}replan`,
1391
1543
  `${ROUTE_PREFIX}warm`,
1392
1544
  `${ROUTE_PREFIX}verify`,
1545
+ `${ROUTE_PREFIX}settings`,
1393
1546
  ]
1394
1547
  if (settings.enableDebugRoutes) {
1395
1548
  paths.push(
@@ -1494,3 +1647,5 @@ export function apply(ctx, config = {}) {
1494
1647
  })
1495
1648
  }
1496
1649
 
1650
+
1651
+
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.6",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Implicit session plans with Tab-completable next-message suggestions for DeepSeek Harness",