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 +1 -0
- keepm/cli.py +18 -0
- keepm/config.py +97 -0
- keepm/database.py +572 -0
- keepm/doctor.py +131 -0
- keepm/instructions.py +226 -0
- keepm/integrations.py +253 -0
- keepm/markdown.py +152 -0
- keepm/models.py +48 -0
- keepm/projects.py +57 -0
- keepm/server.py +450 -0
- keepm/service.py +299 -0
- keepm/setup.py +405 -0
- keepm/storage.py +66 -0
- keepm/sync.py +230 -0
- keepm/watcher.py +113 -0
- keepm-0.2.0.dist-info/METADATA +15 -0
- keepm-0.2.0.dist-info/RECORD +22 -0
- keepm-0.2.0.dist-info/WHEEL +5 -0
- keepm-0.2.0.dist-info/entry_points.txt +2 -0
- keepm-0.2.0.dist-info/licenses/LICENSE +21 -0
- keepm-0.2.0.dist-info/top_level.txt +1 -0
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()
|