@heixui/dsh-session-suite 0.4.2 → 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/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.',
@@ -372,6 +386,11 @@ window.__ModuleLoader__.load({
372
386
  'settings.budget': '每个会话保留最近 {keep} 个自动快照',
373
387
  'settings.budgetInherited': '+ {n} 个继承',
374
388
  'settings.inherited': '继承',
389
+ 'settings.orphansSweep': '清理孤儿快照',
390
+ 'settings.orphansConfirm': '再点一次确认删除',
391
+ 'settings.orphansNone': '没有孤儿快照目录',
392
+ 'settings.orphansFound': '{n} 个会话的孤儿快照,可释放 {size} —— 再点一次删除',
393
+ 'settings.orphansRemoved': '已删除 {n} 个会话的孤儿快照,释放 {size}',
375
394
  'settings.budgetNote':
376
395
  '只有这个会话自己的自动快照计入预算。从父会话继承来的快照(回滚或删除消息时产生)单独保留,所以列表可能比 {keep} 长。',
377
396
  'settings.usage': '快照占用 {size}',
@@ -442,6 +461,16 @@ window.__ModuleLoader__.load({
442
461
  'memory.unfreeze': '解冻',
443
462
  'memory.remove': '删除',
444
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',
445
474
  'memory.pendingHint': '从最近几轮里识别出来的,确认前不会入库。',
446
475
  'memory.extractionIdle': '当前没有待确认条目。上次检查结果:{reason}',
447
476
  'memory.extractionNotYet':
@@ -993,6 +1022,61 @@ window.__ModuleLoader__.load({
993
1022
  overflow-y: auto;
994
1023
  overscroll-behavior: contain;
995
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
+ }
996
1080
  .dss-row {
997
1081
  display: flex; align-items: center; gap: 10px; padding: 10px 12px;
998
1082
  border-bottom: 1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.06));
@@ -2295,6 +2379,9 @@ window.__ModuleLoader__.load({
2295
2379
  const [detail, setDetail] = useState(null)
2296
2380
  const [error, setError] = useState(null)
2297
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)
2298
2385
  const [memoryDraft, setMemoryDraft] = useState('')
2299
2386
  // Kept for the memory.add API path that the long-term screen still uses; the
2300
2387
  // snapshot panel no longer renders a session-memory editor.
@@ -2429,6 +2516,44 @@ window.__ModuleLoader__.load({
2429
2516
  ? h('div', { className: 'dss-section-desc' }, t('settings.budgetNote', { keep: stats?.budget ?? 10 }))
2430
2517
  : null
2431
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
+ )
2556
+
2432
2557
  const checkpoints = detail?.checkpoints ?? []
2433
2558
  const checkpointList =
2434
2559
  checkpoints.length === 0
@@ -2574,11 +2699,116 @@ window.__ModuleLoader__.load({
2574
2699
  budgetNote,
2575
2700
  checkpointList,
2576
2701
  undoList,
2702
+ orphanLine,
2577
2703
  )
2578
2704
  }
2579
2705
 
2580
2706
  // ------------------------------------------------------- long-term memory
2581
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
+
2582
2812
  function useWorkspacePath() {
2583
2813
  return runtime.resolveCwd?.() ?? runtime.cwd
2584
2814
  }
@@ -2965,6 +3195,9 @@ window.__ModuleLoader__.load({
2965
3195
  const [draftScope, setDraftScope] = useState('workspace')
2966
3196
  const [busy, setBusy] = useState(null)
2967
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)
2968
3201
  // Maintenance bookkeeping, so "did the timer ever run?" is a fact on
2969
3202
  // screen rather than something the user has to take on trust.
2970
3203
  const [maint, setMaint] = useState(null)
@@ -3022,6 +3255,9 @@ window.__ModuleLoader__.load({
3022
3255
  call({ action: 'longterm.workspaces' })
3023
3256
  .then(list => alive && setKnown(Array.isArray(list) ? list : []))
3024
3257
  .catch(() => alive && setKnown([]))
3258
+ call({ action: 'longterm.stats' })
3259
+ .then(data => alive && setStats(data ?? null))
3260
+ .catch(() => alive && setStats(null))
3025
3261
  return () => {
3026
3262
  alive = false
3027
3263
  }
@@ -3569,6 +3805,7 @@ window.__ModuleLoader__.load({
3569
3805
  { className: 'dss-section' },
3570
3806
  head,
3571
3807
  pendingBlock,
3808
+ h(MemoryStats, { t, stats }),
3572
3809
  explainBlock,
3573
3810
  tabs,
3574
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
@@ -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 })
@@ -210,13 +210,19 @@ export function installMemory({ ctx, root, config, log }) {
210
210
  // The queue stayed empty for an entire session and the reason could have
211
211
  // been any of: the listener never fired, it fired but saw no `turn/end`,
212
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.
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.
217
220
  extractionTrace.eventsSeen = (extractionTrace.eventsSeen ?? 0) + 1
221
+ extractionTrace.at = Date.now()
222
+ publishExtractionTrace(engine)
218
223
  if (!event || event.type !== 'turn/end') return
219
224
  extractionTrace.turnEndsSeen = (extractionTrace.turnEndsSeen ?? 0) + 1
225
+ publishExtractionTrace(engine)
220
226
  // A branded id is still a string at runtime; accept any non-empty one and
221
227
  // report the actual shape when it is not.
222
228
  const sessionId = session?.id
@@ -511,6 +517,12 @@ const extractionTrace = {
511
517
 
512
518
  /** @param {string} sessionId session being processed */
513
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
514
526
  extractionTrace.sessionId = sessionId
515
527
  extractionTrace.at = Date.now()
516
528
  extractionTrace.ok = false
@@ -520,6 +532,8 @@ function resetExtractionTrace(sessionId) {
520
532
  extractionTrace.produced = 0
521
533
  extractionTrace.queued = 0
522
534
  extractionTrace.rejected = 0
535
+ extractionTrace.eventsSeen = eventsSeen
536
+ extractionTrace.turnEndsSeen = turnEndsSeen
523
537
  }
524
538
 
525
539
  /**
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.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",