@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.
- package/README.md +22 -0
- package/README.zh.md +24 -3
- package/client.js +67 -4
- package/cordis.patch.yml +27 -0
- package/lib/embeddings.js +239 -0
- package/lib/memory-engine.js +492 -13
- package/lib/memory-host.js +199 -43
- package/lib/retrieval.js +75 -0
- package/package.json +1 -1
package/lib/memory-host.js
CHANGED
|
@@ -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 })
|
|
213
|
-
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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())
|
|
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
|
-
|
|
503
|
-
|
|
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
|
-
|
|
506
|
-
|
|
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
|
+
"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",
|