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

682 lines
28 KiB
Go

package mcp
import (
"context"
"fmt"
"regexp"
"strings"
"time"
"github.com/mark3labs/mcp-go/mcp"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/persistence"
)
// registerMemoriesTools wires the cross-session development-memory
// triplet:
//
// - store_memory — create or update a memory (auto-linked to
// symbols mentioned in the body)
// - query_memories — list / search memories by symbol / file /
// tag / kind / source / author / text
// - surface_memories — proactively rank memories for a given
// working set (anchor symbols / files + task
// description); the memory analogue of
// smart_context
//
// Memories are workspace-wide and have no session boundary, so
// every result is filtered through the session's workspace scope
// (mirroring notes), but a memory created in session A is visible
// to session B as soon as it lands on disk.
func (s *Server) registerMemoriesTools() {
s.addTool(
mcp.NewTool("store_memory",
mcp.WithDescription("Persist a cross-session development memory: an invariant, gotcha, decision, convention, or reference fact that future agents in this workspace should know. Memories are anchored to symbols and/or files, survive across sessions, and are surfaced automatically when their anchors enter an agent's working set. Pass `id` to update an existing memory."),
mcp.WithString("body", mcp.Description("Free-form memory text. Symbol IDs (file/path.go::Name) and bare identifier names are auto-linked when they resolve in the graph.")),
mcp.WithString("title", mcp.Description("Short caption (one-liner) used as the headline in surface_memories output.")),
mcp.WithString("symbol_ids", mcp.Description("Comma-separated primary symbol anchors (e.g. pkg/foo.go::Bar,pkg/foo.go::Baz). At least one of symbol_ids / file_paths is recommended for high-quality surfacing.")),
mcp.WithString("file_paths", mcp.Description("Comma-separated primary file anchors.")),
mcp.WithString("tags", mcp.Description("Comma-separated labels — e.g. 'invariant,gotcha,decision'.")),
mcp.WithString("kind", mcp.Description("Memory kind: invariant | constraint | convention | gotcha | decision | incident | reference (default: reference).")),
mcp.WithString("source", mcp.Description("Where this came from: manual | distilled | incident | review (default: manual).")),
mcp.WithNumber("importance", mcp.Description("1..5 — operator-assigned weight; surfaced higher in ranking (default: 3).")),
mcp.WithNumber("confidence", mcp.Description("0.0..1.0 — how sure we are this still holds (default: 1.0). Decays the surfacing score.")),
mcp.WithBoolean("pinned", mcp.Description("Pinned memories are never evicted by the store cap and float to the top of surfacing.")),
mcp.WithString("supersedes", mcp.Description("Comma-separated memory IDs that this new memory replaces. Each older entry is marked SupersededBy=<new id> and hidden from surface_memories by default.")),
mcp.WithString("superseded_by", mcp.Description("(Update-only) explicitly mark this memory as superseded by another memory ID — hides it from surface_memories.")),
mcp.WithString("id", mcp.Description("Existing memory ID — passing it switches the call from create to update.")),
mcp.WithBoolean("no_autolink", mcp.Description("Skip the body→symbol auto-linker.")),
mcp.WithString("scope", mcp.Description("Memory scope: workspace (default — per-repo) or global (~/.gortex/memories — shared across every workspace this user touches).")),
mcp.WithString("format", mcp.Description("Output format: json (default), gcx, or toon")),
),
s.handleStoreMemory,
)
s.addTool(
mcp.NewTool("query_memories",
mcp.WithDescription("Search the cross-session memory store by symbol, file, tag, kind, source, author, free-text, or recency. Returns matches sorted pinned-first, then importance-DESC, then UpdatedAt-DESC. Unlike query_notes, memories are workspace-wide and have no session boundary."),
mcp.WithString("symbol_id", mcp.Description("Return memories anchored or auto-linked to this symbol.")),
mcp.WithString("file_path", mcp.Description("Return memories anchored to this file path.")),
mcp.WithString("tag", mcp.Description("Return memories carrying this tag (case-insensitive).")),
mcp.WithString("kind", mcp.Description("Filter by memory kind: invariant | constraint | convention | gotcha | decision | incident | reference.")),
mcp.WithString("source", mcp.Description("Filter by memory source: manual | distilled | incident | review.")),
mcp.WithString("author", mcp.Description("Filter by author agent (MCP clientInfo.name).")),
mcp.WithString("text", mcp.Description("Case-insensitive substring filter on body or title.")),
mcp.WithString("since", mcp.Description("Only return memories updated at or after this RFC-3339 timestamp.")),
mcp.WithNumber("min_importance", mcp.Description("Only return memories with importance >= this value (1..5).")),
mcp.WithBoolean("pinned_only", mcp.Description("Return only pinned memories.")),
mcp.WithBoolean("include_superseded", mcp.Description("Include superseded memories (default false).")),
mcp.WithNumber("limit", mcp.Description("Cap the result set (default: 50).")),
mcp.WithString("scope", mcp.Description("Memory scope: workspace (default), global, or both.")),
mcp.WithString("format", mcp.Description("Output format: json (default), gcx, or toon")),
),
s.handleQueryMemories,
)
s.addTool(
mcp.NewTool("surface_memories",
mcp.WithDescription("Proactively surface relevant development memories for a task or working set. Given anchor symbols / files / a task description, returns memories ranked by symbol overlap, file overlap, keyword hits, importance, pinning, recency, and confidence — deterministic scoring, no LLM. Use at task start (the memory analogue of smart_context) or before editing a symbol with a known history."),
mcp.WithString("task", mcp.Description("Natural-language task description — keywords matched against memory body / title.")),
mcp.WithString("symbol_ids", mcp.Description("Comma-separated anchor symbols (the working set).")),
mcp.WithString("file_paths", mcp.Description("Comma-separated anchor files.")),
mcp.WithNumber("limit", mcp.Description("Cap the surfaced set (default: 10).")),
mcp.WithNumber("excerpt_chars", mcp.Description("Body excerpt cap in bytes (default: 320).")),
mcp.WithNumber("min_score", mcp.Description("Drop hits below this score (default: 0).")),
mcp.WithBoolean("include_superseded", mcp.Description("Include superseded memories (default false).")),
mcp.WithBoolean("mark_accessed", mcp.Description("Increment AccessCount + LastAccessed on returned memories (default true).")),
mcp.WithString("scope", mcp.Description("Memory scope: workspace (default), global, or both.")),
mcp.WithString("format", mcp.Description("Output format: json (default), gcx, or toon")),
),
s.handleSurfaceMemories,
)
s.registerMemoryHierarchyTools()
s.addTool(
mcp.NewTool("check_onboarding_performed",
mcp.WithDescription("Light-weight readiness probe for the gortex-onboarding skill. Returns {performed, total_memories, counts_by_kind, missing_kinds}. `performed` is true iff every essential memory kind has at least `min_per_kind` entries in the session's workspace; `missing_kinds` lists the categories that fall short. Agents call this before deciding to run the onboarding playbook — if performed=false, run the skill and store the durable knowledge as memories."),
mcp.WithString("essential_kinds", mcp.Description("Comma-separated kinds that must each have at least `min_per_kind` memories (default: invariant,convention,decision — the three durable-knowledge categories from CLAUDE.md). Pass an empty string to disable kind checking; then `performed` is true iff total_memories >= min_total.")),
mcp.WithNumber("min_per_kind", mcp.Description("Minimum memories required per essential kind (default: 1).")),
mcp.WithNumber("min_total", mcp.Description("Minimum total memories required (default: 0 — no total threshold).")),
mcp.WithString("format", mcp.Description("Output format: json (default), gcx, or toon")),
),
s.handleCheckOnboardingPerformed,
)
}
// handleStoreMemory — create-or-update entry point. Update mode is
// selected by passing an existing `id`; everything else is a create.
func (s *Server) handleStoreMemory(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
scope := strings.TrimSpace(req.GetString("scope", ""))
store := s.resolveMemoryStore(scope)
if store == nil {
if scope == "global" {
return mcp.NewToolResultError("global memory store not available (could not resolve ~/.gortex)"), nil
}
return mcp.NewToolResultError("memories storage not initialised"), nil
}
id := strings.TrimSpace(req.GetString("id", ""))
body := req.GetString("body", "")
title := strings.TrimSpace(req.GetString("title", ""))
symbolIDs := splitCSV(req.GetString("symbol_ids", ""))
filePaths := splitCSV(req.GetString("file_paths", ""))
tags := splitCSV(req.GetString("tags", ""))
kind := strings.TrimSpace(req.GetString("kind", ""))
source := strings.TrimSpace(req.GetString("source", ""))
importance := req.GetInt("importance", 0)
confidence := float32(req.GetFloat("confidence", 0))
pinned := req.GetBool("pinned", false)
pinnedSet := requestHasArg(req, "pinned")
supersedes := splitCSV(req.GetString("supersedes", ""))
supersededBy := strings.TrimSpace(req.GetString("superseded_by", ""))
noAutolink := req.GetBool("no_autolink", false)
if id == "" && body == "" && len(symbolIDs) == 0 && len(filePaths) == 0 {
return mcp.NewToolResultError("store_memory requires at least one of: body, symbol_ids, file_paths"), nil
}
// Update path.
if id != "" {
patch := MemoryPatch{}
if body != "" {
patch.Body = &body
}
if title != "" {
patch.Title = &title
}
if kind != "" {
patch.Kind = &kind
}
if source != "" {
patch.Source = &source
}
if importance != 0 {
patch.Importance = &importance
}
if confidence > 0 {
patch.Confidence = &confidence
}
if tags != nil {
patch.Tags = tags
}
if symbolIDs != nil {
patch.SymbolIDs = symbolIDs
}
if filePaths != nil {
patch.FilePaths = filePaths
}
if pinnedSet {
patch.Pinned = &pinned
}
if supersededBy != "" {
patch.SupersededBy = &supersededBy
}
if !noAutolink && body != "" {
patch.AddLinks = autoLinkBody(body, s.graph, sessionWorkspaceIDOrEmpty(s, ctx), defaultAutoLinkOptions())
}
updated, err := store.Update(id, patch)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("update memory: %v", err)), nil
}
// Backfill: when the update names `supersedes`, retro-mark
// the older entries even on an update call.
for _, oldID := range supersedes {
if oldID == id {
continue
}
if _, ok := store.Get(oldID); !ok {
continue
}
newID := id
_, _ = store.Update(oldID, MemoryPatch{SupersededBy: &newID})
}
s.reconcileRationale(scope)
return s.respondJSONOrTOON(ctx, req, memoryEntryToWire(updated))
}
// Create path. Stamp scope from session + (when available) the
// first attached symbol's node, so memories inherit the workspace
// boundary without forcing the caller to think about it.
workspaceID, projectID, _ := s.sessionScope(ctx)
repoPrefix, _ := s.sessionLocality(ctx)
if len(symbolIDs) > 0 && s.graph != nil {
if node := s.graph.GetNode(symbolIDs[0]); node != nil {
if workspaceID == "" {
workspaceID = node.WorkspaceID
}
if projectID == "" {
projectID = node.ProjectID
}
if repoPrefix == "" {
repoPrefix = node.RepoPrefix
}
}
}
var autoLinks []string
if !noAutolink && body != "" {
autoLinks = autoLinkBody(body, s.graph, workspaceID, defaultAutoLinkOptions())
}
entry := persistence.MemoryEntry{
Body: body,
Title: title,
Kind: kind,
Source: source,
Importance: importance,
Confidence: confidence,
AuthorAgent: sessionClientName(s, ctx),
SymbolIDs: symbolIDs,
FilePaths: filePaths,
AutoLinks: autoLinks,
Tags: tags,
WorkspaceID: workspaceID,
ProjectID: projectID,
RepoPrefix: repoPrefix,
Pinned: pinned,
}
newID, err := store.Save(entry)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("save memory: %v", err)), nil
}
// Stamp each named older memory as superseded by this new one.
for _, oldID := range supersedes {
if oldID == newID {
continue
}
if _, ok := store.Get(oldID); !ok {
continue
}
nid := newID
_, _ = store.Update(oldID, MemoryPatch{SupersededBy: &nid})
}
saved, _ := store.Get(newID)
s.reconcileRationale(scope)
return s.respondJSONOrTOON(ctx, req, memoryEntryToWire(saved))
}
// handleQueryMemories — multi-filter listing. Honours the session
// workspace boundary: every result lives inside the session's
// workspace (or carries no workspace when the session is unbound).
func (s *Server) handleQueryMemories(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
scope := strings.ToLower(strings.TrimSpace(req.GetString("scope", "")))
stores := s.resolveMemoryStores(scope)
if len(stores) == 0 {
return mcp.NewToolResultError("memories storage not initialised"), nil
}
limit := req.GetInt("limit", 50)
filter := MemoryQueryFilter{
SymbolID: strings.TrimSpace(req.GetString("symbol_id", "")),
FilePath: strings.TrimSpace(req.GetString("file_path", "")),
Tag: strings.TrimSpace(req.GetString("tag", "")),
Kind: strings.TrimSpace(req.GetString("kind", "")),
Source: strings.TrimSpace(req.GetString("source", "")),
AuthorAgent: strings.TrimSpace(req.GetString("author", "")),
TextSearch: req.GetString("text", ""),
MinImportance: req.GetInt("min_importance", 0),
IncludeSuperseded: req.GetBool("include_superseded", false),
Limit: limit,
}
if since := strings.TrimSpace(req.GetString("since", "")); since != "" {
if ts, err := time.Parse(time.RFC3339, since); err == nil {
filter.Since = ts
} else {
return mcp.NewToolResultError(fmt.Sprintf("invalid `since` timestamp: %v", err)), nil
}
}
if req.GetBool("pinned_only", false) {
yes := true
filter.Pinned = &yes
}
wire := make([]map[string]any, 0)
for _, store := range stores {
// Per-store WorkspaceID filter: only applies to the workspace
// store, not global. Global entries have no WorkspaceID by
// construction, so the filter would silently drop them.
perStoreFilter := filter
if store != s.globalMemories {
if workspaceID, _, bound := s.sessionScope(ctx); bound {
perStoreFilter.WorkspaceID = workspaceID
}
} else {
perStoreFilter.WorkspaceID = ""
}
for _, m := range store.Query(perStoreFilter) {
row := memoryEntryToWire(m)
if store == s.globalMemories {
row["scope"] = "global"
} else {
row["scope"] = "workspace"
}
wire = append(wire, row)
}
}
// When both stores were consulted, the combined list may exceed
// limit; trim at the call boundary so the contract holds.
if limit > 0 && len(wire) > limit {
wire = wire[:limit]
}
return s.respondJSONOrTOON(ctx, req, map[string]any{
"memories": wire,
"total": len(wire),
})
}
// handleSurfaceMemories — proactively retrieve memories for a
// working set. Defaults to mark_accessed=true so the access stats
// stay accurate.
func (s *Server) handleSurfaceMemories(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
scope := strings.ToLower(strings.TrimSpace(req.GetString("scope", "")))
store := s.resolveMemoryStore(scope)
if store == nil {
if scope == "global" {
return mcp.NewToolResultError("global memory store not available"), nil
}
return mcp.NewToolResultError("memories storage not initialised"), nil
}
opts := SurfaceOptions{
Task: strings.TrimSpace(req.GetString("task", "")),
SymbolIDs: splitCSV(req.GetString("symbol_ids", "")),
FilePaths: splitCSV(req.GetString("file_paths", "")),
Limit: req.GetInt("limit", 10),
ExcerptCap: req.GetInt("excerpt_chars", 320),
MinScore: float32(req.GetFloat("min_score", 0)),
IncludeSuperseded: req.GetBool("include_superseded", false),
MarkAccessed: requestBoolDefault(req, "mark_accessed", true),
}
// Workspace scoping only applies to the workspace store. Global
// entries don't carry a WorkspaceID, so leaving the opts default
// is correct.
if store != s.globalMemories {
if workspaceID, projectID, bound := s.sessionScope(ctx); bound {
opts.WorkspaceID = workspaceID
opts.ProjectID = projectID
}
}
res := store.Surface(opts, func(id string) *graph.Node {
if s.graph == nil {
return nil
}
return s.graph.GetNode(id)
})
return s.respondJSONOrTOON(ctx, req, res)
}
// memoryEntryToWire shapes a stored memory for inclusion in
// JSON / GCX / TOON responses.
func memoryEntryToWire(e persistence.MemoryEntry) map[string]any {
m := map[string]any{
"id": e.ID,
"timestamp": e.Timestamp.UTC().Format(time.RFC3339),
"updated_at": e.UpdatedAt.UTC().Format(time.RFC3339),
"body": e.Body,
}
if e.Title != "" {
m["title"] = e.Title
}
if e.Kind != "" {
m["kind"] = e.Kind
}
if e.Source != "" {
m["source"] = e.Source
}
if e.Confidence > 0 {
m["confidence"] = e.Confidence
}
if e.Importance > 0 {
m["importance"] = e.Importance
}
if e.AuthorAgent != "" {
m["author_agent"] = e.AuthorAgent
}
if len(e.SymbolIDs) > 0 {
m["symbol_ids"] = e.SymbolIDs
}
if len(e.FilePaths) > 0 {
m["file_paths"] = e.FilePaths
}
if len(e.AutoLinks) > 0 {
m["links"] = e.AutoLinks
}
if len(e.Tags) > 0 {
m["tags"] = e.Tags
}
if e.WorkspaceID != "" {
m["workspace_id"] = e.WorkspaceID
}
if e.ProjectID != "" {
m["project_id"] = e.ProjectID
}
if e.RepoPrefix != "" {
m["repo_prefix"] = e.RepoPrefix
}
if e.Pinned {
m["pinned"] = true
}
if e.SupersededBy != "" {
m["superseded_by"] = e.SupersededBy
}
if e.AccessCount > 0 {
m["access_count"] = e.AccessCount
}
if !e.LastAccessed.IsZero() {
m["last_accessed"] = e.LastAccessed.UTC().Format(time.RFC3339)
}
return m
}
// requestHasArg reports whether the caller explicitly supplied a
// named argument. Needed for tri-state booleans (pinned: set vs.
// unset vs. false) on update.
func requestHasArg(req mcp.CallToolRequest, key string) bool {
args, ok := req.Params.Arguments.(map[string]any)
if !ok {
return false
}
_, present := args[key]
return present
}
// requestBoolDefault returns the bool at key, or def when the
// caller didn't supply the argument at all. (GetBool returns the
// zero value for "not present", which conflates absent with false.)
func requestBoolDefault(req mcp.CallToolRequest, key string, def bool) bool {
if !requestHasArg(req, key) {
return def
}
return req.GetBool(key, def)
}
// defaultEssentialKinds is the kind list check_onboarding_performed
// uses when the caller doesn't override it — the three durable
// knowledge categories CLAUDE.md tells agents to record. Onboarding
// is "done" when each has at least one anchored memory in the
// workspace.
var defaultEssentialKinds = []string{"invariant", "convention", "decision"}
// handleCheckOnboardingPerformed is the onboarding-readiness probe.
// Returns whether each essential memory kind has min_per_kind
// memories anchored in the session's workspace, plus the per-kind
// breakdown so the caller can tell exactly which categories are
// missing before deciding to run the onboarding playbook.
func (s *Server) handleCheckOnboardingPerformed(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
if s.memories == nil {
return mcp.NewToolResultError("memories storage not initialised"), nil
}
var essentialKinds []string
if requestHasArg(req, "essential_kinds") {
essentialKinds = splitCSV(req.GetString("essential_kinds", ""))
} else {
essentialKinds = append(essentialKinds, defaultEssentialKinds...)
}
minPerKind := max(req.GetInt("min_per_kind", 1), 1)
minTotal := max(req.GetInt("min_total", 0), 0)
workspaceID := ""
if id, _, bound := s.sessionScope(ctx); bound {
workspaceID = id
}
// One Query per kind. The store is in-memory so this is cheap;
// the readability win over a single Query+group-by is worth
// the extra calls.
countsByKind := make(map[string]int, len(essentialKinds))
missing := make([]string, 0)
for _, k := range essentialKinds {
entries := s.memories.Query(MemoryQueryFilter{
Kind: k,
WorkspaceID: workspaceID,
})
countsByKind[k] = len(entries)
if len(entries) < minPerKind {
missing = append(missing, k)
}
}
// total counts every workspace-scoped memory regardless of kind.
total := len(s.memories.Query(MemoryQueryFilter{WorkspaceID: workspaceID}))
performed := len(missing) == 0 && total >= minTotal
return s.respondJSONOrTOON(ctx, req, map[string]any{
"performed": performed,
"total_memories": total,
"essential_kinds": essentialKinds,
"min_per_kind": minPerKind,
"min_total": minTotal,
"counts_by_kind": countsByKind,
"missing_kinds": missing,
})
}
// registerMemoryHierarchyTools wires edit_memory + rename_memory —
// the two operations that extend the existing memory triplet with
// scope migration (workspace ↔ global) and in-place body edits
// (regex or literal find/replace). Closes serena's 6-tool surface
// gap (we previously had 3 + check_onboarding_performed; now 6).
func (s *Server) registerMemoryHierarchyTools() {
s.addTool(
mcp.NewTool("edit_memory",
mcp.WithDescription("In-place find/replace inside a memory's body. Use when a memory is mostly right but one detail drifted — beats supersede-and-recreate. Returns the updated entry. mode=regex (default) treats `pattern` as a Go regexp; mode=literal escapes it. Updates UpdatedAt; preserves Importance / Confidence / Pinned / SymbolIDs / FilePaths."),
mcp.WithString("id", mcp.Description("Memory ID to edit.")),
mcp.WithString("pattern", mcp.Description("Find pattern. Treated as a regexp under mode=regex; as a literal substring under mode=literal.")),
mcp.WithString("replacement", mcp.Description("Replacement text. Regex groups (`$1`, `$2`) work under mode=regex.")),
mcp.WithString("mode", mcp.Description("regex (default) or literal.")),
mcp.WithString("scope", mcp.Description("workspace (default) or global.")),
mcp.WithString("format", mcp.Description("Output format: json (default), gcx, or toon")),
),
s.handleEditMemory,
)
s.addTool(
mcp.NewTool("rename_memory",
mcp.WithDescription("Move a memory between workspace and global scope. Preserves Body / Title / Kind / Tags / SymbolIDs / FilePaths / Importance / Confidence / Pinned; clears WorkspaceID + ProjectID when promoting to global, otherwise stamps them from the session. Returns the entry as it landed in the new scope. The original entry is deleted on success."),
mcp.WithString("id", mcp.Description("Memory ID to move.")),
mcp.WithString("to_scope", mcp.Description("Target scope: workspace or global.")),
mcp.WithString("from_scope", mcp.Description("Source scope (default: workspace).")),
mcp.WithString("format", mcp.Description("Output format: json (default), gcx, or toon")),
),
s.handleRenameMemory,
)
}
// handleEditMemory applies an in-place find/replace to the memory's
// body and persists the result. Regex mode is the default — pass
// mode=literal for a verbatim substring replace.
func (s *Server) handleEditMemory(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
id, err := req.RequireString("id")
if err != nil {
return mcp.NewToolResultError("id is required"), nil
}
pattern, err := req.RequireString("pattern")
if err != nil {
return mcp.NewToolResultError("pattern is required"), nil
}
replacement := req.GetString("replacement", "")
mode := strings.ToLower(strings.TrimSpace(req.GetString("mode", "regex")))
scope := strings.TrimSpace(req.GetString("scope", ""))
store := s.resolveMemoryStore(scope)
if store == nil {
return mcp.NewToolResultError("memories storage not initialised"), nil
}
current, ok := store.Get(id)
if !ok {
return mcp.NewToolResultError(fmt.Sprintf("memory %q not found in %s scope", id, defaultIfEmpty(scope, "workspace"))), nil
}
var newBody string
switch mode {
case "literal":
newBody = strings.ReplaceAll(current.Body, pattern, replacement)
case "regex", "":
re, rerr := regexp.Compile(pattern)
if rerr != nil {
return mcp.NewToolResultError(fmt.Sprintf("invalid regex: %v", rerr)), nil
}
newBody = re.ReplaceAllString(current.Body, replacement)
default:
return mcp.NewToolResultError(fmt.Sprintf("unknown mode %q (use regex or literal)", mode)), nil
}
if newBody == current.Body {
// No change — return current entry so callers can detect
// idempotent application without an extra round-trip.
return s.respondJSONOrTOON(ctx, req, memoryEntryToWire(current))
}
updated, uerr := store.Update(id, MemoryPatch{Body: &newBody})
if uerr != nil {
return mcp.NewToolResultError(fmt.Sprintf("update memory: %v", uerr)), nil
}
return s.respondJSONOrTOON(ctx, req, memoryEntryToWire(updated))
}
// handleRenameMemory moves a memory between workspace and global
// scope. Preserves every field except WorkspaceID / ProjectID which
// are cleared on the promote-to-global path and re-stamped from
// the session on the demote-to-workspace path. The original entry
// is deleted on success.
func (s *Server) handleRenameMemory(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
id, err := req.RequireString("id")
if err != nil {
return mcp.NewToolResultError("id is required"), nil
}
toScope := strings.ToLower(strings.TrimSpace(req.GetString("to_scope", "")))
if toScope != "workspace" && toScope != "global" {
return mcp.NewToolResultError("to_scope must be 'workspace' or 'global'"), nil
}
fromScope := strings.ToLower(strings.TrimSpace(req.GetString("from_scope", "workspace")))
if fromScope == toScope {
return mcp.NewToolResultError("from_scope and to_scope are identical — nothing to do"), nil
}
fromStore := s.resolveMemoryStore(fromScope)
toStore := s.resolveMemoryStore(toScope)
if fromStore == nil {
return mcp.NewToolResultError(fmt.Sprintf("%s memory store not initialised", fromScope)), nil
}
if toStore == nil {
return mcp.NewToolResultError(fmt.Sprintf("%s memory store not initialised", toScope)), nil
}
current, ok := fromStore.Get(id)
if !ok {
return mcp.NewToolResultError(fmt.Sprintf("memory %q not found in %s scope", id, fromScope)), nil
}
// Build the new entry. Drop the ID so the destination store
// generates a fresh one — the move semantically creates a new
// memory at the destination and removes the source.
moved := current
moved.ID = ""
switch toScope {
case "global":
moved.WorkspaceID = ""
moved.ProjectID = ""
moved.RepoPrefix = ""
case "workspace":
if workspaceID, projectID, bound := s.sessionScope(ctx); bound {
moved.WorkspaceID = workspaceID
moved.ProjectID = projectID
}
}
newID, serr := toStore.Save(moved)
if serr != nil {
return mcp.NewToolResultError(fmt.Sprintf("save to %s: %v", toScope, serr)), nil
}
if derr := fromStore.Delete(id); derr != nil {
// Save succeeded but delete failed — leave both copies in
// place rather than risk losing the entry. Surface the
// inconsistency to the caller.
return mcp.NewToolResultError(fmt.Sprintf("saved to %s but could not delete from %s: %v (memory now exists in both scopes)", toScope, fromScope, derr)), nil
}
saved, _ := toStore.Get(newID)
wire := memoryEntryToWire(saved)
wire["scope"] = toScope
wire["previous_id"] = id
return s.respondJSONOrTOON(ctx, req, wire)
}
// defaultIfEmpty returns def when s is the empty string. Tiny helper
// kept inline because the two-line alternative is everywhere.
func defaultIfEmpty(s, def string) string {
if s == "" {
return def
}
return s
}