flurryx-code-memory 0.4.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 (53) hide show
  1. code_memory/__init__.py +1 -0
  2. code_memory/claims/__init__.py +32 -0
  3. code_memory/claims/extractor.py +325 -0
  4. code_memory/claims/indexer.py +258 -0
  5. code_memory/claims/resolver.py +186 -0
  6. code_memory/claims/store.py +424 -0
  7. code_memory/cli.py +1192 -0
  8. code_memory/config.py +268 -0
  9. code_memory/embed/__init__.py +224 -0
  10. code_memory/embed/cache.py +204 -0
  11. code_memory/embed/m3.py +174 -0
  12. code_memory/embed/ollama.py +92 -0
  13. code_memory/embed/tei.py +106 -0
  14. code_memory/episodic/__init__.py +3 -0
  15. code_memory/episodic/sqlite_store.py +278 -0
  16. code_memory/extractor/__init__.py +3 -0
  17. code_memory/extractor/csproj.py +166 -0
  18. code_memory/extractor/dll.py +385 -0
  19. code_memory/extractor/gitignore.py +162 -0
  20. code_memory/extractor/nuget.py +275 -0
  21. code_memory/extractor/sanity.py +124 -0
  22. code_memory/extractor/sln.py +108 -0
  23. code_memory/extractor/treesitter.py +1172 -0
  24. code_memory/graph/__init__.py +3 -0
  25. code_memory/graph/falkor_store.py +740 -0
  26. code_memory/mcp_server.py +1816 -0
  27. code_memory/metrics.py +260 -0
  28. code_memory/orchestrator/__init__.py +13 -0
  29. code_memory/orchestrator/git_delta.py +211 -0
  30. code_memory/orchestrator/ingest_state.py +71 -0
  31. code_memory/orchestrator/pipeline.py +1478 -0
  32. code_memory/orchestrator/reset.py +130 -0
  33. code_memory/orchestrator/resolver.py +825 -0
  34. code_memory/orchestrator/retrieve.py +505 -0
  35. code_memory/resilience.py +73 -0
  36. code_memory/sync/__init__.py +20 -0
  37. code_memory/sync/autostart/__init__.py +42 -0
  38. code_memory/sync/autostart/base.py +106 -0
  39. code_memory/sync/autostart/launchd.py +115 -0
  40. code_memory/sync/autostart/schtasks.py +155 -0
  41. code_memory/sync/autostart/systemd.py +113 -0
  42. code_memory/sync/hooks.py +164 -0
  43. code_memory/sync/safety.py +65 -0
  44. code_memory/sync/snapshot.py +461 -0
  45. code_memory/sync/store.py +399 -0
  46. code_memory/sync/sync.py +405 -0
  47. code_memory/sync/watcher.py +320 -0
  48. code_memory/vector/__init__.py +3 -0
  49. code_memory/vector/qdrant_store.py +302 -0
  50. flurryx_code_memory-0.4.0.dist-info/METADATA +26 -0
  51. flurryx_code_memory-0.4.0.dist-info/RECORD +53 -0
  52. flurryx_code_memory-0.4.0.dist-info/WHEEL +4 -0
  53. flurryx_code_memory-0.4.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,106 @@
