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,486 @@
|
|
|
1
|
+
"""History tools for the code-intel MCP server.
|
|
2
|
+
|
|
3
|
+
Tools: code_evolution, recent_changes, change_coupling, churn_hotspots,
|
|
4
|
+
merge_risk.
|
|
5
|
+
|
|
6
|
+
Local mode uses git subprocess calls directly.
|
|
7
|
+
Service mode queries the Commit + CommitFileStat DB tables.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
import threading
|
|
16
|
+
from collections import defaultdict
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from attocode_intel._shared import (
|
|
20
|
+
_get_project_dir,
|
|
21
|
+
_get_remote_service,
|
|
22
|
+
_get_service,
|
|
23
|
+
mcp,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from attocode_intel._internal.integrations.context.temporal_coupling import TemporalCouplingAnalyzer
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Helpers — git subprocess for local mode
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _run_git(args: list[str], cwd: str) -> str:
|
|
38
|
+
"""Run a git command and return stdout, or empty string on error."""
|
|
39
|
+
try:
|
|
40
|
+
result = subprocess.run(
|
|
41
|
+
["git"] + args,
|
|
42
|
+
cwd=cwd,
|
|
43
|
+
capture_output=True,
|
|
44
|
+
text=True,
|
|
45
|
+
timeout=30,
|
|
46
|
+
)
|
|
47
|
+
if result.returncode != 0:
|
|
48
|
+
logger.debug("git %s failed: %s", " ".join(args), result.stderr.strip())
|
|
49
|
+
return ""
|
|
50
|
+
return result.stdout
|
|
51
|
+
except (FileNotFoundError, subprocess.TimeoutExpired) as exc:
|
|
52
|
+
logger.debug("git command error: %s", exc)
|
|
53
|
+
return ""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _parse_evolution_output(raw: str) -> list[dict]:
|
|
57
|
+
"""Parse git log output with --numstat --format=%H|%an|%ae|%aI|%s.
|
|
58
|
+
|
|
59
|
+
Returns a list of commit dicts with per-file stats.
|
|
60
|
+
"""
|
|
61
|
+
if not raw.strip():
|
|
62
|
+
return []
|
|
63
|
+
|
|
64
|
+
commits: list[dict] = []
|
|
65
|
+
current: dict | None = None
|
|
66
|
+
|
|
67
|
+
for line in raw.splitlines():
|
|
68
|
+
line = line.strip()
|
|
69
|
+
if not line:
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
# Header line: sha|author_name|author_email|date|subject
|
|
73
|
+
if "|" in line and line.count("|") >= 4:
|
|
74
|
+
parts = line.split("|", 4)
|
|
75
|
+
if len(parts) == 5 and len(parts[0]) >= 7:
|
|
76
|
+
if current is not None:
|
|
77
|
+
commits.append(current)
|
|
78
|
+
current = {
|
|
79
|
+
"sha": parts[0],
|
|
80
|
+
"author": parts[1],
|
|
81
|
+
"email": parts[2],
|
|
82
|
+
"date": parts[3],
|
|
83
|
+
"subject": parts[4],
|
|
84
|
+
"files": [],
|
|
85
|
+
}
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
# Numstat line: added\tremoved\tpath
|
|
89
|
+
if current is not None and "\t" in line:
|
|
90
|
+
parts = line.split("\t")
|
|
91
|
+
if len(parts) >= 3:
|
|
92
|
+
try:
|
|
93
|
+
added = int(parts[0]) if parts[0] != "-" else 0
|
|
94
|
+
removed = int(parts[1]) if parts[1] != "-" else 0
|
|
95
|
+
except ValueError:
|
|
96
|
+
continue
|
|
97
|
+
file_path = parts[2]
|
|
98
|
+
# Handle renames: old => new
|
|
99
|
+
if " => " in file_path:
|
|
100
|
+
file_path = file_path.split(" => ")[-1].rstrip("}")
|
|
101
|
+
if "{" in file_path:
|
|
102
|
+
# Handle partial rename like src/{old => new}/file.py
|
|
103
|
+
file_path = file_path.replace("{", "").replace("}", "")
|
|
104
|
+
current["files"].append({
|
|
105
|
+
"path": file_path,
|
|
106
|
+
"added": added,
|
|
107
|
+
"removed": removed,
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
if current is not None:
|
|
111
|
+
commits.append(current)
|
|
112
|
+
|
|
113
|
+
return commits
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _filter_by_symbol(commits: list[dict], symbol: str) -> list[dict]:
|
|
117
|
+
"""Filter commits to only those whose subject or file paths mention the symbol."""
|
|
118
|
+
if not symbol:
|
|
119
|
+
return commits
|
|
120
|
+
symbol_lower = symbol.lower()
|
|
121
|
+
filtered = []
|
|
122
|
+
for commit in commits:
|
|
123
|
+
if symbol_lower in commit["subject"].lower():
|
|
124
|
+
filtered.append(commit)
|
|
125
|
+
continue
|
|
126
|
+
# Check if any changed file might contain the symbol
|
|
127
|
+
# (heuristic: symbol name appears in the path)
|
|
128
|
+
for f in commit["files"]:
|
|
129
|
+
if symbol_lower in f["path"].lower():
|
|
130
|
+
filtered.append(commit)
|
|
131
|
+
break
|
|
132
|
+
return filtered
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
# MCP Tools
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@mcp.tool()
|
|
141
|
+
def code_evolution(
|
|
142
|
+
path: str,
|
|
143
|
+
symbol: str = "",
|
|
144
|
+
since: str = "",
|
|
145
|
+
max_results: int = 20,
|
|
146
|
+
) -> str:
|
|
147
|
+
"""Show change history for a file or symbol.
|
|
148
|
+
|
|
149
|
+
Traces how a file (or a specific symbol within it) has evolved over
|
|
150
|
+
time, showing commits with line-level change statistics. Useful for
|
|
151
|
+
understanding why code looks the way it does and who changed it.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
path: File path (relative to project root or absolute).
|
|
155
|
+
symbol: Optional symbol name to filter commits mentioning it.
|
|
156
|
+
since: Optional date filter (e.g. "2024-01-01", "3 months ago").
|
|
157
|
+
max_results: Maximum number of commits to return (default 20).
|
|
158
|
+
"""
|
|
159
|
+
remote = _get_remote_service()
|
|
160
|
+
if remote is not None:
|
|
161
|
+
return remote.code_evolution(path=path, symbol=symbol, since=since, max_results=max_results)
|
|
162
|
+
|
|
163
|
+
project_dir = _get_project_dir()
|
|
164
|
+
|
|
165
|
+
# Resolve path relative to project
|
|
166
|
+
if os.path.isabs(path):
|
|
167
|
+
try:
|
|
168
|
+
rel_path = os.path.relpath(path, project_dir)
|
|
169
|
+
except ValueError:
|
|
170
|
+
rel_path = path
|
|
171
|
+
else:
|
|
172
|
+
rel_path = path
|
|
173
|
+
|
|
174
|
+
# Build git log command
|
|
175
|
+
git_args = [
|
|
176
|
+
"log",
|
|
177
|
+
"--follow",
|
|
178
|
+
"--numstat",
|
|
179
|
+
"--format=%H|%an|%ae|%aI|%s",
|
|
180
|
+
f"-{max_results * 2}", # fetch extra to account for filtering
|
|
181
|
+
]
|
|
182
|
+
if since:
|
|
183
|
+
git_args.append(f"--since={since}")
|
|
184
|
+
git_args.extend(["--", rel_path])
|
|
185
|
+
|
|
186
|
+
raw = _run_git(git_args, project_dir)
|
|
187
|
+
if not raw.strip():
|
|
188
|
+
return f"No commit history found for '{rel_path}'."
|
|
189
|
+
|
|
190
|
+
commits = _parse_evolution_output(raw)
|
|
191
|
+
|
|
192
|
+
# Filter by symbol if requested
|
|
193
|
+
if symbol:
|
|
194
|
+
commits = _filter_by_symbol(commits, symbol)
|
|
195
|
+
|
|
196
|
+
commits = commits[:max_results]
|
|
197
|
+
|
|
198
|
+
if not commits:
|
|
199
|
+
msg = f"No commits found for '{rel_path}'"
|
|
200
|
+
if symbol:
|
|
201
|
+
msg += f" mentioning symbol '{symbol}'"
|
|
202
|
+
return msg + "."
|
|
203
|
+
|
|
204
|
+
# Format output
|
|
205
|
+
lines = [f"Change history for {rel_path}"]
|
|
206
|
+
if symbol:
|
|
207
|
+
lines[0] += f" (symbol: {symbol})"
|
|
208
|
+
lines.append(f"({len(commits)} commits shown)\n")
|
|
209
|
+
|
|
210
|
+
for i, commit in enumerate(commits, 1):
|
|
211
|
+
sha_short = commit["sha"][:8]
|
|
212
|
+
date_str = commit["date"][:10] if len(commit["date"]) >= 10 else commit["date"]
|
|
213
|
+
lines.append(
|
|
214
|
+
f" {i:>3}. {sha_short} {date_str} {commit['author']}"
|
|
215
|
+
)
|
|
216
|
+
lines.append(f" {commit['subject']}")
|
|
217
|
+
|
|
218
|
+
# Show file stats
|
|
219
|
+
total_added = 0
|
|
220
|
+
total_removed = 0
|
|
221
|
+
for f in commit["files"]:
|
|
222
|
+
total_added += f["added"]
|
|
223
|
+
total_removed += f["removed"]
|
|
224
|
+
|
|
225
|
+
if len(commit["files"]) == 1:
|
|
226
|
+
f = commit["files"][0]
|
|
227
|
+
lines.append(f" +{f['added']} -{f['removed']} {f['path']}")
|
|
228
|
+
elif commit["files"]:
|
|
229
|
+
lines.append(f" +{total_added} -{total_removed} across {len(commit['files'])} files")
|
|
230
|
+
# Show the target file specifically if among many
|
|
231
|
+
for f in commit["files"]:
|
|
232
|
+
if rel_path in f["path"] or f["path"] in rel_path:
|
|
233
|
+
lines.append(f" +{f['added']} -{f['removed']} {f['path']}")
|
|
234
|
+
break
|
|
235
|
+
|
|
236
|
+
lines.append("")
|
|
237
|
+
|
|
238
|
+
# Summary
|
|
239
|
+
total_commits = len(commits)
|
|
240
|
+
all_authors = sorted(set(c["author"] for c in commits))
|
|
241
|
+
lines.append(f"Summary: {total_commits} commits by {len(all_authors)} author(s)")
|
|
242
|
+
if len(all_authors) <= 5:
|
|
243
|
+
lines.append(f"Authors: {', '.join(all_authors)}")
|
|
244
|
+
else:
|
|
245
|
+
lines.append(f"Top authors: {', '.join(all_authors[:5])}, +{len(all_authors) - 5} more")
|
|
246
|
+
|
|
247
|
+
return "\n".join(lines)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@mcp.tool()
|
|
251
|
+
def recent_changes(
|
|
252
|
+
days: int = 7,
|
|
253
|
+
path: str = "",
|
|
254
|
+
top_n: int = 20,
|
|
255
|
+
) -> str:
|
|
256
|
+
"""Show recently modified files and change frequency.
|
|
257
|
+
|
|
258
|
+
Aggregates recent git activity to identify files with the most
|
|
259
|
+
changes — useful for finding active development areas, understanding
|
|
260
|
+
project velocity, and spotting potential merge conflict hotspots.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
days: Look back this many days (default 7).
|
|
264
|
+
path: Optional path prefix to filter (e.g. "src/api/").
|
|
265
|
+
top_n: Number of top files to show (default 20).
|
|
266
|
+
"""
|
|
267
|
+
remote = _get_remote_service()
|
|
268
|
+
if remote is not None:
|
|
269
|
+
return remote.recent_changes(days=days, path=path, top_n=top_n)
|
|
270
|
+
|
|
271
|
+
project_dir = _get_project_dir()
|
|
272
|
+
|
|
273
|
+
git_args = [
|
|
274
|
+
"log",
|
|
275
|
+
f"--since={days} days ago",
|
|
276
|
+
"--numstat",
|
|
277
|
+
"--format=%H|%aI|%s",
|
|
278
|
+
]
|
|
279
|
+
if path:
|
|
280
|
+
git_args.extend(["--", path])
|
|
281
|
+
|
|
282
|
+
raw = _run_git(git_args, project_dir)
|
|
283
|
+
if not raw.strip():
|
|
284
|
+
scope = f" under '{path}'" if path else ""
|
|
285
|
+
return f"No changes found in the last {days} day(s){scope}."
|
|
286
|
+
|
|
287
|
+
# Parse and aggregate
|
|
288
|
+
file_stats: dict[str, dict] = defaultdict(
|
|
289
|
+
lambda: {"commits": 0, "added": 0, "removed": 0, "authors": set(), "last_date": ""}
|
|
290
|
+
)
|
|
291
|
+
commit_count = 0
|
|
292
|
+
all_authors: set[str] = set()
|
|
293
|
+
current_date = ""
|
|
294
|
+
|
|
295
|
+
for line in raw.splitlines():
|
|
296
|
+
line = line.strip()
|
|
297
|
+
if not line:
|
|
298
|
+
continue
|
|
299
|
+
|
|
300
|
+
# Header line
|
|
301
|
+
if "|" in line and line.count("|") >= 2:
|
|
302
|
+
parts = line.split("|", 2)
|
|
303
|
+
if len(parts) == 3 and len(parts[0]) >= 7:
|
|
304
|
+
commit_count += 1
|
|
305
|
+
current_date = parts[1][:10] if len(parts[1]) >= 10 else parts[1]
|
|
306
|
+
# Extract author from a separate git log if needed
|
|
307
|
+
continue
|
|
308
|
+
|
|
309
|
+
# Numstat line
|
|
310
|
+
if "\t" in line:
|
|
311
|
+
parts = line.split("\t")
|
|
312
|
+
if len(parts) >= 3:
|
|
313
|
+
try:
|
|
314
|
+
added = int(parts[0]) if parts[0] != "-" else 0
|
|
315
|
+
removed = int(parts[1]) if parts[1] != "-" else 0
|
|
316
|
+
except ValueError:
|
|
317
|
+
continue
|
|
318
|
+
file_path = parts[2]
|
|
319
|
+
if " => " in file_path:
|
|
320
|
+
file_path = file_path.split(" => ")[-1].rstrip("}")
|
|
321
|
+
if "{" in file_path:
|
|
322
|
+
file_path = file_path.replace("{", "").replace("}", "")
|
|
323
|
+
|
|
324
|
+
stats = file_stats[file_path]
|
|
325
|
+
stats["commits"] += 1
|
|
326
|
+
stats["added"] += added
|
|
327
|
+
stats["removed"] += removed
|
|
328
|
+
if current_date and (not stats["last_date"] or current_date > stats["last_date"]):
|
|
329
|
+
stats["last_date"] = current_date
|
|
330
|
+
|
|
331
|
+
if not file_stats:
|
|
332
|
+
scope = f" under '{path}'" if path else ""
|
|
333
|
+
return f"No file changes found in the last {days} day(s){scope}."
|
|
334
|
+
|
|
335
|
+
# Also get author info with a separate command
|
|
336
|
+
author_args = [
|
|
337
|
+
"log",
|
|
338
|
+
f"--since={days} days ago",
|
|
339
|
+
"--format=%an",
|
|
340
|
+
]
|
|
341
|
+
if path:
|
|
342
|
+
author_args.extend(["--", path])
|
|
343
|
+
author_raw = _run_git(author_args, project_dir)
|
|
344
|
+
if author_raw:
|
|
345
|
+
all_authors = set(a.strip() for a in author_raw.splitlines() if a.strip())
|
|
346
|
+
|
|
347
|
+
# Sort by commit frequency, then by total churn
|
|
348
|
+
ranked = sorted(
|
|
349
|
+
file_stats.items(),
|
|
350
|
+
key=lambda kv: (kv[1]["commits"], kv[1]["added"] + kv[1]["removed"]),
|
|
351
|
+
reverse=True,
|
|
352
|
+
)[:top_n]
|
|
353
|
+
|
|
354
|
+
# Format output
|
|
355
|
+
scope_label = f" under '{path}'" if path else ""
|
|
356
|
+
lines = [
|
|
357
|
+
f"Recent changes (last {days} day(s){scope_label})",
|
|
358
|
+
f"{commit_count} commits, {len(file_stats)} files modified, "
|
|
359
|
+
f"{len(all_authors)} contributor(s)\n",
|
|
360
|
+
]
|
|
361
|
+
|
|
362
|
+
# Table header
|
|
363
|
+
lines.append(f" {'#':>3} {'Commits':>7} {'Added':>6} {'Removed':>7} {'Last':>10} File")
|
|
364
|
+
lines.append(f" {'':->3} {'':->7} {'':->6} {'':->7} {'':->10} {'':->40}")
|
|
365
|
+
|
|
366
|
+
for i, (fpath, stats) in enumerate(ranked, 1):
|
|
367
|
+
last_date = stats["last_date"] if stats["last_date"] else "?"
|
|
368
|
+
lines.append(
|
|
369
|
+
f" {i:>3} {stats['commits']:>7} "
|
|
370
|
+
f"+{stats['added']:>5} -{stats['removed']:>6} "
|
|
371
|
+
f"{last_date:>10} {fpath}"
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
# Churn summary
|
|
375
|
+
total_added = sum(s["added"] for s in file_stats.values())
|
|
376
|
+
total_removed = sum(s["removed"] for s in file_stats.values())
|
|
377
|
+
lines.append(f"\nTotal churn: +{total_added} -{total_removed} lines")
|
|
378
|
+
|
|
379
|
+
if all_authors:
|
|
380
|
+
sorted_authors = sorted(all_authors)
|
|
381
|
+
if len(sorted_authors) <= 5:
|
|
382
|
+
lines.append(f"Contributors: {', '.join(sorted_authors)}")
|
|
383
|
+
else:
|
|
384
|
+
lines.append(
|
|
385
|
+
f"Contributors: {', '.join(sorted_authors[:5])}, "
|
|
386
|
+
f"+{len(sorted_authors) - 5} more"
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
return "\n".join(lines)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
# ---------------------------------------------------------------------------
|
|
393
|
+
# Temporal coupling helpers
|
|
394
|
+
# ---------------------------------------------------------------------------
|
|
395
|
+
|
|
396
|
+
_temporal_analyzer: TemporalCouplingAnalyzer | None = None
|
|
397
|
+
_temporal_lock = threading.Lock()
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _get_temporal_analyzer() -> TemporalCouplingAnalyzer:
|
|
401
|
+
"""Lazily initialize the TemporalCouplingAnalyzer singleton."""
|
|
402
|
+
from attocode_intel.request_context import current_request, scoped_store
|
|
403
|
+
if current_request.get() is not None:
|
|
404
|
+
from attocode_intel._internal.integrations.context.temporal_coupling import TemporalCouplingAnalyzer
|
|
405
|
+
return scoped_store("temporal", TemporalCouplingAnalyzer)
|
|
406
|
+
global _temporal_analyzer
|
|
407
|
+
if _temporal_analyzer is None:
|
|
408
|
+
with _temporal_lock:
|
|
409
|
+
if _temporal_analyzer is None:
|
|
410
|
+
from attocode_intel._internal.integrations.context.temporal_coupling import (
|
|
411
|
+
TemporalCouplingAnalyzer,
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
project_dir = _get_project_dir()
|
|
415
|
+
_temporal_analyzer = TemporalCouplingAnalyzer(project_dir=project_dir)
|
|
416
|
+
return _temporal_analyzer
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
@mcp.tool()
|
|
420
|
+
def change_coupling(
|
|
421
|
+
file: str,
|
|
422
|
+
days: int = 90,
|
|
423
|
+
min_coupling: float = 0.3,
|
|
424
|
+
top_k: int = 20,
|
|
425
|
+
) -> str:
|
|
426
|
+
"""Find files that frequently change alongside a given file.
|
|
427
|
+
|
|
428
|
+
Uses git history to build a co-change frequency matrix. Files that
|
|
429
|
+
appear in the same commit as the target file are scored by how often
|
|
430
|
+
they co-occur relative to their individual change frequency.
|
|
431
|
+
|
|
432
|
+
A coupling score of 1.0 means the files ALWAYS change together.
|
|
433
|
+
A score of 0.5 means they change together half the time.
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
file: Target file path (relative to project root).
|
|
437
|
+
days: Time window in days (default 90).
|
|
438
|
+
min_coupling: Minimum coupling score to include (0.0-1.0, default 0.3).
|
|
439
|
+
top_k: Maximum number of results (default 20).
|
|
440
|
+
"""
|
|
441
|
+
return _get_service().change_coupling(
|
|
442
|
+
file=file,
|
|
443
|
+
days=days,
|
|
444
|
+
min_coupling=min_coupling,
|
|
445
|
+
top_k=top_k,
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
@mcp.tool()
|
|
450
|
+
def churn_hotspots(
|
|
451
|
+
days: int = 90,
|
|
452
|
+
top_n: int = 20,
|
|
453
|
+
) -> str:
|
|
454
|
+
"""Rank files by change frequency and churn intensity.
|
|
455
|
+
|
|
456
|
+
Combines commit frequency with line-level churn to identify the
|
|
457
|
+
most actively modified files. High-churn files are candidates for
|
|
458
|
+
refactoring, increased test coverage, or architectural attention.
|
|
459
|
+
|
|
460
|
+
Args:
|
|
461
|
+
days: Time window in days (default 90).
|
|
462
|
+
top_n: Number of top files to return (default 20).
|
|
463
|
+
"""
|
|
464
|
+
return _get_service().churn_hotspots(days=days, top_n=top_n)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
@mcp.tool()
|
|
468
|
+
def merge_risk(
|
|
469
|
+
files: list[str],
|
|
470
|
+
days: int = 90,
|
|
471
|
+
) -> str:
|
|
472
|
+
"""Predict which other files will likely need changes.
|
|
473
|
+
|
|
474
|
+
Given a set of files you're about to modify, predicts which other
|
|
475
|
+
files are at risk of needing changes too — based on both temporal
|
|
476
|
+
coupling (git co-change history) and structural coupling (import
|
|
477
|
+
dependencies).
|
|
478
|
+
|
|
479
|
+
Each prediction is annotated with its source ("temporal",
|
|
480
|
+
"structural", or "both") and a confidence score.
|
|
481
|
+
|
|
482
|
+
Args:
|
|
483
|
+
files: List of file paths being modified.
|
|
484
|
+
days: Time window for temporal coupling (default 90).
|
|
485
|
+
"""
|
|
486
|
+
return _get_service().merge_risk(files=files, days=days)
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Learning / memory tools for the code-intel MCP server.
|
|
2
|
+
|
|
3
|
+
Tools: recall, record_learning, learning_feedback, list_learnings.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import threading
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from attocode_intel._shared import (
|
|
13
|
+
_get_project_dir,
|
|
14
|
+
_get_remote_service,
|
|
15
|
+
mcp,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from attocode_intel._internal.integrations.context.memory_store import MemoryStore
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Lazy memory store singleton
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
_memory_store_lock = threading.Lock()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _get_memory_store() -> MemoryStore:
|
|
31
|
+
"""Lazily initialize and return the MemoryStore singleton (thread-safe).
|
|
32
|
+
|
|
33
|
+
Uses ``server._memory_store`` as the authoritative location so tests
|
|
34
|
+
can reset it by setting ``srv._memory_store = None``.
|
|
35
|
+
"""
|
|
36
|
+
from attocode_intel.request_context import current_request
|
|
37
|
+
if (request := current_request.get()) is not None:
|
|
38
|
+
return request.service._get_memory_store()
|
|
39
|
+
import attocode_intel.server as _srv
|
|
40
|
+
from attocode_intel._internal.integrations.context.memory_store import MemoryStore
|
|
41
|
+
|
|
42
|
+
existing = _srv._memory_store
|
|
43
|
+
if isinstance(existing, MemoryStore):
|
|
44
|
+
return existing
|
|
45
|
+
# server._memory_store is None — create a fresh instance
|
|
46
|
+
with _memory_store_lock:
|
|
47
|
+
# Double-check after acquiring lock
|
|
48
|
+
existing = _srv._memory_store
|
|
49
|
+
if isinstance(existing, MemoryStore):
|
|
50
|
+
return existing
|
|
51
|
+
|
|
52
|
+
store = MemoryStore(_get_project_dir())
|
|
53
|
+
_srv._memory_store = store
|
|
54
|
+
return store
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# Tools
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@mcp.tool()
|
|
63
|
+
def recall(query: str, scope: str = "", max_results: int = 10) -> str:
|
|
64
|
+
"""Retrieve relevant project learnings (patterns, conventions, gotchas).
|
|
65
|
+
|
|
66
|
+
Call this at the start of a task or when working in unfamiliar code.
|
|
67
|
+
Scope narrows results to a directory subtree (e.g. 'src/api/').
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
query: Natural language description of what you're working on.
|
|
71
|
+
scope: Optional directory scope to filter learnings.
|
|
72
|
+
max_results: Maximum number of learnings to return.
|
|
73
|
+
"""
|
|
74
|
+
remote = _get_remote_service()
|
|
75
|
+
if remote is not None:
|
|
76
|
+
return remote.recall(query, scope=scope, max_results=max_results)
|
|
77
|
+
store = _get_memory_store()
|
|
78
|
+
results = store.recall(query, scope=scope, max_results=max_results)
|
|
79
|
+
if not results:
|
|
80
|
+
return "No relevant learnings found for this project."
|
|
81
|
+
|
|
82
|
+
lines = [f"## Project Learnings ({len(results)} relevant)\n"]
|
|
83
|
+
for r in results:
|
|
84
|
+
lines.append(f"- **[{r['type']}]** (confidence: {r['confidence']:.0%}, id: {r['id']})")
|
|
85
|
+
lines.append(f" {r['description']}")
|
|
86
|
+
if r["details"]:
|
|
87
|
+
lines.append(f" _{r['details']}_")
|
|
88
|
+
|
|
89
|
+
# Increment apply_count for returned learnings (best-effort)
|
|
90
|
+
for r in results:
|
|
91
|
+
try:
|
|
92
|
+
store.record_applied(r["id"])
|
|
93
|
+
except Exception:
|
|
94
|
+
logger.debug("Failed to record_applied for learning %d", r["id"])
|
|
95
|
+
|
|
96
|
+
return "\n".join(lines)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@mcp.tool()
|
|
100
|
+
def record_learning(
|
|
101
|
+
type: str, # noqa: A002
|
|
102
|
+
description: str,
|
|
103
|
+
details: str = "",
|
|
104
|
+
scope: str = "",
|
|
105
|
+
confidence: float = 0.7,
|
|
106
|
+
anchor_blob_oid: str = "",
|
|
107
|
+
) -> str:
|
|
108
|
+
"""Record a project learning for future recall.
|
|
109
|
+
|
|
110
|
+
Call this when you discover something important about the codebase:
|
|
111
|
+
patterns, conventions, gotchas, workarounds, or anti-patterns.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
type: One of 'pattern', 'antipattern', 'workaround', 'convention', 'gotcha'.
|
|
115
|
+
description: Short description (1-2 sentences).
|
|
116
|
+
details: Optional longer explanation or example.
|
|
117
|
+
scope: Optional directory scope (e.g. 'src/api/').
|
|
118
|
+
confidence: Initial confidence 0.0-1.0 (default 0.7).
|
|
119
|
+
anchor_blob_oid: Optional ``"git:<sha>"`` / ``"sha256:<hex>"`` anchor
|
|
120
|
+
for the file this learning is about. If left empty and ``scope``
|
|
121
|
+
looks like a concrete file path, it's auto-computed so the
|
|
122
|
+
learning survives renames and ``orphan_scan`` can verify
|
|
123
|
+
reachability via git.
|
|
124
|
+
"""
|
|
125
|
+
remote = _get_remote_service()
|
|
126
|
+
if remote is not None:
|
|
127
|
+
return remote.record_learning(
|
|
128
|
+
type=type, description=description, details=details,
|
|
129
|
+
scope=scope, confidence=confidence,
|
|
130
|
+
)
|
|
131
|
+
store = _get_memory_store()
|
|
132
|
+
|
|
133
|
+
# Auto-compute the anchor when the caller didn't supply one and the
|
|
134
|
+
# scope looks like a file path we can hash.
|
|
135
|
+
resolved_anchor = anchor_blob_oid
|
|
136
|
+
if not resolved_anchor and scope:
|
|
137
|
+
import os as _os
|
|
138
|
+
project_dir = _get_project_dir()
|
|
139
|
+
candidate = scope if _os.path.isabs(scope) else _os.path.join(project_dir, scope)
|
|
140
|
+
if _os.path.isfile(candidate):
|
|
141
|
+
try:
|
|
142
|
+
from attocode_intel._internal.integrations.context.blob_oid import (
|
|
143
|
+
BlobOidCache,
|
|
144
|
+
compute_blob_oid,
|
|
145
|
+
)
|
|
146
|
+
cache = BlobOidCache(project_dir=project_dir)
|
|
147
|
+
try:
|
|
148
|
+
resolved_anchor = compute_blob_oid(
|
|
149
|
+
candidate, project_dir, cache=cache,
|
|
150
|
+
)
|
|
151
|
+
finally:
|
|
152
|
+
cache.close()
|
|
153
|
+
except Exception as exc: # noqa: BLE001
|
|
154
|
+
logger.debug("record_learning: anchor compute failed: %s", exc)
|
|
155
|
+
resolved_anchor = ""
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
learning_id = store.add(
|
|
159
|
+
type=type, description=description,
|
|
160
|
+
details=details, scope=scope, confidence=confidence,
|
|
161
|
+
anchor_blob_oid=resolved_anchor,
|
|
162
|
+
)
|
|
163
|
+
except ValueError as e:
|
|
164
|
+
return f"Error: {e}"
|
|
165
|
+
suffix = f" (anchor={resolved_anchor[:24]}…)" if resolved_anchor else ""
|
|
166
|
+
return f"Recorded learning #{learning_id}: [{type}] {description}{suffix}"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@mcp.tool()
|
|
170
|
+
def learning_feedback(learning_id: int, helpful: bool) -> str:
|
|
171
|
+
"""Mark a previously recalled learning as helpful or unhelpful.
|
|
172
|
+
|
|
173
|
+
Call this after a recalled learning influenced your work, to improve
|
|
174
|
+
future recall quality. Unhelpful learnings are eventually auto-archived.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
learning_id: The ID from a previous recall result.
|
|
178
|
+
helpful: Whether the learning was actually useful.
|
|
179
|
+
"""
|
|
180
|
+
remote = _get_remote_service()
|
|
181
|
+
if remote is not None:
|
|
182
|
+
return remote.learning_feedback(learning_id, helpful)
|
|
183
|
+
store = _get_memory_store()
|
|
184
|
+
store.record_feedback(learning_id, helpful)
|
|
185
|
+
action = "boosted" if helpful else "reduced"
|
|
186
|
+
return f"Feedback recorded — confidence {action} for learning #{learning_id}."
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@mcp.tool()
|
|
190
|
+
def list_learnings(
|
|
191
|
+
status: str = "active",
|
|
192
|
+
type: str = "", # noqa: A002
|
|
193
|
+
scope: str = "",
|
|
194
|
+
) -> str:
|
|
195
|
+
"""List all stored project learnings.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
status: Filter by status: 'active' or 'archived'.
|
|
199
|
+
type: Optional filter by type (pattern/antipattern/workaround/convention/gotcha).
|
|
200
|
+
scope: Optional filter by directory scope.
|
|
201
|
+
"""
|
|
202
|
+
remote = _get_remote_service()
|
|
203
|
+
if remote is not None:
|
|
204
|
+
return remote.list_learnings(status=status, type=type, scope=scope)
|
|
205
|
+
store = _get_memory_store()
|
|
206
|
+
results = store.list_all(status=status, type=type or None)
|
|
207
|
+
if scope:
|
|
208
|
+
results = [r for r in results if r["scope"].startswith(scope) or r["scope"] == ""]
|
|
209
|
+
if not results:
|
|
210
|
+
return "No learnings found matching the filters."
|
|
211
|
+
|
|
212
|
+
lines = [f"## Learnings ({len(results)} total)\n"]
|
|
213
|
+
lines.append("| ID | Type | Description | Confidence | Applied | Scope |")
|
|
214
|
+
lines.append("|---|---|---|---|---|---|")
|
|
215
|
+
for r in results:
|
|
216
|
+
desc = r["description"][:60] + ("..." if len(r["description"]) > 60 else "")
|
|
217
|
+
lines.append(
|
|
218
|
+
f"| {r['id']} | {r['type']} | {desc} "
|
|
219
|
+
f"| {r['confidence']:.0%} | {r['apply_count']}x "
|
|
220
|
+
f"| {r['scope'] or '(global)'} |"
|
|
221
|
+
)
|
|
222
|
+
return "\n".join(lines)
|