codetherapy 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.
codetherapy/context.py ADDED
@@ -0,0 +1,119 @@
1
+ """Fast, voice-sized context about a project, built from files, git and session logs (no AI calls)."""
2
+ from __future__ import annotations
3
+
4
+ import subprocess
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+
8
+ from codetherapy import notes
9
+ from codetherapy.projects import Project, file_mtime
10
+ from codetherapy.sessions import SessionSummary, summarize
11
+ from codetherapy.textutil import clip
12
+
13
+ MAX_BRIEF = 6000
14
+ OVERVIEW_CHARS = 1500
15
+ TRIM_MARK = "\n(trimmed)"
16
+ GIT_TIMEOUT_SECONDS = 5.0
17
+
18
+
19
+ def run_git(path: Path, *args: str) -> str | None:
20
+ try:
21
+ result = subprocess.run(["git", "--no-optional-locks", "-C", str(path), *args], capture_output=True, timeout=GIT_TIMEOUT_SECONDS)
22
+ except (OSError, subprocess.TimeoutExpired):
23
+ return None
24
+ if result.returncode != 0:
25
+ return None
26
+ return result.stdout.decode("utf-8", errors="replace").strip()
27
+
28
+
29
+ def overview(path: Path) -> str:
30
+ for name in ("CLAUDE.md", "README.md"):
31
+ file = path / name
32
+ if file.is_file():
33
+ text = file.read_text(encoding="utf-8", errors="replace").strip()
34
+ if text:
35
+ return f"From {name}:\n{clip(text, OVERVIEW_CHARS)}"
36
+ return ""
37
+
38
+
39
+ def git_summary(path: Path, since_days: int | None = None) -> str:
40
+ branch = run_git(path, "rev-parse", "--abbrev-ref", "HEAD")
41
+ if branch is None:
42
+ return ""
43
+ status = run_git(path, "status", "--porcelain") or ""
44
+ changed = sum(1 for line in status.splitlines() if line.strip())
45
+ state = "clean" if changed == 0 else f"{changed} uncommitted file(s)"
46
+ log_args = ["log", "--date=short", "--pretty=format:%h %ad %s"]
47
+ log_args.append(f"--since={since_days} days ago" if since_days else "-10")
48
+ log = run_git(path, *log_args) or "(no commits in range)"
49
+ return f"Branch: {branch} ({state})\nCommits:\n{log}"
50
+
51
+
52
+ def _when(moment: datetime | None) -> str:
53
+ return moment.astimezone().strftime("%Y-%m-%d") if moment else "unknown date"
54
+
55
+
56
+ def _session_line(s: SessionSummary, all_prompts: bool = False) -> str:
57
+ line = f"- {_when(s.ended)}: {s.title or '(untitled session)'}"
58
+ shown = s.prompts if all_prompts else s.prompts[:1]
59
+ if shown:
60
+ line += " — asked: " + " / ".join(f'"{p}"' for p in shown)
61
+ return line
62
+
63
+
64
+ def _summaries(files: tuple[Path, ...] | list[Path]) -> list[SessionSummary]:
65
+ out = []
66
+ for file in files:
67
+ try:
68
+ out.append(summarize(file))
69
+ except OSError:
70
+ continue
71
+ return out
72
+
73
+
74
+ def assemble(header: str, sections: list[tuple[str, str]], limit: int) -> str:
75
+ """Render markdown sections, trimming the longest bodies until the whole fits in limit."""
76
+
77
+ def render(secs: list[tuple[str, str]]) -> str:
78
+ return "\n\n".join([header, *(f"## {title}\n{body}" for title, body in secs)])
79
+
80
+ secs = list(sections)
81
+ for _ in range(2 * len(secs)):
82
+ overflow = len(render(secs)) - limit
83
+ if overflow <= 0:
84
+ break
85
+ i = max(range(len(secs)), key=lambda j: len(secs[j][1]))
86
+ title, body = secs[i]
87
+ keep = max(0, len(body) - overflow - len(TRIM_MARK))
88
+ secs[i] = (title, body[:keep].rstrip() + TRIM_MARK)
89
+ return render(secs)[:limit]
90
+
91
+
92
+ def brief(project: Project) -> str:
93
+ header = f"# {project.name}\nPath: {project.path}\nLast active: {_when(project.last_active)}"
94
+ sections: list[tuple[str, str]] = []
95
+ if not project.exists:
96
+ header += "\nNote: the project folder no longer exists on disk; only Claude Code session history is available."
97
+ else:
98
+ if text := overview(project.path):
99
+ sections.append(("Overview", text))
100
+ if text := git_summary(project.path):
101
+ sections.append(("Git", text))
102
+ sessions = _summaries(project.session_files[:5])
103
+ sections.append(("Recent Claude Code sessions", "\n".join(_session_line(s) for s in sessions) or "(none)"))
104
+ if project.exists and (open_notes := notes.recent(project.path, 15)):
105
+ sections.append(("Open notes (PM_NOTES.md)", "\n".join(open_notes)))
106
+ return assemble(header, sections, MAX_BRIEF)
107
+
108
+
109
+ def activity(project: Project, days: int = 7) -> str:
110
+ days = max(1, min(int(days), 365))
111
+ cutoff = datetime.now().timestamp() - days * 86400
112
+ recent_files = [f for f in project.session_files if file_mtime(f) >= cutoff]
113
+ sessions = _summaries(recent_files[:20])
114
+ header = f"# {project.name}: activity in the last {days} day(s)"
115
+ sections = [("Claude Code sessions",
116
+ "\n".join(_session_line(s, all_prompts=True) for s in sessions) or "(no sessions in this window)")]
117
+ if project.exists and (text := git_summary(project.path, since_days=days)):
118
+ sections.append(("Git", text))
119
+ return assemble(header, sections, MAX_BRIEF)
codetherapy/deep.py ADDED
@@ -0,0 +1,93 @@
1
+ """Deep questions: run Claude Code headless and read-only inside the project folder."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import shutil
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+ from codetherapy import procutil
10
+ from codetherapy.search import HOME_FOLDER_MESSAGE, SECRET_DIRS, SECRET_PATTERNS, is_home_or_above
11
+ from codetherapy.textutil import clip
12
+
13
+ TIMEOUT_SECONDS = 90.0
14
+ MAX_ANSWER = 4000
15
+ TOOLS = "Read,Grep,Glob"
16
+ # Pre-approve reads only inside the project folder ("./" resolves against cwd = the project path).
17
+ # A bare "Read" rule would pre-approve reading any file on the PC.
18
+ ALLOWED_TOOLS = "Read(./**),Grep(./**),Glob(./**)"
19
+ # Belt and braces: credentials and secrets stay unreadable even if a project path sits above them,
20
+ # and the secret files search_project skips are denied inside the project too.
21
+ SECRET_READ_RULES = (*(f"Read(**/{pattern})" for pattern in SECRET_PATTERNS),
22
+ *(f"Read(**/{folder}/**)" for folder in SECRET_DIRS))
23
+ # Home-folder secrets, in case a project path sits above them: Claude Code's own config and history,
24
+ # SSH keys, Windows app data, and the config of Code Therapy (the connector token), ngrok (its authtoken),
25
+ # gh, Docker, kubectl and PyPI. Targeted folders only: ~/.config and ~/Library hold harmless files too.
26
+ HOME_READ_RULES = ("Read(~/.claude/**)", "Read(~/.claude.json)", "Read(~/.ssh/**)", "Read(~/AppData/**)",
27
+ "Read(~/.config/codetherapy/**)", "Read(~/Library/Application Support/codetherapy/**)",
28
+ "Read(~/.config/ngrok/**)", "Read(~/Library/Application Support/ngrok/**)",
29
+ "Read(~/.config/gh/**)", "Read(~/.docker/config.json)", "Read(~/.kube/**)", "Read(~/.pypirc)")
30
+ DISALLOWED_TOOLS = ",".join(("Bash", "Write", "Edit", "NotebookEdit", "WebFetch", "WebSearch", "Task", "Agent",
31
+ *HOME_READ_RULES, *SECRET_READ_RULES))
32
+ # Set by Claude Code in its own terminals; a child `claude` would refuse to start as a nested session.
33
+ NESTED_SESSION_VARS = ("CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT")
34
+ TIMEOUT_MESSAGE = "That needs a longer look than I can do live — try asking it in Claude Code directly."
35
+ FRAMING = (
36
+ "You are the project manager for this codebase, answering a question the owner asked out loud. "
37
+ "Investigate with your read-only tools as needed, then answer in a few plain spoken sentences: "
38
+ "no code blocks, no tables, no long lists.\n\nQuestion: "
39
+ )
40
+
41
+
42
+ def default_command() -> list[str]:
43
+ exe = shutil.which("claude")
44
+ if not exe:
45
+ raise FileNotFoundError("Claude Code CLI ('claude') not found on PATH")
46
+ return [exe]
47
+
48
+
49
+ def build_args() -> list[str]:
50
+ """Flags that make the headless run read-only by construction (not just by denylist)."""
51
+ return ["-p",
52
+ "--no-session-persistence", # a lookup isn't a session: keep it out of history and briefs
53
+ "--tools", TOOLS,
54
+ "--allowedTools", ALLOWED_TOOLS,
55
+ "--disallowedTools", DISALLOWED_TOOLS,
56
+ "--permission-mode", "dontAsk",
57
+ "--strict-mcp-config",
58
+ "--disable-slash-commands",
59
+ "--restricted",
60
+ "--output-format", "text"]
61
+
62
+
63
+ def _run(cmd: list[str], prompt: str, cwd: Path, env: dict[str, str], timeout: float):
64
+ """Run cmd; on timeout kill the whole process tree so inherited pipes close. Returns (code, out, err) or None on timeout."""
65
+ proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
66
+ cwd=cwd, env=env, encoding="utf-8", errors="replace", **procutil.popen_group_kwargs())
67
+ try:
68
+ out, err = proc.communicate(prompt, timeout=timeout)
69
+ except subprocess.TimeoutExpired:
70
+ procutil.kill_tree(proc)
71
+ return None
72
+ return proc.returncode, out, err
73
+
74
+
75
+ def ask(project_path: Path, question: str, command: list[str] | None = None,
76
+ timeout: float = TIMEOUT_SECONDS) -> str:
77
+ if not project_path.is_dir():
78
+ return f"I can't open that project's folder anymore ({project_path})."
79
+ if is_home_or_above(project_path):
80
+ return HOME_FOLDER_MESSAGE
81
+ env = {k: v for k, v in os.environ.items() if k not in NESTED_SESSION_VARS}
82
+ try:
83
+ cmd = [*(command or default_command()), *build_args()]
84
+ result = _run(cmd, FRAMING + question.strip(), project_path, env, timeout)
85
+ except OSError as exc: # includes FileNotFoundError and PermissionError
86
+ return f"The deep lookup isn't available: {exc}"
87
+ if result is None:
88
+ return TIMEOUT_MESSAGE
89
+ code, out, err = result
90
+ if code != 0:
91
+ lines = (err or out or "").strip().splitlines()
92
+ return f"The deep lookup failed: {lines[-1][:300]}" if lines else "The deep lookup failed."
93
+ return clip(out.strip() or "The deep lookup returned no answer.", MAX_ANSWER)
codetherapy/notes.py ADDED
@@ -0,0 +1,58 @@
1
+ """PM_NOTES.md: the one file the server writes, read by the user's next Claude Code session."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+
7
+ from codetherapy.textutil import one_line
8
+
9
+ NOTES_FILE = "PM_NOTES.md"
10
+ HEADER = "# PM Notes\n\nDecisions, to-dos and notes captured by Code Therapy.\n\n"
11
+ LABELS = {"decision": "DECISION", "todo": "TODO", "note": "NOTE"}
12
+ ALIASES = {
13
+ "to do": "todo", "to-do": "todo", "todos": "todo", "action item": "todo",
14
+ "decisions": "decision", "notes": "note",
15
+ }
16
+
17
+
18
+ def normalize_kind(kind: str) -> str:
19
+ key = " ".join(kind.lower().split())
20
+ key = ALIASES.get(key, key)
21
+ if key not in LABELS:
22
+ raise ValueError(f"kind must be one of: decision, todo, note (got {kind!r})")
23
+ return key
24
+
25
+
26
+ def record(project_path: Path, kind: str, text: str, now: datetime | None = None) -> str:
27
+ label = LABELS[normalize_kind(kind)]
28
+ body = one_line(text)
29
+ if not body:
30
+ raise ValueError("note text is empty")
31
+ if not project_path.is_dir():
32
+ raise FileNotFoundError(f"project folder not found: {project_path}")
33
+ stamp = (now or datetime.now()).strftime("%Y-%m-%d %H:%M")
34
+ line = f"- [{stamp}] **{label}:** {body}"
35
+ path = project_path / NOTES_FILE
36
+ is_new = not path.exists() or path.stat().st_size == 0
37
+ needs_newline = not is_new and not _ends_with_newline(path) # hand-edited file without a final newline
38
+ with path.open("a", encoding="utf-8", newline="\n") as f:
39
+ if is_new:
40
+ f.write(HEADER)
41
+ elif needs_newline:
42
+ f.write("\n")
43
+ f.write(line + "\n")
44
+ return line
45
+
46
+
47
+ def _ends_with_newline(path: Path) -> bool:
48
+ with path.open("rb") as f:
49
+ f.seek(-1, 2)
50
+ return f.read(1) == b"\n"
51
+
52
+
53
+ def recent(project_path: Path, n: int = 15) -> list[str]:
54
+ path = project_path / NOTES_FILE
55
+ if not path.is_file():
56
+ return []
57
+ lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
58
+ return [line for line in lines if line.startswith("- [")][-n:]
@@ -0,0 +1,35 @@
1
+ """Start child processes in their own group and kill the whole tree, so inherited pipes close."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import signal
6
+ import subprocess
7
+ import sys
8
+
9
+
10
+ def popen_group_kwargs() -> dict:
11
+ if sys.platform == "win32":
12
+ return {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP}
13
+ return {"start_new_session": True}
14
+
15
+
16
+ def kill_tree(proc: subprocess.Popen) -> None:
17
+ if sys.platform != "win32":
18
+ try: # always: a grandchild can outlive an already-exited leader and hold the pipes
19
+ os.killpg(proc.pid, signal.SIGKILL)
20
+ except OSError:
21
+ pass
22
+ if proc.poll() is None:
23
+ if sys.platform == "win32":
24
+ try:
25
+ subprocess.run(["taskkill", "/PID", str(proc.pid), "/T", "/F"], capture_output=True, timeout=10)
26
+ except (OSError, subprocess.TimeoutExpired):
27
+ pass
28
+ try:
29
+ proc.kill()
30
+ except OSError:
31
+ pass
32
+ try:
33
+ proc.communicate(timeout=5)
34
+ except (subprocess.TimeoutExpired, ValueError, OSError):
35
+ pass
@@ -0,0 +1,131 @@
1
+ """Discover Claude Code projects from session logs and resolve spoken names to them."""
2
+ from __future__ import annotations
3
+
4
+ import difflib
5
+ import os
6
+ import re
7
+ import time
8
+ from dataclasses import dataclass
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from typing import Callable
12
+
13
+ from codetherapy.sessions import read_cwd
14
+
15
+ SCAN_TTL_SECONDS = 60.0
16
+ MAX_CANDIDATES = 5
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Project:
21
+ name: str
22
+ path: Path
23
+ last_active: datetime
24
+ session_files: tuple[Path, ...] # newest first
25
+
26
+ @property
27
+ def exists(self) -> bool:
28
+ return self.path.is_dir()
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Resolution:
33
+ project: Project | None
34
+ candidates: tuple[Project, ...] = ()
35
+
36
+
37
+ def file_mtime(path: Path) -> float:
38
+ try:
39
+ return path.stat().st_mtime
40
+ except OSError:
41
+ return 0.0
42
+
43
+
44
+ def _unique_names(paths: list[Path]) -> dict[Path, str]:
45
+ """Folder basename, or parent/basename when two projects share a basename."""
46
+
47
+ def dupes(names: dict[Path, str]) -> set[str]:
48
+ seen: dict[str, int] = {}
49
+ for n in names.values():
50
+ seen[n.lower()] = seen.get(n.lower(), 0) + 1
51
+ return {n for n, count in seen.items() if count > 1}
52
+
53
+ names = {p: p.name or str(p) for p in paths}
54
+ clashing = dupes(names)
55
+ names = {p: (f"{p.parent.name}/{p.name}" if n.lower() in clashing and p.parent.name else n) for p, n in names.items()}
56
+ clashing = dupes(names)
57
+ return {p: (str(p) if n.lower() in clashing else n) for p, n in names.items()}
58
+
59
+
60
+ def discover(projects_dir: Path) -> list[Project]:
61
+ if not projects_dir.is_dir():
62
+ return []
63
+ grouped: dict[str, tuple[Path, list[Path]]] = {}
64
+ for folder in projects_dir.iterdir():
65
+ if not folder.is_dir():
66
+ continue
67
+ files = sorted(folder.glob("*.jsonl"), key=file_mtime, reverse=True)
68
+ # The folder name encoding is lossy; the real path is the cwd recorded in the log.
69
+ cwd = next((c for c in map(read_cwd, files) if c), "")
70
+ if not cwd:
71
+ continue
72
+ key = os.path.normcase(os.path.normpath(cwd))
73
+ grouped.setdefault(key, (Path(cwd), []))[1].extend(files)
74
+ names = _unique_names([path for path, _ in grouped.values()])
75
+ projects = []
76
+ for path, files in grouped.values():
77
+ files.sort(key=file_mtime, reverse=True)
78
+ last_active = datetime.fromtimestamp(file_mtime(files[0])).astimezone()
79
+ projects.append(Project(names[path], path, last_active, tuple(files)))
80
+ projects.sort(key=lambda p: p.last_active, reverse=True)
81
+ return projects
82
+
83
+
84
+ def _normalize(text: str) -> str:
85
+ return re.sub(r"[^a-z0-9]+", "", text.lower())
86
+
87
+
88
+ def resolve(projects: list[Project], query: str) -> Resolution:
89
+ q = query.strip()
90
+ if not q:
91
+ return Resolution(None, tuple(projects[:MAX_CANDIDATES]))
92
+ nq = _normalize(q)
93
+ checks: list[Callable[[Project], bool]] = [
94
+ lambda p: p.name == q,
95
+ lambda p: p.name.lower() == q.lower(),
96
+ lambda p: bool(nq) and _normalize(p.name) == nq,
97
+ lambda p: bool(nq) and nq in _normalize(p.name),
98
+ ]
99
+ for check in checks:
100
+ hits = [p for p in projects if check(p)]
101
+ if len(hits) == 1:
102
+ return Resolution(hits[0])
103
+ if hits:
104
+ return Resolution(None, tuple(hits[:MAX_CANDIDATES]))
105
+ by_norm = {_normalize(p.name): p for p in projects}
106
+ close = difflib.get_close_matches(nq, list(by_norm), n=MAX_CANDIDATES, cutoff=0.6)
107
+ if len(close) == 1:
108
+ return Resolution(by_norm[close[0]])
109
+ return Resolution(None, tuple(by_norm[c] for c in close))
110
+
111
+
112
+ class ProjectIndex:
113
+ """discover() with a short cache, since tool calls arrive in bursts."""
114
+
115
+ def __init__(self, projects_dir: Path, ttl: float = SCAN_TTL_SECONDS,
116
+ clock: Callable[[], float] = time.monotonic) -> None:
117
+ self._dir = projects_dir
118
+ self._ttl = ttl
119
+ self._clock = clock
120
+ self._cache: list[Project] | None = None
121
+ self._scanned_at = 0.0
122
+
123
+ def all(self) -> list[Project]:
124
+ now = self._clock()
125
+ if self._cache is None or now - self._scanned_at >= self._ttl:
126
+ self._cache = discover(self._dir)
127
+ self._scanned_at = now
128
+ return list(self._cache)
129
+
130
+ def resolve(self, query: str) -> Resolution:
131
+ return resolve(self.all(), query)
codetherapy/search.py ADDED
@@ -0,0 +1,163 @@
1
+ """Quick literal search over a project's file names and text: the fast alternative to a deep lookup."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import subprocess
6
+ import time
7
+ from fnmatch import fnmatch
8
+ from pathlib import Path
9
+
10
+ from codetherapy.textutil import clip
11
+
12
+ GIT_TIMEOUT_SECONDS = 5
13
+ MIN_GIT_SECONDS = 0.5
14
+ MAX_FILE_BYTES = 1024 * 1024
15
+ BINARY_SNIFF_BYTES = 8192
16
+ MAX_LINE = 160
17
+ MAX_OUTPUT = 4000
18
+ SKIP_DIRS = {".git", "node_modules", ".venv", "venv", "__pycache__", "dist", "build", ".next", ".tox",
19
+ ".mypy_cache", ".pytest_cache", "target", ".idea", ".vscode"}
20
+ # Matched case-insensitively against the file name; such files are never listed or read.
21
+ # deep.py turns the same lists into deny rules for ask_project, so the two can't drift apart.
22
+ SECRET_PATTERNS = (".env", ".env.*", ".envrc", ".npmrc", ".netrc", ".pgpass", "*.pem", "*.key", "*.p12",
23
+ "*.pfx", "*.keystore", "*.jks", "id_rsa*", "id_ed25519*", "*credentials*", "*secret*")
24
+ SECRET_DIRS = (".ssh", ".aws", ".gnupg")
25
+ # People often run `claude` from their home folder, which makes ~ a "project"; that's every file they own.
26
+ HOME_FOLDER_MESSAGE = ("That project is your whole home folder, so I won't search it — "
27
+ "open Claude Code in a specific project folder instead.")
28
+
29
+
30
+ def is_home_or_above(path: Path) -> bool:
31
+ """True for the home folder or a folder that contains it (such as /, /Users or a drive root)."""
32
+ try:
33
+ home, target = Path.home().resolve(), path.resolve()
34
+ except (OSError, RuntimeError): # RuntimeError: no home folder can be determined
35
+ return False
36
+ return target == home or target in home.parents
37
+
38
+
39
+ def _is_secret(name: str) -> bool:
40
+ lowered = name.lower()
41
+ return any(fnmatch(lowered, pattern) for pattern in SECRET_PATTERNS)
42
+
43
+
44
+ def _in_secret_dir(rel: str) -> bool:
45
+ return any(part in SECRET_DIRS for part in rel.split("/")[:-1])
46
+
47
+
48
+ def _git_files(project_path: Path, deadline: float) -> list[str] | None:
49
+ timeout = min(GIT_TIMEOUT_SECONDS, max(MIN_GIT_SECONDS, deadline - time.monotonic()))
50
+ try:
51
+ result = subprocess.run(
52
+ ["git", "--no-optional-locks", "-C", str(project_path), "ls-files", "-z",
53
+ "--cached", "--others", "--exclude-standard"],
54
+ capture_output=True, timeout=timeout)
55
+ except (OSError, subprocess.TimeoutExpired):
56
+ return None
57
+ if result.returncode != 0:
58
+ return None
59
+ text = result.stdout.decode("utf-8", errors="replace")
60
+ return [name for name in text.split("\0") if name]
61
+
62
+
63
+ def _walk_files(project_path: Path, deadline: float) -> tuple[list[str], bool]:
64
+ found: list[str] = []
65
+ for folder, dirs, files in os.walk(project_path):
66
+ dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS)
67
+ for name in sorted(files):
68
+ if time.monotonic() >= deadline:
69
+ return found, True
70
+ found.append((Path(folder) / name).relative_to(project_path).as_posix())
71
+ return found, False
72
+
73
+
74
+ def _candidates(project_path: Path, deadline: float) -> tuple[list[str], bool]:
75
+ """Searchable relative paths, and whether gathering ran out of time."""
76
+ files = _git_files(project_path, deadline)
77
+ timed_out = False
78
+ if files is None:
79
+ files, timed_out = _walk_files(project_path, deadline)
80
+ root = project_path.resolve()
81
+ safe = []
82
+ for rel in sorted(set(files)):
83
+ if timed_out or time.monotonic() >= deadline:
84
+ return safe, True
85
+ if _is_secret(rel.rsplit("/", 1)[-1]) or _in_secret_dir(rel):
86
+ continue
87
+ try:
88
+ resolved = (project_path / rel).resolve()
89
+ resolved.relative_to(root) # a symlink pointing outside the project is not ours to read
90
+ except (OSError, ValueError):
91
+ continue
92
+ if _is_secret(resolved.name): # e.g. notes.txt -> .env
93
+ continue
94
+ if resolved.is_file():
95
+ safe.append(rel)
96
+ return safe, timed_out
97
+
98
+
99
+ def _matching_lines(file: Path, needle: str):
100
+ """Yield (lineno, line) for text lines containing needle; nothing for big or binary files."""
101
+ try:
102
+ if file.stat().st_size > MAX_FILE_BYTES:
103
+ return
104
+ with open(file, "rb") as handle:
105
+ if b"\0" in handle.read(BINARY_SNIFF_BYTES):
106
+ return
107
+ text = file.read_text(encoding="utf-8", errors="replace")
108
+ except OSError:
109
+ return
110
+ for lineno, line in enumerate(text.splitlines(), 1):
111
+ if needle in line.lower():
112
+ yield lineno, line
113
+
114
+
115
+ def search(project_path: Path, query: str, *, max_matches: int = 20, time_budget: float = 3.0) -> str:
116
+ needle = query.strip().lower()
117
+ if not needle:
118
+ return "Tell me what to search for."
119
+ if not project_path.is_dir():
120
+ return f"I can't open that project's folder anymore ({project_path})."
121
+ if is_home_or_above(project_path):
122
+ return HOME_FOLDER_MESSAGE
123
+ deadline = time.monotonic() + time_budget
124
+ name = project_path.name
125
+ files, note_stopped = _candidates(project_path, deadline)
126
+ stopped = f"(search stopped after {time_budget:g}s — narrow the search)"
127
+
128
+ lines: list[str] = []
129
+ note = stopped if note_stopped else ""
130
+
131
+ def add(line: str) -> bool:
132
+ """Append a match; False (and set the note) once the cap or the clock says stop."""
133
+ nonlocal note
134
+ if len(lines) >= max_matches:
135
+ note = "(more matches — narrow the search)" # only reached when one more match exists
136
+ return False
137
+ lines.append(line)
138
+ return True
139
+
140
+ def out_of_time() -> bool:
141
+ nonlocal note
142
+ if time.monotonic() >= deadline:
143
+ note = note or stopped
144
+ return bool(note)
145
+
146
+ for rel in files:
147
+ if needle in rel.lower() and (out_of_time() or not add(f"- {rel}")):
148
+ break
149
+ if not note:
150
+ for rel in files:
151
+ if out_of_time():
152
+ break
153
+ for lineno, line in _matching_lines(project_path / rel, needle):
154
+ if not add(f"{rel}:{lineno}: {clip(line.strip(), MAX_LINE)}"):
155
+ break
156
+ if note:
157
+ break
158
+ if not lines and not note:
159
+ return f'No matches for "{query.strip()}" in {name}.'
160
+ body = [f'Matches for "{query.strip()}" in {name}:', *lines]
161
+ if note:
162
+ body.append(note)
163
+ return clip("\n".join(body), MAX_OUTPUT)