@heixui/dsh-session-suite 0.8.0 → 0.8.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/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('')
@@ -3331,14 +3494,12 @@ window.__ModuleLoader__.load({
3331
3494
  ...(tagFilter ? { tag: tagFilter } : {}),
3332
3495
  ...(sinceFilter ? { sinceDays: Number(sinceFilter) } : {}),
3333
3496
  }),
3334
- call({ action: 'longterm.pending', cwd }),
3335
3497
  call({ action: 'longterm.settings' }),
3336
3498
  call({ action: 'longterm.status' }).catch(() => null),
3337
3499
  ])
3338
- .then(([list, waiting, config, status]) => {
3500
+ .then(([list, config, status]) => {
3339
3501
  if (!alive) return
3340
3502
  setItems(list)
3341
- setPending(waiting)
3342
3503
  setSettings(config)
3343
3504
  setMaint(status)
3344
3505
  })
@@ -3478,141 +3639,17 @@ window.__ModuleLoader__.load({
3478
3639
  // because a hook that never installed cannot write a trace — that is the whole
3479
3640
  // point: "no trace" has two causes and this separates them.
3480
3641
  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
- )
3642
+ // The "suggested memories" panel is REMOVED.
3643
+ //
3644
+ // It existed to review suggestions produced by a per-turn extraction pipeline —
3645
+ // a background model call that never once produced a suggestion across seven
3646
+ // attempts, and whose replacement crashed the Host. Memories are now written
3647
+ // directly by the model calling the `remember` tool, so there is nothing to
3648
+ // "confirm": a stored memory is already a memory, visible in the list below.
3649
+ //
3650
+ // Kept as a null constant rather than deleted outright so the render site below
3651
+ // stays untouched and the diff stays small.
3652
+ const pendingBlock = null
3616
3653
 
3617
3654
  // The list gets its own scroll box.
3618
3655
  //
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.
@@ -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) {
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.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",