Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,12 @@ Engram is local-first: local SQLite is authoritative; cloud features are optiona
- `GET /observations` β€” Recent observations compatibility endpoint. Query: `?project=X&scope=project|personal|global&limit=N&sort=created_at:desc`
- `GET /observations/recent` β€” Recent observations. Query: `?project=X&scope=project|personal|global&limit=N`
- `GET /observations/{id}` β€” Get single observation by ID
- `PATCH /observations/{id}` β€” Update fields. Body: `{title?, content?, type?, project?, scope?, topic_key?}`
- `PATCH /observations/{id}` β€” Update fields. Body: `{title?, content?, find?, replace?, type?, project?, scope?, topic_key?}`
- `find` and `replace` provide an atomic literal find/replace against the observation's current content. A complete pair replaces every literal occurrence; regular expressions, case-insensitive matching, and replacement limits are not supported.
- `find` and `replace` must be supplied together. Supplying only one (including an empty string) returns `400 {"error":"find and replace must be used together"}`.
- `find/replace` is mutually exclusive with `content`. Supplying all three returns `400 {"error":"find/replace is mutually exclusive with content"}`.
- An empty or non-matching `find` leaves content unchanged, but still performs the normal update: revision and sync semantics remain intact, so the revision advances and an observation-upsert sync mutation is recorded.
- Example: `PATCH /observations/7` with `{"find":"alpha","replace":"beta"}` changes `alpha alpha` to `beta beta` in one transaction.
- `DELETE /observations/{id}` β€” Delete observation (`?hard=true` for hard delete, soft delete by default)
- `200` when deleted
- `404` when observation does not exist
Expand Down Expand Up @@ -852,7 +857,19 @@ Save responses include lifecycle metadata for the saved observation: computed `s

### mem_update

Update an observation by ID. Public schema supports partial updates for `title`, `content`, `type`, `scope`, and `topic_key`. For legacy/raw MCP clients, a non-empty `project` argument is still tolerated by the handler even though it is not exposed in the schema.
Update an observation by ID. Supports partial updates for `title`, `content`, `find`, `replace`, `type`, `project`, `scope`, and `topic_key`.

`find` and `replace` atomically replace every literal occurrence in the stored content. Both fields are required together; a lone field returns exactly `find and replace must be used together`. A complete pair cannot be sent with `content`; that combination returns exactly `find/replace is mutually exclusive with content`. An empty or non-matching `find` is a content no-op, while preserving the normal revision and sync side effects.

Example:

```json
{
"id": 7,
"find": "alpha",
"replace": "beta"
}
```

### mem_review

Expand Down
19 changes: 17 additions & 2 deletions internal/mcp/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,7 @@ Examples:
if shouldRegister("mem_update", allowlist) {
srv.AddTool(
mcp.NewTool("mem_update",
mcp.WithDescription("Update an existing observation by ID. Only provided fields are changed."),
mcp.WithDescription("Update an existing observation by ID. Only provided fields are changed. find and replace must be supplied together and cannot be combined with content."),
mcp.WithDeferLoading(true),
mcp.WithTitleAnnotation("Update Memory"),
mcp.WithReadOnlyHintAnnotation(false),
Expand All @@ -390,6 +390,12 @@ Examples:
mcp.WithString("content",
mcp.Description("New content"),
),
mcp.WithString("find",
mcp.Description("Literal text to replace everywhere in the current content; requires replace"),
),
mcp.WithString("replace",
mcp.Description("Replacement text for every literal find occurrence; requires find"),
),
mcp.WithString("type",
mcp.Description("New type/category"),
),
Expand Down Expand Up @@ -1401,6 +1407,12 @@ func handleUpdate(s *store.Store) server.ToolHandlerFunc {
if v, ok := req.GetArguments()["content"].(string); ok {
update.Content = &v
}
if v, ok := req.GetArguments()["find"].(string); ok {
update.Find = &v
}
if v, ok := req.GetArguments()["replace"].(string); ok {
update.Replace = &v
}
if v, ok := req.GetArguments()["type"].(string); ok {
update.Type = &v
}
Expand All @@ -1411,7 +1423,7 @@ func handleUpdate(s *store.Store) server.ToolHandlerFunc {
update.TopicKey = &v
}

if update.Title == nil && update.Content == nil && update.Type == nil && update.Project == nil && update.Scope == nil && update.TopicKey == nil {
if update.Title == nil && update.Content == nil && update.Find == nil && update.Replace == nil && update.Type == nil && update.Project == nil && update.Scope == nil && update.TopicKey == nil {
return mcp.NewToolResultError("provide at least one field to update"), nil
}

Expand All @@ -1422,6 +1434,9 @@ func handleUpdate(s *store.Store) server.ToolHandlerFunc {

obs, err := s.UpdateObservation(id, update)
if err != nil {
if errors.Is(err, store.ErrFindReplacePairRequired) || errors.Is(err, store.ErrFindReplaceWithContent) {
return mcp.NewToolResultError(err.Error()), nil
}
return mcp.NewToolResultError("Failed to update memory: " + err.Error()), nil
}

Expand Down
73 changes: 73 additions & 0 deletions internal/mcp/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1478,6 +1478,79 @@ func TestHandleUpdateAcceptsAllOptionalFields(t *testing.T) {
}
}

func TestHandleUpdateFindReplaceContract(t *testing.T) {
s := newMCPTestStore(t)
if err := s.CreateSession("mcp-find-replace", "engram", "/tmp/engram"); err != nil {
t.Fatalf("create session: %v", err)
}
id, err := s.AddObservation(store.AddObservationParams{
SessionID: "mcp-find-replace",
Type: "note",
Title: "replacement",
Content: "alpha alpha",
Project: "engram",
})
if err != nil {
t.Fatalf("add observation: %v", err)
}

h := handleUpdate(s)
updated, err := h(context.Background(), mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: map[string]any{
"id": float64(id),
"find": "alpha",
"replace": "beta",
}}})
if err != nil {
t.Fatalf("replace handler error: %v", err)
}
if updated.IsError {
t.Fatalf("expected replacement success, got %q", callResultText(t, updated))
}
observation, err := s.GetObservation(id)
if err != nil {
t.Fatalf("get replaced observation: %v", err)
}
if observation.Content != "beta beta" {
t.Fatalf("expected all occurrences replaced, got %q", observation.Content)
}

for _, args := range []map[string]any{
{"id": float64(id), "find": ""},
{"id": float64(id), "replace": "beta"},
{"id": float64(id), "find": "beta", "replace": "gamma", "content": "replacement"},
} {
res, err := h(context.Background(), mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: args}})
if err != nil {
t.Fatalf("invalid handler error: %v", err)
}
if !res.IsError {
t.Fatalf("expected tool error for %#v", args)
}
got := callResultText(t, res)
want := "find and replace must be used together"
if args["content"] != nil {
want = "find/replace is mutually exclusive with content"
}
if got != want {
t.Fatalf("expected exact error %q, got %q", want, got)
}
}
}

func TestMemUpdateSchemaIncludesFindAndReplace(t *testing.T) {
srv := NewServer(newMCPTestStore(t))
tool := srv.GetTool("mem_update")
if tool == nil {
t.Fatal("mem_update tool not registered")
}

for _, property := range []string{"find", "replace"} {
if _, ok := tool.Tool.InputSchema.Properties[property]; !ok {
t.Errorf("mem_update schema missing %q property", property)
}
}
}

func TestHandleContextWithSessionOnlyUsesNoneProjects(t *testing.T) {
s := newMCPTestStore(t)
if err := s.CreateSession("s-context-none", "engram", "/tmp/engram"); err != nil {
Expand Down
11 changes: 9 additions & 2 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -458,14 +458,21 @@ func (s *Server) handleUpdateObservation(w http.ResponseWriter, r *http.Request)
return
}

if body.Type == nil && body.Title == nil && body.Content == nil && body.Project == nil && body.Scope == nil && body.TopicKey == nil {
if body.Type == nil && body.Title == nil && body.Content == nil && body.Find == nil && body.Replace == nil && body.Project == nil && body.Scope == nil && body.TopicKey == nil {
jsonError(w, http.StatusBadRequest, "at least one field is required")
return
}

obs, err := s.store.UpdateObservation(id, body)
if err != nil {
jsonError(w, http.StatusNotFound, err.Error())
switch {
case errors.Is(err, store.ErrFindReplacePairRequired), errors.Is(err, store.ErrFindReplaceWithContent):
jsonError(w, http.StatusBadRequest, err.Error())
case errors.Is(err, sql.ErrNoRows), errors.Is(err, store.ErrObservationNotFound):
jsonError(w, http.StatusNotFound, err.Error())
default:
jsonError(w, http.StatusInternalServerError, err.Error())
}
return
}

Expand Down
87 changes: 87 additions & 0 deletions internal/server/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
Expand Down Expand Up @@ -523,6 +524,92 @@ func TestExportRejectsExplicitBlankProjectQuery(t *testing.T) {
}
}

func TestHandleUpdateObservationFindReplaceContract(t *testing.T) {
st := newServerTestStore(t)
if err := st.CreateSession("http-find-replace", "engram", "/tmp/engram"); err != nil {
t.Fatalf("create session: %v", err)
}
id, err := st.AddObservation(store.AddObservationParams{
SessionID: "http-find-replace",
Type: "note",
Title: "replacement",
Content: "alpha alpha",
Project: "engram",
})
if err != nil {
t.Fatalf("add observation: %v", err)
}

srv := New(st, 0)
h := srv.Handler()

replaceReq := httptest.NewRequest(http.MethodPatch, fmt.Sprintf("/observations/%d", id), strings.NewReader(`{"find":"alpha","replace":"beta"}`))
replaceRec := httptest.NewRecorder()
h.ServeHTTP(replaceRec, replaceReq)
if replaceRec.Code != http.StatusOK {
t.Fatalf("expected replacement status 200, got %d: %s", replaceRec.Code, replaceRec.Body.String())
}
var updated store.Observation
if err := json.NewDecoder(replaceRec.Body).Decode(&updated); err != nil {
t.Fatalf("decode replacement response: %v", err)
}
if updated.Content != "beta beta" {
t.Fatalf("expected all occurrences replaced, got %q", updated.Content)
}

tests := []struct {
name string
body string
want string
}{
{name: "find only", body: `{"find":""}`, want: "find and replace must be used together"},
{name: "replace only", body: `{"replace":"gamma"}`, want: "find and replace must be used together"},
{name: "pair with content", body: `{"find":"beta","replace":"gamma","content":"replacement"}`, want: "find/replace is mutually exclusive with content"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPatch, fmt.Sprintf("/observations/%d", id), strings.NewReader(tt.body))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d: %s", rec.Code, rec.Body.String())
}
var response map[string]string
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
t.Fatalf("decode error response: %v", err)
}
if response["error"] != tt.want {
t.Fatalf("expected exact error %q, got %q", tt.want, response["error"])
}
})
}

missingReq := httptest.NewRequest(http.MethodPatch, "/observations/999999", strings.NewReader(`{"find":"beta","replace":"gamma"}`))
missingRec := httptest.NewRecorder()
h.ServeHTTP(missingRec, missingReq)
if missingRec.Code != http.StatusNotFound {
t.Fatalf("expected missing observation status 404, got %d: %s", missingRec.Code, missingRec.Body.String())
}
}

func TestDocsDescribeFindReplaceUpdateContract(t *testing.T) {
docs, err := os.ReadFile("../../DOCS.md")
if err != nil {
t.Fatalf("read DOCS.md: %v", err)
}
for _, want := range []string{
"find and replace must be used together",
"find/replace is mutually exclusive with content",
"every literal occurrence",
"revision and sync",
`{"find":"alpha","replace":"beta"}`,
} {
if !strings.Contains(string(docs), want) {
t.Fatalf("DOCS.md must describe %q", want)
}
}
}

// ─── Sync Status Tests ───────────────────────────────────────────────────────

// stubSyncStatusProvider is a fake SyncStatusProvider for tests.
Expand Down
Loading
Loading