@starwish-ai/xingyuan-dsh 0.5.2 → 0.5.3

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
@@ -5,6 +5,99 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.5.3] - 2026-08-28
9
+
10
+ > **Note:** this is a patch release but it carries one breaking change — the `xingyuan`
11
+ > settings namespace is replaced by `xingyuan-pref` (see *Removed*). Consumers of the
12
+ > `./domain` subpath must also adapt to `makeXingyuanStore()` taking a second, required
13
+ > argument. Read *Upgrading from 0.5.2 or earlier* before updating.
14
+
15
+ **Chat preferences are now always editable.** Settings → XingYuan rendered the
16
+ write-confirmation toggle and the memory-injection limit as permanently disabled until a
17
+ XingYuan session had been opened once since the last dsh start; clicking them did nothing
18
+ and produced no error. Both preferences lived in the preset-layer namespace, which only
19
+ comes into existence when the preset is first mounted, so they now live in a
20
+ bundle-resident namespace registered whenever the bundle row is active.
21
+
22
+ ### Upgrading from 0.5.2 or earlier
23
+
24
+ The two chat preferences moved from the `xingyuan` block of `$DSH_HOME/settings.yaml` to a
25
+ new `xingyuan-pref` block. **A value you had customised is not carried over** and falls back
26
+ to the default: `confirmWrites` returns to `true` (the safe direction — write actions ask for
27
+ confirmation again) and `memoryInjectLimit` to `40`. Re-apply your choices under
28
+ **Settings → XingYuan → Chat preferences**. The leftover `xingyuan:` block is inert and is
29
+ safe to delete.
30
+
31
+ ### Added
32
+ - **`xingyuan-pref` settings namespace** (`src/pref-settings.ts`), installed by the bundle
33
+ layer next to the existing `xingyuan-ui` namespace, carrying `confirmWrites` (default
34
+ `true`) and `memoryInjectLimit` (default `40`, integer `5`–`200`). The schema declares
35
+ `.step(1)`, so the Host also rejects fractional values that a hand-edited settings
36
+ document or a direct RPC write could otherwise smuggle in.
37
+ - **Shared preference policy module** (`src/pref-policy.ts`) holding the bounds, defaults
38
+ and input parser for both halves — the host module depends on
39
+ `@deepseek-ai/dsh-settings` and must never reach the browser bundle. This also removes
40
+ the `5`/`200`/`40` literals that were previously repeated in the settings page. Covered
41
+ by `test/pref-policy.test.ts`.
42
+ - **Write verification**: `scope.set()` resolves rather than rejects on failure (the client
43
+ controller catches, recovers and returns silently), so `toastError` never fired. The page
44
+ now compares the snapshot after each write and raises a "did not save" toast when the
45
+ value did not change. Both controls share one scope — and therefore one write queue — so
46
+ the comparison is gated on a per-component write sequence number to stay honest under
47
+ interleaved writes (see below).
48
+ - **Regression lock** (`test/pref-settings.test.ts`): five tests pinning that the bundle
49
+ layer registers `xingyuan-pref` as soon as the settings service is available, that the
50
+ read thunk resolves fresh on every call, that the headless path falls back to the schema
51
+ defaults, and — structurally — that **no namespace bound by the settings page is ever
52
+ registered from the preset layer**. That last one is the abstract shape of this bug and is
53
+ what stops it from coming back.
54
+ - **Four-way readiness notice** for the preferences card: loading, remote/temporary
55
+ connection (`mode === 'memory'`), namespace not registered, and read-only document. The
56
+ previous code only covered the first two, leaving the rest as silently disabled controls.
57
+
58
+ ### Fixed
59
+ - The two preferences could not be changed before the first XingYuan session of the process
60
+ — the root cause described above.
61
+ - The write-confirmation toggle rendered as **on** whenever its value was unavailable
62
+ (`value?.confirmWrites !== false` reads "unknown" as "true"), which is the worst possible
63
+ default for a safety switch. It now falls back to the schema default explicitly.
64
+ - Namespace-unavailable and read-only states disabled the controls with no explanation.
65
+ - The "did not save" toast could fire on a write that **had** been saved. Both controls share
66
+ one settings scope, and the client controller fences writes with a generation counter: when
67
+ a write is superseded by a newer one it only records a pending revision and does **not**
68
+ fold the result into the snapshot, while the superseded write's `.then` still runs first and
69
+ sees the stale value. Verification is now skipped for superseded writes, and the number
70
+ input got the pending guard the toggle already had.
71
+ - Clearing the memory-injection-limit field and blurring reported "enter an integer between 5
72
+ and 200" while simultaneously refilling the field with the saved value — two contradictory
73
+ messages at once. An empty field is again treated as abandoning the edit.
74
+ - Tool descriptions for the creation / check-in / cancel-check-in tools asserted that a system
75
+ confirmation card would appear. Toggle **Confirm write actions** off and the model kept
76
+ telling users a card had been shown when none was. Tool descriptions are baked in at
77
+ registration and dsh-tools requires a plain string (no getter), so they are now worded to
78
+ hold in both states instead of asserting the card. Delete tools are unaffected — deletes
79
+ always confirm.
80
+
81
+ ### Changed
82
+ - The preset layer no longer registers a settings namespace. It reads preferences through
83
+ `ctx.xingyuan.prefs()` — a thunk that resolves the current value on every call, so edits
84
+ still take effect immediately without re-registering tools or prompts. The remaining
85
+ `Config` fields (`batchWishLimit`, `chartTrendDays`, …) have no settings UI and stay as
86
+ composition-only entry config, which matches the upstream rule that a namespace should
87
+ carry only the user-editable subset.
88
+ - **Breaking for `./domain` consumers**: `makeXingyuanStore(domain)` now takes a second,
89
+ required argument — `makeXingyuanStore(domain, readPrefs)`, where `readPrefs` is a thunk
90
+ returning the resolved chat preferences. The required-ness is deliberate: every call site
91
+ (one in production, five in tests) is surfaced by the compiler rather than silently
92
+ missing the wiring. `XingyuanStore` gained a matching `prefs()` method.
93
+
94
+ ### Removed
95
+ - The preset-layer `xingyuan` settings namespace and the `confirmWrites` /
96
+ `memoryInjectLimit` fields of the preset `Config`. **A value customised under the old
97
+ `xingyuan:` section of `$DSH_HOME/settings.yaml` is no longer read and falls back to the
98
+ default** — `confirmWrites` therefore returns to `true`, the conservative direction. The
99
+ stale section is left in the document and is harmless.
100
+
8
101
  ## [0.5.2] - 2026-08-27
9
102
 
10
103
  ### Added
