From 1fa9ef68a4c62b47a0d4ac6d2ff55ff19b097e4c Mon Sep 17 00:00:00 2001
From: JianFeeeee
Date: Sat, 26 Sep 2026 19:56:28 +0800
Subject: [PATCH 01/13] =?UTF-8?q?fix(memory):=20=E5=9C=BA=E6=99=AF?=
=?UTF-8?q?=E9=94=AE=E5=BD=92=E4=B8=80=E5=8C=96=20+=20=E6=8E=92=E9=99=A4?=
=?UTF-8?q?=E6=9C=80=E5=BC=B1=E7=BB=B4=E5=BA=A6=EF=BC=8C=E4=BF=AE=E5=8F=8C?=
=?UTF-8?q?=E8=83=9E=E8=83=8E=E4=B8=8E=E7=A9=BA=E8=BD=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
现网实测:scenes 表 65 行里有 6 组是同一场面的双胞胎键,最严重的
auto:chan:qq+part:morning 累积到 strength=270、6 个 features、
0 条记忆,日志里被"命中"179 次;孪生的 auto:chan:qq_part:morning
则持有 201 条记忆却有 0 个 features(不参与聚类)。两套特征体系
各活各的,谁也发现不了谁。
病因:键构造不唯一。
- Label() 直接用 "+" 拼接且不过 NormalizeSceneKey,而
EnsureScene / effectiveScenes / RecallByScene 三处都过了归一化。
"+" 会被 normalizeSceneSegment 归一成 "_",于是两个字符串都合法,
key UNIQUE 约束拦不住。
- Label(2) 会把权重仅 0.2 的 part(时段)挤进场景身份。实测
morning 场景吞掉 evening 指纹:共享 chan:qq 权重 1.0、并集含
part 0.2×2,相似度 1.0/1.4 = 0.714 > joinSceneThreshold 0.5。
这本就是加权 Jaccard 的正常行为,但键名不该写进时段。
- createSceneLocked 的 "#N" 冲突后缀同样会被归一成 "_",
造成 auto:chan:mc:event+topic:mc#2 在库、而写侧归一化后去找
auto:chan:mc:event_topic:mc_2 —— 又一对匹配不上的双胞胎。
修法:
- Label 只取权重 ≥ 0.5 的主导特征,结果过 NormalizeSceneKey;
全部特征都弱于门槛时退回最强的一批(宁可名字信息量低,也不能
没有名字 —— 没名字就没有键,场景根本长不出来)。
- createSceneLocked 对 base 再做一次防御性归一化,并在 label 为空
时拒建无名场景。
- 冲突后缀由 "#" 改为 "."('#' 会被归一化,'.' 是白名单字符)。
判据:新增 scene_key_test.go 6 例,参照物在生产代码之外("同一场面
⇒ 同一个键"这条不变量 + 直查 scenes 表复算行数)。先跑红确认判据
在跑(4 红 1 绿,失败的正是键唯一性/时段污染/证据桶),再改实现。
其中 TestWrittenRefReachableFromItsScene 一开始就是绿的——写侧到读侧
那条路本身是通的,坏的只是键的构造。
记忆 8 包 + agent/core 全绿。
---
docs/zh/scene-memory-fix-plan.md | 116 +++++++++++++++
internal/memory/scene_emerge.go | 70 +++++++--
internal/memory/scene_key_test.go | 238 ++++++++++++++++++++++++++++++
3 files changed, 414 insertions(+), 10 deletions(-)
create mode 100644 docs/zh/scene-memory-fix-plan.md
create mode 100644 internal/memory/scene_key_test.go
diff --git a/docs/zh/scene-memory-fix-plan.md b/docs/zh/scene-memory-fix-plan.md
new file mode 100644
index 0000000..7e552c9
--- /dev/null
+++ b/docs/zh/scene-memory-fix-plan.md
@@ -0,0 +1,116 @@
+# 场景式记忆修复 plan
+
+> 起点:2026-09-26 生产库实测(`/home/newqqagent/memory/graph.db`)。
+> 现象:65 个场景中 6 组是同一场面的双胞胎键;主力场景 `auto:chan:qq+part:morning`
+> strength=270、6 个 features、**0 条记忆**,日志里被"命中"179 次。
+> 分支:`feature/scene-writeback`(从 main 拉出,工作树干净)。
+
+---
+
+## 根因(三条,逐条修)
+
+### R1 建键不过归一化(病因)
+
+`internal/memory/scene_emerge.go:329`
+
+```go
+base := "auto:" + sig.Label(2) // Label 用 "+" 拼接,未归一化
+```
+
+而同一层的另外三条路**都**过了归一化:
+
+| 位置 | 是否归一化 |
+|---|---|
+| `EnsureScene`(声明建键)`scene_emerge.go:555` | ✅ |
+| `effectiveScenes`(写侧挂 ref)`graph.go:1397` | ✅ |
+| `RecallByScene`(读侧召回)`scene.go:328` | ✅ |
+| **`createSceneLocked`(涌现建键)`scene_emerge.go:329`** | ❌ |
+
+`normalizeSceneSegment` 把 `+` 归一成 `_`,所以涌现键天生带 `+`、其余三方说 `_`。
+`scenes.key` 虽有 `UNIQUE` 约束,但**两个不同字符串都合法**,拦不住。
+
+实测 6 组双胞胎(`REPLACE(key,'+','_')` 后重名):
+
+```
+auto:chan:qq+part:morning ↔ auto:chan:qq_part:morning (270/0 refs)
+auto:chan:mc:event+topic:mc ↔ auto:chan:mc:event_topic:mc
+auto:chan:mc:system+topic:mc ↔ auto:chan:mc:system_topic:mc
+auto:chan:mc-resident+topic:mc ↔ auto:chan:mc-resident_topic:mc
+auto:chan:homeagent-mail-bridge+... ↔ auto:chan:homeagent-mail-bridge_...
+auto:chan:system+topic:任务 ↔ auto:chan:system_topic:任务
+```
+
+### R2 `part`(时段,权重 0.2)污染主键与门槛
+
+`Label(2)` 取权重最高的 2 个特征。特征顺序是
+`chan(1.0) → peer(1.0) → tool(0.8) → topic(0.4) → part(0.2)`,
+只有当前 2 个强特征不足时 `part` 才会进键——于是出现
+`auto:chan:qq+part:morning` 这种"时段成了场景身份"的名字。
+
+**更糟的是 `recordSituationEvidenceLocked` 也用 `Label(2)` 做桶**(`scene_emerge.go:380`),
+所以 `part` 不只影响名字,还决定 `minSceneEvidence=2` 这个门槛在哪个桶里计数。
+
+实测证据(键名与实际指纹自相矛盾):
+
+```
+场景键: auto:chan:qq+part:morning strength=270
+实际指纹: [chan:qq part:evening] ← 19:15 命中,evening 却并入 morning
+```
+
+这不是 bug 触发,是加权 Jaccard 正常工作:共享 `chan:qq`(1.0)、
+并集含 `part`(0.2×2),相似度 1.0/1.4 = 0.714 > `joinSceneThreshold` 0.5。
+
+### R3 图整理心跳漏扫 `scenes`
+
+`detectEntityMerge` 唯一遍历入口 `Recall(nil,nil,1,"")`,其全量路径只查
+`entities`(`graph.go:609`) + `relations`(`graph.go:631`),`scenes` 不在其中。
+所以 R1/R2 造成的双胞胎从 9-15 起无人发现,23 号空转到 strength=270。
+
+**R3 是"没被发现"的原因,R1/R2 是病因。** 顺序不能反。
+
+---
+
+## 步骤
+
+### 步骤 1:修 R1 + R2(源头,不碰存量)
+
+- [ ] `Label` 改名语义:**只取权重 ≥ 阈值的主导特征**,且结果过 `NormalizeSceneKey`
+- [ ] `createSceneLocked` 的 `base` 用归一化后的 label
+- [ ] `recordSituationEvidenceLocked` 的桶键用同一个归一化 label(R2 的另一半)
+- [ ] 判据:先写**红**测试——同一指纹两次建键必须落同一行(现状会落两行)
+
+### 步骤 2:修 R3(让图整理覆盖全库)
+
+- [ ] 给 `mergeLoop` 加**独立的场景去重路径**,不塞进实体那个 O(n²) 双重循环
+ (理由:实体 1 万行 × bigram + LLM 裁决,实测 5000 万次配对/轮;
+ 场景表小且**已有现成的 `situationSimilarity` 加权 Jaccard**,语义更准)
+- [ ] 键归一化后相同 ⇒ 合并 refs/features/strength,**不经 LLM**(键相同已证明同一场面)
+- [ ] 判据:构造两个 `+`/`_` 孪生键,跑一次 mergeLoop 后期望合成一个
+
+### 步骤 3:清理现网垃圾场景,让它重新生成
+
+用户明确要求:**直接清理,重新生成**(不做保守迁移)。
+
+- [ ] `sqlite3 .backup` 备份(**禁用 cp**,WAL 模式会拷出不一致快照)
+- [ ] 删 `origin='emergent'` 的全部场景 + 其 `scene_features`/`scene_refs`
+ (**保留 `origin='declared'`**:那些是插件声明的,不是垃圾)
+- [ ] 同步清 `situation_evidence`
+- [ ] 重启 homed,等 ≥2 次同类交互让场景重新涌现
+- [ ] 复验:新场景键**不含 `+`**、有 features **且**有 refs
+
+### 步骤 4:验证与收口
+
+- [ ] `go test ./internal/memory/... ./internal/agent/core/...` 全绿
+- [ ] `go build ./...` + 全仓 `go test ./...`
+- [ ] 现网观察:日志中场景命中后能查到 refs(非 0)
+- [ ] `git_release_check.sh` 无新增红项
+
+---
+
+## 不做的事(防反复挂账)
+
+- **不**把 `scenes` 塞进 `memory_merge` 工具:该工具语义是"实体删除 + 关系重定向",
+ 场景合并需要"特征并集 + refs 重定向 + strength 相加",是另一套操作,硬塞会让工具语义变危险。
+- **不**改 `validGraphNodeKind`:它只管 `memory_block_edges` 端点校验,与 `scenes` 无关。
+- **不**给实体那个 O(n²) 循环做优化:属独立问题(已实测:1 万实体→~224GB 瞬时分配/轮),
+ 混进本次修复会让 diff 失焦。单独开条目。
diff --git a/internal/memory/scene_emerge.go b/internal/memory/scene_emerge.go
index ce14ea3..3c8dca3 100644
--- a/internal/memory/scene_emerge.go
+++ b/internal/memory/scene_emerge.go
@@ -132,17 +132,52 @@ func (s Situation) Keys() []string {
// Empty 表示指纹里没有任何可判定的信号。
func (s Situation) Empty() bool { return len(s.Features) == 0 }
-// Label 用权重最高的少数特征给场景起个可读名字(`chan:qq+tool:qq_get_message`)。
-// 只用于人看,不参与匹配——匹配永远走特征集合。
+// labelFeatureWeight 是参与**场景身份**的最低特征权重。
+//
+// 为什么设门槛:part(时段)权重只有 0.2,是场面里最弱的维度——
+// 「在 QQ 上」和「在 QQ 上且是早上」是同一个场面,时段不该把它切成两个。
+// 早期实现直接取 Label(2),只有 chan 一个强特征时 part 必然挤进第二位,
+// 于是键名变成 auto:chan:qq+part:morning:既是「时段成了身份」,
+// 又让加权 Jaccard 把它当成另一个场面(实测:morning 场景吞掉 evening 指纹,
+// 共享 chan:qq 权重 1.0、并集含 part 0.2×2,相似度 1.0/1.4=0.714 > 0.5)。
+const labelFeatureWeight = 0.5
+
+// Label 用权重达标的主导特征给场景起个**可读名**(`chan:qq+tool:qq_get_message`)。
+//
+// 两条硬约束(缺一就会造出写侧匹配不上的键):
+// 1. 只取权重 ≥ labelFeatureWeight 的特征:时段/话题不进身份。
+// 2. 结果**必须过 NormalizeSceneKey**:'+' 会被 normalizeSceneSegment 归一成
+// '_',而 EnsureScene / effectiveScenes / RecallByScene 三处都过了归一化。
+// 建键路径漏掉这一步,库中就会并存 auto:chan:qq+part:morning 与
+// auto:chan:qq_part:morning 两个键——key UNIQUE 拦不住(两个不同字符串),
+// 于是「有 features 却 0 条记忆」与「有记忆却不参与聚类」两个半死节点并存
+// (生产实测 strength=270 / 6 features / 0 refs 对 strength=1 / 0 / 201)。
func (s Situation) Label(max int) string {
if max <= 0 {
max = 2
}
- keys := s.Keys()
- if len(keys) > max {
- keys = keys[:max]
+ var picked []string
+ for _, f := range s.Features {
+ if f.Weight() < labelFeatureWeight {
+ continue
+ }
+ picked = append(picked, f.Key())
+ if len(picked) >= max {
+ break
+ }
}
- return strings.Join(keys, "+")
+ if len(picked) == 0 {
+ // 全部特征都弱于门槛(纯 topic/part 的轮次):退回最强的一批特征,
+ // 宁可名字信息量低,也不要没有名字——没名字就没有键,场景根本长不出来。
+ n := max
+ if n > len(s.Features) {
+ n = len(s.Features)
+ }
+ for _, f := range s.Features[:n] {
+ picked = append(picked, f.Key())
+ }
+ }
+ return NormalizeSceneKey(strings.Join(picked, "+"))
}
// emergentScene 是一次聚类计算中的场景视图。
@@ -326,7 +361,15 @@ func (g *GraphDB) reinforceSceneLocked(sceneID int64, sig Situation) error {
// createSceneLocked 用指纹长出一个新场景(键由主导特征派生,仅作可读名)。
func (g *GraphDB) createSceneLocked(sig Situation) (string, error) {
- base := "auto:" + sig.Label(2)
+ // Label 已保证:过滤弱特征 + 过 NormalizeSceneKey。
+ // 这里再过一次防御性归一化:键的唯一性是整个场景层的地基,
+ // 不能依赖「上游一定调对了 Label」——生产库里已经存在双胞胎键,
+ // 任何一条新路径再漏归一化就会再生产一批(见 Label 的注释)。
+ base := NormalizeSceneKey("auto:" + sig.Label(2))
+ if base == "auto:" {
+ // Label 退化到空(指纹被裁空):不建无主场景,否则所有空指纹会堆进同一行。
+ return "", fmt.Errorf("situation label 为空,拒绝建无名场景")
+ }
key := base
tx, err := g.db.Begin()
@@ -336,6 +379,13 @@ func (g *GraphDB) createSceneLocked(sig Situation) (string, error) {
defer tx.Rollback()
// 键冲突(同一可读名已被占)时加后缀,不合并——真正的合并交给相似度判定。
+ //
+ // ★ 这里加出来的 #N 后缀**必须与原键一样合法**:它会被写进 scenes.key,
+ // 而写侧(effectiveScenes)与读侧(RecallByScene)都会对它做归一化。
+ // '#' 不在 normalizeSceneSegment 的白名单里,会被归一成 '_'——
+ // 于是 auto:chan:qq#2 在库里存在,而写侧归一化后去找 auto:chan:qq_2,
+ // 又是一对匹配不上的双胞胎(生产库已有 auto:chan:mc:event+topic:mc#2 这类)。
+ // 所以后缀改用不会触发归一化改写的字符。
for i := 2; ; i++ {
var exists int
if err := tx.QueryRow(`SELECT COUNT(*) FROM scenes WHERE key = ?`, key).Scan(&exists); err != nil {
@@ -344,7 +394,7 @@ func (g *GraphDB) createSceneLocked(sig Situation) (string, error) {
if exists == 0 {
break
}
- key = fmt.Sprintf("%s#%d", base, i)
+ key = fmt.Sprintf("%s.%d", base, i)
}
res, err := tx.Exec(`INSERT INTO scenes (key, strength, origin) VALUES (?, 1, 'emergent')`, key)
@@ -372,8 +422,8 @@ func (g *GraphDB) createSceneLocked(sig Situation) (string, error) {
// recordSituationEvidenceLocked 登记一次「同类指纹出现过」,返回累计次数。
//
-// 用指纹标签(主导特征)做粗聚类桶,只服务于「首次不建场景」的门槛判定,
-// 不参与后续匹配——匹配永远走 EnterScene 的相似度。
+// 用 Label(2) 做粗聚类桶(Label 已过归一化、不含时段),只服务于
+// 「首次不建场景」的门槛判定,不参与后续匹配——匹配永远走 EnterScene 的相似度。
func (g *GraphDB) recordSituationEvidenceLocked(sig Situation) (int, error) {
label := sig.Label(2)
if _, err := g.db.Exec(
diff --git a/internal/memory/scene_key_test.go b/internal/memory/scene_key_test.go
new file mode 100644
index 0000000..759054a
--- /dev/null
+++ b/internal/memory/scene_key_test.go
@@ -0,0 +1,238 @@
+package memory
+
+// 场景键唯一性的判据(修复 R1/R2 的红测试)。
+//
+// 背景(生产实测):scenes.key 有 UNIQUE 约束,但同一场面仍裂成两个键——
+// auto:chan:qq+part:morning strength=270 6 features 0 refs
+// auto:chan:qq_part:morning strength=1 0 features 201 refs
+// 病因:createSceneLocked 用 sig.Label(2) 建键且不过 NormalizeSceneKey,
+// 而 EnsureScene / effectiveScenes / RecallByScene 三处都过了。
+//
+// 本组测试的判据在**生产代码之外**:期望值是「同一场面 ⇒ 同一个键」这条
+// 不变量,不引用任何被测实现细节。判据自身也做了双向检查:
+// 先用「改实现 ⇒ 必须变红」验证过它真的在跑。
+
+import (
+ "os"
+ "strings"
+ "testing"
+)
+
+// sigWithPart 只有 chan + part 两个特征(part 权重 0.2,是最弱维度)。
+func sigWithPart(chanName, part string) Situation {
+ return NewSituation(
+ SituationFeature{Kind: "chan", Value: chanName},
+ SituationFeature{Kind: "part", Value: part},
+ )
+}
+
+// R1:同一指纹反复出现,键必须唯一,不能每次都造新行。
+func TestSceneKeyIsUniqueForSameSituation(t *testing.T) {
+ g := newTestGraph(t)
+ defer os.Remove(g.dbPath)
+ defer g.Close()
+
+ sig := mkSig("qq", "", "")
+
+ // 连喂 6 次。minSceneEvidence=2 ⇒ 第 2 次建场景、之后只强化。
+ // 要断言的**不是**哪一次 created,而是:全程只允许存在一个键。
+ keys := map[string]int{}
+ for i := 0; i < 6; i++ {
+ key, _, err := g.EnterScene(sig)
+ if err != nil {
+ t.Fatalf("第 %d 次 EnterScene 出错: %v", i+1, err)
+ }
+ if key == "" {
+ continue
+ }
+ keys[key]++
+ }
+
+ if len(keys) == 0 {
+ t.Fatal("6 次重复交互后仍未建出场景,与 minSceneEvidence=2 的设计矛盾")
+ }
+ if len(keys) > 1 {
+ t.Fatalf("同一指纹造出了 %d 个不同场景键: %v —— 键构造不唯一", len(keys), keys)
+ }
+ if n := len(sceneKeys(t, g)); n != 1 {
+ t.Fatalf("scenes 表里有 %d 行,应为 1(重复交互不得增殖场景行)", n)
+ }
+}
+
+// 门槛本身:第 1 次只留足迹、第 2 次建场景。这是 minSceneEvidence 的语义,
+// 单独钉住,免得修键时把门槛一起改掉。
+func TestSceneEvidenceThresholdIsTwo(t *testing.T) {
+ g := newTestGraph(t)
+ defer os.Remove(g.dbPath)
+ defer g.Close()
+
+ sig := mkSig("qq", "", "")
+
+ if key, _, err := g.EnterScene(sig); err != nil {
+ t.Fatal(err)
+ } else if key != "" {
+ t.Fatalf("第 1 次就建了场景 %q,minSceneEvidence=2 失效", key)
+ }
+
+ key, created, err := g.EnterScene(sig)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if key == "" || !created {
+ t.Fatalf("第 2 次应建出新场景(key=%q created=%v)", key, created)
+ }
+
+ // 第 3 次起只强化,不再新建
+ if _, created, err := g.EnterScene(sig); err != nil {
+ t.Fatal(err)
+ } else if created {
+ t.Fatal("第 3 次不该再新建场景")
+ }
+}
+
+// R1(生产现场形态):带 + 的键与带 _ 的键必须归一到同一个。
+// 这是双胞胎的直接复现:现状下 auto:chan:qq+part:morning 与
+// auto:chan:qq_part:morning 会同时存在于 scenes 表。
+func TestSceneKeyNormalized_NoPlusVersusUnderscore(t *testing.T) {
+ g := newTestGraph(t)
+ defer os.Remove(g.dbPath)
+ defer g.Close()
+
+ // 造两次:强特征相同、只有 part 不同(现实中「早上在 QQ」与「晚上在 QQ」)
+ for _, part := range []string{"morning", "evening"} {
+ sig := sigWithPart("qq", part)
+ for i := 0; i < 3; i++ {
+ if _, _, err := g.EnterScene(sig); err != nil {
+ t.Fatalf("EnterScene(%s) 出错: %v", part, err)
+ }
+ }
+ }
+
+ keys := sceneKeys(t, g)
+ if len(keys) == 0 {
+ t.Fatal("未建出任何场景")
+ }
+ for _, k := range keys {
+ if strings.Contains(k, "+") {
+ t.Errorf("场景键 %q 含未归一化的 '+';其余三条路径(EnsureScene/"+
+ "effectiveScenes/RecallByScene)都用 NormalizeSceneKey,"+
+ "只有建键路径漏了 ⇒ 写侧永远匹配不上", k)
+ }
+ }
+}
+
+// R2:part(权重 0.2,最弱维度)不该成为场景身份的一部分。
+func TestSceneKeyExcludesWeakPartFeature(t *testing.T) {
+ g := newTestGraph(t)
+ defer os.Remove(g.dbPath)
+ defer g.Close()
+
+ // 只有 chan 一个强特征,part 必然挤进 Label(2) 的第二位。
+ for i := 0; i < 3; i++ {
+ if _, _, err := g.EnterScene(sigWithPart("qq", "morning")); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ keys := sceneKeys(t, g)
+ if len(keys) == 0 {
+ t.Fatal("未建出场景")
+ }
+ for _, k := range keys {
+ if strings.Contains(k, "part") {
+ t.Errorf("场景键 %q 把时段(part, 权重 0.2)写进了身份。"+
+ "时段是最弱维度:生产库实测出现「morning」场景吞掉 evening 指纹"+
+ "(共享 chan:qq,相似度 1.0/1.4=0.714 > 阈值 0.5)", k)
+ }
+ }
+}
+
+// R2 的另一半:part 不该影响「是否建场景」的门槛桶。
+// 同一个场面在 morning 出现两次、evening 出现一次,应该只算两次
+// (minSceneEvidence=2 已满足),而不是按时段分桶各数一次。
+func TestEvidenceBucketIgnoresPart(t *testing.T) {
+ g := newTestGraph(t)
+ defer os.Remove(g.dbPath)
+ defer g.Close()
+
+ // 第一轮 morning:只登记足迹,不建场景
+ if key, _, err := g.EnterScene(sigWithPart("qq", "morning")); err != nil {
+ t.Fatal(err)
+ } else if key != "" {
+ t.Fatalf("首次不该建场景,却建了 %q", key)
+ }
+
+ // 第二轮换成 evening:若门槛按 part 分桶,这里就又要再等一次
+ key, _, err := g.EnterScene(sigWithPart("qq", "evening"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if key == "" {
+ t.Fatal("同一场面(只差时段)第 2 次仍未建场景 —— 时段把证据桶拆开了")
+ }
+}
+
+// R1 完整复现:模拟生产库里「写入侧归一化、建键侧不归一化」的分裂。
+// 断言:写侧挂的记忆,最终能通过读侧召回回到同一个场景。
+func TestWrittenRefReachableFromItsScene(t *testing.T) {
+ g := newTestGraph(t)
+ defer os.Remove(g.dbPath)
+ defer g.Close()
+
+ sig := mkSig("qq", "", "")
+ var key string
+ for i := 0; i < 3 && key == ""; i++ {
+ k, _, err := g.EnterScene(sig)
+ if err != nil {
+ t.Fatal(err)
+ }
+ key = k
+ }
+ if key == "" {
+ t.Fatal("未建出场景")
+ }
+
+ // 写侧走 effectiveScenes(它会归一化)——这正是生产代码的路径
+ ec, rc, err := g.Commit([]Triple{{
+ Subject: "老大", Relation: "偏好", Object: "咖啡",
+ Scenes: []string{key}, // 模型/内核给的键,可能带 + 或 _
+ }}, "s1", 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if ec == 0 || rc == 0 {
+ t.Fatal("三元组未写入")
+ }
+
+ // 读侧也走归一化(RecallByScene 内部会做)
+ res, err := g.RecallByScene([]string{key}, 8)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(res.Relations) == 0 {
+ t.Fatalf("刚写进场景 %q 的关系,经同键召回却取不回 —— "+
+ "建键与写/读两侧对「同一个键」的认定不一致", key)
+ }
+}
+
+// sceneKeys 直查 scenes 表(不引用被测统计函数,独立复算)。
+func sceneKeys(t *testing.T, g *GraphDB) []string {
+ t.Helper()
+ rows, err := g.db.Query(`SELECT key FROM scenes ORDER BY id`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer rows.Close()
+ var out []string
+ for rows.Next() {
+ var k string
+ if err := rows.Scan(&k); err != nil {
+ t.Fatal(err)
+ }
+ out = append(out, k)
+ }
+ if err := rows.Err(); err != nil {
+ t.Fatal(err)
+ }
+ return out
+}
From 6e9420965d33b39211424d10217b647ae6db03de Mon Sep 17 00:00:00 2001
From: JianFeeeee
Date: Sat, 26 Sep 2026 19:58:00 +0800
Subject: [PATCH 02/13] =?UTF-8?q?docs(memory):=20=E8=A1=A5=20R4=20?=
=?UTF-8?q?=E8=A6=86=E7=9B=96=E9=9D=A2=E4=B8=8E=20R5=20=E8=AF=81=E6=8D=AE?=
=?UTF-8?q?=E6=A1=B6=E4=B8=A4=E6=9D=A1=E6=A0=B9=E5=9B=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
R4:现网 65 个场景键里 peer 主导 0 个、topic 主导 0 个,36 个
auto:chan + 26 个 chan: 全部锚在输入通道。两个独立原因:
采集侧全仓无插件在 InjectInput 填 peer/group_id/user_id/chat_id
(日志中 peer 出现 0 次);排序侧 chan 与 peer 权重同为 1.0 而
NewSituation 稳定排序让 chan 恒在前,即使采集到也进不了 Label(2)。
这与 R1/R2 是不同层面:R1 修完只会得到「正确的单一维度」。
R5:createSceneLocked 新场景成立即 DELETE FROM situation_evidence
(全表清),会连带清掉别的场景尚未攒够门槛的证据。
---
docs/zh/scene-memory-fix-plan.md | 52 +++++++++++++++++++++++++++++++-
1 file changed, 51 insertions(+), 1 deletion(-)
diff --git a/docs/zh/scene-memory-fix-plan.md b/docs/zh/scene-memory-fix-plan.md
index 7e552c9..67c0f79 100644
--- a/docs/zh/scene-memory-fix-plan.md
+++ b/docs/zh/scene-memory-fix-plan.md
@@ -68,6 +68,41 @@ auto:chan:system+topic:任务 ↔ auto:chan:system_topic:任务
**R3 是"没被发现"的原因,R1/R2 是病因。** 顺序不能反。
+### R4 场景身份只有「输入通道」一个可靠维度
+
+现网 65 个场景键的维度分布(实测):
+
+```
+auto:chan(涌现·通道主导) 36
+chan: (输入通道) 26
+tool: (工具) 3
+peer 主导 0
+topic 主导 0
+```
+
+两个独立原因叠加:
+
+1. **采集侧恒空**:`situationFeaturesFor` 从 `evt.Payload` 找
+ `peer/peer_id/group_id/user_id/chat_id`,而全仓**没有任何插件在 InjectInput
+ 时填这些键**(`group_id` 只出现在 `output.go:145` 的发送侧帮助文本里)。
+ ⇒ 日志中 `peer` 特征出现次数为 **0**。
+2. **排序上被挤掉**:`chan` 与 `peer` 权重同为 1.0,而 `NewSituation` 按权重
+ 降序**稳定**排序,`chan` 先 append 就永远在前 ⇒ 即使采集到 peer,
+ `Label(2)` 也轮不到它。
+
+后果:「跟谁对话」这个本该最强的身份信号(权重与 chan 并列)**根本进不了
+场景身份**。同一件事在 QQ 和 Telegram 上会落进不同场景而无法共享。
+
+**R4 与 R1/R2 是不同层面的问题**:R1/R2 让键构造正确,R4 决定键**能表达
+什么**。R1 修完后 `auto:chan:qq` 会取代 `auto:chan:qq+part:morning`,
+但它依然只认通道——所以 R4 不修,修复效果只到「正确的单一维度」。
+
+### R5 `situation_evidence` 全表清空
+
+`createSceneLocked` 新场景一成立就 `DELETE FROM situation_evidence`
+(不只删本指纹的足迹)。多场景并发轮次下,A 场景的建立会连带清掉 B 尚未
+攒够 `minSceneEvidence=2` 的证据 ⇒ 门槛判定被别的场景的建立随机打断。
+
---
## 步骤
@@ -98,11 +133,26 @@ auto:chan:system+topic:任务 ↔ auto:chan:system_topic:任务
- [ ] 重启 homed,等 ≥2 次同类交互让场景重新涌现
- [ ] 复验:新场景键**不含 `+`**、有 features **且**有 refs
-### 步骤 4:验证与收口
+### 步骤 4:修 R4(覆盖面:让 peer 进得来)— 需跨插件,等用户确认范围
+
+- [ ] 先查各插件 InjectInput 时手上**有没有** peer 信息可用(发信侧有
+ `meta.group_id`/`user_id`,收信侧是否拿得到要逐个确认)
+- [ ] 有则补:插件填 `payload["group_id"]`/`["user_id"]`
+- [ ] 排序侧:`peer` 权重提到高于 `chan`,或 `NewSituation` 排序时 peer 优先
+ (两者都要,否则采集到了也进不了身份)
+- [ ] 判据:构造「同一 chan、不同 peer」的两轮,期望落进**不同**场景
+
+### 步骤 5:修 R5(证据桶别全表清)
+
+- [ ] `DELETE FROM situation_evidence` 改为按本指纹的桶标签删
+- [ ] 判据:预置两个桶的证据各 1 次;建一个场景后断言另一个桶的证据还在
+
+### 步骤 6:验证与收口
- [ ] `go test ./internal/memory/... ./internal/agent/core/...` 全绿
- [ ] `go build ./...` + 全仓 `go test ./...`
- [ ] 现网观察:日志中场景命中后能查到 refs(非 0)
+- [ ] 现网观察:场景键前缀分布不再 100% 锚在 chan(R4 未做则保持挂账)
- [ ] `git_release_check.sh` 无新增红项
---
From f441574b80f6a96579697ff7549a90ee5f309624 Mon Sep 17 00:00:00 2001
From: JianFeeeee
Date: Sat, 26 Sep 2026 20:03:51 +0800
Subject: [PATCH 03/13] =?UTF-8?q?docs(memory):=20=E8=A1=A5=20R6/R7=20?=
=?UTF-8?q?=E4=B8=8E=20SDK=20=E5=A3=B0=E6=98=8E=E9=A1=B9=E6=96=B9=E6=A1=88?=
=?UTF-8?q?=EF=BC=8C=E9=87=8D=E6=8E=92=E4=B8=BA=207=20=E6=AD=A5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
R6:ChannelDef 的记忆声明已有三件套(NoMemory/ContextPolicy/
RecallPolicy),唯独没有「这条通道是否参与场面识别」,现状是无条件
参与 ⇒ chan:system/kernel/timer 等内部信噪通道也在场面聚类里。
穷举确认非查漏:go.mod replace 指向 third_party/homeagent-sdk,
plugin.go 中 scene 出现 0 次,SDK 自身 git 历史 -S'Scene' 为空。
R7:payload[scene] 的层级键(chan:qq/peer:group_1)已支持前缀
召回(RecallByScene scene.go:351),但因 R6 无人使用而闲置。
步骤重排为 7 步:SDK 声明项提到步骤 2(用户已授权动公开 SDK,
开发阶段非 release 阶段),并把「peer 覆盖面」拆到步骤 6 单列,
先只读调查插件手上有什么再动。
---
docs/zh/scene-memory-fix-plan.md | 100 ++++++++++++++++++++++++-------
1 file changed, 77 insertions(+), 23 deletions(-)
diff --git a/docs/zh/scene-memory-fix-plan.md b/docs/zh/scene-memory-fix-plan.md
index 67c0f79..2ada97d 100644
--- a/docs/zh/scene-memory-fix-plan.md
+++ b/docs/zh/scene-memory-fix-plan.md
@@ -103,18 +103,64 @@ topic 主导 0
(不只删本指纹的足迹)。多场景并发轮次下,A 场景的建立会连带清掉 B 尚未
攒够 `minSceneEvidence=2` 的证据 ⇒ 门槛判定被别的场景的建立随机打断。
+### R6 通道侧没有「是否参与场面识别」的声明项(SDK 缺口)
+
+`ChannelDef` 的记忆相关声明已有三件套,语义各管一轴:
+
+```
+NoMemory 进不进记忆计算
+ContextPolicy 裁不裁上下文(破坏性,默认关)
+RecallPolicy 召不召回记忆(只读,默认开)
+```
+
+**唯独没有「这条通道是否参与场面识别」。** 现状是**无条件参与**:
+`situationFeaturesFor` 里只要 `evt.Source != ""` 就塞一个 `chan` 特征,
+没有可关的开关 ⇒ 现网 `chan:system` / `chan:kernel` / `chan:timer` 这类
+**纯内部信噪通道也在参与场面聚类**。
+
+穷举确认不是查漏:编译使用的就是 `third_party/homeagent-sdk`(go.mod replace),
+`plugin.go` 中 `scene` 出现 0 次,SDK 自身 git 历史 `-S'Scene' -- sdk/` 为空。
+
+已有但未被使用的另一个口子:`Payload["scene"]`(`memorypass.go:37`)允许插件
+在单次注入时声明场景键(string/[]string/[]interface{} 三形态,来自 `d98bf51`)。
+现网 **0 个插件使用**,24 个 declared 场景全是 `ChannelScene(evt.Source)` 派生。
+
+### R7 渠道本身可以覆盖多个场景
+
+`payload["scene"]` 传 `chan:qq/peer:group_1` 这类**层级键**时,
+`RecallByScene` 的 `(key = ? OR key LIKE ? || '/%')`(`scene.go:351`)
+支持前缀召回——这层能力已存在,但因 R6 无人使用而闲置。
+
---
## 步骤
-### 步骤 1:修 R1 + R2(源头,不碰存量)
+### 步骤 1:修 R1 + R2(源头,不碰存量)✅ 已完成
-- [ ] `Label` 改名语义:**只取权重 ≥ 阈值的主导特征**,且结果过 `NormalizeSceneKey`
-- [ ] `createSceneLocked` 的 `base` 用归一化后的 label
-- [ ] `recordSituationEvidenceLocked` 的桶键用同一个归一化 label(R2 的另一半)
-- [ ] 判据:先写**红**测试——同一指纹两次建键必须落同一行(现状会落两行)
+- [x] `Label` 只取权重 ≥ `labelFeatureWeight`(0.5) 的主导特征,结果过 `NormalizeSceneKey`
+- [x] `createSceneLocked` 的 `base` 再做一次防御性归一化;label 为空时拒建无名场景
+- [x] 冲突后缀 `#N` → `.N`(`'#'` 会被归一化成 `'_'`,是第四处双胞胎来源)
+- [x] 判据:`scene_key_test.go` 6 例,先红后绿(4 红 1 绿 → 全绿)
+- 提交:`1fa9ef6`
-### 步骤 2:修 R3(让图整理覆盖全库)
+### 步骤 2:SDK 补 `ScenePolicy` 声明项(公开接口,可动)
+
+按 `ContextPolicy` / `RecallPolicy` 的既有风格补齐(同一文件、同一形状):
+
+- [ ] 常量:`ScenePolicyAuto = "auto"` / `ScenePolicyNone = "none"`
+- [ ] 校验:`ValidScenePolicy(policy string) bool`(空串等价默认)
+- [ ] `ChannelDef.ScenePolicy string` + json tag `scene_policy,omitempty`
+- [ ] `InjectOptions.ScenePolicy string`(单次注入可覆盖通道默认)
+- [ ] 内核接线:
+ - `internal/agent/io/channel.go:349-357` 的 payload 搬运加一条 `scene_policy`
+ - `situationFeaturesFor` 读到 `none` 时**不产任何特征**(连 `part` 也不产——
+ 一个不参与场面识别的通道不该留下时段噪声)
+ - 声明路 `sceneKeysFor` 同样受 `none` 约束
+- [ ] 判据:`none` 通道连续 5 次交互,`scenes` 表行数不变
+- [ ] 存量标注:给 `system` / `kernel` / `timer` / `healthcheck` 等内部信噪通道
+ 标 `ScenePolicyNone`(**逐个确认后再标**,不批量猜)
+
+### 步骤 3:修 R3(让图整理覆盖全库)
- [ ] 给 `mergeLoop` 加**独立的场景去重路径**,不塞进实体那个 O(n²) 双重循环
(理由:实体 1 万行 × bigram + LLM 裁决,实测 5000 万次配对/轮;
@@ -122,40 +168,46 @@ topic 主导 0
- [ ] 键归一化后相同 ⇒ 合并 refs/features/strength,**不经 LLM**(键相同已证明同一场面)
- [ ] 判据:构造两个 `+`/`_` 孪生键,跑一次 mergeLoop 后期望合成一个
-### 步骤 3:清理现网垃圾场景,让它重新生成
+### 步骤 4:清理现网垃圾场景,让它重新生成
用户明确要求:**直接清理,重新生成**(不做保守迁移)。
- [ ] `sqlite3 .backup` 备份(**禁用 cp**,WAL 模式会拷出不一致快照)
- [ ] 删 `origin='emergent'` 的全部场景 + 其 `scene_features`/`scene_refs`
- (**保留 `origin='declared'`**:那些是插件声明的,不是垃圾)
- [ ] 同步清 `situation_evidence`
- [ ] 重启 homed,等 ≥2 次同类交互让场景重新涌现
-- [ ] 复验:新场景键**不含 `+`**、有 features **且**有 refs
+- [ ] 复验:新场景键**不含 `+`/`#`**、有 features **且**有 refs
-### 步骤 4:修 R4(覆盖面:让 peer 进得来)— 需跨插件,等用户确认范围
-
-- [ ] 先查各插件 InjectInput 时手上**有没有** peer 信息可用(发信侧有
- `meta.group_id`/`user_id`,收信侧是否拿得到要逐个确认)
-- [ ] 有则补:插件填 `payload["group_id"]`/`["user_id"]`
-- [ ] 排序侧:`peer` 权重提到高于 `chan`,或 `NewSituation` 排序时 peer 优先
- (两者都要,否则采集到了也进不了身份)
-- [ ] 判据:构造「同一 chan、不同 peer」的两轮,期望落进**不同**场景
+> 清理**不影响记忆本体**:868 条 active 关系与 1179 个实体都在
+> `relations`/`entities` 表,与 `scenes` 无外键依赖。
+> 兜底不冷场:`chan:qq` 声明场景(strength=281、108 条关系)全程保留,
+> 涌现重建期间它继续承担 QQ 场景召回。
### 步骤 5:修 R5(证据桶别全表清)
- [ ] `DELETE FROM situation_evidence` 改为按本指纹的桶标签删
- [ ] 判据:预置两个桶的证据各 1 次;建一个场景后断言另一个桶的证据还在
-### 步骤 6:验证与收口
+### 步骤 6:修 R4 的「覆盖面」部分(让 peer / 语义场景进得来)
+
+前置:先只读调查各插件 InjectInput 时手上有什么,产出结论表再动。
+`payload["scene"]` 的口子已存在(R6),插件声明比内核猜 peer 更直接。
+
+- [ ] 调查:qq / mail-bridge / a2a / acp / webui / cli … 各自可声明什么
+- [ ] 排序侧:`chan` 与 `peer` 权重同为 1.0 而 `chan` 恒在前(稳定排序),
+ 即使采集到 peer 也进不了 `Label(2)` ⇒ 需要 peer 优先或提权
+- [ ] 判据:构造「同一 chan、不同 peer」的两轮,期望落进**不同**场景
+
+### 步骤 7:验证与收口
-- [ ] `go test ./internal/memory/... ./internal/agent/core/...` 全绿
- [ ] `go build ./...` + 全仓 `go test ./...`
-- [ ] 现网观察:日志中场景命中后能查到 refs(非 0)
+- [ ] `go test ./internal/memory/... ./internal/agent/core/...` 全绿
+- [ ] SDK 接口冻结检查:`git diff main -- third_party/homeagent-sdk/sdk/` 的变化
+ **已获用户授权**(开发阶段),但需在提交信息里写明「纯追加、omitempty、
+ 老插件行为不变」
+- [ ] 现网观察:场景命中后能查到 refs(非 0)
- [ ] 现网观察:场景键前缀分布不再 100% 锚在 chan(R4 未做则保持挂账)
-- [ ] `git_release_check.sh` 无新增红项
-
----
+- [ ] `git_release_check.sh` 无新增红项(SDK 冻结项变化属预期)
## 不做的事(防反复挂账)
@@ -164,3 +216,5 @@ topic 主导 0
- **不**改 `validGraphNodeKind`:它只管 `memory_block_edges` 端点校验,与 `scenes` 无关。
- **不**给实体那个 O(n²) 循环做优化:属独立问题(已实测:1 万实体→~224GB 瞬时分配/轮),
混进本次修复会让 diff 失焦。单独开条目。
+- **不**在 R6 里动 `ValidContextPolicy` / `ValidRecallPolicy` 的既有语义:
+ 新增项是纯追加,不借机改旧行为。
From 8887e06274c5a819211f9258dfed152332300d0f Mon Sep 17 00:00:00 2001
From: JianFeeeee
Date: Sat, 26 Sep 2026 20:09:17 +0800
Subject: [PATCH 04/13] =?UTF-8?q?feat(sdk+memory):=20=E8=A1=A5=20ScenePoli?=
=?UTF-8?q?cy=20=E5=A3=B0=E6=98=8E=E9=A1=B9=EF=BC=8C=E8=AE=A9=E9=80=9A?=
=?UTF-8?q?=E9=81=93=E8=83=BD=E9=80=80=E5=87=BA=E5=9C=BA=E9=9D=A2=E8=AF=86?=
=?UTF-8?q?=E5=88=AB?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
缺口(R6):ChannelDef 的记忆声明已有三件套——NoMemory 管「进不进
记忆计算」、ContextPolicy 管「裁不裁上下文」、RecallPolicy 管「召不召回
记忆」,唯独没有「这条输入算不算一场戏的一部分」。现状是无条件参与:
situationFeaturesFor 里只要 evt.Source != "" 就产出一个 chan 特征,没有
可关的开关 ⇒ chan:system / chan:kernel / chan:timer 这类纯内部信噪通道
也在撑场面,每次触发都让不相干的场景长出来或变强,召回时又会把
「内核在跑定时器」当成「用户在这类场景下说过的话」取回。
穷举确认不是查漏:go.mod replace 指向 third_party/homeagent-sdk,
plugin.go 中 scene 出现 0 次,SDK 自身 git 历史 -S'Scene' -- sdk/ 为空。
SDK(纯追加,老插件行为逐字节不变):
- 常量 ScenePolicyAuto / ScenePolicyNone + ValidScenePolicy,形状与
ContextPolicy / RecallPolicy 完全一致
- ChannelDef.ScenePolicy 与 InjectOptions.ScenePolicy,均带 omitempty
- 默认取 auto(参与)而非 none:场景只附加检索路、不改记忆本体,
默认关会让存量通道突然失去召回;「关」是少数意图。与 ContextPolicy
刻意相反(同为破坏性操作,那里是默认关)。
内核:
- applyInjectOpts 搬运 scene_policy(与另外三个标志位同面)
- sceneSuppressed 完全照 recallDeclared 的形状:注入点 payload >
通道定义 > 默认。none 时连时段(part)特征都不产,也不派生场景键
(只停指纹采集而留声明路,等于给这个口子开后门)
- situationFeaturesFor / sceneKeysFor 由包级函数改为 Agent 方法
(需要 a.io 查通道定义),23 个调用点同步
判据:scenepolicy_test.go 7 例,改前编译期红(undefined:
pubsdk.ScenePolicyAuto),改后全绿。其中两例专门护住「未声明时行为
逐字节不变」,是纯追加承诺的护栏。
记忆 8 包 + agent/core 全绿,8 包齐全、无 FAIL/panic/race。
存量通道标注待定:kernel/timer/offload-*/child/* 是纯 0-refs 信噪,
可直接标 none;但 mc:system(12 refs) 与 system(3 refs) 带真实记忆,
性质不明,不擅自标。
---
internal/agent/core/eventloop.go | 2 +-
internal/agent/core/memorypass.go | 46 +++++++-
internal/agent/core/scene_test.go | 19 ++--
internal/agent/core/scenepolicy_test.go | 144 ++++++++++++++++++++++++
internal/agent/core/task.go | 2 +-
internal/agent/core/tooldefs.go | 2 +-
internal/agent/io/channel.go | 5 +-
third_party/homeagent-sdk/sdk/plugin.go | 39 ++++++-
8 files changed, 241 insertions(+), 18 deletions(-)
create mode 100644 internal/agent/core/scenepolicy_test.go
diff --git a/internal/agent/core/eventloop.go b/internal/agent/core/eventloop.go
index c30ca78..48428d1 100644
--- a/internal/agent/core/eventloop.go
+++ b/internal/agent/core/eventloop.go
@@ -430,7 +430,7 @@ func (a *Agent) pruneOnInput(evt *agentIO.InputEvent, cleanInput string) int {
if !a.pruneDeclared(evt) {
return 0
}
- return a.memoryPass(cleanInput, "input:"+evt.Source, true, false, sceneKeysFor(evt, "")).Archived
+ return a.memoryPass(cleanInput, "input:"+evt.Source, true, false, a.sceneKeysFor(evt, "")).Archived
}
// pruneDeclared 判定这次输入是否显式声明了裁剪。
diff --git a/internal/agent/core/memorypass.go b/internal/agent/core/memorypass.go
index a2da104..e20179c 100644
--- a/internal/agent/core/memorypass.go
+++ b/internal/agent/core/memorypass.go
@@ -7,6 +7,7 @@ import (
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
+ pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// sceneKeysFor 推导本轮输入的**当前场景**。
@@ -19,8 +20,13 @@ import (
//
// 多个场景是**并列命中**(取回任一场景的记忆),不是交集:
// 「在 QQ 上」与「刚取回消息正文」是两个都能独立成立的触发条件。
-func sceneKeysFor(evt *agentIO.InputEvent, toolName string) []string {
+func (a *Agent) sceneKeysFor(evt *agentIO.InputEvent, toolName string) []string {
var keys []string
+ // 通道/注入点声明不参与场面识别时,**连派生场景键也不给**。
+ // 只停掉指纹采集而留着声明路,等于给「不参与场面」这个口子开了后门。
+ if a.sceneSuppressed(evt) {
+ return nil
+ }
seen := make(map[string]bool)
add := func(k string) {
// 显式声明的场景键来自插件,大小写/空白/标点都不可控;归一化后再去重,
@@ -129,11 +135,41 @@ func (a *Agent) pruneByQuery(query string) int {
// 已有量,不需要模型配合,也不需要人工标注。
// ──────────────────────────────────────────────
-// situationFeaturesFor 采集一轮交互的场面指纹。
+// sceneSuppressed 报告本次输入是否被声明为**不参与场面识别**。
+//
+// 读取面与其它记忆声明完全一致:先看注入点 payload(单次覆盖),
+// 再看通道定义(ChannelDef.ScenePolicy),都没声明 = 参与(保持既有行为)。
+// 优先级与 pruneDeclared / recallDeclared 同构。
+//
+// 为什么要一个显式开关:场面指纹只要 evt.Source != "" 就无条件产出一个 chan
+// 特征,于是内核自循环(system)、心跳(timer)、内部状态汇报(kernel)这类
+// **纯信噪通道**也在撑场面——它们每次触发都让一个不相干的场景长出来或变强,
+// 而召回时又会把「内核在跑定时器」当成「用户在这类场景下说过的话」取回。
+func (a *Agent) sceneSuppressed(evt *agentIO.InputEvent) bool {
+ if evt == nil {
+ return false
+ }
+ if p, ok := evt.Payload["scene_policy"].(string); ok && p != "" {
+ return p == pubsdk.ScenePolicyNone
+ }
+ if a.io != nil {
+ if chDef, ok := a.io.GetInputChannelDef(evt.Source); ok && chDef.ScenePolicy != "" {
+ return chDef.ScenePolicy == pubsdk.ScenePolicyNone
+ }
+ }
+ return false
+}
+
+// sceneFeaturesFor 采集一轮交互的场面指纹。
//
// 特征权重由种类决定(见 memory.SituationFeature.Weight):通道与对象是
// 「同一个场面」最强的同一性信号,工具是行为信号,话题是软信号。
-func situationFeaturesFor(evt *agentIO.InputEvent, cleanInput, tool string) []memory.SituationFeature {
+func (a *Agent) situationFeaturesFor(evt *agentIO.InputEvent, cleanInput, tool string) []memory.SituationFeature {
+ // 声明不参与场面识别:连时段特征都不产——一个不参与的面孔
+ // 不该在 situation_evidence / scene_features 里留下任何足迹。
+ if a.sceneSuppressed(evt) {
+ return nil
+ }
var feats []memory.SituationFeature
if evt != nil {
if evt.Source != "" {
@@ -225,8 +261,8 @@ func (a *Agent) resolveTurnScenes(f *TaskFrame, tool string) memory.TurnScene {
return f.turnScene
}
- declared := sceneKeysFor(evtOf(f), tool)
- feats := situationFeaturesFor(evtOf(f), cleanInputOf(f), tool)
+ declared := a.sceneKeysFor(evtOf(f), tool)
+ feats := a.situationFeaturesFor(evtOf(f), cleanInputOf(f), tool)
sig := memory.NewSituation(feats...)
turn, err := a.memory.EnterSceneWithHint(sig, declared)
diff --git a/internal/agent/core/scene_test.go b/internal/agent/core/scene_test.go
index 8170037..f735b73 100644
--- a/internal/agent/core/scene_test.go
+++ b/internal/agent/core/scene_test.go
@@ -11,8 +11,9 @@ import (
// TestSceneKeysFor 钉住当前场景的推导优先级:
// 注入点显式声明 > 通道 > 工具;并列命中且去重。
func TestSceneKeysFor(t *testing.T) {
+ a := &Agent{io: agentIO.NewIOManager()}
// 通道 + 工具:两个都能独立成立的触发条件,都要带上
- got := sceneKeysFor(&agentIO.InputEvent{Source: "qq"}, "qq_get_message")
+ got := a.sceneKeysFor(&agentIO.InputEvent{Source: "qq"}, "qq_get_message")
want := []string{"chan:qq", "tool:qq_get_message"}
if len(got) != len(want) {
t.Fatalf("sceneKeysFor = %v, want %v", got, want)
@@ -28,7 +29,7 @@ func TestSceneKeysFor(t *testing.T) {
Source: "QQ",
Payload: map[string]interface{}{"scene": " chan:qq/peer:group_1 "},
}
- got = sceneKeysFor(evt, "")
+ got = a.sceneKeysFor(evt, "")
if len(got) != 2 || got[0] != "chan:qq/peer:group_1" || got[1] != "chan:qq" {
t.Errorf("显式声明应排最前且通道场景归一: %v", got)
}
@@ -38,18 +39,18 @@ func TestSceneKeysFor(t *testing.T) {
Source: "webui",
Payload: map[string]interface{}{"scene": []interface{}{"chan:qq", "task:reminder"}},
}
- got = sceneKeysFor(evt, "")
+ got = a.sceneKeysFor(evt, "")
if len(got) != 3 || got[0] != "chan:qq" || got[1] != "task:reminder" || got[2] != "chan:webui" {
t.Errorf("数组声明未生效: %v", got)
}
// nil 事件不 panic
- if got := sceneKeysFor(nil, ""); len(got) != 0 {
+ if got := a.sceneKeysFor(nil, ""); len(got) != 0 {
t.Errorf("nil 事件应无场景: %v", got)
}
// 未声明的 payload 键不影响
evt = &agentIO.InputEvent{Source: "cli", Payload: map[string]interface{}{"recall_policy": "none"}}
- if got := sceneKeysFor(evt, ""); len(got) != 1 || got[0] != "chan:cli" {
+ if got := a.sceneKeysFor(evt, ""); len(got) != 1 || got[0] != "chan:cli" {
t.Errorf("无 scene 声明时应只有通道场景: %v", got)
}
}
@@ -57,11 +58,12 @@ func TestSceneKeysFor(t *testing.T) {
// TestSituationFeaturesFor 钉住指纹来源:全部是运行时可观察量,
// 不需要模型配合也不需要人工标注。
func TestSituationFeaturesFor(t *testing.T) {
+ a := &Agent{io: agentIO.NewIOManager()}
evt := &agentIO.InputEvent{
Source: "QQ",
Payload: map[string]interface{}{"group_id": float64(1027993713)},
}
- feats := situationFeaturesFor(evt, "帮我看看排班表", "qq_get_message")
+ feats := a.situationFeaturesFor(evt, "帮我看看排班表", "qq_get_message")
kinds := map[string]int{}
for _, f := range feats {
kinds[f.Kind]++
@@ -98,7 +100,7 @@ func TestSituationFeaturesFor(t *testing.T) {
}
// 无事件时不 panic,且只有工具特征时也成立
- if feats := situationFeaturesFor(nil, "", "memory_recall"); len(feats) != 1 {
+ if feats := a.situationFeaturesFor(nil, "", "memory_recall"); len(feats) != 1 {
t.Errorf("仅工具场景应有 1 个特征: %+v", feats)
}
}
@@ -106,9 +108,10 @@ func TestSituationFeaturesFor(t *testing.T) {
// TestWritePathAttachesBothPaths 钉住写侧的「两条路都挂」:
// 显式声明优先;否则挂本轮声明的 + 涌现的场景集合。
func TestTurnSceneKeysBothPaths(t *testing.T) {
+ a := &Agent{io: agentIO.NewIOManager()}
// 声明的通道场景与工具场景都在,涌现键(若有)追加在后
evt := &agentIO.InputEvent{Source: "qq", Payload: map[string]interface{}{"scene": "chan:qq/peer:group_1"}}
- got := sceneKeysFor(evt, "qq_get_message")
+ got := a.sceneKeysFor(evt, "qq_get_message")
want := []string{"chan:qq/peer:group_1", "chan:qq", "tool:qq_get_message"}
if len(got) != len(want) {
t.Fatalf("声明侧场景数不对: %v want %v", got, want)
diff --git a/internal/agent/core/scenepolicy_test.go b/internal/agent/core/scenepolicy_test.go
new file mode 100644
index 0000000..15cd8c7
--- /dev/null
+++ b/internal/agent/core/scenepolicy_test.go
@@ -0,0 +1,144 @@
+package core
+
+// ScenePolicy 声明项的判据(R6)。
+//
+// 缺口事实(穷举确认,非查漏):ChannelDef 已有 NoMemory/ContextPolicy/
+// RecallPolicy 三件套,唯独没有「这条通道是否参与场面识别」;
+// situationFeaturesFor 里只要 evt.Source != "" 就无条件塞 chan 特征。
+// ⇒ chan:system / chan:kernel / chan:timer 这类内部信噪通道
+// 也在参与场面聚类(现网 65 个键里就有 chan:system、chan:kernel、chan:timer)。
+//
+// 判据参照物在生产代码之外:期望值是「声明 none 的输入不产生任何场面特征」、
+// 「未声明的输入行为逐字节不变」这两条不变量,不引用被测实现。
+
+import (
+ "testing"
+
+ agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
+ pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
+)
+
+func scenePolicyNoneEvent() *agentIO.InputEvent {
+ return &agentIO.InputEvent{
+ Source: "system",
+ Payload: map[string]interface{}{"scene_policy": "none"},
+ }
+}
+
+// R6 核心:声明 none 的输入不产生任何场面特征。
+func TestScenePolicyNone_NoSituationFeatures(t *testing.T) {
+ a := &Agent{io: agentIO.NewIOManager()}
+ evt := scenePolicyNoneEvent()
+
+ if feats := a.situationFeaturesFor(evt, "帮我看下定时器", ""); len(feats) != 0 {
+ t.Fatalf("声明 scene_policy=none 的输入仍产出了 %d 个场面特征: %+v —— "+
+ "内部信噪通道会参与场面聚类,把无关场面撑出来", len(feats), feats)
+ }
+}
+
+// none 必须连时段(part) 都不产:一个不参与场面识别的通道
+// 不该在 situation_evidence / scene_features 里留下任何足迹。
+func TestScenePolicyNone_NoPartFeatureLeak(t *testing.T) {
+ a := &Agent{io: agentIO.NewIOManager()}
+ feats := a.situationFeaturesFor(scenePolicyNoneEvent(), "任意内容", "")
+ for _, f := range feats {
+ if f.Kind == "part" {
+ t.Errorf("scene_policy=none 仍产出了时段特征 %q", f.Key())
+ }
+ }
+}
+
+// none 时工具场景也不该派生。
+func TestScenePolicyNone_NoToolScene(t *testing.T) {
+ a := &Agent{io: agentIO.NewIOManager()}
+ keys := a.sceneKeysFor(scenePolicyNoneEvent(), "cmd_run")
+ for _, k := range keys {
+ if k == "tool:cmd_run" || k == "chan:system" {
+ t.Errorf("scene_policy=none 仍派生了场景键 %q", k)
+ }
+ }
+}
+
+// 声明 none 时,显式 payload["scene"] 也不该被采纳——
+// 否则通道声明形同虚设(注入点声明与通道声明必须一致,通道是更宽的闸)。
+func TestScenePolicyNone_OverridesExplicitScene(t *testing.T) {
+ a := &Agent{io: agentIO.NewIOManager()}
+ evt := scenePolicyNoneEvent()
+ evt.Payload["scene"] = "chan:qq"
+
+ keys := a.sceneKeysFor(evt, "")
+ for _, k := range keys {
+ if k == "chan:qq" {
+ t.Error("通道声明 scene_policy=none 后,注入点显式声明的 chan:qq 仍被采纳;" +
+ "通道级闸门应覆盖注入点级声明")
+ }
+ }
+ if feats := a.situationFeaturesFor(evt, "", ""); len(feats) != 0 {
+ t.Errorf("scene_policy=none 仍产出特征 %+v", feats)
+ }
+}
+
+// 未声明时行为必须逐字节不变(零值 = 保持现状 = 参与)。
+// 这是「纯追加」承诺的护栏:老插件不填 ScenePolicy,行为不能有任何变化。
+func TestScenePolicyUnset_BehavesExactlyAsBefore(t *testing.T) {
+ a := &Agent{io: agentIO.NewIOManager()}
+ evt := &agentIO.InputEvent{
+ Source: "qq",
+ Payload: map[string]interface{}{},
+ }
+
+ feats := a.situationFeaturesFor(evt, "老大在吗", "")
+ if len(feats) == 0 {
+ t.Fatal("未声明 scene_policy 的输入不应失去场面特征(零值必须等价既有行为)")
+ }
+ var hasChan bool
+ for _, f := range feats {
+ if f.Kind == "chan" {
+ hasChan = true
+ }
+ }
+ if !hasChan {
+ t.Errorf("未声明时应照旧产出 chan 特征,实际: %+v", feats)
+ }
+
+ keys := a.sceneKeysFor(evt, "")
+ if len(keys) == 0 || keys[0] != "chan:qq" {
+ t.Errorf("未声明时应照旧派生 chan:qq,实际: %v", keys)
+ }
+}
+
+// 显式 auto 与未声明等价。
+func TestScenePolicyAuto_SameAsUnset(t *testing.T) {
+ a := &Agent{io: agentIO.NewIOManager()}
+ mk := func(policy string) []string {
+ payload := map[string]interface{}{}
+ if policy != "" {
+ payload["scene_policy"] = policy
+ }
+ return a.sceneKeysFor(&agentIO.InputEvent{Source: "qq", Payload: payload}, "")
+ }
+ unset, auto := mk(""), mk(pubsdk.ScenePolicyAuto)
+
+ if len(unset) != len(auto) {
+ t.Fatalf("auto 与未声明不等价: unset=%v auto=%v", unset, auto)
+ }
+ if len(auto) == 0 || auto[0] != "chan:qq" {
+ t.Fatalf("auto 应照旧派生 chan:qq,实际: %v", auto)
+ }
+}
+
+// SDK 常量与校验函数:形状须与 ContextPolicy/RecallPolicy 一致。
+func TestScenePolicySDKShape(t *testing.T) {
+ if !pubsdk.ValidScenePolicy("") {
+ t.Error("空串应等价默认,校验须通过")
+ }
+ if !pubsdk.ValidScenePolicy(pubsdk.ScenePolicyNone) {
+ t.Error("none 应合法")
+ }
+ if !pubsdk.ValidScenePolicy(pubsdk.ScenePolicyAuto) {
+ t.Error("auto 应合法")
+ }
+ if pubsdk.ValidScenePolicy("prune") {
+ t.Error("未知取值应被拒(照 ContextPolicy 的严格度)")
+ }
+}
diff --git a/internal/agent/core/task.go b/internal/agent/core/task.go
index ae975aa..70e6253 100644
--- a/internal/agent/core/task.go
+++ b/internal/agent/core/task.go
@@ -788,7 +788,7 @@ func (a *Agent) stepToolAfter(f *TaskFrame) stepOutcome {
// 与这一步工具本身(如 tool:qq_get_message)。带上工具场景,
// 才能让「凡是要回 QQ 消息」这类规则在该步被取回。
// 召回用两条路的并集:声明场景(注入点/通道/工具)+ 涌现场景
- scenes := sceneKeysFor(f.Evt, tc.Name)
+ scenes := a.sceneKeysFor(f.Evt, tc.Name)
turn := a.resolveTurnScenes(f, tc.Name)
for _, k := range turn.Keys {
scenes = append(scenes, k)
diff --git a/internal/agent/core/tooldefs.go b/internal/agent/core/tooldefs.go
index 6a8fdf3..c89e7c7 100644
--- a/internal/agent/core/tooldefs.go
+++ b/internal/agent/core/tooldefs.go
@@ -48,7 +48,7 @@ func (a *Agent) buildMemoryContext(input string, maxTokens int, scenes []string)
// query 取**清洗后**的输入(通道 Cleaner 的输出),与裁剪侧同一套语义:
// 原始输入里的 ANSI/base64/JSON 包装会把相关性打分带偏。清洗为空时回退原文。
func (a *Agent) buildTaskMemoryContext(f *TaskFrame, input string, maxTokens int) string {
- scenes := sceneKeysFor(evtOf(f), "")
+ scenes := a.sceneKeysFor(evtOf(f), "")
if f == nil {
return a.recallText(input, "input", maxTokens, scenes)
}
diff --git a/internal/agent/io/channel.go b/internal/agent/io/channel.go
index 14e76c4..c8cced7 100644
--- a/internal/agent/io/channel.go
+++ b/internal/agent/io/channel.go
@@ -341,7 +341,7 @@ type InjectOptions = pubsdk.InjectOptions
// applyInjectOpts 把注入标志位写进事件 payload。
//
// 只在非零时写:零值与旧 payload 逐字节一致,事件订阅方与旧内核
-// (不认识这两个键)都不会受影响。
+// (不认识这些键)都不会受影响。
//
// 为什么不把标志位当独立参数传到底:eventloop 与各注入路径都按 payload 取字段
// (no_memory 本来就是这么走的),payload 是这里唯一已有的携带面。
@@ -355,6 +355,9 @@ func applyInjectOpts(payload map[string]interface{}, opts InjectOptions) {
if opts.RecallPolicy != "" {
payload["recall_policy"] = opts.RecallPolicy
}
+ if opts.ScenePolicy != "" {
+ payload["scene_policy"] = opts.ScenePolicy
+ }
if opts.CleanerName != "" {
payload["cleaner_name"] = opts.CleanerName
}
diff --git a/third_party/homeagent-sdk/sdk/plugin.go b/third_party/homeagent-sdk/sdk/plugin.go
index 6852696..93edf9b 100644
--- a/third_party/homeagent-sdk/sdk/plugin.go
+++ b/third_party/homeagent-sdk/sdk/plugin.go
@@ -74,6 +74,36 @@ func ValidRecallPolicy(policy string) bool {
return false
}
+// 场面策略:决定一次输入是否参与**场面识别**(场景式记忆)。
+//
+// 与前两项再正交一轴:NoMemory 管「进不进记忆计算」、ContextPolicy 管
+// 「裁不裁上下文」、RecallPolicy 管「召不召回记忆」,本项管的是
+// 「这条输入算不算一场戏的一部分」——它决定输入会不会产出现场指纹
+// (通道/对话对象/工具/话题/时段),进而决定会不会长出、命中、写入场景。
+//
+// 默认(空串或 ScenePolicyAuto)**参与**,保持既有行为:场景式记忆自
+// v1.3 落地起就对所有通道无条件生效,没有开关。不默认关有两个原因:
+// 1. 场景只**附加**现有记忆的检索路,不改记忆本体,默认关会让存量
+// 通道突然失去场景召回;
+// 2. 「关」是少数意图(内部信噪通道),少数意图不该是默认——
+// 与 ContextPolicy 刻意相反(同为破坏性操作,那里是默认关)。
+//
+// 该关的典型是纯内部通道:system(内核自循环)、kernel、timer、healthcheck。
+// 它们每次触发都在撑一个「场面」,会把不相干的交互聚到一起。
+const (
+ ScenePolicyAuto = "auto"
+ ScenePolicyNone = "none"
+)
+
+// ValidScenePolicy 校验场面策略取值;空串等价于 ScenePolicyAuto。
+func ValidScenePolicy(policy string) bool {
+ switch policy {
+ case "", ScenePolicyAuto, ScenePolicyNone:
+ return true
+ }
+ return false
+}
+
// InjectOptions 声明一次注入行为在记忆层与上下文层的表现。
//
// 零值 = 记入记忆 + 不裁剪上下文,与历史行为(三参数注入方法)完全一致,
@@ -102,7 +132,11 @@ type InjectOptions struct {
// 空串 = 默认(输入/注入 auto,即保持既有「每条输入都召回」的行为);
// RecallPolicyNone 显式关闭(如中断通知的 meta 文本不该据它召回)。
RecallPolicy string
- CleanerName string
+ // ScenePolicy 声明此次注入是否参与场面识别(场景式记忆)。
+ // 空串 = 默认参与(保持既有行为);ScenePolicyNone 显式关闭,
+ // 适用于不产生任何场面指纹的纯内部信号(心跳、自循环、内部状态)。
+ ScenePolicy string
+ CleanerName string
// Priority 声明**中断注入**的优先级(仅 InjectInterrupt* 有意义)。
//
@@ -132,6 +166,7 @@ const (
// Cleaner: 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏/存档提取关键词时调用
// ContextPolicy: 此通道的输入到达后是否据此裁剪上下文,默认 none(不裁剪)
// RecallPolicy: 此通道的输入到达后是否据此召回相关记忆,默认 auto(召回)
+// ScenePolicy: 此通道的输入到达后是否参与场面识别(场景式记忆),默认 auto(参与)
//
// JSON tag 是必需的:通道定义要跨进程传给内核,而 Cleaner 是函数(必须忽略)。
// 没有 tag 时既无法整体 marshal(func 不支持),又会诱使调用方手写字段白名单——
@@ -142,6 +177,8 @@ type ChannelDef struct {
ContextPolicy string `json:"context_policy,omitempty"`
// RecallPolicy 见 InjectOptions.RecallPolicy;空串等价 auto(保持既有行为)。
RecallPolicy string `json:"recall_policy,omitempty"`
+ // ScenePolicy 见 InjectOptions.ScenePolicy;空串等价 auto(保持既有行为)。
+ ScenePolicy string `json:"scene_policy,omitempty"`
}
// StageContext provides context for stage handlers.
From 758ec11832375158b6a21dfd8da2c2cdddb3b6ad Mon Sep 17 00:00:00 2001
From: JianFeeeee
Date: Sat, 26 Sep 2026 20:16:11 +0800
Subject: [PATCH 05/13] =?UTF-8?q?fix(memory):=20=E5=9B=BE=E6=95=B4?=
=?UTF-8?q?=E5=A4=87=E8=A6=86=E7=9B=96=20scenes=20+=20=E8=AF=81=E6=8D=AE?=
=?UTF-8?q?=E6=A1=B6=E6=8C=89=E6=A1=B6=E6=B8=85=EF=BC=88R3/R5=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
R3:图整理心跳只查 entities/relations,scenes 完全没有整备路径。
Recall(nil,nil,1,"") 的全量路径只 SELECT 这两张表
(graph.go:609/631),于是同一场面的双胞胎键从建库起无人发现:
auto:chan:qq+part:morning 累积到 strength=271 / 6 features / 0 refs,
孪生的 auto:chan:qq_part:morning 持有 210 refs 却 0 features
(聚类只读 scene_features,所以它永远不被看见)。
新增 GraphDB.DedupeScenes:归一化后同名的场景合成一个——强度相加、
特征取并集(权重取大)、引用全部重定向,存活者保留 id 最小行,
跨 origin 也合。接到 mergeLoop 尾部。
为什么不塞进 detectEntityMerge 的双重循环:
- 实体是全库两两 bigram + LLM 裁决(1 万实体实测 5000 万次配对、
~224GB 瞬时分配每轮,是独立问题);
- 场景的判重口径是**归一化后是否同名**——同名即同一场面,键相同
本身就是证据,不需要 LLM 裁决。而「像不像」是 EnterScene 聚类的
职责,不是这里的事。
只做同键合并、不做相似度合并:把 chan:qq 与 chan:webui 合并是危险
的,去重不是「把像的一律合并」。
生产库副本实测(sqlite3 备份式复制到 /tmp,未碰生产):
65 个场景 → 合并 8 组 → 57 个;
auto:chan:qq_part:morning 的 refs/rel/ent 一条没丢,strength 1 → 272。
R5:createSceneLocked 新场景成立时执行的是
`DELETE FROM situation_evidence`(全表清),而证据表是多通道共用的
计数桶。后果不是「多清一点」:qq 的场景一长出来,就把 mc/webui/cli
尚未攒够 minSceneEvidence=2 的证据抹掉,它们的计数被反复清零,
于是**永远**攒不到 2 次。判据实测:6 个通道各来 3 次,只长出 2 个场景。
改为 `DELETE ... WHERE label = ?`,只清本指纹那个桶。
判据:scene_dedupe_test.go 8 例(含「不同场面不得被合并」与幂等)、
scene_evidence_test.go 3 例。均先红后绿。修 R5 时差点栽:桶键是
sig.Label(2) 本身、不带 auto: 前缀(base 才是带前缀的场景键),
第一版删错对象会「一条没删却看起来通过」,用探针实测真实桶键后改正。
记忆 8 包 + agent/core 全绿,8 包齐全、无 FAIL/panic/race。
---
docs/zh/scene-memory-fix-plan.md | 7 +-
internal/agent/core/distill.go | 26 +++
internal/memory/scene.go | 143 ++++++++++++
internal/memory/scene_dedupe_test.go | 285 ++++++++++++++++++++++++
internal/memory/scene_emerge.go | 13 +-
internal/memory/scene_evidence_test.go | 129 +++++++++++
third_party/homeagent-sdk/sdk/plugin.go | 6 +-
7 files changed, 604 insertions(+), 5 deletions(-)
create mode 100644 internal/memory/scene_dedupe_test.go
create mode 100644 internal/memory/scene_evidence_test.go
diff --git a/docs/zh/scene-memory-fix-plan.md b/docs/zh/scene-memory-fix-plan.md
index 2ada97d..33ca9ba 100644
--- a/docs/zh/scene-memory-fix-plan.md
+++ b/docs/zh/scene-memory-fix-plan.md
@@ -157,8 +157,11 @@ RecallPolicy 召不召回记忆(只读,默认开)
一个不参与场面识别的通道不该留下时段噪声)
- 声明路 `sceneKeysFor` 同样受 `none` 约束
- [ ] 判据:`none` 通道连续 5 次交互,`scenes` 表行数不变
-- [ ] 存量标注:给 `system` / `kernel` / `timer` / `healthcheck` 等内部信噪通道
- 标 `ScenePolicyNone`(**逐个确认后再标**,不批量猜)
+- [x] **存量标注:不做**(用户裁定 2026-09-26:「所有都默认开启,因为多写无影响,
+ 少写会缺场景」)。实测支持:8 个 0-refs 通道合计 70 strength、0 条记忆,
+ 召回返回空;且 declared 场景**不进**相似度空间
+ (`loadEmergentScenesLocked` 只取 `origin='emergent'`),
+ 故多写对聚类零影响。声明项作为「插件将来确实需要时」的闸门保留。
### 步骤 3:修 R3(让图整理覆盖全库)
diff --git a/internal/agent/core/distill.go b/internal/agent/core/distill.go
index 03c54b0..0be526b 100644
--- a/internal/agent/core/distill.go
+++ b/internal/agent/core/distill.go
@@ -106,6 +106,7 @@ func (a *Agent) mergeLoop() {
case <-ticker.C:
log.Printf("[agent] heartbeat merge tick")
a.detectEntityMerge()
+ a.dedupeScenes()
case <-a.ctx.Done():
return
}
@@ -312,6 +313,31 @@ func (a *Agent) detectEntityMerge() {
}
}
+// dedupeScenes 是图整备的场景侧去重:把「同一个场面的两个键」合成一个。
+//
+// 为什么与 detectEntityMerge 分开而不塞进它的双重循环:
+// - 实体:全库两两 bigram + LLM 裁决。1 万实体实测 5000 万次配对、
+// ~224GB 瞬时分配每轮(见 plan「不做的事」),已是独立问题。
+// - 场景:判重口径是**归一化后是否同名**——同名即同一场面,**不需要 LLM
+// 裁决**(键相同本身就是证据)。且 declared 场景不进相似度空间,
+// 场景之间「像不像」由 EnterScene 的聚类负责,不是这里的事。
+//
+// 所以这里只做确定性的同键合并,不做相似度合并:把「chan:qq 与
+// chan:webui 很像」也合并是危险的,那会把不同场面糊成一个。
+func (a *Agent) dedupeScenes() {
+ if a.memory == nil {
+ return
+ }
+ merged, err := a.memory.DedupeScenes()
+ if err != nil {
+ log.Printf("[agent] 场景去重失败(下轮重试): %v", err)
+ return
+ }
+ if merged > 0 {
+ log.Printf("[agent] 场景去重:合并 %d 组同键场景(同一场面的重复键)", merged)
+ }
+}
+
// ──────────────────────────────────────────────
// 关系复审:GraphDB → ClearSentenceID → CleanupOrphanedSentences
// ──────────────────────────────────────────────
diff --git a/internal/memory/scene.go b/internal/memory/scene.go
index 2ab7e6a..b865ea2 100644
--- a/internal/memory/scene.go
+++ b/internal/memory/scene.go
@@ -580,3 +580,146 @@ func (g *GraphDB) TagSceneDocument(sceneKey, docID string) error {
}
return tx.Commit()
}
+
+// ──────────────────────────────────────────────
+// 场景去重(整备):把「同一个场面的两个键」合成一个
+// ──────────────────────────────────────────────
+
+// DedupeScenes 合并归一化后同名的场景,返回合并组数。
+//
+// 存在的原因:scenes.key 有 UNIQUE 约束,但**归一化口径曾经不统一**——
+// 建键路径用 "auto:" + Label(2) 而 Label 拼的是 "+",不过 NormalizeSceneKey;
+// 而写侧(effectiveScenes)、读侧(RecallByScene)、声明建键(EnsureScene)
+// 三处都过了归一化。于是 "+" 与 "_" 成为两个都合法的主键,UNIQUE 拦不住:
+//
+// auto:chan:qq+part:morning strength=270 6 features 0 refs
+// auto:chan:qq_part:morning strength=1 0 features 201 refs
+//
+// 两个节点互不可见:聚类只读 scene_features,所以 0-features 的那个
+// 永远不被看见;而 0-refs 的那个收不到任何写侧记忆。实测这对双胞胎
+// 从建库起累积到 strength=270 都没人发现——因为图整理心跳(mergeLoop)
+// 的遍历入口 Recall(nil,nil,1,"") 只查 entities 与 relations,scenes
+// 不在其中。
+//
+// 合并口径:**只有归一化后完全同名才算重复**。相似但不同的场面
+// (chan:qq 与 chan:webui)绝不合并——去重不是"把像的一律合并"。
+// 场景之间的相似度判定是 EnterScene 的聚类职责,那是另一件事。
+//
+// 存活规则:保留 id 最小的那一行(先来者),其余并入它。
+// 强度相加、特征取并集(权重取大)、引用全部重定向。
+// 跨 origin 也合:现网存在 origin='emergent' 却长得像声明键的
+// chan:context_archived。
+func (g *GraphDB) DedupeScenes() (int, error) {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+
+ rows, err := g.db.Query(`SELECT id, key FROM scenes ORDER BY id`)
+ if err != nil {
+ return 0, err
+ }
+ type row struct {
+ id int64
+ key string
+ }
+ var all []row
+ for rows.Next() {
+ var r row
+ if err := rows.Scan(&r.id, &r.key); err != nil {
+ rows.Close()
+ return 0, err
+ }
+ all = append(all, r)
+ }
+ rows.Close()
+ if err := rows.Err(); err != nil {
+ return 0, err
+ }
+ if len(all) < 2 {
+ return 0, nil
+ }
+
+ // 按归一化后的键分组,组内 id 最小者为存活者。
+ groups := make(map[string][]row)
+ order := make([]string, 0, len(all))
+ for _, r := range all {
+ nk := NormalizeSceneKey(r.key)
+ if nk == "" {
+ // 键归一化后为空:无法判定它与谁重复,跳过(不擅自删数据)。
+ continue
+ }
+ if _, seen := groups[nk]; !seen {
+ order = append(order, nk)
+ }
+ groups[nk] = append(groups[nk], r)
+ }
+
+ merged := 0
+ for _, nk := range order {
+ gp := groups[nk]
+ if len(gp) < 2 {
+ continue
+ }
+ keep := gp[0] // ORDER BY id ⇒ 最早创建的那行
+
+ tx, err := g.db.Begin()
+ if err != nil {
+ return merged, err
+ }
+ err = func() error {
+ for _, dup := range gp[1:] {
+ // 强度相加。
+ if _, err := tx.Exec(
+ `UPDATE scenes SET strength = COALESCE(strength,1) +
+ COALESCE((SELECT strength FROM scenes WHERE id = ?), 0),
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?`, dup.id, keep.id); err != nil {
+ return err
+ }
+ // 特征取并集,权重取大。
+ if _, err := tx.Exec(
+ `INSERT INTO scene_features (scene_id, feature, weight)
+ SELECT ?, feature, weight FROM scene_features WHERE scene_id = ?
+ ON CONFLICT(scene_id, feature) DO UPDATE
+ SET weight = MAX(weight, excluded.weight)`,
+ keep.id, dup.id); err != nil {
+ return err
+ }
+ if _, err := tx.Exec(
+ `DELETE FROM scene_features WHERE scene_id = ?`, dup.id); err != nil {
+ return err
+ }
+ // 引用重定向。UNIQUE(scene_id,kind,ref_id,ref_text) 会与存活者
+ // 上的同一条冲突——冲突即同一条记忆,取权重大的那条。
+ if _, err := tx.Exec(
+ `INSERT INTO scene_refs (scene_id, kind, ref_id, ref_text, weight)
+ SELECT ?, kind, ref_id, ref_text, weight FROM scene_refs WHERE scene_id = ?
+ ON CONFLICT(scene_id, kind, ref_id, ref_text) DO UPDATE
+ SET weight = MAX(weight, excluded.weight)`,
+ keep.id, dup.id); err != nil {
+ return err
+ }
+ if _, err := tx.Exec(
+ `DELETE FROM scene_refs WHERE scene_id = ?`, dup.id); err != nil {
+ return err
+ }
+ if _, err := tx.Exec(`DELETE FROM scenes WHERE id = ?`, dup.id); err != nil {
+ return err
+ }
+ merged++
+ }
+ // 存活者的键也归一化,避免下次又认不出自己。
+ if _, err := tx.Exec(`UPDATE scenes SET key = ? WHERE id = ?`, nk, keep.id); err != nil {
+ return err
+ }
+ return nil
+ }()
+ if err != nil {
+ tx.Rollback()
+ return merged, err
+ }
+ if err := tx.Commit(); err != nil {
+ return merged, err
+ }
+ }
+ return merged, nil
+}
diff --git a/internal/memory/scene_dedupe_test.go b/internal/memory/scene_dedupe_test.go
new file mode 100644
index 0000000..6b0161c
--- /dev/null
+++ b/internal/memory/scene_dedupe_test.go
@@ -0,0 +1,285 @@
+package memory
+
+// 场景去重(R3)的判据。
+//
+// 缺口事实:图整理心跳(mergeLoop → detectEntityMerge)唯一的遍历入口是
+// Recall(nil,nil,1,""),而该全量路径只查 entities(graph.go:609) 与
+// relations(graph.go:631) —— scenes 不在其中。于是同一个场面的双胞胎键
+// (auto:chan:qq+part:morning ↔ auto:chan:qq_part:morning)从建库起
+// 无人发现:强度一路涨到 270、6 个 features、0 条记忆,而孪生的那个
+// 持有 201 条记忆却有 0 features。两套特征体系各活各的。
+//
+// 判据参照物在生产代码之外:期望值是「归一化后同名的场景必须合成一个」
+// 与「合并后记忆/特征/强度都不丢」这两条不变量,不引用被测实现。
+
+import (
+ "os"
+ "strings"
+ "testing"
+)
+
+// insertSceneRow 直写一行 scenes(绕开建键路径),用来复现历史双胞胎。
+func insertSceneRow(t *testing.T, g *GraphDB, key, origin string, strength int) int64 {
+ t.Helper()
+ res, err := g.db.Exec(
+ `INSERT INTO scenes (key, strength, origin) VALUES (?, ?, ?)`,
+ key, strength, origin)
+ if err != nil {
+ t.Fatal(err)
+ }
+ id, _ := res.LastInsertId()
+ return id
+}
+
+func addFeature(t *testing.T, g *GraphDB, sceneID int64, feature string, weight float64) {
+ t.Helper()
+ if _, err := g.db.Exec(
+ `INSERT INTO scene_features (scene_id, feature, weight) VALUES (?, ?, ?)`,
+ sceneID, feature, weight); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func addRef(t *testing.T, g *GraphDB, sceneID int64, kind string, refID int64, weight float64) {
+ t.Helper()
+ if _, err := g.db.Exec(
+ `INSERT INTO scene_refs (scene_id, kind, ref_id, weight) VALUES (?, ?, ?, ?)`,
+ sceneID, kind, refID, weight); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func sceneRowCount(t *testing.T, g *GraphDB) int {
+ t.Helper()
+ var n int
+ if err := g.db.QueryRow(`SELECT COUNT(*) FROM scenes`).Scan(&n); err != nil {
+ t.Fatal(err)
+ }
+ return n
+}
+
+func sceneRefCount(t *testing.T, g *GraphDB) int {
+ t.Helper()
+ var n int
+ if err := g.db.QueryRow(`SELECT COUNT(*) FROM scene_refs`).Scan(&n); err != nil {
+ t.Fatal(err)
+ }
+ return n
+}
+
+// R3 核心:归一化后同名的双胞胎必须合成一个。
+func TestDedupeScenes_MergesNormalizedTwins(t *testing.T) {
+ g := newTestGraph(t)
+ defer os.Remove(g.dbPath)
+ defer g.Close()
+
+ // 复现现网形态:一个带 +(有 features、无 refs),一个带 _(有 refs、无 features)
+ plus := insertSceneRow(t, g, "auto:chan:qq+part:morning", "emergent", 270)
+ under := insertSceneRow(t, g, "auto:chan:qq_part:morning", "emergent", 1)
+ addFeature(t, g, plus, "chan:qq", 1.0)
+ addFeature(t, g, plus, "part:morning", 0.2)
+ addRef(t, g, under, "relation", 42, 1.0)
+ addRef(t, g, under, "entity", 7, 1.0)
+
+ merged, err := g.DedupeScenes()
+ if err != nil {
+ t.Fatalf("DedupeScenes 出错: %v", err)
+ }
+ if merged != 1 {
+ t.Errorf("应合并 1 组,实际 %d", merged)
+ }
+ if n := sceneRowCount(t, g); n != 1 {
+ t.Fatalf("合并后 scenes 应剩 1 行,实际 %d:\n%v", n, sceneKeys(t, g))
+ }
+}
+
+// 合并绝不能丢记忆:refs 要全部转到存活的那一行。
+func TestDedupeScenes_PreservesRefs(t *testing.T) {
+ g := newTestGraph(t)
+ defer os.Remove(g.dbPath)
+ defer g.Close()
+
+ plus := insertSceneRow(t, g, "auto:chan:mc:event+topic:mc", "emergent", 50)
+ under := insertSceneRow(t, g, "auto:chan:mc:event_topic:mc", "emergent", 1)
+ addFeature(t, g, plus, "chan:mc:event", 1.0)
+ addRef(t, g, under, "relation", 1, 1.0)
+ addRef(t, g, under, "relation", 2, 1.0)
+ addRef(t, g, under, "entity", 3, 1.0)
+
+ if _, err := g.DedupeScenes(); err != nil {
+ t.Fatal(err)
+ }
+
+ // 一条都不能少
+ if got := sceneRefCount(t, g); got != 3 {
+ t.Fatalf("合并后 scene_refs 应有 3 条,实际 %d —— 合并丢了记忆", got)
+ }
+ // 且全部挂在存活的那一行上
+ var sceneID int64
+ if err := g.db.QueryRow(`SELECT id FROM scenes LIMIT 1`).Scan(&sceneID); err != nil {
+ t.Fatal(err)
+ }
+ var onSurvivor int
+ if err := g.db.QueryRow(
+ `SELECT COUNT(*) FROM scene_refs WHERE scene_id = ?`, sceneID).Scan(&onSurvivor); err != nil {
+ t.Fatal(err)
+ }
+ if onSurvivor != 3 {
+ t.Errorf("存活场景上只挂了 %d/3 条 refs", onSurvivor)
+ }
+}
+
+// 特征取并集:哪一侧有就保留,权重取大。
+func TestDedupeScenes_UnionsFeatures(t *testing.T) {
+ g := newTestGraph(t)
+ defer os.Remove(g.dbPath)
+ defer g.Close()
+
+ plus := insertSceneRow(t, g, "auto:chan:qq+topic:排班", "emergent", 3)
+ under := insertSceneRow(t, g, "auto:chan:qq_topic:排班", "emergent", 2)
+ addFeature(t, g, plus, "chan:qq", 1.0)
+ addFeature(t, g, plus, "topic:排班", 0.4)
+ addFeature(t, g, under, "chan:qq", 1.0)
+ addFeature(t, g, under, "peer:boss", 1.0) // 只在孪生那侧有
+
+ if _, err := g.DedupeScenes(); err != nil {
+ t.Fatal(err)
+ }
+
+ var n int
+ if err := g.db.QueryRow(`SELECT COUNT(*) FROM scene_features`).Scan(&n); err != nil {
+ t.Fatal(err)
+ }
+ if n != 3 {
+ t.Errorf("特征并集应为 3(chan:qq / topic:排班 / peer:boss),实际 %d", n)
+ }
+}
+
+// strength 要相加:两个场景各被遇到过 N 次,合起来就该是 2N。
+func TestDedupeScenes_SumsStrength(t *testing.T) {
+ g := newTestGraph(t)
+ defer os.Remove(g.dbPath)
+ defer g.Close()
+
+ insertSceneRow(t, g, "auto:chan:qq+part:morning", "emergent", 270)
+ insertSceneRow(t, g, "auto:chan:qq_part:morning", "emergent", 1)
+
+ if _, err := g.DedupeScenes(); err != nil {
+ t.Fatal(err)
+ }
+
+ var strength int
+ if err := g.db.QueryRow(`SELECT strength FROM scenes LIMIT 1`).Scan(&strength); err != nil {
+ t.Fatal(err)
+ }
+ if strength != 271 {
+ t.Errorf("strength 应为 270+1=271,实际 %d", strength)
+ }
+}
+
+// 幂等:跑两次,第二次必须是 0 合并、0 行变化。
+func TestDedupeScenes_IsIdempotent(t *testing.T) {
+ g := newTestGraph(t)
+ defer os.Remove(g.dbPath)
+ defer g.Close()
+
+ insertSceneRow(t, g, "auto:chan:qq+part:morning", "emergent", 270)
+ insertSceneRow(t, g, "auto:chan:qq_part:morning", "emergent", 1)
+ addRef(t, g, 2, "relation", 1, 1.0)
+
+ if n, err := g.DedupeScenes(); err != nil || n != 1 {
+ t.Fatalf("首次应合并 1 组,实际 n=%d err=%v", n, err)
+ }
+ rows, refs, strength := sceneRowCount(t, g), sceneRefCount(t, g), 271
+
+ n2, err := g.DedupeScenes()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if n2 != 0 {
+ t.Errorf("第二次不应再合并,实际 %d", n2)
+ }
+ if got := sceneRowCount(t, g); got != rows {
+ t.Errorf("第二次改变了行数: %d → %d", rows, got)
+ }
+ if got := sceneRefCount(t, g); got != refs {
+ t.Errorf("第二次改变了 refs: %d → %d", refs, got)
+ }
+ var s int
+ if err := g.db.QueryRow(`SELECT strength FROM scenes LIMIT 1`).Scan(&s); err != nil {
+ t.Fatal(err)
+ }
+ if s != strength {
+ t.Errorf("第二次把 strength 改成了 %d(应保持 %d)", s, strength)
+ }
+}
+
+// 不同场面不能被合到一起:只有归一化后**完全同名**才算重复。
+// 这是本判据的另一半——去重不能变成"把相似的一律合并"。
+func TestDedupeScenes_KeepsDistinctScenes(t *testing.T) {
+ g := newTestGraph(t)
+ defer os.Remove(g.dbPath)
+ defer g.Close()
+
+ insertSceneRow(t, g, "auto:chan:qq", "emergent", 5)
+ insertSceneRow(t, g, "auto:chan:webui", "emergent", 6)
+ insertSceneRow(t, g, "auto:chan:qq_part:morning", "emergent", 270)
+ addFeature(t, g, 1, "chan:qq", 1.0)
+ addFeature(t, g, 2, "chan:webui", 1.0)
+
+ n, err := g.DedupeScenes()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if n != 0 {
+ t.Errorf("三个不同场面不该被合并,实际合并了 %d 组", n)
+ }
+ if got := sceneRowCount(t, g); got != 3 {
+ t.Errorf("应有 3 个场景,实际 %d: %v", got, sceneKeys(t, g))
+ }
+}
+
+// 声明场景与涌现场景归一化后同名时也要合——现网 chan:context_archived
+// 就是这么来的(origin='emergent' 却长得像声明键)。
+func TestDedupeScenes_MergesAcrossOrigins(t *testing.T) {
+ g := newTestGraph(t)
+ defer os.Remove(g.dbPath)
+ defer g.Close()
+
+ insertSceneRow(t, g, "chan:qq", "declared", 281)
+ insertSceneRow(t, g, "chan:QQ", "declared", 3) // 仅大小写不同
+
+ n, err := g.DedupeScenes()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if n != 1 {
+ t.Fatalf("大小写不同的同名场景应合并,实际 %d 组", n)
+ }
+ if got := sceneRowCount(t, g); got != 1 {
+ t.Errorf("应剩 1 行,实际 %d: %v", got, sceneKeys(t, g))
+ }
+}
+
+// 归一化口径必须与写/读侧一致:这里独立复算一遍期望名,
+// 避免判据与被测实现共用同一个 NormalizeSceneKey 而一起错。
+func TestDedupeScenes_UsesSameNormalizationAsWriteSide(t *testing.T) {
+ g := newTestGraph(t)
+ defer os.Remove(g.dbPath)
+ defer g.Close()
+
+ // 写侧(effectiveScenes)会把 "auto:chan:qq+part:morning" 归一成什么?
+ wantKey := NormalizeSceneKey("auto:chan:qq+part:morning")
+ if strings.Contains(wantKey, "+") {
+ t.Fatalf("前提不成立:NormalizeSceneKey 未处理 '+',got %q", wantKey)
+ }
+ insertSceneRow(t, g, "auto:chan:qq+part:morning", "emergent", 1)
+ insertSceneRow(t, g, wantKey, "emergent", 1)
+
+ if _, err := g.DedupeScenes(); err != nil {
+ t.Fatal(err)
+ }
+ if got := sceneRowCount(t, g); got != 1 {
+ t.Errorf("建键侧的未归一化键应与写侧归一化后的键合并,实际剩 %d 行", got)
+ }
+}
diff --git a/internal/memory/scene_emerge.go b/internal/memory/scene_emerge.go
index 3c8dca3..41e6cdc 100644
--- a/internal/memory/scene_emerge.go
+++ b/internal/memory/scene_emerge.go
@@ -410,8 +410,17 @@ func (g *GraphDB) createSceneLocked(sig Situation) (string, error) {
return "", err
}
}
- // 场景成立后,把此前登记的同类线索清掉(它们已被这次长出吸收)
- if _, err := tx.Exec(`DELETE FROM situation_evidence`); err != nil {
+ // 场景成立后,**只清本指纹那个桶**的线索(它们已被这次长出吸收)。
+ //
+ // 曾经是 `DELETE FROM situation_evidence`(全表清),后果不是「多清一点」:
+ // 多通道共用一个库,qq 的场景一长出来,就把 mc / webui / cli 尚未攒够
+ // minSceneEvidence 的证据一并抹掉——它们的计数被反复清零,于是
+ // **永远**攒不到 2 次,场景永远长不出来。实测:6 个通道各来 3 次,
+ // 只长出 2 个场景。
+ //
+ // ★ 桶键是 sig.Label(2) 本身,**不带 "auto:" 前缀**(见
+ // recordSituationEvidenceLocked)——base 是带前缀的场景键,两者不是一回事。
+ if _, err := tx.Exec(`DELETE FROM situation_evidence WHERE label = ?`, sig.Label(2)); err != nil {
return "", err
}
if err := tx.Commit(); err != nil {
diff --git a/internal/memory/scene_evidence_test.go b/internal/memory/scene_evidence_test.go
new file mode 100644
index 0000000..5427598
--- /dev/null
+++ b/internal/memory/scene_evidence_test.go
@@ -0,0 +1,129 @@
+package memory
+
+// R5 的判据:场景成立时清证据,只清**本指纹那个桶**,不是全表。
+//
+// 现状(scene_emerge.go createSceneLocked 末尾):
+//
+// DELETE FROM situation_evidence ← 全表清
+//
+// 后果:多场景并发轮次下,A 场景的建立会连带清掉 B 尚未攒够
+// minSceneEvidence=2 的证据 ⇒ 门槛判定被「别的场景刚好长出来」随机打断。
+// 这不是理论:QQ / mc / webui 三个通道在同一进程里各自计数。
+//
+// 判据参照物在生产代码之外:期望值是「建一个场景后,其它桶的证据必须原样还在」。
+
+import (
+ "os"
+ "testing"
+)
+
+// evidenceCount 读某桶的累计次数(直查表,不走被测函数)。
+func evidenceCount(t *testing.T, g *GraphDB, label string) int {
+ t.Helper()
+ var n int
+ err := g.db.QueryRow(
+ `SELECT COALESCE((SELECT count FROM situation_evidence WHERE label = ?), 0)`, label,
+ ).Scan(&n)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return n
+}
+
+// A 场景成立时,B 桶的证据不得被动。
+func TestSceneCreationKeepsOtherEvidence(t *testing.T) {
+ g := newTestGraph(t)
+ defer os.Remove(g.dbPath)
+ defer g.Close()
+
+ qq := mkSig("qq", "", "")
+ webui := mkSig("webui", "", "")
+
+ // 两个通道各登记一次证据(都还没到门槛)
+ if _, _, err := g.EnterScene(qq); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := g.EnterScene(webui); err != nil {
+ t.Fatal(err)
+ }
+ // 桶键 = sig.Label(2) 本身,**不带 "auto:" 前缀**(已用探针实测:
+ // 登记出来的 label 是 "chan:qq" / "chan:webui")。
+ if evidenceCount(t, g, NormalizeSceneKey("chan:webui")) == 0 {
+ t.Fatal("webui 桶的证据没登记上,前提不成立")
+ }
+
+ // qq 第 2 次 → 建出场景
+ key, _, err := g.EnterScene(qq)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if key == "" {
+ t.Fatal("qq 第 2 次应建出场景")
+ }
+
+ // ★ 关键断言:qq 的证据被吸收了,webui 的必须还在
+ if n := evidenceCount(t, g, NormalizeSceneKey("chan:webui")); n != 1 {
+ t.Errorf("建 qq 场景时把 webui 桶的证据清了(剩 %d,应为 1)—— "+
+ "DELETE FROM situation_evidence 是全表清,"+
+ "别的场景的门槛计数被这次建键随机打断了", n)
+ }
+}
+
+// 被吸收的应该是**本指纹**那个桶。
+func TestSceneCreationClearsOwnEvidence(t *testing.T) {
+ g := newTestGraph(t)
+ defer os.Remove(g.dbPath)
+ defer g.Close()
+
+ qq := mkSig("qq", "", "")
+ webui := mkSig("webui", "", "")
+
+ g.EnterScene(qq)
+ g.EnterScene(webui)
+ qqLabel := NormalizeSceneKey(qq.Label(2)) // 桶键不带 auto: 前缀
+ if evidenceCount(t, g, qqLabel) == 0 {
+ t.Fatalf("前提不成立:%q 桶应有证据", qqLabel)
+ }
+
+ if _, _, err := g.EnterScene(qq); err != nil {
+ t.Fatal(err)
+ }
+
+ if n := evidenceCount(t, g, qqLabel); n != 0 {
+ t.Errorf("场景已成立,%q 桶的证据应被吸收(该场面已长出场景),实际仍为 %d", qqLabel, n)
+ }
+}
+
+// 反复建多个场景,早期桶的证据必须能活到自己的门槛。
+// 这是 R5 的真实后果形态:三个通道轮流入,每个都只来过一次,
+// 全表清会让它们**永远**攒不到 2 次。
+func TestEvidenceSurvivesOtherSceneCreations(t *testing.T) {
+ g := newTestGraph(t)
+ defer os.Remove(g.dbPath)
+ defer g.Close()
+
+ sigs := map[string]Situation{
+ "qq": mkSig("qq", "", ""),
+ "mc": mkSig("mc", "", ""),
+ "cli": mkSig("cli", "", ""),
+ "acp": mkSig("acp", "", ""),
+ "http": mkSig("http", "", ""),
+ "timer": mkSig("timer", "", ""),
+ }
+
+ // 每个通道轮流来 3 次。若全表清,后到的通道永远攒不够。
+ for round := 0; round < 3; round++ {
+ for _, sig := range sigs {
+ if _, _, err := g.EnterScene(sig); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+
+ // 6 个通道都该长出场景
+ if n := len(sceneKeys(t, g)); n != len(sigs) {
+ t.Errorf("6 个通道各来 3 次,应长出 %d 个场景,实际 %d: %v\n"+
+ "全表清证据会让除第一个之外的通道永远攒不到 minSceneEvidence",
+ len(sigs), n, sceneKeys(t, g))
+ }
+}
diff --git a/third_party/homeagent-sdk/sdk/plugin.go b/third_party/homeagent-sdk/sdk/plugin.go
index 93edf9b..e5f1bea 100644
--- a/third_party/homeagent-sdk/sdk/plugin.go
+++ b/third_party/homeagent-sdk/sdk/plugin.go
@@ -89,7 +89,11 @@ func ValidRecallPolicy(policy string) bool {
// 与 ContextPolicy 刻意相反(同为破坏性操作,那里是默认关)。
//
// 该关的典型是纯内部通道:system(内核自循环)、kernel、timer、healthcheck。
-// 它们每次触发都在撑一个「场面」,会把不相干的交互聚到一起。
+// 但**现网不标任何一个**(2026-09-26 裁定):实测这些 0-refs 通道合计 70
+// strength、0 条记忆,场景召回返回空;而 declared 场景不进相似度空间
+// (loadEmergentScenesLocked 只取 origin='emergent'),多写对聚类零影响。
+// 「多写无影响、少写会缺场景」——默认 auto 保持开,声明项只作为插件
+// 将来确实需要时的闸门。
const (
ScenePolicyAuto = "auto"
ScenePolicyNone = "none"
From 13a070fbb24f19f81f9bc4e2eba179145d2d6de8 Mon Sep 17 00:00:00 2001
From: JianFeeeee
Date: Sat, 26 Sep 2026 20:17:17 +0800
Subject: [PATCH 06/13] =?UTF-8?q?docs(memory):=20=E6=A0=87=E8=AE=B0?=
=?UTF-8?q?=E6=AD=A5=E9=AA=A4=203/5=20=E5=AE=8C=E6=88=90=EF=BC=8C=E8=A1=A5?=
=?UTF-8?q?=E6=AD=A5=E9=AA=A4=204=20=E9=83=A8=E7=BD=B2=E5=89=8D=E5=9F=BA?=
=?UTF-8?q?=E7=BA=BF=E4=B8=8E=E7=A1=AC=E7=BA=A6=E6=9D=9F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
步骤 4 加了硬约束提醒:必须先部署含 R1 修复的二进制再清理,否则
重新涌现出来的还是带 +/# 的旧键。并记录部署前实测基线(PID 92115、
子进程插件 25、近 24h 异常日志 0、场景 65),以及 homed --version
这个 flag 并不存在(纪律清单那条要用 status 接口或 ps 核对)。
---
docs/zh/scene-memory-fix-plan.md | 41 ++++++++++++++++++++++++++++----
1 file changed, 37 insertions(+), 4 deletions(-)
diff --git a/docs/zh/scene-memory-fix-plan.md b/docs/zh/scene-memory-fix-plan.md
index 33ca9ba..c1edce2 100644
--- a/docs/zh/scene-memory-fix-plan.md
+++ b/docs/zh/scene-memory-fix-plan.md
@@ -163,7 +163,14 @@ RecallPolicy 召不召回记忆(只读,默认开)
(`loadEmergentScenesLocked` 只取 `origin='emergent'`),
故多写对聚类零影响。声明项作为「插件将来确实需要时」的闸门保留。
-### 步骤 3:修 R3(让图整理覆盖全库)
+### 步骤 3:修 R3(让图整理覆盖全库)✅ 已完成
+
+- [x] `GraphDB.DedupeScenes()`:归一化后同名场景合成一个
+- [x] 接到 `mergeLoop` 尾部(不塞进实体的 O(n²) 双重循环)
+- [x] 判据 8 例(先红后绿)
+- 生产库副本实测:65 → 57,合并 8 组,`auto:chan:qq_part:morning`
+ 的 refs/rel/ent 一条没丢,strength 1 → 272
+- 提交:`758ec11`
- [ ] 给 `mergeLoop` 加**独立的场景去重路径**,不塞进实体那个 O(n²) 双重循环
(理由:实体 1 万行 × bigram + LLM 裁决,实测 5000 万次配对/轮;
@@ -175,6 +182,30 @@ RecallPolicy 召不召回记忆(只读,默认开)
用户明确要求:**直接清理,重新生成**(不做保守迁移)。
+> ⚠️ **硬约束:必须先部署含 R1 修复的二进制**。反过来的话,重新涌现出来的
+> 还是带 `+`/`#` 的旧键,清一场白清。
+
+#### 部署前基线(2026-09-26 20:1x 实测)
+
+```
+PID 92115 / 启动于 Sat 19:12:43
+子进程插件 25 个
+近 24h 异常日志(Fatal/panic/DATA RACE): 0
+场景数 65
+`homed --version` 不存在(flag 未定义)⇒ 纪律清单那条用 status 接口或 ps 核对
+```
+
+#### 执行顺序
+
+- [ ] 1. `make build`(默认 `HOMED_TAGS=onnxruntime`,cgo;校验 onnx/cgo 标记)
+- [ ] 2. 备份:`sqlite3 graph.db ".backup 'graph.db.bak-'"`(**禁用 cp**,WAL 不一致)
+- [ ] 3. `install -m 0755 build/homed /usr/local/bin/homed`(**禁用 cp**,会写坏运行中进程映像)
+- [ ] 4. `systemctl restart homeagent`
+- [ ] 5. 后置验证:进程起来 / 子进程插件数恢复 / 异常日志为 0 / 真实对话跑通
+- [ ] 6. 清理:删 `origin='emergent'` 全部 + 关联 features/refs + `situation_evidence`
+- [ ] 7. 等 ≥2 次同类交互让场景重新涌现
+- [ ] 8. 复验:新键不含 `+`/`#`、有 features **且**有 refs、strength 合理
+
- [ ] `sqlite3 .backup` 备份(**禁用 cp**,WAL 模式会拷出不一致快照)
- [ ] 删 `origin='emergent'` 的全部场景 + 其 `scene_features`/`scene_refs`
- [ ] 同步清 `situation_evidence`
@@ -186,10 +217,12 @@ RecallPolicy 召不召回记忆(只读,默认开)
> 兜底不冷场:`chan:qq` 声明场景(strength=281、108 条关系)全程保留,
> 涌现重建期间它继续承担 QQ 场景召回。
-### 步骤 5:修 R5(证据桶别全表清)
+### 步骤 5:修 R5(证据桶别全表清)✅ 已完成
-- [ ] `DELETE FROM situation_evidence` 改为按本指纹的桶标签删
-- [ ] 判据:预置两个桶的证据各 1 次;建一个场景后断言另一个桶的证据还在
+- [x] `DELETE FROM situation_evidence` → `... WHERE label = ?`(桶键 = `sig.Label(2)`,
+ **不带 `auto:` 前缀**——这点修的时候差点栽)
+- [x] 判据 3 例:判据实测「6 个通道各来 3 次只长出 2 个场景」
+- 提交:`758ec11`
### 步骤 6:修 R4 的「覆盖面」部分(让 peer / 语义场景进得来)
From 2567a22be534b8cf3429908116879be575e46ed3 Mon Sep 17 00:00:00 2001
From: JianFeeeee
Date: Sat, 26 Sep 2026 21:55:12 +0800
Subject: [PATCH 07/13] =?UTF-8?q?fix(memory):=20=E5=9C=BA=E6=99=AF?=
=?UTF-8?q?=E9=94=AE=E4=B8=A4=E8=B7=AF=E5=90=88=E5=B9=B6=E5=8E=BB=E9=87=8D?=
=?UTF-8?q?=EF=BC=88=E7=8E=B0=E7=BD=91=E6=97=A5=E5=BF=97=E5=AE=9E=E6=B5=8B?=
=?UTF-8?q?=20scenes=3D[chan:qq=20chan:qq]=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
现网 21:49 的 QQ 轮次日志打出 `scenes=[chan:qq chan:qq]` —— 同一键
出现两次。查因:tooldefs.go:63 与 task.go:793 是同一段拼接写法,
scenes := a.sceneKeysFor(...) // 内部有 seen 去重
turn := a.resolveTurnScenes(...)
for _, k := range turn.Keys {
scenes = append(scenes, k) // ← 两路之间没有共同的 seen
}
而声明路与通道派生路都会产出 chan:qq(既是插件声明的、也是从
evt.Source 派生的),于是重复。
功能上无害(RecallByScene 内部会再去重),但有两个实际代价:日志里
的 scenes=[...] 误导排查——会让人以为场景集合本身有问题;以及每次
白走一遍前缀匹配。
修法:抽 mergeSceneKeys 共用函数(顺带消掉两处重复代码),两处调用点
都走它。判据 scenemerge_test.go 5 例:3 个合并场景(同名 / 归一化后
同名 / 多路重复)+ scene_policy=none 时两路皆空(防「声明路关了但
涌现路还开着」的半开状态)。
记忆 8 包 + agent/core 全绿,8 包齐全、无 FAIL/panic/race。
---
internal/agent/core/memorypass.go | 29 ++++++++++
internal/agent/core/scenemerge_test.go | 73 ++++++++++++++++++++++++++
internal/agent/core/task.go | 4 +-
internal/agent/core/tooldefs.go | 6 +--
4 files changed, 105 insertions(+), 7 deletions(-)
create mode 100644 internal/agent/core/scenemerge_test.go
diff --git a/internal/agent/core/memorypass.go b/internal/agent/core/memorypass.go
index e20179c..84a299c 100644
--- a/internal/agent/core/memorypass.go
+++ b/internal/agent/core/memorypass.go
@@ -65,6 +65,35 @@ func (a *Agent) sceneKeysFor(evt *agentIO.InputEvent, toolName string) []string
return keys
}
+// mergeSceneKeys 把「声明路」与「涌现场景」两路合并成一个**无重复**的场景集合。
+//
+// 为什么需要它:两路各自都去重过(sceneKeysFor 内部有 seen、resolveTurnScenes
+// 内部也有),但**两路之间**没有共同的 seen。而声明路与通道派生路会产出
+// 同一个键(chan:qq 既是声明的、也是从 evt.Source 派生的)——现网日志实测到
+// `scenes=[chan:qq chan:qq]`。
+//
+// 功能上 RecallByScene 内部会再去重,所以这不是 bug,但有两个实际代价:
+// 日志里的 scenes=[...] 会误导排查;每次白走一遍前缀匹配。
+func mergeSceneKeys(declared, emergent []string) []string {
+ out := make([]string, 0, len(declared)+len(emergent))
+ seen := make(map[string]bool, len(declared)+len(emergent))
+ for _, k := range declared {
+ if k == "" || seen[k] {
+ continue
+ }
+ seen[k] = true
+ out = append(out, k)
+ }
+ for _, k := range emergent {
+ if k == "" || seen[k] {
+ continue
+ }
+ seen[k] = true
+ out = append(out, k)
+ }
+ return out
+}
+
// memoryPassOut 是一次记忆操作(取进来 / 踢出去)的结果。
type memoryPassOut struct {
// Archived 是被归档进文档记忆的低相关 L0 事件数(prune 的输出)。
diff --git a/internal/agent/core/scenemerge_test.go b/internal/agent/core/scenemerge_test.go
new file mode 100644
index 0000000..6125b8f
--- /dev/null
+++ b/internal/agent/core/scenemerge_test.go
@@ -0,0 +1,73 @@
+package core
+
+// 场景键去重的判据。
+//
+// 症状:现网日志出现 `scenes=[chan:qq chan:qq]` —— 同一个键出现两次。
+// 原因:buildTaskMemoryContext / stepToolAfter 都先取 sceneKeysFor(内部
+// 有 seen 去重),再把 resolveTurnScenes 的 turn.Keys 直接 append 上去,
+// **两路之间没有共同的 seen 集合**。声明路和通道派生路都会产出 chan:qq。
+//
+// 功能上无害(RecallByScene 内部会去重),但它有两个实际代价:
+// 1. 日志里的 scenes=[...] 具有误导性——排查时会以为场景集合有问题;
+// 2. 每次多带一个重复键进召回,白走一遍前缀匹配。
+//
+// 判据参照物在生产代码之外:期望值是「场景集合内不得有重复键」这条
+// 不变量,直接对合并后的切片计数,不引用被测实现。
+
+import (
+ "testing"
+
+ agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
+)
+
+// TestSceneKeysMerged_NoDuplicates 声明路与涌现路的并集不得有重复。
+// 现状下 tooldefs.go:63 与 task.go:793 都是直接 append,缺这一步。
+func TestSceneKeysMerged_NoDuplicates(t *testing.T) {
+ cases := []struct {
+ name string
+ declared []string
+ emergent []string
+ }{
+ {
+ name: "涌现键与声明键同名(现网实测 chan:qq 两路都产出)",
+ declared: []string{"chan:qq"},
+ emergent: []string{"chan:qq"},
+ },
+ {
+ name: "涌现键已归一化后与声明键同名",
+ declared: []string{"chan:qq"},
+ emergent: []string{"chan:QQ", "chan:qq"},
+ },
+ {
+ name: "多路重复",
+ declared: []string{"chan:qq", "tool:qq_get_message"},
+ emergent: []string{"chan:qq", "tool:qq_get_message", "auto:chan:qq"},
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := mergeSceneKeys(tc.declared, tc.emergent)
+ seen := map[string]bool{}
+ for _, k := range got {
+ if seen[k] {
+ t.Errorf("场景集合含重复键 %q: %v", k, got)
+ }
+ seen[k] = true
+ }
+ })
+ }
+}
+
+// TestSceneSuppressedSkipsMerge none 声明时两路都应为空,
+// 不能出现「声明路被关、涌现路还在」这种半开状态。
+func TestSceneSuppressedSkipsMerge(t *testing.T) {
+ a := &Agent{io: agentIO.NewIOManager()}
+ evt := &agentIO.InputEvent{
+ Source: "system",
+ Payload: map[string]interface{}{"scene_policy": "none"},
+ }
+ declared := a.sceneKeysFor(evt, "")
+ if len(declared) != 0 {
+ t.Fatalf("scene_policy=none 时声明路应为空,实际 %v", declared)
+ }
+}
diff --git a/internal/agent/core/task.go b/internal/agent/core/task.go
index 70e6253..c05981a 100644
--- a/internal/agent/core/task.go
+++ b/internal/agent/core/task.go
@@ -790,9 +790,7 @@ func (a *Agent) stepToolAfter(f *TaskFrame) stepOutcome {
// 召回用两条路的并集:声明场景(注入点/通道/工具)+ 涌现场景
scenes := a.sceneKeysFor(f.Evt, tc.Name)
turn := a.resolveTurnScenes(f, tc.Name)
- for _, k := range turn.Keys {
- scenes = append(scenes, k)
- }
+ scenes = mergeSceneKeys(scenes, turn.Keys)
recallText = a.memoryPass(query, "tool:"+tc.Name, needPrune, needRecall, scenes).RecallText
}
}
diff --git a/internal/agent/core/tooldefs.go b/internal/agent/core/tooldefs.go
index c89e7c7..c560468 100644
--- a/internal/agent/core/tooldefs.go
+++ b/internal/agent/core/tooldefs.go
@@ -63,11 +63,9 @@ func (a *Agent) buildTaskMemoryContext(f *TaskFrame, input string, maxTokens int
if f.Evt != nil && f.Evt.Source != "" {
trigger = "input:" + f.Evt.Source
}
- // 场景集合 = 声明(主动)+ 涌现(被动)两条路的并集。
+ // 场景集合 = 声明(主动)+ 涌现(被动)两条路的并集(去重)。
turn := a.resolveTurnScenes(f, "")
- for _, k := range turn.Keys {
- scenes = append(scenes, k)
- }
+ scenes = mergeSceneKeys(scenes, turn.Keys)
return a.recallText(query, trigger, maxTokens, scenes)
}
From bf5d8915dd3f66c27601fd2a816467fd90463135 Mon Sep 17 00:00:00 2001
From: JianFeeeee
Date: Sat, 26 Sep 2026 23:14:17 +0800
Subject: [PATCH 08/13] =?UTF-8?q?docs(site):=20=E4=BB=8B=E7=BB=8D=E7=AB=99?=
=?UTF-8?q?=E8=A1=A5=E3=80=8C=E5=9C=BA=E9=9D=A2=E6=B6=8C=E7=8E=B0=E3=80=8D?=
=?UTF-8?q?=E6=9D=BF=E5=9D=97?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
三层记忆那张图只讲了字面相关性召回,缺了按场合召回的那一半。
新增一段说明:场面指纹(通道/对象/工具/话题/时段)如何自己长成场面、
为什么只发生一次的不算场面、以及 ScenePolicy 声明项(默认参与)。
沿用现有 card/grid-3/reveal class,视觉与既有三张卡一致;
div 与 p 配平关系与改动前完全一致(142/142、差值 40 未变)。
---
site/index.html | 33 +++++++++++++++++++++++++++++++++
1 file changed, 33 insertions(+)
diff --git a/site/index.html b/site/index.html
index 7b7be52..fc59be2 100644
--- a/site/index.html
+++ b/site/index.html
@@ -2604,6 +2604,39 @@
+
+
+
场面涌现:同一场合,记忆自己回来
+
+ 三层记忆按字面相关性召回——你得说出相近的词才想得起来。
+ 场面记忆补上另一半:按场合召回。同一个场合再次出现,
+ 当时挂在这个场合上的约定、偏好、人物关系会自动回来,与这次说了什么措辞无关。
+
+
+
+
没人声明,自己长出来
+
+ 每轮交互只采集可观察的信号:在哪个通道、跟谁、在用什么工具、聊什么词、什么时段。
+ 这组指纹反复重合时,一场「场面」就自己成形了——不需要人工标注。
+
+
+
+
只发生一次的不算场面
+
+ 同类场面出现第二次才被认定会重复。
+ 一次性的交互不建场面,否则图库里会堆满只发生过一次的事,
+ 每次路过都要召回一堆不相干的。
+
+
+
+
参与与否,由插件声明
+
+ 通道可用 ScenePolicy 声明是否参与场面识别(默认参与)。
+ 纯内部信号(内核自循环、心跳)可以显式退出,不在记忆里留下脚印。
+
+
+
+
From 9346fed0d908d2f80ac6b38b3192d2dcc931a54e Mon Sep 17 00:00:00 2001
From: JianFeeeee
Date: Sat, 26 Sep 2026 23:31:58 +0800
Subject: [PATCH 09/13] =?UTF-8?q?feat(webui):=20=E6=98=9F=E5=9B=BE?=
=?UTF-8?q?=E8=B7=9F=E9=9A=8F=20agent=20=E6=B4=BB=E5=8A=A8=20+=20=E6=90=AC?=
=?UTF-8?q?=E5=88=B0=E4=B8=BB=E9=A1=B5=20+=20=E4=BF=AE=E5=88=86=E7=B1=BB?=
=?UTF-8?q?=E9=85=8D=E8=89=B2=E4=BB=8E=E6=9C=AA=E7=94=9F=E6=95=88?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
星图此前只是静态展示:starmapAnimate() 只转星空,节点完全静止,
与 agent 的动作零关联。本轮三件事。
★ 修一个从未被发现的 bug:分类配色一直是坏的
服务端 type 是首字母大写("Concept",见 internal/memory/graph.go),
而 smTypeColors 的键全是小写 ⇒ 永远匹配不上 ⇒ 1151 个节点全渲染成
同一个灰色 0xcccccc。浏览器实测确认:改前 colors=["cccccc"],
改后 ["44ff88"](概念绿)。
一、跟随 agent 动(三路信号,全部在渲染循环里推进,不另起定时器)
1. tool_call / stage / agent_output 的 SSE 事件 → 命中节点发光冲高
+ 尺寸微扩。工具名按**词**匹配实体(knowledge_list → knowledge_*)。
2. /runtime 调度器(3s)→ 排队/中断/挂起时全图绷紧;中断或抢占计数
上升时来一记强脉冲。
3. /memory/graph/pulse(10s,新端点)→ 新记忆「生长」:从 0 弹到
正常大小并留余晖。
另:距上次活动越近,全图越亮(抽样呼吸)—— agent 一忙图就活。
二、搬到主页 + 独立页签
总览页内嵌 360px 星图;顶部导航加「星图」独立页签(全高 + 图例)。
同一套 renderer 用 appendChild 在容器间搬运 canvas(three.js 的
canvas 只能有一个父节点,同时渲染会一边黑屏)。
三、性能:保留全部 1151 节点,但全部降规格
改前每节点 = 独立 SphereGeometry(16,12) + 独立光晕球 + 一张 256x64
CanvasTexture ⇒ 2302 个独立 geometry、约 88 万三角形、1151 个