pantheon-opencode 1.5.2 → 1.6.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/.pantheon/code-mode/README.md +4 -2
  2. package/.pantheon/code-mode/checkpoint_session.py +138 -102
  3. package/.pantheon/code-mode/compress-inline.py +102 -57
  4. package/.pantheon/code-mode/manifest.json +2 -8
  5. package/CHANGELOG.md +59 -0
  6. package/README.md +115 -55
  7. package/README.pt-BR.md +86 -43
  8. package/commands/pantheon-optimize.md +2 -2
  9. package/package.json +13 -1
  10. package/plugin.json +1 -1
  11. package/pyproject.toml +8 -15
  12. package/scripts/build-agents-md.mjs +2 -2
  13. package/scripts/check-plugin-v2-coverage.mjs +213 -0
  14. package/scripts/ci-validate-yaml.py +7 -5
  15. package/scripts/code_mode_server.py +116 -24
  16. package/scripts/doctor.mjs +85 -28
  17. package/scripts/install/cli-ui.mjs +1 -1
  18. package/scripts/install/config-migration.mjs +124 -3
  19. package/scripts/install/health-check.mjs +6 -19
  20. package/scripts/install/interactive.mjs +6 -6
  21. package/scripts/install/migrate.mjs +1 -1
  22. package/scripts/install/opencode.mjs +43 -10
  23. package/scripts/mcp_persistence_server.py +390 -76
  24. package/scripts/memory_mcp_server.py +370 -196
  25. package/scripts/postinstall.mjs +0 -7
  26. package/scripts/probe-context-rehydrate.mjs +28 -11
  27. package/scripts/prune.mjs +2 -2
  28. package/scripts/test-opencode-v2-sandbox.sh +1759 -0
  29. package/scripts/validate-routing.mjs +40 -0
  30. package/skills-lock.json +9 -10
  31. package/src/agents/aphrodite.md +0 -1
  32. package/src/agents/apollo.md +0 -1
  33. package/src/agents/athena.md +0 -1
  34. package/src/agents/demeter.md +0 -1
  35. package/src/agents/gaia.md +0 -1
  36. package/src/agents/hephaestus.md +0 -1
  37. package/src/agents/hermes.md +0 -1
  38. package/src/agents/iris.md +0 -1
  39. package/src/agents/mnemosyne.md +15 -17
  40. package/src/agents/nyx.md +0 -1
  41. package/src/agents/prometheus.md +0 -1
  42. package/src/agents/talos.md +0 -1
  43. package/src/agents/themis.md +0 -1
  44. package/src/agents/zeus.md +1 -3
  45. package/src/mcp/code_mode_server.py +116 -24
  46. package/src/mcp/mcp_codemap_module.py +559 -129
  47. package/src/mcp/mcp_persistence_server.py +390 -76
  48. package/src/mcp/memory_mcp_server.py +371 -248
  49. package/src/mcp/pantheon_vision_server.py +136 -24
  50. package/src/mcp/requirements-mcp.txt +5 -3
  51. package/src/mcp/requirements-vision.txt +2 -0
  52. package/src/pantheon/model-command.ts +18 -7
  53. package/src/pantheon/presets.d.mts +0 -8
  54. package/src/pantheon/presets.mjs +0 -29
  55. package/src/pantheon/todo-enforcer.ts +1 -1
  56. package/src/pantheon/vision.ts +1 -1
  57. package/src/plugin-v2.ts +134 -104
  58. package/src/plugins/tui/dist/tui.js +1 -1
  59. package/src/plugins/tui/package-lock.json +23 -23
  60. package/src/plugins/tui/package.json +12 -12
  61. package/src/routing.yml +0 -16
  62. package/src/skills/README.md +31 -36
  63. package/src/skills/auto-continue/SKILL.md +27 -77
  64. package/.pantheon/code-mode/eval-llm-judge.py +0 -255
  65. package/.pantheon/code-mode/eval-monte-carlo.py +0 -368
  66. package/.pantheon/code-mode/eval-run.py +0 -197
  67. package/.pantheon/code-mode/eval-static.py +0 -321
  68. package/.pantheon/code-mode/session-end-save.py +0 -186
  69. package/.pantheon/code-mode/session-end-save.sh +0 -18
  70. package/scripts/recover-zenodo-deposition.mjs +0 -146
  71. package/scripts/test-opencode-v1-v2-sandbox.sh +0 -862
  72. package/scripts/validate-zenodo-release.mjs +0 -154
  73. package/scripts/zenodo-artifact.mjs +0 -94
  74. package/scripts/zenodo-release-state.mjs +0 -53
  75. package/src/skills/orchestration-workflow/SKILL.md +0 -311
