cortex-claude 0.7.0__tar.gz → 0.7.2__tar.gz
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-0.7.0 → cortex_claude-0.7.2}/PKG-INFO +13 -2
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/README.md +12 -1
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/pyproject.toml +1 -1
- cortex_claude-0.7.2/src/cortex_claude/__init__.py +1 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/clustering/engine.py +106 -5
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/code/extractor.py +23 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/code/facts.py +14 -4
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/core/engine.py +64 -45
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/daemon.py +16 -0
- cortex_claude-0.7.2/src/cortex_claude/server/tools/code.py +217 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/setup.py +62 -7
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/storage/fact_repo.py +28 -13
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/storage/memory_repo.py +46 -4
- cortex_claude-0.7.2/tests/integration/test_code_graph_reindex.py +79 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/integration/test_forget_scopes_status.py +58 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/integration/test_save_recall.py +21 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/unit/test_clustering.py +77 -1
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/unit/test_code_extractor.py +55 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/unit/test_code_facts.py +23 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/unit/test_fact_repo.py +17 -0
- cortex_claude-0.7.0/.claude/scheduled_tasks.lock +0 -1
- cortex_claude-0.7.0/src/cortex_claude/__init__.py +0 -1
- cortex_claude-0.7.0/src/cortex_claude/server/tools/code.py +0 -181
- cortex_claude-0.7.0/tests/integration/test_code_graph_reindex.py +0 -43
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/.claude/hooks/post-tool-use.sh +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/.claude/hooks/session-start.sh +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/.github/workflows/ci.yml +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/.github/workflows/publish.yml +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/.gitignore +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/ARCHITECTURE.md +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/CONTRIBUTING.md +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/LICENSE +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/docs/example-ui.gif +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/docs/logo.png +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/examples/config.json +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/examples/mcp-global.json +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/examples/mcp-project.json +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/scripts/benchmark.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/scripts/demo.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/__main__.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/capture/__init__.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/capture/filter.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/clustering/__init__.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/code/__init__.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/core/__init__.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/core/decay.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/core/privacy.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/core/scope_manager.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/core/token_budget.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/embeddings/__init__.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/embeddings/engine.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/embeddings/tokenizer.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/facts/__init__.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/facts/claude_extract.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/facts/extractor.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/facts/normalizer.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/facts/patterns.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/facts/spacy_extract.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/facts/temporal.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/models/__init__.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/models/fact.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/models/memory.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/models/scope.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/pipeline/__init__.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/server/__init__.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/server/app.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/server/config.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/server/tools/__init__.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/server/tools/clusters.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/server/tools/facts.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/server/tools/forget.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/server/tools/recall.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/server/tools/save.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/server/tools/scopes.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/server/tools/status.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/server/tools/traverse.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/storage/__init__.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/storage/cluster_repo.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/storage/database.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/storage/migrations.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/summarizer/__init__.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/summarizer/extractive.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/summarizer/scoring.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/__init__.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/.gitignore +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/README.md +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/eslint.config.js +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/index.html +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/package-lock.json +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/package.json +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/public/favicon.svg +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/public/icons.svg +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/src/App.tsx +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/src/assets/hero.png +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/src/assets/vite.svg +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/src/components/DetailPanel.tsx +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/src/components/GraphView.tsx +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/src/components/Header.tsx +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/src/components/Sidebar.tsx +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/src/index.css +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/src/lib/api.ts +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/src/lib/cn.ts +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/src/main.tsx +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/src/vite-env.d.ts +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/tsconfig.app.json +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/tsconfig.json +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/tsconfig.node.json +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/vite.config.ts +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/server.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/static/assets/index-BiPLkRYh.css +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/static/assets/index-Cg9ibm7y.js +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/static/favicon.svg +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/static/icons.svg +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/static/index.html +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/__init__.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/conftest.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/integration/__init__.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/integration/test_confidence.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/integration/test_daemon_project_scan.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/integration/test_fact_merging.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/integration/test_fts_and_decay.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/integration/test_graph_traversal.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/integration/test_privacy.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/integration/test_progressive_recall.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/integration/test_storage.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/unit/__init__.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/unit/test_capture_filter.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/unit/test_claude_extract.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/unit/test_config.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/unit/test_decay.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/unit/test_facts.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/unit/test_migration_v6.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/unit/test_migration_v7.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/unit/test_normalizer.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/unit/test_patterns.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/unit/test_pre_tool_use_hook.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/unit/test_privacy.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/unit/test_scope_manager.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/unit/test_summarizer.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/unit/test_temporal.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/unit/test_token_budget.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/unit/test_tokenizer.py +0 -0
- {cortex_claude-0.7.0 → cortex_claude-0.7.2}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: cortex-claude
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.2
|
|
4
4
|
Summary: Local-first, token-efficient memory system for Claude Code via MCP
|
|
5
5
|
Project-URL: Homepage, https://github.com/rafaelaugusto/cortex-claude
|
|
6
6
|
Project-URL: Repository, https://github.com/rafaelaugusto/cortex-claude
|
|
@@ -68,7 +68,7 @@ Description-Content-Type: text/markdown
|
|
|
68
68
|
<img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License">
|
|
69
69
|
</a>
|
|
70
70
|
<a href="pyproject.toml">
|
|
71
|
-
<img src="https://img.shields.io/badge/version-0.7.
|
|
71
|
+
<img src="https://img.shields.io/badge/version-0.7.2-green.svg" alt="Version">
|
|
72
72
|
</a>
|
|
73
73
|
<a href="pyproject.toml">
|
|
74
74
|
<img src="https://img.shields.io/badge/python-%3E%3D3.11-brightgreen.svg" alt="Python">
|
|
@@ -177,6 +177,17 @@ That's it. The setup command:
|
|
|
177
177
|
|
|
178
178
|
Restart Claude Code and it works in **every project**, no per-project config needed. Claude will automatically consult Cortex before saying "I don't know" and save important context to memory.
|
|
179
179
|
|
|
180
|
+
### Upgrading
|
|
181
|
+
|
|
182
|
+
`uv tool install cortex-claude` is idempotent by design — if it's already installed, running it again is a no-op and does **not** fetch a newer version, even after you've `uv tool install`ed a fresh copy. Use the dedicated upgrade command instead:
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
uv tool upgrade cortex-claude
|
|
186
|
+
cortex-claude setup
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
(`pip install --upgrade cortex-claude` is the equivalent if you installed via pip.)
|
|
190
|
+
|
|
180
191
|
### Alternative: pip
|
|
181
192
|
|
|
182
193
|
```bash
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
<img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License">
|
|
14
14
|
</a>
|
|
15
15
|
<a href="pyproject.toml">
|
|
16
|
-
<img src="https://img.shields.io/badge/version-0.7.
|
|
16
|
+
<img src="https://img.shields.io/badge/version-0.7.2-green.svg" alt="Version">
|
|
17
17
|
</a>
|
|
18
18
|
<a href="pyproject.toml">
|
|
19
19
|
<img src="https://img.shields.io/badge/python-%3E%3D3.11-brightgreen.svg" alt="Python">
|
|
@@ -122,6 +122,17 @@ That's it. The setup command:
|
|
|
122
122
|
|
|
123
123
|
Restart Claude Code and it works in **every project**, no per-project config needed. Claude will automatically consult Cortex before saying "I don't know" and save important context to memory.
|
|
124
124
|
|
|
125
|
+
### Upgrading
|
|
126
|
+
|
|
127
|
+
`uv tool install cortex-claude` is idempotent by design — if it's already installed, running it again is a no-op and does **not** fetch a newer version, even after you've `uv tool install`ed a fresh copy. Use the dedicated upgrade command instead:
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
uv tool upgrade cortex-claude
|
|
131
|
+
cortex-claude setup
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
(`pip install --upgrade cortex-claude` is the equivalent if you installed via pip.)
|
|
135
|
+
|
|
125
136
|
### Alternative: pip
|
|
126
137
|
|
|
127
138
|
```bash
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.7.2"
|
|
@@ -20,6 +20,14 @@ class ClusteringConfig:
|
|
|
20
20
|
max_label_length: int = 60
|
|
21
21
|
saves_between_runs: int = 20
|
|
22
22
|
cooldown_seconds: int = 300
|
|
23
|
+
# How many incremental cluster_scope() runs happen before a reassignment
|
|
24
|
+
# pass considers moving stale (poorly-fit) memories to a better cluster.
|
|
25
|
+
# 0 disables reassignment entirely. See ClusteringEngine.reassign_stale.
|
|
26
|
+
reassign_after_runs: int = 10
|
|
27
|
+
# A member is "stale" if its similarity to its own cluster's centroid
|
|
28
|
+
# has drifted below threshold * this factor — i.e. it's no longer a
|
|
29
|
+
# confident fit for the cluster it's sitting in.
|
|
30
|
+
reassign_drift_factor: float = 0.9
|
|
23
31
|
|
|
24
32
|
@classmethod
|
|
25
33
|
def from_dict(cls, raw: dict) -> ClusteringConfig:
|
|
@@ -33,6 +41,8 @@ class ClusteringConfig:
|
|
|
33
41
|
cfg.max_label_length = raw.get("max_label_length", cfg.max_label_length)
|
|
34
42
|
cfg.saves_between_runs = raw.get("saves_between_runs", cfg.saves_between_runs)
|
|
35
43
|
cfg.cooldown_seconds = raw.get("cooldown_seconds", cfg.cooldown_seconds)
|
|
44
|
+
cfg.reassign_after_runs = raw.get("reassign_after_runs", cfg.reassign_after_runs)
|
|
45
|
+
cfg.reassign_drift_factor = raw.get("reassign_drift_factor", cfg.reassign_drift_factor)
|
|
36
46
|
return cfg
|
|
37
47
|
|
|
38
48
|
|
|
@@ -44,6 +54,13 @@ class ClusterStats:
|
|
|
44
54
|
skipped: int = 0
|
|
45
55
|
|
|
46
56
|
|
|
57
|
+
@dataclass
|
|
58
|
+
class ReassignStats:
|
|
59
|
+
moved: int = 0
|
|
60
|
+
examined: int = 0
|
|
61
|
+
emptied_clusters: int = 0
|
|
62
|
+
|
|
63
|
+
|
|
47
64
|
def _cosine(a: np.ndarray, b: np.ndarray) -> float:
|
|
48
65
|
return _cosine_with_norms(a, float(np.linalg.norm(a)), b, float(np.linalg.norm(b)))
|
|
49
66
|
|
|
@@ -64,11 +81,15 @@ class ClusteringEngine:
|
|
|
64
81
|
|
|
65
82
|
Labels are derived from the most frequent subjects/objects in the cluster's facts.
|
|
66
83
|
|
|
67
|
-
Note:
|
|
68
|
-
its `cluster_id` is NULL, so
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
84
|
+
Note: incremental assignment (cluster_scope) is append-only — a memory
|
|
85
|
+
is only ever considered while its `cluster_id` is NULL, so a single run
|
|
86
|
+
never migrates an already-assigned memory even if a better-fitting
|
|
87
|
+
cluster appears later. Memories are processed in a fixed `created_at
|
|
88
|
+
ASC` order so repeated runs over the same data are deterministic.
|
|
89
|
+
`reassign_stale` mitigates drift from the append-only design with a
|
|
90
|
+
separate, periodic pass that moves poorly-fit memories to a better
|
|
91
|
+
cluster (see its docstring) — this is targeted, not a full
|
|
92
|
+
re-clustering. To force a full re-clustering (e.g. after tuning
|
|
72
93
|
`similarity_threshold`), use `cortex_clusters action="backfill"`, which
|
|
73
94
|
resets all assignments in the scope before re-running.
|
|
74
95
|
"""
|
|
@@ -145,6 +166,86 @@ class ClusteringEngine:
|
|
|
145
166
|
|
|
146
167
|
return stats
|
|
147
168
|
|
|
169
|
+
def reassign_stale(self, conn: sqlite3.Connection, scope: str) -> ReassignStats:
|
|
170
|
+
"""Move poorly-fit memories to a better cluster.
|
|
171
|
+
|
|
172
|
+
Clustering is normally append-only (see class docstring) — this is
|
|
173
|
+
the mitigation for that: a periodic, targeted pass (not a full
|
|
174
|
+
re-clustering) that only touches memories whose similarity to their
|
|
175
|
+
*own* cluster's centroid has drifted below
|
|
176
|
+
`threshold * reassign_drift_factor`. For each such memory, it finds
|
|
177
|
+
the best-fitting cluster among all existing centroids in the scope
|
|
178
|
+
and moves the memory there if that's a different, better-fitting
|
|
179
|
+
cluster. Both the origin and destination clusters' centroids and
|
|
180
|
+
member_counts are updated. A cluster left with zero members is
|
|
181
|
+
deleted.
|
|
182
|
+
"""
|
|
183
|
+
stats = ReassignStats()
|
|
184
|
+
if not self.config.enabled or self.config.reassign_after_runs <= 0:
|
|
185
|
+
return stats
|
|
186
|
+
|
|
187
|
+
existing = self._clusters.list_for_scope(conn, scope)
|
|
188
|
+
centroids: dict[int, tuple[np.ndarray, float, int]] = {
|
|
189
|
+
c.id: (c.centroid, float(np.linalg.norm(c.centroid)), c.member_count)
|
|
190
|
+
for c in existing
|
|
191
|
+
if c.centroid is not None
|
|
192
|
+
}
|
|
193
|
+
if len(centroids) < 2:
|
|
194
|
+
return stats
|
|
195
|
+
|
|
196
|
+
members = self._memories.iter_clustered_with_embeddings(conn, scope)
|
|
197
|
+
threshold = self.config.similarity_threshold * self.config.reassign_drift_factor
|
|
198
|
+
|
|
199
|
+
moves: list[tuple[str, int, int]] = [] # (memory_id, from_cluster, to_cluster)
|
|
200
|
+
for memory_id, emb, current_cluster_id in members:
|
|
201
|
+
current = centroids.get(current_cluster_id)
|
|
202
|
+
if current is None:
|
|
203
|
+
continue
|
|
204
|
+
emb_norm = float(np.linalg.norm(emb))
|
|
205
|
+
current_sim = _cosine_with_norms(emb, emb_norm, current[0], current[1])
|
|
206
|
+
if current_sim >= threshold:
|
|
207
|
+
continue
|
|
208
|
+
|
|
209
|
+
stats.examined += 1
|
|
210
|
+
best_id, best_sim = current_cluster_id, current_sim
|
|
211
|
+
for cid, (centroid, centroid_norm, _) in centroids.items():
|
|
212
|
+
sim = _cosine_with_norms(emb, emb_norm, centroid, centroid_norm)
|
|
213
|
+
if sim > best_sim:
|
|
214
|
+
best_sim = sim
|
|
215
|
+
best_id = cid
|
|
216
|
+
|
|
217
|
+
if best_id != current_cluster_id:
|
|
218
|
+
moves.append((memory_id, current_cluster_id, best_id))
|
|
219
|
+
|
|
220
|
+
if not moves:
|
|
221
|
+
return stats
|
|
222
|
+
|
|
223
|
+
# Apply moves against the DB (member_count/centroid recomputed from
|
|
224
|
+
# ground truth afterward, not tracked incrementally — reassignment
|
|
225
|
+
# is infrequent enough that re-reading is cheap and avoids drift
|
|
226
|
+
# between in-memory bookkeeping and what's actually stored).
|
|
227
|
+
self._memories.set_cluster_batch(
|
|
228
|
+
conn, [(mid, to_cid) for mid, _, to_cid in moves]
|
|
229
|
+
)
|
|
230
|
+
stats.moved = len(moves)
|
|
231
|
+
|
|
232
|
+
touched: set[int] = set()
|
|
233
|
+
for _, from_cid, to_cid in moves:
|
|
234
|
+
touched.add(from_cid)
|
|
235
|
+
touched.add(to_cid)
|
|
236
|
+
|
|
237
|
+
for cid in touched:
|
|
238
|
+
remaining = self._memories.get_embeddings_for_cluster(conn, cid)
|
|
239
|
+
if not remaining:
|
|
240
|
+
self._clusters.delete(conn, cid)
|
|
241
|
+
stats.emptied_clusters += 1
|
|
242
|
+
continue
|
|
243
|
+
new_centroid = np.mean(remaining, axis=0)
|
|
244
|
+
self._clusters.update_centroid(conn, cid, new_centroid, member_count=len(remaining))
|
|
245
|
+
self._relabel_cluster(conn, cid)
|
|
246
|
+
|
|
247
|
+
return stats
|
|
248
|
+
|
|
148
249
|
def _relabel_cluster(self, conn: sqlite3.Connection, cluster_id: int) -> bool:
|
|
149
250
|
cluster = self._clusters.get(conn, cluster_id)
|
|
150
251
|
if cluster is None or cluster.member_count < self.config.min_members_for_label:
|
|
@@ -32,6 +32,13 @@ class Symbol:
|
|
|
32
32
|
calls: list[str] = field(default_factory=list)
|
|
33
33
|
extends: list[str] = field(default_factory=list)
|
|
34
34
|
imports: list[str] = field(default_factory=list)
|
|
35
|
+
# Best-effort correlation of calls to the module they were imported
|
|
36
|
+
# from, populated only where the extractor can resolve it (currently:
|
|
37
|
+
# Python `from module import name` followed by a call to `name()` in
|
|
38
|
+
# the same file — the most common case, not a full import resolver).
|
|
39
|
+
# Maps callee name -> source module. See extract_symbols for how this
|
|
40
|
+
# is built; empty when nothing could be resolved.
|
|
41
|
+
resolved_calls: dict[str, str] = field(default_factory=dict)
|
|
35
42
|
|
|
36
43
|
|
|
37
44
|
# Tree-sitter S-expression queries per language. We capture functions, classes/structs,
|
|
@@ -44,6 +51,7 @@ QUERIES: dict[str, str] = {
|
|
|
44
51
|
(class_definition name: (identifier) @class.name) @class.body
|
|
45
52
|
(import_statement name: (dotted_name) @import.name)
|
|
46
53
|
(import_from_statement module_name: (dotted_name) @import.name)
|
|
54
|
+
(import_from_statement name: (dotted_name) @import.imported_name)
|
|
47
55
|
(call function: (identifier) @call.name)
|
|
48
56
|
(call function: (attribute attribute: (identifier) @call.name))
|
|
49
57
|
""",
|
|
@@ -209,6 +217,10 @@ def extract_symbols(path: str | Path, content: str) -> list[Symbol]:
|
|
|
209
217
|
pending_calls: list[tuple[int, str]] = []
|
|
210
218
|
pending_imports: list[str] = []
|
|
211
219
|
pending_extends: list[str] = []
|
|
220
|
+
# name imported via `from module import name` -> module it came from.
|
|
221
|
+
# Populated only for Python (see QUERIES["python"]'s import.imported_name
|
|
222
|
+
# capture); empty for every other language.
|
|
223
|
+
imported_name_to_module: dict[str, str] = {}
|
|
212
224
|
|
|
213
225
|
path_str = str(path)
|
|
214
226
|
captures_iter = (
|
|
@@ -242,6 +254,14 @@ def extract_symbols(path: str | Path, content: str) -> list[Symbol]:
|
|
|
242
254
|
elif capture_name == "import.name":
|
|
243
255
|
text = node.text.decode("utf-8", errors="ignore").strip("\"'")
|
|
244
256
|
pending_imports.append(text)
|
|
257
|
+
elif capture_name == "import.imported_name":
|
|
258
|
+
imported_name = node.text.decode("utf-8", errors="ignore")
|
|
259
|
+
parent = node.parent
|
|
260
|
+
if parent is not None and parent.type == "import_from_statement":
|
|
261
|
+
module_node = parent.child_by_field_name("module_name")
|
|
262
|
+
if module_node is not None:
|
|
263
|
+
module_text = module_node.text.decode("utf-8", errors="ignore")
|
|
264
|
+
imported_name_to_module[imported_name] = module_text
|
|
245
265
|
elif capture_name == "class.extends":
|
|
246
266
|
text = node.text.decode("utf-8", errors="ignore")
|
|
247
267
|
pending_extends_with_pos.append((node.start_byte, text))
|
|
@@ -274,6 +294,9 @@ def extract_symbols(path: str | Path, content: str) -> list[Symbol]:
|
|
|
274
294
|
sym = symbols_by_body[key]
|
|
275
295
|
if call_name != sym.name and call_name not in sym.calls:
|
|
276
296
|
sym.calls.append(call_name)
|
|
297
|
+
module = imported_name_to_module.get(call_name)
|
|
298
|
+
if module is not None:
|
|
299
|
+
sym.resolved_calls[call_name] = module
|
|
277
300
|
break
|
|
278
301
|
|
|
279
302
|
# Attach extends to the enclosing class symbol when possible.
|
|
@@ -72,15 +72,22 @@ def symbols_to_facts(symbols: list[Symbol]) -> list[Fact]:
|
|
|
72
72
|
"""Convert extracted symbols into knowledge-graph facts.
|
|
73
73
|
|
|
74
74
|
Vocabulary:
|
|
75
|
-
- subject → defined_in → path:line
|
|
76
|
-
- subject → in_language → lang
|
|
77
|
-
- subject → calls → callee
|
|
75
|
+
- subject → defined_in → path:line (one per symbol)
|
|
76
|
+
- subject → in_language → lang (one per symbol)
|
|
77
|
+
- subject → calls → callee (one per call site)
|
|
78
78
|
- subject → extends → parent_class
|
|
79
79
|
- subject → imports → module_path
|
|
80
|
+
- path:subject → calls_from → module:callee (aditional, best-effort)
|
|
80
81
|
|
|
81
82
|
`calls`/`extends` resolution is purely textual (same-name matching
|
|
82
83
|
within the parsed file), not scope- or import-aware — two same-named
|
|
83
|
-
functions in different files/scopes will share the same graph node
|
|
84
|
+
functions in different files/scopes will share the same graph node
|
|
85
|
+
under `calls`. `calls_from` is an additive relation (never replaces
|
|
86
|
+
`calls`) that qualifies a call by both the caller's file (via a
|
|
87
|
+
`path:name` subject) and the callee's source module, but only for
|
|
88
|
+
calls the extractor could resolve against a `from module import name`
|
|
89
|
+
in the same file (see `Symbol.resolved_calls`) — most calls (same-file,
|
|
90
|
+
builtins, `module.func()` style, method calls) are not covered.
|
|
84
91
|
"""
|
|
85
92
|
facts: list[Fact] = []
|
|
86
93
|
seen: set[tuple[str, str, str]] = set()
|
|
@@ -108,6 +115,9 @@ def symbols_to_facts(symbols: list[Symbol]) -> list[Fact]:
|
|
|
108
115
|
for callee in sym.calls:
|
|
109
116
|
add(sym.name, "calls", callee)
|
|
110
117
|
|
|
118
|
+
for callee, module in sym.resolved_calls.items():
|
|
119
|
+
add(f"{sym.path}:{sym.name}", "calls_from", f"{module}:{callee}")
|
|
120
|
+
|
|
111
121
|
for parent in sym.extends:
|
|
112
122
|
add(sym.name, "extends", parent)
|
|
113
123
|
|
|
@@ -5,6 +5,8 @@ from dataclasses import dataclass
|
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
from typing import Literal
|
|
7
7
|
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
8
10
|
from cortex_claude.core.decay import recalculate_decay_scores
|
|
9
11
|
from cortex_claude.core.privacy import is_fully_private, strip_private
|
|
10
12
|
from cortex_claude.core.scope_manager import ScopeManager
|
|
@@ -88,42 +90,55 @@ class CortexEngine:
|
|
|
88
90
|
(c for c in candidates if c[1] >= self._config.dedup_similarity_threshold),
|
|
89
91
|
None,
|
|
90
92
|
)
|
|
91
|
-
if dup_candidate:
|
|
92
|
-
existing_memory = self._memory_repo.get(conn, dup_candidate[0])
|
|
93
|
-
if existing_memory:
|
|
94
|
-
merged = f"{existing_memory.content}\n\n{content}"
|
|
95
|
-
self._fact_repo.delete_by_memory(conn, existing_memory.id)
|
|
96
|
-
self._memory_repo.delete(conn, existing_memory.id)
|
|
97
|
-
content = merged
|
|
98
|
-
embedding = self._embeddings.embed(content)
|
|
99
|
-
tokens = count_tokens(content)
|
|
100
|
-
|
|
101
|
-
summary = summarize(content) if tokens > 50 else None
|
|
102
|
-
|
|
103
|
-
memory = Memory(
|
|
104
|
-
content=content,
|
|
105
|
-
summary=summary,
|
|
106
|
-
tags=tags or [],
|
|
107
|
-
scope=write_scope,
|
|
108
|
-
)
|
|
109
93
|
|
|
110
|
-
|
|
94
|
+
# Everything below is one logical unit of work (dedup merge, insert,
|
|
95
|
+
# fact extraction/contradiction handling) — run it as a single
|
|
96
|
+
# transaction so a crash or exception midway can't leave a memory
|
|
97
|
+
# persisted without its facts, or facts penalized without the new
|
|
98
|
+
# memory actually saved. Every repo call below passes commit=False;
|
|
99
|
+
# we commit once at the end (or roll back on any exception).
|
|
100
|
+
try:
|
|
101
|
+
if dup_candidate:
|
|
102
|
+
existing_memory = self._memory_repo.get(conn, dup_candidate[0])
|
|
103
|
+
if existing_memory:
|
|
104
|
+
merged = f"{existing_memory.content}\n\n{content}"
|
|
105
|
+
self._fact_repo.delete_by_memory(conn, existing_memory.id, commit=False)
|
|
106
|
+
self._memory_repo.delete(conn, existing_memory.id, commit=False)
|
|
107
|
+
content = merged
|
|
108
|
+
embedding = self._embeddings.embed(content)
|
|
109
|
+
tokens = count_tokens(content)
|
|
110
|
+
|
|
111
|
+
summary = summarize(content) if tokens > 50 else None
|
|
112
|
+
|
|
113
|
+
memory = Memory(
|
|
114
|
+
content=content,
|
|
115
|
+
summary=summary,
|
|
116
|
+
tags=tags or [],
|
|
117
|
+
scope=write_scope,
|
|
118
|
+
)
|
|
111
119
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
fact.scope = write_scope
|
|
121
|
-
if facts:
|
|
120
|
+
self._memory_repo.save(conn, memory, embedding, commit=False)
|
|
121
|
+
|
|
122
|
+
facts = extract_facts(
|
|
123
|
+
content,
|
|
124
|
+
min_confidence=self._config.fact_min_confidence,
|
|
125
|
+
claude_fallback=self._config.fact_claude_fallback,
|
|
126
|
+
claude_confidence_threshold=self._config.fact_claude_confidence_threshold,
|
|
127
|
+
)
|
|
122
128
|
for fact in facts:
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
129
|
+
fact.source_memory_id = memory.id
|
|
130
|
+
fact.scope = write_scope
|
|
131
|
+
if facts:
|
|
132
|
+
for fact in facts:
|
|
133
|
+
contradictions = self._fact_repo.detect_contradictions(conn, fact, write_scope)
|
|
134
|
+
if contradictions:
|
|
135
|
+
self._fact_repo.penalize(conn, [c.id for c in contradictions], commit=False)
|
|
136
|
+
self._fact_repo.save_batch(conn, facts, commit=False)
|
|
137
|
+
|
|
138
|
+
conn.commit()
|
|
139
|
+
except Exception:
|
|
140
|
+
conn.rollback()
|
|
141
|
+
raise
|
|
127
142
|
|
|
128
143
|
return SaveResult(
|
|
129
144
|
memory_id=memory.id,
|
|
@@ -258,8 +273,6 @@ class CortexEngine:
|
|
|
258
273
|
if cluster is None or cluster.scope != s:
|
|
259
274
|
continue
|
|
260
275
|
|
|
261
|
-
import numpy as np
|
|
262
|
-
|
|
263
276
|
centroid = cluster.centroid
|
|
264
277
|
if centroid is None:
|
|
265
278
|
continue
|
|
@@ -592,12 +605,10 @@ class CortexEngine:
|
|
|
592
605
|
) -> ForgetResult:
|
|
593
606
|
"""Delete memories by id or semantic query.
|
|
594
607
|
|
|
595
|
-
|
|
596
|
-
member_count
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
would require re-reading every remaining member's embedding, which
|
|
600
|
-
is too expensive to do per-delete.
|
|
608
|
+
Deleting a clustered memory recomputes the owning cluster's
|
|
609
|
+
centroid and member_count from its remaining members. Clusters left
|
|
610
|
+
with zero members are deleted outright rather than lingering with a
|
|
611
|
+
stale centroid.
|
|
601
612
|
"""
|
|
602
613
|
scopes = [scope] if scope else self._scope_manager.resolve(cwd)
|
|
603
614
|
# candidate_scope tracks, per to-be-deleted memory id, which scope it
|
|
@@ -625,7 +636,7 @@ class CortexEngine:
|
|
|
625
636
|
|
|
626
637
|
deleted_by_scope: dict[str, list[str]] = {}
|
|
627
638
|
if not dry_run:
|
|
628
|
-
|
|
639
|
+
touched_clusters: set[tuple[str, int]] = set()
|
|
629
640
|
for mid in to_delete:
|
|
630
641
|
s = candidate_scope.get(mid)
|
|
631
642
|
if s is None:
|
|
@@ -637,11 +648,19 @@ class CortexEngine:
|
|
|
637
648
|
cluster_id = self._memory_repo.delete(conn, mid)
|
|
638
649
|
deleted_by_scope.setdefault(s, []).append(mid)
|
|
639
650
|
if cluster_id is not None:
|
|
640
|
-
|
|
651
|
+
touched_clusters.add((s, cluster_id))
|
|
641
652
|
|
|
642
|
-
for s in
|
|
653
|
+
for s, cluster_id in touched_clusters:
|
|
643
654
|
conn = self._storage.get_database(s)
|
|
644
|
-
self.
|
|
655
|
+
remaining = self._memory_repo.get_embeddings_for_cluster(conn, cluster_id)
|
|
656
|
+
if not remaining:
|
|
657
|
+
self._cluster_repo.delete(conn, cluster_id)
|
|
658
|
+
conn.commit()
|
|
659
|
+
continue
|
|
660
|
+
new_centroid = np.mean(remaining, axis=0)
|
|
661
|
+
self._cluster_repo.update_centroid(
|
|
662
|
+
conn, cluster_id, new_centroid, member_count=len(remaining)
|
|
663
|
+
)
|
|
645
664
|
else:
|
|
646
665
|
for mid in to_delete:
|
|
647
666
|
s = candidate_scope.get(mid)
|
|
@@ -43,6 +43,7 @@ class CortexDaemon:
|
|
|
43
43
|
self._saves_since_cluster: dict[str, int] = {}
|
|
44
44
|
self._last_cluster_at: dict[str, float] = {}
|
|
45
45
|
self._cluster_in_flight: set[str] = set()
|
|
46
|
+
self._cluster_runs_since_reassign: dict[str, int] = {}
|
|
46
47
|
self._known_symbols_cache: dict[str, set[str]] = {}
|
|
47
48
|
self._project_scan_in_flight: set[str] = set()
|
|
48
49
|
self._project_scan_tasks: dict[str, asyncio.Task] = {}
|
|
@@ -305,10 +306,25 @@ class CortexDaemon:
|
|
|
305
306
|
conn = self._engine.get_scope_connection(scope)
|
|
306
307
|
stats = await asyncio.to_thread(self._cluster_engine.cluster_scope, conn, scope)
|
|
307
308
|
self._last_cluster_at[scope] = time.time()
|
|
309
|
+
self._maybe_reassign_stale(conn, scope)
|
|
308
310
|
return stats
|
|
309
311
|
finally:
|
|
310
312
|
self._cluster_in_flight.discard(scope)
|
|
311
313
|
|
|
314
|
+
def _maybe_reassign_stale(self, conn, scope: str) -> None:
|
|
315
|
+
reassign_after = self._cluster_config.reassign_after_runs
|
|
316
|
+
if reassign_after <= 0:
|
|
317
|
+
return
|
|
318
|
+
|
|
319
|
+
self._cluster_runs_since_reassign[scope] = (
|
|
320
|
+
self._cluster_runs_since_reassign.get(scope, 0) + 1
|
|
321
|
+
)
|
|
322
|
+
if self._cluster_runs_since_reassign[scope] < reassign_after:
|
|
323
|
+
return
|
|
324
|
+
|
|
325
|
+
self._cluster_runs_since_reassign[scope] = 0
|
|
326
|
+
self._cluster_engine.reassign_stale(conn, scope)
|
|
327
|
+
|
|
312
328
|
async def start(self):
|
|
313
329
|
if SOCKET_PATH.exists():
|
|
314
330
|
SOCKET_PATH.unlink()
|