keepm 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.
keepm/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
keepm/cli.py ADDED
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from collections.abc import Sequence
5
+
6
+
7
+ def main(argv: Sequence[str] | None = None) -> None:
8
+ """Dispatch the management CLI without contaminating MCP stdio mode."""
9
+ arguments = list(sys.argv[1:] if argv is None else argv)
10
+ if arguments[:1] == ["setup"]:
11
+ from keepm.setup import main as setup_main
12
+
13
+ setup_main(arguments[1:])
14
+ return
15
+
16
+ from keepm.server import main as server_main
17
+
18
+ server_main(arguments)
keepm/config.py ADDED
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import tomllib
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Literal, cast
9
+
10
+
11
+ MemoryLanguage = Literal["zh", "en"]
12
+
13
+
14
+ def default_config_path() -> Path:
15
+ root = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
16
+ return root / "keepm" / "config.toml"
17
+
18
+
19
+ def default_data_root() -> Path:
20
+ root = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
21
+ return root / "keepm"
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class KeepMConfig:
26
+ vault_path: Path
27
+ memory_dir: str = "Memory"
28
+ stale_after_seconds: int = 30
29
+ language: MemoryLanguage = "en"
30
+
31
+ def __post_init__(self) -> None:
32
+ memory_path = Path(self.memory_dir)
33
+ if (
34
+ not self.memory_dir.strip()
35
+ or memory_path.is_absolute()
36
+ or ".." in memory_path.parts
37
+ ):
38
+ raise ValueError("memory_dir must be relative and remain inside the Vault")
39
+ if self.stale_after_seconds < 0:
40
+ raise ValueError("stale_after_seconds must be non-negative")
41
+ if self.language not in ("zh", "en"):
42
+ raise ValueError("language must be 'zh' or 'en'")
43
+ object.__setattr__(self, "vault_path", self.vault_path.expanduser().resolve())
44
+
45
+ @property
46
+ def memory_root(self) -> Path:
47
+ return self.vault_path / self.memory_dir
48
+
49
+ @property
50
+ def global_root(self) -> Path:
51
+ return self.memory_root / "global"
52
+
53
+ @property
54
+ def projects_root(self) -> Path:
55
+ return self.memory_root / "projects"
56
+
57
+ @property
58
+ def trash_root(self) -> Path:
59
+ return self.memory_root / ".trash"
60
+
61
+
62
+ class ConfigStore:
63
+ def __init__(self, path: Path | None = None) -> None:
64
+ self.path = path or default_config_path()
65
+
66
+ def load(self) -> KeepMConfig:
67
+ if not self.path.is_file():
68
+ raise FileNotFoundError(f"KeepM is not configured: {self.path}")
69
+ data = tomllib.loads(self.path.read_text(encoding="utf-8"))
70
+ return KeepMConfig(
71
+ vault_path=Path(data["vault_path"]),
72
+ memory_dir=str(data.get("memory_dir", "Memory")),
73
+ stale_after_seconds=int(data.get("stale_after_seconds", 30)),
74
+ language=cast(MemoryLanguage, str(data.get("language", "en"))),
75
+ )
76
+
77
+ def load_optional(self) -> KeepMConfig | None:
78
+ try:
79
+ return self.load()
80
+ except FileNotFoundError:
81
+ return None
82
+
83
+ def save(self, config: KeepMConfig) -> KeepMConfig:
84
+ config.vault_path.mkdir(parents=True, exist_ok=True)
85
+ for path in (config.global_root, config.projects_root, config.trash_root):
86
+ path.mkdir(parents=True, exist_ok=True)
87
+ self.path.parent.mkdir(parents=True, exist_ok=True)
88
+ content = (
89
+ f"vault_path = {json.dumps(str(config.vault_path))}\n"
90
+ f"memory_dir = {json.dumps(config.memory_dir)}\n"
91
+ f"stale_after_seconds = {config.stale_after_seconds}\n"
92
+ f"language = {json.dumps(config.language)}\n"
93
+ )
94
+ temporary = self.path.with_suffix(".tmp")
95
+ temporary.write_text(content, encoding="utf-8", newline="\n")
96
+ os.replace(temporary, self.path)
97
+ return config
keepm/database.py ADDED
@@ -0,0 +1,572 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime as dt
4
+ import json
5
+ import sqlite3
6
+ import threading
7
+ from contextlib import contextmanager
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Iterator
11
+
12
+ from keepm.markdown import slug_filename
13
+ from keepm.models import MemoryDocument, SearchHit
14
+
15
+ SCHEMA_VERSION = "1"
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class FileState:
20
+ id: str
21
+ path: Path
22
+ name: str
23
+ scope: str
24
+ mtime_ns: int
25
+ size: int
26
+ checksum: str
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class IndexedMemory:
31
+ id: str
32
+ path: Path
33
+ name: str
34
+ description: str
35
+ type: str
36
+ scope: str
37
+ tags: tuple[str, ...]
38
+ pinned: bool
39
+ created: str
40
+ updated: str
41
+ mtime_ns: int
42
+ size: int
43
+ checksum: str
44
+
45
+
46
+ class MemoryDatabase:
47
+ def __init__(self, path: Path) -> None:
48
+ self.path = path.expanduser().resolve()
49
+ self.path.parent.mkdir(parents=True, exist_ok=True)
50
+ self._lock = threading.RLock()
51
+ self.connection = sqlite3.connect(
52
+ self.path,
53
+ timeout=5,
54
+ isolation_level=None,
55
+ check_same_thread=False,
56
+ )
57
+ self.connection.row_factory = sqlite3.Row
58
+ self.connection.execute("PRAGMA journal_mode=WAL")
59
+ self.connection.execute("PRAGMA busy_timeout=5000")
60
+ self.connection.execute("PRAGMA foreign_keys=ON")
61
+ self.tokenizer = self._create_schema()
62
+
63
+ def _create_schema(self) -> str:
64
+ self.connection.executescript(
65
+ """
66
+ CREATE TABLE IF NOT EXISTS memories (
67
+ id TEXT PRIMARY KEY,
68
+ path TEXT NOT NULL UNIQUE,
69
+ name TEXT NOT NULL,
70
+ description TEXT NOT NULL,
71
+ type TEXT NOT NULL,
72
+ scope TEXT NOT NULL,
73
+ tags_json TEXT NOT NULL,
74
+ pinned INTEGER NOT NULL DEFAULT 0,
75
+ created TEXT NOT NULL,
76
+ updated TEXT NOT NULL,
77
+ mtime_ns INTEGER NOT NULL,
78
+ size INTEGER NOT NULL,
79
+ checksum TEXT NOT NULL,
80
+ indexed_at TEXT NOT NULL,
81
+ UNIQUE(scope, name)
82
+ );
83
+
84
+ CREATE TABLE IF NOT EXISTS links (
85
+ source_id TEXT NOT NULL REFERENCES memories(id) ON DELETE CASCADE,
86
+ target_text TEXT NOT NULL,
87
+ target_id TEXT,
88
+ relation_type TEXT NOT NULL,
89
+ resolved INTEGER NOT NULL DEFAULT 0,
90
+ PRIMARY KEY(source_id, target_text, relation_type)
91
+ );
92
+
93
+ CREATE TABLE IF NOT EXISTS index_errors (
94
+ path TEXT PRIMARY KEY,
95
+ error TEXT NOT NULL,
96
+ mtime_ns INTEGER NOT NULL,
97
+ size INTEGER NOT NULL
98
+ );
99
+
100
+ CREATE TABLE IF NOT EXISTS index_state (
101
+ key TEXT PRIMARY KEY,
102
+ value TEXT NOT NULL
103
+ );
104
+ """
105
+ )
106
+
107
+ row = self.connection.execute(
108
+ "SELECT value FROM index_state WHERE key = 'tokenizer'"
109
+ ).fetchone()
110
+ fts_exists = self.connection.execute(
111
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'memory_fts'"
112
+ ).fetchone()
113
+ if fts_exists:
114
+ tokenizer = str(row[0]) if row else "unicode61"
115
+ else:
116
+ try:
117
+ self.connection.execute(
118
+ """CREATE VIRTUAL TABLE memory_fts USING fts5(
119
+ id UNINDEXED, name, description, tags, body, tokenize='trigram'
120
+ )"""
121
+ )
122
+ tokenizer = "trigram"
123
+ except sqlite3.OperationalError:
124
+ self.connection.execute(
125
+ """CREATE VIRTUAL TABLE memory_fts USING fts5(
126
+ id UNINDEXED, name, description, tags, body, tokenize='unicode61'
127
+ )"""
128
+ )
129
+ tokenizer = "unicode61"
130
+
131
+ self.connection.execute(
132
+ """INSERT INTO index_state(key, value) VALUES('tokenizer', ?)
133
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value""",
134
+ (tokenizer,),
135
+ )
136
+ self.connection.execute(
137
+ """INSERT INTO index_state(key, value) VALUES('schema_version', ?)
138
+ ON CONFLICT(key) DO NOTHING""",
139
+ (SCHEMA_VERSION,),
140
+ )
141
+ return tokenizer
142
+
143
+ @contextmanager
144
+ def _write_transaction(self) -> Iterator[None]:
145
+ with self._lock:
146
+ self.connection.execute("BEGIN IMMEDIATE")
147
+ try:
148
+ yield
149
+ except Exception:
150
+ self.connection.rollback()
151
+ raise
152
+ else:
153
+ self.connection.commit()
154
+
155
+ @staticmethod
156
+ def _now() -> str:
157
+ return dt.datetime.now(dt.timezone.utc).isoformat()
158
+
159
+ @staticmethod
160
+ def _record(row: sqlite3.Row) -> IndexedMemory:
161
+ return IndexedMemory(
162
+ id=str(row["id"]),
163
+ path=Path(row["path"]),
164
+ name=str(row["name"]),
165
+ description=str(row["description"]),
166
+ type=str(row["type"]),
167
+ scope=str(row["scope"]),
168
+ tags=tuple(json.loads(row["tags_json"])),
169
+ pinned=bool(row["pinned"]),
170
+ created=str(row["created"]),
171
+ updated=str(row["updated"]),
172
+ mtime_ns=int(row["mtime_ns"]),
173
+ size=int(row["size"]),
174
+ checksum=str(row["checksum"]),
175
+ )
176
+
177
+ @staticmethod
178
+ def _search_hit(row: sqlite3.Row) -> SearchHit:
179
+ return SearchHit(
180
+ id=str(row["id"]),
181
+ name=str(row["name"]),
182
+ description=str(row["description"]),
183
+ scope=str(row["scope"]),
184
+ path=Path(row["path"]),
185
+ snippet=str(row["snippet"] or row["description"]),
186
+ rank=float(row["rank"] or 0.0),
187
+ pinned=bool(row["pinned"]),
188
+ )
189
+
190
+ def upsert(
191
+ self,
192
+ document: MemoryDocument,
193
+ *,
194
+ mtime_ns: int,
195
+ size: int,
196
+ checksum: str,
197
+ ) -> None:
198
+ path = document.path.expanduser().resolve()
199
+ with self._write_transaction():
200
+ replaced = self.connection.execute(
201
+ "SELECT id FROM memories WHERE path = ? AND id <> ?",
202
+ (str(path), document.id),
203
+ ).fetchall()
204
+ for row in replaced:
205
+ self.connection.execute("DELETE FROM memory_fts WHERE id = ?", (row["id"],))
206
+ self.connection.execute("DELETE FROM memories WHERE id = ?", (row["id"],))
207
+
208
+ self.connection.execute(
209
+ """
210
+ INSERT INTO memories(
211
+ id, path, name, description, type, scope, tags_json, pinned,
212
+ created, updated, mtime_ns, size, checksum, indexed_at
213
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
214
+ ON CONFLICT(id) DO UPDATE SET
215
+ path = excluded.path,
216
+ name = excluded.name,
217
+ description = excluded.description,
218
+ type = excluded.type,
219
+ scope = excluded.scope,
220
+ tags_json = excluded.tags_json,
221
+ pinned = excluded.pinned,
222
+ created = excluded.created,
223
+ updated = excluded.updated,
224
+ mtime_ns = excluded.mtime_ns,
225
+ size = excluded.size,
226
+ checksum = excluded.checksum,
227
+ indexed_at = excluded.indexed_at
228
+ """,
229
+ (
230
+ document.id,
231
+ str(path),
232
+ document.name,
233
+ document.description,
234
+ document.type,
235
+ document.scope,
236
+ json.dumps(document.tags, ensure_ascii=False),
237
+ int(document.pinned),
238
+ document.created,
239
+ document.updated,
240
+ mtime_ns,
241
+ size,
242
+ checksum,
243
+ self._now(),
244
+ ),
245
+ )
246
+ self.connection.execute("DELETE FROM memory_fts WHERE id = ?", (document.id,))
247
+ self.connection.execute(
248
+ "INSERT INTO memory_fts(id, name, description, tags, body) VALUES (?, ?, ?, ?, ?)",
249
+ (
250
+ document.id,
251
+ document.name,
252
+ document.description,
253
+ " ".join(document.tags),
254
+ document.body,
255
+ ),
256
+ )
257
+ self.connection.execute("DELETE FROM links WHERE source_id = ?", (document.id,))
258
+ self.connection.executemany(
259
+ """INSERT INTO links(
260
+ source_id, target_text, target_id, relation_type, resolved
261
+ ) VALUES (?, ?, NULL, ?, 0)""",
262
+ (
263
+ (document.id, link.target, link.relation_type)
264
+ for link in document.links
265
+ ),
266
+ )
267
+ self.connection.execute("DELETE FROM index_errors WHERE path = ?", (str(path),))
268
+ self._resolve_links()
269
+
270
+ @staticmethod
271
+ def _target_names(target_text: str) -> tuple[str, ...]:
272
+ basename = Path(target_text).name.removesuffix(".md")
273
+ candidates = [target_text, basename]
274
+ try:
275
+ candidates.append(slug_filename(basename).removesuffix(".md"))
276
+ except ValueError:
277
+ pass
278
+ return tuple(dict.fromkeys(candidate.casefold() for candidate in candidates))
279
+
280
+ def _resolve_links(self) -> None:
281
+ self.connection.execute("UPDATE links SET target_id = NULL, resolved = 0")
282
+ rows = self.connection.execute(
283
+ """SELECT l.source_id, l.target_text, l.relation_type, m.scope
284
+ FROM links l JOIN memories m ON m.id = l.source_id"""
285
+ ).fetchall()
286
+ memories = self.connection.execute("SELECT id, name, scope FROM memories").fetchall()
287
+ for link in rows:
288
+ names = self._target_names(str(link["target_text"]))
289
+ source_scope = str(link["scope"])
290
+ eligible = [
291
+ row
292
+ for row in memories
293
+ if str(row["name"]).casefold() in names
294
+ and (row["scope"] == source_scope or row["scope"] == "global")
295
+ ]
296
+ eligible.sort(key=lambda row: 0 if row["scope"] == source_scope else 1)
297
+ if not eligible:
298
+ continue
299
+ self.connection.execute(
300
+ """UPDATE links SET target_id = ?, resolved = 1
301
+ WHERE source_id = ? AND target_text = ? AND relation_type = ?""",
302
+ (
303
+ eligible[0]["id"],
304
+ link["source_id"],
305
+ link["target_text"],
306
+ link["relation_type"],
307
+ ),
308
+ )
309
+
310
+ def delete_path(self, path: Path) -> bool:
311
+ resolved = path.expanduser().resolve()
312
+ with self._write_transaction():
313
+ row = self.connection.execute(
314
+ "SELECT id FROM memories WHERE path = ?", (str(resolved),)
315
+ ).fetchone()
316
+ if row:
317
+ self.connection.execute("DELETE FROM memory_fts WHERE id = ?", (row["id"],))
318
+ self.connection.execute("DELETE FROM memories WHERE id = ?", (row["id"],))
319
+ self.connection.execute(
320
+ "DELETE FROM index_errors WHERE path = ?", (str(resolved),)
321
+ )
322
+ self._resolve_links()
323
+ return row is not None
324
+
325
+ def mark_error(self, path: Path, error: str, *, mtime_ns: int, size: int) -> None:
326
+ resolved = path.expanduser().resolve()
327
+ with self._write_transaction():
328
+ row = self.connection.execute(
329
+ "SELECT id FROM memories WHERE path = ?", (str(resolved),)
330
+ ).fetchone()
331
+ if row:
332
+ self.connection.execute("DELETE FROM memory_fts WHERE id = ?", (row["id"],))
333
+ self.connection.execute("DELETE FROM memories WHERE id = ?", (row["id"],))
334
+ self.connection.execute(
335
+ """INSERT INTO index_errors(path, error, mtime_ns, size)
336
+ VALUES (?, ?, ?, ?)
337
+ ON CONFLICT(path) DO UPDATE SET
338
+ error = excluded.error,
339
+ mtime_ns = excluded.mtime_ns,
340
+ size = excluded.size""",
341
+ (str(resolved), error, mtime_ns, size),
342
+ )
343
+ self._resolve_links()
344
+
345
+ def get_by_id(self, memory_id: str) -> IndexedMemory | None:
346
+ with self._lock:
347
+ row = self.connection.execute(
348
+ "SELECT * FROM memories WHERE id = ?", (memory_id,)
349
+ ).fetchone()
350
+ return self._record(row) if row else None
351
+
352
+ def get_by_path(self, path: Path) -> IndexedMemory | None:
353
+ resolved = path.expanduser().resolve()
354
+ with self._lock:
355
+ row = self.connection.execute(
356
+ "SELECT * FROM memories WHERE path = ?", (str(resolved),)
357
+ ).fetchone()
358
+ return self._record(row) if row else None
359
+
360
+ def get_by_name(
361
+ self, name: str, *, scopes: tuple[str, ...]
362
+ ) -> IndexedMemory | None:
363
+ names = self._target_names(name)
364
+ with self._lock:
365
+ for scope in scopes:
366
+ rows = self.connection.execute(
367
+ "SELECT * FROM memories WHERE scope = ?", (scope,)
368
+ ).fetchall()
369
+ for row in rows:
370
+ if str(row["name"]).casefold() in names:
371
+ return self._record(row)
372
+ return None
373
+
374
+ def all_records(self) -> tuple[IndexedMemory, ...]:
375
+ with self._lock:
376
+ rows = self.connection.execute(
377
+ "SELECT * FROM memories ORDER BY scope, name"
378
+ ).fetchall()
379
+ return tuple(self._record(row) for row in rows)
380
+
381
+ def file_states(self) -> dict[Path, FileState]:
382
+ with self._lock:
383
+ rows = self.connection.execute(
384
+ "SELECT id, path, name, scope, mtime_ns, size, checksum FROM memories"
385
+ ).fetchall()
386
+ return {
387
+ Path(row["path"]): FileState(
388
+ id=str(row["id"]),
389
+ path=Path(row["path"]),
390
+ name=str(row["name"]),
391
+ scope=str(row["scope"]),
392
+ mtime_ns=int(row["mtime_ns"]),
393
+ size=int(row["size"]),
394
+ checksum=str(row["checksum"]),
395
+ )
396
+ for row in rows
397
+ }
398
+
399
+ def update_file_metadata(self, path: Path, *, mtime_ns: int, size: int) -> None:
400
+ resolved = path.expanduser().resolve()
401
+ with self._lock:
402
+ self.connection.execute(
403
+ """UPDATE memories SET mtime_ns = ?, size = ?, indexed_at = ?
404
+ WHERE path = ?""",
405
+ (mtime_ns, size, self._now(), str(resolved)),
406
+ )
407
+
408
+ def index_error_states(self) -> dict[Path, tuple[int, int]]:
409
+ with self._lock:
410
+ rows = self.connection.execute(
411
+ "SELECT path, mtime_ns, size FROM index_errors"
412
+ ).fetchall()
413
+ return {
414
+ Path(row["path"]): (int(row["mtime_ns"]), int(row["size"]))
415
+ for row in rows
416
+ }
417
+
418
+ def index_errors(self) -> tuple[tuple[Path, str, int, int], ...]:
419
+ with self._lock:
420
+ rows = self.connection.execute(
421
+ "SELECT path, error, mtime_ns, size FROM index_errors ORDER BY path"
422
+ ).fetchall()
423
+ return tuple(
424
+ (Path(row["path"]), str(row["error"]), int(row["mtime_ns"]), int(row["size"]))
425
+ for row in rows
426
+ )
427
+
428
+ def search(
429
+ self,
430
+ query: str,
431
+ *,
432
+ scopes: tuple[str, ...],
433
+ limit: int,
434
+ ) -> tuple[SearchHit, ...]:
435
+ cleaned = query.strip()
436
+ unique_scopes = tuple(dict.fromkeys(scopes))
437
+ if not cleaned or not unique_scopes or limit < 1:
438
+ return ()
439
+ bounded_limit = min(limit, 100)
440
+ placeholders = ",".join("?" for _ in unique_scopes)
441
+ with self._lock:
442
+ if self.tokenizer == "trigram" and len(cleaned) >= 3:
443
+ literal_query = '"' + cleaned.replace('"', '""') + '"'
444
+ sql = f"""
445
+ SELECT m.*,
446
+ snippet(memory_fts, 4, '[', ']', ' ... ', 24) AS snippet,
447
+ bm25(memory_fts) AS rank
448
+ FROM memory_fts JOIN memories m ON m.id = memory_fts.id
449
+ WHERE memory_fts MATCH ? AND m.scope IN ({placeholders})
450
+ ORDER BY m.pinned DESC, rank ASC LIMIT ?
451
+ """
452
+ rows = self.connection.execute(
453
+ sql, (literal_query, *unique_scopes, bounded_limit)
454
+ ).fetchall()
455
+ else:
456
+ escaped = (
457
+ cleaned.replace("\\", "\\\\")
458
+ .replace("%", "\\%")
459
+ .replace("_", "\\_")
460
+ )
461
+ pattern = "%" + escaped + "%"
462
+ sql = f"""
463
+ SELECT m.*, substr(f.body, 1, 240) AS snippet, 0.0 AS rank
464
+ FROM memory_fts f JOIN memories m ON m.id = f.id
465
+ WHERE m.scope IN ({placeholders})
466
+ AND (f.name LIKE ? ESCAPE '\\'
467
+ OR f.description LIKE ? ESCAPE '\\'
468
+ OR f.tags LIKE ? ESCAPE '\\'
469
+ OR f.body LIKE ? ESCAPE '\\')
470
+ ORDER BY m.pinned DESC, m.updated DESC LIMIT ?
471
+ """
472
+ rows = self.connection.execute(
473
+ sql,
474
+ (
475
+ *unique_scopes,
476
+ pattern,
477
+ pattern,
478
+ pattern,
479
+ pattern,
480
+ bounded_limit,
481
+ ),
482
+ ).fetchall()
483
+ return tuple(self._search_hit(row) for row in rows)
484
+
485
+ def list_pinned(
486
+ self, *, scopes: tuple[str, ...], limit: int
487
+ ) -> tuple[SearchHit, ...]:
488
+ unique_scopes = tuple(dict.fromkeys(scopes))
489
+ if not unique_scopes or limit < 1:
490
+ return ()
491
+ placeholders = ",".join("?" for _ in unique_scopes)
492
+ with self._lock:
493
+ rows = self.connection.execute(
494
+ f"""SELECT m.*, m.description AS snippet, 0.0 AS rank
495
+ FROM memories m
496
+ WHERE m.pinned = 1 AND m.scope IN ({placeholders})
497
+ ORDER BY m.updated DESC LIMIT ?""",
498
+ (*unique_scopes, min(limit, 100)),
499
+ ).fetchall()
500
+ return tuple(self._search_hit(row) for row in rows)
501
+
502
+ def forward_links(
503
+ self, source_id: str
504
+ ) -> tuple[tuple[str, str | None, str, bool], ...]:
505
+ with self._lock:
506
+ rows = self.connection.execute(
507
+ """SELECT target_text, target_id, relation_type, resolved
508
+ FROM links WHERE source_id = ?
509
+ ORDER BY target_text, relation_type""",
510
+ (source_id,),
511
+ ).fetchall()
512
+ return tuple(
513
+ (
514
+ str(row["target_text"]),
515
+ str(row["target_id"]) if row["target_id"] else None,
516
+ str(row["relation_type"]),
517
+ bool(row["resolved"]),
518
+ )
519
+ for row in rows
520
+ )
521
+
522
+ def backlinks(self, target_id: str) -> tuple[tuple[str, str], ...]:
523
+ with self._lock:
524
+ rows = self.connection.execute(
525
+ """SELECT source_id, relation_type FROM links
526
+ WHERE target_id = ? ORDER BY source_id, relation_type""",
527
+ (target_id,),
528
+ ).fetchall()
529
+ return tuple((str(row["source_id"]), str(row["relation_type"])) for row in rows)
530
+
531
+ def unresolved_links(self) -> tuple[tuple[str, str, str], ...]:
532
+ with self._lock:
533
+ rows = self.connection.execute(
534
+ """SELECT source_id, target_text, relation_type FROM links
535
+ WHERE resolved = 0 ORDER BY source_id, target_text, relation_type"""
536
+ ).fetchall()
537
+ return tuple(
538
+ (str(row["source_id"]), str(row["target_text"]), str(row["relation_type"]))
539
+ for row in rows
540
+ )
541
+
542
+ def set_state(self, key: str, value: str) -> None:
543
+ with self._lock:
544
+ self.connection.execute(
545
+ """INSERT INTO index_state(key, value) VALUES (?, ?)
546
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value""",
547
+ (key, value),
548
+ )
549
+
550
+ def get_state(self, key: str) -> str | None:
551
+ with self._lock:
552
+ row = self.connection.execute(
553
+ "SELECT value FROM index_state WHERE key = ?", (key,)
554
+ ).fetchone()
555
+ return str(row["value"]) if row else None
556
+
557
+ def quick_check(self) -> bool:
558
+ with self._lock:
559
+ row = self.connection.execute("PRAGMA quick_check").fetchone()
560
+ return bool(row and row[0] == "ok")
561
+
562
+ def clear(self) -> None:
563
+ with self._write_transaction():
564
+ self.connection.execute("DELETE FROM memory_fts")
565
+ self.connection.execute("DELETE FROM links")
566
+ self.connection.execute("DELETE FROM memories")
567
+ self.connection.execute("DELETE FROM index_errors")
568
+ self.connection.execute("DELETE FROM index_state WHERE key = 'last_reconcile'")
569
+
570
+ def close(self) -> None:
571
+ with self._lock:
572
+ self.connection.close()