@@ -1,14 +1,18 @@
1
1
  #!/usr/bin/env python3
2
2
  """Pantheon Memory MCP Server — lightweight, zero heavy deps.
3
3
 
4
- Uses sqlite-vec (vector extension) + fastembed (ONNX, no PyTorch)
5
- for semantic memory with hybrid search (vector cosine + FTS5 BM25).
4
+ Search is FTS5 (BM25) only. The vector pipeline (sqlite-vec + fastembed,
5
+ ~50MB of wheels and ~185MB RSS) was removed outright: the prefix-matched OR
6
+ query it fused against returned a candidate pool so polluted that the vector
7
+ half was compensating for a query bug rather than adding recall. See
8
+ ``_build_fts_query`` for the query form that replaced it.
6
9
 
7
- Total footprint: ~50MB (sqlite-vec + fastembed) vs ~1.4GB (old chromadb).
10
+ Recall is therefore purely lexical. A query that shares no token with a
11
+ stored document will not retrieve it; there is no embedding to fall back on.
8
12
 
9
13
  Tools:
10
- memory_store — Store with automatic embedding
11
- memory_search — Hybrid vector + FTS5 keyword search
14
+ memory_store — Store a value (auto-indexed into FTS5 by trigger)
15
+ memory_search — FTS5 BM25 keyword search with optional freshness decay
12
16
  memory_recall — Exact recall by key
13
17
  memory_forget — Delete by ID or key
14
18
  memory_list — Chronological listing
@@ -22,26 +26,24 @@ from __future__ import annotations
22
26
 
23
27
  import functools
24
28
  import json
25
- import os
26
29
  import sqlite3
27
30
  import time
28
31
  from datetime import UTC, datetime
29
32
  from pathlib import Path
30
- from typing import Any
33
+ from typing import Annotated, Any
31
34
 
32
- import sqlite_vec
33
35
  from _pantheon_paths import pantheon_home
34
- from fastembed import TextEmbedding
35
36
  from mcp.server.fastmcp import FastMCP
37
+ from pydantic import BeforeValidator, Field
36
38
 
37
39
  # ── Paths ─────────────────────────────────────────────────────────────────────
38
40
 
39
41
  DB_PATH = pantheon_home() / "memory" / "memory.db"
40
- EMBED_CACHE = Path.home() / ".cache" / "fastembed"
41
42
 
42
43
 
43
44
  _BYTE_UNIT = 1024
44
45
 
46
+
45
47
  def _set_memory_dir(path: str | Path) -> None:
46
48
  """Override the memory db path for testing."""
47
49
  global DB_PATH # noqa: PLW0603
@@ -68,11 +70,6 @@ CREATE TABLE IF NOT EXISTS memories (
68
70
  CREATE UNIQUE INDEX IF NOT EXISTS idx_memories_ns_key
69
71
  ON memories(namespace, key);
70
72
 
71
- CREATE VIRTUAL TABLE IF NOT EXISTS vec_memories USING vec0(
72
- id INTEGER PRIMARY KEY,
73
- embedding float[384]
74
- );
75
-
76
73
  CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
77
74
  namespace, key, value, metadata,
78
75
  content='memories', content_rowid='id',
@@ -104,7 +101,11 @@ END;
104
101
 
105
102
  mcp = FastMCP(
106
103
  "pantheon-memory",
107
- instructions="Lightweight semantic memory with sqlite-vec + fastembed",
104
+ instructions=(
105
+ "Lightweight memory with FTS5 (SQLite BM25) keyword search across "
106
+ "namespaces. Lexical retrieval only — there is no embedding or vector "
107
+ "index, so a query must share a token with the stored text to match."
108
+ ),
108
109
  )
109
110
 
110
111
 
@@ -115,8 +116,7 @@ mcp = FastMCP(
115
116
  def _get_db() -> sqlite3.Connection:
116
117
  """Get or create the SQLite connection singleton.
117
118
 
