@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 +260 -1
- package/index.js +17 -0
- package/lib/api.js +9 -0
- package/lib/checkpoints.js +42 -0
- package/lib/memory-host.js +31 -7
- 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.',
|
|
@@ -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
|
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
|
@@ -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
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
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.
|
|
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",
|