package loop import ( "context" "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // injectMemory retrieves the persona's relevant long-term memories and prepends // them as a leading system message, so the model can ground its answer in recalled // context (design §6 / §2). It is re-run every round via assembleContext so the // retrieval reflects the latest conversation rather than a first-turn snapshot. It // returns the input messages unchanged when no retriever/persona is configured, // retrieval fails, or nothing relevant is found. // // The session env on ctx gates the retrieval: the owner-full env keeps the full // recall (unchanged behavior); the restricted env (group chat / stranger, // design/02 §3, design/11 §2) is read-only isolation — only non-owner-private // project memory is injected so no owner-sensitive memory leaks to a group or // stranger. func (l *Loop) injectMemory(ctx context.Context, messages []model.Message) []model.Message { if l.retriever == nil || l.personaID == "" { return messages } env := sessionEnvFrom(ctx) block, err := l.retrieveMemoryBlock(ctx, env, queryFromMessages(messages)) if err != nil { l.log.Warn("memory inject", "error", err, "env", env.ActorEnv) return messages } if block == "" { return messages } out := make([]model.Message, 0, len(messages)+1) out = append(out, model.TextMessage(model.RoleSystem, block)) out = append(out, messages...) return out } // retrieveMemoryBlock returns the memory injection block for env. The owner-full // env keeps the pre-existing Inject behavior. The restricted env (group chat / // stranger) injects only project-scope memory (人人可读, not owner-private) and // marks the block as group-isolated so callers can tell an isolated read from the // owner's full read. func (l *Loop) retrieveMemoryBlock(ctx context.Context, env SessionEnv, query string) (string, error) { if !env.Restricted() { return l.retriever.Inject(ctx, l.personaID, query, 0) } results, err := l.retriever.Retrieve(ctx, l.personaID, query, 0) if err != nil { return "", err } shared := make([]memory.Result, 0, len(results)) for _, r := range results { if r.Item.Scope == memory.ScopeProject { shared = append(shared, r) } } if len(shared) == 0 { return "", nil } return formatMemoryBlock(shared, "群聊隔离"), nil } // formatMemoryBlock builds the leading system block from retrieved results. The // non-empty marker prefixes the block so an isolated group read is distinguishable // from the owner's full read. func formatMemoryBlock(results []memory.Result, marker string) string { var b strings.Builder if marker != "" { b.WriteString(marker) b.WriteString(":") } b.WriteString("相关记忆(按相关度 × 衰减排序):") for _, res := range results { b.WriteByte('\n') b.WriteString("- [") b.WriteString(string(res.Item.Kind)) b.WriteString("] ") b.WriteString(res.Item.Content) } return b.String() } // assembleContext builds the model-visible message list for one round (design // §1.3 每轮 assemble, the loop's equivalent of OpenClaw's transformContext): it // re-injects the persona's relevant long-term memory — retrieval is re-run every // round from the current conversation, not cached from the first turn, so it // supports cross-round refresh — folds in the per-session working-memory summary // when present, and applies the optional TransformContext hook last so a caller // can project/filter what the model actually sees. It returns the input unchanged // when nothing is configured. func (l *Loop) assembleContext(ctx context.Context, messages []model.Message, round int) []model.Message { var leading []model.Message if l.working != nil { if s := l.working.Summarize(); s != "" { leading = append(leading, model.TextMessage(model.RoleSystem, "工作记忆: "+s)) } } leading = append(leading, l.injectMemory(ctx, messages)...) out := leading if l.transformContext != nil { out = l.transformContext(ctx, leading, round) } return out } // compactIfNeeded compacts the conversation when its estimated context exceeds // the configured MaxContextTokens. It is a no-op when compaction is disabled // (non-positive MaxContextTokens), the compactor is nil, the estimate is within // budget, or the compactor returns an error — a compaction failure is logged and // the conversation is kept as-is rather than failing the run. func (l *Loop) compactIfNeeded(ctx context.Context, messages []model.Message) []model.Message { if l.maxContextTokens <= 0 || l.compactor == nil { return messages } compacted, err := l.compactor.Compact(ctx, messages, l.maxContextTokens) if err != nil { l.log.Warn("context compact", "error", err, "tokens", l.maxContextTokens) return messages } if compacted == nil { return messages } return compacted } // extractMemory runs the extraction hook (design §6) over the current // conversation and persists any derived items. Errors are logged, never fatal, so // a bad extraction cannot abort the run. // // In a restricted context (group chat / stranger, !MemWritable) extraction is // skipped entirely: the conversation may read shared memory read-only and may // never persist anything that could leak owner-sensitive information (design/02 // §3, design/11 §2). func (l *Loop) extractMemory(ctx context.Context, messages []model.Message, step int) { env := sessionEnvFrom(ctx) if env.Restricted() { l.log.Debug("memory extract skipped: read-only context", "step", step, "env", env.ActorEnv) return } if l.extractor == nil || l.memory == nil { return } items, err := l.extractor.Extract(ctx, messages) if err != nil { l.log.Warn("memory extract", "step", step, "error", err) return } for _, it := range items { if it.PersonaID == "" && l.personaID != "" { it.PersonaID = l.personaID } if _, err := l.memory.Add(ctx, it); err != nil { l.log.Warn("memory store", "step", step, "error", err) } } } // queryFromMessages derives a retrieval query from the conversation: the last // user turn's text, falling back to the last message's text, then "". func queryFromMessages(messages []model.Message) string { for i := len(messages) - 1; i >= 0; i-- { if messages[i].Role == model.RoleUser { return messageText(messages[i]) } } if n := len(messages); n > 0 { return messageText(messages[n-1]) } return "" } // messageText returns the concatenated plain-text payload of a message's text // parts, which is the shared view used to build a retrieval query. func messageText(m model.Message) string { var b strings.Builder for _, part := range m.Content { if part.Type != model.ContentTypeText { continue } if b.Len() > 0 { b.WriteByte(' ') } b.WriteString(part.Text) } return b.String() }