@heixui/dsh-session-suite 0.3.3 → 0.4.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.
@@ -209,9 +209,17 @@ export function installMemory({ ctx, root, config, log }) {
209
209
  const sessionId = session?.id
210
210
  if (typeof sessionId !== 'string') return
211
211
  const cwd = resolveCwd(ctx, session)
212
- void extractAfterTurn({ ctx, engine, sessionId, cwd, log, config }).catch(error =>
213
- log('implicit memory extraction failed', error),
214
- )
212
+ void extractAfterTurn({ ctx, engine, sessionId, cwd, log, config })
213
+ // The trace is published in a finally so a thrown error still leaves a
214
+ // readable cause on the settings page instead of an empty queue with
215
+ // no explanation.
216
+ .finally(() => publishExtractionTrace(engine))
217
+ .catch(error => {
218
+ extractionTrace.stage = 'threw'
219
+ extractionTrace.reason = errorMessage(error)
220
+ publishExtractionTrace(engine)
221
+ log('implicit memory extraction failed', error)
222
+ })
215
223
  }),
216
224
  'session-suite: implicit memory',
217
225
  ),
@@ -372,6 +380,10 @@ export function installMemory({ ctx, root, config, log }) {
372
380
  hasText: Boolean(lastRecall) || recallByScope.size > 0,
373
381
  scopes: recallByScope.size,
374
382
  }),
383
+ // Why the last implicit extraction produced (or did not produce) candidates.
384
+ // "The queue is empty" needs a cause, or the user cannot tell a working
385
+ // feature from a broken one.
386
+ extractionStatus: () => ({ ...extractionTrace }),
375
387
  }
376
388
  }
377
389
 