118
- Creates DB directory, applies WAL/performance pragmas, registers
119
- the sqlite-vec extension, and runs schema init.
119
+ Creates DB directory, applies WAL/performance pragmas and runs schema init.
120
120
  """
121
121
  DB_PATH.parent.mkdir(parents=True, exist_ok=True)
122
122
  conn = sqlite3.connect(str(DB_PATH), check_same_thread=False)
@@ -124,9 +124,6 @@ def _get_db() -> sqlite3.Connection:
124
124
  conn.execute("PRAGMA journal_mode=WAL")
125
125
  conn.execute("PRAGMA busy_timeout=5000")
126
126
  conn.execute("PRAGMA foreign_keys=ON")
127
- # Register sqlite-vec extension
128
-
129
- sqlite_vec.load(conn)
130
127
  conn.executescript(SCHEMA_SQL)
131
128
  return conn
132
129
 
@@ -136,26 +133,180 @@ def _get_conn() -> sqlite3.Connection:
136
133
  return _get_db()
137
134
 
138
135
 
139
- # ── Embedding ─────────────────────────────────────────────────────────────────
136
+ # ── FTS Query Building ────────────────────────────────────────────────────────
137
+
138
+ # Function words carry no retrieval signal but prefix-match a large slice of
139
+ # the corpus, so OR-ing them in dilutes BM25 ranking with noise instead of
140
+ # narrowing the candidate pool. The list is deliberately limited to English
141
+ # function words and auxiliaries — no domain vocabulary.
142
+ _STOPWORDS = frozenset(
143
+ [
144
+ "a",
145
+ "about",
146
+ "above",
147
+ "after",
148
+ "again",
149
+ "against",
150
+ "all",
151
+ "am",
152
+ "an",
153
+ "and",
154
+ "any",
155
+ "are",
156
+ "as",
157
+ "at",
158
+ "be",
159
+ "because",
160
+ "been",
161
+ "before",
162
+ "being",
163
+ "below",
164
+ "between",
165
+ "both",
166
+ "but",
167
+ "by",
168
+ "can",
169
+ "cannot",
170
+ "could",
171
+ "did",
172
+ "do",
173
+ "does",
174
+ "doing",
175
+ "done",
176
+ "down",
177
+ "during",
178
+ "each",
179
+ "few",
180
+ "for",
181
+ "from",
182
+ "further",
183
+ "had",
184
+ "has",
185
+ "have",
186
+ "having",
187
+ "he",
188
+ "her",
189
+ "here",
190
+ "hers",
191
+ "him",
192
+ "his",
193
+ "how",
194
+ "i",
195
+ "if",
196
+ "in",
197
+ "into",
198
+ "is",
199
+ "it",
200
+ "its",
201
+ "itself",
202
+ "just",
203
+ "me",
204
+ "more",
205
+ "most",
206
+ "my",
207
+ "no",
208
+ "nor",
209
+ "not",
210
+ "now",
211
+ "of",
212
+ "off",
213
+ "on",
214
+ "once",
215
+ "only",
216
+ "or",
217
+ "other",
218
+ "our",
219
+ "ours",
220
+ "out",
221
+ "over",
222
+ "own",
223
+ "same",
224
+ "she",
225
+ "should",
226
+ "so",
227
+ "some",
228
+ "such",
229
+ "than",
230
+ "that",
231
+ "the",
232
+ "their",
233
+ "theirs",
234
+ "them",
235
+ "then",
236
+ "there",
237
+ "these",
238
+ "they",
239
+ "this",
240
+ "those",
241
+ "through",
242
+ "to",
243
+ "too",
244
+ "under",
245
+ "until",
246
+ "up",
247
+ "very",
248
+ "was",
249
+ "we",
250
+ "were",
251
+ "what",
252
+ "when",
253
+ "where",
254
+ "which",
255
+ "while",
256
+ "who",
257
+ "whom",
258
+ "why",
259
+ "will",
260
+ "with",
261
+ "would",
262
+ "you",
263
+ "your",
264
+ "yours",
265
+ ]
266
+ )
140
267
 
268
+ # A prefix query on a token this short is broader than the token itself
269
+ # ("in*" matches ingest/input/install/...), so short terms match exactly.
270
+ _MIN_PREFIX_LEN = 4
141
271
 
142
- @functools.cache
143
- def _get_embedder() -> TextEmbedding:
144
- """Lazy-load fastembed model (auto-downloads on first call, ~30MB).
145
272
 
146
- Uses BAAI/bge-small-en-v1.5 (384-dim, CPU, ONNX) — no PyTorch needed.
147
- Model is cached in ~/.cache/fastembed/.
148
- """
149
- os.environ.setdefault("TQDM_DISABLE", "1")
150
- return TextEmbedding(
151
- model_name="BAAI/bge-small-en-v1.5",
152
- cache_dir=str(EMBED_CACHE),
153
- )
273
+ def _build_fts_query(query: str) -> str:
274
+ """Build an FTS5 ``MATCH`` expression from free-text search input.
275
+
276
+ Replaces the previous ``" OR ".join(f'"{w}"*' for w in query.split())``
277
+ form, which prefix-matched *every* token, stopwords included. Measured on
278
+ the live store (1,553 memories) that form OR-ed in terms like ``the``,
279
+ ``is`` and ``how`` whose prefixes match most of the corpus, so a query for
280
+ ``purge threshold checkpoint`` pulled 1,001 documents as candidates and
281
+ 1,524 of them were noise. Two corrections:
154
282
 
