Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ The project publishes 0.x prerelease versions; a stable release line is not yet

### Added

- Advertise the model-free lexical file-search route in the MCP `mem_search`
schema and verify `tools/list` plus route/filter forwarding through `tools/call`.

- `mem doctor` — a read-only diagnosis of why the CLI cannot talk to a working
server (`#112`). It reports four checks in a fixed order: reachability of the
configured server URL, whether a credential exists, the workspace the server
Expand All @@ -28,6 +31,10 @@ The project publishes 0.x prerelease versions; a stable release line is not yet
deployment path (`deploy/compose`, `docs/DEPLOYMENT.md`), instead of telling
somebody to log in against a server that is not running yet. Hosts that already
have a configuration keep the previous, shorter hint.
- File search gains a model-free lexical route (`route=lexical`). FTS + trigram
over `files.name` — same tier shape as memory Recall — so a deployment with
no embedding worker can still find files by name. CLI: `mem search "query"
--route lexical`.

### Changed

Expand Down
5 changes: 4 additions & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@
| F5.1 | `mem context "..."` → 返回有大小预算的文件/结构化记忆证据包 |
| F5.2 | 每条 evidence 必须含 source kind/id、稳定 citation、内容哈希、片段和 locator |
| F5.3 | mem 只走 recall → context pack;回答与行动由调用方 Agent 完成 |
| F5.4 | `source=all|file|memory`;结构化记忆在无 Worker、无模型时也必须可立即召回 |
| F5.4 | `source=all|file|memory`;结构化记忆在无 Worker、无模型时也必须可立即召回;文件词法路由(`route=lexical`)同样无需 Worker |
| F5.5 | 联合召回单路失败但仍有证据时返回 `200 + partial=true + warnings[]`;无幸存证据时返回 `502 context_unavailable` |

