@heixui/dsh-session-suite 0.8.0 → 0.8.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/client.js CHANGED
@@ -56,6 +56,14 @@ window.__ModuleLoader__.load({
56
56
  'share.hint': 'Choose the turns to include.',
57
57
  'share.all': 'Select all',
58
58
  'share.none': 'Clear',
59
+ 'share.recentLabel': 'Newest',
60
+ 'share.recentApply': 'Select',
61
+ 'share.searchPlaceholder': 'Search a turn number or text…',
62
+ 'share.searchHit': '{n} of {total} turns match',
63
+ 'share.pageFirst': 'First',
64
+ 'share.pagePrev': 'Prev',
65
+ 'share.pageNext': 'Next',
66
+ 'share.pageLast': 'Last',
59
67
  'share.selected': '{count} of {total} turns selected',
60
68
  'share.image': 'Image',
61
69
  'share.html': 'Web page',
@@ -305,6 +313,14 @@ window.__ModuleLoader__.load({
305
313
  'share.hint': '选择要包含的对话轮次。',
306
314
  'share.all': '全选',
307
315
  'share.none': '清空',
316
+ 'share.recentLabel': '最近',
317
+ 'share.recentApply': '选中',
318
+ 'share.searchPlaceholder': '搜索轮次号或文字…',
319
+ 'share.searchHit': '匹配 {n} / {total} 轮',
320
+ 'share.pageFirst': '首页',
321
+ 'share.pagePrev': '上一页',
322
+ 'share.pageNext': '下一页',
323
+ 'share.pageLast': '末页',
308
324
  'share.selected': '已选 {count} / {total} 轮',
309
325
  'share.image': '生成图片',
310
326
  'share.html': '网页文件',
@@ -1115,6 +1131,26 @@ window.__ModuleLoader__.load({
1115
1131
  .dss-badge.dss-temp { background: color-mix(in srgb, var(--dsw-alias-state-warn-primary, #b07b00) 16%, transparent); color: var(--dsw-alias-state-warn-primary, #b07b00); }
1116
1132
  .dss-empty { padding: 18px 12px; font-size: 13px; color: var(--dsw-alias-label-secondary, #6b6b6b); }
1117
1133
  .dss-toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
1134
+ /* "Newest N" shortcut: sits at the end of the selection toolbar. The box is small on
1135
+ purpose — it takes a turn count, not free text. */
1136
+ .dss-recent { display: flex; align-items: center; gap: 6px; margin-left: auto; }
1137
+ .dss-recent-label { font-size: 12px; color: var(--dsw-alias-label-secondary, #6b6b6b); white-space: nowrap; }
1138
+ .dss-recent-input { width: 72px; flex: 0 0 auto; text-align: right; }
1139
+ /* Turn picker: search box, a fixed 10-row page, and a pager. The picker itself keeps
1140
+ its own scroll as a fallback for short viewports, but the page is bounded so the
1141
+ dialog height no longer depends on how long the conversation is. */
1142
+ .dss-picker-wrap { display: flex; flex-direction: column; gap: 8px; min-height: 0; }
1143
+ .dss-picker-filter { display: flex; align-items: center; gap: 8px; }
1144
+ .dss-picker-search { flex: 1 1 auto; min-width: 0; }
1145
+ .dss-picker-hit { font-size: 12px; color: var(--dsw-alias-label-secondary, #6b6b6b); white-space: nowrap; }
1146
+ .dss-pager {
1147
+ display: flex; align-items: center; justify-content: center; gap: 6px;
1148
+ padding-top: 4px; font-size: 12px;
1149
+ color: var(--dsw-alias-label-secondary, #6b6b6b);
1150
+ }
1151
+ .dss-pager-pos { display: flex; align-items: center; gap: 4px; }
1152
+ .dss-pager-input { width: 56px; text-align: center; }
1153
+ .dss-pager-total { font-variant-numeric: tabular-nums; }
1118
1154
  .dss-select {
1119
1155
  height: 30px; border-radius: 8px; padding: 0 8px; font-size: 13px;
1120
1156
  border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.12));
@@ -1362,7 +1398,21 @@ window.__ModuleLoader__.load({
1362
1398
  }, [])
1363
1399
  const all = useCallback(() => setSelected(new Set((outline?.turns ?? []).map(row => row.turn))), [outline])
1364
1400
  const none = useCallback(() => setSelected(new Set()), [])
1365
- return { selected, toggle, all, none }
1401
+ // Select the newest N turns. Turn numbers ascend with the conversation, so
1402
+ // "recent" means the highest N — sorting rather than slicing by array position,
1403
+ // because the outline order is not guaranteed to be the turn order.
1404
+ const recent = useCallback(
1405
+ count => {
1406
+ const rows = outline?.turns ?? []
1407
+ const keep = [...rows]
1408
+ .sort((a, b) => b.turn - a.turn)
1409
+ .slice(0, Math.max(1, count))
1410
+ .map(row => row.turn)
1411
+ setSelected(new Set(keep))
1412
+ },
1413
+ [outline],
1414
+ )
1415
+ return { selected, toggle, all, none, recent }
1366
1416
  }
1367
1417
 
1368
1418
  function rangesOf(outline, selected) {
@@ -1371,37 +1421,149 @@ window.__ModuleLoader__.load({
1371
1421
  .map(row => ({ startSeq: row.startSeq, endSeq: row.endSeq }))
1372
1422
  }
1373
1423
 
1424
+ const TURNS_PER_PAGE = 10
1425
+
1374
1426
  function TurnPicker({ outline, selected, onToggle, t }) {
1375
- const turns = outline?.turns ?? []
1427
+ const raw = outline?.turns ?? []
1428
+ // Newest first. Turn numbers ascend with the conversation, so sorting descending
1429
+ // puts the turn you just had on page 1 — which is what anyone opening this dialog
1430
+ // expects. The outline itself arrives oldest-first because that is the log order.
1431
+ const turns = [...raw].sort((a, b) => b.turn - a.turn)
1432
+ // Page + filter state live here, so paging does not reset when a box is ticked.
1433
+ const [page, setPage] = useState(1)
1434
+ const [query, setQuery] = useState('')
1435
+
1376
1436
  if (turns.length === 0) return h('div', { className: 'dss-empty' }, t('settings.empty'))
1437
+
1438
+ // Search matches the turn number or either side's text. Matching the NUMBER as a
1439
+ // string is deliberate: typing "141" is how a user names a specific turn, and a
1440
+ // substring match finds it alongside any turn whose text mentions 141.
1441
+ const needle = query.trim().toLowerCase()
1442
+ const matched = needle
1443
+ ? turns.filter(row => {
1444
+ if (String(row.turn).includes(needle)) return true
1445
+ const hay = `${row.preview ?? ''} ${row.answerPreview ?? ''}`.toLowerCase()
1446
+ return hay.includes(needle)
1447
+ })
1448
+ : turns
1449
+
1450
+ const pageCount = Math.max(1, Math.ceil(matched.length / TURNS_PER_PAGE))
1451
+ // Clamp rather than store a valid page: the filter can shrink the list under the
1452
+ // current page, and showing an empty page would look like the search found nothing.
1453
+ const current = Math.min(Math.max(1, page), pageCount)
1454
+ const from = (current - 1) * TURNS_PER_PAGE
1455
+ const visible = matched.slice(from, from + TURNS_PER_PAGE)
1456
+
1457
+ const go = next => setPage(Math.min(Math.max(1, next), pageCount))
1458
+
1377
1459
  return h(
1378
1460
  'div',
1379
- { className: 'dss-picker' },
1380
- ...turns.map(row => {
1381
- const checked = selected.has(row.turn)
1382
- return h(
1383
- 'label',
1384
- { key: row.turn, className: 'dss-pick' + (checked ? ' dss-pick-on' : '') },
1385
- h('input', { type: 'checkbox', checked, onChange: () => onToggle(row.turn) }),
1386
- h(
1387
- 'span',
1388
- { className: 'dss-pick-main' },
1389
- h('span', { className: 'dss-pick-title' }, `${t('settings.turn', { turn: row.turn })} · ${row.preview || '—'}`),
1390
- row.answerPreview ? h('span', { className: 'dss-pick-sub' }, row.answerPreview) : null,
1391
- ),
1392
- )
1393
- }),
1461
+ { className: 'dss-picker-wrap' },
1462
+ h(
1463
+ 'div',
1464
+ { className: 'dss-picker-filter' },
1465
+ h('input', {
1466
+ type: 'search',
1467
+ className: 'dss-input dss-picker-search',
1468
+ value: query,
1469
+ placeholder: t('share.searchPlaceholder'),
1470
+ onChange: event => {
1471
+ setQuery(event.target.value)
1472
+ setPage(1)
1473
+ },
1474
+ }),
1475
+ needle
1476
+ ? h('span', { className: 'dss-picker-hit' }, t('share.searchHit', { n: matched.length, total: turns.length }))
1477
+ : null,
1478
+ ),
1479
+ h(
1480
+ 'div',
1481
+ { className: 'dss-picker' },
1482
+ ...visible.map(row => {
1483
+ const checked = selected.has(row.turn)
1484
+ return h(
1485
+ 'label',
1486
+ { key: row.turn, className: 'dss-pick' + (checked ? ' dss-pick-on' : '') },
1487
+ h('input', { type: 'checkbox', checked, onChange: () => onToggle(row.turn) }),
1488
+ h(
1489
+ 'span',
1490
+ { className: 'dss-pick-main' },
1491
+ h(
1492
+ 'span',
1493
+ { className: 'dss-pick-title' },
1494
+ `${t('settings.turn', { turn: row.turn })} · ${row.preview || '—'}`,
1495
+ ),
1496
+ row.answerPreview ? h('span', { className: 'dss-pick-sub' }, row.answerPreview) : null,
1497
+ ),
1498
+ )
1499
+ }),
1500
+ ),
1501
+ // Paging controls. A page number input rather than only prev/next, because
1502
+ // scrolling 146 rows to reach turn 3 is exactly what the user objected to.
1503
+ pageCount > 1
1504
+ ? h(
1505
+ 'div',
1506
+ { className: 'dss-pager' },
1507
+ h('button', { type: 'button', className: 'dss-btn dss-small dss-ghost', disabled: current <= 1, onClick: () => go(1) }, t('share.pageFirst')),
1508
+ h('button', { type: 'button', className: 'dss-btn dss-small dss-ghost', disabled: current <= 1, onClick: () => go(current - 1) }, t('share.pagePrev')),
1509
+ h(
1510
+ 'span',
1511
+ { className: 'dss-pager-pos' },
1512
+ h('input', {
1513
+ type: 'number',
1514
+ min: 1,
1515
+ max: pageCount,
1516
+ className: 'dss-input dss-pager-input',
1517
+ value: current,
1518
+ onChange: event => {
1519
+ const value = Number.parseInt(event.target.value, 10)
1520
+ if (Number.isFinite(value)) go(value)
1521
+ },
1522
+ }),
1523
+ h('span', { className: 'dss-pager-total' }, `/ ${pageCount}`),
1524
+ ),
1525
+ h('button', { type: 'button', className: 'dss-btn dss-small dss-ghost', disabled: current >= pageCount, onClick: () => go(current + 1) }, t('share.pageNext')),
1526
+ h('button', { type: 'button', className: 'dss-btn dss-small dss-ghost', disabled: current >= pageCount, onClick: () => go(pageCount) }, t('share.pageLast')),
1527
+ )
1528
+ : null,
1394
1529
  )
1395
1530
  }
1396
1531
 
1397
- function SelectionToolbar({ t, outline, selected, onAll, onNone }) {
1532
+ function SelectionToolbar({ t, outline, selected, onAll, onNone, onRecent }) {
1398
1533
  const total = outline?.turns?.length ?? 0
1534
+ // A "last N turns" box, because "select all" then unticking forty checkboxes is
1535
+ // not a workflow. The number is a shortcut into the SAME selection set — it does
1536
+ // not change what gets exported, only which rows start ticked.
1537
+ const [recent, setRecent] = useState('')
1538
+ const apply = () => {
1539
+ const count = Number.parseInt(recent, 10)
1540
+ if (!Number.isFinite(count) || count <= 0) return
1541
+ onRecent(count)
1542
+ }
1399
1543
  return h(
1400
1544
  'div',
1401
1545
  { className: 'dss-toolbar' },
1402
1546
  h('span', { className: 'dss-group-title' }, t('share.selected', { count: selected.size, total })),
1403
1547
  h('button', { type: 'button', className: 'dss-btn dss-small dss-ghost', onClick: onAll }, t('share.all')),
1404
1548
  h('button', { type: 'button', className: 'dss-btn dss-small dss-ghost', onClick: onNone }, t('share.none')),
1549
+ h(
1550
+ 'div',
1551
+ { className: 'dss-recent' },
1552
+ h('span', { className: 'dss-recent-label' }, t('share.recentLabel')),
1553
+ h('input', {
1554
+ type: 'number',
1555
+ min: 1,
1556
+ max: total,
1557
+ className: 'dss-input dss-recent-input',
1558
+ value: recent,
1559
+ placeholder: String(Math.min(10, total || 10)),
1560
+ onChange: event => setRecent(event.target.value),
1561
+ onKeyDown: event => {
1562
+ if (event.key === 'Enter') apply()
1563
+ },
1564
+ }),
1565
+ h('button', { type: 'button', className: 'dss-btn dss-small', onClick: apply }, t('share.recentApply')),
1566
+ ),
1405
1567
  )
1406
1568
  }
1407
1569
 
@@ -1857,7 +2019,7 @@ window.__ModuleLoader__.load({
1857
2019
  function ShareDialog({ state, onClose }) {
1858
2020
  const t = state.t ?? (key => key)
1859
2021
  const { loading, outline, error } = useOutline(state.sessionId)
1860
- const { selected, toggle, all, none } = useTurnSelection(outline)
2022
+ const { selected, toggle, all, none, recent } = useTurnSelection(outline)
1861
2023
  const [busy, setBusy] = useState(null)
1862
2024
  const [note, setNote] = useState(null)
1863
2025
  const [finalOnly, setFinalOnly] = useState(true)
@@ -1923,7 +2085,7 @@ window.__ModuleLoader__.load({
1923
2085
  : h(
1924
2086
  React.Fragment,
1925
2087
  null,
1926
- h(SelectionToolbar, { t, outline, selected, onAll: all, onNone: none }),
2088
+ h(SelectionToolbar, { t, outline, selected, onAll: all, onNone: none, onRecent: recent }),
1927
2089
  h('div', { className: 'dss-dialog-scroll dss-picker-scroll' }, h(TurnPicker, { outline, selected, onToggle: toggle, t })),
1928
2090
  h(
1929
2091
  'label',
@@ -1979,7 +2141,7 @@ window.__ModuleLoader__.load({
1979
2141
  function SegmentDialog({ state, onClose }) {
1980
2142
  const t = state.t ?? (key => key)
1981
2143
  const { loading, outline, error } = useOutline(state.sessionId)
1982
- const { selected, toggle, all, none } = useTurnSelection(outline)
2144
+ const { selected, toggle, all, none, recent } = useTurnSelection(outline)
1983
2145
  const [busy, setBusy] = useState(false)
1984
2146
  const [problem, setProblem] = useState(null)
1985
2147
 
@@ -2020,7 +2182,7 @@ window.__ModuleLoader__.load({
2020
2182
  : h(
2021
2183
  React.Fragment,
2022
2184
  null,
2023
- h(SelectionToolbar, { t, outline, selected, onAll: all, onNone: none }),
2185
+ h(SelectionToolbar, { t, outline, selected, onAll: all, onNone: none, onRecent: recent }),
2024
2186
  h('div', { className: 'dss-dialog-scroll dss-picker-scroll' }, h(TurnPicker, { outline, selected, onToggle: toggle, t })),
2025
2187
  partial ? h('div', { className: 'dss-warn' }, t('segment.warning')) : null,
2026
2188
  picked.length === turns.length && turns.length > 0 ? h('div', { className: 'dss-warn' }, t('segment.whole')) : null,
@@ -3103,8 +3265,10 @@ window.__ModuleLoader__.load({
3103
3265
  'div',
3104
3266
  { className: 'dss-nav' },
3105
3267
  row('snapshots', t('panel.snapshots'), t('panel.snapshotsHint'), counts?.snapshots),
3106
- // The memory screen is omitted entirely when the engine is off: it would
3107
- // open onto a disabled backend and report LONGTERM_DISABLED in red.
3268
+ // The memory screen is shown whenever the engine is running. It was hidden
3269
+ // for a while when the engine was off by default; now that the engine is on
3270
+ // by default again, the row belongs here unconditionally — the engine is
3271
+ // this bundle's own long-term memory, not a competing plugin's.
3108
3272
  counts?.memoryEnabled === true
3109
3273
  ? row('memory', t('panel.memory'), t('panel.memoryHint'), counts?.memories)
3110
3274
  : null,
@@ -3245,7 +3409,6 @@ window.__ModuleLoader__.load({
3245
3409
  const rev = useStore(revision)
3246
3410
  const [tab, setTab] = useState('all')
3247
3411
  const [items, setItems] = useState(null)
3248
- const [pending, setPending] = useState([])
3249
3412
  const [settings, setSettings] = useState(null)
3250
3413
  const [error, setError] = useState(null)
3251
3414
  const [draft, setDraft] = useState('')
@@ -3274,20 +3437,12 @@ window.__ModuleLoader__.load({
3274
3437
 
3275
3438
  // Which models can be called, loaded from the Host rather than from config.
3276
3439
  //
3277
- // The list used to come from `settings.memoryModels`, which is a patch value —
3278
- // and this profile has no patch entry, so the picker offered only "off" and
3279
- // "use current" while the user had several working providers in
3280
- // Settings → 模型. The Host knows what it can actually call; ask it.
3281
- const [models, setModels] = useState(null)
3282
- useEffect(() => {
3283
- let alive = true
3284
- call({ action: 'longterm.models' })
3285
- .then(list => alive && setModels(Array.isArray(list) ? list : []))
3286
- .catch(() => alive && setModels([]))
3287
- return () => {
3288
- alive = false
3289
- }
3290
- }, [rev])
3440
+ // The model list is no longer fetched.
3441
+ //
3442
+ // It existed only to populate the "model for automatic extraction" picker, which
3443
+ // is gone along with the background extraction pipeline. Asking the Host for the
3444
+ // full model catalogue on every panel refresh was a real cost for a control that
3445
+ // no longer exists.
3291
3446
 
3292
3447
  const detected = useWorkspacePath()
3293
3448
 
@@ -3331,14 +3486,12 @@ window.__ModuleLoader__.load({
3331
3486
  ...(tagFilter ? { tag: tagFilter } : {}),
3332
3487
  ...(sinceFilter ? { sinceDays: Number(sinceFilter) } : {}),
3333
3488
  }),
3334
- call({ action: 'longterm.pending', cwd }),
3335
3489
  call({ action: 'longterm.settings' }),
3336
3490
  call({ action: 'longterm.status' }).catch(() => null),
3337
3491
  ])
3338
- .then(([list, waiting, config, status]) => {
3492
+ .then(([list, config, status]) => {
3339
3493
  if (!alive) return
3340
3494
  setItems(list)
3341
- setPending(waiting)
3342
3495
  setSettings(config)
3343
3496
  setMaint(status)
3344
3497
  })
@@ -3478,141 +3631,17 @@ window.__ModuleLoader__.load({
3478
3631
  // because a hook that never installed cannot write a trace — that is the whole
3479
3632
  // point: "no trace" has two causes and this separates them.
3480
3633
  const installStatus = Array.isArray(status?.install) ? status.install : []
3481
- const pendingBlock =
3482
- pending.length > 0
3483
- ? h(
3484
- 'div',
3485
- { className: 'dss-mem-pending' },
3486
- h('div', { className: 'dss-group-title' }, t('memory.pendingTitle')),
3487
- h('div', { className: 'dss-mem-pending-hint' }, t('memory.pendingHint')),
3488
- ...pending.map(item =>
3489
- h(
3490
- 'div',
3491
- { className: 'dss-row', key: item.id },
3492
- h(
3493
- 'div',
3494
- { className: 'dss-row-main' },
3495
- h('div', { className: 'dss-memory-text' }, item.text),
3496
- h('div', { className: 'dss-row-meta' }, h('span', { className: 'dss-badge' }, item.scope === 'global' ? t('memory.global') : workspaceLabel(item.workspaceKey))),
3497
- ),
3498
- h(
3499
- 'div',
3500
- { className: 'dss-row-actions' },
3501
- h(
3502
- 'button',
3503
- {
3504
- type: 'button',
3505
- className: 'dss-btn dss-small dss-primary',
3506
- disabled: busy === `keep:${item.id}`,
3507
- onClick: () => run(`keep:${item.id}`, () => call({ action: 'longterm.settle', id: item.id, accept: true }), t('memory.accepted')),
3508
- },
3509
- t('memory.accept'),
3510
- ),
3511
- h(
3512
- 'button',
3513
- {
3514
- type: 'button',
3515
- className: 'dss-btn dss-small dss-ghost',
3516
- disabled: busy === `drop:${item.id}`,
3517
- onClick: () => run(`drop:${item.id}`, () => call({ action: 'longterm.settle', id: item.id, accept: false }), t('memory.rejected')),
3518
- },
3519
- t('memory.reject'),
3520
- ),
3521
- ),
3522
- ),
3523
- ),
3524
- )
3525
- : h(
3526
- // Always render this block, even when extraction has never run.
3527
- //
3528
- // It used to render only when a trace existed, so a freshly started
3529
- // Host showed NOTHING here — indistinguishable from a broken feature,
3530
- // and exactly the question the user then asked. An empty state has to
3531
- // say why it is empty.
3532
- 'div',
3533
- { className: 'dss-mem-pending' },
3534
- h('div', { className: 'dss-group-title' }, t('memory.pendingTitle')),
3535
- h(
3536
- 'div',
3537
- { className: 'dss-mem-pending-hint' },
3538
- extraction
3539
- ? t('memory.extractionIdle', {
3540
- reason: extraction.reason || t('memory.extractionUnknown'),
3541
- })
3542
- : t('memory.extractionNotYet'),
3543
- ),
3544
- extraction?.candidates?.length
3545
- ? h(
3546
- 'div',
3547
- { className: 'dss-row-meta' },
3548
- h('span', null, t('memory.extractionTried', { n: extraction.candidates.length })),
3549
- extraction.queued !== undefined
3550
- ? h('span', null, t('memory.extractionQueued', { n: extraction.queued }))
3551
- : null,
3552
- extraction.rejected !== undefined
3553
- ? h('span', null, t('memory.extractionRejected', { n: extraction.rejected }))
3554
- : null,
3555
- )
3556
- : null,
3557
- // Raw counts of what the Host actually delivered, so "nothing waiting"
3558
- // can be told apart from "the hook never fired". Without this the two
3559
- // states look identical on screen.
3560
- //
3561
- // `registered` is the third state: the hook was never installed at all
3562
- // (the optional registration threw), which is a different bug from
3563
- // "installed but never called".
3564
- //
3565
- // This block renders even when `extraction` is null, and says so. The
3566
- // previous version was gated on `extraction` being present — so a Host
3567
- // that never recorded a trace (registration failed, or the status
3568
- // endpoint reads a different engine) showed nothing at all, which is
3569
- // exactly the case the diagnostic existed to expose.
3570
- //
3571
- // Rendered at a readable size rather than in the row-meta micro type:
3572
- // it is a diagnostic, and a diagnostic nobody can find is the same as
3573
- // no diagnostic. The 11px meta line was reported as "I don't see it".
3574
- h(
3575
- 'div',
3576
- { className: 'dss-extraction-diag' },
3577
- h('span', { className: 'dss-diag-title' }, t('memory.diagTitle')),
3578
- h(
3579
- 'span',
3580
- null,
3581
- extraction
3582
- ? t('memory.extractionEvents', { n: extraction.eventsSeen ?? 0 })
3583
- : t('memory.extractionNoTrace'),
3584
- ),
3585
- extraction
3586
- ? h('span', null, t('memory.extractionTurns', { n: extraction.turnEndsSeen ?? 0 }))
3587
- : null,
3588
- extraction
3589
- ? h(
3590
- 'span',
3591
- null,
3592
- extraction.registered
3593
- ? t('memory.extractionRegistered')
3594
- : t('memory.extractionNotRegistered'),
3595
- )
3596
- : null,
3597
- // The registration ledger: which optional hooks installed and which
3598
- // threw. This is the only way to see a step that never ran, because the
3599
- // code that would have reported it is the code that failed.
3600
- installStatus.length > 0
3601
- ? h(
3602
- 'span',
3603
- { className: 'dss-diag-steps' },
3604
- t('memory.diagSteps', {
3605
- ok: installStatus.filter(step => step.ok).length,
3606
- total: installStatus.length,
3607
- }),
3608
- installStatus
3609
- .filter(step => !step.ok)
3610
- .map(step => ` · ${step.label}: ${step.error ?? 'failed'}`)
3611
- .join(''),
3612
- )
3613
- : null,
3614
- ),
3615
- )
3634
+ // The "suggested memories" panel is REMOVED.
3635
+ //
3636
+ // It existed to review suggestions produced by a per-turn extraction pipeline —
3637
+ // a background model call that never once produced a suggestion across seven
3638
+ // attempts, and whose replacement crashed the Host. Memories are now written
3639
+ // directly by the model calling the `remember` tool, so there is nothing to
3640
+ // "confirm": a stored memory is already a memory, visible in the list below.
3641
+ //
3642
+ // Kept as a null constant rather than deleted outright so the render site below
3643
+ // stays untouched and the diff stays small.
3644
+ const pendingBlock = null
3616
3645
 
3617
3646
  // The list gets its own scroll box.
3618
3647
  //
@@ -3748,40 +3777,13 @@ window.__ModuleLoader__.load({
3748
3777
  //
3749
3778
  // This has to be an explicit choice. An earlier build silently fell back to
3750
3779
  // the Host's default model whenever no chain was configured, which spends the
3751
- // user's tokens once per turn on a model they never picked for this job. A
3752
- // background cost has to be visible and opt-in, so the choice is a control
3753
- // with an explicit "off" that costs nothing.
3754
- h(
3755
- 'div',
3756
- { className: 'dss-curate-field' },
3757
- h('label', { className: 'dss-curate-label' }, t('memory.implicitModel')),
3758
- h(
3759
- 'select',
3760
- {
3761
- className: 'dss-select',
3762
- value: settings?.implicitModel ?? 'off',
3763
- disabled: busy === 'implicitModel',
3764
- onChange: event =>
3765
- run('implicitModel', () =>
3766
- call({ action: 'longterm.settings', patch: { implicitModel: event.target.value } }),
3767
- ),
3768
- },
3769
- h('option', { value: 'off' }, t('memory.implicitModelOff')),
3770
- h('option', { value: 'current' }, t('memory.implicitModelCurrent')),
3771
- // Real models from the Host, grouped by provider so a long list stays
3772
- // readable. `models` is null while loading and [] when the Host reports
3773
- // none — both render no group, which is honest: the two above are the
3774
- // only choices that exist without a callable model.
3775
- ...(Array.isArray(models) ? models : []).map(entry =>
3776
- h(
3777
- 'option',
3778
- { value: `${entry.provider}/${entry.model}`, key: `${entry.provider}/${entry.model}` },
3779
- entry.label ?? `${entry.provider}/${entry.model}`,
3780
- ),
3781
- ),
3782
- ),
3783
- h('div', { className: 'dss-curate-hint' }, t('memory.implicitModelHint')),
3784
- ),
3780
+ // The "model for automatic extraction" selector is REMOVED.
3781
+ //
3782
+ // It configured which model a background per-turn extraction call would use.
3783
+ // That pipeline no longer exists: memories are written by the model calling the
3784
+ // `remember` tool mid-conversation, so there is no separate model to choose and
3785
+ // no background cost to authorise. A control that configures nothing is worse
3786
+ // than no control — it implies a cost that is not being incurred.
3785
3787
  h(
3786
3788
  'button',
3787
3789
  {
package/cordis.patch.yml CHANGED
@@ -34,19 +34,14 @@
34
34
  stripStepTags: true
35
35
 
36
36
  # ---------- long-term memory ----------
37
- # OFF by default. The session half of this bundle (snapshots, rewind,
38
- # delete, share, export, archive) does not need it and is always on.
37
+ # ON. This bundle owns its own long-term memory: the recall injection, the
38
+ # memory tool, the workspace+global stores, and the management UI.
39
39
  #
40
- # Why off: dsh-fish-memory registers the SAME injection point —
41
- # `systemPrompt.context({ order: 900 })` — so with both plugins enabled
42
- # every request carries two memory blocks. That doubles the injected
43
- # tokens and hands the model two possibly-contradictory fact sets.
44
- # fish-memory's implicit extraction demonstrably works; ours never
45
- # produced a single suggestion across seven fix attempts and, in the
46
- # last one, crashed the Host with a 500ms polling timer. The memory
47
- # engine therefore stays available but dormant: set this to true only
48
- # after disabling fish-memory's injection.
49
- longTermMemory: false
40
+ # dsh-fish-memory also registers `systemPrompt.context({ order: 900 })`, so
41
+ # running BOTH with injection enabled puts two memory blocks in every request.
42
+ # fish-memory is installed for architectural reference only and is not enabled
43
+ # in this profile, so there is no collision to avoid.
44
+ longTermMemory: true
50
45
  # Model chain used for implicit extraction (one cheap call per turn).
51
46
  # Tried in order until one answers; a 429 or an unreachable provider just
52
47
  # moves on to the next entry. All of these are free tiers.
@@ -1725,6 +1725,70 @@ export class MemoryEngine {
1725
1725
  return { removed }
1726
1726
  }
1727
1727
 
1728
+ /**
1729
+ * Remove every durable fact that came from one session.
1730
+ *
1731
+ * Deleting a session used to leave its long-term memories behind: the per-session
1732
+ * ledger was purged (lib/memory.js) but the durable library was not, because the two
1733
+ * are different stores. A fact carries `sessionId` of the conversation that produced
1734
+ * it, so those rows survived as orphans — invisible in the session, still injected
1735
+ * into later prompts, and impossible to attribute once the session was gone.
1736
+ *
1737
+ * Scope matters: only entries whose `sessionId` matches are removed. Facts the user
1738
+ * promoted to `global` deliberately, and facts with no sessionId at all (imported, or
1739
+ * written by the tool outside a session), are left alone.
1740
+ *
1741
+ * @param {string} sessionId session whose facts should go
1742
+ * @returns {Promise<{ removed: number, workspaces: number }>} what was deleted
1743
+ */
1744
+ async purgeSession(sessionId) {
1745
+ const target = String(sessionId ?? '')
1746
+ if (!target) return { removed: 0, workspaces: 0 }
1747
+
1748
+ let removed = 0
1749
+ let workspaces = 0
1750
+
1751
+ /** @param {string} scope @param {string} key @returns {Promise<number>} rows removed */
1752
+ const purgeOne = async (scope, key) => {
1753
+ const store = await this.#readStore(scope, key)
1754
+ if (!Array.isArray(store.entries) || store.entries.length === 0) return 0
1755
+ const kept = store.entries.filter(entry => String(entry.sessionId ?? '') !== target)
1756
+ const dropped = store.entries.length - kept.length
1757
+ if (dropped === 0) return 0
1758
+ store.entries = kept
1759
+ await this.#writeStore(scope, key, store)
1760
+
1761
+ // The cold store holds compressed/archived rows; they belong to the same fact and
1762
+ // must go too, or the session's content survives in a quieter place.
1763
+ const cold = await this.#readCold(scope, key)
1764
+ if (Array.isArray(cold.entries) && cold.entries.length > 0) {
1765
+ const coldKept = cold.entries.filter(entry => String(entry.sessionId ?? '') !== target)
1766
+ if (coldKept.length !== cold.entries.length) {
1767
+ await this.#writeCold(scope, key, { version: 2, scope, entries: coldKept })
1768
+ }
1769
+ }
1770
+ return dropped
1771
+ }
1772
+
1773
+ // Global store first: a fact written from this session may have been promoted.
1774
+ removed += await purgeOne('global', 'global')
1775
+
1776
+ // Then every registered workspace. Enumerating them (rather than purging only the
1777
+ // caller's cwd) is deliberate: the session may have written into another workspace.
1778
+ const registered = await this.workspaces().catch(() => [])
1779
+ for (const workspace of registered) {
1780
+ const key = typeof workspace?.key === 'string' ? workspace.key : undefined
1781
+ if (!key) continue
1782
+ const dropped = await purgeOne('workspace', key)
1783
+ if (dropped > 0) {
1784
+ removed += dropped
1785
+ workspaces += 1
1786
+ }
1787
+ }
1788
+
1789
+ return { removed, workspaces }
1790
+ }
1791
+
1728
1792
  /**
1729
1793
  * @param {object} [input] export input
1730
1794
  * @param {string} [input.cwd] workspace
@@ -336,8 +336,43 @@ export function installMemory({ ctx, root, config, log }) {
336
336
  try {
337
337
  return tools.register({
338
338
  name: 'memory',
339
- description:
340
- 'Manage long-term memory. `remember` stores a durable fact or preference (scope: workspace by default, or global). `recall` searches memory. `list` shows scores and decay state. `forget` deletes one entry. Use it when the user states a lasting rule, preference, or convention.',
339
+ // The description IS the extraction policy.
340
+ //
341
+ // There is no background model call: nothing else decides what to remember.
342
+ // The main conversation model reads this text and decides whether to call
343
+ // `remember` at all, so every rule about WHAT is worth keeping has to live
344
+ // here. dsh-fish-memory works the same way — its "implicit memory" is a tool
345
+ // description the model acts on, not a pipeline — and that is why its version
346
+ // works while a per-turn timer never did.
347
+ //
348
+ // Three rules matter most and all three were missing:
349
+ // • one memory = one fact (a packed sentence becomes unsearchable);
350
+ // • record silently, without announcing it in the reply;
351
+ // • record the user's durable facts, not your own actions.
352
+ description: [
353
+ 'Long-term memory that survives across sessions. Call `remember` when the',
354
+ 'user states something durable; use `recall` to search, `list` to inspect,',
355
+ '`forget` to delete, `freeze` to pin.',
356
+ '',
357
+ 'WHEN TO REMEMBER (decide yourself, every turn — the user will not ask):',
358
+ '- a stated preference, habit, or dislike ("I prefer X", "never do Y")',
359
+ '- a durable fact about the user, their work, or their environment',
360
+ '- a rule or convention they set for how you should behave',
361
+ '- a decision that was reached, with its reason',
362
+ '- a mistake that was made and the lesson that came out of it',
363
+ '',
364
+ 'WHEN NOT TO: small talk, one-off requests, anything already in the library,',
365
+ 'and anything you merely did yourself. Your own actions are not memories.',
366
+ '',
367
+ 'HOW TO WRITE ONE:',
368
+ '- ONE memory = ONE fact. "She is 170cm, weighs 52kg, sleeps on her side" is',
369
+ ' THREE memories, not one — packed entries cannot be matched individually.',
370
+ '- Under 300 characters. If it is longer, split it.',
371
+ '- Write it so a future search will find it: keep the concrete nouns.',
372
+ '- Do NOT say "I noted that" or "got it, remembered" in your reply. Record',
373
+ ' quietly; announcing it reads like a status report.',
374
+ '- Prefer workspace scope; use global only for things true everywhere.',
375
+ ].join('\n'),
341
376
  parameters: {
342
377
  type: 'object',
343
378
  properties: {
package/lib/service.js CHANGED
@@ -77,31 +77,44 @@ export class SessionSuiteService {
77
77
  // "Cannot read properties of undefined (reading 'throwIfAborted')" rather than
78
78
  // defaulting to "no cancellation". Pass a real AbortSignal.
79
79
  //
80
- // A single call returns ONE PAGE, not the whole session. The share dialog showed
81
- // "3/3 turns" for a conversation with 129 — the reader had been given the last
82
- // page and nothing else. Page backwards until the reader reports no more, so
83
- // callers get the complete history they were always promised.
80
+ // A single call returns ONE PAGE. Read the Host implementation
81
+ // (dsh-api-session-controller/lib/index.js) to get this exactly right:
82
+ //
83
+ // resolveHistoryRequest: maxMessages defaults to 50.
84
+ // history(): cursor = throughSeq ?? observation.cursor // the LOG END, not a page edge
85
+ // paginate(): end = min(throughSeq + 1, beforeSeq ?? throughSeq + 1)
86
+ // scans backward, stops once maxMessages messages are collected,
87
+ // cut = the seq of this page's FIRST message,
88
+ // returns { events: slice(cut, end), hasMore: cut > 0 }
89
+ //
90
+ // The trap: `page.cursor` is the log end, NOT the page boundary. Stepping
91
+ // `beforeSeq = cursor - 1` therefore asks for almost the same window again — which
92
+ // is why a 145-turn session showed 9 turns. The correct next cursor is the seq of
93
+ // the page's FIRST event: everything at or above it has been read.
84
94
  const signal = new AbortController().signal
85
95
  const collected = []
96
+ const maxMessages = 200
86
97
  let beforeSeq
87
98
  let guard = 0
88
99
  while (guard < 400) {
89
100
  guard += 1
90
- const request = { address: { kind: 'session', sessionId } }
101
+ const request = { address: { kind: 'session', sessionId }, maxMessages }
91
102
  if (typeof beforeSeq === 'number') request.beforeSeq = beforeSeq
92
103
  const page = await controller.events(request, signal)
93
104
  const records = Array.isArray(page?.records) ? page.records : []
94
105
  const events = records
95
106
  .map(record => (record?.type === 'event' ? record.event : record?.event ?? record))
96
107
  .filter(Boolean)
97
- // Pages arrive newest-first or oldest-first depending on the cursor; collecting
98
- // in order and de-duplicating by seq at the end keeps this independent of which.
99
108
  collected.push(...events)
100
109
  if (!page?.hasMore || records.length === 0) break
101
- // The cursor is the inclusive boundary of the page just read.
102
- const next = typeof page?.cursor === 'number' ? page.cursor : undefined
103
- if (next === undefined || next === beforeSeq) break
104
- beforeSeq = next
110
+ // The oldest seq in this page is the new exclusive upper bound.
111
+ const lowest = events.reduce(
112
+ (min, event) => (typeof event?.seq === 'number' && event.seq < min ? event.seq : min),
113
+ Number.POSITIVE_INFINITY,
114
+ )
115
+ if (!Number.isFinite(lowest)) break
116
+ if (typeof beforeSeq === 'number' && lowest >= beforeSeq) break
117
+ beforeSeq = lowest
105
118
  }
106
119
  const seen = new Map()
107
120
  for (const event of collected) {
@@ -871,6 +884,18 @@ export class SessionSuiteService {
871
884
  removed.checkpoints = true
872
885
  await this.memory.purge(sessionId).catch(() => {})
873
886
  removed.memory = true
887
+ // The durable library is a SEPARATE store from the per-session ledger above, and it
888
+ // was never purged — so deleting a session left its long-term facts behind as
889
+ // orphans: still injected into later prompts, and unattributable once the session
890
+ // was gone. Facts carry `sessionId`, so they can be removed precisely.
891
+ //
892
+ // Guarded separately: a Host without the long-term engine must still be able to
893
+ // delete a session.
894
+ try {
895
+ removed.durableMemory = await this.longTerm?.purgeSession?.(sessionId)
896
+ } catch (error) {
897
+ this.log(`could not purge durable memory for ${sessionId}`, error)
898
+ }
874
899
  for (const undo of await this.listUndos(sessionId)) await this.#discardUndo(undo.id).catch(() => {})
875
900
 
876
901
  // Finally, drop the archive mark itself.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heixui/dsh-session-suite",
3
- "version": "0.8.0",
3
+ "version": "0.8.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",