synap-git 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. synap_git/__init__.py +5 -0
  2. synap_git/api/__init__.py +5 -0
  3. synap_git/api/app.py +111 -0
  4. synap_git/api/static/index.html +615 -0
  5. synap_git/cli/__init__.py +3 -0
  6. synap_git/cli/__main__.py +4 -0
  7. synap_git/cli/main.py +1174 -0
  8. synap_git/config.py +169 -0
  9. synap_git/diagnostics/__init__.py +0 -0
  10. synap_git/diagnostics/logger.py +82 -0
  11. synap_git/diagnostics/tracing.py +71 -0
  12. synap_git/embeddings/__init__.py +0 -0
  13. synap_git/git/__init__.py +11 -0
  14. synap_git/git/state.py +139 -0
  15. synap_git/indexer/__init__.py +0 -0
  16. synap_git/indexer/daemon.py +143 -0
  17. synap_git/indexer/engine.py +327 -0
  18. synap_git/indexer/scanner.py +463 -0
  19. synap_git/indexer/wiki.py +78 -0
  20. synap_git/mcp/__init__.py +5 -0
  21. synap_git/mcp/server.py +232 -0
  22. synap_git/parser/__init__.py +0 -0
  23. synap_git/parser/registry.py +209 -0
  24. synap_git/provider/anthropic.py +86 -0
  25. synap_git/provider/base.py +44 -0
  26. synap_git/provider/factory.py +101 -0
  27. synap_git/provider/gemini.py +98 -0
  28. synap_git/provider/mock.py +66 -0
  29. synap_git/provider/ollama.py +98 -0
  30. synap_git/provider/openai.py +90 -0
  31. synap_git/py.typed +1 -0
  32. synap_git/retrieval/__init__.py +0 -0
  33. synap_git/retrieval/engine.py +258 -0
  34. synap_git/retrieval/memory.py +123 -0
  35. synap_git/storage/__init__.py +3 -0
  36. synap_git/storage/sqlite.py +575 -0
  37. synap_git/utils/__init__.py +0 -0
  38. synap_git/utils/serialization.py +68 -0
  39. synap_git-0.2.0.dist-info/METADATA +251 -0
  40. synap_git-0.2.0.dist-info/RECORD +43 -0
  41. synap_git-0.2.0.dist-info/WHEEL +4 -0
  42. synap_git-0.2.0.dist-info/entry_points.txt +2 -0
  43. synap_git-0.2.0.dist-info/licenses/LICENSE.md +201 -0
