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.
- attocode_code_intel-0.1.0.dist-info/METADATA +221 -0
- attocode_code_intel-0.1.0.dist-info/RECORD +345 -0
- attocode_code_intel-0.1.0.dist-info/WHEEL +4 -0
- attocode_code_intel-0.1.0.dist-info/entry_points.txt +2 -0
- attocode_code_intel-0.1.0.dist-info/licenses/LICENSE +21 -0
- attocode_intel/GUIDELINES.md +432 -0
- attocode_intel/__init__.py +52 -0
- attocode_intel/_internal/__init__.py +1 -0
- attocode_intel/_internal/errors/__init__.py +126 -0
- attocode_intel/_internal/integrations/__init__.py +1 -0
- attocode_intel/_internal/integrations/context/__init__.py +1 -0
- attocode_intel/_internal/integrations/context/architecture_drift.py +354 -0
- attocode_intel/_internal/integrations/context/ast_chunker.py +202 -0
- attocode_intel/_internal/integrations/context/ast_service.py +1483 -0
- attocode_intel/_internal/integrations/context/blob_oid.py +423 -0
- attocode_intel/_internal/integrations/context/cache_manifest.py +221 -0
- attocode_intel/_internal/integrations/context/cas.py +466 -0
- attocode_intel/_internal/integrations/context/code_analyzer.py +528 -0
- attocode_intel/_internal/integrations/context/codebase_ast.py +1461 -0
- attocode_intel/_internal/integrations/context/codebase_context.py +1736 -0
- attocode_intel/_internal/integrations/context/cross_mode.py +263 -0
- attocode_intel/_internal/integrations/context/cross_references.py +616 -0
- attocode_intel/_internal/integrations/context/embedding_rotation.py +597 -0
- attocode_intel/_internal/integrations/context/embeddings.py +282 -0
- attocode_intel/_internal/integrations/context/frecency.py +427 -0
- attocode_intel/_internal/integrations/context/fuzzy.py +320 -0
- attocode_intel/_internal/integrations/context/hierarchical_explorer.py +331 -0
- attocode_intel/_internal/integrations/context/hydration.py +90 -0
- attocode_intel/_internal/integrations/context/index_repair.py +87 -0
- attocode_intel/_internal/integrations/context/index_store.py +586 -0
- attocode_intel/_internal/integrations/context/memory_store.py +602 -0
- attocode_intel/_internal/integrations/context/module_resolution.py +52 -0
- attocode_intel/_internal/integrations/context/query_constraints.py +361 -0
- attocode_intel/_internal/integrations/context/query_history.py +417 -0
- attocode_intel/_internal/integrations/context/reranker.py +351 -0
- attocode_intel/_internal/integrations/context/semantic_search.py +2931 -0
- attocode_intel/_internal/integrations/context/syntax_evidence.py +94 -0
- attocode_intel/_internal/integrations/context/systemone_ranker.py +245 -0
- attocode_intel/_internal/integrations/context/temporal_coupling.py +411 -0
- attocode_intel/_internal/integrations/context/trigram_index.py +820 -0
- attocode_intel/_internal/integrations/context/trigram_regex.py +171 -0
- attocode_intel/_internal/integrations/context/ts_parser.py +2199 -0
- attocode_intel/_internal/integrations/context/vector_store.py +795 -0
- attocode_intel/_internal/integrations/feature_flags/__init__.py +357 -0
- attocode_intel/_internal/integrations/lsp/__init__.py +1 -0
- attocode_intel/_internal/integrations/lsp/client.py +1140 -0
- attocode_intel/_internal/integrations/mcp/__init__.py +1 -0
- attocode_intel/_internal/integrations/mcp/client.py +253 -0
- attocode_intel/_internal/integrations/security/__init__.py +1 -0
- attocode_intel/_internal/integrations/security/custom_rules.py +176 -0
- attocode_intel/_internal/integrations/security/dataflow.py +522 -0
- attocode_intel/_internal/integrations/security/dependency_audit.py +303 -0
- attocode_intel/_internal/integrations/security/matcher.py +44 -0
- attocode_intel/_internal/integrations/security/patterns.py +724 -0
- attocode_intel/_internal/integrations/security/scanner.py +351 -0
- attocode_intel/_internal/integrations/utilities/__init__.py +1 -0
- attocode_intel/_internal/integrations/utilities/ignore.py +64 -0
- attocode_intel/_internal/integrations/utilities/token_estimate.py +51 -0
- attocode_intel/_internal/tricks/__init__.py +1 -0
- attocode_intel/_internal/tricks/recursive_context.py +357 -0
- attocode_intel/_internal/types/__init__.py +1 -0
- attocode_intel/_internal/types/events.py +289 -0
- attocode_intel/_internal/types/severity.py +15 -0
- attocode_intel/_shared.py +341 -0
- attocode_intel/analysis_coverage.py +56 -0
- attocode_intel/api/__init__.py +1 -0
- attocode_intel/api/access.py +32 -0
- attocode_intel/api/app.py +247 -0
- attocode_intel/api/auth/__init__.py +128 -0
- attocode_intel/api/auth/api_keys.py +61 -0
- attocode_intel/api/auth/context.py +34 -0
- attocode_intel/api/auth/jwt.py +66 -0
- attocode_intel/api/auth/oauth.py +146 -0
- attocode_intel/api/auth/passwords.py +15 -0
- attocode_intel/api/deps.py +351 -0
- attocode_intel/api/knowledge_bridge.py +13 -0
- attocode_intel/api/mcp_transport.py +51 -0
- attocode_intel/api/middleware.py +411 -0
- attocode_intel/api/models.py +698 -0
- attocode_intel/api/providers/__init__.py +1 -0
- attocode_intel/api/providers/db_provider.py +1106 -0
- attocode_intel/api/providers/local_provider.py +476 -0
- attocode_intel/api/providers/remote_provider.py +740 -0
- attocode_intel/api/repository_source.py +21 -0
- attocode_intel/api/routes/__init__.py +1 -0
- attocode_intel/api/routes/_pin_helper.py +158 -0
- attocode_intel/api/routes/activity.py +148 -0
- attocode_intel/api/routes/adr.py +102 -0
- attocode_intel/api/routes/analysis.py +515 -0
- attocode_intel/api/routes/api_keys.py +170 -0
- attocode_intel/api/routes/auth.py +428 -0
- attocode_intel/api/routes/branches.py +262 -0
- attocode_intel/api/routes/cross_repo_search.py +198 -0
- attocode_intel/api/routes/embeddings.py +386 -0
- attocode_intel/api/routes/files.py +132 -0
- attocode_intel/api/routes/files_v2.py +388 -0
- attocode_intel/api/routes/gc.py +322 -0
- attocode_intel/api/routes/git_v2.py +433 -0
- attocode_intel/api/routes/graph.py +174 -0
- attocode_intel/api/routes/graph_viz.py +160 -0
- attocode_intel/api/routes/health.py +62 -0
- attocode_intel/api/routes/history.py +175 -0
- attocode_intel/api/routes/jobs.py +84 -0
- attocode_intel/api/routes/learning.py +205 -0
- attocode_intel/api/routes/lsp.py +272 -0
- attocode_intel/api/routes/notify.py +623 -0
- attocode_intel/api/routes/operations.py +27 -0
- attocode_intel/api/routes/orgs.py +924 -0
- attocode_intel/api/routes/preferences.py +100 -0
- attocode_intel/api/routes/presence.py +42 -0
- attocode_intel/api/routes/projects.py +116 -0
- attocode_intel/api/routes/repos.py +47 -0
- attocode_intel/api/routes/search.py +180 -0
- attocode_intel/api/routes/snapshots.py +614 -0
- attocode_intel/api/routes/webhooks.py +313 -0
- attocode_intel/api/routes/websocket.py +116 -0
- attocode_intel/api/static/graph.html +239 -0
- attocode_intel/api/utils.py +96 -0
- attocode_intel/artifacts/__init__.py +45 -0
- attocode_intel/artifacts/hashing.py +230 -0
- attocode_intel/artifacts/provenance.py +190 -0
- attocode_intel/artifacts/retrieval_pin.py +159 -0
- attocode_intel/audit.py +59 -0
- attocode_intel/bug_finder.py +206 -0
- attocode_intel/bundle.py +80 -0
- attocode_intel/catalog.py +242 -0
- attocode_intel/change_review.py +75 -0
- attocode_intel/cli.py +3062 -0
- attocode_intel/community.py +63 -0
- attocode_intel/confidence/__init__.py +144 -0
- attocode_intel/confidence/jev.py +234 -0
- attocode_intel/confidence/llm.py +330 -0
- attocode_intel/confidence/redact.py +44 -0
- attocode_intel/confidence/settings.py +74 -0
- attocode_intel/config.py +256 -0
- attocode_intel/crypto.py +31 -0
- attocode_intel/db/__init__.py +7 -0
- attocode_intel/db/base.py +33 -0
- attocode_intel/db/engine.py +61 -0
- attocode_intel/db/models.py +529 -0
- attocode_intel/entrypoint.py +185 -0
- attocode_intel/focused_evidence.py +130 -0
- attocode_intel/freshness.py +154 -0
- attocode_intel/gateway.py +734 -0
- attocode_intel/git/__init__.py +7 -0
- attocode_intel/git/credentials.py +75 -0
- attocode_intel/git/manager.py +493 -0
- attocode_intel/git/models.py +96 -0
- attocode_intel/git/storage.py +98 -0
- attocode_intel/graph_query_parser.py +761 -0
- attocode_intel/helpers.py +712 -0
- attocode_intel/indexing/__init__.py +29 -0
- attocode_intel/indexing/debouncer.py +136 -0
- attocode_intel/indexing/delta_indexer.py +151 -0
- attocode_intel/indexing/full_indexer.py +323 -0
- attocode_intel/indexing/incremental.py +240 -0
- attocode_intel/indexing/parser.py +403 -0
- attocode_intel/indexing/summarizer.py +264 -0
- attocode_intel/installer.py +1896 -0
- attocode_intel/knowledge.py +269 -0
- attocode_intel/local_knowledge.py +77 -0
- attocode_intel/migrations/alembic.ini +36 -0
- attocode_intel/migrations/env.py +71 -0
- attocode_intel/migrations/script.py.mako +27 -0
- attocode_intel/migrations/versions/001_initial_schema.py +219 -0
- attocode_intel/migrations/versions/002_branch_version_and_embeddings.py +61 -0
- attocode_intel/migrations/versions/003_embedding_uniqueness_and_cascade.py +51 -0
- attocode_intel/migrations/versions/004_audit_events.py +38 -0
- attocode_intel/migrations/versions/005_user_preferences.py +30 -0
- attocode_intel/migrations/versions/006_google_oauth.py +27 -0
- attocode_intel/migrations/versions/007_pgvector_embeddings.py +28 -0
- attocode_intel/migrations/versions/008_symbol_references.py +37 -0
- attocode_intel/migrations/versions/009_webhook_secret_encryption.py +25 -0
- attocode_intel/migrations/versions/010_add_commits_table.py +40 -0
- attocode_intel/migrations/versions/011_commit_changed_files.py +25 -0
- attocode_intel/migrations/versions/012_branch_merged_at.py +25 -0
- attocode_intel/migrations/versions/013_revoked_tokens.py +32 -0
- attocode_intel/migrations/versions/014_blame_hunks.py +40 -0
- attocode_intel/migrations/versions/015_commit_file_stats.py +40 -0
- attocode_intel/migrations/versions/016_embedding_provenance_columns.py +179 -0
- attocode_intel/migrations/versions/017_provenance_table.py +102 -0
- attocode_intel/migrations/versions/018_repo_snapshots.py +178 -0
- attocode_intel/migrations/versions/019_symref_caller.py +42 -0
- attocode_intel/migrations/versions/020_shared_knowledge.py +29 -0
- attocode_intel/onboarding.py +258 -0
- attocode_intel/output.py +178 -0
- attocode_intel/precision.py +133 -0
- attocode_intel/presence.py +160 -0
- attocode_intel/probe.py +110 -0
- attocode_intel/project_dir.py +97 -0
- attocode_intel/pubsub.py +113 -0
- attocode_intel/query_ranking.py +242 -0
- attocode_intel/readiness.py +972 -0
- attocode_intel/redis.py +51 -0
- attocode_intel/reference_pages.py +60 -0
- attocode_intel/remote.py +131 -0
- attocode_intel/repo_ranker.py +246 -0
- attocode_intel/request_context.py +82 -0
- attocode_intel/rules/__init__.py +28 -0
- attocode_intel/rules/ci.py +320 -0
- attocode_intel/rules/combinators.py +293 -0
- attocode_intel/rules/enricher.py +127 -0
- attocode_intel/rules/evolution.py +452 -0
- attocode_intel/rules/executor.py +531 -0
- attocode_intel/rules/filters/__init__.py +5 -0
- attocode_intel/rules/filters/pipeline.py +151 -0
- attocode_intel/rules/formatter.py +198 -0
- attocode_intel/rules/hygiene.py +242 -0
- attocode_intel/rules/importers/__init__.py +1 -0
- attocode_intel/rules/importers/semgrep.py +382 -0
- attocode_intel/rules/loader.py +292 -0
- attocode_intel/rules/marketplace.py +375 -0
- attocode_intel/rules/metavar.py +181 -0
- attocode_intel/rules/model.py +317 -0
- attocode_intel/rules/packs/__init__.py +1 -0
- attocode_intel/rules/packs/community/_license_templates/Apache-2.0.txt +176 -0
- attocode_intel/rules/packs/community/_license_templates/MIT.txt +21 -0
- attocode_intel/rules/packs/community/bandit-python/LICENSE +176 -0
- attocode_intel/rules/packs/community/bandit-python/NOTICE +13 -0
- attocode_intel/rules/packs/community/bandit-python/PORTING.md +23 -0
- attocode_intel/rules/packs/community/bandit-python/manifest.yaml +14 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B105-hardcoded-password.yaml +23 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B303-weak-md5.yaml +21 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B304-weak-des.yaml +20 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B307-use-of-eval.yaml +22 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B324-weak-sha1.yaml +21 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B501-no-cert-validation.yaml +23 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B602-shell-true.yaml +22 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B608-sqli.yaml +22 -0
- attocode_intel/rules/packs/community/eslint-typescript/LICENSE +21 -0
- attocode_intel/rules/packs/community/eslint-typescript/NOTICE +13 -0
- attocode_intel/rules/packs/community/eslint-typescript/PORTING.md +23 -0
- attocode_intel/rules/packs/community/eslint-typescript/manifest.yaml +14 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/eqeqeq.yaml +20 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-eval.yaml +22 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-implied-eval.yaml +20 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-new-func.yaml +21 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-throw-literal.yaml +19 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-var.yaml +20 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-with.yaml +18 -0
- attocode_intel/rules/packs/community/gosec-go/LICENSE +176 -0
- attocode_intel/rules/packs/community/gosec-go/NOTICE +13 -0
- attocode_intel/rules/packs/community/gosec-go/PORTING.md +23 -0
- attocode_intel/rules/packs/community/gosec-go/manifest.yaml +14 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G101-hardcoded-credentials.yaml +22 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G102-bind-all-interfaces.yaml +21 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G103-unsafe-block.yaml +21 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G201-sql-format.yaml +21 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G304-file-inclusion.yaml +23 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G401-weak-cipher.yaml +20 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G402-tls-min-version.yaml +20 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G501-md5-import.yaml +21 -0
- attocode_intel/rules/packs/examples/cpp/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/cpp/rules/security.yaml +90 -0
- attocode_intel/rules/packs/examples/cpp/rules/structural.yaml +42 -0
- attocode_intel/rules/packs/examples/csharp/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/csharp/rules/security.yaml +69 -0
- attocode_intel/rules/packs/examples/csharp/rules/structural.yaml +42 -0
- attocode_intel/rules/packs/examples/go/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/go/rules/performance.yaml +136 -0
- attocode_intel/rules/packs/examples/go/rules/security.yaml +48 -0
- attocode_intel/rules/packs/examples/go/rules/structural.yaml +58 -0
- attocode_intel/rules/packs/examples/go/taint/sanitizers.yaml +24 -0
- attocode_intel/rules/packs/examples/go/taint/sinks.yaml +51 -0
- attocode_intel/rules/packs/examples/go/taint/sources.yaml +26 -0
- attocode_intel/rules/packs/examples/java/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/java/rules/correctness.yaml +55 -0
- attocode_intel/rules/packs/examples/java/rules/structural.yaml +48 -0
- attocode_intel/rules/packs/examples/java/taint/sinks.yaml +37 -0
- attocode_intel/rules/packs/examples/java/taint/sources.yaml +21 -0
- attocode_intel/rules/packs/examples/kotlin/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/kotlin/rules/correctness.yaml +50 -0
- attocode_intel/rules/packs/examples/kotlin/rules/structural.yaml +38 -0
- attocode_intel/rules/packs/examples/php/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/php/rules/security.yaml +66 -0
- attocode_intel/rules/packs/examples/php/rules/structural.yaml +41 -0
- attocode_intel/rules/packs/examples/python/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/python/rules/performance.yaml +90 -0
- attocode_intel/rules/packs/examples/python/rules/structural.yaml +55 -0
- attocode_intel/rules/packs/examples/ruby/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/ruby/rules/security.yaml +73 -0
- attocode_intel/rules/packs/examples/ruby/rules/structural.yaml +41 -0
- attocode_intel/rules/packs/examples/rust/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/rust/rules/correctness.yaml +51 -0
- attocode_intel/rules/packs/examples/rust/rules/structural.yaml +49 -0
- attocode_intel/rules/packs/examples/typescript/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/typescript/rules/performance.yaml +70 -0
- attocode_intel/rules/packs/examples/typescript/rules/structural.yaml +48 -0
- attocode_intel/rules/packs/pack_loader.py +244 -0
- attocode_intel/rules/plugins/__init__.py +1 -0
- attocode_intel/rules/plugins/plugin_loader.py +132 -0
- attocode_intel/rules/profiling.py +332 -0
- attocode_intel/rules/registry.py +213 -0
- attocode_intel/rules/sarif.py +187 -0
- attocode_intel/rules/synthesis.py +420 -0
- attocode_intel/rules/testing.py +326 -0
- attocode_intel/server.py +734 -0
- attocode_intel/service.py +3416 -0
- attocode_intel/sharing.py +57 -0
- attocode_intel/storage/__init__.py +6 -0
- attocode_intel/storage/branch_overlay.py +352 -0
- attocode_intel/storage/content_store.py +220 -0
- attocode_intel/storage/dependency_store.py +143 -0
- attocode_intel/storage/diff_engine.py +187 -0
- attocode_intel/storage/embedding_store.py +594 -0
- attocode_intel/storage/provenance_store.py +137 -0
- attocode_intel/storage/security_scanner_db.py +164 -0
- attocode_intel/storage/symbol_store.py +160 -0
- attocode_intel/structural_evidence.py +144 -0
- attocode_intel/symbol_inspection.py +141 -0
- attocode_intel/symbol_links.py +167 -0
- attocode_intel/telemetry.py +42 -0
- attocode_intel/test_ranking.py +73 -0
- attocode_intel/testing/__init__.py +43 -0
- attocode_intel/testing/fixtures.py +122 -0
- attocode_intel/testing/helpers.py +75 -0
- attocode_intel/testing/mocks.py +99 -0
- attocode_intel/tools/__init__.py +2 -0
- attocode_intel/tools/adr_tools.py +628 -0
- attocode_intel/tools/analysis_tools.py +382 -0
- attocode_intel/tools/composite_tools.py +695 -0
- attocode_intel/tools/cross_mode_tools.py +173 -0
- attocode_intel/tools/dead_code_tools.py +582 -0
- attocode_intel/tools/distill_tools.py +353 -0
- attocode_intel/tools/frecency_tools.py +220 -0
- attocode_intel/tools/fuzzy_tools.py +255 -0
- attocode_intel/tools/history_tools.py +486 -0
- attocode_intel/tools/learning_tools.py +222 -0
- attocode_intel/tools/lsp_tools.py +218 -0
- attocode_intel/tools/maintenance_tools.py +1662 -0
- attocode_intel/tools/navigation_tools.py +220 -0
- attocode_intel/tools/overlay_tools.py +540 -0
- attocode_intel/tools/pin_store.py +432 -0
- attocode_intel/tools/pin_tools.py +266 -0
- attocode_intel/tools/query_constraints_tools.py +262 -0
- attocode_intel/tools/query_history_tools.py +158 -0
- attocode_intel/tools/readiness_tools.py +44 -0
- attocode_intel/tools/rule_tools.py +992 -0
- attocode_intel/tools/search_tools.py +770 -0
- attocode_intel/tools/snapshot_tools.py +624 -0
- attocode_intel/workers/__init__.py +1 -0
- attocode_intel/workers/job_utils.py +111 -0
- attocode_intel/workers/jobs.py +573 -0
- attocode_intel/workers/run.py +30 -0
- 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"
|