@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 +213 -211
- package/cordis.patch.yml +7 -12
- package/lib/memory-engine.js +64 -0
- package/lib/memory-host.js +37 -2
- package/lib/service.js +36 -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('')
|
|
@@ -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
|
|
3278
|
-
//
|
|
3279
|
-
//
|
|
3280
|
-
//
|
|
3281
|
-
|
|
3282
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
)
|
|
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
|
-
//
|
|
3752
|
-
//
|
|
3753
|
-
//
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
|
|
3757
|
-
|
|
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
|
-
#
|
|
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-engine.js
CHANGED
|
@@ -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
|
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) {
|
|
@@ -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.
|
|
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",
|