synap_git/config.py ADDED
@@ -0,0 +1,169 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from enum import StrEnum
5
+ from pathlib import Path
6
+ from typing import Self
7
+
8
+ import keyring
9
+ from pydantic import Field, field_validator, model_validator
10
+ from pydantic_settings import BaseSettings, SettingsConfigDict
11
+
12
+
13
+ class RuntimeProfile(StrEnum):
14
+ DEV = "dev"
15
+ TEST = "test"
16
+ PROD = "prod"
17
+
18
+
19
+ class RuntimeMode(StrEnum):
20
+ IDLE = "idle"
21
+ ACTIVE = "active"
22
+
23
+
24
+ class LoggingMode(StrEnum):
25
+ HUMAN = "human"
26
+ JSON = "json"
27
+
28
+
29
+ def _get_config_file_path() -> Path:
30
+ """Get global Synap config file path (XDG-compliant)."""
31
+ if env_path := os.environ.get("SYNAP_CONFIG"):
32
+ return Path(env_path).expanduser()
33
+ return Path.home() / ".config" / "synap" / "config.toml"
34
+
35
+
36
+ class SynapSettings(BaseSettings):
37
+ """Production-grade configuration with secure secret management."""
38
+
39
+ model_config = SettingsConfigDict(
40
+ env_prefix="SYNAP_",
41
+ env_file=str(_get_config_file_path()),
42
+ env_file_encoding="utf-8",
43
+ case_sensitive=False,
44
+ extra="ignore",
45
+ )
46
+
47
+ profile: RuntimeProfile = RuntimeProfile.DEV
48
+ mode: RuntimeMode = RuntimeMode.ACTIVE
49
+
50
+ repository_path: Path = Field(default=Path(), description="Repository root.")
51
+ state_path: Path = Field(default=Path(".synap"), description="Local runtime state.")
52
+ sqlite_path: Path | None = None
53
+ object_path: Path | None = None
54
+ log_path: Path | None = None
55
+
56
+ logging_mode: LoggingMode = LoggingMode.HUMAN
57
+ log_level: str = "INFO"
58
+
59
+ max_file_bytes: int = Field(default=1_000_000, ge=1_024)
60
+
61
+ llm_provider: str | None = Field(
62
+ default=None,
63
+ description="LLM provider (openai, gemini, ollama). Leave empty for Mode A (structural only).",
64
+ )
65
+ llm_model: str | None = Field(default=None, description="LLM model.")
66
+ ollama_url: str = "http://127.0.0.1:11434"
67
+
68
+ mcp_host: str = "127.0.0.1"
69
+ mcp_port: int = 9876
70
+
71
+ daemon_poll_interval_seconds: float = 2.0
72
+
73
+ def _get_fallback_credential(self, key: str) -> str | None:
74
+ try:
75
+ cred_file = Path.home() / ".synap" / "credentials"
76
+ if cred_file.exists():
77
+ for line in cred_file.read_text().splitlines():
78
+ if line.strip().startswith(f"{key}="):
79
+ return line.split("=", 1)[1].strip().strip("\"'")
80
+ except Exception:
81
+ pass
82
+ return None
83
+
84
+ @property
85
+ def openai_api_key(self) -> str | None:
86
+ return (
87
+ keyring.get_password("synap", "openai_api_key")
88
+ or os.environ.get("SYNAP_OPENAI_API_KEY")
89
+ or self._get_fallback_credential("SYNAP_OPENAI_API_KEY")
90
+ )
91
+
92
+ @property
93
+ def gemini_api_key(self) -> str | None:
94
+ return (
95
+ keyring.get_password("synap", "gemini_api_key")
96
+ or os.environ.get("SYNAP_GEMINI_API_KEY")
97
+ or self._get_fallback_credential("SYNAP_GEMINI_API_KEY")
98
+ )
99
+
100
+ @property
101
+ def anthropic_api_key(self) -> str | None:
102
+ return (
103
+ keyring.get_password("synap", "anthropic_api_key")
104
+ or os.environ.get("SYNAP_ANTHROPIC_API_KEY")
105
+ or self._get_fallback_credential("SYNAP_ANTHROPIC_API_KEY")
106
+ )
107
+
108
+ @field_validator("repository_path", "state_path", mode="before")
109
+ @classmethod
110
+ def _expand_path(cls, value: str | Path) -> Path:
111
+ return Path(value).expanduser()
112
+
113
+ @model_validator(mode="after")
114
+ def _derive_paths(self) -> Self:
115
+ repository_path = self.repository_path.resolve()
116
+ state_path = self.state_path
117
+ if not state_path.is_absolute():
118
+ state_path = repository_path / state_path
119
+ self.repository_path = repository_path
120
+ self.state_path = state_path.resolve()
121
+ self.sqlite_path = self._resolve_optional_path(self.sqlite_path, "synap.db")
122
+ self.object_path = self._resolve_optional_path(self.object_path, "objects")
123
+ self.log_path = self._resolve_optional_path(self.log_path, "logs")
124
+
125
+ if self.profile is RuntimeProfile.TEST:
126
+ self.logging_mode = LoggingMode.JSON
127
+ self.log_level = "DEBUG"
128
+ return self
129
+
130
+ def _resolve_optional_path(self, value: Path | None, default_name: str) -> Path:
131
+ path = value or self.state_path / default_name
132
+ if not path.is_absolute():
133
+ path = self.repository_path / path
134
+ return path.resolve()
135
+
136
+ def ensure_directories(self) -> None:
137
+ self.state_path.mkdir(parents=True, exist_ok=True)
138
+ if self.object_path:
139
+ self.object_path.mkdir(parents=True, exist_ok=True)
140
+ if self.log_path:
141
+ self.log_path.mkdir(parents=True, exist_ok=True)
142
+
143
+ def validate_configuration(self) -> list[str]:
144
+ errors = []
145
+
146
+ if self.llm_provider == "openai" and not self.openai_api_key:
147
+ errors.append("Missing OpenAI API key in keyring or SYNAP_OPENAI_API_KEY")
148
+ if self.llm_provider == "gemini" and not self.gemini_api_key:
149
+ errors.append("Missing Gemini API key in keyring or SYNAP_GEMINI_API_KEY")
150
+ if self.llm_provider == "anthropic" and not self.anthropic_api_key:
151
+ errors.append("Missing Anthropic API key in keyring or SYNAP_ANTHROPIC_API_KEY")
152
+
153
+ return errors
154
+
155
+ def test_connectivity(self) -> list[str]:
156
+ errors = []
157
+ from synap_git.provider.factory import get_llm_provider
158
+
159
+ try:
160
+ get_llm_provider(self)
161
+ if self.llm_provider == "ollama":
162
+ import httpx
163
+
164
+ resp = httpx.get(f"{self.ollama_url}/api/tags", timeout=2.0)
165
+ if resp.status_code != 200:
166
+ errors.append(f"Ollama offline: {resp.status_code}")
167
+ except Exception as e:
168
+ errors.append(f"Provider init failed: {e}")
169
+ return errors
File without changes
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import logging
5
+ import sys
6
+ import time
7
+ from collections.abc import Iterator
8
+ from dataclasses import dataclass
9
+ from typing import cast
10
+ from uuid import uuid4
11
+
12
+ import structlog
13
+
14
+ from synap_git.config import LoggingMode, SynapSettings
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class TraceResult:
19
+ correlation_id: str
20
+ latency_ms: float
21
+
22
+
23
+ def configure_logging(settings: SynapSettings) -> None:
24
+ timestamper = structlog.processors.TimeStamper(fmt="iso", utc=True)
25
+ shared_processors: list[structlog.typing.Processor] = [
26
+ structlog.contextvars.merge_contextvars,
27
+ structlog.stdlib.add_log_level,
28
+ timestamper,
29
+ structlog.processors.StackInfoRenderer(),
30
+ structlog.processors.format_exc_info,
31
+ ]
32
+ renderer: structlog.typing.Processor
33
+ if settings.logging_mode is LoggingMode.JSON:
34
+ renderer = structlog.processors.JSONRenderer()
35
+ else:
36
+ renderer = structlog.dev.ConsoleRenderer(colors=False)
37
+
38
+ logging.basicConfig(
39
+ format="%(message)s",
40
+ stream=sys.stderr,
41
+ level=getattr(logging, settings.log_level.upper(), logging.INFO),
42
+ )
43
+ structlog.configure(
44
+ processors=[*shared_processors, renderer],
45
+ wrapper_class=structlog.stdlib.BoundLogger,
46
+ logger_factory=structlog.stdlib.LoggerFactory(),
47
+ cache_logger_on_first_use=True,
48
+ )
49
+
50
+
51
+ def get_logger(subsystem: str) -> structlog.stdlib.BoundLogger:
52
+ logger = structlog.get_logger("synap").bind(subsystem=subsystem)
53
+ return cast(structlog.stdlib.BoundLogger, logger)
54
+
55
+
56
+ @contextlib.contextmanager
57
+ def traced_operation(
58
+ logger: structlog.stdlib.BoundLogger,
59
+ operation: str,
60
+ *,
61
+ object_id: str | None = None,
62
+ correlation_id: str | None = None,
63
+ **fields: object,
64
+ ) -> Iterator[TraceResult]:
65
+ correlation = correlation_id or str(uuid4())
66
+ start = time.perf_counter()
67
+ bound = logger.bind(
68
+ operation=operation,
69
+ object_id=object_id,
70
+ correlation_id=correlation,
71
+ **fields,
72
+ )
73
+ bound.info("operation_started", status="started")
74
+ try:
75
+ yield TraceResult(correlation_id=correlation, latency_ms=0.0)
76
+ except Exception:
77
+ latency_ms = (time.perf_counter() - start) * 1000
78
+ bound.exception("operation_failed", status="error", latency=latency_ms)
79
+ raise
80
+ else:
81
+ latency_ms = (time.perf_counter() - start) * 1000
82
+ bound.info("operation_finished", status="ok", latency=latency_ms)
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from datetime import UTC, datetime
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+
9
+ class TraceStore:
10
+ """Manages recording and retrieving the latest operational trace."""
11
+
12
+ def __init__(self, repository_path: Path) -> None:
13
+ self.repo_path = repository_path
14
+ self.trace_file = repository_path / ".synap" / "trace_latest.json"
15
+
16
+ def record_trace(
17
+ self,
18
+ trace_type: str,
19
+ summary: str,
20
+ details: dict[str, Any],
21
+ ) -> dict[str, Any]:
22
+ """Save a new trace event as the latest trace."""
23
+ import uuid
24
+
25
+ trace_id = str(uuid.uuid4())
26
+ trace_data = {
27
+ "trace_id": trace_id,
28
+ "timestamp": datetime.now(UTC).isoformat(),
29
+ "type": trace_type,
30
+ "summary": summary,
31
+ "details": details,
32
+ }
33
+
34
+ try:
35
+ self.trace_file.parent.mkdir(parents=True, exist_ok=True)
36
+ self.trace_file.write_text(json.dumps(trace_data, indent=2), encoding="utf-8")
37
+ except Exception:
38
+ # Operational stability: do not crash the app if trace file cannot be written
39
+ pass
40
+
41
+ return trace_data
42
+
43
+ def get_latest(self) -> dict[str, Any]:
44
+ """Retrieve the latest trace event."""
45
+ if not self.trace_file.exists():
46
+ return {
47
+ "trace_id": "none",
48
+ "timestamp": datetime.now(UTC).isoformat(),
49
+ "type": "system",
50
+ "summary": "No traces recorded yet",
51
+ "details": {
52
+ "status": "ready",
53
+ "message": "Tracing system initialized. Waiting for retrieval/indexing activity.",
54
+ },
55
+ }
56
+
57
+ try:
58
+ data = json.loads(self.trace_file.read_text(encoding="utf-8"))
59
+ if isinstance(data, dict):
60
+ import typing
61
+
62
+ return typing.cast(dict[str, Any], data)
63
+ return {"error": "Invalid trace structure"}
64
+ except Exception as e:
65
+ return {
66
+ "trace_id": "error",
67
+ "timestamp": datetime.now(UTC).isoformat(),
68
+ "type": "error",
69
+ "summary": "Failed to read latest trace",
70
+ "details": {"error": str(e)},
71
+ }
File without changes
@@ -0,0 +1,11 @@
1
+ """Git state inspection for context commits."""
2
+
3
+ from synap_git.git.state import (
4
+ GitChange,
5
+ GitChangeKind,
6
+ GitIntegrationError,
7
+ GitRepository,
8
+ GitState,
9
+ )
10
+
11
+ __all__ = ["GitChange", "GitChangeKind", "GitIntegrationError", "GitRepository", "GitState"]
synap_git/git/state.py ADDED
@@ -0,0 +1,139 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from enum import StrEnum
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+
9
+ class GitIntegrationError(RuntimeError):
10
+ """Raised when Git state cannot be inspected."""
11
+
12
+
13
+ class GitChangeKind(StrEnum):
14
+ INITIAL = "initial"
15
+ COMMIT = "commit"
16
+ CHECKOUT = "checkout"
17
+ BRANCH = "branch"
18
+ MERGE = "merge"
19
+ REBASE = "rebase"
20
+ REVERT = "revert"
21
+ UNCHANGED = "unchanged"
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class GitState:
26
+ repository_path: Path
27
+ is_repository: bool
28
+ head_commit: str | None = None
29
+ branch: str | None = None
30
+ is_dirty: bool = False
31
+ is_detached: bool = False
32
+ merge_in_progress: bool = False
33
+ rebase_in_progress: bool = False
34
+ commit_parent_hashes: tuple[str, ...] = ()
35
+ commit_message: str | None = None
36
+
37
+ @property
38
+ def effective_branch(self) -> str:
39
+ return self.branch or "detached"
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class GitChange:
44
+ kind: GitChangeKind
45
+ previous: GitState | None
46
+ current: GitState
47
+
48
+
49
+ class GitRepository:
50
+ """Thin GitPython-backed reader for repository lineage state."""
51
+
52
+ def __init__(self, path: Path) -> None:
53
+ self.path = path
54
+
55
+ def state(self) -> GitState:
56
+ try:
57
+ from git import InvalidGitRepositoryError, NoSuchPathError, Repo
58
+ except ImportError as exc:
59
+ raise GitIntegrationError("GitPython is not installed") from exc
60
+
61
+ try:
62
+ repo: Any = Repo(self.path, search_parent_directories=True)
63
+ except (InvalidGitRepositoryError, NoSuchPathError):
64
+ return GitState(repository_path=self.path.resolve(), is_repository=False)
65
+
66
+ repository_path = Path(repo.working_tree_dir or self.path).resolve()
67
+ git_dir = Path(repo.git_dir)
68
+ is_detached = bool(repo.head.is_detached)
69
+ branch: str | None
70
+ if is_detached:
71
+ branch = None
72
+ else:
73
+ branch = str(repo.active_branch.name)
74
+
75
+ head_commit: str | None = None
76
+ parent_hashes: tuple[str, ...] = ()
77
+ commit_message: str | None = None
78
+ try:
79
+ commit = repo.head.commit
80
+ head_commit = str(commit.hexsha)
81
+ parent_hashes = tuple(str(parent.hexsha) for parent in commit.parents)
82
+ commit_message = str(commit.message).strip()
83
+ except ValueError:
84
+ pass
85
+
86
+ return GitState(
87
+ repository_path=repository_path,
88
+ is_repository=True,
89
+ head_commit=head_commit,
90
+ branch=branch,
91
+ is_dirty=bool(repo.is_dirty(untracked_files=True)),
92
+ is_detached=is_detached,
93
+ merge_in_progress=(git_dir / "MERGE_HEAD").exists(),
94
+ rebase_in_progress=(git_dir / "rebase-merge").exists()
95
+ or (git_dir / "rebase-apply").exists(),
96
+ commit_parent_hashes=parent_hashes,
97
+ commit_message=commit_message,
98
+ )
99
+
100
+ @staticmethod
101
+ def classify(previous: GitState | None, current: GitState) -> GitChange:
102
+ if previous is None:
103
+ return GitChange(kind=GitChangeKind.INITIAL, previous=None, current=current)
104
+ if current.rebase_in_progress and not previous.rebase_in_progress:
105
+ return GitChange(kind=GitChangeKind.REBASE, previous=previous, current=current)
106
+ if current.merge_in_progress and not previous.merge_in_progress:
107
+ return GitChange(kind=GitChangeKind.MERGE, previous=previous, current=current)
108
+ if previous.branch != current.branch:
109
+ return GitChange(kind=GitChangeKind.BRANCH, previous=previous, current=current)
110
+ if previous.head_commit != current.head_commit:
111
+ message = current.commit_message or ""
112
+ if message.startswith("Revert "):
113
+ return GitChange(kind=GitChangeKind.REVERT, previous=previous, current=current)
114
+
115
+ try:
116
+ from git import Repo
117
+
118
+ repo = Repo(current.repository_path)
119
+ curr_commit = repo.head.commit
120
+ curr_tree = curr_commit.tree.hexsha
121
+ is_revert = False
122
+ for parent in curr_commit.parents:
123
+ for ancestor in repo.iter_commits(parent, max_count=20):
124
+ if ancestor.tree.hexsha == curr_tree:
125
+ is_revert = True
126
+ break
127
+ if is_revert:
128
+ break
129
+ if is_revert:
130
+ return GitChange(kind=GitChangeKind.REVERT, previous=previous, current=current)
131
+ except Exception:
132
+ pass
133
+
134
+ if len(current.commit_parent_hashes) > 1:
135
+ return GitChange(kind=GitChangeKind.MERGE, previous=previous, current=current)
136
+ return GitChange(kind=GitChangeKind.COMMIT, previous=previous, current=current)
137
+ if previous.is_detached != current.is_detached:
138
+ return GitChange(kind=GitChangeKind.CHECKOUT, previous=previous, current=current)
139
+ return GitChange(kind=GitChangeKind.UNCHANGED, previous=previous, current=current)
File without changes
@@ -0,0 +1,143 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import signal
5
+ import time
6
+ from dataclasses import dataclass
7
+
8
+ from synap_git.config import SynapSettings
9
+ from synap_git.diagnostics.logger import get_logger
10
+ from synap_git.git import GitRepository, GitState
11
+ from synap_git.indexer.engine import SynapRuntime
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class DaemonHealth:
16
+ running: bool
17
+ active_commit: str | None
18
+ branch: str
19
+
20
+
21
+ class RuntimeDaemon:
22
+ """Simplified long-running local daemon with Git-aware indexing."""
23
+
24
+ def __init__(self, settings: SynapSettings) -> None:
25
+ self.settings = settings
26
+ self.runtime = SynapRuntime(settings)
27
+ self.git = GitRepository(settings.repository_path)
28
+ self.logger = get_logger("daemon")
29
+ self._stop_event = asyncio.Event()
30
+ self._last_git_state: GitState | None = None
31
+ self._running = False
32
+
33
+ async def start(self) -> None:
34
+ self.runtime.initialize_storage()
35
+ self._uptime_start = time.time()
36
+ self._recovery_attempts = 0
37
+
38
+ # Initial bootstrap
39
+ await asyncio.to_thread(self.runtime.bootstrap)
40
+
41
+ self._running = True
42
+ self._write_heartbeat(status="healthy")
43
+ self._install_signal_handlers()
44
+ try:
45
+ await self._poll_git_loop()
46
+ finally:
47
+ self._running = False
48
+ self._delete_heartbeat()
49
+
50
+ def stop(self) -> None:
51
+ self._stop_event.set()
52
+
53
+ def health(self) -> DaemonHealth:
54
+ status = self.runtime.status()
55
+ return DaemonHealth(
56
+ running=self._running,
57
+ active_commit=status.active_commit,
58
+ branch=status.branch,
59
+ )
60
+
61
+ def _write_heartbeat(self, status: str = "healthy", last_error: str | None = None) -> None:
62
+ import json
63
+ import os
64
+ import time as time_lib
65
+ from datetime import UTC, datetime
66
+
67
+ heartbeat_file = self.settings.repository_path / ".synap" / "daemon_heartbeat.json"
68
+ heartbeat_file.parent.mkdir(parents=True, exist_ok=True)
69
+
70
+ uptime = int(time_lib.time() - self._uptime_start) if hasattr(self, "_uptime_start") else 0
71
+
72
+ data = {
73
+ "pid": os.getpid(),
74
+ "timestamp": datetime.now(UTC).isoformat(),
75
+ "status": status,
76
+ "uptime_seconds": uptime,
77
+ "recovery_attempts": getattr(self, "_recovery_attempts", 0),
78
+ "last_error": last_error,
79
+ "branch": self._last_git_state.effective_branch if self._last_git_state else "unknown",
80
+ "active_commit": self._last_git_state.head_commit
81
+ if self._last_git_state
82
+ else "unknown",
83
+ }
84
+ try:
85
+ heartbeat_file.write_text(json.dumps(data, indent=2), encoding="utf-8")
86
+ except Exception:
87
+ pass
88
+
89
+ def _delete_heartbeat(self) -> None:
90
+ heartbeat_file = self.settings.repository_path / ".synap" / "daemon_heartbeat.json"
91
+ if heartbeat_file.exists():
92
+ try:
93
+ heartbeat_file.unlink()
94
+ except Exception:
95
+ pass
96
+
97
+ async def _poll_git_loop(self) -> None:
98
+ while not self._stop_event.is_set():
99
+ try:
100
+ state = self.git.state()
101
+ change = self.git.classify(self._last_git_state, state)
102
+ self._last_git_state = state
103
+
104
+ if change.kind == "commit":
105
+ self.logger.info("git_commit_detected")
106
+ await asyncio.to_thread(self.runtime.handle_commit, state)
107
+ elif change.kind in ("branch", "checkout"):
108
+ self.logger.info("git_branch_switch_detected")
109
+ await asyncio.to_thread(self.runtime.handle_branch_switch, state)
110
+ elif change.kind == "merge":
111
+ self.logger.info("git_merge_detected")
112
+ await asyncio.to_thread(self.runtime.handle_merge, state)
113
+ elif change.kind == "revert":
114
+ self.logger.info("git_revert_detected")
115
+ await asyncio.to_thread(self.runtime.handle_revert, self._last_git_state, state)
116
+ elif change.kind not in ("initial", "unchanged"):
117
+ self.logger.info("git_change_detected", kind=change.kind)
118
+ await asyncio.to_thread(self.runtime.index_repository, git_state=state)
119
+
120
+ self._write_heartbeat(status="healthy")
121
+ except Exception as e:
122
+ self.logger.error("daemon_loop_error", error=str(e))
123
+ self._recovery_attempts += 1
124
+ self._write_heartbeat(status="degraded", last_error=str(e))
125
+
126
+ # Database recovery if corrupted
127
+ try:
128
+ is_wiped = await asyncio.to_thread(self.runtime.store.recover_if_corrupted)
129
+ if is_wiped:
130
+ self.logger.warning("daemon_rebuilding_corrupted_db")
131
+ await asyncio.to_thread(self.runtime.bootstrap, force=True)
132
+ except Exception as ex:
133
+ self.logger.error("daemon_self_healing_failed", error=str(ex))
134
+
135
+ await asyncio.sleep(self.settings.daemon_poll_interval_seconds)
136
+
137
+ def _install_signal_handlers(self) -> None:
138
+ loop = asyncio.get_running_loop()
139
+ for sig in (signal.SIGINT, signal.SIGTERM):
140
+ try:
141
+ loop.add_signal_handler(sig, self.stop)
142
+ except NotImplementedError:
143
+ continue