@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 +238 -1
- package/index.js +17 -0
- package/lib/api.js +9 -0
- package/lib/checkpoints.js +42 -0
- package/lib/memory-host.js +18 -4
- package/lib/service.js +96 -0
- package/package.json +1 -1
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.
|
|
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
|
package/lib/checkpoints.js
CHANGED
|
@@ -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 })
|
package/lib/memory-host.js
CHANGED
|
@@ -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
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
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.
|
|
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",
|