fix(memory,loop,capacity): 打通记忆链三条空壳(P0-3 / P0-4 / P0-17,审计 §4.3)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- P0-3 持久向量索引**不再空壳**:Store.Add 落库后真做 Embed+Add(失败只记日志、绝不让记忆写入失败);
  NewRetriever 拿到 WithVectorIndex 时**自动接上写入路径**(无需改组合根);索引无命中 → WARN + **回退内存余弦**;
  新增每进程一次的**回填**(索引确实为空时把已落库条目向量化,分批 64、上限 512)
- P0-4 工作记忆**按会话接线**:回合落定时写入(owner 会话 + 带会话 id 的窗口,LRU 上限 64),
  组装上下文时按会话读取 ⇒ 前导块真的出现;原单窗口 API 语义不变
- P0-17 L4 学习回环**真回写**:新增 capacity.Judge(只跑判定、不施人格覆盖/不发事件/不改状态);
  loop 在回合落定"只写 owner 会话 + 跳过子代理分支",写 ScopeUser(L4 的读域)+ 结构化溯源
- 顺带红线加固:意图记录是主人**原话**的机器可读形态 ⇒ **绝不注入群聊/陌生人块**(design/03 §5)
- 复核:三条各自的包 build/vet/test 绿;「真生效」断言含生产构造路径(vector.New + 真实 SQLite 后端 +
  n-gram embedder)、跑完一轮窗口非空、L4 回写后能唤醒一个自身无强信号的表达;
  **5 条变异反证**(其中两条专门打「受限会话零写入」与「群聊不泄露主人原话」)均已还原
- 未做(登记):Index.Search 无 ID 过滤(客户端按候选集过滤,极端情况走回退);每条记忆一次 Embed
This commit is contained in:
AskaEth committed 2026-10-04 14:20:22 +08:00
1 parent 983bc49218
commit fbad02899a
10 files changed
+1020 -81

No files matched your search

