ghostcode-canary 0.1.25-canary.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/LICENSE +201 -0
- package/PAT.txt +1 -0
- package/README.md +95 -0
- package/cli.js +92 -0
- package/error/err1.txt +12 -0
- package/package.json +31 -0
- package/setup.js +67 -0
- package/src/ghostcode/__init__.py +3 -0
- package/src/ghostcode/__main__.py +3 -0
- package/src/ghostcode/_version.py +17 -0
- package/src/ghostcode/agents/__init__.py +20 -0
- package/src/ghostcode/agents/code.py +104 -0
- package/src/ghostcode/agents/explore.py +50 -0
- package/src/ghostcode/ai/__init__.py +0 -0
- package/src/ghostcode/ai/anthropic_client.py +8 -0
- package/src/ghostcode/ai/base.py +33 -0
- package/src/ghostcode/ai/factory.py +38 -0
- package/src/ghostcode/ai/groq_client.py +7 -0
- package/src/ghostcode/ai/nvidia_client.py +7 -0
- package/src/ghostcode/ai/ollama_client.py +7 -0
- package/src/ghostcode/ai/openai_client.py +7 -0
- package/src/ghostcode/ai/openai_compat.py +139 -0
- package/src/ghostcode/ai/opencode_go_client.py +7 -0
- package/src/ghostcode/ai/opencode_zen_client.py +7 -0
- package/src/ghostcode/ai/openrouter_client.py +7 -0
- package/src/ghostcode/ai/registry.py +169 -0
- package/src/ghostcode/app.py +148 -0
- package/src/ghostcode/config.py +24 -0
- package/src/ghostcode/core/__init__.py +5 -0
- package/src/ghostcode/core/commands.py +55 -0
- package/src/ghostcode/core/file_ops.py +127 -0
- package/src/ghostcode/core/hooks.py +122 -0
- package/src/ghostcode/core/memory.py +137 -0
- package/src/ghostcode/core/prompt.py +73 -0
- package/src/ghostcode/core/reminders.py +61 -0
- package/src/ghostcode/core/security.py +119 -0
- package/src/ghostcode/core/tasks.py +125 -0
- package/src/ghostcode/tools/__init__.py +2 -0
- package/src/ghostcode/tools/defs.py +325 -0
- package/src/ghostcode/tools/executor.py +261 -0
- package/src/ghostcode/tui/__init__.py +0 -0
- package/src/ghostcode/tui/app.py +12 -0
- package/src/ghostcode/tui/dialogs.py +241 -0
- package/src/ghostcode/tui/screens.py +799 -0
- package/src/ghostcode/tui/widgets.py +32 -0
- package/src/ghostcode/utils/__init__.py +0 -0
- package/src/ghostcode/utils/config_loader.py +102 -0
- package/src/ghostcode/utils/logger.py +16 -0
- package/src/pyproject.toml +18 -0
- package/token.txt +1 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
from collections.abc import Callable, Coroutine
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclasses.dataclass
|
|
7
|
+
class Command:
|
|
8
|
+
name: str
|
|
9
|
+
description: str
|
|
10
|
+
handler: Callable[..., Coroutine[Any, Any, str | None]]
|
|
11
|
+
usage: str = ""
|
|
12
|
+
aliases: list[str] | None = None
|
|
13
|
+
category: str = "general"
|
|
14
|
+
|
|
15
|
+
def __post_init__(self):
|
|
16
|
+
if not self.usage:
|
|
17
|
+
self.usage = f"/{self.name}"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_command_registry: dict[str, Command] = {}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def register(cmd: Command):
|
|
24
|
+
_command_registry[cmd.name] = cmd
|
|
25
|
+
for alias in (cmd.aliases or []):
|
|
26
|
+
_command_registry[alias] = cmd
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_command(name: str) -> Command | None:
|
|
30
|
+
return _command_registry.get(name.lstrip("/").lower().split()[0])
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def list_commands(category: str | None = None) -> list[Command]:
|
|
34
|
+
cmds = list(_command_registry.values())
|
|
35
|
+
if category:
|
|
36
|
+
cmds = [c for c in cmds if c.category == category]
|
|
37
|
+
seen = {}
|
|
38
|
+
for c in cmds:
|
|
39
|
+
if c.name not in seen:
|
|
40
|
+
seen[c.name] = c
|
|
41
|
+
return list(seen.values())
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def all_commands() -> dict[str, Command]:
|
|
45
|
+
deduped = {}
|
|
46
|
+
for name, cmd in _command_registry.items():
|
|
47
|
+
deduped[name] = cmd
|
|
48
|
+
return deduped
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def dispatch(cmd_name: str, args: list[str], ctx: dict | None = None) -> str | None:
|
|
52
|
+
cmd = get_command(cmd_name)
|
|
53
|
+
if not cmd:
|
|
54
|
+
return None
|
|
55
|
+
return await cmd.handler(args, ctx or {})
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
OUTPUT_DIR = "ghostcode_gen"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _detect_name(code: str, lang: str) -> str:
|
|
8
|
+
for pat in [
|
|
9
|
+
r"^(?:def|class|async def)\s+(\w+)",
|
|
10
|
+
r"(?:def|class)\s+(\w+)",
|
|
11
|
+
r"(?:export\s+)?(?:function|const)\s+(\w+)",
|
|
12
|
+
r"^(?:fn|fun|func)\s+(\w+)",
|
|
13
|
+
r"^(?:public\s+)?(?:class|interface|trait)\s+(\w+)",
|
|
14
|
+
]:
|
|
15
|
+
m = re.search(pat, code, re.MULTILINE)
|
|
16
|
+
if m:
|
|
17
|
+
return m.group(1)
|
|
18
|
+
return None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_EXT_MAP = {
|
|
22
|
+
"python": "py", "py": "py",
|
|
23
|
+
"javascript": "js", "js": "js", "node": "js",
|
|
24
|
+
"typescript": "ts", "ts": "ts",
|
|
25
|
+
"jsx": "jsx", "tsx": "tsx",
|
|
26
|
+
"go": "go", "golang": "go",
|
|
27
|
+
"rust": "rs", "rs": "rs",
|
|
28
|
+
"ruby": "rb", "rb": "rb",
|
|
29
|
+
"java": "java",
|
|
30
|
+
"kotlin": "kt", "kt": "kt",
|
|
31
|
+
"c": "c", "cpp": "cpp", "c++": "cpp",
|
|
32
|
+
"h": "h", "hpp": "hpp",
|
|
33
|
+
"html": "html", "htm": "html",
|
|
34
|
+
"css": "css", "scss": "scss", "sass": "scss",
|
|
35
|
+
"bash": "sh", "sh": "sh", "shell": "sh", "zsh": "sh",
|
|
36
|
+
"sql": "sql",
|
|
37
|
+
"yaml": "yaml", "yml": "yaml",
|
|
38
|
+
"json": "json",
|
|
39
|
+
"toml": "toml",
|
|
40
|
+
"markdown": "md", "md": "md",
|
|
41
|
+
"dockerfile": "Dockerfile",
|
|
42
|
+
"makefile": "Makefile",
|
|
43
|
+
"swift": "swift",
|
|
44
|
+
"php": "php",
|
|
45
|
+
"r": "r",
|
|
46
|
+
"lua": "lua",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _ext_for_lang(lang: str) -> str:
|
|
51
|
+
lang = lang.lower().strip()
|
|
52
|
+
return _EXT_MAP.get(lang, lang)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_counter = 0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _next_fallback(lang: str) -> str:
|
|
59
|
+
global _counter
|
|
60
|
+
_counter += 1
|
|
61
|
+
ext = _ext_for_lang(lang)
|
|
62
|
+
return f"code_{_counter}.{ext}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def autosave_code_blocks(content: str) -> list[dict]:
|
|
66
|
+
blocks = extract_code_blocks(content)
|
|
67
|
+
saved = []
|
|
68
|
+
out_dir = Path(OUTPUT_DIR)
|
|
69
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
|
|
71
|
+
for i, (lang, code) in enumerate(blocks):
|
|
72
|
+
name = _detect_name(code, lang)
|
|
73
|
+
if name:
|
|
74
|
+
ext = _ext_for_lang(lang)
|
|
75
|
+
filename = f"{name}.{ext}" if ext != lang else name
|
|
76
|
+
else:
|
|
77
|
+
filename = _next_fallback(lang)
|
|
78
|
+
|
|
79
|
+
path = out_dir / filename
|
|
80
|
+
|
|
81
|
+
if path.exists():
|
|
82
|
+
stem = path.stem
|
|
83
|
+
path = out_dir / f"{stem}_{i+1}{path.suffix}"
|
|
84
|
+
|
|
85
|
+
path.write_text(code, encoding="utf-8")
|
|
86
|
+
saved.append({
|
|
87
|
+
"path": str(path),
|
|
88
|
+
"size": len(code),
|
|
89
|
+
"language": lang,
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
return saved
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def safe_write(path: str, content: str, backup: bool = True) -> dict:
|
|
96
|
+
p = Path(path).expanduser().resolve()
|
|
97
|
+
if p.exists() and backup:
|
|
98
|
+
bak = p.with_suffix(p.suffix + ".bak")
|
|
99
|
+
import shutil
|
|
100
|
+
shutil.copy2(str(p), str(bak))
|
|
101
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
p.write_text(content, encoding="utf-8")
|
|
103
|
+
return {"path": str(p), "size": len(content)}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def read_file(path: str) -> str | None:
|
|
107
|
+
p = Path(path).expanduser().resolve()
|
|
108
|
+
if not p.exists():
|
|
109
|
+
return None
|
|
110
|
+
return p.read_text(encoding="utf-8")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def extract_code_blocks(text: str) -> list[tuple[str, str]]:
|
|
114
|
+
blocks = []
|
|
115
|
+
lines = text.splitlines()
|
|
116
|
+
i = 0
|
|
117
|
+
while i < len(lines):
|
|
118
|
+
if lines[i].strip().startswith("```"):
|
|
119
|
+
lang = lines[i].strip().removeprefix("```").strip()
|
|
120
|
+
code = []
|
|
121
|
+
i += 1
|
|
122
|
+
while i < len(lines) and not lines[i].strip().startswith("```"):
|
|
123
|
+
code.append(lines[i])
|
|
124
|
+
i += 1
|
|
125
|
+
blocks.append((lang, "\n".join(code)))
|
|
126
|
+
i += 1
|
|
127
|
+
return blocks
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
HOOKS_FILE = ".ghostcode/hooks.json"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class HookRegistry:
|
|
11
|
+
def __init__(self):
|
|
12
|
+
self._before: list[dict] = []
|
|
13
|
+
self._after: list[dict] = []
|
|
14
|
+
self._stop: dict | None = None
|
|
15
|
+
self._load()
|
|
16
|
+
|
|
17
|
+
def _load(self):
|
|
18
|
+
p = Path.cwd() / HOOKS_FILE
|
|
19
|
+
if not p.exists():
|
|
20
|
+
return
|
|
21
|
+
try:
|
|
22
|
+
data = json.loads(p.read_text())
|
|
23
|
+
self._before = data.get("before_tool", [])
|
|
24
|
+
self._after = data.get("after_tool", [])
|
|
25
|
+
self._stop = data.get("stop_hook", None)
|
|
26
|
+
except (json.JSONDecodeError, Exception):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
def _save(self):
|
|
30
|
+
p = Path.cwd() / HOOKS_FILE
|
|
31
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
p.write_text(json.dumps({
|
|
33
|
+
"before_tool": self._before,
|
|
34
|
+
"after_tool": self._after,
|
|
35
|
+
"stop_hook": self._stop,
|
|
36
|
+
}, indent=2))
|
|
37
|
+
|
|
38
|
+
def set_hook(self, hook_type: str, config: dict):
|
|
39
|
+
if hook_type == "before_tool":
|
|
40
|
+
self._before.append(config)
|
|
41
|
+
elif hook_type == "after_tool":
|
|
42
|
+
self._after.append(config)
|
|
43
|
+
elif hook_type == "stop_hook":
|
|
44
|
+
self._stop = config
|
|
45
|
+
self._save()
|
|
46
|
+
|
|
47
|
+
def remove_hook(self, hook_type: str, index: int = 0) -> bool:
|
|
48
|
+
if hook_type == "before_tool" and 0 <= index < len(self._before):
|
|
49
|
+
self._before.pop(index)
|
|
50
|
+
self._save()
|
|
51
|
+
return True
|
|
52
|
+
if hook_type == "after_tool" and 0 <= index < len(self._after):
|
|
53
|
+
self._after.pop(index)
|
|
54
|
+
self._save()
|
|
55
|
+
return True
|
|
56
|
+
if hook_type == "stop_hook" and self._stop:
|
|
57
|
+
self._stop = None
|
|
58
|
+
self._save()
|
|
59
|
+
return True
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
def get_hooks(self, hook_type: str) -> list[dict]:
|
|
63
|
+
if hook_type == "before_tool":
|
|
64
|
+
return self._before
|
|
65
|
+
if hook_type == "after_tool":
|
|
66
|
+
return self._after
|
|
67
|
+
if hook_type == "stop_hook":
|
|
68
|
+
return [self._stop] if self._stop else []
|
|
69
|
+
return []
|
|
70
|
+
|
|
71
|
+
def run_before(self, tool_name: str, args: dict) -> tuple[bool, str]:
|
|
72
|
+
for hook in self._before:
|
|
73
|
+
condition = hook.get("condition", "")
|
|
74
|
+
if condition and condition not in json.dumps({"tool": tool_name, "args": args}):
|
|
75
|
+
continue
|
|
76
|
+
command = hook.get("command", "")
|
|
77
|
+
if command:
|
|
78
|
+
try:
|
|
79
|
+
result = subprocess.run(command, shell=True, capture_output=True, text=True, timeout=10)
|
|
80
|
+
if result.returncode != 0:
|
|
81
|
+
return False, result.stderr[:200]
|
|
82
|
+
except subprocess.TimeoutExpired:
|
|
83
|
+
return False, "Hook timed out"
|
|
84
|
+
except Exception as e:
|
|
85
|
+
return False, str(e)
|
|
86
|
+
return True, ""
|
|
87
|
+
|
|
88
|
+
def run_after(self, tool_name: str, args: dict, result: str) -> str:
|
|
89
|
+
for hook in self._after:
|
|
90
|
+
condition = hook.get("condition", "")
|
|
91
|
+
if condition and condition not in json.dumps({"tool": tool_name, "args": args}):
|
|
92
|
+
continue
|
|
93
|
+
command = hook.get("command", "")
|
|
94
|
+
if command:
|
|
95
|
+
try:
|
|
96
|
+
subprocess.run(command, shell=True, capture_output=True, text=True, timeout=10)
|
|
97
|
+
except Exception:
|
|
98
|
+
pass
|
|
99
|
+
return result
|
|
100
|
+
|
|
101
|
+
def check_stop(self) -> str | None:
|
|
102
|
+
if not self._stop:
|
|
103
|
+
return None
|
|
104
|
+
command = self._stop.get("command", "")
|
|
105
|
+
if command:
|
|
106
|
+
try:
|
|
107
|
+
result = subprocess.run(command, shell=True, capture_output=True, text=True, timeout=5)
|
|
108
|
+
if result.returncode == 0:
|
|
109
|
+
return self._stop.get("message", "Stop hook triggered")
|
|
110
|
+
except Exception:
|
|
111
|
+
pass
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
def summary(self) -> dict:
|
|
115
|
+
return {
|
|
116
|
+
"before_tool": len(self._before),
|
|
117
|
+
"after_tool": len(self._after),
|
|
118
|
+
"stop_hook": 1 if self._stop else 0,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
hook_registry = HookRegistry()
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import yaml
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
MEMORY_DIR = ".ghostcode/memory"
|
|
7
|
+
MEMORY_TYPES = ("user", "feedback", "project", "reference")
|
|
8
|
+
|
|
9
|
+
_frontmatter_re = re.compile(r"^---\s*\n(.*?)\n---\s*\n?(.*)", re.DOTALL)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _memories_dir() -> Path:
|
|
13
|
+
return Path.cwd() / MEMORY_DIR
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _memory_path(name: str) -> Path:
|
|
17
|
+
return _memories_dir() / f"{name}.md"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _list_memory_files() -> list[Path]:
|
|
21
|
+
d = _memories_dir()
|
|
22
|
+
if not d.exists():
|
|
23
|
+
return []
|
|
24
|
+
return sorted(d.glob("*.md"))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_memory(name: str) -> dict | None:
|
|
28
|
+
p = _memory_path(name)
|
|
29
|
+
if not p.exists():
|
|
30
|
+
return None
|
|
31
|
+
raw = p.read_text(encoding="utf-8")
|
|
32
|
+
m = _frontmatter_re.match(raw)
|
|
33
|
+
if not m:
|
|
34
|
+
return {"name": name, "content": raw.strip(), "metadata": {}}
|
|
35
|
+
metadata = yaml.safe_load(m.group(1)) or {}
|
|
36
|
+
content = m.group(2).strip()
|
|
37
|
+
return {"name": metadata.get("name", name), "description": metadata.get("description", ""), "metadata": metadata.get("metadata", {}), "content": content}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def search_memory(query: str) -> list[dict]:
|
|
41
|
+
q = query.lower()
|
|
42
|
+
results = []
|
|
43
|
+
for fp in _list_memory_files():
|
|
44
|
+
entry = load_memory(fp.stem)
|
|
45
|
+
if not entry:
|
|
46
|
+
continue
|
|
47
|
+
text = f"{entry.get('name', '')} {entry.get('description', '')} {entry.get('content', '')}".lower()
|
|
48
|
+
if q in text:
|
|
49
|
+
results.append(entry)
|
|
50
|
+
return results
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def save_memory(name: str, content: str, metadata: dict | None = None) -> dict:
|
|
54
|
+
_memories_dir().mkdir(parents=True, exist_ok=True)
|
|
55
|
+
safe_name = name.lower().replace(" ", "-")
|
|
56
|
+
safe_name = "".join(c for c in safe_name if c.isalnum() or c in "-_")
|
|
57
|
+
if not safe_name:
|
|
58
|
+
safe_name = "memory"
|
|
59
|
+
|
|
60
|
+
slug = safe_name
|
|
61
|
+
path = _memory_path(slug)
|
|
62
|
+
if path.exists():
|
|
63
|
+
for i in range(1, 100):
|
|
64
|
+
alt = f"{slug}-{i}"
|
|
65
|
+
p = _memory_path(alt)
|
|
66
|
+
if not p.exists():
|
|
67
|
+
slug, path = alt, p
|
|
68
|
+
break
|
|
69
|
+
|
|
70
|
+
meta = metadata or {}
|
|
71
|
+
mtype = meta.get("type", "reference")
|
|
72
|
+
if mtype not in MEMORY_TYPES:
|
|
73
|
+
mtype = "reference"
|
|
74
|
+
|
|
75
|
+
desc = meta.get("description", content[:80] if len(content) > 80 else content)
|
|
76
|
+
pinned = meta.get("pinned", False)
|
|
77
|
+
|
|
78
|
+
frontmatter = {
|
|
79
|
+
"name": slug,
|
|
80
|
+
"description": desc,
|
|
81
|
+
"metadata": {"type": mtype},
|
|
82
|
+
}
|
|
83
|
+
if pinned:
|
|
84
|
+
frontmatter["metadata"]["pinned"] = True
|
|
85
|
+
|
|
86
|
+
raw = f"---\n{yaml.dump(frontmatter, default_flow_style=False).strip()}\n---\n\n{content}\n"
|
|
87
|
+
path.write_text(raw, encoding="utf-8")
|
|
88
|
+
return {"path": str(path), "slug": slug, "type": mtype, "bytes": len(raw)}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def delete_memory(name: str) -> bool:
|
|
92
|
+
p = _memory_path(name)
|
|
93
|
+
if p.exists():
|
|
94
|
+
p.unlink()
|
|
95
|
+
return True
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def list_memories(mtype: str | None = None) -> list[dict]:
|
|
100
|
+
results = []
|
|
101
|
+
for fp in _list_memory_files():
|
|
102
|
+
entry = load_memory(fp.stem)
|
|
103
|
+
if not entry:
|
|
104
|
+
continue
|
|
105
|
+
if mtype and entry.get("metadata", {}).get("type") != mtype:
|
|
106
|
+
continue
|
|
107
|
+
results.append(entry)
|
|
108
|
+
return results
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def update_memory(name: str, content: str | None = None, metadata: dict | None = None) -> dict | None:
|
|
112
|
+
existing = load_memory(name)
|
|
113
|
+
if not existing:
|
|
114
|
+
return None
|
|
115
|
+
new_meta = existing.get("metadata", {})
|
|
116
|
+
if metadata:
|
|
117
|
+
mtype = metadata.get("type")
|
|
118
|
+
if mtype and mtype in MEMORY_TYPES:
|
|
119
|
+
new_meta["type"] = mtype
|
|
120
|
+
pinned = metadata.get("pinned")
|
|
121
|
+
if pinned is not None:
|
|
122
|
+
new_meta["pinned"] = pinned
|
|
123
|
+
save_memory(name, content or existing["content"], {"type": new_meta.get("type", "reference"), "description": existing.get("description", ""), "pinned": new_meta.get("pinned", False)})
|
|
124
|
+
return load_memory(name)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def count_memories() -> int:
|
|
128
|
+
return len(_list_memory_files())
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def memory_summary() -> dict:
|
|
132
|
+
memories = list_memories()
|
|
133
|
+
by_type: dict[str, int] = {}
|
|
134
|
+
for m in memories:
|
|
135
|
+
t = m.get("metadata", {}).get("type", "unknown")
|
|
136
|
+
by_type[t] = by_type.get(t, 0) + 1
|
|
137
|
+
return {"total": len(memories), "by_type": by_type}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
from ghostcode.agents import get_system_prompt
|
|
4
|
+
from ghostcode.core.reminders import build_reminders, format_reminders
|
|
5
|
+
|
|
6
|
+
SYSTEM_PROMPT = get_system_prompt("code")
|
|
7
|
+
|
|
8
|
+
_GREETINGS = re.compile(
|
|
9
|
+
r"^(hi|hello|hey|yo|sup|hola|hai|hallo|heyy?|helloo?|good\s* morning|good\s* evening|what'?s up|howdy|hey\s+there)\s*[.!]*\s*$",
|
|
10
|
+
re.IGNORECASE,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
_THANK_YOUS = re.compile(
|
|
14
|
+
r"^(thanks|thank\s*you|ty|thx|tysm|thank\s*you\s*so\s*much|cheers|appreciate\s*it)\s*[.!]*\s*$",
|
|
15
|
+
re.IGNORECASE,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
_AGREEMENTS = re.compile(
|
|
19
|
+
r"^(ok|okay|k|got it|i see|understood|sure|yes|yeah|yep|alright|fine|makes sense)\s*[.!]*\s*$",
|
|
20
|
+
re.IGNORECASE,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
_WHO_MADE = re.compile(
|
|
24
|
+
r"^(who\s+)?((made|created|built|develop(ed)?|program(med)?|wrote|own(s)?)\s+(you|ghostcode)|(is\s+your\s+(creator|author|maker)))(\?)?\s*$",
|
|
25
|
+
re.IGNORECASE,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def detect_greeting(text: str) -> str | None:
|
|
30
|
+
t = text.strip().lower()
|
|
31
|
+
if _WHO_MADE.match(t):
|
|
32
|
+
return "GhostCode was built by [bold]TheDarkGhost Team[/bold]. The underlying LLM is by its respective provider (e.g., Meta for Llama, Mistral AI for Mistral, etc.)."
|
|
33
|
+
if _GREETINGS.match(t):
|
|
34
|
+
return "hi"
|
|
35
|
+
if _THANK_YOUS.match(t):
|
|
36
|
+
return "np"
|
|
37
|
+
if _AGREEMENTS.match(t):
|
|
38
|
+
return "👍"
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def build_system_content(agent: str = "code", **context) -> str:
|
|
43
|
+
prompt = get_system_prompt(agent)
|
|
44
|
+
reminders = build_reminders(**context)
|
|
45
|
+
if reminders:
|
|
46
|
+
sep = "\n\n" if agent == "code" else "\n"
|
|
47
|
+
return prompt + sep + format_reminders(reminders)
|
|
48
|
+
return prompt
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_messages(
|
|
52
|
+
history: list[dict],
|
|
53
|
+
user_prompt: str,
|
|
54
|
+
agent: str = "code",
|
|
55
|
+
plan_mode: bool = False,
|
|
56
|
+
file_state: dict | None = None,
|
|
57
|
+
hook_state: dict | None = None,
|
|
58
|
+
memory_count: int = 0,
|
|
59
|
+
task_count: int = 0,
|
|
60
|
+
) -> list[dict]:
|
|
61
|
+
content = build_system_content(
|
|
62
|
+
agent=agent,
|
|
63
|
+
plan_mode=plan_mode,
|
|
64
|
+
file_state=file_state,
|
|
65
|
+
hook_state=hook_state,
|
|
66
|
+
memory_count=memory_count,
|
|
67
|
+
task_count=task_count,
|
|
68
|
+
)
|
|
69
|
+
messages = [{"role": "system", "content": content}]
|
|
70
|
+
for entry in history:
|
|
71
|
+
messages.append({"role": entry["role"], "content": entry["content"]})
|
|
72
|
+
messages.append({"role": "user", "content": user_prompt})
|
|
73
|
+
return messages
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
SYSTEM_REMINDERS = {
|
|
6
|
+
"file_empty": "<system-reminder>Warning: the file exists but the contents are empty.</system-reminder>",
|
|
7
|
+
"file_truncated": "Note: The file was too large and has been truncated. Use read_file to read more of the file if you need.",
|
|
8
|
+
"file_range_exceeded": "<system-reminder>Warning: the file exists but is shorter than the provided offset.</system-reminder>",
|
|
9
|
+
"exited_plan_mode": "## Exited Plan Mode\n\nYou have exited plan mode. You can now make edits, run tools, and take actions.",
|
|
10
|
+
"session_continued": "This session is being continued from another machine. Application state may have changed.",
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def reminder(key: str, **kwargs) -> str:
|
|
15
|
+
tmpl = SYSTEM_REMINDERS.get(key)
|
|
16
|
+
if not tmpl:
|
|
17
|
+
return ""
|
|
18
|
+
if kwargs:
|
|
19
|
+
return tmpl.format(**kwargs)
|
|
20
|
+
return tmpl
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_reminders(
|
|
24
|
+
plan_mode: bool = False,
|
|
25
|
+
file_state: dict | None = None,
|
|
26
|
+
hook_state: dict | None = None,
|
|
27
|
+
memory_count: int = 0,
|
|
28
|
+
task_count: int = 0,
|
|
29
|
+
) -> list[str]:
|
|
30
|
+
result = []
|
|
31
|
+
if plan_mode:
|
|
32
|
+
result.append(
|
|
33
|
+
"## Plan mode is active\n\n"
|
|
34
|
+
"You are in read-only plan mode. DO NOT write or edit any files. "
|
|
35
|
+
"Explore the codebase, understand patterns, and design an implementation strategy. "
|
|
36
|
+
"When ready, describe your plan to the user."
|
|
37
|
+
)
|
|
38
|
+
if file_state:
|
|
39
|
+
if file_state.get("empty"):
|
|
40
|
+
result.append(reminder("file_empty"))
|
|
41
|
+
if file_state.get("truncated"):
|
|
42
|
+
result.append(reminder("file_truncated"))
|
|
43
|
+
if file_state.get("range_exceeded"):
|
|
44
|
+
result.append(reminder("file_range_exceeded"))
|
|
45
|
+
if file_state.get("modified_by_user"):
|
|
46
|
+
result.append(
|
|
47
|
+
"Note: A file was modified externally (by user or linter). "
|
|
48
|
+
"This change was intentional — don't revert it unless asked."
|
|
49
|
+
)
|
|
50
|
+
if hook_state:
|
|
51
|
+
if hook_state.get("blocking_error"):
|
|
52
|
+
result.append(f"Hook blocking error: {hook_state['blocking_error']}")
|
|
53
|
+
if hook_state.get("success"):
|
|
54
|
+
result.append(f"Hook success: {hook_state['success']}")
|
|
55
|
+
return result
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def format_reminders(reminders_list: list[str]) -> str:
|
|
59
|
+
if not reminders_list:
|
|
60
|
+
return ""
|
|
61
|
+
return "\n\n".join(reminders_list)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
DENY_FILE = ".ghostcode/deny.json"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _load_deny_rules() -> list[dict]:
|
|
10
|
+
import json
|
|
11
|
+
p = Path.cwd() / DENY_FILE
|
|
12
|
+
if not p.exists():
|
|
13
|
+
return []
|
|
14
|
+
try:
|
|
15
|
+
return json.loads(p.read_text())
|
|
16
|
+
except (json.JSONDecodeError, Exception):
|
|
17
|
+
return []
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _save_deny_rules(rules: list[dict]):
|
|
21
|
+
import json
|
|
22
|
+
p = Path.cwd() / DENY_FILE
|
|
23
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
p.write_text(json.dumps(rules, indent=2))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def is_action_denied(tool_name: str, args: dict) -> tuple[bool, str]:
|
|
28
|
+
rules = _load_deny_rules()
|
|
29
|
+
for rule in rules:
|
|
30
|
+
pattern = rule.get("pattern", "")
|
|
31
|
+
if not pattern:
|
|
32
|
+
continue
|
|
33
|
+
if tool_name == "run_command":
|
|
34
|
+
cmd = args.get("command", "")
|
|
35
|
+
if re.search(pattern, cmd):
|
|
36
|
+
return True, rule.get("reason", "denied by pattern")
|
|
37
|
+
elif tool_name in ("write_file", "edit_file", "read_file", "list_files"):
|
|
38
|
+
path = args.get("file_path", "") or args.get("path", "")
|
|
39
|
+
if re.search(pattern, path):
|
|
40
|
+
return True, rule.get("reason", "denied by pattern")
|
|
41
|
+
elif tool_name == "grep_search":
|
|
42
|
+
pat = args.get("pattern", "")
|
|
43
|
+
inc = args.get("include", "")
|
|
44
|
+
combined = f"{pat} {inc}"
|
|
45
|
+
if re.search(pattern, combined):
|
|
46
|
+
return True, rule.get("reason", "denied by pattern")
|
|
47
|
+
return False, ""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def add_deny_rule(pattern: str, reason: str = ""):
|
|
51
|
+
rules = _load_deny_rules()
|
|
52
|
+
rules.append({"pattern": pattern, "reason": reason})
|
|
53
|
+
_save_deny_rules(rules)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def remove_deny_rule(pattern: str) -> bool:
|
|
57
|
+
rules = _load_deny_rules()
|
|
58
|
+
new_rules = [r for r in rules if r.get("pattern") != pattern]
|
|
59
|
+
if len(new_rules) == len(rules):
|
|
60
|
+
return False
|
|
61
|
+
_save_deny_rules(new_rules)
|
|
62
|
+
return True
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def list_deny_rules() -> list[dict]:
|
|
66
|
+
return _load_deny_rules()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
DESTRUCTIVE_TOOLS = {"write_file", "edit_file", "run_command"}
|
|
70
|
+
OUTWARD_FACING_PATTERNS = [r"git\s+push", r"gh\s+pr", r"git\s+commit", r"npm\s+publish", r"pip(\s+|.*\s+)publish", r"kubectl\s+(apply|delete|create)", r"aws\s+"]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def is_reversible(args: dict) -> bool:
|
|
74
|
+
return True
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def needs_confirmation(tool_name: str, args: dict) -> tuple[bool, str]:
|
|
78
|
+
if tool_name in ("write_file", "edit_file"):
|
|
79
|
+
path = args.get("file_path", "")
|
|
80
|
+
p = Path(path)
|
|
81
|
+
if p.exists():
|
|
82
|
+
return True, f"File already exists: {path}"
|
|
83
|
+
if tool_name == "run_command":
|
|
84
|
+
cmd = args.get("command", "")
|
|
85
|
+
if any(re.search(pat, cmd) for pat in OUTWARD_FACING_PATTERNS):
|
|
86
|
+
return True, f"Command may affect external systems: {cmd[:80]}"
|
|
87
|
+
destructive_words = ["rm ", "rm -rf", "rmdir", "del ", "rd ", "format ", "mkfs", "dd "]
|
|
88
|
+
if any(w in cmd for w in destructive_words):
|
|
89
|
+
return True, f"Destructive command: {cmd[:80]}"
|
|
90
|
+
if tool_name in ("delete_memory",):
|
|
91
|
+
return True, f"Deleting memory: {args.get('name', '')}"
|
|
92
|
+
return False, ""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
SECRET_PATTERNS = [
|
|
96
|
+
r"(?:api[_-]?key|apikey|secret|token|password|passwd|credential)[\s\"'=:]+[A-Za-z0-9_\-\.]{16,}",
|
|
97
|
+
r"sk-[A-Za-z0-9]{32,}",
|
|
98
|
+
r"ghp_[A-Za-z0-9]{36}",
|
|
99
|
+
r"gho_[A-Za-z0-9]{36}",
|
|
100
|
+
r"ghu_[A-Za-z0-9]{36}",
|
|
101
|
+
r"xox[bpras]-[A-Za-z0-9\-]{10,}",
|
|
102
|
+
r"AKIA[0-9A-Z]{16}",
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def scan_for_secrets(content: str) -> list[str]:
|
|
107
|
+
findings = []
|
|
108
|
+
for pat in SECRET_PATTERNS:
|
|
109
|
+
matches = re.findall(pat, content)
|
|
110
|
+
for m in matches:
|
|
111
|
+
if m not in findings:
|
|
112
|
+
findings.append(m)
|
|
113
|
+
return findings
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def redact_secrets(content: str) -> str:
|
|
117
|
+
for pat in SECRET_PATTERNS:
|
|
118
|
+
content = re.sub(pat, lambda m: m.group(0)[:4] + "..." + m.group(0)[-4:], content)
|
|
119
|
+
return content
|