diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1d9bfd55..b5621095 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,7 +7,7 @@ Thank you for considering contributing to Mnemon! - Bug fixes with a reproducing test or E2E scenario - Performance improvements with benchmark evidence - Documentation improvements (typos, clarity, missing examples) -- New integrations for LLM CLIs beyond Claude Code and OpenClaw +- New integrations for agent runtimes and MCP clients For significant features or architectural changes, please **open an issue first** to discuss the approach before writing code. diff --git a/Makefile b/Makefile index 44b3a8c9..6ba88435 100644 --- a/Makefile +++ b/Makefile @@ -17,6 +17,7 @@ DETERMINISTIC_PKGS := \ . \ ./cmd \ ./cmd/agency \ + ./cmd/mcp \ ./cmd/memory \ ./internal/agency \ ./internal/agency/client \ @@ -24,10 +25,12 @@ DETERMINISTIC_PKGS := \ ./internal/agency/authority \ ./internal/agency/artifact \ ./internal/memory/embed \ + ./internal/mcp \ ./internal/memory/graph \ ./internal/memory/importdraft \ ./internal/memory/model \ ./internal/memory/search \ + ./internal/memory/service \ ./internal/memory/setup \ ./internal/memory/setup/assets \ ./internal/memory/store \ diff --git a/README.md b/README.md index a24f616c..a81531c1 100644 --- a/README.md +++ b/README.md @@ -19,10 +19,11 @@ LLM agents forget everything between sessions. Context compaction drops critical Mnemon gives your agent persistent, cross-session memory — a four-graph knowledge store with intent-aware recall, importance decay, and automatic deduplication. The `mnemon` memory path remains one local binary with zero API keys and one setup command. -Mnemon ships one executable with two separate surfaces. Memory stays at the -`mnemon` root; [Agency Preview](docs/AGENCY.md) lives at `mnemon agency ...` and adds -durable, project-local responsibility and effect admission to an existing Pi -agent. Agency does not replace Memory or the Agent Runtime. +Mnemon ships one executable with three entry points. Memory stays at the +`mnemon` root; `mnemon mcp serve` exposes the same Memory engine to MCP clients; +and [Agency Preview](docs/AGENCY.md) lives at `mnemon agency ...`, adding durable, +project-local responsibility and effect admission to an existing Pi agent. +Neither MCP nor Agency replaces Memory or the Agent Runtime. > **Claude Max / Pro subscriber?** Mnemon works entirely through your existing subscription — no separate API key required. Your LLM subscription *is* the intelligence layer. Two commands and you're done. @@ -37,6 +38,9 @@ Most memory tools embed their own LLM inside the pipeline. Mnemon takes a differ | **MCP Server** | Tool provider via MCP protocol | claude-mem | | **LLM-Supervised** | External supervisor of a standalone binary | **Mnemon** | +Mnemon's built-in MCP server is a transport adapter for the same deterministic, +LLM-supervised engine; it does not embed another LLM or require another API key. + Mnemon also addresses a gap in the protocol stack. MCP standardizes how LLMs discover and invoke tools. ODBC/JDBC standardizes how applications access databases. But how LLMs interact with databases using memory semantics — this layer has no protocol. Mnemon's three primitives — `remember`, `link`, `recall` — form an intent-native protocol: command names map to the LLM's cognitive vocabulary (`remember` not INSERT, `recall` not SELECT), and output is structured JSON with signal transparency rather than raw database rows.
@@ -71,7 +75,7 @@ brew install --cask mnemon-dev/tap/mnemon go install github.com/mnemon-dev/mnemon@latest ``` -Windows supports the core Memory commands. Agency remains unavailable on +Windows supports the core Memory commands and MCP server. Agency remains unavailable on Windows until its local authority boundary has native Windows security. **From source** (macOS / Linux): @@ -85,6 +89,7 @@ make install ```bash mnemon --version +mnemon mcp --help mnemon agency --version ``` @@ -100,6 +105,29 @@ enables Memory, while the command above enables Agency. See the [Agency guide](docs/AGENCY.md) for its operating model, Preview compatibility boundary, and optional peers. +### MCP clients + +Any stdio MCP client can use Mnemon's six Memory tools through the same binary. +For clients using the conventional `mcpServers` configuration shape, add: + +```json +{ + "mcpServers": { + "mnemon": { + "command": "mnemon", + "args": ["mcp", "serve"] + } + } +} +``` + +To select a store, place root flags before the namespace, for example +`["--store", "work", "mcp", "serve"]`, or set `MNEMON_STORE`. The server +offers `recall`, `search`, `remember`, `related`, `link`, and `status`. Read +results are capped at 600 Unicode characters per memory by default; clients can +request `full: true` when complete content is needed. See the +[MCP reference](docs/USAGE.md#mcp-server) for all limits and options. + ### [Claude Code](https://github.com/anthropics/claude-code) ```bash @@ -344,7 +372,8 @@ memory is useful. - **Zero user-side operation** — install once; supported runtimes can use hooks, minimal runtimes can use persistent rules - **LLM-supervised** — the host LLM decides what to remember, update, and forget; no embedded LLM, no API keys -- **Multi-framework support** — Claude Code, Codex, Cursor, ZCode, TRAE/TRAE Work, Qoder/QoderWork, CodeBuddy, WorkBuddy, Kimi Code, OpenCode, and Hermes Agent (hooks/plugins), OpenClaw (plugins), Pi (extensions), MiniMax Code and Nanobot (skills), DeepSeek Harness (via the dsh-mnemon plugin), and more +- **Built-in MCP server** — one stdio command exposes six bounded, schema-described Memory tools to MCP clients +- **Multi-framework support** — Claude Code, Codex, Cursor, ZCode, TRAE/TRAE Work, Qoder/QoderWork, CodeBuddy, WorkBuddy, Kimi Code, OpenCode, and Hermes Agent (hooks/plugins), OpenClaw (plugins), Pi (extensions), MiniMax Code and Nanobot (skills), DeepSeek Harness (via the dsh-mnemon plugin), any stdio MCP client, and more - **Runtime-native integration** — runtime-specific `SKILL.md`, shared `guide.md`, and supported hooks or extensions - **Four-graph architecture** — temporal, entity, causal, and semantic edges, not just vector similarity - **Intent-native protocol** — three primitives (`remember`, `link`, `recall`) map to the LLM's cognitive vocabulary, not database syntax; structured JSON output with signal transparency @@ -490,7 +519,7 @@ mnemon setup --eject # remove all integrations make help # show all targets ``` -**Dependencies**: Go 1.24+, `modernc.org/sqlite`, `spf13/cobra`, `google/uuid` +**Dependencies**: Go 1.24+, `modernc.org/sqlite`, `spf13/cobra`, `google/uuid`, `modelcontextprotocol/go-sdk` See [Development and Deployment](docs/DEPLOYMENT.md) for Docker, Compose, Ollama embedding, and release setup. @@ -499,7 +528,7 @@ See [Development and Deployment](docs/DEPLOYMENT.md) for Docker, Compose, Ollama - [Agency Preview](docs/AGENCY.md) — maturity boundary, Pi setup, operating model, completion semantics, and optional peers - [Go Engineering Standard](docs/development/go-engineering-standard.md) — maintainability, concurrency, persistence, testing, and review thresholds - [Design & Architecture](docs/DESIGN.md) — current engine architecture, algorithms, integration design -- [Memory Usage & Reference](docs/USAGE.md) — root Memory commands, import, receipts, and embedding support +- [Memory & MCP Usage Reference](docs/USAGE.md) — root Memory commands, built-in MCP server, import, receipts, and embedding support - [Memory Import Guide](docs/IMPORT.md) — schema and LLM prompt for importing historical chats - [Architecture Diagrams](docs/diagrams/) — system architecture, pipelines, lifecycle management diff --git a/cmd/mcp/command.go b/cmd/mcp/command.go new file mode 100644 index 00000000..7a52cc40 --- /dev/null +++ b/cmd/mcp/command.go @@ -0,0 +1,37 @@ +// Package mcp composes Mnemon's Model Context Protocol command namespace. +package mcp + +import ( + "io" + + mcpserver "github.com/mnemon-dev/mnemon/internal/mcp" + memoryservice "github.com/mnemon-dev/mnemon/internal/memory/service" + "github.com/spf13/cobra" +) + +// ConfigProvider resolves the parsed product-level Memory flags at execution +// time. +type ConfigProvider func(warnings io.Writer) memoryservice.Config + +// New returns the `mnemon mcp` command namespace. +func New(version string, provideConfig ConfigProvider) *cobra.Command { + root := &cobra.Command{ + Use: "mcp", + Short: "Expose Mnemon memory through the Model Context Protocol", + } + serve := &cobra.Command{ + Use: "serve", + Short: "Serve MCP over standard input and output", + Args: cobra.NoArgs, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + config := provideConfig(cmd.ErrOrStderr()) + config.AuditContent = false + memory := memoryservice.New(config) + server := mcpserver.New(version, memory) + return server.Serve(cmd.Context(), cmd.InOrStdin(), cmd.OutOrStdout()) + }, + } + root.AddCommand(serve) + return root +} diff --git a/cmd/mcp/command_test.go b/cmd/mcp/command_test.go new file mode 100644 index 00000000..64b67b1b --- /dev/null +++ b/cmd/mcp/command_test.go @@ -0,0 +1,24 @@ +package mcp + +import ( + "io" + "testing" + + memoryservice "github.com/mnemon-dev/mnemon/internal/memory/service" +) + +func TestNewBuildsServeNamespace(t *testing.T) { + command := New("test-version", func(io.Writer) memoryservice.Config { + return memoryservice.Config{DataDir: t.TempDir()} + }) + if command.Use != "mcp" { + t.Fatalf("use = %q, want mcp", command.Use) + } + serve, _, err := command.Find([]string{"serve"}) + if err != nil || serve == command || serve.Use != "serve" { + t.Fatalf("serve command is not registered: %v", err) + } + if !serve.SilenceUsage { + t.Fatal("serve command must suppress CLI usage on protocol failures") + } +} diff --git a/cmd/memory/link.go b/cmd/memory/link.go index a0f04cb1..bdd9af83 100644 --- a/cmd/memory/link.go +++ b/cmd/memory/link.go @@ -4,9 +4,8 @@ import ( "encoding/json" "fmt" "os" - "time" - "github.com/mnemon-dev/mnemon/internal/memory/model" + memoryservice "github.com/mnemon-dev/mnemon/internal/memory/service" "github.com/spf13/cobra" ) @@ -22,85 +21,22 @@ var linkCmd = &cobra.Command{ Long: "Create or update a typed edge between two insights. Used by Claude to create semantic edges after evaluating candidates.", Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { - sourceID := args[0] - targetID := args[1] - - // Validate edge type - edgeType := model.EdgeType(linkType) - if !model.ValidEdgeTypes[edgeType] { - return fmt.Errorf("invalid edge type %q; valid: temporal, semantic, causal, entity", linkType) - } - - // Validate weight - if linkWeight < 0.0 || linkWeight > 1.0 { - return fmt.Errorf("weight must be between 0.0 and 1.0, got %.2f", linkWeight) - } - - db, err := openWritableDB("link") - if err != nil { - return fmt.Errorf("open database: %w", err) - } - defer db.Close() - - // Validate both insights exist - src, err := db.GetInsightByID(sourceID) - if err != nil || src == nil { - return fmt.Errorf("source insight %s not found", sourceID) - } - tgt, err := db.GetInsightByID(targetID) - if err != nil || tgt == nil { - return fmt.Errorf("target insight %s not found", targetID) - } - - // Parse optional metadata - metadata := map[string]string{"created_by": "claude"} + metadata := map[string]string{} if linkMeta != "" { if err := json.Unmarshal([]byte(linkMeta), &metadata); err != nil { return fmt.Errorf("invalid metadata JSON: %w", err) } - metadata["created_by"] = "claude" - } - - now := time.Now().UTC() - - // Create bidirectional edges (INSERT OR REPLACE) - err = db.InsertEdge(&model.Edge{ - SourceID: sourceID, - TargetID: targetID, - EdgeType: edgeType, - Weight: linkWeight, - Metadata: metadata, - CreatedAt: now, - }) - if err != nil { - return fmt.Errorf("create edge %s→%s: %w", sourceID, targetID, err) } - - err = db.InsertEdge(&model.Edge{ - SourceID: targetID, - TargetID: sourceID, - EdgeType: edgeType, - Weight: linkWeight, - Metadata: metadata, - CreatedAt: now, + result, err := newRuntimeService(os.Stderr).Link(cmd.Context(), memoryservice.LinkRequest{ + SourceID: args[0], TargetID: args[1], EdgeType: linkType, + Weight: linkWeight, Metadata: metadata, CreatedBy: "claude", }) if err != nil { - return fmt.Errorf("create edge %s→%s: %w", targetID, sourceID, err) - } - - db.LogOp("link", sourceID, fmt.Sprintf("%s→%s type=%s weight=%.2f", truncID(sourceID), truncID(targetID), linkType, linkWeight)) - - output := map[string]interface{}{ - "status": "linked", - "source_id": sourceID, - "target_id": targetID, - "edge_type": linkType, - "weight": linkWeight, - "metadata": metadata, + return err } enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") - return enc.Encode(output) + return enc.Encode(result) }, } diff --git a/cmd/memory/recall.go b/cmd/memory/recall.go index e37344f0..e1e71e7d 100644 --- a/cmd/memory/recall.go +++ b/cmd/memory/recall.go @@ -7,10 +7,8 @@ import ( "os" "strings" - "github.com/mnemon-dev/mnemon/internal/memory/embed" - "github.com/mnemon-dev/mnemon/internal/memory/graph" "github.com/mnemon-dev/mnemon/internal/memory/search" - "github.com/mnemon-dev/mnemon/internal/memory/store" + memoryservice "github.com/mnemon-dev/mnemon/internal/memory/service" "github.com/spf13/cobra" ) @@ -115,31 +113,18 @@ var recallCmd = &cobra.Command{ return fmt.Errorf("--brief and --verbose cannot be used together") } - db, err := openDB() - if err != nil { - return fmt.Errorf("open database: %w", err) - } - defer db.Close() - enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") + response, err := newRuntimeService(os.Stderr).Recall(cmd.Context(), memoryservice.RecallRequest{ + Query: keyword, Category: recCategory, Source: recSource, + Limit: recLimit, Basic: recBasic, Intent: recIntent, + }) + if err != nil { + return err + } if recBasic { - // Legacy SQL LIKE recall. - results, err := db.QueryInsights(store.QueryFilter{ - Keyword: keyword, - Category: recCategory, - Source: recSource, - Limit: recLimit, - }) - if err != nil { - return fmt.Errorf("query insights: %w", err) - } - - for _, r := range results { - _ = db.IncrementAccessCount(r.ID) - } - db.LogOp("recall:basic", "", fmt.Sprintf("q=%s hits=%d", keyword, len(results))) + results := response.BasicResults if recBrief { brief := make([]briefResult, 0, len(results)) for _, result := range results { @@ -154,39 +139,7 @@ var recallCmd = &cobra.Command{ return enc.Encode(results) } - // Default: intent-aware graph-enhanced recall - var intentOverride *search.Intent - if recIntent != "" { - parsed, err := search.IntentFromString(recIntent) - if err != nil { - return err - } - intentOverride = &parsed - } - - // Try to get query embedding for hybrid search - var queryVec []float64 - ec := embed.NewClientWithModel(resolveEmbedModel()) - if ec.Available() { - queryVec, _ = ec.Embed(keyword) - } - - // Extract query entities at cmd layer (avoid graph->search circular dep). - // Load the known-entity set so the indexed extractor's fourth path can - // admit user vocabulary (single-segment CamelCase, lowercase project - // names) that techDictionary does not cover. The lookup is read-only; - // on error we fall through to the default regex+dictionary extractor. - knownEntities, _ := db.LoadKnownEntities() - queryEntities := graph.ExtractEntitiesIndexed(keyword, knownEntities) - - resp, err := search.IntentAwareRecall(db, keyword, queryVec, queryEntities, recLimit, intentOverride) - if err != nil { - return fmt.Errorf("recall: %w", err) - } - for _, r := range resp.Results { - _ = db.IncrementAccessCount(r.Insight.ID) - } - db.LogOp("recall", "", fmt.Sprintf("q=%s hits=%d", keyword, len(resp.Results))) + resp := *response.SmartResults if recVerbose { return enc.Encode(resp) diff --git a/cmd/memory/related.go b/cmd/memory/related.go index 44152e06..211d6b25 100644 --- a/cmd/memory/related.go +++ b/cmd/memory/related.go @@ -2,12 +2,9 @@ package memory import ( "encoding/json" - "fmt" "os" - "github.com/mnemon-dev/mnemon/internal/memory/graph" - "github.com/mnemon-dev/mnemon/internal/memory/model" - "github.com/mnemon-dev/mnemon/internal/memory/store" + memoryservice "github.com/mnemon-dev/mnemon/internal/memory/service" "github.com/spf13/cobra" ) @@ -22,66 +19,18 @@ var relatedCmd = &cobra.Command{ Long: "BFS traversal from a given insight, optionally filtered by edge type.", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - startID := args[0] - - db, err := openDB() - if err != nil { - return fmt.Errorf("open database: %w", err) - } - defer db.Close() - - // Verify start node exists - start, err := db.GetInsightByID(startID) + results, err := newRuntimeService(os.Stderr).Related(cmd.Context(), memoryservice.RelatedRequest{ + ID: args[0], EdgeType: relEdgeType, Depth: relDepth, + }) if err != nil { - return fmt.Errorf("insight not found: %w", err) - } - - var edgeFilter model.EdgeType - if relEdgeType != "" { - et := model.EdgeType(relEdgeType) - if !model.ValidEdgeTypes[et] { - return fmt.Errorf("invalid edge type %q; valid: temporal, semantic, causal, entity", relEdgeType) - } - edgeFilter = et + return err } - - // BFS traversal - related := bfsTraverse(db, start.ID, edgeFilter, relDepth) - enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") - return enc.Encode(related) + return enc.Encode(results) }, } -type relatedResult struct { - ID string `json:"id"` - Content string `json:"content"` - Category string `json:"category"` - Importance int `json:"importance"` - Depth int `json:"depth"` - EdgeType string `json:"via_edge_type,omitempty"` -} - -func bfsTraverse(db *store.DB, startID string, edgeFilter model.EdgeType, maxDepth int) []relatedResult { - nodes := graph.BFS(db, startID, graph.BFSOptions{ - MaxDepth: maxDepth, - EdgeFilter: edgeFilter, - }) - results := make([]relatedResult, 0, len(nodes)) - for _, n := range nodes { - results = append(results, relatedResult{ - ID: n.Insight.ID, - Content: n.Insight.Content, - Category: string(n.Insight.Category), - Importance: n.Insight.Importance, - Depth: n.Hop, - EdgeType: string(n.ViaEdge.EdgeType), - }) - } - return results -} - func init() { relatedCmd.Flags().StringVar(&relEdgeType, "edge", "", "filter by edge type (temporal|semantic|causal|entity)") relatedCmd.Flags().IntVar(&relDepth, "depth", 2, "max traversal depth") diff --git a/cmd/memory/remember.go b/cmd/memory/remember.go index 5f565757..f9e784c2 100644 --- a/cmd/memory/remember.go +++ b/cmd/memory/remember.go @@ -2,17 +2,11 @@ package memory import ( "encoding/json" - "fmt" "os" "strings" - "time" - "github.com/google/uuid" - "github.com/mnemon-dev/mnemon/internal/memory/embed" "github.com/mnemon-dev/mnemon/internal/memory/graph" - "github.com/mnemon-dev/mnemon/internal/memory/model" - "github.com/mnemon-dev/mnemon/internal/memory/search" - "github.com/mnemon-dev/mnemon/internal/memory/store" + memoryservice "github.com/mnemon-dev/mnemon/internal/memory/service" "github.com/spf13/cobra" ) @@ -32,289 +26,27 @@ var rememberCmd = &cobra.Command{ Long: "Store a new insight into the memory graph with optional category, importance, and tags.", Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - content := strings.Join(args, " ") - if len(content) > 8000 { - return fmt.Errorf("content too long (%d chars, max 8000); consider chunking into multiple remember calls", len(content)) - } - - cat := model.Category(remCategory) - if !model.ValidCategories[cat] { - return fmt.Errorf("invalid category %q; valid: preference, decision, fact, insight, context, general", remCategory) - } - if remImportance < 1 || remImportance > 5 { - return fmt.Errorf("importance must be 1-5, got %d", remImportance) - } - entityMode := graph.EntityMode(remEntityMode) - if !graph.ValidEntityMode(entityMode) { - return fmt.Errorf("invalid entity mode %q; valid: merge, provided, auto", remEntityMode) - } - - var tags []string - if remTags != "" { - for _, t := range strings.Split(remTags, ",") { - t = strings.TrimSpace(t) - if t != "" { - if len(t) > 100 { - return fmt.Errorf("tag too long (%d chars, max 100): %s", len(t), t[:50]) - } - tags = append(tags, t) - } - } - if len(tags) > 20 { - return fmt.Errorf("too many tags (%d, max 20)", len(tags)) - } - } - if tags == nil { - tags = []string{} - } - - var entities []string - if remEntities != "" { - for _, e := range strings.Split(remEntities, ",") { - e = strings.TrimSpace(e) - if e != "" { - if len(e) > 200 { - return fmt.Errorf("entity too long (%d chars, max 200): %s", len(e), e[:50]) - } - entities = append(entities, e) - } - } - if len(entities) > 50 { - return fmt.Errorf("too many entities (%d, max 50)", len(entities)) - } - } - if entities == nil { - entities = []string{} - } - - now := time.Now().UTC() - insight := &model.Insight{ - ID: uuid.New().String(), - Content: content, - Category: cat, - Importance: remImportance, - Tags: tags, - Entities: entities, - Source: remSource, - CreatedAt: now, - UpdatedAt: now, - } - - db, err := openWritableDB("remember") - if err != nil { - return fmt.Errorf("open database: %w", err) - } - defer db.Close() - - // 1. Compute embedding BEFORE the transaction (HTTP call should not hold a DB lock) - var embeddingBlob []byte - var embeddingVec []float64 - ec := embed.NewClientWithModel(resolveEmbedModel()) - if ec.Available() { - if vec, err := ec.Embed(content); err == nil { - embeddingVec = vec - embeddingBlob = embed.SerializeVector(vec) - } - } - - // 2. Built-in diff: check for duplicates/conflicts (read-only, before transaction) - var diffAction string // "added", "updated", "skipped" - var replacedID string - var diffSuggestion search.DiffSuggestion - - // Build embed cache once — reused by diff, engine, and semantic candidates. - var embedCache graph.EmbedCache - if ec.Available() { - dbEmbeds, err := db.GetAllEmbeddings() - if err == nil { - embedCache = make(graph.EmbedCache, len(dbEmbeds)) - for _, e := range dbEmbeds { - if v := embed.DeserializeVector(e.Embedding); v != nil { - embedCache[e.ID] = v - } - } - } - } - - if remNoDiff { - diffAction = "added" - diffSuggestion = search.DiffAdd - } else { - allInsights, err := db.GetAllActiveInsights() - if err != nil { - return fmt.Errorf("load insights for diff: %w", err) - } - - opts := search.DiffOptions{Limit: 5, NewEmbedding: embeddingVec} - if embedCache != nil { - opts.ExistingEmbed = make([]search.EmbeddedItem, 0, len(embedCache)) - for id, v := range embedCache { - opts.ExistingEmbed = append(opts.ExistingEmbed, search.EmbeddedItem{ - ID: id, - Embedding: v, - }) - } - } - - result := search.Diff(allInsights, content, opts) - diffSuggestion = result.Suggestion - - switch result.Suggestion { - case search.DiffDuplicate: - diffAction = "skipped" - if len(result.Matches) > 0 { - replacedID = result.Matches[0].ID - } - case search.DiffConflict: - // A CONFLICT means the two texts appear to disagree. Silently - // soft-deleting one side is destructive and has repeatedly - // clobbered unrelated same-domain memories (long technical - // notes share vocabulary at >=0.7 similarity, and change-log - // words like "replaced"/"no longer" appear in almost all of - // them). Keep both; the caller sees diff_suggestion=CONFLICT - // and can merge or delete deliberately. - diffAction = "added" - case search.DiffUpdate: - // Only auto-replace when the texts overlap heavily by TOKENS. - // Cosine similarity alone (same-domain embeddings cluster at - // 0.85+) is not enough evidence to destroy an existing memory. - if len(result.Matches) > 0 && result.Matches[0].TokenSimilarity >= 0.6 { - diffAction = "updated" - replacedID = result.Matches[0].ID - } else { - diffAction = "added" - } - default: - diffAction = "added" - } - } - - // If duplicate, skip insert entirely - if diffAction == "skipped" { - db.LogOp("diff-skip", insight.ID, fmt.Sprintf("duplicate of %s", replacedID)) - output := map[string]interface{}{ - "id": insight.ID, - "content": content, - "action": "skipped", - "diff_suggestion": string(diffSuggestion), - "replaced_id": replacedID, - } - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - return enc.Encode(output) - } - - // 3. All DB writes in a single atomic transaction - var ( - edgeStats graph.EdgeStats - ei float64 - prunedIDs []string - embedded bool - ) - err = db.InTransaction(func() error { - // Soft-delete old insight if updating - if diffAction == "updated" && replacedID != "" { - if err := db.SoftDeleteInsight(replacedID); err != nil { - fmt.Fprintf(os.Stderr, "warning: soft-delete %s: %v\n", replacedID, err) - } else { - db.LogOp("diff-replace", replacedID, fmt.Sprintf("replaced by %s", insight.ID)) - // Remove deleted insight from embed cache to prevent - // creating edges to a soft-deleted node. - delete(embedCache, replacedID) - } - } - - if err := db.InsertInsight(insight); err != nil { - return fmt.Errorf("insert insight: %w", err) - } - - if embeddingBlob != nil { - if err := db.UpdateEmbedding(insight.ID, embeddingBlob); err != nil { - return fmt.Errorf("update embedding: %w", err) - } - embedded = true - // Add the new insight's embedding to the cache so the engine sees it. - if embedCache != nil { - embedCache[insight.ID] = embeddingVec - } - } - - // Run graph edge engine (includes auto semantic edges when embedded) - engine := graph.NewEngineWithEntityMode(db, embedCache, entityMode) - edgeStats = engine.OnInsightCreated(insight) - - // Update entities extracted by the engine - if len(insight.Entities) > 0 { - if err := db.UpdateEntities(insight.ID, insight.Entities); err != nil { - fmt.Fprintf(os.Stderr, "warning: update entities: %v\n", err) - } - } - - // Compute and store effective_importance (after edges are created) - var eiErr error - ei, eiErr = db.RefreshEffectiveImportance(insight.ID) - if eiErr != nil { - fmt.Fprintf(os.Stderr, "warning: refresh EI: %v\n", eiErr) - } - - // Auto-prune if over capacity (excludeID protects the just-created insight) - var pruneErr error - prunedIDs, pruneErr = db.AutoPruneWithResult(store.MaxInsightsLimit(), []string{insight.ID}, insight.ID) - if pruneErr != nil { - return fmt.Errorf("auto-prune: %w", pruneErr) - } - - db.LogOp("remember", insight.ID, insight.Content) - return nil + result, err := newRuntimeService(os.Stderr).Remember(cmd.Context(), memoryservice.RememberRequest{ + Content: strings.Join(args, " "), Category: remCategory, + Importance: remImportance, Tags: splitMemoryList(remTags), Source: remSource, + Entities: splitMemoryList(remEntities), EntityMode: remEntityMode, NoDiff: remNoDiff, }) if err != nil { - // Cache was mutated inside the transaction closure (delete/add entries). - // On rollback those mutations don't match DB state, so discard the cache - // to prevent any future code from accidentally using stale data. - embedCache = nil return err } - - // 4. Read-only operations outside the transaction (data already committed) - // Note: embedCache may still contain entries for insights pruned by AutoPrune. - // findCandidatesByEmbedding safely filters them via GetInsightByID (deleted_at check). - semanticCandidates := graph.FindSemanticCandidates(db, insight, embedCache) - if semanticCandidates == nil { - semanticCandidates = []graph.SemanticCandidate{} - } - - causalCandidates := graph.FindCausalCandidates(db, insight) - if causalCandidates == nil { - causalCandidates = []graph.CausalCandidate{} - } - - output := map[string]interface{}{ - "id": insight.ID, - "content": insight.Content, - "category": insight.Category, - "importance": insight.Importance, - "tags": insight.Tags, - "entities": insight.Entities, - "action": diffAction, - "diff_suggestion": string(diffSuggestion), - "created_at": insight.CreatedAt.Format(time.RFC3339), - "edges_created": edgeStats, - "semantic_candidates": semanticCandidates, - "causal_candidates": causalCandidates, - "embedded": embedded, - "effective_importance": ei, - "auto_pruned": len(prunedIDs), - "auto_pruned_ids": prunedIDs, - } - if replacedID != "" { - output["replaced_id"] = replacedID - } enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") - return enc.Encode(output) + return enc.Encode(result) }, } +func splitMemoryList(value string) []string { + if value == "" { + return nil + } + return strings.Split(value, ",") +} + func init() { rememberCmd.Flags().StringVar(&remCategory, "cat", "general", "category (preference|decision|fact|insight|context|general)") rememberCmd.Flags().IntVar(&remImportance, "imp", 3, "importance (1-5)") diff --git a/cmd/memory/root.go b/cmd/memory/root.go index 7c3a965e..c71b0de3 100644 --- a/cmd/memory/root.go +++ b/cmd/memory/root.go @@ -2,9 +2,11 @@ package memory import ( "fmt" + "io" "os" "github.com/mnemon-dev/mnemon/internal/memory/embed" + memoryservice "github.com/mnemon-dev/mnemon/internal/memory/service" "github.com/mnemon-dev/mnemon/internal/memory/store" "github.com/spf13/cobra" ) @@ -61,6 +63,24 @@ func resolveEmbedModel() string { return embedModel } +// RuntimeServiceConfig projects the parsed root Memory flags into the shared +// application service. Protocol adapters may disable AuditContent before +// constructing a service so unrestricted client text never enters the oplog. +func RuntimeServiceConfig(warnings io.Writer) memoryservice.Config { + return memoryservice.Config{ + DataDir: dataDir, + StoreName: storeName, + ReadOnly: readOnly, + EmbedModel: resolveEmbedModel(), + Warnings: warnings, + AuditContent: true, + } +} + +func newRuntimeService(warnings io.Writer) *memoryservice.Service { + return memoryservice.New(RuntimeServiceConfig(warnings)) +} + // resolveStoreName returns the effective store name. // Priority: --store flag > MNEMON_STORE env > active file > "default". func resolveStoreName() string { diff --git a/cmd/memory/search.go b/cmd/memory/search.go index ae8e9709..5171e7e0 100644 --- a/cmd/memory/search.go +++ b/cmd/memory/search.go @@ -2,11 +2,10 @@ package memory import ( "encoding/json" - "fmt" "os" "strings" - "github.com/mnemon-dev/mnemon/internal/memory/search" + memoryservice "github.com/mnemon-dev/mnemon/internal/memory/service" "github.com/spf13/cobra" ) @@ -30,25 +29,12 @@ var searchCmd = &cobra.Command{ return err } - db, err := openDB() + results, err := newRuntimeService(os.Stderr).Search(cmd.Context(), memoryservice.SearchRequest{ + Query: query, Limit: searchLimit, + }) if err != nil { - return fmt.Errorf("open database: %w", err) - } - defer db.Close() - - all, err := db.GetAllActiveInsights() - if err != nil { - return fmt.Errorf("get insights: %w", err) - } - - results := search.KeywordSearch(all, query, searchLimit) - - // Increment access count for returned results - for _, r := range results { - _ = db.IncrementAccessCount(r.Insight.ID) + return err } - - db.LogOp("search", "", fmt.Sprintf("q=%s hits=%d", query, len(results))) if searchBrief { brief := make([]briefResult, 0, len(results)) for _, result := range results { diff --git a/cmd/memory/status.go b/cmd/memory/status.go index 8749a609..edfbdc04 100644 --- a/cmd/memory/status.go +++ b/cmd/memory/status.go @@ -2,7 +2,6 @@ package memory import ( "encoding/json" - "fmt" "os" "github.com/spf13/cobra" @@ -13,36 +12,13 @@ var statusCmd = &cobra.Command{ Short: "Show memory statistics", Long: "Display aggregate statistics about stored insights and graph edges.", RunE: func(cmd *cobra.Command, args []string) error { - db, err := openDB() + result, err := newRuntimeService(os.Stderr).Status(cmd.Context()) if err != nil { - return fmt.Errorf("open database: %w", err) - } - defer db.Close() - - stats, err := db.GetStats() - if err != nil { - return fmt.Errorf("get stats: %w", err) - } - - // Get file size - var fileSize int64 - if fi, err := os.Stat(db.Path()); err == nil { - fileSize = fi.Size() - } - - output := map[string]interface{}{ - "total_insights": stats.Total, - "deleted_insights": stats.DeletedCount, - "by_category": stats.ByCategory, - "edge_count": stats.EdgeCount, - "top_entities": stats.TopEntities, - "oplog_count": stats.OplogCount, - "db_path": db.Path(), - "db_size_bytes": fileSize, + return err } enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") - return enc.Encode(output) + return enc.Encode(result) }, } diff --git a/cmd/root.go b/cmd/root.go index cd4e22c3..86b2a78a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -7,6 +7,7 @@ import ( "io" "github.com/mnemon-dev/mnemon/cmd/agency" + mcpcmd "github.com/mnemon-dev/mnemon/cmd/mcp" "github.com/mnemon-dev/mnemon/cmd/memory" "github.com/spf13/cobra" ) @@ -41,7 +42,8 @@ func Execute(ctx context.Context, args []string, stdin io.Reader, stdout, stderr if err == nil { return 0 } - if findErr == nil && !agencyRequest && !belongsToAgency(executed) && executed != nil { + if findErr == nil && !agencyRequest && !belongsToAgency(executed) && + !belongsToMCP(executed) && executed != nil { _, _ = fmt.Fprintln(stderr, executed.UsageString()) } if err.Error() != "" { @@ -56,6 +58,15 @@ func Execute(ctx context.Context, args []string, stdin io.Reader, stdout, stderr return 1 } +func belongsToMCP(command *cobra.Command) bool { + for current := command; current != nil; current = current.Parent() { + if current.Name() == "mcp" { + return true + } + } + return false +} + func belongsToAgency(command *cobra.Command) bool { for current := command; current != nil; current = current.Parent() { if current.Name() == "agency" { @@ -75,10 +86,10 @@ func productRoot() *cobra.Command { // so focused tests can construct the product root more than once without // changing the production command set. for _, child := range root.Commands() { - if child.Name() == "agency" { + if child.Name() == "agency" || child.Name() == "mcp" { root.RemoveCommand(child) } } - root.AddCommand(agency.New(version)) + root.AddCommand(agency.New(version), mcpcmd.New(version, memory.RuntimeServiceConfig)) return root } diff --git a/cmd/root_test.go b/cmd/root_test.go index df5c4b57..b3467bf5 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -9,7 +9,7 @@ import ( func TestRootComposesMemoryAndAgency(t *testing.T) { root := productRoot() - for _, name := range []string{"remember", "recall", "setup", "agency"} { + for _, name := range []string{"remember", "recall", "setup", "agency", "mcp"} { child, _, err := root.Find([]string{name}) if err != nil || child == root { t.Fatalf("root command %q is not registered", name) @@ -19,6 +19,10 @@ func TestRootComposesMemoryAndAgency(t *testing.T) { if err != nil || command.CommandPath() != "mnemon agency peer prepare" { t.Fatalf("Agency subtree is not composed into the product root: %v", err) } + command, _, err = root.Find([]string{"mcp", "serve"}) + if err != nil || command.CommandPath() != "mnemon mcp serve" { + t.Fatalf("MCP subtree is not composed into the product root: %v", err) + } } func TestExecuteRoutesAgencyWithoutChangingItsExitCode(t *testing.T) { diff --git a/docs/USAGE.md b/docs/USAGE.md index d2a70bd0..41dc284e 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -1,6 +1,6 @@ # Mnemon Memory — Usage & Reference -> You don't run Memory commands yourself — the agent does, driven by hooks and guided by the skill file. This document covers the root Memory CLI for understanding what the agent can do, debugging, and advanced manual operation. For durable Agent work and peer collaboration, see the [Agency Preview guide](AGENCY.md). +> You don't run Memory commands yourself — the agent does, driven by hooks, skills, or the built-in MCP adapter. This document covers the root Memory CLI and MCP server for understanding what the agent can do, debugging, and advanced manual operation. For durable Agent work and peer collaboration, see the [Agency Preview guide](AGENCY.md). --- @@ -26,6 +26,64 @@ the read-only SQLite file URI internally; do not prepend `file:` yourself. --- +## MCP Server + +`mnemon mcp serve` exposes the same Memory service as six schema-described MCP +tools over standard input/output. It starts no network listener and serves one +client connection for the life of the process. + +For MCP hosts that use the conventional `mcpServers` configuration shape: + +```json +{ + "mcpServers": { + "mnemon": { + "command": "mnemon", + "args": ["mcp", "serve"] + } + } +} +``` + +Product-level Memory flags must precede the `mcp` namespace. For example, this +selects an isolated store: + +```json +{ + "command": "mnemon", + "args": ["--store", "work", "mcp", "serve"] +} +``` + +`MNEMON_DATA_DIR`, `MNEMON_STORE`, and embedding environment variables are also +honored. With `--readonly`, `recall`, `search`, `related`, and `status` remain +available, while `remember` and `link` fail closed. Stdout is reserved for MCP +JSON-RPC frames; startup or Memory diagnostics go to stderr. + +| Tool | Purpose | Main inputs and defaults | +|---|---|---| +| `recall` | Intent-aware graph recall or basic substring matching | `query` required; `limit: 10`; optional `basic`, `intent`, `category`, `source`, `full` | +| `search` | Token-scored keyword search | `query` required; `limit: 10`; optional `full` | +| `remember` | Store one durable insight with diff and graph processing | `content` required; `category: general`; `importance: 3`; optional tags, entities, source, entity mode, and `no_diff` | +| `related` | Traverse typed graph edges from one insight | `id` required; `depth: 2`; `limit: 20`; optional `edge_type`, `full` | +| `link` | Create or replace a bidirectional typed relationship | `source_id` and `target_id` required; `edge_type: semantic`; `weight: 0.5`; optional metadata | +| `status` | Report aggregate statistics for the selected store | No inputs | + +Discovery responses from `recall`, `search`, and `related` truncate each stored +content value to 600 Unicode characters by default and explicitly mark truncated +items. Pass `full: true` to the same tool only after selecting content that needs +full inspection. Result limits are 1–100, graph depth is 1–10, queries are capped +at 2,000 Unicode characters, IDs at 256, and remembered content at 8,000 bytes. +`remember` accepts at most 20 tags of 100 characters and 50 entities of 200 +characters; `source` is capped at 100 characters. `link` accepts at most 20 +metadata entries, with 100-character keys and 1,000-character values. These +limits keep tool calls and model context bounded. Input schemas publish the +client-visible bounds, defaults, and enums; handlers repeat fail-closed +validation, including byte-based limits. Tool annotations identify read-only, +destructive, and idempotent behavior. + +--- + ## Memory Setup Deploy mnemon into LLM CLI environments. This is the first command to run after installation. diff --git a/docs/zh/README.md b/docs/zh/README.md index da1b0d67..0b9d2911 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -19,10 +19,10 @@ LLM 智能体在会话之间会遗忘一切。上下文压缩丢失关键决策 Mnemon 为你的 LLM 提供持久的跨会话记忆 — 四图知识存储、意图感知检索、重要度衰减、自动去重。`mnemon` 记忆路径仍是一个本地二进制,零 API 密钥,一条命令完成部署。 -Mnemon 只发布一个 `mnemon` 可执行文件,同时提供两套相互独立的能力:根级 -Memory 命令保存跨会话知识;Preview 阶段的 `mnemon agency ...` 为项目内 Agent -提供持久、受约束的协作状态。Agency 以 Pi 为首个 Runtime 集成,详情见 -[Agency 指南](AGENCY.md)。 +Mnemon 只发布一个 `mnemon` 可执行文件,同时提供三个入口:根级 Memory 命令 +保存跨会话知识;`mnemon mcp serve` 向 MCP 客户端开放同一 Memory 引擎;Preview +阶段的 `mnemon agency ...` 为项目内 Agent 提供持久、受约束的协作状态。MCP +与 Agency 都不会替代 Memory 或 Agent Runtime。Agency 详情见 [Agency 指南](AGENCY.md)。 > **Claude Max / Pro 订阅用户?** Mnemon 完全通过你现有的订阅运作——不需要额外的 API 密钥。你的 LLM 订阅*本身*就是智能层。两条命令即可完成。 @@ -37,6 +37,9 @@ Memory 命令保存跨会话知识;Preview 阶段的 `mnemon agency ...` 为 | **MCP Server** | 通过 MCP 协议提供工具 | claude-mem | | **LLM-Supervised** | 独立二进制的外部监督者 | **Mnemon** | +Mnemon 内置的 MCP 服务只是同一套确定性、LLM 监督式引擎的传输适配器;它不会 +内嵌另一个 LLM,也不需要额外 API 密钥。 + Mnemon 同时填补了协议栈中的空白。MCP 标准化了 LLM 如何发现和调用工具,ODBC/JDBC 标准化了应用如何访问数据库,但 LLM 以记忆语义与数据库交互——这一层尚无协议。Mnemon 的三个原语——`remember`、`link`、`recall`——构成一个意图原生协议:命令名称映射到 LLM 的认知词汇(`remember` 而非 INSERT,`recall` 而非 SELECT),输出是带有信号透明度的结构化 JSON,而非原始数据库行。
@@ -71,7 +74,7 @@ brew install --cask mnemon-dev/tap/mnemon go install github.com/mnemon-dev/mnemon@latest ``` -Windows 支持核心 Memory 命令。Agency 的本地权威边界完成原生 Windows +Windows 支持核心 Memory 命令和 MCP 服务。Agency 的本地权威边界完成原生 Windows 安全实现前,在 Windows 上保持不可用。 **从源码构建**(macOS / Linux): @@ -85,6 +88,7 @@ make install ```bash mnemon --version +mnemon mcp --help mnemon agency --version ``` @@ -99,6 +103,28 @@ Memory 保持独立:`mnemon setup --target pi --yes` 启用 Memory,以上命 Agency。当前成熟度与兼容边界、工作方式及可选 peer 配置见 [Agency 指南](AGENCY.md)。 +### MCP 客户端 + +任何支持 stdio 的 MCP 客户端都能通过同一个二进制使用 Mnemon 的六个 Memory +工具。对于采用常见 `mcpServers` 配置格式的客户端,加入: + +```json +{ + "mcpServers": { + "mnemon": { + "command": "mnemon", + "args": ["mcp", "serve"] + } + } +} +``` + +如需选择 store,把根标志放在命名空间之前,例如 +`["--store", "work", "mcp", "serve"]`,也可设置 `MNEMON_STORE`。服务提供 +`recall`、`search`、`remember`、`related`、`link`、`status`。读取结果默认把 +每条记忆限制为 600 个 Unicode 字符;需要全文时客户端可传 `full: true`。全部 +限制和选项见 [MCP 参考](USAGE.md#mcp-server)。 + ### [Claude Code](https://github.com/anthropics/claude-code) ```bash @@ -306,7 +332,8 @@ store 可见。**Remind** 触发 recall 判断。**Nudge** 触发 writeback 判 - **零用户操作** — 安装一次;支持 hook 的 runtime 可用 hook,minimal runtime 可用持久规则 - **LLM 监督式** — 宿主 LLM 主动决定记什么、更新什么、遗忘什么;无内嵌 LLM,无 API 密钥 -- **多框架支持** — Claude Code、Codex、Cursor、ZCode、TRAE/TRAE Work、Qoder/QoderWork、CodeBuddy、WorkBuddy、Kimi Code、OpenCode 和 Hermes Agent(hooks/plugins)、OpenClaw(plugins)、Pi(extensions)、MiniMax Code 和 Nanobot(skills)、DeepSeek Harness(通过 dsh-mnemon 插件)等 +- **内置 MCP 服务** — 一条 stdio 命令向 MCP 客户端开放六个有界、带 schema 的 Memory 工具 +- **多框架支持** — Claude Code、Codex、Cursor、ZCode、TRAE/TRAE Work、Qoder/QoderWork、CodeBuddy、WorkBuddy、Kimi Code、OpenCode 和 Hermes Agent(hooks/plugins)、OpenClaw(plugins)、Pi(extensions)、MiniMax Code 和 Nanobot(skills)、DeepSeek Harness(通过 dsh-mnemon 插件)、任意 stdio MCP 客户端等 - **Runtime 原生集成** — 各 runtime 的 `SKILL.md`、共享 `guide.md`,以及受支持的 hook 或 extension - **四图架构** — 时序、实体、因果、语义四种边,不仅仅是向量相似度 - **意图原生协议** — 三个原语(`remember`、`link`、`recall`)映射到 LLM 的认知词汇而非数据库语法;结构化 JSON 输出,带信号透明度 @@ -432,7 +459,7 @@ mnemon setup --eject # 移除所有集成 make help # 显示所有目标 ``` -**依赖**:Go 1.24+、`modernc.org/sqlite`、`spf13/cobra`、`google/uuid` +**依赖**:Go 1.24+、`modernc.org/sqlite`、`spf13/cobra`、`google/uuid`、`modelcontextprotocol/go-sdk` **可选**:[Ollama](https://ollama.ai) 或 OpenAI 兼容的嵌入服务器 @@ -441,7 +468,7 @@ make help # 显示所有目标 - [Agency Preview 指南](AGENCY.md) — 成熟度边界、Pi 设置、View → Intent → Receipt 与可选 peer 协作 - [Go 工程规范](../development/go-engineering-standard.md) — 可维护性、并发、持久化、测试与质量 ratchet - [设计与架构](DESIGN.md) — 当前 engine architecture、核心概念、算法、集成设计 -- [Memory 用法与参考](USAGE.md) — 根级 Memory 命令、导入、回执与嵌入向量支持 +- [Memory 与 MCP 用法参考](USAGE.md) — 根级 Memory 命令、内置 MCP 服务、导入、回执与嵌入向量支持 - [记忆导入指南](IMPORT.md) — 导入历史聊天的 schema 与 LLM 提取提示词 - [架构图](../diagrams/) — 系统架构、记忆/召回流程、四图模型、生命周期管理 diff --git a/docs/zh/USAGE.md b/docs/zh/USAGE.md index eb3e1f76..89fc3d25 100644 --- a/docs/zh/USAGE.md +++ b/docs/zh/USAGE.md @@ -1,6 +1,6 @@ # Mnemon Memory — 用法与参考 -> 你不需要自己运行 Memory 命令 — agent 会在 Hook 和 Skill 指引下执行。本文档只介绍根命名空间下的 Memory CLI,供理解能力、调试和高级手动操作使用。持久 Agent 工作与 Peer 协作请参阅 [Agency Preview 指南](AGENCY.md)。 +> 你不需要自己运行 Memory 命令 — agent 会在 Hook、Skill 或内置 MCP 适配器的指引下执行。本文档介绍根命名空间下的 Memory CLI 与 MCP 服务,供理解能力、调试和高级手动操作使用。持久 Agent 工作与 Peer 协作请参阅 [Agency Preview 指南](AGENCY.md)。 --- @@ -24,6 +24,60 @@ Mnemon 会在内部解析并编码只读 SQLite 文件 URI,无需手动添加 --- +## MCP Server + +`mnemon mcp serve` 通过标准输入/输出,把同一套 Memory 服务开放为六个带 schema +的 MCP 工具。它不会启动网络监听;每个进程在生命周期内服务一个客户端连接。 + +对于采用常见 `mcpServers` 配置格式的 MCP host: + +```json +{ + "mcpServers": { + "mnemon": { + "command": "mnemon", + "args": ["mcp", "serve"] + } + } +} +``` + +产品级 Memory 标志必须放在 `mcp` 命名空间之前。例如,以下配置选择隔离的 +store: + +```json +{ + "command": "mnemon", + "args": ["--store", "work", "mcp", "serve"] +} +``` + +服务同样读取 `MNEMON_DATA_DIR`、`MNEMON_STORE` 和嵌入相关环境变量。使用 +`--readonly` 时,`recall`、`search`、`related`、`status` 仍可用,`remember` +和 `link` 则会 fail closed。stdout 只输出 MCP JSON-RPC 帧;启动或 Memory +诊断信息写入 stderr。 + +| 工具 | 用途 | 主要输入与默认值 | +|---|---|---| +| `recall` | 意图感知图召回或基础子串匹配 | 必填 `query`;`limit: 10`;可选 `basic`、`intent`、`category`、`source`、`full` | +| `search` | 基于 token 评分的关键词搜索 | 必填 `query`;`limit: 10`;可选 `full` | +| `remember` | 通过 diff 与图处理存储一条持久洞察 | 必填 `content`;`category: general`;`importance: 3`;可选标签、实体、来源、实体模式及 `no_diff` | +| `related` | 从一条洞察出发遍历类型化图边 | 必填 `id`;`depth: 2`;`limit: 20`;可选 `edge_type`、`full` | +| `link` | 创建或替换双向类型化关系 | 必填 `source_id`、`target_id`;`edge_type: semantic`;`weight: 0.5`;可选 metadata | +| `status` | 返回当前 store 的汇总统计 | 无输入 | + +`recall`、`search`、`related` 的发现结果默认把每条存储内容截断为 600 个 +Unicode 字符,并明确标记被截断的条目。选中确实需要完整查看的内容后,再对同一 +工具传 `full: true`。结果数范围为 1–100,图深度为 1–10,query 最多 2,000 +个 Unicode 字符,ID 最多 256 个字符,记忆内容最多 8,000 字节。`remember` +最多接受 20 个 100 字符的 tag 和 50 个 200 字符的 entity,`source` 最多 100 +字符;`link` 最多接受 20 个 metadata 条目,key 最多 100 字符,value 最多 +1,000 字符。这些限制用于约束工具调用和模型上下文。Input schema 会发布客户端 +可见的边界、默认值和枚举;handler 继续执行 fail-closed 校验,包括字节数限制。 +Tool annotation 则标识只读、破坏性和幂等行为。 + +--- + ## Memory 设置 将 mnemon 部署到 LLM CLI 环境中。安装后首先运行此命令。 diff --git a/go.mod b/go.mod index 59650f87..db03f6ec 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,10 @@ module github.com/mnemon-dev/mnemon go 1.24.6 require ( + github.com/google/jsonschema-go v0.4.2 github.com/google/uuid v1.6.0 github.com/mattn/go-isatty v0.0.20 + github.com/modelcontextprotocol/go-sdk v1.3.1 github.com/spf13/cobra v1.10.2 go.yaml.in/yaml/v3 v3.0.4 golang.org/x/sys v0.41.0 @@ -19,8 +21,12 @@ require ( github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rogpeppe/go-internal v1.10.0 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.3 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect + golang.org/x/oauth2 v0.30.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index 033031a1..2de6fd1d 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,12 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -19,6 +25,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modelcontextprotocol/go-sdk v1.3.1 h1:TfqtNKOIWN4Z1oqmPAiWDC2Jq7K9OdJaooe0teoXASI= +github.com/modelcontextprotocol/go-sdk v1.3.1/go.mod h1:DgVX498dMD8UJlseK1S5i1T4tFz2fkBk4xogC3D15nw= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= @@ -28,16 +36,24 @@ github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/f github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w= +github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/mcp/input_schema.go b/internal/mcp/input_schema.go new file mode 100644 index 00000000..00d22eb5 --- /dev/null +++ b/internal/mcp/input_schema.go @@ -0,0 +1,112 @@ +package mcp + +import ( + "encoding/json" + "strconv" + + "github.com/google/jsonschema-go/jsonschema" +) + +const ( + maxRememberBytes = 8000 + maxTags = 20 + maxTagChars = 100 + maxEntities = 50 + maxEntityChars = 200 + maxMetadata = 20 + maxMetadataKey = 100 + maxMetadataValue = 1000 + defaultImportance = 3 + defaultDepth = 2 + defaultWeight = 0.5 +) + +func recallInputSchema() *jsonschema.Schema { + return mustInputSchema[recallInput](func(properties map[string]*jsonschema.Schema) { + boundedString(properties["query"], 1, maxQueryChars) + boundedInteger(properties["limit"], 1, maxToolResults, defaultLimit) + properties["category"].Enum = stringEnum("preference", "decision", "fact", "insight", "context", "general") + properties["source"].MaxLength = jsonschema.Ptr(maxSourceChars) + properties["intent"].Enum = stringEnum("WHY", "WHEN", "ENTITY", "GENERAL") + }) +} + +func searchInputSchema() *jsonschema.Schema { + return mustInputSchema[searchInput](func(properties map[string]*jsonschema.Schema) { + boundedString(properties["query"], 1, maxQueryChars) + boundedInteger(properties["limit"], 1, maxToolResults, defaultLimit) + }) +} + +func relatedInputSchema() *jsonschema.Schema { + return mustInputSchema[relatedInput](func(properties map[string]*jsonschema.Schema) { + boundedString(properties["id"], 1, maxIDChars) + properties["edge_type"].Enum = stringEnum("temporal", "semantic", "causal", "entity") + boundedInteger(properties["depth"], 1, maxRelatedDepth, defaultDepth) + boundedInteger(properties["limit"], 1, maxToolResults, defaultRelatedLimit) + }) +} + +func rememberInputSchema() *jsonschema.Schema { + return mustInputSchema[rememberInput](func(properties map[string]*jsonschema.Schema) { + boundedString(properties["content"], 1, maxRememberBytes) + properties["category"].Enum = stringEnum("preference", "decision", "fact", "insight", "context", "general") + properties["category"].Default = json.RawMessage(`"general"`) + boundedInteger(properties["importance"], 1, 5, defaultImportance) + boundedStringArray(properties["tags"], maxTags, maxTagChars) + properties["source"].MaxLength = jsonschema.Ptr(maxSourceChars) + properties["source"].Default = json.RawMessage(`"user"`) + boundedStringArray(properties["entities"], maxEntities, maxEntityChars) + properties["entity_mode"].Enum = stringEnum("merge", "provided", "auto") + properties["entity_mode"].Default = json.RawMessage(`"merge"`) + }) +} + +func linkInputSchema() *jsonschema.Schema { + return mustInputSchema[linkInput](func(properties map[string]*jsonschema.Schema) { + boundedString(properties["source_id"], 1, maxIDChars) + boundedString(properties["target_id"], 1, maxIDChars) + properties["edge_type"].Enum = stringEnum("temporal", "semantic", "causal", "entity") + properties["edge_type"].Default = json.RawMessage(`"semantic"`) + properties["weight"].Minimum = jsonschema.Ptr(0.0) + properties["weight"].Maximum = jsonschema.Ptr(1.0) + properties["weight"].Default = json.RawMessage(`0.5`) + metadata := properties["metadata"] + metadata.MaxProperties = jsonschema.Ptr(maxMetadata) + metadata.PropertyNames = &jsonschema.Schema{Type: "string", MaxLength: jsonschema.Ptr(maxMetadataKey)} + metadata.AdditionalProperties.MaxLength = jsonschema.Ptr(maxMetadataValue) + }) +} + +func mustInputSchema[T any](configure func(map[string]*jsonschema.Schema)) *jsonschema.Schema { + schema, err := jsonschema.For[T](nil) + if err != nil { + panic(err) + } + configure(schema.Properties) + return schema +} + +func boundedString(schema *jsonschema.Schema, minimum, maximum int) { + schema.MinLength = jsonschema.Ptr(minimum) + schema.MaxLength = jsonschema.Ptr(maximum) +} + +func boundedInteger(schema *jsonschema.Schema, minimum, maximum, defaultValue int) { + schema.Minimum = jsonschema.Ptr(float64(minimum)) + schema.Maximum = jsonschema.Ptr(float64(maximum)) + schema.Default = json.RawMessage(strconv.Itoa(defaultValue)) +} + +func boundedStringArray(schema *jsonschema.Schema, maxItems, maxItemChars int) { + schema.MaxItems = jsonschema.Ptr(maxItems) + schema.Items.MaxLength = jsonschema.Ptr(maxItemChars) +} + +func stringEnum(values ...string) []any { + result := make([]any, len(values)) + for index, value := range values { + result[index] = value + } + return result +} diff --git a/internal/mcp/projection.go b/internal/mcp/projection.go new file mode 100644 index 00000000..bb576094 --- /dev/null +++ b/internal/mcp/projection.go @@ -0,0 +1,190 @@ +package mcp + +import ( + "fmt" + "math" + "strings" + "unicode/utf8" + + "github.com/mnemon-dev/mnemon/internal/memory/model" + "github.com/mnemon-dev/mnemon/internal/memory/search" + memoryservice "github.com/mnemon-dev/mnemon/internal/memory/service" + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" +) + +const ( + defaultLimit = 10 + defaultRelatedLimit = 20 + maxToolResults = 100 + maxRelatedDepth = 10 + maxQueryChars = 2000 + maxIDChars = 256 + maxSourceChars = 100 + defaultContentChars = 600 + confidenceLowMax = 0.25 + confidenceMediumMax = 0.6 + truncationInstruction = "Some content was truncated to 600 characters. Call the same tool with full=true to retrieve complete content." +) + +type insightResult struct { + ID string `json:"id"` + Content string `json:"content"` + Category string `json:"category,omitempty"` + Importance int `json:"importance,omitempty"` + Tags []string `json:"tags,omitempty"` + Intent string `json:"intent,omitempty"` + MatchedVia string `json:"matched_via,omitempty"` + Confidence string `json:"confidence,omitempty"` + Score float64 `json:"score,omitempty"` + Depth int `json:"depth,omitempty"` + EdgeType string `json:"via_edge_type,omitempty"` + Truncated bool `json:"truncated,omitempty"` +} + +type insightListResult struct { + Results []insightResult `json:"results"` + Hint string `json:"hint,omitempty"` + TruncationHint string `json:"truncation_hint,omitempty"` +} + +func projectRecall(response memoryservice.RecallResponse, full bool) insightListResult { + if response.SmartResults != nil { + return projectSmartRecall(*response.SmartResults, full) + } + results := make([]insightResult, 0, len(response.BasicResults)) + truncated := false + for _, insight := range response.BasicResults { + content, cut := projectContent(insight.Content, full) + truncated = truncated || cut + results = append(results, insightResult{ + ID: insight.ID, Content: content, Category: string(insight.Category), + Importance: insight.Importance, Tags: insight.Tags, Truncated: cut, + }) + } + return newInsightList(results, "", truncated) +} + +func projectSmartRecall(response search.RecallResponse, full bool) insightListResult { + results := make([]insightResult, 0, len(response.Results)) + truncated := false + for _, result := range response.Results { + content, cut := projectContent(result.Insight.Content, full) + truncated = truncated || cut + score := roundScore(result.Score) + results = append(results, insightResult{ + ID: result.Insight.ID, Content: content, + Category: string(result.Insight.Category), Importance: result.Insight.Importance, + Intent: string(result.Intent), MatchedVia: result.Via, + Confidence: confidenceLabel(score), Score: score, Truncated: cut, + }) + } + return newInsightList(results, response.Meta.Hint, truncated) +} + +func projectSearch(results []search.ScoredInsight, full bool) insightListResult { + projected := make([]insightResult, 0, len(results)) + truncated := false + for _, result := range results { + content, cut := projectContent(result.Insight.Content, full) + truncated = truncated || cut + projected = append(projected, insightResult{ + ID: result.Insight.ID, Content: content, + Category: string(result.Insight.Category), Importance: result.Insight.Importance, + Tags: result.Insight.Tags, Score: roundScore(result.Score), Truncated: cut, + }) + } + return newInsightList(projected, "", truncated) +} + +func projectRelated(results []memoryservice.RelatedResult, full bool) insightListResult { + projected := make([]insightResult, 0, len(results)) + truncated := false + for _, result := range results { + content, cut := projectContent(result.Content, full) + truncated = truncated || cut + projected = append(projected, insightResult{ + ID: result.ID, Content: content, Category: result.Category, + Importance: result.Importance, Depth: result.Depth, + EdgeType: result.EdgeType, Truncated: cut, + }) + } + return newInsightList(projected, "", truncated) +} + +func newInsightList(results []insightResult, hint string, truncated bool) insightListResult { + if results == nil { + results = []insightResult{} + } + output := insightListResult{Results: results, Hint: hint} + if truncated { + output.TruncationHint = truncationInstruction + } + return output +} + +func projectContent(content string, full bool) (string, bool) { + if full || utf8.RuneCountInString(content) <= defaultContentChars { + return content, false + } + runes := []rune(content) + return strings.TrimSpace(string(runes[:defaultContentChars-1])) + "…", true +} + +func boundedLimit(value *int, fallback int) (int, error) { + if value == nil { + return fallback, nil + } + if *value < 1 || *value > maxToolResults { + return 0, fmt.Errorf("limit must be between 1 and %d", maxToolResults) + } + return *value, nil +} + +func boundedDepth(value *int) (int, error) { + if value == nil { + return defaultDepth, nil + } + if *value < 1 || *value > maxRelatedDepth { + return 0, fmt.Errorf("depth must be between 1 and %d", maxRelatedDepth) + } + return *value, nil +} + +func validateText(name, value string, maxChars int) error { + count := utf8.RuneCountInString(value) + if strings.TrimSpace(value) == "" { + return fmt.Errorf("%s must not be empty", name) + } + if count > maxChars { + return fmt.Errorf("%s is too long (%d characters, max %d)", name, count, maxChars) + } + return nil +} + +func roundScore(score float64) float64 { + return math.Round(score*1000) / 1000 +} + +func confidenceLabel(score float64) string { + switch { + case score < confidenceLowMax: + return "low" + case score < confidenceMediumMax: + return "medium" + default: + return "high" + } +} + +func toolAnnotations(readOnly, destructive, idempotent bool) *sdkmcp.ToolAnnotations { + return &sdkmcp.ToolAnnotations{ + ReadOnlyHint: readOnly, DestructiveHint: boolPointer(destructive), + IdempotentHint: idempotent, OpenWorldHint: boolPointer(false), + } +} + +func boolPointer(value bool) *bool { return &value } + +func validCategory(category string) bool { + return category == "" || model.ValidCategories[model.Category(category)] +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go new file mode 100644 index 00000000..4457e629 --- /dev/null +++ b/internal/mcp/server.go @@ -0,0 +1,67 @@ +// Package mcp exposes Mnemon Memory through the Model Context Protocol. +package mcp + +import ( + "context" + "fmt" + "io" + + "github.com/mnemon-dev/mnemon/internal/memory/search" + memoryservice "github.com/mnemon-dev/mnemon/internal/memory/service" + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" +) + +const serverInstructions = "Mnemon provides shared, durable memory. Recall, search, and related results are truncated by default; pass full=true only when complete stored content is needed." + +// Memory is the application service consumed by the MCP adapter. +type Memory interface { + Recall(context.Context, memoryservice.RecallRequest) (memoryservice.RecallResponse, error) + Search(context.Context, memoryservice.SearchRequest) ([]search.ScoredInsight, error) + Remember(context.Context, memoryservice.RememberRequest) (memoryservice.RememberResult, error) + Related(context.Context, memoryservice.RelatedRequest) ([]memoryservice.RelatedResult, error) + Link(context.Context, memoryservice.LinkRequest) (memoryservice.LinkResult, error) + Status(context.Context) (memoryservice.StatusResult, error) +} + +// Server owns one MCP server and its Memory application service. +type Server struct { + memory Memory + sdk *sdkmcp.Server +} + +// New constructs a tool-only Mnemon MCP server. +func New(version string, memory Memory) *Server { + server := &Server{memory: memory} + server.sdk = sdkmcp.NewServer( + &sdkmcp.Implementation{Name: "mnemon", Version: version}, + &sdkmcp.ServerOptions{Instructions: serverInstructions}, + ) + server.registerReadTools() + server.registerWriteTools() + return server +} + +// Serve runs one MCP stdio session until the client disconnects or ctx is +// canceled. Only protocol frames are written to stdout. +func (s *Server) Serve(ctx context.Context, stdin io.Reader, stdout io.Writer) error { + if ctx == nil { + return fmt.Errorf("serve MCP: context must not be nil") + } + if stdin == nil { + return fmt.Errorf("serve MCP: stdin must not be nil") + } + if stdout == nil { + return fmt.Errorf("serve MCP: stdout must not be nil") + } + transport := &sdkmcp.IOTransport{ + Reader: io.NopCloser(stdin), + Writer: nopWriteCloser{Writer: stdout}, + } + return s.sdk.Run(ctx, transport) +} + +type nopWriteCloser struct { + io.Writer +} + +func (nopWriteCloser) Close() error { return nil } diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go new file mode 100644 index 00000000..72385f89 --- /dev/null +++ b/internal/mcp/server_test.go @@ -0,0 +1,283 @@ +package mcp + +import ( + "context" + "encoding/json" + "io" + "reflect" + "slices" + "strings" + "testing" + "time" + "unicode/utf8" + + memoryservice "github.com/mnemon-dev/mnemon/internal/memory/service" + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func newTestMemory(t *testing.T) *memoryservice.Service { + t.Helper() + t.Setenv("MNEMON_EMBED_ENDPOINT", "http://127.0.0.1:1") + return memoryservice.New(memoryservice.Config{ + DataDir: t.TempDir(), StoreName: "mcp-test", Warnings: io.Discard, + }) +} + +func connectTestClient(t *testing.T, server *Server) *sdkmcp.ClientSession { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + clientTransport, serverTransport := sdkmcp.NewInMemoryTransports() + serverSession, err := server.sdk.Connect(ctx, serverTransport, nil) + if err != nil { + cancel() + t.Fatalf("connect server: %v", err) + } + client := sdkmcp.NewClient(&sdkmcp.Implementation{Name: "mnemon-test", Version: "test"}, nil) + clientSession, err := client.Connect(ctx, clientTransport, nil) + if err != nil { + _ = serverSession.Close() + cancel() + t.Fatalf("connect client: %v", err) + } + t.Cleanup(func() { + _ = clientSession.Close() + _ = serverSession.Wait() + cancel() + }) + return clientSession +} + +func callStructured[T any](t *testing.T, session *sdkmcp.ClientSession, + name string, arguments map[string]any) (T, *sdkmcp.CallToolResult) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + result, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: name, Arguments: arguments}) + if err != nil { + t.Fatalf("call %s: %v", name, err) + } + if result.IsError { + t.Fatalf("call %s returned tool error: %#v", name, result.Content) + } + encoded, err := json.Marshal(result.StructuredContent) + if err != nil { + t.Fatalf("marshal %s structured result: %v", name, err) + } + var output T + if err := json.Unmarshal(encoded, &output); err != nil { + t.Fatalf("decode %s structured result: %v\n%s", name, err, encoded) + } + return output, result +} + +func TestServerAdvertisesSixBoundedTools(t *testing.T) { + session := connectTestClient(t, New("test-version", newTestMemory(t))) + result, err := session.ListTools(context.Background(), nil) + if err != nil { + t.Fatal(err) + } + names := make([]string, 0, len(result.Tools)) + byName := make(map[string]*sdkmcp.Tool, len(result.Tools)) + for _, tool := range result.Tools { + names = append(names, tool.Name) + byName[tool.Name] = tool + } + slices.Sort(names) + want := []string{"link", "recall", "related", "remember", "search", "status"} + if !reflect.DeepEqual(names, want) { + t.Fatalf("tools = %v, want %v", names, want) + } + for _, name := range []string{"recall", "search", "related", "status"} { + annotations := byName[name].Annotations + if annotations == nil || !annotations.ReadOnlyHint || annotations.OpenWorldHint == nil || *annotations.OpenWorldHint { + t.Errorf("%s annotations = %#v", name, annotations) + } + } + for _, name := range []string{"remember", "link"} { + annotations := byName[name].Annotations + if annotations == nil || annotations.ReadOnlyHint || annotations.DestructiveHint == nil || !*annotations.DestructiveHint { + t.Errorf("%s annotations = %#v", name, annotations) + } + } + recallSchema, ok := byName["recall"].InputSchema.(map[string]any) + if !ok { + t.Fatalf("recall schema type = %T", byName["recall"].InputSchema) + } + required, _ := recallSchema["required"].([]any) + if !slices.Contains(required, any("query")) { + t.Fatalf("recall required fields = %#v", required) + } + recallProperties, ok := recallSchema["properties"].(map[string]any) + if !ok { + t.Fatalf("recall properties = %#v", recallSchema["properties"]) + } + assertSchemaNumber(t, recallProperties, "query", "maxLength", maxQueryChars) + assertSchemaNumber(t, recallProperties, "limit", "minimum", 1) + assertSchemaNumber(t, recallProperties, "limit", "maximum", maxToolResults) + + rememberSchema, ok := byName["remember"].InputSchema.(map[string]any) + if !ok { + t.Fatalf("remember schema type = %T", byName["remember"].InputSchema) + } + rememberProperties, ok := rememberSchema["properties"].(map[string]any) + if !ok { + t.Fatalf("remember properties = %#v", rememberSchema["properties"]) + } + assertSchemaNumber(t, rememberProperties, "content", "maxLength", maxRememberBytes) + assertSchemaNumber(t, rememberProperties, "importance", "maximum", 5) +} + +func TestServerMemoryWorkflowAndDefaultTruncation(t *testing.T) { + session := connectTestClient(t, New("test-version", newTestMemory(t))) + longContent := "durable marker " + strings.Repeat("记忆content ", 90) + first, _ := callStructured[memoryservice.RememberResult](t, session, "remember", map[string]any{ + "content": longContent, "category": "fact", "no_diff": true, + }) + second, _ := callStructured[memoryservice.RememberResult](t, session, "remember", map[string]any{ + "content": "A release review depends on the durable marker", "category": "decision", "no_diff": true, + }) + if first.ID == "" || second.ID == "" || first.ID == second.ID { + t.Fatalf("remember IDs = %q, %q", first.ID, second.ID) + } + + link, _ := callStructured[memoryservice.LinkResult](t, session, "link", map[string]any{ + "source_id": first.ID, "target_id": second.ID, "edge_type": "causal", "weight": 0.8, + }) + if link.Metadata["created_by"] != "mcp" { + t.Fatalf("link metadata = %#v", link.Metadata) + } + related, _ := callStructured[insightListResult](t, session, "related", map[string]any{ + "id": first.ID, "edge_type": "causal", "depth": 1, + }) + if len(related.Results) != 1 || related.Results[0].ID != second.ID { + t.Fatalf("related = %#v", related) + } + + recall, _ := callStructured[insightListResult](t, session, "recall", map[string]any{ + "query": "durable marker", "basic": true, + }) + firstRecall := resultByID(t, recall.Results, first.ID) + if !firstRecall.Truncated || utf8.RuneCountInString(firstRecall.Content) > defaultContentChars || recall.TruncationHint == "" { + t.Fatalf("default recall projection = %#v, hint = %q", firstRecall, recall.TruncationHint) + } + fullRecall, _ := callStructured[insightListResult](t, session, "recall", map[string]any{ + "query": "durable marker", "basic": true, "full": true, + }) + fullFirst := resultByID(t, fullRecall.Results, first.ID) + if fullFirst.Content != longContent || fullFirst.Truncated || fullRecall.TruncationHint != "" { + t.Fatalf("full recall projection = %#v, hint = %q", fullFirst, fullRecall.TruncationHint) + } + search, _ := callStructured[insightListResult](t, session, "search", map[string]any{ + "query": "release durable", + }) + if len(search.Results) != 2 { + t.Fatalf("search results = %#v", search.Results) + } + status, _ := callStructured[memoryservice.StatusResult](t, session, "status", map[string]any{}) + if status.TotalInsights != 2 || status.EdgeCount < 2 { + t.Fatalf("status = %#v", status) + } +} + +func TestServerReturnsActionableToolErrors(t *testing.T) { + session := connectTestClient(t, New("test-version", newTestMemory(t))) + zero := 0 + _, err := session.CallTool(context.Background(), &sdkmcp.CallToolParams{ + Name: "recall", Arguments: map[string]any{"query": "anything", "limit": zero}, + }) + if err == nil || !strings.Contains(err.Error(), "minimum") { + t.Fatalf("invalid limit error = %v", err) + } + + _, err = session.CallTool(context.Background(), &sdkmcp.CallToolParams{ + Name: "remember", Arguments: map[string]any{ + "content": "bounded input", "source": strings.Repeat("s", maxSourceChars+1), + }, + }) + if err == nil || !strings.Contains(err.Error(), "maxLength") { + t.Fatalf("oversized source error = %v", err) + } +} + +func TestServeRejectsInvalidStreams(t *testing.T) { + server := New("test-version", newTestMemory(t)) + if err := server.Serve(nil, strings.NewReader(""), io.Discard); err == nil { + t.Fatal("Serve accepted a nil context") + } + if err := server.Serve(context.Background(), nil, io.Discard); err == nil { + t.Fatal("Serve accepted nil stdin") + } + if err := server.Serve(context.Background(), strings.NewReader(""), nil); err == nil { + t.Fatal("Serve accepted nil stdout") + } +} + +func TestStdioInitializeEchoesSupportedClientProtocolVersion(t *testing.T) { + for _, version := range []string{"2024-11-05", "2025-03-26", "2025-06-18"} { + t.Run(version, func(t *testing.T) { + server := New("test-version", newTestMemory(t)) + serverInput, clientOutput := io.Pipe() + clientInput, serverOutput := io.Pipe() + done := make(chan error, 1) + go func() { + done <- server.Serve(context.Background(), serverInput, serverOutput) + }() + + request := map[string]any{ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": map[string]any{ + "protocolVersion": version, + "capabilities": map[string]any{}, + "clientInfo": map[string]any{"name": "raw-test", "version": "1"}, + }, + } + if err := json.NewEncoder(clientOutput).Encode(request); err != nil { + t.Fatal(err) + } + var response struct { + Result struct { + ProtocolVersion string `json:"protocolVersion"` + } `json:"result"` + } + if err := json.NewDecoder(clientInput).Decode(&response); err != nil { + t.Fatal(err) + } + if response.Result.ProtocolVersion != version { + t.Fatalf("protocol version = %q, want client version %q", response.Result.ProtocolVersion, version) + } + _ = clientOutput.Close() + select { + case err := <-done: + if err != nil { + t.Fatalf("serve after client close: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("stdio server did not stop after client closed stdin") + } + _ = serverOutput.Close() + _ = clientInput.Close() + }) + } +} + +func resultByID(t *testing.T, results []insightResult, id string) insightResult { + t.Helper() + for _, result := range results { + if result.ID == id { + return result + } + } + t.Fatalf("result %s not found in %#v", id, results) + return insightResult{} +} + +func assertSchemaNumber(t *testing.T, properties map[string]any, property, keyword string, want int) { + t.Helper() + schema, ok := properties[property].(map[string]any) + if !ok { + t.Fatalf("schema property %q = %#v", property, properties[property]) + } + if got, ok := schema[keyword].(float64); !ok || got != float64(want) { + t.Fatalf("schema property %q %s = %#v, want %d", property, keyword, schema[keyword], want) + } +} diff --git a/internal/mcp/tools_read.go b/internal/mcp/tools_read.go new file mode 100644 index 00000000..283d95e2 --- /dev/null +++ b/internal/mcp/tools_read.go @@ -0,0 +1,120 @@ +package mcp + +import ( + "context" + "fmt" + + memoryservice "github.com/mnemon-dev/mnemon/internal/memory/service" + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type recallInput struct { + Query string `json:"query" jsonschema:"natural-language memory query, at most 2000 characters"` + Limit *int `json:"limit,omitempty" jsonschema:"maximum results, 1-100; defaults to 10"` + Category string `json:"category,omitempty" jsonschema:"basic-mode category filter"` + Source string `json:"source,omitempty" jsonschema:"basic-mode source filter"` + Basic bool `json:"basic,omitempty" jsonschema:"use simple SQL substring matching instead of intent-aware recall"` + Intent string `json:"intent,omitempty" jsonschema:"optional smart-recall intent override: WHY, WHEN, ENTITY, or GENERAL"` + Full bool `json:"full,omitempty" jsonschema:"return complete content instead of the default 600-character projection"` +} + +type searchInput struct { + Query string `json:"query" jsonschema:"token search query, at most 2000 characters"` + Limit *int `json:"limit,omitempty" jsonschema:"maximum results, 1-100; defaults to 10"` + Full bool `json:"full,omitempty" jsonschema:"return complete content instead of the default 600-character projection"` +} + +type relatedInput struct { + ID string `json:"id" jsonschema:"starting insight ID"` + EdgeType string `json:"edge_type,omitempty" jsonschema:"optional edge filter: temporal, semantic, causal, or entity"` + Depth *int `json:"depth,omitempty" jsonschema:"maximum graph depth, 1-10; defaults to 2"` + Limit *int `json:"limit,omitempty" jsonschema:"maximum results, 1-100; defaults to 20"` + Full bool `json:"full,omitempty" jsonschema:"return complete content instead of the default 600-character projection"` +} + +func (s *Server) registerReadTools() { + sdkmcp.AddTool(s.sdk, &sdkmcp.Tool{ + Name: "recall", Description: "Retrieve durable insights using intent-aware graph search, or basic substring matching.", + InputSchema: recallInputSchema(), Annotations: toolAnnotations(true, false, true), + }, s.recall) + sdkmcp.AddTool(s.sdk, &sdkmcp.Tool{ + Name: "search", Description: "Search durable insights with token-based relevance scoring.", + InputSchema: searchInputSchema(), Annotations: toolAnnotations(true, false, true), + }, s.search) + sdkmcp.AddTool(s.sdk, &sdkmcp.Tool{ + Name: "related", Description: "Traverse typed graph edges from one insight to find related memories.", + InputSchema: relatedInputSchema(), Annotations: toolAnnotations(true, false, true), + }, s.related) + sdkmcp.AddTool(s.sdk, &sdkmcp.Tool{ + Name: "status", Description: "Show aggregate statistics for the selected Mnemon store.", + Annotations: toolAnnotations(true, false, true), + }, s.status) +} + +func (s *Server) recall(ctx context.Context, _ *sdkmcp.CallToolRequest, input recallInput) (*sdkmcp.CallToolResult, *insightListResult, error) { + if err := validateText("query", input.Query, maxQueryChars); err != nil { + return nil, nil, err + } + if !validCategory(input.Category) { + return nil, nil, fmt.Errorf("invalid category %q", input.Category) + } + limit, err := boundedLimit(input.Limit, defaultLimit) + if err != nil { + return nil, nil, err + } + response, err := s.memory.Recall(ctx, memoryservice.RecallRequest{ + Query: input.Query, Category: input.Category, Source: input.Source, + Limit: limit, Basic: input.Basic, Intent: input.Intent, + }) + if err != nil { + return nil, nil, err + } + output := projectRecall(response, input.Full) + return nil, &output, nil +} + +func (s *Server) search(ctx context.Context, _ *sdkmcp.CallToolRequest, input searchInput) (*sdkmcp.CallToolResult, *insightListResult, error) { + if err := validateText("query", input.Query, maxQueryChars); err != nil { + return nil, nil, err + } + limit, err := boundedLimit(input.Limit, defaultLimit) + if err != nil { + return nil, nil, err + } + results, err := s.memory.Search(ctx, memoryservice.SearchRequest{Query: input.Query, Limit: limit}) + if err != nil { + return nil, nil, err + } + output := projectSearch(results, input.Full) + return nil, &output, nil +} + +func (s *Server) related(ctx context.Context, _ *sdkmcp.CallToolRequest, input relatedInput) (*sdkmcp.CallToolResult, *insightListResult, error) { + if err := validateText("id", input.ID, maxIDChars); err != nil { + return nil, nil, err + } + depth, err := boundedDepth(input.Depth) + if err != nil { + return nil, nil, err + } + limit, err := boundedLimit(input.Limit, defaultRelatedLimit) + if err != nil { + return nil, nil, err + } + results, err := s.memory.Related(ctx, memoryservice.RelatedRequest{ + ID: input.ID, EdgeType: input.EdgeType, Depth: depth, Limit: limit, + }) + if err != nil { + return nil, nil, err + } + output := projectRelated(results, input.Full) + return nil, &output, nil +} + +func (s *Server) status(ctx context.Context, _ *sdkmcp.CallToolRequest, _ struct{}) (*sdkmcp.CallToolResult, *memoryservice.StatusResult, error) { + result, err := s.memory.Status(ctx) + if err != nil { + return nil, nil, err + } + return nil, &result, nil +} diff --git a/internal/mcp/tools_write.go b/internal/mcp/tools_write.go new file mode 100644 index 00000000..75611b5f --- /dev/null +++ b/internal/mcp/tools_write.go @@ -0,0 +1,101 @@ +package mcp + +import ( + "context" + "fmt" + "unicode/utf8" + + memoryservice "github.com/mnemon-dev/mnemon/internal/memory/service" + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type rememberInput struct { + Content string `json:"content" jsonschema:"durable insight content, at most 8000 bytes"` + Category string `json:"category,omitempty" jsonschema:"preference, decision, fact, insight, context, or general; defaults to general"` + Importance *int `json:"importance,omitempty" jsonschema:"importance from 1 to 5; defaults to 3"` + Tags []string `json:"tags,omitempty" jsonschema:"up to 20 tags, each at most 100 characters"` + Source string `json:"source,omitempty" jsonschema:"memory source, at most 100 characters; defaults to user"` + Entities []string `json:"entities,omitempty" jsonschema:"up to 50 explicit entities, each at most 200 characters"` + EntityMode string `json:"entity_mode,omitempty" jsonschema:"entity handling: merge, provided, or auto; defaults to merge"` + NoDiff bool `json:"no_diff,omitempty" jsonschema:"skip duplicate and update detection"` +} + +type linkInput struct { + SourceID string `json:"source_id" jsonschema:"source insight ID"` + TargetID string `json:"target_id" jsonschema:"target insight ID"` + EdgeType string `json:"edge_type,omitempty" jsonschema:"temporal, semantic, causal, or entity; defaults to semantic"` + Weight *float64 `json:"weight,omitempty" jsonschema:"edge weight from 0.0 to 1.0; defaults to 0.5"` + Metadata map[string]string `json:"metadata,omitempty" jsonschema:"up to 20 metadata entries; keys at most 100 and values at most 1000 characters"` +} + +func (s *Server) registerWriteTools() { + sdkmcp.AddTool(s.sdk, &sdkmcp.Tool{ + Name: "remember", Description: "Store one durable insight and update its memory graph; duplicate detection may skip or replace an older insight.", + InputSchema: rememberInputSchema(), Annotations: toolAnnotations(false, true, false), + }, s.remember) + sdkmcp.AddTool(s.sdk, &sdkmcp.Tool{ + Name: "link", Description: "Create or replace a bidirectional typed relationship between two stored insights.", + InputSchema: linkInputSchema(), Annotations: toolAnnotations(false, true, true), + }, s.link) +} + +func (s *Server) remember(ctx context.Context, _ *sdkmcp.CallToolRequest, input rememberInput) (*sdkmcp.CallToolResult, *memoryservice.RememberResult, error) { + if input.Category != "" && !validCategory(input.Category) { + return nil, nil, fmt.Errorf("invalid category %q", input.Category) + } + if input.Source != "" && utf8.RuneCountInString(input.Source) > maxSourceChars { + return nil, nil, fmt.Errorf("source is too long (max %d characters)", maxSourceChars) + } + importance := 0 + if input.Importance != nil { + importance = *input.Importance + } + result, err := s.memory.Remember(ctx, memoryservice.RememberRequest{ + Content: input.Content, Category: input.Category, Importance: importance, + Tags: input.Tags, Source: input.Source, Entities: input.Entities, + EntityMode: input.EntityMode, NoDiff: input.NoDiff, + }) + if err != nil { + return nil, nil, err + } + return nil, &result, nil +} + +func (s *Server) link(ctx context.Context, _ *sdkmcp.CallToolRequest, input linkInput) (*sdkmcp.CallToolResult, *memoryservice.LinkResult, error) { + if err := validateText("source_id", input.SourceID, maxIDChars); err != nil { + return nil, nil, err + } + if err := validateText("target_id", input.TargetID, maxIDChars); err != nil { + return nil, nil, err + } + if err := validateMetadata(input.Metadata); err != nil { + return nil, nil, err + } + weight := defaultWeight + if input.Weight != nil { + weight = *input.Weight + } + result, err := s.memory.Link(ctx, memoryservice.LinkRequest{ + SourceID: input.SourceID, TargetID: input.TargetID, EdgeType: input.EdgeType, + Weight: weight, Metadata: input.Metadata, CreatedBy: "mcp", + }) + if err != nil { + return nil, nil, err + } + return nil, &result, nil +} + +func validateMetadata(metadata map[string]string) error { + if len(metadata) > maxMetadata { + return fmt.Errorf("metadata has too many entries (%d, max %d)", len(metadata), maxMetadata) + } + for key, value := range metadata { + if utf8.RuneCountInString(key) > maxMetadataKey { + return fmt.Errorf("metadata key is too long (max %d characters)", maxMetadataKey) + } + if utf8.RuneCountInString(value) > maxMetadataValue { + return fmt.Errorf("metadata value for %q is too long (max %d characters)", key, maxMetadataValue) + } + } + return nil +} diff --git a/internal/memory/service/graph.go b/internal/memory/service/graph.go new file mode 100644 index 00000000..3b89c89f --- /dev/null +++ b/internal/memory/service/graph.go @@ -0,0 +1,190 @@ +package service + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/mnemon-dev/mnemon/internal/memory/graph" + "github.com/mnemon-dev/mnemon/internal/memory/model" +) + +// RelatedRequest describes a bounded graph traversal from one insight. +type RelatedRequest struct { + ID string + EdgeType string + Depth int + Limit int +} + +// RelatedResult is one insight discovered by graph traversal. +type RelatedResult struct { + ID string `json:"id"` + Content string `json:"content"` + Category string `json:"category"` + Importance int `json:"importance"` + Depth int `json:"depth"` + EdgeType string `json:"via_edge_type,omitempty"` +} + +// Related returns insights reachable from the requested insight. +func (s *Service) Related(ctx context.Context, request RelatedRequest) ([]RelatedResult, error) { + ctx = normalizeContext(ctx) + release, err := s.acquire(ctx) + if err != nil { + return nil, err + } + defer release() + + if strings.TrimSpace(request.ID) == "" { + return nil, fmt.Errorf("id must not be empty") + } + if request.Depth <= 0 { + return nil, fmt.Errorf("depth must be greater than 0") + } + if request.Limit < 0 { + return nil, fmt.Errorf("limit must be greater than 0") + } + var edgeFilter model.EdgeType + if request.EdgeType != "" { + edgeFilter = model.EdgeType(request.EdgeType) + if !model.ValidEdgeTypes[edgeFilter] { + return nil, fmt.Errorf( + "invalid edge type %q; valid: temporal, semantic, causal, entity", request.EdgeType) + } + } + db, err := s.openDB() + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + defer db.Close() + start, err := db.GetInsightByID(request.ID) + if err != nil { + return nil, fmt.Errorf("insight not found: %w", err) + } + + nodes := graph.BFS(db, start.ID, graph.BFSOptions{ + MaxDepth: request.Depth, MaxNodes: request.Limit, EdgeFilter: edgeFilter, + }) + results := make([]RelatedResult, 0, len(nodes)) + for _, node := range nodes { + results = append(results, RelatedResult{ + ID: node.Insight.ID, Content: node.Insight.Content, + Category: string(node.Insight.Category), Importance: node.Insight.Importance, + Depth: node.Hop, EdgeType: string(node.ViaEdge.EdgeType), + }) + } + return results, nil +} + +// LinkRequest describes a bidirectional typed edge between two insights. +type LinkRequest struct { + SourceID string + TargetID string + EdgeType string + Weight float64 + Metadata map[string]string + CreatedBy string +} + +// LinkResult reports the durable edge pair. +type LinkResult struct { + Status string `json:"status"` + SourceID string `json:"source_id"` + TargetID string `json:"target_id"` + EdgeType string `json:"edge_type"` + Weight float64 `json:"weight"` + Metadata map[string]string `json:"metadata"` +} + +// Link creates or replaces both directions of one typed relationship. +func (s *Service) Link(ctx context.Context, request LinkRequest) (LinkResult, error) { + ctx = normalizeContext(ctx) + release, err := s.acquire(ctx) + if err != nil { + return LinkResult{}, err + } + defer release() + + if strings.TrimSpace(request.SourceID) == "" || strings.TrimSpace(request.TargetID) == "" { + return LinkResult{}, fmt.Errorf("source_id and target_id must not be empty") + } + if request.EdgeType == "" { + request.EdgeType = string(model.EdgeSemantic) + } + edgeType := model.EdgeType(request.EdgeType) + if !model.ValidEdgeTypes[edgeType] { + return LinkResult{}, fmt.Errorf( + "invalid edge type %q; valid: temporal, semantic, causal, entity", request.EdgeType) + } + if request.Weight < 0 || request.Weight > 1 { + return LinkResult{}, fmt.Errorf("weight must be between 0.0 and 1.0, got %.2f", request.Weight) + } + db, err := s.openWritableDB("link") + if err != nil { + return LinkResult{}, fmt.Errorf("open database: %w", err) + } + defer db.Close() + if err := validateLinkEndpoints(db, request.SourceID, request.TargetID); err != nil { + return LinkResult{}, err + } + metadata := copyMetadata(request.Metadata) + if request.CreatedBy == "" { + request.CreatedBy = "agent" + } + metadata["created_by"] = request.CreatedBy + + now := time.Now().UTC() + err = db.InTransaction(func() error { + for _, endpoints := range [][2]string{ + {request.SourceID, request.TargetID}, {request.TargetID, request.SourceID}, + } { + if err := db.InsertEdge(&model.Edge{ + SourceID: endpoints[0], TargetID: endpoints[1], EdgeType: edgeType, + Weight: request.Weight, Metadata: metadata, CreatedAt: now, + }); err != nil { + return fmt.Errorf("create edge %s→%s: %w", endpoints[0], endpoints[1], err) + } + } + db.LogOp("link", request.SourceID, fmt.Sprintf("%s→%s type=%s weight=%.2f", + truncateID(request.SourceID), truncateID(request.TargetID), request.EdgeType, request.Weight)) + return nil + }) + if err != nil { + return LinkResult{}, err + } + return LinkResult{ + Status: "linked", SourceID: request.SourceID, TargetID: request.TargetID, + EdgeType: request.EdgeType, Weight: request.Weight, Metadata: metadata, + }, nil +} + +type insightLookup interface { + GetInsightByID(string) (*model.Insight, error) +} + +func validateLinkEndpoints(db insightLookup, sourceID, targetID string) error { + if source, err := db.GetInsightByID(sourceID); err != nil || source == nil { + return fmt.Errorf("source insight %s not found", sourceID) + } + if target, err := db.GetInsightByID(targetID); err != nil || target == nil { + return fmt.Errorf("target insight %s not found", targetID) + } + return nil +} + +func copyMetadata(metadata map[string]string) map[string]string { + result := make(map[string]string, len(metadata)+1) + for key, value := range metadata { + result[key] = value + } + return result +} + +func truncateID(id string) string { + if len(id) > 8 { + return id[:8] + } + return id +} diff --git a/internal/memory/service/recall.go b/internal/memory/service/recall.go new file mode 100644 index 00000000..4257f66b --- /dev/null +++ b/internal/memory/service/recall.go @@ -0,0 +1,147 @@ +package service + +import ( + "context" + "fmt" + "strings" + + "github.com/mnemon-dev/mnemon/internal/memory/embed" + "github.com/mnemon-dev/mnemon/internal/memory/graph" + "github.com/mnemon-dev/mnemon/internal/memory/model" + "github.com/mnemon-dev/mnemon/internal/memory/search" + "github.com/mnemon-dev/mnemon/internal/memory/store" +) + +// RecallRequest describes intent-aware or basic insight retrieval. +type RecallRequest struct { + Query string + Category string + Source string + Limit int + Basic bool + Intent string +} + +// RecallResponse contains exactly one of BasicResults or SmartResults. +type RecallResponse struct { + BasicResults []*model.Insight + SmartResults *search.RecallResponse +} + +// Recall retrieves insights using the same retrieval semantics as the root +// recall command. +func (s *Service) Recall(ctx context.Context, request RecallRequest) (RecallResponse, error) { + ctx = normalizeContext(ctx) + release, err := s.acquire(ctx) + if err != nil { + return RecallResponse{}, err + } + defer release() + + limit, err := normalizeLimit(request.Limit) + if err != nil { + return RecallResponse{}, err + } + db, err := s.openDB() + if err != nil { + return RecallResponse{}, fmt.Errorf("open database: %w", err) + } + defer db.Close() + + if request.Basic { + return s.basicRecall(db, request, limit) + } + return s.smartRecall(ctx, db, request, limit) +} + +func (s *Service) basicRecall(db *store.DB, request RecallRequest, limit int) (RecallResponse, error) { + results, err := db.QueryInsights(store.QueryFilter{ + Keyword: request.Query, + Category: request.Category, + Source: request.Source, + Limit: limit, + }) + if err != nil { + return RecallResponse{}, fmt.Errorf("query insights: %w", err) + } + for _, result := range results { + _ = db.IncrementAccessCount(result.ID) + } + detail := fmt.Sprintf("q=%s hits=%d", request.Query, len(results)) + db.LogOp("recall:basic", "", s.auditDetail(detail, fmt.Sprintf("hits=%d", len(results)))) + return RecallResponse{BasicResults: results}, nil +} + +func (s *Service) smartRecall(ctx context.Context, db *store.DB, request RecallRequest, limit int) (RecallResponse, error) { + var intentOverride *search.Intent + if request.Intent != "" { + parsed, err := search.IntentFromString(request.Intent) + if err != nil { + return RecallResponse{}, err + } + intentOverride = &parsed + } + + var queryVector []float64 + embedder := embed.NewClientWithModel(s.config.EmbedModel) + if embedder.Available() { + if err := ctx.Err(); err != nil { + return RecallResponse{}, err + } + queryVector, _ = embedder.Embed(request.Query) + } + knownEntities, _ := db.LoadKnownEntities() + queryEntities := graph.ExtractEntitiesIndexed(request.Query, knownEntities) + response, err := search.IntentAwareRecall( + db, request.Query, queryVector, queryEntities, limit, intentOverride) + if err != nil { + return RecallResponse{}, fmt.Errorf("recall: %w", err) + } + for _, result := range response.Results { + _ = db.IncrementAccessCount(result.Insight.ID) + } + detail := fmt.Sprintf("q=%s hits=%d", request.Query, len(response.Results)) + db.LogOp("recall", "", s.auditDetail(detail, fmt.Sprintf("hits=%d", len(response.Results)))) + return RecallResponse{SmartResults: &response}, nil +} + +// SearchRequest describes token-ranked insight search. +type SearchRequest struct { + Query string + Limit int +} + +// Search finds insights using token-based relevance scoring. +func (s *Service) Search(ctx context.Context, request SearchRequest) ([]search.ScoredInsight, error) { + ctx = normalizeContext(ctx) + release, err := s.acquire(ctx) + if err != nil { + return nil, err + } + defer release() + + if strings.TrimSpace(request.Query) == "" { + return nil, fmt.Errorf("query must not be empty") + } + limit, err := normalizeLimit(request.Limit) + if err != nil { + return nil, err + } + db, err := s.openDB() + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + defer db.Close() + + all, err := db.GetAllActiveInsights() + if err != nil { + return nil, fmt.Errorf("get insights: %w", err) + } + results := search.KeywordSearch(all, request.Query, limit) + for _, result := range results { + _ = db.IncrementAccessCount(result.Insight.ID) + } + detail := fmt.Sprintf("q=%s hits=%d", request.Query, len(results)) + db.LogOp("search", "", s.auditDetail(detail, fmt.Sprintf("hits=%d", len(results)))) + return results, nil +} diff --git a/internal/memory/service/remember.go b/internal/memory/service/remember.go new file mode 100644 index 00000000..4b4301e2 --- /dev/null +++ b/internal/memory/service/remember.go @@ -0,0 +1,358 @@ +package service + +import ( + "context" + "fmt" + "sort" + "strings" + "time" + + "github.com/google/uuid" + "github.com/mnemon-dev/mnemon/internal/memory/embed" + "github.com/mnemon-dev/mnemon/internal/memory/graph" + "github.com/mnemon-dev/mnemon/internal/memory/model" + "github.com/mnemon-dev/mnemon/internal/memory/search" + "github.com/mnemon-dev/mnemon/internal/memory/store" +) + +// RememberRequest describes a new insight and its graph behavior. +type RememberRequest struct { + Content string + Category string + Importance int + Tags []string + Source string + Entities []string + EntityMode string + NoDiff bool +} + +// RememberResult reports the stored insight and any graph or diff effects. +// Effect-only fields are omitted when a duplicate is skipped. +type RememberResult struct { + ID string `json:"id"` + Content string `json:"content"` + Category model.Category `json:"category,omitempty"` + Importance int `json:"importance,omitempty"` + Tags *[]string `json:"tags,omitempty"` + Entities *[]string `json:"entities,omitempty"` + Action string `json:"action"` + DiffSuggestion search.DiffSuggestion `json:"diff_suggestion"` + ReplacedID string `json:"replaced_id,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + EdgesCreated *graph.EdgeStats `json:"edges_created,omitempty"` + SemanticCandidates *[]graph.SemanticCandidate `json:"semantic_candidates,omitempty"` + CausalCandidates *[]graph.CausalCandidate `json:"causal_candidates,omitempty"` + Embedded *bool `json:"embedded,omitempty"` + EffectiveImportance *float64 `json:"effective_importance,omitempty"` + AutoPruned *int `json:"auto_pruned,omitempty"` + AutoPrunedIDs *[]string `json:"auto_pruned_ids,omitempty"` +} + +type normalizedRemember struct { + content string + category model.Category + importance int + tags []string + source string + entities []string + entityMode graph.EntityMode + noDiff bool +} + +type embeddingState struct { + vector []float64 + blob []byte + cache graph.EmbedCache +} + +type diffDecision struct { + action string + suggestion search.DiffSuggestion + replacedID string +} + +type writeEffects struct { + edges graph.EdgeStats + embedded bool + importance float64 + prunedIDs []string +} + +// Remember validates and stores one insight using the CLI's diff and graph +// semantics. +func (s *Service) Remember(ctx context.Context, request RememberRequest) (RememberResult, error) { + ctx = normalizeContext(ctx) + release, err := s.acquire(ctx) + if err != nil { + return RememberResult{}, err + } + defer release() + + normalized, err := normalizeRememberRequest(request) + if err != nil { + return RememberResult{}, err + } + db, err := s.openWritableDB("remember") + if err != nil { + return RememberResult{}, fmt.Errorf("open database: %w", err) + } + defer db.Close() + + insight := newInsight(normalized) + embedding := s.prepareEmbedding(ctx, db, normalized.content) + decision, err := decideRememberDiff(db, normalized, embedding) + if err != nil { + return RememberResult{}, err + } + if decision.action == "skipped" { + db.LogOp("diff-skip", insight.ID, fmt.Sprintf("duplicate of %s", decision.replacedID)) + return skippedRememberResult(insight, decision), nil + } + + effects, err := s.persistInsight(db, insight, normalized.entityMode, embedding, decision) + if err != nil { + return RememberResult{}, err + } + return completeRememberResult(db, insight, embedding.cache, decision, effects), nil +} + +func normalizeRememberRequest(request RememberRequest) (normalizedRemember, error) { + if strings.TrimSpace(request.Content) == "" { + return normalizedRemember{}, fmt.Errorf("content must not be empty") + } + if len(request.Content) > 8000 { + return normalizedRemember{}, fmt.Errorf( + "content too long (%d chars, max 8000); consider chunking into multiple remember calls", + len(request.Content)) + } + if request.Category == "" { + request.Category = string(model.CategoryGeneral) + } + category := model.Category(request.Category) + if !model.ValidCategories[category] { + return normalizedRemember{}, fmt.Errorf( + "invalid category %q; valid: preference, decision, fact, insight, context, general", + request.Category) + } + if request.Importance == 0 { + request.Importance = 3 + } + if request.Importance < 1 || request.Importance > 5 { + return normalizedRemember{}, fmt.Errorf("importance must be 1-5, got %d", request.Importance) + } + if request.Source == "" { + request.Source = "user" + } + if request.EntityMode == "" { + request.EntityMode = string(graph.EntityModeMerge) + } + entityMode := graph.EntityMode(request.EntityMode) + if !graph.ValidEntityMode(entityMode) { + return normalizedRemember{}, fmt.Errorf( + "invalid entity mode %q; valid: merge, provided, auto", request.EntityMode) + } + tags, err := normalizeValues("tag", "tags", request.Tags, 20, 100) + if err != nil { + return normalizedRemember{}, err + } + entities, err := normalizeValues("entity", "entities", request.Entities, 50, 200) + if err != nil { + return normalizedRemember{}, err + } + return normalizedRemember{ + content: request.Content, category: category, importance: request.Importance, + tags: tags, source: request.Source, entities: entities, + entityMode: entityMode, noDiff: request.NoDiff, + }, nil +} + +func normalizeValues(label, plural string, values []string, maxCount, maxLength int) ([]string, error) { + normalized := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if len(value) > maxLength { + return nil, fmt.Errorf("%s too long (%d chars, max %d): %s", + label, len(value), maxLength, value[:50]) + } + normalized = append(normalized, value) + } + if len(normalized) > maxCount { + return nil, fmt.Errorf("too many %s (%d, max %d)", plural, len(normalized), maxCount) + } + if normalized == nil { + normalized = []string{} + } + return normalized, nil +} + +func newInsight(request normalizedRemember) *model.Insight { + now := time.Now().UTC() + return &model.Insight{ + ID: uuid.New().String(), Content: request.content, Category: request.category, + Importance: request.importance, Tags: request.tags, Entities: request.entities, + Source: request.source, CreatedAt: now, UpdatedAt: now, + } +} + +func (s *Service) prepareEmbedding(ctx context.Context, db *store.DB, content string) embeddingState { + client := embed.NewClientWithModel(s.config.EmbedModel) + if !client.Available() || ctx.Err() != nil { + return embeddingState{} + } + state := embeddingState{} + if vector, err := client.Embed(content); err == nil { + state.vector = vector + state.blob = embed.SerializeVector(vector) + } + dbEmbeddings, err := db.GetAllEmbeddings() + if err != nil { + return state + } + state.cache = make(graph.EmbedCache, len(dbEmbeddings)) + for _, item := range dbEmbeddings { + if vector := embed.DeserializeVector(item.Embedding); vector != nil { + state.cache[item.ID] = vector + } + } + return state +} + +func decideRememberDiff(db *store.DB, request normalizedRemember, embedding embeddingState) (diffDecision, error) { + if request.noDiff { + return diffDecision{action: "added", suggestion: search.DiffAdd}, nil + } + insights, err := db.GetAllActiveInsights() + if err != nil { + return diffDecision{}, fmt.Errorf("load insights for diff: %w", err) + } + options := search.DiffOptions{Limit: 5, NewEmbedding: embedding.vector} + if embedding.cache != nil { + ids := make([]string, 0, len(embedding.cache)) + for id := range embedding.cache { + ids = append(ids, id) + } + sort.Strings(ids) + options.ExistingEmbed = make([]search.EmbeddedItem, 0, len(ids)) + for _, id := range ids { + options.ExistingEmbed = append(options.ExistingEmbed, search.EmbeddedItem{ + ID: id, Embedding: embedding.cache[id], + }) + } + } + result := search.Diff(insights, request.content, options) + decision := diffDecision{action: "added", suggestion: result.Suggestion} + if len(result.Matches) == 0 { + return decision, nil + } + switch result.Suggestion { + case search.DiffDuplicate: + decision.action = "skipped" + decision.replacedID = result.Matches[0].ID + case search.DiffUpdate: + if result.Matches[0].TokenSimilarity >= 0.6 { + decision.action = "updated" + decision.replacedID = result.Matches[0].ID + } + } + return decision, nil +} + +func (s *Service) persistInsight(db *store.DB, insight *model.Insight, entityMode graph.EntityMode, + embedding embeddingState, decision diffDecision) (writeEffects, error) { + var effects writeEffects + err := db.InTransaction(func() error { + if decision.action == "updated" && decision.replacedID != "" { + if err := db.SoftDeleteInsight(decision.replacedID); err != nil { + fmt.Fprintf(s.config.Warnings, "warning: soft-delete %s: %v\n", decision.replacedID, err) + } else { + db.LogOp("diff-replace", decision.replacedID, fmt.Sprintf("replaced by %s", insight.ID)) + delete(embedding.cache, decision.replacedID) + } + } + if err := db.InsertInsight(insight); err != nil { + return fmt.Errorf("insert insight: %w", err) + } + if embedding.blob != nil { + if err := db.UpdateEmbedding(insight.ID, embedding.blob); err != nil { + return fmt.Errorf("update embedding: %w", err) + } + effects.embedded = true + if embedding.cache != nil { + embedding.cache[insight.ID] = embedding.vector + } + } + effects.edges = graph.NewEngineWithEntityMode(db, embedding.cache, entityMode).OnInsightCreated(insight) + s.updateRememberEntities(db, insight) + effects.importance, _ = s.refreshImportance(db, insight.ID) + var err error + effects.prunedIDs, err = db.AutoPruneWithResult(store.MaxInsightsLimit(), []string{insight.ID}, insight.ID) + if err != nil { + return fmt.Errorf("auto-prune: %w", err) + } + db.LogOp("remember", insight.ID, s.auditDetail(insight.Content, "content redacted")) + return nil + }) + return effects, err +} + +func (s *Service) updateRememberEntities(db *store.DB, insight *model.Insight) { + if len(insight.Entities) == 0 { + return + } + if err := db.UpdateEntities(insight.ID, insight.Entities); err != nil { + fmt.Fprintf(s.config.Warnings, "warning: update entities: %v\n", err) + } +} + +func (s *Service) refreshImportance(db *store.DB, id string) (float64, error) { + importance, err := db.RefreshEffectiveImportance(id) + if err != nil { + fmt.Fprintf(s.config.Warnings, "warning: refresh EI: %v\n", err) + } + return importance, err +} + +func skippedRememberResult(insight *model.Insight, decision diffDecision) RememberResult { + return RememberResult{ + ID: insight.ID, Content: insight.Content, Action: decision.action, + DiffSuggestion: decision.suggestion, ReplacedID: decision.replacedID, + } +} + +func completeRememberResult(db *store.DB, insight *model.Insight, cache graph.EmbedCache, + decision diffDecision, effects writeEffects) RememberResult { + semantic := graph.FindSemanticCandidates(db, insight, cache) + if semantic == nil { + semantic = []graph.SemanticCandidate{} + } + causal := graph.FindCausalCandidates(db, insight) + if causal == nil { + causal = []graph.CausalCandidate{} + } + pruned := effects.prunedIDs + if pruned == nil { + pruned = []string{} + } + prunedCount := len(pruned) + tags := insight.Tags + if tags == nil { + tags = []string{} + } + entities := insight.Entities + if entities == nil { + entities = []string{} + } + return RememberResult{ + ID: insight.ID, Content: insight.Content, Category: insight.Category, + Importance: insight.Importance, Tags: &tags, Entities: &entities, + Action: decision.action, DiffSuggestion: decision.suggestion, + ReplacedID: decision.replacedID, CreatedAt: insight.CreatedAt.Format(time.RFC3339), + EdgesCreated: &effects.edges, SemanticCandidates: &semantic, CausalCandidates: &causal, + Embedded: &effects.embedded, EffectiveImportance: &effects.importance, + AutoPruned: &prunedCount, AutoPrunedIDs: &pruned, + } +} diff --git a/internal/memory/service/service.go b/internal/memory/service/service.go new file mode 100644 index 00000000..c3ad3fc8 --- /dev/null +++ b/internal/memory/service/service.go @@ -0,0 +1,120 @@ +// Package service exposes Mnemon's Memory operations independently from any +// command or transport projection. +package service + +import ( + "context" + "fmt" + "io" + "os" + + "github.com/mnemon-dev/mnemon/internal/memory/store" +) + +const defaultResultLimit = 10 + +// Config selects the Memory store and runtime behavior used by a Service. +type Config struct { + DataDir string + StoreName string + ReadOnly bool + EmbedModel string + Warnings io.Writer + AuditContent bool +} + +// Service owns the application-level Memory operations shared by the CLI and +// protocol adapters. Operations are serialized because store.DB tracks its +// active transaction on the handle and remember performs a read/decide/write +// sequence that must not race another operation in the same server process. +type Service struct { + config Config + gate chan struct{} +} + +// New constructs a Memory service. A single Service may be reused safely by +// concurrent callers; callers waiting for the operation gate can be canceled. +func New(config Config) *Service { + if config.DataDir == "" { + config.DataDir = store.DefaultDataDir() + } + if config.Warnings == nil { + config.Warnings = io.Discard + } + gate := make(chan struct{}, 1) + gate <- struct{}{} + return &Service{config: config, gate: gate} +} + +func (s *Service) acquire(ctx context.Context) (func(), error) { + if ctx == nil { + ctx = context.Background() + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-s.gate: + return func() { s.gate <- struct{}{} }, nil + } +} + +func normalizeContext(ctx context.Context) context.Context { + if ctx == nil { + return context.Background() + } + return ctx +} + +func (s *Service) selectedStoreName() string { + if s.config.StoreName != "" { + return s.config.StoreName + } + if name := os.Getenv("MNEMON_STORE"); name != "" { + return name + } + return store.ReadActive(s.config.DataDir) +} + +func (s *Service) openDB() (*store.DB, error) { + name := s.selectedStoreName() + if !store.ValidStoreName(name) { + return nil, fmt.Errorf("invalid store name %q", name) + } + dir := store.StoreDir(s.config.DataDir, name) + if s.config.ReadOnly { + return store.OpenReadOnly(dir) + } + if err := store.MigrateIfNeeded(s.config.DataDir); err != nil { + return nil, fmt.Errorf("migrate: %w", err) + } + return store.Open(dir) +} + +func (s *Service) openWritableDB(action string) (*store.DB, error) { + db, err := s.openDB() + if err != nil { + return nil, err + } + if db.IsReadOnly() { + _ = db.Close() + return nil, fmt.Errorf("%s is unavailable with --readonly: database writes are disabled", action) + } + return db, nil +} + +func (s *Service) auditDetail(content, redacted string) string { + if s.config.AuditContent { + return content + } + return redacted +} + +func normalizeLimit(limit int) (int, error) { + if limit == 0 { + return defaultResultLimit, nil + } + if limit < 0 { + return 0, fmt.Errorf("limit must be greater than 0") + } + return limit, nil +} diff --git a/internal/memory/service/service_test.go b/internal/memory/service/service_test.go new file mode 100644 index 00000000..9ab36099 --- /dev/null +++ b/internal/memory/service/service_test.go @@ -0,0 +1,221 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/mnemon-dev/mnemon/internal/memory/model" + "github.com/mnemon-dev/mnemon/internal/memory/store" +) + +func testService(t *testing.T, auditContent bool) (*Service, Config) { + t.Helper() + t.Setenv("MNEMON_EMBED_ENDPOINT", "http://127.0.0.1:1") + config := Config{ + DataDir: t.TempDir(), StoreName: "service-test", + AuditContent: auditContent, + } + return New(config), config +} + +func rememberTestInsight(t *testing.T, service *Service, content string) RememberResult { + t.Helper() + result, err := service.Remember(context.Background(), RememberRequest{ + Content: content, Category: "fact", Importance: 4, + Tags: []string{"test"}, Entities: []string{"Mnemon"}, NoDiff: true, + }) + if err != nil { + t.Fatalf("remember %q: %v", content, err) + } + return result +} + +func TestServiceMemoryWorkflow(t *testing.T) { + service, _ := testService(t, true) + first := rememberTestInsight(t, service, "Mnemon keeps durable release decisions") + second := rememberTestInsight(t, service, "Release reviews depend on durable decisions") + + searchResults, err := service.Search(context.Background(), SearchRequest{Query: "durable decisions", Limit: 10}) + if err != nil || len(searchResults) != 2 { + t.Fatalf("search results = %d, err = %v", len(searchResults), err) + } + recall, err := service.Recall(context.Background(), RecallRequest{Query: "release", Limit: 10}) + if err != nil || recall.SmartResults == nil || len(recall.SmartResults.Results) != 2 { + t.Fatalf("smart recall = %#v, err = %v", recall.SmartResults, err) + } + basic, err := service.Recall(context.Background(), RecallRequest{Query: "Mnemon", Basic: true}) + if err != nil || len(basic.BasicResults) != 1 || basic.BasicResults[0].ID != first.ID { + t.Fatalf("basic recall = %#v, err = %v", basic.BasicResults, err) + } + + link, err := service.Link(context.Background(), LinkRequest{ + SourceID: first.ID, TargetID: second.ID, EdgeType: "causal", + Weight: 0.8, Metadata: map[string]string{"reason": "test"}, CreatedBy: "service-test", + }) + if err != nil || link.Metadata["created_by"] != "service-test" { + t.Fatalf("link = %#v, err = %v", link, err) + } + related, err := service.Related(context.Background(), RelatedRequest{ + ID: first.ID, EdgeType: "causal", Depth: 1, + }) + if err != nil || len(related) != 1 || related[0].ID != second.ID { + t.Fatalf("related = %#v, err = %v", related, err) + } + status, err := service.Status(context.Background()) + if err != nil || status.TotalInsights != 2 || status.EdgeCount < 2 || status.DBSizeBytes == 0 { + t.Fatalf("status = %#v, err = %v", status, err) + } +} + +func TestRememberResultPreservesCLIWireShape(t *testing.T) { + service, _ := testService(t, true) + added, err := service.Remember(context.Background(), RememberRequest{ + Content: "wire shape marker", NoDiff: true, + }) + if err != nil { + t.Fatal(err) + } + addedJSON, err := json.Marshal(added) + if err != nil { + t.Fatal(err) + } + var addedFields map[string]json.RawMessage + if err := json.Unmarshal(addedJSON, &addedFields); err != nil { + t.Fatal(err) + } + for _, field := range []string{ + "tags", "entities", "semantic_candidates", "causal_candidates", "auto_pruned_ids", + } { + if value, ok := addedFields[field]; !ok || string(value) != "[]" { + t.Errorf("added %s = %s, present = %v; want []", field, value, ok) + } + } + + skipped, err := service.Remember(context.Background(), RememberRequest{Content: "wire shape marker"}) + if err != nil { + t.Fatal(err) + } + skippedJSON, err := json.Marshal(skipped) + if err != nil { + t.Fatal(err) + } + var skippedFields map[string]json.RawMessage + if err := json.Unmarshal(skippedJSON, &skippedFields); err != nil { + t.Fatal(err) + } + if skipped.Action != "skipped" { + t.Fatalf("duplicate action = %q", skipped.Action) + } + for _, field := range []string{ + "tags", "entities", "semantic_candidates", "causal_candidates", "auto_pruned_ids", + } { + if _, ok := skippedFields[field]; ok { + t.Errorf("skipped result unexpectedly contains %q", field) + } + } +} + +func TestServiceRedactsRemoteNaturalLanguageFromOplog(t *testing.T) { + service, config := testService(t, false) + secretContent := "private remote memory payload" + rememberTestInsight(t, service, secretContent) + if _, err := service.Search(context.Background(), SearchRequest{Query: "private remote"}); err != nil { + t.Fatal(err) + } + if _, err := service.Recall(context.Background(), RecallRequest{Query: "private", Basic: true}); err != nil { + t.Fatal(err) + } + + db, err := store.Open(store.StoreDir(config.DataDir, config.StoreName)) + if err != nil { + t.Fatal(err) + } + defer db.Close() + entries, err := db.GetOplog(20) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if strings.Contains(entry.Detail, "private") || strings.Contains(entry.Detail, secretContent) { + t.Fatalf("oplog leaked remote content: %#v", entry) + } + } +} + +func TestServiceReadOnlyRejectsWritesAndAllowsReads(t *testing.T) { + service, config := testService(t, true) + remembered := rememberTestInsight(t, service, "read-only snapshot content") + readOnly := New(Config{ + DataDir: config.DataDir, StoreName: config.StoreName, ReadOnly: true, + }) + results, err := readOnly.Recall(context.Background(), RecallRequest{Query: "snapshot", Basic: true}) + if err != nil || len(results.BasicResults) != 1 || results.BasicResults[0].ID != remembered.ID { + t.Fatalf("read-only recall = %#v, err = %v", results.BasicResults, err) + } + if _, err := readOnly.Remember(context.Background(), RememberRequest{Content: "rejected"}); err == nil || !strings.Contains(err.Error(), "remember is unavailable with --readonly") { + t.Fatalf("read-only remember error = %v", err) + } + if _, err := readOnly.Link(context.Background(), LinkRequest{ + SourceID: remembered.ID, TargetID: remembered.ID, Weight: 0.5, + }); err == nil || !strings.Contains(err.Error(), "link is unavailable with --readonly") { + t.Fatalf("read-only link error = %v", err) + } +} + +func TestServiceLinkRollsBackBothDirections(t *testing.T) { + service, config := testService(t, true) + first := rememberTestInsight(t, service, "first atomic link endpoint") + second := rememberTestInsight(t, service, "second atomic link endpoint") + if first.ID > second.ID { + first, second = second, first + } + db, err := store.Open(store.StoreDir(config.DataDir, config.StoreName)) + if err != nil { + t.Fatal(err) + } + _, err = db.Conn().Exec(` + CREATE TRIGGER reject_reverse_service_link + BEFORE INSERT ON edges + WHEN NEW.source_id > NEW.target_id AND NEW.edge_type = 'causal' + BEGIN + SELECT RAISE(ABORT, 'reverse rejected'); + END`) + if err != nil { + db.Close() + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + + _, err = service.Link(context.Background(), LinkRequest{ + SourceID: first.ID, TargetID: second.ID, EdgeType: "causal", Weight: 0.5, + }) + if err == nil || !strings.Contains(err.Error(), "reverse rejected") { + t.Fatalf("link error = %v", err) + } + db, err = store.Open(store.StoreDir(config.DataDir, config.StoreName)) + if err != nil { + t.Fatal(err) + } + defer db.Close() + edges, err := db.GetEdgesByNodeAndType(first.ID, model.EdgeCausal) + if err != nil || len(edges) != 0 { + t.Fatalf("causal edges after rollback = %#v, err = %v", edges, err) + } +} + +func TestServiceCanceledWaitDoesNotStartOperation(t *testing.T) { + service, _ := testService(t, true) + <-service.gate + defer func() { service.gate <- struct{}{} }() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := service.Status(ctx) + if !errors.Is(err, context.Canceled) { + t.Fatalf("status error = %v, want context cancellation", err) + } +} diff --git a/internal/memory/service/status.go b/internal/memory/service/status.go new file mode 100644 index 00000000..d2ff825c --- /dev/null +++ b/internal/memory/service/status.go @@ -0,0 +1,51 @@ +package service + +import ( + "context" + "fmt" + "os" + + "github.com/mnemon-dev/mnemon/internal/memory/store" +) + +// StatusResult contains aggregate statistics for the selected Memory store. +type StatusResult struct { + TotalInsights int `json:"total_insights"` + DeletedInsights int `json:"deleted_insights"` + ByCategory map[string]int `json:"by_category"` + EdgeCount int `json:"edge_count"` + TopEntities []store.EntityStat `json:"top_entities"` + OplogCount int `json:"oplog_count"` + DBPath string `json:"db_path"` + DBSizeBytes int64 `json:"db_size_bytes"` +} + +// Status returns statistics and storage metadata for the selected store. +func (s *Service) Status(ctx context.Context) (StatusResult, error) { + ctx = normalizeContext(ctx) + release, err := s.acquire(ctx) + if err != nil { + return StatusResult{}, err + } + defer release() + + db, err := s.openDB() + if err != nil { + return StatusResult{}, fmt.Errorf("open database: %w", err) + } + defer db.Close() + stats, err := db.GetStats() + if err != nil { + return StatusResult{}, fmt.Errorf("get stats: %w", err) + } + var size int64 + if info, err := os.Stat(db.Path()); err == nil { + size = info.Size() + } + return StatusResult{ + TotalInsights: stats.Total, DeletedInsights: stats.DeletedCount, + ByCategory: stats.ByCategory, EdgeCount: stats.EdgeCount, + TopEntities: stats.TopEntities, OplogCount: stats.OplogCount, + DBPath: db.Path(), DBSizeBytes: size, + }, nil +} diff --git a/test/mcp/stdio_e2e_test.go b/test/mcp/stdio_e2e_test.go new file mode 100644 index 00000000..c66276c2 --- /dev/null +++ b/test/mcp/stdio_e2e_test.go @@ -0,0 +1,199 @@ +package mcp_test + +import ( + "bufio" + "context" + "encoding/json" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "testing" + "time" +) + +type rpcResponse struct { + JSONRPC string `json:"jsonrpc"` + ID int `json:"id"` + Result json.RawMessage `json:"result"` + Error json.RawMessage `json:"error"` +} + +type toolCallResult struct { + IsError bool `json:"isError"` + StructuredContent map[string]any `json:"structuredContent"` +} + +func TestMnemonMCPStdioProcess(t *testing.T) { + repository := repositoryRoot(t) + binary := filepath.Join(t.TempDir(), "mnemon") + if runtime.GOOS == "windows" { + binary += ".exe" + } + build := exec.Command("go", "build", "-o", binary, ".") + build.Dir = repository + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build mnemon: %v\n%s", err, output) + } + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + dataDir := t.TempDir() + command := exec.CommandContext(ctx, binary, "--data-dir", dataDir, "--store", "mcp-e2e", "mcp", "serve") + command.Env = append(os.Environ(), "MNEMON_EMBED_ENDPOINT=http://127.0.0.1:1") + stdin, err := command.StdinPipe() + if err != nil { + t.Fatal(err) + } + stdout, err := command.StdoutPipe() + if err != nil { + t.Fatal(err) + } + stderr, err := command.StderrPipe() + if err != nil { + t.Fatal(err) + } + if err := command.Start(); err != nil { + t.Fatal(err) + } + encoder := json.NewEncoder(stdin) + decoder := json.NewDecoder(bufio.NewReader(stdout)) + + writeRPC(t, encoder, map[string]any{ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": map[string]any{ + "protocolVersion": "2025-06-18", "capabilities": map[string]any{}, + "clientInfo": map[string]any{"name": "process-e2e", "version": "1"}, + }, + }) + initialize := readRPC(t, decoder, 1) + var initialized struct { + ProtocolVersion string `json:"protocolVersion"` + ServerInfo struct { + Name string `json:"name"` + Version string `json:"version"` + } `json:"serverInfo"` + } + decodeResult(t, initialize, &initialized) + if initialized.ProtocolVersion != "2025-06-18" || initialized.ServerInfo.Name != "mnemon" { + t.Fatalf("initialize result = %#v", initialized) + } + writeRPC(t, encoder, map[string]any{ + "jsonrpc": "2.0", "method": "notifications/initialized", "params": map[string]any{}, + }) + + writeRPC(t, encoder, map[string]any{ + "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": map[string]any{}, + }) + listed := readRPC(t, decoder, 2) + var tools struct { + Tools []struct { + Name string `json:"name"` + } `json:"tools"` + } + decodeResult(t, listed, &tools) + names := make([]string, 0, len(tools.Tools)) + for _, tool := range tools.Tools { + names = append(names, tool.Name) + } + slices.Sort(names) + if want := []string{"link", "recall", "related", "remember", "search", "status"}; !slices.Equal(names, want) { + t.Fatalf("tool names = %v, want %v", names, want) + } + + remembered := callTool(t, encoder, decoder, 3, "remember", map[string]any{ + "content": "process boundary durable memory", "category": "fact", "no_diff": true, + }) + id, _ := remembered.StructuredContent["id"].(string) + if remembered.IsError || id == "" { + t.Fatalf("remember result = %#v", remembered) + } + searched := callTool(t, encoder, decoder, 4, "search", map[string]any{"query": "process durable"}) + results, _ := searched.StructuredContent["results"].([]any) + if searched.IsError || len(results) != 1 { + t.Fatalf("search result = %#v", searched) + } + status := callTool(t, encoder, decoder, 5, "status", map[string]any{}) + if status.IsError || status.StructuredContent["total_insights"] != float64(1) { + t.Fatalf("status result = %#v", status) + } + + if err := stdin.Close(); err != nil { + t.Fatal(err) + } + stderrBytes, readErr := io.ReadAll(stderr) + if readErr != nil { + t.Fatal(readErr) + } + if err := command.Wait(); err != nil { + t.Fatalf("wait for MCP server: %v\nstderr: %s", err, stderrBytes) + } + if len(stderrBytes) != 0 { + t.Fatalf("MCP server wrote unexpected diagnostics: %s", stderrBytes) + } + if _, err := os.Stat(filepath.Join(dataDir, "data", "mcp-e2e", "mnemon.db")); err != nil { + t.Fatalf("global data/store flags did not select the expected database: %v", err) + } +} + +func callTool(t *testing.T, encoder *json.Encoder, decoder *json.Decoder, + id int, name string, arguments map[string]any) toolCallResult { + t.Helper() + writeRPC(t, encoder, map[string]any{ + "jsonrpc": "2.0", "id": id, "method": "tools/call", + "params": map[string]any{"name": name, "arguments": arguments}, + }) + response := readRPC(t, decoder, id) + var result toolCallResult + decodeResult(t, response, &result) + return result +} + +func writeRPC(t *testing.T, encoder *json.Encoder, message map[string]any) { + t.Helper() + if err := encoder.Encode(message); err != nil { + t.Fatalf("write JSON-RPC message: %v", err) + } +} + +func readRPC(t *testing.T, decoder *json.Decoder, id int) rpcResponse { + t.Helper() + for { + var response rpcResponse + if err := decoder.Decode(&response); err != nil { + t.Fatalf("read JSON-RPC response %d: %v", id, err) + } + if response.ID != id { + continue + } + if len(response.Error) != 0 && string(response.Error) != "null" { + t.Fatalf("JSON-RPC response %d failed: %s", id, response.Error) + } + return response + } +} + +func decodeResult(t *testing.T, response rpcResponse, output any) { + t.Helper() + if err := json.Unmarshal(response.Result, output); err != nil { + t.Fatalf("decode JSON-RPC response %d: %v\n%s", response.ID, err, response.Result) + } +} + +func repositoryRoot(t *testing.T) string { + t.Helper() + _, filename, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("resolve test filename") + } + root, err := filepath.Abs(filepath.Join(filepath.Dir(filename), "..", "..")) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(root, "go.mod")); err != nil { + t.Fatalf("repository root %q has no go.mod: %v", root, err) + } + return root +} diff --git a/test/mnemond/architecture/mnemond_structure_test.go b/test/mnemond/architecture/mnemond_structure_test.go index 466f648c..eb8a3f99 100644 --- a/test/mnemond/architecture/mnemond_structure_test.go +++ b/test/mnemond/architecture/mnemond_structure_test.go @@ -79,6 +79,13 @@ func assertMnemondPackageGraph(t *testing.T, root string) { "internal/memory/embed", "internal/memory/model", "internal/memory/search", "internal/memory/store", }, + "internal/memory/service": { + "internal/memory/embed", "internal/memory/graph", "internal/memory/model", + "internal/memory/search", "internal/memory/store", + }, + "internal/mcp": { + "internal/memory/model", "internal/memory/search", "internal/memory/service", + }, "internal/memory/setup/assets": {}, "internal/memory/setup": {"internal/memory/setup/assets"}, "internal/agency": {}, @@ -92,10 +99,11 @@ func assertMnemondPackageGraph(t *testing.T, root string) { "internal/agency/peerlink", }, "cmd/agency": {"internal/agency/attach", "internal/agency/client", "internal/daemon"}, + "cmd/mcp": {"internal/mcp", "internal/memory/service"}, "cmd/memory": { "internal/memory/embed", "internal/memory/graph", "internal/memory/importdraft", - "internal/memory/model", "internal/memory/search", "internal/memory/setup", - "internal/memory/setup/assets", "internal/memory/store", + "internal/memory/model", "internal/memory/search", "internal/memory/service", + "internal/memory/setup", "internal/memory/setup/assets", "internal/memory/store", }, } got := make(map[string]map[string]struct{}, len(want)) diff --git a/test/mnemond/architecture/release_boundary_test.go b/test/mnemond/architecture/release_boundary_test.go index 06cc1f60..5617d227 100644 --- a/test/mnemond/architecture/release_boundary_test.go +++ b/test/mnemond/architecture/release_boundary_test.go @@ -22,13 +22,13 @@ func TestReleaseBoundary(t *testing.T) { t.Run("all retained Go packages belong to the root module", func(t *testing.T) { assertSingleModuleImportLaw(t, root) }) - t.Run("the release has one mnemon executable with two command domains", func(t *testing.T) { + t.Run("the release has one mnemon executable with formal command namespaces", func(t *testing.T) { assertFormalCommands(t, root) }) t.Run("retired command and Harness topology is absent", func(t *testing.T) { assertRetiredHarnessAbsent(t, root) }) - t.Run("command help preserves Memory and Agency separation", func(t *testing.T) { + t.Run("command help preserves Memory Agency and MCP separation", func(t *testing.T) { assertCommandHelpSeparation(t, root) }) } @@ -123,12 +123,13 @@ func assertImportsUseRootModule(t *testing.T, base string) { func assertFormalCommands(t *testing.T, root string) { t.Helper() - assertDirectoryNames(t, filepath.Join(root, "cmd"), []string{"agency", "memory"}) + assertDirectoryNames(t, filepath.Join(root, "cmd"), []string{"agency", "mcp", "memory"}) assertRootCommandDelegatesToCmd(t, root) for target, want := range map[string]string{ ".": "main", "./cmd": "cmd", "./cmd/agency": "agency", + "./cmd/mcp": "mcp", "./cmd/memory": "memory", } { command := exec.Command("go", "list", "-f", "{{.Name}}", target) @@ -199,7 +200,7 @@ func assertCommandHelpSeparation(t *testing.T, root string) { mnemon := commandHelp(t, root) wantMnemon := []string{ "agency", "completion", "embed", "forget", "gc", "help", "import", "link", "log", - "recall", "receipt", "related", "remember", "search", "setup", "show", "status", + "mcp", "recall", "receipt", "related", "remember", "search", "setup", "show", "status", "store", "viz", } if got := cobraTopLevelCommands(mnemon); !slices.Equal(got, wantMnemon) { @@ -210,6 +211,11 @@ func assertCommandHelpSeparation(t *testing.T, root string) { if got, want := cobraTopLevelCommands(agency), []string{"peer", "serve", "setup"}; !slices.Equal(got, want) { t.Errorf("mnemon agency top-level commands = %v, want %v", got, want) } + + mcp := commandHelp(t, root, "mcp") + if got, want := cobraTopLevelCommands(mcp), []string{"serve"}; !slices.Equal(got, want) { + t.Errorf("mnemon mcp top-level commands = %v, want %v", got, want) + } } func cobraTopLevelCommands(help []byte) []string {