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.
Files changed (145) hide show
  1. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/PKG-INFO +13 -2
  2. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/README.md +12 -1
  3. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/pyproject.toml +1 -1
  4. cortex_claude-0.7.2/src/cortex_claude/__init__.py +1 -0
  5. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/clustering/engine.py +106 -5
  6. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/code/extractor.py +23 -0
  7. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/code/facts.py +14 -4
  8. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/core/engine.py +64 -45
  9. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/daemon.py +16 -0
  10. cortex_claude-0.7.2/src/cortex_claude/server/tools/code.py +217 -0
  11. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/setup.py +62 -7
  12. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/storage/fact_repo.py +28 -13
  13. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/storage/memory_repo.py +46 -4
  14. cortex_claude-0.7.2/tests/integration/test_code_graph_reindex.py +79 -0
  15. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/integration/test_forget_scopes_status.py +58 -0
  16. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/integration/test_save_recall.py +21 -0
  17. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/unit/test_clustering.py +77 -1
  18. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/unit/test_code_extractor.py +55 -0
  19. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/unit/test_code_facts.py +23 -0
  20. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/unit/test_fact_repo.py +17 -0
  21. cortex_claude-0.7.0/.claude/scheduled_tasks.lock +0 -1
  22. cortex_claude-0.7.0/src/cortex_claude/__init__.py +0 -1
  23. cortex_claude-0.7.0/src/cortex_claude/server/tools/code.py +0 -181
  24. cortex_claude-0.7.0/tests/integration/test_code_graph_reindex.py +0 -43
  25. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/.claude/hooks/post-tool-use.sh +0 -0
  26. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/.claude/hooks/session-start.sh +0 -0
  27. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  28. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  29. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/.github/workflows/ci.yml +0 -0
  30. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/.github/workflows/publish.yml +0 -0
  31. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/.gitignore +0 -0
  32. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/ARCHITECTURE.md +0 -0
  33. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/CONTRIBUTING.md +0 -0
  34. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/LICENSE +0 -0
  35. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/docs/example-ui.gif +0 -0
  36. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/docs/logo.png +0 -0
  37. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/examples/config.json +0 -0
  38. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/examples/mcp-global.json +0 -0
  39. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/examples/mcp-project.json +0 -0
  40. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/scripts/benchmark.py +0 -0
  41. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/scripts/demo.py +0 -0
  42. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/__main__.py +0 -0
  43. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/capture/__init__.py +0 -0
  44. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/capture/filter.py +0 -0
  45. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/clustering/__init__.py +0 -0
  46. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/code/__init__.py +0 -0
  47. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/core/__init__.py +0 -0
  48. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/core/decay.py +0 -0
  49. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/core/privacy.py +0 -0
  50. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/core/scope_manager.py +0 -0
  51. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/core/token_budget.py +0 -0
  52. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/embeddings/__init__.py +0 -0
  53. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/embeddings/engine.py +0 -0
  54. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/embeddings/tokenizer.py +0 -0
  55. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/facts/__init__.py +0 -0
  56. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/facts/claude_extract.py +0 -0
  57. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/facts/extractor.py +0 -0
  58. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/facts/normalizer.py +0 -0
  59. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/facts/patterns.py +0 -0
  60. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/facts/spacy_extract.py +0 -0
  61. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/facts/temporal.py +0 -0
  62. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/models/__init__.py +0 -0
  63. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/models/fact.py +0 -0
  64. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/models/memory.py +0 -0
  65. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/models/scope.py +0 -0
  66. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/pipeline/__init__.py +0 -0
  67. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/server/__init__.py +0 -0
  68. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/server/app.py +0 -0
  69. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/server/config.py +0 -0
  70. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/server/tools/__init__.py +0 -0
  71. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/server/tools/clusters.py +0 -0
  72. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/server/tools/facts.py +0 -0
  73. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/server/tools/forget.py +0 -0
  74. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/server/tools/recall.py +0 -0
  75. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/server/tools/save.py +0 -0
  76. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/server/tools/scopes.py +0 -0
  77. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/server/tools/status.py +0 -0
  78. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/server/tools/traverse.py +0 -0
  79. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/storage/__init__.py +0 -0
  80. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/storage/cluster_repo.py +0 -0
  81. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/storage/database.py +0 -0
  82. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/storage/migrations.py +0 -0
  83. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/summarizer/__init__.py +0 -0
  84. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/summarizer/extractive.py +0 -0
  85. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/summarizer/scoring.py +0 -0
  86. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/__init__.py +0 -0
  87. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/.gitignore +0 -0
  88. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/README.md +0 -0
  89. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/eslint.config.js +0 -0
  90. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/index.html +0 -0
  91. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/package-lock.json +0 -0
  92. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/package.json +0 -0
  93. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/public/favicon.svg +0 -0
  94. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/public/icons.svg +0 -0
  95. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/src/App.tsx +0 -0
  96. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/src/assets/hero.png +0 -0
  97. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/src/assets/vite.svg +0 -0
  98. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/src/components/DetailPanel.tsx +0 -0
  99. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/src/components/GraphView.tsx +0 -0
  100. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/src/components/Header.tsx +0 -0
  101. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/src/components/Sidebar.tsx +0 -0
  102. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/src/index.css +0 -0
  103. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/src/lib/api.ts +0 -0
  104. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/src/lib/cn.ts +0 -0
  105. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/src/main.tsx +0 -0
  106. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/src/vite-env.d.ts +0 -0
  107. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/tsconfig.app.json +0 -0
  108. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/tsconfig.json +0 -0
  109. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/tsconfig.node.json +0 -0
  110. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/frontend/vite.config.ts +0 -0
  111. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/server.py +0 -0
  112. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/static/assets/index-BiPLkRYh.css +0 -0
  113. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/static/assets/index-Cg9ibm7y.js +0 -0
  114. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/static/favicon.svg +0 -0
  115. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/static/icons.svg +0 -0
  116. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/src/cortex_claude/web/static/index.html +0 -0
  117. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/__init__.py +0 -0
  118. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/conftest.py +0 -0
  119. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/integration/__init__.py +0 -0
  120. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/integration/test_confidence.py +0 -0
  121. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/integration/test_daemon_project_scan.py +0 -0
  122. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/integration/test_fact_merging.py +0 -0
  123. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/integration/test_fts_and_decay.py +0 -0
  124. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/integration/test_graph_traversal.py +0 -0
  125. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/integration/test_privacy.py +0 -0
  126. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/integration/test_progressive_recall.py +0 -0
  127. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/integration/test_storage.py +0 -0
  128. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/unit/__init__.py +0 -0
  129. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/unit/test_capture_filter.py +0 -0
  130. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/unit/test_claude_extract.py +0 -0
  131. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/unit/test_config.py +0 -0
  132. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/unit/test_decay.py +0 -0
  133. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/unit/test_facts.py +0 -0
  134. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/unit/test_migration_v6.py +0 -0
  135. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/unit/test_migration_v7.py +0 -0
  136. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/unit/test_normalizer.py +0 -0
  137. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/unit/test_patterns.py +0 -0
  138. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/unit/test_pre_tool_use_hook.py +0 -0
  139. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/unit/test_privacy.py +0 -0
  140. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/unit/test_scope_manager.py +0 -0
  141. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/unit/test_summarizer.py +0 -0
  142. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/unit/test_temporal.py +0 -0
  143. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/unit/test_token_budget.py +0 -0
  144. {cortex_claude-0.7.0 → cortex_claude-0.7.2}/tests/unit/test_tokenizer.py +0 -0
  145. {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.0
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.0-green.svg" alt="Version">
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 &mdash; 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.0-green.svg" alt="Version">
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 &mdash; 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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cortex-claude"
3
- version = "0.7.0"
3
+ version = "0.7.2"
4
4
  description = "Local-first, token-efficient memory system for Claude Code via MCP"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -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: clustering is append-only. A memory is only ever considered while
68
- its `cluster_id` is NULL, so once assigned it never migrates even if a
69
- better-fitting cluster appears later. Memories are processed in a fixed
70
- `created_at ASC` order so repeated runs over the same data are
71
- deterministic. To force a full re-clustering (e.g. after tuning
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 (one per symbol)
76
- - subject → in_language → lang (one per symbol)
77
- - subject → calls → callee (one per call site)
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
- self._memory_repo.save(conn, memory, embedding)
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
- facts = extract_facts(
113
- content,
114
- min_confidence=self._config.fact_min_confidence,
115
- claude_fallback=self._config.fact_claude_fallback,
116
- claude_confidence_threshold=self._config.fact_claude_confidence_threshold,
117
- )
118
- for fact in facts:
119
- fact.source_memory_id = memory.id
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
- contradictions = self._fact_repo.detect_contradictions(conn, fact, write_scope)
124
- if contradictions:
125
- self._fact_repo.penalize(conn, [c.id for c in contradictions])
126
- self._fact_repo.save_batch(conn, facts)
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
- Note: deleting a clustered memory updates the owning cluster's
596
- member_count (via recount_members) but does not recompute its
597
- centroid — the centroid drifts slightly stale until the next
598
- `cortex_clusters action="backfill"`. Recomputing it on every forget
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
- touched_cluster_scopes: set[str] = set()
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
- touched_cluster_scopes.add(s)
651
+ touched_clusters.add((s, cluster_id))
641
652
 
642
- for s in touched_cluster_scopes:
653
+ for s, cluster_id in touched_clusters:
643
654
  conn = self._storage.get_database(s)
644
- self._cluster_repo.recount_members(conn, s)
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()