eljay-ai 1.1.0

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 (64) hide show
  1. package/AGENTS.md +57 -0
  2. package/README.md +479 -0
  3. package/agent/__init__.py +10 -0
  4. package/agent/agent.py +269 -0
  5. package/agent/agents/__init__.py +34 -0
  6. package/agent/agents/registry.py +212 -0
  7. package/agent/builtin_tools.py +591 -0
  8. package/agent/chat.py +354 -0
  9. package/agent/config.py +67 -0
  10. package/agent/context.py +63 -0
  11. package/agent/edits.py +167 -0
  12. package/agent/gitaware.py +126 -0
  13. package/agent/hardware.py +331 -0
  14. package/agent/knowledge.py +133 -0
  15. package/agent/memory.py +98 -0
  16. package/agent/ollama_client.py +133 -0
  17. package/agent/permissions.py +93 -0
  18. package/agent/providers/__init__.py +51 -0
  19. package/agent/providers/base.py +78 -0
  20. package/agent/providers/image.py +110 -0
  21. package/agent/providers/ollama.py +111 -0
  22. package/agent/providers/video.py +96 -0
  23. package/agent/providers/web.py +155 -0
  24. package/agent/router.py +200 -0
  25. package/agent/rules.py +52 -0
  26. package/agent/runner.py +168 -0
  27. package/agent/skills.py +161 -0
  28. package/agent/tools.py +102 -0
  29. package/agent/verification.py +80 -0
  30. package/agent/workspace.py +487 -0
  31. package/bin/eljay +5 -0
  32. package/bin/eljay.cmd +4 -0
  33. package/bin/myagent +4 -0
  34. package/bin/myagent.cmd +4 -0
  35. package/eljay-ai-1.1.0.tgz +0 -0
  36. package/eljay.js +56 -0
  37. package/eljay.py +150 -0
  38. package/install.ps1 +28 -0
  39. package/knowledge/reference/diffusers.md +32 -0
  40. package/knowledge/reference/video-providers.md +21 -0
  41. package/knowledge/setup/comfyui.md +35 -0
  42. package/knowledge/setup/image-providers.md +16 -0
  43. package/knowledge/test-category/test-entry.md +5 -0
  44. package/myagent.py +30 -0
  45. package/package.json +40 -0
  46. package/skills/coding/code-review.md +3 -0
  47. package/skills/coding/fix-attempt-protocol.md +9 -0
  48. package/skills/debugging/root-cause-analysis.md +9 -0
  49. package/skills/general/communication.md +7 -0
  50. package/skills/laravel/authentication.md +15 -0
  51. package/skills/mysql/performance.md +9 -0
  52. package/skills/php/standards.md +8 -0
  53. package/skills/react/component-best-practices.md +8 -0
  54. package/skills/research/source-tracking.md +7 -0
  55. package/skills/security/input-validation.md +9 -0
  56. package/skills/testing/pytest-best-practices.md +7 -0
  57. package/tests/run_all.py +34 -0
  58. package/tests/test_agent_core.py +385 -0
  59. package/tests/test_capabilities.py +169 -0
  60. package/tests/test_edits.py +117 -0
  61. package/tests/test_eljay.py +209 -0
  62. package/tests/test_runner.py +114 -0
  63. package/tests/test_universal.py +458 -0
  64. package/tests/test_workspace.py +221 -0
