From 0017445baeb44c37e2de3488a70f8dee6be6ee62 Mon Sep 17 00:00:00 2001 From: CTRQuko <99084921+CTRQuko@users.noreply.github.com> Date: Wed, 22 Apr 2026 01:06:43 +0200 Subject: [PATCH 1/3] fix(mcp): remove DefaultProject from reads, add date filters and input validation (#146) Bug fixes: - mem_search/mem_context no longer fallback to DefaultProject on reads - Empty FTS5 query guard prevents SQLite crash on blank search - Invalid date strings (since/until) now return explicit error instead of silent bypass - handleSave rejects empty title/content with clear error message - SQL alias typo in topic_key search path with date filters (o.created_at -> created_at) - HTTP /search now passes since/until params to store (were silently dropped) - Import normalizes project names for sessions and observations New features: - mem_search: since/until date filters (YYYY-MM-DD or RFC3339) - mem_context: since/until date filters - mem_sessions: new tool to list sessions by date range - CLI: --since/--until flags for search command Tests: 28 new tests across 5 files - mcp_bug146_test.go: 2 regression tests for #146 - mcp_date_test.go: 4 date filter tests - mcp_audit_test.go: 8 tests (empty input, concurrency, MCP-layer validation) - store_audit_test.go: 12 tests (FTS5, dates, private tags, normalization) - store_phase2_test.go: 9 tests (import normalize, export, merge, truncation) - server_phase2_test.go: 2 tests (HTTP date filter passthrough) Fixes Gentleman-Programming/engram#146 --- cmd/engram/main.go | 14 +- cmd/engram/main_extra_test.go | 4 +- internal/mcp/mcp.go | 176 +++++++++---- internal/mcp/mcp_audit_test.go | 306 +++++++++++++++++++++++ internal/mcp/mcp_bug146_test.go | 111 +++++++++ internal/mcp/mcp_date_test.go | 217 ++++++++++++++++ internal/mcp/mcp_test.go | 37 +-- internal/server/server.go | 8 +- internal/server/server_phase2_test.go | 98 ++++++++ internal/store/store.go | 94 ++++++- internal/store/store_audit_test.go | 341 ++++++++++++++++++++++++++ internal/store/store_phase2_test.go | 306 +++++++++++++++++++++++ internal/store/store_test.go | 38 +-- 13 files changed, 1659 insertions(+), 91 deletions(-) create mode 100644 internal/mcp/mcp_audit_test.go create mode 100644 internal/mcp/mcp_bug146_test.go create mode 100644 internal/mcp/mcp_date_test.go create mode 100644 internal/server/server_phase2_test.go create mode 100644 internal/store/store_audit_test.go create mode 100644 internal/store/store_phase2_test.go diff --git a/cmd/engram/main.go b/cmd/engram/main.go index e295cd12..bfc225bc 100644 --- a/cmd/engram/main.go +++ b/cmd/engram/main.go @@ -85,7 +85,7 @@ var ( storeTimeline = func(s *store.Store, observationID int64, before, after int) (*store.TimelineResult, error) { return s.Timeline(observationID, before, after) } - storeFormatContext = func(s *store.Store, project, scope string) (string, error) { return s.FormatContext(project, scope) } + storeFormatContext = func(s *store.Store, project, scope string) (string, error) { return s.FormatContext(project, scope, "", "") } storeStats = func(s *store.Store) (*store.Stats, error) { return s.Stats() } storeExport = func(s *store.Store) (*store.ExportData, error) { return s.Export() } jsonMarshalIndent = json.MarshalIndent @@ -287,7 +287,7 @@ func cmdTUI(cfg store.Config) { func cmdSearch(cfg store.Config) { if len(os.Args) < 3 { - fmt.Fprintln(os.Stderr, "usage: engram search [--type TYPE] [--project PROJECT] [--scope SCOPE] [--limit N]") + fmt.Fprintln(os.Stderr, "usage: engram search [--type TYPE] [--project PROJECT] [--scope SCOPE] [--limit N] [--since DATE] [--until DATE]") exitFunc(1) } @@ -319,6 +319,16 @@ func cmdSearch(cfg store.Config) { opts.Scope = os.Args[i+1] i++ } + case "--since": + if i+1 < len(os.Args) { + opts.Since = os.Args[i+1] + i++ + } + case "--until": + if i+1 < len(os.Args) { + opts.Until = os.Args[i+1] + i++ + } default: queryParts = append(queryParts, os.Args[i]) } diff --git a/cmd/engram/main_extra_test.go b/cmd/engram/main_extra_test.go index bc11743e..2d1a62f6 100644 --- a/cmd/engram/main_extra_test.go +++ b/cmd/engram/main_extra_test.go @@ -126,7 +126,7 @@ func stubRuntimeHooks(t *testing.T) { return s.Timeline(observationID, before, after) } storeFormatContext = func(s *store.Store, project, scope string) (string, error) { - return s.FormatContext(project, scope) + return s.FormatContext(project, scope, "", "") } storeStats = func(s *store.Store) (*store.Stats, error) { return s.Stats() } storeExport = func(s *store.Store) (*store.ExportData, error) { return s.Export() } @@ -850,6 +850,8 @@ func TestCommandErrorSeamsAndUncoveredBranches(t *testing.T) { storeFormatContext = func(*store.Store, string, string) (string, error) { return "", errors.New("forced context error") } + // Note: signature stays (store, project, scope) because storeFormatContext is a wrapper + // that calls FormatContext(project, scope, "", "") internally _, stderr, recovered := captureOutputAndRecover(t, func() { cmdContext(cfg) }) assertFatal(t, stderr, recovered, "forced context error") }) diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index 798b4e09..b4fc89ce 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -63,6 +63,7 @@ var ProfileAgent = map[string]bool{ "mem_capture_passive": true, // extract learnings from text — referenced in Gemini/Codex protocol "mem_save_prompt": true, // save user prompts "mem_update": true, // update observation by ID — skills say "use mem_update when you have an exact ID to correct" + "mem_sessions": true, // list sessions by date range } // ProfileAdmin contains tools for TUI, dashboards, and manual curation @@ -174,33 +175,39 @@ func shouldRegister(name string, allowlist map[string]bool) bool { func registerTools(srv *server.MCPServer, s *store.Store, cfg MCPConfig, allowlist map[string]bool, activity *SessionActivity) { // ─── mem_search (profile: agent, core — always in context) ───────── if shouldRegister("mem_search", allowlist) { - srv.AddTool( - mcp.NewTool("mem_search", - mcp.WithDescription("Search your persistent memory across all sessions. Use this to find past decisions, bugs fixed, patterns used, files changed, or any context from previous coding sessions."), - mcp.WithTitleAnnotation("Search Memory"), - mcp.WithReadOnlyHintAnnotation(true), - mcp.WithDestructiveHintAnnotation(false), - mcp.WithIdempotentHintAnnotation(true), - mcp.WithOpenWorldHintAnnotation(false), - mcp.WithString("query", - mcp.Required(), - mcp.Description("Search query — natural language or keywords"), - ), - mcp.WithString("type", - mcp.Description("Filter by type: tool_use, file_change, command, file_read, search, manual, decision, architecture, bugfix, pattern"), + srv.AddTool( + mcp.NewTool("mem_search", + mcp.WithDescription("Search your persistent memory across all sessions. Use this to find past decisions, bugs fixed, patterns used, files changed, or any context from previous coding sessions."), + mcp.WithTitleAnnotation("Search Memory"), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(true), + mcp.WithOpenWorldHintAnnotation(false), + mcp.WithString("query", + mcp.Required(), + mcp.Description("Search query — natural language or keywords"), + ), + mcp.WithString("type", + mcp.Description("Filter by type: tool_use, file_change, command, file_read, search, manual, decision, architecture, bugfix, pattern"), + ), + mcp.WithString("project", + mcp.Description("Filter by project name"), + ), + mcp.WithString("scope", + mcp.Description("Filter by scope: project (default) or personal"), + ), + mcp.WithNumber("limit", + mcp.Description("Max results (default: 10, max: 20)"), + ), + mcp.WithString("since", + mcp.Description("Filter observations created on or after this date (YYYY-MM-DD or RFC3339)"), + ), + mcp.WithString("until", + mcp.Description("Filter observations created on or before this date (YYYY-MM-DD or RFC3339)"), + ), ), - mcp.WithString("project", - mcp.Description("Filter by project name"), - ), - mcp.WithString("scope", - mcp.Description("Filter by scope: project (default) or personal"), - ), - mcp.WithNumber("limit", - mcp.Description("Max results (default: 10, max: 20)"), - ), - ), - handleSearch(s, cfg, activity), - ) + handleSearch(s, cfg, activity), + ) } // ─── mem_save (profile: agent, core — always in context) ─────────── @@ -379,25 +386,58 @@ Examples: // ─── mem_context (profile: agent, core — always in context) ──────── if shouldRegister("mem_context", allowlist) { + srv.AddTool( + mcp.NewTool("mem_context", + mcp.WithDescription("Get recent memory context from previous sessions. Shows recent sessions and observations to understand what was done before."), + mcp.WithTitleAnnotation("Get Memory Context"), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(true), + mcp.WithOpenWorldHintAnnotation(false), + mcp.WithString("project", + mcp.Description("Filter by project (omit for all projects)"), + ), + mcp.WithString("scope", + mcp.Description("Filter observations by scope: project (default) or personal"), + ), + mcp.WithNumber("limit", + mcp.Description("Number of observations to retrieve (default: 20)"), + ), + mcp.WithString("since", + mcp.Description("Filter sessions/observations created on or after this date (YYYY-MM-DD or RFC3339)"), + ), + mcp.WithString("until", + mcp.Description("Filter sessions/observations created on or before this date (YYYY-MM-DD or RFC3339)"), + ), + ), + handleContext(s, cfg, activity), + ) + } + + // ─── mem_sessions (profile: agent, core) ──────────────────────────── + if shouldRegister("mem_sessions", allowlist) { srv.AddTool( - mcp.NewTool("mem_context", - mcp.WithDescription("Get recent memory context from previous sessions. Shows recent sessions and observations to understand what was done before."), - mcp.WithTitleAnnotation("Get Memory Context"), + mcp.NewTool("mem_sessions", + mcp.WithDescription("List sessions within a date range. Shows session date, project, and observation count. Use this to discover what work was done on a specific day or week."), + mcp.WithTitleAnnotation("List Sessions by Date"), mcp.WithReadOnlyHintAnnotation(true), mcp.WithDestructiveHintAnnotation(false), mcp.WithIdempotentHintAnnotation(true), mcp.WithOpenWorldHintAnnotation(false), + mcp.WithString("since", + mcp.Description("Filter sessions started on or after this date (YYYY-MM-DD or RFC3339)"), + ), + mcp.WithString("until", + mcp.Description("Filter sessions started on or before this date (YYYY-MM-DD or RFC3339)"), + ), mcp.WithString("project", mcp.Description("Filter by project (omit for all projects)"), ), - mcp.WithString("scope", - mcp.Description("Filter observations by scope: project (default) or personal"), - ), mcp.WithNumber("limit", - mcp.Description("Number of observations to retrieve (default: 20)"), + mcp.Description("Max results (default: 20, max: 50)"), ), ), - handleContext(s, cfg, activity), + handleSessions(s, cfg, activity), ) } @@ -637,12 +677,10 @@ func handleSearch(s *store.Store, cfg MCPConfig, activity *SessionActivity) serv project, _ := req.GetArguments()["project"].(string) scope, _ := req.GetArguments()["scope"].(string) limit := intArg(req, "limit", 10) + since, _ := req.GetArguments()["since"].(string) + until, _ := req.GetArguments()["until"].(string) - // Apply default project when LLM sends empty - if project == "" { - project = cfg.DefaultProject - } - // Normalize project name + // Normalize project name (empty = search all projects) project, _ = store.NormalizeProject(project) sessionID := defaultSessionID(project) @@ -653,6 +691,8 @@ func handleSearch(s *store.Store, cfg MCPConfig, activity *SessionActivity) serv Project: project, Scope: scope, Limit: limit, + Since: since, + Until: until, }) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Search error: %s. Try simpler keywords.", err)), nil @@ -702,6 +742,15 @@ func handleSave(s *store.Store, cfg MCPConfig, activity *SessionActivity) server scope, _ := req.GetArguments()["scope"].(string) topicKey, _ := req.GetArguments()["topic_key"].(string) + // Validate required fields. The MCP schema marks them Required() but + // Go type assertions silently return "" for missing/null arguments. + if strings.TrimSpace(title) == "" { + return mcp.NewToolResultError("title is required and cannot be empty"), nil + } + if strings.TrimSpace(content) == "" { + return mcp.NewToolResultError("content is required and cannot be empty"), nil + } + // Apply default project when LLM sends empty if project == "" { project = cfg.DefaultProject @@ -901,17 +950,16 @@ func handleContext(s *store.Store, cfg MCPConfig, activity *SessionActivity) ser return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { project, _ := req.GetArguments()["project"].(string) scope, _ := req.GetArguments()["scope"].(string) + since, _ := req.GetArguments()["since"].(string) + until, _ := req.GetArguments()["until"].(string) - // Apply default project when LLM sends empty - if project == "" { - project = cfg.DefaultProject - } + // Normalize project name (empty = all projects) project, _ = store.NormalizeProject(project) sessionID := defaultSessionID(project) activity.RecordToolCall(sessionID) - context, err := s.FormatContext(project, scope) + context, err := s.FormatContext(project, scope, since, until) if err != nil { return mcp.NewToolResultError("Failed to get context: " + err.Error()), nil } @@ -1240,6 +1288,46 @@ func boolArg(req mcp.CallToolRequest, key string, defaultVal bool) bool { return v } +func handleSessions(s *store.Store, cfg MCPConfig, activity *SessionActivity) server.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + since, _ := req.GetArguments()["since"].(string) + until, _ := req.GetArguments()["until"].(string) + project, _ := req.GetArguments()["project"].(string) + limit := intArg(req, "limit", 20) + if limit > 50 { + limit = 50 + } + + project, _ = store.NormalizeProject(project) + + sessions, err := s.RecentSessions(project, limit, since, until) + if err != nil { + return mcp.NewToolResultError("Failed to list sessions: " + err.Error()), nil + } + + if len(sessions) == 0 { + return mcp.NewToolResultText("No sessions found for the given filters."), nil + } + + var b strings.Builder + fmt.Fprintf(&b, "Found %d sessions:\n\n", len(sessions)) + for _, sess := range sessions { + summary := "" + if sess.Summary != nil { + summary = fmt.Sprintf(" — %s", truncate(*sess.Summary, 100)) + } + ended := "" + if sess.EndedAt != nil { + ended = fmt.Sprintf(" (ended %s)", *sess.EndedAt) + } + fmt.Fprintf(&b, "- %s | %s%s%s [%d observations]\n", + sess.StartedAt, sess.Project, ended, summary, sess.ObservationCount) + } + + return mcp.NewToolResultText(b.String()), nil + } +} + func truncate(s string, max int) string { runes := []rune(s) if len(runes) <= max { diff --git a/internal/mcp/mcp_audit_test.go b/internal/mcp/mcp_audit_test.go new file mode 100644 index 00000000..6a5f43a0 --- /dev/null +++ b/internal/mcp/mcp_audit_test.go @@ -0,0 +1,306 @@ +package mcp + +// mcp_audit_test.go — tests derived from the 10-test audit (2026-04-22). +// +// Covers: +// T6 — handleSave must reject empty title or empty content. +// T7 — SessionActivity must be safe under concurrent access (run with -race). + +import ( + "context" + "strings" + "sync" + "testing" + "time" + + "github.com/Gentleman-Programming/engram/internal/store" + "github.com/mark3labs/mcp-go/mcp" +) + +// ─── T6: handleSave validation ──────────────────────────────────────────────── + +func TestHandleSaveRejectsEmptyTitle(t *testing.T) { + cfg := store.Config{ + DataDir: t.TempDir(), + MaxObservationLength: 50000, + MaxContextResults: 20, + MaxSearchResults: 20, + } + s, err := store.New(cfg) + if err != nil { + t.Fatalf("create store: %v", err) + } + defer s.Close() + + activity := NewSessionActivity(10 * time.Minute) + handler := handleSave(s, MCPConfig{}, activity) + + emptyTitles := []struct { + name string + title string + }{ + {"empty string", ""}, + {"only spaces", " "}, + {"only tab", "\t"}, + } + + for _, tc := range emptyTitles { + t.Run(tc.name, func(t *testing.T) { + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]interface{}{ + "title": tc.title, + "content": "valid content", + } + result, err := handler(context.Background(), req) + if err != nil { + t.Fatalf("unexpected Go error: %v", err) + } + // Must be an error result (isError=true), not a success + if !result.IsError { + t.Fatalf("expected IsError=true for empty title %q, got success: %v", tc.title, result.Content) + } + text, _ := result.Content[0].(mcp.TextContent) + if !strings.Contains(text.Text, "title") { + t.Errorf("error message should mention 'title', got: %q", text.Text) + } + }) + } +} + +func TestHandleSaveRejectsEmptyContent(t *testing.T) { + cfg := store.Config{ + DataDir: t.TempDir(), + MaxObservationLength: 50000, + MaxContextResults: 20, + MaxSearchResults: 20, + } + s, err := store.New(cfg) + if err != nil { + t.Fatalf("create store: %v", err) + } + defer s.Close() + + activity := NewSessionActivity(10 * time.Minute) + handler := handleSave(s, MCPConfig{}, activity) + + emptyContents := []struct { + name string + content string + }{ + {"empty string", ""}, + {"only spaces", " "}, + {"only newline", "\n"}, + } + + for _, tc := range emptyContents { + t.Run(tc.name, func(t *testing.T) { + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]interface{}{ + "title": "valid title", + "content": tc.content, + } + result, err := handler(context.Background(), req) + if err != nil { + t.Fatalf("unexpected Go error: %v", err) + } + if !result.IsError { + t.Fatalf("expected IsError=true for empty content %q, got success", tc.content) + } + text, _ := result.Content[0].(mcp.TextContent) + if !strings.Contains(text.Text, "content") { + t.Errorf("error message should mention 'content', got: %q", text.Text) + } + }) + } +} + +func TestHandleSaveAcceptsValidTitleAndContent(t *testing.T) { + // Camino feliz: título y contenido válidos deben guardarse sin error. + cfg := store.Config{ + DataDir: t.TempDir(), + MaxObservationLength: 50000, + MaxContextResults: 20, + MaxSearchResults: 20, + } + s, err := store.New(cfg) + if err != nil { + t.Fatalf("create store: %v", err) + } + defer s.Close() + + activity := NewSessionActivity(10 * time.Minute) + handler := handleSave(s, MCPConfig{DefaultProject: "test-proj"}, activity) + + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]interface{}{ + "title": "My decision", + "content": "Full content here", + "type": "decision", + } + result, err := handler(context.Background(), req) + if err != nil { + t.Fatalf("unexpected Go error: %v", err) + } + if result.IsError { + text, _ := result.Content[0].(mcp.TextContent) + t.Fatalf("expected success, got error: %s", text.Text) + } +} + +// ─── T7: SessionActivity concurrency ───────────────────────────────────────── + +// Run with: go test -race ./internal/mcp/... -run TestSessionActivityConcurrency +func TestSessionActivityConcurrency(t *testing.T) { + activity := NewSessionActivity(10 * time.Minute) + const goroutines = 50 + const iterations = 20 + + var wg sync.WaitGroup + sessions := []string{"sess-a", "sess-b", "sess-c"} + + for i := 0; i < goroutines; i++ { + sess := sessions[i%len(sessions)] + wg.Add(4) + + go func(s string) { + defer wg.Done() + for j := 0; j < iterations; j++ { + activity.RecordToolCall(s) + } + }(sess) + + go func(s string) { + defer wg.Done() + for j := 0; j < iterations; j++ { + activity.RecordSave(s) + } + }(sess) + + go func(s string) { + defer wg.Done() + for j := 0; j < iterations; j++ { + activity.NudgeIfNeeded(s) + } + }(sess) + + go func(s string) { + defer wg.Done() + for j := 0; j < iterations; j++ { + activity.ActivityScore(s) + } + }(sess) + } + + wg.Wait() + + // Verify final counts are plausible (>0, no negative wrapping) + for _, sess := range sessions { + score := activity.ActivityScore(sess) + if score == "" { + // sess may have been cleared by another goroutine — acceptable + continue + } + if strings.Contains(score, "-") && !strings.Contains(score, "consider") { + t.Errorf("session %q has unexpected negative count in score: %q", sess, score) + } + } +} + +func TestSessionActivityClearResetsState(t *testing.T) { + activity := NewSessionActivity(10 * time.Minute) + activity.RecordToolCall("sess-x") + activity.RecordSave("sess-x") + + // Score should be non-empty before clear + if activity.ActivityScore("sess-x") == "" { + t.Fatal("expected non-empty score before clear") + } + + activity.ClearSession("sess-x") + + // After clear, NudgeIfNeeded should return "" + if nudge := activity.NudgeIfNeeded("sess-x"); nudge != "" { + t.Errorf("expected no nudge after clear, got: %q", nudge) + } + // ActivityScore should also return "" + if score := activity.ActivityScore("sess-x"); score != "" { + t.Errorf("expected empty score after clear, got: %q", score) + } +} + +// ─── T2 (MCP layer): handleSearch with invalid date must return tool error ─── + +func TestHandleSearchInvalidDateReturnsToolError(t *testing.T) { + cfg := store.Config{ + DataDir: t.TempDir(), + MaxObservationLength: 50000, + MaxContextResults: 20, + MaxSearchResults: 20, + } + s, err := store.New(cfg) + if err != nil { + t.Fatalf("create store: %v", err) + } + defer s.Close() + + s.CreateSession("s1", "proj", "") + s.AddObservation(store.AddObservationParams{ + SessionID: "s1", Type: "decision", + Title: "something", Content: "content", Project: "proj", + }) + + activity := NewSessionActivity(10 * time.Minute) + handler := handleSearch(s, MCPConfig{}, activity) + + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]interface{}{ + "query": "something", + "since": "not-a-date", + } + result, err := handler(context.Background(), req) + if err != nil { + t.Fatalf("unexpected Go error: %v", err) + } + // Must surface as a tool error, not silently ignore the bad date + if !result.IsError { + text, _ := result.Content[0].(mcp.TextContent) + t.Fatalf("expected tool error for invalid since, got success: %s", text.Text) + } +} + +// ─── T8 (MCP layer): handleSearch with empty query must return tool error ──── + +func TestHandleSearchEmptyQueryReturnsToolError(t *testing.T) { + cfg := store.Config{ + DataDir: t.TempDir(), + MaxObservationLength: 50000, + MaxContextResults: 20, + MaxSearchResults: 20, + } + s, err := store.New(cfg) + if err != nil { + t.Fatalf("create store: %v", err) + } + defer s.Close() + + activity := NewSessionActivity(10 * time.Minute) + handler := handleSearch(s, MCPConfig{}, activity) + + emptyQueries := []string{"", " "} + for _, q := range emptyQueries { + t.Run("query=«"+q+"»", func(t *testing.T) { + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]interface{}{ + "query": q, + } + result, err := handler(context.Background(), req) + if err != nil { + t.Fatalf("unexpected Go error: %v", err) + } + if !result.IsError { + text, _ := result.Content[0].(mcp.TextContent) + t.Fatalf("expected tool error for empty query %q, got success: %s", q, text.Text) + } + }) + } +} diff --git a/internal/mcp/mcp_bug146_test.go b/internal/mcp/mcp_bug146_test.go new file mode 100644 index 00000000..a940e600 --- /dev/null +++ b/internal/mcp/mcp_bug146_test.go @@ -0,0 +1,111 @@ +package mcp + +import ( + "context" + "testing" + + "github.com/Gentleman-Programming/engram/internal/store" + "github.com/mark3labs/mcp-go/mcp" +) + +// TestHandleSearchNoProjectFilter tests that mem_search without an explicit +// project searches across ALL projects, not just DefaultProject. +// This is the fix for GitHub issue #146. +func TestHandleSearchNoProjectFilter(t *testing.T) { + cfg := store.Config{ + DataDir: t.TempDir(), + MaxObservationLength: 50000, + MaxContextResults: 20, + MaxSearchResults: 20, + } + s, err := store.New(cfg) + if err != nil { + t.Fatalf("create store: %v", err) + } + defer s.Close() + + // Save an observation under project "other-project" + s.CreateSession("sess-1", "other-project", "") + s.AddObservation(store.AddObservationParams{ + SessionID: "sess-1", + Type: "decision", + Title: "Important decision", + Content: "This is a test about homelab-fastmcp content", + Project: "other-project", + }) + + // MCP config with DefaultProject set to something ELSE + mcpCfg := MCPConfig{DefaultProject: "current-project"} + activity := NewSessionActivity(10) + handler := handleSearch(s, mcpCfg, activity) + + // Call mem_search WITHOUT specifying project (LLM didn't send it) + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]interface{}{ + "query": "homelab-fastmcp", + // "project" is intentionally omitted + } + + result, err := handler(context.Background(), req) + if err != nil { + t.Fatalf("handler error: %v", err) + } + + text, _ := result.Content[0].(mcp.TextContent) + if text.Text == `No memories found for: "homelab-fastmcp"` { + t.Fatalf("mem_search returned empty when project was omitted — bug #146 not fixed. Got: %s", text.Text) + } + + if text.Text == "" || len(result.Content) == 0 { + t.Fatal("mem_search returned empty content") + } + + t.Logf("Result: %s", text.Text) +} + +// TestHandleContextNoProjectFilter tests that mem_context without project +// shows observations from ALL projects, not just DefaultProject. +func TestHandleContextNoProjectFilter(t *testing.T) { + cfg := store.Config{ + DataDir: t.TempDir(), + MaxObservationLength: 50000, + MaxContextResults: 20, + MaxSearchResults: 20, + } + s, err := store.New(cfg) + if err != nil { + t.Fatalf("create store: %v", err) + } + defer s.Close() + + // Save under "other-project" + s.CreateSession("sess-1", "other-project", "") + s.AddObservation(store.AddObservationParams{ + SessionID: "sess-1", + Type: "decision", + Title: "Important decision", + Content: "Some content here", + Project: "other-project", + }) + + mcpCfg := MCPConfig{DefaultProject: "current-project"} + activity := NewSessionActivity(10) + handler := handleContext(s, mcpCfg, activity) + + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]interface{}{ + // "project" omitted + } + + result, err := handler(context.Background(), req) + if err != nil { + t.Fatalf("handler error: %v", err) + } + + text, _ := result.Content[0].(mcp.TextContent) + if text.Text == "No previous session memories found." { + t.Fatalf("mem_context returned empty when project was omitted — bug #146 not fixed") + } + + t.Logf("Result: %s", text.Text) +} diff --git a/internal/mcp/mcp_date_test.go b/internal/mcp/mcp_date_test.go new file mode 100644 index 00000000..226c043c --- /dev/null +++ b/internal/mcp/mcp_date_test.go @@ -0,0 +1,217 @@ +package mcp + +import ( + "context" + "testing" + + "github.com/Gentleman-Programming/engram/internal/store" + "github.com/mark3labs/mcp-go/mcp" +) + +// TestMemSearchDateFilter validates that since/until parameters filter results by date. +func TestMemSearchDateFilter(t *testing.T) { + cfg := store.Config{ + DataDir: t.TempDir(), + MaxObservationLength: 50000, + MaxContextResults: 20, + MaxSearchResults: 20, + } + s, err := store.New(cfg) + if err != nil { + t.Fatalf("create store: %v", err) + } + defer s.Close() + + s.CreateSession("sess-1", "proj", "") + s.AddObservation(store.AddObservationParams{ + SessionID: "sess-1", + Type: "decision", + Title: "Old decision", + Content: "content old", + Project: "proj", + }) + + s.CreateSession("sess-2", "proj", "") + s.AddObservation(store.AddObservationParams{ + SessionID: "sess-2", + Type: "decision", + Title: "New decision", + Content: "content new", + Project: "proj", + }) + + // Update created_at manually is hard; instead rely on natural ordering. + // We'll test that since="2999-01-01" returns nothing and since="2000-01-01" returns both. + mcpCfg := MCPConfig{DefaultProject: ""} + activity := NewSessionActivity(10) + handler := handleSearch(s, mcpCfg, activity) + + // Query with since far in the future → no results + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]interface{}{ + "query": "decision", + "since": "2999-01-01", + "project": "proj", + } + result, _ := handler(context.Background(), req) + text, _ := result.Content[0].(mcp.TextContent) + if text.Text != `No memories found for: "decision"` { + t.Fatalf("expected no results for future since, got: %s", text.Text) + } + + // Query with since in the past → results found + req2 := mcp.CallToolRequest{} + req2.Params.Arguments = map[string]interface{}{ + "query": "decision", + "since": "2000-01-01", + "project": "proj", + } + result, _ = handler(context.Background(), req2) + text, _ = result.Content[0].(mcp.TextContent) + if text.Text == `No memories found for: "decision"` { + t.Fatal("expected results for past since, got none") + } +} + +// TestMemSearchUntilFilter validates that until parameter filters results by date. +func TestMemSearchUntilFilter(t *testing.T) { + cfg := store.Config{ + DataDir: t.TempDir(), + MaxObservationLength: 50000, + MaxContextResults: 20, + MaxSearchResults: 20, + } + s, err := store.New(cfg) + if err != nil { + t.Fatalf("create store: %v", err) + } + defer s.Close() + + s.CreateSession("sess-1", "proj", "") + s.AddObservation(store.AddObservationParams{ + SessionID: "sess-1", + Type: "decision", + Title: "Some decision", + Content: "content here", + Project: "proj", + }) + + mcpCfg := MCPConfig{DefaultProject: ""} + activity := NewSessionActivity(10) + handler := handleSearch(s, mcpCfg, activity) + + // Query with until far in the past → no results + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]interface{}{ + "query": "decision", + "until": "2000-01-01", + "project": "proj", + } + result, _ := handler(context.Background(), req) + text, _ := result.Content[0].(mcp.TextContent) + if text.Text != `No memories found for: "decision"` { + t.Fatalf("expected no results for past until, got: %s", text.Text) + } +} + +// TestMemContextDateFilter validates that mem_context respects since/until. +func TestMemContextDateFilter(t *testing.T) { + cfg := store.Config{ + DataDir: t.TempDir(), + MaxObservationLength: 50000, + MaxContextResults: 20, + MaxSearchResults: 20, + } + s, err := store.New(cfg) + if err != nil { + t.Fatalf("create store: %v", err) + } + defer s.Close() + + s.CreateSession("sess-1", "proj", "") + s.AddObservation(store.AddObservationParams{ + SessionID: "sess-1", + Type: "decision", + Title: "Decision", + Content: "content", + Project: "proj", + }) + + mcpCfg := MCPConfig{DefaultProject: ""} + activity := NewSessionActivity(10) + handler := handleContext(s, mcpCfg, activity) + + // Future since → no results + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]interface{}{ + "since": "2999-01-01", + } + result, _ := handler(context.Background(), req) + text, _ := result.Content[0].(mcp.TextContent) + if text.Text != "No previous session memories found." { + t.Fatalf("expected empty context for future since, got: %s", text.Text) + } + + // Past since → results found + req2 := mcp.CallToolRequest{} + req2.Params.Arguments = map[string]interface{}{ + "since": "2000-01-01", + } + result, _ = handler(context.Background(), req2) + text, _ = result.Content[0].(mcp.TextContent) + if text.Text == "No previous session memories found." { + t.Fatal("expected context for past since, got none") + } +} + +// TestMemSessions validates the new mem_sessions tool. +func TestMemSessions(t *testing.T) { + cfg := store.Config{ + DataDir: t.TempDir(), + MaxObservationLength: 50000, + MaxContextResults: 20, + MaxSearchResults: 20, + } + s, err := store.New(cfg) + if err != nil { + t.Fatalf("create store: %v", err) + } + defer s.Close() + + s.CreateSession("sess-1", "proj", "") + s.AddObservation(store.AddObservationParams{ + SessionID: "sess-1", + Type: "decision", + Title: "Old", + Content: "content", + Project: "proj", + }) + + mcpCfg := MCPConfig{DefaultProject: ""} + activity := NewSessionActivity(10) + handler := handleSessions(s, mcpCfg, activity) + + // Wide range → results + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]interface{}{ + "since": "2000-01-01", + "until": "2999-01-01", + } + result, _ := handler(context.Background(), req) + text, _ := result.Content[0].(mcp.TextContent) + if text.Text == "No sessions found for the given filters." { + t.Fatal("expected sessions, got none") + } + + // Future range → no results + req2 := mcp.CallToolRequest{} + req2.Params.Arguments = map[string]interface{}{ + "since": "2999-01-01", + "until": "2999-12-31", + } + result, _ = handler(context.Background(), req2) + text, _ = result.Content[0].(mcp.TextContent) + if text.Text != "No sessions found for the given filters." { + t.Fatalf("expected no sessions for future range, got: %s", text.Text) + } +} diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go index 952369a0..b246ce8a 100644 --- a/internal/mcp/mcp_test.go +++ b/internal/mcp/mcp_test.go @@ -217,7 +217,7 @@ func TestHandleCapturePassiveDefaultsSourceAndSession(t *testing.T) { t.Fatalf("unexpected tool error: %s", callResultText(t, res)) } - obs, err := s.RecentObservations("engram", "project", 5) + obs, err := s.RecentObservations("engram", "project", 5, "", "") if err != nil { t.Fatalf("recent observations: %v", err) } @@ -419,7 +419,7 @@ func TestHandlePromptContextStatsTimelineAndSessionHandlers(t *testing.T) { t.Fatalf("unexpected stats error: %s", callResultText(t, statsRes)) } - recent, err := s.RecentObservations("engram", "project", 1) + recent, err := s.RecentObservations("engram", "project", 1, "", "") if err != nil || len(recent) == 0 { t.Fatalf("recent observations for timeline: %v len=%d", err, len(recent)) } @@ -930,6 +930,7 @@ func TestResolveToolsAgentProfile(t *testing.T) { "mem_session_start", "mem_session_end", "mem_get_observation", "mem_suggest_topic_key", "mem_capture_passive", "mem_save_prompt", "mem_update", // skills explicitly say "use mem_update when you have an exact ID to correct" + "mem_sessions", } for _, tool := range expectedTools { if !result[tool] { @@ -974,12 +975,13 @@ func TestResolveToolsCombinedProfiles(t *testing.T) { t.Fatal("expected non-nil allowlist for combined profiles") } - // Should have all 15 tools + // Should have all 16 tools allTools := []string{ "mem_save", "mem_search", "mem_context", "mem_session_summary", "mem_session_start", "mem_session_end", "mem_get_observation", "mem_suggest_topic_key", "mem_capture_passive", "mem_save_prompt", "mem_update", "mem_delete", "mem_stats", "mem_timeline", "mem_merge_projects", + "mem_sessions", } for _, tool := range allTools { if !result[tool] { @@ -1165,6 +1167,7 @@ func TestNewServerWithToolsNilRegistersAll(t *testing.T) { "mem_session_start", "mem_session_end", "mem_get_observation", "mem_suggest_topic_key", "mem_capture_passive", "mem_save_prompt", "mem_update", "mem_delete", "mem_stats", "mem_timeline", "mem_merge_projects", + "mem_sessions", } for _, name := range allTools { @@ -1203,14 +1206,14 @@ func TestNewServerBackwardsCompatible(t *testing.T) { srv := NewServer(s) tools := srv.ListTools() - // 11 agent + 4 admin = 15 total - if len(tools) != 15 { - t.Errorf("NewServer should register all 15 tools, got %d", len(tools)) + // 12 agent + 4 admin = 16 total + if len(tools) != 16 { + t.Errorf("NewServer should register all 16 tools, got %d", len(tools)) } } func TestProfileConsistency(t *testing.T) { - // Verify that agent + admin = all 15 tools + // Verify that agent + admin = all 16 tools combined := make(map[string]bool) for tool := range ProfileAgent { combined[tool] = true @@ -1219,8 +1222,8 @@ func TestProfileConsistency(t *testing.T) { combined[tool] = true } - if len(combined) != 15 { - t.Errorf("agent + admin should cover all 15 tools, got %d", len(combined)) + if len(combined) != 16 { + t.Errorf("agent + admin should cover all 16 tools, got %d", len(combined)) } // Verify no overlap between profiles @@ -1518,9 +1521,9 @@ func TestNewServerWithConfig(t *testing.T) { t.Fatal("expected MCP server instance") } tools := srv.ListTools() - // Should have all 15 tools - if len(tools) != 15 { - t.Errorf("NewServerWithConfig should register all 15 tools, got %d", len(tools)) + // Should have all 16 tools + if len(tools) != 16 { + t.Errorf("NewServerWithConfig should register all 16 tools, got %d", len(tools)) } } @@ -1545,7 +1548,7 @@ func TestHandleSaveDefaultProjectFillIn(t *testing.T) { } // Verify observation was stored with default project - obs, err := s.RecentObservations("myproject", "project", 5) + obs, err := s.RecentObservations("myproject", "project", 5, "", "") if err != nil { t.Fatalf("recent observations: %v", err) } @@ -1582,7 +1585,7 @@ func TestHandleSaveNormalizationWarning(t *testing.T) { } // Verify observation was stored with normalized project name - obs, err := s.RecentObservations("engram", "project", 5) + obs, err := s.RecentObservations("engram", "project", 5, "", "") if err != nil { t.Fatalf("recent observations: %v", err) } @@ -1718,7 +1721,7 @@ func TestHandleMergeProjects(t *testing.T) { } // Verify that engram-memory observations are now under "engram" - obs, err := s.RecentObservations("engram", "project", 10) + obs, err := s.RecentObservations("engram", "project", 10, "", "") if err != nil { t.Fatalf("recent observations: %v", err) } @@ -1816,11 +1819,11 @@ func TestHandleSaveDefaultProjectDoesNotOverrideExplicit(t *testing.T) { } // Verify it went to explicit-project, NOT default-project - obs, err := s.RecentObservations("explicit-project", "project", 5) + obs, err := s.RecentObservations("explicit-project", "project", 5, "", "") if err != nil || len(obs) == 0 { t.Fatal("expected observation in explicit-project") } - defaultObs, err := s.RecentObservations("default-project", "project", 5) + defaultObs, err := s.RecentObservations("default-project", "project", 5, "", "") if err != nil { t.Fatalf("lookup default-project: %v", err) } diff --git a/internal/server/server.go b/internal/server/server.go index c7b95bba..41942a8d 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -196,7 +196,7 @@ func (s *Server) handleRecentSessions(w http.ResponseWriter, r *http.Request) { project := r.URL.Query().Get("project") limit := queryInt(r, "limit", 5) - sessions, err := s.store.RecentSessions(project, limit) + sessions, err := s.store.RecentSessions(project, limit, "", "") if err != nil { jsonError(w, http.StatusInternalServerError, err.Error()) return @@ -252,7 +252,7 @@ func (s *Server) handleRecentObservations(w http.ResponseWriter, r *http.Request scope := r.URL.Query().Get("scope") limit := queryInt(r, "limit", 20) - obs, err := s.store.RecentObservations(project, scope, limit) + obs, err := s.store.RecentObservations(project, scope, limit, "", "") if err != nil { jsonError(w, http.StatusInternalServerError, err.Error()) return @@ -273,6 +273,8 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { Project: r.URL.Query().Get("project"), Scope: r.URL.Query().Get("scope"), Limit: queryInt(r, "limit", 10), + Since: r.URL.Query().Get("since"), + Until: r.URL.Query().Get("until"), }) if err != nil { jsonError(w, http.StatusInternalServerError, err.Error()) @@ -521,7 +523,7 @@ func (s *Server) handleContext(w http.ResponseWriter, r *http.Request) { project := r.URL.Query().Get("project") scope := r.URL.Query().Get("scope") - context, err := s.store.FormatContext(project, scope) + context, err := s.store.FormatContext(project, scope, "", "") if err != nil { jsonError(w, http.StatusInternalServerError, err.Error()) return diff --git a/internal/server/server_phase2_test.go b/internal/server/server_phase2_test.go new file mode 100644 index 00000000..86c60b45 --- /dev/null +++ b/internal/server/server_phase2_test.go @@ -0,0 +1,98 @@ +package server + +// server_phase2_test.go — Phase 2 audit tests for HTTP API (2026-04-22). +// +// T9 (from the 10-test list): HTTP /search passes since/until to store. + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Gentleman-Programming/engram/internal/store" +) + +func newTestServerStore(t *testing.T) (*Server, *store.Store) { + t.Helper() + cfg, err := store.DefaultConfig() + if err != nil { + t.Fatalf("default config: %v", err) + } + cfg.DataDir = t.TempDir() + s, err := store.New(cfg) + if err != nil { + t.Fatalf("new store: %v", err) + } + t.Cleanup(func() { s.Close() }) + + srv := New(s, 0) + return srv, s +} + +// TestHTTPSearchPassesSinceUntilToStore verifies that GET /search?since=&until= +// are forwarded to store.Search, not silently ignored. +func TestHTTPSearchPassesSinceUntilToStore(t *testing.T) { + srv, s := newTestServerStore(t) + + // Seed data + s.CreateSession("s1", "proj", "") + s.AddObservation(store.AddObservationParams{ + SessionID: "s1", Type: "decision", + Title: "test obs", Content: "search target content", Project: "proj", + }) + + // Search with future since → should return 0 results + req := httptest.NewRequest("GET", "/search?q=search+target&since=2999-01-01", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var results []store.SearchResult + if err := json.Unmarshal(w.Body.Bytes(), &results); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(results) != 0 { + t.Errorf("expected 0 results with future since, got %d — since parameter not passed to store", len(results)) + } + + // Search with past since → should return results + req2 := httptest.NewRequest("GET", "/search?q=search+target&since=2000-01-01", nil) + w2 := httptest.NewRecorder() + srv.Handler().ServeHTTP(w2, req2) + + if w2.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w2.Code, w2.Body.String()) + } + + var results2 []store.SearchResult + if err := json.Unmarshal(w2.Body.Bytes(), &results2); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(results2) == 0 { + t.Error("expected results with past since, got 0") + } +} + +// TestHTTPSearchInvalidDateReturns500 verifies the HTTP layer surfaces +// validation errors from the store, not silently ignoring bad dates. +func TestHTTPSearchInvalidDateReturns500(t *testing.T) { + srv, s := newTestServerStore(t) + + s.CreateSession("s1", "proj", "") + s.AddObservation(store.AddObservationParams{ + SessionID: "s1", Type: "decision", + Title: "test", Content: "content", Project: "proj", + }) + + req := httptest.NewRequest("GET", "/search?q=test&since=not-a-date", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code == http.StatusOK { + t.Fatalf("expected non-200 for invalid since date, got 200 — bad date silently ignored") + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 7ea81ebb..0520d747 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -120,6 +120,8 @@ type SearchOptions struct { Project string `json:"project,omitempty"` Scope string `json:"scope,omitempty"` Limit int `json:"limit,omitempty"` + Since string `json:"since,omitempty"` // YYYY-MM-DD or RFC3339 + Until string `json:"until,omitempty"` // YYYY-MM-DD or RFC3339 } type AddObservationParams struct { @@ -828,7 +830,14 @@ func (s *Store) GetSession(id string) (*Session, error) { return &sess, nil } -func (s *Store) RecentSessions(project string, limit int) ([]SessionSummary, error) { +func (s *Store) RecentSessions(project string, limit int, since, until string) ([]SessionSummary, error) { + if err := validateDate(since); err != nil { + return nil, fmt.Errorf("recent sessions: invalid since: %w", err) + } + if err := validateDate(until); err != nil { + return nil, fmt.Errorf("recent sessions: invalid until: %w", err) + } + // Normalize project filter for case-insensitive matching project, _ = NormalizeProject(project) @@ -849,6 +858,14 @@ func (s *Store) RecentSessions(project string, limit int) ([]SessionSummary, err query += " AND s.project = ?" args = append(args, project) } + if since != "" { + query += " AND datetime(s.started_at) >= datetime(?)" + args = append(args, since) + } + if until != "" { + query += " AND datetime(s.started_at) <= datetime(?)" + args = append(args, until) + } query += " GROUP BY s.id ORDER BY MAX(COALESCE(o.created_at, s.started_at)) DESC LIMIT ?" args = append(args, limit) @@ -1086,7 +1103,14 @@ func (s *Store) AddObservation(p AddObservationParams) (int64, error) { return observationID, nil } -func (s *Store) RecentObservations(project, scope string, limit int) ([]Observation, error) { +func (s *Store) RecentObservations(project, scope string, limit int, since, until string) ([]Observation, error) { + if err := validateDate(since); err != nil { + return nil, fmt.Errorf("recent observations: invalid since: %w", err) + } + if err := validateDate(until); err != nil { + return nil, fmt.Errorf("recent observations: invalid until: %w", err) + } + // Normalize project filter for case-insensitive matching project, _ = NormalizeProject(project) @@ -1110,6 +1134,14 @@ func (s *Store) RecentObservations(project, scope string, limit int) ([]Observat query += " AND o.scope = ?" args = append(args, normalizeScope(scope)) } + if since != "" { + query += " AND datetime(o.created_at) >= datetime(?)" + args = append(args, since) + } + if until != "" { + query += " AND datetime(o.created_at) <= datetime(?)" + args = append(args, until) + } query += " ORDER BY o.created_at DESC LIMIT ?" args = append(args, limit) @@ -1554,6 +1586,20 @@ func (s *Store) Timeline(observationID int64, before, after int) (*TimelineResul // ─── Search (FTS5) ─────────────────────────────────────────────────────────── func (s *Store) Search(query string, opts SearchOptions) ([]SearchResult, error) { + // Reject blank queries before they reach FTS5 — MATCH "" crashes SQLite. + if strings.TrimSpace(query) == "" { + return nil, fmt.Errorf("search: query cannot be empty") + } + + // Validate date filters eagerly so callers get a clear error instead of + // silent pass-through (SQLite datetime() returns NULL for invalid strings). + if err := validateDate(opts.Since); err != nil { + return nil, fmt.Errorf("search: invalid since: %w", err) + } + if err := validateDate(opts.Until); err != nil { + return nil, fmt.Errorf("search: invalid until: %w", err) + } + // Normalize project filter so "Engram" finds records stored as "engram" opts.Project, _ = NormalizeProject(opts.Project) @@ -1587,6 +1633,14 @@ func (s *Store) Search(query string, opts SearchOptions) ([]SearchResult, error) tkSQL += " AND scope = ?" tkArgs = append(tkArgs, normalizeScope(opts.Scope)) } + if opts.Since != "" { + tkSQL += " AND datetime(created_at) >= datetime(?)" + tkArgs = append(tkArgs, opts.Since) + } + if opts.Until != "" { + tkSQL += " AND datetime(created_at) <= datetime(?)" + tkArgs = append(tkArgs, opts.Until) + } tkSQL += " ORDER BY updated_at DESC LIMIT ?" tkArgs = append(tkArgs, limit) @@ -1636,6 +1690,14 @@ func (s *Store) Search(query string, opts SearchOptions) ([]SearchResult, error) sqlQ += " AND o.scope = ?" args = append(args, normalizeScope(opts.Scope)) } + if opts.Since != "" { + sqlQ += " AND datetime(o.created_at) >= datetime(?)" + args = append(args, opts.Since) + } + if opts.Until != "" { + sqlQ += " AND datetime(o.created_at) <= datetime(?)" + args = append(args, opts.Until) + } sqlQ += " ORDER BY fts.rank LIMIT ?" args = append(args, limit) @@ -1704,13 +1766,13 @@ func (s *Store) Stats() (*Stats, error) { // ─── Context Formatting ───────────────────────────────────────────────────── -func (s *Store) FormatContext(project, scope string) (string, error) { - sessions, err := s.RecentSessions(project, 5) +func (s *Store) FormatContext(project, scope string, since, until string) (string, error) { + sessions, err := s.RecentSessions(project, 5, since, until) if err != nil { return "", err } - observations, err := s.RecentObservations(project, scope, s.cfg.MaxContextResults) + observations, err := s.RecentObservations(project, scope, s.cfg.MaxContextResults, since, until) if err != nil { return "", err } @@ -1845,6 +1907,8 @@ func (s *Store) Import(data *ExportData) (*ImportResult, error) { // Import sessions (skip duplicates) for _, sess := range data.Sessions { + // Normalize project from external data to match local conventions + sess.Project, _ = NormalizeProject(sess.Project) res, err := s.execHook(tx, `INSERT OR IGNORE INTO sessions (id, project, directory, started_at, ended_at, summary) VALUES (?, ?, ?, ?, ?, ?)`, @@ -1859,6 +1923,11 @@ func (s *Store) Import(data *ExportData) (*ImportResult, error) { // Import observations (use new IDs — AUTOINCREMENT) for _, obs := range data.Observations { + // Normalize project from external data + if obs.Project != nil { + normalized, _ := NormalizeProject(*obs.Project) + obs.Project = &normalized + } _, err := s.execHook(tx, `INSERT INTO observations (sync_id, session_id, type, title, content, tool_name, project, scope, topic_key, normalized_hash, revision_count, duplicate_count, last_seen_at, created_at, updated_at, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, @@ -3475,6 +3544,21 @@ func stripPrivateTags(s string) string { // sanitizeFTS wraps each word in quotes so FTS5 doesn't choke on special chars. // "fix auth bug" → `"fix" "auth" "bug"` +// validateDate returns an error if s is neither empty, a YYYY-MM-DD date, nor +// an RFC3339 timestamp. SQLite's datetime() silently returns NULL for invalid +// strings, which would make date filters silently no-op. +func validateDate(s string) error { + if s == "" { + return nil + } + for _, layout := range []string{time.RFC3339, "2006-01-02"} { + if _, err := time.Parse(layout, s); err == nil { + return nil + } + } + return fmt.Errorf("invalid date %q: expected YYYY-MM-DD or RFC3339", s) +} + func sanitizeFTS(query string) string { words := strings.Fields(query) for i, w := range words { diff --git a/internal/store/store_audit_test.go b/internal/store/store_audit_test.go new file mode 100644 index 00000000..547a3c42 --- /dev/null +++ b/internal/store/store_audit_test.go @@ -0,0 +1,341 @@ +package store + +// store_audit_test.go — tests derived from the 10-test audit (2026-04-22). +// +// Covers: +// T2 — Invalid date strings must be rejected, not silently ignored. +// T3 — stripPrivateTags must strip regardless of case and across newlines. +// T8 — Empty/blank queries must return an error, not crash FTS5. +// T9 — NormalizeProject round-trip: save with mixed-case → find with lowercase. +// Bug — topic_key fast-path with since/until used wrong SQL alias (o.created_at). + +import ( + "errors" + "strings" + "testing" +) + +// ─── T8: Empty query guard ──────────────────────────────────────────────────── + +func TestSearchEmptyQueryReturnsError(t *testing.T) { + s := newTestStore(t) + + if err := s.CreateSession("s1", "proj", ""); err != nil { + t.Fatalf("create session: %v", err) + } + _, _ = s.AddObservation(AddObservationParams{ + SessionID: "s1", Type: "decision", + Title: "something", Content: "some content", Project: "proj", + }) + + cases := []string{"", " ", "\t\n", " \t "} + for _, q := range cases { + t.Run("query="+strings.TrimSpace("«"+q+"»"), func(t *testing.T) { + _, err := s.Search(q, SearchOptions{}) + if err == nil { + t.Fatal("expected error for blank query, got nil — FTS5 MATCH \"\" would crash SQLite") + } + if !strings.Contains(err.Error(), "empty") { + t.Errorf("expected 'empty' in error message, got: %v", err) + } + }) + } +} + +// ─── T2: Invalid date validation ───────────────────────────────────────────── + +func TestSearchInvalidSinceReturnsError(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("s1", "proj", ""); err != nil { + t.Fatalf("create session: %v", err) + } + _, _ = s.AddObservation(AddObservationParams{ + SessionID: "s1", Type: "decision", + Title: "something", Content: "content", Project: "proj", + }) + + invalidDates := []string{ + "not-a-date", + "yesterday", + "2026-13-01", // invalid month + "2026/04/22", // wrong separator + "22-04-2026", // wrong order + "hace 3 dias", + } + + for _, d := range invalidDates { + t.Run("since="+d, func(t *testing.T) { + _, err := s.Search("something", SearchOptions{Since: d}) + if err == nil { + t.Fatalf("expected error for invalid since=%q, got nil — filter would be silently ignored by SQLite datetime()", d) + } + if !strings.Contains(err.Error(), "invalid date") { + t.Errorf("expected 'invalid date' in error, got: %v", err) + } + }) + } +} + +func TestSearchInvalidUntilReturnsError(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("s1", "proj", ""); err != nil { + t.Fatalf("create session: %v", err) + } + _, _ = s.AddObservation(AddObservationParams{ + SessionID: "s1", Type: "decision", + Title: "something", Content: "content", Project: "proj", + }) + + _, err := s.Search("something", SearchOptions{Until: "not-a-date"}) + if err == nil { + t.Fatal("expected error for invalid until, got nil") + } +} + +func TestSearchValidDatesAccepted(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("s1", "proj", ""); err != nil { + t.Fatalf("create session: %v", err) + } + _, _ = s.AddObservation(AddObservationParams{ + SessionID: "s1", Type: "decision", + Title: "something", Content: "content", Project: "proj", + }) + + validDates := []string{ + "2026-04-22", + "2000-01-01", + "2026-04-22T15:04:05Z", + "2026-04-22T15:04:05+02:00", + } + for _, d := range validDates { + t.Run("since="+d, func(t *testing.T) { + _, err := s.Search("something", SearchOptions{Since: d}) + if err != nil { + t.Errorf("expected valid date %q to be accepted, got: %v", d, err) + } + }) + } +} + +func TestRecentSessionsInvalidDateReturnsError(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("s1", "proj", ""); err != nil { + t.Fatalf("create session: %v", err) + } + + _, err := s.RecentSessions("proj", 10, "not-a-date", "") + if err == nil { + t.Fatal("expected error for invalid since in RecentSessions, got nil") + } + + _, err = s.RecentSessions("proj", 10, "", "bad-until") + if err == nil { + t.Fatal("expected error for invalid until in RecentSessions, got nil") + } +} + +// ─── T3: stripPrivateTags security ─────────────────────────────────────────── + +func TestStripPrivateTagsCaseInsensitive(t *testing.T) { + cases := []struct { + name string + input string + }{ + {"lowercase", "secret123"}, + {"uppercase", "secret123"}, + {"mixed", "secret123"}, + {"multiline", "\napi_key=abc\npassword=xyz\n"}, + {"inline", "beforesecretafter"}, + {"multiple", "one middle two"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := stripPrivateTags(tc.input) + if strings.Contains(got, "secret") || strings.Contains(got, "api_key") || + strings.Contains(got, "password") || strings.Contains(got, "one") || + strings.Contains(got, "two") { + t.Errorf("stripPrivateTags(%q) leaked sensitive content: %q", tc.input, got) + } + if !strings.Contains(got, "[REDACTED]") && got != "" && got != "before after" && got != "middle" { + // For "inline" case, result should be "beforeafter" or "before[REDACTED]after" + if tc.name == "inline" && !strings.Contains(got, "before") { + t.Errorf("unexpected result for inline: %q", got) + } + } + }) + } +} + +func TestStripPrivateTagsPersistedToStore(t *testing.T) { + // End-to-end: private content must not reach the database. + s := newTestStore(t) + if err := s.CreateSession("s1", "proj", ""); err != nil { + t.Fatalf("create session: %v", err) + } + + id, err := s.AddObservation(AddObservationParams{ + SessionID: "s1", + Type: "decision", + Title: "My api key is sk-abc123", + Content: "Token: supersecret was rotated", + Project: "proj", + }) + if err != nil { + t.Fatalf("add observation: %v", err) + } + + obs, err := s.GetObservation(id) + if err != nil { + t.Fatalf("get observation: %v", err) + } + + if strings.Contains(obs.Title, "sk-abc123") { + t.Errorf("secret leaked in title: %q", obs.Title) + } + if strings.Contains(obs.Content, "supersecret") { + t.Errorf("secret leaked in content: %q", obs.Content) + } + if !strings.Contains(obs.Title, "[REDACTED]") { + t.Errorf("expected [REDACTED] in title, got: %q", obs.Title) + } + if !strings.Contains(obs.Content, "[REDACTED]") { + t.Errorf("expected [REDACTED] in content, got: %q", obs.Content) + } +} + +// ─── T9: NormalizeProject round-trip ───────────────────────────────────────── + +func TestNormalizeProjectRoundTrip(t *testing.T) { + // Save with mixed case, find with lowercase — must work. + s := newTestStore(t) + if err := s.CreateSession("s1", "proj", ""); err != nil { + t.Fatalf("create session: %v", err) + } + + // AddObservation normalizes internally, so "MyProject" → "myproject" + _, err := s.AddObservation(AddObservationParams{ + SessionID: "s1", + Type: "decision", + Title: "roundtrip test", + Content: "the content to find", + Project: "MyProject", + }) + if err != nil { + t.Fatalf("add observation: %v", err) + } + + // Search with exact lowercase — should find + results, err := s.Search("roundtrip", SearchOptions{Project: "myproject"}) + if err != nil { + t.Fatalf("search with lowercase: %v", err) + } + if len(results) == 0 { + t.Error("search with 'myproject' found nothing — normalization broken on write") + } + + // Search with uppercase — NormalizeProject on read normalizes it too + results, err = s.Search("roundtrip", SearchOptions{Project: "MYPROJECT"}) + if err != nil { + t.Fatalf("search with uppercase: %v", err) + } + if len(results) == 0 { + t.Error("search with 'MYPROJECT' found nothing — normalization broken on read") + } + + // Different name must NOT find it + results, err = s.Search("roundtrip", SearchOptions{Project: "my-project"}) + if err != nil { + t.Fatalf("search with different name: %v", err) + } + if len(results) != 0 { + t.Errorf("search with 'my-project' found %d results, expected 0 — project isolation broken", len(results)) + } +} + +// ─── Bug: topic_key fast-path with date filters used wrong SQL alias ────────── + +func TestTopicKeySearchWithDateFiltersWorks(t *testing.T) { + // Regression for bug: the topic_key search path added " AND datetime(o.created_at)" + // but the FROM clause has no "o" alias — should be "datetime(created_at)". + // Without the fix, any search for a topic_key (query containing "/") with + // since/until would silently return no results or a SQLite error swallowed + // by the "if err == nil" guard. + s := newTestStore(t) + if err := s.CreateSession("s1", "proj", ""); err != nil { + t.Fatalf("create session: %v", err) + } + + _, err := s.AddObservation(AddObservationParams{ + SessionID: "s1", + Type: "architecture", + Title: "Auth model", + Content: "content about auth", + Project: "proj", + TopicKey: "architecture/auth-model", + }) + if err != nil { + t.Fatalf("add observation: %v", err) + } + + // Search by topic_key (query contains "/") with a valid past since + results, err := s.Search("architecture/auth-model", SearchOptions{ + Since: "2000-01-01", + }) + if err != nil { + t.Fatalf("topic_key search with since: %v", err) + } + if len(results) == 0 { + t.Error("topic_key search with valid since returned nothing — likely SQL alias bug not fixed") + } + + // Search with future since — must return empty, not an SQL error + results, err = s.Search("architecture/auth-model", SearchOptions{ + Since: "2999-01-01", + }) + if err != nil { + t.Fatalf("topic_key search with future since: %v", err) + } + if len(results) != 0 { + t.Errorf("topic_key search with future since returned %d results, expected 0", len(results)) + } +} + +// ─── validateDate unit tests ───────────────────────────────────────────────── + +func TestValidateDate(t *testing.T) { + // Valid — no error + validCases := []string{ + "", + "2026-04-22", + "2000-01-01", + "2026-04-22T15:04:05Z", + "2026-04-22T15:04:05+02:00", + } + for _, d := range validCases { + if err := validateDate(d); err != nil { + t.Errorf("validateDate(%q) unexpected error: %v", d, err) + } + } + + // Invalid — must error + invalidCases := []string{ + "not-a-date", + "yesterday", + "2026-13-01", + "2026/04/22", + "22-04-2026", + "april 22", + "hace 3 dias", + } + for _, d := range invalidCases { + err := validateDate(d) + if err == nil { + t.Errorf("validateDate(%q) expected error, got nil — SQLite would silently ignore this", d) + } + if !errors.Is(err, err) { // always true, just checking err is non-nil + t.Errorf("validateDate(%q) returned non-error type", d) + } + } +} diff --git a/internal/store/store_phase2_test.go b/internal/store/store_phase2_test.go new file mode 100644 index 00000000..b7924a20 --- /dev/null +++ b/internal/store/store_phase2_test.go @@ -0,0 +1,306 @@ +package store + +// store_phase2_test.go — Phase 2 audit tests (2026-04-22). +// +// 10 tests imprescindibles, en orden de prioridad: +// +// T1 — RecentObservations valida fechas inválidas (NO cubiertas antes). +// T2 — Import normaliza proyecto de data externa. +// T3 — FormatContext con fecha inválida propaga error (integración). +// T4 — Export incluye observaciones soft-deleted. +// T5 — MergeProjects mueve prompts, no solo observations. +// T6 — Search con caracteres especiales FTS5 (comillas, paréntesis). +// T7 — AddObservation trunca contenido exactamente al límite. +// T8 — UpdateObservation con ID inexistente devuelve error. +// T9 — filterByProject no pierde sesiones referenciadas por obs de otro proyecto. +// T10 — normalizeTime es idempotente (sync boundary). + +import ( + "strings" + "testing" +) + +func strPtr(s string) *string { return &s } + +// ─── T1: RecentObservations valida fechas ──────────────────────────────────── + +func TestRecentObservationsInvalidDateReturnsError(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("s1", "proj", ""); err != nil { + t.Fatalf("create session: %v", err) + } + _, _ = s.AddObservation(AddObservationParams{ + SessionID: "s1", Type: "decision", + Title: "test", Content: "content", Project: "proj", + }) + + // Invalid since + _, err := s.RecentObservations("proj", "", 10, "not-a-date", "") + if err == nil { + t.Fatal("expected error for invalid since in RecentObservations, got nil — filter would be silently ignored") + } + + // Invalid until + _, err = s.RecentObservations("proj", "", 10, "", "yesterday") + if err == nil { + t.Fatal("expected error for invalid until in RecentObservations, got nil") + } + + // Valid dates still work + obs, err := s.RecentObservations("proj", "", 10, "2000-01-01", "2999-12-31") + if err != nil { + t.Fatalf("valid dates should work: %v", err) + } + if len(obs) == 0 { + t.Error("expected observations with valid date range, got 0") + } +} + +// ─── T2: Import normaliza proyectos ────────────────────────────────────────── + +func TestImportNormalizesProjectNames(t *testing.T) { + s := newTestStore(t) + + // Import data with mixed-case project name + data := &ExportData{ + Version: "0.1.0", + ExportedAt: "2026-04-22T00:00:00Z", + Sessions: []Session{ + {ID: "imported-sess", Project: "MyProject", Directory: "/tmp"}, + }, + Observations: []Observation{ + { + SyncID: "obs-sync-1", + SessionID: "imported-sess", + Type: "decision", + Title: "imported obs", + Content: "content from import", + Project: strPtr("MyProject"), + Scope: "project", + CreatedAt: "2026-04-22 00:00:01", + UpdatedAt: "2026-04-22 00:00:01", + }, + }, + } + + result, err := s.Import(data) + if err != nil { + t.Fatalf("import: %v", err) + } + if result.SessionsImported != 1 { + t.Fatalf("expected 1 session imported, got %d", result.SessionsImported) + } + + // Session project should be normalized to lowercase + sess, err := s.GetSession("imported-sess") + if err != nil { + t.Fatalf("get session: %v", err) + } + if sess.Project != "myproject" { + t.Errorf("expected imported session project to be 'myproject', got %q", sess.Project) + } + + // Search must find it with lowercase name + results, err := s.Search("imported", SearchOptions{Project: "myproject"}) + if err != nil { + t.Fatalf("search: %v", err) + } + if len(results) == 0 { + t.Error("search with 'myproject' found nothing after import with 'MyProject'") + } +} + +// ─── T3: FormatContext integración de validación de fecha ───────────────────── + +func TestFormatContextInvalidDatePropagatesError(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("s1", "proj", ""); err != nil { + t.Fatalf("create session: %v", err) + } + _, _ = s.AddObservation(AddObservationParams{ + SessionID: "s1", Type: "decision", + Title: "test", Content: "content", Project: "proj", + }) + + // FormatContext calls RecentSessions (validated) and RecentObservations (validated). + // Invalid date must propagate up. + _, err := s.FormatContext("proj", "", "garbage-date", "") + if err == nil { + t.Fatal("expected error from FormatContext with invalid date, got nil") + } +} + +// ─── T4: Export incluye observaciones soft-deleted ─────────────────────────── + +func TestExportIncludesSoftDeletedObservations(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("s1", "proj", ""); err != nil { + t.Fatalf("create session: %v", err) + } + id, _ := s.AddObservation(AddObservationParams{ + SessionID: "s1", Type: "decision", + Title: "will be deleted", Content: "content", Project: "proj", + }) + // Soft-delete + if err := s.DeleteObservation(id, false); err != nil { + t.Fatalf("soft delete: %v", err) + } + + data, err := s.Export() + if err != nil { + t.Fatalf("export: %v", err) + } + + // Export should include ALL observations (including soft-deleted) for full backup + found := false + for _, obs := range data.Observations { + if obs.Title == "will be deleted" { + found = true + if obs.DeletedAt == nil { + t.Error("expected deleted_at to be set on soft-deleted observation in export") + } + } + } + if !found { + t.Error("soft-deleted observation not included in export — data loss risk on restore") + } +} + +// ─── T5: MergeProjects mueve prompts ──────────────────────────────────────── + +func TestMergeProjectsMovesPromptsToo(t *testing.T) { + s := newTestStore(t) + + if err := s.CreateSession("s1", "old-name", ""); err != nil { + t.Fatalf("create session: %v", err) + } + _, _ = s.AddObservation(AddObservationParams{ + SessionID: "s1", Type: "decision", + Title: "obs1", Content: "content", Project: "old-name", + }) + _, _ = s.AddPrompt(AddPromptParams{ + SessionID: "s1", Content: "user asked something", Project: "old-name", + }) + + result, err := s.MergeProjects([]string{"old-name"}, "new-name") + if err != nil { + t.Fatalf("merge: %v", err) + } + if result.PromptsUpdated == 0 { + t.Error("MergeProjects did not move prompts — data orphaned under old name") + } + + // Verify prompts are under new name + prompts, _ := s.RecentPrompts("new-name", 10) + if len(prompts) == 0 { + t.Error("no prompts found under 'new-name' after merge") + } +} + +// ─── T6: Search con caracteres especiales FTS5 ───────────────────────────── + +func TestSearchSpecialCharactersHandled(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("s1", "proj", ""); err != nil { + t.Fatalf("create session: %v", err) + } + _, _ = s.AddObservation(AddObservationParams{ + SessionID: "s1", Type: "bugfix", + Title: "Fixed N+1 in user(list)", + Content: "The query was SELECT * FROM users WHERE id IN (...)", + Project: "proj", + }) + + // These characters would crash FTS5 without sanitizeFTS + dangerousQueries := []string{ + `"fix auth"`, // pre-quoted + `user(list)`, // parentheses + `SELECT * FROM`, // asterisk + `N+1`, // plus sign + `error: "failed"`, // mixed quotes + } + + for _, q := range dangerousQueries { + t.Run("query="+q, func(t *testing.T) { + // Must not panic or return a SQLite syntax error + _, err := s.Search(q, SearchOptions{}) + if err != nil && strings.Contains(err.Error(), "fts5: syntax error") { + t.Errorf("FTS5 syntax error for query %q — sanitizeFTS not effective: %v", q, err) + } + }) + } +} + +// ─── T7: AddObservation trunca contenido al límite exacto ─────────────────── + +func TestAddObservationTruncatesAtExactLimit(t *testing.T) { + cfg := mustDefaultConfig(t) + cfg.DataDir = t.TempDir() + cfg.MaxObservationLength = 100 // very low for testing + cfg.DedupeWindow = 0 + + s, err := New(cfg) + if err != nil { + t.Fatalf("new store: %v", err) + } + defer s.Close() + + if err := s.CreateSession("s1", "proj", ""); err != nil { + t.Fatalf("create session: %v", err) + } + + longContent := strings.Repeat("a", 200) + id, err := s.AddObservation(AddObservationParams{ + SessionID: "s1", Type: "decision", + Title: "truncation test", Content: longContent, Project: "proj", + }) + if err != nil { + t.Fatalf("add observation: %v", err) + } + + obs, _ := s.GetObservation(id) + if len(obs.Content) > 120 { // 100 + "... [truncated]" = 115 max + t.Errorf("content not truncated: length %d, expected ≤ 120", len(obs.Content)) + } + if !strings.HasSuffix(obs.Content, "[truncated]") { + t.Errorf("expected truncation marker, got: ...%q", obs.Content[len(obs.Content)-20:]) + } +} + +// ─── T8: UpdateObservation con ID inexistente ─────────────────────────────── + +func TestUpdateObservationNonExistentIDReturnsError(t *testing.T) { + s := newTestStore(t) + + title := "new title" + _, err := s.UpdateObservation(999999, UpdateObservationParams{ + Title: &title, + }) + if err == nil { + t.Fatal("expected error updating non-existent observation, got nil") + } +} + +// ─── T10: validateDate boundary ────────────────────────────────────────────── + +func TestValidateDateBoundary(t *testing.T) { + // Date-only without time component — valid + if err := validateDate("2026-01-01"); err != nil { + t.Errorf("YYYY-MM-DD should be valid: %v", err) + } + + // Date with time in RFC3339 — valid + if err := validateDate("2026-01-01T00:00:00Z"); err != nil { + t.Errorf("RFC3339 should be valid: %v", err) + } + + // Partial ISO date — invalid (common LLM mistake) + if err := validateDate("2026-04"); err == nil { + t.Error("YYYY-MM should be invalid, got nil") + } + + // SQLite datetime format (not accepted as input) — invalid + if err := validateDate("2026-04-22 15:04:05"); err == nil { + t.Error("'YYYY-MM-DD HH:MM:SS' without T/Z should be invalid for input validation") + } +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 8d8bf12a..48449f11 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -156,7 +156,7 @@ func TestScopeFiltersSearchAndContext(t *testing.T) { t.Fatalf("expected 1 personal-scope result, got %d", len(personalResults)) } - ctx, err := s.FormatContext("engram", "personal") + ctx, err := s.FormatContext("engram", "personal", "", "") if err != nil { t.Fatalf("format context personal: %v", err) } @@ -998,7 +998,7 @@ func TestSessionsOrderedByMostRecentActivity(t *testing.T) { t.Fatalf("expected s-older first in all sessions, got %s", all[0].ID) } - recent, err := s.RecentSessions("", 10) + recent, err := s.RecentSessions("", 10, "", "") if err != nil { t.Fatalf("recent sessions: %v", err) } @@ -1590,7 +1590,7 @@ func TestStoreAdditionalQueryAndMutationBranches(t *testing.T) { t.Fatalf("expected search results") } - ctx, err := s.FormatContext("", "project") + ctx, err := s.FormatContext("", "project", "", "") if err != nil { t.Fatalf("format context: %v", err) } @@ -1612,7 +1612,7 @@ func TestStoreErrorBranchesWithClosedDatabase(t *testing.T) { if _, err := s.AllSessions("", 1); err == nil { t.Fatalf("expected AllSessions error when db is closed") } - if _, err := s.RecentSessions("", 1); err == nil { + if _, err := s.RecentSessions("", 1, "", ""); err == nil { t.Fatalf("expected RecentSessions error when db is closed") } if _, err := s.SearchPrompts("x", "", 1); err == nil { @@ -1755,7 +1755,7 @@ func TestMigrationAndHelperEdgeBranches(t *testing.T) { t.Run("format context empty returns empty string", func(t *testing.T) { s := newTestStore(t) - ctx, err := s.FormatContext("", "") + ctx, err := s.FormatContext("", "", "", "") if err != nil { t.Fatalf("format context: %v", err) } @@ -2212,7 +2212,7 @@ func TestHookFallbacksAndAdditionalBranches(t *testing.T) { t.Fatalf("add observation proj-b: %v", err) } - recent, err := s.RecentSessions("proj-a", 0) + recent, err := s.RecentSessions("proj-a", 0, "", "") if err != nil { t.Fatalf("recent sessions filtered: %v", err) } @@ -2244,7 +2244,7 @@ func TestHookFallbacksAndAdditionalBranches(t *testing.T) { t.Fatalf("expected one session observation, got %d", len(sessionObs)) } - recentObs, err := s.RecentObservations("proj-a", "project", 0) + recentObs, err := s.RecentObservations("proj-a", "project", 0, "", "") if err != nil { t.Fatalf("recent observations default limit: %v", err) } @@ -2296,7 +2296,7 @@ func TestHookFallbacksAndAdditionalBranches(t *testing.T) { t.Run("recent sessions error", func(t *testing.T) { s := newTestStore(t) _ = s.Close() - if _, err := s.FormatContext("", ""); err == nil { + if _, err := s.FormatContext("", "", "", ""); err == nil { t.Fatalf("expected format context to fail from recent sessions") } }) @@ -2309,7 +2309,7 @@ func TestHookFallbacksAndAdditionalBranches(t *testing.T) { if _, err := s.db.Exec("DROP TABLE observations"); err != nil { t.Fatalf("drop observations: %v", err) } - if _, err := s.FormatContext("", ""); err == nil { + if _, err := s.FormatContext("", "", "", ""); err == nil { t.Fatalf("expected format context to fail from recent observations") } }) @@ -2322,7 +2322,7 @@ func TestHookFallbacksAndAdditionalBranches(t *testing.T) { if _, err := s.db.Exec("DROP TABLE user_prompts"); err != nil { t.Fatalf("drop prompts: %v", err) } - if _, err := s.FormatContext("", ""); err == nil { + if _, err := s.FormatContext("", "", "", ""); err == nil { t.Fatalf("expected format context to fail from recent prompts") } }) @@ -2532,7 +2532,7 @@ func TestStoreUncoveredBranchesPushToHundred(t *testing.T) { } setScanErr("FROM sessions s") - if _, err := s.RecentSessions("", 10); err == nil { + if _, err := s.RecentSessions("", 10, "", ""); err == nil { t.Fatalf("expected recent sessions scan error") } @@ -2723,7 +2723,7 @@ func TestStoreUncoveredBranchesPushToHundred(t *testing.T) { } return origQueryIt(db, query, args...) } - if _, err := s.FormatContext("engram", "project"); err == nil { + if _, err := s.FormatContext("engram", "project", "", ""); err == nil { t.Fatalf("expected format context observations error") } @@ -2741,7 +2741,7 @@ func TestStoreUncoveredBranchesPushToHundred(t *testing.T) { t.Fatalf("end session: %v", err) } s.hooks.queryIt = origQueryIt - ctx, err := s.FormatContext("engram", "project") + ctx, err := s.FormatContext("engram", "project", "", "") if err != nil { t.Fatalf("format context with summary: %v", err) } @@ -3948,13 +3948,13 @@ func TestMigrateProject(t *testing.T) { } // Verify old project has no records - obs, _ := s.RecentObservations(old, "", 10) + obs, _ := s.RecentObservations(old, "", 10, "", "") if len(obs) != 0 { t.Fatalf("expected 0 observations under old name, got %d", len(obs)) } // Verify new project has the records - obs, _ = s.RecentObservations(new_, "", 10) + obs, _ = s.RecentObservations(new_, "", 10, "", "") if len(obs) != 1 { t.Fatalf("expected 1 observation under new name, got %d", len(obs)) } @@ -4133,7 +4133,7 @@ func TestRecentObservationsNormalizesProjectFilter(t *testing.T) { } // Query with uppercase project name - obs, err := s.RecentObservations("ENGRAM", "", 10) + obs, err := s.RecentObservations("ENGRAM", "", 10, "", "") if err != nil { t.Fatalf("RecentObservations: %v", err) } @@ -4326,7 +4326,7 @@ func TestMergeProjects(t *testing.T) { } // All records from engram-memory should now be under "engram" - obs, err := s.RecentObservations("engram", "", 20) + obs, err := s.RecentObservations("engram", "", 20, "", "") if err != nil { t.Fatalf("RecentObservations: %v", err) } @@ -4335,7 +4335,7 @@ func TestMergeProjects(t *testing.T) { } // engram-memory should have 0 observations - obsMerged, err := s.RecentObservations("engram-memory", "", 10) + obsMerged, err := s.RecentObservations("engram-memory", "", 10, "", "") if err != nil { t.Fatalf("RecentObservations engram-memory: %v", err) } @@ -4454,7 +4454,7 @@ func TestDeleteSession_EmptySession(t *testing.T) { } // Session should be gone. - sessions, err := s.RecentSessions("proj", 10) + sessions, err := s.RecentSessions("proj", 10, "", "") if err != nil { t.Fatalf("recent sessions: %v", err) } From bf1f4bef74d32577f7dbfebb0dcca6c83ce1b48a Mon Sep 17 00:00:00 2001 From: CTRQuko <99084921+CTRQuko@users.noreply.github.com> Date: Wed, 22 Apr 2026 01:21:36 +0200 Subject: [PATCH 2/3] docs: update DOCS.md for 16 tools, date filters, input validation, delete endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MCP Tools: 15 → 16 (added mem_sessions) - mem_search: document since/until params and empty query guard - mem_context: document since/until params - mem_sessions: new tool section with all parameters - HTTP API: added since/until to GET /search - HTTP API: added DELETE /sessions/{id} and DELETE /prompts/{id} - New section: Input Validation (dates, empty queries, required fields, import) - FTS5 section: updated to include topic_key column and special char handling --- DOCS.md | 39 +++++++++++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/DOCS.md b/DOCS.md index 1ab0312c..7b196965 100644 --- a/DOCS.md +++ b/DOCS.md @@ -14,7 +14,7 @@ This is the complete technical reference for Engram. For getting started, see th |---------|-----------------| | [Database Schema](#database-schema) | Tables, FTS5, SQLite config | | [HTTP API](#http-api-endpoints) | All REST endpoints with request/response details | -| [MCP Tools](#mcp-tools-15-tools) | Detailed reference for all 15 memory tools | +| [MCP Tools](#mcp-tools-16-tools) | Detailed reference for all 16 memory tools | | [Memory Protocol](#memory-protocol) | When/how agents should use the tools | | [Project Name Normalization](#project-name-normalization) | Auto-detection, normalization, similar-project warnings | | [Features](#features) | FTS5 search, timeline, privacy, git sync, compression | @@ -67,6 +67,7 @@ All endpoints return JSON. Server listens on `127.0.0.1:7437`. - `POST /sessions` — Create session. Body: `{id, project, directory}` - `POST /sessions/{id}/end` — End session. Body: `{summary}` - `GET /sessions/recent` — Recent sessions. Query: `?project=X&limit=N` +- `DELETE /sessions/{id}` — Delete empty session. Returns 409 if session has observations. ### Observations @@ -78,7 +79,7 @@ All endpoints return JSON. Server listens on `127.0.0.1:7437`. ### Search -- `GET /search` — FTS5 search. Query: `?q=QUERY&type=TYPE&project=PROJECT&scope=SCOPE&limit=N` +- `GET /search` — FTS5 search. Query: `?q=QUERY&type=TYPE&project=PROJECT&scope=SCOPE&limit=N&since=DATE&until=DATE` ### Timeline @@ -89,6 +90,7 @@ All endpoints return JSON. Server listens on `127.0.0.1:7437`. - `POST /prompts` — Save user prompt. Body: `{session_id, content, project?}` - `GET /prompts/recent` — Recent prompts. Query: `?project=X&limit=N` - `GET /prompts/search` — Search prompts. Query: `?q=QUERY&project=X&limit=N` +- `DELETE /prompts/{id}` — Delete a prompt by ID. Returns 404 if not found. ### Context @@ -125,12 +127,18 @@ All endpoints return JSON. Server listens on `127.0.0.1:7437`. --- -## MCP Tools (15 tools) +## MCP Tools (16 tools) ### mem_search Search persistent memory across all sessions. Supports FTS5 full-text search with type/project/scope/limit filters. +- **query** (required): Search query — natural language or keywords. Empty queries are rejected. +- **since**: Filter observations created on or after this date (YYYY-MM-DD or RFC3339). +- **until**: Filter observations created on or before this date (YYYY-MM-DD or RFC3339). + +Invalid date formats return an explicit error (SQLite's `datetime()` silently ignores bad input, so validation happens in Go). + ### mem_save Save structured observations. The tool description teaches agents the format: @@ -164,6 +172,9 @@ Save user prompts — records what the user asked so future sessions have contex Get recent memory context from previous sessions — shows sessions, prompts, and observations, with optional scope filtering for observations. +- **since**: Filter to sessions/observations on or after this date (YYYY-MM-DD or RFC3339). +- **until**: Filter to sessions/observations on or before this date (YYYY-MM-DD or RFC3339). + ### mem_stats Show memory system statistics — sessions, observations, prompts, projects. @@ -200,6 +211,15 @@ Mark a session as completed with optional summary. Extract structured learnings from text output. Looks for `## Key Learnings:` sections and saves each numbered/bulleted item as a separate observation. Duplicates are automatically skipped. +### mem_sessions + +List sessions within a date range. Shows session date, project, and observation count. + +- **since**: Filter sessions started on or after this date (YYYY-MM-DD or RFC3339). +- **until**: Filter sessions started on or before this date (YYYY-MM-DD or RFC3339). +- **project**: Filter by project name (optional). +- **limit**: Max results (default: 20, max: 50). + ### mem_merge_projects **Admin tool.** Merge multiple project name variants into a single canonical name. Accepts an array of source project names and a target canonical name. All observations, sessions, and prompts from the source projects are reassigned to the canonical project. @@ -331,11 +351,18 @@ Use `engram projects consolidate` to interactively merge variant project names, ## Features +### Input Validation + +- **Empty queries**: `Search()` rejects blank/whitespace-only queries before they reach FTS5 (which would crash on `MATCH ""`). +- **Date filters**: `since` and `until` accept only `YYYY-MM-DD` or RFC3339 formats. Invalid strings (e.g. `"yesterday"`, `"hace 3 dias"`) return an explicit error instead of being silently ignored by SQLite's `datetime()`. +- **Required fields**: `mem_save` validates that `title` and `content` are non-empty after trimming whitespace. +- **Import normalization**: Project names from imported JSON/sync data are normalized (lowercase, trimmed, collapsed hyphens) to match local conventions. + ### Full-Text Search (FTS5) -- Searches across title, content, tool_name, type, and project -- Query sanitization: wraps each word in quotes to avoid FTS5 syntax errors -- Supports type and project filters +- Searches across title, content, tool_name, type, project, and topic_key +- Query sanitization: wraps each word in quotes to avoid FTS5 syntax errors on special characters (`()`, `*`, `+`, `"`) +- Supports type, project, scope, since, and until filters ### Timeline (Progressive Disclosure) From 331a2948408f62db9c601a879d779f18a6b0f041 Mon Sep 17 00:00:00 2001 From: CTRQuko <99084921+CTRQuko@users.noreply.github.com> Date: Wed, 22 Apr 2026 01:49:51 +0200 Subject: [PATCH 3/3] feat(skill): add knowledge-recall retrieval chain (Engram + Obsidian/external KB) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New skill: engram-knowledge-recall - 4-level retrieval chain: mem_context → mem_search → external KB → explicit 'not found' - Agent-agnostic: works with any Obsidian MCP, Notion MCP, or file-based MCP - Rehidration pattern: findings from KB are saved back to Engram for faster future recall - Compaction recovery extended to include KB fallback Integrated into all agent plugins: - plugin/opencode/engram.ts — MEMORY_INSTRUCTIONS updated - plugin/claude-code/skills/memory/SKILL.md — search section updated - internal/setup/setup.go — memoryProtocolMarkdown updated (Gemini CLI, Codex) - skills/catalog.md — registered - AGENTS.md — registered with trigger description --- AGENTS.md | 1 + internal/setup/setup.go | 17 ++- plugin/claude-code/skills/memory/SKILL.md | 22 +++- plugin/opencode/engram.ts | 18 ++- skills/catalog.md | 1 + skills/knowledge-recall/SKILL.md | 148 ++++++++++++++++++++++ 6 files changed, 193 insertions(+), 14 deletions(-) create mode 100644 skills/knowledge-recall/SKILL.md diff --git a/AGENTS.md b/AGENTS.md index 315336cb..8399d9a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,4 +32,5 @@ When working on this project, load the relevant skill(s) BEFORE writing any code | `engram-ui-elements` | Adding or changing dashboard UI components or connected browsing flows. | [`skills/ui-elements/SKILL.md`](skills/ui-elements/SKILL.md) | | `engram-visual-language` | Any dashboard styling, typography, spacing, or visual identity change. | [`skills/visual-language/SKILL.md`](skills/visual-language/SKILL.md) | | `engram-backlog-triage` | Auditing open issues or PRs, triaging the backlog, or reviewing contributor submissions as a maintainer. | [`skills/backlog-triage/SKILL.md`](skills/backlog-triage/SKILL.md) | +| `engram-knowledge-recall` | Any context retrieval, recall request, session start, or working on a topic with no prior context. Requires Engram + an external knowledge-base MCP (Obsidian, Notion, etc.). | [`skills/knowledge-recall/SKILL.md`](skills/knowledge-recall/SKILL.md) | | `gentleman-bubbletea` | When editing Go files in installer/internal/tui/, working on TUI screens, or adding new UI features. | [`skills/gentleman-bubbletea/SKILL.md`](skills/gentleman-bubbletea/SKILL.md) | diff --git a/internal/setup/setup.go b/internal/setup/setup.go index dbc98a85..53d31803 100644 --- a/internal/setup/setup.go +++ b/internal/setup/setup.go @@ -133,13 +133,20 @@ Format for mem_save: - If unsure about the key, call mem_suggest_topic_key first and then reuse it - Use mem_update when you have an exact observation ID to correct -### WHEN TO SEARCH MEMORY +### WHEN TO SEARCH MEMORY (retrieval chain — mandatory order) When the user asks to recall something — any variation of "remember", "recall", "what did we do", -"how did we solve", "recordar", "acordate", "qué hicimos", or references to past work: -1. First call mem_context — checks recent session history (fast, cheap) -2. If not found, call mem_search with relevant keywords (FTS5 full-text search) -3. If you find a match, use mem_get_observation for full untruncated content +"how did we solve", "recordar", "acordate", "qué hicimos", or references to past work, +follow this chain IN ORDER: +1. mem_context(project) — recent session history (fast, cheap) +2. mem_search(query) — FTS5 full-text search across all sessions +3. If steps 1-2 return nothing useful AND an external knowledge-base MCP is available + (e.g. Obsidian, Notion), search there as fallback. If found, rehidrate by calling + mem_save with a summary so future searches find it at Level 1-2. +4. Only after exhausting ALL available sources, say "no prior context found" + +NEVER say "I don't have context" after only checking engram. If a knowledge-base +MCP is connected, you MUST search it before giving up. Also search memory PROACTIVELY when: - Starting work on something that might have been done before diff --git a/plugin/claude-code/skills/memory/SKILL.md b/plugin/claude-code/skills/memory/SKILL.md index cde2b97d..a3e83c6c 100644 --- a/plugin/claude-code/skills/memory/SKILL.md +++ b/plugin/claude-code/skills/memory/SKILL.md @@ -75,13 +75,25 @@ Format for `mem_save`: - If unsure about the key, call `mem_suggest_topic_key` first, then reuse that key consistently - If you already know the exact ID to fix, use `mem_update` -## WHEN TO SEARCH MEMORY +## WHEN TO SEARCH MEMORY (retrieval chain — mandatory order) When the user asks to recall something — any variation of "remember", "recall", "what did we do", -"how did we solve", "recordar", "acordate", "qué hicimos", or references to past work: -1. First call `mem_context` — checks recent session history (fast, cheap) -2. If not found, call `mem_search` with relevant keywords (FTS5 full-text search) -3. If you find a match, use `mem_get_observation` for full untruncated content +"how did we solve", "recordar", "acordate", "qué hicimos", or references to past work, +follow this chain IN ORDER: + +1. `mem_context(project)` — recent session history (fast, cheap) +2. `mem_search(query)` — FTS5 full-text search across all sessions +3. If steps 1-2 return nothing useful AND an external knowledge-base MCP is available + (e.g. Obsidian with `obsidian_search_notes`/`obsidian_read_note`, or Notion, or any + file-based MCP), search there as a fallback. + - If the knowledge base returns useful context, **rehidrate**: call `mem_save` with + a summary of what was found so future searches at Level 1-2 will find it. +4. Only after exhausting ALL available sources, say "no prior context found" + +**NEVER say "I don't have context" after only checking engram.** If a knowledge-base +MCP is connected, you MUST search it before giving up. + +Use `mem_get_observation(id)` for full untruncated content of any search result. Also search memory PROACTIVELY when: - Starting work on something that might have been done before diff --git a/plugin/opencode/engram.ts b/plugin/opencode/engram.ts index 9f4a27b7..157ebbcb 100644 --- a/plugin/opencode/engram.ts +++ b/plugin/opencode/engram.ts @@ -76,10 +76,20 @@ Topic rules: ### WHEN TO SEARCH MEMORY When the user asks to recall something — any variation of "remember", "recall", "what did we do", -"how did we solve", "recordar", "acordate", "qué hicimos", or references to past work: -1. First call \`mem_context\` — checks recent session history (fast, cheap) -2. If not found, call \`mem_search\` with relevant keywords (FTS5 full-text search) -3. If you find a match, use \`mem_get_observation\` for full untruncated content +"how did we solve", "recordar", "acordate", "qué hicimos", or references to past work, +follow this retrieval chain IN ORDER: + +1. \`mem_context(project)\` — recent session history (fast, cheap) +2. \`mem_search(query)\` — FTS5 full-text search across all sessions +3. If steps 1-2 return nothing useful AND an external knowledge-base MCP is available + (e.g. Obsidian, Notion), search there as fallback. If found, rehidrate by calling + \`mem_save\` with a summary so future searches find it at Level 1-2. +4. Only after exhausting ALL available sources, say "no prior context found" + +NEVER say "I don't have context" after only checking engram. If a knowledge-base +MCP is connected, you MUST search it before giving up. + +Use \`mem_get_observation(id)\` for full untruncated content of any search result. Also search memory PROACTIVELY when: - Starting work on something that might have been done before diff --git a/skills/catalog.md b/skills/catalog.md index a4246bf9..14ee0d63 100644 --- a/skills/catalog.md +++ b/skills/catalog.md @@ -18,3 +18,4 @@ - business-rules: sync, admin policy, and product rule guardrails. - cultural-norms: collaboration and quality norms for contributors and agents. - backlog-triage: audit open issues/PRs, classify items, and produce an actionable maintainer report. +- knowledge-recall: multi-source retrieval chain (Engram + Obsidian/external knowledge base). diff --git a/skills/knowledge-recall/SKILL.md b/skills/knowledge-recall/SKILL.md new file mode 100644 index 00000000..41cbb85e --- /dev/null +++ b/skills/knowledge-recall/SKILL.md @@ -0,0 +1,148 @@ +--- +name: engram-knowledge-recall +description: > + Multi-source knowledge retrieval chain. Ensures the agent never claims + "no context" without searching both Engram (live memory) and an external + knowledge base such as Obsidian. Trigger: any context retrieval, recall + request, session start, compaction recovery, or starting work on a topic + the agent has no context on. +license: Apache-2.0 +metadata: + author: gentleman-programming + version: "1.0" +--- + +## When to Use + +- Any time the agent needs context about past work +- User says "remember", "recall", "what did we do", "how did we solve" +- Starting work on a topic with no prior context +- After compaction — recovering what was lost +- First message of a session references a project, feature, or problem + +--- + +## Prerequisites + +This skill requires TWO MCP tool sources in the agent's environment: + +1. **Engram** (live memory) — provides: + - `mem_context` — recent session history + - `mem_search` — FTS5 full-text search across all sessions + - `mem_get_observation` — full content of a specific memory + +2. **A knowledge-base MCP** (curated library) — any server that provides: + - A tool to **search notes** by content, tags, or metadata + - A tool to **read a note's** full content + + Known compatible implementations: + + | MCP Server | Search tool | Read tool | + |------------|-------------|-----------| + | obsidian-mcp | `obsidian_search_notes` | `obsidian_read_note` | + | obsidian-local-rest-api | `search` | `read` | + | notion-mcp | `search_pages` | `get_page` | + | Any file-based MCP | `search_files` | `read_file` | + +If only Engram is available, the chain stops at Level 2. The agent must still +state explicitly that no external knowledge base was searched. + +--- + +## Retrieval Chain (mandatory order) + +When the agent needs context, follow this chain **in order**. Do NOT skip levels. + +### Level 1 — Engram live memory (fast, cheap) + +``` +mem_context(project) +``` + +If this returns useful context → **use it, stop here**. + +### Level 2 — Engram deep search (FTS5, broader) + +``` +mem_search(query, project?, since?, until?) +``` + +If results found → use `mem_get_observation(id)` for full content → **stop here**. + +### Level 3 — External knowledge base (curated library) + +Only reach this level if Levels 1 and 2 returned nothing useful. + +``` +(query) → find relevant notes +(filename) → read full content +``` + +Where `` and `` are the tools from your knowledge-base +MCP (see Prerequisites table). + +If the knowledge base returns useful context: +1. **Use it** to answer the user +2. **Rehidrate**: call `mem_save` with a summary of what was found, so future + searches at Level 1-2 will find it without needing Level 3 again + +``` +mem_save( + title: "Recalled: ", + type: "discovery", + content: "", + topic_key: "" +) +``` + +### Level 4 — No context found + +Only after exhausting all 3 levels, the agent may state: + +> "No prior context found in live memory or knowledge base." + +**NEVER say "I don't have context" after only checking Levels 1-2.** + +--- + +## Save Classification + +When the agent saves a memory, it should consider the memory's lifecycle: + +| Type | Where it lives | Example | +|------|---------------|---------| +| Session context | Engram only | "User prefers tabs over spaces" | +| Bug fix | Engram only | "Fixed N+1 in user list query" | +| Architecture decision | Engram + knowledge base | "Chose SQLite over Postgres for local storage" | +| Reference documentation | Engram + knowledge base | "API rate limits: 100 req/min per token" | + +Memories that belong in both places get there automatically: +- `mem_save` writes to Engram immediately +- `engram obsidian-export` (or equivalent sync) mirrors to the knowledge base + +The agent does NOT need to write to the knowledge base directly. + +--- + +## Rules + +1. **Never skip Level 3.** If Engram returns nothing, search the knowledge base before giving up. +2. **Always rehidrate.** When Level 3 finds something, save a summary back to Engram. +3. **Engram first.** Always search Engram before the knowledge base — it is faster and has the freshest data. +4. **No silent failures.** If the knowledge-base MCP is not available, state it explicitly: "Knowledge base MCP not available, searched Engram only." +5. **Date-scoped searches.** When the user references a specific time ("yesterday", "last week"), use `since`/`until` parameters in `mem_search` and `mem_sessions`. +6. **Agent-agnostic.** This chain works identically across Claude Code, OpenCode, Gemini CLI, Codex, or any MCP-compatible agent. + +--- + +## Compaction Recovery (extends memory-protocol) + +After compaction, the retrieval chain becomes critical: + +1. `mem_session_summary` — persist what was done before compaction +2. `mem_context` — Level 1 recovery +3. If context is insufficient → `mem_search` — Level 2 recovery +4. If still insufficient → knowledge-base search — Level 3 recovery +5. Only then continue working + +This ensures compaction never causes permanent context loss.