@heixui/dsh-session-suite 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.zh.md CHANGED
@@ -175,18 +175,21 @@ docs/ 需求与 API 笔记
175
175
 
176
176
  ## 0.4.1 改了什么
177
177
 
178
- 新增侧边栏入口,并修掉三个「看起来能用、实际没生效」的问题。
178
+ 新增侧边栏入口,修掉几个「看起来能用、实际没生效」的问题,并把界面上说不清的数字说清楚。
179
179
 
180
180
  | 方面 | 改前 | 改后 |
181
181
  |---|---|---|
182
182
  | 入口位置 | 只能在 设置 → 会话快照 / 长期记忆 里找,三步才能到 | **左侧栏「会话套件」图标**,点开即用;设置页那三个重复入口已移除 |
183
- | 记忆总数 | 面板显示 17,设置页显示 51 —— 同一句话两个数 | 面板遍历**所有已登记工作区**,与设置页一致;每个数字都写明统计口径 |
184
- | 归档恢复 | 点了没反应,磁盘上的标记纹丝不动 | 修了参数签名(两个 Host 服务的入参形状不同),恢复真正生效 |
185
- | 长列表滚动 | 面板内容被裁掉,滚轮无效 | 面板可滚动 |
186
-
187
- **为什么数字会不一样(0.4.0 的真实缺陷)**:长期记忆分「全局库」和「各工作区的库」。
188
- `longterm.list` 不带 `cwd` 时只返回全局库(17 条),带上 `cwd` 才是全局 + 该工作区。
189
- 面板没有工作区概念,于是只显示了全局那 17 条。现在面板不带 `cwd` 时会遍历所有已登记工作区。
183
+ | 记忆总数 | 面板显示 17,设置页显示 51 —— 同一句话两个数 | 面板遍历**所有已登记工作区**;每个数字都写明统计口径,并给出分工作区明细 |
184
+ | 归档恢复 | 点了没反应,磁盘上的标记纹丝不动 | 修了两个 Host 服务参数形状不一致的问题,恢复真正生效 |
185
+ | 快照数量 | 预算 10 张,列表却有 16 张,无从解释 | 区分「自有 10 张」与「继承 6 张」,继承项打徽章并说明为何不计入预算 |
186
+ | 隐式提取模型 | 只能选「关闭」和「用当前模型」,看不到自己配的模型 | 从 Host 的 llm 服务枚举真实可用模型;**默认关闭,不选就不消耗 token** |
187
+ | 长列表 | 配置区被五十条记忆挤到看不见 | 列表独立滚动,配置区始终可见 |
188
+ | 会话记忆 | 一个从未写入的残缺区块,旁边就是 dsh 自带的轨迹 | 已移除 —— 轨迹是权威且不可改,手写笔记只会误导 |
189
+
190
+ **关于记忆总数为什么会有多个数字**:长期记忆分「全局库」和「各工作区的库」。
191
+ `longterm.list` 不带 `cwd` 时只返回全局库,带上才是全局 + 该工作区。面板没有工作区概念,
192
+ 于是只显示了全局那部分。现在面板会遍历所有已登记工作区,并在界面上直接列出每个库的条数。
190
193
 
191
194
  ## 测试
192
195
 
