From 1a514dabeaaf5c4685ca92237cb4223bcfb79250 Mon Sep 17 00:00:00 2001 From: Dan Mills Date: Sun, 22 Feb 2026 23:24:40 +0100 Subject: [PATCH 1/2] feat(api): add POST /v1/engrams/search endpoint GET requests with large query bodies are technically undefined behavior in HTTP. Move semantic search to POST /v1/engrams/search with a JSON body containing query, limit, detail, and level fields. GET /v1/engrams remains for listing all engrams (with optional threshold filter). The query path is removed from that handler. Co-Authored-By: Claude Sonnet 4.6 --- internal/api/handlers.go | 110 ++++++++++++++++++++-------------- internal/api/handlers_test.go | 20 +++++-- internal/api/router.go | 1 + 3 files changed, 82 insertions(+), 49 deletions(-) diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 8dd2d2d..51ee3d6 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -325,58 +325,78 @@ func (s *Services) nerEntityEngrams(queryStr string) []string { // --- Engrams --- -func (s *Services) handleListEngrams(w http.ResponseWriter, r *http.Request) { - full := parseDetail(r) - level := parseLevel(r) - queryStr := r.URL.Query().Get("query") +type searchEngramsRequest struct { + Query string `json:"query"` + Limit int `json:"limit,omitempty"` + Detail string `json:"detail,omitempty"` + Level int `json:"level,omitempty"` +} - // Semantic search path - if queryStr != "" { - limit := parseLimit(r, 10) - - // Run embedding and NER concurrently — both are network calls. - embCh := make(chan []float64, 1) - seedCh := make(chan []string, 1) - - if s.EmbedClient != nil { - go func() { - emb, err := s.EmbedClient.Embed(queryStr) - if err != nil { - s.Logger.Warn("query embedding failed", "err", err) - embCh <- nil - return - } - embCh <- emb - }() - } else { - embCh <- nil - } +// handleSearchEngrams handles POST /v1/engrams/search. +// Accepts a JSON body to support arbitrarily large query strings. +func (s *Services) handleSearchEngrams(w http.ResponseWriter, r *http.Request) { + var req searchEngramsRequest + if err := decode(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + if req.Query == "" { + writeError(w, http.StatusBadRequest, "missing_field", "query is required") + return + } - go func() { seedCh <- s.nerEntityEngrams(queryStr) }() + full := req.Detail == "full" + limit := req.Limit + if limit <= 0 { + limit = 10 + } - queryEmb := <-embCh - extraSeeds := <-seedCh + // Run embedding and NER concurrently — both are network calls. + embCh := make(chan []float64, 1) + seedCh := make(chan []string, 1) - result, err := s.Graph.Retrieve(queryEmb, queryStr, limit, extraSeeds...) - if err != nil { - writeError(w, http.StatusInternalServerError, "retrieval_error", err.Error()) - return - } - applyEngramLevels(s.Graph, result.Engrams, level) - if full { - for _, e := range result.Engrams { - e.Embedding = nil - } - writeJSON(w, http.StatusOK, result.Engrams) - } else { - cards := make([]map[string]any, 0, len(result.Engrams)) - for _, e := range result.Engrams { - cards = append(cards, engramCard(e)) + if s.EmbedClient != nil { + go func() { + emb, err := s.EmbedClient.Embed(req.Query) + if err != nil { + s.Logger.Warn("query embedding failed", "err", err) + embCh <- nil + return } - writeJSON(w, http.StatusOK, cards) - } + embCh <- emb + }() + } else { + embCh <- nil + } + + go func() { seedCh <- s.nerEntityEngrams(req.Query) }() + + queryEmb := <-embCh + extraSeeds := <-seedCh + + result, err := s.Graph.Retrieve(queryEmb, req.Query, limit, extraSeeds...) + if err != nil { + writeError(w, http.StatusInternalServerError, "retrieval_error", err.Error()) return } + applyEngramLevels(s.Graph, result.Engrams, req.Level) + if full { + for _, e := range result.Engrams { + e.Embedding = nil + } + writeJSON(w, http.StatusOK, result.Engrams) + } else { + cards := make([]map[string]any, 0, len(result.Engrams)) + for _, e := range result.Engrams { + cards = append(cards, engramCard(e)) + } + writeJSON(w, http.StatusOK, cards) + } +} + +func (s *Services) handleListEngrams(w http.ResponseWriter, r *http.Request) { + full := parseDetail(r) + level := parseLevel(r) // Threshold filter path thresholdStr := r.URL.Query().Get("threshold") diff --git a/internal/api/handlers_test.go b/internal/api/handlers_test.go index 74e0052..8d9826e 100644 --- a/internal/api/handlers_test.go +++ b/internal/api/handlers_test.go @@ -254,15 +254,27 @@ func TestIngestThought_Success(t *testing.T) { // --- Query on list endpoints --- -func TestListEngrams_Query_EmptyDB(t *testing.T) { +func TestSearchEngrams_EmptyDB(t *testing.T) { _, srv, cleanup := setupTestServices(t) defer cleanup() - resp := doRequest(t, srv, http.MethodGet, "/v1/engrams?query=anything", "") + resp := doRequest(t, srv, http.MethodPost, "/v1/engrams/search", `{"query":"anything"}`) defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - t.Errorf("expected 200 on empty DB query, got %d", resp.StatusCode) + t.Errorf("expected 200 on empty DB search, got %d", resp.StatusCode) + } +} + +func TestSearchEngrams_MissingQuery(t *testing.T) { + _, srv, cleanup := setupTestServices(t) + defer cleanup() + + resp := doRequest(t, srv, http.MethodPost, "/v1/engrams/search", `{}`) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("expected 400 for missing query, got %d", resp.StatusCode) } } @@ -709,7 +721,7 @@ func TestFullCycle_IngestAndList(t *testing.T) { } // Search with keyword (no embedding available but should not error) - searchResp := doRequest(t, srv, http.MethodGet, "/v1/engrams?query=architecture&limit=5", "") + searchResp := doRequest(t, srv, http.MethodPost, "/v1/engrams/search", `{"query":"architecture","limit":5}`) searchResp.Body.Close() if searchResp.StatusCode != http.StatusOK { diff --git a/internal/api/router.go b/internal/api/router.go index 34342dd..20f5cfd 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -28,6 +28,7 @@ func NewRouter(svc *Services, apiKey string) *chi.Mux { // Engrams r.Get("/v1/engrams", svc.handleListEngrams) + r.Post("/v1/engrams/search", svc.handleSearchEngrams) r.Get("/v1/engrams/{id}", svc.handleGetEngram) r.Delete("/v1/engrams/{id}", svc.handleDeleteEngram) r.Get("/v1/engrams/{id}/context", svc.handleGetEngramContext) From 582ae58606369ec610aaf1ac82ba40d89bac42c0 Mon Sep 17 00:00:00 2001 From: Dan Mills Date: Mon, 23 Feb 2026 11:30:15 +0100 Subject: [PATCH 2/2] feat(api): add POST /v1/episodes/search and POST /v1/entities/search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the search-as-POST pattern from engrams to all three resource types. GET list endpoints are now list-only; search always uses POST with a JSON body containing query, limit, detail, and level. - POST /v1/episodes/search — text search over episode content - POST /v1/entities/search — text search over entity names and aliases - Remove ?query= from GET /v1/episodes and GET /v1/entities - Update openapi.yaml, docs/api.md, README.md, CHANGELOG.md Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 9 +- README.md | 6 +- docs/api.md | 63 ++++++++-- internal/api/handlers.go | 88 ++++++++++---- internal/api/handlers_test.go | 50 +++++++- internal/api/router.go | 2 + openapi.yaml | 220 ++++++++++++++++++++++++++++------ 7 files changed, 362 insertions(+), 76 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9834487..ca04dfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added + +- `POST /v1/engrams/search` — semantic search (spreading activation) with a JSON body. Accepts `query`, `limit`, `detail`, `level`. +- `POST /v1/episodes/search` — text search over episode content with a JSON body. Same fields as engram search. +- `POST /v1/entities/search` — text search over entity names and aliases with a JSON body. + ### Changed - **Automatic background decay.** Engram now runs activation decay on a configurable interval (default: every hour) without requiring clients to call `POST /v1/activation/decay` on a schedule. Configure via the new `decay` config block (`interval`, `lambda`, `floor`). The endpoint remains for manual/one-off use. +- `GET /v1/engrams`, `GET /v1/episodes`, `GET /v1/entities` no longer accept a `?query=` parameter. Use the new `POST /{resource}/search` endpoints instead. GET list endpoints are now list-only with filter/cursor params. --- @@ -37,7 +44,7 @@ Initial public release. Engram is a standalone episodic memory service for AI ag - `POST /v1/activation/decay` — apply exponential activation decay (forgetting) - `DELETE /v1/memory/reset` — destructive wipe of all memory - `GET /health` — public health check endpoint -- `?query=` semantic search on list endpoints (engrams, episodes, entities) +- `?query=` semantic/text search on list endpoints (engrams, episodes, entities) — moved to dedicated `POST /{resource}/search` endpoints in Unreleased - `?detail=full` verbosity flag — minimal responses by default (id + primary field only) - `?level=N` pyramid summary compression on engrams and entities (4 / 8 / 16 / 32 / 64 words) diff --git a/README.md b/README.md index f4e96ef..8354026 100644 --- a/README.md +++ b/README.md @@ -108,8 +108,10 @@ curl -X POST http://localhost:8080/v1/episodes \ -d '{"content": "Alice mentioned she prefers morning standups.", "source": "slack", "author": "alice"}' # Query memory (spreading activation retrieval) -curl "http://localhost:8080/v1/engrams?query=Alice+meeting+preferences" \ - -H "Authorization: Bearer your-secret-key" +curl -X POST http://localhost:8080/v1/engrams/search \ + -H "Authorization: Bearer your-secret-key" \ + -H "Content-Type: application/json" \ + -d '{"query": "Alice meeting preferences", "limit": 10}' # Trigger consolidation manually curl -X POST http://localhost:8080/v1/consolidate \ diff --git a/docs/api.md b/docs/api.md index 21b366c..d396a75 100644 --- a/docs/api.md +++ b/docs/api.md @@ -12,19 +12,22 @@ A full OpenAPI 3.0 specification is at [`openapi.yaml`](../openapi.yaml). |--------|------|-------------| | `GET` | `/health` | Service health check (public) | | `POST` | `/v1/episodes` | Ingest a raw episode | -| `GET` | `/v1/episodes` | List episodes; `?query=`, `?channel=`, `?unconsolidated=`, `?before={id}`, `?level=N` | +| `GET` | `/v1/episodes` | List episodes; `?channel=`, `?unconsolidated=`, `?before={id}`, `?level=N` | +| `POST` | `/v1/episodes/search` | Text search over episode content | | `GET` | `/v1/episodes/count` | Episode count; `?channel=`, `?unconsolidated=` filters | | `GET` | `/v1/episodes/{id}` | Get episode by ID or 5-char prefix | | `POST` | `/v1/episodes/summaries` | Batch fetch pyramid summaries for episode IDs | | `POST` | `/v1/episodes/{id}/edges` | Add a typed edge between two episodes | | `POST` | `/v1/thoughts` | Ingest a free-form thought (shorthand for episodes) | -| `GET` | `/v1/engrams` | List engrams; `?query=` triggers spreading activation | +| `GET` | `/v1/engrams` | List engrams; `?threshold=` filter | +| `POST` | `/v1/engrams/search` | Semantic search via spreading activation | | `GET` | `/v1/engrams/{id}` | Get engram by ID; `?level=N` for pyramid compression | | `DELETE` | `/v1/engrams/{id}` | Delete an engram | | `GET` | `/v1/engrams/{id}/context` | Engram + source episodes + linked entities | | `POST` | `/v1/engrams/{id}/reinforce` | Boost activation and optionally blend embedding | | `POST` | `/v1/engrams/boost` | Batch activation boost | -| `GET` | `/v1/entities` | List entities; `?query=`, `?type=` filters | +| `GET` | `/v1/entities` | List entities; `?type=` filter | +| `POST` | `/v1/entities/search` | Text search over entity names and aliases | | `GET` | `/v1/entities/{id}` | Get entity by canonical ID | | `GET` | `/v1/entities/{id}/engrams` | All engrams linked to an entity | | `POST` | `/v1/consolidate` | Trigger consolidation pipeline manually | @@ -125,7 +128,6 @@ Edge types: `REPLIES_TO`, `FOLLOWS`, `RELATED_TO`. List episodes. Returns `[{id, content}]` by default. Query params: -- `query` — substring search over episode content (does not combine with other filters) - `channel` — filter by channel value - `unconsolidated=true` — only return episodes not yet part of any engram - `before={id}` — return only episodes older than the given episode ID (full or 5-char prefix); used for cursor-based pagination. Returns `400` if the ID is not found. @@ -133,7 +135,21 @@ Query params: - `detail=full` — return all fields (applies after `?level=N` compression) - `limit` — max results (default 50) -All filters except `query` compose: `?channel=X&unconsolidated=true&before={id}&level=8` is a valid combination. +All filters compose freely: `?channel=X&unconsolidated=true&before={id}&level=8` is a valid combination. + +#### `POST /v1/episodes/search` + +Text search over episode content. Returns `[{id, content}]` by default. + +Request: +```json +{"query": "morning standup", "limit": 10, "detail": "full", "level": 8} +``` + +- `query` — required; substring search over episode content +- `limit` — max results (default 10) +- `detail` — set to `"full"` for all fields +- `level` — pyramid compression level applied to returned content #### `GET /v1/episodes/{id}` @@ -195,7 +211,7 @@ GET /v1/episodes?channel=guild:general&limit=70&before={ep30_id}&unconsolidated= GET /v1/episodes/count?channel=guild:general&unconsolidated=true ``` -The third query returns an empty list once all older episodes have been consolidated — they are then reachable via `GET /v1/engrams?query=...` (spreading activation search). Setting `consolidation.max_buffer` to match the bot's fetch limit (e.g. `100`) ensures episodes are always accessible via one path or the other. +The third query returns an empty list once all older episodes have been consolidated — they are then reachable via `POST /v1/engrams/search` (spreading activation search). Setting `consolidation.max_buffer` to match the bot's fetch limit (e.g. `100`) ensures episodes are always accessible via one path or the other. --- @@ -206,11 +222,23 @@ The third query returns an empty list once all older episodes have been consolid List consolidated engrams. Returns `[{id, summary}]` by default. Query params: -- `query` — semantic search via spreading activation; returns ranked results - `detail=full` — return all fields - `level` — pyramid compression level applied to every returned engram (default `0` = verbatim) -- `limit` — max results when using `?query=` (default 10) -- `threshold` — filter by minimum activation level (list-all mode only) +- `threshold` — filter by minimum activation level + +#### `POST /v1/engrams/search` + +Semantic search via spreading activation. Returns ranked engrams matching the query. + +Request: +```json +{"query": "Alice meeting preferences", "limit": 10, "detail": "full", "level": 0} +``` + +- `query` — required; natural language search. Seeds spreading activation via semantic KNN, lexical BM25, and entity matching. +- `limit` — max results (default 10) +- `detail` — set to `"full"` for all fields +- `level` — pyramid compression level applied to returned engrams #### `GET /v1/engrams/{id}` @@ -282,11 +310,24 @@ Request: List extracted named entities. Returns `[{id, name}]` by default. Query params: -- `query` — text search over entity names and aliases - `detail=full` — return all fields - `type` — filter by entity type - `level` — pyramid compression level (same semantics as engrams) -- `limit` — max results (default 100; default 10 when using `?query=`) +- `limit` — max results (default 100) + +#### `POST /v1/entities/search` + +Text search over entity names and aliases. Returns `[{id, name}]` by default. + +Request: +```json +{"query": "Alice", "limit": 10, "detail": "full", "level": 0} +``` + +- `query` — required; text search over entity names and aliases +- `limit` — max results (default 10) +- `detail` — set to `"full"` for all fields +- `level` — pyramid compression level applied to returned entities #### `GET /v1/entities/{id}` diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 51ee3d6..e9c8993 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -555,24 +555,42 @@ func (s *Services) handleGetEngramContext(w http.ResponseWriter, r *http.Request // --- Episodes --- +type searchEpisodesRequest struct { + Query string `json:"query"` + Limit int `json:"limit,omitempty"` + Detail string `json:"detail,omitempty"` + Level int `json:"level,omitempty"` +} + +// handleSearchEpisodes handles POST /v1/episodes/search. +func (s *Services) handleSearchEpisodes(w http.ResponseWriter, r *http.Request) { + var req searchEpisodesRequest + if err := decode(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + if req.Query == "" { + writeError(w, http.StatusBadRequest, "missing_field", "query is required") + return + } + limit := req.Limit + if limit <= 0 { + limit = 10 + } + episodes, err := s.Graph.SearchEpisodesByText(req.Query, limit) + if err != nil { + writeError(w, http.StatusInternalServerError, "db_error", err.Error()) + return + } + applyEpisodeLevels(s.Graph, episodes, req.Level) + writeEpisodeList(w, episodes, req.Detail == "full") +} + func (s *Services) handleListEpisodes(w http.ResponseWriter, r *http.Request) { full := parseDetail(r) level := parseLevel(r) - queryStr := r.URL.Query().Get("query") limit := parseLimit(r, 50) - // Text search path (does not support before/level/unconsolidated filters) - if queryStr != "" { - episodes, err := s.Graph.SearchEpisodesByText(queryStr, limit) - if err != nil { - writeError(w, http.StatusInternalServerError, "db_error", err.Error()) - return - } - applyEpisodeLevels(s.Graph, episodes, level) - writeEpisodeList(w, episodes, full) - return - } - channel := r.URL.Query().Get("channel") unconsolidated := r.URL.Query().Get("unconsolidated") == "true" @@ -730,24 +748,42 @@ func (s *Services) handleAddEpisodeEdge(w http.ResponseWriter, r *http.Request) // --- Entities --- +type searchEntitiesRequest struct { + Query string `json:"query"` + Limit int `json:"limit,omitempty"` + Detail string `json:"detail,omitempty"` + Level int `json:"level,omitempty"` +} + +// handleSearchEntities handles POST /v1/entities/search. +func (s *Services) handleSearchEntities(w http.ResponseWriter, r *http.Request) { + var req searchEntitiesRequest + if err := decode(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + if req.Query == "" { + writeError(w, http.StatusBadRequest, "missing_field", "query is required") + return + } + limit := req.Limit + if limit <= 0 { + limit = 10 + } + entities, err := s.Graph.FindEntitiesByText(req.Query, limit) + if err != nil { + writeError(w, http.StatusInternalServerError, "db_error", err.Error()) + return + } + applyEntityLevels(s.Graph, entities, req.Level) + writeEntityList(w, entities, req.Detail == "full") +} + func (s *Services) handleListEntities(w http.ResponseWriter, r *http.Request) { full := parseDetail(r) level := parseLevel(r) - queryStr := r.URL.Query().Get("query") limit := parseLimit(r, 100) - // Text search path - if queryStr != "" { - entities, err := s.Graph.FindEntitiesByText(queryStr, limit) - if err != nil { - writeError(w, http.StatusInternalServerError, "db_error", err.Error()) - return - } - applyEntityLevels(s.Graph, entities, level) - writeEntityList(w, entities, full) - return - } - entityType := r.URL.Query().Get("type") var entities []*graph.Entity var err error diff --git a/internal/api/handlers_test.go b/internal/api/handlers_test.go index 8d9826e..ce3063e 100644 --- a/internal/api/handlers_test.go +++ b/internal/api/handlers_test.go @@ -252,7 +252,55 @@ func TestIngestThought_Success(t *testing.T) { } } -// --- Query on list endpoints --- +// --- Search endpoints --- + +func TestSearchEpisodes_EmptyDB(t *testing.T) { + _, srv, cleanup := setupTestServices(t) + defer cleanup() + + resp := doRequest(t, srv, http.MethodPost, "/v1/episodes/search", `{"query":"anything"}`) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200 on empty DB search, got %d", resp.StatusCode) + } +} + +func TestSearchEpisodes_MissingQuery(t *testing.T) { + _, srv, cleanup := setupTestServices(t) + defer cleanup() + + resp := doRequest(t, srv, http.MethodPost, "/v1/episodes/search", `{}`) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("expected 400 for missing query, got %d", resp.StatusCode) + } +} + +func TestSearchEntities_EmptyDB(t *testing.T) { + _, srv, cleanup := setupTestServices(t) + defer cleanup() + + resp := doRequest(t, srv, http.MethodPost, "/v1/entities/search", `{"query":"Alice"}`) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200 on empty DB search, got %d", resp.StatusCode) + } +} + +func TestSearchEntities_MissingQuery(t *testing.T) { + _, srv, cleanup := setupTestServices(t) + defer cleanup() + + resp := doRequest(t, srv, http.MethodPost, "/v1/entities/search", `{}`) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("expected 400 for missing query, got %d", resp.StatusCode) + } +} func TestSearchEngrams_EmptyDB(t *testing.T) { _, srv, cleanup := setupTestServices(t) diff --git a/internal/api/router.go b/internal/api/router.go index 20f5cfd..e49b5fd 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -37,6 +37,7 @@ func NewRouter(svc *Services, apiKey string) *chi.Mux { // Episodes r.Get("/v1/episodes", svc.handleListEpisodes) + r.Post("/v1/episodes/search", svc.handleSearchEpisodes) r.Get("/v1/episodes/count", svc.handleEpisodeCount) r.Get("/v1/episodes/{id}", svc.handleGetEpisode) r.Post("/v1/episodes/summaries", svc.handleBatchEpisodeSummaries) @@ -44,6 +45,7 @@ func NewRouter(svc *Services, apiKey string) *chi.Mux { // Entities r.Get("/v1/entities", svc.handleListEntities) + r.Post("/v1/entities/search", svc.handleSearchEntities) r.Get("/v1/entities/{id}", svc.handleGetEntity) r.Get("/v1/entities/{id}/engrams", svc.handleGetEntityEngrams) diff --git a/openapi.yaml b/openapi.yaml index ed1dbe1..23a6499 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -272,26 +272,78 @@ paths: type: string format: date-time + /v1/episodes/search: + post: + summary: Search episodes by text + description: | + Text search over episode content. Returns `[{id, content}]` by default. + Add `"detail": "full"` in the request body for all fields. + operationId: searchEpisodes + tags: + - Episodes + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - query + properties: + query: + type: string + description: Text substring search over episode content + limit: + type: integer + default: 10 + description: Maximum results (default 10) + detail: + type: string + enum: [full] + description: "Return full fields instead of minimal. Use `\"detail\": \"full\"`." + level: + type: integer + default: 0 + minimum: 0 + description: | + Apply pyramid compression to the `content` field before returning. + Valid targets: 4, 8, 16, 32, 64 (approx word count). 0 = verbatim (default). + responses: + "200": + description: List of matching episodes + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Episode" + "400": + description: Missing or invalid request body + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "500": + description: Database error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /v1/episodes: get: - summary: List or search episodes + summary: List episodes description: | - List raw episodes with optional filtering, or search by text with `?query=`. + List raw episodes with optional filtering. **Default response** is minimal: `[{id, content}]`. Add `?detail=full` for all fields (embeddings are never returned). - Filters `channel`, `unconsolidated`, `before`, and `level` compose freely. - The `?query=` text search path does not combine with these filters. + All filters compose freely: `?channel=X&unconsolidated=true&before={id}&level=8` is valid. operationId: listEpisodes tags: - Episodes parameters: - - name: query - in: query - schema: - type: string - description: Text substring search over episode content. Does not combine with other filters. - name: channel in: query schema: @@ -661,11 +713,73 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" + /v1/engrams/search: + post: + summary: Search engrams by semantic query + description: | + Semantic search via spreading activation. Returns ranked engrams matching the query. + + **Default response** is minimal: `[{id, summary}]`. + Add `"detail": "full"` in the request body for all fields. + + Retrieval uses three concurrent seed triggers: semantic KNN, lexical BM25, and entity + regex matching. When a NER service is configured, entities extracted from the query + string also seed the activation graph (fourth trigger). + operationId: searchEngrams + tags: + - Engrams + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - query + properties: + query: + type: string + description: Natural language search query + limit: + type: integer + default: 10 + description: Maximum results (default 10) + detail: + type: string + enum: [full] + description: "Return full fields instead of minimal. Use `\"detail\": \"full\"`." + level: + type: integer + default: 0 + minimum: 0 + description: "Summary compression level: 0 = verbatim (default); N = approx N-word summary" + responses: + "200": + description: Ranked list of matching engrams (minimal by default) + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Engram" + "400": + description: Missing or invalid request body + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "500": + description: Database error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /v1/engrams: get: - summary: List or search engrams + summary: List engrams description: | - List consolidated memory engrams, or search them semantically with `?query=`. + List consolidated memory engrams. **Default response** is minimal: `[{id, summary}]`. Add `?detail=full` for all fields (embeddings are never returned). @@ -673,15 +787,6 @@ paths: tags: - Engrams parameters: - - name: query - in: query - schema: - type: string - description: | - Natural language search query. Retrieval uses spreading activation with - three concurrent seed triggers: semantic KNN, lexical BM25, and entity - regex matching. When a NER service is configured, entities extracted from - the query string also seed the activation graph (fourth trigger). - name: detail in: query schema: @@ -695,18 +800,12 @@ paths: default: 0 minimum: 0 description: "Summary compression level for all returned engrams: 0 = verbatim (default); N = approx N-word summary" - - name: limit - in: query - schema: - type: integer - default: 10 - description: Maximum results when using ?query= (ignored for list-all) - name: threshold in: query schema: type: number format: double - description: Filter by minimum activation level (without ?query=) + description: Filter by minimum activation level responses: "200": description: List of engrams (minimal by default) @@ -928,11 +1027,67 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" + /v1/entities/search: + post: + summary: Search entities by name + description: | + Text search over entity names and aliases. Returns `[{id, name}]` by default. + Add `"detail": "full"` in the request body for all fields. + operationId: searchEntities + tags: + - Entities + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - query + properties: + query: + type: string + description: Text search over entity names and aliases + limit: + type: integer + default: 10 + description: Maximum results (default 10) + detail: + type: string + enum: [full] + description: "Return full fields instead of minimal. Use `\"detail\": \"full\"`." + level: + type: integer + default: 0 + minimum: 0 + description: "Summary compression level: 0 = verbatim (default); N = approx N-word summary" + responses: + "200": + description: List of matching entities + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Entity" + "400": + description: Missing or invalid request body + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "500": + description: Database error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /v1/entities: get: - summary: List or search entities + summary: List entities description: | - List extracted named entities, or search them by name with `?query=`. + List extracted named entities. **Default response** is minimal: `[{id, name}]`. Add `?detail=full` for all fields (embeddings are never returned). @@ -940,11 +1095,6 @@ paths: tags: - Entities parameters: - - name: query - in: query - schema: - type: string - description: Text search over entity names and aliases - name: detail in: query schema: @@ -968,7 +1118,7 @@ paths: schema: type: integer default: 100 - description: Maximum results (default 100; default 10 when using ?query=) + description: Maximum results (default 100) responses: "200": description: List of entities