@heixui/dsh-session-suite 0.4.3 → 0.5.0
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 +81 -19
- package/cordis.patch.yml +8 -0
- package/lib/memory-host.js +94 -4
- package/package.json +1 -1
package/client.js
CHANGED
|
@@ -268,6 +268,10 @@ window.__ModuleLoader__.load({
|
|
|
268
268
|
'memory.extractionRejected': '{n} rejected as unusable',
|
|
269
269
|
'memory.extractionEvents': '{n} session event(s) received',
|
|
270
270
|
'memory.extractionTurns': '{n} turn(s) ended',
|
|
271
|
+
'memory.extractionRegistered': 'hook registered',
|
|
272
|
+
'memory.extractionNotRegistered': 'HOOK NOT REGISTERED',
|
|
273
|
+
'memory.extractionNoTrace': 'no extraction trace recorded by this Host',
|
|
274
|
+
'memory.diagTitle': 'Extraction diagnostics:',
|
|
271
275
|
'memory.accept': 'Keep',
|
|
272
276
|
'memory.reject': 'Discard',
|
|
273
277
|
'memory.accepted': 'Kept',
|
|
@@ -512,6 +516,10 @@ window.__ModuleLoader__.load({
|
|
|
512
516
|
'memory.extractionRejected': '剔除 {n} 条',
|
|
513
517
|
'memory.extractionEvents': '收到 {n} 个会话事件',
|
|
514
518
|
'memory.extractionTurns': '结束 {n} 轮',
|
|
519
|
+
'memory.extractionRegistered': '钩子已注册',
|
|
520
|
+
'memory.extractionNotRegistered': '钩子未注册',
|
|
521
|
+
'memory.extractionNoTrace': '本次运行没有记录到提取痕迹',
|
|
522
|
+
'memory.diagTitle': '提取诊断:',
|
|
515
523
|
'memory.accept': '保留',
|
|
516
524
|
'memory.reject': '丢弃',
|
|
517
525
|
'memory.accepted': '已保留',
|
|
@@ -1022,6 +1030,23 @@ window.__ModuleLoader__.load({
|
|
|
1022
1030
|
overflow-y: auto;
|
|
1023
1031
|
overscroll-behavior: contain;
|
|
1024
1032
|
}
|
|
1033
|
+
/* The extraction diagnostic. Deliberately larger than the meta text around it and set
|
|
1034
|
+
on its own tinted strip: it exists to answer "why is the queue empty", and the first
|
|
1035
|
+
version was 11px grey text inside a bordered box that the user reported not seeing
|
|
1036
|
+
at all. A diagnostic that cannot be found is not a diagnostic. */
|
|
1037
|
+
.dss-extraction-diag {
|
|
1038
|
+
display: flex; flex-wrap: wrap; gap: 6px 16px; align-items: baseline;
|
|
1039
|
+
margin-top: 8px; padding: 8px 12px;
|
|
1040
|
+
border-radius: 8px;
|
|
1041
|
+
background: var(--dsw-alias-bg-layer-2, #f2f3f5);
|
|
1042
|
+
font-size: 12.5px; line-height: 1.5;
|
|
1043
|
+
color: var(--dsw-alias-label-secondary, #4a4a4a);
|
|
1044
|
+
font-variant-numeric: tabular-nums;
|
|
1045
|
+
}
|
|
1046
|
+
.dss-diag-title {
|
|
1047
|
+
font-weight: 600;
|
|
1048
|
+
color: var(--dsw-alias-label-primary, #1f1f1f);
|
|
1049
|
+
}
|
|
1025
1050
|
/* The summary charts. Two narrow panels for composition and health, one wide panel
|
|
1026
1051
|
for tags: composition and health are short lists, tags can run long. Bars are divs,
|
|
1027
1052
|
not SVG, so they inherit the theme and stay legible in both palettes. */
|
|
@@ -1046,17 +1071,9 @@ window.__ModuleLoader__.load({
|
|
|
1046
1071
|
line-height: 1.35;
|
|
1047
1072
|
overflow-wrap: anywhere;
|
|
1048
1073
|
}
|
|
1049
|
-
/* Composition and tag bars get distinct hues
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
had to go back to the label each time. Colour-coding by position makes the rows
|
|
1053
|
-
separately trackable while staying inside the theme's palette. */
|
|
1054
|
-
.dss-tone-c0 { background: #4d6bfe; }
|
|
1055
|
-
.dss-tone-c1 { background: #7b5cf5; }
|
|
1056
|
-
.dss-tone-c2 { background: #2aa6c9; }
|
|
1057
|
-
.dss-tone-c3 { background: #14a37f; }
|
|
1058
|
-
.dss-tone-c4 { background: #e0a03a; }
|
|
1059
|
-
.dss-tone-c5 { background: #d9636a; }
|
|
1074
|
+
/* Composition and tag bars get distinct hues — declared at the END of the sheet, next
|
|
1075
|
+
to the fill rule, because source order decides between two single-class rules and an
|
|
1076
|
+
earlier declaration lost to the fill's own background. */
|
|
1060
1077
|
.dss-statbar-track {
|
|
1061
1078
|
flex: 1; height: 8px; min-width: 0;
|
|
1062
1079
|
background: var(--dsw-alias-bg-layer-2, #f2f3f5);
|
|
@@ -1121,6 +1138,21 @@ window.__ModuleLoader__.load({
|
|
|
1121
1138
|
.dss-row-actions { display: flex; align-items: center; gap: 4px; flex: none; }
|
|
1122
1139
|
.dss-spin { width: 14px; height: 14px; border-radius: 50%; border: 2px solid currentColor; border-top-color: transparent; animation: dss-spin .8s linear infinite; }
|
|
1123
1140
|
@keyframes dss-spin { to { transform: rotate(360deg); } }
|
|
1141
|
+
/* Colour overrides, last in the sheet on purpose.
|
|
1142
|
+
The first version put the hue classes before the fill rule, which sets a brand
|
|
1143
|
+
background — so every bar rendered the same near-black colour and the chart looked
|
|
1144
|
+
like five copies of one object. Source order decides here, not specificity: both are
|
|
1145
|
+
single-class selectors. Declared last so the hue wins, and the double-class form for
|
|
1146
|
+
the extra specificity that makes it survive a future rule added above. */
|
|
1147
|
+
.dss-statbar-fill.dss-tone-c0 { background: #4d6bfe; }
|
|
1148
|
+
.dss-statbar-fill.dss-tone-c1 { background: #7b5cf5; }
|
|
1149
|
+
.dss-statbar-fill.dss-tone-c2 { background: #2aa6c9; }
|
|
1150
|
+
.dss-statbar-fill.dss-tone-c3 { background: #14a37f; }
|
|
1151
|
+
.dss-statbar-fill.dss-tone-c4 { background: #e0a03a; }
|
|
1152
|
+
.dss-statbar-fill.dss-tone-c5 { background: #d9636a; }
|
|
1153
|
+
.dss-statbar-fill.dss-tone-good { background: var(--dsw-alias-state-success-primary, #1a9c50); }
|
|
1154
|
+
.dss-statbar-fill.dss-tone-warn { background: var(--dsw-alias-state-warning-primary, #d97706); }
|
|
1155
|
+
.dss-statbar-fill.dss-tone-bad { background: var(--dsw-alias-state-error-primary, #d93025); }
|
|
1124
1156
|
`
|
|
1125
1157
|
|
|
1126
1158
|
function installStyles() {
|
|
@@ -3496,14 +3528,44 @@ window.__ModuleLoader__.load({
|
|
|
3496
3528
|
// Raw counts of what the Host actually delivered, so "nothing waiting"
|
|
3497
3529
|
// can be told apart from "the hook never fired". Without this the two
|
|
3498
3530
|
// states look identical on screen.
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3531
|
+
//
|
|
3532
|
+
// `registered` is the third state: the hook was never installed at all
|
|
3533
|
+
// (the optional registration threw), which is a different bug from
|
|
3534
|
+
// "installed but never called".
|
|
3535
|
+
//
|
|
3536
|
+
// This block renders even when `extraction` is null, and says so. The
|
|
3537
|
+
// previous version was gated on `extraction` being present — so a Host
|
|
3538
|
+
// that never recorded a trace (registration failed, or the status
|
|
3539
|
+
// endpoint reads a different engine) showed nothing at all, which is
|
|
3540
|
+
// exactly the case the diagnostic existed to expose.
|
|
3541
|
+
//
|
|
3542
|
+
// Rendered at a readable size rather than in the row-meta micro type:
|
|
3543
|
+
// it is a diagnostic, and a diagnostic nobody can find is the same as
|
|
3544
|
+
// no diagnostic. The 11px meta line was reported as "I don't see it".
|
|
3545
|
+
h(
|
|
3546
|
+
'div',
|
|
3547
|
+
{ className: 'dss-extraction-diag' },
|
|
3548
|
+
h('span', { className: 'dss-diag-title' }, t('memory.diagTitle')),
|
|
3549
|
+
h(
|
|
3550
|
+
'span',
|
|
3551
|
+
null,
|
|
3552
|
+
extraction
|
|
3553
|
+
? t('memory.extractionEvents', { n: extraction.eventsSeen ?? 0 })
|
|
3554
|
+
: t('memory.extractionNoTrace'),
|
|
3555
|
+
),
|
|
3556
|
+
extraction
|
|
3557
|
+
? h('span', null, t('memory.extractionTurns', { n: extraction.turnEndsSeen ?? 0 }))
|
|
3558
|
+
: null,
|
|
3559
|
+
extraction
|
|
3560
|
+
? h(
|
|
3561
|
+
'span',
|
|
3562
|
+
null,
|
|
3563
|
+
extraction.registered
|
|
3564
|
+
? t('memory.extractionRegistered')
|
|
3565
|
+
: t('memory.extractionNotRegistered'),
|
|
3566
|
+
)
|
|
3567
|
+
: null,
|
|
3568
|
+
),
|
|
3507
3569
|
)
|
|
3508
3570
|
|
|
3509
3571
|
// The list gets its own scroll box.
|
package/cordis.patch.yml
CHANGED
|
@@ -53,6 +53,14 @@
|
|
|
53
53
|
# the budget would claim six memories are relevant when the evidence
|
|
54
54
|
# says otherwise.
|
|
55
55
|
recallLimit: 6
|
|
56
|
+
# How much of the conversation before the current message is folded into the
|
|
57
|
+
# retrieval query. A follow-up like "那它为什么慢?" has no referent for "它", so
|
|
58
|
+
# searching those characters alone cannot find the memory that answers it.
|
|
59
|
+
# The previous user turns are appended BEFORE the current question, so the
|
|
60
|
+
# question's own terms still dominate the score. 0 disables the behaviour and
|
|
61
|
+
# searches the current message exactly as before.
|
|
62
|
+
contextQueryChars: 160
|
|
63
|
+
contextQueryTurns: 3
|
|
56
64
|
# How many of the most important memories ride along regardless of the
|
|
57
65
|
# question. Without a resident layer, an ordinary follow-up like "继续"
|
|
58
66
|
# shares no vocabulary with any memory and a strict relevance filter
|
package/lib/memory-host.js
CHANGED
|
@@ -175,7 +175,13 @@ export function installMemory({ ctx, root, config, log }) {
|
|
|
175
175
|
log(`explicit memory stored (${instruction.scope})`)
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
-
|
|
178
|
+
// The question, plus enough of the conversation before it to resolve a
|
|
179
|
+
// short follow-up. See contextualQuery: a bare 「那它为什么慢?」 has no
|
|
180
|
+
// referent for 「它」 and cannot match the memory that answers it.
|
|
181
|
+
const question = contextualQuery(messages, {
|
|
182
|
+
chars: typeof config?.contextQueryChars === 'number' ? config.contextQueryChars : undefined,
|
|
183
|
+
turns: typeof config?.contextQueryTurns === 'number' ? config.contextQueryTurns : undefined,
|
|
184
|
+
})
|
|
179
185
|
const text = await buildRecallText({ engine, cwd, question, config })
|
|
180
186
|
if (agent) recallByScope.set(agent, { text, at: Date.now() })
|
|
181
187
|
lastRecall = text
|
|
@@ -201,7 +207,17 @@ export function installMemory({ ctx, root, config, log }) {
|
|
|
201
207
|
)
|
|
202
208
|
|
|
203
209
|
// --- 3. Implicit extraction after a turn --------------------------------
|
|
204
|
-
safe('implicit extraction', () =>
|
|
210
|
+
safe('implicit extraction', () => {
|
|
211
|
+
// A startup marker, recorded before the listener is even registered.
|
|
212
|
+
//
|
|
213
|
+
// The whole question "why is the queue empty" has two halves that look identical
|
|
214
|
+
// on screen: the hook is not registered at all, or it is registered and never
|
|
215
|
+
// called. This records the first half so the trace can distinguish them — the
|
|
216
|
+
// block used to render only after extraction ran, so a dead hook produced no
|
|
217
|
+
// trace and therefore no diagnostics.
|
|
218
|
+
extractionTrace.registered = true
|
|
219
|
+
extractionTrace.registeredAt = Date.now()
|
|
220
|
+
publishExtractionTrace(engine)
|
|
205
221
|
ctx.effect(
|
|
206
222
|
() =>
|
|
207
223
|
ctx.on('session/event', (session, event) => {
|
|
@@ -245,8 +261,8 @@ export function installMemory({ ctx, root, config, log }) {
|
|
|
245
261
|
})
|
|
246
262
|
}),
|
|
247
263
|
'session-suite: implicit memory',
|
|
248
|
-
)
|
|
249
|
-
)
|
|
264
|
+
)
|
|
265
|
+
})
|
|
250
266
|
|
|
251
267
|
// --- 3b. Automatic maintenance -------------------------------------------
|
|
252
268
|
// Scoring, decay, clustering, and cold archiving are otherwise only reachable
|
|
@@ -485,6 +501,80 @@ function lastUserText(messages) {
|
|
|
485
501
|
return ''
|
|
486
502
|
}
|
|
487
503
|
|
|
504
|
+
/**
|
|
505
|
+
* The current question plus the tail of the conversation before it.
|
|
506
|
+
*
|
|
507
|
+
* Retrieval used to search for the last user message alone, so a follow-up like
|
|
508
|
+
* 「那它为什么慢?」 searched for those seven characters and nothing else. "它" is a
|
|
509
|
+
* pronoun with no referent in the query, so the memory that explains the slowness could
|
|
510
|
+
* not be found no matter how well it was written. That is a real gap, not a tuning
|
|
511
|
+
* problem.
|
|
512
|
+
*
|
|
513
|
+
* The shape here is deliberate:
|
|
514
|
+
*
|
|
515
|
+
* • the current question comes LAST, so it dominates the lexical score (BM25 and the
|
|
516
|
+
* coverage route both reward a match in the query; putting context first would let
|
|
517
|
+
* an older turn outvote the thing being asked now);
|
|
518
|
+
* • only the previous USER turns are included, not assistant replies. Assistant text
|
|
519
|
+
* is verbose, and a long reply would drown the query in words the user never typed;
|
|
520
|
+
* • the tail is capped in characters rather than turns, because one long message and
|
|
521
|
+
* ten short ones should not cost the same;
|
|
522
|
+
* • nothing is included when the current question already stands alone (long enough
|
|
523
|
+
* and not pronoun-led), so an ordinary self-contained question is searched exactly
|
|
524
|
+
* as before.
|
|
525
|
+
*
|
|
526
|
+
* This is a heuristic, not a language model. It will not resolve 「那个东西」 back to a
|
|
527
|
+
* noun three turns ago. It does fix the common case — a short follow-up — and it costs
|
|
528
|
+
* nothing at query time.
|
|
529
|
+
*
|
|
530
|
+
* @param {readonly any[]} messages step messages
|
|
531
|
+
* @param {{ chars?: number, turns?: number }} [limits] budget
|
|
532
|
+
* @returns {string} query text
|
|
533
|
+
*/
|
|
534
|
+
function contextualQuery(messages, limits = {}) {
|
|
535
|
+
const current = lastUserText(messages)
|
|
536
|
+
if (!current) return ''
|
|
537
|
+
|
|
538
|
+
const maxChars = limits.chars ?? 160
|
|
539
|
+
const maxTurns = limits.turns ?? 3
|
|
540
|
+
|
|
541
|
+
// A self-contained question needs no help. The test is NOT length alone: a short
|
|
542
|
+
// question that names its own subject ("导出的图片为什么断链") is self-contained,
|
|
543
|
+
// while a long one made of pronouns ("那这个呢,还有刚才说的那个,它到底是怎么回事")
|
|
544
|
+
// is not. Pronouns are the actual signal — they are what a query cannot resolve.
|
|
545
|
+
//
|
|
546
|
+
// An earlier version used `length >= 24`, which was a guess and misclassified a
|
|
547
|
+
// perfectly clear 20-character question as needing context. Pronoun presence is a
|
|
548
|
+
// narrower, more defensible rule: it fires exactly when the query contains a word
|
|
549
|
+
// that refers to something outside itself.
|
|
550
|
+
const PRONOUNS = /(它|他|她|这个|那个|这些|那些|该|此|上述|前面|刚才|上面|之前那个)/
|
|
551
|
+
const looksSelfContained = !PRONOUNS.test(current)
|
|
552
|
+
|
|
553
|
+
const earlier = []
|
|
554
|
+
let budget = maxChars
|
|
555
|
+
let seen = 0
|
|
556
|
+
for (let index = (messages?.length ?? 0) - 1; index >= 0 && seen < maxTurns; index -= 1) {
|
|
557
|
+
const message = messages[index]
|
|
558
|
+
if (message?.role !== 'user') continue
|
|
559
|
+
const text = contentText(message.content)
|
|
560
|
+
if (!text) continue
|
|
561
|
+
seen += 1
|
|
562
|
+
// The newest user message IS `current`; skip it so it is not duplicated.
|
|
563
|
+
if (text === current) continue
|
|
564
|
+
if (budget <= 0) break
|
|
565
|
+
const slice = text.length > budget ? text.slice(-budget) : text
|
|
566
|
+
earlier.unshift(slice)
|
|
567
|
+
budget -= slice.length
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Nothing to add, or the question stands on its own: search it as-is.
|
|
571
|
+
if (earlier.length === 0 || looksSelfContained) return current
|
|
572
|
+
|
|
573
|
+
// Context first, question last. The engine tokenises the whole string, and the
|
|
574
|
+
// question's own terms are what must win.
|
|
575
|
+
return `${earlier.join(' ')} ${current}`.trim()
|
|
576
|
+
}
|
|
577
|
+
|
|
488
578
|
/**
|
|
489
579
|
* Ask a small model call what, if anything, is worth remembering from this turn.
|
|
490
580
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heixui/dsh-session-suite",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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",
|