package/client.js CHANGED
@@ -141,6 +141,10 @@ window.__ModuleLoader__.load({
141
141
  'settings.unlock': 'Unlock',
142
142
  'settings.locked': 'Locked',
143
143
  'settings.budget': 'Keeps the newest {keep} automatic snapshots',
144
+ 'settings.budgetInherited': '+ {n} inherited',
145
+ 'settings.inherited': 'Inherited',
146
+ 'settings.budgetNote':
147
+ 'Only this session\'s own automatic snapshots count against the budget. Snapshots inherited from a parent session (created by a rewind or message delete) are kept separately and are why the list can be longer than {keep}.',
144
148
  'settings.usage': 'Snapshots use {size}',
145
149
  'settings.files': '{count} files',
146
150
  'settings.turn': 'Turn {turn}',
@@ -234,7 +238,7 @@ window.__ModuleLoader__.load({
234
238
  'panel.breakdownTitle': 'Where the memories live',
235
239
  'panel.breakdownGlobal': 'Global (shared by every workspace)',
236
240
  'panel.breakdownHint': 'Each workspace keeps its own store; the total above is the sum of these plus the global one.',
237
- 'panel.repoLink': 'Source on GitHub',
241
+ 'panel.repoLink': 'GitHub source — a small encouragement',
238
242
  'panel.noSettingsSeam': 'This Host build does not expose the settings navigation seam, so this row cannot open its page.',
239
243
  'archived.title': 'Archived sessions',
240
244
  'archived.description': 'Archived sessions are hidden from the sidebar. Restoring one puts it back; nothing is deleted.',
@@ -248,6 +252,8 @@ window.__ModuleLoader__.load({
248
252
  'memory.extractionTried': 'tried {n} memory model(s)',
249
253
  'memory.extractionQueued': '{n} queued',
250
254
  'memory.extractionRejected': '{n} rejected as unusable',
255
+ 'memory.extractionEvents': '{n} session event(s) received',
256
+ 'memory.extractionTurns': '{n} turn(s) ended',
251
257
  'memory.accept': 'Keep',
252
258
  'memory.reject': 'Discard',
253
259
  'memory.accepted': 'Kept',
@@ -260,6 +266,11 @@ window.__ModuleLoader__.load({
260
266
  'memory.maintainedCompressed': 'compressed',
261
267
  'memory.maintainedRules': 'rules',
262
268
  'memory.implicit': 'Suggest memories automatically',
269
+ 'memory.implicitModel': 'Model for automatic extraction',
270
+ 'memory.implicitModelOff': 'Off — no model call, costs nothing',
271
+ 'memory.implicitModelCurrent': 'Use the model this conversation is already using',
272
+ 'memory.implicitModelHint':
273
+ 'Automatic extraction sends one small request at the end of each turn. Choosing a model here is what authorises that spend; "Off" and "Use the current model" are the two ends of the tradeoff.',
263
274
  'memory.clearCold': 'Clear cold archive',
264
275
  'memory.coldCleared': 'Cold archive cleared',
265
276
  },
@@ -359,6 +370,10 @@ window.__ModuleLoader__.load({
359
370
  'settings.unlock': '解锁',
360
371
  'settings.locked': '已锁定',
361
372
  'settings.budget': '每个会话保留最近 {keep} 个自动快照',
373
+ 'settings.budgetInherited': '+ {n} 个继承',
374
+ 'settings.inherited': '继承',
375
+ 'settings.budgetNote':
376
+ '只有这个会话自己的自动快照计入预算。从父会话继承来的快照(回滚或删除消息时产生)单独保留,所以列表可能比 {keep} 长。',
362
377
  'settings.usage': '快照占用 {size}',
363
378
  'settings.files': '{count} 个文件',
364
379
  'settings.turn': '第 {turn} 轮',
@@ -452,7 +467,7 @@ window.__ModuleLoader__.load({
452
467
  'panel.breakdownTitle': '记忆存在哪',
453
468
  'panel.breakdownGlobal': '全局(所有工作区共享)',
454
469
  'panel.breakdownHint': '每个工作区有独立的库;上面的总数 = 这些之和 + 全局。',
455
- 'panel.repoLink': 'GitHub 源码',
470
+ 'panel.repoLink': 'GitHub 源码 - 鼓励一下',
456
471
  'panel.noSettingsSeam': '这个 Host 版本没有暴露设置导航接口,无法打开对应页面。',
457
472
  'archived.title': '归档会话',
458
473
  'archived.description': '归档的会话从侧边栏隐藏。恢复后会重新出现,不会删除任何东西。',
@@ -466,6 +481,8 @@ window.__ModuleLoader__.load({
466
481
  'memory.extractionTried': '试过 {n} 个记忆模型',
467
482
  'memory.extractionQueued': '入队 {n} 条',
468
483
  'memory.extractionRejected': '剔除 {n} 条',
484
+ 'memory.extractionEvents': '收到 {n} 个会话事件',
485
+ 'memory.extractionTurns': '结束 {n} 轮',
469
486
  'memory.accept': '保留',
470
487
  'memory.reject': '丢弃',
471
488
  'memory.accepted': '已保留',
@@ -478,6 +495,11 @@ window.__ModuleLoader__.load({
478
495
  'memory.maintainedCompressed': '已压缩',
479
496
  'memory.maintainedRules': '条规则',
480
497
  'memory.implicit': '自动识别并建议记忆',
498
+ 'memory.implicitModel': '自动提取使用的模型',
499
+ 'memory.implicitModelOff': '关闭 —— 不调用模型,零消耗',
500
+ 'memory.implicitModelCurrent': '用当前对话正在使用的模型',
501
+ 'memory.implicitModelHint':
502
+ '自动提取会在每轮结束时发一次小请求。在这里选定模型才代表你授权了这笔消耗;「关闭」和「用当前模型」是这笔取舍的两端。',
481
503
  'memory.clearCold': '清空冷归档',
482
504
  'memory.coldCleared': '冷归档已清空',
483
505
  },
@@ -793,6 +815,16 @@ window.__ModuleLoader__.load({
793
815
  .dss-curate-title { font-size: 13px; color: var(--dsw-alias-label-primary, #1f1f1f); }
794
816
  .dss-curate-hint { font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary, #6b6b6b); }
795
817
  .dss-curate-inline { margin-top: 0; padding: 6px 10px; border-color: transparent; background: transparent; }
818
+ /* The model picker sits below the toggle as a labelled control, because it is a
819
+ separate decision from "on/off": one says whether to extract, the other says who
820
+ pays for it. */
821
+ .dss-curate-field {
822
+ display: flex; flex-direction: column; gap: 6px; margin-top: 10px;
823
+ padding: 12px 14px; border-radius: 8px;
824
+ border: 1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.08));
825
+ background: var(--dsw-alias-bg-layer-1, #fff);
826
+ }
827
+ .dss-curate-label { font-size: 13px; font-weight: 600; color: var(--dsw-alias-label-primary, #1f1f1f); }
796
828
 
797
829
  .dss-tabs { display: flex; gap: 4px; flex-wrap: wrap; }
798
830
  .dss-tab {
@@ -949,6 +981,18 @@ window.__ModuleLoader__.load({
949
981
  border: 1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.08));
950
982
  border-radius: 10px; background: var(--dsw-alias-bg-layer-1, #fff);
951
983
  }
984
+ /* A bounded list: the library scrolls, the page does not.
985
+ Without this the settings below sat below the fold behind fifty rows, so every
986
+ change meant scrolling past the whole library first. 480px is about eleven rows,
987
+ picked by looking at it rather than by arithmetic — it was raised from 360px after
988
+ the first version read as too short. Single number, retune freely. overscroll-behavior
989
+ contain stops a scroll that reaches the end of the list from continuing into the page,
990
+ which is what makes the box feel like a box rather than a speed bump. */
991
+ .dss-scroll-box {
992
+ max-height: 480px;
993
+ overflow-y: auto;
994
+ overscroll-behavior: contain;
995
+ }
952
996
  .dss-row {
953
997
  display: flex; align-items: center; gap: 10px; padding: 10px 12px;
954
998
  border-bottom: 1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.06));
@@ -2252,6 +2296,8 @@ window.__ModuleLoader__.load({
2252
2296
  const [error, setError] = useState(null)
2253
2297
  const [busy, setBusy] = useState(null)
2254
2298
  const [memoryDraft, setMemoryDraft] = useState('')
2299
+ // Kept for the memory.add API path that the long-term screen still uses; the
2300
+ // snapshot panel no longer renders a session-memory editor.
2255
2301
 
2256
2302
  useEffect(() => {
2257
2303
  let alive = true
@@ -2357,15 +2403,31 @@ window.__ModuleLoader__.load({
2357
2403
  )
2358
2404
 
2359
2405
  const stats = detail?.stats
2406
+ // Split the list so the budget makes sense on screen.
2407
+ //
2408
+ // The header said "keeps the newest N automatic snapshots" while the list showed
2409
+ // sixteen, and nothing explained the difference: ten were this session's own and
2410
+ // six were inherited from the parent session by a rewind. Counting inherited
2411
+ // entries against the budget would make a fork delete every snapshot it takes
2412
+ // itself, so they are kept separately — but the user has to be told that, or the
2413
+ // number looks like a bug.
2414
+ const inheritedCount = (detail?.checkpoints ?? []).filter(cp => cp.inheritedFrom).length
2360
2415
  const statsLine = stats
2361
2416
  ? h(
2362
2417
  'div',
2363
2418
  { className: 'dss-row-meta' },
2364
2419
  h('span', null, t('settings.usage', { size: stats.label })),
2365
2420
  h('span', null, t('settings.budget', { keep: stats.budget })),
2421
+ inheritedCount > 0
2422
+ ? h('span', null, t('settings.budgetInherited', { n: inheritedCount }))
2423
+ : null,
2366
2424
  detail.cwd ? h('span', { title: detail.cwd }, detail.cwd) : null,
2367
2425
  )
2368
2426
  : null
2427
+ const budgetNote =
2428
+ inheritedCount > 0
2429
+ ? h('div', { className: 'dss-section-desc' }, t('settings.budgetNote', { keep: stats?.budget ?? 10 }))
2430
+ : null
2369
2431
 
2370
2432
  const checkpoints = detail?.checkpoints ?? []
2371
2433
  const checkpointList =
@@ -2387,6 +2449,10 @@ window.__ModuleLoader__.load({
2387
2449
  h('span', null, cp.label || cp.id),
2388
2450
  cp.locked ? h('span', { className: 'dss-badge dss-locked' }, t('settings.locked')) : null,
2389
2451
  cp.temporary ? h('span', { className: 'dss-badge dss-temp' }, t('settings.temporary')) : null,
2452
+ // Inherited snapshots are a different thing from this session's
2453
+ // own: they came from the parent by a rewind, they do not count
2454
+ // against the budget, and deleting the child does not delete them.
2455
+ cp.inheritedFrom ? h('span', { className: 'dss-badge' }, t('settings.inherited')) : null,
2390
2456
  ),
2391
2457
  h(
2392
2458
  'div',
@@ -2489,63 +2555,15 @@ window.__ModuleLoader__.load({
2489
2555
  ),
2490
2556
  )
2491
2557
 
2492
- const memory = detail?.memory ?? []
2493
- const memoryBlock = h(
2494
- 'div',
2495
- { style: { display: 'flex', flexDirection: 'column', gap: 8 } },
2496
- h('div', { className: 'dss-group-title' }, t('settings.memory')),
2497
- h(
2498
- 'div',
2499
- { className: 'dss-card' },
2500
- memory.length === 0
2501
- ? h('div', { className: 'dss-empty' }, t('settings.memoryEmpty'))
2502
- : memory.map(entry =>
2503
- h(
2504
- 'div',
2505
- { className: 'dss-row', key: entry.id },
2506
- h('div', { className: 'dss-row-main' }, h('div', { className: 'dss-memory-text' }, entry.text), h('div', { className: 'dss-row-meta' }, h('span', null, new Date(entry.createdAt).toLocaleString()))),
2507
- h(
2508
- 'div',
2509
- { className: 'dss-row-actions' },
2510
- h(
2511
- 'button',
2512
- {
2513
- type: 'button',
2514
- className: 'dss-btn dss-small dss-ghost',
2515
- title: t('settings.memoryRemove'),
2516
- onClick: () => run(`mem:${entry.id}`, () => call({ action: 'memory.remove', sessionId: selected, id: entry.id }), t('toast.memoryRemoved')),
2517
- },
2518
- h(Icons.trash, { size: 14 }),
2519
- ),
2520
- ),
2521
- ),
2522
- ),
2523
- ),
2524
- h(
2525
- 'div',
2526
- { className: 'dss-toolbar' },
2527
- h('input', {
2528
- className: 'dss-input',
2529
- value: memoryDraft,
2530
- placeholder: t('settings.memoryPlaceholder'),
2531
- onChange: event => setMemoryDraft(event.target.value),
2532
- }),
2533
- h(
2534
- 'button',
2535
- {
2536
- type: 'button',
2537
- className: 'dss-btn dss-small',
2538
- disabled: !memoryDraft.trim() || !selected,
2539
- onClick: () =>
2540
- run('memory:add', async () => {
2541
- await call({ action: 'memory.add', sessionId: selected, text: memoryDraft })
2542
- setMemoryDraft('')
2543
- }, t('toast.memoryAdded')),
2544
- },
2545
- t('settings.memorySave'),
2546
- ),
2547
- ),
2548
- )
2558
+ // The session-memory block was removed deliberately.
2559
+ //
2560
+ // It was a per-session list of loose facts, never written to, sitting next to a
2561
+ // real trajectory view that the Host already provides. Two surfaces for "what
2562
+ // happened in this session" — one authoritative and read-only, one a stub that
2563
+ // nobody fed — invites the reader to trust the stub. The trajectory is the
2564
+ // record of what happened and is not meant to be edited; a hand-written note
2565
+ // beside it is a weaker duplicate. Removed rather than hidden so the panel does
2566
+ // not advertise a capability it does not have.
2549
2567
 
2550
2568
  return h(
2551
2569
  'div',
@@ -2553,9 +2571,9 @@ window.__ModuleLoader__.load({
2553
2571
  head,
2554
2572
  picker,
2555
2573
  statsLine,
2574
+ budgetNote,
2556
2575
  checkpointList,
2557
2576
  undoList,
2558
- memoryBlock,
2559
2577
  )
2560
2578
  }
2561
2579
 
@@ -2964,7 +2982,39 @@ window.__ModuleLoader__.load({
2964
2982
  const [tagFilter, setTagFilter] = useState('')
2965
2983
  const [sinceFilter, setSinceFilter] = useState('')
2966
2984
 
2985
+ // Which models can be called, loaded from the Host rather than from config.
2986
+ //
2987
+ // The list used to come from `settings.memoryModels`, which is a patch value —
2988
+ // and this profile has no patch entry, so the picker offered only "off" and
2989
+ // "use current" while the user had several working providers in
2990
+ // Settings → 模型. The Host knows what it can actually call; ask it.
2991
+ const [models, setModels] = useState(null)
2992
+ useEffect(() => {
2993
+ let alive = true
2994
+ call({ action: 'longterm.models' })
2995
+ .then(list => alive && setModels(Array.isArray(list) ? list : []))
2996
+ .catch(() => alive && setModels([]))
2997
+ return () => {
2998
+ alive = false
2999
+ }
3000
+ }, [rev])
3001
+
2967
3002
  const detected = useWorkspacePath()
3003
+
3004
+ // Turn a stored workspace key into a readable name.
3005
+ //
3006
+ // The badge used to say 「本工作区」 for every workspace-scoped row, so a list
3007
+ // filtered to 全部 showed a dozen rows all claiming to be "this workspace" —
3008
+ // including rows from other workspaces. The key is a one-way hash, so the name
3009
+ // has to come from the registry, which is exactly what `longterm.workspaces`
3010
+ // returns.
3011
+ const workspaceLabel = key => {
3012
+ if (!key) return t('memory.workspace')
3013
+ const match = known.find(item => item.key === key)
3014
+ if (!match) return t('memory.workspace')
3015
+ const cwd = String(match.cwd ?? '')
3016
+ return cwd.split(/[\\/]/).filter(Boolean).pop() ?? cwd ?? t('memory.workspace')
3017
+ }
2968
3018
  const cwd = picked !== undefined ? picked : detected
2969
3019
 
2970
3020
  useEffect(() => {
@@ -3146,7 +3196,7 @@ window.__ModuleLoader__.load({
3146
3196
  'div',
3147
3197
  { className: 'dss-row-main' },
3148
3198
  h('div', { className: 'dss-memory-text' }, item.text),
3149
- h('div', { className: 'dss-row-meta' }, h('span', { className: 'dss-badge' }, item.scope === 'global' ? t('memory.global') : t('memory.workspace'))),
3199
+ h('div', { className: 'dss-row-meta' }, h('span', { className: 'dss-badge' }, item.scope === 'global' ? t('memory.global') : workspaceLabel(item.workspaceKey))),
3150
3200
  ),
3151
3201
  h(
3152
3202
  'div',
@@ -3207,14 +3257,33 @@ window.__ModuleLoader__.load({
3207
3257
  : null,
3208
3258
  )
3209
3259
  : null,
3260
+ // Raw counts of what the Host actually delivered, so "nothing waiting"
3261
+ // can be told apart from "the hook never fired". Without this the two
3262
+ // states look identical on screen.
3263
+ extraction
3264
+ ? h(
3265
+ 'div',
3266
+ { className: 'dss-row-meta' },
3267
+ h('span', null, t('memory.extractionEvents', { n: extraction.eventsSeen ?? 0 })),
3268
+ h('span', null, t('memory.extractionTurns', { n: extraction.turnEndsSeen ?? 0 })),
3269
+ )
3270
+ : null,
3210
3271
  )
3211
3272
 
3273
+ // The list gets its own scroll box.
3274
+ //
3275
+ // Without one, the whole page scrolled: fifty memories pushed the maintenance
3276
+ // and model controls far below the fold, so changing a setting meant scrolling
3277
+ // past the entire library first. A bounded box keeps the controls in place and
3278
+ // makes the list the only thing that moves. The height is a deliberate starting
3279
+ // point — roughly eight rows — chosen to leave the settings visible on a
3280
+ // laptop screen; it is a single number, easy to retune.
3212
3281
  const listBlock =
3213
3282
  visible.length === 0
3214
3283
  ? h('div', { className: 'dss-empty' }, t('memory.empty'))
3215
3284
  : h(
3216
3285
  'div',
3217
- { className: 'dss-card' },
3286
+ { className: 'dss-card dss-scroll-box' },
3218
3287
  ...visible.map(entry =>
3219
3288
  h(
3220
3289
  'div',
@@ -3226,7 +3295,7 @@ window.__ModuleLoader__.load({
3226
3295
  h(
3227
3296
  'div',
3228
3297
  { className: 'dss-row-meta' },
3229
- h('span', { className: 'dss-badge' + (entry.scope === 'global' ? ' dss-locked' : '') }, entry.scope === 'global' ? t('memory.global') : t('memory.workspace')),
3298
+ h('span', { className: 'dss-badge' + (entry.scope === 'global' ? ' dss-locked' : '') }, entry.scope === 'global' ? t('memory.global') : workspaceLabel(entry.workspaceKey)),
3230
3299
  entry.kind === 'rule' ? h('span', { className: 'dss-badge dss-temp' }, t('memory.rule')) : null,
3231
3300
  // An episode is a dated event, not a truth. Labelling it
3232
3301
  // separately is what lets the user tell "this happened once"
@@ -3331,6 +3400,44 @@ window.__ModuleLoader__.load({
3331
3400
  }),
3332
3401
  h('span', { className: 'dss-curate-title' }, t('memory.implicit')),
3333
3402
  ),
3403
+ // Which model does the background extraction call?
3404
+ //
3405
+ // This has to be an explicit choice. An earlier build silently fell back to
3406
+ // the Host's default model whenever no chain was configured, which spends the
3407
+ // user's tokens once per turn on a model they never picked for this job. A
3408
+ // background cost has to be visible and opt-in, so the choice is a control
3409
+ // with an explicit "off" that costs nothing.
3410
+ h(
3411
+ 'div',
3412
+ { className: 'dss-curate-field' },
3413
+ h('label', { className: 'dss-curate-label' }, t('memory.implicitModel')),
3414
+ h(
3415
+ 'select',
3416
+ {
3417
+ className: 'dss-select',
3418
+ value: settings?.implicitModel ?? 'off',
3419
+ disabled: busy === 'implicitModel',
3420
+ onChange: event =>
3421
+ run('implicitModel', () =>
3422
+ call({ action: 'longterm.settings', patch: { implicitModel: event.target.value } }),
3423
+ ),
3424
+ },
3425
+ h('option', { value: 'off' }, t('memory.implicitModelOff')),
3426
+ h('option', { value: 'current' }, t('memory.implicitModelCurrent')),
3427
+ // Real models from the Host, grouped by provider so a long list stays
3428
+ // readable. `models` is null while loading and [] when the Host reports
3429
+ // none — both render no group, which is honest: the two above are the
3430
+ // only choices that exist without a callable model.
3431
+ ...(Array.isArray(models) ? models : []).map(entry =>
3432
+ h(
3433
+ 'option',
3434
+ { value: `${entry.provider}/${entry.model}`, key: `${entry.provider}/${entry.model}` },
3435
+ entry.label ?? `${entry.provider}/${entry.model}`,
3436
+ ),
3437
+ ),
3438
+ ),
3439
+ h('div', { className: 'dss-curate-hint' }, t('memory.implicitModelHint')),
3440
+ ),
3334
3441
  h(
3335
3442
  'button',
3336
3443
  {
package/lib/api.js CHANGED
@@ -307,6 +307,16 @@ async function respond(service, deps, action, input) {
307
307
  return json({ ok: true, data: await service.longTerm.status() })
308
308
  case 'longterm.workspaces':
309
309
  return json({ ok: true, data: await service.longTerm.workspaces() })
310
+ case 'longterm.models':
311
+ // The models this Host can actually call, straight from the llm service.
312
+ //
313
+ // The picker used to render `config.memoryModels`, a list that comes from the
314
+ // bundle patch — and a profile with no patch entry for this bundle therefore
315
+ // showed only "off" and "use current", no real models at all. The llm service
316
+ // is the authoritative source: it knows every provider route with a registered
317
+ // adapter and what each one advertises. Reading configuration to answer "what
318
+ // can I call" was the mistake.
319
+ return json({ ok: true, data: await service.longTermModels() })
310
320
  case 'longterm.settings':
311
321
  return json(
312
322
  input.patch
@@ -205,9 +205,26 @@ export function installMemory({ ctx, root, config, log }) {
205
205
  ctx.effect(
206
206
  () =>
207
207
  ctx.on('session/event', (session, event) => {
208
+ // Count what arrives, not just what is acted on.
209
+ //
210
+ // The queue stayed empty for an entire session and the reason could have
211
+ // been any of: the listener never fired, it fired but saw no `turn/end`,
212
+ // `session.id` was not the shape assumed, or extraction ran and refused.
213
+ // Each has a different fix, so the trace records which one happened —
214
+ // "nothing waiting" with no counts is indistinguishable from "the hook is
215
+ // never called at all", which is exactly the state that wasted several
216
+ // rounds of guessing.
217
+ extractionTrace.eventsSeen = (extractionTrace.eventsSeen ?? 0) + 1
208
218
  if (!event || event.type !== 'turn/end') return
219
+ extractionTrace.turnEndsSeen = (extractionTrace.turnEndsSeen ?? 0) + 1
220
+ // A branded id is still a string at runtime; accept any non-empty one and
221
+ // report the actual shape when it is not.
209
222
  const sessionId = session?.id
210
- if (typeof sessionId !== 'string') return
223
+ if (typeof sessionId !== 'string' || !sessionId) {
224
+ extractionTrace.reason = `a turn ended but the session id was ${typeof sessionId}`
225
+ publishExtractionTrace(engine)
226
+ return
227
+ }
211
228
  const cwd = resolveCwd(ctx, session)
212
229
  void extractAfterTurn({ ctx, engine, sessionId, cwd, log, config })
213
230
  // The trace is published in a finally so a thrown error still leaves a
@@ -553,8 +570,14 @@ async function extractAfterTurn({ ctx, engine, sessionId, cwd, log, config }) {
553
570
  // The memory model is a fallback chain: a free provider that is rate limited
554
571
  // (429) or unreachable must not silently disable extraction for the session,
555
572
  // so every candidate is tried in order until one answers.
556
- const candidates = memoryCandidates(config)
557
- if (candidates.length === 0) return failExtraction('no memoryModels chain is configured')
573
+ const candidates = memoryCandidates(config, ctx)
574
+ if (candidates.length === 0) {
575
+ return failExtraction(
576
+ config?.implicitModel === 'current'
577
+ ? 'the Host exposes no default model to fall back to'
578
+ : 'implicit extraction has no model configured — choose one in Settings → 长期记忆 (隐式提取)',
579
+ )
580
+ }
558
581
  extractionTrace.candidates = candidates.map(candidate => `${candidate.provider}/${candidate.model}`)
559
582
 
560
583
  // The prompt asks for two different shapes, because the library stores two
@@ -709,7 +732,7 @@ function numberOr(value, fallback) {
709
732
  * @param {any} config plugin config
710
733
  * @returns {{ provider: string, model: string }[]} candidates in priority order
711
734
  */
712
- function memoryCandidates(config) {
735
+ function memoryCandidates(config, ctx) {
713
736
  const raw = []
714
737
  const push = value => {
715
738
  if (Array.isArray(value)) raw.push(...value)
@@ -717,6 +740,37 @@ function memoryCandidates(config) {
717
740
  }
718
741
  push(config?.memoryModels)
719
742
  push(config?.memoryModel)
743
+ // A single explicit pick, stored by the settings UI as "provider/model".
744
+ //
745
+ // This is the "one model, chosen here" case: the user picked a specific entry from
746
+ // the offered chain, and that pick must beat the chain (which is a fallback list,
747
+ // not a preference). Parsed on the last slash so a model id that itself contains a
748
+ // slash — "Qwen/Qwen2.5-7B-Instruct" — survives intact.
749
+ const picked = typeof config?.implicitModel === 'string' ? config.implicitModel : ''
750
+ if (picked && picked !== 'current' && picked !== 'off') {
751
+ const at = picked.indexOf('/')
752
+ if (at > 0 && at < picked.length - 1) {
753
+ raw.unshift({ provider: picked.slice(0, at), model: picked.slice(at + 1) })
754
+ }
755
+ }
756
+ // Falling back to the Host's own default model is OFF unless the user asks for it.
757
+ //
758
+ // An earlier version did this unconditionally, to fix "extraction never runs when
759
+ // no chain is configured". That traded one bug for a worse one: it spends the
760
+ // user's tokens on a model they never chose for this purpose, once per turn,
761
+ // silently. Background spending has to be opted into — so the fallback requires
762
+ // `implicitModel: 'current'` and otherwise returns nothing, leaving extraction off
763
+ // with a reason the user can read.
764
+ if (raw.length === 0 && ctx && picked === 'current') {
765
+ try {
766
+ const selection = ctx.get('agentDefaultModel')?.currentSelection?.()
767
+ if (selection?.provider && selection?.model) {
768
+ raw.push({ provider: selection.provider, model: selection.model })
769
+ }
770
+ } catch {
771
+ /* a Host without the service just means no fallback */
772
+ }
773
+ }
720
774
  const out = []
721
775
  const seen = new Set()
722
776
  for (const entry of raw) {
package/lib/service.js CHANGED
@@ -159,6 +159,9 @@ export class SessionSuiteService {
159
159
  label: formatBytes(stats.bytes),
160
160
  checkpoints: stats.checkpoints,
161
161
  budget: this.config.keep ?? 10,
162
+ // Counted here so the UI does not have to re-derive it from the rows it
163
+ // renders — and so the number exists even if a future change filters rows.
164
+ inherited: checkpoints.filter(item => item.inheritedFrom).length,
162
165
  },
163
166
  undos: await this.listUndos(sessionId),
164
167
  }
@@ -959,6 +962,53 @@ export class SessionSuiteService {
959
962
  return { sessionId, restored: true }
960
963
  }
961
964
 
965
+ /**
966
+ * Every model this Host can actually call, for the extraction picker.
967
+ *
968
+ * Read from the `llm` service, not from configuration. The picker used to render
969
+ * `config.memoryModels` — a list supplied by the bundle patch — so a profile with no
970
+ * patch entry for this bundle offered only "off" and "use current" and no real
971
+ * models, even though the user had several providers configured and working in
972
+ * Settings → 模型. What can be called is the llm service's answer to give; a config
973
+ * file only says what someone once wrote down.
974
+ *
975
+ * @returns {Promise<Array<{ provider: string, model: string, label: string }>>} models
976
+ */
977
+ async longTermModels() {
978
+ const llm = this.ctx.get?.('llm')
979
+ if (!llm) return []
980
+ const out = []
981
+ let providers = []
982
+ try {
983
+ providers = await llm.listProviders?.() ?? []
984
+ } catch (error) {
985
+ this.log('could not list llm providers', error)
986
+ return []
987
+ }
988
+ for (const provider of providers) {
989
+ const id = provider?.id
990
+ if (typeof id !== 'string' || !id) continue
991
+ let models = []
992
+ try {
993
+ models = await llm.listModels?.(id) ?? []
994
+ } catch (error) {
995
+ // One provider failing to advertise must not empty the whole picker.
996
+ this.log(`could not list models for ${id}`, error)
997
+ continue
998
+ }
999
+ for (const model of models) {
1000
+ const modelId = model?.id
1001
+ if (typeof modelId !== 'string' || !modelId) continue
1002
+ out.push({
1003
+ provider: id,
1004
+ model: modelId,
1005
+ label: `${provider?.name ?? id} · ${model?.name ?? modelId}`,
1006
+ })
1007
+ }
1008
+ }
1009
+ return out
1010
+ }
1011
+
962
1012
  /**
963
1013
  * The set of archived session ids, read from the registry that owns them.
964
1014
  *
@@ -1082,6 +1132,13 @@ function decorateCheckpoint(checkpoint) {
1082
1132
  temporary: Boolean(checkpoint.expiresAt),
1083
1133
  expiresAt: checkpoint.expiresAt ?? null,
1084
1134
  skipped: checkpoint.skipped ?? 0,
1135
+ // Inherited snapshots came from the parent session by a rewind, and they are
1136
+ // EXEMPT from this session's keep-budget. The UI has to know that: without the
1137
+ // flag it cannot explain why a session with a budget of 10 shows sixteen rows,
1138
+ // which reads as a prune bug rather than as the intended behaviour. This field
1139
+ // was silently dropped by this whitelist — every field not listed here never
1140
+ // reaches the browser.
1141
+ inheritedFrom: checkpoint.inheritedFrom ?? null,
1085
1142
  }
1086
1143
  }
1087
1144
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heixui/dsh-session-suite",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "Session checkpoints for DSH: rewind a conversation with its files and memory, permanent session deletion, and shareable exports",
5
5
  "type": "module",
6
6
  "main": "./index.js",