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.
- package/AGENTS.md +57 -0
- package/README.md +479 -0
- package/agent/__init__.py +10 -0
- package/agent/agent.py +269 -0
- package/agent/agents/__init__.py +34 -0
- package/agent/agents/registry.py +212 -0
- package/agent/builtin_tools.py +591 -0
- package/agent/chat.py +354 -0
- package/agent/config.py +67 -0
- package/agent/context.py +63 -0
- package/agent/edits.py +167 -0
- package/agent/gitaware.py +126 -0
- package/agent/hardware.py +331 -0
- package/agent/knowledge.py +133 -0
- package/agent/memory.py +98 -0
- package/agent/ollama_client.py +133 -0
- package/agent/permissions.py +93 -0
- package/agent/providers/__init__.py +51 -0
- package/agent/providers/base.py +78 -0
- package/agent/providers/image.py +110 -0
- package/agent/providers/ollama.py +111 -0
- package/agent/providers/video.py +96 -0
- package/agent/providers/web.py +155 -0
- package/agent/router.py +200 -0
- package/agent/rules.py +52 -0
- package/agent/runner.py +168 -0
- package/agent/skills.py +161 -0
- package/agent/tools.py +102 -0
- package/agent/verification.py +80 -0
- package/agent/workspace.py +487 -0
- package/bin/eljay +5 -0
- package/bin/eljay.cmd +4 -0
- package/bin/myagent +4 -0
- package/bin/myagent.cmd +4 -0
- package/eljay-ai-1.1.0.tgz +0 -0
- package/eljay.js +56 -0
- package/eljay.py +150 -0
- package/install.ps1 +28 -0
- package/knowledge/reference/diffusers.md +32 -0
- package/knowledge/reference/video-providers.md +21 -0
- package/knowledge/setup/comfyui.md +35 -0
- package/knowledge/setup/image-providers.md +16 -0
- package/knowledge/test-category/test-entry.md +5 -0
- package/myagent.py +30 -0
- package/package.json +40 -0
- package/skills/coding/code-review.md +3 -0
- package/skills/coding/fix-attempt-protocol.md +9 -0
- package/skills/debugging/root-cause-analysis.md +9 -0
- package/skills/general/communication.md +7 -0
- package/skills/laravel/authentication.md +15 -0
- package/skills/mysql/performance.md +9 -0
- package/skills/php/standards.md +8 -0
- package/skills/react/component-best-practices.md +8 -0
- package/skills/research/source-tracking.md +7 -0
- package/skills/security/input-validation.md +9 -0
- package/skills/testing/pytest-best-practices.md +7 -0
- package/tests/run_all.py +34 -0
- package/tests/test_agent_core.py +385 -0
- package/tests/test_capabilities.py +169 -0
- package/tests/test_edits.py +117 -0
- package/tests/test_eljay.py +209 -0
- package/tests/test_runner.py +114 -0
- package/tests/test_universal.py +458 -0
- 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
|
package/agent/config.py
ADDED
|
@@ -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
|
+
)
|
package/agent/context.py
ADDED
|
@@ -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
|
+
)
|