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/markdown.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import datetime as dt
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from keepm.models import MemoryDocument, MemoryLink
|
|
11
|
+
|
|
12
|
+
VALID_TYPES = {"user", "feedback", "project", "reference"}
|
|
13
|
+
LINK_RE = re.compile(
|
|
14
|
+
r"^\s*-\s+(?:(?P<relation>[A-Za-z0-9_-]+)\s+)?"
|
|
15
|
+
r"\[\[(?P<target>[^\]|#]+)(?:#[^\]|]+)?(?:\|[^\]]+)?\]\]",
|
|
16
|
+
re.MULTILINE,
|
|
17
|
+
)
|
|
18
|
+
WIKILINK_RE = re.compile(
|
|
19
|
+
r"\[\[(?P<target>[^\]|#]+)(?:#[^\]|]+)?(?:\|[^\]]+)?\]\]"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def slug_filename(name: str) -> str:
|
|
24
|
+
slug = name.casefold().strip().replace(" ", "-")
|
|
25
|
+
slug = re.sub(r"[^\w-]", "", slug, flags=re.UNICODE)
|
|
26
|
+
slug = re.sub(r"[-_]+", "-", slug).strip("-")
|
|
27
|
+
if not slug:
|
|
28
|
+
raise ValueError("memory name cannot produce a filename")
|
|
29
|
+
return f"{slug}.md"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _date_string(value: object) -> str:
|
|
33
|
+
if isinstance(value, dt.datetime):
|
|
34
|
+
value = value.date()
|
|
35
|
+
if isinstance(value, dt.date):
|
|
36
|
+
return value.isoformat()
|
|
37
|
+
text = str(value)
|
|
38
|
+
try:
|
|
39
|
+
dt.date.fromisoformat(text)
|
|
40
|
+
except ValueError as error:
|
|
41
|
+
raise ValueError("date must be YYYY-MM-DD") from error
|
|
42
|
+
return text
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _has_protocol_label(body: str, label: str) -> bool:
|
|
46
|
+
pattern = rf"(?im)^\s*(?:\*\*)?{re.escape(label)}(?:\*\*)?\s*[::](?:\*\*)?"
|
|
47
|
+
return re.search(pattern, body) is not None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def parse_memory(path: Path, text: str) -> MemoryDocument:
|
|
51
|
+
normalized = text.lstrip("\ufeff").replace("\r\n", "\n").replace("\r", "\n")
|
|
52
|
+
if not normalized.startswith("---\n"):
|
|
53
|
+
raise ValueError(f"{path}: missing YAML frontmatter")
|
|
54
|
+
closing = normalized.find("\n---\n", 4)
|
|
55
|
+
if closing < 0:
|
|
56
|
+
raise ValueError(f"{path}: unclosed YAML frontmatter")
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
loaded = yaml.safe_load(normalized[4:closing]) or {}
|
|
60
|
+
except yaml.YAMLError as error:
|
|
61
|
+
raise ValueError(f"{path}: invalid YAML frontmatter: {error}") from error
|
|
62
|
+
if not isinstance(loaded, Mapping):
|
|
63
|
+
raise ValueError(f"{path}: YAML frontmatter must be a mapping")
|
|
64
|
+
metadata = dict(loaded)
|
|
65
|
+
body = normalized[closing + 5 :].lstrip("\n")
|
|
66
|
+
|
|
67
|
+
required = ("id", "name", "description", "type", "scope", "created", "updated")
|
|
68
|
+
missing = [key for key in required if metadata.get(key) in (None, "")]
|
|
69
|
+
if missing:
|
|
70
|
+
raise ValueError(f"{path}: missing required fields: {', '.join(missing)}")
|
|
71
|
+
|
|
72
|
+
memory_type = str(metadata["type"])
|
|
73
|
+
if memory_type not in VALID_TYPES:
|
|
74
|
+
raise ValueError(f"{path}: invalid type: {memory_type}")
|
|
75
|
+
try:
|
|
76
|
+
created = _date_string(metadata["created"])
|
|
77
|
+
updated = _date_string(metadata["updated"])
|
|
78
|
+
except ValueError as error:
|
|
79
|
+
raise ValueError(f"{path}: {error}") from error
|
|
80
|
+
|
|
81
|
+
if memory_type in {"feedback", "project"}:
|
|
82
|
+
if not _has_protocol_label(body, "Why"):
|
|
83
|
+
raise ValueError(f"{path}: type {memory_type} requires Why:")
|
|
84
|
+
if not _has_protocol_label(body, "How to apply"):
|
|
85
|
+
raise ValueError(f"{path}: type {memory_type} requires How to apply:")
|
|
86
|
+
|
|
87
|
+
raw_tags = metadata.get("tags", []) or []
|
|
88
|
+
if not isinstance(raw_tags, list):
|
|
89
|
+
raise ValueError(f"{path}: tags must be a YAML list")
|
|
90
|
+
tags = tuple(str(tag).lstrip("#") for tag in raw_tags)
|
|
91
|
+
|
|
92
|
+
raw_pinned = metadata.get("pinned", False)
|
|
93
|
+
if not isinstance(raw_pinned, bool):
|
|
94
|
+
raise ValueError(f"{path}: pinned must be true or false")
|
|
95
|
+
|
|
96
|
+
links: list[MemoryLink] = []
|
|
97
|
+
relation_spans: list[tuple[int, int]] = []
|
|
98
|
+
for match in LINK_RE.finditer(body):
|
|
99
|
+
links.append(
|
|
100
|
+
MemoryLink(
|
|
101
|
+
target=match.group("target").strip(),
|
|
102
|
+
relation_type=match.group("relation") or "links_to",
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
relation_spans.append(match.span())
|
|
106
|
+
for match in WIKILINK_RE.finditer(body):
|
|
107
|
+
if any(start <= match.start() < end for start, end in relation_spans):
|
|
108
|
+
continue
|
|
109
|
+
links.append(MemoryLink(target=match.group("target").strip()))
|
|
110
|
+
deduplicated_links = tuple(
|
|
111
|
+
{
|
|
112
|
+
(link.relation_type, link.target.casefold()): link
|
|
113
|
+
for link in links
|
|
114
|
+
}.values()
|
|
115
|
+
)
|
|
116
|
+
return MemoryDocument(
|
|
117
|
+
id=str(metadata["id"]),
|
|
118
|
+
name=str(metadata["name"]),
|
|
119
|
+
description=str(metadata["description"]),
|
|
120
|
+
type=memory_type,
|
|
121
|
+
scope=str(metadata["scope"]),
|
|
122
|
+
created=created,
|
|
123
|
+
updated=updated,
|
|
124
|
+
body=body,
|
|
125
|
+
path=path,
|
|
126
|
+
tags=tags,
|
|
127
|
+
pinned=raw_pinned,
|
|
128
|
+
links=deduplicated_links,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def render_memory(document: MemoryDocument) -> str:
|
|
133
|
+
metadata = {
|
|
134
|
+
"id": document.id,
|
|
135
|
+
"name": document.name,
|
|
136
|
+
"description": document.description,
|
|
137
|
+
"type": document.type,
|
|
138
|
+
"scope": document.scope,
|
|
139
|
+
"tags": list(document.tags),
|
|
140
|
+
"pinned": document.pinned,
|
|
141
|
+
"created": document.created,
|
|
142
|
+
"updated": document.updated,
|
|
143
|
+
}
|
|
144
|
+
frontmatter = yaml.safe_dump(
|
|
145
|
+
metadata,
|
|
146
|
+
sort_keys=False,
|
|
147
|
+
allow_unicode=True,
|
|
148
|
+
default_flow_style=False,
|
|
149
|
+
).strip()
|
|
150
|
+
body = document.body.rstrip()
|
|
151
|
+
separator = "\n\n" if body else "\n"
|
|
152
|
+
return f"---\n{frontmatter}\n---{separator}{body}\n"
|
keepm/models.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True, slots=True)
|
|
8
|
+
class MemoryLink:
|
|
9
|
+
target: str
|
|
10
|
+
relation_type: str = "links_to"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class MemoryDocument:
|
|
15
|
+
id: str
|
|
16
|
+
name: str
|
|
17
|
+
description: str
|
|
18
|
+
type: str
|
|
19
|
+
scope: str
|
|
20
|
+
created: str
|
|
21
|
+
updated: str
|
|
22
|
+
body: str
|
|
23
|
+
path: Path
|
|
24
|
+
tags: tuple[str, ...] = ()
|
|
25
|
+
pinned: bool = False
|
|
26
|
+
links: tuple[MemoryLink, ...] = ()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class SearchHit:
|
|
31
|
+
id: str
|
|
32
|
+
name: str
|
|
33
|
+
description: str
|
|
34
|
+
scope: str
|
|
35
|
+
path: Path
|
|
36
|
+
snippet: str
|
|
37
|
+
rank: float
|
|
38
|
+
pinned: bool = False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class SyncReport:
|
|
43
|
+
new: int = 0
|
|
44
|
+
modified: int = 0
|
|
45
|
+
deleted: int = 0
|
|
46
|
+
moved: int = 0
|
|
47
|
+
unchanged: int = 0
|
|
48
|
+
errors: tuple[str, ...] = field(default_factory=tuple)
|
keepm/projects.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
import tomllib
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from urllib.parse import unquote, urlparse
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def normalize_project_name(name: str) -> str:
|
|
12
|
+
normalized = re.sub(r"[^a-z0-9_-]", "", name.lower().replace(" ", "-"))
|
|
13
|
+
normalized = re.sub(r"[-_]+", "-", normalized).strip("-")
|
|
14
|
+
if not normalized:
|
|
15
|
+
raise ValueError("project name is empty after normalization")
|
|
16
|
+
return normalized
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def resolve_workspace(
|
|
20
|
+
client_roots: Sequence[str] | None,
|
|
21
|
+
workspace_path: str | None,
|
|
22
|
+
cwd: Path,
|
|
23
|
+
) -> Path:
|
|
24
|
+
if client_roots:
|
|
25
|
+
first = client_roots[0]
|
|
26
|
+
parsed = urlparse(first)
|
|
27
|
+
value = unquote(parsed.path) if parsed.scheme == "file" else first
|
|
28
|
+
return Path(value).expanduser().resolve()
|
|
29
|
+
if workspace_path:
|
|
30
|
+
return Path(workspace_path).expanduser().resolve()
|
|
31
|
+
return cwd.expanduser().resolve()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def resolve_project_name(workspace: Path) -> str:
|
|
35
|
+
override = workspace / ".keepm.toml"
|
|
36
|
+
if override.is_file():
|
|
37
|
+
data = tomllib.loads(override.read_text(encoding="utf-8"))
|
|
38
|
+
if data.get("project"):
|
|
39
|
+
return normalize_project_name(str(data["project"]))
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
result = subprocess.run(
|
|
43
|
+
["git", "remote", "get-url", "origin"],
|
|
44
|
+
cwd=workspace,
|
|
45
|
+
capture_output=True,
|
|
46
|
+
text=True,
|
|
47
|
+
timeout=2,
|
|
48
|
+
check=False,
|
|
49
|
+
)
|
|
50
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
51
|
+
result = None
|
|
52
|
+
|
|
53
|
+
if result and result.returncode == 0 and result.stdout.strip():
|
|
54
|
+
remote_name = result.stdout.strip().rstrip("/").rsplit("/", 1)[-1]
|
|
55
|
+
remote_name = remote_name.rsplit(":", 1)[-1].removesuffix(".git")
|
|
56
|
+
return normalize_project_name(remote_name)
|
|
57
|
+
return normalize_project_name(workspace.name)
|