@@ -393,38 +405,50 @@ async function buildRecallText({ engine, cwd, question, config }) {
393
405
  // actually justifies, and records the reason for every candidate it dropped so
394
406
  // the settings page can explain a recall instead of leaving it a mystery.
395
407
  const { items: matched } = await engine.select({ cwd, query: question ?? '', limit })
396
- const usable = matched.filter(item => !item.archived)
397
-
398
- // Then a small resident layer: the most important memories ride along whatever
399
- // the question happened to be. Without it, a follow-up like "继续" shares no
400
- // characters with any memory, so a strict relevance filter would inject nothing
401
- // at all — and a feature that vanishes on ordinary follow-ups reads as broken,
402
- // not as a relevance decision. This is the same shape as the resident layer in
403
- // dsh-fish-memory's design: durable facts get their own small quota instead of
404
- // competing with question-matched events.
405
- if (residentLimit > 0 && usable.length < limit) {
406
- const ranked = await engine.recall({ cwd, query: '', limit, touch: false })
407
- let residents = 0
408
- for (const item of ranked) {
409
- if (residents >= residentLimit || usable.length >= limit) break
410
- if (item.archived) continue
411
- if (usable.some(existing => existing.id === item.id)) continue
412
- usable.push(item)
413
- residents += 1
414
- }
408
+ // A secret never reaches a prompt. It is not context — it is a credential — and
409
+ // injecting it would copy it into every provider request and every transcript.
410
+ const usable = matched.filter(item => !item.archived && item.privacy !== 'secret')
411
+
412
+ // The resident layer is an OVERVIEW, not more search results.
413
+ //
414
+ // It used to append the N highest-scoring entries as if they were relevant,
415
+ // which produced two problems: the injected block mixed "matched your question"
416
+ // with "just important in general" under one heading, and a user reading the
417
+ // settings probe reasonably concluded that a memory which DID match had been
418
+ // missed — it was sitting three lines below, unrelated to the query.
419
+ //
420
+ // Topic cards fix both: the block now says what the library knows in aggregate,
421
+ // and the two halves are labelled so nobody has to guess which is which.
422
+ let cards = []
423
+ if (residentLimit > 0) {
424
+ cards = await engine.residentCards({ cwd, limit: residentLimit }).catch(() => [])
415
425
  }
416
426
 
417
- if (usable.length === 0) return ''
418
- const lines = usable.map(item => `- ${item.scope === 'global' ? '[全局]' : '[本工作区]'} ${item.text}`)
419
- return [
420
- '## 长期记忆(跨会话)',
421
- '',
422
- '以下条目来自长期记忆库,与本轮问题相关。它们是稳定事实或约定,不是本轮对话内容;冲突时以用户当前的话为准。',
423
- '',
424
- ...lines,
427
+ if (usable.length === 0 && cards.length === 0) return ''
428
+
429
+ const sections = ['## 长期记忆(跨会话)', '']
430
+ if (usable.length > 0) {
431
+ sections.push(
432
+ '### 与本次提问相关',
433
+ '',
434
+ ...usable.map(item => `- ${item.scope === 'global' ? '[全局]' : '[本工作区]'} ${item.text}`),
435
+ '',
436
+ )
437
+ }
438
+ if (cards.length > 0) {
439
+ sections.push(
440
+ '### 库内概览(与本次提问无关,供你了解已积累的内容)',
441
+ '',
442
+ ...MemoryEngine.formatCards(cards),
443
+ '',
444
+ )
445
+ }
446
+ sections.push(
447
+ '以上是稳定事实或约定,不是本轮对话内容;冲突时以用户当前的话为准。',
425
448
  '',
426
449
  '如果用户说【记住:…】或【新增全局记忆:…】,立即照做;如果这些记忆已经过时,用 memory 工具更正。',
427
- ].join('\n')
450
+ )
451
+ return sections.join('\n')
428
452
  }
429
453
 
430
454
  /** @param {readonly any[]} messages step messages @returns {string} last user text */
@@ -449,27 +473,103 @@ function lastUserText(messages) {
449
473
  * @param {(message: string, error?: unknown) => void} input.log logger
450
474
  * @param {any} input.config plugin config
451
475
  */
476
+ /**
477
+ * Why the last implicit-extraction attempt produced what it did.
478
+ *
479
+ * Kept as module state rather than only a log line because the plugin has no log
480
+ * surface the user can reach: the settings page and the status endpoint read this
481
+ * so "the queue is empty" comes with a cause.
482
+ */
483
+ const extractionTrace = {
484
+ sessionId: null,
485
+ at: 0,
486
+ ok: false,
487
+ stage: 'idle',
488
+ reason: '',
489
+ candidates: [],
490
+ produced: 0,
491
+ queued: 0,
492
+ rejected: 0,
493
+ }
494
+
495
+ /** @param {string} sessionId session being processed */
496
+ function resetExtractionTrace(sessionId) {
497
+ extractionTrace.sessionId = sessionId
498
+ extractionTrace.at = Date.now()
499
+ extractionTrace.ok = false
500
+ extractionTrace.stage = 'start'
501
+ extractionTrace.reason = ''
502
+ extractionTrace.candidates = []
503
+ extractionTrace.produced = 0
504
+ extractionTrace.queued = 0
505
+ extractionTrace.rejected = 0
506
+ }
507
+
508
+ /**
509
+ * Record why extraction stopped before it could queue anything.
510
+ *
511
+ * @param {string} reason human-readable cause
512
+ * @returns {undefined} always undefined, so callers can `return failExtraction(...)`
513
+ */
514
+ function failExtraction(reason) {
515
+ extractionTrace.ok = true
516
+ extractionTrace.stage = 'stopped'
517
+ extractionTrace.reason = reason
518
+ return undefined
519
+ }
520
+
521
+ /**
522
+ * Publish the trace to the engine so the settings page can read it.
523
+ *
524
+ * @param {any} engine memory engine
525
+ */
526
+ function publishExtractionTrace(engine) {
527
+ try {
528
+ engine.lastExtraction = { ...extractionTrace }
529
+ } catch {
530
+ /* a diagnostic must never break extraction itself */
531
+ }
532
+ }
533
+
452
534
  async function extractAfterTurn({ ctx, engine, sessionId, cwd, log, config }) {
535
+ // Every early return records why. An empty pending queue previously carried no
536
+ // explanation at all: the user could see "nothing waiting" but not whether the
537
+ // model found nothing, no model answered, or a filter dropped the result. That
538
+ // is the difference between a feature that works and one that looks broken.
539
+ resetExtractionTrace(sessionId)
453
540
  const settings = await engine.settings()
454
- if (settings.implicit === false) return
541
+ if (settings.implicit === false) return failExtraction('implicit extraction is switched off in settings')
455
542
  const llm = ctx.get('llm')
456
- if (!llm?.stream) return
543
+ if (!llm?.stream) return failExtraction('the Host exposes no llm.stream')
457
544
 
458
545
  const sessionController = ctx.get('sessionController')
459
546
  const inspection = await sessionController?.inspect?.(sessionId).catch(() => undefined)
460
547
  const events = inspection?.events ?? []
461
548
  const turn = lastCompletedTurn(events)
462
- if (typeof turn !== 'number') return
549
+ if (typeof turn !== 'number') return failExtraction('this session has no completed turn yet')
463
550
  const transcript = turnTranscript(events, turn)
464
- if (!transcript) return
551
+ if (!transcript) return failExtraction('the last turn produced no readable transcript')
465
552
 
466
553
  // The memory model is a fallback chain: a free provider that is rate limited
467
554
  // (429) or unreachable must not silently disable extraction for the session,
468
555
  // so every candidate is tried in order until one answers.
469
556
  const candidates = memoryCandidates(config)
470
- if (candidates.length === 0) return
471
-
472
- const prompt = `下面是一轮对话。找出其中**长期有效**的信息:稳定偏好、固定技术约定、长期策略、用户固有习惯。\n单次任务、一次性查询、临时调试、闲聊都不要。\n只输出 JSON:{"memories":[{"text":"...","scope":"workspace|global","affect":0.0-1.0,"tags":["..."]}]}\n没有就输出 {"memories":[]}。\n\n对话:\n${transcript}`
557
+ if (candidates.length === 0) return failExtraction('no memoryModels chain is configured')
558
+ extractionTrace.candidates = candidates.map(candidate => `${candidate.provider}/${candidate.model}`)
559
+
560
+ // The prompt asks for two different shapes, because the library stores two
561
+ // different things: durable truths, and dated events with an outcome. Asking only
562
+ // for the former is why episodic knowledge — "that approach failed last time" —
563
+ // was never captured, even though it is the kind that prevents repeating a
564
+ // mistake. The `kind` field lets the reply say which it means.
565
+ const prompt =
566
+ '下面是一轮对话。找出其中**值得长期记住**的信息,分两类:\n' +
567
+ '1. durable:稳定偏好、固定技术约定、长期策略、用户固有习惯,跨会话仍然成立的事实。\n' +
568
+ '2. episode:这一轮里实际**发生了什么**——尝试了什么、结果如何、踩了什么坑、为什么失败。带具体情境,不是泛泛结论。\n' +
569
+ '单次闲聊、情绪流水账、没有依据的猜测都不要。\n' +
570
+ '只输出 JSON:{"memories":[{"kind":"durable|episode","text":"...","scope":"workspace|global","affect":0.0-1.0,"tags":["..."],"context":"仅episode:当时在做什么","outcome":"仅episode:结果或教训"}]}\n' +
571
+ '没有就输出 {"memories":[]}。\n\n' +
572
+ `对话:\n${transcript}`
473
573
 
474
574
  let answer = ''
475
575
  let used
@@ -495,24 +595,80 @@ async function extractAfterTurn({ ctx, engine, sessionId, cwd, log, config }) {
495
595
  log(`memory extraction failed on ${candidate.provider}/${candidate.model}: ${errorMessage(error)}`)
496
596
  }
497
597
  }
498
- if (!answer.trim()) return
598
+ if (!answer.trim()) {
599
+ return failExtraction(
600
+ `no memory model answered (tried ${candidates.length}: ${candidates
601
+ .map(candidate => `${candidate.provider}/${candidate.model}`)
602
+ .join(', ')})`,
603
+ )
604
+ }
499
605
  log(`memory extraction answered by ${used?.provider}/${used?.model}`)
606
+ extractionTrace.used = `${used?.provider}/${used?.model}`
500
607
 
501
608
  const parsed = parseJsonBlock(answer)
502
- if (!parsed?.memories?.length) return
503
- for (const item of parsed.memories.slice(0, 4)) {
609
+ // Record what happened even when nothing survives, so "the queue is empty" has
610
+ // a cause attached instead of being an unexplained absence.
611
+ if (!parsed) {
612
+ extractionTrace.parsed = false
613
+ extractionTrace.reason = 'the model answered but the reply was not usable JSON'
614
+ log(`memory extraction: no usable JSON in the reply`)
615
+ return
616
+ }
617
+ const produced = Array.isArray(parsed.memories) ? parsed.memories : []
618
+ extractionTrace.parsed = true
619
+ extractionTrace.produced = produced.length
620
+ if (produced.length === 0) {
621
+ extractionTrace.reason = 'the model judged this turn to hold nothing durable'
622
+ return
623
+ }
624
+
625
+ let queued = 0
626
+ let rejected = 0
627
+ for (const item of produced.slice(0, 4)) {
504
628
  const text = String(item.text ?? '').trim()
505
- if (!text || !DURABLE_HINTS.test(text)) continue
506
- if (TRANSIENT_HINTS.test(text)) continue
629
+ // The extraction prompt already asked the model for long-lived information
630
+ // only, so a second keyword gate here was acting as a filter on the model's
631
+ // own paraphrase rather than on the user's words. Measured cost: 7 of 10
632
+ // realistic candidates were dropped ("本项目使用 pnpm 管理依赖" carries none
633
+ // of 以后/一律/总是), which made the pending queue look broken. Keep only the
634
+ // checks that describe a malformed candidate, not a stylistic one.
635
+ if (!text) {
636
+ rejected += 1
637
+ continue
638
+ }
639
+ if (!isMeaningfulText(text)) {
640
+ rejected += 1
641
+ continue
642
+ }
643
+ if (TRANSIENT_HINTS.test(text)) {
644
+ rejected += 1
645
+ continue
646
+ }
507
647
  await engine.suggest({
508
648
  text,
509
649
  scope: item.scope === 'global' ? 'global' : 'workspace',
510
650
  cwd,
651
+ // The prompt asks the model to label an event as an episode. Anything else —
652
+ // including a reply from a model that ignored the new field — stays a fact,
653
+ // so an older prompt shape cannot corrupt the library.
654
+ kind: item.kind === 'episode' ? 'episode' : 'fact',
655
+ ...(item.kind === 'episode'
656
+ ? {
657
+ context: typeof item.context === 'string' ? item.context : undefined,
658
+ outcome: typeof item.outcome === 'string' ? item.outcome : undefined,
659
+ }
660
+ : {}),
511
661
  tags: Array.isArray(item.tags) ? item.tags : inferTags(text),
512
662
  affect: typeof item.affect === 'number' ? item.affect : 0.6,
513
663
  sessionId,
514
664
  })
665
+ queued += 1
515
666
  }
667
+ extractionTrace.queued = queued
668
+ extractionTrace.rejected = rejected
669
+ extractionTrace.reason =
670
+ queued > 0 ? `queued ${queued} suggestion(s) for confirmation` : 'every candidate was rejected as unusable'
671
+ log(`memory extraction: ${queued} queued, ${rejected} rejected`)
516
672
  }
517
673
 
518
674
  /** @param {string} text model output @returns {any | undefined} parsed JSON */
package/lib/retrieval.js CHANGED
@@ -215,3 +215,78 @@ export function reciprocalRankFusion(rankings, k = 60) {
215
215
  export function rankIds(ids, scores) {
216
216
  return [...ids].sort((a, b) => (scores.get(b) ?? 0) - (scores.get(a) ?? 0) || String(a).localeCompare(String(b)))
217
217
  }
218
+
219
+ /**
220
+ * Cosine similarity between two equal-length vectors.
221
+ *
222
+ * Kept here rather than in the embedding provider so the arithmetic is testable
223
+ * without a model: everything that decides ranking order is a pure function.
224
+ *
225
+ * @param {ArrayLike<number>} a first vector
226
+ * @param {ArrayLike<number>} b second vector
227
+ * @returns {number} similarity, 0 when either vector is degenerate
228
+ */
229
+ export function cosineSimilarity(a, b) {
230
+ const length = Math.min(a?.length ?? 0, b?.length ?? 0)
231
+ if (length === 0) return 0
232
+ let dot = 0
233
+ let normA = 0
234
+ let normB = 0
235
+ for (let index = 0; index < length; index += 1) {
236
+ const x = Number(a[index]) || 0
237
+ const y = Number(b[index]) || 0
238
+ dot += x * y
239
+ normA += x * x
240
+ normB += y * y
241
+ }
242
+ if (normA === 0 || normB === 0) return 0
243
+ return dot / Math.sqrt(normA * normB)
244
+ }
245
+
246
+ /**
247
+ * Re-rank a candidate list by semantic similarity, as a second stage.
248
+ *
249
+ * This is the "retrieve then rerank" shape: the lexical routes decide *which*
250
+ * memories are candidates (cheap, offline, no model), and a dense model decides
251
+ * the *order* among them (expensive, so it only sees a short list). Doing it the
252
+ * other way round — embedding the whole library per query — would cost far more
253
+ * and would still need the lexical routes to catch exact tokens.
254
+ *
255
+ * The function is deliberately pure and provider-agnostic: it takes the vectors
256
+ * it should compare. If a caller passes no vectors, the input order is returned
257
+ * untouched, so a missing or failed model degrades to "no reranking" rather than
258
+ * to an exception. That matters because reranking is an optimisation, not a
259
+ * correctness requirement — a broken model must not break recall.
260
+ *
261
+ * Interpolation rather than replacement: the fused score already encodes lexical
262
+ * agreement, and letting the dense score overwrite it would discard signal. The
263
+ * weight defaults to 0.5 because with no held-out set to tune against, a claim of
264
+ * a better constant would be unfounded.
265
+ *
266
+ * @param {Array<{ id: string | number }>} candidates ordered by the lexical stage
267
+ * @param {ArrayLike<number> | undefined} queryVector query embedding
268
+ * @param {Map<string | number, ArrayLike<number>>} vectorsById candidate embeddings
269
+ * @param {{ lexical?: Map<string | number, number>, weight?: number }} [options]
270
+ * lexical scores to interpolate with, and how much the dense score counts
271
+ * @returns {Array<{ id: string | number }>} the same candidates, reordered
272
+ */
273
+ export function rerankBySimilarity(candidates, queryVector, vectorsById, options = {}) {
274
+ const list = Array.isArray(candidates) ? candidates : []
275
+ if (!queryVector || !(vectorsById instanceof Map) || vectorsById.size === 0) return list
276
+ const weight = typeof options.weight === 'number' ? Math.min(1, Math.max(0, options.weight)) : 0.5
277
+ const lexical = options.lexical instanceof Map ? options.lexical : undefined
278
+
279
+ const scored = list.map(candidate => {
280
+ const vector = vectorsById.get(candidate.id)
281
+ const dense = vector ? cosineSimilarity(queryVector, vector) : 0
282
+ const prior = lexical ? (lexical.get(candidate.id) ?? 0) : 0
283
+ // A candidate with no vector keeps its lexical standing instead of being
284
+ // pushed to the bottom: an embedding failure for one row must not delete it
285
+ // from the results.
286
+ const combined = vector ? (1 - weight) * prior + weight * dense : prior
287
+ return { candidate, combined, dense, hadVector: Boolean(vector) }
288
+ })
289
+
290
+ scored.sort((a, b) => b.combined - a.combined || String(a.candidate.id).localeCompare(String(b.candidate.id)))
291
+ return scored.map(entry => entry.candidate)
292
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heixui/dsh-session-suite",
3
- "version": "0.3.3",
3
+ "version": "0.4.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",