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.
- cortex_claude/__init__.py +1 -0
- cortex_claude/__main__.py +10 -0
- cortex_claude/core/__init__.py +4 -0
- cortex_claude/core/decay.py +31 -0
- cortex_claude/core/engine.py +574 -0
- cortex_claude/core/scope_manager.py +48 -0
- cortex_claude/core/token_budget.py +35 -0
- cortex_claude/embeddings/__init__.py +4 -0
- cortex_claude/embeddings/engine.py +26 -0
- cortex_claude/embeddings/tokenizer.py +9 -0
- cortex_claude/facts/__init__.py +3 -0
- cortex_claude/facts/extractor.py +71 -0
- cortex_claude/facts/normalizer.py +69 -0
- cortex_claude/facts/patterns.py +37 -0
- cortex_claude/facts/spacy_extract.py +122 -0
- cortex_claude/models/__init__.py +14 -0
- cortex_claude/models/fact.py +32 -0
- cortex_claude/models/memory.py +47 -0
- cortex_claude/models/scope.py +18 -0
- cortex_claude/pipeline/__init__.py +0 -0
- cortex_claude/server/__init__.py +0 -0
- cortex_claude/server/app.py +140 -0
- cortex_claude/server/config.py +96 -0
- cortex_claude/server/tools/__init__.py +0 -0
- cortex_claude/server/tools/facts.py +29 -0
- cortex_claude/server/tools/forget.py +33 -0
- cortex_claude/server/tools/recall.py +35 -0
- cortex_claude/server/tools/save.py +23 -0
- cortex_claude/server/tools/scopes.py +49 -0
- cortex_claude/server/tools/status.py +37 -0
- cortex_claude/server/tools/traverse.py +27 -0
- cortex_claude/storage/__init__.py +5 -0
- cortex_claude/storage/database.py +81 -0
- cortex_claude/storage/fact_repo.py +117 -0
- cortex_claude/storage/memory_repo.py +142 -0
- cortex_claude/storage/migrations.py +117 -0
- cortex_claude/summarizer/__init__.py +3 -0
- cortex_claude/summarizer/extractive.py +76 -0
- cortex_claude/summarizer/scoring.py +30 -0
- cortex_claude-0.1.0.dist-info/METADATA +258 -0
- cortex_claude-0.1.0.dist-info/RECORD +44 -0
- cortex_claude-0.1.0.dist-info/WHEEL +4 -0
- cortex_claude-0.1.0.dist-info/entry_points.txt +2 -0
- cortex_claude-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -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()
|