pantheon-opencode 1.5.1 → 1.6.0-beta.1
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.
- package/.pantheon/code-mode/README.md +4 -2
- package/.pantheon/code-mode/checkpoint_session.py +138 -102
- package/.pantheon/code-mode/compress-inline.py +102 -57
- package/.pantheon/code-mode/manifest.json +2 -8
- package/CHANGELOG.md +22 -0
- package/README.md +86 -52
- package/README.pt-BR.md +63 -43
- package/commands/pantheon-optimize.md +2 -2
- package/package.json +13 -1
- package/plugin.json +1 -1
- package/pyproject.toml +8 -15
- package/scripts/build-agents-md.mjs +2 -2
- package/scripts/check-plugin-v2-coverage.mjs +213 -0
- package/scripts/ci-validate-yaml.py +7 -5
- package/scripts/code_mode_server.py +116 -24
- package/scripts/doctor.mjs +85 -28
- package/scripts/install/cli-ui.mjs +1 -1
- package/scripts/install/config-migration.mjs +2 -2
- package/scripts/install/health-check.mjs +6 -19
- package/scripts/install/interactive.mjs +6 -6
- package/scripts/install/migrate.mjs +1 -1
- package/scripts/install/state.mjs +27 -2
- package/scripts/mcp_persistence_server.py +390 -76
- package/scripts/memory_mcp_server.py +370 -196
- package/scripts/postinstall.mjs +0 -7
- package/scripts/probe-context-rehydrate.mjs +28 -11
- package/scripts/prune.mjs +2 -2
- package/scripts/sync-tui.mjs +11 -5
- package/scripts/test-opencode-v2-sandbox.sh +1759 -0
- package/scripts/validate-routing.mjs +40 -0
- package/skills-lock.json +9 -10
- package/src/agents/mnemosyne.md +15 -16
- package/src/agents/zeus.md +1 -2
- package/src/mcp/code_mode_server.py +116 -24
- package/src/mcp/mcp_codemap_module.py +559 -129
- package/src/mcp/mcp_persistence_server.py +390 -76
- package/src/mcp/memory_mcp_server.py +371 -248
- package/src/mcp/pantheon_vision_server.py +136 -24
- package/src/mcp/requirements-mcp.txt +5 -3
- package/src/mcp/requirements-vision.txt +2 -0
- package/src/pantheon/model-command.ts +18 -7
- package/src/pantheon/todo-enforcer.ts +1 -1
- package/src/pantheon/vision.ts +1 -1
- package/src/plugin-v2.ts +134 -104
- package/src/plugins/tui/dist/tui.js +1 -1
- package/src/plugins/tui/package-lock.json +20 -20
- package/src/plugins/tui/package.json +12 -12
- package/src/routing.yml +0 -1
- package/src/skills/README.md +31 -36
- package/src/skills/auto-continue/SKILL.md +25 -75
- package/.pantheon/code-mode/eval-llm-judge.py +0 -255
- package/.pantheon/code-mode/eval-monte-carlo.py +0 -368
- package/.pantheon/code-mode/eval-run.py +0 -197
- package/.pantheon/code-mode/eval-static.py +0 -321
- package/.pantheon/code-mode/session-end-save.py +0 -186
- package/.pantheon/code-mode/session-end-save.sh +0 -18
- package/scripts/recover-zenodo-deposition.mjs +0 -146
- package/scripts/test-opencode-v1-v2-sandbox.sh +0 -862
- package/scripts/validate-zenodo-release.mjs +0 -154
- package/scripts/zenodo-artifact.mjs +0 -21
- package/scripts/zenodo-release-state.mjs +0 -53
- 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
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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
|
|
11
|
-
memory_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=
|
|
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
|
|
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
|
-
# ──
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
# ──
|
|
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
|
|
234
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
top_k: Maximum number of
|
|
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
|
|
249
|
-
|
|
250
|
-
|
|
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,
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
307
|
-
|
|
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
|
-
|
|
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="
|
|
349
|
-
"
|
|
350
|
-
"
|
|
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
|
|
570
|
+
"higher; default None keeps the raw BM25 order.",
|
|
353
571
|
)
|
|
354
|
-
def memory_search(
|
|
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
|
-
"""
|
|
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,
|
|
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
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
418
|
-
|
|
602
|
+
params: list[Any] = [fts_query]
|
|
603
|
+
namespace_clause = ""
|
|
419
604
|
if namespace:
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
621
|
+
return []
|
|
622
|
+
|
|
623
|
+
if not hits:
|
|
624
|
+
return []
|
|
437
625
|
|
|
438
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
if not fused:
|
|
448
|
-
return []
|
|
632
|
+
ranked = _score_hits(hits, top_k, created_at_map, decay_days)
|
|
449
633
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
"
|
|
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
|
|
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.
|
|
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,
|
|
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,
|
|
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
|