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/service.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import dataclasses
|
|
5
|
+
import datetime as dt
|
|
6
|
+
import uuid
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from keepm.config import KeepMConfig
|
|
10
|
+
from keepm.database import IndexedMemory, MemoryDatabase
|
|
11
|
+
from keepm.doctor import DoctorFinding, run_doctor
|
|
12
|
+
from keepm.markdown import parse_memory, render_memory, slug_filename
|
|
13
|
+
from keepm.models import MemoryDocument, SearchHit, SyncReport
|
|
14
|
+
from keepm.projects import resolve_project_name
|
|
15
|
+
from keepm.storage import MemoryStorage
|
|
16
|
+
from keepm.sync import SyncEngine, sha256_file
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ConcurrentUpdateError(RuntimeError):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class IndexSyncError(RuntimeError):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MemoryService:
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
config: KeepMConfig,
|
|
31
|
+
storage: MemoryStorage,
|
|
32
|
+
database: MemoryDatabase,
|
|
33
|
+
sync_engine: SyncEngine,
|
|
34
|
+
) -> None:
|
|
35
|
+
self.config = config
|
|
36
|
+
self.storage = storage
|
|
37
|
+
self.database = database
|
|
38
|
+
self.sync_engine = sync_engine
|
|
39
|
+
self._freshness_lock = asyncio.Lock()
|
|
40
|
+
|
|
41
|
+
@staticmethod
|
|
42
|
+
def _new_id() -> str:
|
|
43
|
+
uuid7 = getattr(uuid, "uuid7", None)
|
|
44
|
+
return str(uuid7() if uuid7 else uuid.uuid4())
|
|
45
|
+
|
|
46
|
+
@staticmethod
|
|
47
|
+
def _project(workspace: Path) -> str:
|
|
48
|
+
return resolve_project_name(workspace.resolve())
|
|
49
|
+
|
|
50
|
+
def _scope_name(self, workspace: Path, scope: str) -> str:
|
|
51
|
+
if scope == "global":
|
|
52
|
+
return "global"
|
|
53
|
+
if scope == "project":
|
|
54
|
+
return self._project(workspace)
|
|
55
|
+
raise ValueError("scope must be 'global' or 'project'")
|
|
56
|
+
|
|
57
|
+
def _query_scopes(self, workspace: Path, scope: str | None = None) -> tuple[str, ...]:
|
|
58
|
+
project = self._project(workspace)
|
|
59
|
+
if scope is None:
|
|
60
|
+
return (project, "global")
|
|
61
|
+
if scope == "project":
|
|
62
|
+
return (project,)
|
|
63
|
+
if scope == "global":
|
|
64
|
+
return ("global",)
|
|
65
|
+
raise ValueError("scope must be 'global', 'project', or omitted")
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def _apply_project_override(
|
|
69
|
+
hits: tuple[SearchHit, ...], project: str
|
|
70
|
+
) -> tuple[SearchHit, ...]:
|
|
71
|
+
project_names = {
|
|
72
|
+
hit.name.casefold() for hit in hits if hit.scope == project
|
|
73
|
+
}
|
|
74
|
+
seen_ids: set[str] = set()
|
|
75
|
+
filtered: list[SearchHit] = []
|
|
76
|
+
for hit in hits:
|
|
77
|
+
if hit.id in seen_ids:
|
|
78
|
+
continue
|
|
79
|
+
if hit.scope == "global" and hit.name.casefold() in project_names:
|
|
80
|
+
continue
|
|
81
|
+
seen_ids.add(hit.id)
|
|
82
|
+
filtered.append(hit)
|
|
83
|
+
return tuple(filtered)
|
|
84
|
+
|
|
85
|
+
def search(
|
|
86
|
+
self,
|
|
87
|
+
*,
|
|
88
|
+
workspace: Path,
|
|
89
|
+
query: str,
|
|
90
|
+
scope: str | None = None,
|
|
91
|
+
limit: int = 10,
|
|
92
|
+
) -> tuple[SearchHit, ...]:
|
|
93
|
+
project = self._project(workspace)
|
|
94
|
+
scopes = self._query_scopes(workspace, scope)
|
|
95
|
+
hits = self.database.search(query, scopes=scopes, limit=max(limit * 2, limit))
|
|
96
|
+
if scope is None:
|
|
97
|
+
hits = self._apply_project_override(hits, project)
|
|
98
|
+
return hits[: max(limit, 0)]
|
|
99
|
+
|
|
100
|
+
def context(
|
|
101
|
+
self,
|
|
102
|
+
*,
|
|
103
|
+
workspace: Path,
|
|
104
|
+
query: str,
|
|
105
|
+
limit: int = 10,
|
|
106
|
+
max_chars: int = 6_000,
|
|
107
|
+
) -> tuple[SearchHit, ...]:
|
|
108
|
+
if limit < 1 or max_chars < 1:
|
|
109
|
+
return ()
|
|
110
|
+
project = self._project(workspace)
|
|
111
|
+
scopes = (project, "global")
|
|
112
|
+
pinned = self.database.list_pinned(scopes=scopes, limit=limit * 2)
|
|
113
|
+
ranked = self.database.search(query, scopes=scopes, limit=limit * 2)
|
|
114
|
+
merged = self._apply_project_override(pinned + ranked, project)
|
|
115
|
+
|
|
116
|
+
bounded: list[SearchHit] = []
|
|
117
|
+
used = 0
|
|
118
|
+
for hit in merged:
|
|
119
|
+
if len(bounded) >= limit or used >= max_chars:
|
|
120
|
+
break
|
|
121
|
+
remaining = max_chars - used
|
|
122
|
+
snippet = hit.snippet[:remaining]
|
|
123
|
+
if not snippet:
|
|
124
|
+
break
|
|
125
|
+
bounded.append(dataclasses.replace(hit, snippet=snippet))
|
|
126
|
+
used += len(snippet)
|
|
127
|
+
return tuple(bounded)
|
|
128
|
+
|
|
129
|
+
def _lookup_record(self, workspace: Path, identifier: str) -> IndexedMemory:
|
|
130
|
+
scopes = self._query_scopes(workspace)
|
|
131
|
+
record = self.database.get_by_id(identifier)
|
|
132
|
+
if record is None:
|
|
133
|
+
record = self.database.get_by_name(identifier, scopes=scopes)
|
|
134
|
+
if record is None and ("/" in identifier or identifier.endswith(".md")):
|
|
135
|
+
candidate = Path(identifier)
|
|
136
|
+
if not candidate.is_absolute():
|
|
137
|
+
candidate = self.config.memory_root / candidate
|
|
138
|
+
safe_path = self.storage.require_safe_path(candidate)
|
|
139
|
+
record = self.database.get_by_path(safe_path)
|
|
140
|
+
if record is None:
|
|
141
|
+
raise LookupError(f"memory not found: {identifier}")
|
|
142
|
+
if record.scope not in scopes:
|
|
143
|
+
raise LookupError(f"memory is outside the active scopes: {identifier}")
|
|
144
|
+
return record
|
|
145
|
+
|
|
146
|
+
def read(self, *, workspace: Path, identifier: str) -> MemoryDocument:
|
|
147
|
+
document, _ = self.read_with_checksum(
|
|
148
|
+
workspace=workspace, identifier=identifier
|
|
149
|
+
)
|
|
150
|
+
return document
|
|
151
|
+
|
|
152
|
+
def read_with_checksum(
|
|
153
|
+
self, *, workspace: Path, identifier: str
|
|
154
|
+
) -> tuple[MemoryDocument, str]:
|
|
155
|
+
record = self._lookup_record(workspace, identifier)
|
|
156
|
+
try:
|
|
157
|
+
document = parse_memory(
|
|
158
|
+
record.path, record.path.read_text(encoding="utf-8")
|
|
159
|
+
)
|
|
160
|
+
except OSError as error:
|
|
161
|
+
raise LookupError(f"memory file is unavailable: {record.path}") from error
|
|
162
|
+
return document, record.checksum
|
|
163
|
+
|
|
164
|
+
def write(
|
|
165
|
+
self,
|
|
166
|
+
*,
|
|
167
|
+
workspace: Path,
|
|
168
|
+
scope: str,
|
|
169
|
+
name: str,
|
|
170
|
+
description: str,
|
|
171
|
+
memory_type: str,
|
|
172
|
+
body: str,
|
|
173
|
+
tags: tuple[str, ...] = (),
|
|
174
|
+
pinned: bool = False,
|
|
175
|
+
expected_checksum: str | None = None,
|
|
176
|
+
) -> MemoryDocument:
|
|
177
|
+
target_scope = self._scope_name(workspace, scope)
|
|
178
|
+
logical_name = slug_filename(name).removesuffix(".md")
|
|
179
|
+
existing = self.database.get_by_name(logical_name, scopes=(target_scope,))
|
|
180
|
+
if expected_checksum is not None:
|
|
181
|
+
if existing is None or not existing.path.is_file():
|
|
182
|
+
raise ConcurrentUpdateError("memory changed on disk or no longer exists")
|
|
183
|
+
if sha256_file(existing.path) != expected_checksum:
|
|
184
|
+
raise ConcurrentUpdateError("memory changed on disk since it was read")
|
|
185
|
+
|
|
186
|
+
if existing:
|
|
187
|
+
target = existing.path
|
|
188
|
+
memory_id = existing.id
|
|
189
|
+
created = existing.created
|
|
190
|
+
else:
|
|
191
|
+
target = self.storage.path_for(
|
|
192
|
+
scope=target_scope, filename=slug_filename(logical_name)
|
|
193
|
+
)
|
|
194
|
+
if target.exists():
|
|
195
|
+
raise FileExistsError(f"unindexed memory file already exists: {target}")
|
|
196
|
+
memory_id = self._new_id()
|
|
197
|
+
created = dt.date.today().isoformat()
|
|
198
|
+
|
|
199
|
+
today = dt.date.today().isoformat()
|
|
200
|
+
candidate = MemoryDocument(
|
|
201
|
+
id=memory_id,
|
|
202
|
+
name=logical_name,
|
|
203
|
+
description=description.strip(),
|
|
204
|
+
type=memory_type,
|
|
205
|
+
scope=target_scope,
|
|
206
|
+
created=created,
|
|
207
|
+
updated=today,
|
|
208
|
+
body=body.strip(),
|
|
209
|
+
path=target,
|
|
210
|
+
tags=tuple(tags),
|
|
211
|
+
pinned=pinned,
|
|
212
|
+
)
|
|
213
|
+
rendered = render_memory(candidate)
|
|
214
|
+
validated = parse_memory(target, rendered)
|
|
215
|
+
self.storage.atomic_write(target, rendered)
|
|
216
|
+
report = self.sync_engine.reconcile_paths((target,))
|
|
217
|
+
if report.errors:
|
|
218
|
+
self.database.set_state("dirty", "1")
|
|
219
|
+
raise IndexSyncError(
|
|
220
|
+
"Markdown was saved but the SQLite index update failed: "
|
|
221
|
+
+ "; ".join(report.errors)
|
|
222
|
+
)
|
|
223
|
+
self.database.set_state("dirty", "0")
|
|
224
|
+
return validated
|
|
225
|
+
|
|
226
|
+
def delete(self, *, workspace: Path, identifier: str) -> Path:
|
|
227
|
+
record = self._lookup_record(workspace, identifier)
|
|
228
|
+
trashed = self.storage.soft_delete(record.path)
|
|
229
|
+
report = self.sync_engine.reconcile_paths((record.path,))
|
|
230
|
+
if report.errors:
|
|
231
|
+
self.database.set_state("dirty", "1")
|
|
232
|
+
raise IndexSyncError(
|
|
233
|
+
"Markdown was moved to trash but index cleanup failed: "
|
|
234
|
+
+ "; ".join(report.errors)
|
|
235
|
+
)
|
|
236
|
+
return trashed
|
|
237
|
+
|
|
238
|
+
def links(
|
|
239
|
+
self, *, workspace: Path, identifier: str, direction: str = "both"
|
|
240
|
+
) -> dict[str, object]:
|
|
241
|
+
if direction not in {"forward", "backward", "both"}:
|
|
242
|
+
raise ValueError("direction must be 'forward', 'backward', or 'both'")
|
|
243
|
+
record = self._lookup_record(workspace, identifier)
|
|
244
|
+
result: dict[str, object] = {"id": record.id}
|
|
245
|
+
if direction in {"forward", "both"}:
|
|
246
|
+
result["forward"] = self.database.forward_links(record.id)
|
|
247
|
+
if direction in {"backward", "both"}:
|
|
248
|
+
result["backlinks"] = self.database.backlinks(record.id)
|
|
249
|
+
return result
|
|
250
|
+
|
|
251
|
+
def status(self, *, workspace: Path | None = None) -> dict[str, object]:
|
|
252
|
+
records = self.database.all_records()
|
|
253
|
+
return {
|
|
254
|
+
"configured": True,
|
|
255
|
+
"vault_path": str(self.config.vault_path),
|
|
256
|
+
"memory_root": str(self.config.memory_root),
|
|
257
|
+
"language": self.config.language,
|
|
258
|
+
"project": self._project(workspace) if workspace else None,
|
|
259
|
+
"database_path": str(self.database.path),
|
|
260
|
+
"tokenizer": self.database.tokenizer,
|
|
261
|
+
"memory_count": len(records),
|
|
262
|
+
"index_error_count": len(self.database.index_errors()),
|
|
263
|
+
"last_reconcile": self.database.get_state("last_reconcile"),
|
|
264
|
+
"dirty": self.database.get_state("dirty") == "1",
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
def sync(self, *, full: bool = False) -> SyncReport:
|
|
268
|
+
report = (
|
|
269
|
+
self.sync_engine.full_rebuild()
|
|
270
|
+
if full
|
|
271
|
+
else self.sync_engine.quick_reconcile()
|
|
272
|
+
)
|
|
273
|
+
self.database.set_state("dirty", "1" if report.errors else "0")
|
|
274
|
+
return report
|
|
275
|
+
|
|
276
|
+
def doctor(self) -> tuple[DoctorFinding, ...]:
|
|
277
|
+
return run_doctor(self.storage, self.database)
|
|
278
|
+
|
|
279
|
+
async def ensure_fresh(self) -> SyncReport | None:
|
|
280
|
+
threshold = self.config.stale_after_seconds
|
|
281
|
+
last_reconcile = self.database.get_state("last_reconcile")
|
|
282
|
+
stale = last_reconcile is None
|
|
283
|
+
if last_reconcile is not None:
|
|
284
|
+
try:
|
|
285
|
+
last = dt.datetime.fromisoformat(last_reconcile)
|
|
286
|
+
if last.tzinfo is None:
|
|
287
|
+
last = last.replace(tzinfo=dt.timezone.utc)
|
|
288
|
+
age = (dt.datetime.now(dt.timezone.utc) - last).total_seconds()
|
|
289
|
+
stale = age >= threshold
|
|
290
|
+
except ValueError:
|
|
291
|
+
stale = True
|
|
292
|
+
if not stale:
|
|
293
|
+
return None
|
|
294
|
+
async with self._freshness_lock:
|
|
295
|
+
if threshold > 0:
|
|
296
|
+
refreshed = self.database.get_state("last_reconcile")
|
|
297
|
+
if refreshed and refreshed != last_reconcile:
|
|
298
|
+
return None
|
|
299
|
+
return self.sync(full=False)
|
keepm/setup.py
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Literal, Protocol, cast
|
|
10
|
+
|
|
11
|
+
import questionary
|
|
12
|
+
|
|
13
|
+
from keepm.config import ConfigStore, KeepMConfig, MemoryLanguage
|
|
14
|
+
from keepm.instructions import (
|
|
15
|
+
InstructionError,
|
|
16
|
+
InstructionInstaller,
|
|
17
|
+
InstructionResult,
|
|
18
|
+
)
|
|
19
|
+
from keepm.integrations import (
|
|
20
|
+
AgentName,
|
|
21
|
+
AgentRegistrar,
|
|
22
|
+
IntegrationError,
|
|
23
|
+
McpScope,
|
|
24
|
+
McpTarget,
|
|
25
|
+
RegistrationState,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SetupError(RuntimeError):
|
|
30
|
+
"""The requested setup cannot proceed without a user decision."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Prompt(Protocol):
|
|
34
|
+
def ask(self, message: str, *, default: str | None = None) -> str: ...
|
|
35
|
+
|
|
36
|
+
def choose(
|
|
37
|
+
self, message: str, choices: Sequence[str], *, default: str
|
|
38
|
+
) -> str: ...
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
PROMPT_STYLE = questionary.Style(
|
|
42
|
+
[
|
|
43
|
+
("qmark", "fg:#00afff bold"),
|
|
44
|
+
("question", "fg:#5f87ff bold"),
|
|
45
|
+
("answer", "fg:#00d787 bold"),
|
|
46
|
+
("pointer", "fg:#00d787 bold"),
|
|
47
|
+
("highlighted", "fg:#00d787 bold"),
|
|
48
|
+
("selected", "fg:#00d7af bold"),
|
|
49
|
+
("instruction", "fg:#808080"),
|
|
50
|
+
("text", ""),
|
|
51
|
+
("disabled", "fg:#666666 italic"),
|
|
52
|
+
]
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
CHOICE_TITLES = {
|
|
56
|
+
"zh": "中文 (zh)",
|
|
57
|
+
"en": "English (en)",
|
|
58
|
+
"codex": "Codex",
|
|
59
|
+
"claude": "Claude Code",
|
|
60
|
+
"user": "User scope",
|
|
61
|
+
"project": "Project scope",
|
|
62
|
+
"replace": "Replace",
|
|
63
|
+
"keep": "Keep existing",
|
|
64
|
+
"skip": "Skip",
|
|
65
|
+
"cancel": "Cancel",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class TerminalPrompt:
|
|
70
|
+
@staticmethod
|
|
71
|
+
def _required(answer: object) -> object:
|
|
72
|
+
if answer is None:
|
|
73
|
+
raise SetupError("setup cancelled")
|
|
74
|
+
return answer
|
|
75
|
+
|
|
76
|
+
def ask(self, message: str, *, default: str | None = None) -> str:
|
|
77
|
+
answer = questionary.text(
|
|
78
|
+
message,
|
|
79
|
+
default=default or "",
|
|
80
|
+
qmark="?",
|
|
81
|
+
style=PROMPT_STYLE,
|
|
82
|
+
).ask()
|
|
83
|
+
return str(self._required(answer)).strip()
|
|
84
|
+
|
|
85
|
+
def choose(
|
|
86
|
+
self, message: str, choices: Sequence[str], *, default: str
|
|
87
|
+
) -> str:
|
|
88
|
+
options = [
|
|
89
|
+
questionary.Choice(CHOICE_TITLES.get(choice, choice), value=choice)
|
|
90
|
+
for choice in choices
|
|
91
|
+
]
|
|
92
|
+
answer = questionary.select(
|
|
93
|
+
message,
|
|
94
|
+
choices=options,
|
|
95
|
+
default=default,
|
|
96
|
+
pointer="❯",
|
|
97
|
+
qmark="?",
|
|
98
|
+
style=PROMPT_STYLE,
|
|
99
|
+
instruction="(↑/↓ move, enter confirm)",
|
|
100
|
+
).ask()
|
|
101
|
+
return str(self._required(answer))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True, slots=True)
|
|
106
|
+
class SetupRequest:
|
|
107
|
+
vault_path: Path | None = None
|
|
108
|
+
memory_dir: str | None = None
|
|
109
|
+
language: MemoryLanguage | None = None
|
|
110
|
+
agent: AgentName | None = None
|
|
111
|
+
scope: McpScope | None = None
|
|
112
|
+
project_dir: Path = field(default_factory=Path.cwd)
|
|
113
|
+
force: bool = False
|
|
114
|
+
assume_yes: bool = False
|
|
115
|
+
targets: tuple[McpTarget, ...] | None = None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True, slots=True)
|
|
119
|
+
class TargetResult:
|
|
120
|
+
target: McpTarget
|
|
121
|
+
status: Literal["configured", "already-configured", "skipped", "failed"]
|
|
122
|
+
detail: str
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True, slots=True)
|
|
126
|
+
class SetupResult:
|
|
127
|
+
vault_status: Literal["configured", "already-configured", "kept"]
|
|
128
|
+
config: KeepMConfig
|
|
129
|
+
targets: tuple[TargetResult, ...]
|
|
130
|
+
instructions: tuple[InstructionResult, ...]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def default_language(environment: Mapping[str, str] | None = None) -> MemoryLanguage:
|
|
134
|
+
values = environment if environment is not None else os.environ
|
|
135
|
+
locale_name = next(
|
|
136
|
+
(values[key] for key in ("LC_ALL", "LC_MESSAGES", "LANG") if values.get(key)),
|
|
137
|
+
"",
|
|
138
|
+
)
|
|
139
|
+
return "zh" if locale_name.casefold().startswith("zh") else "en"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class SetupCoordinator:
|
|
143
|
+
def __init__(
|
|
144
|
+
self,
|
|
145
|
+
*,
|
|
146
|
+
store: ConfigStore | None = None,
|
|
147
|
+
registrar: AgentRegistrar | None = None,
|
|
148
|
+
prompt: Prompt | None = None,
|
|
149
|
+
instruction_installer: InstructionInstaller | None = None,
|
|
150
|
+
) -> None:
|
|
151
|
+
self.store = store or ConfigStore()
|
|
152
|
+
self.registrar = registrar or AgentRegistrar()
|
|
153
|
+
self.prompt = prompt or TerminalPrompt()
|
|
154
|
+
registrar_home = getattr(self.registrar, "home", None)
|
|
155
|
+
self.instruction_installer = instruction_installer or InstructionInstaller(
|
|
156
|
+
home=registrar_home
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def run(self, request: SetupRequest) -> SetupResult:
|
|
160
|
+
config, vault_status = self._configure_vault(request)
|
|
161
|
+
targets = request.targets
|
|
162
|
+
if targets is None:
|
|
163
|
+
targets = self._prompt_targets(request)
|
|
164
|
+
if targets and not self.registrar.has_uvx():
|
|
165
|
+
raise SetupError("uvx is required to register KeepM; install uv first")
|
|
166
|
+
|
|
167
|
+
results: list[TargetResult] = []
|
|
168
|
+
instruction_results: list[InstructionResult] = []
|
|
169
|
+
for target in targets:
|
|
170
|
+
try:
|
|
171
|
+
target_result = self.register(target, request=request)
|
|
172
|
+
results.append(target_result)
|
|
173
|
+
except IntegrationError as error:
|
|
174
|
+
results.append(TargetResult(target, "failed", str(error)))
|
|
175
|
+
continue
|
|
176
|
+
if target_result.status not in {"configured", "already-configured"}:
|
|
177
|
+
continue
|
|
178
|
+
try:
|
|
179
|
+
instruction_results.append(
|
|
180
|
+
self.instruction_installer.install(target, config.language)
|
|
181
|
+
)
|
|
182
|
+
except InstructionError as error:
|
|
183
|
+
instruction_results.append(
|
|
184
|
+
InstructionResult(target, None, "failed", str(error))
|
|
185
|
+
)
|
|
186
|
+
return SetupResult(
|
|
187
|
+
vault_status=vault_status,
|
|
188
|
+
config=config,
|
|
189
|
+
targets=tuple(results),
|
|
190
|
+
instructions=tuple(instruction_results),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def register(self, target: McpTarget, *, request: SetupRequest) -> TargetResult:
|
|
194
|
+
if target.scope == "project":
|
|
195
|
+
assert target.project_dir is not None
|
|
196
|
+
if not target.project_dir.is_dir():
|
|
197
|
+
raise SetupError(f"project directory does not exist: {target.project_dir}")
|
|
198
|
+
state = self.registrar.inspect(target)
|
|
199
|
+
if state.kind == "equivalent":
|
|
200
|
+
return TargetResult(target, "already-configured", state.detail)
|
|
201
|
+
if state.kind == "different":
|
|
202
|
+
if request.force:
|
|
203
|
+
self.registrar.apply(target, replace=True)
|
|
204
|
+
return TargetResult(target, "configured", "replaced existing keepm MCP")
|
|
205
|
+
if request.assume_yes:
|
|
206
|
+
raise SetupError(
|
|
207
|
+
f"{target.agent} {target.scope} has a different keepm MCP; rerun with --force to replace it"
|
|
208
|
+
)
|
|
209
|
+
decision = self.prompt.choose(
|
|
210
|
+
f"{target.agent} {target.scope} already has a different keepm MCP",
|
|
211
|
+
("replace", "skip", "cancel"),
|
|
212
|
+
default="skip",
|
|
213
|
+
)
|
|
214
|
+
if decision == "cancel":
|
|
215
|
+
raise SetupError("setup cancelled; no remaining MCP registrations were changed")
|
|
216
|
+
if decision == "skip":
|
|
217
|
+
return TargetResult(target, "skipped", state.detail)
|
|
218
|
+
self.registrar.apply(target, replace=True)
|
|
219
|
+
return TargetResult(target, "configured", "replaced existing keepm MCP")
|
|
220
|
+
|
|
221
|
+
self.registrar.apply(target, replace=False)
|
|
222
|
+
return TargetResult(target, "configured", "registered KeepM MCP")
|
|
223
|
+
|
|
224
|
+
def _configure_vault(
|
|
225
|
+
self, request: SetupRequest
|
|
226
|
+
) -> tuple[KeepMConfig, Literal["configured", "already-configured", "kept"]]:
|
|
227
|
+
existing = self.store.load_optional()
|
|
228
|
+
vault_path = request.vault_path
|
|
229
|
+
if vault_path is None and existing is not None:
|
|
230
|
+
vault_path = existing.vault_path
|
|
231
|
+
if vault_path is None:
|
|
232
|
+
if request.assume_yes:
|
|
233
|
+
raise SetupError("--vault is required with --yes for first-time setup")
|
|
234
|
+
vault_path = Path(self.prompt.ask("Obsidian Vault path")).expanduser()
|
|
235
|
+
memory_dir = request.memory_dir or (
|
|
236
|
+
existing.memory_dir if existing is not None else "Memory"
|
|
237
|
+
)
|
|
238
|
+
selected_language = request.language
|
|
239
|
+
if selected_language is None:
|
|
240
|
+
language_default = existing.language if existing is not None else default_language()
|
|
241
|
+
if request.assume_yes:
|
|
242
|
+
selected_language = language_default
|
|
243
|
+
else:
|
|
244
|
+
selected_language = cast(
|
|
245
|
+
MemoryLanguage,
|
|
246
|
+
self.prompt.choose(
|
|
247
|
+
"Memory language", ("zh", "en"), default=language_default
|
|
248
|
+
),
|
|
249
|
+
)
|
|
250
|
+
vault_path = vault_path.expanduser().resolve()
|
|
251
|
+
if not vault_path.is_dir():
|
|
252
|
+
raise SetupError(f"vault path must be an existing directory: {vault_path}")
|
|
253
|
+
try:
|
|
254
|
+
desired = KeepMConfig(
|
|
255
|
+
vault_path=vault_path,
|
|
256
|
+
memory_dir=memory_dir,
|
|
257
|
+
stale_after_seconds=(
|
|
258
|
+
existing.stale_after_seconds if existing is not None else 30
|
|
259
|
+
),
|
|
260
|
+
language=selected_language,
|
|
261
|
+
)
|
|
262
|
+
except ValueError as error:
|
|
263
|
+
raise SetupError(str(error)) from error
|
|
264
|
+
|
|
265
|
+
if existing == desired:
|
|
266
|
+
return existing, "already-configured"
|
|
267
|
+
if existing is not None:
|
|
268
|
+
same_storage = (
|
|
269
|
+
existing.vault_path == desired.vault_path
|
|
270
|
+
and existing.memory_dir == desired.memory_dir
|
|
271
|
+
and existing.stale_after_seconds == desired.stale_after_seconds
|
|
272
|
+
)
|
|
273
|
+
if same_storage:
|
|
274
|
+
return self.store.save(desired), "configured"
|
|
275
|
+
if request.force:
|
|
276
|
+
return self.store.save(desired), "configured"
|
|
277
|
+
if request.assume_yes:
|
|
278
|
+
raise SetupError(
|
|
279
|
+
"KeepM already uses a different Vault configuration; rerun with --force to replace it"
|
|
280
|
+
)
|
|
281
|
+
decision = self.prompt.choose(
|
|
282
|
+
"KeepM already has a different Vault configuration",
|
|
283
|
+
("replace", "keep", "cancel"),
|
|
284
|
+
default="keep",
|
|
285
|
+
)
|
|
286
|
+
if decision == "cancel":
|
|
287
|
+
raise SetupError("setup cancelled before changing the KeepM Vault")
|
|
288
|
+
if decision == "keep":
|
|
289
|
+
return existing, "kept"
|
|
290
|
+
return self.store.save(desired), "configured"
|
|
291
|
+
|
|
292
|
+
def _prompt_targets(self, request: SetupRequest) -> tuple[McpTarget, ...]:
|
|
293
|
+
agents = self.registrar.available_agents()
|
|
294
|
+
if not agents:
|
|
295
|
+
return ()
|
|
296
|
+
if request.agent is not None:
|
|
297
|
+
if request.agent not in agents:
|
|
298
|
+
raise SetupError(f"{request.agent} is not available on PATH")
|
|
299
|
+
agent = request.agent
|
|
300
|
+
elif request.assume_yes:
|
|
301
|
+
if len(agents) != 1:
|
|
302
|
+
raise SetupError(
|
|
303
|
+
"--agent is required with --yes when multiple Agents are available"
|
|
304
|
+
)
|
|
305
|
+
agent = agents[0]
|
|
306
|
+
else:
|
|
307
|
+
agent = cast(
|
|
308
|
+
AgentName,
|
|
309
|
+
self.prompt.choose(
|
|
310
|
+
"Agent to register",
|
|
311
|
+
agents,
|
|
312
|
+
default=agents[0],
|
|
313
|
+
),
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
scope = request.scope or "user"
|
|
317
|
+
if request.scope is None and not request.assume_yes:
|
|
318
|
+
scope = cast(
|
|
319
|
+
McpScope,
|
|
320
|
+
self.prompt.choose(
|
|
321
|
+
f"Register KeepM for {agent}",
|
|
322
|
+
("user", "project"),
|
|
323
|
+
default="user",
|
|
324
|
+
),
|
|
325
|
+
)
|
|
326
|
+
return (
|
|
327
|
+
McpTarget(
|
|
328
|
+
agent,
|
|
329
|
+
scope,
|
|
330
|
+
request.project_dir if scope == "project" else None,
|
|
331
|
+
),
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
def _parser() -> argparse.ArgumentParser:
|
|
335
|
+
parser = argparse.ArgumentParser(
|
|
336
|
+
prog="keepm setup", description="Configure KeepM and register its local MCP."
|
|
337
|
+
)
|
|
338
|
+
parser.add_argument("--vault", type=Path, help="existing local Obsidian Vault path")
|
|
339
|
+
parser.add_argument(
|
|
340
|
+
"--memory-dir", help="relative directory inside the Vault (default: Memory)"
|
|
341
|
+
)
|
|
342
|
+
parser.add_argument(
|
|
343
|
+
"--language", choices=("zh", "en"), help="language for Agent instructions and new memories"
|
|
344
|
+
)
|
|
345
|
+
parser.add_argument(
|
|
346
|
+
"--agent", choices=("codex", "claude"), help="Agent to register"
|
|
347
|
+
)
|
|
348
|
+
parser.add_argument(
|
|
349
|
+
"--scope", choices=("user", "project"), help="MCP and instruction scope (default: user)"
|
|
350
|
+
)
|
|
351
|
+
parser.add_argument(
|
|
352
|
+
"--project-dir", type=Path, default=Path.cwd(), help="project for project-scope registration"
|
|
353
|
+
)
|
|
354
|
+
parser.add_argument("--yes", action="store_true", help="accept normal confirmations")
|
|
355
|
+
parser.add_argument(
|
|
356
|
+
"--force", action="store_true", help="replace a different KeepM configuration without prompting"
|
|
357
|
+
)
|
|
358
|
+
return parser
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _print_result(result: SetupResult) -> None:
|
|
362
|
+
print(
|
|
363
|
+
f"KeepM Vault: {result.vault_status} ({result.config.vault_path}, language={result.config.language})"
|
|
364
|
+
)
|
|
365
|
+
for target in result.targets:
|
|
366
|
+
detail = f" - {target.detail}" if target.status == "failed" else ""
|
|
367
|
+
print(
|
|
368
|
+
f"{target.target.agent} ({target.target.scope}): {target.status}{detail}"
|
|
369
|
+
)
|
|
370
|
+
for instruction in result.instructions:
|
|
371
|
+
location = f" ({instruction.path})" if instruction.path is not None else ""
|
|
372
|
+
detail = f" - {instruction.detail}" if instruction.status == "failed" else ""
|
|
373
|
+
print(
|
|
374
|
+
f"{instruction.target.agent} instructions ({instruction.target.scope}): "
|
|
375
|
+
f"{instruction.status}{location}{detail}"
|
|
376
|
+
)
|
|
377
|
+
if not result.targets:
|
|
378
|
+
print("No supported Agent was selected; register KeepM later with `keepm setup`.")
|
|
379
|
+
print("Manual registration: codex mcp add keepm -- uvx --from keepm keepm")
|
|
380
|
+
print(
|
|
381
|
+
"Manual registration: claude mcp add --scope user keepm -- uvx --from keepm keepm"
|
|
382
|
+
)
|
|
383
|
+
print("Restart or open a new Agent session before using the KeepM MCP.")
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def main(argv: Sequence[str] | None = None) -> None:
|
|
387
|
+
parser = _parser()
|
|
388
|
+
arguments = parser.parse_args(argv)
|
|
389
|
+
if not arguments.yes and not sys.stdin.isatty():
|
|
390
|
+
parser.error("interactive setup requires a TTY; use --yes for automation")
|
|
391
|
+
request = SetupRequest(
|
|
392
|
+
vault_path=arguments.vault,
|
|
393
|
+
memory_dir=arguments.memory_dir,
|
|
394
|
+
language=cast(MemoryLanguage | None, arguments.language),
|
|
395
|
+
agent=cast(AgentName | None, arguments.agent),
|
|
396
|
+
scope=cast(McpScope | None, arguments.scope),
|
|
397
|
+
project_dir=arguments.project_dir.expanduser().resolve(),
|
|
398
|
+
force=arguments.force,
|
|
399
|
+
assume_yes=arguments.yes,
|
|
400
|
+
)
|
|
401
|
+
try:
|
|
402
|
+
result = SetupCoordinator().run(request)
|
|
403
|
+
except (SetupError, IntegrationError) as error:
|
|
404
|
+
parser.error(str(error))
|
|
405
|
+
_print_result(result)
|