bareagent-cli 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.
Files changed (121) hide show
  1. bareagent/__init__.py +10 -0
  2. bareagent/concurrency/__init__.py +6 -0
  3. bareagent/concurrency/background.py +97 -0
  4. bareagent/concurrency/notification.py +61 -0
  5. bareagent/concurrency/scheduler.py +136 -0
  6. bareagent/config.toml +299 -0
  7. bareagent/core/__init__.py +1 -0
  8. bareagent/core/config_paths.py +49 -0
  9. bareagent/core/context.py +127 -0
  10. bareagent/core/fileutil.py +103 -0
  11. bareagent/core/goal.py +214 -0
  12. bareagent/core/handlers/__init__.py +1 -0
  13. bareagent/core/handlers/bash.py +79 -0
  14. bareagent/core/handlers/file_edit.py +47 -0
  15. bareagent/core/handlers/file_read.py +270 -0
  16. bareagent/core/handlers/file_write.py +34 -0
  17. bareagent/core/handlers/glob_search.py +30 -0
  18. bareagent/core/handlers/goal.py +60 -0
  19. bareagent/core/handlers/grep_search.py +52 -0
  20. bareagent/core/handlers/memory.py +71 -0
  21. bareagent/core/handlers/plan.py +106 -0
  22. bareagent/core/handlers/search_utils.py +77 -0
  23. bareagent/core/handlers/skill.py +87 -0
  24. bareagent/core/handlers/subagent_send.py +70 -0
  25. bareagent/core/handlers/web_fetch.py +126 -0
  26. bareagent/core/handlers/web_search.py +165 -0
  27. bareagent/core/handlers/workflow.py +190 -0
  28. bareagent/core/loop.py +535 -0
  29. bareagent/core/retry.py +131 -0
  30. bareagent/core/sandbox.py +27 -0
  31. bareagent/core/schema.py +21 -0
  32. bareagent/core/tools.py +779 -0
  33. bareagent/core/workflow.py +517 -0
  34. bareagent/core/workflow_registry.py +219 -0
  35. bareagent/debug/__init__.py +0 -0
  36. bareagent/debug/interaction_log.py +263 -0
  37. bareagent/debug/viewer.html +1750 -0
  38. bareagent/debug/web_viewer.py +157 -0
  39. bareagent/hooks/__init__.py +32 -0
  40. bareagent/hooks/config.py +118 -0
  41. bareagent/hooks/engine.py +197 -0
  42. bareagent/hooks/errors.py +14 -0
  43. bareagent/hooks/events.py +22 -0
  44. bareagent/lsp/__init__.py +63 -0
  45. bareagent/lsp/config.py +134 -0
  46. bareagent/lsp/coord.py +118 -0
  47. bareagent/lsp/diagnostics.py +240 -0
  48. bareagent/lsp/errors.py +24 -0
  49. bareagent/lsp/manager.py +866 -0
  50. bareagent/lsp/tools.py +629 -0
  51. bareagent/lsp/workspace_edit.py +305 -0
  52. bareagent/main.py +4205 -0
  53. bareagent/mcp/__init__.py +69 -0
  54. bareagent/mcp/_sse.py +69 -0
  55. bareagent/mcp/client.py +341 -0
  56. bareagent/mcp/config.py +169 -0
  57. bareagent/mcp/errors.py +32 -0
  58. bareagent/mcp/manager.py +318 -0
  59. bareagent/mcp/protocol.py +187 -0
  60. bareagent/mcp/registry.py +557 -0
  61. bareagent/mcp/transport/__init__.py +15 -0
  62. bareagent/mcp/transport/base.py +149 -0
  63. bareagent/mcp/transport/http_legacy.py +192 -0
  64. bareagent/mcp/transport/http_streamable.py +217 -0
  65. bareagent/mcp/transport/stdio.py +202 -0
  66. bareagent/memory/__init__.py +1 -0
  67. bareagent/memory/compact.py +203 -0
  68. bareagent/memory/conversation_io.py +226 -0
  69. bareagent/memory/embedding.py +194 -0
  70. bareagent/memory/persistent.py +515 -0
  71. bareagent/memory/token_counter.py +67 -0
  72. bareagent/memory/token_tracker.py +262 -0
  73. bareagent/memory/transcript.py +100 -0
  74. bareagent/permission/__init__.py +1 -0
  75. bareagent/permission/guard.py +329 -0
  76. bareagent/permission/rules.py +19 -0
  77. bareagent/planning/__init__.py +19 -0
  78. bareagent/planning/agent_types.py +169 -0
  79. bareagent/planning/skill_gen.py +141 -0
  80. bareagent/planning/skill_store.py +173 -0
  81. bareagent/planning/skills.py +146 -0
  82. bareagent/planning/subagent.py +355 -0
  83. bareagent/planning/subagent_registry.py +77 -0
  84. bareagent/planning/tasks.py +348 -0
  85. bareagent/planning/todo.py +153 -0
  86. bareagent/planning/worktree.py +122 -0
  87. bareagent/provider/__init__.py +1 -0
  88. bareagent/provider/anthropic.py +348 -0
  89. bareagent/provider/base.py +136 -0
  90. bareagent/provider/factory.py +130 -0
  91. bareagent/provider/openai.py +881 -0
  92. bareagent/provider/presets.py +72 -0
  93. bareagent/provider/setup.py +356 -0
  94. bareagent/skills/.gitkeep +1 -0
  95. bareagent/skills/code-review/SKILL.md +68 -0
  96. bareagent/skills/git/SKILL.md +68 -0
  97. bareagent/skills/test/SKILL.md +70 -0
  98. bareagent/team/__init__.py +17 -0
  99. bareagent/team/autonomous.py +193 -0
  100. bareagent/team/mailbox.py +239 -0
  101. bareagent/team/manager.py +155 -0
  102. bareagent/team/protocols.py +129 -0
  103. bareagent/tracing/__init__.py +12 -0
  104. bareagent/tracing/_api.py +92 -0
  105. bareagent/tracing/_proxy.py +60 -0
  106. bareagent/tracing/composite.py +115 -0
  107. bareagent/tracing/json_file.py +115 -0
  108. bareagent/tracing/langfuse.py +139 -0
  109. bareagent/tracing/otel.py +107 -0
  110. bareagent/tracing/setup.py +85 -0
  111. bareagent/ui/__init__.py +24 -0
  112. bareagent/ui/console.py +167 -0
  113. bareagent/ui/prompt.py +78 -0
  114. bareagent/ui/protocol.py +24 -0
  115. bareagent/ui/stream.py +66 -0
  116. bareagent/ui/theme.py +240 -0
  117. bareagent_cli-0.1.0.dist-info/METADATA +331 -0
  118. bareagent_cli-0.1.0.dist-info/RECORD +121 -0
  119. bareagent_cli-0.1.0.dist-info/WHEEL +4 -0
  120. bareagent_cli-0.1.0.dist-info/entry_points.txt +2 -0
  121. bareagent_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,270 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from bareagent.core.sandbox import safe_path
