Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

646 lines
18 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 mcp
import (
"crypto/rand"
"encoding/hex"
"fmt"
"slices"
"sort"
"strings"
"sync"
"time"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/persistence"
)
// maxMemoriesCap is the soft ceiling on stored memories per repo
// scope. Trimming honours pinned + high-importance memories. Matches
// the prior gob.gz cap.
const maxMemoriesCap = 10000
// memoryManager owns the cross-session development-memory store.
// It mirrors notesManager structurally (same persistence + filter
// shape) but its entries have no SessionID — every memory is
// workspace-scoped and durable across sessions, so the store
// compounds the longer a team uses Gortex.
//
// Memories live alongside the graph as a separate, persistent
// side-store backed by the SQLite sidecar DB. The in-memory slice +
// scorers are unchanged from the gob.gz era; only the persistence
// layer changed. A nil sidecar yields an in-memory-only manager
// (test fixtures, single-shot CLI calls).
type memoryManager struct {
mu sync.Mutex
store persistence.MemoryStore
sidecar *persistence.SidecarStore
repoKey string
}
// newMemoryManager constructs a manager, lazily loading any existing
// memories from the sidecar. Empty cacheDir/repoPath yields a no-disk
// manager. The sidecar lives at <cacheDir>/sidecar.sqlite; any legacy
// memories.gob.gz under the per-repo cache subdir is imported once,
// then renamed to *.bak.
func newMemoryManager(cacheDir, repoPath string) *memoryManager {
if cacheDir == "" || repoPath == "" {
return &memoryManager{}
}
sidecar, err := persistence.OpenSidecar(persistence.DefaultSidecarPath(cacheDir))
if err != nil || sidecar == nil {
return &memoryManager{}
}
return newMemoryManagerFromSidecar(sidecar, persistence.RepoCacheKey(repoPath), persistence.MemoriesDir(cacheDir, repoPath))
}
// newMemoryManagerFromSidecar builds a memory manager bound to an
// already-open sidecar + repo key, importing legacyDir/memories.gob.gz
// once. Used by the daemon path where the sidecar is opened once and
// shared across managers.
func newMemoryManagerFromSidecar(sidecar *persistence.SidecarStore, repoKey, legacyDir string) *memoryManager {
mm := &memoryManager{sidecar: sidecar, repoKey: repoKey}
if sidecar != nil {
_ = sidecar.MigrateLegacyMemories(repoKey, legacyDir)
if rows, err := sidecar.LoadMemoriesRows(repoKey); err == nil {
mm.store.Entries = rows
}
}
return mm
}
// allEntries returns a snapshot copy of every stored memory entry, for callers
// (e.g. the rationale projection) that need the full set under the lock.
func (m *memoryManager) allEntries() []persistence.MemoryEntry {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]persistence.MemoryEntry, len(m.store.Entries))
copy(out, m.store.Entries)
return out
}
// MemoryQueryFilter constrains a Query call. Zero-value fields
// disable the corresponding filter; tag matching is exact
// (case-insensitive).
type MemoryQueryFilter struct {
SymbolID string
FilePath string
Tag string
Kind string
Source string
AuthorAgent string
TextSearch string // case-insensitive substring against Body / Title
Since time.Time
WorkspaceID string
ProjectID string
Pinned *bool
MinImportance int // 0 = no filter; 1..5 = lower bound
IncludeSuperseded bool // false (default) hides entries with SupersededBy != ""
Limit int
}
// MemoryPatch is the update payload — every pointer field is
// optional ("leave alone" when nil), every slice field replaces
// the prior value when non-nil. AddLinks is additive.
type MemoryPatch struct {
Body *string
Title *string
Kind *string
Source *string
Confidence *float32
Importance *int
Pinned *bool
SupersededBy *string
Tags []string
SymbolIDs []string
FilePaths []string
AddLinks []string
}
// Save persists a new entry, returning the generated ID. Defaults
// for missing fields: Confidence=1.0, Importance=3, Kind="reference",
// Source="manual".
func (mm *memoryManager) Save(entry persistence.MemoryEntry) (string, error) {
mm.mu.Lock()
defer mm.mu.Unlock()
if entry.ID == "" {
entry.ID = newMemoryID()
}
now := time.Now().UTC()
if entry.Timestamp.IsZero() {
entry.Timestamp = now
}
entry.UpdatedAt = now
entry.AutoLinks = dedupeStrings(entry.AutoLinks)
entry.SymbolIDs = dedupeStrings(entry.SymbolIDs)
entry.FilePaths = dedupeStrings(entry.FilePaths)
entry.Tags = dedupeStrings(normaliseTags(entry.Tags))
if entry.Confidence == 0 {
entry.Confidence = 1.0
}
if entry.Importance == 0 {
entry.Importance = 3
}
if entry.Kind == "" {
entry.Kind = "reference"
}
if entry.Source == "" {
entry.Source = "manual"
}
mm.store.Entries = append(mm.store.Entries, entry)
if err := mm.persistLocked(entry); err != nil {
return entry.ID, err
}
mm.trimLocked()
return entry.ID, nil
}
// Update mutates an existing memory by ID. Pass nil for a field
// you don't want to change.
func (mm *memoryManager) Update(id string, patch MemoryPatch) (persistence.MemoryEntry, error) {
mm.mu.Lock()
defer mm.mu.Unlock()
idx := mm.findLocked(id)
if idx < 0 {
return persistence.MemoryEntry{}, fmt.Errorf("memory %q not found", id)
}
e := mm.store.Entries[idx]
if patch.Body != nil {
e.Body = *patch.Body
}
if patch.Title != nil {
e.Title = *patch.Title
}
if patch.Kind != nil {
e.Kind = *patch.Kind
}
if patch.Source != nil {
e.Source = *patch.Source
}
if patch.Confidence != nil {
e.Confidence = *patch.Confidence
}
if patch.Importance != nil {
e.Importance = *patch.Importance
}
if patch.Pinned != nil {
e.Pinned = *patch.Pinned
}
if patch.SupersededBy != nil {
e.SupersededBy = *patch.SupersededBy
}
if patch.Tags != nil {
e.Tags = dedupeStrings(normaliseTags(patch.Tags))
}
if patch.SymbolIDs != nil {
e.SymbolIDs = dedupeStrings(patch.SymbolIDs)
}
if patch.FilePaths != nil {
e.FilePaths = dedupeStrings(patch.FilePaths)
}
if len(patch.AddLinks) > 0 {
e.AutoLinks = dedupeStrings(append(append([]string{}, e.AutoLinks...), patch.AddLinks...))
}
e.UpdatedAt = time.Now().UTC()
mm.store.Entries[idx] = e
if err := mm.persistLocked(e); err != nil {
return e, err
}
return e, nil
}
// Delete removes a memory by ID. Idempotent.
func (mm *memoryManager) Delete(id string) error {
mm.mu.Lock()
defer mm.mu.Unlock()
idx := mm.findLocked(id)
if idx < 0 {
return nil
}
mm.store.Entries = append(mm.store.Entries[:idx], mm.store.Entries[idx+1:]...)
if mm.sidecar == nil {
return nil
}
return mm.sidecar.DeleteMemory(mm.repoKey, id)
}
// Get returns a single memory by ID, or (zero, false) when not found.
func (mm *memoryManager) Get(id string) (persistence.MemoryEntry, bool) {
mm.mu.Lock()
defer mm.mu.Unlock()
idx := mm.findLocked(id)
if idx < 0 {
return persistence.MemoryEntry{}, false
}
return mm.store.Entries[idx], true
}
// MarkAccessed increments AccessCount and stamps LastAccessed on
// the given IDs. Best-effort: a flush failure is non-fatal because
// access stats are advisory.
func (mm *memoryManager) MarkAccessed(ids []string) {
if len(ids) == 0 {
return
}
mm.mu.Lock()
defer mm.mu.Unlock()
now := time.Now().UTC()
for _, id := range ids {
idx := mm.findLocked(id)
if idx < 0 {
continue
}
mm.store.Entries[idx].AccessCount++
mm.store.Entries[idx].LastAccessed = now
_ = mm.persistLocked(mm.store.Entries[idx])
}
}
// Query returns memories matching every set filter. Results sort
// pinned-first, then by importance DESC, then by UpdatedAt DESC.
// Limit caps the slice after filtering & sorting.
func (mm *memoryManager) Query(f MemoryQueryFilter) []persistence.MemoryEntry {
mm.mu.Lock()
defer mm.mu.Unlock()
textNeedle := strings.ToLower(f.TextSearch)
tagNeedle := strings.ToLower(f.Tag)
var out []persistence.MemoryEntry
for _, e := range mm.store.Entries {
if !f.IncludeSuperseded && e.SupersededBy != "" {
continue
}
if f.WorkspaceID != "" && e.WorkspaceID != f.WorkspaceID {
continue
}
if f.ProjectID != "" && e.ProjectID != f.ProjectID {
continue
}
if f.Kind != "" && !strings.EqualFold(e.Kind, f.Kind) {
continue
}
if f.Source != "" && !strings.EqualFold(e.Source, f.Source) {
continue
}
if f.AuthorAgent != "" && !strings.EqualFold(e.AuthorAgent, f.AuthorAgent) {
continue
}
if f.SymbolID != "" && !memoryReferencesSymbol(e, f.SymbolID) {
continue
}
if f.FilePath != "" && !memoryReferencesFile(e, f.FilePath) {
continue
}
if tagNeedle != "" && !hasTag(e.Tags, tagNeedle) {
continue
}
if textNeedle != "" &&
!strings.Contains(strings.ToLower(e.Body), textNeedle) &&
!strings.Contains(strings.ToLower(e.Title), textNeedle) {
continue
}
if !f.Since.IsZero() && e.UpdatedAt.Before(f.Since) {
continue
}
if f.Pinned != nil && e.Pinned != *f.Pinned {
continue
}
if f.MinImportance > 0 && e.Importance < f.MinImportance {
continue
}
out = append(out, e)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Pinned != out[j].Pinned {
return out[i].Pinned
}
if out[i].Importance != out[j].Importance {
return out[i].Importance > out[j].Importance
}
return out[i].UpdatedAt.After(out[j].UpdatedAt)
})
if f.Limit > 0 && len(out) > f.Limit {
out = out[:f.Limit]
}
return out
}
// HasData reports whether the store holds at least one memory.
func (mm *memoryManager) HasData() bool {
mm.mu.Lock()
defer mm.mu.Unlock()
return len(mm.store.Entries) > 0
}
// Count returns the total number of stored memories.
func (mm *memoryManager) Count() int {
mm.mu.Lock()
defer mm.mu.Unlock()
return len(mm.store.Entries)
}
func (mm *memoryManager) findLocked(id string) int {
for i := range mm.store.Entries {
if mm.store.Entries[i].ID == id {
return i
}
}
return -1
}
// persistLocked writes a single memory row to the sidecar. No-op for
// an in-memory-only manager. Callers hold mm.mu.
func (mm *memoryManager) persistLocked(e persistence.MemoryEntry) error {
if mm.sidecar == nil {
return nil
}
return mm.sidecar.UpsertMemory(mm.repoKey, e)
}
// trimLocked enforces the soft cap (maxMemoriesCap) via the two-pass
// bounded DELETE on the sidecar, then reconciles the in-memory slice.
// No-op when under cap or in-memory-only. Callers hold mm.mu.
func (mm *memoryManager) trimLocked() {
if mm.sidecar == nil || len(mm.store.Entries) <= maxMemoriesCap {
return
}
if err := mm.sidecar.TrimMemories(mm.repoKey, maxMemoriesCap); err != nil {
return
}
if rows, err := mm.sidecar.LoadMemoriesRows(mm.repoKey); err == nil {
mm.store.Entries = rows
}
}
// ---------------------------------------------------------------------------
// surface_memories — proactive retrieval given a working set
// ---------------------------------------------------------------------------
// SurfaceOptions tunes Surface. Defaults: Limit=10, ExcerptCap=320,
// MinScore=0, MarkAccessed=true, IncludeSuperseded=false.
type SurfaceOptions struct {
Task string
SymbolIDs []string
FilePaths []string
WorkspaceID string
ProjectID string
Limit int
ExcerptCap int
MinScore float32
IncludeSuperseded bool
MarkAccessed bool
}
// surfaceResult is the structured digest returned by Surface. The
// shape is stable across JSON / TOON / GCX wire formats.
type surfaceResult struct {
Task string `json:"task,omitempty"`
Total int `json:"total"`
Memories []surfaceHit `json:"memories,omitempty"`
Anchors []string `json:"anchors,omitempty"`
Truncated bool `json:"truncated,omitempty"`
}
type surfaceHit struct {
ID string `json:"id"`
Title string `json:"title,omitempty"`
Body string `json:"body"`
Kind string `json:"kind,omitempty"`
Source string `json:"source,omitempty"`
Tags []string `json:"tags,omitempty"`
SymbolIDs []string `json:"symbol_ids,omitempty"`
FilePaths []string `json:"file_paths,omitempty"`
Importance int `json:"importance,omitempty"`
Confidence float32 `json:"confidence,omitempty"`
Pinned bool `json:"pinned,omitempty"`
Score float32 `json:"score"`
UpdatedAt time.Time `json:"updated_at"`
MatchReasons []string `json:"match_reasons,omitempty"`
}
// Surface returns memories ranked by relevance to a set of anchor
// symbols / files plus an optional task description.
//
// Scoring (deterministic, no LLM):
//
// +3.0 per anchor symbol overlap with SymbolIDs
// +1.5 per anchor symbol overlap with AutoLinks
// +1.5 per anchor file overlap with FilePaths
// +1.0 per task-keyword hit in body or title
// +0.5 × importance
// +0.4 if pinned
// +0.3 if updated within the last 30 days
// +0.2 if AccessCount > 0 (already proven useful)
// *= confidence (when 0 < confidence < 1)
//
// Containing files of anchor symbols are added to the file set
// automatically — a memory anchored to the file usually applies
// when you're editing any symbol inside it.
//
// When at least one anchor / keyword is provided, a memory must
// match at least one reason (or be pinned) to be returned. When
// no anchors are provided, every memory is returned, ranked by
// importance + recency.
func (mm *memoryManager) Surface(opts SurfaceOptions, resolveNode func(string) *graph.Node) surfaceResult {
if opts.Limit <= 0 {
opts.Limit = 10
}
res := surfaceResult{Task: opts.Task}
symbolSet := make(map[string]struct{}, len(opts.SymbolIDs))
for _, s := range opts.SymbolIDs {
if s != "" {
symbolSet[s] = struct{}{}
}
}
fileSet := make(map[string]struct{}, len(opts.FilePaths))
for _, f := range opts.FilePaths {
if f != "" {
fileSet[f] = struct{}{}
}
}
// Promote anchor symbols → their containing files (free signal).
if resolveNode != nil {
for sym := range symbolSet {
if node := resolveNode(sym); node != nil && node.FilePath != "" {
fileSet[node.FilePath] = struct{}{}
}
}
}
for s := range symbolSet {
res.Anchors = append(res.Anchors, s)
}
sort.Strings(res.Anchors)
keywords := tokeniseIdentifiers(opts.Task, 3)
keywordSet := make(map[string]struct{}, len(keywords))
for _, k := range keywords {
keywordSet[strings.ToLower(k)] = struct{}{}
}
hasAnchor := len(symbolSet)+len(fileSet)+len(keywordSet) > 0
mm.mu.Lock()
candidates := make([]persistence.MemoryEntry, 0, len(mm.store.Entries))
for _, e := range mm.store.Entries {
if e.SupersededBy != "" && !opts.IncludeSuperseded {
continue
}
if opts.WorkspaceID != "" && e.WorkspaceID != opts.WorkspaceID {
continue
}
if opts.ProjectID != "" && e.ProjectID != opts.ProjectID {
continue
}
candidates = append(candidates, e)
}
mm.mu.Unlock()
now := time.Now().UTC()
scored := make([]surfaceHit, 0, len(candidates))
for _, e := range candidates {
var score float32
var reasons []string
for _, s := range e.SymbolIDs {
if _, ok := symbolSet[s]; ok {
score += 3.0
reasons = append(reasons, "symbol:"+s)
}
}
for _, s := range e.AutoLinks {
if _, ok := symbolSet[s]; ok {
score += 1.5
reasons = append(reasons, "link:"+s)
}
}
for _, f := range e.FilePaths {
if _, ok := fileSet[f]; ok {
score += 1.5
reasons = append(reasons, "file:"+f)
}
}
if len(keywordSet) > 0 {
bodyLower := strings.ToLower(e.Body)
titleLower := strings.ToLower(e.Title)
for k := range keywordSet {
if strings.Contains(bodyLower, k) || strings.Contains(titleLower, k) {
score += 1.0
reasons = append(reasons, "kw:"+k)
}
}
}
if e.Importance > 0 {
score += float32(e.Importance) * 0.5
}
if e.Pinned {
score += 0.4
reasons = append(reasons, "pinned")
}
if !e.UpdatedAt.IsZero() && now.Sub(e.UpdatedAt) < 30*24*time.Hour {
score += 0.3
}
if e.AccessCount > 0 {
score += 0.2
}
if e.Confidence > 0 && e.Confidence < 1 {
score *= e.Confidence
}
if score < opts.MinScore {
continue
}
// With anchors/keywords: require at least one match reason
// (pinned counts). Without anchors: return everything.
if hasAnchor && len(reasons) == 0 {
continue
}
body := e.Body
if opts.ExcerptCap > 0 && len(body) > opts.ExcerptCap {
body = body[:opts.ExcerptCap] + "…"
}
scored = append(scored, surfaceHit{
ID: e.ID,
Title: e.Title,
Body: body,
Kind: e.Kind,
Source: e.Source,
Tags: append([]string{}, e.Tags...),
SymbolIDs: append([]string{}, e.SymbolIDs...),
FilePaths: append([]string{}, e.FilePaths...),
Importance: e.Importance,
Confidence: e.Confidence,
Pinned: e.Pinned,
Score: score,
UpdatedAt: e.UpdatedAt,
MatchReasons: reasons,
})
}
sort.Slice(scored, func(i, j int) bool {
if scored[i].Score != scored[j].Score {
return scored[i].Score > scored[j].Score
}
if scored[i].Pinned != scored[j].Pinned {
return scored[i].Pinned
}
return scored[i].UpdatedAt.After(scored[j].UpdatedAt)
})
res.Total = len(scored)
if len(scored) > opts.Limit {
scored = scored[:opts.Limit]
res.Truncated = true
}
res.Memories = scored
if opts.MarkAccessed && len(res.Memories) > 0 {
ids := make([]string, 0, len(res.Memories))
for _, h := range res.Memories {
ids = append(ids, h.ID)
}
mm.MarkAccessed(ids)
}
return res
}
// memoryReferencesSymbol reports whether the memory is attached to
// the given symbol either directly (SymbolIDs) or via auto-link.
func memoryReferencesSymbol(e persistence.MemoryEntry, sym string) bool {
if slices.Contains(e.SymbolIDs, sym) {
return true
}
return slices.Contains(e.AutoLinks, sym)
}
// memoryReferencesFile reports whether the memory is attached to
// the given file via FilePaths.
func memoryReferencesFile(e persistence.MemoryEntry, path string) bool {
return slices.Contains(e.FilePaths, path)
}
// newMemoryID returns an 8-byte hex token. Crypto-strength because
// the IDs travel through MCP responses and can be referenced by
// other tool calls — predictability would let one session guess
// another's memory IDs on a shared cache.
func newMemoryID() string {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
return fmt.Sprintf("mem%016x", time.Now().UnixNano())
}
return "mem" + hex.EncodeToString(b[:])
}