@modusensus/dsh-mneme 0.4.4 → 0.4.6
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 +27 -0
- package/lib/api.js +40 -2
- package/lib/config.js +53 -0
- package/lib/dream/decisions.js +43 -2
- package/lib/dream.js +86 -6
- package/lib/embedding.js +59 -2
- package/lib/index.js +68 -1
- package/lib/inject.js +84 -4
- package/lib/quality-filter.js +123 -0
- package/lib/service.js +355 -14
- package/lib/store.js +366 -5
- package/lib/summarize.js +65 -7
- package/lib/vector-index.js +12 -2
- package/package.json +1 -1
- package/src/api.js +40 -2
- package/src/config.js +53 -0
- package/src/dream/decisions.js +43 -2
- package/src/dream.js +86 -6
- package/src/embedding.js +59 -2
- package/src/index.js +68 -1
- package/src/inject.js +84 -4
- package/src/quality-filter.js +123 -0
- package/src/service.js +355 -14
- package/src/store.js +366 -5
- package/src/summarize.js +65 -7
- package/src/vector-index.js +12 -2
- package/test/api.test.js +84 -0
- package/test/dream.test.js +52 -0
- package/test/epistemic.test.js +298 -0
- package/test/inject.test.js +21 -0
- package/test/llm-audit.test.js +279 -0
- package/test/mirror-edit-digest.test.js +3 -1
- package/test/quality-filter.test.js +118 -0
- package/test/recall-evals.test.js +235 -0
- package/test/service.test.js +133 -2
- package/test/vector-index.test.js +22 -6
package/lib/service.js
CHANGED
|
@@ -1,8 +1,65 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { TYPE_FILE } from "./mirror.js";
|
|
3
|
+
import { evaluateMemoryQuality } from "./quality-filter.js";
|
|
3
4
|
|
|
4
5
|
const INJECT_TYPES = new Set(["preference", "project", "decision", "summary"]);
|
|
5
6
|
|
|
7
|
+
// Epistemic trust weights (v0.4.5): when config.trustEpistemicWeighting is on,
|
|
8
|
+
// each recall candidate's existing score is multiplied by the weight of its
|
|
9
|
+
// epistemic_status before ranking — measured facts outrank guesses. Missing /
|
|
10
|
+
// unknown statuses are unscaled (×1). Off by default, so nothing changes.
|
|
11
|
+
const EPISTEMIC_WEIGHTS = { observation: 1.0, inferred: 0.85, subjective: 0.7 };
|
|
12
|
+
|
|
13
|
+
// Bug5: content version history cap (FIFO — the newest 20 versions are kept,
|
|
14
|
+
// older ones dropped). Entries are {content, source, updated_at}; source marks
|
|
15
|
+
// how the version was superseded (auto_merge | human_override | overwrite).
|
|
16
|
+
const CONTENT_HISTORY_MAX = 20;
|
|
17
|
+
|
|
18
|
+
/** Prepend the previous content to a memory's content_history (FIFO capped). */
|
|
19
|
+
function pushContentHistory(existing, source) {
|
|
20
|
+
const history = Array.isArray(existing?.content_history) ? existing.content_history : [];
|
|
21
|
+
return [
|
|
22
|
+
{ content: existing?.content ?? "", source, updated_at: new Date().toISOString() },
|
|
23
|
+
...history
|
|
24
|
+
].slice(0, CONTENT_HISTORY_MAX);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Bug5: same-title merge appends the new content under a timestamped `---`
|
|
28
|
+
* separator instead of overwriting, so a re-noted memory never loses history.
|
|
29
|
+
* The `---` line is compatible with the mirror's readHumanEdits (which strips
|
|
30
|
+
* only the LAST structural `---` when parsing the human-editable file). */
|
|
31
|
+
function appendContent(oldContent, newContent) {
|
|
32
|
+
const ts = new Date().toISOString();
|
|
33
|
+
return `${oldContent}\n\n---\n[${ts}] ${newContent}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Standard retrieval-quality metrics over the ordered candidate ids actually
|
|
38
|
+
* returned vs the ids the evaluator marked relevant (方案 B). Pure + total, so
|
|
39
|
+
* callers (and tests) get deterministic numbers without touching a store:
|
|
40
|
+
* precision = |relevant ∩ retrieved| / |retrieved|
|
|
41
|
+
* recall = |relevant ∩ retrieved| / |expected|
|
|
42
|
+
* mrr = 1 / rank of the first relevant doc (0 when none retrieved)
|
|
43
|
+
* hit_count is the raw intersection size. Values are rounded to 4 decimals so
|
|
44
|
+
* repeated divisions (e.g. 1/3) never surface binary-float noise.
|
|
45
|
+
*/
|
|
46
|
+
export function computeRetrievalMetrics(actualIds, expectedIds) {
|
|
47
|
+
const expected = new Set(Array.isArray(expectedIds) ? expectedIds : []);
|
|
48
|
+
const actual = Array.isArray(actualIds) ? actualIds : [];
|
|
49
|
+
const relevant = actual.filter((id) => expected.has(id)).length;
|
|
50
|
+
const round4 = (x) => Math.round(x * 10000) / 10000;
|
|
51
|
+
let mrr = 0;
|
|
52
|
+
for (let i = 0; i < actual.length; i++) {
|
|
53
|
+
if (expected.has(actual[i])) { mrr = 1 / (i + 1); break; }
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
precision: round4(actual.length ? relevant / actual.length : 0),
|
|
57
|
+
recall: round4(expected.size ? relevant / expected.size : 0),
|
|
58
|
+
mrr: round4(mrr),
|
|
59
|
+
hit_count: relevant
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
6
63
|
export function createService({ store, mirror, config, onWrite, logger }) {
|
|
7
64
|
// Optional dream scheduler hook, installed via setDreamHook after creation
|
|
8
65
|
// (the scheduler holds a reference back to the service, so it cannot be
|
|
@@ -36,6 +93,13 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
36
93
|
// judgment-layer audit trail (dream_runs).
|
|
37
94
|
let recallRecorder = null;
|
|
38
95
|
|
|
96
|
+
// Bug4: semantic recall cache for the injection path. The system-prompt
|
|
97
|
+
// interpolator renders context synchronously, so injectCandidates cannot
|
|
98
|
+
// fire a fresh async embed. The most recent searchMemories recall is cached
|
|
99
|
+
// here (query + ordered candidates) and reused when the injection query
|
|
100
|
+
// matches, giving semantic-first injection without breaking the sync render.
|
|
101
|
+
let lastSemanticRecall = null;
|
|
102
|
+
|
|
39
103
|
// Transaction nesting depth. Inside service.transaction the per-mutation side
|
|
40
104
|
// effects (mirror render, write notify, re-embed) are deferred so a ROLLBACK
|
|
41
105
|
// never leaves the mirror file diverged from the database; transaction()
|
|
@@ -357,9 +421,21 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
357
421
|
}
|
|
358
422
|
|
|
359
423
|
merged = merged.slice(0, lim);
|
|
360
|
-
|
|
424
|
+
let result = useRerank && reranker && merged.length
|
|
361
425
|
? await rerankCandidates(q, merged, lim)
|
|
362
426
|
: merged;
|
|
427
|
+
// Epistemic trust (v0.4.5): opt-in re-weighting of the final candidate
|
|
428
|
+
// scores by source credibility. When off (default) `result` is returned
|
|
429
|
+
// untouched — exactly the legacy behavior.
|
|
430
|
+
if (config.trustEpistemicWeighting === true) {
|
|
431
|
+
result = result
|
|
432
|
+
.map((m) => ({
|
|
433
|
+
...m,
|
|
434
|
+
score: (m.score ?? 0) * (EPISTEMIC_WEIGHTS[m.epistemic_status] ?? 1)
|
|
435
|
+
}))
|
|
436
|
+
.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
|
|
437
|
+
.slice(0, lim);
|
|
438
|
+
}
|
|
363
439
|
|
|
364
440
|
// Recall layer receipt: with recordRecall on, hand the actual merged
|
|
365
441
|
// candidate list (id/title/content/score/source) to the injected recorder
|
|
@@ -384,10 +460,100 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
384
460
|
});
|
|
385
461
|
} catch { /* recall receipt is best effort */ }
|
|
386
462
|
}
|
|
463
|
+
// Bug4: cache the latest semantic recall so the sync injection path can
|
|
464
|
+
// reuse it when the injection query matches (no async embed available).
|
|
465
|
+
lastSemanticRecall = { query: q, items: result };
|
|
387
466
|
touchRecalled(result);
|
|
388
467
|
return result;
|
|
389
468
|
}
|
|
390
469
|
|
|
470
|
+
/**
|
|
471
|
+
* Retrieval evaluation (方案 B): run one search for `query`, compare the ids
|
|
472
|
+
* it actually returned against `expectedIds`, and return the computed
|
|
473
|
+
* metrics. When persistence is on (config.evalPersistTestResults, or an
|
|
474
|
+
* explicit `persist` override per call) the snapshot is written to the
|
|
475
|
+
* recall_evals table — a SEPARATE store from the recall_runs production audit,
|
|
476
|
+
* so test/eval data never inflates the production trail.
|
|
477
|
+
*
|
|
478
|
+
* options:
|
|
479
|
+
* mode/topK/threshold/useRerank — passed through to searchMemories
|
|
480
|
+
* evalType — label for the snapshot (default 'manual')
|
|
481
|
+
* recordRecall — also write a recall_runs audit row for the
|
|
482
|
+
* same scene and link it via recall_run_id
|
|
483
|
+
* (default false: eval stays unlinked)
|
|
484
|
+
* recallRunId — explicit link to an existing recall_runs id
|
|
485
|
+
* persist — override the config gate for this call
|
|
486
|
+
*
|
|
487
|
+
* Returns { metrics, actualIds, expectedIds, recallRunId, persisted }.
|
|
488
|
+
* Never throws on persistence failures: a broken eval write must not break
|
|
489
|
+
* the retrieval quality measurement.
|
|
490
|
+
*/
|
|
491
|
+
async function evaluateRetrieval(query, expectedIds, options = {}) {
|
|
492
|
+
const q = String(query ?? "").trim();
|
|
493
|
+
const expected = Array.isArray(expectedIds) ? expectedIds : [];
|
|
494
|
+
const {
|
|
495
|
+
mode = "auto",
|
|
496
|
+
topK = 20,
|
|
497
|
+
threshold,
|
|
498
|
+
useRerank = true,
|
|
499
|
+
evalType = "manual",
|
|
500
|
+
recordRecall = false,
|
|
501
|
+
recallRunId = null,
|
|
502
|
+
persist = config.evalPersistTestResults === true
|
|
503
|
+
} = options;
|
|
504
|
+
if (!q) {
|
|
505
|
+
const empty = computeRetrievalMetrics([], expected);
|
|
506
|
+
return { metrics: empty, actualIds: [], expectedIds: expected, recallRunId: null, persisted: false };
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const rows = await searchMemories(q, { mode, topK, threshold, useRerank, recordRecall: false });
|
|
510
|
+
const actualIds = rows.map((m) => m.id);
|
|
511
|
+
const metrics = computeRetrievalMetrics(actualIds, expected);
|
|
512
|
+
|
|
513
|
+
// Optional recall_runs audit for the same scene; the eval row then links to
|
|
514
|
+
// it. Kept separate from the production recorder (which fires only on
|
|
515
|
+
// recordRecall=true inside searchMemories) — eval never double-records.
|
|
516
|
+
// An explicit recallRunId wins; recordRecall only mints a NEW audit run when
|
|
517
|
+
// the caller did not already link one (never clobber an existing link).
|
|
518
|
+
let runId = recallRunId ?? null;
|
|
519
|
+
if (recordRecall && runId === null) {
|
|
520
|
+
try {
|
|
521
|
+
const run = store.saveRecallRun({
|
|
522
|
+
query: q,
|
|
523
|
+
mode,
|
|
524
|
+
topK,
|
|
525
|
+
threshold: threshold ?? null,
|
|
526
|
+
candidates: rows.map((m) => ({
|
|
527
|
+
id: m.id,
|
|
528
|
+
title: m.title,
|
|
529
|
+
content: m.content,
|
|
530
|
+
score: m.score ?? null,
|
|
531
|
+
source: m.source ?? "keyword"
|
|
532
|
+
})),
|
|
533
|
+
created_at: new Date().toISOString()
|
|
534
|
+
});
|
|
535
|
+
runId = run.id;
|
|
536
|
+
} catch { /* non-fatal: the eval itself still succeeds */ }
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
let persisted = false;
|
|
540
|
+
if (persist) {
|
|
541
|
+
try {
|
|
542
|
+
store.saveRecallEval({
|
|
543
|
+
recall_run_id: runId,
|
|
544
|
+
query: q,
|
|
545
|
+
expected_ids: expected,
|
|
546
|
+
actual_ids: actualIds,
|
|
547
|
+
metrics,
|
|
548
|
+
eval_type: evalType,
|
|
549
|
+
created_at: new Date().toISOString()
|
|
550
|
+
});
|
|
551
|
+
persisted = true;
|
|
552
|
+
} catch { /* non-fatal: measurement survives a failed eval write */ }
|
|
553
|
+
}
|
|
554
|
+
return { metrics, actualIds, expectedIds: expected, recallRunId: runId, persisted };
|
|
555
|
+
}
|
|
556
|
+
|
|
391
557
|
/**
|
|
392
558
|
* Fire-and-forget write notification; errors are swallowed to keep write
|
|
393
559
|
* paths clean. The store mutation has already committed, so a throwing
|
|
@@ -438,25 +604,91 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
438
604
|
}
|
|
439
605
|
}
|
|
440
606
|
|
|
607
|
+
/**
|
|
608
|
+
* Embed an arbitrary query text and return its vector (null on failure / no
|
|
609
|
+
* embedder). Used by the injector to prefetch the semantic-first recall
|
|
610
|
+
* vector for the current user message — the system-prompt render is
|
|
611
|
+
* synchronous, so the vector must be cached in advance (Bug4).
|
|
612
|
+
*/
|
|
613
|
+
async function embedQuery(query) {
|
|
614
|
+
const q = String(query ?? "").trim();
|
|
615
|
+
if (!q || !embedder) return null;
|
|
616
|
+
try {
|
|
617
|
+
const embedSingle = typeof embedder.embedSingle === "function"
|
|
618
|
+
? embedder.embedSingle.bind(embedder)
|
|
619
|
+
: embedder.embed.bind(embedder);
|
|
620
|
+
const vector = await embedSingle(q);
|
|
621
|
+
return Array.isArray(vector) && vector.length ? vector : null;
|
|
622
|
+
} catch {
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
441
627
|
/**
|
|
442
628
|
* Save a memory, merging into an existing one when title matches within the same type.
|
|
629
|
+
*
|
|
630
|
+
* Bug5: a same-title merge no longer overwrites — the new content is appended
|
|
631
|
+
* under a timestamped `---` separator (`旧内容\n\n---\n[时间戳] 新内容`) and the
|
|
632
|
+
* previous content is archived into content_history (source: auto_merge, FIFO
|
|
633
|
+
* capped at 20). importance takes the max of both (capped at 5). Callers that
|
|
634
|
+
* truly replace a row (dream summary regeneration) pass `_overwrite: true` to
|
|
635
|
+
* overwrite directly while still archiving the old version (source: overwrite).
|
|
636
|
+
* mergeHumanEdits entry points pass `_humanEdited: true` — same direct
|
|
637
|
+
* overwrite semantics, source: human_override.
|
|
638
|
+
*
|
|
639
|
+
* Bug7 (memory quality filter): when config.memoryQualityFilter.enabled, the
|
|
640
|
+
* memory is scored after dedupe, before write:
|
|
641
|
+
* score >= degradeThreshold → stored normally (score persisted)
|
|
642
|
+
* archiveThreshold <= score < 60 → persisted + ranked degraded
|
|
643
|
+
* score < archiveThreshold → archived + tagged low_quality (still
|
|
644
|
+
* explicitly searchable via includeArchived)
|
|
443
645
|
* @returns {{action: "created"|"merged", memory: object}}
|
|
444
646
|
*/
|
|
445
647
|
function saveWithDedupe(memory) {
|
|
648
|
+
// Bug7: score quality once (after dedupe lookup, before write). Failures
|
|
649
|
+
// inside the evaluator are impossible (pure function), but the write that
|
|
650
|
+
// records the score must never fail the save — wrap defensively.
|
|
651
|
+
const qf = config.memoryQualityFilter;
|
|
652
|
+
let quality = null;
|
|
653
|
+
if (qf?.enabled === true) {
|
|
654
|
+
try {
|
|
655
|
+
const recentContents = store.all().slice(0, 20).map((m) => m.content ?? "");
|
|
656
|
+
quality = evaluateMemoryQuality(memory, {
|
|
657
|
+
minContentLength: qf.minContentLength ?? 10,
|
|
658
|
+
recentContents
|
|
659
|
+
});
|
|
660
|
+
} catch { /* quality scoring is best-effort */ }
|
|
661
|
+
}
|
|
446
662
|
const existing = store
|
|
447
663
|
.list({ type: memory.type, limit: 100 })
|
|
448
664
|
.find((m) => m.title.trim() === String(memory.title).trim());
|
|
449
665
|
if (existing) {
|
|
666
|
+
const newContent = String(memory.content ?? "");
|
|
667
|
+
if (!newContent.trim()) {
|
|
668
|
+
// Nothing to merge: the row stays untouched.
|
|
669
|
+
return { action: "merged", memory: existing };
|
|
670
|
+
}
|
|
671
|
+
const direct = memory._overwrite === true || memory._humanEdited === true;
|
|
672
|
+
const content = direct
|
|
673
|
+
? newContent
|
|
674
|
+
: appendContent(existing.content, newContent);
|
|
675
|
+
const importance = Math.min(5, Math.max(existing.importance, memory.importance ?? existing.importance));
|
|
450
676
|
const merged = store.update(existing.id, {
|
|
451
|
-
content
|
|
452
|
-
importance
|
|
677
|
+
content,
|
|
678
|
+
importance,
|
|
453
679
|
tags: memory.tags ?? existing.tags,
|
|
454
|
-
title: memory.title ?? existing.title
|
|
680
|
+
title: memory.title ?? existing.title,
|
|
681
|
+
content_history: pushContentHistory(existing, direct
|
|
682
|
+
? (memory._humanEdited === true ? "human_override" : "overwrite")
|
|
683
|
+
: "auto_merge"),
|
|
684
|
+
...(quality ? { quality_score: quality.score } : {})
|
|
455
685
|
});
|
|
686
|
+
// Bug7: a degraded/archived result is applied on top of the merged row.
|
|
687
|
+
const result = applyQualityDisposition(merged, quality, qf);
|
|
456
688
|
afterSync("write");
|
|
457
689
|
notifyWrite();
|
|
458
|
-
scheduleEmbed(
|
|
459
|
-
return { action: "merged", memory:
|
|
690
|
+
scheduleEmbed(result);
|
|
691
|
+
return { action: "merged", memory: result };
|
|
460
692
|
}
|
|
461
693
|
const created = store.save({
|
|
462
694
|
type: memory.type,
|
|
@@ -464,13 +696,44 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
464
696
|
content: memory.content,
|
|
465
697
|
tags: memory.tags ?? [],
|
|
466
698
|
importance: memory.importance ?? 3,
|
|
467
|
-
source: memory.source ?? "manual"
|
|
699
|
+
source: memory.source ?? "manual",
|
|
700
|
+
...(quality ? { quality_score: quality.score } : {})
|
|
468
701
|
});
|
|
702
|
+
const result = applyQualityDisposition(created, quality, qf);
|
|
469
703
|
afterSync("write");
|
|
470
704
|
notifyWrite();
|
|
471
|
-
scheduleEmbed(
|
|
472
|
-
scheduleEntityExtraction(
|
|
473
|
-
return { action: "created", memory:
|
|
705
|
+
scheduleEmbed(result);
|
|
706
|
+
scheduleEntityExtraction(result);
|
|
707
|
+
return { action: "created", memory: result };
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Bug7: apply the quality verdict to a freshly written row. Below the archive
|
|
712
|
+
* threshold the memory is archived + tagged low_quality (still searchable
|
|
713
|
+
* explicitly via includeArchived); between archive and degrade thresholds the
|
|
714
|
+
* score is already persisted and only the injection ranking is affected
|
|
715
|
+
* (importance × score/100). Best-effort: a disposition write failure must
|
|
716
|
+
* never fail the save. Returns the (possibly refreshed) memory row so callers
|
|
717
|
+
* see the archived/tagged state, not the pre-disposition snapshot.
|
|
718
|
+
*/
|
|
719
|
+
function applyQualityDisposition(memory, quality, qf) {
|
|
720
|
+
if (!quality || qf?.enabled !== true) return memory;
|
|
721
|
+
const archiveThreshold = qf.archiveThreshold ?? 30;
|
|
722
|
+
// Signal tags (meta / repetitive / duplicate / short_content / low_quality)
|
|
723
|
+
// are merged onto the stored row in every assessed band so the verdict is
|
|
724
|
+
// observable, not just the numeric score. Below the archive threshold the
|
|
725
|
+
// memory is additionally archived (still explicitly searchable).
|
|
726
|
+
const tags = [...new Set([...(memory.tags ?? []), ...(quality.tags ?? [])])];
|
|
727
|
+
if (tags.length === (memory.tags?.length ?? 0) && quality.score >= archiveThreshold) {
|
|
728
|
+
return memory; // no tag drift and not archived → nothing extra to write
|
|
729
|
+
}
|
|
730
|
+
try {
|
|
731
|
+
store.update(memory.id, { tags, quality_score: quality.score });
|
|
732
|
+
if (quality.score < archiveThreshold) store.setArchived(memory.id, true);
|
|
733
|
+
return store.getById(memory.id);
|
|
734
|
+
} catch {
|
|
735
|
+
return memory;
|
|
736
|
+
}
|
|
474
737
|
}
|
|
475
738
|
|
|
476
739
|
/**
|
|
@@ -479,17 +742,71 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
479
742
|
* importance >= threshold. History is never auto-injected. Archived entries
|
|
480
743
|
* are excluded (store.list already filters them by default; the extra
|
|
481
744
|
* !m.archived check is kept as double insurance).
|
|
745
|
+
*
|
|
746
|
+
* Bug4 (hybridInject): when a non-empty `query` is available and a matching
|
|
747
|
+
* semantic recall was cached by the last searchMemories, the vector hits
|
|
748
|
+
* lead the selection (up to maxItems*2 candidates) and the rule-based pick
|
|
749
|
+
* fills + dedupes the remaining slots. Empty query / no cached recall /
|
|
750
|
+
* hybridInject off → pure legacy rule-based selection.
|
|
482
751
|
*/
|
|
483
|
-
function injectCandidates({ maxItems = 5, threshold = 3 } = {}) {
|
|
752
|
+
function injectCandidates({ query = "", maxItems = 5, threshold = 3, queryVector } = {}) {
|
|
753
|
+
const q = String(query ?? "").trim();
|
|
754
|
+
// Bug7: quality-weighted importance in the rule-based tier. Unassessed rows
|
|
755
|
+
// (quality_score null) count as 100 (weight 1), so legacy stores keep their
|
|
756
|
+
// exact summary>preference>importance ordering.
|
|
757
|
+
const qualityWeight = (m) => (m.quality_score != null ? m.quality_score / 100 : 1);
|
|
484
758
|
const items = store.list({ limit: 200, includeForgotten: false })
|
|
485
759
|
.filter((m) => !m.archived && INJECT_TYPES.has(m.type) && !m.forgotten &&
|
|
486
760
|
(m.type === "summary" || m.type === "preference" || m.importance >= threshold))
|
|
487
761
|
.sort((a, b) => {
|
|
488
762
|
const pa = a.type === "summary" ? 0 : a.type === "preference" ? 1 : 2;
|
|
489
763
|
const pb = b.type === "summary" ? 0 : b.type === "preference" ? 1 : 2;
|
|
490
|
-
return pa - pb || b.importance - a.importance;
|
|
764
|
+
return pa - pb || (b.importance * qualityWeight(b)) - (a.importance * qualityWeight(a));
|
|
491
765
|
});
|
|
492
|
-
|
|
766
|
+
let candidates = items;
|
|
767
|
+
if (config.hybridInject !== false && q) {
|
|
768
|
+
// Bug4: semantic-first recall. Vector hits (queryVector, cached by the
|
|
769
|
+
// injector's async prefetch) lead when present; otherwise the last
|
|
770
|
+
// searchMemories recall for the exact same query is reused. Rule-based
|
|
771
|
+
// items fill + dedupe the remaining slots. Empty query / no vector /
|
|
772
|
+
// no cached recall → pure legacy rule-based selection.
|
|
773
|
+
const semanticItems = [];
|
|
774
|
+
if (Array.isArray(queryVector) && queryVector.length && vectorIndex) {
|
|
775
|
+
try {
|
|
776
|
+
const hits = vectorIndex.search(queryVector, { limit: maxItems * 2, threshold: 0 });
|
|
777
|
+
for (const m of hits) {
|
|
778
|
+
if (m && !m.archived && INJECT_TYPES.has(m.type) && !m.forgotten &&
|
|
779
|
+
(m.type === "summary" || m.type === "preference" || m.importance >= threshold)) {
|
|
780
|
+
semanticItems.push(m);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
} catch { /* vector unavailable: fall through to the recall cache */ }
|
|
784
|
+
}
|
|
785
|
+
if (!semanticItems.length && lastSemanticRecall?.query === q && lastSemanticRecall.items?.length) {
|
|
786
|
+
for (const m of lastSemanticRecall.items) {
|
|
787
|
+
if (m && !m.archived && INJECT_TYPES.has(m.type) && !m.forgotten) semanticItems.push(m);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
if (semanticItems.length) {
|
|
791
|
+
const seen = new Set();
|
|
792
|
+
const merged = [];
|
|
793
|
+
const push = (m) => {
|
|
794
|
+
if (seen.has(m.id)) return;
|
|
795
|
+
seen.add(m.id);
|
|
796
|
+
merged.push(m);
|
|
797
|
+
};
|
|
798
|
+
for (const m of semanticItems) {
|
|
799
|
+
push(m);
|
|
800
|
+
if (merged.length >= maxItems * 2) break;
|
|
801
|
+
}
|
|
802
|
+
for (const m of items) {
|
|
803
|
+
if (merged.length >= maxItems * 2) break;
|
|
804
|
+
push(m);
|
|
805
|
+
}
|
|
806
|
+
candidates = merged;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
const selected = candidates.slice(0, maxItems);
|
|
493
810
|
touchRecalled(selected);
|
|
494
811
|
return selected;
|
|
495
812
|
}
|
|
@@ -522,7 +839,15 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
522
839
|
// 人工编辑回灌后触发 re-embed(issue #3 残留修复):向量必须与
|
|
523
840
|
// 新 title/content 一致。scheduleEmbed 为 fire-and-forget,
|
|
524
841
|
// 内部 try/catch 吞错,失败不影响主流程。
|
|
525
|
-
|
|
842
|
+
// Bug5: human edits overwrite directly, but the machine version is
|
|
843
|
+
// archived into content_history (source: human_override) before being
|
|
844
|
+
// replaced, so a manual correction never silently destroys the old value.
|
|
845
|
+
const merged = store.update(edit.id, {
|
|
846
|
+
...patch,
|
|
847
|
+
content_history: patch.content !== undefined && existing.content !== patch.content
|
|
848
|
+
? pushContentHistory(existing, "human_override")
|
|
849
|
+
: existing.content_history
|
|
850
|
+
});
|
|
526
851
|
applied++;
|
|
527
852
|
scheduleEmbed(merged);
|
|
528
853
|
}
|
|
@@ -867,6 +1192,9 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
867
1192
|
setReranker(rn) { reranker = rn; },
|
|
868
1193
|
setRecallRecorder(fn) { recallRecorder = fn; },
|
|
869
1194
|
searchMemories,
|
|
1195
|
+
embedQuery,
|
|
1196
|
+
evaluateRetrieval,
|
|
1197
|
+
computeRetrievalMetrics,
|
|
870
1198
|
// passthroughs used by tools and api layers; mutations keep the mirror in sync
|
|
871
1199
|
search: (q, o) => store.search(q, o),
|
|
872
1200
|
searchVector: (v, o) => store.searchVector(v, o),
|
|
@@ -999,6 +1327,19 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
999
1327
|
listConflictPending: (opts) => store.listConflictPending(opts),
|
|
1000
1328
|
resolveConflictPending: (id, o) => store.resolveConflictPending(id, o),
|
|
1001
1329
|
countConflictPending: () => store.countConflictPending(),
|
|
1330
|
+
// Recall evaluation trail (方案 B): audit-bookkeeping semantics like the
|
|
1331
|
+
// dream/recall passthroughs above — a recall_evals write is a snapshot, not
|
|
1332
|
+
// a memory mutation, so it never triggers write hooks.
|
|
1333
|
+
saveRecallEval: (r) => store.saveRecallEval(r),
|
|
1334
|
+
getRecallEval: (id) => store.getRecallEval(id),
|
|
1335
|
+
listRecallEvals: (opts) => store.listRecallEvals(opts),
|
|
1336
|
+
// LLM audit trail (Bug8): bookkeeping semantics like the recall/dream
|
|
1337
|
+
// passthroughs — a saveLlmAudit write never triggers write hooks.
|
|
1338
|
+
saveLlmAudit: (entry) => store.saveLlmAudit(entry),
|
|
1339
|
+
listLlmAudits: (opts) => store.listLlmAudits(opts),
|
|
1340
|
+
countLlmAudits: (opts) => store.countLlmAudits(opts),
|
|
1341
|
+
getLlmAuditStats: (opts) => store.getLlmAuditStats(opts),
|
|
1342
|
+
deleteOldLlmAudits: (before) => store.deleteOldLlmAudits(before),
|
|
1002
1343
|
// Entity gene (v0.3.0) passthroughs for the autoDream apply path
|
|
1003
1344
|
// (applyDecisions): records supersedes relations after an update and
|
|
1004
1345
|
// migrates entity_attrs on merge. Bookkeeping writes like the audit
|