9
+
10
+ # Image extension -> Anthropic-supported mime type. The whitelist mirrors
11
+ # ``mcp/registry.py:_SUPPORTED_IMAGE_MIME_TYPES`` so local image reads produce
12
+ # the same internal block shape MCP multimodal results do.
13
+ _IMAGE_EXT_TO_MIME: dict[str, str] = {
14
+ ".png": "image/png",
15
+ ".jpg": "image/jpeg",
16
+ ".jpeg": "image/jpeg",
17
+ ".gif": "image/gif",
18
+ ".webp": "image/webp",
19
+ }
20
+
21
+ # Hard cap on image payloads. Aligned with the MCP default
22
+ # ``max_result_binary_bytes`` (5 MiB). Over-limit images return an Error string
23
+ # (D2: ask the user to downscale; we do not auto-resize — that needs Pillow).
24
+ _MAX_IMAGE_BYTES = 5_242_880 # 5 MiB
25
+
26
+ # Per-output and whole-document length caps for textual renderings (notebook
27
+ # cell outputs, PDF text). Keeps a pathological file from flooding the context.
28
+ _MAX_CELL_OUTPUT_CHARS = 2000
29
+ _MAX_DOCUMENT_CHARS = 200_000
30
+
31
+ _TRUNCATED_MARKER = "... [truncated]"
32
+
33
+
34
+ def run_read(
35
+ file_path: str,
36
+ offset: int = 0,
37
+ limit: int | None = None,
38
+ *,
39
+ pages: str | None = None,
40
+ workspace: Path,
41
+ ) -> str | list[dict[str, Any]]:
42
+ """Read a workspace file, dispatching by extension.
43
+
44
+ - Images (.png/.jpg/.jpeg/.gif/.webp) -> ``[text, image]`` content blocks.
45
+ - PDF (.pdf) -> extracted text (optional ``pages`` range); needs the
46
+ ``[pdf]`` extra (pypdf).
47
+ - Notebook (.ipynb) -> text rendering of markdown/code cells + outputs.
48
+ - Everything else -> the legacy UTF-8 text path with line numbers
49
+ (``offset`` / ``limit`` slice).
50
+ """
51
+ if offset < 0:
52
+ raise ValueError("offset must be >= 0")
53
+ if limit is not None and limit < 0:
54
+ raise ValueError("limit must be >= 0")
55
+
56
+ resolved = safe_path(file_path, workspace)
57
+ suffix = resolved.suffix.lower()
58
+
59
+ if suffix in _IMAGE_EXT_TO_MIME:
60
+ return _read_image(resolved, _IMAGE_EXT_TO_MIME[suffix])
61
+ if suffix == ".pdf":
62
+ return _read_pdf(resolved, pages)
63
+ if suffix == ".ipynb":
64
+ return _read_notebook(resolved)
65
+
66
+ return _read_text(resolved, offset, limit)
67
+
68
+
69
+ def _read_text(resolved: Path, offset: int, limit: int | None) -> str:
70
+ """Read a UTF-8 text file and prefix each line with its line number."""
71
+ if offset > 0 or limit is not None:
72
+ # Stream lines to avoid loading the entire file when only a slice is needed.
73
+ selected: list[str] = []
74
+ end = None if limit is None else offset + limit
75
+ with resolved.open(encoding="utf-8") as fh:
76
+ for idx, line in enumerate(fh):
77
+ if idx < offset:
78
+ continue
79
+ if end is not None and idx >= end:
80
+ break
81
+ selected.append(line.rstrip("\n\r"))
82
+ else:
83
+ selected = resolved.read_text(encoding="utf-8").splitlines()
84
+
85
+ return "\n".join(
86
+ f"{line_number}: {line}" for line_number, line in enumerate(selected, start=offset + 1)
87
+ )
88
+
89
+
90
+ def _read_image(resolved: Path, mime: str) -> str | list[dict[str, Any]]:
91
+ """Read an image as base64 and return ``[text, image]`` content blocks."""
92
+ try:
93
+ raw = resolved.read_bytes()
94
+ except OSError as exc:
95
+ return f"Error: cannot read image {resolved.name!r}: {exc}"
96
+ size = len(raw)
97
+ if size > _MAX_IMAGE_BYTES:
98
+ return (
99
+ f"Error: image {resolved.name!r} is {size} bytes, exceeding the "
100
+ f"{_MAX_IMAGE_BYTES} byte limit. Downscale or compress it before reading."
101
+ )
102
+ data = base64.b64encode(raw).decode("ascii")
103
+ description = f"Image {resolved.name} ({mime}, {size} bytes)"
104
+ return [
105
+ {"type": "text", "text": description},
106
+ {
107
+ "type": "image",
108
+ "source": {
109
+ "type": "base64",
110
+ "media_type": mime,
111
+ "data": data,
112
+ },
113
+ },
114
+ ]
115
+
116
+
117
+ def _read_notebook(resolved: Path) -> str:
118
+ """Render a Jupyter notebook's markdown/code cells (+ outputs) as text."""
119
+ try:
120
+ raw = resolved.read_text(encoding="utf-8")
121
+ except OSError as exc:
122
+ return f"Error: cannot read notebook {resolved.name!r}: {exc}"
123
+ try:
124
+ nb = json.loads(raw)
125
+ except (json.JSONDecodeError, ValueError) as exc:
126
+ return f"Error: notebook {resolved.name!r} is not valid JSON: {exc}"
127
+
128
+ cells = nb.get("cells") if isinstance(nb, dict) else None
129
+ if not isinstance(cells, list):
130
+ return f"Error: notebook {resolved.name!r} has no 'cells' array"
131
+
132
+ parts: list[str] = []
133
+ for index, cell in enumerate(cells, start=1):
134
+ if not isinstance(cell, dict):
135
+ continue
136
+ cell_type = cell.get("cell_type")
137
+ source = _join_source(cell.get("source"))
138
+ if cell_type == "markdown":
139
+ parts.append(f"## Markdown cell {index}\n{source}")
140
+ elif cell_type == "code":
141
+ block = f"## Code cell {index}\n{source}"
142
+ outputs = _render_outputs(cell.get("outputs"))
143
+ if outputs:
144
+ block += f"\n### Output\n{outputs}"
145
+ parts.append(block)
146
+ # Other cell types (e.g. raw) are skipped.
147
+
148
+ rendered = "\n\n".join(parts)
149
+ return _cap_document(rendered)
150
+
151
+
152
+ def _join_source(source: Any) -> str:
153
+ """Notebook ``source`` is a list of lines or a single string."""
154
+ if isinstance(source, list):
155
+ return "".join(str(item) for item in source)
156
+ if isinstance(source, str):
157
+ return source
158
+ return ""
159
+
160
+
161
+ def _render_outputs(outputs: Any) -> str:
162
+ """Extract human-readable text from a code cell's ``outputs`` array."""
163
+ if not isinstance(outputs, list):
164
+ return ""
165
+ rendered: list[str] = []
166
+ for output in outputs:
167
+ if not isinstance(output, dict):
168
+ continue
169
+ output_type = output.get("output_type")
170
+ text = ""
171
+ if output_type == "stream":
172
+ text = _join_source(output.get("text"))
173
+ elif output_type in ("execute_result", "display_data"):
174
+ data = output.get("data")
175
+ if isinstance(data, dict):
176
+ text = _join_source(data.get("text/plain"))
177
+ elif output_type == "error":
178
+ traceback = output.get("traceback")
179
+ if isinstance(traceback, list):
180
+ text = "\n".join(str(line) for line in traceback)
181
+ if text:
182
+ rendered.append(_truncate(text, _MAX_CELL_OUTPUT_CHARS))
183
+ return "\n".join(rendered)
184
+
185
+
186
+ def _read_pdf(resolved: Path, pages: str | None) -> str:
187
+ """Extract text from a PDF. Needs the ``[pdf]`` extra (pypdf)."""
188
+ try:
189
+ from pypdf import PdfReader # type: ignore[import-untyped]
190
+ except ImportError:
191
+ return (
192
+ 'Error: PDF support is not installed. Run uv pip install -e ".[pdf]" '
193
+ "(or pip install pypdf)."
194
+ )
195
+
196
+ try:
197
+ reader = PdfReader(str(resolved))
198
+ except Exception as exc: # noqa: BLE001 - pypdf raises a variety of types
199
+ return f"Error: cannot open PDF {resolved.name!r}: {type(exc).__name__}: {exc}"
200
+
201
+ total = len(reader.pages)
202
+ if total == 0:
203
+ return f"Error: PDF {resolved.name!r} has no pages"
204
+
205
+ selection = _parse_page_range(pages, total)
206
+ if isinstance(selection, str):
207
+ return selection # error message
208
+
209
+ parts: list[str] = []
210
+ for page_no in selection:
211
+ try:
212
+ text = reader.pages[page_no].extract_text() or ""
213
+ except Exception as exc: # noqa: BLE001 - extraction can fail per page
214
+ text = f"[Error extracting text: {type(exc).__name__}: {exc}]"
215
+ parts.append(f"--- Page {page_no + 1} ---\n{text}")
216
+
217
+ return _cap_document("\n\n".join(parts))
218
+
219
+
220
+ def _parse_page_range(pages: str | None, total: int) -> list[int] | str:
221
+ """Parse a 1-based user page spec into a list of 0-based indices.
222
+
223
+ Accepts ``None`` (all pages), ``"3"`` (single page) and ``"1-5"`` (range).
224
+ A range's ``end`` past the last page is clamped, but a ``start`` past the
225
+ last page is an explicit error (mirroring the single-page branch) rather
226
+ than silently returning a different page. Returns an Error string on bad
227
+ input.
228
+ """
229
+ if pages is None:
230
+ return list(range(total))
231
+ spec = pages.strip()
232
+ if not spec:
233
+ return list(range(total))
234
+
235
+ if "-" in spec:
236
+ start_str, _, end_str = spec.partition("-")
237
+ try:
238
+ start = int(start_str)
239
+ end = int(end_str)
240
+ except ValueError:
241
+ return f"Error: invalid page range {pages!r} (expected e.g. '1-5' or '3')"
242
+ if start < 1 or end < 1 or start > end:
243
+ return f"Error: invalid page range {pages!r}"
244
+ if start > total:
245
+ return f"Error: page range start {start} out of range (PDF has {total} pages)"
246
+ start_idx = start - 1
247
+ end_idx = min(end, total)
248
+ return list(range(start_idx, end_idx))
249
+
250
+ try:
251
+ single = int(spec)
252
+ except ValueError:
253
+ return f"Error: invalid page range {pages!r} (expected e.g. '1-5' or '3')"
254
+ if single < 1:
255
+ return f"Error: invalid page number {pages!r}"
256
+ if single > total:
257
+ return f"Error: page {single} out of range (PDF has {total} pages)"
258
+ return [single - 1]
259
+
260
+
261
+ def _truncate(text: str, max_chars: int) -> str:
262
+ if len(text) <= max_chars:
263
+ return text
264
+ return text[:max_chars] + _TRUNCATED_MARKER
265
+
266
+
267
+ def _cap_document(text: str) -> str:
268
+ if len(text) <= _MAX_DOCUMENT_CHARS:
269
+ return text
270
+ return text[:_MAX_DOCUMENT_CHARS] + f"\n{_TRUNCATED_MARKER}"
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from bareagent.core.sandbox import safe_path
8
+
9
+
10
+ def run_write(
11
+ file_path: str,
12
+ content: str,
13
+ *,
14
+ workspace: Path,
15
+ diagnostics_hook: Callable[[str, Any], str | None] | None = None,
16
+ ) -> str:
17
+ """Write content to a workspace file, creating parent directories when needed.
18
+
19
+ ``diagnostics_hook`` (Hybrid auto-diagnostics) follows the same contract
20
+ as :func:`run_edit`: the handler snapshots before, performs the write,
21
+ then asks the hook for an appendix. Returning ``None`` from either call
22
+ leaves the result untouched.
23
+ """
24
+ resolved = safe_path(file_path, workspace)
25
+ resolved.parent.mkdir(parents=True, exist_ok=True)
26
+ before = diagnostics_hook(str(resolved), None) if diagnostics_hook else None
27
+ resolved.write_text(content, encoding="utf-8")
28
+ relative = resolved.relative_to(workspace.resolve(strict=False))
29
+ result = f"Wrote {len(content)} characters to {relative.as_posix()}"
30
+ if diagnostics_hook is not None:
31
+ appendix = diagnostics_hook(str(resolved), before)
32
+ if appendix:
33
+ result = f"{result}{appendix}"
34
+ return result
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from bareagent.core.handlers.search_utils import (
6
+ iter_search_files,
7
+ matches_glob_pattern,
8
+ )
9
+ from bareagent.core.sandbox import safe_path
10
+
11
+
12
+ def run_glob(pattern: str, path: str = ".", *, workspace: Path) -> list[str]:
13
+ """Return workspace-relative paths that match a glob pattern."""
14
+ workspace_path = workspace.resolve(strict=False)
15
+ search_root = safe_path(path, workspace_path)
16
+
17
+ if search_root.is_file():
18
+ candidates = [search_root]
19
+ else:
20
+ candidates = list(iter_search_files(search_root))
21
+
22
+ matches: list[str] = []
23
+ for candidate in candidates:
24
+ resolved = candidate.resolve(strict=False)
25
+ if not resolved.is_relative_to(workspace_path):
26
+ continue
27
+ if not matches_glob_pattern(resolved, search_root, pattern):
28
+ continue
29
+ matches.append(resolved.relative_to(workspace_path).as_posix())
30
+ return sorted(dict.fromkeys(matches))
@@ -0,0 +1,60 @@
1
+ """Handler + schema for the ``goal_verdict`` tool (goal-completion evaluator).
2
+
3
+ Like ``skill_create``, ``goal_verdict`` is NOT registered in the global tool set.
4
+ It is exposed only inside the isolated evaluator ``agent_loop`` call that runs
5
+ after each turn of a ``/goal`` loop (see ``main.py`` and ``src/core/goal.py``).
6
+ Keeping it out of the global set means the main loop never offers it and
7
+ sub-agents never receive it (isolation, same stance as ``skill_create`` /
8
+ ``hook_engine``).
9
+
10
+ The handler is a thin shim: it parses the model-supplied fields into a
11
+ :class:`bareagent.core.goal.Verdict` (delegating coercion to ``goal.parse_verdict``)
12
+ and records it into a caller-provided ``sink`` list as a side effect, mirroring
13
+ how ``skill_create`` persists via its store. The caller reads ``sink`` after the
14
+ isolated evaluator loop returns. Returning a plain confirmation string keeps the
15
+ evaluator loop from crashing (see ``error-handling.md``).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from bareagent.core.goal import Verdict, parse_verdict
21
+ from bareagent.core.schema import tool_schema
22
+
23
+ GOAL_VERDICT_TOOL_SCHEMA = tool_schema(
24
+ "goal_verdict",
25
+ (
26
+ "Report whether the goal completion condition is now fully satisfied, "
27
+ "judging only from the conversation. Call exactly once."
28
+ ),
29
+ {
30
+ "met": {
31
+ "type": "boolean",
32
+ "description": "True only if the transcript verifiably satisfies the condition.",
33
+ },
34
+ "reason": {
35
+ "type": "string",
36
+ "description": (
37
+ "Concrete justification. If not met, name what is still missing "
38
+ "and what the agent should do next."
39
+ ),
40
+ },
41
+ },
42
+ ["met", "reason"],
43
+ )
44
+
45
+
46
+ def run_goal_verdict(
47
+ *,
48
+ sink: list[Verdict],
49
+ met: object = None,
50
+ reason: object = None,
51
+ ) -> str:
52
+ """Record the evaluator's verdict into ``sink`` and confirm to the model.
53
+
54
+ ``sink`` is a one-element accumulator the caller reads after the isolated
55
+ evaluator loop returns. Coercion/validation lives in ``goal.parse_verdict``,
56
+ so this stays a thin shim that never raises.
57
+ """
58
+ verdict = parse_verdict({"met": met, "reason": reason})
59
+ sink.append(verdict)
60
+ return "Verdict recorded."
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+ from bareagent.core.handlers.search_utils import iter_search_files
7
+ from bareagent.core.sandbox import safe_path
8
+
9
+ MAX_MATCHES = 1000
10
+ MAX_FILE_SIZE = 1_048_576 # 1 MB
11
+
12
+
13
+ def run_grep(
14
+ pattern: str,
15
+ path: str = ".",
16
+ include: str = "",
17
+ *,
18
+ workspace: Path,
19
+ ) -> list[str]:
20
+ """Search for a regex pattern in workspace files."""
21
+ workspace_path = workspace.resolve(strict=False)
22
+ search_root = safe_path(path, workspace_path)
23
+ try:
24
+ regex = re.compile(pattern)
25
+ except re.error as exc:
26
+ return [f"Invalid regex pattern: {exc}"]
27
+
28
+ matches: list[str] = []
29
+ for file_path in iter_search_files(search_root):
30
+ resolved = file_path.resolve(strict=False)
31
+ if not resolved.is_relative_to(workspace_path):
32
+ continue
33
+
34
+ relative = resolved.relative_to(workspace_path)
35
+ if include and not relative.match(include):
36
+ continue
37
+
38
+ try:
39
+ if resolved.stat().st_size > MAX_FILE_SIZE:
40
+ continue
41
+ lines = resolved.read_text(encoding="utf-8").splitlines()
42
+ except (OSError, UnicodeDecodeError):
43
+ continue
44
+
45
+ for line_number, line in enumerate(lines, start=1):
46
+ if regex.search(line):
47
+ matches.append(f"{relative.as_posix()}:{line_number}:{line}")
48
+ if len(matches) >= MAX_MATCHES:
49
+ break
50
+ if len(matches) >= MAX_MATCHES:
51
+ break
52
+ return matches
@@ -0,0 +1,71 @@
1
+ """Handler for the single ``memory`` tool.
2
+
3
+ Thin dispatcher over :class:`bareagent.memory.persistent.MemoryManager`. It validates
4
+ per-command required arguments and converts the manager's stdlib exceptions
5
+ into ``Error:`` strings so the LLM can read and react to failures instead of
6
+ crashing the agent loop (see ``.trellis/spec/backend/error-handling.md``).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING
12
+
13
+ if TYPE_CHECKING:
14
+ from bareagent.memory.persistent import MemoryManager
15
+
16
+ # Predictable, LLM-facing failures. Anything outside this set is a real bug and
17
+ # is allowed to propagate to the loop's blanket safety net.
18
+ _HANDLED_ERRORS = (
19
+ FileNotFoundError,
20
+ PermissionError,
21
+ ValueError,
22
+ IsADirectoryError,
23
+ NotADirectoryError,
24
+ OSError,
25
+ )
26
+
27
+
28
+ def run_memory(
29
+ *,
30
+ manager: MemoryManager,
31
+ command: str,
32
+ path: str | None = None,
33
+ file_text: str | None = None,
34
+ old_str: str | None = None,
35
+ new_str: str | None = None,
36
+ insert_line: int | None = None,
37
+ insert_text: str | None = None,
38
+ old_path: str | None = None,
39
+ new_path: str | None = None,
40
+ view_range: list[int] | None = None,
41
+ ) -> str:
42
+ cmd = (command or "").strip()
43
+ try:
44
+ if cmd == "view":
45
+ return manager.view(path or ".", view_range=view_range)
46
+ if cmd == "create":
47
+ if path is None or file_text is None:
48
+ return "Error: create requires 'path' and 'file_text'."
49
+ return manager.create(path, file_text)
50
+ if cmd == "str_replace":
51
+ if path is None or old_str is None or new_str is None:
52
+ return "Error: str_replace requires 'path', 'old_str', and 'new_str'."
53
+ return manager.str_replace(path, old_str, new_str)
54
+ if cmd == "insert":
55
+ if path is None or insert_line is None or insert_text is None:
56
+ return "Error: insert requires 'path', 'insert_line', and 'insert_text'."
57
+ return manager.insert(path, int(insert_line), insert_text)
58
+ if cmd == "delete":
59
+ if path is None:
60
+ return "Error: delete requires 'path'."
61
+ return manager.delete(path)
62
+ if cmd == "rename":
63
+ if old_path is None or new_path is None:
64
+ return "Error: rename requires 'old_path' and 'new_path'."
65
+ return manager.rename(old_path, new_path)
66
+ except _HANDLED_ERRORS as exc:
67
+ return f"Error: {exc}"
68
+ return (
69
+ f"Error: unknown memory command {command!r}. "
70
+ "Valid commands: view, create, str_replace, insert, delete, rename."
71
+ )
@@ -0,0 +1,106 @@
1
+ """Handler + schema for the ``exit_plan_mode`` tool (plan-mode workflow).
2
+
3
+ Like ``skill_create``, ``exit_plan_mode`` is NOT registered in the global tool
4
+ set (``core/tools.py``). It is injected only into the *main* REPL loop's tool
5
+ list in ``main.py`` after the base handlers are built. Keeping it out of
6
+ ``get_tools()`` / the base handler dict means sub-agents never receive it:
7
+ ``run_subagent`` filters tools by the base schema list (which lacks it) and
8
+ ``filter_handlers`` then drops the orphaned handler. A sub-agent must never be
9
+ able to flip the parent's permission mode.
10
+
11
+ The handler is pure: it validates the plan, delegates the user interaction and
12
+ the permission-mode flip to an injected ``approve_fn`` (wired in ``main.py``
13
+ where the UI console and ``PermissionGuard`` live), and maps the resulting
14
+ decision to an ``Error:``-or-instruction string the LLM reads to decide what to
15
+ do next. This keeps ``core/handlers`` free of any dependency on ``permission`` /
16
+ ``ui`` and makes the mapping unit-testable with a fake ``approve_fn``.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from collections.abc import Callable
22
+ from dataclasses import dataclass
23
+
24
+ from bareagent.core.schema import tool_schema
25
+
26
+ EXIT_PLAN_MODE_TOOL_SCHEMA = tool_schema(
27
+ "exit_plan_mode",
28
+ (
29
+ "Present your completed implementation plan for user approval and leave "
30
+ "plan mode. Call this only after researching the task with read-only "
31
+ "tools. On approval the permission mode switches and you continue with "
32
+ "the implementation; on rejection you stay in plan mode and revise."
33
+ ),
34
+ {
35
+ "plan": {
36
+ "type": "string",
37
+ "description": (
38
+ "The implementation plan as markdown: what you will change, in "
39
+ "what order, and any risks. Concise but complete."
40
+ ),
41
+ },
42
+ },
43
+ ["plan"],
44
+ )
45
+
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class PlanDecision:
49
+ """Outcome of the plan-approval interaction.
50
+
51
+ ``outcome`` is one of:
52
+
53
+ - ``"approve-default"`` -- user approved; mode flipped to DEFAULT.
54
+ - ``"approve-auto"`` -- user approved with auto-accept; mode flipped to AUTO.
55
+ - ``"reject"`` -- user rejected; ``reason`` carries optional feedback.
56
+ - ``"noop"`` -- called while not in plan mode (defensive; nothing happened).
57
+ - ``"unavailable"`` -- no interactive approval possible (non-tty); stayed in plan.
58
+ """
59
+
60
+ outcome: str
61
+ reason: str = ""
62
+
63
+
64
+ def run_exit_plan_mode(
65
+ *,
66
+ plan: str | None = None,
67
+ approve_fn: Callable[[str], PlanDecision],
68
+ ) -> str:
69
+ if not plan or not str(plan).strip():
70
+ return "Error: exit_plan_mode requires a non-empty 'plan'."
71
+
72
+ decision = approve_fn(str(plan))
73
+
74
+ if decision.outcome == "approve-default":
75
+ return (
76
+ "Plan approved. Permission mode is now DEFAULT (write operations are "
77
+ "still confirmed individually). Proceed with the implementation."
78
+ )
79
+ if decision.outcome == "approve-auto":
80
+ return (
81
+ "Plan approved with auto-accept. Permission mode is now AUTO (safe "
82
+ "commands run without prompts; dangerous ones are still blocked). "
83
+ "Proceed with the implementation."
84
+ )
85
+ if decision.outcome == "unavailable":
86
+ return (
87
+ "Plan approval is unavailable in a non-interactive environment. Staying in plan mode."
88
+ )
89
+ if decision.outcome == "noop":
90
+ return (
91
+ "Error: exit_plan_mode is only valid in plan mode, and you are not "
92
+ "currently in plan mode."
93
+ )
94
+ # Reject is the safety-net default for any unexpected outcome: staying in
95
+ # plan mode is the conservative choice (never auto-grant write access).
96
+ reason = decision.reason.strip()
97
+ if reason:
98
+ return (
99
+ f"The user rejected the plan. Reason: {reason}\n"
100
+ "You are still in plan mode. Revise the plan to address this feedback "
101
+ "and call exit_plan_mode again."
102
+ )
103
+ return (
104
+ "The user rejected the plan. You are still in plan mode. Revise the plan "
105
+ "and call exit_plan_mode again."
106
+ )