cortex-claude 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. cortex_claude/__init__.py +1 -0
  2. cortex_claude/__main__.py +10 -0
  3. cortex_claude/core/__init__.py +4 -0
  4. cortex_claude/core/decay.py +31 -0
  5. cortex_claude/core/engine.py +574 -0
  6. cortex_claude/core/scope_manager.py +48 -0
  7. cortex_claude/core/token_budget.py +35 -0
  8. cortex_claude/embeddings/__init__.py +4 -0
  9. cortex_claude/embeddings/engine.py +26 -0
  10. cortex_claude/embeddings/tokenizer.py +9 -0
  11. cortex_claude/facts/__init__.py +3 -0
  12. cortex_claude/facts/extractor.py +71 -0
  13. cortex_claude/facts/normalizer.py +69 -0
  14. cortex_claude/facts/patterns.py +37 -0
  15. cortex_claude/facts/spacy_extract.py +122 -0
  16. cortex_claude/models/__init__.py +14 -0
  17. cortex_claude/models/fact.py +32 -0
  18. cortex_claude/models/memory.py +47 -0
  19. cortex_claude/models/scope.py +18 -0
  20. cortex_claude/pipeline/__init__.py +0 -0
  21. cortex_claude/server/__init__.py +0 -0
  22. cortex_claude/server/app.py +140 -0
  23. cortex_claude/server/config.py +96 -0
  24. cortex_claude/server/tools/__init__.py +0 -0
  25. cortex_claude/server/tools/facts.py +29 -0
  26. cortex_claude/server/tools/forget.py +33 -0
  27. cortex_claude/server/tools/recall.py +35 -0
  28. cortex_claude/server/tools/save.py +23 -0
  29. cortex_claude/server/tools/scopes.py +49 -0
  30. cortex_claude/server/tools/status.py +37 -0
  31. cortex_claude/server/tools/traverse.py +27 -0
  32. cortex_claude/storage/__init__.py +5 -0
  33. cortex_claude/storage/database.py +81 -0
  34. cortex_claude/storage/fact_repo.py +117 -0
  35. cortex_claude/storage/memory_repo.py +142 -0
  36. cortex_claude/storage/migrations.py +117 -0
  37. cortex_claude/summarizer/__init__.py +3 -0
  38. cortex_claude/summarizer/extractive.py +76 -0
  39. cortex_claude/summarizer/scoring.py +30 -0
  40. cortex_claude-0.1.0.dist-info/METADATA +258 -0
  41. cortex_claude-0.1.0.dist-info/RECORD +44 -0
  42. cortex_claude-0.1.0.dist-info/WHEEL +4 -0
  43. cortex_claude-0.1.0.dist-info/entry_points.txt +2 -0
  44. cortex_claude-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,10 @@