@@ -230,11 +230,14 @@ const ZH_DICT = {
230
230
  'settings.pref.title': '对话偏好',
231
231
  'settings.pref.unavailable': '当前连接不支持持久化偏好(远程或临时模式),以下两项不可用。',
232
232
  'settings.pref.loading': '偏好加载中…',
233
+ 'settings.pref.notRegistered': '偏好暂不可用(组件未就绪),请稍后重试。',
234
+ 'settings.pref.readOnly': '当前设置文档为只读,无法保存修改。',
233
235
  'settings.pref.confirmWrites': '写操作二次确认',
234
236
  'settings.pref.confirmWritesHint': '创建愿望/任务、打卡、取消打卡时先弹应用内确认卡(删除始终确认);关闭后对话中的这类操作将直接执行。',
235
237
  'settings.pref.memoryLimit': '记忆注入上限',
236
238
  'settings.pref.memoryLimitHint': '每次对话自动注入上下文的记忆条数上限(5-200,默认 40);失焦后保存。',
237
239
  'settings.pref.limitInvalid': '请输入 5-200 的整数',
240
+ 'settings.pref.writeFailed': '保存未生效,请重试。',
238
241
  'settings.tabs.title': '标签页显示',
239
242
  'settings.tabs.mode.follow': '跟随会话',
240
243
  'settings.tabs.mode.show': '始终显示',
@@ -536,11 +539,14 @@ const EN_DICT = {
536
539
  'settings.pref.title': 'Chat preferences',
537
540
  'settings.pref.unavailable': 'This connection cannot persist preferences (remote or temporary mode); the two options below are disabled.',
538
541
  'settings.pref.loading': 'Loading preferences…',
542
+ 'settings.pref.notRegistered': 'Preferences are unavailable right now (component not ready); please retry later.',
543
+ 'settings.pref.readOnly': 'The settings document is read-only; changes cannot be saved.',
539
544
  'settings.pref.confirmWrites': 'Confirm write actions',
540
545
  'settings.pref.confirmWritesHint': 'Show an in-app confirmation before creating wishes/tasks, checking in or canceling (deletes always confirm). Turn off to execute directly.',
541
546
  'settings.pref.memoryLimit': 'Memory injection limit',
542
547
  'settings.pref.memoryLimitHint': 'Max memories auto-injected per chat (5-200, default 40); saved on blur.',
543
548
  'settings.pref.limitInvalid': 'Enter an integer between 5 and 200',
549
+ 'settings.pref.writeFailed': 'The change did not save; please try again.',
544
550
  'settings.tabs.title': 'Tab visibility',
545
551
  'settings.tabs.mode.follow': 'Follow session',
546
552
  'settings.tabs.mode.show': 'Always show',
@@ -79,10 +79,12 @@ export function apply(ctx) {
79
79
  // label 用 thunk 跟随当前语言;locale 声明让壳在语言切换时刷新标签行。
80
80
  ctx.slots.inject('conversation.view', () => installTabVisibility(ctx));
81
81
  // 星愿设置整页(设置 → 星愿):教练风格/画像(星愿库)+ 二次确认开关与注入上限
82
- // (preset 命名空间)+ 标签页显隐(bundle 常驻命名空间 xingyuan-ui,未选星愿也可调)
82
+ // (bundle 常驻命名空间 xingyuan-pref)+ 标签页显隐(bundle 常驻命名空间 xingyuan-ui)。
83
+ // 两个偏好命名空间都挂在常驻层:整页由本文件无条件注册,命名空间若随 preset 懒加载
84
+ // 缺席,就会出现「整页可见但两项写不进去且静默失败」。
83
85
  ctx.slots.inject('settings.section', () => {
84
- const scope = ctx.settingsScope.bind({ namespace: 'xingyuan' });
86
+ const prefscope = ctx.settingsScope.bind({ namespace: 'xingyuan-pref' });
85
87
  const uiscope = ctx.settingsScope.bind({ namespace: 'xingyuan-ui' });
86
- return ctx.slots.register({ name: 'settings.section', id: 'xingyuan', order: 60, label: () => t('settings.tabLabel'), locale: XY_NS }, () => SettingsSection({ scope, uiscope }));
88
+ return ctx.slots.register({ name: 'settings.section', id: 'xingyuan', order: 60, label: () => t('settings.tabLabel'), locale: XY_NS }, () => SettingsSection({ scope: prefscope, uiscope }));
87
89
  });
88
90
  }
@@ -1,10 +1,12 @@
1
1
  /** 星愿设置整页(设置 → 星愿):教练风格/画像(星愿库)+ 二次确认开关与注入上限
2
- * (preset 命名空间)+ 标签页显隐(bundle 常驻命名空间 xingyuan-ui,未选星愿也可调)。 */
2
+ * (bundle 常驻命名空间 xingyuan-pref)+ 标签页显隐(bundle 常驻命名空间 xingyuan-ui,
3
+ * 未选星愿也可调)。 */
3
4
  import { createElement, useEffect, useRef, useState, useSyncExternalStore } from 'react';
4
5
  import { getJson, postJson } from '../api.js';
5
6
  import { toastError } from '../ui.js';
6
7
  import { useXyT, activeLocale } from '../i18n.js';
7
8
  import { TAB_IDS } from '../../tab-policy.js';
9
+ import { MEMORY_LIMIT_MAX, MEMORY_LIMIT_MIN, PREF_DEFAULTS, parseMemoryLimit, } from '../../pref-policy.js';
8
10
  const COACH_IDS = ['gentle', 'strict', 'humorous'];
9
11
  const COACH_KEYS = ['settings.coach.gentle', 'settings.coach.strict', 'settings.coach.humorous'];
10
12
  /** 显隐三态(顺序即分段按钮顺序;与 tab-policy 字面量同源)。 */
@@ -28,7 +30,7 @@ export function SettingsSection(props) {
28
30
  // 正确订阅:useSyncExternalStore + getSnapshot(scope 契约见 dsh-client-runtime contract/settings-scope)
29
31
  const snap = useSyncExternalStore((listener) => scope.subscribe(listener), () => scope.getSnapshot());
30
32
  const writable = snap.status === 'ready' && snap.writable;
31
- const limit = String(snap.value?.memoryInjectLimit ?? 40);
33
+ const limit = String(snap.value?.memoryInjectLimit ?? PREF_DEFAULTS.memoryInjectLimit);
32
34
  // 界面偏好命名空间(标签页显隐)独立订阅:两个命名空间各自走自己的快照
33
35
  const uisnap = useSyncExternalStore((listener) => props.uiscope.subscribe(listener), () => props.uiscope.getSnapshot());
34
36
  const uiWritable = uisnap.status === 'ready' && uisnap.writable;
@@ -41,6 +43,9 @@ export function SettingsSection(props) {
41
43
  // 记忆注入上限:草稿态编辑(null=显示快照值),失焦才校验提交——避免逐键持久化与非法中间值死锁
42
44
  const [limitDraft, setLimitDraft] = useState(null);
43
45
  const [limitError, setLimitError] = useState(false);
46
+ // 记忆注入上限:写入在途的乐观值(与开关的 pendingToggle 同款,用于禁用控件并顶住回显,
47
+ // 否则提交后到快照回折前会闪回旧值)
48
+ const [pendingLimit, setPendingLimit] = useState(undefined);
44
49
  const [saving, setSaving] = useState(false);
45
50
  const [savedMsg, setSavedMsg] = useState('');
46
51
  // 二次确认开关:本地乐观值(undefined=跟随远端快照)——写入在途时 UI 立即响应,失败回滚
@@ -51,8 +56,20 @@ export function SettingsSection(props) {
51
56
  const savedTimer = useRef(undefined);
52
57
  // 档案加载代数守卫:慢响应不得覆盖用户已开始的草稿
53
58
  const profileSeqRef = useRef(0);
54
- // 开关显示值:乐观本地值优先(写入在途),否则跟随远端快照
55
- const confirmWrites = pendingToggle !== undefined ? pendingToggle : snap.value?.confirmWrites !== false;
59
+ // 偏好写序号:两个控件共用同一个 scope(即同一条写队列)。控制器以 writeGeneration
60
+ // 做栅栏——一次写被更新的写取代时只记 pendingRevision、不回折快照,而其 .then 又
61
+ // 先于后继写执行,此时比对快照必然读到旧值。故只有最后结算的那次有权判定成败。
62
+ const writeSeqRef = useRef(0);
63
+ // 开关显示值:乐观本地值优先(写入在途),否则远端快照,值缺席回落 schema 默认。
64
+ // 不可写成 `!== false`——那样「值未知」会被渲染成「已开启」,安全策略类开关尤其不能撒谎。
65
+ const confirmWrites = pendingToggle ?? snap.value?.confirmWrites ?? PREF_DEFAULTS.confirmWrites;
66
+ // 偏好区提示:判定顺序不可换——memory 模式下 status 同样是 unavailable,
67
+ // 先判 mode 才不会把「远程/临时模式」误报成「命名空间未就绪」
68
+ const prefNoticeKey = snap.status === 'loading' ? 'settings.pref.loading'
69
+ : snap.mode === 'memory' ? 'settings.pref.unavailable'
70
+ : snap.status === 'unavailable' ? 'settings.pref.notRegistered'
71
+ : !snap.writable ? 'settings.pref.readOnly'
72
+ : undefined;
56
73
  // 标签页显隐显示值:乐观优先,否则远端快照,再兜底 schema 默认
57
74
  const tabMode = pendingMode ?? uisnap.value?.tabVisibilityMode ?? 'follow';
58
75
  const hiddenTabs = pendingHidden ?? uisnap.value?.hiddenTabs ?? [];
@@ -116,28 +133,59 @@ export function SettingsSection(props) {
116
133
  setSaving(true);
117
134
  run().catch(toastError).finally(() => setSaving(false));
118
135
  };
136
+ /**
137
+ * 校验偏好是否真的落盘:scope.set() 失败时是 resolve 而非 reject(内部 catch 后静默
138
+ * recover),故只能在写入结算后比对快照——值没变即视为未保存,须显式告知用户。
139
+ *
140
+ * seq 为发起写时取的号:只有仍是队列里最后一次写才校验,否则快照本就不会回折本次
141
+ * 的结果(被更新的写取代),比对必然误报。见 writeSeqRef。
142
+ */
143
+ const verifyWritten = (seq, field, value) => {
144
+ if (seq !== writeSeqRef.current)
145
+ return;
146
+ if (scope.getSnapshot().value?.[field] !== value)
147
+ toastError(new Error(t('settings.pref.writeFailed')));
148
+ };
119
149
  const commitLimit = () => {
120
150
  const raw = limitDraft;
121
151
  setLimitDraft(null);
122
152
  setLimitError(false);
123
- if (raw === null || !writable || raw.trim() === '')
153
+ if (raw === null || !writable)
124
154
  return;
125
- const n = Number(raw);
155
+ // 空串视为放弃编辑:静默回显保存值。若在此报错,会同时出现「已填回旧值」与
156
+ // 「请输入 5-200 的整数」两条互相矛盾的信息。
157
+ if (raw.trim() === '')
158
+ return;
159
+ const parsed = parseMemoryLimit(raw);
126
160
  // 非法输入就地报错(不再静默回弹):用户明确知道哪里错、该怎么改
127
- if (!Number.isFinite(n) || Math.round(n) !== n) {
161
+ if (parsed === undefined) {
128
162
  setLimitError(true);
129
163
  return;
130
164
  }
131
- const clamped = Math.min(200, Math.max(5, Math.round(n)));
132
- if (clamped !== Number(raw)) {
133
- // 越界但可夹取:直接按夹取值提交并回显,同时给出行内提示
134
- void scope.set('memoryInjectLimit', clamped).then(() => setLimitDraft(String(clamped))).catch(toastError);
165
+ if (parsed.clamped) {
166
+ // 越界但可夹取:按夹取值提交并回显,同时给出行内提示
167
+ const seq = ++writeSeqRef.current;
168
+ setPendingLimit(parsed.value);
169
+ void scope.set('memoryInjectLimit', parsed.value)
170
+ .then(() => {
171
+ setLimitDraft(String(parsed.value));
172
+ setPendingLimit(undefined);
173
+ verifyWritten(seq, 'memoryInjectLimit', parsed.value);
174
+ })
175
+ .catch((err) => { setPendingLimit(undefined); toastError(err); });
135
176
  setLimitError(true);
136
177
  return;
137
178
  }
138
- if (clamped === snap.value?.memoryInjectLimit)
179
+ if (parsed.value === snap.value?.memoryInjectLimit)
139
180
  return;
140
- void scope.set('memoryInjectLimit', clamped).catch(toastError);
181
+ const seq = ++writeSeqRef.current;
182
+ setPendingLimit(parsed.value);
183
+ void scope.set('memoryInjectLimit', parsed.value)
184
+ .then(() => {
185
+ setPendingLimit(undefined);
186
+ verifyWritten(seq, 'memoryInjectLimit', parsed.value);
187
+ })
188
+ .catch((err) => { setPendingLimit(undefined); toastError(err); });
141
189
  };
142
190
  const coachLabel = (id) => {
143
191
  const index = COACH_IDS.indexOf(id);
@@ -198,11 +246,11 @@ export function SettingsSection(props) {
198
246
  className: 'xy-btn xy-btn-primary', disabled: saving, onClick: saveProfile,
199
247
  }, saving ? t('settings.profile.saving') : t('settings.profile.save')), savedMsg !== '' ? createElement('span', { className: 'xy-saved', role: 'status' },
200
248
  // 勾形装饰对读屏隐藏(语义由文案承担),与卡片完成态同一语法
201
- createElement('span', { 'aria-hidden': 'true' }, '✓ '), savedMsg) : null), createElement('p', { className: 'xy-hint' }, t('settings.profile.sharedHint'))), createElement('section', { className: 'xy-panel' }, createElement('h3', { className: 'xy-panel-head' }, t('settings.pref.title')), snap.status === 'unavailable'
202
- ? createElement('p', { className: 'xy-hint' }, t('settings.pref.unavailable'))
203
- : snap.status === 'loading'
204
- ? createElement('p', { className: 'xy-hint' }, t('settings.pref.loading'))
205
- : null, createElement('label', { className: 'xy-field' }, createElement('span', { className: 'xy-field-head' }, createElement('input', {
249
+ createElement('span', { 'aria-hidden': 'true' }, '✓ '), savedMsg) : null), createElement('p', { className: 'xy-hint' }, t('settings.profile.sharedHint'))), createElement('section', { className: 'xy-panel' }, createElement('h3', { className: 'xy-panel-head' }, t('settings.pref.title')),
250
+ // 整页 section 没有官方卡片那套「按命名空间自动显隐」的保护,失败呈现归注册方
251
+ prefNoticeKey !== undefined
252
+ ? createElement('p', { className: 'xy-hint' }, t(prefNoticeKey))
253
+ : null, createElement('label', { className: 'xy-field' }, createElement('span', { className: 'xy-field-head' }, createElement('input', {
206
254
  type: 'checkbox',
207
255
  className: 'xy-toggle',
208
256
  name: 'confirmWrites',
@@ -214,19 +262,23 @@ export function SettingsSection(props) {
214
262
  // 乐观写:先落 UI 再等持久化;失败回滚到快照口径并 toast
215
263
  const next = e.target.checked;
216
264
  setPendingToggle(next);
265
+ const seq = ++writeSeqRef.current;
217
266
  void scope.set('confirmWrites', next)
218
- .then(() => setPendingToggle(undefined))
267
+ .then(() => { setPendingToggle(undefined); verifyWritten(seq, 'confirmWrites', next); })
219
268
  .catch((err) => {
220
269
  setPendingToggle(undefined);
221
270
  toastError(err);
222
271
  });
223
272
  },
224
273
  }), t('settings.pref.confirmWrites')), createElement('p', { className: 'xy-hint' }, t('settings.pref.confirmWritesHint'))), createElement('label', { className: 'xy-field' }, createElement('span', { className: 'xy-field-head' }, t('settings.pref.memoryLimit')), createElement('input', {
225
- type: 'number', min: 5, max: 200, className: 'xy-input xy-input-num', name: 'memoryInjectLimit', inputMode: 'numeric',
274
+ type: 'number', min: MEMORY_LIMIT_MIN, max: MEMORY_LIMIT_MAX,
275
+ className: 'xy-input xy-input-num', name: 'memoryInjectLimit', inputMode: 'numeric',
226
276
  'aria-label': t('settings.pref.memoryLimit'),
227
277
  'aria-invalid': limitError || undefined,
228
278
  ...(limitError ? { 'aria-describedby': 'xy-limit-error' } : {}),
229
- value: limitDraft ?? limit, disabled: !writable,
279
+ // 写入在途时顶住乐观值并禁用(与开关同款),避免闪回旧值与并发写交错
280
+ value: limitDraft ?? (pendingLimit !== undefined ? String(pendingLimit) : limit),
281
+ disabled: !writable || pendingLimit !== undefined,
230
282
  onChange: (e) => { setLimitDraft(e.target.value); setLimitError(false); },
231
283
  onBlur: commitLimit,
232
284
  onKeyDown: (e) => {
package/lib/client.js CHANGED
@@ -226,11 +226,14 @@ const ZH_DICT = {
226
226
  "settings.pref.title": "对话偏好",
227
227
  "settings.pref.unavailable": "当前连接不支持持久化偏好(远程或临时模式),以下两项不可用。",
228
228
  "settings.pref.loading": "偏好加载中…",
229
+ "settings.pref.notRegistered": "偏好暂不可用(组件未就绪),请稍后重试。",
230
+ "settings.pref.readOnly": "当前设置文档为只读,无法保存修改。",
229
231
  "settings.pref.confirmWrites": "写操作二次确认",
230
232
  "settings.pref.confirmWritesHint": "创建愿望/任务、打卡、取消打卡时先弹应用内确认卡(删除始终确认);关闭后对话中的这类操作将直接执行。",
231
233
  "settings.pref.memoryLimit": "记忆注入上限",
232
234
  "settings.pref.memoryLimitHint": "每次对话自动注入上下文的记忆条数上限(5-200,默认 40);失焦后保存。",
233
235
  "settings.pref.limitInvalid": "请输入 5-200 的整数",
236
+ "settings.pref.writeFailed": "保存未生效,请重试。",
234
237
  "settings.tabs.title": "标签页显示",
235
238
  "settings.tabs.mode.follow": "跟随会话",
236
239
  "settings.tabs.mode.show": "始终显示",
@@ -522,11 +525,14 @@ const EN_DICT = {
522
525
  "settings.pref.title": "Chat preferences",
523
526
  "settings.pref.unavailable": "This connection cannot persist preferences (remote or temporary mode); the two options below are disabled.",
524
527
  "settings.pref.loading": "Loading preferences…",
528
+ "settings.pref.notRegistered": "Preferences are unavailable right now (component not ready); please retry later.",
529
+ "settings.pref.readOnly": "The settings document is read-only; changes cannot be saved.",
525
530
  "settings.pref.confirmWrites": "Confirm write actions",
526
531
  "settings.pref.confirmWritesHint": "Show an in-app confirmation before creating wishes/tasks, checking in or canceling (deletes always confirm). Turn off to execute directly.",
527
532
  "settings.pref.memoryLimit": "Memory injection limit",
528
533
  "settings.pref.memoryLimitHint": "Max memories auto-injected per chat (5-200, default 40); saved on blur.",
529
534
  "settings.pref.limitInvalid": "Enter an integer between 5 and 200",
535
+ "settings.pref.writeFailed": "The change did not save; please try again.",
530
536
  "settings.tabs.title": "Tab visibility",
531
537
  "settings.tabs.mode.follow": "Follow session",
532
538
  "settings.tabs.mode.show": "Always show",
@@ -3766,10 +3772,52 @@ function installTabVisibility(ctx) {
3766
3772
  };
3767
3773
  }
3768
3774
 
3775
+ //#endregion
3776
+ //#region src/pref-policy.ts
3777
+ /**
3778
+ * 星愿「对话偏好」的纯策略与常量(host/client 两半侧共用 + 测试对拍)。
3779
+ *
3780
+ * 独立成模块而非并入 pref-settings.ts 的理由:后者依赖 host-only 的
3781
+ * `@deepseek-ai/dsh-settings`,绝不可进浏览器包;而设置页需要上下界与默认值
3782
+ * 做输入夹取、回显兜底与不可用态兜底。形态对标 tab-policy.ts。
3783
+ *
3784
+ * 决策口径:
3785
+ * - confirmWrites:创建/打卡/取消打卡类写操作是否二次确认,默认开;
3786
+ * 删除类始终确认,不受此开关控制(破坏性操作不设开关)。
3787
+ * - memoryInjectLimit:记忆注入条数上限,默认 40,合法区间 [5,200] 的整数。
3788
+ * 越界输入夹取到区间内并回显;非整数/非数字/空一律拒绝,不静默改值。
3789
+ */
3790
+ /** 记忆注入上限的下界(含)。 */
3791
+ const MEMORY_LIMIT_MIN = 5;
3792
+ /** 记忆注入上限的上界(含)。 */
3793
+ const MEMORY_LIMIT_MAX = 200;
3794
+ /** 命名空间缺省(与 xingyuan-pref schema 默认值同源,见 src/pref-settings.ts)。 */
3795
+ const PREF_DEFAULTS = {
3796
+ confirmWrites: true,
3797
+ memoryInjectLimit: 40
3798
+ };
3799
+ /**
3800
+ * 解析「记忆注入上限」输入:非整数、非数字、空串一律返回 undefined(拒绝);
3801
+ * 越界值夹取到 [MIN,MAX] 且 clamped 置 true(调用方据此给出行内提示并回显)。
3802
+ */
3803
+ function parseMemoryLimit(raw) {
3804
+ const trimmed = raw.trim();
3805
+ if (trimmed === "") return void 0;
3806
+ const n = Number(trimmed);
3807
+ if (!Number.isFinite(n)) return void 0;
3808
+ if (Math.round(n) !== n) return void 0;
3809
+ const value = Math.min(200, Math.max(5, Math.round(n)));
3810
+ return {
3811
+ value,
3812
+ clamped: value !== n
3813
+ };
3814
+ }
3815
+
3769
3816
  //#endregion
3770
3817
  //#region src/client/pages/settings.ts
3771
3818
  /** 星愿设置整页(设置 → 星愿):教练风格/画像(星愿库)+ 二次确认开关与注入上限
3772
- * (preset 命名空间)+ 标签页显隐(bundle 常驻命名空间 xingyuan-ui,未选星愿也可调)。 */
3819
+ * (bundle 常驻命名空间 xingyuan-pref)+ 标签页显隐(bundle 常驻命名空间 xingyuan-ui,
3820
+ * 未选星愿也可调)。 */
3773
3821
  const COACH_IDS = [
3774
3822
  "gentle",
3775
3823
  "strict",
@@ -3809,7 +3857,7 @@ function SettingsSection(props) {
3809
3857
  const scope = props.scope;
3810
3858
  const snap = (0, react.useSyncExternalStore)((listener) => scope.subscribe(listener), () => scope.getSnapshot());
3811
3859
  const writable = snap.status === "ready" && snap.writable;
3812
- const limit = String(snap.value?.memoryInjectLimit ?? 40);
3860
+ const limit = String(snap.value?.memoryInjectLimit ?? PREF_DEFAULTS.memoryInjectLimit);
3813
3861
  const uisnap = (0, react.useSyncExternalStore)((listener) => props.uiscope.subscribe(listener), () => props.uiscope.getSnapshot());
3814
3862
  const uiWritable = uisnap.status === "ready" && uisnap.writable;
3815
3863
  const [profile, setProfile] = (0, react.useState)(void 0);
@@ -3819,6 +3867,7 @@ function SettingsSection(props) {
3819
3867
  const [interestsDraft, setInterestsDraft] = (0, react.useState)("");
3820
3868
  const [limitDraft, setLimitDraft] = (0, react.useState)(null);
3821
3869
  const [limitError, setLimitError] = (0, react.useState)(false);
3870
+ const [pendingLimit, setPendingLimit] = (0, react.useState)(void 0);
3822
3871
  const [saving, setSaving] = (0, react.useState)(false);
3823
3872
  const [savedMsg, setSavedMsg] = (0, react.useState)("");
3824
3873
  const [pendingToggle, setPendingToggle] = (0, react.useState)(void 0);
@@ -3826,7 +3875,9 @@ function SettingsSection(props) {
3826
3875
  const [pendingHidden, setPendingHidden] = (0, react.useState)(void 0);
3827
3876
  const savedTimer = (0, react.useRef)(void 0);
3828
3877
  const profileSeqRef = (0, react.useRef)(0);
3829
- const confirmWrites = pendingToggle !== void 0 ? pendingToggle : snap.value?.confirmWrites !== false;
3878
+ const writeSeqRef = (0, react.useRef)(0);
3879
+ const confirmWrites = pendingToggle ?? snap.value?.confirmWrites ?? PREF_DEFAULTS.confirmWrites;
3880
+ const prefNoticeKey = snap.status === "loading" ? "settings.pref.loading" : snap.mode === "memory" ? "settings.pref.unavailable" : snap.status === "unavailable" ? "settings.pref.notRegistered" : !snap.writable ? "settings.pref.readOnly" : void 0;
3830
3881
  const tabMode = pendingMode ?? uisnap.value?.tabVisibilityMode ?? "follow";
3831
3882
  const hiddenTabs = pendingHidden ?? uisnap.value?.hiddenTabs ?? [];
3832
3883
  /** 切换显隐模式:乐观写,失效即回滚(分段按钮视觉与持久化口径一致)。 */
@@ -3876,24 +3927,52 @@ function SettingsSection(props) {
3876
3927
  setSaving(true);
3877
3928
  run().catch(toastError).finally(() => setSaving(false));
3878
3929
  };
3930
+ /**
3931
+ * 校验偏好是否真的落盘:scope.set() 失败时是 resolve 而非 reject(内部 catch 后静默
3932
+ * recover),故只能在写入结算后比对快照——值没变即视为未保存,须显式告知用户。
3933
+ *
3934
+ * seq 为发起写时取的号:只有仍是队列里最后一次写才校验,否则快照本就不会回折本次
3935
+ * 的结果(被更新的写取代),比对必然误报。见 writeSeqRef。
3936
+ */
3937
+ const verifyWritten = (seq, field, value) => {
3938
+ if (seq !== writeSeqRef.current) return;
3939
+ if (scope.getSnapshot().value?.[field] !== value) toastError(new Error(t("settings.pref.writeFailed")));
3940
+ };
3879
3941
  const commitLimit = () => {
3880
3942
  const raw = limitDraft;
3881
3943
  setLimitDraft(null);
3882
3944
  setLimitError(false);
3883
- if (raw === null || !writable || raw.trim() === "") return;
3884
- const n = Number(raw);
3885
- if (!Number.isFinite(n) || Math.round(n) !== n) {
3945
+ if (raw === null || !writable) return;
3946
+ if (raw.trim() === "") return;
3947
+ const parsed = parseMemoryLimit(raw);
3948
+ if (parsed === void 0) {
3886
3949
  setLimitError(true);
3887
3950
  return;
3888
3951
  }
3889
- const clamped = Math.min(200, Math.max(5, Math.round(n)));
3890
- if (clamped !== Number(raw)) {
3891
- scope.set("memoryInjectLimit", clamped).then(() => setLimitDraft(String(clamped))).catch(toastError);
3952
+ if (parsed.clamped) {
3953
+ const seq = ++writeSeqRef.current;
3954
+ setPendingLimit(parsed.value);
3955
+ scope.set("memoryInjectLimit", parsed.value).then(() => {
3956
+ setLimitDraft(String(parsed.value));
3957
+ setPendingLimit(void 0);
3958
+ verifyWritten(seq, "memoryInjectLimit", parsed.value);
3959
+ }).catch((err) => {
3960
+ setPendingLimit(void 0);
3961
+ toastError(err);
3962
+ });
3892
3963
  setLimitError(true);
3893
3964
  return;
3894
3965
  }
3895
- if (clamped === snap.value?.memoryInjectLimit) return;
3896
- scope.set("memoryInjectLimit", clamped).catch(toastError);
3966
+ if (parsed.value === snap.value?.memoryInjectLimit) return;
3967
+ const seq = ++writeSeqRef.current;
3968
+ setPendingLimit(parsed.value);
3969
+ scope.set("memoryInjectLimit", parsed.value).then(() => {
3970
+ setPendingLimit(void 0);
3971
+ verifyWritten(seq, "memoryInjectLimit", parsed.value);
3972
+ }).catch((err) => {
3973
+ setPendingLimit(void 0);
3974
+ toastError(err);
3975
+ });
3897
3976
  };
3898
3977
  const coachLabel = (id) => {
3899
3978
  const index = COACH_IDS.indexOf(id);
@@ -3966,7 +4045,7 @@ function SettingsSection(props) {
3966
4045
  }, saving ? t("settings.profile.saving") : t("settings.profile.save")), savedMsg !== "" ? (0, react.createElement)("span", {
3967
4046
  className: "xy-saved",
3968
4047
  role: "status"
3969
- }, (0, react.createElement)("span", { "aria-hidden": "true" }, "✓ "), savedMsg) : null), (0, react.createElement)("p", { className: "xy-hint" }, t("settings.profile.sharedHint"))), (0, react.createElement)("section", { className: "xy-panel" }, (0, react.createElement)("h3", { className: "xy-panel-head" }, t("settings.pref.title")), snap.status === "unavailable" ? (0, react.createElement)("p", { className: "xy-hint" }, t("settings.pref.unavailable")) : snap.status === "loading" ? (0, react.createElement)("p", { className: "xy-hint" }, t("settings.pref.loading")) : null, (0, react.createElement)("label", { className: "xy-field" }, (0, react.createElement)("span", { className: "xy-field-head" }, (0, react.createElement)("input", {
4048
+ }, (0, react.createElement)("span", { "aria-hidden": "true" }, "✓ "), savedMsg) : null), (0, react.createElement)("p", { className: "xy-hint" }, t("settings.profile.sharedHint"))), (0, react.createElement)("section", { className: "xy-panel" }, (0, react.createElement)("h3", { className: "xy-panel-head" }, t("settings.pref.title")), prefNoticeKey !== void 0 ? (0, react.createElement)("p", { className: "xy-hint" }, t(prefNoticeKey)) : null, (0, react.createElement)("label", { className: "xy-field" }, (0, react.createElement)("span", { className: "xy-field-head" }, (0, react.createElement)("input", {
3970
4049
  type: "checkbox",
3971
4050
  className: "xy-toggle",
3972
4051
  name: "confirmWrites",
@@ -3976,7 +4055,11 @@ function SettingsSection(props) {
3976
4055
  if (!writable) return;
3977
4056
  const next = e.target.checked;
3978
4057
  setPendingToggle(next);
3979
- scope.set("confirmWrites", next).then(() => setPendingToggle(void 0)).catch((err) => {
4058
+ const seq = ++writeSeqRef.current;
4059
+ scope.set("confirmWrites", next).then(() => {
4060
+ setPendingToggle(void 0);
4061
+ verifyWritten(seq, "confirmWrites", next);
4062
+ }).catch((err) => {
3980
4063
  setPendingToggle(void 0);
3981
4064
  toastError(err);
3982
4065
  });
@@ -3991,8 +4074,8 @@ function SettingsSection(props) {
3991
4074
  "aria-label": t("settings.pref.memoryLimit"),
3992
4075
  "aria-invalid": limitError || void 0,
3993
4076
  ...limitError ? { "aria-describedby": "xy-limit-error" } : {},
3994
- value: limitDraft ?? limit,
3995
- disabled: !writable,
4077
+ value: limitDraft ?? (pendingLimit !== void 0 ? String(pendingLimit) : limit),
4078
+ disabled: !writable || pendingLimit !== void 0,
3996
4079
  onChange: (e) => {
3997
4080
  setLimitDraft(e.target.value);
3998
4081
  setLimitError(false);
@@ -4119,7 +4202,7 @@ function apply(ctx) {
4119
4202
  ]);
4120
4203
  ctx.slots.inject("conversation.view", () => installTabVisibility(ctx));
4121
4204
  ctx.slots.inject("settings.section", () => {
4122
- const scope = ctx.settingsScope.bind({ namespace: "xingyuan" });
4205
+ const prefscope = ctx.settingsScope.bind({ namespace: "xingyuan-pref" });
4123
4206
  const uiscope = ctx.settingsScope.bind({ namespace: "xingyuan-ui" });
4124
4207
  return ctx.slots.register({
4125
4208
  name: "settings.section",
@@ -4128,7 +4211,7 @@ function apply(ctx) {
4128
4211
  label: () => t("settings.tabLabel"),
4129
4212
  locale: XY_NS
4130
4213
  }, () => SettingsSection({
4131
- scope,
4214
+ scope: prefscope,
4132
4215
  uiscope
4133
4216
  }));
4134
4217
  });
package/lib/domain.js CHANGED
@@ -89,10 +89,11 @@ export const xingyuanDomainSpec = defineDomain({
89
89
  },
90
90
  });
91
91
  /** 由已打开领域构造服务句柄(bundle 入口组装用)。 */
92
- export function makeXingyuanStore(domain) {
92
+ export function makeXingyuanStore(domain, readPrefs) {
93
93
  return {
94
94
  spec: xingyuanDomainSpec,
95
95
  domain,
96
+ prefs: readPrefs,
96
97
  newId: () => randomUUID(),
97
98
  checkinKey: (taskId, date) => `${taskId}|${date}`,
98
99
  };
package/lib/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import z from '@deepseek-ai/schemastery';
2
2
  import { makeXingyuanStore, xingyuanDomainSpec } from './domain.js';
3
+ import { installPrefSettings } from './pref-settings.js';
3
4
  import { registerXingyuanRoutes } from './routes/index.js';
4
5
  import { ensurePresetRoot } from './preset-root.js';
5
6
  import { repairSessionLogs } from './session-log-repair.js';
@@ -28,6 +29,10 @@ export async function apply(ctx, config) {
28
29
  });
29
30
  // 界面偏好命名空间(标签页显隐)常驻注册:不依赖 settings 服务存在,服务挂载后自动生效
30
31
  installUiSettings(ctx);
32
+ // 对话偏好命名空间(二次确认 + 记忆注入上限)常驻注册:与界面偏好同款时序。
33
+ // 返回的读取 thunk 由领域服务持有,preset 层经 ctx.xingyuan.prefs() 读取——
34
+ // 命名空间必须常驻,否则整页可见而数据随 preset 懒加载缺席,写入静默失败。
35
+ const readPrefs = installPrefSettings(ctx);
31
36
  // preset 发布成功后再开领域;两步就绪后才 provide,注入方(preset 子树)由
32
37
  // cordis inject 语义等待本行激活完成
33
38
  await ensurePresetRoot();
@@ -37,7 +42,7 @@ export async function apply(ctx, config) {
37
42
  return;
38
43
  }
39
44
  domain = opened;
40
- ctx.provide('xingyuan', makeXingyuanStore(opened));
45
+ ctx.provide('xingyuan', makeXingyuanStore(opened, readPrefs));
41
46
  registerXingyuanRoutes(webServer, ctx.xingyuan, config);
42
47
  if (config.repairSessionLogs) {
43
48
  try {
@@ -0,0 +1,38 @@
1
+ /**
2
+ * 星愿「对话偏好」的纯策略与常量(host/client 两半侧共用 + 测试对拍)。
3
+ *
4
+ * 独立成模块而非并入 pref-settings.ts 的理由:后者依赖 host-only 的
5
+ * `@deepseek-ai/dsh-settings`,绝不可进浏览器包;而设置页需要上下界与默认值
6
+ * 做输入夹取、回显兜底与不可用态兜底。形态对标 tab-policy.ts。
7
+ *
8
+ * 决策口径:
9
+ * - confirmWrites:创建/打卡/取消打卡类写操作是否二次确认,默认开;
10
+ * 删除类始终确认,不受此开关控制(破坏性操作不设开关)。
11
+ * - memoryInjectLimit:记忆注入条数上限,默认 40,合法区间 [5,200] 的整数。
12
+ * 越界输入夹取到区间内并回显;非整数/非数字/空一律拒绝,不静默改值。
13
+ */
14
+ /** 记忆注入上限的下界(含)。 */
15
+ export const MEMORY_LIMIT_MIN = 5;
16
+ /** 记忆注入上限的上界(含)。 */
17
+ export const MEMORY_LIMIT_MAX = 200;
18
+ /** 命名空间缺省(与 xingyuan-pref schema 默认值同源,见 src/pref-settings.ts)。 */
19
+ export const PREF_DEFAULTS = {
20
+ confirmWrites: true,
21
+ memoryInjectLimit: 40,
22
+ };
23
+ /**
24
+ * 解析「记忆注入上限」输入:非整数、非数字、空串一律返回 undefined(拒绝);
25
+ * 越界值夹取到 [MIN,MAX] 且 clamped 置 true(调用方据此给出行内提示并回显)。
26
+ */
27
+ export function parseMemoryLimit(raw) {
28
+ const trimmed = raw.trim();
29
+ if (trimmed === '')
30
+ return undefined;
31
+ const n = Number(trimmed);
32
+ if (!Number.isFinite(n))
33
+ return undefined;
34
+ if (Math.round(n) !== n)
35
+ return undefined;
36
+ const value = Math.min(MEMORY_LIMIT_MAX, Math.max(MEMORY_LIMIT_MIN, Math.round(n)));
37
+ return { value, clamped: value !== n };
38
+ }
@@ -0,0 +1,27 @@
1
+ import z from '@deepseek-ai/schemastery';
2
+ import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings';
3
+ import { MEMORY_LIMIT_MAX, MEMORY_LIMIT_MIN, PREF_DEFAULTS } from './pref-policy.js';
4
+ /** 对话偏好命名空间(client 半侧 settingsScope 同名配对,勿改)。 */
5
+ export const PREF_NS = settingsNamespace('xingyuan-pref');
6
+ /** 配置 schema(默认值写进 schema;step(1) 让服务端也拒绝小数,防手改文档/RPC 直写)。 */
7
+ export const PrefSettingsSchema = z.object({
8
+ confirmWrites: z.boolean().default(PREF_DEFAULTS.confirmWrites),
9
+ memoryInjectLimit: z.number().step(1).min(MEMORY_LIMIT_MIN).max(MEMORY_LIMIT_MAX)
10
+ .default(PREF_DEFAULTS.memoryInjectLimit),
11
+ });
12
+ /**
13
+ * bundle 层安装对话偏好命名空间,并返回偏好读取 thunk。
14
+ *
15
+ * thunk 每次调用都读「当前解析值」(schema 默认 → base → 用户层),故设置热改后
16
+ * 下一次读取即生效,无需重建任何注册;settings 服务缺席时返回 PREF_DEFAULTS。
17
+ */
18
+ export function installPrefSettings(ctx) {
19
+ // 闭包局部,非模块级单例(AGENTS.md §8:不做任何跨重载的模块级单例状态)
20
+ let read = () => PREF_DEFAULTS;
21
+ installSettingsSection(ctx, PREF_NS, PrefSettingsSchema, PREF_DEFAULTS, {
22
+ setSource: (current) => { read = current; },
23
+ // 消费方(工具/提示词)每次执行即时读 thunk,无需在此重建任何东西
24
+ onChange: () => { },
25
+ });
26
+ return () => read();
27
+ }
@@ -1,5 +1,4 @@
1
1
  import z from '@deepseek-ai/schemastery';
2
- import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings';
3
2
  import { registerTools } from './tools.js';
4
3
  import { registerPrompts } from './prompts.js';
5
4
  /** Cordis 插件名。 */
@@ -8,8 +7,6 @@ export const name = 'xingyuan-preset-side';
8
7
  export const inject = ['tools', 'systemPrompt', 'userQuestions', 'xingyuan'];
9
8
  /** 配置 schema(默认值写进 schema;加载期校验失败即响亮报错)。 */
10
9
  export const Config = z.object({
11
- // 5-200 与设置页文案/客户端夹取范围同口径,服务端兜底防越界
12
- memoryInjectLimit: z.number().default(40).min(5).max(200),
13
10
  batchWishLimit: z.number().default(50),
14
11
  batchTaskLimit: z.number().default(100),
15
12
  chartTrendDays: z.number().default(14),
@@ -17,29 +14,21 @@ export const Config = z.object({
17
14
  chartMaxDays: z.number().default(90),
18
15
  chartRankLimit: z.number().default(10),
19
16
  chartRankMax: z.number().default(20),
20
- confirmWrites: z.boolean().default(true),
21
17
  });
22
- /** 星愿设置命名空间(与浏览器半侧卡片配对)。 */
23
- export const SETTINGS_NS = settingsNamespace('xingyuan');
24
18
  export function apply(ctx, config) {
25
- let source = () => config;
26
- // 设置卡命名空间:客户端经 settingsScope 写入,onChange 热生效(HMR 安全)
27
- installSettingsSection(ctx, SETTINGS_NS, Config, config, {
28
- setSource: (current) => { source = current; },
29
- onChange: () => { },
30
- });
31
- const current = () => source();
32
- // 以 getter 对象透传:设置卡热改后工具下一次 execute 即读新值(满足 ToolsConfig 形状)
19
+ // 对话偏好:bundle 层常驻命名空间的读取 thunk,每次调用取当前解析值
20
+ const prefs = () => ctx.xingyuan.prefs();
21
+ // 组合层参数进程内不变,直接取 entry config;对话偏好用 getter,保证热改即时生效
33
22
  const toolsConfig = {
34
- get batchWishLimit() { return current().batchWishLimit; },
35
- get batchTaskLimit() { return current().batchTaskLimit; },
36
- get chartTrendDays() { return current().chartTrendDays; },
37
- get chartDistributionDays() { return current().chartDistributionDays; },
38
- get chartMaxDays() { return current().chartMaxDays; },
39
- get chartRankLimit() { return current().chartRankLimit; },
40
- get chartRankMax() { return current().chartRankMax; },
41
- get confirmWrites() { return current().confirmWrites; },
23
+ batchWishLimit: config.batchWishLimit,
24
+ batchTaskLimit: config.batchTaskLimit,
25
+ chartTrendDays: config.chartTrendDays,
26
+ chartDistributionDays: config.chartDistributionDays,
27
+ chartMaxDays: config.chartMaxDays,
28
+ chartRankLimit: config.chartRankLimit,
29
+ chartRankMax: config.chartRankMax,
30
+ get confirmWrites() { return prefs().confirmWrites; },
42
31
  };
43
32
  registerTools(ctx, toolsConfig);
44
- registerPrompts(ctx, { get memoryInjectLimit() { return current().memoryInjectLimit; } });
33
+ registerPrompts(ctx, { get memoryInjectLimit() { return prefs().memoryInjectLimit; } });
45
34
  }
@@ -113,10 +113,20 @@ function requireTask(store, taskId) {
113
113
  function wishNameOf(store, task) {
114
114
  return task.wishId !== undefined ? store.domain.table('wishes').get(task.wishId)?.title : undefined;
115
115
  }
116
- /** 愿望记录构造已收口至 store.createWish/updateWish(校验同源);此处保留字段预检以便在确认卡前失败。 */
117
- const DELETE_NOTE = '删除不可恢复:会弹出系统确认卡片,直接调用等待确认结果即可,无需自行询问用户。';
118
- const CREATE_NOTE = '会弹出系统确认卡片展示完整创建内容,直接调用等待确认结果即可,无需自行询问用户;用户在确认卡取消则向用户说明并询问要调整的地方。';
119
- const CANCEL_CHECKIN_NOTE = '会弹出系统确认卡片,直接调用等待确认结果即可,无需自行询问用户。';
116
+ /**
117
+ * 写操作的模型侧提示文案(拼进工具 description)。
118
+ *
119
+ * 两类不可混用:
120
+ * - `DELETE_NOTE`:删除类**始终**弹确认卡,不受设置开关影响,故可以断言「会弹卡」。
121
+ * - `CREATE_NOTE` / `CANCEL_CHECKIN_NOTE`:受「写操作二次确认」开关门控(见
122
+ * `confirmGate`),而工具描述是注册时烘焙的静态字符串——dsh-tools 校验
123
+ * `description` 必须是 string(不支持 getter),改设置也不会重建描述。故这两条
124
+ * 一律写成「开与关都成立」的措辞:绝不可断言会弹卡,否则关掉开关后模型会向用户
125
+ * 宣称已弹出确认卡,而实际没有卡片。
126
+ */
127
+ const DELETE_NOTE = '删除不可恢复:会弹出系统确认卡片,直接调用等待确认结果即可,无需事先询问用户。';
128
+ const CREATE_NOTE = '无需事先询问用户,直接调用即可;结果返回前不要宣称已创建。若系统弹出确认卡片且用户在卡片上取消,则向用户说明并询问要调整的地方。';
129
+ const CANCEL_CHECKIN_NOTE = '无需事先询问用户,直接调用即可;结果返回前不要宣称已取消。';
120
130
  /** 分类颜色白名单(schema enum 与展示色共用同一事实源)。 */
121
131
  const COLOR_KEY_ENUM = CATEGORY_COLOR_KEYS;
122
132
  const COLOR_KEY_NOTE = `可选值:${CATEGORY_COLOR_KEYS.join('/')}(不传则按分类名自动配色)`;
@@ -629,7 +639,8 @@ export function registerTools(ctx, config) {
629
639
  ctx.tools.register(defineTool({
630
640
  name: 'check_in_task',
631
641
  description: '任务打卡。何时使用:用户完成任务并需要记录打卡时使用。支持指定日期打卡(日历补卡/提前勾),不传日期则自动勾选今天(含)起最早未勾选的打卡日——过去的日期不会自动补,补卡请在日历中指定日期。'
632
- + '注意:本工具会弹出系统确认卡片,确认后自动执行;今天不是打卡日时自动勾选的是未来日期(提前打卡 = 承诺当天完成),回复时请用「打卡日」等通俗用语并如实告知勾选日期。',
642
+ // 与 CREATE_NOTE 同口径:是否弹卡受设置门控,描述里不可断言(见 DELETE_NOTE 组注释)
643
+ + '注意:无需事先询问用户,直接调用即可;今天不是打卡日时自动勾选的是未来日期(提前打卡 = 承诺当天完成),回复时请用「打卡日」等通俗用语并如实告知勾选日期。',
633
644
  parameters: {
634
645
  taskId: { type: 'string', required: true, description: '任务ID,取列表返回的真实值' },
635
646
  checkInDate: { type: 'string', description: '打卡日期,可选。yyyy-MM-dd,必须是任务的打卡日;不传则自动勾选今天(含)起最早未勾选的打卡日' },
@@ -203,11 +203,14 @@ declare const ZH_DICT: {
203
203
  readonly 'settings.pref.title': "对话偏好";
204
204
  readonly 'settings.pref.unavailable': "当前连接不支持持久化偏好(远程或临时模式),以下两项不可用。";
205
205
  readonly 'settings.pref.loading': "偏好加载中…";
206
+ readonly 'settings.pref.notRegistered': "偏好暂不可用(组件未就绪),请稍后重试。";
207
+ readonly 'settings.pref.readOnly': "当前设置文档为只读,无法保存修改。";
206
208
  readonly 'settings.pref.confirmWrites': "写操作二次确认";
207
209
  readonly 'settings.pref.confirmWritesHint': "创建愿望/任务、打卡、取消打卡时先弹应用内确认卡(删除始终确认);关闭后对话中的这类操作将直接执行。";
208
210
  readonly 'settings.pref.memoryLimit': "记忆注入上限";
209
211
  readonly 'settings.pref.memoryLimitHint': "每次对话自动注入上下文的记忆条数上限(5-200,默认 40);失焦后保存。";
210
212
  readonly 'settings.pref.limitInvalid': "请输入 5-200 的整数";
213
+ readonly 'settings.pref.writeFailed': "保存未生效,请重试。";
211
214
  readonly 'settings.tabs.title': "标签页显示";
212
215
  readonly 'settings.tabs.mode.follow': "跟随会话";
213
216
  readonly 'settings.tabs.mode.show': "始终显示";
@@ -1,15 +1,18 @@
1
1
  /** 星愿设置整页(设置 → 星愿):教练风格/画像(星愿库)+ 二次确认开关与注入上限
2
- * (preset 命名空间)+ 标签页显隐(bundle 常驻命名空间 xingyuan-ui,未选星愿也可调)。 */
2
+ * (bundle 常驻命名空间 xingyuan-pref)+ 标签页显隐(bundle 常驻命名空间 xingyuan-ui,
3
+ * 未选星愿也可调)。 */
3
4
  import { type ReactElement } from 'react';
4
5
  import { type TabId, type TabVisibilityMode } from '../../tab-policy.js';
5
- /** 与 dsh-client-runtime 的 SettingsScope 快照对齐的叶子视图(只读字段)。 */
6
- interface ScopeSnapshotLike {
6
+ /** xingyuan-pref 命名空间快照(对话偏好字段)。 */
7
+ interface PrefScopeSnapshotLike {
7
8
  readonly status: 'loading' | 'ready' | 'unavailable';
8
9
  readonly value?: {
9
10
  readonly confirmWrites?: boolean;
10
11
  readonly memoryInjectLimit?: number;
11
12
  };
12
13
  readonly writable: boolean;
14
+ /** `host` 与宿主文档同步;`memory` 为远程/临时模式,此时任何写入都不会落盘。 */
15
+ readonly mode: 'host' | 'memory';
13
16
  }
14
17
  /** xingyuan-ui 命名空间快照(标签页显隐字段)。 */
15
18
  interface UiScopeSnapshotLike {
@@ -30,15 +33,17 @@ export interface UiScopeLike {
30
33
  /**
31
34
  * settingsScope.bind() 返回的真实形状:getSnapshot()/subscribe()/set()/unset()。
32
35
  * 注意 scope 本身没有 .value 字段——值在快照里(此前直接读 scope.value 导致整节渲染崩溃)。
36
+ * 另:set() 失败是 resolve 而非 reject(内部 catch 后静默 recover),故调用方须事后
37
+ * 比对快照确认落盘,见下方 verifyWritten。
33
38
  */
34
- export interface SettingsScopeLike {
35
- getSnapshot(): ScopeSnapshotLike;
39
+ export interface PrefScopeLike {
40
+ getSnapshot(): PrefScopeSnapshotLike;
36
41
  subscribe(listener: () => void): () => void;
37
42
  set(field: string, value: unknown): Promise<void>;
38
43
  unset(field: string): Promise<void>;
39
44
  }
40
45
  export declare function SettingsSection(props: {
41
- scope: SettingsScopeLike;
46
+ scope: PrefScopeLike;
42
47
  uiscope: UiScopeLike;
43
48
  }): ReactElement;
44
49
  export {};
@@ -1,5 +1,6 @@
1
1
  import { type Domain, type DomainTableSpec } from '@deepseek-ai/dsh-storage-domain';
2
2
  import { z } from 'zod';
3
+ import type { PrefSettings } from './pref-policy.js';
3
4
  export declare const DOMAIN_VERSION = 1;
4
5
  /** 教练风格。 */
5
6
  export declare const COACH_STYLES: readonly ["gentle", "strict", "humorous"];
@@ -98,6 +99,8 @@ export type MemoryRecord = TableValue<(typeof xingyuanDomainSpec.tables)['memori
98
99
  export interface XingyuanStore {
99
100
  readonly spec: typeof xingyuanDomainSpec;
100
101
  readonly domain: Domain<typeof xingyuanDomainSpec>;
102
+ /** 对话偏好:bundle 层常驻命名空间的解析值(非领域数据),每次调用读当前值。 */
103
+ prefs(): PrefSettings;
101
104
  newId(): string;
102
105
  checkinKey(taskId: string, date: string): string;
103
106
  }
@@ -108,5 +111,5 @@ declare module '@deepseek-ai/cordis' {
108
111
  }
109
112
  type TableValue<S extends DomainTableSpec> = S extends DomainTableSpec<string, infer V> ? V : never;
110
113
  /** 由已打开领域构造服务句柄(bundle 入口组装用)。 */
111
- export declare function makeXingyuanStore(domain: Domain<typeof xingyuanDomainSpec>): XingyuanStore;
114
+ export declare function makeXingyuanStore(domain: Domain<typeof xingyuanDomainSpec>, readPrefs: () => PrefSettings): XingyuanStore;
112
115
  export {};
@@ -0,0 +1,34 @@
1
+ /**
2
+ * 星愿「对话偏好」的纯策略与常量(host/client 两半侧共用 + 测试对拍)。
3
+ *
4
+ * 独立成模块而非并入 pref-settings.ts 的理由:后者依赖 host-only 的
5
+ * `@deepseek-ai/dsh-settings`,绝不可进浏览器包;而设置页需要上下界与默认值
6
+ * 做输入夹取、回显兜底与不可用态兜底。形态对标 tab-policy.ts。
7
+ *
8
+ * 决策口径:
9
+ * - confirmWrites:创建/打卡/取消打卡类写操作是否二次确认,默认开;
10
+ * 删除类始终确认,不受此开关控制(破坏性操作不设开关)。
11
+ * - memoryInjectLimit:记忆注入条数上限,默认 40,合法区间 [5,200] 的整数。
12
+ * 越界输入夹取到区间内并回显;非整数/非数字/空一律拒绝,不静默改值。
13
+ */
14
+ /** 记忆注入上限的下界(含)。 */
15
+ export declare const MEMORY_LIMIT_MIN = 5;
16
+ /** 记忆注入上限的上界(含)。 */
17
+ export declare const MEMORY_LIMIT_MAX = 200;
18
+ /** 命名空间解析值(schema 默认 → base 层 → 用户层)。 */
19
+ export interface PrefSettings {
20
+ /** 创建/取消打卡类写操作是否二次确认(删除类始终确认)。 */
21
+ confirmWrites: boolean;
22
+ /** 每次对话自动注入上下文的记忆条数上限。 */
23
+ memoryInjectLimit: number;
24
+ }
25
+ /** 命名空间缺省(与 xingyuan-pref schema 默认值同源,见 src/pref-settings.ts)。 */
26
+ export declare const PREF_DEFAULTS: PrefSettings;
27
+ /**
28
+ * 解析「记忆注入上限」输入:非整数、非数字、空串一律返回 undefined(拒绝);
29
+ * 越界值夹取到 [MIN,MAX] 且 clamped 置 true(调用方据此给出行内提示并回显)。
30
+ */
31
+ export declare function parseMemoryLimit(raw: string): {
32
+ value: number;
33
+ clamped: boolean;
34
+ } | undefined;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * 星愿 bundle 层「对话偏好」设置命名空间(xingyuan-pref):
3
+ * 写操作二次确认(confirmWrites)与记忆注入上限(memoryInjectLimit)。
4
+ *
5
+ * 挂在 bundle 常驻层而非 preset 层的理由(本次根因,实测结论):
6
+ * 设置整页(settings.section)由 bundle client 层无条件注册、常驻可见;而 preset
7
+ * 挂载是**按 preset 常驻但懒加载**的(官方 agent-presets:per-preset standing
8
+ * mount,进程内只挂一次、只随整棵树卸载),首次开星愿会话才建立。于是每次 dsh
9
+ * 重启后、开过星愿会话之前,整页可见而命名空间不存在:两项均 unavailable,
10
+ * 写入静默失败(client scope.set() 失败是 resolve 而非 reject),表现为
11
+ * 「点了弹回原样、没有任何提示」。
12
+ *
13
+ * 官方 cookbook 的 settings.plugin.item 卡片按「Host 是否服务该命名空间」自动显隐,
14
+ * 整页 section 没有这层保护——slots.d.ts 契约把失败呈现的责任明确交给注册方。
15
+ * 故此处让数据常驻以对齐常驻 UI,而非让 UI 跟随数据(那会让设置页出现
16
+ * 部分字段时有时无的割裂,且安全策略类设置「有时候找不到」不可接受)。
17
+ *
18
+ * installSettingsSection 经 ctx.inject(['settings']) 等待 settings 服务挂载,
19
+ * 服务缺席(headless 等)时整段不运行,bundle 激活不依赖它。
20
+ */
21
+ import type { Context } from '@deepseek-ai/cordis';
22
+ import z from '@deepseek-ai/schemastery';
23
+ import { type PrefSettings } from './pref-policy.js';
24
+ /** 对话偏好命名空间(client 半侧 settingsScope 同名配对,勿改)。 */
25
+ export declare const PREF_NS: import("@deepseek-ai/dsh-settings").SettingsNamespace;
26
+ /** 配置 schema(默认值写进 schema;step(1) 让服务端也拒绝小数,防手改文档/RPC 直写)。 */
27
+ export declare const PrefSettingsSchema: z<PrefSettings>;
28
+ /**
29
+ * bundle 层安装对话偏好命名空间,并返回偏好读取 thunk。
30
+ *
31
+ * thunk 每次调用都读「当前解析值」(schema 默认 → base → 用户层),故设置热改后
32
+ * 下一次读取即生效,无需重建任何注册;settings 服务缺席时返回 PREF_DEFAULTS。
33
+ */
34
+ export declare function installPrefSettings(ctx: Context): () => PrefSettings;
@@ -2,6 +2,11 @@
2
2
  * 星愿 preset 侧入口:工具与提示词只注册在 preset 层,不出现在未选择星愿的会话里。
3
3
  * 本文件由 presets/xingyuan/agent.cordis.yml 以裸包名子路径装载;发布服务必须留在
4
4
  * isolate realm(本插件只向 ctx 注册贡献,不 provide 服务,天然合规)。
5
+ *
6
+ * 对话偏好(写操作二次确认 / 记忆注入上限)不在本层提供:设置整页由 bundle client
7
+ * 层常驻注册,而 preset 挂载按 preset 常驻却懒加载——首次开星愿会话前命名空间缺席,
8
+ * 整页可见而数据不在,写入静默失败。两项已迁至 bundle 层常驻命名空间 xingyuan-pref
9
+ * (src/pref-settings.ts),本层经 ctx.xingyuan.prefs() 读取。
5
10
  */
6
11
  import type { Context } from '@deepseek-ai/cordis';
7
12
  import z from '@deepseek-ai/schemastery';
@@ -10,10 +15,8 @@ import type { XingyuanStore } from '../domain.js';
10
15
  export declare const name = "xingyuan-preset-side";
11
16
  /** 依赖:宿主组装常驻层提供的注册表与星愿领域。 */
12
17
  export declare const inject: string[];
13
- /** 插件配置。 */
18
+ /** 插件配置:仅组合层可调参数(无 Web 设置界面;用户可编辑偏好见 pref-settings.ts)。 */
14
19
  export interface Config {
15
- /** 记忆注入上限(条),默认 40。 */
16
- memoryInjectLimit: number;
17
20
  /** batch_query_user_data 愿望返回上限,默认 50。 */
18
21
  batchWishLimit: number;
19
22
  /** batch_query_user_data 任务返回上限,默认 100。 */
@@ -28,13 +31,9 @@ export interface Config {
28
31
  chartRankLimit: number;
29
32
  /** 排行/进度类返回条数上限,默认 20。 */
30
33
  chartRankMax: number;
31
- /** 创建/取消打卡类写操作二次确认(默认开;删除类始终确认)。 */
32
- confirmWrites: boolean;
33
34
  }
34
35
  /** 配置 schema(默认值写进 schema;加载期校验失败即响亮报错)。 */
35
36
  export declare const Config: z<Config>;
36
- /** 星愿设置命名空间(与浏览器半侧卡片配对)。 */
37
- export declare const SETTINGS_NS: import("@deepseek-ai/dsh-settings").SettingsNamespace;
38
37
  export declare function apply(ctx: Context & {
39
38
  xingyuan: XingyuanStore;
40
39
  }, config: Config): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starwish-ai/xingyuan-dsh",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "星愿 XingYuan for DeepSeek Harness: 对话创建愿望/任务、机会日打卡、微行动、记忆与图表的 Agent Preset 组合包",
5
5
  "type": "module",
6
6
  "license": "MIT",