codetrail-cli 1.4.1__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 (171) hide show
  1. codetrail/__init__.py +3 -0
  2. codetrail/__main__.py +5 -0
  3. codetrail/agents/__init__.py +4 -0
  4. codetrail/agents/convert.py +92 -0
  5. codetrail/agents/export.py +284 -0
  6. codetrail/agents/instructions.py +42 -0
  7. codetrail/agents/protocol.py +111 -0
  8. codetrail/agents/tools.py +185 -0
  9. codetrail/assistant/__init__.py +161 -0
  10. codetrail/assistant/claude_code.py +330 -0
  11. codetrail/assistant/codex.py +260 -0
  12. codetrail/assistant/environment.py +66 -0
  13. codetrail/assistant/estimate.py +225 -0
  14. codetrail/assistant/fake.py +74 -0
  15. codetrail/assistant/guard.py +80 -0
  16. codetrail/assistant/guard_hook.py +58 -0
  17. codetrail/assistant/local.py +245 -0
  18. codetrail/assistant/local_tools.py +177 -0
  19. codetrail/assistant/prompts.py +404 -0
  20. codetrail/assistant/routing.py +86 -0
  21. codetrail/assistant/runner.py +120 -0
  22. codetrail/assistant/status.py +186 -0
  23. codetrail/assistant/usage.py +93 -0
  24. codetrail/bridge.py +292 -0
  25. codetrail/cli.py +407 -0
  26. codetrail/config.py +485 -0
  27. codetrail/database/__init__.py +60 -0
  28. codetrail/database/migrations/0001_facts.sql +34 -0
  29. codetrail/database/migrations/0002_settings.sql +5 -0
  30. codetrail/database/migrations/0003_learning.sql +34 -0
  31. codetrail/database/migrations/0004_assistant_usage.sql +24 -0
  32. codetrail/database/migrations/0005_page_failures.sql +7 -0
  33. codetrail/database/migrations/0006_metric_values.sql +9 -0
  34. codetrail/errors.py +5 -0
  35. codetrail/extract/__init__.py +249 -0
  36. codetrail/extract/adr.py +98 -0
  37. codetrail/extract/github_actions.py +140 -0
  38. codetrail/extract/openapi.py +101 -0
  39. codetrail/extract/python.py +221 -0
  40. codetrail/extract/swift.py +204 -0
  41. codetrail/extract/terraform.py +166 -0
  42. codetrail/extract/typescript.py +464 -0
  43. codetrail/facts/__init__.py +112 -0
  44. codetrail/facts/store.py +209 -0
  45. codetrail/generate/__init__.py +1 -0
  46. codetrail/generate/failures.py +31 -0
  47. codetrail/generate/outline.py +159 -0
  48. codetrail/generate/revise.py +72 -0
  49. codetrail/generate/run.py +530 -0
  50. codetrail/generate/scope.py +135 -0
  51. codetrail/generate/validate.py +218 -0
  52. codetrail/guide/__init__.py +147 -0
  53. codetrail/learn/__init__.py +143 -0
  54. codetrail/learn/routes.py +139 -0
  55. codetrail/locales/ar/LC_MESSAGES/codetrail.mo +0 -0
  56. codetrail/locales/ar/LC_MESSAGES/codetrail.po +2236 -0
  57. codetrail/locales/codetrail.pot +1942 -0
  58. codetrail/lock.py +114 -0
  59. codetrail/metrics/__init__.py +6 -0
  60. codetrail/metrics/activity.py +96 -0
  61. codetrail/metrics/churn.py +156 -0
  62. codetrail/metrics/commits.py +45 -0
  63. codetrail/metrics/coverage.py +114 -0
  64. codetrail/metrics/decisions.py +77 -0
  65. codetrail/metrics/history.py +47 -0
  66. codetrail/metrics/inventory.py +63 -0
  67. codetrail/metrics/rationale.py +74 -0
  68. codetrail/metrics/releases.py +26 -0
  69. codetrail/metrics/report.py +111 -0
  70. codetrail/metrics/repository.py +128 -0
  71. codetrail/metrics/size.py +95 -0
  72. codetrail/py.typed +0 -0
  73. codetrail/remove.py +50 -0
  74. codetrail/repo/__init__.py +4 -0
  75. codetrail/repo/git.py +56 -0
  76. codetrail/repo/gitleaks.toml +6 -0
  77. codetrail/repo/history.py +262 -0
  78. codetrail/repo/mirror.py +137 -0
  79. codetrail/repo/refresh.py +41 -0
  80. codetrail/repo/rules.py +83 -0
  81. codetrail/repo/secrets.py +145 -0
  82. codetrail/repo/signal.py +65 -0
  83. codetrail/repo/source.py +166 -0
  84. codetrail/search.py +178 -0
  85. codetrail/server.py +45 -0
  86. codetrail/system.py +484 -0
  87. codetrail/update.py +276 -0
  88. codetrail/web/__init__.py +1 -0
  89. codetrail/web/app.py +897 -0
  90. codetrail/web/diagrams.py +414 -0
  91. codetrail/web/documentation.py +146 -0
  92. codetrail/web/i18n.py +44 -0
  93. codetrail/web/navigation.py +127 -0
  94. codetrail/web/render.py +126 -0
  95. codetrail/web/repository_view.py +230 -0
  96. codetrail/web/security.py +171 -0
  97. codetrail/web/static/brand/favicon.svg +1 -0
  98. codetrail/web/static/brand/mark.svg +1 -0
  99. codetrail/web/static/css/base.css +192 -0
  100. codetrail/web/static/css/components.css +2254 -0
  101. codetrail/web/static/css/content.css +310 -0
  102. codetrail/web/static/css/layout.css +410 -0
  103. codetrail/web/static/css/print.css +42 -0
  104. codetrail/web/static/css/tokens.css +228 -0
  105. codetrail/web/static/js/api.js +49 -0
  106. codetrail/web/static/js/ask.js +182 -0
  107. codetrail/web/static/js/d2-worker.js +34 -0
  108. codetrail/web/static/js/d2.js +28 -0
  109. codetrail/web/static/js/diagram.js +52 -0
  110. codetrail/web/static/js/documentation.js +24 -0
  111. codetrail/web/static/js/learning.js +47 -0
  112. codetrail/web/static/js/main.js +38 -0
  113. codetrail/web/static/js/outline.js +44 -0
  114. codetrail/web/static/js/palette.js +211 -0
  115. codetrail/web/static/js/repository.js +528 -0
  116. codetrail/web/static/js/resize.js +126 -0
  117. codetrail/web/static/js/shortcuts.js +87 -0
  118. codetrail/web/static/js/sidebar.js +194 -0
  119. codetrail/web/static/js/theme-init.js +27 -0
  120. codetrail/web/static/js/theme.js +42 -0
  121. codetrail/web/static/js/update.js +107 -0
  122. codetrail/web/static/vendor/d2/LICENSE +375 -0
  123. codetrail/web/static/vendor/d2/LICENSE-elk +263 -0
  124. codetrail/web/static/vendor/d2/LICENSE-wasm_exec +27 -0
  125. codetrail/web/static/vendor/d2/VERSION +12 -0
  126. codetrail/web/static/vendor/d2/d2.wasm +0 -0
  127. codetrail/web/static/vendor/d2/elk.js +105914 -0
  128. codetrail/web/static/vendor/d2/wasm_exec.js +656 -0
  129. codetrail/web/static/vendor/inter/InterVariable-Italic.woff2 +0 -0
  130. codetrail/web/static/vendor/inter/InterVariable.woff2 +0 -0
  131. codetrail/web/static/vendor/inter/LICENSE.txt +92 -0
  132. codetrail/web/static/vendor/inter/VERSION +6 -0
  133. codetrail/web/static/vendor/noto-sans-arabic/NotoSansArabic-wght.woff2 +0 -0
  134. codetrail/web/static/vendor/noto-sans-arabic/OFL.txt +93 -0
  135. codetrail/web/static/vendor/noto-sans-arabic/VERSION +12 -0
  136. codetrail/web/target_view.py +63 -0
  137. codetrail/web/templates/answers.html +25 -0
  138. codetrail/web/templates/area.html +46 -0
  139. codetrail/web/templates/base.html +37 -0
  140. codetrail/web/templates/decisions.html +30 -0
  141. codetrail/web/templates/diagram.html +10 -0
  142. codetrail/web/templates/digests.html +24 -0
  143. codetrail/web/templates/documentation.html +247 -0
  144. codetrail/web/templates/error.html +9 -0
  145. codetrail/web/templates/estimate.html +48 -0
  146. codetrail/web/templates/fact.html +29 -0
  147. codetrail/web/templates/home.html +196 -0
  148. codetrail/web/templates/layout/ask_panel.html +27 -0
  149. codetrail/web/templates/layout/header.html +32 -0
  150. codetrail/web/templates/layout/icons.html +27 -0
  151. codetrail/web/templates/layout/outline.html +4 -0
  152. codetrail/web/templates/layout/palette.html +27 -0
  153. codetrail/web/templates/layout/shortcuts.html +27 -0
  154. codetrail/web/templates/layout/sidebar.html +79 -0
  155. codetrail/web/templates/layout/update_dialog.html +18 -0
  156. codetrail/web/templates/macros.html +13 -0
  157. codetrail/web/templates/page.html +93 -0
  158. codetrail/web/templates/progress.html +46 -0
  159. codetrail/web/templates/repository.html +369 -0
  160. codetrail/web/templates/search.html +31 -0
  161. codetrail/web/templates/source.html +15 -0
  162. codetrail/web/templates/system.html +43 -0
  163. codetrail/web/templates/system_arrows.html +17 -0
  164. codetrail/web/templates/system_legend.html +9 -0
  165. codetrail/web/templates/update_log.html +30 -0
  166. codetrail/web/trend.py +22 -0
  167. codetrail_cli-1.4.1.dist-info/METADATA +372 -0
  168. codetrail_cli-1.4.1.dist-info/RECORD +171 -0
  169. codetrail_cli-1.4.1.dist-info/WHEEL +4 -0
  170. codetrail_cli-1.4.1.dist-info/entry_points.txt +3 -0
  171. codetrail_cli-1.4.1.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,80 @@
