@modusensus/dsh-mneme 0.4.5 → 0.4.7

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.
@@ -0,0 +1,123 @@
1
+ // Rule-based memory quality filter (Bug7). Pure + total: no shared state, no
2
+ // async, no external calls, so it can be unit-tested in isolation and wired
3
+ // into the writer without any I/O or store access.
4
+ //
5
+ // evaluateMemoryQuality scores a memory 0-100 and tags low-value signals. The
6
+ // writer then decides (config.memoryQualityFilter):
7
+ // score >= degradeThreshold (60) → stored normally
8
+ // archiveThreshold (30) <= score < 60 → quality_score persisted; the
9
+ // injection sort re-ranks by importance * quality_score/100 (degraded)
10
+ // score < archiveThreshold (30) → archived + tagged low_quality (still
11
+ // recallable via explicit search, just never auto-injected)
12
+ //
13
+ // Signals and their deductions from the base 100:
14
+ // meta meta-memory vocabulary (the memory talks about the
15
+ // memory system itself, not the user's world) −45
16
+ // self_referential title/content mentions its own type label −15
17
+ // short_content content shorter than minContentLength −80
18
+ // repetitive dedup ratio (unique chars / total) < 0.3 −50
19
+ // duplicate bigram similarity to a recent memory > 0.85 −80
20
+ //
21
+ // The meta signal alone lands a well-formed memory in the degraded band
22
+ // (30..60) — it is still stored and searchable, just demoted in injection.
23
+ // Reaching the archive band (< 30) needs a degenerate body (short, repetitive
24
+ // or near-duplicated) or stacked signals.
25
+
26
+ export const META_MEMORY_RE =
27
+ /记忆|mneme|recall|inject|上下文|token|prompt|系统指令|作为AI|作为助手|我需要记住|总结一下刚才/;
28
+
29
+ // Own-type labels, used for self-reference detection (the English type value
30
+ // the AI writers emit plus the Chinese equivalent a human would type).
31
+ const TYPE_LABELS = {
32
+ preference: ["preference", "偏好"],
33
+ project: ["project", "项目"],
34
+ decision: ["decision", "决策", "决定"],
35
+ history: ["history", "历史", "事件"],
36
+ summary: ["summary", "总结", "摘要", "总览"],
37
+ pattern: ["pattern", "模式", "规律"]
38
+ };
39
+
40
+ /** Normalized bigram-overlap similarity in [0,1]; 0 for tiny/empty inputs. */
41
+ export function textSimilarity(a, b) {
42
+ const bigrams = (s) => {
43
+ const set = new Set();
44
+ const t = String(s).replace(/\s+/g, "");
45
+ for (let i = 0; i < t.length - 1; i++) set.add(t.slice(i, i + 2));
46
+ return set;
47
+ };
48
+ const A = bigrams(a);
49
+ const B = bigrams(b);
50
+ if (!A.size || !B.size) return 0;
51
+ let inter = 0;
52
+ for (const g of A) if (B.has(g)) inter++;
53
+ return inter / Math.min(A.size, B.size);
54
+ }
55
+
56
+ /** Fraction of characters that are unique (dedup ratio in [0,1]). */
57
+ export function dedupRatio(text) {
58
+ const t = String(text);
59
+ if (!t.length) return 0;
60
+ return new Set(t).size / t.length;
61
+ }
62
+
63
+ /**
64
+ * Score a memory's quality. `recentContents` (optional) is the list of recent
65
+ * memory contents used for near-duplicate detection; when omitted the duplicate
66
+ * signal is skipped. Never throws: every input is coerced defensively.
67
+ * @param {object} memory { type, title, content }
68
+ * @param {object} [options]
69
+ * @param {number} [options.minContentLength=10]
70
+ * @param {string[]} [options.recentContents] up to ~20 recent contents
71
+ * @returns {{score: number, tags: string[], reason: string}}
72
+ */
73
+ export function evaluateMemoryQuality(memory, options = {}) {
74
+ const minContentLength = options.minContentLength ?? 10;
75
+ const recentContents = Array.isArray(options.recentContents) ? options.recentContents : [];
76
+ const title = String(memory?.title ?? "");
77
+ const content = String(memory?.content ?? "");
78
+ const text = `${title}\n${content}`;
79
+ const trimmed = content.trim();
80
+ const tags = [];
81
+ const reasons = [];
82
+ let score = 100;
83
+
84
+ if (META_MEMORY_RE.test(text)) {
85
+ score -= 45;
86
+ tags.push("meta");
87
+ reasons.push("meta-memory vocabulary");
88
+ }
89
+
90
+ const labels = TYPE_LABELS[memory?.type];
91
+ if (labels && labels.some((l) => text.includes(l))) {
92
+ score -= 15;
93
+ tags.push("self_referential");
94
+ reasons.push("mentions its own type");
95
+ }
96
+
97
+ if (minContentLength > 0 && trimmed.length < minContentLength) {
98
+ score -= 80;
99
+ tags.push("short_content");
100
+ reasons.push(`content shorter than ${minContentLength} chars`);
101
+ }
102
+
103
+ if (trimmed.length > 0 && dedupRatio(trimmed) < 0.3) {
104
+ score -= 50;
105
+ tags.push("repetitive");
106
+ reasons.push("repetitive content");
107
+ }
108
+
109
+ if (recentContents.length > 0 && trimmed.length > 0) {
110
+ for (const other of recentContents) {
111
+ if (textSimilarity(trimmed, other) > 0.85) {
112
+ score -= 80;
113
+ tags.push("duplicate");
114
+ reasons.push("near-duplicate of a recent memory");
115
+ break;
116
+ }
117
+ }
118
+ }
119
+
120
+ score = Math.max(0, Math.min(100, Math.round(score)));
121
+ if (score < 30) tags.push("low_quality");
122
+ return { score, tags: [...new Set(tags)], reason: reasons.length ? reasons.join("; ") : "ok" };
123
+ }
package/lib/service.js CHANGED
@@ -1,5 +1,6 @@
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
 