1
+ from cortex_claude.server.app import mcp, _init_engine
2
+
3
+
4
+ def main() -> None:
5
+ _init_engine()
6
+ mcp.run(transport="stdio")
7
+
8
+
9
+ if __name__ == "__main__":
10
+ main()
@@ -0,0 +1,4 @@
1
+ from cortex_claude.core.engine import CortexEngine
2
+ from cortex_claude.core.scope_manager import ScopeManager
3
+
4
+ __all__ = ["CortexEngine", "ScopeManager"]
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import sqlite3
5
+ import time
6
+
7
+
8
+ def recalculate_decay_scores(
9
+ conn: sqlite3.Connection,
10
+ decay_lambda: float = 0.05,
11
+ min_score: float = 0.01,
12
+ ) -> int:
13
+ now_ms = int(time.time() * 1000)
14
+ rows = conn.execute(
15
+ "SELECT id, accessed_at, access_count FROM memories"
16
+ ).fetchall()
17
+
18
+ updated = 0
19
+ for row in rows:
20
+ days_since = (now_ms - row["accessed_at"]) / 86_400_000
21
+ access_boost = math.log(1 + row["access_count"])
22
+ score = max(math.exp(-decay_lambda * days_since) * (1 + access_boost), min_score)
23
+
24
+ conn.execute(
25
+ "UPDATE memories SET decay_score = ? WHERE id = ?",
26
+ (score, row["id"]),
27
+ )
28
+ updated += 1
29
+
30
+ conn.commit()
31
+ return updated
@@ -0,0 +1,574 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Literal
7
+
8
+ from cortex_claude.core.decay import recalculate_decay_scores
9
+ from cortex_claude.core.scope_manager import ScopeManager
10
+ from cortex_claude.core.token_budget import TokenBudget
11
+ from cortex_claude.embeddings import EmbeddingEngine, count_tokens
12
+ from cortex_claude.facts import extract_facts
13
+ from cortex_claude.models.fact import Fact
14
+ from cortex_claude.models.memory import Memory, RecallItem, RecallResult, SaveResult
15
+ from cortex_claude.server.config import CortexConfig
16
+ from cortex_claude.storage import FactRepository, MemoryRepository, StorageManager
17
+ from cortex_claude.summarizer import summarize
18
+
19
+
20
+ @dataclass
21
+ class ForgetResult:
22
+ deleted: list[str]
23
+ scope: str
24
+ dry_run: bool
25
+
26
+
27
+ @dataclass
28
+ class StatusResult:
29
+ scopes: list[dict]
30
+ total_memories: int
31
+ total_facts: int
32
+ total_size_bytes: int
33
+
34
+
35
+ class CortexEngine:
36
+ def __init__(self, base_path: Path | None = None, config: CortexConfig | None = None):
37
+ self._config = config or CortexConfig.load(base_path)
38
+ self._base_path = self._config.base_path
39
+ self._storage = StorageManager(self._base_path)
40
+ self._embeddings = EmbeddingEngine(model_name=self._config.embedding_model)
41
+ self._scope_manager = ScopeManager(self._base_path)
42
+ self._memory_repo = MemoryRepository()
43
+ self._fact_repo = FactRepository()
44
+
45
+ def initialize(self) -> None:
46
+ for scope in self._storage.list_scopes():
47
+ conn = self._storage.get_database(scope)
48
+ recalculate_decay_scores(
49
+ conn,
50
+ decay_lambda=self._config.decay_lambda,
51
+ min_score=self._config.decay_min_score,
52
+ )
53
+
54
+ async def save(
55
+ self,
56
+ content: str,
57
+ tags: list[str] | None = None,
58
+ scope: str | None = None,
59
+ cwd: str = ".",
60
+ ) -> SaveResult:
61
+ write_scope = scope or self._scope_manager.get_write_scope(cwd)
62
+ conn = self._storage.get_database(write_scope)
63
+
64
+ embedding = self._embeddings.embed(content)
65
+ tokens = count_tokens(content)
66
+
67
+ existing = self._memory_repo.search_by_vector(conn, embedding, limit=1)
68
+ if existing and existing[0][1] >= self._config.dedup_similarity_threshold:
69
+ existing_memory = self._memory_repo.get(conn, existing[0][0])
70
+ if existing_memory:
71
+ merged = f"{existing_memory.content}\n\n{content}"
72
+ self._fact_repo.delete_by_memory(conn, existing_memory.id)
73
+ self._memory_repo.delete(conn, existing_memory.id)
74
+ content = merged
75
+ embedding = self._embeddings.embed(content)
76
+ tokens = count_tokens(content)
77
+
78
+ summary = summarize(content) if tokens > 50 else None
79
+
80
+ memory = Memory(
81
+ content=content,
82
+ summary=summary,
83
+ tags=tags or [],
84
+ scope=write_scope,
85
+ )
86
+
87
+ self._memory_repo.save(conn, memory, embedding)
88
+
89
+ facts = extract_facts(content, min_confidence=self._config.fact_min_confidence)
90
+ for fact in facts:
91
+ fact.source_memory_id = memory.id
92
+ fact.scope = write_scope
93
+ if facts:
94
+ self._fact_repo.save_batch(conn, facts)
95
+
96
+ return SaveResult(
97
+ memory_id=memory.id,
98
+ scope=write_scope,
99
+ tokens_stored=tokens,
100
+ )
101
+
102
+ async def recall(
103
+ self,
104
+ query: str,
105
+ max_tokens: int = 200,
106
+ scope: str | None = None,
107
+ depth: Literal["auto", "facts", "summaries", "full"] = "auto",
108
+ cwd: str = ".",
109
+ ) -> RecallResult:
110
+ scopes = [scope] if scope else self._scope_manager.resolve(cwd)
111
+ budget = TokenBudget(max_tokens=max_tokens)
112
+ items: list[RecallItem] = []
113
+
114
+ # Layer 1: Facts
115
+ if depth in ("auto", "facts"):
116
+ fact_items = self._recall_facts(scopes, query, budget)
117
+ items.extend(fact_items)
118
+
119
+ if depth == "facts" or (depth == "auto" and self._is_sufficient(items, budget)):
120
+ return self._build_result(items, budget, query)
121
+
122
+ # Layer 2: Summaries
123
+ if depth in ("auto", "summaries"):
124
+ summary_items = self._recall_summaries(scopes, query, budget)
125
+ items.extend(summary_items)
126
+
127
+ if depth == "summaries" or (depth == "auto" and self._is_sufficient(items, budget)):
128
+ return self._build_result(items, budget, query)
129
+
130
+ # Layer 3: Full chunks
131
+ full_items = self._recall_chunks(scopes, query, budget)
132
+ items.extend(full_items)
133
+
134
+ return self._build_result(items, budget, query)
135
+
136
+ async def get_facts(
137
+ self,
138
+ topic: str,
139
+ relation: str | None = None,
140
+ scope: str | None = None,
141
+ limit: int = 20,
142
+ cwd: str = ".",
143
+ ) -> list[Fact]:
144
+ scopes = [scope] if scope else self._scope_manager.resolve(cwd)
145
+ all_facts: list[Fact] = []
146
+
147
+ for s in scopes:
148
+ conn = self._storage.get_database(s)
149
+ facts = self._fact_repo.search(conn, topic, relation, limit)
150
+ all_facts.extend(facts)
151
+
152
+ all_facts.sort(key=lambda f: f.confidence, reverse=True)
153
+ return all_facts[:limit]
154
+
155
+ async def traverse_graph(
156
+ self,
157
+ start: str,
158
+ max_hops: int = 2,
159
+ scope: str | None = None,
160
+ cwd: str = ".",
161
+ ) -> list[Fact]:
162
+ scopes = [scope] if scope else self._scope_manager.resolve(cwd)
163
+ visited: set[str] = set()
164
+ result: list[Fact] = []
165
+ frontier: list[str] = [start.lower()]
166
+
167
+ for _ in range(max_hops):
168
+ next_frontier: list[str] = []
169
+ for entity in frontier:
170
+ if entity in visited:
171
+ continue
172
+ visited.add(entity)
173
+
174
+ for s in scopes:
175
+ conn = self._storage.get_database(s)
176
+ facts = self._fact_repo.search(conn, entity, limit=20)
177
+ for fact in facts:
178
+ result.append(fact)
179
+ if fact.subject.lower() not in visited:
180
+ next_frontier.append(fact.subject.lower())
181
+ if fact.object.lower() not in visited:
182
+ next_frontier.append(fact.object.lower())
183
+
184
+ frontier = next_frontier
185
+ if not frontier:
186
+ break
187
+
188
+ seen: set[str] = set()
189
+ unique: list[Fact] = []
190
+ for f in result:
191
+ key = f"{f.subject}|{f.relation}|{f.object}"
192
+ if key not in seen:
193
+ seen.add(key)
194
+ unique.append(f)
195
+
196
+ return unique
197
+
198
+ def _recall_facts(
199
+ self,
200
+ scopes: list[str],
201
+ query: str,
202
+ budget: TokenBudget,
203
+ ) -> list[RecallItem]:
204
+ items: list[RecallItem] = []
205
+ query_embedding = self._embeddings.embed(query)
206
+ seen_facts: set[str] = set()
207
+
208
+ for s in scopes:
209
+ conn = self._storage.get_database(s)
210
+
211
+ # Vector search → find relevant memories → get their facts
212
+ vector_results = self._memory_repo.search_by_vector(conn, query_embedding, limit=5)
213
+ for memory_id, vec_score in vector_results:
214
+ memory = self._memory_repo.get(conn, memory_id)
215
+ decay = memory.decay_score if memory else 1.0
216
+
217
+ memory_facts = self._fact_repo.search_by_memory(conn, memory_id)
218
+ for fact in memory_facts:
219
+ fact_key = f"{fact.subject}|{fact.relation}|{fact.object}"
220
+ if fact_key in seen_facts:
221
+ continue
222
+ seen_facts.add(fact_key)
223
+
224
+ text = f"{fact.subject} → {fact.relation} → {fact.object}"
225
+ tokens = count_tokens(text)
226
+ if not budget.consume(tokens):
227
+ return items
228
+ items.append(RecallItem(
229
+ memory_id=fact.source_memory_id,
230
+ content=text,
231
+ score=(vec_score + fact.confidence) * decay,
232
+ scope=fact.scope,
233
+ created_at=fact.created_at,
234
+ ))
235
+
236
+ # FTS keyword search → find additional facts
237
+ fts_ids = self._memory_repo.search_fts(conn, query, limit=5)
238
+ for memory_id in fts_ids:
239
+ memory = self._memory_repo.get(conn, memory_id)
240
+ decay = memory.decay_score if memory else 1.0
241
+
242
+ memory_facts = self._fact_repo.search_by_memory(conn, memory_id)
243
+ for fact in memory_facts:
244
+ fact_key = f"{fact.subject}|{fact.relation}|{fact.object}"
245
+ if fact_key in seen_facts:
246
+ continue
247
+ seen_facts.add(fact_key)
248
+
249
+ text = f"{fact.subject} → {fact.relation} → {fact.object}"
250
+ tokens = count_tokens(text)
251
+ if not budget.consume(tokens):
252
+ return items
253
+ items.append(RecallItem(
254
+ memory_id=fact.source_memory_id,
255
+ content=text,
256
+ score=fact.confidence * decay,
257
+ scope=fact.scope,
258
+ created_at=fact.created_at,
259
+ ))
260
+
261
+ # Keyword search on fact table directly
262
+ for word in query.lower().split():
263
+ if len(word) < 3:
264
+ continue
265
+ keyword_facts = self._fact_repo.search(conn, word, limit=5)
266
+ for fact in keyword_facts:
267
+ fact_key = f"{fact.subject}|{fact.relation}|{fact.object}"
268
+ if fact_key in seen_facts:
269
+ continue
270
+ seen_facts.add(fact_key)
271
+
272
+ text = f"{fact.subject} → {fact.relation} → {fact.object}"
273
+ tokens = count_tokens(text)
274
+ if not budget.consume(tokens):
275
+ return items
276
+ items.append(RecallItem(
277
+ memory_id=fact.source_memory_id,
278
+ content=text,
279
+ score=fact.confidence,
280
+ scope=fact.scope,
281
+ created_at=fact.created_at,
282
+ ))
283
+
284
+ return items
285
+
286
+ def _recall_summaries(
287
+ self,
288
+ scopes: list[str],
289
+ query: str,
290
+ budget: TokenBudget,
291
+ ) -> list[RecallItem]:
292
+ items: list[RecallItem] = []
293
+ query_embedding = self._embeddings.embed(query)
294
+
295
+ seen_ids: set[str] = set()
296
+ for s in scopes:
297
+ conn = self._storage.get_database(s)
298
+
299
+ candidates = self._memory_repo.search_by_vector(conn, query_embedding, limit=10)
300
+
301
+ # Merge with FTS results
302
+ fts_ids = self._memory_repo.search_fts(conn, query, limit=5)
303
+ fts_set = set(fts_ids)
304
+ for fts_id in fts_ids:
305
+ if not any(fts_id == c[0] for c in candidates):
306
+ candidates.append((fts_id, 0.5))
307
+
308
+ for memory_id, score in candidates:
309
+ if memory_id in seen_ids:
310
+ continue
311
+ seen_ids.add(memory_id)
312
+
313
+ memory = self._memory_repo.get(conn, memory_id)
314
+ if not memory or not memory.summary:
315
+ continue
316
+
317
+ adjusted_score = score * memory.decay_score
318
+ if memory_id in fts_set:
319
+ adjusted_score += 0.2
320
+
321
+ tokens = count_tokens(memory.summary)
322
+ if not budget.consume(tokens):
323
+ return items
324
+
325
+ self._memory_repo.update_accessed(conn, memory_id)
326
+ items.append(RecallItem(
327
+ memory_id=memory.id,
328
+ content=memory.summary,
329
+ score=adjusted_score,
330
+ scope=memory.scope,
331
+ tags=memory.tags,
332
+ created_at=memory.created_at,
333
+ ))
334
+
335
+ return items
336
+
337
+ def _recall_chunks(
338
+ self,
339
+ scopes: list[str],
340
+ query: str,
341
+ budget: TokenBudget,
342
+ ) -> list[RecallItem]:
343
+ items: list[RecallItem] = []
344
+ query_embedding = self._embeddings.embed(query)
345
+
346
+ seen_ids: set[str] = set()
347
+ for s in scopes:
348
+ conn = self._storage.get_database(s)
349
+
350
+ candidates = self._memory_repo.search_by_vector(conn, query_embedding, limit=10)
351
+
352
+ # Merge with FTS results
353
+ fts_ids = self._memory_repo.search_fts(conn, query, limit=5)
354
+ fts_set = set(fts_ids)
355
+ for fts_id in fts_ids:
356
+ if not any(fts_id == c[0] for c in candidates):
357
+ candidates.append((fts_id, 0.5))
358
+
359
+ for memory_id, score in candidates:
360
+ if memory_id in seen_ids:
361
+ continue
362
+ seen_ids.add(memory_id)
363
+
364
+ memory = self._memory_repo.get(conn, memory_id)
365
+ if not memory:
366
+ continue
367
+
368
+ adjusted_score = score * memory.decay_score
369
+ if memory_id in fts_set:
370
+ adjusted_score += 0.2
371
+
372
+ tokens = count_tokens(memory.content)
373
+ if not budget.consume(tokens):
374
+ return items
375
+
376
+ self._memory_repo.update_accessed(conn, memory_id)
377
+ items.append(RecallItem(
378
+ memory_id=memory.id,
379
+ content=memory.content,
380
+ score=adjusted_score,
381
+ scope=memory.scope,
382
+ tags=memory.tags,
383
+ created_at=memory.created_at,
384
+ ))
385
+
386
+ return items
387
+
388
+ def _is_sufficient(self, items: list[RecallItem], budget: TokenBudget) -> bool:
389
+ if not items:
390
+ return False
391
+ if budget.used_tokens >= budget.max_tokens * 0.6:
392
+ return True
393
+ avg_score = sum(i.score for i in items) / len(items)
394
+ return (
395
+ len(items) >= 3
396
+ and avg_score > self._config.sufficiency_confidence_threshold
397
+ )
398
+
399
+ def _build_result(
400
+ self,
401
+ items: list[RecallItem],
402
+ budget: TokenBudget,
403
+ query: str,
404
+ ) -> RecallResult:
405
+ seen: dict[str, RecallItem] = {}
406
+ for item in items:
407
+ key = item.content
408
+ if key not in seen or item.score > seen[key].score:
409
+ seen[key] = item
410
+
411
+ unique = sorted(seen.values(), key=lambda x: x.score, reverse=True)
412
+ return RecallResult(
413
+ memories=unique,
414
+ total_tokens=budget.used_tokens,
415
+ query=query,
416
+ )
417
+
418
+ async def forget(
419
+ self,
420
+ query: str | None = None,
421
+ memory_id: str | None = None,
422
+ scope: str | None = None,
423
+ dry_run: bool = True,
424
+ cwd: str = ".",
425
+ ) -> ForgetResult:
426
+ scopes = [scope] if scope else self._scope_manager.resolve(cwd)
427
+ target_scope = scopes[0]
428
+ to_delete: list[str] = []
429
+
430
+ if memory_id:
431
+ to_delete = [memory_id]
432
+ elif query:
433
+ query_embedding = self._embeddings.embed(query)
434
+ for s in scopes:
435
+ conn = self._storage.get_database(s)
436
+ results = self._memory_repo.search_by_vector(conn, query_embedding, limit=5)
437
+ for mid, score in results:
438
+ if score > 0.3:
439
+ to_delete.append(mid)
440
+ target_scope = s
441
+
442
+ if not dry_run:
443
+ for mid in to_delete:
444
+ for s in scopes:
445
+ conn = self._storage.get_database(s)
446
+ memory = self._memory_repo.get(conn, mid)
447
+ if memory:
448
+ self._fact_repo.delete_by_memory(conn, mid)
449
+ self._memory_repo.delete(conn, mid)
450
+ target_scope = s
451
+ break
452
+
453
+ return ForgetResult(deleted=to_delete, scope=target_scope, dry_run=dry_run)
454
+
455
+ async def manage_scopes(
456
+ self,
457
+ action: str,
458
+ name: str | None = None,
459
+ path: str | None = None,
460
+ cwd: str = ".",
461
+ ) -> dict:
462
+ if action == "list":
463
+ scope_names = self._storage.list_scopes()
464
+ infos = []
465
+ for s in scope_names:
466
+ conn = self._storage.get_database(s)
467
+ mem_count = self._memory_repo.count(conn)
468
+ fact_count = self._fact_repo.count(conn)
469
+ db_path = self._storage.get_database_path(s)
470
+ size = db_path.stat().st_size if db_path.exists() else 0
471
+ infos.append({
472
+ "name": s,
473
+ "memories": mem_count,
474
+ "facts": fact_count,
475
+ "size_bytes": size,
476
+ })
477
+ return {"action": "list", "scopes": infos}
478
+
479
+ elif action == "create" and name:
480
+ self._storage.get_database(name)
481
+ return {"action": "create", "scope": name, "status": "created"}
482
+
483
+ elif action == "delete" and name:
484
+ if name == "global":
485
+ return {"action": "delete", "scope": name, "status": "cannot delete global scope"}
486
+ self._storage.delete_scope(name)
487
+ return {"action": "delete", "scope": name, "status": "deleted"}
488
+
489
+ elif action == "link" and name and path:
490
+ config_path = self._base_path / "config.json"
491
+ config = {}
492
+ if config_path.exists():
493
+ with open(config_path) as f:
494
+ config = json.load(f)
495
+ config.setdefault("scopes", {}).setdefault("mappings", {})[path] = name
496
+ with open(config_path, "w") as f:
497
+ json.dump(config, f, indent=2)
498
+ self._scope_manager.reload()
499
+ return {"action": "link", "path": path, "scope": name, "status": "linked"}
500
+
501
+ elif action == "unlink" and path:
502
+ config_path = self._base_path / "config.json"
503
+ config = {}
504
+ if config_path.exists():
505
+ with open(config_path) as f:
506
+ config = json.load(f)
507
+ mappings = config.get("scopes", {}).get("mappings", {})
508
+ mappings.pop(path, None)
509
+ with open(config_path, "w") as f:
510
+ json.dump(config, f, indent=2)
511
+ self._scope_manager.reload()
512
+ return {"action": "unlink", "path": path, "status": "unlinked"}
513
+
514
+ elif action == "info" and name:
515
+ conn = self._storage.get_database(name)
516
+ db_path = self._storage.get_database_path(name)
517
+ return {
518
+ "action": "info",
519
+ "scope": name,
520
+ "memories": self._memory_repo.count(conn),
521
+ "facts": self._fact_repo.count(conn),
522
+ "size_bytes": db_path.stat().st_size if db_path.exists() else 0,
523
+ }
524
+
525
+ return {"action": action, "status": "invalid action or missing parameters"}
526
+
527
+ async def status(self, scope: str | None = None, cwd: str = ".") -> StatusResult:
528
+ scopes = [scope] if scope else self._storage.list_scopes()
529
+ if not scopes:
530
+ scopes = ["global"]
531
+
532
+ scope_infos = []
533
+ total_mem = 0
534
+ total_facts = 0
535
+ total_size = 0
536
+
537
+ for s in scopes:
538
+ conn = self._storage.get_database(s)
539
+ mem_count = self._memory_repo.count(conn)
540
+ fact_count = self._fact_repo.count(conn)
541
+ db_path = self._storage.get_database_path(s)
542
+ size = db_path.stat().st_size if db_path.exists() else 0
543
+
544
+ scope_infos.append({
545
+ "name": s,
546
+ "memories": mem_count,
547
+ "facts": fact_count,
548
+ "size_bytes": size,
549
+ })
550
+ total_mem += mem_count
551
+ total_facts += fact_count
552
+ total_size += size
553
+
554
+ return StatusResult(
555
+ scopes=scope_infos,
556
+ total_memories=total_mem,
557
+ total_facts=total_facts,
558
+ total_size_bytes=total_size,
559
+ )
560
+
561
+ async def run_decay(self, scope: str | None = None, cwd: str = ".") -> int:
562
+ scopes = [scope] if scope else self._storage.list_scopes()
563
+ total = 0
564
+ for s in scopes:
565
+ conn = self._storage.get_database(s)
566
+ total += recalculate_decay_scores(
567
+ conn,
568
+ decay_lambda=self._config.decay_lambda,
569
+ min_score=self._config.decay_min_score,
570
+ )
571
+ return total
572
+
573
+ def close(self) -> None:
574
+ self._storage.close_all()
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from cortex_claude.models.scope import ScopeConfig
7
+
8
+
9
+ class ScopeManager:
10
+ def __init__(self, base_path: Path):
11
+ self._base_path = base_path
12
+ self._config = self._load_config()
13
+
14
+ def _load_config(self) -> ScopeConfig:
15
+ config_path = self._base_path / "config.json"
16
+ if not config_path.exists():
17
+ return ScopeConfig()
18
+
19
+ with open(config_path) as f:
20
+ raw = json.load(f)
21
+
22
+ scopes_raw = raw.get("scopes", {})
23
+ return ScopeConfig(
24
+ mappings=scopes_raw.get("mappings", {}),
25
+ default=scopes_raw.get("default", "global"),
26
+ search_order=scopes_raw.get("search_order", "project_first"),
27
+ )
28
+
29
+ def resolve(self, cwd: str) -> list[str]:
30
+ cwd_path = Path(cwd).resolve()
31
+ scopes: list[str] = []
32
+
33
+ for path_str, scope_name in self._config.mappings.items():
34
+ mapped_path = Path(path_str).resolve()
35
+ if cwd_path == mapped_path or mapped_path in cwd_path.parents:
36
+ scopes.append(scope_name)
37
+
38
+ if "global" not in scopes:
39
+ scopes.append("global")
40
+
41
+ return scopes
42
+
43
+ def get_write_scope(self, cwd: str) -> str:
44
+ scopes = self.resolve(cwd)
45
+ return scopes[0]
46
+
47
+ def reload(self) -> None:
48
+ self._config = self._load_config()