Files
AskaEth fbad02899a
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(memory,loop,capacity): 打通记忆链三条空壳(P0-3 / P0-4 / P0-17,审计 §4.3)
- 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
2026-10-04 14:20:22 +08:00

352 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package capacity
import (
"context"
"math"
"strconv"
"strings"
)
// RecallItem is one recalled memory hit handed to the decider for L4 habit
// alignment (design §2.2 L4). It is a decoupling view over memory.Result so the
// capacity package stays independent of the memory subsystem; the loop builds it
// from Retriever.Retrieve. Scope carries the memory scope value ("user" / "persona"
// / "project") so L4 can honor the isolation red line (学行为习惯, 不读陌生人敏感).
type RecallItem struct {
Content string
Scope string
Kind string
Tags []string
Score float64
}
// Judgment is the intent outcome of one round, meant to be written back into
// memory so the L4 layer learns the user's task-expression habits over time
// (design §2.4 闭环学习). It records the raw intent-layer classification (before
// any persona override) because the closed loop learns the user's intent pattern,
// not the persona's presentation. Text is the user expression that was judged — the
// habit key L4 re-matches on later rounds.
type Judgment struct {
IsTask bool
TaskKind string
State State
Confidence float64
Text string
}
// L4 tuning constants.
const (
// minHabitOverlap is the minimum longest-common-substring length (in runes) a
// recalled habit must share with the current turn before L4 treats it as a
// matching habit. 3 runes covers a short Chinese phrase (搞一下) while staying
// conservative against token noise.
minHabitOverlap = 3
// taskHabitConfidence is the wake confidence L4 assigns when it matches a
// habit-style memory (not a canonical intent record) that leans 办事. It sits
// above the default wake threshold so the habit can actually wake a missed task.
taskHabitConfidence = 0.7
// intent kind tokens stored in an intent record and understood by awakenVerdict.
taskKindComplex = "complex"
taskKindCode = "code"
taskKindQuery = "query"
taskKindOps = "ops"
// intentRecordPrefix marks a canonical intent record written back by RecordIntent.
intentRecordPrefix = "intent:"
)
// Judge 只跑意图分层(L1 → L2 → L3 → L4),返回该轮的原始判定
// (design §2.4 闭环学习的写回对象):它不施加人格覆盖、不产出事件、
// 不改动任何状态,也不写任何东西。
//
// 它是 Decide 的「只看判定」视图:回合落定时调它一次即可拿到该轮判定
// 用于回写记忆(RecordIntent),既不必重跑整次决策,也不会重复触发
// capacity_awaken 广播。判定层被禁用或没有可判文本时返回零值
// (IsTask=false),调用方据此跳过写入。
func (d *Decider) Judge(ctx context.Context, in Input) Judgment {
if !d.cfg.Enabled || len(in.Messages) == 0 {
return Judgment{}
}
state, conf, tool := d.intent(ctx, in)
return judgmentFor(in, state, conf, tool)
}
// IsIntentRecord 报告 content 是否为 RecordIntent 写回的规范意图记录
// (design §2.4)。群聊/陌生人的记忆注入据此把主人原话的机器可读形态
// 挡在只读共享块之外:那是 L4 的学习素材与「主人敏感信息」,
// 不是给群聊消费的非敏感共享记忆。
func IsIntentRecord(content string) bool {
_, ok := parseIntentRecord(content)
return ok
}
// RecordIntent formats a judgment into the canonical intent-record string written
// back to memory (design §2.4). Only task judgments are worth learning — a quiet /
// non-task judgment returns "" so callers can skip persisting noise. The format is
// machine-parseable by parseIntentRecord, which L4 re-reads on later rounds to
// close the learning loop. The user expression is written in plain text so the
// keyword retriever can still surface it against a later similar turn.
func (d *Decider) RecordIntent(j Judgment) string {
if !j.IsTask {
return ""
}
return formatIntentRecord(j)
}
// formatIntentRecord encodes j as one parseable line. The text field comes last and
// is taken verbatim (it may contain ';' or '='), so parseIntentRecord reads the
// fixed prefix and keeps the remainder as the expression.
func formatIntentRecord(j Judgment) string {
return intentRecordPrefix +
"isTask=" + strconv.FormatBool(j.IsTask) +
";taskKind=" + j.TaskKind +
";confidence=" + strconv.FormatFloat(j.Confidence, 'f', -1, 64) +
";text=" + j.Text
}
// intentRecord is the parsed form of a canonical intent record.
type intentRecord struct {
isTask bool
taskKind string
confidence float64
text string
}
// parseIntentRecord decodes a canonical intent record written by formatIntentRecord.
// ok is false when content is not a well-formed intent record (missing isTask).
func parseIntentRecord(content string) (intentRecord, bool) {
rest, ok := strings.CutPrefix(content, intentRecordPrefix)
if !ok {
return intentRecord{}, false
}
// text is last and taken verbatim, so a ';' or '=' inside it cannot split it.
head, text := rest, ""
if idx := strings.Index(rest, ";text="); idx >= 0 {
head = rest[:idx]
text = rest[idx+len(";text="):]
}
rec := intentRecord{text: text}
seenTask := false
for _, f := range strings.Split(head, ";") {
k, v, cut := strings.Cut(f, "=")
if !cut {
continue
}
switch k {
case "isTask":
rec.isTask = v == "true"
seenTask = true
case "taskKind":
rec.taskKind = v
case "confidence":
if x, err := strconv.ParseFloat(v, 64); err == nil {
rec.confidence = x
}
}
}
if !seenTask {
return intentRecord{}, false
}
return rec, true
}
// l4 is the L4 habit-alignment layer (design §2.2, §2.4): it uses recalled user
// habits to correct or fill in the intent that L1/L2 could not settle. It returns
// ok=false when no habit applies, so the caller's L1/L2 verdict (or the
// conservative fallback) stands — L4 is silent unless a learned habit matches
// (向后兼容, 数据缺失时回退).
func (d *Decider) l4(current string, recall []RecallItem) (State, float64, string, bool) {
text := strings.TrimSpace(current)
if text == "" || len(recall) == 0 {
return "", 0, "", false
}
bestState, bestConf, bestTool := State(""), 0.0, ""
matched := false
for _, it := range recall {
if !allowedHabitScope(it.Scope) {
continue
}
if rec, ok := parseIntentRecord(it.Content); ok {
// A canonical record written back by RecordIntent: it knows the exact
// task kind and confidence. Only a task record with a matching expression
// testifies to task; the stored text is plaintext so overlap is matched
// against it (tolerating paraphrase).
if !rec.isTask || rec.text == "" || overlapLen(text, rec.text) < minHabitOverlap {
continue
}
state, tool := awakenVerdict(rec.taskKind)
conf := clampConf(rec.confidence)
if conf == 0 {
conf = taskHabitConfidence
}
if conf > bestConf {
bestState, bestConf, bestTool, matched = state, conf, tool, true
}
continue
}
// Habit-style memory (not written by us): treat it as a task habit only when
// it leans 办事 and shares a phrase with the current turn, so an unrelated
// preference (coffee habit) never triggers a wake.
if !leansTask(it.Content) || overlapLen(text, it.Content) < minHabitOverlap {
continue
}
conf := taskHabitConfidence
if it.Score > 0 {
conf = clampConf(taskHabitConfidence + it.Score*0.15)
}
state, tool := StateAwake, inferTool(text)
if isComplex(text) {
state = StateFull
}
if conf > bestConf {
bestState, bestConf, bestTool, matched = state, conf, tool, true
}
}
if !matched {
return "", 0, "", false
}
return bestState, bestConf, bestTool, true
}
// mergeL4 combines an L1/L2 verdict with the L4 habit verdict (design §2.2 合并规则).
// It is conservative: a strong learned habit may count a task the heuristics missed
// (quiet → awake, a 举手示意 but never a jump straight to full) or confirm/boost one
// they caught, up to full when the habit says complex; it never drags a clear task
// down to quiet (学行为习惯,不误打断).
func (d *Decider) mergeL4(s State, c float64, t string, l4s State, l4c float64, l4t string, l4ok bool) (State, float64, string) {
if !l4ok || (l4s != StateAwake && l4s != StateFull) {
return s, c, t
}
if s == StateQuiet || s == "" {
// 保守红线:静息→唤醒是"举手示意",即便习惯标记复杂也不直接跳到全能。
return StateAwake, l4c, l4t
}
st := s
if l4s == StateFull {
st = StateFull
}
return st, boostConf(c, l4c), pickTool(t, l4t)
}
// awakenVerdict maps an intent-record task kind back to a state + lit tool.
func awakenVerdict(kind string) (State, string) {
switch kind {
case taskKindComplex:
return StateFull, ""
case taskKindCode:
return StateAwake, "file"
case taskKindQuery:
return StateAwake, "web-search"
case taskKindOps:
return StateAwake, "terminal"
default:
return StateAwake, ""
}
}
// taskKindFor maps a (state, tool) to the intent kind recorded for writeback.
func taskKindFor(state State, tool string) string {
if state == StateFull {
return taskKindComplex
}
switch tool {
case "file":
return taskKindCode
case "web-search", "web-fetch", "http":
return taskKindQuery
case "terminal":
return taskKindOps
default:
return ""
}
}
// allowedHabitScope reports the scopes L4 may read for user habits. ScopeUser is the
// owner's shared habit (primary); ScopePersona / ScopeProject are readable per design
// §2.2; an empty scope (recall without scope metadata) is tolerated. Any other scope
// is refused, so a stranger / owner-private memory can never reach L4 here (design
// §2.2 红线: 学行为习惯, 不读陌生人敏感).
func allowedHabitScope(scope string) bool {
switch scope {
case "", "user", "persona", "project":
return true
default:
return false
}
}
// leansTask reports whether a habit memory is about the user's task-expression
// pattern rather than an unrelated preference, so L4 does not wake on a coffee habit.
func leansTask(content string) bool {
return containsAny(content, []string{
"办事", "任务", "布置", "要办", "让我", "要我", "去做", "来办", "干活", "做工",
"习惯用", "习惯说", "让我做", "叫我",
// ASCII
"task", "asks me to", "want me to", "has me", "habit",
})
}
// overlapLen returns the length (in runes) of the longest common substring of a and
// b — the lexical-overlap measure L4 uses to detect "the user expression this round
// echoes a phrase the memory recorded", tolerating paraphrase (搞一下那个部署 vs
// 搞一下这个部署 share 搞一下).
func overlapLen(a, b string) int {
ar, br := []rune(a), []rune(b)
if len(ar) == 0 || len(br) == 0 {
return 0
}
prev := make([]int, len(br)+1)
best := 0
for i := 1; i <= len(ar); i++ {
cur := make([]int, len(br)+1)
for j := 1; j <= len(br); j++ {
if ar[i-1] == br[j-1] {
cur[j] = prev[j-1] + 1
if cur[j] > best {
best = cur[j]
}
}
}
prev = cur
}
return best
}
// boostConf raises a confirmed task confidence above the stronger of the two sources,
// reflecting corroboration (设计: 越用越准), capped at 1.
func boostConf(a, b float64) float64 { return clampConf(math.Max(a, b) + 0.05) }
// clampConf clamps c to [0,1].
func clampConf(c float64) float64 {
if c < 0 {
return 0
}
if c > 1 {
return 1
}
return c
}
// pickTool prefers the learned habit's lit tool when non-empty, else the caller's.
func pickTool(t, l4t string) string {
if l4t != "" {
return l4t
}
return t
}
// judgmentFor builds the write-back judgment from the raw intent-layer result.
func judgmentFor(in Input, state State, conf float64, tool string) Judgment {
j := Judgment{State: state, Confidence: conf}
if n := len(in.Messages); n > 0 {
j.Text = in.Messages[n-1]
}
j.IsTask = state == StateAwake || state == StateFull
if j.IsTask {
j.TaskKind = taskKindFor(state, tool)
}
return j
}