package/agent/chat.py ADDED
@@ -0,0 +1,354 @@
1
+ """Interactive terminal chat loop and one-shot mode.
2
+
3
+ Part of ElJay AI's universal assistant — supports coding, research, image
4
+ generation, video generation, and general knowledge through a single interface.
5
+ """
6
+
7
+ import sys
8
+
9
+ from .agent import AgentCore
10
+ from .config import SYSTEM_PROMPT
11
+ from .memory import Memory
12
+ from .ollama_client import OllamaError
13
+ from .permissions import DECISION_ALWAYS, DECISION_NO, DECISION_YES, PermissionPolicy
14
+ from .skills import Skills
15
+ from .knowledge import KnowledgeBase
16
+ from .tools import RISK_HIGH, Tool, ToolRegistry
17
+ from .verification import format_checks
18
+ from .workspace import Workspace
19
+
20
+ BANNER = r"""
21
+ ╔══════════════════════════════════════╗
22
+ ║ ElJay AI ║
23
+ ║ Universal AI Assistant ║
24
+ ╠══════════════════════════════════════╣
25
+ ║ Coding · Research · Image · Video ║
26
+ ║ General Knowledge · General Help ║
27
+ ╚══════════════════════════════════════╝
28
+ """
29
+
30
+ HELP = """Commands:
31
+ /help show this help
32
+ /tools list the tools the agent can call
33
+ /agents list specialized agents (Coding, Research, Image, ...)
34
+ /skills list loaded skills
35
+ /knowledge [query] search knowledge entries
36
+ /status show hardware, model, agents, tools
37
+ /providers show provider availability
38
+ /verify show the project's test/build commands
39
+ /memory show local memory; '/memory clear' erases it
40
+ /permissions show risk levels and session allowances
41
+ /permissions reset clear this session's allowances
42
+ /clear forget the conversation so far
43
+ /model [name] show or switch the active model
44
+ /exit leave the agent (also: /quit, Ctrl+C)"""
45
+
46
+
47
+ def ensure_utf8() -> None:
48
+ """Make sure we can print box-drawing characters on Windows consoles.
49
+
50
+ Windows terminals often default to cp1252, which cannot encode the banner.
51
+ Call this once at startup, before any output; calling it again is harmless.
52
+ """
53
+ for stream in (sys.stdout, sys.stderr):
54
+ try:
55
+ stream.reconfigure(encoding="utf-8") # type: ignore[union-attr]
56
+ except (AttributeError, ValueError):
57
+ pass
58
+
59
+
60
+ def ask_permission(tool: Tool, preview: str, risk: str = "") -> str:
61
+ """Ask the user before running a non-safe tool.
62
+
63
+ Returns a DECISION_* value. Denies on EOF/interrupt.
64
+ High-risk tools require typing 'yes' and never offer 'always'.
65
+ `risk` is the effective risk for this specific call.
66
+ """
67
+ risk = risk or tool.risk
68
+ print(f"\n Permission required: '{tool.name}' [{risk}]")
69
+ for line in preview.splitlines():
70
+ print(f" {line}")
71
+
72
+ if risk == RISK_HIGH:
73
+ print(" !! This is a HIGH-RISK action.")
74
+ try:
75
+ answer = input(" Type 'yes' (exactly) to proceed, anything else cancels: ")
76
+ except (EOFError, KeyboardInterrupt):
77
+ print()
78
+ return DECISION_NO
79
+ return DECISION_YES if answer.strip().lower() == "yes" else DECISION_NO
80
+
81
+ try:
82
+ answer = input(" Proceed? [y]es / [n]o / [a]lways (this session): ")
83
+ except (EOFError, KeyboardInterrupt):
84
+ print()
85
+ return DECISION_NO
86
+ answer = answer.strip().lower()
87
+ if answer in ("a", "always"):
88
+ return DECISION_ALWAYS
89
+ if answer in ("y", "yes"):
90
+ return DECISION_YES
91
+ return DECISION_NO
92
+
93
+
94
+ def print_permissions(policy: PermissionPolicy, registry: ToolRegistry) -> None:
95
+ """Show each tool's risk level and any session allowances."""
96
+ print("Tools and risk levels:")
97
+ for tool in registry.tools():
98
+ mark = " <- allowed this session" if policy.is_always_allowed(tool.name) else ""
99
+ print(f" {tool.name:<18} [{tool.risk}]{mark}")
100
+ mode = "ON (ask-level writes skip confirmation)" if policy.auto_approve else "off"
101
+ print(f"Auto-approve: {mode}")
102
+ if policy.session_allowed:
103
+ print("Session allowances: " + ", ".join(sorted(policy.session_allowed)))
104
+ else:
105
+ print("Session allowances: none")
106
+ print("Use /permissions reset to clear session allowances.")
107
+
108
+
109
+ def _new_messages() -> list[dict[str, str]]:
110
+ return [{"role": "system", "content": SYSTEM_PROMPT}]
111
+
112
+
113
+ def _print_tools(registry: ToolRegistry) -> None:
114
+ tools = registry.tools()
115
+ if not tools:
116
+ print("[no tools registered]")
117
+ return
118
+ print(f"Tools ({len(tools)}):")
119
+ for tool in tools:
120
+ print(f" {tool.name:<20} [{tool.risk}] {tool.description}")
121
+
122
+
123
+ def _print_agents(router=None) -> None:
124
+ """List all specialized agents."""
125
+ try:
126
+ from .agents import list_agents
127
+ except Exception:
128
+ print("[agents not available]")
129
+ return
130
+ agents = list_agents()
131
+ print(f"Specialized agents ({len(agents)}):")
132
+ for agent in agents:
133
+ tools_list = ", ".join(agent.tools[:8])
134
+ if len(agent.tools) > 8:
135
+ tools_list += f" (+{len(agent.tools) - 8} more)"
136
+ provs = ", ".join(agent.providers) if agent.providers else "none"
137
+ print(f" {agent.name:12s} — {agent.purpose}")
138
+ print(f" tools: {tools_list}")
139
+ print(f" providers: {provs}")
140
+ print(f" keywords: {', '.join(agent.keywords[:8])}")
141
+
142
+
143
+ def _print_skills(skills: Skills | None) -> None:
144
+ if skills is None:
145
+ print("[skills not loaded]")
146
+ return
147
+ all_skills = skills.all_skills()
148
+ if not all_skills:
149
+ print("[no skills found — create a skills/ directory to add skills]")
150
+ return
151
+ print(f"Loaded skills ({len(all_skills)}):")
152
+ by_cat = skills.by_category()
153
+ for cat, cats_skills in sorted(by_cat.items()):
154
+ print(f"\n [{cat}]")
155
+ for skill in cats_skills:
156
+ print(f" {skill.name:30s} — {skill.description}")
157
+
158
+
159
+ def _print_knowledge(knowledge: KnowledgeBase | None, query: str = "") -> None:
160
+ if knowledge is None:
161
+ print("[knowledge base not loaded]")
162
+ return
163
+ if query:
164
+ results = knowledge.search(query)
165
+ if not results:
166
+ print(f"No knowledge entries found for: {query}")
167
+ return
168
+ print(f"Knowledge matches for '{query}' ({len(results)}):")
169
+ for entry in results:
170
+ print(f" [{entry.category}/{entry.name}] {entry.description}")
171
+ else:
172
+ entries = knowledge.entries()
173
+ if not entries:
174
+ print("[knowledge base is empty]")
175
+ return
176
+ print(f"Knowledge entries ({len(entries)}):")
177
+ for entry in entries:
178
+ print(f" [{entry.category}/{entry.name}] {entry.description}")
179
+
180
+
181
+ def _print_providers(router=None) -> None:
182
+ """Show provider availability."""
183
+ try:
184
+ from .providers import PROVIDERS
185
+ except Exception:
186
+ print("[providers not available]")
187
+ return
188
+ print("Providers:")
189
+ for p in PROVIDERS:
190
+ print(f" {p.status_string()}")
191
+
192
+
193
+ def _print_status(
194
+ core: AgentCore,
195
+ registry: ToolRegistry,
196
+ workspace: Workspace | None,
197
+ router=None,
198
+ skills: Skills | None = None,
199
+ knowledge: KnowledgeBase | None = None,
200
+ hardware=None,
201
+ ) -> None:
202
+ """Print a comprehensive status snapshot."""
203
+ print("=" * 50)
204
+ print("ElJay AI — System Status")
205
+ print("=" * 50)
206
+ print(f"Model: {core.model}")
207
+
208
+ if workspace:
209
+ print(f"Project: {workspace.name}")
210
+ print(f"Root: {workspace.root}")
211
+ stack = workspace.detect_stack()
212
+ if stack:
213
+ print(f"Stack: {', '.join(stack)}")
214
+ else:
215
+ print("Project: [none]")
216
+
217
+ print(f"Tools: {len(registry)} available")
218
+
219
+ if router is not None:
220
+ _print_agents(router)
221
+ _print_providers(router)
222
+ else:
223
+ _print_agents()
224
+ _print_providers()
225
+
226
+ if skills is not None:
227
+ s = skills.summary()
228
+ print(f"Skills: {s}")
229
+ else:
230
+ print("Skills: [not loaded]")
231
+
232
+ if knowledge is not None:
233
+ k = knowledge.summary()
234
+ print(f"Knowledge: {k}")
235
+ else:
236
+ print("Knowledge: [not loaded]")
237
+
238
+ if hardware is not None:
239
+ print(f"\nHardware:")
240
+ print(f" OS: {hardware.os} {hardware.os_version} ({hardware.arch})")
241
+ print(f" CPU: {hardware.cpu_brand or 'unknown'} ({hardware.cpu_cores} cores)")
242
+ print(f" RAM: {hardware.ram_gb:.1f} GB")
243
+ print(f" GPU: {hardware.gpu}" + (f" ({hardware.vram_gb:.1f} GB VRAM)" if hardware.vram_gb else ""))
244
+ print(f" Capabilities:")
245
+ print(f" Coding/Research : {'yes' if hardware.can_code else 'no'}")
246
+ print(f" Image generation: {'yes' if hardware.can_image else 'no'}")
247
+ print(f" Video generation: {'yes' if hardware.can_video else 'no'}")
248
+ for n in hardware.notes:
249
+ print(f" note: {n}")
250
+ print("=" * 50)
251
+
252
+
253
+ def run_chat(
254
+ core: AgentCore,
255
+ registry: ToolRegistry,
256
+ workspace: Workspace | None = None,
257
+ memory: Memory | None = None,
258
+ router=None,
259
+ skills: Skills | None = None,
260
+ knowledge: KnowledgeBase | None = None,
261
+ hardware=None,
262
+ ) -> None:
263
+ ensure_utf8()
264
+ messages = _new_messages()
265
+
266
+ while True:
267
+ try:
268
+ user_input = input("\nyou> ").strip()
269
+ except (EOFError, KeyboardInterrupt):
270
+ print("\nGoodbye.")
271
+ return
272
+
273
+ if not user_input:
274
+ continue
275
+ if user_input in ("/exit", "/quit"):
276
+ print("Goodbye.")
277
+ return
278
+ if user_input == "/help":
279
+ print(HELP)
280
+ continue
281
+ if user_input == "/tools":
282
+ _print_tools(registry)
283
+ continue
284
+ if user_input == "/agents":
285
+ _print_agents(router)
286
+ continue
287
+ if user_input == "/skills":
288
+ _print_skills(skills)
289
+ continue
290
+ if user_input.startswith("/knowledge"):
291
+ parts = user_input.split(maxsplit=1)
292
+ query = parts[1] if len(parts) > 1 else ""
293
+ _print_knowledge(knowledge, query)
294
+ continue
295
+ if user_input == "/providers":
296
+ _print_providers(router)
297
+ continue
298
+ if user_input == "/status":
299
+ _print_status(core, registry, workspace, router, skills, knowledge, hardware)
300
+ continue
301
+ if user_input == "/verify":
302
+ if workspace is None:
303
+ print("[no workspace]")
304
+ else:
305
+ print(format_checks(workspace))
306
+ continue
307
+ if user_input.startswith("/memory"):
308
+ parts = user_input.split()
309
+ if len(parts) > 1 and parts[1] == "clear":
310
+ if memory is not None:
311
+ memory.clear()
312
+ print("[local memory cleared]")
313
+ elif memory is None:
314
+ print("[no memory]")
315
+ else:
316
+ print(memory.summary())
317
+ continue
318
+ if user_input.startswith("/permissions"):
319
+ parts = user_input.split()
320
+ if len(parts) > 1 and parts[1] == "reset":
321
+ core.policy.reset()
322
+ print("[session allowances cleared]")
323
+ else:
324
+ print_permissions(core.policy, registry)
325
+ continue
326
+ if user_input == "/clear":
327
+ messages = _new_messages()
328
+ print("[history cleared]")
329
+ continue
330
+ if user_input.startswith("/model"):
331
+ parts = user_input.split(maxsplit=1)
332
+ if len(parts) == 2 and parts[1].strip():
333
+ core.model = parts[1].strip()
334
+ print(f"[model: {core.model}]")
335
+ continue
336
+
337
+ messages.append({"role": "user", "content": user_input})
338
+ _print_reply(core, messages)
339
+ if memory is not None:
340
+ memory.save_history(messages)
341
+
342
+
343
+ def run_once(core: AgentCore, prompt: str) -> int:
344
+ """Non-interactive mode: send one prompt, print the reply, exit."""
345
+ ensure_utf8()
346
+ messages = _new_messages()
347
+ messages.append({"role": "user", "content": prompt})
348
+ try:
349
+ reply = core.run(messages)
350
+ except OllamaError as exc:
351
+ sys.stderr.write(f"[error] {exc}\n")
352
+ return 1
353
+ print(reply)
354
+ return 0
@@ -0,0 +1,67 @@
1
+ """Configuration for the local coding agent.
2
+
3
+ Everything here is overridable via environment variables so the agent stays
4
+ flexible without code changes. No external config files yet (that comes later).
5
+ """
6
+
7
+ import os
8
+
9
+ # Where Ollama's HTTP API listens. Ollama sets OLLAMA_HOST sometimes without a
10
+ # scheme, so we normalise it below.
11
+ _RAW_HOST = os.environ.get("OLLAMA_HOST", "http://127.0.0.1:11434")
12
+
13
+
14
+ def _normalise_host(host: str) -> str:
15
+ host = host.strip()
16
+ if not host:
17
+ return "http://127.0.0.1:11434"
18
+ if not host.startswith(("http://", "https://")):
19
+ host = "http://" + host
20
+ return host.rstrip("/")
21
+
22
+
23
+ OLLAMA_HOST = _normalise_host(_RAW_HOST)
24
+
25
+ # The model to use. Defaults to the coding model verified in Phase 1.
26
+ DEFAULT_MODEL = os.environ.get("AGENT_MODEL", "qwen2.5-coder:3b")
27
+
28
+ # How long (seconds) to wait for a single model response before giving up.
29
+ REQUEST_TIMEOUT = int(os.environ.get("AGENT_TIMEOUT", "300"))
30
+
31
+ # Maximum tool-calling steps per user turn, to prevent endless loops.
32
+ MAX_STEPS = int(os.environ.get("AGENT_MAX_STEPS", "6"))
33
+
34
+ # Character budget for the conversation sent to the model; older turns are
35
+ # trimmed first. Keeps context small (we never send the whole project).
36
+ MAX_CONTEXT_CHARS = int(os.environ.get("AGENT_MAX_CONTEXT_CHARS", "16000"))
37
+
38
+ # When true, non-safe tools (file writes) run WITHOUT asking for confirmation.
39
+ # Dangerous and OFF by default. Set AGENT_AUTO_APPROVE=1 only if you accept that.
40
+ AUTO_APPROVE = os.environ.get("AGENT_AUTO_APPROVE", "").strip().lower() in (
41
+ "1",
42
+ "true",
43
+ "yes",
44
+ "on",
45
+ )
46
+
47
+ # System prompt that frames the assistant and teaches the tool protocol.
48
+ # Wording here matters: a too-eager prompt makes small models (e.g. 3B) emit
49
+ # bogus tool calls for ordinary questions. Keep "only when needed" explicit.
50
+ # Later phases inject project rules (AGENTS.md) and gathered context here.
51
+ SYSTEM_PROMPT = (
52
+ "You are ElJay AI, a local, universal AI assistant running in the user's "
53
+ "terminal. You help with coding, research, image generation, video "
54
+ "generation, and general knowledge — all powered by local models via "
55
+ "Ollama and free web search. Be concise and practical.\n\n"
56
+ "You have tools available. Call a tool ONLY when the request needs live "
57
+ "information or access to the user's machine or project that you cannot "
58
+ "know otherwise (for example the current time, a file on disk, or a web "
59
+ "search result). For general knowledge questions that a coding model can "
60
+ "answer from training, respond directly in plain language.\n\n"
61
+ "To call a tool, reply with ONLY this JSON and nothing else: "
62
+ '{"name": "<tool_name>", "arguments": {...}}. '
63
+ "Never output a JSON object unless you are calling a tool. "
64
+ "Never invent file contents, paths, or command output. "
65
+ "If a capability is not available on this hardware, explain what is "
66
+ "needed and how to set it up — never pretend it succeeded."
67
+ )
@@ -0,0 +1,63 @@
1
+ """Context management: keep what we send to the model small and relevant.
2
+
3
+ We never send the whole project. Tools gather only what is needed, and this
4
+ module keeps the growing conversation within a character budget by dropping the
5
+ oldest turns first.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+
13
+ def message_size(messages: list[dict[str, Any]]) -> int:
14
+ return sum(len(str(m.get("content", ""))) for m in messages)
15
+
16
+
17
+ def trim_messages(
18
+ messages: list[dict[str, Any]], max_chars: int
19
+ ) -> list[dict[str, Any]]:
20
+ """Return a copy of `messages` within `max_chars`.
21
+
22
+ Always keeps the system prompt and the most recent turns. When older turns
23
+ are dropped, a short note is appended to the system prompt.
24
+ """
25
+ if max_chars <= 0 or message_size(messages) <= max_chars:
26
+ return list(messages)
27
+
28
+ system = [m for m in messages if m.get("role") == "system"][:1]
29
+ rest = [m for m in messages if m.get("role") != "system"]
30
+
31
+ kept: list[dict[str, Any]] = []
32
+ total = message_size(system)
33
+ for message in reversed(rest):
34
+ size = len(str(message.get("content", "")))
35
+ if total + size > max_chars and kept:
36
+ break
37
+ kept.append(message)
38
+ total += size
39
+ kept.reverse()
40
+
41
+ dropped = len(rest) - len(kept)
42
+ out: list[dict[str, Any]] = []
43
+ if system:
44
+ base = dict(system[0])
45
+ if dropped > 0:
46
+ base["content"] = (
47
+ f"{base.get('content', '')}\n\n"
48
+ f"[context] {dropped} earlier message(s) were omitted to stay "
49
+ "within the context budget."
50
+ )
51
+ out.append(base)
52
+ elif dropped > 0:
53
+ out.append(
54
+ {
55
+ "role": "system",
56
+ "content": (
57
+ f"[context] {dropped} earlier message(s) were omitted to stay "
58
+ "within the context budget."
59
+ ),
60
+ }
61
+ )
62
+ out.extend(kept)
63
+ return out
package/agent/edits.py ADDED
@@ -0,0 +1,167 @@
1
+ """File modification — the ONLY module that writes to the project.
2
+
3
+ Design rules (safety first):
4
+ - `create_file` only ever creates NEW files; it refuses to overwrite.
5
+ - `edit_file` replaces exact text; it refuses to guess and refuses ambiguous matches.
6
+ - There is deliberately NO delete, rename, or move capability.
7
+ - Every function assumes the caller has ALREADY obtained permission. The
8
+ permission check lives in `permissions.py` / the agent core, not here.
9
+
10
+ Paths are confined to the project root via the Workspace.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from .workspace import Workspace, human_size
16
+
17
+ # Refuse to write files larger than this.
18
+ MAX_WRITE_BYTES = 1_000_000
19
+
20
+ # How many lines to show in a preview.
21
+ PREVIEW_LINES = 20
22
+
23
+
24
+ def _is_new_path(rel: str) -> bool:
25
+ return bool(rel and str(rel).strip() not in ("", ".", "./"))
26
+
27
+
28
+ def create_file(ws: Workspace, rel: str, content: str) -> str:
29
+ """Create a NEW file. Refuses to overwrite an existing file."""
30
+ if not _is_new_path(rel):
31
+ return "ERROR: a file path is required"
32
+ if not isinstance(content, str):
33
+ return "ERROR: content must be a string"
34
+
35
+ try:
36
+ path = ws.resolve(rel)
37
+ except ValueError as exc:
38
+ return f"ERROR: {exc}"
39
+
40
+ if path.exists():
41
+ return (
42
+ f"ERROR: '{rel}' already exists. Use edit_file to change an existing "
43
+ "file; overwriting is not allowed."
44
+ )
45
+
46
+ data = content.encode("utf-8")
47
+ if len(data) > MAX_WRITE_BYTES:
48
+ return (
49
+ f"ERROR: content is {human_size(len(data))}, larger than the "
50
+ f"{human_size(MAX_WRITE_BYTES)} write limit."
51
+ )
52
+
53
+ if not path.parent.exists():
54
+ try:
55
+ path.parent.mkdir(parents=True, exist_ok=True)
56
+ except OSError as exc:
57
+ return f"ERROR: cannot create folder for '{rel}': {exc}"
58
+
59
+ try:
60
+ path.write_text(content, encoding="utf-8")
61
+ except OSError as exc:
62
+ return f"ERROR: cannot write '{rel}': {exc}"
63
+
64
+ line_count = content.count("\n") + (0 if content.endswith("\n") else 1)
65
+ return f"Created {rel} ({human_size(len(data))}, {line_count} line(s))."
66
+
67
+
68
+ def edit_file(
69
+ ws: Workspace,
70
+ rel: str,
71
+ old_text: str,
72
+ new_text: str,
73
+ replace_all: bool = False,
74
+ ) -> str:
75
+ """Replace exact text in an existing file. Refuses ambiguous/absent matches."""
76
+ if not isinstance(old_text, str) or not isinstance(new_text, str):
77
+ return "ERROR: old_text and new_text must be strings"
78
+ if old_text == "":
79
+ return "ERROR: old_text must not be empty"
80
+ if not _is_new_path(rel):
81
+ return "ERROR: a file path is required"
82
+
83
+ try:
84
+ path = ws.resolve(rel)
85
+ except ValueError as exc:
86
+ return f"ERROR: {exc}"
87
+
88
+ if not path.exists():
89
+ return f"ERROR: file does not exist: {rel}. Use create_file to make a new file."
90
+ if path.is_dir():
91
+ return f"ERROR: '{rel}' is a directory"
92
+
93
+ try:
94
+ size = path.stat().st_size
95
+ except OSError as exc:
96
+ return f"ERROR: cannot read '{rel}': {exc}"
97
+ if size > MAX_WRITE_BYTES:
98
+ return (
99
+ f"ERROR: '{rel}' is {human_size(size)}, too large to edit safely "
100
+ f"(limit {human_size(MAX_WRITE_BYTES)})."
101
+ )
102
+
103
+ try:
104
+ with path.open("rb") as fh:
105
+ if b"\x00" in fh.read(2048):
106
+ return f"ERROR: '{rel}' looks like a binary file; refusing to edit it."
107
+ except OSError as exc:
108
+ return f"ERROR: cannot read '{rel}': {exc}"
109
+
110
+ try:
111
+ text = path.read_text(encoding="utf-8")
112
+ except (OSError, UnicodeDecodeError) as exc:
113
+ return f"ERROR: cannot read '{rel}': {exc}"
114
+
115
+ count = text.count(old_text)
116
+ if count == 0:
117
+ return (
118
+ f"ERROR: the text to replace was not found in '{rel}'. "
119
+ "Read the file first to get the exact text."
120
+ )
121
+ if count > 1 and not replace_all:
122
+ return (
123
+ f"ERROR: found {count} occurrences of that text in '{rel}'. "
124
+ "Include more surrounding text to make it unique, or set "
125
+ "replace_all=true to change them all."
126
+ )
127
+
128
+ updated = text.replace(old_text, new_text) if replace_all else text.replace(old_text, new_text, 1)
129
+ try:
130
+ path.write_text(updated, encoding="utf-8")
131
+ except OSError as exc:
132
+ return f"ERROR: cannot write '{rel}': {exc}"
133
+
134
+ replaced = count if replace_all else 1
135
+ return f"Updated {rel}: replaced {replaced} occurrence(s)."
136
+
137
+
138
+ # -- previews (shown before asking for permission) -------------------------
139
+
140
+
141
+ def _block(sign: str, lines: list[str]) -> str:
142
+ shown = lines[:PREVIEW_LINES]
143
+ text = "\n".join(f" {sign} {line}" for line in shown)
144
+ remaining = len(lines) - len(shown)
145
+ if remaining > 0:
146
+ text += f"\n {sign} ... ({remaining} more line(s))"
147
+ return text
148
+
149
+
150
+ def preview_create(rel: str, content: str = "") -> str:
151
+ lines = content.splitlines() if isinstance(content, str) else []
152
+ header = f"CREATE {rel} ({len(lines)} line(s))"
153
+ return header + "\n" + _block("+", lines)
154
+
155
+
156
+ def preview_edit(
157
+ rel: str, old_text: str = "", new_text: str = "", replace_all: bool = False
158
+ ) -> str:
159
+ old_lines = old_text.splitlines() if isinstance(old_text, str) else []
160
+ new_lines = new_text.splitlines() if isinstance(new_text, str) else []
161
+ suffix = " (replace all)" if replace_all else ""
162
+ return (
163
+ f"EDIT {rel}{suffix}\n"
164
+ + _block("-", old_lines)
165
+ + "\n"
166
+ + _block("+", new_lines)
167
+ )