- 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
352 lines
12 KiB
Go
352 lines
12 KiB
Go
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
|
||
}
|