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 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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any