@heixui/dsh-session-suite 0.4.2 → 0.4.4

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/client.js CHANGED
@@ -143,6 +143,11 @@ window.__ModuleLoader__.load({
143
143
  'settings.budget': 'Keeps the newest {keep} automatic snapshots',
144
144
  'settings.budgetInherited': '+ {n} inherited',
145
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}',
146
151
  'settings.budgetNote':
147
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}.',
148
153
  'settings.usage': 'Snapshots use {size}',
@@ -213,7 +218,16 @@ window.__ModuleLoader__.load({
213
218
  'memory.unfreeze': 'Unfreeze',
214
219
  'memory.remove': 'Delete',
215
220
  'memory.pendingTitle': 'Suggested memories',
216
- '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.',
217
231
  'memory.extractionIdle': 'Nothing is waiting. Last check: {reason}',
218
232
  'memory.extractionNotYet':
219
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.',
@@ -254,6 +268,8 @@ window.__ModuleLoader__.load({
254
268
  'memory.extractionRejected': '{n} rejected as unusable',
255
269
  'memory.extractionEvents': '{n} session event(s) received',
256
270
  'memory.extractionTurns': '{n} turn(s) ended',
271
+ 'memory.extractionRegistered': 'hook registered',
272
+ 'memory.extractionNotRegistered': 'HOOK NOT REGISTERED',
257
273
  'memory.accept': 'Keep',
258
274
  'memory.reject': 'Discard',
259
275
  'memory.accepted': 'Kept',
@@ -372,6 +388,11 @@ window.__ModuleLoader__.load({
372
388
  'settings.budget': '每个会话保留最近 {keep} 个自动快照',
373
389
  'settings.budgetInherited': '+ {n} 个继承',
374
390
  'settings.inherited': '继承',
391
+ 'settings.orphansSweep': '清理孤儿快照',
392
+ 'settings.orphansConfirm': '再点一次确认删除',
393
+ 'settings.orphansNone': '没有孤儿快照目录',
394
+ 'settings.orphansFound': '{n} 个会话的孤儿快照,可释放 {size} —— 再点一次删除',
395
+ 'settings.orphansRemoved': '已删除 {n} 个会话的孤儿快照,释放 {size}',
375
396
  'settings.budgetNote':
376
397
  '只有这个会话自己的自动快照计入预算。从父会话继承来的快照(回滚或删除消息时产生)单独保留,所以列表可能比 {keep} 长。',
377
398
  'settings.usage': '快照占用 {size}',
@@ -442,6 +463,16 @@ window.__ModuleLoader__.load({
442
463
  'memory.unfreeze': '解冻',
443
464
  'memory.remove': '删除',
444
465
  'memory.pendingTitle': '待确认的记忆',
466
+ 'memory.statsKind': '库里有什么',
467
+ 'memory.statsHealth': '分数分布',
468
+ 'memory.statsTags': '主题分布',
469
+ 'memory.kind.fact': '事实',
470
+ 'memory.kind.rule': '规则',
471
+ 'memory.kind.summary': '摘要',
472
+ 'memory.kind.episode': '经历',
473
+ 'memory.band.high': '强 ≥0.60',
474
+ 'memory.band.mid': '衰减 0.45-0.60',
475
+ 'memory.band.low': '低于线 <0.45',
445
476
  'memory.pendingHint': '从最近几轮里识别出来的,确认前不会入库。',
446
477
  'memory.extractionIdle': '当前没有待确认条目。上次检查结果:{reason}',
447
478
  'memory.extractionNotYet':
@@ -483,6 +514,8 @@ window.__ModuleLoader__.load({
483
514
  'memory.extractionRejected': '剔除 {n} 条',
484
515
  'memory.extractionEvents': '收到 {n} 个会话事件',
485
516
  'memory.extractionTurns': '结束 {n} 轮',
517
+ 'memory.extractionRegistered': '钩子已注册',
518
+ 'memory.extractionNotRegistered': '钩子未注册',
486
519
  'memory.accept': '保留',
487
520
  'memory.reject': '丢弃',
488
521
  'memory.accepted': '已保留',
@@ -993,6 +1026,53 @@ window.__ModuleLoader__.load({
993
1026
  overflow-y: auto;
994
1027
  overscroll-behavior: contain;
995
1028
  }
1029
+ /* The summary charts. Two narrow panels for composition and health, one wide panel
1030
+ for tags: composition and health are short lists, tags can run long. Bars are divs,
1031
+ not SVG, so they inherit the theme and stay legible in both palettes. */
1032
+ .dss-stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
1033
+ .dss-stats-panel {
1034
+ display: flex; flex-direction: column; gap: 8px;
1035
+ padding: 14px 16px;
1036
+ background: var(--dsw-alias-bg-layer-1, #fff);
1037
+ border: 1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.08));
1038
+ border-radius: 10px;
1039
+ }
1040
+ .dss-stats-wide { grid-column: 1 / -1; }
1041
+ .dss-statbar { display: flex; align-items: center; gap: 10px; font-size: 12px; }
1042
+ /* The label was a fixed 76px with nowrap + ellipsis, so anything longer than a
1043
+ couple of Chinese characters was cut off mid-word — 「低于压缩线(<0.45)」
1044
+ rendered as 「低于压缩线…」 and the reader could not tell which band it was.
1045
+ A wider column that wraps is the honest fix: a truncated label still occupies the
1046
+ same space, it just stops being readable. */
1047
+ .dss-statbar-label {
1048
+ flex: none; width: 108px;
1049
+ color: var(--dsw-alias-label-primary, #1f1f1f);
1050
+ line-height: 1.35;
1051
+ overflow-wrap: anywhere;
1052
+ }
1053
+ /* Composition and tag bars get distinct hues — declared at the END of the sheet, next
1054
+ to the fill rule, because source order decides between two single-class rules and an
1055
+ earlier declaration lost to the fill's own background. */
1056
+ .dss-statbar-track {
1057
+ flex: 1; height: 8px; min-width: 0;
1058
+ background: var(--dsw-alias-bg-layer-2, #f2f3f5);
1059
+ border-radius: 4px; overflow: hidden;
1060
+ }
1061
+ .dss-statbar-fill {
1062
+ height: 100%; border-radius: 4px;
1063
+ background: var(--dsw-alias-brand-primary, #2563eb);
1064
+ transition: width .2s ease;
1065
+ }
1066
+ /* Health bands use the semantic colours the rest of the app already uses, so the
1067
+ meaning does not depend on reading the label. */
1068
+ .dss-tone-good { background: var(--dsw-alias-state-success-primary, #1a9c50); }
1069
+ .dss-tone-warn { background: var(--dsw-alias-state-warning-primary, #d97706); }
1070
+ .dss-tone-bad { background: var(--dsw-alias-state-error-primary, #d93025); }
1071
+ .dss-statbar-value {
1072
+ flex: none; width: 32px; text-align: right;
1073
+ font-variant-numeric: tabular-nums; font-weight: 600;
1074
+ color: var(--dsw-alias-label-secondary, #6b6b6b);
1075
+ }
996
1076
  .dss-row {
997
1077
  display: flex; align-items: center; gap: 10px; padding: 10px 12px;
998
1078
  border-bottom: 1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.06));
@@ -1037,6 +1117,21 @@ window.__ModuleLoader__.load({
1037
1117
  .dss-row-actions { display: flex; align-items: center; gap: 4px; flex: none; }
1038
1118
  .dss-spin { width: 14px; height: 14px; border-radius: 50%; border: 2px solid currentColor; border-top-color: transparent; animation: dss-spin .8s linear infinite; }
1039
1119
  @keyframes dss-spin { to { transform: rotate(360deg); } }
1120
+ /* Colour overrides, last in the sheet on purpose.
1121
+ The first version put the hue classes before the fill rule, which sets a brand
1122
+ background — so every bar rendered the same near-black colour and the chart looked
1123
+ like five copies of one object. Source order decides here, not specificity: both are
1124
+ single-class selectors. Declared last so the hue wins, and the double-class form for
1125
+ the extra specificity that makes it survive a future rule added above. */
1126
+ .dss-statbar-fill.dss-tone-c0 { background: #4d6bfe; }
1127
+ .dss-statbar-fill.dss-tone-c1 { background: #7b5cf5; }
1128
+ .dss-statbar-fill.dss-tone-c2 { background: #2aa6c9; }
1129
+ .dss-statbar-fill.dss-tone-c3 { background: #14a37f; }
1130
+ .dss-statbar-fill.dss-tone-c4 { background: #e0a03a; }
1131
+ .dss-statbar-fill.dss-tone-c5 { background: #d9636a; }
1132
+ .dss-statbar-fill.dss-tone-good { background: var(--dsw-alias-state-success-primary, #1a9c50); }
1133
+ .dss-statbar-fill.dss-tone-warn { background: var(--dsw-alias-state-warning-primary, #d97706); }
1134
+ .dss-statbar-fill.dss-tone-bad { background: var(--dsw-alias-state-error-primary, #d93025); }
1040
1135
  `
1041
1136
 
1042
1137
  function installStyles() {
@@ -2295,6 +2390,9 @@ window.__ModuleLoader__.load({
2295
2390
  const [detail, setDetail] = useState(null)
2296
2391
  const [error, setError] = useState(null)
2297
2392
  const [busy, setBusy] = useState(null)
2393
+ // Two-step confirm for the orphan sweep: the first click reports, the second
2394
+ // deletes. Held in state so the button label can say which step it is on.
2395
+ const [orphanArmed, setOrphanArmed] = useState(false)
2298
2396
  const [memoryDraft, setMemoryDraft] = useState('')
2299
2397
  // Kept for the memory.add API path that the long-term screen still uses; the
2300
2398
  // snapshot panel no longer renders a session-memory editor.
@@ -2429,6 +2527,44 @@ window.__ModuleLoader__.load({
2429
2527
  ? h('div', { className: 'dss-section-desc' }, t('settings.budgetNote', { keep: stats?.budget ?? 10 }))
2430
2528
  : null
2431
2529
 
2530
+ // Reclaiming snapshot directories of deleted sessions.
2531
+ //
2532
+ // `#prune` only runs when a session takes another snapshot, so a deleted
2533
+ // session's directory is never revisited and its blobs are never freed. The
2534
+ // first click reports what would go; the second confirms. A destructive action
2535
+ // that also runs on a timer is how people lose data they did not know they had,
2536
+ // so the timer only reports and this control is the only thing that deletes.
2537
+ const orphanLine = h(
2538
+ 'div',
2539
+ { className: 'dss-toolbar' },
2540
+ h(
2541
+ 'button',
2542
+ {
2543
+ type: 'button',
2544
+ className: 'dss-btn dss-small dss-ghost',
2545
+ disabled: busy === 'orphans',
2546
+ onClick: () =>
2547
+ run('orphans', async () => {
2548
+ const result = await call({ action: 'sweepOrphans', apply: orphanArmed })
2549
+ if (result?.orphans === 0) {
2550
+ notify(t('settings.orphansNone'))
2551
+ setOrphanArmed(false)
2552
+ return
2553
+ }
2554
+ if (!orphanArmed) {
2555
+ notify(t('settings.orphansFound', { n: result.orphans, size: result.freed }))
2556
+ setOrphanArmed(true)
2557
+ return
2558
+ }
2559
+ notify(t('settings.orphansRemoved', { n: result.removed, size: result.freed }))
2560
+ setOrphanArmed(false)
2561
+ bump()
2562
+ }, null),
2563
+ },
2564
+ orphanArmed ? t('settings.orphansConfirm') : t('settings.orphansSweep'),
2565
+ ),
2566
+ )
2567
+
2432
2568
  const checkpoints = detail?.checkpoints ?? []
2433
2569
  const checkpointList =
2434
2570
  checkpoints.length === 0
@@ -2574,11 +2710,116 @@ window.__ModuleLoader__.load({
2574
2710
  budgetNote,
2575
2711
  checkpointList,
2576
2712
  undoList,
2713
+ orphanLine,
2577
2714
  )
2578
2715
  }
2579
2716
 
2580
2717
  // ------------------------------------------------------- long-term memory
2581
2718
 
2719
+ /**
2720
+ * One horizontal bar, drawn with a div rather than SVG.
2721
+ *
2722
+ * A bar chart of five rows does not need a charting library: a filled div inside
2723
+ * a track is the whole implementation, it inherits the theme's colours, and it
2724
+ * stays readable in both light and dark without any per-chart configuration. The
2725
+ * value is printed next to the bar, because the bar communicates proportion and
2726
+ * the number communicates quantity — one without the other is guesswork.
2727
+ *
2728
+ * @param {any} props row props
2729
+ * @returns {any} row element
2730
+ */
2731
+ function StatBar(props) {
2732
+ const { label, value, max, tone, hue } = props
2733
+ const ratio = max > 0 ? Math.min(1, value / max) : 0
2734
+ // `tone` is semantic (health bands); `hue` is positional (composition and tags).
2735
+ // They are separate because one means "this is bad" and the other means "this is
2736
+ // a different row" — conflating them would colour a healthy category red just
2737
+ // because it happens to be fifth.
2738
+ const fillClass = tone
2739
+ ? ` dss-tone-${tone}`
2740
+ : typeof hue === 'number'
2741
+ ? ` dss-tone-c${hue % 6}`
2742
+ : ''
2743
+ return h(
2744
+ 'div',
2745
+ { className: 'dss-statbar' },
2746
+ h('div', { className: 'dss-statbar-label', title: String(label) }, label),
2747
+ h(
2748
+ 'div',
2749
+ { className: 'dss-statbar-track' },
2750
+ h('div', {
2751
+ className: `dss-statbar-fill${fillClass}`,
2752
+ style: { width: `${Math.round(ratio * 100)}%` },
2753
+ }),
2754
+ ),
2755
+ h('div', { className: 'dss-statbar-value' }, String(value)),
2756
+ )
2757
+ }
2758
+
2759
+ /**
2760
+ * The memory summary: what the library is made of, and whether it is healthy.
2761
+ *
2762
+ * Each panel answers one question a reader would actually ask, and each implies a
2763
+ * next action: a high rule count means maintenance is over-producing, a large
2764
+ * "below threshold" band means decay is about to archive things, a lopsided tag
2765
+ * list means one topic is crowding out the rest. Numbers nobody would act on are
2766
+ * decoration; these are not.
2767
+ *
2768
+ * @param {any} props panel props
2769
+ * @returns {any} summary element
2770
+ */
2771
+ function MemoryStats(props) {
2772
+ const t = props.t ?? (key => key)
2773
+ const stats = props.stats
2774
+ if (!stats || typeof stats !== 'object') return null
2775
+
2776
+ const kinds = [
2777
+ ['fact', stats.kind?.fact ?? 0],
2778
+ ['rule', stats.kind?.rule ?? 0],
2779
+ ['summary', stats.kind?.summary ?? 0],
2780
+ ['episode', stats.kind?.episode ?? 0],
2781
+ ].filter(([, count]) => count > 0)
2782
+ const kindMax = Math.max(1, ...kinds.map(([, count]) => count))
2783
+
2784
+ const bands = [
2785
+ ['high', stats.bands?.high ?? 0, 'good'],
2786
+ ['mid', stats.bands?.mid ?? 0, 'warn'],
2787
+ ['low', stats.bands?.low ?? 0, 'bad'],
2788
+ ]
2789
+ const bandMax = Math.max(1, ...bands.map(([, count]) => count))
2790
+
2791
+ const tagMax = Math.max(1, ...(stats.tags ?? []).map(item => item.count))
2792
+
2793
+ return h(
2794
+ 'div',
2795
+ { className: 'dss-stats' },
2796
+ h(
2797
+ 'div',
2798
+ { className: 'dss-stats-panel' },
2799
+ h('div', { className: 'dss-group-title' }, t('memory.statsKind')),
2800
+ ...kinds.map(([kind, count], index) =>
2801
+ h(StatBar, { key: kind, label: t(`memory.kind.${kind}`), value: count, max: kindMax, hue: index }),
2802
+ ),
2803
+ ),
2804
+ h(
2805
+ 'div',
2806
+ { className: 'dss-stats-panel' },
2807
+ h('div', { className: 'dss-group-title' }, t('memory.statsHealth')),
2808
+ ...bands.map(([band, count, tone]) =>
2809
+ h(StatBar, { key: band, label: t(`memory.band.${band}`), value: count, max: bandMax, tone }),
2810
+ ),
2811
+ ),
2812
+ h(
2813
+ 'div',
2814
+ { className: 'dss-stats-panel dss-stats-wide' },
2815
+ h('div', { className: 'dss-group-title' }, t('memory.statsTags')),
2816
+ ...(stats.tags ?? []).slice(0, 6).map((item, index) =>
2817
+ h(StatBar, { key: item.tag, label: item.tag, value: item.count, max: tagMax, hue: index }),
2818
+ ),
2819
+ ),
2820
+ )
2821
+ }
2822
+
2582
2823
  function useWorkspacePath() {
2583
2824
  return runtime.resolveCwd?.() ?? runtime.cwd
2584
2825
  }
@@ -2965,6 +3206,9 @@ window.__ModuleLoader__.load({
2965
3206
  const [draftScope, setDraftScope] = useState('workspace')
2966
3207
  const [busy, setBusy] = useState(null)
2967
3208
  const [known, setKnown] = useState([])
3209
+ // Aggregates for the summary charts, fetched separately so a slow list load
3210
+ // never blocks the numbers from appearing.
3211
+ const [stats, setStats] = useState(null)
2968
3212
  // Maintenance bookkeeping, so "did the timer ever run?" is a fact on
2969
3213
  // screen rather than something the user has to take on trust.
2970
3214
  const [maint, setMaint] = useState(null)
@@ -3022,6 +3266,9 @@ window.__ModuleLoader__.load({
3022
3266
  call({ action: 'longterm.workspaces' })
3023
3267
  .then(list => alive && setKnown(Array.isArray(list) ? list : []))
3024
3268
  .catch(() => alive && setKnown([]))
3269
+ call({ action: 'longterm.stats' })
3270
+ .then(data => alive && setStats(data ?? null))
3271
+ .catch(() => alive && setStats(null))
3025
3272
  return () => {
3026
3273
  alive = false
3027
3274
  }
@@ -3260,12 +3507,23 @@ window.__ModuleLoader__.load({
3260
3507
  // Raw counts of what the Host actually delivered, so "nothing waiting"
3261
3508
  // can be told apart from "the hook never fired". Without this the two
3262
3509
  // states look identical on screen.
3510
+ //
3511
+ // `registered` is the third state: the hook was never installed at all
3512
+ // (the optional registration threw), which is a different bug from
3513
+ // "installed but never called".
3263
3514
  extraction
3264
3515
  ? h(
3265
3516
  'div',
3266
3517
  { className: 'dss-row-meta' },
3267
3518
  h('span', null, t('memory.extractionEvents', { n: extraction.eventsSeen ?? 0 })),
3268
3519
  h('span', null, t('memory.extractionTurns', { n: extraction.turnEndsSeen ?? 0 })),
3520
+ h(
3521
+ 'span',
3522
+ null,
3523
+ extraction.registered
3524
+ ? t('memory.extractionRegistered')
3525
+ : t('memory.extractionNotRegistered'),
3526
+ ),
3269
3527
  )
3270
3528
  : null,
3271
3529
  )
@@ -3569,6 +3827,7 @@ window.__ModuleLoader__.load({
3569
3827
  { className: 'dss-section' },
3570
3828
  head,
3571
3829
  pendingBlock,
3830
+ h(MemoryStats, { t, stats }),
3572
3831
  explainBlock,
3573
3832
  tabs,
3574
3833
  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
@@ -317,6 +317,15 @@ async function respond(service, deps, action, input) {
317
317
  // adapter and what each one advertises. Reading configuration to answer "what
318
318
  // can I call" was the mistake.
319
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) })
320
329
  case 'longterm.settings':
321
330
  return json(
322
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 })
@@ -201,7 +201,17 @@ export function installMemory({ ctx, root, config, log }) {
201
201
  )
202
202
 
203
203
  // --- 3. Implicit extraction after a turn --------------------------------
204
- safe('implicit extraction', () =>
204
+ safe('implicit extraction', () => {
205
+ // A startup marker, recorded before the listener is even registered.
206
+ //
207
+ // The whole question "why is the queue empty" has two halves that look identical
208
+ // on screen: the hook is not registered at all, or it is registered and never
209
+ // called. This records the first half so the trace can distinguish them — the
210
+ // block used to render only after extraction ran, so a dead hook produced no
211
+ // trace and therefore no diagnostics.
212
+ extractionTrace.registered = true
213
+ extractionTrace.registeredAt = Date.now()
214
+ publishExtractionTrace(engine)
205
215
  ctx.effect(
206
216
  () =>
207
217
  ctx.on('session/event', (session, event) => {
@@ -210,13 +220,19 @@ export function installMemory({ ctx, root, config, log }) {
210
220
  // The queue stayed empty for an entire session and the reason could have
211
221
  // been any of: the listener never fired, it fired but saw no `turn/end`,
212
222
  // `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.
223
+ // Each has a different fix, so the trace records which one happened.
224
+ //
225
+ // These counters are published IMMEDIATELY, not in the finally below. The
226
+ // first version only published after extraction ran, so a session where the
227
+ // hook never fired left `lastExtraction` null and the settings page showed
228
+ // no counters at all — the diagnostic could not report the one case it
229
+ // existed for.
217
230
  extractionTrace.eventsSeen = (extractionTrace.eventsSeen ?? 0) + 1
231
+ extractionTrace.at = Date.now()
232
+ publishExtractionTrace(engine)
218
233
  if (!event || event.type !== 'turn/end') return
219
234
  extractionTrace.turnEndsSeen = (extractionTrace.turnEndsSeen ?? 0) + 1
235
+ publishExtractionTrace(engine)
220
236
  // A branded id is still a string at runtime; accept any non-empty one and
221
237
  // report the actual shape when it is not.
222
238
  const sessionId = session?.id
@@ -239,8 +255,8 @@ export function installMemory({ ctx, root, config, log }) {
239
255
  })
240
256
  }),
241
257
  'session-suite: implicit memory',
242
- ),
243
- )
258
+ )
259
+ })
244
260
 
245
261
  // --- 3b. Automatic maintenance -------------------------------------------
246
262
  // Scoring, decay, clustering, and cold archiving are otherwise only reachable
@@ -511,6 +527,12 @@ const extractionTrace = {
511
527
 
512
528
  /** @param {string} sessionId session being processed */
513
529
  function resetExtractionTrace(sessionId) {
530
+ // The counters survive a reset on purpose. They measure whether the hook is being
531
+ // called at all, which is a property of the process, not of one extraction attempt:
532
+ // clearing them here made every attempt look like the first one and erased the
533
+ // evidence the diagnostic exists to collect.
534
+ const eventsSeen = extractionTrace.eventsSeen ?? 0
535
+ const turnEndsSeen = extractionTrace.turnEndsSeen ?? 0
514
536
  extractionTrace.sessionId = sessionId
515
537
  extractionTrace.at = Date.now()
516
538
  extractionTrace.ok = false
@@ -520,6 +542,8 @@ function resetExtractionTrace(sessionId) {
520
542
  extractionTrace.produced = 0
521
543
  extractionTrace.queued = 0
522
544
  extractionTrace.rejected = 0
545
+ extractionTrace.eventsSeen = eventsSeen
546
+ extractionTrace.turnEndsSeen = turnEndsSeen
523
547
  }
524
548
 
525
549
  /**
package/lib/service.js CHANGED
@@ -962,6 +962,102 @@ export class SessionSuiteService {
962
962
  return { sessionId, restored: true }
963
963
  }
964
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
+
965
1061
  /**
966
1062
  * Every model this Host can actually call, for the extraction picker.
967
1063
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heixui/dsh-session-suite",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
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",