@modusensus/dsh-mneme 0.11.0 → 0.13.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 +54 -21
- package/docs/CONFIGURATION.md +45 -4
- package/docs/DIRECTORIES.md +114 -0
- package/docs/DISTILL.md +64 -0
- package/docs/DSH-GO.md +97 -0
- package/docs/ENTITIES.md +5 -3
- package/docs/FORGET.md +77 -0
- package/docs/KB.md +110 -0
- package/docs/SLEEP.md +28 -3
- package/docs/STORAGE.md +23 -0
- package/lib/api-standalone.js +4 -1
- package/lib/api.js +744 -26
- package/lib/attachment-text.js +122 -0
- package/lib/choro-command.js +9 -2
- package/lib/client.js +3752 -819
- package/lib/config.js +171 -2
- package/lib/continuity.js +7 -6
- package/lib/diary/auto.js +190 -0
- package/lib/diary/compose.js +440 -0
- package/lib/diary/dir.js +59 -0
- package/lib/diary/export.js +97 -0
- package/lib/diary/index.js +26 -0
- package/lib/diary/library.js +189 -0
- package/lib/diary/qq-inbox.js +142 -0
- package/lib/diary/rollover.js +61 -0
- package/lib/diary/session-provider.js +122 -0
- package/lib/document.js +9 -3
- package/lib/dream/clustering.js +6 -1
- package/lib/dream/decisions.js +56 -5
- package/lib/dream/sleep.js +616 -15
- package/lib/dream.js +45 -13
- package/lib/dsh-go/host.js +162 -0
- package/lib/dsh-go/panel.js +2091 -0
- package/lib/dsh-go/text.js +342 -0
- package/lib/dsh-go/views/common.js +68 -0
- package/lib/dsh-go/views/entity.js +48 -0
- package/lib/dsh-go/views/memory.js +198 -0
- package/lib/dsh-go/views/root.js +30 -0
- package/lib/dsh-go/views/settings.js +398 -0
- package/lib/dsh-go/views/status.js +51 -0
- package/lib/embedding.js +42 -8
- package/lib/entities/extractor.js +36 -7
- package/lib/hot-memory.js +8 -9
- package/lib/index.js +131 -5
- package/lib/inject.js +192 -19
- package/lib/kb/index.js +309 -0
- package/lib/kb/routes.js +220 -0
- package/lib/kb/tools.js +148 -0
- package/lib/lang.js +115 -4
- package/lib/peak-hours.js +57 -2
- package/lib/profile-facts.js +356 -0
- package/lib/semantic.js +347 -60
- package/lib/service.js +302 -70
- package/lib/settings.js +117 -14
- package/lib/store.js +830 -55
- package/lib/summarize.js +38 -11
- package/lib/tools.js +173 -25
- package/lib/user-settings.js +175 -23
- package/lib/vector-index.js +76 -6
- package/lib/wiki/cli-hint.js +90 -0
- package/lib/wiki/queries.js +264 -10
- package/lib/wiki/t1-jobs.js +232 -0
- package/lib/wiki/t1.js +7 -1
- package/package.json +25 -1
- package/scripts/sync-test-badge.mjs +1 -1
- package/scripts/test-count-sync.mjs +9 -5
- package/src/api-standalone.js +4 -1
- package/src/api.js +744 -26
- package/src/attachment-text.js +122 -0
- package/src/choro-command.js +9 -2
- package/src/config.js +171 -2
- package/src/continuity.js +7 -6
- package/src/diary/auto.js +190 -0
- package/src/diary/compose.js +440 -0
- package/src/diary/dir.js +59 -0
- package/src/diary/export.js +97 -0
- package/src/diary/index.js +26 -0
- package/src/diary/library.js +189 -0
- package/src/diary/qq-inbox.js +142 -0
- package/src/diary/rollover.js +61 -0
- package/src/diary/session-provider.js +122 -0
- package/src/document.js +9 -3
- package/src/dream/clustering.js +6 -1
- package/src/dream/decisions.js +56 -5
- package/src/dream/sleep.js +616 -15
- package/src/dream.js +45 -13
- package/src/dsh-go/host.js +162 -0
- package/src/dsh-go/panel.js +2091 -0
- package/src/dsh-go/text.js +342 -0
- package/src/dsh-go/views/common.js +68 -0
- package/src/dsh-go/views/entity.js +48 -0
- package/src/dsh-go/views/memory.js +198 -0
- package/src/dsh-go/views/root.js +30 -0
- package/src/dsh-go/views/settings.js +398 -0
- package/src/dsh-go/views/status.js +51 -0
- package/src/embedding.js +42 -8
- package/src/entities/extractor.js +36 -7
- package/src/hot-memory.js +8 -9
- package/src/index.js +131 -5
- package/src/inject.js +192 -19
- package/src/kb/index.js +309 -0
- package/src/kb/routes.js +220 -0
- package/src/kb/tools.js +148 -0
- package/src/lang.js +115 -4
- package/src/peak-hours.js +57 -2
- package/src/profile-facts.js +356 -0
- package/src/semantic.js +347 -60
- package/src/service.js +302 -70
- package/src/settings.js +117 -14
- package/src/store.js +830 -55
- package/src/summarize.js +38 -11
- package/src/tools.js +173 -25
- package/src/user-settings.js +175 -23
- package/src/vector-index.js +76 -6
- package/src/wiki/cli-hint.js +90 -0
- package/src/wiki/queries.js +264 -10
- package/src/wiki/t1-jobs.js +232 -0
- package/src/wiki/t1.js +7 -1
- package/test/api-routes-gaps.test.js +63 -0
- package/test/api.test.js +377 -4
- package/test/attachment-text.test.js +140 -0
- package/test/bm25-corpus-cache.test.js +145 -0
- package/test/capacity-warning.test.js +262 -0
- package/test/client.test.js +908 -138
- package/test/config.test.js +79 -0
- package/test/conflict-freeze.test.js +45 -1
- package/test/continuity.test.js +8 -1
- package/test/diary-library.test.js +187 -0
- package/test/diary.test.js +576 -0
- package/test/document.test.js +32 -0
- package/test/dream-maxtokens-fallback.test.js +3 -3
- package/test/dream.test.js +62 -1
- package/test/dsh-go-host.test.js +134 -0
- package/test/dsh-go-panel-actions.test.js +854 -0
- package/test/dsh-go-panel.test.js +251 -0
- package/test/embedding-cache-f32.test.js +276 -0
- package/test/embedding-ready.test.js +33 -3
- package/test/entities.test.js +73 -0
- package/test/forget-corrections.test.js +70 -0
- package/test/image-memory.test.js +171 -0
- package/test/inject-pin-explicit.test.js +300 -0
- package/test/inject-pin-vector-crowd.test.js +225 -0
- package/test/issue-617-step1-query-and-fusion.test.js +361 -0
- package/test/kb-api-wiring.test.js +128 -0
- package/test/kb-collections.test.js +272 -0
- package/test/kb-routes.test.js +379 -0
- package/test/mint-source.test.js +26 -0
- package/test/peak-hours-observability.test.js +70 -0
- package/test/persona-api.test.js +3 -3
- package/test/persona-by-session.test.js +568 -0
- package/test/persona-settings.test.js +38 -7
- package/test/profile-facts.test.js +492 -0
- package/test/reflection-update-gate.test.js +129 -0
- package/test/reindex-backfill.test.js +334 -0
- package/test/reindex-scheduler.test.js +369 -0
- package/test/scope-candidates.test.js +249 -0
- package/test/session-gate.test.js +1 -1
- package/test/sleep-action-set.test.js +14 -0
- package/test/sleep-heat.test.js +49 -0
- package/test/sleep.test.js +598 -0
- package/test/standalone-api.test.js +24 -0
- package/test/store.test.js +20 -0
- package/test/summarize.test.js +26 -0
- package/test/test-count-sync.test.js +14 -3
- package/test/tool-state-ignore.test.js +65 -0
- package/test/tools-dto-schema-matrix.test.js +23 -2
- package/test/tools.test.js +144 -7
- package/test/user-settings-segments.test.js +528 -0
- package/test/vector-index.test.js +120 -6
- package/test/wiki-api.test.js +476 -1
- package/test/wiki-cli-hint.test.js +118 -0
- package/test/wiki-queries.test.js +91 -1
- package/test/wiki-t1-jobs.test.js +125 -0
- package/test/wiki-tools.test.js +131 -76
package/src/service.js
CHANGED
|
@@ -7,6 +7,7 @@ import { STR, langOf } from "./lang.js";
|
|
|
7
7
|
import { computeHeat } from "./heat.js";
|
|
8
8
|
import { recallStats } from "./recall-stats.js";
|
|
9
9
|
import { evaluateMemoryQuality } from "./quality-filter.js";
|
|
10
|
+
import { CAPACITY_WARNING_DEFAULTS } from "./config.js";
|
|
10
11
|
// 外来行(source='imported',导入路径落的来源)的身份判定只许有一个定义处——本文件的
|
|
11
12
|
// 两处汇流口与巩固/降级那些读侧行池都调这同一个谓词,避免各处自己写字符串比较然后各自漂移。
|
|
12
13
|
import { isImportedRow } from "./memory-source.js";
|
|
@@ -516,9 +517,12 @@ export function createService({ store, mirror, config, onWrite, logger, document
|
|
|
516
517
|
// Weighted blend factor for hybrid search; exposed so callers can tune it.
|
|
517
518
|
const DEFAULT_HYBRID_WEIGHTS = { vector: 0.6, keyword: 0.4 };
|
|
518
519
|
|
|
519
|
-
// Cosine over two
|
|
520
|
+
// Cosine over two numeric vectors (shared by the search-time semantic dedup).
|
|
521
|
+
// Accepts typed arrays too — the store's cache holds Float32Array, and
|
|
522
|
+
// `Array.isArray` is false for typed arrays (would score everything 0).
|
|
520
523
|
function cosineVec(a, b) {
|
|
521
|
-
|
|
524
|
+
const isVec = (v) => Array.isArray(v) || (ArrayBuffer.isView(v) && !(v instanceof DataView));
|
|
525
|
+
if (!isVec(a) || !isVec(b) || a.length !== b.length || !a.length) return 0;
|
|
522
526
|
let dot = 0, na = 0, nb = 0;
|
|
523
527
|
for (let i = 0; i < a.length; i++) {
|
|
524
528
|
dot += a[i] * b[i];
|
|
@@ -529,6 +533,25 @@ export function createService({ store, mirror, config, onWrite, logger, document
|
|
|
529
533
|
return dot / (Math.sqrt(na) * Math.sqrt(nb));
|
|
530
534
|
}
|
|
531
535
|
|
|
536
|
+
// BM25 语料索引缓存(#617 审计 O2)。此前每次渲染都 store.list 500 行 + 重新
|
|
537
|
+
// tokenize 全语料,实测 4.318ms/次、每个 step 一次;其中 store.list 占 4.318ms
|
|
538
|
+
// 里的绝大部分,而语料在两次写入之间根本没变。
|
|
539
|
+
//
|
|
540
|
+
// 失效键用 mirror_state.generation,不另建计数器:改正文/可见性的写入(insert /
|
|
541
|
+
// update / remove / setArchived / setForgotten / setContent …)都已经 bump 它,
|
|
542
|
+
// 而 last_accessed_at 与 embedding 这两类写入**刻意不 bump**(store.js 里写明
|
|
543
|
+
// reads must not mark the mirror dirty)——这两类恰好也不改 title+content 语料,
|
|
544
|
+
// 正是我们要的失效粒度,不用额外判断。
|
|
545
|
+
//
|
|
546
|
+
// 水位每次从库里现读,不在进程内记本地计数:多宿主共用一个 memoryDir 是支持形态
|
|
547
|
+
// (busy_timeout + WAL),另一个进程写完 bump 的是库里的 generation,本地计数
|
|
548
|
+
// 永远看不见,只有现读才收得到。
|
|
549
|
+
//
|
|
550
|
+
// 只缓存索引、不缓存查询结果:search() 返回的是 {...row, score} 副本,索引里
|
|
551
|
+
// 留着的原始行不会被调用方改到;缓存结果则会跨 step 串味(同一 query 在不同
|
|
552
|
+
// 池子状态下该给不同答案)。
|
|
553
|
+
let bm25IndexCache = null; // { generation, index } | null
|
|
554
|
+
|
|
532
555
|
/**
|
|
533
556
|
* BM25 third recall path (v0.5.0 1.1). Scores the query tokens against the
|
|
534
557
|
* live non-archived rows and returns the top `limit` hits with scores
|
|
@@ -538,14 +561,40 @@ export function createService({ store, mirror, config, onWrite, logger, document
|
|
|
538
561
|
function bm25Recall(q, limit) {
|
|
539
562
|
if (config?.bm25SearchEnabled === false) return [];
|
|
540
563
|
try {
|
|
541
|
-
const
|
|
542
|
-
if (!
|
|
543
|
-
return
|
|
564
|
+
const index = bm25CorpusIndex();
|
|
565
|
+
if (!index) return [];
|
|
566
|
+
return index.search(q, { limit });
|
|
544
567
|
} catch {
|
|
545
568
|
return [];
|
|
546
569
|
}
|
|
547
570
|
}
|
|
548
571
|
|
|
572
|
+
/**
|
|
573
|
+
* 取(必要时重建)BM25 语料索引。水位读不到时退化成每次重建而不是报错:这条
|
|
574
|
+
* 通路是 recall booster,读不到 mirror_state 只该慢一点,不该让词法召回整批消失。
|
|
575
|
+
*/
|
|
576
|
+
function bm25CorpusIndex() {
|
|
577
|
+
let generation = null;
|
|
578
|
+
try {
|
|
579
|
+
generation = store.getMirrorState().generation;
|
|
580
|
+
} catch { /* 水位不可读:本次退回重建,且不写缓存 */ }
|
|
581
|
+
if (bm25IndexCache && generation !== null && bm25IndexCache.generation === generation) {
|
|
582
|
+
return bm25IndexCache.index;
|
|
583
|
+
}
|
|
584
|
+
const docs = store.list({ limit: 500, includeForgotten: false }).filter((m) => !m.archived);
|
|
585
|
+
if (!docs.length) {
|
|
586
|
+
bm25IndexCache = null;
|
|
587
|
+
return null;
|
|
588
|
+
}
|
|
589
|
+
const index = createBM25Index(docs);
|
|
590
|
+
// 水位读不到时**不碰缓存**,保留上一份「已知有效 generation」的条目:读失败
|
|
591
|
+
// 若只是偶发(库锁、迁移窗口)而期间没有写入,恢复后水位与条目一致,直接命中;
|
|
592
|
+
// 期间真有写入,水位已经变了,下一轮自然重建。反过来把 null 当键存进去则是纯
|
|
593
|
+
// 噪声——那样的条目永远命不中,还把有效的那份挤掉了。
|
|
594
|
+
if (generation !== null) bm25IndexCache = { generation, index };
|
|
595
|
+
return index;
|
|
596
|
+
}
|
|
597
|
+
|
|
549
598
|
/**
|
|
550
599
|
* Graph fourth recall path (issue #219 → #24 块1 锚定层). When the query text
|
|
551
600
|
* mentions a known entity name, memories linked to that entity (attrs +
|
|
@@ -919,18 +968,13 @@ export function createService({ store, mirror, config, onWrite, logger, document
|
|
|
919
968
|
}
|
|
920
969
|
}
|
|
921
970
|
|
|
922
|
-
//
|
|
923
|
-
//
|
|
924
|
-
// rrf/minmax
|
|
925
|
-
//
|
|
926
|
-
//
|
|
927
|
-
//
|
|
928
|
-
|
|
929
|
-
const head = keyword.slice(0, lim);
|
|
930
|
-
const seen = new Set(head.map((m) => m.id));
|
|
931
|
-
const tail = merged.filter((m) => !seen.has(m.id)).slice(0, Math.max(0, lim - head.length));
|
|
932
|
-
merged = head.concat(tail);
|
|
933
|
-
}
|
|
971
|
+
// #617 第 3 条:这里原本还有一段「recipe !== "blend" 且 mode === "auto" 时用
|
|
972
|
+
// keyword.slice(0,lim) 覆盖 merged」的收尾,注释自称是「保留 blend 的 auto
|
|
973
|
+
// 契约」。但 rrf/minmax 的全部意义就是跨源排序——把它们算出来的序整个换成
|
|
974
|
+
// 「关键词命中先上」,等于配置了 rrf 却在 auto 下拿到 keyword 的顺序,配方
|
|
975
|
+
// 白选。auto 的「关键词领位」语义只属于 blend(它在 :906-922 里自己实现了,
|
|
976
|
+
// 且带 seen 去重与容量控制),不该由收尾段替别的配方代劳。删掉后 rrf/minmax
|
|
977
|
+
// 的 auto 排序就是它们各自算出来的排序。
|
|
934
978
|
|
|
935
979
|
return { merged, signals };
|
|
936
980
|
}
|
|
@@ -1775,62 +1819,60 @@ export function createService({ store, mirror, config, onWrite, logger, document
|
|
|
1775
1819
|
(effImportance(a) * qualityWeight(a) * heatOf(a) * scopeMultOf(a));
|
|
1776
1820
|
});
|
|
1777
1821
|
let candidates = items;
|
|
1822
|
+
// #617 第 2 条(前半):向量路与 BM25 路此前是**互斥**的——`if (!semanticItems.length)`
|
|
1823
|
+
// 包住 BM25,于是「有向量」等于「词法命中整批消失」。可两条路测的不是同一件事:
|
|
1824
|
+
// 向量管「意思像」,BM25 管「词面撞上」,专有名词、issue 编号、函数名这类查询
|
|
1825
|
+
// 恰恰是向量最容易糊、词法最准的地方。现在两路都跑,用 RRF(k=60)融合排名:
|
|
1826
|
+
// 某一路的第 1 名恒得 1/(60+1),两路都靠前的行自然走高,不再由「谁先跑」决定。
|
|
1827
|
+
// 另外先按 fetchLimit 多取再过滤——旧代码先按 poolSize 截断再筛门控,筛掉的
|
|
1828
|
+
// 名额永远补不回来(缩水),池子越脏越明显。
|
|
1829
|
+
let fusedRrf = null; // id -> RRF 原始分;selectiveInject 复用它做二次重排
|
|
1778
1830
|
if (config.hybridInject !== false && q) {
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
const
|
|
1831
|
+
const fetchLimit = Math.max(poolSize * 4, 40);
|
|
1832
|
+
const passesGate = (m) => Boolean(m) && !m.archived && injectTypes.has(m.type) && !m.forgotten &&
|
|
1833
|
+
(m.source !== "narrative" || allowNarrative) &&
|
|
1834
|
+
codingGate(m) &&
|
|
1835
|
+
(m.type === "summary" || m.type === "preference" || m.importance >= threshold);
|
|
1836
|
+
const vectorHits = [];
|
|
1785
1837
|
if (Array.isArray(queryVector) && queryVector.length && vectorIndex) {
|
|
1786
1838
|
try {
|
|
1787
|
-
const
|
|
1788
|
-
|
|
1789
|
-
if (m && !m.archived && injectTypes.has(m.type) && !m.forgotten &&
|
|
1790
|
-
(m.source !== "narrative" || allowNarrative) &&
|
|
1791
|
-
codingGate(m) &&
|
|
1792
|
-
(m.type === "summary" || m.type === "preference" || m.importance >= threshold)) {
|
|
1793
|
-
semanticItems.push(m);
|
|
1794
|
-
}
|
|
1839
|
+
for (const m of vectorIndex.search(queryVector, { limit: fetchLimit, threshold: 0 })) {
|
|
1840
|
+
if (passesGate(m)) vectorHits.push(m);
|
|
1795
1841
|
}
|
|
1796
1842
|
} catch { /* vector unavailable: fall through to the recall cache */ }
|
|
1797
1843
|
}
|
|
1798
|
-
if (!
|
|
1799
|
-
// CodeRabbit on #404
|
|
1800
|
-
// BM25 路(下方 :1655 注释「门控与向量路径一致(含 importance 阈值)」)
|
|
1844
|
+
if (!vectorHits.length && lastSemanticRecall?.query === q && lastSemanticRecall.items?.length) {
|
|
1845
|
+
// CodeRabbit on #404:这条回填分支此前独漏重要度门槛——向量路与 BM25 路
|
|
1801
1846
|
// 都带着它。#369 收编 history 后缺口会被放大:库里低于闸门的行(实测
|
|
1802
1847
|
// history 378 条中有 27 条 i<=2)走一次检索进缓存就能绕开门槛占注入位。
|
|
1803
|
-
for (const m of lastSemanticRecall.items)
|
|
1804
|
-
if (m && !m.archived && injectTypes.has(m.type) && !m.forgotten &&
|
|
1805
|
-
(m.source !== "narrative" || allowNarrative) && codingGate(m) &&
|
|
1806
|
-
(m.type === "summary" || m.type === "preference" || m.importance >= threshold)) semanticItems.push(m);
|
|
1807
|
-
}
|
|
1808
|
-
}
|
|
1809
|
-
// Issue #198:首轮(无向量、无缓存召回)的同步兜底——BM25 词法召回领位。
|
|
1810
|
-
// 注入渲染是同步的(宿主 systemPrompt 的 contexts 不支持异步 text),
|
|
1811
|
-
// 查询向量只能异步 prefetch 给下一轮,首轮必 miss;此时纯静态排序会让
|
|
1812
|
-
// 老的高重要性 summary/preference 占满槽位,当前话题的 decision/project
|
|
1813
|
-
// 进不来。查询文本本身就在手上,BM25 是纯进程内同步计算,词法相关性
|
|
1814
|
-
// 足以把当前话题顶到前排;门控与向量路径一致(含 importance 阈值)。
|
|
1815
|
-
// 语义向量命中时本分支不参与,行为不变。
|
|
1816
|
-
if (!semanticItems.length) {
|
|
1817
|
-
for (const hit of bm25Recall(q, poolSize)) {
|
|
1818
|
-
if (hit && !hit.archived && injectTypes.has(hit.type) && !hit.forgotten &&
|
|
1819
|
-
(hit.source !== "narrative" || allowNarrative) &&
|
|
1820
|
-
codingGate(hit) &&
|
|
1821
|
-
(hit.type === "summary" || hit.type === "preference" || hit.importance >= threshold)) {
|
|
1822
|
-
semanticItems.push(hit);
|
|
1823
|
-
}
|
|
1824
|
-
}
|
|
1825
|
-
// issue #339(CodeRabbit review on #350):首轮 BM25 兜底领跑合并池,
|
|
1826
|
-
// 而 selectiveInject 相似度重排要等查询向量就绪才执行——软加权必须
|
|
1827
|
-
// 在这里就位,否则 foreign 命中绕过降权直接占注入位。
|
|
1828
|
-
if (softScopeActive) {
|
|
1829
|
-
semanticItems.sort((a, b) =>
|
|
1830
|
-
(b.score ?? 0) * scopeMultOf(b) - (a.score ?? 0) * scopeMultOf(a));
|
|
1831
|
-
}
|
|
1848
|
+
for (const m of lastSemanticRecall.items) if (passesGate(m)) vectorHits.push(m);
|
|
1832
1849
|
}
|
|
1833
|
-
|
|
1850
|
+
// Issue #198:BM25 词法路。首轮(无向量、无缓存召回)这是唯一的同步相关性
|
|
1851
|
+
// 信号;有向量时它补上向量糊掉的那一类命中。#617 之后它不再被向量挤掉。
|
|
1852
|
+
const bm25Hits = [];
|
|
1853
|
+
for (const hit of bm25Recall(q, fetchLimit)) if (passesGate(hit)) bm25Hits.push(hit);
|
|
1854
|
+
if (vectorHits.length || bm25Hits.length) {
|
|
1855
|
+
const RRF_K = 60;
|
|
1856
|
+
const rrf = new Map();
|
|
1857
|
+
const rowById = new Map();
|
|
1858
|
+
const addList = (list) => {
|
|
1859
|
+
list.forEach((m, idx) => {
|
|
1860
|
+
rowById.set(m.id, m);
|
|
1861
|
+
rrf.set(m.id, (rrf.get(m.id) ?? 0) + 1 / (RRF_K + idx + 1));
|
|
1862
|
+
});
|
|
1863
|
+
};
|
|
1864
|
+
addList(vectorHits);
|
|
1865
|
+
addList(bm25Hits);
|
|
1866
|
+
fusedRrf = rrf;
|
|
1867
|
+
// 软加权(issue #339 / CodeRabbit on #350)作用在融合分上:foreign 行不再
|
|
1868
|
+
// 靠一路的原始 score 绕过降权,也不需要像旧代码那样单独给 BM25 排一次序。
|
|
1869
|
+
const ranked = [...rrf.entries()]
|
|
1870
|
+
.map(([id, score]) => {
|
|
1871
|
+
const m = rowById.get(id);
|
|
1872
|
+
return { m, score: score * scopeMultOf(m) };
|
|
1873
|
+
})
|
|
1874
|
+
.sort((a, b) => b.score - a.score)
|
|
1875
|
+
.map((row) => row.m);
|
|
1834
1876
|
const seen = new Set();
|
|
1835
1877
|
const merged = [];
|
|
1836
1878
|
const push = (m) => {
|
|
@@ -1838,7 +1880,7 @@ export function createService({ store, mirror, config, onWrite, logger, document
|
|
|
1838
1880
|
seen.add(m.id);
|
|
1839
1881
|
merged.push(m);
|
|
1840
1882
|
};
|
|
1841
|
-
for (const m of
|
|
1883
|
+
for (const m of ranked) {
|
|
1842
1884
|
push(m);
|
|
1843
1885
|
if (merged.length >= poolSize) break;
|
|
1844
1886
|
}
|
|
@@ -1849,21 +1891,104 @@ export function createService({ store, mirror, config, onWrite, logger, document
|
|
|
1849
1891
|
candidates = merged;
|
|
1850
1892
|
}
|
|
1851
1893
|
}
|
|
1894
|
+
// pin 池取数修复(#249 第一批):pin 类行被混合合并的封顶挡在候选池外面。
|
|
1895
|
+
// `merged.length >= poolSize` 在 pin 筛选**之前**就封顶(生产 maxItems=5 →
|
|
1896
|
+
// poolSize=10,向量路一次正好返回 10 条即 break),规则窗口里那些
|
|
1897
|
+
// constraint/preference 于是一条都进不了 candidates,pin 池只能从那 10 条里
|
|
1898
|
+
// 碰运气选——而本文件注释(:1919)与 issue #249 验收都写着「pin 不占
|
|
1899
|
+
// maxItems 名额」。实测真库 maxItems=5 时 pin shown 只有 2/5,补回后恒 5/5;
|
|
1900
|
+
// maxItems=10(poolSize=20)时前后都是 5/5——所以这不是多给名额,只是把被
|
|
1901
|
+
// 向量路吃掉的那部分补回来。
|
|
1902
|
+
// 两条纪律:①只在 pin 预算 > 0 时补,预算关时 candidates 与改前逐字节一致;
|
|
1903
|
+
// ②只补到候选池尾部,后面的 strictScope(:1923)与 scopeState(:1930)两道
|
|
1904
|
+
// 闸门照旧作用其上,「pin 池 eligible 取自过滤之后的候选」这条位置纪律不变。
|
|
1905
|
+
//
|
|
1906
|
+
// #249 第二批(显式 pin):判据从「类型」换成「用户亲手钉的 id」。两种口径
|
|
1907
|
+
// 由 pinned_memories 表**里能用的行**回答,而不是由「表里有几行」回答:
|
|
1908
|
+
// - 有可用行 → explicit:按 id 点查(store.listByIdsForInject)取回行,于是
|
|
1909
|
+
// **200 行规则窗口之外的记忆也能进**(这是第二层存在的理由本身);不再按
|
|
1910
|
+
// type 认 pin(写得最全的那版往往是 pattern,限类型就只能拿残缺版)。
|
|
1911
|
+
// - 一条可用行都没有 → 回落旧的类型口径,升级即等价;她钉第一条才切换。
|
|
1912
|
+
//
|
|
1913
|
+
// ⚠ 判据为什么必须看「能用的」而不是 countPinned()>0:pin 行会在 memories
|
|
1914
|
+
// 那边被归档、遗忘、硬删,而 pinned_memories 对这三种一无所知。用 count(*)
|
|
1915
|
+
// 当判据,只要表里剩一行(哪怕是归档行),pinMode 就永远钉在 explicit,而
|
|
1916
|
+
// eligible 恒为空 → pin 全灭且**永不回落**,无日志无报错——正是这批改动要
|
|
1917
|
+
// 治的那类静默失败。删除路径补 DELETE 只堵「硬删」这一种,归档/遗忘比硬删
|
|
1918
|
+
// 常见得多,所以根因在判据,不在清理。
|
|
1919
|
+
// 只要还有一条能用就留在 explicit、只注入能用的那几条:若因为「有 3 条归档」
|
|
1920
|
+
// 就整段回落到类型口径,会突然冒出一批她没钉过的条目,那比少注入更糟。
|
|
1921
|
+
// 补位仍在这段:早于 topic 重排(:1930)、strictScope(:1972)、scopeState
|
|
1922
|
+
// (:1979),所以越权行与暂停作用域行照样过两道闸——pin 不是绕过闸门的通道。
|
|
1923
|
+
// 预算 0 时整段不执行:不读表、不改 candidates,逐字节等于改前。
|
|
1924
|
+
const pinnedBudget = Math.max(0, Math.min(5, Math.floor(config?.pinnedInjectBudget ?? 0)));
|
|
1925
|
+
// null | "explicit" | "type-fallback" | "fallback-none-usable"
|
|
1926
|
+
let pinMode = null;
|
|
1927
|
+
let pinRows = []; // explicit:按 pin 先后(created_at ASC)排好、已滤遗忘/归档
|
|
1928
|
+
let pinOutsideWindow = 0; // explicit:点查取回、原本不在候选池的行数(观测点)
|
|
1929
|
+
if (pinnedBudget > 0) {
|
|
1930
|
+
let pinRecords = [];
|
|
1931
|
+
try {
|
|
1932
|
+
// 先读表(本表行数以个位计);能用的行下一步按 id 点查回来再判。
|
|
1933
|
+
pinRecords = store.countPinned() > 0 ? store.listPinned() : [];
|
|
1934
|
+
} catch { pinRecords = []; }
|
|
1935
|
+
if (pinRecords.length > 0) {
|
|
1936
|
+
try {
|
|
1937
|
+
// 点查带列裁剪(listByIdsForInject 复用 memoryColumnList,已排除 embedding)。
|
|
1938
|
+
// 遗忘/归档行在这里就滤掉:pin 不是复活通道(验收第 7 条)。
|
|
1939
|
+
const byId = new Map(store.listByIdsForInject(pinRecords.map((p) => p.memory_id))
|
|
1940
|
+
.map((m) => [m.id, m]));
|
|
1941
|
+
pinRows = pinRecords.map((p) => byId.get(p.memory_id)).filter((m) => m && !m.forgotten && !m.archived);
|
|
1942
|
+
} catch { pinRows = []; }
|
|
1943
|
+
}
|
|
1944
|
+
if (pinRows.length > 0) {
|
|
1945
|
+
pinMode = "explicit";
|
|
1946
|
+
const knownIds = new Set(candidates.map((m) => m.id));
|
|
1947
|
+
const additions = pinRows.filter((m) => !knownIds.has(m.id));
|
|
1948
|
+
pinOutsideWindow = additions.length;
|
|
1949
|
+
if (additions.length) candidates = [...candidates, ...additions];
|
|
1950
|
+
} else {
|
|
1951
|
+
// 表空,或表里有行但一条都用不了(归档/遗忘/被删)。两种都回落类型口径,
|
|
1952
|
+
// 但 source 分开记:后者是一次**静默降级**,必须留痕,否则读的人只会看到
|
|
1953
|
+
// 「pin 一条都没注入」而不知道为什么。
|
|
1954
|
+
pinMode = pinRecords.length > 0 ? "fallback-none-usable" : "type-fallback";
|
|
1955
|
+
if (candidates !== items) {
|
|
1956
|
+
const knownIds = new Set(candidates.map((m) => m.id));
|
|
1957
|
+
const spilledPins = items.filter(
|
|
1958
|
+
(m) => PINNED_MEMORY_TYPES.has(m.type) && m.graphHint !== true && !knownIds.has(m.id)
|
|
1959
|
+
);
|
|
1960
|
+
if (spilledPins.length) candidates = [...candidates, ...spilledPins];
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1852
1964
|
// Topic-ranked selection (v0.5.0 2.2): when the current query's vector is
|
|
1853
1965
|
// available the whole candidate list is re-ordered by similarity to that
|
|
1854
1966
|
// vector, so the injected slots go to memories on the current topic
|
|
1855
1967
|
// rather than to the rule-based order. Rows the index did not return
|
|
1856
1968
|
// keep their relative order after the scored ones.
|
|
1969
|
+
//
|
|
1970
|
+
// #617 第 2 条(后半):这段此前是**纯余弦**全量 sort,于是上游用 RRF 顶上来的
|
|
1971
|
+
// BM25 命中(词面精确、余弦却低)会被重新压到 maxItems 之后,再被 general 槽
|
|
1972
|
+
// 的 `general.length >= maxItems` 丢掉——融合白做。现在余弦只占一半权重,另一半
|
|
1973
|
+
// 给 RRF 分(同一轮融合算出来的那份):纯余弦命中仍是主体,但词法精确命中
|
|
1974
|
+
// 不会因为「意思不够像」被挤出去。
|
|
1975
|
+
// RRF 归一化:单路第 1 名原始分 1/(60+1),乘 (60+1)/2 得 0.5,双路都在第 1 名
|
|
1976
|
+
// 得 1.0 —— 与余弦同量纲,且天然落在 [0,1]。
|
|
1857
1977
|
if (config?.selectiveInjectEnabled !== false && Array.isArray(queryVector) && queryVector.length && vectorIndex) {
|
|
1858
1978
|
try {
|
|
1859
1979
|
const hits = vectorIndex.search(queryVector, { limit: 200, threshold: 0 });
|
|
1860
1980
|
const sim = new Map(hits.map((m) => [m.id, m.score ?? 0]));
|
|
1861
1981
|
if (sim.size) {
|
|
1862
|
-
|
|
1863
|
-
//
|
|
1982
|
+
const RRF_NORM = (60 + 1) / 2;
|
|
1983
|
+
// 软加权乘在融合分上(不是乘在某一路的原始分上);两路都没有的规则
|
|
1984
|
+
// 候选保持 -1 沉底(缺失项乘乘数会让 foreign 缺失项反而排到自己的缺失
|
|
1985
|
+
// 项之上,故不乘)。
|
|
1864
1986
|
const scopedSim = (m) => {
|
|
1865
1987
|
const s = sim.get(m.id);
|
|
1866
|
-
|
|
1988
|
+
const rrf = fusedRrf?.get(m.id);
|
|
1989
|
+
if (s === undefined && rrf === undefined) return -1;
|
|
1990
|
+
const fused = 0.5 * (s ?? 0) + 0.5 * (rrf ?? 0) * RRF_NORM;
|
|
1991
|
+
return fused * scopeMultOf(m);
|
|
1867
1992
|
};
|
|
1868
1993
|
candidates = [...candidates].sort((a, b) => scopedSim(b) - scopedSim(a));
|
|
1869
1994
|
}
|
|
@@ -1909,12 +2034,24 @@ export function createService({ store, mirror, config, onWrite, logger, document
|
|
|
1909
2034
|
// (验收:pin 不参与跨轮轮换)。独立预算的两层意义:pin 既不占 maxItems
|
|
1910
2035
|
// 名额、也不被 document 预算截断,因此不会把当前任务需要的情景候选挤出去;
|
|
1911
2036
|
// 超预算的条数回报给调用方,在块内如实标注(绝不静默省略)。
|
|
1912
|
-
|
|
2037
|
+
// (pinnedBudget 与口径在候选池那段已算好——见 :1876 起的 #249 第二批注释。)
|
|
1913
2038
|
// eligible 留到块外:未展示条数要等 general 槽选完才算得准(见 selected 之后)。
|
|
1914
2039
|
// graphHint 行不进 pin 池:pin 是「每轮必进的约束/偏好」,而线索行按定义是
|
|
1915
2040
|
// 「链路信息、非事实断言」,且 pin 长在候选池遍历之前——不排除的话,保守档
|
|
1916
2041
|
// (graphInjectHint 关)也能从 pin 侧把它放回注入块,线索开关就被绕过去了。
|
|
1917
|
-
|
|
2042
|
+
//
|
|
2043
|
+
// 两种口径(#249 第二批):explicit 时 eligible 就是「钉过、点查取回、且过了
|
|
2044
|
+
// 两道闸(仍在 candidates 里)」的行,次序按 pin 先后(created_at ASC)——
|
|
2045
|
+
// 稳定偏好按「什么时候被钉」排,而不是按「最近改过」,正是第二层要修的
|
|
2046
|
+
// 那个排序维度;type-fallback 时保持旧口径(候选里按类型筛),升级即等价。
|
|
2047
|
+
const eligible = pinnedBudget > 0
|
|
2048
|
+
? (pinMode === "explicit"
|
|
2049
|
+
? (() => {
|
|
2050
|
+
const live = new Set(candidates.map((m) => m.id));
|
|
2051
|
+
return pinRows.filter((m) => live.has(m.id) && m.graphHint !== true);
|
|
2052
|
+
})()
|
|
2053
|
+
: candidates.filter((m) => PINNED_MEMORY_TYPES.has(m.type) && m.graphHint !== true))
|
|
2054
|
+
: [];
|
|
1918
2055
|
let pinned = [];
|
|
1919
2056
|
if (pinnedBudget > 0 && eligible.length > 0) {
|
|
1920
2057
|
pinned = eligible.slice(0, pinnedBudget);
|
|
@@ -1969,6 +2106,17 @@ export function createService({ store, mirror, config, onWrite, logger, document
|
|
|
1969
2106
|
const shownIds = new Set(selected.map((m) => m.id));
|
|
1970
2107
|
pinnedStats.shown = pinned.length;
|
|
1971
2108
|
pinnedStats.suppressed = eligible.filter((m) => !shownIds.has(m.id)).length;
|
|
2109
|
+
// #249 第二批:口径来源与「绕开窗口」的观测点。source 四档(预算 0 时整个
|
|
2110
|
+
// 键不出现):
|
|
2111
|
+
// "explicit" 用户钉的行里有能用的,按 pin 先后注入
|
|
2112
|
+
// "type-fallback" 表里从没钉过(含升级后还没钉),走旧类型口径
|
|
2113
|
+
// "fallback-none-usable" 钉过,但那些行全被归档/遗忘/删了 —— 这次是一次
|
|
2114
|
+
// 静默降级,留这一档就是为了让它不再静默
|
|
2115
|
+
// 前两档的行为差异在「窗口外也能进」:只有 explicit 享受点查补位,
|
|
2116
|
+
// outsideWindow 也只数这批(cognee 那条「别让它进候选池」的等价做法);
|
|
2117
|
+
// 两个 fallback 档恒 0(它们只能从窗口里取)。
|
|
2118
|
+
pinnedStats.source = pinMode ?? undefined;
|
|
2119
|
+
pinnedStats.outsideWindow = pinOutsideWindow;
|
|
1972
2120
|
}
|
|
1973
2121
|
// Issue #380:注入前判定(preInjectGate)在这里消费——**必须在 touchRecalled
|
|
1974
2122
|
// 之前**,与 strictScope 同纪律(见本文件 :419-422 的注释:闸门放在记账之后,
|
|
@@ -2164,6 +2312,10 @@ export function createService({ store, mirror, config, onWrite, logger, document
|
|
|
2164
2312
|
id: r.id,
|
|
2165
2313
|
run_id: r.run_id ?? null,
|
|
2166
2314
|
reason: r.reason ?? null,
|
|
2315
|
+
// Issue #603 第 2 层:入队时记下的相似度(0–1)。存量行与 LLM 裁决路径
|
|
2316
|
+
// 没记 → null。面板据此排序与画条,不再从 reason 文本里正则回收;
|
|
2317
|
+
// null 与 0 必须区分开(「没测过」不是「不相似」)。
|
|
2318
|
+
similarity: typeof r.similarity === "number" ? r.similarity : null,
|
|
2167
2319
|
created_at: r.created_at,
|
|
2168
2320
|
memory_a: side(r.memory_a),
|
|
2169
2321
|
memory_b: side(r.memory_b)
|
|
@@ -2516,6 +2668,71 @@ export function createService({ store, mirror, config, onWrite, logger, document
|
|
|
2516
2668
|
}
|
|
2517
2669
|
}
|
|
2518
2670
|
|
|
2671
|
+
// getCapacityHealth:容量预警(#275 空间维度)——库体积 / 归档区规模是否过线。
|
|
2672
|
+
// 与 getMirrorHealth 同款 fail-safe:读失败显式 unknown,绝不伪装成 ok,也绝不
|
|
2673
|
+
// 向外抛(/health 还有镜像那半张答复,容量不能把整张健康面拖塌)。
|
|
2674
|
+
// 阈值读 config.capacityWarning;config 缺席(直连调用方 / 测试传裸对象)时退回
|
|
2675
|
+
// config.js 的同一份常量,不另立一套数字。答复里只有数字,**绝不带 storageStats
|
|
2676
|
+
// 的 path**——那是本机文件系统布局,/health 是半张对外面。
|
|
2677
|
+
function getCapacityHealth() {
|
|
2678
|
+
const unknown = () => ({
|
|
2679
|
+
status: "unknown",
|
|
2680
|
+
file_bytes: null,
|
|
2681
|
+
freelist_bytes: null,
|
|
2682
|
+
active_rows: null,
|
|
2683
|
+
archived_rows: null,
|
|
2684
|
+
archived_ratio: null,
|
|
2685
|
+
checks: []
|
|
2686
|
+
});
|
|
2687
|
+
try {
|
|
2688
|
+
const cfg = { ...CAPACITY_WARNING_DEFAULTS, ...(config?.capacityWarning ?? {}) };
|
|
2689
|
+
const stats = store.storageStats();
|
|
2690
|
+
const counts = store.capacityStats();
|
|
2691
|
+
const total = counts.active + counts.archived;
|
|
2692
|
+
const ratio = total > 0 ? counts.archived / total : null;
|
|
2693
|
+
const checks = [];
|
|
2694
|
+
// 每条检查:阈值 > 0 才启用(0 = 显式关掉这一条),过线判 >= ——「到阈值」
|
|
2695
|
+
// 即报警(#275 口径)。没法评的检查直接缺席而不是伪装 ok:file_bytes 为
|
|
2696
|
+
// null(:memory: 库)时体积检查进不了列表,total 为 0 时占比检查同理。
|
|
2697
|
+
if (cfg.dbSizeWarnMb > 0 && stats.fileBytes !== null) {
|
|
2698
|
+
checks.push({
|
|
2699
|
+
key: "db_size",
|
|
2700
|
+
status: stats.fileBytes >= cfg.dbSizeWarnMb * 1048576 ? "warn" : "ok",
|
|
2701
|
+
value_bytes: stats.fileBytes,
|
|
2702
|
+
threshold_bytes: cfg.dbSizeWarnMb * 1048576
|
|
2703
|
+
});
|
|
2704
|
+
}
|
|
2705
|
+
if (cfg.archivedRowsWarn > 0) {
|
|
2706
|
+
checks.push({
|
|
2707
|
+
key: "archived_rows",
|
|
2708
|
+
status: counts.archived >= cfg.archivedRowsWarn ? "warn" : "ok",
|
|
2709
|
+
value: counts.archived,
|
|
2710
|
+
threshold: cfg.archivedRowsWarn
|
|
2711
|
+
});
|
|
2712
|
+
}
|
|
2713
|
+
if (cfg.archivedRatioWarn > 0 && ratio !== null) {
|
|
2714
|
+
checks.push({
|
|
2715
|
+
key: "archived_ratio",
|
|
2716
|
+
status: ratio >= cfg.archivedRatioWarn ? "warn" : "ok",
|
|
2717
|
+
value: Math.round(ratio * 1000) / 1000,
|
|
2718
|
+
threshold: cfg.archivedRatioWarn
|
|
2719
|
+
});
|
|
2720
|
+
}
|
|
2721
|
+
return {
|
|
2722
|
+
status: checks.some((c) => c.status === "warn") ? "warn" : "ok",
|
|
2723
|
+
file_bytes: stats.fileBytes,
|
|
2724
|
+
freelist_bytes: stats.freelistCount * stats.pageSize,
|
|
2725
|
+
active_rows: counts.active,
|
|
2726
|
+
archived_rows: counts.archived,
|
|
2727
|
+
archived_ratio: ratio === null ? null : Math.round(ratio * 1000) / 1000,
|
|
2728
|
+
checks
|
|
2729
|
+
};
|
|
2730
|
+
} catch (error) {
|
|
2731
|
+
logger?.warn?.("getCapacityHealth failed:", error);
|
|
2732
|
+
return unknown();
|
|
2733
|
+
}
|
|
2734
|
+
}
|
|
2735
|
+
|
|
2519
2736
|
// 返回字面量与内部裁决器(resolveConflictPending → applyDecisions 的 conflict
|
|
2520
2737
|
// 处置)共用的写路径:mirror 同步、写通知、嵌入调度全部同源,不能各写一份。
|
|
2521
2738
|
function updateMemory(id, p, ctx = {}) {
|
|
@@ -2669,6 +2886,7 @@ export function createService({ store, mirror, config, onWrite, logger, document
|
|
|
2669
2886
|
organize,
|
|
2670
2887
|
recoverMirror,
|
|
2671
2888
|
getMirrorHealth,
|
|
2889
|
+
getCapacityHealth,
|
|
2672
2890
|
getMirrorState: () => store.getMirrorState(),
|
|
2673
2891
|
injectCandidates,
|
|
2674
2892
|
mergeHumanEdits,
|
|
@@ -2688,6 +2906,15 @@ export function createService({ store, mirror, config, onWrite, logger, document
|
|
|
2688
2906
|
setScopeState: (r) => store.setScopeState(r),
|
|
2689
2907
|
getScopeState: (d, v) => store.getScopeState(d, v),
|
|
2690
2908
|
listScopeStates: (o) => store.listScopeStates(o),
|
|
2909
|
+
// #249 第二批:pin 的读写口。这四个都是纯 bookkeeping——它们不动任何记忆行,
|
|
2910
|
+
// 只动 pinned_memories 这张关系表,所以照 setScopeState 那一批的纪律**不挂
|
|
2911
|
+
// write hook**(不 afterSync、不 notifyWrite):pin 一条不该让 mirror 重渲染,
|
|
2912
|
+
// 也不该把那行记忆的 heat/embedding 重新排队。注入侧只经 store 读本表
|
|
2913
|
+
// (listPinned / listByIdsForInject),写侧只经这里,两处都绕开 memories 主表。
|
|
2914
|
+
pinMemory: (id, opts) => store.pinMemory(id, opts),
|
|
2915
|
+
unpinMemory: (id) => store.unpinMemory(id),
|
|
2916
|
+
listPinned: () => store.listPinned(),
|
|
2917
|
+
countPinned: () => store.countPinned(),
|
|
2691
2918
|
// 巩固(dream.js)侧的过滤出口。暴露出来而不是让 dream 自己读状态表,是因为
|
|
2692
2919
|
// 判定只能有一份实现(gateByScope 注释里那条「过滤点漂移」的教训);dream 侧
|
|
2693
2920
|
// 的三个读入口各自的附加条件不同(有的排 forgotten、有的不排),但「被按下的
|
|
@@ -2792,6 +3019,7 @@ export function createService({ store, mirror, config, onWrite, logger, document
|
|
|
2792
3019
|
// 向量出身的两个分组读数:状态端点 /semantic 用它们区分「有向量」和
|
|
2793
3020
|
// 「可检索」(byModel 按行级 embedding_model,byDimension 按向量维数)。
|
|
2794
3021
|
// passthrough 形状与 embeddedCount 同款,api 层不直接摸 store。
|
|
3022
|
+
countNeedsEmbeddingFor: (modelHash) => store.countNeedsEmbeddingFor(modelHash),
|
|
2795
3023
|
embeddingModelBreakdown: () => store.embeddingModelBreakdown(),
|
|
2796
3024
|
embeddingDimensionBreakdown: () => store.embeddingDimensionBreakdown(),
|
|
2797
3025
|
// #383:维度探针,供 /context 判「库里有没有一条活跃向量与查询同维」。注意
|
|
@@ -2811,6 +3039,8 @@ export function createService({ store, mirror, config, onWrite, logger, document
|
|
|
2811
3039
|
list: (o) => store.list(o),
|
|
2812
3040
|
all: () => store.all(),
|
|
2813
3041
|
count: (type, opts) => store.count(type, opts),
|
|
3042
|
+
// #597:/list 的 typeCounts——同一份筛选、不带 type 的全库分布(面板分类栏数字源)。
|
|
3043
|
+
countByType: (opts) => store.countByType(opts),
|
|
2814
3044
|
getById: (id) => store.getById(id),
|
|
2815
3045
|
remove: (id) => {
|
|
2816
3046
|
store.remove(id);
|
|
@@ -2930,6 +3160,8 @@ export function createService({ store, mirror, config, onWrite, logger, document
|
|
|
2930
3160
|
// migrates entity_attrs on merge. Bookkeeping writes like the audit
|
|
2931
3161
|
// passthroughs above — never write-hook-triggering memory mutations.
|
|
2932
3162
|
saveRelation: (r) => store.saveRelation(r),
|
|
3163
|
+
// #619:同一条边被后续观察补充证据时回写 metadata(情感相位判重命中)。
|
|
3164
|
+
updateRelationMetadata: (id, metadata) => store.updateRelationMetadata(id, metadata),
|
|
2933
3165
|
listEntities: (o) => store.listEntities(o),
|
|
2934
3166
|
countEntities: () => store.countEntities(),
|
|
2935
3167
|
getRelations: (id) => store.getRelations(id),
|