@@ -9,6 +10,29 @@ const INJECT_TYPES = new Set(["preference", "project", "decision", "summary"]);
9
10
  // unknown statuses are unscaled (×1). Off by default, so nothing changes.
10
11
  const EPISTEMIC_WEIGHTS = { observation: 1.0, inferred: 0.85, subjective: 0.7 };
11
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
+
12
36
  /**
13
37
  * Standard retrieval-quality metrics over the ordered candidate ids actually
14
38
  * returned vs the ids the evaluator marked relevant (方案 B). Pure + total, so
@@ -69,6 +93,13 @@ export function createService({ store, mirror, config, onWrite, logger }) {
69
93
  // judgment-layer audit trail (dream_runs).
70
94
  let recallRecorder = null;
71
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
+
72
103
  // Transaction nesting depth. Inside service.transaction the per-mutation side
73
104
  // effects (mirror render, write notify, re-embed) are deferred so a ROLLBACK
74
105
  // never leaves the mirror file diverged from the database; transaction()
@@ -429,6 +460,9 @@ export function createService({ store, mirror, config, onWrite, logger }) {
429
460
  });
430
461
  } catch { /* recall receipt is best effort */ }
431
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 };
432
466
  touchRecalled(result);
433
467
  return result;
434
468
  }
@@ -570,25 +604,91 @@ export function createService({ store, mirror, config, onWrite, logger }) {
570
604
  }
571
605
  }
572
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
+
573
627
  /**
574
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)
575
645
  * @returns {{action: "created"|"merged", memory: object}}
576
646
  */
577
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
+ }
578
662
  const existing = store
579
663
  .list({ type: memory.type, limit: 100 })
580
664
  .find((m) => m.title.trim() === String(memory.title).trim());