+120 -3
View File
@@ -5,6 +5,7 @@ import (
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
@@ -77,6 +78,10 @@ func (l *Loop) memoryBlock(ctx context.Context, messages []model.Message) string
// (design/03 §5「不泄露主人敏感信息」+ 主人 2026-10-03
// 裁决「用户习惯记忆与不敏感数据:可查、不可写」)。
//
// 同样按敏感项排除的是 L4 意图记录(capacity.RecordIntent 的回写格式,
// 内容是主人的逐句原话):它虽落在共享的用户习惯域以便 L4 闭环学习,
// 但群聊/陌生人只该看到「不敏感的习惯结论」,不该看到主人原话。
//
// 召回本身包在一个 memory span 里(裁决 11.4.2,design/19 §1
// 旁路节点):span 只记元数据(作用域/命中条数/注入块字节数/耗时),
// 绝不记召回了什么内容。
@@ -95,9 +100,17 @@ func (l *Loop) retrieveMemoryBlock(ctx context.Context, env SessionEnv, personaI
}
shared := make([]memory.Result, 0, len(results))
for _, r := range results {
if sharedScope(r.Item.Scope) {
shared = append(shared, r)
if !sharedScope(r.Item.Scope) {
continue
}
// L4 意图记录(capacity.RecordIntent 的回写格式)是主人原话的
// 机器可读形态,属主人敏感信息:共享域可查的是「不敏感的用户
// 习惯」,主人的逐句原话绝不注入群聊/陌生人上下文
// (design/03 §5「不泄露主人敏感信息」)。
if capacity.IsIntentRecord(r.Item.Content) {
continue
}
shared = append(shared, r)
}
if len(shared) == 0 {
l.finishMemoryRecallSpan(span, "restricted", 0, 0, nil)
@@ -162,7 +175,11 @@ func (l *Loop) assembleContext(ctx context.Context, messages []model.Message, ro
leading = append(leading, l.currentTimeBlock())
}
if l.working != nil {
if s := l.working.Summarize(); s != "" {
// 读**本会话**的窗口(回合落定时由 recordWorkingMemory 写入):
// 进程内只有一个 WorkingMemory,按会话读才不至于把别的会话
// (尤其是群聊/陌生人)的工作记忆混进主人的上下文。该会话
// 尚无窗口(如首轮之前)时摘要为空,前导块自然不出现。
if s := l.working.SummarizeSession(loopSession(ctx)); s != "" {
leading = append(leading, model.TextMessage(model.RoleSystem, "工作记忆: "+s))
}
}
@@ -237,7 +254,13 @@ func (l *Loop) extractMemory(ctx context.Context, messages []model.Message, step
// 被提取(只读隔离,design/02 §3 / design/11 §2),缺少
// extractor 或 store 时为空操作。提取失败与 store 失败会
// 带循环的来源记录日志,绝不向上传播。
//
// 它同时是 loop 侧**回合落定**的记忆钩子入口(run 路径每个已落定
// 的 turn 调用它一次):无论提取器是否配置,都在此先落下工作记忆
// 与 L4 判定回写(见 recordTurnMemory)。两者各自带门(工作记忆写
// 会话窗口、L4 只写 owner 会话),因此不依赖这里的 extractor 门。
func (l *Loop) extractMemoryAsync(ctx context.Context, messages []model.Message, step int) {
l.recordTurnMemory(ctx, messages, step)
env := sessionEnvFrom(ctx)
if env.Restricted() {
l.log.Debug("memory extract skipped: read-only context", "step", step, "env", env.ActorEnv)
@@ -318,6 +341,100 @@ func (l *Loop) persistMemories(ctx context.Context, items []memory.MemoryItem, s
l.finishMemoryWriteSpan(span, written, failed, nil)
}
// recordTurnMemory 是回合落定时 loop 拥有的记忆钩子:把该会话的当前
// 对话窗口写进工作记忆(三层记忆的层①),并把该轮的 L4 判定回写进
// 记忆(design/14 §2.2 L4 / §2.4 闭环学习)。它由 extractMemoryAsync
// 在每个已落定的 turn 上调用一次 —— 落点是 run 路径既有的 turn 结束
// 钩子,因此 sub-agent 与主会话走同一条路径。
//
// 两个钩子各自带门(见 recordWorkingMemory / recordIntent),因此
// 未配置 capacity、未配置检索器、或处于群聊/陌生人环境时它们
// 安静跳过,绝不因为「钩子跑了」而多写一行记忆。
func (l *Loop) recordTurnMemory(ctx context.Context, messages []model.Message, step int) {
l.recordWorkingMemory(ctx, messages)
l.recordIntent(ctx, messages, step)
}
// recordWorkingMemory 把该会话的对话窗口记进工作记忆(design/03 §0
// 层①「当前会话的对话窗口(滑动窗口 + 摘要)」),使 assembleContext
// 的「工作记忆: …」前导块与 Summarize 真的有内容 —— 接线之前生产
// 代码从不调用 Add,窗口恒空、摘要恒 ""(审计 P0-4)。
//
// 两道门:
// - 只写**主人会话**(MemWritable):群聊/陌生人的对话不入工作记忆
// (design/02 §3、design/11 §2 的只读隔离)。
// - 只写带会话 id 的窗口:进程内只有一个 WorkingMemory 实例,无
// 会话键的写入会污染别的会话的读点。
//
// 传入的是该回合的完整对话(含 checkpoint 还原出的历史),窗口因此
// 就是「最近 N 条」滑窗;会话恢复时同一条路径把历史回填进窗口,
// 而不是从空白重启(design §1 durable execution)。
func (l *Loop) recordWorkingMemory(ctx context.Context, messages []model.Message) {
if l.working == nil || len(messages) == 0 {
return
}
if !sessionEnvFrom(ctx).MemWritable {
return
}
sessionID := loopSession(ctx)
if sessionID == "" {
return
}
l.working.RecordSession(sessionID, messages)
}
// recordIntent 把该轮的 L4 判定写回记忆(design/14 §2.2 L4「写入记忆…
// 让意图理解越用越准」、§2.4 闭环学习)。接线之前 capacity.RecordIntent
// 在生产代码里零调用,判定层因此只读不写,「越用越准」永远不成立
// (审计 P0-17)。
//
// 红线:**只写 owner 会话**。群聊/陌生人有独立的记忆域且只读不写
// (design/02 §3、design/11 §2、design/03 §5),子代理的内部回合也
// 不是用户本人的表达习惯(经 flowBranch 识别),两者都不写。
// 判定层禁用、无角色、缺 store 时为空操作;判定为非任务时
// capacity.RecordIntent 返回 "",跳过写入(不堆噪声记忆)。
//
// 条目落进**用户习惯域**(ScopeUser,design/03 §3「用户习惯记忆全局
// 共享」):L4 学的正是主人的任务表达习惯,写入与读取因此在同一个
// 作用域里闭环(capacity L4 只采信 user/persona/project 域的召回)。
func (l *Loop) recordIntent(ctx context.Context, messages []model.Message, step int) {
if l.capacity == nil || !l.capacity.Enabled() || l.memory == nil || l.retriever == nil {
return
}
if !sessionEnvFrom(ctx).MemWritable {
return
}
if _, isBranch := flowBranch(ctx); isBranch {
return
}
personaID := l.personaFor(ctx)
if personaID == "" {
return
}
judgment := l.capacity.Judge(ctx, capacity.Input{
Messages: capacityTexts(messages),
Awakening: l.awakeningFor(ctx),
Recall: l.capacityRecall(ctx, messages),
})
content := l.capacity.RecordIntent(judgment)
if content == "" {
return
}
item := memory.MemoryItem{
PersonaID: personaID,
Scope: memory.ScopeUser,
Kind: memory.KindRelationship,
Content: content,
Confidence: judgment.Confidence,
SourceConversation: memory.FormatSourceConversation(loopSession(ctx), loopTurn(ctx)),
}
if _, err := l.memory.Add(ctx, item); err != nil {
l.log.Warn("memory: L4 intent writeback failed", "step", step, "error", err)
return
}
l.log.Debug("memory: L4 intent writeback stored", "step", step, "task_kind", judgment.TaskKind)
}
// queryFromMessages 从对话导出检索查询:最后一个用户
// turn 的文本,回退到最后一条消息的文本,再回退到 ""。
func queryFromMessages(messages []model.Message) string {