1
+ """The tool guard: the assistant may only read `source/`, with Read, Grep and Glob (design section 6.9).
2
+
3
+ It runs as a PreToolUse hook, which sees every tool call, read-only ones included, before it runs. Anything it can't
4
+ place inside `source/` is refused; every file Read opens is recorded, and becomes the page's `files`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Mapping
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ ALLOWED_TOOLS = ("Read", "Grep", "Glob")
14
+ # The channel structured answers come back through; it reads nothing and takes no path.
15
+ ANSWER_TOOL = "StructuredOutput"
16
+
17
+
18
+ class ToolGuard:
19
+ def __init__(self, source_root: Path) -> None:
20
+ self.root = source_root.resolve()
21
+ self.files_read: list[str] = []
22
+
23
+ def decide(self, tool: str, arguments: Mapping[str, Any]) -> str | None:
24
+ """None to allow the call, or the reason it is refused; any error refuses (fails closed)."""
25
+ try:
26
+ return self._decide(tool, arguments)
27
+ except Exception:
28
+ return "The request couldn't be checked, so it is refused."
29
+
30
+ def _decide(self, tool: str, arguments: Mapping[str, Any]) -> str | None:
31
+ if tool == ANSWER_TOOL:
32
+ return None
33
+ if tool not in ALLOWED_TOOLS:
34
+ return f"{tool} isn't available; only Read, Grep and Glob inside the repository are."
35
+ if tool == "Read":
36
+ path = arguments.get("file_path")
37
+ if not isinstance(path, str):
38
+ return "Read needs a file path."
39
+ inside = self._inside(path)
40
+ if inside is None or not inside.is_file():
41
+ return f"{path} isn't a file in the repository."
42
+ relative = inside.relative_to(self.root).as_posix()
43
+ if relative not in self.files_read:
44
+ self.files_read.append(relative)
45
+ return None
46
+ folder = arguments.get("path")
47
+ if folder is not None and (not isinstance(folder, str) or self._inside(folder) is None):
48
+ return f"{folder} isn't inside the repository."
49
+ for key in ("pattern", "glob") if tool == "Glob" else ("glob",):
50
+ pattern = arguments.get(key)
51
+ # Braces could expand to .. or an absolute path before the pattern is resolved; refuse them outright.
52
+ if isinstance(pattern, str) and (
53
+ pattern.startswith(("/", "~")) or ".." in pattern or any(char in pattern for char in "{}\\\0")
54
+ ):
55
+ return f"The pattern {pattern} could reach outside the repository."
56
+ return None
57
+
58
+ def _inside(self, path: str) -> Path | None:
59
+ if "\0" in path or path.startswith("~"):
60
+ return None
61
+ candidate = Path(path)
62
+ resolved = (candidate if candidate.is_absolute() else self.root / candidate).resolve()
63
+ return resolved if resolved == self.root or resolved.is_relative_to(self.root) else None
64
+
65
+ async def hook(self, input_data: Mapping[str, Any], tool_use_id: str | None, context: Any) -> dict[str, Any]:
66
+ arguments = input_data.get("tool_input")
67
+ if not isinstance(arguments, Mapping):
68
+ arguments = {"invalid": True}
69
+ reason = self.decide(str(input_data.get("tool_name", "")), arguments)
70
+ if reason is None and arguments.get("invalid") is True and len(arguments) == 1:
71
+ reason = "The request couldn't be checked, so it is refused."
72
+ if reason is None:
73
+ return {}
74
+ return {
75
+ "hookSpecificOutput": {
76
+ "hookEventName": "PreToolUse",
77
+ "permissionDecision": "deny",
78
+ "permissionDecisionReason": reason,
79
+ }
80
+ }
@@ -0,0 +1,58 @@
1
+ """The tool guard as Claude Code's PreToolUse hook: `python -m codetrail.assistant.guard_hook <source> <read-log>`.
2
+
3
+ Claude Code runs it before every tool call, with the call as JSON on stdin. A refusal is printed as the hook's deny
4
+ decision; each allowed Read's path is appended to the read log, which becomes the page's `files`. Claude Code runs a
5
+ tool when a hook fails in any way other than exit code 2, so every error of the hook's own exits with code 2, which
6
+ refuses the call (design section 15.1). Claude Code's permission rules confine reads independently of this hook.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import sys
13
+ from collections.abc import Mapping
14
+ from pathlib import Path
15
+
16
+
17
+ def main(arguments: list[str]) -> int:
18
+ from codetrail.assistant.guard import ToolGuard
19
+
20
+ source, log = Path(arguments[0]), Path(arguments[1])
21
+ payload = json.loads(sys.stdin.read())
22
+ guard = ToolGuard(source)
23
+ if not isinstance(payload, Mapping):
24
+ raise ValueError("The hook's input isn't a JSON object.")
25
+ tool_input = payload.get("tool_input")
26
+ if not isinstance(tool_input, Mapping):
27
+ raise ValueError("The tool's input isn't a JSON object.")
28
+ reason = guard.decide(str(payload.get("tool_name", "")), tool_input)
29
+ if reason is not None:
30
+ print(
31
+ json.dumps(
32
+ {
33
+ "hookSpecificOutput": {
34
+ "hookEventName": "PreToolUse",
35
+ "permissionDecision": "deny",
36
+ "permissionDecisionReason": reason,
37
+ }
38
+ }
39
+ )
40
+ )
41
+ return 0
42
+ if guard.files_read:
43
+ with log.open("a", encoding="utf-8") as handle:
44
+ handle.writelines(f"{path}\n" for path in guard.files_read)
45
+ return 0
46
+
47
+
48
+ if __name__ == "__main__":
49
+ try:
50
+ sys.exit(main(sys.argv[1:]))
51
+ except BaseException as error: # any failure must refuse the call, never let it through
52
+ if isinstance(error, SystemExit) and error.code == 0:
53
+ raise
54
+ print(
55
+ f"Codetrail's tool guard couldn't check this call ({type(error).__name__}), so it is refused.",
56
+ file=sys.stderr,
57
+ )
58
+ sys.exit(2)
@@ -0,0 +1,245 @@
1
+ """The local adapter: a model on the reader's machine, through Codetrail's own read-only tools (design 15.1, 15.5).
2
+
3
+ It talks to an OpenAI-compatible chat endpoint on loopback (Ollama or LM Studio) with httpx, ignoring proxy settings
4
+ and never following redirects. The model may call Read, Grep and Glob, which Codetrail runs over `source/` through the
5
+ tool guard. A structured answer must be JSON with the schema's keys; one retry is given with the errors. Turns,
6
+ tokens and time are limited by configuration. Answers are buffered, so the bridge can scan them before the reader sees
7
+ them. Local models cost nothing.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from collections.abc import AsyncIterator, Mapping
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Any, Final
17
+
18
+ import anyio
19
+ import httpx
20
+
21
+ from codetrail.assistant import (
22
+ AnswerChunk,
23
+ AssistantError,
24
+ DigestDraft,
25
+ DigestRequest,
26
+ GradeRequest,
27
+ PageDraft,
28
+ PageRequest,
29
+ PlanDraft,
30
+ PlanRequest,
31
+ QuestionRequest,
32
+ Usage,
33
+ Verdict,
34
+ )
35
+ from codetrail.assistant.local_tools import SCHEMAS, LocalTools
36
+ from codetrail.assistant.prompts import (
37
+ ANSWER_RULES,
38
+ DIGEST_SCHEMA,
39
+ GRADE_RULES,
40
+ GRADE_SCHEMA,
41
+ GROUND_RULES,
42
+ PAGE_SYNTAX,
43
+ PLAN_SCHEMA,
44
+ answer_prompt,
45
+ digest_prompt,
46
+ grade_prompt,
47
+ neutralize,
48
+ page_task,
49
+ plan_prompt,
50
+ )
51
+ from codetrail.config import GenerationSettings, LocalSettings
52
+
53
+ PROVIDER: Final = "local"
54
+ MAX_TOOL_CALLS_PER_TURN = 20
55
+ TOOL_RULES = "Use the read, grep and glob tools to look at the repository's files before you answer."
56
+
57
+
58
+ @dataclass
59
+ class _Result:
60
+ text: str
61
+ files_read: list[str]
62
+ usage: Usage
63
+
64
+
65
+ class LocalAssistant:
66
+ def __init__(
67
+ self,
68
+ source_root: Path,
69
+ models: Mapping[str, str],
70
+ settings: LocalSettings,
71
+ limits: GenerationSettings,
72
+ client: httpx.AsyncClient | None = None,
73
+ ) -> None:
74
+ self.root = source_root.resolve()
75
+ self.models = dict(models)
76
+ self.settings = settings
77
+ self.limits = limits
78
+ self.client = client or httpx.AsyncClient(
79
+ base_url=settings.base_url,
80
+ trust_env=False,
81
+ follow_redirects=False,
82
+ timeout=httpx.Timeout(settings.timeout_seconds),
83
+ )
84
+
85
+ async def plan(self, request: PlanRequest) -> PlanDraft:
86
+ data, result = await self._structured("plan", GROUND_RULES, plan_prompt(request), PLAN_SCHEMA)
87
+ return PlanDraft(list(data.get("pages", [])), list(data.get("paths", [])), result.files_read, 0.0, result.usage)
88
+
89
+ async def write_page(self, request: PageRequest) -> PageDraft:
90
+ prompt, schema = page_task(request) # a new page, or a revision's sections
91
+ data, result = await self._structured("write", GROUND_RULES + "\n" + PAGE_SYNTAX, prompt, schema)
92
+ return PageDraft(str(data.get("body", "")), list(data.get("checks", [])), result.files_read, 0.0, result.usage,
93
+ list(data.get("sections", [])), str(data.get("brief", ""))) # fmt: skip
94
+
95
+ async def write_digest(self, request: DigestRequest) -> DigestDraft:
96
+ data, result = await self._structured(
97
+ "digest", GROUND_RULES + "\n" + PAGE_SYNTAX, digest_prompt(request), DIGEST_SCHEMA
98
+ )
99
+ return DigestDraft(str(data.get("title", "")), str(data.get("body", "")), result.files_read, 0.0, result.usage)
100
+
101
+ async def grade(self, request: GradeRequest) -> Verdict:
102
+ data, result = await self._structured("grade", GRADE_RULES, grade_prompt(request), GRADE_SCHEMA, tools=False)
103
+ missed = [str(item) for item in data.get("missed", [])]
104
+ return Verdict(str(data.get("verdict", "")), missed, str(data.get("feedback", "")), 0.0, result.usage)
105
+
106
+ async def answer(self, request: QuestionRequest) -> AsyncIterator[AnswerChunk]:
107
+ rules = GROUND_RULES + "\n" + PAGE_SYNTAX + "\n" + ANSWER_RULES
108
+ result = await self._loop("answer", rules, answer_prompt(request), None)
109
+ # One final chunk: the bridge shows it only after scanning it (design section 15.5).
110
+ yield AnswerChunk(result.text, True, result.files_read, usage=result.usage)
111
+
112
+ async def _structured(
113
+ self, kind: str, rules: str, prompt: str, schema: dict[str, Any], tools: bool = True
114
+ ) -> tuple[dict[str, Any], _Result]:
115
+ result = await self._loop(kind, rules, prompt, schema, tools)
116
+ data = _parse(result.text, schema)
117
+ if isinstance(data, str):
118
+ raise AssistantError(f"The local model's answer wasn't usable: {data}")
119
+ return data, result
120
+
121
+ async def _loop(
122
+ self, kind: str, rules: str, prompt: str, schema: dict[str, Any] | None, tools: bool = True
123
+ ) -> _Result:
124
+ model = self.models.get(kind, "")
125
+ files = LocalTools(self.root, self.settings.max_read_bytes)
126
+ system = rules
127
+ if tools:
128
+ system += "\n" + TOOL_RULES
129
+ if schema is not None:
130
+ system += "\nFinish with only a JSON object that follows this schema, and nothing else:\n" + json.dumps(
131
+ schema
132
+ )
133
+ messages: list[dict[str, Any]] = [
134
+ {"role": "system", "content": system},
135
+ {"role": "user", "content": neutralize(prompt)},
136
+ ]
137
+ tokens_in = tokens_out = 0
138
+ retried = False
139
+ with anyio.fail_after(self.settings.timeout_seconds):
140
+ for _turn in range(self.settings.max_turns):
141
+ body: dict[str, Any] = {"model": model, "messages": messages, "temperature": 0}
142
+ if tools:
143
+ body["tools"] = SCHEMAS
144
+ reply = await self._chat(body)
145
+ usage = reply.get("usage") or {}
146
+ tokens_in += _count(usage, "prompt_tokens")
147
+ tokens_out += _count(usage, "completion_tokens")
148
+ if tokens_in + tokens_out > self.settings.max_tokens_per_call:
149
+ raise AssistantError(
150
+ f"The local model passed its token limit ({self.settings.max_tokens_per_call})."
151
+ )
152
+ message = _message(reply)
153
+ calls = message.get("tool_calls") or []
154
+ if calls and tools:
155
+ messages.append({"role": "assistant", "content": message.get("content") or "", "tool_calls": calls})
156
+ for index, call in enumerate(calls):
157
+ content = (
158
+ await _run_tool(files, call)
159
+ if index < MAX_TOOL_CALLS_PER_TURN
160
+ else f"Refused: too many tool calls in one turn (at most {MAX_TOOL_CALLS_PER_TURN})."
161
+ )
162
+ messages.append({"role": "tool", "tool_call_id": str(call.get("id", "")), "content": content})
163
+ continue
164
+ text = str(message.get("content") or "")
165
+ if schema is not None and isinstance(problem := _parse(text, schema), str) and not retried:
166
+ retried = True
167
+ messages += [
168
+ {"role": "assistant", "content": text},
169
+ {
170
+ "role": "user",
171
+ "content": f"That wasn't usable: {problem} Answer again with only the JSON object.",
172
+ },
173
+ ]
174
+ continue
175
+ usage_record = Usage(PROVIDER, model, tokens_in, 0, tokens_out, 0.0)
176
+ return _Result(text, list(files.files_read), usage_record)
177
+ raise AssistantError(f"The local model didn't finish within {self.settings.max_turns} turns.")
178
+
179
+ async def _chat(self, body: dict[str, Any]) -> dict[str, Any]:
180
+ try:
181
+ response = await self.client.post("chat/completions", json=body)
182
+ except httpx.HTTPError as error:
183
+ raise AssistantError(
184
+ f"The local model at {self.settings.base_url} couldn't be reached ({type(error).__name__})."
185
+ ) from error
186
+ if response.status_code != 200:
187
+ raise AssistantError(f"The local model answered with HTTP {response.status_code}.")
188
+ try:
189
+ reply = response.json()
190
+ except ValueError as error:
191
+ raise AssistantError("The local model's reply wasn't JSON.") from error
192
+ if not isinstance(reply, dict):
193
+ raise AssistantError("The local model's reply wasn't a JSON object.")
194
+ return reply
195
+
196
+
197
+ async def _run_tool(files: LocalTools, call: Mapping[str, Any]) -> str:
198
+ function = call.get("function") or {}
199
+ try:
200
+ arguments = json.loads(function.get("arguments") or "{}")
201
+ except ValueError:
202
+ return "Refused: the tool's arguments weren't JSON."
203
+ if not isinstance(arguments, dict):
204
+ return "Refused: the tool's arguments weren't a JSON object."
205
+ # In a worker thread, so a slow search can't block the event loop or outlast the time limit.
206
+ return await anyio.to_thread.run_sync(files.run, str(function.get("name", "")), arguments, abandon_on_cancel=True)
207
+
208
+
209
+ def _message(reply: Mapping[str, Any]) -> dict[str, Any]:
210
+ choices = reply.get("choices")
211
+ if not isinstance(choices, list) or not choices or not isinstance(choices[0], dict):
212
+ raise AssistantError("The local model's reply had no message.")
213
+ message = choices[0].get("message")
214
+ if not isinstance(message, dict):
215
+ raise AssistantError("The local model's reply had no message.")
216
+ return message
217
+
218
+
219
+ def _unfenced(text: str) -> str:
220
+ """The text inside a ```json fence, if the whole answer is one; plain string work, so it stays linear."""
221
+ stripped = text.strip()
222
+ if stripped.startswith("```") and stripped.endswith("```") and len(stripped) >= 6:
223
+ inner = stripped[3:-3]
224
+ first_line, _newline, rest = inner.partition("\n")
225
+ return rest if first_line.strip() in ("", "json") else inner
226
+ return stripped
227
+
228
+
229
+ def _parse(text: str, schema: Mapping[str, Any]) -> dict[str, Any] | str:
230
+ """The answer as a JSON object with the schema's required keys, or what is wrong with it."""
231
+ try:
232
+ data = json.loads(_unfenced(text))
233
+ except ValueError:
234
+ return "it isn't valid JSON."
235
+ if not isinstance(data, dict):
236
+ return "it isn't a JSON object."
237
+ missing = [key for key in schema.get("required", []) if key not in data]
238
+ if missing:
239
+ return f"it lacks {', '.join(missing)}."
240
+ return data
241
+
242
+
243
+ def _count(usage: Mapping[str, Any], key: str) -> int:
244
+ value = usage.get(key, 0)
245
+ return int(value) if isinstance(value, int | float) else 0
@@ -0,0 +1,177 @@
1
+ """The local provider's file tools, implemented by Codetrail: Read, Grep and Glob, only inside `source/` (design 15.1).
2
+
3
+ Every path goes through the tool guard. Grep searches for plain text, never a regular expression, so no pattern from
4
+ the model can run away; Glob patterns are short, with few wildcards, for the same reason. A refusal comes back to the
5
+ model as text starting with "Refused:", so it can try something else.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import fnmatch
11
+ from collections.abc import Mapping
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from codetrail.assistant.guard import ToolGuard
16
+
17
+ MAX_GREP_LINES = 200
18
+ MAX_GLOB_PATHS = 500
19
+ MAX_PATTERN_CHARS = 200
20
+ MAX_WILDCARDS = 10
21
+ MAX_LINE_CHARS = 300
22
+
23
+ SCHEMAS: list[dict[str, Any]] = [
24
+ {
25
+ "type": "function",
26
+ "function": {
27
+ "name": "read",
28
+ "description": "Read a text file in the repository, with line numbers.",
29
+ "parameters": {
30
+ "type": "object",
31
+ "required": ["path"],
32
+ "properties": {
33
+ "path": {"type": "string", "description": "A path relative to the repository's root."},
34
+ "offset": {"type": "integer", "description": "The first line to read, from 1."},
35
+ "limit": {"type": "integer", "description": "How many lines to read."},
36
+ },
37
+ },
38
+ },
39
+ },
40
+ {
41
+ "type": "function",
42
+ "function": {
43
+ "name": "grep",
44
+ "description": "Find lines containing some text (plain text, not case-sensitive).",
45
+ "parameters": {
46
+ "type": "object",
47
+ "required": ["text"],
48
+ "properties": {
49
+ "text": {"type": "string"},
50
+ "path": {"type": "string", "description": "A folder to search in; the root by default."},
51
+ "glob": {"type": "string", "description": "Only files whose path matches this pattern, like *.py."},
52
+ },
53
+ },
54
+ },
55
+ },
56
+ {
57
+ "type": "function",
58
+ "function": {
59
+ "name": "glob",
60
+ "description": "List the files whose paths match a pattern, like **/*.py.",
61
+ "parameters": {
62
+ "type": "object",
63
+ "required": ["pattern"],
64
+ "properties": {
65
+ "pattern": {"type": "string"},
66
+ "path": {"type": "string", "description": "A folder to search in; the root by default."},
67
+ },
68
+ },
69
+ },
70
+ },
71
+ ]
72
+
73
+
74
+ class _Refused(Exception):
75
+ pass
76
+
77
+
78
+ class LocalTools:
79
+ def __init__(self, source_root: Path, max_read_bytes: int) -> None:
80
+ self.guard = ToolGuard(source_root)
81
+ self.root = self.guard.root
82
+ self.max_read_bytes = max_read_bytes
83
+
84
+ @property
85
+ def files_read(self) -> list[str]:
86
+ return self.guard.files_read
87
+
88
+ def run(self, name: str, arguments: Mapping[str, Any]) -> str:
89
+ try:
90
+ if name == "read":
91
+ return self._read(
92
+ _text(arguments, "path"), _number(arguments, "offset", 1), _number(arguments, "limit", 2000)
93
+ )
94
+ if name == "grep":
95
+ return self._grep(_text(arguments, "text"), _text(arguments, "path", "."), arguments.get("glob"))
96
+ if name == "glob":
97
+ return self._glob(_text(arguments, "pattern"), _text(arguments, "path", "."))
98
+ raise _Refused(f"{name} isn't a tool here; only read, grep and glob are.")
99
+ except _Refused as refusal:
100
+ return f"Refused: {refusal}"
101
+
102
+ def _read(self, path: str, offset: int, limit: int) -> str:
103
+ reason = self.guard.decide("Read", {"file_path": path})
104
+ if reason is not None:
105
+ raise _Refused(reason)
106
+ with (self.root / path).open("rb") as handle:
107
+ data = handle.read(self.max_read_bytes)
108
+ lines = data.decode("utf-8", "replace").splitlines()
109
+ start = max(offset, 1)
110
+ shown = lines[start - 1 : start - 1 + max(limit, 1)]
111
+ return "\n".join(f"{start + index}\t{line}" for index, line in enumerate(shown))
112
+
113
+ def _grep(self, text: str, path: str, glob: object) -> str:
114
+ if not text:
115
+ raise _Refused("grep needs some text to find.")
116
+ folder = self._folder(path)
117
+ pattern = _pattern(glob) if glob is not None else None
118
+ needle = text.lower()
119
+ found: list[str] = []
120
+ for file in sorted(folder.rglob("*")):
121
+ if not self._plain_file(file) or (pattern and not fnmatch.fnmatch(file.name, pattern)):
122
+ continue
123
+ relative = file.relative_to(self.root).as_posix()
124
+ with file.open("rb") as handle:
125
+ content = handle.read(self.max_read_bytes).decode("utf-8", "replace")
126
+ for number, line in enumerate(content.splitlines(), start=1):
127
+ if needle in line.lower():
128
+ found.append(f"{relative}:{number}: {line.strip()[:MAX_LINE_CHARS]}")
129
+ if len(found) >= MAX_GREP_LINES:
130
+ return "\n".join(found)
131
+ return "\n".join(found) or "No matches."
132
+
133
+ def _glob(self, pattern: str, path: str) -> str:
134
+ reason = self.guard.decide("Glob", {"pattern": _pattern(pattern), "path": path})
135
+ if reason is not None:
136
+ raise _Refused(reason)
137
+ folder = self._folder(path)
138
+ paths = sorted(
139
+ file.relative_to(self.root).as_posix() for file in folder.glob(pattern) if self._plain_file(file)
140
+ )
141
+ return "\n".join(paths[:MAX_GLOB_PATHS]) or "No matches."
142
+
143
+ def _plain_file(self, file: Path) -> bool:
144
+ """A regular file inside the root, reached through no symlink: source/ never holds one, but don't rely on it."""
145
+ if file.is_symlink() or not file.is_file():
146
+ return False
147
+ resolved = file.resolve()
148
+ return resolved.is_relative_to(self.root) and resolved == file.absolute()
149
+
150
+ def _folder(self, path: str) -> Path:
151
+ reason = self.guard.decide("Grep", {"path": path})
152
+ folder = (self.root / path).resolve()
153
+ if reason is not None or not folder.is_dir():
154
+ raise _Refused(reason or f"{path} isn't a folder in the repository.")
155
+ return folder
156
+
157
+
158
+ def _pattern(value: object) -> str:
159
+ if not isinstance(value, str) or not value:
160
+ raise _Refused("A pattern must be some text.")
161
+ if len(value) > MAX_PATTERN_CHARS or value.count("*") + value.count("?") > MAX_WILDCARDS:
162
+ raise _Refused(f"Patterns are limited to {MAX_PATTERN_CHARS} characters and {MAX_WILDCARDS} wildcards.")
163
+ return value
164
+
165
+
166
+ def _text(arguments: Mapping[str, Any], key: str, default: str | None = None) -> str:
167
+ value = arguments.get(key, default)
168
+ if not isinstance(value, str):
169
+ raise _Refused(f"{key} must be text.")
170
+ return value
171
+
172
+
173
+ def _number(arguments: Mapping[str, Any], key: str, default: int) -> int:
174
+ value = arguments.get(key, default)
175
+ if not isinstance(value, int) or isinstance(value, bool):
176
+ raise _Refused(f"{key} must be a whole number.")
177
+ return value