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/server.py ADDED
@@ -0,0 +1,163 @@
1
+ """Code Therapy: the MCP surface (six PM tools) and the token-guarded Streamable HTTP app."""
2
+ from __future__ import annotations
3
+
4
+ import threading
5
+ from typing import Callable
6
+
7
+ import anyio
8
+ import uvicorn
9
+ from mcp.server.mcpserver import MCPServer
10
+ from mcp.server.transport_security import TransportSecuritySettings
11
+ from mcp.types import ToolAnnotations
12
+ from starlette.applications import Starlette
13
+
14
+ from codetherapy import context, deep, notes, search
15
+ from codetherapy.config import Config
16
+ from codetherapy.projects import Project, ProjectIndex
17
+ from codetherapy.sessions import summarize
18
+
19
+ LIST_LIMIT = 15
20
+ READ_ONLY = ToolAnnotations(read_only_hint=True)
21
+ # Each deep lookup is a full `claude -p` run; cap how many the connector runs at once.
22
+ DEEP_SLOTS = threading.BoundedSemaphore(2)
23
+ DEEP_BUSY_MESSAGE = "Another deep lookup is already running; try again in a minute."
24
+ SERVER_NAME = "Code Therapy"
25
+
26
+ INSTRUCTIONS = """\
27
+ You are the project manager for the user's software projects, which they build with Claude Code. \
28
+ The user is usually talking to you by voice, so your answers are spoken aloud: lead with the status, \
29
+ keep it to a few sentences, and avoid code blocks, tables and long lists unless asked.
30
+ - If you aren't sure which project they mean, call list_projects with what they said.
31
+ - Call get_project_brief before answering questions about a project.
32
+ - Use get_recent_activity for "what happened lately" questions.
33
+ - For "where is X / find the file that..." questions, use search_project: it answers in about a second.
34
+ - Use ask_project only for questions the brief and search can't answer, such as how something works in the code. It is slow, so tell the user before you call it.
35
+ - When the user makes a decision or names a to-do, offer to save it with record_note.
36
+ Anything about the user's projects goes through these Code Therapy tools, never your own sandbox, shell, git or web search: these tools read the user's real project folders on their computer. The tools behave the same in voice and text sessions.
37
+ Tools by speed: fast (about 1 second): list_projects, get_project_brief, get_recent_activity, search_project, record_note. Slow (20-90 seconds): ask_project."""
38
+
39
+
40
+ def _resolve(index: ProjectIndex, query: str) -> tuple[Project | None, str]:
41
+ result = index.resolve(query)
42
+ if result.project:
43
+ return result.project, ""
44
+ if result.candidates:
45
+ names = ", ".join(p.name for p in result.candidates)
46
+ return None, f"Did you mean: {names}? Ask again with the exact name."
47
+ return None, f'No project matches "{query}". Call list_projects to see them all.'
48
+
49
+
50
+ def limited(slots: threading.BoundedSemaphore, work: Callable[[], str]) -> str:
51
+ """Run work if a slot is free right now; otherwise say so instead of queueing."""
52
+ if not slots.acquire(blocking=False):
53
+ return DEEP_BUSY_MESSAGE
54
+ try:
55
+ return work()
56
+ finally:
57
+ slots.release()
58
+
59
+
60
+ def _project_line(project: Project) -> str:
61
+ line = f"- {project.name} (last active {project.last_active:%Y-%m-%d})"
62
+ if project.session_files:
63
+ try:
64
+ if title := summarize(project.session_files[0]).title:
65
+ line += f': latest session "{title}"'
66
+ except OSError:
67
+ pass
68
+ if not project.exists:
69
+ line += " [folder missing]"
70
+ return line
71
+
72
+
73
+ def build_server(index: ProjectIndex, claude_command: list[str] | None = None) -> MCPServer:
74
+ server = MCPServer(SERVER_NAME, instructions=INSTRUCTIONS)
75
+
76
+ @server.tool(annotations=READ_ONLY)
77
+ def list_projects(query: str = "") -> str:
78
+ """List the user's Claude Code projects, most recently active first, each with its latest session title.
79
+ Pass query (whatever the user called the project) to narrow the list."""
80
+ projects = index.all()
81
+ if not projects:
82
+ return "No Claude Code projects found."
83
+ if query.strip():
84
+ result = index.resolve(query)
85
+ matches = [result.project] if result.project else list(result.candidates)
86
+ if matches:
87
+ return "\n".join(_project_line(p) for p in matches)
88
+ prefix = f'No project matches "{query}". The most recent are:\n'
89
+ else:
90
+ prefix = f"{len(projects)} projects; the {min(len(projects), LIST_LIMIT)} most recent:\n"
91
+ return prefix + "\n".join(_project_line(p) for p in projects[:LIST_LIMIT])
92
+
93
+ @server.tool(annotations=READ_ONLY)
94
+ def get_project_brief(project: str) -> str:
95
+ """Current state of one project: overview (CLAUDE.md/README), git branch and recent commits,
96
+ recent Claude Code sessions, and open PM notes. Call this before discussing a project."""
97
+ found, message = _resolve(index, project)
98
+ return context.brief(found) if found else message
99
+
100
+ @server.tool(annotations=READ_ONLY)
101
+ def get_recent_activity(project: str, days: int = 7) -> str:
102
+ """What was worked on in a project over the last N days: Claude Code sessions and git commits."""
103
+ found, message = _resolve(index, project)
104
+ return context.activity(found, days) if found else message
105
+
106
+ @server.tool(annotations=READ_ONLY)
107
+ async def ask_project(project: str, question: str) -> str:
108
+ """Slow: 20-90 seconds. BEFORE calling, tell the user you're starting a lookup that can take up to a
109
+ minute or so; don't go quiet. For "where is X / find the file that..." questions, try search_project
110
+ first; it answers in about a second. Otherwise this has Claude Code read the project (read-only) to
111
+ answer a detailed question about its code that get_project_brief can't answer.
112
+ If the result says another lookup is running or the lookup failed, retry once. If it says it needs a
113
+ longer look (timeout), don't retry; suggest asking in Claude Code instead."""
114
+ found, message = _resolve(index, project)
115
+ if found is None:
116
+ return message
117
+ return await anyio.to_thread.run_sync(
118
+ lambda: limited(DEEP_SLOTS, lambda: deep.ask(found.path, question, command=claude_command)))
119
+
120
+ @server.tool(annotations=READ_ONLY)
121
+ def search_project(project: str, query: str) -> str:
122
+ """Fast (about 1 second) literal search of a project's file names and file contents. Use it for
123
+ "where is X / find the file that mentions Y" questions. Case-insensitive plain text, not regex;
124
+ returns file paths and matching lines. Secret files such as .env are never searched."""
125
+ found, message = _resolve(index, project)
126
+ return search.search(found.path, query) if found else message
127
+
128
+ @server.tool(annotations=ToolAnnotations(read_only_hint=False, destructive_hint=False))
129
+ def record_note(project: str, kind: str, text: str) -> str:
130
+ """Save a decision, to-do or note to PM_NOTES.md in the project for the user's next coding session.
131
+ kind is one of: decision, todo, note."""
132
+ found, message = _resolve(index, project)
133
+ if found is None:
134
+ return message
135
+ try:
136
+ line = notes.record(found.path, kind, text)
137
+ except (ValueError, OSError) as error:
138
+ return f"Couldn't save that: {error}"
139
+ return f"Saved to {found.name}/{notes.NOTES_FILE}: {line}"
140
+
141
+ return server
142
+
143
+
144
+ def build_app(server: MCPServer, config: Config) -> Starlette:
145
+ hosts = ["127.0.0.1:*", "localhost:*", "[::1]:*"]
146
+ origins = ["http://127.0.0.1:*", "http://localhost:*", "http://[::1]:*", "https://claude.ai"]
147
+ if config.public_host:
148
+ hosts.append(config.public_host)
149
+ origins.append(f"https://{config.public_host}")
150
+ return server.streamable_http_app(
151
+ streamable_http_path=f"/mcp/{config.token}",
152
+ stateless_http=True,
153
+ transport_security=TransportSecuritySettings(
154
+ enable_dns_rebinding_protection=True, allowed_hosts=hosts, allowed_origins=origins),
155
+ )
156
+
157
+
158
+ def run(config: Config) -> None:
159
+ app = build_app(build_server(ProjectIndex(config.projects_dir)), config)
160
+ print(f"Code Therapy listening on http://127.0.0.1:{config.port}/mcp/<token> "
161
+ f"(public host: {config.public_host or 'none'})", flush=True)
162
+ # access_log off: the secret token is part of every request path.
163
+ uvicorn.run(app, host="127.0.0.1", port=config.port, access_log=False, log_level="info")
@@ -0,0 +1,101 @@
1
+ """Read Claude Code session logs (~/.claude/projects/<folder>/<session-id>.jsonl)."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from functools import lru_cache
8
+ from pathlib import Path
9
+ from typing import Iterator
10
+
11
+ from codetherapy.textutil import clip, one_line
12
+
13
+ PROMPT_LIMIT = 200
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class SessionSummary:
18
+ session_id: str
19
+ title: str
20
+ cwd: str
21
+ started: datetime | None
22
+ ended: datetime | None
23
+ git_branch: str
24
+ prompts: tuple[str, ...]
25
+
26
+
27
+ def iter_records(path: Path) -> Iterator[dict]:
28
+ with path.open(encoding="utf-8", errors="replace") as f:
29
+ for line in f:
30
+ try:
31
+ record = json.loads(line)
32
+ except json.JSONDecodeError:
33
+ continue
34
+ if isinstance(record, dict):
35
+ yield record
36
+
37
+
38
+ def _parse_timestamp(value: object) -> datetime | None:
39
+ if not isinstance(value, str):
40
+ return None
41
+ try:
42
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
43
+ except ValueError:
44
+ return None
45
+
46
+
47
+ def _prompt_text(record: dict) -> str | None:
48
+ """A prompt the user actually typed: not tool results, not subagents, not <command> wrappers."""
49
+ if record.get("type") != "user" or record.get("isSidechain"):
50
+ return None
51
+ message = record.get("message")
52
+ content = message.get("content") if isinstance(message, dict) else None
53
+ if not isinstance(content, str):
54
+ return None
55
+ text = content.strip()
56
+ if not text or text.startswith("<"):
57
+ return None
58
+ return clip(one_line(text), PROMPT_LIMIT)
59
+
60
+
61
+ def read_cwd(path: Path, max_records: int = 500) -> str:
62
+ """The session's working directory, or "" if the log has none or can't be read."""
63
+ try:
64
+ for i, record in enumerate(iter_records(path)):
65
+ if i >= max_records:
66
+ break
67
+ cwd = record.get("cwd")
68
+ if isinstance(cwd, str) and cwd:
69
+ return cwd
70
+ except OSError: # locked, deleted mid-scan, or not a file: one bad log must not break discovery
71
+ pass
72
+ return ""
73
+
74
+
75
+ def summarize(path: Path, max_prompts: int = 3) -> SessionSummary:
76
+ stat = path.stat()
77
+ return _summarize_cached(str(path), stat.st_mtime_ns, stat.st_size, max_prompts)
78
+
79
+
80
+ @lru_cache(maxsize=1024)
81
+ def _summarize_cached(path_str: str, _mtime_ns: int, _size: int, max_prompts: int) -> SessionSummary:
82
+ title = cwd = git_branch = ""
83
+ started: datetime | None = None
84
+ ended: datetime | None = None
85
+ prompts: list[str] = []
86
+ for record in iter_records(Path(path_str)):
87
+ if record.get("type") == "ai-title" and isinstance(record.get("aiTitle"), str):
88
+ title = record["aiTitle"] # later titles supersede earlier ones
89
+ timestamp = _parse_timestamp(record.get("timestamp"))
90
+ if timestamp is not None:
91
+ started = started or timestamp
92
+ ended = timestamp
93
+ if not cwd and isinstance(record.get("cwd"), str):
94
+ cwd = record["cwd"]
95
+ if isinstance(record.get("gitBranch"), str) and record["gitBranch"]:
96
+ git_branch = record["gitBranch"]
97
+ if len(prompts) < max_prompts:
98
+ text = _prompt_text(record)
99
+ if text:
100
+ prompts.append(text)
101
+ return SessionSummary(Path(path_str).stem, title, cwd, started, ended, git_branch, tuple(prompts))
@@ -0,0 +1,11 @@
1
+ """Small string helpers shared by the context, sessions and deep modules."""
2
+
3
+
4
+ def one_line(text: str) -> str:
5
+ return " ".join(text.split())
6
+
7
+
8
+ def clip(text: str, limit: int, marker: str = "…") -> str:
9
+ if len(text) <= limit:
10
+ return text
11
+ return text[: max(0, limit - len(marker))].rstrip() + marker
codetherapy/tunnel.py ADDED
@@ -0,0 +1,134 @@
1
+ """Find and drive ngrok, and turn its log lines into plain-language problems."""
2
+ from __future__ import annotations
3
+
4
+ import collections
5
+ import os
6
+ import queue
7
+ import re
8
+ import shutil
9
+ import subprocess
10
+ import threading
11
+ import time
12
+ from pathlib import Path
13
+
14
+ LEFTOVER = (" (if an old `codetherapy start` crashed, end the leftover ngrok in Task Manager / Activity Monitor, "
15
+ "or `pkill ngrok`)")
16
+ HINTS = {
17
+ "ERR_NGROK_4018": "ngrok has no authtoken. Run `ngrok config add-authtoken <token>` "
18
+ "(token: https://dashboard.ngrok.com/get-started/your-authtoken).",
19
+ "ERR_NGROK_105": "ngrok rejected the authtoken. Copy it again from "
20
+ "https://dashboard.ngrok.com/get-started/your-authtoken and run `ngrok config add-authtoken <token>`.",
21
+ "ERR_NGROK_108": "ngrok is already running somewhere else on your account (the free plan allows one). "
22
+ f"Stop the other ngrok and try again{LEFTOVER}.",
23
+ "ERR_NGROK_334": f"Your ngrok domain is already online in another ngrok session. Stop it and try again{LEFTOVER}.",
24
+ }
25
+ STARTED = re.compile(r'msg="started tunnel".*?\burl=(\S+)')
26
+ TAIL_LINES = 50
27
+
28
+
29
+ class NgrokOutput:
30
+ """What wait_online reads from ngrok: lines go to `pending` until wait_online returns, then only
31
+ into the bounded `tail`. ngrok keeps being drained, so it never blocks on a full pipe."""
32
+
33
+ def __init__(self) -> None:
34
+ self.pending: queue.Queue[str | None] = queue.Queue()
35
+ self.tail: collections.deque[str] = collections.deque(maxlen=TAIL_LINES)
36
+ self.done = threading.Event() # wait_online has returned
37
+ self.eof = threading.Event() # ngrok's output has ended
38
+
39
+
40
+ def recent_output(proc: subprocess.Popen, wait: float = 0.0) -> list[str]:
41
+ """The last ngrok log lines read by wait_online (at most TAIL_LINES). `wait` gives the reader
42
+ that long to reach the end of the output, for when ngrok has just exited."""
43
+ output = getattr(proc, "ngrok_output", None)
44
+ if not output:
45
+ return []
46
+ output.eof.wait(wait)
47
+ return list(output.tail)
48
+
49
+
50
+ def find_ngrok() -> str | None:
51
+ found = shutil.which("ngrok")
52
+ if found:
53
+ return found
54
+ local = os.environ.get("LOCALAPPDATA")
55
+ if local: # winget installs without updating the current shell's PATH
56
+ link = Path(local, "Microsoft", "WinGet", "Links", "ngrok.exe")
57
+ if link.is_file():
58
+ return str(link)
59
+ matches = sorted(Path(local, "Microsoft", "WinGet", "Packages").glob("Ngrok.Ngrok_*/ngrok.exe"))
60
+ if matches:
61
+ return str(matches[-1])
62
+ return None
63
+
64
+
65
+ def ngrok_has_authtoken(ngrok: str) -> bool:
66
+ try:
67
+ result = subprocess.run([ngrok, "config", "check"], capture_output=True, text=True, encoding="utf-8",
68
+ errors="replace", timeout=15)
69
+ except (OSError, subprocess.TimeoutExpired):
70
+ return False
71
+ match = re.search(r"configuration file at (.+)$", (result.stdout + result.stderr).strip(), re.MULTILINE)
72
+ if result.returncode != 0 or not match:
73
+ return False
74
+ try:
75
+ text = Path(match.group(1).strip()).read_text(encoding="utf-8", errors="replace")
76
+ except OSError:
77
+ return False
78
+ return re.search(r"^[ \t]*authtoken:[ \t]*\S+", text, re.MULTILINE) is not None
79
+
80
+
81
+ def ngrok_command(ngrok: str, port: int, host: str) -> list[str]:
82
+ return [ngrok, "http", f"127.0.0.1:{port}", f"--url=https://{host}", "--inspect=false", "--log=stdout"]
83
+
84
+
85
+ def hint_for(line: str) -> str | None:
86
+ for code, hint in HINTS.items():
87
+ if code in line:
88
+ return hint
89
+ if "lvl=eror" in line or "lvl=crit" in line:
90
+ return "ngrok reported a problem: " + line.strip()[:300]
91
+ return None
92
+
93
+
94
+ def wait_online(proc: subprocess.Popen, timeout: float) -> tuple[bool, str]:
95
+ """Read ngrok's stdout until the tunnel starts, an error appears, ngrok exits, or timeout.
96
+
97
+ Reading continues in the background afterwards, into a bounded tail: see recent_output()."""
98
+ output = NgrokOutput()
99
+ proc.ngrok_output = output # type: ignore[attr-defined]
100
+
101
+ def pump() -> None:
102
+ try:
103
+ for line in proc.stdout: # type: ignore[union-attr]
104
+ output.tail.append(line)
105
+ if not output.done.is_set():
106
+ output.pending.put(line)
107
+ except (OSError, ValueError):
108
+ pass
109
+ finally:
110
+ output.eof.set()
111
+ output.pending.put(None)
112
+
113
+ def finish(ok: bool, detail: str) -> tuple[bool, str]:
114
+ output.done.set()
115
+ while True: # drop anything still queued; the tail keeps the recent lines
116
+ try:
117
+ output.pending.get_nowait()
118
+ except queue.Empty:
119
+ return ok, detail
120
+
121
+ threading.Thread(target=pump, daemon=True).start()
122
+ deadline = time.monotonic() + timeout
123
+ while (remaining := deadline - time.monotonic()) > 0:
124
+ try:
125
+ line = output.pending.get(timeout=min(remaining, 0.5))
126
+ except queue.Empty:
127
+ continue
128
+ if line is None:
129
+ return finish(False, "ngrok exited before the tunnel came online.")
130
+ if match := STARTED.search(line):
131
+ return finish(True, match.group(1))
132
+ if hint := hint_for(line):
133
+ return finish(False, hint)
134
+ return finish(False, f"ngrok didn't come online within {int(timeout)}s.")