@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 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
- Every bar was the same brand blue, so a chart of five categories read as one
1051
- repeated object and the only thing distinguishing a row was its length — the eye
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
- extraction
3500
- ? h(
3501
- 'div',
3502
- { className: 'dss-row-meta' },
3503
- h('span', null, t('memory.extractionEvents', { n: extraction.eventsSeen ?? 0 })),
3504
- h('span', null, t('memory.extractionTurns', { n: extraction.turnEndsSeen ?? 0 })),
3505
- )
3506
- : null,
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
@@ -175,7 +175,13 @@ export function installMemory({ ctx, root, config, log }) {
175
175
  log(`explicit memory stored (${instruction.scope})`)
176
176
  }
177
177
 
178
- const question = lastUserText(messages)
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.4.3",
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",