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