@heixui/dsh-session-suite 0.4.0 → 0.4.1
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.md +17 -0
- package/README.zh.md +15 -0
- package/client.js +609 -53
- package/lib/api.js +5 -0
- package/lib/memory-engine.js +33 -4
- package/lib/service.js +99 -20
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -349,6 +349,23 @@ memory, or export paths.
|
|
|
349
349
|
two-layer agent-memory design, with measured numbers rather than claims — including
|
|
350
350
|
the parts that are deliberately not built and why.
|
|
351
351
|
|
|
352
|
+
## What changed in 0.4.1
|
|
353
|
+
|
|
354
|
+
A sidebar entry, plus three fixes for things that looked functional but were not.
|
|
355
|
+
|
|
356
|
+
| Area | Before | Now |
|
|
357
|
+
|---|---|---|
|
|
358
|
+
| Where to find it | Settings → Snapshots / Long-term memory, three steps away | A **sidebar entry** opens everything in place; the three duplicate Settings sections are removed |
|
|
359
|
+
| Memory count | Panel said 17, Settings said 51 — one question, two answers | The panel walks **every registered workspace**; each figure states what it counts |
|
|
360
|
+
| Archive restore | Clicked, nothing happened, the mark on disk never changed | Fixed an argument-shape mismatch between the two Host services; restore now takes effect |
|
|
361
|
+
| Long lists | The panel clipped its content; the wheel did nothing | The panel scrolls |
|
|
362
|
+
|
|
363
|
+
**Why the counts disagreed (the real 0.4.0 defect):** long-term memory has a *global*
|
|
364
|
+
store plus one store per workspace. `longterm.list` without a `cwd` returns only the
|
|
365
|
+
global store (17 rows); with a `cwd` it returns global plus that workspace. The panel
|
|
366
|
+
has no workspace of its own, so it showed the 17. It now enumerates every registered
|
|
367
|
+
workspace instead.
|
|
368
|
+
|
|
352
369
|
## What changed in 0.4.0
|
|
353
370
|
|
|
354
371
|
The memory engine was reorganised; the session features are unchanged.
|
package/README.zh.md
CHANGED
|
@@ -173,6 +173,21 @@ docs/ 需求与 API 笔记
|
|
|
173
173
|
**embedding 是可选增强,默认不下载任何东西。** 不装模型就是纯词法检索,功能完整;装了才有跨语言与改写召回。
|
|
174
174
|
实测效果与安装方式见 `docs/memory-architecture.md` 和 `tools/download-embedding.mjs`。
|
|
175
175
|
|
|
176
|
+
## 0.4.1 改了什么
|
|
177
|
+
|
|
178
|
+
新增侧边栏入口,并修掉三个「看起来能用、实际没生效」的问题。
|
|
179
|
+
|
|
180
|
+
| 方面 | 改前 | 改后 |
|
|
181
|
+
|---|---|---|
|
|
182
|
+
| 入口位置 | 只能在 设置 → 会话快照 / 长期记忆 里找,三步才能到 | **左侧栏「会话套件」图标**,点开即用;设置页那三个重复入口已移除 |
|
|
183
|
+
| 记忆总数 | 面板显示 17,设置页显示 51 —— 同一句话两个数 | 面板遍历**所有已登记工作区**,与设置页一致;每个数字都写明统计口径 |
|
|
184
|
+
| 归档恢复 | 点了没反应,磁盘上的标记纹丝不动 | 修了参数签名(两个 Host 服务的入参形状不同),恢复真正生效 |
|
|
185
|
+
| 长列表滚动 | 面板内容被裁掉,滚轮无效 | 面板可滚动 |
|
|
186
|
+
|
|
187
|
+
**为什么数字会不一样(0.4.0 的真实缺陷)**:长期记忆分「全局库」和「各工作区的库」。
|
|
188
|
+
`longterm.list` 不带 `cwd` 时只返回全局库(17 条),带上 `cwd` 才是全局 + 该工作区。
|
|
189
|
+
面板没有工作区概念,于是只显示了全局那 17 条。现在面板不带 `cwd` 时会遍历所有已登记工作区。
|
|
190
|
+
|
|
176
191
|
## 测试
|
|
177
192
|
|
|
178
193
|
全部自测脚本都在临时目录里跑,不会碰真实的 DSH 数据。
|
package/client.js
CHANGED
|
@@ -128,8 +128,7 @@ window.__ModuleLoader__.load({
|
|
|
128
128
|
'dialog.deleteSubagents': 'This will also delete {n} subagent session(s) started by it.',
|
|
129
129
|
'dialog.deleteWouldRefuse': 'Something here is still running. Stop it first — nothing has been deleted.',
|
|
130
130
|
'dialog.sessionRunning': 'This session is running. Stop it before deleting.',
|
|
131
|
-
'settings.title': 'Session snapshots',
|
|
132
|
-
'settings.description': 'Workspace checkpoints captured for a session, with disk usage, locking, and restore controls.',
|
|
131
|
+
'settings.title': 'Session snapshots', 'settings.description': 'Workspace checkpoints captured for a session, with disk usage, locking, and restore controls.',
|
|
133
132
|
'settings.empty': 'No snapshots yet. A checkpoint is captured at the start of each turn.',
|
|
134
133
|
'settings.capture': 'Capture now',
|
|
135
134
|
'settings.capturing': 'Capturing…',
|
|
@@ -212,7 +211,40 @@ window.__ModuleLoader__.load({
|
|
|
212
211
|
'memory.pendingTitle': 'Suggested memories',
|
|
213
212
|
'memory.pendingHint': 'Extracted from the last turns. Nothing is stored until you accept it.',
|
|
214
213
|
'memory.extractionIdle': 'Nothing is waiting. Last check: {reason}',
|
|
214
|
+
'memory.extractionNotYet':
|
|
215
|
+
'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.',
|
|
215
216
|
'memory.extractionUnknown': 'no attempt recorded since this Host started',
|
|
217
|
+
'panel.title': 'Session suite',
|
|
218
|
+
'panel.description': 'Snapshots, long-term memory, and archived sessions. Each row opens its own page.',
|
|
219
|
+
'panel.snapshots': 'Session snapshots',
|
|
220
|
+
'panel.snapshotsHint': 'Workspace checkpoints taken when each turn finishes',
|
|
221
|
+
'panel.memory': 'Long-term memory',
|
|
222
|
+
'panel.memoryHint': 'Facts, rules, preferences, and past events kept across sessions',
|
|
223
|
+
'panel.archived': 'Archived sessions',
|
|
224
|
+
'panel.archivedHint': 'Sessions hidden from the sidebar; restore them here',
|
|
225
|
+
'panel.sessionsCount': '{n} session(s) with snapshots',
|
|
226
|
+
'panel.statSessions': 'Sessions',
|
|
227
|
+
'panel.statSnapshots': 'Snapshots',
|
|
228
|
+
'panel.statMemories': 'Memories',
|
|
229
|
+
'panel.statArchived': 'Archived',
|
|
230
|
+
'panel.statSessionsHint': 'Every workspace, subagents excluded',
|
|
231
|
+
'panel.statSnapshotsHint': 'Automatic checkpoints across all sessions',
|
|
232
|
+
'panel.statMemoriesHint': 'Global plus every workspace, archived included',
|
|
233
|
+
'panel.statArchivedHint': 'Hidden from the sidebar, restorable',
|
|
234
|
+
'panel.breakdownTitle': 'Where the memories live',
|
|
235
|
+
'panel.breakdownGlobal': 'Global (shared by every workspace)',
|
|
236
|
+
'panel.breakdownHint': 'Each workspace keeps its own store; the total above is the sum of these plus the global one.',
|
|
237
|
+
'panel.repoLink': 'Source on GitHub',
|
|
238
|
+
'panel.noSettingsSeam': 'This Host build does not expose the settings navigation seam, so this row cannot open its page.',
|
|
239
|
+
'archived.title': 'Archived sessions',
|
|
240
|
+
'archived.description': 'Archived sessions are hidden from the sidebar. Restoring one puts it back; nothing is deleted.',
|
|
241
|
+
'archived.empty': 'No archived sessions.',
|
|
242
|
+
'archived.restore': 'Restore',
|
|
243
|
+
'archived.restored': 'Restored',
|
|
244
|
+
'archived.restoreFailed': 'Could not restore this session',
|
|
245
|
+
'settings.archived': 'Archived',
|
|
246
|
+
'settings.checkpointCount': '{n} snapshot(s)',
|
|
247
|
+
'settings.working': 'Working…',
|
|
216
248
|
'memory.extractionTried': 'tried {n} memory model(s)',
|
|
217
249
|
'memory.extractionQueued': '{n} queued',
|
|
218
250
|
'memory.extractionRejected': '{n} rejected as unusable',
|
|
@@ -397,7 +429,40 @@ window.__ModuleLoader__.load({
|
|
|
397
429
|
'memory.pendingTitle': '待确认的记忆',
|
|
398
430
|
'memory.pendingHint': '从最近几轮里识别出来的,确认前不会入库。',
|
|
399
431
|
'memory.extractionIdle': '当前没有待确认条目。上次检查结果:{reason}',
|
|
432
|
+
'memory.extractionNotYet':
|
|
433
|
+
'暂时还没有待确认条目。提取在每轮对话结束时运行,而本次启动后还没完成过任何一轮——聊一两句再回来看。',
|
|
400
434
|
'memory.extractionUnknown': '本次运行还没有执行过提取',
|
|
435
|
+
'panel.title': '会话套件',
|
|
436
|
+
'panel.description': '会话快照、长期记忆、归档会话。每行点开就是对应的页面。',
|
|
437
|
+
'panel.snapshots': '会话快照',
|
|
438
|
+
'panel.snapshotsHint': '每轮结束时自动记录的工作区状态',
|
|
439
|
+
'panel.memory': '长期记忆',
|
|
440
|
+
'panel.memoryHint': '跨会话保留的事实、规则、偏好与经历',
|
|
441
|
+
'panel.archived': '归档会话',
|
|
442
|
+
'panel.archivedHint': '已从侧边栏隐藏的会话,可以在这里恢复',
|
|
443
|
+
'panel.sessionsCount': '{n} 个会话有快照',
|
|
444
|
+
'panel.statSessions': '会话',
|
|
445
|
+
'panel.statSnapshots': '快照',
|
|
446
|
+
'panel.statMemories': '记忆',
|
|
447
|
+
'panel.statArchived': '已归档',
|
|
448
|
+
'panel.statSessionsHint': '所有工作区,不含子代理会话',
|
|
449
|
+
'panel.statSnapshotsHint': '所有会话的自动快照总数',
|
|
450
|
+
'panel.statMemoriesHint': '全局 + 各工作区,含已归档',
|
|
451
|
+
'panel.statArchivedHint': '从侧边栏隐藏,可恢复',
|
|
452
|
+
'panel.breakdownTitle': '记忆存在哪',
|
|
453
|
+
'panel.breakdownGlobal': '全局(所有工作区共享)',
|
|
454
|
+
'panel.breakdownHint': '每个工作区有独立的库;上面的总数 = 这些之和 + 全局。',
|
|
455
|
+
'panel.repoLink': 'GitHub 源码',
|
|
456
|
+
'panel.noSettingsSeam': '这个 Host 版本没有暴露设置导航接口,无法打开对应页面。',
|
|
457
|
+
'archived.title': '归档会话',
|
|
458
|
+
'archived.description': '归档的会话从侧边栏隐藏。恢复后会重新出现,不会删除任何东西。',
|
|
459
|
+
'archived.empty': '没有归档的会话。',
|
|
460
|
+
'archived.restore': '恢复',
|
|
461
|
+
'archived.restored': '已恢复',
|
|
462
|
+
'archived.restoreFailed': '恢复失败',
|
|
463
|
+
'settings.archived': '已归档',
|
|
464
|
+
'settings.checkpointCount': '{n} 张快照',
|
|
465
|
+
'settings.working': '处理中…',
|
|
401
466
|
'memory.extractionTried': '试过 {n} 个记忆模型',
|
|
402
467
|
'memory.extractionQueued': '入队 {n} 条',
|
|
403
468
|
'memory.extractionRejected': '剔除 {n} 条',
|
|
@@ -568,6 +633,9 @@ window.__ModuleLoader__.load({
|
|
|
568
633
|
h('path', { key: 'd', d: 'M10.2 10.2 4.8 2.6' }),
|
|
569
634
|
]),
|
|
570
635
|
lock: () => icon([h('rect', { key: 'a', x: 3.4, y: 7, width: 9.2, height: 6.4, rx: 1.4 }), h('path', { key: 'b', d: 'M5.6 7V5.4a2.4 2.4 0 0 1 4.8 0V7' })]),
|
|
636
|
+
// A right-pointing chevron: these rows navigate somewhere, so the row's
|
|
637
|
+
// affordance is "go", not "expand".
|
|
638
|
+
chevron: () => icon([h('path', { key: 'a', d: 'M6.2 3.6 10.6 8l-4.4 4.4' })]),
|
|
571
639
|
unlock: () => icon([h('rect', { key: 'a', x: 3.4, y: 7, width: 9.2, height: 6.4, rx: 1.4 }), h('path', { key: 'b', d: 'M5.6 7V5.4a2.4 2.4 0 0 1 4.6-.8' })]),
|
|
572
640
|
restore: () => icon([h('path', { key: 'a', d: 'M3 8a5 5 0 1 1 1.6 3.7' }), h('path', { key: 'b', d: 'M2.6 2.8v3h3' })]),
|
|
573
641
|
close: () => icon([h('path', { key: 'a', d: 'M4 4l8 8' }), h('path', { key: 'b', d: 'M12 4l-8 8' })]),
|
|
@@ -760,6 +828,118 @@ window.__ModuleLoader__.load({
|
|
|
760
828
|
.dss-toast.dss-error { background: var(--dsw-alias-state-error-primary, #d93025); }
|
|
761
829
|
|
|
762
830
|
.dss-section { display: flex; flex-direction: column; gap: 12px; max-width: 720px; }
|
|
831
|
+
/* A screen opened from the sidebar panel inherits the panel's scroll container, so it
|
|
832
|
+
must not impose its own height. The settings dialog still renders these sections
|
|
833
|
+
directly and relies on the dialog's own scrolling. */
|
|
834
|
+
/* The sidebar panel is a main-column surface. It must scroll: the memory list is
|
|
835
|
+
dozens of rows, and without an explicit overflow the page clipped at the viewport
|
|
836
|
+
with no way to reach the rest — reported as "no scroll wheel, there are already
|
|
837
|
+
a dozen entries". The min-height 0 is required for the child flex column to shrink
|
|
838
|
+
below its content size, otherwise overflow never triggers. */
|
|
839
|
+
.dss-panel {
|
|
840
|
+
display: flex; flex-direction: column; gap: 14px;
|
|
841
|
+
padding: 24px 28px; max-width: 760px; margin: 0 auto;
|
|
842
|
+
height: 100%; min-height: 0;
|
|
843
|
+
overflow-y: auto; overscroll-behavior: contain;
|
|
844
|
+
}
|
|
845
|
+
.dss-panel h2 { margin: 0; font-size: 17px; font-weight: 600; color: var(--dsw-alias-label-primary, #1f1f1f); }
|
|
846
|
+
.dss-nav { display: flex; flex-direction: column; gap: 10px; }
|
|
847
|
+
/* Rows follow the shipped card idiom: the same border and background tokens
|
|
848
|
+
dsh-connect-trae uses (--dsw-alias-border-l2 / --dsw-alias-bg-l2), so the panel
|
|
849
|
+
looks like part of the app rather than a bolt-on. A left accent bar appears on
|
|
850
|
+
hover and focus instead of a colour swap, which keeps the text readable in both
|
|
851
|
+
themes and gives keyboard users the same affordance as mouse users. */
|
|
852
|
+
.dss-nav-row {
|
|
853
|
+
position: relative;
|
|
854
|
+
display: flex; align-items: center; gap: 14px; width: 100%;
|
|
855
|
+
padding: 14px 16px; text-align: left; cursor: pointer;
|
|
856
|
+
font: inherit; color: inherit;
|
|
857
|
+
background: var(--dsw-alias-bg-l2, #fff);
|
|
858
|
+
border: 1px solid var(--dsw-alias-border-l2, #e3e4e6);
|
|
859
|
+
border-radius: 12px;
|
|
860
|
+
transition: border-color .14s ease, background .14s ease, transform .14s ease;
|
|
861
|
+
}
|
|
862
|
+
.dss-nav-row::before {
|
|
863
|
+
content: ''; position: absolute; left: 0; top: 12px; bottom: 12px; width: 3px;
|
|
864
|
+
border-radius: 0 3px 3px 0;
|
|
865
|
+
background: var(--dsw-alias-brand, #4d6bfe);
|
|
866
|
+
opacity: 0; transition: opacity .14s ease;
|
|
867
|
+
}
|
|
868
|
+
.dss-nav-row:hover, .dss-nav-row:focus-visible {
|
|
869
|
+
border-color: var(--dsw-alias-brand, #4d6bfe);
|
|
870
|
+
background: var(--dsw-alias-bg-l3, #f7f8fa);
|
|
871
|
+
outline: none;
|
|
872
|
+
}
|
|
873
|
+
.dss-nav-row:hover::before, .dss-nav-row:focus-visible::before { opacity: 1; }
|
|
874
|
+
.dss-nav-row:active { transform: translateY(1px); }
|
|
875
|
+
.dss-nav-row > svg { flex: none; color: var(--dsw-alias-label-tertiary, #9a9a9a); }
|
|
876
|
+
.dss-nav-main { display: flex; flex-direction: column; gap: 3px; flex: 1; min-width: 0; }
|
|
877
|
+
.dss-nav-title { font-size: 13.5px; font-weight: 600; color: var(--dsw-alias-label-primary, #1f1f1f); }
|
|
878
|
+
.dss-nav-hint { font-size: 12px; line-height: 1.5; color: var(--dsw-alias-label-secondary, #6b6b6b); }
|
|
879
|
+
.dss-nav-meta {
|
|
880
|
+
flex: none; min-width: 28px; text-align: center;
|
|
881
|
+
font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums;
|
|
882
|
+
color: var(--dsw-alias-label-secondary, #6b6b6b);
|
|
883
|
+
background: var(--dsw-alias-bg-l3, #f2f3f5);
|
|
884
|
+
border: 1px solid var(--dsw-alias-border-l2, #e3e4e6);
|
|
885
|
+
border-radius: 999px; padding: 2px 9px;
|
|
886
|
+
}
|
|
887
|
+
.dss-panel-footer {
|
|
888
|
+
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
|
889
|
+
font-size: 12px; color: var(--dsw-alias-label-tertiary, #9a9a9a);
|
|
890
|
+
padding-top: 8px; border-top: 1px solid var(--dsw-alias-border-l3, #f0f0f0);
|
|
891
|
+
}
|
|
892
|
+
.dss-panel-link {
|
|
893
|
+
color: var(--dsw-alias-brand, #4d6bfe);
|
|
894
|
+
text-decoration: none; font-size: 12px;
|
|
895
|
+
}
|
|
896
|
+
.dss-panel-link:hover, .dss-panel-link:focus-visible { text-decoration: underline; }
|
|
897
|
+
/* A four-up summary strip. It fills the column with information rather than
|
|
898
|
+
decoration, and the numbers match the badges on the rows below. */
|
|
899
|
+
.dss-stat-strip {
|
|
900
|
+
display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px;
|
|
901
|
+
}
|
|
902
|
+
.dss-stat {
|
|
903
|
+
display: flex; flex-direction: column; gap: 3px;
|
|
904
|
+
padding: 14px 16px;
|
|
905
|
+
background: var(--dsw-alias-bg-l2, #fff);
|
|
906
|
+
border: 1px solid var(--dsw-alias-border-l2, #e3e4e6);
|
|
907
|
+
border-radius: 12px;
|
|
908
|
+
}
|
|
909
|
+
.dss-stat-value {
|
|
910
|
+
font-size: 22px; font-weight: 650; line-height: 1.15;
|
|
911
|
+
font-variant-numeric: tabular-nums;
|
|
912
|
+
color: var(--dsw-alias-label-primary, #1f1f1f);
|
|
913
|
+
}
|
|
914
|
+
.dss-stat-label {
|
|
915
|
+
font-size: 11.5px; color: var(--dsw-alias-label-secondary, #6b6b6b);
|
|
916
|
+
}
|
|
917
|
+
/* The caption under each figure. Small and tertiary: it explains the number without
|
|
918
|
+
competing with it. */
|
|
919
|
+
.dss-stat-hint {
|
|
920
|
+
font-size: 10.5px; line-height: 1.4;
|
|
921
|
+
color: var(--dsw-alias-label-tertiary, #9a9a9a);
|
|
922
|
+
}
|
|
923
|
+
.dss-breakdown {
|
|
924
|
+
display: flex; flex-direction: column; gap: 6px;
|
|
925
|
+
padding: 14px 16px;
|
|
926
|
+
background: var(--dsw-alias-bg-l3, #f7f8fa);
|
|
927
|
+
border: 1px solid var(--dsw-alias-border-l2, #e3e4e6);
|
|
928
|
+
border-radius: 12px;
|
|
929
|
+
}
|
|
930
|
+
.dss-breakdown-row { display: flex; align-items: baseline; gap: 10px; font-size: 12.5px; }
|
|
931
|
+
.dss-breakdown-key {
|
|
932
|
+
flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
933
|
+
color: var(--dsw-alias-label-primary, #1f1f1f);
|
|
934
|
+
}
|
|
935
|
+
.dss-breakdown-value {
|
|
936
|
+
flex: none; font-variant-numeric: tabular-nums; font-weight: 600;
|
|
937
|
+
color: var(--dsw-alias-label-secondary, #6b6b6b);
|
|
938
|
+
}
|
|
939
|
+
.dss-breakdown-hint { font-size: 11px; line-height: 1.5; color: var(--dsw-alias-label-tertiary, #9a9a9a); }
|
|
940
|
+
/* The way back from a screen. Left-aligned and small: it is an escape hatch, not
|
|
941
|
+
an action competing with the page's own controls. */
|
|
942
|
+
.dss-panel-nav { display: flex; align-items: center; margin-bottom: -2px; }
|
|
763
943
|
.dss-section.dss-embedded { max-width: none; gap: 10px; }
|
|
764
944
|
.dss-section h2 { margin: 0; font-size: 15px; font-weight: 600; color: var(--dsw-alias-label-primary, #1f1f1f); }
|
|
765
945
|
.dss-section-head { display: flex; flex-direction: column; gap: 4px; }
|
|
@@ -2392,6 +2572,369 @@ window.__ModuleLoader__.load({
|
|
|
2392
2572
|
return cut >= 0 ? text.slice(cut + 1) || text : text
|
|
2393
2573
|
}
|
|
2394
2574
|
|
|
2575
|
+
// --------------------------------------------------------- sidebar panel
|
|
2576
|
+
|
|
2577
|
+
/**
|
|
2578
|
+
* The sidebar icon.
|
|
2579
|
+
*
|
|
2580
|
+
* The owner supplies `size` and `active`, so the glyph has to inherit both
|
|
2581
|
+
* rather than carry its own colour — a tinted icon reads as branding and
|
|
2582
|
+
* clashes with the selected state the shell already draws around it.
|
|
2583
|
+
*
|
|
2584
|
+
* @param {any} props owner props
|
|
2585
|
+
* @returns {any} icon element
|
|
2586
|
+
*/
|
|
2587
|
+
function SuiteIcon(props) {
|
|
2588
|
+
const size = typeof props?.size === 'number' ? props.size : 18
|
|
2589
|
+
return h(
|
|
2590
|
+
'svg',
|
|
2591
|
+
{
|
|
2592
|
+
width: size,
|
|
2593
|
+
height: size,
|
|
2594
|
+
viewBox: '0 0 16 16',
|
|
2595
|
+
fill: 'none',
|
|
2596
|
+
stroke: 'currentColor',
|
|
2597
|
+
strokeWidth: 1.4,
|
|
2598
|
+
strokeLinecap: 'round',
|
|
2599
|
+
strokeLinejoin: 'round',
|
|
2600
|
+
'aria-hidden': 'true',
|
|
2601
|
+
},
|
|
2602
|
+
// Two stacked frames: this bundle is about sessions kept as snapshots, so
|
|
2603
|
+
// the mark is layers rather than the clock-like arc a "history" icon would
|
|
2604
|
+
// use (and which already means rewind in the action row).
|
|
2605
|
+
h('rect', { key: 'a', x: 2.6, y: 4.4, width: 8.4, height: 8.2, rx: 1.6 }),
|
|
2606
|
+
h('path', { key: 'b', d: 'M5.4 4.3V3.4a1.2 1.2 0 0 1 1.2-1.2h5.4a1.2 1.2 0 0 1 1.2 1.2v6.4' }),
|
|
2607
|
+
)
|
|
2608
|
+
}
|
|
2609
|
+
|
|
2610
|
+
/**
|
|
2611
|
+
* The panel shown when the sidebar icon is selected.
|
|
2612
|
+
*
|
|
2613
|
+
* It renders the three screens IN PLACE rather than navigating to Settings.
|
|
2614
|
+
*
|
|
2615
|
+
* That is not the first design. The first version was a navigation menu whose
|
|
2616
|
+
* rows called the settings shell's `openSection(id)`, copied from a comment in
|
|
2617
|
+
* dsh-connect-trae. It did nothing, and reading the shipped bundles explains why:
|
|
2618
|
+
*
|
|
2619
|
+
* • `createSettingsShellStore()` is created inside
|
|
2620
|
+
* `dsh-client-ui-settings-general` and never published — no `reflect.provide`,
|
|
2621
|
+
* no service name — so no other client plugin can reach `openSection`.
|
|
2622
|
+
* • `pluginNavigation` (the one real navigation service) offers only
|
|
2623
|
+
* `openList()` and `openBundle(name)`, and `openBundle` lands on the bundle
|
|
2624
|
+
* page (`view: { kind: 'package' }`), which renders `plugins.bundle.config`
|
|
2625
|
+
* and bundle rows — it does not render `settings.section` pages at all.
|
|
2626
|
+
*
|
|
2627
|
+
* So "click a row, land on a settings page" cannot be built from outside the
|
|
2628
|
+
* settings package. The honest fix is to put the screens here, where they are
|
|
2629
|
+
* reachable, and keep the settings pages as a second way in. Both render the same
|
|
2630
|
+
* components, so there is one implementation of each screen and nothing to sync.
|
|
2631
|
+
*
|
|
2632
|
+
* @param {any} props slot props
|
|
2633
|
+
* @returns {any} panel element
|
|
2634
|
+
*/
|
|
2635
|
+
function SuitePanel(props) {
|
|
2636
|
+
const t = props.t ?? (key => key)
|
|
2637
|
+
const rev = useStore(revision)
|
|
2638
|
+
// Which screen is open. `null` shows the three-entry list.
|
|
2639
|
+
const [open, setOpen] = useState(null)
|
|
2640
|
+
const [counts, setCounts] = useState(null)
|
|
2641
|
+
const [error, setError] = useState(null)
|
|
2642
|
+
|
|
2643
|
+
// The three numbers that tell the user whether there is anything behind each
|
|
2644
|
+
// row. A panel whose entries all look identical regardless of content is just
|
|
2645
|
+
// a menu; showing counts is what makes it worth opening.
|
|
2646
|
+
//
|
|
2647
|
+
// Two shape facts this relies on, both of which cost a round of debugging when
|
|
2648
|
+
// guessed wrong: the action is `list` (not `listSessions` — the Host method is
|
|
2649
|
+
// named that, the action is not), and it answers with a bare ARRAY.
|
|
2650
|
+
//
|
|
2651
|
+
// `scope: 'all'` is passed deliberately. The panel has no workspace of its own,
|
|
2652
|
+
// and calling `longterm.list` with no scope returned only the GLOBAL store —
|
|
2653
|
+
// 17 rows for a library of 51 — which is why the panel said 17 while the
|
|
2654
|
+
// settings page said something else entirely. 'all' now means every store.
|
|
2655
|
+
useEffect(() => {
|
|
2656
|
+
let alive = true
|
|
2657
|
+
Promise.all([
|
|
2658
|
+
call({ action: 'longterm.list', scope: 'all' }).catch(() => []),
|
|
2659
|
+
call({ action: 'list' }).catch(() => []),
|
|
2660
|
+
// Per-workspace breakdown, so the totals can be explained rather than
|
|
2661
|
+
// merely displayed. The counts above are sums; without this the user
|
|
2662
|
+
// cannot tell which workspace contributes what — the exact question that
|
|
2663
|
+
// made the earlier 17/21/27/51 mismatch look like a bug.
|
|
2664
|
+
call({ action: 'longterm.workspaces' }).catch(() => []),
|
|
2665
|
+
])
|
|
2666
|
+
.then(([memories, sessions, workspaces]) => {
|
|
2667
|
+
if (!alive) return
|
|
2668
|
+
const items = Array.isArray(sessions) ? sessions : []
|
|
2669
|
+
const stores = Array.isArray(workspaces) ? workspaces : []
|
|
2670
|
+
const memoryRows = Array.isArray(memories) ? memories : []
|
|
2671
|
+
// The global store's size, so the breakdown adds up to the total instead
|
|
2672
|
+
// of leaving the difference unexplained.
|
|
2673
|
+
const globalRows = memoryRows.filter(row => row.scope === 'global').length
|
|
2674
|
+
setCounts({
|
|
2675
|
+
memories: memoryRows.length,
|
|
2676
|
+
memoryActive: memoryRows.filter(row => row.archived !== true).length,
|
|
2677
|
+
memoryArchived: memoryRows.filter(row => row.archived === true).length,
|
|
2678
|
+
memoryGlobal: globalRows,
|
|
2679
|
+
sessions: items.length,
|
|
2680
|
+
snapshots: items.reduce((sum, item) => sum + (item.checkpoints ?? 0), 0),
|
|
2681
|
+
archived: items.filter(item => item.archived === true).length,
|
|
2682
|
+
// Each store's own size, so the total has a visible decomposition.
|
|
2683
|
+
stores: stores.map(store => ({
|
|
2684
|
+
cwd: store.cwd ?? store.key,
|
|
2685
|
+
entries: store.entries ?? 0,
|
|
2686
|
+
})),
|
|
2687
|
+
})
|
|
2688
|
+
})
|
|
2689
|
+
.catch(failure => alive && setError(failure?.message ?? String(failure)))
|
|
2690
|
+
return () => {
|
|
2691
|
+
alive = false
|
|
2692
|
+
}
|
|
2693
|
+
}, [rev])
|
|
2694
|
+
|
|
2695
|
+
// One screen is open: render it, with a way back.
|
|
2696
|
+
if (open) {
|
|
2697
|
+
const screens = {
|
|
2698
|
+
snapshots: SnapshotSection,
|
|
2699
|
+
memory: MemorySection,
|
|
2700
|
+
archived: ArchivedSection,
|
|
2701
|
+
}
|
|
2702
|
+
const Screen = screens[open]
|
|
2703
|
+
return h(
|
|
2704
|
+
'div',
|
|
2705
|
+
{ className: 'dss-panel' },
|
|
2706
|
+
h(
|
|
2707
|
+
'div',
|
|
2708
|
+
{ className: 'dss-panel-nav' },
|
|
2709
|
+
h(
|
|
2710
|
+
'button',
|
|
2711
|
+
{ type: 'button', className: 'dss-btn dss-small dss-ghost', onClick: () => setOpen(null) },
|
|
2712
|
+
`‹ ${t('panel.title')}`,
|
|
2713
|
+
),
|
|
2714
|
+
),
|
|
2715
|
+
Screen ? h(Screen, { t }) : null,
|
|
2716
|
+
)
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2719
|
+
/** @param {string} key screen key @param {string} title row title @param {string} hint row hint */
|
|
2720
|
+
const row = (key, title, hint, meta) =>
|
|
2721
|
+
h(
|
|
2722
|
+
'button',
|
|
2723
|
+
{
|
|
2724
|
+
key,
|
|
2725
|
+
type: 'button',
|
|
2726
|
+
className: 'dss-nav-row',
|
|
2727
|
+
onClick: () => setOpen(key),
|
|
2728
|
+
},
|
|
2729
|
+
h(
|
|
2730
|
+
'div',
|
|
2731
|
+
{ className: 'dss-nav-main' },
|
|
2732
|
+
h('div', { className: 'dss-nav-title' }, title),
|
|
2733
|
+
h('div', { className: 'dss-nav-hint' }, hint),
|
|
2734
|
+
),
|
|
2735
|
+
meta === undefined ? h(Icons.chevron, null) : h('div', { className: 'dss-nav-meta' }, String(meta)),
|
|
2736
|
+
)
|
|
2737
|
+
|
|
2738
|
+
return h(
|
|
2739
|
+
'div',
|
|
2740
|
+
{ className: 'dss-panel' },
|
|
2741
|
+
h(
|
|
2742
|
+
'div',
|
|
2743
|
+
{ className: 'dss-section-head' },
|
|
2744
|
+
h('h2', null, t('panel.title')),
|
|
2745
|
+
h('div', { className: 'dss-section-desc' }, t('panel.description')),
|
|
2746
|
+
),
|
|
2747
|
+
error ? h('div', { className: 'dss-dialog-error' }, error) : null,
|
|
2748
|
+
// A summary strip, because three rows alone left most of the column empty.
|
|
2749
|
+
// The numbers are the same ones the rows carry, so it adds orientation
|
|
2750
|
+
// without inventing a second source of truth.
|
|
2751
|
+
//
|
|
2752
|
+
// Each figure states what it counts. Showing bare numbers is how the earlier
|
|
2753
|
+
// mismatch became a bug report: the user saw 17 here and 21 there, both
|
|
2754
|
+
// labelled "memory", with nothing on screen saying one was the global store
|
|
2755
|
+
// and the other was global plus one workspace.
|
|
2756
|
+
h(
|
|
2757
|
+
'div',
|
|
2758
|
+
{ className: 'dss-stat-strip' },
|
|
2759
|
+
...[
|
|
2760
|
+
['panel.statSessions', counts?.sessions, 'panel.statSessionsHint'],
|
|
2761
|
+
['panel.statSnapshots', counts?.snapshots, 'panel.statSnapshotsHint'],
|
|
2762
|
+
['panel.statMemories', counts?.memories, 'panel.statMemoriesHint'],
|
|
2763
|
+
['panel.statArchived', counts?.archived, 'panel.statArchivedHint'],
|
|
2764
|
+
].map(([label, value, hint]) =>
|
|
2765
|
+
h(
|
|
2766
|
+
'div',
|
|
2767
|
+
{ className: 'dss-stat', key: label, title: t(hint) },
|
|
2768
|
+
h('div', { className: 'dss-stat-value' }, value === undefined ? '—' : String(value)),
|
|
2769
|
+
h('div', { className: 'dss-stat-label' }, t(label)),
|
|
2770
|
+
h('div', { className: 'dss-stat-hint' }, t(hint)),
|
|
2771
|
+
),
|
|
2772
|
+
),
|
|
2773
|
+
),
|
|
2774
|
+
// The decomposition, so the total is checkable rather than a number to trust.
|
|
2775
|
+
counts?.stores?.length
|
|
2776
|
+
? h(
|
|
2777
|
+
'div',
|
|
2778
|
+
{ className: 'dss-breakdown' },
|
|
2779
|
+
h('div', { className: 'dss-group-title' }, t('panel.breakdownTitle')),
|
|
2780
|
+
h(
|
|
2781
|
+
'div',
|
|
2782
|
+
{ className: 'dss-breakdown-row' },
|
|
2783
|
+
h('span', { className: 'dss-breakdown-key' }, t('panel.breakdownGlobal')),
|
|
2784
|
+
h('span', { className: 'dss-breakdown-value' }, String(counts.memoryGlobal ?? '—')),
|
|
2785
|
+
),
|
|
2786
|
+
...counts.stores.map(store =>
|
|
2787
|
+
h(
|
|
2788
|
+
'div',
|
|
2789
|
+
{ className: 'dss-breakdown-row', key: store.cwd },
|
|
2790
|
+
h(
|
|
2791
|
+
'span',
|
|
2792
|
+
{ className: 'dss-breakdown-key', title: store.cwd },
|
|
2793
|
+
String(store.cwd).split(/[\\/]/).filter(Boolean).pop() ?? store.cwd,
|
|
2794
|
+
),
|
|
2795
|
+
h('span', { className: 'dss-breakdown-value' }, String(store.entries)),
|
|
2796
|
+
),
|
|
2797
|
+
),
|
|
2798
|
+
h('div', { className: 'dss-breakdown-hint' }, t('panel.breakdownHint')),
|
|
2799
|
+
)
|
|
2800
|
+
: null,
|
|
2801
|
+
h(
|
|
2802
|
+
'div',
|
|
2803
|
+
{ className: 'dss-nav' },
|
|
2804
|
+
row('snapshots', t('panel.snapshots'), t('panel.snapshotsHint'), counts?.snapshots),
|
|
2805
|
+
row('memory', t('panel.memory'), t('panel.memoryHint'), counts?.memories),
|
|
2806
|
+
row('archived', t('panel.archived'), t('panel.archivedHint'), counts?.archived),
|
|
2807
|
+
),
|
|
2808
|
+
h(
|
|
2809
|
+
'div',
|
|
2810
|
+
{ className: 'dss-panel-footer' },
|
|
2811
|
+
h('span', null, t('panel.sessionsCount', { n: counts?.sessions ?? 0 })),
|
|
2812
|
+
// A way to find the source. dsh-connect-trae puts a GitHub link in the same
|
|
2813
|
+
// place, and it is the one line in a panel like this that answers "who made
|
|
2814
|
+
// this and where do I report a problem".
|
|
2815
|
+
h(
|
|
2816
|
+
'a',
|
|
2817
|
+
{
|
|
2818
|
+
className: 'dss-panel-link',
|
|
2819
|
+
href: 'https://github.com/heixiua/dsh-session-suite',
|
|
2820
|
+
target: '_blank',
|
|
2821
|
+
rel: 'noreferrer noopener',
|
|
2822
|
+
},
|
|
2823
|
+
t('panel.repoLink'),
|
|
2824
|
+
),
|
|
2825
|
+
),
|
|
2826
|
+
)
|
|
2827
|
+
}
|
|
2828
|
+
|
|
2829
|
+
// --------------------------------------------------- archived sessions
|
|
2830
|
+
|
|
2831
|
+
/**
|
|
2832
|
+
* Archived sessions, with a way back.
|
|
2833
|
+
*
|
|
2834
|
+
* Archiving existed as a Host command but had no surface here, so a session
|
|
2835
|
+
* could be archived and then be unreachable — the sidebar hides it and the only
|
|
2836
|
+
* way back was the Host's own menu, which is disabled in the context where the
|
|
2837
|
+
* archive happened. This page is the missing half: it lists what the sidebar is
|
|
2838
|
+
* hiding and offers restore.
|
|
2839
|
+
*
|
|
2840
|
+
* Restore, not delete, is the primary action. Archived rows are still complete
|
|
2841
|
+
* sessions; the common mistake is to treat "hidden" as "gone" and destroy data
|
|
2842
|
+
* to tidy a list.
|
|
2843
|
+
*
|
|
2844
|
+
* @param {any} props slot props
|
|
2845
|
+
* @returns {any} section element
|
|
2846
|
+
*/
|
|
2847
|
+
function ArchivedSection(props) {
|
|
2848
|
+
const t = props.t ?? (key => key)
|
|
2849
|
+
const rev = useStore(revision)
|
|
2850
|
+
const [rows, setRows] = useState(null)
|
|
2851
|
+
const [error, setError] = useState(null)
|
|
2852
|
+
const [busy, setBusy] = useState(null)
|
|
2853
|
+
|
|
2854
|
+
useEffect(() => {
|
|
2855
|
+
let alive = true
|
|
2856
|
+
// `list` answers with a bare array; see the note in SuitePanel.
|
|
2857
|
+
call({ action: 'list' })
|
|
2858
|
+
.then(result => {
|
|
2859
|
+
if (!alive) return
|
|
2860
|
+
const items = Array.isArray(result) ? result : []
|
|
2861
|
+
setRows(items.filter(item => item.archived === true))
|
|
2862
|
+
})
|
|
2863
|
+
.catch(failure => alive && setError(failure?.message ?? String(failure)))
|
|
2864
|
+
return () => {
|
|
2865
|
+
alive = false
|
|
2866
|
+
}
|
|
2867
|
+
}, [rev])
|
|
2868
|
+
|
|
2869
|
+
/** @param {string} sessionId session to restore */
|
|
2870
|
+
const restore = async sessionId => {
|
|
2871
|
+
setBusy(sessionId)
|
|
2872
|
+
setError(null)
|
|
2873
|
+
try {
|
|
2874
|
+
await call({ action: 'restoreSession', sessionId })
|
|
2875
|
+
notify(t('archived.restored'))
|
|
2876
|
+
bump()
|
|
2877
|
+
} catch (failure) {
|
|
2878
|
+
setError(failure?.message ?? t('archived.restoreFailed'))
|
|
2879
|
+
} finally {
|
|
2880
|
+
setBusy(null)
|
|
2881
|
+
}
|
|
2882
|
+
}
|
|
2883
|
+
|
|
2884
|
+
const head = h(
|
|
2885
|
+
'div',
|
|
2886
|
+
{ className: 'dss-section-head' },
|
|
2887
|
+
h('h2', null, t('archived.title')),
|
|
2888
|
+
h('div', { className: 'dss-section-desc' }, t('archived.description')),
|
|
2889
|
+
)
|
|
2890
|
+
|
|
2891
|
+
if (error) return h('div', { className: 'dss-section' }, head, h('div', { className: 'dss-dialog-error' }, error))
|
|
2892
|
+
if (!rows) return h('div', { className: 'dss-section' }, head, h('div', { className: 'dss-empty' }, t('settings.loading')))
|
|
2893
|
+
|
|
2894
|
+
const body =
|
|
2895
|
+
rows.length === 0
|
|
2896
|
+
? h('div', { className: 'dss-empty' }, t('archived.empty'))
|
|
2897
|
+
: h(
|
|
2898
|
+
'div',
|
|
2899
|
+
{ className: 'dss-card' },
|
|
2900
|
+
...rows.map(entry =>
|
|
2901
|
+
h(
|
|
2902
|
+
'div',
|
|
2903
|
+
{ className: 'dss-row', key: entry.sessionId },
|
|
2904
|
+
h(
|
|
2905
|
+
'div',
|
|
2906
|
+
{ className: 'dss-row-main' },
|
|
2907
|
+
h('div', { className: 'dss-memory-text' }, entry.title || entry.sessionId),
|
|
2908
|
+
h(
|
|
2909
|
+
'div',
|
|
2910
|
+
{ className: 'dss-row-meta' },
|
|
2911
|
+
h('span', { className: 'dss-badge dss-temp' }, t('settings.archived')),
|
|
2912
|
+
typeof entry.checkpoints === 'number'
|
|
2913
|
+
? h('span', null, t('settings.checkpointCount', { n: entry.checkpoints }))
|
|
2914
|
+
: null,
|
|
2915
|
+
),
|
|
2916
|
+
),
|
|
2917
|
+
h(
|
|
2918
|
+
'div',
|
|
2919
|
+
{ className: 'dss-row-actions' },
|
|
2920
|
+
h(
|
|
2921
|
+
'button',
|
|
2922
|
+
{
|
|
2923
|
+
type: 'button',
|
|
2924
|
+
className: 'dss-btn dss-small',
|
|
2925
|
+
disabled: busy === entry.sessionId,
|
|
2926
|
+
onClick: () => restore(entry.sessionId),
|
|
2927
|
+
},
|
|
2928
|
+
busy === entry.sessionId ? t('settings.working') : t('archived.restore'),
|
|
2929
|
+
),
|
|
2930
|
+
),
|
|
2931
|
+
),
|
|
2932
|
+
),
|
|
2933
|
+
)
|
|
2934
|
+
|
|
2935
|
+
return h('div', { className: 'dss-section' }, head, body)
|
|
2936
|
+
}
|
|
2937
|
+
|
|
2395
2938
|
function MemorySection(props) {
|
|
2396
2939
|
const t = props.t
|
|
2397
2940
|
const rev = useStore(revision)
|
|
@@ -2632,33 +3175,39 @@ window.__ModuleLoader__.load({
|
|
|
2632
3175
|
),
|
|
2633
3176
|
),
|
|
2634
3177
|
)
|
|
2635
|
-
:
|
|
2636
|
-
|
|
3178
|
+
: h(
|
|
3179
|
+
// Always render this block, even when extraction has never run.
|
|
3180
|
+
//
|
|
3181
|
+
// It used to render only when a trace existed, so a freshly started
|
|
3182
|
+
// Host showed NOTHING here — indistinguishable from a broken feature,
|
|
3183
|
+
// and exactly the question the user then asked. An empty state has to
|
|
3184
|
+
// say why it is empty.
|
|
3185
|
+
'div',
|
|
3186
|
+
{ className: 'dss-mem-pending' },
|
|
3187
|
+
h('div', { className: 'dss-group-title' }, t('memory.pendingTitle')),
|
|
3188
|
+
h(
|
|
2637
3189
|
'div',
|
|
2638
|
-
{ className: 'dss-mem-pending' },
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
t('memory.
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
h('span', null, t('memory.
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
: null,
|
|
2660
|
-
)
|
|
2661
|
-
: null
|
|
3190
|
+
{ className: 'dss-mem-pending-hint' },
|
|
3191
|
+
extraction
|
|
3192
|
+
? t('memory.extractionIdle', {
|
|
3193
|
+
reason: extraction.reason || t('memory.extractionUnknown'),
|
|
3194
|
+
})
|
|
3195
|
+
: t('memory.extractionNotYet'),
|
|
3196
|
+
),
|
|
3197
|
+
extraction?.candidates?.length
|
|
3198
|
+
? h(
|
|
3199
|
+
'div',
|
|
3200
|
+
{ className: 'dss-row-meta' },
|
|
3201
|
+
h('span', null, t('memory.extractionTried', { n: extraction.candidates.length })),
|
|
3202
|
+
extraction.queued !== undefined
|
|
3203
|
+
? h('span', null, t('memory.extractionQueued', { n: extraction.queued }))
|
|
3204
|
+
: null,
|
|
3205
|
+
extraction.rejected !== undefined
|
|
3206
|
+
? h('span', null, t('memory.extractionRejected', { n: extraction.rejected }))
|
|
3207
|
+
: null,
|
|
3208
|
+
)
|
|
3209
|
+
: null,
|
|
3210
|
+
)
|
|
2662
3211
|
|
|
2663
3212
|
const listBlock =
|
|
2664
3213
|
visible.length === 0
|
|
@@ -3034,6 +3583,29 @@ window.__ModuleLoader__.load({
|
|
|
3034
3583
|
)
|
|
3035
3584
|
}
|
|
3036
3585
|
|
|
3586
|
+
// ---------------------------------------------------- sidebar entry
|
|
3587
|
+
//
|
|
3588
|
+
// A first-class sidebar entry, matching how dsh-connect-trae,
|
|
3589
|
+
// dsh-connect-workbuddy and dsh-context present themselves: an icon in
|
|
3590
|
+
// `sidebar.panellist` plus a panel under the same key in `main`. Both are
|
|
3591
|
+
// additive (`replaceRisk: none` on the list, and the key is one only this
|
|
3592
|
+
// plugin claims), so nothing shipped is shadowed.
|
|
3593
|
+
//
|
|
3594
|
+
// Why a panel at all: the features already existed but were reachable only
|
|
3595
|
+
// through Settings → one of two sections, three steps from anywhere. The
|
|
3596
|
+
// panel is pure navigation — it does not reimplement the sections, it opens
|
|
3597
|
+
// them, so there is one implementation of every screen.
|
|
3598
|
+
ctx.slots.inject('sidebar.panellist', () =>
|
|
3599
|
+
ctx.slots.register(
|
|
3600
|
+
{ name: 'sidebar.panellist', id: 'session-suite', order: 25, locale: NS },
|
|
3601
|
+
withT(SuiteIcon),
|
|
3602
|
+
),
|
|
3603
|
+
)
|
|
3604
|
+
|
|
3605
|
+
ctx.slots.inject('main', () =>
|
|
3606
|
+
ctx.slots.register({ name: 'main', key: 'session-suite' }, withT(SuitePanel)),
|
|
3607
|
+
)
|
|
3608
|
+
|
|
3037
3609
|
ctx.slots.inject('conversation.chat.assistant-actions', () =>
|
|
3038
3610
|
ctx.slots.register(
|
|
3039
3611
|
{ name: 'conversation.chat.assistant-actions', id: 'session-suite-rewind', order: 20, locale: NS },
|
|
@@ -3052,31 +3624,15 @@ window.__ModuleLoader__.load({
|
|
|
3052
3624
|
ctx.slots.register({ name: 'shell.overlay', id: 'session-suite-overlay', order: 40 }, SuiteOverlay),
|
|
3053
3625
|
)
|
|
3054
3626
|
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
withT(SnapshotSection),
|
|
3065
|
-
),
|
|
3066
|
-
)
|
|
3067
|
-
|
|
3068
|
-
ctx.slots.inject('settings.section', () =>
|
|
3069
|
-
ctx.slots.register(
|
|
3070
|
-
{
|
|
3071
|
-
name: 'settings.section',
|
|
3072
|
-
id: 'long-term-memory',
|
|
3073
|
-
order: 520,
|
|
3074
|
-
locale: NS,
|
|
3075
|
-
label: () => t('memory.title'),
|
|
3076
|
-
},
|
|
3077
|
-
withT(MemorySection),
|
|
3078
|
-
),
|
|
3079
|
-
)
|
|
3627
|
+
// The three settings sections are gone on purpose.
|
|
3628
|
+
//
|
|
3629
|
+
// They were the only way in before the sidebar panel existed, so the same
|
|
3630
|
+
// three screens appeared twice: once under Settings and once behind the
|
|
3631
|
+
// sidebar icon. Two entry points for one screen is not convenience, it is
|
|
3632
|
+
// two places to keep consistent and two places for the user to wonder about.
|
|
3633
|
+
// The panel is the primary surface now; these registrations were removed
|
|
3634
|
+
// rather than hidden behind a flag, because a dead section in Settings is
|
|
3635
|
+
// worse than an absent one.
|
|
3080
3636
|
}
|
|
3081
3637
|
},
|
|
3082
3638
|
}
|
package/lib/api.js
CHANGED
|
@@ -144,6 +144,11 @@ async function respond(service, deps, action, input) {
|
|
|
144
144
|
{ cascade: input.cascade !== false },
|
|
145
145
|
),
|
|
146
146
|
})
|
|
147
|
+
case 'restoreSession':
|
|
148
|
+
// The inverse of archiving. Kept separate from any delete path on purpose:
|
|
149
|
+
// un-hiding a session and destroying one are different intents and must not
|
|
150
|
+
// share a control.
|
|
151
|
+
return json({ ok: true, data: await service.restoreSession(requireSession(input)) })
|
|
147
152
|
case 'deleteSession':
|
|
148
153
|
return json({
|
|
149
154
|
ok: true,
|
package/lib/memory-engine.js
CHANGED
|
@@ -1599,11 +1599,39 @@ export class MemoryEngine {
|
|
|
1599
1599
|
async list(input = {}) {
|
|
1600
1600
|
const settings = await this.settings()
|
|
1601
1601
|
const out = []
|
|
1602
|
-
|
|
1602
|
+
// `'all'` is a legitimate request meaning "every scope". Treating it as a scope
|
|
1603
|
+
// NAME selected a store called `all`, which does not exist, so the answer came
|
|
1604
|
+
// back as whatever stray rows happened to be there — the settings page showed
|
|
1605
|
+
// 「共 5 条」 for a library of 51. Any unrecognised value falls back to every
|
|
1606
|
+
// scope for the same reason: an unknown scope must not silently narrow the list.
|
|
1607
|
+
const requested = input.scope
|
|
1608
|
+
const scopes =
|
|
1609
|
+
requested === 'workspace' || requested === 'global'
|
|
1610
|
+
? [requested]
|
|
1611
|
+
: SCOPES
|
|
1612
|
+
// Which workspace stores to read.
|
|
1613
|
+
//
|
|
1614
|
+
// A caller without a workspace — the sidebar panel has none — used to get only
|
|
1615
|
+
// the global store, because `workspaceKey(undefined)` hashes the empty string and
|
|
1616
|
+
// reads a store that never exists. That is why the panel showed 17 for a library
|
|
1617
|
+
// of 51 while the settings page showed 51. When no cwd is supplied, read every
|
|
1618
|
+
// REGISTERED workspace instead of pretending there is one unnamed store: the
|
|
1619
|
+
// registry is the only place that knows which keys exist.
|
|
1620
|
+
const workspaceKeys = await (async () => {
|
|
1621
|
+
// Only asked for global: no workspace stores at all.
|
|
1622
|
+
if (requested === 'global') return []
|
|
1623
|
+
// A caller that names its workspace gets exactly that one.
|
|
1624
|
+
if (typeof input.cwd === 'string' && input.cwd) return [MemoryEngine.workspaceKey(input.cwd)]
|
|
1625
|
+
// A caller with no workspace gets every registered one.
|
|
1626
|
+
const registered = await this.workspaces()
|
|
1627
|
+
return registered.map(item => item.key).filter(key => typeof key === 'string' && key)
|
|
1628
|
+
})()
|
|
1603
1629
|
for (const scope of scopes) {
|
|
1604
|
-
|
|
1605
|
-
const
|
|
1606
|
-
const
|
|
1630
|
+
// The workspace scope may cover several keys; global covers exactly one.
|
|
1631
|
+
const keys = scope === 'workspace' ? workspaceKeys : ['global']
|
|
1632
|
+
for (const key of keys) {
|
|
1633
|
+
const store = await this.#readStore(scope, key)
|
|
1634
|
+
const cold = await this.#readCold(scope, key)
|
|
1607
1635
|
const maxAccess = Math.max(1, ...store.entries.map(entry => entry.accessCount ?? 0))
|
|
1608
1636
|
for (const entry of store.entries) {
|
|
1609
1637
|
const own = shingles(entry.text)
|
|
@@ -1621,6 +1649,7 @@ export class MemoryEngine {
|
|
|
1621
1649
|
out.push({ ...entry, scope, score, factors })
|
|
1622
1650
|
}
|
|
1623
1651
|
for (const entry of cold.entries) out.push({ ...entry, scope, score: 0, factors: {}, state: 'archived', archived: true })
|
|
1652
|
+
}
|
|
1624
1653
|
}
|
|
1625
1654
|
// Filtering happens here rather than in the caller so the settings page and
|
|
1626
1655
|
// the export both see the same set for the same query. A library that grows
|
package/lib/service.js
CHANGED
|
@@ -70,6 +70,9 @@ export class SessionSuiteService {
|
|
|
70
70
|
if (!controller?.list) return []
|
|
71
71
|
const value = await controller.list({})
|
|
72
72
|
const items = Array.isArray(value?.items) ? value.items : []
|
|
73
|
+
// Whether a session is archived is read once for the whole call rather than per
|
|
74
|
+
// row: it is one file, and a per-row read would open it twelve times.
|
|
75
|
+
const archivedIds = await this.#archivedIds()
|
|
73
76
|
const out = []
|
|
74
77
|
for (const item of items) {
|
|
75
78
|
// Hide subagent children only. They are not conversations of their own —
|
|
@@ -100,6 +103,12 @@ export class SessionSuiteService {
|
|
|
100
103
|
checkpoints: checkpoints.length,
|
|
101
104
|
checkpointBytes: stats?.bytes ?? 0,
|
|
102
105
|
checkpointLabel: formatBytes(stats?.bytes ?? 0),
|
|
106
|
+
// Archived is a property the caller has to ASK about, because the sidebar
|
|
107
|
+
// hides these rows and the archive page is the only place that wants them
|
|
108
|
+
// back. Reported here rather than filtered out, so one list serves both
|
|
109
|
+
// screens: the archive page selects the archived ones, everything else
|
|
110
|
+
// ignores the flag and gets exactly the old behaviour.
|
|
111
|
+
archived: archivedIds.has(item.sessionId),
|
|
103
112
|
})
|
|
104
113
|
}
|
|
105
114
|
return out
|
|
@@ -921,34 +930,104 @@ export class SessionSuiteService {
|
|
|
921
930
|
await fs.rm(path.join(this.undoDir, `${undoId}.json`), { force: true })
|
|
922
931
|
}
|
|
923
932
|
|
|
924
|
-
/**
|
|
925
|
-
|
|
926
|
-
|
|
933
|
+
/**
|
|
934
|
+
* Bring an archived session back into the sidebar.
|
|
935
|
+
*
|
|
936
|
+
* Archiving hides a session without destroying it, so restore is the inverse and
|
|
937
|
+
* needs nothing more than clearing the archive mark. It is a separate operation
|
|
938
|
+
* from deleteSession on purpose: a list of hidden rows is not a list of things to
|
|
939
|
+
* destroy, and offering only "delete" would teach the user that hiding equals
|
|
940
|
+
* losing.
|
|
941
|
+
*
|
|
942
|
+
* @param {string} sessionId session to restore
|
|
943
|
+
* @returns {Promise<{ sessionId: string, restored: boolean }>} result
|
|
944
|
+
*/
|
|
945
|
+
async restoreSession(sessionId) {
|
|
946
|
+
if (typeof sessionId !== 'string' || !sessionId) throw new Error('sessionId is required')
|
|
947
|
+
this.#require('sessionController')
|
|
948
|
+
const restored = await this.#unarchive(sessionId)
|
|
949
|
+
if (!restored) {
|
|
950
|
+
throw new Error('this Host build does not expose a way to unarchive a session')
|
|
951
|
+
}
|
|
952
|
+
// Tell the live layer so the sidebar redraws without a restart — the same
|
|
953
|
+
// signal deleteSession emits in the opposite direction.
|
|
954
|
+
try {
|
|
955
|
+
this.ctx.emit?.('api-session/restored', sessionId)
|
|
956
|
+
} catch (error) {
|
|
957
|
+
this.log('could not announce the restored session', error)
|
|
958
|
+
}
|
|
959
|
+
return { sessionId, restored: true }
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* The set of archived session ids, read from the registry that owns them.
|
|
964
|
+
*
|
|
965
|
+
* Read from `workspace.json` rather than through a controller because the
|
|
966
|
+
* controller offers archive/unarchive as WRITE operations and no read — and the
|
|
967
|
+
* list endpoint this feeds does not project the flag, so a page built on it could
|
|
968
|
+
* only ever show zero. This is the same file `#archive` ultimately writes through
|
|
969
|
+
* the registry controller, so the two cannot disagree about what "archived" means.
|
|
970
|
+
*
|
|
971
|
+
* @returns {Promise<Set<string>>} archived ids
|
|
972
|
+
*/
|
|
973
|
+
async #archivedIds() {
|
|
974
|
+
try {
|
|
975
|
+
const registry = await readJson(path.join(this.dshHome, 'storages', 'workspace.json'))
|
|
976
|
+
const ids = registry?.global?.archivedSessionIds
|
|
977
|
+
return new Set(Array.isArray(ids) ? ids.filter(id => typeof id === 'string') : [])
|
|
978
|
+
} catch (error) {
|
|
979
|
+
// A missing or malformed registry means "nothing is known to be archived",
|
|
980
|
+
// which renders as an empty page rather than an error — the archive list is
|
|
981
|
+
// advisory and must never block the panel from loading.
|
|
982
|
+
this.log('could not read the archived session marks', error)
|
|
983
|
+
return new Set()
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* Call an archive/unarchive operation on whichever controller is mounted.
|
|
989
|
+
*
|
|
990
|
+
* The two candidate services take DIFFERENT argument shapes, and picking the wrong
|
|
991
|
+
* one fails silently — which is why the Restore button appeared to do nothing:
|
|
992
|
+
* `workspaceRegistry.unarchiveSession(sessionId)` takes the id directly, while
|
|
993
|
+
* `workspaceController.unarchiveSession(request)` is a generated Remote that takes
|
|
994
|
+
* a request OBJECT (`{ sessionId }`). Passing a bare string to the latter throws,
|
|
995
|
+
* the throw was caught and logged, and the caller only saw `false`.
|
|
996
|
+
*
|
|
997
|
+
* Both shapes are attempted rather than guessed: try the plain argument first
|
|
998
|
+
* (registry), then the request object (controller), and treat success as whichever
|
|
999
|
+
* one resolves.
|
|
1000
|
+
*
|
|
1001
|
+
* @param {string} method archiveSession | unarchiveSession
|
|
1002
|
+
* @param {string} sessionId session
|
|
1003
|
+
* @param {any} [extra] extra options for the plain-argument shape
|
|
1004
|
+
* @returns {Promise<boolean>} whether it took effect
|
|
1005
|
+
*/
|
|
1006
|
+
async #sessionFlag(method, sessionId, extra) {
|
|
1007
|
+
const controllers = [this.#service('workspaceRegistry'), this.#service('workspaceController')]
|
|
927
1008
|
for (const controller of controllers) {
|
|
928
|
-
if (!controller
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
1009
|
+
if (!controller || typeof controller[method] !== 'function') continue
|
|
1010
|
+
// Registry shape: (sessionId, options). Controller shape: ({ sessionId, ... }).
|
|
1011
|
+
for (const args of [[sessionId, extra].filter(Boolean), [{ sessionId, ...(extra ?? {}) }]]) {
|
|
1012
|
+
try {
|
|
1013
|
+
await controller[method](...args)
|
|
1014
|
+
return true
|
|
1015
|
+
} catch (error) {
|
|
1016
|
+
this.log(`${method} attempt failed with ${args.length} argument(s)`, error)
|
|
1017
|
+
}
|
|
934
1018
|
}
|
|
935
1019
|
}
|
|
936
1020
|
return false
|
|
937
1021
|
}
|
|
938
1022
|
|
|
1023
|
+
/** @param {string} sessionId session @param {boolean} stop stop its running work */
|
|
1024
|
+
async #archive(sessionId, stop) {
|
|
1025
|
+
return this.#sessionFlag('archiveSession', sessionId, { stopActivity: stop === true })
|
|
1026
|
+
}
|
|
1027
|
+
|
|
939
1028
|
/** @param {string} sessionId session */
|
|
940
1029
|
async #unarchive(sessionId) {
|
|
941
|
-
|
|
942
|
-
for (const controller of controllers) {
|
|
943
|
-
if (!controller?.unarchiveSession) continue
|
|
944
|
-
try {
|
|
945
|
-
await controller.unarchiveSession(sessionId)
|
|
946
|
-
return true
|
|
947
|
-
} catch (error) {
|
|
948
|
-
this.log('unarchiveSession failed', error)
|
|
949
|
-
}
|
|
950
|
-
}
|
|
951
|
-
return false
|
|
1030
|
+
return this.#sessionFlag('unarchiveSession', sessionId)
|
|
952
1031
|
}
|
|
953
1032
|
|
|
954
1033
|
/**
|
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.1",
|
|
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",
|