1
+ """Shared types + platform dispatch for autostart adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import platform
7
+ import shutil
8
+ import sys
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING
12
+
13
+ if TYPE_CHECKING:
14
+ from . import Adapter
15
+
16
+ from ...config import detect_project_slug
17
+
18
+ log = logging.getLogger("codememory.autostart")
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class AutostartStatus:
23
+ installed: bool
24
+ running: bool
25
+ label: str
26
+ unit_path: str | None = None
27
+ note: str | None = None
28
+
29
+
30
+ def get_adapter() -> Adapter:
31
+ """Return the adapter for the current OS."""
32
+ system = platform.system()
33
+ if system == "Darwin":
34
+ from .launchd import LaunchdAdapter
35
+
36
+ return LaunchdAdapter()
37
+ if system == "Linux":
38
+ from .systemd import SystemdUserAdapter
39
+
40
+ return SystemdUserAdapter()
41
+ if system == "Windows":
42
+ from .schtasks import SchtasksAdapter
43
+
44
+ return SchtasksAdapter()
45
+ raise RuntimeError(f"unsupported OS: {system}")
46
+
47
+
48
+ def ensure_autostart(repo: Path, *, project: str | None = None) -> AutostartStatus:
49
+ """Install + start the autostart service for ``repo`` if not already.
50
+
51
+ Idempotent. Safe to call on every MCP server boot.
52
+ """
53
+ from ..safety import UnsafeWatchRootError, assert_safe_watch_root
54
+
55
+ try:
56
+ repo = assert_safe_watch_root(repo)
57
+ except UnsafeWatchRootError as e:
58
+ return AutostartStatus(
59
+ installed=False,
60
+ running=False,
61
+ label="<unsafe-root>",
62
+ note=str(e),
63
+ )
64
+ try:
65
+ adapter = get_adapter()
66
+ except RuntimeError as e:
67
+ return AutostartStatus(
68
+ installed=False,
69
+ running=False,
70
+ label="<unsupported>",
71
+ note=str(e),
72
+ )
73
+
74
+ status = adapter.status(repo)
75
+ if status.installed and status.running:
76
+ return status
77
+ if not status.installed:
78
+ status = adapter.install(repo)
79
+ if status.installed and not status.running:
80
+ status = adapter.start(repo)
81
+ return status
82
+
83
+
84
+ def watcher_command(repo: Path) -> list[str]:
85
+ """Resolve the command line that launches the watcher.
86
+
87
+ Prefer the installed ``code-memory`` script; fall back to ``python -m``
88
+ invocation when the script isn't on PATH (development checkouts).
89
+ """
90
+ exe = shutil.which("code-memory")
91
+ if exe:
92
+ return [exe, "watch", str(repo)]
93
+ return [sys.executable, "-m", "code_memory.cli", "watch", str(repo)]
94
+
95
+
96
+ def repo_label(repo: Path) -> str:
97
+ """Deterministic label / unit name suffix for a repo.
98
+
99
+ Uses the same slug logic as project detection so a repo's autostart
100
+ label matches its project slug — easy to spot in `launchctl list`,
101
+ `systemctl --user list-units`, or Task Scheduler.
102
+ """
103
+ try:
104
+ return detect_project_slug(repo)
105
+ except Exception: # noqa: BLE001
106
+ return repo.name.lower().replace(" ", "-")
@@ -0,0 +1,115 @@
1
+ """macOS launchd LaunchAgent adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import plistlib
7
+ import subprocess
8
+ from pathlib import Path
9
+
10
+ from .base import AutostartStatus, repo_label, watcher_command
11
+
12
+ LABEL_PREFIX = "com.codememory.watch"
13
+
14
+
15
+ class LaunchdAdapter:
16
+ def _label(self, repo: Path) -> str:
17
+ return f"{LABEL_PREFIX}.{repo_label(repo)}"
18
+
19
+ def _plist_path(self, repo: Path) -> Path:
20
+ agents = Path.home() / "Library" / "LaunchAgents"
21
+ return agents / f"{self._label(repo)}.plist"
22
+
23
+ def _logs_dir(self) -> Path:
24
+ return Path.home() / "Library" / "Logs" / "codememory"
25
+
26
+ # ------------------------------------------------------------------
27
+
28
+ def install(self, repo: Path) -> AutostartStatus:
29
+ repo = Path(repo).resolve()
30
+ path = self._plist_path(repo)
31
+ path.parent.mkdir(parents=True, exist_ok=True)
32
+ logs = self._logs_dir()
33
+ logs.mkdir(parents=True, exist_ok=True)
34
+ label = self._label(repo)
35
+
36
+ plist = {
37
+ "Label": label,
38
+ "ProgramArguments": watcher_command(repo),
39
+ "WorkingDirectory": str(repo),
40
+ "RunAtLoad": True,
41
+ "KeepAlive": True,
42
+ "StandardOutPath": str(logs / f"{label}.log"),
43
+ "StandardErrorPath": str(logs / f"{label}.err"),
44
+ "ProcessType": "Background",
45
+ "EnvironmentVariables": {
46
+ "PATH": os.environ.get(
47
+ "PATH",
48
+ "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin",
49
+ ),
50
+ },
51
+ }
52
+ with path.open("wb") as fh:
53
+ plistlib.dump(plist, fh)
54
+ return AutostartStatus(
55
+ installed=True,
56
+ running=False,
57
+ label=label,
58
+ unit_path=str(path),
59
+ )
60
+
61
+ def uninstall(self, repo: Path) -> AutostartStatus:
62
+ label = self._label(repo)
63
+ path = self._plist_path(repo)
64
+ domain = f"gui/{os.getuid()}"
65
+ subprocess.run(
66
+ ["launchctl", "bootout", domain, str(path)],
67
+ capture_output=True,
68
+ check=False,
69
+ )
70
+ path.unlink(missing_ok=True)
71
+ return AutostartStatus(
72
+ installed=False, running=False, label=label, unit_path=str(path)
73
+ )
74
+
75
+ def status(self, repo: Path) -> AutostartStatus:
76
+ label = self._label(repo)
77
+ path = self._plist_path(repo)
78
+ installed = path.is_file()
79
+ running = False
80
+ if installed:
81
+ out = subprocess.run(
82
+ ["launchctl", "list", label],
83
+ capture_output=True,
84
+ text=True,
85
+ check=False,
86
+ )
87
+ running = out.returncode == 0
88
+ return AutostartStatus(
89
+ installed=installed,
90
+ running=running,
91
+ label=label,
92
+ unit_path=str(path),
93
+ )
94
+
95
+ def start(self, repo: Path) -> AutostartStatus:
96
+ label = self._label(repo)
97
+ path = self._plist_path(repo)
98
+ domain = f"gui/{os.getuid()}"
99
+ # bootstrap (may fail if already loaded — that's fine)
100
+ subprocess.run(
101
+ ["launchctl", "bootstrap", domain, str(path)],
102
+ capture_output=True,
103
+ check=False,
104
+ )
105
+ subprocess.run(
106
+ ["launchctl", "enable", f"{domain}/{label}"],
107
+ capture_output=True,
108
+ check=False,
109
+ )
110
+ subprocess.run(
111
+ ["launchctl", "kickstart", "-k", f"{domain}/{label}"],
112
+ capture_output=True,
113
+ check=False,
114
+ )
115
+ return self.status(repo)
@@ -0,0 +1,155 @@
1
+ """Windows Task Scheduler adapter.
2
+
3
+ Registers a per-user logon trigger via ``schtasks`` (no admin required).
4
+ Uses an XML definition so we can configure restart-on-failure semantics
5
+ that the simple ``/Create`` flags don't expose.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import getpass
11
+ import subprocess
12
+ import tempfile
13
+ from pathlib import Path
14
+ from xml.sax.saxutils import escape
15
+
16
+ from .base import AutostartStatus, repo_label, watcher_command
17
+
18
+ TASK_FOLDER = "CodeMemory\\Watch"
19
+
20
+
21
+ class SchtasksAdapter:
22
+ def _task_name(self, repo: Path) -> str:
23
+ return f"{TASK_FOLDER}\\{repo_label(repo)}"
24
+
25
+ def install(self, repo: Path) -> AutostartStatus:
26
+ repo = Path(repo).resolve()
27
+ argv = watcher_command(repo)
28
+ exe = argv[0]
29
+ args = " ".join(_quote_win(a) for a in argv[1:])
30
+ user = getpass.getuser()
31
+ xml = _TASK_XML_TEMPLATE.format(
32
+ description=escape(f"code-memory watcher for {repo}"),
33
+ user=escape(user),
34
+ exe=escape(exe),
35
+ args=escape(args),
36
+ working_dir=escape(str(repo)),
37
+ )
38
+ with tempfile.NamedTemporaryFile(
39
+ "w", suffix=".xml", delete=False, encoding="utf-16"
40
+ ) as fh:
41
+ fh.write(xml)
42
+ xml_path = fh.name
43
+ try:
44
+ res = subprocess.run(
45
+ [
46
+ "schtasks",
47
+ "/Create",
48
+ "/TN",
49
+ self._task_name(repo),
50
+ "/XML",
51
+ xml_path,
52
+ "/F",
53
+ ],
54
+ capture_output=True,
55
+ text=True,
56
+ check=False,
57
+ )
58
+ ok = res.returncode == 0
59
+ finally:
60
+ Path(xml_path).unlink(missing_ok=True)
61
+ return AutostartStatus(
62
+ installed=ok,
63
+ running=False,
64
+ label=self._task_name(repo),
65
+ note=None if ok else res.stderr.strip(),
66
+ )
67
+
68
+ def uninstall(self, repo: Path) -> AutostartStatus:
69
+ name = self._task_name(repo)
70
+ subprocess.run(
71
+ ["schtasks", "/Delete", "/TN", name, "/F"],
72
+ capture_output=True,
73
+ check=False,
74
+ )
75
+ return AutostartStatus(installed=False, running=False, label=name)
76
+
77
+ def status(self, repo: Path) -> AutostartStatus:
78
+ name = self._task_name(repo)
79
+ out = subprocess.run(
80
+ ["schtasks", "/Query", "/TN", name, "/FO", "LIST"],
81
+ capture_output=True,
82
+ text=True,
83
+ check=False,
84
+ )
85
+ if out.returncode != 0:
86
+ return AutostartStatus(installed=False, running=False, label=name)
87
+ running = "Status:" in out.stdout and "Running" in out.stdout
88
+ return AutostartStatus(installed=True, running=running, label=name)
89
+
90
+ def start(self, repo: Path) -> AutostartStatus:
91
+ name = self._task_name(repo)
92
+ subprocess.run(
93
+ ["schtasks", "/Run", "/TN", name],
94
+ capture_output=True,
95
+ check=False,
96
+ )
97
+ return self.status(repo)
98
+
99
+
100
+ def _quote_win(arg: str) -> str:
101
+ if not arg or any(c in arg for c in ' \t"'):
102
+ return '"' + arg.replace('"', '\\"') + '"'
103
+ return arg
104
+
105
+
106
+ _TASK_XML_TEMPLATE = """<?xml version="1.0" encoding="UTF-16"?>
107
+ <Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
108
+ <RegistrationInfo>
109
+ <Description>{description}</Description>
110
+ </RegistrationInfo>
111
+ <Triggers>
112
+ <LogonTrigger>
113
+ <Enabled>true</Enabled>
114
+ <UserId>{user}</UserId>
115
+ </LogonTrigger>
116
+ </Triggers>
117
+ <Principals>
118
+ <Principal id="Author">
119
+ <UserId>{user}</UserId>
120
+ <LogonType>InteractiveToken</LogonType>
121
+ <RunLevel>LeastPrivilege</RunLevel>
122
+ </Principal>
123
+ </Principals>
124
+ <Settings>
125
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
126
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
127
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
128
+ <AllowHardTerminate>true</AllowHardTerminate>
129
+ <StartWhenAvailable>true</StartWhenAvailable>
130
+ <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
131
+ <IdleSettings>
132
+ <StopOnIdleEnd>false</StopOnIdleEnd>
133
+ <RestartOnIdle>false</RestartOnIdle>
134
+ </IdleSettings>
135
+ <AllowStartOnDemand>true</AllowStartOnDemand>
136
+ <Enabled>true</Enabled>
137
+ <Hidden>false</Hidden>
138
+ <RunOnlyIfIdle>false</RunOnlyIfIdle>
139
+ <WakeToRun>false</WakeToRun>
140
+ <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
141
+ <Priority>7</Priority>
142
+ <RestartOnFailure>
143
+ <Interval>PT1M</Interval>
144
+ <Count>999</Count>
145
+ </RestartOnFailure>
146
+ </Settings>
147
+ <Actions Context="Author">
148
+ <Exec>
149
+ <Command>{exe}</Command>
150
+ <Arguments>{args}</Arguments>
151
+ <WorkingDirectory>{working_dir}</WorkingDirectory>
152
+ </Exec>
153
+ </Actions>
154
+ </Task>
155
+ """
@@ -0,0 +1,113 @@
1
+ """Linux systemd --user adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shlex
7
+ import subprocess
8
+ from pathlib import Path
9
+
10
+ from .base import AutostartStatus, repo_label, watcher_command
11
+
12
+ UNIT_PREFIX = "codememory-watch"
13
+
14
+
15
+ class SystemdUserAdapter:
16
+ def _unit(self, repo: Path) -> str:
17
+ return f"{UNIT_PREFIX}-{repo_label(repo)}.service"
18
+
19
+ def _unit_path(self, repo: Path) -> Path:
20
+ base = (
21
+ Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config")))
22
+ / "systemd"
23
+ / "user"
24
+ )
25
+ return base / self._unit(repo)
26
+
27
+ # ------------------------------------------------------------------
28
+
29
+ def install(self, repo: Path) -> AutostartStatus:
30
+ repo = Path(repo).resolve()
31
+ unit_path = self._unit_path(repo)
32
+ unit_path.parent.mkdir(parents=True, exist_ok=True)
33
+ exec_start = " ".join(shlex.quote(arg) for arg in watcher_command(repo))
34
+ unit = f"""[Unit]
35
+ Description=code-memory watcher ({repo.name})
36
+ After=default.target
37
+
38
+ [Service]
39
+ Type=simple
40
+ ExecStart={exec_start}
41
+ WorkingDirectory={repo}
42
+ Restart=on-failure
43
+ RestartSec=5
44
+ Environment=PATH={os.environ.get('PATH', '/usr/local/bin:/usr/bin:/bin')}
45
+
46
+ [Install]
47
+ WantedBy=default.target
48
+ """
49
+ unit_path.write_text(unit)
50
+ subprocess.run(
51
+ ["systemctl", "--user", "daemon-reload"],
52
+ capture_output=True,
53
+ check=False,
54
+ )
55
+ return AutostartStatus(
56
+ installed=True,
57
+ running=False,
58
+ label=self._unit(repo),
59
+ unit_path=str(unit_path),
60
+ )
61
+
62
+ def uninstall(self, repo: Path) -> AutostartStatus:
63
+ unit = self._unit(repo)
64
+ path = self._unit_path(repo)
65
+ subprocess.run(
66
+ ["systemctl", "--user", "disable", "--now", unit],
67
+ capture_output=True,
68
+ check=False,
69
+ )
70
+ path.unlink(missing_ok=True)
71
+ subprocess.run(
72
+ ["systemctl", "--user", "daemon-reload"],
73
+ capture_output=True,
74
+ check=False,
75
+ )
76
+ return AutostartStatus(
77
+ installed=False, running=False, label=unit, unit_path=str(path)
78
+ )
79
+
80
+ def status(self, repo: Path) -> AutostartStatus:
81
+ unit = self._unit(repo)
82
+ path = self._unit_path(repo)
83
+ installed = path.is_file()
84
+ running = False
85
+ if installed:
86
+ out = subprocess.run(
87
+ ["systemctl", "--user", "is-active", unit],
88
+ capture_output=True,
89
+ text=True,
90
+ check=False,
91
+ )
92
+ running = out.stdout.strip() == "active"
93
+ return AutostartStatus(
94
+ installed=installed,
95
+ running=running,
96
+ label=unit,
97
+ unit_path=str(path),
98
+ )
99
+
100
+ def start(self, repo: Path) -> AutostartStatus:
101
+ unit = self._unit(repo)
102
+ subprocess.run(
103
+ ["systemctl", "--user", "enable", "--now", unit],
104
+ capture_output=True,
105
+ check=False,
106
+ )
107
+ # best-effort linger so the service can run without active session
108
+ subprocess.run(
109
+ ["loginctl", "enable-linger", os.getenv("USER", "")],
110
+ capture_output=True,
111
+ check=False,
112
+ )
113
+ return self.status(repo)
@@ -0,0 +1,164 @@
1
+ """Git hooks installer: post-checkout / post-merge / post-rewrite / post-commit.
2
+
3
+ Each hook fires ``code-memory sync`` in the background. Hooks are
4
+ idempotent — the installer detects an existing code-memory block and
5
+ overwrites it without disturbing the rest of the file. Plain ``rm`` of
6
+ the marker block removes our integration cleanly.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import stat
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ HOOKS = ("post-checkout", "post-merge", "post-rewrite", "post-commit", "post-applypatch")
17
+
18
+ MARKER_START = "# >>> code-memory sync >>>"
19
+ MARKER_END = "# <<< code-memory sync <<<"
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class HookInstallResult:
24
+ installed: list[str]
25
+ skipped: list[str]
26
+ hooks_dir: str
27
+
28
+
29
+ def install_hooks(repo: Path, *, trigger_cmd: str | None = None) -> HookInstallResult:
30
+ """Install code-memory hooks into ``repo``.
31
+
32
+ Returns lists of hook names that were (re)written vs skipped.
33
+ """
34
+ repo = Path(repo).resolve()
35
+ hooks_dir = _hooks_dir(repo)
36
+ hooks_dir.mkdir(parents=True, exist_ok=True)
37
+
38
+ trigger = trigger_cmd or _default_trigger()
39
+ installed: list[str] = []
40
+ skipped: list[str] = []
41
+ for hook in HOOKS:
42
+ path = hooks_dir / hook
43
+ block = _block_for(hook, trigger)
44
+ if path.exists():
45
+ current = path.read_text()
46
+ if MARKER_START in current and MARKER_END in current:
47
+ new = _replace_block(current, block)
48
+ if new == current:
49
+ skipped.append(hook)
50
+ continue
51
+ else:
52
+ new = current.rstrip() + "\n\n" + block + "\n"
53
+ else:
54
+ new = "#!/usr/bin/env bash\nset -euo pipefail\n\n" + block + "\n"
55
+ path.write_text(new)
56
+ _chmod_exec(path)
57
+ installed.append(hook)
58
+ return HookInstallResult(
59
+ installed=installed,
60
+ skipped=skipped,
61
+ hooks_dir=str(hooks_dir),
62
+ )
63
+
64
+
65
+ def uninstall_hooks(repo: Path) -> HookInstallResult:
66
+ repo = Path(repo).resolve()
67
+ hooks_dir = _hooks_dir(repo)
68
+ removed: list[str] = []
69
+ skipped: list[str] = []
70
+ for hook in HOOKS:
71
+ path = hooks_dir / hook
72
+ if not path.exists():
73
+ skipped.append(hook)
74
+ continue
75
+ content = path.read_text()
76
+ if MARKER_START not in content:
77
+ skipped.append(hook)
78
+ continue
79
+ stripped = _strip_block(content)
80
+ if stripped.strip() in ("", "#!/usr/bin/env bash", "#!/usr/bin/env bash\nset -euo pipefail"):
81
+ path.unlink()
82
+ else:
83
+ path.write_text(stripped)
84
+ _chmod_exec(path)
85
+ removed.append(hook)
86
+ return HookInstallResult(
87
+ installed=removed,
88
+ skipped=skipped,
89
+ hooks_dir=str(hooks_dir),
90
+ )
91
+
92
+
93
+ def hook_status(repo: Path) -> dict[str, bool]:
94
+ hooks_dir = _hooks_dir(Path(repo).resolve())
95
+ out: dict[str, bool] = {}
96
+ for hook in HOOKS:
97
+ path = hooks_dir / hook
98
+ out[hook] = path.is_file() and MARKER_START in path.read_text(errors="ignore")
99
+ return out
100
+
101
+
102
+ # ---------------------------------------------------------------------------
103
+
104
+
105
+ def _hooks_dir(repo: Path) -> Path:
106
+ """Return the hooks directory honouring ``core.hooksPath`` when set."""
107
+ import subprocess
108
+
109
+ out = subprocess.run(
110
+ ["git", "-C", str(repo), "config", "--get", "core.hooksPath"],
111
+ capture_output=True,
112
+ text=True,
113
+ check=False,
114
+ )
115
+ custom = out.stdout.strip()
116
+ if custom:
117
+ p = Path(custom)
118
+ if not p.is_absolute():
119
+ p = repo / p
120
+ return p
121
+ return repo / ".git" / "hooks"
122
+
123
+
124
+ def _default_trigger() -> str:
125
+ """The command embedded in each hook.
126
+
127
+ Background + disown so git never blocks on the sync. Stderr -> log
128
+ file so failures are observable.
129
+ """
130
+ log_path = "$HOME/.cache/codememory/hook.log"
131
+ return (
132
+ f"mkdir -p \"$(dirname {log_path})\" && "
133
+ f"( code-memory sync . --trigger \"$HOOK\" "
134
+ f">> {log_path} 2>&1 & ) "
135
+ "; disown 2>/dev/null || true"
136
+ )
137
+
138
+
139
+ def _block_for(hook: str, trigger: str) -> str:
140
+ return (
141
+ f"{MARKER_START}\n"
142
+ f"HOOK={hook}\n"
143
+ f"command -v code-memory >/dev/null 2>&1 && {{ {trigger}; }}\n"
144
+ f"{MARKER_END}"
145
+ )
146
+
147
+
148
+ def _replace_block(content: str, block: str) -> str:
149
+ start = content.index(MARKER_START)
150
+ end = content.index(MARKER_END, start) + len(MARKER_END)
151
+ return content[:start] + block + content[end:]
152
+
153
+
154
+ def _strip_block(content: str) -> str:
155
+ start = content.index(MARKER_START)
156
+ end = content.index(MARKER_END, start) + len(MARKER_END)
157
+ return (content[:start].rstrip() + "\n" + content[end:].lstrip()).strip() + "\n"
158
+
159
+
160
+ def _chmod_exec(path: Path) -> None:
161
+ if os.name == "nt":
162
+ return # Git for Windows uses bash; chmod n/a
163
+ mode = path.stat().st_mode
164
+ path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)