### F5A · 结构化 Agent 记忆
Expand Down Expand Up @@ -534,6 +534,9 @@ embeddings_face (
- `folders (user_id, path)` UNIQUE — 路径唯一性约束
- `memories (workspace_id, idempotency_key_sha256)` UNIQUE — 不落明文幂等键的幂等写入
- `memories` FTS + trigram — 无模型的确定性立即召回
- `files` FTS + trigram — 显式指定 `route=lexical` 的文件名无模型词法召回;
`auto` 只融合 text/visual,不自动回退到 lexical,worker 不可用时仍报错。
仅搜索 `files.name`,路径只用于筛选,不检索文件正文或路径片段。
- `embeddings_* (embedding)` — pgvector HNSW
- `file_entities (entity_id)` — 反查"和某人有关的所有文件"

Expand Down
65 changes: 65 additions & 0 deletions docs/MIGRATION_SEQUENCE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Pending migration deployment sequence

These draft changes are cumulative, not independently deployable:

| Order | Draft / original PR | Migration | Required predecessor |
| --- | --- | --- | --- |
| 1 | #194 / #183 | 0024 file lexical lane | released/main schema 23 |
| 2 | #197 / #180 | 0025 HNSW indexes | #194, schema 24 |
| 3 | #195 / #185 | 0026 data-plane hygiene | #197, schema 25 |

The PR base chain is `main` → `codex/fix-pr-183` → `codex/fix-pr-180`
→ `codex/fix-pr-185`. Successor branches must include their predecessor schema and source. Local
repair branches are rebuilt on current main and replay the original authored
changes; published commit identities remain in the original PR history.
Keep this order when retargeting after a predecessor merges.

On 2026-09-10, main `2986fe38175f54d99f15dd38a498708c6ecd88cd` and published
tags `v0.1.0` / `v0.1.1` contain only migrations 0001–0023. This does not prove
that a private deployment never applied a draft. Consequently migration
numbers and SQL identities are retained, not renumbered on an assumption.

HNSW DDL and shipping text-query planner acceptance are separate evidence:
creating indexes does not prove that a text query uses them. An index-only
successor must describe that partial scope and leave the broader #173 acceptance
open. #195 still requires the predecessor schema 25 regardless of query strategy.
This document does not approve a product decision or query-strategy change,
waive a review gate, or authorize deployment. #176's model-free file-lane RFC
also requires a maintainer decision before this draft is made review-ready.

Goose startup remains strict: no `WithAllowMissing` or equivalent option is
enabled. A database that already applied 26 while omitting 24/25 will correctly
fail startup against the cumulative schema. Stop and obtain an operator-owned
recovery plan for such a database; do not edit its migration history, renumber
its SQL, or apply lower versions out of order to manufacture a pass.

## Migration 0024 operational boundary

Adding the stored generated `search_tsv` column rewrites existing `files` rows,
and its two indexes are built without `CONCURRENTLY`. Schedule a maintenance
window sized for the file corpus and expect table locks to block other access.
The migration indexes filenames only; `PathPrefix` remains a filter, not path
substring retrieval. Downgrading 0024 removes the derived column/indexes and
requires deploying server code that does not query the lexical route.

Goose runs this migration transactionally, so an ordinary failure rolls back
its DDL. If an operator has applied some statements manually, `IF NOT EXISTS`
does not prove that an existing column or index has the correct definition.
Inspect both `goose_db_version` and the actual schema/index definitions before
an operator-owned recovery; do not mark an unverified partial schema applied.

## Regression evidence

`TestMigrationFilesContiguous` rejects embedded numeric gaps without a DB.
`scripts/verify.sh integration` creates a separate, owned `_test` database and
runs `TestMigrationUpgradeSequence`. It applies real Goose migrations to 23,
seeds a file with duplicate text chunks, then advances one version at a time
to the branch's declared head (24, 25, or 26). Each step checks full applied
history and preserved data; subsequent steps check lexical backfill, valid
HNSW DDL, and deduplication/unique rejection. Finally the ordinary production
`DB.Migrate` startup path must accept the resulting history unchanged.

The dedicated test uses `MEM_MIGRATION_SEQUENCE_TEST_DB`, refuses a database
that already has Goose history, and must not target any developer or production
database. The existing owned-database runner performs cleanup. These are real
database tests over synthetic fixtures, not retrieval-quality or latency proof.
17 changes: 15 additions & 2 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,18 @@ coordinates, timezone-free timestamps and control characters are rejected by
the HTTP API. The metadata is persisted server-side and is not included in an
enrichment-model prompt.

### Model-free file search

Use `mem_search` with `route=lexical` (or `mem search "query" --route lexical`)
to search filenames without an embedding worker or managed provider. `scope`
restricts the virtual-folder subtree; it does not make paths or file contents
searchable. `route=auto` fuses only the text and visual embedding routes and
does not fall back to lexical when the worker is unavailable.

Lexical scoring uses name substrings, simple full-text matching, then tolerant
trigram matching. It scores the files remaining after workspace, path, MIME
and time filters; no indexed candidate-pruning or latency guarantee is claimed.

### Reviewing file annotations

Use `mem_info` (or `mem info <file_id> --format json`) to read pending
Expand Down Expand Up @@ -138,7 +150,7 @@ The canonical product surface is:
| `mem_checkpoint_list` | List newest-first bounded checkpoint summaries for one task |
| `mem_checkpoint_get` | Get one immutable checkpoint and its full handoff payload |
| `mem_resume` | Restore the current task head or a selected historical checkpoint, including resolved and missing evidence |
| `mem_search` | Natural-language search (text / visual / auto fuse); ranked files + snippets |
| `mem_search` | Natural-language search (text / visual / auto fuse); ranked files + snippets. `route=lexical` is model-free (FTS + trigram over file names, no worker needed) |
| `mem_context` | Build an evidence-backed context pack for the calling Agent |
| `mem_related` | Top-K files related to a `file_id` by embedding similarity |
| `mem_face` | Person clusters: `action=list` / `name` / `merge` |
Expand Down Expand Up @@ -295,7 +307,8 @@ same logical request should supply and retain a stable key so a committed
result can replay without another provider invocation or charge. A `504`
means the provider outcome is uncertain: do not automatically retry, and do
not invent a new key. `mem_context` with `source=memory` stays lexical and
model-independent.
model-independent. `mem_search` with `route=lexical` is likewise model-free:
it uses FTS + trigram over file names and works without a configured worker.

Its target output is structured for an Agent to consume:

Expand Down
13 changes: 12 additions & 1 deletion scripts/verify.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ set -euo pipefail

REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
MODE="${1:-unit}"
EXPECTED_MIGRATION_HEAD=23
EXPECTED_MIGRATION_HEAD=24
MIGRATION_ROLLBACK_TARGET=11
MODEL_TEXT_CANONICAL_BASE=15
WORKSPACE_AI_PROFILE_BASE=16
Expand Down Expand Up @@ -304,6 +304,16 @@ run_migration_round_trip() {
MEM_TEST_TARGET_DB="$MEM_TEST_DB" testdb assert-unsafe-derived-text-scrubbed
}

run_migration_sequence() {
log "Strict populated migration upgrades from released head 23 to $EXPECTED_MIGRATION_HEAD"
(
cd "${REPO_ROOT}/server"
MEM_MIGRATION_SEQUENCE_TEST_DB="$MEM_TEST_DB" \
go test -count=1 -v ./internal/db -run '^TestMigrationUpgradeSequence$'
)
assert_migration_version "$EXPECTED_MIGRATION_HEAD"
}

run_migrations_up() {
(
cd "${REPO_ROOT}/server"
Expand Down Expand Up @@ -385,6 +395,7 @@ run_postgres_tests() {

run_integration() {
validate_test_database
with_fresh_test_database migration_sequence run_migration_sequence
with_fresh_test_database migration run_migration_round_trip
with_fresh_test_database integration run_postgres_integration
}
Expand Down
68 changes: 68 additions & 0 deletions server/cmd/mem-mcp/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,74 @@ func TestMCP_ToolsCallRoundTrip(t *testing.T) {
}
}

func TestMCP_LexicalSearchSchemaAndForwarding(t *testing.T) {
requests := make(chan map[string]any, 1)
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/v1/search" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Errorf("decode search: %v", err)
}
requests <- body
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"results":[]}`))
}))
defer fake.Close()
reg := tools.New()
if err := builtin.RegisterAll(reg, apiclient.New(fake.URL, "test-token")); err != nil {
t.Fatal(err)
}
srv, buf := newTestServer(reg)
in := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}` + "\n" +
`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"mem_search","arguments":{"query":"budget","route":"lexical","scope":"/Work","type":"text","limit":3}}}` + "\n")
if err := srv.serve(in); err != nil {
t.Fatal(err)
}
responses := readResponses(t, buf)
if len(responses) != 2 {
t.Fatalf("want list and call responses, got %d", len(responses))
}
t.Run("exported schema advertises all routes", func(t *testing.T) {
for _, item := range responses[0]["result"].(map[string]any)["tools"].([]any) {
tool := item.(map[string]any)
if tool["name"] != "mem_search" {
continue
}
schema := tool["inputSchema"].(map[string]any)
route := schema["properties"].(map[string]any)["route"].(map[string]any)
enum := route["enum"].([]any)
want := map[string]bool{"text": true, "visual": true, "auto": true, "lexical": true}
for _, value := range enum {
delete(want, value.(string))
}
if len(enum) != 4 || len(want) != 0 {
t.Fatalf("mem_search route enum = %v; missing %v", enum, want)
}
return
}
t.Fatal("mem_search missing from tools/list")
})
t.Run("lexical call forwards route and filters", func(t *testing.T) {
if responses[1]["error"] != nil {
t.Fatalf("RPC error: %v", responses[1]["error"])
}
if result := responses[1]["result"].(map[string]any); result["isError"] != false {
t.Fatalf("tool error: %v", result)
}
select {
case body := <-requests:
if body["query"] != "budget" || body["route"] != "lexical" ||
body["scope"] != "/Work" || body["type"] != "text" || body["limit"] != float64(3) {
t.Fatalf("forwarded search = %#v", body)
}
default:
t.Fatal("lexical request was not forwarded")
}
})
}

