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.
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