@modusensus/dsh-mneme 0.10.0 → 0.10.1
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 +4 -3
- package/lib/api.js +51 -1
- package/lib/client.js +125 -4
- package/lib/graph/anchoring.js +7 -2
- package/lib/service.js +69 -8
- package/lib/store.js +155 -0
- package/package.json +1 -1
- package/src/api.js +51 -1
- package/src/graph/anchoring.js +7 -2
- package/src/service.js +69 -8
- package/src/store.js +155 -0
- package/test/client.test.js +165 -0
- package/test/graph-boosted-relations.test.js +189 -0
- package/test/graph-multipath.test.js +269 -0
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://www.npmjs.com/package/@modusensus/dsh-mneme)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
[](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
|
|
8
|
-
[](https://github.com/slow-stack/mneme)
|
|
9
9
|
[](https://github.com/slow-stack/mneme/actions)
|
|
10
10
|
[](https://nodejs.org)
|
|
11
11
|
[](https://www.npmjs.com/package/@modusensus/dsh-mneme)
|
|
@@ -285,6 +285,7 @@ v0.3.0 起新增**记忆基因**层:从记忆里抽取**命名实体**、**带
|
|
|
285
285
|
| **v0.9.4**(2026-10-21) | 三条链各自补上「最后一环」——东西早就造好了,只差没人能叫它:**agent 主动整理**(`memory_organize`,先 dryRun 出比对报告 → agent 判断 → apply 落库,筛除只归档不删、全程留审计回执;`organizeEnabled` 默认关,关时工具根本不注册);**画像提取草稿**(面板按钮读活跃记忆 + 一次模型调用,产一段「助手眼中的用户」草稿,`profileExtractEnabled` 默认关)——**只出草稿**,采用是用户再点一次,手写的画像原文不会被模型改写,空库与解析失败都直接报错、绝不回空草稿冒充「没提炼出东西」;**人格寻址收口**(`default` 卡以前无条件落给所有没建卡的身份,宿主里自带身份的 bot 每帧收到两句「你是谁」;`personaStrictAddressing` 默认关,把「没建卡」与「卡被显式关掉」都收回为不注入)。同版补蒸馏的**转录围栏 + 解析失败护栏**(#494:一条会话被同一窗口卡死、重试 20 次越试越贵的根因;失败行现在恒带原始输出形状),把面板自称「注入生效文本」的只读区改成后端单源渲染(并修它从不显示人格行),评审另收掉两处「坏了却不报」:整理报告侧的存在性探测口(`strictScope` 下按调用方可见性过滤落点并重算计数)、画像保存不判 `res.ok` 却提示已保存 |
|
|
286
286
|
| **v0.9.5**(2026-10-30) | 记忆能安全地递给别人,也能整包收回来:**分享包 `mneme.share/v1`**(`dsh-mneme share` / `receive` / `undo-share`,面板「⋯」里同一批三件事)只含活跃行,scope 与 home 前缀不进包,命中密钥 / PII 的行整条不进;出包先给报告再给文件,超 3 MiB 水位回 413、按 `--type` / `--tag` 分批;入包一条不合格整包拒、干跑为默认、按 `share_external_id` 幂等,整包退出走物删。「本机巩固不许改写别人导入的原文」由单一谓词 `isImportedRow` 守在汇流口与九个行池上。同版给记忆来源加了读出口 `GET /memory/provenance`(#421,@z2Ace0107 实现;409 = 用户关了、503 = 装配坏了),修掉同标题重复入库(#505)、扫描谎报「没东西可扫」(#515)、五处未围栏的巩固 prompt(#501)与 dream 合并护栏整类误拦(#521),面板新增设置页分组目录(#518) |
|
|
287
287
|
| **v0.10.0**(2026-11-02) | 归因与闸门——给已经发生的事补上它自己的身份:**蒸馏有了运行身份**(窗口级 `distill_runs` 记 `ok`/`degraded`/`noop`/`failed`/`aborted` 与 `audit_id`,行级 `memory_mints` 记「这条记忆出生自哪一轮巩固」;历史行留空**不猜**,失败轮也真记一行),此前归因只到「会话区间」——那是输入的坐标不是运行的身份;**溯源补上反方向** `GET /memory/provenance?sessionId=`(两个主键互斥,`limit` 越界 400 不静默截断,撞上限带 `truncated: true`);**会话级无痕闸门** `sessionGateEnabled`(指定会话与子代理不进不出,其余会话照常;子代理判据只认宿主委派标记,`parentSession` 不是判据——本机 641 个会话头实测会误判 3 个 fork 主会话);**防谄媚补到写入面**(蒸馏与总览加「立场纪律」四条与「叙述纪律」,排在与时间换算的指令之前)。另修 `differentiate` 正反馈闭环(注记改落 `differentiated_pairs` 账本、正文一个字节都改不了,实测单条曾堆 19 段)、sleep 冲突相位的输入侧预算与续排(分批后不再隔夜才轮到)、面板「最近巩固」区分 organize 回执(#495)、面板首次有移动端与 481–519px 中间带断点(#542) |
|
|
288
|
+
| **v0.10.1**(2026-11-03) | 打开记忆详情不再崩掉整个宿主:0.10.0 里点开侧边栏任意一条记忆的详情抽屉,渲染进程立刻抛 `ReferenceError: provenance is not defined`,整个宿主跟着重启、进行中的会话被打断,100% 复现(#553)。根因是两个隔了三千多行的组件——`MemoryDrawer` 的归因 effect 与渲染块在读 `provenance` / `setProvenance`,而那句 `useState` 落在了同文件**上一个**组件 `EntityPanel` 里,在那里是纯死代码(整个函数体除声明外对 `provenance` 零引用)。这个形状躲得过眼睛、语法检查与原有用例,所以补的不是「锁一个名字」的用例,而是按组件扫「调了 `setXxx(` 却没有声明」。同版带上 v0.10.0 之后合入的图谱锚定层收尾(#24):Tag 与向量两路起点接线(`normalizePath` 补回通路身份、`findEntitiesByTags` 走全等匹配并按 500 分块、反查改 UNION 三源以覆盖只看关系不建 attr 行的记忆、向量路复用同一份 query 向量并滤掉 0 分种子),以及被动确认的复核出口(`GET /semantic/graph/boosted-relations` 按「合法抬升量 > 0」列出、ego 边补 `weight`/`source`、面板默认收起的审计视角) |
|
|
288
289
|
|
|
289
290
|
| **v0.8.x** | 作用域隔离(agent / workspace 双维 + opt-in 硬过滤)、冲突队列人工裁决、生态化(stdio MCP server / 图召回轴 / 冷启动 / 注入截断) |
|
|
290
291
|
|
|
@@ -721,7 +722,7 @@ src/
|
|
|
721
722
|
├── api.js # HTTP 路由(Web 面板数据通道,含 /conflicts 冲突队列)
|
|
722
723
|
└── index.js # 插件接线
|
|
723
724
|
lib/ # src 的同步分发产物(npm run sync,prepack 钩子在发布时自动跑;一致性由 scripts/check-sync.js 在 CI 与 release.yml 校验——它只查不同步;唯一手写例外 lib/client.js——Web 面板 bundle,sync 不覆盖)
|
|
724
|
-
test/ #
|
|
725
|
+
test/ # 2681 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验)
|
|
725
726
|
scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压测 · sync-lib.js 同步 · check-sync.js 发布闸门 · benchmark-recall.js / benchmark-embed.js / benchmark-rerank.js 基准 · sync-test-badge.mjs 测试徽章 · build-runtime-manifest.mjs 运行时清单
|
|
726
727
|
```
|
|
727
728
|
|
|
@@ -730,7 +731,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压
|
|
|
730
731
|
```bash
|
|
731
732
|
cd dsh-mneme
|
|
732
733
|
npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试)
|
|
733
|
-
npm test # 运行
|
|
734
|
+
npm test # 运行 2681 个测试
|
|
734
735
|
npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM)
|
|
735
736
|
npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
|
|
736
737
|
```
|
package/lib/api.js
CHANGED
|
@@ -1438,7 +1438,57 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1438
1438
|
to: e.to_entity,
|
|
1439
1439
|
relation_type: e.relation_type,
|
|
1440
1440
|
memory_id: e.memory_id ?? null,
|
|
1441
|
-
created_at: e.created_at
|
|
1441
|
+
created_at: e.created_at,
|
|
1442
|
+
// issue #24 块4:被动确认的复核要靠这两个字段——weight 是「用出来
|
|
1443
|
+
// 的分」,source 决定它的起点(manual 1.0 / tag 0.3 / llm 0.4)。
|
|
1444
|
+
// 少了它们,「这条边被抬过」在面板上根本看不出来。取不到时给
|
|
1445
|
+
// 初值形状而不是 undefined,前端不必到处判空。
|
|
1446
|
+
weight: e.weight ?? 1.0,
|
|
1447
|
+
source: e.source ?? "manual"
|
|
1448
|
+
}))
|
|
1449
|
+
});
|
|
1450
|
+
} catch {
|
|
1451
|
+
sendJson(res, 500, { error: "internal" });
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
});
|
|
1455
|
+
|
|
1456
|
+
// --- graph boosted relations: the review side of passive confirmation ---
|
|
1457
|
+
// issue #24 块4(被动确认)的复核出口。正常触达即 bump 边权重是安静的设计,
|
|
1458
|
+
// 但「抬过哪些边」必须能看见,否则「例外管理」只剩自动确认没有例外。
|
|
1459
|
+
// 判据是 gain = 现权 − 该 source 初值 > 0(见 store.listBoostedRelations),
|
|
1460
|
+
// 不另设记账表:抬升是唯一会改变检索形状的动作,它自己就是证据。
|
|
1461
|
+
// 只读、挂在 /semantic/graph/ 前缀下(SECURITY.md 的开放读清单已含该通配)。
|
|
1462
|
+
register({
|
|
1463
|
+
kind: "exact",
|
|
1464
|
+
path: "/api/dsh-mneme/semantic/graph/boosted-relations",
|
|
1465
|
+
policy: "open",
|
|
1466
|
+
handler(req, res) {
|
|
1467
|
+
try {
|
|
1468
|
+
const url = new URL(req.url, "http://localhost");
|
|
1469
|
+
const limit = Math.max(1, Math.min(200, Number(url.searchParams.get("limit") ?? 100) || 100));
|
|
1470
|
+
const rows = service.listBoostedRelations?.({ limit }) ?? [];
|
|
1471
|
+
sendJson(res, 200, {
|
|
1472
|
+
count: rows.length,
|
|
1473
|
+
// 提示「这张表为什么可能为空」:被确认抬升的边只在图真的被用过之后
|
|
1474
|
+
// 才出现,两个相关闸默认都关。面板据此说明而不是让人以为坏了。
|
|
1475
|
+
// 必须两个闸同时开才算「会继续产生新提升」——touchRecalled 里
|
|
1476
|
+
// passive = graphPassiveConfirm && graphWeightEnabled,只看前者会让面板
|
|
1477
|
+
// 说「会自动提升」而实际上一条都不会抬(评审指出)。
|
|
1478
|
+
passiveConfirmEnabled: config?.graphPassiveConfirm === true && config?.graphWeightEnabled === true,
|
|
1479
|
+
items: rows.map((r) => ({
|
|
1480
|
+
id: r.id,
|
|
1481
|
+
from: r.from_entity,
|
|
1482
|
+
to: r.to_entity,
|
|
1483
|
+
from_name: r.from_name ?? null,
|
|
1484
|
+
to_name: r.to_name ?? null,
|
|
1485
|
+
relation_type: r.relation_type,
|
|
1486
|
+
memory_id: r.memory_id ?? null,
|
|
1487
|
+
created_at: r.created_at,
|
|
1488
|
+
weight: r.weight,
|
|
1489
|
+
base_weight: r.base_weight,
|
|
1490
|
+
gain: r.gain,
|
|
1491
|
+
source: r.source
|
|
1442
1492
|
}))
|
|
1443
1493
|
});
|
|
1444
1494
|
} catch {
|
package/lib/client.js
CHANGED
|
@@ -452,6 +452,23 @@ window.__ModuleLoader__.load({
|
|
|
452
452
|
"memory.graph.viewInGraph": "在实体视图中打开",
|
|
453
453
|
"memory.graph.loading": "加载中…",
|
|
454
454
|
"memory.graph.distance": "距中心 {n} 跳",
|
|
455
|
+
// issue #24 块4:被动确认的复核面。边侧卡显示权重/来源,复核区列「抬过的边」。
|
|
456
|
+
"memory.graph.weight": "权重 {w}",
|
|
457
|
+
"memory.graph.source": "来源 {s}",
|
|
458
|
+
"memory.graph.boosted.title": "被用出来的边",
|
|
459
|
+
"memory.graph.boosted.hint": "正常触达会自动抬高边的权重,这里列出被抬过的那些——抬升是唯一会改变检索形状的动作。",
|
|
460
|
+
"memory.graph.boosted.empty": "当前筛选下没有边(只列权重已高于其来源基线的边)",
|
|
461
|
+
"memory.graph.boosted.loadFailed": "这一屏没取到数据(接口不可用),不是「没有边」——稍后重试。",
|
|
462
|
+
"memory.graph.boosted.gateOff": "被动确认当前是关的,今后不会再自动抬权;下面列的是此前已经抬过的边。",
|
|
463
|
+
"memory.graph.boosted.show": "查看被用出来的边",
|
|
464
|
+
"memory.graph.boosted.hide": "收起",
|
|
465
|
+
"memory.graph.boosted.gain": "+{g}",
|
|
466
|
+
// 边的来源(#24 块2 的 RELATION_SOURCE_DEFAULTS):决定权重的起点,
|
|
467
|
+
// 所以复核时必须和权重一起显示,否则「0.5 比 1.0 弱」这种误读无可避免。
|
|
468
|
+
"memory.graph.src.manual": "人工建立",
|
|
469
|
+
"memory.graph.src.confirmed": "人工确认",
|
|
470
|
+
"memory.graph.src.tag": "标签共现",
|
|
471
|
+
"memory.graph.src.llm": "模型抽取",
|
|
455
472
|
"memory.settings.vectorTitle": "向量搜索",
|
|
456
473
|
"memory.settings.vectorHint": "搜索不再只认字面:接入任意 OpenAI 兼容的 embedding 接口后,意思相近的记忆也能被召回。",
|
|
457
474
|
"memory.settings.vectorEnabled": "启用向量搜索",
|
|
@@ -1242,6 +1259,23 @@ window.__ModuleLoader__.load({
|
|
|
1242
1259
|
"memory.graph.viewInGraph": "Open in entity explorer",
|
|
1243
1260
|
"memory.graph.loading": "Loading…",
|
|
1244
1261
|
"memory.graph.distance": "{n} hop(s) from root",
|
|
1262
|
+
// issue #24 块4:被动确认的复核面。
|
|
1263
|
+
"memory.graph.weight": "weight {w}",
|
|
1264
|
+
"memory.graph.source": "via {s}",
|
|
1265
|
+
"memory.graph.boosted.title": "Edges put to use",
|
|
1266
|
+
"memory.graph.boosted.hint": "A normal touch raises an edge's weight automatically. These are the ones that rose — raising is the only action that changes what gets recalled.",
|
|
1267
|
+
"memory.graph.boosted.empty": "No edges match this filter (only edges already above their source baseline are listed)",
|
|
1268
|
+
"memory.graph.boosted.loadFailed": "Couldn't load this panel (endpoint unavailable) — that is not the same as \"no edges\". Try again later.",
|
|
1269
|
+
"memory.graph.boosted.gateOff": "Passive confirmation is off, so nothing more will be raised; the edges below were raised earlier.",
|
|
1270
|
+
"memory.graph.boosted.show": "Show edges put to use",
|
|
1271
|
+
"memory.graph.boosted.hide": "Hide",
|
|
1272
|
+
"memory.graph.boosted.gain": "+{g}",
|
|
1273
|
+
// 边的来源(#24 块2 的 RELATION_SOURCE_DEFAULTS):决定权重的起点,
|
|
1274
|
+
// 所以复核时必须和权重一起显示,否则「0.5 比 1.0 弱」这种误读无可避免。
|
|
1275
|
+
"memory.graph.src.manual": "manual",
|
|
1276
|
+
"memory.graph.src.confirmed": "confirmed",
|
|
1277
|
+
"memory.graph.src.tag": "tag overlap",
|
|
1278
|
+
"memory.graph.src.llm": "model-extracted",
|
|
1245
1279
|
"memory.settings.vectorTitle": "Vector Search",
|
|
1246
1280
|
"memory.settings.vectorHint": "Match by meaning, not just wording — connect any OpenAI-compatible embeddings API.",
|
|
1247
1281
|
"memory.settings.vectorEnabled": "Enable vector search",
|
|
@@ -2089,6 +2123,13 @@ window.__ModuleLoader__.load({
|
|
|
2089
2123
|
".mneme-relrow:hover{background:var(--dsw-alias-interactive-bg-hover)}",
|
|
2090
2124
|
".mneme-reltype{flex:none;font-size:11px;line-height:16px;color:var(--mneme-accent);border:1px solid color-mix(in srgb,var(--dsw-alias-state-business-primary) 35%,transparent);border-radius:6px;padding:0 6px}",
|
|
2091
2125
|
".mneme-relname{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
|
2126
|
+
// issue #24 块4 复核面:抬过的边。用虚线边色(--mneme-graphic-dim 同层)
|
|
2127
|
+
// 而不是新增颜色——它表达的是「同一条边,只是被用过」,不是新类别。
|
|
2128
|
+
".mneme-boosted{margin-top:6px;padding-top:6px;border-top:1px dashed var(--dsw-alias-border-l2)}",
|
|
2129
|
+
".mneme-boostedbody{margin-top:6px}",
|
|
2130
|
+
".mneme-boostedrow{cursor:default;font-size:12px;line-height:18px;padding:5px 10px}",
|
|
2131
|
+
".mneme-boostedrow:hover{background:none}",
|
|
2132
|
+
".mneme-boostedgain{font-size:11px;color:var(--mneme-accent)}",
|
|
2092
2133
|
// --- settings: quiet stacked sections, one concern per section ---
|
|
2093
2134
|
".mneme-set{flex:1;min-height:0;overflow-y:auto;padding:8px 24px 48px;box-sizing:border-box}",
|
|
2094
2135
|
".mneme-set-inner{max-width:720px;margin:0 auto}",
|
|
@@ -2790,9 +2831,6 @@ window.__ModuleLoader__.load({
|
|
|
2790
2831
|
// ④ 关系图谱 the same ego graph the old graph-only tab drew
|
|
2791
2832
|
function EntityPanel({ t, focusEntity, onJumpMemory }) {
|
|
2792
2833
|
const [entities, setEntities] = useState(null);
|
|
2793
|
-
// #512:这条记忆的归因——窗口级(哪一段原文蒸出来的)与行级(哪一轮铸造的)。
|
|
2794
|
-
// null = 还没读到(或读失败),两者要分得开,不能被当成「没有归因」。
|
|
2795
|
-
const [provenance, setProvenance] = useState(null); // null = still loading
|
|
2796
2834
|
const [railQuery, setRailQuery] = useState("");
|
|
2797
2835
|
const [entityName, setEntityName] = useState(focusEntity || "");
|
|
2798
2836
|
const [depth, setDepth] = useState(1);
|
|
@@ -2801,6 +2839,10 @@ window.__ModuleLoader__.load({
|
|
|
2801
2839
|
const [selected, setSelected] = useState(null); // { kind: "node"|"edge", node?|edge? }
|
|
2802
2840
|
const [attrs, setAttrs] = useState([]);
|
|
2803
2841
|
const [related, setRelated] = useState([]);
|
|
2842
|
+
// issue #24 块4:被动确认的复核面。默认收起——它是一个「想查的时候
|
|
2843
|
+
// 才查」的面,不该占实体页第一屏;展开时才去取,省一次请求。
|
|
2844
|
+
const [boostedOpen, setBoostedOpen] = useState(false);
|
|
2845
|
+
const [boosted, setBoosted] = useState(null); // { count, passiveConfirmEnabled, items }
|
|
2804
2846
|
const svgRef = useRef(null);
|
|
2805
2847
|
const posRef = useRef([]); // live simulation positions, not React state
|
|
2806
2848
|
const dragRef = useRef(null); // { id, moved }
|
|
@@ -2866,6 +2908,21 @@ window.__ModuleLoader__.load({
|
|
|
2866
2908
|
return () => { cancelled = true; };
|
|
2867
2909
|
}, [entityName]);
|
|
2868
2910
|
|
|
2911
|
+
// issue #24 块4 复核面:只在展开时取,收起时不发请求。
|
|
2912
|
+
const loadBoosted = useCallback(async () => {
|
|
2913
|
+
try {
|
|
2914
|
+
const res = await apiFetch("/api/dsh-mneme/semantic/graph/boosted-relations?limit=100");
|
|
2915
|
+
setBoosted(res.ok
|
|
2916
|
+
? await res.json()
|
|
2917
|
+
: { count: 0, items: [], passiveConfirmEnabled: false, error: true });
|
|
2918
|
+
} catch {
|
|
2919
|
+
setBoosted({ count: 0, items: [], passiveConfirmEnabled: false, error: true });
|
|
2920
|
+
}
|
|
2921
|
+
}, []);
|
|
2922
|
+
useEffect(() => {
|
|
2923
|
+
if (boostedOpen) loadBoosted();
|
|
2924
|
+
}, [boostedOpen, loadBoosted]);
|
|
2925
|
+
|
|
2869
2926
|
// Simulation: run in rAF against posRef, tick React only every few frames.
|
|
2870
2927
|
// Re-seeds when data changes; dragging writes straight into posRef.
|
|
2871
2928
|
useEffect(() => {
|
|
@@ -3096,6 +3153,17 @@ window.__ModuleLoader__.load({
|
|
|
3096
3153
|
),
|
|
3097
3154
|
h("div", { className: "mneme-gs-meta" },
|
|
3098
3155
|
`${t("memory.graph.relation")} · ${formatRelativeTime(selected.edge.created_at, t)}`),
|
|
3156
|
+
// issue #24 块4:复核要看得见「这条边被抬过」。weight 是当前分,
|
|
3157
|
+
// source 决定它的起点(manual 1.0 / confirmed 1.0 / tag 0.3 / llm 0.4)——
|
|
3158
|
+
// 只看 weight 会把「tag 边被抬到 0.5」误读成「比 manual 边弱」。
|
|
3159
|
+
(selected.edge.weight !== undefined || selected.edge.source !== undefined) && h("div", { className: "mneme-gs-meta" },
|
|
3160
|
+
selected.edge.weight !== undefined
|
|
3161
|
+
? t("memory.graph.weight").replace("{w}", Number(selected.edge.weight).toFixed(2))
|
|
3162
|
+
: null,
|
|
3163
|
+
selected.edge.weight !== undefined && selected.edge.source ? " · " : null,
|
|
3164
|
+
selected.edge.source
|
|
3165
|
+
? t("memory.graph.source").replace("{s}", t(`memory.graph.src.${selected.edge.source}`) || selected.edge.source)
|
|
3166
|
+
: null),
|
|
3099
3167
|
selected.edge.memory_id && h("button", {
|
|
3100
3168
|
className: "mneme-footbtn",
|
|
3101
3169
|
onClick: () => onJumpMemory && onJumpMemory({ id: selected.edge.memory_id })
|
|
@@ -3200,7 +3268,53 @@ window.__ModuleLoader__.load({
|
|
|
3200
3268
|
h("span", { className: "mneme-reltype" }, e.relation_type),
|
|
3201
3269
|
h("span", { className: "mneme-relname", style: { flex: 1 } }, otherName)
|
|
3202
3270
|
);
|
|
3203
|
-
})
|
|
3271
|
+
}),
|
|
3272
|
+
// issue #24 块4「例外管理」的可见面。被动确认是唯一会悄悄
|
|
3273
|
+
// 改变检索形状的动作(正常触达就抬权),所以它必须有个地方
|
|
3274
|
+
// 能查——否则「自动确认」和「没人看过的自动确认」没有区别。
|
|
3275
|
+
// 默认收起:这是审计视角,不是日常视角。
|
|
3276
|
+
h("div", { className: "mneme-boosted" },
|
|
3277
|
+
h("button", {
|
|
3278
|
+
className: boostedOpen ? "mneme-chip mneme-active" : "mneme-chip",
|
|
3279
|
+
"aria-expanded": boostedOpen ? "true" : "false",
|
|
3280
|
+
onClick: () => setBoostedOpen(!boostedOpen),
|
|
3281
|
+
title: t("memory.graph.boosted.hint")
|
|
3282
|
+
}, boostedOpen ? t("memory.graph.boosted.hide") : t("memory.graph.boosted.show")),
|
|
3283
|
+
boostedOpen && h("div", { className: "mneme-boostedbody" },
|
|
3284
|
+
(() => {
|
|
3285
|
+
if (boosted === null) return h("div", { className: "mneme-hint" }, t("memory.graph.loading"));
|
|
3286
|
+
// 加载失败 ≠ 闸关:接口挂了却说「被动确认是关的」,会把人
|
|
3287
|
+
// 引去翻设置,而真正的问题是这一屏没取到数据。
|
|
3288
|
+
if (boosted.error === true) return h("div", { className: "mneme-hint" }, t("memory.graph.boosted.loadFailed"));
|
|
3289
|
+
const items = boosted.items || [];
|
|
3290
|
+
const off = boosted.passiveConfirmEnabled !== true;
|
|
3291
|
+
// 闸关 ≠ 没有数据:已经抬过的边仍然在表里,关掉闸只表示
|
|
3292
|
+
// 「今后不再自动抬」。所以先出闸关提示,再照常列历史——
|
|
3293
|
+
// 否则人永远没法复核既有的提升。
|
|
3294
|
+
// 空表提示与闸门状态无关:闸关且一条都没有时,光说「下面列的是
|
|
3295
|
+
// 此前已经抬过的边」却不给任何行,人会以为这一屏坏了。
|
|
3296
|
+
return h(react.Fragment, null,
|
|
3297
|
+
off && h("div", { className: "mneme-hint" }, t("memory.graph.boosted.gateOff")),
|
|
3298
|
+
items.length === 0
|
|
3299
|
+
? h("div", { className: "mneme-hint" }, t("memory.graph.boosted.empty"))
|
|
3300
|
+
: h(react.Fragment, null,
|
|
3301
|
+
h("div", { className: "mneme-hint" }, t("memory.graph.boosted.hint")),
|
|
3302
|
+
items.map((b) => h("div", {
|
|
3303
|
+
key: b.id,
|
|
3304
|
+
className: "mneme-relrow mneme-boostedrow",
|
|
3305
|
+
title: `${t("memory.graph.source").replace("{s}", t(`memory.graph.src.${b.source}`) || b.source)} · ${Number(b.weight).toFixed(2)}`
|
|
3306
|
+
},
|
|
3307
|
+
h("span", { className: "mneme-relname", style: { textAlign: "right", flex: 1 } }, b.from_name ?? b.from),
|
|
3308
|
+
h("span", { className: "mneme-reltype" }, b.relation_type),
|
|
3309
|
+
h("span", { className: "mneme-relname", style: { flex: 1 } }, `${b.to_name ?? b.to} `,
|
|
3310
|
+
h("span", { className: "mneme-boostedgain" },
|
|
3311
|
+
t("memory.graph.boosted.gain").replace("{g}", Number(b.gain).toFixed(2))))
|
|
3312
|
+
))
|
|
3313
|
+
)
|
|
3314
|
+
);
|
|
3315
|
+
})()
|
|
3316
|
+
)
|
|
3317
|
+
)
|
|
3204
3318
|
)
|
|
3205
3319
|
),
|
|
3206
3320
|
// ② 属性 — current snapshot (valid_until IS NULL);空层整层隐藏
|
|
@@ -6498,6 +6612,13 @@ window.__ModuleLoader__.load({
|
|
|
6498
6612
|
const [saveError, setSaveError] = useState(false);
|
|
6499
6613
|
const [copied, setCopied] = useState(false);
|
|
6500
6614
|
const [entities, setEntities] = useState(null);
|
|
6615
|
+
// #512:这条记忆的归因——窗口级(哪一段原文蒸出来的)与行级(哪一轮铸造的)。
|
|
6616
|
+
// null = 还没读到(或读失败),两者要分得开,不能被当成「没有归因」。
|
|
6617
|
+
// 这个 state 必须住在**读它的那个组件**里:加归因区块时它曾被误放进
|
|
6618
|
+
// EntityPanel(同文件的上一个组件),于是 MemoryDrawer 的 effect 与渲染块
|
|
6619
|
+
// 引用到一个不存在的绑定,一开抽屉就 ReferenceError 把整个 renderer 带走
|
|
6620
|
+
// (issue #553)。同名 state 落在隔壁组件里,静态扫描与 review 都不容易看出来。
|
|
6621
|
+
const [provenance, setProvenance] = useState(null); // null = still loading
|
|
6501
6622
|
// 整包退出的两段:先干跑清点(点按钮那一发),再确认物删。撤是**物理删除**不是归档
|
|
6502
6623
|
// (归档行仍占 share_external_id 的 UNIQUE 位,撤了会导不回来,理由在
|
|
6503
6624
|
// src/share-import.js 的 deleteSharePack),所以这里比删除那条路多一站清点。
|
package/lib/graph/anchoring.js
CHANGED
|
@@ -46,7 +46,12 @@ export function minMax(scores) {
|
|
|
46
46
|
/**
|
|
47
47
|
* 把一条检测通路的结果归一化成 0..1 的打分表。
|
|
48
48
|
* path 是离散通路名(exact/tag/...)→ 查 DISCRETE_LEVELS;是连续通路
|
|
49
|
-
* (continuous=true)→ min-max。返回 { byId: Map<id, score>, order
|
|
49
|
+
* (continuous=true)→ min-max。返回 { path, byId: Map<id, score>, order }。
|
|
50
|
+
*
|
|
51
|
+
* issue #24 块1 补充:返回值带上 `path`。anchorSeeds 用它记「这个种子被哪几路
|
|
52
|
+
* 激活」(`seeds[i].paths`),复核时据此判断一条边是被精确名、Tag 桥接还是向量
|
|
53
|
+
* 拉进来的。此前只有 { byId, order },`p.path ?? "?"` 把每一路都记成 "?",
|
|
54
|
+
* 通路身份在合并那一步就丢了。连续通路调用方传 `path: "vector"`(cos 相似度)。
|
|
50
55
|
*/
|
|
51
56
|
export function normalizePath(hits, { path = "exact", continuous = false } = {}) {
|
|
52
57
|
const ids = Array.isArray(hits) ? hits : [];
|
|
@@ -62,7 +67,7 @@ export function normalizePath(hits, { path = "exact", continuous = false } = {})
|
|
|
62
67
|
const level = DISCRETE_LEVELS[path] ?? DISCRETE_LEVELS.substring;
|
|
63
68
|
for (const id of ids) byId.set(id, level);
|
|
64
69
|
}
|
|
65
|
-
return { byId, order: [...byId.keys()] };
|
|
70
|
+
return { path, byId, order: [...byId.keys()] };
|
|
66
71
|
}
|
|
67
72
|
|
|
68
73
|
/**
|
package/lib/service.js
CHANGED
|
@@ -560,16 +560,36 @@ export function createService({ store, mirror, config, onWrite, logger, document
|
|
|
560
560
|
* 用出来的」延伸成检索信号:查询命中的不只有直接挂联,还有跳跃可达的活跃子图。
|
|
561
561
|
* 邻居权重 = HOP_QUOTA[hop] × linked tier(attr 1.0 / relation 0.9),仍低于
|
|
562
562
|
* 直达种子的满权,绝不喧宾夺主。
|
|
563
|
+
*
|
|
564
|
+
* #24 块1 补充(三路起点接线):前半段只喂了 exact 一路,Tag 桥接与向量两路
|
|
565
|
+
* 能力在 anchoring.js 里却从未被调用。现补齐——
|
|
566
|
+
* · exact:查询里出现实体名(布尔 1.0);
|
|
567
|
+
* · tag :查询词命中某条记忆的 tag,该记忆挂联的实体即种子(计数级别 0.4);
|
|
568
|
+
* · vector:查询向量近邻的记忆挂联的实体(cos 走 min-max,连续通路)。
|
|
569
|
+
* 三路在累加前各按自己的尺度归一化(离散级别 vs min-max),混加不会让高频
|
|
570
|
+
* Tag 淹没强向量命中。两路都是弱信号入口:任一路为空就少一路,全空则
|
|
571
|
+
* anchorSeeds 返回空、整个图轴让位(与加这两路之前的行为一致)。
|
|
572
|
+
* 三者共用 graphAnchoringEnabled 一个闸:默认关时仍是逐字节的 #219 单跳轴。
|
|
563
573
|
*/
|
|
564
|
-
function entityRecall(q, limit) {
|
|
574
|
+
function entityRecall(q, limit, { queryVector = null } = {}) {
|
|
565
575
|
if (config?.entityRecallEnabled !== true) return [];
|
|
566
576
|
try {
|
|
567
577
|
const entities = store.findEntitiesMentionedIn(q);
|
|
568
|
-
|
|
578
|
+
// Tag 桥接与向量两路只服务锚定层:关锚定时不查(少两次库读,也守住
|
|
579
|
+
// 「关档行为逐字节不变」的承诺——见下方 if 分支)。
|
|
580
|
+
const anchoring = config?.graphAnchoringEnabled === true;
|
|
581
|
+
const tagEntities = anchoring && typeof store.findEntitiesByTags === "function"
|
|
582
|
+
? (() => { try { return store.findEntitiesByTags(q); } catch { return []; } })()
|
|
583
|
+
: [];
|
|
584
|
+
// 早退只在**三路全无来源**时才成立。向量路的存在感恰恰在查询里没有实体名
|
|
585
|
+
// 的时候最大(「数据库迁移」说不出任何实体),若仍以「名字没命中」早退,
|
|
586
|
+
// 第二、三路就永远等不到自己被调用——接上了也等于没接。
|
|
587
|
+
const canVector = anchoring && Array.isArray(queryVector) && queryVector.length > 0 && !!vectorIndex;
|
|
588
|
+
if (!entities.length && !tagEntities.length && !canVector) return [];
|
|
569
589
|
// 关闭锚定级联=逐字节复用 #219 单跳轴:种子裁剪/重排/截断全是 #24 新增的
|
|
570
590
|
// 行为,默认关时一条都不许漏进旧路径(graphSeedCap 也只在开启后生效——
|
|
571
591
|
// 无论 cap 调多小,关档时命中实体仍全量挂联)。
|
|
572
|
-
if (
|
|
592
|
+
if (!anchoring) {
|
|
573
593
|
const linked = store.getLinkedMemoryIds(entities.map((e) => e.id));
|
|
574
594
|
if (!linked.size) return [];
|
|
575
595
|
const hits = [];
|
|
@@ -581,9 +601,43 @@ export function createService({ store, mirror, config, onWrite, logger, document
|
|
|
581
601
|
}
|
|
582
602
|
return hits;
|
|
583
603
|
}
|
|
584
|
-
//
|
|
585
|
-
const
|
|
586
|
-
|
|
604
|
+
// 三路激活信号,各自先归一化再累加(尺度不同,混加会让高频通路淹没强相关)。
|
|
605
|
+
const paths = [];
|
|
606
|
+
// 第一路:精确名(布尔通路——命名实体逻辑上是离散匹配,不参与 min-max)。
|
|
607
|
+
if (entities.length) {
|
|
608
|
+
paths.push(normalizePath(entities.map((e) => e.id), { path: "exact" }));
|
|
609
|
+
}
|
|
610
|
+
// 第二路:Tag 桥接(tag 共现,离散级别 0.4——比精确名弱、比子串强)。
|
|
611
|
+
if (tagEntities.length) {
|
|
612
|
+
paths.push(normalizePath(tagEntities.map((e) => e.id), { path: "tag" }));
|
|
613
|
+
}
|
|
614
|
+
// 第三路:向量(连续通路,cos 相似度走 min-max)。记忆 → 挂联实体反查,
|
|
615
|
+
// 同一实体被多条近邻记忆挂联时取最高分(一条更近的记忆就够证明相关)。
|
|
616
|
+
if (Array.isArray(queryVector) && queryVector.length && vectorIndex) {
|
|
617
|
+
try {
|
|
618
|
+
const near = vectorIndex.search(queryVector, { limit: limit * 2, threshold: 0 });
|
|
619
|
+
const bestOf = new Map();
|
|
620
|
+
for (const m of near) {
|
|
621
|
+
const score = m?.score ?? 0;
|
|
622
|
+
if (!(score > 0)) continue;
|
|
623
|
+
for (const eid of store.entityIdsForMemories([m.id])) {
|
|
624
|
+
if (!(bestOf.get(eid) >= score)) bestOf.set(eid, score);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
if (bestOf.size) {
|
|
628
|
+
paths.push(normalizePath(
|
|
629
|
+
[...bestOf.entries()].map(([id, score]) => ({ id, score })),
|
|
630
|
+
{ path: "vector", continuous: true }
|
|
631
|
+
));
|
|
632
|
+
}
|
|
633
|
+
} catch { /* 向量路失败只是少一路,不拖垮整条图轴 */ }
|
|
634
|
+
}
|
|
635
|
+
if (!paths.length) return [];
|
|
636
|
+
// 激活分为 0 的种子不算种子:min-max 会把**连续通路里最弱的那条**压成 0,
|
|
637
|
+
// 可下游给种子挂联记忆的是固定满权 tier(1.0/0.9),不看种子自己的分数。
|
|
638
|
+
// 留着 0 分种子,等于让「这一路里最不像的那条」以最强的方式把记忆拉进池。
|
|
639
|
+
// 零分项在 anchorSeeds 里排最后,所以先取 cap 再滤掉不会浪费名额。
|
|
640
|
+
const seeds = anchorSeeds({ paths, cap: config?.graphSeedCap ?? 12 }).filter((s) => s.score > 0);
|
|
587
641
|
if (!seeds.length) return [];
|
|
588
642
|
// 0-hop 直达:挂联记忆按 tier 定分(attr 1.0 / relation 0.9)——与 #219
|
|
589
643
|
// 单跳轴同分。MAX 语义:同一条记忆被多实体挂联时保留最高分。
|
|
@@ -939,6 +993,9 @@ export function createService({ store, mirror, config, onWrite, logger, document
|
|
|
939
993
|
const keyword = rawKeyword.map((m) => ({ ...m, score: scoreKeyword(m, q), source: "keyword" }));
|
|
940
994
|
const wantVector = mode === "vector" || mode === "hybrid" || (mode === "auto" && !!embedder);
|
|
941
995
|
let vector = [];
|
|
996
|
+
// #24 块1:查询向量在这里算出来,图轴的向量起点要用同一份——再嵌一次既
|
|
997
|
+
// 多一次模型调用,也可能拿到不同的向量(本地模型带随机性时)。
|
|
998
|
+
let vectorQuery = null;
|
|
942
999
|
if (wantVector && embedder) {
|
|
943
1000
|
try {
|
|
944
1001
|
// Legacy embedders expose embed(query); local ones expose embedSingle.
|
|
@@ -947,6 +1004,7 @@ export function createService({ store, mirror, config, onWrite, logger, document
|
|
|
947
1004
|
: embedder.embed.bind(embedder);
|
|
948
1005
|
const qv = await embedSingle(q);
|
|
949
1006
|
if (qv?.length) {
|
|
1007
|
+
vectorQuery = qv;
|
|
950
1008
|
// Adaptive threshold (v0.5.0 1.2): the fetch runs at the loosest
|
|
951
1009
|
// branch floor so the head-gap rule can still re-admit the tail;
|
|
952
1010
|
// the final cutoff is computed against the fetched score
|
|
@@ -980,7 +1038,7 @@ export function createService({ store, mirror, config, onWrite, logger, document
|
|
|
980
1038
|
// mentioned entities pull their linked memories into the pool. Keyword
|
|
981
1039
|
// mode stays text-only per the mode contract, so the axis is skipped
|
|
982
1040
|
// there even when enabled.
|
|
983
|
-
const entity = mode === "keyword" ? [] : entityRecall(q, lim);
|
|
1041
|
+
const entity = mode === "keyword" ? [] : entityRecall(q, lim, { queryVector: vectorQuery });
|
|
984
1042
|
// Entity axis shares BM25's backfill weight (we = wb) until #217 recall
|
|
985
1043
|
// data justifies a distinct entity weight.
|
|
986
1044
|
|
|
@@ -1819,7 +1877,7 @@ export function createService({ store, mirror, config, onWrite, logger, document
|
|
|
1819
1877
|
// 为空时兜底检索,避免注入路径双倍候选成本。
|
|
1820
1878
|
if (config?.entityRecallEnabled === true && q) {
|
|
1821
1879
|
try {
|
|
1822
|
-
const graphHits = entityRecall(q, maxItems * 2);
|
|
1880
|
+
const graphHits = entityRecall(q, maxItems * 2, { queryVector });
|
|
1823
1881
|
if (graphHits.length) {
|
|
1824
1882
|
const graphIds = new Set(graphHits.map((m) => m.id));
|
|
1825
1883
|
candidates = [
|
|
@@ -2872,6 +2930,9 @@ export function createService({ store, mirror, config, onWrite, logger, document
|
|
|
2872
2930
|
saveRelation: (r) => store.saveRelation(r),
|
|
2873
2931
|
listEntities: (o) => store.listEntities(o),
|
|
2874
2932
|
getRelations: (id) => store.getRelations(id),
|
|
2933
|
+
// #24 块4 被动确认的复核出口:列出权重被 bump 抬过的边(gain = 现权 −
|
|
2934
|
+
// 该 source 的初值)。纯读,不动权重——「看哪些边被用出来过」是人的动作。
|
|
2935
|
+
listBoostedRelations: (o) => store.listBoostedRelations(o),
|
|
2875
2936
|
// v0.7.0 实体热投影:实体热 = 关联记忆 heat 聚合(取 max)。无关联记忆
|
|
2876
2937
|
// 或 heatEnabled=false 时返回 null;前端据此决定图谱节点大小/明暗。
|
|
2877
2938
|
entityHeat: (entityId) => {
|
package/lib/store.js
CHANGED
|
@@ -1647,6 +1647,11 @@ export function createStore(path) {
|
|
|
1647
1647
|
// 解析成本可以忽略、又永远够不到那格上限,块数只随包条数线性增长。
|
|
1648
1648
|
const DELETE_ID_CHUNK = 500;
|
|
1649
1649
|
|
|
1650
|
+
// Tag 锚点的 IN 列表分块。比 DELETE_ID_CHUNK 多一层顾虑:那条语句只有一处 IN,
|
|
1651
|
+
// 这条有**三处**(attrs / relations.from / relations.to)共用同一个记忆 id 集合,
|
|
1652
|
+
// 所以单块占用的参数是 3×chunk。取 500 后最坏 1500 个参数,离 32766 仍有二十倍余量。
|
|
1653
|
+
const TAG_ID_CHUNK = 500;
|
|
1654
|
+
|
|
1650
1655
|
/**
|
|
1651
1656
|
* 整包退出(车道 C)。删空一个不存在的包是幂等的 0,不是错误——对方点第二次「收起
|
|
1652
1657
|
* 这个包」与「收起一个从来没导入过的包」都该是这个答案;早退也不 bump generation:
|
|
@@ -3712,6 +3717,152 @@ export function createStore(path) {
|
|
|
3712
3717
|
return { migrated, invalidated };
|
|
3713
3718
|
}
|
|
3714
3719
|
|
|
3720
|
+
/**
|
|
3721
|
+
* Tag 桥接(issue #24 · 块1 三路起点里的第二路):查询词命中某些记忆的 tag
|
|
3722
|
+
* 时,那些记忆挂联的实体本身就是「这个查询正在谈的东西」——即使查询里没有
|
|
3723
|
+
* 出现它们的名字。Tag 是 LLM 打的标签,比精确名松,但比向量具体,正好填在
|
|
3724
|
+
* 二者之间。
|
|
3725
|
+
*
|
|
3726
|
+
* 匹配口径与 findEntitiesMentionedIn 对齐(JS 侧 toLowerCase,不受 SQLite
|
|
3727
|
+
* 只折 ASCII 的限制):把查询按空白与常见中英标点切词,任一词与某个 tag
|
|
3728
|
+
* 全等即算命中(不做子串——tag 通常是短词,子串匹配会让「ai」命中「mail」)。
|
|
3729
|
+
* 只认未归档未遗忘的行:一条已归档记忆的 tag 不该再拉出种子。
|
|
3730
|
+
* 返回 {id, name},按命中记忆数降序、名称升序(稳定)。
|
|
3731
|
+
*/
|
|
3732
|
+
function findEntitiesByTags(text, { limit = 5 } = {}) {
|
|
3733
|
+
const words = String(text ?? "")
|
|
3734
|
+
.toLowerCase()
|
|
3735
|
+
.split(/[\s,,、;;::!!??.。()()[\]【】"'“”‘’/|]+/u)
|
|
3736
|
+
.map((w) => w.trim())
|
|
3737
|
+
.filter((w) => w.length >= 2);
|
|
3738
|
+
if (!words.length) return [];
|
|
3739
|
+
const rows = db.prepare(
|
|
3740
|
+
`SELECT id, tags FROM memories WHERE archived = 0 AND forgotten = 0`
|
|
3741
|
+
).all();
|
|
3742
|
+
const memHits = new Set();
|
|
3743
|
+
for (const row of rows) {
|
|
3744
|
+
const tags = parseTags(row.tags).map((t) => String(t).toLowerCase());
|
|
3745
|
+
if (!tags.length) continue;
|
|
3746
|
+
if (words.some((w) => tags.includes(w))) memHits.add(row.id);
|
|
3747
|
+
}
|
|
3748
|
+
if (!memHits.size) return [];
|
|
3749
|
+
// 计数必须在 SQL 里做:entityIdsForMemories 返回的是 DISTINCT entity_id,
|
|
3750
|
+
// 拿它反推「命中几条记忆」永远是 1,排序就静默失效了(评审抓到的正是这个)。
|
|
3751
|
+
//
|
|
3752
|
+
// 两个来源都要算:只读 entity_attrs 会漏掉「记忆只通过关系挂到实体上」的情况
|
|
3753
|
+
// ——phaseRelations 只写 entity_relations.memory_id、不建 attr 行,于是仅由关系
|
|
3754
|
+
// 关联的记忆对关系两端一个种子都生不出来。这与 getLinkedMemoryIds 的口径一致
|
|
3755
|
+
// (那边 attr 与 relation 两端同样并列)。UNION 去重的是 (实体, 记忆) 对,
|
|
3756
|
+
// 所以同一条记忆的多条关联记录不会把 via 抬高。
|
|
3757
|
+
//
|
|
3758
|
+
// 分块理由同 DELETE_ID_CHUNK:一条语句一行一个 `?`,撞上 SQLITE_MAX_VARIABLE_NUMBER
|
|
3759
|
+
// (32766)会整笔抛错,而调用方 try/catch 兜底 → 静默丢掉整条 Tag 锚点。
|
|
3760
|
+
// 这里每块绑 3×TAG_ID_CHUNK 个参数(三处 IN 列表),500 仍有二十倍余量。
|
|
3761
|
+
const rowsByEntity = new Map();
|
|
3762
|
+
const memoryIds = [...memHits];
|
|
3763
|
+
for (let offset = 0; offset < memoryIds.length; offset += TAG_ID_CHUNK) {
|
|
3764
|
+
const batch = memoryIds.slice(offset, offset + TAG_ID_CHUNK);
|
|
3765
|
+
const marks = batch.map(() => "?").join(",");
|
|
3766
|
+
const batchRows = db.prepare(
|
|
3767
|
+
`SELECT hits.id AS id, e.name AS name, COUNT(DISTINCT hits.memory_id) AS via
|
|
3768
|
+
FROM (
|
|
3769
|
+
SELECT entity_id AS id, memory_id FROM entity_attrs
|
|
3770
|
+
WHERE memory_id IN (${marks}) AND entity_id IS NOT NULL
|
|
3771
|
+
UNION
|
|
3772
|
+
SELECT from_entity AS id, memory_id FROM entity_relations
|
|
3773
|
+
WHERE memory_id IN (${marks}) AND from_entity IS NOT NULL
|
|
3774
|
+
UNION
|
|
3775
|
+
SELECT to_entity AS id, memory_id FROM entity_relations
|
|
3776
|
+
WHERE memory_id IN (${marks}) AND to_entity IS NOT NULL
|
|
3777
|
+
) hits JOIN entities e ON e.id = hits.id
|
|
3778
|
+
GROUP BY hits.id`
|
|
3779
|
+
).all(...batch, ...batch, ...batch);
|
|
3780
|
+
for (const row of batchRows) {
|
|
3781
|
+
const prev = rowsByEntity.get(row.id);
|
|
3782
|
+
// 各块的记忆集合互不相交,所以 via 直接相加就是全集的 DISTINCT 计数。
|
|
3783
|
+
rowsByEntity.set(row.id, { id: row.id, name: row.name, via: (prev?.via ?? 0) + Number(row.via) });
|
|
3784
|
+
}
|
|
3785
|
+
}
|
|
3786
|
+
const rows2 = [...rowsByEntity.values()];
|
|
3787
|
+
if (!rows2.length) return [];
|
|
3788
|
+
return rows2
|
|
3789
|
+
.map((r) => ({ id: r.id, name: r.name, via: r.via }))
|
|
3790
|
+
.sort((a, b) => b.via - a.via || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
3791
|
+
.slice(0, limit)
|
|
3792
|
+
.map(({ id, name }) => ({ id, name }));
|
|
3793
|
+
}
|
|
3794
|
+
|
|
3795
|
+
/**
|
|
3796
|
+
* 按记忆批量反查实体 id(Tag 桥接用)。与 entitiesForMemory 的区别是「批量 +
|
|
3797
|
+
* 带 id」:那边是面板详情页的单条展示(只要 name/type),这边要的是能直接
|
|
3798
|
+
* 喂给 normalizePath 的 id 列表。同一 (记忆, 实体) 只出一次。
|
|
3799
|
+
*
|
|
3800
|
+
* 与 findEntitiesByTags 同口径:attr 行与关系两端并列。只读 entity_attrs 时,
|
|
3801
|
+
* 一条只通过关系挂到实体上的记忆在向量路里也生不出种子(phaseRelations 不建 attr)。
|
|
3802
|
+
*/
|
|
3803
|
+
function entityIdsForMemories(memoryIds) {
|
|
3804
|
+
const ids = Array.isArray(memoryIds) ? memoryIds.filter(Boolean) : [];
|
|
3805
|
+
if (!ids.length) return [];
|
|
3806
|
+
const placeholders = ids.map(() => "?").join(",");
|
|
3807
|
+
const rows = db.prepare(
|
|
3808
|
+
`SELECT entity_id FROM entity_attrs
|
|
3809
|
+
WHERE memory_id IN (${placeholders}) AND entity_id IS NOT NULL
|
|
3810
|
+
UNION
|
|
3811
|
+
SELECT from_entity AS entity_id FROM entity_relations
|
|
3812
|
+
WHERE memory_id IN (${placeholders}) AND from_entity IS NOT NULL
|
|
3813
|
+
UNION
|
|
3814
|
+
SELECT to_entity AS entity_id FROM entity_relations
|
|
3815
|
+
WHERE memory_id IN (${placeholders}) AND to_entity IS NOT NULL`
|
|
3816
|
+
).all(...ids, ...ids, ...ids);
|
|
3817
|
+
return rows.map((r) => r.entity_id);
|
|
3818
|
+
}
|
|
3819
|
+
|
|
3820
|
+
/**
|
|
3821
|
+
* 被动确认的复核出口(issue #24 · 块4):列出「权重已经被抬过」的边。
|
|
3822
|
+
*
|
|
3823
|
+
* 设计口径:被动确认本身是安静的——正常触达就 bump,不打扰人。要复核的恰恰
|
|
3824
|
+
* 是「抬过的那些」,因为抬升是唯一会改变检索形状的动作。判据不看有没有被
|
|
3825
|
+
* bump 过(那要额外记账),看权重是否高于该 source 的初值:RELATION_SOURCE_
|
|
3826
|
+
* DEFAULTS 是建边时的起点,高出去的部分只能来自 bumpRelationWeight。
|
|
3827
|
+
*
|
|
3828
|
+
* 显式传过 weight 的边(manual 自带权重)会被算成「抬过」,这在语义上没错
|
|
3829
|
+
* ——它确实不是初值——但它是人工意图,所以把 source 一起返回,面板自己决定
|
|
3830
|
+
* 怎么标。整数比较用 gain > 0 而不是 >= 0,防浮点累加的 1e-17 噪声。
|
|
3831
|
+
* 返回按 gain 降序、created_at 降序,附两侧实体名(面板不必再查一遍)。
|
|
3832
|
+
*/
|
|
3833
|
+
function listBoostedRelations({ limit = 200, minGain = 0 } = {}) {
|
|
3834
|
+
const { limit: lim } = sanitizePage(limit, 1, 200);
|
|
3835
|
+
const rows = db.prepare(
|
|
3836
|
+
`SELECT r.*, ef.name AS from_name, et.name AS to_name
|
|
3837
|
+
FROM entity_relations r
|
|
3838
|
+
LEFT JOIN entities ef ON ef.id = r.from_entity
|
|
3839
|
+
LEFT JOIN entities et ON et.id = r.to_entity
|
|
3840
|
+
ORDER BY r.weight DESC, r.created_at DESC`
|
|
3841
|
+
).all();
|
|
3842
|
+
const out = [];
|
|
3843
|
+
for (const row of rows) {
|
|
3844
|
+
const src = row.source ?? "manual";
|
|
3845
|
+
const base = Object.hasOwn(RELATION_SOURCE_DEFAULTS, src)
|
|
3846
|
+
? RELATION_SOURCE_DEFAULTS[src]
|
|
3847
|
+
: 1.0;
|
|
3848
|
+
const gain = (row.weight ?? 1.0) - base;
|
|
3849
|
+
if (!(gain > minGain)) continue;
|
|
3850
|
+
out.push({
|
|
3851
|
+
...toRelation(row),
|
|
3852
|
+
from_name: row.from_name ?? null,
|
|
3853
|
+
to_name: row.to_name ?? null,
|
|
3854
|
+
base_weight: base,
|
|
3855
|
+
gain
|
|
3856
|
+
});
|
|
3857
|
+
}
|
|
3858
|
+
// 排序必须在算完 gain 之后做,不能交给 SQL 的 weight DESC:不同 source 的
|
|
3859
|
+
// base 不同,weight 大不等于 gain 大(llm 边 0.95 / gain 0.55 会压过
|
|
3860
|
+
// tag 边 0.90 / gain 0.60)。早先在循环里按 lim 提前 break 时,被截掉的
|
|
3861
|
+
// 恰恰是 gain 更大的边——面板按 limit=100 取数,截断处正是最该看见的那几条。
|
|
3862
|
+
out.sort((a, b) => b.gain - a.gain || String(b.created_at).localeCompare(String(a.created_at)));
|
|
3863
|
+
return out.slice(0, lim);
|
|
3864
|
+
}
|
|
3865
|
+
|
|
3715
3866
|
/** Relations where the entity appears on either side (from or to). */
|
|
3716
3867
|
function getRelations(entityId) {
|
|
3717
3868
|
return db.prepare(
|
|
@@ -4202,6 +4353,10 @@ export function createStore(path) {
|
|
|
4202
4353
|
entitiesForMemory,
|
|
4203
4354
|
findMemoriesByAttr,
|
|
4204
4355
|
findEntitiesMentionedIn,
|
|
4356
|
+
// #24 块1 第二路(Tag 桥接)与块4 复核出口。
|
|
4357
|
+
findEntitiesByTags,
|
|
4358
|
+
entityIdsForMemories,
|
|
4359
|
+
listBoostedRelations,
|
|
4205
4360
|
getLinkedMemoryIds,
|
|
4206
4361
|
saveRelation,
|
|
4207
4362
|
bumpRelationWeight,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modusensus/dsh-mneme",
|
|
3
3
|
"description": "Cross-session memory plugin for DeepSeek Harness with autoDream consolidation: SQLite store, Markdown mirrors, 10 model tools, automatic injection, session summarization, user profile/rules, custom slash commands, vector (semantic) search, and a Web GUI panel",
|
|
4
|
-
"version": "0.10.
|
|
4
|
+
"version": "0.10.1",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|