func TestMCP_ToolErrorSurfacedInContent(t *testing.T) {
// memd returns 404
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
Expand Down
2 changes: 1 addition & 1 deletion server/cmd/mem/cmds_search.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ func newSearchCmd() *cobra.Command {
},
}
cmd.Flags().StringVar(&typ, "type", "", "mime prefix filter: image|text|application|audio|video")
cmd.Flags().StringVar(&route, "route", "", "search route: text|visual|auto (default auto)")
cmd.Flags().StringVar(&route, "route", "", "search route: text|visual|auto|lexical (default auto)")
cmd.Flags().StringVar(&since, "since", "", "YYYY-MM-DD inclusive lower bound on timeline_at")
cmd.Flags().StringVar(&until, "until", "", "YYYY-MM-DD inclusive upper bound on timeline_at")
cmd.Flags().IntVar(&limit, "limit", 0, "max results (default 10, max 100)")
Expand Down
4 changes: 2 additions & 2 deletions server/internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -1334,9 +1334,9 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
return
}
switch req.Route {
case "", search.RouteAuto, search.RouteText, search.RouteVisual:
case "", search.RouteAuto, search.RouteText, search.RouteVisual, search.RouteLexical:
default:
writeError(w, http.StatusBadRequest, "bad_route", "route must be auto, text, or visual")
writeError(w, http.StatusBadRequest, "bad_route", "route must be auto, text, visual, or lexical")
return
}
scope, err := pathx.Normalize(req.Scope)
Expand Down
5 changes: 3 additions & 2 deletions server/internal/api/managed_embeddings.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,9 @@ func (s *Server) managedSearcher(
s.Search == nil {
return nil, nil, entitlement.ErrEntitlementUnavailable
}
// A visual-only query does not invoke the managed text embedding provider.
if query.Route == search.RouteVisual {
// A visual-only or lexical query does not invoke the managed text embedding
// provider.
if query.Route == search.RouteVisual || query.Route == search.RouteLexical {
return s.Search, nil, nil
}
spec, err := s.Search.EmbeddingSpec(r.Context(), query.UserID)
Expand Down
23 changes: 23 additions & 0 deletions server/internal/api/managed_embeddings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -573,3 +573,26 @@ func TestReadinessIsDeploymentModeAwareAndPlanIndependent(t *testing.T) {
}
})
}

