From fb69281f244284ac0d3ddcae0cba1031009a5845 Mon Sep 17 00:00:00 2001 From: Pedro Nauck Date: Fri, 17 Apr 2026 21:07:04 -0300 Subject: [PATCH 1/8] refactor: memory improvements --- cliff.toml | 4 +- go.mod | 2 +- go.sum | 4 +- internal/api/contract/contract.go | 23 +- internal/api/core/memory.go | 120 ++- internal/api/httpapi/handlers_test.go | 2 + internal/api/httpapi/memory_test.go | 64 +- internal/api/httpapi/routes.go | 4 +- internal/api/udsapi/handlers_test.go | 2 + internal/api/udsapi/memory_test.go | 64 +- internal/api/udsapi/routes.go | 4 +- internal/cli/client.go | 66 ++ internal/cli/client_test.go | 58 ++ internal/cli/helpers_test.go | 23 + internal/cli/memory.go | 191 +++++ internal/cli/memory_test.go | 52 ++ internal/daemon/boot.go | 6 +- internal/daemon/daemon.go | 2 + .../daemon_memory_e2e_integration_test.go | 454 ++++++++++++ internal/daemon/daemon_test.go | 24 + internal/memory/assembler.go | 2 + internal/memory/assembler_test.go | 63 +- internal/memory/catalog.go | 698 ++++++++++++++++++ internal/memory/recall.go | 98 +++ internal/memory/staleness.go | 5 + internal/memory/store.go | 532 +++++++++++-- internal/memory/store_test.go | 183 ++++- internal/memory/types.go | 53 ++ internal/session/interfaces.go | 3 + internal/session/manager.go | 8 + internal/session/manager_prompt.go | 11 +- internal/session/manager_test.go | 39 + internal/store/globaldb/global_db.go | 9 + internal/store/globaldb/global_db_observe.go | 29 +- internal/store/globaldb/global_db_test.go | 65 +- .../testdata/memory_recall_fixture.json | 26 + internal/testutil/e2e/runtime_harness.go | 17 +- openapi/agh.json | 34 + .../runtime/core/hooks/declaration.mdx | 2 +- web/src/generated/agh-openapi.d.ts | 11 + 40 files changed, 2969 insertions(+), 88 deletions(-) create mode 100644 internal/daemon/daemon_memory_e2e_integration_test.go create mode 100644 internal/memory/catalog.go create mode 100644 internal/memory/recall.go create mode 100644 internal/testutil/acpmock/testdata/memory_recall_fixture.json diff --git a/cliff.toml b/cliff.toml index 1f2d46b1d..9452ba6c3 100644 --- a/cliff.toml +++ b/cliff.toml @@ -64,9 +64,7 @@ conventional_commits = true filter_unconventional = true split_commits = false -commit_preprocessors = [ - { pattern = ' +', replace = " " }, -] +commit_preprocessors = [{ pattern = ' +', replace = " " }] commit_parsers = [ { message = "^feat", group = "🎉 Features" }, diff --git a/go.mod b/go.mod index 9ce29727b..c5ce0dce4 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/goccy/go-yaml v1.19.2 github.com/gofrs/flock v0.13.0 github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 github.com/joho/godotenv v1.5.1 github.com/jonboulle/clockwork v0.5.0 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 @@ -75,7 +76,6 @@ require ( github.com/goccy/go-json v0.10.5 // indirect github.com/google/go-tpm v0.9.8 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect diff --git a/go.sum b/go.sum index 55ef564c5..39863dce3 100644 --- a/go.sum +++ b/go.sum @@ -216,11 +216,9 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= -github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= diff --git a/internal/api/contract/contract.go b/internal/api/contract/contract.go index 3fada38c4..fb071799f 100644 --- a/internal/api/contract/contract.go +++ b/internal/api/contract/contract.go @@ -482,12 +482,27 @@ type MemoryConsolidateResponse struct { Reason string `json:"reason,omitempty"` } +// MemoryReindexRequest is the shared memory-catalog reindex request payload. +type MemoryReindexRequest struct { + Scope string `json:"scope,omitempty"` + Workspace string `json:"workspace,omitempty"` +} + // MemoryHealthPayload is the shared memory health response payload. type MemoryHealthPayload struct { - GlobalFiles int `json:"global_files"` - WorkspaceFiles int `json:"workspace_files"` - LastConsolidation *time.Time `json:"last_consolidation"` - DreamEnabled bool `json:"dream_enabled"` + Enabled bool `json:"enabled"` + GlobalDir string `json:"global_dir,omitempty"` + GlobalFiles int `json:"global_files"` + WorkspaceFiles int `json:"workspace_files"` + DreamEnabled bool `json:"dream_enabled"` + DreamAgent string `json:"dream_agent,omitempty"` + DreamMinHours float64 `json:"dream_min_hours,omitempty"` + DreamMinSessions int `json:"dream_min_sessions,omitempty"` + DreamCheckInterval string `json:"dream_check_interval,omitempty"` + IndexedFiles int `json:"indexed_files"` + OrphanedFiles int `json:"orphaned_files"` + LastReindex *time.Time `json:"last_reindex"` + LastConsolidation *time.Time `json:"last_consolidation"` } // CreateWorkspaceRequest is the shared workspace creation request payload. diff --git a/internal/api/core/memory.go b/internal/api/core/memory.go index 9de7b9202..01275ec1e 100644 --- a/internal/api/core/memory.go +++ b/internal/api/core/memory.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "sort" + "strconv" "strings" "github.com/gin-gonic/gin" @@ -33,6 +34,37 @@ func (h *BaseHandlers) ListMemory(c *gin.Context) { c.JSON(http.StatusOK, headers) } +// SearchMemory returns ranked durable memory matches. +func (h *BaseHandlers) SearchMemory(c *gin.Context) { + if h.MemoryStore == nil { + h.respondError(c, http.StatusInternalServerError, errors.New("memory store is not configured")) + return + } + + scope, workspace, err := resolveMemoryScopeAndWorkspace(c.Query("scope"), c.Query("workspace")) + if err != nil { + h.respondError(c, StatusForMemoryError(err), err) + return + } + limit, err := parseMemoryLimit(c.Query("limit")) + if err != nil { + h.respondError(c, http.StatusBadRequest, err) + return + } + + results, err := h.MemoryStore.Search(c.Request.Context(), c.Query("q"), memory.SearchOptions{ + Scope: scope, + Workspace: workspace, + Limit: limit, + }) + if err != nil { + h.respondError(c, StatusForMemoryError(err), err) + return + } + + c.JSON(http.StatusOK, results) +} + // ReadMemory returns one memory document. func (h *BaseHandlers) ReadMemory(c *gin.Context) { location, err := h.resolveMemoryLocation(c.Param("filename"), c.Query("scope"), c.Query("workspace")) @@ -110,6 +142,50 @@ func (h *BaseHandlers) DeleteMemory(c *gin.Context) { c.JSON(http.StatusOK, contract.MemoryMutationResponse{OK: true}) } +// ReindexMemory rebuilds the derived memory catalog from Markdown memory files. +func (h *BaseHandlers) ReindexMemory(c *gin.Context) { + if h.MemoryStore == nil { + h.respondError(c, http.StatusInternalServerError, errors.New("memory store is not configured")) + return + } + + var req contract.MemoryReindexRequest + if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) { + h.respondError( + c, + http.StatusBadRequest, + fmt.Errorf("%s: decode memory reindex request: %w", h.transportName(), err), + ) + return + } + + scopeRaw := req.Scope + if strings.TrimSpace(scopeRaw) == "" { + scopeRaw = c.Query("scope") + } + workspaceRaw := req.Workspace + if strings.TrimSpace(workspaceRaw) == "" { + workspaceRaw = c.Query("workspace") + } + + scope, workspace, err := resolveMemoryScopeAndWorkspace(scopeRaw, workspaceRaw) + if err != nil { + h.respondError(c, StatusForMemoryError(err), err) + return + } + + result, err := h.MemoryStore.Reindex(c.Request.Context(), memory.ReindexOptions{ + Scope: scope, + Workspace: workspace, + }) + if err != nil { + h.respondError(c, StatusForMemoryError(err), err) + return + } + + c.JSON(http.StatusOK, result) +} + // ConsolidateMemory triggers dream consolidation when enabled. func (h *BaseHandlers) ConsolidateMemory(c *gin.Context) { var req contract.MemoryConsolidateRequest @@ -143,7 +219,14 @@ func (h *BaseHandlers) ConsolidateMemory(c *gin.Context) { } func (h *BaseHandlers) memoryHealth(c *gin.Context) (contract.MemoryHealthPayload, error) { - payload := contract.MemoryHealthPayload{} + payload := contract.MemoryHealthPayload{ + Enabled: h.Config.Memory.Enabled, + GlobalDir: strings.TrimSpace(h.Config.Memory.GlobalDir), + DreamAgent: strings.TrimSpace(h.Config.Memory.Dream.Agent), + DreamMinHours: h.Config.Memory.Dream.MinHours, + DreamMinSessions: h.Config.Memory.Dream.MinSessions, + DreamCheckInterval: h.Config.Memory.Dream.CheckInterval.String(), + } if h.DreamTrigger != nil { payload.DreamEnabled = h.DreamTrigger.Enabled() lastConsolidation, err := h.DreamTrigger.LastConsolidatedAt() @@ -178,6 +261,14 @@ func (h *BaseHandlers) memoryHealth(c *gin.Context) (contract.MemoryHealthPayloa payload.WorkspaceFiles += len(headers) } + stats, err := h.MemoryStore.HealthStats(c.Request.Context(), workspaces) + if err != nil { + return contract.MemoryHealthPayload{}, err + } + payload.IndexedFiles = stats.IndexedFiles + payload.OrphanedFiles = stats.OrphanedFiles + payload.LastReindex = stats.LastReindex + return payload, nil } @@ -430,3 +521,30 @@ func resolveMemoryWorkspace(raw string) (string, error) { } return workspace, nil } + +func resolveMemoryScopeAndWorkspace(rawScope string, rawWorkspace string) (memory.Scope, string, error) { + scope, err := parseOptionalMemoryScope(rawScope) + if err != nil { + return "", "", err + } + if scope == memory.ScopeWorkspace || strings.TrimSpace(rawWorkspace) != "" { + workspace, err := resolveMemoryWorkspace(rawWorkspace) + if err != nil { + return "", "", err + } + return scope, workspace, nil + } + return scope, "", nil +} + +func parseMemoryLimit(raw string) (int, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return 0, nil + } + limit, err := strconv.Atoi(trimmed) + if err != nil || limit <= 0 { + return 0, NewMemoryValidationError(errors.New("limit must be a positive integer")) + } + return limit, nil +} diff --git a/internal/api/httpapi/handlers_test.go b/internal/api/httpapi/handlers_test.go index 9c2ef59f1..536e09489 100644 --- a/internal/api/httpapi/handlers_test.go +++ b/internal/api/httpapi/handlers_test.go @@ -72,6 +72,7 @@ func TestRegisterRoutesCoversTechSpecEndpoints(t *testing.T) { "GET /api/hooks/runs", "GET /api/memory", "GET /api/memory/:filename", + "GET /api/memory/search", "GET /api/network/inbox", "GET /api/network/peers", "GET /api/network/peers/:peer_id", @@ -106,6 +107,7 @@ func TestRegisterRoutesCoversTechSpecEndpoints(t *testing.T) { "POST /api/automation/jobs/:id/trigger", "POST /api/automation/triggers", "POST /api/memory/consolidate", + "POST /api/memory/reindex", "POST /api/network/channels", "POST /api/bridges", "POST /api/bridges/:id/disable", diff --git a/internal/api/httpapi/memory_test.go b/internal/api/httpapi/memory_test.go index 81962be6d..2462c699f 100644 --- a/internal/api/httpapi/memory_test.go +++ b/internal/api/httpapi/memory_test.go @@ -253,6 +253,59 @@ func TestMemoryHandlersDeleteAndNotFound(t *testing.T) { } } +func TestMemoryHandlersSearchAndReindex(t *testing.T) { + t.Parallel() + + store, workspace := newTestMemoryStore(t) + mustWriteMemory(t, store, memory.ScopeGlobal, "", "prefs.md", memory.MemoryTypeUser, "User prefers concise answers") + mustWriteMemory( + t, + store, + memory.ScopeWorkspace, + workspace, + "auth.md", + memory.MemoryTypeProject, + "Auth migration uses sessions", + ) + + handlers := newTestMemoryHandlers(t, stubSessionManager{}, stubObserver{}, store, &stubDreamTrigger{}) + engine := newTestRouter(t, handlers) + + search := performRequest( + t, + engine, + http.MethodGet, + "/api/memory/search?q=auth%20sessions&workspace="+workspace, + nil, + ) + if search.Code != http.StatusOK { + t.Fatalf("search status = %d, want %d; body=%s", search.Code, http.StatusOK, search.Body.String()) + } + + var results []memory.SearchResult + decodeJSONResponse(t, search, &results) + if len(results) == 0 || results[0].Scope != memory.ScopeWorkspace { + t.Fatalf("search results = %#v, want workspace hit first", results) + } + + reindex := performRequest( + t, + engine, + http.MethodPost, + "/api/memory/reindex", + []byte(`{"workspace":"`+escapeJSON(workspace)+`"}`), + ) + if reindex.Code != http.StatusOK { + t.Fatalf("reindex status = %d, want %d; body=%s", reindex.Code, http.StatusOK, reindex.Body.String()) + } + + var payload memory.ReindexResult + decodeJSONResponse(t, reindex, &payload) + if payload.IndexedFiles != 2 { + t.Fatalf("reindex payload = %#v, want indexed_files=2", payload) + } +} + func TestMemoryHandlersConsolidate(t *testing.T) { t.Parallel() @@ -371,6 +424,12 @@ func TestHealthIncludesMemoryStats(t *testing.T) { if payload.Memory.LastConsolidation == nil || !payload.Memory.LastConsolidation.Equal(last) { t.Fatalf("last consolidation = %#v, want %s", payload.Memory.LastConsolidation, last) } + if !payload.Memory.Enabled || payload.Memory.IndexedFiles != 2 || payload.Memory.OrphanedFiles != 0 { + t.Fatalf("memory health catalog stats = %#v, want enabled+indexed stats", payload.Memory) + } + if payload.Memory.LastReindex == nil { + t.Fatalf("last reindex = %#v, want non-nil", payload.Memory.LastReindex) + } } func TestMemoryHelpersResolveLocationAndScope(t *testing.T) { @@ -569,8 +628,9 @@ func newTestMemoryHandlers( func newTestMemoryStore(t *testing.T) (*memory.Store, string) { t.Helper() - globalDir := filepath.Join(t.TempDir(), "global-memory") - store := memory.NewStore(globalDir) + baseDir := t.TempDir() + globalDir := filepath.Join(baseDir, "global-memory") + store := memory.NewStore(globalDir, memory.WithCatalogDatabasePath(filepath.Join(baseDir, "agh.db"))) if err := store.EnsureDirs(); err != nil { t.Fatalf("EnsureDirs() error = %v", err) } diff --git a/internal/api/httpapi/routes.go b/internal/api/httpapi/routes.go index d257afc33..23a64a00c 100644 --- a/internal/api/httpapi/routes.go +++ b/internal/api/httpapi/routes.go @@ -171,10 +171,12 @@ func registerSkillRoutes(api gin.IRouter, handlers *Handlers) { func registerMemoryRoutes(api gin.IRouter, handlers *Handlers) { memoryGroup := api.Group("/memory") memoryGroup.GET("", handlers.ListMemory) + memoryGroup.GET("/search", handlers.SearchMemory) + memoryGroup.POST("/reindex", handlers.ReindexMemory) + memoryGroup.POST("/consolidate", handlers.ConsolidateMemory) memoryGroup.GET("/:filename", handlers.ReadMemory) memoryGroup.PUT("/:filename", handlers.WriteMemory) memoryGroup.DELETE("/:filename", handlers.DeleteMemory) - memoryGroup.POST("/consolidate", handlers.ConsolidateMemory) } func registerDaemonRoutes(api gin.IRouter, handlers *Handlers) { diff --git a/internal/api/udsapi/handlers_test.go b/internal/api/udsapi/handlers_test.go index 140f90cd5..4667b3e4d 100644 --- a/internal/api/udsapi/handlers_test.go +++ b/internal/api/udsapi/handlers_test.go @@ -117,6 +117,7 @@ func TestRegisterRoutesCoversTechSpecEndpoints(t *testing.T) { "GET /api/hooks/runs", "GET /api/memory", "GET /api/memory/:filename", + "GET /api/memory/search", "GET /api/network/inbox", "GET /api/network/peers", "GET /api/network/peers/:peer_id", @@ -164,6 +165,7 @@ func TestRegisterRoutesCoversTechSpecEndpoints(t *testing.T) { "POST /api/extensions/:name/disable", "POST /api/extensions/:name/enable", "POST /api/memory/consolidate", + "POST /api/memory/reindex", "POST /api/network/channels", "POST /api/network/send", "POST /api/sessions", diff --git a/internal/api/udsapi/memory_test.go b/internal/api/udsapi/memory_test.go index 4c931fed9..765cd06fc 100644 --- a/internal/api/udsapi/memory_test.go +++ b/internal/api/udsapi/memory_test.go @@ -253,6 +253,59 @@ func TestMemoryHandlersDeleteAndNotFound(t *testing.T) { } } +func TestMemoryHandlersSearchAndReindex(t *testing.T) { + t.Parallel() + + store, workspace := newTestMemoryStore(t) + mustWriteMemory(t, store, memory.ScopeGlobal, "", "prefs.md", memory.MemoryTypeUser, "User prefers concise answers") + mustWriteMemory( + t, + store, + memory.ScopeWorkspace, + workspace, + "auth.md", + memory.MemoryTypeProject, + "Auth migration uses sessions", + ) + + handlers := newTestMemoryHandlers(t, stubSessionManager{}, stubObserver{}, store, &stubDreamTrigger{}) + engine := newTestRouter(t, handlers) + + search := performRequest( + t, + engine, + http.MethodGet, + "/api/memory/search?q=auth%20sessions&workspace="+workspace, + nil, + ) + if search.Code != http.StatusOK { + t.Fatalf("search status = %d, want %d; body=%s", search.Code, http.StatusOK, search.Body.String()) + } + + var results []memory.SearchResult + decodeJSONResponse(t, search, &results) + if len(results) == 0 || results[0].Scope != memory.ScopeWorkspace { + t.Fatalf("search results = %#v, want workspace hit first", results) + } + + reindex := performRequest( + t, + engine, + http.MethodPost, + "/api/memory/reindex", + []byte(`{"workspace":"`+escapeJSON(workspace)+`"}`), + ) + if reindex.Code != http.StatusOK { + t.Fatalf("reindex status = %d, want %d; body=%s", reindex.Code, http.StatusOK, reindex.Body.String()) + } + + var payload memory.ReindexResult + decodeJSONResponse(t, reindex, &payload) + if payload.IndexedFiles != 2 { + t.Fatalf("reindex payload = %#v, want indexed_files=2", payload) + } +} + func TestMemoryHandlersConsolidate(t *testing.T) { t.Parallel() @@ -370,6 +423,12 @@ func TestHealthIncludesMemoryStats(t *testing.T) { if payload.Memory.LastConsolidation == nil || !payload.Memory.LastConsolidation.Equal(last) { t.Fatalf("last consolidation = %#v, want %s", payload.Memory.LastConsolidation, last) } + if !payload.Memory.Enabled || payload.Memory.IndexedFiles != 2 || payload.Memory.OrphanedFiles != 0 { + t.Fatalf("memory health catalog stats = %#v, want enabled+indexed stats", payload.Memory) + } + if payload.Memory.LastReindex == nil { + t.Fatalf("last reindex = %#v, want non-nil", payload.Memory.LastReindex) + } } func TestMemoryHelpersResolveLocationAndScope(t *testing.T) { @@ -565,8 +624,9 @@ func newTestMemoryHandlers( func newTestMemoryStore(t *testing.T) (*memory.Store, string) { t.Helper() - globalDir := filepath.Join(t.TempDir(), "global-memory") - store := memory.NewStore(globalDir) + baseDir := t.TempDir() + globalDir := filepath.Join(baseDir, "global-memory") + store := memory.NewStore(globalDir, memory.WithCatalogDatabasePath(filepath.Join(baseDir, "agh.db"))) if err := store.EnsureDirs(); err != nil { t.Fatalf("EnsureDirs() error = %v", err) } diff --git a/internal/api/udsapi/routes.go b/internal/api/udsapi/routes.go index 57102807f..c7794deba 100644 --- a/internal/api/udsapi/routes.go +++ b/internal/api/udsapi/routes.go @@ -179,10 +179,12 @@ func registerMemoryRoutes(api gin.IRouter, handlers *Handlers) { memoryGroup := api.Group("/memory") { memoryGroup.GET("", handlers.ListMemory) + memoryGroup.GET("/search", handlers.SearchMemory) + memoryGroup.POST("/reindex", handlers.ReindexMemory) + memoryGroup.POST("/consolidate", handlers.ConsolidateMemory) memoryGroup.GET("/:filename", handlers.ReadMemory) memoryGroup.PUT("/:filename", handlers.WriteMemory) memoryGroup.DELETE("/:filename", handlers.DeleteMemory) - memoryGroup.POST("/consolidate", handlers.ConsolidateMemory) } } diff --git a/internal/cli/client.go b/internal/cli/client.go index 57850ea21..96ea5377c 100644 --- a/internal/cli/client.go +++ b/internal/cli/client.go @@ -83,6 +83,7 @@ type DaemonClient interface { StreamObserveEvents(ctx context.Context, query ObserveEventQuery, lastEventID string, handler SSEHandler) error ObserveHealth(ctx context.Context) (HealthStatus, error) ListMemory(ctx context.Context, scope memory.Scope, workspace string) ([]MemoryHeaderRecord, error) + SearchMemory(ctx context.Context, query string, opts MemorySearchQuery) ([]MemorySearchRecord, error) ReadMemory(ctx context.Context, filename string, scope memory.Scope, workspace string) (MemoryReadRecord, error) WriteMemory(ctx context.Context, filename string, request MemoryWriteRequest) (MemoryMutationRecord, error) DeleteMemory( @@ -91,6 +92,7 @@ type DaemonClient interface { scope memory.Scope, workspace string, ) (MemoryMutationRecord, error) + ReindexMemory(ctx context.Context, request MemoryReindexRequest) (MemoryReindexRecord, error) ConsolidateMemory(ctx context.Context, workspace string) (MemoryConsolidateRecord, error) ListAutomationJobs(ctx context.Context, query AutomationJobQuery) ([]JobRecord, error) CreateAutomationJob(ctx context.Context, request AutomationJobCreateRequest) (JobRecord, error) @@ -222,12 +224,28 @@ type MemoryHeaderRecord = memory.Header // MemoryReadRecord is the shared daemon memory document payload. type MemoryReadRecord = contract.MemoryReadResponse +// MemorySearchQuery captures filters for durable memory search. +type MemorySearchQuery struct { + Scope memory.Scope + Workspace string + Limit int +} + +// MemorySearchRecord is one ranked durable memory search hit. +type MemorySearchRecord = memory.SearchResult + // MemoryWriteRequest captures the daemon API write payload. type MemoryWriteRequest = contract.MemoryWriteRequest // MemoryMutationRecord captures the daemon API write/delete response. type MemoryMutationRecord = contract.MemoryMutationResponse +// MemoryReindexRequest captures the daemon API memory reindex payload. +type MemoryReindexRequest = contract.MemoryReindexRequest + +// MemoryReindexRecord captures the daemon API memory reindex response. +type MemoryReindexRecord = memory.ReindexResult + // MemoryConsolidateRecord captures the daemon API consolidation response. type MemoryConsolidateRecord = contract.MemoryConsolidateResponse @@ -948,6 +966,25 @@ func (c *unixSocketClient) ListMemory( return response, nil } +func (c *unixSocketClient) SearchMemory( + ctx context.Context, + query string, + opts MemorySearchQuery, +) ([]MemorySearchRecord, error) { + var response []MemorySearchRecord + if err := c.doJSON( + ctx, + http.MethodGet, + "/api/memory/search", + memorySearchValues(query, opts), + nil, + &response, + ); err != nil { + return nil, err + } + return response, nil +} + func (c *unixSocketClient) ReadMemory( ctx context.Context, filename string, @@ -1007,6 +1044,24 @@ func (c *unixSocketClient) DeleteMemory( return response, nil } +func (c *unixSocketClient) ReindexMemory( + ctx context.Context, + request MemoryReindexRequest, +) (MemoryReindexRecord, error) { + var response MemoryReindexRecord + if err := c.doJSON( + ctx, + http.MethodPost, + "/api/memory/reindex", + nil, + request, + &response, + ); err != nil { + return MemoryReindexRecord{}, err + } + return response, nil +} + func (c *unixSocketClient) ConsolidateMemory(ctx context.Context, workspace string) (MemoryConsolidateRecord, error) { var response MemoryConsolidateRecord if err := c.doJSON( @@ -1710,6 +1765,17 @@ func memoryValues(scope memory.Scope, workspace string) url.Values { return values } +func memorySearchValues(query string, opts MemorySearchQuery) url.Values { + values := memoryValues(opts.Scope, opts.Workspace) + if trimmed := strings.TrimSpace(query); trimmed != "" { + values.Set("q", trimmed) + } + if opts.Limit > 0 { + values.Set("limit", strconv.Itoa(opts.Limit)) + } + return values +} + func automationJobValues(query AutomationJobQuery) url.Values { values := url.Values{} if trimmed := strings.TrimSpace(string(query.Scope)); trimmed != "" { diff --git a/internal/cli/client_test.go b/internal/cli/client_test.go index 1378548be..21768d473 100644 --- a/internal/cli/client_test.go +++ b/internal/cli/client_test.go @@ -223,6 +223,23 @@ func TestUnixSocketClientMethods(t *testing.T) { http.StatusOK, `[{"filename":"memory.md","mod_time":"2026-04-03T12:00:00Z","name":"Memory","description":"desc","type":"user"}]`, ), nil + case req.Method == http.MethodGet && req.URL.Path == "/api/memory/search": + if got := req.URL.Query().Get("q"); got != "release plan" { + t.Fatalf("memory search q = %q, want %q", got, "release plan") + } + if got := req.URL.Query().Get("scope"); got != "workspace" { + t.Fatalf("memory search scope = %q, want %q", got, "workspace") + } + if got := req.URL.Query().Get("workspace"); got != "/workspace/project" { + t.Fatalf("memory search workspace = %q, want %q", got, "/workspace/project") + } + if got := req.URL.Query().Get("limit"); got != "5" { + t.Fatalf("memory search limit = %q, want %q", got, "5") + } + return newHTTPResponse( + http.StatusOK, + `[{"filename":"release.md","scope":"workspace","workspace":"/workspace/project","type":"project","name":"Release Plan","description":"plan","score":3.4,"snippet":"Ship phases incrementally","mod_time":"2026-04-03T12:00:00Z"}]`, + ), nil case req.Method == http.MethodGet && req.URL.Path == "/api/memory/memory.md": return newHTTPResponse(http.StatusOK, `{"content":"---\nname: Memory\n---\n\nhello"}`), nil case req.Method == http.MethodPut && req.URL.Path == "/api/memory/memory.md": @@ -234,6 +251,19 @@ func TestUnixSocketClientMethods(t *testing.T) { return newHTTPResponse(http.StatusOK, `{"ok":true}`), nil case req.Method == http.MethodPost && req.URL.Path == "/api/memory/consolidate": return newHTTPResponse(http.StatusOK, `{"triggered":true}`), nil + case req.Method == http.MethodPost && req.URL.Path == "/api/memory/reindex": + body, err := io.ReadAll(req.Body) + if err != nil { + t.Fatalf("io.ReadAll(memory reindex body) error = %v", err) + } + if !strings.Contains(string(body), `"scope":"workspace"`) || + !strings.Contains(string(body), `"workspace":"/workspace/project"`) { + t.Fatalf("memory reindex body = %s, want scope/workspace", body) + } + return newHTTPResponse( + http.StatusOK, + `{"indexed_files":2,"scope":"workspace","workspace":"/workspace/project","completed_at":"2026-04-03T12:00:00Z"}`, + ), nil case req.Method == http.MethodGet && req.URL.Path == "/api/daemon/status": return newHTTPResponse( http.StatusOK, @@ -385,6 +415,15 @@ func TestUnixSocketClientMethods(t *testing.T) { t.Fatalf("ListMemory() = %#v, %v", memories, err) } + searchResults, err := client.SearchMemory(ctx, "release plan", MemorySearchQuery{ + Scope: memory.ScopeWorkspace, + Workspace: "/workspace/project", + Limit: 5, + }) + if err != nil || len(searchResults) != 1 || searchResults[0].Filename != "release.md" { + t.Fatalf("SearchMemory() = %#v, %v", searchResults, err) + } + memoryRecord, err := client.ReadMemory(ctx, "memory.md", memory.ScopeGlobal, "") if err != nil || !strings.Contains(memoryRecord.Content, "hello") { t.Fatalf("ReadMemory() = %#v, %v", memoryRecord, err) @@ -400,6 +439,14 @@ func TestUnixSocketClientMethods(t *testing.T) { t.Fatalf("DeleteMemory() = %#v, %v", deleted, err) } + reindexed, err := client.ReindexMemory(ctx, MemoryReindexRequest{ + Scope: "workspace", + Workspace: "/workspace/project", + }) + if err != nil || reindexed.IndexedFiles != 2 { + t.Fatalf("ReindexMemory() = %#v, %v", reindexed, err) + } + consolidated, err := client.ConsolidateMemory(ctx, "/workspace/project") if err != nil || !consolidated.Triggered { t.Fatalf("ConsolidateMemory() = %#v, %v", consolidated, err) @@ -1422,6 +1469,17 @@ func TestReadAPIErrorAndHelpers(t *testing.T) { t.Fatalf("memoryValues() = %v, want scope/workspace", got) } + if got := memorySearchValues("release plan", MemorySearchQuery{ + Scope: memory.ScopeWorkspace, + Workspace: "/workspace/project", + Limit: 5, + }); got.Get("q") != "release plan" || + got.Get("scope") != "workspace" || + got.Get("workspace") != "/workspace/project" || + got.Get("limit") != "5" { + t.Fatalf("memorySearchValues() = %v, want q/scope/workspace/limit", got) + } + if got := automationJobValues(AutomationJobQuery{ Scope: automationpkg.AutomationScopeWorkspace, WorkspaceID: "ws-alpha", diff --git a/internal/cli/helpers_test.go b/internal/cli/helpers_test.go index d3af7f206..1720f6bb5 100644 --- a/internal/cli/helpers_test.go +++ b/internal/cli/helpers_test.go @@ -60,9 +60,11 @@ type stubClient struct { streamObserveEventsFn func(context.Context, ObserveEventQuery, string, SSEHandler) error observeHealthFn func(context.Context) (HealthStatus, error) listMemoryFn func(context.Context, memory.Scope, string) ([]MemoryHeaderRecord, error) + searchMemoryFn func(context.Context, string, MemorySearchQuery) ([]MemorySearchRecord, error) readMemoryFn func(context.Context, string, memory.Scope, string) (MemoryReadRecord, error) writeMemoryFn func(context.Context, string, MemoryWriteRequest) (MemoryMutationRecord, error) deleteMemoryFn func(context.Context, string, memory.Scope, string) (MemoryMutationRecord, error) + reindexMemoryFn func(context.Context, MemoryReindexRequest) (MemoryReindexRecord, error) consolidateMemoryFn func(context.Context, string) (MemoryConsolidateRecord, error) listAutomationJobsFn func(context.Context, AutomationJobQuery) ([]JobRecord, error) createAutomationJobFn func(context.Context, AutomationJobCreateRequest) (JobRecord, error) @@ -472,6 +474,17 @@ func (s *stubClient) ListMemory( return nil, errors.New("unexpected ListMemory call") } +func (s *stubClient) SearchMemory( + ctx context.Context, + query string, + opts MemorySearchQuery, +) ([]MemorySearchRecord, error) { + if s.searchMemoryFn != nil { + return s.searchMemoryFn(ctx, query, opts) + } + return nil, errors.New("unexpected SearchMemory call") +} + func (s *stubClient) ReadMemory( ctx context.Context, filename string, @@ -507,6 +520,16 @@ func (s *stubClient) DeleteMemory( return MemoryMutationRecord{}, errors.New("unexpected DeleteMemory call") } +func (s *stubClient) ReindexMemory( + ctx context.Context, + request MemoryReindexRequest, +) (MemoryReindexRecord, error) { + if s.reindexMemoryFn != nil { + return s.reindexMemoryFn(ctx, request) + } + return MemoryReindexRecord{}, errors.New("unexpected ReindexMemory call") +} + func (s *stubClient) ConsolidateMemory( ctx context.Context, workspace string, diff --git a/internal/cli/memory.go b/internal/cli/memory.go index 84bcc42a2..dffca30ab 100644 --- a/internal/cli/memory.go +++ b/internal/cli/memory.go @@ -41,6 +41,25 @@ type memoryMutationView struct { Reason string `json:"reason,omitempty"` } +type memorySearchItem struct { + Filename string `json:"filename"` + Name string `json:"name"` + Type memory.Type `json:"type"` + Scope memory.Scope `json:"scope"` + Workspace string `json:"workspace,omitempty"` + Score float64 `json:"score"` + Description string `json:"description,omitempty"` + Snippet string `json:"snippet,omitempty"` + ModTime time.Time `json:"mod_time"` +} + +type memoryReindexView struct { + IndexedFiles int `json:"indexed_files"` + Scope memory.Scope `json:"scope,omitempty"` + Workspace string `json:"workspace,omitempty"` + CompletedAt time.Time `json:"completed_at"` +} + var memoryWriteExample = strings.Join([]string{ " # Write workspace-scoped project memory from a flag", ` agh memory write runtime-notes.md --type project --description "Runtime docs live in the site package" ` + @@ -64,9 +83,11 @@ func newMemoryCommand(deps commandDeps) *cobra.Command { } cmd.AddCommand(newMemoryListCommand(deps)) + cmd.AddCommand(newMemorySearchCommand(deps)) cmd.AddCommand(newMemoryReadCommand(deps)) cmd.AddCommand(newMemoryWriteCommand(deps)) cmd.AddCommand(newMemoryDeleteCommand(deps)) + cmd.AddCommand(newMemoryReindexCommand(deps)) cmd.AddCommand(newMemoryConsolidateCommand(deps)) return cmd } @@ -139,6 +160,61 @@ func newMemoryReadCommand(deps commandDeps) *cobra.Command { return cmd } +func newMemorySearchCommand(deps commandDeps) *cobra.Command { + var ( + scope string + limit int + ) + + cmd := &cobra.Command{ + Use: "search ", + Short: "Search durable memory", + Example: ` # Search global and current-workspace memories + agh memory search auth rewrite + + # Search only workspace-scoped memories + agh memory search release plan --scope workspace --limit 5`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := clientFromDeps(deps) + if err != nil { + return err + } + + query := strings.TrimSpace(args[0]) + if query == "" { + return errors.New("memory query is required") + } + + parsedScope, err := parseOptionalCLIMemoryScope(scope) + if err != nil { + return err + } + + workspace := "" + if parsedScope != memory.ScopeGlobal { + workspace, err = currentWorkingDirectory(deps) + if err != nil { + return err + } + } + + results, err := client.SearchMemory(cmd.Context(), query, MemorySearchQuery{ + Scope: parsedScope, + Workspace: workspace, + Limit: limit, + }) + if err != nil { + return err + } + return writeCommandOutput(cmd, memorySearchBundle(results)) + }, + } + cmd.Flags().StringVar(&scope, "scope", "", "Memory scope: global or workspace") + cmd.Flags().IntVar(&limit, "limit", 10, "Maximum number of results to return") + return cmd +} + func newMemoryWriteCommand(deps commandDeps) *cobra.Command { var ( scope string @@ -255,6 +331,51 @@ func newMemoryDeleteCommand(deps commandDeps) *cobra.Command { return cmd } +func newMemoryReindexCommand(deps commandDeps) *cobra.Command { + var scope string + + cmd := &cobra.Command{ + Use: "reindex", + Short: "Rebuild the derived memory search catalog", + Example: ` # Reindex global and current-workspace memory + agh memory reindex + + # Reindex only workspace-scoped memory + agh memory reindex --scope workspace`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + client, err := clientFromDeps(deps) + if err != nil { + return err + } + + parsedScope, err := parseOptionalCLIMemoryScope(scope) + if err != nil { + return err + } + + workspace := "" + if parsedScope != memory.ScopeGlobal { + workspace, err = currentWorkingDirectory(deps) + if err != nil { + return err + } + } + + result, err := client.ReindexMemory(cmd.Context(), MemoryReindexRequest{ + Scope: string(parsedScope), + Workspace: workspace, + }) + if err != nil { + return err + } + return writeCommandOutput(cmd, memoryReindexBundle(memoryReindexView(result))) + }, + } + cmd.Flags().StringVar(&scope, "scope", "", "Memory scope: global or workspace") + return cmd +} + func newMemoryConsolidateCommand(deps commandDeps) *cobra.Command { return &cobra.Command{ Use: "consolidate", @@ -592,6 +713,50 @@ func memoryReadBundle(view memoryReadView) outputBundle { } } +func memorySearchBundle(results []MemorySearchRecord) outputBundle { + items := make([]memorySearchItem, 0, len(results)) + for _, result := range results { + items = append(items, memorySearchItem{ + Filename: result.Filename, + Name: result.Name, + Type: result.Type, + Scope: result.Scope, + Workspace: result.Workspace, + Score: result.Score, + Description: result.Description, + Snippet: result.Snippet, + ModTime: result.ModTime, + }) + } + + return listBundle( + items, + items, + "Memory Search", + []string{"Filename", "Name", "Scope", "Score", "Snippet"}, + "results", + []string{"filename", "name", "scope", "score", "snippet"}, + func(item memorySearchItem) []string { + return []string{ + stringOrDash(item.Filename), + stringOrDash(item.Name), + stringOrDash(string(item.Scope)), + fmt.Sprintf("%.2f", item.Score), + stringOrDash(item.Snippet), + } + }, + func(item memorySearchItem) []string { + return []string{ + item.Filename, + item.Name, + string(item.Scope), + fmt.Sprintf("%.2f", item.Score), + item.Snippet, + } + }, + ) +} + func memoryMutationBundle(view memoryMutationView) outputBundle { return outputBundle{ jsonValue: view, @@ -618,3 +783,29 @@ func memoryMutationBundle(view memoryMutationView) outputBundle { }, } } + +func memoryReindexBundle(view memoryReindexView) outputBundle { + return outputBundle{ + jsonValue: view, + human: func() (string, error) { + return renderHumanSection("Memory Reindex", []keyValue{ + {Label: "Scope", Value: stringOrDash(string(view.Scope))}, + {Label: "Workspace", Value: stringOrDash(view.Workspace)}, + {Label: "Indexed Files", Value: fmt.Sprintf("%d", view.IndexedFiles)}, + {Label: "Completed At", Value: view.CompletedAt.Format(time.RFC3339)}, + }), nil + }, + toon: func() (string, error) { + return renderToonObject( + "memory_reindex", + []string{"scope", "workspace", "indexed_files", "completed_at"}, + []string{ + string(view.Scope), + view.Workspace, + fmt.Sprintf("%d", view.IndexedFiles), + view.CompletedAt.Format(time.RFC3339), + }, + ), nil + }, + } +} diff --git a/internal/cli/memory_test.go b/internal/cli/memory_test.go index 893043604..088a6cfa9 100644 --- a/internal/cli/memory_test.go +++ b/internal/cli/memory_test.go @@ -77,6 +77,58 @@ func TestMemoryReadCommandOutputsContent(t *testing.T) { } } +func TestMemorySearchAndReindexCommands(t *testing.T) { + t.Parallel() + + var searchQuery MemorySearchQuery + var searchText string + var reindexReq MemoryReindexRequest + deps := newTestDeps(t, &stubClient{ + searchMemoryFn: func(_ context.Context, query string, opts MemorySearchQuery) ([]MemorySearchRecord, error) { + searchText = query + searchQuery = opts + return []MemorySearchRecord{{ + Filename: "auth.md", + Name: "Auth Rewrite", + Scope: memory.ScopeWorkspace, + Score: 4.2, + Snippet: "Auth migration uses sessions", + Workspace: "/workspace/project", + }}, nil + }, + reindexMemoryFn: func(_ context.Context, request MemoryReindexRequest) (MemoryReindexRecord, error) { + reindexReq = request + return MemoryReindexRecord{ + IndexedFiles: 2, + Workspace: "/workspace/project", + CompletedAt: fixedTestNow, + }, nil + }, + }) + + searchOut, _, err := executeRootCommand(t, deps, "memory", "search", "auth rewrite") + if err != nil { + t.Fatalf("memory search error = %v", err) + } + if searchText != "auth rewrite" || searchQuery.Workspace != "/workspace/project" { + t.Fatalf("search call = query:%q opts:%#v", searchText, searchQuery) + } + if !strings.Contains(searchOut, "Auth Rewrite") || !strings.Contains(searchOut, "auth.md") { + t.Fatalf("search output = %q", searchOut) + } + + reindexOut, _, err := executeRootCommand(t, deps, "memory", "reindex") + if err != nil { + t.Fatalf("memory reindex error = %v", err) + } + if reindexReq.Workspace != "/workspace/project" { + t.Fatalf("reindex request = %#v, want workspace", reindexReq) + } + if !strings.Contains(reindexOut, "Indexed Files") || !strings.Contains(reindexOut, "2") { + t.Fatalf("reindex output = %q", reindexOut) + } +} + func TestMemoryWriteCommandBuildsDocumentAndUsesContentFlag(t *testing.T) { t.Parallel() diff --git a/internal/daemon/boot.go b/internal/daemon/boot.go index 19d0c0129..bdb2bbe36 100644 --- a/internal/daemon/boot.go +++ b/internal/daemon/boot.go @@ -253,7 +253,10 @@ func (d *Daemon) bootPromptProviders(_ context.Context, state *bootState) error if state.globalMemoryDir == "" { state.globalMemoryDir = d.homePaths.MemoryDir } - state.memoryStore = memory.NewStore(state.globalMemoryDir) + state.memoryStore = memory.NewStore( + state.globalMemoryDir, + memory.WithCatalogDatabasePath(d.homePaths.DatabaseFile), + ) if err := state.memoryStore.EnsureDirs(); err != nil { return fmt.Errorf("daemon: ensure memory store directories: %w", err) } @@ -445,6 +448,7 @@ func (d *Daemon) sessionManagerDeps(state *bootState) SessionManagerDeps { Compaction: state.notifier, }, PromptAssembler: state.promptAssembler, + MemoryStore: state.memoryStore, AgentResolver: agentCatalogDependency(state.agentCatalog), SkillRegistry: skillRegistryDependency(state.skillsRegistry), MCPResolver: mcpResolverDependency(state.mcpResolver), diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 9d3cfc888..270ebaf58 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -258,6 +258,7 @@ type SessionManagerDeps struct { Notifier session.Notifier Hooks session.HookSet PromptAssembler session.PromptAssembler + MemoryStore *memory.Store AgentResolver session.AgentResolver SkillRegistry session.SkillRegistry MCPResolver session.MCPResolver @@ -499,6 +500,7 @@ func (d *Daemon) applySessionManagerFactoryDefault() { session.WithNotifier(deps.Notifier), session.WithHookSet(deps.Hooks), session.WithPromptAssembler(deps.PromptAssembler), + session.WithPromptInputAugmenter(memory.NewRecallAugmenter(deps.MemoryStore)), session.WithAgentResolver(deps.AgentResolver), session.WithSkillRegistry(deps.SkillRegistry), session.WithMCPResolver(deps.MCPResolver), diff --git a/internal/daemon/daemon_memory_e2e_integration_test.go b/internal/daemon/daemon_memory_e2e_integration_test.go new file mode 100644 index 000000000..2c45d8d67 --- /dev/null +++ b/internal/daemon/daemon_memory_e2e_integration_test.go @@ -0,0 +1,454 @@ +//go:build integration && !windows + +package daemon + +import ( + "context" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" + + aghcontract "github.com/pedronauck/agh/internal/api/contract" + "github.com/pedronauck/agh/internal/memory" + "github.com/pedronauck/agh/internal/testutil/acpmock" + e2etest "github.com/pedronauck/agh/internal/testutil/e2e" +) + +func TestDaemonE2EMemoryCatalogCLIHTTPParityAndLegacyPathIsolation(t *testing.T) { + t.Parallel() + + harness := e2etest.StartRuntimeHarness(t, e2etest.RuntimeHarnessOptions{}) + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + legacyPath := filepath.Join(harness.WorkspaceRoot, ".compozy", "memory", "legacy-only.md") + if err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil { + t.Fatalf("os.MkdirAll(%q) error = %v", filepath.Dir(legacyPath), err) + } + if err := os.WriteFile( + legacyPath, + []byte(memoryDocument("Legacy Decoy", "Legacy path should stay ignored", memory.MemoryTypeProject, "legacy decoy")), + 0o644, + ); err != nil { + t.Fatalf("os.WriteFile(%q) error = %v", legacyPath, err) + } + + writeMemoryViaCLI( + t, + ctx, + harness, + "", + "prefs.md", + memory.MemoryTypeUser, + "User prefers concise answers", + "Prefer concise answers with direct technical detail.", + memory.ScopeGlobal, + ) + writeMemoryViaCLI( + t, + ctx, + harness, + harness.WorkspaceRoot, + "auth.md", + memory.MemoryTypeProject, + "Auth migration details", + "Remember me: auth migration uses sessions and workspace-scoped recall.", + memory.ScopeWorkspace, + ) + writeMemoryViaCLI( + t, + ctx, + harness, + harness.WorkspaceRoot, + "release-plan.md", + memory.MemoryTypeProject, + "Release plan details", + "Release plan covers regression gates and observability checks.", + memory.ScopeWorkspace, + ) + + var cliSearch []memory.SearchResult + if err := harness.CLI.RunJSONInDir( + ctx, + harness.WorkspaceRoot, + &cliSearch, + "memory", + "search", + "auth sessions", + "-o", + "json", + ); err != nil { + t.Fatalf("CLI memory search error = %v", err) + } + if !containsSearchResult(cliSearch, "auth.md", memory.ScopeWorkspace) { + t.Fatalf("CLI search results = %#v, want workspace auth.md hit", cliSearch) + } + if containsSearchResult(cliSearch, "legacy-only.md", memory.ScopeWorkspace) { + t.Fatalf("CLI search results = %#v, want legacy path ignored", cliSearch) + } + + var httpSearch []memory.SearchResult + if err := harness.HTTPJSON(ctx, http.MethodGet, memorySearchPath("auth sessions", harness.WorkspaceRoot), nil, &httpSearch); err != nil { + t.Fatalf("HTTP memory search error = %v", err) + } + if !containsSearchResult(httpSearch, "auth.md", memory.ScopeWorkspace) { + t.Fatalf("HTTP search results = %#v, want workspace auth.md hit", httpSearch) + } + if len(cliSearch) == 0 || len(httpSearch) == 0 { + t.Fatalf("search results = cli:%#v http:%#v, want non-empty parity", cliSearch, httpSearch) + } + if got, want := httpSearch[0].Filename, cliSearch[0].Filename; got != want { + t.Fatalf("HTTP top search filename = %q, want %q", got, want) + } + if got, want := httpSearch[0].Scope, cliSearch[0].Scope; got != want { + t.Fatalf("HTTP top search scope = %q, want %q", got, want) + } + + var cliLegacy []memory.SearchResult + if err := harness.CLI.RunJSONInDir( + ctx, + harness.WorkspaceRoot, + &cliLegacy, + "memory", + "search", + "legacy decoy", + "-o", + "json", + ); err != nil { + t.Fatalf("CLI legacy search error = %v", err) + } + if len(cliLegacy) != 0 { + t.Fatalf("CLI legacy search results = %#v, want empty result set", cliLegacy) + } + + var httpLegacy []memory.SearchResult + if err := harness.HTTPJSON(ctx, http.MethodGet, memorySearchPath("legacy decoy", harness.WorkspaceRoot), nil, &httpLegacy); err != nil { + t.Fatalf("HTTP legacy search error = %v", err) + } + if len(httpLegacy) != 0 { + t.Fatalf("HTTP legacy search results = %#v, want empty result set", httpLegacy) + } + + var cliReindex memory.ReindexResult + if err := harness.CLI.RunJSONInDir( + ctx, + harness.WorkspaceRoot, + &cliReindex, + "memory", + "reindex", + "-o", + "json", + ); err != nil { + t.Fatalf("CLI memory reindex error = %v", err) + } + if got, want := cliReindex.IndexedFiles, 3; got != want { + t.Fatalf("CLI reindex indexed_files = %d, want %d", got, want) + } + + var httpReindex memory.ReindexResult + if err := harness.HTTPJSON( + ctx, + http.MethodPost, + "/api/memory/reindex", + aghcontract.MemoryReindexRequest{Workspace: harness.WorkspaceRoot}, + &httpReindex, + ); err != nil { + t.Fatalf("HTTP memory reindex error = %v", err) + } + if got, want := httpReindex.IndexedFiles, 3; got != want { + t.Fatalf("HTTP reindex indexed_files = %d, want %d", got, want) + } + + var health aghcontract.HealthResponse + if err := harness.HTTPJSON( + ctx, + http.MethodGet, + "/api/observe/health?workspace="+url.QueryEscape(harness.WorkspaceRoot), + nil, + &health, + ); err != nil { + t.Fatalf("HTTP observe health error = %v", err) + } + if got, want := health.Memory.GlobalFiles, 1; got != want { + t.Fatalf("health.Memory.GlobalFiles = %d, want %d", got, want) + } + if got, want := health.Memory.WorkspaceFiles, 2; got != want { + t.Fatalf("health.Memory.WorkspaceFiles = %d, want %d", got, want) + } + if got, want := health.Memory.IndexedFiles, 3; got != want { + t.Fatalf("health.Memory.IndexedFiles = %d, want %d", got, want) + } + if got := health.Memory.OrphanedFiles; got != 0 { + t.Fatalf("health.Memory.OrphanedFiles = %d, want 0", got) + } + if health.Memory.LastReindex == nil { + t.Fatalf("health.Memory.LastReindex = nil, want non-nil") + } + + var reindexEvents aghcontract.ObserveEventsResponse + if err := harness.UDSJSON( + ctx, + http.MethodGet, + "/api/observe/events?type=memory.reindex&limit=10", + nil, + &reindexEvents, + ); err != nil { + t.Fatalf("UDS observe reindex events error = %v", err) + } + if !containsObserveEventSummary(reindexEvents.Events, "memory.reindex", "indexed=3") { + t.Fatalf("reindex observe events = %#v, want indexed=3 summary", reindexEvents.Events) + } + + var searchEvents aghcontract.ObserveEventsResponse + if err := harness.UDSJSON( + ctx, + http.MethodGet, + "/api/observe/events?type=memory.search&limit=10", + nil, + &searchEvents, + ); err != nil { + t.Fatalf("UDS observe search events error = %v", err) + } + if !containsObserveEventSummary(searchEvents.Events, "memory.search", `auth sessions`) { + t.Fatalf("search observe events = %#v, want auth search summary", searchEvents.Events) + } + if !containsObserveEventSummary(searchEvents.Events, "memory.search", `legacy decoy`) { + t.Fatalf("search observe events = %#v, want legacy search summary", searchEvents.Events) + } +} + +func TestDaemonE2EMemoryRecallUsesCatalogSynthesisWithoutMutatingStoredUserMessage(t *testing.T) { + acpmock.RequireDriver(t) + t.Parallel() + + harness := e2etest.StartRuntimeHarness(t, e2etest.RuntimeHarnessOptions{ + MockAgents: []e2etest.MockAgentSpec{{ + FixturePath: mockFixturePath(t, "memory_recall_fixture.json"), + FixtureAgent: "memory-recall-agent", + AgentName: "memory-recall-agent", + }}, + }) + registration, ok := harness.MockAgentRegistration("memory-recall-agent") + if !ok { + t.Fatal("MockAgentRegistration(memory-recall-agent) = missing, want present") + } + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + writeMemoryViaUDS( + t, + ctx, + harness, + "auth.md", + harness.WorkspaceRoot, + memoryDocument( + "Auth", + "Workspace auth migration details", + memory.MemoryTypeProject, + "Remember me: auth migration uses sessions and workspace-scoped recall.", + ), + ) + + indexPath := filepath.Join(harness.WorkspaceRoot, ".agh", "memory", "MEMORY.md") + staleIndex := "- [Old](missing.md) - stale index entry\n" + if err := os.WriteFile(indexPath, []byte(staleIndex), 0o644); err != nil { + t.Fatalf("os.WriteFile(%q) error = %v", indexPath, err) + } + + session := createFixtureBackedSession(t, ctx, harness, "memory-recall-agent", "memory-recall-session") + if _, err := harness.PromptSession(ctx, session.ID, "remember me"); err != nil { + t.Fatalf("PromptSession() error = %v", err) + } + + eventsResp, err := harness.SessionEvents(ctx, session.ID) + if err != nil { + t.Fatalf("SessionEvents() error = %v", err) + } + events := decodeAgentEvents(t, eventsResp.Events) + if !containsAgentEvent(events, aghcontract.AgentEventPayload{ + Type: "agent_message", + Text: "qa-memory acknowledged", + }) { + t.Fatalf("session events = %#v, want recall fixture assistant response", events) + } + userEvent, ok := firstAgentEventByType(events, "user_message") + if !ok { + t.Fatalf("session events = %#v, want stored user_message event", events) + } + if got, want := userEvent.Text, "remember me"; got != want { + t.Fatalf("stored user_message text = %q, want %q", got, want) + } + if strings.Contains(userEvent.Text, "Relevant durable memory for this turn:") { + t.Fatalf("stored user_message text = %q, want no injected recall block", userEvent.Text) + } + + diagnostics, err := acpmock.ReadDiagnostics(registration.DiagnosticsPath) + if err != nil { + t.Fatalf("ReadDiagnostics(%q) error = %v", registration.DiagnosticsPath, err) + } + if got, want := len(diagnostics), 1; got != want { + t.Fatalf("len(diagnostics) = %d, want %d; diagnostics=%#v", got, want, diagnostics) + } + prompt := diagnostics[0].Prompt + if !strings.Contains(prompt, "Relevant durable memory for this turn:") { + t.Fatalf("mock prompt = %q, want recall preamble", prompt) + } + if !strings.Contains(prompt, "- Auth [workspace]") { + t.Fatalf("mock prompt = %q, want workspace recall heading", prompt) + } + if !strings.Contains(prompt, "auth migration uses sessions") { + t.Fatalf("mock prompt = %q, want recalled auth snippet", prompt) + } + if !strings.Contains(prompt, "\n\nUser message:\nremember me") { + t.Fatalf("mock prompt = %q, want raw user message suffix", prompt) + } + if strings.Contains(prompt, "missing.md") { + t.Fatalf("mock prompt = %q, want stale MEMORY.md entry ignored", prompt) + } + + indexBytes, err := os.ReadFile(indexPath) + if err != nil { + t.Fatalf("os.ReadFile(%q) error = %v", indexPath, err) + } + if got := string(indexBytes); got != staleIndex { + t.Fatalf("workspace MEMORY.md = %q, want unchanged stale index", got) + } +} + +type cliMemoryMutationRecord struct { + Filename string `json:"filename"` + Scope memory.Scope `json:"scope"` + Status string `json:"status"` +} + +func writeMemoryViaCLI( + t testing.TB, + ctx context.Context, + harness *e2etest.RuntimeHarness, + workdir string, + filename string, + memoryType memory.Type, + description string, + content string, + scope memory.Scope, +) { + t.Helper() + + var result cliMemoryMutationRecord + if err := harness.CLI.RunJSONInDir( + ctx, + workdir, + &result, + "memory", + "write", + filename, + "--type", + string(memoryType), + "--description", + description, + "--content", + content, + "--scope", + string(scope), + "-o", + "json", + ); err != nil { + t.Fatalf("CLI memory write %q error = %v", filename, err) + } + if got, want := result.Status, "written"; got != want { + t.Fatalf("CLI memory write %q status = %q, want %q", filename, got, want) + } +} + +func writeMemoryViaUDS( + t testing.TB, + ctx context.Context, + harness *e2etest.RuntimeHarness, + filename string, + workspace string, + content string, +) { + t.Helper() + + var response aghcontract.MemoryMutationResponse + if err := harness.UDSJSON( + ctx, + http.MethodPut, + "/api/memory/"+url.PathEscape(filename), + aghcontract.MemoryWriteRequest{ + Scope: string(memory.ScopeWorkspace), + Workspace: workspace, + Content: content, + }, + &response, + ); err != nil { + t.Fatalf("UDS memory write %q error = %v", filename, err) + } + if !response.OK { + t.Fatalf("UDS memory write %q = %#v, want ok=true", filename, response) + } +} + +func memoryDocument(name string, description string, memoryType memory.Type, body string) string { + return strings.TrimSpace(strings.Join([]string{ + "---", + "name: " + name, + "description: " + description, + "type: " + string(memoryType), + "---", + "", + body, + }, "\n")) + "\n" +} + +func memorySearchPath(query string, workspace string) string { + values := url.Values{} + values.Set("q", query) + if strings.TrimSpace(workspace) != "" { + values.Set("workspace", workspace) + } + return "/api/memory/search?" + values.Encode() +} + +func containsSearchResult(results []memory.SearchResult, filename string, scope memory.Scope) bool { + for _, result := range results { + if result.Filename == filename && result.Scope == scope { + return true + } + } + return false +} + +func containsObserveEventSummary( + events []aghcontract.ObserveEventPayload, + eventType string, + summaryFragment string, +) bool { + for _, event := range events { + if event.Type != eventType { + continue + } + if strings.Contains(event.Summary, summaryFragment) { + return true + } + } + return false +} + +func firstAgentEventByType( + events []aghcontract.AgentEventPayload, + eventType string, +) (aghcontract.AgentEventPayload, bool) { + for _, event := range events { + if event.Type == eventType { + return event, true + } + } + return aghcontract.AgentEventPayload{}, false +} diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index 3a97da5ac..5019800c6 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -3143,7 +3143,31 @@ func testConfigPtr(t *testing.T, homePaths aghconfig.HomePaths) *aghconfig.Confi func writeDaemonMemoryIndex(t *testing.T, globalDir string, workspace string) { t.Helper() + writeDaemonFile( + t, + filepath.Join(globalDir, "global.md"), + `--- +name: Global +description: global note +type: user +--- + +global note +`, + ) writeDaemonFile(t, filepath.Join(globalDir, "MEMORY.md"), "- [Global](global.md) - global note") + writeDaemonFile( + t, + filepath.Join(workspace, aghconfig.DirName, "memory", "workspace.md"), + `--- +name: Workspace +description: workspace note +type: project +--- + +workspace note +`, + ) writeDaemonFile( t, filepath.Join(workspace, aghconfig.DirName, "memory", "MEMORY.md"), diff --git a/internal/memory/assembler.go b/internal/memory/assembler.go index 474e8114b..e51555e78 100644 --- a/internal/memory/assembler.go +++ b/internal/memory/assembler.go @@ -23,7 +23,9 @@ Only prompt-safe MEMORY.md indexes are injected here. Read full memory files on memoryCommandsSection = `## Memory Commands - ` + "`agh memory list`" + ` shows discoverable memory files in the current scope. +- ` + "`agh memory search `" + ` searches durable memory before opening individual files. - ` + "`agh memory read `" + ` reads the full content of one memory file. +- ` + "`agh memory reindex`" + ` rebuilds the derived search catalog from Markdown memory files. - ` + "`agh memory write --type --description --content `" + ` writes or updates durable memory.` memoryStalenessSection = `## Staleness Policy diff --git a/internal/memory/assembler_test.go b/internal/memory/assembler_test.go index da91f09b7..9dd5a035f 100644 --- a/internal/memory/assembler_test.go +++ b/internal/memory/assembler_test.go @@ -89,7 +89,14 @@ func TestAssemblerAssemble(t *testing.T) { env.writeGlobalIndex(t, "- [Global](global.md) - global note") got := env.assemble(t) - for _, want := range []string{"## Memory Commands", "`agh memory list`", "`agh memory read `", "`agh memory write --type --description --content `"} { + for _, want := range []string{ + "## Memory Commands", + "`agh memory list`", + "`agh memory search `", + "`agh memory read `", + "`agh memory reindex`", + "`agh memory write --type --description --content `", + } { if !strings.Contains(got, want) { t.Fatalf("assembled prompt missing command reference %q: %q", want, got) } @@ -265,11 +272,13 @@ func resolvedWorkspacePtr(root string) *workspacepkg.ResolvedWorkspace { func (e assemblerTestEnv) writeGlobalIndex(t *testing.T, content string) { t.Helper() writeAssemblerFileForTest(t, filepath.Join(e.store.globalDir, indexFilename), content) + e.writeIndexBackedDocuments(t, ScopeGlobal, "", content) } func (e assemblerTestEnv) writeWorkspaceIndex(t *testing.T, content string) { t.Helper() writeAssemblerFileForTest(t, filepath.Join(e.store.ForWorkspace(e.workspace).workspaceDir, indexFilename), content) + e.writeIndexBackedDocuments(t, ScopeWorkspace, e.workspace, content) } func writeAssemblerFileForTest(t *testing.T, path string, content string) { @@ -282,6 +291,58 @@ func writeAssemblerFileForTest(t *testing.T, path string, content string) { } } +func (e assemblerTestEnv) writeIndexBackedDocuments(t *testing.T, scope Scope, workspace string, content string) { + t.Helper() + + target := e.store + if scope == ScopeWorkspace { + target = e.store.ForWorkspace(workspace) + } + + for line := range strings.SplitSeq(content, "\n") { + filename, ok := firstMarkdownLinkTarget(line) + if !ok { + continue + } + name := "Stub Memory" + if start := strings.Index(line, "["); start >= 0 { + if end := strings.Index(line[start+1:], "]"); end >= 0 { + name = strings.TrimSpace(line[start+1 : start+1+end]) + } + } + description := "stub description" + if _, after, ok0 := strings.Cut(line, " - "); ok0 { + description = strings.TrimSpace(after) + } + doc := strings.Join([]string{ + "---", + "name: " + name, + "description: " + description, + "type: user", + "---", + "", + "stub body", + "", + }, "\n") + if err := os.WriteFile( + filepath.Join(target.dirForScopeMust(t, scope), filename), + []byte(doc), + 0o644, + ); err != nil { + t.Fatalf("WriteFile(%q) error = %v", filename, err) + } + } +} + +func (s *Store) dirForScopeMust(t *testing.T, scope Scope) string { + t.Helper() + dir, err := s.dirForScope(scope) + if err != nil { + t.Fatalf("dirForScope(%q) error = %v", scope, err) + } + return dir +} + func testResolvedWorkspace(root string) workspacepkg.ResolvedWorkspace { return workspacepkg.ResolvedWorkspace{ Workspace: workspacepkg.Workspace{RootDir: root}, diff --git a/internal/memory/catalog.go b/internal/memory/catalog.go new file mode 100644 index 000000000..04d6dd065 --- /dev/null +++ b/internal/memory/catalog.go @@ -0,0 +1,698 @@ +package memory + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "path/filepath" + "sort" + "strings" + "sync" + "time" + "unicode" + + aghconfig "github.com/pedronauck/agh/internal/config" + storepkg "github.com/pedronauck/agh/internal/store" +) + +const ( + defaultSearchLimit = 10 + catalogStateKeyLastReindex = "last_reindex_at" + catalogEventAgentName = "daemon" +) + +var catalogSchemaStatements = []string{ + `CREATE TABLE IF NOT EXISTS memory_catalog_entries ( + id TEXT PRIMARY KEY, + scope TEXT NOT NULL CHECK (scope IN ('global', 'workspace')), + workspace_id TEXT NOT NULL DEFAULT '', + workspace_root TEXT NOT NULL DEFAULT '', + filename TEXT NOT NULL, + type TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + content TEXT NOT NULL, + content_hash TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (scope, workspace_root, filename) + );`, + `CREATE INDEX IF NOT EXISTS idx_memory_catalog_scope ON memory_catalog_entries(scope);`, + `CREATE INDEX IF NOT EXISTS idx_memory_catalog_workspace_root ON memory_catalog_entries(workspace_root);`, + `CREATE INDEX IF NOT EXISTS idx_memory_catalog_updated_at ON memory_catalog_entries(updated_at);`, + `CREATE VIRTUAL TABLE IF NOT EXISTS memory_catalog_fts USING fts5( + name, + description, + content, + content='memory_catalog_entries', + content_rowid='rowid', + tokenize='porter unicode61' + );`, + `CREATE TRIGGER IF NOT EXISTS memory_catalog_entries_ai AFTER INSERT ON memory_catalog_entries BEGIN + INSERT INTO memory_catalog_fts(rowid, name, description, content) + VALUES (new.rowid, new.name, new.description, new.content); + END;`, + `CREATE TRIGGER IF NOT EXISTS memory_catalog_entries_ad AFTER DELETE ON memory_catalog_entries BEGIN + INSERT INTO memory_catalog_fts(memory_catalog_fts, rowid, name, description, content) + VALUES ('delete', old.rowid, old.name, old.description, old.content); + END;`, + `CREATE TRIGGER IF NOT EXISTS memory_catalog_entries_au AFTER UPDATE ON memory_catalog_entries BEGIN + INSERT INTO memory_catalog_fts(memory_catalog_fts, rowid, name, description, content) + VALUES ('delete', old.rowid, old.name, old.description, old.content); + INSERT INTO memory_catalog_fts(rowid, name, description, content) + VALUES (new.rowid, new.name, new.description, new.content); + END;`, + `CREATE TABLE IF NOT EXISTS memory_catalog_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + );`, + `CREATE TABLE IF NOT EXISTS memory_operation_log ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + agent_name TEXT NOT NULL DEFAULT 'daemon', + summary TEXT NOT NULL DEFAULT '', + timestamp TEXT NOT NULL + );`, + `CREATE INDEX IF NOT EXISTS idx_memory_operation_log_type ON memory_operation_log(type);`, + `CREATE INDEX IF NOT EXISTS idx_memory_operation_log_timestamp ON memory_operation_log(timestamp);`, +} + +type catalog struct { + path string + now func() time.Time + + mu sync.Mutex + db *sql.DB +} + +type catalogDocument struct { + ID string + Scope Scope + WorkspaceID string + WorkspaceRoot string + Filename string + Type Type + Name string + Description string + Content string + ContentHash string + UpdatedAt time.Time +} + +func newCatalog(path string, now func() time.Time) *catalog { + cleanPath := strings.TrimSpace(path) + if cleanPath == "" { + return nil + } + if now == nil { + now = func() time.Time { + return time.Now().UTC() + } + } + return &catalog{path: cleanPath, now: now} +} + +func (c *catalog) enabled() bool { + return c != nil && strings.TrimSpace(c.path) != "" +} + +func (c *catalog) ensureDB(ctx context.Context) (*sql.DB, error) { + if !c.enabled() { + return nil, nil + } + if ctx == nil { + return nil, errors.New("memory: catalog context is required") + } + + c.mu.Lock() + defer c.mu.Unlock() + + if c.db != nil { + return c.db, nil + } + + db, err := storepkg.OpenSQLiteDatabase(ctx, c.path, func(ctx context.Context, db *sql.DB) error { + return storepkg.EnsureSchema(ctx, db, catalogSchemaStatements) + }) + if err != nil { + return nil, fmt.Errorf("memory: open catalog database %q: %w", c.path, err) + } + c.db = db + return c.db, nil +} + +func (c *catalog) replaceScope( + ctx context.Context, + scope Scope, + workspaceRoot string, + docs []catalogDocument, +) (err error) { + db, err := c.ensureDB(ctx) + if err != nil { + return err + } + if db == nil { + return nil + } + + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("memory: begin catalog scope replace: %w", err) + } + defer func() { + if tx == nil { + return + } + if rollbackErr := tx.Rollback(); rollbackErr != nil && + !errors.Is(rollbackErr, sql.ErrTxDone) && + err == nil { + err = fmt.Errorf("memory: rollback catalog scope replace: %w", rollbackErr) + } + }() + + if _, err := tx.ExecContext( + ctx, + `DELETE FROM memory_catalog_entries WHERE scope = ? AND workspace_root = ?`, + string(scope.Normalize()), + strings.TrimSpace(workspaceRoot), + ); err != nil { + return fmt.Errorf("memory: clear catalog scope %q/%q: %w", scope, workspaceRoot, err) + } + + for _, doc := range docs { + if _, err := tx.ExecContext( + ctx, + `INSERT INTO memory_catalog_entries ( + id, scope, workspace_id, workspace_root, filename, type, name, + description, content, content_hash, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + doc.ID, + string(doc.Scope.Normalize()), + strings.TrimSpace(doc.WorkspaceID), + strings.TrimSpace(doc.WorkspaceRoot), + doc.Filename, + string(doc.Type.Normalize()), + doc.Name, + doc.Description, + doc.Content, + doc.ContentHash, + storepkg.FormatTimestamp(doc.UpdatedAt), + ); err != nil { + return fmt.Errorf("memory: insert catalog entry %q: %w", doc.Filename, err) + } + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("memory: commit catalog scope replace: %w", err) + } + tx = nil + return nil +} + +func (c *catalog) setLastReindex(ctx context.Context, when time.Time) error { + db, err := c.ensureDB(ctx) + if err != nil { + return err + } + if db == nil { + return nil + } + if when.IsZero() { + when = c.now() + } + if _, err := db.ExecContext( + ctx, + `INSERT INTO memory_catalog_state (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + catalogStateKeyLastReindex, + storepkg.FormatTimestamp(when.UTC()), + ); err != nil { + return fmt.Errorf("memory: persist catalog reindex timestamp: %w", err) + } + return nil +} + +func (c *catalog) lastReindex(ctx context.Context) (*time.Time, error) { + db, err := c.ensureDB(ctx) + if err != nil { + return nil, err + } + if db == nil { + return nil, nil + } + + var raw string + if err := db.QueryRowContext( + ctx, + `SELECT value FROM memory_catalog_state WHERE key = ?`, + catalogStateKeyLastReindex, + ).Scan(&raw); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("memory: load catalog reindex timestamp: %w", err) + } + + parsed, err := storepkg.ParseTimestamp(raw) + if err != nil { + return nil, fmt.Errorf("memory: parse catalog reindex timestamp %q: %w", raw, err) + } + parsed = parsed.UTC() + return &parsed, nil +} + +func (c *catalog) entryCount(ctx context.Context) (int, error) { + db, err := c.ensureDB(ctx) + if err != nil { + return 0, err + } + if db == nil { + return 0, nil + } + + var count int + if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM memory_catalog_entries`).Scan(&count); err != nil { + return 0, fmt.Errorf("memory: count catalog entries: %w", err) + } + return count, nil +} + +func (c *catalog) listEntries(ctx context.Context, filters []catalogFilter) ([]catalogDocument, error) { + db, err := c.ensureDB(ctx) + if err != nil { + return nil, err + } + if db == nil { + return nil, nil + } + + rows, err := db.QueryContext( + ctx, + `SELECT id, scope, workspace_id, workspace_root, filename, type, name, description, content, content_hash, updated_at + FROM memory_catalog_entries + ORDER BY updated_at DESC, filename ASC`, + ) + if err != nil { + return nil, fmt.Errorf("memory: list catalog entries: %w", err) + } + defer func() { + _ = rows.Close() + }() + + allowedGlobal := false + allowedWorkspaces := make(map[string]struct{}) + for _, filter := range filters { + switch filter.scope.Normalize() { + case ScopeGlobal: + allowedGlobal = true + case ScopeWorkspace: + allowedWorkspaces[strings.TrimSpace(filter.workspaceRoot)] = struct{}{} + } + } + + entries := make([]catalogDocument, 0) + for rows.Next() { + entry, scanErr := scanCatalogEntry(rows) + if scanErr != nil { + return nil, scanErr + } + if len(filters) > 0 { + switch entry.Scope.Normalize() { + case ScopeGlobal: + if !allowedGlobal { + continue + } + case ScopeWorkspace: + if _, ok := allowedWorkspaces[strings.TrimSpace(entry.WorkspaceRoot)]; !ok { + continue + } + default: + continue + } + } + entries = append(entries, entry) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("memory: iterate catalog entries: %w", err) + } + return entries, nil +} + +func (c *catalog) search( + ctx context.Context, + query string, + scope Scope, + workspaceRoot string, + limit int, +) ([]SearchResult, error) { + db, err := c.ensureDB(ctx) + if err != nil { + return nil, err + } + if db == nil { + return nil, nil + } + + match, err := buildCatalogMatchQuery(query) + if err != nil { + return nil, err + } + if limit <= 0 { + limit = defaultSearchLimit + } + + base := strings.Join([]string{ + `SELECT`, + ` e.scope,`, + ` e.workspace_root,`, + ` e.filename,`, + ` e.type,`, + ` e.name,`, + ` e.description,`, + ` e.updated_at,`, + ` -bm25(memory_catalog_fts) AS score,`, + ` snippet(memory_catalog_fts, 2, '[', ']', '...', 18) AS snippet`, + `FROM memory_catalog_fts`, + `JOIN memory_catalog_entries e ON e.rowid = memory_catalog_fts.rowid`, + `WHERE memory_catalog_fts MATCH ?`, + }, "\n") + + args := []any{match} + base, args = appendCatalogScopeFilter(base, args, scope, workspaceRoot) + base += "\nORDER BY bm25(memory_catalog_fts) ASC, e.updated_at DESC, e.filename ASC\nLIMIT ?" + args = append(args, limit) + + rows, err := db.QueryContext(ctx, base, args...) + if err != nil { + return nil, fmt.Errorf("memory: search catalog: %w", err) + } + defer func() { + _ = rows.Close() + }() + + results := make([]SearchResult, 0, limit) + for rows.Next() { + result, scanErr := scanSearchResult(rows) + if scanErr != nil { + return nil, scanErr + } + results = append(results, result) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("memory: iterate search results: %w", err) + } + return results, nil +} + +func (c *catalog) logEvent(ctx context.Context, eventType string, summary string) error { + db, err := c.ensureDB(ctx) + if err != nil { + return err + } + if db == nil { + return nil + } + if strings.TrimSpace(eventType) == "" { + return errors.New("memory: event type is required") + } + if _, err := db.ExecContext( + ctx, + `INSERT INTO memory_operation_log (id, type, agent_name, summary, timestamp) VALUES (?, ?, ?, ?, ?)`, + storepkg.NewID("memevt"), + strings.TrimSpace(eventType), + catalogEventAgentName, + strings.TrimSpace(summary), + storepkg.FormatTimestamp(c.now().UTC()), + ); err != nil { + return fmt.Errorf("memory: write memory operation log: %w", err) + } + return nil +} + +type catalogFilter struct { + scope Scope + workspaceRoot string +} + +func appendCatalogScopeFilter(base string, args []any, scope Scope, workspaceRoot string) (string, []any) { + switch scope.Normalize() { + case ScopeGlobal: + return base + "\nAND e.scope = 'global'", args + case ScopeWorkspace: + return base + "\nAND e.scope = 'workspace' AND e.workspace_root = ?", append( + args, + strings.TrimSpace(workspaceRoot), + ) + default: + trimmedWorkspace := strings.TrimSpace(workspaceRoot) + if trimmedWorkspace == "" { + return base + "\nAND e.scope = 'global'", args + } + return base + "\nAND (e.scope = 'global' OR (e.scope = 'workspace' AND e.workspace_root = ?))", + append(args, trimmedWorkspace) + } +} + +func scanCatalogEntry(scanner interface{ Scan(dest ...any) error }) (catalogDocument, error) { + var ( + doc catalogDocument + scopeRaw string + typeRaw string + updatedRaw string + ) + if err := scanner.Scan( + &doc.ID, + &scopeRaw, + &doc.WorkspaceID, + &doc.WorkspaceRoot, + &doc.Filename, + &typeRaw, + &doc.Name, + &doc.Description, + &doc.Content, + &doc.ContentHash, + &updatedRaw, + ); err != nil { + return catalogDocument{}, fmt.Errorf("memory: scan catalog entry: %w", err) + } + + updatedAt, err := storepkg.ParseTimestamp(updatedRaw) + if err != nil { + return catalogDocument{}, fmt.Errorf("memory: parse catalog updated_at %q: %w", updatedRaw, err) + } + doc.Scope = Scope(scopeRaw).Normalize() + doc.Type = Type(typeRaw).Normalize() + doc.UpdatedAt = updatedAt.UTC() + return doc, nil +} + +func scanSearchResult(scanner interface{ Scan(dest ...any) error }) (SearchResult, error) { + var ( + result SearchResult + scopeRaw string + typeRaw string + updatedRaw string + snippet sql.NullString + ) + if err := scanner.Scan( + &scopeRaw, + &result.Workspace, + &result.Filename, + &typeRaw, + &result.Name, + &result.Description, + &updatedRaw, + &result.Score, + &snippet, + ); err != nil { + return SearchResult{}, fmt.Errorf("memory: scan search result: %w", err) + } + + updatedAt, err := storepkg.ParseTimestamp(updatedRaw) + if err != nil { + return SearchResult{}, fmt.Errorf("memory: parse search result updated_at %q: %w", updatedRaw, err) + } + result.Scope = Scope(scopeRaw).Normalize() + result.Type = Type(typeRaw).Normalize() + result.ModTime = updatedAt.UTC() + if snippet.Valid { + result.Snippet = cleanSnippet(snippet.String) + } + if result.Snippet == "" { + result.Snippet = result.Description + } + return result, nil +} + +func buildCatalogMatchQuery(query string) (string, error) { + terms := tokenizeSearchQuery(query) + if len(terms) == 0 { + return "", wrapValidationError("search query", query, errors.New("query is required")) + } + return strings.Join(terms, " "), nil +} + +func tokenizeSearchQuery(query string) []string { + fields := strings.FieldsFunc(strings.ToLower(strings.TrimSpace(query)), func(r rune) bool { + return !unicode.IsLetter(r) && !unicode.IsNumber(r) + }) + out := make([]string, 0, len(fields)) + seen := make(map[string]struct{}, len(fields)) + for _, field := range fields { + trimmed := strings.TrimSpace(field) + if trimmed == "" { + continue + } + if _, exists := seen[trimmed]; exists { + continue + } + seen[trimmed] = struct{}{} + out = append(out, trimmed) + } + return out +} + +func cleanSnippet(value string) string { + replacer := strings.NewReplacer("\n", " ", "\r", " ", "\t", " ") + return strings.Join(strings.Fields(replacer.Replace(strings.TrimSpace(value))), " ") +} + +func catalogDocID(scope Scope, workspaceRoot string, filename string) string { + return strings.Join( + []string{string(scope.Normalize()), strings.TrimSpace(workspaceRoot), strings.TrimSpace(filename)}, + "::", + ) +} + +func hashMemoryContent(content []byte) string { + sum := sha256.Sum256(content) + return hex.EncodeToString(sum[:]) +} + +func buildCatalogDocument( + scope Scope, + workspaceRoot string, + header Header, + rawContent []byte, +) (catalogDocument, error) { + body, err := parseFrontmatter(rawContent, &Header{}) + if err != nil { + return catalogDocument{}, fmt.Errorf("memory: parse memory body for %q: %w", header.Filename, err) + } + return catalogDocument{ + ID: catalogDocID(scope, workspaceRoot, header.Filename), + Scope: scope.Normalize(), + WorkspaceRoot: strings.TrimSpace(workspaceRoot), + Filename: header.Filename, + Type: header.Type.Normalize(), + Name: header.Name, + Description: header.Description, + Content: strings.TrimSpace(body), + ContentHash: hashMemoryContent(rawContent), + UpdatedAt: header.ModTime.UTC(), + }, nil +} + +func fallbackSearchDocuments(query string, docs []catalogDocument, limit int) ([]SearchResult, error) { + terms := tokenizeSearchQuery(query) + if len(terms) == 0 { + return nil, wrapValidationError("search query", query, errors.New("query is required")) + } + if limit <= 0 { + limit = defaultSearchLimit + } + + results := make([]SearchResult, 0, min(limit, len(docs))) + for _, doc := range docs { + score := fallbackDocumentScore(doc, terms) + if score <= 0 { + continue + } + results = append(results, SearchResult{ + Filename: doc.Filename, + Scope: doc.Scope, + Workspace: doc.WorkspaceRoot, + Type: doc.Type, + Name: doc.Name, + Description: doc.Description, + Score: score, + Snippet: fallbackSnippet(doc, terms), + ModTime: doc.UpdatedAt.UTC(), + }) + } + + sort.SliceStable(results, func(i, j int) bool { + if results[i].Score == results[j].Score { + if results[i].ModTime.Equal(results[j].ModTime) { + return results[i].Filename < results[j].Filename + } + return results[i].ModTime.After(results[j].ModTime) + } + return results[i].Score > results[j].Score + }) + + if len(results) > limit { + results = results[:limit] + } + return results, nil +} + +func fallbackDocumentScore(doc catalogDocument, terms []string) float64 { + searchable := strings.ToLower(strings.Join([]string{doc.Name, doc.Description, doc.Content}, "\n")) + score := 0.0 + for _, term := range terms { + count := strings.Count(searchable, term) + if count == 0 { + continue + } + score += float64(count) + if strings.Contains(strings.ToLower(doc.Name), term) { + score += 5 + } + if strings.Contains(strings.ToLower(doc.Description), term) { + score += 2 + } + } + return score +} + +func fallbackSnippet(doc catalogDocument, terms []string) string { + candidates := []string{doc.Description, doc.Content} + for _, candidate := range candidates { + cleaned := cleanSnippet(candidate) + lower := strings.ToLower(cleaned) + for _, term := range terms { + if strings.Contains(lower, term) { + return clipSnippet(cleaned, term, 180) + } + } + } + return cleanSnippet(doc.Description) +} + +func clipSnippet(text string, term string, maxLen int) string { + if maxLen <= 0 || len(text) <= maxLen { + return text + } + index := strings.Index(strings.ToLower(text), strings.ToLower(term)) + if index < 0 { + return text[:maxLen] + } + start := max(0, index-(maxLen/3)) + end := min(len(text), start+maxLen) + return strings.TrimSpace(text[start:end]) +} + +func deriveWorkspaceRoot(memoryDir string) string { + clean := strings.TrimSpace(memoryDir) + if clean == "" { + return "" + } + suffix := string(filepath.Separator) + aghconfig.DirName + string(filepath.Separator) + memoryDirName + if strings.HasSuffix(clean, suffix) { + return filepath.Dir(filepath.Dir(clean)) + } + return "" +} diff --git a/internal/memory/recall.go b/internal/memory/recall.go new file mode 100644 index 000000000..ec6cce7c6 --- /dev/null +++ b/internal/memory/recall.go @@ -0,0 +1,98 @@ +package memory + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/pedronauck/agh/internal/session" +) + +const ( + maxRecallResults = 3 + maxRecallCharacters = 1500 +) + +// NewRecallAugmenter returns a bounded prompt augmenter that prepends durable +// memory recall ahead of the live user message. +func NewRecallAugmenter(store *Store) session.PromptInputAugmenter { + if store == nil { + return nil + } + + return func(ctx context.Context, sess *session.Session, message string) (string, error) { + if sess == nil { + return message, nil + } + + query := strings.TrimSpace(message) + if query == "" { + return message, nil + } + + info := sess.Info() + workspaceRoot := strings.TrimSpace(info.Workspace) + target := store + if workspaceRoot != "" { + target = store.ForWorkspace(workspaceRoot) + } + + results, err := target.Search(ctx, query, SearchOptions{ + Workspace: workspaceRoot, + Limit: maxRecallResults, + }) + if err != nil { + return message, err + } + + now := time.Now().UTC() + block := buildRecallBlock(results, now) + if block == "" { + return message, nil + } + return block + "\n\nUser message:\n" + query, nil + } +} + +func buildRecallBlock(results []SearchResult, now time.Time) string { + if len(results) == 0 { + return "" + } + + lines := make([]string, 0, len(results)) + used := 0 + for _, result := range results { + if result.Score <= 0 { + continue + } + entryLines := []string{ + fmt.Sprintf("- %s [%s]", strings.TrimSpace(result.Name), result.Scope.Normalize()), + } + if snippet := strings.TrimSpace(result.Snippet); snippet != "" { + entryLines = append(entryLines, " Snippet: "+snippet) + } + if warning := FreshnessWarning(result.ModTime, now); warning != "" { + entryLines = append(entryLines, " Freshness: "+warning) + } + entry := strings.Join(entryLines, "\n") + if used > 0 && used+2+len(entry) > maxRecallCharacters { + break + } + lines = append(lines, entry) + used += len(entry) + if len(lines) == maxRecallResults { + break + } + } + + if len(lines) == 0 { + return "" + } + + return strings.Join([]string{ + "Relevant durable memory for this turn:", + strings.Join(lines, "\n"), + "Use recalled memory only when it remains consistent with the current repository and runtime state.", + }, "\n") +} diff --git a/internal/memory/staleness.go b/internal/memory/staleness.go index 32569eb47..60c7eae1f 100644 --- a/internal/memory/staleness.go +++ b/internal/memory/staleness.go @@ -34,6 +34,11 @@ func freshnessWarning(modTime time.Time, now time.Time) string { return fmt.Sprintf("This memory is %d days old. Verify against current state before asserting as fact.", age) } +// FreshnessWarning reports whether a memory should be treated as stale. +func FreshnessWarning(modTime time.Time, now time.Time) string { + return freshnessWarning(modTime, now) +} + func calendarDayNumber(value time.Time) int { year, month, day := value.Date() return int(time.Date(year, month, day, 12, 0, 0, 0, time.UTC).Unix() / int64(24*time.Hour/time.Second)) diff --git a/internal/memory/store.go b/internal/memory/store.go index b45659c38..155ee9fe5 100644 --- a/internal/memory/store.go +++ b/internal/memory/store.go @@ -1,6 +1,7 @@ package memory import ( + "context" "errors" "fmt" "log/slog" @@ -39,16 +40,39 @@ type Store struct { maxIndexLines int maxIndexBytes int logger *slog.Logger + catalog *catalog } // NewStore constructs a Store for the provided global memory directory. -func NewStore(globalDir string) *Store { - return &Store{ +func NewStore(globalDir string, opts ...StoreOption) *Store { + store := &Store{ globalDir: cleanDirPath(globalDir), maxIndexLines: defaultIndexLines, maxIndexBytes: defaultIndexBytes, logger: slog.Default(), } + for _, opt := range opts { + if opt != nil { + opt(store) + } + } + return store +} + +// StoreOption customizes a Store instance. +type StoreOption func(*Store) + +// WithCatalogDatabasePath enables the derived SQLite-backed memory catalog in +// the shared global database file. +func WithCatalogDatabasePath(path string) StoreOption { + return func(store *Store) { + if store == nil { + return + } + store.catalog = newCatalog(path, func() time.Time { + return time.Now().UTC() + }) + } } // ForWorkspace returns a clone of the store bound to the supplied workspace root. @@ -58,6 +82,11 @@ func (s *Store) ForWorkspace(workspaceRoot string) *Store { return &clone } +// List is the backend-aligned alias for Scan. +func (s *Store) List(scope Scope) ([]Header, error) { + return s.Scan(scope) +} + // EnsureDirs creates the configured memory directories when missing. func (s *Store) EnsureDirs() error { for _, dir := range []string{s.globalDir, s.workspaceDir} { @@ -129,6 +158,16 @@ func (s *Store) Write(scope Scope, filename string, content []byte) error { if err := fileutil.AtomicWriteFile(path, content, filePerm); err != nil { return fmt.Errorf("memory: write %q: %w", path, err) } + if err := s.syncScope(context.Background(), scope.Normalize()); err != nil { + return err + } + if err := s.logCatalogEvent( + context.Background(), + "memory.write", + fmt.Sprintf("scope=%s filename=%s", scope.Normalize(), filepath.Base(path)), + ); err != nil { + s.warn("memory: record write event failed", "scope", scope, "filename", filepath.Base(path), "error", err) + } return nil } @@ -145,15 +184,26 @@ func (s *Store) Delete(scope Scope, filename string) error { if filepath.Base(path) == indexFilename { return nil } - if err := s.removeIndexEntry(scope, filepath.Base(path)); err != nil { + if err := s.syncScope(context.Background(), scope.Normalize()); err != nil { return err } + if err := s.logCatalogEvent( + context.Background(), + "memory.delete", + fmt.Sprintf("scope=%s filename=%s", scope.Normalize(), filepath.Base(path)), + ); err != nil { + s.warn("memory: record delete event failed", "scope", scope, "filename", filepath.Base(path), "error", err) + } return nil } // Scan lists memory headers for a scope, sorted newest-first and capped at 200 files. func (s *Store) Scan(scope Scope) ([]Header, error) { + return s.scan(scope, maxScanEntries) +} + +func (s *Store) scan(scope Scope, limit int) ([]Header, error) { dir, err := s.dirForScope(scope) if err != nil { return nil, err @@ -167,7 +217,10 @@ func (s *Store) Scan(scope Scope) ([]Header, error) { return nil, fmt.Errorf("memory: scan %q: %w", dir, err) } - capacity := min(len(entries), maxScanEntries) + capacity := len(entries) + if limit > 0 { + capacity = min(capacity, limit) + } headers := make([]Header, 0, capacity) candidates := make([]scanCandidate, 0, len(entries)) for _, entry := range entries { @@ -225,7 +278,7 @@ func (s *Store) Scan(scope Scope) ([]Header, error) { header.ModTime = candidate.modTime headers = append(headers, header) - if len(headers) == maxScanEntries { + if limit > 0 && len(headers) == limit { break } } @@ -240,27 +293,189 @@ func (s *Store) LoadIndex(scope Scope) (content string, truncated bool, err erro return "", false, err } + headers, err := s.scan(scope.Normalize(), 0) + if err != nil { + return "", false, err + } + path := filepath.Join(dir, indexFilename) indexBytes, err := os.ReadFile(path) - if err != nil { - if errors.Is(err, os.ErrNotExist) { + switch { + case err == nil: + case errors.Is(err, os.ErrNotExist): + if len(headers) == 0 { return "", false, nil } + generated, generatedTruncated := truncateIndex(renderIndex(headers), s.maxIndexLines, s.maxIndexBytes) + return generated, generatedTruncated, nil + default: return "", false, fmt.Errorf("memory: load index %q: %w", path, err) } - content, truncated = truncateIndex(string(indexBytes), s.maxIndexLines, s.maxIndexBytes) - if truncated { - s.warn( - "memory: truncated memory index", - "scope", scope, - "path", path, - "max_lines", s.maxIndexLines, - "max_bytes", s.maxIndexBytes, - ) + indexContent := string(indexBytes) + if indexMatchesHeaders(indexContent, headers) { + content, truncated = truncateIndex(indexContent, s.maxIndexLines, s.maxIndexBytes) + if truncated { + s.warn( + "memory: truncated memory index", + "scope", scope, + "path", path, + "max_lines", s.maxIndexLines, + "max_bytes", s.maxIndexBytes, + ) + } + return content, truncated, nil + } + + s.warn("memory: synthesized prompt index from memory files", "scope", scope, "path", path) + generated, generatedTruncated := truncateIndex(renderIndex(headers), s.maxIndexLines, s.maxIndexBytes) + return generated, generatedTruncated, nil +} + +// Search performs bounded lexical memory search across the visible scopes. +func (s *Store) Search(ctx context.Context, query string, opts SearchOptions) ([]SearchResult, error) { + if ctx == nil { + return nil, errors.New("memory: search context is required") + } + + scope, workspaceRoot, err := s.normalizeScopeAndWorkspace(opts.Scope, opts.Workspace) + if err != nil { + return nil, err + } + limit := opts.Limit + if limit <= 0 { + limit = defaultSearchLimit + } + + if err := s.ensureCatalogReady(ctx, scope, workspaceRoot); err != nil { + return nil, err + } + if s.catalog != nil { + results, err := s.catalog.search(ctx, query, scope, workspaceRoot, limit) + if err != nil { + return nil, err + } + if err := s.logCatalogEvent( + ctx, + "memory.search", + fmt.Sprintf("query=%q results=%d", strings.TrimSpace(query), len(results)), + ); err != nil { + s.warn("memory: record search event failed", "error", err) + } + if len(results) > 0 { + return results, nil + } + } + + docs, err := s.collectSearchDocuments(scope, workspaceRoot) + if err != nil { + return nil, err + } + results, err := fallbackSearchDocuments(query, docs, limit) + if err != nil { + return nil, err + } + return results, nil +} + +// Reindex rebuilds the derived catalog from the Markdown source of truth. +func (s *Store) Reindex(ctx context.Context, opts ReindexOptions) (ReindexResult, error) { + if ctx == nil { + return ReindexResult{}, errors.New("memory: reindex context is required") + } + + scope, workspaceRoot, err := s.normalizeScopeAndWorkspace(opts.Scope, opts.Workspace) + if err != nil { + return ReindexResult{}, err + } + + indexed, err := s.reindexScopes(ctx, scope, workspaceRoot) + if err != nil { + return ReindexResult{}, err + } + completedAt := time.Now().UTC() + if err := s.logCatalogEvent( + ctx, + "memory.reindex", + fmt.Sprintf("scope=%s workspace=%s indexed=%d", string(scope.Normalize()), workspaceRoot, indexed), + ); err != nil { + s.warn("memory: record reindex event failed", "error", err) + } + return ReindexResult{ + IndexedFiles: indexed, + Scope: scope.Normalize(), + Workspace: workspaceRoot, + CompletedAt: completedAt, + }, nil +} + +// HealthStats returns derived-catalog stats for the visible scopes. +func (s *Store) HealthStats(ctx context.Context, workspaces []string) (HealthStats, error) { + if ctx == nil { + return HealthStats{}, errors.New("memory: health stats context is required") + } + if s.catalog == nil { + return HealthStats{}, nil + } + + filters := make([]catalogFilter, 0, len(workspaces)+1) + filters = append(filters, catalogFilter{scope: ScopeGlobal}) + seen := map[string]struct{}{} + for _, workspace := range workspaces { + trimmed := strings.TrimSpace(workspace) + if trimmed == "" { + continue + } + if _, exists := seen[trimmed]; exists { + continue + } + seen[trimmed] = struct{}{} + filters = append(filters, catalogFilter{scope: ScopeWorkspace, workspaceRoot: trimmed}) + } + + entryCount, err := s.catalog.entryCount(ctx) + if err != nil { + return HealthStats{}, err + } + if entryCount == 0 { + if _, err := s.reindexScopes(ctx, ScopeGlobal, ""); err != nil { + return HealthStats{}, err + } + for _, filter := range filters { + if filter.scope != ScopeWorkspace { + continue + } + if _, err := s.reindexScopes(ctx, ScopeWorkspace, filter.workspaceRoot); err != nil { + return HealthStats{}, err + } + } } - return content, truncated, nil + entries, err := s.catalog.listEntries(ctx, filters) + if err != nil { + return HealthStats{}, err + } + actual, err := s.collectActualCatalogIDs(filters) + if err != nil { + return HealthStats{}, err + } + + orphaned := 0 + for _, entry := range entries { + if _, exists := actual[entry.ID]; !exists { + orphaned++ + } + } + + lastReindex, err := s.catalog.lastReindex(ctx) + if err != nil { + return HealthStats{}, err + } + return HealthStats{ + IndexedFiles: len(entries), + OrphanedFiles: orphaned, + LastReindex: lastReindex, + }, nil } func (s *Store) dirForScope(scope Scope) (string, error) { @@ -303,39 +518,59 @@ func (s *Store) pathFor(scope Scope, filename string) (string, error) { return filepath.Join(dir, base), nil } -func (s *Store) removeIndexEntry(scope Scope, filename string) error { - dir, err := s.dirForScope(scope) +func (s *Store) syncScope(ctx context.Context, scope Scope) error { + scope = scope.Normalize() + headers, err := s.scan(scope, 0) if err != nil { return err } + if err := s.syncIndex(scope, headers); err != nil { + return err + } + if err := s.syncCatalogScope(ctx, scope, headers); err != nil { + return err + } + return nil +} - indexPath := filepath.Join(dir, indexFilename) - indexContent, err := os.ReadFile(indexPath) +func (s *Store) syncIndex(scope Scope, headers []Header) error { + dir, err := s.dirForScope(scope) if err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil - } - return fmt.Errorf("memory: load index %q: %w", indexPath, err) + return err } - lines := strings.SplitAfter(string(indexContent), "\n") - filtered := make([]string, 0, len(lines)) - removed := false - for _, line := range lines { - if indexLineTargetsFilename(line, filename) { - removed = true - continue + indexPath := filepath.Join(dir, indexFilename) + if len(headers) == 0 { + if err := os.Remove(indexPath); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("memory: remove empty index %q: %w", indexPath, err) } - filtered = append(filtered, line) - } - if !removed { return nil } - if err := fileutil.AtomicWriteFile(indexPath, []byte(strings.Join(filtered, "")), filePerm); err != nil { - return fmt.Errorf("memory: update index %q: %w", indexPath, err) + if err := fileutil.AtomicWriteFile(indexPath, []byte(renderIndex(headers)), filePerm); err != nil { + return fmt.Errorf("memory: write index %q: %w", indexPath, err) } + return nil +} +func (s *Store) syncCatalogScope(ctx context.Context, scope Scope, headers []Header) error { + if s.catalog == nil { + return nil + } + workspaceRoot := "" + if scope.Normalize() == ScopeWorkspace { + workspaceRoot = deriveWorkspaceRoot(s.workspaceDir) + } + docs, err := s.documentsForHeaders(scope, workspaceRoot, headers) + if err != nil { + return err + } + if err := s.catalog.replaceScope(ctx, scope, workspaceRoot, docs); err != nil { + return err + } + if err := s.catalog.setLastReindex(ctx, time.Now().UTC()); err != nil { + return err + } return nil } @@ -354,6 +589,178 @@ type scanCandidate struct { modTime time.Time } +func (s *Store) normalizeScopeAndWorkspace(scope Scope, workspace string) (Scope, string, error) { + normalizedScope := scope.Normalize() + if normalizedScope != "" { + if err := normalizedScope.Validate(); err != nil { + return "", "", wrapValidationError("resolve scope", string(scope), err) + } + } + + workspaceRoot := cleanDirPath(workspace) + if workspaceRoot == "" { + workspaceRoot = deriveWorkspaceRoot(s.workspaceDir) + } + if normalizedScope == ScopeWorkspace && workspaceRoot == "" { + return "", "", wrapValidationError( + "resolve scope", + string(scope), + errors.New("workspace directory is required"), + ) + } + return normalizedScope, workspaceRoot, nil +} + +func (s *Store) ensureCatalogReady(ctx context.Context, scope Scope, workspaceRoot string) error { + if s.catalog == nil { + return nil + } + entryCount, err := s.catalog.entryCount(ctx) + if err != nil { + return err + } + if entryCount > 0 { + return nil + } + _, err = s.reindexScopes(ctx, scope, workspaceRoot) + return err +} + +func (s *Store) reindexScopes(ctx context.Context, scope Scope, workspaceRoot string) (int, error) { + if s.catalog == nil { + return 0, nil + } + + total := 0 + seenWorkspace := strings.TrimSpace(workspaceRoot) + + reindexScope := func(scope Scope, workspaceRoot string) error { + headers, err := s.headersForCatalogScope(scope, workspaceRoot) + if err != nil { + return err + } + docs, err := s.documentsForHeaders(scope, workspaceRoot, headers) + if err != nil { + return err + } + if err := s.catalog.replaceScope(ctx, scope, workspaceRoot, docs); err != nil { + return err + } + total += len(docs) + return nil + } + + switch scope.Normalize() { + case ScopeGlobal: + if err := reindexScope(ScopeGlobal, ""); err != nil { + return 0, err + } + case ScopeWorkspace: + if err := reindexScope(ScopeWorkspace, seenWorkspace); err != nil { + return 0, err + } + default: + if err := reindexScope(ScopeGlobal, ""); err != nil { + return 0, err + } + if seenWorkspace != "" { + if err := reindexScope(ScopeWorkspace, seenWorkspace); err != nil { + return 0, err + } + } + } + + if err := s.catalog.setLastReindex(ctx, time.Now().UTC()); err != nil { + return 0, err + } + return total, nil +} + +func (s *Store) headersForCatalogScope(scope Scope, workspaceRoot string) ([]Header, error) { + target := s + if scope.Normalize() == ScopeWorkspace { + target = s.ForWorkspace(workspaceRoot) + } + return target.scan(scope, 0) +} + +func (s *Store) documentsForHeaders(scope Scope, workspaceRoot string, headers []Header) ([]catalogDocument, error) { + target := s + if scope.Normalize() == ScopeWorkspace { + target = s.ForWorkspace(workspaceRoot) + } + + docs := make([]catalogDocument, 0, len(headers)) + for _, header := range headers { + rawContent, err := target.Read(scope, header.Filename) + if err != nil { + return nil, err + } + doc, err := buildCatalogDocument(scope, workspaceRoot, header, rawContent) + if err != nil { + return nil, err + } + docs = append(docs, doc) + } + return docs, nil +} + +func (s *Store) collectSearchDocuments(scope Scope, workspaceRoot string) ([]catalogDocument, error) { + scopes := []struct { + scope Scope + workspace string + }{{scope: ScopeGlobal}} + if scope.Normalize() == ScopeWorkspace { + scopes = []struct { + scope Scope + workspace string + }{{scope: ScopeWorkspace, workspace: workspaceRoot}} + } else if strings.TrimSpace(workspaceRoot) != "" { + scopes = append(scopes, struct { + scope Scope + workspace string + }{scope: ScopeWorkspace, workspace: workspaceRoot}) + } + + docs := make([]catalogDocument, 0) + for _, item := range scopes { + headers, err := s.headersForCatalogScope(item.scope, item.workspace) + if err != nil { + return nil, err + } + items, err := s.documentsForHeaders(item.scope, item.workspace, headers) + if err != nil { + return nil, err + } + docs = append(docs, items...) + } + return docs, nil +} + +func (s *Store) collectActualCatalogIDs(filters []catalogFilter) (map[string]struct{}, error) { + actual := make(map[string]struct{}) + for _, filter := range filters { + headers, err := s.headersForCatalogScope(filter.scope, filter.workspaceRoot) + if err != nil { + return nil, err + } + for _, header := range headers { + actual[catalogDocID(filter.scope, filter.workspaceRoot, header.Filename)] = struct{}{} + } + } + return actual, nil +} + +func (s *Store) logCatalogEvent(ctx context.Context, eventType string, summary string) error { + if s.catalog == nil { + return nil + } + if ctx == nil { + ctx = context.Background() + } + return s.catalog.logEvent(ctx, eventType, summary) +} + func truncateIndex(content string, maxLines int, maxBytes int) (string, bool) { if content == "" { return "", false @@ -400,11 +807,6 @@ func truncateToUTF8Boundary(value string, maxBytes int) string { return truncated } -func indexLineTargetsFilename(line string, filename string) bool { - target, ok := firstMarkdownLinkTarget(line) - return ok && target == strings.TrimSpace(filename) -} - func firstMarkdownLinkTarget(line string) (string, bool) { start := strings.Index(line, "](") if start < 0 { @@ -432,6 +834,52 @@ func firstMarkdownLinkTarget(line string) (string, bool) { return "", false } +func renderIndex(headers []Header) string { + if len(headers) == 0 { + return "" + } + lines := make([]string, 0, len(headers)) + for _, header := range headers { + lines = append(lines, renderIndexLine(header)) + } + return strings.Join(lines, "\n") + "\n" +} + +func renderIndexLine(header Header) string { + header.Normalize() + if header.Description == "" { + return fmt.Sprintf("- [%s](%s)", header.Name, header.Filename) + } + return fmt.Sprintf("- [%s](%s) - %s", header.Name, header.Filename, header.Description) +} + +func indexMatchesHeaders(content string, headers []Header) bool { + if len(headers) == 0 { + return strings.TrimSpace(content) == "" + } + targets := make(map[string]struct{}, len(headers)) + for _, header := range headers { + targets[header.Filename] = struct{}{} + } + + seen := make(map[string]struct{}, len(headers)) + for line := range strings.SplitSeq(content, "\n") { + target, ok := firstMarkdownLinkTarget(line) + if !ok { + continue + } + if _, exists := targets[target]; !exists { + return false + } + if _, exists := seen[target]; exists { + return false + } + seen[target] = struct{}{} + } + + return len(seen) == len(targets) +} + func shouldSkipFile(name string) bool { return name == indexFilename || strings.HasPrefix(name, ".") } diff --git a/internal/memory/store_test.go b/internal/memory/store_test.go index b26b9f6d2..ed9b5cb34 100644 --- a/internal/memory/store_test.go +++ b/internal/memory/store_test.go @@ -2,6 +2,7 @@ package memory import ( "bytes" + "context" "errors" "fmt" "log/slog" @@ -241,14 +242,14 @@ func TestStoreDeleteRemovesFileAndIndexEntry(t *testing.T) { indexBytes, err := os.ReadFile(filepath.Join(env.store.globalDir, indexFilename)) if err != nil { - t.Fatalf("os.ReadFile(index) error = %v", err) + if !errors.Is(err, os.ErrNotExist) { + t.Fatalf("os.ReadFile(index) error = %v", err) + } + return } if strings.Contains(string(indexBytes), "(user_preferences.md)") { t.Fatalf("index content still references deleted file: %q", string(indexBytes)) } - if !strings.Contains(string(indexBytes), "(other.md)") { - t.Fatalf("index content removed unrelated entry: %q", string(indexBytes)) - } } func TestStoreDeletePreservesLinesThatOnlyMentionFilenameInDescription(t *testing.T) { @@ -284,15 +285,15 @@ func TestStoreDeletePreservesLinesThatOnlyMentionFilenameInDescription(t *testin indexBytes, err := os.ReadFile(filepath.Join(env.store.globalDir, indexFilename)) if err != nil { - t.Fatalf("os.ReadFile(index) error = %v", err) + if !errors.Is(err, os.ErrNotExist) { + t.Fatalf("os.ReadFile(index) error = %v", err) + } + return } got := string(indexBytes) if strings.Contains(got, "- [User Preferences](user_preferences.md)") { t.Fatalf("index content still references deleted file: %q", got) } - if !strings.Contains(got, "- [Related Notes](other.md) - Mirrors notes from (user_preferences.md)") { - t.Fatalf("index content removed unrelated descriptive line: %q", got) - } } func TestStoreDeleteRemovesIndexEntryForFilenameWithParentheses(t *testing.T) { @@ -329,15 +330,15 @@ func TestStoreDeleteRemovesIndexEntryForFilenameWithParentheses(t *testing.T) { indexBytes, err := os.ReadFile(filepath.Join(env.store.globalDir, indexFilename)) if err != nil { - t.Fatalf("os.ReadFile(index) error = %v", err) + if !errors.Is(err, os.ErrNotExist) { + t.Fatalf("os.ReadFile(index) error = %v", err) + } + return } got := string(indexBytes) if strings.Contains(got, "- [User Preferences](user(preferences).md)") { t.Fatalf("index content still references deleted file: %q", got) } - if !strings.Contains(got, "- [Other](other.md) - Another note") { - t.Fatalf("index content removed unrelated line: %q", got) - } } func TestStoreDeleteMissingFile(t *testing.T) { @@ -573,6 +574,7 @@ func TestStoreLoadIndex(t *testing.T) { ); err != nil { t.Fatalf("write index: %v", err) } + writeIndexFixtures(t, env.store.workspaceDir, want) got, truncated, err := env.store.LoadIndex(ScopeWorkspace) if err != nil { @@ -599,6 +601,7 @@ func TestStoreLoadIndex(t *testing.T) { if err := os.WriteFile(filepath.Join(env.store.globalDir, indexFilename), []byte(index), filePerm); err != nil { t.Fatalf("write index: %v", err) } + writeIndexFixtures(t, env.store.globalDir, index) got, truncated, err := env.store.LoadIndex(ScopeGlobal) if err != nil { @@ -622,7 +625,8 @@ func TestStoreLoadIndex(t *testing.T) { env := newTestStoreEnv(t) - line := strings.Repeat("é", defaultIndexBytes) + "\n" + line := "- [Oversized](oversized.md) - " + strings.Repeat("é", defaultIndexBytes) + "\n" + writeIndexFixtures(t, env.store.globalDir, line) if err := os.WriteFile(filepath.Join(env.store.globalDir, indexFilename), []byte(line), filePerm); err != nil { t.Fatalf("write index: %v", err) } @@ -660,6 +664,131 @@ func TestStoreLoadIndex(t *testing.T) { }) } +func TestStoreLoadIndexSynthesizesWhenIndexIsMissingOrStale(t *testing.T) { + t.Parallel() + + t.Run("missing index synthesizes from files", func(t *testing.T) { + t.Parallel() + + env := newTestStoreEnv(t) + if err := env.store.Write(ScopeGlobal, "prefs.md", mustMemoryContent(t, testMemoryMeta{ + Name: "Prefs", + Description: "User preferences", + Type: MemoryTypeUser, + }, "body\n")); err != nil { + t.Fatalf("Store.Write() error = %v", err) + } + if err := os.Remove(filepath.Join(env.store.globalDir, indexFilename)); err != nil { + t.Fatalf("remove index: %v", err) + } + + got, truncated, err := env.store.LoadIndex(ScopeGlobal) + if err != nil { + t.Fatalf("Store.LoadIndex() error = %v", err) + } + if truncated { + t.Fatal("Store.LoadIndex() truncated = true, want false") + } + if !strings.Contains(got, "- [Prefs](prefs.md) - User preferences") { + t.Fatalf("Store.LoadIndex() = %q, want synthesized entry", got) + } + }) + + t.Run("stale index synthesizes and ignores missing targets", func(t *testing.T) { + t.Parallel() + + env := newTestStoreEnv(t) + if err := env.store.Write(ScopeWorkspace, "project.md", mustMemoryContent(t, testMemoryMeta{ + Name: "Project", + Description: "Current plan", + Type: MemoryTypeProject, + }, "body\n")); err != nil { + t.Fatalf("Store.Write() error = %v", err) + } + stale := strings.Join([]string{ + "- [Old](missing.md) - stale", + "- [Project](project.md) - Current plan", + "", + }, "\n") + if err := os.WriteFile( + filepath.Join(env.store.workspaceDir, indexFilename), + []byte(stale), + filePerm, + ); err != nil { + t.Fatalf("write stale index: %v", err) + } + + got, truncated, err := env.store.LoadIndex(ScopeWorkspace) + if err != nil { + t.Fatalf("Store.LoadIndex() error = %v", err) + } + if truncated { + t.Fatal("Store.LoadIndex() truncated = true, want false") + } + if strings.Contains(got, "missing.md") || !strings.Contains(got, "project.md") { + t.Fatalf("Store.LoadIndex() = %q, want synthesized workspace-only entry", got) + } + }) +} + +func TestStoreSearchAndReindex(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + workspaceRoot := filepath.Join(baseDir, "workspace") + catalogPath := filepath.Join(baseDir, "agh.db") + store := NewStore( + filepath.Join(baseDir, "global"), + WithCatalogDatabasePath(catalogPath), + ).ForWorkspace(workspaceRoot) + if err := store.EnsureDirs(); err != nil { + t.Fatalf("Store.EnsureDirs() error = %v", err) + } + + if err := store.Write(ScopeGlobal, "prefs.md", mustMemoryContent(t, testMemoryMeta{ + Name: "Code Style", + Description: "Keep prompts concise", + Type: MemoryTypeUser, + }, "User prefers concise answers and explicit tradeoffs.\n")); err != nil { + t.Fatalf("Store.Write(global) error = %v", err) + } + if err := store.Write(ScopeWorkspace, "auth.md", mustMemoryContent(t, testMemoryMeta{ + Name: "Auth Rewrite", + Description: "Workspace auth migration", + Type: MemoryTypeProject, + }, "The workspace is migrating auth from JWT to sessions.\n")); err != nil { + t.Fatalf("Store.Write(workspace) error = %v", err) + } + + ctx := context.Background() + results, err := store.Search(ctx, "auth sessions concise", SearchOptions{Workspace: workspaceRoot, Limit: 5}) + if err != nil { + t.Fatalf("Store.Search() error = %v", err) + } + if len(results) != 2 { + t.Fatalf("len(results) = %d, want 2; results=%#v", len(results), results) + } + if results[0].Scope != ScopeWorkspace { + t.Fatalf("results[0].Scope = %q, want workspace", results[0].Scope) + } + + reindex, err := store.Reindex(ctx, ReindexOptions{Workspace: workspaceRoot}) + if err != nil { + t.Fatalf("Store.Reindex() error = %v", err) + } + if reindex.IndexedFiles != 2 { + t.Fatalf("Reindex.IndexedFiles = %d, want 2", reindex.IndexedFiles) + } + + stats, err := store.HealthStats(ctx, []string{workspaceRoot}) + if err != nil { + t.Fatalf("Store.HealthStats() error = %v", err) + } + if stats.IndexedFiles != 2 || stats.OrphanedFiles != 0 || stats.LastReindex == nil { + t.Fatalf("HealthStats() = %#v, want indexed=2 orphaned=0 lastReindex set", stats) + } +} + func TestStoreEnsureDirs(t *testing.T) { t.Parallel() @@ -686,6 +815,15 @@ func TestStoreEnsureDirs(t *testing.T) { } } +func TestWorkspaceMemoryDirRoundTripsToWorkspaceRoot(t *testing.T) { + t.Parallel() + + workspaceRoot := filepath.Join(t.TempDir(), "workspace") + if got := deriveWorkspaceRoot(workspaceMemoryDir(workspaceRoot)); got != workspaceRoot { + t.Fatalf("deriveWorkspaceRoot(workspaceMemoryDir(%q)) = %q, want %q", workspaceRoot, got, workspaceRoot) + } +} + func TestStoreRejectsInvalidInputs(t *testing.T) { t.Parallel() @@ -954,3 +1092,22 @@ func mustMemoryContent(t *testing.T, meta testMemoryMeta, body string) []byte { return []byte("---\n" + strings.TrimRight(string(metaBytes), "\n") + "\n---\n" + body) } + +func writeIndexFixtures(t *testing.T, dir string, indexContent string) { + t.Helper() + + for line := range strings.SplitSeq(indexContent, "\n") { + filename, ok := firstMarkdownLinkTarget(line) + if !ok { + continue + } + doc := mustMemoryContent(t, testMemoryMeta{ + Name: "Fixture", + Description: "desc", + Type: MemoryTypeUser, + }, "body\n") + if err := os.WriteFile(filepath.Join(dir, filename), doc, filePerm); err != nil { + t.Fatalf("write fixture %q: %v", filename, err) + } + } +} diff --git a/internal/memory/types.go b/internal/memory/types.go index 9a8d4aba6..a43bd651f 100644 --- a/internal/memory/types.go +++ b/internal/memory/types.go @@ -2,6 +2,7 @@ package memory import ( + "context" "fmt" "strings" "time" @@ -42,6 +43,58 @@ type Header struct { AgentName string `json:"agent_name,omitempty" yaml:"agent_name,omitempty"` } +// SearchOptions controls catalog-backed or fallback memory search behavior. +type SearchOptions struct { + Scope Scope + Workspace string + Limit int +} + +// SearchResult is one ranked memory search hit. +type SearchResult struct { + Filename string `json:"filename"` + Scope Scope `json:"scope"` + Workspace string `json:"workspace,omitempty"` + Type Type `json:"type"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Score float64 `json:"score"` + Snippet string `json:"snippet,omitempty"` + ModTime time.Time `json:"mod_time"` +} + +// ReindexOptions controls which scopes are rebuilt into the derived catalog. +type ReindexOptions struct { + Scope Scope + Workspace string +} + +// ReindexResult reports the outcome of a catalog rebuild. +type ReindexResult struct { + IndexedFiles int `json:"indexed_files"` + Scope Scope `json:"scope,omitempty"` + Workspace string `json:"workspace,omitempty"` + CompletedAt time.Time `json:"completed_at"` +} + +// HealthStats summarizes derived-catalog state for operator surfaces. +type HealthStats struct { + IndexedFiles int `json:"indexed_files"` + OrphanedFiles int `json:"orphaned_files"` + LastReindex *time.Time `json:"last_reindex"` +} + +// Backend captures the memory backend surface used by daemon, API, and CLI layers. +type Backend interface { + List(scope Scope) ([]Header, error) + Read(scope Scope, filename string) ([]byte, error) + Write(scope Scope, filename string, content []byte) error + Delete(scope Scope, filename string) error + Search(ctx context.Context, query string, opts SearchOptions) ([]SearchResult, error) + Reindex(ctx context.Context, opts ReindexOptions) (ReindexResult, error) + LoadPromptIndex(scope Scope) (content string, truncated bool, err error) +} + // Normalize returns the normalized representation of the memory type. func (t Type) Normalize() Type { return Type(strings.ToLower(strings.TrimSpace(string(t)))) diff --git a/internal/session/interfaces.go b/internal/session/interfaces.go index 9cf71ce1f..a49f3e743 100644 --- a/internal/session/interfaces.go +++ b/internal/session/interfaces.go @@ -40,6 +40,9 @@ type NetworkPeerLifecycle interface { // TurnEndNotifier is invoked once after a prompt turn finishes dispatching. type TurnEndNotifier func(sessionID string) +// PromptInputAugmenter can add bounded daemon-local context before prompt dispatch. +type PromptInputAugmenter func(ctx context.Context, session *Session, message string) (string, error) + func normalizeTurnSource(source TurnSource) TurnSource { switch TurnSource(strings.TrimSpace(string(source))) { case "", TurnSourceUser: diff --git a/internal/session/manager.go b/internal/session/manager.go index 4baab76fd..fc5b471f8 100644 --- a/internal/session/manager.go +++ b/internal/session/manager.go @@ -66,6 +66,7 @@ type Manager struct { notifier Notifier networkPeers NetworkPeerLifecycle turnEndNotifier TurnEndNotifier + inputAugmenter PromptInputAugmenter hooks HookSet environment *environment.Registry agentResolver AgentResolver @@ -176,6 +177,13 @@ func WithWorkspaceResolver(resolver workspacepkg.RuntimeResolver) Option { } } +// WithPromptInputAugmenter injects a bounded pre-dispatch message augmenter. +func WithPromptInputAugmenter(augmenter PromptInputAugmenter) Option { + return func(manager *Manager) { + manager.inputAugmenter = augmenter + } +} + // WithNow overrides the manager clock, mainly for tests. func WithNow(now func() time.Time) Option { return func(manager *Manager) { diff --git a/internal/session/manager_prompt.go b/internal/session/manager_prompt.go index 650f11971..4bb48a477 100644 --- a/internal/session/manager_prompt.go +++ b/internal/session/manager_prompt.go @@ -92,9 +92,18 @@ func (m *Manager) PromptWithOpts(ctx context.Context, id string, opts PromptOpts return nil, err } + dispatchMessage := message + if m.inputAugmenter != nil { + augmented, augmentErr := m.inputAugmenter(ctx, session, message) + if augmentErr != nil { + m.sessionLogger(session).Warn("session: prompt input augmentation failed", "error", augmentErr) + } else if strings.TrimSpace(augmented) != "" { + dispatchMessage = augmented + } + } source, err := m.driver.Prompt(ctx, proc, acp.PromptRequest{ TurnID: turnID, - Message: message, + Message: dispatchMessage, Meta: req.meta, }) if err != nil { diff --git a/internal/session/manager_test.go b/internal/session/manager_test.go index 19e553efb..00998e371 100644 --- a/internal/session/manager_test.go +++ b/internal/session/manager_test.go @@ -861,6 +861,45 @@ func TestPromptPersistsUserMessageBeforeDriverPrompt(t *testing.T) { } } +func TestPromptAugmenterPreservesStoredUserMessageAndAugmentsDriverDispatch(t *testing.T) { + t.Parallel() + + h := newHarness(t, WithPromptInputAugmenter(func( + _ context.Context, + _ *Session, + message string, + ) (string, error) { + return "MEMORY RECALL\n\n" + message, nil + })) + session := createSession(t, h) + t.Cleanup(func() { + _ = h.manager.Stop(testutil.Context(t), session.ID) + }) + + eventsCh, err := h.manager.Prompt(testutil.Context(t), session.ID, "remember me") + if err != nil { + t.Fatalf("Prompt() error = %v", err) + } + _ = collectEvents(t, eventsCh) + + stored, err := session.recorderHandle().Query(testutil.Context(t), store.EventQuery{}) + if err != nil { + t.Fatalf("Query() error = %v", err) + } + if len(stored) == 0 { + t.Fatal("stored events = 0, want at least one event") + } + if got := h.driver.promptCalls[0].Message; got != "MEMORY RECALL\n\nremember me" { + t.Fatalf("driver prompt message = %q, want augmented content", got) + } + if !strings.Contains(stored[0].Content, `"text":"remember me"`) { + t.Fatalf("stored user_message content = %s, want original message", stored[0].Content) + } + if strings.Contains(stored[0].Content, "MEMORY RECALL") { + t.Fatalf("stored user_message content = %s, want no augmentation block", stored[0].Content) + } +} + func TestPromptWithOptsTracksTurnSourceAndClearsAfterPrompt(t *testing.T) { t.Parallel() diff --git a/internal/store/globaldb/global_db.go b/internal/store/globaldb/global_db.go index 3fa8c0a1c..89e6e4b2e 100644 --- a/internal/store/globaldb/global_db.go +++ b/internal/store/globaldb/global_db.go @@ -59,6 +59,15 @@ var globalSchemaStatements = append([]string{ `CREATE INDEX IF NOT EXISTS idx_summaries_session ON event_summaries(session_id);`, `CREATE INDEX IF NOT EXISTS idx_summaries_type ON event_summaries(type);`, `CREATE INDEX IF NOT EXISTS idx_summaries_timestamp ON event_summaries(timestamp);`, + `CREATE TABLE IF NOT EXISTS memory_operation_log ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + agent_name TEXT NOT NULL DEFAULT 'daemon', + summary TEXT NOT NULL DEFAULT '', + timestamp TEXT NOT NULL + );`, + `CREATE INDEX IF NOT EXISTS idx_memory_operation_log_type ON memory_operation_log(type);`, + `CREATE INDEX IF NOT EXISTS idx_memory_operation_log_timestamp ON memory_operation_log(timestamp);`, `CREATE TABLE IF NOT EXISTS token_stats ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL REFERENCES sessions(id), diff --git a/internal/store/globaldb/global_db_observe.go b/internal/store/globaldb/global_db_observe.go index 6997f2992..338799d97 100644 --- a/internal/store/globaldb/global_db_observe.go +++ b/internal/store/globaldb/global_db_observe.go @@ -52,23 +52,36 @@ func (g *GlobalDB) ListEventSummaries( return nil, err } - baseQuery := `SELECT rowid, id, session_id, type, agent_name, summary, timestamp FROM event_summaries` - where, args := store.BuildClauses( + eventQuery := `SELECT 0 AS source_rank, rowid AS source_rowid, id, session_id, type, agent_name, summary, timestamp FROM event_summaries` + eventWhere, args := store.BuildClauses( store.StringClause("session_id", query.SessionID), store.StringClause("agent_name", query.AgentName), store.StringClause("type", query.Type), store.TimeClause("timestamp", ">=", query.Since), ) - baseQuery = store.AppendWhere(baseQuery, where) + eventQuery = store.AppendWhere(eventQuery, eventWhere) + + combinedQuery := eventQuery + if strings.TrimSpace(query.SessionID) == "" { + memoryQuery := `SELECT 1 AS source_rank, rowid AS source_rowid, id, '' AS session_id, type, agent_name, summary, timestamp FROM memory_operation_log` + memoryWhere, memoryArgs := store.BuildClauses( + store.StringClause("agent_name", query.AgentName), + store.StringClause("type", query.Type), + store.TimeClause("timestamp", ">=", query.Since), + ) + memoryQuery = store.AppendWhere(memoryQuery, memoryWhere) + combinedQuery += ` UNION ALL ` + memoryQuery + args = append(args, memoryArgs...) + } - sqlQuery := baseQuery + sqlQuery := `SELECT source_rowid, id, session_id, type, agent_name, summary, timestamp FROM (` + combinedQuery + `)` if query.Limit > 0 { - sqlQuery = `SELECT rowid, id, session_id, type, agent_name, summary, timestamp - FROM (` + baseQuery + ` ORDER BY timestamp DESC LIMIT ?) AS recent_summaries - ORDER BY timestamp ASC, rowid ASC` + sqlQuery = `SELECT source_rowid, id, session_id, type, agent_name, summary, timestamp + FROM (` + combinedQuery + ` ORDER BY timestamp DESC, source_rank DESC, source_rowid DESC LIMIT ?) AS recent_summaries + ORDER BY timestamp ASC, source_rank ASC, source_rowid ASC` args = append(args, query.Limit) } else { - sqlQuery += " ORDER BY timestamp ASC, rowid ASC" + sqlQuery += " ORDER BY timestamp ASC, source_rank ASC, source_rowid ASC" } rows, err := g.db.QueryContext(ctx, sqlQuery, args...) diff --git a/internal/store/globaldb/global_db_test.go b/internal/store/globaldb/global_db_test.go index 1eff9748e..eb4beec69 100644 --- a/internal/store/globaldb/global_db_test.go +++ b/internal/store/globaldb/global_db_test.go @@ -67,6 +67,7 @@ func TestOpenGlobalDBCreatesSchemaAndEnablesWAL(t *testing.T) { "workspaces", "sessions", "event_summaries", + "memory_operation_log", "token_stats", "permission_log", "extensions", @@ -1273,6 +1274,59 @@ func TestGlobalDBWriteEventSummary(t *testing.T) { } } +func TestGlobalDBListEventSummariesIncludesMemoryOperations(t *testing.T) { + t.Parallel() + + globalDB := openTestGlobalDB(t) + registerSessionForGlobalTests(t, globalDB, "sess-summary") + + if err := globalDB.WriteEventSummary(testutil.Context(t), EventSummary{ + SessionID: "sess-summary", + Type: "agent_message", + AgentName: "coder", + Summary: "assistant replied", + Timestamp: time.Date(2026, 4, 3, 14, 0, 0, 0, time.UTC), + }); err != nil { + t.Fatalf("WriteEventSummary() error = %v", err) + } + if _, err := globalDB.db.ExecContext( + testutil.Context(t), + `INSERT INTO memory_operation_log (id, type, agent_name, summary, timestamp) VALUES (?, ?, ?, ?, ?)`, + "mem-1", + "memory.write", + "daemon", + `scope=global filename=prefs.md`, + formatTimestamp(time.Date(2026, 4, 3, 14, 1, 0, 0, time.UTC)), + ); err != nil { + t.Fatalf("insert memory operation log error = %v", err) + } + + summaries, err := globalDB.ListEventSummaries(testutil.Context(t), EventSummaryQuery{}) + if err != nil { + t.Fatalf("ListEventSummaries() error = %v", err) + } + if got, want := len(summaries), 2; got != want { + t.Fatalf("len(summaries) = %d, want %d", got, want) + } + if got, want := summaries[1].Type, "memory.write"; got != want { + t.Fatalf("summaries[1].Type = %q, want %q", got, want) + } + if got := summaries[1].SessionID; got != "" { + t.Fatalf("summaries[1].SessionID = %q, want empty for memory operation", got) + } + + sessionOnly, err := globalDB.ListEventSummaries(testutil.Context(t), EventSummaryQuery{SessionID: "sess-summary"}) + if err != nil { + t.Fatalf("ListEventSummaries(session filter) error = %v", err) + } + if got, want := len(sessionOnly), 1; got != want { + t.Fatalf("len(sessionOnly) = %d, want %d", got, want) + } + if got, want := sessionOnly[0].Type, "agent_message"; got != want { + t.Fatalf("sessionOnly[0].Type = %q, want %q", got, want) + } +} + func TestGlobalDBUpdateTokenStatsAggregation(t *testing.T) { t.Parallel() @@ -1760,7 +1814,16 @@ func TestGlobalDBRecoversFromCorruption(t *testing.T) { } }) - assertTablesPresent(t, globalDB.db, "workspaces", "sessions", "event_summaries", "token_stats", "permission_log") + assertTablesPresent( + t, + globalDB.db, + "workspaces", + "sessions", + "event_summaries", + "memory_operation_log", + "token_stats", + "permission_log", + ) matches, err := filepath.Glob(path + ".corrupt.*") if err != nil { diff --git a/internal/testutil/acpmock/testdata/memory_recall_fixture.json b/internal/testutil/acpmock/testdata/memory_recall_fixture.json new file mode 100644 index 000000000..49961ed26 --- /dev/null +++ b/internal/testutil/acpmock/testdata/memory_recall_fixture.json @@ -0,0 +1,26 @@ +{ + "version": 2, + "agents": [ + { + "name": "memory-recall-agent", + "provider": "claude", + "permissions": "approve-all", + "prompt": "You acknowledge recalled durable memory in daemon E2E tests.", + "turns": [ + { + "name": "remember-me", + "match": { + "turn_source": "user", + "occurrence": 1 + }, + "steps": [ + { + "kind": "assistant", + "text": "qa-memory acknowledged" + } + ] + } + ] + } + ] +} diff --git a/internal/testutil/e2e/runtime_harness.go b/internal/testutil/e2e/runtime_harness.go index 53f4b39a8..f29001c7d 100644 --- a/internal/testutil/e2e/runtime_harness.go +++ b/internal/testutil/e2e/runtime_harness.go @@ -822,10 +822,18 @@ func (h *RuntimeHarness) CaptureBrowserNetworkJSON(value any) error { // Run executes one CLI command against the isolated daemon runtime. func (c *CLIClient) Run(ctx context.Context, args ...string) (string, string, error) { + return c.RunInDir(ctx, "", args...) +} + +// RunInDir executes one CLI command against the isolated daemon runtime using the provided working directory. +func (c *CLIClient) RunInDir(ctx context.Context, workdir string, args ...string) (string, string, error) { // #nosec G204 -- test helper intentionally shells out to the current agh test binary. cmd := execabs.CommandContext(ctx, c.binaryPath, args...) cmd.Env = append([]string(nil), c.env...) - cmd.Dir = c.workdir + cmd.Dir = strings.TrimSpace(workdir) + if cmd.Dir == "" { + cmd.Dir = c.workdir + } var stdout bytes.Buffer var stderr bytes.Buffer @@ -838,7 +846,12 @@ func (c *CLIClient) Run(ctx context.Context, args ...string) (string, string, er // RunJSON executes one CLI command and decodes its JSON stdout. func (c *CLIClient) RunJSON(ctx context.Context, dest any, args ...string) error { - stdout, stderr, err := c.Run(ctx, args...) + return c.RunJSONInDir(ctx, "", dest, args...) +} + +// RunJSONInDir executes one CLI command in the provided working directory and decodes its JSON stdout. +func (c *CLIClient) RunJSONInDir(ctx context.Context, workdir string, dest any, args ...string) error { + stdout, stderr, err := c.RunInDir(ctx, workdir, args...) if err != nil { return fmt.Errorf("run CLI %q: %w; stderr=%s", strings.Join(args, " "), err, strings.TrimSpace(stderr)) } diff --git a/openapi/agh.json b/openapi/agh.json index 9d685d561..dd8441011 100644 --- a/openapi/agh.json +++ b/openapi/agh.json @@ -10979,25 +10979,59 @@ }, "memory": { "properties": { + "dream_agent": { + "type": "string" + }, + "dream_check_interval": { + "type": "string" + }, "dream_enabled": { "type": "boolean" }, + "dream_min_hours": { + "format": "double", + "type": "number" + }, + "dream_min_sessions": { + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "global_dir": { + "type": "string" + }, "global_files": { "type": "integer" }, + "indexed_files": { + "type": "integer" + }, "last_consolidation": { "format": "date-time", "nullable": true, "type": "string" }, + "last_reindex": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "orphaned_files": { + "type": "integer" + }, "workspace_files": { "type": "integer" } }, "required": [ "dream_enabled", + "enabled", "global_files", + "indexed_files", "last_consolidation", + "last_reindex", + "orphaned_files", "workspace_files" ], "type": "object" diff --git a/packages/site/content/runtime/core/hooks/declaration.mdx b/packages/site/content/runtime/core/hooks/declaration.mdx index 6673b91be..15d2fa200 100644 --- a/packages/site/content/runtime/core/hooks/declaration.mdx +++ b/packages/site/content/runtime/core/hooks/declaration.mdx @@ -30,7 +30,7 @@ agent definitions, skills, and extension manifests into one deterministic pipeli | Field | Required | Meaning | | --------------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------- | | `name` | yes | Stable hook name used in catalog and run history. | -| `event` | yes | One event from the [event catalog](/runtime/core/hooks/event-catalog). | +| `event` | yes | One event from the [event catalog](/runtime/core/hooks/event-catalog). | | `mode` | no | `async` by default; set `sync` only for sync-eligible events. | | `required` | no | If `true`, the hook must be `sync`; execution failure aborts the dispatch. | | `priority` | no | Higher values run earlier inside the same source group. | diff --git a/web/src/generated/agh-openapi.d.ts b/web/src/generated/agh-openapi.d.ts index e65a8aa46..31a712f0e 100644 --- a/web/src/generated/agh-openapi.d.ts +++ b/web/src/generated/agh-openapi.d.ts @@ -6931,10 +6931,21 @@ export interface operations { version: string; }; memory: { + dream_agent?: string; + dream_check_interval?: string; dream_enabled: boolean; + /** Format: double */ + dream_min_hours?: number; + dream_min_sessions?: number; + enabled: boolean; + global_dir?: string; global_files: number; + indexed_files: number; /** Format: date-time */ last_consolidation: string | null; + /** Format: date-time */ + last_reindex: string | null; + orphaned_files: number; workspace_files: number; }; }; From 2f28b2dc0e5c7db7d8f330a6cb11ab4bc9f25f0d Mon Sep 17 00:00:00 2001 From: Pedro Nauck Date: Fri, 17 Apr 2026 21:38:00 -0300 Subject: [PATCH 2/8] fix: resolve mem-improvs review batch --- .../mem-improvs/reviews-001/issue_001.md | 24 + .../mem-improvs/reviews-001/issue_002.md | 73 +++ .../mem-improvs/reviews-001/issue_003.md | 31 ++ .../mem-improvs/reviews-001/issue_004.md | 27 + .../mem-improvs/reviews-001/issue_005.md | 94 ++++ .../mem-improvs/reviews-001/issue_006.md | 62 +++ .../mem-improvs/reviews-001/issue_007.md | 326 +++++++++++ .../mem-improvs/reviews-001/issue_008.md | 62 +++ .../mem-improvs/reviews-001/issue_009.md | 86 +++ .../mem-improvs/reviews-001/issue_010.md | 514 ++++++++++++++++++ .../mem-improvs/reviews-001/issue_011.md | 54 ++ .../mem-improvs/reviews-001/issue_012.md | 30 + .../mem-improvs/reviews-001/issue_013.md | 101 ++++ internal/cli/memory.go | 6 +- internal/cli/memory_test.go | 2 +- .../daemon_memory_e2e_integration_test.go | 403 +++++++------- internal/daemon/daemon_test.go | 30 +- internal/memory/catalog.go | 10 +- internal/memory/store.go | 84 ++- internal/memory/store_test.go | 179 +++++- internal/session/manager_prompt.go | 3 + internal/session/manager_test.go | 23 + internal/store/globaldb/global_db_test.go | 24 + internal/testutil/e2e/runtime_harness.go | 9 +- .../e2e/runtime_harness_lifecycle_test.go | 23 + 25 files changed, 2011 insertions(+), 269 deletions(-) create mode 100644 .compozy/tasks/mem-improvs/reviews-001/issue_001.md create mode 100644 .compozy/tasks/mem-improvs/reviews-001/issue_002.md create mode 100644 .compozy/tasks/mem-improvs/reviews-001/issue_003.md create mode 100644 .compozy/tasks/mem-improvs/reviews-001/issue_004.md create mode 100644 .compozy/tasks/mem-improvs/reviews-001/issue_005.md create mode 100644 .compozy/tasks/mem-improvs/reviews-001/issue_006.md create mode 100644 .compozy/tasks/mem-improvs/reviews-001/issue_007.md create mode 100644 .compozy/tasks/mem-improvs/reviews-001/issue_008.md create mode 100644 .compozy/tasks/mem-improvs/reviews-001/issue_009.md create mode 100644 .compozy/tasks/mem-improvs/reviews-001/issue_010.md create mode 100644 .compozy/tasks/mem-improvs/reviews-001/issue_011.md create mode 100644 .compozy/tasks/mem-improvs/reviews-001/issue_012.md create mode 100644 .compozy/tasks/mem-improvs/reviews-001/issue_013.md diff --git a/.compozy/tasks/mem-improvs/reviews-001/issue_001.md b/.compozy/tasks/mem-improvs/reviews-001/issue_001.md new file mode 100644 index 000000000..1399e1ad5 --- /dev/null +++ b/.compozy/tasks/mem-improvs/reviews-001/issue_001.md @@ -0,0 +1,24 @@ +--- +status: resolved +file: go.mod +line: 17 +severity: nitpick +author: coderabbitai[bot] +provider_ref: review:4132976935,nitpick_hash:c032c612cf2e +review_hash: c032c612cf2e +source_review_id: "4132976935" +source_review_submitted_at: "2026-04-18T00:19:15Z" +--- + +# Issue 001: Confirm whether HTTPS proxy functionality in the unreleased gorilla/websocket commit is required. +## Review Comment + +The pinned pseudo-version v1.5.4-0.20250319132907-e064f32e3674 points to an unreleased commit (23 commits ahead of v1.5.3, the latest stable release). This commit implements HTTPS proxy functionality. If this feature is not required by this PR, prefer the latest tagged release v1.5.3 to maintain better provenance tracking and easier vulnerability management. + +## Triage + +- Decision: `invalid` +- Notes: + - The `gorilla/websocket` pseudo-version was already present in the repo before this memory batch; this PR only surfaced it as a direct requirement in `go.mod`. + - The current scoped changes do not modify Daytona websocket behavior, and the codebase gives no evidence that `v1.5.3` is a safe drop-in replacement for the existing sidecar transport. + - Changing this dependency here would be unrelated dependency churn with unclear behavioral impact, so no code change is warranted in this batch. diff --git a/.compozy/tasks/mem-improvs/reviews-001/issue_002.md b/.compozy/tasks/mem-improvs/reviews-001/issue_002.md new file mode 100644 index 000000000..e3856314d --- /dev/null +++ b/.compozy/tasks/mem-improvs/reviews-001/issue_002.md @@ -0,0 +1,73 @@ +--- +status: resolved +file: internal/cli/memory.go +line: 177 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM5745as,comment:PRRC_kwDOR5y4QM65BAP5 +--- + +# Issue 002: _⚠️ Potential issue_ | _🟠 Major_ +## Review Comment + +_⚠️ Potential issue_ | _🟠 Major_ + +**Accept multi-word queries here.** + +`cobra.ExactArgs(1)` plus `query := args[0]` makes the documented examples fail: `agh memory search auth rewrite` is parsed as 2 args and never reaches the search call. This blocks the common unquoted CLI case. + + +
+Suggested fix + +```diff +- Use: "search ", ++ Use: "search ", + Short: "Search durable memory", +@@ +- Args: cobra.ExactArgs(1), ++ Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { +@@ +- query := strings.TrimSpace(args[0]) ++ query := strings.TrimSpace(strings.Join(args, " ")) + if query == "" { + return errors.New("memory query is required") + } +``` +
+ + +Also applies to: 184-185 + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/cli/memory.go` around lines 170 - 177, The CLI currently enforces +ExactArgs(1) and uses query := args[0], which breaks multi-word queries like +"agh memory search auth rewrite"; change the command's Args from +cobra.ExactArgs(1) to cobra.MinimumNArgs(1), replace any direct use of args[0] +with query := strings.Join(args, " "), and add an import for "strings" if +missing; apply the same change to the other memory command mentioned (the block +around the lines referenced 184-185) so both accept and join multi-word queries. +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Notes: + - `newMemorySearchCommand` currently uses `cobra.ExactArgs(1)` and `args[0]`, so unquoted multi-word searches like `agh memory search auth rewrite` fail argument validation before reaching the client. + - The fix is straightforward and localized: accept `cobra.MinimumNArgs(1)`, join `args` into one query string, and add CLI coverage for the common unquoted form. + +## Resolution + +- Updated `agh memory search` to accept `search ` with `cobra.MinimumNArgs(1)` and `strings.Join(args, " ")`. +- Added CLI regression coverage for the unquoted multi-argument invocation. diff --git a/.compozy/tasks/mem-improvs/reviews-001/issue_003.md b/.compozy/tasks/mem-improvs/reviews-001/issue_003.md new file mode 100644 index 000000000..657bd15f0 --- /dev/null +++ b/.compozy/tasks/mem-improvs/reviews-001/issue_003.md @@ -0,0 +1,31 @@ +--- +status: resolved +file: internal/daemon/daemon_memory_e2e_integration_test.go +line: 21 +severity: nitpick +author: coderabbitai[bot] +provider_ref: review:4132976935,nitpick_hash:a216b942a23f +review_hash: a216b942a23f +source_review_id: "4132976935" +source_review_submitted_at: "2026-04-18T00:19:15Z" +--- + +# Issue 003: Please wrap these E2E scenarios in t.Run("Should...") subtests. +## Review Comment + +Both tests pack several independent contract checks into one large flow, which makes failures harder to localize and drifts from the repository’s test shape. Splitting the main assertions into `t.Run("Should ...")` blocks would make regressions much easier to pinpoint. + +As per coding guidelines, `**/*_test.go`: MUST use t.Run("Should...") pattern for ALL test cases`. + +Also applies to: 225-225 + +## Triage + +- Decision: `valid` +- Notes: + - Both E2E tests currently bundle several independent contract checks into single large flows, which makes failures harder to localize and does not match the repository’s `t.Run("Should ...")` style. + - I will keep the scenario setup intact and split the assertion groups into explicit `Should ...` subtests so regressions point at the failing contract directly. + +## Resolution + +- Split both daemon memory E2E scenarios into focused `t.Run("Should ...")` subtests around search parity, reindex/health, stored message integrity, prompt recall injection, and stale index preservation. diff --git a/.compozy/tasks/mem-improvs/reviews-001/issue_004.md b/.compozy/tasks/mem-improvs/reviews-001/issue_004.md new file mode 100644 index 000000000..6924be27d --- /dev/null +++ b/.compozy/tasks/mem-improvs/reviews-001/issue_004.md @@ -0,0 +1,27 @@ +--- +status: resolved +file: internal/daemon/daemon_test.go +line: 3146 +severity: nitpick +author: coderabbitai[bot] +provider_ref: review:4132976935,nitpick_hash:661e281738f2 +review_hash: 661e281738f2 +source_review_id: "4132976935" +source_review_submitted_at: "2026-04-18T00:19:15Z" +--- + +# Issue 004: Extract the memory fixture serializer instead of duplicating raw frontmatter. +## Review Comment + +This helper now embeds the same document shape already serialized in `internal/daemon/daemon_memory_e2e_integration_test.go:398-408`. Reusing that formatter, or extracting a shared helper, will keep both test suites aligned if frontmatter fields or newline handling change. + +## Triage + +- Decision: `valid` +- Notes: + - `writeDaemonMemoryIndex` duplicates the same memory document serialization shape already provided by `memoryDocument(...)` in the E2E test file. + - Reusing the shared formatter inside package `daemon` removes format drift risk for frontmatter/newline handling without changing test intent. + +## Resolution + +- Extracted the shared `memoryDocument(...)` serializer into the non-tagged daemon test file set and reused it from `writeDaemonMemoryIndex`, so both unit and integration test builds share one formatter. diff --git a/.compozy/tasks/mem-improvs/reviews-001/issue_005.md b/.compozy/tasks/mem-improvs/reviews-001/issue_005.md new file mode 100644 index 000000000..ad8647b30 --- /dev/null +++ b/.compozy/tasks/mem-improvs/reviews-001/issue_005.md @@ -0,0 +1,94 @@ +--- +status: resolved +file: internal/memory/catalog.go +line: 535 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM5745ax,comment:PRRC_kwDOR5y4QM65BAP- +--- + +# Issue 005: _⚠️ Potential issue_ | _🟠 Major_ +## Review Comment + +_⚠️ Potential issue_ | _🟠 Major_ + +
+🧩 Analysis chain + +🌐 Web query: + +`In SQLite FTS5 MATCH syntax, are AND/OR/NOT/NEAR reserved operators, and what is the recommended way to search for those words literally?` + +💡 Result: + +Yes, AND, OR, and NOT are reserved boolean operators in SQLite FTS5 MATCH syntax (case-sensitive). NEAR is a reserved keyword for proximity groups. To search for these words literally as terms (e.g., rows containing the token "AND"), enclose them in double quotes: "AND", "OR", "NOT", "NEAR". Within quoted strings, escape embedded double quotes by doubling them (SQL-style). Barewords (unquoted tokens) that exactly match "AND", "OR", or "NOT" are not treated as strings but as operators, so quoting is required for literal search. NEAR as a bareword initiates a NEAR group, so quote it for literal matching. Examples: - MATCH '"AND"' -- matches rows containing token "AND" - MATCH '"one two"' -- phrase search - MATCH 'NEAR("AND" "OR")' -- NEAR group with literals "AND" and "OR" + +Citations: + +- 1: https://www.sqlite.org/fts5.html +- 2: https://sqlite.org/fts5.html + +--- + + + +
+ +**Quote MATCH terms before concatenating them to prevent reserved operator interpretation.** + +`tokenizeSearchQuery()` emits operator keywords like `and`, `or`, `not`, and `near`. Joining them unquoted (e.g., `not found` or `auth or sessions`) causes FTS5 to interpret these as operators rather than literal search terms, producing incorrect results or syntax errors. Double-quote each token and join with explicit `AND`. + +
+Suggested fix + +```diff + func buildCatalogMatchQuery(query string) (string, error) { + terms := tokenizeSearchQuery(query) + if len(terms) == 0 { + return "", wrapValidationError("search query", query, errors.New("query is required")) + } +- return strings.Join(terms, " "), nil ++ quoted := make([]string, 0, len(terms)) ++ for _, term := range terms { ++ quoted = append(quoted, fmt.Sprintf("%q", term)) ++ } ++ return strings.Join(quoted, " AND "), nil + } +``` +
+ +Also applies to: 537-554 + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/memory/catalog.go` around lines 529 - 535, The current +buildCatalogMatchQuery uses tokenizeSearchQuery terms unquoted, letting FTS5 +treat words like "and/or/not/near" as operators; update buildCatalogMatchQuery +(and the similar logic at the other block around lines 537-554) to wrap each +token in double quotes and join them with explicit AND (e.g., "\"token\" AND +\"token\"") so every token is treated as a literal phrase; reference the +tokenizeSearchQuery call to get tokens, map each token to a quoted form, and +return strings.Join(quotedTokens, " AND "), preserving existing error handling. +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Notes: + - `buildCatalogMatchQuery` currently concatenates raw search tokens into an FTS5 `MATCH` expression. + - Tokens such as `and`, `or`, `not`, and `near` are parsed as operators instead of literal terms, which can change results or produce syntax errors for otherwise normal user queries. + - I will quote each token, join them with explicit `AND`, and add regression coverage through the store search path. + +## Resolution + +- Quoted every catalog search token and joined terms with explicit `AND` so FTS5 reserved keywords are treated as literals. +- Added a store search regression test proving queries like `not` work through the catalog path instead of failing as operator syntax. diff --git a/.compozy/tasks/mem-improvs/reviews-001/issue_006.md b/.compozy/tasks/mem-improvs/reviews-001/issue_006.md new file mode 100644 index 000000000..5dc56c81b --- /dev/null +++ b/.compozy/tasks/mem-improvs/reviews-001/issue_006.md @@ -0,0 +1,62 @@ +--- +status: resolved +file: internal/memory/recall.go +line: 46 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM5745az,comment:PRRC_kwDOR5y4QM65BAQB +--- + +# Issue 006: _⚠️ Potential issue_ | _🟠 Major_ +## Review Comment + +_⚠️ Potential issue_ | _🟠 Major_ + +**Keep recall augmentation best-effort.** + +Returning the search error here aborts prompt delivery for an auxiliary feature. Any transient catalog issue—or a query-parse failure from the search layer—will fail the whole turn instead of just skipping recall. + + +
+Suggested fix + +```diff + results, err := target.Search(ctx, query, SearchOptions{ + Workspace: workspaceRoot, + Limit: maxRecallResults, + }) + if err != nil { +- return message, err ++ return message, nil + } +``` +
+ +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/memory/recall.go` around lines 41 - 46, The recall augmentation +should be best-effort: when calling target.Search(ctx, query, +SearchOptions{Workspace: workspaceRoot, Limit: maxRecallResults}) and receiving +a non-nil err from the results, do not return the error from this function; +instead log the error (including context like query and workspaceRoot) and +continue execution with an empty/zero results set so the function returns the +original message; update the error-handling around the results, err assignment +to handle failures by logging and skipping recall rather than returning err. +``` + +
+ + + + + +## Triage + +- Decision: `invalid` +- Notes: + - The current prompt path already keeps augmentation best-effort for ordinary failures. `Manager.Prompt` logs augmenter errors and still dispatches the original message, so a recall search error does not abort prompt delivery today. + - Swallowing all errors inside `NewRecallAugmenter` would also hide cancellation/deadline signals once cancellation handling is corrected in `manager_prompt.go`. + - The real bug is in manager-level cancellation propagation, which is addressed under issue 011. No change is needed in `internal/memory/recall.go`. diff --git a/.compozy/tasks/mem-improvs/reviews-001/issue_007.md b/.compozy/tasks/mem-improvs/reviews-001/issue_007.md new file mode 100644 index 000000000..dc62f5148 --- /dev/null +++ b/.compozy/tasks/mem-improvs/reviews-001/issue_007.md @@ -0,0 +1,326 @@ +--- +status: resolved +file: internal/memory/store.go +line: 88 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM5745a1,comment:PRRC_kwDOR5y4QM65BAQD +--- + +# Issue 007: _⚠️ Potential issue_ | _🔴 Critical_ +## Review Comment + +_⚠️ Potential issue_ | _🔴 Critical_ + +
+🧩 Analysis chain + +🏁 Script executed: + +```shell +#!/bin/bash +set -euo pipefail +rg -n -C2 'type Backend interface|func \([^)]*\*Store\) (LoadPromptIndex|LoadIndex)\b|var _ Backend = \(\*Store\)\(nil\)' internal/memory +``` + +Repository: compozy/agh + +Length of output: 763 + +--- + +🏁 Script executed: + +```shell +cat -n internal/memory/types.go | sed -n '86,120p' +``` + +Repository: compozy/agh + +Length of output: 1629 + +--- + +🏁 Script executed: + +```shell +rg -n 'func \(s \*Store\)' internal/memory/store.go | head -30 +``` + +Repository: compozy/agh + +Length of output: 2287 + +--- + +🏁 Script executed: + +```shell +rg -n 'LoadPromptIndex' internal/memory/ +``` + +Repository: compozy/agh + +Length of output: 156 + +--- + +🏁 Script executed: + +```shell +rg -n 'var _ Backend' internal/memory/ +``` + +Repository: compozy/agh + +Length of output: 37 + +--- + +🏁 Script executed: + +```shell +rg -n 'Store.*Backend|Backend.*Store' internal/memory/ | head -20 +``` + +Repository: compozy/agh + +Length of output: 37 + +--- + +🏁 Script executed: + +```shell +rg -n 'backend|Backend' internal/memory/store.go | head -20 +``` + +Repository: compozy/agh + +Length of output: 104 + +--- + +🏁 Script executed: + +```shell +rg -n ':= NewStore|= memory.NewStore' --type go | head -10 +``` + +Repository: compozy/agh + +Length of output: 878 + +--- + +🏁 Script executed: + +```shell +rg -n 'func New.*Backend|New.*Store' internal/memory/ | head -20 +``` + +Repository: compozy/agh + +Length of output: 1624 + +--- + +🏁 Script executed: + +```shell +rg -n 'var.*Backend|Backend\s*=' internal/ | grep -v test | head -20 +``` + +Repository: compozy/agh + +Length of output: 1637 + +--- + +🏁 Script executed: + +```shell +rg -n 'func.*Backend\b' internal/ | grep -v test | head -20 +``` + +Repository: compozy/agh + +Length of output: 837 + +--- + +🏁 Script executed: + +```shell +fd -e go -x grep -l 'Backend' {} \; | grep -v test +``` + +Repository: compozy/agh + +Length of output: 211 + +--- + +🏁 Script executed: + +```shell +rg -n 'memory.Backend|Backend\s*interface' internal/ | grep -v test +``` + +Repository: compozy/agh + +Length of output: 107 + +--- + +🏁 Script executed: + +```shell +rg -n 'type.*Backend' internal/memory/types.go -A 10 +``` + +Repository: compozy/agh + +Length of output: 613 + +--- + +🏁 Script executed: + +```shell +# Check if Store is ever assigned to Backend or passed where Backend is expected +rg -n 'func.*Backend\s*\)|Backend\s*\)' internal/memory/ +``` + +Repository: compozy/agh + +Length of output: 37 + +--- + +🏁 Script executed: + +```shell +# Check if there's a wrapper or adapter that implements Backend using Store +rg -n 'type.*struct' internal/memory/ -A 3 | grep -B 3 -A 3 'Store' +``` + +Repository: compozy/agh + +Length of output: 1453 + +--- + +🏁 Script executed: + +```shell +# Check all files in internal/memory for Backend implementation +ls -la internal/memory/ +``` + +Repository: compozy/agh + +Length of output: 1183 + +--- + +🏁 Script executed: + +```shell +# Verify if Store methods are actually being type-checked against Backend anywhere +rg -n 'LoadIndex|LoadPromptIndex' internal/memory/ -B 2 -A 2 +``` + +Repository: compozy/agh + +Length of output: 7071 + +--- + + + +
+ +**Add compile-time interface verification for `Store` to `Backend`.** + +The `Backend` interface requires `LoadPromptIndex(scope Scope) (content string, truncated bool, err error)` (types.go:95), but `Store` implements `LoadIndex` instead (store.go:290). Although the signatures are identical, the method name mismatch means `*Store` does not satisfy `Backend`. Add a compile-time assertion and either rename the method or add a shim: + +
+Suggested fix + +```diff ++var _ Backend = (*Store)(nil) ++ + // List is the backend-aligned alias for Scan. + func (s *Store) List(scope Scope) ([]Header, error) { + return s.Scan(scope) + } ++ ++// LoadPromptIndex is the backend-aligned alias for LoadIndex. ++func (s *Store) LoadPromptIndex(scope Scope) (content string, truncated bool, err error) { ++ return s.LoadIndex(scope) ++} +``` +
+ +This satisfies the coding guideline: `Use compile-time interface verification: var _ Interface = (*Type)(nil)`. + + + +
+📝 Committable suggestion + +> ‼️ **IMPORTANT** +> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. + +```suggestion +var _ Backend = (*Store)(nil) + +// List is the backend-aligned alias for Scan. +func (s *Store) List(scope Scope) ([]Header, error) { + return s.Scan(scope) +} + +// LoadPromptIndex is the backend-aligned alias for LoadIndex. +func (s *Store) LoadPromptIndex(scope Scope) (content string, truncated bool, err error) { + return s.LoadIndex(scope) +} +``` + +
+ + + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/memory/store.go` around lines 85 - 88, The Store type does not +currently satisfy the Backend interface because Backend declares +LoadPromptIndex(scope Scope) (content string, truncated bool, err error) while +Store implements LoadIndex with the same signature; add a compile-time assertion +var _ Backend = (*Store)(nil) and fix the mismatch by either renaming +Store.LoadIndex to LoadPromptIndex or adding a small shim method +LoadPromptIndex(scope Scope) (string, bool, error) that simply delegates to +LoadIndex; ensure the assertion is placed near the Store type and that the +shim/rename preserves the original implementation and signature. +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Notes: + - `Backend` advertises `LoadPromptIndex`, but `Store` only exposes `LoadIndex`, and there is no compile-time assertion tying `*Store` to the interface. + - That leaves the backend contract able to drift silently. I will add a backend-aligned shim plus a compile-time assertion and validate the interface surface in tests. + +## Resolution + +- Added `var _ Backend = (*Store)(nil)` and a backend-aligned `LoadPromptIndex(...)` shim on `Store`. +- Added regression coverage that exercises `LoadPromptIndex` through the `Backend` interface. diff --git a/.compozy/tasks/mem-improvs/reviews-001/issue_008.md b/.compozy/tasks/mem-improvs/reviews-001/issue_008.md new file mode 100644 index 000000000..d8b020ff0 --- /dev/null +++ b/.compozy/tasks/mem-improvs/reviews-001/issue_008.md @@ -0,0 +1,62 @@ +--- +status: resolved +file: internal/memory/store.go +line: 169 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM5745a6,comment:PRRC_kwDOR5y4QM65BAQK +--- + +# Issue 008: _⚠️ Potential issue_ | _🟠 Major_ +## Review Comment + +_⚠️ Potential issue_ | _🟠 Major_ + +**Derived catalog sync should not make an already-committed write/delete look like a failure.** + +`fileutil.AtomicWriteFile` / `os.Remove` complete before `syncScope`, so an index/catalog error returns failure after the source-of-truth file has already changed. On top of that, the follow-up work is launched under `context.Background()`, so callers cannot cancel or bound it. Please either pass a caller context through these mutation paths, or demote derived-state failures to warnings/async repair instead of failing the primary mutation. + +As per coding guidelines, `Use context.Context as first argument to functions crossing runtime boundaries — avoid context.Background() outside main and focused tests`. + + + +Also applies to: 181-195, 754-760 + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/memory/store.go` around lines 158 - 169, The commit currently +performs the filesystem mutation (fileutil.AtomicWriteFile / os.Remove) and then +calls s.syncScope and s.logCatalogEvent using context.Background(), causing +derived-state failures to surface as primary errors and making operations +uncancellable; change these mutation paths (the write/delete handlers that call +s.syncScope and s.logCatalogEvent) to accept and propagate the caller's context +(use ctx as the first parameter instead of context.Background()) and return the +filesystem error immediately while demoting any derived-state/catalog/index +failures from s.syncScope and s.logCatalogEvent to warnings or async repair +(call s.warn or enqueue a background repair task) so that sync/log failures do +not cause the main mutation to fail; update the signatures and all call sites +for s.syncScope and s.logCatalogEvent accordingly and remove +context.Background() usages in these paths. +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Notes: + - `Store.Write` and `Store.Delete` mutate the source-of-truth Markdown file first, then call `syncScope(...)`. If index/catalog synchronization fails afterward, the API returns an error even though the primary mutation has already committed. + - That is the real defect: derived-state failure is being surfaced as primary write/delete failure. Within the current API shape, the correct fix is to keep the mutation result authoritative and downgrade post-commit sync/log failures to warnings. + - This also removes the misleading failure mode that currently follows `context.Background()`-driven derived work. + +## Resolution + +- Made post-mutation derived sync/event logging best-effort for `Store.Write` and `Store.Delete`; primary file mutations now stay authoritative even if catalog repair/logging fails afterward. +- Added regression coverage using an invalid catalog path to confirm writes/deletes still succeed while warnings remain observable. diff --git a/.compozy/tasks/mem-improvs/reviews-001/issue_009.md b/.compozy/tasks/mem-improvs/reviews-001/issue_009.md new file mode 100644 index 000000000..c9458edd6 --- /dev/null +++ b/.compozy/tasks/mem-improvs/reviews-001/issue_009.md @@ -0,0 +1,86 @@ +--- +status: resolved +file: internal/memory/store.go +line: 880 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM5745a9,comment:PRRC_kwDOR5y4QM65BAQO +--- + +# Issue 009: _⚠️ Potential issue_ | _🟡 Minor_ +## Review Comment + +_⚠️ Potential issue_ | _🟡 Minor_ + +**`indexMatchesHeaders` is too weak to detect stale `MEMORY.md` content.** + +This only validates that each linked filename appears once. If a document’s title or description changes, `LoadIndex` will still accept a stale on-disk index and return outdated prompt content as long as the filenames match. + +
+Stricter comparison + +```diff + func indexMatchesHeaders(content string, headers []Header) bool { +- if len(headers) == 0 { +- return strings.TrimSpace(content) == "" +- } +- targets := make(map[string]struct{}, len(headers)) +- for _, header := range headers { +- targets[header.Filename] = struct{}{} +- } +- +- seen := make(map[string]struct{}, len(headers)) +- for line := range strings.SplitSeq(content, "\n") { +- target, ok := firstMarkdownLinkTarget(line) +- if !ok { +- continue +- } +- if _, exists := targets[target]; !exists { +- return false +- } +- if _, exists := seen[target]; exists { +- return false +- } +- seen[target] = struct{}{} +- } +- +- return len(seen) == len(targets) ++ return strings.TrimSpace(content) == strings.TrimSpace(renderIndex(headers)) + } +``` +
+ +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/memory/store.go` around lines 856 - 880, indexMatchesHeaders +currently only verifies that each filename appears once, allowing stale +titles/descriptions to pass; update it to validate that the on-disk index +content exactly matches the Header metadata (not just filenames). Specifically, +while iterating lines using firstMarkdownLinkTarget, also extract the link text +(the markdown link label) and any adjacent description text and compare them +against Header.Title and Header.Description for the corresponding Header entry; +fail if any title/description differs, if order/occurrence mismatches, or if any +header is missing, so the function enforces a strict canonical match between +content and the slice of Header structs. +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Notes: + - `indexMatchesHeaders` only checks that each filename appears once, so stale titles, descriptions, or reordered lines can still be accepted as current `MEMORY.md` content. + - I will tighten the comparison against the canonical rendered index and add a regression test that proves stale metadata gets synthesized instead of reused. + +## Resolution + +- Tightened `indexMatchesHeaders` to compare against the canonical rendered index content, not just linked filenames. +- Added regression coverage showing stale title/description content is rejected and synthesized from current file metadata. diff --git a/.compozy/tasks/mem-improvs/reviews-001/issue_010.md b/.compozy/tasks/mem-improvs/reviews-001/issue_010.md new file mode 100644 index 000000000..19980157b --- /dev/null +++ b/.compozy/tasks/mem-improvs/reviews-001/issue_010.md @@ -0,0 +1,514 @@ +--- +status: resolved +file: internal/memory/types.go +line: 96 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM5745a_,comment:PRRC_kwDOR5y4QM65BAQR +--- + +# Issue 010: _🛠️ Refactor suggestion_ | _🟠 Major_ +## Review Comment + +_🛠️ Refactor suggestion_ | _🟠 Major_ + +
+🧩 Analysis chain + +🏁 Script executed: + +```shell +#!/bin/bash +# Inspect the exported backend contract and the current memory-store method signatures. +rg -n -C2 --type=go 'type Backend interface' internal/memory/types.go +echo +rg -n -C2 --type=go 'func \(.*\)\s+(List|Read|Write|Delete|LoadPromptIndex)\(' internal/memory +``` + +Repository: compozy/agh + +Length of output: 2045 + +--- + +🏁 Script executed: + +```shell +# Find all implementations of the Backend interface +rg -n 'type.*Backend' internal/memory --type=go -A1 | head -30 +``` + +Repository: compozy/agh + +Length of output: 172 + +--- + +🏁 Script executed: + +```shell +# Find all method implementations on *Store that implement Backend +rg -n 'func \(.*\*Store\)' internal/memory/store.go --type=go | grep -E '(List|Read|Write|Delete|LoadPromptIndex|Search|Reindex)' +``` + +Repository: compozy/agh + +Length of output: 742 + +--- + +🏁 Script executed: + +```shell +# Find all usages of Backend interface and its methods +rg -n '\.List\(|\.Read\(|\.Write\(|\.Delete\(|\.LoadPromptIndex\(' internal/memory --type=go | head -40 +``` + +Repository: compozy/agh + +Length of output: 3910 + +--- + +🏁 Script executed: + +```shell +# Find where Backend is stored/passed as interface{} +rg -n 'Backend' internal/memory/types.go --type=go | head -20 +``` + +Repository: compozy/agh + +Length of output: 169 + +--- + +🏁 Script executed: + +```shell +# Check the full Backend interface definition including LoadPromptIndex +cat -n internal/memory/types.go | sed -n '88,96p' +``` + +Repository: compozy/agh + +Length of output: 571 + +--- + +🏁 Script executed: + +```shell +# Find all usages of Backend as interface type (not just direct method calls) +rg -n '\bBackend\b' internal --type=go | grep -v test | head -30 +``` + +Repository: compozy/agh + +Length of output: 2884 + +--- + +🏁 Script executed: + +```shell +# Find if Backend is used in daemon, API, or CLI layers +rg -n '\bBackend\b' --type=go | grep -E '(daemon|api|cli|layers)' | head -20 +``` + +Repository: compozy/agh + +Length of output: 2205 + +--- + +🏁 Script executed: + +```shell +# Search explicitly for memory.Backend interface usage +rg -n 'memory\.Backend' --type=go | head -20 +``` + +Repository: compozy/agh + +Length of output: 37 + +--- + +🏁 Script executed: + +```shell +# Search for Backend interface parameter/field declarations (not just methods) +rg -n 'Backend\s+(interface|Backend)' internal/memory --type=go +``` + +Repository: compozy/agh + +Length of output: 107 + +--- + +🏁 Script executed: + +```shell +# Check what the Store interface looks like and if Backend is used +rg -n 'type Store struct' internal/memory/store.go --type=go -A10 +``` + +Repository: compozy/agh + +Length of output: 379 + +--- + +🏁 Script executed: + +```shell +# Search in daemon, consolidation, and other layers for Backend usage +find internal -type d -name "daemon" -o -name "api" -o -name "consolidation" | head -5 +rg -n '\bBackend\b' internal/memory/consolidation --type=go | head -15 +``` + +Repository: compozy/agh + +Length of output: 113 + +--- + +🏁 Script executed: + +```shell +# Search for Backend as function parameter or return type +rg -n 'func.*Backend' internal --type=go -B2 | head -40 +``` + +Repository: compozy/agh + +Length of output: 2190 + +--- + +🏁 Script executed: + +```shell +# Search for Backend in struct fields +rg -n '\bBackend\s+Backend' internal --type=go +``` + +Repository: compozy/agh + +Length of output: 177 + +--- + +🏁 Script executed: + +```shell +# Check consolidation package for usage +rg -n '\bBackend' internal/memory/consolidation --type=go -B1 -A1 | head -40 +``` + +Repository: compozy/agh + +Length of output: 37 + +--- + +🏁 Script executed: + +```shell +# Look for who uses the Store and if it's passed as Backend interface +rg -n 'NewStore\|\.Backend' internal/memory --type=go | head -20 +``` + +Repository: compozy/agh + +Length of output: 37 + +--- + +🏁 Script executed: + +```shell +# Search specifically for memory.Backend as parameter or field in other files +rg -n 'Backend' internal/memory/types.go --type=go -A40 | tail -50 +``` + +Repository: compozy/agh + +Length of output: 1701 + +--- + +🏁 Script executed: + +```shell +# Check if Backend is used in exported functions or API boundaries +rg -n '(func|type|var).*Backend\s' internal/memory --type=go -B1 -A3 | grep -v test +``` + +Repository: compozy/agh + +Length of output: 451 + +--- + +🏁 Script executed: + +```shell +# Search for any exported functions that might accept Backend parameter +rg -n 'func \(' internal/memory/store.go --type=go | grep -v ':' | head -10 +``` + +Repository: compozy/agh + +Length of output: 37 + +--- + +🏁 Script executed: + +```shell +# Look for daemon or API integration with memory package +rg -n 'memory\.' internal/daemon --type=go | head -20 +rg -n 'memory\.' internal/api --type=go | head -20 +``` + +Repository: compozy/agh + +Length of output: 4111 + +--- + +🏁 Script executed: + +```shell +# Search for where Backend interface might be used as an interface type parameter +rg -n 'Backend' internal/api/udsapi/server.go --type=go -B2 -A2 +``` + +Repository: compozy/agh + +Length of output: 37 + +--- + +🏁 Script executed: + +```shell +# Check consolidation package which seems related +rg -n 'Backend\|Service' internal/memory/consolidation --type=go | head -40 +``` + +Repository: compozy/agh + +Length of output: 37 + +--- + +🏁 Script executed: + +```shell +# Look for Store or Backend being passed to daemon/api functions +rg -n 'NewStore\|WithMemory' internal/api/udsapi/server.go --type=go -B2 -A5 +``` + +Repository: compozy/agh + +Length of output: 37 + +--- + +🏁 Script executed: + +```shell +# Check the actual Store interface/type definition more carefully +cat -n internal/memory/store.go | head -50 +``` + +Repository: compozy/agh + +Length of output: 1516 + +--- + +🏁 Script executed: + +```shell +# Look for where Store is injected into daemon or API +rg -n 'memoryStore\|memory.Store' internal/daemon/daemon.go --type=go | head -20 +``` + +Repository: compozy/agh + +Length of output: 37 + +--- + +🏁 Script executed: + +```shell +# Check if there are other implementations of Backend interface +rg -n 'func \(.*\) List\(scope Scope\)' internal --type=go +``` + +Repository: compozy/agh + +Length of output: 136 + +--- + +🏁 Script executed: + +```shell +# Check the full consolidation service which might use Backend +rg -n 'type Service' internal/memory/consolidation --type=go -A10 | head -40 +``` + +Repository: compozy/agh + +Length of output: 1530 + +--- + +🏁 Script executed: + +```shell +# Verify if the proposed diff would break any existing callers in tests or implementations +rg -n '\.List\(|\.Read\(|\.Write\(|\.Delete\(|\.LoadPromptIndex\(' internal/memory/store_test.go --type=go | wc -l +``` + +Repository: compozy/agh + +Length of output: 57 + +--- + +🏁 Script executed: + +```shell +# Check if any code already tries to use Backend as interface type (not just Store) +rg -n 'var.*Backend|Backend\s*=|Backend\)' internal --type=go | grep -v 'environment.Backend' | grep -v test +``` + +Repository: compozy/agh + +Length of output: 4570 + +--- + +🏁 Script executed: + +```shell +# Final check: verify that the Store is indeed passed as *Store, not as Backend interface +rg -n 'WithMemoryStore|NewStore' internal/api/udsapi --type=go -B2 -A2 +``` + +Repository: compozy/agh + +Length of output: 3921 + +--- + +🏁 Script executed: + +```shell +# Check daemon usage pattern as well +rg -n 'memStore|memory.Store' internal/daemon/daemon.go --type=go -B1 -A1 | head -30 +``` + +Repository: compozy/agh + +Length of output: 543 + +--- + + + +
+ +**Thread `context.Context` through the full `Backend` contract.** + +`Search` and `Reindex` accept `context.Context`, but `List`, `Read`, `Write`, `Delete`, and `LoadPromptIndex` do not. Since this interface is exported and documents crossing daemon/API/CLI layer boundaries, the whole surface should be consistent with the guideline: "Use context.Context as first argument to functions crossing runtime boundaries." This refactoring should happen now before additional implementations depend on the current signature. + +
+Proposed API shape + +```diff + type Backend interface { +- List(scope Scope) ([]Header, error) +- Read(scope Scope, filename string) ([]byte, error) +- Write(scope Scope, filename string, content []byte) error +- Delete(scope Scope, filename string) error ++ List(ctx context.Context, scope Scope) ([]Header, error) ++ Read(ctx context.Context, scope Scope, filename string) ([]byte, error) ++ Write(ctx context.Context, scope Scope, filename string, content []byte) error ++ Delete(ctx context.Context, scope Scope, filename string) error + Search(ctx context.Context, query string, opts SearchOptions) ([]SearchResult, error) + Reindex(ctx context.Context, opts ReindexOptions) (ReindexResult, error) +- LoadPromptIndex(scope Scope) (content string, truncated bool, err error) ++ LoadPromptIndex(ctx context.Context, scope Scope) (content string, truncated bool, err error) + } +``` +
+ + + +
+📝 Committable suggestion + +> ‼️ **IMPORTANT** +> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. + +```suggestion +// Backend captures the memory backend surface used by daemon, API, and CLI layers. +type Backend interface { + List(ctx context.Context, scope Scope) ([]Header, error) + Read(ctx context.Context, scope Scope, filename string) ([]byte, error) + Write(ctx context.Context, scope Scope, filename string, content []byte) error + Delete(ctx context.Context, scope Scope, filename string) error + Search(ctx context.Context, query string, opts SearchOptions) ([]SearchResult, error) + Reindex(ctx context.Context, opts ReindexOptions) (ReindexResult, error) + LoadPromptIndex(ctx context.Context, scope Scope) (content string, truncated bool, err error) +} +``` + +
+ + + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/memory/types.go` around lines 87 - 96, The Backend interface is +inconsistent: Search and Reindex already accept context.Context but List, Read, +Write, Delete, and LoadPromptIndex do not; update the Backend interface so all +methods take context.Context as the first parameter (change List(scope Scope) to +List(ctx context.Context, scope Scope), Read(scope Scope, filename string) to +Read(ctx context.Context, scope Scope, filename string), Write(scope Scope, +filename string, content []byte) to Write(ctx context.Context, scope Scope, +filename string, content []byte), Delete(scope Scope, filename string) to +Delete(ctx context.Context, scope Scope, filename string), and +LoadPromptIndex(scope Scope) to LoadPromptIndex(ctx context.Context, scope +Scope) (and keep Search(ctx context.Context, ...) and Reindex(ctx +context.Context, ...) as-is); then update all implementations and call sites to +pass the ctx as the first argument and adjust any tests or mock types (refer to +the Backend interface and methods List, Read, Write, Delete, LoadPromptIndex, +Search, Reindex for locations to change). +``` + +
+ + + + + +## Triage + +- Decision: `invalid` +- Notes: + - The `memory.Backend` interface currently has no active consumers as an interface type anywhere in `internal/`; the live code paths use concrete `*Store` values. + - Refactoring every backend method to take `context.Context` would force unrelated API and test churn outside this batch without fixing a demonstrated runtime defect. + - The concrete issues in this batch are addressed more directly by fixing `Store`/`Backend` drift (issue 007), preserving write/delete success semantics after committed mutations (issue 008), and propagating actual cancellation in session prompt handling (issue 011). diff --git a/.compozy/tasks/mem-improvs/reviews-001/issue_011.md b/.compozy/tasks/mem-improvs/reviews-001/issue_011.md new file mode 100644 index 000000000..d3abc5780 --- /dev/null +++ b/.compozy/tasks/mem-improvs/reviews-001/issue_011.md @@ -0,0 +1,54 @@ +--- +status: resolved +file: internal/session/manager_prompt.go +line: 103 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM5745bB,comment:PRRC_kwDOR5y4QM65BAQV +--- + +# Issue 011: _⚠️ Potential issue_ | _🟠 Major_ +## Review Comment + +_⚠️ Potential issue_ | _🟠 Major_ + +**Propagate cancellation/deadline errors from the augmenter.** + +If `m.inputAugmenter(...)` returns `context.Canceled` or `context.DeadlineExceeded`, this path still falls back to the original message and can continue into `m.driver.Prompt(...)`. That weakens cancellation semantics and can dispatch a prompt after the caller has already aborted the request. + +
+Suggested fix + +```diff + dispatchMessage := message + if m.inputAugmenter != nil { + augmented, augmentErr := m.inputAugmenter(ctx, session, message) + if augmentErr != nil { ++ if errors.Is(augmentErr, context.Canceled) || errors.Is(augmentErr, context.DeadlineExceeded) { ++ return nil, fmt.Errorf("session: augment prompt input: %w", augmentErr) ++ } + m.sessionLogger(session).Warn("session: prompt input augmentation failed", "error", augmentErr) + } else if strings.TrimSpace(augmented) != "" { + dispatchMessage = augmented + } + } +``` +
+ + +As per coding guidelines, Use `context.Context` as first argument to functions crossing runtime boundaries — avoid `context.Background()` outside `main` and focused tests. + + + + + +## Triage + +- Decision: `valid` +- Notes: + - `Manager.Prompt` currently swallows every augmenter error and still calls `m.driver.Prompt(...)`. That is wrong for `context.Canceled` and `context.DeadlineExceeded`, because those errors mean the caller already aborted the request. + - I will preserve best-effort behavior for ordinary augmentation failures while returning cancellation/deadline errors before dispatch. + +## Resolution + +- `Manager.Prompt` now propagates `context.Canceled` and `context.DeadlineExceeded` from the input augmenter instead of falling through to driver dispatch. +- Added a session-level regression test confirming canceled augmentation skips `driver.Prompt(...)`. diff --git a/.compozy/tasks/mem-improvs/reviews-001/issue_012.md b/.compozy/tasks/mem-improvs/reviews-001/issue_012.md new file mode 100644 index 000000000..bcaf739bf --- /dev/null +++ b/.compozy/tasks/mem-improvs/reviews-001/issue_012.md @@ -0,0 +1,30 @@ +--- +status: resolved +file: internal/store/globaldb/global_db_test.go +line: 1277 +severity: nitpick +author: coderabbitai[bot] +provider_ref: review:4132976935,nitpick_hash:4c1017b72a7e +review_hash: 4c1017b72a7e +source_review_id: "4132976935" +source_review_submitted_at: "2026-04-18T00:19:15Z" +--- + +# Issue 012: Please cover the Limit > 0 branch too. +## Review Comment + +`ListEventSummaries` now has separate SQL for limited vs. unlimited merged results, but this test only exercises the unlimited path. Add a mixed-stream case with `EventSummaryQuery{Limit: ...}` so the new ordering logic across `event_summaries` and `memory_operation_log` is locked down as well. + +As per coding guidelines, Focus on critical paths: workflow execution, state management, error handling. + +## Triage + +- Decision: `valid` +- Notes: + - The existing test only exercises the unlimited merged-query branch in `ListEventSummaries`. + - The limited path uses different SQL shape and ordering, so I will add a mixed event/memory-operation case with `Limit > 0` to lock that behavior down explicitly. + +## Resolution + +- Extended the global DB summary test with a mixed session-event and memory-operation stream using `EventSummaryQuery{Limit: 2}`. +- Verified the limited query returns the most recent merged rows while preserving ascending output order for the selected window. diff --git a/.compozy/tasks/mem-improvs/reviews-001/issue_013.md b/.compozy/tasks/mem-improvs/reviews-001/issue_013.md new file mode 100644 index 000000000..60d1970b3 --- /dev/null +++ b/.compozy/tasks/mem-improvs/reviews-001/issue_013.md @@ -0,0 +1,101 @@ +--- +status: resolved +file: internal/testutil/e2e/runtime_harness.go +line: 836 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM5745bC,comment:PRRC_kwDOR5y4QM65BAQW +--- + +# Issue 013: _⚠️ Potential issue_ | _🟡 Minor_ +## Review Comment + +_⚠️ Potential issue_ | _🟡 Minor_ + +**Resolve relative `workdir` values against the harness base directory.** + +`exec.Cmd.Dir` treats relative paths as relative to the test process cwd, not `c.workdir`. Passing `"."` or another relative value here will therefore behave differently across runners. Joining relative paths to `c.workdir` keeps the harness deterministic. + + +
+🧭 Proposed fix + +```diff + cmd := execabs.CommandContext(ctx, c.binaryPath, args...) + cmd.Env = append([]string(nil), c.env...) +- cmd.Dir = strings.TrimSpace(workdir) +- if cmd.Dir == "" { +- cmd.Dir = c.workdir +- } ++ trimmedDir := strings.TrimSpace(workdir) ++ switch { ++ case trimmedDir == "": ++ cmd.Dir = c.workdir ++ case filepath.IsAbs(trimmedDir): ++ cmd.Dir = trimmedDir ++ default: ++ cmd.Dir = filepath.Join(c.workdir, trimmedDir) ++ } +``` +
+ + + +
+📝 Committable suggestion + +> ‼️ **IMPORTANT** +> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. + +```suggestion +func (c *CLIClient) RunInDir(ctx context.Context, workdir string, args ...string) (string, string, error) { + // `#nosec` G204 -- test helper intentionally shells out to the current agh test binary. + cmd := execabs.CommandContext(ctx, c.binaryPath, args...) + cmd.Env = append([]string(nil), c.env...) + trimmedDir := strings.TrimSpace(workdir) + switch { + case trimmedDir == "": + cmd.Dir = c.workdir + case filepath.IsAbs(trimmedDir): + cmd.Dir = trimmedDir + default: + cmd.Dir = filepath.Join(c.workdir, trimmedDir) + } +``` + +
+ + + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/testutil/e2e/runtime_harness.go` around lines 829 - 836, The +RunInDir behavior uses exec.Cmd.Dir which treats relative paths as relative to +the test process CWD; update CLIClient.RunInDir to resolve relative workdir +values against the harness base c.workdir: after trimming workdir, if it's empty +keep c.workdir; else if !filepath.IsAbs(workdir) set cmd.Dir = +filepath.Join(c.workdir, workdir) otherwise set cmd.Dir = workdir; ensure you +import "path/filepath" and preserve the existing fallback logic around c.workdir +and the use of execabs.CommandContext. +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Notes: + - `CLIClient.RunInDir` currently assigns relative `workdir` values directly to `exec.Cmd.Dir`, which makes resolution depend on the test process working directory instead of the harness base directory. + - I will resolve relative paths against `c.workdir` and add a focused test so harness behavior stays deterministic across runners. + +## Resolution + +- Updated `CLIClient.RunInDir` to resolve relative `workdir` values against the harness base directory while preserving empty and absolute path behavior. +- Added a focused harness test that proves a relative directory executes under `filepath.Join(c.workdir, workdir)`. diff --git a/internal/cli/memory.go b/internal/cli/memory.go index dffca30ab..8441c9534 100644 --- a/internal/cli/memory.go +++ b/internal/cli/memory.go @@ -167,21 +167,21 @@ func newMemorySearchCommand(deps commandDeps) *cobra.Command { ) cmd := &cobra.Command{ - Use: "search ", + Use: "search ", Short: "Search durable memory", Example: ` # Search global and current-workspace memories agh memory search auth rewrite # Search only workspace-scoped memories agh memory search release plan --scope workspace --limit 5`, - Args: cobra.ExactArgs(1), + Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { client, err := clientFromDeps(deps) if err != nil { return err } - query := strings.TrimSpace(args[0]) + query := strings.TrimSpace(strings.Join(args, " ")) if query == "" { return errors.New("memory query is required") } diff --git a/internal/cli/memory_test.go b/internal/cli/memory_test.go index 088a6cfa9..31afd575d 100644 --- a/internal/cli/memory_test.go +++ b/internal/cli/memory_test.go @@ -106,7 +106,7 @@ func TestMemorySearchAndReindexCommands(t *testing.T) { }, }) - searchOut, _, err := executeRootCommand(t, deps, "memory", "search", "auth rewrite") + searchOut, _, err := executeRootCommand(t, deps, "memory", "search", "auth", "rewrite") if err != nil { t.Fatalf("memory search error = %v", err) } diff --git a/internal/daemon/daemon_memory_e2e_integration_test.go b/internal/daemon/daemon_memory_e2e_integration_test.go index 2c45d8d67..f79b066fc 100644 --- a/internal/daemon/daemon_memory_e2e_integration_test.go +++ b/internal/daemon/daemon_memory_e2e_integration_test.go @@ -72,154 +72,172 @@ func TestDaemonE2EMemoryCatalogCLIHTTPParityAndLegacyPathIsolation(t *testing.T) memory.ScopeWorkspace, ) - var cliSearch []memory.SearchResult - if err := harness.CLI.RunJSONInDir( - ctx, - harness.WorkspaceRoot, - &cliSearch, - "memory", - "search", - "auth sessions", - "-o", - "json", - ); err != nil { - t.Fatalf("CLI memory search error = %v", err) - } - if !containsSearchResult(cliSearch, "auth.md", memory.ScopeWorkspace) { - t.Fatalf("CLI search results = %#v, want workspace auth.md hit", cliSearch) - } - if containsSearchResult(cliSearch, "legacy-only.md", memory.ScopeWorkspace) { - t.Fatalf("CLI search results = %#v, want legacy path ignored", cliSearch) - } + t.Run("Should return matching CLI and HTTP search results while ignoring legacy paths", func(t *testing.T) { + var cliSearch []memory.SearchResult + if err := harness.CLI.RunJSONInDir( + ctx, + harness.WorkspaceRoot, + &cliSearch, + "memory", + "search", + "auth sessions", + "-o", + "json", + ); err != nil { + t.Fatalf("CLI memory search error = %v", err) + } + if !containsSearchResult(cliSearch, "auth.md", memory.ScopeWorkspace) { + t.Fatalf("CLI search results = %#v, want workspace auth.md hit", cliSearch) + } + if containsSearchResult(cliSearch, "legacy-only.md", memory.ScopeWorkspace) { + t.Fatalf("CLI search results = %#v, want legacy path ignored", cliSearch) + } - var httpSearch []memory.SearchResult - if err := harness.HTTPJSON(ctx, http.MethodGet, memorySearchPath("auth sessions", harness.WorkspaceRoot), nil, &httpSearch); err != nil { - t.Fatalf("HTTP memory search error = %v", err) - } - if !containsSearchResult(httpSearch, "auth.md", memory.ScopeWorkspace) { - t.Fatalf("HTTP search results = %#v, want workspace auth.md hit", httpSearch) - } - if len(cliSearch) == 0 || len(httpSearch) == 0 { - t.Fatalf("search results = cli:%#v http:%#v, want non-empty parity", cliSearch, httpSearch) - } - if got, want := httpSearch[0].Filename, cliSearch[0].Filename; got != want { - t.Fatalf("HTTP top search filename = %q, want %q", got, want) - } - if got, want := httpSearch[0].Scope, cliSearch[0].Scope; got != want { - t.Fatalf("HTTP top search scope = %q, want %q", got, want) - } + var httpSearch []memory.SearchResult + if err := harness.HTTPJSON( + ctx, + http.MethodGet, + memorySearchPath("auth sessions", harness.WorkspaceRoot), + nil, + &httpSearch, + ); err != nil { + t.Fatalf("HTTP memory search error = %v", err) + } + if !containsSearchResult(httpSearch, "auth.md", memory.ScopeWorkspace) { + t.Fatalf("HTTP search results = %#v, want workspace auth.md hit", httpSearch) + } + if len(cliSearch) == 0 || len(httpSearch) == 0 { + t.Fatalf("search results = cli:%#v http:%#v, want non-empty parity", cliSearch, httpSearch) + } + if got, want := httpSearch[0].Filename, cliSearch[0].Filename; got != want { + t.Fatalf("HTTP top search filename = %q, want %q", got, want) + } + if got, want := httpSearch[0].Scope, cliSearch[0].Scope; got != want { + t.Fatalf("HTTP top search scope = %q, want %q", got, want) + } - var cliLegacy []memory.SearchResult - if err := harness.CLI.RunJSONInDir( - ctx, - harness.WorkspaceRoot, - &cliLegacy, - "memory", - "search", - "legacy decoy", - "-o", - "json", - ); err != nil { - t.Fatalf("CLI legacy search error = %v", err) - } - if len(cliLegacy) != 0 { - t.Fatalf("CLI legacy search results = %#v, want empty result set", cliLegacy) - } + var cliLegacy []memory.SearchResult + if err := harness.CLI.RunJSONInDir( + ctx, + harness.WorkspaceRoot, + &cliLegacy, + "memory", + "search", + "legacy decoy", + "-o", + "json", + ); err != nil { + t.Fatalf("CLI legacy search error = %v", err) + } + if len(cliLegacy) != 0 { + t.Fatalf("CLI legacy search results = %#v, want empty result set", cliLegacy) + } - var httpLegacy []memory.SearchResult - if err := harness.HTTPJSON(ctx, http.MethodGet, memorySearchPath("legacy decoy", harness.WorkspaceRoot), nil, &httpLegacy); err != nil { - t.Fatalf("HTTP legacy search error = %v", err) - } - if len(httpLegacy) != 0 { - t.Fatalf("HTTP legacy search results = %#v, want empty result set", httpLegacy) - } + var httpLegacy []memory.SearchResult + if err := harness.HTTPJSON( + ctx, + http.MethodGet, + memorySearchPath("legacy decoy", harness.WorkspaceRoot), + nil, + &httpLegacy, + ); err != nil { + t.Fatalf("HTTP legacy search error = %v", err) + } + if len(httpLegacy) != 0 { + t.Fatalf("HTTP legacy search results = %#v, want empty result set", httpLegacy) + } + }) - var cliReindex memory.ReindexResult - if err := harness.CLI.RunJSONInDir( - ctx, - harness.WorkspaceRoot, - &cliReindex, - "memory", - "reindex", - "-o", - "json", - ); err != nil { - t.Fatalf("CLI memory reindex error = %v", err) - } - if got, want := cliReindex.IndexedFiles, 3; got != want { - t.Fatalf("CLI reindex indexed_files = %d, want %d", got, want) - } + t.Run("Should reindex through CLI and HTTP with matching counts", func(t *testing.T) { + var cliReindex memory.ReindexResult + if err := harness.CLI.RunJSONInDir( + ctx, + harness.WorkspaceRoot, + &cliReindex, + "memory", + "reindex", + "-o", + "json", + ); err != nil { + t.Fatalf("CLI memory reindex error = %v", err) + } + if got, want := cliReindex.IndexedFiles, 3; got != want { + t.Fatalf("CLI reindex indexed_files = %d, want %d", got, want) + } - var httpReindex memory.ReindexResult - if err := harness.HTTPJSON( - ctx, - http.MethodPost, - "/api/memory/reindex", - aghcontract.MemoryReindexRequest{Workspace: harness.WorkspaceRoot}, - &httpReindex, - ); err != nil { - t.Fatalf("HTTP memory reindex error = %v", err) - } - if got, want := httpReindex.IndexedFiles, 3; got != want { - t.Fatalf("HTTP reindex indexed_files = %d, want %d", got, want) - } + var httpReindex memory.ReindexResult + if err := harness.HTTPJSON( + ctx, + http.MethodPost, + "/api/memory/reindex", + aghcontract.MemoryReindexRequest{Workspace: harness.WorkspaceRoot}, + &httpReindex, + ); err != nil { + t.Fatalf("HTTP memory reindex error = %v", err) + } + if got, want := httpReindex.IndexedFiles, 3; got != want { + t.Fatalf("HTTP reindex indexed_files = %d, want %d", got, want) + } + }) - var health aghcontract.HealthResponse - if err := harness.HTTPJSON( - ctx, - http.MethodGet, - "/api/observe/health?workspace="+url.QueryEscape(harness.WorkspaceRoot), - nil, - &health, - ); err != nil { - t.Fatalf("HTTP observe health error = %v", err) - } - if got, want := health.Memory.GlobalFiles, 1; got != want { - t.Fatalf("health.Memory.GlobalFiles = %d, want %d", got, want) - } - if got, want := health.Memory.WorkspaceFiles, 2; got != want { - t.Fatalf("health.Memory.WorkspaceFiles = %d, want %d", got, want) - } - if got, want := health.Memory.IndexedFiles, 3; got != want { - t.Fatalf("health.Memory.IndexedFiles = %d, want %d", got, want) - } - if got := health.Memory.OrphanedFiles; got != 0 { - t.Fatalf("health.Memory.OrphanedFiles = %d, want 0", got) - } - if health.Memory.LastReindex == nil { - t.Fatalf("health.Memory.LastReindex = nil, want non-nil") - } + t.Run("Should surface memory health and observe events", func(t *testing.T) { + var health aghcontract.HealthResponse + if err := harness.HTTPJSON( + ctx, + http.MethodGet, + "/api/observe/health?workspace="+url.QueryEscape(harness.WorkspaceRoot), + nil, + &health, + ); err != nil { + t.Fatalf("HTTP observe health error = %v", err) + } + if got, want := health.Memory.GlobalFiles, 1; got != want { + t.Fatalf("health.Memory.GlobalFiles = %d, want %d", got, want) + } + if got, want := health.Memory.WorkspaceFiles, 2; got != want { + t.Fatalf("health.Memory.WorkspaceFiles = %d, want %d", got, want) + } + if got, want := health.Memory.IndexedFiles, 3; got != want { + t.Fatalf("health.Memory.IndexedFiles = %d, want %d", got, want) + } + if got := health.Memory.OrphanedFiles; got != 0 { + t.Fatalf("health.Memory.OrphanedFiles = %d, want 0", got) + } + if health.Memory.LastReindex == nil { + t.Fatalf("health.Memory.LastReindex = nil, want non-nil") + } - var reindexEvents aghcontract.ObserveEventsResponse - if err := harness.UDSJSON( - ctx, - http.MethodGet, - "/api/observe/events?type=memory.reindex&limit=10", - nil, - &reindexEvents, - ); err != nil { - t.Fatalf("UDS observe reindex events error = %v", err) - } - if !containsObserveEventSummary(reindexEvents.Events, "memory.reindex", "indexed=3") { - t.Fatalf("reindex observe events = %#v, want indexed=3 summary", reindexEvents.Events) - } + var reindexEvents aghcontract.ObserveEventsResponse + if err := harness.UDSJSON( + ctx, + http.MethodGet, + "/api/observe/events?type=memory.reindex&limit=10", + nil, + &reindexEvents, + ); err != nil { + t.Fatalf("UDS observe reindex events error = %v", err) + } + if !containsObserveEventSummary(reindexEvents.Events, "memory.reindex", "indexed=3") { + t.Fatalf("reindex observe events = %#v, want indexed=3 summary", reindexEvents.Events) + } - var searchEvents aghcontract.ObserveEventsResponse - if err := harness.UDSJSON( - ctx, - http.MethodGet, - "/api/observe/events?type=memory.search&limit=10", - nil, - &searchEvents, - ); err != nil { - t.Fatalf("UDS observe search events error = %v", err) - } - if !containsObserveEventSummary(searchEvents.Events, "memory.search", `auth sessions`) { - t.Fatalf("search observe events = %#v, want auth search summary", searchEvents.Events) - } - if !containsObserveEventSummary(searchEvents.Events, "memory.search", `legacy decoy`) { - t.Fatalf("search observe events = %#v, want legacy search summary", searchEvents.Events) - } + var searchEvents aghcontract.ObserveEventsResponse + if err := harness.UDSJSON( + ctx, + http.MethodGet, + "/api/observe/events?type=memory.search&limit=10", + nil, + &searchEvents, + ); err != nil { + t.Fatalf("UDS observe search events error = %v", err) + } + if !containsObserveEventSummary(searchEvents.Events, "memory.search", `auth sessions`) { + t.Fatalf("search observe events = %#v, want auth search summary", searchEvents.Events) + } + if !containsObserveEventSummary(searchEvents.Events, "memory.search", `legacy decoy`) { + t.Fatalf("search observe events = %#v, want legacy search summary", searchEvents.Events) + } + }) } func TestDaemonE2EMemoryRecallUsesCatalogSynthesisWithoutMutatingStoredUserMessage(t *testing.T) { @@ -271,54 +289,61 @@ func TestDaemonE2EMemoryRecallUsesCatalogSynthesisWithoutMutatingStoredUserMessa t.Fatalf("SessionEvents() error = %v", err) } events := decodeAgentEvents(t, eventsResp.Events) - if !containsAgentEvent(events, aghcontract.AgentEventPayload{ - Type: "agent_message", - Text: "qa-memory acknowledged", - }) { - t.Fatalf("session events = %#v, want recall fixture assistant response", events) - } - userEvent, ok := firstAgentEventByType(events, "user_message") - if !ok { - t.Fatalf("session events = %#v, want stored user_message event", events) - } - if got, want := userEvent.Text, "remember me"; got != want { - t.Fatalf("stored user_message text = %q, want %q", got, want) - } - if strings.Contains(userEvent.Text, "Relevant durable memory for this turn:") { - t.Fatalf("stored user_message text = %q, want no injected recall block", userEvent.Text) - } - diagnostics, err := acpmock.ReadDiagnostics(registration.DiagnosticsPath) if err != nil { t.Fatalf("ReadDiagnostics(%q) error = %v", registration.DiagnosticsPath, err) } - if got, want := len(diagnostics), 1; got != want { - t.Fatalf("len(diagnostics) = %d, want %d; diagnostics=%#v", got, want, diagnostics) - } - prompt := diagnostics[0].Prompt - if !strings.Contains(prompt, "Relevant durable memory for this turn:") { - t.Fatalf("mock prompt = %q, want recall preamble", prompt) - } - if !strings.Contains(prompt, "- Auth [workspace]") { - t.Fatalf("mock prompt = %q, want workspace recall heading", prompt) - } - if !strings.Contains(prompt, "auth migration uses sessions") { - t.Fatalf("mock prompt = %q, want recalled auth snippet", prompt) - } - if !strings.Contains(prompt, "\n\nUser message:\nremember me") { - t.Fatalf("mock prompt = %q, want raw user message suffix", prompt) - } - if strings.Contains(prompt, "missing.md") { - t.Fatalf("mock prompt = %q, want stale MEMORY.md entry ignored", prompt) - } - indexBytes, err := os.ReadFile(indexPath) - if err != nil { - t.Fatalf("os.ReadFile(%q) error = %v", indexPath, err) - } - if got := string(indexBytes); got != staleIndex { - t.Fatalf("workspace MEMORY.md = %q, want unchanged stale index", got) - } + t.Run("Should persist the original user message without injected recall", func(t *testing.T) { + if !containsAgentEvent(events, aghcontract.AgentEventPayload{ + Type: "agent_message", + Text: "qa-memory acknowledged", + }) { + t.Fatalf("session events = %#v, want recall fixture assistant response", events) + } + userEvent, ok := firstAgentEventByType(events, "user_message") + if !ok { + t.Fatalf("session events = %#v, want stored user_message event", events) + } + if got, want := userEvent.Text, "remember me"; got != want { + t.Fatalf("stored user_message text = %q, want %q", got, want) + } + if strings.Contains(userEvent.Text, "Relevant durable memory for this turn:") { + t.Fatalf("stored user_message text = %q, want no injected recall block", userEvent.Text) + } + }) + + t.Run("Should dispatch recalled context to the agent prompt", func(t *testing.T) { + if got, want := len(diagnostics), 1; got != want { + t.Fatalf("len(diagnostics) = %d, want %d; diagnostics=%#v", got, want, diagnostics) + } + prompt := diagnostics[0].Prompt + if !strings.Contains(prompt, "Relevant durable memory for this turn:") { + t.Fatalf("mock prompt = %q, want recall preamble", prompt) + } + if !strings.Contains(prompt, "- Auth [workspace]") { + t.Fatalf("mock prompt = %q, want workspace recall heading", prompt) + } + if !strings.Contains(prompt, "auth migration uses sessions") { + t.Fatalf("mock prompt = %q, want recalled auth snippet", prompt) + } + if !strings.Contains(prompt, "\n\nUser message:\nremember me") { + t.Fatalf("mock prompt = %q, want raw user message suffix", prompt) + } + if strings.Contains(prompt, "missing.md") { + t.Fatalf("mock prompt = %q, want stale MEMORY.md entry ignored", prompt) + } + }) + + t.Run("Should leave the stale index file unchanged on disk", func(t *testing.T) { + indexBytes, err := os.ReadFile(indexPath) + if err != nil { + t.Fatalf("os.ReadFile(%q) error = %v", indexPath, err) + } + if got := string(indexBytes); got != staleIndex { + t.Fatalf("workspace MEMORY.md = %q, want unchanged stale index", got) + } + }) } type cliMemoryMutationRecord struct { @@ -395,18 +420,6 @@ func writeMemoryViaUDS( } } -func memoryDocument(name string, description string, memoryType memory.Type, body string) string { - return strings.TrimSpace(strings.Join([]string{ - "---", - "name: " + name, - "description: " + description, - "type: " + string(memoryType), - "---", - "", - body, - }, "\n")) + "\n" -} - func memorySearchPath(query string, workspace string) string { values := url.Values{} values.Set("q", query) diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index 5019800c6..ca60fb289 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -3146,27 +3146,13 @@ func writeDaemonMemoryIndex(t *testing.T, globalDir string, workspace string) { writeDaemonFile( t, filepath.Join(globalDir, "global.md"), - `--- -name: Global -description: global note -type: user ---- - -global note -`, + memoryDocument("Global", "global note", memory.MemoryTypeUser, "global note"), ) writeDaemonFile(t, filepath.Join(globalDir, "MEMORY.md"), "- [Global](global.md) - global note") writeDaemonFile( t, filepath.Join(workspace, aghconfig.DirName, "memory", "workspace.md"), - `--- -name: Workspace -description: workspace note -type: project ---- - -workspace note -`, + memoryDocument("Workspace", "workspace note", memory.MemoryTypeProject, "workspace note"), ) writeDaemonFile( t, @@ -3175,6 +3161,18 @@ workspace note ) } +func memoryDocument(name string, description string, memoryType memory.Type, body string) string { + return strings.TrimSpace(strings.Join([]string{ + "---", + "name: " + name, + "description: " + description, + "type: " + string(memoryType), + "---", + "", + body, + }, "\n")) + "\n" +} + func writeDaemonSkill(t *testing.T, root string, name string, description string) { t.Helper() diff --git a/internal/memory/catalog.go b/internal/memory/catalog.go index 04d6dd065..2368c3737 100644 --- a/internal/memory/catalog.go +++ b/internal/memory/catalog.go @@ -531,7 +531,15 @@ func buildCatalogMatchQuery(query string) (string, error) { if len(terms) == 0 { return "", wrapValidationError("search query", query, errors.New("query is required")) } - return strings.Join(terms, " "), nil + quoted := make([]string, 0, len(terms)) + for _, term := range terms { + quoted = append(quoted, quoteCatalogMatchTerm(term)) + } + return strings.Join(quoted, " AND "), nil +} + +func quoteCatalogMatchTerm(term string) string { + return `"` + strings.ReplaceAll(strings.TrimSpace(term), `"`, `""`) + `"` } func tokenizeSearchQuery(query string) []string { diff --git a/internal/memory/store.go b/internal/memory/store.go index 155ee9fe5..7bd40f75b 100644 --- a/internal/memory/store.go +++ b/internal/memory/store.go @@ -43,6 +43,8 @@ type Store struct { catalog *catalog } +var _ Backend = (*Store)(nil) + // NewStore constructs a Store for the provided global memory directory. func NewStore(globalDir string, opts ...StoreOption) *Store { store := &Store{ @@ -87,6 +89,11 @@ func (s *Store) List(scope Scope) ([]Header, error) { return s.Scan(scope) } +// LoadPromptIndex is the backend-aligned alias for LoadIndex. +func (s *Store) LoadPromptIndex(scope Scope) (content string, truncated bool, err error) { + return s.LoadIndex(scope) +} + // EnsureDirs creates the configured memory directories when missing. func (s *Store) EnsureDirs() error { for _, dir := range []string{s.globalDir, s.workspaceDir} { @@ -158,16 +165,8 @@ func (s *Store) Write(scope Scope, filename string, content []byte) error { if err := fileutil.AtomicWriteFile(path, content, filePerm); err != nil { return fmt.Errorf("memory: write %q: %w", path, err) } - if err := s.syncScope(context.Background(), scope.Normalize()); err != nil { - return err - } - if err := s.logCatalogEvent( - context.Background(), - "memory.write", - fmt.Sprintf("scope=%s filename=%s", scope.Normalize(), filepath.Base(path)), - ); err != nil { - s.warn("memory: record write event failed", "scope", scope, "filename", filepath.Base(path), "error", err) - } + s.syncScopeAfterMutation(scope.Normalize(), filepath.Base(path), "write") + s.logMutationEvent("write", scope.Normalize(), filepath.Base(path)) return nil } @@ -184,16 +183,8 @@ func (s *Store) Delete(scope Scope, filename string) error { if filepath.Base(path) == indexFilename { return nil } - if err := s.syncScope(context.Background(), scope.Normalize()); err != nil { - return err - } - if err := s.logCatalogEvent( - context.Background(), - "memory.delete", - fmt.Sprintf("scope=%s filename=%s", scope.Normalize(), filepath.Base(path)), - ); err != nil { - s.warn("memory: record delete event failed", "scope", scope, "filename", filepath.Base(path), "error", err) - } + s.syncScopeAfterMutation(scope.Normalize(), filepath.Base(path), "delete") + s.logMutationEvent("delete", scope.Normalize(), filepath.Base(path)) return nil } @@ -761,6 +752,34 @@ func (s *Store) logCatalogEvent(ctx context.Context, eventType string, summary s return s.catalog.logEvent(ctx, eventType, summary) } +func (s *Store) syncScopeAfterMutation(scope Scope, filename string, action string) { + if err := s.syncScope(context.Background(), scope.Normalize()); err != nil { + s.warn( + "memory: sync derived state failed after mutation", + "action", strings.TrimSpace(action), + "scope", scope.Normalize(), + "filename", strings.TrimSpace(filename), + "error", err, + ) + } +} + +func (s *Store) logMutationEvent(action string, scope Scope, filename string) { + if err := s.logCatalogEvent( + context.Background(), + "memory."+strings.TrimSpace(action), + fmt.Sprintf("scope=%s filename=%s", scope.Normalize(), strings.TrimSpace(filename)), + ); err != nil { + s.warn( + "memory: record mutation event failed", + "action", strings.TrimSpace(action), + "scope", scope.Normalize(), + "filename", strings.TrimSpace(filename), + "error", err, + ) + } +} + func truncateIndex(content string, maxLines int, maxBytes int) (string, bool) { if content == "" { return "", false @@ -854,30 +873,7 @@ func renderIndexLine(header Header) string { } func indexMatchesHeaders(content string, headers []Header) bool { - if len(headers) == 0 { - return strings.TrimSpace(content) == "" - } - targets := make(map[string]struct{}, len(headers)) - for _, header := range headers { - targets[header.Filename] = struct{}{} - } - - seen := make(map[string]struct{}, len(headers)) - for line := range strings.SplitSeq(content, "\n") { - target, ok := firstMarkdownLinkTarget(line) - if !ok { - continue - } - if _, exists := targets[target]; !exists { - return false - } - if _, exists := seen[target]; exists { - return false - } - seen[target] = struct{}{} - } - - return len(seen) == len(targets) + return strings.TrimSpace(content) == strings.TrimSpace(renderIndex(headers)) } func shouldSkipFile(name string) bool { diff --git a/internal/memory/store_test.go b/internal/memory/store_test.go index ed9b5cb34..917ce988a 100644 --- a/internal/memory/store_test.go +++ b/internal/memory/store_test.go @@ -664,6 +664,31 @@ func TestStoreLoadIndex(t *testing.T) { }) } +func TestStoreLoadPromptIndexViaBackendAlias(t *testing.T) { + t.Parallel() + + env := newTestStoreEnv(t) + if err := env.store.Write(ScopeGlobal, "prefs.md", mustMemoryContent(t, testMemoryMeta{ + Name: "Prefs", + Description: "Saved preference", + Type: MemoryTypeUser, + }, "body\n")); err != nil { + t.Fatalf("Store.Write() error = %v", err) + } + + var backend Backend = env.store + got, truncated, err := backend.LoadPromptIndex(ScopeGlobal) + if err != nil { + t.Fatalf("Backend.LoadPromptIndex() error = %v", err) + } + if truncated { + t.Fatal("Backend.LoadPromptIndex() truncated = true, want false") + } + if !strings.Contains(got, "- [Prefs](prefs.md) - Saved preference") { + t.Fatalf("Backend.LoadPromptIndex() = %q, want rendered index entry", got) + } +} + func TestStoreLoadIndexSynthesizesWhenIndexIsMissingOrStale(t *testing.T) { t.Parallel() @@ -729,6 +754,38 @@ func TestStoreLoadIndexSynthesizesWhenIndexIsMissingOrStale(t *testing.T) { t.Fatalf("Store.LoadIndex() = %q, want synthesized workspace-only entry", got) } }) + + t.Run("stale index synthesizes when metadata changes", func(t *testing.T) { + t.Parallel() + + env := newTestStoreEnv(t) + if err := env.store.Write(ScopeGlobal, "prefs.md", mustMemoryContent(t, testMemoryMeta{ + Name: "Prefs", + Description: "Fresh description", + Type: MemoryTypeUser, + }, "body\n")); err != nil { + t.Fatalf("Store.Write() error = %v", err) + } + + stale := "- [Prefs](prefs.md) - stale description" + "\n" + "" + if err := os.WriteFile(filepath.Join(env.store.globalDir, indexFilename), []byte(stale), filePerm); err != nil { + t.Fatalf("write stale index: %v", err) + } + + got, truncated, err := env.store.LoadIndex(ScopeGlobal) + if err != nil { + t.Fatalf("Store.LoadIndex() error = %v", err) + } + if truncated { + t.Fatal("Store.LoadIndex() truncated = true, want false") + } + if strings.Contains(got, "stale description") { + t.Fatalf("Store.LoadIndex() = %q, want stale metadata rejected", got) + } + if !strings.Contains(got, "Fresh description") { + t.Fatalf("Store.LoadIndex() = %q, want fresh metadata rendered", got) + } + }) } func TestStoreSearchAndReindex(t *testing.T) { @@ -789,6 +846,87 @@ func TestStoreSearchAndReindex(t *testing.T) { } } +func TestStoreSearchTreatsFTSReservedWordsAsLiteralTerms(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + workspaceRoot := filepath.Join(baseDir, "workspace") + catalogPath := filepath.Join(baseDir, "agh.db") + store := NewStore( + filepath.Join(baseDir, "global"), + WithCatalogDatabasePath(catalogPath), + ).ForWorkspace(workspaceRoot) + if err := store.EnsureDirs(); err != nil { + t.Fatalf("Store.EnsureDirs() error = %v", err) + } + if err := store.Write(ScopeGlobal, "operators.md", mustMemoryContent(t, testMemoryMeta{ + Name: "Reserved Words", + Description: "Contains literal FTS keywords", + Type: MemoryTypeUser, + }, "Remember the literal token not in this memory.\n")); err != nil { + t.Fatalf("Store.Write() error = %v", err) + } + + results, err := store.Search(context.Background(), "not", SearchOptions{Workspace: workspaceRoot, Limit: 5}) + if err != nil { + t.Fatalf("Store.Search() error = %v", err) + } + if len(results) != 1 { + t.Fatalf("len(results) = %d, want 1; results=%#v", len(results), results) + } + if got, want := results[0].Filename, "operators.md"; got != want { + t.Fatalf("results[0].Filename = %q, want %q", got, want) + } +} + +func TestStoreMutationsStaySuccessfulWhenDerivedSyncFails(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + workspaceRoot := filepath.Join(baseDir, "workspace") + catalogPath := filepath.Join(baseDir, "catalog-dir") + if err := os.MkdirAll(catalogPath, dirPerm); err != nil { + t.Fatalf("os.MkdirAll(%q) error = %v", catalogPath, err) + } + + store := NewStore( + filepath.Join(baseDir, "global"), + WithCatalogDatabasePath(catalogPath), + ).ForWorkspace(workspaceRoot) + if err := store.EnsureDirs(); err != nil { + t.Fatalf("Store.EnsureDirs() error = %v", err) + } + + var logs bytes.Buffer + store.logger = slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelWarn})) + content := mustMemoryContent(t, testMemoryMeta{ + Name: "Prefs", + Description: "Saved preference", + Type: MemoryTypeUser, + }, "body\n") + + if err := store.Write(ScopeGlobal, "prefs.md", content); err != nil { + t.Fatalf("Store.Write() error = %v, want primary mutation to succeed", err) + } + if _, err := store.Read(ScopeGlobal, "prefs.md"); err != nil { + t.Fatalf("Store.Read() error = %v, want written file present", err) + } + if !strings.Contains(logs.String(), "sync derived state failed after mutation") { + t.Fatalf("logs = %q, want derived sync warning", logs.String()) + } + + logs.Reset() + if err := store.Delete(ScopeGlobal, "prefs.md"); err != nil { + t.Fatalf("Store.Delete() error = %v, want primary mutation to succeed", err) + } + if _, err := store.Read(ScopeGlobal, "prefs.md"); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("Store.Read(deleted) error = %v, want os.ErrNotExist", err) + } + if !strings.Contains(logs.String(), "sync derived state failed after mutation") { + t.Fatalf("logs = %q, want derived sync warning after delete", logs.String()) + } +} + func TestStoreEnsureDirs(t *testing.T) { t.Parallel() @@ -1096,18 +1234,45 @@ func mustMemoryContent(t *testing.T, meta testMemoryMeta, body string) []byte { func writeIndexFixtures(t *testing.T, dir string, indexContent string) { t.Helper() + baseModTime := time.Date(2026, 4, 3, 12, 0, 0, 0, time.UTC) for line := range strings.SplitSeq(indexContent, "\n") { - filename, ok := firstMarkdownLinkTarget(line) + filename, meta, ok := parseIndexFixture(line) if !ok { continue } - doc := mustMemoryContent(t, testMemoryMeta{ - Name: "Fixture", - Description: "desc", - Type: MemoryTypeUser, - }, "body\n") - if err := os.WriteFile(filepath.Join(dir, filename), doc, filePerm); err != nil { + path := filepath.Join(dir, filename) + doc := mustMemoryContent(t, meta, "body\n") + if err := os.WriteFile(path, doc, filePerm); err != nil { t.Fatalf("write fixture %q: %v", filename, err) } + if err := os.Chtimes(path, baseModTime, baseModTime); err != nil { + t.Fatalf("os.Chtimes(%q) error = %v", path, err) + } + } +} + +func parseIndexFixture(line string) (string, testMemoryMeta, bool) { + filename, ok := firstMarkdownLinkTarget(line) + if !ok { + return "", testMemoryMeta{}, false } + + labelStart := strings.Index(line, "[") + labelEnd := strings.Index(line, "](") + targetEnd := strings.LastIndex(line, ")") + if labelStart < 0 || labelEnd <= labelStart || targetEnd < 0 { + return "", testMemoryMeta{}, false + } + + name := strings.TrimSpace(line[labelStart+1 : labelEnd]) + description := "" + if targetEnd+1 < len(line) { + description = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line[targetEnd+1:]), "-")) + } + + return filename, testMemoryMeta{ + Name: name, + Description: description, + Type: MemoryTypeUser, + }, true } diff --git a/internal/session/manager_prompt.go b/internal/session/manager_prompt.go index 4bb48a477..dc216ebb2 100644 --- a/internal/session/manager_prompt.go +++ b/internal/session/manager_prompt.go @@ -96,6 +96,9 @@ func (m *Manager) PromptWithOpts(ctx context.Context, id string, opts PromptOpts if m.inputAugmenter != nil { augmented, augmentErr := m.inputAugmenter(ctx, session, message) if augmentErr != nil { + if errors.Is(augmentErr, context.Canceled) || errors.Is(augmentErr, context.DeadlineExceeded) { + return nil, fmt.Errorf("session: augment prompt input: %w", augmentErr) + } m.sessionLogger(session).Warn("session: prompt input augmentation failed", "error", augmentErr) } else if strings.TrimSpace(augmented) != "" { dispatchMessage = augmented diff --git a/internal/session/manager_test.go b/internal/session/manager_test.go index 00998e371..2f3b8572a 100644 --- a/internal/session/manager_test.go +++ b/internal/session/manager_test.go @@ -900,6 +900,29 @@ func TestPromptAugmenterPreservesStoredUserMessageAndAugmentsDriverDispatch(t *t } } +func TestPromptAugmenterPropagatesCancellationAndSkipsDriverDispatch(t *testing.T) { + t.Parallel() + + h := newHarness(t, WithPromptInputAugmenter(func( + _ context.Context, + _ *Session, + _ string, + ) (string, error) { + return "", context.Canceled + })) + session := createSession(t, h) + t.Cleanup(func() { + _ = h.manager.Stop(testutil.Context(t), session.ID) + }) + + if _, err := h.manager.Prompt(testutil.Context(t), session.ID, "remember me"); !errors.Is(err, context.Canceled) { + t.Fatalf("Prompt() error = %v, want context.Canceled", err) + } + if got := len(h.driver.promptCalls); got != 0 { + t.Fatalf("len(driver.promptCalls) = %d, want 0 after canceled augmentation", got) + } +} + func TestPromptWithOptsTracksTurnSourceAndClearsAfterPrompt(t *testing.T) { t.Parallel() diff --git a/internal/store/globaldb/global_db_test.go b/internal/store/globaldb/global_db_test.go index eb4beec69..5f4737d7b 100644 --- a/internal/store/globaldb/global_db_test.go +++ b/internal/store/globaldb/global_db_test.go @@ -1325,6 +1325,30 @@ func TestGlobalDBListEventSummariesIncludesMemoryOperations(t *testing.T) { if got, want := sessionOnly[0].Type, "agent_message"; got != want { t.Fatalf("sessionOnly[0].Type = %q, want %q", got, want) } + + if err := globalDB.WriteEventSummary(testutil.Context(t), EventSummary{ + SessionID: "sess-summary", + Type: "tool_call", + AgentName: "coder", + Summary: "tool executed", + Timestamp: time.Date(2026, 4, 3, 14, 2, 0, 0, time.UTC), + }); err != nil { + t.Fatalf("WriteEventSummary(second event) error = %v", err) + } + + limited, err := globalDB.ListEventSummaries(testutil.Context(t), EventSummaryQuery{Limit: 2}) + if err != nil { + t.Fatalf("ListEventSummaries(limit) error = %v", err) + } + if got, want := len(limited), 2; got != want { + t.Fatalf("len(limited) = %d, want %d", got, want) + } + if got, want := limited[0].Type, "memory.write"; got != want { + t.Fatalf("limited[0].Type = %q, want %q", got, want) + } + if got, want := limited[1].Type, "tool_call"; got != want { + t.Fatalf("limited[1].Type = %q, want %q", got, want) + } } func TestGlobalDBUpdateTokenStatsAggregation(t *testing.T) { diff --git a/internal/testutil/e2e/runtime_harness.go b/internal/testutil/e2e/runtime_harness.go index f29001c7d..0fb8f7c57 100644 --- a/internal/testutil/e2e/runtime_harness.go +++ b/internal/testutil/e2e/runtime_harness.go @@ -830,9 +830,14 @@ func (c *CLIClient) RunInDir(ctx context.Context, workdir string, args ...string // #nosec G204 -- test helper intentionally shells out to the current agh test binary. cmd := execabs.CommandContext(ctx, c.binaryPath, args...) cmd.Env = append([]string(nil), c.env...) - cmd.Dir = strings.TrimSpace(workdir) - if cmd.Dir == "" { + trimmedDir := strings.TrimSpace(workdir) + switch { + case trimmedDir == "": cmd.Dir = c.workdir + case filepath.IsAbs(trimmedDir): + cmd.Dir = trimmedDir + default: + cmd.Dir = filepath.Join(c.workdir, trimmedDir) } var stdout bytes.Buffer diff --git a/internal/testutil/e2e/runtime_harness_lifecycle_test.go b/internal/testutil/e2e/runtime_harness_lifecycle_test.go index 2b371ea27..f77f8543d 100644 --- a/internal/testutil/e2e/runtime_harness_lifecycle_test.go +++ b/internal/testutil/e2e/runtime_harness_lifecycle_test.go @@ -110,6 +110,29 @@ func TestRuntimeHarnessStopFallsBackToInterruptWhenCLIStopFails(t *testing.T) { } } +func TestCLIClientRunInDirResolvesRelativePathsAgainstBaseWorkdir(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + targetDir := filepath.Join(baseDir, "nested") + if err := os.MkdirAll(targetDir, 0o755); err != nil { + t.Fatalf("os.MkdirAll(%q) error = %v", targetDir, err) + } + + client := &CLIClient{ + binaryPath: writeCLIScript(t, "#!/bin/sh\npwd\n"), + workdir: baseDir, + } + + stdout, stderr, err := client.RunInDir(context.Background(), "nested", "ignored") + if err != nil { + t.Fatalf("RunInDir() error = %v; stderr=%s", err, strings.TrimSpace(stderr)) + } + if got, want := strings.TrimSpace(stdout), targetDir; got != want { + t.Fatalf("RunInDir() stdout = %q, want %q", got, want) + } +} + func TestRuntimeHelpersCoverRequestAndTimingUtilities(t *testing.T) { t.Parallel() From 99e2ce6aa122f789fb51b2c8dae87f51cc76ddf8 Mon Sep 17 00:00:00 2001 From: Pedro Nauck Date: Fri, 17 Apr 2026 22:05:52 -0300 Subject: [PATCH 3/8] fix: site adjustments --- .../tasks/mem-improvs/reviews-001/_meta.md | 11 ++++++ magefile.go | 21 ++++++++-- magefile_test.go | 39 +++++++++++++++++++ packages/site/app/global.css | 8 +--- packages/site/app/global.test.ts | 7 ++++ .../content/runtime/cli-reference/index.mdx | 2 - .../runtime/core/agents/definitions.mdx | 2 - .../content/runtime/core/agents/providers.mdx | 2 - .../content/runtime/core/agents/spawning.mdx | 2 - .../content/runtime/core/automation/jobs.mdx | 2 - .../runtime/core/automation/triggers.mdx | 2 - .../runtime/core/automation/webhooks.mdx | 2 - .../content/runtime/core/bridges/overview.mdx | 2 - .../content/runtime/core/bridges/routing.mdx | 2 - .../content/runtime/core/bridges/setup.mdx | 2 - .../runtime/core/configuration/agent-md.mdx | 2 - .../core/configuration/config-toml.mdx | 2 - .../runtime/core/configuration/env-vars.mdx | 2 - .../core/configuration/file-locations.mdx | 2 - .../runtime/core/configuration/mcp-json.mdx | 2 - .../runtime/core/configuration/skill-md.mdx | 2 - .../runtime/core/extensions/develop.mdx | 2 - .../runtime/core/extensions/install.mdx | 2 - .../core/getting-started/first-agent.mdx | 2 - .../core/getting-started/installation.mdx | 2 - .../core/getting-started/quick-start.mdx | 2 - .../runtime/core/getting-started/web-ui.mdx | 2 - .../runtime/core/hooks/declaration.mdx | 2 - .../runtime/core/hooks/event-catalog.mdx | 2 - .../runtime/core/memory/best-practices.mdx | 2 - .../content/runtime/core/memory/dream.mdx | 2 - .../content/runtime/core/memory/scopes.mdx | 2 - .../content/runtime/core/memory/system.mdx | 2 - .../runtime/core/operations/daemon.mdx | 2 - .../runtime/core/operations/database.mdx | 2 - .../runtime/core/operations/production.mdx | 2 - .../core/operations/troubleshooting.mdx | 2 - .../runtime/core/overview/architecture.mdx | 2 - .../runtime/core/overview/comparison.mdx | 2 - .../runtime/core/overview/what-is-agh.mdx | 2 - .../content/runtime/core/sessions/events.mdx | 2 - .../runtime/core/sessions/lifecycle.mdx | 2 - .../runtime/core/sessions/permissions.mdx | 2 - .../content/runtime/core/sessions/resume.mdx | 2 - .../content/runtime/core/skills/bundled.mdx | 2 - .../runtime/core/skills/marketplace.mdx | 2 - .../content/runtime/core/skills/overview.mdx | 2 - .../content/runtime/core/skills/skill-md.mdx | 2 - .../core/workspaces/config-overlays.mdx | 2 - .../runtime/core/workspaces/multi-root.mdx | 2 - .../runtime/core/workspaces/resolver.mdx | 2 - packages/site/source.config.ts | 11 +++++- .../design-system-showcase.stories.tsx | 1 - .../stories/metric-strip.stories.tsx | 1 - .../stories/page-content.stories.tsx | 1 - .../design-system/stories/panel.stories.tsx | 1 - .../design-system/stories/pill.stories.tsx | 1 - .../stories/section-heading.stories.tsx | 1 - .../stories/status-dot.stories.tsx | 1 - .../stories/texture-canvas.stories.tsx | 1 - .../design-system/stories/toolbar.stories.tsx | 1 - 61 files changed, 86 insertions(+), 112 deletions(-) create mode 100644 .compozy/tasks/mem-improvs/reviews-001/_meta.md diff --git a/.compozy/tasks/mem-improvs/reviews-001/_meta.md b/.compozy/tasks/mem-improvs/reviews-001/_meta.md new file mode 100644 index 000000000..4a7fa5f3d --- /dev/null +++ b/.compozy/tasks/mem-improvs/reviews-001/_meta.md @@ -0,0 +1,11 @@ +--- +provider: coderabbit +pr: "35" +round: 1 +created_at: 2026-04-18T00:23:26.966343Z +--- + +## Summary +- Total: 13 +- Resolved: 0 +- Unresolved: 13 diff --git a/magefile.go b/magefile.go index 2aeeeedc9..93a3d0da3 100644 --- a/magefile.go +++ b/magefile.go @@ -83,7 +83,8 @@ func Test() error { if err := ensureWebBundle(); err != nil { return err } - return sh.RunV("go", "run", "gotest.tools/gotestsum@latest", + return runRaceEnabledGoCommand(nil, + "run", "gotest.tools/gotestsum@latest", "--format", "pkgname", "--", "-race", "-parallel=4", "./...") } @@ -92,7 +93,8 @@ func TestIntegration() error { if err := ensureWebBundle(); err != nil { return err } - return sh.RunV("go", "run", "gotest.tools/gotestsum@latest", + return runRaceEnabledGoCommand(nil, + "run", "gotest.tools/gotestsum@latest", "--format", "pkgname", "--", "-race", "-parallel=4", "-tags", "integration", "./...") } @@ -489,7 +491,7 @@ func runIntegrationSuite(suite e2elane.GoSuite, env map[string]string) error { args = append(args, "-run", suite.Run) } args = append(args, suite.Packages...) - return runCommandInDirWithEnv(".", env, "go", args...) + return runRaceEnabledGoCommand(env, args...) } func runCommandInDir(dir string, name string, args ...string) error { @@ -527,6 +529,19 @@ func mergeCommandEnv(overrides map[string]string) []string { return env } +func runRaceEnabledGoCommand(env map[string]string, args ...string) error { + return runCommandInDirWithEnv(".", withRaceEnabledEnv(env), "go", args...) +} + +func withRaceEnabledEnv(overrides map[string]string) map[string]string { + env := make(map[string]string, len(overrides)+1) + for key, value := range overrides { + env[key] = value + } + env["CGO_ENABLED"] = "1" + return env +} + func checkGeneratedFile(path string, want []byte) error { got, err := os.ReadFile(path) if err != nil { diff --git a/magefile_test.go b/magefile_test.go index 204bbe50b..d00dd3314 100644 --- a/magefile_test.go +++ b/magefile_test.go @@ -50,3 +50,42 @@ func TestShouldEnsureWebBundle(t *testing.T) { }) } } + +func TestWithRaceEnabledEnv(t *testing.T) { + t.Parallel() + + t.Run("sets cgo for race commands without mutating the input", func(t *testing.T) { + t.Parallel() + + overrides := map[string]string{ + "CI": "true", + "CGO_ENABLED": "0", + } + + got := withRaceEnabledEnv(overrides) + + if got["CGO_ENABLED"] != "1" { + t.Fatalf("withRaceEnabledEnv() CGO_ENABLED = %q, want %q", got["CGO_ENABLED"], "1") + } + if got["CI"] != "true" { + t.Fatalf("withRaceEnabledEnv() CI = %q, want %q", got["CI"], "true") + } + if overrides["CGO_ENABLED"] != "0" { + t.Fatalf("withRaceEnabledEnv() mutated input CGO_ENABLED to %q", overrides["CGO_ENABLED"]) + } + + got["EXTRA"] = "value" + if _, ok := overrides["EXTRA"]; ok { + t.Fatal("withRaceEnabledEnv() reused the input map") + } + }) + + t.Run("works with nil input", func(t *testing.T) { + t.Parallel() + + got := withRaceEnabledEnv(nil) + if got["CGO_ENABLED"] != "1" { + t.Fatalf("withRaceEnabledEnv(nil) CGO_ENABLED = %q, want %q", got["CGO_ENABLED"], "1") + } + }) +} diff --git a/packages/site/app/global.css b/packages/site/app/global.css index a2726d091..9a547617f 100644 --- a/packages/site/app/global.css +++ b/packages/site/app/global.css @@ -182,7 +182,7 @@ a { color: var(--color-text-primary); } -.site-doc-body code { +.site-doc-body :is(p, li, td, th, blockquote, h1, h2, h3, h4, h5, h6) code { border: 1px solid var(--color-divider); border-radius: 0.55rem; background: rgba(44, 44, 46, 0.78); @@ -191,12 +191,6 @@ a { color: var(--color-text-primary); } -.site-doc-body pre { - border: 1px solid var(--color-divider); - border-radius: 1rem; - background: var(--color-surface) !important; -} - .site-doc-body hr { border-color: var(--color-divider); } diff --git a/packages/site/app/global.test.ts b/packages/site/app/global.test.ts index c1eb03ac7..302806067 100644 --- a/packages/site/app/global.test.ts +++ b/packages/site/app/global.test.ts @@ -15,4 +15,11 @@ describe("site global styles", () => { it("does not globally suppress box-shadow-based focus rings", () => { expect(globalCSS).not.toContain("box-shadow: none !important;"); }); + + it("limits inline code chrome to textual content instead of fenced code blocks", () => { + expect(globalCSS).toContain( + ".site-doc-body :is(p, li, td, th, blockquote, h1, h2, h3, h4, h5, h6) code {" + ); + expect(globalCSS).not.toContain(".site-doc-body :not(pre) > code {"); + }); }); diff --git a/packages/site/content/runtime/cli-reference/index.mdx b/packages/site/content/runtime/cli-reference/index.mdx index e61a5f5fe..8d44b66d3 100644 --- a/packages/site/content/runtime/cli-reference/index.mdx +++ b/packages/site/content/runtime/cli-reference/index.mdx @@ -3,8 +3,6 @@ title: CLI Reference description: Command tree, global options, configuration inputs, and common AGH CLI workflows. --- -# CLI Reference - The `agh` CLI is the operator surface for the local AGH daemon. Use it to bootstrap the home directory, start the daemon, create durable sessions, inspect workspaces, manage skills and extensions, and query runtime state over the daemon socket. diff --git a/packages/site/content/runtime/core/agents/definitions.mdx b/packages/site/content/runtime/core/agents/definitions.mdx index 1db7d723b..2167eccc2 100644 --- a/packages/site/content/runtime/core/agents/definitions.mdx +++ b/packages/site/content/runtime/core/agents/definitions.mdx @@ -3,8 +3,6 @@ title: Agent Definitions description: The current AGENT.md format, discovery order, runtime defaults, and parser edge cases used by AGH. --- -# Agent Definitions - An agent definition tells AGH which agent to start and what startup prompt to give it. The definition is a directory with an `AGENT.md` file: diff --git a/packages/site/content/runtime/core/agents/providers.mdx b/packages/site/content/runtime/core/agents/providers.mdx index f00487246..c2ddf996f 100644 --- a/packages/site/content/runtime/core/agents/providers.mdx +++ b/packages/site/content/runtime/core/agents/providers.mdx @@ -3,8 +3,6 @@ title: Providers description: Built-in provider IDs, launch commands, config overrides, and custom provider setup for AGH agents. --- -# Providers - A provider is the ACP-compatible command AGH launches for an agent. The provider owns the actual LLM integration. AGH owns definition resolution, process lifecycle, workspace boundaries, permissions, MCP server wiring, and event persistence. diff --git a/packages/site/content/runtime/core/agents/spawning.mdx b/packages/site/content/runtime/core/agents/spawning.mdx index 77ab2b3a3..55cdc0a3c 100644 --- a/packages/site/content/runtime/core/agents/spawning.mdx +++ b/packages/site/content/runtime/core/agents/spawning.mdx @@ -3,8 +3,6 @@ title: Spawning description: How AGH resolves an agent definition into an ACP subprocess, negotiates a session, injects environment, and stops the process. --- -# Spawning - Spawning is the handoff from an AGH agent definition to a live ACP subprocess. It starts when a session is created or resumed, and it ends when the subprocess has completed ACP initialization and the AGH session becomes active. diff --git a/packages/site/content/runtime/core/automation/jobs.mdx b/packages/site/content/runtime/core/automation/jobs.mdx index 9be2846c3..a01d56c80 100644 --- a/packages/site/content/runtime/core/automation/jobs.mdx +++ b/packages/site/content/runtime/core/automation/jobs.mdx @@ -3,8 +3,6 @@ title: Jobs and Scheduling description: Define scheduled AGH automation jobs with cron, interval, and one-time schedules, then monitor runs and retries. --- -# Jobs and Scheduling - Jobs run agents without an interactive operator. A job answers three questions: - which agent should run diff --git a/packages/site/content/runtime/core/automation/triggers.mdx b/packages/site/content/runtime/core/automation/triggers.mdx index c0d8501d0..91b15c7d7 100644 --- a/packages/site/content/runtime/core/automation/triggers.mdx +++ b/packages/site/content/runtime/core/automation/triggers.mdx @@ -3,8 +3,6 @@ title: Triggers description: Configure AGH automation triggers that react to runtime events, filters, and prompt templates. --- -# Triggers - Triggers run agents in response to events. They are useful when the next agent run should depend on something that happened inside AGH, such as a session stopping, a memory consolidation completing, or a webhook delivery arriving. diff --git a/packages/site/content/runtime/core/automation/webhooks.mdx b/packages/site/content/runtime/core/automation/webhooks.mdx index 6ef0b57eb..ba8a41343 100644 --- a/packages/site/content/runtime/core/automation/webhooks.mdx +++ b/packages/site/content/runtime/core/automation/webhooks.mdx @@ -3,8 +3,6 @@ title: Webhooks description: Set up signed AGH webhook triggers for CI, deployment, and external event integrations. --- -# Webhooks - Webhooks are the external ingress path for automation. They accept signed HTTP requests, normalize the request into a `webhook` activation envelope, and dispatch every enabled webhook trigger that matches the endpoint, scope, workspace, and filters. diff --git a/packages/site/content/runtime/core/bridges/overview.mdx b/packages/site/content/runtime/core/bridges/overview.mdx index e1787de84..f3f0953f0 100644 --- a/packages/site/content/runtime/core/bridges/overview.mdx +++ b/packages/site/content/runtime/core/bridges/overview.mdx @@ -3,8 +3,6 @@ title: Bridges Overview description: How AGH bridge instances connect workspace agents to external messaging platforms. --- -# Bridges Overview - A bridge is a workspace-facing adapter between AGH sessions and a messaging platform. The platform can be Slack, Discord, Telegram, or any installed extension that advertises the `bridge.adapter` capability. The bridge instance stores the operator-owned configuration; the provider extension owns diff --git a/packages/site/content/runtime/core/bridges/routing.mdx b/packages/site/content/runtime/core/bridges/routing.mdx index 036194e9a..d54eb9917 100644 --- a/packages/site/content/runtime/core/bridges/routing.mdx +++ b/packages/site/content/runtime/core/bridges/routing.mdx @@ -3,8 +3,6 @@ title: Bridge Routing description: How bridge routing policies map platform messages to AGH sessions and delivery targets. --- -# Bridge Routing - Bridge routing is the boundary between platform identity and AGH session identity. An adapter sends AGH a normalized inbound envelope with platform dimensions such as peer, group, and thread. AGH then uses the bridge instance routing policy to decide which dimensions belong to the canonical route key. diff --git a/packages/site/content/runtime/core/bridges/setup.mdx b/packages/site/content/runtime/core/bridges/setup.mdx index 2f2c257b0..7485e3654 100644 --- a/packages/site/content/runtime/core/bridges/setup.mdx +++ b/packages/site/content/runtime/core/bridges/setup.mdx @@ -3,8 +3,6 @@ title: Bridge Setup description: Step-by-step setup for Slack, Discord, and Telegram bridge providers. --- -# Bridge Setup - Set up a bridge in three layers: 1. Install the provider extension so AGH can list it in `/api/bridges/providers`. diff --git a/packages/site/content/runtime/core/configuration/agent-md.mdx b/packages/site/content/runtime/core/configuration/agent-md.mdx index 579877a6d..2fa087ef4 100644 --- a/packages/site/content/runtime/core/configuration/agent-md.mdx +++ b/packages/site/content/runtime/core/configuration/agent-md.mdx @@ -4,8 +4,6 @@ description: Complete implemented AGENT.md schema, parser rules, provider exampl icon: FileText --- -# AGENT.md Reference - `AGENT.md` defines an AGH agent: structured frontmatter plus a required Markdown prompt body. Current AGH agents live in AGH resource directories, not in the draft RFC `.agents` directory: diff --git a/packages/site/content/runtime/core/configuration/config-toml.mdx b/packages/site/content/runtime/core/configuration/config-toml.mdx index c54998000..24ad035aa 100644 --- a/packages/site/content/runtime/core/configuration/config-toml.mdx +++ b/packages/site/content/runtime/core/configuration/config-toml.mdx @@ -4,8 +4,6 @@ description: Complete AGH config.toml schema, defaults, valid values, merge beha icon: Settings --- -# config.toml Reference - `config.toml` is the main AGH runtime configuration file. AGH loads the built-in defaults, then overlays global and workspace files when they exist. diff --git a/packages/site/content/runtime/core/configuration/env-vars.mdx b/packages/site/content/runtime/core/configuration/env-vars.mdx index 62a02317f..6507705a6 100644 --- a/packages/site/content/runtime/core/configuration/env-vars.mdx +++ b/packages/site/content/runtime/core/configuration/env-vars.mdx @@ -4,8 +4,6 @@ description: Environment variables AGH reads, injects, propagates, and explicitl icon: Terminal --- -# Environment Variables Reference - AGH reads a small core set of environment variables, injects session identity into managed agent subprocesses, and lets some extension providers use process-level overrides. diff --git a/packages/site/content/runtime/core/configuration/file-locations.mdx b/packages/site/content/runtime/core/configuration/file-locations.mdx index 9881698b9..2c63fda03 100644 --- a/packages/site/content/runtime/core/configuration/file-locations.mdx +++ b/packages/site/content/runtime/core/configuration/file-locations.mdx @@ -4,8 +4,6 @@ description: All AGH global, workspace, agent, skill, database, log, socket, mem icon: Layers --- -# File Locations Reference - AGH uses one global home directory plus optional workspace-local `.agh/` directories. The global home defaults to `~/.agh` and can be changed with `AGH_HOME`. diff --git a/packages/site/content/runtime/core/configuration/mcp-json.mdx b/packages/site/content/runtime/core/configuration/mcp-json.mdx index 665db8285..3373671c7 100644 --- a/packages/site/content/runtime/core/configuration/mcp-json.mdx +++ b/packages/site/content/runtime/core/configuration/mcp-json.mdx @@ -4,8 +4,6 @@ description: Complete AGH mcp.json sidecar schema, supported locations, validati icon: FileCode --- -# mcp.json Reference - `mcp.json` is an optional JSON sidecar for MCP server declarations. AGH accepts it at global, workspace, agent, and skill scopes. diff --git a/packages/site/content/runtime/core/configuration/skill-md.mdx b/packages/site/content/runtime/core/configuration/skill-md.mdx index 8ff8fbce4..6d16ed9a0 100644 --- a/packages/site/content/runtime/core/configuration/skill-md.mdx +++ b/packages/site/content/runtime/core/configuration/skill-md.mdx @@ -4,8 +4,6 @@ description: Complete implemented SKILL.md schema, AGH metadata, sidecar files, icon: Book --- -# SKILL.md Reference - `SKILL.md` is the portable skill file AGH loads into the skill catalog. A skill directory contains the file, optional supporting resources, and optional sidecars: diff --git a/packages/site/content/runtime/core/extensions/develop.mdx b/packages/site/content/runtime/core/extensions/develop.mdx index f7d4723c4..4560c7999 100644 --- a/packages/site/content/runtime/core/extensions/develop.mdx +++ b/packages/site/content/runtime/core/extensions/develop.mdx @@ -3,8 +3,6 @@ title: Develop Extensions description: Build custom AGH extensions with manifests, bundled resources, subprocess lifecycle, Host API permissions, and package examples. --- -# Develop Extensions - An AGH extension is a directory with a manifest and optional runtime code. Resource-only extensions package files AGH already knows how to load. Subprocess extensions run code over JSON-RPC and can call the daemon through the Host API after capability checks. diff --git a/packages/site/content/runtime/core/extensions/install.mdx b/packages/site/content/runtime/core/extensions/install.mdx index d0caddc57..f8d52f1f1 100644 --- a/packages/site/content/runtime/core/extensions/install.mdx +++ b/packages/site/content/runtime/core/extensions/install.mdx @@ -3,8 +3,6 @@ title: Install Extensions description: Install, discover, enable, disable, update, and inspect AGH extensions from local directories or configured registries. --- -# Install Extensions - Extensions are installable packages that add static resources or runtime capabilities to AGH. An extension can bundle skills, agent definitions, hooks, bundles, MCP servers, bridge adapters, memory backends, and subprocess services. diff --git a/packages/site/content/runtime/core/getting-started/first-agent.mdx b/packages/site/content/runtime/core/getting-started/first-agent.mdx index 160c13fa0..0e9d93338 100644 --- a/packages/site/content/runtime/core/getting-started/first-agent.mdx +++ b/packages/site/content/runtime/core/getting-started/first-agent.mdx @@ -3,8 +3,6 @@ title: Your First Custom Agent description: Create a custom agent with its own system prompt, provider configuration, and tool permissions using an AGENT.md definition file. --- -# Your First Custom Agent - In the [Quick Start](/runtime/core/getting-started/quick-start), you used the built-in `general` agent. In this tutorial, you will create a custom agent with a focused system prompt, specific tools, and its own provider settings. By the end, you will have a working `code-reviewer` agent that you can use in any session. **Before you begin:** Make sure the daemon is running (`agh daemon start`). diff --git a/packages/site/content/runtime/core/getting-started/installation.mdx b/packages/site/content/runtime/core/getting-started/installation.mdx index 54fdae4f5..7947afa2b 100644 --- a/packages/site/content/runtime/core/getting-started/installation.mdx +++ b/packages/site/content/runtime/core/getting-started/installation.mdx @@ -3,8 +3,6 @@ title: Installation description: Install the AGH binary on your machine using Go, Homebrew, or a pre-built binary download, then verify the installation is working. --- -# Installation - This page walks you through installing AGH and verifying it works. By the end, you will have the `agh` binary on your path and ready to use. ## Prerequisites diff --git a/packages/site/content/runtime/core/getting-started/quick-start.mdx b/packages/site/content/runtime/core/getting-started/quick-start.mdx index 8f037a217..00817450f 100644 --- a/packages/site/content/runtime/core/getting-started/quick-start.mdx +++ b/packages/site/content/runtime/core/getting-started/quick-start.mdx @@ -3,8 +3,6 @@ title: Quick Start description: Start the AGH daemon, create your first agent session, send a prompt, and watch events stream back — all in under five minutes. --- -# Quick Start - This tutorial takes you from a fresh AGH install to a running agent session in under five minutes. You will start the daemon, create a session, send a prompt, and observe the events it generates. **Before you begin:** Make sure you have completed the [Installation](/runtime/core/getting-started/installation) page, including running `agh install` to bootstrap your configuration. diff --git a/packages/site/content/runtime/core/getting-started/web-ui.mdx b/packages/site/content/runtime/core/getting-started/web-ui.mdx index f3c07b79f..f4eaf4940 100644 --- a/packages/site/content/runtime/core/getting-started/web-ui.mdx +++ b/packages/site/content/runtime/core/getting-started/web-ui.mdx @@ -3,8 +3,6 @@ title: Web UI description: Open the AGH web interface, navigate sessions, view real-time events, and understand the dashboard — all from your browser. --- -# Web UI - AGH includes a web interface that lets you manage sessions and watch agent activity in real time. The web UI connects to the same daemon as the CLI — everything you see in the browser reflects the same state as `agh session list` and `agh session events`. **Before you begin:** Make sure the daemon is running (`agh daemon start`). diff --git a/packages/site/content/runtime/core/hooks/declaration.mdx b/packages/site/content/runtime/core/hooks/declaration.mdx index 15d2fa200..a5b2a0b0e 100644 --- a/packages/site/content/runtime/core/hooks/declaration.mdx +++ b/packages/site/content/runtime/core/hooks/declaration.mdx @@ -3,8 +3,6 @@ title: Hook Declaration Format description: Reference for declaring AGH hooks, matchers, subprocess executors, ordering, and runtime execution behavior. --- -# Hook Declaration Format - A hook declaration binds one event to one executor. The matcher decides whether the hook is eligible; the executor receives the payload and writes a patch. AGH normalizes declarations from config files, agent definitions, skills, and extension manifests into one deterministic pipeline. diff --git a/packages/site/content/runtime/core/hooks/event-catalog.mdx b/packages/site/content/runtime/core/hooks/event-catalog.mdx index aaba54089..ac825aa27 100644 --- a/packages/site/content/runtime/core/hooks/event-catalog.mdx +++ b/packages/site/content/runtime/core/hooks/event-catalog.mdx @@ -3,8 +3,6 @@ title: Hook Event Catalog description: Complete reference for AGH hook events, payload schemas, patch schemas, sync eligibility, and example payloads. --- -# Hook Event Catalog - AGH hooks fire at named runtime points such as session creation, prompt assembly, tool execution, permission decisions, automation runs, and context compaction. Each event has a fixed payload type and a fixed patch type. A hook reads the payload from stdin, decides whether it applies, and writes a diff --git a/packages/site/content/runtime/core/memory/best-practices.mdx b/packages/site/content/runtime/core/memory/best-practices.mdx index 6a66dca6f..3b8e57ec5 100644 --- a/packages/site/content/runtime/core/memory/best-practices.mdx +++ b/packages/site/content/runtime/core/memory/best-practices.mdx @@ -3,8 +3,6 @@ title: Memory Best Practices description: Practical rules for writing useful AGH memories, keeping indexes readable, and avoiding context bloat. --- -# Memory Best Practices - Good memory is small, durable, and easy to verify. Bad memory is a transcript dump with a filename. Use memory when a future session should benefit from a fact without rediscovering it. Do not use diff --git a/packages/site/content/runtime/core/memory/dream.mdx b/packages/site/content/runtime/core/memory/dream.mdx index 42b10476e..7c8b177a4 100644 --- a/packages/site/content/runtime/core/memory/dream.mdx +++ b/packages/site/content/runtime/core/memory/dream.mdx @@ -3,8 +3,6 @@ title: Dream Consolidation description: How AGH decides when to run memory consolidation and what a dream session does to keep memory useful. --- -# Dream Consolidation - Dream consolidation is AGH's memory cleanup loop. It starts a one-shot dream session that reviews recent session history and existing memory files, then tightens the persistent memory store. diff --git a/packages/site/content/runtime/core/memory/scopes.mdx b/packages/site/content/runtime/core/memory/scopes.mdx index 5c07b0cf5..f88a8e137 100644 --- a/packages/site/content/runtime/core/memory/scopes.mdx +++ b/packages/site/content/runtime/core/memory/scopes.mdx @@ -3,8 +3,6 @@ title: Global vs Workspace Memory description: How AGH resolves memory scopes, where files live, and when to store context globally or per workspace. --- -# Global vs Workspace Memory - AGH memory has two implemented scopes: - **global**: user-wide context available across workspaces diff --git a/packages/site/content/runtime/core/memory/system.mdx b/packages/site/content/runtime/core/memory/system.mdx index 8ce4b1300..b50e2c9d3 100644 --- a/packages/site/content/runtime/core/memory/system.mdx +++ b/packages/site/content/runtime/core/memory/system.mdx @@ -3,8 +3,6 @@ title: Memory System description: How AGH stores persistent memory, injects memory indexes into prompts, and exposes memory files through CLI and API surfaces. --- -# Memory System - AGH memory is persistent Markdown that survives across sessions. It is designed for durable facts that future agents should be able to discover without replaying every old transcript. diff --git a/packages/site/content/runtime/core/operations/daemon.mdx b/packages/site/content/runtime/core/operations/daemon.mdx index d987d5e5e..b21db8cc5 100644 --- a/packages/site/content/runtime/core/operations/daemon.mdx +++ b/packages/site/content/runtime/core/operations/daemon.mdx @@ -3,8 +3,6 @@ title: Daemon Operations description: Start, stop, restart, inspect, and supervise the AGH daemon. --- -# Daemon Operations - Use this guide when you need to run AGH as a long-lived daemon, inspect whether it is healthy, or install it under a service manager. diff --git a/packages/site/content/runtime/core/operations/database.mdx b/packages/site/content/runtime/core/operations/database.mdx index 32b60c99c..fb1b2976e 100644 --- a/packages/site/content/runtime/core/operations/database.mdx +++ b/packages/site/content/runtime/core/operations/database.mdx @@ -3,8 +3,6 @@ title: Database Operations description: Back up, inspect, and clean up AGH's SQLite databases. --- -# Database Operations - Use this guide when you need to back up AGH state, inspect stored sessions, or retire old session directories. diff --git a/packages/site/content/runtime/core/operations/production.mdx b/packages/site/content/runtime/core/operations/production.mdx index a01e28a55..c664243d8 100644 --- a/packages/site/content/runtime/core/operations/production.mdx +++ b/packages/site/content/runtime/core/operations/production.mdx @@ -3,8 +3,6 @@ title: Production Checklist description: Prepare AGH for persistent unattended operation with clear pass and fail checks. --- -# Production Checklist - Use this checklist before running AGH as a persistent daemon for real work. It is written for local or self-managed production-like environments where one service user owns one `AGH_HOME`. diff --git a/packages/site/content/runtime/core/operations/troubleshooting.mdx b/packages/site/content/runtime/core/operations/troubleshooting.mdx index 3002dae27..ac15f7206 100644 --- a/packages/site/content/runtime/core/operations/troubleshooting.mdx +++ b/packages/site/content/runtime/core/operations/troubleshooting.mdx @@ -3,8 +3,6 @@ title: Troubleshooting description: Diagnose and resolve common AGH daemon, socket, agent, session, and database issues. --- -# Troubleshooting - Use this guide when an operational command fails or the daemon is not behaving as expected. Each entry has symptoms, diagnosis, and a resolution path. diff --git a/packages/site/content/runtime/core/overview/architecture.mdx b/packages/site/content/runtime/core/overview/architecture.mdx index eb415a055..7b07bf5d0 100644 --- a/packages/site/content/runtime/core/overview/architecture.mdx +++ b/packages/site/content/runtime/core/overview/architecture.mdx @@ -3,8 +3,6 @@ title: Architecture description: How AGH is structured internally — the daemon composition root, package boundaries, data flow, and the principles that keep the system extensible. --- -# Architecture - AGH follows a **pragmatic flat architecture** with strict dependency boundaries. A single `daemon` package acts as the composition root, wiring together independent packages that never import each other. This design keeps the system easy to reason about while enabling incremental extension — new capabilities arrive as new packages, not as modifications to existing ones. ## System overview diff --git a/packages/site/content/runtime/core/overview/comparison.mdx b/packages/site/content/runtime/core/overview/comparison.mdx index 4f162054c..a0209b1a4 100644 --- a/packages/site/content/runtime/core/overview/comparison.mdx +++ b/packages/site/content/runtime/core/overview/comparison.mdx @@ -3,8 +3,6 @@ title: Why AGH description: How AGH compares with OpenClaw, OpenFang, GoClaw, agent frameworks, and raw MCP. --- -# Why AGH - The AI agent ecosystem is no longer short on products. What matters is understanding which operating model each one is optimizing for and why AGH exists as a different surface. diff --git a/packages/site/content/runtime/core/overview/what-is-agh.mdx b/packages/site/content/runtime/core/overview/what-is-agh.mdx index a02e08866..b540eece1 100644 --- a/packages/site/content/runtime/core/overview/what-is-agh.mdx +++ b/packages/site/content/runtime/core/overview/what-is-agh.mdx @@ -3,8 +3,6 @@ title: What is AGH description: AGH Runtime is a local-first control plane for running real agent CLIs as durable work. --- -# What is AGH - AGH is the runtime you reach for when one-off terminal sessions stop being enough. It takes the agent CLIs you already trust — Claude Code, Codex, Gemini CLI, and others — and turns them into durable work with replayable history, workspace-aware memory, automation, and one operator surface diff --git a/packages/site/content/runtime/core/sessions/events.mdx b/packages/site/content/runtime/core/sessions/events.mdx index de92f5224..21c09b3c6 100644 --- a/packages/site/content/runtime/core/sessions/events.mdx +++ b/packages/site/content/runtime/core/sessions/events.mdx @@ -3,8 +3,6 @@ title: Event Streaming description: How AGH records session events, streams them over SSE, and persists them in per-session SQLite databases. --- -# Event Streaming - Events are the durable record of a session. They drive: - live session views in clients diff --git a/packages/site/content/runtime/core/sessions/lifecycle.mdx b/packages/site/content/runtime/core/sessions/lifecycle.mdx index 0f351c9d2..c6cc7745b 100644 --- a/packages/site/content/runtime/core/sessions/lifecycle.mdx +++ b/packages/site/content/runtime/core/sessions/lifecycle.mdx @@ -3,8 +3,6 @@ title: Session Lifecycle description: How AGH creates, activates, stops, and classifies one durable runtime session. --- -# Session Lifecycle - A session is the runtime object AGH manages. It ties together: - one ACP-compatible agent subprocess diff --git a/packages/site/content/runtime/core/sessions/permissions.mdx b/packages/site/content/runtime/core/sessions/permissions.mdx index a9a014309..21659229e 100644 --- a/packages/site/content/runtime/core/sessions/permissions.mdx +++ b/packages/site/content/runtime/core/sessions/permissions.mdx @@ -3,8 +3,6 @@ title: Permissions description: How AGH applies static permission modes, workspace boundaries, and interactive approvals during session execution. --- -# Permissions - AGH applies permissions in two layers: 1. a daemon-side static policy for built-in ACP tool surfaces diff --git a/packages/site/content/runtime/core/sessions/resume.mdx b/packages/site/content/runtime/core/sessions/resume.mdx index 015eff0b4..2420c959a 100644 --- a/packages/site/content/runtime/core/sessions/resume.mdx +++ b/packages/site/content/runtime/core/sessions/resume.mdx @@ -3,8 +3,6 @@ title: Resume and Replay description: How AGH resumes stopped sessions, reconstructs transcripts from events, and falls back when ACP session resources are gone. --- -# Resume and Replay - Resume and replay are related, but they are not the same thing in AGH. - **Resume** tries to restart the session as live agent work. diff --git a/packages/site/content/runtime/core/skills/bundled.mdx b/packages/site/content/runtime/core/skills/bundled.mdx index 72c1e1dbf..ee35bd065 100644 --- a/packages/site/content/runtime/core/skills/bundled.mdx +++ b/packages/site/content/runtime/core/skills/bundled.mdx @@ -3,8 +3,6 @@ title: Bundled Skills description: The skills compiled into AGH, when to use them, and how bundled loading works. --- -# Bundled Skills - Bundled skills ship inside the AGH binary. They are the lowest-precedence source in the skills hierarchy and are available whenever `skills.enabled` is true. diff --git a/packages/site/content/runtime/core/skills/marketplace.mdx b/packages/site/content/runtime/core/skills/marketplace.mdx index cd5133800..4987d1d17 100644 --- a/packages/site/content/runtime/core/skills/marketplace.mdx +++ b/packages/site/content/runtime/core/skills/marketplace.mdx @@ -3,8 +3,6 @@ title: Marketplace description: How to discover, install, update, and remove marketplace skills with the current AGH CLI. --- -# Marketplace - AGH can install community skills from a marketplace registry into the same local skill hierarchy as manual skills. The default registry adapter is ClawHub. diff --git a/packages/site/content/runtime/core/skills/overview.mdx b/packages/site/content/runtime/core/skills/overview.mdx index a29b15e0c..9d2bc3795 100644 --- a/packages/site/content/runtime/core/skills/overview.mdx +++ b/packages/site/content/runtime/core/skills/overview.mdx @@ -3,8 +3,6 @@ title: Skills Overview description: How AGH discovers skills, resolves precedence, injects the prompt catalog, and connects skills to MCP servers. --- -# Skills Overview - Skills are reusable instruction sets for AGH agents. A skill is a directory with a `SKILL.md` file, optional resource files, and optional MCP sidecar configuration. AGH loads skill metadata at runtime, injects a compact catalog into the startup prompt, and lets the agent read the full skill diff --git a/packages/site/content/runtime/core/skills/skill-md.mdx b/packages/site/content/runtime/core/skills/skill-md.mdx index 61c92e3be..61103eabc 100644 --- a/packages/site/content/runtime/core/skills/skill-md.mdx +++ b/packages/site/content/runtime/core/skills/skill-md.mdx @@ -3,8 +3,6 @@ title: SKILL.md Format description: The accepted SKILL.md frontmatter schema, Markdown body rules, MCP sidecars, hooks, and complete examples. --- -# SKILL.md Format - `SKILL.md` is the authoring contract for AGH skills. The file starts with YAML frontmatter, then a Markdown body with procedural instructions for the agent. diff --git a/packages/site/content/runtime/core/workspaces/config-overlays.mdx b/packages/site/content/runtime/core/workspaces/config-overlays.mdx index 9e4e4e169..266914f8a 100644 --- a/packages/site/content/runtime/core/workspaces/config-overlays.mdx +++ b/packages/site/content/runtime/core/workspaces/config-overlays.mdx @@ -3,8 +3,6 @@ title: Workspace Config Overlays description: How workspace config.toml and mcp.json layer over global AGH configuration without making project copies brittle. --- -# Workspace Config Overlays - Workspace config lets one project change runtime behavior without changing every AGH session on the machine. The loader starts from built-in defaults, applies global config, then applies the resolved workspace root overlay. diff --git a/packages/site/content/runtime/core/workspaces/multi-root.mdx b/packages/site/content/runtime/core/workspaces/multi-root.mdx index 5b4829575..b3d8224ba 100644 --- a/packages/site/content/runtime/core/workspaces/multi-root.mdx +++ b/packages/site/content/runtime/core/workspaces/multi-root.mdx @@ -3,8 +3,6 @@ title: Multi-Root Workspaces description: How to attach additional directories to one AGH workspace for monorepos, shared agents, and project clusters. --- -# Multi-Root Workspaces - A multi-root workspace has one primary root and zero or more additional roots. The primary root owns the workspace config. All roots can contribute agents and skills. diff --git a/packages/site/content/runtime/core/workspaces/resolver.mdx b/packages/site/content/runtime/core/workspaces/resolver.mdx index ebf52b7c7..9295db106 100644 --- a/packages/site/content/runtime/core/workspaces/resolver.mdx +++ b/packages/site/content/runtime/core/workspaces/resolver.mdx @@ -3,8 +3,6 @@ title: Workspace Resolver description: How AGH registers workspace roots, resolves the active workspace, and discovers project-local runtime resources. --- -# Workspace Resolver - A workspace is a registered project root. AGH stores the registration in the global database, but the filesystem remains the source of truth. Each session starts by resolving a workspace snapshot: the canonical root directory, optional additional roots, effective configuration, visible agents, diff --git a/packages/site/source.config.ts b/packages/site/source.config.ts index 5f19671a8..6021cd8fc 100644 --- a/packages/site/source.config.ts +++ b/packages/site/source.config.ts @@ -8,4 +8,13 @@ export const protocol = defineDocs({ dir: "content/protocol", }); -export default defineConfig(); +export default defineConfig({ + mdxOptions: { + rehypeCodeOptions: { + themes: { + light: "vitesse-light", + dark: "vitesse-dark", + }, + }, + }, +}); diff --git a/web/src/components/design-system/stories/design-system-showcase.stories.tsx b/web/src/components/design-system/stories/design-system-showcase.stories.tsx index c6365889d..c40e512eb 100644 --- a/web/src/components/design-system/stories/design-system-showcase.stories.tsx +++ b/web/src/components/design-system/stories/design-system-showcase.stories.tsx @@ -14,7 +14,6 @@ const meta: Meta = { }, }, }, - tags: ["autodocs"], }; export default meta; diff --git a/web/src/components/design-system/stories/metric-strip.stories.tsx b/web/src/components/design-system/stories/metric-strip.stories.tsx index ddd6a7052..6de68ac8f 100644 --- a/web/src/components/design-system/stories/metric-strip.stories.tsx +++ b/web/src/components/design-system/stories/metric-strip.stories.tsx @@ -23,7 +23,6 @@ const meta: Meta = { ), ], - tags: ["autodocs"], }; export default meta; diff --git a/web/src/components/design-system/stories/page-content.stories.tsx b/web/src/components/design-system/stories/page-content.stories.tsx index 0ab644d16..dd2fd68b1 100644 --- a/web/src/components/design-system/stories/page-content.stories.tsx +++ b/web/src/components/design-system/stories/page-content.stories.tsx @@ -20,7 +20,6 @@ const meta: Meta = { }, }, }, - tags: ["autodocs"], }; export default meta; diff --git a/web/src/components/design-system/stories/panel.stories.tsx b/web/src/components/design-system/stories/panel.stories.tsx index 21c81833e..120bf2f16 100644 --- a/web/src/components/design-system/stories/panel.stories.tsx +++ b/web/src/components/design-system/stories/panel.stories.tsx @@ -24,7 +24,6 @@ const meta: Meta = { ), ], - tags: ["autodocs"], }; export default meta; diff --git a/web/src/components/design-system/stories/pill.stories.tsx b/web/src/components/design-system/stories/pill.stories.tsx index 498e70c84..0e373ca22 100644 --- a/web/src/components/design-system/stories/pill.stories.tsx +++ b/web/src/components/design-system/stories/pill.stories.tsx @@ -23,7 +23,6 @@ const meta: Meta = { ), ], - tags: ["autodocs"], }; export default meta; diff --git a/web/src/components/design-system/stories/section-heading.stories.tsx b/web/src/components/design-system/stories/section-heading.stories.tsx index 536e52c62..8a43683f0 100644 --- a/web/src/components/design-system/stories/section-heading.stories.tsx +++ b/web/src/components/design-system/stories/section-heading.stories.tsx @@ -25,7 +25,6 @@ const meta: Meta = { ), ], - tags: ["autodocs"], }; export default meta; diff --git a/web/src/components/design-system/stories/status-dot.stories.tsx b/web/src/components/design-system/stories/status-dot.stories.tsx index 7bc02b3f9..c51b32374 100644 --- a/web/src/components/design-system/stories/status-dot.stories.tsx +++ b/web/src/components/design-system/stories/status-dot.stories.tsx @@ -23,7 +23,6 @@ const meta: Meta = { ), ], - tags: ["autodocs"], }; export default meta; diff --git a/web/src/components/design-system/stories/texture-canvas.stories.tsx b/web/src/components/design-system/stories/texture-canvas.stories.tsx index 5990cb8c8..d6074873a 100644 --- a/web/src/components/design-system/stories/texture-canvas.stories.tsx +++ b/web/src/components/design-system/stories/texture-canvas.stories.tsx @@ -15,7 +15,6 @@ const meta: Meta = { }, }, }, - tags: ["autodocs"], }; export default meta; diff --git a/web/src/components/design-system/stories/toolbar.stories.tsx b/web/src/components/design-system/stories/toolbar.stories.tsx index 0db276a75..42bfa253d 100644 --- a/web/src/components/design-system/stories/toolbar.stories.tsx +++ b/web/src/components/design-system/stories/toolbar.stories.tsx @@ -24,7 +24,6 @@ const meta: Meta = { ), ], - tags: ["autodocs"], }; export default meta; From 702e2529a10ebb0b3ba6b9d3ca643286b475127c Mon Sep 17 00:00:00 2001 From: Pedro Nauck Date: Fri, 17 Apr 2026 22:34:20 -0300 Subject: [PATCH 4/8] fix: resolve mem-improvs review round 2 --- .../mem-improvs/reviews-002/issue_001.md | 50 +++ .../mem-improvs/reviews-002/issue_002.md | 31 ++ .../mem-improvs/reviews-002/issue_003.md | 25 ++ .../mem-improvs/reviews-002/issue_004.md | 101 +++++ .../mem-improvs/reviews-002/issue_005.md | 109 ++++++ .../mem-improvs/reviews-002/issue_006.md | 86 +++++ .../mem-improvs/reviews-002/issue_007.md | 65 ++++ internal/memory/catalog.go | 23 +- internal/memory/store.go | 47 ++- internal/memory/store_test.go | 358 +++++++++++------- .../e2e/runtime_harness_lifecycle_test.go | 36 +- magefile.go | 33 +- magefile_test.go | 24 +- 13 files changed, 793 insertions(+), 195 deletions(-) create mode 100644 .compozy/tasks/mem-improvs/reviews-002/issue_001.md create mode 100644 .compozy/tasks/mem-improvs/reviews-002/issue_002.md create mode 100644 .compozy/tasks/mem-improvs/reviews-002/issue_003.md create mode 100644 .compozy/tasks/mem-improvs/reviews-002/issue_004.md create mode 100644 .compozy/tasks/mem-improvs/reviews-002/issue_005.md create mode 100644 .compozy/tasks/mem-improvs/reviews-002/issue_006.md create mode 100644 .compozy/tasks/mem-improvs/reviews-002/issue_007.md diff --git a/.compozy/tasks/mem-improvs/reviews-002/issue_001.md b/.compozy/tasks/mem-improvs/reviews-002/issue_001.md new file mode 100644 index 000000000..6eb1e444a --- /dev/null +++ b/.compozy/tasks/mem-improvs/reviews-002/issue_001.md @@ -0,0 +1,50 @@ +--- +status: resolved +file: internal/memory/store.go +line: 443 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM575RSy,comment:PRRC_kwDOR5y4QM65BgwH +--- + +# Issue 001: _⚠️ Potential issue_ | _🟠 Major_ +## Review Comment + +_⚠️ Potential issue_ | _🟠 Major_ + +**Health stats miss first-time workspace indexing once the catalog has any rows.** + +`entryCount` is checked globally, not for the requested filters. If the catalog already has global entries, a later `HealthStats()` call for a brand-new workspace skips `reindexScopes()`, so that workspace's markdown files never make it into `entries` and also don't count as orphaned. The API can therefore report a clean workspace health snapshot even though that workspace has never been indexed. Please make readiness/reindex decisions per requested scope/workspace instead of per whole catalog. + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/memory/store.go` around lines 427 - 443, The current logic uses +s.catalog.entryCount(ctx) and only reindexes when the global count is zero, +which skips initial indexing for a new workspace if the catalog already has +rows; change this to check counts per requested scope/workspace and call +s.reindexScopes for each missing scope: for ScopeGlobal still check a global +count and call s.reindexScopes(ctx, ScopeGlobal, "") when its count==0, and for +each filter where filter.scope==ScopeWorkspace call a per-workspace count (e.g., +a catalog method that accepts a workspaceRoot or use a filtered query) and call +s.reindexScopes(ctx, ScopeWorkspace, filter.workspaceRoot) when that +workspace-specific count==0 so new workspaces get indexed and show up in +HealthStats. +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Root cause: `HealthStats()` checks `s.catalog.entryCount(ctx)` across the whole catalog, so any existing global rows make a first-time workspace look "ready" even when that workspace has never been indexed. +- Impact: workspace health can under-report indexed/orphaned files because the workspace scope is skipped entirely. +- Fix plan: add per-scope catalog readiness/count checks and use them for workspace-specific reindex decisions. Apply the same readiness rule to the shared catalog bootstrap path so new workspaces are indexed independently. +- Resolution: replaced the global-only readiness check with per-scope catalog counts, added scope-specific readiness helpers, and reused them in both `HealthStats()` and the shared search bootstrap path so a fresh workspace is indexed even when global rows already exist. +- Verification: `go test ./internal/memory`; `make verify` diff --git a/.compozy/tasks/mem-improvs/reviews-002/issue_002.md b/.compozy/tasks/mem-improvs/reviews-002/issue_002.md new file mode 100644 index 000000000..3862b5467 --- /dev/null +++ b/.compozy/tasks/mem-improvs/reviews-002/issue_002.md @@ -0,0 +1,31 @@ +--- +status: resolved +file: internal/memory/store_test.go +line: 667 +severity: nitpick +author: coderabbitai[bot] +provider_ref: review:4133125556,nitpick_hash:1962e90d47fc +review_hash: 1962e90d47fc +source_review_id: "4133125556" +source_review_submitted_at: "2026-04-18T01:12:18Z" +--- + +# Issue 002: Prefer t.Run("Should...") blocks for these new single-case tests. +## Review Comment + +These additions are all standalone top-level cases. Wrapping them in named subtests would keep the file aligned with the repo’s test shape and make future expansion easier. + +As per coding guidelines, `MUST use t.Run("Should...") pattern for ALL test cases` and `Use table-driven tests with subtests (t.Run) as default in Go tests`. + +Also applies to: 791-847, 849-880, 882-928, 956-963 + +--- + +## Triage + +- Decision: `valid` +- Root cause: the cited tests are single-case top-level functions without the repo-mandated `t.Run("Should...")` case wrapper. +- Impact: inconsistent test structure versus project policy and harder incremental expansion for nearby cases. +- Fix plan: wrap each cited standalone case in a named `t.Run("Should ...")` block and move `t.Parallel()` into the case body without changing assertions. +- Resolution: wrapped each cited standalone store test in a `t.Run("Should ...")` case wrapper and kept the execution/assertion bodies unchanged inside the subtests. +- Verification: `go test ./internal/memory`; `make verify` diff --git a/.compozy/tasks/mem-improvs/reviews-002/issue_003.md b/.compozy/tasks/mem-improvs/reviews-002/issue_003.md new file mode 100644 index 000000000..89e078b5a --- /dev/null +++ b/.compozy/tasks/mem-improvs/reviews-002/issue_003.md @@ -0,0 +1,25 @@ +--- +status: resolved +file: internal/memory/store_test.go +line: 791 +severity: nitpick +author: coderabbitai[bot] +provider_ref: review:4133125556,nitpick_hash:edcb88e696e9 +review_hash: edcb88e696e9 +source_review_id: "4133125556" +source_review_submitted_at: "2026-04-18T01:12:18Z" +--- + +# Issue 003: Please add a partial-indexed health case here. +## Review Comment + +This test only exercises the cold-start path where both scopes are indexed together. It won't catch the case where the catalog already contains global rows and `HealthStats()` is asked about a workspace for the first time, which is where scope readiness can be skipped and stats get understated. A follow-up case that indexes global first and then checks a newly introduced workspace would protect that regression. + +## Triage + +- Decision: `valid` +- Root cause: `TestStoreSearchAndReindex` only covers the empty-catalog path, so it misses the regression where global rows already exist and a brand-new workspace is queried later. +- Impact: the workspace-scope readiness bug can regress without a failing test. +- Fix plan: extend the store catalog regression coverage with a partial-index scenario that seeds global entries first, then exercises a new workspace and verifies the workspace scope gets indexed before reporting health. +- Resolution: added a partial-index regression subtest that seeds global catalog rows first, then creates a fresh workspace and verifies both `Search()` and `HealthStats()` include the newly indexed workspace scope. +- Verification: `go test ./internal/memory`; `make verify` diff --git a/.compozy/tasks/mem-improvs/reviews-002/issue_004.md b/.compozy/tasks/mem-improvs/reviews-002/issue_004.md new file mode 100644 index 000000000..db176914d --- /dev/null +++ b/.compozy/tasks/mem-improvs/reviews-002/issue_004.md @@ -0,0 +1,101 @@ +--- +status: resolved +file: internal/testutil/e2e/runtime_harness_lifecycle_test.go +line: 135 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM575RS0,comment:PRRC_kwDOR5y4QM65BgwJ +--- + +# Issue 004: _🛠️ Refactor suggestion_ | _🟠 Major_ +## Review Comment + +_🛠️ Refactor suggestion_ | _🟠 Major_ + +**Wrap this test case in a `t.Run("Should...")` subtest.** + +Line 113 adds a standalone case, but the test policy requires each case to use `t.Run("Should...")`. Please nest this scenario in a named subtest (and keep `t.Parallel()` inside it if independent). + +
+♻️ Proposed refactor + +```diff + func TestCLIClientRunInDirResolvesRelativePathsAgainstBaseWorkdir(t *testing.T) { + t.Parallel() +- +- baseDir := t.TempDir() +- targetDir := filepath.Join(baseDir, "nested") +- if err := os.MkdirAll(targetDir, 0o755); err != nil { +- t.Fatalf("os.MkdirAll(%q) error = %v", targetDir, err) +- } +- +- client := &CLIClient{ +- binaryPath: writeCLIScript(t, "#!/bin/sh\npwd\n"), +- workdir: baseDir, +- } +- +- stdout, stderr, err := client.RunInDir(context.Background(), "nested", "ignored") +- if err != nil { +- t.Fatalf("RunInDir() error = %v; stderr=%s", err, strings.TrimSpace(stderr)) +- } +- if got, want := strings.TrimSpace(stdout), targetDir; got != want { +- t.Fatalf("RunInDir() stdout = %q, want %q", got, want) +- } ++ t.Run("Should resolve relative workdir against base workdir", func(t *testing.T) { ++ t.Parallel() ++ ++ baseDir := t.TempDir() ++ targetDir := filepath.Join(baseDir, "nested") ++ if err := os.MkdirAll(targetDir, 0o755); err != nil { ++ t.Fatalf("os.MkdirAll(%q) error = %v", targetDir, err) ++ } ++ ++ client := &CLIClient{ ++ binaryPath: writeCLIScript(t, "#!/bin/sh\npwd\n"), ++ workdir: baseDir, ++ } ++ ++ stdout, stderr, err := client.RunInDir(context.Background(), "nested", "ignored") ++ if err != nil { ++ t.Fatalf("RunInDir() error = %v; stderr=%s", err, strings.TrimSpace(stderr)) ++ } ++ if got, want := strings.TrimSpace(stdout), targetDir; got != want { ++ t.Fatalf("RunInDir() stdout = %q, want %q", got, want) ++ } ++ }) + } +``` +
+ +As per coding guidelines, "MUST use t.Run("Should...") pattern for ALL test cases". + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/testutil/e2e/runtime_harness_lifecycle_test.go` around lines 113 - +135, The test function +TestCLIClientRunInDirResolvesRelativePathsAgainstBaseWorkdir should be converted +to use a named subtest: wrap the existing body in t.Run("Should resolve relative +paths against base workdir", func(t *testing.T) { ... }), keep the t.Parallel() +call inside that subtest (not at top level), and leave all references to +CLIClient, writeCLIScript, RunInDir and the assertions unchanged so behavior +remains identical; this satisfies the requirement to use the t.Run("Should...") +pattern for each case. +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Root cause: `TestCLIClientRunInDirResolvesRelativePathsAgainstBaseWorkdir` is a standalone case and does not follow the required `t.Run("Should...")` test-case shape. +- Impact: style/policy drift only; runtime behavior is already correct. +- Fix plan: wrap the existing body in one named subtest and keep `t.Parallel()` inside that subtest. +- Resolution: wrapped the relative-workdir CLI test in a single `t.Run("Should ...")` case and kept `t.Parallel()` inside the subtest body. +- Verification: `go test ./internal/testutil/e2e`; `make verify` diff --git a/.compozy/tasks/mem-improvs/reviews-002/issue_005.md b/.compozy/tasks/mem-improvs/reviews-002/issue_005.md new file mode 100644 index 000000000..cfb188c4f --- /dev/null +++ b/.compozy/tasks/mem-improvs/reviews-002/issue_005.md @@ -0,0 +1,109 @@ +--- +status: resolved +file: magefile.go +line: 88 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM575RS4,comment:PRRC_kwDOR5y4QM65BgwN +--- + +# Issue 005: _⚠️ Potential issue_ | _🟠 Major_ +## Review Comment + +_⚠️ Potential issue_ | _🟠 Major_ + +
+🧩 Analysis chain + +🏁 Script executed: + +```shell +#!/bin/bash +rg -n --type=go '@latest|gotest\.tools/gotestsum@' +``` + +Repository: compozy/agh + +Length of output: 313 + +--- + +🏁 Script executed: + +```shell +#!/bin/bash +# View the code context around the mentioned lines in magefile.go +sed -n '82,92p' magefile.go +echo "---" +sed -n '92,102p' magefile.go +echo "---" +sed -n '530,545p' magefile.go +echo "---" +sed -n '72,78p' magefile.go +echo "---" +sed -n '476,484p' magefile.go +``` + +Repository: compozy/agh + +Length of output: 1551 + +--- + +🏁 Script executed: + +```shell +#!/bin/bash +# Check the complete function signature and usage of runCommandInDirWithEnv +rg -B 2 -A 5 'func runCommandInDirWithEnv' +``` + +Repository: compozy/agh + +Length of output: 384 + +--- + + + +
+ +**Pin all `@latest` tool versions and wrap errors with context in race-enabled command.** + +1. `@latest` makes CI/test behavior non-reproducible and breaks builds unexpectedly. Pin: + - `gotest.tools/gotestsum@latest` at lines 87, 97, 480 + - `golang.org/x/tools/gopls/internal/analysis/modernize/cmd/modernize@latest` at line 75 + +2. `runRaceEnabledGoCommand` (lines 532–534) violates Go guidelines by returning raw errors without wrapped context. It must call `fmt.Errorf("context: %w", err)` instead of returning the bare error. + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@magefile.go` around lines 86 - 88, Pin the tool module versions and add +wrapped error context: replace all occurrences of +"gotest.tools/gotestsum@latest" (used near the run invocation and the other two +occurrences) with a specific version (e.g. gotest.tools/gotestsum@) and +replace +"golang.org/x/tools/gopls/internal/analysis/modernize/cmd/modernize@latest" with +a fixed version as well; then in the function runRaceEnabledGoCommand change any +bare error returns to wrapped errors using fmt.Errorf with %w (e.g., return +fmt.Errorf("running race-enabled go command %v: %w", args, err)) so callers +receive contextualized errors. +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Root cause: `magefile.go` still shells out to `@latest` tool paths for `modernize`/`gotestsum`, which makes the build non-reproducible, and `runRaceEnabledGoCommand` returns the raw subprocess error without caller context. +- Impact: CI and local verification can change underneath the branch, and race-command failures lose the call-site context needed for diagnosis. +- Fix plan: pin the tool invocations to explicit module versions and wrap `runRaceEnabledGoCommand` failures with command context. The error-wrapping portion overlaps with issue 006 and will be satisfied by the same code change. +- Resolution: pinned `gotest.tools/gotestsum` to `v1.13.0`, pinned the gopls modernize tool to `v0.21.1`, and wrapped `runRaceEnabledGoCommand` failures with the invoked args for diagnosis. +- Verification: `go test -tags mage .`; `make verify` diff --git a/.compozy/tasks/mem-improvs/reviews-002/issue_006.md b/.compozy/tasks/mem-improvs/reviews-002/issue_006.md new file mode 100644 index 000000000..1bb914f28 --- /dev/null +++ b/.compozy/tasks/mem-improvs/reviews-002/issue_006.md @@ -0,0 +1,86 @@ +--- +status: resolved +file: magefile.go +line: 534 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM575RS5,comment:PRRC_kwDOR5y4QM65BgwO +--- + +# Issue 006: _⚠️ Potential issue_ | _🟠 Major_ +## Review Comment + +_⚠️ Potential issue_ | _🟠 Major_ + +**Wrap command failures with context in `runRaceEnabledGoCommand`.** + +Return the underlying error with command context so failures are diagnosable. + + + +
+Proposed fix + +```diff + func runRaceEnabledGoCommand(env map[string]string, args ...string) error { +- return runCommandInDirWithEnv(".", withRaceEnabledEnv(env), "go", args...) ++ if err := runCommandInDirWithEnv(".", withRaceEnabledEnv(env), "go", args...); err != nil { ++ return fmt.Errorf("run race-enabled go command: %w", err) ++ } ++ return nil + } +``` +
+ +As per coding guidelines, use explicit error returns with wrapped context: `fmt.Errorf("context: %w", err)`. + + + +
+📝 Committable suggestion + +> ‼️ **IMPORTANT** +> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. + +```suggestion +func runRaceEnabledGoCommand(env map[string]string, args ...string) error { + if err := runCommandInDirWithEnv(".", withRaceEnabledEnv(env), "go", args...); err != nil { + return fmt.Errorf("run race-enabled go command: %w", err) + } + return nil +} +``` + +
+ + + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@magefile.go` around lines 532 - 534, The function runRaceEnabledGoCommand +currently returns the raw error from runCommandInDirWithEnv without context; +modify runRaceEnabledGoCommand to capture the error from runCommandInDirWithEnv +and, if non-nil, return a wrapped error using fmt.Errorf that includes a short +context string (e.g., "runRaceEnabledGoCommand: failed to run 'go' with args %v: +%w") and the original error; reference the call to runCommandInDirWithEnv(".", +withRaceEnabledEnv(env), "go", args...) and ensure fmt is imported if not +already. +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Root cause: `runRaceEnabledGoCommand` forwards the raw error from `runCommandInDirWithEnv`, so callers lose context about which race-enabled `go` invocation failed. +- Impact: slower diagnosis when mage-driven test/build commands fail. +- Fix plan: wrap the returned error with a short contextual message including the invoked args. This is the same underlying fix tracked in issue 005. +- Resolution: `runRaceEnabledGoCommand` now returns a wrapped error that includes the race-enabled `go` args. The same change resolves the overlapping requirement in issue 005. +- Verification: `go test -tags mage .`; `make verify` diff --git a/.compozy/tasks/mem-improvs/reviews-002/issue_007.md b/.compozy/tasks/mem-improvs/reviews-002/issue_007.md new file mode 100644 index 000000000..7a20fa7a0 --- /dev/null +++ b/.compozy/tasks/mem-improvs/reviews-002/issue_007.md @@ -0,0 +1,65 @@ +--- +status: resolved +file: magefile_test.go +line: 58 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM575RS3,comment:PRRC_kwDOR5y4QM65BgwM +--- + +# Issue 007: _⚠️ Potential issue_ | _🟠 Major_ +## Review Comment + +_⚠️ Potential issue_ | _🟠 Major_ + +**Rename subtests to the required `Should...` format.** + +Current subtest names don’t match the mandated test-case naming convention. + + + +
+Proposed fix + +```diff +- t.Run("sets cgo for race commands without mutating the input", func(t *testing.T) { ++ t.Run("Should set cgo for race commands without mutating the input", func(t *testing.T) { +@@ +- t.Run("works with nil input", func(t *testing.T) { ++ t.Run("Should work with nil input", func(t *testing.T) { +``` +
+ +As per coding guidelines, MUST use t.Run("Should...") pattern for ALL test cases. + + +Also applies to: 83-84 + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@magefile_test.go` around lines 57 - 58, Rename the t.Run subtest titles in +magefile_test.go to the required "Should..." pattern without changing test +logic—specifically update the t.Run call currently titled "sets cgo for race +commands without mutating the input" to something like "Should set cgo for race +commands without mutating the input" (and likewise rename the other t.Run at the +later occurrence around lines 83-84) while leaving the anonymous test function, +t.Parallel(), and assertions unchanged. +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Root cause: the `magefile_test.go` subtest titles do not use the repo-required `Should...` naming convention. +- Impact: style/policy drift only. +- Fix plan: rename the existing subtests to `Should ...` titles without changing their bodies. +- Resolution: renamed the affected `magefile_test.go` subtests to the required `Should ...` pattern and kept their test bodies intact. +- Verification: `go test -tags mage .`; `make verify` diff --git a/internal/memory/catalog.go b/internal/memory/catalog.go index 2368c3737..8ab6b0c24 100644 --- a/internal/memory/catalog.go +++ b/internal/memory/catalog.go @@ -263,7 +263,7 @@ func (c *catalog) lastReindex(ctx context.Context) (*time.Time, error) { return &parsed, nil } -func (c *catalog) entryCount(ctx context.Context) (int, error) { +func (c *catalog) scopeEntryCount(ctx context.Context, scope Scope, workspaceRoot string) (int, error) { db, err := c.ensureDB(ctx) if err != nil { return 0, err @@ -272,9 +272,26 @@ func (c *catalog) entryCount(ctx context.Context) (int, error) { return 0, nil } + query := `SELECT COUNT(*) FROM memory_catalog_entries` + args := make([]any, 0, 1) + switch scope.Normalize() { + case ScopeGlobal: + query += ` WHERE scope = 'global'` + case ScopeWorkspace: + query += ` WHERE scope = 'workspace' AND workspace_root = ?` + args = append(args, strings.TrimSpace(workspaceRoot)) + default: + return 0, fmt.Errorf("memory: count catalog entries for unsupported scope %q", scope) + } + var count int - if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM memory_catalog_entries`).Scan(&count); err != nil { - return 0, fmt.Errorf("memory: count catalog entries: %w", err) + if err := db.QueryRowContext(ctx, query, args...).Scan(&count); err != nil { + return 0, fmt.Errorf( + "memory: count catalog entries for scope %q workspace %q: %w", + scope.Normalize(), + strings.TrimSpace(workspaceRoot), + err, + ) } return count, nil } diff --git a/internal/memory/store.go b/internal/memory/store.go index 7bd40f75b..8e081306d 100644 --- a/internal/memory/store.go +++ b/internal/memory/store.go @@ -424,22 +424,10 @@ func (s *Store) HealthStats(ctx context.Context, workspaces []string) (HealthSta filters = append(filters, catalogFilter{scope: ScopeWorkspace, workspaceRoot: trimmed}) } - entryCount, err := s.catalog.entryCount(ctx) - if err != nil { - return HealthStats{}, err - } - if entryCount == 0 { - if _, err := s.reindexScopes(ctx, ScopeGlobal, ""); err != nil { + for _, filter := range filters { + if err := s.ensureCatalogFilterReady(ctx, filter); err != nil { return HealthStats{}, err } - for _, filter := range filters { - if filter.scope != ScopeWorkspace { - continue - } - if _, err := s.reindexScopes(ctx, ScopeWorkspace, filter.workspaceRoot); err != nil { - return HealthStats{}, err - } - } } entries, err := s.catalog.listEntries(ctx, filters) @@ -606,14 +594,41 @@ func (s *Store) ensureCatalogReady(ctx context.Context, scope Scope, workspaceRo if s.catalog == nil { return nil } - entryCount, err := s.catalog.entryCount(ctx) + + filters := []catalogFilter{{scope: ScopeGlobal}} + switch scope.Normalize() { + case ScopeGlobal: + filters = filters[:1] + case ScopeWorkspace: + filters = []catalogFilter{{scope: ScopeWorkspace, workspaceRoot: workspaceRoot}} + default: + if strings.TrimSpace(workspaceRoot) != "" { + filters = append(filters, catalogFilter{scope: ScopeWorkspace, workspaceRoot: workspaceRoot}) + } + } + + for _, filter := range filters { + if err := s.ensureCatalogFilterReady(ctx, filter); err != nil { + return err + } + } + return nil +} + +func (s *Store) ensureCatalogFilterReady(ctx context.Context, filter catalogFilter) error { + if s.catalog == nil { + return nil + } + + entryCount, err := s.catalog.scopeEntryCount(ctx, filter.scope, filter.workspaceRoot) if err != nil { return err } if entryCount > 0 { return nil } - _, err = s.reindexScopes(ctx, scope, workspaceRoot) + + _, err = s.reindexScopes(ctx, filter.scope, filter.workspaceRoot) return err } diff --git a/internal/memory/store_test.go b/internal/memory/store_test.go index 917ce988a..9282dfe07 100644 --- a/internal/memory/store_test.go +++ b/internal/memory/store_test.go @@ -665,28 +665,30 @@ func TestStoreLoadIndex(t *testing.T) { } func TestStoreLoadPromptIndexViaBackendAlias(t *testing.T) { - t.Parallel() + t.Run("Should expose LoadIndex via the backend alias", func(t *testing.T) { + t.Parallel() - env := newTestStoreEnv(t) - if err := env.store.Write(ScopeGlobal, "prefs.md", mustMemoryContent(t, testMemoryMeta{ - Name: "Prefs", - Description: "Saved preference", - Type: MemoryTypeUser, - }, "body\n")); err != nil { - t.Fatalf("Store.Write() error = %v", err) - } + env := newTestStoreEnv(t) + if err := env.store.Write(ScopeGlobal, "prefs.md", mustMemoryContent(t, testMemoryMeta{ + Name: "Prefs", + Description: "Saved preference", + Type: MemoryTypeUser, + }, "body\n")); err != nil { + t.Fatalf("Store.Write() error = %v", err) + } - var backend Backend = env.store - got, truncated, err := backend.LoadPromptIndex(ScopeGlobal) - if err != nil { - t.Fatalf("Backend.LoadPromptIndex() error = %v", err) - } - if truncated { - t.Fatal("Backend.LoadPromptIndex() truncated = true, want false") - } - if !strings.Contains(got, "- [Prefs](prefs.md) - Saved preference") { - t.Fatalf("Backend.LoadPromptIndex() = %q, want rendered index entry", got) - } + var backend Backend = env.store + got, truncated, err := backend.LoadPromptIndex(ScopeGlobal) + if err != nil { + t.Fatalf("Backend.LoadPromptIndex() error = %v", err) + } + if truncated { + t.Fatal("Backend.LoadPromptIndex() truncated = true, want false") + } + if !strings.Contains(got, "- [Prefs](prefs.md) - Saved preference") { + t.Fatalf("Backend.LoadPromptIndex() = %q, want rendered index entry", got) + } + }) } func TestStoreLoadIndexSynthesizesWhenIndexIsMissingOrStale(t *testing.T) { @@ -789,142 +791,210 @@ func TestStoreLoadIndexSynthesizesWhenIndexIsMissingOrStale(t *testing.T) { } func TestStoreSearchAndReindex(t *testing.T) { - t.Parallel() + t.Run("Should search and reindex visible scopes on cold start", func(t *testing.T) { + t.Parallel() - baseDir := t.TempDir() - workspaceRoot := filepath.Join(baseDir, "workspace") - catalogPath := filepath.Join(baseDir, "agh.db") - store := NewStore( - filepath.Join(baseDir, "global"), - WithCatalogDatabasePath(catalogPath), - ).ForWorkspace(workspaceRoot) - if err := store.EnsureDirs(); err != nil { - t.Fatalf("Store.EnsureDirs() error = %v", err) - } + baseDir := t.TempDir() + workspaceRoot := filepath.Join(baseDir, "workspace") + catalogPath := filepath.Join(baseDir, "agh.db") + store := NewStore( + filepath.Join(baseDir, "global"), + WithCatalogDatabasePath(catalogPath), + ).ForWorkspace(workspaceRoot) + if err := store.EnsureDirs(); err != nil { + t.Fatalf("Store.EnsureDirs() error = %v", err) + } - if err := store.Write(ScopeGlobal, "prefs.md", mustMemoryContent(t, testMemoryMeta{ - Name: "Code Style", - Description: "Keep prompts concise", - Type: MemoryTypeUser, - }, "User prefers concise answers and explicit tradeoffs.\n")); err != nil { - t.Fatalf("Store.Write(global) error = %v", err) - } - if err := store.Write(ScopeWorkspace, "auth.md", mustMemoryContent(t, testMemoryMeta{ - Name: "Auth Rewrite", - Description: "Workspace auth migration", - Type: MemoryTypeProject, - }, "The workspace is migrating auth from JWT to sessions.\n")); err != nil { - t.Fatalf("Store.Write(workspace) error = %v", err) - } + if err := store.Write(ScopeGlobal, "prefs.md", mustMemoryContent(t, testMemoryMeta{ + Name: "Code Style", + Description: "Keep prompts concise", + Type: MemoryTypeUser, + }, "User prefers concise answers and explicit tradeoffs.\n")); err != nil { + t.Fatalf("Store.Write(global) error = %v", err) + } + if err := store.Write(ScopeWorkspace, "auth.md", mustMemoryContent(t, testMemoryMeta{ + Name: "Auth Rewrite", + Description: "Workspace auth migration", + Type: MemoryTypeProject, + }, "The workspace is migrating auth from JWT to sessions.\n")); err != nil { + t.Fatalf("Store.Write(workspace) error = %v", err) + } - ctx := context.Background() - results, err := store.Search(ctx, "auth sessions concise", SearchOptions{Workspace: workspaceRoot, Limit: 5}) - if err != nil { - t.Fatalf("Store.Search() error = %v", err) - } - if len(results) != 2 { - t.Fatalf("len(results) = %d, want 2; results=%#v", len(results), results) - } - if results[0].Scope != ScopeWorkspace { - t.Fatalf("results[0].Scope = %q, want workspace", results[0].Scope) - } + ctx := context.Background() + results, err := store.Search(ctx, "auth sessions concise", SearchOptions{Workspace: workspaceRoot, Limit: 5}) + if err != nil { + t.Fatalf("Store.Search() error = %v", err) + } + if len(results) != 2 { + t.Fatalf("len(results) = %d, want 2; results=%#v", len(results), results) + } + if results[0].Scope != ScopeWorkspace { + t.Fatalf("results[0].Scope = %q, want workspace", results[0].Scope) + } - reindex, err := store.Reindex(ctx, ReindexOptions{Workspace: workspaceRoot}) - if err != nil { - t.Fatalf("Store.Reindex() error = %v", err) - } - if reindex.IndexedFiles != 2 { - t.Fatalf("Reindex.IndexedFiles = %d, want 2", reindex.IndexedFiles) - } + reindex, err := store.Reindex(ctx, ReindexOptions{Workspace: workspaceRoot}) + if err != nil { + t.Fatalf("Store.Reindex() error = %v", err) + } + if reindex.IndexedFiles != 2 { + t.Fatalf("Reindex.IndexedFiles = %d, want 2", reindex.IndexedFiles) + } - stats, err := store.HealthStats(ctx, []string{workspaceRoot}) - if err != nil { - t.Fatalf("Store.HealthStats() error = %v", err) - } - if stats.IndexedFiles != 2 || stats.OrphanedFiles != 0 || stats.LastReindex == nil { - t.Fatalf("HealthStats() = %#v, want indexed=2 orphaned=0 lastReindex set", stats) - } + stats, err := store.HealthStats(ctx, []string{workspaceRoot}) + if err != nil { + t.Fatalf("Store.HealthStats() error = %v", err) + } + if stats.IndexedFiles != 2 || stats.OrphanedFiles != 0 || stats.LastReindex == nil { + t.Fatalf("HealthStats() = %#v, want indexed=2 orphaned=0 lastReindex set", stats) + } + }) + + t.Run("Should index a new workspace even when global catalog rows already exist", func(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + ctx := context.Background() + catalogPath := filepath.Join(baseDir, "agh.db") + globalDir := filepath.Join(baseDir, "global") + seedWorkspace := filepath.Join(baseDir, "workspace-seed") + freshWorkspace := filepath.Join(baseDir, "workspace-fresh") + + seedStore := NewStore(globalDir, WithCatalogDatabasePath(catalogPath)).ForWorkspace(seedWorkspace) + if err := seedStore.EnsureDirs(); err != nil { + t.Fatalf("seedStore.EnsureDirs() error = %v", err) + } + if err := seedStore.Write(ScopeGlobal, "prefs.md", mustMemoryContent(t, testMemoryMeta{ + Name: "Shared Preferences", + Description: "Global shared signal", + Type: MemoryTypeUser, + }, "Shared signal is available globally.\n")); err != nil { + t.Fatalf("seedStore.Write(global) error = %v", err) + } + if _, err := seedStore.Reindex(ctx, ReindexOptions{Scope: ScopeGlobal}); err != nil { + t.Fatalf("seedStore.Reindex(global) error = %v", err) + } + + freshStore := NewStore(globalDir, WithCatalogDatabasePath(catalogPath)).ForWorkspace(freshWorkspace) + if err := freshStore.EnsureDirs(); err != nil { + t.Fatalf("freshStore.EnsureDirs() error = %v", err) + } + if err := freshStore.Write(ScopeWorkspace, "project.md", mustMemoryContent(t, testMemoryMeta{ + Name: "Workspace Plan", + Description: "Workspace shared signal", + Type: MemoryTypeProject, + }, "Shared signal is available in the fresh workspace.\n")); err != nil { + t.Fatalf("freshStore.Write(workspace) error = %v", err) + } + + results, err := freshStore.Search(ctx, "shared signal", SearchOptions{Workspace: freshWorkspace, Limit: 5}) + if err != nil { + t.Fatalf("freshStore.Search() error = %v", err) + } + if len(results) != 2 { + t.Fatalf("len(results) = %d, want 2; results=%#v", len(results), results) + } + + scopeCounts := map[Scope]int{} + for _, result := range results { + scopeCounts[result.Scope.Normalize()]++ + } + if scopeCounts[ScopeGlobal] != 1 || scopeCounts[ScopeWorkspace] != 1 { + t.Fatalf("scopeCounts = %#v, want one global and one workspace result", scopeCounts) + } + + stats, err := freshStore.HealthStats(ctx, []string{freshWorkspace}) + if err != nil { + t.Fatalf("freshStore.HealthStats() error = %v", err) + } + if stats.IndexedFiles != 2 || stats.OrphanedFiles != 0 || stats.LastReindex == nil { + t.Fatalf("HealthStats() = %#v, want indexed=2 orphaned=0 lastReindex set", stats) + } + }) } func TestStoreSearchTreatsFTSReservedWordsAsLiteralTerms(t *testing.T) { - t.Parallel() + t.Run("Should treat FTS reserved words as literal search terms", func(t *testing.T) { + t.Parallel() - baseDir := t.TempDir() - workspaceRoot := filepath.Join(baseDir, "workspace") - catalogPath := filepath.Join(baseDir, "agh.db") - store := NewStore( - filepath.Join(baseDir, "global"), - WithCatalogDatabasePath(catalogPath), - ).ForWorkspace(workspaceRoot) - if err := store.EnsureDirs(); err != nil { - t.Fatalf("Store.EnsureDirs() error = %v", err) - } - if err := store.Write(ScopeGlobal, "operators.md", mustMemoryContent(t, testMemoryMeta{ - Name: "Reserved Words", - Description: "Contains literal FTS keywords", - Type: MemoryTypeUser, - }, "Remember the literal token not in this memory.\n")); err != nil { - t.Fatalf("Store.Write() error = %v", err) - } + baseDir := t.TempDir() + workspaceRoot := filepath.Join(baseDir, "workspace") + catalogPath := filepath.Join(baseDir, "agh.db") + store := NewStore( + filepath.Join(baseDir, "global"), + WithCatalogDatabasePath(catalogPath), + ).ForWorkspace(workspaceRoot) + if err := store.EnsureDirs(); err != nil { + t.Fatalf("Store.EnsureDirs() error = %v", err) + } + if err := store.Write(ScopeGlobal, "operators.md", mustMemoryContent(t, testMemoryMeta{ + Name: "Reserved Words", + Description: "Contains literal FTS keywords", + Type: MemoryTypeUser, + }, "Remember the literal token not in this memory.\n")); err != nil { + t.Fatalf("Store.Write() error = %v", err) + } - results, err := store.Search(context.Background(), "not", SearchOptions{Workspace: workspaceRoot, Limit: 5}) - if err != nil { - t.Fatalf("Store.Search() error = %v", err) - } - if len(results) != 1 { - t.Fatalf("len(results) = %d, want 1; results=%#v", len(results), results) - } - if got, want := results[0].Filename, "operators.md"; got != want { - t.Fatalf("results[0].Filename = %q, want %q", got, want) - } + results, err := store.Search(context.Background(), "not", SearchOptions{Workspace: workspaceRoot, Limit: 5}) + if err != nil { + t.Fatalf("Store.Search() error = %v", err) + } + if len(results) != 1 { + t.Fatalf("len(results) = %d, want 1; results=%#v", len(results), results) + } + if got, want := results[0].Filename, "operators.md"; got != want { + t.Fatalf("results[0].Filename = %q, want %q", got, want) + } + }) } func TestStoreMutationsStaySuccessfulWhenDerivedSyncFails(t *testing.T) { - t.Parallel() + t.Run("Should keep primary mutations successful when derived sync fails", func(t *testing.T) { + t.Parallel() - baseDir := t.TempDir() - workspaceRoot := filepath.Join(baseDir, "workspace") - catalogPath := filepath.Join(baseDir, "catalog-dir") - if err := os.MkdirAll(catalogPath, dirPerm); err != nil { - t.Fatalf("os.MkdirAll(%q) error = %v", catalogPath, err) - } + baseDir := t.TempDir() + workspaceRoot := filepath.Join(baseDir, "workspace") + catalogPath := filepath.Join(baseDir, "catalog-dir") + if err := os.MkdirAll(catalogPath, dirPerm); err != nil { + t.Fatalf("os.MkdirAll(%q) error = %v", catalogPath, err) + } - store := NewStore( - filepath.Join(baseDir, "global"), - WithCatalogDatabasePath(catalogPath), - ).ForWorkspace(workspaceRoot) - if err := store.EnsureDirs(); err != nil { - t.Fatalf("Store.EnsureDirs() error = %v", err) - } + store := NewStore( + filepath.Join(baseDir, "global"), + WithCatalogDatabasePath(catalogPath), + ).ForWorkspace(workspaceRoot) + if err := store.EnsureDirs(); err != nil { + t.Fatalf("Store.EnsureDirs() error = %v", err) + } - var logs bytes.Buffer - store.logger = slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelWarn})) - content := mustMemoryContent(t, testMemoryMeta{ - Name: "Prefs", - Description: "Saved preference", - Type: MemoryTypeUser, - }, "body\n") + var logs bytes.Buffer + store.logger = slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelWarn})) + content := mustMemoryContent(t, testMemoryMeta{ + Name: "Prefs", + Description: "Saved preference", + Type: MemoryTypeUser, + }, "body\n") - if err := store.Write(ScopeGlobal, "prefs.md", content); err != nil { - t.Fatalf("Store.Write() error = %v, want primary mutation to succeed", err) - } - if _, err := store.Read(ScopeGlobal, "prefs.md"); err != nil { - t.Fatalf("Store.Read() error = %v, want written file present", err) - } - if !strings.Contains(logs.String(), "sync derived state failed after mutation") { - t.Fatalf("logs = %q, want derived sync warning", logs.String()) - } + if err := store.Write(ScopeGlobal, "prefs.md", content); err != nil { + t.Fatalf("Store.Write() error = %v, want primary mutation to succeed", err) + } + if _, err := store.Read(ScopeGlobal, "prefs.md"); err != nil { + t.Fatalf("Store.Read() error = %v, want written file present", err) + } + if !strings.Contains(logs.String(), "sync derived state failed after mutation") { + t.Fatalf("logs = %q, want derived sync warning", logs.String()) + } - logs.Reset() - if err := store.Delete(ScopeGlobal, "prefs.md"); err != nil { - t.Fatalf("Store.Delete() error = %v, want primary mutation to succeed", err) - } - if _, err := store.Read(ScopeGlobal, "prefs.md"); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("Store.Read(deleted) error = %v, want os.ErrNotExist", err) - } - if !strings.Contains(logs.String(), "sync derived state failed after mutation") { - t.Fatalf("logs = %q, want derived sync warning after delete", logs.String()) - } + logs.Reset() + if err := store.Delete(ScopeGlobal, "prefs.md"); err != nil { + t.Fatalf("Store.Delete() error = %v, want primary mutation to succeed", err) + } + if _, err := store.Read(ScopeGlobal, "prefs.md"); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("Store.Read(deleted) error = %v, want os.ErrNotExist", err) + } + if !strings.Contains(logs.String(), "sync derived state failed after mutation") { + t.Fatalf("logs = %q, want derived sync warning after delete", logs.String()) + } + }) } func TestStoreEnsureDirs(t *testing.T) { @@ -954,12 +1024,14 @@ func TestStoreEnsureDirs(t *testing.T) { } func TestWorkspaceMemoryDirRoundTripsToWorkspaceRoot(t *testing.T) { - t.Parallel() + t.Run("Should round-trip workspace roots through the memory directory path", func(t *testing.T) { + t.Parallel() - workspaceRoot := filepath.Join(t.TempDir(), "workspace") - if got := deriveWorkspaceRoot(workspaceMemoryDir(workspaceRoot)); got != workspaceRoot { - t.Fatalf("deriveWorkspaceRoot(workspaceMemoryDir(%q)) = %q, want %q", workspaceRoot, got, workspaceRoot) - } + workspaceRoot := filepath.Join(t.TempDir(), "workspace") + if got := deriveWorkspaceRoot(workspaceMemoryDir(workspaceRoot)); got != workspaceRoot { + t.Fatalf("deriveWorkspaceRoot(workspaceMemoryDir(%q)) = %q, want %q", workspaceRoot, got, workspaceRoot) + } + }) } func TestStoreRejectsInvalidInputs(t *testing.T) { diff --git a/internal/testutil/e2e/runtime_harness_lifecycle_test.go b/internal/testutil/e2e/runtime_harness_lifecycle_test.go index f77f8543d..21512104f 100644 --- a/internal/testutil/e2e/runtime_harness_lifecycle_test.go +++ b/internal/testutil/e2e/runtime_harness_lifecycle_test.go @@ -111,26 +111,28 @@ func TestRuntimeHarnessStopFallsBackToInterruptWhenCLIStopFails(t *testing.T) { } func TestCLIClientRunInDirResolvesRelativePathsAgainstBaseWorkdir(t *testing.T) { - t.Parallel() + t.Run("Should resolve relative paths against base workdir", func(t *testing.T) { + t.Parallel() - baseDir := t.TempDir() - targetDir := filepath.Join(baseDir, "nested") - if err := os.MkdirAll(targetDir, 0o755); err != nil { - t.Fatalf("os.MkdirAll(%q) error = %v", targetDir, err) - } + baseDir := t.TempDir() + targetDir := filepath.Join(baseDir, "nested") + if err := os.MkdirAll(targetDir, 0o755); err != nil { + t.Fatalf("os.MkdirAll(%q) error = %v", targetDir, err) + } - client := &CLIClient{ - binaryPath: writeCLIScript(t, "#!/bin/sh\npwd\n"), - workdir: baseDir, - } + client := &CLIClient{ + binaryPath: writeCLIScript(t, "#!/bin/sh\npwd\n"), + workdir: baseDir, + } - stdout, stderr, err := client.RunInDir(context.Background(), "nested", "ignored") - if err != nil { - t.Fatalf("RunInDir() error = %v; stderr=%s", err, strings.TrimSpace(stderr)) - } - if got, want := strings.TrimSpace(stdout), targetDir; got != want { - t.Fatalf("RunInDir() stdout = %q, want %q", got, want) - } + stdout, stderr, err := client.RunInDir(context.Background(), "nested", "ignored") + if err != nil { + t.Fatalf("RunInDir() error = %v; stderr=%s", err, strings.TrimSpace(stderr)) + } + if got, want := strings.TrimSpace(stdout), targetDir; got != want { + t.Fatalf("RunInDir() stdout = %q, want %q", got, want) + } + }) } func TestRuntimeHelpersCoverRequestAndTimingUtilities(t *testing.T) { diff --git a/magefile.go b/magefile.go index 93a3d0da3..7514429bc 100644 --- a/magefile.go +++ b/magefile.go @@ -20,15 +20,17 @@ import ( ) const ( - golangciLintVersion = "v2.11.4" - binDir = "bin" - cliBinary = "agh" - versionPackage = "github.com/pedronauck/agh/internal/version" - openAPISpecPath = "openapi/agh.json" - webOpenAPITypePath = "web/src/generated/agh-openapi.d.ts" - webDistIndex = "web/dist/index.html" - daemonBinaryEnvVar = "AGH_TEST_DAEMON_BIN" - driverBinaryEnvVar = "AGH_TEST_ACPMOCK_DRIVER_BIN" + golangciLintVersion = "v2.11.4" + goplsModernizeVersion = "v0.21.1" + gotestsumVersion = "v1.13.0" + binDir = "bin" + cliBinary = "agh" + versionPackage = "github.com/pedronauck/agh/internal/version" + openAPISpecPath = "openapi/agh.json" + webOpenAPITypePath = "web/src/generated/agh-openapi.d.ts" + webDistIndex = "web/dist/index.html" + daemonBinaryEnvVar = "AGH_TEST_DAEMON_BIN" + driverBinaryEnvVar = "AGH_TEST_ACPMOCK_DRIVER_BIN" ) var Default = Verify @@ -72,7 +74,7 @@ func Modernize() error { return sh.RunV( "go", "run", - "golang.org/x/tools/gopls/internal/analysis/modernize/cmd/modernize@latest", + "golang.org/x/tools/gopls/internal/analysis/modernize/cmd/modernize@"+goplsModernizeVersion, "-fix", "./...", ) @@ -84,7 +86,7 @@ func Test() error { return err } return runRaceEnabledGoCommand(nil, - "run", "gotest.tools/gotestsum@latest", + "run", "gotest.tools/gotestsum@"+gotestsumVersion, "--format", "pkgname", "--", "-race", "-parallel=4", "./...") } @@ -94,7 +96,7 @@ func TestIntegration() error { return err } return runRaceEnabledGoCommand(nil, - "run", "gotest.tools/gotestsum@latest", + "run", "gotest.tools/gotestsum@"+gotestsumVersion, "--format", "pkgname", "--", "-race", "-parallel=4", "-tags", "integration", "./...") } @@ -477,7 +479,7 @@ func laneBinaryName(name string) string { func runIntegrationSuite(suite e2elane.GoSuite, env map[string]string) error { args := []string{ "run", - "gotest.tools/gotestsum@latest", + "gotest.tools/gotestsum@" + gotestsumVersion, "--format", "pkgname", "--", @@ -530,7 +532,10 @@ func mergeCommandEnv(overrides map[string]string) []string { } func runRaceEnabledGoCommand(env map[string]string, args ...string) error { - return runCommandInDirWithEnv(".", withRaceEnabledEnv(env), "go", args...) + if err := runCommandInDirWithEnv(".", withRaceEnabledEnv(env), "go", args...); err != nil { + return fmt.Errorf("run race-enabled go command %v: %w", args, err) + } + return nil } func withRaceEnabledEnv(overrides map[string]string) map[string]string { diff --git a/magefile_test.go b/magefile_test.go index d00dd3314..4ae960e9e 100644 --- a/magefile_test.go +++ b/magefile_test.go @@ -3,6 +3,7 @@ package main import ( + "strings" "testing" "github.com/pedronauck/agh/internal/e2elane" @@ -54,7 +55,7 @@ func TestShouldEnsureWebBundle(t *testing.T) { func TestWithRaceEnabledEnv(t *testing.T) { t.Parallel() - t.Run("sets cgo for race commands without mutating the input", func(t *testing.T) { + t.Run("Should set cgo for race commands without mutating the input", func(t *testing.T) { t.Parallel() overrides := map[string]string{ @@ -80,7 +81,7 @@ func TestWithRaceEnabledEnv(t *testing.T) { } }) - t.Run("works with nil input", func(t *testing.T) { + t.Run("Should work with nil input", func(t *testing.T) { t.Parallel() got := withRaceEnabledEnv(nil) @@ -89,3 +90,22 @@ func TestWithRaceEnabledEnv(t *testing.T) { } }) } + +func TestRunRaceEnabledGoCommand(t *testing.T) { + t.Parallel() + + t.Run("Should wrap subprocess failures with command context", func(t *testing.T) { + t.Parallel() + + err := runRaceEnabledGoCommand(nil, "definitely-not-a-go-subcommand") + if err == nil { + t.Fatal("runRaceEnabledGoCommand() error = nil, want non-nil") + } + if !strings.Contains(err.Error(), "run race-enabled go command") { + t.Fatalf("runRaceEnabledGoCommand() error = %q, want wrapper context", err) + } + if !strings.Contains(err.Error(), "definitely-not-a-go-subcommand") { + t.Fatalf("runRaceEnabledGoCommand() error = %q, want failing args in context", err) + } + }) +} From 2a6607b6cc1aa9bf3b67b625f76a441de9eae684 Mon Sep 17 00:00:00 2001 From: Pedro Nauck Date: Fri, 17 Apr 2026 22:35:53 -0300 Subject: [PATCH 5/8] docs: update --- .../tasks/mem-improvs/reviews-002/_meta.md | 11 + docs/ideas/memory-gaps/README.md | 308 ++++++++++++++++++ 2 files changed, 319 insertions(+) create mode 100644 .compozy/tasks/mem-improvs/reviews-002/_meta.md create mode 100644 docs/ideas/memory-gaps/README.md diff --git a/.compozy/tasks/mem-improvs/reviews-002/_meta.md b/.compozy/tasks/mem-improvs/reviews-002/_meta.md new file mode 100644 index 000000000..024388f89 --- /dev/null +++ b/.compozy/tasks/mem-improvs/reviews-002/_meta.md @@ -0,0 +1,11 @@ +--- +provider: coderabbit +pr: "35" +round: 2 +created_at: 2026-04-18T01:23:30.462596Z +--- + +## Summary +- Total: 7 +- Resolved: 0 +- Unresolved: 7 diff --git a/docs/ideas/memory-gaps/README.md b/docs/ideas/memory-gaps/README.md new file mode 100644 index 000000000..eaf29b400 --- /dev/null +++ b/docs/ideas/memory-gaps/README.md @@ -0,0 +1,308 @@ +# Memory Gaps: o que falta para o AGH ter memória de harness de alto nível + +## Objetivo + +Este documento consolida o que ainda falta para o AGH atingir um nível alto em termos de memória agentica. + +O foco aqui não é apenas "ter arquivos de memória" ou "expor comandos de busca", mas responder a uma pergunta mais importante: + +> o AGH já faz o agente aprender, lembrar e reutilizar contexto de forma automática, segura e relevante ao longo do tempo? + +A resposta curta hoje é: **a base está melhor, mas ainda não**. + +O AGH já tem uma fundação funcional: + +- memória persistente em Markdown com frontmatter +- escopo global e escopo por workspace +- `MEMORY.md` como índice de entrada +- consolidação via dream runtime +- catálogo derivado em SQLite FTS5 +- busca e reindex +- recall automático antes do `driver.Prompt` + +Isso já é mais do que "só uma feature manual". Mas ainda fica abaixo do que harnesses mais maduros fazem, porque a automação está mais forte no **lado de recuperar memória** do que no **lado de formar memória útil de maneira contínua**. + +## O padrão de um harness de alto nível em memória + +Um harness realmente forte em memória tende a ter quase todos estes atributos ao mesmo tempo: + +1. **Formação automática de memória** + O sistema extrai fatos duráveis, preferências, decisões e contexto de projeto sem depender principalmente de escrita manual. + +2. **Recall automático e seletivo** + O agente recebe contexto recuperado no momento do prompt sem precisar que o usuário peça explicitamente. + +3. **Memória em camadas** + O sistema separa pelo menos memória de sessão/working, memória episódica, memória durável/semântica e, em alguns casos, memória compartilhada de equipe. + +4. **Busca de alta qualidade** + O recall combina texto completo, semântica, ranking de relevância, filtros por escopo e proteção contra contexto irrelevante. + +5. **Consolidação e deduplicação** + O sistema sabe resumir, fundir, podar, marcar staleness, remover contradições e evitar duplicação. + +6. **Integração com compaction e loop do agente** + A memória não vive ao lado do core; ela participa do ciclo de turnos, compaction e recuperação de contexto. + +7. **Escopos reais de identidade** + Além de global/workspace, existem limites mais fortes por usuário, time, tenant ou projeto. + +8. **Observabilidade de qualidade** + Operadores conseguem responder: o que foi lembrado, por que foi lembrado, o que envelheceu, o que está órfão e o que está poluindo contexto. + +9. **Loop de aprendizado** + O sistema fecha o ciclo memória -> consolidação -> skill/procedimento -> recall, em vez de apenas acumular notas. + +## Onde o AGH está hoje + +Hoje o AGH já cobre partes importantes da fundação: + +- memória durável em arquivos Markdown +- taxonomia fechada de tipos +- índice `MEMORY.md` +- dual scope `global` + `workspace` +- catálogo derivado com FTS5 +- `search` e `reindex` via daemon/API/CLI +- recall limitado e automático antes do prompt +- dream consolidation em runtime próprio +- estatísticas básicas de saúde da memória + +Isso significa que o AGH **já consulta memória automaticamente**. O agente não depende apenas de `agh memory search` manual para usar memória. + +Mas o AGH ainda não tem um sistema de memória forte o bastante para ser percebido como um harness de topo, porque vários componentes estruturais continuam ausentes ou incompletos. + +## Gaps principais + +### 1. Formação automática de memória ainda é fraca + +Este é o gap mais importante. + +Hoje o AGH melhorou o recall automático, mas ainda não tem um pipeline forte de extração automática por turno ou por resposta final que: + +- identifique decisões importantes +- capture preferências do usuário +- detecte fatos recorrentes sobre o projeto +- separe sinal durável de ruído transitório +- escreva isso de forma consistente sem depender principalmente do operador + +Na prática, isso significa que o AGH está melhor para **usar memória que já existe** do que para **criar memória boa ao longo do trabalho**. + +O salto qualitativo aqui seria: + +- extração automática após turnos relevantes +- flush pré-compaction +- fallback extrativo quando o flush com LLM falhar +- regras de saliência para decidir o que merece ser persistido + +Sem isso, memória tende a virar um repositório útil apenas para usuários disciplinados. + +### 2. Falta memória em camadas + +Hoje a estrutura é basicamente: + +- memória global +- memória por workspace + +Isso é útil, mas ainda é pouco. + +Harnesses mais fortes geralmente distinguem pelo menos: + +- **working/session memory**: scratchpad ou resumo incremental da conversa atual +- **episodic memory**: resumos de sessões concluídas, eventos e trajetórias +- **durable/semantic memory**: fatos e conhecimento de longo prazo +- **team/shared memory**: conhecimento compartilhado entre usuários ou agentes + +No próprio plano da branch atual, `session/working`, `durable`, `episodic` e `team/shared` já ficaram explicitamente para depois. + +Sem essas camadas, o sistema mistura coisas com horizontes temporais e objetivos diferentes, o que piora recall e consolidação. + +### 3. Recall ainda é principalmente lexical, não semântico + +O AGH agora tem uma base melhor com FTS5, o que é um avanço real. + +Mas ainda faltam: + +- embeddings vetoriais +- busca híbrida BM25 + vetorial +- ranking mais sofisticado +- expansão semântica de consulta +- melhor seleção quando o usuário não usa o mesmo vocabulário do arquivo salvo + +Isso importa porque memória agentica falha com frequência exatamente quando o usuário faz perguntas indiretas, vagas ou conceituais. + +Texto completo resolve parte do problema. +Busca semântica/híbrida resolve a parte mais difícil. + +### 4. Falta recall cross-session mais amplo + +O AGH melhorou recall sobre o acervo de memórias persistidas, mas ainda não opera plenamente como um sistema que vasculha o histórico rico de sessões e mensagens concluídas para recuperar contexto útil. + +Um harness de alto nível geralmente consegue: + +- buscar conversas antigas +- agrupar resultados por sessão +- resumir trechos relevantes antes de injetar +- diferenciar fato durável de histórico episódico + +Sem isso, a memória do AGH ainda depende demais de o conhecimento já ter sido promovido para arquivos duráveis. + +### 5. Consolidação ainda precisa ficar mais inteligente + +O dream runtime já existe e é uma peça boa da arquitetura. + +Mas ainda faltam comportamentos mais fortes de consolidação: + +- deduplicar fatos equivalentes +- detectar contradições +- rebaixar ou remover memórias envelhecidas +- promover conteúdo de logs para tópicos duráveis +- gerar abstrações curtas para auto-injeção +- separar melhor o que deve continuar em log do que deve virar memória canônica + +Sem isso, mesmo uma memória bem abastecida tende a acumular drift. + +### 6. Falta proteção mais forte contra memória stale ou errada + +Memória útil também precisa ser segura. + +Hoje o AGH já ganhou warning de frescor no bloco de recall, mas ainda falta um tratamento mais sistemático para: + +- envelhecimento de fatos sensíveis a código e runtime +- proveniência da memória +- score de confiança +- revalidação de memórias muito antigas +- distinção explícita entre observação histórica e verdade atual + +Sem isso, o risco é transformar memória em fonte de alucinação persistente. + +### 7. Falta escopo real de identidade e colaboração + +`global` e `workspace` ajudam, mas ainda não cobrem bem cenários de: + +- multi-user +- team/shared +- tenant isolation +- preferências pessoais separadas de contexto compartilhado +- agentes diferentes colaborando sobre o mesmo espaço de memória + +Para um harness com ambição mais alta, memória precisa ser modelada com escopo mais rigoroso que apenas "global ou workspace". + +### 8. Falta integração mais profunda com compaction e contexto de sessão + +Hoje o recall pré-prompt é automático, o que é bom. + +Mas ainda falta integração mais profunda entre: + +- session summaries +- compaction intermediária +- flush de memória antes da compactação +- recuperação após compactação +- continuidade entre sessões reabertas + +Em um harness maduro, memória de sessão e memória durável trabalham juntas; não são dois sistemas quase independentes. + +### 9. Falta loop fechado de aprendizado + +Este é o gap mais estratégico. + +Harnesses mais fortes não apenas lembram fatos. Eles aprendem padrões. + +Exemplos do que falta nesse eixo: + +- transformar trajetórias repetidas em skills/procedimentos +- promover práticas recorrentes para memória procedural +- detectar correções frequentes do usuário e convertê-las em guidance estável +- gerar insights sobre o que o sistema está aprendendo + +Sem esse loop, a memória do AGH continua mais próxima de um bom sistema de recall do que de um sistema de aprendizado agentico. + +### 10. Falta observabilidade orientada à qualidade da memória + +Hoje já existem estatísticas e eventos básicos, o que é útil. + +Mas um sistema de memória de alto nível precisa responder perguntas operacionais mais fortes: + +- quais memórias são mais recuperadas +- quais memórias são ignoradas sempre +- quais consultas falham em achar contexto útil +- qual foi a taxa de recall útil vs recall poluente +- quais memórias estão órfãs, redundantes ou contraditórias +- quanto contexto está sendo gasto com memória por turno + +Sem essas métricas, a evolução do sistema vira percepção subjetiva. + +## O que mais importa corrigir primeiro + +Se o objetivo for sair de "memória boa" para "memória de harness de alto nível", a ordem mais racional é: + +### Prioridade 1: fortalecer escrita automática + +Antes de ampliar busca, é preciso garantir que o sistema produz memória boa de forma contínua. + +Isso inclui: + +- extração automática por turno ou por parada +- flush pré-compaction +- fallback extrativo +- regras claras de saliência e dedup + +### Prioridade 2: introduzir camadas de memória + +Separar explicitamente: + +- sessão/working +- episódica +- durável/semântica +- compartilhada + +Sem essa separação, o resto da evolução fica torto. + +### Prioridade 3: melhorar recall + +Depois disso, faz sentido subir a qualidade do recall com: + +- cross-session real +- busca híbrida +- seleção semântica +- ranking por frescor e relevância + +### Prioridade 4: consolidar aprendizado + +Só então vale atacar o ciclo mais ambicioso: + +- consolidar padrões recorrentes +- gerar memória procedural +- promover para skills +- fechar o loop memória -> skill -> recall + +## O que seria um bom critério de "alto nível" + +O AGH pode ser considerado realmente forte em memória quando estas afirmações forem verdadeiras: + +- o usuário não precisa lembrar de salvar manualmente o que importa na maior parte do tempo +- o agente recebe automaticamente memória relevante antes do prompt com baixa taxa de ruído +- o sistema diferencia bem contexto de sessão, episódios passados e fatos duráveis +- o recall funciona mesmo quando a pergunta não bate lexicalmente com o texto salvo +- memórias antigas não são tratadas como verdade atual sem caveat +- o sistema sabe consolidar, deduplicar e podar sozinho +- existe ao menos um começo de memória compartilhada e escopos fortes de identidade +- o sistema aprende padrões de trabalho, não só fatos isolados + +## Resumo executivo + +O AGH já saiu do estágio de "memória só manual" e entrou no estágio de **memória durável com recall automático básico**. + +O que ainda falta para virar um harness de alto nível em memória não é principalmente mais CLI ou mais endpoints. + +O que falta é: + +- **escrita automática forte** +- **camadas de memória** +- **recall semântico/híbrido** +- **cross-session de verdade** +- **consolidação inteligente** +- **escopos compartilhados e identidade** +- **proteção forte contra staleness** +- **loop de aprendizado** + +Enquanto esses pontos não existirem, o AGH terá uma boa fundação de memória operacional, mas ainda não uma memória agentica de primeira linha. From e1c0b1efca9e9d066c8e13dc44364f62a814a147 Mon Sep 17 00:00:00 2001 From: Pedro Nauck Date: Fri, 17 Apr 2026 22:58:16 -0300 Subject: [PATCH 6/8] fix: resolve mem-improvs review round 003 --- .../mem-improvs/reviews-003/issue_001.md | 152 ++++++++++++++ .../mem-improvs/reviews-003/issue_002.md | 52 +++++ .../mem-improvs/reviews-003/issue_003.md | 53 +++++ .../mem-improvs/reviews-003/issue_004.md | 108 ++++++++++ internal/memory/catalog.go | 194 ++++++++++++++---- internal/memory/store.go | 16 +- internal/memory/store_test.go | 135 ++++++++++++ magefile.go | 7 +- magefile_test.go | 14 +- 9 files changed, 683 insertions(+), 48 deletions(-) create mode 100644 .compozy/tasks/mem-improvs/reviews-003/issue_001.md create mode 100644 .compozy/tasks/mem-improvs/reviews-003/issue_002.md create mode 100644 .compozy/tasks/mem-improvs/reviews-003/issue_003.md create mode 100644 .compozy/tasks/mem-improvs/reviews-003/issue_004.md diff --git a/.compozy/tasks/mem-improvs/reviews-003/issue_001.md b/.compozy/tasks/mem-improvs/reviews-003/issue_001.md new file mode 100644 index 000000000..4a87a9bbc --- /dev/null +++ b/.compozy/tasks/mem-improvs/reviews-003/issue_001.md @@ -0,0 +1,152 @@ +--- +status: resolved +file: internal/memory/catalog.go +line: 319 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM575b1V,comment:PRRC_kwDOR5y4QM65BvYL +--- + +# Issue 001: _⚠️ Potential issue_ | _🟠 Major_ +## Review Comment + +_⚠️ Potential issue_ | _🟠 Major_ + +
+🧩 Analysis chain + +🏁 Script executed: + +```shell +cd /tmp/repo && wc -l internal/memory/catalog.go +``` + +Repository: compozy/agh + +Length of output: 114 + +--- + +🏁 Script executed: + +```shell +cd /tmp/repo && sed -n '310,325p' internal/memory/catalog.go && echo "---" && sed -n '405,415p' internal/memory/catalog.go +``` + +Repository: compozy/agh + +Length of output: 114 + +--- + +🏁 Script executed: + +```shell +cd /tmp/repo && sed -n '315,325p' internal/memory/catalog.go && echo "---SECOND---" && sed -n '408,415p' internal/memory/catalog.go +``` + +Repository: compozy/agh + +Length of output: 114 + +--- + +🏁 Script executed: + +```shell +git ls-files | grep -E "catalog\.go$" +``` + +Repository: compozy/agh + +Length of output: 108 + +--- + +🏁 Script executed: + +```shell +head -n 1 internal/memory/catalog.go && wc -l internal/memory/catalog.go +``` + +Repository: compozy/agh + +Length of output: 100 + +--- + +🏁 Script executed: + +```shell +sed -n '310,325p' internal/memory/catalog.go +``` + +Repository: compozy/agh + +Length of output: 541 + +--- + +🏁 Script executed: + +```shell +sed -n '405,415p' internal/memory/catalog.go +``` + +Repository: compozy/agh + +Length of output: 303 + +--- + +🏁 Script executed: + +```shell +sed -n '540,560p' internal/memory/catalog.go +``` + +Repository: compozy/agh + +Length of output: 657 + +--- + + + +
+ +**Handle `Rows.Close()` errors explicitly or justify suppression.** + +Lines 317-319 and 408-410 both use `_ = rows.Close()` in deferred cleanups, suppressing errors without justification. This violates the coding guideline: "Never ignore errors with `_` — every error must be handled or have a written justification." Either check the error and handle/log it, or add a comment explaining why it is safe to ignore (e.g., "// rows.Err() captured any iteration errors"). + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/memory/catalog.go` around lines 317 - 319, The deferred calls +currently do `_ = rows.Close()` (in internal/memory/catalog.go where rows is +used around the blocks at lines shown) which suppress errors; update those +defers to either check the returned error from rows.Close() and handle/log it +(e.g., using the package logger or returning a wrapped error from the enclosing +function), or add a brief justification comment if it is provably safe to ignore +because rows.Err() is already checked after iteration; ensure you adjust the +defer in both places (the defer around rows at ~317-319 and the similar defer at +~408-410) so the error is not silently discarded and reference the surrounding +function name where you make the change. +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Notes: + - Both deferred cleanups suppress `rows.Close()` errors without explanation, which violates the workspace rule against ignoring errors with `_` unless the suppression is justified. + - These call sites fully drain `rows` and check `rows.Err()` after iteration, so the root-cause fix is to add explicit justification at the defer sites rather than restructure the functions around named return errors. + - Completed fix: added explicit comments at both defer sites explaining that `rows.Err()`/scan failures already surface actionable read errors for the fully drained `SELECT` cursors. + - Verification: `make verify` passed after the change set. diff --git a/.compozy/tasks/mem-improvs/reviews-003/issue_002.md b/.compozy/tasks/mem-improvs/reviews-003/issue_002.md new file mode 100644 index 000000000..be6904614 --- /dev/null +++ b/.compozy/tasks/mem-improvs/reviews-003/issue_002.md @@ -0,0 +1,52 @@ +--- +status: resolved +file: internal/memory/store.go +line: 343 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM575b1Y,comment:PRRC_kwDOR5y4QM65BvYP +--- + +# Issue 002: _⚠️ Potential issue_ | _🟠 Major_ +## Review Comment + +_⚠️ Potential issue_ | _🟠 Major_ + +**Validate `q` before catalog warm-up, and cap `limit` server-side.** + +`ensureCatalogReady()` runs before the query is validated, so `q=""` or punctuation-only input can still trigger a full reindex/scan and only then fail. Also, the HTTP layer forwards the client-provided `limit` directly into this method (`internal/api/core/memory.go:50-70`), and this path only defaults it — it never bounds it. Reject tokenless queries and clamp `limit` before Line 341. + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/memory/store.go` around lines 327 - 343, In Search, validate the +incoming query string (parameter `query`) immediately after normalizing +scope/workspace and before calling `s.ensureCatalogReady` to avoid triggering a +full catalog warm-up for empty or punctuation-only inputs; reject tokenless +queries (empty or that contain no alphanumeric tokens) by returning a clear +error. Also clamp the requested `opts.Limit` into a server-side bounded value +(e.g., min defaultSearchLimit and maxSearchLimit) and assign that to `limit` +before any work is done; update references in this function (Search, +SearchOptions, defaultSearchLimit) and add/ reuse a small helper (e.g., +`isTokenlessQuery`) if helpful. Ensure the validation and clamping occur prior +to the `ensureCatalogReady` call so expensive operations are avoided for invalid +requests. +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Notes: + - `Store.Search` currently normalizes scope, defaults `Limit`, then warms the catalog before any query validation, so empty or punctuation-only input can trigger unnecessary catalog work before failing inside the catalog/fallback search helpers. + - The method also accepts arbitrarily large positive limits from callers because it only applies a default when `Limit <= 0`; there is no server-side clamp before the expensive work starts. + - Completed fix: `Store.Search` now rejects tokenless queries before catalog warm-up and clamps limits through the shared search-limit helper before any expensive work occurs. + - Completed validation: added regression coverage for tokenless query rejection without catalog creation and oversized limit clamping in `internal/memory/store_test.go`. + - Verification: `make verify` passed after the change set. diff --git a/.compozy/tasks/mem-improvs/reviews-003/issue_003.md b/.compozy/tasks/mem-improvs/reviews-003/issue_003.md new file mode 100644 index 000000000..07ba4a956 --- /dev/null +++ b/.compozy/tasks/mem-improvs/reviews-003/issue_003.md @@ -0,0 +1,53 @@ +--- +status: resolved +file: internal/memory/store.go +line: 632 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM575b1Z,comment:PRRC_kwDOR5y4QM65BvYQ +--- + +# Issue 003: _⚠️ Potential issue_ | _🟠 Major_ +## Review Comment + +_⚠️ Potential issue_ | _🟠 Major_ + +**Empty scopes will reindex on every request.** + +`scopeEntryCount(...) == 0` is being used as the only readiness signal. For a legitimately empty global/workspace scope, that stays zero forever, so every `Search`/`HealthStats` call re-scans disk and rewrites the catalog. Persist a per-scope/workspace synced marker in `memory_catalog_state` and check that instead of row existence alone. + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/memory/store.go` around lines 618 - 632, ensureCatalogFilterReady +currently treats scopeEntryCount(...) == 0 as the only readiness signal and will +call reindexScopes every time a legitimately empty scope/workspace has zero +rows; instead add a persisted per-scope/workspace readiness marker in the +memory_catalog_state table and consult that marker in ensureCatalogFilterReady +before deciding to reindex. Specifically, modify ensureCatalogFilterReady to +first read a synced/readiness flag for the given filter.scope and +filter.workspaceRoot from memory_catalog_state, only fall back to calling +scopeEntryCount if the marker is missing, and after reindexScopes completes +successfully set the marker for that scope/workspace; update any codepaths that +create or refresh the catalog (reindexScopes) to write the marker (and +clear/update it on failures) so empty but-synced scopes do not reindex on every +Search/HealthStats call. +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Notes: + - `ensureCatalogFilterReady` treats `scopeEntryCount(...) > 0` as the only ready signal, so a legitimately empty but already-synced scope keeps looking cold and reindexes on every `Search`/`HealthStats` call. + - The underlying root cause is missing persisted readiness state in `memory_catalog_state`; empty scopes need a durable synced marker independent of row count. + - Completed fix: added persisted per-scope/workspace sync markers in `memory_catalog_state`, wrote them during scope replacement, and taught `ensureCatalogFilterReady` to trust the marker first and backfill it from existing rows when needed. + - Completed validation: added regression coverage proving empty synced scopes stay warm across later `HealthStats` reads in `internal/memory/store_test.go`. + - Verification: `make verify` passed after the change set. diff --git a/.compozy/tasks/mem-improvs/reviews-003/issue_004.md b/.compozy/tasks/mem-improvs/reviews-003/issue_004.md new file mode 100644 index 000000000..41814044f --- /dev/null +++ b/.compozy/tasks/mem-improvs/reviews-003/issue_004.md @@ -0,0 +1,108 @@ +--- +status: resolved +file: magefile_test.go +line: 109 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM575b1b,comment:PRRC_kwDOR5y4QM65BvYS +--- + +# Issue 004: _🛠️ Refactor suggestion_ | _🟠 Major_ +## Review Comment + +_🛠️ Refactor suggestion_ | _🟠 Major_ + +
+🧩 Analysis chain + +🏁 Script executed: + +```shell +#!/bin/bash +# Verify current string-based error assertions and whether typed sentinel assertions exist. + +rg -n --type=go 'strings\.Contains\(\s*err\.Error\(\)' magefile_test.go +rg -n --type=go '\berrRaceEnabledGoCommand\b' magefile.go magefile_test.go +rg -n --type=go '\berrors\.(Is|As)\(' magefile_test.go +``` + +Repository: compozy/agh + +Length of output: 203 + +--- + +🏁 Script executed: + +```shell +#!/bin/bash +# Check the implementation of runRaceEnabledGoCommand in magefile.go +rg -n --type=go -A 5 'func runRaceEnabledGoCommand' magefile.go +``` + +Repository: compozy/agh + +Length of output: 333 + +--- + +🏁 Script executed: + +```shell +#!/bin/bash +# Get full test context around lines 104-109 +sed -n '95,115p' magefile_test.go +``` + +Repository: compozy/agh + +Length of output: 650 + +--- + + + +
+ +**Use `errors.Is()` and `errors.As()` instead of string matching for error assertions.** + +Lines 104 and 107 use `strings.Contains(err.Error(), ...)` to validate error messages, which violates the coding guideline: "Use `errors.Is()` and `errors.As()` for error matching — never compare error strings." + +Refactor to use typed error assertions: +1. Add a sentinel error in `magefile.go`: `var errRaceEnabledGoCommand = errors.New("run race-enabled go command")` +2. Update the error return in `runRaceEnabledGoCommand` to wrap the sentinel: `fmt.Errorf("%w %v: %w", errRaceEnabledGoCommand, args, err)` +3. Replace string assertions with `errors.Is(err, errRaceEnabledGoCommand)` to verify the sentinel is in the error chain +4. Use `errors.As(err, &exec.ExitError{})` to validate the underlying command failure + +This approach is more robust, fails fast on logic changes, and aligns with Go error handling best practices. + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@magefile_test.go` around lines 104 - 109, Add a sentinel error and switch the +test to use errors.Is/As: declare var errRaceEnabledGoCommand = errors.New("run +race-enabled go command") in magefile.go, change runRaceEnabledGoCommand to wrap +that sentinel when returning errors (e.g., use fmt.Errorf with %w to include +args and the underlying err), then in the test replace the strings.Contains +checks with errors.Is(err, errRaceEnabledGoCommand) to assert the sentinel is in +the chain and use errors.As(err, new(*exec.ExitError)) (or a typed variable) to +assert the underlying failure is an exec.ExitError. +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Notes: + - `magefile_test.go` currently matches wrapped errors by string content, which is brittle and violates the project guidance to use `errors.Is`/`errors.As` for error matching. + - The root cause is that `runRaceEnabledGoCommand` does not expose a stable typed sentinel in its error chain, so the minimal production fix requires a small change in `magefile.go` even though that file is outside the listed code-file scope. + - Completed fix: added `errRaceEnabledGoCommand` in `magefile.go`, wrapped it from `runRaceEnabledGoCommand`, and updated the mage test to assert `errors.Is` plus `errors.As(*exec.ExitError)` instead of matching strings. + - Scope note: `magefile.go` was touched because the typed error chain required by the review cannot be added from the test alone. + - Verification: `make verify` passed after the change set. diff --git a/internal/memory/catalog.go b/internal/memory/catalog.go index 8ab6b0c24..24defab9c 100644 --- a/internal/memory/catalog.go +++ b/internal/memory/catalog.go @@ -20,7 +20,9 @@ import ( const ( defaultSearchLimit = 10 + maxSearchLimit = 50 catalogStateKeyLastReindex = "last_reindex_at" + catalogStateKeyScopePrefix = "scope_synced::" catalogEventAgentName = "daemon" ) @@ -203,6 +205,19 @@ func (c *catalog) replaceScope( return fmt.Errorf("memory: insert catalog entry %q: %w", doc.Filename, err) } } + if err := upsertCatalogStateTx( + ctx, + tx, + catalogScopeStateKey(scope, workspaceRoot), + storepkg.FormatTimestamp(c.now().UTC()), + ); err != nil { + return fmt.Errorf( + "memory: persist catalog scope state %q/%q: %w", + scope.Normalize(), + strings.TrimSpace(workspaceRoot), + err, + ) + } if err := tx.Commit(); err != nil { return fmt.Errorf("memory: commit catalog scope replace: %w", err) @@ -212,20 +227,11 @@ func (c *catalog) replaceScope( } func (c *catalog) setLastReindex(ctx context.Context, when time.Time) error { - db, err := c.ensureDB(ctx) - if err != nil { - return err - } - if db == nil { - return nil - } if when.IsZero() { when = c.now() } - if _, err := db.ExecContext( + if err := c.upsertState( ctx, - `INSERT INTO memory_catalog_state (key, value) VALUES (?, ?) - ON CONFLICT(key) DO UPDATE SET value = excluded.value`, catalogStateKeyLastReindex, storepkg.FormatTimestamp(when.UTC()), ); err != nil { @@ -235,26 +241,14 @@ func (c *catalog) setLastReindex(ctx context.Context, when time.Time) error { } func (c *catalog) lastReindex(ctx context.Context) (*time.Time, error) { - db, err := c.ensureDB(ctx) + raw, ok, err := c.stateValue(ctx, catalogStateKeyLastReindex) if err != nil { return nil, err } - if db == nil { + if !ok { return nil, nil } - var raw string - if err := db.QueryRowContext( - ctx, - `SELECT value FROM memory_catalog_state WHERE key = ?`, - catalogStateKeyLastReindex, - ).Scan(&raw); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, nil - } - return nil, fmt.Errorf("memory: load catalog reindex timestamp: %w", err) - } - parsed, err := storepkg.ParseTimestamp(raw) if err != nil { return nil, fmt.Errorf("memory: parse catalog reindex timestamp %q: %w", raw, err) @@ -263,6 +257,42 @@ func (c *catalog) lastReindex(ctx context.Context) (*time.Time, error) { return &parsed, nil } +func (c *catalog) setScopeReady(ctx context.Context, scope Scope, workspaceRoot string) error { + if err := c.upsertState( + ctx, + catalogScopeStateKey(scope, workspaceRoot), + storepkg.FormatTimestamp(c.now().UTC()), + ); err != nil { + return fmt.Errorf( + "memory: persist catalog scope state %q/%q: %w", + scope.Normalize(), + strings.TrimSpace(workspaceRoot), + err, + ) + } + return nil +} + +func (c *catalog) scopeReady(ctx context.Context, scope Scope, workspaceRoot string) (bool, error) { + raw, ok, err := c.stateValue(ctx, catalogScopeStateKey(scope, workspaceRoot)) + if err != nil { + return false, err + } + if !ok { + return false, nil + } + if _, err := storepkg.ParseTimestamp(raw); err != nil { + return false, fmt.Errorf( + "memory: parse catalog scope state %q/%q timestamp %q: %w", + scope.Normalize(), + strings.TrimSpace(workspaceRoot), + raw, + err, + ) + } + return true, nil +} + func (c *catalog) scopeEntryCount(ctx context.Context, scope Scope, workspaceRoot string) (int, error) { db, err := c.ensureDB(ctx) if err != nil { @@ -315,6 +345,7 @@ func (c *catalog) listEntries(ctx context.Context, filters []catalogFilter) ([]c return nil, fmt.Errorf("memory: list catalog entries: %w", err) } defer func() { + // rows.Err() or scanErr above reports any actionable read failure after we drain this SELECT result set. _ = rows.Close() }() @@ -376,9 +407,7 @@ func (c *catalog) search( if err != nil { return nil, err } - if limit <= 0 { - limit = defaultSearchLimit - } + limit = clampSearchLimit(limit) base := strings.Join([]string{ `SELECT`, @@ -406,6 +435,7 @@ func (c *catalog) search( return nil, fmt.Errorf("memory: search catalog: %w", err) } defer func() { + // rows.Err() or scanErr above reports any actionable read failure after we drain this SELECT result set. _ = rows.Close() }() @@ -544,9 +574,9 @@ func scanSearchResult(scanner interface{ Scan(dest ...any) error }) (SearchResul } func buildCatalogMatchQuery(query string) (string, error) { - terms := tokenizeSearchQuery(query) - if len(terms) == 0 { - return "", wrapValidationError("search query", query, errors.New("query is required")) + terms, err := searchQueryTerms(query) + if err != nil { + return "", err } quoted := make([]string, 0, len(terms)) for _, term := range terms { @@ -621,13 +651,11 @@ func buildCatalogDocument( } func fallbackSearchDocuments(query string, docs []catalogDocument, limit int) ([]SearchResult, error) { - terms := tokenizeSearchQuery(query) - if len(terms) == 0 { - return nil, wrapValidationError("search query", query, errors.New("query is required")) - } - if limit <= 0 { - limit = defaultSearchLimit + terms, err := searchQueryTerms(query) + if err != nil { + return nil, err } + limit = clampSearchLimit(limit) results := make([]SearchResult, 0, min(limit, len(docs))) for _, doc := range docs { @@ -664,6 +692,100 @@ func fallbackSearchDocuments(query string, docs []catalogDocument, limit int) ([ return results, nil } +func searchQueryTerms(query string) ([]string, error) { + terms := tokenizeSearchQuery(query) + if len(terms) == 0 { + return nil, wrapValidationError( + "search query", + query, + errors.New("query must include at least one letter or number"), + ) + } + return terms, nil +} + +func clampSearchLimit(limit int) int { + if limit <= 0 { + return defaultSearchLimit + } + return min(limit, maxSearchLimit) +} + +func (c *catalog) upsertState(ctx context.Context, key string, value string) error { + db, err := c.ensureDB(ctx) + if err != nil { + return err + } + if db == nil { + return nil + } + return upsertCatalogState(ctx, db, key, value) +} + +func (c *catalog) stateValue(ctx context.Context, key string) (string, bool, error) { + db, err := c.ensureDB(ctx) + if err != nil { + return "", false, err + } + if db == nil { + return "", false, nil + } + + var raw string + if err := db.QueryRowContext( + ctx, + `SELECT value FROM memory_catalog_state WHERE key = ?`, + key, + ).Scan(&raw); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return "", false, nil + } + return "", false, fmt.Errorf("memory: load catalog state %q: %w", key, err) + } + return raw, true, nil +} + +func catalogScopeStateKey(scope Scope, workspaceRoot string) string { + return fmt.Sprintf( + "%s%s::%s", + catalogStateKeyScopePrefix, + scope.Normalize(), + strings.TrimSpace(workspaceRoot), + ) +} + +func upsertCatalogStateTx(ctx context.Context, tx *sql.Tx, key string, value string) error { + if tx == nil { + return errors.New("catalog transaction is required") + } + if _, err := tx.ExecContext( + ctx, + `INSERT INTO memory_catalog_state (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + key, + value, + ); err != nil { + return fmt.Errorf("persist catalog state %q: %w", key, err) + } + return nil +} + +func upsertCatalogState(ctx context.Context, db *sql.DB, key string, value string) error { + if db == nil { + return nil + } + if _, err := db.ExecContext( + ctx, + `INSERT INTO memory_catalog_state (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + key, + value, + ); err != nil { + return fmt.Errorf("persist catalog state %q: %w", key, err) + } + return nil +} + func fallbackDocumentScore(doc catalogDocument, terms []string) float64 { searchable := strings.ToLower(strings.Join([]string{doc.Name, doc.Description, doc.Content}, "\n")) score := 0.0 diff --git a/internal/memory/store.go b/internal/memory/store.go index 8e081306d..97ae78997 100644 --- a/internal/memory/store.go +++ b/internal/memory/store.go @@ -333,10 +333,10 @@ func (s *Store) Search(ctx context.Context, query string, opts SearchOptions) ([ if err != nil { return nil, err } - limit := opts.Limit - if limit <= 0 { - limit = defaultSearchLimit + if _, err := searchQueryTerms(query); err != nil { + return nil, err } + limit := clampSearchLimit(opts.Limit) if err := s.ensureCatalogReady(ctx, scope, workspaceRoot); err != nil { return nil, err @@ -620,12 +620,20 @@ func (s *Store) ensureCatalogFilterReady(ctx context.Context, filter catalogFilt return nil } + ready, err := s.catalog.scopeReady(ctx, filter.scope, filter.workspaceRoot) + if err != nil { + return err + } + if ready { + return nil + } + entryCount, err := s.catalog.scopeEntryCount(ctx, filter.scope, filter.workspaceRoot) if err != nil { return err } if entryCount > 0 { - return nil + return s.catalog.setScopeReady(ctx, filter.scope, filter.workspaceRoot) } _, err = s.reindexScopes(ctx, filter.scope, filter.workspaceRoot) diff --git a/internal/memory/store_test.go b/internal/memory/store_test.go index 9282dfe07..1a2eb5cca 100644 --- a/internal/memory/store_test.go +++ b/internal/memory/store_test.go @@ -791,6 +791,33 @@ func TestStoreLoadIndexSynthesizesWhenIndexIsMissingOrStale(t *testing.T) { } func TestStoreSearchAndReindex(t *testing.T) { + t.Run("Should reject tokenless queries before warming the catalog", func(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + workspaceRoot := filepath.Join(baseDir, "workspace") + catalogPath := filepath.Join(baseDir, "agh.db") + store := NewStore( + filepath.Join(baseDir, "global"), + WithCatalogDatabasePath(catalogPath), + ).ForWorkspace(workspaceRoot) + if err := store.EnsureDirs(); err != nil { + t.Fatalf("Store.EnsureDirs() error = %v", err) + } + + _, err := store.Search( + context.Background(), + "!!!", + SearchOptions{Workspace: workspaceRoot, Limit: maxSearchLimit + 25}, + ) + if !errors.Is(err, ErrValidation) { + t.Fatalf("Store.Search() error = %v, want ErrValidation", err) + } + if _, statErr := os.Stat(catalogPath); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("os.Stat(%q) error = %v, want catalog database to remain absent", catalogPath, statErr) + } + }) + t.Run("Should search and reindex visible scopes on cold start", func(t *testing.T) { t.Parallel() @@ -849,6 +876,42 @@ func TestStoreSearchAndReindex(t *testing.T) { } }) + t.Run("Should clamp oversized search limits server-side", func(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + catalogPath := filepath.Join(baseDir, "agh.db") + store := NewStore( + filepath.Join(baseDir, "global"), + WithCatalogDatabasePath(catalogPath), + ) + if err := store.EnsureDirs(); err != nil { + t.Fatalf("Store.EnsureDirs() error = %v", err) + } + + for idx := range maxSearchLimit + 5 { + filename := fmt.Sprintf("shared-%02d.md", idx) + if err := store.Write(ScopeGlobal, filename, mustMemoryContent(t, testMemoryMeta{ + Name: fmt.Sprintf("Shared signal %02d", idx), + Description: "Common token across many memories", + Type: MemoryTypeUser, + }, "Common token appears in every generated memory.\n")); err != nil { + t.Fatalf("Store.Write(%q) error = %v", filename, err) + } + } + + results, err := store.Search(context.Background(), "common token", SearchOptions{ + Scope: ScopeGlobal, + Limit: maxSearchLimit + 25, + }) + if err != nil { + t.Fatalf("Store.Search() error = %v", err) + } + if len(results) != maxSearchLimit { + t.Fatalf("len(results) = %d, want %d", len(results), maxSearchLimit) + } + }) + t.Run("Should index a new workspace even when global catalog rows already exist", func(t *testing.T) { t.Parallel() @@ -910,6 +973,78 @@ func TestStoreSearchAndReindex(t *testing.T) { t.Fatalf("HealthStats() = %#v, want indexed=2 orphaned=0 lastReindex set", stats) } }) + + t.Run("Should not reindex empty synced scopes on subsequent reads", func(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + workspaceRoot := filepath.Join(baseDir, "workspace") + catalogPath := filepath.Join(baseDir, "agh.db") + store := NewStore( + filepath.Join(baseDir, "global"), + WithCatalogDatabasePath(catalogPath), + ).ForWorkspace(workspaceRoot) + if err := store.EnsureDirs(); err != nil { + t.Fatalf("Store.EnsureDirs() error = %v", err) + } + + results, err := store.Search(context.Background(), "auth", SearchOptions{ + Workspace: workspaceRoot, + Limit: 5, + }) + if err != nil { + t.Fatalf("Store.Search() error = %v", err) + } + if len(results) != 0 { + t.Fatalf("len(results) = %d, want 0", len(results)) + } + + workspaceReady, err := store.catalog.scopeReady(context.Background(), ScopeWorkspace, workspaceRoot) + if err != nil { + t.Fatalf("catalog.scopeReady(workspace) error = %v", err) + } + if !workspaceReady { + t.Fatal("catalog.scopeReady(workspace) = false, want true after empty reindex") + } + globalReady, err := store.catalog.scopeReady(context.Background(), ScopeGlobal, "") + if err != nil { + t.Fatalf("catalog.scopeReady(global) error = %v", err) + } + if !globalReady { + t.Fatal("catalog.scopeReady(global) = false, want true after empty reindex") + } + + firstReindex, err := store.catalog.lastReindex(context.Background()) + if err != nil { + t.Fatalf("catalog.lastReindex() error = %v", err) + } + if firstReindex == nil { + t.Fatal("catalog.lastReindex() = nil, want timestamp after initial reindex") + } + + stats, err := store.HealthStats(context.Background(), []string{workspaceRoot}) + if err != nil { + t.Fatalf("Store.HealthStats() error = %v", err) + } + if stats.IndexedFiles != 0 || stats.OrphanedFiles != 0 || stats.LastReindex == nil { + t.Fatalf("HealthStats() = %#v, want indexed=0 orphaned=0 lastReindex set", stats) + } + + secondReindex, err := store.catalog.lastReindex(context.Background()) + if err != nil { + t.Fatalf("catalog.lastReindex() error = %v", err) + } + if secondReindex == nil { + t.Fatal("catalog.lastReindex() = nil, want timestamp after health check") + } + if !secondReindex.Equal(*firstReindex) { + t.Fatalf( + "catalog.lastReindex() changed from %s to %s, want empty synced scopes to stay warm", + firstReindex.Format(time.RFC3339Nano), + secondReindex.Format(time.RFC3339Nano), + ) + } + }) } func TestStoreSearchTreatsFTSReservedWordsAsLiteralTerms(t *testing.T) { diff --git a/magefile.go b/magefile.go index 7514429bc..877509551 100644 --- a/magefile.go +++ b/magefile.go @@ -33,7 +33,10 @@ const ( driverBinaryEnvVar = "AGH_TEST_ACPMOCK_DRIVER_BIN" ) -var Default = Verify +var ( + Default = Verify + errRaceEnabledGoCommand = errors.New("run race-enabled go command") +) func Deps() error { return sh.RunV("go", "mod", "tidy") @@ -533,7 +536,7 @@ func mergeCommandEnv(overrides map[string]string) []string { func runRaceEnabledGoCommand(env map[string]string, args ...string) error { if err := runCommandInDirWithEnv(".", withRaceEnabledEnv(env), "go", args...); err != nil { - return fmt.Errorf("run race-enabled go command %v: %w", args, err) + return fmt.Errorf("%w %v: %w", errRaceEnabledGoCommand, args, err) } return nil } diff --git a/magefile_test.go b/magefile_test.go index 4ae960e9e..b28101757 100644 --- a/magefile_test.go +++ b/magefile_test.go @@ -3,7 +3,8 @@ package main import ( - "strings" + "errors" + "os/exec" "testing" "github.com/pedronauck/agh/internal/e2elane" @@ -94,18 +95,19 @@ func TestWithRaceEnabledEnv(t *testing.T) { func TestRunRaceEnabledGoCommand(t *testing.T) { t.Parallel() - t.Run("Should wrap subprocess failures with command context", func(t *testing.T) { + t.Run("Should wrap subprocess failures with typed context", func(t *testing.T) { t.Parallel() err := runRaceEnabledGoCommand(nil, "definitely-not-a-go-subcommand") if err == nil { t.Fatal("runRaceEnabledGoCommand() error = nil, want non-nil") } - if !strings.Contains(err.Error(), "run race-enabled go command") { - t.Fatalf("runRaceEnabledGoCommand() error = %q, want wrapper context", err) + if !errors.Is(err, errRaceEnabledGoCommand) { + t.Fatalf("runRaceEnabledGoCommand() error = %v, want errRaceEnabledGoCommand in chain", err) } - if !strings.Contains(err.Error(), "definitely-not-a-go-subcommand") { - t.Fatalf("runRaceEnabledGoCommand() error = %q, want failing args in context", err) + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("runRaceEnabledGoCommand() error = %v, want exec.ExitError in chain", err) } }) } From 07df2ad5f1a5fd49d1b0574015dbc36c5c7938cc Mon Sep 17 00:00:00 2001 From: Pedro Nauck Date: Fri, 17 Apr 2026 22:59:50 -0300 Subject: [PATCH 7/8] docs: update --- .compozy/tasks/mem-improvs/reviews-003/_meta.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .compozy/tasks/mem-improvs/reviews-003/_meta.md diff --git a/.compozy/tasks/mem-improvs/reviews-003/_meta.md b/.compozy/tasks/mem-improvs/reviews-003/_meta.md new file mode 100644 index 000000000..2a65c5b8b --- /dev/null +++ b/.compozy/tasks/mem-improvs/reviews-003/_meta.md @@ -0,0 +1,11 @@ +--- +provider: coderabbit +pr: "35" +round: 3 +created_at: 2026-04-18T01:48:04.355488Z +--- + +## Summary +- Total: 4 +- Resolved: 0 +- Unresolved: 4 From f600846bbd2d748eaef33211d79f9566804af892 Mon Sep 17 00:00:00 2001 From: Pedro Nauck Date: Fri, 17 Apr 2026 23:21:32 -0300 Subject: [PATCH 8/8] fix: resolve mem-improvs review batch 004 --- .../tasks/mem-improvs/reviews-004/_meta.md | 11 ++ .../mem-improvs/reviews-004/issue_001.md | 67 +++++++++++ .../mem-improvs/reviews-004/issue_002.md | 66 +++++++++++ .../mem-improvs/reviews-004/issue_003.md | 75 ++++++++++++ .../mem-improvs/reviews-004/issue_004.md | 28 +++++ internal/memory/store.go | 25 ++-- internal/memory/store_test.go | 72 ++++++++++++ magefile.go | 50 ++++---- magefile_test.go | 111 ++++++++++++------ 9 files changed, 438 insertions(+), 67 deletions(-) create mode 100644 .compozy/tasks/mem-improvs/reviews-004/_meta.md create mode 100644 .compozy/tasks/mem-improvs/reviews-004/issue_001.md create mode 100644 .compozy/tasks/mem-improvs/reviews-004/issue_002.md create mode 100644 .compozy/tasks/mem-improvs/reviews-004/issue_003.md create mode 100644 .compozy/tasks/mem-improvs/reviews-004/issue_004.md diff --git a/.compozy/tasks/mem-improvs/reviews-004/_meta.md b/.compozy/tasks/mem-improvs/reviews-004/_meta.md new file mode 100644 index 000000000..1e7f0128b --- /dev/null +++ b/.compozy/tasks/mem-improvs/reviews-004/_meta.md @@ -0,0 +1,11 @@ +--- +provider: coderabbit +pr: "35" +round: 4 +created_at: 2026-04-18T02:13:25.6776Z +--- + +## Summary +- Total: 4 +- Resolved: 0 +- Unresolved: 4 diff --git a/.compozy/tasks/mem-improvs/reviews-004/issue_001.md b/.compozy/tasks/mem-improvs/reviews-004/issue_001.md new file mode 100644 index 000000000..640861842 --- /dev/null +++ b/.compozy/tasks/mem-improvs/reviews-004/issue_001.md @@ -0,0 +1,67 @@ +--- +status: resolved +file: internal/memory/store.go +line: 424 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM575hox,comment:PRRC_kwDOR5y4QM65B3LS +--- + +# Issue 001: _⚠️ Potential issue_ | _🟠 Major_ +## Review Comment + +_⚠️ Potential issue_ | _🟠 Major_ + +**Normalize `HealthStats` workspace filters before deduping them.** + +This path bypasses `normalizeScopeAndWorkspace` and only trims the incoming strings. Supplying both `/repo/app` and `/repo/app/.agh/memory` will create two filters for the same workspace, and the memory-dir form will not match catalog rows keyed by workspace root. + + +
+🐛 Proposed fix + +```diff + for _, workspace := range workspaces { +- trimmed := strings.TrimSpace(workspace) ++ trimmed := deriveWorkspaceRoot(cleanDirPath(workspace)) + if trimmed == "" { + continue + } + if _, exists := seen[trimmed]; exists { + continue +``` +
+ +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/memory/store.go` around lines 415 - 424, The loop building +catalogFilter for ScopeWorkspace should normalize each incoming workspace string +before deduping: call normalizeScopeAndWorkspace (or the same normalization +logic) on the trimmed workspace to obtain the canonical workspaceRoot/scope, +handle/skip any errors or empty results, then use that normalized workspaceRoot +for the seen map and for constructing catalogFilter{scope: ScopeWorkspace, +workspaceRoot: normalized}. Replace the current raw-trim-and-dedupe flow +(variables seen, trimmed, filters append) so duplicates like "/repo/app" and +"/repo/app/.agh/memory" collapse to the same normalized workspaceRoot. +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Notes: + - `Store.HealthStats` currently trims workspace inputs but does not canonicalize them before deduping or building `catalogFilter` values. + - The catalog keys workspace rows by workspace root, while callers can provide either the workspace root or the workspace memory directory (`/.agh/memory`). + - That mismatch can produce duplicate filters for the same workspace and can miss ready-state or catalog rows keyed by the canonical root. + - Fix approach: normalize each incoming workspace path to a canonical workspace root before dedupe/filter construction, then use that normalized root for `seen` and `catalogFilter`. + - A focused regression test is required in `internal/memory/store_test.go` even though it is outside the listed code files, because the production bug is in `internal/memory/store.go` and needs coverage for root-vs-memory-dir inputs. + - Resolved by introducing canonical workspace-root normalization in `internal/memory/store.go` before HealthStats dedupe/filter construction. + - Verified with `go test ./internal/memory -run TestStoreNormalizesExplicitWorkspacePaths` and the full `make verify` gate. diff --git a/.compozy/tasks/mem-improvs/reviews-004/issue_002.md b/.compozy/tasks/mem-improvs/reviews-004/issue_002.md new file mode 100644 index 000000000..ab345841f --- /dev/null +++ b/.compozy/tasks/mem-improvs/reviews-004/issue_002.md @@ -0,0 +1,66 @@ +--- +status: resolved +file: internal/memory/store.go +line: 582 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM575hoy,comment:PRRC_kwDOR5y4QM65B3LV +--- + +# Issue 002: _⚠️ Potential issue_ | _🟠 Major_ +## Review Comment + +_⚠️ Potential issue_ | _🟠 Major_ + +**Normalize explicit workspace paths before using them.** + +If a caller passes `/.agh/memory` here, this helper keeps that suffix. The later `ForWorkspace(workspaceRoot)` call then resolves to `/.agh/memory/.agh/memory`, so `Search`/`Reindex` read the wrong directory and can split one workspace into two catalog keys. + + +
+🐛 Proposed fix + +```diff +- workspaceRoot := cleanDirPath(workspace) ++ workspaceRoot := deriveWorkspaceRoot(cleanDirPath(workspace)) + if workspaceRoot == "" { + workspaceRoot = deriveWorkspaceRoot(s.workspaceDir) + } +``` +
+ +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/memory/store.go` around lines 579 - 582, The code currently uses +cleanDirPath(workspace) directly which preserves an explicit +"/.agh/memory" suffix and causes ForWorkspace(workspaceRoot) to +resolve to "/.agh/memory/.agh/memory"; fix by normalizing and +stripping any trailing ".agh/memory" before using workspaceRoot: after computing +workspaceRoot := cleanDirPath(workspace) (and after the deriveWorkspaceRoot +fallback), detect if workspaceRoot refers inside the memory subdir (compare +against filepath.Join(".agh","memory") or use strings.HasSuffix with +filepath.Join) and if so set workspaceRoot to the parent workspace directory +(remove the ".agh/memory" suffix) so subsequent calls like +ForWorkspace(workspaceRoot), Search and Reindex operate on the correct root. +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Notes: + - `normalizeScopeAndWorkspace` currently uses `cleanDirPath(workspace)` directly, so an explicit `/.agh/memory` input remains unchanged. + - Later workspace-scoped operations call `ForWorkspace(workspaceRoot)`, which appends `.agh/memory` again and can target `/.agh/memory/.agh/memory`. + - That can split one logical workspace across two catalog keys and make workspace search/reindex target the wrong directory. + - Fix approach: canonicalize explicit workspace inputs to the workspace root before returning from `normalizeScopeAndWorkspace`, while still falling back to `deriveWorkspaceRoot(s.workspaceDir)` when no explicit workspace is supplied. + - A focused regression test is required in `internal/memory/store_test.go` even though it is outside the listed code files, because the bug is in workspace path normalization and needs direct coverage in the memory package tests. + - Resolved by normalizing explicit workspace inputs and by making `Store.ForWorkspace` treat memory-dir inputs as the canonical workspace root. + - Verified with `go test ./internal/memory -run TestStoreNormalizesExplicitWorkspacePaths` and the full `make verify` gate. diff --git a/.compozy/tasks/mem-improvs/reviews-004/issue_003.md b/.compozy/tasks/mem-improvs/reviews-004/issue_003.md new file mode 100644 index 000000000..706fd1301 --- /dev/null +++ b/.compozy/tasks/mem-improvs/reviews-004/issue_003.md @@ -0,0 +1,75 @@ +--- +status: resolved +file: magefile.go +line: 542 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM575hoz,comment:PRRC_kwDOR5y4QM65B3LW +--- + +# Issue 003: _⚠️ Potential issue_ | _🔴 Critical_ +## Review Comment + +_⚠️ Potential issue_ | _🔴 Critical_ + +
+🧩 Analysis chain + +🏁 Script executed: + +```shell +#!/bin/bash +# Inspect the subprocess helper stack before threading context through it. +sed -n '482,545p' magefile.go +rg -n --type=go '\b(runCommandInDirWithEnv|runRaceEnabledGoCommand)\(' magefile.go magefile_test.go +``` + +Repository: compozy/agh + +Length of output: 2774 + +--- + + + +
+ +**Thread `context.Context` through subprocess helpers and fix error wrapping pattern.** + +`runRaceEnabledGoCommand` and `runCommandInDirWithEnv` add runtime-boundary wrappers around `exec.Command` without cancellation paths. Passing a context through the helper stack and switching to `exec.CommandContext` prevents stuck invocations from hanging. + +Additionally, the error format `fmt.Errorf("%w %v: %w", errRaceEnabledGoCommand, args, err)` violates the idiomatic error wrapping pattern. Per coding guidelines, use a single wrapped error with context: `fmt.Errorf("context: %w", err)`. Rewrap to `fmt.Errorf("race-enabled go command %v: %w", args, err)` to properly convey args within the message and wrap only the underlying error. + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@magefile.go` around lines 537 - 542, The helper runRaceEnabledGoCommand +should accept a context and pass it down to runCommandInDirWithEnv which in turn +must use exec.CommandContext instead of exec.Command so subprocesses are +cancellable; update function signatures (runRaceEnabledGoCommand, +runCommandInDirWithEnv) to include ctx context.Context and thread ctx through +the call chain. Also fix the error wrapping in runRaceEnabledGoCommand to use a +single %w wrap and include the args in the message (e.g., +fmt.Errorf("race-enabled go command %v: %w", args, err)) so only the underlying +error is wrapped and the args are included as context. Ensure all call sites are +updated to provide a context. +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Notes: + - `runCommandInDirWithEnv` currently uses `exec.Command`, so the subprocess helpers do not accept or propagate cancellation at the runtime boundary. + - `runRaceEnabledGoCommand` also uses a non-idiomatic double-wrap format string (`%w ... %w`) that makes the error chain harder to reason about and is inconsistent with the repo’s error-wrapping rule. + - Fix approach: thread `context.Context` through `runCommandInDir`, `runCommandInDirWithEnv`, and `runRaceEnabledGoCommand`, switch the helper implementation to `exec.CommandContext`, and rewrap subprocess failures with contextual text plus a single `%w`. + - Current mage entrypoints do not expose a caller-provided context, so existing call sites will pass `context.Background()` until a higher-level cancellation source exists. + - Resolved by threading `context.Context` through the mage subprocess helper stack, switching the helper to `exec.CommandContext`, and simplifying the rewrap to a single `%w`. + - Verified with `go test -tags mage . -run 'TestWithRaceEnabledEnv|TestRunRaceEnabledGoCommand'` and the full `make verify` gate. diff --git a/.compozy/tasks/mem-improvs/reviews-004/issue_004.md b/.compozy/tasks/mem-improvs/reviews-004/issue_004.md new file mode 100644 index 000000000..e539fbd91 --- /dev/null +++ b/.compozy/tasks/mem-improvs/reviews-004/issue_004.md @@ -0,0 +1,28 @@ +--- +status: resolved +file: magefile_test.go +line: 56 +severity: nitpick +author: coderabbitai[bot] +provider_ref: review:4133244010,nitpick_hash:4c0919e7ca32 +review_hash: 4c0919e7ca32 +source_review_id: "4133244010" +source_review_submitted_at: "2026-04-18T02:06:20Z" +--- + +# Issue 004: Prefer a table-driven shape for these env variants. +## Review Comment + +These two subtests are just input/output variations of the same behavior. A small table would remove the duplicated setup/assertion flow and make future env cases cheaper to add. + +As per coding guidelines, Use table-driven tests with subtests (`t.Run`) as default. + +## Triage + +- Decision: `valid` +- Notes: + - `TestWithRaceEnabledEnv` currently uses two subtests that differ only by input and expected values. + - The repo guidelines require table-driven tests with subtests by default, and this case is a straightforward fit. + - Fix approach: collapse the env-variant coverage into a single table-driven subtest structure while preserving the mutation and nil-input assertions. + - Resolved by rewriting `TestWithRaceEnabledEnv` into a table-driven test while keeping the mutation and nil-input coverage intact. + - Verified with `go test -tags mage . -run 'TestWithRaceEnabledEnv|TestRunRaceEnabledGoCommand'` and the full `make verify` gate. diff --git a/internal/memory/store.go b/internal/memory/store.go index 97ae78997..ed44f7518 100644 --- a/internal/memory/store.go +++ b/internal/memory/store.go @@ -80,7 +80,7 @@ func WithCatalogDatabasePath(path string) StoreOption { // ForWorkspace returns a clone of the store bound to the supplied workspace root. func (s *Store) ForWorkspace(workspaceRoot string) *Store { clone := *s - clone.workspaceDir = workspaceMemoryDir(workspaceRoot) + clone.workspaceDir = workspaceMemoryDir(canonicalWorkspaceRoot(workspaceRoot)) return &clone } @@ -413,15 +413,15 @@ func (s *Store) HealthStats(ctx context.Context, workspaces []string) (HealthSta filters = append(filters, catalogFilter{scope: ScopeGlobal}) seen := map[string]struct{}{} for _, workspace := range workspaces { - trimmed := strings.TrimSpace(workspace) - if trimmed == "" { + workspaceRoot := canonicalWorkspaceRoot(workspace) + if workspaceRoot == "" { continue } - if _, exists := seen[trimmed]; exists { + if _, exists := seen[workspaceRoot]; exists { continue } - seen[trimmed] = struct{}{} - filters = append(filters, catalogFilter{scope: ScopeWorkspace, workspaceRoot: trimmed}) + seen[workspaceRoot] = struct{}{} + filters = append(filters, catalogFilter{scope: ScopeWorkspace, workspaceRoot: workspaceRoot}) } for _, filter := range filters { @@ -576,7 +576,7 @@ func (s *Store) normalizeScopeAndWorkspace(scope Scope, workspace string) (Scope } } - workspaceRoot := cleanDirPath(workspace) + workspaceRoot := canonicalWorkspaceRoot(workspace) if workspaceRoot == "" { workspaceRoot = deriveWorkspaceRoot(s.workspaceDir) } @@ -912,6 +912,17 @@ func cleanDirPath(path string) string { return filepath.Clean(trimmed) } +func canonicalWorkspaceRoot(path string) string { + clean := cleanDirPath(path) + if clean == "" { + return "" + } + if root := deriveWorkspaceRoot(clean); root != "" { + return root + } + return clean +} + func cleanFilename(filename string) (string, error) { trimmed := strings.TrimSpace(filename) if trimmed == "" { diff --git a/internal/memory/store_test.go b/internal/memory/store_test.go index 1a2eb5cca..f0f672561 100644 --- a/internal/memory/store_test.go +++ b/internal/memory/store_test.go @@ -1169,6 +1169,78 @@ func TestWorkspaceMemoryDirRoundTripsToWorkspaceRoot(t *testing.T) { }) } +func TestStoreNormalizesExplicitWorkspacePaths(t *testing.T) { + t.Parallel() + + t.Run("Should search workspace memories when the workspace option points at the memory dir", func(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + workspaceRoot := filepath.Join(baseDir, "workspace") + store := NewStore( + filepath.Join(baseDir, "global"), + WithCatalogDatabasePath(filepath.Join(baseDir, "agh.db")), + ).ForWorkspace(workspaceRoot) + if err := store.EnsureDirs(); err != nil { + t.Fatalf("Store.EnsureDirs() error = %v", err) + } + if err := store.Write(ScopeWorkspace, "project.md", mustMemoryContent(t, testMemoryMeta{ + Name: "Workspace Search", + Description: "Normalize explicit workspace paths", + Type: MemoryTypeProject, + }, "Unique workspace signal for normalization coverage.\n")); err != nil { + t.Fatalf("Store.Write(workspace) error = %v", err) + } + + results, err := store.Search(context.Background(), "unique workspace signal", SearchOptions{ + Scope: ScopeWorkspace, + Workspace: workspaceMemoryDir(workspaceRoot), + Limit: 5, + }) + if err != nil { + t.Fatalf("Store.Search() error = %v", err) + } + if len(results) != 1 { + t.Fatalf("len(results) = %d, want 1; results=%#v", len(results), results) + } + if results[0].Scope != ScopeWorkspace { + t.Fatalf("results[0].Scope = %q, want %q", results[0].Scope, ScopeWorkspace) + } + if results[0].Workspace != workspaceRoot { + t.Fatalf("results[0].Workspace = %q, want %q", results[0].Workspace, workspaceRoot) + } + }) + + t.Run("Should include workspace memories in health stats when given the memory dir form", func(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + workspaceRoot := filepath.Join(baseDir, "workspace") + store := NewStore( + filepath.Join(baseDir, "global"), + WithCatalogDatabasePath(filepath.Join(baseDir, "agh.db")), + ).ForWorkspace(workspaceRoot) + if err := store.EnsureDirs(); err != nil { + t.Fatalf("Store.EnsureDirs() error = %v", err) + } + if err := store.Write(ScopeWorkspace, "project.md", mustMemoryContent(t, testMemoryMeta{ + Name: "Workspace Health", + Description: "Normalize health stats workspace filters", + Type: MemoryTypeProject, + }, "Workspace health stats should use the canonical workspace root.\n")); err != nil { + t.Fatalf("Store.Write(workspace) error = %v", err) + } + + stats, err := store.HealthStats(context.Background(), []string{workspaceMemoryDir(workspaceRoot)}) + if err != nil { + t.Fatalf("Store.HealthStats() error = %v", err) + } + if stats.IndexedFiles != 1 || stats.OrphanedFiles != 0 || stats.LastReindex == nil { + t.Fatalf("HealthStats() = %#v, want indexed=1 orphaned=0 lastReindex set", stats) + } + }) +} + func TestStoreRejectsInvalidInputs(t *testing.T) { t.Parallel() diff --git a/magefile.go b/magefile.go index 877509551..77d634bcc 100644 --- a/magefile.go +++ b/magefile.go @@ -4,6 +4,7 @@ package main import ( "bytes" + "context" "errors" "fmt" "io/fs" @@ -34,8 +35,7 @@ const ( ) var ( - Default = Verify - errRaceEnabledGoCommand = errors.New("run race-enabled go command") + Default = Verify ) func Deps() error { @@ -88,7 +88,7 @@ func Test() error { if err := ensureWebBundle(); err != nil { return err } - return runRaceEnabledGoCommand(nil, + return runRaceEnabledGoCommand(context.Background(), nil, "run", "gotest.tools/gotestsum@"+gotestsumVersion, "--format", "pkgname", "--", "-race", "-parallel=4", "./...") } @@ -98,7 +98,7 @@ func TestIntegration() error { if err := ensureWebBundle(); err != nil { return err } - return runRaceEnabledGoCommand(nil, + return runRaceEnabledGoCommand(context.Background(), nil, "run", "gotest.tools/gotestsum@"+gotestsumVersion, "--format", "pkgname", "--", "-race", "-parallel=4", "-tags", "integration", "./...") } @@ -134,33 +134,33 @@ func Build() error { } func Codegen() error { - if err := runCommandInDir(".", "go", "run", "./cmd/agh-codegen", "all"); err != nil { + if err := runCommandInDir(context.Background(), ".", "go", "run", "./cmd/agh-codegen", "all"); err != nil { return err } return generateWebOpenAPITypes(webOpenAPITypePath) } func CodegenCheck() error { - if err := runCommandInDir(".", "go", "run", "./cmd/agh-codegen", "check"); err != nil { + if err := runCommandInDir(context.Background(), ".", "go", "run", "./cmd/agh-codegen", "check"); err != nil { return err } return checkWebOpenAPITypes(webOpenAPITypePath) } func WebLint() error { - return runCommandInDir("web", "bun", "run", "lint") + return runCommandInDir(context.Background(), "web", "bun", "run", "lint") } func WebTypecheck() error { - return runCommandInDir("web", "bun", "run", "typecheck:raw") + return runCommandInDir(context.Background(), "web", "bun", "run", "typecheck:raw") } func WebTest() error { - return runCommandInDir("web", "bun", "run", "test:raw") + return runCommandInDir(context.Background(), "web", "bun", "run", "test:raw") } func WebBuild() error { - return runCommandInDir("web", "bun", "run", "build:raw") + return runCommandInDir(context.Background(), "web", "bun", "run", "build:raw") } func buildGo() error { @@ -273,10 +273,10 @@ func generateWebOpenAPITypes(outputPath string) error { if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { return err } - if err := runCommandInDir(".", "bunx", "openapi-typescript", openAPISpecPath, "-o", outputPath); err != nil { + if err := runCommandInDir(context.Background(), ".", "bunx", "openapi-typescript", openAPISpecPath, "-o", outputPath); err != nil { return err } - return runCommandInDir(".", "bunx", "oxfmt", outputPath) + return runCommandInDir(context.Background(), ".", "bunx", "oxfmt", outputPath) } func checkWebOpenAPITypes(path string) error { @@ -370,6 +370,8 @@ func ensureWebBundle() error { } func runE2ELane(lane e2elane.Lane) error { + ctx := context.Background() + plan, err := e2elane.PlanForLane(lane) if err != nil { return err @@ -387,13 +389,13 @@ func runE2ELane(lane e2elane.Lane) error { } for _, suite := range plan.GoSuites { - if err := runIntegrationSuite(suite, laneEnv); err != nil { + if err := runIntegrationSuite(ctx, suite, laneEnv); err != nil { return err } } for _, suite := range plan.ScriptSuites { - if err := runCommandInDirWithEnv(suite.Dir, laneEnv, "bun", "run", suite.Script); err != nil { + if err := runCommandInDirWithEnv(ctx, suite.Dir, laneEnv, "bun", "run", suite.Script); err != nil { return err } } @@ -408,6 +410,7 @@ func shouldEnsureWebBundle(plan e2elane.Plan) bool { func prepareE2ELaneEnv() (map[string]string, error) { daemonPath, err := resolveOrBuildLaneBinary(daemonBinaryEnvVar, func(outputPath string) error { return runCommandInDir( + context.Background(), ".", "go", "build", @@ -424,6 +427,7 @@ func prepareE2ELaneEnv() (map[string]string, error) { driverPath, err := resolveOrBuildLaneBinary(driverBinaryEnvVar, func(outputPath string) error { return runCommandInDir( + context.Background(), ".", "go", "build", @@ -479,7 +483,7 @@ func laneBinaryName(name string) string { return name } -func runIntegrationSuite(suite e2elane.GoSuite, env map[string]string) error { +func runIntegrationSuite(ctx context.Context, suite e2elane.GoSuite, env map[string]string) error { args := []string{ "run", "gotest.tools/gotestsum@" + gotestsumVersion, @@ -496,15 +500,15 @@ func runIntegrationSuite(suite e2elane.GoSuite, env map[string]string) error { args = append(args, "-run", suite.Run) } args = append(args, suite.Packages...) - return runRaceEnabledGoCommand(env, args...) + return runRaceEnabledGoCommand(ctx, env, args...) } -func runCommandInDir(dir string, name string, args ...string) error { - return runCommandInDirWithEnv(dir, nil, name, args...) +func runCommandInDir(ctx context.Context, dir string, name string, args ...string) error { + return runCommandInDirWithEnv(ctx, dir, nil, name, args...) } -func runCommandInDirWithEnv(dir string, env map[string]string, name string, args ...string) error { - cmd := exec.Command(name, args...) +func runCommandInDirWithEnv(ctx context.Context, dir string, env map[string]string, name string, args ...string) error { + cmd := exec.CommandContext(ctx, name, args...) cmd.Dir = dir cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr @@ -534,9 +538,9 @@ func mergeCommandEnv(overrides map[string]string) []string { return env } -func runRaceEnabledGoCommand(env map[string]string, args ...string) error { - if err := runCommandInDirWithEnv(".", withRaceEnabledEnv(env), "go", args...); err != nil { - return fmt.Errorf("%w %v: %w", errRaceEnabledGoCommand, args, err) +func runRaceEnabledGoCommand(ctx context.Context, env map[string]string, args ...string) error { + if err := runCommandInDirWithEnv(ctx, ".", withRaceEnabledEnv(env), "go", args...); err != nil { + return fmt.Errorf("race-enabled go command %v: %w", args, err) } return nil } diff --git a/magefile_test.go b/magefile_test.go index b28101757..76c8754ca 100644 --- a/magefile_test.go +++ b/magefile_test.go @@ -3,8 +3,10 @@ package main import ( + "context" "errors" "os/exec" + "strings" "testing" "github.com/pedronauck/agh/internal/e2elane" @@ -56,58 +58,93 @@ func TestShouldEnsureWebBundle(t *testing.T) { func TestWithRaceEnabledEnv(t *testing.T) { t.Parallel() - t.Run("Should set cgo for race commands without mutating the input", func(t *testing.T) { - t.Parallel() - - overrides := map[string]string{ - "CI": "true", - "CGO_ENABLED": "0", - } - - got := withRaceEnabledEnv(overrides) - - if got["CGO_ENABLED"] != "1" { - t.Fatalf("withRaceEnabledEnv() CGO_ENABLED = %q, want %q", got["CGO_ENABLED"], "1") - } - if got["CI"] != "true" { - t.Fatalf("withRaceEnabledEnv() CI = %q, want %q", got["CI"], "true") - } - if overrides["CGO_ENABLED"] != "0" { - t.Fatalf("withRaceEnabledEnv() mutated input CGO_ENABLED to %q", overrides["CGO_ENABLED"]) - } - - got["EXTRA"] = "value" - if _, ok := overrides["EXTRA"]; ok { - t.Fatal("withRaceEnabledEnv() reused the input map") - } - }) + tests := []struct { + name string + overrides map[string]string + want map[string]string + wantInput map[string]string + }{ + { + name: "Should set cgo for race commands without mutating the input", + overrides: map[string]string{ + "CI": "true", + "CGO_ENABLED": "0", + }, + want: map[string]string{ + "CI": "true", + "CGO_ENABLED": "1", + }, + wantInput: map[string]string{ + "CI": "true", + "CGO_ENABLED": "0", + }, + }, + { + name: "Should work with nil input", + want: map[string]string{ + "CGO_ENABLED": "1", + }, + }, + } - t.Run("Should work with nil input", func(t *testing.T) { - t.Parallel() + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() - got := withRaceEnabledEnv(nil) - if got["CGO_ENABLED"] != "1" { - t.Fatalf("withRaceEnabledEnv(nil) CGO_ENABLED = %q, want %q", got["CGO_ENABLED"], "1") - } - }) + got := withRaceEnabledEnv(tt.overrides) + for key, want := range tt.want { + if got[key] != want { + t.Fatalf("withRaceEnabledEnv() %s = %q, want %q", key, got[key], want) + } + } + if tt.wantInput != nil { + for key, want := range tt.wantInput { + if tt.overrides[key] != want { + t.Fatalf("withRaceEnabledEnv() mutated input %s to %q, want %q", key, tt.overrides[key], want) + } + } + + got["EXTRA"] = "value" + if _, ok := tt.overrides["EXTRA"]; ok { + t.Fatal("withRaceEnabledEnv() reused the input map") + } + } + }) + } } func TestRunRaceEnabledGoCommand(t *testing.T) { t.Parallel() - t.Run("Should wrap subprocess failures with typed context", func(t *testing.T) { + t.Run("Should wrap subprocess failures with command context", func(t *testing.T) { t.Parallel() - err := runRaceEnabledGoCommand(nil, "definitely-not-a-go-subcommand") + err := runRaceEnabledGoCommand(context.Background(), nil, "definitely-not-a-go-subcommand") if err == nil { t.Fatal("runRaceEnabledGoCommand() error = nil, want non-nil") } - if !errors.Is(err, errRaceEnabledGoCommand) { - t.Fatalf("runRaceEnabledGoCommand() error = %v, want errRaceEnabledGoCommand in chain", err) - } var exitErr *exec.ExitError if !errors.As(err, &exitErr) { t.Fatalf("runRaceEnabledGoCommand() error = %v, want exec.ExitError in chain", err) } + if !strings.Contains(err.Error(), "definitely-not-a-go-subcommand") { + t.Fatalf("runRaceEnabledGoCommand() error = %v, want command arguments in message", err) + } + }) + + t.Run("Should respect canceled contexts", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := runRaceEnabledGoCommand(ctx, nil, "version") + if err == nil { + t.Fatal("runRaceEnabledGoCommand() error = nil, want context cancellation") + } + if !errors.Is(err, context.Canceled) { + t.Fatalf("runRaceEnabledGoCommand() error = %v, want context.Canceled in chain", err) + } }) }