apiary-memory 0.2.0__py3-none-any.whl
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.
- apiary_memory-0.2.0.dist-info/METADATA +333 -0
- apiary_memory-0.2.0.dist-info/RECORD +11 -0
- apiary_memory-0.2.0.dist-info/WHEEL +5 -0
- apiary_memory-0.2.0.dist-info/entry_points.txt +5 -0
- apiary_memory-0.2.0.dist-info/licenses/LICENSE +21 -0
- apiary_memory-0.2.0.dist-info/top_level.txt +1 -0
- hive_mcp/__init__.py +27 -0
- hive_mcp/cli.py +261 -0
- hive_mcp/stdio.py +799 -0
- hive_mcp/store.py +872 -0
- hive_mcp/tools.json +211 -0
hive_mcp/store.py
ADDED
|
@@ -0,0 +1,872 @@
|
|
|
1
|
+
"""hive_mcp.store — a small, self-contained memory store.
|
|
2
|
+
|
|
3
|
+
Why this file exists
|
|
4
|
+
--------------------
|
|
5
|
+
Until 0.1.6 this package was a pure bridge: it relayed MCP traffic to the Hive
|
|
6
|
+
app and shipped no engine of its own. That was honest, and it was also the
|
|
7
|
+
reason an MCP directory rejected it — a server that answers ``tools/list`` but
|
|
8
|
+
cannot answer a single ``tools/call`` on a machine without the app is a shell.
|
|
9
|
+
|
|
10
|
+
This module is the smallest thing that is not a shell: a SQLite file on the
|
|
11
|
+
user's own disk that holds what ``propose`` submits, releases it when the human
|
|
12
|
+
confirms, and ranks it when ``recall`` asks.
|
|
13
|
+
|
|
14
|
+
What it is not
|
|
15
|
+
--------------
|
|
16
|
+
It is not the Hive app. The app adds encrypted vaults (Argon2id-wrapped key,
|
|
17
|
+
AES-256 at rest), daily evolution, multi-agent identity and a UI. None of that
|
|
18
|
+
is here, and this module does not pretend otherwise: the store is plain SQLite,
|
|
19
|
+
every response it produces is tagged ``"mode": "local"`` so a caller can tell
|
|
20
|
+
which engine answered, and ``stats()`` reports ``"encrypted": False`` outright.
|
|
21
|
+
|
|
22
|
+
Propose-only writes
|
|
23
|
+
-------------------
|
|
24
|
+
There is no ``approve`` in the MCP tool surface, here or upstream. An agent can
|
|
25
|
+
only submit; confirmation is a human action and lives in ``hive_mcp.cli``, run
|
|
26
|
+
by the person who owns the machine. Removing that gate would make the tool
|
|
27
|
+
cheerful and the promise worthless.
|
|
28
|
+
|
|
29
|
+
No dependencies
|
|
30
|
+
---------------
|
|
31
|
+
Standard library only. No segmenter, no embedder, no vector index. Chinese and
|
|
32
|
+
English are indexed by overlapping character bigrams plus Latin words, which is
|
|
33
|
+
coarse but needs nothing installed — see ``tokenize``.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
import json
|
|
37
|
+
import math
|
|
38
|
+
import os
|
|
39
|
+
import random
|
|
40
|
+
import re
|
|
41
|
+
import sqlite3
|
|
42
|
+
import time
|
|
43
|
+
from datetime import datetime
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
|
|
46
|
+
SCHEMA_VERSION = 1
|
|
47
|
+
|
|
48
|
+
PENDING = "pending"
|
|
49
|
+
CONFIRMED = "confirmed"
|
|
50
|
+
DISCARDED = "discarded"
|
|
51
|
+
|
|
52
|
+
DEFAULT_VAULT = "current"
|
|
53
|
+
|
|
54
|
+
# The defaults the MCP surface advertises, and therefore the defaults this engine
|
|
55
|
+
# applies. ``tools.json`` mirrors the app, and the app uses exactly these numbers
|
|
56
|
+
# (mcp_server.py: ``limit = 10`` for recall, ``args.get('compact', True)``,
|
|
57
|
+
# ``limit = 8`` per vault for get_context, ``limit = 20`` for board, and
|
|
58
|
+
# ``CARD_TTL_DEFAULT = 24 * 3600`` clamped to 60 s .. 30 days for say). Naming
|
|
59
|
+
# each promise once is what stops the two from drifting again: 0.1.8 advertised
|
|
60
|
+
# ``recall.limit`` 10 and answered 8, advertised ``compact`` true and answered
|
|
61
|
+
# false, and advertised a 24-hour card and then stored one that never expired.
|
|
62
|
+
# A literal repeated in a signature and in a call site is a promise with two
|
|
63
|
+
# sources of truth; ``tests/test_bridge.py`` now checks both the behaviour below
|
|
64
|
+
# and the numbers advertised in ``tools.json`` against these names.
|
|
65
|
+
DEFAULT_RECALL_LIMIT = 10
|
|
66
|
+
DEFAULT_RECALL_COMPACT = True
|
|
67
|
+
DEFAULT_CONTEXT_LIMIT = 8
|
|
68
|
+
DEFAULT_BOARD_LIMIT = 20
|
|
69
|
+
DEFAULT_PENDING_LIMIT = 20
|
|
70
|
+
DEFAULT_SAY_TTL = 24 * 3600
|
|
71
|
+
SAY_TTL_MIN = 60
|
|
72
|
+
SAY_TTL_MAX = 30 * 24 * 3600
|
|
73
|
+
|
|
74
|
+
# Soft-deleted rows stay on disk so `restore` can undo `forget`. Only
|
|
75
|
+
# `discard_pending` physically removes a row, because that entry was never part
|
|
76
|
+
# of the user's memory in the first place — the tool description promises
|
|
77
|
+
# "permanently drops", so it must actually drop it.
|
|
78
|
+
_SCHEMA = """
|
|
79
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
80
|
+
key TEXT PRIMARY KEY,
|
|
81
|
+
value TEXT NOT NULL
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
CREATE TABLE IF NOT EXISTS memories (
|
|
85
|
+
id TEXT PRIMARY KEY,
|
|
86
|
+
state TEXT NOT NULL,
|
|
87
|
+
title TEXT NOT NULL DEFAULT '',
|
|
88
|
+
content TEXT NOT NULL,
|
|
89
|
+
vault TEXT NOT NULL DEFAULT 'current',
|
|
90
|
+
tags TEXT NOT NULL DEFAULT '',
|
|
91
|
+
tokens TEXT NOT NULL DEFAULT '',
|
|
92
|
+
client_id TEXT NOT NULL DEFAULT '',
|
|
93
|
+
source TEXT NOT NULL DEFAULT 'mcp',
|
|
94
|
+
created REAL NOT NULL,
|
|
95
|
+
confirmed_at REAL
|
|
96
|
+
);
|
|
97
|
+
CREATE INDEX IF NOT EXISTS idx_mem_state ON memories(state);
|
|
98
|
+
CREATE INDEX IF NOT EXISTS idx_mem_confirmed ON memories(confirmed_at);
|
|
99
|
+
|
|
100
|
+
CREATE TABLE IF NOT EXISTS board (
|
|
101
|
+
id TEXT PRIMARY KEY,
|
|
102
|
+
summary TEXT NOT NULL,
|
|
103
|
+
to_agent TEXT NOT NULL DEFAULT '',
|
|
104
|
+
kind TEXT NOT NULL DEFAULT 'data',
|
|
105
|
+
stage TEXT NOT NULL DEFAULT '',
|
|
106
|
+
task_id TEXT NOT NULL DEFAULT '',
|
|
107
|
+
refs TEXT NOT NULL DEFAULT '',
|
|
108
|
+
actor TEXT NOT NULL DEFAULT '',
|
|
109
|
+
created REAL NOT NULL,
|
|
110
|
+
expires REAL
|
|
111
|
+
);
|
|
112
|
+
CREATE INDEX IF NOT EXISTS idx_board_created ON board(created);
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
# Rows scanned by one recall. A personal memory file is measured in thousands,
|
|
116
|
+
# never in millions; this cap keeps a pathological database from turning a
|
|
117
|
+
# search into a multi-second stall, and `stats()` reports when it was hit.
|
|
118
|
+
SCAN_CAP = 20000
|
|
119
|
+
|
|
120
|
+
# ids get read aloud, typed into a terminal and pasted into chat, so the
|
|
121
|
+
# alphabet drops 0/o, 1/l/i and other pairs that survive OCR but not humans.
|
|
122
|
+
_ID_ALPHABET = "23456789abcdefghjkmnpqrstuvwxyz"
|
|
123
|
+
|
|
124
|
+
_CJK = re.compile(u"[\u3400-\u4dbf\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]")
|
|
125
|
+
_LATIN = re.compile(r"[a-z0-9]+")
|
|
126
|
+
|
|
127
|
+
# Words that would match almost anything. Dropping them at index time and at
|
|
128
|
+
# query time keeps a one-character Chinese query from returning the whole file.
|
|
129
|
+
_STOP = {
|
|
130
|
+
"the", "a", "an", "of", "to", "in", "is", "it", "and", "or", "for", "on",
|
|
131
|
+
"with", "this", "that", "be", "was", "were", "as", "at", "by", "from",
|
|
132
|
+
u"的", u"了", u"是", u"和", u"在", u"我", u"有", u"就", u"不", u"人", u"都",
|
|
133
|
+
u"一", u"上", u"也", u"很", u"到", u"说", u"要", u"去", u"你", u"会", u"着",
|
|
134
|
+
u"个", u"这", u"那", u"吗", u"呢", u"吧", u"啊",
|
|
135
|
+
# Question words. They carry no topic, and because Chinese is indexed by
|
|
136
|
+
# bigrams a query like "什么系统" would otherwise match every sentence
|
|
137
|
+
# containing "什么" ("干什么", "为什么") as strongly as the one that
|
|
138
|
+
# actually mentions a system.
|
|
139
|
+
u"什么", u"怎么", u"如何", u"为什么", u"哪里", u"哪儿", u"哪些", u"哪个", u"多少",
|
|
140
|
+
u"这个", u"那个", u"这些", u"那些", u"一下", u"可以",
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class StoreError(Exception):
|
|
145
|
+
"""A caller mistake we can explain, as opposed to a broken database."""
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# --------------------------------------------------------------------------- #
|
|
149
|
+
# locating and opening the database
|
|
150
|
+
# --------------------------------------------------------------------------- #
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def default_db_path():
|
|
154
|
+
"""Where the memory file lives when the caller does not say.
|
|
155
|
+
|
|
156
|
+
``HIVE_DB`` wins so a container can point at a mounted volume. Otherwise the
|
|
157
|
+
file sits under the user's home, which is the whole point: the data belongs
|
|
158
|
+
to the person running the server, on the machine they are sitting at.
|
|
159
|
+
"""
|
|
160
|
+
override = (os.environ.get("HIVE_DB") or "").strip()
|
|
161
|
+
if override:
|
|
162
|
+
return Path(override).expanduser()
|
|
163
|
+
home = Path.home()
|
|
164
|
+
try:
|
|
165
|
+
exists = home.exists()
|
|
166
|
+
except Exception:
|
|
167
|
+
exists = False
|
|
168
|
+
if exists and str(home) not in ("", "."):
|
|
169
|
+
return home / ".memory-hive" / "hive.db"
|
|
170
|
+
return Path.cwd() / "hive.db"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def connect(path=None):
|
|
174
|
+
"""Open (and initialise) the store. Creates the parent directory if needed."""
|
|
175
|
+
resolved = Path(path).expanduser() if path else default_db_path()
|
|
176
|
+
try:
|
|
177
|
+
resolved.parent.mkdir(parents=True, exist_ok=True)
|
|
178
|
+
except OSError as exc:
|
|
179
|
+
raise StoreError(
|
|
180
|
+
"cannot create the directory for the memory database at %s (%s). "
|
|
181
|
+
"Set HIVE_DB to a writable path." % (resolved, exc)
|
|
182
|
+
)
|
|
183
|
+
try:
|
|
184
|
+
conn = sqlite3.connect(str(resolved), timeout=5.0)
|
|
185
|
+
except sqlite3.Error as exc:
|
|
186
|
+
raise StoreError("cannot open the memory database at %s (%s)" % (resolved, exc))
|
|
187
|
+
conn.row_factory = sqlite3.Row
|
|
188
|
+
# WAL matters here, not as a tuning knob: the stdio server holds a long-lived
|
|
189
|
+
# connection while the human runs `memory-hive-mcp-cli approve` in another
|
|
190
|
+
# process. Under the default rollback journal a write collides with a write,
|
|
191
|
+
# the CLI raises "database is locked", and the confirmation silently does not
|
|
192
|
+
# happen - the worst possible failure for a store whose entire promise is
|
|
193
|
+
# that confirmation is deliberate.
|
|
194
|
+
try:
|
|
195
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
196
|
+
except sqlite3.DatabaseError:
|
|
197
|
+
pass # a filesystem that refuses WAL (some network mounts) still works
|
|
198
|
+
conn.execute("PRAGMA synchronous=NORMAL")
|
|
199
|
+
_initialise(conn)
|
|
200
|
+
return conn
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _initialise(conn):
|
|
204
|
+
conn.executescript(_SCHEMA)
|
|
205
|
+
row = conn.execute("SELECT value FROM meta WHERE key='schema_version'").fetchone()
|
|
206
|
+
if row is None:
|
|
207
|
+
conn.execute(
|
|
208
|
+
"INSERT INTO meta(key, value) VALUES('schema_version', ?)",
|
|
209
|
+
(str(SCHEMA_VERSION),),
|
|
210
|
+
)
|
|
211
|
+
conn.commit()
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# --------------------------------------------------------------------------- #
|
|
215
|
+
# search: bigrams for Chinese, words for Latin
|
|
216
|
+
# --------------------------------------------------------------------------- #
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _flush_cjk(buf, out):
|
|
220
|
+
"""Append single characters and overlapping bigrams for one CJK run.
|
|
221
|
+
|
|
222
|
+
Single characters alone are too coarse (a one-character query would match
|
|
223
|
+
everything); bigrams alone lose one-character queries. Keeping both and
|
|
224
|
+
weighting bigrams higher covers queries of any length without shipping a
|
|
225
|
+
segmenter - jieba would be a 20 MB dependency in a package that promises
|
|
226
|
+
none.
|
|
227
|
+
"""
|
|
228
|
+
if not buf:
|
|
229
|
+
return
|
|
230
|
+
for ch in buf:
|
|
231
|
+
if ch not in _STOP:
|
|
232
|
+
out.append(ch)
|
|
233
|
+
for i in range(len(buf) - 1):
|
|
234
|
+
pair = buf[i] + buf[i + 1]
|
|
235
|
+
if pair not in _STOP:
|
|
236
|
+
out.append(pair)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def tokenize(text):
|
|
240
|
+
"""Turn mixed Chinese/Latin text into a de-duplicated list of search tokens."""
|
|
241
|
+
if not text:
|
|
242
|
+
return []
|
|
243
|
+
low = text.lower()
|
|
244
|
+
raw = []
|
|
245
|
+
for word in _LATIN.findall(low):
|
|
246
|
+
if len(word) >= 2 and word not in _STOP:
|
|
247
|
+
raw.append(word)
|
|
248
|
+
buf = []
|
|
249
|
+
for ch in low:
|
|
250
|
+
if _CJK.match(ch):
|
|
251
|
+
buf.append(ch)
|
|
252
|
+
else:
|
|
253
|
+
_flush_cjk(buf, raw)
|
|
254
|
+
buf = []
|
|
255
|
+
_flush_cjk(buf, raw)
|
|
256
|
+
|
|
257
|
+
seen = set()
|
|
258
|
+
out = []
|
|
259
|
+
for tok in raw:
|
|
260
|
+
if tok not in seen:
|
|
261
|
+
seen.add(tok)
|
|
262
|
+
out.append(tok)
|
|
263
|
+
return out
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _weight(token):
|
|
267
|
+
"""A two-character (or longer) token is evidence; a single character is a hint."""
|
|
268
|
+
return 2.0 if len(token) >= 2 else 1.0
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _score(query_tokens, doc_tokens, content_lower, query_lower, age_days):
|
|
272
|
+
"""Relevance in the 0..~1.5 range.
|
|
273
|
+
|
|
274
|
+
This is a ranking score, not a probability, and it is not comparable across
|
|
275
|
+
queries — the README says so rather than letting a caller read 0.8 as
|
|
276
|
+
"80% sure".
|
|
277
|
+
"""
|
|
278
|
+
if not query_tokens:
|
|
279
|
+
if query_lower and query_lower in content_lower:
|
|
280
|
+
return 1.0
|
|
281
|
+
return 0.0
|
|
282
|
+
if not doc_tokens:
|
|
283
|
+
# A row written before the index existed, or by another tool.
|
|
284
|
+
return 1.0 if query_lower and query_lower in content_lower else 0.0
|
|
285
|
+
total = sum(_weight(t) for t in query_tokens) or 1.0
|
|
286
|
+
hit = sum(_weight(t) for t in query_tokens if t in doc_tokens)
|
|
287
|
+
if hit <= 0:
|
|
288
|
+
return 0.0
|
|
289
|
+
score = hit / total
|
|
290
|
+
if query_lower and len(query_lower) >= 2 and query_lower in content_lower:
|
|
291
|
+
score += 0.5 # the whole query appears verbatim: strong evidence
|
|
292
|
+
# Gentle recency. Recency should break ties, not overturn relevance, which
|
|
293
|
+
# is why this is a 10% multiplier and not a dominant term.
|
|
294
|
+
score *= 1.0 + 0.10 * math.exp(-max(0.0, age_days) / 180.0)
|
|
295
|
+
return round(score, 4)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# --------------------------------------------------------------------------- #
|
|
299
|
+
# helpers
|
|
300
|
+
# --------------------------------------------------------------------------- #
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _new_id(prefix):
|
|
304
|
+
return "%s_%d_%s" % (
|
|
305
|
+
prefix,
|
|
306
|
+
int(time.time()),
|
|
307
|
+
"".join(random.choice(_ID_ALPHABET) for _ in range(4)),
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _iso(ts):
|
|
312
|
+
if not ts:
|
|
313
|
+
return None
|
|
314
|
+
try:
|
|
315
|
+
return datetime.fromtimestamp(float(ts)).astimezone().isoformat(timespec="seconds")
|
|
316
|
+
except (ValueError, OSError):
|
|
317
|
+
return None
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _clean(text, limit):
|
|
321
|
+
return re.sub(r"\s+", " ", (text or "")).strip()[:limit]
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _ttl_seconds(ttl):
|
|
325
|
+
"""How long a board card lives, mirroring the app rather than inventing a rule.
|
|
326
|
+
|
|
327
|
+
The app treats an absent or non-numeric ``ttl`` as 24 hours and clamps every
|
|
328
|
+
value to 60 s .. 30 days, so that no card is immortal and none is invisible
|
|
329
|
+
the moment it is posted. This engine used to store ``expires = NULL`` for
|
|
330
|
+
anything falsy while its own ``tools.json`` said "Defaults to 24 hours" - the
|
|
331
|
+
card outlived the promise silently, which is the same defect as a default
|
|
332
|
+
that answers 8 where 10 was advertised. ``0`` becomes 60 s here exactly as it
|
|
333
|
+
does in the app; "forever" is not an option either engine offers.
|
|
334
|
+
"""
|
|
335
|
+
try:
|
|
336
|
+
secs = int(ttl) if ttl is not None else DEFAULT_SAY_TTL
|
|
337
|
+
except (TypeError, ValueError):
|
|
338
|
+
secs = DEFAULT_SAY_TTL
|
|
339
|
+
return max(SAY_TTL_MIN, min(SAY_TTL_MAX, secs))
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
class Store(object):
|
|
343
|
+
"""The memory file. One instance per process is enough."""
|
|
344
|
+
|
|
345
|
+
def __init__(self, path=None):
|
|
346
|
+
self.path = Path(path).expanduser() if path else default_db_path()
|
|
347
|
+
self.conn = connect(self.path)
|
|
348
|
+
|
|
349
|
+
def close(self):
|
|
350
|
+
try:
|
|
351
|
+
self.conn.close()
|
|
352
|
+
except sqlite3.Error:
|
|
353
|
+
pass
|
|
354
|
+
|
|
355
|
+
# -- writes ------------------------------------------------------------ #
|
|
356
|
+
|
|
357
|
+
def propose(self, content, title="", vault=DEFAULT_VAULT, tags=None, client_id=""):
|
|
358
|
+
"""Queue a memory for the human. Never touches confirmed memory.
|
|
359
|
+
|
|
360
|
+
Re-submitting the same text is a no-op that returns the existing id
|
|
361
|
+
instead of piling up duplicates: an agent that retries on a timeout
|
|
362
|
+
should not fill the human's queue with copies of one sentence.
|
|
363
|
+
"""
|
|
364
|
+
content = (content or "").strip()
|
|
365
|
+
if not content:
|
|
366
|
+
raise StoreError("propose requires non-empty `content`")
|
|
367
|
+
if len(content) > 20000:
|
|
368
|
+
raise StoreError("`content` is longer than 20000 characters")
|
|
369
|
+
vault = _clean(vault, 40) or DEFAULT_VAULT
|
|
370
|
+
|
|
371
|
+
existing = self.conn.execute(
|
|
372
|
+
"SELECT id, state FROM memories WHERE content = ? AND state IN (?, ?) "
|
|
373
|
+
"ORDER BY created DESC LIMIT 1",
|
|
374
|
+
(content, PENDING, CONFIRMED),
|
|
375
|
+
).fetchone()
|
|
376
|
+
if existing is not None:
|
|
377
|
+
return {
|
|
378
|
+
"ok": True,
|
|
379
|
+
"id": existing["id"],
|
|
380
|
+
"state": existing["state"],
|
|
381
|
+
"duplicate": True,
|
|
382
|
+
"message": (
|
|
383
|
+
"This text is already stored as %s; nothing new was queued."
|
|
384
|
+
% existing["state"]
|
|
385
|
+
),
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
mem_id = _new_id("mem")
|
|
389
|
+
now = time.time()
|
|
390
|
+
clean_title = _clean(title, 120) or _clean(content, 60)
|
|
391
|
+
tag_list = [str(t).strip() for t in (tags or []) if str(t).strip()][:20]
|
|
392
|
+
self.conn.execute(
|
|
393
|
+
"INSERT INTO memories(id, state, title, content, vault, tags, tokens,"
|
|
394
|
+
" client_id, source, created)"
|
|
395
|
+
" VALUES(?,?,?,?,?,?,?,?,?,?)",
|
|
396
|
+
(
|
|
397
|
+
mem_id,
|
|
398
|
+
PENDING,
|
|
399
|
+
clean_title,
|
|
400
|
+
content,
|
|
401
|
+
vault,
|
|
402
|
+
" ".join(tag_list),
|
|
403
|
+
" ".join(tokenize(clean_title + " " + content + " " + " ".join(tag_list))),
|
|
404
|
+
_clean(client_id, 80),
|
|
405
|
+
"mcp",
|
|
406
|
+
now,
|
|
407
|
+
),
|
|
408
|
+
)
|
|
409
|
+
self.conn.commit()
|
|
410
|
+
return {
|
|
411
|
+
"ok": True,
|
|
412
|
+
"id": mem_id,
|
|
413
|
+
"state": PENDING,
|
|
414
|
+
"vault": vault,
|
|
415
|
+
"duplicate": False,
|
|
416
|
+
"message": (
|
|
417
|
+
"Queued for the human to confirm. It is not searchable by recall "
|
|
418
|
+
"until they approve it with `memory-hive-mcp-cli approve %s`." % mem_id
|
|
419
|
+
),
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
def approve(self, ids=None, all_pending=False):
|
|
423
|
+
"""Move pending entries into confirmed memory. Human-only, via the CLI."""
|
|
424
|
+
now = time.time()
|
|
425
|
+
approved = []
|
|
426
|
+
if all_pending:
|
|
427
|
+
rows = self.conn.execute(
|
|
428
|
+
"SELECT id FROM memories WHERE state = ?", (PENDING,)
|
|
429
|
+
).fetchall()
|
|
430
|
+
ids = [r["id"] for r in rows]
|
|
431
|
+
if not ids:
|
|
432
|
+
raise StoreError("approve needs one or more ids, or --all")
|
|
433
|
+
for mem_id in ids:
|
|
434
|
+
row = self.conn.execute(
|
|
435
|
+
"SELECT id, state FROM memories WHERE id = ?", (mem_id,)
|
|
436
|
+
).fetchone()
|
|
437
|
+
if row is None:
|
|
438
|
+
raise StoreError("no memory with id %s" % mem_id)
|
|
439
|
+
if row["state"] == CONFIRMED:
|
|
440
|
+
continue
|
|
441
|
+
if row["state"] != PENDING:
|
|
442
|
+
raise StoreError(
|
|
443
|
+
"%s is %s, not pending; use restore first" % (mem_id, row["state"])
|
|
444
|
+
)
|
|
445
|
+
self.conn.execute(
|
|
446
|
+
"UPDATE memories SET state = ?, confirmed_at = ? WHERE id = ?",
|
|
447
|
+
(CONFIRMED, now, mem_id),
|
|
448
|
+
)
|
|
449
|
+
approved.append(mem_id)
|
|
450
|
+
self.conn.commit()
|
|
451
|
+
return approved
|
|
452
|
+
|
|
453
|
+
def forget(self, mem_id):
|
|
454
|
+
"""Soft delete. Reversible with restore."""
|
|
455
|
+
row = self._require(mem_id)
|
|
456
|
+
if row["state"] == DISCARDED:
|
|
457
|
+
return {"ok": True, "id": mem_id, "state": DISCARDED, "already": True}
|
|
458
|
+
self.conn.execute(
|
|
459
|
+
"UPDATE memories SET state = ? WHERE id = ?", (DISCARDED, mem_id)
|
|
460
|
+
)
|
|
461
|
+
self.conn.commit()
|
|
462
|
+
return {"ok": True, "id": mem_id, "state": DISCARDED, "reversible": True}
|
|
463
|
+
|
|
464
|
+
def restore(self, mem_id):
|
|
465
|
+
"""Bring a forgotten entry back as confirmed memory."""
|
|
466
|
+
row = self._require(mem_id)
|
|
467
|
+
if row["state"] != DISCARDED:
|
|
468
|
+
raise StoreError("%s is %s, not forgotten" % (mem_id, row["state"]))
|
|
469
|
+
self.conn.execute(
|
|
470
|
+
"UPDATE memories SET state = ?, confirmed_at = ? WHERE id = ?",
|
|
471
|
+
(CONFIRMED, time.time(), mem_id),
|
|
472
|
+
)
|
|
473
|
+
self.conn.commit()
|
|
474
|
+
return {"ok": True, "id": mem_id, "state": CONFIRMED}
|
|
475
|
+
|
|
476
|
+
def drop_pending(self, mem_id):
|
|
477
|
+
"""Physically remove an entry that was never confirmed."""
|
|
478
|
+
row = self._require(mem_id)
|
|
479
|
+
if row["state"] != PENDING:
|
|
480
|
+
raise StoreError(
|
|
481
|
+
"%s is %s; only pending entries can be dropped. Use forget for "
|
|
482
|
+
"confirmed memory." % (mem_id, row["state"])
|
|
483
|
+
)
|
|
484
|
+
self.conn.execute("DELETE FROM memories WHERE id = ?", (mem_id,))
|
|
485
|
+
self.conn.commit()
|
|
486
|
+
return {"ok": True, "id": mem_id, "state": "dropped"}
|
|
487
|
+
|
|
488
|
+
def say(self, summary, to="", kind="data", stage="", task_id="", refs=None,
|
|
489
|
+
ttl=DEFAULT_SAY_TTL, actor=""):
|
|
490
|
+
"""Post to the shared board. The board is coordination, not memory."""
|
|
491
|
+
summary = (summary or "").strip()
|
|
492
|
+
if not summary:
|
|
493
|
+
raise StoreError("say requires non-empty `summary`")
|
|
494
|
+
if kind not in ("data", "handoff", "result"):
|
|
495
|
+
raise StoreError("`kind` must be one of: data, handoff, result")
|
|
496
|
+
if stage and stage not in ("start", "handoff", "done", "blocked"):
|
|
497
|
+
raise StoreError("`stage` must be: start, handoff, done, blocked")
|
|
498
|
+
now = time.time()
|
|
499
|
+
msg_id = _new_id("msg")
|
|
500
|
+
ttl = _ttl_seconds(ttl)
|
|
501
|
+
expires = now + ttl
|
|
502
|
+
self.conn.execute(
|
|
503
|
+
"INSERT INTO board(id, summary, to_agent, kind, stage, task_id, refs,"
|
|
504
|
+
" actor, created, expires) VALUES(?,?,?,?,?,?,?,?,?,?)",
|
|
505
|
+
(
|
|
506
|
+
msg_id,
|
|
507
|
+
_clean(summary, 8000),
|
|
508
|
+
_clean(to, 80),
|
|
509
|
+
kind,
|
|
510
|
+
_clean(stage, 20),
|
|
511
|
+
_clean(task_id, 80),
|
|
512
|
+
json.dumps(refs or [], ensure_ascii=False),
|
|
513
|
+
_clean(actor, 80) or "local",
|
|
514
|
+
now,
|
|
515
|
+
expires,
|
|
516
|
+
),
|
|
517
|
+
)
|
|
518
|
+
self.conn.commit()
|
|
519
|
+
# `ttl` and `expires` are echoed because the effective lifetime is the
|
|
520
|
+
# only way a caller can see that omitting ttl capped the card at a day.
|
|
521
|
+
return {
|
|
522
|
+
"ok": True,
|
|
523
|
+
"id": msg_id,
|
|
524
|
+
"created": _iso(now),
|
|
525
|
+
"ttl": ttl,
|
|
526
|
+
"expires": _iso(expires),
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
# -- reads ------------------------------------------------------------- #
|
|
530
|
+
|
|
531
|
+
def recall(self, query, limit=DEFAULT_RECALL_LIMIT, vaults=None,
|
|
532
|
+
compact=DEFAULT_RECALL_COMPACT):
|
|
533
|
+
"""Rank confirmed memory against a natural-language query."""
|
|
534
|
+
try:
|
|
535
|
+
limit = int(limit)
|
|
536
|
+
except (TypeError, ValueError):
|
|
537
|
+
limit = DEFAULT_RECALL_LIMIT
|
|
538
|
+
limit = max(1, min(limit, 50))
|
|
539
|
+
query = (query or "").strip()
|
|
540
|
+
query_tokens = tokenize(query)
|
|
541
|
+
query_lower = query.lower()
|
|
542
|
+
|
|
543
|
+
sql = (
|
|
544
|
+
"SELECT id, title, content, vault, tags, tokens, created, confirmed_at"
|
|
545
|
+
" FROM memories WHERE state = ?"
|
|
546
|
+
)
|
|
547
|
+
params = [CONFIRMED]
|
|
548
|
+
if vaults:
|
|
549
|
+
if not isinstance(vaults, (list, tuple)):
|
|
550
|
+
raise StoreError("`vaults` must be an array of vault names")
|
|
551
|
+
wanted = [_clean(v, 40) for v in vaults if _clean(v, 40)]
|
|
552
|
+
if wanted:
|
|
553
|
+
sql += " AND vault IN (%s)" % ",".join("?" * len(wanted))
|
|
554
|
+
params.extend(wanted)
|
|
555
|
+
sql += " ORDER BY COALESCE(confirmed_at, created) DESC LIMIT %d" % SCAN_CAP
|
|
556
|
+
|
|
557
|
+
rows = self.conn.execute(sql, params).fetchall()
|
|
558
|
+
now = time.time()
|
|
559
|
+
scored = []
|
|
560
|
+
for row in rows:
|
|
561
|
+
doc_tokens = set((row["tokens"] or "").split())
|
|
562
|
+
age_days = max(0.0, (now - (row["confirmed_at"] or row["created"])) / 86400.0)
|
|
563
|
+
score = _score(
|
|
564
|
+
query_tokens,
|
|
565
|
+
doc_tokens,
|
|
566
|
+
(row["content"] or "").lower(),
|
|
567
|
+
query_lower,
|
|
568
|
+
age_days,
|
|
569
|
+
)
|
|
570
|
+
if query_tokens and score <= 0:
|
|
571
|
+
continue
|
|
572
|
+
scored.append((score, row))
|
|
573
|
+
|
|
574
|
+
scored.sort(key=lambda pair: (pair[0], pair[1]["confirmed_at"] or 0.0), reverse=True)
|
|
575
|
+
picked = scored[:limit]
|
|
576
|
+
|
|
577
|
+
results = []
|
|
578
|
+
for score, row in picked:
|
|
579
|
+
entry = {
|
|
580
|
+
"id": row["id"],
|
|
581
|
+
"title": row["title"],
|
|
582
|
+
"vault": row["vault"],
|
|
583
|
+
"tags": (row["tags"] or "").split(),
|
|
584
|
+
"timestamp": _iso(row["confirmed_at"] or row["created"]),
|
|
585
|
+
"score": score,
|
|
586
|
+
}
|
|
587
|
+
if compact:
|
|
588
|
+
entry["content"] = _clean(row["content"], 160)
|
|
589
|
+
else:
|
|
590
|
+
entry["content"] = row["content"]
|
|
591
|
+
results.append(entry)
|
|
592
|
+
|
|
593
|
+
response = {
|
|
594
|
+
"ok": True,
|
|
595
|
+
"query": query,
|
|
596
|
+
"results": results,
|
|
597
|
+
"total": len(results),
|
|
598
|
+
"mode": "local",
|
|
599
|
+
}
|
|
600
|
+
if query_tokens and not results:
|
|
601
|
+
pending = self.conn.execute(
|
|
602
|
+
"SELECT COUNT(*) AS n FROM memories WHERE state = ?", (PENDING,)
|
|
603
|
+
).fetchone()["n"]
|
|
604
|
+
response["note"] = (
|
|
605
|
+
"no confirmed memory matched this query"
|
|
606
|
+
+ (
|
|
607
|
+
"; %d entr%s awaiting the human's approval"
|
|
608
|
+
% (pending, "y is" if pending == 1 else "ies are")
|
|
609
|
+
if pending
|
|
610
|
+
else ""
|
|
611
|
+
)
|
|
612
|
+
)
|
|
613
|
+
return response
|
|
614
|
+
|
|
615
|
+
def get_context(self, limit=DEFAULT_CONTEXT_LIMIT):
|
|
616
|
+
"""A digest of what the human has confirmed most recently."""
|
|
617
|
+
try:
|
|
618
|
+
limit = int(limit)
|
|
619
|
+
except (TypeError, ValueError):
|
|
620
|
+
limit = DEFAULT_CONTEXT_LIMIT
|
|
621
|
+
limit = max(1, min(limit, 30))
|
|
622
|
+
rows = self.conn.execute(
|
|
623
|
+
"SELECT id, title, content, vault, tags, confirmed_at, created"
|
|
624
|
+
" FROM memories WHERE state = ?"
|
|
625
|
+
" ORDER BY COALESCE(confirmed_at, created) DESC LIMIT ?",
|
|
626
|
+
(CONFIRMED, limit),
|
|
627
|
+
).fetchall()
|
|
628
|
+
pending = self.conn.execute(
|
|
629
|
+
"SELECT COUNT(*) AS n FROM memories WHERE state = ?", (PENDING,)
|
|
630
|
+
).fetchone()["n"]
|
|
631
|
+
digest = [
|
|
632
|
+
{
|
|
633
|
+
"id": r["id"],
|
|
634
|
+
"title": r["title"],
|
|
635
|
+
"vault": r["vault"],
|
|
636
|
+
"tags": (r["tags"] or "").split(),
|
|
637
|
+
"timestamp": _iso(r["confirmed_at"] or r["created"]),
|
|
638
|
+
"excerpt": _clean(r["content"], 200),
|
|
639
|
+
}
|
|
640
|
+
for r in rows
|
|
641
|
+
]
|
|
642
|
+
response = {
|
|
643
|
+
"ok": True,
|
|
644
|
+
"digest": digest,
|
|
645
|
+
"total": len(digest),
|
|
646
|
+
"pending": pending,
|
|
647
|
+
"mode": "local",
|
|
648
|
+
}
|
|
649
|
+
if pending:
|
|
650
|
+
response["note"] = (
|
|
651
|
+
"%d entr%s awaiting the human's approval; call manage with action "
|
|
652
|
+
"list_pending to read %s."
|
|
653
|
+
% (pending, "y is" if pending == 1 else "ies are", "it" if pending == 1 else "them")
|
|
654
|
+
)
|
|
655
|
+
if not digest and not pending:
|
|
656
|
+
response["note"] = (
|
|
657
|
+
"the store is empty; nothing has been proposed or confirmed yet"
|
|
658
|
+
)
|
|
659
|
+
return response
|
|
660
|
+
|
|
661
|
+
def get_by_id(self, mem_id):
|
|
662
|
+
row = self._require(mem_id)
|
|
663
|
+
return {
|
|
664
|
+
"ok": True,
|
|
665
|
+
"id": row["id"],
|
|
666
|
+
"state": row["state"],
|
|
667
|
+
"title": row["title"],
|
|
668
|
+
"content": row["content"],
|
|
669
|
+
"vault": row["vault"],
|
|
670
|
+
"tags": (row["tags"] or "").split(),
|
|
671
|
+
"created": _iso(row["created"]),
|
|
672
|
+
"confirmed_at": _iso(row["confirmed_at"]),
|
|
673
|
+
"mode": "local",
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
def list_pending(self, limit=DEFAULT_PENDING_LIMIT):
|
|
677
|
+
try:
|
|
678
|
+
limit = int(limit)
|
|
679
|
+
except (TypeError, ValueError):
|
|
680
|
+
limit = DEFAULT_PENDING_LIMIT
|
|
681
|
+
limit = max(1, min(limit, 100))
|
|
682
|
+
rows = self.conn.execute(
|
|
683
|
+
"SELECT id, title, content, vault, created FROM memories WHERE state = ?"
|
|
684
|
+
" ORDER BY created ASC LIMIT ?",
|
|
685
|
+
(PENDING, limit),
|
|
686
|
+
).fetchall()
|
|
687
|
+
total = self.conn.execute(
|
|
688
|
+
"SELECT COUNT(*) AS n FROM memories WHERE state = ?", (PENDING,)
|
|
689
|
+
).fetchone()["n"]
|
|
690
|
+
return {
|
|
691
|
+
"ok": True,
|
|
692
|
+
"pending": [
|
|
693
|
+
{
|
|
694
|
+
"id": r["id"],
|
|
695
|
+
"title": r["title"],
|
|
696
|
+
"excerpt": _clean(r["content"], 200),
|
|
697
|
+
"vault": r["vault"],
|
|
698
|
+
"created": _iso(r["created"]),
|
|
699
|
+
}
|
|
700
|
+
for r in rows
|
|
701
|
+
],
|
|
702
|
+
"total": total,
|
|
703
|
+
"returned": len(rows),
|
|
704
|
+
"mode": "local",
|
|
705
|
+
"next": (
|
|
706
|
+
"Approve or reject from a terminal: memory-hive-mcp-cli approve <id>"
|
|
707
|
+
if total
|
|
708
|
+
else None
|
|
709
|
+
),
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
def board(self, limit=DEFAULT_BOARD_LIMIT, since="", roster=False, reader=""):
|
|
713
|
+
"""Read the board the way the app's ``board`` tool reads it.
|
|
714
|
+
|
|
715
|
+
The cursor is a card **id**, and ``since`` takes that same id back. That
|
|
716
|
+
is the app's contract (``mcp_server.py``: ``_tool_board`` returns
|
|
717
|
+
``cursor = ordered[-1]['id']``) and it is what ``tools.json`` already
|
|
718
|
+
declared: ``board.since`` has ``"type": "string"`` in the snapshot and in
|
|
719
|
+
the app's own schema. This engine was the odd one out in the most
|
|
720
|
+
damaging way available — it answered a float epoch timestamp and parsed
|
|
721
|
+
``since`` with ``float()``.
|
|
722
|
+
|
|
723
|
+
What that cost, under ``HIVE_MODE=auto``: a caller polls while the app
|
|
724
|
+
answers, keeps the card id it was given; the app stops answering; the
|
|
725
|
+
next poll reaches this store, ``float('CB2026...')`` raises, the
|
|
726
|
+
exception was swallowed into ``since = 0``, and the whole board came back
|
|
727
|
+
as if nothing had been read — with no field to say the cursor had been
|
|
728
|
+
discarded. The other direction was equally quiet: an epoch float matched
|
|
729
|
+
no card id in the app, so the app answered with its newest ``limit``.
|
|
730
|
+
Both are the silent-substitution defect ``stdio.py`` already refuses on
|
|
731
|
+
the error path, so the fix is one cursor type rather than two of them
|
|
732
|
+
plus a translation nobody wrote.
|
|
733
|
+
|
|
734
|
+
A single type removes the misread; it cannot remove the case behind it. A
|
|
735
|
+
cursor whose card is no longer on the board — expired (cards live 24
|
|
736
|
+
hours by default), trimmed, or issued by the *other* engine, whose ids
|
|
737
|
+
are a different shape (``CB…`` in the app, ``msg_…`` here) — has nothing
|
|
738
|
+
to resume from. Both engines then return their newest window; the app
|
|
739
|
+
says nothing about it, and this one sets ``since_unknown`` plus a
|
|
740
|
+
``note`` naming the reason. A poll that silently restarts is exactly the
|
|
741
|
+
failure this change removes, so the restart is stated.
|
|
742
|
+
|
|
743
|
+
The rest of the shape is the app's, field for field — ``items``,
|
|
744
|
+
``unread``, ``count``, ``reader`` — so a caller never has to branch on
|
|
745
|
+
which engine answered. ``unread`` counts every live card after the cursor,
|
|
746
|
+
``count`` counts what this answer carries, and ``unread > count``
|
|
747
|
+
therefore means cards were waiting that the page did not cover; the
|
|
748
|
+
``note`` says that in words rather than leaving it to be inferred.
|
|
749
|
+
``messages`` and ``total`` no longer exist here: they were this engine's
|
|
750
|
+
private names for two of these fields.
|
|
751
|
+
"""
|
|
752
|
+
try:
|
|
753
|
+
limit = int(limit)
|
|
754
|
+
except (TypeError, ValueError):
|
|
755
|
+
limit = DEFAULT_BOARD_LIMIT
|
|
756
|
+
limit = max(1, min(limit, 100))
|
|
757
|
+
# An omitted value, `None`, a foreign id and a legacy epoch all arrive
|
|
758
|
+
# here as text: the declared type is a string, so a number is a caller
|
|
759
|
+
# mistake this method reports rather than a second shape it invents.
|
|
760
|
+
since = "" if since is None else str(since).strip()
|
|
761
|
+
now = time.time()
|
|
762
|
+
# The whole live board, oldest first. A cursor can only be resolved
|
|
763
|
+
# against the order the app resolves it in, and `unread` counts what
|
|
764
|
+
# follows the cursor rather than what fits in a page. `SCAN_CAP` guards
|
|
765
|
+
# `memories` because that table grows without bound; a board card deletes
|
|
766
|
+
# itself by expiry, so the live set stays small by construction.
|
|
767
|
+
rows = self.conn.execute(
|
|
768
|
+
"SELECT * FROM board WHERE expires IS NULL OR expires > ?"
|
|
769
|
+
" ORDER BY created ASC",
|
|
770
|
+
(now,),
|
|
771
|
+
).fetchall()
|
|
772
|
+
start = 0
|
|
773
|
+
located = not since
|
|
774
|
+
if since:
|
|
775
|
+
for index, row in enumerate(rows):
|
|
776
|
+
if row["id"] == since:
|
|
777
|
+
start = index + 1
|
|
778
|
+
located = True
|
|
779
|
+
break
|
|
780
|
+
fresh = rows[start:]
|
|
781
|
+
# Page the newest `limit` of what followed the cursor, exactly as
|
|
782
|
+
# `_tool_board` does (``fresh[-limit:]``), oldest-first within the page.
|
|
783
|
+
# Which window is returned is part of the shape a caller sees.
|
|
784
|
+
shown = fresh[-limit:] if len(fresh) > limit else fresh
|
|
785
|
+
items = [
|
|
786
|
+
{
|
|
787
|
+
"id": r["id"],
|
|
788
|
+
"summary": r["summary"],
|
|
789
|
+
"to": r["to_agent"] or None,
|
|
790
|
+
"kind": r["kind"],
|
|
791
|
+
"stage": r["stage"] or None,
|
|
792
|
+
"task_id": r["task_id"] or None,
|
|
793
|
+
"refs": json.loads(r["refs"] or "[]"),
|
|
794
|
+
"from": r["actor"],
|
|
795
|
+
"created": _iso(r["created"]),
|
|
796
|
+
}
|
|
797
|
+
for r in shown
|
|
798
|
+
]
|
|
799
|
+
response = {
|
|
800
|
+
"ok": True,
|
|
801
|
+
"cursor": rows[-1]["id"] if rows else "",
|
|
802
|
+
"unread": len(fresh),
|
|
803
|
+
"count": len(items),
|
|
804
|
+
"items": items,
|
|
805
|
+
"reader": str(reader or ""),
|
|
806
|
+
"mode": "local",
|
|
807
|
+
}
|
|
808
|
+
if since and not located:
|
|
809
|
+
response["since_unknown"] = True
|
|
810
|
+
response["note"] = (
|
|
811
|
+
"`since` (%s) is not a card on this board, so there was nothing "
|
|
812
|
+
"to resume from: cards expire (24 hours by default), the other "
|
|
813
|
+
"engine's cards use a different id shape (CB... in the app, "
|
|
814
|
+
"msg_... here), and an epoch timestamp is not a cursor. The "
|
|
815
|
+
"newest %d live card(s) are returned instead; pass `cursor` "
|
|
816
|
+
"back as `since` to poll from here on."
|
|
817
|
+
% (_clean(since, 40), len(items))
|
|
818
|
+
)
|
|
819
|
+
elif len(fresh) > limit:
|
|
820
|
+
response["note"] = (
|
|
821
|
+
"%d card(s) were waiting but `limit` is %d, so the newest %d are "
|
|
822
|
+
"here - `unread` %d > `count` %d is how that shows. Raise `limit` "
|
|
823
|
+
"to read the rest, and note the cursor has already moved to the "
|
|
824
|
+
"newest card on the board."
|
|
825
|
+
% (len(fresh), limit, len(items), len(fresh), len(items))
|
|
826
|
+
)
|
|
827
|
+
if roster:
|
|
828
|
+
seen = self.conn.execute(
|
|
829
|
+
"SELECT actor, COUNT(*) AS n, MAX(created) AS last FROM board"
|
|
830
|
+
" WHERE expires IS NULL OR expires > ? GROUP BY actor ORDER BY last DESC",
|
|
831
|
+
(now,),
|
|
832
|
+
).fetchall()
|
|
833
|
+
# `agent_id` because that is the key the app's roster cards use for
|
|
834
|
+
# the same fact; `messages` and `last_seen` are what a store with no
|
|
835
|
+
# registry can honestly say. No seats, leases or display names are
|
|
836
|
+
# invented here.
|
|
837
|
+
response["roster"] = [
|
|
838
|
+
{"agent_id": r["actor"], "messages": r["n"], "last_seen": _iso(r["last"])}
|
|
839
|
+
for r in seen
|
|
840
|
+
]
|
|
841
|
+
return response
|
|
842
|
+
|
|
843
|
+
def stats(self):
|
|
844
|
+
counts = {}
|
|
845
|
+
for state in (CONFIRMED, PENDING, DISCARDED):
|
|
846
|
+
counts[state] = self.conn.execute(
|
|
847
|
+
"SELECT COUNT(*) AS n FROM memories WHERE state = ?", (state,)
|
|
848
|
+
).fetchone()["n"]
|
|
849
|
+
board_n = self.conn.execute("SELECT COUNT(*) AS n FROM board").fetchone()["n"]
|
|
850
|
+
return {
|
|
851
|
+
"ok": True,
|
|
852
|
+
"mode": "local",
|
|
853
|
+
"database": str(self.path),
|
|
854
|
+
"exists": self.path.exists(),
|
|
855
|
+
"encrypted": False,
|
|
856
|
+
"memories": counts,
|
|
857
|
+
"board_messages": board_n,
|
|
858
|
+
"schema_version": SCHEMA_VERSION,
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
# -- internals --------------------------------------------------------- #
|
|
862
|
+
|
|
863
|
+
def _require(self, mem_id):
|
|
864
|
+
mem_id = (mem_id or "").strip()
|
|
865
|
+
if not mem_id:
|
|
866
|
+
raise StoreError("this action needs an `id`")
|
|
867
|
+
row = self.conn.execute(
|
|
868
|
+
"SELECT * FROM memories WHERE id = ?", (mem_id,)
|
|
869
|
+
).fetchone()
|
|
870
|
+
if row is None:
|
|
871
|
+
raise StoreError("no memory with id %s" % mem_id)
|
|
872
|
+
return row
|