omnidb-server 0.1.0__tar.gz
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.
- omnidb_server-0.1.0/PKG-INFO +157 -0
- omnidb_server-0.1.0/README.md +137 -0
- omnidb_server-0.1.0/omnidb_server/__init__.py +8 -0
- omnidb_server-0.1.0/omnidb_server/__main__.py +6 -0
- omnidb_server-0.1.0/omnidb_server/cli.py +88 -0
- omnidb_server-0.1.0/omnidb_server/config.py +105 -0
- omnidb_server-0.1.0/omnidb_server/consolidate.py +205 -0
- omnidb_server-0.1.0/omnidb_server/db.py +114 -0
- omnidb_server-0.1.0/omnidb_server/embeddings.py +93 -0
- omnidb_server-0.1.0/omnidb_server/fusion.py +26 -0
- omnidb_server-0.1.0/omnidb_server/portability.py +135 -0
- omnidb_server-0.1.0/omnidb_server/rerank.py +74 -0
- omnidb_server-0.1.0/omnidb_server/scoring.py +29 -0
- omnidb_server-0.1.0/omnidb_server/server.py +121 -0
- omnidb_server-0.1.0/omnidb_server/store.py +732 -0
- omnidb_server-0.1.0/omnidb_server/vector_index.py +249 -0
- omnidb_server-0.1.0/omnidb_server.egg-info/PKG-INFO +157 -0
- omnidb_server-0.1.0/omnidb_server.egg-info/SOURCES.txt +43 -0
- omnidb_server-0.1.0/omnidb_server.egg-info/dependency_links.txt +1 -0
- omnidb_server-0.1.0/omnidb_server.egg-info/entry_points.txt +2 -0
- omnidb_server-0.1.0/omnidb_server.egg-info/requires.txt +15 -0
- omnidb_server-0.1.0/omnidb_server.egg-info/top_level.txt +1 -0
- omnidb_server-0.1.0/pyproject.toml +41 -0
- omnidb_server-0.1.0/setup.cfg +4 -0
- omnidb_server-0.1.0/tests/test_agent_flow.py +61 -0
- omnidb_server-0.1.0/tests/test_chaos.py +119 -0
- omnidb_server-0.1.0/tests/test_cli_ops.py +105 -0
- omnidb_server-0.1.0/tests/test_consolidate.py +86 -0
- omnidb_server-0.1.0/tests/test_db.py +84 -0
- omnidb_server-0.1.0/tests/test_foundation_v2.py +226 -0
- omnidb_server-0.1.0/tests/test_graph_fuzz.py +72 -0
- omnidb_server-0.1.0/tests/test_graph_search.py +35 -0
- omnidb_server-0.1.0/tests/test_hardening.py +104 -0
- omnidb_server-0.1.0/tests/test_issue_fixes.py +268 -0
- omnidb_server-0.1.0/tests/test_memory.py +112 -0
- omnidb_server-0.1.0/tests/test_perf_smoke.py +93 -0
- omnidb_server-0.1.0/tests/test_portability.py +126 -0
- omnidb_server-0.1.0/tests/test_reconcile.py +55 -0
- omnidb_server-0.1.0/tests/test_reranker.py +199 -0
- omnidb_server-0.1.0/tests/test_scoring_properties.py +69 -0
- omnidb_server-0.1.0/tests/test_security_matrix.py +126 -0
- omnidb_server-0.1.0/tests/test_semantic.py +40 -0
- omnidb_server-0.1.0/tests/test_stdio_conformance.py +75 -0
- omnidb_server-0.1.0/tests/test_supersession.py +76 -0
- omnidb_server-0.1.0/tests/test_tools_v2.py +39 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: omnidb-server
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP-native agent memory & retrieval server over SQLite (WAL + FTS5) with an hnswlib vector sidecar.
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: mcp<2,>=1.9
|
|
9
|
+
Requires-Dist: numpy>=1.26
|
|
10
|
+
Requires-Dist: httpx>=0.27
|
|
11
|
+
Provides-Extra: semantic
|
|
12
|
+
Requires-Dist: fastembed>=0.3; extra == "semantic"
|
|
13
|
+
Provides-Extra: hnsw
|
|
14
|
+
Requires-Dist: hnswlib>=0.8; extra == "hnsw"
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
17
|
+
Requires-Dist: pytest-cov>=5; extra == "dev"
|
|
18
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
19
|
+
Requires-Dist: hypothesis>=6.100; extra == "dev"
|
|
20
|
+
|
|
21
|
+
# OmniDB — MCP-native agent memory & retrieval server
|
|
22
|
+
|
|
23
|
+
A local-first, single-binary-feel **agent memory and retrieval server over SQLite**, exposed to AI
|
|
24
|
+
agents through the Model Context Protocol. This is the revised v1 that came out of the August 2026
|
|
25
|
+
research + adversarial review (see `../OmniDB Vault/`): an MCP memory/retrieval server, not a database.
|
|
26
|
+
|
|
27
|
+
- **Substrate**: SQLite in WAL mode (`synchronous=NORMAL`, `busy_timeout=5000`) — one directory on disk,
|
|
28
|
+
copyable as a unit (`omnidb.db` + WAL sidecars + vector index file). The single-file mandate was dropped.
|
|
29
|
+
- **Retrieval**: BM25 (FTS5, porter tokenization) fused with dense vector search via Reciprocal Rank Fusion,
|
|
30
|
+
then a gentle recency/frequency rerank. Hybrid retrieval runs in-process; SQLite table-valued functions
|
|
31
|
+
(`VECTOR_SIM` / `MEMORY_RECALL`) need a native extension and are the documented upgrade path for the
|
|
32
|
+
future Go/Rust port.
|
|
33
|
+
- **Vectors**: hnswlib (M=16, efC=200) inner-product on L2-normalized vectors == cosine, persisted as a
|
|
34
|
+
sidecar file; exact brute-force fallback always available. Deletions are mark-deleted.
|
|
35
|
+
- **Memory model**: plain relational tables (`episodic | semantic | procedural`) — no bespoke LSM, no fixed
|
|
36
|
+
decay religion (weights are config). Consolidation distills episodic → semantic via a configurable LLM,
|
|
37
|
+
with an offline stub when none is set.
|
|
38
|
+
|
|
39
|
+
## Quickstart
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install -e ".[hnsw,semantic,dev]" # hnsw/semantic extras optional; falls back gracefully
|
|
43
|
+
pytest
|
|
44
|
+
omnidb-server serve # MCP stdio server on $OMNIDB_HOME (default ~/.omnidb)
|
|
45
|
+
python -m omnidb_server serve # equivalent
|
|
46
|
+
python -m omnidb_server stats
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Claude Desktop / any MCP client
|
|
50
|
+
|
|
51
|
+
See [`examples/claude-desktop-config.example.json`](examples/claude-desktop-config.example.json)
|
|
52
|
+
— includes the env vars for real (fastembed) semantics and a working `HF_HOME`
|
|
53
|
+
override. Minimal version:
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{
|
|
57
|
+
"mcpServers": {
|
|
58
|
+
"omnidb": {
|
|
59
|
+
"command": "python",
|
|
60
|
+
"args": ["-m", "omnidb_server", "serve"]
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Tools
|
|
67
|
+
|
|
68
|
+
| Tool | Purpose |
|
|
69
|
+
|---|---|
|
|
70
|
+
| `remember(content, kind, metadata, links)` | persist a memory (episodic/semantic/procedural), optional graph links |
|
|
71
|
+
| `recall(query, k, kind, k_fetch)` | hybrid BM25+dense recall with recency/frequency rerank; pool floors at `k` |
|
|
72
|
+
| `update_memory(memory_id, content, metadata)` | edit content (re-embeds + reindexes via FTS trigger) and/or merge metadata |
|
|
73
|
+
| `search(sql)` | read-only SELECT/WITH escape hatch against SQLite |
|
|
74
|
+
| `related(memory_id, max_hops, label)` | undirected graph walk (recursive CTE, cycle-safe, hops clamped 1-8) |
|
|
75
|
+
| `forget(memory_id)` / `forget_many(ids)` | soft delete one / many |
|
|
76
|
+
| `consolidate(hours)` | episodic → semantic distillation |
|
|
77
|
+
| `stats()` | counts, edges, embedder/index info, dead-ratio, all file sizes |
|
|
78
|
+
|
|
79
|
+
Error messages carry `OMN-*` codes from the original spec's taxonomy (`[OMN-020]` read-only violation, `[OMN-021]` not found, `[OMN-006]` dimension mismatch, `[OMN-011]` timeout, ...).
|
|
80
|
+
|
|
81
|
+
## Configuration
|
|
82
|
+
|
|
83
|
+
Precedence: **CLI flags > environment > `omnidb.toml` > defaults**. The TOML file is looked up in
|
|
84
|
+
the CWD and the data directory (field names mirror the config dataclass):
|
|
85
|
+
|
|
86
|
+
```toml
|
|
87
|
+
half_life_hours = 168
|
|
88
|
+
rrf_k = 60
|
|
89
|
+
k_fetch = 50
|
|
90
|
+
persist_every_ops = 64 # vector-sidecar fsync debouncing
|
|
91
|
+
dead_compact_ratio = 0.2 # hnsw tombstone ratio triggering rebuild-on-open
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Operations
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
omnidb-server compact --home D:/path/to/data # force full vector-index rebuild
|
|
98
|
+
python -m omnidb_server stats # includes pending_compaction, reconciled_at, WAL size
|
|
99
|
+
omnidb-server check # FTS integrity, index drift, orphan edges, WAL size
|
|
100
|
+
omnidb-server purge [--vacuum] # hard-delete soft-deleted rows; --vacuum reclaims space
|
|
101
|
+
omnidb-server export D:/backup/memories.jsonl # dump all memories + edges to JSONL
|
|
102
|
+
omnidb-server import D:/backup/memories.jsonl # restore from a JSONL dump
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Configuration (environment)
|
|
106
|
+
|
|
107
|
+
| Variable | Default | Meaning |
|
|
108
|
+
|---|---|---|
|
|
109
|
+
| `OMNIDB_HOME` | `~/.omnidb` | data directory |
|
|
110
|
+
| `OMNIDB_EMBEDDER` | `auto` | `auto`/`hashing` are offline-safe (never download); `fastembed` opts into real semantics (`[semantic]` extra, ~100MB model on first run). Embedder identity is pinned per database. |
|
|
111
|
+
| `OMNIDB_INDEX` | `auto` | `auto` \| `hnsw` \| `brute` |
|
|
112
|
+
| `OMNIDB_HALF_LIFE_HOURS` | `168` | recency half-life for rerank |
|
|
113
|
+
| `OMNIDB_RECENCY_WEIGHT` / `OMNIDB_FREQ_WEIGHT` | `0.15` / `0.05` | rerank weights (RRF base is weight-free) |
|
|
114
|
+
| `OMNIDB_K_FETCH` | `50` | candidates fetched per leg before fusion |
|
|
115
|
+
| `OMNIDB_RRF_K` | `60` | RRF smoothing constant |
|
|
116
|
+
| `OMNIDB_MAX_SEARCH_ROWS` | `500` | row cap for the `search` tool |
|
|
117
|
+
| `OMNIDB_LLM_BASE_URL` / `_MODEL` / `_API_KEY` | unset | OpenAI-compatible endpoint used by `consolidate`; stub when unset. With one configured, a failed call consumes nothing (episodes stay retryable) |
|
|
118
|
+
|
|
119
|
+
## Benchmarking
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
python bench/baseline.py # writes bench/results.md
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Measures hnswlib (M=16, efC=200) recall@10 vs exact brute force across an ef sweep at
|
|
126
|
+
384/768 dims on clustered synthetic data (1k/10k/100k), plus the SQ8 experiment.
|
|
127
|
+
Findings so far: at 768-dim/10k, SQ8+rerank (0.947) beats raw HNSW at ef=128 (0.837);
|
|
128
|
+
at 100k the clustered-synthetic distribution is strongly pessimistic for graph ANN
|
|
129
|
+
(tight clusters create hub structure), where quantized scoring + f32 rerank stays
|
|
130
|
+
robust (0.90) but at full-scan cost (~120ms). Treat as a floor, not a forecast —
|
|
131
|
+
re-run against your real corpus before drawing conclusions.
|
|
132
|
+
|
|
133
|
+
## Encryption at rest
|
|
134
|
+
|
|
135
|
+
OmniDB is local-first and writes only to `OMNIDB_HOME`; it phones home to no one. For
|
|
136
|
+
data-at-rest protection we recommend OS-level disk encryption (BitLocker / FileVault /
|
|
137
|
+
LUKS) — that covers the stolen-disk threat model without adding a dependency or a key
|
|
138
|
+
you must manage.
|
|
139
|
+
|
|
140
|
+
A SQLCipher swap is documented but deliberately not bundled: encrypting the SQLite file
|
|
141
|
+
would require `pysqlcipher3`, whose Windows wheels for Python 3.14 are effectively
|
|
142
|
+
unobtainable, so shipping it as an extra would break installs rather than protect them.
|
|
143
|
+
If your threat model needs per-file encryption beyond full-disk, compile SQLCipher and
|
|
144
|
+
point this package's connection at it — the store layer is plain `sqlite3` DB-API and
|
|
145
|
+
needs no code changes.
|
|
146
|
+
|
|
147
|
+
## Limitations (v1, deliberate)
|
|
148
|
+
|
|
149
|
+
- Single-writer process per home directory (SQLite WAL semantics). The vector sidecar is
|
|
150
|
+
last-writer-wins: run one server per home; startup reconciliation after a crash between the DB and
|
|
151
|
+
the index file is deferred.
|
|
152
|
+
- The Python `sqlite3` module cannot register virtual tables, so SQL++-style TVFs arrive with the
|
|
153
|
+
native port; until then hybrid queries go through the tools.
|
|
154
|
+
- Hashing embedder matches surface forms, not paraphrases — install `fastembed` for real semantics.
|
|
155
|
+
Embedder identity is pinned per database; switching requires a fresh home.
|
|
156
|
+
- No QPS targets: this serves one agent session locally. Gates are interactive latency, cold start,
|
|
157
|
+
and RAM at scale (re-baselined empirically at true embedding dimensions, not SIFT-128 folklore).
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# OmniDB — MCP-native agent memory & retrieval server
|
|
2
|
+
|
|
3
|
+
A local-first, single-binary-feel **agent memory and retrieval server over SQLite**, exposed to AI
|
|
4
|
+
agents through the Model Context Protocol. This is the revised v1 that came out of the August 2026
|
|
5
|
+
research + adversarial review (see `../OmniDB Vault/`): an MCP memory/retrieval server, not a database.
|
|
6
|
+
|
|
7
|
+
- **Substrate**: SQLite in WAL mode (`synchronous=NORMAL`, `busy_timeout=5000`) — one directory on disk,
|
|
8
|
+
copyable as a unit (`omnidb.db` + WAL sidecars + vector index file). The single-file mandate was dropped.
|
|
9
|
+
- **Retrieval**: BM25 (FTS5, porter tokenization) fused with dense vector search via Reciprocal Rank Fusion,
|
|
10
|
+
then a gentle recency/frequency rerank. Hybrid retrieval runs in-process; SQLite table-valued functions
|
|
11
|
+
(`VECTOR_SIM` / `MEMORY_RECALL`) need a native extension and are the documented upgrade path for the
|
|
12
|
+
future Go/Rust port.
|
|
13
|
+
- **Vectors**: hnswlib (M=16, efC=200) inner-product on L2-normalized vectors == cosine, persisted as a
|
|
14
|
+
sidecar file; exact brute-force fallback always available. Deletions are mark-deleted.
|
|
15
|
+
- **Memory model**: plain relational tables (`episodic | semantic | procedural`) — no bespoke LSM, no fixed
|
|
16
|
+
decay religion (weights are config). Consolidation distills episodic → semantic via a configurable LLM,
|
|
17
|
+
with an offline stub when none is set.
|
|
18
|
+
|
|
19
|
+
## Quickstart
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install -e ".[hnsw,semantic,dev]" # hnsw/semantic extras optional; falls back gracefully
|
|
23
|
+
pytest
|
|
24
|
+
omnidb-server serve # MCP stdio server on $OMNIDB_HOME (default ~/.omnidb)
|
|
25
|
+
python -m omnidb_server serve # equivalent
|
|
26
|
+
python -m omnidb_server stats
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Claude Desktop / any MCP client
|
|
30
|
+
|
|
31
|
+
See [`examples/claude-desktop-config.example.json`](examples/claude-desktop-config.example.json)
|
|
32
|
+
— includes the env vars for real (fastembed) semantics and a working `HF_HOME`
|
|
33
|
+
override. Minimal version:
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{
|
|
37
|
+
"mcpServers": {
|
|
38
|
+
"omnidb": {
|
|
39
|
+
"command": "python",
|
|
40
|
+
"args": ["-m", "omnidb_server", "serve"]
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Tools
|
|
47
|
+
|
|
48
|
+
| Tool | Purpose |
|
|
49
|
+
|---|---|
|
|
50
|
+
| `remember(content, kind, metadata, links)` | persist a memory (episodic/semantic/procedural), optional graph links |
|
|
51
|
+
| `recall(query, k, kind, k_fetch)` | hybrid BM25+dense recall with recency/frequency rerank; pool floors at `k` |
|
|
52
|
+
| `update_memory(memory_id, content, metadata)` | edit content (re-embeds + reindexes via FTS trigger) and/or merge metadata |
|
|
53
|
+
| `search(sql)` | read-only SELECT/WITH escape hatch against SQLite |
|
|
54
|
+
| `related(memory_id, max_hops, label)` | undirected graph walk (recursive CTE, cycle-safe, hops clamped 1-8) |
|
|
55
|
+
| `forget(memory_id)` / `forget_many(ids)` | soft delete one / many |
|
|
56
|
+
| `consolidate(hours)` | episodic → semantic distillation |
|
|
57
|
+
| `stats()` | counts, edges, embedder/index info, dead-ratio, all file sizes |
|
|
58
|
+
|
|
59
|
+
Error messages carry `OMN-*` codes from the original spec's taxonomy (`[OMN-020]` read-only violation, `[OMN-021]` not found, `[OMN-006]` dimension mismatch, `[OMN-011]` timeout, ...).
|
|
60
|
+
|
|
61
|
+
## Configuration
|
|
62
|
+
|
|
63
|
+
Precedence: **CLI flags > environment > `omnidb.toml` > defaults**. The TOML file is looked up in
|
|
64
|
+
the CWD and the data directory (field names mirror the config dataclass):
|
|
65
|
+
|
|
66
|
+
```toml
|
|
67
|
+
half_life_hours = 168
|
|
68
|
+
rrf_k = 60
|
|
69
|
+
k_fetch = 50
|
|
70
|
+
persist_every_ops = 64 # vector-sidecar fsync debouncing
|
|
71
|
+
dead_compact_ratio = 0.2 # hnsw tombstone ratio triggering rebuild-on-open
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Operations
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
omnidb-server compact --home D:/path/to/data # force full vector-index rebuild
|
|
78
|
+
python -m omnidb_server stats # includes pending_compaction, reconciled_at, WAL size
|
|
79
|
+
omnidb-server check # FTS integrity, index drift, orphan edges, WAL size
|
|
80
|
+
omnidb-server purge [--vacuum] # hard-delete soft-deleted rows; --vacuum reclaims space
|
|
81
|
+
omnidb-server export D:/backup/memories.jsonl # dump all memories + edges to JSONL
|
|
82
|
+
omnidb-server import D:/backup/memories.jsonl # restore from a JSONL dump
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Configuration (environment)
|
|
86
|
+
|
|
87
|
+
| Variable | Default | Meaning |
|
|
88
|
+
|---|---|---|
|
|
89
|
+
| `OMNIDB_HOME` | `~/.omnidb` | data directory |
|
|
90
|
+
| `OMNIDB_EMBEDDER` | `auto` | `auto`/`hashing` are offline-safe (never download); `fastembed` opts into real semantics (`[semantic]` extra, ~100MB model on first run). Embedder identity is pinned per database. |
|
|
91
|
+
| `OMNIDB_INDEX` | `auto` | `auto` \| `hnsw` \| `brute` |
|
|
92
|
+
| `OMNIDB_HALF_LIFE_HOURS` | `168` | recency half-life for rerank |
|
|
93
|
+
| `OMNIDB_RECENCY_WEIGHT` / `OMNIDB_FREQ_WEIGHT` | `0.15` / `0.05` | rerank weights (RRF base is weight-free) |
|
|
94
|
+
| `OMNIDB_K_FETCH` | `50` | candidates fetched per leg before fusion |
|
|
95
|
+
| `OMNIDB_RRF_K` | `60` | RRF smoothing constant |
|
|
96
|
+
| `OMNIDB_MAX_SEARCH_ROWS` | `500` | row cap for the `search` tool |
|
|
97
|
+
| `OMNIDB_LLM_BASE_URL` / `_MODEL` / `_API_KEY` | unset | OpenAI-compatible endpoint used by `consolidate`; stub when unset. With one configured, a failed call consumes nothing (episodes stay retryable) |
|
|
98
|
+
|
|
99
|
+
## Benchmarking
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
python bench/baseline.py # writes bench/results.md
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Measures hnswlib (M=16, efC=200) recall@10 vs exact brute force across an ef sweep at
|
|
106
|
+
384/768 dims on clustered synthetic data (1k/10k/100k), plus the SQ8 experiment.
|
|
107
|
+
Findings so far: at 768-dim/10k, SQ8+rerank (0.947) beats raw HNSW at ef=128 (0.837);
|
|
108
|
+
at 100k the clustered-synthetic distribution is strongly pessimistic for graph ANN
|
|
109
|
+
(tight clusters create hub structure), where quantized scoring + f32 rerank stays
|
|
110
|
+
robust (0.90) but at full-scan cost (~120ms). Treat as a floor, not a forecast —
|
|
111
|
+
re-run against your real corpus before drawing conclusions.
|
|
112
|
+
|
|
113
|
+
## Encryption at rest
|
|
114
|
+
|
|
115
|
+
OmniDB is local-first and writes only to `OMNIDB_HOME`; it phones home to no one. For
|
|
116
|
+
data-at-rest protection we recommend OS-level disk encryption (BitLocker / FileVault /
|
|
117
|
+
LUKS) — that covers the stolen-disk threat model without adding a dependency or a key
|
|
118
|
+
you must manage.
|
|
119
|
+
|
|
120
|
+
A SQLCipher swap is documented but deliberately not bundled: encrypting the SQLite file
|
|
121
|
+
would require `pysqlcipher3`, whose Windows wheels for Python 3.14 are effectively
|
|
122
|
+
unobtainable, so shipping it as an extra would break installs rather than protect them.
|
|
123
|
+
If your threat model needs per-file encryption beyond full-disk, compile SQLCipher and
|
|
124
|
+
point this package's connection at it — the store layer is plain `sqlite3` DB-API and
|
|
125
|
+
needs no code changes.
|
|
126
|
+
|
|
127
|
+
## Limitations (v1, deliberate)
|
|
128
|
+
|
|
129
|
+
- Single-writer process per home directory (SQLite WAL semantics). The vector sidecar is
|
|
130
|
+
last-writer-wins: run one server per home; startup reconciliation after a crash between the DB and
|
|
131
|
+
the index file is deferred.
|
|
132
|
+
- The Python `sqlite3` module cannot register virtual tables, so SQL++-style TVFs arrive with the
|
|
133
|
+
native port; until then hybrid queries go through the tools.
|
|
134
|
+
- Hashing embedder matches surface forms, not paraphrases — install `fastembed` for real semantics.
|
|
135
|
+
Embedder identity is pinned per database; switching requires a fresh home.
|
|
136
|
+
- No QPS targets: this serves one agent session locally. Gates are interactive latency, cold start,
|
|
137
|
+
and RAM at scale (re-baselined empirically at true embedding dimensions, not SIFT-128 folklore).
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from .config import Config
|
|
8
|
+
from .consolidate import run_consolidation
|
|
9
|
+
from .server import build_stack
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main(argv: list[str] | None = None) -> int:
|
|
13
|
+
parser = argparse.ArgumentParser(prog="omnidb-server",
|
|
14
|
+
description="MCP-native agent memory & retrieval server over SQLite")
|
|
15
|
+
parser.add_argument("--home", help="data directory (default $OMNIDB_HOME or ~/.omnidb)")
|
|
16
|
+
# accept --home after the subcommand too; SUPPRESS keeps the subparser
|
|
17
|
+
# from overwriting a top-level value with its own default
|
|
18
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
19
|
+
common.add_argument("--home", default=argparse.SUPPRESS)
|
|
20
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
21
|
+
sub.add_parser("serve", parents=[common], help="run the MCP stdio server")
|
|
22
|
+
sub.add_parser("stats", parents=[common], help="print database statistics as JSON")
|
|
23
|
+
sub.add_parser("compact", parents=[common],
|
|
24
|
+
help="force a full vector-index rebuild (compaction / repair)")
|
|
25
|
+
p_consol = sub.add_parser("consolidate", parents=[common],
|
|
26
|
+
help="distill episodic memories into semantic ones")
|
|
27
|
+
p_consol.add_argument("--hours", type=float, default=24.0)
|
|
28
|
+
sub.add_parser("check", parents=[common],
|
|
29
|
+
help="run integrity checks (FTS, index drift, orphan edges)")
|
|
30
|
+
p_purge = sub.add_parser("purge", parents=[common],
|
|
31
|
+
help="hard-delete soft-deleted memories")
|
|
32
|
+
p_purge.add_argument("--vacuum", action="store_true",
|
|
33
|
+
help="reclaim disk space with VACUUM afterwards")
|
|
34
|
+
p_export = sub.add_parser("export", parents=[common],
|
|
35
|
+
help="dump live memories and edges to a JSONL file")
|
|
36
|
+
p_export.add_argument("path")
|
|
37
|
+
p_import = sub.add_parser("import", parents=[common],
|
|
38
|
+
help="restore a JSONL export into an empty home")
|
|
39
|
+
p_import.add_argument("path")
|
|
40
|
+
|
|
41
|
+
args = parser.parse_args(argv)
|
|
42
|
+
home = getattr(args, "home", None)
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
if args.command == "serve":
|
|
46
|
+
from .server import serve
|
|
47
|
+
serve(home)
|
|
48
|
+
return 0
|
|
49
|
+
|
|
50
|
+
cfg = Config.load(home)
|
|
51
|
+
store = build_stack(cfg)
|
|
52
|
+
try:
|
|
53
|
+
if args.command == "stats":
|
|
54
|
+
print(json.dumps(store.stats(), indent=2))
|
|
55
|
+
elif args.command == "consolidate":
|
|
56
|
+
print(json.dumps(run_consolidation(store, args.hours), indent=2))
|
|
57
|
+
elif args.command == "compact":
|
|
58
|
+
print(json.dumps(store.rebuild_index(reason="manual"), indent=2))
|
|
59
|
+
elif args.command == "check":
|
|
60
|
+
print(json.dumps(store.check(), indent=2))
|
|
61
|
+
elif args.command == "purge":
|
|
62
|
+
print(json.dumps(store.purge(do_vacuum=args.vacuum), indent=2))
|
|
63
|
+
elif args.command == "export":
|
|
64
|
+
from .portability import export_jsonl
|
|
65
|
+
|
|
66
|
+
print(json.dumps(export_jsonl(store, args.path), indent=2))
|
|
67
|
+
elif args.command == "import":
|
|
68
|
+
from .portability import import_jsonl
|
|
69
|
+
|
|
70
|
+
print(json.dumps(import_jsonl(store, args.path), indent=2))
|
|
71
|
+
finally:
|
|
72
|
+
store.close()
|
|
73
|
+
except Exception as exc:
|
|
74
|
+
from .config import ConfigError
|
|
75
|
+
from .db import DbError
|
|
76
|
+
from .embeddings import EmbeddingError
|
|
77
|
+
from .store import StoreError
|
|
78
|
+
from .vector_index import VectorIndexError
|
|
79
|
+
if isinstance(exc, (StoreError, DbError, EmbeddingError,
|
|
80
|
+
VectorIndexError, ConfigError)):
|
|
81
|
+
print(f"omnidb-server: error: {exc}", file=sys.stderr)
|
|
82
|
+
return 2
|
|
83
|
+
raise
|
|
84
|
+
return 0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if __name__ == "__main__":
|
|
88
|
+
sys.exit(main())
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tomllib
|
|
5
|
+
from dataclasses import MISSING, dataclass, fields
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
_TOML_NAME = "omnidb.toml"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ConfigError(RuntimeError):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _env(name: str) -> str | None:
|
|
16
|
+
value = os.environ.get(name)
|
|
17
|
+
return value if value not in (None, "") else None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _coerce(raw, template):
|
|
21
|
+
"""Coerce a TOML/env scalar to the field's declared type."""
|
|
22
|
+
if template is None or isinstance(template, str):
|
|
23
|
+
return raw if isinstance(raw, str) else str(raw)
|
|
24
|
+
if isinstance(template, bool):
|
|
25
|
+
return str(raw).strip().lower() in ("1", "true", "yes", "on")
|
|
26
|
+
if isinstance(template, int):
|
|
27
|
+
return int(float(raw))
|
|
28
|
+
if isinstance(template, float):
|
|
29
|
+
return float(raw)
|
|
30
|
+
return raw
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _default_of(field):
|
|
34
|
+
return None if field.default is MISSING else field.default
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _read_toml(path: Path) -> dict:
|
|
38
|
+
with path.open("rb") as handle:
|
|
39
|
+
return tomllib.load(handle)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Config:
|
|
44
|
+
home: Path
|
|
45
|
+
embedder: str = "auto" # auto | hashing | fastembed
|
|
46
|
+
vector_index: str = "auto" # auto | hnsw | brute
|
|
47
|
+
half_life_hours: float = 168.0
|
|
48
|
+
recency_weight: float = 0.15
|
|
49
|
+
freq_weight: float = 0.05
|
|
50
|
+
rrf_k: int = 60
|
|
51
|
+
k_fetch: int = 50
|
|
52
|
+
max_search_rows: int = 500
|
|
53
|
+
persist_every_ops: int = 64
|
|
54
|
+
persist_every_s: float = 0.25
|
|
55
|
+
dead_compact_ratio: float = 0.2
|
|
56
|
+
|
|
57
|
+
llm_base_url: str | None = None
|
|
58
|
+
llm_model: str | None = None
|
|
59
|
+
llm_api_key: str | None = None
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def load(home: str | Path | None = None) -> "Config":
|
|
63
|
+
"""Precedence: CLI arg > environment > omnidb.toml > built-in defaults.
|
|
64
|
+
|
|
65
|
+
omnidb.toml is looked up in the CWD and in the resolved home directory
|
|
66
|
+
(the home copy wins). Field names in the file mirror the dataclass.
|
|
67
|
+
"""
|
|
68
|
+
arg_home = Path(home) if home is not None else None
|
|
69
|
+
env_home_value = _env("OMNIDB_HOME")
|
|
70
|
+
env_home = Path(env_home_value) if env_home_value else None
|
|
71
|
+
resolved_home = arg_home or env_home or Path.home() / ".omnidb"
|
|
72
|
+
|
|
73
|
+
merged: dict = {}
|
|
74
|
+
cwd_toml = Path.cwd() / _TOML_NAME
|
|
75
|
+
if cwd_toml.is_file():
|
|
76
|
+
merged.update(_read_toml(cwd_toml))
|
|
77
|
+
# a bare "home" key in the CWD file can redirect the home directory
|
|
78
|
+
# when neither a CLI arg nor the environment pinned one
|
|
79
|
+
if not arg_home and not env_home and isinstance(merged.get("home"), str):
|
|
80
|
+
resolved_home = Path(merged["home"])
|
|
81
|
+
home_toml = resolved_home / _TOML_NAME
|
|
82
|
+
if home_toml != cwd_toml and home_toml.is_file():
|
|
83
|
+
merged.update(_read_toml(home_toml))
|
|
84
|
+
|
|
85
|
+
known = {f.name for f in fields(Config)}
|
|
86
|
+
unknown = set(merged) - known
|
|
87
|
+
if unknown:
|
|
88
|
+
raise ConfigError(f"unknown keys in {_TOML_NAME}: {sorted(unknown)}")
|
|
89
|
+
|
|
90
|
+
values: dict = {}
|
|
91
|
+
for field in fields(Config):
|
|
92
|
+
if field.name == "home":
|
|
93
|
+
continue
|
|
94
|
+
default = _default_of(field)
|
|
95
|
+
if field.name in merged:
|
|
96
|
+
values[field.name] = _coerce(merged[field.name], default)
|
|
97
|
+
env_value = _env("OMNIDB_" + field.name.upper())
|
|
98
|
+
if env_value is not None:
|
|
99
|
+
values[field.name] = _coerce(env_value, default)
|
|
100
|
+
|
|
101
|
+
values["home"] = arg_home or env_home or resolved_home
|
|
102
|
+
return Config(**values)
|
|
103
|
+
|
|
104
|
+
# backward-compatible alias used across tests/CLI
|
|
105
|
+
from_env = load
|