kepta 0.1.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.
- kepta/__init__.py +39 -0
- kepta/client.py +247 -0
- kepta-0.1.0.dist-info/METADATA +115 -0
- kepta-0.1.0.dist-info/RECORD +5 -0
- kepta-0.1.0.dist-info/WHEEL +4 -0
kepta/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""KEPTA — lokales Gedächtnis für KI-Agenten, von Python aus.
|
|
2
|
+
|
|
3
|
+
Dieses Paket ist der Client, nicht die App. KEPTA selbst läuft als Desktop-App
|
|
4
|
+
auf demselben Rechner; hier verbindest du dich damit.
|
|
5
|
+
|
|
6
|
+
from kepta import KeptaClient
|
|
7
|
+
|
|
8
|
+
kepta = KeptaClient() # findet die laufende Instanz von allein
|
|
9
|
+
kepta.save("Wohnort", "Alex wohnt in Hamburg.", tags=["personal"])
|
|
10
|
+
for hit in kepta.search("wo wohnt Alex"):
|
|
11
|
+
print(hit.memory.title, hit.score)
|
|
12
|
+
|
|
13
|
+
Alles bleibt auf dem Gerät: Der Server lauscht nur auf 127.0.0.1.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from .client import (
|
|
17
|
+
DEFAULT_URL,
|
|
18
|
+
KeptaClient,
|
|
19
|
+
KeptaError,
|
|
20
|
+
Memory,
|
|
21
|
+
MemoryType,
|
|
22
|
+
SearchHit,
|
|
23
|
+
data_dir,
|
|
24
|
+
discover_url,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"KeptaClient",
|
|
29
|
+
"KeptaError",
|
|
30
|
+
"Memory",
|
|
31
|
+
"MemoryType",
|
|
32
|
+
"SearchHit",
|
|
33
|
+
"discover_url",
|
|
34
|
+
"data_dir",
|
|
35
|
+
"DEFAULT_URL",
|
|
36
|
+
"__version__",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
__version__ = "0.1.0"
|
kepta/client.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Python-Client für ein lokal laufendes KEPTA.
|
|
2
|
+
|
|
3
|
+
KEPTA selbst ist eine Desktop-App (Electron). Dieses Paket installiert sie nicht —
|
|
4
|
+
es spricht mit der HTTP-API der laufenden Instanz, damit Python-Agenten dasselbe
|
|
5
|
+
Gedächtnis nutzen wie Claude Desktop oder Cursor.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Iterable, Literal
|
|
16
|
+
from urllib.error import HTTPError, URLError
|
|
17
|
+
from urllib.parse import urlencode
|
|
18
|
+
from urllib.request import Request, urlopen
|
|
19
|
+
|
|
20
|
+
MemoryType = Literal["semantic", "episodic", "procedural"]
|
|
21
|
+
|
|
22
|
+
DEFAULT_URL = "http://127.0.0.1:3000"
|
|
23
|
+
ENDPOINT_FILE = "endpoint.json"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class KeptaError(RuntimeError):
|
|
27
|
+
"""KEPTA war nicht erreichbar oder hat einen Fehler zurückgegeben."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def data_dir() -> Path:
|
|
31
|
+
"""Datenverzeichnis von KEPTA. `KEPTA_DATA_DIR` hat Vorrang, sonst ~/.kepta."""
|
|
32
|
+
override = os.environ.get("KEPTA_DATA_DIR")
|
|
33
|
+
return Path(override) if override else Path.home() / ".kepta"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def discover_url() -> str:
|
|
37
|
+
"""Findet die laufende Instanz.
|
|
38
|
+
|
|
39
|
+
Reihenfolge: `KEPTA_URL`, dann die Adressdatei, die der Server beim Start
|
|
40
|
+
schreibt, sonst der Entwicklungs-Standardport. Die gepackte App wählt einen
|
|
41
|
+
zufälligen Port — ohne die Datei wäre sie nicht auffindbar.
|
|
42
|
+
"""
|
|
43
|
+
env = os.environ.get("KEPTA_URL")
|
|
44
|
+
if env:
|
|
45
|
+
return env.rstrip("/")
|
|
46
|
+
try:
|
|
47
|
+
raw = (data_dir() / ENDPOINT_FILE).read_text(encoding="utf-8")
|
|
48
|
+
url = json.loads(raw).get("url")
|
|
49
|
+
if isinstance(url, str) and url:
|
|
50
|
+
return url.rstrip("/")
|
|
51
|
+
except (OSError, ValueError):
|
|
52
|
+
pass
|
|
53
|
+
return DEFAULT_URL
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class Memory:
|
|
58
|
+
"""Eine Erinnerung. Zeitstempel sind Millisekunden seit Epoch."""
|
|
59
|
+
|
|
60
|
+
id: str
|
|
61
|
+
title: str
|
|
62
|
+
content: str
|
|
63
|
+
tags: list[str] = field(default_factory=list)
|
|
64
|
+
type: MemoryType = "semantic"
|
|
65
|
+
scope: str = "user"
|
|
66
|
+
confidence: float | None = None
|
|
67
|
+
valid_from: int | None = None
|
|
68
|
+
valid_to: int | None = None
|
|
69
|
+
superseded_by: str | None = None
|
|
70
|
+
deleted_at: int | None = None
|
|
71
|
+
created_at: int | None = None
|
|
72
|
+
updated_at: int | None = None
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def is_expired(self) -> bool:
|
|
76
|
+
"""Gültigkeit abgelaufen? Solche Treffer wertet KEPTA im Ranking ab."""
|
|
77
|
+
return self.valid_to is not None and self.valid_to < time.time() * 1000
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def is_superseded(self) -> bool:
|
|
81
|
+
"""Wurde durch eine neuere Erinnerung ersetzt."""
|
|
82
|
+
return bool(self.superseded_by)
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def from_api(cls, d: dict[str, Any]) -> "Memory":
|
|
86
|
+
return cls(
|
|
87
|
+
id=str(d.get("id", "")),
|
|
88
|
+
title=str(d.get("title", "")),
|
|
89
|
+
content=str(d.get("content", "")),
|
|
90
|
+
tags=list(d.get("tags") or []),
|
|
91
|
+
type=d.get("type") or "semantic",
|
|
92
|
+
scope=d.get("scope") or "user",
|
|
93
|
+
confidence=d.get("confidence"),
|
|
94
|
+
valid_from=d.get("validFrom"),
|
|
95
|
+
valid_to=d.get("validTo"),
|
|
96
|
+
superseded_by=d.get("supersededBy"),
|
|
97
|
+
deleted_at=d.get("deletedAt"),
|
|
98
|
+
created_at=d.get("createdAt"),
|
|
99
|
+
updated_at=d.get("updatedAt"),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True)
|
|
104
|
+
class SearchHit:
|
|
105
|
+
"""Ein Suchtreffer mit den Einzelwerten der Suchspuren."""
|
|
106
|
+
|
|
107
|
+
memory: Memory
|
|
108
|
+
score: float
|
|
109
|
+
vector_score: float = 0.0
|
|
110
|
+
lexical_score: float = 0.0
|
|
111
|
+
|
|
112
|
+
@classmethod
|
|
113
|
+
def from_api(cls, d: dict[str, Any]) -> "SearchHit":
|
|
114
|
+
return cls(
|
|
115
|
+
memory=Memory.from_api(d.get("memory") or {}),
|
|
116
|
+
score=float(d.get("score") or 0.0),
|
|
117
|
+
vector_score=float(d.get("cosineScore") or 0.0),
|
|
118
|
+
lexical_score=float(d.get("bm25Score") or 0.0),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class KeptaClient:
|
|
123
|
+
"""Sprechverbindung zu einer laufenden KEPTA-Instanz.
|
|
124
|
+
|
|
125
|
+
>>> kepta = KeptaClient()
|
|
126
|
+
>>> kepta.save("Rezept Carbonara", "Guanciale, Pecorino, Eigelb.", tags=["kochen"])
|
|
127
|
+
>>> [h.memory.title for h in kepta.search("was koche ich mit Nudeln")]
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def __init__(self, url: str | None = None, timeout: float = 20.0) -> None:
|
|
131
|
+
self.url = (url or discover_url()).rstrip("/")
|
|
132
|
+
self.timeout = timeout
|
|
133
|
+
|
|
134
|
+
# ---------- HTTP ----------
|
|
135
|
+
|
|
136
|
+
def _request(self, method: str, path: str, body: Any = None, params: dict[str, Any] | None = None) -> Any:
|
|
137
|
+
target = f"{self.url}{path}"
|
|
138
|
+
if params:
|
|
139
|
+
cleaned = {k: v for k, v in params.items() if v is not None}
|
|
140
|
+
if cleaned:
|
|
141
|
+
target += "?" + urlencode(cleaned)
|
|
142
|
+
data = json.dumps(body).encode("utf-8") if body is not None else None
|
|
143
|
+
req = Request(target, data=data, method=method)
|
|
144
|
+
req.add_header("Content-Type", "application/json")
|
|
145
|
+
try:
|
|
146
|
+
with urlopen(req, timeout=self.timeout) as res:
|
|
147
|
+
raw = res.read().decode("utf-8")
|
|
148
|
+
return json.loads(raw) if raw else None
|
|
149
|
+
except HTTPError as e:
|
|
150
|
+
detail = e.read().decode("utf-8", "replace")[:400]
|
|
151
|
+
raise KeptaError(f"{method} {path} scheiterte ({e.code}): {detail}") from e
|
|
152
|
+
except (URLError, TimeoutError) as e:
|
|
153
|
+
raise KeptaError(
|
|
154
|
+
f"KEPTA unter {self.url} nicht erreichbar. Läuft die App? "
|
|
155
|
+
f"Alternativ KEPTA_URL setzen. Ursache: {e}"
|
|
156
|
+
) from e
|
|
157
|
+
|
|
158
|
+
# ---------- Lesen ----------
|
|
159
|
+
|
|
160
|
+
def health(self) -> dict[str, Any]:
|
|
161
|
+
"""Statusabfrage — Version, Anzahl Knoten, Pfad der Datenbank."""
|
|
162
|
+
return self._request("GET", "/api/health")
|
|
163
|
+
|
|
164
|
+
def is_alive(self) -> bool:
|
|
165
|
+
"""True, wenn eine Instanz antwortet."""
|
|
166
|
+
try:
|
|
167
|
+
return bool(self.health().get("ok"))
|
|
168
|
+
except KeptaError:
|
|
169
|
+
return False
|
|
170
|
+
|
|
171
|
+
def list(self, *, trash: bool = False) -> list[Memory]:
|
|
172
|
+
"""Alle Erinnerungen. Mit `trash=True` stattdessen den Papierkorb."""
|
|
173
|
+
data = self._request("GET", "/api/memories", params={"trash": "1" if trash else None})
|
|
174
|
+
return [Memory.from_api(d) for d in (data or [])]
|
|
175
|
+
|
|
176
|
+
def search(
|
|
177
|
+
self,
|
|
178
|
+
query: str,
|
|
179
|
+
*,
|
|
180
|
+
top_k: int = 5,
|
|
181
|
+
tags: Iterable[str] | None = None,
|
|
182
|
+
type: MemoryType | None = None,
|
|
183
|
+
scope: str | None = None,
|
|
184
|
+
) -> list[SearchHit]:
|
|
185
|
+
"""Hybride Suche: Volltext, Vektoren und Wissensgraph, per RRF verschmolzen."""
|
|
186
|
+
body: dict[str, Any] = {"query": query, "topK": max(1, min(int(top_k), 100))}
|
|
187
|
+
if tags:
|
|
188
|
+
body["tags"] = list(tags)
|
|
189
|
+
if type:
|
|
190
|
+
body["type"] = type
|
|
191
|
+
if scope:
|
|
192
|
+
body["scope"] = scope
|
|
193
|
+
data = self._request("POST", "/api/search", body)
|
|
194
|
+
return [SearchHit.from_api(d) for d in (data or {}).get("results", [])]
|
|
195
|
+
|
|
196
|
+
def graph(self) -> dict[str, Any]:
|
|
197
|
+
"""Entitäten und Relationen des Wissensgraphen."""
|
|
198
|
+
return self._request("GET", "/api/graph")
|
|
199
|
+
|
|
200
|
+
# ---------- Schreiben ----------
|
|
201
|
+
|
|
202
|
+
def save(
|
|
203
|
+
self,
|
|
204
|
+
title: str,
|
|
205
|
+
content: str,
|
|
206
|
+
*,
|
|
207
|
+
tags: Iterable[str] | None = None,
|
|
208
|
+
type: MemoryType | None = None,
|
|
209
|
+
confidence: float | None = None,
|
|
210
|
+
valid_from: int | None = None,
|
|
211
|
+
valid_to: int | None = None,
|
|
212
|
+
) -> Memory:
|
|
213
|
+
"""Legt eine Erinnerung an."""
|
|
214
|
+
body: dict[str, Any] = {"title": title, "content": content}
|
|
215
|
+
if tags is not None:
|
|
216
|
+
body["tags"] = list(tags)
|
|
217
|
+
if type is not None:
|
|
218
|
+
body["type"] = type
|
|
219
|
+
if confidence is not None:
|
|
220
|
+
body["confidence"] = max(0.0, min(1.0, float(confidence)))
|
|
221
|
+
if valid_from is not None:
|
|
222
|
+
body["validFrom"] = valid_from
|
|
223
|
+
if valid_to is not None:
|
|
224
|
+
body["validTo"] = valid_to
|
|
225
|
+
data = self._request("POST", "/api/memories", body)
|
|
226
|
+
return Memory.from_api((data or {}).get("memory") or data or {})
|
|
227
|
+
|
|
228
|
+
def update(self, memory_id: str, **patch: Any) -> Memory:
|
|
229
|
+
"""Ändert Felder einer Erinnerung. Schlüssel wie bei `save`."""
|
|
230
|
+
mapping = {"valid_from": "validFrom", "valid_to": "validTo"}
|
|
231
|
+
body: dict[str, Any] = {"id": memory_id}
|
|
232
|
+
for key, value in patch.items():
|
|
233
|
+
body[mapping.get(key, key)] = list(value) if key == "tags" and value is not None else value
|
|
234
|
+
data = self._request("POST", "/api/memories", body)
|
|
235
|
+
return Memory.from_api((data or {}).get("memory") or {})
|
|
236
|
+
|
|
237
|
+
def delete(self, memory_id: str, *, permanent: bool = False) -> bool:
|
|
238
|
+
"""In den Papierkorb. Mit `permanent=True` endgültig — nicht umkehrbar."""
|
|
239
|
+
data = self._request(
|
|
240
|
+
"DELETE", f"/api/memories/{memory_id}", params={"permanent": "1" if permanent else None}
|
|
241
|
+
)
|
|
242
|
+
return bool((data or {}).get("ok"))
|
|
243
|
+
|
|
244
|
+
def restore(self, memory_id: str) -> bool:
|
|
245
|
+
"""Holt eine Erinnerung aus dem Papierkorb zurück."""
|
|
246
|
+
data = self._request("POST", f"/api/memories/{memory_id}/restore")
|
|
247
|
+
return bool((data or {}).get("ok", True))
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: kepta
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python-Client für KEPTA — das lokale Gedächtnis für KI-Agenten. Ohne Cloud, ohne Konto.
|
|
5
|
+
Project-URL: Homepage, https://github.com/DamianTodorovic/kepta
|
|
6
|
+
Project-URL: Repository, https://github.com/DamianTodorovic/kepta
|
|
7
|
+
Project-URL: Issues, https://github.com/DamianTodorovic/kepta/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/DamianTodorovic/kepta/blob/main/CHANGELOG.md
|
|
9
|
+
Author-email: KEPTA <hello@kepta.app>
|
|
10
|
+
License: MIT
|
|
11
|
+
Keywords: agents,ai,knowledge-graph,local-first,mcp,memory,privacy,rag
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# kepta
|
|
30
|
+
|
|
31
|
+
**Python-Client für [KEPTA](https://github.com/DamianTodorovic/kepta) — dem lokalen Gedächtnis für KI-Agenten.**
|
|
32
|
+
|
|
33
|
+
Deine Agenten vergessen dich nach jedem Gespräch. KEPTA behebt das — mit einer SQLite-Datei auf deinem Rechner. Kein Konto, keine Cloud, keine Telemetrie.
|
|
34
|
+
|
|
35
|
+
Dieses Paket ist der **Client, nicht die App**. KEPTA läuft als Desktop-Anwendung auf demselben Gerät; hier verbindest du dich damit.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install kepta
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## In dreißig Sekunden
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from kepta import KeptaClient
|
|
45
|
+
|
|
46
|
+
kepta = KeptaClient() # findet die laufende Instanz von allein
|
|
47
|
+
|
|
48
|
+
kepta.save("Rezept Carbonara", "Guanciale, Pecorino, Eigelb. Keine Sahne.", tags=["kochen"])
|
|
49
|
+
|
|
50
|
+
for hit in kepta.search("was koche ich mit Nudeln"):
|
|
51
|
+
print(f"{hit.score:.2f} {hit.memory.title}")
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Das Wort *Nudeln* steht in der Notiz nicht. Gefunden wird sie trotzdem — die Suche kombiniert Volltext, Vektoren und Wissensgraph per Reciprocal Rank Fusion.
|
|
55
|
+
|
|
56
|
+
## Warum das interessant ist
|
|
57
|
+
|
|
58
|
+
**Dasselbe Gedächtnis wie deine Agenten.** Claude Desktop und Cursor sprechen über MCP mit derselben Datenbank. Was dein Python-Skript schreibt, weiß Claude in der nächsten Antwort.
|
|
59
|
+
|
|
60
|
+
**Erinnerungen altern.** Jede hat Typ, Gültigkeit und Konfidenz. Zieht jemand um, verdrängt die neue Adresse die alte — die alte bleibt als Historie und fällt im Ranking ab. Widersprüche stapeln sich nicht.
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
alt = kepta.save("Wohnort", "Alex wohnt in Hamburg.")
|
|
64
|
+
kepta.update(alt.id, valid_to=1788400000000) # abgelaufen ab diesem Zeitpunkt
|
|
65
|
+
kepta.save("Wohnort aktuell", "Alex wohnt jetzt in Leipzig.")
|
|
66
|
+
|
|
67
|
+
m = kepta.list()[0]
|
|
68
|
+
m.is_expired, m.is_superseded # Zustand direkt am Objekt
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
**Keine Abhängigkeiten.** Nur die Standardbibliothek. Ein Gedächtnis, das Privatsphäre verspricht, sollte keinen fremden Code in deinen Prozess holen.
|
|
72
|
+
|
|
73
|
+
## Die Verbindung finden
|
|
74
|
+
|
|
75
|
+
`KeptaClient()` sucht in dieser Reihenfolge:
|
|
76
|
+
|
|
77
|
+
1. Umgebungsvariable `KEPTA_URL`
|
|
78
|
+
2. `~/.kepta/endpoint.json` — die Adressdatei, die KEPTA beim Start schreibt
|
|
79
|
+
3. `http://127.0.0.1:3000` als Rückfall für den Entwicklungsmodus
|
|
80
|
+
|
|
81
|
+
Schritt 2 ist der wichtige: Die gepackte App wählt einen zufälligen Port. Explizit geht natürlich auch:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
kepta = KeptaClient("http://127.0.0.1:52341")
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Läuft nichts, bekommst du keinen kryptischen Netzwerkfehler, sondern einen Satz, der sagt, was zu tun ist:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
if not kepta.is_alive():
|
|
91
|
+
print("KEPTA läuft nicht — App starten oder KEPTA_URL setzen.")
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Was der Client kann
|
|
95
|
+
|
|
96
|
+
| Methode | Zweck |
|
|
97
|
+
|---|---|
|
|
98
|
+
| `health()` · `is_alive()` | Status, Version, Anzahl Knoten |
|
|
99
|
+
| `list(trash=False)` | Alle Erinnerungen oder den Papierkorb |
|
|
100
|
+
| `search(query, top_k, tags, type, scope)` | Hybride Suche mit temporaler Gewichtung |
|
|
101
|
+
| `save(title, content, …)` | Anlegen — Typ, Tags, Konfidenz, Gültigkeit |
|
|
102
|
+
| `update(id, **felder)` | Ändern; `valid_to=` statt `validTo=` |
|
|
103
|
+
| `delete(id, permanent=False)` | Papierkorb, auf Wunsch endgültig |
|
|
104
|
+
| `restore(id)` | Aus dem Papierkorb zurückholen |
|
|
105
|
+
| `graph()` | Entitäten und Relationen |
|
|
106
|
+
|
|
107
|
+
`Memory` und `SearchHit` sind eingefrorene Dataclasses mit Typannotationen. `SearchHit` zeigt neben dem Gesamtwert auch die Einzelspuren `vector_score` und `lexical_score`.
|
|
108
|
+
|
|
109
|
+
## KEPTA installieren
|
|
110
|
+
|
|
111
|
+
Die App gibt es für macOS, Windows und Linux unter [Releases](https://github.com/DamianTodorovic/kepta/releases) — jeweils Intel und ARM. MIT-Lizenz, kostenlos.
|
|
112
|
+
|
|
113
|
+
## Lizenz
|
|
114
|
+
|
|
115
|
+
MIT
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
kepta/__init__.py,sha256=fMnpqWFVvFGK6JdXEQyBSiVmUL8coGqMqaKmcc7hqyY,893
|
|
2
|
+
kepta/client.py,sha256=_1ZIUf9MLLLmnhfhBcPjxYYr6CtddAK_EhEvkTujq8o,8947
|
|
3
|
+
kepta-0.1.0.dist-info/METADATA,sha256=91uBDNQoHFMzNA1FHpiSMtPhQYWLGyAXQjAbDeDBdFQ,4754
|
|
4
|
+
kepta-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
5
|
+
kepta-0.1.0.dist-info/RECORD,,
|