cortex-mem 1.0.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.
cortex/__init__.py ADDED
File without changes
cortex/db.py ADDED
@@ -0,0 +1,135 @@
1
+ """
2
+ SQLite metadata store for Cortex tiered documents.
3
+ Thread-safe, async-compatible via aiosqlite.
4
+ """
5
+ import json
6
+ import sqlite3
7
+ import uuid
8
+ from pathlib import Path
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ DB_PATH = Path(__file__).parent.parent / "index" / "cortex_tiers.db"
12
+ SCHEMA_PATH = Path(__file__).parent / "schema.sql"
13
+
14
+
15
+ def _get_conn(db_path: Path = DB_PATH) -> sqlite3.Connection:
16
+ db_path.parent.mkdir(parents=True, exist_ok=True)
17
+ conn = sqlite3.connect(str(db_path))
18
+ conn.row_factory = sqlite3.Row
19
+ conn.execute("PRAGMA journal_mode=WAL")
20
+ conn.execute("PRAGMA foreign_keys=ON")
21
+ return conn
22
+
23
+
24
+ def init_db(db_path: Path = DB_PATH) -> sqlite3.Connection:
25
+ conn = _get_conn(db_path)
26
+ with open(SCHEMA_PATH) as f:
27
+ conn.executescript(f.read())
28
+ conn.commit()
29
+ return conn
30
+
31
+
32
+ def insert_document(conn: sqlite3.Connection, doc: Dict[str, Any]) -> str:
33
+ doc_id = doc.get("doc_id") or str(uuid.uuid4())
34
+ tags = json.dumps(doc.get("tags", []))
35
+ conn.execute(
36
+ """
37
+ INSERT OR REPLACE INTO documents (
38
+ doc_id, hierarchy_path, title, doc_type,
39
+ l0_abstract, l0_token_count,
40
+ l1_overview, l1_token_count,
41
+ l2_file_path, l2_token_count, l2_checksum,
42
+ chromadb_l0_id, chromadb_l1_id,
43
+ parent_path, depth, tags,
44
+ l0_generated_at, l1_generated_at,
45
+ source_type, cortex_tier
46
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
47
+ """,
48
+ (
49
+ doc_id,
50
+ doc["hierarchy_path"],
51
+ doc["title"],
52
+ doc["doc_type"],
53
+ doc["l0_abstract"],
54
+ doc["l0_token_count"],
55
+ doc.get("l1_overview"),
56
+ doc.get("l1_token_count", 0),
57
+ doc["l2_file_path"],
58
+ doc.get("l2_token_count", 0),
59
+ doc["l2_checksum"],
60
+ doc.get("chromadb_l0_id"),
61
+ doc.get("chromadb_l1_id"),
62
+ doc.get("parent_path"),
63
+ doc.get("depth", 0),
64
+ tags,
65
+ doc.get("l0_generated_at"),
66
+ doc.get("l1_generated_at"),
67
+ doc.get("source_type", "manual"),
68
+ doc.get("cortex_tier"),
69
+ ),
70
+ )
71
+ conn.commit()
72
+ return doc_id
73
+
74
+
75
+ def get_document(conn: sqlite3.Connection, doc_id: str) -> Optional[Dict[str, Any]]:
76
+ row = conn.execute("SELECT * FROM documents WHERE doc_id = ?", (doc_id,)).fetchone()
77
+ if row is None:
78
+ return None
79
+ return _row_to_dict(row)
80
+
81
+
82
+ def get_all_documents(conn: sqlite3.Connection) -> List[Dict[str, Any]]:
83
+ rows = conn.execute("SELECT * FROM documents ORDER BY updated_at DESC").fetchall()
84
+ return [_row_to_dict(r) for r in rows]
85
+
86
+
87
+ def search_by_hierarchy(
88
+ conn: sqlite3.Connection, prefix: str
89
+ ) -> List[Dict[str, Any]]:
90
+ rows = conn.execute(
91
+ "SELECT * FROM documents WHERE hierarchy_path LIKE ? ORDER BY hierarchy_path",
92
+ (prefix + "%",),
93
+ ).fetchall()
94
+ return [_row_to_dict(r) for r in rows]
95
+
96
+
97
+ def mark_stale(conn: sqlite3.Connection, doc_id: str, reason: str):
98
+ conn.execute(
99
+ "UPDATE documents SET is_stale = 1, stale_reason = ? WHERE doc_id = ?",
100
+ (reason, doc_id),
101
+ )
102
+ conn.commit()
103
+
104
+
105
+ def log_query(
106
+ conn: sqlite3.Connection,
107
+ query_text: str,
108
+ l0_results: int,
109
+ l1_expansions: int,
110
+ l2_loads: int,
111
+ total_tokens: int,
112
+ latency_ms: int,
113
+ agent_id: Optional[str] = None,
114
+ ):
115
+ conn.execute(
116
+ """
117
+ INSERT INTO query_log (query_id, query_text, agent_id,
118
+ l0_results, l1_expansions, l2_loads, total_tokens, latency_ms)
119
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
120
+ """,
121
+ (str(uuid.uuid4()), query_text, agent_id,
122
+ l0_results, l1_expansions, l2_loads, total_tokens, latency_ms),
123
+ )
124
+ conn.commit()
125
+
126
+
127
+ def _row_to_dict(row: sqlite3.Row) -> Dict[str, Any]:
128
+ d = dict(row)
129
+ if isinstance(d.get("tags"), str):
130
+ try:
131
+ d["tags"] = json.loads(d["tags"])
132
+ except (json.JSONDecodeError, TypeError):
133
+ d["tags"] = []
134
+ d["is_stale"] = bool(d.get("is_stale"))
135
+ return d
cortex/schema.sql ADDED
@@ -0,0 +1,75 @@
1
+ -- Cortex Tiered Retrieval — SQLite schema
2
+ -- Equivalent to cortex_tiers PostgreSQL schema from master plan.
3
+ -- Stores document metadata + L0/L1 content; L2 lives on filesystem.
4
+
5
+ CREATE TABLE IF NOT EXISTS documents (
6
+ doc_id TEXT PRIMARY KEY,
7
+ hierarchy_path TEXT NOT NULL,
8
+ title TEXT NOT NULL,
9
+ doc_type TEXT NOT NULL CHECK (doc_type IN (
10
+ 'strategy', 'backtest', 'research', 'episode',
11
+ 'skill', 'pattern', 'session_learning', 'reference'
12
+ )),
13
+
14
+ -- Tier content
15
+ l0_abstract TEXT NOT NULL,
16
+ l0_token_count INTEGER NOT NULL,
17
+ l1_overview TEXT,
18
+ l1_token_count INTEGER DEFAULT 0,
19
+ l2_file_path TEXT NOT NULL,
20
+ l2_token_count INTEGER DEFAULT 0,
21
+ l2_checksum TEXT NOT NULL,
22
+
23
+ -- ChromaDB references
24
+ chromadb_l0_id TEXT,
25
+ chromadb_l1_id TEXT,
26
+
27
+ -- Hierarchy
28
+ parent_path TEXT,
29
+ depth INTEGER DEFAULT 0,
30
+ tags TEXT DEFAULT '[]',
31
+
32
+ -- Timestamps
33
+ created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
34
+ updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
35
+ l0_generated_at TEXT,
36
+ l1_generated_at TEXT,
37
+
38
+ -- Invalidation
39
+ is_stale INTEGER DEFAULT 0,
40
+ stale_reason TEXT,
41
+
42
+ -- Source tracking
43
+ source_type TEXT DEFAULT 'manual',
44
+ cortex_tier TEXT CHECK (cortex_tier IN ('episodic', 'semantic', 'procedural') OR cortex_tier IS NULL)
45
+ );
46
+
47
+ CREATE INDEX IF NOT EXISTS idx_docs_hierarchy ON documents (hierarchy_path);
48
+ CREATE INDEX IF NOT EXISTS idx_docs_parent ON documents (parent_path);
49
+ CREATE INDEX IF NOT EXISTS idx_docs_type ON documents (doc_type);
50
+ CREATE INDEX IF NOT EXISTS idx_docs_stale ON documents (is_stale) WHERE is_stale = 1;
51
+
52
+ CREATE TABLE IF NOT EXISTS directories (
53
+ dir_id TEXT PRIMARY KEY,
54
+ path TEXT UNIQUE NOT NULL,
55
+ parent_path TEXT,
56
+ name TEXT NOT NULL,
57
+ description TEXT,
58
+ doc_count INTEGER DEFAULT 0,
59
+ created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
60
+ updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
61
+ );
62
+
63
+ CREATE INDEX IF NOT EXISTS idx_dirs_parent ON directories (parent_path);
64
+
65
+ CREATE TABLE IF NOT EXISTS query_log (
66
+ query_id TEXT PRIMARY KEY,
67
+ query_text TEXT NOT NULL,
68
+ agent_id TEXT,
69
+ l0_results INTEGER DEFAULT 0,
70
+ l1_expansions INTEGER DEFAULT 0,
71
+ l2_loads INTEGER DEFAULT 0,
72
+ total_tokens INTEGER DEFAULT 0,
73
+ latency_ms INTEGER DEFAULT 0,
74
+ created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
75
+ );
@@ -0,0 +1,280 @@
1
+ """
2
+ TierGenerator — auto-generates L0/L1 tiers from L2 (full) documents.
3
+
4
+ Uses Ollama (deepseek-r1:7b) for summarization and ChromaDB for vector indexing.
5
+ Prompts follow MEMORY_ARCHITECTURE_MASTER_PLAN.md Section 1.4.
6
+ """
7
+ import hashlib
8
+ import logging
9
+ import re
10
+ import time
11
+ import uuid
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ import aiohttp
17
+ import chromadb
18
+
19
+ from . import db
20
+
21
+ logger = logging.getLogger("aoms.cortex")
22
+
23
+ OLLAMA_URL = "http://localhost:11434"
24
+ GENERATION_MODEL = "deepseek-r1:7b"
25
+ L0_MAX_TOKENS = 100
26
+ L1_MAX_TOKENS = 2000
27
+
28
+ L2_STORAGE_ROOT = Path("/home/dhawal/cortex-mem/cortex-mem/cortex/l2_docs")
29
+ CHROMA_PATH = Path("/home/dhawal/cortex-mem/cortex-mem/index/chroma")
30
+
31
+ L0_PROMPT = """Summarize in ONE sentence (max 100 tokens): what this document \
32
+ is about, its primary conclusion, and one key differentiator.
33
+
34
+ DOCUMENT TYPE: {doc_type}
35
+ HIERARCHY: {hierarchy_path}
36
+ TITLE: {title}
37
+
38
+ CONTENT:
39
+ {content}
40
+
41
+ ABSTRACT:"""
42
+
43
+ L1_PROMPT = """Create a structured overview (under 2000 tokens):
44
+
45
+ 1. PURPOSE: What this is and why it exists (1-2 sentences)
46
+ 2. KEY POINTS: 3-5 most important facts/findings
47
+ 3. METRICS: Quantitative results (percentages, performance)
48
+ 4. RELATIONSHIPS: What this relates to
49
+ 5. ACTIONABILITY: When/how to use this
50
+
51
+ Format as clean markdown. Be data-dense.
52
+
53
+ DOCUMENT TYPE: {doc_type}
54
+ HIERARCHY: {hierarchy_path}
55
+ TITLE: {title}
56
+
57
+ CONTENT:
58
+ {content}
59
+
60
+ OVERVIEW:"""
61
+
62
+
63
+ def _estimate_tokens(text: str) -> int:
64
+ """Rough token estimate: ~4 chars per token for English."""
65
+ return len(text) // 4
66
+
67
+
68
+ def _strip_thinking_tags(text: str) -> str:
69
+ """Remove <think>...</think> blocks that deepseek-r1 sometimes emits."""
70
+ return re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
71
+
72
+
73
+ async def _ollama_generate(prompt: str, max_tokens: int = 2048) -> str:
74
+ """Call Ollama chat API and return the response text."""
75
+ payload = {
76
+ "model": GENERATION_MODEL,
77
+ "prompt": prompt,
78
+ "stream": False,
79
+ "options": {"num_predict": max_tokens, "temperature": 0.3},
80
+ }
81
+ async with aiohttp.ClientSession() as session:
82
+ async with session.post(
83
+ f"{OLLAMA_URL}/api/generate",
84
+ json=payload,
85
+ timeout=aiohttp.ClientTimeout(total=120),
86
+ ) as resp:
87
+ if resp.status != 200:
88
+ body = await resp.text()
89
+ raise RuntimeError(f"Ollama returned {resp.status}: {body}")
90
+ data = await resp.json()
91
+ return _strip_thinking_tags(data.get("response", ""))
92
+
93
+
94
+ def _get_chroma_client() -> chromadb.ClientAPI:
95
+ CHROMA_PATH.mkdir(parents=True, exist_ok=True)
96
+ return chromadb.PersistentClient(path=str(CHROMA_PATH))
97
+
98
+
99
+ class TierGenerator:
100
+ """Auto-generates L0 and L1 tiers from L2 content."""
101
+
102
+ def __init__(self, db_conn=None):
103
+ self.conn = db_conn or db.init_db()
104
+ self._chroma = None
105
+
106
+ @property
107
+ def chroma(self):
108
+ if self._chroma is None:
109
+ self._chroma = _get_chroma_client()
110
+ return self._chroma
111
+
112
+ @property
113
+ def l0_collection(self):
114
+ return self.chroma.get_or_create_collection(
115
+ "cortex_l0", metadata={"hnsw:space": "cosine"}
116
+ )
117
+
118
+ @property
119
+ def l1_collection(self):
120
+ return self.chroma.get_or_create_collection(
121
+ "cortex_l1", metadata={"hnsw:space": "cosine"}
122
+ )
123
+
124
+ async def ingest_document(
125
+ self,
126
+ content: str,
127
+ title: str,
128
+ hierarchy_path: str,
129
+ doc_type: str = "reference",
130
+ tags: Optional[list] = None,
131
+ source_file: Optional[str] = None,
132
+ ) -> str:
133
+ """
134
+ Full pipeline: store L2, generate L0/L1, index in ChromaDB, save metadata.
135
+ Returns doc_id.
136
+ """
137
+ doc_id = str(uuid.uuid4())
138
+ now = datetime.now(timezone.utc).isoformat()
139
+ l2_checksum = hashlib.sha256(content.encode()).hexdigest()
140
+
141
+ # 1. Store L2 on filesystem
142
+ l2_path = self._resolve_l2_path(hierarchy_path, title)
143
+ l2_path.parent.mkdir(parents=True, exist_ok=True)
144
+ l2_path.write_text(content, encoding="utf-8")
145
+ l2_tokens = _estimate_tokens(content)
146
+
147
+ logger.info(f"Ingesting '{title}' ({l2_tokens} tokens) → generating L0/L1...")
148
+
149
+ # 2. Generate L0 via Ollama
150
+ t0 = time.monotonic()
151
+ l0_abstract = await self._generate_l0(content, title, hierarchy_path, doc_type)
152
+ l0_tokens = _estimate_tokens(l0_abstract)
153
+ logger.info(f" L0 generated: {l0_tokens} tokens ({time.monotonic()-t0:.1f}s)")
154
+
155
+ # 3. Generate L1 via Ollama
156
+ t1 = time.monotonic()
157
+ l1_overview = await self._generate_l1(content, title, hierarchy_path, doc_type)
158
+ l1_tokens = _estimate_tokens(l1_overview)
159
+ logger.info(f" L1 generated: {l1_tokens} tokens ({time.monotonic()-t1:.1f}s)")
160
+
161
+ # 4. Index in ChromaDB
162
+ self.l0_collection.upsert(
163
+ ids=[doc_id],
164
+ documents=[l0_abstract],
165
+ metadatas=[{"title": title, "hierarchy_path": hierarchy_path, "doc_type": doc_type}],
166
+ )
167
+ self.l1_collection.upsert(
168
+ ids=[doc_id],
169
+ documents=[l1_overview],
170
+ metadatas=[{"title": title, "hierarchy_path": hierarchy_path, "doc_type": doc_type}],
171
+ )
172
+
173
+ # 5. Save to SQLite
174
+ parent_path = "/".join(hierarchy_path.rstrip("/").split("/")[:-1]) or "/"
175
+ depth = hierarchy_path.strip("/").count("/")
176
+
177
+ db.insert_document(self.conn, {
178
+ "doc_id": doc_id,
179
+ "hierarchy_path": hierarchy_path,
180
+ "title": title,
181
+ "doc_type": doc_type,
182
+ "l0_abstract": l0_abstract,
183
+ "l0_token_count": l0_tokens,
184
+ "l1_overview": l1_overview,
185
+ "l1_token_count": l1_tokens,
186
+ "l2_file_path": str(l2_path),
187
+ "l2_token_count": l2_tokens,
188
+ "l2_checksum": l2_checksum,
189
+ "chromadb_l0_id": doc_id,
190
+ "chromadb_l1_id": doc_id,
191
+ "parent_path": parent_path,
192
+ "depth": depth,
193
+ "tags": tags or [],
194
+ "l0_generated_at": now,
195
+ "l1_generated_at": now,
196
+ "source_type": "file" if source_file else "manual",
197
+ })
198
+
199
+ total_time = time.monotonic() - t0
200
+ logger.info(
201
+ f" Ingested '{title}': L2={l2_tokens}tok → L0={l0_tokens}tok + L1={l1_tokens}tok "
202
+ f"({(1 - (l0_tokens + l1_tokens) / max(l2_tokens, 1)) * 100:.0f}% reduction, {total_time:.1f}s)"
203
+ )
204
+ return doc_id
205
+
206
+ async def regenerate(self, doc_id: str) -> bool:
207
+ """Re-generate L0/L1 for an existing document (e.g., after L2 update)."""
208
+ doc = db.get_document(self.conn, doc_id)
209
+ if doc is None:
210
+ return False
211
+
212
+ l2_path = Path(doc["l2_file_path"])
213
+ if not l2_path.exists():
214
+ logger.error(f"L2 file missing for {doc_id}: {l2_path}")
215
+ return False
216
+
217
+ content = l2_path.read_text(encoding="utf-8")
218
+ now = datetime.now(timezone.utc).isoformat()
219
+
220
+ l0 = await self._generate_l0(content, doc["title"], doc["hierarchy_path"], doc["doc_type"])
221
+ l1 = await self._generate_l1(content, doc["title"], doc["hierarchy_path"], doc["doc_type"])
222
+
223
+ self.conn.execute(
224
+ """
225
+ UPDATE documents SET
226
+ l0_abstract = ?, l0_token_count = ?,
227
+ l1_overview = ?, l1_token_count = ?,
228
+ l2_checksum = ?, l2_token_count = ?,
229
+ is_stale = 0, stale_reason = NULL,
230
+ l0_generated_at = ?, l1_generated_at = ?
231
+ WHERE doc_id = ?
232
+ """,
233
+ (
234
+ l0, _estimate_tokens(l0),
235
+ l1, _estimate_tokens(l1),
236
+ hashlib.sha256(content.encode()).hexdigest(),
237
+ _estimate_tokens(content),
238
+ now, now, doc_id,
239
+ ),
240
+ )
241
+ self.conn.commit()
242
+
243
+ self.l0_collection.upsert(ids=[doc_id], documents=[l0])
244
+ self.l1_collection.upsert(ids=[doc_id], documents=[l1])
245
+
246
+ logger.info(f"Regenerated L0/L1 for '{doc['title']}'")
247
+ return True
248
+
249
+ async def _generate_l0(
250
+ self, content: str, title: str, hierarchy_path: str, doc_type: str
251
+ ) -> str:
252
+ truncated = content[:12000]
253
+ prompt = L0_PROMPT.format(
254
+ doc_type=doc_type,
255
+ hierarchy_path=hierarchy_path,
256
+ title=title,
257
+ content=truncated,
258
+ )
259
+ result = await _ollama_generate(prompt, max_tokens=400)
260
+ if len(result.split()) < 10:
261
+ logger.warning(f"L0 too short ({len(result.split())} words), using title-based fallback")
262
+ first_500 = content[:2000].replace("\n", " ").strip()
263
+ result = f"{title}: {first_500[:300]}"
264
+ return result
265
+
266
+ async def _generate_l1(
267
+ self, content: str, title: str, hierarchy_path: str, doc_type: str
268
+ ) -> str:
269
+ truncated = content[:16000]
270
+ prompt = L1_PROMPT.format(
271
+ doc_type=doc_type,
272
+ hierarchy_path=hierarchy_path,
273
+ title=title,
274
+ content=truncated,
275
+ )
276
+ return await _ollama_generate(prompt, max_tokens=3000)
277
+
278
+ def _resolve_l2_path(self, hierarchy_path: str, title: str) -> Path:
279
+ safe_title = re.sub(r"[^\w\-.]", "_", title)[:80]
280
+ return L2_STORAGE_ROOT / hierarchy_path.strip("/") / f"{safe_title}.md"