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
@@ -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"
@@ -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)