581
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));
582
676
  const merged = store.update(existing.id, {
583
- content: memory.content ?? existing.content,
584
- importance: memory.importance ?? existing.importance,
677
+ content,
678
+ importance,
585
679
  tags: memory.tags ?? existing.tags,
586
- 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 } : {})
587
685
  });
686
+ // Bug7: a degraded/archived result is applied on top of the merged row.
687
+ const result = applyQualityDisposition(merged, quality, qf);
588
688
  afterSync("write");
589
689
  notifyWrite();
590
- scheduleEmbed(merged);
591
- return { action: "merged", memory: merged };
690
+ scheduleEmbed(result);
691
+ return { action: "merged", memory: result };
592
692
  }
593
693
  const created = store.save({
594
694
  type: memory.type,
@@ -596,13 +696,44 @@ export function createService({ store, mirror, config, onWrite, logger }) {
596
696
  content: memory.content,
597
697
  tags: memory.tags ?? [],
598
698
  importance: memory.importance ?? 3,
599
- source: memory.source ?? "manual"
699
+ source: memory.source ?? "manual",
700
+ ...(quality ? { quality_score: quality.score } : {})
600
701
  });
702
+ const result = applyQualityDisposition(created, quality, qf);
601
703
  afterSync("write");
602
704
  notifyWrite();
603
- scheduleEmbed(created);
604
- scheduleEntityExtraction(created);
605
- return { action: "created", memory: created };
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
+ }
606
737
  }
607
738
 
608
739
  /**
@@ -611,17 +742,71 @@ export function createService({ store, mirror, config, onWrite, logger }) {
611
742
  * importance >= threshold. History is never auto-injected. Archived entries
612
743
  * are excluded (store.list already filters them by default; the extra
613
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.
614
751
  */
615
- 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);
616
758
  const items = store.list({ limit: 200, includeForgotten: false })
617
759
  .filter((m) => !m.archived && INJECT_TYPES.has(m.type) && !m.forgotten &&
618
760
  (m.type === "summary" || m.type === "preference" || m.importance >= threshold))
619
761
  .sort((a, b) => {
620
762
  const pa = a.type === "summary" ? 0 : a.type === "preference" ? 1 : 2;
621
763
  const pb = b.type === "summary" ? 0 : b.type === "preference" ? 1 : 2;
622
- return pa - pb || b.importance - a.importance;
764
+ return pa - pb || (b.importance * qualityWeight(b)) - (a.importance * qualityWeight(a));
623
765
  });
624
- const selected = items.slice(0, maxItems);
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);
625
810
  touchRecalled(selected);
626
811
  return selected;
627
812
  }
@@ -654,7 +839,15 @@ export function createService({ store, mirror, config, onWrite, logger }) {
654
839
  // 人工编辑回灌后触发 re-embed(issue #3 残留修复):向量必须与
655
840
  // 新 title/content 一致。scheduleEmbed 为 fire-and-forget,
656
841
  // 内部 try/catch 吞错,失败不影响主流程。
657
- const merged = store.update(edit.id, patch);
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
+ });
658
851
  applied++;
659
852
  scheduleEmbed(merged);
660
853
  }
@@ -999,6 +1192,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
999
1192
  setReranker(rn) { reranker = rn; },
1000
1193
  setRecallRecorder(fn) { recallRecorder = fn; },
1001
1194
  searchMemories,
1195
+ embedQuery,
1002
1196
  evaluateRetrieval,
1003
1197
  computeRetrievalMetrics,
1004
1198
  // passthroughs used by tools and api layers; mutations keep the mirror in sync
@@ -1139,6 +1333,13 @@ export function createService({ store, mirror, config, onWrite, logger }) {
1139
1333
  saveRecallEval: (r) => store.saveRecallEval(r),
1140
1334
  getRecallEval: (id) => store.getRecallEval(id),
1141
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),
1142
1343
  // Entity gene (v0.3.0) passthroughs for the autoDream apply path
1143
1344
  // (applyDecisions): records supersedes relations after an update and
1144
1345
  // migrates entity_attrs on merge. Bookkeeping writes like the audit