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
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Read-only Git awareness.
|
|
2
|
+
|
|
3
|
+
The agent may INSPECT the repository (branch, changed files) but never commits,
|
|
4
|
+
pushes, resets, or cleans. Only read-only git subcommands are invoked here.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import subprocess
|
|
10
|
+
|
|
11
|
+
from .workspace import Workspace
|
|
12
|
+
|
|
13
|
+
# Only these read-only subcommands are ever run from this module.
|
|
14
|
+
_ALLOWED = ("status", "branch", "rev-parse", "log", "diff", "remote")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def is_git_repo(ws: Workspace) -> bool:
|
|
18
|
+
return (ws.root / ".git").exists()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _git(ws: Workspace, args: list[str], timeout: int = 20):
|
|
22
|
+
if args and args[0] not in _ALLOWED:
|
|
23
|
+
return None, f"refused: '{args[0]}' is not a read-only git subcommand"
|
|
24
|
+
try:
|
|
25
|
+
proc = subprocess.run(
|
|
26
|
+
["git", *args],
|
|
27
|
+
cwd=str(ws.root),
|
|
28
|
+
capture_output=True,
|
|
29
|
+
text=True,
|
|
30
|
+
encoding="utf-8",
|
|
31
|
+
errors="replace",
|
|
32
|
+
timeout=timeout,
|
|
33
|
+
)
|
|
34
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
35
|
+
return None, str(exc)
|
|
36
|
+
return proc, None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def branch(ws: Workspace) -> str:
|
|
40
|
+
if not is_git_repo(ws):
|
|
41
|
+
return ""
|
|
42
|
+
proc, err = _git(ws, ["rev-parse", "--abbrev-ref", "HEAD"])
|
|
43
|
+
if err or proc is None or proc.returncode != 0:
|
|
44
|
+
return ""
|
|
45
|
+
return proc.stdout.strip()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def status(ws: Workspace) -> str:
|
|
49
|
+
"""Return a readable git status, or a clear message if not a repo."""
|
|
50
|
+
if not is_git_repo(ws):
|
|
51
|
+
return "This folder is not a git repository."
|
|
52
|
+
proc, err = _git(ws, ["status", "--short", "--branch"])
|
|
53
|
+
if err:
|
|
54
|
+
return f"ERROR: could not run git: {err}"
|
|
55
|
+
if proc is None:
|
|
56
|
+
return "ERROR: could not run git."
|
|
57
|
+
if proc.returncode != 0:
|
|
58
|
+
detail = (proc.stderr or "").strip()
|
|
59
|
+
return f"ERROR: git status failed: {detail}"
|
|
60
|
+
|
|
61
|
+
output = proc.stdout.strip()
|
|
62
|
+
if not output:
|
|
63
|
+
return "Working tree is clean."
|
|
64
|
+
|
|
65
|
+
lines = output.splitlines()
|
|
66
|
+
changed = [line for line in lines if not line.startswith("##")]
|
|
67
|
+
header = lines[0] if lines and lines[0].startswith("##") else ""
|
|
68
|
+
parts = [f"Git status ({header[3:].strip() if header else 'unknown branch'}):"]
|
|
69
|
+
if changed:
|
|
70
|
+
parts.append(f"Changed files ({len(changed)}):")
|
|
71
|
+
parts.extend(f" {line}" for line in changed)
|
|
72
|
+
else:
|
|
73
|
+
parts.append("No changed files.")
|
|
74
|
+
return "\n".join(parts)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def git_log(ws: Workspace, n: int = 10) -> str:
|
|
78
|
+
"""Show the last *n* git log entries (read-only)."""
|
|
79
|
+
if not is_git_repo(ws):
|
|
80
|
+
return "This folder is not a git repository."
|
|
81
|
+
proc, err = _git(ws, ["log", f"-{n}", "--oneline", "--decorate"])
|
|
82
|
+
if err:
|
|
83
|
+
return f"ERROR: could not run git: {err}"
|
|
84
|
+
if proc is None or proc.returncode != 0:
|
|
85
|
+
detail = (proc.stderr if proc else "") or ""
|
|
86
|
+
return f"ERROR: git log failed: {detail}"
|
|
87
|
+
output = proc.stdout.strip()
|
|
88
|
+
if not output:
|
|
89
|
+
return "No commits yet."
|
|
90
|
+
lines = [f"Recent commits (last {n}):"]
|
|
91
|
+
lines.extend(f" {line}" for line in output.splitlines())
|
|
92
|
+
return "\n".join(lines)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def git_diff(ws: Workspace, target: str = "", n: int = 3) -> str:
|
|
96
|
+
"""Show uncommitted changes or a diff of *target* (read-only)."""
|
|
97
|
+
if not is_git_repo(ws):
|
|
98
|
+
return "This folder is not a git repository."
|
|
99
|
+
args = ["diff", "--stat"]
|
|
100
|
+
if target:
|
|
101
|
+
args = ["diff", f"-{n}", target]
|
|
102
|
+
proc, err = _git(ws, args)
|
|
103
|
+
if err:
|
|
104
|
+
return f"ERROR: could not run git: {err}"
|
|
105
|
+
if proc is None or proc.returncode != 0:
|
|
106
|
+
detail = (proc.stderr if proc else "") or ""
|
|
107
|
+
return f"ERROR: git diff failed: {detail}"
|
|
108
|
+
output = proc.stdout.strip()
|
|
109
|
+
if not output:
|
|
110
|
+
return "No changes to show (working tree clean or no diff for that target)."
|
|
111
|
+
lines = [f"Git diff ({' '.join(args[1:]) or 'uncommitted'}):"]
|
|
112
|
+
lines.extend(f" {line}" for line in output.splitlines())
|
|
113
|
+
return "\n".join(lines)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def changed_files(ws: Workspace) -> list[str]:
|
|
117
|
+
if not is_git_repo(ws):
|
|
118
|
+
return []
|
|
119
|
+
proc, err = _git(ws, ["status", "--porcelain"])
|
|
120
|
+
if err or proc is None or proc.returncode != 0:
|
|
121
|
+
return []
|
|
122
|
+
files = []
|
|
123
|
+
for line in proc.stdout.splitlines():
|
|
124
|
+
if len(line) > 3:
|
|
125
|
+
files.append(line[3:].strip())
|
|
126
|
+
return files
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""Hardware detection for ElJay AI.
|
|
2
|
+
|
|
3
|
+
Detects practical hardware constraints that determine whether local AI
|
|
4
|
+
tasks (especially image/video generation) are feasible. Uses stdlib and
|
|
5
|
+
platform commands only — no third-party packages.
|
|
6
|
+
|
|
7
|
+
Reports **total** VRAM *and* **currently available (free)** VRAM separately,
|
|
8
|
+
so the caller can make hardware-aware decisions:
|
|
9
|
+
|
|
10
|
+
High-end GPU → large models
|
|
11
|
+
Mid-range GPU → medium / quantized models
|
|
12
|
+
~4 GB VRAM → small / quantized models or CPU offloading
|
|
13
|
+
Low VRAM → CPU / system RAM where practical
|
|
14
|
+
No GPU → CPU-only mode
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import platform
|
|
21
|
+
import shutil
|
|
22
|
+
import subprocess
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from typing import Optional
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class HardwareInfo:
|
|
29
|
+
"""Structured hardware profile relevant to local AI."""
|
|
30
|
+
|
|
31
|
+
os: str = ""
|
|
32
|
+
os_version: str = ""
|
|
33
|
+
arch: str = ""
|
|
34
|
+
cpu_brand: str = ""
|
|
35
|
+
cpu_cores: int = 0
|
|
36
|
+
ram_gb: float = 0.0
|
|
37
|
+
disk_free_gb: float = 0.0
|
|
38
|
+
gpu: str = "none"
|
|
39
|
+
# Total VRAM and currently-available (free) VRAM, both in GB.
|
|
40
|
+
vram_total_gb: float = 0.0
|
|
41
|
+
vram_available_gb: float = 0.0
|
|
42
|
+
# Derived readiness flags
|
|
43
|
+
can_code: bool = False # text generation (CPU-capable)
|
|
44
|
+
can_research: bool = False # web search (network-capable)
|
|
45
|
+
can_image: bool = False # local image generation
|
|
46
|
+
can_video: bool = False # local video generation (needs VRAM)
|
|
47
|
+
notes: list[str] = field(default_factory=list)
|
|
48
|
+
|
|
49
|
+
# Convenience aliases for backward compatibility.
|
|
50
|
+
@property
|
|
51
|
+
def vram_gb(self) -> float:
|
|
52
|
+
"""Alias for total VRAM (kept for simpler banners)."""
|
|
53
|
+
return self.vram_total_gb
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def available_vram_gb(self) -> float:
|
|
57
|
+
"""Currently free VRAM in GB."""
|
|
58
|
+
return self.vram_available_gb
|
|
59
|
+
|
|
60
|
+
def recommended_image_mode(self) -> str:
|
|
61
|
+
"""Return a human-readable recommendation for image generation."""
|
|
62
|
+
vram = self.vram_available_gb
|
|
63
|
+
if vram >= 8.0:
|
|
64
|
+
return "GPU large-model (full precision)"
|
|
65
|
+
if vram >= 4.0:
|
|
66
|
+
return "GPU medium/quantized model"
|
|
67
|
+
if self.gpu != "none" and self.ram_gb >= 16.0:
|
|
68
|
+
return "CPU offload with RAM"
|
|
69
|
+
if self.cpu_cores >= 6 and self.ram_gb >= 12.0:
|
|
70
|
+
return "CPU model (slow)"
|
|
71
|
+
return "No local option — use a free external provider"
|
|
72
|
+
|
|
73
|
+
def recommended_video_mode(self) -> str:
|
|
74
|
+
"""Return a recommendation for video generation."""
|
|
75
|
+
vram = self.vram_available_gb
|
|
76
|
+
if vram >= 8.0:
|
|
77
|
+
return "GPU local video (e.g. Wan-2.1 / AnimateDiff)"
|
|
78
|
+
if vram >= 6.0:
|
|
79
|
+
return "GPU local video (limited resolution)"
|
|
80
|
+
if vram > 0.0:
|
|
81
|
+
return "GPU VRAM insufficient for local video"
|
|
82
|
+
return "No GPU — use a free external provider or CPU (slow)"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _detect_os() -> tuple[str, str]:
|
|
86
|
+
return platform.system(), platform.release()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _detect_cpu() -> tuple[str, int]:
|
|
90
|
+
brand = "unknown"
|
|
91
|
+
cores = os.cpu_count() or 1
|
|
92
|
+
|
|
93
|
+
if platform.system() == "Windows":
|
|
94
|
+
try:
|
|
95
|
+
out = subprocess.check_output(
|
|
96
|
+
"wmic cpu get name", shell=True, timeout=5,
|
|
97
|
+
stderr=subprocess.DEVNULL,
|
|
98
|
+
).decode("utf-8", errors="replace").strip()
|
|
99
|
+
lines = [l.strip() for l in out.splitlines() if l.strip()]
|
|
100
|
+
if len(lines) > 1:
|
|
101
|
+
brand = lines[1]
|
|
102
|
+
except Exception:
|
|
103
|
+
pass
|
|
104
|
+
else:
|
|
105
|
+
if os.path.exists("/proc/cpuinfo"):
|
|
106
|
+
try:
|
|
107
|
+
with open("/proc/cpuinfo") as f:
|
|
108
|
+
for line in f:
|
|
109
|
+
if line.startswith("model name"):
|
|
110
|
+
brand = line.split(":", 1)[1].strip()
|
|
111
|
+
break
|
|
112
|
+
except Exception:
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
return brand, cores
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _detect_ram() -> float:
|
|
119
|
+
"""Return total system RAM in GB."""
|
|
120
|
+
if platform.system() == "Windows":
|
|
121
|
+
try:
|
|
122
|
+
out = subprocess.check_output(
|
|
123
|
+
"wmic computersystem get TotalPhysicalMemory",
|
|
124
|
+
shell=True, timeout=5, stderr=subprocess.DEVNULL,
|
|
125
|
+
).decode("utf-8", errors="replace").strip()
|
|
126
|
+
lines = [l.strip() for l in out.splitlines() if l.strip()]
|
|
127
|
+
if len(lines) > 1:
|
|
128
|
+
return round(int(lines[1]) / (1024 ** 3), 1)
|
|
129
|
+
except Exception:
|
|
130
|
+
pass
|
|
131
|
+
else:
|
|
132
|
+
try:
|
|
133
|
+
with open("/proc/meminfo") as f:
|
|
134
|
+
for line in f:
|
|
135
|
+
if line.startswith("MemTotal:"):
|
|
136
|
+
kb = int(line.split()[1])
|
|
137
|
+
return round(kb / (1024 * 1024), 1)
|
|
138
|
+
except Exception:
|
|
139
|
+
pass
|
|
140
|
+
return 0.0
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _detect_disk() -> float:
|
|
144
|
+
"""Return free disk space in GB at the project root."""
|
|
145
|
+
try:
|
|
146
|
+
st = os.statvfs(".") if platform.system() != "Windows" else None
|
|
147
|
+
except Exception:
|
|
148
|
+
st = None
|
|
149
|
+
|
|
150
|
+
if st:
|
|
151
|
+
return round(st.f_bavail * st.f_frsize / (1024 ** 3), 1)
|
|
152
|
+
|
|
153
|
+
if platform.system() == "Windows":
|
|
154
|
+
try:
|
|
155
|
+
out = subprocess.check_output(
|
|
156
|
+
"dir", shell=True, timeout=5, stderr=subprocess.DEVNULL,
|
|
157
|
+
).decode("utf-8", errors="replace")
|
|
158
|
+
for line in out.splitlines():
|
|
159
|
+
if "bytes free" in line.lower():
|
|
160
|
+
parts = line.split()
|
|
161
|
+
for p in parts:
|
|
162
|
+
p = p.replace(",", "")
|
|
163
|
+
if p.isdigit():
|
|
164
|
+
return round(int(p) / (1024 ** 3), 1)
|
|
165
|
+
except Exception:
|
|
166
|
+
pass
|
|
167
|
+
return 0.0
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _detect_gpu() -> tuple[str, float, float]:
|
|
171
|
+
"""Return (gpu_name, total_vram_gb, available_vram_gb).
|
|
172
|
+
|
|
173
|
+
Uses nvidia-smi which reports both total and free VRAM.
|
|
174
|
+
Returns ("none", 0.0, 0.0) if no GPU is found.
|
|
175
|
+
"""
|
|
176
|
+
# Try NVIDIA first (most common for AI).
|
|
177
|
+
if shutil.which("nvidia-smi"):
|
|
178
|
+
try:
|
|
179
|
+
out = subprocess.check_output(
|
|
180
|
+
["nvidia-smi",
|
|
181
|
+
"--query-gpu=name,memory.total,memory.free",
|
|
182
|
+
"--format=csv,noheader"],
|
|
183
|
+
timeout=5, stderr=subprocess.DEVNULL,
|
|
184
|
+
).decode("utf-8", errors="replace").strip()
|
|
185
|
+
if out:
|
|
186
|
+
parts = out.split(",")
|
|
187
|
+
name = parts[0].strip()
|
|
188
|
+
total_mb = int(parts[1].strip()) if len(parts) > 1 else 0
|
|
189
|
+
free_mb = int(parts[2].strip()) if len(parts) > 2 else 0
|
|
190
|
+
return name, round(total_mb / 1024, 1), round(free_mb / 1024, 1)
|
|
191
|
+
except Exception:
|
|
192
|
+
pass
|
|
193
|
+
|
|
194
|
+
# Try dxdiag (Windows, captures Intel/AMD too) — total only, no free.
|
|
195
|
+
if platform.system() == "Windows" and shutil.which("dxdiag"):
|
|
196
|
+
try:
|
|
197
|
+
out = subprocess.check_output(
|
|
198
|
+
"dxdiag /t /d 2>nul & type dxdiag.txt",
|
|
199
|
+
shell=True, timeout=10, stderr=subprocess.DEVNULL,
|
|
200
|
+
).decode("utf-8", errors="replace").strip()
|
|
201
|
+
for line in out.splitlines():
|
|
202
|
+
if "chip type" in line.lower() or "card name" in line.lower():
|
|
203
|
+
name = line.split(":", 1)[1].strip()
|
|
204
|
+
if name and name != "n/a":
|
|
205
|
+
return name, 0.0, 0.0
|
|
206
|
+
except Exception:
|
|
207
|
+
pass
|
|
208
|
+
|
|
209
|
+
return "none", 0.0, 0.0
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def detect_hardware() -> HardwareInfo:
|
|
213
|
+
"""Probe the system and return a HardwareInfo profile."""
|
|
214
|
+
info = HardwareInfo()
|
|
215
|
+
|
|
216
|
+
info.os, info.os_version = _detect_os()
|
|
217
|
+
info.arch = platform.machine()
|
|
218
|
+
info.cpu_brand, info.cpu_cores = _detect_cpu()
|
|
219
|
+
info.ram_gb = _detect_ram()
|
|
220
|
+
info.disk_free_gb = _detect_disk()
|
|
221
|
+
info.gpu, info.vram_total_gb, info.vram_available_gb = _detect_gpu()
|
|
222
|
+
|
|
223
|
+
# --- Derive capability flags ----------------------------------------
|
|
224
|
+
|
|
225
|
+
# Text generation / coding: any modern CPU can run small models via Ollama.
|
|
226
|
+
info.can_code = info.cpu_cores >= 2 and info.ram_gb >= 4.0
|
|
227
|
+
|
|
228
|
+
# Web research: network-dependent; assume available, user can override.
|
|
229
|
+
info.can_research = True
|
|
230
|
+
|
|
231
|
+
# Image generation: need either enough available VRAM or enough RAM/CPU.
|
|
232
|
+
vram = info.vram_available_gb
|
|
233
|
+
if vram >= 4.0:
|
|
234
|
+
info.can_image = True
|
|
235
|
+
elif vram >= 2.0:
|
|
236
|
+
info.can_image = True # small models may fit
|
|
237
|
+
elif info.cpu_cores >= 6 and info.ram_gb >= 12.0:
|
|
238
|
+
info.can_image = True # CPU offloading possible (slow)
|
|
239
|
+
elif info.ram_gb >= 16.0:
|
|
240
|
+
info.can_image = True # CPU model (very slow)
|
|
241
|
+
|
|
242
|
+
# Video generation: requires >= 6 GB VRAM for practical local generation.
|
|
243
|
+
if vram >= 6.0:
|
|
244
|
+
info.can_video = True
|
|
245
|
+
|
|
246
|
+
# Collect human-readable notes about limitations.
|
|
247
|
+
if not info.can_image:
|
|
248
|
+
if info.gpu == "none":
|
|
249
|
+
info.notes.append(
|
|
250
|
+
"No GPU detected. Image generation is CPU-only (very slow) "
|
|
251
|
+
"or requires an external/free provider."
|
|
252
|
+
)
|
|
253
|
+
else:
|
|
254
|
+
info.notes.append(
|
|
255
|
+
f"GPU has {info.vram_available_gb:.1f} GB available VRAM "
|
|
256
|
+
f"(total: {info.vram_total_gb:.1f} GB). Local image "
|
|
257
|
+
"generation requires >= 2 GB free VRAM or a CPU with >= 12 GB RAM."
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
if not info.can_video:
|
|
261
|
+
if info.gpu == "none":
|
|
262
|
+
info.notes.append(
|
|
263
|
+
"No GPU detected. Video generation requires >= 6 GB VRAM. "
|
|
264
|
+
"Use a free external provider or expect CPU-only (impractical)."
|
|
265
|
+
)
|
|
266
|
+
elif info.vram_available_gb < 6.0:
|
|
267
|
+
info.notes.append(
|
|
268
|
+
f"GPU available VRAM ({info.vram_available_gb:.1f} GB) is below "
|
|
269
|
+
"the 6 GB threshold for local video generation."
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
return info
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def hardware_summary() -> str:
|
|
276
|
+
"""Return a short human-readable hardware summary."""
|
|
277
|
+
info = detect_hardware()
|
|
278
|
+
parts = [
|
|
279
|
+
f"OS: {info.os} {info.os_version} ({info.arch})",
|
|
280
|
+
f"CPU: {info.cpu_brand or 'unknown'} ({info.cpu_cores} cores)",
|
|
281
|
+
f"RAM: {info.ram_gb:.1f} GB",
|
|
282
|
+
f"Disk free: {info.disk_free_gb:.1f} GB",
|
|
283
|
+
f"GPU: {info.gpu}",
|
|
284
|
+
]
|
|
285
|
+
if info.vram_total_gb > 0:
|
|
286
|
+
parts.append(
|
|
287
|
+
f"VRAM: {info.vram_total_gb:.1f} GB total, "
|
|
288
|
+
f"{info.vram_available_gb:.1f} GB available"
|
|
289
|
+
)
|
|
290
|
+
return "\n".join(parts)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def summarize_hardware(hw: HardwareInfo) -> str:
|
|
294
|
+
"""Return a compact hardware summary string for a given HardwareInfo."""
|
|
295
|
+
if hw.gpu != "none" and hw.vram_total_gb > 0:
|
|
296
|
+
gpu_str = (
|
|
297
|
+
f"GPU: {hw.gpu} "
|
|
298
|
+
f"(total {hw.vram_total_gb:.1f} GB, "
|
|
299
|
+
f"available {hw.vram_available_gb:.1f} GB)"
|
|
300
|
+
)
|
|
301
|
+
elif hw.gpu != "none":
|
|
302
|
+
gpu_str = f"GPU: {hw.gpu} (VRAM: unknown)"
|
|
303
|
+
else:
|
|
304
|
+
gpu_str = "GPU: none"
|
|
305
|
+
|
|
306
|
+
caps = []
|
|
307
|
+
if hw.can_code:
|
|
308
|
+
caps.append("code")
|
|
309
|
+
if hw.can_image:
|
|
310
|
+
caps.append("image")
|
|
311
|
+
if hw.can_video:
|
|
312
|
+
caps.append("video")
|
|
313
|
+
cap_str = "capable: " + ", ".join(caps) if caps else "no local AI acceleration"
|
|
314
|
+
return f"{gpu_str}; {cap_str}"
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
if __name__ == "__main__":
|
|
318
|
+
print(hardware_summary())
|
|
319
|
+
print()
|
|
320
|
+
info = detect_hardware()
|
|
321
|
+
print("Capabilities:")
|
|
322
|
+
print(f" Code/reasoning : {'yes' if info.can_code else 'no'}")
|
|
323
|
+
print(f" Web research : {'yes' if info.can_research else 'no'}")
|
|
324
|
+
print(f" Image generation: {'yes' if info.can_image else 'no'}")
|
|
325
|
+
print(f" Video generation: {'yes' if info.can_video else 'no'}")
|
|
326
|
+
if info.can_image:
|
|
327
|
+
print(f" Image mode: {info.recommended_image_mode()}")
|
|
328
|
+
if info.can_video:
|
|
329
|
+
print(f" Video mode: {info.recommended_video_mode()}")
|
|
330
|
+
for n in info.notes:
|
|
331
|
+
print(f" note: {n}")
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Knowledge base for ElJay AI.
|
|
2
|
+
|
|
3
|
+
Supports two levels:
|
|
4
|
+
|
|
5
|
+
* **Global knowledge** — ``knowledge/`` in the ElJay installation directory.
|
|
6
|
+
Reusable across all projects.
|
|
7
|
+
* **Project knowledge** — ``.agent/knowledge/`` in the project workspace.
|
|
8
|
+
Project-specific facts the agent should remember.
|
|
9
|
+
|
|
10
|
+
Each entry is a ``.md`` file whose first line is a description. Knowledge
|
|
11
|
+
entries are *not* treated as guaranteed truth — they are user-provided
|
|
12
|
+
context that should be verified where possible.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class KnowledgeEntry:
|
|
24
|
+
"""One knowledge file."""
|
|
25
|
+
|
|
26
|
+
name: str
|
|
27
|
+
category: str
|
|
28
|
+
source: str
|
|
29
|
+
description: str
|
|
30
|
+
content: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class KnowledgeBase:
|
|
34
|
+
"""Read/search knowledge from global and project-local directories."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, project_root: str | os.PathLike, global_root: Optional[str | os.PathLike] = None):
|
|
37
|
+
self.project_root = str(project_root)
|
|
38
|
+
self.global_root = str(global_root) if global_root else None
|
|
39
|
+
|
|
40
|
+
def _scan_dir(self, directory: str) -> list[KnowledgeEntry]:
|
|
41
|
+
if not os.path.isdir(directory):
|
|
42
|
+
return []
|
|
43
|
+
found: list[KnowledgeEntry] = []
|
|
44
|
+
for category in sorted(os.listdir(directory)):
|
|
45
|
+
cat_dir = os.path.join(directory, category)
|
|
46
|
+
if not os.path.isdir(cat_dir):
|
|
47
|
+
continue
|
|
48
|
+
for fname in sorted(os.listdir(cat_dir)):
|
|
49
|
+
if not fname.endswith((".md", ".txt")):
|
|
50
|
+
continue
|
|
51
|
+
fpath = os.path.join(cat_dir, fname)
|
|
52
|
+
try:
|
|
53
|
+
content = open(fpath, encoding="utf-8").read()
|
|
54
|
+
except OSError:
|
|
55
|
+
continue
|
|
56
|
+
lines = content.splitlines()
|
|
57
|
+
desc = lines[0].lstrip("# ").strip() if lines else ""
|
|
58
|
+
found.append(
|
|
59
|
+
KnowledgeEntry(
|
|
60
|
+
name=fname.rsplit(".", 1)[0],
|
|
61
|
+
category=category,
|
|
62
|
+
source=fpath,
|
|
63
|
+
description=desc,
|
|
64
|
+
content=content,
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
return found
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def project_dir(self) -> str:
|
|
71
|
+
return os.path.join(self.project_root, ".agent", "knowledge")
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def global_dir(self) -> Optional[str]:
|
|
75
|
+
if self.global_root:
|
|
76
|
+
return os.path.join(self.global_root, "knowledge")
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
def entries(self) -> list[KnowledgeEntry]:
|
|
80
|
+
"""All entries (global + project), project takes precedence."""
|
|
81
|
+
result = []
|
|
82
|
+
seen = set()
|
|
83
|
+
if self.global_dir:
|
|
84
|
+
for e in self._scan_dir(self.global_dir):
|
|
85
|
+
result.append(e)
|
|
86
|
+
seen.add(e.name)
|
|
87
|
+
for e in self._scan_dir(self.project_dir):
|
|
88
|
+
result.append(e)
|
|
89
|
+
return result
|
|
90
|
+
|
|
91
|
+
def search(self, query: str) -> list[KnowledgeEntry]:
|
|
92
|
+
"""Search all knowledge entries for *query*."""
|
|
93
|
+
q = query.lower()
|
|
94
|
+
q_words = set(w.strip() for w in q.split())
|
|
95
|
+
results: list[tuple[KnowledgeEntry, int]] = []
|
|
96
|
+
for entry in self.entries():
|
|
97
|
+
score = 0
|
|
98
|
+
for word in q_words:
|
|
99
|
+
if word in entry.name.lower():
|
|
100
|
+
score += 3
|
|
101
|
+
if word in entry.category.lower():
|
|
102
|
+
score += 2
|
|
103
|
+
if word in entry.description.lower():
|
|
104
|
+
score += 1
|
|
105
|
+
if word in entry.content.lower():
|
|
106
|
+
score += 1
|
|
107
|
+
if score > 0:
|
|
108
|
+
results.append((entry, score))
|
|
109
|
+
results.sort(key=lambda x: -x[1])
|
|
110
|
+
return [e for e, _ in results]
|
|
111
|
+
|
|
112
|
+
def add_entry(self, category: str, name: str, description: str, content: str,
|
|
113
|
+
project_local: bool = True) -> str:
|
|
114
|
+
"""Add a new knowledge entry. Returns the file path written."""
|
|
115
|
+
if project_local:
|
|
116
|
+
target_dir = os.path.join(self.project_dir, category)
|
|
117
|
+
elif self.global_dir:
|
|
118
|
+
target_dir = os.path.join(self.global_dir, category)
|
|
119
|
+
else:
|
|
120
|
+
raise ValueError("No global knowledge directory configured.")
|
|
121
|
+
os.makedirs(target_dir, exist_ok=True)
|
|
122
|
+
fpath = os.path.join(target_dir, f"{name}.md")
|
|
123
|
+
header = f"# {description}\n\n{content}"
|
|
124
|
+
import datetime
|
|
125
|
+
header = f"# {description}\n\n<!-- {datetime.date.today().isoformat()} -->\n\n{content}"
|
|
126
|
+
with open(fpath, "w", encoding="utf-8") as f:
|
|
127
|
+
f.write(header)
|
|
128
|
+
return fpath
|
|
129
|
+
|
|
130
|
+
def summary(self) -> str:
|
|
131
|
+
entries = self.entries()
|
|
132
|
+
cats = set(e.category for e in entries)
|
|
133
|
+
return f"{len(entries)} entr(ies) in {len(cats)} categor(ies)" if entries else "empty"
|
package/agent/memory.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Lightweight local memory, stored under the project's `.agent/` folder.
|
|
2
|
+
|
|
3
|
+
Layout (gitignored — never part of the user's source):
|
|
4
|
+
.agent/config.json small agent settings
|
|
5
|
+
.agent/history.json the most recent conversation turns
|
|
6
|
+
.agent/context.json notes worth remembering between sessions
|
|
7
|
+
|
|
8
|
+
The user can inspect or clear all of it (`/memory`, `/memory clear`).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .workspace import Workspace
|
|
18
|
+
|
|
19
|
+
HISTORY_LIMIT = 100
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Memory:
|
|
23
|
+
def __init__(self, ws: Workspace) -> None:
|
|
24
|
+
self.dir = ws.root / ".agent"
|
|
25
|
+
self.config_path = self.dir / "config.json"
|
|
26
|
+
self.history_path = self.dir / "history.json"
|
|
27
|
+
self.context_path = self.dir / "context.json"
|
|
28
|
+
|
|
29
|
+
# -- helpers -----------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
def _ensure_dir(self) -> None:
|
|
32
|
+
self.dir.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
|
|
34
|
+
def _read(self, path: Path, default: Any) -> Any:
|
|
35
|
+
try:
|
|
36
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
37
|
+
except (OSError, json.JSONDecodeError, ValueError):
|
|
38
|
+
return default
|
|
39
|
+
|
|
40
|
+
# -- history -----------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
def save_history(self, messages: list[dict[str, Any]]) -> None:
|
|
43
|
+
try:
|
|
44
|
+
self._ensure_dir()
|
|
45
|
+
trimmed = messages[-HISTORY_LIMIT:]
|
|
46
|
+
self.history_path.write_text(
|
|
47
|
+
json.dumps(trimmed, ensure_ascii=False, indent=2), encoding="utf-8"
|
|
48
|
+
)
|
|
49
|
+
except OSError:
|
|
50
|
+
pass # memory is best-effort; never break the session over it
|
|
51
|
+
|
|
52
|
+
def load_history(self) -> list[dict[str, Any]]:
|
|
53
|
+
data = self._read(self.history_path, [])
|
|
54
|
+
return data if isinstance(data, list) else []
|
|
55
|
+
|
|
56
|
+
# -- notes -------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
def load_notes(self) -> list[str]:
|
|
59
|
+
data = self._read(self.context_path, {})
|
|
60
|
+
notes = data.get("notes", []) if isinstance(data, dict) else []
|
|
61
|
+
return [str(n) for n in notes] if isinstance(notes, list) else []
|
|
62
|
+
|
|
63
|
+
def add_note(self, text: str) -> None:
|
|
64
|
+
text = (text or "").strip()
|
|
65
|
+
if not text:
|
|
66
|
+
return
|
|
67
|
+
notes = self.load_notes()
|
|
68
|
+
notes.append(text)
|
|
69
|
+
try:
|
|
70
|
+
self._ensure_dir()
|
|
71
|
+
self.context_path.write_text(
|
|
72
|
+
json.dumps({"notes": notes}, ensure_ascii=False, indent=2),
|
|
73
|
+
encoding="utf-8",
|
|
74
|
+
)
|
|
75
|
+
except OSError:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
def clear(self) -> None:
|
|
79
|
+
for path in (self.history_path, self.context_path):
|
|
80
|
+
try:
|
|
81
|
+
path.unlink()
|
|
82
|
+
except OSError:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
# -- reporting ---------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def summary(self) -> str:
|
|
88
|
+
history = self.load_history()
|
|
89
|
+
notes = self.load_notes()
|
|
90
|
+
lines = [f"Memory folder: {self.dir}"]
|
|
91
|
+
lines.append(f" history: {len(history)} message(s) -> {self.history_path.name}")
|
|
92
|
+
lines.append(f" notes: {len(notes)} -> {self.context_path.name}")
|
|
93
|
+
if notes:
|
|
94
|
+
lines.append(" remembered notes:")
|
|
95
|
+
for note in notes[-10:]:
|
|
96
|
+
lines.append(f" - {note}")
|
|
97
|
+
lines.append("Use /memory clear to erase all local memory.")
|
|
98
|
+
return "\n".join(lines)
|