@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 +200 -163
- package/cordis.patch.yml +7 -12
- package/lib/memory-host.js +37 -2
- package/lib/service.js +24 -11
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
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
|
|
3107
|
-
//
|
|
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,
|
|
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
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
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
|
-
#
|
|
38
|
-
#
|
|
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
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
|
|
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.
|
package/lib/memory-host.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
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
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
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.
|
|
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",
|