func TestLexicalSearchBypassesManagedEmbeddingReservation(t *testing.T) {
searchFake := &managedSearchFake{spec: "openai:text-embedding-3-small"}
usageFake := &managedEntitlementFake{reserveErr: errors.New("must not reserve lexical search")}
server := &Server{
Search: searchFake, DeploymentMode: "saas",
ManagedEmbeddingProvider: searchFake.spec, Entitlements: usageFake,
}
// There is deliberately no paid plan, idempotency key, or model context.
request := httptest.NewRequest(http.MethodPost, "/v1/search", nil)
query := search.Query{UserID: uuid.New(), Route: search.RouteLexical, Text: "notes"}
searcher, executor, err := server.managedSearcher(request, "search.query", nil, query)
if err != nil || executor != nil || searcher != searchFake {
t.Fatalf("lexical dispatch: searcher=%T executor=%v err=%v", searcher, executor, err)
}
if _, err := searcher.Search(request.Context(), query); err != nil {
t.Fatal(err)
}
if searchFake.searchCalls != 1 || searchFake.embeddingCalls != 0 || usageFake.reserveCalls != 0 {
t.Fatalf("lexical dispatch invoked model policy: search=%d embedding=%d reserve=%d",
searchFake.searchCalls, searchFake.embeddingCalls, usageFake.reserveCalls)
}
}
Loading
Loading