package main import ( "net" "net/url" "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // memoryOwnerRoleID is the role id the composition root authors extracted // memories with; the owner is the only actor whose private memory is written // (group/stranger conversations never extract at all, design/11 §2). const memoryOwnerRoleID = "owner" // buildMemoryExtractor selects the memory extractor the loop runs after each // settled turn (design/03 §1 LLM 异步自动提取): // // - the LLM extractor when the provider stack has a usable active group, so // the model denoises and deduplicates what is worth remembering; // - the deterministic ManualExtractor otherwise, so a keyless deployment still // records the owner's explicit "记住 X" directives instead of failing a model // call on every turn. // // The loop detaches the LLM extractor to a background goroutine (ExtractAsync); // see internal/runtime/loop/memory.go. func (a *app) buildMemoryExtractor(personaID string) memory.Extractor { if llmGroupUsable(a.llm) { a.log.Info("memory extractor: llm", "model", a.llm.Model(), "provider", a.llm.Provider()) return memory.NewLLMExtractor(a.llm, personaID, memoryOwnerRoleID) } a.log.Warn("memory extractor: manual (no usable llm model group)") return memory.NewManualExtractor(personaID, memoryOwnerRoleID) } // llmGroupUsable reports whether the active model group of the runtime stack can // actually be called: a model and a base URL are mandatory, and either an API key // is configured or the endpoint is a local/private address, where a keyless call // is the normal case (a self-hosted OpenAI-compatible server). // // It is deliberately a cheap structural check, never a probe request: boot must // not spend a model call. A group that looks usable but fails at call time only // logs an extraction error (extraction failures are never fatal). func llmGroupUsable(llm *model.RuntimeLLM) bool { if llm == nil { return false } group := llm.Group() if strings.TrimSpace(group.Model) == "" || strings.TrimSpace(group.BaseURL) == "" { return false } if strings.TrimSpace(group.APIKey) != "" { return true } return keylessEndpoint(group.BaseURL) } // keylessEndpoint reports whether baseURL points at a loopback or private-network // host, where a missing API key is expected rather than a misconfiguration. func keylessEndpoint(baseURL string) bool { parsed, err := url.Parse(strings.TrimSpace(baseURL)) if err != nil { return false } host := parsed.Hostname() if host == "" { return false } if strings.EqualFold(host, "localhost") { return true } ip := net.ParseIP(host) if ip == nil { return false } return ip.IsLoopback() || ip.IsPrivate() }