@heixui/dsh-session-suite 0.4.4 → 0.5.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 +74 -10
- package/cordis.patch.yml +15 -0
- package/index.js +4 -0
- package/lib/api.js +8 -2
- package/lib/memory-host.js +101 -1
- package/package.json +1 -1
package/client.js
CHANGED
|
@@ -270,6 +270,9 @@ window.__ModuleLoader__.load({
|
|
|
270
270
|
'memory.extractionTurns': '{n} turn(s) ended',
|
|
271
271
|
'memory.extractionRegistered': 'hook registered',
|
|
272
272
|
'memory.extractionNotRegistered': 'HOOK NOT REGISTERED',
|
|
273
|
+
'memory.extractionNoTrace': 'no extraction trace recorded by this Host',
|
|
274
|
+
'memory.diagTitle': 'Extraction diagnostics:',
|
|
275
|
+
'memory.diagSteps': '{ok}/{total} hooks installed',
|
|
273
276
|
'memory.accept': 'Keep',
|
|
274
277
|
'memory.reject': 'Discard',
|
|
275
278
|
'memory.accepted': 'Kept',
|
|
@@ -516,6 +519,9 @@ window.__ModuleLoader__.load({
|
|
|
516
519
|
'memory.extractionTurns': '结束 {n} 轮',
|
|
517
520
|
'memory.extractionRegistered': '钩子已注册',
|
|
518
521
|
'memory.extractionNotRegistered': '钩子未注册',
|
|
522
|
+
'memory.extractionNoTrace': '本次运行没有记录到提取痕迹',
|
|
523
|
+
'memory.diagTitle': '提取诊断:',
|
|
524
|
+
'memory.diagSteps': '{ok}/{total} 个钩子已装',
|
|
519
525
|
'memory.accept': '保留',
|
|
520
526
|
'memory.reject': '丢弃',
|
|
521
527
|
'memory.accepted': '已保留',
|
|
@@ -1026,6 +1032,24 @@ window.__ModuleLoader__.load({
|
|
|
1026
1032
|
overflow-y: auto;
|
|
1027
1033
|
overscroll-behavior: contain;
|
|
1028
1034
|
}
|
|
1035
|
+
/* The extraction diagnostic. Deliberately larger than the meta text around it and set
|
|
1036
|
+
on its own tinted strip: it exists to answer "why is the queue empty", and the first
|
|
1037
|
+
version was 11px grey text inside a bordered box that the user reported not seeing
|
|
1038
|
+
at all. A diagnostic that cannot be found is not a diagnostic. */
|
|
1039
|
+
.dss-extraction-diag {
|
|
1040
|
+
display: flex; flex-wrap: wrap; gap: 6px 16px; align-items: baseline;
|
|
1041
|
+
margin-top: 8px; padding: 8px 12px;
|
|
1042
|
+
border-radius: 8px;
|
|
1043
|
+
background: var(--dsw-alias-bg-layer-2, #f2f3f5);
|
|
1044
|
+
font-size: 12.5px; line-height: 1.5;
|
|
1045
|
+
color: var(--dsw-alias-label-secondary, #4a4a4a);
|
|
1046
|
+
font-variant-numeric: tabular-nums;
|
|
1047
|
+
}
|
|
1048
|
+
.dss-diag-title {
|
|
1049
|
+
font-weight: 600;
|
|
1050
|
+
color: var(--dsw-alias-label-primary, #1f1f1f);
|
|
1051
|
+
}
|
|
1052
|
+
.dss-diag-steps { color: var(--dsw-alias-label-secondary, #6b6b6b); }
|
|
1029
1053
|
/* The summary charts. Two narrow panels for composition and health, one wide panel
|
|
1030
1054
|
for tags: composition and health are short lists, tags can run long. Bars are divs,
|
|
1031
1055
|
not SVG, so they inherit the theme and stay legible in both palettes. */
|
|
@@ -3428,6 +3452,10 @@ window.__ModuleLoader__.load({
|
|
|
3428
3452
|
// unremarkable" from "every memory model failed" or "a filter ate the
|
|
3429
3453
|
// result". When there is nothing pending, say why.
|
|
3430
3454
|
const extraction = status?.extraction
|
|
3455
|
+
// Which optional hooks installed. Reported separately from the extraction trace
|
|
3456
|
+
// because a hook that never installed cannot write a trace — that is the whole
|
|
3457
|
+
// point: "no trace" has two causes and this separates them.
|
|
3458
|
+
const installStatus = Array.isArray(status?.install) ? status.install : []
|
|
3431
3459
|
const pendingBlock =
|
|
3432
3460
|
pending.length > 0
|
|
3433
3461
|
? h(
|
|
@@ -3511,21 +3539,57 @@ window.__ModuleLoader__.load({
|
|
|
3511
3539
|
// `registered` is the third state: the hook was never installed at all
|
|
3512
3540
|
// (the optional registration threw), which is a different bug from
|
|
3513
3541
|
// "installed but never called".
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3542
|
+
//
|
|
3543
|
+
// This block renders even when `extraction` is null, and says so. The
|
|
3544
|
+
// previous version was gated on `extraction` being present — so a Host
|
|
3545
|
+
// that never recorded a trace (registration failed, or the status
|
|
3546
|
+
// endpoint reads a different engine) showed nothing at all, which is
|
|
3547
|
+
// exactly the case the diagnostic existed to expose.
|
|
3548
|
+
//
|
|
3549
|
+
// Rendered at a readable size rather than in the row-meta micro type:
|
|
3550
|
+
// it is a diagnostic, and a diagnostic nobody can find is the same as
|
|
3551
|
+
// no diagnostic. The 11px meta line was reported as "I don't see it".
|
|
3552
|
+
h(
|
|
3553
|
+
'div',
|
|
3554
|
+
{ className: 'dss-extraction-diag' },
|
|
3555
|
+
h('span', { className: 'dss-diag-title' }, t('memory.diagTitle')),
|
|
3556
|
+
h(
|
|
3557
|
+
'span',
|
|
3558
|
+
null,
|
|
3559
|
+
extraction
|
|
3560
|
+
? t('memory.extractionEvents', { n: extraction.eventsSeen ?? 0 })
|
|
3561
|
+
: t('memory.extractionNoTrace'),
|
|
3562
|
+
),
|
|
3563
|
+
extraction
|
|
3564
|
+
? h('span', null, t('memory.extractionTurns', { n: extraction.turnEndsSeen ?? 0 }))
|
|
3565
|
+
: null,
|
|
3566
|
+
extraction
|
|
3567
|
+
? h(
|
|
3521
3568
|
'span',
|
|
3522
3569
|
null,
|
|
3523
3570
|
extraction.registered
|
|
3524
3571
|
? t('memory.extractionRegistered')
|
|
3525
3572
|
: t('memory.extractionNotRegistered'),
|
|
3526
|
-
)
|
|
3527
|
-
|
|
3528
|
-
:
|
|
3573
|
+
)
|
|
3574
|
+
: null,
|
|
3575
|
+
// The registration ledger: which optional hooks installed and which
|
|
3576
|
+
// threw. This is the only way to see a step that never ran, because the
|
|
3577
|
+
// code that would have reported it is the code that failed.
|
|
3578
|
+
installStatus.length > 0
|
|
3579
|
+
? h(
|
|
3580
|
+
'span',
|
|
3581
|
+
{ className: 'dss-diag-steps' },
|
|
3582
|
+
t('memory.diagSteps', {
|
|
3583
|
+
ok: installStatus.filter(step => step.ok).length,
|
|
3584
|
+
total: installStatus.length,
|
|
3585
|
+
}),
|
|
3586
|
+
installStatus
|
|
3587
|
+
.filter(step => !step.ok)
|
|
3588
|
+
.map(step => ` · ${step.label}: ${step.error ?? 'failed'}`)
|
|
3589
|
+
.join(''),
|
|
3590
|
+
)
|
|
3591
|
+
: null,
|
|
3592
|
+
),
|
|
3529
3593
|
)
|
|
3530
3594
|
|
|
3531
3595
|
// The list gets its own scroll box.
|
package/cordis.patch.yml
CHANGED
|
@@ -53,6 +53,21 @@
|
|
|
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. DEFAULT 0 = OFF.
|
|
58
|
+
#
|
|
59
|
+
# Why off by default: the model already receives the full conversation history,
|
|
60
|
+
# so it knows what 「它」 refers to. Folding earlier turns into the query only
|
|
61
|
+
# helps the RETRIEVAL step pick a better memory; it does not make the model
|
|
62
|
+
# understand the question better. That is a real but small gain, and it costs a
|
|
63
|
+
# little accuracy on self-contained questions that get padded unnecessarily.
|
|
64
|
+
#
|
|
65
|
+
# Turn it on when the prompt is short by design — a workflow that deliberately
|
|
66
|
+
# keeps requests small (an agent that works from a compact brief rather than a
|
|
67
|
+
# long transcript) benefits, because there the history is NOT in the prompt.
|
|
68
|
+
# 160 characters across 3 previous turns is a reasonable starting point.
|
|
69
|
+
contextQueryChars: 0
|
|
70
|
+
contextQueryTurns: 3
|
|
56
71
|
# How many of the most important memories ride along regardless of the
|
|
57
72
|
# question. Without a resident layer, an ordinary follow-up like "继续"
|
|
58
73
|
# shares no vocabulary with any memory and a strict relevance filter
|
package/index.js
CHANGED
|
@@ -97,6 +97,10 @@ export function apply(ctx, config = {}) {
|
|
|
97
97
|
try {
|
|
98
98
|
const installed = installMemory({ ctx, root, config, log })
|
|
99
99
|
service.longTerm = installed.engine
|
|
100
|
+
// Kept separately: the engine is the memory store, and this is the record of
|
|
101
|
+
// which optional registrations succeeded. Exposed through the status endpoint so
|
|
102
|
+
// a missing feature can be traced to a step that never installed.
|
|
103
|
+
service.memoryInstallStatus = installed.installStatus
|
|
100
104
|
log('long-term memory engine ready')
|
|
101
105
|
} catch (error) {
|
|
102
106
|
log('long-term memory failed to install; continuing without it', error)
|
package/lib/api.js
CHANGED
|
@@ -303,8 +303,14 @@ async function respond(service, deps, action, input) {
|
|
|
303
303
|
})
|
|
304
304
|
case 'longterm.maintain':
|
|
305
305
|
return json({ ok: true, data: await service.longTerm.maintain(workspaceOf(input)) })
|
|
306
|
-
case 'longterm.status':
|
|
307
|
-
|
|
306
|
+
case 'longterm.status': {
|
|
307
|
+
// The engine's own status plus the registration ledger. Two sources on purpose:
|
|
308
|
+
// the engine cannot report a hook that never installed, because the code that
|
|
309
|
+
// would have written it is the code that failed.
|
|
310
|
+
const status = await service.longTerm.status()
|
|
311
|
+
const install = typeof service.memoryInstallStatus === 'function' ? service.memoryInstallStatus() : []
|
|
312
|
+
return json({ ok: true, data: { ...status, install } })
|
|
313
|
+
}
|
|
308
314
|
case 'longterm.workspaces':
|
|
309
315
|
return json({ ok: true, data: await service.longTerm.workspaces() })
|
|
310
316
|
case 'longterm.models':
|
package/lib/memory-host.js
CHANGED
|
@@ -91,13 +91,21 @@ export function installMemory({ ctx, root, config, log }) {
|
|
|
91
91
|
* Run one optional registration; a failure is reported and skipped so a single
|
|
92
92
|
* incompatible hook can never disable the rest of the bundle.
|
|
93
93
|
*
|
|
94
|
+
* Every step records its outcome. The silent-catch was the reason a broken step was
|
|
95
|
+
* invisible: "the queue is empty" could mean the hook was never installed, and
|
|
96
|
+
* nothing on screen said so. The steps list is published through status() so the
|
|
97
|
+
* settings page can show which registrations succeeded.
|
|
98
|
+
*
|
|
94
99
|
* @param {string} label what is being installed
|
|
95
100
|
* @param {() => void} install registration body
|
|
96
101
|
*/
|
|
102
|
+
const steps = []
|
|
97
103
|
const safe = (label, install) => {
|
|
98
104
|
try {
|
|
99
105
|
install()
|
|
106
|
+
steps.push({ label, ok: true })
|
|
100
107
|
} catch (error) {
|
|
108
|
+
steps.push({ label, ok: false, error: error instanceof Error ? error.message : String(error) })
|
|
101
109
|
log(`long-term memory: ${label} failed to install`, error)
|
|
102
110
|
}
|
|
103
111
|
}
|
|
@@ -175,7 +183,13 @@ export function installMemory({ ctx, root, config, log }) {
|
|
|
175
183
|
log(`explicit memory stored (${instruction.scope})`)
|
|
176
184
|
}
|
|
177
185
|
|
|
178
|
-
|
|
186
|
+
// The question, plus enough of the conversation before it to resolve a
|
|
187
|
+
// short follow-up. See contextualQuery: a bare 「那它为什么慢?」 has no
|
|
188
|
+
// referent for 「它」 and cannot match the memory that answers it.
|
|
189
|
+
const question = contextualQuery(messages, {
|
|
190
|
+
chars: typeof config?.contextQueryChars === 'number' ? config.contextQueryChars : undefined,
|
|
191
|
+
turns: typeof config?.contextQueryTurns === 'number' ? config.contextQueryTurns : undefined,
|
|
192
|
+
})
|
|
179
193
|
const text = await buildRecallText({ engine, cwd, question, config })
|
|
180
194
|
if (agent) recallByScope.set(agent, { text, at: Date.now() })
|
|
181
195
|
lastRecall = text
|
|
@@ -404,6 +418,9 @@ export function installMemory({ ctx, root, config, log }) {
|
|
|
404
418
|
return {
|
|
405
419
|
engine,
|
|
406
420
|
dispose: () => {},
|
|
421
|
+
// The outcome of every optional registration, so "the queue is empty" can be
|
|
422
|
+
// traced to a specific step that never installed.
|
|
423
|
+
installStatus: () => [...steps],
|
|
407
424
|
// Exposed so the settings page and the status endpoint can report whether the
|
|
408
425
|
// last recall rebuild failed. Without this the failure is silent: the model
|
|
409
426
|
// simply acts as if the memory is not there.
|
|
@@ -495,6 +512,89 @@ function lastUserText(messages) {
|
|
|
495
512
|
return ''
|
|
496
513
|
}
|
|
497
514
|
|
|
515
|
+
/**
|
|
516
|
+
* The current question plus the tail of the conversation before it.
|
|
517
|
+
*
|
|
518
|
+
* Retrieval used to search for the last user message alone, so a follow-up like
|
|
519
|
+
* 「那它为什么慢?」 searched for those seven characters and nothing else. "它" is a
|
|
520
|
+
* pronoun with no referent in the query, so the memory that explains the slowness could
|
|
521
|
+
* not be found no matter how well it was written. That is a real gap, not a tuning
|
|
522
|
+
* problem.
|
|
523
|
+
*
|
|
524
|
+
* The shape here is deliberate:
|
|
525
|
+
*
|
|
526
|
+
* • the current question comes LAST, so it dominates the lexical score (BM25 and the
|
|
527
|
+
* coverage route both reward a match in the query; putting context first would let
|
|
528
|
+
* an older turn outvote the thing being asked now);
|
|
529
|
+
* • only the previous USER turns are included, not assistant replies. Assistant text
|
|
530
|
+
* is verbose, and a long reply would drown the query in words the user never typed;
|
|
531
|
+
* • the tail is capped in characters rather than turns, because one long message and
|
|
532
|
+
* ten short ones should not cost the same;
|
|
533
|
+
* • nothing is included when the current question already stands alone (long enough
|
|
534
|
+
* and not pronoun-led), so an ordinary self-contained question is searched exactly
|
|
535
|
+
* as before.
|
|
536
|
+
*
|
|
537
|
+
* This is a heuristic, not a language model. It will not resolve 「那个东西」 back to a
|
|
538
|
+
* noun three turns ago. It does fix the common case — a short follow-up — and it costs
|
|
539
|
+
* nothing at query time.
|
|
540
|
+
*
|
|
541
|
+
* @param {readonly any[]} messages step messages
|
|
542
|
+
* @param {{ chars?: number, turns?: number }} [limits] budget
|
|
543
|
+
* @returns {string} query text
|
|
544
|
+
*/
|
|
545
|
+
function contextualQuery(messages, limits = {}) {
|
|
546
|
+
const current = lastUserText(messages)
|
|
547
|
+
if (!current) return ''
|
|
548
|
+
|
|
549
|
+
// 0 is the default, and it means disabled.
|
|
550
|
+
//
|
|
551
|
+
// Why: the model already receives the conversation history, so it knows what a
|
|
552
|
+
// pronoun refers to. Folding earlier turns into the retrieval query only improves
|
|
553
|
+
// which MEMORY is picked — a real but small gain — and it costs a little accuracy
|
|
554
|
+
// on self-contained questions that get padded unnecessarily. It matters for a
|
|
555
|
+
// workflow that deliberately keeps the prompt small, where the history is not there
|
|
556
|
+
// to be read; that is the case this exists for, and it is opt-in.
|
|
557
|
+
const maxChars = typeof limits.chars === 'number' ? limits.chars : 0
|
|
558
|
+
const maxTurns = typeof limits.turns === 'number' ? limits.turns : 3
|
|
559
|
+
if (maxChars <= 0) return current
|
|
560
|
+
|
|
561
|
+
// A self-contained question needs no help. The test is NOT length alone: a short
|
|
562
|
+
// question that names its own subject ("导出的图片为什么断链") is self-contained,
|
|
563
|
+
// while a long one made of pronouns ("那这个呢,还有刚才说的那个,它到底是怎么回事")
|
|
564
|
+
// is not. Pronouns are the actual signal — they are what a query cannot resolve.
|
|
565
|
+
//
|
|
566
|
+
// An earlier version used `length >= 24`, which was a guess and misclassified a
|
|
567
|
+
// perfectly clear 20-character question as needing context. Pronoun presence is a
|
|
568
|
+
// narrower, more defensible rule: it fires exactly when the query contains a word
|
|
569
|
+
// that refers to something outside itself.
|
|
570
|
+
const PRONOUNS = /(它|他|她|这个|那个|这些|那些|该|此|上述|前面|刚才|上面|之前那个)/
|
|
571
|
+
const looksSelfContained = !PRONOUNS.test(current)
|
|
572
|
+
|
|
573
|
+
const earlier = []
|
|
574
|
+
let budget = maxChars
|
|
575
|
+
let seen = 0
|
|
576
|
+
for (let index = (messages?.length ?? 0) - 1; index >= 0 && seen < maxTurns; index -= 1) {
|
|
577
|
+
const message = messages[index]
|
|
578
|
+
if (message?.role !== 'user') continue
|
|
579
|
+
const text = contentText(message.content)
|
|
580
|
+
if (!text) continue
|
|
581
|
+
seen += 1
|
|
582
|
+
// The newest user message IS `current`; skip it so it is not duplicated.
|
|
583
|
+
if (text === current) continue
|
|
584
|
+
if (budget <= 0) break
|
|
585
|
+
const slice = text.length > budget ? text.slice(-budget) : text
|
|
586
|
+
earlier.unshift(slice)
|
|
587
|
+
budget -= slice.length
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Nothing to add, or the question stands on its own: search it as-is.
|
|
591
|
+
if (earlier.length === 0 || looksSelfContained) return current
|
|
592
|
+
|
|
593
|
+
// Context first, question last. The engine tokenises the whole string, and the
|
|
594
|
+
// question's own terms are what must win.
|
|
595
|
+
return `${earlier.join(' ')} ${current}`.trim()
|
|
596
|
+
}
|
|
597
|
+
|
|
498
598
|
/**
|
|
499
599
|
* Ask a small model call what, if anything, is worth remembering from this turn.
|
|
500
600
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heixui/dsh-session-suite",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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",
|