@heixui/dsh-session-suite 0.4.1 → 0.4.2
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 +170 -63
- package/lib/api.js +10 -0
- package/lib/memory-host.js +58 -4
- package/lib/service.js +57 -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,10 @@ 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.budgetNote':
|
|
147
|
+
'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
148
|
'settings.usage': 'Snapshots use {size}',
|
|
145
149
|
'settings.files': '{count} files',
|
|
146
150
|
'settings.turn': 'Turn {turn}',
|
|
@@ -234,7 +238,7 @@ window.__ModuleLoader__.load({
|
|
|
234
238
|
'panel.breakdownTitle': 'Where the memories live',
|
|
235
239
|
'panel.breakdownGlobal': 'Global (shared by every workspace)',
|
|
236
240
|
'panel.breakdownHint': 'Each workspace keeps its own store; the total above is the sum of these plus the global one.',
|
|
237
|
-
'panel.repoLink': '
|
|
241
|
+
'panel.repoLink': 'GitHub source — a small encouragement',
|
|
238
242
|
'panel.noSettingsSeam': 'This Host build does not expose the settings navigation seam, so this row cannot open its page.',
|
|
239
243
|
'archived.title': 'Archived sessions',
|
|
240
244
|
'archived.description': 'Archived sessions are hidden from the sidebar. Restoring one puts it back; nothing is deleted.',
|
|
@@ -248,6 +252,8 @@ window.__ModuleLoader__.load({
|
|
|
248
252
|
'memory.extractionTried': 'tried {n} memory model(s)',
|
|
249
253
|
'memory.extractionQueued': '{n} queued',
|
|
250
254
|
'memory.extractionRejected': '{n} rejected as unusable',
|
|
255
|
+
'memory.extractionEvents': '{n} session event(s) received',
|
|
256
|
+
'memory.extractionTurns': '{n} turn(s) ended',
|
|
251
257
|
'memory.accept': 'Keep',
|
|
252
258
|
'memory.reject': 'Discard',
|
|
253
259
|
'memory.accepted': 'Kept',
|
|
@@ -260,6 +266,11 @@ window.__ModuleLoader__.load({
|
|
|
260
266
|
'memory.maintainedCompressed': 'compressed',
|
|
261
267
|
'memory.maintainedRules': 'rules',
|
|
262
268
|
'memory.implicit': 'Suggest memories automatically',
|
|
269
|
+
'memory.implicitModel': 'Model for automatic extraction',
|
|
270
|
+
'memory.implicitModelOff': 'Off — no model call, costs nothing',
|
|
271
|
+
'memory.implicitModelCurrent': 'Use the model this conversation is already using',
|
|
272
|
+
'memory.implicitModelHint':
|
|
273
|
+
'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
274
|
'memory.clearCold': 'Clear cold archive',
|
|
264
275
|
'memory.coldCleared': 'Cold archive cleared',
|
|
265
276
|
},
|
|
@@ -359,6 +370,10 @@ window.__ModuleLoader__.load({
|
|
|
359
370
|
'settings.unlock': '解锁',
|
|
360
371
|
'settings.locked': '已锁定',
|
|
361
372
|
'settings.budget': '每个会话保留最近 {keep} 个自动快照',
|
|
373
|
+
'settings.budgetInherited': '+ {n} 个继承',
|
|
374
|
+
'settings.inherited': '继承',
|
|
375
|
+
'settings.budgetNote':
|
|
376
|
+
'只有这个会话自己的自动快照计入预算。从父会话继承来的快照(回滚或删除消息时产生)单独保留,所以列表可能比 {keep} 长。',
|
|
362
377
|
'settings.usage': '快照占用 {size}',
|
|
363
378
|
'settings.files': '{count} 个文件',
|
|
364
379
|
'settings.turn': '第 {turn} 轮',
|
|
@@ -452,7 +467,7 @@ window.__ModuleLoader__.load({
|
|
|
452
467
|
'panel.breakdownTitle': '记忆存在哪',
|
|
453
468
|
'panel.breakdownGlobal': '全局(所有工作区共享)',
|
|
454
469
|
'panel.breakdownHint': '每个工作区有独立的库;上面的总数 = 这些之和 + 全局。',
|
|
455
|
-
'panel.repoLink': 'GitHub 源码',
|
|
470
|
+
'panel.repoLink': 'GitHub 源码 - 鼓励一下',
|
|
456
471
|
'panel.noSettingsSeam': '这个 Host 版本没有暴露设置导航接口,无法打开对应页面。',
|
|
457
472
|
'archived.title': '归档会话',
|
|
458
473
|
'archived.description': '归档的会话从侧边栏隐藏。恢复后会重新出现,不会删除任何东西。',
|
|
@@ -466,6 +481,8 @@ window.__ModuleLoader__.load({
|
|
|
466
481
|
'memory.extractionTried': '试过 {n} 个记忆模型',
|
|
467
482
|
'memory.extractionQueued': '入队 {n} 条',
|
|
468
483
|
'memory.extractionRejected': '剔除 {n} 条',
|
|
484
|
+
'memory.extractionEvents': '收到 {n} 个会话事件',
|
|
485
|
+
'memory.extractionTurns': '结束 {n} 轮',
|
|
469
486
|
'memory.accept': '保留',
|
|
470
487
|
'memory.reject': '丢弃',
|
|
471
488
|
'memory.accepted': '已保留',
|
|
@@ -478,6 +495,11 @@ window.__ModuleLoader__.load({
|
|
|
478
495
|
'memory.maintainedCompressed': '已压缩',
|
|
479
496
|
'memory.maintainedRules': '条规则',
|
|
480
497
|
'memory.implicit': '自动识别并建议记忆',
|
|
498
|
+
'memory.implicitModel': '自动提取使用的模型',
|
|
499
|
+
'memory.implicitModelOff': '关闭 —— 不调用模型,零消耗',
|
|
500
|
+
'memory.implicitModelCurrent': '用当前对话正在使用的模型',
|
|
501
|
+
'memory.implicitModelHint':
|
|
502
|
+
'自动提取会在每轮结束时发一次小请求。在这里选定模型才代表你授权了这笔消耗;「关闭」和「用当前模型」是这笔取舍的两端。',
|
|
481
503
|
'memory.clearCold': '清空冷归档',
|
|
482
504
|
'memory.coldCleared': '冷归档已清空',
|
|
483
505
|
},
|
|
@@ -793,6 +815,16 @@ window.__ModuleLoader__.load({
|
|
|
793
815
|
.dss-curate-title { font-size: 13px; color: var(--dsw-alias-label-primary, #1f1f1f); }
|
|
794
816
|
.dss-curate-hint { font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary, #6b6b6b); }
|
|
795
817
|
.dss-curate-inline { margin-top: 0; padding: 6px 10px; border-color: transparent; background: transparent; }
|
|
818
|
+
/* The model picker sits below the toggle as a labelled control, because it is a
|
|
819
|
+
separate decision from "on/off": one says whether to extract, the other says who
|
|
820
|
+
pays for it. */
|
|
821
|
+
.dss-curate-field {
|
|
822
|
+
display: flex; flex-direction: column; gap: 6px; margin-top: 10px;
|
|
823
|
+
padding: 12px 14px; border-radius: 8px;
|
|
824
|
+
border: 1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.08));
|
|
825
|
+
background: var(--dsw-alias-bg-layer-1, #fff);
|
|
826
|
+
}
|
|
827
|
+
.dss-curate-label { font-size: 13px; font-weight: 600; color: var(--dsw-alias-label-primary, #1f1f1f); }
|
|
796
828
|
|
|
797
829
|
.dss-tabs { display: flex; gap: 4px; flex-wrap: wrap; }
|
|
798
830
|
.dss-tab {
|
|
@@ -949,6 +981,18 @@ window.__ModuleLoader__.load({
|
|
|
949
981
|
border: 1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.08));
|
|
950
982
|
border-radius: 10px; background: var(--dsw-alias-bg-layer-1, #fff);
|
|
951
983
|
}
|
|
984
|
+
/* A bounded list: the library scrolls, the page does not.
|
|
985
|
+
Without this the settings below sat below the fold behind fifty rows, so every
|
|
986
|
+
change meant scrolling past the whole library first. 480px is about eleven rows,
|
|
987
|
+
picked by looking at it rather than by arithmetic — it was raised from 360px after
|
|
988
|
+
the first version read as too short. Single number, retune freely. overscroll-behavior
|
|
989
|
+
contain stops a scroll that reaches the end of the list from continuing into the page,
|
|
990
|
+
which is what makes the box feel like a box rather than a speed bump. */
|
|
991
|
+
.dss-scroll-box {
|
|
992
|
+
max-height: 480px;
|
|
993
|
+
overflow-y: auto;
|
|
994
|
+
overscroll-behavior: contain;
|
|
995
|
+
}
|
|
952
996
|
.dss-row {
|
|
953
997
|
display: flex; align-items: center; gap: 10px; padding: 10px 12px;
|
|
954
998
|
border-bottom: 1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.06));
|
|
@@ -2252,6 +2296,8 @@ window.__ModuleLoader__.load({
|
|
|
2252
2296
|
const [error, setError] = useState(null)
|
|
2253
2297
|
const [busy, setBusy] = useState(null)
|
|
2254
2298
|
const [memoryDraft, setMemoryDraft] = useState('')
|
|
2299
|
+
// Kept for the memory.add API path that the long-term screen still uses; the
|
|
2300
|
+
// snapshot panel no longer renders a session-memory editor.
|
|
2255
2301
|
|
|
2256
2302
|
useEffect(() => {
|
|
2257
2303
|
let alive = true
|
|
@@ -2357,15 +2403,31 @@ window.__ModuleLoader__.load({
|
|
|
2357
2403
|
)
|
|
2358
2404
|
|
|
2359
2405
|
const stats = detail?.stats
|
|
2406
|
+
// Split the list so the budget makes sense on screen.
|
|
2407
|
+
//
|
|
2408
|
+
// The header said "keeps the newest N automatic snapshots" while the list showed
|
|
2409
|
+
// sixteen, and nothing explained the difference: ten were this session's own and
|
|
2410
|
+
// six were inherited from the parent session by a rewind. Counting inherited
|
|
2411
|
+
// entries against the budget would make a fork delete every snapshot it takes
|
|
2412
|
+
// itself, so they are kept separately — but the user has to be told that, or the
|
|
2413
|
+
// number looks like a bug.
|
|
2414
|
+
const inheritedCount = (detail?.checkpoints ?? []).filter(cp => cp.inheritedFrom).length
|
|
2360
2415
|
const statsLine = stats
|
|
2361
2416
|
? h(
|
|
2362
2417
|
'div',
|
|
2363
2418
|
{ className: 'dss-row-meta' },
|
|
2364
2419
|
h('span', null, t('settings.usage', { size: stats.label })),
|
|
2365
2420
|
h('span', null, t('settings.budget', { keep: stats.budget })),
|
|
2421
|
+
inheritedCount > 0
|
|
2422
|
+
? h('span', null, t('settings.budgetInherited', { n: inheritedCount }))
|
|
2423
|
+
: null,
|
|
2366
2424
|
detail.cwd ? h('span', { title: detail.cwd }, detail.cwd) : null,
|
|
2367
2425
|
)
|
|
2368
2426
|
: null
|
|
2427
|
+
const budgetNote =
|
|
2428
|
+
inheritedCount > 0
|
|
2429
|
+
? h('div', { className: 'dss-section-desc' }, t('settings.budgetNote', { keep: stats?.budget ?? 10 }))
|
|
2430
|
+
: null
|
|
2369
2431
|
|
|
2370
2432
|
const checkpoints = detail?.checkpoints ?? []
|
|
2371
2433
|
const checkpointList =
|
|
@@ -2387,6 +2449,10 @@ window.__ModuleLoader__.load({
|
|
|
2387
2449
|
h('span', null, cp.label || cp.id),
|
|
2388
2450
|
cp.locked ? h('span', { className: 'dss-badge dss-locked' }, t('settings.locked')) : null,
|
|
2389
2451
|
cp.temporary ? h('span', { className: 'dss-badge dss-temp' }, t('settings.temporary')) : null,
|
|
2452
|
+
// Inherited snapshots are a different thing from this session's
|
|
2453
|
+
// own: they came from the parent by a rewind, they do not count
|
|
2454
|
+
// against the budget, and deleting the child does not delete them.
|
|
2455
|
+
cp.inheritedFrom ? h('span', { className: 'dss-badge' }, t('settings.inherited')) : null,
|
|
2390
2456
|
),
|
|
2391
2457
|
h(
|
|
2392
2458
|
'div',
|
|
@@ -2489,63 +2555,15 @@ window.__ModuleLoader__.load({
|
|
|
2489
2555
|
),
|
|
2490
2556
|
)
|
|
2491
2557
|
|
|
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
|
-
)
|
|
2558
|
+
// The session-memory block was removed deliberately.
|
|
2559
|
+
//
|
|
2560
|
+
// It was a per-session list of loose facts, never written to, sitting next to a
|
|
2561
|
+
// real trajectory view that the Host already provides. Two surfaces for "what
|
|
2562
|
+
// happened in this session" — one authoritative and read-only, one a stub that
|
|
2563
|
+
// nobody fed — invites the reader to trust the stub. The trajectory is the
|
|
2564
|
+
// record of what happened and is not meant to be edited; a hand-written note
|
|
2565
|
+
// beside it is a weaker duplicate. Removed rather than hidden so the panel does
|
|
2566
|
+
// not advertise a capability it does not have.
|
|
2549
2567
|
|
|
2550
2568
|
return h(
|
|
2551
2569
|
'div',
|
|
@@ -2553,9 +2571,9 @@ window.__ModuleLoader__.load({
|
|
|
2553
2571
|
head,
|
|
2554
2572
|
picker,
|
|
2555
2573
|
statsLine,
|
|
2574
|
+
budgetNote,
|
|
2556
2575
|
checkpointList,
|
|
2557
2576
|
undoList,
|
|
2558
|
-
memoryBlock,
|
|
2559
2577
|
)
|
|
2560
2578
|
}
|
|
2561
2579
|
|
|
@@ -2964,7 +2982,39 @@ window.__ModuleLoader__.load({
|
|
|
2964
2982
|
const [tagFilter, setTagFilter] = useState('')
|
|
2965
2983
|
const [sinceFilter, setSinceFilter] = useState('')
|
|
2966
2984
|
|
|
2985
|
+
// Which models can be called, loaded from the Host rather than from config.
|
|
2986
|
+
//
|
|
2987
|
+
// The list used to come from `settings.memoryModels`, which is a patch value —
|
|
2988
|
+
// and this profile has no patch entry, so the picker offered only "off" and
|
|
2989
|
+
// "use current" while the user had several working providers in
|
|
2990
|
+
// Settings → 模型. The Host knows what it can actually call; ask it.
|
|
2991
|
+
const [models, setModels] = useState(null)
|
|
2992
|
+
useEffect(() => {
|
|
2993
|
+
let alive = true
|
|
2994
|
+
call({ action: 'longterm.models' })
|
|
2995
|
+
.then(list => alive && setModels(Array.isArray(list) ? list : []))
|
|
2996
|
+
.catch(() => alive && setModels([]))
|
|
2997
|
+
return () => {
|
|
2998
|
+
alive = false
|
|
2999
|
+
}
|
|
3000
|
+
}, [rev])
|
|
3001
|
+
|
|
2967
3002
|
const detected = useWorkspacePath()
|
|
3003
|
+
|
|
3004
|
+
// Turn a stored workspace key into a readable name.
|
|
3005
|
+
//
|
|
3006
|
+
// The badge used to say 「本工作区」 for every workspace-scoped row, so a list
|
|
3007
|
+
// filtered to 全部 showed a dozen rows all claiming to be "this workspace" —
|
|
3008
|
+
// including rows from other workspaces. The key is a one-way hash, so the name
|
|
3009
|
+
// has to come from the registry, which is exactly what `longterm.workspaces`
|
|
3010
|
+
// returns.
|
|
3011
|
+
const workspaceLabel = key => {
|
|
3012
|
+
if (!key) return t('memory.workspace')
|
|
3013
|
+
const match = known.find(item => item.key === key)
|
|
3014
|
+
if (!match) return t('memory.workspace')
|
|
3015
|
+
const cwd = String(match.cwd ?? '')
|
|
3016
|
+
return cwd.split(/[\\/]/).filter(Boolean).pop() ?? cwd ?? t('memory.workspace')
|
|
3017
|
+
}
|
|
2968
3018
|
const cwd = picked !== undefined ? picked : detected
|
|
2969
3019
|
|
|
2970
3020
|
useEffect(() => {
|
|
@@ -3146,7 +3196,7 @@ window.__ModuleLoader__.load({
|
|
|
3146
3196
|
'div',
|
|
3147
3197
|
{ className: 'dss-row-main' },
|
|
3148
3198
|
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') :
|
|
3199
|
+
h('div', { className: 'dss-row-meta' }, h('span', { className: 'dss-badge' }, item.scope === 'global' ? t('memory.global') : workspaceLabel(item.workspaceKey))),
|
|
3150
3200
|
),
|
|
3151
3201
|
h(
|
|
3152
3202
|
'div',
|
|
@@ -3207,14 +3257,33 @@ window.__ModuleLoader__.load({
|
|
|
3207
3257
|
: null,
|
|
3208
3258
|
)
|
|
3209
3259
|
: null,
|
|
3260
|
+
// Raw counts of what the Host actually delivered, so "nothing waiting"
|
|
3261
|
+
// can be told apart from "the hook never fired". Without this the two
|
|
3262
|
+
// states look identical on screen.
|
|
3263
|
+
extraction
|
|
3264
|
+
? h(
|
|
3265
|
+
'div',
|
|
3266
|
+
{ className: 'dss-row-meta' },
|
|
3267
|
+
h('span', null, t('memory.extractionEvents', { n: extraction.eventsSeen ?? 0 })),
|
|
3268
|
+
h('span', null, t('memory.extractionTurns', { n: extraction.turnEndsSeen ?? 0 })),
|
|
3269
|
+
)
|
|
3270
|
+
: null,
|
|
3210
3271
|
)
|
|
3211
3272
|
|
|
3273
|
+
// The list gets its own scroll box.
|
|
3274
|
+
//
|
|
3275
|
+
// Without one, the whole page scrolled: fifty memories pushed the maintenance
|
|
3276
|
+
// and model controls far below the fold, so changing a setting meant scrolling
|
|
3277
|
+
// past the entire library first. A bounded box keeps the controls in place and
|
|
3278
|
+
// makes the list the only thing that moves. The height is a deliberate starting
|
|
3279
|
+
// point — roughly eight rows — chosen to leave the settings visible on a
|
|
3280
|
+
// laptop screen; it is a single number, easy to retune.
|
|
3212
3281
|
const listBlock =
|
|
3213
3282
|
visible.length === 0
|
|
3214
3283
|
? h('div', { className: 'dss-empty' }, t('memory.empty'))
|
|
3215
3284
|
: h(
|
|
3216
3285
|
'div',
|
|
3217
|
-
{ className: 'dss-card' },
|
|
3286
|
+
{ className: 'dss-card dss-scroll-box' },
|
|
3218
3287
|
...visible.map(entry =>
|
|
3219
3288
|
h(
|
|
3220
3289
|
'div',
|
|
@@ -3226,7 +3295,7 @@ window.__ModuleLoader__.load({
|
|
|
3226
3295
|
h(
|
|
3227
3296
|
'div',
|
|
3228
3297
|
{ className: 'dss-row-meta' },
|
|
3229
|
-
h('span', { className: 'dss-badge' + (entry.scope === 'global' ? ' dss-locked' : '') }, entry.scope === 'global' ? t('memory.global') :
|
|
3298
|
+
h('span', { className: 'dss-badge' + (entry.scope === 'global' ? ' dss-locked' : '') }, entry.scope === 'global' ? t('memory.global') : workspaceLabel(entry.workspaceKey)),
|
|
3230
3299
|
entry.kind === 'rule' ? h('span', { className: 'dss-badge dss-temp' }, t('memory.rule')) : null,
|
|
3231
3300
|
// An episode is a dated event, not a truth. Labelling it
|
|
3232
3301
|
// separately is what lets the user tell "this happened once"
|
|
@@ -3331,6 +3400,44 @@ window.__ModuleLoader__.load({
|
|
|
3331
3400
|
}),
|
|
3332
3401
|
h('span', { className: 'dss-curate-title' }, t('memory.implicit')),
|
|
3333
3402
|
),
|
|
3403
|
+
// Which model does the background extraction call?
|
|
3404
|
+
//
|
|
3405
|
+
// This has to be an explicit choice. An earlier build silently fell back to
|
|
3406
|
+
// the Host's default model whenever no chain was configured, which spends the
|
|
3407
|
+
// user's tokens once per turn on a model they never picked for this job. A
|
|
3408
|
+
// background cost has to be visible and opt-in, so the choice is a control
|
|
3409
|
+
// with an explicit "off" that costs nothing.
|
|
3410
|
+
h(
|
|
3411
|
+
'div',
|
|
3412
|
+
{ className: 'dss-curate-field' },
|
|
3413
|
+
h('label', { className: 'dss-curate-label' }, t('memory.implicitModel')),
|
|
3414
|
+
h(
|
|
3415
|
+
'select',
|
|
3416
|
+
{
|
|
3417
|
+
className: 'dss-select',
|
|
3418
|
+
value: settings?.implicitModel ?? 'off',
|
|
3419
|
+
disabled: busy === 'implicitModel',
|
|
3420
|
+
onChange: event =>
|
|
3421
|
+
run('implicitModel', () =>
|
|
3422
|
+
call({ action: 'longterm.settings', patch: { implicitModel: event.target.value } }),
|
|
3423
|
+
),
|
|
3424
|
+
},
|
|
3425
|
+
h('option', { value: 'off' }, t('memory.implicitModelOff')),
|
|
3426
|
+
h('option', { value: 'current' }, t('memory.implicitModelCurrent')),
|
|
3427
|
+
// Real models from the Host, grouped by provider so a long list stays
|
|
3428
|
+
// readable. `models` is null while loading and [] when the Host reports
|
|
3429
|
+
// none — both render no group, which is honest: the two above are the
|
|
3430
|
+
// only choices that exist without a callable model.
|
|
3431
|
+
...(Array.isArray(models) ? models : []).map(entry =>
|
|
3432
|
+
h(
|
|
3433
|
+
'option',
|
|
3434
|
+
{ value: `${entry.provider}/${entry.model}`, key: `${entry.provider}/${entry.model}` },
|
|
3435
|
+
entry.label ?? `${entry.provider}/${entry.model}`,
|
|
3436
|
+
),
|
|
3437
|
+
),
|
|
3438
|
+
),
|
|
3439
|
+
h('div', { className: 'dss-curate-hint' }, t('memory.implicitModelHint')),
|
|
3440
|
+
),
|
|
3334
3441
|
h(
|
|
3335
3442
|
'button',
|
|
3336
3443
|
{
|
package/lib/api.js
CHANGED
|
@@ -307,6 +307,16 @@ 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() })
|
|
310
320
|
case 'longterm.settings':
|
|
311
321
|
return json(
|
|
312
322
|
input.patch
|
package/lib/memory-host.js
CHANGED
|
@@ -205,9 +205,26 @@ 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
|
+
// "nothing waiting" with no counts is indistinguishable from "the hook is
|
|
215
|
+
// never called at all", which is exactly the state that wasted several
|
|
216
|
+
// rounds of guessing.
|
|
217
|
+
extractionTrace.eventsSeen = (extractionTrace.eventsSeen ?? 0) + 1
|
|
208
218
|
if (!event || event.type !== 'turn/end') return
|
|
219
|
+
extractionTrace.turnEndsSeen = (extractionTrace.turnEndsSeen ?? 0) + 1
|
|
220
|
+
// A branded id is still a string at runtime; accept any non-empty one and
|
|
221
|
+
// report the actual shape when it is not.
|
|
209
222
|
const sessionId = session?.id
|
|
210
|
-
if (typeof sessionId !== 'string')
|
|
223
|
+
if (typeof sessionId !== 'string' || !sessionId) {
|
|
224
|
+
extractionTrace.reason = `a turn ended but the session id was ${typeof sessionId}`
|
|
225
|
+
publishExtractionTrace(engine)
|
|
226
|
+
return
|
|
227
|
+
}
|
|
211
228
|
const cwd = resolveCwd(ctx, session)
|
|
212
229
|
void extractAfterTurn({ ctx, engine, sessionId, cwd, log, config })
|
|
213
230
|
// The trace is published in a finally so a thrown error still leaves a
|
|
@@ -553,8 +570,14 @@ async function extractAfterTurn({ ctx, engine, sessionId, cwd, log, config }) {
|
|
|
553
570
|
// The memory model is a fallback chain: a free provider that is rate limited
|
|
554
571
|
// (429) or unreachable must not silently disable extraction for the session,
|
|
555
572
|
// so every candidate is tried in order until one answers.
|
|
556
|
-
const candidates = memoryCandidates(config)
|
|
557
|
-
if (candidates.length === 0)
|
|
573
|
+
const candidates = memoryCandidates(config, ctx)
|
|
574
|
+
if (candidates.length === 0) {
|
|
575
|
+
return failExtraction(
|
|
576
|
+
config?.implicitModel === 'current'
|
|
577
|
+
? 'the Host exposes no default model to fall back to'
|
|
578
|
+
: 'implicit extraction has no model configured — choose one in Settings → 长期记忆 (隐式提取)',
|
|
579
|
+
)
|
|
580
|
+
}
|
|
558
581
|
extractionTrace.candidates = candidates.map(candidate => `${candidate.provider}/${candidate.model}`)
|
|
559
582
|
|
|
560
583
|
// The prompt asks for two different shapes, because the library stores two
|
|
@@ -709,7 +732,7 @@ function numberOr(value, fallback) {
|
|
|
709
732
|
* @param {any} config plugin config
|
|
710
733
|
* @returns {{ provider: string, model: string }[]} candidates in priority order
|
|
711
734
|
*/
|
|
712
|
-
function memoryCandidates(config) {
|
|
735
|
+
function memoryCandidates(config, ctx) {
|
|
713
736
|
const raw = []
|
|
714
737
|
const push = value => {
|
|
715
738
|
if (Array.isArray(value)) raw.push(...value)
|
|
@@ -717,6 +740,37 @@ function memoryCandidates(config) {
|
|
|
717
740
|
}
|
|
718
741
|
push(config?.memoryModels)
|
|
719
742
|
push(config?.memoryModel)
|
|
743
|
+
// A single explicit pick, stored by the settings UI as "provider/model".
|
|
744
|
+
//
|
|
745
|
+
// This is the "one model, chosen here" case: the user picked a specific entry from
|
|
746
|
+
// the offered chain, and that pick must beat the chain (which is a fallback list,
|
|
747
|
+
// not a preference). Parsed on the last slash so a model id that itself contains a
|
|
748
|
+
// slash — "Qwen/Qwen2.5-7B-Instruct" — survives intact.
|
|
749
|
+
const picked = typeof config?.implicitModel === 'string' ? config.implicitModel : ''
|
|
750
|
+
if (picked && picked !== 'current' && picked !== 'off') {
|
|
751
|
+
const at = picked.indexOf('/')
|
|
752
|
+
if (at > 0 && at < picked.length - 1) {
|
|
753
|
+
raw.unshift({ provider: picked.slice(0, at), model: picked.slice(at + 1) })
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
// Falling back to the Host's own default model is OFF unless the user asks for it.
|
|
757
|
+
//
|
|
758
|
+
// An earlier version did this unconditionally, to fix "extraction never runs when
|
|
759
|
+
// no chain is configured". That traded one bug for a worse one: it spends the
|
|
760
|
+
// user's tokens on a model they never chose for this purpose, once per turn,
|
|
761
|
+
// silently. Background spending has to be opted into — so the fallback requires
|
|
762
|
+
// `implicitModel: 'current'` and otherwise returns nothing, leaving extraction off
|
|
763
|
+
// with a reason the user can read.
|
|
764
|
+
if (raw.length === 0 && ctx && picked === 'current') {
|
|
765
|
+
try {
|
|
766
|
+
const selection = ctx.get('agentDefaultModel')?.currentSelection?.()
|
|
767
|
+
if (selection?.provider && selection?.model) {
|
|
768
|
+
raw.push({ provider: selection.provider, model: selection.model })
|
|
769
|
+
}
|
|
770
|
+
} catch {
|
|
771
|
+
/* a Host without the service just means no fallback */
|
|
772
|
+
}
|
|
773
|
+
}
|
|
720
774
|
const out = []
|
|
721
775
|
const seen = new Set()
|
|
722
776
|
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,53 @@ export class SessionSuiteService {
|
|
|
959
962
|
return { sessionId, restored: true }
|
|
960
963
|
}
|
|
961
964
|
|
|
965
|
+
/**
|
|
966
|
+
* Every model this Host can actually call, for the extraction picker.
|
|
967
|
+
*
|
|
968
|
+
* Read from the `llm` service, not from configuration. The picker used to render
|
|
969
|
+
* `config.memoryModels` — a list supplied by the bundle patch — so a profile with no
|
|
970
|
+
* patch entry for this bundle offered only "off" and "use current" and no real
|
|
971
|
+
* models, even though the user had several providers configured and working in
|
|
972
|
+
* Settings → 模型. What can be called is the llm service's answer to give; a config
|
|
973
|
+
* file only says what someone once wrote down.
|
|
974
|
+
*
|
|
975
|
+
* @returns {Promise<Array<{ provider: string, model: string, label: string }>>} models
|
|
976
|
+
*/
|
|
977
|
+
async longTermModels() {
|
|
978
|
+
const llm = this.ctx.get?.('llm')
|
|
979
|
+
if (!llm) return []
|
|
980
|
+
const out = []
|
|
981
|
+
let providers = []
|
|
982
|
+
try {
|
|
983
|
+
providers = await llm.listProviders?.() ?? []
|
|
984
|
+
} catch (error) {
|
|
985
|
+
this.log('could not list llm providers', error)
|
|
986
|
+
return []
|
|
987
|
+
}
|
|
988
|
+
for (const provider of providers) {
|
|
989
|
+
const id = provider?.id
|
|
990
|
+
if (typeof id !== 'string' || !id) continue
|
|
991
|
+
let models = []
|
|
992
|
+
try {
|
|
993
|
+
models = await llm.listModels?.(id) ?? []
|
|
994
|
+
} catch (error) {
|
|
995
|
+
// One provider failing to advertise must not empty the whole picker.
|
|
996
|
+
this.log(`could not list models for ${id}`, error)
|
|
997
|
+
continue
|
|
998
|
+
}
|
|
999
|
+
for (const model of models) {
|
|
1000
|
+
const modelId = model?.id
|
|
1001
|
+
if (typeof modelId !== 'string' || !modelId) continue
|
|
1002
|
+
out.push({
|
|
1003
|
+
provider: id,
|
|
1004
|
+
model: modelId,
|
|
1005
|
+
label: `${provider?.name ?? id} · ${model?.name ?? modelId}`,
|
|
1006
|
+
})
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
return out
|
|
1010
|
+
}
|
|
1011
|
+
|
|
962
1012
|
/**
|
|
963
1013
|
* The set of archived session ids, read from the registry that owns them.
|
|
964
1014
|
*
|
|
@@ -1082,6 +1132,13 @@ function decorateCheckpoint(checkpoint) {
|
|
|
1082
1132
|
temporary: Boolean(checkpoint.expiresAt),
|
|
1083
1133
|
expiresAt: checkpoint.expiresAt ?? null,
|
|
1084
1134
|
skipped: checkpoint.skipped ?? 0,
|
|
1135
|
+
// Inherited snapshots came from the parent session by a rewind, and they are
|
|
1136
|
+
// EXEMPT from this session's keep-budget. The UI has to know that: without the
|
|
1137
|
+
// flag it cannot explain why a session with a budget of 10 shows sixteen rows,
|
|
1138
|
+
// which reads as a prune bug rather than as the intended behaviour. This field
|
|
1139
|
+
// was silently dropped by this whitelist — every field not listed here never
|
|
1140
|
+
// reaches the browser.
|
|
1141
|
+
inheritedFrom: checkpoint.inheritedFrom ?? null,
|
|
1085
1142
|
}
|
|
1086
1143
|
}
|
|
1087
1144
|
|
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.2",
|
|
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",
|