attocode-code-intel 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (345) hide show
  1. attocode_code_intel-0.1.0.dist-info/METADATA +221 -0
  2. attocode_code_intel-0.1.0.dist-info/RECORD +345 -0
  3. attocode_code_intel-0.1.0.dist-info/WHEEL +4 -0
  4. attocode_code_intel-0.1.0.dist-info/entry_points.txt +2 -0
  5. attocode_code_intel-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. attocode_intel/GUIDELINES.md +432 -0
  7. attocode_intel/__init__.py +52 -0
  8. attocode_intel/_internal/__init__.py +1 -0
  9. attocode_intel/_internal/errors/__init__.py +126 -0
  10. attocode_intel/_internal/integrations/__init__.py +1 -0
  11. attocode_intel/_internal/integrations/context/__init__.py +1 -0
  12. attocode_intel/_internal/integrations/context/architecture_drift.py +354 -0
  13. attocode_intel/_internal/integrations/context/ast_chunker.py +202 -0
  14. attocode_intel/_internal/integrations/context/ast_service.py +1483 -0
  15. attocode_intel/_internal/integrations/context/blob_oid.py +423 -0
  16. attocode_intel/_internal/integrations/context/cache_manifest.py +221 -0
  17. attocode_intel/_internal/integrations/context/cas.py +466 -0
  18. attocode_intel/_internal/integrations/context/code_analyzer.py +528 -0
  19. attocode_intel/_internal/integrations/context/codebase_ast.py +1461 -0
  20. attocode_intel/_internal/integrations/context/codebase_context.py +1736 -0
  21. attocode_intel/_internal/integrations/context/cross_mode.py +263 -0
  22. attocode_intel/_internal/integrations/context/cross_references.py +616 -0
  23. attocode_intel/_internal/integrations/context/embedding_rotation.py +597 -0
  24. attocode_intel/_internal/integrations/context/embeddings.py +282 -0
  25. attocode_intel/_internal/integrations/context/frecency.py +427 -0
  26. attocode_intel/_internal/integrations/context/fuzzy.py +320 -0
  27. attocode_intel/_internal/integrations/context/hierarchical_explorer.py +331 -0
  28. attocode_intel/_internal/integrations/context/hydration.py +90 -0
  29. attocode_intel/_internal/integrations/context/index_repair.py +87 -0
  30. attocode_intel/_internal/integrations/context/index_store.py +586 -0
  31. attocode_intel/_internal/integrations/context/memory_store.py +602 -0
  32. attocode_intel/_internal/integrations/context/module_resolution.py +52 -0
  33. attocode_intel/_internal/integrations/context/query_constraints.py +361 -0
  34. attocode_intel/_internal/integrations/context/query_history.py +417 -0
  35. attocode_intel/_internal/integrations/context/reranker.py +351 -0
  36. attocode_intel/_internal/integrations/context/semantic_search.py +2931 -0
  37. attocode_intel/_internal/integrations/context/syntax_evidence.py +94 -0
  38. attocode_intel/_internal/integrations/context/systemone_ranker.py +245 -0
  39. attocode_intel/_internal/integrations/context/temporal_coupling.py +411 -0
  40. attocode_intel/_internal/integrations/context/trigram_index.py +820 -0
  41. attocode_intel/_internal/integrations/context/trigram_regex.py +171 -0
  42. attocode_intel/_internal/integrations/context/ts_parser.py +2199 -0
  43. attocode_intel/_internal/integrations/context/vector_store.py +795 -0
  44. attocode_intel/_internal/integrations/feature_flags/__init__.py +357 -0
  45. attocode_intel/_internal/integrations/lsp/__init__.py +1 -0
  46. attocode_intel/_internal/integrations/lsp/client.py +1140 -0
  47. attocode_intel/_internal/integrations/mcp/__init__.py +1 -0
  48. attocode_intel/_internal/integrations/mcp/client.py +253 -0
  49. attocode_intel/_internal/integrations/security/__init__.py +1 -0
  50. attocode_intel/_internal/integrations/security/custom_rules.py +176 -0
  51. attocode_intel/_internal/integrations/security/dataflow.py +522 -0
  52. attocode_intel/_internal/integrations/security/dependency_audit.py +303 -0
  53. attocode_intel/_internal/integrations/security/matcher.py +44 -0
  54. attocode_intel/_internal/integrations/security/patterns.py +724 -0
  55. attocode_intel/_internal/integrations/security/scanner.py +351 -0
  56. attocode_intel/_internal/integrations/utilities/__init__.py +1 -0
  57. attocode_intel/_internal/integrations/utilities/ignore.py +64 -0
  58. attocode_intel/_internal/integrations/utilities/token_estimate.py +51 -0
  59. attocode_intel/_internal/tricks/__init__.py +1 -0
  60. attocode_intel/_internal/tricks/recursive_context.py +357 -0
  61. attocode_intel/_internal/types/__init__.py +1 -0
  62. attocode_intel/_internal/types/events.py +289 -0
  63. attocode_intel/_internal/types/severity.py +15 -0
  64. attocode_intel/_shared.py +341 -0
  65. attocode_intel/analysis_coverage.py +56 -0
  66. attocode_intel/api/__init__.py +1 -0
  67. attocode_intel/api/access.py +32 -0
  68. attocode_intel/api/app.py +247 -0
  69. attocode_intel/api/auth/__init__.py +128 -0
  70. attocode_intel/api/auth/api_keys.py +61 -0
  71. attocode_intel/api/auth/context.py +34 -0
  72. attocode_intel/api/auth/jwt.py +66 -0
  73. attocode_intel/api/auth/oauth.py +146 -0
  74. attocode_intel/api/auth/passwords.py +15 -0
  75. attocode_intel/api/deps.py +351 -0
  76. attocode_intel/api/knowledge_bridge.py +13 -0
  77. attocode_intel/api/mcp_transport.py +51 -0
  78. attocode_intel/api/middleware.py +411 -0
  79. attocode_intel/api/models.py +698 -0
  80. attocode_intel/api/providers/__init__.py +1 -0
  81. attocode_intel/api/providers/db_provider.py +1106 -0
  82. attocode_intel/api/providers/local_provider.py +476 -0
  83. attocode_intel/api/providers/remote_provider.py +740 -0
  84. attocode_intel/api/repository_source.py +21 -0
  85. attocode_intel/api/routes/__init__.py +1 -0
  86. attocode_intel/api/routes/_pin_helper.py +158 -0
  87. attocode_intel/api/routes/activity.py +148 -0
  88. attocode_intel/api/routes/adr.py +102 -0
  89. attocode_intel/api/routes/analysis.py +515 -0
  90. attocode_intel/api/routes/api_keys.py +170 -0
  91. attocode_intel/api/routes/auth.py +428 -0
  92. attocode_intel/api/routes/branches.py +262 -0
  93. attocode_intel/api/routes/cross_repo_search.py +198 -0
  94. attocode_intel/api/routes/embeddings.py +386 -0
  95. attocode_intel/api/routes/files.py +132 -0
  96. attocode_intel/api/routes/files_v2.py +388 -0
  97. attocode_intel/api/routes/gc.py +322 -0
  98. attocode_intel/api/routes/git_v2.py +433 -0
  99. attocode_intel/api/routes/graph.py +174 -0
  100. attocode_intel/api/routes/graph_viz.py +160 -0
  101. attocode_intel/api/routes/health.py +62 -0
  102. attocode_intel/api/routes/history.py +175 -0
  103. attocode_intel/api/routes/jobs.py +84 -0
  104. attocode_intel/api/routes/learning.py +205 -0
  105. attocode_intel/api/routes/lsp.py +272 -0
  106. attocode_intel/api/routes/notify.py +623 -0
  107. attocode_intel/api/routes/operations.py +27 -0
  108. attocode_intel/api/routes/orgs.py +924 -0
  109. attocode_intel/api/routes/preferences.py +100 -0
  110. attocode_intel/api/routes/presence.py +42 -0
  111. attocode_intel/api/routes/projects.py +116 -0
  112. attocode_intel/api/routes/repos.py +47 -0
  113. attocode_intel/api/routes/search.py +180 -0
  114. attocode_intel/api/routes/snapshots.py +614 -0
  115. attocode_intel/api/routes/webhooks.py +313 -0
  116. attocode_intel/api/routes/websocket.py +116 -0
  117. attocode_intel/api/static/graph.html +239 -0
  118. attocode_intel/api/utils.py +96 -0
  119. attocode_intel/artifacts/__init__.py +45 -0
  120. attocode_intel/artifacts/hashing.py +230 -0
  121. attocode_intel/artifacts/provenance.py +190 -0
  122. attocode_intel/artifacts/retrieval_pin.py +159 -0
  123. attocode_intel/audit.py +59 -0
  124. attocode_intel/bug_finder.py +206 -0
  125. attocode_intel/bundle.py +80 -0
  126. attocode_intel/catalog.py +242 -0
  127. attocode_intel/change_review.py +75 -0
  128. attocode_intel/cli.py +3062 -0
  129. attocode_intel/community.py +63 -0
  130. attocode_intel/confidence/__init__.py +144 -0
  131. attocode_intel/confidence/jev.py +234 -0
  132. attocode_intel/confidence/llm.py +330 -0
  133. attocode_intel/confidence/redact.py +44 -0
  134. attocode_intel/confidence/settings.py +74 -0
  135. attocode_intel/config.py +256 -0
  136. attocode_intel/crypto.py +31 -0
  137. attocode_intel/db/__init__.py +7 -0
  138. attocode_intel/db/base.py +33 -0
  139. attocode_intel/db/engine.py +61 -0
  140. attocode_intel/db/models.py +529 -0
  141. attocode_intel/entrypoint.py +185 -0
  142. attocode_intel/focused_evidence.py +130 -0
  143. attocode_intel/freshness.py +154 -0
  144. attocode_intel/gateway.py +734 -0
  145. attocode_intel/git/__init__.py +7 -0
  146. attocode_intel/git/credentials.py +75 -0
  147. attocode_intel/git/manager.py +493 -0
  148. attocode_intel/git/models.py +96 -0
  149. attocode_intel/git/storage.py +98 -0
  150. attocode_intel/graph_query_parser.py +761 -0
  151. attocode_intel/helpers.py +712 -0
  152. attocode_intel/indexing/__init__.py +29 -0
  153. attocode_intel/indexing/debouncer.py +136 -0
  154. attocode_intel/indexing/delta_indexer.py +151 -0
  155. attocode_intel/indexing/full_indexer.py +323 -0
  156. attocode_intel/indexing/incremental.py +240 -0
  157. attocode_intel/indexing/parser.py +403 -0
  158. attocode_intel/indexing/summarizer.py +264 -0
  159. attocode_intel/installer.py +1896 -0
  160. attocode_intel/knowledge.py +269 -0
  161. attocode_intel/local_knowledge.py +77 -0
  162. attocode_intel/migrations/alembic.ini +36 -0
  163. attocode_intel/migrations/env.py +71 -0
  164. attocode_intel/migrations/script.py.mako +27 -0
  165. attocode_intel/migrations/versions/001_initial_schema.py +219 -0
  166. attocode_intel/migrations/versions/002_branch_version_and_embeddings.py +61 -0
  167. attocode_intel/migrations/versions/003_embedding_uniqueness_and_cascade.py +51 -0
  168. attocode_intel/migrations/versions/004_audit_events.py +38 -0
  169. attocode_intel/migrations/versions/005_user_preferences.py +30 -0
  170. attocode_intel/migrations/versions/006_google_oauth.py +27 -0
  171. attocode_intel/migrations/versions/007_pgvector_embeddings.py +28 -0
  172. attocode_intel/migrations/versions/008_symbol_references.py +37 -0
  173. attocode_intel/migrations/versions/009_webhook_secret_encryption.py +25 -0
  174. attocode_intel/migrations/versions/010_add_commits_table.py +40 -0
  175. attocode_intel/migrations/versions/011_commit_changed_files.py +25 -0
  176. attocode_intel/migrations/versions/012_branch_merged_at.py +25 -0
  177. attocode_intel/migrations/versions/013_revoked_tokens.py +32 -0
  178. attocode_intel/migrations/versions/014_blame_hunks.py +40 -0
  179. attocode_intel/migrations/versions/015_commit_file_stats.py +40 -0
  180. attocode_intel/migrations/versions/016_embedding_provenance_columns.py +179 -0
  181. attocode_intel/migrations/versions/017_provenance_table.py +102 -0
  182. attocode_intel/migrations/versions/018_repo_snapshots.py +178 -0
  183. attocode_intel/migrations/versions/019_symref_caller.py +42 -0
  184. attocode_intel/migrations/versions/020_shared_knowledge.py +29 -0
  185. attocode_intel/onboarding.py +258 -0
  186. attocode_intel/output.py +178 -0
  187. attocode_intel/precision.py +133 -0
  188. attocode_intel/presence.py +160 -0
  189. attocode_intel/probe.py +110 -0
  190. attocode_intel/project_dir.py +97 -0
  191. attocode_intel/pubsub.py +113 -0
  192. attocode_intel/query_ranking.py +242 -0
  193. attocode_intel/readiness.py +972 -0
  194. attocode_intel/redis.py +51 -0
  195. attocode_intel/reference_pages.py +60 -0
  196. attocode_intel/remote.py +131 -0
  197. attocode_intel/repo_ranker.py +246 -0
  198. attocode_intel/request_context.py +82 -0
  199. attocode_intel/rules/__init__.py +28 -0
  200. attocode_intel/rules/ci.py +320 -0
  201. attocode_intel/rules/combinators.py +293 -0
  202. attocode_intel/rules/enricher.py +127 -0
  203. attocode_intel/rules/evolution.py +452 -0
  204. attocode_intel/rules/executor.py +531 -0
  205. attocode_intel/rules/filters/__init__.py +5 -0
  206. attocode_intel/rules/filters/pipeline.py +151 -0
  207. attocode_intel/rules/formatter.py +198 -0
  208. attocode_intel/rules/hygiene.py +242 -0
  209. attocode_intel/rules/importers/__init__.py +1 -0
  210. attocode_intel/rules/importers/semgrep.py +382 -0
  211. attocode_intel/rules/loader.py +292 -0
  212. attocode_intel/rules/marketplace.py +375 -0
  213. attocode_intel/rules/metavar.py +181 -0
  214. attocode_intel/rules/model.py +317 -0
  215. attocode_intel/rules/packs/__init__.py +1 -0
  216. attocode_intel/rules/packs/community/_license_templates/Apache-2.0.txt +176 -0
  217. attocode_intel/rules/packs/community/_license_templates/MIT.txt +21 -0
  218. attocode_intel/rules/packs/community/bandit-python/LICENSE +176 -0
  219. attocode_intel/rules/packs/community/bandit-python/NOTICE +13 -0
  220. attocode_intel/rules/packs/community/bandit-python/PORTING.md +23 -0
  221. attocode_intel/rules/packs/community/bandit-python/manifest.yaml +14 -0
  222. attocode_intel/rules/packs/community/bandit-python/rules/B105-hardcoded-password.yaml +23 -0
  223. attocode_intel/rules/packs/community/bandit-python/rules/B303-weak-md5.yaml +21 -0
  224. attocode_intel/rules/packs/community/bandit-python/rules/B304-weak-des.yaml +20 -0
  225. attocode_intel/rules/packs/community/bandit-python/rules/B307-use-of-eval.yaml +22 -0
  226. attocode_intel/rules/packs/community/bandit-python/rules/B324-weak-sha1.yaml +21 -0
  227. attocode_intel/rules/packs/community/bandit-python/rules/B501-no-cert-validation.yaml +23 -0
  228. attocode_intel/rules/packs/community/bandit-python/rules/B602-shell-true.yaml +22 -0
  229. attocode_intel/rules/packs/community/bandit-python/rules/B608-sqli.yaml +22 -0
  230. attocode_intel/rules/packs/community/eslint-typescript/LICENSE +21 -0
  231. attocode_intel/rules/packs/community/eslint-typescript/NOTICE +13 -0
  232. attocode_intel/rules/packs/community/eslint-typescript/PORTING.md +23 -0
  233. attocode_intel/rules/packs/community/eslint-typescript/manifest.yaml +14 -0
  234. attocode_intel/rules/packs/community/eslint-typescript/rules/eqeqeq.yaml +20 -0
  235. attocode_intel/rules/packs/community/eslint-typescript/rules/no-eval.yaml +22 -0
  236. attocode_intel/rules/packs/community/eslint-typescript/rules/no-implied-eval.yaml +20 -0
  237. attocode_intel/rules/packs/community/eslint-typescript/rules/no-new-func.yaml +21 -0
  238. attocode_intel/rules/packs/community/eslint-typescript/rules/no-throw-literal.yaml +19 -0
  239. attocode_intel/rules/packs/community/eslint-typescript/rules/no-var.yaml +20 -0
  240. attocode_intel/rules/packs/community/eslint-typescript/rules/no-with.yaml +18 -0
  241. attocode_intel/rules/packs/community/gosec-go/LICENSE +176 -0
  242. attocode_intel/rules/packs/community/gosec-go/NOTICE +13 -0
  243. attocode_intel/rules/packs/community/gosec-go/PORTING.md +23 -0
  244. attocode_intel/rules/packs/community/gosec-go/manifest.yaml +14 -0
  245. attocode_intel/rules/packs/community/gosec-go/rules/G101-hardcoded-credentials.yaml +22 -0
  246. attocode_intel/rules/packs/community/gosec-go/rules/G102-bind-all-interfaces.yaml +21 -0
  247. attocode_intel/rules/packs/community/gosec-go/rules/G103-unsafe-block.yaml +21 -0
  248. attocode_intel/rules/packs/community/gosec-go/rules/G201-sql-format.yaml +21 -0
  249. attocode_intel/rules/packs/community/gosec-go/rules/G304-file-inclusion.yaml +23 -0
  250. attocode_intel/rules/packs/community/gosec-go/rules/G401-weak-cipher.yaml +20 -0
  251. attocode_intel/rules/packs/community/gosec-go/rules/G402-tls-min-version.yaml +20 -0
  252. attocode_intel/rules/packs/community/gosec-go/rules/G501-md5-import.yaml +21 -0
  253. attocode_intel/rules/packs/examples/cpp/manifest.yaml +4 -0
  254. attocode_intel/rules/packs/examples/cpp/rules/security.yaml +90 -0
  255. attocode_intel/rules/packs/examples/cpp/rules/structural.yaml +42 -0
  256. attocode_intel/rules/packs/examples/csharp/manifest.yaml +4 -0
  257. attocode_intel/rules/packs/examples/csharp/rules/security.yaml +69 -0
  258. attocode_intel/rules/packs/examples/csharp/rules/structural.yaml +42 -0
  259. attocode_intel/rules/packs/examples/go/manifest.yaml +4 -0
  260. attocode_intel/rules/packs/examples/go/rules/performance.yaml +136 -0
  261. attocode_intel/rules/packs/examples/go/rules/security.yaml +48 -0
  262. attocode_intel/rules/packs/examples/go/rules/structural.yaml +58 -0
  263. attocode_intel/rules/packs/examples/go/taint/sanitizers.yaml +24 -0
  264. attocode_intel/rules/packs/examples/go/taint/sinks.yaml +51 -0
  265. attocode_intel/rules/packs/examples/go/taint/sources.yaml +26 -0
  266. attocode_intel/rules/packs/examples/java/manifest.yaml +4 -0
  267. attocode_intel/rules/packs/examples/java/rules/correctness.yaml +55 -0
  268. attocode_intel/rules/packs/examples/java/rules/structural.yaml +48 -0
  269. attocode_intel/rules/packs/examples/java/taint/sinks.yaml +37 -0
  270. attocode_intel/rules/packs/examples/java/taint/sources.yaml +21 -0
  271. attocode_intel/rules/packs/examples/kotlin/manifest.yaml +4 -0
  272. attocode_intel/rules/packs/examples/kotlin/rules/correctness.yaml +50 -0
  273. attocode_intel/rules/packs/examples/kotlin/rules/structural.yaml +38 -0
  274. attocode_intel/rules/packs/examples/php/manifest.yaml +4 -0
  275. attocode_intel/rules/packs/examples/php/rules/security.yaml +66 -0
  276. attocode_intel/rules/packs/examples/php/rules/structural.yaml +41 -0
  277. attocode_intel/rules/packs/examples/python/manifest.yaml +4 -0
  278. attocode_intel/rules/packs/examples/python/rules/performance.yaml +90 -0
  279. attocode_intel/rules/packs/examples/python/rules/structural.yaml +55 -0
  280. attocode_intel/rules/packs/examples/ruby/manifest.yaml +4 -0
  281. attocode_intel/rules/packs/examples/ruby/rules/security.yaml +73 -0
  282. attocode_intel/rules/packs/examples/ruby/rules/structural.yaml +41 -0
  283. attocode_intel/rules/packs/examples/rust/manifest.yaml +4 -0
  284. attocode_intel/rules/packs/examples/rust/rules/correctness.yaml +51 -0
  285. attocode_intel/rules/packs/examples/rust/rules/structural.yaml +49 -0
  286. attocode_intel/rules/packs/examples/typescript/manifest.yaml +4 -0
  287. attocode_intel/rules/packs/examples/typescript/rules/performance.yaml +70 -0
  288. attocode_intel/rules/packs/examples/typescript/rules/structural.yaml +48 -0
  289. attocode_intel/rules/packs/pack_loader.py +244 -0
  290. attocode_intel/rules/plugins/__init__.py +1 -0
  291. attocode_intel/rules/plugins/plugin_loader.py +132 -0
  292. attocode_intel/rules/profiling.py +332 -0
  293. attocode_intel/rules/registry.py +213 -0
  294. attocode_intel/rules/sarif.py +187 -0
  295. attocode_intel/rules/synthesis.py +420 -0
  296. attocode_intel/rules/testing.py +326 -0
  297. attocode_intel/server.py +734 -0
  298. attocode_intel/service.py +3416 -0
  299. attocode_intel/sharing.py +57 -0
  300. attocode_intel/storage/__init__.py +6 -0
  301. attocode_intel/storage/branch_overlay.py +352 -0
  302. attocode_intel/storage/content_store.py +220 -0
  303. attocode_intel/storage/dependency_store.py +143 -0
  304. attocode_intel/storage/diff_engine.py +187 -0
  305. attocode_intel/storage/embedding_store.py +594 -0
  306. attocode_intel/storage/provenance_store.py +137 -0
  307. attocode_intel/storage/security_scanner_db.py +164 -0
  308. attocode_intel/storage/symbol_store.py +160 -0
  309. attocode_intel/structural_evidence.py +144 -0
  310. attocode_intel/symbol_inspection.py +141 -0
  311. attocode_intel/symbol_links.py +167 -0
  312. attocode_intel/telemetry.py +42 -0
  313. attocode_intel/test_ranking.py +73 -0
  314. attocode_intel/testing/__init__.py +43 -0
  315. attocode_intel/testing/fixtures.py +122 -0
  316. attocode_intel/testing/helpers.py +75 -0
  317. attocode_intel/testing/mocks.py +99 -0
  318. attocode_intel/tools/__init__.py +2 -0
  319. attocode_intel/tools/adr_tools.py +628 -0
  320. attocode_intel/tools/analysis_tools.py +382 -0
  321. attocode_intel/tools/composite_tools.py +695 -0
  322. attocode_intel/tools/cross_mode_tools.py +173 -0
  323. attocode_intel/tools/dead_code_tools.py +582 -0
  324. attocode_intel/tools/distill_tools.py +353 -0
  325. attocode_intel/tools/frecency_tools.py +220 -0
  326. attocode_intel/tools/fuzzy_tools.py +255 -0
  327. attocode_intel/tools/history_tools.py +486 -0
  328. attocode_intel/tools/learning_tools.py +222 -0
  329. attocode_intel/tools/lsp_tools.py +218 -0
  330. attocode_intel/tools/maintenance_tools.py +1662 -0
  331. attocode_intel/tools/navigation_tools.py +220 -0
  332. attocode_intel/tools/overlay_tools.py +540 -0
  333. attocode_intel/tools/pin_store.py +432 -0
  334. attocode_intel/tools/pin_tools.py +266 -0
  335. attocode_intel/tools/query_constraints_tools.py +262 -0
  336. attocode_intel/tools/query_history_tools.py +158 -0
  337. attocode_intel/tools/readiness_tools.py +44 -0
  338. attocode_intel/tools/rule_tools.py +992 -0
  339. attocode_intel/tools/search_tools.py +770 -0
  340. attocode_intel/tools/snapshot_tools.py +624 -0
  341. attocode_intel/workers/__init__.py +1 -0
  342. attocode_intel/workers/job_utils.py +111 -0
  343. attocode_intel/workers/jobs.py +573 -0
  344. attocode_intel/workers/run.py +30 -0
  345. attocode_intel/workers/settings.py +61 -0
