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,282 @@
|
|
|
1
|
+
"""Embedding provider abstraction for semantic search.
|
|
2
|
+
|
|
3
|
+
Tries providers in order:
|
|
4
|
+
1. Local: sentence-transformers with code-optimized model (no API cost)
|
|
5
|
+
2. Local: sentence-transformers with general model (smaller fallback)
|
|
6
|
+
3. API: OpenAI text-embedding-3-small (if OPENAI_API_KEY set)
|
|
7
|
+
4. None: graceful degradation (returns empty vectors)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
from abc import ABC, abstractmethod
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class EmbeddingProvider(ABC):
|
|
20
|
+
"""Abstract embedding provider."""
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
def embed(self, texts: list[str]) -> list[list[float]]:
|
|
24
|
+
"""Embed a batch of texts. Returns list of vectors."""
|
|
25
|
+
...
|
|
26
|
+
|
|
27
|
+
def embed_query(self, texts: list[str]) -> list[list[float]]:
|
|
28
|
+
"""Embed query texts. Symmetric providers reuse embed(); asymmetric
|
|
29
|
+
providers (e.g. nomic) override with a query-specific prefix."""
|
|
30
|
+
return self.embed(texts)
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def dimension(self) -> int:
|
|
34
|
+
"""Return the embedding dimension."""
|
|
35
|
+
...
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def name(self) -> str:
|
|
40
|
+
"""Provider name for logging."""
|
|
41
|
+
...
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class LocalEmbeddingProvider(EmbeddingProvider):
|
|
45
|
+
"""Local embeddings via sentence-transformers (all-MiniLM-L6-v2)."""
|
|
46
|
+
|
|
47
|
+
def __init__(self) -> None:
|
|
48
|
+
from sentence_transformers import SentenceTransformer # type: ignore[import-untyped]
|
|
49
|
+
self._model = SentenceTransformer("all-MiniLM-L6-v2")
|
|
50
|
+
self._dim = 384
|
|
51
|
+
|
|
52
|
+
def embed(self, texts: list[str]) -> list[list[float]]:
|
|
53
|
+
embeddings = self._model.encode(texts, convert_to_numpy=True)
|
|
54
|
+
return [e.tolist() for e in embeddings]
|
|
55
|
+
|
|
56
|
+
def dimension(self) -> int:
|
|
57
|
+
return self._dim
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def name(self) -> str:
|
|
61
|
+
return "local:all-MiniLM-L6-v2"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class OpenAIEmbeddingProvider(EmbeddingProvider):
|
|
65
|
+
"""OpenAI embeddings via text-embedding-3-small."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, api_key: str | None = None) -> None:
|
|
68
|
+
import openai # type: ignore[import-untyped]
|
|
69
|
+
self._client = openai.OpenAI(api_key=api_key or os.environ.get("OPENAI_API_KEY"))
|
|
70
|
+
self._dim = 1536
|
|
71
|
+
|
|
72
|
+
def embed(self, texts: list[str]) -> list[list[float]]:
|
|
73
|
+
response = self._client.embeddings.create(
|
|
74
|
+
model="text-embedding-3-small",
|
|
75
|
+
input=texts,
|
|
76
|
+
)
|
|
77
|
+
return [item.embedding for item in response.data]
|
|
78
|
+
|
|
79
|
+
def dimension(self) -> int:
|
|
80
|
+
return self._dim
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def name(self) -> str:
|
|
84
|
+
return "openai:text-embedding-3-small"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class NomicEmbeddingProvider(EmbeddingProvider):
|
|
88
|
+
"""Local embeddings via nomic-embed-text (137M params, better code understanding)."""
|
|
89
|
+
|
|
90
|
+
def __init__(self) -> None:
|
|
91
|
+
from sentence_transformers import SentenceTransformer # type: ignore[import-untyped]
|
|
92
|
+
logger.warning(
|
|
93
|
+
"Loading nomic-embed-text with trust_remote_code=True — "
|
|
94
|
+
"this executes code from the model repository"
|
|
95
|
+
)
|
|
96
|
+
self._model = SentenceTransformer(
|
|
97
|
+
"nomic-ai/nomic-embed-text-v1.5", trust_remote_code=True,
|
|
98
|
+
)
|
|
99
|
+
self._dim = 768
|
|
100
|
+
|
|
101
|
+
def embed(self, texts: list[str]) -> list[list[float]]:
|
|
102
|
+
# nomic-embed-text recommends prefixing with task type
|
|
103
|
+
prefixed = [f"search_document: {t}" for t in texts]
|
|
104
|
+
embeddings = self._model.encode(prefixed, convert_to_numpy=True)
|
|
105
|
+
return [e.tolist() for e in embeddings]
|
|
106
|
+
|
|
107
|
+
def embed_query(self, texts: list[str]) -> list[list[float]]:
|
|
108
|
+
# Queries use a different prefix than documents (asymmetric model).
|
|
109
|
+
prefixed = [f"search_query: {t}" for t in texts]
|
|
110
|
+
embeddings = self._model.encode(prefixed, convert_to_numpy=True)
|
|
111
|
+
return [e.tolist() for e in embeddings]
|
|
112
|
+
|
|
113
|
+
def dimension(self) -> int:
|
|
114
|
+
return self._dim
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def name(self) -> str:
|
|
118
|
+
return "local:nomic-embed-text-v1.5"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class CodeEmbeddingProvider(EmbeddingProvider):
|
|
122
|
+
"""Local embeddings via BAAI/bge-base-en-v1.5 (768-dim, ~440MB model).
|
|
123
|
+
|
|
124
|
+
Significantly outperforms all-MiniLM-L6-v2 on code search tasks due to
|
|
125
|
+
larger model capacity and better handling of code tokens (identifiers,
|
|
126
|
+
camelCase, snake_case, function signatures).
|
|
127
|
+
|
|
128
|
+
Note: Switching models requires reindexing (different vector dimensions).
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
def __init__(self) -> None:
|
|
132
|
+
from sentence_transformers import SentenceTransformer # type: ignore[import-untyped]
|
|
133
|
+
self._model = SentenceTransformer("BAAI/bge-base-en-v1.5")
|
|
134
|
+
self._dim = 768
|
|
135
|
+
|
|
136
|
+
def embed(self, texts: list[str]) -> list[list[float]]:
|
|
137
|
+
embeddings = self._model.encode(texts, convert_to_numpy=True)
|
|
138
|
+
return [e.tolist() for e in embeddings]
|
|
139
|
+
|
|
140
|
+
def dimension(self) -> int:
|
|
141
|
+
return self._dim
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def name(self) -> str:
|
|
145
|
+
return "local:bge-base-en-v1.5"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class NullEmbeddingProvider(EmbeddingProvider):
|
|
149
|
+
"""Fallback provider that returns empty vectors."""
|
|
150
|
+
|
|
151
|
+
def embed(self, texts: list[str]) -> list[list[float]]:
|
|
152
|
+
return [[] for _ in texts]
|
|
153
|
+
|
|
154
|
+
def dimension(self) -> int:
|
|
155
|
+
return 0
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def name(self) -> str:
|
|
159
|
+
return "none"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
_provider_cache: dict[str, EmbeddingProvider] = {}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def create_embedding_provider(
|
|
166
|
+
model: str = "",
|
|
167
|
+
) -> EmbeddingProvider:
|
|
168
|
+
"""Create the best available embedding provider.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
model: Preferred model name. Options:
|
|
172
|
+
- "bge" (768-dim, recommended for code search)
|
|
173
|
+
- "all-MiniLM-L6-v2" (384-dim, fast, smaller)
|
|
174
|
+
- "nomic-embed-text" (768-dim, better code understanding)
|
|
175
|
+
- "openai" (API-based, requires OPENAI_API_KEY)
|
|
176
|
+
- "" (auto-detect best available)
|
|
177
|
+
|
|
178
|
+
Auto-detect order: bge (code-optimized) > MiniLM > OpenAI API > null.
|
|
179
|
+
Note: switching models requires reindexing (different vector dims).
|
|
180
|
+
"""
|
|
181
|
+
model = model or os.environ.get("ATTOCODE_EMBEDDING_MODEL", "")
|
|
182
|
+
|
|
183
|
+
if model in _provider_cache:
|
|
184
|
+
return _provider_cache[model]
|
|
185
|
+
|
|
186
|
+
# Explicit BGE request (code-optimized)
|
|
187
|
+
if model == "bge":
|
|
188
|
+
try:
|
|
189
|
+
provider = CodeEmbeddingProvider()
|
|
190
|
+
logger.info("Using embedding provider: %s", provider.name)
|
|
191
|
+
_provider_cache[model] = provider
|
|
192
|
+
return provider
|
|
193
|
+
except ImportError:
|
|
194
|
+
raise ImportError(
|
|
195
|
+
f"Embedding model '{model}' requires sentence-transformers. "
|
|
196
|
+
"Install with: pip install attocode[semantic]"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# Explicit MiniLM request
|
|
200
|
+
if model == "all-MiniLM-L6-v2":
|
|
201
|
+
try:
|
|
202
|
+
provider = LocalEmbeddingProvider()
|
|
203
|
+
logger.info("Using embedding provider: %s", provider.name)
|
|
204
|
+
_provider_cache[model] = provider
|
|
205
|
+
return provider
|
|
206
|
+
except ImportError:
|
|
207
|
+
raise ImportError(
|
|
208
|
+
f"Embedding model '{model}' requires sentence-transformers. "
|
|
209
|
+
"Install with: pip install attocode[semantic]"
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# Explicit nomic request
|
|
213
|
+
if model == "nomic-embed-text":
|
|
214
|
+
try:
|
|
215
|
+
provider = NomicEmbeddingProvider()
|
|
216
|
+
logger.info("Using embedding provider: %s", provider.name)
|
|
217
|
+
_provider_cache[model] = provider
|
|
218
|
+
return provider
|
|
219
|
+
except ImportError:
|
|
220
|
+
raise ImportError(
|
|
221
|
+
f"Embedding model '{model}' requires sentence-transformers and einops. "
|
|
222
|
+
"Install with: pip install attocode[semantic-nomic]"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
# Explicit OpenAI request
|
|
226
|
+
if model == "openai":
|
|
227
|
+
try:
|
|
228
|
+
provider = OpenAIEmbeddingProvider()
|
|
229
|
+
logger.info("Using embedding provider: %s", provider.name)
|
|
230
|
+
_provider_cache[model] = provider
|
|
231
|
+
return provider
|
|
232
|
+
except ImportError:
|
|
233
|
+
raise ImportError(
|
|
234
|
+
"OpenAI embeddings require the openai package. "
|
|
235
|
+
"Install with: pip install attocode[openai]"
|
|
236
|
+
)
|
|
237
|
+
except Exception as e:
|
|
238
|
+
raise RuntimeError(f"OpenAI embedding provider failed: {e}") from e
|
|
239
|
+
|
|
240
|
+
# Auto-detect: code-trained nomic first, then BGE, then MiniLM (no API cost)
|
|
241
|
+
try:
|
|
242
|
+
provider = NomicEmbeddingProvider()
|
|
243
|
+
logger.info("Using code-trained embedding provider: %s", provider.name)
|
|
244
|
+
_provider_cache[model] = provider
|
|
245
|
+
return provider
|
|
246
|
+
except Exception as exc:
|
|
247
|
+
logger.info("nomic unavailable (%s), trying BGE", exc)
|
|
248
|
+
try:
|
|
249
|
+
provider = CodeEmbeddingProvider()
|
|
250
|
+
logger.info("Using code-optimized embedding provider: %s", provider.name)
|
|
251
|
+
_provider_cache[model] = provider
|
|
252
|
+
return provider
|
|
253
|
+
except ImportError:
|
|
254
|
+
logger.debug("sentence-transformers not installed, trying OpenAI")
|
|
255
|
+
except Exception as exc:
|
|
256
|
+
logger.info("BGE model unavailable (%s), falling back to MiniLM", exc)
|
|
257
|
+
try:
|
|
258
|
+
provider = LocalEmbeddingProvider()
|
|
259
|
+
logger.info("Using local embedding provider: %s", provider.name)
|
|
260
|
+
_provider_cache[model] = provider
|
|
261
|
+
return provider
|
|
262
|
+
except ImportError:
|
|
263
|
+
logger.debug("sentence-transformers not installed, trying OpenAI")
|
|
264
|
+
except Exception as exc2:
|
|
265
|
+
logger.warning("Local embedding provider unavailable: %s", exc2)
|
|
266
|
+
logger.debug("Local embedding provider traceback", exc_info=True)
|
|
267
|
+
|
|
268
|
+
# Try OpenAI API
|
|
269
|
+
if os.environ.get("OPENAI_API_KEY"):
|
|
270
|
+
try:
|
|
271
|
+
provider = OpenAIEmbeddingProvider()
|
|
272
|
+
logger.info("Using OpenAI embedding provider: %s", provider.name)
|
|
273
|
+
_provider_cache[model] = provider
|
|
274
|
+
return provider
|
|
275
|
+
except ImportError:
|
|
276
|
+
logger.debug("openai package not installed")
|
|
277
|
+
except Exception:
|
|
278
|
+
logger.debug("OpenAI embedding provider failed", exc_info=True)
|
|
279
|
+
|
|
280
|
+
# Graceful degradation — don't cache so retry picks up newly installed packages
|
|
281
|
+
logger.info("No embedding provider available; semantic search will use keyword fallback")
|
|
282
|
+
return NullEmbeddingProvider()
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
"""Frecency tracker for intelligent file ranking based on access patterns.
|
|
2
|
+
|
|
3
|
+
Frecency combines "frequency" and "recency" to rank files that are both
|
|
4
|
+
often accessed AND recently accessed. This is similar to browser bookmark
|
|
5
|
+
scoring or the fff.nvim frecency algorithm.
|
|
6
|
+
|
|
7
|
+
Algorithm:
|
|
8
|
+
- 10-day half-life (or 3-day for AI mode)
|
|
9
|
+
- 30-day lookback window
|
|
10
|
+
- Score = Σ e^(-ln(2)/10 * days_ago) with diminishing returns
|
|
11
|
+
- Modification bonus: +16 for <2min, +8 for <15min, etc.
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
tracker = FrecencyTracker(".attocode/frecency")
|
|
15
|
+
tracker.track_access("src/main.py")
|
|
16
|
+
score = tracker.get_score("src/main.py")
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import math
|
|
24
|
+
import sqlite3
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
# Decay constants (ln(2)/10 for 10-day half-life)
|
|
33
|
+
_DECAY_CONSTANT: float = 0.069314718 # ln(2)/10
|
|
34
|
+
_SECONDS_PER_DAY: float = 86400.0
|
|
35
|
+
_MAX_HISTORY_DAYS: float = 30.0
|
|
36
|
+
|
|
37
|
+
# AI mode: faster decay since AI sessions are shorter
|
|
38
|
+
_AI_DECAY_CONSTANT: float = 0.231 # ln(2)/3 for 3-day half-life
|
|
39
|
+
_AI_MAX_HISTORY_DAYS: float = 7.0
|
|
40
|
+
|
|
41
|
+
# Modification score thresholds (seconds, points)
|
|
42
|
+
_MODIFICATION_THRESHOLDS: list[tuple[int, int]] = [
|
|
43
|
+
(120, 16), # < 2 minutes: 16 points
|
|
44
|
+
(900, 8), # < 15 minutes: 8 points
|
|
45
|
+
(3600, 4), # < 1 hour: 4 points
|
|
46
|
+
(86400, 2), # < 1 day: 2 points
|
|
47
|
+
(604800, 1), # < 1 week: 1 point
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
# AI mode: compressed thresholds since AI edits happen in rapid bursts
|
|
51
|
+
_AI_MODIFICATION_THRESHOLDS: list[tuple[int, int]] = [
|
|
52
|
+
(30, 16), # < 30 seconds: 16 points
|
|
53
|
+
(300, 8), # < 5 minutes: 8 points
|
|
54
|
+
(900, 4), # < 15 minutes: 4 points
|
|
55
|
+
(3600, 2), # < 1 hour: 2 points
|
|
56
|
+
(14400, 1), # < 4 hours: 1 point
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
# SQLite schema version
|
|
60
|
+
_SCHEMA_VERSION: int = 1
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class FrecencyResult:
|
|
65
|
+
"""Result of a frecency query."""
|
|
66
|
+
score: int
|
|
67
|
+
accesses: int
|
|
68
|
+
last_access: float | None
|
|
69
|
+
is_ai_mode: bool
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class FrecencyTracker:
|
|
73
|
+
"""Tracks file access patterns for intelligent ranking.
|
|
74
|
+
|
|
75
|
+
Uses SQLite to store access timestamps per file path (keyed by path hash).
|
|
76
|
+
Implements exponential decay with a 10-day half-life (or 3-day for AI mode).
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
db_path: str | Path = ".attocode/frecency",
|
|
82
|
+
*,
|
|
83
|
+
ai_mode: bool = False,
|
|
84
|
+
) -> None:
|
|
85
|
+
self._db_path = Path(db_path)
|
|
86
|
+
self._ai_mode = ai_mode
|
|
87
|
+
self._lock = threading.Lock()
|
|
88
|
+
self._conn: sqlite3.Connection | None = None
|
|
89
|
+
self._ensure_db()
|
|
90
|
+
|
|
91
|
+
def _ensure_db(self) -> None:
|
|
92
|
+
"""Ensure the database directory and tables exist."""
|
|
93
|
+
self._db_path.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
db_file = self._db_path / "frecency.db"
|
|
95
|
+
|
|
96
|
+
conn = sqlite3.connect(
|
|
97
|
+
str(db_file),
|
|
98
|
+
timeout=5.0,
|
|
99
|
+
check_same_thread=False,
|
|
100
|
+
)
|
|
101
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
102
|
+
conn.execute("PRAGMA foreign_keys=ON")
|
|
103
|
+
|
|
104
|
+
# Create tables
|
|
105
|
+
conn.execute("""
|
|
106
|
+
CREATE TABLE IF NOT EXISTS frecency_accesses (
|
|
107
|
+
path_hash TEXT PRIMARY KEY,
|
|
108
|
+
timestamps TEXT NOT NULL DEFAULT '[]',
|
|
109
|
+
updated_at REAL NOT NULL DEFAULT 0
|
|
110
|
+
)
|
|
111
|
+
""")
|
|
112
|
+
conn.execute("""
|
|
113
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
114
|
+
key TEXT PRIMARY KEY,
|
|
115
|
+
value TEXT NOT NULL
|
|
116
|
+
)
|
|
117
|
+
""")
|
|
118
|
+
|
|
119
|
+
# Initialize schema version if not set
|
|
120
|
+
cursor = conn.execute(
|
|
121
|
+
"SELECT value FROM meta WHERE key = 'version'",
|
|
122
|
+
)
|
|
123
|
+
row = cursor.fetchone()
|
|
124
|
+
if row is None:
|
|
125
|
+
conn.execute(
|
|
126
|
+
"INSERT INTO meta (key, value) VALUES ('version', ?)",
|
|
127
|
+
(str(_SCHEMA_VERSION),),
|
|
128
|
+
)
|
|
129
|
+
conn.commit()
|
|
130
|
+
|
|
131
|
+
self._conn = conn
|
|
132
|
+
|
|
133
|
+
def close(self) -> None:
|
|
134
|
+
"""Close the database connection."""
|
|
135
|
+
with self._lock:
|
|
136
|
+
if self._conn is not None:
|
|
137
|
+
self._conn.close()
|
|
138
|
+
self._conn = None
|
|
139
|
+
|
|
140
|
+
def __del__(self) -> None:
|
|
141
|
+
try:
|
|
142
|
+
self.close()
|
|
143
|
+
except Exception:
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
def track_access(self, file_path: str | Path) -> None:
|
|
147
|
+
"""Record that a file was accessed.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
file_path: Path to the file that was accessed.
|
|
151
|
+
"""
|
|
152
|
+
path = str(file_path)
|
|
153
|
+
now = time.time()
|
|
154
|
+
|
|
155
|
+
with self._lock:
|
|
156
|
+
if self._conn is None:
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
cursor = self._conn.execute(
|
|
160
|
+
"SELECT timestamps FROM frecency_accesses WHERE path_hash = ?",
|
|
161
|
+
(path,),
|
|
162
|
+
)
|
|
163
|
+
row = cursor.fetchone()
|
|
164
|
+
|
|
165
|
+
if row is None:
|
|
166
|
+
# New entry
|
|
167
|
+
timestamps = [now]
|
|
168
|
+
self._conn.execute(
|
|
169
|
+
"INSERT INTO frecency_accesses (path_hash, timestamps, updated_at) VALUES (?, ?, ?)",
|
|
170
|
+
(path, json.dumps(timestamps), now),
|
|
171
|
+
)
|
|
172
|
+
else:
|
|
173
|
+
# Update existing
|
|
174
|
+
timestamps: list[float] = json.loads(row[0])
|
|
175
|
+
timestamps.append(now)
|
|
176
|
+
|
|
177
|
+
# Prune old timestamps (beyond retention window)
|
|
178
|
+
max_history = _AI_MAX_HISTORY_DAYS if self._ai_mode else _MAX_HISTORY_DAYS
|
|
179
|
+
cutoff = now - (max_history * _SECONDS_PER_DAY)
|
|
180
|
+
timestamps = [ts for ts in timestamps if ts >= cutoff]
|
|
181
|
+
|
|
182
|
+
self._conn.execute(
|
|
183
|
+
"UPDATE frecency_accesses SET timestamps = ?, updated_at = ? WHERE path_hash = ?",
|
|
184
|
+
(json.dumps(timestamps), now, path),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
self._conn.commit()
|
|
188
|
+
|
|
189
|
+
def get_score(
|
|
190
|
+
self,
|
|
191
|
+
file_path: str | Path,
|
|
192
|
+
*,
|
|
193
|
+
modified_time: float | None = None,
|
|
194
|
+
is_modified_git: bool = False,
|
|
195
|
+
ai_mode: bool | None = None,
|
|
196
|
+
) -> FrecencyResult:
|
|
197
|
+
"""Calculate the frecency score for a file.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
file_path: Path to the file.
|
|
201
|
+
modified_time: Unix timestamp of last modification (for git score).
|
|
202
|
+
is_modified_git: Whether the file has uncommitted changes.
|
|
203
|
+
ai_mode: Override AI mode (default: use instance setting).
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
FrecencyResult with score and metadata.
|
|
207
|
+
"""
|
|
208
|
+
path = str(file_path)
|
|
209
|
+
now = time.time()
|
|
210
|
+
|
|
211
|
+
# Determine mode
|
|
212
|
+
use_ai = self._ai_mode if ai_mode is None else ai_mode
|
|
213
|
+
|
|
214
|
+
decay_constant = _AI_DECAY_CONSTANT if use_ai else _DECAY_CONSTANT
|
|
215
|
+
max_history_days = _AI_MAX_HISTORY_DAYS if use_ai else _MAX_HISTORY_DAYS
|
|
216
|
+
cutoff_time = now - (max_history_days * _SECONDS_PER_DAY)
|
|
217
|
+
|
|
218
|
+
with self._lock:
|
|
219
|
+
if self._conn is None:
|
|
220
|
+
return FrecencyResult(score=0, accesses=0, last_access=None, is_ai_mode=use_ai)
|
|
221
|
+
|
|
222
|
+
cursor = self._conn.execute(
|
|
223
|
+
"SELECT timestamps FROM frecency_accesses WHERE path_hash = ?",
|
|
224
|
+
(path,),
|
|
225
|
+
)
|
|
226
|
+
row = cursor.fetchone()
|
|
227
|
+
|
|
228
|
+
if row is None:
|
|
229
|
+
timestamps: list[float] = []
|
|
230
|
+
else:
|
|
231
|
+
timestamps = json.loads(row[0])
|
|
232
|
+
|
|
233
|
+
# Filter to within retention window
|
|
234
|
+
timestamps = [ts for ts in timestamps if ts >= cutoff_time]
|
|
235
|
+
|
|
236
|
+
if not timestamps:
|
|
237
|
+
accesses = 0
|
|
238
|
+
total_frecency = 0.0
|
|
239
|
+
else:
|
|
240
|
+
accesses = len(timestamps)
|
|
241
|
+
total_frecency = 0.0
|
|
242
|
+
|
|
243
|
+
for access_time in timestamps:
|
|
244
|
+
days_ago = (now - access_time) / _SECONDS_PER_DAY
|
|
245
|
+
decay_factor = math.exp(-decay_constant * days_ago)
|
|
246
|
+
total_frecency += decay_factor
|
|
247
|
+
|
|
248
|
+
# Apply diminishing returns normalization
|
|
249
|
+
# if total > 10: return 10 + sqrt(total - 10)
|
|
250
|
+
if total_frecency <= 10.0:
|
|
251
|
+
normalized = total_frecency
|
|
252
|
+
else:
|
|
253
|
+
normalized = 10.0 + math.sqrt(total_frecency - 10.0)
|
|
254
|
+
|
|
255
|
+
frecency_score = round(normalized)
|
|
256
|
+
|
|
257
|
+
# Add modification bonus
|
|
258
|
+
if modified_time is not None and is_modified_git:
|
|
259
|
+
thresholds = _AI_MODIFICATION_THRESHOLDS if use_ai else _MODIFICATION_THRESHOLDS
|
|
260
|
+
duration_since = now - modified_time
|
|
261
|
+
|
|
262
|
+
for threshold_seconds, points in thresholds:
|
|
263
|
+
if duration_since <= threshold_seconds:
|
|
264
|
+
frecency_score += points
|
|
265
|
+
break
|
|
266
|
+
|
|
267
|
+
last_access = timestamps[-1] if timestamps else None
|
|
268
|
+
|
|
269
|
+
return FrecencyResult(
|
|
270
|
+
score=frecency_score,
|
|
271
|
+
accesses=accesses,
|
|
272
|
+
last_access=last_access,
|
|
273
|
+
is_ai_mode=use_ai,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def get_scores_batch(
|
|
277
|
+
self,
|
|
278
|
+
file_paths: list[str | Path],
|
|
279
|
+
*,
|
|
280
|
+
modified_times: dict[str, float] | None = None,
|
|
281
|
+
git_status: dict[str, bool] | None = None,
|
|
282
|
+
ai_mode: bool | None = None,
|
|
283
|
+
) -> dict[str, FrecencyResult]:
|
|
284
|
+
"""Get frecency scores for multiple files efficiently.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
file_paths: List of file paths to score.
|
|
288
|
+
modified_times: Dict mapping path -> modification timestamp.
|
|
289
|
+
git_status: Dict mapping path -> is_modified bool.
|
|
290
|
+
ai_mode: Override AI mode.
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
Dict mapping path -> FrecencyResult.
|
|
294
|
+
"""
|
|
295
|
+
modified_times = modified_times or {}
|
|
296
|
+
git_status = git_status or {}
|
|
297
|
+
|
|
298
|
+
results = {}
|
|
299
|
+
for path in file_paths:
|
|
300
|
+
path_str = str(path)
|
|
301
|
+
results[path_str] = self.get_score(
|
|
302
|
+
path_str,
|
|
303
|
+
modified_time=modified_times.get(path_str),
|
|
304
|
+
is_modified_git=git_status.get(path_str, False),
|
|
305
|
+
ai_mode=ai_mode,
|
|
306
|
+
)
|
|
307
|
+
return results
|
|
308
|
+
|
|
309
|
+
def clear(self, file_path: str | Path | None = None) -> int:
|
|
310
|
+
"""Clear frecency data.
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
file_path: If provided, clear only this file. Otherwise clear all.
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
Number of entries cleared.
|
|
317
|
+
"""
|
|
318
|
+
with self._lock:
|
|
319
|
+
if self._conn is None:
|
|
320
|
+
return 0
|
|
321
|
+
|
|
322
|
+
if file_path is None:
|
|
323
|
+
self._conn.execute("DELETE FROM frecency_accesses")
|
|
324
|
+
self._conn.commit()
|
|
325
|
+
return -1 # Indicates all cleared
|
|
326
|
+
|
|
327
|
+
path = str(file_path)
|
|
328
|
+
cursor = self._conn.execute(
|
|
329
|
+
"DELETE FROM frecency_accesses WHERE path_hash = ?",
|
|
330
|
+
(path,),
|
|
331
|
+
)
|
|
332
|
+
self._conn.commit()
|
|
333
|
+
return cursor.rowcount
|
|
334
|
+
|
|
335
|
+
def get_stats(self) -> dict:
|
|
336
|
+
"""Get frecency statistics."""
|
|
337
|
+
with self._lock:
|
|
338
|
+
if self._conn is None:
|
|
339
|
+
return {"entries": 0, "ai_mode": self._ai_mode}
|
|
340
|
+
|
|
341
|
+
cursor = self._conn.execute(
|
|
342
|
+
"SELECT COUNT(*) FROM frecency_accesses",
|
|
343
|
+
)
|
|
344
|
+
row = cursor.fetchone()
|
|
345
|
+
count = row[0] if row else 0
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
"entries": count,
|
|
349
|
+
"ai_mode": self._ai_mode,
|
|
350
|
+
"db_path": str(self._db_path),
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
def get_leaderboard(
|
|
354
|
+
self,
|
|
355
|
+
top_n: int = 20,
|
|
356
|
+
*,
|
|
357
|
+
ai_mode: bool | None = None,
|
|
358
|
+
) -> list[tuple[str, FrecencyResult]]:
|
|
359
|
+
"""Get the top N files by frecency score.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
top_n: Number of top files to return.
|
|
363
|
+
ai_mode: Override AI mode (default: use instance setting).
|
|
364
|
+
|
|
365
|
+
Returns:
|
|
366
|
+
List of (path, FrecencyResult) tuples sorted by score descending.
|
|
367
|
+
"""
|
|
368
|
+
with self._lock:
|
|
369
|
+
if self._conn is None:
|
|
370
|
+
return []
|
|
371
|
+
cursor = self._conn.execute(
|
|
372
|
+
"SELECT path_hash FROM frecency_accesses",
|
|
373
|
+
)
|
|
374
|
+
all_paths = [row[0] for row in cursor.fetchall()]
|
|
375
|
+
|
|
376
|
+
# Score each path (get_score handles its own locking)
|
|
377
|
+
scored: list[tuple[str, FrecencyResult]] = []
|
|
378
|
+
for path in all_paths:
|
|
379
|
+
result = self.get_score(path, ai_mode=ai_mode)
|
|
380
|
+
if result.score > 0:
|
|
381
|
+
scored.append((path, result))
|
|
382
|
+
|
|
383
|
+
scored.sort(key=lambda x: x[1].score, reverse=True)
|
|
384
|
+
return scored[:top_n]
|
|
385
|
+
|
|
386
|
+
def vacuum(self) -> None:
|
|
387
|
+
"""Compact the database."""
|
|
388
|
+
with self._lock:
|
|
389
|
+
if self._conn is not None:
|
|
390
|
+
self._conn.execute("VACUUM")
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
# Global singleton instance
|
|
394
|
+
_tracker: FrecencyTracker | None = None
|
|
395
|
+
_tracker_lock = threading.Lock()
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def get_tracker(
|
|
399
|
+
db_path: str | Path = ".attocode/frecency",
|
|
400
|
+
*,
|
|
401
|
+
ai_mode: bool = False,
|
|
402
|
+
) -> FrecencyTracker:
|
|
403
|
+
"""Get or create the global FrecencyTracker instance.
|
|
404
|
+
|
|
405
|
+
Args:
|
|
406
|
+
db_path: Path to the frecency database.
|
|
407
|
+
ai_mode: Whether to use AI mode (faster decay).
|
|
408
|
+
|
|
409
|
+
Returns:
|
|
410
|
+
FrecencyTracker singleton.
|
|
411
|
+
"""
|
|
412
|
+
global _tracker
|
|
413
|
+
|
|
414
|
+
with _tracker_lock:
|
|
415
|
+
if _tracker is None:
|
|
416
|
+
_tracker = FrecencyTracker(db_path=db_path, ai_mode=ai_mode)
|
|
417
|
+
return _tracker
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def reset_tracker() -> None:
|
|
421
|
+
"""Reset the global tracker instance."""
|
|
422
|
+
global _tracker
|
|
423
|
+
|
|
424
|
+
with _tracker_lock:
|
|
425
|
+
if _tracker is not None:
|
|
426
|
+
_tracker.close()
|
|
427
|
+
_tracker = None
|