项目文件夹

文件
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 行
18 KiB
Go

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
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[:])
}