kore-memory 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.
- kore_memory-0.2.0.dist-info/METADATA +196 -0
- kore_memory-0.2.0.dist-info/RECORD +16 -0
- kore_memory-0.2.0.dist-info/WHEEL +4 -0
- kore_memory-0.2.0.dist-info/entry_points.txt +2 -0
- kore_memory-0.2.0.dist-info/licenses/LICENSE +21 -0
- src/__init__.py +1 -0
- src/auth.py +107 -0
- src/cli.py +37 -0
- src/compressor.py +146 -0
- src/database.py +84 -0
- src/decay.py +69 -0
- src/embedder.py +50 -0
- src/main.py +138 -0
- src/models.py +68 -0
- src/repository.py +269 -0
- src/scorer.py +70 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kore-memory
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: The memory layer that thinks like a human: remembers what matters, forgets what doesn't, and never calls home.
|
|
5
|
+
Project-URL: Homepage, https://github.com/juanauriti/kore-memory
|
|
6
|
+
Project-URL: Repository, https://github.com/juanauriti/kore-memory
|
|
7
|
+
Project-URL: Issues, https://github.com/juanauriti/kore-memory/issues
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: agents,ai,embeddings,llm,memory,rag,semantic-search
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Requires-Dist: fastapi>=0.115.0
|
|
20
|
+
Requires-Dist: httpx>=0.27.0
|
|
21
|
+
Requires-Dist: pydantic>=2.7.0
|
|
22
|
+
Requires-Dist: uvicorn[standard]>=0.30.0
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: httpx>=0.27.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
27
|
+
Provides-Extra: semantic
|
|
28
|
+
Requires-Dist: sentence-transformers>=3.0.0; extra == 'semantic'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# Kore Memory
|
|
32
|
+
|
|
33
|
+
> **The memory layer that thinks like a human: remembers what matters, forgets what doesn't, and never calls home.**
|
|
34
|
+
|
|
35
|
+
[](LICENSE)
|
|
36
|
+
[](https://python.org)
|
|
37
|
+
[]()
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Why Kore?
|
|
42
|
+
|
|
43
|
+
Every AI agent memory tool out there has the same problem: they remember everything forever, require cloud APIs, or need an LLM just to decide what's worth storing.
|
|
44
|
+
|
|
45
|
+
**Kore is different.**
|
|
46
|
+
|
|
47
|
+
| Feature | Kore | Mem0 | Letta | Memori |
|
|
48
|
+
|---|---|---|---|---|
|
|
49
|
+
| Runs fully offline | ✅ | ❌ | ❌ | ❌ |
|
|
50
|
+
| No LLM required | ✅ | ❌ | ❌ | ✅ |
|
|
51
|
+
| Memory Decay (Ebbinghaus) | ✅ | ❌ | ❌ | ❌ |
|
|
52
|
+
| Auto-importance scoring | ✅ local | ✅ via LLM | ❌ | ❌ |
|
|
53
|
+
| Memory Compression | ✅ | ❌ | ❌ | ❌ |
|
|
54
|
+
| Semantic search (50+ langs) | ✅ local | ✅ via API | ✅ | ✅ |
|
|
55
|
+
| Timeline API | ✅ | ❌ | ❌ | ❌ |
|
|
56
|
+
| Access reinforcement | ✅ | ❌ | ❌ | ❌ |
|
|
57
|
+
| Install in 2 minutes | ✅ | ❌ | ❌ | ❌ |
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## How It Works
|
|
62
|
+
|
|
63
|
+
Kore models memory the way the human brain does:
|
|
64
|
+
|
|
65
|
+
1. **Save** — store a memory with optional category and importance
|
|
66
|
+
2. **Auto-score** — Kore calculates importance locally using content analysis (no API calls)
|
|
67
|
+
3. **Decay** — memories fade over time using the [Ebbinghaus forgetting curve](https://en.wikipedia.org/wiki/Forgetting_curve)
|
|
68
|
+
4. **Reinforce** — retrieving a memory resets its clock and boosts its score
|
|
69
|
+
5. **Compress** — similar memories are automatically merged to keep the DB lean
|
|
70
|
+
6. **Search** — semantic search in any language, filtered by relevance and freshness
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Quickstart
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
pip install kore-memory
|
|
78
|
+
pip install kore-memory[semantic] # + multilingual embeddings (50+ languages)
|
|
79
|
+
|
|
80
|
+
kore # starts server on http://localhost:8765
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Save a memory
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
curl -X POST http://localhost:8765/save \
|
|
87
|
+
-H "Content-Type: application/json" \
|
|
88
|
+
-d '{"content": "User prefers concise responses", "category": "preference"}'
|
|
89
|
+
# → {"id": 1, "importance": 4, "message": "Memory saved"}
|
|
90
|
+
# importance was auto-scored: "preference" category + keyword "prefers" → 4
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Search (any language)
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
# English query finds Italian content, French content, etc.
|
|
97
|
+
curl "http://localhost:8765/search?q=user+preferences&limit=5"
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Run decay pass (call daily via cron)
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
curl -X POST http://localhost:8765/decay/run
|
|
104
|
+
# → {"updated": 42, "message": "Decay pass complete"}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Compress similar memories
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
curl -X POST http://localhost:8765/compress
|
|
111
|
+
# → {"clusters_found": 3, "memories_merged": 7, "new_records_created": 3}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Timeline: what did I know about X over time?
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
curl "http://localhost:8765/timeline?subject=project+alpha"
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## Memory Decay
|
|
123
|
+
|
|
124
|
+
Kore uses the **Ebbinghaus forgetting curve** to assign each memory a `decay_score` between 0.0 and 1.0:
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
decay = e^(-t * ln(2) / half_life)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Where:
|
|
131
|
+
- `t` = days since last access
|
|
132
|
+
- `half_life` = base days before 50% decay, adjusted by importance level
|
|
133
|
+
|
|
134
|
+
| Importance | Half-life | Meaning |
|
|
135
|
+
|---|---|---|
|
|
136
|
+
| 1 (low) | 7 days | Casual notes |
|
|
137
|
+
| 2 (normal) | 14 days | General context |
|
|
138
|
+
| 3 (important) | 30 days | Project info |
|
|
139
|
+
| 4 (high) | 90 days | Critical decisions |
|
|
140
|
+
| 5 (critical) | 365 days | Passwords, rules, never forget |
|
|
141
|
+
|
|
142
|
+
Every time a memory is retrieved, its `access_count` increases and its half-life is extended by 15% — just like spaced repetition in human learning.
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## Auto-Importance Scoring
|
|
147
|
+
|
|
148
|
+
When you save a memory without an explicit importance level, Kore scores it automatically:
|
|
149
|
+
|
|
150
|
+
- **Category baseline** — `preference` starts at 4, `finance` at 3, `general` at 1
|
|
151
|
+
- **Keyword signals** — words like `password`, `token`, `urgente` → importance 5
|
|
152
|
+
- **Content length** — detailed content gets a small boost
|
|
153
|
+
|
|
154
|
+
Zero LLM calls. Zero API costs.
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## API Reference
|
|
159
|
+
|
|
160
|
+
| Method | Endpoint | Description |
|
|
161
|
+
|---|---|---|
|
|
162
|
+
| `POST` | `/save` | Save a memory |
|
|
163
|
+
| `GET` | `/search` | Semantic search (any language) |
|
|
164
|
+
| `GET` | `/timeline` | Chronological history for a subject |
|
|
165
|
+
| `DELETE` | `/memories/{id}` | Delete a memory |
|
|
166
|
+
| `POST` | `/decay/run` | Update all decay scores |
|
|
167
|
+
| `POST` | `/compress` | Merge similar memories |
|
|
168
|
+
| `GET` | `/health` | Health check + capabilities |
|
|
169
|
+
|
|
170
|
+
Full interactive docs: `http://localhost:8765/docs`
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## Categories
|
|
175
|
+
|
|
176
|
+
`general` · `project` · `trading` · `finance` · `person` · `preference` · `task` · `decision`
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## Requirements
|
|
181
|
+
|
|
182
|
+
- Python 3.11+
|
|
183
|
+
- SQLite (built into Python)
|
|
184
|
+
- Optional: `sentence-transformers` for semantic search
|
|
185
|
+
|
|
186
|
+
No PostgreSQL. No Redis. No Docker. No API keys.
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## License
|
|
191
|
+
|
|
192
|
+
MIT — use it, fork it, build on it.
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
*Built with ❤️ for AI agents that deserve better memory.*
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
src/__init__.py,sha256=fiq5GIGazcLLUkNGCDmG05r1ELglKiOWFDXdB0n39wY,15
|
|
2
|
+
src/auth.py,sha256=yLTOrBtY3lfqX7PWfk0btUlQcU54-9mu_HEIK9ThWxM,3513
|
|
3
|
+
src/cli.py,sha256=1JPGeKRPTIvadhb377RQggstQjKgdYDkM5WmoSRw_lk,1046
|
|
4
|
+
src/compressor.py,sha256=PxxBaup2UWtCHeQiaM3NcAiwu1AXyhku2CImmXXrxo8,4328
|
|
5
|
+
src/database.py,sha256=mVg19jUXNOhFx9lET-TEQHtA2qiHwmD8kn0wel2h70w,3525
|
|
6
|
+
src/decay.py,sha256=AwYnekog7u21QNjLr9xs-lb0-vC4obTedkfYwLp0yRc,2416
|
|
7
|
+
src/embedder.py,sha256=dt9KKEd6ekuHSVoMMKfDgVPV15xW3SQ42p_IkHe6nQs,1425
|
|
8
|
+
src/main.py,sha256=G8QBudnKrfqZmnBZBXu9mSrv2EfshIvgxEjphG6L_Jo,4552
|
|
9
|
+
src/models.py,sha256=ye-l-b0blVsTHmXcx4ZqSk-tbLVwFZj35gG94WeJY0w,1430
|
|
10
|
+
src/repository.py,sha256=jFr2D9lds_m5n1yQCmJYw7Q4bSU79movK2q1D9s7lfM,9410
|
|
11
|
+
src/scorer.py,sha256=d8pOOLIQXpGDWlJ9iGqmVc5mBGuwKTBScOkeZiOlxiw,2271
|
|
12
|
+
kore_memory-0.2.0.dist-info/METADATA,sha256=GBIRu20KTAaQqyOjHiQOsG2GZ4a2SOgupcbttgEp9Kw,6187
|
|
13
|
+
kore_memory-0.2.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
14
|
+
kore_memory-0.2.0.dist-info/entry_points.txt,sha256=sSks4ncnR0ur7xTqYrkhehtXy1K70wK-b2gVBYpAkhQ,39
|
|
15
|
+
kore_memory-0.2.0.dist-info/licenses/LICENSE,sha256=UJe81-OhbzRibujLXWpETnCKbGvlZsjbhJ6zuwjz8F8,1068
|
|
16
|
+
kore_memory-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Juan Auriti
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
src/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Kore package
|
src/auth.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Kore — Authentication & Authorization
|
|
3
|
+
API key validation + agent namespace isolation.
|
|
4
|
+
|
|
5
|
+
Config via environment variables:
|
|
6
|
+
KORE_API_KEY — master key (required in non-local mode)
|
|
7
|
+
KORE_LOCAL_ONLY — if "1", skip auth for 127.0.0.1 requests (default: "1")
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import secrets
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from fastapi import Header, HTTPException, Request, status
|
|
15
|
+
|
|
16
|
+
_KEY_FILE = Path(__file__).parent.parent / "data" / ".api_key"
|
|
17
|
+
|
|
18
|
+
# ── Key management ────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
def get_or_create_api_key() -> str:
|
|
21
|
+
"""
|
|
22
|
+
Load API key from env or file. Generate and persist one if missing.
|
|
23
|
+
Priority: KORE_API_KEY env → data/.api_key file → auto-generate
|
|
24
|
+
"""
|
|
25
|
+
env_key = os.getenv("KORE_API_KEY")
|
|
26
|
+
if env_key:
|
|
27
|
+
return env_key
|
|
28
|
+
|
|
29
|
+
if _KEY_FILE.exists():
|
|
30
|
+
return _KEY_FILE.read_text().strip()
|
|
31
|
+
|
|
32
|
+
# Auto-generate a secure key on first run
|
|
33
|
+
new_key = secrets.token_urlsafe(32)
|
|
34
|
+
_KEY_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
_KEY_FILE.write_text(new_key)
|
|
36
|
+
_KEY_FILE.chmod(0o600) # owner read/write only
|
|
37
|
+
print(f"\n🔑 Kore API key generated: {new_key}")
|
|
38
|
+
print(f" Saved to: {_KEY_FILE}")
|
|
39
|
+
print(f" Set KORE_API_KEY env var or use X-Kore-Key header.\n")
|
|
40
|
+
return new_key
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_API_KEY: str | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _loaded_key() -> str:
|
|
47
|
+
global _API_KEY
|
|
48
|
+
if _API_KEY is None:
|
|
49
|
+
_API_KEY = get_or_create_api_key()
|
|
50
|
+
return _API_KEY
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _is_local(request: Request) -> bool:
|
|
54
|
+
client_host = request.client.host if request.client else ""
|
|
55
|
+
# "testclient" is the FastAPI TestClient host — treat as local in tests
|
|
56
|
+
return client_host in ("127.0.0.1", "::1", "localhost", "testclient")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _local_only_mode() -> bool:
|
|
60
|
+
return os.getenv("KORE_LOCAL_ONLY", "1") == "1"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ── FastAPI dependency ────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
async def require_auth(
|
|
66
|
+
request: Request,
|
|
67
|
+
x_kore_key: str | None = Header(default=None, alias="X-Kore-Key"),
|
|
68
|
+
) -> str:
|
|
69
|
+
"""
|
|
70
|
+
FastAPI dependency: validates API key.
|
|
71
|
+
In local-only mode, skips auth for 127.0.0.1 requests.
|
|
72
|
+
Returns the validated API key (or 'local' for unauthenticated local requests).
|
|
73
|
+
"""
|
|
74
|
+
if _local_only_mode() and _is_local(request):
|
|
75
|
+
return "local"
|
|
76
|
+
|
|
77
|
+
if not x_kore_key:
|
|
78
|
+
raise HTTPException(
|
|
79
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
80
|
+
detail="Missing API key. Pass X-Kore-Key header.",
|
|
81
|
+
headers={"WWW-Authenticate": "ApiKey"},
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
if not secrets.compare_digest(x_kore_key, _loaded_key()):
|
|
85
|
+
raise HTTPException(
|
|
86
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
87
|
+
detail="Invalid API key.",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
return x_kore_key
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def get_agent_id(
|
|
94
|
+
request: Request,
|
|
95
|
+
x_agent_id: str | None = Header(default=None, alias="X-Agent-Id"),
|
|
96
|
+
) -> str:
|
|
97
|
+
"""
|
|
98
|
+
FastAPI dependency: extracts agent namespace.
|
|
99
|
+
Defaults to 'default' when not provided.
|
|
100
|
+
Agent IDs are sanitized to alphanumeric + dash/underscore only.
|
|
101
|
+
"""
|
|
102
|
+
agent_id = (x_agent_id or "default").strip()
|
|
103
|
+
# Sanitize: only allow safe chars
|
|
104
|
+
safe = "".join(c for c in agent_id if c.isalnum() or c in "-_")
|
|
105
|
+
if not safe:
|
|
106
|
+
safe = "default"
|
|
107
|
+
return safe[:64] # max 64 chars
|
src/cli.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Kore — CLI entry point
|
|
3
|
+
Usage: kore [--host HOST] [--port PORT] [--reload]
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main() -> None:
|
|
11
|
+
parser = argparse.ArgumentParser(
|
|
12
|
+
prog="kore",
|
|
13
|
+
description="Kore memory server — start the API.",
|
|
14
|
+
)
|
|
15
|
+
parser.add_argument("--host", default="127.0.0.1", help="Bind host (default: 127.0.0.1)")
|
|
16
|
+
parser.add_argument("--port", type=int, default=8765, help="Bind port (default: 8765)")
|
|
17
|
+
parser.add_argument("--reload", action="store_true", help="Enable auto-reload (dev mode)")
|
|
18
|
+
parser.add_argument("--log-level", default="warning", choices=["debug", "info", "warning", "error"])
|
|
19
|
+
args = parser.parse_args()
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
import uvicorn
|
|
23
|
+
except ImportError:
|
|
24
|
+
print("Error: uvicorn not found. Run: pip install kore-memory", file=sys.stderr)
|
|
25
|
+
sys.exit(1)
|
|
26
|
+
|
|
27
|
+
uvicorn.run(
|
|
28
|
+
"kore.src.main:app",
|
|
29
|
+
host=args.host,
|
|
30
|
+
port=args.port,
|
|
31
|
+
reload=args.reload,
|
|
32
|
+
log_level=args.log_level,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
if __name__ == "__main__":
|
|
37
|
+
main()
|
src/compressor.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Kore — Memory Compressor
|
|
3
|
+
Finds clusters of similar memories and merges them into a single richer record.
|
|
4
|
+
|
|
5
|
+
Strategy:
|
|
6
|
+
1. Load all memories without a compressed_into reference
|
|
7
|
+
2. For each pair, compute cosine similarity
|
|
8
|
+
3. Cluster memories with similarity > threshold
|
|
9
|
+
4. Merge each cluster into one record (union of content, max importance)
|
|
10
|
+
5. Mark originals as compressed_into the new record
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
from .database import get_connection
|
|
18
|
+
from .embedder import cosine_similarity, deserialize, embed, serialize
|
|
19
|
+
from .models import MemorySaveRequest
|
|
20
|
+
from .repository import save_memory
|
|
21
|
+
|
|
22
|
+
SIMILARITY_THRESHOLD = 0.88 # memories above this are considered duplicates
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class CompressionResult:
|
|
27
|
+
clusters_found: int
|
|
28
|
+
memories_merged: int
|
|
29
|
+
new_records_created: int
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def run_compression(agent_id: str = "default") -> CompressionResult:
|
|
33
|
+
"""
|
|
34
|
+
Full compression pass: find similar memories, merge clusters.
|
|
35
|
+
Returns a summary of what was done.
|
|
36
|
+
"""
|
|
37
|
+
memories = _load_compressible_memories(agent_id)
|
|
38
|
+
if len(memories) < 2:
|
|
39
|
+
return CompressionResult(0, 0, 0)
|
|
40
|
+
|
|
41
|
+
clusters = _find_clusters(memories)
|
|
42
|
+
if not clusters:
|
|
43
|
+
return CompressionResult(0, 0, 0)
|
|
44
|
+
|
|
45
|
+
merged = 0
|
|
46
|
+
created = 0
|
|
47
|
+
for cluster in clusters:
|
|
48
|
+
new_id = _merge_cluster(cluster, agent_id=agent_id)
|
|
49
|
+
if new_id:
|
|
50
|
+
merged += len(cluster)
|
|
51
|
+
created += 1
|
|
52
|
+
|
|
53
|
+
return CompressionResult(
|
|
54
|
+
clusters_found=len(clusters),
|
|
55
|
+
memories_merged=merged,
|
|
56
|
+
new_records_created=created,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _load_compressible_memories(agent_id: str = "default") -> list[dict]:
|
|
61
|
+
with get_connection() as conn:
|
|
62
|
+
rows = conn.execute(
|
|
63
|
+
"""
|
|
64
|
+
SELECT id, content, category, importance, embedding
|
|
65
|
+
FROM memories
|
|
66
|
+
WHERE compressed_into IS NULL AND embedding IS NOT NULL AND agent_id = ?
|
|
67
|
+
""",
|
|
68
|
+
(agent_id,),
|
|
69
|
+
).fetchall()
|
|
70
|
+
return [dict(r) for r in rows]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _find_clusters(memories: list[dict]) -> list[list[dict]]:
|
|
74
|
+
"""
|
|
75
|
+
Greedy clustering: group memories where any pair exceeds threshold.
|
|
76
|
+
Each memory belongs to at most one cluster.
|
|
77
|
+
"""
|
|
78
|
+
used = set()
|
|
79
|
+
clusters = []
|
|
80
|
+
|
|
81
|
+
for i, mem_a in enumerate(memories):
|
|
82
|
+
if mem_a["id"] in used:
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
vec_a = deserialize(mem_a["embedding"])
|
|
86
|
+
cluster = [mem_a]
|
|
87
|
+
|
|
88
|
+
for j, mem_b in enumerate(memories):
|
|
89
|
+
if i == j or mem_b["id"] in used:
|
|
90
|
+
continue
|
|
91
|
+
vec_b = deserialize(mem_b["embedding"])
|
|
92
|
+
if cosine_similarity(vec_a, vec_b) >= SIMILARITY_THRESHOLD:
|
|
93
|
+
cluster.append(mem_b)
|
|
94
|
+
|
|
95
|
+
if len(cluster) > 1:
|
|
96
|
+
for m in cluster:
|
|
97
|
+
used.add(m["id"])
|
|
98
|
+
clusters.append(cluster)
|
|
99
|
+
|
|
100
|
+
return clusters
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _merge_cluster(cluster: list[dict], agent_id: str = "default") -> int | None:
|
|
104
|
+
"""
|
|
105
|
+
Merge a cluster of memories into a single new record.
|
|
106
|
+
Returns the id of the new merged record, or None on failure.
|
|
107
|
+
"""
|
|
108
|
+
if not cluster:
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
# Build merged content: combine unique sentences
|
|
112
|
+
combined_parts = []
|
|
113
|
+
seen = set()
|
|
114
|
+
for mem in cluster:
|
|
115
|
+
for sentence in mem["content"].split("|"):
|
|
116
|
+
s = sentence.strip()
|
|
117
|
+
if s and s not in seen:
|
|
118
|
+
seen.add(s)
|
|
119
|
+
combined_parts.append(s)
|
|
120
|
+
|
|
121
|
+
merged_content = " | ".join(combined_parts)
|
|
122
|
+
if len(merged_content) > 4000:
|
|
123
|
+
merged_content = merged_content[:3997] + "..."
|
|
124
|
+
|
|
125
|
+
# Use the most common category and highest importance
|
|
126
|
+
categories = [m["category"] for m in cluster]
|
|
127
|
+
merged_category = max(set(categories), key=categories.count)
|
|
128
|
+
merged_importance = max(m["importance"] for m in cluster)
|
|
129
|
+
|
|
130
|
+
# Save the new merged record
|
|
131
|
+
req = MemorySaveRequest(
|
|
132
|
+
content=merged_content,
|
|
133
|
+
category=merged_category,
|
|
134
|
+
importance=merged_importance,
|
|
135
|
+
)
|
|
136
|
+
new_id = save_memory(req, agent_id=agent_id)
|
|
137
|
+
|
|
138
|
+
# Mark originals as compressed
|
|
139
|
+
ids = [m["id"] for m in cluster]
|
|
140
|
+
with get_connection() as conn:
|
|
141
|
+
conn.executemany(
|
|
142
|
+
"UPDATE memories SET compressed_into = ? WHERE id = ?",
|
|
143
|
+
[(new_id, mid) for mid in ids],
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
return new_id
|
src/database.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Kore - Database layer
|
|
3
|
+
Handles SQLite connection and schema initialization.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import sqlite3
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
DB_PATH = Path(__file__).parent.parent / "data" / "memory.db"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def init_db() -> None:
|
|
14
|
+
"""Initialize the database and create tables if they don't exist."""
|
|
15
|
+
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
16
|
+
# Ensure DB file exists before chmod
|
|
17
|
+
DB_PATH.touch(exist_ok=True)
|
|
18
|
+
DB_PATH.chmod(0o600) # owner only — protects memory data
|
|
19
|
+
|
|
20
|
+
with get_connection() as conn:
|
|
21
|
+
conn.executescript("""
|
|
22
|
+
CREATE TABLE IF NOT EXISTS memories (
|
|
23
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
24
|
+
agent_id TEXT NOT NULL DEFAULT 'default',
|
|
25
|
+
content TEXT NOT NULL,
|
|
26
|
+
category TEXT NOT NULL DEFAULT 'general',
|
|
27
|
+
importance INTEGER NOT NULL DEFAULT 1 CHECK (importance BETWEEN 1 AND 5),
|
|
28
|
+
decay_score REAL NOT NULL DEFAULT 1.0,
|
|
29
|
+
access_count INTEGER NOT NULL DEFAULT 0,
|
|
30
|
+
last_accessed TEXT DEFAULT NULL,
|
|
31
|
+
compressed_into INTEGER DEFAULT NULL REFERENCES memories(id),
|
|
32
|
+
embedding TEXT DEFAULT NULL,
|
|
33
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
34
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
CREATE INDEX IF NOT EXISTS idx_memories_agent ON memories (agent_id);
|
|
38
|
+
CREATE INDEX IF NOT EXISTS idx_memories_decay ON memories (decay_score DESC);
|
|
39
|
+
CREATE INDEX IF NOT EXISTS idx_memories_compressed ON memories (compressed_into);
|
|
40
|
+
|
|
41
|
+
CREATE INDEX IF NOT EXISTS idx_memories_category ON memories (category);
|
|
42
|
+
CREATE INDEX IF NOT EXISTS idx_memories_importance ON memories (importance DESC);
|
|
43
|
+
CREATE INDEX IF NOT EXISTS idx_memories_created_at ON memories (created_at DESC);
|
|
44
|
+
|
|
45
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts
|
|
46
|
+
USING fts5(content, category, content='memories', content_rowid='id', tokenize='unicode61');
|
|
47
|
+
|
|
48
|
+
CREATE TRIGGER IF NOT EXISTS memories_ai
|
|
49
|
+
AFTER INSERT ON memories BEGIN
|
|
50
|
+
INSERT INTO memories_fts (rowid, content, category)
|
|
51
|
+
VALUES (new.id, new.content, new.category);
|
|
52
|
+
END;
|
|
53
|
+
|
|
54
|
+
CREATE TRIGGER IF NOT EXISTS memories_ad
|
|
55
|
+
AFTER DELETE ON memories BEGIN
|
|
56
|
+
INSERT INTO memories_fts (memories_fts, rowid, content, category)
|
|
57
|
+
VALUES ('delete', old.id, old.content, old.category);
|
|
58
|
+
END;
|
|
59
|
+
|
|
60
|
+
CREATE TRIGGER IF NOT EXISTS memories_au
|
|
61
|
+
AFTER UPDATE ON memories BEGIN
|
|
62
|
+
INSERT INTO memories_fts (memories_fts, rowid, content, category)
|
|
63
|
+
VALUES ('delete', old.id, old.content, old.category);
|
|
64
|
+
INSERT INTO memories_fts (rowid, content, category)
|
|
65
|
+
VALUES (new.id, new.content, new.category);
|
|
66
|
+
END;
|
|
67
|
+
""")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@contextmanager
|
|
71
|
+
def get_connection():
|
|
72
|
+
"""Yield a thread-safe SQLite connection with WAL mode enabled."""
|
|
73
|
+
conn = sqlite3.connect(DB_PATH, check_same_thread=False)
|
|
74
|
+
conn.row_factory = sqlite3.Row
|
|
75
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
76
|
+
conn.execute("PRAGMA foreign_keys=ON")
|
|
77
|
+
try:
|
|
78
|
+
yield conn
|
|
79
|
+
conn.commit()
|
|
80
|
+
except Exception:
|
|
81
|
+
conn.rollback()
|
|
82
|
+
raise
|
|
83
|
+
finally:
|
|
84
|
+
conn.close()
|
src/decay.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Kore — Memory Decay Engine
|
|
3
|
+
Implements human-like forgetting: memories fade over time unless reinforced.
|
|
4
|
+
|
|
5
|
+
Formula inspired by Ebbinghaus forgetting curve:
|
|
6
|
+
decay = importance_factor * e^(-t / half_life)
|
|
7
|
+
|
|
8
|
+
Where:
|
|
9
|
+
t = days since last access
|
|
10
|
+
half_life = base days before 50% decay (modulated by importance)
|
|
11
|
+
importance_fac = 1.0 to 2.0 (higher importance = slower decay)
|
|
12
|
+
access_count = each retrieval resets the clock and boosts score
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
|
|
18
|
+
# Half-life in days per importance level (higher importance = longer half-life)
|
|
19
|
+
HALF_LIFE: dict[int, float] = {
|
|
20
|
+
1: 7.0, # low — fades in ~1 week
|
|
21
|
+
2: 14.0, # normal — fades in ~2 weeks
|
|
22
|
+
3: 30.0, # important — fades in ~1 month
|
|
23
|
+
4: 90.0, # very important — fades in ~3 months
|
|
24
|
+
5: 365.0, # critical — fades in ~1 year
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
# Access reinforcement: each retrieval extends half-life by this factor
|
|
28
|
+
ACCESS_BOOST = 0.15 # +15% half-life per access
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def compute_decay(
|
|
32
|
+
importance: int,
|
|
33
|
+
created_at: str,
|
|
34
|
+
last_accessed: str | None,
|
|
35
|
+
access_count: int,
|
|
36
|
+
) -> float:
|
|
37
|
+
"""
|
|
38
|
+
Compute current decay score (0.0–1.0) for a memory.
|
|
39
|
+
1.0 = perfectly fresh, 0.0 = completely faded.
|
|
40
|
+
"""
|
|
41
|
+
reference_time = last_accessed or created_at
|
|
42
|
+
try:
|
|
43
|
+
ref_dt = datetime.fromisoformat(reference_time).replace(tzinfo=timezone.utc)
|
|
44
|
+
except ValueError:
|
|
45
|
+
ref_dt = datetime.now(timezone.utc)
|
|
46
|
+
|
|
47
|
+
now = datetime.now(timezone.utc)
|
|
48
|
+
days_elapsed = max(0.0, (now - ref_dt).total_seconds() / 86400)
|
|
49
|
+
|
|
50
|
+
base_half_life = HALF_LIFE.get(importance, 14.0)
|
|
51
|
+
effective_half_life = base_half_life * (1 + ACCESS_BOOST * access_count)
|
|
52
|
+
|
|
53
|
+
# Ebbinghaus formula: R = e^(-t/S) where S is stability (half-life)
|
|
54
|
+
decay = math.exp(-days_elapsed * math.log(2) / effective_half_life)
|
|
55
|
+
return round(min(1.0, max(0.0, decay)), 4)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def effective_score(decay_score: float, importance: int) -> float:
|
|
59
|
+
"""
|
|
60
|
+
Combined relevance score used for ranking search results.
|
|
61
|
+
Balances semantic similarity with memory freshness and importance.
|
|
62
|
+
"""
|
|
63
|
+
importance_weight = importance / 5.0 # normalize to 0.2–1.0
|
|
64
|
+
return round(decay_score * importance_weight, 4)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def should_forget(decay_score: float, threshold: float = 0.05) -> bool:
|
|
68
|
+
"""Returns True when a memory has faded below the forgetting threshold."""
|
|
69
|
+
return decay_score < threshold
|
src/embedder.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Kore — Embedder (v2)
|
|
3
|
+
Lazy-loaded sentence embeddings using a small multilingual model.
|
|
4
|
+
Model: paraphrase-multilingual-MiniLM-L12-v2 (~120MB, supports Italian + English)
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from functools import lru_cache
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from sentence_transformers import SentenceTransformer
|
|
15
|
+
|
|
16
|
+
MODEL_NAME = "paraphrase-multilingual-MiniLM-L12-v2"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@lru_cache(maxsize=1)
|
|
20
|
+
def get_model() -> "SentenceTransformer":
|
|
21
|
+
"""Load model once and cache it in memory."""
|
|
22
|
+
from sentence_transformers import SentenceTransformer
|
|
23
|
+
return SentenceTransformer(MODEL_NAME)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def embed(text: str) -> list[float]:
|
|
27
|
+
"""Return embedding vector for a single text."""
|
|
28
|
+
model = get_model()
|
|
29
|
+
vector = model.encode(text, normalize_embeddings=True)
|
|
30
|
+
return vector.tolist()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def embed_batch(texts: list[str]) -> list[list[float]]:
|
|
34
|
+
"""Return embedding vectors for a list of texts."""
|
|
35
|
+
model = get_model()
|
|
36
|
+
vectors = model.encode(texts, normalize_embeddings=True, batch_size=32)
|
|
37
|
+
return [v.tolist() for v in vectors]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def cosine_similarity(a: list[float], b: list[float]) -> float:
|
|
41
|
+
"""Dot product of two normalized vectors = cosine similarity."""
|
|
42
|
+
return sum(x * y for x, y in zip(a, b))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def serialize(vector: list[float]) -> str:
|
|
46
|
+
return json.dumps(vector)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def deserialize(blob: str) -> list[float]:
|
|
50
|
+
return json.loads(blob)
|
src/main.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Kore — FastAPI application
|
|
3
|
+
Memory layer with decay, auto-scoring, compression, semantic search, and auth.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
7
|
+
|
|
8
|
+
from fastapi import Depends, FastAPI, HTTPException, Query
|
|
9
|
+
from fastapi.responses import JSONResponse
|
|
10
|
+
|
|
11
|
+
from .auth import get_agent_id, require_auth
|
|
12
|
+
from .database import init_db
|
|
13
|
+
from .models import (
|
|
14
|
+
CompressRunResponse,
|
|
15
|
+
DecayRunResponse,
|
|
16
|
+
MemorySaveRequest,
|
|
17
|
+
MemorySaveResponse,
|
|
18
|
+
MemorySearchResponse,
|
|
19
|
+
)
|
|
20
|
+
from .repository import (
|
|
21
|
+
delete_memory,
|
|
22
|
+
get_timeline,
|
|
23
|
+
run_decay_pass,
|
|
24
|
+
save_memory,
|
|
25
|
+
search_memories,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@asynccontextmanager
|
|
30
|
+
async def lifespan(app: FastAPI):
|
|
31
|
+
init_db()
|
|
32
|
+
yield
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
app = FastAPI(
|
|
36
|
+
title="Kore",
|
|
37
|
+
description=(
|
|
38
|
+
"The memory layer that thinks like a human: "
|
|
39
|
+
"remembers what matters, forgets what doesn't, and never calls home."
|
|
40
|
+
),
|
|
41
|
+
version="0.2.0",
|
|
42
|
+
lifespan=lifespan,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Shared auth dependencies
|
|
46
|
+
_Auth = Depends(require_auth)
|
|
47
|
+
_Agent = Depends(get_agent_id)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ── Core endpoints ────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
@app.post("/save", response_model=MemorySaveResponse, status_code=201)
|
|
53
|
+
def save(
|
|
54
|
+
req: MemorySaveRequest,
|
|
55
|
+
_: str = _Auth,
|
|
56
|
+
agent_id: str = _Agent,
|
|
57
|
+
) -> MemorySaveResponse:
|
|
58
|
+
"""Save a memory scoped to the requesting agent. Importance is auto-scored if omitted."""
|
|
59
|
+
from .scorer import auto_score
|
|
60
|
+
importance = req.importance if req.importance > 1 else auto_score(req.content, req.category)
|
|
61
|
+
memory_id = save_memory(req, agent_id=agent_id)
|
|
62
|
+
return MemorySaveResponse(id=memory_id, importance=importance)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@app.get("/search", response_model=MemorySearchResponse)
|
|
66
|
+
def search(
|
|
67
|
+
q: str = Query(..., min_length=1, description="Search query (any language)"),
|
|
68
|
+
limit: int = Query(5, ge=1, le=20),
|
|
69
|
+
category: str | None = Query(None),
|
|
70
|
+
semantic: bool = Query(True),
|
|
71
|
+
_: str = _Auth,
|
|
72
|
+
agent_id: str = _Agent,
|
|
73
|
+
) -> MemorySearchResponse:
|
|
74
|
+
"""Semantic search scoped to the requesting agent."""
|
|
75
|
+
results = search_memories(query=q, limit=limit, category=category, semantic=semantic, agent_id=agent_id)
|
|
76
|
+
return MemorySearchResponse(results=results, total=len(results))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@app.get("/timeline", response_model=MemorySearchResponse)
|
|
80
|
+
def timeline(
|
|
81
|
+
subject: str = Query(..., min_length=1),
|
|
82
|
+
limit: int = Query(20, ge=1, le=50),
|
|
83
|
+
_: str = _Auth,
|
|
84
|
+
agent_id: str = _Agent,
|
|
85
|
+
) -> MemorySearchResponse:
|
|
86
|
+
"""Chronological memory history for a subject, scoped to agent."""
|
|
87
|
+
results = get_timeline(subject=subject, limit=limit, agent_id=agent_id)
|
|
88
|
+
return MemorySearchResponse(results=results, total=len(results))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@app.delete("/memories/{memory_id}", status_code=204)
|
|
92
|
+
def delete(
|
|
93
|
+
memory_id: int,
|
|
94
|
+
_: str = _Auth,
|
|
95
|
+
agent_id: str = _Agent,
|
|
96
|
+
) -> None:
|
|
97
|
+
"""Delete a memory. Agents can only delete their own memories."""
|
|
98
|
+
if not delete_memory(memory_id, agent_id=agent_id):
|
|
99
|
+
raise HTTPException(status_code=404, detail="Memory not found")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ── Maintenance endpoints ─────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
@app.post("/decay/run", response_model=DecayRunResponse)
|
|
105
|
+
def decay_run(
|
|
106
|
+
_: str = _Auth,
|
|
107
|
+
agent_id: str = _Agent,
|
|
108
|
+
) -> DecayRunResponse:
|
|
109
|
+
"""Recalculate decay scores for agent's memories."""
|
|
110
|
+
updated = run_decay_pass(agent_id=agent_id)
|
|
111
|
+
return DecayRunResponse(updated=updated)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@app.post("/compress", response_model=CompressRunResponse)
|
|
115
|
+
def compress(
|
|
116
|
+
_: str = _Auth,
|
|
117
|
+
agent_id: str = _Agent,
|
|
118
|
+
) -> CompressRunResponse:
|
|
119
|
+
"""Merge similar memories for this agent."""
|
|
120
|
+
from .compressor import run_compression
|
|
121
|
+
result = run_compression(agent_id=agent_id)
|
|
122
|
+
return CompressRunResponse(
|
|
123
|
+
clusters_found=result.clusters_found,
|
|
124
|
+
memories_merged=result.memories_merged,
|
|
125
|
+
new_records_created=result.new_records_created,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ── Utility ───────────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
@app.get("/health")
|
|
132
|
+
def health() -> JSONResponse:
|
|
133
|
+
from .repository import _embeddings_available
|
|
134
|
+
return JSONResponse({
|
|
135
|
+
"status": "ok",
|
|
136
|
+
"version": app.version,
|
|
137
|
+
"semantic_search": _embeddings_available(),
|
|
138
|
+
})
|
src/models.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Kore — Pydantic models
|
|
3
|
+
Request/response schemas with validation.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field, field_validator
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
Category = Literal[
|
|
13
|
+
"general",
|
|
14
|
+
"project",
|
|
15
|
+
"trading",
|
|
16
|
+
"finance",
|
|
17
|
+
"person",
|
|
18
|
+
"preference",
|
|
19
|
+
"task",
|
|
20
|
+
"decision",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MemorySaveRequest(BaseModel):
|
|
25
|
+
content: str = Field(..., min_length=3, max_length=4000)
|
|
26
|
+
category: Category = Field("general")
|
|
27
|
+
importance: int = Field(1, ge=1, le=5, description="1=auto-scored, 2-5=explicit")
|
|
28
|
+
|
|
29
|
+
@field_validator("content")
|
|
30
|
+
@classmethod
|
|
31
|
+
def content_must_not_be_blank(cls, v: str) -> str:
|
|
32
|
+
if not v.strip():
|
|
33
|
+
raise ValueError("Content cannot be blank")
|
|
34
|
+
return v.strip()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class MemoryRecord(BaseModel):
|
|
38
|
+
id: int
|
|
39
|
+
content: str
|
|
40
|
+
category: str
|
|
41
|
+
importance: int
|
|
42
|
+
decay_score: float = 1.0
|
|
43
|
+
created_at: datetime
|
|
44
|
+
updated_at: datetime
|
|
45
|
+
score: float | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class MemorySaveResponse(BaseModel):
|
|
49
|
+
id: int
|
|
50
|
+
importance: int
|
|
51
|
+
message: str = "Memory saved"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class MemorySearchResponse(BaseModel):
|
|
55
|
+
results: list[MemoryRecord]
|
|
56
|
+
total: int
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class DecayRunResponse(BaseModel):
|
|
60
|
+
updated: int
|
|
61
|
+
message: str = "Decay pass complete"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class CompressRunResponse(BaseModel):
|
|
65
|
+
clusters_found: int
|
|
66
|
+
memories_merged: int
|
|
67
|
+
new_records_created: int
|
|
68
|
+
message: str = "Compression complete"
|
src/repository.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Kore — Repository layer
|
|
3
|
+
All database operations, keeping business logic out of routes.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
|
|
8
|
+
from .database import get_connection
|
|
9
|
+
from .decay import compute_decay, effective_score, should_forget
|
|
10
|
+
from .models import MemoryRecord, MemorySaveRequest
|
|
11
|
+
from .scorer import auto_score
|
|
12
|
+
|
|
13
|
+
_EMBEDDINGS_AVAILABLE: bool | None = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _embeddings_available() -> bool:
|
|
17
|
+
global _EMBEDDINGS_AVAILABLE
|
|
18
|
+
if _EMBEDDINGS_AVAILABLE is None:
|
|
19
|
+
try:
|
|
20
|
+
import sentence_transformers # noqa: F401
|
|
21
|
+
_EMBEDDINGS_AVAILABLE = True
|
|
22
|
+
except ImportError:
|
|
23
|
+
_EMBEDDINGS_AVAILABLE = False
|
|
24
|
+
return _EMBEDDINGS_AVAILABLE
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def save_memory(req: MemorySaveRequest, agent_id: str = "default") -> int:
|
|
28
|
+
"""
|
|
29
|
+
Persist a new memory record scoped to agent_id.
|
|
30
|
+
Auto-scores importance if not explicitly set.
|
|
31
|
+
Returns the new row id.
|
|
32
|
+
"""
|
|
33
|
+
importance = req.importance
|
|
34
|
+
if importance == 1:
|
|
35
|
+
importance = auto_score(req.content, req.category)
|
|
36
|
+
|
|
37
|
+
embedding_blob = None
|
|
38
|
+
if _embeddings_available():
|
|
39
|
+
from .embedder import embed, serialize
|
|
40
|
+
embedding_blob = serialize(embed(req.content))
|
|
41
|
+
|
|
42
|
+
with get_connection() as conn:
|
|
43
|
+
cursor = conn.execute(
|
|
44
|
+
"""
|
|
45
|
+
INSERT INTO memories (agent_id, content, category, importance, embedding)
|
|
46
|
+
VALUES (:agent_id, :content, :category, :importance, :embedding)
|
|
47
|
+
""",
|
|
48
|
+
{
|
|
49
|
+
"agent_id": agent_id,
|
|
50
|
+
"content": req.content,
|
|
51
|
+
"category": req.category,
|
|
52
|
+
"importance": importance,
|
|
53
|
+
"embedding": embedding_blob,
|
|
54
|
+
},
|
|
55
|
+
)
|
|
56
|
+
return cursor.lastrowid
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def search_memories(
|
|
60
|
+
query: str,
|
|
61
|
+
limit: int = 5,
|
|
62
|
+
category: str | None = None,
|
|
63
|
+
semantic: bool = True,
|
|
64
|
+
agent_id: str = "default",
|
|
65
|
+
) -> list[MemoryRecord]:
|
|
66
|
+
"""
|
|
67
|
+
Search memories. Uses semantic (embedding) search when available,
|
|
68
|
+
falls back to FTS5 full-text search, then LIKE.
|
|
69
|
+
Filters out fully-decayed memories. Reinforces access count on results.
|
|
70
|
+
"""
|
|
71
|
+
if semantic and _embeddings_available():
|
|
72
|
+
results = _semantic_search(query, limit * 2, category, agent_id)
|
|
73
|
+
else:
|
|
74
|
+
results = _fts_search(query, limit * 2, category, agent_id)
|
|
75
|
+
|
|
76
|
+
# Filter forgotten memories, re-rank by effective score
|
|
77
|
+
alive = [r for r in results if not should_forget(r.decay_score or 1.0)]
|
|
78
|
+
alive.sort(key=lambda r: effective_score(r.decay_score or 1.0, r.importance), reverse=True)
|
|
79
|
+
top = alive[:limit]
|
|
80
|
+
|
|
81
|
+
# Reinforce access for retrieved memories
|
|
82
|
+
if top:
|
|
83
|
+
_reinforce([r.id for r in top])
|
|
84
|
+
|
|
85
|
+
return top
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def delete_memory(memory_id: int, agent_id: str = "default") -> bool:
|
|
89
|
+
"""Delete a memory by id, scoped to agent. Returns True if deleted."""
|
|
90
|
+
with get_connection() as conn:
|
|
91
|
+
cursor = conn.execute(
|
|
92
|
+
"DELETE FROM memories WHERE id = ? AND agent_id = ?",
|
|
93
|
+
(memory_id, agent_id),
|
|
94
|
+
)
|
|
95
|
+
return cursor.rowcount > 0
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def run_decay_pass(agent_id: str | None = None) -> int:
|
|
99
|
+
"""
|
|
100
|
+
Recalculate decay_score for all active memories (optionally scoped to agent).
|
|
101
|
+
Returns the count of memories updated.
|
|
102
|
+
"""
|
|
103
|
+
with get_connection() as conn:
|
|
104
|
+
sql = "SELECT id, importance, created_at, last_accessed, access_count FROM memories WHERE compressed_into IS NULL"
|
|
105
|
+
params: list = []
|
|
106
|
+
if agent_id:
|
|
107
|
+
sql += " AND agent_id = ?"
|
|
108
|
+
params.append(agent_id)
|
|
109
|
+
rows = conn.execute(sql, params).fetchall()
|
|
110
|
+
|
|
111
|
+
updated = 0
|
|
112
|
+
for row in rows:
|
|
113
|
+
new_score = compute_decay(
|
|
114
|
+
importance=row["importance"],
|
|
115
|
+
created_at=row["created_at"],
|
|
116
|
+
last_accessed=row["last_accessed"],
|
|
117
|
+
access_count=row["access_count"],
|
|
118
|
+
)
|
|
119
|
+
with get_connection() as conn:
|
|
120
|
+
conn.execute(
|
|
121
|
+
"UPDATE memories SET decay_score = ?, updated_at = datetime('now') WHERE id = ?",
|
|
122
|
+
(new_score, row["id"]),
|
|
123
|
+
)
|
|
124
|
+
updated += 1
|
|
125
|
+
|
|
126
|
+
return updated
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def get_timeline(subject: str, limit: int = 20, agent_id: str = "default") -> list[MemoryRecord]:
|
|
130
|
+
"""Return memories about a subject ordered by creation time (oldest first)."""
|
|
131
|
+
if _embeddings_available():
|
|
132
|
+
results = _semantic_search(subject, limit, category=None, agent_id=agent_id)
|
|
133
|
+
else:
|
|
134
|
+
results = _fts_search(subject, limit, category=None, agent_id=agent_id)
|
|
135
|
+
return sorted(results, key=lambda r: r.created_at)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ── Private helpers ──────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
def _reinforce(memory_ids: list[int]) -> None:
|
|
141
|
+
"""Increment access_count and update last_accessed for retrieved memories."""
|
|
142
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
143
|
+
with get_connection() as conn:
|
|
144
|
+
conn.executemany(
|
|
145
|
+
"""
|
|
146
|
+
UPDATE memories
|
|
147
|
+
SET access_count = access_count + 1,
|
|
148
|
+
last_accessed = ?,
|
|
149
|
+
decay_score = MIN(1.0, decay_score + 0.05),
|
|
150
|
+
updated_at = datetime('now')
|
|
151
|
+
WHERE id = ?
|
|
152
|
+
""",
|
|
153
|
+
[(now, mid) for mid in memory_ids],
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _fts_search(query: str, limit: int, category: str | None, agent_id: str = "default") -> list[MemoryRecord]:
|
|
158
|
+
"""Full-text search via SQLite FTS5 with prefix wildcards, scoped to agent."""
|
|
159
|
+
with get_connection() as conn:
|
|
160
|
+
safe_query = _sanitize_fts_query(query)
|
|
161
|
+
|
|
162
|
+
if safe_query:
|
|
163
|
+
sql = """
|
|
164
|
+
SELECT m.id, m.content, m.category, m.importance,
|
|
165
|
+
m.decay_score, m.access_count, m.last_accessed,
|
|
166
|
+
m.created_at, m.updated_at, rank AS score
|
|
167
|
+
FROM memories_fts
|
|
168
|
+
JOIN memories m ON memories_fts.rowid = m.id
|
|
169
|
+
WHERE memories_fts MATCH :query
|
|
170
|
+
AND m.agent_id = :agent_id
|
|
171
|
+
AND m.compressed_into IS NULL
|
|
172
|
+
{category_filter}
|
|
173
|
+
ORDER BY rank, m.importance DESC
|
|
174
|
+
LIMIT :limit
|
|
175
|
+
"""
|
|
176
|
+
params: dict = {"query": safe_query, "limit": limit, "agent_id": agent_id}
|
|
177
|
+
else:
|
|
178
|
+
sql = """
|
|
179
|
+
SELECT id, content, category, importance,
|
|
180
|
+
decay_score, access_count, last_accessed,
|
|
181
|
+
created_at, updated_at, NULL AS score
|
|
182
|
+
FROM memories
|
|
183
|
+
WHERE content LIKE :query
|
|
184
|
+
AND agent_id = :agent_id
|
|
185
|
+
AND compressed_into IS NULL
|
|
186
|
+
{category_filter}
|
|
187
|
+
ORDER BY importance DESC, created_at DESC
|
|
188
|
+
LIMIT :limit
|
|
189
|
+
"""
|
|
190
|
+
params = {"query": f"%{query}%", "limit": limit, "agent_id": agent_id}
|
|
191
|
+
|
|
192
|
+
category_filter = (
|
|
193
|
+
"AND m.category = :category" if safe_query and category
|
|
194
|
+
else "AND category = :category" if category
|
|
195
|
+
else ""
|
|
196
|
+
)
|
|
197
|
+
if category:
|
|
198
|
+
params["category"] = category
|
|
199
|
+
|
|
200
|
+
rows = conn.execute(sql.format(category_filter=category_filter), params).fetchall()
|
|
201
|
+
|
|
202
|
+
return [_row_to_record(r) for r in rows]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _semantic_search(query: str, limit: int, category: str | None, agent_id: str = "default") -> list[MemoryRecord]:
|
|
206
|
+
"""Cosine similarity search over stored embeddings, scoped to agent."""
|
|
207
|
+
from .embedder import cosine_similarity, deserialize, embed
|
|
208
|
+
|
|
209
|
+
query_vec = embed(query)
|
|
210
|
+
|
|
211
|
+
with get_connection() as conn:
|
|
212
|
+
sql = """
|
|
213
|
+
SELECT id, content, category, importance,
|
|
214
|
+
decay_score, access_count, last_accessed,
|
|
215
|
+
embedding, created_at, updated_at
|
|
216
|
+
FROM memories
|
|
217
|
+
WHERE embedding IS NOT NULL
|
|
218
|
+
AND compressed_into IS NULL
|
|
219
|
+
AND agent_id = ?
|
|
220
|
+
"""
|
|
221
|
+
params: list = [agent_id]
|
|
222
|
+
if category:
|
|
223
|
+
sql += " AND category = ?"
|
|
224
|
+
params.append(category)
|
|
225
|
+
rows = conn.execute(sql, params).fetchall()
|
|
226
|
+
|
|
227
|
+
scored = []
|
|
228
|
+
for row in rows:
|
|
229
|
+
vec = deserialize(row["embedding"])
|
|
230
|
+
sim = cosine_similarity(query_vec, vec)
|
|
231
|
+
scored.append((sim, row))
|
|
232
|
+
|
|
233
|
+
scored.sort(key=lambda x: x[0], reverse=True)
|
|
234
|
+
|
|
235
|
+
return [
|
|
236
|
+
MemoryRecord(
|
|
237
|
+
id=row["id"],
|
|
238
|
+
content=row["content"],
|
|
239
|
+
category=row["category"],
|
|
240
|
+
importance=row["importance"],
|
|
241
|
+
decay_score=row["decay_score"],
|
|
242
|
+
created_at=row["created_at"],
|
|
243
|
+
updated_at=row["updated_at"],
|
|
244
|
+
score=round(sim, 4),
|
|
245
|
+
)
|
|
246
|
+
for sim, row in scored[:limit]
|
|
247
|
+
]
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _sanitize_fts_query(query: str) -> str:
|
|
251
|
+
special = set('"^():-')
|
|
252
|
+
cleaned = "".join(c if c not in special else " " for c in query).strip()
|
|
253
|
+
if not cleaned:
|
|
254
|
+
return ""
|
|
255
|
+
tokens = [t for t in cleaned.split() if t]
|
|
256
|
+
return " OR ".join(f"{t}*" for t in tokens)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _row_to_record(row) -> MemoryRecord:
|
|
260
|
+
return MemoryRecord(
|
|
261
|
+
id=row["id"],
|
|
262
|
+
content=row["content"],
|
|
263
|
+
category=row["category"],
|
|
264
|
+
importance=row["importance"],
|
|
265
|
+
decay_score=row["decay_score"] if "decay_score" in row.keys() else 1.0,
|
|
266
|
+
created_at=row["created_at"],
|
|
267
|
+
updated_at=row["updated_at"],
|
|
268
|
+
score=row["score"],
|
|
269
|
+
)
|
src/scorer.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Kore — Auto-Importance Scorer
|
|
3
|
+
Calculates memory importance (1–5) locally, without any LLM API call.
|
|
4
|
+
|
|
5
|
+
Scoring factors:
|
|
6
|
+
1. Content length — longer = more detailed = more important
|
|
7
|
+
2. Keyword signals — critical words bump importance up
|
|
8
|
+
3. Category baseline — some categories are inherently more important
|
|
9
|
+
4. Uniqueness — if similar memories exist with high importance, inherit it
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
# Keywords that signal high importance
|
|
15
|
+
HIGH_IMPORTANCE_KEYWORDS = {
|
|
16
|
+
5: [
|
|
17
|
+
"password", "token", "chiave", "secret", "api key", "credenziali",
|
|
18
|
+
"urgente", "critico", "never", "mai", "sempre", "always",
|
|
19
|
+
"private key", "segreto",
|
|
20
|
+
],
|
|
21
|
+
4: [
|
|
22
|
+
"decisione", "decision", "importante", "important", "priorità", "priority",
|
|
23
|
+
"deadline", "scadenza", "pagamento", "payment", "debito", "debt",
|
|
24
|
+
"errore critico", "bug critico", "non fare", "do not", "regola",
|
|
25
|
+
],
|
|
26
|
+
3: [
|
|
27
|
+
"progetto", "project", "strategia", "strategy", "obiettivo", "goal",
|
|
28
|
+
"configurazione", "config", "server", "deploy", "produzione",
|
|
29
|
+
],
|
|
30
|
+
2: [
|
|
31
|
+
"nota", "note", "reminder", "appunto", "considerare", "consider",
|
|
32
|
+
],
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
# Category importance baselines
|
|
36
|
+
CATEGORY_BASELINE: dict[str, int] = {
|
|
37
|
+
"general": 1,
|
|
38
|
+
"preference": 4,
|
|
39
|
+
"decision": 4,
|
|
40
|
+
"finance": 3,
|
|
41
|
+
"trading": 3,
|
|
42
|
+
"project": 3,
|
|
43
|
+
"task": 2,
|
|
44
|
+
"person": 2,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def auto_score(content: str, category: str) -> int:
|
|
49
|
+
"""
|
|
50
|
+
Return an importance score (1–5) based on content analysis.
|
|
51
|
+
Used when importance is not explicitly set.
|
|
52
|
+
"""
|
|
53
|
+
score = CATEGORY_BASELINE.get(category, 2)
|
|
54
|
+
content_lower = content.lower()
|
|
55
|
+
|
|
56
|
+
# Keyword signals — take the highest match
|
|
57
|
+
for level in sorted(HIGH_IMPORTANCE_KEYWORDS.keys(), reverse=True):
|
|
58
|
+
for kw in HIGH_IMPORTANCE_KEYWORDS[level]:
|
|
59
|
+
if kw in content_lower:
|
|
60
|
+
score = max(score, level)
|
|
61
|
+
break
|
|
62
|
+
|
|
63
|
+
# Length bonus: detailed content is likely more important
|
|
64
|
+
word_count = len(content.split())
|
|
65
|
+
if word_count > 60:
|
|
66
|
+
score = min(5, score + 1)
|
|
67
|
+
elif word_count > 30:
|
|
68
|
+
score = min(5, score + 0) # no change, just don't penalize
|
|
69
|
+
|
|
70
|
+
return max(1, min(5, score))
|