@heixui/dsh-session-suite 0.4.1 → 0.4.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/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,15 @@ 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.orphansSweep': 'Clean up orphan snapshots',
147
+ 'settings.orphansConfirm': 'Click again to delete',
148
+ 'settings.orphansNone': 'No orphan snapshot directories',
149
+ 'settings.orphansFound': '{n} orphan session(s), {size} reclaimable — click again to delete',
150
+ 'settings.orphansRemoved': 'Removed {n} orphan session(s), freed {size}',
151
+ 'settings.budgetNote':
152
+ '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
153
  'settings.usage': 'Snapshots use {size}',
145
154
  'settings.files': '{count} files',
146
155
  'settings.turn': 'Turn {turn}',
@@ -209,7 +218,16 @@ window.__ModuleLoader__.load({
209
218
  'memory.unfreeze': 'Unfreeze',
210
219
  'memory.remove': 'Delete',
211
220
  'memory.pendingTitle': 'Suggested memories',
212
- 'memory.pendingHint': 'Extracted from the last turns. Nothing is stored until you accept it.',
221
+ 'memory.statsKind': 'What the library holds',
222
+ 'memory.statsHealth': 'Score distribution',
223
+ 'memory.statsTags': 'Topics',
224
+ 'memory.kind.fact': 'Facts',
225
+ 'memory.kind.rule': 'Rules',
226
+ 'memory.kind.summary': 'Summaries',
227
+ 'memory.kind.episode': 'Episodes',
228
+ 'memory.band.high': 'Strong ≥0.60',
229
+ 'memory.band.mid': 'Fading 0.45–0.60',
230
+ 'memory.band.low': 'Below line <0.45', 'memory.pendingHint': 'Extracted from the last turns. Nothing is stored until you accept it.',
213
231
  'memory.extractionIdle': 'Nothing is waiting. Last check: {reason}',
214
232
  'memory.extractionNotYet':
215
233
  'Nothing is waiting yet. Extraction runs at the end of each turn, and this Host has not completed one since it started — have a short exchange and come back.',
@@ -234,7 +252,7 @@ window.__ModuleLoader__.load({
234
252
  'panel.breakdownTitle': 'Where the memories live',
235
253
  'panel.breakdownGlobal': 'Global (shared by every workspace)',
236
254
  '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',
255
+ 'panel.repoLink': 'GitHub source — a small encouragement',
238
256
  'panel.noSettingsSeam': 'This Host build does not expose the settings navigation seam, so this row cannot open its page.',
239
257
  'archived.title': 'Archived sessions',
240
258
  'archived.description': 'Archived sessions are hidden from the sidebar. Restoring one puts it back; nothing is deleted.',
@@ -248,6 +266,8 @@ window.__ModuleLoader__.load({
248
266
  'memory.extractionTried': 'tried {n} memory model(s)',
249
267
  'memory.extractionQueued': '{n} queued',
250
268
  'memory.extractionRejected': '{n} rejected as unusable',
269
+ 'memory.extractionEvents': '{n} session event(s) received',
270
+ 'memory.extractionTurns': '{n} turn(s) ended',
251
271
  'memory.accept': 'Keep',
252
272
  'memory.reject': 'Discard',
253
273
  'memory.accepted': 'Kept',
@@ -260,6 +280,11 @@ window.__ModuleLoader__.load({
260
280
  'memory.maintainedCompressed': 'compressed',
261
281
  'memory.maintainedRules': 'rules',
262
282
  'memory.implicit': 'Suggest memories automatically',
283
+ 'memory.implicitModel': 'Model for automatic extraction',
284
+ 'memory.implicitModelOff': 'Off — no model call, costs nothing',
285
+ 'memory.implicitModelCurrent': 'Use the model this conversation is already using',
286
+ 'memory.implicitModelHint':
287
+ '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
288
  'memory.clearCold': 'Clear cold archive',
264
289
  'memory.coldCleared': 'Cold archive cleared',
265
290
  },
@@ -359,6 +384,15 @@ window.__ModuleLoader__.load({
359
384
  'settings.unlock': '解锁',
360
385
  'settings.locked': '已锁定',
361
386
  'settings.budget': '每个会话保留最近 {keep} 个自动快照',
387
+ 'settings.budgetInherited': '+ {n} 个继承',
388
+ 'settings.inherited': '继承',
389
+ 'settings.orphansSweep': '清理孤儿快照',
390
+ 'settings.orphansConfirm': '再点一次确认删除',
391
+ 'settings.orphansNone': '没有孤儿快照目录',
392
+ 'settings.orphansFound': '{n} 个会话的孤儿快照,可释放 {size} —— 再点一次删除',
393
+ 'settings.orphansRemoved': '已删除 {n} 个会话的孤儿快照,释放 {size}',
394
+ 'settings.budgetNote':
395
+ '只有这个会话自己的自动快照计入预算。从父会话继承来的快照(回滚或删除消息时产生)单独保留,所以列表可能比 {keep} 长。',
362
396
  'settings.usage': '快照占用 {size}',
363
397
  'settings.files': '{count} 个文件',
364
398
  'settings.turn': '第 {turn} 轮',
@@ -427,6 +461,16 @@ window.__ModuleLoader__.load({
427
461
  'memory.unfreeze': '解冻',
428
462
  'memory.remove': '删除',
429
463
  'memory.pendingTitle': '待确认的记忆',
464
+ 'memory.statsKind': '库里有什么',
465
+ 'memory.statsHealth': '分数分布',
466
+ 'memory.statsTags': '主题分布',
467
+ 'memory.kind.fact': '事实',
468
+ 'memory.kind.rule': '规则',
469
+ 'memory.kind.summary': '摘要',
470
+ 'memory.kind.episode': '经历',
471
+ 'memory.band.high': '强 ≥0.60',
472
+ 'memory.band.mid': '衰减 0.45-0.60',
473
+ 'memory.band.low': '低于线 <0.45',
430
474
  'memory.pendingHint': '从最近几轮里识别出来的,确认前不会入库。',
431
475
  'memory.extractionIdle': '当前没有待确认条目。上次检查结果:{reason}',
432
476
  'memory.extractionNotYet':
@@ -452,7 +496,7 @@ window.__ModuleLoader__.load({
452
496
  'panel.breakdownTitle': '记忆存在哪',
453
497
  'panel.breakdownGlobal': '全局(所有工作区共享)',
454
498
  'panel.breakdownHint': '每个工作区有独立的库;上面的总数 = 这些之和 + 全局。',
455
- 'panel.repoLink': 'GitHub 源码',
499
+ 'panel.repoLink': 'GitHub 源码 - 鼓励一下',
456
500
  'panel.noSettingsSeam': '这个 Host 版本没有暴露设置导航接口,无法打开对应页面。',
457
501
  'archived.title': '归档会话',
458
502
  'archived.description': '归档的会话从侧边栏隐藏。恢复后会重新出现,不会删除任何东西。',
@@ -466,6 +510,8 @@ window.__ModuleLoader__.load({
466
510
  'memory.extractionTried': '试过 {n} 个记忆模型',
467
511
  'memory.extractionQueued': '入队 {n} 条',
468
512
  'memory.extractionRejected': '剔除 {n} 条',
513
+ 'memory.extractionEvents': '收到 {n} 个会话事件',
514
+ 'memory.extractionTurns': '结束 {n} 轮',
469
515
  'memory.accept': '保留',
470
516
  'memory.reject': '丢弃',
471
517
  'memory.accepted': '已保留',
@@ -478,6 +524,11 @@ window.__ModuleLoader__.load({
478
524
  'memory.maintainedCompressed': '已压缩',
479
525
  'memory.maintainedRules': '条规则',
480
526
  'memory.implicit': '自动识别并建议记忆',
527
+ 'memory.implicitModel': '自动提取使用的模型',
528
+ 'memory.implicitModelOff': '关闭 —— 不调用模型,零消耗',
529
+ 'memory.implicitModelCurrent': '用当前对话正在使用的模型',
530
+ 'memory.implicitModelHint':
531
+ '自动提取会在每轮结束时发一次小请求。在这里选定模型才代表你授权了这笔消耗;「关闭」和「用当前模型」是这笔取舍的两端。',
481
532
  'memory.clearCold': '清空冷归档',
482
533
  'memory.coldCleared': '冷归档已清空',
483
534
  },
@@ -793,6 +844,16 @@ window.__ModuleLoader__.load({
793
844
  .dss-curate-title { font-size: 13px; color: var(--dsw-alias-label-primary, #1f1f1f); }
794
845
  .dss-curate-hint { font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary, #6b6b6b); }
795
846
  .dss-curate-inline { margin-top: 0; padding: 6px 10px; border-color: transparent; background: transparent; }
847
+ /* The model picker sits below the toggle as a labelled control, because it is a
848
+ separate decision from "on/off": one says whether to extract, the other says who
849
+ pays for it. */
850
+ .dss-curate-field {
851
+ display: flex; flex-direction: column; gap: 6px; margin-top: 10px;
852
+ padding: 12px 14px; border-radius: 8px;
853
+ border: 1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.08));
854
+ background: var(--dsw-alias-bg-layer-1, #fff);
855
+ }
856
+ .dss-curate-label { font-size: 13px; font-weight: 600; color: var(--dsw-alias-label-primary, #1f1f1f); }
796
857
 
797
858
  .dss-tabs { display: flex; gap: 4px; flex-wrap: wrap; }
798
859
  .dss-tab {
@@ -949,6 +1010,73 @@ window.__ModuleLoader__.load({
949
1010
  border: 1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.08));
950
1011
  border-radius: 10px; background: var(--dsw-alias-bg-layer-1, #fff);
951
1012
  }
1013
+ /* A bounded list: the library scrolls, the page does not.
1014
+ Without this the settings below sat below the fold behind fifty rows, so every
1015
+ change meant scrolling past the whole library first. 480px is about eleven rows,
1016
+ picked by looking at it rather than by arithmetic — it was raised from 360px after
1017
+ the first version read as too short. Single number, retune freely. overscroll-behavior
1018
+ contain stops a scroll that reaches the end of the list from continuing into the page,
1019
+ which is what makes the box feel like a box rather than a speed bump. */
1020
+ .dss-scroll-box {
1021
+ max-height: 480px;
1022
+ overflow-y: auto;
1023
+ overscroll-behavior: contain;
1024
+ }
1025
+ /* The summary charts. Two narrow panels for composition and health, one wide panel
1026
+ for tags: composition and health are short lists, tags can run long. Bars are divs,
1027
+ not SVG, so they inherit the theme and stay legible in both palettes. */
1028
+ .dss-stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
1029
+ .dss-stats-panel {
1030
+ display: flex; flex-direction: column; gap: 8px;
1031
+ padding: 14px 16px;
1032
+ background: var(--dsw-alias-bg-layer-1, #fff);
1033
+ border: 1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.08));
1034
+ border-radius: 10px;
1035
+ }
1036
+ .dss-stats-wide { grid-column: 1 / -1; }
1037
+ .dss-statbar { display: flex; align-items: center; gap: 10px; font-size: 12px; }
1038
+ /* The label was a fixed 76px with nowrap + ellipsis, so anything longer than a
1039
+ couple of Chinese characters was cut off mid-word — 「低于压缩线(<0.45)」
1040
+ rendered as 「低于压缩线…」 and the reader could not tell which band it was.
1041
+ A wider column that wraps is the honest fix: a truncated label still occupies the
1042
+ same space, it just stops being readable. */
1043
+ .dss-statbar-label {
1044
+ flex: none; width: 108px;
1045
+ color: var(--dsw-alias-label-primary, #1f1f1f);
1046
+ line-height: 1.35;
1047
+ overflow-wrap: anywhere;
1048
+ }
1049
+ /* Composition and tag bars get distinct hues.
1050
+ Every bar was the same brand blue, so a chart of five categories read as one
1051
+ repeated object and the only thing distinguishing a row was its length — the eye
1052
+ had to go back to the label each time. Colour-coding by position makes the rows
1053
+ separately trackable while staying inside the theme's palette. */
1054
+ .dss-tone-c0 { background: #4d6bfe; }
1055
+ .dss-tone-c1 { background: #7b5cf5; }
1056
+ .dss-tone-c2 { background: #2aa6c9; }
1057
+ .dss-tone-c3 { background: #14a37f; }
1058
+ .dss-tone-c4 { background: #e0a03a; }
1059
+ .dss-tone-c5 { background: #d9636a; }
1060
+ .dss-statbar-track {
1061
+ flex: 1; height: 8px; min-width: 0;
1062
+ background: var(--dsw-alias-bg-layer-2, #f2f3f5);
1063
+ border-radius: 4px; overflow: hidden;
1064
+ }
1065
+ .dss-statbar-fill {
1066
+ height: 100%; border-radius: 4px;
1067
+ background: var(--dsw-alias-brand-primary, #2563eb);
1068
+ transition: width .2s ease;
1069
+ }
1070
+ /* Health bands use the semantic colours the rest of the app already uses, so the
1071
+ meaning does not depend on reading the label. */
1072
+ .dss-tone-good { background: var(--dsw-alias-state-success-primary, #1a9c50); }
1073
+ .dss-tone-warn { background: var(--dsw-alias-state-warning-primary, #d97706); }
1074
+ .dss-tone-bad { background: var(--dsw-alias-state-error-primary, #d93025); }
1075
+ .dss-statbar-value {
1076
+ flex: none; width: 32px; text-align: right;
1077
+ font-variant-numeric: tabular-nums; font-weight: 600;
1078
+ color: var(--dsw-alias-label-secondary, #6b6b6b);
1079
+ }
952
1080
  .dss-row {
953
1081
  display: flex; align-items: center; gap: 10px; padding: 10px 12px;
954
1082
  border-bottom: 1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.06));
@@ -2251,7 +2379,12 @@ window.__ModuleLoader__.load({
2251
2379
  const [detail, setDetail] = useState(null)
2252
2380
  const [error, setError] = useState(null)
2253
2381
  const [busy, setBusy] = useState(null)
2382
+ // Two-step confirm for the orphan sweep: the first click reports, the second
2383
+ // deletes. Held in state so the button label can say which step it is on.
2384
+ const [orphanArmed, setOrphanArmed] = useState(false)
2254
2385
  const [memoryDraft, setMemoryDraft] = useState('')
2386
+ // Kept for the memory.add API path that the long-term screen still uses; the
2387
+ // snapshot panel no longer renders a session-memory editor.
2255
2388
 
2256
2389
  useEffect(() => {
2257
2390
  let alive = true
@@ -2357,15 +2490,69 @@ window.__ModuleLoader__.load({
2357
2490
  )
2358
2491
 
2359
2492
  const stats = detail?.stats
2493
+ // Split the list so the budget makes sense on screen.
2494
+ //
2495
+ // The header said "keeps the newest N automatic snapshots" while the list showed
2496
+ // sixteen, and nothing explained the difference: ten were this session's own and
2497
+ // six were inherited from the parent session by a rewind. Counting inherited
2498
+ // entries against the budget would make a fork delete every snapshot it takes
2499
+ // itself, so they are kept separately — but the user has to be told that, or the
2500
+ // number looks like a bug.
2501
+ const inheritedCount = (detail?.checkpoints ?? []).filter(cp => cp.inheritedFrom).length
2360
2502
  const statsLine = stats
2361
2503
  ? h(
2362
2504
  'div',
2363
2505
  { className: 'dss-row-meta' },
2364
2506
  h('span', null, t('settings.usage', { size: stats.label })),
2365
2507
  h('span', null, t('settings.budget', { keep: stats.budget })),
2508
+ inheritedCount > 0
2509
+ ? h('span', null, t('settings.budgetInherited', { n: inheritedCount }))
2510
+ : null,
2366
2511
  detail.cwd ? h('span', { title: detail.cwd }, detail.cwd) : null,
2367
2512
  )
2368
2513
  : null
2514
+ const budgetNote =
2515
+ inheritedCount > 0
2516
+ ? h('div', { className: 'dss-section-desc' }, t('settings.budgetNote', { keep: stats?.budget ?? 10 }))
2517
+ : null
2518
+
2519
+ // Reclaiming snapshot directories of deleted sessions.
2520
+ //
2521
+ // `#prune` only runs when a session takes another snapshot, so a deleted
2522
+ // session's directory is never revisited and its blobs are never freed. The
2523
+ // first click reports what would go; the second confirms. A destructive action
2524
+ // that also runs on a timer is how people lose data they did not know they had,
2525
+ // so the timer only reports and this control is the only thing that deletes.
2526
+ const orphanLine = h(
2527
+ 'div',
2528
+ { className: 'dss-toolbar' },
2529
+ h(
2530
+ 'button',
2531
+ {
2532
+ type: 'button',
2533
+ className: 'dss-btn dss-small dss-ghost',
2534
+ disabled: busy === 'orphans',
2535
+ onClick: () =>
2536
+ run('orphans', async () => {
2537
+ const result = await call({ action: 'sweepOrphans', apply: orphanArmed })
2538
+ if (result?.orphans === 0) {
2539
+ notify(t('settings.orphansNone'))
2540
+ setOrphanArmed(false)
2541
+ return
2542
+ }
2543
+ if (!orphanArmed) {
2544
+ notify(t('settings.orphansFound', { n: result.orphans, size: result.freed }))
2545
+ setOrphanArmed(true)
2546
+ return
2547
+ }
2548
+ notify(t('settings.orphansRemoved', { n: result.removed, size: result.freed }))
2549
+ setOrphanArmed(false)
2550
+ bump()
2551
+ }, null),
2552
+ },
2553
+ orphanArmed ? t('settings.orphansConfirm') : t('settings.orphansSweep'),
2554
+ ),
2555
+ )
2369
2556
 
2370
2557
  const checkpoints = detail?.checkpoints ?? []
2371
2558
  const checkpointList =
@@ -2387,6 +2574,10 @@ window.__ModuleLoader__.load({
2387
2574
  h('span', null, cp.label || cp.id),
2388
2575
  cp.locked ? h('span', { className: 'dss-badge dss-locked' }, t('settings.locked')) : null,
2389
2576
  cp.temporary ? h('span', { className: 'dss-badge dss-temp' }, t('settings.temporary')) : null,
2577
+ // Inherited snapshots are a different thing from this session's
2578
+ // own: they came from the parent by a rewind, they do not count
2579
+ // against the budget, and deleting the child does not delete them.
2580
+ cp.inheritedFrom ? h('span', { className: 'dss-badge' }, t('settings.inherited')) : null,
2390
2581
  ),
2391
2582
  h(
2392
2583
  'div',
@@ -2489,63 +2680,15 @@ window.__ModuleLoader__.load({
2489
2680
  ),
2490
2681
  )
2491
2682
 
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
- )
2683
+ // The session-memory block was removed deliberately.
2684
+ //
2685
+ // It was a per-session list of loose facts, never written to, sitting next to a
2686
+ // real trajectory view that the Host already provides. Two surfaces for "what
2687
+ // happened in this session" — one authoritative and read-only, one a stub that
2688
+ // nobody fed — invites the reader to trust the stub. The trajectory is the
2689
+ // record of what happened and is not meant to be edited; a hand-written note
2690
+ // beside it is a weaker duplicate. Removed rather than hidden so the panel does
2691
+ // not advertise a capability it does not have.
2549
2692
 
2550
2693
  return h(
2551
2694
  'div',
@@ -2553,14 +2696,119 @@ window.__ModuleLoader__.load({
2553
2696
  head,
2554
2697
  picker,
2555
2698
  statsLine,
2699
+ budgetNote,
2556
2700
  checkpointList,
2557
2701
  undoList,
2558
- memoryBlock,
2702
+ orphanLine,
2559
2703
  )
2560
2704
  }
2561
2705
 
2562
2706
  // ------------------------------------------------------- long-term memory
2563
2707
 
2708
+ /**
2709
+ * One horizontal bar, drawn with a div rather than SVG.
2710
+ *
2711
+ * A bar chart of five rows does not need a charting library: a filled div inside
2712
+ * a track is the whole implementation, it inherits the theme's colours, and it
2713
+ * stays readable in both light and dark without any per-chart configuration. The
2714
+ * value is printed next to the bar, because the bar communicates proportion and
2715
+ * the number communicates quantity — one without the other is guesswork.
2716
+ *
2717
+ * @param {any} props row props
2718
+ * @returns {any} row element
2719
+ */
2720
+ function StatBar(props) {
2721
+ const { label, value, max, tone, hue } = props
2722
+ const ratio = max > 0 ? Math.min(1, value / max) : 0
2723
+ // `tone` is semantic (health bands); `hue` is positional (composition and tags).
2724
+ // They are separate because one means "this is bad" and the other means "this is
2725
+ // a different row" — conflating them would colour a healthy category red just
2726
+ // because it happens to be fifth.
2727
+ const fillClass = tone
2728
+ ? ` dss-tone-${tone}`
2729
+ : typeof hue === 'number'
2730
+ ? ` dss-tone-c${hue % 6}`
2731
+ : ''
2732
+ return h(
2733
+ 'div',
2734
+ { className: 'dss-statbar' },
2735
+ h('div', { className: 'dss-statbar-label', title: String(label) }, label),
2736
+ h(
2737
+ 'div',
2738
+ { className: 'dss-statbar-track' },
2739
+ h('div', {
2740
+ className: `dss-statbar-fill${fillClass}`,
2741
+ style: { width: `${Math.round(ratio * 100)}%` },
2742
+ }),
2743
+ ),
2744
+ h('div', { className: 'dss-statbar-value' }, String(value)),
2745
+ )
2746
+ }
2747
+
2748
+ /**
2749
+ * The memory summary: what the library is made of, and whether it is healthy.
2750
+ *
2751
+ * Each panel answers one question a reader would actually ask, and each implies a
2752
+ * next action: a high rule count means maintenance is over-producing, a large
2753
+ * "below threshold" band means decay is about to archive things, a lopsided tag
2754
+ * list means one topic is crowding out the rest. Numbers nobody would act on are
2755
+ * decoration; these are not.
2756
+ *
2757
+ * @param {any} props panel props
2758
+ * @returns {any} summary element
2759
+ */
2760
+ function MemoryStats(props) {
2761
+ const t = props.t ?? (key => key)
2762
+ const stats = props.stats
2763
+ if (!stats || typeof stats !== 'object') return null
2764
+
2765
+ const kinds = [
2766
+ ['fact', stats.kind?.fact ?? 0],
2767
+ ['rule', stats.kind?.rule ?? 0],
2768
+ ['summary', stats.kind?.summary ?? 0],
2769
+ ['episode', stats.kind?.episode ?? 0],
2770
+ ].filter(([, count]) => count > 0)
2771
+ const kindMax = Math.max(1, ...kinds.map(([, count]) => count))
2772
+
2773
+ const bands = [
2774
+ ['high', stats.bands?.high ?? 0, 'good'],
2775
+ ['mid', stats.bands?.mid ?? 0, 'warn'],
2776
+ ['low', stats.bands?.low ?? 0, 'bad'],
2777
+ ]
2778
+ const bandMax = Math.max(1, ...bands.map(([, count]) => count))
2779
+
2780
+ const tagMax = Math.max(1, ...(stats.tags ?? []).map(item => item.count))
2781
+
2782
+ return h(
2783
+ 'div',
2784
+ { className: 'dss-stats' },
2785
+ h(
2786
+ 'div',
2787
+ { className: 'dss-stats-panel' },
2788
+ h('div', { className: 'dss-group-title' }, t('memory.statsKind')),
2789
+ ...kinds.map(([kind, count], index) =>
2790
+ h(StatBar, { key: kind, label: t(`memory.kind.${kind}`), value: count, max: kindMax, hue: index }),
2791
+ ),
2792
+ ),
2793
+ h(
2794
+ 'div',
2795
+ { className: 'dss-stats-panel' },
2796
+ h('div', { className: 'dss-group-title' }, t('memory.statsHealth')),
2797
+ ...bands.map(([band, count, tone]) =>
2798
+ h(StatBar, { key: band, label: t(`memory.band.${band}`), value: count, max: bandMax, tone }),
2799
+ ),
2800
+ ),
2801
+ h(
2802
+ 'div',
2803
+ { className: 'dss-stats-panel dss-stats-wide' },
2804
+ h('div', { className: 'dss-group-title' }, t('memory.statsTags')),
2805
+ ...(stats.tags ?? []).slice(0, 6).map((item, index) =>
2806
+ h(StatBar, { key: item.tag, label: item.tag, value: item.count, max: tagMax, hue: index }),
2807
+ ),
2808
+ ),
2809
+ )
2810
+ }
2811
+
2564
2812
  function useWorkspacePath() {
2565
2813
  return runtime.resolveCwd?.() ?? runtime.cwd
2566
2814
  }
@@ -2947,6 +3195,9 @@ window.__ModuleLoader__.load({
2947
3195
  const [draftScope, setDraftScope] = useState('workspace')
2948
3196
  const [busy, setBusy] = useState(null)
2949
3197
  const [known, setKnown] = useState([])
3198
+ // Aggregates for the summary charts, fetched separately so a slow list load
3199
+ // never blocks the numbers from appearing.
3200
+ const [stats, setStats] = useState(null)
2950
3201
  // Maintenance bookkeeping, so "did the timer ever run?" is a fact on
2951
3202
  // screen rather than something the user has to take on trust.
2952
3203
  const [maint, setMaint] = useState(null)
@@ -2964,7 +3215,39 @@ window.__ModuleLoader__.load({
2964
3215
  const [tagFilter, setTagFilter] = useState('')
2965
3216
  const [sinceFilter, setSinceFilter] = useState('')
2966
3217
 
3218
+ // Which models can be called, loaded from the Host rather than from config.
3219
+ //
3220
+ // The list used to come from `settings.memoryModels`, which is a patch value —
3221
+ // and this profile has no patch entry, so the picker offered only "off" and
3222
+ // "use current" while the user had several working providers in
3223
+ // Settings → 模型. The Host knows what it can actually call; ask it.
3224
+ const [models, setModels] = useState(null)
3225
+ useEffect(() => {
3226
+ let alive = true
3227
+ call({ action: 'longterm.models' })
3228
+ .then(list => alive && setModels(Array.isArray(list) ? list : []))
3229
+ .catch(() => alive && setModels([]))
3230
+ return () => {
3231
+ alive = false
3232
+ }
3233
+ }, [rev])
3234
+
2967
3235
  const detected = useWorkspacePath()
3236
+
3237
+ // Turn a stored workspace key into a readable name.
3238
+ //
3239
+ // The badge used to say 「本工作区」 for every workspace-scoped row, so a list
3240
+ // filtered to 全部 showed a dozen rows all claiming to be "this workspace" —
3241
+ // including rows from other workspaces. The key is a one-way hash, so the name
3242
+ // has to come from the registry, which is exactly what `longterm.workspaces`
3243
+ // returns.
3244
+ const workspaceLabel = key => {
3245
+ if (!key) return t('memory.workspace')
3246
+ const match = known.find(item => item.key === key)
3247
+ if (!match) return t('memory.workspace')
3248
+ const cwd = String(match.cwd ?? '')
3249
+ return cwd.split(/[\\/]/).filter(Boolean).pop() ?? cwd ?? t('memory.workspace')
3250
+ }
2968
3251
  const cwd = picked !== undefined ? picked : detected
2969
3252
 
2970
3253
  useEffect(() => {
@@ -2972,6 +3255,9 @@ window.__ModuleLoader__.load({
2972
3255
  call({ action: 'longterm.workspaces' })
2973
3256
  .then(list => alive && setKnown(Array.isArray(list) ? list : []))
2974
3257
  .catch(() => alive && setKnown([]))
3258
+ call({ action: 'longterm.stats' })
3259
+ .then(data => alive && setStats(data ?? null))
3260
+ .catch(() => alive && setStats(null))
2975
3261
  return () => {
2976
3262
  alive = false
2977
3263
  }
@@ -3146,7 +3432,7 @@ window.__ModuleLoader__.load({
3146
3432
  'div',
3147
3433
  { className: 'dss-row-main' },
3148
3434
  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'))),
3435
+ h('div', { className: 'dss-row-meta' }, h('span', { className: 'dss-badge' }, item.scope === 'global' ? t('memory.global') : workspaceLabel(item.workspaceKey))),
3150
3436
  ),
3151
3437
  h(
3152
3438
  'div',
@@ -3207,14 +3493,33 @@ window.__ModuleLoader__.load({
3207
3493
  : null,
3208
3494
  )
3209
3495
  : null,
3496
+ // Raw counts of what the Host actually delivered, so "nothing waiting"
3497
+ // can be told apart from "the hook never fired". Without this the two
3498
+ // states look identical on screen.
3499
+ extraction
3500
+ ? h(
3501
+ 'div',
3502
+ { className: 'dss-row-meta' },
3503
+ h('span', null, t('memory.extractionEvents', { n: extraction.eventsSeen ?? 0 })),
3504
+ h('span', null, t('memory.extractionTurns', { n: extraction.turnEndsSeen ?? 0 })),
3505
+ )
3506
+ : null,
3210
3507
  )
3211
3508
 
3509
+ // The list gets its own scroll box.
3510
+ //
3511
+ // Without one, the whole page scrolled: fifty memories pushed the maintenance
3512
+ // and model controls far below the fold, so changing a setting meant scrolling
3513
+ // past the entire library first. A bounded box keeps the controls in place and
3514
+ // makes the list the only thing that moves. The height is a deliberate starting
3515
+ // point — roughly eight rows — chosen to leave the settings visible on a
3516
+ // laptop screen; it is a single number, easy to retune.
3212
3517
  const listBlock =
3213
3518
  visible.length === 0
3214
3519
  ? h('div', { className: 'dss-empty' }, t('memory.empty'))
3215
3520
  : h(
3216
3521
  'div',
3217
- { className: 'dss-card' },
3522
+ { className: 'dss-card dss-scroll-box' },
3218
3523
  ...visible.map(entry =>
3219
3524
  h(
3220
3525
  'div',
@@ -3226,7 +3531,7 @@ window.__ModuleLoader__.load({
3226
3531
  h(
3227
3532
  'div',
3228
3533
  { className: 'dss-row-meta' },
3229
- h('span', { className: 'dss-badge' + (entry.scope === 'global' ? ' dss-locked' : '') }, entry.scope === 'global' ? t('memory.global') : t('memory.workspace')),
3534
+ h('span', { className: 'dss-badge' + (entry.scope === 'global' ? ' dss-locked' : '') }, entry.scope === 'global' ? t('memory.global') : workspaceLabel(entry.workspaceKey)),
3230
3535
  entry.kind === 'rule' ? h('span', { className: 'dss-badge dss-temp' }, t('memory.rule')) : null,
3231
3536
  // An episode is a dated event, not a truth. Labelling it
3232
3537
  // separately is what lets the user tell "this happened once"
@@ -3331,6 +3636,44 @@ window.__ModuleLoader__.load({
3331
3636
  }),
3332
3637
  h('span', { className: 'dss-curate-title' }, t('memory.implicit')),
3333
3638
  ),
3639
+ // Which model does the background extraction call?
3640
+ //
3641
+ // This has to be an explicit choice. An earlier build silently fell back to
3642
+ // the Host's default model whenever no chain was configured, which spends the
3643
+ // user's tokens once per turn on a model they never picked for this job. A
3644
+ // background cost has to be visible and opt-in, so the choice is a control
3645
+ // with an explicit "off" that costs nothing.
3646
+ h(
3647
+ 'div',
3648
+ { className: 'dss-curate-field' },
3649
+ h('label', { className: 'dss-curate-label' }, t('memory.implicitModel')),
3650
+ h(
3651
+ 'select',
3652
+ {
3653
+ className: 'dss-select',
3654
+ value: settings?.implicitModel ?? 'off',
3655
+ disabled: busy === 'implicitModel',
3656
+ onChange: event =>
3657
+ run('implicitModel', () =>
3658
+ call({ action: 'longterm.settings', patch: { implicitModel: event.target.value } }),
3659
+ ),
3660
+ },
3661
+ h('option', { value: 'off' }, t('memory.implicitModelOff')),
3662
+ h('option', { value: 'current' }, t('memory.implicitModelCurrent')),
3663
+ // Real models from the Host, grouped by provider so a long list stays
3664
+ // readable. `models` is null while loading and [] when the Host reports
3665
+ // none — both render no group, which is honest: the two above are the
3666
+ // only choices that exist without a callable model.
3667
+ ...(Array.isArray(models) ? models : []).map(entry =>
3668
+ h(
3669
+ 'option',
3670
+ { value: `${entry.provider}/${entry.model}`, key: `${entry.provider}/${entry.model}` },
3671
+ entry.label ?? `${entry.provider}/${entry.model}`,
3672
+ ),
3673
+ ),
3674
+ ),
3675
+ h('div', { className: 'dss-curate-hint' }, t('memory.implicitModelHint')),
3676
+ ),
3334
3677
  h(
3335
3678
  'button',
3336
3679
  {
@@ -3462,6 +3805,7 @@ window.__ModuleLoader__.load({
3462
3805
  { className: 'dss-section' },
3463
3806
  head,
3464
3807
  pendingBlock,
3808
+ h(MemoryStats, { t, stats }),
3465
3809
  explainBlock,
3466
3810
  tabs,
3467
3811
  filterBlock,
package/index.js CHANGED
@@ -170,8 +170,25 @@ export function apply(ctx, config = {}) {
170
170
  const sweepMs = numberOr(config.sweepIntervalMs, 5 * 60 * 1000)
171
171
  if (sweepMs > 0) {
172
172
  ctx.effect(() => {
173
+ // Snapshot directories of deleted sessions are never revisited by the
174
+ // per-session prune, so they accumulate. Checked here rather than deleted:
175
+ // reclaiming disk on a timer without a record of what went is not something to
176
+ // do unattended, so this only reports and the settings page applies it. The
177
+ // check runs a quarter as often as the sweep — it lists sessions, which is
178
+ // more expensive than expiring a file.
179
+ let tick = 0
173
180
  const timer = setInterval(() => {
174
181
  void service.sweep().catch(error => log('sweep failed', error))
182
+ tick += 1
183
+ if (tick % 4 !== 0) return
184
+ void service
185
+ .sweepOrphanCheckpoints(false)
186
+ .then(result => {
187
+ if (result?.orphans > 0) {
188
+ log(`orphan snapshots: ${result.orphans} session(s), ${result.freed} reclaimable`)
189
+ }
190
+ })
191
+ .catch(error => log('orphan snapshot check failed', error))
175
192
  }, sweepMs)
176
193
  timer.unref?.()
177
194
  return () => clearInterval(timer)
package/lib/api.js CHANGED
@@ -307,6 +307,25 @@ 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() })
320
+ case 'longterm.stats':
321
+ // Aggregates for the visual summary. Computed here rather than in the browser
322
+ // because the browser would have to fetch every row to count them, and the
323
+ // counts must match what the list shows — one source, one answer.
324
+ return json({ ok: true, data: await service.longTermStats() })
325
+ case 'sweepOrphans':
326
+ // `apply` must be explicit. A dry run is the default so a stray call can never
327
+ // delete snapshot directories, and the report says how much would be freed.
328
+ return json({ ok: true, data: await service.sweepOrphanCheckpoints(input.apply === true) })
310
329
  case 'longterm.settings':
311
330
  return json(
312
331
  input.patch
@@ -652,6 +652,48 @@ export class CheckpointStore {
652
652
  }
653
653
  }
654
654
 
655
+ /**
656
+ * Find and remove snapshot directories whose session no longer exists.
657
+ *
658
+ * `#prune` only runs when a session takes another snapshot, so a session that is
659
+ * deleted — or removed by another tool — leaves its whole directory behind: index,
660
+ * manifests, and blobs. Nothing ever revisits it. The blobs are content-addressed
661
+ * and can be large, so this is real disk, not just clutter.
662
+ *
663
+ * The test for "orphan" is deliberately conservative: a directory is only removed
664
+ * when the Host has no session log for that id. A session that exists but has no
665
+ * snapshots yet is not an orphan, and a session whose log is temporarily unreadable
666
+ * must not be treated as gone — so the caller supplies the set of live ids rather
667
+ * than this module guessing at the Host's layout.
668
+ *
669
+ * @param {object} input input
670
+ * @param {Set<string>} input.liveSessionIds ids that still exist
671
+ * @param {boolean} [input.apply] delete for real; false only reports
672
+ * @returns {Promise<{ orphans: string[], removed: number, bytes: number }>} result
673
+ */
674
+ async sweepOrphans({ liveSessionIds, apply = false }) {
675
+ const ids = await this.#sessionIds()
676
+ const orphans = ids.filter(id => !liveSessionIds.has(id))
677
+
678
+ let bytes = 0
679
+ for (const id of orphans) {
680
+ bytes += await dirSize(this.sessionDir(id)).catch(() => 0)
681
+ }
682
+
683
+ let removed = 0
684
+ if (apply) {
685
+ for (const id of orphans) {
686
+ try {
687
+ await removeDir(this.sessionDir(id))
688
+ removed += 1
689
+ } catch {
690
+ /* one failure must not stop the sweep */
691
+ }
692
+ }
693
+ }
694
+ return { orphans, removed, bytes }
695
+ }
696
+
655
697
  async #sessionIds() {
656
698
  try {
657
699
  const entries = await fs.readdir(path.join(this.root, 'sessions'), { withFileTypes: true })
@@ -205,9 +205,32 @@ 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
+ //
215
+ // These counters are published IMMEDIATELY, not in the finally below. The
216
+ // first version only published after extraction ran, so a session where the
217
+ // hook never fired left `lastExtraction` null and the settings page showed
218
+ // no counters at all — the diagnostic could not report the one case it
219
+ // existed for.
220
+ extractionTrace.eventsSeen = (extractionTrace.eventsSeen ?? 0) + 1
221
+ extractionTrace.at = Date.now()
222
+ publishExtractionTrace(engine)
208
223
  if (!event || event.type !== 'turn/end') return
224
+ extractionTrace.turnEndsSeen = (extractionTrace.turnEndsSeen ?? 0) + 1
225
+ publishExtractionTrace(engine)
226
+ // A branded id is still a string at runtime; accept any non-empty one and
227
+ // report the actual shape when it is not.
209
228
  const sessionId = session?.id
210
- if (typeof sessionId !== 'string') return
229
+ if (typeof sessionId !== 'string' || !sessionId) {
230
+ extractionTrace.reason = `a turn ended but the session id was ${typeof sessionId}`
231
+ publishExtractionTrace(engine)
232
+ return
233
+ }
211
234
  const cwd = resolveCwd(ctx, session)
212
235
  void extractAfterTurn({ ctx, engine, sessionId, cwd, log, config })
213
236
  // The trace is published in a finally so a thrown error still leaves a
@@ -494,6 +517,12 @@ const extractionTrace = {
494
517
 
495
518
  /** @param {string} sessionId session being processed */
496
519
  function resetExtractionTrace(sessionId) {
520
+ // The counters survive a reset on purpose. They measure whether the hook is being
521
+ // called at all, which is a property of the process, not of one extraction attempt:
522
+ // clearing them here made every attempt look like the first one and erased the
523
+ // evidence the diagnostic exists to collect.
524
+ const eventsSeen = extractionTrace.eventsSeen ?? 0
525
+ const turnEndsSeen = extractionTrace.turnEndsSeen ?? 0
497
526
  extractionTrace.sessionId = sessionId
498
527
  extractionTrace.at = Date.now()
499
528
  extractionTrace.ok = false
@@ -503,6 +532,8 @@ function resetExtractionTrace(sessionId) {
503
532
  extractionTrace.produced = 0
504
533
  extractionTrace.queued = 0
505
534
  extractionTrace.rejected = 0
535
+ extractionTrace.eventsSeen = eventsSeen
536
+ extractionTrace.turnEndsSeen = turnEndsSeen
506
537
  }
507
538
 
508
539
  /**
@@ -553,8 +584,14 @@ async function extractAfterTurn({ ctx, engine, sessionId, cwd, log, config }) {
553
584
  // The memory model is a fallback chain: a free provider that is rate limited
554
585
  // (429) or unreachable must not silently disable extraction for the session,
555
586
  // 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')
587
+ const candidates = memoryCandidates(config, ctx)
588
+ if (candidates.length === 0) {
589
+ return failExtraction(
590
+ config?.implicitModel === 'current'
591
+ ? 'the Host exposes no default model to fall back to'
592
+ : 'implicit extraction has no model configured — choose one in Settings → 长期记忆 (隐式提取)',
593
+ )
594
+ }
558
595
  extractionTrace.candidates = candidates.map(candidate => `${candidate.provider}/${candidate.model}`)
559
596
 
560
597
  // The prompt asks for two different shapes, because the library stores two
@@ -709,7 +746,7 @@ function numberOr(value, fallback) {
709
746
  * @param {any} config plugin config
710
747
  * @returns {{ provider: string, model: string }[]} candidates in priority order
711
748
  */
712
- function memoryCandidates(config) {
749
+ function memoryCandidates(config, ctx) {
713
750
  const raw = []
714
751
  const push = value => {
715
752
  if (Array.isArray(value)) raw.push(...value)
@@ -717,6 +754,37 @@ function memoryCandidates(config) {
717
754
  }
718
755
  push(config?.memoryModels)
719
756
  push(config?.memoryModel)
757
+ // A single explicit pick, stored by the settings UI as "provider/model".
758
+ //
759
+ // This is the "one model, chosen here" case: the user picked a specific entry from
760
+ // the offered chain, and that pick must beat the chain (which is a fallback list,
761
+ // not a preference). Parsed on the last slash so a model id that itself contains a
762
+ // slash — "Qwen/Qwen2.5-7B-Instruct" — survives intact.
763
+ const picked = typeof config?.implicitModel === 'string' ? config.implicitModel : ''
764
+ if (picked && picked !== 'current' && picked !== 'off') {
765
+ const at = picked.indexOf('/')
766
+ if (at > 0 && at < picked.length - 1) {
767
+ raw.unshift({ provider: picked.slice(0, at), model: picked.slice(at + 1) })
768
+ }
769
+ }
770
+ // Falling back to the Host's own default model is OFF unless the user asks for it.
771
+ //
772
+ // An earlier version did this unconditionally, to fix "extraction never runs when
773
+ // no chain is configured". That traded one bug for a worse one: it spends the
774
+ // user's tokens on a model they never chose for this purpose, once per turn,
775
+ // silently. Background spending has to be opted into — so the fallback requires
776
+ // `implicitModel: 'current'` and otherwise returns nothing, leaving extraction off
777
+ // with a reason the user can read.
778
+ if (raw.length === 0 && ctx && picked === 'current') {
779
+ try {
780
+ const selection = ctx.get('agentDefaultModel')?.currentSelection?.()
781
+ if (selection?.provider && selection?.model) {
782
+ raw.push({ provider: selection.provider, model: selection.model })
783
+ }
784
+ } catch {
785
+ /* a Host without the service just means no fallback */
786
+ }
787
+ }
720
788
  const out = []
721
789
  const seen = new Set()
722
790
  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,149 @@ export class SessionSuiteService {
959
962
  return { sessionId, restored: true }
960
963
  }
961
964
 
965
+ /**
966
+ * Remove snapshot directories belonging to sessions that no longer exist.
967
+ *
968
+ * The live set comes from the Host's own session listing, not from our own files:
969
+ * a session that exists but has not snapshotted anything must not be mistaken for a
970
+ * dead one, and the Host is the only authority on what exists.
971
+ *
972
+ * @param {boolean} [apply] delete for real; false only reports
973
+ * @returns {Promise<{ orphans: number, removed: number, bytes: number, freed: string }>} result
974
+ */
975
+ async sweepOrphanCheckpoints(apply = false) {
976
+ const live = new Set()
977
+ try {
978
+ const controller = this.#service('sessionController')
979
+ const value = await controller?.list?.({})
980
+ for (const item of Array.isArray(value?.items) ? value.items : []) {
981
+ if (typeof item?.sessionId === 'string') live.add(item.sessionId)
982
+ }
983
+ } catch (error) {
984
+ // Without a trustworthy live set, doing nothing is the only safe answer: every
985
+ // directory would look like an orphan and the sweep would delete everything.
986
+ this.log('could not list live sessions; refusing to sweep', error)
987
+ throw new Error('could not list live sessions, so the orphan sweep was refused')
988
+ }
989
+ const result = await this.store.sweepOrphans({ liveSessionIds: live, apply })
990
+ return {
991
+ orphans: result.orphans.length,
992
+ removed: result.removed,
993
+ bytes: result.bytes,
994
+ freed: formatBytes(result.bytes),
995
+ }
996
+ }
997
+
998
+ /**
999
+ * Aggregate counts for the memory summary.
1000
+ *
1001
+ * Every figure here answers a question a reader actually asks about their own
1002
+ * library: what is it made of (kind), where does it live (scope), how healthy is it
1003
+ * (state and score bands), what is it about (tags). A dashboard that shows numbers
1004
+ * nobody would act on is decoration; these five all imply a next action — a rule
1005
+ * count too high means maintenance is over-producing, a low score band means decay
1006
+ * is about to archive things, a lopsided tag means one topic is crowding the rest.
1007
+ *
1008
+ * @returns {Promise<any>} aggregates
1009
+ */
1010
+ async longTermStats() {
1011
+ // `this.memory` is the per-session MemoryLedger, NOT the long-term engine — that
1012
+ // one is assigned later as `this.longTerm`. Reading the wrong object is why every
1013
+ // figure came back zero while the list showed 55 rows: the ledger is empty by
1014
+ // design, so the summary was honestly reporting an empty library that the panel
1015
+ // next to it was simultaneously disproving.
1016
+ const engine = this.longTerm
1017
+ if (!engine?.list) {
1018
+ return { total: 0, active: 0, archived: 0, kind: {}, scope: {}, privacy: {}, bands: { high: 0, mid: 0, low: 0 }, tags: [] }
1019
+ }
1020
+ const rows = await engine.list({ scope: 'all' })
1021
+ const active = rows.filter(row => row.archived !== true)
1022
+
1023
+ /** @param {any[]} list @param {(row: any) => string} key @returns {Record<string, number>} */
1024
+ const tally = (list, key) => {
1025
+ const out = {}
1026
+ for (const row of list) {
1027
+ const value = key(row)
1028
+ out[value] = (out[value] ?? 0) + 1
1029
+ }
1030
+ return out
1031
+ }
1032
+
1033
+ // Score bands match the engine's own thresholds so the chart and the behaviour
1034
+ // agree: >=0.6 is comfortably alive, 0.45-0.6 is approaching the compress line,
1035
+ // <0.45 is at or below it and is what maintenance will compress next.
1036
+ const bands = { high: 0, mid: 0, low: 0 }
1037
+ for (const row of active) {
1038
+ const score = Number(row.score ?? 0)
1039
+ if (score >= 0.6) bands.high += 1
1040
+ else if (score >= 0.45) bands.mid += 1
1041
+ else bands.low += 1
1042
+ }
1043
+
1044
+ const tags = Object.entries(tally(active, row => (Array.isArray(row.tags) && row.tags[0]) || '未分类'))
1045
+ .sort((a, b) => b[1] - a[1])
1046
+ .slice(0, 10)
1047
+ .map(([tag, count]) => ({ tag, count }))
1048
+
1049
+ return {
1050
+ total: rows.length,
1051
+ active: active.length,
1052
+ archived: rows.length - active.length,
1053
+ kind: tally(rows, row => row.kind ?? 'fact'),
1054
+ scope: tally(rows, row => row.scope ?? 'workspace'),
1055
+ privacy: tally(rows, row => row.privacy ?? 'internal'),
1056
+ bands,
1057
+ tags,
1058
+ }
1059
+ }
1060
+
1061
+ /**
1062
+ * Every model this Host can actually call, for the extraction picker.
1063
+ *
1064
+ * Read from the `llm` service, not from configuration. The picker used to render
1065
+ * `config.memoryModels` — a list supplied by the bundle patch — so a profile with no
1066
+ * patch entry for this bundle offered only "off" and "use current" and no real
1067
+ * models, even though the user had several providers configured and working in
1068
+ * Settings → 模型. What can be called is the llm service's answer to give; a config
1069
+ * file only says what someone once wrote down.
1070
+ *
1071
+ * @returns {Promise<Array<{ provider: string, model: string, label: string }>>} models
1072
+ */
1073
+ async longTermModels() {
1074
+ const llm = this.ctx.get?.('llm')
1075
+ if (!llm) return []
1076
+ const out = []
1077
+ let providers = []
1078
+ try {
1079
+ providers = await llm.listProviders?.() ?? []
1080
+ } catch (error) {
1081
+ this.log('could not list llm providers', error)
1082
+ return []
1083
+ }
1084
+ for (const provider of providers) {
1085
+ const id = provider?.id
1086
+ if (typeof id !== 'string' || !id) continue
1087
+ let models = []
1088
+ try {
1089
+ models = await llm.listModels?.(id) ?? []
1090
+ } catch (error) {
1091
+ // One provider failing to advertise must not empty the whole picker.
1092
+ this.log(`could not list models for ${id}`, error)
1093
+ continue
1094
+ }
1095
+ for (const model of models) {
1096
+ const modelId = model?.id
1097
+ if (typeof modelId !== 'string' || !modelId) continue
1098
+ out.push({
1099
+ provider: id,
1100
+ model: modelId,
1101
+ label: `${provider?.name ?? id} · ${model?.name ?? modelId}`,
1102
+ })
1103
+ }
1104
+ }
1105
+ return out
1106
+ }
1107
+
962
1108
  /**
963
1109
  * The set of archived session ids, read from the registry that owns them.
964
1110
  *
@@ -1082,6 +1228,13 @@ function decorateCheckpoint(checkpoint) {
1082
1228
  temporary: Boolean(checkpoint.expiresAt),
1083
1229
  expiresAt: checkpoint.expiresAt ?? null,
1084
1230
  skipped: checkpoint.skipped ?? 0,
1231
+ // Inherited snapshots came from the parent session by a rewind, and they are
1232
+ // EXEMPT from this session's keep-budget. The UI has to know that: without the
1233
+ // flag it cannot explain why a session with a budget of 10 shows sixteen rows,
1234
+ // which reads as a prune bug rather than as the intended behaviour. This field
1235
+ // was silently dropped by this whitelist — every field not listed here never
1236
+ // reaches the browser.
1237
+ inheritedFrom: checkpoint.inheritedFrom ?? null,
1085
1238
  }
1086
1239
  }
1087
1240
 
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.3",
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",