283
+ - Stopwords are dropped rather than OR-ed in.
284
+ - Tokens shorter than ``_MIN_PREFIX_LEN`` characters match exactly instead
285
+ of by prefix, which removes the 3-character prefix blowups (``"com"*``
286
+ alone matched 1,235 of 1,550 documents).
155
287
 
156
- def _embed(text: str) -> list[float]:
157
- """Generate a 384-dim embedding vector for the given text."""
158
- return next(iter(_get_embedder().embed(text))).tolist()
288
+ Tokens surviving both filters are still prefix-matched, so a query term
289
+ retrieves documents containing a longer word that starts with it. If every
290
+ token is a stopword the unfiltered tokens are matched exactly, so an
291
+ all-stopword query degrades to a literal lookup rather than to no results.
292
+
293
+ Args:
294
+ query: Raw, user-supplied search text.
295
+
296
+ Returns:
297
+ An FTS5 expression (terms joined with ``OR``), or ``""`` for an empty
298
+ query.
299
+ """
300
+ words = [word for word in query.split() if word]
301
+ if not words:
302
+ return ""
303
+ content = [word for word in words if word.lower() not in _STOPWORDS]
304
+ if not content:
305
+ content = words
306
+ return " OR ".join(
307
+ f'"{word}"*' if len(word) >= _MIN_PREFIX_LEN else f'"{word}"'
308
+ for word in content
309
+ )
159
310
 
160
311
 
161
312
  # ── Helpers ───────────────────────────────────────────────────────────────────
@@ -182,9 +333,7 @@ def _parse_metadata(row: dict[str, Any]) -> dict[str, Any]:
182
333
  return row
183
334
 
184
335
 
185
- # ── RRF Fusion ────────────────────────────────────────────────────────────────
186
-
187
- _RRF_CONST = 60
336
+ # ── Ranking ───────────────────────────────────────────────────────────────────
188
337
 
189
338
 
190
339
  def _parse_iso_ts(value: str) -> float:
@@ -202,9 +351,7 @@ def _parse_iso_ts(value: str) -> float:
202
351
  return dt.timestamp()
203
352
 
204
353
 
205
- def _fetch_created_at_map(
206
- db: sqlite3.Connection, ids: list[int]
207
- ) -> dict[int, str]:
354
+ def _fetch_created_at_map(db: sqlite3.Connection, ids: list[int]) -> dict[int, str]:
208
355
  """Fetch created_at timestamps for the given memory IDs.
209
356
 
210
357
  Args:
@@ -220,8 +367,7 @@ def _fetch_created_at_map(
220
367
  try:
221
368
  placeholders = ",".join("?" * len(ids))
222
369
  rows = db.execute(
223
- f"SELECT id, created_at FROM memories "
224
- f"WHERE id IN ({placeholders})",
370
+ f"SELECT id, created_at FROM memories WHERE id IN ({placeholders})",
225
371
  ids,
226
372
  ).fetchall()
227
373
  created_at_map = {r["id"]: r["created_at"] for r in rows}
@@ -230,34 +376,33 @@ def _fetch_created_at_map(
230
376
  return created_at_map
231
377
 
232
378
 
233
- def _rrf_fuse(
234
- vec_ids: list[int],
235
- fts_ids: list[int],
379
+ def _score_hits(
380
+ hits: list[tuple[int, float]],
236
381
  top_k: int,
237
382
  created_at_map: dict[int, str] | None = None,
238
383
  decay_days: float | None = None,
239
384
  ) -> list[tuple[int, float]]:
240
- """Reciprocal Rank Fusion of vector and FTS5 result ID lists.
385
+ """Rank FTS5 hits by BM25 relevance, optionally freshness-decayed.
386
+
387
+ This replaces the vector+BM25 Reciprocal Rank Fusion: with a single ranked
388
+ list there is nothing left to fuse, so each hit keeps its own BM25 score
389
+ instead of a rank-derived one.
241
390
 
242
391
  Args:
243
- vec_ids: Ordered list of IDs from vector search (most relevant first).
244
- fts_ids: Ordered list of IDs from FTS5 search (most relevant first).
245
- top_k: Maximum number of fused results to return.
392
+ hits: ``(id, bm25)`` pairs from FTS5, most relevant first. ``bm25`` is
393
+ ``-rank`` (FTS5 reports ``rank`` negated, lower = better).
394
+ top_k: Maximum number of results to return.
246
395
  created_at_map: Optional mapping of doc_id -> created_at ISO string,
247
396
  used to compute freshness when ``decay_days`` is set.
248
- decay_days: Optional freshness half-life in days. When set, each fused
249
- score is multiplied by ``freshness = 2^(-days/decay_days)`` so
250
- older entries rank lower. Default None = no decay (backward
251
- compatible).
397
+ decay_days: Optional freshness half-life in days. When set, each score
398
+ is multiplied by ``2^(-days_since_created/decay_days)`` so older
399
+ entries rank lower. Default None = no decay.
252
400
 
253
401
  Returns:
254
- List of (id, rrf_score) tuples sorted by descending score.
402
+ List of ``(id, score)`` tuples sorted by descending score, ties broken
403
+ by ascending id so ranking is deterministic.
255
404
  """
256
- scores: dict[int, float] = {}
257
- for rank, doc_id in enumerate(vec_ids, start=1):
258
- scores[doc_id] = 1.0 / (_RRF_CONST + rank)
259
- for rank, doc_id in enumerate(fts_ids, start=1):
260
- scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (_RRF_CONST + rank)
405
+ scores: dict[int, float] = {doc_id: bm25 for doc_id, bm25 in hits}
261
406
 
262
407
  if decay_days is not None and decay_days > 0 and created_at_map:
263
408
  now = time.time()
@@ -277,46 +422,127 @@ def _rrf_fuse(
277
422
 
278
423
  # ── Tools ─────────────────────────────────────────────────────────────────────
279
424
 
425
+ # ── Argument Validation ─────────────────────────────────────────────────────────
426
+ # One call must surface EVERY invalid argument. ``metadata`` is a JSON object
427
+ # encoded as a STRING, and the natural mistake is to pass the object itself,
428
+ # which used to surface as pydantic's bare "Input should be a valid string" —
429
+ # no path, no received type, no example. The validator below names all three,
430
+ # and pydantic still accumulates it alongside every other invalid argument in
431
+ # the same call.
432
+
433
+ METADATA_EXAMPLE: str = """metadata='{"type": "decision", "score": 0.9}'"""
434
+
435
+
436
+ def _json_type_name(value: object) -> str:
437
+ """Name a value's JSON type for error messages (ints read as ``number``)."""
438
+ if value is None:
439
+ type_name = "null"
440
+ elif isinstance(value, bool):
441
+ type_name = "boolean"
442
+ elif isinstance(value, str):
443
+ type_name = "string"
444
+ elif isinstance(value, int | float):
445
+ type_name = "number"
446
+ elif isinstance(value, list):
447
+ type_name = "array"
448
+ elif isinstance(value, dict):
449
+ type_name = "object"
450
+ else:
451
+ type_name = type(value).__name__
452
+ return type_name
453
+
454
+
455
+ def _metadata_must_be_json_string(value: Any) -> Any:
456
+ """Accept only a JSON object encoded as a string, with an actionable error.
457
+
458
+ Runs before pydantic's own ``str`` check so the failure names the received
459
+ type and a correct example instead of leaking a raw type error.
460
+ """
461
+ if isinstance(value, str):
462
+ return value
463
+ raise ValueError(
464
+ "metadata must be a JSON object encoded as a string "
465
+ f"(got {_json_type_name(value)}). json.dumps it first. "
466
+ f"Example: {METADATA_EXAMPLE}"
467
+ )
468
+
469
+
470
+ def _collect_store_violations(value: str, metadata: str) -> tuple[list[str], list[str]]:
471
+ """Collect every in-body ``memory_store`` violation, plus any examples.
472
+
473
+ Returns ``(violations, examples)`` so the caller reports all of them in one
474
+ message instead of returning on the first problem it happens to notice.
475
+ """
476
+ violations: list[str] = []
477
+ examples: list[str] = []
478
+ if not value or not value.strip():
479
+ violations.append("value is required and must be a non-empty string")
480
+ if metadata and metadata != "{}":
481
+ try:
482
+ json.loads(metadata)
483
+ except (json.JSONDecodeError, TypeError, ValueError) as exc:
484
+ violations.append(
485
+ "metadata must be a JSON object encoded as a valid JSON string "
486
+ f"(got invalid JSON: {exc})"
487
+ )
488
+ examples.append(METADATA_EXAMPLE)
489
+ return violations, examples
490
+
491
+
492
+ def _format_store_violations(violations: list[str], examples: list[str]) -> str:
493
+ """Render stored-error text: one line per violation, then one example block."""
494
+ lines = [f"memory_store: {violation}" for violation in violations]
495
+ if len(violations) > 1:
496
+ lines.insert(0, f"memory_store: {len(violations)} invalid arguments")
497
+ deduped = list(dict.fromkeys(examples))
498
+ if deduped:
499
+ lines.append("example: " + " ".join(deduped))
500
+ return "\n".join(lines)
501
+
280
502
 
281
503
  @mcp.tool(
282
- description="Store a memory entry with automatic embedding generation. "
283
- "Returns the entry ID and status.",
504
+ description="Store a memory entry. The FTS5 index is updated automatically "
505
+ "by database trigger. Returns the entry ID and status. "
506
+ "metadata is a JSON object encoded as a STRING (not an object) and value "
507
+ "must be non-empty; every invalid argument is reported together in one "
508
+ f"message. Example: {METADATA_EXAMPLE}",
284
509
  )
285
510
  def memory_store(
286
511
  value: str = "",
287
512
  namespace: str = "default",
288
513
  key: str | None = None,
289
- metadata: str = "{}",
514
+ metadata: Annotated[
515
+ str,
516
+ BeforeValidator(_metadata_must_be_json_string),
517
+ Field(
518
+ default="{}",
519
+ description="JSON object encoded as a STRING, not an object. "
520
+ 'Example: \'{"type": "decision", "score": 0.9}\'.',
521
+ ),
522
+ ] = "{}",
290
523
  ) -> dict[str, Any]:
291
- """Store a value with its embedding vector for semantic search.
292
-
293
- Generates a 384-dim embedding via fastembed (BAAI/bge-small-en-v1.5)
294
- and stores it in the vec_memories virtual table alongside the text
295
- content in the memories table.
524
+ """Store a value and index it for FTS5 keyword search.
296
525
 
297
526
  Args:
298
- value: Text content to store.
527
+ value: Text content to store (required, non-empty).
299
528
  namespace: Namespace for isolation (default: "default").
300
529
  key: Optional unique key within namespace.
301
- metadata: Optional JSON metadata string.
530
+ metadata: Optional JSON object encoded as a string.
302
531
 
303
532
  Returns:
304
533
  Dict with id, namespace, key, status.
305
534
  """
306
- if not value or not value.strip():
307
- return {"error": "value cannot be empty"}
535
+ violations, examples = _collect_store_violations(value, metadata)
536
+ if violations:
537
+ return {"error": _format_store_violations(violations, examples)}
308
538
 
309
539
  value = value.strip()
310
540
  db = _get_conn()
311
541
  now = _now_iso()
312
542
 
313
- try:
314
- # Validate metadata is valid JSON
315
- md_obj: dict[str, Any] = (
316
- json.loads(metadata) if metadata and metadata != "{}" else {}
317
- )
318
- metadata_str = json.dumps(md_obj)
543
+ metadata_str = metadata if metadata else "{}"
319
544
 
545
+ try:
320
546
  cur = db.execute(
321
547
  """INSERT INTO memories (namespace, key, value, metadata,
322
548
  created_at, updated_at)
@@ -324,15 +550,6 @@ def memory_store(
324
550
  [namespace, key, value, metadata_str, now, now],
325
551
  )
326
552
  entry_id = cur.lastrowid
327
-
328
- # Generate embedding
329
- embedding = _embed(value)
330
- embedding_json = json.dumps(embedding)
331
-
332
- db.execute(
333
- "INSERT INTO vec_memories(id, embedding) VALUES (?, ?)",
334
- [entry_id, embedding_json],
335
- )
336
553
  db.commit()
337
554
  except sqlite3.IntegrityError as e:
338
555
  db.rollback()
@@ -345,133 +562,101 @@ def memory_store(
345
562
 
346
563
 
347
564
  @mcp.tool(
348
- description="Hybrid semantic search across memories. "
349
- "Combines vector cosine similarity and FTS5 BM25 keyword search "
350
- "via Reciprocal Rank Fusion (RRF). Optional decay_days applies a "
565
+ description="Lexical keyword search across memories using SQLite FTS5 "
566
+ "BM25 ranking. Stopwords are ignored and terms of 4+ characters are "
567
+ "prefix-matched, so a query must share a token with the stored text — "
568
+ "there is no semantic/vector retrieval. Optional decay_days applies a "
351
569
  "freshness half-life (2^(-days/decay_days)) so recent entries rank "
352
- "higher; default None keeps the original rank-only behavior.",
570
+ "higher; default None keeps the raw BM25 order.",
353
571
  )
354
- def memory_search( # noqa: PLR0912
572
+ def memory_search(
355
573
  query: str,
356
574
  namespace: str | None = None,
357
575
  top_k: int = 5,
358
576
  decay_days: float | None = None,
359
577
  ) -> list[dict[str, Any]]:
360
- """Hybrid search: vector + keyword fused via RRF.
361
-
362
- For short or keyword-heavy queries, FTS5 BM25 dominates.
363
- For conceptual or long queries, vector cosine dominates.
364
- RRF combines both into a single ranked list.
578
+ """Search memories with FTS5 BM25.
365
579
 
366
580
  Args:
367
581
  query: Search query text.
368
582
  namespace: Optional namespace filter.
369
583
  top_k: Maximum results (default 5, max 50).
370
- decay_days: Optional freshness half-life in days. When set, fused
584
+ decay_days: Optional freshness half-life in days. When set, BM25
371
585
  scores are multiplied by ``2^(-days_since_created/decay_days)``
372
586
  so older entries rank lower. Default None = no decay.
373
587
 
374
588
  Returns:
375
- List of memory entries with score and metadata.
589
+ List of memory entries with a ``score`` (BM25, higher is better,
590
+ freshness-decayed when ``decay_days`` is set) and parsed metadata.
376
591
  """
377
592
  if not query or not query.strip():
378
593
  return []
379
594
 
380
595
  top_k = max(1, min(50, int(top_k)))
381
596
  db = _get_conn()
382
- query_stripped = query.strip()
383
-
384
- # 1. Generate query embedding
385
- try:
386
- query_vec = json.dumps(_embed(query_stripped))
387
- except Exception:
388
- query_vec = None
389
-
390
- vec_ids: list[int] = []
391
- fts_ids: list[int] = []
392
-
393
- # 2. Vector search
394
- if query_vec is not None:
395
- nvec = top_k * 2
396
- try:
397
- if namespace:
398
- vec_rows = db.execute(
399
- """SELECT v.id FROM vec_memories v
400
- JOIN memories m ON m.id = v.id
401
- WHERE m.namespace = ?
402
- ORDER BY v.embedding MATCH ? LIMIT ?""",
403
- [namespace, query_vec, nvec],
404
- ).fetchall()
405
- else:
406
- vec_rows = db.execute(
407
- """SELECT v.id FROM vec_memories v
408
- ORDER BY v.embedding MATCH ? LIMIT ?""",
409
- [query_vec, nvec],
410
- ).fetchall()
411
- vec_ids = [r["id"] for r in vec_rows]
412
- except Exception:
413
- vec_ids = []
597
+ fts_query = _build_fts_query(query)
598
+ if not fts_query:
599
+ return []
414
600
 
415
- # 3. FTS5 keyword search
416
601
  try:
417
- # Build FTS query: escape special chars, use prefix matching
418
- fts_query = " OR ".join(f'"{word}"*' for word in query_stripped.split() if word)
602
+ params: list[Any] = [fts_query]
603
+ namespace_clause = ""
419
604
  if namespace:
420
- fts_rows = db.execute(
421
- """SELECT rowid FROM memories_fts
422
- WHERE memories_fts MATCH ?
423
- AND namespace = ?
424
- ORDER BY rank LIMIT ?""",
425
- [fts_query, namespace, top_k * 2],
426
- ).fetchall()
427
- else:
428
- fts_rows = db.execute(
429
- """SELECT rowid FROM memories_fts
430
- WHERE memories_fts MATCH ?
431
- ORDER BY rank LIMIT ?""",
432
- [fts_query, top_k * 2],
433
- ).fetchall()
434
- fts_ids = [r["rowid"] for r in fts_rows]
605
+ namespace_clause = " AND namespace = ?"
606
+ params.append(namespace)
607
+ # Over-fetch 2x: freshness decay can promote a candidate that BM25
608
+ # ranked outside top_k, and ranking happens after the fetch.
609
+ params.append(top_k * 2)
610
+ # ``rank`` is BM25 negated by FTS5 (lower = better), so -rank is the
611
+ # relevance score. It is selected explicitly so the value is available
612
+ # for scoring rather than only for ordering.
613
+ rows = db.execute(
614
+ f"""SELECT rowid, -rank AS bm25 FROM memories_fts
615
+ WHERE memories_fts MATCH ?{namespace_clause}
616
+ ORDER BY rank LIMIT ?""",
617
+ params,
618
+ ).fetchall()
619
+ hits = [(r["rowid"], r["bm25"]) for r in rows]
435
620
  except Exception:
436
- fts_ids = []
621
+ return []
622
+
623
+ if not hits:
624
+ return []
437
625
 
438
- # 4. RRF fusion (optionally freshness-decayed)
439
- candidate_ids = list(dict.fromkeys([*vec_ids, *fts_ids]))
626
+ candidate_ids = [doc_id for doc_id, _ in hits]
440
627
  created_at_map = (
441
628
  _fetch_created_at_map(db, candidate_ids)
442
629
  if decay_days is not None and decay_days > 0
443
630
  else {}
444
631
  )
445
- fused = _rrf_fuse(vec_ids, fts_ids, top_k, created_at_map, decay_days)
446
-
447
- if not fused:
448
- return []
632
+ ranked = _score_hits(hits, top_k, created_at_map, decay_days)
449
633
 
450
- # 5. Fetch full entries
451
- id_list = [doc_id for doc_id, _ in fused]
452
- score_map = {doc_id: score for doc_id, score in fused}
634
+ results: list[dict[str, Any]] = []
635
+ if ranked:
636
+ # 5. Fetch full entries
637
+ id_list = [doc_id for doc_id, _ in ranked]
638
+ score_map = {doc_id: score for doc_id, score in ranked}
453
639
 
454
- try:
455
- placeholders = ",".join("?" * len(id_list))
456
- rows = db.execute(
457
- f"""SELECT id, namespace, key, value, metadata, created_at
458
- FROM memories WHERE id IN ({placeholders})""",
459
- id_list,
460
- ).fetchall()
461
- except Exception:
462
- return []
640
+ try:
641
+ placeholders = ",".join("?" * len(id_list))
642
+ rows = db.execute(
643
+ f"""SELECT id, namespace, key, value, metadata, created_at
644
+ FROM memories WHERE id IN ({placeholders})""",
645
+ id_list,
646
+ ).fetchall()
647
+ except Exception:
648
+ return []
463
649
 
464
- # Preserve RRF ranking order
465
- row_map = {r["id"]: r for r in rows}
466
- results: list[dict[str, Any]] = []
467
- for doc_id in id_list:
468
- row = row_map.get(doc_id)
469
- if row is None:
470
- continue
471
- entry = dict(row)
472
- entry = _parse_metadata(entry)
473
- entry["score"] = round(score_map.get(doc_id, 0.0), 4)
474
- results.append(entry)
650
+ # Preserve ranking order
651
+ row_map = {r["id"]: r for r in rows}
652
+ for doc_id in id_list:
653
+ row = row_map.get(doc_id)
654
+ if row is None:
655
+ continue
656
+ entry = dict(row)
657
+ entry = _parse_metadata(entry)
658
+ entry["score"] = round(score_map.get(doc_id, 0.0), 4)
659
+ results.append(entry)
475
660
 
476
661
  return results
477
662
 
@@ -512,18 +697,17 @@ def memory_recall(
512
697
 
513
698
  @mcp.tool(
514
699
  description="Delete a memory entry by ID or by key. "
515
- "Vector embedding and FTS index entries are removed automatically "
516
- "via CASCADE / triggers.",
700
+ "The FTS5 index entry is removed automatically by trigger.",
517
701
  )
518
702
  def memory_forget(
519
703
  id: int | None = None,
520
704
  key: str | None = None,
521
705
  namespace: str = "default",
522
706
  ) -> dict[str, Any]:
523
- """Delete a memory entry and its associated vector embedding.
707
+ """Delete a memory entry and its FTS5 index entry.
524
708
 
525
709
  Provide either ``id`` (exact rowid) or ``key`` (within namespace)
526
- to identify the entry. Foreign key + FTS triggers handle cleanup.
710
+ to identify the entry. The FTS sync trigger handles index cleanup.
527
711
 
528
712
  Args:
529
713
  id: Exact row ID of the entry to delete.
@@ -548,8 +732,6 @@ def memory_forget(
548
732
  db = _get_conn()
549
733
  try:
550
734
  if col == "id":
551
- # Vec table cascade
552
- db.execute("DELETE FROM vec_memories WHERE id = ?", [id])
553
735
  cur = db.execute("DELETE FROM memories WHERE id = ?", [id])
554
736
  else:
555
737
  cur = db.execute(sql, params)
@@ -618,13 +800,12 @@ def memory_list(
618
800
 
619
801
  @mcp.tool(
620
802
  description="Memory database statistics: total entries, per-namespace "
621
- "breakdown, DB file size, and FTS/vector table sizes.",
803
+ "breakdown, and file size on disk.",
622
804
  )
623
805
  def memory_stats() -> dict[str, Any]:
624
806
  """Return aggregate statistics about the memory database.
625
807
 
626
- Includes total count, namespace breakdown, FTS entry count,
627
- vector entry count, and file size on disk.
808
+ Includes total count, namespace breakdown, and file size on disk.
628
809
 
629
810
  Returns:
630
811
  Dict with count, namespaces, and storage info.
@@ -651,13 +832,6 @@ def memory_stats() -> dict[str, Any]:
651
832
  except Exception:
652
833
  stats["namespaces"] = []
653
834
 
654
- try:
655
- stats["vector_entries"] = db.execute(
656
- "SELECT COUNT(*) AS c FROM vec_memories"
657
- ).fetchone()["c"]
658
- except Exception:
659
- stats["vector_entries"] = 0
660
-
661
835
  try:
662
836
  db_size = DB_PATH.stat().st_size if DB_PATH.exists() else 0
663
837
  stats["db_size_bytes"] = db_size