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.
@@ -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: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
36
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://python.org)
37
+ [![Zero Cloud Dependencies](https://img.shields.io/badge/cloud-zero-green.svg)]()
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ kore = kore.cli:main
@@ -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))