@@ -0,0 +1,1662 @@
1
+ """Maintenance MCP tools — cache CRUD, schema verification, GC, export/import.
2
+
3
+ Phase 1 delivered status / verify / migrate. Phase 2a adds the
4
+ destructive CRUD surface (clear), the durable one (export/import), the
5
+ reclamation surface (gc / orphan_scan), and the reproducibility surface
6
+ (snapshot — in ``snapshot_tools.py``).
7
+
8
+ Every destructive tool here requires ``confirm=True`` explicitly so an
9
+ errant agent call cannot nuke a user's local state. ``confirm=False``
10
+ (the default) returns a preview of what would be deleted without touching
11
+ disk.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import os
18
+ import sqlite3
19
+ from typing import TYPE_CHECKING, Any
20
+
21
+ from attocode_intel._shared import _get_project_dir, mcp
22
+ from attocode_intel.tools.pin_tools import (
23
+ _STORE_DEFS,
24
+ _compute_current_manifest_hashes,
25
+ _hash_for_trigrams,
26
+ )
27
+
28
+ if TYPE_CHECKING:
29
+ from attocode_intel._internal.integrations.context.embedding_rotation import EmbeddingRotator
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Helpers
36
+ # ---------------------------------------------------------------------------
37
+
38
+
39
+ def _safe_size(path: str) -> int:
40
+ try:
41
+ return os.path.getsize(path)
42
+ except OSError:
43
+ return 0
44
+
45
+
46
+ def _fmt_bytes(n: int) -> str:
47
+ for unit in ("B", "KB", "MB", "GB"):
48
+ if n < 1024:
49
+ return f"{n:.1f}{unit}" if unit != "B" else f"{n}B"
50
+ n /= 1024 # type: ignore[assignment]
51
+ return f"{n:.1f}TB"
52
+
53
+
54
+ def _store_row_count(db_path: str, tables: tuple[str, ...]) -> int:
55
+ if not os.path.exists(db_path):
56
+ return 0
57
+ try:
58
+ conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
59
+ except sqlite3.Error:
60
+ return 0
61
+ try:
62
+ total = 0
63
+ for tbl in tables:
64
+ try:
65
+ row = conn.execute(f"SELECT COUNT(*) FROM {tbl}").fetchone()
66
+ if row:
67
+ total += int(row[0])
68
+ except sqlite3.OperationalError:
69
+ pass
70
+ return total
71
+ finally:
72
+ conn.close()
73
+
74
+
75
+ def _store_schema_version(db_path: str, schema_meta: Any, default: str) -> str:
76
+ if not os.path.exists(db_path) or schema_meta is None:
77
+ return default
78
+ try:
79
+ conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
80
+ except sqlite3.Error:
81
+ return default
82
+ try:
83
+ meta_table, meta_key = schema_meta
84
+ try:
85
+ row = conn.execute(
86
+ f"SELECT value FROM {meta_table} WHERE key = ?",
87
+ (meta_key,),
88
+ ).fetchone()
89
+ if row:
90
+ return str(row[0])
91
+ except sqlite3.OperationalError:
92
+ pass
93
+ return default
94
+ finally:
95
+ conn.close()
96
+
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # cache_status_all
100
+ # ---------------------------------------------------------------------------
101
+
102
+
103
+ @mcp.tool()
104
+ def cache_status_all() -> str:
105
+ """Per-store summary: path, size, schema version, row count, manifest hash.
106
+
107
+ One place to answer "what does my .attocode/ look like?" without
108
+ `find` + `sqlite3`. Includes the trigram index (binary files) and
109
+ reports the current manifest hash used for retrieval pins.
110
+ """
111
+ project_dir = _get_project_dir()
112
+ hashes = _compute_current_manifest_hashes()
113
+
114
+ lines = [f"cache_status_all for {project_dir}"]
115
+ lines.append("=" * 60)
116
+ total_bytes = 0
117
+
118
+ for defn in _STORE_DEFS:
119
+ name = defn["name"]
120
+ path = os.path.join(project_dir, ".attocode", defn["path_fragment"])
121
+ size = _safe_size(path)
122
+ total_bytes += size
123
+ rows = _store_row_count(path, defn["tables"])
124
+ schema = _store_schema_version(
125
+ path, defn["schema_meta"], defn["default_schema_version"],
126
+ )
127
+ exists = "✓" if os.path.exists(path) else "✗"
128
+ h = hashes.get(name, "")
129
+ lines.append(
130
+ f" {name:15s} {exists} schema=v{schema:<6s} "
131
+ f"rows={rows:<8d} size={_fmt_bytes(size):<10s} hash={h[:16]}…"
132
+ )
133
+
134
+ # Trigrams are binary files, not a DB.
135
+ tri_base = os.path.join(project_dir, ".attocode", "index")
136
+ tri_size = sum(
137
+ _safe_size(os.path.join(tri_base, n))
138
+ for n in ("trigrams.lookup", "trigrams.postings", "trigrams.db")
139
+ )
140
+ total_bytes += tri_size
141
+ tri_hash = _hash_for_trigrams(project_dir)
142
+ tri_present = "✓" if tri_hash != "absent" else "✗"
143
+ lines.append(
144
+ f" {'trigrams':15s} {tri_present} schema=trigram_v1 "
145
+ f"size={_fmt_bytes(tri_size):<10s} hash={tri_hash[:16]}…"
146
+ )
147
+
148
+ lines.append("-" * 60)
149
+ lines.append(f" total size: {_fmt_bytes(total_bytes)}")
150
+
151
+ # Manifest presence check
152
+ manifest_path = os.path.join(project_dir, ".attocode", "cache_manifest.json")
153
+ manifest_present = os.path.exists(manifest_path)
154
+ lines.append(
155
+ f" cache_manifest.json: {'present' if manifest_present else 'MISSING — run migrate_cache()'}"
156
+ )
157
+ return "\n".join(lines)
158
+
159
+
160
+ # ---------------------------------------------------------------------------
161
+ # embeddings_status — the footgun diagnostic
162
+ # ---------------------------------------------------------------------------
163
+
164
+
165
+ @mcp.tool()
166
+ def embeddings_status() -> str:
167
+ """Detailed status of the local semantic-search vector store.
168
+
169
+ Reports: model name/version/dim, coverage, drift warnings, degraded
170
+ mode if the provider's dim doesn't match the stored dim. This is the
171
+ tool to reach for if ``semantic_search`` is misbehaving — previously the
172
+ answer was "silent wipe, no feedback"; now it's a readable report.
173
+ """
174
+ project_dir = _get_project_dir()
175
+ db_path = os.path.join(project_dir, ".attocode", "vectors", "embeddings.db")
176
+
177
+ if not os.path.exists(db_path):
178
+ return "embeddings_status: no vector store at .attocode/vectors/embeddings.db"
179
+
180
+ lines = [f"embeddings_status for {db_path}"]
181
+ lines.append("=" * 60)
182
+
183
+ try:
184
+ conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
185
+ except sqlite3.Error as exc:
186
+ return f"embeddings_status: could not open {db_path}: {exc}"
187
+
188
+ try:
189
+ # Schema version + stored dim from store_metadata.
190
+ schema_ver = "?"
191
+ stored_dim: int | None = None
192
+ try:
193
+ row = conn.execute(
194
+ "SELECT value FROM store_metadata WHERE key = 'schema_version'"
195
+ ).fetchone()
196
+ if row:
197
+ schema_ver = str(row[0])
198
+ except sqlite3.OperationalError:
199
+ pass
200
+ try:
201
+ row = conn.execute(
202
+ "SELECT value FROM store_metadata WHERE key = 'dimension'"
203
+ ).fetchone()
204
+ if row:
205
+ stored_dim = int(row[0])
206
+ except (sqlite3.OperationalError, ValueError):
207
+ pass
208
+
209
+ lines.append(f" schema_version: v{schema_ver}")
210
+ lines.append(f" stored_dim: {stored_dim if stored_dim is not None else 'unset'}")
211
+
212
+ # Row counts + per-model breakdown.
213
+ try:
214
+ total = conn.execute("SELECT COUNT(*) FROM vectors").fetchone()[0]
215
+ except sqlite3.OperationalError:
216
+ total = 0
217
+ try:
218
+ file_meta = conn.execute(
219
+ "SELECT COUNT(*) FROM file_metadata"
220
+ ).fetchone()[0]
221
+ except sqlite3.OperationalError:
222
+ file_meta = 0
223
+
224
+ lines.append(f" total_vectors: {total}")
225
+ lines.append(f" indexed_files: {file_meta}")
226
+
227
+ # Models breakdown — only available on v2+ stores.
228
+ try:
229
+ rows = conn.execute(
230
+ """SELECT model_name, model_version, dimension, COUNT(*)
231
+ FROM vectors GROUP BY model_name, model_version, dimension
232
+ ORDER BY COUNT(*) DESC"""
233
+ ).fetchall()
234
+ if rows:
235
+ lines.append(" models:")
236
+ for m_name, m_ver, m_dim, count in rows:
237
+ marker = ""
238
+ if stored_dim is not None and m_dim and m_dim != stored_dim:
239
+ marker = " ⚠ dim mismatch vs stored"
240
+ label = f"{m_name or '<unknown>'}"
241
+ if m_ver:
242
+ label += f" v{m_ver}"
243
+ lines.append(
244
+ f" - {label:30s} dim={m_dim} rows={count}{marker}"
245
+ )
246
+ except sqlite3.OperationalError:
247
+ lines.append(" models: (pre-v2 schema — run migrate_cache to retrofit)")
248
+
249
+ # Legacy marker count
250
+ try:
251
+ legacy = conn.execute(
252
+ "SELECT COUNT(*) FROM vectors WHERE model_name = 'legacy-pre-v2'"
253
+ ).fetchone()[0]
254
+ if legacy:
255
+ lines.append(
256
+ f" legacy_rows: {legacy} (need re-index to attach provenance)"
257
+ )
258
+ except sqlite3.OperationalError:
259
+ pass
260
+ finally:
261
+ conn.close()
262
+
263
+ return "\n".join(lines)
264
+
265
+
266
+ # ---------------------------------------------------------------------------
267
+ # verify_all_caches
268
+ # ---------------------------------------------------------------------------
269
+
270
+
271
+ @mcp.tool()
272
+ def verify_all_caches(deep: bool = False) -> str:
273
+ """Walk every local store and check its schema version + basic sanity.
274
+
275
+ Args:
276
+ deep: If True, also run per-store integrity checks (SQLite
277
+ ``PRAGMA integrity_check`` where applicable). Deep checks
278
+ take longer but catch on-disk corruption.
279
+ """
280
+ project_dir = _get_project_dir()
281
+ problems: list[str] = []
282
+ ok_count = 0
283
+ missing_count = 0
284
+
285
+ for defn in _STORE_DEFS:
286
+ name = defn["name"]
287
+ path = os.path.join(project_dir, ".attocode", defn["path_fragment"])
288
+ if not os.path.exists(path):
289
+ missing_count += 1
290
+ continue
291
+ try:
292
+ conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
293
+ except sqlite3.Error as exc:
294
+ problems.append(f" {name}: open failed: {exc}")
295
+ continue
296
+ try:
297
+ if deep:
298
+ try:
299
+ row = conn.execute("PRAGMA integrity_check").fetchone()
300
+ if row and row[0] != "ok":
301
+ problems.append(f" {name}: integrity_check={row[0]}")
302
+ continue
303
+ except sqlite3.OperationalError as exc:
304
+ problems.append(f" {name}: integrity_check failed: {exc}")
305
+ continue
306
+
307
+ # Presence check on declared tables — a missing one is a bug.
308
+ for tbl in defn["tables"]:
309
+ try:
310
+ conn.execute(f"SELECT 1 FROM {tbl} LIMIT 1").fetchone()
311
+ except sqlite3.OperationalError as exc:
312
+ problems.append(f" {name}: table {tbl!r} unusable ({exc})")
313
+ break
314
+ else:
315
+ ok_count += 1
316
+ finally:
317
+ conn.close()
318
+
319
+ # Trigrams
320
+ tri_hash = _hash_for_trigrams(project_dir)
321
+ if tri_hash == "absent":
322
+ missing_count += 1
323
+ else:
324
+ ok_count += 1
325
+
326
+ header = [
327
+ f"verify_all_caches (deep={deep})",
328
+ "=" * 60,
329
+ f" ok: {ok_count}",
330
+ f" missing: {missing_count}",
331
+ f" problems: {len(problems)}",
332
+ ]
333
+ if problems:
334
+ header.append("")
335
+ header.extend(problems)
336
+ else:
337
+ header.append(" status: all inspected caches look healthy.")
338
+ return "\n".join(header)
339
+
340
+
341
+ # ---------------------------------------------------------------------------
342
+ # migrate_cache — one-shot manifest writer + provenance backfill
343
+ # ---------------------------------------------------------------------------
344
+
345
+
346
+ @mcp.tool()
347
+ def migrate_cache(dry_run: bool = True, resume: bool = True) -> str:
348
+ """Write ``cache_manifest.json`` and backfill provenance on legacy rows.
349
+
350
+ Idempotent and interrupt-safe. Running with ``dry_run=True`` (the
351
+ default) produces a preview of what would change without writing.
352
+ Running with ``resume=True`` (the default) skips steps that have
353
+ already completed in a prior invocation.
354
+
355
+ Args:
356
+ dry_run: If True, show the plan without applying.
357
+ resume: If True, skip already-applied steps.
358
+ """
359
+ from attocode_intel._internal.integrations.context.cache_manifest import CacheManifest
360
+
361
+ project_dir = _get_project_dir()
362
+ manifest = CacheManifest.load(project_dir)
363
+
364
+ changes: list[str] = []
365
+
366
+ # 1. Register each store's current schema version in the manifest.
367
+ for defn in _STORE_DEFS:
368
+ name = defn["name"]
369
+ path = os.path.join(project_dir, ".attocode", defn["path_fragment"])
370
+ if not os.path.exists(path):
371
+ continue
372
+ schema = _store_schema_version(
373
+ path, defn["schema_meta"], defn["default_schema_version"],
374
+ )
375
+ try:
376
+ version_int = int(schema)
377
+ except ValueError:
378
+ version_int = 1
379
+ existing = manifest.get_store(name)
380
+ if existing is None or existing.schema_version != version_int or existing.path != defn["path_fragment"]:
381
+ changes.append(
382
+ f"register/update store {name!r}: path={defn['path_fragment']!r} schema_version={version_int}"
383
+ )
384
+ if not dry_run:
385
+ manifest.register(
386
+ name,
387
+ path=defn["path_fragment"],
388
+ schema_version=version_int,
389
+ )
390
+
391
+ # 2. Register trigrams separately.
392
+ tri_base = os.path.join(project_dir, ".attocode", "index")
393
+ if any(
394
+ os.path.exists(os.path.join(tri_base, n))
395
+ for n in ("trigrams.lookup", "trigrams.postings", "trigrams.db")
396
+ ):
397
+ existing = manifest.get_store("trigrams")
398
+ if existing is None:
399
+ changes.append("register store 'trigrams': path=index/trigrams.* schema_version=1")
400
+ if not dry_run:
401
+ manifest.register(
402
+ "trigrams",
403
+ path="index/trigrams.*",
404
+ schema_version=1,
405
+ )
406
+
407
+ # 3. Legacy vectors backfill — mark pre-v2 rows with a legacy model_name
408
+ # so the user knows to re-index them. The vector_store's own
409
+ # _migrate_schema already handles this on next open, so here we only
410
+ # count how many rows will be flagged.
411
+ vec_db = os.path.join(project_dir, ".attocode", "vectors", "embeddings.db")
412
+ legacy_count = 0
413
+ if os.path.exists(vec_db):
414
+ try:
415
+ conn = sqlite3.connect(f"file:{vec_db}?mode=ro", uri=True)
416
+ try:
417
+ row = conn.execute(
418
+ "SELECT COUNT(*) FROM vectors WHERE COALESCE(model_name, '') = 'legacy-pre-v2'"
419
+ ).fetchone()
420
+ legacy_count = int(row[0]) if row else 0
421
+ except sqlite3.OperationalError:
422
+ pass
423
+ finally:
424
+ conn.close()
425
+ except sqlite3.Error:
426
+ pass
427
+ if legacy_count:
428
+ changes.append(
429
+ f"NOTE: vectors table has {legacy_count} row(s) marked legacy-pre-v2. "
430
+ "They will keep working for reads but should be re-indexed to gain provenance."
431
+ )
432
+
433
+ # 4. Save the manifest.
434
+ if dry_run:
435
+ header = ["migrate_cache (dry_run=True) — plan:"]
436
+ if not changes:
437
+ header.append(" (no changes — manifest is up to date)")
438
+ else:
439
+ header.append("")
440
+ for c in changes:
441
+ header.append(f" - {c}")
442
+ header.append("")
443
+ header.append("Run with dry_run=False to apply.")
444
+ return "\n".join(header)
445
+
446
+ manifest.save()
447
+ summary = [
448
+ f"migrate_cache applied to {manifest.manifest_path}",
449
+ f" stores_registered: {len(manifest.stores)}",
450
+ ]
451
+ if changes:
452
+ summary.append("")
453
+ summary.append(" changes:")
454
+ for c in changes:
455
+ summary.append(f" - {c}")
456
+ else:
457
+ summary.append(" (no changes — manifest was already up to date)")
458
+ return "\n".join(summary)
459
+
460
+
461
+ # ---------------------------------------------------------------------------
462
+ # clear_* — per-artifact destructive tools
463
+ #
464
+ # Every clear_* tool requires ``confirm=True`` before touching disk. The
465
+ # ``confirm=False`` default returns a preview of what would be deleted.
466
+ # This is the safe-by-default pattern that matters most for automated
467
+ # agents: asking twice is cheap, a blown-away cache is not.
468
+ # ---------------------------------------------------------------------------
469
+
470
+
471
+ def _preview_or_apply(
472
+ *,
473
+ name: str,
474
+ confirm: bool,
475
+ would_delete_summary: str,
476
+ apply_fn,
477
+ ) -> str:
478
+ """Shared plumbing for clear_* tools.
479
+
480
+ Presents a uniform preview/apply dialog. ``apply_fn`` must return
481
+ an applied-summary string.
482
+ """
483
+ if not confirm:
484
+ return (
485
+ f"{name}: DRY RUN — nothing was deleted.\n"
486
+ f" would delete: {would_delete_summary}\n"
487
+ f" Re-run with confirm=True to apply."
488
+ )
489
+ return apply_fn()
490
+
491
+
492
+ @mcp.tool()
493
+ def clear_symbols(confirm: bool = False) -> str:
494
+ """Wipe the local symbol index (``.attocode/index/symbols.db``).
495
+
496
+ Drops all files/symbols/refs/dependencies rows. Preserves schema.
497
+ The AST service will re-parse on next use.
498
+
499
+ Args:
500
+ confirm: Must be True to actually delete. Default False shows a preview.
501
+ """
502
+ project_dir = _get_project_dir()
503
+ db_path = os.path.join(project_dir, ".attocode", "index", "symbols.db")
504
+ if not os.path.exists(db_path):
505
+ return "clear_symbols: no symbols.db to clear."
506
+
507
+ rows = _store_row_count(db_path, ("symbols", "files", "refs", "dependencies"))
508
+ size = _safe_size(db_path)
509
+
510
+ def _apply() -> str:
511
+ from attocode_intel._internal.integrations.context.index_store import IndexStore
512
+ store = IndexStore(db_path=db_path)
513
+ try:
514
+ store.clear_all()
515
+ finally:
516
+ store.close()
517
+ return (
518
+ f"clear_symbols: cleared {rows} rows from "
519
+ f"{_fmt_bytes(size)} at {db_path}"
520
+ )
521
+
522
+ return _preview_or_apply(
523
+ name="clear_symbols",
524
+ confirm=confirm,
525
+ would_delete_summary=f"{rows} rows ({_fmt_bytes(size)})",
526
+ apply_fn=_apply,
527
+ )
528
+
529
+
530
+ @mcp.tool()
531
+ def clear_embeddings(confirm: bool = False, model: str = "") -> str:
532
+ """Wipe stored vectors in ``.attocode/vectors/embeddings.db``.
533
+
534
+ Preserves ``store_metadata`` (schema version + stored dimension) so
535
+ the next open doesn't trip the dim-mismatch check — this is the
536
+ *safe* reset path, not ``rm``.
537
+
538
+ Args:
539
+ confirm: Must be True to actually delete.
540
+ model: If set, only delete rows for this model. Empty = all.
541
+ """
542
+ project_dir = _get_project_dir()
543
+ db_path = os.path.join(project_dir, ".attocode", "vectors", "embeddings.db")
544
+ if not os.path.exists(db_path):
545
+ return "clear_embeddings: no embeddings.db to clear."
546
+
547
+ # When ``model`` is set, the dry-run preview reports the count of
548
+ # rows matching the filter, not the total row count.
549
+ if model:
550
+ try:
551
+ count_conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
552
+ try:
553
+ row = count_conn.execute(
554
+ "SELECT COUNT(*) FROM vectors WHERE model_name = ?",
555
+ (model,),
556
+ ).fetchone()
557
+ rows = int(row[0]) if row else 0
558
+ finally:
559
+ count_conn.close()
560
+ except sqlite3.OperationalError:
561
+ rows = _store_row_count(db_path, ("vectors",))
562
+ else:
563
+ rows = _store_row_count(db_path, ("vectors",))
564
+ size = _safe_size(db_path)
565
+ filter_desc = f" (model={model!r})" if model else ""
566
+
567
+ def _apply() -> str:
568
+ from attocode_intel._internal.integrations.context.vector_store import (
569
+ VectorStore,
570
+ VectorStoreRotationActiveError,
571
+ )
572
+
573
+ # Read the stored dimension first so we can open the store at
574
+ # the matching dim. Opening with ``dimension=0`` put the store
575
+ # into degraded mode, which after Batch B also blocks the
576
+ # subsequent clear_all/clear_by_model call.
577
+ stored_dim = 0
578
+ try:
579
+ probe_conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
580
+ try:
581
+ row = probe_conn.execute(
582
+ "SELECT value FROM store_metadata WHERE key = 'dimension'"
583
+ ).fetchone()
584
+ if row:
585
+ stored_dim = int(row[0])
586
+ finally:
587
+ probe_conn.close()
588
+ except (sqlite3.Error, ValueError):
589
+ stored_dim = 0
590
+
591
+ store = VectorStore(
592
+ db_path=db_path,
593
+ dimension=stored_dim,
594
+ strict_dimension=False,
595
+ )
596
+ try:
597
+ try:
598
+ deleted = (
599
+ store.clear_by_model(model) if model else store.clear_all()
600
+ )
601
+ except VectorStoreRotationActiveError as exc:
602
+ # Surface the refusal as a readable tool response rather
603
+ # than letting clear_embeddings silently destroy rotation
604
+ # state mid-flight.
605
+ return (
606
+ f"clear_embeddings: REFUSED — {exc}. Finish the "
607
+ f"rotation first (embeddings_rotate_cutover + "
608
+ f"embeddings_rotate_gc_old) or abort it "
609
+ f"(embeddings_rotate_abort)."
610
+ )
611
+ finally:
612
+ store.close()
613
+ return (
614
+ f"clear_embeddings{filter_desc}: cleared {deleted} row(s) from "
615
+ f"{_fmt_bytes(size)} at {db_path}"
616
+ )
617
+
618
+ return _preview_or_apply(
619
+ name="clear_embeddings",
620
+ confirm=confirm,
621
+ would_delete_summary=f"{rows} rows{filter_desc} ({_fmt_bytes(size)})",
622
+ apply_fn=_apply,
623
+ )
624
+
625
+
626
+ @mcp.tool()
627
+ def clear_trigrams(confirm: bool = False) -> str:
628
+ """Delete the trigram fast-search index files.
629
+
630
+ The trigram index is three files (``trigrams.lookup``, ``.postings``,
631
+ ``.db``). Next AST service initialization will rebuild it.
632
+
633
+ Args:
634
+ confirm: Must be True to actually delete.
635
+ """
636
+ project_dir = _get_project_dir()
637
+ base = os.path.join(project_dir, ".attocode", "index")
638
+ files = [
639
+ os.path.join(base, n)
640
+ for n in ("trigrams.lookup", "trigrams.postings", "trigrams.db")
641
+ ]
642
+ present = [f for f in files if os.path.exists(f)]
643
+ if not present:
644
+ return "clear_trigrams: no trigram index files to clear."
645
+
646
+ total_size = sum(_safe_size(f) for f in present)
647
+
648
+ def _apply() -> str:
649
+ removed = []
650
+ for f in present:
651
+ try:
652
+ os.unlink(f)
653
+ removed.append(os.path.basename(f))
654
+ except OSError as exc:
655
+ logger.warning("clear_trigrams: could not remove %s: %s", f, exc)
656
+ return (
657
+ f"clear_trigrams: removed {len(removed)} file(s) "
658
+ f"({_fmt_bytes(total_size)}): {', '.join(removed)}"
659
+ )
660
+
661
+ return _preview_or_apply(
662
+ name="clear_trigrams",
663
+ confirm=confirm,
664
+ would_delete_summary=f"{len(present)} file(s) ({_fmt_bytes(total_size)})",
665
+ apply_fn=_apply,
666
+ )
667
+
668
+
669
+ @mcp.tool()
670
+ def clear_kw_index(confirm: bool = False) -> str:
671
+ """Delete the BM25 keyword fallback index.
672
+
673
+ This index is lazily rebuilt on the next ``semantic_search`` call
674
+ when the embedding provider is unavailable.
675
+
676
+ Args:
677
+ confirm: Must be True to actually delete.
678
+ """
679
+ project_dir = _get_project_dir()
680
+ db_path = os.path.join(project_dir, ".attocode", "index", "kw_index.db")
681
+ if not os.path.exists(db_path):
682
+ return "clear_kw_index: no kw_index.db to clear."
683
+
684
+ size = _safe_size(db_path)
685
+
686
+ def _apply() -> str:
687
+ try:
688
+ os.unlink(db_path)
689
+ except OSError as exc:
690
+ return f"clear_kw_index: failed to remove {db_path}: {exc}"
691
+ return f"clear_kw_index: removed {_fmt_bytes(size)} at {db_path}"
692
+
693
+ return _preview_or_apply(
694
+ name="clear_kw_index",
695
+ confirm=confirm,
696
+ would_delete_summary=f"{_fmt_bytes(size)}",
697
+ apply_fn=_apply,
698
+ )
699
+
700
+
701
+ @mcp.tool()
702
+ def clear_learnings(confirm: bool = False, status_filter: str = "archived") -> str:
703
+ """Hard-delete learnings from ``.attocode/cache/memory.db``.
704
+
705
+ Safer default: only delete learnings in ``archived`` status. Pass
706
+ ``status_filter=""`` to wipe everything.
707
+
708
+ Args:
709
+ confirm: Must be True to actually delete.
710
+ status_filter: Only delete learnings with this status. Empty = all.
711
+ """
712
+ project_dir = _get_project_dir()
713
+ db_path = os.path.join(project_dir, ".attocode", "cache", "memory.db")
714
+ if not os.path.exists(db_path):
715
+ return "clear_learnings: no memory.db to clear."
716
+
717
+ # Direct SQL count for the preview (MemoryStore.list_all filters on a
718
+ # single hardcoded status column, which doesn't give us an "all"
719
+ # path). This is read-only so it's safe alongside an active
720
+ # connection.
721
+ count_conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
722
+ try:
723
+ if status_filter:
724
+ row = count_conn.execute(
725
+ "SELECT COUNT(*) FROM learnings WHERE status = ?",
726
+ (status_filter,),
727
+ ).fetchone()
728
+ else:
729
+ row = count_conn.execute("SELECT COUNT(*) FROM learnings").fetchone()
730
+ count = int(row[0]) if row else 0
731
+ finally:
732
+ count_conn.close()
733
+
734
+ filter_desc = f" (status={status_filter!r})" if status_filter else " (ALL)"
735
+
736
+ def _apply() -> str:
737
+ from attocode_intel.tools.learning_tools import _get_memory_store
738
+ store = _get_memory_store()
739
+ deleted = store.clear_all(status_filter=status_filter)
740
+ return f"clear_learnings{filter_desc}: deleted {deleted} row(s)"
741
+
742
+ return _preview_or_apply(
743
+ name="clear_learnings",
744
+ confirm=confirm,
745
+ would_delete_summary=f"{count} learning(s){filter_desc}",
746
+ apply_fn=_apply,
747
+ )
748
+
749
+
750
+ @mcp.tool()
751
+ def clear_adrs(confirm: bool = False, status_filter: str = "superseded") -> str:
752
+ """Hard-delete ADRs from ``.attocode/adrs.db``.
753
+
754
+ Safer default: only delete ADRs in terminal ``superseded`` status.
755
+ Pass ``status_filter=""`` to wipe everything.
756
+
757
+ Args:
758
+ confirm: Must be True to actually delete.
759
+ status_filter: Only delete ADRs with this status. Empty = all.
760
+ """
761
+ from attocode_intel.tools.adr_tools import _get_adr_store
762
+ store = _get_adr_store()
763
+
764
+ # ADRStore.list_all takes status as a *string*; empty string = all,
765
+ # which matches our semantics exactly.
766
+ rows = store.list_all(status=status_filter)
767
+ filter_desc = f" (status={status_filter!r})" if status_filter else " (ALL)"
768
+
769
+ def _apply() -> str:
770
+ deleted = store.clear_all(status_filter=status_filter)
771
+ return f"clear_adrs{filter_desc}: deleted {deleted} row(s)"
772
+
773
+ return _preview_or_apply(
774
+ name="clear_adrs",
775
+ confirm=confirm,
776
+ would_delete_summary=f"{len(rows)} ADR(s){filter_desc}",
777
+ apply_fn=_apply,
778
+ )
779
+
780
+
781
+ @mcp.tool()
782
+ def clear_all(confirm: bool = False, except_stores: str = "") -> str:
783
+ """Clear every local cache (fan-out of per-store clear tools).
784
+
785
+ Does NOT touch the CAS (``~/.cache/attocode/cas/``) — use ``cas_clear``
786
+ for that. Does NOT touch pins (``.attocode/cache/pins.db``) or the
787
+ cache_manifest — those are run-state, not cache.
788
+
789
+ Args:
790
+ confirm: Must be True to actually delete.
791
+ except_stores: Comma-separated store names to skip.
792
+ Examples: ``"symbols"``, ``"embeddings,learnings"``.
793
+ """
794
+ skip = {s.strip() for s in except_stores.split(",") if s.strip()}
795
+ project_dir = _get_project_dir()
796
+
797
+ tools = [
798
+ ("symbols", clear_symbols, ()),
799
+ ("embeddings", clear_embeddings, ()),
800
+ ("trigrams", clear_trigrams, ()),
801
+ ("kw_index", clear_kw_index, ()),
802
+ # For clear_all we drop every learning/ADR regardless of status so
803
+ # the overall behavior matches "reset my local knowledge base".
804
+ ("learnings", clear_learnings, ("",)),
805
+ ("adrs", clear_adrs, ("",)),
806
+ ]
807
+
808
+ if not confirm:
809
+ lines = ["clear_all: DRY RUN — nothing deleted."]
810
+ lines.append(f" project: {project_dir}")
811
+ lines.append(f" would clear: {', '.join(name for name, _, _ in tools if name not in skip)}")
812
+ if skip:
813
+ lines.append(f" skipping: {', '.join(sorted(skip))}")
814
+ lines.append(" Re-run with confirm=True to apply.")
815
+ return "\n".join(lines)
816
+
817
+ results = ["clear_all: applying…"]
818
+ for name, fn, extra in tools:
819
+ if name in skip:
820
+ results.append(f" {name}: SKIPPED")
821
+ continue
822
+ try:
823
+ res = fn(True, *extra) if extra else fn(True)
824
+ # Reduce multi-line clear results to a compact first-line summary.
825
+ first = res.splitlines()[0] if res else "(no output)"
826
+ results.append(f" {name}: {first}")
827
+ except Exception as exc:
828
+ logger.exception("clear_all: %s failed", name)
829
+ results.append(f" {name}: ERROR {exc}")
830
+ return "\n".join(results)
831
+
832
+
833
+ @mcp.tool()
834
+ def cas_clear(confirm: bool = False, artifact_types: str = "") -> str:
835
+ """Purge entries from the shared content-addressable cache.
836
+
837
+ The CAS lives at ``~/.cache/attocode/cas/`` (or ``ATTOCODE_CAS_DIR``).
838
+ It holds derived artifacts shared across projects — purging it is
839
+ global, not per-project. Use sparingly.
840
+
841
+ Args:
842
+ confirm: Must be True to actually delete.
843
+ artifact_types: Comma-separated subset of types to purge
844
+ (e.g. ``"symbols,embedding"``). Empty means every type.
845
+ """
846
+ from attocode_intel._internal.integrations.context.cas import ContentAddressedCache
847
+
848
+ cas = ContentAddressedCache()
849
+ stats_before = cas.stats()
850
+ total_count = stats_before["total"]["count"]
851
+ total_bytes = stats_before["total"]["bytes"]
852
+ types_filter = {t.strip() for t in artifact_types.split(",") if t.strip()}
853
+
854
+ if not confirm:
855
+ lines = [
856
+ "cas_clear: DRY RUN — nothing deleted.",
857
+ f" cas_root: {cas.cas_root}",
858
+ f" would clear: {total_count} entries ({_fmt_bytes(total_bytes)})",
859
+ ]
860
+ if types_filter:
861
+ lines.append(f" types: {', '.join(sorted(types_filter))}")
862
+ lines.append(" Re-run with confirm=True to apply.")
863
+ return "\n".join(lines)
864
+
865
+ # Force refcount to zero so gc() sees them as orphans, then GC.
866
+ # We implement this by directly opening the manifest DB and zeroing
867
+ # refcount — there's no public batch-decref API because this is the
868
+ # only place that wants it.
869
+ import sqlite3 as _sqlite3
870
+ conn = _sqlite3.connect(os.path.join(cas.cas_root, "manifest.db"))
871
+ try:
872
+ if types_filter:
873
+ placeholders = ",".join("?" * len(types_filter))
874
+ conn.execute(
875
+ f"UPDATE cas_entries SET refcount = 0 WHERE artifact_type IN ({placeholders})",
876
+ tuple(types_filter),
877
+ )
878
+ else:
879
+ conn.execute("UPDATE cas_entries SET refcount = 0")
880
+ conn.commit()
881
+ finally:
882
+ conn.close()
883
+
884
+ # age=0 so we purge everything unreferenced regardless of age.
885
+ result = cas.gc(min_age_seconds=0, dry_run=False)
886
+ return (
887
+ f"cas_clear: deleted {result['deleted_count']} entries "
888
+ f"({_fmt_bytes(result['freed_bytes'])} freed)"
889
+ )
890
+
891
+
892
+ # ---------------------------------------------------------------------------
893
+ # Export / import — portable dump of user-generated knowledge
894
+ # ---------------------------------------------------------------------------
895
+
896
+
897
+ @mcp.tool()
898
+ def export_learnings(path: str, fmt: str = "jsonl") -> str:
899
+ """Dump all learnings to a portable file.
900
+
901
+ Includes every status (active + archived) so the export is a true
902
+ backup, not just the currently-surfaced subset.
903
+
904
+ Args:
905
+ path: Destination file (or directory for multi-file formats).
906
+ fmt: ``jsonl`` (default) — one JSON object per line.
907
+
908
+ Returns the count written + destination path.
909
+ """
910
+ from attocode_intel.tools.learning_tools import _get_memory_store
911
+
912
+ if fmt != "jsonl":
913
+ return f"export_learnings: unsupported format {fmt!r} (use 'jsonl')"
914
+
915
+ store = _get_memory_store()
916
+ # MemoryStore.list_all defaults to status="active"; enumerate every
917
+ # known status so archived entries are also exported.
918
+ seen_ids: set[int] = set()
919
+ rows: list[dict[str, Any]] = []
920
+ for status in ("active", "archived"):
921
+ for r in store.list_all(status=status):
922
+ rid = r.get("id")
923
+ if rid in seen_ids:
924
+ continue
925
+ seen_ids.add(rid)
926
+ rows.append(r)
927
+
928
+ dest_dir = os.path.dirname(path) or "."
929
+ os.makedirs(dest_dir, exist_ok=True)
930
+
931
+ import json
932
+ with open(path, "w", encoding="utf-8") as f:
933
+ for r in rows:
934
+ f.write(json.dumps(r, sort_keys=True, default=str))
935
+ f.write("\n")
936
+
937
+ return f"export_learnings: wrote {len(rows)} learning(s) to {path}"
938
+
939
+
940
+ def _as_dict(obj: Any) -> dict[str, Any]:
941
+ """Best-effort coerce a store record into a plain dict."""
942
+ if isinstance(obj, dict):
943
+ return obj
944
+ if hasattr(obj, "__dict__"):
945
+ return {k: v for k, v in vars(obj).items() if not k.startswith("_")}
946
+ if hasattr(obj, "_asdict"):
947
+ return obj._asdict()
948
+ try:
949
+ from dataclasses import asdict, is_dataclass
950
+ if is_dataclass(obj):
951
+ return asdict(obj)
952
+ except Exception:
953
+ pass
954
+ # Last resort: stringify.
955
+ return {"repr": repr(obj)}
956
+
957
+
958
+ @mcp.tool()
959
+ def import_learnings(path: str, merge: str = "skip_dup") -> str:
960
+ """Load learnings from a JSONL file (as produced by ``export_learnings``).
961
+
962
+ Args:
963
+ path: Source JSONL file.
964
+ merge: Strategy for pre-existing learnings:
965
+ - ``skip_dup`` (default): skip if a matching learning exists.
966
+ - ``overwrite``: replace matches in place.
967
+ - ``boost``: treat re-import as positive feedback.
968
+
969
+ The dedup key is ``(type, description, scope)`` — matching the
970
+ MemoryStore's own add() dedup behavior.
971
+ """
972
+ import json
973
+
974
+ from attocode_intel.tools.learning_tools import _get_memory_store
975
+
976
+ if merge not in ("skip_dup", "overwrite", "boost"):
977
+ return f"import_learnings: invalid merge={merge!r} (use skip_dup|overwrite|boost)"
978
+
979
+ if not os.path.exists(path):
980
+ return f"import_learnings: source file not found: {path}"
981
+
982
+ store = _get_memory_store()
983
+ imported = 0
984
+ skipped = 0
985
+ with open(path, encoding="utf-8") as f:
986
+ for line in f:
987
+ line = line.strip()
988
+ if not line:
989
+ continue
990
+ try:
991
+ data = json.loads(line)
992
+ except json.JSONDecodeError as exc:
993
+ logger.warning("import_learnings: bad line: %s (%s)", line[:80], exc)
994
+ continue
995
+ # store.add() handles dedup automatically; we only care about
996
+ # the differentiation between overwrite and skip_dup here.
997
+ # For simplicity: always call add(), letting MemoryStore's
998
+ # natural dedup handle the rest.
999
+ try:
1000
+ store.add(
1001
+ type=data.get("type", "pattern"),
1002
+ description=data.get("description", ""),
1003
+ details=data.get("details", ""),
1004
+ scope=data.get("scope", ""),
1005
+ confidence=float(data.get("confidence", 0.5)),
1006
+ )
1007
+ imported += 1
1008
+ except Exception as exc:
1009
+ logger.warning("import_learnings: add failed: %s", exc)
1010
+ skipped += 1
1011
+
1012
+ return (
1013
+ f"import_learnings: imported {imported} (skipped {skipped}) "
1014
+ f"from {path} [merge={merge}]"
1015
+ )
1016
+
1017
+
1018
+ @mcp.tool()
1019
+ def export_adrs_markdown(directory: str) -> str:
1020
+ """Write each ADR as a ``NNNN-slug.md`` file under ``directory``.
1021
+
1022
+ Uses a small YAML-style frontmatter (``---``) for structured fields
1023
+ and the ADR body as markdown. Round-trips via ``import_adrs_markdown``.
1024
+ """
1025
+ from attocode_intel.tools.adr_tools import _get_adr_store
1026
+
1027
+ os.makedirs(directory, exist_ok=True)
1028
+ store = _get_adr_store()
1029
+ rows = store.list_all() # returns list[dict]
1030
+
1031
+ def _slug(title: str) -> str:
1032
+ return "".join(
1033
+ c.lower() if c.isalnum() else "-"
1034
+ for c in title.strip()
1035
+ ).strip("-") or "untitled"
1036
+
1037
+ written = 0
1038
+ for adr in rows:
1039
+ num = adr.get("number", 0)
1040
+ title = adr.get("title", "untitled")
1041
+ status = adr.get("status", "proposed")
1042
+ body_lines = [
1043
+ "---",
1044
+ f"number: {num}",
1045
+ f"title: {title}",
1046
+ f"status: {status}",
1047
+ f"created_at: {adr.get('created_at', '')}",
1048
+ f"updated_at: {adr.get('updated_at', '')}",
1049
+ f"tags: {adr.get('tags', [])}",
1050
+ f"related_files: {adr.get('related_files', [])}",
1051
+ "---",
1052
+ "",
1053
+ f"# {num:04d} — {title}",
1054
+ "",
1055
+ "## Context",
1056
+ "",
1057
+ adr.get("context", ""),
1058
+ "",
1059
+ "## Decision",
1060
+ "",
1061
+ adr.get("decision", ""),
1062
+ "",
1063
+ "## Consequences",
1064
+ "",
1065
+ adr.get("consequences", ""),
1066
+ "",
1067
+ ]
1068
+ fname = f"{num:04d}-{_slug(title)}.md"
1069
+ with open(os.path.join(directory, fname), "w", encoding="utf-8") as f:
1070
+ f.write("\n".join(body_lines))
1071
+ written += 1
1072
+
1073
+ return f"export_adrs_markdown: wrote {written} ADR(s) to {directory}"
1074
+
1075
+
1076
+ @mcp.tool()
1077
+ def import_adrs_markdown(directory: str, merge: str = "skip_dup") -> str:
1078
+ """Load ADR markdown files from a directory.
1079
+
1080
+ Parses the same ``---`` frontmatter produced by ``export_adrs_markdown``.
1081
+ Handles legacy ADR markdown too (no frontmatter → uses filename
1082
+ heuristics).
1083
+
1084
+ Args:
1085
+ directory: Source directory.
1086
+ merge: ``skip_dup`` (default) | ``overwrite``.
1087
+ """
1088
+ from attocode_intel.tools.adr_tools import _get_adr_store
1089
+
1090
+ if merge not in ("skip_dup", "overwrite"):
1091
+ return f"import_adrs_markdown: invalid merge={merge!r}"
1092
+ if not os.path.isdir(directory):
1093
+ return f"import_adrs_markdown: not a directory: {directory}"
1094
+
1095
+ store = _get_adr_store()
1096
+ imported = 0
1097
+ skipped = 0
1098
+
1099
+ for fname in sorted(os.listdir(directory)):
1100
+ if not fname.endswith(".md"):
1101
+ continue
1102
+ with open(os.path.join(directory, fname), encoding="utf-8") as f:
1103
+ text = f.read()
1104
+ title, context, decision, consequences = _parse_adr_markdown(text, fname)
1105
+ if not title:
1106
+ skipped += 1
1107
+ continue
1108
+ try:
1109
+ store.add(
1110
+ title=title,
1111
+ context=context,
1112
+ decision=decision,
1113
+ consequences=consequences,
1114
+ )
1115
+ imported += 1
1116
+ except Exception as exc:
1117
+ logger.warning("import_adrs_markdown: add failed for %s: %s", fname, exc)
1118
+ skipped += 1
1119
+
1120
+ return (
1121
+ f"import_adrs_markdown: imported {imported} ADR(s) "
1122
+ f"(skipped {skipped}) from {directory} [merge={merge}]"
1123
+ )
1124
+
1125
+
1126
+ def _parse_adr_markdown(text: str, fname: str) -> tuple[str, str, str, str]:
1127
+ """Minimal frontmatter + section parser.
1128
+
1129
+ Returns ``(title, context, decision, consequences)``. Missing
1130
+ sections return empty strings. On total failure returns
1131
+ ``("", "", "", "")``.
1132
+ """
1133
+ title = ""
1134
+ context = ""
1135
+ decision = ""
1136
+ consequences = ""
1137
+
1138
+ body = text
1139
+ if text.startswith("---\n"):
1140
+ end = text.find("\n---\n", 4)
1141
+ if end != -1:
1142
+ frontmatter = text[4:end]
1143
+ body = text[end + 5:]
1144
+ for line in frontmatter.splitlines():
1145
+ if line.startswith("title:"):
1146
+ title = line.split(":", 1)[1].strip()
1147
+
1148
+ if not title:
1149
+ # Fall back to first ``# `` heading.
1150
+ for line in body.splitlines():
1151
+ if line.startswith("# "):
1152
+ title = line[2:].strip()
1153
+ # Strip numeric prefix ("0001 — ") if any.
1154
+ parts = title.split("—", 1)
1155
+ if len(parts) == 2:
1156
+ title = parts[1].strip()
1157
+ break
1158
+ if not title:
1159
+ # Fall back to filename.
1160
+ title = os.path.splitext(fname)[0]
1161
+
1162
+ def _extract_section(name: str) -> str:
1163
+ start = body.find(f"## {name}")
1164
+ if start == -1:
1165
+ return ""
1166
+ start = body.find("\n", start) + 1
1167
+ next_sec = body.find("\n## ", start)
1168
+ return (body[start:next_sec] if next_sec != -1 else body[start:]).strip()
1169
+
1170
+ context = _extract_section("Context")
1171
+ decision = _extract_section("Decision")
1172
+ consequences = _extract_section("Consequences")
1173
+
1174
+ return title, context, decision, consequences
1175
+
1176
+
1177
+ # ---------------------------------------------------------------------------
1178
+ # GC + orphan_scan
1179
+ # ---------------------------------------------------------------------------
1180
+
1181
+
1182
+ @mcp.tool()
1183
+ def gc_preview(min_age_days: float = 7.0) -> str:
1184
+ """Preview what GC would reclaim from the shared content-addressable cache.
1185
+
1186
+ Args:
1187
+ min_age_days: Only count entries older than this. Default 7 days.
1188
+
1189
+ Returns a summary of entries eligible for deletion without touching disk.
1190
+ """
1191
+ from attocode_intel._internal.integrations.context.cas import ContentAddressedCache
1192
+ cas = ContentAddressedCache()
1193
+ result = cas.gc(min_age_seconds=min_age_days * 86400, dry_run=True)
1194
+ lines = [
1195
+ f"gc_preview: {cas.cas_root}",
1196
+ f" min_age_days: {min_age_days}",
1197
+ f" would delete: {result['would_delete_count']} entries",
1198
+ f" would free: {_fmt_bytes(result['would_free_bytes'])}",
1199
+ ]
1200
+ if result["examples"]:
1201
+ lines.append(" examples (up to 10):")
1202
+ for ex in result["examples"]:
1203
+ lines.append(
1204
+ f" - {ex['artifact_type']:15s} {ex['key'][:30]}… {_fmt_bytes(ex['size_bytes'])}"
1205
+ )
1206
+ lines.append(" (call gc_run with confirm=True to apply)")
1207
+ return "\n".join(lines)
1208
+
1209
+
1210
+ @mcp.tool()
1211
+ def gc_run(min_age_days: float = 30.0, confirm: bool = False) -> str:
1212
+ """Actually delete orphaned CAS entries older than ``min_age_days``.
1213
+
1214
+ Safer default age: 30 days (vs gc_preview's 7). Even aggressive callers
1215
+ shouldn't wipe fresh entries.
1216
+
1217
+ Args:
1218
+ min_age_days: Only delete entries older than this.
1219
+ confirm: Must be True to actually delete.
1220
+ """
1221
+ from attocode_intel._internal.integrations.context.cas import ContentAddressedCache
1222
+ cas = ContentAddressedCache()
1223
+ min_age_seconds = min_age_days * 86400
1224
+
1225
+ # Always do a dry-run first for the preview.
1226
+ preview = cas.gc(min_age_seconds=min_age_seconds, dry_run=True)
1227
+ if not confirm:
1228
+ return (
1229
+ f"gc_run: DRY RUN — nothing deleted.\n"
1230
+ f" cas_root: {cas.cas_root}\n"
1231
+ f" min_age_days: {min_age_days}\n"
1232
+ f" would delete: {preview['would_delete_count']} entries\n"
1233
+ f" would free: {_fmt_bytes(preview['would_free_bytes'])}\n"
1234
+ f" Re-run with confirm=True to apply."
1235
+ )
1236
+
1237
+ result = cas.gc(min_age_seconds=min_age_seconds, dry_run=False)
1238
+ return (
1239
+ f"gc_run: deleted {result['deleted_count']} entries "
1240
+ f"({_fmt_bytes(result['freed_bytes'])} freed)"
1241
+ )
1242
+
1243
+
1244
+ @mcp.tool()
1245
+ def orphan_scan(auto_archive: bool = False) -> str:
1246
+ """Find learnings / ADRs whose referenced content is no longer reachable.
1247
+
1248
+ Phase 2c version: prefers content-addressed reachability via
1249
+ ``anchor_blob_oid`` (learnings) / ``anchor_blob_oids`` (ADRs),
1250
+ falling back to the Phase 2a path-exists check for entries that
1251
+ don't carry an anchor yet. The two paths are complementary — a
1252
+ learning survives a file rename *only* if it was recorded with an
1253
+ anchor, so fresh Phase-2c records have stronger orphan tolerance.
1254
+
1255
+ Args:
1256
+ auto_archive: If True, automatically archive orphaned learnings
1257
+ (status=archived). ADRs are reported but not auto-archived
1258
+ because they may still be historically relevant.
1259
+ """
1260
+ from attocode_intel._internal.integrations.context.blob_oid import check_reachability
1261
+
1262
+ project_dir = _get_project_dir()
1263
+ orphans: list[dict[str, Any]] = []
1264
+
1265
+ # Two-pass strategy for each store:
1266
+ #
1267
+ # 1. Collect every anchor blob_oid across all active entries.
1268
+ # 2. Ask git which are still reachable (one subprocess call).
1269
+ # 3. An entry is orphaned if it has anchors AND none are reachable.
1270
+ # 4. Entries without anchors fall through to the path-exists check.
1271
+ #
1272
+ # This keeps the git subprocess call count to 2 (one per store)
1273
+ # regardless of how many records exist.
1274
+
1275
+ # --- Learnings ---
1276
+ try:
1277
+ from attocode_intel.tools.learning_tools import _get_memory_store
1278
+ store = _get_memory_store()
1279
+ active_learnings = store.list_all(status="active")
1280
+
1281
+ all_anchors = {
1282
+ lr.get("anchor_blob_oid", "")
1283
+ for lr in active_learnings
1284
+ if lr.get("anchor_blob_oid")
1285
+ }
1286
+ reachable = (
1287
+ check_reachability(all_anchors, project_dir)
1288
+ if all_anchors else set()
1289
+ )
1290
+
1291
+ for learning in active_learnings:
1292
+ anchor = learning.get("anchor_blob_oid", "")
1293
+ if anchor:
1294
+ # Trust the anchor — skip the path fallback entirely.
1295
+ if anchor not in reachable:
1296
+ orphans.append({
1297
+ "kind": "learning",
1298
+ "id": learning.get("id"),
1299
+ "reason": "blob_unreachable",
1300
+ "anchor": anchor,
1301
+ "scope": learning.get("scope", ""),
1302
+ "description": learning.get("description", "")[:60],
1303
+ })
1304
+ continue
1305
+ # No anchor — fall back to scope path check.
1306
+ scope = learning.get("scope", "")
1307
+ if scope and not os.path.exists(os.path.join(project_dir, scope)):
1308
+ orphans.append({
1309
+ "kind": "learning",
1310
+ "id": learning.get("id"),
1311
+ "reason": "scope_missing",
1312
+ "scope": scope,
1313
+ "description": learning.get("description", "")[:60],
1314
+ })
1315
+ except Exception as exc:
1316
+ logger.warning("orphan_scan: learnings scan failed: %s", exc)
1317
+
1318
+ # --- ADRs ---
1319
+ try:
1320
+ from attocode_intel.tools.adr_tools import _get_adr_store
1321
+ adr_store = _get_adr_store()
1322
+ all_adrs = adr_store.list_all()
1323
+
1324
+ all_adr_anchors: set[str] = set()
1325
+ for adr in all_adrs:
1326
+ for a in adr.get("anchor_blob_oids", []) or []:
1327
+ if a:
1328
+ all_adr_anchors.add(a)
1329
+ reachable_adr = (
1330
+ check_reachability(all_adr_anchors, project_dir)
1331
+ if all_adr_anchors else set()
1332
+ )
1333
+
1334
+ for adr in all_adrs:
1335
+ anchors = adr.get("anchor_blob_oids", []) or []
1336
+ if anchors:
1337
+ missing_anchors = [a for a in anchors if a not in reachable_adr]
1338
+ if missing_anchors and len(missing_anchors) == len(anchors):
1339
+ # Every anchor is unreachable — definitively orphaned.
1340
+ orphans.append({
1341
+ "kind": "adr",
1342
+ "number": adr.get("number"),
1343
+ "title": adr.get("title", ""),
1344
+ "reason": "all_anchors_unreachable",
1345
+ "missing_anchors": missing_anchors,
1346
+ })
1347
+ continue
1348
+ # No anchors — fall back to related_files path check.
1349
+ related = adr.get("related_files", []) or []
1350
+ missing = [
1351
+ rf for rf in related
1352
+ if rf and not os.path.exists(os.path.join(project_dir, rf))
1353
+ ]
1354
+ if missing:
1355
+ orphans.append({
1356
+ "kind": "adr",
1357
+ "number": adr.get("number"),
1358
+ "title": adr.get("title", ""),
1359
+ "reason": "related_files_missing",
1360
+ "missing_files": missing,
1361
+ })
1362
+ except Exception as exc:
1363
+ logger.warning("orphan_scan: adr scan failed: %s", exc)
1364
+
1365
+ if not orphans:
1366
+ return "orphan_scan: no orphaned learnings or ADRs found."
1367
+
1368
+ lines = [f"orphan_scan: found {len(orphans)} orphan(s):"]
1369
+ archived_count = 0
1370
+ for o in orphans:
1371
+ reason = o.get("reason", "?")
1372
+ if o["kind"] == "learning":
1373
+ if reason == "blob_unreachable":
1374
+ lines.append(
1375
+ f" learning #{o['id']} [{reason}]: anchor={o['anchor'][:24]}… "
1376
+ f"— {o['description']!r}"
1377
+ )
1378
+ else:
1379
+ lines.append(
1380
+ f" learning #{o['id']} [{reason}]: scope={o['scope']!r} "
1381
+ f"— {o['description']!r}"
1382
+ )
1383
+ if auto_archive:
1384
+ try:
1385
+ # Archive (soft-delete) instead of hard-deleting so
1386
+ # the learning is still surfaceable via
1387
+ # list_learnings(status='archived') and resurrectable
1388
+ # via import_learnings.
1389
+ store.archive_by_id(o["id"])
1390
+ archived_count += 1
1391
+ except Exception as exc:
1392
+ logger.warning(
1393
+ "orphan_scan: archive failed for learning %s: %s",
1394
+ o.get("id"), exc,
1395
+ )
1396
+ else:
1397
+ if reason == "all_anchors_unreachable":
1398
+ lines.append(
1399
+ f" adr #{o['number']} [{reason}]: {o['title']!r} "
1400
+ f"— unreachable anchors: {o['missing_anchors']}"
1401
+ )
1402
+ else:
1403
+ lines.append(
1404
+ f" adr #{o['number']} [{reason}]: {o['title']!r} "
1405
+ f"— missing {o['missing_files']}"
1406
+ )
1407
+ if auto_archive and archived_count:
1408
+ lines.append("")
1409
+ lines.append(f" auto-archived {archived_count} learning(s)")
1410
+ return "\n".join(lines)
1411
+
1412
+
1413
+ # ---------------------------------------------------------------------------
1414
+ # Embedding rotation — Phase 2b
1415
+ #
1416
+ # State machine lives in ``embedding_rotation.EmbeddingRotator``. These
1417
+ # tools are thin MCP wrappers over it that load a new embedding provider
1418
+ # on demand and dispatch to the rotator.
1419
+ # ---------------------------------------------------------------------------
1420
+
1421
+
1422
+ def _rotator() -> EmbeddingRotator:
1423
+ """Build a rotator bound to the current project's vectors.db."""
1424
+ from attocode_intel._internal.integrations.context.embedding_rotation import EmbeddingRotator
1425
+ project_dir = _get_project_dir()
1426
+ db_path = os.path.join(project_dir, ".attocode", "vectors", "embeddings.db")
1427
+ return EmbeddingRotator(db_path=db_path)
1428
+
1429
+
1430
+ def _format_rotation_status(status: Any) -> str:
1431
+ """Render a RotationStatus as a user-readable summary."""
1432
+ lines = [
1433
+ f" state: {status.state.value}",
1434
+ f" from: {status.from_model or '<unknown>'} "
1435
+ f"(v={status.from_version or '?'}, dim={status.from_dim})",
1436
+ f" to: {status.to_model or '<unknown>'} "
1437
+ f"(v={status.to_version or '?'}, dim={status.to_dim})",
1438
+ f" progress: {status.processed_rows}/{status.total_rows} "
1439
+ f"({status.progress_pct():.1f}%)",
1440
+ ]
1441
+ if status.last_error:
1442
+ lines.append(f" last_error: {status.last_error}")
1443
+ return "\n".join(lines)
1444
+
1445
+
1446
+ @mcp.tool()
1447
+ def embeddings_rotate_start(
1448
+ new_model: str,
1449
+ new_version: str = "",
1450
+ new_dim: int = 0,
1451
+ ) -> str:
1452
+ """Begin rotating all stored embeddings to a new model.
1453
+
1454
+ Creates a staging table (``vectors_rotating``) that will be
1455
+ populated by repeated ``embeddings_rotate_step`` calls, then
1456
+ swapped into place via ``embeddings_rotate_cutover``. The primary
1457
+ ``vectors`` table is untouched until the atomic cutover.
1458
+
1459
+ Args:
1460
+ new_model: Provider model identifier. See ``create_embedding_provider``
1461
+ for accepted names (``"bge"``, ``"all-MiniLM-L6-v2"``,
1462
+ ``"nomic-embed-text"``, ``"openai"``, or a local path).
1463
+ new_version: Optional version tag stored alongside the rotation
1464
+ (e.g. ``"v1.5"``). Free-form.
1465
+ new_dim: Optional explicit target dimension. If 0, the rotator
1466
+ loads the provider and queries its ``dimension()``.
1467
+
1468
+ Returns a human-readable summary of the new rotation state.
1469
+
1470
+ Note: during rotation, avoid concurrent ``semantic_search`` reindexes
1471
+ — writes that land on the primary ``vectors`` table mid-rotation will
1472
+ not be propagated to the new model and are effectively lost on cutover.
1473
+ """
1474
+ from attocode_intel._internal.integrations.context.embeddings import create_embedding_provider
1475
+
1476
+ rot = _rotator()
1477
+ try:
1478
+ # Load the provider once up-front to confirm it's importable +
1479
+ # fail loud before any schema mutation.
1480
+ try:
1481
+ provider = create_embedding_provider(model=new_model)
1482
+ except ImportError as exc:
1483
+ return (
1484
+ f"embeddings_rotate_start: provider for {new_model!r} not available: {exc}. "
1485
+ f"Install the relevant extras, or try `pip install attocode[semantic]`."
1486
+ )
1487
+
1488
+ effective_dim = new_dim or provider.dimension()
1489
+ if effective_dim <= 0:
1490
+ return (
1491
+ f"embeddings_rotate_start: could not resolve a dimension for "
1492
+ f"provider {new_model!r}; pass new_dim explicitly."
1493
+ )
1494
+
1495
+ try:
1496
+ status = rot.start(
1497
+ to_model=provider.name,
1498
+ to_version=new_version,
1499
+ to_dim=effective_dim,
1500
+ )
1501
+ except RuntimeError as exc:
1502
+ return f"embeddings_rotate_start: {exc}"
1503
+
1504
+ return "embeddings_rotate_start: rotation created\n" + _format_rotation_status(status)
1505
+ finally:
1506
+ rot.close()
1507
+
1508
+
1509
+ @mcp.tool()
1510
+ def embeddings_rotate_status() -> str:
1511
+ """Report the current rotation state, or 'none' if nothing is active."""
1512
+ rot = _rotator()
1513
+ try:
1514
+ status = rot.status()
1515
+ finally:
1516
+ rot.close()
1517
+ if status.state.value == "none":
1518
+ return "embeddings_rotate_status: no rotation active."
1519
+ return "embeddings_rotate_status:\n" + _format_rotation_status(status)
1520
+
1521
+
1522
+ @mcp.tool()
1523
+ def embeddings_rotate_step(batch_size: int = 32, max_batches: int = 1) -> str:
1524
+ """Process one or more batches of the backfill.
1525
+
1526
+ Args:
1527
+ batch_size: Rows per batch. Larger batches amortize provider
1528
+ load cost at the expense of wall-clock per call. Default 32.
1529
+ max_batches: Loop up to this many batches in a single call.
1530
+ Default 1 (one batch per invocation). Use a larger number
1531
+ to run a whole rotation in one MCP call on a small repo.
1532
+
1533
+ Returns a summary of the rotation state after processing.
1534
+ """
1535
+ from attocode_intel._internal.integrations.context.embeddings import create_embedding_provider
1536
+
1537
+ rot = _rotator()
1538
+ try:
1539
+ status = rot.status()
1540
+ if status.state.value not in ("pending", "backfilling"):
1541
+ return (
1542
+ f"embeddings_rotate_step: rotation is in state "
1543
+ f"{status.state.value!r}, not pending|backfilling. Nothing to do."
1544
+ )
1545
+ try:
1546
+ provider = create_embedding_provider(model=status.to_model)
1547
+ except ImportError as exc:
1548
+ return f"embeddings_rotate_step: provider import failed: {exc}"
1549
+
1550
+ total_processed = 0
1551
+ for _ in range(max(1, max_batches)):
1552
+ try:
1553
+ processed = rot.step(provider, batch_size=batch_size)
1554
+ except RuntimeError as exc:
1555
+ return f"embeddings_rotate_step: {exc}"
1556
+ total_processed += processed
1557
+ if processed == 0:
1558
+ break # done
1559
+
1560
+ status = rot.status()
1561
+ finally:
1562
+ rot.close()
1563
+
1564
+ return (
1565
+ f"embeddings_rotate_step: processed {total_processed} row(s)\n"
1566
+ + _format_rotation_status(status)
1567
+ )
1568
+
1569
+
1570
+ @mcp.tool()
1571
+ def embeddings_rotate_cutover(confirm: bool = False) -> str:
1572
+ """Atomically swap ``vectors`` and ``vectors_rotating``.
1573
+
1574
+ The old table becomes ``vectors_archive`` (still on disk until
1575
+ ``embeddings_rotate_gc_old``). Subsequent reads and writes target
1576
+ the new table. Requires state ``ready_to_cutover`` and ``confirm=True``.
1577
+ """
1578
+ rot = _rotator()
1579
+ try:
1580
+ status = rot.status()
1581
+ if status.state.value != "ready_to_cutover":
1582
+ return (
1583
+ f"embeddings_rotate_cutover: state is {status.state.value!r}, "
1584
+ f"not ready_to_cutover. Run embeddings_rotate_step until done."
1585
+ )
1586
+ if not confirm:
1587
+ return (
1588
+ "embeddings_rotate_cutover: DRY RUN — nothing changed.\n"
1589
+ + _format_rotation_status(status)
1590
+ + "\n Re-run with confirm=True to apply the swap."
1591
+ )
1592
+ try:
1593
+ status = rot.cutover()
1594
+ except (RuntimeError, Exception) as exc:
1595
+ return f"embeddings_rotate_cutover: failed: {exc}"
1596
+ finally:
1597
+ rot.close()
1598
+ return "embeddings_rotate_cutover: success\n" + _format_rotation_status(status)
1599
+
1600
+
1601
+ @mcp.tool()
1602
+ def embeddings_rotate_gc_old(confirm: bool = False) -> str:
1603
+ """Drop the archived old-model vectors table and clear rotation state.
1604
+
1605
+ Requires state ``cutover_done``. This is the point of no return for
1606
+ the old embeddings — once dropped, the only recovery is to re-embed
1607
+ from scratch (or restore from a snapshot).
1608
+ """
1609
+ rot = _rotator()
1610
+ try:
1611
+ status = rot.status()
1612
+ if status.state.value != "cutover_done":
1613
+ return (
1614
+ f"embeddings_rotate_gc_old: state is {status.state.value!r}, "
1615
+ f"not cutover_done."
1616
+ )
1617
+ if not confirm:
1618
+ return (
1619
+ "embeddings_rotate_gc_old: DRY RUN — nothing changed.\n"
1620
+ " Re-run with confirm=True to drop the archived old vectors."
1621
+ )
1622
+ try:
1623
+ status = rot.gc_old()
1624
+ except RuntimeError as exc:
1625
+ return f"embeddings_rotate_gc_old: {exc}"
1626
+ finally:
1627
+ rot.close()
1628
+ return "embeddings_rotate_gc_old: done\n" + _format_rotation_status(status)
1629
+
1630
+
1631
+ @mcp.tool()
1632
+ def embeddings_rotate_abort(confirm: bool = False) -> str:
1633
+ """Cancel a pre-cutover rotation.
1634
+
1635
+ Drops ``vectors_rotating`` and clears rotation state. The primary
1636
+ ``vectors`` table is untouched — the user's embeddings stay live.
1637
+ Cannot be called after ``cutover``; use ``gc_old`` or restore from
1638
+ a snapshot instead.
1639
+ """
1640
+ rot = _rotator()
1641
+ try:
1642
+ status = rot.status()
1643
+ if status.state.value in ("none", "gc_done"):
1644
+ return "embeddings_rotate_abort: no rotation in progress."
1645
+ if status.state.value in ("cutover_done",):
1646
+ return (
1647
+ "embeddings_rotate_abort: cannot abort after cutover. "
1648
+ "Use embeddings_rotate_gc_old to finalize or restore a snapshot."
1649
+ )
1650
+ if not confirm:
1651
+ return (
1652
+ "embeddings_rotate_abort: DRY RUN — nothing changed.\n"
1653
+ + _format_rotation_status(status)
1654
+ + "\n Re-run with confirm=True to abort."
1655
+ )
1656
+ try:
1657
+ status = rot.abort()
1658
+ except RuntimeError as exc:
1659
+ return f"embeddings_rotate_abort: {exc}"
1660
+ finally:
1661
+ rot.close()
1662
+ return "embeddings_rotate_abort: rotation aborted, primary vectors untouched"