dsh-plan-completion 0.1.4 → 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 +28 -0
- package/client.js +263 -2
- package/cordis.patch.yml +16 -16
- package/docs/publish-assessment.md +7 -1
- package/index.js +192 -14
- package/package.json +1 -1
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.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
|
+
|
|
21
|
+
## [0.1.5] - 2026-11-02
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
- Detect a plan that stopped tracking the conversation: after **two consecutive `op=none`**
|
|
25
|
+
generation outcomes (e.g. an edited plan whose coarse step needs splitting), the published
|
|
26
|
+
suggestion becomes `/replan 按最近进展重排计划,把已完成项标记完成` and the plan is marked
|
|
27
|
+
stale, so the user can fix the plan with one Tab fill instead of seeing a repeated stale hint.
|
|
28
|
+
- `/reset` and forced `/replan` clear the none-streak counter.
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
- `npm test` now reports **133/133** tests.
|
|
32
|
+
|
|
6
33
|
## [0.1.4] - 2026-11-02
|
|
7
34
|
|
|
8
35
|
### Fixed
|
|
@@ -124,3 +151,4 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versioning: Se
|
|
|
124
151
|
- Debug endpoints (`theme`, `experiment`, `autojob`, `steps`, `reset`) are only registered when
|
|
125
152
|
`enableDebugRoutes: true`; production endpoints are `state`, `debug`, `diag`, `replan`, `warm`.
|
|
126
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) {
|
package/cordis.patch.yml
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
- insert:
|
|
2
|
-
- id: plan-completion
|
|
3
|
-
name: 'dsh-plan-completion'
|
|
4
|
-
config:
|
|
5
|
-
evidenceChars: 1500
|
|
6
|
-
maxTokensPatch: 400
|
|
7
|
-
maxTokensRewrite: 1200
|
|
8
|
-
warmBackfill: true
|
|
9
|
-
modelCalls: true # false: heuristic-only; no plugin model requests
|
|
10
|
-
# Plans + last suggestion are persisted per session so a DSH restart restores the UI.
|
|
11
|
-
persistPlans: true
|
|
12
|
-
storageDir: '~/.dsh/plan-completion-data'
|
|
13
|
-
# Debug endpoints (debug/diag/theme/experiment/autojob/steps/reset) stay off in production.
|
|
14
|
-
enableDebugRoutes: false
|
|
15
|
-
# Optional: set a local directory to persist debug logs / experiment data.
|
|
16
|
-
# debugDir: '<local debug directory>'
|
|
1
|
+
- insert:
|
|
2
|
+
- id: plan-completion
|
|
3
|
+
name: 'dsh-plan-completion'
|
|
4
|
+
config:
|
|
5
|
+
evidenceChars: 1500
|
|
6
|
+
maxTokensPatch: 400
|
|
7
|
+
maxTokensRewrite: 1200
|
|
8
|
+
warmBackfill: true
|
|
9
|
+
modelCalls: true # false: heuristic-only; no plugin model requests
|
|
10
|
+
# Plans + last suggestion are persisted per session so a DSH restart restores the UI.
|
|
11
|
+
persistPlans: true
|
|
12
|
+
storageDir: '~/.dsh/plan-completion-data'
|
|
13
|
+
# Debug endpoints (debug/diag/theme/experiment/autojob/steps/reset) stay off in production.
|
|
14
|
+
enableDebugRoutes: false
|
|
15
|
+
# Optional: set a local directory to persist debug logs / experiment data.
|
|
16
|
+
# debugDir: '<local debug directory>'
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
# GitHub 发布评估 · 会话计划补全插件(2026-11-02)
|
|
2
2
|
|
|
3
|
-
> 历史文档:以下保留 v0.1.0 的交付/评估/计划记录。**当前最新为 v0.1.
|
|
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
|
+
>
|
|
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`)。
|
|
8
|
+
>
|
|
5
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、客户端鉴权请求正常。
|
|
6
10
|
>
|
|
7
11
|
> v0.1.1 发布记录(2026-11-02):tag `v0.1.1` + [Release](https://github.com/Xumi0920/dsh-plan-completion/releases/tag/v0.1.1);GitHub Actions [run 41366651557](https://github.com/Xumi0920/dsh-plan-completion/actions/runs/41366651557)(Ubuntu/Windows × Node20/22 四 job 全绿);npm `dsh-plan-completion@0.1.1`(31 files,shasum `18989b0f50b72d5c8c332f3fdee7f43ccb5fd91a`)。
|
|
@@ -105,3 +109,5 @@ git tag v0.1.0 && git push --tags
|
|
|
105
109
|
> 建议:**先把 P0 B1~B8 全部打勾,再公开仓库**;否则许可证与数据问题会成为不可逆的历史提交。
|
|
106
110
|
|
|
107
111
|
|
|
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
|
|
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)
|
|
@@ -215,7 +292,7 @@ const HOST_GLOBAL_KEY = '__dsh_plan_completion_host__'
|
|
|
215
292
|
* up with two live handler sets answering different state.
|
|
216
293
|
*/
|
|
217
294
|
function hostGlobal() {
|
|
218
|
-
const fallback = { states: new Map(), sessions: new Map(), handledTurns: new Map(), warmAttempts: new Map(), disposers: [] }
|
|
295
|
+
const fallback = { states: new Map(), sessions: new Map(), handledTurns: new Map(), warmAttempts: new Map(), noneStreaks: new Map(), disposers: [] }
|
|
219
296
|
if (typeof globalThis === 'undefined') return { ...fallback, store: createMemoryStore() }
|
|
220
297
|
if (!globalThis[HOST_GLOBAL_KEY] || typeof globalThis[HOST_GLOBAL_KEY] !== 'object') {
|
|
221
298
|
globalThis[HOST_GLOBAL_KEY] = fallback
|
|
@@ -225,6 +302,7 @@ function hostGlobal() {
|
|
|
225
302
|
if (!(shared.sessions instanceof Map)) shared.sessions = new Map()
|
|
226
303
|
if (!(shared.handledTurns instanceof Map)) shared.handledTurns = new Map()
|
|
227
304
|
if (!(shared.warmAttempts instanceof Map)) shared.warmAttempts = new Map()
|
|
305
|
+
if (!(shared.noneStreaks instanceof Map)) shared.noneStreaks = new Map()
|
|
228
306
|
if (!Array.isArray(shared.disposers)) shared.disposers = []
|
|
229
307
|
if (!shared.store || typeof shared.store.get !== 'function' || typeof shared.store.put !== 'function') {
|
|
230
308
|
shared.store = createMemoryStore()
|
|
@@ -246,14 +324,19 @@ export function apply(ctx, config = {}) {
|
|
|
246
324
|
return Math.min(max, Math.max(min, number))
|
|
247
325
|
}
|
|
248
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)
|
|
249
329
|
const settings = {
|
|
250
330
|
evidenceChars: readInt(rawConfig.evidenceChars, 1500, 400, 8000),
|
|
251
331
|
maxTokensPatch: readInt(rawConfig.maxTokensPatch, 400, 120, 4000),
|
|
252
332
|
maxTokensRewrite: readInt(rawConfig.maxTokensRewrite, 1200, 400, 8000),
|
|
253
333
|
warmBackfill: rawConfig.warmBackfill !== false,
|
|
254
|
-
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) : '',
|
|
255
338
|
persistPlans: rawConfig.persistPlans !== false,
|
|
256
|
-
storageDir
|
|
339
|
+
storageDir,
|
|
257
340
|
maxTokensVerify: readInt(rawConfig.maxTokensVerify, DEFAULTS.maxTokensVerify, 200, 2000),
|
|
258
341
|
enableDebugRoutes: rawConfig.enableDebugRoutes === true,
|
|
259
342
|
debugDir: typeof rawConfig.debugDir === 'string' && rawConfig.debugDir.trim() !== ''
|
|
@@ -336,6 +419,28 @@ export function apply(ctx, config = {}) {
|
|
|
336
419
|
}
|
|
337
420
|
}
|
|
338
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
|
+
|
|
339
444
|
function publishState(sessionId, patch) {
|
|
340
445
|
const previous = states.get(sessionId) ?? { plan: null, suggestion: null, stale: false }
|
|
341
446
|
states.set(sessionId, { ...previous, ...patch })
|
|
@@ -632,7 +737,7 @@ export function apply(ctx, config = {}) {
|
|
|
632
737
|
]
|
|
633
738
|
}
|
|
634
739
|
try {
|
|
635
|
-
const route = await pickModel(services)
|
|
740
|
+
const route = await pickModel(services, settings)
|
|
636
741
|
if (!isCurrent()) return
|
|
637
742
|
debug.model = route
|
|
638
743
|
debugLog({ phase: 'model-choice', sessionId, route })
|
|
@@ -754,12 +859,22 @@ export function apply(ctx, config = {}) {
|
|
|
754
859
|
const withinSuggestionDeadline = elapsed <= suggestionDeadline
|
|
755
860
|
const finalSuggestion = withinSuggestionDeadline ? suggestion ?? provisionalSuggestion : provisionalSuggestion
|
|
756
861
|
|
|
862
|
+
// A long `op=none` streak means the visible plan stopped tracking the conversation
|
|
863
|
+
// (edited-by-user plans cannot be rewritten automatically, and one coarse step may need
|
|
864
|
+
// splitting). Surface an actionable replan suggestion instead of repeating a stale one.
|
|
865
|
+
const priorNoneStreak = noneStreaks.get(sessionId) ?? 0
|
|
866
|
+
const nextNoneStreak = force ? 0 : outcome === 'none' && nextPlan ? priorNoneStreak + 1 : 0
|
|
867
|
+
const replanSuggested = nextNoneStreak >= 2 && Boolean(nextPlan)
|
|
868
|
+
const publishedSuggestion = replanSuggested
|
|
869
|
+
? { text: '/replan 按最近进展重排计划,把已完成项标记完成', alternates: [] }
|
|
870
|
+
: finalSuggestion
|
|
871
|
+
|
|
757
872
|
if (nextPlan) {
|
|
758
873
|
try {
|
|
759
874
|
// Check CAS even for an unchanged plan; an old snapshot is never an
|
|
760
875
|
// unconditional write. null also protects the first commit from races.
|
|
761
876
|
// The last suggestion is persisted in the same write so a restart can restore it.
|
|
762
|
-
const committed = await store.put(nextPlan, { cas: storedVersion, suggestion:
|
|
877
|
+
const committed = await store.put(nextPlan, { cas: storedVersion, suggestion: publishedSuggestion })
|
|
763
878
|
if (!committed) {
|
|
764
879
|
debugLog({ phase: 'store-cas-miss', sessionId })
|
|
765
880
|
const winner = typeof store.getState === 'function'
|
|
@@ -779,13 +894,23 @@ export function apply(ctx, config = {}) {
|
|
|
779
894
|
}
|
|
780
895
|
}
|
|
781
896
|
|
|
897
|
+
noneStreaks.set(sessionId, nextNoneStreak)
|
|
782
898
|
publishState(sessionId, {
|
|
783
899
|
plan: nextPlan,
|
|
784
|
-
suggestion:
|
|
785
|
-
stale:
|
|
900
|
+
suggestion: publishedSuggestion,
|
|
901
|
+
stale: replanSuggested,
|
|
786
902
|
})
|
|
787
|
-
debug.lastJob = {
|
|
788
|
-
|
|
903
|
+
debug.lastJob = {
|
|
904
|
+
sessionId,
|
|
905
|
+
turn,
|
|
906
|
+
elapsed,
|
|
907
|
+
outcome,
|
|
908
|
+
suggestion: Boolean(suggestion),
|
|
909
|
+
withinSuggestionDeadline,
|
|
910
|
+
noneStreak: nextNoneStreak,
|
|
911
|
+
replanSuggested,
|
|
912
|
+
}
|
|
913
|
+
debugLog({ phase: 'job-done', sessionId, turn, elapsed, outcome, withinSuggestionDeadline, replanSuggested })
|
|
789
914
|
} catch (error) {
|
|
790
915
|
if (!isCurrent()) return
|
|
791
916
|
debugLog({ phase: 'job-failed', sessionId, turn, error: String(error) })
|
|
@@ -800,6 +925,7 @@ export function apply(ctx, config = {}) {
|
|
|
800
925
|
|
|
801
926
|
// ── Session event subscription: one plan job per completed turn ──────────
|
|
802
927
|
const handledTurns = shared.handledTurns // sessionId -> last handled turn number
|
|
928
|
+
const noneStreaks = shared.noneStreaks // sessionId -> consecutive op=none outcomes
|
|
803
929
|
|
|
804
930
|
function triggerTurnEnd(session, seq, turn, source) {
|
|
805
931
|
if (!session?.id || disposed) return
|
|
@@ -987,7 +1113,7 @@ export function apply(ctx, config = {}) {
|
|
|
987
1113
|
jsonResponse(res, 200, { ok: false, error: 'in-progress' })
|
|
988
1114
|
return
|
|
989
1115
|
}
|
|
990
|
-
const route = await pickModel(services)
|
|
1116
|
+
const route = await pickModel(services, settings)
|
|
991
1117
|
if (!route) {
|
|
992
1118
|
jsonResponse(res, 200, { ok: false, error: 'no-model-route' })
|
|
993
1119
|
return
|
|
@@ -1055,6 +1181,54 @@ export function apply(ctx, config = {}) {
|
|
|
1055
1181
|
}
|
|
1056
1182
|
return
|
|
1057
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
|
+
}
|
|
1058
1232
|
if (pathname === `${ROUTE_PREFIX}state` && method === 'GET') {
|
|
1059
1233
|
const sessionId = url?.searchParams?.get('sessionId') ?? ''
|
|
1060
1234
|
await hydrateSession(sessionId)
|
|
@@ -1161,6 +1335,7 @@ export function apply(ctx, config = {}) {
|
|
|
1161
1335
|
states.delete(sessionId)
|
|
1162
1336
|
warmAttempts.delete(sessionId)
|
|
1163
1337
|
handledTurns.delete(sessionId)
|
|
1338
|
+
noneStreaks.delete(sessionId)
|
|
1164
1339
|
debugLog({ phase: 'state-reset', sessionId, existed })
|
|
1165
1340
|
jsonResponse(res, 200, { ok: true, sessionId, existed })
|
|
1166
1341
|
return
|
|
@@ -1274,7 +1449,7 @@ export function apply(ctx, config = {}) {
|
|
|
1274
1449
|
experimentState.error = null
|
|
1275
1450
|
void (async () => {
|
|
1276
1451
|
try {
|
|
1277
|
-
const route = await pickModel(services)
|
|
1452
|
+
const route = await pickModel(services, settings)
|
|
1278
1453
|
if (!route) throw new Error('no-model-route')
|
|
1279
1454
|
const judgeRoute = await pickJudgeRoute(services, route)
|
|
1280
1455
|
debugLog({ phase: 'experiment-start', sessionId, route, judgeRoute })
|
|
@@ -1367,6 +1542,7 @@ export function apply(ctx, config = {}) {
|
|
|
1367
1542
|
`${ROUTE_PREFIX}replan`,
|
|
1368
1543
|
`${ROUTE_PREFIX}warm`,
|
|
1369
1544
|
`${ROUTE_PREFIX}verify`,
|
|
1545
|
+
`${ROUTE_PREFIX}settings`,
|
|
1370
1546
|
]
|
|
1371
1547
|
if (settings.enableDebugRoutes) {
|
|
1372
1548
|
paths.push(
|
|
@@ -1471,3 +1647,5 @@ export function apply(ctx, config = {}) {
|
|
|
1471
1647
|
})
|
|
1472
1648
|
}
|
|
1473
1649
|
|
|
1650
|
+
|
|
1651
|
+
|